From f43c9eef603f5fff6d6e758a2daa0c65cbec7cea Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Jan 2025 06:23:41 +0000 Subject: [PATCH 001/791] Seed CLI application for JavaScript SDK --- .gitignore | 1 + js/sdk/.eslintrc.js | 23 + js/sdk/jest.config.js | 11 + js/sdk/jest.transform.js | 4 + js/sdk/package-lock.json | 10704 +++++++ js/sdk/package.json | 35 + js/sdk/src/cache/index.ts | 2 + js/sdk/src/cache/interface.ts | 113 + js/sdk/src/cache/memoryCache.test.ts | 120 + js/sdk/src/cache/memoryCache.ts | 94 + js/sdk/src/config.ts | 10 + js/sdk/src/crypto/driveCrypto.ts | 185 + js/sdk/src/crypto/index.ts | 5 + js/sdk/src/crypto/interface.ts | 275 + js/sdk/src/crypto/openPGPCrypto.ts | 227 + js/sdk/src/crypto/openPGPSerialisation.ts | 42 + js/sdk/src/crypto/utils.ts | 40 + js/sdk/src/index.ts | 6 + js/sdk/src/interface/constructor.ts | 74 + js/sdk/src/interface/devices.ts | 16 + js/sdk/src/interface/download.ts | 23 + js/sdk/src/interface/events.ts | 42 + js/sdk/src/interface/index.ts | 35 + js/sdk/src/interface/nodes.ts | 90 + js/sdk/src/interface/result.ts | 11 + js/sdk/src/interface/sharing.ts | 106 + js/sdk/src/interface/upload.ts | 43 + js/sdk/src/internal/apiService/apiService.ts | 67 + js/sdk/src/internal/apiService/coreTypes.ts | 24460 ++++++++++++++++ js/sdk/src/internal/apiService/driveTypes.ts | 10564 +++++++ js/sdk/src/internal/apiService/errorCodes.ts | 4 + js/sdk/src/internal/apiService/errors.ts | 38 + js/sdk/src/internal/apiService/index.ts | 3 + js/sdk/src/internal/events/index.ts | 38 + js/sdk/src/internal/nodes/apiService.test.ts | 210 + js/sdk/src/internal/nodes/apiService.ts | 380 + js/sdk/src/internal/nodes/cache.test.ts | 168 + js/sdk/src/internal/nodes/cache.ts | 206 + js/sdk/src/internal/nodes/cryptoCache.test.ts | 116 + js/sdk/src/internal/nodes/cryptoCache.ts | 94 + .../src/internal/nodes/cryptoService.test.ts | 380 + js/sdk/src/internal/nodes/cryptoService.ts | 302 + js/sdk/src/internal/nodes/events.ts | 101 + js/sdk/src/internal/nodes/hmac.ts | 46 + js/sdk/src/internal/nodes/index.ts | 74 + js/sdk/src/internal/nodes/interface.ts | 90 + js/sdk/src/internal/nodes/manager.ts | 356 + js/sdk/src/internal/nodes/nodeUid.ts | 13 + js/sdk/src/internal/nodes/nodesAccess.test.ts | 135 + js/sdk/src/internal/nodes/nodesAccess.ts | 88 + js/sdk/src/internal/shares/apiService.ts | 161 + js/sdk/src/internal/shares/cache.test.ts | 56 + js/sdk/src/internal/shares/cache.ts | 63 + .../src/internal/shares/cryptoCache.test.ts | 116 + js/sdk/src/internal/shares/cryptoCache.ts | 83 + js/sdk/src/internal/shares/cryptoService.ts | 68 + js/sdk/src/internal/shares/index.ts | 33 + js/sdk/src/internal/shares/interface.ts | 87 + js/sdk/src/internal/shares/manager.ts | 153 + js/sdk/src/internal/sharing/apiService.ts | 31 + js/sdk/src/internal/sharing/cryptoService.ts | 44 + js/sdk/src/internal/sharing/index.ts | 80 + js/sdk/src/internal/sharing/interface.ts | 3 + js/sdk/src/internal/sharing/sharingAccess.ts | 43 + .../src/internal/sharing/sharingManagement.ts | 61 + js/sdk/src/internal/upload/apiService.ts | 10 + js/sdk/src/internal/upload/cryptoService.ts | 14 + js/sdk/src/internal/upload/fileUploader.ts | 36 + js/sdk/src/internal/upload/index.ts | 44 + js/sdk/src/internal/upload/interface.ts | 3 + js/sdk/src/internal/upload/queue.ts | 7 + js/sdk/src/protonDriveClient.ts | 57 + js/sdk/src/protonDrivePhotosClient.ts | 18 + js/sdk/src/protonDrivePublicClient.ts | 49 + js/sdk/src/transformers.ts | 31 + js/sdk/tsconfig.json | 26 + js/sdk/typings/index.d.ts | 2 + 77 files changed, 51649 insertions(+) create mode 100644 js/sdk/.eslintrc.js create mode 100644 js/sdk/jest.config.js create mode 100644 js/sdk/jest.transform.js create mode 100644 js/sdk/package-lock.json create mode 100644 js/sdk/package.json create mode 100644 js/sdk/src/cache/index.ts create mode 100644 js/sdk/src/cache/interface.ts create mode 100644 js/sdk/src/cache/memoryCache.test.ts create mode 100644 js/sdk/src/cache/memoryCache.ts create mode 100644 js/sdk/src/config.ts create mode 100644 js/sdk/src/crypto/driveCrypto.ts create mode 100644 js/sdk/src/crypto/index.ts create mode 100644 js/sdk/src/crypto/interface.ts create mode 100644 js/sdk/src/crypto/openPGPCrypto.ts create mode 100644 js/sdk/src/crypto/openPGPSerialisation.ts create mode 100644 js/sdk/src/crypto/utils.ts create mode 100644 js/sdk/src/index.ts create mode 100644 js/sdk/src/interface/constructor.ts create mode 100644 js/sdk/src/interface/devices.ts create mode 100644 js/sdk/src/interface/download.ts create mode 100644 js/sdk/src/interface/events.ts create mode 100644 js/sdk/src/interface/index.ts create mode 100644 js/sdk/src/interface/nodes.ts create mode 100644 js/sdk/src/interface/result.ts create mode 100644 js/sdk/src/interface/sharing.ts create mode 100644 js/sdk/src/interface/upload.ts create mode 100644 js/sdk/src/internal/apiService/apiService.ts create mode 100644 js/sdk/src/internal/apiService/coreTypes.ts create mode 100644 js/sdk/src/internal/apiService/driveTypes.ts create mode 100644 js/sdk/src/internal/apiService/errorCodes.ts create mode 100644 js/sdk/src/internal/apiService/errors.ts create mode 100644 js/sdk/src/internal/apiService/index.ts create mode 100644 js/sdk/src/internal/events/index.ts create mode 100644 js/sdk/src/internal/nodes/apiService.test.ts create mode 100644 js/sdk/src/internal/nodes/apiService.ts create mode 100644 js/sdk/src/internal/nodes/cache.test.ts create mode 100644 js/sdk/src/internal/nodes/cache.ts create mode 100644 js/sdk/src/internal/nodes/cryptoCache.test.ts create mode 100644 js/sdk/src/internal/nodes/cryptoCache.ts create mode 100644 js/sdk/src/internal/nodes/cryptoService.test.ts create mode 100644 js/sdk/src/internal/nodes/cryptoService.ts create mode 100644 js/sdk/src/internal/nodes/events.ts create mode 100644 js/sdk/src/internal/nodes/hmac.ts create mode 100644 js/sdk/src/internal/nodes/index.ts create mode 100644 js/sdk/src/internal/nodes/interface.ts create mode 100644 js/sdk/src/internal/nodes/manager.ts create mode 100644 js/sdk/src/internal/nodes/nodeUid.ts create mode 100644 js/sdk/src/internal/nodes/nodesAccess.test.ts create mode 100644 js/sdk/src/internal/nodes/nodesAccess.ts create mode 100644 js/sdk/src/internal/shares/apiService.ts create mode 100644 js/sdk/src/internal/shares/cache.test.ts create mode 100644 js/sdk/src/internal/shares/cache.ts create mode 100644 js/sdk/src/internal/shares/cryptoCache.test.ts create mode 100644 js/sdk/src/internal/shares/cryptoCache.ts create mode 100644 js/sdk/src/internal/shares/cryptoService.ts create mode 100644 js/sdk/src/internal/shares/index.ts create mode 100644 js/sdk/src/internal/shares/interface.ts create mode 100644 js/sdk/src/internal/shares/manager.ts create mode 100644 js/sdk/src/internal/sharing/apiService.ts create mode 100644 js/sdk/src/internal/sharing/cryptoService.ts create mode 100644 js/sdk/src/internal/sharing/index.ts create mode 100644 js/sdk/src/internal/sharing/interface.ts create mode 100644 js/sdk/src/internal/sharing/sharingAccess.ts create mode 100644 js/sdk/src/internal/sharing/sharingManagement.ts create mode 100644 js/sdk/src/internal/upload/apiService.ts create mode 100644 js/sdk/src/internal/upload/cryptoService.ts create mode 100644 js/sdk/src/internal/upload/fileUploader.ts create mode 100644 js/sdk/src/internal/upload/index.ts create mode 100644 js/sdk/src/internal/upload/interface.ts create mode 100644 js/sdk/src/internal/upload/queue.ts create mode 100644 js/sdk/src/protonDriveClient.ts create mode 100644 js/sdk/src/protonDrivePhotosClient.ts create mode 100644 js/sdk/src/protonDrivePublicClient.ts create mode 100644 js/sdk/src/transformers.ts create mode 100644 js/sdk/tsconfig.json create mode 100644 js/sdk/typings/index.d.ts diff --git a/.gitignore b/.gitignore index 34bccf99..5eb4fd96 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ public node_modules .eslintcache tsconfig.tsbuildinfo +bin diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js new file mode 100644 index 00000000..8c3b98a4 --- /dev/null +++ b/js/sdk/.eslintrc.js @@ -0,0 +1,23 @@ +module.exports = { + extends: [ + 'plugin:@typescript-eslint/recommended' + ], + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: __dirname, + project: "./tsconfig.json", + ecmaVersion: 2018, + sourceType: "module" + }, + rules: { + "tsdoc/syntax": "warn", + // Any is used during prototyping - remove once all the types are available to fix all the places. + "@typescript-eslint/no-explicit-any": "off", + // Many variables are unused during prototyping - remove later once more modules are implemented. + "@typescript-eslint/no-unused-vars": "off", + }, + plugins: [ + "@typescript-eslint/eslint-plugin", + "eslint-plugin-tsdoc" + ] +}; diff --git a/js/sdk/jest.config.js b/js/sdk/jest.config.js new file mode 100644 index 00000000..b7b164ba --- /dev/null +++ b/js/sdk/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + moduleDirectories: ['/node_modules', 'node_modules'], + testPathIgnorePatterns: ['/tests'], + collectCoverage: false, + transformIgnorePatterns: [], + transform: { + '^.+\\.(m?js|tsx?)$': '/jest.transform.js', + }, + moduleNameMapper: {}, + reporters: ['default'], +}; diff --git a/js/sdk/jest.transform.js b/js/sdk/jest.transform.js new file mode 100644 index 00000000..997b831d --- /dev/null +++ b/js/sdk/jest.transform.js @@ -0,0 +1,4 @@ +module.exports = require('babel-jest').default.createTransformer({ + presets: ['@babel/preset-env', '@babel/preset-typescript'], + plugins: [], +}); diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json new file mode 100644 index 00000000..c38f24da --- /dev/null +++ b/js/sdk/package-lock.json @@ -0,0 +1,10704 @@ +{ + "name": "proton-drive", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proton-drive", + "version": "0.0.1", + "license": "GPL-3.0", + "dependencies": { + "pmcrypto": "npm:@protontech/pmcrypto@~8.1.3", + "ttag": "^1.8.7" + }, + "devDependencies": { + "@babel/preset-env": "^7.26.0", + "@babel/preset-typescript": "^7.26.0", + "@types/jest": "^29.5.14", + "@types/mocha": "^10.0.10", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@web/dev-server-esbuild": "^1.0.3", + "@web/test-runner": "^0.19.0", + "@web/test-runner-playwright": "^0.11.0", + "eslint": "^8.57.1", + "eslint-plugin-tsdoc": "^0.3.0", + "jest": "^29.7.0", + "openapi-typescript": "^7.4.1", + "prettier": "^3.4.2", + "typedoc": "^0.26.11", + "typescript": "^5.6.3", + "web-test-runner-jasmine": "^0.0.7" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", + "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", + "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.26.3", + "@babel/types": "^7.26.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", + "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", + "dev": true, + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", + "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.26.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", + "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", + "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-classes/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", + "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", + "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", + "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", + "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", + "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", + "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.3.tgz", + "integrity": "sha512-6+5hpdr6mETwSKjmJUdYw0EIkATiQhnELWlE3kJFBwSg/BGIVwVaVbX+gOXBCdc7Ln1RXZxyWGecIXhUfnl7oA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", + "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.38.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", + "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime/node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", + "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.3", + "@babel/parser": "^7.26.3", + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.3", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", + "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mdn/browser-compat-data": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz", + "integrity": "sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", + "integrity": "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==", + "dev": true + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.0.tgz", + "integrity": "sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.15.0", + "ajv": "~8.12.0", + "jju": "~1.4.0", + "resolve": "~1.22.2" + } + }, + "node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@openpgp/web-stream-tools": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@openpgp/web-stream-tools/-/web-stream-tools-0.1.3.tgz", + "integrity": "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ==", + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "typescript": ">=4.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", + "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.20.1.tgz", + "integrity": "sha512-TYiTDtuItiv95YMsrRxyCs1HKLrDPtTvpaD3+kDKXBnFDeJuYKZ+eHXpCr6YeN4inxfVBs7DLhHsQcs9srddyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.27.2", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.27.2.tgz", + "integrity": "sha512-qVrDc27DHpeO2NRCMeRdb4299nijKQE3BY0wrA+WUHlOLScorIi/y7JzammLk22IaTvjR9Mv9aTAdjE1aUwJnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.11.2", + "@redocly/config": "^0.20.1", + "colorette": "^1.2.0", + "https-proxy-agent": "^7.0.4", + "js-levenshtein": "^1.1.6", + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1", + "node-fetch": "^2.6.1", + "pluralize": "^8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=14.19.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz", + "integrity": "sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz", + "integrity": "sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@shikijs/core": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.26.1.tgz", + "integrity": "sha512-yeo7sG+WZQblKPclUOKRPwkv1PyoHYkJ4gP9DzhFJbTdueKR7wYTI1vfF/bFi1NTgc545yG/DzvVhZgueVOXMA==", + "dev": true, + "dependencies": { + "@shikijs/engine-javascript": "1.26.1", + "@shikijs/engine-oniguruma": "1.26.1", + "@shikijs/types": "1.26.1", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.26.1.tgz", + "integrity": "sha512-CRhA0b8CaSLxS0E9A4Bzcb3LKBNpykfo9F85ozlNyArxjo2NkijtiwrJZ6eHa+NT5I9Kox2IXVdjUsP4dilsmw==", + "dev": true, + "dependencies": { + "@shikijs/types": "1.26.1", + "@shikijs/vscode-textmate": "^10.0.1", + "oniguruma-to-es": "0.10.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.26.1.tgz", + "integrity": "sha512-F5XuxN1HljLuvfXv7d+mlTkV7XukC1cawdtOo+7pKgPD83CAB1Sf8uHqP3PK0u7njFH0ZhoXE1r+0JzEgAQ+kg==", + "dev": true, + "dependencies": { + "@shikijs/types": "1.26.1", + "@shikijs/vscode-textmate": "^10.0.1" + } + }, + "node_modules/@shikijs/langs": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.26.1.tgz", + "integrity": "sha512-oz/TQiIqZejEIZbGtn68hbJijAOTtYH4TMMSWkWYozwqdpKR3EXgILneQy26WItmJjp3xVspHdiUxUCws4gtuw==", + "dev": true, + "dependencies": { + "@shikijs/types": "1.26.1" + } + }, + "node_modules/@shikijs/themes": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.26.1.tgz", + "integrity": "sha512-JDxVn+z+wgLCiUhBGx2OQrLCkKZQGzNH3nAxFir4PjUcYiyD8Jdms9izyxIogYmSwmoPTatFTdzyrRKbKlSfPA==", + "dev": true, + "dependencies": { + "@shikijs/types": "1.26.1" + } + }, + "node_modules/@shikijs/types": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.26.1.tgz", + "integrity": "sha512-d4B00TKKAMaHuFYgRf3L0gwtvqpW4hVdVwKcZYbBfAAQXspgkbWqnFfuFl3MDH6gLbsubOcr+prcnsqah3ny7Q==", + "dev": true, + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.1.tgz", + "integrity": "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg==", + "dev": true + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/babel__code-frame": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz", + "integrity": "sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/co-body": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@types/co-body/-/co-body-6.1.3.tgz", + "integrity": "sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*" + } + }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.8.tgz", + "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/convert-source-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/convert-source-map/-/convert-source-map-2.0.3.tgz", + "integrity": "sha512-ag0BfJLZf6CQz8VIuRIEYQ5Ggwk/82uvTQf27RcpyDNbY0Vw49LIPqAxk5tqYfrCs9xDaIMvl4aj7ZopnYL8bA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", + "integrity": "sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "node_modules/@types/debounce": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz", + "integrity": "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", + "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.4.tgz", + "integrity": "sha512-5kz9ScmzBdzTgB/3susoCgfqNDzBjvLL4taparufgSvlwjdLy6UyUy9T/tCpYd2GIdIilCatC4iSQS0QSYHt0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", + "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.8.tgz", + "integrity": "sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", + "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/parse5": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", + "integrity": "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true + }, + "node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", + "integrity": "sha512-tJzcVyvvb9h/PB96g30MpxACd9IrunT7GF9wfA9/0TJ1LxGOJx1TdPzSbBBnNED7K9Ka8ybJsnEpiXPktolTLg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.19.1", + "@typescript-eslint/type-utils": "8.19.1", + "@typescript-eslint/utils": "8.19.1", + "@typescript-eslint/visitor-keys": "8.19.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.1.tgz", + "integrity": "sha512-67gbfv8rAwawjYx3fYArwldTQKoYfezNUT4D5ioWetr/xCrxXxvleo3uuiFuKfejipvq+og7mjz3b0G2bVyUCw==", + "dev": true, + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.19.1", + "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/typescript-estree": "8.19.1", + "@typescript-eslint/visitor-keys": "8.19.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.1.tgz", + "integrity": "sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/visitor-keys": "8.19.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.1.tgz", + "integrity": "sha512-Rp7k9lhDKBMRJB/nM9Ksp1zs4796wVNyihG9/TU9R6KCJDNkQbc2EOKjrBtLYh3396ZdpXLtr/MkaSEmNMtykw==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "8.19.1", + "@typescript-eslint/utils": "8.19.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.1.tgz", + "integrity": "sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.1.tgz", + "integrity": "sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/visitor-keys": "8.19.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.1.tgz", + "integrity": "sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.19.1", + "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/typescript-estree": "8.19.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.1.tgz", + "integrity": "sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.19.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/@web/browser-logs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-0.4.1.tgz", + "integrity": "sha512-ypmMG+72ERm+LvP+loj9A64MTXvWMXHUOu773cPO4L1SV/VWg6xA9Pv7vkvkXQX+ItJtCJt+KQ+U6ui2HhSFUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "errorstacks": "^2.4.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/config-loader": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@web/config-loader/-/config-loader-0.3.2.tgz", + "integrity": "sha512-Vrjv/FexBGmAdnCYpJKLHX1dfT1UaUdvHmX1JRaWos9OvDf/tFznYJ5SpJwww3Rl87/ewvLSYG7kfsMqEAsizQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/dev-server": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@web/dev-server/-/dev-server-0.4.6.tgz", + "integrity": "sha512-jj/1bcElAy5EZet8m2CcUdzxT+CRvUjIXGh8Lt7vxtthkN9PzY9wlhWx/9WOs5iwlnG1oj0VGo6f/zvbPO0s9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.11", + "@types/command-line-args": "^5.0.0", + "@web/config-loader": "^0.3.0", + "@web/dev-server-core": "^0.7.2", + "@web/dev-server-rollup": "^0.6.1", + "camelcase": "^6.2.0", + "command-line-args": "^5.1.1", + "command-line-usage": "^7.0.1", + "debounce": "^1.2.0", + "deepmerge": "^4.2.2", + "internal-ip": "^6.2.0", + "nanocolors": "^0.2.1", + "open": "^8.0.2", + "portfinder": "^1.0.32" + }, + "bin": { + "wds": "dist/bin.js", + "web-dev-server": "dist/bin.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/dev-server-core": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.5.tgz", + "integrity": "sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "^2.11.6", + "@types/ws": "^7.4.0", + "@web/parse5-utils": "^2.1.0", + "chokidar": "^4.0.1", + "clone": "^2.1.2", + "es-module-lexer": "^1.0.0", + "get-stream": "^6.0.0", + "is-stream": "^2.0.0", + "isbinaryfile": "^5.0.0", + "koa": "^2.13.0", + "koa-etag": "^4.0.0", + "koa-send": "^5.0.1", + "koa-static": "^5.0.0", + "lru-cache": "^8.0.4", + "mime-types": "^2.1.27", + "parse5": "^6.0.1", + "picomatch": "^2.2.2", + "ws": "^7.5.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/dev-server-core/node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16.14" + } + }, + "node_modules/@web/dev-server-esbuild": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-1.0.3.tgz", + "integrity": "sha512-oImN4/cpyfQC8+JcCx61M7WIo09zE2aDMFuwh+brqxuNXIBRQ+hnRGQK7fEIZSQeWWT5dFrWmH4oYZfqzCAlfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdn/browser-compat-data": "^4.0.0", + "@web/dev-server-core": "^0.7.4", + "esbuild": "^0.24.0", + "parse5": "^6.0.1", + "ua-parser-js": "^1.0.33" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/dev-server-rollup": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@web/dev-server-rollup/-/dev-server-rollup-0.6.4.tgz", + "integrity": "sha512-sJZfTGCCrdku5xYnQQG51odGI092hKY9YFM0X3Z0tRY3iXKXcYRaLZrErw5KfCxr6g0JRuhe4BBhqXTA5Q2I3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-node-resolve": "^15.0.1", + "@web/dev-server-core": "^0.7.2", + "nanocolors": "^0.2.1", + "parse5": "^6.0.1", + "rollup": "^4.4.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/dev-server/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@web/parse5-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-2.1.0.tgz", + "integrity": "sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse5": "^6.0.1", + "parse5": "^6.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/test-runner": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@web/test-runner/-/test-runner-0.19.0.tgz", + "integrity": "sha512-qLUupi88OK1Kl52cWPD/2JewUCRUxYsZ1V1DyLd05P7u09zCdrUYrtkB/cViWyxlBe/TOvqkSNpcTv6zLJ9GoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/browser-logs": "^0.4.0", + "@web/config-loader": "^0.3.0", + "@web/dev-server": "^0.4.0", + "@web/test-runner-chrome": "^0.17.0", + "@web/test-runner-commands": "^0.9.0", + "@web/test-runner-core": "^0.13.0", + "@web/test-runner-mocha": "^0.9.0", + "camelcase": "^6.2.0", + "command-line-args": "^5.1.1", + "command-line-usage": "^7.0.1", + "convert-source-map": "^2.0.0", + "diff": "^5.0.0", + "globby": "^11.0.1", + "nanocolors": "^0.2.1", + "portfinder": "^1.0.32", + "source-map": "^0.7.3" + }, + "bin": { + "web-test-runner": "dist/bin.js", + "wtr": "dist/bin.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/test-runner-chrome": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-chrome/-/test-runner-chrome-0.17.0.tgz", + "integrity": "sha512-Il5N9z41NKWCrQM1TVgRaDWWYoJtG5Ha4fG+cN1MWL2OlzBS4WoOb4lFV3EylZ7+W3twZOFr1zy2Rx61yDYd/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0", + "@web/test-runner-coverage-v8": "^0.8.0", + "async-mutex": "0.4.0", + "chrome-launcher": "^0.15.0", + "puppeteer-core": "^23.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/test-runner-commands": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-0.9.0.tgz", + "integrity": "sha512-zeLI6QdH0jzzJMDV5O42Pd8WLJtYqovgdt0JdytgHc0d1EpzXDsc7NTCJSImboc2NcayIsWAvvGGeRF69SMMYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0", + "mkdirp": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/test-runner-core": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.13.4.tgz", + "integrity": "sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.11", + "@types/babel__code-frame": "^7.0.2", + "@types/co-body": "^6.1.0", + "@types/convert-source-map": "^2.0.0", + "@types/debounce": "^1.2.0", + "@types/istanbul-lib-coverage": "^2.0.3", + "@types/istanbul-reports": "^3.0.0", + "@web/browser-logs": "^0.4.0", + "@web/dev-server-core": "^0.7.3", + "chokidar": "^4.0.1", + "cli-cursor": "^3.1.0", + "co-body": "^6.1.0", + "convert-source-map": "^2.0.0", + "debounce": "^1.2.0", + "dependency-graph": "^0.11.0", + "globby": "^11.0.1", + "internal-ip": "^6.2.0", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.0.2", + "log-update": "^4.0.0", + "nanocolors": "^0.2.1", + "nanoid": "^3.1.25", + "open": "^8.0.2", + "picomatch": "^2.2.2", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/test-runner-core/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@web/test-runner-coverage-v8": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.8.0.tgz", + "integrity": "sha512-PskiucYpjUtgNfR2zF2AWqWwjXL7H3WW/SnCAYmzUrtob7X9o/+BjdyZ4wKbOxWWSbJO4lEdGIDLu+8X2Xw+lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0", + "istanbul-lib-coverage": "^3.0.0", + "lru-cache": "^8.0.4", + "picomatch": "^2.2.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/test-runner-coverage-v8/node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16.14" + } + }, + "node_modules/@web/test-runner-mocha": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-mocha/-/test-runner-mocha-0.9.0.tgz", + "integrity": "sha512-ZL9F6FXd0DBQvo/h/+mSfzFTSRVxzV9st/AHhpgABtUtV/AIpVE9to6+xdkpu6827kwjezdpuadPfg+PlrBWqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/test-runner-playwright": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-playwright/-/test-runner-playwright-0.11.0.tgz", + "integrity": "sha512-s+f43DSAcssKYVOD9SuzueUcctJdHzq1by45gAnSCKa9FQcaTbuYe8CzmxA21g+NcL5+ayo4z+MA9PO4H+PssQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0", + "@web/test-runner-coverage-v8": "^0.8.0", + "playwright": "^1.22.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/test-runner/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@web/test-runner/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/async-mutex": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", + "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.3", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/bare-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-buffer/-/bare-buffer-3.0.1.tgz", + "integrity": "sha512-QuDV/Wv5k1xsevh24zQwEjlQJuRvt3tUC39VFai6PoJiDIwmISEoc76ZTae4yVcacRBw0HBArrHssV1o3TEKhQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "bare": ">=1.13.0" + } + }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz", + "integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.0.0", + "bare-path": "^2.0.0", + "bare-stream": "^2.0.0" + } + }, + "node_modules/bare-os": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz", + "integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-path": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", + "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^2.1.0" + } + }, + "node_modules/bare-stream": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.3.tgz", + "integrity": "sha512-AiqV593yTkEU3Lka0Sn+UT8X8U5hZ713RHa5Dg88GtJRite8TeD0oBOESNY6LnaBXTK0LjAW82OVhws+7L4JGA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", + "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001690", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", + "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-bidi": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", + "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "dev": true + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/co-body": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.2.0.tgz", + "integrity": "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hapi/bourne": "^3.0.0", + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/command-line-usage/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/command-line-usage/node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-js-compat": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", + "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "dev": true, + "dependencies": { + "browserslist": "^4.24.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1367902", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", + "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.78", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.78.tgz", + "integrity": "sha512-UmwIt7HRKN1rsJfddG5UG7rCTCTAKoS9JeOy/R0zSenAyaZ8SU3RuXlwcratxhdxGRNpk03iq8O7BA3W7ibLVw==", + "dev": true + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/errorstacks": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/errorstacks/-/errorstacks-2.4.1.tgz", + "integrity": "sha512-jE4i0SMYevwu/xxAuzhly/KTwtj0xDhbzB6m1xPImxTkw8wcCbgarOQPfCVMi5JKVyW7in29pNJCCJrry3Ynnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.1.tgz", + "integrity": "sha512-BPOBuyUF9QIVhuNLhbToCLHP6+0MHwZ7xLBkPPCZqK4JmpJgGnv10035STzzQwFpqdzNFMB3irvDI63IagvDwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-tsdoc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.3.0.tgz", + "integrity": "sha512-0MuFdBrrJVBjT/gyhkP2BqpD0np1NxNLfQ38xXDlSs/KVVpKI2A6vN7jx2Rve/CyUsvOsMGwp9KKrinv7q9g3A==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.15.0", + "@microsoft/tsdoc-config": "0.17.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", + "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.4.tgz", + "integrity": "sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA==", + "dev": true, + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/index-to-position": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", + "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflation": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz", + "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-ip": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-6.2.0.tgz", + "integrity": "sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-gateway": "^6.0.0", + "ipaddr.js": "^1.9.1", + "is-ip": "^3.1.0", + "p-event": "^4.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/internal-ip?sponsor=1" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ip-regex": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", + "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-ip": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz", + "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-regex": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isbinaryfile": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.4.tgz", + "integrity": "sha512-YKBKVkKhty7s8rxddb40oOkuP0NbaeXrQvLin6QMHL7Ypiy2RW9LwOVrVgZRyOrhQlayMd9t+D8yDy8MKFTSDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jasmine": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-5.5.0.tgz", + "integrity": "sha512-JKlEVCVD5QBPYLsg/VE+IUtjyseDCrW8rMBu8la+9ysYashDgavMLM9Kotls1FhI6dCJLJ40dBCIfQjGLPZI1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.2.2", + "jasmine-core": "~5.5.0" + }, + "bin": { + "jasmine": "bin/jasmine.js" + } + }, + "node_modules/jasmine-core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz", + "integrity": "sha512-NHOvoPO6o9gVR6pwqEACTEpbgcH+JJ6QDypyymGbSUIFIFsMMbBJ/xsFNud8MSClfnWclXd7RQlAZBz7yVo5TQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jasmine/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsmimeparser": { + "name": "@protontech/jsmimeparser", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@protontech/jsmimeparser/-/jsmimeparser-3.0.1.tgz", + "integrity": "sha512-bi0RBkritKep1cKnQ6U1538++aQ+7XZxG5Uzm4ZivvP7FTE3iaOA5lm0CCFbSoQ5e8KtvjI5KR+Vj6apXhyhXQ==", + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/koa": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", + "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.9.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "dev": true, + "license": "MIT", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa-etag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/koa-etag/-/koa-etag-4.0.0.tgz", + "integrity": "sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "etag": "^1.8.1" + } + }, + "node_modules/koa-send": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", + "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "koa-send": "^5.0.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa-static/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/marky": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz", + "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dev": true, + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanocolors": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/nanocolors/-/nanocolors-0.2.13.tgz", + "integrity": "sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oniguruma-to-es": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-0.10.0.tgz", + "integrity": "sha512-zapyOUOCJxt+xhiNRPPMtfJkHGsZ98HHB9qJEkdT8BGytO/+kpe4m1Ngf0MzbzTmhacn11w9yGeDP6tzDhnCdg==", + "dev": true, + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^5.1.1", + "regex-recursion": "^5.1.1" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==", + "dev": true + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.5.2.tgz", + "integrity": "sha512-W/QXuQz0Fa3bGY6LKoqTCgrSX+xI/ST+E5RXo2WBmp3WwgXCWKDJPHv5GZmElF4yLCccnqYsakBDOJikHZYGRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.27.0", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.1.0", + "supports-color": "^9.4.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/parse-json": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", + "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.22.13", + "index-to-position": "^0.1.2", + "type-fest": "^4.7.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", + "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/openapi-typescript/node_modules/type-fest": { + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.32.0.tgz", + "integrity": "sha512-rfgpoi08xagF3JSdtJlCwMq9DGNDE0IMh3Mkpc1wUypg9vPi786AiqeBBKcqvIkq42azsBM85N490fyZjeUftw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-event": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", + "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^3.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz", + "integrity": "sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/plural-forms": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/plural-forms/-/plural-forms-0.5.5.tgz", + "integrity": "sha512-rJw4xp22izsfJOVqta5Hyvep2lR3xPkFUtj7dyQtpf/FbxUiX7PQCajTn2EHDRylizH5N/Uqqodfdu22I0ju+g==" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pmcrypto": { + "name": "@protontech/pmcrypto", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@protontech/pmcrypto/-/pmcrypto-8.1.3.tgz", + "integrity": "sha512-KtrYr2z4BlKY27+k43hpCizLmloSbYrA7BeYUl0ET2zRvXAGUo89czZ/QN8PbcVGnbOeFWU56tnMhCUudxCx+Q==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.6.0", + "@openpgp/web-stream-tools": "~0.1.3", + "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.1", + "openpgp": "npm:@protontech/openpgp@~6.0.2-patch.1" + } + }, + "node_modules/pmcrypto/node_modules/openpgp": { + "name": "@protontech/openpgp", + "version": "6.0.2-patch.1", + "resolved": "https://registry.npmjs.org/@protontech/openpgp/-/openpgp-6.0.2-patch.1.tgz", + "integrity": "sha512-3lVr60/gmVEkfrJsPbbIq7BAcXabJuPM8V7wtH04brLYVAxMKOpDi96C0XQyOURAAYiHYXKkGvuR4cwoNy9WBw==", + "license": "LGPL-3.0+", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/portfinder": { + "version": "1.0.32", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", + "integrity": "sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^2.6.4", + "debug": "^3.2.7", + "mkdirp": "^0.5.6" + }, + "engines": { + "node": ">= 0.12.0" + } + }, + "node_modules/portfinder/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/portfinder/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer-core": { + "version": "23.11.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz", + "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.11.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1367902", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/qs": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", + "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true, + "license": "MIT" + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/readdirp": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz", + "integrity": "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regex": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", + "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", + "dev": true, + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", + "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", + "dev": true, + "dependencies": { + "regex": "^5.1.1", + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true + }, + "node_modules/regexpu-core": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.12.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-path": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", + "integrity": "sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-errors": "~1.6.2", + "path-is-absolute": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-path/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve-path/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz", + "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.30.1", + "@rollup/rollup-android-arm64": "4.30.1", + "@rollup/rollup-darwin-arm64": "4.30.1", + "@rollup/rollup-darwin-x64": "4.30.1", + "@rollup/rollup-freebsd-arm64": "4.30.1", + "@rollup/rollup-freebsd-x64": "4.30.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.30.1", + "@rollup/rollup-linux-arm-musleabihf": "4.30.1", + "@rollup/rollup-linux-arm64-gnu": "4.30.1", + "@rollup/rollup-linux-arm64-musl": "4.30.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.30.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.30.1", + "@rollup/rollup-linux-riscv64-gnu": "4.30.1", + "@rollup/rollup-linux-s390x-gnu": "4.30.1", + "@rollup/rollup-linux-x64-gnu": "4.30.1", + "@rollup/rollup-linux-x64-musl": "4.30.1", + "@rollup/rollup-win32-arm64-msvc": "4.30.1", + "@rollup/rollup-win32-ia32-msvc": "4.30.1", + "@rollup/rollup-win32-x64-msvc": "4.30.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.26.1.tgz", + "integrity": "sha512-Gqg6DSTk3wYqaZ5OaYtzjcdxcBvX5kCy24yvRJEgjT5U+WHlmqCThLuBUx0juyxQBi+6ug53IGeuQS07DWwpcw==", + "dev": true, + "dependencies": { + "@shikijs/core": "1.26.1", + "@shikijs/engine-javascript": "1.26.1", + "@shikijs/engine-oniguruma": "1.26.1", + "@shikijs/langs": "1.26.1", + "@shikijs/themes": "1.26.1", + "@shikijs/types": "1.26.1", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/streamx": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.21.1.tgz", + "integrity": "sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "queue-tick": "^1.0.1", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/table-layout/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/tar-fs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.7.tgz", + "integrity": "sha512-2sAfoF/zw/2n8goUGnGRZTWTD4INtnScPZvyYBI6BDlJ3wNR5o1dw03EfBvuhG6GBLvC4J+C7j7W+64aZ0ogQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^2.1.1", + "bare-path": "^2.1.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", + "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/ttag": { + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/ttag/-/ttag-1.8.7.tgz", + "integrity": "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA==", + "dependencies": { + "dedent": "1.5.1", + "plural-forms": "^0.5.3" + } + }, + "node_modules/ttag/node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/typedoc": { + "version": "0.26.11", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.11.tgz", + "integrity": "sha512-sFEgRRtrcDl2FxVP58Ze++ZK2UQAEvtvvH8rRlig1Ja3o7dDaMHmaBfvJmdGnNEFaLTpQsN8dpvZaTqJSu/Ugw==", + "dev": true, + "dependencies": { + "lunr": "^2.3.9", + "markdown-it": "^14.1.0", + "minimatch": "^9.0.5", + "shiki": "^1.16.2", + "yaml": "^2.5.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.40", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", + "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dev": true, + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dev": true, + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dev": true, + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dev": true, + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/web-test-runner-jasmine": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/web-test-runner-jasmine/-/web-test-runner-jasmine-0.0.7.tgz", + "integrity": "sha512-/w2U4dQNWPmgl8a1sxIC9bF22XL+bJYxL83/JQ5IQoK1bnVXwSAeJVRqmTLCYVNHIqadDTehf9FalRPBvZzlaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner": "^0.19.0", + "@web/test-runner-core": "^0.13.4", + "jasmine": "^5.5.0" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", + "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrapjs": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.0.tgz", + "integrity": "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/ylru": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", + "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/js/sdk/package.json b/js/sdk/package.json new file mode 100644 index 00000000..2339ac6c --- /dev/null +++ b/js/sdk/package.json @@ -0,0 +1,35 @@ +{ + "name": "proton-drive", + "version": "0.0.1", + "description": "Proton Drive SDK", + "license": "GPL-3.0", + "scripts": { + "check-types": "tsc", + "generate-doc:interface": "typedoc src/index.ts --out ${OUTPUT_PATH}", + "generate-doc:internal": "typedoc src/**/*.ts --out ${OUTPUT_PATH}", + "generate-types": "openapi-typescript ../../api/openapi-drive.json -o ./src/internal/apiService/driveTypes.ts && openapi-typescript ../../api/openapi-core.json -o ./src/internal/apiService/coreTypes.ts", + "lint": "eslint src --ext .ts --cache --ignore-pattern '**/apiService/*Types.ts'", + "pretty": "prettier --write $(find src -type f -name '*.ts')", + "test": "jest", + "test:watch": "jest --watch --coverage=false" + }, + "dependencies": { + "pmcrypto": "npm:@protontech/pmcrypto@~8.1.3", + "ttag": "^1.8.7" + }, + "devDependencies": { + "@babel/preset-env": "^7.26.0", + "@babel/preset-typescript": "^7.26.0", + "@types/jest": "^29.5.14", + "@types/mocha": "^10.0.10", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@web/dev-server-esbuild": "^1.0.3", + "eslint": "^8.57.1", + "eslint-plugin-tsdoc": "^0.3.0", + "jest": "^29.7.0", + "openapi-typescript": "^7.4.1", + "prettier": "^3.4.2", + "typedoc": "^0.26.11", + "typescript": "^5.6.3" + } +} diff --git a/js/sdk/src/cache/index.ts b/js/sdk/src/cache/index.ts new file mode 100644 index 00000000..1f780224 --- /dev/null +++ b/js/sdk/src/cache/index.ts @@ -0,0 +1,2 @@ +export type { ProtonDriveCache, EntityResult } from './interface'; +export { MemoryCache } from './memoryCache'; diff --git a/js/sdk/src/cache/interface.ts b/js/sdk/src/cache/interface.ts new file mode 100644 index 00000000..9e4b88fe --- /dev/null +++ b/js/sdk/src/cache/interface.ts @@ -0,0 +1,113 @@ +export interface ProtonDriveCacheConstructor { + /** + * Initialize the cache. + * + * The local database should follow document-based structure. The SDK does + * serialisation and data is not intended to be read by 3rd party. The SDK, + * however, provides also clear fields in form of tags that is used for + * search. Local database should have indexes, columns, or other structure + * for easier look-up. The list of used tags by the SDK is passed via + * `usedTagKeysBySDK` parameter. + * + * See {@link setEntity} for more details how tags are used. + * + * @param usedTagKeysBySDK - Example of tags: ["trashed", "shared", "parentUid"] + */ + new (usedTagKeysBySDK: string[]): ProtonDriveCache, +} + +export interface ProtonDriveCache { + + /** + * Re-creates the whole persistent cache. + * + * The SDK can call this when there is some inconsistency and it is better + * to start from scratch rather than fix it. + */ + purge(): Promise, + + /** + * Adds or updates entity in the local database. + * + * The `data` doesn't include any keys. It is up to the client store this + * information privately. + * + * The `tags` is an object that should be stored properly for fast look-up. + * The SDK provides list of used tags by the SDK in the {@link "constructor"}. + * + * @example Usage by the SDK + * ```ts + * await cache.setEntity("node-abc42", "{ node abc42 serialised data }", { "parentUid": "abc123", "shared": "withMe" }); + * await Array.fromAsync(cache.iterateEntitiesByTag("parentUid", "abc123")); // returns ["node-abc42"] + * await cache.getEntity("node-abc42"); // returns "{ node abc42 serialised data }" + * await Array.fromAsync(cache.iterateEntities(["node-abc42"])); // returns ["{ node abc42 serialised data }"] + * ``` + * + * @example Stored data + * ```json + * { + * type: "node", + * version: 1, + * internal: { + * isStale, + * claimedDigests, + * // ... + * } + * node: { + * // same as node entity, here some example + * uid, + * parentUid, + * // ... + * } + * } + * ``` + * + * @param uid - UID is internal ID controlled by the SDK. It combines type and ID of the entity. + * @param data - Serialised JSON object controlled by the SDK. It is not intended for use outside of the SDK. + * @param tags - Clear metadata about the entity used for filtering. It is intended to store efficiently for fast look-up. + * @throws Exception if `key` from `tags` is not one of the tag keys provided from `usedTagKeysBySDK` in constructor. + */ + setEntity(uid: string, data: string, tags?: { [ key: string ]: string }): Promise, + + /** + * Returns the data of the entity stored locally. + * + * @throws Exception if entity is not present. + */ + getEntity(uid: string): Promise, + + /** + * Generator providing the data of the entities stored locally for given + * list of UIDs. + * + * No exception is thrown when data is missing. + */ + iterateEntities(uids: string[]): AsyncGenerator, + + /** + * Generator providing the data of the entities stored locally for given + * filter option. + * + * No exception is thrown when data is missing. + * + * @example Usage by the SDK + * ```ts + * await cache.setEntity("node-abc42", "{ node abc42 serialised data }", { "parentUid": "abc123", "shared": "withMe" }); + * await Array.fromAsync(cache.iterateEntitiesByTag("parentUid", "abc123")); // returns ["node-abc42"] + * ``` + * + * @param key - The tag key, for example `parentUid` + * @param value - The tag value, for example `"abc123"` + * @throws Exception if `key` is not one of the tag keys provided from `usedTagKeysBySDK` in constructor + */ + iterateEntitiesByTag(key: string, value: string): AsyncGenerator, + + /** + * Removes completely the entity stored locally from the database. + * + * It is no-op if entity is not present. + */ + removeEntities(uids: string[]): Promise, +} + +export type EntityResult = {uid: string, ok: true, data: string} | {uid: string, ok: false, error: string}; diff --git a/js/sdk/src/cache/memoryCache.test.ts b/js/sdk/src/cache/memoryCache.test.ts new file mode 100644 index 00000000..80c8aabb --- /dev/null +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -0,0 +1,120 @@ +import { MemoryCache } from "./memoryCache"; + +describe('MemoryCache', () => { + let cache: MemoryCache; + + beforeEach(() => { + cache = new MemoryCache(['tag1', 'tag2']); + + cache.setEntity('uid1', 'data1', { tag1: 'hello' }); + cache.setEntity('uid2', 'data2', { tag2: 'world' }); + cache.setEntity('uid3', 'data3'); + }); + + it('should store and retrieve an entity', async () => { + const uid = 'newuid'; + const data = 'newdata'; + + await cache.setEntity(uid, data); + const result = await cache.getEntity(uid); + + expect(result).toBe(data); + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + const uid = 'newuid'; + + try { + await cache.getEntity(uid); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should iterate over entities', async () => { + const results = []; + for await (const result of cache.iterateEntities(['uid1', 'uid2', 'uid100'])) { + results.push(result); + } + + expect(results).toEqual([ + { uid: 'uid1', ok: true, data: 'data1' }, + { uid: 'uid2', ok: true, data: 'data2' }, + { uid: 'uid100', ok: false, error: 'Error: Entity not found' }, + ]); + }); + + it('should iterate over entities by tag', async () => { + const results = []; + for await (const result of cache.iterateEntitiesByTag('tag1', 'hello')) { + results.push(result); + } + + expect(results).toEqual([ + { uid: 'uid1', ok: true, data: 'data1' }, + ]); + }); + + it('should iterate over entities by empty tag', async () => { + const results = []; + for await (const result of cache.iterateEntitiesByTag('tag1', 'nonexistent')) { + results.push(result); + } + + expect(results).toEqual([]); + }); + + it('should iterate over entities with concurrent changes to the same set', async () => { + const iterator = cache.iterateEntities(['uid1', 'uid2', 'uid3']); + + const results: string[] = []; + const { value: { uid: uid1 } } = await iterator.next(); + results.push(uid1); + cache.removeEntities([uid1]); + + let value = await iterator.next(); // uid2 + results.push(value.value.uid); + + value = await iterator.next(); // uid3 + results.push(value.value.uid); + + expect(results).toEqual(['uid1', 'uid2', 'uid3']); + }); + + it('should remove entities', async () => { + await cache.removeEntities(['uid1', 'uid3']); + + const results = []; + for await (const result of cache.iterateEntities(['uid1', 'uid2', 'uid3'])) { + results.push(result); + } + + expect(results).toEqual([ + { uid: 'uid1', ok: false, error: 'Error: Entity not found' }, + { uid: 'uid2', ok: true, data: 'data2' }, + { uid: 'uid3', ok: false, error: 'Error: Entity not found' }, + ]); + + const results2 = []; + for await (const result of cache.iterateEntitiesByTag('tag1', 'hello')) { + results2.push(result); + } + expect(results2).toEqual([]); + }); + + it('should purge the cache', async () => { + await cache.purge(); + + const results = []; + for await (const result of cache.iterateEntities(['uid1', 'uid2', 'uid3'])) { + results.push(result); + } + + expect(results).toEqual([ + { uid: 'uid1', ok: false, error: 'Error: Entity not found' }, + { uid: 'uid2', ok: false, error: 'Error: Entity not found' }, + { uid: 'uid3', ok: false, error: 'Error: Entity not found' }, + ]); + }); +}); diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts new file mode 100644 index 00000000..edb8b6b5 --- /dev/null +++ b/js/sdk/src/cache/memoryCache.ts @@ -0,0 +1,94 @@ +import type { ProtonDriveCache, EntityResult } from './interface.js'; + +type KeyValueCache = { [ uid: string ]: string }; +type TagsCache = { [ key: string ]: { [ value: string ]: string[] } }; + +/** + * In-memory cache implementation for Proton Drive SDK. + * + * This cache is not persistent and is intended for mostly for testing or + * development only. It is not recommended to use this cache in production + * environments. + */ +export class MemoryCache implements ProtonDriveCache { + private entities: KeyValueCache; + private entitiesByTag: TagsCache; + + constructor(usedTagKeysBySDK: string[]) { + this.entities = {}; + this.entitiesByTag = usedTagKeysBySDK.reduce((acc, key) => { + acc[key] = {}; + return acc; + }, {} as TagsCache); + } + + async purge() { + this.entities = {}; + } + + async setEntity(uid: string, data: string, tags?: { [ key: string ]: string }) { + this.entities[uid] = data; + if (tags) { + for (const key in tags) { + const value = tags[key]; + const tag = this.entitiesByTag[key]; + if (!tag) { + throw Error('Tag is not recognised'); + } + if (!tag[value]) { + tag[value] = []; + } + tag[value].push(uid); + } + } + } + + async getEntity(uid: string) { + const data = this.entities[uid]; + if (!data) { + throw Error('Entity not found'); + } + return data; + } + + async *iterateEntities(uids: string[]): AsyncGenerator { + for (const uid of uids) { + try { + const data = await this.getEntity(uid); + yield { uid, ok: true, data }; + } catch (error) { + yield { uid, ok: false, error: `${error}` }; + } + } + } + + async *iterateEntitiesByTag(key: string, value: string): AsyncGenerator { + const tag = this.entitiesByTag[key]; + if (!tag) { + throw Error('Tag is not recognised'); + } + + const uids = tag[value]; + if (!uids) { + return; + } + + // Pass copy of UIDs so concurrent changes to the cache do not affect + // results from iterating entities. + yield* this.iterateEntities([...uids]); + } + + async removeEntities(uids: string[]) { + for (const uid of uids) { + delete this.entities[uid]; + Object.entries(this.entitiesByTag).forEach(([ key, tag ]) => { + Object.entries(tag).forEach(([ value, uids ]) => { + const index = uids.indexOf(uid); + if (index !== -1) { + uids.splice(index, 1); + } + }); + }); + } + } +}; diff --git a/js/sdk/src/config.ts b/js/sdk/src/config.ts new file mode 100644 index 00000000..129dbc22 --- /dev/null +++ b/js/sdk/src/config.ts @@ -0,0 +1,10 @@ +import { ProtonDriveConfig } from './interface/index.js'; + +export function getConfig(config?: ProtonDriveConfig) { + return { + baseUrl: config?.baseUrl || 'https://drive.proton.me/api', + language: config?.language || 'en', + // TODO: add defaults for all fields + ...config, + }; +} diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts new file mode 100644 index 00000000..39fa8537 --- /dev/null +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -0,0 +1,185 @@ +import { OpenPGPCrypto, DriveCrypto, PrivateKey, PublicKey, SessionKey } from './interface.js'; + +/** + * See interface for more info. + */ +export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { + async function generateKey(encryptionKeys: PrivateKey[], signingKey: PrivateKey) { + const passphrase = openPGPCrypto.generatePassphrase(); + const [{ privateKey, armoredKey }, sessionKey] = await Promise.all([ + openPGPCrypto.generateKey(passphrase), + openPGPCrypto.generateSessionKey(encryptionKeys), + ]); + + const { armoredPassphrase, armoredPassphraseSignature } = await encryptPassphrase( + passphrase, + sessionKey, + encryptionKeys, + signingKey, + ); + + return { + encrypted: { + armoredKey, + armoredPassphrase, + armoredPassphraseSignature, + }, + decrypted: { + passphrase, + key: privateKey, + sessionKey, + }, + }; + }; + + async function encryptPassphrase( + passphrase: string, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) { + const { armoredData: armoredPassphrase, armoredSignature: armoredPassphraseSignature } = await openPGPCrypto.encryptAndSignDetachedArmored( + new TextEncoder().encode(passphrase), + sessionKey, + encryptionKeys, + signingKey, + ); + + return { + armoredPassphrase, + armoredPassphraseSignature, + }; + } + + async function decryptKey( + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + decryptionKeys: PrivateKey[], + verificationKeys: PublicKey[], + ) { + const sessionKey = await openPGPCrypto.decryptSessionKey( + armoredPassphrase, + decryptionKeys, + ); + + const { data: decryptedPassphrase, verified } = await openPGPCrypto.decryptArmoredAndVerifyDetached( + armoredPassphrase, + armoredPassphraseSignature, + sessionKey, + verificationKeys, + ); + + const passphrase = new TextDecoder().decode(decryptedPassphrase); + + const key = await openPGPCrypto.decryptKey( + armoredKey, + passphrase, + ); + return { + passphrase, + key, + sessionKey, + verified, + }; + } + + async function encryptSignature( + signature: Uint8Array, + encryptionKey: PrivateKey, + sessionKey: SessionKey, + ) { + const { armoredData: armoredSignature } = await openPGPCrypto.encryptArmored( + signature, + sessionKey, + [encryptionKey], + ); + return { + armoredSignature, + } + } + + async function generateHashKey( + encryptionAndSigningKey: PrivateKey, + ) { + // Once all clients can use non-ascii bytes, switch to simple + // generating of random bytes without encoding it into base64: + //const passphrase crypto.getRandomValues(new Uint8Array(32)); + const passphrase = openPGPCrypto.generatePassphrase(); + const hashKey = new TextEncoder().encode(passphrase); + + const { armoredData: armoredHashKey } = await openPGPCrypto.encryptAndSignArmored( + hashKey, + [encryptionAndSigningKey], + encryptionAndSigningKey, + ); + return { + armoredHashKey, + hashKey, + } + } + + async function encryptNodeName( + nodeName: string, + encryptionKey: PrivateKey, + signingKey: PrivateKey, + ) { + const { armoredData: armoredNodeName } = await openPGPCrypto.encryptAndSignArmored( + new TextEncoder().encode(nodeName), + [encryptionKey], + signingKey, + ); + return { + armoredNodeName, + } + } + + async function decryptNodeName( + armoredNodeName: string, + decryptionKey: PrivateKey, + verificationKeys: PublicKey[], + ) { + const { data: name, verified } = await openPGPCrypto.decryptArmoredAndVerify( + armoredNodeName, + [decryptionKey], + verificationKeys, + ); + return { + name: new TextDecoder().decode(name), + verified, + } + } + + async function decryptNodeHashKey( + armoredHashKey: string, + decryptionAndVerificationKey: PrivateKey, + extraVerificationKeys: PublicKey[], + ) { + // In the past, we had misunderstanding what key is used to sign hash + // key. Originally, it meant to be the node key, which web used for all + // nodes besides the root one, where address key was used instead. + // Similarly, iOS or Android used address key for all nodes. Latest + // versions should use node key in all cases, but we accept also + // address key. Its still signed with a valid key. + const { data: hashKey, verified } = await openPGPCrypto.decryptArmoredAndVerify( + armoredHashKey, + [decryptionAndVerificationKey], + [decryptionAndVerificationKey, ...extraVerificationKeys], + ); + return { + hashKey, + verified, + }; + } + + return { + generateKey, + encryptPassphrase, + decryptKey, + encryptSignature, + generateHashKey, + encryptNodeName, + decryptNodeName, + decryptNodeHashKey, + } +} diff --git a/js/sdk/src/crypto/index.ts b/js/sdk/src/crypto/index.ts new file mode 100644 index 00000000..68835d3c --- /dev/null +++ b/js/sdk/src/crypto/index.ts @@ -0,0 +1,5 @@ +export type { DriveCrypto, OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interface'; +export { VERIFICATION_STATUS } from './interface'; +export { driveCrypto } from './driveCrypto'; +export { openPGPCrypto } from './openPGPCrypto'; +export { serializePrivateKey, deserializePrivateKey, serializeSessionKey, deserializeSessionKey, serializeHashKey, deserializeHashKey } from './openPGPSerialisation'; diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts new file mode 100644 index 00000000..2502364d --- /dev/null +++ b/js/sdk/src/crypto/interface.ts @@ -0,0 +1,275 @@ +// TODO: Re-export them from openpgp/CryptoProxy directly. +// Depeding on openpgp requires additional setup for tests, so we can't do it yet. +export type PrivateKey = { + armor(): string; +}; + +export type PublicKey = { + armor(): string; +}; + +export type SessionKey = { + data: Uint8Array, + algorithm: string, + aeadAlgorithm?: string, +}; + +export enum VERIFICATION_STATUS { + NOT_SIGNED = 0, + SIGNED_AND_VALID = 1, + SIGNED_AND_INVALID = 2 +} + +/** + * Drive crypto layer to provide general operations for Drive crypto. + * + * This layer focuses on providing general Drive crypto functions. Only + * high-level functions that are required on multiple places should be + * peresent. E.g., no specific implementation how keys are encrypted, + * but we do share same key generation across shares and nodes modules, + * for example, which we can generelise here and in each module just + * call with specific arguments. + */ +export interface DriveCrypto { + /** + * It generates passphrase and key that is encrypted with the + * generated passphrase. + * + * `encrpytionKeys` are used to generate session key, which is + * also used to encrypt the passphrase. The encrypted passphrase + * is signed with `signingKey`. + * + * @returns Object with: + * - encrypted (armored) data (key, passphrase and passphrase + * signature) for sending to the server + * - decrypted data (key, sessionKey) for crypto usage + */ + generateKey: ( + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) => Promise<{ + encrypted: { + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + }, + decrypted: { + passphrase: string, + key: PrivateKey, + sessionKey: SessionKey, + }, + }>, + + /** + * It encrypts passphrase with provided session and encryption keys. + * This should be used only for re-encrypting the passphrase with + * different key (e.g., moving the node to different parent). + * + * @returns Object with armored passphrase and passphrase signature. + */ + encryptPassphrase: ( + passphrase: string, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) => Promise<{ + armoredPassphrase: string, + armoredPassphraseSignature: string, + }>, + + /** + * It decrypts key generated via `generateKey`. + * + * Armored data are passed from the server. `decryptionKeys` are used + * to decrypt the session key from the `armoredPassphrase`. Then the + * session key is used with `verificationKeys` to decrypt and verify + * the passphrase. Finally, the armored key is decrypted. + * + * Note: The function doesn't throw in case of verification issue. + * You have to read `verified` result and act based on that. + * + * @returns key and sessionKey for crypto usage, and verification status + */ + decryptKey: ( + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + decryptionKeys: PrivateKey[], + verificationKeys: PublicKey[], + ) => Promise<{ + passphrase: string, + key: PrivateKey, + sessionKey: SessionKey, + verified: VERIFICATION_STATUS, + }>, + + /** + * It encrypts and armors signature with provided session and encryption keys. + */ + encryptSignature: ( + signature: Uint8Array, + encryptionKey: PrivateKey, + sessionKey: SessionKey, + ) => Promise<{ + armoredSignature: string, + }>, + + /** + * It generates random 32 bytes that are encrypted and signed with + * the provided key. + */ + generateHashKey: ( + encryptionAndSigningKey: PrivateKey, + ) => Promise<{ + armoredHashKey: string, + hashKey: Uint8Array, + }>, + + /** + * It converts node name into bytes array and encrypts and signs + * with provided keys. + */ + encryptNodeName: ( + nodeName: string, + encryptionKey: PrivateKey, + signingKey: PrivateKey, + ) => Promise<{ + armoredNodeName: string, + }>, + + /** + * It decrypts armored node name and verifies embeded signature. + * + * Note: The function doesn't throw in case of verification issue. + * You have to read `verified` result and act based on that. + */ + decryptNodeName: ( + armoredNodeName: string, + encryptionKey: PrivateKey, + verificationKeys: PublicKey[], + ) => Promise<{ + name: string, + verified: VERIFICATION_STATUS, + }>, + + /** + * It decrypts armored node hash key and verifies embeded signature. + * + * Note: The function doesn't throw in case of verification issue. + * You have to read `verified` result and act based on that. + */ + decryptNodeHashKey: ( + armoredNodeName: string, + decryptionAndVerificationKey: PrivateKey, + extraVerificationKeys: PublicKey[], + ) => Promise<{ + hashKey: Uint8Array, + verified: VERIFICATION_STATUS, + }>, +} + +/** + * OpenPGP crypto layer to provide necessary PGP operations for Drive crypto. + * + * This layer focuses on providing general openPGP functions. Every operation + * should prefer binary input and output. Ideally, armoring should be done + * later in serialisation step, but for now, it is part of the interface to + * be somewhat compatible with current web app, and also be more efficient + * (current CryptoProxy can do encryption and armoring in one operation with + * less passing data between web workers). In the future, we want to separate + * this out of here more. + */ +export interface OpenPGPCrypto { + /** + * Generate a random passphrase. + * + * 32 random bytes are generated and encoded into a base64 string. + */ + generatePassphrase: () => string, + + generateSessionKey: (encryptionKeys: PrivateKey[]) => Promise, + + /** + * Generate a new key pair locked by a passphrase. + * + * The key pair is generated using the Curve25519 algorithm. + */ + generateKey: (passphrase: string) => Promise<{ + privateKey: PrivateKey, + armoredKey: string, + }>, + + encryptArmored: ( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + ) => Promise<{ + armoredData: string, + }>, + + encryptAndSign: ( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) => Promise<{ + encryptedData: Uint8Array, + }>, + + encryptAndSignArmored: ( + data: Uint8Array, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) => Promise<{ + armoredData: string, + }>, + + encryptAndSignDetached: ( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) => Promise<{ + encryptedData: Uint8Array, + signature: Uint8Array, + }>, + + encryptAndSignDetachedArmored: ( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) => Promise<{ + armoredData: string, + armoredSignature: string, + }>, + + decryptSessionKey: ( + armoredPassphrase: string, + decryptionKeys: PrivateKey[], + ) => Promise, + + decryptKey: ( + armoredKey: string, + passphrase: string, + ) => Promise, + + decryptArmoredAndVerify: ( + armoredData: string, + decryptionKeys: PrivateKey[], + verificationKeys: PublicKey[], + ) => Promise<{ + data: Uint8Array, + verified: VERIFICATION_STATUS, + }>, + + decryptArmoredAndVerifyDetached: ( + armoredData: string, + armoredSignature: string, + sessionKey: SessionKey, + verificationKeys: PublicKey[], + ) => Promise<{ + data: Uint8Array, + verified: VERIFICATION_STATUS, + }>, +} diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts new file mode 100644 index 00000000..16c95ee9 --- /dev/null +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -0,0 +1,227 @@ +import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; +import { uint8ArrayToBase64String } from './utils'; + +/** + * Interface matching CryptoProxy interface from client's monorepo: + * clients/packages/crypto/lib/proxy/proxy.ts. + */ +interface OpenPGPCryptoProxy { + generateKey: (options: { userIDs: { name: string }[], type: string, curve: string }) => Promise<{ privateKey: PrivateKey, publicKey: PublicKey }>, + exportPrivateKey: (options: { key: { privateKey: PrivateKey }, passphrase: string }) => Promise, + importPrivateKey: (options: { armoredKey: string, passphrase: string }) => Promise, + generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, + decryptSessionKey: (options: { armoredMessage: string, decryptionKeys: PrivateKey[] }) => Promise, + encryptMessage: OpenPGPCryptoProxyEncryptMessage, + decryptMessage: OpenPGPCryptoProxyDecryptMessage, +} + +interface OpenPGPCryptoProxyEncryptMessage { + (options: { textData?: string, binaryData?: Uint8Array, sessionKey?: SessionKey, signingKeys?: PrivateKey, encryptionKeys?: PublicKey[], detached?: boolean }): Promise<{ message: string, signature: string }>; + (options: { format: 'binary', binaryData: Uint8Array, sessionKey: SessionKey, signingKeys: PrivateKey, encryptionKeys?: PublicKey[], detached: boolean }): Promise<{ message: Uint8Array, signature: Uint8Array }>; +} +interface OpenPGPCryptoProxyDecryptMessage { + (options: { armoredMessage: string, signature: string, sessionKeys: SessionKey, verificationKeys: PublicKey[] }): Promise<{ data: string, verified: VERIFICATION_STATUS }>; + (options: { format: 'binary', armoredMessage?: string, binaryMessage?: Uint8Array, signature?: string, binarySignature?: Uint8Array, sessionKeys?: SessionKey, decryptionKeys?: PrivateKey[], verificationKeys: PublicKey[] }): Promise<{ data: Uint8Array, verified: VERIFICATION_STATUS }>; +} + +/** + * See interface for more info. + */ +export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { + function generatePassphrase(): string { + const value = crypto.getRandomValues(new Uint8Array(32)); + return uint8ArrayToBase64String(value); + } + + async function generateSessionKey(encryptionKeys: PrivateKey[]) { + return cryptoProxy.generateSessionKey({ recipientKeys: encryptionKeys }); + } + + async function generateKey(passphrase: string) { + const key = await cryptoProxy.generateKey({ + userIDs: [{ name: 'Drive key' }], + type: 'ecc', + curve: 'ed25519Legacy', + }); + + const armoredKey = await cryptoProxy.exportPrivateKey({ + key, + passphrase, + }); + + return { + armoredKey, + privateKey: key.privateKey, + }; + } + + async function encryptArmored( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + ) { + const { message: armoredData } = await cryptoProxy.encryptMessage({ + binaryData: data, + sessionKey, + encryptionKeys, + }); + return { + armoredData, + } + } + + async function encryptAndSign( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) { + const { message: encryptedData } = await cryptoProxy.encryptMessage({ + binaryData: data, + sessionKey, + signingKeys: signingKey, + encryptionKeys, + format: 'binary', + detached: false, + }); + return { + encryptedData + }; + } + + async function encryptAndSignArmored( + data: Uint8Array, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) { + const { message: armoredData } = await cryptoProxy.encryptMessage({ + binaryData: data, + encryptionKeys, + signingKeys: signingKey, + detached: false, + }); + return { + armoredData + }; + } + + async function encryptAndSignDetached( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) { + const { message: encryptedData, signature } = await cryptoProxy.encryptMessage({ + binaryData: data, + sessionKey, + signingKeys: signingKey, + encryptionKeys, + format: 'binary', + detached: true, + }); + return { + encryptedData, + signature, + } + } + + async function encryptAndSignDetachedArmored( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ) { + const { message: armoredData, signature: armoredSignature } = await cryptoProxy.encryptMessage({ + binaryData: data, + sessionKey, + signingKeys: signingKey, + encryptionKeys, + detached: true, + }); + return { + armoredData, + armoredSignature, + } + } + + async function decryptSessionKey( + armoredPassphrase: string, + decryptionKeys: PrivateKey[], + ) { + const sessionKey = await cryptoProxy.decryptSessionKey({ + armoredMessage: armoredPassphrase, + decryptionKeys, + }); + + if (!sessionKey) { + // TODO: error type & message + throw new Error('Could not decrypt session key'); + } + + return sessionKey; + } + + async function decryptKey( + armoredKey: string, + passphrase: string, + ) { + const key = await cryptoProxy.importPrivateKey({ + armoredKey, + passphrase, + }); + return key; + } + + async function decryptArmoredAndVerify( + armoredData: string, + decryptionKeys: PrivateKey[], + verificationKeys: PublicKey[], + ) { + const { data, verified } = await cryptoProxy.decryptMessage({ + armoredMessage: armoredData, + decryptionKeys, + verificationKeys, + format: 'binary', + }); + + return { + data, + verified, + } + } + + async function decryptArmoredAndVerifyDetached( + armoredData: string, + armoredSignature: string, + sessionKey: SessionKey, + verificationKeys: PublicKey[], + ) { + const { data, verified } = await cryptoProxy.decryptMessage({ + armoredMessage: armoredData, + signature: armoredSignature, + sessionKeys: sessionKey, + verificationKeys, + format: 'binary', + }); + + return { + data, + verified, + } + } + + return { + generatePassphrase, + generateSessionKey, + generateKey, + encryptArmored, + encryptAndSign, + encryptAndSignArmored, + encryptAndSignDetached, + encryptAndSignDetachedArmored, + decryptSessionKey, + decryptKey, + decryptArmoredAndVerify, + decryptArmoredAndVerifyDetached, + } +} diff --git a/js/sdk/src/crypto/openPGPSerialisation.ts b/js/sdk/src/crypto/openPGPSerialisation.ts new file mode 100644 index 00000000..664bc2f6 --- /dev/null +++ b/js/sdk/src/crypto/openPGPSerialisation.ts @@ -0,0 +1,42 @@ +import { PrivateKey, SessionKey } from './interface'; +import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; + +export function serializePrivateKey(key: PrivateKey): string { + return key.armor(); +} + +export function deserializePrivateKey(armoredKey: string): Promise { + // TODO: Implement this with real pmcrypto/CryptoProxy. + // Depeding on openpgp requires additional setup for tests, so we can't do it yet. + // Maybe this will not be even needed if we solve serialising differently (probably we should). + //import { readPrivateKey } from 'pmcrypto'; + //return readPrivateKey({ armoredKey }); + return Promise.resolve({ + armor: () => armoredKey, + }) +} + +export function serializeSessionKey(key: SessionKey): string { + return JSON.stringify({ + ...key, + data: uint8ArrayToBase64String(key.data), + }); +} + +export function deserializeSessionKey(jsonKey: string): SessionKey { + const result = JSON.parse(jsonKey); + const data = base64StringToUint8Array(result.data); + return { + data, + algorithm: result.algorithm, + aeadAlgorithm: result.aeadAlgorithm, + } +} + +export function serializeHashKey(key: Uint8Array): string { + return uint8ArrayToBase64String(key); +} + +export function deserializeHashKey(jsonKey: string): Uint8Array { + return base64StringToUint8Array(jsonKey); +} diff --git a/js/sdk/src/crypto/utils.ts b/js/sdk/src/crypto/utils.ts new file mode 100644 index 00000000..2b874120 --- /dev/null +++ b/js/sdk/src/crypto/utils.ts @@ -0,0 +1,40 @@ +// This file has copy-pasted utilities from CryptoProxy located in Proton web clients monorepo. + +export function uint8ArrayToBase64String(array: Uint8Array) { + return encodeBase64(arrayToBinaryString(array)); +} + +export function base64StringToUint8Array(string: string){ + return binaryStringToArray(decodeBase64(string) || ''); +} + +const ifDefined = + (cb: (input: T) => R) => + (input: U) => { + return (input !== undefined ? cb(input as T) : undefined) as U extends T ? R : undefined; + }; + +const encodeBase64 = ifDefined((input: string) => btoa(input).trim()); + +const decodeBase64 = ifDefined((input: string) => atob(input.trim())); + +const arrayToBinaryString = (bytes: Uint8Array) => { + const result = []; + const bs = 1 << 14; + const j = bytes.length; + + for (let i = 0; i < j; i += bs) { + // @ts-expect-error Uint8Array treated as number[] + // eslint-disable-next-line prefer-spread + result.push(String.fromCharCode.apply(String, bytes.subarray(i, i + bs < j ? i + bs : j))); + } + return result.join(''); +}; + +const binaryStringToArray = (str: string) => { + const result = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + result[i] = str.charCodeAt(i); + } + return result; +}; diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts new file mode 100644 index 00000000..f22f1c5c --- /dev/null +++ b/js/sdk/src/index.ts @@ -0,0 +1,6 @@ +export * from './interface/index.js'; +export * from './cache/index.js'; +export { openPGPCrypto, OpenPGPCrypto } from './crypto/index.js'; +export { protonDriveClient } from './protonDriveClient.js'; +export { protonDrivePhotosClient } from './protonDrivePhotosClient.js'; +export { protonDrivePublicClient } from './protonDrivePublicClient.js'; diff --git a/js/sdk/src/interface/constructor.ts b/js/sdk/src/interface/constructor.ts new file mode 100644 index 00000000..ee2e0eff --- /dev/null +++ b/js/sdk/src/interface/constructor.ts @@ -0,0 +1,74 @@ + +import { PrivateKey, PublicKey } from '../crypto'; + +export interface ProtonDriveAccount { + getOwnPrimaryKey(): Promise<{ email: string, addressKey: PrivateKey, addressId: string, addressKeyId: string }>, + getOwnPrivateKey(addressId: string): Promise, + getOwnPrivateKeys(addressId: string): Promise, + getPublicKeys(email: string): Promise, +} + +export interface ProtonDriveHTTPClient { + fetch(request: Request, signal?: AbortSignal): Promise, +} + +export type GetLogger = (name: string) => Logger; + +export interface Logger { + debug(msg: string, ...x: any[]): void; + info(msg: string, ...x: any[]): void; + warn(msg: string, ...x: any[]): void; + error(msg: string, ...x: any[]): void; +} + +export type ProtonDriveConfig = { + baseUrl?: string, + language?: string, + observabilityEnabled?: boolean, + uploadTimeout?: number, + uploadQueueLimitItems?: number, + downloadTimeout?: number, + downloadQueueLimitItems?: number, +} + +export type MetricsShareType = 'main' | 'device' | 'shared' | 'shared_public' | 'photo'; +export type MetricsUploadErrorType = + 'free_space_exceeded' | + 'too_many_children' | + 'network_error' | + 'server_error' | + 'integrity_error' | + 'rate_limited' | + '4xx' | + '5xx' | + 'unknown'; +export type MetricsDownloadErrorType = + 'server_error' | + 'network_error' | + 'decryption_error' | + 'rate_limited' | + '4xx' | + '5xx' | + 'unknown'; + +export interface Metrics { + uploadSucceeded(shareType: MetricsShareType, retry: boolean, uploadedSize: number, fileSize: number): void, + uploadFailed(shareType: MetricsShareType, retry: boolean, uploadedSize: number, fileSize: number, error: MetricsUploadErrorType): void, + + downloadSucceeded(shareType: MetricsShareType, retry: boolean, downloadedSize: number, fileSize: number): void, + downloadFailed(shareType: MetricsShareType, retry: boolean, downloadedSize: number, fileSize: number, error: MetricsDownloadErrorType): void, + + decryptionFailed( + shareType: MetricsShareType, + entity: 'share' | 'node' | 'content', + fromBefore2024?: boolean + ): void, + varificationFailed( + shareType: MetricsShareType, + verificationKey: 'ShareAddress' | 'NameSignatureEmail' | 'SignatureEmail' | 'NodeKey' | 'other', + addressMatchingDefaultShare?: boolean, + fromBefore2024?: boolean + ): void, + + numberOfVolumeEventsSubscriptionsChanged(number: number): void, +} diff --git a/js/sdk/src/interface/devices.ts b/js/sdk/src/interface/devices.ts new file mode 100644 index 00000000..89011ef9 --- /dev/null +++ b/js/sdk/src/interface/devices.ts @@ -0,0 +1,16 @@ +import { Result } from './result.js'; + +export type Device = { + uid: string, + name: Result, + rootFolderUid: string, +} + +export type DeviceOrUid = Device | string; + +export interface Devices { + iterateDevices(signal?: AbortSignal): AsyncGenerator, + createDevice(name: string): Promise, + renameDevice(deviceOrUid: DeviceOrUid, name: string): Promise, + deleteDevice(deviceOrUid: DeviceOrUid): Promise, +} diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts new file mode 100644 index 00000000..23016535 --- /dev/null +++ b/js/sdk/src/interface/download.ts @@ -0,0 +1,23 @@ +import { NodeOrUid } from './nodes.js'; +import { ThumbnailType } from './upload.js'; + +export interface Download { + getFileDownloader(node: NodeOrUid, signal?: AbortSignal): Promise, + + iterateThumbnails(nodeUids: NodeOrUid[], thumbnailType: ThumbnailType, signal?: AbortSignal): AsyncGenerator<{ + nodeUid: string, + thumbnail: Uint8Array, + }>, +} + +export interface FileDownloader { + getClaimedSizeInBytes(): number, + writeToStream(streamFactory: WritableStream | any, onProgress: (uploadedBytes: number) => void): DownloadController, + unsafeWriteToStream(streamFactory: WritableStream | any, onProgress: (uploadedBytes: number) => void): DownloadController, +} + +export interface DownloadController { + pause(): void, + resume(): void, + completion(): Promise, +} diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts new file mode 100644 index 00000000..78b146ca --- /dev/null +++ b/js/sdk/src/interface/events.ts @@ -0,0 +1,42 @@ +import { Device } from './devices.js'; +import { NodeEntity, NodeOrUid } from './nodes.js'; + +export interface Events { + subscribeToRemoteDataUpdates(): void, + + subscribeToDevices(callback: DeviceEventCallback): () => void, + subscribeToSharedNodesByMe(callback: NodeEventCallback): () => void, + subscribeToSharedNodesWithMe(callback: NodeEventCallback): () => void, + subscribeToTrashedNodes(callback: NodeEventCallback): () => void, + subscribeToChildren(parentNodeUid: NodeOrUid, callback: NodeEventCallback): () => void, + + onMessage(eventName: SDKEvent, callback: () => void): () => void, +} + +export type DeviceEventCallback = (deviceEvent: DeviceEvent) => void; +export type NodeEventCallback = (nodeEvent: NodeEvent) => void; + +export type NodeEvent = { + type: 'update', + uid: string, + node: NodeEntity, +} | { + type: 'remove', + uid: string, +} + +export type DeviceEvent = { + type: 'update', + uid: string, + device: Device, +} | { + type: 'remove', + uid: string, +} + +export enum SDKEvent { + TransfersPaused = "transfersPaused", + TransfersResumed = "transfersResumed", + SpeedLimited = "speedLimited", + SpeedResumed = "speedResumed", +} diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts new file mode 100644 index 00000000..23f6085a --- /dev/null +++ b/js/sdk/src/interface/index.ts @@ -0,0 +1,35 @@ +import { ProtonDriveCache } from '../cache'; +import { OpenPGPCrypto } from '../crypto'; +import { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig, GetLogger, Metrics } from './constructor'; +import { Devices } from './devices'; +import { Download } from './download'; +import { Events } from './events'; +import { Nodes, NodesManagement, TrashManagement, Revisions } from './nodes'; +import { Sharing, SharingManagement } from './sharing'; +import { Upload } from './upload'; + +export type { Result } from './result'; +export { resultOk, resultError } from './result'; +export type { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig, GetLogger, Logger, Metrics, MetricsShareType, MetricsUploadErrorType, MetricsDownloadErrorType } from './constructor'; +export type { Device, DeviceOrUid } from './devices'; +export type { FileDownloader, DownloadController } from './download'; +export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; +export type { NodeEntity, InvalidNameError, UnverifiedAuthorError, AnonymousUser, Revision, NodeOrUid, RevisionOrUid, NodesResults, NodeErrorResult } from './nodes'; +export { NodeType, MemberRole } from './nodes'; +export type { ProtonInvitation, NonProtonInvitation, NonProtonInvitationState, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, ShareResult } from './sharing'; +export { ShareRole } from './sharing'; +export type { Fileuploader, UploadController, Thumbnail, ThumbnailType, UploadMetadata } from './upload'; + +export interface ProtonDriveClientContructorParameters { + entitiesCache: ProtonDriveCache, + cryptoCache: ProtonDriveCache, + account: ProtonDriveAccount, + httpClient: ProtonDriveHTTPClient, + getLogger?: GetLogger, + config?: ProtonDriveConfig, + metrics?: Metrics, + openPGPCryptoModule: OpenPGPCrypto, + acceptNoGuaranteeWithCustomModules?: boolean, +}; + +export interface ProtonDriveClientInterface extends Devices, Download, Events, Nodes, NodesManagement, TrashManagement, Revisions, Sharing, SharingManagement, Upload {}; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts new file mode 100644 index 00000000..18defe23 --- /dev/null +++ b/js/sdk/src/interface/nodes.ts @@ -0,0 +1,90 @@ +import { Result } from './result.js'; + +// Note: Node is reserved by JS/DOM, thus we need exception how the entity is called +export type NodeEntity = { + uid: string, + parentUid: string, + name: Result, + author: Result, + nameAuthor: Result, + directMemberRole: MemberRole, + type: NodeType, + mimeType: string, + isShared: boolean, + createdDate: Date, // created on server date + trashedDate?: Date, + activeRevision: Result // null for folders +} + +export type InvalidNameError = { + name: string, // placeholder instead of node name + error: string, +} + +export type UnverifiedAuthorError = { + claimedAuthor?: string, + error: string, +} + +export type AnonymousUser = null; + +export enum NodeType { + File = "file", + Folder = "folder", +} + +export enum MemberRole { + Viewer = "viewer", + Editor = "editor", + Admin = "admin", + Inherited = "inherited", +} + +export type Revision = { + uid: string, + state: RevisionState, + claimedSize?: number, + claimedModificationTime?: Date, + claimedDigests: { + sha1?: string, + }, + claimedAdditionalMetadata: object, +} + +export enum RevisionState { + Active = "active", + Superseded = "superseded", +} + +export type NodeOrUid = NodeEntity | string; +export type RevisionOrUid = Revision | string; + +export interface Nodes { + getNodeUid(shareId: string, nodeId: string): Promise; // deprected right away + getMyFilesRootFolder(): Promise, + iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator, + iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, +} + +export interface NodesManagement { + createFolder(parentNodeUid: NodeOrUid, name: string): Promise, + renameNode(nodeUid: NodeOrUid, newName: string): Promise, + moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal): Promise, + trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): Promise, + restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): Promise, +} + +export interface TrashManagement { + iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator, + deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): Promise, + emptyTrash(): Promise, +} + +export interface Revisions { + iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator, + restoreRevision(revisionUid: RevisionOrUid): Promise, + deleteRevision(revisionUid: RevisionOrUid): Promise, +} + +export type NodesResults = { processedNodeIds: string[], errors: NodeErrorResult[] }; +export type NodeErrorResult = { nodeId: string, error: any }; diff --git a/js/sdk/src/interface/result.ts b/js/sdk/src/interface/result.ts new file mode 100644 index 00000000..3c460091 --- /dev/null +++ b/js/sdk/src/interface/result.ts @@ -0,0 +1,11 @@ +export type Result = +| { ok: true; value: T } +| { ok: false; error: E }; + +export function resultOk(value: T): Result { + return { ok: true, value }; +} + +export function resultError(error: E): Result { + return { ok: false, error }; +} diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts new file mode 100644 index 00000000..5801d7db --- /dev/null +++ b/js/sdk/src/interface/sharing.ts @@ -0,0 +1,106 @@ +import { Result } from './result.js'; +import { NodeEntity, NodeOrUid, MemberRole, InvalidNameError, UnverifiedAuthorError } from './nodes.js'; + +export type ProtonInvitation = { + uid: string, + nodeName: Result, + invitedDate: Date, + addedByEmail: Result, + inviteeEmail: string, + role: MemberRole, +} + +export type NonProtonInvitation = { + uid: string, + nodeName: Result, + invitedDate: Date, + addedByEmail: Result, + inviteeEmail: string, + role: MemberRole, + state: NonProtonInvitationState, +} + +export enum NonProtonInvitationState { + Pending = "pending", + UserRegistered = "userRegistered", +} + +export type Member = { + uid: string, + invitedDate: Date, + addedByEmail: Result, + inviteeEmail: string, + role: MemberRole, +} + +export type PublicLink = { + uid: string, + createDate: string, + role: MemberRole, + url: string, + password: string, + customPassword: string, + expirationDate: Date, +} + +export type Bookmark = { + uid: string, + nodeName: Result, + bookmarkedDate: Date, + rootNodeUid: string, +} + +export type ProtonInvitationOrUid = ProtonInvitation | string; +export type NonProtonInvitationOrUid = NonProtonInvitation | string; +export type BookmarkOrUid = Bookmark | string; + +export interface Sharing { + iterateInvitations(signal?: AbortSignal): Promise, + acceptInvitation(invitationUid: ProtonInvitationOrUid): Promise, + rejectInvitation(invitationUid: ProtonInvitationOrUid): Promise, + + iterateSharedNodes(signal?: AbortSignal): AsyncGenerator, + iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator, + leaveSharedNode(nodeUid: NodeOrUid): void, + + iterateBookmarks(signal?: AbortSignal): AsyncGenerator, + removeBookmark(bookmarkUid: BookmarkOrUid): Promise, +} + +export interface SharingManagement { + shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings): Promise, + unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings): Promise, + resendInvitation(invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid): Promise, +} + +export type ShareNodeSettings = { + protonUsers?: ShareMembersSettings, + nonProtonUsers?: ShareMembersSettings, + publicLink?: boolean | { + role: ShareRole, + customPassword?: string | null | undefined, + expiration?: Date | null | undefined, + } +} + +export type ShareMembersSettings = string[] | { + email: string, + role: ShareRole, +}[]; + +export enum ShareRole { + VIEW = 'view', + EDIT = 'edit', +}; + +export type ShareResult = { + protonInitations: ProtonInvitation[], + nonProtonInvitations: NonProtonInvitation[], + members: Member[], + publicLink?: PublicLink +} + +export type UnshareNodeSettings = { + users?: string[], + publicLink?: 'remove', +}; diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts new file mode 100644 index 00000000..9c292953 --- /dev/null +++ b/js/sdk/src/interface/upload.ts @@ -0,0 +1,43 @@ +import { NodeOrUid } from './nodes.js'; + +export interface Upload { + getFileUploader( + parentFolder: NodeOrUid, + name: string, + metadata: UploadMetadata, + signal?: AbortSignal + ): Promise, + + getFileRevisionUploader( + node: NodeOrUid, + metadata: UploadMetadata, + signal?: AbortSignal + ): Promise, +} + +export type UploadMetadata = { + mimeType: string, + expectedSize: number, + additionalMetadata?: object, +}; + +export interface Fileuploader { + writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController, + writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController, +} + +export interface UploadController { + pause(): void, + resume(): void, + completion(): Promise, +} + +export type Thumbnail = { + type: ThumbnailType, + thumbnail: Uint8Array, +} + +export enum ThumbnailType { + THUMBNAIL_TYPE_1 = 1, + THUMBNAIL_TYPE_2 = 2, +} diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts new file mode 100644 index 00000000..e5f55805 --- /dev/null +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -0,0 +1,67 @@ +import { ProtonDriveHTTPClient, Logger } from "../../interface/index.js"; +import { ErrorCode } from './errorCodes'; +import { apiErrorFactory, APIError } from './errors'; + +export interface DriveAPIService { + get: (url: string, signal?: AbortSignal) => Promise, + post: (url: string, data: Request, signal?: AbortSignal) => Promise, + put: (url: string, data: Request, signal?: AbortSignal) => Promise, +}; + +/** + * Provides API communication used withing the Drive SDK. + * + * The service is responsible for handling general headers, errors, conversion + * or rate limiting. + */ +export function getApiService(httpClient: ProtonDriveHTTPClient, baseUrl: string, language: string, logger?: Logger): DriveAPIService { + async function get(url: string, signal?: AbortSignal): Promise { + return makeRequest(url, 'GET', undefined, signal); + }; + + async function post(url: string, data: Request, signal?: AbortSignal): Promise { + return makeRequest(url, 'POST', data, signal); + }; + + async function put(url: string, data: Request, signal?: AbortSignal): Promise { + return makeRequest(url, 'PUT', data, signal); + }; + + // TODO: rate limit implementation + async function makeRequest(url: string, method = 'GET', data?: Request, signal?: AbortSignal) { + logger?.debug(`${method} ${url}`); + + const response = await httpClient.fetch(new Request(`${baseUrl}/${url}`, { + method: method || 'GET', + // TODO: set SDK-specific headers (accept: json, language, SDK version) + headers: new Headers({ + "Language": language, + }), + }), signal); + + if (response.ok) { + logger?.info(`${method} ${url}: ${response.status}`); + } else { + logger?.warn(`${method} ${url}: ${response.status}`); + } + + try { + const result = await response.json(); + if (!response.ok || result.Code !== ErrorCode.OK) { + throw apiErrorFactory({ response, result }); + } + return result as Response; + } catch (error: unknown) { + if (error instanceof APIError) { + throw error; + } + throw apiErrorFactory({ response }); + } + } + + return { + get, + put, + post, + }; +} diff --git a/js/sdk/src/internal/apiService/coreTypes.ts b/js/sdk/src/internal/apiService/coreTypes.ts new file mode 100644 index 00000000..f4b17758 --- /dev/null +++ b/js/sdk/src/internal/apiService/coreTypes.ts @@ -0,0 +1,24460 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/core/{_version}/addresses/allowAddressDeletion": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-addresses-allowAddressDeletion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/address/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update list of active keys per address */ + put: operations["put_core-{_version}-keys-address-active"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get mail-enabled active public keys. + * @deprecated + * @description This route returns **all the mail-enabled** public keys that + * the owner of the address is **able to decrypt**. + * + * Deprecated! Please refer to https://confluence.protontech.ch/pages/viewpage.action?pageId=157816403 to upgrade + */ + get: operations["get_core-{_version}-keys"]; + put?: never; + /** POST /keys route (Deprecated, AddressKey migration step 1.2) + * Only used for address-associated keys, otherwise this would be a backdoor way to change the mailbox password + * Does not enforce key list validation. */ + post: operations["post_core-{_version}-keys"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/address": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Creates a new AddressKeyUser instance, linked to an AddressKey instance. + * @description Locked route, only used for address-associated keys, + * otherwise this would be a backdoor way to change the mailbox password. + */ + post: operations["post_core-{_version}-keys-address"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/group": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a group key */ + post: operations["post_core-{_version}-keys-group"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Setup keys for new account, private user. + * @description Initial key setup for new private users. + */ + post: operations["post_core-{_version}-keys-setup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/{enc_id}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Delete address key. + * @deprecated + * @description Locked route + */ + put: operations["put_core-{_version}-keys-{enc_id}-delete"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/address/{enc_id}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete address key. + * @description Locked route + */ + post: operations["post_core-{_version}-keys-address-{enc_id}-delete"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/private": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update user keys for password change. + * @description Update private keys only, use for mailbox password/single password updates. + * + * This route can not be used to re-activate keys that we don't have access to, + * in that case the route "Activate Key" must be used first. + */ + put: operations["put_core-{_version}-keys-private"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/images/logo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get logo corresponding to an address or a domain. */ + get: operations["get_core-{_version}-images-logo"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/addresses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get addresses of a member. */ + get: operations["get_core-{_version}-members-{enc_id}-addresses"]; + put?: never; + /** + * Create new address. + * @description allow admins to create address (`Local@Domain`) for UserID. + * + * MEMBERS ROUTE TOO!! + * + * Response body example: + * + * ```json + * { + * "Code": 30004, + * "Error": "Domain not found", + * "Details": } + * } + * ``` + */ + post: operations["post_core-{_version}-members-{enc_id}-addresses"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-addresses"]; + put?: never; + /** + * Create new address. + * @description allow admins to create address (`Local@Domain`) for UserID. + * + * MEMBERS ROUTE TOO!! + * + * Response body example: + * + * ```json + * { + * "Code": 30004, + * "Error": "Domain not found", + * "Details": } + * } + * ``` + */ + post: operations["post_core-{_version}-addresses"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/addresses/available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Validates an address before creation (format and availability). */ + post: operations["post_core-{_version}-members-addresses-available"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/order": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Reorder user's addresses. */ + put: operations["put_core-{_version}-addresses-order"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Setup new non-subuser address. */ + post: operations["post_core-{_version}-addresses-setup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/canonical": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the canonical form of email addresses. */ + get: operations["get_core-{_version}-addresses-canonical"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a single address. */ + get: operations["get_core-{_version}-addresses-{enc_id}"]; + /** + * Update address. + * @description Update display name and/or signature. + */ + put: operations["put_core-{_version}-addresses-{enc_id}"]; + post?: never; + /** + * Delete a Disabled Address. + * @deprecated + * @description This route now edit the Address & AddressUser objects. In the future, will edit only AddressUser object. + * + * Warning - Locked route + */ + delete: operations["delete_core-{_version}-addresses-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}/addresses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a specific domain's addresses. */ + get: operations["get_core-{_version}-domains-{enc_id}-addresses"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}/claimedAddresses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get external addresses belonging to users outside the organization + * with the same domain name as the specified domain. */ + get: operations["get_core-{_version}-domains-{enc_id}-claimedAddresses"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Enable Address. + * @description This route now edit the Address & AddressUser objects. In the future, will edit only AddressUser object. + * + * Warning - Locked route + */ + put: operations["put_core-{_version}-addresses-{enc_id}-enable"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Disable Address. + * @description This route now edit the Address & AddressUser objects. In the future, will edit only AddressUser object. + * + * Warning - Locked route + */ + put: operations["put_core-{_version}-addresses-{enc_id}-disable"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Delete a Disabled Address. + * @description This route now edit the Address & AddressUser objects. In the future, will edit only AddressUser object. + * + * Warning - Locked route + */ + put: operations["put_core-{_version}-addresses-{enc_id}-delete"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/type": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Change address type. + * @description As of now it is possible only to convert an external address into a custom address when a domain has been activated. + */ + put: operations["put_core-{_version}-addresses-{enc_id}-type"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/rename/internal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Rename address keeping the keys, keeping the same clean email */ + put: operations["put_core-{_version}-addresses-{enc_id}-rename-internal"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/rename/external": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Rename unverified external addresses freely (any change is allowed) */ + put: operations["put_core-{_version}-addresses-{enc_id}-rename-external"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_addressId}/encryption": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set encryption signature flags. + * @description Allows setting "E2EE disabled" or "Do not expect signed" flags, address wide. + */ + put: operations["put_core-{_version}-addresses-{enc_addressId}-encryption"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/addresses/permissions/organization/switch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Switch an array of permissions for an array of addressIDs owned by the organization. + * @description Only custom addresses are affected. + * Having both PERMISSIONS_SEND_ALL and PERMISSIONS_SEND_ORG in the permissions array is forbidden. + * Having both PERMISSIONS_RECEIVE_ALL and PERMISSIONS_RECEIVE_ORG in the permissions array is forbidden. + */ + put: operations["put_core-{_version}-members-addresses-permissions-organization-switch"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/saml": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-members-{memberId}-saml"]; + delete: operations["delete_core-{_version}-members-{memberId}-saml"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/devices/{deviceId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_core-{_version}-members-{memberId}-devices-{deviceId}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_core-{_version}-members-{memberId}-devices"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{id}/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-{id}-devices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/devices/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-devices-pending"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/devices/{deviceId}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-members-{memberId}-devices-{deviceId}-reject"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/devices/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-members-{memberId}-devices-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-members-{enc_id}-keys"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/scim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-organizations-scim"]; + put: operations["put_core-{_version}-organizations-scim"]; + post: operations["post_core-{_version}-organizations-scim"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/user": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-keys-user"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Domains. + * @description Get all domains for this user's organization and check their DNS's + */ + get: operations["get_core-{_version}-domains"]; + put?: never; + /** + * Create Domain. + * @description Create new Domain, Return domain info if success, locked route + * + * Response body on error: + * + * ```json + * { + * "Code": 30106, + * "Error": "Domain setup failed or domain is already in use within Proton Mail", + * "Details": } + * } + * ``` + */ + post: operations["post_core-{_version}-domains"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get available domains. */ + get: operations["get_core-{_version}-domains-available"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/premium": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get premium domains. */ + get: operations["get_core-{_version}-domains-premium"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/optin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get opt-in domain if user is eligible. */ + get: operations["get_core-{_version}-domains-optin"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Domain. + * @description Get a specific domains and its check DNS + */ + get: operations["get_core-{_version}-domains-{enc_id}"]; + put?: never; + post?: never; + /** + * Delete Domain. + * @description Delete a Domain, locked route + */ + delete: operations["delete_core-{_version}-domains-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}/catchall": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set catch-all address, locked route. */ + put: operations["put_core-{_version}-domains-{enc_id}-catchall"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get information of current organization */ + get: operations["get_core-{_version}-organizations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/external/{jwt}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-groups-external-{jwt}"]; + post?: never; + delete: operations["delete_core-{_version}-groups-external-{jwt}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-groups-members"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-groups"]; + put?: never; + post: operations["post_core-{_version}-groups"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/unsubscribe/{jwt}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-groups-unsubscribe-{jwt}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-groups-{enc_id}"]; + post?: never; + delete: operations["delete_core-{_version}-groups-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/members/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_core-{_version}-groups-members-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/members/{groupMemberId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-groups-members-{groupMemberId}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/groups/members/external/{jwt}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v4-groups-members-external-{jwt}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/groups/{group_enc_id}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v4-groups-{group_enc_id}-members"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/groups/members/internal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v4-groups-members-internal"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/{enc_id}/reinvite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-groups-{enc_id}-reinvite"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/members/{groupMemberId}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-groups-members-{groupMemberId}-resume"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/invites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-invites"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/invites/unused": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-invites-unused"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/invites/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-invites-check"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all active public keys. + * @description This route returns **all the public keys** that the owner of the address is **able to decrypt**. + * + * This route replaces GET /keys. Please refer to https://confluence.protontech.ch/pages/viewpage.action?pageId=157816403 to upgrade + */ + get: operations["get_core-{_version}-keys-all"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/signedkeylists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get multiple signed key lists for different epochs */ + get: operations["get_core-{_version}-keys-signedkeylists"]; + put?: never; + /** Update signed key list. */ + post: operations["post_core-{_version}-keys-signedkeylists"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/signedkeylist": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a single signed key lists for a specific epoch */ + get: operations["get_core-{_version}-keys-signedkeylist"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/salts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get key salts. + * @description Locked route + */ + get: operations["get_core-{_version}-keys-salts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/address/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** (Migrated keys) Reactivate just an address key */ + put: operations["put_core-{_version}-keys-address-{enc_id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/address/{enc_id}/subkeys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Add subkeys to an existing keypair. */ + put: operations["put_core-{_version}-keys-address-{enc_id}-subkeys"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/signedkeylists/signature": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update signed key list signature for a specific revision. */ + put: operations["put_core-{_version}-keys-signedkeylists-signature"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/{enc_id}/primary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Make address key primary. + * @description Locked route, only used for address-associated keys, + * otherwise this could be a backdoor way to revert to an earlier mailbox password. + */ + put: operations["put_core-{_version}-keys-{enc_id}-primary"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/{enc_id}/flags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update key flags. + * @description Locked route + */ + put: operations["put_core-{_version}-keys-{enc_id}-flags"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-keys-tokens"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/user/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Reactivate inactive user key. + * @description Reactivate inactive user key by sending a key copy encrypted with current mailbox password and the list + * of address key fingerprints to reactivate. Locked route. + */ + put: operations["put_core-{_version}-keys-user-{enc_id}"]; + post?: never; + delete: operations["delete_core-{_version}-keys-user-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/private/upgrade": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upgrade private keys with obsolete or incorrect metadata. + * @description Upgrade keys from lower version to current version. Done by webclient on login. + * + * This route can not be used to re-activate keys that we don't have access to, + * in that case the route "Activate Key" must be used first. + */ + post: operations["post_core-{_version}-keys-private-upgrade"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/migrate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upgrade keys for key migration step 2 + * This route can not be used to re-activate keys that we don't have access to, + * in that case the route "Activate Key" must be used before or after. */ + post: operations["post_core-{_version}-keys-migrate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/{enc_id}/activate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** (Legacy keys) Activate newly-provisioned member address key by sending a key copy encrypted with + * current mailbox password. */ + put: operations["put_core-{_version}-keys-{enc_id}-activate"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** (Legacy keys) Activate just an address key, when access to the user key is lost */ + put: operations["put_core-{_version}-keys-{enc_id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Install a new key for each address. */ + post: operations["post_core-{_version}-keys-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Members. + * @description Get all members of user's organization + */ + get: operations["get_core-{_version}-members"]; + put?: never; + /** + * Create a new member. + * @description Locked route + * + * phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded + */ + post: operations["post_core-{_version}-members"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-members-invitations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/invitations/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Edit a pending invitation. + * @description Locked route + */ + put: operations["put_core-{_version}-members-invitations-{enc_id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Disable a member. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-disable"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Enable a member. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-enable"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/quota": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update disk space quota in bytes. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-quota"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/name": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update member name. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-name"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update member role. */ + put: operations["put_core-{_version}-members-{enc_id}-role"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/ai": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update AI entitlement for member. */ + put: operations["put_core-{_version}-members-{memberId}-ai"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/privatize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Make account private. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-privatize"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get user's member. */ + get: operations["get_core-{_version}-members-me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/me/unprivatize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get unprivatization info for self */ + get: operations["get_core-{_version}-members-me-unprivatize"]; + put?: never; + /** Accept member unprivatization */ + post: operations["post_core-{_version}-members-me-unprivatize"]; + /** Refuse unprivatization for self */ + delete: operations["delete_core-{_version}-members-me-unprivatize"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{id}/unprivatize/resend": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resend magic link email */ + post: operations["post_core-{_version}-members-{id}-unprivatize-resend"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{id}/unprivatize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request unprivatization to existing member. */ + post: operations["post_core-{_version}-members-{id}-unprivatize"]; + /** Cancel unprivatization for member */ + delete: operations["delete_core-{_version}-members-{id}-unprivatize"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a specific member. */ + get: operations["get_core-{_version}-members-{enc_id}"]; + put?: never; + post?: never; + /** + * Delete a member. + * @description Remove member, deletes user if not PM user, locked route. + */ + delete: operations["delete_core-{_version}-members-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/details": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-{enc_id}-details"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/authlog": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-{enc_id}-authlog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/require2fa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Enforce two-factor for a member based on the current organization two-factor grace period setting, locked route */ + put: operations["put_core-{_version}-members-{enc_id}-require2fa"]; + post?: never; + /** Do not enforce two-factor for a member, locked route */ + delete: operations["delete_core-{_version}-members-{enc_id}-require2fa"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/permissions/forwarding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Allow member to use Email Forwarding */ + post: operations["post_core-{_version}-members-{enc_id}-permissions-forwarding"]; + /** Forbid member to use Email Forwarding */ + delete: operations["delete_core-{_version}-members-{enc_id}-permissions-forwarding"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Add or remove Permissions field for a list of MemberIDs */ + put: operations["put_core-{_version}-members-permissions"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/keys/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Setup Member Keys. + * @description Setup new member keys, locked route. + */ + post: operations["post_core-{_version}-members-{enc_id}-keys-setup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/keys/migrate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upgrade keys for key migration step 2 + * @description This route can not be used to re-activate keys that we don't have access to, + * in that case the route "Activate Key" must be used before or after. + */ + post: operations["post_core-{_version}-members-{enc_id}-keys-migrate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/keys/signedkeylists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Update signed key lists for a subuser. */ + post: operations["post_core-{_version}-members-{enc_id}-keys-signedkeylists"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/keys/unprivatize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Unprivatize member + * @description Can be called from the background provided validation of InvitationData succeeds + */ + post: operations["post_core-{_version}-members-{enc_id}-keys-unprivatize"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/auth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Session. + * @description Login as non-private member, password route + */ + post: operations["post_core-{_version}-members-{enc_id}-auth"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get sessions route. + * @description Get active sessions. + */ + get: operations["get_core-{_version}-members-{enc_id}-sessions"]; + put?: never; + /** + * Create Session. + * @description Login as non-private member, password route + */ + post: operations["post_core-{_version}-members-{enc_id}-sessions"]; + /** + * Revoke all sessions route. + * @description Revoke all access tokens, locked. + */ + delete: operations["delete_core-{_version}-members-{enc_id}-sessions"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/sessions/{uid}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Revoke a session by UID, locked. */ + delete: operations["delete_core-{_version}-members-{enc_id}-sessions-{uid}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get organization keys. + * @description Get PGP keys of the current organization + */ + get: operations["get_core-{_version}-organizations-keys"]; + /** + * Create or replace organization keys. + * @description Replace current organization keys and member keys + */ + put: operations["put_core-{_version}-organizations-keys"]; + /** + * Create or replace organization keys. + * @description Replace current organization keys and member keys + */ + post: operations["post_core-{_version}-organizations-keys"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/keys/backup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get backup key. + * @description Get current organization backup private key, locked route. + */ + get: operations["get_core-{_version}-organizations-keys-backup"]; + /** + * Update backup key. + * @description Update current organization backup private key, locked route. + */ + put: operations["put_core-{_version}-organizations-keys-backup"]; + /** + * Update backup key. + * @description Update current organization backup private key, locked route. + */ + post: operations["post_core-{_version}-organizations-keys-backup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/name": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update organization name. + * @description Update current organization name, locked route + */ + put: operations["put_core-{_version}-organizations-name"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update organization email. + * @description Update current organization email, locked route. + */ + put: operations["put_core-{_version}-organizations-email"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/2fa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update current organization two-factor grace period setting, locked route */ + put: operations["put_core-{_version}-organizations-2fa"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/require2fa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Enforce current organization two-factor authentication for a specific group of members, locked route */ + put: operations["put_core-{_version}-organizations-require2fa"]; + post?: never; + /** Remove current organization two-factor authentication enforcement, locked route */ + delete: operations["delete_core-{_version}-organizations-require2fa"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/keys/activate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Activate organization private key. + * @description Update inactive private key with new copy encrypted with current mailbox password, locked route. + */ + put: operations["put_core-{_version}-organizations-keys-activate"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/membership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Leave organization. + * @description Lets a member delete themselves from an organization. + */ + delete: operations["delete_core-{_version}-organizations-membership"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/2fa/remind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a 2FA reminder email to all members without 2FA set. */ + post: operations["post_core-{_version}-organizations-2fa-remind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/keys/migrate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Migrate organization key. */ + post: operations["post_core-{_version}-organizations-keys-migrate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/keys/signature": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-organizations-keys-signature"]; + put: operations["put_core-{_version}-organizations-keys-signature"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/logo/{logo_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Having {enc_id} in the route allows us to cache the logo without invalidating the cache when a new logo is uploaded */ + get: operations["get_core-{_version}-organizations-logo-{logo_id}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-organizations-settings"]; + put: operations["put_core-{_version}-organizations-settings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/settings/logo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-organizations-settings-logo"]; + delete: operations["delete_core-{_version}-organizations-settings-logo"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/captcha": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Captcha page. + * @deprecated + */ + get: operations["get_core-{_version}-captcha"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/resources/captcha": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Captcha page. */ + get: operations["get_core-{_version}-resources-captcha"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/resources/zendesk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Zendesk chat. */ + get: operations["get_core-{_version}-resources-zendesk"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/setup/fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-saml-setup-fields"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/setup/xml": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-saml-setup-xml"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/setup/url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-saml-setup-url"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/configs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-configs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/configs/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-configs-{enc_id}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/configs/{enc_id}/fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-saml-configs-{enc_id}-fields"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/configs/{enc_id}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-saml-configs-{enc_id}-delete"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/sp/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-sp-info"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/edugain/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-edugain-info"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/edugain/info/{domainName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-edugain-info-{domainName}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the XML representation of the Service Provider metadata. */ + get: operations["get_core-{_version}-saml-metadata"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get general settings. */ + get: operations["get_core-{_version}-settings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update login password. Only called in 2-password mode (or onboarding to 2-password mode). */ + put: operations["put_core-{_version}-settings-password"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/password/upgrade": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upgrade Password. + * @description Upgrade login password on login if version < 4. + */ + put: operations["put_core-{_version}-settings-password-upgrade"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-email"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/email/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify associated email address. */ + post: operations["post_core-{_version}-settings-email-verify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/email/notify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Toggle email notifications. */ + put: operations["put_core-{_version}-settings-email-notify"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/email/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Enable or disable login password reset by email. */ + put: operations["put_core-{_version}-settings-email-reset"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/phone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-phone"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/phone/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify associated phone number. */ + post: operations["post_core-{_version}-settings-phone-verify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/phone/notify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Toggle phone notifications. */ + put: operations["put_core-{_version}-settings-phone-notify"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/phone/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Enable or disable login password reset by phone. */ + put: operations["put_core-{_version}-settings-phone-reset"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/locale": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-locale"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/logauth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update authentication logging. */ + put: operations["put_core-{_version}-settings-logauth"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/devicerecovery": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update device recovery enabled preference. */ + put: operations["put_core-{_version}-settings-devicerecovery"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/news": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update newsletter subscription. + * @deprecated + */ + put: operations["put_core-{_version}-settings-news"]; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch newsletter subscription. */ + patch: operations["patch_core-{_version}-settings-news"]; + trace?: never; + }; + "/core/{_version}/settings/news/external": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get newsletter subscription status as external user. */ + get: operations["get_core-{_version}-settings-news-external"]; + /** + * Update newsletter subscription as external user. + * @deprecated + */ + put: operations["put_core-{_version}-settings-news-external"]; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch newsletter subscription as external user. */ + patch: operations["patch_core-{_version}-settings-news-external"]; + trace?: never; + }; + "/core/{_version}/settings/density": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update the mail list density. */ + put: operations["put_core-{_version}-settings-density"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/invoicetext": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update invoice user-defined text. */ + put: operations["put_core-{_version}-settings-invoicetext"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/codes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Regenerate recovery codes. + * @description Replace current recovery codes with new ones. + */ + post: operations["post_core-{_version}-settings-2fa-codes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/totp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-2fa-totp"]; + /** Signup for TOTP. */ + post: operations["post_core-{_version}-settings-2fa-totp"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Disable all the 2FA methods. */ + put: operations["put_core-{_version}-settings-2fa"]; + /** Signup for TOTP. */ + post: operations["post_core-{_version}-settings-2fa"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request Reset 2FA. + * @description Reset all 2FA methods to disabled state. + */ + post: operations["post_core-{_version}-settings-2fa-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a challenge for registration of a FIDO2 credential. */ + get: operations["get_core-{_version}-settings-2fa-register"]; + put?: never; + /** Register a FIDO2 credential. */ + post: operations["post_core-{_version}-settings-2fa-register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/{credentialID}/remove": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Remove a FIDO2 credential. */ + post: operations["post_core-{_version}-settings-2fa-{credentialID}-remove"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/{credentialID}/rename": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Rename a FIDO2 credential. */ + put: operations["put_core-{_version}-settings-2fa-{credentialID}-rename"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/hide-side-panel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update HideSidePanel for the current client. */ + put: operations["put_core-{_version}-settings-hide-side-panel"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/username": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set username for external ProtonAccount. */ + put: operations["put_core-{_version}-settings-username"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/theme": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-theme"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/themetype": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-themetype"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/weekstart": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-weekstart"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/dateformat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-dateformat"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/timeformat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-timeformat"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/welcome": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-welcome"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/earlyaccess": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update BetaFlags. */ + put: operations["put_core-{_version}-settings-earlyaccess"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/flags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-flags"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/telemetry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update telemetry enabled preference. */ + put: operations["put_core-{_version}-settings-telemetry"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/crashreports": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update crash reports enabled preference. */ + put: operations["put_core-{_version}-settings-crashreports"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/highsecurity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * High Security program - enable + * @description https://confluence.protontech.ch/display/MSA/High+Security+Program + */ + post: operations["post_core-{_version}-settings-highsecurity"]; + /** + * High Security program - disable + * @description https://confluence.protontech.ch/display/MSA/High+Security+Program + */ + delete: operations["delete_core-{_version}-settings-highsecurity"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/breachalerts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Breach Alert - enable + * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting + */ + post: operations["post_core-{_version}-settings-breachalerts"]; + /** + * Breach Alert - disable + * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting + */ + delete: operations["delete_core-{_version}-settings-breachalerts"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/sessionaccountrecovery": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update session account recovery preference. */ + put: operations["put_core-{_version}-settings-sessionaccountrecovery"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/ai-assistant-flags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update setting to enable or disable AI Assistant. */ + put: operations["put_core-{_version}-settings-ai-assistant-flags"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/news/unsubscribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-settings-news-unsubscribe"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/support/schedulecall": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-support-schedulecall"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/lumo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-members-{memberId}-lumo"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/product-disabled": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update setting to enable or disable specific product for all platforms. */ + put: operations["put_core-{_version}-settings-product-disabled"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Can user delete account. + * @description > 1. Free user: delete (you’ll have to enter your password and we might want to do feedback like on web) + * + * > 2. Paid user and the only user: delete (there might be an unsubscribe first) + * + * > 3. Multi-user organization and are a proton a non-admin proton user: + * > you should be able to leave the org and delete (might not be built yet, not a current case). + * + * > 4. Multi-user organization and a proton user and an admin: + * > you need to be demoted by another admin first, then #3 applies + * + * > 5. Managed user in a multi-user organization (non-proton): you can’t delete yourself + */ + get: operations["get_core-{_version}-users-delete"]; + /** Delete self, will invalidate API access token. */ + put: operations["put_core-{_version}-users-delete"]; + post?: never; + /** Delete self, will invalidate API access token. */ + delete: operations["delete_core-{_version}-users-delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get available reset methods and account type. */ + get: operations["get_core-{_version}-users-reset"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user's info. + * @description Alternative response for user without address: + * + * ```js + * { + * "User": { + * "ID": "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==", + * "Name": "jason", + * "UsedSpace": 96691332, + * "Currency": "USD", + * "Credit": 0, + * "CreateTime": 1654615960, + * "MaxSpace": 10737418240, + * "MaxUpload": 26214400, + * "Role": 2, + * "Private": 1, + * "Subscribed": 1, + * "Services": 1, + * "Delinquent": 0, + * "Keys": [] + * }, + * "Code": 1000 + * } + * ``` + * + * Alternative response for organization admin logged in as a sub-user: + * + * ```js + * { + * "User": { + * "ID": "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==", + * "Name": "jason", + * "UsedSpace": 96691332, + * "Currency": "USD", + * "Credit": 0, + * "CreateTime": 1654615960, + * "MaxSpace": 10737418240, + * "MaxUpload": 26214400, + * "Role": 2, + * "Private": 1, + * "Subscribed": 1, + * "Services": 1, + * "Delinquent": 0, + * "OrganizationPrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----*", + * "Email": "jason@protonmail.ch", + * "DisplayName": "Jason", + * "Keys": [ + * { + * "ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==", + * "Version": 3, + * "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----*-----END PGP PRIVATE KEY BLOCK-----", // correspond to OrgPrivateKey + * "Token": "-----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE-----", // contains the organization (keypackets, signature) pair + * "Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353", // DEPRECATED + * "Primary": 1 + * } + * ] + * }, + * "Code": 1000 + * } + * ``` + */ + get: operations["get_core-{_version}-users"]; + put?: never; + /** + * Create a user or ProtonID user with a 3rd party email as username. + * @description TODO(fsalathe): Refactor this function into a service [refactor] + */ + post: operations["post_core-{_version}-users"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/external": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a user or ProtonID user with a 3rd party email as username. + * @description TODO(fsalathe): Refactor this function into a service [refactor] + */ + post: operations["post_core-{_version}-users-external"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Check user creation token validity. */ + put: operations["put_core-{_version}-users-check"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/availableExternal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check if username already taken. */ + get: operations["get_core-{_version}-users-availableExternal"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check if username already taken. */ + get: operations["get_core-{_version}-users-available"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/available/{username}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @deprecated */ + get: operations["get_core-{_version}-users-available-{username}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/direct": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Deprecated. Placeholder left in place for handling old clients. */ + get: operations["get_core-{_version}-users-direct"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/code": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a verification code. */ + post: operations["post_core-{_version}-users-code"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/lock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Lock sensitive settings for keys/organization. */ + put: operations["put_core-{_version}-users-lock"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/unlock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Unlock sensitive settings for keys/organization. */ + put: operations["put_core-{_version}-users-unlock"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Unlock password changes. */ + put: operations["put_core-{_version}-users-password"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/captcha/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get captcha (javascript) (hv1). */ + get: operations["get_core-{_version}-users-captcha-{token}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/disable/{jwt}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-users-disable-{jwt}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/vpn": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-{enc_id}-vpn"]; + /** + * Update max number of VPNs for member. + * @description Update number of maximum VPN connections, locked route. + */ + put: operations["put_core-{_version}-members-{enc_id}-vpn"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/features": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the list of the features (optionally filtered). + * @description TypeScript typing files: + * https://gitlab.protontech.ch/ProtonMail/Slim-API/-/blob/develop/bundles/FeatureBundle/tests/Mock/Feature.ts + */ + get: operations["get_core-v4-features"]; + put?: never; + /** Add a new feature definition. */ + post: operations["post_core-v4-features"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/features/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update feature configuration. */ + put: operations["put_core-v4-features-{id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/features/{featureID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a feature definition. */ + delete: operations["delete_core-v4-features-{featureID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/features/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a single feature by its code. */ + get: operations["get_core-v4-features-{code}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/features/{code}/value": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set the value of a single feature by its code. */ + put: operations["put_core-v4-features-{code}-value"]; + post?: never; + /** Clear the value of a single feature by its code. */ + delete: operations["delete_core-v4-features-{code}-value"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/features/{code}/user/value": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set the value of a single feature by its code for a given list of users (selected by ID or Username). */ + put: operations["put_core-v4-features-{code}-user-value"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set up SRP authentication request. */ + post: operations["post_core-{_version}-auth-info"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/sso/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Initiate SSO flow using token from POST /auth/info */ + get: operations["get_core-{_version}-auth-sso-{token}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/saml": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** HTTP-POST binding for SAML authentication. Only to be called by an IdP. */ + post: operations["post_core-{_version}-auth-saml"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Authenticate. */ + post: operations["post_core-{_version}-auth"]; + /** Revoke a token. */ + delete: operations["delete_core-{_version}-auth"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/jwt": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Authenticate using pre-issued JWT. */ + post: operations["post_core-{_version}-auth-jwt"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/2fa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit second factor. */ + post: operations["post_core-{_version}-auth-2fa"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/modulus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get random SRP modulus. */ + get: operations["get_core-{_version}-auth-modulus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/scopes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the current user scopes. + * @description Note that the bitmap of scopes is a string to avoid truncations of big numbers. + */ + get: operations["get_core-{_version}-auth-scopes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Refresh an expired token. + * @description Other response 200 body example: + * ```js + * { + * "Code": 1000, + * "AccessToken": "8a2575fad8788d253543957073d494c86f22f829", // Decrypted if reset scope or no keys set up + * "ExpiresIn": 360000, // DEPRECATED + * "TokenType": "Bearer", + * "Scope": "reset", // DEPRECATED + * "Scopes": ["reset"], // Can only be used to reset mailbox password + * "RefreshToken": "b894b4c4f20003f12d486900d8b88c7d68e67235" + * } + * ``` + */ + post: operations["post_core-{_version}-auth-refresh"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/cookies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set secure cookies, web app only. + * @description Cookies have lifetime of one year for persistent sessions. + * For non-persistent sessions cookie expiration is set to 0 and the client should garbage collect them at the end + * of the session. + */ + post: operations["post_core-{_version}-auth-cookies"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/credentialless": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create and authenticate a credential-less user. */ + post: operations["post_core-{_version}-auth-credentialless"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/mnemonic": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get mnemonic keyring to restore keys. + * @description Returns the mnemonic keyring and its encryption salt, to allow re-enabling user keys if a logged in user + * remembers an old mnemonic. + */ + get: operations["get_core-{_version}-settings-mnemonic"]; + /** + * Update or set mnemonic. + * @description This route allows submission of a new mnemonic or update an existing mnemonic, alongside a backup keyring. + * If a keyring already exists the keys will be merged (newer replaces older). + */ + put: operations["put_core-{_version}-settings-mnemonic"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/mnemonic/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get mnemonic keyring to restore keys. + * @description Returns the mnemonic keyring and its encryption salt, to allow re-enabling user keys in the reset flow. + */ + get: operations["get_core-{_version}-settings-mnemonic-reset"]; + put?: never; + /** + * Reset account using a mnemonic. + * @description This route accepts a new password, returns the mnemonic keyring and its encryption salt, + * to allow resetting an account. This will change the session's scopes to the regular user's scopes. + * It logs out other sessions for security reasons. + */ + post: operations["post_core-{_version}-settings-mnemonic-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/mnemonic/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Disable mnemonic for current user. + * @description To re-enable it's needed to submit a new mnemonic via PUT /settings/mnemonic. + * This route removes the PASSWORD scope from the token. + */ + post: operations["post_core-{_version}-settings-mnemonic-disable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/mnemonic/reactivate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update or set mnemonic. + * @description This route allows submission of a new mnemonic or update an existing mnemonic, alongside a backup keyring. + * If a keyring already exists the keys will be merged (newer replaces older). + * + * This route requires LOCKED scope only to allow prompting a mnemonic by default right after login. + * It will work only if the mnemonic needs to be (re) activated and is to be prompted automatically (i.e. for + * states MNEMONIC_ENABLED and MNEMONIC_OUTDATED). + */ + put: operations["put_core-{_version}-settings-mnemonic-reactivate"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/pushes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get active pushes info. + * @deprecated + * @description List of active notifications for the current logged user. + * Can be used by the clients to always know what should still be showed as active notification. + */ + get: operations["get_core-{_version}-pushes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/pushes/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get active pushes info. + * @description List of active notifications for the current logged user. + * Can be used by the clients to always know what should still be showed as active notification. + */ + get: operations["get_core-{_version}-pushes-active"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/pushes/active/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get active pushes info (using session). + * @description List of active notifications for the current logged user using the current session. + * Can be used by the clients to always know what should still be showed as active notification. + */ + get: operations["get_core-{_version}-pushes-active-session"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/pushes/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete the given push. + * @description If the session belongs to a family, the pushes for the whole session family will be deleted. + */ + delete: operations["delete_core-{_version}-pushes-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/referrals": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List current user referrals. */ + get: operations["get_core-{_version}-referrals"]; + put?: never; + /** Send referral invitation by email to a list of recipients. */ + post: operations["post_core-{_version}-referrals"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/referrals/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Current user referral status. */ + get: operations["get_core-{_version}-referrals-status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/referrals/identifiers/{identifier}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check if referrer identifier exists */ + get: operations["get_core-{_version}-referrals-identifiers-{identifier}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register device. The registering will delete any duplicate having the same (UserID, Product, DeviceToken) from + * different sessions. If the registering is done from a session already having a registered device, the existing + * device will be replaced with the new one. */ + post: operations["post_core-{_version}-devices"]; + /** + * Unregister device. + * @description > Note: Please use the `DELETE /core/v4/devices` route + */ + delete: operations["delete_core-{_version}-devices"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/betas/{client_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a specific beta registration. */ + get: operations["get_core-{_version}-betas-{client_id}"]; + /** Create or update beta registration. */ + put: operations["put_core-{_version}-betas-{client_id}"]; + post?: never; + /** Delete a specific beta registration. */ + delete: operations["delete_core-{_version}-betas-{client_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/betas": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all beta registrations. */ + get: operations["get_core-{_version}-betas"]; + put?: never; + post?: never; + /** Delete all beta registrations. */ + delete: operations["delete_core-{_version}-betas"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/geofeed/geofeed.csv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a CSV export for GeoFeed. */ + get: operations["get_core-{_version}-geofeed-geofeed-csv"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/geofeed/geofeed-public.csv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get geofeed data containing only the custom admin-set data */ + get: operations["get_core-{_version}-geofeed-geofeed-public-csv"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/load": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Placeholder route. + * @description Placeholder route for app pages and modals that are loaded by front-end JavaScript instead of + * obtained via a GET request. + */ + get: operations["get_core-{_version}-load"]; + put?: never; + /** + * Placeholder route. + * @description Placeholder route for app pages and modals that are loaded by front-end JavaScript instead of + * obtained via a GET request. + */ + post: operations["post_core-{_version}-load"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/logs/auth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get authentication logs. */ + get: operations["get_core-{_version}-logs-auth"]; + put?: never; + post?: never; + /** Delete all authentication logs. */ + delete: operations["delete_core-{_version}-logs-auth"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Send Simple Metrics. */ + get: operations["get_core-{_version}-metrics"]; + put?: never; + /** + * Send Metrics Report. + * @description The `Data` key can contain anything, that is what will be saved in the log (as context). + */ + post: operations["post_core-{_version}-metrics"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/recovery/secret": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set secret when empty. + * @description This route allows submission of new secrets when they are empty for the primary user key. + */ + post: operations["post_core-{_version}-settings-recovery-secret"]; + /** + * Reset secrets to the null state, in case the files are (suspect) compromised. + * @description To re-enable it's needed to submit new secrets. + */ + delete: operations["delete_core-{_version}-settings-recovery-secret"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/form/{portal_id}/{form_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Please refer to the Hubspot API docs for this route: https://legacydocs.hubspot.com/docs/methods/forms/submit_form */ + post: operations["post_core-{_version}-reports-form-{portal_id}-{form_id}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/bug": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Report a bug. + * @description Request Body example: + * ``` + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="OS" + * + * iOS + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="OSVersion" + * + * 8.0.3 + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Client" + * + * Web + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="ClientVersion" + * + * 2.0.0 + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="ClientType" + * + * 1 // 1 = email, 2 = VPN + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Title" + * + * My issue title + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Description" + * + * Some text here + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Username" + * + * 4w350m3h4x0r + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Email" + * + * derp@gmail.com + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Country" + * + * MK + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="ISP" + * + * Makedonski Telekom AD-Skopje + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="MyAttachment"; filename="logs.txt" + * Content-Type: text/plain + * + * {attachment contents} + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * ``` + */ + post: operations["post_core-{_version}-reports-bug"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/bug/attachments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload attachment for the ticket + * @description ``` + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="ISP" + * + * Makedonski Telekom AD-Skopje + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="MyAttachment"; filename="logs.txt" + * Content-Type: text/plain + * + * {attachment contents} + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * ``` + */ + post: operations["post_core-{_version}-reports-bug-attachments"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/bug/{ticketId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Solve ticket */ + delete: operations["delete_core-{_version}-reports-bug-{ticketId}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/abuse": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Report abuse. This is different to bug report, because the expectation + * is that the reporting user will be visiting from the public website and + * may not even be a Proton customer. The reporting user can submit + * multiple Proton accounts as potential abusers. + * @description Request Body example: + * ``` + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Category" + * + * harassment + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Description" + * + * These people have been harassing me. + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Usernames" + * + * abuser123,abuser456 + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Email" + * + * reporter@example.com + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * Content-Disposition: form-data; name="Screenshot"; filename="screenshot.png" + * Content-Type: image/png + * + * {binary data} + * ----WebKitFormBoundary7MA4YWxkTrZu0gW + * ``` + */ + post: operations["post_core-{_version}-reports-abuse"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/crash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Report a client crash. */ + post: operations["post_core-{_version}-reports-crash"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/sentry/api/{id}/{type}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Report a client crash via Sentry Proxy (new). + * @description The interface proxies request generated by a Sentry client to a configured Sentry server. + * + * This endpoint uses the new version of Sentry (https://sentry-new.protontech.ch). + * + *
+ * When configuring a Sentry client, the DSN should not be built with this URI but with: + * https://SENTRY_PUBLIC_KEY@api.protonmail.ch/core/v4/reports/sentry/{sentry_project_id} + *
+ */ + post: operations["post_core-{_version}-reports-sentry-api-{id}-{type}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/sentry/api/{id}/{type}/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Report a client crash via Sentry Proxy (new). + * @description The interface proxies request generated by a Sentry client to a configured Sentry server. + * + * This endpoint uses the new version of Sentry (https://sentry-new.protontech.ch). + * + *
+ * When configuring a Sentry client, the DSN should not be built with this URI but with: + * https://SENTRY_PUBLIC_KEY@api.protonmail.ch/core/v4/reports/sentry/{sentry_project_id} + *
+ */ + post: operations["post_core-{_version}-reports-sentry-api-{id}-{type}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/phishing": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Report a phishing email. */ + post: operations["post_core-{_version}-reports-phishing"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/spam": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Report spam. */ + post: operations["post_core-{_version}-reports-spam"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reports/cancel-plan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-reports-cancel-plan"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reset/{username}/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Validate reset token. */ + get: operations["get_core-{_version}-reset-{username}-{token}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request login reset token. */ + post: operations["post_core-{_version}-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reset/username": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send usernames to notification email. */ + post: operations["post_core-{_version}-reset-username"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/system/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-system-config"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/system/version": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-system-version"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/exception": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-tests-exception"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/error": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-tests-error"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/notice": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-tests-notice"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/memoryLeak": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Simulate a memory leak. */ + get: operations["get_core-{_version}-tests-memoryLeak"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/logger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-tests-logger"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/logger/observability": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-tests-logger-observability"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/ping": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * A "ping" route to check connectivity. + * @description More info about when to use this route: + * https://confluence.protontech.ch/display/CP/When+and+How+to+Retry+API+Requests + */ + get: operations["get_core-{_version}-tests-ping"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/version": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @deprecated */ + get: operations["get_core-{_version}-tests-version"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/tests/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Test endpoint to check streaming capabilities */ + get: operations["get_core-{_version}-tests-stream"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-update"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Gets organization invitations sent to a user. */ + get: operations["get_core-{_version}-users-invitations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/invitations/{enc_id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rejects an invitation. */ + post: operations["post_core-{_version}-users-invitations-{enc_id}-reject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/invitations/{enc_id}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Accepts an invitation. */ + post: operations["post_core-{_version}-users-invitations-{enc_id}-accept"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/validate/email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Validate email address. */ + post: operations["post_core-{_version}-validate-email"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/validate/phone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Validate phone number. */ + post: operations["post_core-{_version}-validate-phone"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verification/ownership/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get details of a given Ownership Verification. */ + get: operations["get_core-{_version}-verification-ownership-{token}"]; + put?: never; + /** Request ownership verification. */ + post: operations["post_core-{_version}-verification-ownership-{token}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verification/ownership-email/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get details of a given Ownership Verification. */ + get: operations["get_core-{_version}-verification-ownership-email-{token}"]; + put?: never; + /** Request ownership verification. */ + post: operations["post_core-{_version}-verification-ownership-email-{token}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verification/ownership-sms/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get details of a given Ownership Verification. */ + get: operations["get_core-{_version}-verification-ownership-sms-{token}"]; + put?: never; + /** Request ownership verification. */ + post: operations["post_core-{_version}-verification-ownership-sms-{token}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verification/ownership/{token}/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request ownership verification. */ + post: operations["post_core-{_version}-verification-ownership-{token}-{code}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verification/ownership-email/{token}/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request ownership verification. */ + post: operations["post_core-{_version}-verification-ownership-email-{token}-{code}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verification/ownership-sms/{token}/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request ownership verification. */ + post: operations["post_core-{_version}-verification-ownership-sms-{token}-{code}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v6/events/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v6-events-{id}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/events/latest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-events-latest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/events/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get events since ID. + * @description Get a list of models to refresh for each event type. + */ + get: operations["get_core-{_version}-events-{id}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/events/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get events since ID. + * @deprecated + * @description Get a list of models to refresh for each event type. + */ + get: operations["get_core-v4-events-{id}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/feedback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Log general user feedback. */ + post: operations["post_core-{_version}-feedback"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/get-started": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-checklist-get-started"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/paying-user": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-checklist-paying-user"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/get-started/seen-completed-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-checklist-get-started-seen-completed-list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/paying-user/hide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-checklist-paying-user-hide"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/paying-user/seen-completed-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-checklist-paying-user-seen-completed-list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/get-started/init": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-checklist-get-started-init"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/paying-user/init": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-checklist-paying-user-init"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/check-item": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-checklist-check-item"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/update-display": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-checklist-update-display"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verify/send": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a verification link. */ + post: operations["post_core-{_version}-verify-send"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verify/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Validate JWT token. */ + post: operations["post_core-{_version}-verify-validate"]; + /** Validate JWT token. */ + delete: operations["delete_core-{_version}-verify-validate"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verify/email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Trigger ownership verification using email only. */ + post: operations["post_core-{_version}-verify-email"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verify/phone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Trigger ownership verification on phone number only. */ + post: operations["post_core-{_version}-verify-phone"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verify/reauth/email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Re-authenticate by verifying email and add Password scope to the session if the verification is successful. */ + post: operations["post_core-{_version}-verify-reauth-email"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/verify/reauth/phone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Re-authenticate by verifying phone and add Password scope to the session if the verification is successful. */ + post: operations["post_core-{_version}-verify-reauth-phone"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all the notifications. */ + get: operations["get_core-{_version}-notifications"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/labels/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch existing label. */ + patch: operations["patch_core-v4-labels-{enc_id}"]; + trace?: never; + }; + "/core/v4/labels/by-ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Get user labels by IDs. */ + post: operations["post_core-v4-labels-by-ids"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/labels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get user's labels. */ + get: operations["get_core-{_version}-labels"]; + put?: never; + /** Create new label. */ + post: operations["post_core-{_version}-labels"]; + /** Delete multiple labels. */ + delete: operations["delete_core-{_version}-labels"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/labels/available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check Label name availability. + * @description Validates that a name is available for creation. + * For labels and folders, it must be a unique name at the root label. + * + * If a ParentID is passed, it must be for folders only and the uniqueness is checked only under that parent folder. + * + * The name can't be a reserved name like `Inbox`, `Sent`, ... + */ + get: operations["get_core-{_version}-labels-available"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/labels/order": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Change label priority. */ + put: operations["put_core-{_version}-labels-order"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/labels/order/tree/{startLabelId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-labels-order-tree-{startLabelId}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/labels/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update existing label. */ + put: operations["put_core-{_version}-labels-{id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/labels/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a label. */ + delete: operations["delete_core-{_version}-labels-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/labels/{enc_labelID}/detach": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Detach messages from the label. + * @description Remove the label from all messages that have it. It deletes the MessageLabels entries in the db. + */ + put: operations["put_core-{_version}-labels-{enc_labelID}-detach"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/images": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get image through proxy. */ + get: operations["get_core-{_version}-images"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * ProtonResponseCode + * @enum {integer} + */ + ResponseCodeSuccess: 1000; + ProtonSuccess: { + Code: components["schemas"]["ResponseCodeSuccess"]; + }; + ProtonError: { + /** ErrorCode */ + Code: number; + /** @description Error message */ + Error: string; + /** @description Error description (can be an empty object) */ + Details: Record; + }; + DriveConstants: { + /** @enum {integer} */ + BlockMaxSizeInBytes?: 5300000; + /** @enum {integer} */ + ThumbnailMaxSizeInBytes?: 65536; + /** @enum {integer} */ + DraftRevisionLifetimeInSec?: 14400; + /** @enum {integer} */ + ExtendedAttributesMaxSizeInBytes?: 65535; + /** @enum {integer} */ + UploadTokenExpirationTimeInSec?: 10800; + /** @enum {integer} */ + DownloadTokenExpirationTimeInSec?: 1800; + }; + CreateLegacyKeyInput: { + AddressID: components["schemas"]["EncryptedId"]; + PrivateKey: components["schemas"]["PGPPrivateKey"]; + /** @example 1 */ + Primary?: number | null; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + AddressForwardingID: Record; + /** @default null */ + GroupMemberID: Record | null; + /** @default null */ + Signature: string | null; + /** @default null */ + OrgToken: string | null; + /** @default null */ + OrgSignature: string | null; + /** @default null */ + Token: string | null; + }; + SetupKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrimaryKey: string; + /** + * @description RANDOMLY generated client-side + * @example + */ + KeySalt: string; + /** + * @description For setup using magic link, the primary key encrypted to the token contained in OrgActivationToken + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- + */ + OrgPrimaryUserKey: string; + /** + * @description For setup using magic link, a 32-byte random token encoded as hex and encrypted to the organization key, signed with the newly created address key. Context should be set to account.key-token.user-unprivatization + * @example -----BEGIN PGP MESSAGE-----.* + */ + OrgActivationToken: string; + AddressKeys: components["schemas"]["AddressKeyInput5"][]; + Auth: components["schemas"]["AuthInput2"]; + AddressList: components["schemas"]["KTAddressListTransformer"]; + /** + * @description base64 encoded AES-GCM encrypted secret using the DeviceSecret as key + * @example dzOtLW5psxgB8oNc8On...oFRykab4EW1ka3GtQPF9x + */ + EncryptedSecret: string; + }; + SignedKeyListInputWrapper: { + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + }; + UpdateKeyInput: { + /** @example */ + KeySalt: string; + Keys: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + }[]; + UserKeys: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + }[]; + /** + * @description If org admin (legacy scheme) that can decrypt org key + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- + */ + OrganizationKey: string; + Auth: components["schemas"]["AuthInput2"]; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode: string; + FIDO2: components["schemas"]["Fido2Input"]; + /** + * @description Required only when the session is SSO, base64 encoded AES-GCM encrypted secret using the DeviceSecret as key + * @example + */ + EncryptedSecret: string; + }; + LogoRequest: { + /** + * The percent encoded address. Either Domain or Address are required. + * @example noreply%40amazon.com + */ + Address?: string | null; + /** + * Domain to get the logo for. Either Domain or Address are required. + * @default null + * @example amazon.com + */ + Domain: string | null; + /** + * The size of the logo to be returned. + * @default 32 + * @example 64 + */ + Size: number; + /** + * The theme being used. + * @default light + * @enum {string} + */ + Mode: "light" | "dark"; + /** + * The bimi-selector of the message + * @default default + */ + BimiSelector: string; + /** + * The maximum factor an image can be scaled up. + * @default 2 + * @example 2 + * @enum {integer} + */ + MaxScaleUpFactor: 1 | 2 | 3 | 4; + /** + * Format to convert SVG images to + * @default null + * @enum {string|null} + */ + Format: "png" | null; + ComputedAddress: string; + }; + CreateAddressInput: { + /** @example me */ + Local: string; + /** + * @description Either custom domain or a protonmail domain + * @example funoccupied.com + */ + Domain: string; + /** + * @description Optional, default empty + * @example hi + */ + DisplayName: string; + /** + * @description Optional, default empty + * @example signature + */ + Signature: string; + MemberID: Record; + RequesterMemberId?: number | null; + AddressList: components["schemas"]["KTAddressListTransformer"]; + }; + ReorderAddressesInput: { + /** @description Will amend the order of addresses with the order of the corresponding AddressIDs */ + AddressIDs: string[]; + }; + AddressListInput: { + AddressList: components["schemas"]["KTAddressListTransformer"]; + }; + ChangeAddressTypeInput: { + /** + * @description 1: original, 2: Alias, 3: Custom, 4: Premium, 5: External + * @example 3 + */ + Type: number; + /** @default null */ + SignedKeyList: components["schemas"]["SignedKeyListInput"] | null; + }; + RenameUnverifiedAddressInput: { + /** @example me */ + Local: string; + /** + * @description either custom domain or a protonmail domain + * @example funoccupied.com + */ + Domain: string; + AddressList: components["schemas"]["KTAddressListTransformer"]; + AddressKeys: { + /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + }[]; + }; + UpdateAddressInput: { + /** + * @description Optional, if empty string - use default + * @example hi + */ + DisplayName: string; + /** + * @description Optional, if empty string - use default + * @example signature + */ + Signature: string; + }; + UpdateEncryptionSignatureFlagsInput: { + /** @example 1 */ + Encrypt: number; + /** @example 1 */ + Sign: number; + SignedKeyList: components["schemas"]["KTKeyList"]; + }; + AddressIdsInput: { + /** @description List of encrypted addressIDs */ + IDs: unknown[]; + /** @description Permissions bit to apply */ + Permissions: (1 | 2 | 8 | 16)[]; + }; + /** @description An encrypted ID */ + Id: string; + ResetAuthDevicesInput: { + AuthDeviceID: components["schemas"]["Id"]; + EncryptedSecret: components["schemas"]["BinaryString"]; + /** @description List of re-encrypted user keys secret to random generated secret (32 bytes, then hex encoded) */ + UserKeys: components["schemas"]["ResetAuthDevicesUserKeyDto"][]; + }; + CreateMemberKeysInput: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + AddressID: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + OrgSignature: string; + /** @example 1 */ + Primary: number; + SignedKeyList: { + /** @example JSON.stringify([{""SHA256Fingerprints"": [""5ab9c...900a"", ""e456a9...ac730""],""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 1,""Flags"": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + CreateScimTenantInput: { + /** @description The password for the SCIM tenant, used for the integration with the IdP */ + Password: string; + }; + AddNewUserKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + /** @example 1 */ + Primary: Record; + }; + CreateDomainInput: { + /** @example funoccupied.com */ + Name: string; + /** + * @description True if this domain is intended for Mail usage + * @default true + */ + AllowedForMail: boolean; + /** + * @description True if this domain is intended for SSO usage + * @default false + */ + AllowedForSSO: boolean; + }; + UpdateCatchAllAddressInput: { + /** + * @description or null to unset + * @example + */ + AddressID?: string | null; + AddressList: components["schemas"]["KTAddressListTransformer"]; + }; + OffsetPagination: { + /** The page size */ + PageSize: number; + /** + * The page index using 0-based indexing + * @default 0 + */ + Page: number; + }; + SuccessfulResponse: { + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + AddGroupMemberRequest: { + Type: components["schemas"]["GroupMemberType"]; + GroupID: components["schemas"]["Id"]; + Email: string; + AddressSignaturePacket: components["schemas"]["PGPSignature"]; + GroupMemberAddressPrivateKey?: components["schemas"]["PGPPrivateKey"] | null; + ActivationToken?: components["schemas"]["PGPMessage"] | null; + ProxyInstances: components["schemas"]["GroupProxyInstance"][]; + Token?: components["schemas"]["PGPMessage"] | null; + Signature?: components["schemas"]["PGPSignature"] | null; + }; + CreateGroupRequest: { + Email: string; + Name: string; + Permissions: components["schemas"]["GroupPermissions"]; + Flags: components["schemas"]["GroupFlags"]; + /** @default */ + Description: string; + }; + EditGroupMemberRequest: { + Permissions: components["schemas"]["GroupMemberPermissions"]; + }; + ExternalGroupMembershipsResponse: { + Memberships: components["schemas"]["ExternalGroupMembership"][]; + Total: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** @description An encrypted ID */ + EncryptedId: string; + GroupMembersResponse: { + Members: components["schemas"]["GroupMember"][]; + Total: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + InternalGroupMembershipsResponse: { + Memberships: components["schemas"]["InternalGroupMembership"][]; + Total: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateGroupRequest: { + /** @default null */ + Name: string | null; + /** + * The new email for the group address + * As of 2024-06-03, unused, currently here just to appear in docs + * Will be used in the future to update the group address, + * to allow users with an auto-generated address to change it + * E.g. VPN-only users who want to use mail at a later time + * can set up a custom email address + * @default null + */ + Email: string | null; + /** @default null */ + Permissions: components["schemas"]["GroupPermissions"] | null; + /** @default null */ + Flags: components["schemas"]["GroupFlags"] | null; + /** @default null */ + Description: string | null; + }; + AddressKeyInput: Record; + AddressKeyInput2: Record; + AddressKeyInput3: Record; + AddressKeyInput4: Record; + UpdateFlagsInput: { + /** @example 1 */ + Flags: number; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + }; + ReplaceAddressTokensInput: { + /** @description List of address key tokens encrypted to the primary user key */ + AddressKeyTokens: components["schemas"]["AddressKeyToken"][]; + }; + MigrateKeyInput: { + AddressKeys: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }[]; + SignedKeyLists: components["schemas"]["SignedKeyListInput"][]; + }; + LegacyKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + }; + ReactivateUserKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + AddressKeyFingerprints: string[]; + SignedKeyLists: components["schemas"]["SignedKeyListInput"][]; + }; + ResetUserKeyInput: { + /** + * @description Required if not logged in + * @example user_name + */ + Username: string; + /** + * @description Reset token + * @example A194YN2F9R + */ + Token: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrimaryKey: string; + /** + * @description RANDOMLY generated client-side + * @example + */ + KeySalt: string; + AddressKeys: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + AddressID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** + * @description For migrated accounts + * @example -----BEGIN PGP MESSAGE-----.* + */ + Token?: string; + /** + * @description For migrated accounts + * @example ----BEGIN PGP SIGNATURE-----.* + */ + Signature?: string; + SignedKeyList?: components["schemas"]["SignedKeyListInput"]; + }[]; + Auth: components["schemas"]["AuthInput2"]; + AddressList: components["schemas"]["KTAddressListTransformer"]; + /** @default null */ + OrgPrimaryUserKey: string | null; + /** @default null */ + OrgActivationToken: string | null; + }; + CreateMemberInput: { + /** @example Jason */ + Name: string; + /** + * @description Use only if Type is 2=MANAGED + * @example 0 + */ + Private: number; + /** @example 1073741824 */ + MaxSpace: number; + /** @example 0 */ + MaxVPN: number; + /** @description Either 1=PROTON or 2=MANAGED (default) */ + Type?: components["schemas"]["UserType"] | null; + /** + * @description Use only if type is 1=PROTON + * @example user_name + */ + Username: string; + /** @description Invitation object if created using magic link */ + Invitation?: components["schemas"]["MagicLinkInvitationInput"] | null; + Auth: components["schemas"]["AuthInfoInput"]; + /** + * @default 0 + * @enum {integer} + */ + MaxAI: 0 | 1; + /** + * @default 0 + * @enum {integer} + */ + MaxLumo: 0 | 1; + }; + CreateMemberInvitationInput: { + /** + * Format: email + * @example ein@stein.com + */ + Email: string; + /** @example 100 */ + MaxSpace: number; + }; + UpdateMemberInvitationInput: { + /** @example 100 */ + MaxSpace: number; + }; + UpdateMemberAIEntitlementInput: { + /** @enum {integer} */ + MaxAI: 0 | 1; + }; + AcceptMemberUnprivatizationInput: { + /** @description The user keys encrypted to the token contained in OrgActivationToken */ + OrgUserKeys: components["schemas"]["PGPPrivateKey"][]; + OrgActivationToken: components["schemas"]["PGPMessage"]; + }; + RequestMemberUnprivatizationInput: { + /** + * @description The invitation data + * @example {"Address":"member@internal-domain.com", "Revision":2} + */ + InvitationData: string; + InvitationSignature: components["schemas"]["PGPSignature"]; + }; + MemberManagePermissionsDto: { + /** @description List of MemberIds */ + Ids: string[]; + Permission: components["schemas"]["MemberPermission"]; + Action: components["schemas"]["MemberPermissionAction"]; + }; + UpdateMemberKeysInput: { + /** + * Format: base64 + * @description random 16 bytes + * @example cmFuZGJhc2U2NHN0cmluZw== + */ + KeySalt: string; + UserKey: components["schemas"]["UserKeyInput"]; + AddressKeys: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + AddressID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + OrgSignature?: string; + SignedKeyList?: { + /** @example JSON.stringify([{""SHA256Fingerprints"": [""5ab9c...900a"", ""e456a9...ac730""],""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 1,""Flags"": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }[]; + AddressList: components["schemas"]["KTAddressListTransformer"]; + Auth: components["schemas"]["AuthInfoInput2"]; + }; + UnprivatizeMemberInput: { + /** @deprecated */ + UserKey?: components["schemas"]["UnprivatizeMemberUserKeyDto"] | null; + /** @description All active member's user keys, with a signed and encrypted token to access them via the org key */ + UserKeys?: components["schemas"]["UnprivatizeMemberUserKeyDto"][] | null; + /** @description A token and signature for each address key to access them via the org key */ + AddressKeys: components["schemas"]["UnprivatizeMemberAddressKeyDto"][]; + }; + UpdateOrganizationKeyBackupInput: { + /** + * @description organization private key encrypted with backup password hash + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + */ + PrivateKey: string; + /** + * Format: base64 + * @description random 16 bytes + * @example cmFuZGJhc2U2NHN0cmluZw== + */ + KeySalt: string; + /** + * Format: base64 + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral: string; + /** + * Format: base64 + * @description Optional, for inline re-authentication + * @example + */ + ClientProof: string; + /** + * Format: hex + * @description Optional, for inline re-authentication + * @example + */ + SRPSession: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** + * Format: base64 + * @description clientData (base64) returned from the client authentication library + */ + ClientData?: string; + /** + * Format: base64 + * @description authenticatorData (base64) returned from the client authentication library + */ + AuthenticatorData?: string; + /** + * Format: base64 + * @description signature (base64) returned from the client authentication library + */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + UpdateOrganizationNameInput: { + /** + * @description organization name, maximum 40 characters + * @example E-Corp + */ + Name: string; + }; + UpdateOrganizationEmailInput: { + /** + * Format: email + * @description organization email, can be null + * @example contact@e-corp.com + */ + Email: string; + }; + UpdateOrganizationTwoFactorGracePeriodInput: { + /** + * @description number of seconds before 2FA enforced + * @example 86400 + */ + GracePeriod: number; + }; + ReplaceOrganizationKeysInput: { + /** + * Format: base64 + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral: string; + /** + * Format: base64 + * @description Optional, for inline re-authentication + * @example + */ + ClientProof: string; + /** + * Format: hex + * @description Optional, for inline re-authentication + * @example + */ + SRPSession: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** + * Format: base64 + * @description clientData (base64) returned from the client authentication library + */ + ClientData?: string; + /** + * Format: base64 + * @description authenticatorData (base64) returned from the client authentication library + */ + AuthenticatorData?: string; + /** + * Format: base64 + * @description signature (base64) returned from the client authentication library + */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** + * @description organization private key encrypted with backup password hash + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + */ + PrivateKey: string; + /** + * @description backup private key encrypted with backup password hash + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + */ + BackupPrivateKey: string; + /** + * Format: base64 + * @description random 16 bytes + * @example cmFuZGJhc2U2NHN0cmluZw== + */ + BackupKeySalt: string; + /** @description For legacy key users: array of UserKey and AddressKey IDs and tokens */ + Tokens: { + /** + * Format: encrypted string + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + ID?: string; + /** @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- */ + Token?: string; + }[]; + /** @description For migrated key users */ + Members: { + /** + * Format: encrypted string + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + ID?: string; + /** @description Array of UserKey IDs and tokens */ + UserKeyTokens?: { + /** + * Format: encrypted string + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + ID?: string; + /** @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- */ + Token?: string; + }[]; + /** @description Array of AddressKey IDs, tokens, and signatures */ + AddressKeyTokens?: { + /** + * Format: encrypted string + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + ID?: string; + /** @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- */ + Token?: string; + /** @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ + Signature?: string; + /** @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ + OrgSignature?: string; + }[]; + }[]; + /** + * @description Token needed to unlock the organization key, encrypted to the user key of the current user + * @default null + * @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- + */ + Token: components["schemas"]["PGPMessage"] | null; + /** + * @description Signature of the token made by the user key of the current user + * @default null + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + Signature: components["schemas"]["PGPSignature"] | null; + /** + * @description Invite all other private admins to the new key + * @default null + */ + AdminInvitations: components["schemas"]["ReplaceOrganizationKeyInvitationDto"][] | null; + /** + * @description Activate new key for all other non-private admins + * @default null + */ + AdminActivations: components["schemas"]["ReplaceOrganizationKeyActivationDto"][] | null; + }; + ActivateOrganizationKeyInput: { + /** + * @description organization private key encrypted with mailbox password hash + * @default null + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + */ + PrivateKey: string | null; + /** + * Format: base64 + * @description For passwordless key, the key packet needed to unlock the key, encrypted to the user key + * @default null + * @example TG9yZW0gaXBzdW0... + */ + TokenKeyPacket: string | null; + /** + * @description For passwordless key, signature of the token key packet + * @default null + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + Signature: string | null; + }; + MigrateOrganizationKeysInput: { + PrivateKey: components["schemas"]["PGPPrivateKey"]; + Token: components["schemas"]["PGPMessage"]; + Signature: components["schemas"]["PGPSignature"]; + /** + * @description Activate key for other active private admins + * @default null + */ + AdminInvitations: components["schemas"]["MigrateOrganizationKeyInvitationDto"][] | null; + /** + * @description Activate new key for all other non-private admins + * @default null + */ + AdminActivations: components["schemas"]["MigrateOrganizationKeyActivationDto"][] | null; + }; + UpdateOrgKeyFingerprintSignatureInput: { + Signature: components["schemas"]["PGPSignature"]; + AddressID: components["schemas"]["Id"]; + }; + OrganizationSettings: { + /** + * @description Whether to show organization name in sidebar or not + * @default false + * @example true + */ + ShowName: boolean; + /** + * @description Whether to show the Scribe writing assistant or not + * @default true + * @example true + */ + ShowScribeWritingAssistant: boolean; + /** + * @description Whether the video conferencing feature is enabled or not + * @default true + * @example true + */ + VideoConferencingEnabled: boolean; + /** @default null */ + LogoID: string | null; + }; + OrganizationLogo: { + /** + * @description The base64 encrypted logo + * @example iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjyPH+/x8ABZMCtpUrn90AAAAASUVORK5CYII= + */ + Image: string; + }; + Sso: { + /** + * @description IdP URL. Optional for eduGAIN SSO configurations + * @example https://account.buck.proton.black + */ + SSOURL: string; + /** + * @description SSOEntityID URL + * @example https://account.buck.proton.black + */ + SSOEntityID: string; + /** + * @description Blob content of the certificate. Optional for eduGAIN SSO configurations + * @example -----BEGIN CERTIFICATE-----... + */ + Certificate: string; + /** + * @description The encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== + */ + DomainID: string; + SCIMOauthClientID?: number | null; + /** + * @description Issuer ID (our side) + * @example https://sso.proton.me/sp + */ + IssuerID: string; + /** + * @description Reply (ACS) URL + * @example https://sso.proton.me/auth/saml + */ + CallbackURL: string; + /** + * @description Allowed domain name + * @example example.com + */ + AllowedDomain: string; + /** + * @description Whether this SSO configuration is enabled or not + * @default true + * @example true + */ + Enabled: boolean; + /** + * @description Whether this SSO configuration is for a regular IdP (1) or eduGain (2) + * @default 1 + * @example 1 + */ + Type: number; + /** + * @description eduGAIN affiliations allowed by the SSO configuration in case of eduGAIN setup + * @default [] + */ + EdugainAffiliations: string[]; + SsoId?: components["schemas"]["Id"] | null; + SendingSubject: boolean; + }; + SsoXml: { + /** + * @description the encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== + */ + DomainID: string; + /** + * @description Base64 encoded XML blob + * @example + */ + XML: string; + }; + SsoUrl: { + /** + * @description the encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== + */ + DomainID: string; + /** + * @description full URL to SAML metadata + * @example + */ + MetadataURL: string; + }; + PatchNewsInput: { + /** + * @description Proton Company announcements + * @default null + */ + Announcements: boolean | null; + /** + * @deprecated + * @description Proton Product announcements + * @default null + */ + Features: boolean | null; + /** + * @description Proton newsletter + * @default null + */ + Newsletter: boolean | null; + /** + * @description Proton beta announcements + * @default null + */ + Beta: boolean | null; + /** + * @description Proton for Business newsletter + * @default null + */ + Business: boolean | null; + /** + * @description Proton offers and promotions + * @default null + */ + Offers: boolean | null; + /** + * @description Proton new email notifications + * @default null + */ + NewEmailNotif: boolean | null; + /** + * @description Proton welcome emails + * @default null + */ + Onboarding: boolean | null; + /** + * @description Proton user surveys + * @default null + */ + UserSurveys: boolean | null; + /** + * @description Proton Mail and Calendar new features + * @default null + */ + InboxNews: boolean | null; + /** + * @description Proton VPN new features + * @default null + */ + VpnNews: boolean | null; + /** + * @description Proton Drive new features + * @default null + */ + DriveNews: boolean | null; + /** + * @description Proton Pass new features + * @default null + */ + PassNews: boolean | null; + /** + * @description Proton Wallet new features + * @default null + */ + WalletNews: boolean | null; + /** + * @description In app notifications + * @default null + */ + InAppNotifications: boolean | null; + }; + UpdateNewsInput: { + /** + * @description + * 16-bit bitmap + * 1: announcements + * 2: features + * 4: newsletter + * 8: beta + * 16: business + * 32: offers + * 64: new mail notification + * 128: onboarding + * 256: user surveys + * 512: inbox features + * 1024: vpn features + * 2048: drive features + * 4096: pass features + * 8192: wallet features + * The rest are currently unused. + * @example 255 + */ + News: number; + }; + UpdateHideSidePanelInput: { + /** @enum {integer} */ + HideSidePanel: 0 | 1; + }; + /** Theme */ + Theme: { + /** + * @description Which theme mode to use (auto, dark, light) + * @example 1 + */ + Mode: number; + /** + * @description What theme to use in light mode + * @example 1 + */ + LightTheme: number; + /** + * @description What theme to use in dark mode + * @example 1 + */ + DarkTheme: number; + /** + * @description Which font face to use + * @example 1 + */ + FontFace: number; + /** + * @description Which font size to use + * @example 1 + */ + FontSize: number; + /** + * @description Bitmap corresponding to which features are enabled and disabled + * @example 1 + */ + Features: number; + }; + /** SessionAccountRecoveryInput */ + SessionAccountRecoveryInput: { + /** + * @description Possible values:
- 0: disable
- 1: enable + * @example 1 + * @enum {integer} + */ + SessionAccountRecovery: 0 | 1; + }; + /** AIAssistantFlagsInput */ + AIAssistantFlagsInput: { + AIAssistantFlags: components["schemas"]["AIAssistantFlags"]; + }; + UpdateMemberLumoEntitlementInput: { + /** @enum {integer} */ + MaxLumo: 0 | 1; + }; + /** ProductDisabledInput */ + ProductDisabledInput: { + /** @description Possible values:
- 1: Mail
- 2: VPN
- 3: Calendar
- 4: Drive
- 5: Pass
- 6: Wallet */ + Product: number; + Disabled: number; + }; + UpdateScimTenantInput: { + /** @description The password for the SCIM tenant, used for the integration with the IdP. Unset or null will not modify the current password */ + Password?: string | null; + /** + * @description State of the SCIM integration: 0 for disabled, 1 for enabled + * @example 1 + */ + State: number; + }; + IdpResponseVO: { + SAMLResponse: string; + }; + CreateCredentiallessUserInput: { + /** + * @description Optional field, frontend fingerprints + * @default null + */ + Payload: { + /** + * Format: base64 + * @example ++3dreJ+cHBSeEXvkxjLCRrf1... + */ + "random-id-1"?: string; + /** + * Format: base64 + * @example Xv5df3dreJ+cHBvkxjSeEXvkx... + */ + "random-id-2"?: string; + /** + * Format: base64 + * @example + */ + "random-id-3"?: string; + /** + * Format: base64 + * @example + */ + "random-id-4"?: string; + } | null; + }; + SendInvitationsInput: { + /** @default [] */ + Recipients: string[]; + }; + RegisterDeviceInput: { + /** @example 2335fcc381ef78a20e580065...515f4e8 */ + DeviceToken: string; + Environment: components["schemas"]["Environment"]; + /** @default null */ + PublicKey: components["schemas"]["PGPPublicKey"] | null; + /** @default null */ + PingNotificationStatus: components["schemas"]["PingNotificationStatus"] | null; + /** @default null */ + PushNotificationStatus: components["schemas"]["PushNotificationStatus"] | null; + }; + UploadAttachment: { + /** + * @description Token return from create ticket api + * @example 4w350m3h4x0r + */ + Token: string; + /** @description The body of attachment */ + Body: string; + Product: components["schemas"]["Product"]; + }; + CancelPlanReport: { + /** + * @description The reason for cancellation + * @example other + */ + Reason: string; + /** @description A message describing the reason */ + Message: string; + /** @description The contact email address */ + Email: string; + /** @example iOS */ + OS: string; + /** @example 8.0.3 */ + OSVersion: string; + /** @example Safari */ + Browser: string; + /** @example 8 */ + BrowserVersion: string; + /** @example Web */ + Client: string; + /** @example 2.0.0 */ + ClientVersion: string; + /** + * @description 1: email, 2: VPN, 3: calendar, 4: drive, 5: pass + * @example 2 + */ + ClientType: number; + Tags: string[]; + }; + Stream: { + /** @default null */ + Users: components["schemas"]["EventCollectionOutput"]; + Addresses: components["schemas"]["EventCollectionOutput"]; + Settings: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + IncomingDefaults: components["schemas"]["EventCollectionOutput"]; + /** true if there is more events to pull */ + More: boolean; + /** true if all data should be refreshed */ + Refresh: boolean; + EventID: components["schemas"]["Id"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1True
0False
+ * @enum {integer} + */ + BoolInt: 1 | 0; + FeedbackVO: { + FeedbackType: string; + Score: number; + Feedback?: string | null; + }; + NotificationRequest: { + FullScreenImageSupport?: string | null; + FullScreenImageWidth?: number | null; + FullScreenImageHeight?: number | null; + SupportedFullScreenImageFormats: string[]; + Null: boolean; + }; + PatchInput: { + /** + * @description possible values: + * * - 0 => collapse and hide sub-folders + * * - 1 => expanded and show sub-folders + * @default null + * @enum {integer|null} + */ + Expanded: 1 | 0 | null; + /** + * @description possible values: + * * - 0 => no desktop/email notifications + * * - 1 => notifications, folders only + * @default null + * @enum {integer|null} + */ + Notify: 1 | 0 | null; + }; + LabelIDs: { + LabelIDs: components["schemas"]["LabelID"][]; + }; + /** Signed Key List */ + KTKeyList: { + /** + * @description Starting Epoch ID for SKL. Can be null, if the epoch is not yet released + * @example 125 + */ + MinEpochID?: number | null; + /** + * @description Ending Epoch ID for SKL. Can be null, if the epoch is not yet released + * @example 241 + */ + MaxEpochID?: number | null; + /** + * @description If epoch is not yet released this will be a future epoch ID + * @example 265 + */ + ExpectedMinEpochID?: number | null; + /** + * @description JSON-encoded content of the SKL. If null, this SKL contains an ObsolescenceToken + * @example [{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1},{""Fingerprint"": ""fde90483475164ec6353c93f767df53b0ca8395c"",""Primary"": 1,""Flags"": 3}] + */ + Data?: string | null; + /** + * @description Hex token to prove the obsolescence of the signed key list in the merkle tree or null. The first 16 characters are a committed big-endian hex-encoded unix timestamp, remaining is random + * @example 000000006243460497f838b649439b5f29c4e73014b9da096d0fe3ed + */ + ObsolescenceToken?: string | null; + /** + * @description Armored OpenPGP signature for the data. If null, proof contains an obsolescenceToken + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + Signature?: string | null; + /** + * @description Identifier of the revision version + * @example 42 + */ + Revision: number; + }; + User: { + /** @example MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA== */ + ID: string; + /** @example jason */ + Name?: string | null; + /** @example jason */ + DisplayName?: string | null; + /** @example jason@proton.me */ + Email: string; + /** @example USD */ + Currency: string; + /** @example 0 */ + Credit: number; + /** + * @description 1: Proton (full), 2: Managed, 3: External, 4: CredentialLess + * @example 0 + */ + Type: number; + /** @example 1654615966 */ + CreateTime: number; + /** + * Format: int64 + * @description Max space (in bytes) + * @example 10737418240 + */ + MaxSpace: number; + /** + * Format: int64 + * @description Max upload space (in bytes) + * @example 26214400 + */ + MaxUpload: number; + /** + * Format: int64 + * @description Used space (in bytes) + * @example 70376905 + */ + UsedSpace: number; + ProductUsedSpace: components["schemas"]["UserUsage"]; + /** @description 1 when the user's member has an AI seat, 0 otherwise */ + NumAI: number; + /** @description the number of lumo seats attributed to the user, 0 otherwise */ + NumLumo: number; + /** @example 2 */ + Role: number; + /** @example 1 */ + Private: number; + /** + * @example 1 + * @enum {integer} + */ + ToMigrate: 0 | 1; + /** + * @description + * * 0: Mnemonic is disabled, + * * 1: Mnemonic is enabled but not set, + * * 2: Mnemonic is enabled but needs to be re-activated, + * * 3: Mnemonic is enabled and set + * @example 1 + */ + MnemonicStatus: number; + /** + * @description Subscribed (bitmap): `1`: User has a mail subscription, `4`: User has a VPN subscription + * @example 5 + */ + Subscribed: number; + /** + * @description Activated services (bitmap): + * * `1`: User has the mail product activated, + * * `4`: User has the VPN activated + * @example 5 + */ + Services: number; + Delinquent: components["schemas"]["DelinquentState"]; + Keys: components["schemas"]["UserKey"]; + Flags: { + protected?: boolean; + "onboard-checklist-storage-granted"?: boolean; + "has-temporary-password"?: boolean; + "test-account"?: boolean; + "no-login"?: boolean; + "recovery-attempt"?: boolean; + sso?: boolean; + /** @description User have no or only external addresses */ + "no-proton-address"?: boolean; + }; + }; + UserKey: { + /** @example IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA== */ + ID: string; + /** @example 3 */ + Version: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + /** + * @description Deprecated! Please compute the fingerprint from the key + * @example c93f767df53b0ca8395cfde90483475164ec6353 + */ + Fingerprint: string; + /** @example 1 */ + Primary: number; + /** + * @description Inactive keys (0) are kept for reactivation only, they are not trusted, and should not be unlocked + * @example 1 + */ + Active: number; + /** + * @description Base64-encoded secret, made up of 32 random bytes + * @example 1H8EGg3J1...Qwk243hf + */ + RecoverySecret: string; + /** @example -----BEGIN PGP SIGNATURE-----... */ + RecoverySecretSignature: string; + }; + AddressUser: { + /** + * @description Encrypted address ID + * @example qmhrlFY24BhSHiFplF0B7G_cMVLi1sokaWIhfNaee6dRtdIZPYnqgI4-MpAb8h3JhOOykKv8ZsuTH8X_SrUZSg== + */ + ID: string; + /** + * @description Encrypted domain ID + * @example l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA== + */ + DomainID?: string | null; + /** @example jason@protonmail.dev */ + Email: string; + /** + * @description 0 or 1 + * @example 1 + */ + Send: number; + /** + * @description 0 or 1 + * @example 1 + */ + Receive: number; + /** + * @description Bitflag of: 1 - ReceiveAll, 2 - SendAll, 4 - AutoResponder, 8 - ReceiveOrg, 16 - SendOrg + * @example 7 + */ + Permissions: number; + /** + * @description 2 if the address is invalid, 1 if the address is internal or has been verified, otherwise 0 + * @example 1 + */ + ConfirmationState: number; + /** + * @description 0: Disabled, 1:Enabled, 2:Deleting + * @example 1 + */ + Status: number; + /** + * @description 1: Original, 2: Alias, 3: Custom, 4: Premium, 5: External + * @example 1 + */ + Type: number; + /** + * @deprecated + * @description Replaced by "Priority" + * @example 1 + */ + Order: number; + /** + * @description Ordered list, lowest first. Can start with a number > 1. + * @example 1 + */ + Priority: number; + /** + * @description Can be empty but not null + * @example D L'u, P.D. 定超 + */ + DisplayName: string; + /** + * @description Can be empty but not null + * @example hi there + */ + Signature: string; + /** + * @deprecated + * @description 0 or 1 + * @example 1 + */ + HasKeys: number; + /** + * @description True if the address is a catch-all + * @example false + */ + CatchAll: Record; + /** + * @description True if the domain's record point to Proton servers + * @example true + */ + ProtonMX: Record; + SignedKeyList: components["schemas"]["KTKeyList"]; + Keys: components["schemas"]["AddressKey"][]; + /** + * @description Bitflags representing noencrypt/nosign + * @example 48 + */ + Flags: number; + }; + /** Signed Key List */ + KTAddressListTransformer: { + /** + * @description JSON-encoded content of the SAL + * @example [{"Email": "test@example.com","Flags": 1}] + */ + Data: string; + /** + * @description The armored signature over the JSON-serialized data with the primary user key + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + Signature: string; + }; + /** LinkResponse */ + SwitchAddressesOrganizationPermissionsTransformer: { + AddressID: string; + Response: { + /** @example 13043 */ + Code?: number; + /** @example Address does not exist */ + Error?: string; + Details?: Record; + }; + }; + AuthDeviceOutput: { + ID: components["schemas"]["Id"]; + State: components["schemas"]["AuthDeviceState"]; + /** @description The device name */ + Name: string; + LocalizedClientName: components["schemas"]["TranslatedStringInterface"]; + /** @description The device platform */ + Platform?: string | null; + /** + * Format: date-time + * @description Time the device was created + */ + CreateTime: string; + /** + * Format: date-time + * @description Time the device was activated + */ + ActivateTime?: string | null; + /** + * Format: date-time + * @description Time the device was rejected + */ + RejectTime?: string | null; + /** + * Format: date-time + * @description Time the device was last used (approximately to the hour) + */ + LastActivityTime: string; + /** @description PGP message encrypted to the AddressID containing a 64-char random hex-encoded token */ + ActivationToken?: components["schemas"]["PGPMessage"] | null; + ActivationAddressID?: components["schemas"]["Id"] | null; + MemberID?: components["schemas"]["Id"] | null; + /** + * @description DeviceToken of the created device + * @example wfih0367aa7dc0359bf5c42d15a93e6c + */ + DeviceToken?: string | null; + }; + DomainTransformer: { + /** @example BKiAUbkGnUPiy2c37zjon_g== */ + ID: string; + /** @example protonvpn.ch */ + DomainName: string; + /** + * @description 1 is Proton, 2 is user-assigned Proton subdomain, 3 is custom domain + * @example 3 + */ + Type: number; + /** + * @description 0 is default, 1 is active (verified), 2 is warn (dns issue) + * @example 1 + */ + State: number; + /** @example 1556136548 */ + LastActiveTime: number; + /** @example 1446095611 */ + CheckTime: number; + /** @example 1554807818 */ + WarnTime: number; + /** @example protonmail-verification=c701a28e2bdd3358c6dda71a3008b806e41950b0 */ + VerifyCode: string; + /** + * @description 0 is default, 1 is has code but wrong, 2 is good + * @example 2 + */ + VerifyState: number; + /** + * @description 0 is default, 1 is set but no us, 2 has us but priority is wrong, 3 is good + * @example 3 + */ + MxState: number; + /** + * @description 0 is default, 1 and 2 means detected a record but wrong, 3 is good + * @example 3 + */ + SpfState: number; + /** + * @description 0 is default, 1 and 2 means detected record but wrong, 3 is good, 4 is good and relaxed policy + * @example 3 + */ + DmarcState: number; + DKIM: { + /** + * @description 0 is default, 1 and 2 means detected record but wrong, 3 means key is wrong, 4 is good, 5 is good and inherited from parent + * @example 4 + */ + State?: number; + /** @description Contains the domain's currently configured DKIM public keys and metadata */ + Config?: { + /** @example protonmail2._domainkey */ + Hostname?: string; + /** @example protonmail2.domainkey.dhgge2q6ksokiqwomdn23r6nnjjwiwblsujm6bjdnj3hhaxlktpqa.domains.proton.ch. */ + CNAME?: string; + Key?: { + /** @example BKiAUbkGnUPiy2c37zjon_g== */ + ID?: string; + /** @example protonmail2 */ + Selector?: string; + /** @example MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0zc0kqr7bnFOD1TmsjJmYthy41QeI1cqga5yU8... */ + PublicKey?: string; + /** + * @description 0 is RSA1024, 1 is RSA2048 + * @example 1 + */ + Algorithm?: number; + /** + * @description 0 is active, 1 is pending, 2 is retired, 3 is deceased + * @example 2 + */ + State?: number; + /** + * @description 0 is unset, 1 is good, 2 is invalid + * @example 1 + */ + DNSState?: number; + /** @example 1687942995 */ + CreateTime?: number; + }; + }[]; + }; + Flags: { + /** @description If the domain is intended to be used for custom addresses */ + "mail-intent"?: boolean; + /** @description If the domain is intended to be used for SSO integration */ + "sso-intent"?: boolean; + }; + }; + /** GroupMemberResponse */ + GroupMemberResponse: { + ID: components["schemas"]["Id"]; + Type: components["schemas"]["GroupMemberType"]; + State: components["schemas"]["GroupMemberState"]; + CreateTime: number; + GroupID: components["schemas"]["Id"]; + AddressID?: components["schemas"]["Id"] | null; + Email?: string | null; + Permissions: components["schemas"]["GroupMemberPermissions"]; + }; + /** GroupResponse */ + GroupResponse: { + ID: components["schemas"]["Id"]; + Name: string; + Address: unknown[]; + Permissions: components["schemas"]["GroupPermissions"]; + CreateTime: number; + Flags: components["schemas"]["GroupFlags"]; + Description?: string | null; + }; + MemberInfo: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + ID: string; + /** @example 2 */ + Role: number; + /** + * @description Invited (2), Member (1) or Disabled (0) + * @example 1 + */ + State: number; + /** @example 1 */ + Private: number; + /** + * @description 1: Proton (full), 2: Managed, 3: External, 4: CredentialLess + * @example 0 + */ + Type: number; + /** @example 100000000 */ + MaxSpace: number; + /** @example 0 */ + MaxVPN: number; + /** @example Jason */ + Name: string; + /** @example 81780955 */ + UsedSpace: number; + /** @example 1 */ + Self: number; + /** + * @example 1 + * @enum {integer} + */ + ToMigrate: 1 | 2; + /** + * @example 1 + * @enum {integer} + */ + BrokenSKL: 1 | 2; + /** @example 1 */ + Subscriber: number; + /** + * @example 1 + * @enum {integer} + */ + SSO: 0 | 1; + /** + * @description 2FA will be required to be set after TwoFactorRequiredTime timestamp + * @example 1679038286 + */ + TwoFactorRequiredTime: number; + /** + * @description bit map: 1=TOTP, 2=FIDO2 + * @example 3 + */ + "2faStatus": number; + Keys: string[]; + /** @example -----BEGIN PUBLIC KEY BLOCK-----.*-----END PUBLIC KEY BLOCK----- */ + PublicKey: string; + /** + * @description Permissions bitmap + * @example 1 + * @enum {integer} + */ + Permissions: number; + /** @description 0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation */ + AccessToOrgKey: number; + /** + * @description Whether or not the member has an AI seat + * @enum {integer} + */ + NumAI: 0 | 1; + /** @description Unprivatization info if member is undergoing one */ + Unprivatization?: unknown[] | null; + /** @description The number of lumo seats allocated to the member */ + NumLumo: Record; + }; + UpdateMemberRoleInput: { + Role: number; + /** @default null */ + OrganizationKeyInvitation: components["schemas"]["OrganizationKeyInvitationDto"] | null; + /** @default null */ + OrganizationKeyActivation: components["schemas"]["OrganizationKeyActivationDto"] | null; + }; + GetMemberUnprivatizationOutput: { + /** @description State of the Unprivatization (0: declined), 1: pending, 2: ready */ + State: number; + /** + * @description Invitation data + * @example {"Address":"member@internal-domain.com", "Revision":2} + */ + InvitationData?: string | null; + /** @description InvitationData signed with org key */ + InvitationSignature?: components["schemas"]["PGPSignature"] | null; + /** @description Email to send the invitation to */ + InvitationEmail?: string | null; + /** @description Administrator email */ + AdminEmail: string; + /** @description Fingerprint of the org key signed with primary address key */ + OrgKeyFingerprintSignature?: components["schemas"]["PGPSignature"] | null; + /** @description Organization public key */ + OrgPublicKey?: components["schemas"]["PGPPublicKey"] | null; + /** @description Whether the member should remain private after creation or be unprivatized */ + PrivateIntent: boolean; + }; + /** @description + * An authentication logs entry. + * `Protection` and `ProtectionDesc` fields are optional, only present if user has High Security enabled. + * */ + AuthLogResponse: { + /** + * @description Encrypted user ID + * @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== + */ + UserID: string; + /** + * @description Unix timestamp of when the log happened. + * @example 1683644736 + */ + Time: number; + Status: components["schemas"]["AuthLogStatus"]; + /** + * @description Various values. See AuthLogEvent constants. + * @example 23 + */ + Event: number; + /** + * @description Localized description (name) of the log event. + * @example Sign in success (attempt) + */ + Description: string; + /** @example 192.168.0.1 */ + IP: string | null; + /** @example web-mail@4.3.1 */ + AppVersion: string | null; + /** @example Android 13.1, Samsung Galaxy A20 */ + Device: string | null; + /** @example England, United Kingdom */ + Location?: string | null; + /** @example AT&T Wireless */ + InternetProvider?: string | null; + /** + * @description ID of protection applied. + * Can be missing. Only present if user has High Security enabled. + * See AuthLogProtection enum for possible values. + * @example 1 + */ + Protection?: components["schemas"]["AuthLogProtection"] | null; + /** + * @description Localized description of protection applied. + * Can be missing. Only present if user has High Security enabled. + * @example Anti-bot verification + */ + ProtectionDesc?: string | null; + }; + Session: { + /** @example cc0a3ec21c3af3461c9c310bf3f568795fdf6dc5 */ + UID: string; + /** @example Web */ + ClientID: string; + /** @example 1527262849 */ + CreateTime: number; + /** @example IhcUWoRxdY3S-6pfk2L1oSTeZx5kvpeqcxuii8h1ic1nYnSJa11LP8DABcgsRJCwXXDjxwPFSxEGJrlrvMWFpQ== */ + MemberID: string; + /** @example 0 */ + Revocable: number; + }; + GetOrganizationKeysOutput: { + /** + * @description Organization private key encrypted with mailbox password hash + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + */ + PrivateKey?: string | null; + /** + * @description If migrating to passwordless key, the private org key encrypted to the user mailbox pass + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + */ + LegacyPrivateKey?: string | null; + /** + * @deprecated + * @description Organization public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----*-----BEGIN PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string | null; + /** + * @description Token (key + data packets) to access the passwordless organization key for this user + * @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- + */ + Token?: string | null; + /** + * @description Signature of the token secret + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + Signature?: string | null; + /** + * @description Address email of the admin that signed the token (if not the user key of the member themself) + * @example someadmin@myorg.com + */ + SignatureAddress?: string | null; + /** + * Format: encrypted string + * @description The address ID of the address that was invited to the organization key + */ + EncryptionAddressID?: string | null; + /** + * @description Signature of the SHA256 fingerprint of the organization key + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + FingerprintSignature?: string | null; + /** + * @description The email address that signed the SHA256 fingerprint of the organization key + * @example someadmin@myorg.com + */ + FingerprintSignatureAddress?: string | null; + /** + * @description 0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation + * @example 1 + */ + AccessToOrgKey?: components["schemas"]["MemberOrgKeyStatus"] | null; + /** @description Whether the organization has passwordless keys or not */ + Passwordless: boolean; + }; + GetOrganizationIdentityOutput: { + /** + * @description Organization public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----*-----BEGIN PGP PUBLIC KEY BLOCK----- + */ + PublicKey: string; + /** + * @description Signature of the SHA256 fingerprint of the organization key + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + FingerprintSignature: string; + /** + * @description The email address that signed the SHA256 fingerprint of the organization key + * @example someadmin@myorg.com + */ + FingerprintSignatureAddress: string; + }; + OrganizationSettings2: { + /** + * @description Whether to show organization name in sidebar or not + * @default false + * @example true + */ + ShowName: boolean; + /** + * @description Whether to show the Scribe writing assistant or not + * @default true + * @example true + */ + ShowScribeWritingAssistant: boolean; + /** + * @description Whether the video conferencing feature is enabled or not + * @default true + * @example true + */ + VideoConferencingEnabled: boolean; + /** @default null */ + LogoID: string | null; + }; + SsoTransformer: Record; + Info: { + /** @example https://sso.proton.me/sp */ + EntityID: string; + /** @example https://sso.proton.me/auth/saml */ + CallbackURL: string; + }; + UserSettingsTransformer: { + Email: { + /** @example abc@gmail.com */ + Value?: string | null; + /** @example 0 */ + Status?: number; + /** @example 1 */ + Notify?: number; + /** @example 0 */ + Reset?: number; + }; + Password: { + /** @example 2 */ + Mode?: number; + /** + * @description If set, after this time force password change + * @example null + */ + ExpirationTime?: number; + }; + Phone: { + /** @example +18005555555 */ + Value?: string | null; + /** @example 0 */ + Status?: number; + /** @example 0 */ + Notify?: number; + /** @example 0 */ + Reset?: number; + }; + "2FA": { + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Enabled?: number; + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Allowed?: number; + /** + * @description If set, after this time force add 2FA + * @example null + */ + ExpirationTime?: number; + /** @deprecated */ + U2FKeys?: { + /** @example A name */ + Label?: string; + /** @example aKeyHandle */ + KeyHandle?: string; + /** @example 0 */ + Compromised?: number; + }[]; + /** @description Contains the user's currently registered FIDO2 credentials. */ + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + }; + /** + * @description Bitmap informing which news the user is subscribed to: + * - 1 (2^0): Announcement + * - 2 (2^1): Features + * - 4 (2^2): Newsletter + * - 8 (2^3): Beta + * - 16 (2^4): Business + * - 32 (2^5): Offers + * - 64 (2^6): New mail notification + * - 128 (2^7): Onboarding + * @example 244 + */ + News: number; + /** @example en_US */ + Locale: string; + /** + * @description 0 => Disabled, 1 => Basic, 2 => Advanced + * @example 2 + */ + LogAuth: number; + /** @example रिवार में हà¥à¤†à¥¤ ज檷\n Cartoon Law Services\n 1 DisneyWorld Lane\n Orlando, FL, 12345\n VAT */ + InvoiceText: string; + /** + * @description 0 => Comfortable, 1 => Compact + * @example 0 + */ + Density: number; + Theme: components["schemas"]["Theme2"]; + /** @example 1 */ + ThemeType: number; + /** + * @description 0 => default, 1 => monday, 6 => saturday, 7 => sunday + * @example 1 + */ + WeekStart: number; + /** + * @description 0 => default, 1 => DD_MM_YYYY, 2 => MM_DD_YYYY, 3 => YYYY_MM_DD + * @example 1 + */ + DateFormat: number; + /** + * @description 0 => default, 1 => 24h, 2 => 12h + * @example 1 + */ + TimeFormat: number; + /** + * @description 0 => Has not been welcomed, 1 => Has been welcomed + * @example 1 + */ + Welcome: number; + /** + * @deprecated + * @description (Use `Welcome`) 0 => Has not been welcomed, 1 => Has been welcomed + * @example 1 + */ + WelcomeFlag: number; + /** + * @description 0 => Regular access, 1 => Beta access + * @example 1 + */ + EarlyAccess: number; + Flags: { + /** @description 1 or 0 */ + Welcomed?: number; + /** @description 1, or 0 */ + SupportPgpV6Keys?: number; + }; + Referral: { + /** @example https://pr.tn/ref/ERBYvlX8SC4KOyb */ + Link?: string; + /** + * @description true if the user is eligible to the referral program + * @example true + */ + Eligible?: boolean; + }; + /** + * @description 0 or 1, 1 means device recovery enabled + * @example 1 + */ + DeviceRecovery: number; + /** + * @description 0 or 1, 1 means sending telemetry enabled + * @example 1 + */ + Telemetry: number; + /** + * @description 0 or 1, 1 means sending crash reports enabled + * @example 1 + */ + CrashReports: number; + /** + * @description 0 or 1, 1 means hiding the side panel + * @example 1 + */ + HideSidePanel: number; + HighSecurity: { + /** + * @description 1 => user can enable High Security, 0 => can't enable + * @example 1 + */ + Eligible?: number; + /** + * @description 1 => user has High Security enabled, 0 => disabled + * @example 1 + */ + Value?: number; + }; + /** + * @description 0 or 1, 1 means session account recovery enabled + * @example 1 + * @enum {integer} + */ + SessionAccountRecovery: 0 | 1; + /** + * @description 0: unset, 1: off, 2: server-only, 3: client-only + * @example 1 + * @enum {integer} + */ + AIAssistantFlags: 0 | 1 | 2 | 3; + /** + * @deprecated + * @description Deprecated in favour of "UsedClients". First 64 bit of bitmap informing which client the user has logged in to. + * @example 1 + */ + UsedClientFlags: number; + /** + * @description List of clients the user has logged in to. + * @example [WebAccount, WebMail, iOSDrive] + */ + UsedClients: Record[]; + }; + Fido2RegisteredKey: { + /** @example fido2-u2f */ + AttestationFormat: string; + CredentialID: Record[]; + /** @example My security key */ + Name: string; + }; + ScheduleSupportCallOutput: { + /** @example https://calendly.com/proton-schedule */ + CalendlyLink: string; + }; + AccountRecoveryAttempt: { + /** + * @description 0 => None, 1 => Grace, 2 => Cancelled, 3 => Insecure, 4 => Expired + * @example 1 + */ + State: number; + /** @example 1686834569 */ + StartTime: number; + /** @example 1687000169 */ + EndTime: number; + /** + * @description 0 => None, 1 => Cancelled, 2 => Authentication + * @example 1 + */ + Reason: number; + /** + * @description The session ID that triggered the process + * @example qmi2ptbz4sefeahddjxghsxtu2orlgyf + */ + UID: string; + }; + VPNAuthenticationCertificateDetailedTransformer: { + /** + * @description Certificate serial number + * @example 6561979746 + */ + SerialNumber: string; + /** + * @description Blob content of the certificate + * @example -----BEGIN CERTIFICATE-----... + */ + Certificate: string; + /** + * @description Fingerprint of the client public key + * @example bHZDBSYbd27GFd + */ + ClientKeyFingerprint: string; + /** + * @description The input or default mode + * @example 1505758141 + */ + ExpirationTime: number; + /** + * @description The input or default mode + * @example session + */ + Mode: string; + SessionUID: string; + Session?: components["schemas"]["Session"] | null; + UserID: number; + UserName: string; + MaxTier: number; + PublicKeyMode: string; + PublicKey: string; + DeviceName: string; + Features: number; + Groups: string[]; + RevocationTime: number; + TwoFactor: boolean; + RemoteSessions: { + RemoteID?: number; + ServerID?: number; + StartTime?: number; + LastRecordTime?: number; + }[]; + }; + FeatureTransformer: { + /** @example promo */ + Code: string; + /** + * @example enumeration + * @enum {string} + */ + Type: "boolean" | "integer" | "float" | "string" | "enumeration" | "mixed"; + /** @example 1 */ + Minimum: Record; + /** @example 100 */ + Maximum: Record; + /** @example false */ + Global: boolean; + /** @example true */ + Writable: boolean; + /** @example true */ + DefaultValue: Record; + /** @example true */ + Value: Record; + /** @example 1527262849 */ + ExpirationTime: number; + /** @example 1527262849 */ + UpdateTime: number; + }; + AuthInput: { + /** + * @description Token received from POST /auth/saml during SSO sign-in flow + * @default null + * @example + */ + SSOResponseToken: string | null; + /** + * @default null + * @example einstein + */ + Username: string | null; + /** + * Format: base64 + * @default null + * @example + */ + ClientEphemeral: string | null; + /** + * Format: base64 + * @default null + * @example + */ + ClientProof: string | null; + /** + * @description Client-specific secret only necessary to access the admin panel + * @default null + * @example demopass + */ + ClientSecret: string | null; + /** + * Format: hex + * @default null + * @example + */ + SRPSession: string | null; + /** + * @description defaults to 0 if not present, transforms cookies into persistent cookies + * @default null + * @example 1 + */ + PersistentCookies: number | null; + /** + * @default null + * @example 123456 or recovery code + */ + TwoFactorCode: string | null; + /** + * @description Either this or the TwoFactorCode + * @default null + */ + FIDO2: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** + * Format: base64 + * @description clientData (base64) returned from the client authentication library + */ + ClientData?: string; + /** + * Format: base64 + * @description authenticatorData (base64) returned from the client authentication library + */ + AuthenticatorData?: string; + /** + * Format: base64 + * @description signature (base64) returned from the client authentication library + */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + } | null; + /** + * @description optional field, frontend fingerprints + * @default null + */ + Payload: { + /** + * Format: base64 + * @example ++3dreJ+cHBSeEXvkxjLCRrf1... + */ + "random-id-1"?: string; + /** + * Format: base64 + * @example Xv5df3dreJ+cHBvkxjSeEXvkx... + */ + "random-id-2"?: string; + /** + * Format: base64 + * @example + */ + "random-id-3"?: string; + /** + * Format: base64 + * @example + */ + "random-id-4"?: string; + } | null; + /** + * @deprecated + * @description optional field used together with Android fingerprinting + * @default null + * @example + */ + Salt: string | null; + }; + CreateCredentiallessUserOutput: { + /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ + UID: string; + /** @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ + UserID: string; + /** @example 0 */ + LocalID: number; + Scopes: string[]; + /** @example ACXDmTaBub14w== */ + EventID: string; + /** @example Bearer */ + TokenType: string; + /** @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 */ + AccessToken: string; + /** @example wfih0367aa7dc0359bf5c42d15a93e6c */ + RefreshToken: string; + }; + AuthInfoInput: { + /** + * @description 4 is the current version, older versions are not accepted + * @example 4 + */ + Version: number; + /** @example */ + ModulusID: string; + /** @example */ + Salt: string; + /** @example */ + Verifier: string; + }; + PushTransformer: { + /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ + PushID: string; + /** + * @description Any objectID from the event feed (*WARNING*: the object can be on another page) + * @example KPlISx5MiML3Xc...3MAOfsds-tfNw== + */ + ObjectID: string; + /** + * @description Type of the ObjectID + * @example Messages + */ + Type: string; + }; + ReferralOutput: { + /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUss-tfNw== */ + ReferralID: string; + /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUss-tfNw== */ + UserID: string; + State: number; + /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUss-tfNw== */ + ReferredUserID?: string | null; + Email?: string | null; + CreateTime: number; + SignupTime?: number | null; + TrialTime?: number | null; + CompleteTime?: number | null; + RewardTime?: number | null; + RewardMonths: number; + InvoiceID?: string | null; + ReferredUserSubscriptionCycle?: number | null; + }; + ReferralStatus: { + /** @example 2 */ + RewardMonths: number; + /** @example 6 */ + RewardMonthsLimit: number; + /** @example 10 */ + EmailsAvailable: number; + }; + GetUserInvitationsOutput: { + UserInvitations: components["schemas"]["GetUserInvitationOutput"][]; + }; + GetUserInvitationOutput: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + ID: string; + /** @example owner@family.org */ + InviterEmail: string; + /** @example 1000000000 */ + MaxSpace: number; + /** @example My Organization */ + OrganizationName: string; + /** @example family2022 | passfamily2024 */ + OrganizationPlanName: string; + Validation: components["schemas"]["AcceptInvitationValidation"]; + }; + EventInfo: { + Code: components["schemas"]["ResponseCodeSuccess"]; + /** + * Format: byte + * @example ACXDmTaBub14w== + */ + EventID: string; + /** + * @description Bitmask to know what to refresh
`0`: Nothing
`1`: MAIL
`2`: CONTACTS
`255`: Everything + * @example 0 + */ + Refresh: number; + /** + * @description `1` if there is more to pull + * @example 0 + * @enum {integer} + */ + More: 0 | 1; + Messages: { + /** @example KPlISx5MiML3XcSYPrREF-...-adgMIhFhPaAukDm9fw3MAOfsds-tfNw== */ + ID?: string; + /** + * @description Message action
`0`: `DELETE`
`1`: `CREATE`
`2`: `UPDATE`
`3`: `UPDATE_FLAGS` + * @example 1 + * @enum {integer} + */ + Action?: 0 | 1 | 2 | 3; + Message?: components["schemas"]["MessageInfo"] & { + /** @deprecated */ + LabelIDsAdded?: string[]; + /** @deprecated */ + LabelIDsRemoved?: string[]; + }; + }[]; + Conversations: { + /** @example I6hgx3Ol-d3HYa3E394T...ACXDmTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + Conversation?: { + /** @example AJuSqm0qvIL4LSMR9LWsqNO...a2OlAU_Iqr2Qcducsz-ZA== */ + AddressID?: string; + } & components["schemas"]["Conversation"] & { + LabelIDsAdded?: string[]; + LabelIDsRemoved?: string[]; + /** + * @deprecated + * @description Not available in the Events API + */ + LabelIDs?: string[]; + } & components["schemas"]["AttachmentsMetadata"]; + }[]; + Importers: { + /** @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEhjBbUPDMHGU699fw== */ + ID?: string; + /** @example 1 */ + Action?: number; + Importer?: components["schemas"]["ImporterTransformer"]; + }[]; + ImportReports: { + /** @example ARy95iNxhniEgYJrRrGv...F49RxFpMqWE_ZGDXEvGV2CEkA== */ + ID?: string; + /** @example 1 */ + Action?: number; + ImportReport?: components["schemas"]["ImportReportTransformer"]; + }[]; + Contacts: { + /** @example afeaefaeTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + Contact?: components["schemas"]["Contact"]; + }[]; + ContactEmails: { + /** @example sadfaACXDmTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + ContactEmail?: components["schemas"]["ContactEmail"]; + }[]; + Filters: { + /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ + ID?: string; + /** @example 1 */ + Action?: number; + Filter?: components["schemas"]["FilterOutput"]; + }[]; + IncomingDefaults: { + /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ + ID?: string; + /** @example 1 */ + Action?: number; + Filter?: components["schemas"]["IncomingDefault"]; + }[]; + OrgIncomingDefaults: { + /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ + ID?: string; + /** @example 1 */ + Action?: number; + OrgIncomingDefault?: components["schemas"]["IncomingDefaultResponse"]; + }[]; + Labels: { + /** @example sadfaACXDmTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + Label?: components["schemas"]["Label"]; + }[]; + Subscription: components["schemas"]["Subscription"]; + User: components["schemas"]["User"] & { + AccountRecovery?: components["schemas"]["AccountRecoveryAttempt"]; + }; + UserSettings: components["schemas"]["UserSettingsTransformer"]; + MailSettings: components["schemas"]["Response"]; + VPNSettings: { + /** @example test-group */ + GroupID?: string; + } & components["schemas"]["VPNSettings"]; + Invoices: { + /** @example IlnTbqicN-...-4NvrrIc6GLvDv28aKYVRRrSgEFhR_zhlkA== */ + ID?: string; + /** @example 1 */ + Action?: number; + Invoice?: components["schemas"]["Invoice"]; + }[]; + Members: { + /** @example LO9aACXDmTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + Member?: { + /** @example LO9aACXDmTaBub14w== */ + MemberID?: string; + /** @example 1 */ + Role?: number; + /** @example 0 */ + Private?: number; + /** @example 0 */ + Type?: number; + /** + * Format: int64 + * @example 0 + */ + MaxSpace?: number; + /** @example Jason */ + Name?: string; + /** + * Format: int64 + * @example 0 + */ + UsedSpace?: number; + Addresses?: string[]; + }; + }[]; + Domains: { + /** @example 9aACXDmTaBub14w== */ + ID?: string; + /** @example 2 */ + Action?: number; + Domain?: components["schemas"]["DomainTransformer"]; + }[]; + Addresses: components["schemas"]["AddressUser"][]; + SignedAddressList?: components["schemas"]["KTAddressListTransformer"] | null; + IncomingAddressForwardings: { + /** @example 9aACXDmTaBub14w== */ + ID?: string; + /** @example 2 */ + Action?: number; + IncomingAddressForwarding?: components["schemas"]["IncomingAddressForwardingResponse"]; + }[]; + OutgoingAddressForwardings: { + /** @example 9aACXDmTaBub14w== */ + ID?: string; + /** @example 2 */ + Action?: number; + OutgoingAddressForwarding?: components["schemas"]["OutgoingAddressForwardingResponse"]; + }[]; + Organization: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** + * @description Plan attribution to specific product, 1 = Mail, 2 = Drive, 4 = VPN + * @example 1 + */ + PlanFlags?: number; + /** @example null */ + TwoFactorGracePeriod?: number; + /** @example null */ + Theme?: number; + /** @example contact@e-corp.com */ + Email?: string; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** + * Format: int64 + * @example 10000000000 + */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** + * Format: int64 + * @example 81788997 + */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + }; + MessageCounts: { + /** @example 0 */ + LabelID?: string; + /** @example 15 */ + Total?: number; + /** @example 6 */ + Unread?: number; + }[]; + ConversationCounts: { + /** @example 0 */ + LabelID?: string; + /** @example 4 */ + Total?: number; + /** @example 3 */ + Unread?: number; + }[]; + /** + * Format: int64 + * @description Used space (in bytes) + * @example 70376905 + */ + UsedSpace: number; + ProductUsedSpace: components["schemas"]["UserUsage"]; + VPNProfiles: { + /** @example q_9v-GXEPLagg81jsUz2mHQ== */ + ID?: string; + /** @example 2 */ + Action?: number; + VPNProfile?: components["schemas"]["VPNProfile"]; + }[]; + LogicalServers: components["schemas"]["VPNLogical"]; + Calendars: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + Calendar?: components["schemas"]["CalendarWithMemberWithFlagsOutput"]; + }[]; + CalendarMembers: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + Member?: components["schemas"]["MemberWithFlagsOutput"]; + }[]; + Pushes: { + /** @example 1H8EGg3J1QpSDL6K8hGs...hrHx6nnGQ== */ + PushID?: string; + /** + * @description Any objectID from the event feed (*WARNING*: the object can be on another page) + * @example KPlISx5MiML3Xc...3MAOfsds-tfNw== + */ + ObjectID?: string; + /** + * @description Type of the ObjectID + * @example Messages + */ + Type?: string; + }[]; + Notifications: components["schemas"]["EventLoopNotificationTransformer"][]; + CalendarUserSettings: components["schemas"]["UserSettingsTransformer2"]; + Wallets: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + Wallet?: components["schemas"]["WalletOutput"]; + }[]; + WalletAccounts: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletAccount?: components["schemas"]["WalletAccountOutput"]; + }[]; + WalletBitcoinAddresses: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletBitcoinAddress?: components["schemas"]["WalletBitcoinAddressOutput"]; + }[]; + WalletKeys: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletKey?: components["schemas"]["WalletKeyOutput"]; + }[]; + WalletSettings: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletSettings?: components["schemas"]["WalletSettingsOutput"]; + }[]; + WalletTransactions: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletTransaction?: components["schemas"]["WalletTransactionOutput"]; + }[]; + WalletUserSettings: components["schemas"]["WalletUserSettingsOutput"]; + Notices: string[]; + } & components["schemas"]["DriveShareRefreshCoreEventService"]; + NotificationVersionTransformer: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + NotificationID: string; + /** @example 1601582623 */ + StartTime: number; + /** @example 1845561234 */ + EndTime: number; + /** + * @description Possible values:
- 0: offer + * @example 0 + */ + Type: number; + /** @description Offer property will be present only when Type is 0 */ + Offer: { + /** @example https://protonvpn.com/black-friday */ + URL?: string; + /** @example https://protonvpn.com/resources/bf.png */ + Icon?: string; + /** + * @description Translated label based on the user's locale + * @example Black-Friday arrived! + */ + Label?: string; + }; + }; + Label: { + /** @example sadfaACXDmTaBub14w== */ + ID: string; + /** @example Event Label! */ + Name: string; + /** @example Folder/Event Label! */ + Path: string; + /** @example 1 */ + Type: number; + /** @example #f66 */ + Color: string; + /** @example 8 */ + Order: number; + /** @example 1 */ + Notify: number; + /** @example 1 */ + Expanded: number; + /** @example 1 */ + Sticky: number; + /** @example sadfaACXDmTaBub14w== */ + ParentID: string; + /** + * @description v3 only + * @example 1 + */ + Display: number; + /** + * @description v3 only + * @example 0 + */ + Exclusive: number; + }; + /** @description An armored PGP Private Key */ + PGPPrivateKey: string; + SignedKeyListInput: { + /** @example JSON.stringify([{""Fingerprint"": ""fde90483475164ec6353c93f767df53b0ca8395c"",""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Primary"": 1,""Flags"": 3}]) */ + Data: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature: string; + }; + AddressKeyInput5: { + /** + * @description The address ID + * @example ACXDmTa...Bub14w== + */ + AddressID: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + /** @example -----BEGIN PGP MESSAGE-----.* */ + Token: string; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + Signature: string; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + /** @example 3 */ + Revision: Record; + }; + AuthInput2: { + /** @example 4 */ + Version: number; + /** @example */ + ModulusID: string; + /** @example */ + Salt: string; + /** @example */ + Verifier: string; + }; + Fido2Input: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData: string; + /** @description signature (base64) returned from the client authentication library */ + Signature: string; + /** @description CredentialID used */ + CredentialID: Record[]; + }; + /** @description Base64 encoded binary data */ + BinaryString: string; + ResetAuthDevicesUserKeyDto: { + ID: components["schemas"]["EncryptedId"]; + PrivateKey: components["schemas"]["PGPPrivateKey"]; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Internal
1External
2InternalTypeExternal
+ * @enum {integer} + */ + GroupMemberType: 0 | 1 | 2; + /** @description An armored PGP Signature */ + PGPSignature: string; + /** @description An armored PGP Message */ + PGPMessage: string; + GroupProxyInstance: { + PgpVersion: number; + GroupAddressKeyFingerprint: string; + GroupMemberAddressKeyFingerprint: string; + ProxyParam: string; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0NobodyCanSend
1GroupMembersCanSend
2OrgMembersCanSend
3EveryoneCanSend
+ * @enum {integer} + */ + GroupPermissions: 0 | 1 | 2 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0None
+ * @enum {integer} + */ + GroupFlags: 0; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0None
1Send
2Leave
3SendAndLeave
+ * @enum {integer} + */ + GroupMemberPermissions: 0 | 1 | 2 | 3; + ExternalGroupMembership: { + ID: components["schemas"]["Id"]; + /** Format: date-time */ + CreateTime: string; + State: components["schemas"]["GroupMemberState"]; + Type: components["schemas"]["GroupMemberType"]; + Email?: string | null; + Permissions: components["schemas"]["GroupMemberPermissions"]; + /** Format: date-time */ + JoinTime?: string | null; + Group: components["schemas"]["GroupMembershipGroup"]; + }; + GroupMember: { + ID: components["schemas"]["Id"]; + /** Format: date-time */ + CreateTime: string; + GroupID: components["schemas"]["Id"]; + State: components["schemas"]["GroupMemberState"]; + Type: components["schemas"]["GroupMemberType"]; + AddressID?: components["schemas"]["Id"] | null; + Email?: string | null; + Permissions: components["schemas"]["GroupMemberPermissions"]; + }; + InternalGroupMembership: { + ID: components["schemas"]["Id"]; + /** Format: date-time */ + CreateTime: string; + State: components["schemas"]["GroupMemberState"]; + Type: components["schemas"]["GroupMemberType"]; + AddressId?: components["schemas"]["Id"] | null; + Email?: string | null; + Permissions: components["schemas"]["GroupMemberPermissions"]; + /** Format: date-time */ + JoinTime?: string | null; + TokenKeyPacket?: components["schemas"]["BinaryString"] | null; + TokenSignaturePacket?: components["schemas"]["BinaryString"] | null; + AddressSignaturePacket?: components["schemas"]["BinaryString"] | null; + Group: components["schemas"]["GroupMembershipGroup"]; + ForwardingKeys: components["schemas"]["ForwardingKeys"]; + GroupID: components["schemas"]["Id"]; + }; + AddressKeyToken: { + /** + * @description Encrypted Address key ID to replace the token + * @example G1MbEt3Ep5P_E...Wz8WbHVAOl_6h== + */ + AddressKeyID: string; + /** + * @description Base-64 encoded key packet + * @example slCpH6qWMKGQ7d...R4eLU2+2BZvK0UeG/QY2 + */ + KeyPacket: string; + /** + * @description Token signature produced with the primary user key + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + Signature: string; + }; + /** + * @description

Either 1=PROTON or 2=MANAGED (default)

See values descriptions
See values descriptions
ValueDescription
1Proton
2Managed
3External
4CredentialLess
+ * @enum {integer} + */ + UserType: 1 | 2 | 3 | 4; + MagicLinkInvitationInput: { + /** + * @description Invitation data containing address and expected KT revision + * @example {"Address":"member@internal-domain.com", "Revision":2} + */ + Data: Record; + Signature?: components["schemas"]["PGPSignature"] | null; + /** + * @description The email to send an invitation to + * @example some.user@example.com + */ + Email: string; + /** @description Whether the member should remain private after creation or be unprivatized */ + PrivateIntent: boolean; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1ManageForwarding
+ * @enum {integer} + */ + MemberPermission: 1; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Remove
1Add
+ * @enum {integer} + */ + MemberPermissionAction: 0 | 1; + UserKeyInput: { + PrivateKey: components["schemas"]["PGPPrivateKey"]; + OrgPrivateKey: components["schemas"]["PGPPrivateKey"]; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + OrgToken: string; + }; + AuthInfoInput2: { + /** + * @description 4 is the current version, older versions are not accepted + * @example 4 + */ + Version: number; + /** @example */ + ModulusID: string; + /** @example */ + Salt: string; + /** @example */ + Verifier: string; + }; + UnprivatizeMemberUserKeyDto: { + OrgPrivateKey: components["schemas"]["PGPPrivateKey"]; + OrgToken: components["schemas"]["PGPMessage"]; + }; + UnprivatizeMemberAddressKeyDto: { + AddressKeyID: components["schemas"]["Id"]; + OrgTokenKeyPacket: components["schemas"]["BinaryString"]; + OrgSignature: components["schemas"]["PGPSignature"]; + }; + ReplaceOrganizationKeyInvitationDto: { + MemberID: components["schemas"]["Id"]; + TokenKeyPacket: components["schemas"]["BinaryString"]; + Signature: components["schemas"]["PGPSignature"]; + SignatureAddressID: components["schemas"]["Id"]; + EncryptionAddressID: components["schemas"]["Id"]; + }; + ReplaceOrganizationKeyActivationDto: { + MemberID: components["schemas"]["Id"]; + TokenKeyPacket: components["schemas"]["BinaryString"]; + Signature: components["schemas"]["PGPSignature"]; + }; + MigrateOrganizationKeyInvitationDto: { + MemberID: components["schemas"]["Id"]; + TokenKeyPacket: components["schemas"]["BinaryString"]; + Signature: components["schemas"]["PGPSignature"]; + }; + MigrateOrganizationKeyActivationDto: { + MemberID: components["schemas"]["Id"]; + TokenKeyPacket: components["schemas"]["BinaryString"]; + Signature: components["schemas"]["PGPSignature"]; + }; + /** + * @description

Possible values:
- 0: Unset
- 1: Off
- 2: Server-Only
- 3: Client-Only

See values descriptions
See values descriptions
ValueDescription
0Unset
1Off
2ServerOnly
3ClientOnly
+ * @enum {integer} + */ + AIAssistantFlags: 0 | 1 | 2 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
4Google
6AppleProd
7AppleBeta
16AppleDev
+ * @enum {integer} + */ + Environment: 4 | 6 | 7 | 16; + /** @description An armored PGP Public Key */ + PGPPublicKey: string; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Off
1On
+ * @enum {integer} + */ + PingNotificationStatus: 0 | 1; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Off
1On
+ * @enum {integer} + */ + PushNotificationStatus: 0 | 1; + /** + * @description

1: email, 2: VPN, 3: calendar, 4: drive, 5: pass

See values descriptions
See values descriptions
ValueDescription
1Mail
2VPN
3Calendar
4Drive
5Pass
6Wallet
7Neutron
8Contacts
9Lumo
+ * @enum {integer} + */ + Product: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + EventCollectionOutput: components["schemas"]["EventOutput"][]; + /** @description An encrypted Label ID and default integer Label ID */ + LabelID: string; + /** Product used space */ + UserUsage: { + Calendar: number; + Contact: number; + Drive: number; + Mail: number; + Pass: number; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Paid
1Available
2Overdue
3Delinquent
4NotReceived
+ * @enum {integer} + */ + DelinquentState: 0 | 1 | 2 | 3 | 4; + /** AddressKey */ + AddressKey: { + /** + * @description Encrypted AddressKey ID + * @example G1MbEt3...Ol_6h== + */ + ID: string; + /** + * @description Latest version is 3 + * @example 3 + */ + Version: number; + /** + * @deprecated + * @description Deprecated! Do not rely on public keys returned from the API! + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.* + */ + PublicKey: string; + /** + * @description This parameter is missing ONLY in the key reset call + * @example -----BEGIN PGP PRIVATE KEY BLOCK----- + */ + PrivateKey?: string | null; + /** + * @description This can be the token to decrypt the address key via the user key + * or a legacy token if logging in as sub-user or null for private legacy keys user + * @example null or -----BEGIN PGP MESSAGE-----.* + */ + Token?: string | null; + /** + * @description If this field is present, the key is migrated. Use it to verify the token! + * @example null or -----BEGIN PGP SIGNATURE----- + */ + Signature?: string | null; + /** + * @deprecated + * @description Deprecated! Do not rely on fingerprints returned from the API! + * @example c93f767df53b0ca8395cfde90483475164ec6353 + */ + Fingerprint: string; + /** + * @deprecated + * @description Deprecated! Do not rely on fingerprints returned from the API! + */ + Fingerprints: string[]; + /** + * @deprecated + * @description Deprecated! + * Migrated accounts do not have the activation field set, + * and they get migrated automatically on login. + * @example -----BEGIN PGP MESSAGE-----.* + */ + Activation?: string | null; + /** + * @description 0 or 1. There is only one primary key per address + * @example 1 + */ + Primary: number; + /** + * @description 0 or 1. + * All active keys should decrypt successfully and all inactive keys should not be decrypted. + * @example 1 + */ + Active: number; + /** + * @description Flags (bitmap): + * * `1`: Can use key to verify signatures; + * * `2`: Can use key to encrypt new data; + * * `4`: Can be used to encrypt email; + * * `8`: Do not expect signed email from this key; + * @example 3 + */ + Flags: number; + /** + * @description If not null, it represents a valid associated Address Forwarding instance + * @example fWIio823...j45sL== + */ + AddressForwardingID: string; + /** + * @description If not null, it represents a valid associated Group Member instance + * @example fWIio823...j45sL== + */ + GroupMemberID: string; + }; + /** + * @description

The current device state

See values descriptions
See values descriptions
ValueDescription
0Inactive
1Active
2PendingActivation
3PendingAdminActivation
4Rejected
5NoSession
+ * @enum {integer} + */ + AuthDeviceState: 0 | 1 | 2 | 3 | 4 | 5; + TranslatedStringInterface: Record; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Pending
1Active
2Outdated
3Paused
4Rejected
+ * @enum {integer} + */ + GroupMemberState: 0 | 1 | 2 | 3 | 4; + OrganizationKeyInvitationDto: { + TokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description Signature of the token key packet by the inviters address key */ + Signature: string; + SignatureAddressID: components["schemas"]["EncryptedId"]; + EncryptionAddressID: components["schemas"]["EncryptedId"]; + }; + OrganizationKeyActivationDto: { + TokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description Signature of the token key packet by the user key of the member */ + Signature: string; + }; + /** @enum {string} */ + AuthLogStatus: "success" | "attempt" | "failure"; + /** + * @description

ID of protection applied.
+ * Can be missing. Only present if user has High Security enabled.
+ * See AuthLogProtection enum for possible values.

See values descriptions
See values descriptions
ValueNameDescription
1Block
2Captcha
3OwnershipVerification
4DeviceVerification
5Ok* AuthLog action was protected by anti-abuse systems + * * and was evaluated as safe.
+ * @enum {integer} + */ + AuthLogProtection: 1 | 2 | 3 | 4 | 5; + /** + * @description

0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation

See values descriptions
See values descriptions
ValueNameDescription
0NoKeyThe member does not and should not have access to the org key (e.g. not an admin)
1ActiveThe member has full access to the most recent copy of the org key
2MissingThe member does not have access to the most recent copy of the org key (including legacy keys)
3PendingThe member has been invited to but needs to activate the most recent copy of the org key
+ * @enum {integer} + */ + MemberOrgKeyStatus: 0 | 1 | 2 | 3; + /** Theme */ + Theme2: Record; + AcceptInvitationValidation: { + /** @example true */ + Valid: boolean; + /** @example false */ + IsLifetimeAccount: boolean; + /** @example false */ + HasOrgWithMembers: boolean; + /** @example false */ + HasCustomDomains: boolean; + /** @example false */ + ExceedsMaxSpace: boolean; + /** @example false */ + ExceedsAddresses: boolean; + /** @example false */ + ExceedsMaxAcceptedInvitations: boolean; + /** @example false */ + OrgExceedsMaxAcceptedInvitations: boolean; + /** @example false */ + IsOnForbiddenPlan: boolean; + /** @example false */ + HasUnpaidInvoice: boolean; + /** @example false */ + IsExternalUser: boolean; + }; + MessageInfo: { + /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUsT_zzWKFI-adgMIhFhPaAukDm9fw3MAOfsds-tfNw== */ + ID: string; + /** + * @description This value is UserID + MessageID.
It gives the order in which the messages were created in our database + * @example 456 + */ + Order: number; + /** @example Wk30GtU7aIj8Gu6yWkSc3SacA== */ + ConversationID: string; + /** @example new subject */ + Subject: string; + /** @example 1 */ + Unread: number; + /** + * @deprecated + * @example 1 + */ + Type: string; + /** + * @deprecated + * @example me@protonmail.com + */ + SenderAddress: string; + /** + * @deprecated + * @example Me + */ + SenderName: string; + Sender: components["schemas"]["Sender"]; + ToList: components["schemas"]["Recipient"]; + CcList: components["schemas"]["Recipient"]; + BccList: components["schemas"]["Recipient"]; + /** @example 1433890289 */ + Time: number; + /** @example 1433890289 */ + SnoozeTime: number; + /** @example 148 */ + Size: number; + /** + * @deprecated + * @example 1 + */ + IsEncrypted: number; + /** @example 0 */ + ExpirationTime: number; + /** @example 0 */ + IsReplied: number; + /** @example 0 */ + IsRepliedAll: number; + /** @example 0 */ + IsForwarded: number; + /** @example cO6RgDJwoHFScLqIkVnRD9luDVkh20EDto1aIHVHU43-dJlREzFcUjS-c7bB-_qlnxBgwAShddHZ4UDdu6RswQ== */ + AddressID: string; + LabelIDs: string[]; + /** @example somesemirandomstringofchars */ + ExternalID: string; + /** + * @description The number of attachments in the message, excluding inline attachments + * @example 2 + */ + NumAttachments: number; + /** + * @description Bitmap of message flags.
+ * * Received = 2^0 Message was received
+ * * Sent = 2^1 Message was sent
+ * * Internal = 2^2 Message is internal
+ * * E2E = 2^3 Message is End-to-End encrypted
+ * * Auto = 2^4 Message was automatically generated
+ * * Replied = 2^5
+ * * RepliedAll = 2^6
+ * * Forwarded = 2^7 Message was forwarded
+ * * Auto replied = 2^8 Message is an automatic reply
+ * * Imported = 2^9 Message was imported
+ * * Opened = 2^10 Message has been opened
+ * * Receipt Sent = 2^11 Message receipt has been sent
+ * * Notified = 2^12 Historical, unused flag, kept here for reservation purposes
+ * * Touched = 2^13
+ * * Receipt = 2^14 Message is a recipt
+ * * Proton = 2^15
+ * * Receipt request = 2^16 Message request a recipt
+ * * Public key = 2^17
+ * * Sign = 2^18 Message is signed
+ * * Unsubscribed = 2^19 Message has been unsubscribed from
+ * * Scheduled send = 2^20 Message was scheduled sent
+ * * 2^21 Not used
+ * * Synced from Gmail = 2^22 Message was synced from Gmail
+ * * DMARC PASS = 2^23 DMARC check passed
+ * * SPF fail = 2^24 SPF check failed
+ * * DKIM fail = 2^25 DKIM check failed
+ * * DMARC fail = 2^26 DMARC check failed
+ * * Ham manual = 2^27 Message was manually marked as ham (non spam)
+ * * Spam auto = 2^28 Message was automatically marked as spam
+ * * Spam manual = 2^29 Message was manually marked as spam
+ * * Phishing auto = 2^30 Message was automatically marked as phishing
+ * * Phishing manual = 2^31 Message was manually marked as phishing
+ * * FrozenExpiration = 2^32 Message expiration time can't be manually edited
+ * * Suspicious = 2^33 Message was automatically marked as suspicious
+ * * Show Snooze Reminder = 2^34 Snooze reminder needs to be shown
+ * * Auto Forwarder = 2^35 Message has been automatically forwarded to another recipient
+ * * Auto Forwardee = 2^35 Message received was automatically forwarded by the sender
+ * * EO Reply = 2^36 Message is a reply to an Encrypted-Outside message
+ * @example 8198 + */ + Flags: number; + AttachmentInfo: components["schemas"]["GroupedAttachmentsCount"]; + AttachmentsMetadata: components["schemas"]["Metadata"][]; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 + */ + SenderImage: number; + /** @description Indicates if the client has to display the text saying that the message has been reminded */ + DisplaySnoozedReminder: boolean; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 + */ + IsProton: number; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example default + */ + BimiSelector?: string | null; + }; + Conversation: { + /** + * @description The ID of the conversation + * @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== + */ + ID: string; + /** + * @description The order is the sum of the conversationID and corresponding userID + * @example 675 + */ + Order: number; + /** + * @description The subject of the conversation + * @example Testing + */ + Subject: string; + /** @description The list of senders */ + Senders: components["schemas"]["Sender2"][]; + /** @description The list of recipients */ + Recipients: components["schemas"]["Recipient2"][]; + /** + * @description The number of messages in the conversation. + * @example 5 + */ + NumMessages: number; + /** + * @description The number of unread messages in the conversation. + * @example 0 + */ + NumUnread: number; + /** + * @description The number of attachments of the messages in the conversation, excluding inline attachments + * @example 0 + */ + NumAttachments: number; + /** + * @description The lowest expiration time of the messages in the conversations. + * * An expiration time of 0 means never. + * @example 0 + */ + ExpirationTime: number; + /** + * @description The sum of the sizes of all the messages in the conversation, expressed in bytes + * @example 3555 + */ + Size: number; + /** @deprecated */ + LabelIDs: string[]; + /** @description List of labels that the conversation has */ + Labels: { + /** @example 0 */ + ID?: string; + /** @example 0 */ + ContextNumUnread?: number; + /** @example 5 */ + ContextNumMessages?: number; + /** @example 1578070879 */ + ContextTime?: number; + /** @example 0 */ + ContextExpirationTime?: number; + /** @example 541 */ + ContextSize?: number; + /** @example 0 */ + ContextNumAttachments?: number; + /** @example 1578070879 */ + ContextSnoozeTime?: number; + }[]; + /** @description Indicates if the client has to display the text saying that the conversation has been reminded */ + DisplaySnoozedReminder: boolean; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 + */ + DisplaySenderImage: Record; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 + */ + IsProton: Record; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example default + */ + BimiSelector?: string | null; + }; + AttachmentsMetadata: { + AttachmentInfo: components["schemas"]["GroupedAttachmentsCount2"]; + AttachmentsMetadata: components["schemas"]["Metadata2"][]; + }; + /** Importer */ + ImporterTransformer: { + /** @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEp-fhjBbUPDMHGU699fw== */ + ID: string; + /** @example test@protonmail.dev */ + Account: string; + Product: string[]; + /** + * @description 0: IMAP, 1: Google + * @example 1 + */ + Provider: number; + /** + * @description nullable, present only with token flow + * @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEp-fhjBbUPDMHGU699fw== + */ + TokenID: string; + /** + * @description Modify time of the importer + * @example 12345678 + */ + ModifyTime: number; + /** + * @description nullable, present only for IMAP flow + * @example imap.mail.ru + */ + ImapHost: string; + /** + * @description nullable, present only for IMAP flow + * @example 993 + */ + ImapPort: number; + /** + * @description nullable, present only for IMAP flow + * @example PLAIN + */ + Sasl: string; + /** + * @description nullable, present only for IMAP flow - 1 if certificate is not verified + * @example 0 + */ + AllowSelfSigned: number; + /** @example 76844 */ + INBOX: number; + /** @example 0 */ + "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043D\u044B\u0435": number; + /** @example 0 */ + "\u0427\u0435\u0440\u043D\u043E\u0432\u0438\u043A\u0438": number; + /** @example 0 */ + "INBOX/Social": number; + /** @example 0 */ + "INBOX/Newsletters": number; + }; + /** ImportReport */ + ImportReportTransformer: { + /** @example ARy95iNxhniEgYJrRrGv...F49RxFpMqWE_ZGDXEvGV2CEkA== */ + ID: string; + /** @example 1 */ + Provider: number; + /** @example test@gmx.fr */ + Account: string; + /** @description Sent (1) or Not Sent (0) */ + State: number; + /** @example 1592827431 */ + CreateTime: number; + /** @example 1592829784 */ + EndTime: number; + /** @example 262612461 */ + TotalSize: number; + Summary: { + Calendar?: { + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** @example 1245 */ + NumEvents?: number; + /** @example 1245 */ + TotalSize?: number; + /** + * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE + * @example 1 + */ + RollbackState?: number; + }; + Contact?: { + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** @example 1245 */ + NumContacts?: number; + /** @example 1245 */ + NumGroups?: number; + /** @example 1245 */ + TotalSize?: number; + /** + * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE + * @example 1 + */ + RollbackState?: number; + }; + Mail?: { + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** @example 1245 */ + NumMessages?: number; + /** @example 1245 */ + TotalSize?: number; + /** + * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE + * @example 1 + */ + RollbackState?: number; + /** @description 1 if source messages can be deleted */ + CanDeleteSource?: number; + }; + }; + }; + Contact: { + /** + * @description Encrypted ID + * @example a29olIjFv0rnXxBhSMw== + */ + ID: string; + /** @example ProtonMail Features */ + Name: string; + /** @example proton-legacy-139892c2-f691-4118-8c29-061196013e04 */ + UID: string; + /** @example 1434 */ + Size: number; + /** + * Format: timestamp + * @example 1503815366 + */ + CreateTime: number; + /** + * Format: timestamp + * @example 1503815366 + */ + ModifyTime: number; + /** @description List of emails, only included when returning one record */ + ContactEmails: components["schemas"]["ContactEmail"][]; + /** @description Labels on Contact, ignore, maybe future feature */ + LabelIDs: string[]; + /** @description Only included when returning one record */ + Cards: components["schemas"]["ContactData"][]; + }; + ContactEmail: { + /** + * @description ContactList.ContactID + * @example aefew4323jFv0BhSMw== + */ + ID: string; + /** @example test1 */ + Name: string; + /** @example features@protonmail.black */ + Email: string; + /** @description List of email types */ + Type: string[]; + /** + * @description 0 if contact contains custom sending preferences or keys, 1 otherwise + * @example 1 + */ + Defaults: number; + /** @example 1 */ + Order: number; + /** @example a29olIjFv0rnXxBhSMw== */ + ContactID: string; + /** @description Groups */ + LabelIDs: string[]; + /** @example features@protonmail.black */ + CanonicalEmail: string; + /** @description The last time the User sent a message to this ContactEmail */ + LastUsedTime: number; + /** + * @description Tells whether this is an official Proton address + * @example 1 + */ + IsProton: number; + }; + FilterOutput: { + ID: components["schemas"]["Id"]; + Name: string; + /** @example 1 */ + Status: number; + /** @example 3 */ + Priority: number; + /** @example require ["fileinto"]; + * + * if address :DOMAIN :is ["From", "Delivered-To"] "protonmail.ch" { + * fileinto "mylabel"; + * } else + * keep; + * } */ + Sieve: string; + Tree: components["schemas"]["Tree"]; + /** @example 1 */ + Version: number; + }; + IncomingDefault: Record; + IncomingDefaultResponse: { + /** ID */ + ID: string; + Location: number; + Type: number; + Time: number; + Email?: string | null; + }; + Subscription: { + /** @example */ + ID: string; + /** @example */ + InvoiceID: string; + /** @example 1 */ + Cycle: number; + /** @example 1455617471 */ + PeriodStart: number; + /** @example 1458119471 */ + PeriodEnd: number; + /** @example null */ + CouponCode: string; + /** @example USD */ + Currency: string; + /** @example 1500 */ + Amount: number; + Plans: components["schemas"]["Plan"][]; + /** @example 1 */ + Renew: boolean; + }; + Response: { + /** @example Put Chinese Here */ + DisplayName: string; + /** @example This is my signature */ + Signature: string; + /** @example */ + Theme: string; + /** @description Automatically respond to incoming messages */ + AutoResponder: { + /** @example 0 */ + StartTime?: number; + /** @example 0 */ + Endtime?: number; + /** @example 0 */ + Repeat?: number; + DaysSelected?: string[]; + /** @example Auto */ + Subject?: string; + /** @example */ + Message?: string; + /** @example null */ + IsEnabled?: boolean | null; + /** @example Europe/Zurich */ + Zone?: string; + }; + /** + * @description Automatically save the recipients as contact. + * If enabled, when a user sends an email, the recipients are automatically added to his contact list. + * Implemented by the backend. + * Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + AutoSaveContacts: number; + /** + * @deprecated + * @description Automatically convert simple queries to wildcarded versions, such as `test` to `*test*`. + * Implemented by web client V3. With v4 everything is wildcarded by default. + * Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + AutoWildcardSearch: number; + /** + * @description Possible values: + * - 0: normal + * - 1: maximized + * @default 0 + */ + ComposerMode: number; + /** + * @description Possible values: + * - 0: read first + * - 1: unread first + * @default 0 + */ + MessageButtons: number; + /** + * @description Possible values: + * - 0: don't auto load + * - 1: auto-load remote content + * - 2: auto-load embedded images + * - 3: auto-load both + * @default 2 + */ + ShowImages: number; + /** + * @description Possible values: + * - 0: don't keep + * - 1: keep draft messages in Draft folder + * - 2: keep sent messages in Sent folder + * - 3: keep both draft and sent messages in their respective folders + * @default 0 + */ + ShowMoved: number; + /** + * @description delay in days before messages put in trash and spam are permanantly deleted + * + * - null: implicitly disabled + * - 0: explicitly disabled + * @default null + */ + AutoDeleteSpamAndTrashDays: number | null; + /** + * @description Possible values: + * - 0: Client should show the `ALL_MAIL` label + * - 1: Client should show the `ALMOST_ALL_MAIL` label + * @default 0 + */ + AlmostAllMail: number; + /** + * @description Whether to load next message when current message is moved somewhere else + * - null: implicitly disabled + * - 0: explicitly disabled + * - 1: implictly disabled + * - 2: explicitly enabled + * @default 0 + * @enum {integer|null} + */ + NextMessageOnMove: 0 | 1 | 2 | null; + /** + * @description Possible values: + * - 0: enable conversation mode + * - 1: no conversation grouping + * @default 0 + */ + ViewMode: number; + /** + * @description Possible values: + * - 0: column + * - 1: row + * @default 0 + */ + ViewLayout: number; + /** + * @description Swipe left action. + * Action taken when user swipes a message to the left on mobile. + * Implemented by the client. + * Possible values: + * - 0: Trash + * - 1: Spam + * - 2: Star + * - 3: Archive + * - 4: Mark as read + * @default 3 + */ + SwipeLeft: number; + /** + * @description Swipe right action. + * Action taken when user swipes a message to the right on mobile. + * Implemented by the client. + * Possible values: + * - 0: Trash + * - 1: Spam + * - 2: Star + * - 3: Archive + * - 4: Mark as read + * @default 0 + */ + SwipeRight: number; + /** + * @deprecated + * @example 0 + */ + AlsoArchive: number; + /** + * @deprecated + * @default 0 + */ + Hotkeys: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + Shortcuts: number; + /** + * @description Possible values: + * - 0: Disabled + * - 1: Enabled + * - 2: Enabled and Locked + * @default 0 + */ + PMSignature: number; + /** + * @description Possible values: + * - 0: Disabled + * - 1: Enabled + * @default 0 + */ + PMSignatureReferralLink: number; + /** + * @description Bitmap of image proxy related settings. + * - IncorporateImages: 1 (2^0), whether remote images are downloaded and incorporated into mail at delivery. + * Implemented by the backend. + * - ProxyImages : 2 (2^1), whether loading remote images on the clients passes through the proton proxy. + * Implemented by the client. + * @default 0 + */ + ImageProxy: number; + /** @example 50 */ + NumMessagePerPage: number; + /** + * @description Default mime type of drafts. Implemented by the client. + * Possible values: + * - 'text/html' + * - 'text/plain' + * @example text/html + */ + DraftMIMEType: string; + /** + * @description Preferred mime type of received messages. Implemented by the backend. + * Possible values: + * - 'text/html' + * - 'text/plain' + * @example text/html + */ + ReceiveMIMEType: string; + /** @example text/html */ + ShowMIMEType: string; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + EnableFolderColor: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + InheritParentFolderColor: number; + /** + * @description Possible values: + * - 0: disabled + * - 1: enabled + * @default 0 + */ + SubmissionAccess: number; + /** + * @deprecated + * @default 0 + */ + TLS: number; + /** + * @description Composer text direction. + * The direction of the text inside the message composer. + * Implemented by the client. + * Possible values: + * - 0: left to right + * - 1: right to left + * @default 0 + */ + RightToLeft: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + AttachPublicKey: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + Sign: number; + /** + * @description Default PGP scheme to use when sending externally. Implemented by the client. + * Possible values: + * - 8: PGP Inline + * - 16: PGP Mime + * @default 16 + */ + PGPScheme: number; + /** + * @description Prompt to trust key. + * When opening a message from another protonmail user for which there is no pinned key, prompt to pin key. + * Pinning the key results in updating the contact. + * Implemented by the client. + * Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + PromptPin: number; + /** + * @deprecated + * @default 0 + */ + Autocrypt: number; + /** + * @description When a message is created, add to it all the labels of the other messages in its conversation. + * Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + StickyLabels: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + ConfirmLink: number; + /** + * @description Possible values between 0 and 30 + * @default 10 + */ + DelaySendSeconds: number; + /** @default 0 */ + KT: number; + /** + * @description Possible values between 10 and 26 + * @default null + */ + FontSize: number | null; + /** @default null */ + FontFace: string; + /** + * @description Configure additional actions to take when messages or conversations are moved to spam + * Possible values: + * - null: ask what to do every time + * - 0: do nothing else + * - 1: unsubscribe with one-click list-unsubscribe if possible + * @default null + */ + SpamAction: number | null; + /** + * @description Whether the user wants to be asked for confirmation before blocking a sender + * Possible values: + * - null: ask for confirmation every time + * - 1: block sender without asking for confirmation + * @default null + */ + BlockSenderConfirmation: number | null; + /** @description Mobile-specific settings, only returned for mobile clients */ + MobileSettings: { + MessageToolbar?: { + IsCustom?: boolean; + Actions?: string[]; + }; + ConversationToolbar?: { + IsCustom?: boolean; + Actions?: string[]; + }; + ListToolbar?: { + IsCustom?: boolean; + Actions?: string[]; + }; + }; + /** + * @description Whether the user wants to have embedded-images hidden on this client. The default vlaue is 0. + * Possible values: + * - 0: Show embedded images + * - 1: Hide embedded images + * @default 0 + * @enum {integer} + */ + HideEmbeddedImages: 1 | 0; + /** + * @description Whether the user wants to have remote-images hidden on this client. The default vlaue is 0. + * Possible values: + * - 0: Show remote images + * - 1: Hide remote images + * @default 0 + * @enum {integer} + */ + HideRemoteImages: 1 | 0; + /** + * @description Whether the user wants to have sender-images hidden. The value is `0` by default. + * Possible values: + * - 0: Do not hide sender images + * - 1: Hide sender images + * @example 1 + * @enum {integer} + */ + HideSenderImages: 1 | 0; + /** + * @description Whether the user wants to remove metadata from image attachments. The value is `0` by default. + * Possible values: + * - false: Do not remove image metadata + * - true: Remove image metadata + * @example true + */ + RemoveImageMetadata: Record; + }; + /** User settings for VPN product */ + VPNSettings: { + /** + * @description OpenVPN / IKEv2 username + * @example 9rXSJiW7xf59U/OqUTjHRJy/ + */ + Name: string; + /** + * @description OpenVPN / IKEv2 password + * @example sHwX8ye/ipCFfj5K0xuZYTlD + */ + Password: string; + /** + * @description Status + * `0`: no vpn access + * `1`: vpn access + * `2`: vpn access eligible + * `3`: vpn access requested (waitlist) + * @example 2 + */ + Status: number; + /** + * @deprecated + * @description Trial has been removed, you should stop using this property + * @example 0 + */ + ExpirationTime: unknown; + /** + * @description Code name of the plan (string unique identifier constant over time) + * the user has, or null if no subscription + * @example mail2022 + */ + BasePlan?: string | null; + /** + * @description Code name of the VPN plan (string unique identifier constant over time), i.e. + * the plan giving to the user the more entitlement to VPN features (such as access to + * VPN paid servers) or 'free' if the user has no such subscription (either is free or + * have a non-VPN subscription, ex.: mail2022, drive2022) + * @example vpnbiz2023 + */ + PlanName?: string | null; + /** + * @description Title of the plan (PlanName) (for display only, the title of a + * plan can change over time, be translated, etc.) + * @example VPN Plus + */ + PlanTitle?: string | null; + /** + * @description Maximum number of connections/devices the user plan allows + * @example 10 + */ + MaxConnect: number; + /** + * @description Maximum server tier level the user can access + * @example 2 + */ + MaxTier?: number | null; + Groups: string[]; + /** + * @description `true` if the user needs to allocate connection + * (to the sub-user via the VPN settings panel for instance) + * @example false + */ + NeedConnectionAllocation: boolean; + /** + * @description `true` if the organization opted-in for telemetry) + * @example false + */ + BusinessEvents: boolean; + /** + * @description `true` if the current user plan allow to use the browser extension + * @example false + */ + BrowserExtension: boolean; + /** + * @description A plan that the current user can buy/upgrade to in order to be able to use the browser extension + * @example vpnpro2023 + */ + BrowserExtensionPlan?: string | null; + }; + Invoice: { + /** @example IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA== */ + ID: string; + /** @example 4 */ + Type: number; + /** @example 1 */ + State: number; + /** @example USD */ + Currency: string; + /** @example 0 */ + AmountDue: number; + /** @example 0 */ + AmountCharged: number; + /** @example 1505758141 */ + CreateTime: number; + /** @example 1506449824 */ + ModifyTime: number; + /** @example 1506449824 */ + AttemptTime: number; + /** @example 1 */ + Attempts: number; + }; + /** IncomingAddressForwardingResponse */ + IncomingAddressForwardingResponse: { + ID: components["schemas"]["Id"]; + Type: components["schemas"]["AddressForwardingType"]; + State: components["schemas"]["AddressForwardingState"]; + /** When an email is received by forwarderEmail, it will be forwarded to forwardeeEmail or forwardeeAddressID */ + ForwarderEmail: string; + ForwardeeAddressID: components["schemas"]["Id"]; + CreateTime: number; + /** The forwarding keys encrypted to the tokens. They are present only for encrypted forwarding + * in the pending state. To activate the forwarding all of them must be re-encrypted to the user + * keys and added to the correct address keyring. */ + ForwardingKeys: components["schemas"]["ActivationForwardingKey"][]; + Filter?: components["schemas"]["AddressForwardingFilter"] | null; + }; + /** OutgoingAddressForwardingResponse */ + OutgoingAddressForwardingResponse: { + ID: components["schemas"]["Id"]; + Type: components["schemas"]["AddressForwardingType"]; + State: components["schemas"]["AddressForwardingState"]; + ForwarderAddressID: components["schemas"]["Id"]; + /** The final email address to forward messages to * */ + ForwardeeEmail: string; + CreateTime: number; + Filter?: components["schemas"]["AddressForwardingFilter"] | null; + }; + VPNProfile: Record; + VPNLogical: { + /** @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ + ID: string; + /** + * @description Name `[A-Z]{2}(-[A-Z]{2})?#{server number}`, such as ES#1, US-FL#22, etc. The state suffix (i.e. `FL` in case of `US-FL`) is meant to be resolvable to a US state. + * @example US-FL#1 + */ + Name: string; + /** + * Format: alpha-2 + * @description alpha-2 country code + * @example CH + */ + EntryCountry: string; + /** + * Format: alpha-2 + * @description alpha-2 country code + * @example CH + */ + ExitCountry: string; + /** + * Format: alpha-2 + * @description alpha-2 country code + * @example CH + */ + HostCountry?: string | null; + /** + * @description Domain name + * @example es-05.protonvpn.com + */ + Domain: string; + /** + * @description A number representing the server tier. Users have access to certain tiers depending to their Plan + * @example 2 + * @enum {integer} + */ + Tier: 0 | 1 | 2; + /** + * @description **Bitmap** + * * `1`: Secure Core + * * `2`: Tor + * * `4`: P2P + * * `8`: Streaming + * * `16`: IPv6 + * * `32`: Restricted + * * `64`: Partner + * * `128`: Double Restriction + * @example 2 + */ + Features: number; + /** + * @description `1` if at least one physical server server is up and running and usable, `0` otherwise + * @example 1 + * @enum {integer} + */ + Status: 0 | 1; + /** + * @deprecated + * @description Use City or Name instead for geographic information + * @example null + */ + Region?: number | null; + /** + * @description Optional city + * @example Stockholm + */ + City?: number | null; + Servers: components["schemas"]["VPNServerTransformerInterface"][]; + /** + * @description Describe in a spiritual way how much the logical server is loaded + * @example 0 + */ + Load: number; + /** @description The coordinate of the datacenter */ + Location: { + /** + * @description Latitude + * @example 39.4667 + */ + Lat?: Record; + /** + * @description Longitude + * @example -0.3667 + */ + Long?: Record; + }; + /** + * @description The lower is the score, the better is the server for the current user, maximal precision (64 bits) for this number must be kept + * @example 3.615154888897451 + */ + Score: Record; + }; + CalendarWithMemberWithFlagsOutput: { + Members: components["schemas"]["MemberWithFlagsOutput"][]; + ID: components["schemas"]["Id"]; + Type: components["schemas"]["CalendarType"]; + Owner: components["schemas"]["CalendarOwner"]; + /** Format: date-time */ + CreateTime: string; + }; + MemberWithFlagsOutput: { + /** + * @description The calendar flags bitmap:
- `0`: Inactive: the calendar keys are not accessible and the current user cannot fix it
- `1`: Active: the calendar is all good!
- `2`: Update passphrase: a deactivated passphrase is again accessible, you should re-encrypt the linked calendar key using the primary passphrase
- `4`: Reset needed: the calendar needs to be reset
- `8`: Incomplete setup: the calendar setup was not completed, need to setup the key and passphrase
- `16`: Lost access: the user lost access to the calendar but an admin can re-invite him
+ * @example 1 + */ + Flags: number; + ID: components["schemas"]["Id"]; + /** + * @description Flags bitmap:
- `1`: Super-owner
- `2`: Owner
- `4`: Admin
- `8`: Read member list
- `16`: Write events
- `32`: Read events (full details)
- `64`: Availability view only
+ * @example 63 + */ + Permissions: number; + /** @example andy@pm.me */ + Email: string; + AddressId: components["schemas"]["Id"]; + CalendarId: components["schemas"]["Id"]; + /** @example Organizational Calendar */ + Name: string; + /** @example This text describes the calendar */ + Description: string; + /** @example #8989AC */ + Color: string; + /** @example 1 */ + Display: number; + /** + * @description Priority describing the order of the member, 1 is highest + * @example 1 + */ + Priority: number; + }; + EventLoopNotificationTransformer: { + /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ + ID: string; + /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ + UserID: string; + /** @example account_recovery */ + Type: string; + /** @description timestamp */ + Time: Record; + Payload: { + Title?: string; + Subtitle?: string; + Body?: string; + }; + }; + /** CalendarUserSettings */ + UserSettingsTransformer2: { + /** + * @description `0`: 7 Days, `1`: 5 Days + * @example 0 + * @enum {integer} + */ + WeekLength: 0 | 1; + /** + * @description `0`: Off, `1`: On + * @example 1 + * @enum {integer} + */ + DisplayWeekNumber: 0 | 1; + /** + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} + */ + AutoDetectPrimaryTimezone: 0 | 1; + /** @example Antarctica/Macquarie */ + PrimaryTimezone: string; + /** + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} + */ + DisplaySecondaryTimezone: 0 | 1; + /** + * @description Can be null if DisplaySecondaryTimezone is 0 + * @example null + */ + SecondaryTimezone: string; + /** + * @description `0`: DAILY, `1`: WEEKLY, `2`: MONTHLY, `3`: YEARLY, `4`: PLANNING + * @example 1 + * @enum {integer} + */ + ViewPreference: 0 | 1 | 2 | 3 | 4; + /** + * @description Can be null, if the calendar type is `subscription`, instead of `normal`, it cannot be set as the default calendar + * @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== + */ + DefaultCalendarID: string; + /** + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} + */ + ShowCancelled: 0 | 1; + /** + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} + */ + ShowDeclined: 0 | 1; + /** + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} + */ + AutoImportInvite: 0 | 1; + /** @description Bitmap of whom to share busy-schedule with:
- 1 (2^0): To users in the same organization */ + ShareBusySchedule: number; + }; + WalletOutput: { + ID: components["schemas"]["Id"]; + /** + * @description 1 if the wallet has a passphrase + * @example 0 + */ + HasPassphrase: number; + /** + * @description 0 if the wallet is created with Proton Wallet + * @example 0 + */ + IsImported: number; + /** + * Format: base64 + * @description Encrypted wallet mnemonic with the WalletKey, in base64 format + * @example + */ + Mnemonic?: components["schemas"]["BinaryString"] | null; + /** + * @description Unique identifier of the mnemonic, using the first 4 bytes of the master public key hash + * @example 912914fb + */ + Fingerprint?: string | null; + Name: components["schemas"]["BinaryString"]; + /** + * @description Order of priority + * @example 1 + */ + Priority: number; + /** + * Format: base64 + * @description Encrypted wallet public key with the WalletKey, in base64 format, only if on-chain watch-only + * @example + */ + PublicKey?: components["schemas"]["BinaryString"] | null; + Status: components["schemas"]["WalletStatus"]; + Type: components["schemas"]["WalletType"]; + /** + * @description Set to 1 if wallet key needs to be rotated + * @example 0 + */ + MigrationRequired: number; + /** + * @description Set to 1 if mnemonic is encrypted with user key too + * @example 0 + */ + Legacy: number; + }; + WalletAccountOutput: { + ID: components["schemas"]["Id"]; + WalletID: components["schemas"]["Id"]; + /** + * @description Preferred fiat currency + * @example CHF + */ + FiatCurrency: string; + DerivationPath: components["schemas"]["DerivationPath"]; + Label: components["schemas"]["BinaryString"]; + /** @description The index number that wallet last used to create address */ + LastUsedIndex: number; + /** + * @description Size of Bitcoin address pool + * @example 10 + */ + PoolSize: number; + /** + * @description Order of priority + * @example 1 + */ + Priority: number; + ScriptType: components["schemas"]["ScriptType"]; + Addresses: unknown[]; + }; + WalletBitcoinAddressOutput: { + ID: components["schemas"]["Id"]; + WalletID: components["schemas"]["Id"]; + WalletAccountID: components["schemas"]["Id"]; + Fetched: number; + Used: number; + /** @default null */ + BitcoinAddress: components["schemas"]["BitcoinAddress"] | null; + /** + * @description Detached signature of the bitcoin address + * @default null + * @example -----BEGIN PGP SIGNATURE-----... + */ + BitcoinAddressSignature: components["schemas"]["PGPSignature"] | null; + /** + * @description Index of the bitcoin address + * @default null + * @example 1 + */ + BitcoinAddressIndex: number | null; + }; + WalletKeyOutput: { + ID: components["schemas"]["Id"]; + WalletID: components["schemas"]["Id"]; + UserKeyID: components["schemas"]["Id"]; + /** + * @description Encrypted AES-GCM 256 key used to encrypt the mnemonic or public key, as armored PGP + * @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- + */ + WalletKey: string; + /** + * @description Detached signature of the encrypted AES-GCM 256 key used to encrypt the mnemonic or public key, as armored PGP + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + WalletKeySignature: string; + }; + WalletSettingsOutput: { + WalletID: components["schemas"]["Id"]; + /** + * @description Hide accounts, only used for on-chain wallet + * @example 0 + */ + HideAccounts: number; + /** + * @description Invoice default description, only used for lightning wallet + * @example Lightning payment from John Doe. + */ + InvoiceDefaultDescription?: string | null; + /** + * @description Invoice expiration time, only used for lightning wallet + * @example 3600 + */ + InvoiceExpirationTime: number; + /** + * @description Max fee for automatic channel opening with Proton Lightning node, expressed in SATS, only used for lightning wallet + * @example 5000 + */ + MaxChannelOpeningFee: number; + /** + * @description User should see wallet recovery phrase without 2FA + * @example false + */ + ShowWalletRecovery: boolean; + }; + WalletTransactionOutput: { + ID: components["schemas"]["Id"]; + WalletID: components["schemas"]["Id"]; + WalletAccountID: components["schemas"]["Id"]; + TransactionID: components["schemas"]["PGPMessage"]; + /** + * @description Unix timestamp of when the transaction got created in Proton Wallet or confirmed in blockchain for incoming ones + * @example 1707287982 + */ + TransactionTime?: string | null; + /** @description Set to 1 if output amount is smaller than 1001 Sats, or output size is bigger than 20 blocks */ + IsSuspicious: number; + /** @description Set to 1 if user does not want to spend UTXO from this transaction */ + IsPrivate: number; + /** @description Set to 1 if user did not want to reveal its identify during sending */ + IsAnonymous: number; + Type: components["schemas"]["TransactionType"]; + HashedTransactionID?: components["schemas"]["BinaryString"] | null; + /** @default null */ + Label: components["schemas"]["BinaryString"] | null; + /** @default null */ + ExchangeRate: components["schemas"]["ExchangeRateOutput"] | null; + /** @default null */ + Sender: components["schemas"]["PGPMessage"] | null; + /** @default null */ + ToList: components["schemas"]["PGPMessage"] | null; + /** @default null */ + Subject: components["schemas"]["PGPMessage"] | null; + /** @default null */ + Body: components["schemas"]["PGPMessage"] | null; + }; + WalletUserSettingsOutput: { + /** + * @description Accept terms and conditions + * @example 1 + */ + AcceptTermsAndConditions: number; + /** + * @description Preferred Bitcoin unit + * @example BTC + */ + BitcoinUnit: string; + /** + * @description Preferred fiat currency + * @example CHF + */ + FiatCurrency: string; + /** + * @description Hide empty used addresses + * @example 1 + */ + HideEmptyUsedAddresses: number; + /** + * @description Ask for 2FA verification when an amount threshold is reached + * @example 1000 + */ + TwoFactorAmountThreshold?: number | null; + /** + * @description Receive inviter notification + * @example 1 + */ + ReceiveInviterNotification: number; + /** + * @description Receive email integration notification + * @example 1 + */ + ReceiveEmailIntegrationNotification: number; + /** + * @description Receive transaction notification + * @example 1 + */ + ReceiveTransactionNotification: number; + /** + * @description User has already created a wallet once + * @example 1 + */ + WalletCreated: number; + }; + DriveShareRefreshCoreEventService: { + DriveShareRefresh: { + /** @enum {integer} */ + Action?: 2; + }; + }; + GroupMembershipGroup: { + ID: components["schemas"]["Id"]; + Name: string; + Address: string; + }; + ForwardingKeys: { + PrivateKey?: components["schemas"]["PGPPrivateKey"] | null; + ActivationToken?: components["schemas"]["PGPMessage"] | null; + }; + EventOutput: { + ID?: components["schemas"]["Id"] | null; + Action: components["schemas"]["EventAction"]; + }; + Sender: { + /** @example foo@protonmail.dev */ + Address: string; + /** @example Joe */ + Name: string; + /** + * @description Optional, whether to display the Proton badge.
+ * * Possible values:
+ * * - 1: Display the Proton badge
+ * * - 0: Do not display the Proton badge + * @example 1 + * @enum {integer} + */ + IsProton: 0 | 1; + /** + * @description Optional, whether to display the SenderImage.
+ * * Possible values:
+ * * - 1: Display the sender image
+ * * - 0: Do not display the sender image + * @example 1 + * @enum {integer} + */ + DisplaySenderImage: 0 | 1; + /** + * @description Optional, BIMI selector header, set if present on message or if domain has BIMI + * @example null + */ + BimiSelector?: string | null; + /** + * @description Whether the mail came through simple login + * @example 1 + * @enum {integer} + */ + IsSimpleLogin: 0 | 1; + }; + Recipient: { + /** @example foo@protonmail.dev */ + Address: string; + /** @example Joe */ + Name: string; + /** @description Optional */ + Group?: string | null; + /** + * @description Optional, whether to display the Proton badge.
+ * Possible values:
+ * - 1: Display the Proton badge
+ * - 0: Do not display the Proton badge + * @example 1 + * @enum {integer} + */ + IsProton: 0 | 1; + }; + /** @description Attachment counts grouped by the MIME type and disposition. + * Listed types here are an example */ + GroupedAttachmentsCount: { + "image/jpeg": { + /** @example 2 */ + inline?: number; + /** @example 1 */ + attachment?: number; + }; + "text/calendar": { + /** @example 1 */ + attachment?: number; + }; + }; + Metadata: { + ID: components["schemas"]["Id2"]; + Name?: string | null; + Size: number; + MIMEType: string; + Disposition?: components["schemas"]["Disposition"] | null; + }; + Sender2: Record; + Recipient2: Record; + /** @description Attachment counts grouped by the MIME type and disposition. + * Listed types here are an example */ + GroupedAttachmentsCount2: { + "image/jpeg": { + /** @example 2 */ + inline?: number; + /** @example 1 */ + attachment?: number; + }; + "text/calendar": { + /** @example 1 */ + attachment?: number; + }; + }; + Metadata2: { + ID: components["schemas"]["Id3"]; + Name?: string | null; + Size: number; + MIMEType: string; + Disposition?: components["schemas"]["Disposition2"] | null; + }; + ContactData: { + /** + * @description Possible values: + *
- 0: clear text + *
- 1: encrypted + *
- 2: signed + *
- 3: encrypted and signed + * @example 2 + * @enum {integer} + */ + Type: 0 | 1 | 2 | 3; + /** + * @description VCard data + * @example BEGIN:VCARD + * VERSION:4.0 + * FN:ProtonMail Features + * UID:proton-legacy-139892c2-f691-4118-8c29-061196013e04 + * item1.EMAIL;TYPE=work;PREF=1:features@protonmail.black + * item2.EMAIL;TYPE=home;PREF=2:features@protonmail.ch + * END:VCARD + */ + Data: string; + /** + * @description PGP signature of the data + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + Signature: string; + }; + Tree: { + List?: string[]; + /** @example Require */ + Type?: string; + }[]; + Plan: { + /** @example */ + ID: string; + /** + * @description bits, 1 = primary plan, 0 = sub-plan (add-on) + * @example [ + * 0, + * 1 + * ] + */ + Type: number; + /** + * @description bits, 1 = plan available for subscription + * @example 1 + * @enum {integer} + */ + State: 0 | 1; + /** @example 1 */ + Cycle: number; + /** @example business */ + Name: string; + /** @example ProtonMail Business (monthly) */ + Title: string; + /** @example USD */ + Currency: string; + /** @example 1000 */ + Amount: number; + /** @example 1 */ + MaxDomains: number; + /** @example 5 */ + MaxAddresses: number; + /** @example 25 */ + MaxCalendars: number; + /** @example 10737418240 */ + MaxSpace: number; + /** @example 2 */ + MaxMembers: number; + /** @example 0 */ + MaxVPN: number; + /** + * @description bits, 1 = mail, 4 = VPN + * @example 1 + */ + Services: number; + /** + * @description bits, 1 = catch-all addresses + * @example 1 + */ + Features: number; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1InternalEncrypted
2ExternalUnencrypted
3ExternalEncrypted
+ * @enum {integer} + */ + AddressForwardingType: 1 | 2 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Pending
1Active
2Outdated
3Paused
4Rejected
+ * @enum {integer} + */ + AddressForwardingState: 0 | 1 | 2 | 3 | 4; + ActivationForwardingKey: { + /** + * PGP message, encrypted with the forwardee address key and signed with the forwarder address key. + * @description The embedded secret is a 64-char hex string. + */ + ActivationToken: string; + /** Armored PGP private key, locked with the token */ + PrivateKey: string; + }; + /** AddressForwardingFilter */ + AddressForwardingFilter: { + Tree: components["schemas"]["Tree"]; + Sieve: string; + Version: components["schemas"]["SieveVersion"]; + }; + VPNServerTransformerInterface: { + /** + * @description Encrypted id + * @example l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA== + */ + ID: string; + /** + * @description IP client calls + * @example 95.215.61.163 + */ + EntryIP: string; + /** + * @description IP that calls the world + * @example 95.215.61.164 + */ + ExitIP: string; + /** + * @description Qualified domain name + * @example es-04.protonvpn.com + */ + Domain: string; + /** + * @description 1 if server is operational or 0 if it's down + * @example 1 + */ + Status: number; + /** + * @description **Bitmap** + * * where each service to be marked as down are flagged: + * * `1`: Bind + * * `2`: HostAlive + * * `4`: OpenVPN_TCP + * * `8`: OpenVPN_UDP + * * `16`: IKEv2 + * * `32`: WireGuard + * @example 12 + */ + ServicesDown: number; + /** + * @description Setup age of the given server + * @example 0 + */ + Generation: number; + /** + * @description Short explanation about the current status + * @example Provisionning + */ + ServicesDownReason?: string | null; + /** + * @description To match username suffixes provided at authentication + * @example us-va-01 + */ + Label: string; + /** + * @description X25519 public key PEM + * @example -----BEGIN PUBLIC KEY----- ... + */ + X25519PublicKey?: string | null; + /** @description Optional list of protocol-specific relays */ + EntryPerProtocol?: { + OpenVPNUDP?: { + /** + * @description IP of the relay + * @example 1.0.0.0 + */ + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: Record[]; + } | null; + OpenVPNTCP?: { + /** + * @description IP of the relay + * @example 1.0.0.0 + */ + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: Record[]; + } | null; + IKEv2?: { + /** + * @description IP of the relay + * @example 1.0.0.0 + */ + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: Record[]; + } | null; + WireGuardUDP?: { + /** + * @description IP of the relay + * @example 1.0.0.0 + */ + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: Record[]; + } | null; + WireGuardTCP?: { + /** + * @description IP of the relay + * @example 1.0.0.0 + */ + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: Record[]; + } | null; + WireGuardTLS?: { + /** + * @description IP of the relay + * @example 1.0.0.0 + */ + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: Record[]; + } | null; + } | null; + }; + /** + * @description

normal calendar: `0`, subscribed calendar: `1`

See values descriptions
See values descriptions
ValueDescription
0Normal
1Subscription
+ * @enum {integer} + */ + CalendarType: 0 | 1; + CalendarOwner: { + /** + * @description owner's email + * @example owner@pm.me + */ + Email: string; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Disabled
1Enabled
+ * @enum {integer} + */ + WalletStatus: 0 | 1; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1OnChain
2Lightning
+ * @enum {integer} + */ + WalletType: 1 | 2; + /** + * @description Path used to generate a series of Bitcoin addresses from a single seed phrase or mnemonic, only BIP 44, 49, 84 and 86 are currently accepted + * @example m/44'/0'/0' + */ + DerivationPath: string; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Legacy
2NestedSegwit
3NativeSegwit
4Taproot
+ * @enum {integer} + */ + ScriptType: 1 | 2 | 3 | 4; + /** + * @description BTC address + * @example 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa + */ + BitcoinAddress: string; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1ProtonToProtonSend
2ProtonToProtonReceive
3ExternalSend
4ExternalReceive
+ * @enum {integer} + */ + TransactionType: 1 | 2 | 3 | 4; + ExchangeRateOutput: { + ID: components["schemas"]["Id"]; + /** + * @description Bitcoin unit of the exchange rate + * @example BTC + */ + BitcoinUnit: string; + /** + * @description Fiat currency of the exchange rate + * @example CHF + */ + FiatCurrency: string; + /** + * @description Sign of the fiat currency (e.g. € for EUR) + * @example 100 + */ + Sign: string; + /** + * @description Time of the BTC/Fiat exchange rate + * @example 1707287982 + */ + ExchangeRateTime?: string | null; + /** + * @description Exchange rate BitcoinUnit/FiatCurrency + * @example 20000000 + */ + ExchangeRate: number; + /** + * @description Cents precision of the fiat currency (e.g. 1 for JPY, 100 for USD) + * @example 100 + */ + Cents: number; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Delete
1Create
2Update
3UpdateFlags
+ * @enum {integer} + */ + EventAction: 0 | 1 | 2 | 3; + /** @description An encrypted ID */ + Id2: string; + /** @enum {string} */ + Disposition: "attachment" | "inline"; + /** @description An encrypted ID */ + Id3: string; + /** @enum {string} */ + Disposition2: "attachment" | "inline"; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
2V2
+ * @enum {integer} + */ + SieveVersion: 2; + }; + responses: { + /** @description Plain success response without additional information */ + ProtonSuccessResponse: { + headers: { + /** @description The same as the body code */ + "X-Pm-Code"?: 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + /** @description General Error */ + ProtonErrorResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "get_core-{_version}-addresses-allowAddressDeletion": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-keys-address-active": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description The address ID + * @example ACXDmTa...Bub14w== + */ + AddressID?: string; + Keys?: { + /** + * @description Encrypted AddressKey ID + * @example G1MbEt3Ep5P_E...Wz8WbHVAOl_6h== + */ + AddressKeyID?: string; + /** + * @description 1 if the FE can decrypt this key + * @example 1 + */ + Active?: number; + }[]; + SignedKeyList?: { + /** @example JSON.stringify([{"Fingerprint": "fde90483475164ec6353c93f767df53b0ca8395c","SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Primary": 1,"Flags": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyList?: components["schemas"]["KTKeyList"] | null; + }; + }; + }; + }; + }; + "get_core-{_version}-keys": { + parameters: { + query?: { + Email?: string; + Fingerprint?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description 1:Internal, 2:External + * @example 1 + */ + RecipientType?: number; + /** + * @description 0:KT is valid, 1: External address - keys omitted, 2: Catch all - wrong SKL + * @example 0 + */ + IgnoreKT?: number; + /** @example text/html */ + MIMEType?: string; + Keys?: { + /** + * @description Bitmap with the following values.
+ * Key is not compromised = 1 (2^0) (if the bit is set to one the key is not compromised)
+ * Key is not obsolete = 2 (2^1)
+ * @example 3 + */ + Flags?: number; + /** @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- */ + PublicKey?: string; + /** + * @description 0: Internal, 1: WKD, 2: KOO + * @example 0 + * @enum {integer} + */ + Source?: 0 | 1 | 2; + }[]; + SignedKeyList?: components["schemas"]["KTKeyList"]; + /** @example [] */ + Warnings?: string[]; + /** + * @description Tells whether this is an official Proton address, optional field + * @example 1 + */ + IsProton?: number; + }; + }; + }; + }; + }; + "post_core-{_version}-keys": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateLegacyKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Key?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Version?: number; + /** @example 3 */ + Flags?: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token?: string | null; + /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string | null; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + Fingerprints?: string[]; + /** @example null */ + Activation?: number; + /** @example 1 */ + Primary?: number; + }; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-address": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example xRvCGwFq_TW7...i8FtJaGyFEq0g== */ + AddressID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ + AddressForwardingID?: string; + /** @example 1 */ + Primary?: number; + /** @example -----BEGIN PGP MESSAGE-----.* */ + Token?: string; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + Signature?: string; + SignedKeyList?: { + /** @example JSON.stringify([{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Key?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Version?: number; + /** @example 3 */ + Flags?: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token?: string | null; + /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string | null; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + Fingerprints?: string[]; + /** @example null */ + Activation?: number; + /** @example 1 */ + Primary?: number; + /** @example 1 */ + Active?: number; + }; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-group": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example xRvCGwFq_TW7...i8FtJaGyFEq0g== */ + AddressID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example -----BEGIN PGP MESSAGE-----.* */ + OrgToken?: string; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + OrgSignature?: string; + SignedKeyList?: { + /** @example JSON.stringify([{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Key?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Flags?: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + OrgToken?: string; + /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + OrgSignature?: string; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + Fingerprints?: string[]; + /** @example 1 */ + Primary?: number; + /** @example 1 */ + Active?: number; + }; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-setup": { + parameters: { + query?: { + /** + * @description Flag indicating that /core/v4/welcome-mail-send and /core/v4/checklist/get-started/init endpoints are called by the client + * @example 1 + */ + AsyncUserInitialization?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SetupKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { + Keys?: components["schemas"]["UserKey"] & { + /** @example 3 */ + Flags?: number; + }; + }; + VPN?: { + /** @example 1 */ + Status?: number; + /** @example 0 */ + ExpirationTime?: number; + /** @example visionary */ + PlanName?: string; + /** @example 10 */ + MaxConnect?: number; + /** @example 2 */ + MaxTier?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}-delete": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example ACXDmTaBub14w== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SignedKeyListInputWrapper"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-address-{enc_id}-delete": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example ACXDmTaBub14w== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SignedKeyListInputWrapper"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-private": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-images-logo": { + parameters: { + query?: { + /** @example noreply%40amazon.com */ + Address?: components["schemas"]["LogoRequest"]["Address"]; + /** @example amazon.com */ + Domain?: components["schemas"]["LogoRequest"]["Domain"]; + /** @example 64 */ + Size?: components["schemas"]["LogoRequest"]["Size"]; + Mode?: components["schemas"]["LogoRequest"]["Mode"]; + BimiSelector?: components["schemas"]["LogoRequest"]["BimiSelector"]; + /** @example 2 */ + MaxScaleUpFactor?: components["schemas"]["LogoRequest"]["MaxScaleUpFactor"]; + Format?: components["schemas"]["LogoRequest"]["Format"]; + ComputedAddress?: components["schemas"]["LogoRequest"]["ComputedAddress"]; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Binary data of the image */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description Return an empty image when we cannot find a valid logo */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-members-{enc_id}-addresses": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: number; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: number; + }; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Addresses?: components["schemas"]["AddressUser"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-addresses": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: components["schemas"]["AddressUser"] & { + /** @example Fred */ + MemberName?: string; + /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + MemberID?: string; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-addresses": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Addresses?: components["schemas"]["AddressUser"][]; + SignedAddressList?: components["schemas"]["KTAddressListTransformer"]; + }; + }; + }; + }; + }; + "post_core-{_version}-addresses": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: components["schemas"]["AddressUser"] & { + /** @example Fred */ + MemberName?: string; + /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + MemberID?: string; + }; + }; + }; + }; + }; + }; + "post_core-{_version}-members-addresses-available": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-addresses-order": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReorderAddressesInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-addresses-setup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: { + /** @example vuGSa1zsx0kV0jsfhX_xKSDQ0dvcLdMduA_c2c9fhaC1ZYCZKe8gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + ID?: string; + /** @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== */ + DomainID?: string; + /** @example me@protonmail.com */ + Email?: string; + /** @example 0 */ + Send?: number; + /** + * @description 0 is disabled, 1 is enabled, can be set by user + * @example 1 + */ + Status?: number; + /** + * @description 1 is original PM, 2 is PM alias, 3 is custom domain address + * @example 1 + */ + Type?: number; + /** + * @description 1 is active address (Status=1 and has key), 0 is inactive (cannot send or receive) + * @example 0 + */ + Receive?: number; + /** @example 1 */ + Order?: number; + /** @example hi */ + DisplayName?: string; + /** @example signature */ + Signature?: string; + /** @example 0 */ + HasKeys?: number; + /** @example [] */ + Keys?: string[]; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-addresses-canonical": { + parameters: { + query?: { + /** @description The list of email addresses, limited to maximum 100. They must be url encoded. */ + Emails?: string[]; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 1001 */ + Code?: number; + Responses?: { + /** @example john.doe+friend@gmail.com */ + Email?: string; + Response?: { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example johndoe@gmail.com */ + CanonicalEmail?: string; + }; + }[]; + }; + }; + }; + }; + }; + "get_core-{_version}-addresses-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: components["schemas"]["AddressUser"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-addresses-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-domains-{enc_id}-addresses": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: number; + }; + header?: never; + path: { + /** + * @description the encrypted domain id + * @example lKJlejjlk== + */ + domainid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Addresses?: (components["schemas"]["AddressUser"] & { + /** + * @description whether this is the catch-all address for this domain + * @example 0 + */ + CatchAll?: number; + /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + MemberID?: string; + })[]; + }; + }; + }; + /** @description Domain does not exist */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Domain does not exist */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-domains-{enc_id}-claimedAddresses": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: number; + }; + header?: never; + path: { + /** + * @description the encrypted domain id + * @example lKJle...jjlk== + */ + DomainId: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Addresses?: { + /** @example john.doe+friend@mydomain.com */ + Email?: string; + }[]; + }; + }; + }; + /** @description Domain does not exist */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Domain does not exist */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-enable": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-disable": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-delete": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-type": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ChangeAddressTypeInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-rename-internal": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJl...ejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example john.doe */ + Local?: string; + AddressKeys?: { + /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + }[]; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-rename-external": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJle...jjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameUnverifiedAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_addressId}-encryption": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the address id + * @example ACXDmTaBub14w== + */ + addressid: string; + _version: string; + enc_addressId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateEncryptionSignatureFlagsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-addresses-permissions-organization-switch": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressIdsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["SwitchAddressesOrganizationPermissionsTransformer"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{memberId}-saml": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{memberId}-saml": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{memberId}-devices-{deviceId}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + deviceId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{memberId}-devices": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-{id}-devices": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-devices-pending": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{memberId}-devices-{deviceId}-reject": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + /** + * @description the device id + * @example ACXDmTaBub14w== + */ + deviceId: components["schemas"]["Id"]; + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{memberId}-devices-reset": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ResetAuthDevicesInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-keys": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateMemberKeysInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + MemberKey?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Version?: number; + /** @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- */ + PublicKey?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + Fingerprints?: string[]; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Activation?: string; + /** @example 1 */ + Primary?: number; + /** @example 3 */ + Flags?: number; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-organizations-scim": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-organizations-scim": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateScimTenantInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-organizations-scim": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateScimTenantInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-keys-user": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddNewUserKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + KeyID?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-domains": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domains?: components["schemas"]["DomainTransformer"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-domains": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateDomainInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domain?: components["schemas"]["DomainTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-domains-available": { + parameters: { + query?: { + Type?: string | null; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domains?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-domains-premium": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domains?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-domains-optin": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example proton.me */ + Domain?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-domains-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domain?: components["schemas"]["DomainTransformer"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-domains-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-domains-{enc_id}-catchall": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateCatchAllAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-organizations": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example My Org */ + Name?: string; + /** @example My Org */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** + * @description Plan attribution to specific product, 1 = Mail, 2 = Drive, 4 = VPN + * @example 1 + */ + PlanFlags?: number; + /** @example 0 */ + TwoFactorRequired?: number; + /** + * @description If non-null, number of seconds until 2FA setup enforced + * @example null + */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 15 */ + MaxVPN?: number; + /** + * @description Bits, 1 = catch-all addresses + * @example 0 + */ + Features?: number; + /** + * @description Bits, 1 = loyalty + * @example 0 + */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 0 */ + UsedCalendars?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 1 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** + * @example 1 + * @enum {integer} + */ + ToMigrate?: 0 | 1; + /** + * @example 1 + * @enum {integer} + */ + BrokenSKL?: 0 | 1; + /** + * @description Number of invitations remaining of the org. This value is decremented when an invitee accepts an invitation + * @example 5 + */ + InvitationsRemaining?: number; + /** + * @description Whether the org requires a key to operate. An org requires a key if it can have public managed members. + * @example 1 + * @enum {integer} + */ + RequiresKey?: 0 | 1; + /** + * @description Whether the org requires a custom domain to operate. + * @example 1 + * @enum {integer} + */ + RequiresDomain?: 0 | 1; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-groups-external-{jwt}": { + parameters: { + query?: { + GroupID?: components["schemas"]["Id"] | null; + }; + header?: never; + path: { + _version: string; + jwt: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "delete_core-{_version}-groups-external-{jwt}": { + parameters: { + query?: { + GroupID?: components["schemas"]["Id"] | null; + }; + header?: never; + path: { + _version: string; + jwt: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_core-{_version}-groups-members": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddGroupMemberRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + GroupMember?: components["schemas"]["GroupMemberResponse"]; + }; + }; + }; + }; + }; + "get_core-{_version}-groups": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Total?: number; + Groups?: components["schemas"]["GroupResponse"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-groups": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateGroupRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; + }; + }; + }; + }; + }; + "post_core-{_version}-groups-unsubscribe-{jwt}": { + parameters: { + query?: { + GroupID?: components["schemas"]["Id"] | null; + }; + header?: never; + path: { + _version: string; + jwt: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "put_core-{_version}-groups-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["EncryptedId"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateGroupRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-groups-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-groups-members-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-groups-members-{groupMemberId}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + groupMemberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["EditGroupMemberRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + GroupMember?: components["schemas"]["GroupMemberResponse"]; + }; + }; + }; + }; + }; + "get_core-v4-groups-members-external-{jwt}": { + parameters: { + query?: never; + header?: never; + path: { + jwt: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExternalGroupMembershipsResponse"]; + }; + }; + }; + }; + "get_core-v4-groups-{group_enc_id}-members": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + group_enc_id: components["schemas"]["EncryptedId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupMembersResponse"]; + }; + }; + }; + }; + "get_core-v4-groups-members-internal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalGroupMembershipsResponse"]; + }; + }; + }; + }; + "put_core-{_version}-groups-{enc_id}-reinvite": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-groups-members-{groupMemberId}-resume": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + groupMemberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + GroupMember?: components["schemas"]["GroupMemberResponse"]; + }; + }; + }; + }; + }; + "post_core-{_version}-invites": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example notification@email */ + Email?: string; + /** + * @description 1 for mail, 2 for VPN + * @example 1 + */ + Type?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_core-{_version}-invites-unused": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-invites-check": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-keys-all": { + parameters: { + query: { + /** + * @description The lookup email + * @example test@example.com + */ + Email: string; + /** + * @description If 1, it will not perform any external lookup, and only provide information from the Proton DB + * @example 1 + */ + InternalOnly?: "0" | "1"; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Success code + * @example 1000 + */ + Code?: number; + /** @description Information about the internal address itself, if it exists. Since the SKL is mandatory, this will never be nullable. */ + Address?: { + /** @description Public key list for this address with metadata */ + Keys?: { + /** + * @description Armored OpenPGP public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string; + /** + * @description 1 when primary, 0 when not + * @example 1 + */ + Primary?: number; + /** + * @description Key usage flags (see confluence docs about Signed Key Lists) + * @example 3 + */ + Flags?: number; + /** + * @description Always (0) internal for verified keys + * @example 0 + */ + Source?: number; + }[]; + /** @description Signed metadata to verify the public key list */ + SignedKeyList?: components["schemas"]["KTKeyList"] | null; + }; + /** @description Information about the catch all address itself, if it exists. This can be null if the address keys are valid */ + CatchAll?: { + /** @description Public key list for the catch-all address with metadata */ + Keys?: { + /** + * @description Armored OpenPGP public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string; + /** + * @description 1 when primary, 0 when not + * @example 1 + */ + Primary?: number; + /** + * @description Key usage flags (see confluence docs about Signed Key Lists) + * @example 3 + */ + Flags?: number; + /** + * @description Always (0) internal for verified keys + * @example 0 + */ + Source?: number; + }[]; + /** @description Signed metadata to verify the public key list */ + SignedKeyList?: components["schemas"]["KTKeyList"] | null; + } | null; + /** @description Any other key that cannot be verified, such as Proton legacy keys or WKD. This can be null if there are none. */ + Unverified?: { + /** @description Public key list without any trusted metadata. These keys should not be used for signature verification, but for opportunistic encryption only. Can be pinned. */ + Keys?: { + /** + * @description Armored OpenPGP public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string; + /** + * @description 1 when primary, 0 when not + * @example 1 + */ + Primary?: number; + /** + * @description Key usage flags (see confluence docs about Signed Key Lists) + * @example 3 + */ + Flags?: number; + /** + * @description 0: Internal, 1: WKD, 2: KOO + * @example 0 + * @enum {integer} + */ + Source?: 0 | 1 | 2; + }[]; + } | null; + /** @description List of warnings to show to the user related to phishing and message routing */ + Warnings?: string[]; + /** + * @description True when domain has valid proton MX + * @example true + */ + ProtonMX?: boolean; + /** + * @description Tells whether this is an official Proton address + * @example 1 + */ + IsProton?: number; + }; + }; + }; + /** @description No address found */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Error code 33102 corresponds to a failed lookup. It is returned only when (a) internal only lookup is requested and the user does not exist or (b) when the address is routed towards an internal domain (with valid MX records) and it does not exist internally + * @example 33102 + */ + Code?: number; + }; + }; + }; + }; + }; + "get_core-{_version}-keys-signedkeylists": { + parameters: { + query?: { + /** @deprecated */ + Email?: string | null; + Identifier?: string | null; + /** + * @deprecated + * @description It will return all SKLs where a revision change happened after the specified epoch ID + */ + AfterEpochID?: number; + /** @description It will return all SKLs where a revision change happened after the specified revision */ + AfterRevision?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyLists?: components["schemas"]["KTKeyList"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-signedkeylists": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressKeyInput3"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyList?: components["schemas"]["KTKeyList"] | null; + }; + }; + }; + }; + }; + "get_core-{_version}-keys-signedkeylist": { + parameters: { + query?: { + /** @deprecated */ + Email?: string | null; + Identifier?: string | null; + /** @description The returned SKL will be for the specified revision, if it exists */ + Revision?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyList?: components["schemas"]["KTKeyList"]; + }; + }; + }; + }; + }; + "get_core-{_version}-keys-salts": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + KeySalts?: { + /** @example */ + ID?: string; + /** @example */ + KeySalt?: string; + }[]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-address-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-address-{enc_id}-subkeys": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressKeyInput2"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-signedkeylists-signature": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressKeyInput4"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}-primary": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example ACXDmTaBub14w== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SignedKeyListInputWrapper"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}-flags": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example ACXDmTaBub14w== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateFlagsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-tokens": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReplaceAddressTokensInput"]; + }; + }; + responses: { + /** @description Address tokens correctly uploaded */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Invalid token list */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-keys-user-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the user key id + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReactivateUserKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-keys-user-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The User Key encrypted id + * @example lKJlej...jlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User key correctly deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Unable to delete user key, some preconditions are missing */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-keys-private-upgrade": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + KeySalt?: string; + Keys?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + }[]; + UserKeys?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + }[]; + AddressKeys?: { + /** @example adsft3Ep5P_EWz8WbasdkVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + /** @example -----BEGIN PGP MESSAGE-----.* */ + Token?: string; + /** @example ----BEGIN PGP SIGNATURE-----.* */ + Signature?: string; + }[]; + SignedKeyLists?: { + "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { + /** @example JSON.stringify([{"SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353","Primary": 1,"Flags": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + /** + * @description If org admin and can decrypt org key + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- + */ + OrganizationKey?: string; + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-migrate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}-activate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LegacyKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LegacyKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ResetUserKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Members?: components["schemas"]["MemberInfo"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-members": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateMemberInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-invitations": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateMemberInvitationInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-invitations-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberInvitationInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-disable": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-enable": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-quota": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example 9900000000 */ + MaxSpace?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-name": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example Jason */ + Name?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-role": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberRoleInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{memberId}-ai": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberAIEntitlementInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-privatize": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-me": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-me-unprivatize": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetMemberUnprivatizationOutput"] & { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-me-unprivatize": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AcceptMemberUnprivatizationInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "delete_core-{_version}-members-me-unprivatize": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-members-{id}-unprivatize-resend": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-members-{id}-unprivatize": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RequestMemberUnprivatizationInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "delete_core-{_version}-members-{id}-unprivatize": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-members-{enc_id}": { + parameters: { + query?: { + /** + * @description If set to 1, will include each members addresses in the response. Defaults to 0. + * @example 1 + */ + IncludeAddresses?: number; + }; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-{enc_id}-details": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTa...Bub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Last login time (unix timestamp) + * @example 1654615966 + */ + LastLoginTime?: number | null; + /** + * @description The user id associated to the member + * @example xRvCGwF...aGyFEq0g== + */ + UserID?: string; + /** + * @description Last activity time (unix timestamp) + * @example 1654615966 + */ + LastActivityTime?: number | null; + /** + * @description Creation time (unix timestamp) + * @example 1654615966 + */ + CreationTime?: number; + }; + }; + }; + }; + }; + "get_core-{_version}-members-{enc_id}-authlog": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: string; + }; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description List of authentication logs, ordered by "Time" (timestamp of the event) descending */ + Log?: components["schemas"]["AuthLogResponse"][]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-require2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "delete_core-{_version}-members-{enc_id}-require2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-permissions-forwarding": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{enc_id}-permissions-forwarding": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-permissions": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MemberManagePermissionsDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-keys-setup": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example DFSXmTadD14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberKeysInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + ID?: string; + /** @example 2 */ + Role?: number; + /** @example 1 */ + Private?: number; + /** @example 0 */ + Type?: number; + /** @example 100000000 */ + MaxSpace?: number; + /** @example 0 */ + MaxVPN?: number; + /** @example Jason */ + Name?: string; + /** @example 81780955 */ + UsedSpace?: number; + /** @example 0 */ + Self?: number; + /** @example 0 */ + Subscriber?: number; + /** @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- */ + PublicKey?: string; + Keys?: { + /** @example adsfgt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Version?: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token?: string; + /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE-----" */ + OrgSignature?: string; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + /** @example 1 */ + Primary?: number; + }[]; + }; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-keys-migrate": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example DFSXmTadD14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + AddressKeys?: { + /** @example adsft3Ep5P_EWz8WbasdkVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + /** @example -----BEGIN PGP MESSAGE-----.* */ + Token?: string; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + Signature?: string; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + OrgSignature?: string; + }[]; + SignedKeyLists?: { + /** @description AddressID */ + "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { + /** @example JSON.stringify([{""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 1,""Flags"": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-keys-signedkeylists": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example DFSXmTadD14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + SignedKeyLists?: { + /** @description AddressID */ + "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { + /** @example JSON.stringify([{""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 1,""Flags"": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-keys-unprivatize": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example DFSXmTadD14w== + */ + memberid: string; + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UnprivatizeMemberInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-auth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description If true, LOCKED and PASSWORD scopes will be assigned to the child session + * @example false + */ + Unlock?: boolean; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example f3804b2ad70c3992a9496ff07f3e9b93 */ + UID?: string; + /** @example 0 */ + LocalID?: number; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + /** + * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have + * @example 5 + */ + RefreshCounter?: number; + }; + }; + }; + }; + }; + "get_core-{_version}-members-{enc_id}-sessions": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Sessions?: (components["schemas"]["Session"] & { + /** + * @deprecated + * @example gony7nIW...KkhhFxA== + */ + UserID?: string | null; + /** + * @deprecated + * @example PlISx5...cSY-tfNw== + */ + OwnerUserID?: string | null; + /** + * @description Localized name of ClientID used in the login process + * @example Proton Account for web + */ + LocalizedClientName?: string; + })[]; + /** @example 0 */ + LocalID?: number; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{enc_id}-sessions": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description If true, LOCKED and PASSWORD scopes will be assigned to the child session + * @example false + */ + Unlock?: boolean; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example f3804b2ad70c3992a9496ff07f3e9b93 */ + UID?: string; + /** @example 0 */ + LocalID?: number; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + /** + * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have + * @example 5 + */ + RefreshCounter?: number; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{enc_id}-sessions": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{enc_id}-sessions-{uid}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-organizations-keys": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetOrganizationKeysOutput"]; + }; + }; + }; + }; + "put_core-{_version}-organizations-keys": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReplaceOrganizationKeysInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-organizations-keys": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReplaceOrganizationKeysInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-organizations-keys-backup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example 0123456789abcdef */ + KeySalt?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-keys-backup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-organizations-keys-backup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-name": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationNameInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-email": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationEmailInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationTwoFactorGracePeriodInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-require2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 1 = at least enforced for admin members, 2 = enforced for all members + * @example 1 + */ + Require?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 20 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + }; + }; + }; + }; + }; + }; + "delete_core-{_version}-organizations-require2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 20 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-keys-activate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ActivateOrganizationKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-organizations-membership": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-organizations-2fa-remind": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-organizations-keys-migrate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateOrganizationKeysInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Organization already migrated */ + Error?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-organizations-keys-signature": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["GetOrganizationIdentityOutput"]; + }; + }; + }; + }; + "put_core-{_version}-organizations-keys-signature": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrgKeyFingerprintSignatureInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-organizations-logo-{logo_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + logo_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Binary data of the image */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + }; + }; + "get_core-{_version}-organizations-settings": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["OrganizationSettings2"]; + }; + }; + }; + }; + "put_core-{_version}-organizations-settings": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["OrganizationSettings"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + }; + }; + "post_core-{_version}-organizations-settings-logo": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["OrganizationLogo"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + }; + }; + "delete_core-{_version}-organizations-settings-logo": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + }; + }; + "get_core-{_version}-captcha": { + parameters: { + query: { + /** @example 1 */ + Dark?: string | null; + /** @example 1 */ + ForceWebMessaging?: number | null; + /** @example a9mT4hlKgS_h66JKxe-MC5pp */ + Token: string; + }; + header?: { + "x-pm-nonce"?: string | null; + host?: string; + }; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Captcha HTML page */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-resources-captcha": { + parameters: { + query: { + /** @example 1 */ + Dark?: string | null; + /** @example 1 */ + ForceWebMessaging?: number | null; + /** @example a9mT4hlKgS_h66JKxe-MC5pp */ + Token: string; + }; + header?: { + "x-pm-nonce"?: string | null; + host?: string; + }; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Captcha HTML page */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-resources-zendesk": { + parameters: { + query?: { + /** @example 83fabdab-1337-4fd7-85c0-39baf5c114fe */ + Key?: string; + }; + header?: { + "x-pm-nonce"?: string | null; + }; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Zendesk chat */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-saml-setup-fields": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Sso"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-saml-setup-xml": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SsoXml"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-saml-setup-url": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SsoUrl"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-configs": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-configs-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-saml-configs-{enc_id}-fields": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Sso"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-saml-configs-{enc_id}-delete": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-sp-info": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Info"]; + }; + }; + }; + }; + "get_core-{_version}-saml-edugain-info": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-edugain-info-{domainName}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + domainName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-metadata": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description XML representation of the SP metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/xml": string; + }; + }; + }; + }; + "get_core-{_version}-settings": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-password": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-password-upgrade": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-email": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example abc@gmail.com */ + Email?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-email-verify": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + Token?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-email-notify": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example 1 + * @enum {integer} + */ + Notify?: 0 | 1; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-email-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** + * @description 0 for off, 1 for on + * @example 1 + */ + Reset?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-phone": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example +18005555555 */ + Phone?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-phone-verify": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + Token?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-phone-notify": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example 1 + * @enum {integer} + */ + Notify?: 0 | 1; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-phone-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** + * @description 0 for off, 1 for on + * @example 1 + */ + Reset?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-locale": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example en_US */ + Locale?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-logauth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0 = off, 1 = on, 2 = on with IP logging + * @example 0 + */ + LogAuth?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-devicerecovery": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description possible values:
- 0: disable
- 1: enable */ + DeviceRecovery?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-news": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateNewsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "patch_core-{_version}-settings-news": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PatchNewsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-settings-news-external": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: { + /** + * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. + * @example 4 + */ + News?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-news-external": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateNewsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: { + /** + * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. + * @example 4 + */ + News?: number; + }; + }; + }; + }; + }; + }; + "patch_core-{_version}-settings-news-external": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PatchNewsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: { + /** + * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. + * @example 4 + */ + News?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-density": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0:comfortable, 1:compact + * @example 0 + */ + Density?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-invoicetext": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Maximum 5 lines + * @example Mickey Mouse, Esq. + * Cartoon Law Services + */ + InvoiceText?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-codes": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + /** @description 16 alphanumeric codes, each 8 characters long */ + TwoFactorRecoveryCodes?: string[]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 10041 */ + Code?: number; + /** @example Two Factor confirmation failed */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-2fa-totp": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-totp": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example JBSWY3DPEHPK3PXP */ + TOTPSharedSecret?: string; + /** @example 203941 */ + TOTPConfirmation?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + /** @description 16 alphanumeric codes, each 8 characters long */ + TwoFactorRecoveryCodes?: string[]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 10041 */ + Code?: number; + /** @example Two Factor confirmation failed */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example JBSWY3DPEHPK3PXP */ + TOTPSharedSecret?: string; + /** @example 203941 */ + TOTPConfirmation?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + /** @description 16 alphanumeric codes, each 8 characters long */ + TwoFactorRecoveryCodes?: string[]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 10041 */ + Code?: number; + /** @example Two Factor confirmation failed */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example user_name */ + Username?: string; + /** + * @description Reset token + * @example A194YN2F9R + */ + Token?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 19502 */ + Code?: number; + /** @example Invalid reset token. Please request another token and try again */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-settings-2fa-register": { + parameters: { + query?: { + /** + * @description If true, it requires a cross-platform authenticator (e.g. Yubikey) and forbids TPMs (e.g. Windows Hello) + * @example true + */ + CrossPlatform?: boolean; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Contains the user's currently registered FIDO2 credentials. */ + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + /** + * @description Refer to the definition of PublicKeyCredentialCreationOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. + * @example + */ + RegistrationOptions?: Record; + /** @description Supported attestation formats. */ + AttestationFormats?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-register": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Refer to the definition of PublicKeyCredentialCreationOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. + * @example + */ + RegistrationOptions?: Record; + /** + * @description clientData (base64) returned from the client authentication library + * @example + */ + ClientData?: string; + /** + * @description attestationObject (base64) returned from the client authentication library + * @example + */ + AttestationObject?: string; + /** + * @description An array of transports if known, otherwise an empty array. + * @example usb + */ + Transports?: string; + /** + * @description My FIDO2 key + * @example User defined name for the credential. + */ + Name?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-{credentialID}-remove": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + credentialID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-2fa-{credentialID}-rename": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + credentialID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description User defined name for the credential. + * @example My FIDO2 key + */ + Name?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-hide-side-panel": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateHideSidePanelInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-username": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Length <= 40 */ + Username?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-theme": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Theme"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-themetype": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example 1 */ + ThemeType?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-weekstart": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description `0`: Locale default, `1`: Monday, `6`: Saturday, `7`: Sunday + * @example 1 + */ + WeekStart?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-dateformat": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0: Locale default, 1: DD_MM_YYYY, 2: MM_DD_YYYY, 3: YYYY_MM_DD + * @example 1 + */ + DateFormat?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-timeformat": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0: Locale default, 1: 24H, 2: 12H + * @example 1 + */ + TimeFormat?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-welcome": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2000 */ + Code?: number; + /** @example Unknown client */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-earlyaccess": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0: Disabled, 1: Enabled + * @example 1 + */ + EarlyAccess?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2000 */ + Code?: number; + /** @example Invalid client */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-flags": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0: Disabled, 1: Enabled + * @example 1 + */ + Welcomed?: number; + /** + * @description 0: Disabled, 1: Enabled - Note: requires SettingsFlagsAllowV6OptIn feature flag to be enabled before use + * @example 0 + */ + SupportPgpV6Keys?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-telemetry": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description possible values:
- 0: disable
- 1: enable */ + Telemetry?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-crashreports": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description possible values:
- 0: disable
- 1: enable */ + CrashReports?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-highsecurity": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description User can't enable High Security (only some users are eligible) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @default 2011 */ + Code: number; + /** @default You do not have an active subscription */ + Error: string; + }; + }; + }; + }; + }; + "delete_core-{_version}-settings-highsecurity": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-breachalerts": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description User can't enable Breach Alert (only some users are eligible) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @default 2011 */ + Code: number; + /** @default You do not have an active subscription */ + Error: string; + }; + }; + }; + }; + }; + "delete_core-{_version}-settings-breachalerts": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-sessionaccountrecovery": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SessionAccountRecoveryInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-ai-assistant-flags": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AIAssistantFlagsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-news-unsubscribe": { + parameters: { + query?: { + News?: number; + Jwt?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_core-{_version}-support-schedulecall": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ScheduleSupportCallOutput"]; + }; + }; + }; + }; + "put_core-{_version}-members-{memberId}-lumo": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberLumoEntitlementInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-settings-product-disabled": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProductDisabledInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-users-delete": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-users-delete": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * Format: base64 + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * Format: base64 + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * Format: hex + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** + * Format: base64 + * @description clientData (base64) returned from the client authentication library + * @example + */ + ClientData?: string; + /** + * Format: base64 + * @description authenticatorData (base64) returned from the client authentication library + */ + AuthenticatorData?: string; + /** + * Format: base64 + * @description signature (base64) returned from the client authentication library + */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example optional {DIFFERENT_ACCOUNT, TOO_EXPENSIVE, MISSING_FEATURE, USE_OTHER_SERVICE, OTHER} */ + Reason?: string; + /** @example #poor */ + Feedback?: string; + /** @example ein@stein.com */ + Email?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "delete_core-{_version}-users-delete": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * Format: base64 + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * Format: base64 + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * Format: hex + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** + * Format: base64 + * @description clientData (base64) returned from the client authentication library + * @example + */ + ClientData?: string; + /** + * Format: base64 + * @description authenticatorData (base64) returned from the client authentication library + */ + AuthenticatorData?: string; + /** + * Format: base64 + * @description signature (base64) returned from the client authentication library + */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example optional {DIFFERENT_ACCOUNT, TOO_EXPENSIVE, MISSING_FEATURE, USE_OTHER_SERVICE, OTHER} */ + Reason?: string; + /** @example #poor */ + Feedback?: string; + /** @example ein@stein.com */ + Email?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-users-reset": { + parameters: { + query: { + /** + * @description the username or email address + * @example einstein + */ + Username: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description internal or external + * @example internal + */ + Type?: string; + /** @description one or more values of: email, sms, login. `login` is used for external user with the same email as recovery address. */ + Methods?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-users": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { + /** @example jason@protonmail.ch */ + Email?: string; + /** @example Jason */ + DisplayName?: string; + Keys?: components["schemas"]["UserKey"][]; + AccountRecovery?: components["schemas"]["AccountRecoveryAttempt"]; + }; + VerifyMethods?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-users": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example user_name */ + Username?: string; + /** @example proton.me */ + Domain?: string; + /** @example external@email */ + Email?: string; + /** @example recovery phone number */ + Phone?: string; + /** + * @deprecated + * @example Please use HV headers + */ + Token?: string; + /** + * @deprecated + * @description captcha, email, sms, invite, or payment + * @example Please use HV headers + */ + TokenType?: string; + /** + * @deprecated + * @example identifier + */ + Referrer?: string; + /** @example identifier */ + ReferralIdentifier?: string; + /** + * @description optional field, the encrypted referral ID + * @example + */ + ReferralID?: string; + /** @example 1 */ + Type?: number; + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + /** @description optional field, frontend fingerprints */ + Payload?: { + /** @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ + "random-id-1"?: string; + /** @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ + "random-id-2"?: string; + /** @example */ + "random-id-3"?: string; + /** @example */ + "random-id-4"?: string; + }; + /** + * @deprecated + * @description optional field used together with Android fingerprinting + * @example + */ + Salt?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { + /** @example 1 */ + Services?: number; + /** @example jason@protonmail.ch */ + Email?: string; + /** @example Jason */ + DisplayName?: string; + Keys?: components["schemas"]["UserKey"][]; + /** + * @description Token for external account creation. If it matches the created email it will be pre-verified + * @example ASD3ldfa.asdfaoa3aw.asdfads + */ + TokenPreVerifiedAddress?: string; + }; + }; + }; + }; + }; + }; + "post_core-{_version}-users-external": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example user_name */ + Username?: string; + /** @example proton.me */ + Domain?: string; + /** @example external@email */ + Email?: string; + /** @example recovery phone number */ + Phone?: string; + /** + * @deprecated + * @example Please use HV headers + */ + Token?: string; + /** + * @deprecated + * @description captcha, email, sms, invite, or payment + * @example Please use HV headers + */ + TokenType?: string; + /** + * @deprecated + * @example identifier + */ + Referrer?: string; + /** @example identifier */ + ReferralIdentifier?: string; + /** + * @description optional field, the encrypted referral ID + * @example + */ + ReferralID?: string; + /** @example 1 */ + Type?: number; + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + /** @description optional field, frontend fingerprints */ + Payload?: { + /** @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ + "random-id-1"?: string; + /** @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ + "random-id-2"?: string; + /** @example */ + "random-id-3"?: string; + /** @example */ + "random-id-4"?: string; + }; + /** + * @deprecated + * @description optional field used together with Android fingerprinting + * @example + */ + Salt?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { + /** @example 1 */ + Services?: number; + /** @example jason@protonmail.ch */ + Email?: string; + /** @example Jason */ + DisplayName?: string; + Keys?: components["schemas"]["UserKey"][]; + /** + * @description Token for external account creation. If it matches the created email it will be pre-verified + * @example ASD3ldfa.asdfaoa3aw.asdfads + */ + TokenPreVerifiedAddress?: string; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-users-check": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description in case of an invite must be selector:token + * @example + */ + Token?: string; + /** + * @description captcha, email, sms, invite, coupon or payment + * @example captcha + */ + TokenType?: string; + /** + * @description 1 = mail, 2 = VPN + * @example 1 + */ + Type?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-users-availableExternal": { + parameters: { + query?: { + /** + * @description the username + * @example bart + */ + Name?: string; + }; + header?: { + /** + * @description Optional header containing a payment token value. When this value is set and the token is valid, the signup flow is started. + * @example 1234567890abcdefghijklmn + */ + "X-PM-Payment-Info-Token"?: string; + }; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 12106 */ + Code?: number; + /** @example Username already used */ + Error?: string; + Details?: { + Suggestions?: string[]; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-users-available": { + parameters: { + query?: { + /** + * @description the username + * @example bart + */ + Name?: string; + /** + * @description Set to 1 if username is the full email address, otherwise 0 (default) + * @example 1 + */ + ParseDomain?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 12106 */ + Code?: number; + /** @example Username already used */ + Error?: string; + Details?: { + Suggestions?: string[]; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-users-available-{username}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + username: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-users-direct": { + parameters: { + query?: { + /** + * @description 1: mail
2: VPN + * @example 1 + */ + Type?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description 1 if enabled, 0 if disabled--client should show invite form + * @example 1 + */ + Direct?: number; + VerifyMethods?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-users-code": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description email or sms + * @example email + * @enum {string} + */ + Type?: "email" | "sms"; + /** + * @description Optional, can use android as well if link support + * @example ios + */ + Platform?: string; + Destination?: { + /** + * @description required if type is email + * @example example@example.com + */ + Address?: string; + /** + * @description required if type is sms + * @example 6176767087 + */ + Phone?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-users-lock": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-users-unlock": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + /** @example */ + SRPSession?: string; + /** + * @description Token to use when re-authenticating a SSO user + * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 + */ + SsoReauthToken?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-users-password": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + /** @example */ + SRPSession?: string; + /** + * @description Either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** + * @description clientData (base64) returned from the client authentication library + * @example + */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** + * @description Token to use when re-authenticating a SSO user + * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 + */ + SsoReauthToken?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example */ + ServerProof?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-users-captcha-{token}": { + parameters: { + query?: never; + header?: { + "x-pm-nonce"?: string | null; + }; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-users-disable-{jwt}": { + parameters: { + query?: never; + header?: never; + path: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-{enc_id}-vpn": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: string; + }; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example */ + VPNName?: string; + /** @example */ + VPNStatus?: number; + /** + * @description Last VPN login time (unix timestamp) + * @example 1654615966 + */ + LastVPNLogin?: number | null; + ActiveVPNSessions?: components["schemas"]["VPNAuthenticationCertificateDetailedTransformer"][]; + AuthenticationCertificates?: components["schemas"]["VPNAuthenticationCertificateDetailedTransformer"][]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-vpn": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example 2 */ + MaxVPN?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-v4-features": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing, prefer using Offset which allow tostart from any precise position + * @example 0 + */ + Page?: string; + /** + * @deprecated + * @description the page size, maximum 150, prefer using Limit which is equivalent + * @example 50 + */ + PageSize?: string; + /** @description skip the given number of results */ + Offset?: string; + /** @description the number of features to return, defaults to page size (1 page), maximum 150 */ + Limit?: string; + /** @description the sorting criteria */ + Sort?: string; + /** + * @description 0 => ASC, 1 => DESC + * @example 1 + */ + Desc?: string; + /** @description return only features of the given type */ + Type?: string; + /** @description return only features newer or equal than BeginID */ + BeginID?: string; + /** @description return only features older than EndID */ + EndID?: string; + /** @description feature ID(s) to filter on */ + ID?: string; + /** @description feature code(s) to filter on */ + Code?: string; + /** @description feature code substring to search */ + SearchCode?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example 76 */ + Total?: number; + Features?: components["schemas"]["FeatureTransformer"][]; + }; + }; + }; + }; + }; + "post_core-v4-features": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example blackFriday */ + Code?: string; + /** + * @example string + * @enum {string} + */ + Type?: "boolean" | "integer" | "float" | "string" | "enumeration" | "mixed"; + /** @description List of the values if type is enumeration */ + Options?: string[]; + /** + * @description Required level to set a user-specific value for this feature such as TokenScope::ADMIN + * @example 131072 + */ + WriteLevelToken?: number; + /** + * @description Same value for all users + * @example false + */ + Global?: boolean; + /** + * @description Default value that can be used if 'Value' is not set + * @example start + */ + DefaultValue?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Feature?: components["schemas"]["FeatureTransformer"]; + }; + }; + }; + }; + }; + "put_core-v4-features-{id}": { + parameters: { + query?: never; + header?: never; + path: { + /** @example 123 */ + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example blackFriday */ + Code?: string; + /** + * @example string + * @enum {string} + */ + Type?: "boolean" | "integer" | "float" | "string" | "enumeration" | "mixed"; + /** @description List of the values if type is enumeration */ + Options?: string[]; + /** + * Format: float + * @description Minimum (included) value of length allowed + * @example 1 + */ + Minimum?: number; + /** + * Format: float + * @description Maximum (included) value of length allowed + * @example 100 + */ + Maximum?: number; + /** + * @description Required level to set a user-specific value for this feature such as TokenScope::ADMIN + * @example 131072 + */ + WriteLevelToken?: number; + /** + * @description Same value for all users + * @example false + */ + Global?: boolean; + /** + * @description Default value that can be used if 'Value' is not set + * @example start + */ + DefaultValue?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Feature?: components["schemas"]["FeatureTransformer"]; + }; + }; + }; + /** @description Feature not found */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2001 */ + Code?: number; + /** @example higher is not one of the possible options among [low, medium, high]. */ + Error?: string; + }; + }; + }; + /** @description Not allowed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2026 */ + Code?: number; + /** @example You're not allowed to modify the value of this feature */ + Error?: string; + }; + }; + }; + /** @description Feature not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Feature not found */ + Error?: string; + }; + }; + }; + }; + }; + "delete_core-v4-features-{featureID}": { + parameters: { + query?: never; + header?: never; + path: { + /** @example 123 */ + ID: string; + featureID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Feature not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Feature not found */ + Error?: string; + }; + }; + }; + }; + }; + "get_core-v4-features-{code}": { + parameters: { + query?: never; + header?: never; + path: { + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Feature?: components["schemas"]["FeatureTransformer"]; + }; + }; + }; + /** @description Feature not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Feature not found */ + Error?: string; + }; + }; + }; + }; + }; + "put_core-v4-features-{code}-value": { + parameters: { + query?: never; + header?: never; + path: { + /** @example blackFriday */ + code: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example true */ + Value?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Feature?: components["schemas"]["FeatureTransformer"]; + }; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2001 */ + Code?: number; + /** @example higher is not one of the possible options among [low, medium, high]. */ + Error?: string; + }; + }; + }; + /** @description Not allowed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2026 */ + Code?: number; + /** @example You're not allowed to modify the value of this feature */ + Error?: string; + }; + }; + }; + /** @description Feature not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Feature not found */ + Error?: string; + }; + }; + }; + }; + }; + "delete_core-v4-features-{code}-value": { + parameters: { + query?: never; + header?: never; + path: { + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Feature?: components["schemas"]["FeatureTransformer"]; + }; + }; + }; + /** @description Not allowed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2026 */ + Code?: number; + /** @example You're not allowed to modify the value of this feature */ + Error?: string; + }; + }; + }; + /** @description Feature not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Feature not found */ + Error?: string; + }; + }; + }; + }; + }; + "put_core-v4-features-{code}-user-value": { + parameters: { + query?: never; + header?: never; + path: { + /** @example blackFriday */ + code: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example true */ + Value?: Record; + UserIDs?: number[]; + UserNames?: string[]; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Number of touched users + * @example 2 + */ + Count?: number; + }; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2001 */ + Code?: number; + /** @example higher is not one of the possible options among [low, medium, high]. */ + Error?: string; + }; + }; + }; + /** @description Not allowed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2026 */ + Code?: number; + /** @example You're not allowed to modify the value of this feature */ + Error?: string; + }; + }; + }; + /** @description Feature not found */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Feature not found */ + Error?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-info": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Client-specific secret only necessary to access the admin panel + * @example demopass + */ + ClientSecret?: Record; + /** @example user_name */ + Username?: string; + /** + * @description If the intent is to sign into a Proton account, SSO managed account or let the backend decide based on the domain. If Auto and user is SRP, an SRP challenge is always returned; if user is SSO either the SSOChallengeToken is returned directly or a switch to SSO error (HTTP 422 with error code 8100) + * @example auto + * @enum {string} + */ + Intent?: "Proton" | "SSO" | "Auto"; + /** + * @description optional field, to start a testing sso login flow + * @example true + */ + IsTesting?: Record; + /** + * @description optional field, to reauthenticate a SSO user and adding the given scope to the session + * @example locked, password + */ + ReauthScope?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description If Intent SSO or Auto, token to pass to GET /auth/sso/{token} for initiating the SSO flow + * @example a5fd396fcbb + */ + SSOChallengeToken?: string; + } | { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example -----BEGIN PGP SIGNED MESSAGE-----*-----END SIGNATURE----- */ + Modulus?: string; + /** @example */ + ServerEphemeral?: string; + /** @example 4 */ + Version?: number; + /** @example */ + Salt?: string; + /** @example */ + SRPSession?: string; + /** @description Only if already authenticated (not on login) */ + "2FA"?: { + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Enabled?: number; + FIDO2?: { + /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ + AuthenticationOptions?: Record; + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + }; + }; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Session is not tied to a user and Username is null + * @enum {integer} + */ + Code?: 2001; + /** @example Invalid input */ + Error?: string; + /** @description Empty */ + Details?: Record; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description User doesn't have SSO. Returned if Intent=SSO or Intent=Auto on backend's whim + * @enum {integer} + */ + Code?: 8101; + /** @example Email domain not found, please sign in with a password */ + Error?: string; + /** @description Empty */ + Details?: Record; + } | { + /** + * @description User has SSO. Returned if Intent=Proton or Intent=Auto on backend's whim + * @enum {integer} + */ + Code?: 8100; + /** @example Email domain associated to an existing organization. Please sign in with SSO */ + Error?: string; + /** @description Empty */ + Details?: Record; + } | { + /** + * @description Upgrade the app to call the endpoint this way + * @enum {integer} + */ + Code?: 5003; + /** @example You need to update this app in order to perform this operation */ + Error?: string; + /** @description Empty */ + Details?: Record; + }; + }; + }; + }; + }; + "get_core-{_version}-auth-sso-{token}": { + parameters: { + query?: { + FinalRedirectBaseUrl?: string | null; + }; + header?: never; + path: { + /** + * @description Token received as SSOChallengeToken from POST /auth/info + * @example a5fd396fcbb + */ + token: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-auth-saml": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["IdpResponseVO"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-auth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AuthInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Session unique ID + * @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 + */ + UID?: string; + /** @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ + UserID?: string; + /** @example ACXDmTaBub14w== */ + EventID?: string; + /** @example */ + ServerProof?: string; + /** + * @description only if the session is not in cookie mode + * @example Bearer + */ + TokenType?: string; + /** + * @description only if the session is not in cookie mode + * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 + */ + AccessToken?: string; + /** + * @description only if the session is not in cookie mode + * @example wfih0367aa7dc0359bf5c42d15a93e6c + */ + RefreshToken?: string; + /** + * @deprecated + * @description only if the session is not in cookie mode + * @example 360000 + */ + ExpiresIn?: number; + /** @example 0 */ + LocalID?: number; + Scopes?: string[]; + /** + * @deprecated + * @example full other_scopes + */ + Scope?: string; + /** @example 2 */ + PasswordMode?: number; + /** + * @description If 1 the user should be prompted to enter a new password on login + * @example 0 + */ + TemporaryPassword?: number; + "2FA"?: { + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Enabled?: number; + FIDO2?: { + /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ + AuthenticationOptions?: Record; + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + }; + }; + }; + }; + }; + }; + }; + "delete_core-{_version}-auth": { + parameters: { + query?: { + /** @description if 1 log out this child only */ + Child?: Record; + /** @description if 1 this will also delete the associated Auth Device */ + AuthDevice?: Record; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-jwt": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example eyJhbGciOiJIUzI1Ni...yJV_adQssw5c */ + Token?: string; + /** + * @description Client-specific secret only necessary to access the admin panel + * @example demopass + */ + ClientSecret?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example 3af9b9780014cacb4b72bc5c73c1d7c4bad6c1e3 */ + AccessToken?: string; + /** + * @description Only in the response if jwt type equals 5 (payments) + * @example 3af9b9780014cacb4b72bc5c73c1d7c4bad6c1e3 + */ + RefreshToken?: string; + /** + * @deprecated + * @example 360000 + */ + ExpiresIn?: number; + /** @example Bearer */ + TokenType?: string; + Scopes?: string[]; + /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ + UID?: string; + /** @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ + UserID?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @deprecated + * @example full + */ + Scope?: string; + Scopes?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-auth-modulus": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example -----BEGIN PGP SIGNED MESSAGE-----.*-----END PGP SIGNATURE----- */ + Modulus?: string; + /** @example Oq_JB_IkrOx5WlpxzlRPocN3_NhJ80V7DGav77eRtSDkOtLxW2jfI3nUpEqANGpboOyN-GuzEFXadlpxgVp7_g== */ + ModulusID?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-auth-scopes": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @deprecated + * @example 217017207043915776 + */ + Scope?: string; + Scopes?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-refresh": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example token */ + ResponseType?: string; + /** @example refresh_token */ + GrantType?: string; + /** @example eaad5a7059835aac32c0bf99c2e208a59b8c1a55 */ + RefreshToken?: string; + /** + * @deprecated + * @description This parameter is deprecated and should be passed via 'x-pm-uid' header instead + * @example m3mxv75of7tuy4na4c3fzkskaqnu35xj + */ + UID?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example abcDecryptedTokenAndNoSaltAndNoPrivateKey123 */ + AccessToken?: string; + /** + * @deprecated + * @example 360000 + */ + ExpiresIn?: number; + /** @example Bearer */ + TokenType?: string; + /** + * @deprecated + * @example full other_scopes + */ + Scope?: string; + Scopes?: string[]; + /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ + UID?: string; + /** @example b894b4c4f20003f12d486900d8b88c7d68e67235 */ + RefreshToken?: string; + /** @example 0 */ + LocalID?: number; + /** + * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have + * @example 5 + */ + RefreshCounter?: number; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-cookies": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example token */ + ResponseType?: string; + /** @example refresh_token */ + GrantType?: string; + /** @example eaad5a7059835aac32c0bf99c2e208a59b8c1a55 */ + RefreshToken?: string; + /** + * @description defaults to 0 if not present, creates persistent cookies + * @example 1 + */ + Persistent?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ + UID?: string; + /** @example 0 */ + LocalID?: number; + /** + * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have + * @example 5 + */ + RefreshCounter?: number; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-credentialless": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateCredentiallessUserInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateCredentiallessUserOutput"]; + }; + }; + }; + }; + "get_core-{_version}-settings-mnemonic": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + /** @example 1H8EGg3J1Qwk243hf== */ + Salt?: string; + }[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-mnemonic": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + }[]; + /** @example 1H8EGg3J1Qwk243hf== */ + MnemonicSalt?: string; + /** @description The new mnemonic SRP verifier */ + MnemonicAuth?: components["schemas"]["AuthInfoInput"]; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 obect + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-settings-mnemonic-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + /** @example 1H8EGg3J1Qwk243hf== */ + Salt?: string; + }[]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-mnemonic-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description The user keys encrypted with the account password */ + UserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + }[]; + /** @example 1H8EGg3J1Qwk243hf== */ + KeysSalt?: string; + /** @description The new account's login password verifier */ + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Scopes?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-mnemonic-disable": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 obect + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-mnemonic-reactivate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + }[]; + /** @example 1H8EGg3J1Qwk243hf== */ + MnemonicSalt?: string; + /** @description The new mnemonic SRP verifier */ + MnemonicAuth?: components["schemas"]["AuthInfoInput"]; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-pushes": { + parameters: { + query?: { + /** + * @description Page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description Page size (max 250) + * @example 100 + */ + PageSize?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; + }; + }; + }; + }; + }; + "get_core-{_version}-pushes-active": { + parameters: { + query?: { + /** + * @description Page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description Page size (max 250) + * @example 100 + */ + PageSize?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; + }; + }; + }; + }; + }; + "get_core-{_version}-pushes-active-session": { + parameters: { + query?: { + /** + * @description Page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description Page size (max 250) + * @example 100 + */ + PageSize?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; + }; + }; + }; + }; + }; + "delete_core-{_version}-pushes-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-referrals": { + parameters: { + query?: { + /** @description Skip the given number of results */ + Offset?: number; + /** @description The number of results to return, maximum 100 */ + Limit?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Referrals?: components["schemas"]["ReferralOutput"][]; + Total?: number; + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-referrals": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SendInvitationsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Referrals?: components["schemas"]["ReferralOutput"][]; + }; + }; + }; + }; + }; + "get_core-{_version}-referrals-status": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Referrals?: components["schemas"]["ReferralStatus"][]; + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-referrals-identifiers-{identifier}": { + parameters: { + query?: never; + header?: never; + path: { + /** @example KPlISx5MiML3XcSYPrREF */ + identifier: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The identifier exists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description The identifier does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "post_core-{_version}-devices": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RegisterDeviceInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-devices": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example 4b3403665fea6... */ + DeviceToken?: string; + /** + * Format: hex + * @example e35a8e0015b6ab79c80045881602b1e0560f59ba + */ + UID?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-betas-{client_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the client ID + * @example iOSVPN + */ + client_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Beta?: { + /** @example iOSVPN */ + ClientID?: string; + /** @example john@exampl.com */ + Email?: string; + /** @example 1538416904 */ + CreateTime?: number; + /** @example 1538416904 */ + ModifyTime?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-betas-{client_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the client ID + * @example iOSVPN + */ + client_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example john@example.com */ + Email?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Beta?: { + /** @example iOSVPN */ + ClientID?: string; + /** @example john@exampl.com */ + Email?: string; + /** @example 1538416904 */ + CreateTime?: number; + /** @example 1538416904 */ + ModifyTime?: number; + }; + }; + }; + }; + }; + }; + "delete_core-{_version}-betas-{client_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the client ID + * @example iOSVPN + */ + client_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-betas": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Betas?: { + /** @example iOSVPN */ + ClientID?: string; + /** @example john@exampl.com */ + Email?: string; + /** @example 1538416904 */ + CreateTime?: number; + /** @example 1538416904 */ + ModifyTime?: number; + }[]; + }; + }; + }; + }; + }; + "delete_core-{_version}-betas": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-geofeed-geofeed-csv": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-geofeed-geofeed-public-csv": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-load": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-load": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-logs-auth": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: string; + /** @description skip the given number of results */ + Offset?: string; + /** @description the number of results to return, defaults to page size (1 page), maximum 150 */ + Limit?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Logs?: components["schemas"]["AuthLogResponse"][]; + /** @example 1 */ + Total?: number; + }; + }; + }; + }; + }; + "delete_core-{_version}-logs-auth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-metrics": { + parameters: { + query?: { + /** @example signup */ + Category?: string; + /** @example click */ + Action?: string; + /** @example coupon */ + Label?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-metrics": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example encrypted_search + * @enum {string} + */ + Log?: "signup" | "encrypted_search" | "dark_styles"; + /** + * @description Optional title + * @example index + */ + Title?: string; + Data?: { + /** @example you want... */ + whatever?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-recovery-secret": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Base64-encoded secret, decodes to 32 bytes + * @example 1H8EGg3J1...Qwk243hf + */ + RecoverySecret?: string; + /** @example -----BEGIN PGP SIGNATURE... */ + Signature?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-settings-recovery-secret": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-form-{portal_id}-{form_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + portal_id: string; + form_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + fields?: Record; + context?: Record; + legalConsentOptions?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-bug": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** + * @description Client should supply if mobile app, ask user if web app + * @example iOS + */ + OS?: string; + /** + * @description Client should supply if mobile app, ask user if web app + * @example 8.0.3 + */ + OSVersion?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example Safari + */ + Browser?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example 8 + */ + BrowserVersion?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example LastPass + */ + BrowserExtensions?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example 1024x768 + */ + Resolution?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example row + */ + DisplayMode?: string; + /** + * @description Optional, what triggered the bug report modal to show if it was not the user asking explictly + * @example chat-no-agents + */ + Trigger?: string; + /** + * @description Client should supply + * @example Web + */ + Client?: string; + /** + * @description Client should supply + * @example 2.0.0 + */ + ClientVersion?: string; + /** + * @description 1: email, 2: VPN, 3: calendar, 4: drive, 5: pass + * @example 1 + */ + ClientType?: number; + /** @example My issue title */ + Title?: string; + /** + * @description Must be at least 10 characters long + * @example some text here + */ + Description?: string; + /** + * @description If user did not enter this themselves and client is unable to detect it, empty string should be posted + * @example 4w350m3h4x0r + */ + Username?: string; + /** + * @description Outside email, must be a valid email address + * @example derp@gmail.com + */ + Email?: string; + /** + * @description Optional, static web site only. Used for the appeal abuse form. + * @example myaccount@proton.me + */ + DisabledEmail?: string; + /** + * @description Optional, VPN only + * @example CH + */ + Country?: string; + /** + * @description Optional, VPN only + * @example Makedonski Telekom AD-Skopje + */ + ISP?: string; + /** + * @description Optional, VPN only + * @example VPN for Windows + */ + Platform?: string; + /** + * @description Optional + * @example https://search.brave.com/ + */ + Referrer?: string; + /** + * @description Optional + * @example link-footer + */ + ClickOrigin?: string; + /** + * @description Upload attachments asynchronously + * @example 1 + * @enum {integer} + */ + AsyncAttachments?: 0 | 1; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-bug-attachments": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UploadAttachment"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-reports-bug-{ticketId}": { + parameters: { + query?: { + RequesterID?: number; + CreatedAt?: string; + BrandID?: number | null; + }; + header?: never; + path: { + _version: string; + ticketId: Record; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Ticket does not exist */ + Error?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-abuse": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example harassment */ + Category?: string; + /** @example This person has been harassing me. */ + Description?: string; + /** + * @description Usernames to report (comma-delimited) + * @example abuser123,abuser456 + */ + Usernames?: string; + /** + * @description Reporter contact email + * @example reporter@example.com + */ + Email?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-crash": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional + * @example iOS + */ + OS?: string; + /** + * @description Optional + * @example 8.0.3 + */ + OSVersion?: string; + /** + * @description Optional + * @example Safari + */ + Browser?: string; + /** + * @description Optional + * @example 8 + */ + BrowserVersion?: string; + /** + * @description Client should supply + * @example Web + */ + Client?: string; + /** + * @description Client should supply + * @example 2.0.0 + */ + ClientVersion?: string; + /** + * @description 1 = email, 2 = VPN + * @example 1 + */ + ClientType?: number; + /** @description Client should supply */ + Debug?: { + /** @example you want */ + "Whatever JSON"?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-sentry-api-{id}-{type}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + id: string; + type: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-reports-sentry-api-{id}-{type}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + id: string; + type: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-reports-phishing": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== */ + MessageID?: string; + /** + * @description text/html or text/plain + * @example text/html + */ + MIMEType?: string; + /** @example */ + Body?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-spam": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-reports-cancel-plan": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CancelPlanReport"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-reset-{username}-{token}": { + parameters: { + query?: never; + header?: never; + path: { + /** @example bob */ + username: string; + /** + * @description 10-character reset token + * @example A194YN2F9R + */ + token: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @example 1 + * @enum {integer} + */ + ToMigrate?: 0 | 1; + /** + * @example 1 + * @enum {integer} + */ + SupportPgpV6Keys?: 0 | 1; + /** @description NB: PrivateKey is null in keys */ + Addresses?: components["schemas"]["AddressUser"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example derp */ + Username?: string; + /** + * @description if Phone is not present + * @example derp@gmail.com + */ + Email?: string; + /** + * @description if Email is not present + * @example +1234567890 + */ + Phone?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 19305 */ + Code?: number; + /** @example Username and recovery email mismatch */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-reset-username": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description if Phone is not present + * @example derp@gmail.com + */ + Email?: string; + /** + * @description if Email is not present + * @example +1234567890 + */ + Phone?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-system-config": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-system-version": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-exception": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-error": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-notice": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-memoryLeak": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-logger": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-logger-observability": { + parameters: { + query?: { + Level?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-ping": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-tests-version": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-stream": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-update": { + parameters: { + query?: { + /** @example 24m */ + cycle?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-users-invitations": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + } & components["schemas"]["GetUserInvitationsOutput"]; + }; + }; + }; + }; + "post_core-{_version}-users-invitations-{enc_id}-reject": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-users-invitations-{enc_id}-accept": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Validation failed */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + Details?: { + Validation?: components["schemas"]["GetUserInvitationOutput"]; + }; + }; + }; + }; + }; + }; + "post_core-{_version}-validate-email": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Email address + * @example einstein@pm.me + */ + Email?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"] & { + /** + * @description Email address failed validation + * @default 2050 + */ + Code: unknown; + }; + }; + }; + }; + }; + "post_core-{_version}-validate-phone": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Phone number + * @example +37012345678 + */ + Phone?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"] & { + /** + * @description Phone number failed validation + * @default 2058 + */ + Code: unknown; + }; + }; + }; + }; + }; + "get_core-{_version}-verification-ownership-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-verification-ownership-email-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-email-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-verification-ownership-sms-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-sms-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-{token}-{code}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-email-{token}-{code}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-sms-{token}-{code}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-v6-events-{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Stream"]; + }; + }; + }; + }; + "get_core-{_version}-events-latest": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example ACXDmTaBub14w== */ + EventID?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-events-{id}": { + parameters: { + query?: { + MessageCounts?: components["schemas"]["BoolInt"]; + ConversationCounts?: components["schemas"]["BoolInt"]; + NoMetaData?: unknown[]; + }; + header?: never; + path: { + _version: string; + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventInfo"]; + }; + }; + }; + }; + "get_core-v4-events-{id}": { + parameters: { + query?: { + MessageCounts?: boolean; + ConversationCounts?: boolean; + }; + header?: { + "x-pm-appversion"?: string; + }; + path: { + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventInfo"]; + }; + }; + }; + }; + "post_core-{_version}-feedback": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FeedbackVO"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-checklist-get-started": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @description Array of completed checklist items */ + Items?: string[]; + /** @description Timestamp of checklist creation */ + CreatedAt?: string; + /** @description Timestamp of checklist expiration */ + ExpiresAt?: string; + /** @description Amount of storage GB completion reward */ + RewardInGB?: number; + }; + }; + }; + }; + }; + "get_core-{_version}-checklist-paying-user": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @description Array of completed checklist items */ + Items?: string[]; + /** @description Timestamp of checklist creation */ + CreatedAt?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-checklist-get-started-seen-completed-list": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-checklist-paying-user-hide": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-checklist-paying-user-seen-completed-list": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-checklist-get-started-init": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-checklist-paying-user-init": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-checklist-check-item": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example MobileApp */ + Item?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-checklist-update-display": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example Hidden */ + Display?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-verify-send": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example external_email + * @enum {string} + */ + Type?: "external_email, recovery_email"; + /** @example me@example.com */ + Destination?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-verify-validate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + JWT?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Previous confirmation state + * @example 1 + */ + PreviousState?: number; + }; + }; + }; + }; + }; + "delete_core-{_version}-verify-validate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + JWT?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Previous confirmation state + * @example 1 + */ + PreviousState?: number; + }; + }; + }; + }; + }; + "post_core-{_version}-verify-email": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Previous confirmation state + * @example 1 + */ + PreviousState?: number; + }; + }; + }; + }; + }; + "post_core-{_version}-verify-phone": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Previous confirmation state + * @example 1 + */ + PreviousState?: number; + }; + }; + }; + }; + }; + "post_core-{_version}-verify-reauth-email": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 12087 */ + Code?: number; + /** @example Invalid or already used token */ + Error?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 9001 */ + Code?: number; + /** @example Human verification required */ + Error?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-verify-reauth-phone": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 12087 */ + Code?: number; + /** @example Invalid or already used token */ + Error?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 9001 */ + Code?: number; + /** @example Human verification required */ + Error?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-notifications": { + parameters: { + query?: { + /** + * @description Scale for images, 1 for @1x, 2 for @2x, etc. (default is maximum scale available) + * @example 2 + */ + WithImageScale?: number; + FullScreenImageSupport?: components["schemas"]["NotificationRequest"]["FullScreenImageSupport"]; + FullScreenImageWidth?: components["schemas"]["NotificationRequest"]["FullScreenImageWidth"]; + FullScreenImageHeight?: components["schemas"]["NotificationRequest"]["FullScreenImageHeight"]; + SupportedFullScreenImageFormats?: components["schemas"]["NotificationRequest"]["SupportedFullScreenImageFormats"]; + Null?: components["schemas"]["NotificationRequest"]["Null"]; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Notifications?: components["schemas"]["NotificationVersionTransformer"][]; + }; + }; + }; + }; + }; + "patch_core-v4-labels-{enc_id}": { + parameters: { + query?: { + /** + * @description the encrypted label id + * @example lKJlejjlk== + */ + enc_id?: string; + }; + header?: never; + path: { + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PatchInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Label?: components["schemas"]["Label"]; + }; + }; + }; + /** @description Invalid request body */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2500 */ + Code?: number; + /** @example Attribute Expanded should be of type int, null (float given) */ + Error?: string; + }; + }; + }; + }; + }; + "post_core-v4-labels-by-ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LabelIDs"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Labels?: { + [key: string]: components["schemas"]["Label"]; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-labels": { + parameters: { + query?: { + /** + * @description 1 => Message Labels, 2 => Contact Groups, 3 => Message Folders, 4 => Message System Folders + * @example 3 + */ + Type?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Labels?: components["schemas"]["Label"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-labels": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description required, cannot be same as an existing label of this Type. Max length is 100 characters + * @example Red Label + */ + Name?: string; + /** + * @description required, must match default colors + * @example #f66 + */ + Color?: string; + /** + * @description required, 1 => Message Labels (default), 2 => Contact Groups, 3 => Message Folders + * @example 1 + */ + Type?: number; + /** + * @description optional, encrypted label id of parent folder, default is root level + * @example 3pf-EZUUjP...Pr70RQ== + */ + ParentID?: string; + /** + * @description optional, 0 => no desktop/email notifications, 1 => notifications, folders only, default is 1 for folders + * @example 0 + */ + Notify?: number; + /** + * @description optional, 0 => collapse and hide sub-folders, 1 => expanded and show sub-folders + * @example 0 + */ + Expanded?: number; + /** + * @description optional, 0 => not sticky, 1 => stick to the page in the sidebar + * @example 0 + */ + Sticky?: number; + /** + * @description + * * * 1 = show the label in the sidebar + * * * 0 = hide label from sidebar + * * + * @example 0 + */ + Display?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Label?: components["schemas"]["Label"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Maximum 3 levels in the folder hierarchy */ + Error?: string; + }; + }; + }; + /** @description Already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2500 */ + Code?: number; + /** @example A label or folder with this name already exists */ + Error?: string; + }; + }; + }; + /** @description Invalid name */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Invalid name */ + Error?: string; + }; + }; + }; + }; + }; + "delete_core-{_version}-labels": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + LabelIDs?: string[]; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 1001 */ + Code?: number; + /** @description Array of responses, one element per label */ + Responses?: { + 0?: { + /** @example KPlISx5MiML3XcSY-tfNw== */ + LabelID?: string; + Response?: { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + 1?: { + /** @example c2RhbGtmamhkbGZrCg== */ + LabelID?: string; + Response?: { + /** @example 2501 */ + Code?: number; + /** @example Label or folder does not exist */ + Error?: string; + }; + }; + }; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @default 2000 */ + Code: number; + /** @default The LabelIDs is required */ + Error: string; + Details?: { + /** @default The LabelIDs is required */ + LabelIDs: Record; + }; + } | { + /** @default 2002 */ + Code: number; + /** @default The LabelIDs must be a array */ + Error: string; + Details?: { + /** @default The LabelIDs must be a array */ + LabelIDs: Record; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-labels-available": { + parameters: { + query: { + /** @description The name to check */ + Name: string; + /** @description `1`: Message Labels, `2`: Contact Groups, `3`: Message Folders */ + Type: number; + /** @description The ParentID under which we check the label name availability */ + ParentID?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Maximum 3 levels in the folder hierarchy */ + Error?: string; + }; + }; + }; + /** @description Name already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2500 */ + Code?: number; + /** @example A label or folder with this name already exists */ + Error?: string; + }; + }; + }; + /** @description Invalid name */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Invalid name */ + Error?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-labels-order": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Will amend the order of labels with the order of the corresponding LabelIDs */ + LabelIDs?: string[]; + /** + * @description optional + * @example 4v-mQLz2NnvtXP0EI3fFSTcSUoZWZ3xgC1Z-Ngg6M2v5nDqV4vGANE33IdHjvyV6_19E9jdhTQA-ndSj2Hi4cQ== + */ + ParentID?: string; + /** + * @description required, 1 => Message Labels, 2 => Contact Groups, 3 => Message Folders, 4 => Message System Folders + * @example 1 + */ + Type?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-labels-order-tree-{startLabelId}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + startLabelId: (string & components["schemas"]["Id"]) | null; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-labels-{id}": { + parameters: { + query?: { + /** + * @description the encrypted label id + * @example lKJlejjlk== + */ + enc_id?: string; + /** + * @description the label id + * @example 4 + */ + id?: string; + }; + header?: never; + path: { + _version: string; + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description required, cannot be same as an existing label of this Type. Max length is 100 characters. + * * Must be the same for Message System Folders (Type = 4) + * @example Stuff + */ + Name?: string; + /** + * @description required + * @example #ff9 + */ + Color?: string; + /** + * @description optional + * @example 3pf-EZUUjP...Pr70RQ== + */ + ParentID?: string; + /** + * @description optional + * @example 0 + */ + Notify?: number; + /** + * @description optional + * @example 0 + */ + Expanded?: number; + /** + * @description optional + * @example 0 + */ + Sticky?: number; + /** + * @description optional + * @example 0 + */ + Display?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Label?: components["schemas"]["Label"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Maximum 3 levels in the folder hierarchy */ + Error?: string; + }; + }; + }; + /** @description Name already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2500 */ + Code?: number; + /** @example A sub-folder with this name already exists in the destination folder */ + Error?: string; + }; + }; + }; + }; + }; + "delete_core-{_version}-labels-{enc_id}": { + parameters: { + query?: { + /** + * @description the encrypted label id + * @example lKJlejjlk== + */ + enc_id?: string; + }; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-labels-{enc_labelID}-detach": { + parameters: { + query?: { + /** + * @description the encrypted label id + * @example lKJlejjlk== + */ + enc_id?: string; + }; + header?: never; + path: { + _version: string; + enc_labelID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example 3 */ + NumMessages?: number; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @default 2001 */ + Code: number; + /** @default The action can't be performed on this label */ + Error: string; + Details?: { + /** @default LabelID corresponds to a global PM label, which can't be edited by this route */ + LabelID: Record; + }; + } | { + /** @default 2002 */ + Code: number; + /** @default The action can't be performed on this label */ + Error: string; + Details?: { + /** @default LabelID must correspond to a label of the MessageLabel type */ + LabelID: Record; + /** @default Folder */ + LabelTypeReceived: Record; + }; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @default 2501 */ + Code: number; + /** @default Label does not exist */ + Error: string; + }; + }; + }; + }; + }; + "get_core-{_version}-images": { + parameters: { + query: { + /** + * @description The percent encoded url to be fetched + * @example https%3A%2F%2Fprotonmail.com%2Fimages%2Ffavicon.ico + */ + Url: string; + /** + * @description Whether tracked urls should be blocked (not downloaded). Acts as a boolean. Default is 1.
+ * * - 0: don't block
+ * * - 1: block
+ * @example 1 + */ + BlockTrackers?: number; + /** + * @description Whether remote data should not be downloaded. Acts as a boolean. Default is 0.
+ * * - 0: download (while still respecting BlockTrackers)
+ * * - 1: don't download
+ * @example 1 + */ + DryRun?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Binary data of the image */ + 200: { + headers: { + /** @description If this header is set, the image is being tracked. + * The value of the headers is the service providing the tracking. */ + "X-Pm-Tracker-Provider"?: string; + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description Return an empty image when we cannot proxy the remote image */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The Url is required */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2000 */ + Code?: number; + /** @example The Url is required */ + Error?: string; + }; + }; + }; + /** @description The Url is not valid URL */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2052 */ + Code?: number; + /** @example The Url is not valid URL */ + Error?: string; + }; + }; + }; + }; + }; +} diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts new file mode 100644 index 00000000..1eaeb605 --- /dev/null +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -0,0 +1,10564 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/albums": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List current user albums */ + get: operations["get_drive-photos-volumes-{volumeID}-albums"]; + put?: never; + /** Create an album */ + post: operations["post_drive-photos-volumes-{volumeID}-albums"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a photo volume + * @description Also, creates : + * + root folder for the new Photo Volume + * + Photo share for the new Photo Volume + * + Adds ShareMember with given Address ID + */ + post: operations["post_drive-photos-volumes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/albums/{linkID}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List photos in album */ + get: operations["get_drive-photos-volumes-{volumeID}-albums-{linkID}-children"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/albums/{linkID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update an album */ + put: operations["put_drive-photos-volumes-{volumeID}-albums-{linkID}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/urls/{token}/bookmark": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create ShareURL Bookmark + * @description It creates a bookmark for the user in an already existing ShareURL. The bookmark would be stored for the current user if the password is encrypted with his/her addressKey + */ + post: operations["post_drive-v2-urls-{token}-bookmark"]; + /** + * Delete ShareURL Bookmark + * @description It soft deletes the bookmark share url, that would be GC later. The user should be the owner of the bookmark. + */ + delete: operations["delete_drive-v2-urls-{token}-bookmark"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shared-bookmarks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all Bookmarks + * @description This endpoint would only show active bookmarks from the user doing the request + */ + get: operations["get_drive-v2-shared-bookmarks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/checklist/get-started": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get onboarding checklist */ + get: operations["get_drive-v2-checklist-get-started"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/checklist/get-started/seen-completed-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark completed checklist as seen */ + post: operations["post_drive-v2-checklist-get-started-seen-completed-list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List devices + * @description Gives a list of devices for current user, ordered by creationTime DESC + */ + get: operations["get_drive-devices"]; + put?: never; + /** Create a Device */ + post: operations["post_drive-devices"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/devices/{deviceID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update device */ + put: operations["put_drive-devices-{deviceID}"]; + post?: never; + /** Delete a device */ + delete: operations["delete_drive-devices-{deviceID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List devices (v2) */ + get: operations["get_drive-v2-devices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create document. + * @description Create a new proton document. + */ + post: operations["post_drive-shares-{shareID}-documents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/events/latest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get latest share event + * @deprecated + * @description Get latest EventID for a given share. Deprecated: Use events per volume instead. + */ + get: operations["get_drive-shares-{shareID}-events-latest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/events/{eventID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get share events + * @deprecated + * @description Get new events for given share since eventID. Deprecated: Use events per volume instead. + */ + get: operations["get_drive-shares-{shareID}-events-{eventID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/events/latest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get latest volume event + * @description Get latest EventID for a given volume. + */ + get: operations["get_drive-volumes-{volumeID}-events-latest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/events/{eventID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get volume events + * @description Get new events for given volume since eventID. + */ + get: operations["get_drive-volumes-{volumeID}-events-{eventID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/links/{linkID}/path": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fetch link parentIDs by token */ + get: operations["get_drive-urls-{token}-links-{linkID}-path"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request block upload. + * @description Request upload information for a set of blocks. + */ + post: operations["post_drive-blocks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/links/{linkID}/copy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Copy a node to a volume. + * @description Copy a single file to a volume, providing the new parent link ID. + */ + post: operations["post_drive-volumes-{volumeID}-links-{linkID}-copy"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a folder + * @description Create a new folder in a given share, under a given folder link. + */ + post: operations["post_drive-shares-{shareID}-folders"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/folders/{linkID}/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete children + * @description Permanently delete children from folder, skipping trash. Can only be done for draft links. + */ + post: operations["post_drive-shares-{shareID}-folders-{linkID}-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/folders/{linkID}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List folder children + * @description List children of a given folder. + */ + get: operations["get_drive-shares-{shareID}-folders-{linkID}-children"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/folders/{linkID}/trash_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Trash children + * @description Send children to trash + */ + post: operations["post_drive-v2-volumes-{volumeID}-folders-{linkID}-trash_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/folders/{linkID}/trash_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Trash children + * @description Send children to trash + */ + post: operations["post_drive-shares-{shareID}-folders-{linkID}-trash_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/folders/{linkID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update folder attributes */ + put: operations["put_drive-shares-{shareID}-folders-{linkID}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a folder (v2) + * @description Create a new folder in a given share, under a given folder link. + */ + post: operations["post_drive-v2-volumes-{volumeID}-folders"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/folders/{linkID}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List folder children (v2) + * @description List children IDs of a given folder. + */ + get: operations["get_drive-v2-volumes-{volumeID}-folders-{linkID}-children"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check available hashes + * @description Filter unavailable hashes out of a list of hashes under a given parent folder. + * + * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. + */ + post: operations["post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}/checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check available hashes + * @description Filter unavailable hashes out of a list of hashes under a given parent folder. + * + * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. + */ + post: operations["post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/fetch_metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Fetch links in share */ + post: operations["post_drive-shares-{shareID}-links-fetch_metadata"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/links/fetch_metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Fetch links in volume */ + post: operations["post_drive-volumes-{volumeID}-links-fetch_metadata"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get link data + * @description Retrieve individual link information. + */ + get: operations["get_drive-shares-{shareID}-links-{linkID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/links": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Load links details */ + post: operations["post_drive-v2-volumes-{volumeID}-links"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}/move": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Move link + * @description Move a file or folder. Client must provide new values for fields encrypted with parent NodeKey. + * + * Clients moving a file or folder MUST reuse the existing session keys + * for the name and passphrase as these are also used by shares pointing + * to the link. The passphrase should NOT be changed, only the KeyPacket + * is used. + */ + put: operations["put_drive-shares-{shareID}-links-{linkID}-move"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeId}/links/{linkID}/rename": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Rename link + * @description Rename a file or folder. Client must provide new values for fields linked to name. + * + * Clients renaming a file or folder MUST reuse the existing session key + * for the name as it is also used by shares pointing to the link. + */ + put: operations["put_drive-v2-volumes-{volumeId}-links-{linkID}-rename"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}/rename": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Rename link + * @description Rename a file or folder. Client must provide new values for fields linked to name. + * + * Clients renaming a file or folder MUST reuse the existing session key + * for the name as it is also used by shares pointing to the link. + */ + put: operations["put_drive-shares-{shareID}-links-{linkID}-rename"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/links/{linkID}/move": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Move link (v2) + * @description Move a file or folder. Client must provide new values for fields encrypted with parent NodeKey. + * Clients moving a file or folder MUST reuse the existing session keys + * for the name and passphrase as these are also used by shares pointing + * to the link. The passphrase should NOT be changed, only the KeyPacket + * is used. + */ + put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-move"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get revision + * @description Get detailed revision information. + */ + get: operations["get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + /** + * Commit a revision + * @description The revision becomes the current active one and the updated file content become available for reading. + * + * If NO `BlockNumber` parameter is passed when creating a new revision, + * ALL blocks after the greatest index in the submitted block list will be + * truncated for this revision. All blocks for the new revision should be + * submitted. If `BlockNumber` is submitted, all previous blocks + * 1...BlockNumber will be preserved if they are not overridden by a new block + * BlockNumber+1... will be discarded. + */ + put: operations["put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + post?: never; + /** + * Delete an obsolete/draft revision + * @description Only the volume owner can delete obsolete revisions. Members with write permission can only delete drafts. + * This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active. Create or revert to + * another revision first. You cannot delete a draft revision for a draft link. Delete the link instead. + */ + delete: operations["delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get revision + * @description Get detailed revision information. + */ + get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; + /** + * Commit a revision + * @description The revision becomes the current active one and the updated file content become available for reading. + * + * If NO `BlockNumber` parameter is passed when creating a new revision, + * ALL blocks after the greatest index in the submitted block list will be + * truncated for this revision. All blocks for the new revision should be + * submitted. If `BlockNumber` is submitted, all previous blocks + * 1...BlockNumber will be preserved if they are not overridden by a new block + * BlockNumber+1... will be discarded. + */ + put: operations["put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; + post?: never; + /** + * Delete an obsolete/draft revision + * @description Only the volume owner can delete obsolete revisions. Members with write permission can only delete drafts. + * This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active. Create or revert to + * another revision first. You cannot delete a draft revision for a draft link. Delete the link instead. + */ + delete: operations["delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a new file */ + post: operations["post_drive-v2-volumes-{volumeID}-files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a new file */ + post: operations["post_drive-shares-{shareID}-files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List revisions */ + get: operations["get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions"]; + put?: never; + /** + * Create revision + * @description Create a new revision on an existing link. + * Only one draft can be created at a time. A draft can be deleted using the DELETE revision endpoint if the new + * draft should be created regardless. The error code indicates the reason for failure. + * + * Client unique ID can be used to track revision ownership to improve concurrency control. + * It can be a single persistent client ID generated by the client and stored locally, + * or it can be specific to the revision. + * The client can use it to identify revisions in case it failed to receive the confirmation of the revision creation. + */ + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files/{linkID}/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List revisions */ + get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions"]; + put?: never; + /** + * Create revision + * @description Create a new revision on an existing link. + * Only one draft can be created at a time. A draft can be deleted using the DELETE revision endpoint if the new + * draft should be created regardless. The error code indicates the reason for failure. + * + * Client unique ID can be used to track revision ownership to improve concurrency control. + * It can be a single persistent client ID generated by the client and stored locally, + * or it can be specific to the revision. + * The client can use it to identify revisions in case it failed to receive the confirmation of the revision creation. + */ + post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get revision thumbnail */ + get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore a revision */ + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore a revision */ + post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get verification data. + * @description Get data to verify encryption of the revision before committing. + */ + get: operations["get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}/revisions/{revisionID}/verification": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get verification data. + * @description Get data to verify encryption of the revision before committing. + */ + get: operations["get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/trash/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete items from trash + * @description Permanently delete list of links from trash of a given share. + */ + post: operations["post_drive-v2-volumes-{volumeID}-trash-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/trash/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete items from trash + * @description Permanently delete list of links from trash of a given share. + */ + post: operations["post_drive-shares-{shareID}-trash-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/trash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List share trash + * @deprecated + * @description List all trashed items of a given share. + * Only used by clients that don’t show photos and devices. Going forward, the volume-based route should be used instead. + */ + get: operations["get_drive-shares-{shareID}-trash"]; + put?: never; + post?: never; + /** + * Empty share trash + * @deprecated + * @description Permanently delete all links from trash of a given share. + * Only used by clients that don’t show photos and devices. Going forward, the volume-based route should be used instead. + */ + delete: operations["delete_drive-shares-{shareID}-trash"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/trash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List volume trash */ + get: operations["get_drive-volumes-{volumeID}-trash"]; + put?: never; + post?: never; + /** Empty volume trash */ + delete: operations["delete_drive-volumes-{volumeID}-trash"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/trash/restore_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Restore items from trash + * @description Restore list of links from trash to original location. + */ + put: operations["put_drive-v2-volumes-{volumeID}-trash-restore_multiple"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/trash/restore_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Restore items from trash + * @description Restore list of links from trash to original location. + */ + put: operations["put_drive-shares-{shareID}-trash-restore_multiple"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/me/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Ping active user + * @description Endpoint that can be pinged by clients to mark a user as an active user + */ + get: operations["get_drive-me-active"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/report/url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Report Share URL */ + post: operations["post_drive-report-url"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/onboarding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_drive-v2-onboarding"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/entitlements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get entitlements + * @description Get the current entitlements and their value for the logged-in user. + */ + get: operations["get_drive-entitlements"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/photos": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List photos sorted by capture time. + * @description When paginating to secondary pages, the PreviousPageLastLinkID must be provided. + */ + get: operations["get_drive-volumes-{volumeID}-photos"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/photos/share": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a photo share */ + post: operations["post_drive-volumes-{volumeID}-photos-share"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/photos/share/{shareID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete an empty photo share. + * @description Can only delete Photo Shares that are empty. + */ + delete: operations["delete_drive-volumes-{volumeID}-photos-share-{shareID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/photos/duplicates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Find Duplicates */ + post: operations["post_drive-volumes-{volumeID}-photos-duplicates"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/files/{linkID}/checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check available hashes + * @description Filter unavailable hashes out of a list of hashes under a given parent folder. + * + * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. + */ + post: operations["post_drive-urls-{token}-files-{linkID}-checkAvailableHashes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/files/{linkID}/revisions/{revisionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Commit a revision + * @description The revision becomes the current active one and the updated file content become available for reading. + * + * If NO `BlockNumber` parameter is passed when creating a new revision, + * ALL blocks after the greatest index in the submitted block list will be + * truncated for this revision. All blocks for the new revision should be + * submitted. If `BlockNumber` is submitted, all previous blocks + * 1...BlockNumber will be preserved if they are not overridden by a new block + * BlockNumber+1... will be discarded. + */ + put: operations["put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}"]; + post?: never; + /** + * Delete a draft revision. + * @description This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active or obsolete. + * You cannot delete a draft revision for a draft link. Delete the link instead. + */ + delete: operations["delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create anonymous document. + * @description Create a new anonymous proton document. + */ + post: operations["post_drive-urls-{token}-documents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create file. + * @description Create a new file. + */ + post: operations["post_drive-urls-{token}-files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a folder. + * @description Create a new folder in a given share, under a given folder link. + */ + post: operations["post_drive-urls-{token}-folders"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/folders/{linkID}/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete children + * @description Permanently delete children from folder, skipping trash. + */ + post: operations["post_drive-urls-{token}-folders-{linkID}-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/links/fetch_metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fetch links metadata using token + * @description This endpoint is a sibling of /drive/volumes/{volumeID}/links/fetch_metadata, but using token + * instead of volumeID. Is meant to be used in public sharing. + */ + post: operations["post_drive-urls-{token}-links-fetch_metadata"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/links/{linkID}/rename": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Rename entry + * @description Rename a file or folder. Client must provide new values for fields linked to name. + * + * Clients renaming a file or folder MUST reuse the existing session key + * for the name as it is also used by shares pointing to the link. + */ + put: operations["put_drive-urls-{token}-links-{linkID}-rename"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request block upload. + * @description Request upload information for a set of blocks. + */ + post: operations["post_drive-urls-{token}-blocks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/links/{linkID}/revisions/{revisionID}/verification": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get verification data. + * @description Get data to verify encryption of the revision before committing. + */ + get: operations["get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/urls": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List ShareURLs in a volume */ + get: operations["get_drive-volumes-{volumeID}-urls"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/my-files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_drive-v2-shares-my-files"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/links/{linkID}/context": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get context share + * @description Gets the highest share, meaning closest to the root, for a link + */ + get: operations["get_drive-volumes-{volumeID}-links-{linkID}-context"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a share + * @description Cannot create two shares on the same link + * Throws 422 with code 2500 in case a share already exists + */ + post: operations["post_drive-volumes-{volumeID}-shares"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List shares + * @description List shares available to current user. + * + * The results can be restricted to a single address by providing the AddressID query parameter. + * By default, only active shares are shown. + * Passing the ShowAll=1 query parameter will show locked and disabled shares also. + */ + get: operations["get_drive-shares"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/owner": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update ownership of a share + * @description Replace the signature and related membership of the share. + * This allows users to change the associated address & key they use for a share, so that they can get rid of it. + */ + post: operations["post_drive-shares-{shareID}-owner"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/map": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search map + * @deprecated + * @description Used only for search on web that does not scale. Should be replaced by better version in the future. + */ + get: operations["get_drive-shares-{shareID}-map"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get share bootstrap */ + get: operations["get_drive-shares-{shareID}"]; + put?: never; + post?: never; + /** + * Delete a share by ID + * @description Only standard shares (type 2) can be deleted this way. + * Will throw 422 with body code 2005 if Members, ShareURLs or Invitations are still attached to the share. + * Use Force=1 query param to delete the share together with any attached entities. + */ + delete: operations["delete_drive-shares-{shareID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/migrations/shareaccesswithnode": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Migrate legacy Shares */ + post: operations["post_drive-migrations-shareaccesswithnode"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/migrations/shareaccesswithnode/unmigrated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List unmigrated shares + * @description List shares that have not been migrated to the new PassphraseNodeKeyPacket yet. + * Will throw a 422 with Code 2000 if the current user doesn't have any active Volume. + */ + get: operations["get_drive-migrations-shareaccesswithnode-unmigrated"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Initiate shared by URL session with SRP. */ + get: operations["get_drive-urls-{token}-info"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/auth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Perform Handshake, Get session information */ + post: operations["post_drive-urls-{token}-auth"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Shared File Information. */ + get: operations["get_drive-urls-{token}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/folders/{linkID}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List shared folder's children. */ + get: operations["get_drive-urls-{token}-folders-{linkID}-children"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/files/{linkID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Shared File & Revision Metadata. */ + get: operations["get_drive-urls-{token}-files-{linkID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Get Shared File Information. */ + post: operations["post_drive-urls-{token}-file"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/security": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Performs virus checks on hashes of files received in the request payload. + * @description https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ + */ + post: operations["post_drive-urls-{token}-security"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/urls": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List URL links on share. */ + get: operations["get_drive-shares-{shareID}-urls"]; + put?: never; + /** Share by URL + * Create a share by URL link. */ + post: operations["post_drive-shares-{shareID}-urls"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/urls/{urlID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a share by URL link. + * @description Only values which are set in the request are updated. When the password is updated, the password, SharePassphraseKeyPacket and SRPVerifier must be updated together. + */ + put: operations["put_drive-shares-{shareID}-urls-{urlID}"]; + post?: never; + /** Delete a Share URL */ + delete: operations["delete_drive-shares-{shareID}-urls-{urlID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/urls/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete multiple ShareURL in a batch. */ + post: operations["post_drive-shares-{shareID}-urls-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Shared by me + * @description List Collaborative Shares in the given volume that are not abandoned, i.e. they either have members, invitations or URLs attached. + */ + get: operations["get_drive-v2-volumes-{volumeID}-shares"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/sharedwithme": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Shared with me + * @description List Collaborative Shares the user has access to as a non-owner + */ + get: operations["get_drive-v2-sharedwithme"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/external-invitations/{invitationID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update an external invitation + * @description Only permissions can be changed. They can be changed when the external invitation is pending or accepted. + * After the external invitation has been accepted, the invitation's permissions can be edited. + * The current user must have admin permission on the share. + */ + put: operations["put_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; + post?: never; + /** + * Delete an external invitation + * @description The current user must have admin permission on the share. + */ + delete: operations["delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/external-invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List external invitations in a share + * @description The current user must have admin permission on the share. + */ + get: operations["get_drive-v2-shares-{shareID}-external-invitations"]; + put?: never; + /** + * Invite an external user to a share + * @description The current user must have admin permission on the share. + */ + post: operations["post_drive-v2-shares-{shareID}-external-invitations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/external-invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List external invitations of a user + * @description List the UserRegistered external invitations where the current user is the invitee. + */ + get: operations["get_drive-v2-shares-external-invitations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/external-invitations/{invitationID}/sendemail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send the external invitation email to the invitee + * @description The current user must have admin permission on the share. + */ + post: operations["post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations/{invitationID}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Accept an invitation */ + post: operations["post_drive-v2-shares-invitations-{invitationID}-accept"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/invitations/{invitationID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update an invitation + * @description Only permissions can be changed. They can be changed when the invitation is pending and when it has been rejected. + * The owner should not be aware of rejection. After the invitation has been accepted, membership permissions can be edited. + * The current user must have admin permission on the share. + */ + put: operations["put_drive-v2-shares-{shareID}-invitations-{invitationID}"]; + post?: never; + /** + * Delete an invitation + * @description The current user must have admin permission on the share. + */ + delete: operations["delete_drive-v2-shares-{shareID}-invitations-{invitationID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List invitations in a share + * @description The current user must have admin permission on the share. + */ + get: operations["get_drive-v2-shares-{shareID}-invitations"]; + put?: never; + /** + * Invite a Proton user to a share + * @description The current user must have admin permission on the share. + */ + post: operations["post_drive-v2-shares-{shareID}-invitations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List invitations of a user + * @description List the pending invitations where the current user is the invitee. + */ + get: operations["get_drive-v2-shares-invitations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations/{invitationID}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reject an invitation */ + post: operations["post_drive-v2-shares-invitations-{invitationID}-reject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/invitations/{invitationID}/sendemail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send the invitation email to the invitee + * @description The current user must have admin permission on the share. + */ + post: operations["post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations/{invitationID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return invitation information + * @description Get the information about a pending invitation where the current user is the invitee. + */ + get: operations["get_drive-v2-shares-invitations-{invitationID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/user-link-access": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List link accesses for a share url. + * @description RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0031-share-resolution-from-copied-url/ + */ + get: operations["get_drive-v2-user-link-access"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List members in a share + * @description The current user must have admin permission on the share. + */ + get: operations["get_drive-v2-shares-{shareID}-members"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/members/{memberID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a member + * @description Only permissions can be changed. They can be changed when the member is active. + * The current user must have admin permission on the share. + */ + put: operations["put_drive-v2-shares-{shareID}-members-{memberID}"]; + post?: never; + /** + * Remove a share member + * @description If the current user is an admin of the share they can remove other members. + * If the current user is not an admin they can only remove themselves. + */ + delete: operations["delete_drive-v2-shares-{shareID}-members-{memberID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/security": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Performs virus checks on hashes of files received in the request payload. */ + post: operations["post_drive-v2-shares-{shareID}-security"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/thumbnails": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Fetch thumbnails by IDs. */ + post: operations["post_drive-volumes-{volumeID}-thumbnails"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/me/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user settings. + * @description Get the user settings for Drive. + */ + get: operations["get_drive-me-settings"]; + /** + * Update user settings. + * @description Update the user settings for Drive. At least one setting must be provided. + */ + put: operations["put_drive-me-settings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List volumes + * @description List all volumes available to current user. + */ + get: operations["get_drive-volumes"]; + put?: never; + /** + * Create volume + * @description Creating a new volume also creates : + * + root folder for the new Volume + * + Main share for the new Volume + * + Adds ShareMember with given Address ID + * + * Main share cannot be deleted. + */ + post: operations["post_drive-volumes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/delete_locked": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Delete locked volume */ + put: operations["put_drive-volumes-{volumeID}-delete_locked"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get volume + * @description Return the attributes of a specific volume. + */ + get: operations["get_drive-volumes-{volumeID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Restore locked volume */ + put: operations["put_drive-volumes-{volumeID}-restore"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * ProtonResponseCode + * @enum {integer} + */ + ResponseCodeSuccess: 1000; + ProtonSuccess: { + Code: components["schemas"]["ResponseCodeSuccess"]; + }; + ProtonError: { + /** ErrorCode */ + Code: number; + /** @description Error message */ + Error: string; + /** @description Error description (can be an empty object) */ + Details: Record; + }; + DriveConstants: { + /** @enum {integer} */ + BlockMaxSizeInBytes?: 5300000; + /** @enum {integer} */ + ThumbnailMaxSizeInBytes?: 65536; + /** @enum {integer} */ + DraftRevisionLifetimeInSec?: 14400; + /** @enum {integer} */ + ExtendedAttributesMaxSizeInBytes?: 65535; + /** @enum {integer} */ + UploadTokenExpirationTimeInSec?: 10800; + /** @enum {integer} */ + DownloadTokenExpirationTimeInSec?: 1800; + }; + AddPhotosToAlbumRequestDto: { + AlbumData: components["schemas"]["AlbumPhotoLinkDataDto"][]; + }; + CreateAlbumRequestDto: { + Locked: boolean; + Link: components["schemas"]["AlbumLinkDto"]; + }; + CreateAlbumResponseDto: { + Album: components["schemas"]["AlbumShortResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreatePhotoShareRequestDto: { + Share: components["schemas"]["ShareDataDto"]; + Link: components["schemas"]["LinkDataDto"]; + }; + GetPhotoVolumeResponseDto: { + Volume: components["schemas"]["PhotoVolumeResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListAlbumsResponseDto: { + Albums: components["schemas"]["AlbumResponseDto"][]; + AnchorID?: string | null; + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListPhotosAlbumQueryParameters: { + /** @default null */ + AnchorID: string | null; + Sort?: components["schemas"]["PhotosAlbumListingFilter"]; + /** @default true */ + Desc: boolean; + OrderedByCaptureTime: boolean; + }; + /** @enum {string} */ + PhotosAlbumListingFilter: "Captured" | "Added"; + ListPhotosAlbumResponseDto: { + Photos: components["schemas"]["ListPhotosAlbumItemResponseDto"][]; + AnchorID?: string | null; + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateAlbumRequestDto: { + CoverLinkID?: components["schemas"]["Id"] | null; + Link?: components["schemas"]["AlbumLinkUpdateDto"] | null; + }; + /** @description An encrypted ID */ + Id: string; + SuccessfulResponse: { + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateBookmarkShareURLRequestDto: { + BookmarkShareURL: components["schemas"]["BookmarkShareURLRequestDto"]; + }; + CreateBookmarkShareURLResponseDto: { + BookmarkShareURL: components["schemas"]["BookmarkShareURLResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListBookmarksOfUserResponseDto: { + Bookmarks: components["schemas"]["BookmarkShareURLInfoResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ChecklistResponseDto: { + /** @description Array of completed checklist items */ + Items: string[]; + CreatedAt?: number | null; + ExpiresAt?: number | null; + /** @description User already has reward quota */ + UserWasRewarded: boolean; + /** @description Client has displayed completed checklist */ + Seen: boolean; + /** @description Client has completed checklist */ + Completed: boolean; + /** + * Format: float + * @description Amount of storage GB completion reward + */ + RewardInGB: number; + /** @description Checklist should be visible to user */ + Visible: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateDeviceRequestDto: { + Device: components["schemas"]["DeviceDataDto"]; + Share: components["schemas"]["ShareDataDto2"]; + Link: components["schemas"]["LinkDataDto"]; + }; + CreateDeviceResponseDto: { + Device: components["schemas"]["DeviceResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListDevicesResponseDto: { + Devices: components["schemas"]["DeviceResponseDto2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListDevicesResponseDto2: { + Devices: components["schemas"]["DeviceResponseDto3"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateDeviceRequestDto: { + /** @default null */ + Device: components["schemas"]["DeviceDataDto2"] | null; + /** + * @deprecated + * @default null + */ + Share: components["schemas"]["ShareDataDto3"] | null; + }; + CreateDocumentDto: { + ContentKeyPacket: components["schemas"]["BinaryString"]; + ManifestSignature: components["schemas"]["PGPSignature"]; + /** + * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @default null + */ + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + SignatureAddress: string; + NodeKey: components["schemas"]["PGPPrivateKey"]; + }; + CreateDocumentResponseDto: { + Document: components["schemas"]["DocumentDetailsDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + LatestEventIDResponseDto: { + EventID: components["schemas"]["Id"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListEventsResponseDto: { + Events: components["schemas"]["EventResponseDto"][]; + /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ + EventID: string; + /** + * @description 1 if there is more to pull, i.e. there are more events than returned in one call + * @enum {integer} + */ + More: 0 | 1; + /** + * @description 1 if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync + * @enum {integer} + */ + Refresh: 0 | 1; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ParentEncryptedLinkIDsResponseDto: { + ParentLinkIDs: string[]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + RequestUploadInput: { + AddressID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + /** + * @deprecated + * @description Request for thumbnail upload + * @default 0 + */ + Thumbnail: number | null; + /** + * @deprecated + * @description Hash of thumbnail contents + * @default null + */ + ThumbnailHash: string | null; + /** + * @deprecated + * @description Size of thumbnail contents + * @default 0 + */ + ThumbnailSize: number | null; + /** @default [] */ + BlockList: components["schemas"]["RequestUploadBlockInput"][]; + /** @default [] */ + ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + }; + RequestUploadResponse: { + UploadLinks: components["schemas"]["BlockURL"][]; + /** @deprecated */ + ThumbnailLink?: components["schemas"]["ThumbnailBlockURL"] | null; + ThumbnailLinks?: components["schemas"]["ThumbnailBlockURL"][] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CopyLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, passphrase should be unchanged, only key packet is used. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + /** @description Volume ID to copy to. */ + TargetVolumeID: string; + /** @description New parent link ID to copy to. */ + TargetParentLinkID: string; + /** + * Format: email + * @description Signature email address used for signing name. + */ + NameSignatureEmail: string; + /** + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Signature email address used for the NodePassphrase. + * @default null + */ + SignatureEmail: string | null; + }; + CopyLinkResponseDto: { + LinkID: components["schemas"]["Id"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateFolderRequestDto: { + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: string; + /** + * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @default null + */ + XAttr: string | null; + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + SignatureAddress: string; + NodeKey: components["schemas"]["PGPPrivateKey"]; + }; + CreateFolderResponseDto: { + Folder: components["schemas"]["FolderResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + LinkIDsRequestDto: { + LinkIDs: components["schemas"]["EncryptedId"][]; + }; + OffsetPagination: { + /** The page size */ + PageSize: number; + /** + * The page index using 0-based indexing + * @default 0 + */ + Page: number; + }; + UpdateFolderRequestDto: { + /** @description Extended Attributes */ + XAttr: string; + }; + CreateFolderRequestDto2: { + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: string; + /** + * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @default null + */ + XAttr: string | null; + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + SignatureEmail: string; + NodeKey: components["schemas"]["PGPPrivateKey"]; + }; + ListChildrenResponseDto: { + LinkIDs: components["schemas"]["Id"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CheckAvailableHashesRequestDto: { + Hashes: string[]; + /** + * @description Client UID list to filter pending drafts with. If not provided, all conflicting draft hashes will be returned in `PendingHashes` + * @default null + */ + ClientUID: string[] | null; + }; + AvailableHashesResponseDto: { + AvailableHashes: string[]; + /** @description Hashes of existing drafts excluding the ones of provided ClientUIDs */ + PendingHashes: components["schemas"]["PendingHashResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + FetchLinksMetadataRequestDto: { + /** + * @deprecated + * @description Get thumbnail download URLs + * @default 0 + * @enum {integer} + */ + Thumbnails: 0 | 1; + LinkIDs: components["schemas"]["EncryptedId"][]; + }; + FetchLinksMetadataResponseDto: { + Links: components["schemas"]["ExtendedLinkTransformer"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + LoadLinkDetailsResponseDto: { + Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"])[]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + MoveLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, passphrase should be unchanged, only key packet is used. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + /** + * Format: email + * @description Signature email address used for signing name; Required when not passing `SignatureAddress` + * @default null + */ + NameSignatureEmail: string | null; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] since only the name is signed, use `NameSignatureEmail`. Signature email address used for the name. + * @default null + */ + SignatureAddress: string | null; + /** + * @description Current name hash before move operation. Used to prevent race conditions. + * @default null + */ + OriginalHash: string | null; + /** + * @deprecated + * @description Deprecated: Target ShareID (for move between shares/devices). Determined on the backend automatically + * @default null + */ + NewShareID: components["schemas"]["Id"] | null; + /** + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Signature email address used for the NodePassphrase. + * @default null + */ + SignatureEmail: string | null; + }; + RenameLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Name hash; ignored/nullable for root-links */ + Hash?: string | null; + /** + * Format: email + * @description Signature email address used for signing name; Required when not passing `SignatureAddress` + * @default null + */ + NameSignatureEmail: string | null; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] since only the name is signed, use `NameSignatureEmail`. Signature email address used for the name. + * @default null + */ + SignatureAddress: string | null; + /** + * @description Current name hash before move operation. Used to prevent race conditions. + * @default null + */ + OriginalHash: string | null; + /** + * @description MIME type, optional, only on files. + * @default null + * @example text/plain + */ + MIMEType: string | null; + }; + MoveLinkRequestDto2: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, passphrase should be unchanged, only key packet is used. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + /** @description Current name hash before move operation. Used to prevent race conditions. */ + OriginalHash: string; + /** + * Format: email + * @description Signature email address used for signing name + * @default null + */ + NameSignatureEmail: string | null; + /** + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Signature email address used for the NodePassphrase. + * @default null + */ + SignatureEmail: string | null; + }; + CommitRevisionDto: { + /** @description Signature of the manifest, signed with the `SignatureAddress` */ + ManifestSignature: string; + /** + * Format: email + * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. + */ + SignatureAddress: string; + /** + * @deprecated + * @description Unused. Was meant for shorter partial revisions. + * @default null + */ + BlockNumber: number | null; + /** + * @description File extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @default null + */ + XAttr: string | null; + /** + * @description Photo attributes + * @default null + */ + Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; + /** + * @deprecated + * @description Ignored entirely by API. Field can be removed from request by client. + * @default null + */ + BlockList: components["schemas"]["BlockTokenDto"][] | null; + /** + * @deprecated + * @default null + */ + ThumbnailToken: string | null; + /** + * @deprecated + * @description Ignored entirely by API, revision will always be committed (made active) + * @default null + */ + State: number | null; + }; + CreateFileDto: { + /** @example text/plain */ + MIMEType: string; + ContentKeyPacket: components["schemas"]["BinaryString"]; + /** + * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @default null + */ + ContentKeyPacketSignature: string | null; + /** + * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. + * @default null + */ + ClientUID: string | null; + /** + * @description Intended upload file size, to check if the user is trying to upload a bigger filesize than allowed. + * @default null + */ + IntendedUploadSize: number | null; + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + SignatureAddress: string; + NodeKey: components["schemas"]["PGPPrivateKey"]; + }; + CreateRevisionRequestDto: { + /** @default null */ + CurrentRevisionID: components["schemas"]["Id"] | null; + /** + * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. + * @default null + */ + ClientUID: string | null; + /** + * @description Intended upload file size, to check if the user is trying to upload a bigger filesize than allowed. + * @default null + */ + IntendedUploadSize: number | null; + }; + RestoreRevisionAcceptedResponse: { + /** + * ProtonResponseCode + * @example 1002 + * @enum {integer} + */ + Code: 1002; + }; + VerificationData: { + VerificationCode: components["schemas"]["BinaryString"]; + ContentKeyPacket: components["schemas"]["BinaryString"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + EmptyTrashAcceptedResponse: { + /** + * ProtonResponseCode + * @example 1002 + * @enum {integer} + */ + Code: 1002; + }; + VolumeTrashList: { + /** @description Trash per share */ + Trash: components["schemas"]["ShareTrashList"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + AbuseReportDto: { + /** + * @description Reported ShareURL, complete including fragment + * @example https://drive.proton.me/urls/1F9BKXYDMA#yF7d7bn01GMM + */ + ShareURL: string; + /** @enum {string} */ + AbuseCategory: "spam" | "copyright" | "child-abuse" | "stolen-data" | "malware" | "other"; + /** @description Passphrase for reported Link's Node key, unencrypted, as a string, escaped for JSON. */ + ResourcePassphrase: string; + /** + * @description Full password, including custom part, as string, escaped for JSON + * @default + */ + Password: string; + /** + * Format: email + * @description Reporter's email if provided + * @default null + */ + ReporterEmail: string | null; + /** + * @description User message about the report. Required for copyright or leak reports. + * @default null + * @example This is malware + */ + ReporterMessage: string | null; + /** @default null */ + VolumeID: components["schemas"]["Id"] | null; + /** @default null */ + LinkID: components["schemas"]["Id"] | null; + /** @default null */ + RevisionID: components["schemas"]["Id"] | null; + }; + OnboardingResponseDto: { + /** @description `true` if the user has pending/rejected invitations or user_registered external invitation */ + HasPendingInvitations: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetEntitlementResponseDto: { + Entitlements: components["schemas"]["EntitlementsDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListPhotosParameters: { + /** @default true */ + Desc: boolean; + /** @default 500 */ + PageSize: number; + /** @default null */ + PreviousPageLastLinkID: components["schemas"]["Id"] | null; + /** @default null */ + MinimumCaptureTime: number | null; + }; + PhotoListingResponse: { + Photos: components["schemas"]["PhotoListingItemResponse"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreatePhotoShareResponseDto: { + Share: components["schemas"]["ShareResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + FindDuplicatesInput: { + /** @description List of Name HMACs to check */ + NameHashes: string[]; + }; + FindDuplicatesOutputCollection: { + DuplicateHashes: components["schemas"]["FoundDuplicate"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CommitAnonymousRevisionDto: { + /** @description Signature of the manifest, signed with the `SignatureEmail` */ + ManifestSignature: string; + /** + * Format: email + * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. + */ + SignatureEmail?: string | null; + /** @description File extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) */ + XAttr: string; + /** + * @description Photo attributes + * @default null + */ + Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; + }; + CreateAnonymousDocumentDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + ContentKeyPacket: components["schemas"]["BinaryString"]; + ManifestSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + SignatureEmail?: string | null; + /** + * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @default null + */ + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + }; + CreateAnonymousDocumentResponseDto: { + Document: components["schemas"]["DocumentDetailsDto"]; + AuthorizationToken: string; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateAnonymousFileRequestDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @example text/plain */ + MIMEType: string; + ContentKeyPacket: components["schemas"]["BinaryString"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + SignatureEmail?: string | null; + /** + * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @default null + */ + ContentKeyPacketSignature: string | null; + /** + * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. + * @default null + */ + ClientUID: string | null; + /** + * @description Intended upload file size, to check if the user is trying to upload a bigger filesize than allowed. + * @default null + */ + IntendedUploadSize: number | null; + }; + CreateAnonymousFileResponseDto: { + File: components["schemas"]["FileResponseDto"]; + AuthorizationToken: string; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateAnonymousFolderRequestDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + SignatureEmail?: string | null; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: string; + /** + * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @default null + */ + XAttr: string | null; + }; + CreateAnonymousFolderResponseDto: { + Folder: components["schemas"]["FolderResponseDto"]; + AuthorizationToken: string; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + DeleteChildrenRequestDto: { + Links: components["schemas"]["LinkWithAuthorizationTokenDto"][]; + }; + RenameAnonymousLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Name hash */ + Hash: string; + /** @description Current name hash before move operation. Used to prevent race conditions. */ + OriginalHash: string; + /** + * Format: email + * @description Signature email address used for signing name + * @default null + */ + NameSignatureEmail: string | null; + /** + * @description MIME type, optional, only on files. + * @default null + * @example text/plain + */ + MIMEType: string | null; + /** @default null */ + AuthorizationToken: string | null; + }; + RequestAnonymousUploadRequestDto: { + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + /** + * Format: email + * @description Signature email address used to sign the blocks content + */ + SignatureEmail?: string | null; + /** @default [] */ + BlockList: components["schemas"]["AnonymousUploadBlockDto"][]; + /** @default [] */ + ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + }; + ShareURLContextsCollection: { + ShareURLContexts: components["schemas"]["ShareURLContext"][]; + /** @description Indicates there may be more ShareURLs */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + MyFilesResponseDto: { + Volume: components["schemas"]["VolumeDto"]; + Share: components["schemas"]["ShareDto"]; + Link: components["schemas"]["LinkDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetHighestContextForDocumentResponse: { + ContextShareID: components["schemas"]["Id"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateShareRequestDto: { + AddressID: components["schemas"]["Id"]; + RootLinkID: components["schemas"]["Id"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + /** @description Full PGP message containing (optionally) PassphraseNodeKP and SharePassphrase-KP and data-packet (encrypted SharePassphrase) -> in this exact order */ + SharePassphrase: string; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description Key packet for passphrase of referenced link's node key passphrase */ + PassphraseKeyPacket: string; + NameKeyPacket: components["schemas"]["BinaryString"]; + /** + * @deprecated + * @default null + */ + Name: string | null; + }; + ListSharesResponseDto: { + Shares: components["schemas"]["ShareResponseDto2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + TransferInput: { + /** @description The ID of the new address */ + AddressID: string; + /** @description The ID of the new key */ + KeyID: string; + /** @description Armored signature of the share passphrase, signed with the users's address with AddressID. */ + SharePassphraseSignature: string; + /** @description Base64 encoded key packet for the share passphrase, reusing the same session key as previously, and encrypted for the key referenced by the KeyID. */ + MemberKeyPacket: string; + }; + LinkMapResponse: { + SessionName: string; + More: number; + Total: number; + Links: components["schemas"]["LinkMapItemResponse"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + BootstrapShareResponseDto: { + ShareID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + Type: components["schemas"]["ShareType"]; + State: components["schemas"]["ShareState"]; + /** Format: email */ + Creator: string; + Locked?: boolean | null; + CreateTime?: number | null; + ModifyTime?: number | null; + LinkID: components["schemas"]["Id"]; + /** + * @deprecated + * @description Deprecated: Use `CreateTime` + */ + CreationTime?: number | null; + /** @deprecated */ + PermissionsMask: number; + LinkType: components["schemas"]["NodeType"]; + /** @deprecated */ + Flags: number; + /** @deprecated */ + BlockSize: number; + /** @deprecated */ + VolumeSoftDeleted: boolean; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description Address ID of the current user's address for the membership of this share. Can be missing if the user is not a direct member of the share. */ + AddressID?: string | null; + /** + * @deprecated + * @description Clients should not use this field but pass the address keyring when validating and decrypting related fields. + */ + AddressKeyID?: string | null; + /** @description Your own memberships */ + Memberships: components["schemas"]["MemberResponseDto"][]; + /** + * @deprecated + * @description Deprecated, use `Memberships` instead + */ + PossibleKeyPackets: components["schemas"]["KeyPacketResponseDto"][]; + RootLinkRecoveryPassphrase?: components["schemas"]["PGPMessage"] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + MigrateSharesRequestDto: { + /** + * @description The sum of PassphraseNodeKeyPacket-pairs and UnreadableShareIDs should not exceed 50 + * @default [] + */ + PassphraseNodeKeyPackets: components["schemas"]["ShareKPMigrationData"][]; + /** + * @description ShareIDs of unmigrated Shares that the client could not decrypt and should be locked + * @default [] + */ + UnreadableShareIDs: components["schemas"]["Id"][]; + }; + MigrateSharesResponseDto: { + /** @description ShareIDs successfully migrated */ + ShareIDs: components["schemas"]["Id"][]; + /** @description ShareIDs not migrated with reason and error code */ + Errors: components["schemas"]["ShareKPMigrationError"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UnmigratedSharesResponseDto: { + /** @description ShareIDs that can be migrated */ + ShareIDs: components["schemas"]["Id"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + AuthShareTokenRequestDto: { + ClientEphemeral: components["schemas"]["BinaryString"]; + ClientProof: components["schemas"]["BinaryString"]; + SRPSession: components["schemas"]["BinaryString"]; + }; + BootstrapShareTokenResponseDto: { + Token: components["schemas"]["TokenResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetSharedFileInfoRequestDto: { + /** @default 1 */ + FromBlockIndex: number; + /** @default null */ + PageSize: number | null; + ClientEphemeral: components["schemas"]["BinaryString"]; + ClientProof: components["schemas"]["BinaryString"]; + SRPSession: components["schemas"]["BinaryString"]; + }; + SecurityRequestDto: { + Hashes: string[]; + }; + /** @description For each hash from the request, response contains either result or error entry */ + SecurityResponseDto: { + Results: components["schemas"]["SecurityResponseResultDto"][]; + Errors: components["schemas"]["SecurityResponseErrorDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListShareURLsResponseDto: { + ShareURLs: components["schemas"]["ShareURLResponseDto"][]; + /** @description If the Recursive query parameter is set, also returns the related links and ancestors up to the share as a dictionary by LinkID. */ + Links: { + [key: string]: components["schemas"]["ExtendedLinkTransformer2"]; + }; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateShareURLRequestDto: { + CreatorEmail: string; + /** + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @enum {integer} + */ + Permissions: 4 | 6; + UrlPasswordSalt: components["schemas"]["BinaryString"]; + SharePasswordSalt: components["schemas"]["BinaryString"]; + SRPVerifier: components["schemas"]["BinaryString"]; + SRPModulusID: components["schemas"]["Id"]; + /** @description Bitmap: 1 = custom password set, 2 = random password set */ + Flags: number; + SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; + /** @description PGP encrypted password. The password is encrypted with the user's address key. */ + Password: string; + /** @description Maximum number of times this link can be accessed. 0 for infinite */ + MaxAccesses: number; + /** + * @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. Optional + * @default null + */ + ExpirationTime: number | null; + /** + * @description Number of seconds after which this link is no longer accessible. Maximum 90 days. Optional + * @default null + */ + ExpirationDuration: number | null; + /** + * @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. + * @default null + */ + Name: string | null; + }; + UpdateShareURLRequestDto: { + /** @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. */ + ExpirationTime: number; + /** @description Number of seconds after which this link is no longer accessible. Maximum 90 days. */ + ExpirationDuration?: number | null; + /** @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. */ + Name: number; + /** + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @default null + * @enum {integer|null} + */ + Permissions: 4 | 6 | null; + /** @default null */ + UrlPasswordSalt: components["schemas"]["BinaryString"] | null; + /** @default null */ + SharePasswordSalt: components["schemas"]["BinaryString"] | null; + /** @default null */ + SRPVerifier: components["schemas"]["BinaryString"] | null; + /** @default null */ + SRPModulusID: components["schemas"]["Id"] | null; + /** + * @description Bitmap: 1 = custom password set, 2 = random password set + * @default null + */ + Flags: number | null; + /** @default null */ + SharePassphraseKeyPacket: components["schemas"]["BinaryString"] | null; + /** + * @description PGP encrypted password. The password is encrypted with the user's address key. + * @default null + */ + Password: components["schemas"]["PGPMessage"] | null; + /** + * @description Maximum number of times this link can be accessed. 0 for infinite + * @default null + */ + MaxAccesses: number | null; + }; + DeleteMultipleShareURLsRequestDto: { + /** @description List of ShareURL ids to delete. */ + ShareURLIDs: components["schemas"]["EncryptedId"][]; + }; + SharedByMeResponseDto: { + Links: components["schemas"]["LinkSharedByMeResponseDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + SharedWithMeResponseDto: { + Links: components["schemas"]["LinkSharedWithMeResponseDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + InviteExternalUserRequestDto: { + ExternalInvitation: components["schemas"]["ExternalInvitationRequestDto"]; + /** @default null */ + EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; + }; + InviteExternalUserResponseDto: { + ExternalInvitation: components["schemas"]["ExternalInvitationResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListShareExternalInvitationsResponseDto: { + ExternalInvitations: components["schemas"]["ExternalInvitationResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListUserRegisteredExternalInvitationResponseDto: { + ExternalInvitations: components["schemas"]["UserRegisteredExternalInvitationItemDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateExternalInvitationRequestDto: { + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; + AcceptInvitationRequestDto: { + /** @description Signature of the share passphrase's session key with the private key of the user (invitee) and the signature context `drive.share-member.member`, base64 encoded */ + SessionKeySignature: string; + }; + InviteUserRequestDto: { + Invitation: components["schemas"]["InvitationRequestDto"]; + /** @default null */ + EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; + }; + InviteUserResponseDto: { + Invitation: components["schemas"]["InvitationResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListShareInvitationsResponseDto: { + Invitations: components["schemas"]["InvitationResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListPendingInvitationResponseDto: { + Invitations: components["schemas"]["PendingInvitationItemDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + PendingInvitationResponseDto: { + Invitation: components["schemas"]["InvitationResponseDto"]; + Share: components["schemas"]["ShareResponseDto3"]; + Link: components["schemas"]["LinkResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateInvitationRequestDto: { + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; + LinkAccessesResponseDto: { + /** @default null */ + ContextShare: components["schemas"]["ContextShareDto"] | null; + /** @default null */ + Invitations: components["schemas"]["PendingInvitationItemDto"][] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListShareMembersResponseDto: { + Members: components["schemas"]["MemberResponseDto2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateShareMemberRequestDto: { + /** + * @description Permission bitfield, cannot exceed the current user's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; + ThumbnailIDsListInput: { + /** @description List of encrypted ThumbnailIDs. Maximum 30. */ + ThumbnailIDs: components["schemas"]["Id"][]; + }; + ListThumbnailsResponse: { + Thumbnails: components["schemas"]["ThumbnailResponse"][]; + Errors: components["schemas"]["ThumbnailErrorResponse"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + SettingsResponse: { + UserSettings: components["schemas"]["UserSettings"]; + Defaults: components["schemas"]["Defaults"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UserSettingsRequest: { + /** + * @description Layout variant to use. 0=list, 1=grid. + * @enum {integer|null} + */ + Layout?: 0 | 1 | null; + /** + * @description Sort order. 1=name asc, 2=size asc, 4=modified asc, -1=name desc, -2=size desc, -4=modified desc + * @enum {integer|null} + */ + Sort?: -4 | -2 | -1 | 1 | 2 | 4 | null; + /** + * @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. + * @enum {integer|null} + */ + RevisionRetentionDays?: 0 | 7 | 30 | 180 | 365 | 3650 | null; + /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ + B2BPhotosEnabled?: boolean | null; + /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsEnabled?: boolean | null; + /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsIncludeDocumentName?: boolean | null; + }; + CreateVolumeRequestDto: { + /** @description User's Address encrypted ID */ + AddressID: string; + ShareKey: components["schemas"]["PGPPrivateKey"]; + SharePassphrase: components["schemas"]["PGPMessage"]; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + FolderName: components["schemas"]["PGPMessage"]; + FolderKey: components["schemas"]["PGPPrivateKey"]; + FolderPassphrase: components["schemas"]["PGPMessage"]; + FolderPassphraseSignature: components["schemas"]["PGPSignature"]; + FolderHashKey: components["schemas"]["PGPMessage"]; + /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ + AddressKeyID: string; + /** + * @deprecated + * @default null + */ + VolumeName: string | null; + /** + * @deprecated + * @default null + */ + ShareName: string | null; + }; + GetVolumeResponseDto: { + Volume: components["schemas"]["VolumeResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListVolumesResponseDto: { + Volumes: components["schemas"]["VolumeResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + RestoreVolumeDto: { + /** @description Folder name as armored PGP message */ + Name: string; + /** Format: email */ + SignatureAddress: string; + /** @description Hash of the name */ + Hash: string; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + TargetVolumeID: components["schemas"]["Id"]; + /** @default [] */ + Devices: components["schemas"]["RestoreDeviceDto"][]; + /** @default [] */ + PhotoShares: components["schemas"]["RestorePhotoShareDto"][]; + /** + * @description Node Hash Key should be provided if it needs to be signed because it was unsigned or signed with the address key (legacy). It should be signed with the new parent's node key. If it was properly signed with the parent node key, it should not be updated. Armored PGP message. + * @default null + */ + NodeHashKey: string | null; + /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ + AddressKeyID: string; + }; + AddPhotoToAlbumWithLinkIDResponseDto: Record; + ConflictErrorResponseDto: { + Details: components["schemas"]["ConflictErrorDetailsDto"]; + Error: string; + Code: number; + }; + MultiDeleteTransformer: { + LinkID: string; + Response: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + }; + /** Link */ + ExtendedLinkTransformer: { + /** + * @deprecated + * @description Shared flag. 0 => No public URL, 1 => shared with a public URL. Deprecated, use SharingDetails properties instead. + * @enum {integer} + */ + Shared: 0 | 1; + /** @deprecated */ + ShareUrls: { + /** + * @deprecated + * @description Share URL ID Deprecated + */ + ShareUrlId?: string; + /** @description ShareURL ID */ + ShareURLID?: string; + /** + * @deprecated + * @description ShareID + */ + ShareID?: string; + /** @description URL Token (not always provided) */ + Token?: string; + /** + * @deprecated + * @description Expiration time timestamp of ShareURL. + */ + ExpireTime?: number; + /** @description Expiration Timestamp */ + ExpirationTime?: number; + /** @description Creation time timestamp of ShareURL. */ + CreateTime?: number; + /** + * @description Number of Accesses (by access is meant download; first block is enough to increase the counter) + * @example 1 + */ + NumAccesses?: number; + }[]; + /** @description Link sharing details, null if not shared. */ + SharingDetails: { + ShareID?: string; + /** @description Share URL linking to this file or folder */ + ShareUrl?: { + /** + * @deprecated + * @description Share URL ID Deprecated + */ + ShareUrlId?: string; + /** @description ShareURL ID */ + ShareURLID?: string; + /** @description URL Token (not always provided) */ + Token?: string; + /** + * @deprecated + * @description Expiration time timestamp of ShareURL. + */ + ExpireTime?: number | null; + /** @description Expiration Timestamp */ + ExpirationTime?: number | null; + /** @description Creation time timestamp of ShareURL. */ + CreateTime?: number; + /** @description Number of Accesses (by access is meant download; first block is enough to increase the counter) */ + NumAccesses?: number; + } | null; + } | null; + /** + * @deprecated + * @description Deprecated, use `SharingDetails.ShareID` since there will only be one share per link. List of Shares related to this link. + */ + ShareIDs: string[]; + /** + * @deprecated + * @description Deprecated, use `SharingDetails.ShareURLs` and count URLs. Number of Share URLs linking to this file or folder. + */ + NbUrls: number; + /** + * @deprecated + * @description Deprecated, use `SharingDetails.ShareURLs` and count valid URLs. Number of active urls + */ + ActiveUrls: number; + /** + * @deprecated + * @description Deprecated, use `SharingDetails.ShareURLs` and check for valid URLs. Set if all URLs are expired. 0 => at least one valid URL, 1 => no usable URL + * @enum {integer} + */ + UrlsExpired: 0 | 1; + /** + * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @example -----BEGIN PGP MESSAGE-----... + */ + XAttr: string | null; + /** @description File properties */ + FileProperties: { + /** @description Content key packet */ + ContentKeyPacket?: string; + /** @description Signature of content key packet. Should be the signature of the (plain) Session Key, signed with the Node Key. Legacy versions must be accepted and can be a signature of the encrypted ContentKeyPacket and can be signed with the NodePassphraseEmail. */ + ContentKeyPacketSignature?: string; + /** @description Active revision */ + ActiveRevision?: { + /** @description Revision ID */ + ID?: string; + /** @description Creation time (UNIX timestamp) */ + CreateTime?: number; + /** @description Size of revision (in bytes) */ + Size?: number; + /** + * @description Signature of the manifest, signed with SignatureEmail + * @example -----BEGIN PGP SIGNATURE-----... + */ + ManifestSignature?: string; + /** + * Format: email + * @description Signature email address for blocks, XAttributes and manifest + */ + SignatureEmail?: string; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] Signature email address for blocks, XAttributes, and manifest + */ + SignatureAddress?: string; + /** + * @description State; Will always be active; 1=active + * @enum {integer} + */ + State?: 1; + /** + * @deprecated + * @description Revision has a thumbnail + * @enum {integer} + */ + Thumbnail?: 0 | 1; + /** + * @deprecated + * @description Download URL for the thumbnail block + */ + ThumbnailDownloadUrl?: string; + /** + * @deprecated + * @description Thumbnail properties + */ + ThumbnailURLInfo?: { + /** + * @deprecated + * @description Bare Download URL for the thumbnail block + */ + BareURL?: string; + /** + * @deprecated + * @description Token for the thumbnail block + */ + Token?: string; + }; + Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; + Photo?: components["schemas"]["PhotoTransformer"] | null; + }; + } | null; + FolderProperties: { + /** + * @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) + * @example -----BEGIN PGP MESSAGE----- + */ + NodeHashKey?: string; + } | null; + /** @description ProtonDocument properties; optional */ + DocumentProperties?: { + /** @description Document size */ + Size?: number; + } | null; + /** @description Album properties; optional */ + AlbumProperties?: { + /** @description Is the album locked */ + Locked?: boolean; + /** @description ID of the album cover link */ + CoverLinkID?: string | null; + /** @description Last time a Photo was added to the Album */ + LastActivityTime?: number; + /** @description Amount of photos in album */ + PhotoCount?: number; + /** + * @description Node hash key + * @example -----BEGIN PGP MESSAGE----- + */ + NodeHashKey?: string; + } | null; + /** @description Photo properties; optional */ + PhotoProperties?: { + /** @description A list of Albums the Photo-Link is part of */ + Albums?: { + /** @description Album Link ID */ + AlbumLinkID?: string; + /** @description NameHash in Album context (encrypted with Album-Link-NodeKey) */ + Hash?: string; + /** @description ContentHash in Album context (encrypted with Album-Link-NodeKey) */ + ContentHash?: string; + /** @description Timestamp Photo-Link was added to this album */ + AddedTime?: number; + }[]; + } | null; + } & components["schemas"]["LinkTransformer"]; + /** Revision */ + DetailedRevisionTransformer: { + /** @description Block list */ + Blocks: { + /** @description Block index */ + Index: number; + /** @description Encrypted block's sha256 hash, in base64 */ + Hash: string; + /** @description Token for download url */ + Token: string | null; + /** + * @deprecated + * @description Block download url + * @example https://block.example.com/abcd/ + */ + URL?: string | null; + /** + * @description Bare Block download url + * @example https://block.example.com/abcd/ + */ + BareURL: string | null; + /** + * @description Encrypted block signature + * @example -----BEGIN PGP MESSAGE-----... + */ + EncSignature: string | null; + /** + * Format: email + * @description Email used to sign block + */ + SignatureEmail?: string | null; + }[]; + Photo: components["schemas"]["PhotoTransformer2"] | null; + } & components["schemas"]["RevisionTransformer"]; + /** @description Conflict, a share already exists for the file or folder. */ + ShareConflictErrorResponseDto: { + Details: components["schemas"]["ShareConflictErrorDetailsDto"]; + Error: string; + Code: number; + }; + /** Revision */ + RevisionTransformer: { + /** @description Encrypted revision ID */ + ID: string; + /** @description Client managed unique ID */ + ClientUID: string | null; + /** @description Creation time (UNIX timestamp) */ + CreateTime: number; + /** @description Size of revision (in bytes) */ + Size: number; + /** + * @description Manifest signature, signed with the user's address associated with the share, `SignatureEmail` + * @example -----BEGIN PGP SIGNATURE-----... + */ + ManifestSignature: string | null; + /** + * Format: email + * @description User's email associated with the share and used to sign the manifest and block contents. + */ + SignatureEmail?: string | null; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature + */ + SignatureAddress?: string | null; + /** + * @description State (0=Draft, 1=Active, 2=Obsolete) + * @enum {integer} + */ + State: 0 | 1 | 2; + /** + * @description Extended attributes. + * @example -----BEGIN PGP MESSAGE + */ + XAttr: string | null; + /** + * @deprecated + * @description Flag stating if revision has a thumbnail + * @enum {integer} + */ + Thumbnail: 0 | 1; + /** + * @deprecated + * @description Hash for thumbnail + */ + ThumbnailHash?: string | null; + /** + * @deprecated + * @description Size thumbnail in bytes; 0 if no thumbnail present + * @example 512 + */ + ThumbnailSize: number; + Thumbnails: components["schemas"]["ThumbnailTransformer"][]; + }; + /** ShareURL */ + ShareURLDownloadTransformer: { + /** @description Share password salt. */ + SharePasswordSalt: string; + /** + * @description Share passphrase. + * @example ----BEGIN PGP MESSAGE----... + */ + SharePassphrase: string; + /** + * @description Share key. + * @example ----BEGIN PGP PRIVATE KEY BLOCK----... + */ + ShareKey: string; + /** + * @description Node passphrase + * @example -----BEGIN PGP MESSAGE-----... + */ + NodePassphrase: string; + /** + * @description Node key. + * @example ----BEGIN PGP PRIVATE KEY BLOCK----... + */ + NodeKey: string; + /** + * @description Name + * @example -----BEGIN PGP MESSAGE-----... + */ + Name: string; + /** @description Size */ + Size: number; + /** + * @deprecated + * @description Download url for thumbnail if present, null otherwise. + * @example https://.../storage/block/123 + */ + ThumbnailURL: string; + /** + * @description MimeType + * @example text/plain + */ + MIMEType: string; + /** @description Expiration time: UNIX timestamp after which this link is no longer accessible. */ + ExpirationTime: number; + /** @description Base64 encoded content key packet. */ + ContentKeyPacket: string; + /** + * @deprecated + * @description Blocks + */ + Blocks: string[]; + /** @description Block Download URLs */ + BlockURLs: { + /** + * @deprecated + * @description Download URL for the block + */ + URL?: string; + /** @description Bare Download URL for the block */ + BareURL?: string; + /** @description Token for the block URL */ + Token?: string; + }[]; + /** @description File properties */ + ThumbnailURLInfo: { + /** + * @deprecated + * @description Download URL for the thumbnail + */ + URL?: string; + /** @description Bare Download URL for the thumbnail */ + BareURL?: string; + /** @description Token for the thumbnail URL */ + Token?: string; + }; + }; + ShareURLResponseDto: { + Token: string; + ShareURLID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + /** @description URL to use to access the ShareURL */ + PublicUrl: string; + ExpirationTime?: number | null; + LastAccessTime?: number | null; + CreateTime: number; + MaxAccesses: number; + NumAccesses: number; + Name?: components["schemas"]["PGPMessage"] | null; + CreatorEmail: string; + /** + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @enum {integer} + */ + Permissions: 4 | 6; + /** @description Bitmap: + * - `1`: FLAG_CUSTOM_PASSWORD, + * - `2`: FLAG_RANDOM_PASSWORD */ + Flags: number; + UrlPasswordSalt: components["schemas"]["BinaryString"]; + SharePasswordSalt: components["schemas"]["BinaryString"]; + SRPVerifier: components["schemas"]["BinaryString"]; + SRPModulusID: components["schemas"]["Id"]; + Password: components["schemas"]["PGPMessage"]; + SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; + }; + AlbumPhotoLinkDataDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name Hash */ + Hash: string; + Name: string; + /** + * Format: email + * @description Email address used for signing name + */ + NameSignatureEmail: string; + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @description Photo content hash */ + ContentHash: string; + /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ + NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature + */ + SignatureEmail?: string | null; + }; + AlbumLinkDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description Album name Hash */ + Hash: string; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + SignatureEmail: string; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: string; + /** @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) */ + XAttr: string; + }; + AlbumShortResponseDto: { + Link: components["schemas"]["AlbumLinkResponseDto"]; + }; + ShareDataDto: { + AddressID: components["schemas"]["Id"]; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ + AddressKeyID: string; + }; + LinkDataDto: { + /** @description Root folder name */ + Name: string; + NodeKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeHashKey: components["schemas"]["PGPMessage"]; + }; + PhotoVolumeResponseDto: { + VolumeID: components["schemas"]["Id"]; + CreateTime?: number | null; + ModifyTime?: number | null; + /** @description Used space in bytes */ + UsedSpace: number; + DownloadedBytes: number; + UploadedBytes: number; + State: components["schemas"]["VolumeState"]; + Share: components["schemas"]["ShareReferenceResponseDto"]; + /** + * @description Type (1=Regular, 2=Photo) + * @enum {integer} + */ + Type: 1 | 2; + /** + * @description Status of restore task if applicable: + * - 0 => done + * - 1 => in progress + * - -1 => failed + * @default null + * @enum {integer|null} + */ + RestoreStatus: 0 | 1 | -1 | null; + }; + AlbumResponseDto: { + Locked: boolean; + LastActivityTime: number; + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + /** @default null */ + ShareID: components["schemas"]["Id"] | null; + /** @default null */ + CoverLinkID: components["schemas"]["Id"] | null; + }; + ListPhotosAlbumItemResponseDto: { + LinkID: components["schemas"]["Id"]; + CaptureTime: number; + Hash: string; + ContentHash: string; + RelatedPhotos: components["schemas"]["ListPhotosAlbumRelatedPhotoItemResponseDto"][]; + AddedTime: number; + IsChildOfAlbum: boolean; + }; + AlbumLinkUpdateDto: { + Name?: components["schemas"]["PGPMessage"] | null; + Hash?: string | null; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + NameSignatureEmail?: string | null; + OriginalHash?: string | null; + /** @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) */ + XAttr: string; + }; + BookmarkShareURLRequestDto: { + EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + AddressID: components["schemas"]["Id"]; + AddressKeyID: components["schemas"]["Id"]; + }; + BookmarkShareURLResponseDto: { + UserID: components["schemas"]["Id"]; + Token: string; + ShareURLID: components["schemas"]["Id"]; + EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + State: components["schemas"]["BookmarkShareURLState"]; + CreateTime: number; + ModifyTime: number; + }; + BookmarkShareURLInfoResponseDto: { + EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + CreateTime: number; + Token: components["schemas"]["TokenResponseDto"]; + }; + DeviceDataDto: { + /** + * @description State of sync for that device; 0=>off, 1=>on + * @enum {integer} + */ + SyncState: 0 | 1; + /** + * @description Type of device; 1=>Windows, 2=>MacOs, 3=>Linux + * @enum {integer} + */ + Type: 1 | 2 | 3; + /** + * @deprecated + * @default null + */ + VolumeID: components["schemas"]["Id"] | null; + }; + ShareDataDto2: { + AddressID: components["schemas"]["Id"]; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ + AddressKeyID: string; + /** + * @deprecated + * @default null + */ + Name: string | null; + }; + DeviceResponseDto: { + DeviceID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; + DeviceResponseDto2: { + Device: components["schemas"]["DeviceDataDto3"]; + Share: components["schemas"]["ShareDataDto4"]; + }; + DeviceResponseDto3: { + Device: components["schemas"]["DeviceDto"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; + DeviceDataDto2: { + /** + * @description State of sync for that device; 0=>off, 1=>on + * @default null + * @enum {integer|null} + */ + SyncState: 0 | 1 | null; + /** + * @description UNIX timestamp when the Device got last synced. Optional + * @default null + */ + LastSyncTime: number | null; + }; + ShareDataDto3: { + /** + * @deprecated + * @default null + */ + Name: string | null; + }; + /** @description Base64 encoded binary data */ + BinaryString: string; + /** @description An armored PGP Signature */ + PGPSignature: string; + /** @description An armored PGP Message */ + PGPMessage: string; + /** @description An armored PGP Private Key */ + PGPPrivateKey: string; + DocumentDetailsDto: { + VolumeID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + }; + EventResponseDto: { + EventID: components["schemas"]["Id"]; + /** + * @description Event type (0=delete, 1=create, 2=update, 3=update metadata) + * @enum {integer} + */ + EventType: 0 | 1 | 2 | 3; + /** @description Event creation timestamp */ + CreateTime: number; + Link: { + LinkID: components["schemas"]["Id"]; + } | components["schemas"]["ExtendedLinkTransformer2"]; + /** + * @description The share the user has access to that is closest to the root. Delete events do not have it but other events do. + * @default null + */ + ContextShareID: string | null; + /** + * @description If a file was moved to a different context share, this shows the old, origin share + * @default null + */ + FromContextShareID: string | null; + /** + * @description Optional event data + * @default null + */ + Data: { + /** @description New or updated ShareURL */ + UrlID?: string; + /** + * @deprecated + * @description Corresponding ShareURL has been deleted + */ + DeletedURLID?: string[]; + /** @description Corresponding locked volume has been restored */ + FLAG_RESTORE_COMPLETE?: string; + /** @description Restoration has failed for corresponding locked volume */ + FLAG_RESTORE_FAILED?: string; + /** @description Revision has been restored for this LinkID */ + FLAG_RESTORE_REVISION_COMPLETE?: string; + /** @description Parent before the move */ + FromParentLinkID?: string; + } | null; + }; + RequestUploadBlockInput: { + /** @description Block size in bytes */ + Size: number; + /** @description Index of block in list (must be consecutive starting at 1) */ + Index: number; + /** @description Encrypted PGP Signature of the raw block content */ + EncSignature: string; + /** @description Hash of encrypted block, base64 encoded */ + Hash: string; + /** @default null */ + Verifier: components["schemas"]["Verifier"] | null; + }; + RequestUploadThumbnailInput: { + /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ + Size: number; + /** + * @description Type of thumbnail : 1=Preview(512), 2=HDPreview(1920), 3=MachineLearning + * @enum {integer} + */ + Type: 1 | 2 | 3; + /** @description Hash of encrypted block, base64 encoded */ + Hash: string; + }; + BlockURL: { + BareURL: string; + Token: string; + /** @deprecated */ + URL: string; + Index: number; + }; + ThumbnailBlockURL: { + BareURL: string; + Token: string; + /** @deprecated */ + URL: string; + /** + * @description Thumbnail type: 1=Preview(512), 2=HDPreview(1920), 3=MachineLearning + * @enum {integer} + */ + ThumbnailType: 1 | 2 | 3; + }; + FolderResponseDto: { + ID: components["schemas"]["Id"]; + }; + /** @description An encrypted ID */ + EncryptedId: string; + PendingHashResponseDto: { + Hash: string; + RevisionID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + ClientUID?: string | null; + }; + FileDetailsDto: { + Link: components["schemas"]["LinkDto"]; + File: components["schemas"]["FileDto"]; + /** @default null */ + ActiveRevision: components["schemas"]["ActiveRevisionDto"] | null; + /** @default null */ + SharingSummary: components["schemas"]["SharingSummaryDto"] | null; + /** @default null */ + Folder: null | null; + }; + FolderDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Folder: components["schemas"]["FolderDto"]; + /** @default null */ + SharingSummary: components["schemas"]["SharingSummaryDto"] | null; + /** @default null */ + File: null | null; + /** @default null */ + ActiveRevision: null | null; + }; + CommitRevisionPhotoDto: { + /** @description Photo capture timestamp */ + CaptureTime: number; + /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ + ContentHash: string; + /** + * @description Main photo LinkID reference. Pass null if none. + * @default null + */ + MainPhotoLinkID: string | null; + /** + * @description Base64 encoded Photo Exif Data, encrypted with Node SessionKey (ContentKeyPacket), signed with user addressKey and signature context "drive.photo.exif" + * @default null + */ + Exif: components["schemas"]["BinaryString"] | null; + }; + BlockTokenDto: { + Index: number; + Token: string; + }; + ShareTrashList: { + ShareID: components["schemas"]["Id"]; + /** @description List of trashed link IDs for that share */ + LinkIDs: components["schemas"]["Id"][]; + /** @description List of trashed link's parentLinkIDs */ + ParentIDs: components["schemas"]["Id"][]; + }; + EntitlementsDto: { + /** @description Maximum number of days revision history can be kept */ + MaxRevisionCount: number; + /** @description Maximum amount of revisions on a single link that can be kept */ + MaxRevisionDays: number; + /** @description Allow or not the user to create writable ShareURLs */ + PublicCollaboration: boolean; + }; + PhotoListingItemResponse: { + LinkID: components["schemas"]["Id"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + /** @description File name hash */ + Hash?: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + /** @default [] */ + RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; + }; + ShareResponseDto: { + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; + FoundDuplicate: { + /** @description NameHash of the found duplicate */ + Hash?: string | null; + /** @description ContentHash of the found duplicate */ + ContentHash?: string | null; + /** + * @description State of the link: 0=draft, 1=active, 2=trashed; Can be null if the Link was deleted + * @enum {integer|null} + */ + LinkState?: 0 | 1 | 2 | null; + /** @description Client defined UID for the draft. Null if no ClientUID passed, or Revision was already committed. */ + ClientUID?: string | null; + /** @description LinkID, null if deleted */ + LinkID: string; + /** @description RevisionID, null if deleted */ + RevisionID: string; + }; + FileResponseDto: { + ID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + ClientUID?: string | null; + }; + LinkWithAuthorizationTokenDto: { + LinkID: components["schemas"]["Id"]; + /** @default null */ + AuthorizationToken: string | null; + }; + AnonymousUploadBlockDto: { + /** @description Block size in bytes */ + Size: number; + /** @description Index of block in list (must be consecutive starting at 1) */ + Index: number; + /** @description Encrypted PGP Signature of the raw block content */ + EncSignature: string; + /** @description Hash of encrypted block, base64 encoded */ + Hash: string; + Verifier: components["schemas"]["Verifier"]; + }; + ShareURLContext: { + /** @description Share ID of the share highest in the tree with permissions */ + ContextShareID: string; + ShareURLs: components["schemas"]["ShareURLResponseDto"][]; + /** @description Related link IDs and ancestors up to the share. */ + LinkIDs: components["schemas"]["Id"][]; + }; + VolumeDto: { + VolumeID: components["schemas"]["Id"]; + UsedSpace: number; + }; + ShareDto: { + ShareID: components["schemas"]["Id"]; + /** Format: email */ + CreatorEmail: string; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; + AddressID: components["schemas"]["Id"]; + }; + LinkDto: { + LinkID: components["schemas"]["Id"]; + /** @enum {integer} */ + Type: 2 | 1; + ParentLinkID?: components["schemas"]["Id"] | null; + /** @enum {integer} */ + State: 0 | 1 | 2; + CreateTime: number; + ModifyTime: number; + TrashTime?: number | null; + Name: components["schemas"]["PGPMessage"]; + NameHash?: string | null; + MIMEType?: string | null; + NodeKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** Format: email */ + SignatureEmail?: string | null; + /** Format: email */ + NameSignatureEmail?: string | null; + }; + ShareResponseDto2: { + ShareID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + Type: components["schemas"]["ShareType"]; + State: components["schemas"]["ShareState"]; + /** Format: email */ + Creator: string; + Locked?: boolean | null; + CreateTime?: number | null; + ModifyTime?: number | null; + LinkID: components["schemas"]["Id"]; + /** + * @deprecated + * @description Deprecated: Use `CreateTime` + */ + CreationTime?: number | null; + /** @deprecated */ + PermissionsMask: number; + /** @deprecated */ + LinkType: number; + /** @deprecated */ + Flags: number; + /** @deprecated */ + BlockSize: number; + /** @deprecated */ + VolumeSoftDeleted: boolean; + }; + LinkMapItemResponse: { + Index: number; + LinkID: components["schemas"]["Id"]; + ParentLinkID?: components["schemas"]["Id"] | null; + /** @enum {integer} */ + Type: 1 | 2; + Name: components["schemas"]["PGPMessage"]; + Hash?: string | null; + /** + * @description State (1=active, 2=trashed) + * @enum {integer} + */ + State: 1 | 2; + Size: number; + MIMEType: string; + CreateTime: number; + ModifyTime: number; + /** @default null */ + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @default null */ + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @default null */ + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** @default null */ + NodeSignatureEmail: string; + }; + /** + * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueDescription
1Main
2Standard
3Device
4Photo
+ * @enum {integer} + */ + ShareType: 1 | 2 | 3 | 4; + /** + * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
+ * @enum {integer} + */ + ShareState: 1 | 2 | 3; + /** + * @description

1=folder, 2=file

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType: 1 | 2 | 3; + MemberResponseDto: { + MemberID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + AddressID: components["schemas"]["Id"]; + AddressKeyID: components["schemas"]["Id"]; + /** Format: email */ + Inviter: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + KeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: string; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + SessionKeySignature: string; + State: components["schemas"]["ShareMemberState"]; + CreateTime: number; + ModifyTime: number; + /** @deprecated */ + CreationTime: number; + /** + * @deprecated + * @description Deprecated and always null + * @default null + */ + Unlockable: boolean | null; + }; + KeyPacketResponseDto: { + AddressID: components["schemas"]["Id"]; + AddressKeyID: components["schemas"]["Id"]; + KeyPacket: components["schemas"]["BinaryString"]; + State: components["schemas"]["ShareMemberState"]; + /** + * @deprecated + * @description Deprecated and always null + * @default null + */ + Unlockable: boolean | null; + }; + ShareKPMigrationData: { + /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ + ShareID: string; + /** @description Key packet to decrypt the share passphrase, encrypted with the node key, base64 encoded */ + PassphraseNodeKeyPacket: string; + }; + /** @description Share unable to be migrated with reason and code; NOT_EXISTS, INCOMPATIBLE_STATE, PERMISSION_DENIED, ENCRYPTION_VERIFICATION_FAILED */ + ShareKPMigrationError: { + ShareID: components["schemas"]["Id"]; + Error: string; + Code: number; + }; + TokenResponseDto: { + /** + * @description Url Token + * @example YTZZRH7DA8 + */ + Token: string; + LinkType: components["schemas"]["NodeType2"]; + LinkID: components["schemas"]["Id"]; + /** + * @description Share password salt + * @example qZBadaNdT8Y1N3== + */ + SharePasswordSalt: string; + SharePassphrase: components["schemas"]["PGPMessage"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + Name: components["schemas"]["PGPMessage"]; + /** @description Base64 encoded content key packet. Null for folders */ + ContentKeyPacket?: string | null; + /** @example text/plain */ + MIMEType: string; + /** + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @enum {integer} + */ + Permissions: 4 | 6; + /** @description File size, null for folders */ + Size?: number | null; + /** @description File properties */ + ThumbnailURLInfo?: components["schemas"]["ThumbnailURLInfoResponseDto"] | null; + /** @default null */ + NodeHashKey: string | null; + /** + * @description Signature email of the share owner. Only set for a ShareURL with read+write permissions. + * @default null + */ + SignatureEmail: string | null; + /** + * @description Only set for a ShareURL with read+write permissions. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + }; + SecurityResponseResultDto: { + Hash: string; + /** @description Whether file is safe or not, true if yes, false if not */ + Safe: boolean; + }; + SecurityResponseErrorDto: { + Hash: string; + /** + * @description An error message describing the error, translated. Can be displayed directly to user. + * @example We cannot check this file at present, please proceed with caution + */ + Error: string; + }; + /** Link */ + ExtendedLinkTransformer2: { + /** + * @deprecated + * @description Shared flag. 0 => No public URL, 1 => shared with a public URL. Deprecated, use SharingDetails properties instead. + * @enum {integer} + */ + Shared: 0 | 1; + /** @deprecated */ + ShareUrls: { + /** + * @deprecated + * @description Share URL ID Deprecated + */ + ShareUrlId?: string; + /** @description ShareURL ID */ + ShareURLID?: string; + /** + * @deprecated + * @description ShareID + */ + ShareID?: string; + /** @description URL Token (not always provided) */ + Token?: string; + /** + * @deprecated + * @description Expiration time timestamp of ShareURL. + */ + ExpireTime?: number; + /** @description Expiration Timestamp */ + ExpirationTime?: number; + /** @description Creation time timestamp of ShareURL. */ + CreateTime?: number; + /** + * @description Number of Accesses (by access is meant download; first block is enough to increase the counter) + * @example 1 + */ + NumAccesses?: number; + }[]; + /** @description Link sharing details, null if not shared. */ + SharingDetails: { + ShareID?: string; + /** @description Share URL linking to this file or folder */ + ShareUrl?: { + /** + * @deprecated + * @description Share URL ID Deprecated + */ + ShareUrlId?: string; + /** @description ShareURL ID */ + ShareURLID?: string; + /** @description URL Token (not always provided) */ + Token?: string; + /** + * @deprecated + * @description Expiration time timestamp of ShareURL. + */ + ExpireTime?: number | null; + /** @description Expiration Timestamp */ + ExpirationTime?: number | null; + /** @description Creation time timestamp of ShareURL. */ + CreateTime?: number; + /** @description Number of Accesses (by access is meant download; first block is enough to increase the counter) */ + NumAccesses?: number; + } | null; + } | null; + /** + * @deprecated + * @description Deprecated, use `SharingDetails.ShareID` since there will only be one share per link. List of Shares related to this link. + */ + ShareIDs: string[]; + /** + * @deprecated + * @description Deprecated, use `SharingDetails.ShareURLs` and count URLs. Number of Share URLs linking to this file or folder. + */ + NbUrls: number; + /** + * @deprecated + * @description Deprecated, use `SharingDetails.ShareURLs` and count valid URLs. Number of active urls + */ + ActiveUrls: number; + /** + * @deprecated + * @description Deprecated, use `SharingDetails.ShareURLs` and check for valid URLs. Set if all URLs are expired. 0 => at least one valid URL, 1 => no usable URL + * @enum {integer} + */ + UrlsExpired: 0 | 1; + /** + * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @example -----BEGIN PGP MESSAGE-----... + */ + XAttr: string | null; + /** @description File properties */ + FileProperties: { + /** @description Content key packet */ + ContentKeyPacket?: string; + /** @description Signature of content key packet. Should be the signature of the (plain) Session Key, signed with the Node Key. Legacy versions must be accepted and can be a signature of the encrypted ContentKeyPacket and can be signed with the NodePassphraseEmail. */ + ContentKeyPacketSignature?: string; + /** @description Active revision */ + ActiveRevision?: { + /** @description Revision ID */ + ID?: string; + /** @description Creation time (UNIX timestamp) */ + CreateTime?: number; + /** @description Size of revision (in bytes) */ + Size?: number; + /** + * @description Signature of the manifest, signed with SignatureEmail + * @example -----BEGIN PGP SIGNATURE-----... + */ + ManifestSignature?: string; + /** + * Format: email + * @description Signature email address for blocks, XAttributes and manifest + */ + SignatureEmail?: string; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] Signature email address for blocks, XAttributes, and manifest + */ + SignatureAddress?: string; + /** + * @description State; Will always be active; 1=active + * @enum {integer} + */ + State?: 1; + /** + * @deprecated + * @description Revision has a thumbnail + * @enum {integer} + */ + Thumbnail?: 0 | 1; + /** + * @deprecated + * @description Download URL for the thumbnail block + */ + ThumbnailDownloadUrl?: string; + /** + * @deprecated + * @description Thumbnail properties + */ + ThumbnailURLInfo?: { + /** + * @deprecated + * @description Bare Download URL for the thumbnail block + */ + BareURL?: string; + /** + * @deprecated + * @description Token for the thumbnail block + */ + Token?: string; + }; + Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; + Photo?: components["schemas"]["PhotoTransformer"] | null; + }; + } | null; + FolderProperties: { + /** + * @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) + * @example -----BEGIN PGP MESSAGE----- + */ + NodeHashKey?: string; + } | null; + /** @description ProtonDocument properties; optional */ + DocumentProperties?: { + /** @description Document size */ + Size?: number; + } | null; + /** @description Album properties; optional */ + AlbumProperties?: { + /** @description Is the album locked */ + Locked?: boolean; + /** @description ID of the album cover link */ + CoverLinkID?: string | null; + /** @description Last time a Photo was added to the Album */ + LastActivityTime?: number; + /** @description Amount of photos in album */ + PhotoCount?: number; + /** + * @description Node hash key + * @example -----BEGIN PGP MESSAGE----- + */ + NodeHashKey?: string; + } | null; + /** @description Photo properties; optional */ + PhotoProperties?: { + /** @description A list of Albums the Photo-Link is part of */ + Albums?: { + /** @description Album Link ID */ + AlbumLinkID?: string; + /** @description NameHash in Album context (encrypted with Album-Link-NodeKey) */ + Hash?: string; + /** @description ContentHash in Album context (encrypted with Album-Link-NodeKey) */ + ContentHash?: string; + /** @description Timestamp Photo-Link was added to this album */ + AddedTime?: number; + }[]; + } | null; + } & components["schemas"]["LinkTransformer"]; + LinkSharedByMeResponseDto: { + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + ContextShareID: components["schemas"]["Id"]; + }; + LinkSharedWithMeResponseDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; + ExternalInvitationRequestDto: { + InviterAddressID: components["schemas"]["Id"]; + /** Format: email */ + InviteeEmail: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ + ExternalInvitationSignature: string; + }; + InvitationEmailDetailsRequestDto: { + Message?: string | null; + ItemName?: string | null; + }; + ExternalInvitationResponseDto: { + ExternalInvitationID: components["schemas"]["Id"]; + /** Format: email */ + InviterEmail: string; + /** Format: email */ + InviteeEmail: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ + ExternalInvitationSignature: string; + State: components["schemas"]["ExternalInvitationState"]; + CreateTime: number; + }; + UserRegisteredExternalInvitationItemDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + ExternalInvitationID: components["schemas"]["Id"]; + }; + InvitationRequestDto: { + /** Format: email */ + InviterEmail: string; + /** Format: email */ + InviteeEmail: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description Encrypting the share passphrase's session key with the invitee's public address key, base64 encoded */ + KeyPacket: string; + /** @description Signature of the above member key packet with the private key of the user (inviter) and the signature context `drive.share-member.inviter`, base64 encoded */ + KeyPacketSignature: string; + /** @default null */ + ExternalInvitationID: components["schemas"]["Id"] | null; + }; + InvitationResponseDto: { + InvitationID: components["schemas"]["Id"]; + /** Format: email */ + InviterEmail: string; + /** Format: email */ + InviteeEmail: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + KeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: string; + CreateTime: number; + }; + PendingInvitationItemDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + InvitationID: components["schemas"]["Id"]; + }; + ShareResponseDto3: { + ShareID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + Passphrase: components["schemas"]["PGPMessage"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + /** Format: email */ + CreatorEmail: string; + }; + LinkResponseDto: { + /** @enum {integer} */ + Type: 1 | 2; + LinkID: components["schemas"]["Id"]; + Name: components["schemas"]["PGPMessage"]; + MIMEType?: string | null; + }; + ContextShareDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; + MemberResponseDto2: { + MemberID: components["schemas"]["Id"]; + /** Format: email */ + InviterEmail: string; + /** Format: email */ + Email: string; + /** + * @description Permission bitfield, cannot exceed the inviter's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + KeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: string; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + SessionKeySignature: string; + CreateTime: number; + }; + ThumbnailResponse: { + ThumbnailID: components["schemas"]["Id"]; + BareURL: string; + Token: string; + }; + ThumbnailErrorResponse: { + ThumbnailID: components["schemas"]["Id"]; + Error: string; + Code: number; + }; + UserSettings: { + /** + * @description Layout variant to use. 0=list, 1=grid. + * @enum {integer|null} + */ + Layout?: 0 | 1 | null; + /** + * @description Sort order. 1=name asc, 2=size asc, 4=modified asc, -1=name desc, -2=size desc, -4=modified desc + * @enum {integer|null} + */ + Sort?: -4 | -2 | -1 | 1 | 2 | 4 | null; + /** + * @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. + * @enum {integer|null} + */ + RevisionRetentionDays?: 0 | 7 | 30 | 180 | 365 | 3650 | null; + /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ + B2BPhotosEnabled?: boolean | null; + /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsEnabled?: boolean | null; + /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsIncludeDocumentName?: boolean | null; + }; + Defaults: { + /** + * @description Number of days revisions should be retained if not defined by the user. Default ALWAYS used for free users, even if different value is set (premium feature). + * @enum {integer} + */ + RevisionRetentionDays: 0 | 7 | 30 | 180 | 365 | 3650; + /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ + B2BPhotosEnabled: boolean; + /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsEnabled: boolean; + /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. */ + DocsCommentsNotificationsIncludeDocumentName: boolean; + }; + VolumeResponseDto: { + ID: components["schemas"]["Id"]; + /** + * @deprecated + * @description Deprecated, use `CreateTime` instead + */ + CreationTime?: number | null; + /** + * @deprecated + * @default null + */ + MaxSpace: number | null; + VolumeID: components["schemas"]["Id"]; + CreateTime?: number | null; + ModifyTime?: number | null; + /** @description Used space in bytes */ + UsedSpace: number; + DownloadedBytes: number; + UploadedBytes: number; + State: components["schemas"]["VolumeState"]; + Share: components["schemas"]["ShareReferenceResponseDto"]; + /** + * @description Type (1=Regular, 2=Photo) + * @enum {integer} + */ + Type: 1 | 2; + /** + * @description Status of restore task if applicable: + * - 0 => done + * - 1 => in progress + * - -1 => failed + * @default null + * @enum {integer|null} + */ + RestoreStatus: 0 | 1 | -1 | null; + }; + RestoreDeviceDto: { + /** @description ShareID of the existing share on the old volume */ + LockedShareID: string; + /** @description Key packet for the share passphrase, encrypted with the active key associated with the new volume. Encoded with Base64. */ + ShareKeyPacket: string; + /** @description Signed with new key as armored PGP signature */ + PassphraseSignature: string; + }; + RestorePhotoShareDto: { + /** @description ShareID of the existing share on the old volume */ + LockedShareID: string; + /** @description Key packet for the share passphrase, encrypted with the active key associated with the new volume. Encoded with Base64. */ + ShareKeyPacket: string; + /** @description Signed with new key as armored PGP signature */ + PassphraseSignature: string; + }; + ConflictErrorDetailsDto: { + ConflictLinkID: components["schemas"]["Id"]; + /** + * @description A conflicting Revision in Active state. + * @default null + */ + ConflictRevisionID: string | null; + /** + * @description A conflicting Revision in Draft state. + * @default null + */ + ConflictDraftRevisionID: string | null; + /** + * @description ClientUID of conflicting Revision if in Draft state. + * @default null + */ + ConflictDraftClientUID: string | null; + /** + * @deprecated + * @description [DEPRECATED] for backwards compatibility on create revision, same value as ConflictDraftRevisionID + * @default null + */ + RevisionID: string | null; + }; + /** Thumbnail */ + ThumbnailTransformer: { + /** @description Encrypted Thumbnail ID. Will be null for legacy Thumbnails. */ + ThumbnailID: string | null; + /** @enum {integer} */ + Type: 1 | 2 | 3; + /** @description Base64 encoded thumbnail-content-hash */ + Hash: string; + Size: number; + }; + /** Photo */ + PhotoTransformer: { + LinkID: string; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + MainPhotoLinkID: string | null; + /** @description File name hash */ + Hash: string; + /** @description Base64 encoded Photo Exif Data, encrypted with Node SessionKey (ContentKeyPacket), signed with user addressKey */ + Exif: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash: string | null; + /** @description LinkIDs of related Photos if there are any */ + RelatedPhotosLinkIDs: string[]; + }; + /** Link */ + LinkTransformer: { + /** @description Encrypted link ID */ + LinkID: string; + /** @description Encrypted parent link ID */ + ParentLinkID: string | null; + /** @description Encrypted volume link ID */ + VolumeID: string; + /** + * @description Node type (1=folder, 2=file) + * @enum {integer} + */ + Type: 1 | 2; + /** + * @description Link name + * @example ----BEGIN PGP MESSAGE----... + */ + Name: string; + /** + * Format: email + * @description Link name signature email (signed since 1st January 2021) + */ + NameSignatureEmail: string; + /** @description Name Hash */ + Hash: string | null; + /** + * @description State (0=draft, 1=active, 2=trashed) + * @enum {integer} + */ + State: 0 | 1 | 2; + /** + * @deprecated + * @description [Deprecated] ExpirationTime (always null) + */ + ExpirationTime: number | null; + /** + * @deprecated + * @description Encrypted size (for files of active revisions, better to use FileProperties > ActiveRevision > Size) + */ + Size: number; + /** @description Encrypted size of Node (all active and obsolete revisions for files) */ + TotalSize: number; + /** + * @description Mime type + * @example application/ms-xls + */ + MIMEType: string; + /** + * @description Attributes + * @example 1 + */ + Attributes: number; + /** + * @deprecated + * @description Always returns 7, read+write+execute + */ + Permissions: number; + /** + * @description Node Key + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----... + */ + NodeKey: string; + /** + * @description Node passphrase + * @example ----BEGIN PGP MESSAGE-----... + */ + NodePassphrase: string; + /** + * @description Node passphrase signature + * @example -----BEGIN PGP SIGNATURE-----... + */ + NodePassphraseSignature: string; + /** + * Format: email + * @description Signature email address used for passphrase, should be the user's address associated with the Share. + */ + SignatureEmail: string; + /** + * Format: email + * @deprecated + * @description [Deprecated] Signature email address used for passphrase + */ + SignatureAddress: string; + /** @description Creation timestamp */ + CreateTime: number; + /** @description Last modification timestamp (on API, real modify date is stored in XAttr) */ + ModifyTime: number; + /** @description Timestamp, time at which the file was trashed, null if file is not trashed. */ + Trashed: number | null; + }; + /** Photo */ + PhotoTransformer2: { + LinkID: string; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + MainPhotoLinkID: string | null; + /** @description File name hash */ + Hash: string; + /** @description Base64 encoded Photo Exif Data, encrypted with Node SessionKey (ContentKeyPacket), signed with user addressKey */ + Exif: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash: string | null; + /** @description LinkIDs of related Photos if there are any */ + RelatedPhotosLinkIDs: string[]; + }; + ShareConflictErrorDetailsDto: { + ConflictLinkID: components["schemas"]["Id"]; + /** @description A conflicting Share on the Link. */ + ConflictShareID: string; + }; + AlbumLinkResponseDto: { + LinkID: components["schemas"]["Id"]; + }; + /** + * @description

State (1=Active, 3=Locked)

See values descriptions
See values descriptions
ValueDescription
1Active
3Locked
+ * @enum {integer} + */ + VolumeState: 1 | 3; + ShareReferenceResponseDto: { + ShareID: components["schemas"]["Id"]; + ID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; + ListPhotosAlbumRelatedPhotoItemResponseDto: { + LinkID: components["schemas"]["Id"]; + CaptureTime: number; + Hash: string; + ContentHash: string; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Active
3Deleted
+ * @enum {integer} + */ + BookmarkShareURLState: 1 | 3; + DeviceDataDto3: { + DeviceID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + /** + * @description State of sync for that device; 0=>off, 1=>on + * @enum {integer} + */ + SyncState: 0 | 1; + /** + * @description Type of device; 1=>Windows, 2=>MacOs, 3=>Linux + * @enum {integer} + */ + Type: 1 | 2 | 3; + /** @description UNIX timestamp when the Device got last synced */ + LastSyncTime?: number | null; + CreateTime: number; + ModifyTime?: number | null; + /** + * @deprecated + * @description Deprecated: use `CreateTime` + */ + CreationTime: number; + }; + ShareDataDto4: { + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + /** @deprecated */ + Name: string; + }; + DeviceDto: { + DeviceID: components["schemas"]["Id"]; + CreateTime: number; + ModifyTime?: number | null; + /** @enum {integer} */ + Type: 3 | 2 | 1; + }; + Verifier: { + /** @description Derived from verificationCode from GET /verification endpoint: base64(xor(verificationCode, padWithZeros(dataPacket, 32))) https://confluence.protontech.ch/x/j_OTC */ + Token: string; + }; + FileDto: { + TotalEncryptedSize: number; + ContentKeyPacket: components["schemas"]["BinaryString"]; + ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + }; + ActiveRevisionDto: { + RevisionID: components["schemas"]["Id"]; + CreateTime: number; + EncryptedSize: number; + /** @description Signature of the manifest, signed with the `SignatureEmail` */ + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + Thumbnails: components["schemas"]["ThumbnailDto"][]; + Photo?: components["schemas"]["PhotoDto"] | null; + /** Format: email */ + SignatureEmail?: string | null; + }; + SharingSummaryDto: { + ShareID: components["schemas"]["Id"]; + ShareURLID?: components["schemas"]["Id"] | null; + ShareAccess: components["schemas"]["ShareAccessDto"]; + }; + FolderDto: { + NodeHashKey?: components["schemas"]["PGPMessage"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + }; + PhotoListingRelatedItemResponse: { + LinkID: components["schemas"]["Id"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + /** @description File name hash */ + Hash?: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + }; + /** + * @description

1=active, 3=locked

See values descriptions
See values descriptions
ValueNameDescription
1Active
2Deleted
3Locked* Locked membership can have two reasons: + * * - either the associated address was disabled/deleted, e.g. due to account deletion + * * - or the associated address key was made inactive due to a password reset + * * + * * It means the membership cannot be used for decryption unless it is restored with account recovery.
+ * @enum {integer} + */ + ShareMemberState: 1 | 2 | 3; + /** + * @description

Types: Folder - 1, File - 2}

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType2: 1 | 2 | 3; + ThumbnailURLInfoResponseDto: { + /** + * @deprecated + * @description Download URL for the thumbnail + */ + URL?: string | null; + /** @description Bare Download URL for the thumbnail */ + BareURL?: string | null; + /** @description Token for the thumbnail URL */ + Token?: string | null; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Pending
2UserRegistered
4Deleted
+ * @enum {integer} + */ + ExternalInvitationState: 1 | 2 | 4; + ThumbnailDto: { + ThumbnailID: components["schemas"]["Id"]; + /** @enum {integer} */ + Type: 1 | 2; + Hash: string; + EncryptedSize: number; + }; + PhotoDto: { + CaptureTime: number; + MainPhotoLinkID?: components["schemas"]["Id"] | null; + ContentHash?: string | null; + RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + }; + ShareAccessDto: { + MembershipID: components["schemas"]["Id"]; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; + }; + responses: { + /** @description Plain success response without additional information */ + ProtonSuccessResponse: { + headers: { + /** @description The same as the body code */ + "X-Pm-Code"?: 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + /** @description General Error */ + ProtonErrorResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddPhotosToAlbumRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["AddPhotoToAlbumWithLinkIDResponseDto"][]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The album does not exist. + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-photos-volumes-{volumeID}-albums": { + parameters: { + query?: { + AnchorID?: string | null; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAlbumsResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: a photo share does not exist for this volume + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-photos-volumes-{volumeID}-albums": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAlbumRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAlbumResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: Limit of albums per volume reached + * - 2501: a photo share does not exist for this volume + * */ + Code: number; + }; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes: + * - 2032 + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "post_drive-photos-volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreatePhotoShareRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetPhotoVolumeResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200001: Maximum number of volumes reached for current user + * - 2500: A volume is already active + * - 2500: Cannot create the new Photo volume. Should be migrated from current Photo stream + * - 2001: Invalid PGP message + * - 200501: Operation failed: Please retry + * - 200200: Address not found + * */ + Code: number; + }; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes: + * - 2032 + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "get_drive-photos-volumes-{volumeID}-albums-{linkID}-children": { + parameters: { + query?: { + AnchorID?: components["schemas"]["ListPhotosAlbumQueryParameters"]["AnchorID"]; + Desc?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Desc"]; + OrderedByCaptureTime?: components["schemas"]["ListPhotosAlbumQueryParameters"]["OrderedByCaptureTime"]; + }; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListPhotosAlbumResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: Volume not found + * - 2501: File or folder not found + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-photos-volumes-{volumeID}-albums-{linkID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateAlbumRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: a photo share does not exist for this volume + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes: + * - 2032 + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "post_drive-v2-urls-{token}-bookmark": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateBookmarkShareURLRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateBookmarkShareURLResponseDto"]; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2001: the token format is invalid + * */ + Code: number; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200001: You have reached the maximum number of items you can save. + * - 2501: Item link not found + * - 2500: This item is already saved in your drive + * - 200501: Operation failed: Please retry + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-v2-urls-{token}-bookmark": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2001: the token format is invalid + * */ + Code: number; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: Item link not found + * - 2501: Item not found + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shared-bookmarks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBookmarksOfUserResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: Item link not found + * - 2501: item not found + * - 2501: Invalid Link ID + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-checklist-get-started": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChecklistResponseDto"]; + }; + }; + }; + }; + "post_drive-v2-checklist-get-started-seen-completed-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListDevicesResponseDto"]; + }; + }; + }; + }; + "post_drive-devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateDeviceRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDeviceResponseDto"]; + }; + }; + }; + }; + "put_drive-devices-{deviceID}": { + parameters: { + query?: never; + header?: never; + path: { + deviceID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateDeviceRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "delete_drive-devices-{deviceID}": { + parameters: { + query?: never; + header?: never; + path: { + deviceID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-v2-devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListDevicesResponseDto2"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-documents": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateDocumentDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDocumentResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: the user does not have permissions to create a file in this share + * + * @enum {unknown} + */ + Code: 200300 | 2500 | 2501 | 2011; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "get_drive-shares-{shareID}-events-latest": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LatestEventIDResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-events-{eventID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + eventID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListEventsResponseDto"]; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-events-latest": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LatestEventIDResponseDto"]; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-events-{eventID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + eventID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListEventsResponseDto"]; + }; + }; + }; + }; + "get_drive-urls-{token}-links-{linkID}-path": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ParentEncryptedLinkIDsResponseDto"]; + }; + }; + /** @description Unprocessable entity */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2061: Invalid ID. */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RequestUploadInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestUploadResponse"]; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-links-{linkID}-copy": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CopyLinkRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CopyLinkResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 2011: Copying Proton Docs to another account is not possible yet. + * - 2501: Volume not found + * - 2501: File or folder not found + * - 2501: parent folder was not found + * - 200300: max folder size reached + * - 2011: the user does not have permissions to create a file in this share + * - 2000: the user cannot move or rename root folder + * - 200002: Storage quota exceeded + * - 200301: target parent exceeded max folder depth + * + * @enum {unknown} + */ + Code: 200300 | 2501 | 2011 | 2000 | 200002 | 200301; + }; + }; + }; + }; + }; + "post_drive-shares-{shareID}-folders": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFolderRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateFolderResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-folders-{linkID}-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + }; + }; + "get_drive-shares-{shareID}-folders-{linkID}-children": { + parameters: { + query?: { + /** @description Field to sort by */ + Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; + /** @description Sort order */ + Desc?: 0 | 1; + /** @description Show all files including those in non-active (drafts) state. */ + ShowAll?: 0 | 1; + /** @description Show folders only */ + FoldersOnly?: 0 | 1; + /** + * @deprecated + * @description Get thumbnail download URLs + */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Links */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + /** @description Allow sorting of items in folder */ + AllowSorting: boolean; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-folders-{linkID}-trash_multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + }; + }; + "post_drive-shares-{shareID}-folders-{linkID}-trash_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + }; + }; + "put_drive-shares-{shareID}-folders-{linkID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateFolderRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Link: components["schemas"]["ExtendedLinkTransformer"]; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-folders": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFolderRequestDto2"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateFolderResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-volumes-{volumeID}-folders-{linkID}-children": { + parameters: { + query?: { + /** @description Link ID use to indicate where to start the next page */ + AnchorID?: (string & components["schemas"]["Id"]) | null; + /** @description Show folders only */ + FoldersOnly?: 0 | 1; + }; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListChildrenResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2032: sharing is temporarily disabled and the user is not the volume owner. + * - 2011: The user does not have permission to access this folder. + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailableHashesResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailableHashesResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-links-fetch_metadata": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FetchLinksMetadataRequestDto"]; + }; + }; + responses: { + /** @description Links */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + Parents: components["schemas"]["ExtendedLinkTransformer"][]; + }; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-links-fetch_metadata": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FetchLinksMetadataRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FetchLinksMetadataResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-links-{linkID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Link */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Link: components["schemas"]["ExtendedLinkTransformer"]; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-links": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoadLinkDetailsResponseDto"]; + }; + }; + }; + }; + "put_drive-shares-{shareID}-links-{linkID}-move": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MoveLinkRequestDto"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + /** @description Error message */ + Error?: string; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "put_drive-v2-volumes-{volumeId}-links-{linkID}-rename": { + parameters: { + query?: never; + header?: never; + path: { + volumeId: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameLinkRequestDto"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Conflict, a file or folder with the new name already exists in the current folder. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "put_drive-shares-{shareID}-links-{linkID}-rename": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameLinkRequestDto"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Conflict, a file or folder with the new name already exists in the current folder. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "put_drive-v2-volumes-{volumeID}-links-{linkID}-move": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MoveLinkRequestDto2"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + /** @description Error message */ + Error?: string; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: { + /** @description Block index from which to fetch block list */ + FromBlockIndex?: number; + /** @description Number of blocks */ + PageSize?: number; + /** @description Do not generate download URLs for blocks */ + NoBlockUrls?: 0 | 1; + }; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revision */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Revision: components["schemas"]["DetailedRevisionTransformer"]; + }; + }; + }; + }; + }; + "put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CommitRevisionDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2011: the current user does not have permission to delete the revision + * - 2511: if the revision is active - create or revert to another revision first + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; + }; + }; + }; + }; + "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: { + /** @description Block index from which to fetch block list */ + FromBlockIndex?: number; + /** @description Number of blocks */ + PageSize?: number; + /** @description Do not generate download URLs for blocks */ + NoBlockUrls?: 0 | 1; + }; + header?: never; + path: { + shareID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revision */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Revision: components["schemas"]["DetailedRevisionTransformer"]; + }; + }; + }; + }; + }; + "put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CommitRevisionDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2011: the current user does not have permission to delete the revision + * - 2511: if the revision is active - create or revert to another revision first + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFileDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + File: { + /** @description Encrypted link ID */ + ID: string; + /** @description Encrypted revision ID. */ + RevisionID: string; + ClientUID: string | null; + }; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * - 200901: Photos backup is disabled for your account. Please enable it in the settings. + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-files": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFileDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + File: { + /** @description Encrypted link ID */ + ID: string; + /** @description Encrypted revision ID. */ + RevisionID: string; + ClientUID: string | null; + }; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * - 200901: Photos backup is disabled for your account. Please enable it in the settings. + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revisions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Revisions: components["schemas"]["RevisionTransformer"][]; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateRevisionRequestDto"]; + }; + }; + responses: { + /** @description Revision */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Revision: { + /** @description Revision ID */ + ID: string; + }; + }; + }; + }; + /** @description Conflict, the submitted revision is no longer up to date or another draft is open. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"] | components["schemas"]["ProtonError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200700: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-files-{linkID}-revisions": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revisions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Revisions: components["schemas"]["RevisionTransformer"][]; + }; + }; + }; + }; + }; + "post_drive-shares-{shareID}-files-{linkID}-revisions": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateRevisionRequestDto"]; + }; + }; + responses: { + /** @description Revision */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Revision: { + /** @description Revision ID */ + ID: string; + }; + }; + }; + }; + /** @description Conflict, the submitted revision is no longer up to date or another draft is open. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"] | components["schemas"]["ProtonError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200700: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail": { + parameters: { + query?: { + /** @description Type of Thumbnail to fetch */ + Type?: 1 | 2 | 3; + }; + header?: never; + path: { + shareID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + /** @description Thumbnail download link */ + ThumbnailLink: string; + /** + * @deprecated + * @description Bare Thumbnail download link + */ + ThumbnailBareURL?: string; + /** @description Thumbnail download token */ + ThumbnailToken: string; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revision restore queued for async processing */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RestoreRevisionAcceptedResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revision restore queued for async processing */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RestoreRevisionAcceptedResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerificationData"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerificationData"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-trash-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + }; + }; + "post_drive-shares-{shareID}-trash-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + }; + }; + "get_drive-shares-{shareID}-trash": { + parameters: { + query?: { + /** + * @deprecated + * @description Get thumbnail download URLs + */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + /** @description Dictionary of ancestors of trashed links. */ + Parents: { + [key: string]: components["schemas"]["ExtendedLinkTransformer"]; + }; + }; + }; + }; + }; + }; + "delete_drive-shares-{shareID}-trash": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-trash": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeTrashList"]; + }; + }; + }; + }; + "delete_drive-volumes-{volumeID}-trash": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Empty volume trash queued for async processing */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmptyTrashAcceptedResponse"]; + }; + }; + }; + }; + "put_drive-v2-volumes-{volumeID}-trash-restore_multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: { + /** @description Encrypted link ID */ + LinkID?: string; + Response?: { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }[]; + }; + }; + }; + }; + }; + "put_drive-shares-{shareID}-trash-restore_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: { + /** @description Encrypted link ID */ + LinkID?: string; + Response?: { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }[]; + }; + }; + }; + }; + }; + "get_drive-me-active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Active User */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @enum {boolean} */ + Active?: true; + }; + }; + }; + }; + }; + "post_drive-report-url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AbuseReportDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-v2-onboarding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OnboardingResponseDto"]; + }; + }; + }; + }; + "get_drive-entitlements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetEntitlementResponseDto"]; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-photos": { + parameters: { + query?: { + /** @description Sort order */ + Desc?: 0 | 1; + PageSize?: number; + /** @description The link ID of the last photo from the previous page when requesting secondary pages */ + PreviousPageLastLinkID?: string; + /** @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) */ + MinimumCaptureTime?: number; + }; + header?: never; + path: { + volumeID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PhotoListingResponse"]; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-photos-share": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreatePhotoShareRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreatePhotoShareResponseDto"]; + }; + }; + }; + }; + "delete_drive-volumes-{volumeID}-photos-share-{shareID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-volumes-{volumeID}-photos-duplicates": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FindDuplicatesInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FindDuplicatesOutputCollection"]; + }; + }; + }; + }; + "post_drive-urls-{token}-files-{linkID}-checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailableHashesResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CommitAnonymousRevisionDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions. + * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2511: if the revision not in draft + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-documents": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAnonymousDocumentDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAnonymousDocumentResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * + * @enum {unknown} + */ + Code: 200300 | 2500 | 2501 | 2011; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-files": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAnonymousFileRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAnonymousFileResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * - 200901: Photos backup is disabled for your account. Please enable it in the settings. + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-urls-{token}-folders": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAnonymousFolderRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAnonymousFolderResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-folders-{linkID}-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["DeleteChildrenRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-links-fetch_metadata": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FetchLinksMetadataResponseDto"]; + }; + }; + /** @description Unprocessable entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: This file was not found, token invalid. */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-urls-{token}-links-{linkID}-rename": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameAnonymousLinkRequestDto"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Conflict, a file or folder with the new name already exists in the current folder. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-urls-{token}-blocks": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RequestAnonymousUploadRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestUploadResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerificationData"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-urls": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareURLContextsCollection"]; + }; + }; + }; + }; + "get_drive-v2-shares-my-files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MyFilesResponseDto"]; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-links-{linkID}-context": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetHighestContextForDocumentResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description 2501: Requested data does not exist or you do not have permission to access it + * + * @enum {integer} + */ + Code: 2501; + }; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-shares": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateShareRequestDto"]; + }; + }; + responses: { + /** @description Share */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Share: { + /** @description Share ID */ + ID: string; + }; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link does not exist in the volume + * - 2011: the current user does not have admin permission on this share + * - 2001: the PGP message is not correct + * - 200601: The user has too many shares already. + * */ + Code?: number; + }; + }; + }; + }; + }; + "get_drive-shares": { + parameters: { + query?: { + /** @description Encrypted AddressID */ + AddressID?: string; + /** @description Show disabled shares as well, i.e. Shares where the ShareMemberShip for the user is non-active (locked), otherwise only return with active Membership */ + ShowAll?: 0 | 1; + /** @description Filter on Share Type */ + ShareType?: 1 | 2 | 3 | 4; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSharesResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-owner": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["TransferInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-map": { + parameters: { + query?: { + PageSize?: number; + /** @description SessionName provided by previous response */ + SessionName?: string; + /** @description Index value of last element in previous request. Required only if SessionName is provided */ + LastIndex?: number; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LinkMapResponse"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapShareResponseDto"]; + }; + }; + }; + }; + "delete_drive-shares-{shareID}": { + parameters: { + query?: { + /** @description Forces the deletion of the share along with attached members and urls */ + Force?: 0 | 1; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the current user does not have admin permission on this share + * - 2005: the share still has members, a public URL or invitations attached and Force=1 has not been used */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-migrations-shareaccesswithnode": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateSharesRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MigrateSharesResponseDto"]; + }; + }; + }; + }; + "get_drive-migrations-shareaccesswithnode-unmigrated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnmigratedSharesResponseDto"]; + }; + }; + }; + }; + "get_drive-urls-{token}-info": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Share URL */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + /** @example -----BEGIN PGP SIGNED MESSAGE-----... */ + Modulus: string; + ServerEphemeral: string; + UrlPasswordSalt: string; + SRPSession: string; + /** @example 4 */ + Version: number; + /** @example 2 */ + Flags: number; + }; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-urls-{token}-auth": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AuthShareTokenRequestDto"]; + }; + }; + responses: { + /** @description Share URL */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + /** @description Session UID */ + UID: string; + /** @description Session Access token (present if new session) */ + AccessToken?: string; + /** @description Duration of the session in seconds (present if new session) */ + ExpiresIn?: number; + /** + * @description Type of token (present if new session) + * @example bearer + */ + TokenType?: string; + /** + * @description SRP server proof, base64 encoded. + * @example 00o4YSsW/Z7a0+ak + */ + ServerProof: string; + }; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-urls-{token}": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapShareTokenResponseDto"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-urls-{token}-folders-{linkID}-children": { + parameters: { + query?: { + /** @description Field to sort by */ + Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; + /** @description Sort order */ + Desc?: 0 | 1; + /** @description Show all files including those in non-active (drafts) state. */ + ShowAll?: 0 | 1; + /** @description Show folders only */ + FoldersOnly?: 0 | 1; + /** + * @deprecated + * @description Get thumbnail download URLs + */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Links */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + }; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-urls-{token}-files-{linkID}": { + parameters: { + query?: { + /** @description Block index from which to fetch block list */ + FromBlockIndex?: number; + /** @description Number of blocks */ + PageSize?: number; + /** @description Do not generate download URLs for blocks */ + NoBlockUrls?: 0 | 1; + }; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revision */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Revision: components["schemas"]["DetailedRevisionTransformer"]; + }; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-urls-{token}-file": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["GetSharedFileInfoRequestDto"]; + }; + }; + responses: { + /** @description Share URL */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + /** @description SRP server proof, base64 encoded. */ + ServerProof: string; + Payload: components["schemas"]["ShareURLDownloadTransformer"]; + }; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-urls-{token}-security": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SecurityRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecurityResponseDto"]; + }; + }; + /** @description Code 2028 if feature is disabled, rate-limited or blocked because of abuse. Code 9001 for HV captcha. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-urls": { + parameters: { + query?: { + /** @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). */ + Recursive?: 0 | 1; + /** @description Fetch Thumbnail URLs */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListShareURLsResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-urls": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateShareURLRequestDto"]; + }; + }; + responses: { + /** @description Share URL created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + ShareURL: components["schemas"]["ShareURLResponseDto"]; + }; + }; + }; + }; + }; + "put_drive-shares-{shareID}-urls-{urlID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + urlID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateShareURLRequestDto"]; + }; + }; + responses: { + /** @description Share URL updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + ShareURL: components["schemas"]["ShareURLResponseDto"]; + }; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "delete_drive-shares-{shareID}-urls-{urlID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + urlID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-urls-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["DeleteMultipleShareURLsRequestDto"]; + }; + }; + responses: { + /** @description Responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: { + ShareURLID: string; + Response: { + /** @enum {integer} */ + Code: 1000 | 2501; + Error?: string; + }; + }[]; + }; + }; + }; + }; + }; + "get_drive-v2-volumes-{volumeID}-shares": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SharedByMeResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-sharedwithme": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SharedWithMeResponseDto"]; + }; + }; + }; + }; + "put_drive-v2-shares-{shareID}-external-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateExternalInvitationRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist, is not pending or accepted + * - 2011: the current user does not have admin permission on this share + * - 2026: trying to grant permissions you do not have to a new member + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the external invitation does not exist, is not pending or accepted + * - 2011: the current user does not have admin permission on this share + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-{shareID}-external-invitations": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListShareExternalInvitationsResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the share does not exist + * - 2011: the current user does not have admin permission on this share */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-external-invitations": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["InviteExternalUserRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InviteExternalUserResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the current user does not have admin permission on this share + * - 2500: an external invitation for this user on this share already exists + * - 2026: trying to grant permissions you do not have to a new member + * - 2001: the inviter address does not belong to a Proton account or does not belong to the current user + * - 2008: inviter email is not the same as the one from the context share */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-external-invitations": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + PageSize?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListUserRegisteredExternalInvitationResponseDto"]; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the external invitation does not exist, is not pending or rejected + * - 2011: the current user does not have admin permission on this share + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-invitations-{invitationID}-accept": { + parameters: { + query?: never; + header?: never; + path: { + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AcceptInvitationRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the share or the invitation was not found or was not pending + * - 2011: the invitee email doesn't belong to the current user + * - 2032: sharing is temporarily disabled. + * - 200602: The user has joined too many shares already. + * - 200201: the user is already member of a share in this volume with another address + * - 2000: the address or address key couldn't be found to the invitee email address and user + * */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-v2-shares-{shareID}-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateInvitationRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist, is not pending or rejected + * - 2011: the current user does not have admin permission on this share + * - 2026: trying to grant permissions you do not have to a new member + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-v2-shares-{shareID}-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist, is not pending or rejected + * - 2011: the current user does not have admin permission on this share + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-{shareID}-invitations": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListShareInvitationsResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the share does not exist + * - 2011: the current user does not have admin permission on this share */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-invitations": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["InviteUserRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InviteUserResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the external invitation does not exists or is still pending + * - 2011: the current user does not have admin permission on this share + * - 2500: an invitation for this user on this share already exists + * - 2001: invitee email doesn't belong to a Proton account or you try to invite yourself + * - 2008: inviter email is not the same as the one from the context share + * - 2032: sharing is temporarily disabled. + * - 2026: trying to grant permissions you do not have to a new member + * - 200501: key packet is invalid + * - 200502: key packet signature is invalid + * - 200600: maximum number of invitations and members reached for current share + * - 200202: Sharing with groups is not available yet. + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-invitations": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + PageSize?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListPendingInvitationResponseDto"]; + }; + }; + }; + }; + "post_drive-v2-shares-invitations-{invitationID}-reject": { + parameters: { + query?: never; + header?: never; + path: { + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist or is not pending + * - 2011: the invitee email doesn't belong to the current user + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist, is not pending or rejected + * - 2011: the current user does not have admin permission on this share + * - 2032: sharing is temporarily disabled. + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PendingInvitationResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist or is not pending, or the link/share/volume for it is gone + * - 2011: the invitee email doesn't belong to the current user + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-user-link-access": { + parameters: { + query?: { + LinkID?: components["schemas"]["Id"]; + VolumeID?: components["schemas"]["Id"] | null; + ShareID?: components["schemas"]["Id"] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LinkAccessesResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-shares-{shareID}-members": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListShareMembersResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the share does not exist + * - 2011: the current user does not have admin permission on this share */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-v2-shares-{shareID}-members-{memberID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + memberID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateShareMemberRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the member does not exist or is removed. + * - 2011: the current user does not have admin permission on this share + * - 2026: trying to grant permissions you do not have to a member + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-v2-shares-{shareID}-members-{memberID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + memberID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the user does not have enough permission to remove another member + * - 2501: the user is not a member of the share + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-security": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SecurityRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecurityResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the current user does not have read permission on this share */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-thumbnails": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ThumbnailIDsListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListThumbnailsResponse"]; + }; + }; + }; + }; + "get_drive-me-settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingsResponse"]; + }; + }; + }; + }; + "put_drive-me-settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UserSettingsRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingsResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200900: Photos cannot be disabled. There is data in your Photos section. + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-volumes": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListVolumesResponseDto"]; + }; + }; + }; + }; + "post_drive-volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateVolumeRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetVolumeResponseDto"]; + }; + }; + }; + }; + "put_drive-volumes-{volumeID}-delete_locked": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-volumes-{volumeID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetVolumeResponseDto"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "put_drive-volumes-{volumeID}-restore": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RestoreVolumeDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; +} diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts new file mode 100644 index 00000000..4b122669 --- /dev/null +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -0,0 +1,4 @@ +export const enum ErrorCode { + OK = 1000, + NOT_EXISTS = 2501, +} diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts new file mode 100644 index 00000000..55a5afd6 --- /dev/null +++ b/js/sdk/src/internal/apiService/errors.ts @@ -0,0 +1,38 @@ +import { ErrorCode } from './errorCodes'; + +export function apiErrorFactory({ response, result }: { response: Response, result?: any }): APIError { + if (!result) { + return new APIHTTPError(response.statusText, response.status); + } + + const code = result.Code; + const message = result.Error; + switch (code) { + case ErrorCode.NOT_EXISTS: + return new NotFoundAPIError(message, code); + default: + return new APICodeError(message, code); + } +} + +export class APIError extends Error {} + +export class APIHTTPError extends APIError { + public statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + +export class APICodeError extends APIError { + public code: number; + + constructor(message: string, code: number) { + super(message); + this.code = code; + } +} + +export class NotFoundAPIError extends APICodeError {} diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts new file mode 100644 index 00000000..01acc82a --- /dev/null +++ b/js/sdk/src/internal/apiService/index.ts @@ -0,0 +1,3 @@ +export { DriveAPIService, getApiService } from './apiService.js'; +export { paths as drivePaths } from './driveTypes.js'; +export * from './errors'; diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts new file mode 100644 index 00000000..2718c245 --- /dev/null +++ b/js/sdk/src/internal/events/index.ts @@ -0,0 +1,38 @@ +import { DriveAPIService } from "../apiService/index.js"; + +export interface DriveEventsService { + subscribeToRemoteDataUpdates: () => void, + registerHandler: (callback: (event: DriveEvent) => Promise) => void, + lastUsedVolume: (volumeId: string) => void, +}; + +// TODO: implement event handling, generic for both core+volume events +export function events(apiService: DriveAPIService): DriveEventsService { + return { + // TODO: exposed to public, starts listening to core+volume events + // TODO: core should listen only to minimum events possible + // TODO: volume should listen: own always, others with limitations as per RFC + subscribeToRemoteDataUpdates: () => {}, + + // TODO: internal only, other modules can react to events + // TODO: events module will wait for event to be processed - if its failing, it will not move forward + registerHandler: (callback: (event: DriveEvent) => Promise) => {}, + // TODO: helper that other modules can help say what volume is more important + lastUsedVolume: (volumeId: string) => {}, + } +} + +export type DriveEvent = { + type: 'node_created' | 'node_updated' | 'node_updated_metadata', + nodeUid: string, + parentNodeUid: string, + // TODO: needs RFC how we can pass it from events system efficiently without computing whole object + isTrashed: boolean, + isShared: boolean, +} | { + type: 'node_deleted', + nodeUid: string, + parentNodeUid: string, +} | { + type: 'share_with_me_updated', +} diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts new file mode 100644 index 00000000..9d2f1087 --- /dev/null +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -0,0 +1,210 @@ +import { MemberRole, NodeType } from "../../interface"; +import { DriveAPIService } from "../apiService"; +import { nodeAPIService } from './apiService'; + +function generateAPIFileNode(linkOverrides = {}, overrides = {}) { + const node = generateAPINode(); + return { + Link: { + ...node.Link, + Type: 1, + MIMEType: 'text', + ...linkOverrides, + }, + File: { + ContentKeyPacket: 'contentKeyPacket', + ContentKeyPacketSignature: 'contentKeyPacketSig', + }, + ActiveRevision: { + RevisionID: 'revisionId', + XAttr: '{}', + }, + ...overrides, + }; +} + +function generateAPIFolderNode(linkOverrides = {}, overrides = {}) { + const node = generateAPINode(); + return { + Link: { + ...node.Link, + Type: 2, + MIMEType: 'Folder', + ...linkOverrides, + }, + Folder: { + XAttr: '{}', + NodeHashKey: 'nodeHashKey', + }, + ...overrides, + }; +} + +function generateAPINode() { + return { + Link: { + LinkID: 'linkId', + ParentLinkID: 'parentLinkId', + NameHash: 'nameHash', + CreateTime: 123456789, + TrashTime: 0, + + Name: 'encName', + SignatureEmail: 'sigEmail', + NameSignatureEmail: 'nameSigEmail', + NodeKey: 'nodeKey', + NodePassphrase: 'nodePass', + NodePassphraseSignature: 'nodePassSig', + }, + SharingSummary: null, + }; +} + +function generateFileNode(overrides = {}) { + const node = generateNode(); + return { + ...node, + type: NodeType.File, + mimeType: "text", + encryptedCrypto: { + ...node.encryptedCrypto, + file: { + base64ContentKeyPacket: "contentKeyPacket", + armoredContentKeyPacketSignature: "contentKeyPacketSig", + }, + activeRevision: { + id: "revisionId", + encryptedExtendedAttributes: "{}", + }, + }, + ...overrides + } +} + +function generateFolderNode(overrides = {}) { + const node = generateNode(); + return { + ...node, + type: NodeType.Folder, + mimeType: "Folder", + encryptedCrypto: { + ...node.encryptedCrypto, + folder: { + armoredHashKey: "nodeHashKey", + encryptedExtendedAttributes: "{}", + }, + }, + ...overrides + } +} + +function generateNode() { + return { + volumeId: "volumeId", + hash: "nameHash", + + uid: "volume:volumeId;node:linkId", + parentUid: "volume:volumeId;node:parentLinkId", + createdDate: new Date(123456789), + trashedDate: undefined, + + shareId: undefined, + isShared: false, + directMemberRole: MemberRole.Viewer, + + encryptedCrypto: { + armoredKey: "nodeKey", + armoredNodePassphrase: "nodePass", + armoredNodePassphraseSignature: "nodePassSig", + encryptedName: "encName", + nameSignatureEmail: "nameSigEmail", + signatureEmail: "sigEmail", + }, + } +} + +describe("nodeAPIService", () => { + let apiMock: DriveAPIService; + let api: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + + apiMock = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + }; + + api = nodeAPIService(apiMock); + }); + + describe('getNodes', () => { + async function testGetNodes(mockedLink: any, expectedNode: any) { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async () => Promise.resolve({ + Links: [mockedLink], + })); + + const nodes = await api.getNodes(['volume:volumeId;node:nodeId']); + expect(nodes).toEqual([expectedNode]); + } + + it('should get folder node', async () => { + await testGetNodes( + generateAPIFolderNode(), + generateFolderNode(), + ); + }); + + it('should get root folder node', async () => { + await testGetNodes( + generateAPIFolderNode({ ParentLinkID: null }), + generateFolderNode({ parentUid: undefined }), + ); + }); + + it('should get file node', async () => { + await testGetNodes( + generateAPIFileNode(), + generateFileNode(), + ); + }); + + it('should get shared node', async () => { + await testGetNodes( + generateAPIFolderNode({}, { + SharingSummary: { + ShareID: 'shareId', + ShareAccess: { + Permissions: 22, + }, + } + }), + generateFolderNode({ + isShared: true, + shareId: 'shareId', + directMemberRole: MemberRole.Admin, + }), + ); + }); + + it('should get shared node with unknown permissions', async () => { + await testGetNodes( + generateAPIFolderNode({}, { + SharingSummary: { + ShareID: 'shareId', + ShareAccess: { + Permissions: 42, + }, + } + }), + generateFolderNode({ + isShared: true, + shareId: 'shareId', + directMemberRole: MemberRole.Viewer, + }), + ); + }); + }); +}); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts new file mode 100644 index 00000000..6cb9758d --- /dev/null +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -0,0 +1,380 @@ +import { Logger, NodeType, MemberRole } from "../../interface"; +import { DriveAPIService, drivePaths } from "../apiService"; +import { splitNodeUid, makeNodeUid } from "./nodeUid"; +import { EncryptedNode } from "./interface"; + +type PostLoadLinksMetadataRequest = Extract['content']['application/json']; +type PostLoadLinksMetadataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; + +type GetChildrenResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders/{linkID}/children']['get']['responses']['200']['content']['application/json']; + +type GetTrashedNodesResponse = drivePaths['/drive/volumes/{volumeID}/trash']['get']['responses']['200']['content']['application/json']; + +type PutRenameNodeRequest = Extract['content']['application/json']; +type PutRenameNodeResponse = drivePaths['/drive/v2/volumes/{volumeId}/links/{linkID}/rename']['put']['responses']['200']['content']['application/json']; + +type PutMoveNodeRequest = Extract['content']['application/json']; +type PutMoveNodeResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/move']['put']['responses']['200']['content']['application/json']; + +type PostTrashNodesRequest = Extract['content']['application/json']; +type PostTrashNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders/{linkID}/trash_multiple']['post']['responses']['200']['content']['application/json']; + +type PutRestoreNodesRequest = Extract['content']['application/json']; +type PutRestoreNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/restore_multiple']['put']['responses']['200']['content']['application/json']; + +type PostDeleteNodesRequest = Extract['content']['application/json']; +type PostDeleteNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['responses']['200']['content']['application/json']; + +type PostCreateFolderRequest = Extract['content']['application/json']; +type PostCreateFolderResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['responses']['200']['content']['application/json']; + +/** + * Provides API communication for fetching and manipulating nodes metadata. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ +export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { + async function getNode(nodeUid: string, signal?: AbortSignal): Promise { + const nodes = await getNodes([nodeUid], signal); + return nodes[0]; + } + + // Improvement requested: support multiple volumes. + async function getNodes(nodeUids: string[], signal?: AbortSignal): Promise { + const nodeIds = nodeUids.map(splitNodeUid); + const volumeId = assertAndGetSingleVolumeId("getNodes", nodeIds); + + const response = await apiService.post(`drive/volumes/${volumeId}/links`, { + LinkIDs: nodeIds.map(({ nodeId }) => nodeId), + }, signal); + + const nodes = response.Links.map((link) => { + const baseNodeMetadata = { + // Internal metadata + volumeId, + hash: link.Link.NameHash || undefined, + + // Basic node metadata + uid: makeNodeUid(volumeId, link.Link.LinkID), + parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, + type: link.Link.Type === 1 ? NodeType.File : NodeType.Folder, + mimeType: link.Link.MIMEType || undefined, + createdDate: new Date(link.Link.CreateTime), + trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime) : undefined, + + // Sharing node metadata + shareId: link.SharingSummary?.ShareID || undefined, + isShared: !!link.SharingSummary, + directMemberRole: sharingSummaryToDirectMemberRole(link.SharingSummary, logger), + } + const baseCryptoNodeMetadata = { + encryptedName: link.Link.Name, + signatureEmail: link.Link.SignatureEmail || undefined, + nameSignatureEmail: link.Link.NameSignatureEmail || undefined, + armoredKey: link.Link.NodeKey, + armoredNodePassphrase: link.Link.NodePassphrase, + armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, + } + + if (link.Link.Type === 1 && link.File && link.ActiveRevision) { + return { + ...baseNodeMetadata, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + file: { + base64ContentKeyPacket: link.File.ContentKeyPacket, + armoredContentKeyPacketSignature: link.File.ContentKeyPacketSignature || undefined, + }, + activeRevision: { + id: link.ActiveRevision.RevisionID, + encryptedExtendedAttributes: link.ActiveRevision.XAttr || undefined, + }, + }, + } + } + if (link.Link.Type === 2 && link.Folder) { + return { + ...baseNodeMetadata, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + folder: { + encryptedExtendedAttributes: link.Folder.XAttr || undefined, + armoredHashKey: link.Folder.NodeHashKey as string, + }, + }, + } + } + throw new Error(`Unknown node type: ${link.Link.Type}`); + }); + return nodes; + } + + // Improvement requested: load next page sooner before all IDs are yielded. + async function* iterateChildrenNodeUids(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + const { volumeId, nodeId } = splitNodeUid(parentNodeUid); + + let anchor = ""; + while (true) { + const response = await apiService.get(`drive/volumes/${volumeId}/folders/${nodeId}/children?AnchorID=${anchor}`, signal); + for (const linkID of response.LinkIDs) { + yield makeNodeUid(volumeId, linkID); + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } + } + + // Improvement requested: load next page sooner before all IDs are yielded. + async function* iterateTrashedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { + let page = 0; + while (true) { + const response = await apiService.get(`drive/volumes/${volumeId}/trash?Page=${page}`, signal); + + // The API returns items per shares which is not straightforward to + // count if there is another page. We had mistakes in the past, thus + // we rather end when the page is fully empty. + // The new API endpoint should not split per shares anymore and adopt + // the new pagination model with More/Anchor. For now, this is not + // the most efficient way, but should be with us only for a short time. + let hasItems = false; + + for (const linksPerShare of response.Trash) { + for (const linkId of linksPerShare.LinkIDs) { + yield makeNodeUid(volumeId, linkId); + hasItems = true; + } + } + + if (!hasItems) { + break; + } + page++; + } + } + + async function renameNode( + nodeUid: string, + originalNode: { + hash: string, + }, + newNode: { + encryptedName: string, + nameSignatureEmail: string, + hash: string, + }, + signal?: AbortSignal, + ): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + + await apiService.put< + Omit, + PutRenameNodeResponse + >(`drive/v2/volumes/${volumeId}/links/${nodeId}/rename`, { + Name: newNode.encryptedName, + NameSignatureEmail: newNode.nameSignatureEmail, + Hash: newNode.hash, + OriginalHash: originalNode.hash, + }, signal); + } + + async function moveNode( + nodeUid: string, + oldNode: { + hash: string, + }, + newNode: { + parentUid: string, + armoredNodePassphrase: string, + armoredNodePassphraseSignature?: string, + signatureEmail: string, + encryptedName: string, + nameSignatureEmail: string, + hash: string, + contentHash?: string, + }, + signal?: AbortSignal, + ): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const { nodeId: newParentNodeId } = splitNodeUid(newNode.parentUid); + + await apiService.put< + Omit, + PutMoveNodeResponse + >(`/drive/v2/volumes/${volumeId}/links/${nodeId}/move`, { + ParentLinkID: newParentNodeId, + NodePassphrase: newNode.armoredNodePassphrase, + NodePassphraseSignature: newNode.armoredNodePassphraseSignature || null, + SignatureEmail: newNode.signatureEmail, + Name: newNode.encryptedName, + NameSignatureEmail: newNode.nameSignatureEmail, + Hash: newNode.hash, + OriginalHash: oldNode.hash, + ContentHash: newNode.contentHash || null, + }, signal); + } + + // Improvement requested: API without requiring parent node (to delete any nodes). + // Improvement requested: split into multiple calls for many nodes. + async function trashNodes(parentNodeUid: string, nodeUids: string[], signal?: AbortSignal): Promise { + const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); + + const nodeIds = nodeUids.map(splitNodeUid); + + const response = await apiService.post< + PostTrashNodesRequest, + PostTrashNodesResponse + >(`/drive/v2/volumes/${volumeId}/folders/${parentNodeId}/trash_multiple`, { + LinkIDs: nodeIds.map(({ nodeId }) => nodeId), + }, signal); + + // TODO: remove `as` when backend fixes OpenAPI schema. + handleResponseErrors(volumeId, response.Responses as LinkResponse[]); + } + + // Improvement requested: split into multiple calls for many nodes. + async function restoreNodes(nodeUids: string[], signal?: AbortSignal): Promise { + const nodeIds = nodeUids.map(splitNodeUid); + const volumeId = assertAndGetSingleVolumeId("restoreNodes", nodeIds); + + const response = await apiService.put< + PutRestoreNodesRequest, + PutRestoreNodesResponse + >(`/drive/v2/volumes/${volumeId}/trash/restore_multiple`, { + LinkIDs: nodeIds.map(({ nodeId }) => nodeId), + }, signal); + + // TODO: remove `as` when backend fixes OpenAPI schema. + handleResponseErrors(volumeId, response.Responses as LinkResponse[]); + } + + // Improvement requested: split into multiple calls for many nodes. + async function deleteNodes(nodeUids: string[], signal?: AbortSignal): Promise { + const nodeIds = nodeUids.map(splitNodeUid); + const volumeId = assertAndGetSingleVolumeId("restoreNodes", nodeIds); + + const response = await apiService.post< + PostDeleteNodesRequest, + PostDeleteNodesResponse + >(`/drive/v2/volumes/${volumeId}/trash/delete_multiple`, { + LinkIDs: nodeIds.map(({ nodeId }) => nodeId), + }, signal); + + // TODO: remove `as` when backend fixes OpenAPI schema. + handleResponseErrors(volumeId, response.Responses as LinkResponse[]); + } + + async function createFolder( + parentUid: string, + newNode: { + armoredKey: string, + armoredHashKey: string, + armoredNodePassphrase: string, + armoredNodePassphraseSignature: string, + signatureEmail: string, + encryptedName: string, + hash: string, + encryptedExtendedAttributes: string, + }, + signal?: AbortSignal, + ): Promise { + const { volumeId, nodeId: parentId } = splitNodeUid(parentUid); + + const response = await apiService.post< + PostCreateFolderRequest, + PostCreateFolderResponse + >(`/drive/v2/volumes/${volumeId}/folders`, { + ParentLinkID: parentId, + NodeKey: newNode.armoredKey, + NodeHashKey: newNode.armoredHashKey, + NodePassphrase: newNode.armoredNodePassphrase, + NodePassphraseSignature: newNode.armoredNodePassphraseSignature, + SignatureEmail: newNode.signatureEmail, + Name: newNode.encryptedName, + Hash: newNode.hash, + XAttr: newNode.encryptedExtendedAttributes, + }, signal); + + return response.Folder.ID; + } + + return { + getNode, + getNodes, + iterateChildrenNodeUids, + iterateTrashedNodeUids, + renameNode, + moveNode, + trashNodes, + restoreNodes, + deleteNodes, + createFolder, + } +} + +function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { volumeId: string }[]): string { + const uniqueVolumeIds = new Set(nodeIds.map(({ volumeId }) => volumeId)); + if (uniqueVolumeIds.size !== 1) { + throw new Error(`${operationForErrorMessage} does not support multiple volumes`); + } + const volumeId = nodeIds[0].volumeId; + return volumeId; +} + +function sharingSummaryToDirectMemberRole(sharingSummary: PostLoadLinksMetadataResponse['Links'][0]['SharingSummary'], logger?: Logger): MemberRole { + switch (sharingSummary?.ShareAccess.Permissions) { + case 4: + return MemberRole.Viewer; + case 6: + return MemberRole.Editor; + case 22: + return MemberRole.Admin; + default: + // User have access to the data, thus at minimum it can view. + logger?.warn(`Unknown sharing permissions: ${sharingSummary?.ShareAccess.Permissions}`); + return MemberRole.Viewer; + } +} + +type LinkResponse = { + LinkID: string, + Response: { + Error?: string + } +}; + +export type NodeErrors = { [ nodeUid: string ]: string }; + +export class ResultErrors extends Error { + nodeErrors: NodeErrors; + + constructor(nodeErrors: NodeErrors) { + super("Some nodes failed to process"); + this.nodeErrors = nodeErrors; + } + + get failingNodeUids(): string[] { + return Object.keys(this.nodeErrors); + } +} + +function handleResponseErrors(volumeId: string, responses?: LinkResponse[]) { + if (!responses) { + return; + } + + const errors: NodeErrors = {}; + + responses.map((response) => { + if (response.Response.Error) { + errors[makeNodeUid(volumeId, response.LinkID)] = response.Response.Error as string; + } + }); + + if (Object.keys(errors).length > 0) { + throw new ResultErrors(errors); + } +} diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts new file mode 100644 index 00000000..39778394 --- /dev/null +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -0,0 +1,168 @@ +import { MemoryCache } from "../../cache"; +import { NodeType, MemberRole } from "../../interface"; +import { CACHE_TAG_KEYS, nodesCache } from "./cache"; +import { DecryptedNode } from "./interface"; + +function generateNode(uid: string, parentUid='root', params: Partial = {}): DecryptedNode { + return { + uid, + parentUid, + directMemberRole: MemberRole.Admin, + type: NodeType.File, + mimeType: "text", + isShared: false, + createdDate: new Date(), + trashedDate: null, + volumeId: "volumeId", + ...params, + } as DecryptedNode; +} + +async function generateTreeStructure(cache: ReturnType) { + for (const node of [ + generateNode('node1', 'root'), + generateNode('node1a', 'node1'), + generateNode('node1b', 'node1', { trashedDate: new Date() }), + generateNode('node1c', 'node1'), + generateNode('node1c-alpha', 'node1c'), + generateNode('node1c-beta', 'node1c', { trashedDate: new Date() }), + + generateNode('node2', 'root'), + generateNode('node2a', 'node2'), + generateNode('node2b', 'node2', { trashedDate: new Date() }), + + generateNode('node3', 'root'), + ]) { + await cache.setNode(node); + } +} + +async function verifyNodesCache(cache: ReturnType, expectedNodes: string[], expectedMissingNodes: string[]) { + for (const nodeUid of expectedNodes) { + try { + await cache.getNode(nodeUid); + } catch (error) { + throw new Error(`${nodeUid} should be in the cache`); + } + } + + for (const nodeUid of expectedMissingNodes) { + try { + await cache.getNode(nodeUid); + throw new Error(`${nodeUid} should not be in the cache`); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + } +} + +describe('nodesCache', () => { + let memoryCache: MemoryCache; + let cache: ReturnType; + + beforeEach(() => { + memoryCache = new MemoryCache([CACHE_TAG_KEYS.ParentUid, CACHE_TAG_KEYS.Trashed]); + memoryCache.setEntity('node-root', JSON.stringify(generateNode('root', ''))); + memoryCache.setEntity('node-badObject', 'aaa', { [CACHE_TAG_KEYS.ParentUid]: 'root' }); + + cache = nodesCache(memoryCache); + }); + + it('should store and retrieve node', async () => { + const node = generateNode('node1', ''); + + await cache.setNode(node); + const result = await cache.getNode(node.uid); + + expect(result).toStrictEqual(node); + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + try { + await cache.getNode('nonExistingNodeUid'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a corrupted node and remove the node from the cache', async () => { + try { + await cache.getNode('badObject'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Failed to deserialise node: Unexpected token \'a\', \"aaa\" is not valid JSON'); + } + + try { + await memoryCache.getEntity('nodes-badObject'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should remove node without children', async () => { + await generateTreeStructure(cache); + await cache.removeNodes(['node3']); + await verifyNodesCache( + cache, + ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b'], + ['node3'], + ) + }); + + it('should remove node and its children', async () => { + await generateTreeStructure(cache); + await cache.removeNodes(['node2']); + await verifyNodesCache( + cache, + ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node3'], + ['node2', 'node2a', 'node2b',], + ) + }); + + it('should remove node and its children recursively', async () => { + await generateTreeStructure(cache); + await cache.removeNodes(['node1']); + await verifyNodesCache( + cache, + ['node2', 'node2a', 'node2b', 'node3'], + ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta'], + ); + }); + + it('should iterate requested nodes', async () => { + await generateTreeStructure(cache); + const result = await Array.fromAsync(cache.iterateNodes(['node1', 'node2'])); + const nodeUids = result.map(({ uid }) => uid); + expect(nodeUids).toStrictEqual(['node1', 'node2']); + }); + + it('should iterate children', async () => { + await generateTreeStructure(cache); + const result = await Array.fromAsync(cache.iterateChildren('node1')); + const nodeUids = result.map(({ uid }) => uid); + expect(nodeUids).toStrictEqual(['node1a', 'node1b', 'node1c']); + }); + + it('should iterate children and silently remove a corrupted node', async () => { + await generateTreeStructure(cache); + // badObject has root as parent. + const result = await Array.fromAsync(cache.iterateChildren('root')); + const nodeUids = result.map(({ uid }) => uid); + expect(nodeUids).toStrictEqual(['node1', 'node2', 'node3']); + await verifyNodesCache( + cache, + ['root', 'node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b', 'node3'], + ['badObject'], + ) + }); + + it('should iterate trashed nodes', async () => { + await generateTreeStructure(cache); + const result = await Array.fromAsync(cache.iterateTrashedNodes()); + const nodeUids = result.map(({ uid }) => uid); + expect(nodeUids).toStrictEqual(['node1b', 'node1c-beta', 'node2b']); + }); +}); \ No newline at end of file diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts new file mode 100644 index 00000000..cc287cbc --- /dev/null +++ b/js/sdk/src/internal/nodes/cache.ts @@ -0,0 +1,206 @@ +import { ProtonDriveCache, EntityResult } from "../../cache"; +import { Logger } from "../../interface"; +import { DecryptedNode } from "./interface.js"; + +export enum CACHE_TAG_KEYS { + ParentUid = 'parentUid', + Trashed = 'trashed', +} + +/** + * Provides caching for nodes metadata. + * + * The cache is responsible for serialising and deserialising node metadata, + * recording parent-child relationships, and recursively removing nodes. + * + * The cache of node metadata should not contain any crypto material. + */ +export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { + async function setNode(node: DecryptedNode) { + const key = getCacheUid(node.uid); + const nodeData = serialiseNode(node); + + const tags: { [ key: string ]: string } = {}; + if (node.parentUid) { + tags[CACHE_TAG_KEYS.ParentUid] = node.parentUid; + } + if (node.trashedDate) { + tags[CACHE_TAG_KEYS.Trashed] = 'true'; + } + + await driveCache.setEntity(key, nodeData, tags); + } + + async function getNode(nodeUid: string): Promise { + const key = getCacheUid(nodeUid); + const nodeData = await driveCache.getEntity(key); + try { + return deserialiseNode(nodeData); + } catch (error: any) { + removeCorruptedNode({ nodeUid }, error); + throw new Error(`Failed to deserialise node: ${error.message}`) + } + } + + /** + * Remove corrupted node never throws, but it logs so we can know + * about issues and fix them. It is crucial to remove corrupted + * nodes and rather let SDK re-fetch them than to auotmatically + * fix issues and do not bother user with it. + */ + async function removeCorruptedNode({ nodeUid, cacheUid }: { nodeUid?: string, cacheUid?: string }, corruptionError: any) { + logger?.error(`Removing corrupted nodes from the cache: ${corruptionError.message}`); + try { + if (nodeUid) { + await removeNodes([nodeUid]); + } else if (cacheUid) { + await driveCache.removeEntities([cacheUid]); + } + } catch (removingError: any) { + // The node will not be returned, thus SDK will re-fetch + // and re-cache it. Setting it again should then fix the + // problem. + logger?.warn(`Failed to remove corrupted node from the cache: ${removingError.message}`); + } + } + + async function removeNodes(nodeUids: string[]) { + const cacheUids = nodeUids.map(getCacheUid); + await driveCache.removeEntities(cacheUids); + for (const nodeUid of nodeUids) { + try { + const childrenCacheUids = await getRecursiveChildrenCacheUids(nodeUid); + // Reverse the order to remove children first. + // Crucial to not leave any children without parent + // if removing nodes fails. + childrenCacheUids.reverse(); + await driveCache.removeEntities(childrenCacheUids); + } catch (error: any) { + // TODO: Should we throw here to the client? + logger?.error(`Failed to remove children from the cache: ${error.message}`); + } + } + } + + async function getRecursiveChildrenCacheUids(parentNodeUid: string): Promise { + const cacheUids = []; + for await (const result of driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.ParentUid, parentNodeUid)) { + cacheUids.push(result.uid); + const childrenCacheUids = await getRecursiveChildrenCacheUids(getNodeUid(result.uid)); + cacheUids.push(...childrenCacheUids); + } + return cacheUids; + } + + async function *iterateNodes(nodeUids: string[]) { + const cacheUids = nodeUids.map(getCacheUid); + for await (const result of driveCache.iterateEntities(cacheUids)) { + const node = await convertCacheResult(result); + if (node) { + yield node; + } + } + } + + async function *iterateChildren(parentNodeUid: string) { + for await (const result of driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.ParentUid, parentNodeUid)) { + const node = await convertCacheResult(result); + if (node) { + yield node; + } + } + } + + async function *iterateTrashedNodes() { + for await (const result of driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.Trashed, 'true')) { + const node = await convertCacheResult(result); + if (node) { + yield node; + } + } + } + + /** + * Converts result from the cache with cache UID and data to result of node + * with node UID and DecryptedNode. + */ + async function convertCacheResult(result: EntityResult): Promise<( + {uid: string, ok: true, node: DecryptedNode} | + {uid: string, ok: false, error: string} | + null + )> { + let nodeUid; + try { + nodeUid = getNodeUid(result.uid); + } catch (error: any) { + await removeCorruptedNode({ cacheUid: result.uid }, error) + return null; + } + if (result.ok) { + let node; + try { + node = deserialiseNode(result.data) + } catch (error: any) { + await removeCorruptedNode({ nodeUid }, error); + return null; + } + return { + uid: nodeUid, + ok: true, + node, + } + } else { + return { + ...result, + uid: nodeUid, + }; + } + } + + function getCacheUid(nodeUid: string) { + return `node-${nodeUid}`; + } + + function getNodeUid(cacheUid: string) { + if (!cacheUid.startsWith('node-')) { + throw new Error('Unexpected cached node uid'); + } + return cacheUid.substring(5); + } + + function serialiseNode(node: DecryptedNode) { + return JSON.stringify(node); + } + + function deserialiseNode(nodeData: string): DecryptedNode { + const node = JSON.parse(nodeData); + if ( + !node || typeof node !== 'object' || + !node.uid || typeof node.uid !== 'string' || + typeof node.parentUid !== 'string' || + !node.directMemberRole || typeof node.directMemberRole !== 'string' || + !node.type || typeof node.type !== 'string' || + !node.mimeType || typeof node.mimeType !== 'string' || + typeof node.isShared !== 'boolean' || + !node.createdDate || typeof node.createdDate !== 'string' || + (typeof node.trashedDate !== 'string' && node.trashedDate !== null) || + !node.volumeId || typeof node.volumeId !== 'string' + ) { + throw new Error(`Invalid node data: ${nodeData}`); + } + return { + ...node, + createdDate: new Date(node.createdDate), + trashedDate: node.trashedDate ? new Date(node.trashedDate) : null, + }; + } + + return { + setNode, + getNode, + removeNodes, + iterateNodes, + iterateChildren, + iterateTrashedNodes, + } +} diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts new file mode 100644 index 00000000..46191692 --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -0,0 +1,116 @@ +import { PrivateKey, SessionKey } from "../../crypto"; +import { MemoryCache } from "../../cache"; +import { nodesCryptoCache } from "./cryptoCache"; + +jest.mock('../../crypto/openPGPSerialisation', () => ({ + serializePrivateKey: jest.fn((value) => value), + deserializePrivateKey: jest.fn((value) => value), + serializeSessionKey: jest.fn((value) => value), + deserializeSessionKey: jest.fn((value) => { + if (value === 'badSessionKey') { + throw new Error('Bad session key'); + } + return value; + }), +})); + +describe('nodesCryptoCache', () => { + let memoryCache: MemoryCache; + let cache: ReturnType; + + const generatePrivateKey = (name: string) => { + return name as unknown as PrivateKey + } + + const generateSessionKey = (name: string) => { + return name as unknown as SessionKey + } + + beforeEach(() => { + memoryCache = new MemoryCache([]); + memoryCache.setEntity('nodeKeys-badKeysObject', 'aaa'); + memoryCache.setEntity('nodeKeys-badSessionKey', '{ "passphrase": "pass", "key": "aaa", "sessionKey": "badSessionKey" }'); + + cache = nodesCryptoCache(memoryCache); + }); + + it('should store and retrieve keys', async () => { + const nodeId = 'newNodeId'; + const keys = { passphrase: 'pass', key: generatePrivateKey('privateKey'), sessionKey: generateSessionKey('sessionKey'), hashKey: undefined }; + + await cache.setNodeKeys(nodeId, keys); + const result = await cache.getNodeKeys(nodeId); + + expect(result).toStrictEqual(keys); + }); + + it('should replace and retrieve new keys', async () => { + const nodeId = 'newNodeId'; + const keys1 = { passphrase: 'pass', key: generatePrivateKey('privateKey1'), sessionKey: generateSessionKey('sessionKey1'), hashKey: undefined }; + const keys2 = { passphrase: 'pass', key: generatePrivateKey('privateKey2'), sessionKey: generateSessionKey('sessionKey2'), hashKey: undefined }; + + await cache.setNodeKeys(nodeId, keys1); + await cache.setNodeKeys(nodeId, keys2); + const result = await cache.getNodeKeys(nodeId); + + expect(result).toStrictEqual(keys2); + }); + + it('should remove keys', async () => { + const nodeId = 'newNodeId'; + const keys = { passphrase: 'pass', key: generatePrivateKey('privateKey'), sessionKey: generateSessionKey('sessionKey'), hashKey: undefined }; + + await cache.setNodeKeys(nodeId, keys); + await cache.removeNodeKeys([nodeId]); + + try { + await cache.getNodeKeys(nodeId); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + const nodeId = 'newNodeId'; + + try { + await cache.getNodeKeys(nodeId); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a bad keys and remove the key', async () => { + try { + await cache.getNodeKeys('badKeysObject'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Failed to deserialize node keys: Unexpected token \'a\', \"aaa\" is not valid JSON'); + } + + try { + await memoryCache.getEntity('nodeKeys-badKeysObject'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a bad session key and remove the key', async () => { + try { + await cache.getNodeKeys('badSessionKey'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Failed to deserialize node keys: Invalid node session key: Error: Bad session key'); + } + + try { + await memoryCache.getEntity('nodeKeys-badSessingKey'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); +}); \ No newline at end of file diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts new file mode 100644 index 00000000..34b4345d --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -0,0 +1,94 @@ +import { ProtonDriveCache } from "../../cache"; +import { serializePrivateKey, deserializePrivateKey, serializeSessionKey, deserializeSessionKey, serializeHashKey, deserializeHashKey } from "../../crypto"; +import { DecryptedNodeKeys } from "./interface"; + +/** + * Provides caching for node crypto material. + * + * The cache is responsible for serialising and deserialising node + * crypto material. + */ +export function nodesCryptoCache(driveCache: ProtonDriveCache) { + async function setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys) { + const cacheUid = getCacheUid(nodeUid); + const nodeKeysData = serializeNodeKeys(keys); + driveCache.setEntity(cacheUid, nodeKeysData); + } + + async function getNodeKeys(nodeUid: string): Promise { + const nodeKeysData = await driveCache.getEntity(getCacheUid(nodeUid)); + try { + const keys = await deserializeNodeKeys(nodeKeysData); + return keys; + } catch (error: unknown) { + try { + await removeNodeKeys([nodeUid]); + } catch (error: unknown) { + // TODO: log error + } + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to deserialize node keys: ${errorMessage}`); + } + } + + async function removeNodeKeys(nodeUids: string[]) { + const cacheUids = nodeUids.map(getCacheUid); + await driveCache.removeEntities(cacheUids); + } + + function getCacheUid(nodeUid: string) { + return `nodeKeys-${nodeUid}`; + } + + function serializeNodeKeys(keys: DecryptedNodeKeys) { + // TODO: verify how we want to serialize keys + return JSON.stringify({ + passphrase: keys.passphrase, + key: serializePrivateKey(keys.key), + sessionKey: serializeSessionKey(keys.sessionKey), + hashKey: keys.hashKey ? serializeHashKey(keys.hashKey) : undefined, + }); + } + + async function deserializeNodeKeys(shareKeyData: string): Promise { + const result = JSON.parse(shareKeyData); + if (!result || typeof result !== 'object') { + throw new Error('Invalid node keys data'); + } + + let key, sessionKey, hashKey; + + if (!result.passphrase || typeof result.passphrase !== 'string') { + throw new Error('Invalid node passphrase'); + } + const passphrase = result.passphrase; + try { + key = await deserializePrivateKey(result.key); + } catch (error: any) { + throw new Error(`Invalid node private key: ${error}`); + } + try { + sessionKey = deserializeSessionKey(result.sessionKey); + } catch (error: any) { + throw new Error(`Invalid node session key: ${error}`); + } + try { + hashKey = result.hashKey ? deserializeHashKey(result.hashKey) : undefined; + } catch (error: any) { + throw new Error(`Invalid node hash key: ${error}`); + } + + return { + passphrase, + key, + sessionKey, + hashKey, + }; + } + + return { + setNodeKeys, + getNodeKeys, + removeNodeKeys, + } +} diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts new file mode 100644 index 00000000..0febc723 --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -0,0 +1,380 @@ +import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; +import { ProtonDriveAccount } from "../../interface"; +import { EncryptedNode, SharesService } from "./interface"; +import { nodesCryptoService } from "./cryptoService"; + +describe("nodesCryptoService", () => { + let driveCrypto: DriveCrypto; + let account: ProtonDriveAccount; + let sharesService: SharesService; + + let cryptoService: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error No need to implement all methods for mocking + driveCrypto = { + decryptKey: jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + sessionKey: "sessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + })), + decryptNodeName: jest.fn(async () => Promise.resolve({ + name: "name", + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + })), + decryptNodeHashKey: jest.fn(async () => Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + })), + encryptNodeName: jest.fn(async () => Promise.resolve({ + armoredNodeName: "armoredName", + })), + }; + // @ts-expect-error No need to implement all methods for mocking + account = { + getPublicKeys: jest.fn(async () => []), + }; + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getVolumeEmailKey: jest.fn(async () => ({ + email: "email", + key: "key" as unknown as PrivateKey, + })), + }; + + cryptoService = nodesCryptoService(driveCrypto, account, sharesService); + }); + + it("should decrypt node with same author everywhere", async () => { + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "signatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "signatureEmail" }, + activeRevision: { ok: true, value: null }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: undefined, + }, + }); + + expect(account.getPublicKeys).toHaveBeenCalledTimes(1); + expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + }); + + it("should decrypt node with different authors", async () => { + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, + activeRevision: { ok: true, value: null }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: undefined, + }, + }); + + expect(account.getPublicKeys).toHaveBeenCalledTimes(2); + expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(account.getPublicKeys).toHaveBeenCalledWith("nameSignatureEmail"); + }); + + it("should decrypt folder node", async () => { + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "signatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + folder: { + armoredHashKey: "armoredHashKey", + } + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "signatureEmail" }, + activeRevision: { ok: true, value: null }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: new Uint8Array(), + }, + }); + }); + + it("should decrypt folder node with signature validation error on key", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + sessionKey: "sessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + })); + + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + folder: { + armoredHashKey: "armoredHashKey", + } + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: true, value: "name" }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing key signature" } }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, + activeRevision: { ok: true, value: null }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: new Uint8Array(), + }, + }); + }); + + it("should decrypt folder node with signature validation error on name", async () => { + driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ + name: "name", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + folder: { + armoredHashKey: "armoredHashKey", + } + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Verification of name signature failed" } }, + activeRevision: { ok: true, value: null }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: new Uint8Array(), + }, + }); + }); + + it("should decrypt folder node with signature validation error on hash key", async () => { + driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + folder: { + armoredHashKey: "armoredHashKey", + } + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: true, value: "name" }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Verification of hash key signature failed" } }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, + activeRevision: { ok: true, value: null }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: new Uint8Array(), + }, + }); + }); + + it("should decrypt folder node with signature validation error on key and hash key", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + sessionKey: "sessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + })); + driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + folder: { + armoredHashKey: "armoredHashKey", + } + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: true, value: "name" }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing key signature" } }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, + activeRevision: { ok: true, value: null }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: new Uint8Array(), + }, + }); + }); + + it("should handle decrypt of node with key decryption issue", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.reject(new Error("Decryption error"))); + + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + folder: { + armoredHashKey: "armoredHashKey", + } + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: false, error: { name: "", error: "Failed to decrypt node key: Decryption error"} }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Failed to decrypt node key: Decryption error" } }, + nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Failed to decrypt node key: Decryption error" } }, + activeRevision: { ok: false, error: new Error("Failed to decrypt node key: Decryption error") }, + }, + }); + }); + + it("should handle decrypt of node with name decryption issue", async () => { + driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(new Error("Decryption error"))); + + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + isStale: false, + name: { ok: false, error: { name: "", error: "Decryption error" } }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, + activeRevision: { ok: true, value: null }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: undefined, + }, + }); + }); +}); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts new file mode 100644 index 00000000..b6dc0610 --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -0,0 +1,302 @@ +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; +import { resultOk, resultError, Result, InvalidNameError, AnonymousUser, UnverifiedAuthorError, ProtonDriveAccount } from "../../interface"; +import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedNode, DecryptedNodeKeys, SharesService } from "./interface"; + +// TODO: Switch to CryptoProxy module once available. +import { importHmacKey, computeHmacSignature } from "./hmac"; + +/** + * Provides crypto operations for nodes metadata. + * + * The node crypto service is responsible for decrypting and encrypting node + * metadata. It should export high-level actions only, such as "decrypt node" + * instead of low-level operations like "decrypt node key". Low-level operations + * should be kept private to the module. + * + * The service owns the logic to switch between old and new crypto model. + */ +export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriveAccount, shareService: SharesService) { + async function decryptNode(node: EncryptedNode, parentKey: PrivateKey): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { + const commonNodeMetadata = { + ...node, + encryptedCrypto: undefined, + } + + // Anonymous uploads (without signature email set) use parent key instead. + const keyVerificationKeys = node.encryptedCrypto.signatureEmail + ? await account.getPublicKeys(node.encryptedCrypto.signatureEmail) + : [parentKey]; + + let nameVerificationKeys; + const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail || node.encryptedCrypto.signatureEmail; + if (nameSignatureEmail === node.encryptedCrypto.signatureEmail) { + nameVerificationKeys = keyVerificationKeys; + } else { + nameVerificationKeys = nameSignatureEmail + ? await account.getPublicKeys(nameSignatureEmail) + : [parentKey]; + } + + let passphrase, key, sessionKey, keyAuthor; + try { + const keyResult = await decryptKey(node, parentKey, keyVerificationKeys); + passphrase = keyResult.passphrase; + key = keyResult.key; + sessionKey = keyResult.sessionKey; + keyAuthor = keyResult.author; + } catch (error: unknown) { + const errorMessage = `Failed to decrypt node key: ${error instanceof Error ? error.message : 'Unknown error'}`; + return { + node: { + ...commonNodeMetadata, + isStale: false, + name: resultError({ + name: '', + error: errorMessage, + }), + keyAuthor: resultError({ + claimedAuthor: node.encryptedCrypto.signatureEmail, + error: errorMessage, + }), + nameAuthor: resultError({ + claimedAuthor: nameSignatureEmail, + error: errorMessage, + }), + activeRevision: resultError(new Error(errorMessage)), + } + } + } + + const { name, author: nameAuthor } = await decryptName(node, parentKey, nameVerificationKeys); + + let hashKey; + let hashKeyAuthor; + if ("folder" in node.encryptedCrypto) { + const hashKeyResult = await decryptHashKey(node, key, keyVerificationKeys); + hashKey = hashKeyResult.hashKey; + hashKeyAuthor = hashKeyResult.author; + } + + return { + node: { + ...commonNodeMetadata, + isStale: false, + name, + // If key signature verificaiton failed, prefer showing error from the key directly. + keyAuthor: keyAuthor.ok && hashKeyAuthor && !hashKeyAuthor.ok ? hashKeyAuthor : keyAuthor, + nameAuthor, + activeRevision: resultOk(null), // TODO: Decrypt extended attributes + }, + keys: { + passphrase, + key, + sessionKey, + hashKey, + }, + }; + }; + + async function decryptKey(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PublicKey[]): Promise, + }> { + const key = await driveCrypto.decryptKey( + node.encryptedCrypto.armoredKey, + node.encryptedCrypto.armoredNodePassphrase, + node.encryptedCrypto.armoredNodePassphraseSignature, + [parentKey], + verificationKeys, + ); + + return { + passphrase: key.passphrase, + key: key.key, + sessionKey: key.sessionKey, + author: handleClaimedAuthor('key', key.verified, node.encryptedCrypto.signatureEmail), + }; + }; + + async function decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PrivateKey[]): Promise<{ + name: Result, + author: Result, + }> { + const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail || node.encryptedCrypto.signatureEmail; + + try { + const { name, verified } = await driveCrypto.decryptNodeName( + node.encryptedCrypto.encryptedName, + parentKey, + verificationKeys, + ); + + return { + name: resultOk(name), + author: handleClaimedAuthor('name', verified, nameSignatureEmail), + } + } catch (error: unknown) { + // TODO: Translation + const message = error instanceof Error ? error.message : 'Unknown error'; + return { + name: resultError({ + name: '', + error: message, + }), + author: resultError({ + claimedAuthor: nameSignatureEmail, + error: message, + }), + } + } + }; + + async function decryptHashKey(node: EncryptedNode, nodeKey: PrivateKey, addressKeys: PublicKey[]): Promise<{ + hashKey: Uint8Array, + author: Result, + }> { + if (!("folder" in node.encryptedCrypto)) { + throw new Error('Node is not a folder'); + } + + const { hashKey, verified } = await driveCrypto.decryptNodeHashKey( + node.encryptedCrypto.folder.armoredHashKey, + nodeKey, + addressKeys, + ); + + return { + hashKey, + author: handleClaimedAuthor('hash key', verified, node.encryptedCrypto.signatureEmail), + } + } + + async function createFolder(parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, name: string): Promise<{ + encryptedCrypto: Required & { hash: string }, + keys: DecryptedNodeKeys, + }> { + const { email, key: addressKey } = await shareService.getVolumeEmailKey(parentNode.volumeId); + const [ + nodeKeys, + { armoredNodeName }, + hash, + ] = await Promise.all([ + driveCrypto.generateKey([parentKeys.key], addressKey), + driveCrypto.encryptNodeName(name, parentKeys.key, addressKey), + generateLookupHash(name, parentKeys.hashKey), + ]); + + const { armoredHashKey, hashKey } = await driveCrypto.generateHashKey(nodeKeys.decrypted.key); + + return { + encryptedCrypto: { + encryptedName: armoredNodeName, + hash, + armoredKey: nodeKeys.encrypted.armoredKey, + armoredNodePassphrase: nodeKeys.encrypted.armoredPassphrase, + armoredNodePassphraseSignature: nodeKeys.encrypted.armoredPassphraseSignature, + folder: { + encryptedExtendedAttributes: '', + armoredHashKey, + }, + signatureEmail: email, + nameSignatureEmail: email, + }, + keys: { + passphrase: nodeKeys.decrypted.passphrase, + key: nodeKeys.decrypted.key, + sessionKey: nodeKeys.decrypted.sessionKey, + hashKey, + }, + }; + } + + async function encryptNewName(node: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, newName: string): Promise<{ + signatureEmail: string, + armoredNodeName: string, + hash: string, + }> { + const { email, key: addressKey } = await shareService.getVolumeEmailKey(node.volumeId); + const { armoredNodeName } = await driveCrypto.encryptNodeName(newName, parentKeys.key, addressKey); + const hash = await generateLookupHash(newName, parentKeys.hashKey); + return { + signatureEmail: email, + armoredNodeName, + hash, + }; + }; + + async function moveNode(node: DecryptedNode, keys: { passphrase: string, sessionKey: SessionKey }, parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }): Promise<{ + encryptedName: string, + hash: string, + armoredNodePassphrase: string, + armoredNodePassphraseSignature: string, + signatureEmail: string, + nameSignatureEmail: string, + }> { + if (!parentKeys.hashKey) { + throw new Error('Moving nodes to a non-folder is not supported'); + } + if (!node.name.ok) { + throw new Error('Cannot move node without a valid name, please rename the node first'); + } + + const { email, key: addressKey } = await shareService.getVolumeEmailKey(parentNode.volumeId); + const { armoredNodeName } = await driveCrypto.encryptNodeName(node.name.value, parentKeys.key, addressKey); + const hash = await generateLookupHash(node.name.value, parentKeys.hashKey); + const { armoredPassphrase, armoredPassphraseSignature } = await driveCrypto.encryptPassphrase(keys.passphrase, keys.sessionKey, [parentKeys.key], addressKey); + + return { + encryptedName: armoredNodeName, + hash, + armoredNodePassphrase: armoredPassphrase, + armoredNodePassphraseSignature: armoredPassphraseSignature, + signatureEmail: email, + nameSignatureEmail: email, + }; + } + + async function generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { + const key = await importHmacKey(parentHashKey); + + const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); + return arrayToHexString(signature); + } + + return { + decryptNode, + createFolder, + encryptNewName, + moveNode, + } +} + +function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string): Result { + if (!claimedAuthor) { + return resultOk(null); // Anonymous user + } + + if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { + return resultOk(claimedAuthor); + } + + // TODO: Translation + const error = verified === VERIFICATION_STATUS.SIGNED_AND_INVALID + ? `Verification of ${signatureType} signature failed` + : `Missing ${signatureType} signature`; + return resultError({ + claimedAuthor: claimedAuthor, + error, + }); +} + +/** + * Convert an array of 8-bit integers to a hex string + * @param bytes - Array of 8-bit integers to convert + * @returns Hexadecimal representation of the array + */ +export const arrayToHexString = (bytes: Uint8Array) => { + const hexAlphabet = '0123456789abcdef'; + let s = ''; + bytes.forEach((v) => { + s += hexAlphabet[v >> 4] + hexAlphabet[v & 15]; + }); + return s; +}; diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts new file mode 100644 index 00000000..a92bdc5f --- /dev/null +++ b/js/sdk/src/internal/nodes/events.ts @@ -0,0 +1,101 @@ +import { NodeEventCallback } from "../../interface/index.js"; +import { DriveEventsService } from "../events/index.js"; +import { nodesCache } from "./cache.js"; + +type NodeEventInfo = { + parentNodeUid: string, + isTrashed?: boolean, + isShared?: boolean, +} + +/** + * Provides both event handling and subscription mechanism for user. + * + * The service is responsible for handling events regarding node metadata + * from the DriveEventsService, and for providing a subscription mechanism + * for the user to listen to updates of specific group of nodes, such as + * any update for trashed nodes. + */ +export function nodesEvents( + cache: ReturnType, + events: DriveEventsService, +) { + const listeners: { condition: (nodeEventInfo: NodeEventInfo) => boolean, callback: NodeEventCallback }[] = []; + + // TODO: handler for saving to internal cache + // errors should not be ignored until event is processed - how to give up after some time? + events.registerHandler(async (event) => { + if (event.type === 'node_created') { + try { + const parentNode = await cache.getNode(event.parentNodeUid); + // TODO: do not fetch and decrypt, only save to cache there is new node + } catch (err) { + // TODO: ignore if missing in cache + throw err; + } + } + if (event.type === 'node_updated' || event.type === 'node_updated_metadata') { + try { + const node = await cache.getNode(event.nodeUid); + node.isStale = true; + await cache.setNode(node); + } catch (err) { + // TODO: ignore if missing in cache + throw err; + } + } + if (event.type === 'node_deleted') { + try { + await cache.removeNodes([event.nodeUid]); + } catch (err) { + // TODO: ignore if missing in cache + throw err; + } + } + }); + + // TODO: ignore errors if this doesn't work so events can continue + // but log them and how to report to the caller? + events.registerHandler(async (event) => { + if (event.type === 'node_created' || event.type === 'node_updated' || event.type === 'node_updated_metadata') { + await Promise.all(listeners.map(async ({ condition, callback }) => { + if (condition(event)) { + // TODO: do fetch and decrypt, not only cache + const node = await cache.getNode(event.nodeUid); + callback({ type: 'update', uid: node.uid, node: node as any }); + } + })); + } + if (event.type === 'node_deleted') { + await Promise.all(listeners.map(async ({ condition, callback }) => { + if (condition(event)) { + callback({ type: 'remove', uid: event.nodeUid }); + } + })); + } + }); + + // TODO: transform internal events to outside events that also fetches whole object and decrypts it + // TODO: hook it up after the cache is updated from above + + // TODO: subscrition to shared by me or trashed nodes needs fetch of every node (to get sharing or trashing info), but not necessarily decryption if not needed node + // TODO: shared by me should be handled in sharing module? + function subscribeToSharedNodesByMe(callback: NodeEventCallback) { + listeners.push({ condition: ({ isShared }) => isShared || false, callback }); + } + + function subscribeToTrashedNodes(callback: NodeEventCallback) { + listeners.push({ condition: ({ isTrashed }) => isTrashed || false, callback }); + } + + // TODO: subscription to children needs info about parent - if parent is matching, it will fetch and decrypt + function subscribeToChildren(parentNodeUid: string, callback: NodeEventCallback) { + listeners.push({ condition: ({ parentNodeUid: parent }) => parent === parentNodeUid, callback }); + } + + return { + subscribeToSharedNodesByMe, + subscribeToTrashedNodes, + subscribeToChildren, + } +} diff --git a/js/sdk/src/internal/nodes/hmac.ts b/js/sdk/src/internal/nodes/hmac.ts new file mode 100644 index 00000000..ef7020bd --- /dev/null +++ b/js/sdk/src/internal/nodes/hmac.ts @@ -0,0 +1,46 @@ +const HASH_ALGORITHM = 'SHA-256'; +const KEY_LENGTH_BYTES = 32; +export type HmacCryptoKey = CryptoKey; + +type HmacKeyUsage = 'sign' | 'verify'; + +/** + * Import an HMAC-SHA256 key in order to use it with `signData` and `verifyData`. + */ +export const importHmacKey = async ( + key: Uint8Array, + keyUsage: HmacKeyUsage[] = ['sign', 'verify'] +): Promise => { + // From https://datatracker.ietf.org/doc/html/rfc2104: + // The key for HMAC can be of any length (keys longer than B bytes are first hashed using H). + // However, less than L bytes (L = 32 bytes for SHA-256) is strongly discouraged as it would + // decrease the security strength of the function. Keys longer than L bytes are acceptable + // but the extra length would not significantly increase the function strength. + // (A longer key may be advisable if the randomness of the key is considered weak.) + if (key.length < KEY_LENGTH_BYTES) { + throw new Error('Unexpected HMAC key size: key is too short'); + } + return crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: HASH_ALGORITHM }, false, keyUsage); +}; + +/** + * Sign data using HMAC-SHA256 + * @param key - WebCrypto secret key for signing + * @param data - data to sign + * @param additionalData - additional data to authenticate + */ +export const computeHmacSignature = async (key: HmacCryptoKey, data: Uint8Array) => { + const signatureBuffer = await crypto.subtle.sign({ name: 'HMAC', hash: HASH_ALGORITHM }, key, data); + return new Uint8Array(signatureBuffer); +}; + +/** + * Verify data using HMAC-SHA256 + * @param key - WebCrypto secret key for verification + * @param signature - signature over data + * @param data - data to verify + * @param additionalData - additional data to authenticate + */ +export const verifyData = async (key: HmacCryptoKey, signature: Uint8Array, data: Uint8Array) => { + return crypto.subtle.verify({ name: 'HMAC', hash: HASH_ALGORITHM }, key, signature, data); +}; diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts new file mode 100644 index 00000000..452edaae --- /dev/null +++ b/js/sdk/src/internal/nodes/index.ts @@ -0,0 +1,74 @@ +import { DriveAPIService } from "../apiService"; +import { ProtonDriveCache } from "../../cache"; +import { DriveCrypto } from "../../crypto"; +import { DriveEventsService } from "../events"; +import { Logger, ProtonDriveAccount } from "../../interface"; +import { nodeAPIService } from "./apiService"; +import { nodesCache } from "./cache"; +import { nodesEvents } from "./events"; +import { nodesCryptoCache } from "./cryptoCache"; +import { nodesCryptoService } from "./cryptoService"; +import { SharesService, DecryptedNode } from "./interface"; +import { nodesAccess } from "./nodesAccess"; +import { nodesManager } from "./manager"; + +/** + * Provides facade for the whole nodes module. + * + * The nodes module is responsible for handling node metadata, including + * API communication, encryption, decryption, caching, and event handling. + * + * This facade provides internal interface that other modules can use to + * interact with the nodes. + */ +export function nodes( + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveCache, + driveCryptoCache: ProtonDriveCache, + account: ProtonDriveAccount, + driveCrypto: DriveCrypto, + driveEvents: DriveEventsService, + sharesService: SharesService, + logger?: Logger, +) { + const api = nodeAPIService(apiService, logger); + const cache = nodesCache(driveEntitiesCache, logger); + const cryptoCache = nodesCryptoCache(driveCryptoCache); + const cryptoService = nodesCryptoService(driveCrypto, account, sharesService); + const nodesAccessFunctions = nodesAccess(api, cache, cryptoCache, cryptoService, sharesService); + const nodesFunctions = nodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccessFunctions); + const nodesEventsFunctions = nodesEvents(cache, driveEvents); + + return { + getNode: nodesAccessFunctions.getNode, + getNodeKeys: nodesAccessFunctions.getNodeKeys, + ...nodesFunctions, + ...nodesEventsFunctions, + } +} + +export function publicNodes( + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveCache, + driveCryptoCache: ProtonDriveCache, + driveCrypto: DriveCrypto, + sharesService: SharesService, +) { + // TODO: create public node API service + const api = nodeAPIService(apiService); + const cache = nodesCache(driveEntitiesCache); + const cryptoCache = nodesCryptoCache(driveCryptoCache); + // @ts-expect-error TODO + const cryptoService = nodesCryptoService(driveCrypto); + const nodesAccessFunctions = nodesAccess(api, cache, cryptoCache, cryptoService, sharesService); + const nodesListingFunctions = nodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccessFunctions); + + return { + // TODO: use public root node, not my files + getPublicRootNode: async (token: string, password: string, customPassword?: string): Promise => { return {} as DecryptedNode }, + getNode: nodesAccessFunctions.getNode, + getNodeKeys: nodesAccessFunctions.getNodeKeys, + iterateChildren: nodesListingFunctions.iterateChildren, + iterateNodes: nodesListingFunctions.iterateNodes, + } +} diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts new file mode 100644 index 00000000..42957635 --- /dev/null +++ b/js/sdk/src/internal/nodes/interface.ts @@ -0,0 +1,90 @@ +import { PrivateKey, SessionKey } from "../../crypto"; +import { Result, InvalidNameError, AnonymousUser, UnverifiedAuthorError, MemberRole, NodeType, Revision } from "../../interface"; + +/** + * Internal common node interface for both encrypted or decrypted node. + */ +interface BaseNode { + // Internal metadata + volumeId: string; + hash?: string; // root node doesn't have any hash + + // Basic node metadata + uid: string; + parentUid?: string; + type: NodeType; + mimeType?: string; + createdDate: Date; // created on the server + trashedDate?: Date; + + // Share node metadata + shareId?: string; + isShared: boolean, + directMemberRole: MemberRole, +} + +/** + * Interface used only internaly in the nodes module. + * + * Outside of the module, the decrypted node interface should be used. + */ +export interface EncryptedNode extends BaseNode { + encryptedCrypto: EncryptedNodeFolderCrypto | EncryptedNodeFileCrypto; +} + +export interface EncryptedNodeCrypto { + encryptedName: string; + + signatureEmail?: string; + nameSignatureEmail?: string; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; +} + +export interface EncryptedNodeFileCrypto extends EncryptedNodeCrypto { + file: { + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature?: string; + }; + activeRevision: { + id: string; + encryptedExtendedAttributes?: string; + }; +} + +export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { + folder: { + encryptedExtendedAttributes?: string; + armoredHashKey: string; + }; +} + +/** + * Interface holding decrypted node metadata. + */ +export interface DecryptedNode extends BaseNode { + // Internal metadata + isStale: boolean; + + keyAuthor: Result, + nameAuthor: Result, + name: Result, + activeRevision: Result, // null for folders +} + +export interface DecryptedNodeKeys { + passphrase: string; + key: PrivateKey; + sessionKey: SessionKey; + hashKey?: Uint8Array; +} + +/** + * Interface describing the dependencies to the shares module. + */ +export interface SharesService { + getMyFilesIDs(): Promise<{ volumeId: string, rootNodeId: string }>, + getSharePrivateKey(shareId: string): Promise, + getVolumeEmailKey(volumeId: string): Promise<{ email: string, key: PrivateKey }>, +} diff --git a/js/sdk/src/internal/nodes/manager.ts b/js/sdk/src/internal/nodes/manager.ts new file mode 100644 index 00000000..60822841 --- /dev/null +++ b/js/sdk/src/internal/nodes/manager.ts @@ -0,0 +1,356 @@ +import { MemberRole, NodeType, resultOk } from "../../interface"; +import { nodeAPIService, ResultErrors, NodeErrors } from "./apiService.js"; +import { nodesCache } from "./cache.js" +import { nodesCryptoCache } from "./cryptoCache.js" +import { nodesCryptoService } from "./cryptoService.js"; +import { SharesService, DecryptedNode } from "./interface.js"; +import { nodesAccess } from "./nodesAccess.js"; +import { makeNodeUid } from "./nodeUid.js"; + +const BATCH_LOADING = 10; + +/** + * Provides high-level actions for managing nodes. + * + * The manager is responsible for handling nodes metadata, including + * API communication, encryption, decryption, and caching. + * + * This module uses other modules providing low-level operations, such + * as API service, cache, crypto service, etc. + */ +export function nodesManager( + apiService: ReturnType, + cache: ReturnType, + cryptoCache: ReturnType, + cryptoService: ReturnType, + shareService: SharesService, + nodesAccessFunctions: ReturnType, +) { + async function getMyFilesRootFolder() { + const { volumeId, rootNodeId } = await shareService.getMyFilesIDs(); + const nodeUid = makeNodeUid(volumeId, rootNodeId); + return nodesAccessFunctions.getNode(nodeUid); + } + + // Improvement requested: keep status of loaded children and leverage cache. + async function *iterateChildren(parentNodeUid: string, signal?: AbortSignal) { + // Ensure the parent is loaded and up-to-date. + const parentNode = await nodesAccessFunctions.getNode(parentNodeUid); + + const batchLoading = new BatchNodesLoading(nodesAccessFunctions.loadNodes); + for await (const nodeUid of apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { + let node; + try { + node = await cache.getNode(nodeUid); + } catch {} + + if (node && !node.isStale) { + yield node; + } else { + yield* batchLoading.loadNode(nodeUid, signal); + } + } + } + + async function *iterateTrashedNodes(signal?: AbortSignal) { + const { volumeId } = await shareService.getMyFilesIDs(); + const batchLoading = new BatchNodesLoading(nodesAccessFunctions.loadNodes); + for await (const nodeUid of apiService.iterateTrashedNodeUids(volumeId, signal)) { + let node; + try { + node = await cache.getNode(nodeUid); + } catch {} + + if (node && !node.isStale) { + yield node; + } else { + yield* batchLoading.loadNode(nodeUid, signal); + } + } + } + + async function *iterateNodes(nodeUids: string[], signal?: AbortSignal) { + const batchLoading = new BatchNodesLoading(nodesAccessFunctions.loadNodes); + for await (const result of cache.iterateNodes(nodeUids)) { + if (result.ok && !result.node.isStale) { + yield result.node; + } else { + yield* batchLoading.loadNode(result.uid, signal); + } + } + } + + async function renameNode(nodeUid: string, newName: string) { + const node = await nodesAccessFunctions.getNode(nodeUid); + const parentKeys = await nodesAccessFunctions.getParentKeys(node); + + if (!node.hash || !parentKeys.hashKey) { + throw new Error('Renaming root nodes is not supported') + } + + const { + signatureEmail, + armoredNodeName, + hash, + } = await cryptoService.encryptNewName(node, { key: parentKeys.key, hashKey: parentKeys.hashKey }, newName); + await apiService.renameNode( + nodeUid, + { + hash: node.hash, + }, + { + encryptedName: armoredNodeName, + nameSignatureEmail: signatureEmail, + hash: hash, + } + ); + await cache.setNode({ + ...node, + name: resultOk(newName), + nameAuthor: resultOk(signatureEmail), + hash, + }); + } + + async function moveNode(nodeUid: string, newParentUid: string) { + const [node, newParentNode] = await Promise.all([ + nodesAccessFunctions.getNode(nodeUid), + nodesAccessFunctions.getNode(newParentUid), + ]); + const [keys, newParentKeys] = await Promise.all([ + nodesAccessFunctions.getNodeKeys(nodeUid), + nodesAccessFunctions.getNodeKeys(newParentUid), + ]); + + if (!node.hash) { + throw new Error('Moving root nodes is not supported'); + } + if (!newParentKeys.hashKey) { + throw new Error('Moving nodes to a non-folder is not supported'); + } + + const encryptedCrypto = await cryptoService.moveNode( + node, + keys, + newParentNode, + { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, + ); + await apiService.moveNode( + nodeUid, + { + hash: node.hash, + }, + { + parentUid: newParentUid, + armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + signatureEmail: encryptedCrypto.signatureEmail, + encryptedName: encryptedCrypto.encryptedName, + nameSignatureEmail: encryptedCrypto.nameSignatureEmail, + hash: encryptedCrypto.hash, + // TODO: content hash + } + ); + await cache.setNode({ + ...node, + parentUid: newParentUid, + }); + } + + async function trashNodes(nodeUids: string[], signal?: AbortSignal) { + const nodesPerParent = new Map(); + + for await (const node of iterateNodes(nodeUids, signal)) { + if (!node.parentUid) { + throw new Error('Trashing root nodes is not supported'); + } + const nodes = nodesPerParent.get(node.parentUid); + if (nodes) { + nodes.push(node); + } else { + nodesPerParent.set(node.parentUid, [node]); + } + } + + let errors: NodeErrors = {}; + + for (const [parentNodeUid, nodes] of nodesPerParent) { + let updatedNodes: DecryptedNode[]; + try { + await apiService.trashNodes(parentNodeUid, nodes.map(node => node.uid), signal); + updatedNodes = nodes; + } catch (error: unknown) { + if (error instanceof ResultErrors) { + updatedNodes = nodes.filter(node => !error.failingNodeUids.includes(node.uid)); + errors = { ...errors, ...error.nodeErrors }; + } else { + updatedNodes = []; + errors = { ...errors, ...Object.fromEntries(nodes.map(node => [node.uid, error instanceof Error ? error.message : `${error}`])) }; + } + } + for (const node of updatedNodes) { + await cache.setNode({ + ...node, + trashedDate: new Date(), + }); + } + } + + if (Object.keys(errors).length) { + throw new ResultErrors(errors); + } + } + + async function restoreNodes(nodeUids: string[], signal?: AbortSignal) { + const nodes = await Array.fromAsync(iterateNodes(nodeUids, signal)); + let updatedNodes: DecryptedNode[]; + let catchedError: unknown; + + try { + await apiService.restoreNodes(nodeUids, signal); + updatedNodes = nodes; + } catch (error: unknown) { + catchedError = error; + if (error instanceof ResultErrors) { + updatedNodes = nodes.filter(node => !error.failingNodeUids.includes(node.uid)); + } else { + updatedNodes = []; + } + } + + for (const node of updatedNodes) { + await cache.setNode({ + ...node, + trashedDate: new Date(), + }); + } + + if (catchedError) { + throw catchedError; + } + } + + async function deleteNodes(nodeUids: string[], signal?: AbortSignal) { + let updatedNodeUids: string[]; + let catchedError: unknown; + + try { + await apiService.restoreNodes(nodeUids, signal); + updatedNodeUids = nodeUids; + } catch (error: unknown) { + catchedError = error; + if (error instanceof ResultErrors) { + updatedNodeUids = nodeUids.filter(nodeUid => !error.failingNodeUids.includes(nodeUid)); + } else { + updatedNodeUids = []; + } + } + + if (updatedNodeUids) { + await cache.removeNodes(updatedNodeUids); + } + + if (catchedError) { + throw catchedError; + } + } + + async function createFolder(parentNodeUid: string, folderName: string, signal?: AbortSignal) { + const parentNode = await nodesAccessFunctions.getNode(parentNodeUid); + const parentKeys = await nodesAccessFunctions.getNodeKeys(parentNodeUid); + if (!parentKeys.hashKey) { + throw new Error('Creating folders in non-folders is not supported'); + } + + const { encryptedCrypto, keys } = await cryptoService.createFolder(parentNode, { key: parentKeys.key, hashKey: parentKeys.hashKey }, folderName); + const nodeUid = await apiService.createFolder(parentNodeUid, { + armoredKey: encryptedCrypto.armoredKey, + armoredHashKey: encryptedCrypto.folder.armoredHashKey, + armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + signatureEmail: encryptedCrypto.signatureEmail, + encryptedName: encryptedCrypto.encryptedName, + hash: encryptedCrypto.hash, + encryptedExtendedAttributes: encryptedCrypto.folder.encryptedExtendedAttributes || "", // TODO + }, signal); + + await cache.setNode({ + // Internal metadata + volumeId: parentNode.volumeId, + hash: encryptedCrypto.hash, + + // Basic node metadata + uid: nodeUid, + parentUid: parentNodeUid, + type: NodeType.Folder, + mimeType: "Folder", + createdDate: new Date(), + + // Share node metadata + isShared: false, + directMemberRole: MemberRole.Admin, // TODO + + // Decrypted metadata + isStale: false, + keyAuthor: resultOk(encryptedCrypto.signatureEmail), + nameAuthor: resultOk(encryptedCrypto.signatureEmail), + name: resultOk(folderName), + activeRevision: resultOk(null), + }); + await cryptoCache.setNodeKeys(nodeUid, keys); + } + + return { + getMyFilesRootFolder, + iterateChildren, + iterateTrashedNodes, + iterateNodes, + renameNode, + moveNode, + trashNodes, + restoreNodes, + deleteNodes, + createFolder, + } +} + +/** + * Helper class for batch loading nodes. + * + * The class is responsible for fetching nodes in batches. Any call to + * `loadNode` will add the node to the batch (without fetching anything), + * and if the batch reaches the limit, it will fetch the nodes and yield + * them transparently to the caller. + * + * Example: + * + * ```typescript + * const batchLoading = new BatchNodesLoading(loadNodesCallback); + * for (const nodeUid of nodeUids) { + * for await (const node of batchLoading.loadNode(nodeUid)) { + * console.log(node); + * } + * } + * ``` + */ +class BatchNodesLoading { + private nodesToFetch: string[]; + private loadNodes: (nodeUids: string[]) => Promise; + + constructor(loadNodes: (nodeUids: string[]) => Promise) { + this.nodesToFetch = []; + this.loadNodes = loadNodes; + } + + async *loadNode(nodeUid: string, signal?: AbortSignal) { + this.nodesToFetch.push(nodeUid); + + if (this.nodesToFetch.length >= BATCH_LOADING) { + const nodes = await this.loadNodes(this.nodesToFetch); + for (const node of nodes) { + yield node; + } + this.nodesToFetch = []; + } + } +} diff --git a/js/sdk/src/internal/nodes/nodeUid.ts b/js/sdk/src/internal/nodes/nodeUid.ts new file mode 100644 index 00000000..5eb4be98 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodeUid.ts @@ -0,0 +1,13 @@ +export function makeNodeUid(volumeId: string, nodeId: string) { + // TODO: format of UID + return `volume:${volumeId};node:${nodeId}`; +} + +export function splitNodeUid(nodeUid: string) { + // TODO: validation + const [ volumeId, nodeId ] = nodeUid.split(';'); + return { + volumeId: volumeId.slice('volume:'.length), + nodeId: nodeId.slice('node:'.length), + }; +} diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts new file mode 100644 index 00000000..36cd0a8c --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -0,0 +1,135 @@ +import { PrivateKey } from "../../crypto"; +import { nodeAPIService } from "./apiService"; +import { nodesCache } from "./cache" +import { nodesCryptoCache } from "./cryptoCache"; +import { nodesCryptoService } from "./cryptoService"; +import { nodesAccess } from './nodesAccess'; +import { SharesService, DecryptedNode, EncryptedNode, DecryptedNodeKeys } from "./interface"; + +describe('nodesAccess', () => { + let apiService: ReturnType; + let cache: ReturnType; + let cryptoCache: ReturnType; + let cryptoService: ReturnType; + let shareService: SharesService; + let access: ReturnType; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = { + getNode: jest.fn(), + getNodes: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cache = { + getNode: jest.fn(), + setNode: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cryptoCache = { + getNodeKeys: jest.fn(), + setNodeKeys: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + decryptNode: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + shareService = { + getSharePrivateKey: jest.fn(), + }; + + access = nodesAccess(apiService, cache, cryptoCache, cryptoService, shareService); + }); + + describe('getNode', () => { + it('should get node from cache', async () => { + const node = { uid: 'nodeId', isStale: false } as DecryptedNode; + cache.getNode = jest.fn(() => Promise.resolve(node)); + + const result = await access.getNode('nodeId'); + expect(result).toBe(node); + expect(apiService.getNode).not.toHaveBeenCalled(); + }); + + it('should get node from API when cahce is stale', async () => { + const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; + const decryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedNode; + const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; + + cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeId', isStale: true } as DecryptedNode)); + apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedNode, keys: decryptedKeys })); + + const result = await access.getNode('nodeId'); + expect(result).toBe(decryptedNode); + expect(apiService.getNode).toHaveBeenCalledWith('nodeId'); + expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('parentUid'); + expect(cryptoService.decryptNode).toHaveBeenCalledWith(encryptedNode, 'parentKey'); + expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('nodeId', decryptedKeys); + }); + + it('should get node from API missing cache', async () => { + const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; + const decryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedNode; + const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; + + cache.getNode = jest.fn(() => Promise.reject(new Error('Entity not found'))); + apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedNode, keys: decryptedKeys })); + + const result = await access.getNode('nodeId'); + expect(result).toBe(decryptedNode); + expect(apiService.getNode).toHaveBeenCalledWith('nodeId'); + expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('parentUid'); + expect(cryptoService.decryptNode).toHaveBeenCalledWith(encryptedNode, 'parentKey'); + expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('nodeId', decryptedKeys); + }); + }); + + describe('getParentKeys', () => { + it('should get share parent keys', async () => { + shareService.getSharePrivateKey = jest.fn(() => Promise.resolve('shareKey' as any as PrivateKey)); + + const result = await access.getParentKeys({ shareId: 'shareId', parentUid: undefined }); + expect(result).toEqual({ key: 'shareKey' }); + expect(cryptoCache.getNodeKeys).not.toHaveBeenCalled(); + }); + + it('should get node parent keys', async () => { + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + + const result = await access.getParentKeys({ shareId: undefined, parentUid: 'parentUid' }); + expect(result).toEqual({ key: 'parentKey' }); + expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); + }); + + it('should get node parent keys even if share is set', async () => { + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + + const result = await access.getParentKeys({ shareId: 'shareId', parentUid: 'parentUid' }); + expect(result).toEqual({ key: 'parentKey' }); + expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); + }); + }); + + it('should load node without accessing cache first', async () => { + const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; + const decryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedNode; + const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; + + apiService.getNodes = jest.fn(() => Promise.resolve([encryptedNode])); + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedNode, keys: decryptedKeys })); + + const result = await access.loadNodes(['nodeId']); + expect(result).toEqual([decryptedNode]); + expect(cache.getNode).not.toHaveBeenCalled(); + expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('nodeId', decryptedKeys); + }); +}); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts new file mode 100644 index 00000000..92102467 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -0,0 +1,88 @@ +import { nodeAPIService } from "./apiService"; +import { nodesCache } from "./cache" +import { nodesCryptoCache } from "./cryptoCache"; +import { nodesCryptoService } from "./cryptoService"; +import { SharesService, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; + +/** + * Provides access to node metadata. + * + * The node access module is responsible for fetching, decrypting and caching + * nodes metadata. + */ +export function nodesAccess( + apiService: ReturnType, + cache: ReturnType, + cryptoCache: ReturnType, + cryptoService: ReturnType, + shareService: SharesService, +) { + async function getNode(nodeUid: string) { + let cachedNode; + try { + cachedNode = await cache.getNode(nodeUid); + } catch {} + + if (cachedNode && !cachedNode.isStale) { + return cachedNode; + } + + const { node } = await loadNode(nodeUid); + return node; + } + + async function loadNode(nodeUid: string) { + const encryptedNode = await apiService.getNode(nodeUid); + return decryptNode(encryptedNode); + } + + async function loadNodes(nodeUids: string[], signal?: AbortSignal) { + // TODO: batching + const encryptedNodes = await apiService.getNodes(nodeUids, signal); + const results = await Promise.all(encryptedNodes.map(decryptNode)); + return results.map(({ node }) => node); + } + + async function decryptNode(encryptedNode: EncryptedNode) { + const { key: parentKey } = await getParentKeys(encryptedNode); + const { node, keys } = await cryptoService.decryptNode(encryptedNode, parentKey); + cache.setNode(node); + if (keys) { + cryptoCache.setNodeKeys(node.uid, keys); + } + return { node, keys }; + } + + async function getParentKeys(node: Pick): Promise> { + if (node.parentUid) { + return getNodeKeys(node.parentUid); + } + if (!node.shareId) { + // TODO: better error message + throw new Error('Node tree has no parent to access the keys'); + } + return { + key: await shareService.getSharePrivateKey(node.shareId), + } + } + + async function getNodeKeys(nodeUid: string): Promise { + try { + return cryptoCache.getNodeKeys(nodeUid); + } catch { + const { keys } = await loadNode(nodeUid); + if (!keys) { + // TODO: better error message + throw new Error('Parent node cannot be decrypted'); + } + return keys; + } + } + + return { + getNode, + getParentKeys, + getNodeKeys, + loadNodes, + } +} diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts new file mode 100644 index 00000000..e693bfc5 --- /dev/null +++ b/js/sdk/src/internal/shares/apiService.ts @@ -0,0 +1,161 @@ +import { DriveAPIService, drivePaths } from "../apiService/index.js"; +import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto } from "./interface.js"; + +type PostCreateVolumeRequest = Extract['content']['application/json']; +type PostCreateVolumeResponse = drivePaths['/drive/volumes']['post']['responses']['200']['content']['application/json']; + +type PostCreateShareRequest = Extract['content']['application/json']; +type PostCreateShareResponse = drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; + +type GetMyFilesResponse = drivePaths['/drive/v2/shares/my-files']['get']['responses']['200']['content']['application/json']; +type GetVolumeResponse = drivePaths['/drive/volumes/{volumeID}']['get']['responses']['200']['content']['application/json']; +type GetShareResponse = drivePaths['/drive/shares/{shareID}']['get']['responses']['200']['content']['application/json']; + +/** + * Provides API communication for fetching shares and creating volumes. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ +export function sharesAPIService(apiService: DriveAPIService) { + async function getMyFiles(): Promise { + const response = await apiService.get('drive/v2/shares/my-files'); + return { + volumeId: response.Volume.VolumeID, + shareId: response.Share.ShareID, + rootNodeId: response.Link.LinkID, + creatorEmail: response.Share.CreatorEmail, + encryptedCrypto: { + armoredKey: response.Share.Key, + armoredPassphrase: response.Share.Passphrase, + armoredPassphraseSignature: response.Share.PassphraseSignature, + }, + addressId: response.Share.AddressID, + }; + } + + async function getVolume(volumeId: string): Promise<{ shareId: string }> { + const response = await apiService.get(`drive/volumes/${volumeId}`); + return { + shareId: response.Volume.Share.ShareID, + } + } + + async function getShare(shareId: string): Promise { + const response = await apiService.get(`drive/shares/${shareId}`); + return convertSharePayload(response); + } + + /** + * Returns root share with address key. + * + * This function provides access to root shares that provides access + * to node tree via address key. For this reason, caller must use this + * only when it is clear the shareId is root share. + * + * @throws Error when share is not root share. + */ + async function getRootShare(shareId: string): Promise { + const response = await apiService.get(`drive/shares/${shareId}`); + + if (!response.AddressID) { + throw new Error('Loading share without direct access is not supported'); + } + + return { + ...convertSharePayload(response), + addressId: response.AddressID, + }; + } + + function convertSharePayload(response: GetShareResponse): EncryptedShare { + return { + volumeId: response.VolumeID, + shareId: response.ShareID, + rootNodeId: response.LinkID, + creatorEmail: response.Creator, + encryptedCrypto: { + armoredKey: response.Key, + armoredPassphrase: response.Passphrase, + armoredPassphraseSignature: response.PassphraseSignature, + }, + }; + } + + async function createVolume( + share: { + addressId: string, + addressKeyId: string, + } & EncryptedShareCrypto, + node: { + encryptedName: string, + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + armoredHashKey: string, + }, + ): Promise<{ volumeId: string, shareId: string, rootNodeId: string }> { + const response = await apiService.post< + // Volume & share names are deprecated. + Omit, + PostCreateVolumeResponse + >('drive/volumes', { + AddressID: share.addressId, + AddressKeyID: share.addressKeyId, + ShareKey: share.armoredKey, + SharePassphrase: share.armoredPassphrase, + SharePassphraseSignature: share.armoredPassphraseSignature, + + FolderName: node.encryptedName, + FolderKey: node.armoredKey, + FolderPassphrase: node.armoredPassphrase, + FolderPassphraseSignature: node.armoredPassphraseSignature, + FolderHashKey: node.armoredHashKey, + }); + return { + volumeId: response.Volume.ID, + shareId: response.Volume.Share.ShareID, + rootNodeId: response.Volume.Share.LinkID, + } + } + + async function createShare( + volumeId: string, + share: { + addressId: string, + } & EncryptedShareCrypto, + node: { + nodeId: string, + encryptedName: string, + nameKeyPacket: string, + passphraseKeyPacket: string, + }, + ): Promise<{ shareId: string }> { + const response = await apiService.post< + // Share name is deprecated. + Omit, + PostCreateShareResponse + >(`/drive/volumes/${volumeId}/shares`, { + AddressID: share.addressId, + ShareKey: share.armoredKey, + SharePassphrase: share.armoredPassphrase, + SharePassphraseSignature: share.armoredPassphraseSignature, + RootLinkID: node.nodeId, + NameKeyPacket: node.nameKeyPacket, + PassphraseKeyPacket: node.passphraseKeyPacket, + }); + + return { + shareId: response.Share.ID, + } + } + + return { + getMyFiles, + getVolume, + getShare, + getRootShare, + createVolume, + createShare, + } +} diff --git a/js/sdk/src/internal/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts new file mode 100644 index 00000000..5709d798 --- /dev/null +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -0,0 +1,56 @@ +import { MemoryCache } from "../../cache"; +import { sharesCache } from "./cache"; + +describe('sharesCache', () => { + let memoryCache: MemoryCache; + let cache: ReturnType; + + beforeEach(() => { + memoryCache = new MemoryCache([]); + memoryCache.setEntity('volume-badObject', 'aaa'); + + cache = sharesCache(memoryCache); + }); + + it('should store and retrieve volume', async () => { + const volumeId = 'volume1'; + const volume = { + volumeId, + shareId: 'share1', + rootNodeId: 'node1', + creatorEmail: 'email', + }; + + await cache.setVolume(volume); + const result = await cache.getVolume(volumeId); + + expect(result).toStrictEqual(volume); + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + const volumeId = 'newVolumeId'; + + try { + await cache.getVolume(volumeId); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a bad keys and remove the key', async () => { + try { + await cache.getVolume('badObject'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Failed to deserialize volume: Unexpected token \'a\', \"aaa\" is not valid JSON'); + } + + try { + await memoryCache.getEntity('volumes-badObject'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); +}); diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts new file mode 100644 index 00000000..76ce93f9 --- /dev/null +++ b/js/sdk/src/internal/shares/cache.ts @@ -0,0 +1,63 @@ +import { ProtonDriveCache, EntityResult } from "../../cache/index.js"; +import { Volume } from "./interface.js"; + +/** + * Provides caching for shares and volume metadata. + * + * The cache is responsible for serialising and deserialising volume metadata. + */ +export function sharesCache(driveCache: ProtonDriveCache) { + async function setVolume(volume: Volume) { + const key = getCacheUid(volume.volumeId); + const shareData = serializeVolume(volume); + driveCache.setEntity(key, shareData); + } + + async function getVolume(volumeId: string): Promise { + const key = getCacheUid(volumeId); + const volumeData = await driveCache.getEntity(key); + + try { + return deserializeVolume(volumeData); + } catch (error: any) { + try { + await removeVolume(volumeId); + } catch (error: any) { + // TODO: log error + } + throw new Error(`Failed to deserialize volume: ${error.message}`); + } + } + + async function removeVolume(volumeId: string) { + await driveCache.removeEntities([getCacheUid(volumeId)]); + } + + function getCacheUid(volumeId: string) { + return `volume-${volumeId}`; + } + + function serializeVolume(volume: Volume) { + return JSON.stringify(volume); + } + + function deserializeVolume(shareData: string): Volume { + const volume = JSON.parse(shareData); + if ( + !volume || typeof volume !== 'object' || + !volume.volumeId || typeof volume.volumeId !== 'string' || + !volume.shareId || typeof volume.shareId !== 'string' || + !volume.rootNodeId || typeof volume.rootNodeId !== 'string' || + !volume.creatorEmail || typeof volume.creatorEmail !== 'string' + ) { + throw new Error('Invalid volume data'); + } + return volume; + } + + return { + setVolume, + getVolume, + removeVolume, + } +} diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts new file mode 100644 index 00000000..1d11fb31 --- /dev/null +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -0,0 +1,116 @@ +import { PrivateKey, SessionKey } from "../../crypto"; +import { MemoryCache } from "../../cache"; +import { sharesCryptoCache } from "./cryptoCache"; + +jest.mock('../../crypto/openPGPSerialisation', () => ({ + serializePrivateKey: jest.fn((value) => value), + deserializePrivateKey: jest.fn((value) => value), + serializeSessionKey: jest.fn((value) => value), + deserializeSessionKey: jest.fn((value) => { + if (value === 'badSessionKey') { + throw new Error('Bad session key'); + } + return value; + }), +})); + +describe('sharesCryptoCache', () => { + let memoryCache: MemoryCache; + let cache: ReturnType; + + const generatePrivateKey = (name: string) => { + return name as unknown as PrivateKey + } + + const generateSessionKey = (name: string) => { + return name as unknown as SessionKey + } + + beforeEach(() => { + memoryCache = new MemoryCache([]); + memoryCache.setEntity('shareKey-badKeysObject', 'aaa'); + memoryCache.setEntity('shareKey-badSessionKey', '{ "key": "aaa", "sessionKey": "badSessionKey" }'); + + cache = sharesCryptoCache(memoryCache); + }); + + it('should store and retrieve keys', async () => { + const shareId = 'newShareId'; + const keys = { key: generatePrivateKey('privateKey'), sessionKey: generateSessionKey('sessionKey') }; + + await cache.setShareKey(shareId, keys); + const result = await cache.getShareKey(shareId); + + expect(result).toStrictEqual(keys); + }); + + it('should replace and retrieve new keys', async () => { + const shareId = 'newShareId'; + const keys1 = { key: generatePrivateKey('privateKey1'), sessionKey: generateSessionKey('sessionKey1') }; + const keys2 = { key: generatePrivateKey('privateKey2'), sessionKey: generateSessionKey('sessionKey2') }; + + await cache.setShareKey(shareId, keys1); + await cache.setShareKey(shareId, keys2); + const result = await cache.getShareKey(shareId); + + expect(result).toStrictEqual(keys2); + }); + + it('should remove keys', async () => { + const shareId = 'newShareId'; + const keys = { key: generatePrivateKey('privateKey'), sessionKey: generateSessionKey('sessionKey') }; + + await cache.setShareKey(shareId, keys); + await cache.removeShareKey([shareId]); + + try { + await cache.getShareKey(shareId); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + const shareId = 'newShareId'; + + try { + await cache.getShareKey(shareId); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a bad keys and remove the key', async () => { + try { + await cache.getShareKey('badKeysObject'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Failed to deserialize share keys: Unexpected token \'a\', \"aaa\" is not valid JSON'); + } + + try { + await memoryCache.getEntity('shareKey-badKeysObject'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a bad session key and remove the key', async () => { + try { + await cache.getShareKey('badSessionKey'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Failed to deserialize share keys: Invalid share session key: Error: Bad session key'); + } + + try { + await memoryCache.getEntity('shareKey-badSessingKey'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); +}); \ No newline at end of file diff --git a/js/sdk/src/internal/shares/cryptoCache.ts b/js/sdk/src/internal/shares/cryptoCache.ts new file mode 100644 index 00000000..7c256297 --- /dev/null +++ b/js/sdk/src/internal/shares/cryptoCache.ts @@ -0,0 +1,83 @@ +import { serializePrivateKey, deserializePrivateKey, serializeSessionKey, deserializeSessionKey } from "../../crypto"; +import { ProtonDriveCache } from "../../cache"; +import { DecryptedShareCrypto } from "./interface"; + +/** + * Provides caching for share crypto material. + * + * The cache is responsible for serialising and deserialising share + * crypto material. + * + * The share crypto materials are cached so the updates to the root + * nodes can be decrypted without the need to fetch the share keys + * from the server again. Otherwise the rest of the tree requires + * only the root node, thus share cache is not needed. + */ +export function sharesCryptoCache(driveCache: ProtonDriveCache) { + async function setShareKey(shareId: string, keys: DecryptedShareCrypto) { + await driveCache.setEntity(getCacheUid(shareId), serializeShareKey(keys)); + } + + async function getShareKey(shareId: string): Promise { + const shareKeyData = await driveCache.getEntity(getCacheUid(shareId)); + try { + const keys = await deserializeShareKey(shareKeyData); + return keys; + } catch (error: unknown) { + try { + await removeShareKey([shareId]); + } catch (error: unknown) { + // TODO: log error + } + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to deserialize share keys: ${errorMessage}`); + } + } + + async function removeShareKey(shareIds: string[]) { + await driveCache.removeEntities(shareIds.map(getCacheUid)); + } + + function getCacheUid(shareId: string) { + return `shareKey-${shareId}`; + } + + function serializeShareKey(keys: DecryptedShareCrypto) { + // TODO: verify how we want to serialize keys + return JSON.stringify({ + key: serializePrivateKey(keys.key), + sessionKey: serializeSessionKey(keys.sessionKey), + }); + } + + async function deserializeShareKey(shareKeyData: string): Promise { + const result = JSON.parse(shareKeyData); + if (!result || typeof result !== 'object') { + throw new Error('Invalid share keys data'); + } + + let key, sessionKey; + + try { + key = await deserializePrivateKey(result.key); + } catch (error: any) { + throw new Error(`Invalid share private key: ${error}`); + } + try { + sessionKey = deserializeSessionKey(result.sessionKey); + } catch (error: any) { + throw new Error(`Invalid share session key: ${error}`); + } + + return { + key, + sessionKey, + }; + } + + return { + setShareKey, + getShareKey, + removeShareKey, + } +} diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts new file mode 100644 index 00000000..203ec99d --- /dev/null +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -0,0 +1,68 @@ +import { ProtonDriveAccount } from "../../interface/index.js"; +import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto/index.js"; +import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareCrypto } from "./interface.js"; + +/** + * Provides crypto operations for share keys. + * + * The share crypto service is responsible for encrypting and decrypting share + * keys. It should export high-level actions only, such as "decrypt share" + * instead of low-level operations like "decrypt share passphrase". Low-level + * operations should be kept private to the module. + * + * The service owns the logic to switch between old and new crypto model. + */ +export function sharesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriveAccount) { + async function generateVolumeBootstrap(addressKey: PrivateKey): Promise<{ + shareKey: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareCrypto }, + rootNode: { + keys: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareCrypto }, + encryptedName: string, + armoredHashKey: string, + } + }> { + const shareKey = await driveCrypto.generateKey([addressKey], addressKey); + const rootNodeKeys = await driveCrypto.generateKey([shareKey.decrypted.key], addressKey); + const { armoredNodeName } = await driveCrypto.encryptNodeName('root', shareKey.decrypted.key, addressKey); + const { armoredHashKey } = await driveCrypto.generateHashKey(rootNodeKeys.decrypted.key); + return { + shareKey, + rootNode: { + keys: rootNodeKeys, + encryptedName: armoredNodeName, + armoredHashKey, + }, + } + } + + async function decryptRootShare(share: EncryptedRootShare): Promise { + const addressPrivateKeys = await account.getOwnPrivateKeys(share.addressId); + const addressPublicKeys = await account.getPublicKeys(share.creatorEmail); + + const { key, sessionKey, verified } = await driveCrypto.decryptKey( + share.encryptedCrypto.armoredKey, + share.encryptedCrypto.armoredPassphrase, + share.encryptedCrypto.armoredPassphraseSignature, + addressPrivateKeys, + addressPublicKeys, + ) + + if (verified !== VERIFICATION_STATUS.SIGNED_AND_VALID) { + // TODO: error object and message + throw new Error('Failed to verify share passphrase'); + } + + return { + ...share, + decryptedCrypto: { + key, + sessionKey, + } + } + } + + return { + generateVolumeBootstrap, + decryptRootShare, + } +} diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts new file mode 100644 index 00000000..99320ac7 --- /dev/null +++ b/js/sdk/src/internal/shares/index.ts @@ -0,0 +1,33 @@ +import { ProtonDriveAccount } from "../../interface/index.js"; +import { DriveCrypto } from '../../crypto/index.js'; +import { DriveAPIService } from "../apiService/index.js"; +import { ProtonDriveCache } from "../../cache/index.js"; +import { sharesAPIService } from "./apiService.js"; +import { sharesCryptoCache } from "./cryptoCache.js"; +import { sharesCache } from "./cache.js"; +import { sharesCryptoService } from "./cryptoService.js"; +import { sharesManager } from "./manager.js"; + +/** + * Provides facade for the whole shares module. + * + * The shares module is responsible for handling shares metadata, including + * API communication, encryption, decryption, caching, and event handling. + * + * This facade provides internal interface that other modules can use to + * interact with the shares. + */ +export function shares( + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveCache, + driveCryptoCache: ProtonDriveCache, + account: ProtonDriveAccount, + crypto: DriveCrypto, +) { + const api = sharesAPIService(apiService); + const cache = sharesCache(driveEntitiesCache); + const cryptoCache = sharesCryptoCache(driveCryptoCache); + const cryptoService = sharesCryptoService(crypto, account); + const sharesFunctions = sharesManager(api, cache, cryptoCache, cryptoService, account); + return sharesFunctions; +} diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts new file mode 100644 index 00000000..aaf6bcef --- /dev/null +++ b/js/sdk/src/internal/shares/interface.ts @@ -0,0 +1,87 @@ +import { PrivateKey, SessionKey } from "../../crypto/index.js"; + +/** + * Internal interface providing basic identification of volume and its root + * share and node. + * + * No interface should inherit from this, this is only for composition to + * create basic volume or share interfaces. + * + * Volumes do not have necessarily share or node, but we want to always + * know what is the root share or node, thus we want to keep this for both + * volumes or any type of share. + */ +interface VolumeShareNodeIDs { + volumeId: string; + shareId: string; + rootNodeId: string; +} + +export type Volume = { + /** + * Creator email comes from the default share. + * + * The idea is to keep this information synced, so whenever we check + * cached volume information, we have creator email at hand for any + * verification checks. + */ + creatorEmail: string; +} & VolumeShareNodeIDs; + +/** + * Internal share interface. + */ +type BaseShare = { + creatorEmail: string; + /** + * Address ID is set only when user is member of the share. + * Owner or invitee of share with higher access in the tree + * might not have this field set. + */ + addressId?: string; +} & VolumeShareNodeIDs; + +interface BaseRootShare extends BaseShare { + /** + * Address ID is always available for root shares, in contrast + * to other standard shares that might not have it. See the comment + * for BaseShare. + */ + addressId: string; +} + +/** + * Interface used only internaly in the shares module. + * + * Outside of the module, the decrypted share interface should be used. + */ +export interface EncryptedShare extends BaseShare { + encryptedCrypto: EncryptedShareCrypto; +} + +/** + * Interface used only internaly in the shares module. + * + * Outside of the module, the decrypted share interface should be used. + */ +export interface EncryptedRootShare extends BaseRootShare { + encryptedCrypto: EncryptedShareCrypto; +} + +/** + * Interface holding decrypted share metadata. + */ +export interface DecryptedRootShare extends BaseRootShare { + decryptedCrypto: DecryptedShareCrypto; +} + +export interface EncryptedShareCrypto { + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; +} + +export interface DecryptedShareCrypto { + key: PrivateKey; + sessionKey: SessionKey; +} diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts new file mode 100644 index 00000000..d7d7ebac --- /dev/null +++ b/js/sdk/src/internal/shares/manager.ts @@ -0,0 +1,153 @@ +import { ProtonDriveAccount } from "../../interface/index"; +import { NotFoundAPIError } from "../apiService/index"; +import { sharesAPIService } from "./apiService"; +import { sharesCache } from "./cache"; +import { sharesCryptoCache } from "./cryptoCache"; +import { sharesCryptoService } from "./cryptoService"; + +/** + * Provides high-level actions for managing shares. + * + * The manager is responsible for handling shares metadata, including + * API communication, encryption, decryption, and caching. + * + * This module uses other modules providing low-level operations, such + * as API service, cache, crypto service, etc. + */ +export function sharesManager( + apiService: ReturnType, + cache: ReturnType, + cryptoCache: ReturnType, + cryptoService: ReturnType, + account: ProtonDriveAccount, +) { + // Cache for My files IDs. + // Those IDs are required very often, so it is better to keep them in memory. + // The IDs are not cached in the cache module, as we want to always fetch + // them from the API, and not from the cache. + const myFilesIds: { + volumeId: string; + shareId: string; + rootNodeId: string; + } | null = null; + + /** + * It returns the IDs of the My files section. + * + * If the default volume or My files section doesn't exist, it creates it. + */ + async function getMyFilesIDs() { + if (myFilesIds) { + return myFilesIds; + } + + try { + const encryptedShare = await apiService.getMyFiles(); + + // Once any place needs IDs for My files, it will most likely + // need also the keys for decrypting the tree. It is better to + // decrypt the share here right away. + const myFilesShare = await cryptoService.decryptRootShare(encryptedShare); + await cryptoCache.setShareKey(myFilesShare.shareId, myFilesShare.decryptedCrypto); + await cache.setVolume({ + volumeId: myFilesShare.volumeId, + shareId: myFilesShare.shareId, + rootNodeId: myFilesShare.rootNodeId, + creatorEmail: myFilesShare.creatorEmail, + }); + + return { + volumeId: myFilesShare.volumeId, + shareId: myFilesShare.shareId, + rootNodeId: myFilesShare.rootNodeId, + }; + + } catch (error: any) { + if (error instanceof NotFoundAPIError) { + return createVolume(); + } + throw error; + } + } + + /** + * Creates new default volume for the user. + * + * It generates the volume bootstrap, creates the volume on the server, + * and caches the volume metadata. + * + * User can have only one default volume. + * + * @throws If the volume cannot be created (e.g., one already exists). + */ + async function createVolume() { + const { email, addressKey, addressId, addressKeyId } = await account.getOwnPrimaryKey(); + const bootstrap = await cryptoService.generateVolumeBootstrap(addressKey); + const myFilesIds = await apiService.createVolume( + { + addressId, + addressKeyId, + ...bootstrap.shareKey.encrypted, + }, + { + ...bootstrap.rootNode.keys.encrypted, + encryptedName: bootstrap.rootNode.encryptedName, + armoredHashKey: bootstrap.rootNode.armoredHashKey, + }, + ); + await cryptoCache.setShareKey(myFilesIds.shareId, bootstrap.shareKey.decrypted); + return myFilesIds; + } + + /** + * It is a high-level action that retrieves the private key for a share. + * If prefers to use the cache, but if the key is not there, it fetches + * the share from the API, decrypts it, and caches it. + * + * @param shareId - The ID of the share. + * @returns The private key for the share. + * @throws If the share is not found or cannot be decrypted, or cached. + */ + async function getSharePrivateKey(shareId: string) { + const keys = await cryptoCache.getShareKey(shareId); + if (keys) { + return keys.key; + } + + const encryptedShare = await apiService.getRootShare(shareId); + const share = await cryptoService.decryptRootShare(encryptedShare); + await cryptoCache.setShareKey(share.shareId, share.decryptedCrypto); + return share.decryptedCrypto.key; + } + + async function getVolumeEmailKey(volumeId: string) { + const volume = await cache.getVolume(volumeId); + if (volume) { + return { + email: volume.creatorEmail, + key: await account.getOwnPrivateKey(volume.creatorEmail), + }; + } + + const { shareId } = await apiService.getVolume(volumeId); + const share = await apiService.getShare(shareId); + + await cache.setVolume({ + volumeId: share.volumeId, + shareId: share.shareId, + rootNodeId: share.rootNodeId, + creatorEmail: share.creatorEmail, + }); + + return { + email: share.creatorEmail, + key: await account.getOwnPrivateKey(share.creatorEmail), + }; + } + + return { + getMyFilesIDs, + getSharePrivateKey, + getVolumeEmailKey, + } +} diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts new file mode 100644 index 00000000..aa7f148d --- /dev/null +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -0,0 +1,31 @@ +import { DriveAPIService } from "../apiService/index.js"; + +export function sharingAPIService(apiService: DriveAPIService) { + // TODO: types + async function *iterateSharedNodes(volumeId: string): any { + // TODO: /drive/v2/volumes/{volumeID}/shares + } + + async function *iterateSharedWithMe(): any { + // TODO: /drive/v2/sharedwithme + } + + async function *iterateInvitations() { + // TODO: /drive/v2/shares/invitations + } + + async function *iterateBookmarks() { + // TODO: /drive/v2/shared-bookmarks + } + + async function inviteProtonUser(object: any) { + } + + return { + iterateSharedNodes, + iterateSharedWithMe, + iterateInvitations, + iterateBookmarks, + inviteProtonUser, + } +} diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts new file mode 100644 index 00000000..0f04b34e --- /dev/null +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -0,0 +1,44 @@ +import { PrivateKey } from '../../crypto/index'; + +import { ProtonDriveAccount } from "../../interface/index.js"; +import { DriveCrypto } from "../../crypto/index.js"; + +export function sharingCryptoService(driveCrypto: DriveCrypto, account: ProtonDriveAccount) { + // TODO: types + async function generateKeys(nodeKey: PrivateKey, addressKey: PrivateKey): Promise { + return driveCrypto.generateKey([nodeKey, addressKey], addressKey); + }; + + // TODO: types + async function decryptShareKeys(share: any, nodeKey: PrivateKey): Promise { + // TODO: use correct address keys + const addressPrivateKeys = await account.getOwnPrivateKeys(share.addressId); + const addressPublicKeys = await account.getPublicKeys(share.creatorEmail); + + // TODO: use verified + const { key, sessionKey } = await driveCrypto.decryptKey( + share.encryptedCrypto.armoredKey, + share.encryptedCrypto.armoredPassphrase, + share.encryptedCrypto.armoredPassphraseSignature, + addressPrivateKeys, + addressPublicKeys, + ) + return { + key, + sessionKey, + } + } + + // TODO: types + async function encryptInvitation(email: string): Promise { + // TODO + const publicKey = await account.getPublicKeys(email); + return publicKey; + }; + + return { + generateKeys, + decryptShareKeys, + encryptInvitation, + } +} diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts new file mode 100644 index 00000000..18d41a5d --- /dev/null +++ b/js/sdk/src/internal/sharing/index.ts @@ -0,0 +1,80 @@ +import { ProtonDriveAccount, ShareNodeSettings, ShareRole, ShareResult, UnshareNodeSettings } from "../../interface/index.js"; +import { DriveCrypto } from '../../crypto/index.js'; +import { DriveAPIService } from "../apiService/index.js"; +import { sharingAPIService } from "./apiService.js"; +import { sharingCryptoService } from "./cryptoService.js"; +import { sharingAccess } from "./sharingAccess.js"; +import { sharingManagement } from "./sharingManagement.js"; +import { NodesService } from "./interface.js"; + +export function sharing( + apiService: DriveAPIService, + account: ProtonDriveAccount, + crypto: DriveCrypto, + nodesService: NodesService, +) { + const api = sharingAPIService(apiService); + const cryptoService = sharingCryptoService(crypto, account); + const sharingAccessFunctions = sharingAccess(api, cryptoService, nodesService); + const sharingManagementFunctions = sharingManagement(api, cryptoService, account); + + // TODO: facade to convert high-level interface with object to low-level calls + async function shareNode(nodeUid: string, settings: ShareNodeSettings) { + let currentSharing = await sharingManagementFunctions.getSharingInfo(nodeUid); + if (!currentSharing) { + currentSharing = await sharingManagementFunctions.createShare(nodeUid); + } + + for (const user of settings.protonUsers || []) { + const { email, role } = typeof user === "string" ? { email: user, role: ShareRole.VIEW } : user; + if (currentSharing.protonInitations[email]) { + if (currentSharing.protonInitations[email].role === role) { + continue; + } + sharingManagementFunctions.updateInvitationPermissions(currentSharing.shareId, currentSharing.protonUsers[email].invitationId, role); + continue; + } + sharingManagementFunctions.inviteProtonUser(currentSharing.shareId, email, role); + } + // TODO: return all the objects + return {} as ShareResult; + } + + async function unshareNode(nodeUid: string, settings?: UnshareNodeSettings) { + const currentSharing = await sharingManagementFunctions.getSharingInfo(nodeUid); + if (!currentSharing) { + return; + } + if (!settings) { + return sharingManagementFunctions.deleteShare(currentSharing.shareId); + } + if (settings.publicLink === 'remove') { + await sharingManagementFunctions.removeSharedLink(currentSharing.shareId); + } + for (const user of settings.users || []) { + const invitationId = currentSharing.protonInitations[user]?.invitationId; + if (invitationId) { + sharingManagementFunctions.deleteInvitation(currentSharing.shareId, invitationId); + continue; + } + const externalInvitationId = currentSharing.nonProtonInvitations[user]?.invitationId; + if (externalInvitationId) { + sharingManagementFunctions.deleteExternalInvitation(currentSharing.shareId, externalInvitationId); + continue; + } + const memberId = currentSharing.members[user]?.memberId; + if (memberId) { + sharingManagementFunctions.removeMember(currentSharing.shareId, memberId); + continue; + } + } + // TODO: return all the objects + return {} as ShareResult; + } + + return { + ...sharingAccessFunctions, + shareNode, + unshareNode, + } +} diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts new file mode 100644 index 00000000..c140d552 --- /dev/null +++ b/js/sdk/src/internal/sharing/interface.ts @@ -0,0 +1,3 @@ +export interface NodesService { + getNode(nodeUid: string): Promise, +} diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts new file mode 100644 index 00000000..8476104d --- /dev/null +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -0,0 +1,43 @@ +import { ProtonDriveAccount } from "../../interface/index.js"; +import { sharingAPIService } from "./apiService.js"; +import { sharingCryptoService } from "./cryptoService.js"; +import { NodesService } from "./interface.js"; + +export function sharingAccess( + apiService: ReturnType, + cryptoService: ReturnType, + nodesService: NodesService, +) { + async function* iterateSharedNodes() { + // TODO: get volume from shares module + const volumeId = 'myFiles'; + for await (const sharedNode of apiService.iterateSharedNodes(volumeId)) { + yield await nodesService.getNode(sharedNode.nodeUid); + } + } + + async function* iterateSharedNodesWithMe() { + for await (const sharedNode of apiService.iterateSharedWithMe()) { + yield await nodesService.getNode(sharedNode.nodeUid); + } + } + + async function* iterateInvitations() { + for await (const invitation of apiService.iterateInvitations()) { + yield invitation; + } + } + + async function* iterateSharedBookmarks() { + for await (const bookmark of apiService.iterateBookmarks()) { + yield bookmark; + } + } + + return { + iterateSharedNodes, + iterateSharedNodesWithMe, + iterateInvitations, + iterateSharedBookmarks, + } +} diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts new file mode 100644 index 00000000..f422d559 --- /dev/null +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -0,0 +1,61 @@ +import { ProtonDriveAccount, ShareRole } from "../../interface/index.js"; +import { sharingAPIService } from "./apiService.js"; +import { sharingCryptoService } from "./cryptoService.js"; + +export function sharingManagement( + apiService: ReturnType, + cryptoService: ReturnType, + account: ProtonDriveAccount, +) { + async function createShare(nodeUid: string): Promise {} + async function deleteShare(shareId: string): Promise {} + async function getSharingInfo(shareId: string): Promise {} + + // Direct invitations + async function inviteProtonUser(shareId: string, email: string, role: ShareRole): Promise { + const invitation = await cryptoService.encryptInvitation(email); + await apiService.inviteProtonUser({ invitation }); + } + async function updateInvitationPermissions(shareId: string, invitationId: string, role: ShareRole): Promise {} + async function resendInvitationEmail(shareId: string, invitationId: string): Promise {} + async function deleteInvitation(shareId: string, invitationId: string): Promise {} + + // Direct external invitations + async function inviteExternalUser(shareId: string, email: string, role: ShareRole): Promise {} + async function updateExternalInvitationPermissions(shareId: string, invitationId: string, role: ShareRole): Promise {} + async function resendExternalInvitationEmail(shareId: string, invitationId: string): Promise {} + async function deleteExternalInvitation(shareId: string, invitationId: string): Promise {} + + async function convertExternalInvitationsToInternal(): Promise {} + + // Direct members + async function removeMember(shareId: string, memberId: string): Promise {} + async function updateMemberPermissions(shareId: string, memberId: string): Promise {} + + // For URL + async function shareViaLink(nodeUid: string): Promise {} + async function updateSharedLink(nodeUid: string, options: any): Promise {} + async function getPublicLink(nodeUid: string): Promise {} + async function removeSharedLink(nodeUid: string): Promise {} + + return { + createShare, + deleteShare, + getSharingInfo, + inviteProtonUser, + updateInvitationPermissions, + resendInvitationEmail, + deleteInvitation, + inviteExternalUser, + updateExternalInvitationPermissions, + resendExternalInvitationEmail, + deleteExternalInvitation, + convertExternalInvitationsToInternal, + removeMember, + updateMemberPermissions, + shareViaLink, + updateSharedLink, + getPublicLink, + removeSharedLink, + } +} diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts new file mode 100644 index 00000000..c0f85956 --- /dev/null +++ b/js/sdk/src/internal/upload/apiService.ts @@ -0,0 +1,10 @@ +import { DriveAPIService } from "../apiService/index.js"; + +export function uploadAPIService(apiService: DriveAPIService) { + async function createDraft(parentNodeUid: string, name: string): Promise { + } + + return { + createDraft, + } +} diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts new file mode 100644 index 00000000..8850b477 --- /dev/null +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -0,0 +1,14 @@ +import { DriveCrypto } from "../../crypto/index.js"; + +export function uploadCryptoService(driveCrypto: DriveCrypto) { + // TODO: types + async function generateKeys(parentKey: any) { + }; + + async function generateHash() { + }; + + return { + generateKeys, + } +} diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts new file mode 100644 index 00000000..c9364a79 --- /dev/null +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -0,0 +1,36 @@ +import { Thumbnail } from "../../interface/index.js"; + +export class Fileuploader { + private controller: UploadController; + + constructor(queue: any, nodeKey: any, draft: any) { + this.controller = new UploadController(draft.nodeUid); + } + + writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController { + // TODO + return this.controller; + } + writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController { + // TODO + return this.controller; + } +} + +class UploadController { + private draftNodeUid: string; + + constructor(draftNodeUid: string) { + this.draftNodeUid = draftNodeUid; + } + + pause(): void {} + + resume(): void {} + + async completion(): Promise { + // TODO: wait for upload to be finished + // TODO: once completed, its not draft anymore + return this.draftNodeUid; + } +} diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts new file mode 100644 index 00000000..0dbc511c --- /dev/null +++ b/js/sdk/src/internal/upload/index.ts @@ -0,0 +1,44 @@ +import { DriveAPIService } from "../apiService/index.js"; +import { DriveCrypto } from "../../crypto/index.js"; +import { uploadAPIService } from "./apiService.js"; +import { uploadCryptoService } from "./cryptoService.js"; +import { UploadQueue } from "./queue.js"; +import { NodesService } from "./interface.js"; +import { Fileuploader } from "./fileUploader.js"; + +type UploadMetadata = { + mimeType: string, + expectedSize: number, + additionalMetadata?: object, +} + +export function upload( + apiService: DriveAPIService, + driveCrypto: DriveCrypto, + nodesService: NodesService, +) { + const api = uploadAPIService(apiService); + const cryptoService = uploadCryptoService(driveCrypto); + + const queue = new UploadQueue(); + + async function getFileUploader( + parentFolderUid: string, + name: string, + metadata: UploadMetadata, + signal?: AbortSignal + ) { + await queue.waitForCapacity(metadata.expectedSize, signal); + const parentKey = nodesService.getNodeKeys(parentFolderUid); + const nodeKeys = cryptoService.generateKeys(parentKey); + // TODO: encrypt name etc. + const draft = api.createDraft(parentFolderUid, name); + return new Fileuploader(queue, nodeKeys, draft); + } + + return { + getFileUploader, + } +} + + diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts new file mode 100644 index 00000000..d9960a33 --- /dev/null +++ b/js/sdk/src/internal/upload/interface.ts @@ -0,0 +1,3 @@ +export interface NodesService { + getNodeKeys(nodeUid: string): Promise, +} diff --git a/js/sdk/src/internal/upload/queue.ts b/js/sdk/src/internal/upload/queue.ts new file mode 100644 index 00000000..230886f3 --- /dev/null +++ b/js/sdk/src/internal/upload/queue.ts @@ -0,0 +1,7 @@ +export class UploadQueue { + constructor() { + } + + async waitForCapacity(expectedSize: number, signal?: AbortSignal) { + } +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts new file mode 100644 index 00000000..de2fd0f1 --- /dev/null +++ b/js/sdk/src/protonDriveClient.ts @@ -0,0 +1,57 @@ +import { getApiService } from './internal/apiService/index.js' +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, ShareNodeSettings, UploadMetadata } from './interface/index.js'; +import { driveCrypto } from './crypto/index.js' +import { nodes as nodesModule } from './internal/nodes/index.js' +import { shares as sharesModule } from './internal/shares/index.js' +import { sharing as sharingModule } from './internal/sharing/index.js' +import { events as eventsModule } from './internal/events/index.js' +import { upload as uploadModule } from './internal/upload/index.js'; +import { getConfig } from './config.js'; +import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers.js'; + +export function protonDriveClient({ + httpClient, + entitiesCache, + cryptoCache, + account, + getLogger, + config, + metrics, + openPGPCryptoModule, + acceptNoGuaranteeWithCustomModules, +}: ProtonDriveClientContructorParameters): Partial { + if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { + // TODO: define errors and use here + throw Error('TODO'); + } + const cryptoModule = driveCrypto(openPGPCryptoModule); + + const fullConfig = getConfig(config); + + const apiService = getApiService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + + const events = eventsModule(apiService); + const shares = sharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); + const nodes = nodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); + const sharing = sharingModule(apiService, account, cryptoModule, nodes); + const upload = uploadModule(apiService, cryptoModule, nodes); + + return { + getNodeUid: (shareId: string, nodeId: string) => Promise.resolve(""), + getMyFilesRootFolder: () => { + return convertInternalNodePromise(nodes.getMyFilesRootFolder()); + }, + iterateChildren: (parentNodeUid: NodeOrUid, signal?: AbortSignal) => { + return convertInternalNodeIterator(nodes.iterateChildren(getUid(parentNodeUid), signal)); + }, + iterateNodes: (nodeUids: NodeOrUid[], signal?: AbortSignal) => { + return convertInternalNodeIterator(nodes.iterateNodes(getUids(nodeUids), signal)); + }, + shareNode: (nodeUid: NodeOrUid, settings: ShareNodeSettings) => { + return sharing.shareNode(getUid(nodeUid), settings); + }, + getFileUploader: (nodeUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) => { + return upload.getFileUploader(getUid(nodeUid), name, metadata, signal); + } + } +} diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts new file mode 100644 index 00000000..4d6590ac --- /dev/null +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -0,0 +1,18 @@ +export const protonDrivePhotosClient = () => { + // TODO: this is only example, on background it use drive internals, but it exposes nice interface for photos + return { + // Timeline or album view + iterateTimelinePhotos: () => {}, // returns only UIDs and dates - used to show grid and scrolling + iterateAlbumPhotos: () => {}, // same as above but for album + iterateThumbnails: () => {}, // returns thumbnails for passed photos that are visible in the UI + getPhoto: () => {}, // returns full photo details + + // Album management + createAlbum: () => {}, + renameAlbum: () => {}, + shareAlbum: () => {}, + deleteAlbum: () => {}, + iterateAlbums: () => {}, + addPhotosToAlbum: () => {}, + } +} diff --git a/js/sdk/src/protonDrivePublicClient.ts b/js/sdk/src/protonDrivePublicClient.ts new file mode 100644 index 00000000..9cf318d1 --- /dev/null +++ b/js/sdk/src/protonDrivePublicClient.ts @@ -0,0 +1,49 @@ +import { getApiService } from './internal/apiService/index.js'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, NodeEntity } from './interface/index.js'; +import { driveCrypto } from './crypto/index.js'; +import { publicNodes as publicNodesModule } from './internal/nodes/index.js'; +import { shares as sharesModule } from './internal/shares/index.js'; +import { getConfig } from './config.js'; +import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers.js'; + +interface ProtonDrivePublicClientInterface extends Partial { + getPublicRootNode(token: string, password: string, customPassword?: string): Promise, +} + +export function protonDrivePublicClient({ + httpClient, + entitiesCache, + cryptoCache, + account, + getLogger, + config, + metrics, + openPGPCryptoModule, + acceptNoGuaranteeWithCustomModules, +}: ProtonDriveClientContructorParameters): ProtonDrivePublicClientInterface { + if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { + // TODO: define errors and use here + throw Error('TODO'); + } + const cryptoModule = driveCrypto(openPGPCryptoModule); + + const fullConfig = getConfig(config); + + const apiService = getApiService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + + // TODO: public sharing module + const publicShares = sharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); + const nodes = publicNodesModule(apiService, entitiesCache, cryptoCache, cryptoModule, publicShares); + + return { + getPublicRootNode: (token: string, password: string, customPassword?: string) => { + return convertInternalNodePromise(nodes.getPublicRootNode(token, password, customPassword)); + }, + iterateChildren: (parentNodeUid: NodeOrUid, signal?: AbortSignal) => { + return convertInternalNodeIterator(nodes.iterateChildren(getUid(parentNodeUid), signal)); + }, + iterateNodes: (nodeUids: NodeOrUid[], signal?: AbortSignal) => { + return convertInternalNodeIterator(nodes.iterateNodes(getUids(nodeUids), signal)); + }, + } +} diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts new file mode 100644 index 00000000..ded14b8f --- /dev/null +++ b/js/sdk/src/transformers.ts @@ -0,0 +1,31 @@ +import { NodeOrUid, NodeEntity } from './interface/index.js'; + +export function getUid(nodeUid: NodeOrUid): string { + if (typeof nodeUid === "string") { + return nodeUid; + } + return nodeUid.uid; +} + +export function getUids(nodeUids: NodeOrUid[]): string[] { + return nodeUids.map(getUid); +} + +// TODO: type +export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { + for await (const node of nodeIterator) { + yield convertInternalNode(node); + } +} + +// TODO: type +export async function convertInternalNodePromise(nodePromise: Promise): Promise { + const node = await nodePromise; + return convertInternalNode(node); +} + +// TODO: type +export function convertInternalNode(node: any): NodeEntity { + // TODO: implement + return {} as NodeEntity +} diff --git a/js/sdk/tsconfig.json b/js/sdk/tsconfig.json new file mode 100644 index 00000000..b7399cb0 --- /dev/null +++ b/js/sdk/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "allowJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "noImplicitAny": true, + // Many variables are unused during prototyping - uncomment later once more modules are implemented. + //"noUnusedLocals": true, + "strict": true, + "skipLibCheck": true, + "target": "esnext", + }, + "include": [ + "src/**/*.ts", + "typings/index.d.ts", + ], + "exclude": [ + "**/node_modules/*", + "**/coreTypes.ts", + "**/driveTypes.ts" + ], +} diff --git a/js/sdk/typings/index.d.ts b/js/sdk/typings/index.d.ts new file mode 100644 index 00000000..76fe5848 --- /dev/null +++ b/js/sdk/typings/index.d.ts @@ -0,0 +1,2 @@ +// TODO: Problem with importing pmcrypto - md5.js has no typing +declare module '*'; From 24721d1b0dff7fc4e1b9230f4bb80ee39b7e3519 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Jan 2025 12:12:49 +0000 Subject: [PATCH 002/791] Enable linter for any or unused variables for implemented parts --- js/sdk/.eslintrc.js | 37 +++++++++++++------ js/sdk/src/cache/memoryCache.ts | 4 +- js/sdk/src/interface/constructor.ts | 8 ++-- js/sdk/src/interface/download.ts | 4 +- js/sdk/src/interface/nodes.ts | 9 +++-- js/sdk/src/internal/apiService/errors.ts | 7 ++-- js/sdk/src/internal/nodes/apiService.ts | 14 +++---- js/sdk/src/internal/nodes/cache.ts | 20 +++++----- js/sdk/src/internal/nodes/cryptoCache.test.ts | 2 +- js/sdk/src/internal/nodes/cryptoCache.ts | 14 +++---- js/sdk/src/internal/nodes/index.ts | 3 ++ js/sdk/src/internal/nodes/manager.ts | 4 +- js/sdk/src/internal/shares/apiService.ts | 4 +- js/sdk/src/internal/shares/cache.ts | 8 ++-- .../src/internal/shares/cryptoCache.test.ts | 2 +- js/sdk/src/internal/shares/cryptoCache.ts | 10 ++--- js/sdk/src/internal/shares/manager.ts | 4 +- js/sdk/src/protonDriveClient.ts | 4 +- js/sdk/src/protonDrivePublicClient.ts | 2 +- js/sdk/src/transformers.ts | 28 +++++++++----- 20 files changed, 109 insertions(+), 79 deletions(-) diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index 8c3b98a4..94767af6 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -1,23 +1,36 @@ module.exports = { extends: [ - 'plugin:@typescript-eslint/recommended' + 'plugin:@typescript-eslint/recommended' ], parser: '@typescript-eslint/parser', parserOptions: { - tsconfigRootDir: __dirname, - project: "./tsconfig.json", - ecmaVersion: 2018, - sourceType: "module" + tsconfigRootDir: __dirname, + project: "./tsconfig.json", + ecmaVersion: 2018, + sourceType: "module" }, rules: { - "tsdoc/syntax": "warn", - // Any is used during prototyping - remove once all the types are available to fix all the places. - "@typescript-eslint/no-explicit-any": "off", - // Many variables are unused during prototyping - remove later once more modules are implemented. - "@typescript-eslint/no-unused-vars": "off", + "tsdoc/syntax": "warn", }, + overrides: [ + { + files: [ + "*.test.ts", + "**/nodes/events.ts", + "**/events/**/*", + "**/sharing/**/*", + "**/upload/**/*", + ], + rules: { + // Any is used during prototyping - remove once all the types are available to fix all the places. + "@typescript-eslint/no-explicit-any": "off", + // Many variables are unused during prototyping - remove later once more modules are implemented. + "@typescript-eslint/no-unused-vars": "off", + }, + }, + ], plugins: [ - "@typescript-eslint/eslint-plugin", - "eslint-plugin-tsdoc" + "@typescript-eslint/eslint-plugin", + "eslint-plugin-tsdoc" ] }; diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index edb8b6b5..7dab2cbb 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -81,8 +81,8 @@ export class MemoryCache implements ProtonDriveCache { async removeEntities(uids: string[]) { for (const uid of uids) { delete this.entities[uid]; - Object.entries(this.entitiesByTag).forEach(([ key, tag ]) => { - Object.entries(tag).forEach(([ value, uids ]) => { + Object.values(this.entitiesByTag).forEach((tag) => { + Object.values(tag).forEach((uids) => { const index = uids.indexOf(uid); if (index !== -1) { uids.splice(index, 1); diff --git a/js/sdk/src/interface/constructor.ts b/js/sdk/src/interface/constructor.ts index ee2e0eff..2c26a60f 100644 --- a/js/sdk/src/interface/constructor.ts +++ b/js/sdk/src/interface/constructor.ts @@ -15,10 +15,10 @@ export interface ProtonDriveHTTPClient { export type GetLogger = (name: string) => Logger; export interface Logger { - debug(msg: string, ...x: any[]): void; - info(msg: string, ...x: any[]): void; - warn(msg: string, ...x: any[]): void; - error(msg: string, ...x: any[]): void; + debug(msg: string, ...x: any[]): void; // eslint-disable-line @typescript-eslint/no-explicit-any + info(msg: string, ...x: any[]): void; // eslint-disable-line @typescript-eslint/no-explicit-any + warn(msg: string, ...x: any[]): void; // eslint-disable-line @typescript-eslint/no-explicit-any + error(msg: string, ...x: any[]): void; // eslint-disable-line @typescript-eslint/no-explicit-any } export type ProtonDriveConfig = { diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index 23016535..98dcb2d8 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -12,8 +12,8 @@ export interface Download { export interface FileDownloader { getClaimedSizeInBytes(): number, - writeToStream(streamFactory: WritableStream | any, onProgress: (uploadedBytes: number) => void): DownloadController, - unsafeWriteToStream(streamFactory: WritableStream | any, onProgress: (uploadedBytes: number) => void): DownloadController, + writeToStream(streamFactory: WritableStream, onProgress: (uploadedBytes: number) => void): DownloadController, + unsafeWriteToStream(streamFactory: WritableStream, onProgress: (uploadedBytes: number) => void): DownloadController, } export interface DownloadController { diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 18defe23..c6205175 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -3,13 +3,13 @@ import { Result } from './result.js'; // Note: Node is reserved by JS/DOM, thus we need exception how the entity is called export type NodeEntity = { uid: string, - parentUid: string, + parentUid?: string, name: Result, - author: Result, + keyAuthor: Result, nameAuthor: Result, directMemberRole: MemberRole, type: NodeType, - mimeType: string, + mimeType?: string, isShared: boolean, createdDate: Date, // created on server date trashedDate?: Date, @@ -87,4 +87,5 @@ export interface Revisions { } export type NodesResults = { processedNodeIds: string[], errors: NodeErrorResult[] }; -export type NodeErrorResult = { nodeId: string, error: any }; +// TODO: fix type - will be solved by converting to different structure +export type NodeErrorResult = { nodeId: string, error: any }; // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 55a5afd6..5e9f8961 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -1,12 +1,13 @@ import { ErrorCode } from './errorCodes'; -export function apiErrorFactory({ response, result }: { response: Response, result?: any }): APIError { +export function apiErrorFactory({ response, result }: { response: Response, result?: unknown }): APIError { if (!result) { return new APIHTTPError(response.statusText, response.status); } - const code = result.Code; - const message = result.Error; + // @ts-expect-error: Result from API can be any JSON that might not have + // error or code set which next lines should handle. + const [code, message] = [result.Code || 0, result.Error || "Unknown error"]; switch (code) { case ErrorCode.NOT_EXISTS: return new NotFoundAPIError(message, code); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 6cb9758d..32a66eec 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -3,29 +3,29 @@ import { DriveAPIService, drivePaths } from "../apiService"; import { splitNodeUid, makeNodeUid } from "./nodeUid"; import { EncryptedNode } from "./interface"; -type PostLoadLinksMetadataRequest = Extract['content']['application/json']; +type PostLoadLinksMetadataRequest = Extract['content']['application/json']; type PostLoadLinksMetadataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; type GetChildrenResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders/{linkID}/children']['get']['responses']['200']['content']['application/json']; type GetTrashedNodesResponse = drivePaths['/drive/volumes/{volumeID}/trash']['get']['responses']['200']['content']['application/json']; -type PutRenameNodeRequest = Extract['content']['application/json']; +type PutRenameNodeRequest = Extract['content']['application/json']; type PutRenameNodeResponse = drivePaths['/drive/v2/volumes/{volumeId}/links/{linkID}/rename']['put']['responses']['200']['content']['application/json']; -type PutMoveNodeRequest = Extract['content']['application/json']; +type PutMoveNodeRequest = Extract['content']['application/json']; type PutMoveNodeResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/move']['put']['responses']['200']['content']['application/json']; -type PostTrashNodesRequest = Extract['content']['application/json']; +type PostTrashNodesRequest = Extract['content']['application/json']; type PostTrashNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders/{linkID}/trash_multiple']['post']['responses']['200']['content']['application/json']; -type PutRestoreNodesRequest = Extract['content']['application/json']; +type PutRestoreNodesRequest = Extract['content']['application/json']; type PutRestoreNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/restore_multiple']['put']['responses']['200']['content']['application/json']; -type PostDeleteNodesRequest = Extract['content']['application/json']; +type PostDeleteNodesRequest = Extract['content']['application/json']; type PostDeleteNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['responses']['200']['content']['application/json']; -type PostCreateFolderRequest = Extract['content']['application/json']; +type PostCreateFolderRequest = Extract['content']['application/json']; type PostCreateFolderResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['responses']['200']['content']['application/json']; /** diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index cc287cbc..47279139 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -36,9 +36,9 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { const nodeData = await driveCache.getEntity(key); try { return deserialiseNode(nodeData); - } catch (error: any) { + } catch (error: unknown) { removeCorruptedNode({ nodeUid }, error); - throw new Error(`Failed to deserialise node: ${error.message}`) + throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`) } } @@ -48,19 +48,19 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { * nodes and rather let SDK re-fetch them than to auotmatically * fix issues and do not bother user with it. */ - async function removeCorruptedNode({ nodeUid, cacheUid }: { nodeUid?: string, cacheUid?: string }, corruptionError: any) { - logger?.error(`Removing corrupted nodes from the cache: ${corruptionError.message}`); + async function removeCorruptedNode({ nodeUid, cacheUid }: { nodeUid?: string, cacheUid?: string }, corruptionError: unknown) { + logger?.error(`Removing corrupted nodes from the cache: ${corruptionError instanceof Error ? corruptionError.message : corruptionError}`); try { if (nodeUid) { await removeNodes([nodeUid]); } else if (cacheUid) { await driveCache.removeEntities([cacheUid]); } - } catch (removingError: any) { + } catch (removingError: unknown) { // The node will not be returned, thus SDK will re-fetch // and re-cache it. Setting it again should then fix the // problem. - logger?.warn(`Failed to remove corrupted node from the cache: ${removingError.message}`); + logger?.warn(`Failed to remove corrupted node from the cache: ${removingError instanceof Error ? removingError.message : removingError}`); } } @@ -75,9 +75,9 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { // if removing nodes fails. childrenCacheUids.reverse(); await driveCache.removeEntities(childrenCacheUids); - } catch (error: any) { + } catch (error: unknown) { // TODO: Should we throw here to the client? - logger?.error(`Failed to remove children from the cache: ${error.message}`); + logger?.error(`Failed to remove children from the cache: ${error instanceof Error ? error.message : error}`); } } } @@ -132,7 +132,7 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { let nodeUid; try { nodeUid = getNodeUid(result.uid); - } catch (error: any) { + } catch (error: unknown) { await removeCorruptedNode({ cacheUid: result.uid }, error) return null; } @@ -140,7 +140,7 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { let node; try { node = deserialiseNode(result.data) - } catch (error: any) { + } catch (error: unknown) { await removeCorruptedNode({ nodeUid }, error); return null; } diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index 46191692..19fd35a0 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -103,7 +103,7 @@ describe('nodesCryptoCache', () => { await cache.getNodeKeys('badSessionKey'); throw new Error('Should have thrown an error'); } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialize node keys: Invalid node session key: Error: Bad session key'); + expect(`${error}`).toBe('Error: Failed to deserialize node keys: Invalid node session key: Bad session key'); } try { diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts index 34b4345d..a6a222f2 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -23,7 +23,7 @@ export function nodesCryptoCache(driveCache: ProtonDriveCache) { } catch (error: unknown) { try { await removeNodeKeys([nodeUid]); - } catch (error: unknown) { + } catch { // TODO: log error } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -64,18 +64,18 @@ export function nodesCryptoCache(driveCache: ProtonDriveCache) { const passphrase = result.passphrase; try { key = await deserializePrivateKey(result.key); - } catch (error: any) { - throw new Error(`Invalid node private key: ${error}`); + } catch (error: unknown) { + throw new Error(`Invalid node private key: ${error instanceof Error ? error.message : error}`); } try { sessionKey = deserializeSessionKey(result.sessionKey); - } catch (error: any) { - throw new Error(`Invalid node session key: ${error}`); + } catch (error: unknown) { + throw new Error(`Invalid node session key: ${error instanceof Error ? error.message : error}`); } try { hashKey = result.hashKey ? deserializeHashKey(result.hashKey) : undefined; - } catch (error: any) { - throw new Error(`Invalid node hash key: ${error}`); + } catch (error: unknown) { + throw new Error(`Invalid node hash key: ${error instanceof Error ? error.message : error}`); } return { diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 452edaae..af30503b 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -12,6 +12,8 @@ import { SharesService, DecryptedNode } from "./interface"; import { nodesAccess } from "./nodesAccess"; import { nodesManager } from "./manager"; +export type { DecryptedNode } from "./interface"; + /** * Provides facade for the whole nodes module. * @@ -65,6 +67,7 @@ export function publicNodes( return { // TODO: use public root node, not my files + // eslint-disable-next-line @typescript-eslint/no-unused-vars getPublicRootNode: async (token: string, password: string, customPassword?: string): Promise => { return {} as DecryptedNode }, getNode: nodesAccessFunctions.getNode, getNodeKeys: nodesAccessFunctions.getNodeKeys, diff --git a/js/sdk/src/internal/nodes/manager.ts b/js/sdk/src/internal/nodes/manager.ts index 60822841..c86df83e 100644 --- a/js/sdk/src/internal/nodes/manager.ts +++ b/js/sdk/src/internal/nodes/manager.ts @@ -335,7 +335,7 @@ export function nodesManager( */ class BatchNodesLoading { private nodesToFetch: string[]; - private loadNodes: (nodeUids: string[]) => Promise; + private loadNodes: (nodeUids: string[], signal?: AbortSignal) => Promise; constructor(loadNodes: (nodeUids: string[]) => Promise) { this.nodesToFetch = []; @@ -346,7 +346,7 @@ class BatchNodesLoading { this.nodesToFetch.push(nodeUid); if (this.nodesToFetch.length >= BATCH_LOADING) { - const nodes = await this.loadNodes(this.nodesToFetch); + const nodes = await this.loadNodes(this.nodesToFetch, signal); for (const node of nodes) { yield node; } diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index e693bfc5..773e8e4d 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -1,10 +1,10 @@ import { DriveAPIService, drivePaths } from "../apiService/index.js"; import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto } from "./interface.js"; -type PostCreateVolumeRequest = Extract['content']['application/json']; +type PostCreateVolumeRequest = Extract['content']['application/json']; type PostCreateVolumeResponse = drivePaths['/drive/volumes']['post']['responses']['200']['content']['application/json']; -type PostCreateShareRequest = Extract['content']['application/json']; +type PostCreateShareRequest = Extract['content']['application/json']; type PostCreateShareResponse = drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; type GetMyFilesResponse = drivePaths['/drive/v2/shares/my-files']['get']['responses']['200']['content']['application/json']; diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index 76ce93f9..d70ae583 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveCache, EntityResult } from "../../cache/index.js"; +import { ProtonDriveCache } from "../../cache/index.js"; import { Volume } from "./interface.js"; /** @@ -19,13 +19,13 @@ export function sharesCache(driveCache: ProtonDriveCache) { try { return deserializeVolume(volumeData); - } catch (error: any) { + } catch (error: unknown) { try { await removeVolume(volumeId); - } catch (error: any) { + } catch { // TODO: log error } - throw new Error(`Failed to deserialize volume: ${error.message}`); + throw new Error(`Failed to deserialize volume: ${error instanceof Error ? error.message : error}`); } } diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts index 1d11fb31..0ea6171d 100644 --- a/js/sdk/src/internal/shares/cryptoCache.test.ts +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -103,7 +103,7 @@ describe('sharesCryptoCache', () => { await cache.getShareKey('badSessionKey'); throw new Error('Should have thrown an error'); } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialize share keys: Invalid share session key: Error: Bad session key'); + expect(`${error}`).toBe('Error: Failed to deserialize share keys: Invalid share session key: Bad session key'); } try { diff --git a/js/sdk/src/internal/shares/cryptoCache.ts b/js/sdk/src/internal/shares/cryptoCache.ts index 7c256297..01f92223 100644 --- a/js/sdk/src/internal/shares/cryptoCache.ts +++ b/js/sdk/src/internal/shares/cryptoCache.ts @@ -26,7 +26,7 @@ export function sharesCryptoCache(driveCache: ProtonDriveCache) { } catch (error: unknown) { try { await removeShareKey([shareId]); - } catch (error: unknown) { + } catch { // TODO: log error } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -60,13 +60,13 @@ export function sharesCryptoCache(driveCache: ProtonDriveCache) { try { key = await deserializePrivateKey(result.key); - } catch (error: any) { - throw new Error(`Invalid share private key: ${error}`); + } catch (error: unknown) { + throw new Error(`Invalid share private key: ${error instanceof Error ? error.message : error}`); } try { sessionKey = deserializeSessionKey(result.sessionKey); - } catch (error: any) { - throw new Error(`Invalid share session key: ${error}`); + } catch (error: unknown) { + throw new Error(`Invalid share session key: ${error instanceof Error ? error.message : error}`); } return { diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index d7d7ebac..c6d0b73b 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -62,7 +62,7 @@ export function sharesManager( rootNodeId: myFilesShare.rootNodeId, }; - } catch (error: any) { + } catch (error: unknown) { if (error instanceof NotFoundAPIError) { return createVolume(); } @@ -81,7 +81,7 @@ export function sharesManager( * @throws If the volume cannot be created (e.g., one already exists). */ async function createVolume() { - const { email, addressKey, addressId, addressKeyId } = await account.getOwnPrimaryKey(); + const { addressKey, addressId, addressKeyId } = await account.getOwnPrimaryKey(); const bootstrap = await cryptoService.generateVolumeBootstrap(addressKey); const myFilesIds = await apiService.createVolume( { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index de2fd0f1..3aa310e1 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -16,7 +16,7 @@ export function protonDriveClient({ account, getLogger, config, - metrics, + metrics, // eslint-disable-line @typescript-eslint/no-unused-vars openPGPCryptoModule, acceptNoGuaranteeWithCustomModules, }: ProtonDriveClientContructorParameters): Partial { @@ -37,6 +37,8 @@ export function protonDriveClient({ const upload = uploadModule(apiService, cryptoModule, nodes); return { + // TODO + // eslint-disable-next-line @typescript-eslint/no-unused-vars getNodeUid: (shareId: string, nodeId: string) => Promise.resolve(""), getMyFilesRootFolder: () => { return convertInternalNodePromise(nodes.getMyFilesRootFolder()); diff --git a/js/sdk/src/protonDrivePublicClient.ts b/js/sdk/src/protonDrivePublicClient.ts index 9cf318d1..5fd8bc99 100644 --- a/js/sdk/src/protonDrivePublicClient.ts +++ b/js/sdk/src/protonDrivePublicClient.ts @@ -17,7 +17,7 @@ export function protonDrivePublicClient({ account, getLogger, config, - metrics, + metrics, // eslint-disable-line @typescript-eslint/no-unused-vars openPGPCryptoModule, acceptNoGuaranteeWithCustomModules, }: ProtonDriveClientContructorParameters): ProtonDrivePublicClientInterface { diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index ded14b8f..1edda900 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,4 +1,5 @@ -import { NodeOrUid, NodeEntity } from './interface/index.js'; +import { NodeOrUid, NodeEntity as PublicNode } from './interface'; +import { DecryptedNode as InternalNode } from './internal/nodes'; export function getUid(nodeUid: NodeOrUid): string { if (typeof nodeUid === "string") { @@ -11,21 +12,30 @@ export function getUids(nodeUids: NodeOrUid[]): string[] { return nodeUids.map(getUid); } -// TODO: type -export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { +export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { for await (const node of nodeIterator) { yield convertInternalNode(node); } } -// TODO: type -export async function convertInternalNodePromise(nodePromise: Promise): Promise { +export async function convertInternalNodePromise(nodePromise: Promise): Promise { const node = await nodePromise; return convertInternalNode(node); } -// TODO: type -export function convertInternalNode(node: any): NodeEntity { - // TODO: implement - return {} as NodeEntity +export function convertInternalNode(node: InternalNode): PublicNode { + return { + uid: node.uid, + parentUid: node.parentUid, + name: node.name, + keyAuthor: node.keyAuthor, + nameAuthor: node.nameAuthor, + directMemberRole: node.directMemberRole, + type: node.type, + mimeType: node.mimeType, + isShared: node.isShared, + createdDate: node.createdDate, + trashedDate: node.trashedDate, + activeRevision: node.activeRevision, + }; } From d5efb8bd567214ce80edb7f060ee7887c049cc91 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Jan 2025 12:18:01 +0000 Subject: [PATCH 003/791] Convert functions to classes and make all types explicit --- js/sdk/src/crypto/driveCrypto.ts | 177 +++++++++++++----- js/sdk/src/crypto/index.ts | 6 +- js/sdk/src/crypto/interface.ts | 148 --------------- js/sdk/src/crypto/openPGPCrypto.ts | 73 ++++---- js/sdk/src/index.ts | 2 +- js/sdk/src/internal/apiService/apiService.ts | 45 ++--- js/sdk/src/internal/apiService/index.ts | 2 +- js/sdk/src/internal/nodes/apiService.test.ts | 7 +- js/sdk/src/internal/nodes/apiService.ts | 62 +++--- js/sdk/src/internal/nodes/cache.test.ts | 10 +- js/sdk/src/internal/nodes/cache.ts | 153 ++++++++------- js/sdk/src/internal/nodes/cryptoCache.test.ts | 6 +- js/sdk/src/internal/nodes/cryptoCache.ts | 112 ++++++----- .../src/internal/nodes/cryptoService.test.ts | 6 +- js/sdk/src/internal/nodes/cryptoService.ts | 75 ++++---- js/sdk/src/internal/nodes/events.ts | 8 +- js/sdk/src/internal/nodes/index.ts | 53 +++--- js/sdk/src/internal/nodes/manager.ts | 151 ++++++++------- js/sdk/src/internal/nodes/nodesAccess.test.ts | 22 +-- js/sdk/src/internal/nodes/nodesAccess.ts | 77 ++++---- js/sdk/src/internal/shares/apiService.ts | 67 +++---- js/sdk/src/internal/shares/cache.test.ts | 6 +- js/sdk/src/internal/shares/cache.ts | 68 ++++--- .../src/internal/shares/cryptoCache.test.ts | 6 +- js/sdk/src/internal/shares/cryptoCache.ts | 86 +++++---- js/sdk/src/internal/shares/cryptoService.ts | 36 ++-- js/sdk/src/internal/shares/index.ts | 32 ++-- js/sdk/src/internal/shares/interface.ts | 2 +- js/sdk/src/internal/shares/manager.ts | 94 +++++----- js/sdk/src/protonDriveClient.ts | 28 +-- js/sdk/src/protonDrivePublicClient.ts | 22 +-- 31 files changed, 779 insertions(+), 863 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 39fa8537..67be877a 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,17 +1,55 @@ -import { OpenPGPCrypto, DriveCrypto, PrivateKey, PublicKey, SessionKey } from './interface.js'; +import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface.js'; /** - * See interface for more info. + * Drive crypto layer to provide general operations for Drive crypto. + * + * This layer focuses on providing general Drive crypto functions. Only + * high-level functions that are required on multiple places should be + * peresent. E.g., no specific implementation how keys are encrypted, + * but we do share same key generation across shares and nodes modules, + * for example, which we can generelise here and in each module just + * call with specific arguments. */ -export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { - async function generateKey(encryptionKeys: PrivateKey[], signingKey: PrivateKey) { - const passphrase = openPGPCrypto.generatePassphrase(); +export class DriveCrypto { + constructor(private openPGPCrypto: OpenPGPCrypto) { + this.openPGPCrypto = openPGPCrypto; + } + + /** + * It generates passphrase and key that is encrypted with the + * generated passphrase. + * + * `encrpytionKeys` are used to generate session key, which is + * also used to encrypt the passphrase. The encrypted passphrase + * is signed with `signingKey`. + * + * @returns Object with: + * - encrypted (armored) data (key, passphrase and passphrase + * signature) for sending to the server + * - decrypted data (key, sessionKey) for crypto usage + */ + async generateKey( + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ): Promise<{ + encrypted: { + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + }, + decrypted: { + passphrase: string, + key: PrivateKey, + sessionKey: SessionKey, + }, + }> { + const passphrase = this.openPGPCrypto.generatePassphrase(); const [{ privateKey, armoredKey }, sessionKey] = await Promise.all([ - openPGPCrypto.generateKey(passphrase), - openPGPCrypto.generateSessionKey(encryptionKeys), + this.openPGPCrypto.generateKey(passphrase), + this.openPGPCrypto.generateSessionKey(encryptionKeys), ]); - const { armoredPassphrase, armoredPassphraseSignature } = await encryptPassphrase( + const { armoredPassphrase, armoredPassphraseSignature } = await this.encryptPassphrase( passphrase, sessionKey, encryptionKeys, @@ -32,13 +70,23 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { }; }; - async function encryptPassphrase( + /** + * It encrypts passphrase with provided session and encryption keys. + * This should be used only for re-encrypting the passphrase with + * different key (e.g., moving the node to different parent). + * + * @returns Object with armored passphrase and passphrase signature. + */ + async encryptPassphrase( passphrase: string, sessionKey: SessionKey, encryptionKeys: PrivateKey[], signingKey: PrivateKey, - ) { - const { armoredData: armoredPassphrase, armoredSignature: armoredPassphraseSignature } = await openPGPCrypto.encryptAndSignDetachedArmored( + ): Promise<{ + armoredPassphrase: string, + armoredPassphraseSignature: string, + }> { + const { armoredData: armoredPassphrase, armoredSignature: armoredPassphraseSignature } = await this.openPGPCrypto.encryptAndSignDetachedArmored( new TextEncoder().encode(passphrase), sessionKey, encryptionKeys, @@ -51,19 +99,37 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { }; } - async function decryptKey( + /** + * It decrypts key generated via `generateKey`. + * + * Armored data are passed from the server. `decryptionKeys` are used + * to decrypt the session key from the `armoredPassphrase`. Then the + * session key is used with `verificationKeys` to decrypt and verify + * the passphrase. Finally, the armored key is decrypted. + * + * Note: The function doesn't throw in case of verification issue. + * You have to read `verified` result and act based on that. + * + * @returns key and sessionKey for crypto usage, and verification status + */ + async decryptKey( armoredKey: string, armoredPassphrase: string, armoredPassphraseSignature: string, decryptionKeys: PrivateKey[], verificationKeys: PublicKey[], - ) { - const sessionKey = await openPGPCrypto.decryptSessionKey( + ): Promise<{ + passphrase: string, + key: PrivateKey, + sessionKey: SessionKey, + verified: VERIFICATION_STATUS, + }> { + const sessionKey = await this.openPGPCrypto.decryptSessionKey( armoredPassphrase, decryptionKeys, ); - const { data: decryptedPassphrase, verified } = await openPGPCrypto.decryptArmoredAndVerifyDetached( + const { data: decryptedPassphrase, verified } = await this.openPGPCrypto.decryptArmoredAndVerifyDetached( armoredPassphrase, armoredPassphraseSignature, sessionKey, @@ -72,7 +138,7 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { const passphrase = new TextDecoder().decode(decryptedPassphrase); - const key = await openPGPCrypto.decryptKey( + const key = await this.openPGPCrypto.decryptKey( armoredKey, passphrase, ); @@ -84,12 +150,17 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { }; } - async function encryptSignature( + /** + * It encrypts and armors signature with provided session and encryption keys. + */ + async encryptSignature( signature: Uint8Array, encryptionKey: PrivateKey, sessionKey: SessionKey, - ) { - const { armoredData: armoredSignature } = await openPGPCrypto.encryptArmored( + ): Promise<{ + armoredSignature: string, + }> { + const { armoredData: armoredSignature } = await this.openPGPCrypto.encryptArmored( signature, sessionKey, [encryptionKey], @@ -99,16 +170,23 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { } } - async function generateHashKey( + /** + * It generates random 32 bytes that are encrypted and signed with + * the provided key. + */ + async generateHashKey( encryptionAndSigningKey: PrivateKey, - ) { + ): Promise<{ + armoredHashKey: string, + hashKey: Uint8Array, + }> { // Once all clients can use non-ascii bytes, switch to simple // generating of random bytes without encoding it into base64: //const passphrase crypto.getRandomValues(new Uint8Array(32)); - const passphrase = openPGPCrypto.generatePassphrase(); + const passphrase = this.openPGPCrypto.generatePassphrase(); const hashKey = new TextEncoder().encode(passphrase); - const { armoredData: armoredHashKey } = await openPGPCrypto.encryptAndSignArmored( + const { armoredData: armoredHashKey } = await this.openPGPCrypto.encryptAndSignArmored( hashKey, [encryptionAndSigningKey], encryptionAndSigningKey, @@ -119,12 +197,18 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { } } - async function encryptNodeName( + /** + * It converts node name into bytes array and encrypts and signs + * with provided keys. + */ + async encryptNodeName( nodeName: string, encryptionKey: PrivateKey, signingKey: PrivateKey, - ) { - const { armoredData: armoredNodeName } = await openPGPCrypto.encryptAndSignArmored( + ): Promise<{ + armoredNodeName: string, + }> { + const { armoredData: armoredNodeName } = await this.openPGPCrypto.encryptAndSignArmored( new TextEncoder().encode(nodeName), [encryptionKey], signingKey, @@ -134,12 +218,21 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { } } - async function decryptNodeName( + /** + * It decrypts armored node name and verifies embeded signature. + * + * Note: The function doesn't throw in case of verification issue. + * You have to read `verified` result and act based on that. + */ + async decryptNodeName( armoredNodeName: string, decryptionKey: PrivateKey, verificationKeys: PublicKey[], - ) { - const { data: name, verified } = await openPGPCrypto.decryptArmoredAndVerify( + ): Promise<{ + name: string, + verified: VERIFICATION_STATUS, + }> { + const { data: name, verified } = await this.openPGPCrypto.decryptArmoredAndVerify( armoredNodeName, [decryptionKey], verificationKeys, @@ -150,18 +243,27 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { } } - async function decryptNodeHashKey( + /** + * It decrypts armored node hash key and verifies embeded signature. + * + * Note: The function doesn't throw in case of verification issue. + * You have to read `verified` result and act based on that. + */ + async decryptNodeHashKey( armoredHashKey: string, decryptionAndVerificationKey: PrivateKey, extraVerificationKeys: PublicKey[], - ) { + ): Promise<{ + hashKey: Uint8Array, + verified: VERIFICATION_STATUS, + }> { // In the past, we had misunderstanding what key is used to sign hash // key. Originally, it meant to be the node key, which web used for all // nodes besides the root one, where address key was used instead. // Similarly, iOS or Android used address key for all nodes. Latest // versions should use node key in all cases, but we accept also // address key. Its still signed with a valid key. - const { data: hashKey, verified } = await openPGPCrypto.decryptArmoredAndVerify( + const { data: hashKey, verified } = await this.openPGPCrypto.decryptArmoredAndVerify( armoredHashKey, [decryptionAndVerificationKey], [decryptionAndVerificationKey, ...extraVerificationKeys], @@ -171,15 +273,4 @@ export function driveCrypto(openPGPCrypto: OpenPGPCrypto): DriveCrypto { verified, }; } - - return { - generateKey, - encryptPassphrase, - decryptKey, - encryptSignature, - generateHashKey, - encryptNodeName, - decryptNodeName, - decryptNodeHashKey, - } } diff --git a/js/sdk/src/crypto/index.ts b/js/sdk/src/crypto/index.ts index 68835d3c..9e028269 100644 --- a/js/sdk/src/crypto/index.ts +++ b/js/sdk/src/crypto/index.ts @@ -1,5 +1,5 @@ -export type { DriveCrypto, OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interface'; +export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interface'; export { VERIFICATION_STATUS } from './interface'; -export { driveCrypto } from './driveCrypto'; -export { openPGPCrypto } from './openPGPCrypto'; +export { DriveCrypto } from './driveCrypto'; +export { OpenPGPCryptoWithCryptoProxy } from './openPGPCrypto'; export { serializePrivateKey, deserializePrivateKey, serializeSessionKey, deserializeSessionKey, serializeHashKey, deserializeHashKey } from './openPGPSerialisation'; diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 2502364d..e374b6a6 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -20,154 +20,6 @@ export enum VERIFICATION_STATUS { SIGNED_AND_INVALID = 2 } -/** - * Drive crypto layer to provide general operations for Drive crypto. - * - * This layer focuses on providing general Drive crypto functions. Only - * high-level functions that are required on multiple places should be - * peresent. E.g., no specific implementation how keys are encrypted, - * but we do share same key generation across shares and nodes modules, - * for example, which we can generelise here and in each module just - * call with specific arguments. - */ -export interface DriveCrypto { - /** - * It generates passphrase and key that is encrypted with the - * generated passphrase. - * - * `encrpytionKeys` are used to generate session key, which is - * also used to encrypt the passphrase. The encrypted passphrase - * is signed with `signingKey`. - * - * @returns Object with: - * - encrypted (armored) data (key, passphrase and passphrase - * signature) for sending to the server - * - decrypted data (key, sessionKey) for crypto usage - */ - generateKey: ( - encryptionKeys: PrivateKey[], - signingKey: PrivateKey, - ) => Promise<{ - encrypted: { - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, - }, - decrypted: { - passphrase: string, - key: PrivateKey, - sessionKey: SessionKey, - }, - }>, - - /** - * It encrypts passphrase with provided session and encryption keys. - * This should be used only for re-encrypting the passphrase with - * different key (e.g., moving the node to different parent). - * - * @returns Object with armored passphrase and passphrase signature. - */ - encryptPassphrase: ( - passphrase: string, - sessionKey: SessionKey, - encryptionKeys: PrivateKey[], - signingKey: PrivateKey, - ) => Promise<{ - armoredPassphrase: string, - armoredPassphraseSignature: string, - }>, - - /** - * It decrypts key generated via `generateKey`. - * - * Armored data are passed from the server. `decryptionKeys` are used - * to decrypt the session key from the `armoredPassphrase`. Then the - * session key is used with `verificationKeys` to decrypt and verify - * the passphrase. Finally, the armored key is decrypted. - * - * Note: The function doesn't throw in case of verification issue. - * You have to read `verified` result and act based on that. - * - * @returns key and sessionKey for crypto usage, and verification status - */ - decryptKey: ( - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, - decryptionKeys: PrivateKey[], - verificationKeys: PublicKey[], - ) => Promise<{ - passphrase: string, - key: PrivateKey, - sessionKey: SessionKey, - verified: VERIFICATION_STATUS, - }>, - - /** - * It encrypts and armors signature with provided session and encryption keys. - */ - encryptSignature: ( - signature: Uint8Array, - encryptionKey: PrivateKey, - sessionKey: SessionKey, - ) => Promise<{ - armoredSignature: string, - }>, - - /** - * It generates random 32 bytes that are encrypted and signed with - * the provided key. - */ - generateHashKey: ( - encryptionAndSigningKey: PrivateKey, - ) => Promise<{ - armoredHashKey: string, - hashKey: Uint8Array, - }>, - - /** - * It converts node name into bytes array and encrypts and signs - * with provided keys. - */ - encryptNodeName: ( - nodeName: string, - encryptionKey: PrivateKey, - signingKey: PrivateKey, - ) => Promise<{ - armoredNodeName: string, - }>, - - /** - * It decrypts armored node name and verifies embeded signature. - * - * Note: The function doesn't throw in case of verification issue. - * You have to read `verified` result and act based on that. - */ - decryptNodeName: ( - armoredNodeName: string, - encryptionKey: PrivateKey, - verificationKeys: PublicKey[], - ) => Promise<{ - name: string, - verified: VERIFICATION_STATUS, - }>, - - /** - * It decrypts armored node hash key and verifies embeded signature. - * - * Note: The function doesn't throw in case of verification issue. - * You have to read `verified` result and act based on that. - */ - decryptNodeHashKey: ( - armoredNodeName: string, - decryptionAndVerificationKey: PrivateKey, - extraVerificationKeys: PublicKey[], - ) => Promise<{ - hashKey: Uint8Array, - verified: VERIFICATION_STATUS, - }>, -} - /** * OpenPGP crypto layer to provide necessary PGP operations for Drive crypto. * diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 16c95ee9..6238eca3 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -25,26 +25,32 @@ interface OpenPGPCryptoProxyDecryptMessage { } /** - * See interface for more info. + * Implementation of OpenPGPCrypto interface using CryptoProxy from clients + * monorepo that must be passed as dependency. In the future, CryptoProxy + * will be published separately and this implementation will use it directly. */ -export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { - function generatePassphrase(): string { +export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { + constructor(private cryptoProxy: OpenPGPCryptoProxy) { + this.cryptoProxy = cryptoProxy; + } + + generatePassphrase(): string { const value = crypto.getRandomValues(new Uint8Array(32)); return uint8ArrayToBase64String(value); } - async function generateSessionKey(encryptionKeys: PrivateKey[]) { - return cryptoProxy.generateSessionKey({ recipientKeys: encryptionKeys }); + async generateSessionKey(encryptionKeys: PrivateKey[]) { + return this.cryptoProxy.generateSessionKey({ recipientKeys: encryptionKeys }); } - async function generateKey(passphrase: string) { - const key = await cryptoProxy.generateKey({ + async generateKey(passphrase: string) { + const key = await this.cryptoProxy.generateKey({ userIDs: [{ name: 'Drive key' }], type: 'ecc', curve: 'ed25519Legacy', }); - const armoredKey = await cryptoProxy.exportPrivateKey({ + const armoredKey = await this.cryptoProxy.exportPrivateKey({ key, passphrase, }); @@ -55,12 +61,12 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { }; } - async function encryptArmored( + async encryptArmored( data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PrivateKey[], ) { - const { message: armoredData } = await cryptoProxy.encryptMessage({ + const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, sessionKey, encryptionKeys, @@ -70,13 +76,13 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { } } - async function encryptAndSign( + async encryptAndSign( data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) { - const { message: encryptedData } = await cryptoProxy.encryptMessage({ + const { message: encryptedData } = await this.cryptoProxy.encryptMessage({ binaryData: data, sessionKey, signingKeys: signingKey, @@ -89,12 +95,12 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { }; } - async function encryptAndSignArmored( + async encryptAndSignArmored( data: Uint8Array, encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) { - const { message: armoredData } = await cryptoProxy.encryptMessage({ + const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, encryptionKeys, signingKeys: signingKey, @@ -105,13 +111,13 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { }; } - async function encryptAndSignDetached( + async encryptAndSignDetached( data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) { - const { message: encryptedData, signature } = await cryptoProxy.encryptMessage({ + const { message: encryptedData, signature } = await this.cryptoProxy.encryptMessage({ binaryData: data, sessionKey, signingKeys: signingKey, @@ -125,13 +131,13 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { } } - async function encryptAndSignDetachedArmored( + async encryptAndSignDetachedArmored( data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) { - const { message: armoredData, signature: armoredSignature } = await cryptoProxy.encryptMessage({ + const { message: armoredData, signature: armoredSignature } = await this.cryptoProxy.encryptMessage({ binaryData: data, sessionKey, signingKeys: signingKey, @@ -144,11 +150,11 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { } } - async function decryptSessionKey( + async decryptSessionKey( armoredPassphrase: string, decryptionKeys: PrivateKey[], ) { - const sessionKey = await cryptoProxy.decryptSessionKey({ + const sessionKey = await this.cryptoProxy.decryptSessionKey({ armoredMessage: armoredPassphrase, decryptionKeys, }); @@ -161,23 +167,23 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { return sessionKey; } - async function decryptKey( + async decryptKey( armoredKey: string, passphrase: string, ) { - const key = await cryptoProxy.importPrivateKey({ + const key = await this.cryptoProxy.importPrivateKey({ armoredKey, passphrase, }); return key; } - async function decryptArmoredAndVerify( + async decryptArmoredAndVerify( armoredData: string, decryptionKeys: PrivateKey[], verificationKeys: PublicKey[], ) { - const { data, verified } = await cryptoProxy.decryptMessage({ + const { data, verified } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, decryptionKeys, verificationKeys, @@ -190,13 +196,13 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { } } - async function decryptArmoredAndVerifyDetached( + async decryptArmoredAndVerifyDetached( armoredData: string, armoredSignature: string, sessionKey: SessionKey, verificationKeys: PublicKey[], ) { - const { data, verified } = await cryptoProxy.decryptMessage({ + const { data, verified } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, signature: armoredSignature, sessionKeys: sessionKey, @@ -209,19 +215,4 @@ export function openPGPCrypto(cryptoProxy: OpenPGPCryptoProxy): OpenPGPCrypto { verified, } } - - return { - generatePassphrase, - generateSessionKey, - generateKey, - encryptArmored, - encryptAndSign, - encryptAndSignArmored, - encryptAndSignDetached, - encryptAndSignDetachedArmored, - decryptSessionKey, - decryptKey, - decryptArmoredAndVerify, - decryptArmoredAndVerifyDetached, - } } diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index f22f1c5c..9272ca90 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -1,6 +1,6 @@ export * from './interface/index.js'; export * from './cache/index.js'; -export { openPGPCrypto, OpenPGPCrypto } from './crypto/index.js'; +export { OpenPGPCryptoWithCryptoProxy, OpenPGPCrypto } from './crypto/index.js'; export { protonDriveClient } from './protonDriveClient.js'; export { protonDrivePhotosClient } from './protonDrivePhotosClient.js'; export { protonDrivePublicClient } from './protonDrivePublicClient.js'; diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index e5f55805..c8650e5a 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -2,47 +2,48 @@ import { ProtonDriveHTTPClient, Logger } from "../../interface/index.js"; import { ErrorCode } from './errorCodes'; import { apiErrorFactory, APIError } from './errors'; -export interface DriveAPIService { - get: (url: string, signal?: AbortSignal) => Promise, - post: (url: string, data: Request, signal?: AbortSignal) => Promise, - put: (url: string, data: Request, signal?: AbortSignal) => Promise, -}; - /** * Provides API communication used withing the Drive SDK. * * The service is responsible for handling general headers, errors, conversion * or rate limiting. */ -export function getApiService(httpClient: ProtonDriveHTTPClient, baseUrl: string, language: string, logger?: Logger): DriveAPIService { - async function get(url: string, signal?: AbortSignal): Promise { - return makeRequest(url, 'GET', undefined, signal); +export class DriveAPIService { + constructor(private httpClient: ProtonDriveHTTPClient, private baseUrl: string, private language: string, private logger?: Logger) { + this.httpClient = httpClient; + this.baseUrl = baseUrl; + this.language = language; + this.logger = logger; + } + + async get(url: string, signal?: AbortSignal): Promise { + return this.makeRequest(url, 'GET', undefined, signal); }; - async function post(url: string, data: Request, signal?: AbortSignal): Promise { - return makeRequest(url, 'POST', data, signal); + async post(url: string, data: Request, signal?: AbortSignal): Promise { + return this.makeRequest(url, 'POST', data, signal); }; - async function put(url: string, data: Request, signal?: AbortSignal): Promise { - return makeRequest(url, 'PUT', data, signal); + async put(url: string, data: Request, signal?: AbortSignal): Promise { + return this.makeRequest(url, 'PUT', data, signal); }; // TODO: rate limit implementation - async function makeRequest(url: string, method = 'GET', data?: Request, signal?: AbortSignal) { - logger?.debug(`${method} ${url}`); + private async makeRequest(url: string, method = 'GET', data?: Request, signal?: AbortSignal) { + this.logger?.debug(`${method} ${url}`); - const response = await httpClient.fetch(new Request(`${baseUrl}/${url}`, { + const response = await this.httpClient.fetch(new Request(`${this.baseUrl}/${url}`, { method: method || 'GET', // TODO: set SDK-specific headers (accept: json, language, SDK version) headers: new Headers({ - "Language": language, + "Language": this.language, }), }), signal); if (response.ok) { - logger?.info(`${method} ${url}: ${response.status}`); + this.logger?.info(`${method} ${url}: ${response.status}`); } else { - logger?.warn(`${method} ${url}: ${response.status}`); + this.logger?.warn(`${method} ${url}: ${response.status}`); } try { @@ -58,10 +59,4 @@ export function getApiService(httpClient: ProtonDriveHTTPClient, baseUrl: string throw apiErrorFactory({ response }); } } - - return { - get, - put, - post, - }; } diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 01acc82a..15bc55b3 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -1,3 +1,3 @@ -export { DriveAPIService, getApiService } from './apiService.js'; +export { DriveAPIService } from './apiService.js'; export { paths as drivePaths } from './driveTypes.js'; export * from './errors'; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 9d2f1087..fc1f431a 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -1,6 +1,6 @@ import { MemberRole, NodeType } from "../../interface"; import { DriveAPIService } from "../apiService"; -import { nodeAPIService } from './apiService'; +import { NodeAPIService } from './apiService'; function generateAPIFileNode(linkOverrides = {}, overrides = {}) { const node = generateAPINode(); @@ -125,18 +125,19 @@ function generateNode() { describe("nodeAPIService", () => { let apiMock: DriveAPIService; - let api: ReturnType; + let api: NodeAPIService; beforeEach(() => { jest.clearAllMocks(); + // @ts-expect-error Mocking for testing purposes apiMock = { get: jest.fn(), post: jest.fn(), put: jest.fn(), }; - api = nodeAPIService(apiMock); + api = new NodeAPIService(apiMock); }); describe('getNodes', () => { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 32a66eec..fe7c72ed 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -34,18 +34,23 @@ type PostCreateFolderResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ -export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { - async function getNode(nodeUid: string, signal?: AbortSignal): Promise { - const nodes = await getNodes([nodeUid], signal); +export class NodeAPIService { + constructor(private apiService: DriveAPIService, private logger?: Logger) { + this.apiService = apiService; + this.logger = logger; + } + + async getNode(nodeUid: string, signal?: AbortSignal): Promise { + const nodes = await this.getNodes([nodeUid], signal); return nodes[0]; } // Improvement requested: support multiple volumes. - async function getNodes(nodeUids: string[], signal?: AbortSignal): Promise { + async getNodes(nodeUids: string[], signal?: AbortSignal): Promise { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId("getNodes", nodeIds); - const response = await apiService.post(`drive/volumes/${volumeId}/links`, { + const response = await this.apiService.post(`drive/volumes/${volumeId}/links`, { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); @@ -66,7 +71,7 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { // Sharing node metadata shareId: link.SharingSummary?.ShareID || undefined, isShared: !!link.SharingSummary, - directMemberRole: sharingSummaryToDirectMemberRole(link.SharingSummary, logger), + directMemberRole: sharingSummaryToDirectMemberRole(link.SharingSummary, this.logger), } const baseCryptoNodeMetadata = { encryptedName: link.Link.Name, @@ -111,12 +116,12 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { } // Improvement requested: load next page sooner before all IDs are yielded. - async function* iterateChildrenNodeUids(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + async *iterateChildrenNodeUids(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { const { volumeId, nodeId } = splitNodeUid(parentNodeUid); let anchor = ""; while (true) { - const response = await apiService.get(`drive/volumes/${volumeId}/folders/${nodeId}/children?AnchorID=${anchor}`, signal); + const response = await this.apiService.get(`drive/volumes/${volumeId}/folders/${nodeId}/children?AnchorID=${anchor}`, signal); for (const linkID of response.LinkIDs) { yield makeNodeUid(volumeId, linkID); } @@ -129,10 +134,10 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { } // Improvement requested: load next page sooner before all IDs are yielded. - async function* iterateTrashedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { + async *iterateTrashedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { let page = 0; while (true) { - const response = await apiService.get(`drive/volumes/${volumeId}/trash?Page=${page}`, signal); + const response = await this.apiService.get(`drive/volumes/${volumeId}/trash?Page=${page}`, signal); // The API returns items per shares which is not straightforward to // count if there is another page. We had mistakes in the past, thus @@ -156,7 +161,7 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { } } - async function renameNode( + async renameNode( nodeUid: string, originalNode: { hash: string, @@ -170,7 +175,7 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { ): Promise { const { volumeId, nodeId } = splitNodeUid(nodeUid); - await apiService.put< + await this.apiService.put< Omit, PutRenameNodeResponse >(`drive/v2/volumes/${volumeId}/links/${nodeId}/rename`, { @@ -181,7 +186,7 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { }, signal); } - async function moveNode( + async moveNode( nodeUid: string, oldNode: { hash: string, @@ -201,7 +206,7 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { const { volumeId, nodeId } = splitNodeUid(nodeUid); const { nodeId: newParentNodeId } = splitNodeUid(newNode.parentUid); - await apiService.put< + await this.apiService.put< Omit, PutMoveNodeResponse >(`/drive/v2/volumes/${volumeId}/links/${nodeId}/move`, { @@ -219,12 +224,12 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { // Improvement requested: API without requiring parent node (to delete any nodes). // Improvement requested: split into multiple calls for many nodes. - async function trashNodes(parentNodeUid: string, nodeUids: string[], signal?: AbortSignal): Promise { + async trashNodes(parentNodeUid: string, nodeUids: string[], signal?: AbortSignal): Promise { const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); const nodeIds = nodeUids.map(splitNodeUid); - const response = await apiService.post< + const response = await this.apiService.post< PostTrashNodesRequest, PostTrashNodesResponse >(`/drive/v2/volumes/${volumeId}/folders/${parentNodeId}/trash_multiple`, { @@ -236,11 +241,11 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { } // Improvement requested: split into multiple calls for many nodes. - async function restoreNodes(nodeUids: string[], signal?: AbortSignal): Promise { + async restoreNodes(nodeUids: string[], signal?: AbortSignal): Promise { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId("restoreNodes", nodeIds); - const response = await apiService.put< + const response = await this.apiService.put< PutRestoreNodesRequest, PutRestoreNodesResponse >(`/drive/v2/volumes/${volumeId}/trash/restore_multiple`, { @@ -252,11 +257,11 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { } // Improvement requested: split into multiple calls for many nodes. - async function deleteNodes(nodeUids: string[], signal?: AbortSignal): Promise { + async deleteNodes(nodeUids: string[], signal?: AbortSignal): Promise { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId("restoreNodes", nodeIds); - const response = await apiService.post< + const response = await this.apiService.post< PostDeleteNodesRequest, PostDeleteNodesResponse >(`/drive/v2/volumes/${volumeId}/trash/delete_multiple`, { @@ -267,7 +272,7 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { handleResponseErrors(volumeId, response.Responses as LinkResponse[]); } - async function createFolder( + async createFolder( parentUid: string, newNode: { armoredKey: string, @@ -283,7 +288,7 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { ): Promise { const { volumeId, nodeId: parentId } = splitNodeUid(parentUid); - const response = await apiService.post< + const response = await this.apiService.post< PostCreateFolderRequest, PostCreateFolderResponse >(`/drive/v2/volumes/${volumeId}/folders`, { @@ -300,19 +305,6 @@ export function nodeAPIService(apiService: DriveAPIService, logger?: Logger) { return response.Folder.ID; } - - return { - getNode, - getNodes, - iterateChildrenNodeUids, - iterateTrashedNodeUids, - renameNode, - moveNode, - trashNodes, - restoreNodes, - deleteNodes, - createFolder, - } } function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { volumeId: string }[]): string { diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 39778394..9cab73dc 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -1,6 +1,6 @@ import { MemoryCache } from "../../cache"; import { NodeType, MemberRole } from "../../interface"; -import { CACHE_TAG_KEYS, nodesCache } from "./cache"; +import { CACHE_TAG_KEYS, NodesCache } from "./cache"; import { DecryptedNode } from "./interface"; function generateNode(uid: string, parentUid='root', params: Partial = {}): DecryptedNode { @@ -18,7 +18,7 @@ function generateNode(uid: string, parentUid='root', params: Partial) { +async function generateTreeStructure(cache: NodesCache) { for (const node of [ generateNode('node1', 'root'), generateNode('node1a', 'node1'), @@ -37,7 +37,7 @@ async function generateTreeStructure(cache: ReturnType) { } } -async function verifyNodesCache(cache: ReturnType, expectedNodes: string[], expectedMissingNodes: string[]) { +async function verifyNodesCache(cache: NodesCache, expectedNodes: string[], expectedMissingNodes: string[]) { for (const nodeUid of expectedNodes) { try { await cache.getNode(nodeUid); @@ -58,14 +58,14 @@ async function verifyNodesCache(cache: ReturnType, expectedNo describe('nodesCache', () => { let memoryCache: MemoryCache; - let cache: ReturnType; + let cache: NodesCache; beforeEach(() => { memoryCache = new MemoryCache([CACHE_TAG_KEYS.ParentUid, CACHE_TAG_KEYS.Trashed]); memoryCache.setEntity('node-root', JSON.stringify(generateNode('root', ''))); memoryCache.setEntity('node-badObject', 'aaa', { [CACHE_TAG_KEYS.ParentUid]: 'root' }); - cache = nodesCache(memoryCache); + cache = new NodesCache(memoryCache); }); it('should store and retrieve node', async () => { diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 47279139..69cdbf95 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -1,12 +1,17 @@ import { ProtonDriveCache, EntityResult } from "../../cache"; import { Logger } from "../../interface"; -import { DecryptedNode } from "./interface.js"; +import { DecryptedNode } from "./interface"; export enum CACHE_TAG_KEYS { ParentUid = 'parentUid', Trashed = 'trashed', } +type DecryptedNodeResult = ( + {uid: string, ok: true, node: DecryptedNode} | + {uid: string, ok: false, error: string} +); + /** * Provides caching for nodes metadata. * @@ -15,8 +20,13 @@ export enum CACHE_TAG_KEYS { * * The cache of node metadata should not contain any crypto material. */ -export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { - async function setNode(node: DecryptedNode) { +export class NodesCache { + constructor(private driveCache: ProtonDriveCache, private logger?: Logger) { + this.driveCache = driveCache; + this.logger = logger; + } + + async setNode(node: DecryptedNode): Promise { const key = getCacheUid(node.uid); const nodeData = serialiseNode(node); @@ -28,16 +38,16 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { tags[CACHE_TAG_KEYS.Trashed] = 'true'; } - await driveCache.setEntity(key, nodeData, tags); + await this.driveCache.setEntity(key, nodeData, tags); } - async function getNode(nodeUid: string): Promise { + async getNode(nodeUid: string): Promise { const key = getCacheUid(nodeUid); - const nodeData = await driveCache.getEntity(key); + const nodeData = await this.driveCache.getEntity(key); try { return deserialiseNode(nodeData); } catch (error: unknown) { - removeCorruptedNode({ nodeUid }, error); + this.removeCorruptedNode({ nodeUid }, error); throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`) } } @@ -48,72 +58,72 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { * nodes and rather let SDK re-fetch them than to auotmatically * fix issues and do not bother user with it. */ - async function removeCorruptedNode({ nodeUid, cacheUid }: { nodeUid?: string, cacheUid?: string }, corruptionError: unknown) { - logger?.error(`Removing corrupted nodes from the cache: ${corruptionError instanceof Error ? corruptionError.message : corruptionError}`); + private async removeCorruptedNode({ nodeUid, cacheUid }: { nodeUid?: string, cacheUid?: string }, corruptionError: unknown): Promise { + this.logger?.error(`Removing corrupted nodes from the cache: ${corruptionError instanceof Error ? corruptionError.message : corruptionError}`); try { if (nodeUid) { - await removeNodes([nodeUid]); + await this.removeNodes([nodeUid]); } else if (cacheUid) { - await driveCache.removeEntities([cacheUid]); + await this.driveCache.removeEntities([cacheUid]); } } catch (removingError: unknown) { // The node will not be returned, thus SDK will re-fetch // and re-cache it. Setting it again should then fix the // problem. - logger?.warn(`Failed to remove corrupted node from the cache: ${removingError instanceof Error ? removingError.message : removingError}`); + this.logger?.warn(`Failed to remove corrupted node from the cache: ${removingError instanceof Error ? removingError.message : removingError}`); } } - async function removeNodes(nodeUids: string[]) { + async removeNodes(nodeUids: string[]): Promise { const cacheUids = nodeUids.map(getCacheUid); - await driveCache.removeEntities(cacheUids); + await this.driveCache.removeEntities(cacheUids); for (const nodeUid of nodeUids) { try { - const childrenCacheUids = await getRecursiveChildrenCacheUids(nodeUid); + const childrenCacheUids = await this.getRecursiveChildrenCacheUids(nodeUid); // Reverse the order to remove children first. // Crucial to not leave any children without parent // if removing nodes fails. childrenCacheUids.reverse(); - await driveCache.removeEntities(childrenCacheUids); + await this.driveCache.removeEntities(childrenCacheUids); } catch (error: unknown) { // TODO: Should we throw here to the client? - logger?.error(`Failed to remove children from the cache: ${error instanceof Error ? error.message : error}`); + this.logger?.error(`Failed to remove children from the cache: ${error instanceof Error ? error.message : error}`); } } } - async function getRecursiveChildrenCacheUids(parentNodeUid: string): Promise { + private async getRecursiveChildrenCacheUids(parentNodeUid: string): Promise { const cacheUids = []; - for await (const result of driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.ParentUid, parentNodeUid)) { + for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.ParentUid, parentNodeUid)) { cacheUids.push(result.uid); - const childrenCacheUids = await getRecursiveChildrenCacheUids(getNodeUid(result.uid)); + const childrenCacheUids = await this.getRecursiveChildrenCacheUids(getNodeUid(result.uid)); cacheUids.push(...childrenCacheUids); } return cacheUids; } - async function *iterateNodes(nodeUids: string[]) { + async *iterateNodes(nodeUids: string[]): AsyncGenerator { const cacheUids = nodeUids.map(getCacheUid); - for await (const result of driveCache.iterateEntities(cacheUids)) { - const node = await convertCacheResult(result); + for await (const result of this.driveCache.iterateEntities(cacheUids)) { + const node = await this.convertCacheResult(result); if (node) { yield node; } } } - async function *iterateChildren(parentNodeUid: string) { - for await (const result of driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.ParentUid, parentNodeUid)) { - const node = await convertCacheResult(result); + async *iterateChildren(parentNodeUid: string): AsyncGenerator { + for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.ParentUid, parentNodeUid)) { + const node = await this.convertCacheResult(result); if (node) { yield node; } } } - async function *iterateTrashedNodes() { - for await (const result of driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.Trashed, 'true')) { - const node = await convertCacheResult(result); + async *iterateTrashedNodes(): AsyncGenerator { + for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.Trashed, 'true')) { + const node = await this.convertCacheResult(result); if (node) { yield node; } @@ -124,16 +134,12 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { * Converts result from the cache with cache UID and data to result of node * with node UID and DecryptedNode. */ - async function convertCacheResult(result: EntityResult): Promise<( - {uid: string, ok: true, node: DecryptedNode} | - {uid: string, ok: false, error: string} | - null - )> { + private async convertCacheResult(result: EntityResult): Promise { let nodeUid; try { nodeUid = getNodeUid(result.uid); } catch (error: unknown) { - await removeCorruptedNode({ cacheUid: result.uid }, error) + await this.removeCorruptedNode({ cacheUid: result.uid }, error) return null; } if (result.ok) { @@ -141,7 +147,7 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { try { node = deserialiseNode(result.data) } catch (error: unknown) { - await removeCorruptedNode({ nodeUid }, error); + await this.removeCorruptedNode({ nodeUid }, error); return null; } return { @@ -156,51 +162,42 @@ export function nodesCache(driveCache: ProtonDriveCache, logger?: Logger) { }; } } +} - function getCacheUid(nodeUid: string) { - return `node-${nodeUid}`; - } - - function getNodeUid(cacheUid: string) { - if (!cacheUid.startsWith('node-')) { - throw new Error('Unexpected cached node uid'); - } - return cacheUid.substring(5); - } +function getCacheUid(nodeUid: string) { + return `node-${nodeUid}`; +} - function serialiseNode(node: DecryptedNode) { - return JSON.stringify(node); +function getNodeUid(cacheUid: string) { + if (!cacheUid.startsWith('node-')) { + throw new Error('Unexpected cached node uid'); } + return cacheUid.substring(5); +} - function deserialiseNode(nodeData: string): DecryptedNode { - const node = JSON.parse(nodeData); - if ( - !node || typeof node !== 'object' || - !node.uid || typeof node.uid !== 'string' || - typeof node.parentUid !== 'string' || - !node.directMemberRole || typeof node.directMemberRole !== 'string' || - !node.type || typeof node.type !== 'string' || - !node.mimeType || typeof node.mimeType !== 'string' || - typeof node.isShared !== 'boolean' || - !node.createdDate || typeof node.createdDate !== 'string' || - (typeof node.trashedDate !== 'string' && node.trashedDate !== null) || - !node.volumeId || typeof node.volumeId !== 'string' - ) { - throw new Error(`Invalid node data: ${nodeData}`); - } - return { - ...node, - createdDate: new Date(node.createdDate), - trashedDate: node.trashedDate ? new Date(node.trashedDate) : null, - }; - } +function serialiseNode(node: DecryptedNode) { + return JSON.stringify(node); +} - return { - setNode, - getNode, - removeNodes, - iterateNodes, - iterateChildren, - iterateTrashedNodes, - } +function deserialiseNode(nodeData: string): DecryptedNode { + const node = JSON.parse(nodeData); + if ( + !node || typeof node !== 'object' || + !node.uid || typeof node.uid !== 'string' || + typeof node.parentUid !== 'string' || + !node.directMemberRole || typeof node.directMemberRole !== 'string' || + !node.type || typeof node.type !== 'string' || + !node.mimeType || typeof node.mimeType !== 'string' || + typeof node.isShared !== 'boolean' || + !node.createdDate || typeof node.createdDate !== 'string' || + (typeof node.trashedDate !== 'string' && node.trashedDate !== null) || + !node.volumeId || typeof node.volumeId !== 'string' + ) { + throw new Error(`Invalid node data: ${nodeData}`); + } + return { + ...node, + createdDate: new Date(node.createdDate), + trashedDate: node.trashedDate ? new Date(node.trashedDate) : null, + }; } diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index 19fd35a0..e8dfbe70 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -1,6 +1,6 @@ import { PrivateKey, SessionKey } from "../../crypto"; import { MemoryCache } from "../../cache"; -import { nodesCryptoCache } from "./cryptoCache"; +import { NodesCryptoCache } from "./cryptoCache"; jest.mock('../../crypto/openPGPSerialisation', () => ({ serializePrivateKey: jest.fn((value) => value), @@ -16,7 +16,7 @@ jest.mock('../../crypto/openPGPSerialisation', () => ({ describe('nodesCryptoCache', () => { let memoryCache: MemoryCache; - let cache: ReturnType; + let cache: NodesCryptoCache; const generatePrivateKey = (name: string) => { return name as unknown as PrivateKey @@ -31,7 +31,7 @@ describe('nodesCryptoCache', () => { memoryCache.setEntity('nodeKeys-badKeysObject', 'aaa'); memoryCache.setEntity('nodeKeys-badSessionKey', '{ "passphrase": "pass", "key": "aaa", "sessionKey": "badSessionKey" }'); - cache = nodesCryptoCache(memoryCache); + cache = new NodesCryptoCache(memoryCache); }); it('should store and retrieve keys', async () => { diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts index a6a222f2..aedb0102 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -8,21 +8,25 @@ import { DecryptedNodeKeys } from "./interface"; * The cache is responsible for serialising and deserialising node * crypto material. */ -export function nodesCryptoCache(driveCache: ProtonDriveCache) { - async function setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys) { +export class NodesCryptoCache { + constructor(private driveCache: ProtonDriveCache) { + this.driveCache = driveCache; + } + + async setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys): Promise { const cacheUid = getCacheUid(nodeUid); const nodeKeysData = serializeNodeKeys(keys); - driveCache.setEntity(cacheUid, nodeKeysData); + this.driveCache.setEntity(cacheUid, nodeKeysData); } - async function getNodeKeys(nodeUid: string): Promise { - const nodeKeysData = await driveCache.getEntity(getCacheUid(nodeUid)); + async getNodeKeys(nodeUid: string): Promise { + const nodeKeysData = await this.driveCache.getEntity(getCacheUid(nodeUid)); try { const keys = await deserializeNodeKeys(nodeKeysData); return keys; } catch (error: unknown) { try { - await removeNodeKeys([nodeUid]); + await this.removeNodeKeys([nodeUid]); } catch { // TODO: log error } @@ -31,64 +35,58 @@ export function nodesCryptoCache(driveCache: ProtonDriveCache) { } } - async function removeNodeKeys(nodeUids: string[]) { + async removeNodeKeys(nodeUids: string[]): Promise { const cacheUids = nodeUids.map(getCacheUid); - await driveCache.removeEntities(cacheUids); + await this.driveCache.removeEntities(cacheUids); } +} - function getCacheUid(nodeUid: string) { - return `nodeKeys-${nodeUid}`; - } - - function serializeNodeKeys(keys: DecryptedNodeKeys) { - // TODO: verify how we want to serialize keys - return JSON.stringify({ - passphrase: keys.passphrase, - key: serializePrivateKey(keys.key), - sessionKey: serializeSessionKey(keys.sessionKey), - hashKey: keys.hashKey ? serializeHashKey(keys.hashKey) : undefined, - }); - } +function getCacheUid(nodeUid: string) { + return `nodeKeys-${nodeUid}`; +} - async function deserializeNodeKeys(shareKeyData: string): Promise { - const result = JSON.parse(shareKeyData); - if (!result || typeof result !== 'object') { - throw new Error('Invalid node keys data'); - } +function serializeNodeKeys(keys: DecryptedNodeKeys) { + // TODO: verify how we want to serialize keys + return JSON.stringify({ + passphrase: keys.passphrase, + key: serializePrivateKey(keys.key), + sessionKey: serializeSessionKey(keys.sessionKey), + hashKey: keys.hashKey ? serializeHashKey(keys.hashKey) : undefined, + }); +} - let key, sessionKey, hashKey; +async function deserializeNodeKeys(shareKeyData: string): Promise { + const result = JSON.parse(shareKeyData); + if (!result || typeof result !== 'object') { + throw new Error('Invalid node keys data'); + } - if (!result.passphrase || typeof result.passphrase !== 'string') { - throw new Error('Invalid node passphrase'); - } - const passphrase = result.passphrase; - try { - key = await deserializePrivateKey(result.key); - } catch (error: unknown) { - throw new Error(`Invalid node private key: ${error instanceof Error ? error.message : error}`); - } - try { - sessionKey = deserializeSessionKey(result.sessionKey); - } catch (error: unknown) { - throw new Error(`Invalid node session key: ${error instanceof Error ? error.message : error}`); - } - try { - hashKey = result.hashKey ? deserializeHashKey(result.hashKey) : undefined; - } catch (error: unknown) { - throw new Error(`Invalid node hash key: ${error instanceof Error ? error.message : error}`); - } + let key, sessionKey, hashKey; - return { - passphrase, - key, - sessionKey, - hashKey, - }; + if (!result.passphrase || typeof result.passphrase !== 'string') { + throw new Error('Invalid node passphrase'); } - - return { - setNodeKeys, - getNodeKeys, - removeNodeKeys, + const passphrase = result.passphrase; + try { + key = await deserializePrivateKey(result.key); + } catch (error: unknown) { + throw new Error(`Invalid node private key: ${error instanceof Error ? error.message : error}`); + } + try { + sessionKey = deserializeSessionKey(result.sessionKey); + } catch (error: unknown) { + throw new Error(`Invalid node session key: ${error instanceof Error ? error.message : error}`); } + try { + hashKey = result.hashKey ? deserializeHashKey(result.hashKey) : undefined; + } catch (error: unknown) { + throw new Error(`Invalid node hash key: ${error instanceof Error ? error.message : error}`); + } + + return { + passphrase, + key, + sessionKey, + hashKey, + }; } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 0febc723..35dbe3a4 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,14 +1,14 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; import { ProtonDriveAccount } from "../../interface"; import { EncryptedNode, SharesService } from "./interface"; -import { nodesCryptoService } from "./cryptoService"; +import { NodesCryptoService } from "./cryptoService"; describe("nodesCryptoService", () => { let driveCrypto: DriveCrypto; let account: ProtonDriveAccount; let sharesService: SharesService; - let cryptoService: ReturnType; + let cryptoService: NodesCryptoService; beforeEach(() => { jest.clearAllMocks(); @@ -45,7 +45,7 @@ describe("nodesCryptoService", () => { })), }; - cryptoService = nodesCryptoService(driveCrypto, account, sharesService); + cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); }); it("should decrypt node with same author everywhere", async () => { diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index b6dc0610..dfb09776 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -15,8 +15,18 @@ import { importHmacKey, computeHmacSignature } from "./hmac"; * * The service owns the logic to switch between old and new crypto model. */ -export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriveAccount, shareService: SharesService) { - async function decryptNode(node: EncryptedNode, parentKey: PrivateKey): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { +export class NodesCryptoService { + constructor( + private driveCrypto: DriveCrypto, + private account: ProtonDriveAccount, + private shareService: SharesService, + ) { + this.driveCrypto = driveCrypto; + this.account = account; + this.shareService = shareService; + } + + async decryptNode(node: EncryptedNode, parentKey: PrivateKey): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { const commonNodeMetadata = { ...node, encryptedCrypto: undefined, @@ -24,7 +34,7 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv // Anonymous uploads (without signature email set) use parent key instead. const keyVerificationKeys = node.encryptedCrypto.signatureEmail - ? await account.getPublicKeys(node.encryptedCrypto.signatureEmail) + ? await this.account.getPublicKeys(node.encryptedCrypto.signatureEmail) : [parentKey]; let nameVerificationKeys; @@ -33,13 +43,13 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv nameVerificationKeys = keyVerificationKeys; } else { nameVerificationKeys = nameSignatureEmail - ? await account.getPublicKeys(nameSignatureEmail) + ? await this.account.getPublicKeys(nameSignatureEmail) : [parentKey]; } let passphrase, key, sessionKey, keyAuthor; try { - const keyResult = await decryptKey(node, parentKey, keyVerificationKeys); + const keyResult = await this.decryptKey(node, parentKey, keyVerificationKeys); passphrase = keyResult.passphrase; key = keyResult.key; sessionKey = keyResult.sessionKey; @@ -67,12 +77,12 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv } } - const { name, author: nameAuthor } = await decryptName(node, parentKey, nameVerificationKeys); + const { name, author: nameAuthor } = await this.decryptName(node, parentKey, nameVerificationKeys); let hashKey; let hashKeyAuthor; if ("folder" in node.encryptedCrypto) { - const hashKeyResult = await decryptHashKey(node, key, keyVerificationKeys); + const hashKeyResult = await this.decryptHashKey(node, key, keyVerificationKeys); hashKey = hashKeyResult.hashKey; hashKeyAuthor = hashKeyResult.author; } @@ -96,10 +106,10 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv }; }; - async function decryptKey(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PublicKey[]): Promise, }> { - const key = await driveCrypto.decryptKey( + const key = await this.driveCrypto.decryptKey( node.encryptedCrypto.armoredKey, node.encryptedCrypto.armoredNodePassphrase, node.encryptedCrypto.armoredNodePassphraseSignature, @@ -115,14 +125,14 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv }; }; - async function decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PrivateKey[]): Promise<{ + async decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PrivateKey[]): Promise<{ name: Result, author: Result, }> { const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail || node.encryptedCrypto.signatureEmail; try { - const { name, verified } = await driveCrypto.decryptNodeName( + const { name, verified } = await this.driveCrypto.decryptNodeName( node.encryptedCrypto.encryptedName, parentKey, verificationKeys, @@ -148,7 +158,7 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv } }; - async function decryptHashKey(node: EncryptedNode, nodeKey: PrivateKey, addressKeys: PublicKey[]): Promise<{ + async decryptHashKey(node: EncryptedNode, nodeKey: PrivateKey, addressKeys: PublicKey[]): Promise<{ hashKey: Uint8Array, author: Result, }> { @@ -156,7 +166,7 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv throw new Error('Node is not a folder'); } - const { hashKey, verified } = await driveCrypto.decryptNodeHashKey( + const { hashKey, verified } = await this.driveCrypto.decryptNodeHashKey( node.encryptedCrypto.folder.armoredHashKey, nodeKey, addressKeys, @@ -168,22 +178,22 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv } } - async function createFolder(parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, name: string): Promise<{ + async createFolder(parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, name: string): Promise<{ encryptedCrypto: Required & { hash: string }, keys: DecryptedNodeKeys, }> { - const { email, key: addressKey } = await shareService.getVolumeEmailKey(parentNode.volumeId); + const { email, key: addressKey } = await this.shareService.getVolumeEmailKey(parentNode.volumeId); const [ nodeKeys, { armoredNodeName }, hash, ] = await Promise.all([ - driveCrypto.generateKey([parentKeys.key], addressKey), - driveCrypto.encryptNodeName(name, parentKeys.key, addressKey), - generateLookupHash(name, parentKeys.hashKey), + this.driveCrypto.generateKey([parentKeys.key], addressKey), + this.driveCrypto.encryptNodeName(name, parentKeys.key, addressKey), + this.generateLookupHash(name, parentKeys.hashKey), ]); - const { armoredHashKey, hashKey } = await driveCrypto.generateHashKey(nodeKeys.decrypted.key); + const { armoredHashKey, hashKey } = await this.driveCrypto.generateHashKey(nodeKeys.decrypted.key); return { encryptedCrypto: { @@ -208,14 +218,14 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv }; } - async function encryptNewName(node: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, newName: string): Promise<{ + async encryptNewName(node: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, newName: string): Promise<{ signatureEmail: string, armoredNodeName: string, hash: string, }> { - const { email, key: addressKey } = await shareService.getVolumeEmailKey(node.volumeId); - const { armoredNodeName } = await driveCrypto.encryptNodeName(newName, parentKeys.key, addressKey); - const hash = await generateLookupHash(newName, parentKeys.hashKey); + const { email, key: addressKey } = await this.shareService.getVolumeEmailKey(node.volumeId); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, parentKeys.key, addressKey); + const hash = await this.generateLookupHash(newName, parentKeys.hashKey); return { signatureEmail: email, armoredNodeName, @@ -223,7 +233,7 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv }; }; - async function moveNode(node: DecryptedNode, keys: { passphrase: string, sessionKey: SessionKey }, parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }): Promise<{ + async moveNode(node: DecryptedNode, keys: { passphrase: string, sessionKey: SessionKey }, parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }): Promise<{ encryptedName: string, hash: string, armoredNodePassphrase: string, @@ -238,10 +248,10 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv throw new Error('Cannot move node without a valid name, please rename the node first'); } - const { email, key: addressKey } = await shareService.getVolumeEmailKey(parentNode.volumeId); - const { armoredNodeName } = await driveCrypto.encryptNodeName(node.name.value, parentKeys.key, addressKey); - const hash = await generateLookupHash(node.name.value, parentKeys.hashKey); - const { armoredPassphrase, armoredPassphraseSignature } = await driveCrypto.encryptPassphrase(keys.passphrase, keys.sessionKey, [parentKeys.key], addressKey); + const { email, key: addressKey } = await this.shareService.getVolumeEmailKey(parentNode.volumeId); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, parentKeys.key, addressKey); + const hash = await this.generateLookupHash(node.name.value, parentKeys.hashKey); + const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.sessionKey, [parentKeys.key], addressKey); return { encryptedName: armoredNodeName, @@ -253,19 +263,12 @@ export function nodesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriv }; } - async function generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { + async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { const key = await importHmacKey(parentHashKey); const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); return arrayToHexString(signature); } - - return { - decryptNode, - createFolder, - encryptNewName, - moveNode, - } } function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string): Result { diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index a92bdc5f..43b31964 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -1,6 +1,6 @@ -import { NodeEventCallback } from "../../interface/index.js"; -import { DriveEventsService } from "../events/index.js"; -import { nodesCache } from "./cache.js"; +import { NodeEventCallback } from "../../interface/index"; +import { DriveEventsService } from "../events/index"; +import { NodesCache } from "./cache"; type NodeEventInfo = { parentNodeUid: string, @@ -17,7 +17,7 @@ type NodeEventInfo = { * any update for trashed nodes. */ export function nodesEvents( - cache: ReturnType, + cache: NodesCache, events: DriveEventsService, ) { const listeners: { condition: (nodeEventInfo: NodeEventInfo) => boolean, callback: NodeEventCallback }[] = []; diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index af30503b..079a665b 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -3,14 +3,14 @@ import { ProtonDriveCache } from "../../cache"; import { DriveCrypto } from "../../crypto"; import { DriveEventsService } from "../events"; import { Logger, ProtonDriveAccount } from "../../interface"; -import { nodeAPIService } from "./apiService"; -import { nodesCache } from "./cache"; +import { NodeAPIService } from "./apiService"; +import { NodesCache } from "./cache"; import { nodesEvents } from "./events"; -import { nodesCryptoCache } from "./cryptoCache"; -import { nodesCryptoService } from "./cryptoService"; +import { NodesCryptoCache } from "./cryptoCache"; +import { NodesCryptoService } from "./cryptoService"; import { SharesService, DecryptedNode } from "./interface"; -import { nodesAccess } from "./nodesAccess"; -import { nodesManager } from "./manager"; +import { NodesAccess } from "./nodesAccess"; +import { NodesManager } from "./manager"; export type { DecryptedNode } from "./interface"; @@ -23,7 +23,7 @@ export type { DecryptedNode } from "./interface"; * This facade provides internal interface that other modules can use to * interact with the nodes. */ -export function nodes( +export function initNodesModule( apiService: DriveAPIService, driveEntitiesCache: ProtonDriveCache, driveCryptoCache: ProtonDriveCache, @@ -33,23 +33,26 @@ export function nodes( sharesService: SharesService, logger?: Logger, ) { - const api = nodeAPIService(apiService, logger); - const cache = nodesCache(driveEntitiesCache, logger); - const cryptoCache = nodesCryptoCache(driveCryptoCache); - const cryptoService = nodesCryptoService(driveCrypto, account, sharesService); - const nodesAccessFunctions = nodesAccess(api, cache, cryptoCache, cryptoService, sharesService); - const nodesFunctions = nodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccessFunctions); + const api = new NodeAPIService(apiService, logger); + const cache = new NodesCache(driveEntitiesCache, logger); + const cryptoCache = new NodesCryptoCache(driveCryptoCache); + const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); + const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); + const nodesManager = new NodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccess); const nodesEventsFunctions = nodesEvents(cache, driveEvents); return { - getNode: nodesAccessFunctions.getNode, - getNodeKeys: nodesAccessFunctions.getNodeKeys, - ...nodesFunctions, + // TODO: expose in better way + getNode: nodesAccess.getNode, + getNodeKeys: nodesAccess.getNodeKeys, + getMyFilesRootFolder: nodesManager.getMyFilesRootFolder, + iterateChildren: nodesManager.iterateChildren, + iterateNodes: nodesManager.iterateNodes, ...nodesEventsFunctions, } } -export function publicNodes( +export function initPublicNodesModule( apiService: DriveAPIService, driveEntitiesCache: ProtonDriveCache, driveCryptoCache: ProtonDriveCache, @@ -57,13 +60,13 @@ export function publicNodes( sharesService: SharesService, ) { // TODO: create public node API service - const api = nodeAPIService(apiService); - const cache = nodesCache(driveEntitiesCache); - const cryptoCache = nodesCryptoCache(driveCryptoCache); + const api = new NodeAPIService(apiService); + const cache = new NodesCache(driveEntitiesCache); + const cryptoCache = new NodesCryptoCache(driveCryptoCache); // @ts-expect-error TODO - const cryptoService = nodesCryptoService(driveCrypto); - const nodesAccessFunctions = nodesAccess(api, cache, cryptoCache, cryptoService, sharesService); - const nodesListingFunctions = nodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccessFunctions); + const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); + const nodesAccessFunctions = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); + const nodesManager = new NodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccessFunctions); return { // TODO: use public root node, not my files @@ -71,7 +74,7 @@ export function publicNodes( getPublicRootNode: async (token: string, password: string, customPassword?: string): Promise => { return {} as DecryptedNode }, getNode: nodesAccessFunctions.getNode, getNodeKeys: nodesAccessFunctions.getNodeKeys, - iterateChildren: nodesListingFunctions.iterateChildren, - iterateNodes: nodesListingFunctions.iterateNodes, + iterateChildren: nodesManager.iterateChildren, + iterateNodes: nodesManager.iterateNodes, } } diff --git a/js/sdk/src/internal/nodes/manager.ts b/js/sdk/src/internal/nodes/manager.ts index c86df83e..51930192 100644 --- a/js/sdk/src/internal/nodes/manager.ts +++ b/js/sdk/src/internal/nodes/manager.ts @@ -1,11 +1,11 @@ import { MemberRole, NodeType, resultOk } from "../../interface"; -import { nodeAPIService, ResultErrors, NodeErrors } from "./apiService.js"; -import { nodesCache } from "./cache.js" -import { nodesCryptoCache } from "./cryptoCache.js" -import { nodesCryptoService } from "./cryptoService.js"; -import { SharesService, DecryptedNode } from "./interface.js"; -import { nodesAccess } from "./nodesAccess.js"; -import { makeNodeUid } from "./nodeUid.js"; +import { NodeAPIService, ResultErrors, NodeErrors } from "./apiService"; +import { NodesCache } from "./cache"; +import { NodesCryptoCache } from "./cryptoCache"; +import { NodesCryptoService } from "./cryptoService"; +import { SharesService, DecryptedNode } from "./interface"; +import { NodesAccess } from "./nodesAccess"; +import { makeNodeUid } from "./nodeUid"; const BATCH_LOADING = 10; @@ -18,30 +18,39 @@ const BATCH_LOADING = 10; * This module uses other modules providing low-level operations, such * as API service, cache, crypto service, etc. */ -export function nodesManager( - apiService: ReturnType, - cache: ReturnType, - cryptoCache: ReturnType, - cryptoService: ReturnType, - shareService: SharesService, - nodesAccessFunctions: ReturnType, -) { - async function getMyFilesRootFolder() { - const { volumeId, rootNodeId } = await shareService.getMyFilesIDs(); +export class NodesManager { + constructor( + private apiService: NodeAPIService, + private cache: NodesCache, + private cryptoCache: NodesCryptoCache, + private cryptoService: NodesCryptoService, + private shareService: SharesService, + private nodesAccess: NodesAccess, + ) { + this.apiService = apiService; + this.cache = cache; + this.cryptoCache = cryptoCache; + this.cryptoService = cryptoService; + this.shareService = shareService; + this.nodesAccess = nodesAccess; + } + + async getMyFilesRootFolder() { + const { volumeId, rootNodeId } = await this.shareService.getMyFilesIDs(); const nodeUid = makeNodeUid(volumeId, rootNodeId); - return nodesAccessFunctions.getNode(nodeUid); + return this.nodesAccess.getNode(nodeUid); } // Improvement requested: keep status of loaded children and leverage cache. - async function *iterateChildren(parentNodeUid: string, signal?: AbortSignal) { + async *iterateChildren(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { // Ensure the parent is loaded and up-to-date. - const parentNode = await nodesAccessFunctions.getNode(parentNodeUid); + const parentNode = await this.nodesAccess.getNode(parentNodeUid); - const batchLoading = new BatchNodesLoading(nodesAccessFunctions.loadNodes); - for await (const nodeUid of apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { + const batchLoading = new BatchNodesLoading(this.nodesAccess.loadNodes); + for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { let node; try { - node = await cache.getNode(nodeUid); + node = await this.cache.getNode(nodeUid); } catch {} if (node && !node.isStale) { @@ -52,13 +61,13 @@ export function nodesManager( } } - async function *iterateTrashedNodes(signal?: AbortSignal) { - const { volumeId } = await shareService.getMyFilesIDs(); - const batchLoading = new BatchNodesLoading(nodesAccessFunctions.loadNodes); - for await (const nodeUid of apiService.iterateTrashedNodeUids(volumeId, signal)) { + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.shareService.getMyFilesIDs(); + const batchLoading = new BatchNodesLoading(this.nodesAccess.loadNodes); + for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { let node; try { - node = await cache.getNode(nodeUid); + node = await this.cache.getNode(nodeUid); } catch {} if (node && !node.isStale) { @@ -69,9 +78,9 @@ export function nodesManager( } } - async function *iterateNodes(nodeUids: string[], signal?: AbortSignal) { - const batchLoading = new BatchNodesLoading(nodesAccessFunctions.loadNodes); - for await (const result of cache.iterateNodes(nodeUids)) { + async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const batchLoading = new BatchNodesLoading(this.nodesAccess.loadNodes); + for await (const result of this.cache.iterateNodes(nodeUids)) { if (result.ok && !result.node.isStale) { yield result.node; } else { @@ -80,9 +89,9 @@ export function nodesManager( } } - async function renameNode(nodeUid: string, newName: string) { - const node = await nodesAccessFunctions.getNode(nodeUid); - const parentKeys = await nodesAccessFunctions.getParentKeys(node); + async renameNode(nodeUid: string, newName: string): Promise { + const node = await this.nodesAccess.getNode(nodeUid); + const parentKeys = await this.nodesAccess.getParentKeys(node); if (!node.hash || !parentKeys.hashKey) { throw new Error('Renaming root nodes is not supported') @@ -92,8 +101,8 @@ export function nodesManager( signatureEmail, armoredNodeName, hash, - } = await cryptoService.encryptNewName(node, { key: parentKeys.key, hashKey: parentKeys.hashKey }, newName); - await apiService.renameNode( + } = await this.cryptoService.encryptNewName(node, { key: parentKeys.key, hashKey: parentKeys.hashKey }, newName); + await this.apiService.renameNode( nodeUid, { hash: node.hash, @@ -104,7 +113,7 @@ export function nodesManager( hash: hash, } ); - await cache.setNode({ + await this.cache.setNode({ ...node, name: resultOk(newName), nameAuthor: resultOk(signatureEmail), @@ -112,14 +121,14 @@ export function nodesManager( }); } - async function moveNode(nodeUid: string, newParentUid: string) { + async moveNode(nodeUid: string, newParentUid: string): Promise { const [node, newParentNode] = await Promise.all([ - nodesAccessFunctions.getNode(nodeUid), - nodesAccessFunctions.getNode(newParentUid), + this.nodesAccess.getNode(nodeUid), + this.nodesAccess.getNode(newParentUid), ]); const [keys, newParentKeys] = await Promise.all([ - nodesAccessFunctions.getNodeKeys(nodeUid), - nodesAccessFunctions.getNodeKeys(newParentUid), + this.nodesAccess.getNodeKeys(nodeUid), + this.nodesAccess.getNodeKeys(newParentUid), ]); if (!node.hash) { @@ -129,13 +138,13 @@ export function nodesManager( throw new Error('Moving nodes to a non-folder is not supported'); } - const encryptedCrypto = await cryptoService.moveNode( + const encryptedCrypto = await this.cryptoService.moveNode( node, keys, newParentNode, { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, ); - await apiService.moveNode( + await this.apiService.moveNode( nodeUid, { hash: node.hash, @@ -151,16 +160,16 @@ export function nodesManager( // TODO: content hash } ); - await cache.setNode({ + await this.cache.setNode({ ...node, parentUid: newParentUid, }); } - async function trashNodes(nodeUids: string[], signal?: AbortSignal) { + async trashNodes(nodeUids: string[], signal?: AbortSignal): Promise { const nodesPerParent = new Map(); - for await (const node of iterateNodes(nodeUids, signal)) { + for await (const node of this.iterateNodes(nodeUids, signal)) { if (!node.parentUid) { throw new Error('Trashing root nodes is not supported'); } @@ -177,7 +186,7 @@ export function nodesManager( for (const [parentNodeUid, nodes] of nodesPerParent) { let updatedNodes: DecryptedNode[]; try { - await apiService.trashNodes(parentNodeUid, nodes.map(node => node.uid), signal); + await this.apiService.trashNodes(parentNodeUid, nodes.map(node => node.uid), signal); updatedNodes = nodes; } catch (error: unknown) { if (error instanceof ResultErrors) { @@ -189,7 +198,7 @@ export function nodesManager( } } for (const node of updatedNodes) { - await cache.setNode({ + await this.cache.setNode({ ...node, trashedDate: new Date(), }); @@ -201,13 +210,13 @@ export function nodesManager( } } - async function restoreNodes(nodeUids: string[], signal?: AbortSignal) { - const nodes = await Array.fromAsync(iterateNodes(nodeUids, signal)); + async restoreNodes(nodeUids: string[], signal?: AbortSignal): Promise { + const nodes = await Array.fromAsync(this.iterateNodes(nodeUids, signal)); let updatedNodes: DecryptedNode[]; let catchedError: unknown; try { - await apiService.restoreNodes(nodeUids, signal); + await this.apiService.restoreNodes(nodeUids, signal); updatedNodes = nodes; } catch (error: unknown) { catchedError = error; @@ -219,7 +228,7 @@ export function nodesManager( } for (const node of updatedNodes) { - await cache.setNode({ + await this.cache.setNode({ ...node, trashedDate: new Date(), }); @@ -230,12 +239,12 @@ export function nodesManager( } } - async function deleteNodes(nodeUids: string[], signal?: AbortSignal) { + async deleteNodes(nodeUids: string[], signal?: AbortSignal): Promise { let updatedNodeUids: string[]; let catchedError: unknown; try { - await apiService.restoreNodes(nodeUids, signal); + await this.apiService.restoreNodes(nodeUids, signal); updatedNodeUids = nodeUids; } catch (error: unknown) { catchedError = error; @@ -247,7 +256,7 @@ export function nodesManager( } if (updatedNodeUids) { - await cache.removeNodes(updatedNodeUids); + await this.cache.removeNodes(updatedNodeUids); } if (catchedError) { @@ -255,15 +264,15 @@ export function nodesManager( } } - async function createFolder(parentNodeUid: string, folderName: string, signal?: AbortSignal) { - const parentNode = await nodesAccessFunctions.getNode(parentNodeUid); - const parentKeys = await nodesAccessFunctions.getNodeKeys(parentNodeUid); + async createFolder(parentNodeUid: string, folderName: string, signal?: AbortSignal): Promise { + const parentNode = await this.nodesAccess.getNode(parentNodeUid); + const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); if (!parentKeys.hashKey) { throw new Error('Creating folders in non-folders is not supported'); } - const { encryptedCrypto, keys } = await cryptoService.createFolder(parentNode, { key: parentKeys.key, hashKey: parentKeys.hashKey }, folderName); - const nodeUid = await apiService.createFolder(parentNodeUid, { + const { encryptedCrypto, keys } = await this.cryptoService.createFolder(parentNode, { key: parentKeys.key, hashKey: parentKeys.hashKey }, folderName); + const nodeUid = await this.apiService.createFolder(parentNodeUid, { armoredKey: encryptedCrypto.armoredKey, armoredHashKey: encryptedCrypto.folder.armoredHashKey, armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, @@ -274,7 +283,7 @@ export function nodesManager( encryptedExtendedAttributes: encryptedCrypto.folder.encryptedExtendedAttributes || "", // TODO }, signal); - await cache.setNode({ + const node: DecryptedNode = { // Internal metadata volumeId: parentNode.volumeId, hash: encryptedCrypto.hash, @@ -296,21 +305,11 @@ export function nodesManager( nameAuthor: resultOk(encryptedCrypto.signatureEmail), name: resultOk(folderName), activeRevision: resultOk(null), - }); - await cryptoCache.setNodeKeys(nodeUid, keys); - } + } - return { - getMyFilesRootFolder, - iterateChildren, - iterateTrashedNodes, - iterateNodes, - renameNode, - moveNode, - trashNodes, - restoreNodes, - deleteNodes, - createFolder, + await this.cache.setNode(node); + await this.cryptoCache.setNodeKeys(nodeUid, keys); + return node; } } diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 36cd0a8c..2de82855 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -1,18 +1,18 @@ import { PrivateKey } from "../../crypto"; -import { nodeAPIService } from "./apiService"; -import { nodesCache } from "./cache" -import { nodesCryptoCache } from "./cryptoCache"; -import { nodesCryptoService } from "./cryptoService"; -import { nodesAccess } from './nodesAccess'; +import { NodeAPIService } from "./apiService"; +import { NodesCache } from "./cache" +import { NodesCryptoCache } from "./cryptoCache"; +import { NodesCryptoService } from "./cryptoService"; +import { NodesAccess } from './nodesAccess'; import { SharesService, DecryptedNode, EncryptedNode, DecryptedNodeKeys } from "./interface"; describe('nodesAccess', () => { - let apiService: ReturnType; - let cache: ReturnType; - let cryptoCache: ReturnType; - let cryptoService: ReturnType; + let apiService: NodeAPIService; + let cache: NodesCache; + let cryptoCache: NodesCryptoCache; + let cryptoService: NodesCryptoService; let shareService: SharesService; - let access: ReturnType; + let access: NodesAccess; beforeEach(() => { // @ts-expect-error No need to implement all methods for mocking @@ -39,7 +39,7 @@ describe('nodesAccess', () => { getSharePrivateKey: jest.fn(), }; - access = nodesAccess(apiService, cache, cryptoCache, cryptoService, shareService); + access = new NodesAccess(apiService, cache, cryptoCache, cryptoService, shareService); }); describe('getNode', () => { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 92102467..87ebbeba 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,7 +1,7 @@ -import { nodeAPIService } from "./apiService"; -import { nodesCache } from "./cache" -import { nodesCryptoCache } from "./cryptoCache"; -import { nodesCryptoService } from "./cryptoService"; +import { NodeAPIService } from "./apiService"; +import { NodesCache } from "./cache" +import { NodesCryptoCache } from "./cryptoCache"; +import { NodesCryptoService } from "./cryptoService"; import { SharesService, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; /** @@ -10,67 +10,75 @@ import { SharesService, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from " * The node access module is responsible for fetching, decrypting and caching * nodes metadata. */ -export function nodesAccess( - apiService: ReturnType, - cache: ReturnType, - cryptoCache: ReturnType, - cryptoService: ReturnType, - shareService: SharesService, -) { - async function getNode(nodeUid: string) { +export class NodesAccess { + constructor( + private apiService: NodeAPIService, + private cache: NodesCache, + private cryptoCache: NodesCryptoCache, + private cryptoService: NodesCryptoService, + private shareService: SharesService, + ) { + this.apiService = apiService; + this.cache = cache; + this.cryptoCache = cryptoCache; + this.cryptoService = cryptoService; + this.shareService = shareService; + } + + async getNode(nodeUid: string): Promise { let cachedNode; try { - cachedNode = await cache.getNode(nodeUid); + cachedNode = await this.cache.getNode(nodeUid); } catch {} if (cachedNode && !cachedNode.isStale) { return cachedNode; } - const { node } = await loadNode(nodeUid); + const { node } = await this.loadNode(nodeUid); return node; } - async function loadNode(nodeUid: string) { - const encryptedNode = await apiService.getNode(nodeUid); - return decryptNode(encryptedNode); + private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { + const encryptedNode = await this.apiService.getNode(nodeUid); + return this.decryptNode(encryptedNode); } - async function loadNodes(nodeUids: string[], signal?: AbortSignal) { + async loadNodes(nodeUids: string[], signal?: AbortSignal): Promise { // TODO: batching - const encryptedNodes = await apiService.getNodes(nodeUids, signal); - const results = await Promise.all(encryptedNodes.map(decryptNode)); + const encryptedNodes = await this.apiService.getNodes(nodeUids, signal); + const results = await Promise.all(encryptedNodes.map((encryptedNode) => this.decryptNode(encryptedNode))); return results.map(({ node }) => node); } - async function decryptNode(encryptedNode: EncryptedNode) { - const { key: parentKey } = await getParentKeys(encryptedNode); - const { node, keys } = await cryptoService.decryptNode(encryptedNode, parentKey); - cache.setNode(node); + private async decryptNode(encryptedNode: EncryptedNode): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { + const { key: parentKey } = await this.getParentKeys(encryptedNode); + const { node, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); + this.cache.setNode(node); if (keys) { - cryptoCache.setNodeKeys(node.uid, keys); + this.cryptoCache.setNodeKeys(node.uid, keys); } return { node, keys }; } - async function getParentKeys(node: Pick): Promise> { + async getParentKeys(node: Pick): Promise> { if (node.parentUid) { - return getNodeKeys(node.parentUid); + return this.getNodeKeys(node.parentUid); } if (!node.shareId) { // TODO: better error message throw new Error('Node tree has no parent to access the keys'); } return { - key: await shareService.getSharePrivateKey(node.shareId), + key: await this.shareService.getSharePrivateKey(node.shareId), } } - async function getNodeKeys(nodeUid: string): Promise { + async getNodeKeys(nodeUid: string): Promise { try { - return cryptoCache.getNodeKeys(nodeUid); + return this.cryptoCache.getNodeKeys(nodeUid); } catch { - const { keys } = await loadNode(nodeUid); + const { keys } = await this.loadNode(nodeUid); if (!keys) { // TODO: better error message throw new Error('Parent node cannot be decrypted'); @@ -78,11 +86,4 @@ export function nodesAccess( return keys; } } - - return { - getNode, - getParentKeys, - getNodeKeys, - loadNodes, - } } diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index 773e8e4d..774c37a7 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -1,5 +1,5 @@ -import { DriveAPIService, drivePaths } from "../apiService/index.js"; -import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto } from "./interface.js"; +import { DriveAPIService, drivePaths } from "../apiService"; +import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto } from "./interface"; type PostCreateVolumeRequest = Extract['content']['application/json']; type PostCreateVolumeResponse = drivePaths['/drive/volumes']['post']['responses']['200']['content']['application/json']; @@ -17,9 +17,13 @@ type GetShareResponse = drivePaths['/drive/shares/{shareID}']['get']['responses' * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ -export function sharesAPIService(apiService: DriveAPIService) { - async function getMyFiles(): Promise { - const response = await apiService.get('drive/v2/shares/my-files'); +export class SharesAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; + } + + async getMyFiles(): Promise { + const response = await this.apiService.get('drive/v2/shares/my-files'); return { volumeId: response.Volume.VolumeID, shareId: response.Share.ShareID, @@ -34,15 +38,15 @@ export function sharesAPIService(apiService: DriveAPIService) { }; } - async function getVolume(volumeId: string): Promise<{ shareId: string }> { - const response = await apiService.get(`drive/volumes/${volumeId}`); + async getVolume(volumeId: string): Promise<{ shareId: string }> { + const response = await this.apiService.get(`drive/volumes/${volumeId}`); return { shareId: response.Volume.Share.ShareID, } } - async function getShare(shareId: string): Promise { - const response = await apiService.get(`drive/shares/${shareId}`); + async getShare(shareId: string): Promise { + const response = await this.apiService.get(`drive/shares/${shareId}`); return convertSharePayload(response); } @@ -55,8 +59,8 @@ export function sharesAPIService(apiService: DriveAPIService) { * * @throws Error when share is not root share. */ - async function getRootShare(shareId: string): Promise { - const response = await apiService.get(`drive/shares/${shareId}`); + async getRootShare(shareId: string): Promise { + const response = await this.apiService.get(`drive/shares/${shareId}`); if (!response.AddressID) { throw new Error('Loading share without direct access is not supported'); @@ -68,21 +72,7 @@ export function sharesAPIService(apiService: DriveAPIService) { }; } - function convertSharePayload(response: GetShareResponse): EncryptedShare { - return { - volumeId: response.VolumeID, - shareId: response.ShareID, - rootNodeId: response.LinkID, - creatorEmail: response.Creator, - encryptedCrypto: { - armoredKey: response.Key, - armoredPassphrase: response.Passphrase, - armoredPassphraseSignature: response.PassphraseSignature, - }, - }; - } - - async function createVolume( + async createVolume( share: { addressId: string, addressKeyId: string, @@ -95,7 +85,7 @@ export function sharesAPIService(apiService: DriveAPIService) { armoredHashKey: string, }, ): Promise<{ volumeId: string, shareId: string, rootNodeId: string }> { - const response = await apiService.post< + const response = await this.apiService.post< // Volume & share names are deprecated. Omit, PostCreateVolumeResponse @@ -119,7 +109,7 @@ export function sharesAPIService(apiService: DriveAPIService) { } } - async function createShare( + async createShare( volumeId: string, share: { addressId: string, @@ -131,7 +121,7 @@ export function sharesAPIService(apiService: DriveAPIService) { passphraseKeyPacket: string, }, ): Promise<{ shareId: string }> { - const response = await apiService.post< + const response = await this.apiService.post< // Share name is deprecated. Omit, PostCreateShareResponse @@ -149,13 +139,18 @@ export function sharesAPIService(apiService: DriveAPIService) { shareId: response.Share.ID, } } +} +function convertSharePayload(response: GetShareResponse): EncryptedShare { return { - getMyFiles, - getVolume, - getShare, - getRootShare, - createVolume, - createShare, - } + volumeId: response.VolumeID, + shareId: response.ShareID, + rootNodeId: response.LinkID, + creatorEmail: response.Creator, + encryptedCrypto: { + armoredKey: response.Key, + armoredPassphrase: response.Passphrase, + armoredPassphraseSignature: response.PassphraseSignature, + }, + }; } diff --git a/js/sdk/src/internal/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts index 5709d798..0e7daeaf 100644 --- a/js/sdk/src/internal/shares/cache.test.ts +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -1,15 +1,15 @@ import { MemoryCache } from "../../cache"; -import { sharesCache } from "./cache"; +import { SharesCache } from "./cache"; describe('sharesCache', () => { let memoryCache: MemoryCache; - let cache: ReturnType; + let cache: SharesCache; beforeEach(() => { memoryCache = new MemoryCache([]); memoryCache.setEntity('volume-badObject', 'aaa'); - cache = sharesCache(memoryCache); + cache = new SharesCache(memoryCache); }); it('should store and retrieve volume', async () => { diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index d70ae583..c2d636d8 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -1,27 +1,31 @@ -import { ProtonDriveCache } from "../../cache/index.js"; -import { Volume } from "./interface.js"; +import { ProtonDriveCache } from "../../cache"; +import { Volume } from "./interface"; /** * Provides caching for shares and volume metadata. * * The cache is responsible for serialising and deserialising volume metadata. */ -export function sharesCache(driveCache: ProtonDriveCache) { - async function setVolume(volume: Volume) { +export class SharesCache { + constructor(private driveCache: ProtonDriveCache) { + this.driveCache = driveCache; + } + + async setVolume(volume: Volume): Promise { const key = getCacheUid(volume.volumeId); const shareData = serializeVolume(volume); - driveCache.setEntity(key, shareData); + this.driveCache.setEntity(key, shareData); } - async function getVolume(volumeId: string): Promise { + async getVolume(volumeId: string): Promise { const key = getCacheUid(volumeId); - const volumeData = await driveCache.getEntity(key); + const volumeData = await this.driveCache.getEntity(key); try { return deserializeVolume(volumeData); } catch (error: unknown) { try { - await removeVolume(volumeId); + await this.removeVolume(volumeId); } catch { // TODO: log error } @@ -29,35 +33,29 @@ export function sharesCache(driveCache: ProtonDriveCache) { } } - async function removeVolume(volumeId: string) { - await driveCache.removeEntities([getCacheUid(volumeId)]); + async removeVolume(volumeId: string): Promise { + await this.driveCache.removeEntities([getCacheUid(volumeId)]); } +} - function getCacheUid(volumeId: string) { - return `volume-${volumeId}`; - } - - function serializeVolume(volume: Volume) { - return JSON.stringify(volume); - } +function getCacheUid(volumeId: string) { + return `volume-${volumeId}`; +} - function deserializeVolume(shareData: string): Volume { - const volume = JSON.parse(shareData); - if ( - !volume || typeof volume !== 'object' || - !volume.volumeId || typeof volume.volumeId !== 'string' || - !volume.shareId || typeof volume.shareId !== 'string' || - !volume.rootNodeId || typeof volume.rootNodeId !== 'string' || - !volume.creatorEmail || typeof volume.creatorEmail !== 'string' - ) { - throw new Error('Invalid volume data'); - } - return volume; - } +function serializeVolume(volume: Volume) { + return JSON.stringify(volume); +} - return { - setVolume, - getVolume, - removeVolume, +function deserializeVolume(shareData: string): Volume { + const volume = JSON.parse(shareData); + if ( + !volume || typeof volume !== 'object' || + !volume.volumeId || typeof volume.volumeId !== 'string' || + !volume.shareId || typeof volume.shareId !== 'string' || + !volume.rootNodeId || typeof volume.rootNodeId !== 'string' || + !volume.creatorEmail || typeof volume.creatorEmail !== 'string' + ) { + throw new Error('Invalid volume data'); } -} + return volume; +} \ No newline at end of file diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts index 0ea6171d..5f12234f 100644 --- a/js/sdk/src/internal/shares/cryptoCache.test.ts +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -1,6 +1,6 @@ import { PrivateKey, SessionKey } from "../../crypto"; import { MemoryCache } from "../../cache"; -import { sharesCryptoCache } from "./cryptoCache"; +import { SharesCryptoCache } from "./cryptoCache"; jest.mock('../../crypto/openPGPSerialisation', () => ({ serializePrivateKey: jest.fn((value) => value), @@ -16,7 +16,7 @@ jest.mock('../../crypto/openPGPSerialisation', () => ({ describe('sharesCryptoCache', () => { let memoryCache: MemoryCache; - let cache: ReturnType; + let cache: SharesCryptoCache; const generatePrivateKey = (name: string) => { return name as unknown as PrivateKey @@ -31,7 +31,7 @@ describe('sharesCryptoCache', () => { memoryCache.setEntity('shareKey-badKeysObject', 'aaa'); memoryCache.setEntity('shareKey-badSessionKey', '{ "key": "aaa", "sessionKey": "badSessionKey" }'); - cache = sharesCryptoCache(memoryCache); + cache = new SharesCryptoCache(memoryCache); }); it('should store and retrieve keys', async () => { diff --git a/js/sdk/src/internal/shares/cryptoCache.ts b/js/sdk/src/internal/shares/cryptoCache.ts index 01f92223..c7d07e1f 100644 --- a/js/sdk/src/internal/shares/cryptoCache.ts +++ b/js/sdk/src/internal/shares/cryptoCache.ts @@ -13,19 +13,23 @@ import { DecryptedShareCrypto } from "./interface"; * from the server again. Otherwise the rest of the tree requires * only the root node, thus share cache is not needed. */ -export function sharesCryptoCache(driveCache: ProtonDriveCache) { - async function setShareKey(shareId: string, keys: DecryptedShareCrypto) { - await driveCache.setEntity(getCacheUid(shareId), serializeShareKey(keys)); +export class SharesCryptoCache { + constructor(private driveCache: ProtonDriveCache) { + this.driveCache = driveCache; } - async function getShareKey(shareId: string): Promise { - const shareKeyData = await driveCache.getEntity(getCacheUid(shareId)); + async setShareKey(shareId: string, keys: DecryptedShareCrypto): Promise { + await this.driveCache.setEntity(getCacheUid(shareId), serializeShareKey(keys)); + } + + async getShareKey(shareId: string): Promise { + const shareKeyData = await this.driveCache.getEntity(getCacheUid(shareId)); try { const keys = await deserializeShareKey(shareKeyData); return keys; } catch (error: unknown) { try { - await removeShareKey([shareId]); + await this.removeShareKey([shareId]); } catch { // TODO: log error } @@ -34,50 +38,44 @@ export function sharesCryptoCache(driveCache: ProtonDriveCache) { } } - async function removeShareKey(shareIds: string[]) { - await driveCache.removeEntities(shareIds.map(getCacheUid)); + async removeShareKey(shareIds: string[]): Promise { + await this.driveCache.removeEntities(shareIds.map(getCacheUid)); } +} - function getCacheUid(shareId: string) { - return `shareKey-${shareId}`; - } +function getCacheUid(shareId: string) { + return `shareKey-${shareId}`; +} - function serializeShareKey(keys: DecryptedShareCrypto) { - // TODO: verify how we want to serialize keys - return JSON.stringify({ - key: serializePrivateKey(keys.key), - sessionKey: serializeSessionKey(keys.sessionKey), - }); - } +function serializeShareKey(keys: DecryptedShareCrypto) { + // TODO: verify how we want to serialize keys + return JSON.stringify({ + key: serializePrivateKey(keys.key), + sessionKey: serializeSessionKey(keys.sessionKey), + }); +} - async function deserializeShareKey(shareKeyData: string): Promise { - const result = JSON.parse(shareKeyData); - if (!result || typeof result !== 'object') { - throw new Error('Invalid share keys data'); - } +async function deserializeShareKey(shareKeyData: string): Promise { + const result = JSON.parse(shareKeyData); + if (!result || typeof result !== 'object') { + throw new Error('Invalid share keys data'); + } - let key, sessionKey; + let key, sessionKey; - try { - key = await deserializePrivateKey(result.key); - } catch (error: unknown) { - throw new Error(`Invalid share private key: ${error instanceof Error ? error.message : error}`); - } - try { - sessionKey = deserializeSessionKey(result.sessionKey); - } catch (error: unknown) { - throw new Error(`Invalid share session key: ${error instanceof Error ? error.message : error}`); - } - - return { - key, - sessionKey, - }; + try { + key = await deserializePrivateKey(result.key); + } catch (error: unknown) { + throw new Error(`Invalid share private key: ${error instanceof Error ? error.message : error}`); } - - return { - setShareKey, - getShareKey, - removeShareKey, + try { + sessionKey = deserializeSessionKey(result.sessionKey); + } catch (error: unknown) { + throw new Error(`Invalid share session key: ${error instanceof Error ? error.message : error}`); } + + return { + key, + sessionKey, + }; } diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 203ec99d..b3a261d2 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -1,6 +1,6 @@ -import { ProtonDriveAccount } from "../../interface/index.js"; -import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto/index.js"; -import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareCrypto } from "./interface.js"; +import { ProtonDriveAccount } from "../../interface"; +import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto"; +import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareCrypto } from "./interface"; /** * Provides crypto operations for share keys. @@ -12,8 +12,13 @@ import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, Decrypted * * The service owns the logic to switch between old and new crypto model. */ -export function sharesCryptoService(driveCrypto: DriveCrypto, account: ProtonDriveAccount) { - async function generateVolumeBootstrap(addressKey: PrivateKey): Promise<{ +export class SharesCryptoService { + constructor(private driveCrypto: DriveCrypto, private account: ProtonDriveAccount) { + this.driveCrypto = driveCrypto; + this.account = account; + } + + async generateVolumeBootstrap(addressKey: PrivateKey): Promise<{ shareKey: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareCrypto }, rootNode: { keys: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareCrypto }, @@ -21,10 +26,10 @@ export function sharesCryptoService(driveCrypto: DriveCrypto, account: ProtonDri armoredHashKey: string, } }> { - const shareKey = await driveCrypto.generateKey([addressKey], addressKey); - const rootNodeKeys = await driveCrypto.generateKey([shareKey.decrypted.key], addressKey); - const { armoredNodeName } = await driveCrypto.encryptNodeName('root', shareKey.decrypted.key, addressKey); - const { armoredHashKey } = await driveCrypto.generateHashKey(rootNodeKeys.decrypted.key); + const shareKey = await this.driveCrypto.generateKey([addressKey], addressKey); + const rootNodeKeys = await this.driveCrypto.generateKey([shareKey.decrypted.key], addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName('root', shareKey.decrypted.key, addressKey); + const { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKeys.decrypted.key); return { shareKey, rootNode: { @@ -35,11 +40,11 @@ export function sharesCryptoService(driveCrypto: DriveCrypto, account: ProtonDri } } - async function decryptRootShare(share: EncryptedRootShare): Promise { - const addressPrivateKeys = await account.getOwnPrivateKeys(share.addressId); - const addressPublicKeys = await account.getPublicKeys(share.creatorEmail); + async decryptRootShare(share: EncryptedRootShare): Promise { + const addressPrivateKeys = await this.account.getOwnPrivateKeys(share.addressId); + const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); - const { key, sessionKey, verified } = await driveCrypto.decryptKey( + const { key, sessionKey, verified } = await this.driveCrypto.decryptKey( share.encryptedCrypto.armoredKey, share.encryptedCrypto.armoredPassphrase, share.encryptedCrypto.armoredPassphraseSignature, @@ -60,9 +65,4 @@ export function sharesCryptoService(driveCrypto: DriveCrypto, account: ProtonDri } } } - - return { - generateVolumeBootstrap, - decryptRootShare, - } } diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts index 99320ac7..12a3d48f 100644 --- a/js/sdk/src/internal/shares/index.ts +++ b/js/sdk/src/internal/shares/index.ts @@ -1,12 +1,12 @@ -import { ProtonDriveAccount } from "../../interface/index.js"; -import { DriveCrypto } from '../../crypto/index.js'; -import { DriveAPIService } from "../apiService/index.js"; -import { ProtonDriveCache } from "../../cache/index.js"; -import { sharesAPIService } from "./apiService.js"; -import { sharesCryptoCache } from "./cryptoCache.js"; -import { sharesCache } from "./cache.js"; -import { sharesCryptoService } from "./cryptoService.js"; -import { sharesManager } from "./manager.js"; +import { ProtonDriveAccount } from "../../interface"; +import { DriveCrypto } from '../../crypto'; +import { DriveAPIService } from "../apiService"; +import { ProtonDriveCache } from "../../cache"; +import { SharesAPIService } from "./apiService"; +import { SharesCryptoCache } from "./cryptoCache"; +import { SharesCache } from "./cache"; +import { SharesCryptoService } from "./cryptoService"; +import { SharesManager } from "./manager"; /** * Provides facade for the whole shares module. @@ -17,17 +17,17 @@ import { sharesManager } from "./manager.js"; * This facade provides internal interface that other modules can use to * interact with the shares. */ -export function shares( +export function initSharesModule( apiService: DriveAPIService, driveEntitiesCache: ProtonDriveCache, driveCryptoCache: ProtonDriveCache, account: ProtonDriveAccount, crypto: DriveCrypto, ) { - const api = sharesAPIService(apiService); - const cache = sharesCache(driveEntitiesCache); - const cryptoCache = sharesCryptoCache(driveCryptoCache); - const cryptoService = sharesCryptoService(crypto, account); - const sharesFunctions = sharesManager(api, cache, cryptoCache, cryptoService, account); - return sharesFunctions; + const api = new SharesAPIService(apiService); + const cache = new SharesCache(driveEntitiesCache); + const cryptoCache = new SharesCryptoCache(driveCryptoCache); + const cryptoService = new SharesCryptoService(crypto, account); + const sharesManager = new SharesManager(api, cache, cryptoCache, cryptoService, account); + return sharesManager; } diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index aaf6bcef..48ec8b66 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -1,4 +1,4 @@ -import { PrivateKey, SessionKey } from "../../crypto/index.js"; +import { PrivateKey, SessionKey } from "../../crypto"; /** * Internal interface providing basic identification of volume and its root diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index c6d0b73b..b0a2e3b3 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -1,9 +1,9 @@ -import { ProtonDriveAccount } from "../../interface/index"; -import { NotFoundAPIError } from "../apiService/index"; -import { sharesAPIService } from "./apiService"; -import { sharesCache } from "./cache"; -import { sharesCryptoCache } from "./cryptoCache"; -import { sharesCryptoService } from "./cryptoService"; +import { ProtonDriveAccount } from "../../interface"; +import { NotFoundAPIError } from "../apiService"; +import { SharesAPIService } from "./apiService"; +import { SharesCache } from "./cache"; +import { SharesCryptoCache } from "./cryptoCache"; +import { SharesCryptoService } from "./cryptoService"; /** * Provides high-level actions for managing shares. @@ -14,42 +14,50 @@ import { sharesCryptoService } from "./cryptoService"; * This module uses other modules providing low-level operations, such * as API service, cache, crypto service, etc. */ -export function sharesManager( - apiService: ReturnType, - cache: ReturnType, - cryptoCache: ReturnType, - cryptoService: ReturnType, - account: ProtonDriveAccount, -) { +export class SharesManager { // Cache for My files IDs. // Those IDs are required very often, so it is better to keep them in memory. // The IDs are not cached in the cache module, as we want to always fetch - // them from the API, and not from the cache. - const myFilesIds: { + // them from the API, and not from the this.cache. + private myFilesIds: { volumeId: string; shareId: string; rootNodeId: string; } | null = null; + constructor( + private apiService: SharesAPIService, + private cache: SharesCache, + private cryptoCache: SharesCryptoCache, + private cryptoService: SharesCryptoService, + private account: ProtonDriveAccount, + ) { + this.apiService = apiService; + this.cache = cache; + this.cryptoCache = cryptoCache; + this.cryptoService = cryptoService; + this.account = account; + } + /** * It returns the IDs of the My files section. * * If the default volume or My files section doesn't exist, it creates it. */ - async function getMyFilesIDs() { - if (myFilesIds) { - return myFilesIds; + async getMyFilesIDs() { + if (this.myFilesIds) { + return this.myFilesIds; } try { - const encryptedShare = await apiService.getMyFiles(); + const encryptedShare = await this.apiService.getMyFiles(); // Once any place needs IDs for My files, it will most likely // need also the keys for decrypting the tree. It is better to // decrypt the share here right away. - const myFilesShare = await cryptoService.decryptRootShare(encryptedShare); - await cryptoCache.setShareKey(myFilesShare.shareId, myFilesShare.decryptedCrypto); - await cache.setVolume({ + const myFilesShare = await this.cryptoService.decryptRootShare(encryptedShare); + await this.cryptoCache.setShareKey(myFilesShare.shareId, myFilesShare.decryptedCrypto); + await this.cache.setVolume({ volumeId: myFilesShare.volumeId, shareId: myFilesShare.shareId, rootNodeId: myFilesShare.rootNodeId, @@ -64,7 +72,7 @@ export function sharesManager( } catch (error: unknown) { if (error instanceof NotFoundAPIError) { - return createVolume(); + return this.createVolume(); } throw error; } @@ -80,10 +88,10 @@ export function sharesManager( * * @throws If the volume cannot be created (e.g., one already exists). */ - async function createVolume() { - const { addressKey, addressId, addressKeyId } = await account.getOwnPrimaryKey(); - const bootstrap = await cryptoService.generateVolumeBootstrap(addressKey); - const myFilesIds = await apiService.createVolume( + async createVolume() { + const { addressKey, addressId, addressKeyId } = await this.account.getOwnPrimaryKey(); + const bootstrap = await this.cryptoService.generateVolumeBootstrap(addressKey); + const myFilesIds = await this.apiService.createVolume( { addressId, addressKeyId, @@ -95,7 +103,7 @@ export function sharesManager( armoredHashKey: bootstrap.rootNode.armoredHashKey, }, ); - await cryptoCache.setShareKey(myFilesIds.shareId, bootstrap.shareKey.decrypted); + await this.cryptoCache.setShareKey(myFilesIds.shareId, bootstrap.shareKey.decrypted); return myFilesIds; } @@ -108,31 +116,31 @@ export function sharesManager( * @returns The private key for the share. * @throws If the share is not found or cannot be decrypted, or cached. */ - async function getSharePrivateKey(shareId: string) { - const keys = await cryptoCache.getShareKey(shareId); + async getSharePrivateKey(shareId: string) { + const keys = await this.cryptoCache.getShareKey(shareId); if (keys) { return keys.key; } - const encryptedShare = await apiService.getRootShare(shareId); - const share = await cryptoService.decryptRootShare(encryptedShare); - await cryptoCache.setShareKey(share.shareId, share.decryptedCrypto); + const encryptedShare = await this.apiService.getRootShare(shareId); + const share = await this.cryptoService.decryptRootShare(encryptedShare); + await this.cryptoCache.setShareKey(share.shareId, share.decryptedCrypto); return share.decryptedCrypto.key; } - async function getVolumeEmailKey(volumeId: string) { - const volume = await cache.getVolume(volumeId); + async getVolumeEmailKey(volumeId: string) { + const volume = await this.cache.getVolume(volumeId); if (volume) { return { email: volume.creatorEmail, - key: await account.getOwnPrivateKey(volume.creatorEmail), + key: await this.account.getOwnPrivateKey(volume.creatorEmail), }; } - const { shareId } = await apiService.getVolume(volumeId); - const share = await apiService.getShare(shareId); + const { shareId } = await this.apiService.getVolume(volumeId); + const share = await this.apiService.getShare(shareId); - await cache.setVolume({ + await this.cache.setVolume({ volumeId: share.volumeId, shareId: share.shareId, rootNodeId: share.rootNodeId, @@ -141,13 +149,7 @@ export function sharesManager( return { email: share.creatorEmail, - key: await account.getOwnPrivateKey(share.creatorEmail), + key: await this.account.getOwnPrivateKey(share.creatorEmail), }; } - - return { - getMyFilesIDs, - getSharePrivateKey, - getVolumeEmailKey, - } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 3aa310e1..1a66a53c 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,13 +1,13 @@ -import { getApiService } from './internal/apiService/index.js' -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, ShareNodeSettings, UploadMetadata } from './interface/index.js'; -import { driveCrypto } from './crypto/index.js' -import { nodes as nodesModule } from './internal/nodes/index.js' -import { shares as sharesModule } from './internal/shares/index.js' -import { sharing as sharingModule } from './internal/sharing/index.js' -import { events as eventsModule } from './internal/events/index.js' -import { upload as uploadModule } from './internal/upload/index.js'; -import { getConfig } from './config.js'; -import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers.js'; +import { DriveAPIService } from './internal/apiService'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, ShareNodeSettings, UploadMetadata } from './interface'; +import { DriveCrypto } from './crypto'; +import { initSharesModule } from './internal/shares'; +import { initNodesModule } from './internal/nodes'; +import { sharing as sharingModule } from './internal/sharing'; +import { events as eventsModule } from './internal/events'; +import { upload as uploadModule } from './internal/upload'; +import { getConfig } from './config'; +import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; export function protonDriveClient({ httpClient, @@ -24,15 +24,15 @@ export function protonDriveClient({ // TODO: define errors and use here throw Error('TODO'); } - const cryptoModule = driveCrypto(openPGPCryptoModule); + const cryptoModule = new DriveCrypto(openPGPCryptoModule); const fullConfig = getConfig(config); - const apiService = getApiService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); const events = eventsModule(apiService); - const shares = sharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); - const nodes = nodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); + const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); + const nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); const sharing = sharingModule(apiService, account, cryptoModule, nodes); const upload = uploadModule(apiService, cryptoModule, nodes); diff --git a/js/sdk/src/protonDrivePublicClient.ts b/js/sdk/src/protonDrivePublicClient.ts index 5fd8bc99..e79ad02d 100644 --- a/js/sdk/src/protonDrivePublicClient.ts +++ b/js/sdk/src/protonDrivePublicClient.ts @@ -1,10 +1,10 @@ -import { getApiService } from './internal/apiService/index.js'; -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, NodeEntity } from './interface/index.js'; -import { driveCrypto } from './crypto/index.js'; -import { publicNodes as publicNodesModule } from './internal/nodes/index.js'; -import { shares as sharesModule } from './internal/shares/index.js'; -import { getConfig } from './config.js'; -import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers.js'; +import { DriveAPIService } from './internal/apiService'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, NodeEntity } from './interface'; +import { DriveCrypto } from './crypto'; +import { initSharesModule } from './internal/shares'; +import { initPublicNodesModule } from './internal/nodes'; +import { getConfig } from './config'; +import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; interface ProtonDrivePublicClientInterface extends Partial { getPublicRootNode(token: string, password: string, customPassword?: string): Promise, @@ -25,15 +25,15 @@ export function protonDrivePublicClient({ // TODO: define errors and use here throw Error('TODO'); } - const cryptoModule = driveCrypto(openPGPCryptoModule); + const cryptoModule = new DriveCrypto(openPGPCryptoModule); const fullConfig = getConfig(config); - const apiService = getApiService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); // TODO: public sharing module - const publicShares = sharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); - const nodes = publicNodesModule(apiService, entitiesCache, cryptoCache, cryptoModule, publicShares); + const publicShares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); + const nodes = initPublicNodesModule(apiService, entitiesCache, cryptoCache, cryptoModule, publicShares); return { getPublicRootNode: (token: string, password: string, customPassword?: string) => { From 462eded89b93693516603e5093133a677d487894 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 6 Feb 2025 13:20:38 +0000 Subject: [PATCH 004/791] Return generator for multiple nodes --- js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/nodes.ts | 12 +- js/sdk/src/internal/nodes/apiService.test.ts | 113 +++++++++++++++++ js/sdk/src/internal/nodes/apiService.ts | 58 ++++----- js/sdk/src/internal/nodes/manager.ts | 121 ++++++++----------- 5 files changed, 194 insertions(+), 112 deletions(-) diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 23f6085a..e883cb83 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -14,7 +14,7 @@ export type { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig, GetL export type { Device, DeviceOrUid } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; -export type { NodeEntity, InvalidNameError, UnverifiedAuthorError, AnonymousUser, Revision, NodeOrUid, RevisionOrUid, NodesResults, NodeErrorResult } from './nodes'; +export type { NodeEntity, InvalidNameError, UnverifiedAuthorError, AnonymousUser, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole } from './nodes'; export type { ProtonInvitation, NonProtonInvitation, NonProtonInvitationState, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, ShareResult } from './sharing'; export { ShareRole } from './sharing'; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index c6205175..6cb8a831 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -69,14 +69,14 @@ export interface Nodes { export interface NodesManagement { createFolder(parentNodeUid: NodeOrUid, name: string): Promise, renameNode(nodeUid: NodeOrUid, newName: string): Promise, - moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal): Promise, - trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): Promise, - restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): Promise, + moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal): Promise, + trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, + restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, } export interface TrashManagement { iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator, - deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): Promise, + deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, emptyTrash(): Promise, } @@ -86,6 +86,4 @@ export interface Revisions { deleteRevision(revisionUid: RevisionOrUid): Promise, } -export type NodesResults = { processedNodeIds: string[], errors: NodeErrorResult[] }; -// TODO: fix type - will be solved by converting to different structure -export type NodeErrorResult = { nodeId: string, error: any }; // eslint-disable-line @typescript-eslint/no-explicit-any +export type NodeResult = {uid: string, ok: true} | {uid: string, ok: false, error: string}; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index fc1f431a..de4802fe 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -208,4 +208,117 @@ describe("nodeAPIService", () => { ); }); }); + + describe('trashNodes', () => { + it('should trash nodes', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async () => Promise.resolve({ + Responses: [ + { + LinkID: 'nodeId1', + Response: { + Code: 1000, + } + }, + { + LinkID: 'nodeId2', + Response: { + Code: 2027, + Error: 'INSUFFICIENT_SCOPE' + } + } + ], + })); + + const parentUid = 'volume:volumeId;node:parentLinkId'; + const result = await Array.fromAsync(api.trashNodes(parentUid, ['volume:volumeId;node:nodeId1', 'volume:volumeId;node:nodeId2'])); + expect(result).toEqual([ + { uid: 'volume:volumeId;node:nodeId1', ok: true }, + { uid: 'volume:volumeId;node:nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, + ]); + }); + }); + + describe('restoreNodes', () => { + it('should restore nodes', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.put = jest.fn(async () => Promise.resolve({ + Responses: [ + { + LinkID: 'nodeId1', + Response: { + Code: 1000, + } + }, + { + LinkID: 'nodeId2', + Response: { + Code: 2027, + Error: 'INSUFFICIENT_SCOPE' + } + }, + { + LinkID: 'nodeId3', + Response: { + Code: 2000, + } + }, + ], + })); + + const result = await Array.fromAsync(api.restoreNodes(['volume:volumeId;node:nodeId1', 'volume:volumeId;node:nodeId2', 'volume:volumeId;node:nodeId3'])); + expect(result).toEqual([ + { uid: 'volume:volumeId;node:nodeId1', ok: true }, + { uid: 'volume:volumeId;node:nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, + { uid: 'volume:volumeId;node:nodeId3', ok: false, error: 'Unknown error' }, + ]); + }); + + it('should fail restoring from multiple volumes', async () => { + try { + await Array.fromAsync(api.restoreNodes(['volume:volumeId1;node:nodeId1', 'volume:volumeId2;node:nodeId2'])); + throw new Error('Should have thrown'); + } catch (error: any) { + expect(error.message).toEqual('restoreNodes does not support multiple volumes'); + } + }); + }); + + describe('deleteNOdes', () => { + it('should delete nodes', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async () => Promise.resolve({ + Responses: [ + { + LinkID: 'nodeId1', + Response: { + Code: 1000, + } + }, + { + LinkID: 'nodeId2', + Response: { + Code: 2027, + Error: 'INSUFFICIENT_SCOPE' + } + } + ], + })); + + const result = await Array.fromAsync(api.deleteNodes(['volume:volumeId;node:nodeId1', 'volume:volumeId;node:nodeId2'])); + expect(result).toEqual([ + { uid: 'volume:volumeId;node:nodeId1', ok: true }, + { uid: 'volume:volumeId;node:nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, + ]); + }); + + it('should fail deleting nodes from multiple volumes', async () => { + try { + await Array.fromAsync(api.deleteNodes(['volume:volumeId1;node:nodeId1', 'volume:volumeId2;node:nodeId2'])); + throw new Error('Should have thrown'); + } catch (error: any) { + expect(error.message).toEqual('deleteNodes does not support multiple volumes'); + } + }); + }); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index fe7c72ed..3d859747 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,4 +1,4 @@ -import { Logger, NodeType, MemberRole } from "../../interface"; +import { Logger, NodeType, MemberRole, NodeResult } from "../../interface"; import { DriveAPIService, drivePaths } from "../apiService"; import { splitNodeUid, makeNodeUid } from "./nodeUid"; import { EncryptedNode } from "./interface"; @@ -224,7 +224,7 @@ export class NodeAPIService { // Improvement requested: API without requiring parent node (to delete any nodes). // Improvement requested: split into multiple calls for many nodes. - async trashNodes(parentNodeUid: string, nodeUids: string[], signal?: AbortSignal): Promise { + async* trashNodes(parentNodeUid: string, nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); const nodeIds = nodeUids.map(splitNodeUid); @@ -237,11 +237,11 @@ export class NodeAPIService { }, signal); // TODO: remove `as` when backend fixes OpenAPI schema. - handleResponseErrors(volumeId, response.Responses as LinkResponse[]); + yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } // Improvement requested: split into multiple calls for many nodes. - async restoreNodes(nodeUids: string[], signal?: AbortSignal): Promise { + async* restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId("restoreNodes", nodeIds); @@ -253,13 +253,13 @@ export class NodeAPIService { }, signal); // TODO: remove `as` when backend fixes OpenAPI schema. - handleResponseErrors(volumeId, response.Responses as LinkResponse[]); + yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } // Improvement requested: split into multiple calls for many nodes. - async deleteNodes(nodeUids: string[], signal?: AbortSignal): Promise { + async* deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId("restoreNodes", nodeIds); + const volumeId = assertAndGetSingleVolumeId("deleteNodes", nodeIds); const response = await this.apiService.post< PostDeleteNodesRequest, @@ -269,7 +269,7 @@ export class NodeAPIService { }, signal); // TODO: remove `as` when backend fixes OpenAPI schema. - handleResponseErrors(volumeId, response.Responses as LinkResponse[]); + yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } async createFolder( @@ -334,39 +334,27 @@ function sharingSummaryToDirectMemberRole(sharingSummary: PostLoadLinksMetadataR type LinkResponse = { LinkID: string, Response: { - Error?: string + Code?: number, + Error?: string, } }; -export type NodeErrors = { [ nodeUid: string ]: string }; - -export class ResultErrors extends Error { - nodeErrors: NodeErrors; - - constructor(nodeErrors: NodeErrors) { - super("Some nodes failed to process"); - this.nodeErrors = nodeErrors; - } +function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: LinkResponse[] = []): Generator { + const errors = new Map(); - get failingNodeUids(): string[] { - return Object.keys(this.nodeErrors); - } -} - -function handleResponseErrors(volumeId: string, responses?: LinkResponse[]) { - if (!responses) { - return; - } - - const errors: NodeErrors = {}; - - responses.map((response) => { - if (response.Response.Error) { - errors[makeNodeUid(volumeId, response.LinkID)] = response.Response.Error as string; + responses.forEach((response) => { + if (response.Response.Code !== 1000 || response.Response.Error) { + const nodeUid = makeNodeUid(volumeId, response.LinkID); + errors.set(nodeUid, response.Response.Error || 'Unknown error'); } }); - if (Object.keys(errors).length > 0) { - throw new ResultErrors(errors); + for (const uid of nodeUids) { + const error = errors.get(uid); + if (error) { + yield { uid, ok: false, error }; + } else { + yield { uid, ok: true }; + } } } diff --git a/js/sdk/src/internal/nodes/manager.ts b/js/sdk/src/internal/nodes/manager.ts index 51930192..9bcfae78 100644 --- a/js/sdk/src/internal/nodes/manager.ts +++ b/js/sdk/src/internal/nodes/manager.ts @@ -1,5 +1,5 @@ -import { MemberRole, NodeType, resultOk } from "../../interface"; -import { NodeAPIService, ResultErrors, NodeErrors } from "./apiService"; +import { MemberRole, NodeType, NodeResult, resultOk } from "../../interface"; +import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; @@ -121,6 +121,24 @@ export class NodesManager { }); } + async* moveNodes(nodeUids: string[], newParentUid: string): AsyncGenerator { + for (const nodeUid of nodeUids) { + try { + await this.moveNode(nodeUid, newParentUid); + yield { + uid: nodeUid, + ok: true, + } + } catch (error: unknown) { + yield { + uid: nodeUid, + ok: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + } + } + async moveNode(nodeUid: string, newParentUid: string): Promise { const [node, newParentNode] = await Promise.all([ this.nodesAccess.getNode(nodeUid), @@ -166,7 +184,7 @@ export class NodesManager { }); } - async trashNodes(nodeUids: string[], signal?: AbortSignal): Promise { + async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodesPerParent = new Map(); for await (const node of this.iterateNodes(nodeUids, signal)) { @@ -181,87 +199,52 @@ export class NodesManager { } } - let errors: NodeErrors = {}; - for (const [parentNodeUid, nodes] of nodesPerParent) { - let updatedNodes: DecryptedNode[]; - try { - await this.apiService.trashNodes(parentNodeUid, nodes.map(node => node.uid), signal); - updatedNodes = nodes; - } catch (error: unknown) { - if (error instanceof ResultErrors) { - updatedNodes = nodes.filter(node => !error.failingNodeUids.includes(node.uid)); - errors = { ...errors, ...error.nodeErrors }; - } else { - updatedNodes = []; - errors = { ...errors, ...Object.fromEntries(nodes.map(node => [node.uid, error instanceof Error ? error.message : `${error}`])) }; + for await (const result of this.apiService.trashNodes(parentNodeUid, nodes.map(node => node.uid), signal)) { + if (result.ok) { + const node = nodes.find(node => node.uid === result.uid); + if (node) { + await this.cache.setNode({ + ...node, + trashedDate: new Date(), + }); + } } - } - for (const node of updatedNodes) { - await this.cache.setNode({ - ...node, - trashedDate: new Date(), - }); - } - } - if (Object.keys(errors).length) { - throw new ResultErrors(errors); + yield result; + } } } - async restoreNodes(nodeUids: string[], signal?: AbortSignal): Promise { + async* restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodes = await Array.fromAsync(this.iterateNodes(nodeUids, signal)); - let updatedNodes: DecryptedNode[]; - let catchedError: unknown; - - try { - await this.apiService.restoreNodes(nodeUids, signal); - updatedNodes = nodes; - } catch (error: unknown) { - catchedError = error; - if (error instanceof ResultErrors) { - updatedNodes = nodes.filter(node => !error.failingNodeUids.includes(node.uid)); - } else { - updatedNodes = []; - } - } - for (const node of updatedNodes) { - await this.cache.setNode({ - ...node, - trashedDate: new Date(), - }); - } + for await (const result of this.apiService.restoreNodes(nodeUids, signal)) { + if (result.ok) { + const node = nodes.find(node => node.uid === result.uid); + if (node) { + await this.cache.setNode({ + ...node, + trashedDate: undefined, + }); + } + } - if (catchedError) { - throw catchedError; + yield result; } } - async deleteNodes(nodeUids: string[], signal?: AbortSignal): Promise { - let updatedNodeUids: string[]; - let catchedError: unknown; - - try { - await this.apiService.restoreNodes(nodeUids, signal); - updatedNodeUids = nodeUids; - } catch (error: unknown) { - catchedError = error; - if (error instanceof ResultErrors) { - updatedNodeUids = nodeUids.filter(nodeUid => !error.failingNodeUids.includes(nodeUid)); - } else { - updatedNodeUids = []; - } - } + async* deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const deletedNodeUids = []; - if (updatedNodeUids) { - await this.cache.removeNodes(updatedNodeUids); + for await (const result of this.apiService.deleteNodes(nodeUids, signal)) { + if (result.ok) { + deletedNodeUids.push(result.uid); + } + yield result; } - if (catchedError) { - throw catchedError; - } + await this.cache.removeNodes(deletedNodeUids); } async createFolder(parentNodeUid: string, folderName: string, signal?: AbortSignal): Promise { From 2d383812e7c81ab85439db05cc5b44136ecd8a73 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 6 Feb 2025 13:22:57 +0000 Subject: [PATCH 005/791] Implement CLI commands to list root and children nodes --- .gitignore | 3 + js/sdk/package-lock.json | 2489 +------------------------ js/sdk/src/index.ts | 6 +- js/sdk/src/internal/nodes/events.ts | 130 +- js/sdk/src/internal/nodes/index.ts | 24 +- js/sdk/src/protonDriveClient.ts | 101 +- js/sdk/src/protonDrivePhotosClient.ts | 30 +- js/sdk/src/protonDrivePublicClient.ts | 73 +- 8 files changed, 210 insertions(+), 2646 deletions(-) diff --git a/.gitignore b/.gitignore index 5eb4fd96..a556cf0a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ public node_modules .eslintcache tsconfig.tsbuildinfo + +# JS CLI bin +auth.txt diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index c38f24da..e70cd1e7 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -19,16 +19,13 @@ "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^8.19.1", "@web/dev-server-esbuild": "^1.0.3", - "@web/test-runner": "^0.19.0", - "@web/test-runner-playwright": "^0.11.0", "eslint": "^8.57.1", "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", "prettier": "^3.4.2", "typedoc": "^0.26.11", - "typescript": "^5.6.3", - "web-test-runner-jasmine": "^0.0.7" + "typescript": "^5.6.3" } }, "node_modules/@ampproject/remapping": { @@ -1848,13 +1845,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@hapi/bourne": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", - "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1912,109 +1902,6 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2538,53 +2425,6 @@ } } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@puppeteer/browsers": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", - "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.0", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -2645,95 +2485,6 @@ "node": ">=10" } }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", - "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz", - "integrity": "sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz", - "integrity": "sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@shikijs/core": { "version": "1.26.1", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.26.1.tgz", @@ -2827,13 +2578,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", @@ -2844,13 +2588,6 @@ "@types/node": "*" } }, - "node_modules/@types/babel__code-frame": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz", - "integrity": "sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2903,24 +2640,6 @@ "@types/node": "*" } }, - "node_modules/@types/co-body": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@types/co-body/-/co-body-6.1.3.tgz", - "integrity": "sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*" - } - }, - "node_modules/@types/command-line-args": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", - "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2938,13 +2657,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/convert-source-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/convert-source-map/-/convert-source-map-2.0.3.tgz", - "integrity": "sha512-ag0BfJLZf6CQz8VIuRIEYQ5Ggwk/82uvTQf27RcpyDNbY0Vw49LIPqAxk5tqYfrCs9xDaIMvl4aj7ZopnYL8bA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/cookies": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", @@ -2958,20 +2670,6 @@ "@types/node": "*" } }, - "node_modules/@types/debounce": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz", - "integrity": "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/express": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", @@ -3150,13 +2848,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -3217,17 +2908,6 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.19.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", @@ -3431,59 +3111,6 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, - "node_modules/@web/browser-logs": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-0.4.1.tgz", - "integrity": "sha512-ypmMG+72ERm+LvP+loj9A64MTXvWMXHUOu773cPO4L1SV/VWg6xA9Pv7vkvkXQX+ItJtCJt+KQ+U6ui2HhSFUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "errorstacks": "^2.4.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/config-loader": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@web/config-loader/-/config-loader-0.3.2.tgz", - "integrity": "sha512-Vrjv/FexBGmAdnCYpJKLHX1dfT1UaUdvHmX1JRaWos9OvDf/tFznYJ5SpJwww3Rl87/ewvLSYG7kfsMqEAsizQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/dev-server": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/@web/dev-server/-/dev-server-0.4.6.tgz", - "integrity": "sha512-jj/1bcElAy5EZet8m2CcUdzxT+CRvUjIXGh8Lt7vxtthkN9PzY9wlhWx/9WOs5iwlnG1oj0VGo6f/zvbPO0s9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.11", - "@types/command-line-args": "^5.0.0", - "@web/config-loader": "^0.3.0", - "@web/dev-server-core": "^0.7.2", - "@web/dev-server-rollup": "^0.6.1", - "camelcase": "^6.2.0", - "command-line-args": "^5.1.1", - "command-line-usage": "^7.0.1", - "debounce": "^1.2.0", - "deepmerge": "^4.2.2", - "internal-ip": "^6.2.0", - "nanocolors": "^0.2.1", - "open": "^8.0.2", - "portfinder": "^1.0.32" - }, - "bin": { - "wds": "dist/bin.js", - "web-dev-server": "dist/bin.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@web/dev-server-core": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.5.tgz", @@ -3541,37 +3168,6 @@ "node": ">=18.0.0" } }, - "node_modules/@web/dev-server-rollup": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@web/dev-server-rollup/-/dev-server-rollup-0.6.4.tgz", - "integrity": "sha512-sJZfTGCCrdku5xYnQQG51odGI092hKY9YFM0X3Z0tRY3iXKXcYRaLZrErw5KfCxr6g0JRuhe4BBhqXTA5Q2I3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/plugin-node-resolve": "^15.0.1", - "@web/dev-server-core": "^0.7.2", - "nanocolors": "^0.2.1", - "parse5": "^6.0.1", - "rollup": "^4.4.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/dev-server/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@web/parse5-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-2.1.0.tgz", @@ -3586,213 +3182,24 @@ "node": ">=18.0.0" } }, - "node_modules/@web/test-runner": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@web/test-runner/-/test-runner-0.19.0.tgz", - "integrity": "sha512-qLUupi88OK1Kl52cWPD/2JewUCRUxYsZ1V1DyLd05P7u09zCdrUYrtkB/cViWyxlBe/TOvqkSNpcTv6zLJ9GoA==", + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, "license": "MIT", "dependencies": { - "@web/browser-logs": "^0.4.0", - "@web/config-loader": "^0.3.0", - "@web/dev-server": "^0.4.0", - "@web/test-runner-chrome": "^0.17.0", - "@web/test-runner-commands": "^0.9.0", - "@web/test-runner-core": "^0.13.0", - "@web/test-runner-mocha": "^0.9.0", - "camelcase": "^6.2.0", - "command-line-args": "^5.1.1", - "command-line-usage": "^7.0.1", - "convert-source-map": "^2.0.0", - "diff": "^5.0.0", - "globby": "^11.0.1", - "nanocolors": "^0.2.1", - "portfinder": "^1.0.32", - "source-map": "^0.7.3" - }, - "bin": { - "web-test-runner": "dist/bin.js", - "wtr": "dist/bin.js" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { - "node": ">=18.0.0" + "node": ">= 0.6" } }, - "node_modules/@web/test-runner-chrome": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@web/test-runner-chrome/-/test-runner-chrome-0.17.0.tgz", - "integrity": "sha512-Il5N9z41NKWCrQM1TVgRaDWWYoJtG5Ha4fG+cN1MWL2OlzBS4WoOb4lFV3EylZ7+W3twZOFr1zy2Rx61yDYd/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@web/test-runner-core": "^0.13.0", - "@web/test-runner-coverage-v8": "^0.8.0", - "async-mutex": "0.4.0", - "chrome-launcher": "^0.15.0", - "puppeteer-core": "^23.2.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/test-runner-commands": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-0.9.0.tgz", - "integrity": "sha512-zeLI6QdH0jzzJMDV5O42Pd8WLJtYqovgdt0JdytgHc0d1EpzXDsc7NTCJSImboc2NcayIsWAvvGGeRF69SMMYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@web/test-runner-core": "^0.13.0", - "mkdirp": "^1.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/test-runner-core": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.13.4.tgz", - "integrity": "sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.11", - "@types/babel__code-frame": "^7.0.2", - "@types/co-body": "^6.1.0", - "@types/convert-source-map": "^2.0.0", - "@types/debounce": "^1.2.0", - "@types/istanbul-lib-coverage": "^2.0.3", - "@types/istanbul-reports": "^3.0.0", - "@web/browser-logs": "^0.4.0", - "@web/dev-server-core": "^0.7.3", - "chokidar": "^4.0.1", - "cli-cursor": "^3.1.0", - "co-body": "^6.1.0", - "convert-source-map": "^2.0.0", - "debounce": "^1.2.0", - "dependency-graph": "^0.11.0", - "globby": "^11.0.1", - "internal-ip": "^6.2.0", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.0.2", - "log-update": "^4.0.0", - "nanocolors": "^0.2.1", - "nanoid": "^3.1.25", - "open": "^8.0.2", - "picomatch": "^2.2.2", - "source-map": "^0.7.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/test-runner-core/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@web/test-runner-coverage-v8": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.8.0.tgz", - "integrity": "sha512-PskiucYpjUtgNfR2zF2AWqWwjXL7H3WW/SnCAYmzUrtob7X9o/+BjdyZ4wKbOxWWSbJO4lEdGIDLu+8X2Xw+lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@web/test-runner-core": "^0.13.0", - "istanbul-lib-coverage": "^3.0.0", - "lru-cache": "^8.0.4", - "picomatch": "^2.2.2", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/test-runner-coverage-v8/node_modules/lru-cache": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", - "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16.14" - } - }, - "node_modules/@web/test-runner-mocha": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@web/test-runner-mocha/-/test-runner-mocha-0.9.0.tgz", - "integrity": "sha512-ZL9F6FXd0DBQvo/h/+mSfzFTSRVxzV9st/AHhpgABtUtV/AIpVE9to6+xdkpu6827kwjezdpuadPfg+PlrBWqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@web/test-runner-core": "^0.13.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/test-runner-playwright": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@web/test-runner-playwright/-/test-runner-playwright-0.11.0.tgz", - "integrity": "sha512-s+f43DSAcssKYVOD9SuzueUcctJdHzq1by45gAnSCKa9FQcaTbuYe8CzmxA21g+NcL5+ayo4z+MA9PO4H+PssQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@web/test-runner-core": "^0.13.0", - "@web/test-runner-coverage-v8": "^0.8.0", - "playwright": "^1.22.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@web/test-runner/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@web/test-runner/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -3916,76 +3323,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" - } - }, - "node_modules/async-mutex": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", - "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -4141,104 +3478,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/bare-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bare-buffer/-/bare-buffer-3.0.1.tgz", - "integrity": "sha512-QuDV/Wv5k1xsevh24zQwEjlQJuRvt3tUC39VFai6PoJiDIwmISEoc76ZTae4yVcacRBw0HBArrHssV1o3TEKhQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "bare": ">=1.13.0" - } - }, - "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", - "dev": true, - "license": "Apache-2.0", - "optional": true - }, - "node_modules/bare-fs": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz", - "integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.0.0", - "bare-path": "^2.0.0", - "bare-stream": "^2.0.0" - } - }, - "node_modules/bare-os": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz", - "integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true - }, - "node_modules/bare-path": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", - "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^2.1.0" - } - }, - "node_modules/bare-stream": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.3.tgz", - "integrity": "sha512-AiqV593yTkEU3Lka0Sn+UT8X8U5hZ713RHa5Dg88GtJRite8TeD0oBOESNY6LnaBXTK0LjAW82OVhws+7L4JGA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -4301,57 +3540,12 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cache-content-type": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", @@ -4461,22 +3655,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk-template": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", - "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/chalk-template?sponsor=1" - } - }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -4529,39 +3707,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chrome-launcher": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", - "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0" - }, - "bin": { - "print-chrome-path": "bin/print-chrome-path.js" - }, - "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/chromium-bidi": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", - "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mitt": "3.0.1", - "zod": "3.23.8" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -4583,19 +3728,6 @@ "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", "dev": true }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4630,23 +3762,6 @@ "node": ">= 0.12.0" } }, - "node_modules/co-body": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.2.0.tgz", - "integrity": "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hapi/bourne": "^3.0.0", - "inflation": "^2.0.0", - "qs": "^6.5.2", - "raw-body": "^2.3.3", - "type-is": "^1.6.16" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -4688,58 +3803,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/command-line-args": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", - "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", - "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-usage": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", - "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "chalk-template": "^0.4.0", - "table-layout": "^4.1.0", - "typical": "^7.1.1" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/command-line-usage/node_modules/array-back": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", - "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/command-line-usage/node_modules/typical": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", - "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4837,23 +3900,6 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "dev": true, - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -4907,44 +3953,6 @@ "node": ">=0.10.0" } }, - "node_modules/default-gateway": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", - "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -4962,16 +3970,6 @@ "node": ">= 0.8" } }, - "node_modules/dependency-graph": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", - "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5014,23 +4012,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/devtools-protocol": { - "version": "0.0.1367902", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", - "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5040,19 +4021,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -5080,13 +4048,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5134,16 +4095,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -5165,13 +4116,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/errorstacks": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/errorstacks/-/errorstacks-2.4.1.tgz", - "integrity": "sha512-jE4i0SMYevwu/xxAuzhly/KTwtj0xDhbzB6m1xPImxTkw8wcCbgarOQPfCVMi5JKVyW7in29pNJCCJrry3Ynnw==", - "dev": true, - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5281,28 +4225,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -5504,13 +4426,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5578,56 +4493,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5686,16 +4557,6 @@ "bser": "2.1.1" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5720,19 +4581,6 @@ "node": ">=8" } }, - "node_modules/find-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", - "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-back": "^3.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5769,36 +4617,6 @@ "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "dev": true }, - "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -5899,22 +4717,7 @@ "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-uri": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", - "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/glob": { @@ -5987,27 +4790,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -6176,20 +4958,6 @@ "node": ">= 0.6" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6213,40 +4981,6 @@ "node": ">=10.17.0" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6313,16 +5047,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inflation": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz", - "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6340,66 +5064,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "node_modules/internal-ip": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-6.2.0.tgz", - "integrity": "sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-gateway": "^6.0.0", - "ipaddr.js": "^1.9.1", - "is-ip": "^3.1.0", - "p-event": "^4.2.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/internal-ip?sponsor=1" - } - }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ip-address/node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/ip-regex": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", - "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6421,22 +5085,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6495,26 +5143,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-ip": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz", - "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-regex": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, - "license": "MIT" - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6564,19 +5192,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/isbinaryfile": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.4.tgz", @@ -6674,64 +5289,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jasmine": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-5.5.0.tgz", - "integrity": "sha512-JKlEVCVD5QBPYLsg/VE+IUtjyseDCrW8rMBu8la+9ysYashDgavMLM9Kotls1FhI6dCJLJ40dBCIfQjGLPZI1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.2.2", - "jasmine-core": "~5.5.0" - }, - "bin": { - "jasmine": "bin/jasmine.js" - } - }, - "node_modules/jasmine-core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz", - "integrity": "sha512-NHOvoPO6o9gVR6pwqEACTEpbgcH+JJ6QDypyymGbSUIFIFsMMbBJ/xsFNud8MSClfnWclXd7RQlAZBz7yVo5TQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jasmine/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -7333,13 +5890,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, - "license": "MIT" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7553,34 +6103,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lighthouse-logger": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", - "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^2.6.9", - "marky": "^1.2.2" - } - }, - "node_modules/lighthouse-logger/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/lighthouse-logger/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7611,20 +6133,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -7637,40 +6145,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7739,13 +6213,6 @@ "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/marky": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz", - "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7957,78 +6424,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, - "node_modules/nanocolors": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/nanocolors/-/nanocolors-0.2.13.tgz", - "integrity": "sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8037,22 +6438,12 @@ }, "node_modules/negotiator": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4.0" + "node": ">= 0.6" } }, "node_modules/node-fetch": { @@ -8134,19 +6525,6 @@ "node": ">=8" } }, - "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8201,24 +6579,6 @@ "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==", "dev": true }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/openapi-typescript": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.5.2.tgz", @@ -8301,32 +6661,6 @@ "node": ">= 0.8.0" } }, - "node_modules/p-event": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", - "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-timeout": "^3.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8357,19 +6691,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -8379,47 +6700,6 @@ "node": ">=6" } }, - "node_modules/pac-proxy-agent": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz", - "integrity": "sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8500,47 +6780,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8632,38 +6871,6 @@ "node": ">=8" } }, - "node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.49.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/plural-forms": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/plural-forms/-/plural-forms-0.5.5.tgz", @@ -8702,44 +6909,6 @@ "node": ">= 18.0.0" } }, - "node_modules/portfinder": { - "version": "1.0.32", - "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", - "integrity": "sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async": "^2.6.4", - "debug": "^3.2.7", - "mkdirp": "^0.5.6" - }, - "engines": { - "node": ">= 0.12.0" - } - }, - "node_modules/portfinder/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/portfinder/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8790,16 +6959,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8823,54 +6982,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8889,46 +7000,6 @@ "node": ">=6" } }, - "node_modules/puppeteer-core": { - "version": "23.11.1", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz", - "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.6.1", - "chromium-bidi": "0.11.0", - "debug": "^4.4.0", - "devtools-protocol": "0.0.1367902", - "typed-query-selector": "^2.12.0", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -8945,22 +7016,6 @@ } ] }, - "node_modules/qs": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", - "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8981,56 +7036,6 @@ } ] }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", - "dev": true, - "license": "MIT" - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9278,20 +7283,6 @@ "node": ">=10" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -9306,55 +7297,16 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz", - "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "glob": "^7.1.3" }, "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "rimraf": "bin.js" }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.30.1", - "@rollup/rollup-android-arm64": "4.30.1", - "@rollup/rollup-darwin-arm64": "4.30.1", - "@rollup/rollup-darwin-x64": "4.30.1", - "@rollup/rollup-freebsd-arm64": "4.30.1", - "@rollup/rollup-freebsd-x64": "4.30.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.30.1", - "@rollup/rollup-linux-arm-musleabihf": "4.30.1", - "@rollup/rollup-linux-arm64-gnu": "4.30.1", - "@rollup/rollup-linux-arm64-musl": "4.30.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.30.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.30.1", - "@rollup/rollup-linux-riscv64-gnu": "4.30.1", - "@rollup/rollup-linux-s390x-gnu": "4.30.1", - "@rollup/rollup-linux-x64-gnu": "4.30.1", - "@rollup/rollup-linux-x64-musl": "4.30.1", - "@rollup/rollup-win32-arm64-msvc": "4.30.1", - "@rollup/rollup-win32-ia32-msvc": "4.30.1", - "@rollup/rollup-win32-x64-msvc": "4.30.1", - "fsevents": "~2.3.2" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/run-parallel": { @@ -9419,13 +7371,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9479,82 +7424,6 @@ "@types/hast": "^3.0.4" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9576,65 +7445,6 @@ "node": ">=8" } }, - "node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9701,21 +7511,6 @@ "node": ">= 0.6" } }, - "node_modules/streamx": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.21.1.tgz", - "integrity": "sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-fifo": "^1.3.2", - "queue-tick": "^1.0.1", - "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" - } - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9743,22 +7538,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -9785,20 +7564,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -9853,57 +7618,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/table-layout": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", - "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "wordwrapjs": "^5.1.0" - }, - "engines": { - "node": ">=12.17" - } - }, - "node_modules/table-layout/node_modules/array-back": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", - "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/tar-fs": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.7.tgz", - "integrity": "sha512-2sAfoF/zw/2n8goUGnGRZTWTD4INtnScPZvyYBI6BDlJ3wNR5o1dw03EfBvuhG6GBLvC4J+C7j7W+64aZ0ogQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^2.1.1", - "bare-path": "^2.1.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9940,29 +7654,12 @@ "node": "*" } }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9991,19 +7688,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", - "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -10026,13 +7710,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, "node_modules/tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", @@ -10112,13 +7789,6 @@ "node": ">= 0.6" } }, - "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", - "dev": true, - "license": "MIT" - }, "node_modules/typedoc": { "version": "0.26.11", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.11.tgz", @@ -10154,16 +7824,6 @@ "node": ">=14.17" } }, - "node_modules/typical": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", - "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ua-parser-js": { "version": "1.0.40", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", @@ -10197,17 +7857,6 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -10322,16 +7971,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", @@ -10439,42 +8078,6 @@ "makeerror": "1.0.12" } }, - "node_modules/web-test-runner-jasmine": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/web-test-runner-jasmine/-/web-test-runner-jasmine-0.0.7.tgz", - "integrity": "sha512-/w2U4dQNWPmgl8a1sxIC9bF22XL+bJYxL83/JQ5IQoK1bnVXwSAeJVRqmTLCYVNHIqadDTehf9FalRPBvZzlaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@web/test-runner": "^0.19.0", - "@web/test-runner-core": "^0.13.4", - "jasmine": "^5.5.0" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", - "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10499,16 +8102,6 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrapjs": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.0.tgz", - "integrity": "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -10526,25 +8119,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -10647,17 +8221,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/ylru": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", @@ -10680,16 +8243,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 9272ca90..307b6f12 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -1,6 +1,6 @@ export * from './interface/index.js'; export * from './cache/index.js'; export { OpenPGPCryptoWithCryptoProxy, OpenPGPCrypto } from './crypto/index.js'; -export { protonDriveClient } from './protonDriveClient.js'; -export { protonDrivePhotosClient } from './protonDrivePhotosClient.js'; -export { protonDrivePublicClient } from './protonDrivePublicClient.js'; +export { ProtonDriveClient } from './protonDriveClient.js'; +export { ProtonDrivePhotosClient } from './protonDrivePhotosClient.js'; +export { ProtonDrivePublicClient } from './protonDrivePublicClient.js'; diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 43b31964..7322abbc 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -16,86 +16,86 @@ type NodeEventInfo = { * for the user to listen to updates of specific group of nodes, such as * any update for trashed nodes. */ -export function nodesEvents( - cache: NodesCache, - events: DriveEventsService, -) { - const listeners: { condition: (nodeEventInfo: NodeEventInfo) => boolean, callback: NodeEventCallback }[] = []; +export class NodesEvents { + private listeners: { condition: (nodeEventInfo: NodeEventInfo) => boolean, callback: NodeEventCallback }[] = []; - // TODO: handler for saving to internal cache - // errors should not be ignored until event is processed - how to give up after some time? - events.registerHandler(async (event) => { - if (event.type === 'node_created') { - try { - const parentNode = await cache.getNode(event.parentNodeUid); - // TODO: do not fetch and decrypt, only save to cache there is new node - } catch (err) { - // TODO: ignore if missing in cache - throw err; - } - } - if (event.type === 'node_updated' || event.type === 'node_updated_metadata') { - try { - const node = await cache.getNode(event.nodeUid); - node.isStale = true; - await cache.setNode(node); - } catch (err) { - // TODO: ignore if missing in cache - throw err; - } - } - if (event.type === 'node_deleted') { - try { - await cache.removeNodes([event.nodeUid]); - } catch (err) { - // TODO: ignore if missing in cache - throw err; - } - } - }); + constructor( + private cache: NodesCache, + private events: DriveEventsService, + ) { + this.cache = cache; + this.events = events; - // TODO: ignore errors if this doesn't work so events can continue - // but log them and how to report to the caller? - events.registerHandler(async (event) => { - if (event.type === 'node_created' || event.type === 'node_updated' || event.type === 'node_updated_metadata') { - await Promise.all(listeners.map(async ({ condition, callback }) => { - if (condition(event)) { - // TODO: do fetch and decrypt, not only cache + // TODO: handler for saving to internal cache + // errors should not be ignored until event is processed - how to give up after some time? + events.registerHandler(async (event) => { + if (event.type === 'node_created') { + try { + const parentNode = await cache.getNode(event.parentNodeUid); + // TODO: do not fetch and decrypt, only save to cache there is new node + } catch (err) { + // TODO: ignore if missing in cache + throw err; + } + } + if (event.type === 'node_updated' || event.type === 'node_updated_metadata') { + try { const node = await cache.getNode(event.nodeUid); - callback({ type: 'update', uid: node.uid, node: node as any }); + node.isStale = true; + await cache.setNode(node); + } catch (err) { + // TODO: ignore if missing in cache + throw err; } - })); - } - if (event.type === 'node_deleted') { - await Promise.all(listeners.map(async ({ condition, callback }) => { - if (condition(event)) { - callback({ type: 'remove', uid: event.nodeUid }); + } + if (event.type === 'node_deleted') { + try { + await cache.removeNodes([event.nodeUid]); + } catch (err) { + // TODO: ignore if missing in cache + throw err; } - })); - } - }); + } + }); + + // TODO: ignore errors if this doesn't work so events can continue + // but log them and how to report to the caller? + events.registerHandler(async (event) => { + if (event.type === 'node_created' || event.type === 'node_updated' || event.type === 'node_updated_metadata') { + await Promise.all(this.listeners.map(async ({ condition, callback }) => { + if (condition(event)) { + // TODO: do fetch and decrypt, not only cache + const node = await cache.getNode(event.nodeUid); + callback({ type: 'update', uid: node.uid, node: node as any }); + } + })); + } + if (event.type === 'node_deleted') { + await Promise.all(this.listeners.map(async ({ condition, callback }) => { + if (condition(event)) { + callback({ type: 'remove', uid: event.nodeUid }); + } + })); + } + }); + } + // TODO: transform internal events to outside events that also fetches whole object and decrypts it // TODO: hook it up after the cache is updated from above // TODO: subscrition to shared by me or trashed nodes needs fetch of every node (to get sharing or trashing info), but not necessarily decryption if not needed node // TODO: shared by me should be handled in sharing module? - function subscribeToSharedNodesByMe(callback: NodeEventCallback) { - listeners.push({ condition: ({ isShared }) => isShared || false, callback }); + subscribeToSharedNodesByMe(callback: NodeEventCallback) { + this.listeners.push({ condition: ({ isShared }) => isShared || false, callback }); } - function subscribeToTrashedNodes(callback: NodeEventCallback) { - listeners.push({ condition: ({ isTrashed }) => isTrashed || false, callback }); + subscribeToTrashedNodes(callback: NodeEventCallback) { + this.listeners.push({ condition: ({ isTrashed }) => isTrashed || false, callback }); } // TODO: subscription to children needs info about parent - if parent is matching, it will fetch and decrypt - function subscribeToChildren(parentNodeUid: string, callback: NodeEventCallback) { - listeners.push({ condition: ({ parentNodeUid: parent }) => parent === parentNodeUid, callback }); - } - - return { - subscribeToSharedNodesByMe, - subscribeToTrashedNodes, - subscribeToChildren, + subscribeToChildren(parentNodeUid: string, callback: NodeEventCallback) { + this.listeners.push({ condition: ({ parentNodeUid: parent }) => parent === parentNodeUid, callback }); } } diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 079a665b..6faeb1af 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -5,7 +5,7 @@ import { DriveEventsService } from "../events"; import { Logger, ProtonDriveAccount } from "../../interface"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache"; -import { nodesEvents } from "./events"; +import { NodesEvents } from "./events"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { SharesService, DecryptedNode } from "./interface"; @@ -39,16 +39,12 @@ export function initNodesModule( const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); const nodesManager = new NodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccess); - const nodesEventsFunctions = nodesEvents(cache, driveEvents); + const nodesEvents = new NodesEvents(cache, driveEvents); return { - // TODO: expose in better way - getNode: nodesAccess.getNode, - getNodeKeys: nodesAccess.getNodeKeys, - getMyFilesRootFolder: nodesManager.getMyFilesRootFolder, - iterateChildren: nodesManager.iterateChildren, - iterateNodes: nodesManager.iterateNodes, - ...nodesEventsFunctions, + access: nodesAccess, + management: nodesManager, + events: nodesEvents, } } @@ -65,16 +61,14 @@ export function initPublicNodesModule( const cryptoCache = new NodesCryptoCache(driveCryptoCache); // @ts-expect-error TODO const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); - const nodesAccessFunctions = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); - const nodesManager = new NodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccessFunctions); + const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); + const nodesManager = new NodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccess); return { // TODO: use public root node, not my files // eslint-disable-next-line @typescript-eslint/no-unused-vars getPublicRootNode: async (token: string, password: string, customPassword?: string): Promise => { return {} as DecryptedNode }, - getNode: nodesAccessFunctions.getNode, - getNodeKeys: nodesAccessFunctions.getNodeKeys, - iterateChildren: nodesManager.iterateChildren, - iterateNodes: nodesManager.iterateNodes, + access: nodesAccess, + management: nodesManager, } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 1a66a53c..7bcc3214 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -9,51 +9,62 @@ import { upload as uploadModule } from './internal/upload'; import { getConfig } from './config'; import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; -export function protonDriveClient({ - httpClient, - entitiesCache, - cryptoCache, - account, - getLogger, - config, - metrics, // eslint-disable-line @typescript-eslint/no-unused-vars - openPGPCryptoModule, - acceptNoGuaranteeWithCustomModules, -}: ProtonDriveClientContructorParameters): Partial { - if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { - // TODO: define errors and use here - throw Error('TODO'); - } - const cryptoModule = new DriveCrypto(openPGPCryptoModule); - - const fullConfig = getConfig(config); - - const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); - - const events = eventsModule(apiService); - const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); - const nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); - const sharing = sharingModule(apiService, account, cryptoModule, nodes); - const upload = uploadModule(apiService, cryptoModule, nodes); - - return { - // TODO - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getNodeUid: (shareId: string, nodeId: string) => Promise.resolve(""), - getMyFilesRootFolder: () => { - return convertInternalNodePromise(nodes.getMyFilesRootFolder()); - }, - iterateChildren: (parentNodeUid: NodeOrUid, signal?: AbortSignal) => { - return convertInternalNodeIterator(nodes.iterateChildren(getUid(parentNodeUid), signal)); - }, - iterateNodes: (nodeUids: NodeOrUid[], signal?: AbortSignal) => { - return convertInternalNodeIterator(nodes.iterateNodes(getUids(nodeUids), signal)); - }, - shareNode: (nodeUid: NodeOrUid, settings: ShareNodeSettings) => { - return sharing.shareNode(getUid(nodeUid), settings); - }, - getFileUploader: (nodeUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) => { - return upload.getFileUploader(getUid(nodeUid), name, metadata, signal); +export class ProtonDriveClient implements Partial { + private nodes: ReturnType; + private sharing: ReturnType; + private upload: ReturnType; + + constructor({ + httpClient, + entitiesCache, + cryptoCache, + account, + getLogger, + config, + metrics, // eslint-disable-line @typescript-eslint/no-unused-vars + openPGPCryptoModule, + acceptNoGuaranteeWithCustomModules, + }: ProtonDriveClientContructorParameters) { + if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { + // TODO: define errors and use here + throw Error('TODO'); } + const cryptoModule = new DriveCrypto(openPGPCryptoModule); + + const fullConfig = getConfig(config); + + const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + + const events = eventsModule(apiService); + const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); + this.nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); + this.sharing = sharingModule(apiService, account, cryptoModule, this.nodes.access); + this.upload = uploadModule(apiService, cryptoModule, this.nodes.access); + } + + // TODO + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getNodeUid(shareId: string, nodeId: string) { + return Promise.resolve("") + } + + async getMyFilesRootFolder() { + return convertInternalNodePromise(this.nodes.management.getMyFilesRootFolder()); + } + + async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { + return convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); + } + + async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + return convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); + } + + async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings) { + return this.sharing.shareNode(getUid(nodeUid), settings); + } + + async getFileUploader(nodeUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) { + return this.upload.getFileUploader(getUid(nodeUid), name, metadata, signal); } } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 4d6590ac..8f128a20 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -1,18 +1,16 @@ -export const protonDrivePhotosClient = () => { - // TODO: this is only example, on background it use drive internals, but it exposes nice interface for photos - return { - // Timeline or album view - iterateTimelinePhotos: () => {}, // returns only UIDs and dates - used to show grid and scrolling - iterateAlbumPhotos: () => {}, // same as above but for album - iterateThumbnails: () => {}, // returns thumbnails for passed photos that are visible in the UI - getPhoto: () => {}, // returns full photo details +// TODO: this is only example, on background it use drive internals, but it exposes nice interface for photos +export class ProtonDrivePhotosClient { + // Timeline or album view + iterateTimelinePhotos() {} // returns only UIDs and dates - used to show grid and scrolling + iterateAlbumPhotos() {} // same as above but for album + iterateThumbnails() {} // returns thumbnails for passed photos that are visible in the UI + getPhoto() {} // returns full photo details - // Album management - createAlbum: () => {}, - renameAlbum: () => {}, - shareAlbum: () => {}, - deleteAlbum: () => {}, - iterateAlbums: () => {}, - addPhotosToAlbum: () => {}, - } + // Album management + createAlbum() {} + renameAlbum() {} + shareAlbum() {} + deleteAlbum() {} + iterateAlbums() {} + addPhotosToAlbum() {} } diff --git a/js/sdk/src/protonDrivePublicClient.ts b/js/sdk/src/protonDrivePublicClient.ts index e79ad02d..50874893 100644 --- a/js/sdk/src/protonDrivePublicClient.ts +++ b/js/sdk/src/protonDrivePublicClient.ts @@ -10,40 +10,45 @@ interface ProtonDrivePublicClientInterface extends Partial, } -export function protonDrivePublicClient({ - httpClient, - entitiesCache, - cryptoCache, - account, - getLogger, - config, - metrics, // eslint-disable-line @typescript-eslint/no-unused-vars - openPGPCryptoModule, - acceptNoGuaranteeWithCustomModules, -}: ProtonDriveClientContructorParameters): ProtonDrivePublicClientInterface { - if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { - // TODO: define errors and use here - throw Error('TODO'); +export class ProtonDrivePublicClient implements ProtonDrivePublicClientInterface { + private nodes: ReturnType; + + constructor({ + httpClient, + entitiesCache, + cryptoCache, + account, + getLogger, + config, + metrics, // eslint-disable-line @typescript-eslint/no-unused-vars + openPGPCryptoModule, + acceptNoGuaranteeWithCustomModules, + }: ProtonDriveClientContructorParameters) { + if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { + // TODO: define errors and use here + throw Error('TODO'); + } + const cryptoModule = new DriveCrypto(openPGPCryptoModule); + + const fullConfig = getConfig(config); + + const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + + // TODO: public sharing module + const publicShares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); + this.nodes = initPublicNodesModule(apiService, entitiesCache, cryptoCache, cryptoModule, publicShares); + } + + async getPublicRootNode(token: string, password: string, customPassword?: string) { + return convertInternalNodePromise(this.nodes.getPublicRootNode(token, password, customPassword)); } - const cryptoModule = new DriveCrypto(openPGPCryptoModule); - - const fullConfig = getConfig(config); - - const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); - - // TODO: public sharing module - const publicShares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); - const nodes = initPublicNodesModule(apiService, entitiesCache, cryptoCache, cryptoModule, publicShares); - - return { - getPublicRootNode: (token: string, password: string, customPassword?: string) => { - return convertInternalNodePromise(nodes.getPublicRootNode(token, password, customPassword)); - }, - iterateChildren: (parentNodeUid: NodeOrUid, signal?: AbortSignal) => { - return convertInternalNodeIterator(nodes.iterateChildren(getUid(parentNodeUid), signal)); - }, - iterateNodes: (nodeUids: NodeOrUid[], signal?: AbortSignal) => { - return convertInternalNodeIterator(nodes.iterateNodes(getUids(nodeUids), signal)); - }, + + async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { + return convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); } + + async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + return convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); + } + } From c9e54f3acad8ad0033efb59dabf678431afaaaae Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Feb 2025 06:36:58 +0000 Subject: [PATCH 006/791] Implement events module --- js/sdk/package.json | 2 +- js/sdk/src/internal/apiService/coreTypes.ts | 21 -- js/sdk/src/internal/apiService/index.ts | 5 +- js/sdk/src/internal/events/apiService.ts | 87 ++++++ js/sdk/src/internal/events/cache.test.ts | 47 ++++ js/sdk/src/internal/events/cache.ts | 73 +++++ .../src/internal/events/coreEventManager.ts | 64 +++++ .../src/internal/events/eventManager.test.ts | 132 +++++++++ js/sdk/src/internal/events/eventManager.ts | 155 +++++++++++ js/sdk/src/internal/events/index.ts | 132 ++++++--- js/sdk/src/internal/events/interface.ts | 47 ++++ .../src/internal/events/volumeEventManager.ts | 69 +++++ js/sdk/src/internal/nodes/apiService.ts | 2 +- js/sdk/src/internal/nodes/events.test.ts | 262 ++++++++++++++++++ js/sdk/src/internal/nodes/events.ts | 214 +++++++++----- js/sdk/src/internal/nodes/index.ts | 12 +- js/sdk/src/internal/nodes/manager.ts | 2 +- js/sdk/src/internal/uids.ts | 13 + js/sdk/src/protonDriveClient.ts | 4 +- 19 files changed, 1212 insertions(+), 131 deletions(-) create mode 100644 js/sdk/src/internal/events/apiService.ts create mode 100644 js/sdk/src/internal/events/cache.test.ts create mode 100644 js/sdk/src/internal/events/cache.ts create mode 100644 js/sdk/src/internal/events/coreEventManager.ts create mode 100644 js/sdk/src/internal/events/eventManager.test.ts create mode 100644 js/sdk/src/internal/events/eventManager.ts create mode 100644 js/sdk/src/internal/events/interface.ts create mode 100644 js/sdk/src/internal/events/volumeEventManager.ts create mode 100644 js/sdk/src/internal/nodes/events.test.ts create mode 100644 js/sdk/src/internal/uids.ts diff --git a/js/sdk/package.json b/js/sdk/package.json index 2339ac6c..bdeccd3d 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -10,7 +10,7 @@ "generate-types": "openapi-typescript ../../api/openapi-drive.json -o ./src/internal/apiService/driveTypes.ts && openapi-typescript ../../api/openapi-core.json -o ./src/internal/apiService/coreTypes.ts", "lint": "eslint src --ext .ts --cache --ignore-pattern '**/apiService/*Types.ts'", "pretty": "prettier --write $(find src -type f -name '*.ts')", - "test": "jest", + "test": "jest --runInBand --no-cache", "test:watch": "jest --watch --coverage=false" }, "dependencies": { diff --git a/js/sdk/src/internal/apiService/coreTypes.ts b/js/sdk/src/internal/apiService/coreTypes.ts index f4b17758..bfa3acdf 100644 --- a/js/sdk/src/internal/apiService/coreTypes.ts +++ b/js/sdk/src/internal/apiService/coreTypes.ts @@ -22187,27 +22187,6 @@ export interface operations { }; }; }; - "post_core-{_version}-reports-sentry-api-{id}-{type}": { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - id: string; - type: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; "post_core-{_version}-reports-phishing": { parameters: { query?: never; diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 15bc55b3..4aeaa65b 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -1,3 +1,4 @@ -export { DriveAPIService } from './apiService.js'; -export { paths as drivePaths } from './driveTypes.js'; +export { DriveAPIService } from './apiService'; +export { paths as drivePaths } from './driveTypes'; +export { paths as corePaths } from './coreTypes'; export * from './errors'; diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts new file mode 100644 index 00000000..2841dd11 --- /dev/null +++ b/js/sdk/src/internal/events/apiService.ts @@ -0,0 +1,87 @@ +import { Logger } from "../../interface"; +import { DriveAPIService, drivePaths, corePaths } from "../apiService"; +import { makeNodeUid } from "../uids"; +import { DriveEvents, DriveEvent, DriveEventType } from "./interface"; + +type GetCoreLatestEventResponse = corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json']; +type GetCoreEventResponse = corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json']; + +type GetVolumeLatestEventResponse = drivePaths['/drive/volumes/{volumeID}/events/latest']['get']['responses']['200']['content']['application/json']; +type GetVokumeEventResponse = drivePaths['/drive/volumes/{volumeID}/events/{eventID}']['get']['responses']['200']['content']['application/json']; + +const VOLUME_EVENT_TYPE_MAP = { + 0: DriveEventType.NodeDeleted, + 1: DriveEventType.NodeCreated, + 2: DriveEventType.NodeUpdated, + 3: DriveEventType.NodeUpdatedMetadata, +} + +/** + * Provides API communication for fetching events. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ +export class EventsAPIService { + constructor(private apiService: DriveAPIService, private logger?: Logger) { + this.apiService = apiService; + this.logger = logger; + } + + async getCoreLatestEventId(): Promise { + const result = await this.apiService.get(`/core/v5/events/latest`); + return result.EventID as string; + } + + async getCoreEvents(eventId: string): Promise { + // TODO: Switch to v6 endpoint. + const result = await this.apiService.get(`/core/v5/events/${eventId}?NoMetaData=1`); + const events: DriveEvent[] = result.DriveShareRefresh?.Action === 2 ? [ + { + type: DriveEventType.ShareWithMeUpdated, + } + ] : []; + + return { + lastEventId: result.EventID, + more: result.More === 1, + refresh: result.Refresh === 1, + events, + }; + } + + async getVolumeLatestEventId(volumeId: string): Promise { + const result = await this.apiService.get(`/drive/volumes/${volumeId}/events/latest`); + return result.EventID; + } + + async getVolumeEvents(volumeId: string, eventId: string): Promise { + // TODO: Switch to the new API once it's available + const result = await this.apiService.get(`/drive/volumes/${volumeId}/events/${eventId}`); + return { + lastEventId: result.EventID, + more: result.More === 1, + refresh: result.Refresh === 1, + events: result.Events.map((event) => { + const type = VOLUME_EVENT_TYPE_MAP[event.EventType]; + const link = event.Link as Extract; + const uids = { + nodeUid: makeNodeUid(volumeId, event.Link.LinkID), + parentNodeUid: makeNodeUid(volumeId, link.ParentLinkID as string), + } + if (type === DriveEventType.NodeDeleted) { + return { + type, + ...uids, + } + } + return { + type, + ...uids, + isTrashed: !!link.Trashed, + isShared: link.SharingDetails?.ShareID !== undefined, + }; + }), + }; + } +} diff --git a/js/sdk/src/internal/events/cache.test.ts b/js/sdk/src/internal/events/cache.test.ts new file mode 100644 index 00000000..e96c119b --- /dev/null +++ b/js/sdk/src/internal/events/cache.test.ts @@ -0,0 +1,47 @@ +import { MemoryCache } from "../../cache"; +import { EventsCache } from "./cache"; + +describe("EventsCache", () => { + let memoryCache: MemoryCache; + let cache: EventsCache; + + beforeEach(() => { + memoryCache = new MemoryCache([]); + cache = new EventsCache(memoryCache); + }); + + it("should store and retrieve last event ID", async () => { + const key = "volume1"; + await cache.setLastEventId(key, "eventId1", 0); + await cache.setLastEventId(key, "eventId2", 0); + const result = await cache.getLastEventId(key); + expect(result).toBe("eventId2"); + }); + + it("should store and retrieve polling interval", async () => { + const key = "volume1"; + await cache.setLastEventId(key, "lastEventId", 10); + await cache.setLastEventId(key, "lastEventId", 20); + const result = await cache.getPollingIntervalInSeconds(key); + expect(result).toBe(20); + }); + + it("should store and retrieve subscribed volume IDs", async () => { + await cache.setLastEventId("volume1", "lastEventId", 0); + await cache.setLastEventId("volume2", "lastEventId", 0); + const result = await cache.getSubscribedVolumeIds(); + expect(result).toStrictEqual(["volume1", "volume2"]); + }); + + it("should not fail if cache is empty", async () => { + const result = await cache.getLastEventId("volume1"); + expect(result).toBe(undefined); + }); + + it("should call cache only once", async () => { + const spy = jest.spyOn(memoryCache, "getEntity"); + await cache.getLastEventId("volume1"); + await cache.getLastEventId("volume1"); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/sdk/src/internal/events/cache.ts b/js/sdk/src/internal/events/cache.ts new file mode 100644 index 00000000..9551d35d --- /dev/null +++ b/js/sdk/src/internal/events/cache.ts @@ -0,0 +1,73 @@ +import { ProtonDriveCache } from "../../cache"; + +type CachedEventsData = { + // Key is either a volume ID for volume events or 'core' for core events. + [key: string]: { + lastEventId: string; + pollingIntervalInSeconds: number; + } +}; + +/** + * Provides caching for events IDs. + */ +export class EventsCache { + /** + * Locally cached events data to avoid unnecessary reads from the cache. + * Data about last event ID or interval might be accessed often by events + * managers. + */ + private events?: CachedEventsData; + + constructor(private driveCache: ProtonDriveCache) { + this.driveCache = driveCache; + } + + async setLastEventId(volumeIdOrCore: string, lastEventId: string, pollingIntervalInSeconds: number): Promise { + const events = await this.getEvents(); + events[volumeIdOrCore] = { + lastEventId, + pollingIntervalInSeconds, + } + await this.cacheEvents(events); + } + + async getLastEventId(volumeIdOrCore: string): Promise { + const events = await this.getEvents(); + if (events[volumeIdOrCore]) { + return events[volumeIdOrCore].lastEventId; + } + } + + async getPollingIntervalInSeconds(volumeIdOrCore: string): Promise { + const events = await this.getEvents(); + if (events[volumeIdOrCore]) { + return events[volumeIdOrCore].pollingIntervalInSeconds; + } + } + + async getSubscribedVolumeIds(): Promise { + const events = await this.getEvents(); + return Object.keys(events).filter((volumeIdOrCore) => volumeIdOrCore !== 'core'); + } + + private async getEvents(): Promise { + if (!this.events) { + this.events = await this.getCachedEvents(); + } + return this.events; + } + + private async getCachedEvents(): Promise { + try { + const events = await this.driveCache.getEntity('events'); + return JSON.parse(events); + } catch {}; + return {}; + } + + private async cacheEvents(events: CachedEventsData): Promise { + this.events = events; + await this.driveCache.setEntity('events', JSON.stringify(events)); + } +} diff --git a/js/sdk/src/internal/events/coreEventManager.ts b/js/sdk/src/internal/events/coreEventManager.ts new file mode 100644 index 00000000..571394f9 --- /dev/null +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -0,0 +1,64 @@ +import { Logger } from "../../interface"; +import { EventsAPIService } from "./apiService"; +import { EventsCache } from "./cache"; +import { DriveEvent, DriveEventType } from "./interface"; +import { EventManager } from "./eventManager"; + +/** + * Combines API and event manager to provide a service for listening to + * core events. Core events are events that are not specific to any volume. + * At this moment, Drive listenes only to shares with me updates from core + * events. Such even indicates that user was invited to the new share or + * that user's membership was removed from existing one and lost access. + * + * The client might be already using own core events, thus this service + * is here only in case the client is not connected to the Proton services + * with own implementation. + */ +export class CoreEventManager { + private manager: EventManager; + + constructor(private apiService: EventsAPIService, private cache: EventsCache, log?: Logger) { + this.apiService = apiService; + + this.manager = new EventManager( + () => this.getLastEventId(), + (eventId) => this.apiService.getCoreEvents(eventId), + (lastEventId) => this.cache.setLastEventId('core', lastEventId, this.manager.pollingIntervalInSeconds), + log, + ); + } + + private async getLastEventId(): Promise { + const lastEventId = await this.cache.getLastEventId('core'); + if (lastEventId) { + return lastEventId; + } + return this.apiService.getCoreLatestEventId(); + } + + startSubscription(): void { + this.manager.start(); + } + + stopSubscription(): void { + this.manager.stop(); + } + + addListener(callback: (events: DriveEvent[]) => Promise): void { + this.manager.addListener(async (events, fullRefresh) => { + if (events) { + await callback(events); + } + if (fullRefresh) { + // Because only updates about shares that are shared with me + // are listened to from core events, in the case of core full + // refresh, we don't have to refresh anything more than this + // one specific event. + await callback([{ + type: DriveEventType.ShareWithMeUpdated, + }]); + } + }); + } +} diff --git a/js/sdk/src/internal/events/eventManager.test.ts b/js/sdk/src/internal/events/eventManager.test.ts new file mode 100644 index 00000000..7fafc2a8 --- /dev/null +++ b/js/sdk/src/internal/events/eventManager.test.ts @@ -0,0 +1,132 @@ +import { NotFoundAPIError } from "../apiService"; +import { EventManager } from "./eventManager"; + +jest.useFakeTimers(); + +describe("EventManager", () => { + let manager: EventManager; + + const getLastEventIdMock = jest.fn(); + const getEventsMock = jest.fn(); + const eventsProcessedMock = jest.fn(); + const listenerMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + getLastEventIdMock.mockImplementation(() => Promise.resolve("eventId1")); + getEventsMock.mockImplementation(() => Promise.resolve({ + lastEventId: "eventId2", + more: false, + refresh: false, + events: ["event1", "event2"], + })); + + manager = new EventManager( + getLastEventIdMock, + getEventsMock, + eventsProcessedMock, + ); + manager.addListener(listenerMock); + }); + + afterEach(async () => { + await manager.stop(); + }); + + it("should get latest event ID on first run only", async () => { + manager.start(); + expect(getLastEventIdMock).toHaveBeenCalledTimes(1); + expect(getEventsMock).toHaveBeenCalledTimes(0); + expect(listenerMock).toHaveBeenCalledTimes(0); + expect(eventsProcessedMock).toHaveBeenCalledTimes(0); + }); + + it("should notify about events in the next run", async () => { + manager.start(); + expect(getLastEventIdMock).toHaveBeenCalledTimes(1); + expect(getEventsMock).toHaveBeenCalledTimes(0); + expect(listenerMock).toHaveBeenCalledTimes(0); + expect(eventsProcessedMock).toHaveBeenCalledTimes(0); + await jest.runOnlyPendingTimersAsync(); + expect(getEventsMock).toHaveBeenCalledTimes(1); + expect(listenerMock).toHaveBeenCalledTimes(1); + expect(eventsProcessedMock).toHaveBeenCalledTimes(1); + expect(eventsProcessedMock).toHaveBeenCalledWith('eventId2'); + }); + + it("should continue with more events", async () => { + getEventsMock.mockImplementation((lastEventId: string) => Promise.resolve({ + lastEventId: lastEventId === "eventId1" ? "eventId2" : "eventId3", + more: lastEventId === "eventId1" ? true : false, + refresh: false, + events: lastEventId === "eventId1" ? ["event1", "event2"] : ["event3"], + })); + manager.start(); + await jest.runOnlyPendingTimersAsync(); + expect(getEventsMock).toHaveBeenCalledTimes(2); + expect(listenerMock).toHaveBeenCalledTimes(2); + expect(listenerMock).toHaveBeenCalledWith(["event1", "event2"], false); + expect(listenerMock).toHaveBeenCalledWith(["event3"], false); + expect(eventsProcessedMock).toHaveBeenCalledTimes(2); + expect(eventsProcessedMock).toHaveBeenCalledWith('eventId2'); + expect(eventsProcessedMock).toHaveBeenCalledWith('eventId3'); + }); + + it("should refresh if event does not exist", async () => { + getEventsMock.mockImplementation(() => Promise.reject(new NotFoundAPIError('Event not found', 2501))); + manager.start(); + await jest.runOnlyPendingTimersAsync(); + expect(getLastEventIdMock).toHaveBeenCalledTimes(2); + expect(listenerMock).toHaveBeenCalledTimes(1); + expect(listenerMock).toHaveBeenCalledWith([], true); + expect(eventsProcessedMock).toHaveBeenCalledTimes(1); + expect(eventsProcessedMock).toHaveBeenCalledWith('eventId1'); + }); + + it("should retry on error", async () => { + let index = 0; + getEventsMock.mockImplementation(() => { + index++; + if (index <= 3) { + return Promise.reject(new Error("Error")); + } + return Promise.resolve({ + lastEventId: "eventId2", + more: false, + refresh: false, + events: ["event1", "event2"], + }); + }); + manager.start(); + + // First failure. + await jest.runOnlyPendingTimersAsync(); + expect(listenerMock).toHaveBeenCalledTimes(0); + expect(manager.nextPollTimeout).toBe(30000); + + // Second failure. + await jest.runOnlyPendingTimersAsync(); + expect(listenerMock).toHaveBeenCalledTimes(0); + expect(manager.nextPollTimeout).toBe(60000); + + // Third failure. + await jest.runOnlyPendingTimersAsync(); + expect(listenerMock).toHaveBeenCalledTimes(0); + expect(manager.nextPollTimeout).toBe(90000); + + // And now it passes. + await jest.runOnlyPendingTimersAsync(); + expect(listenerMock).toHaveBeenCalledTimes(1); + expect(listenerMock).toHaveBeenCalledWith(["event1", "event2"], false); + expect(eventsProcessedMock).toHaveBeenCalledTimes(1); + expect(eventsProcessedMock).toHaveBeenCalledWith('eventId2'); + }); + + it("should stop polling", async () => { + manager.start(); + await manager.stop(); + await jest.runOnlyPendingTimersAsync(); + expect(getEventsMock).toHaveBeenCalledTimes(0); + }); +}); diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts new file mode 100644 index 00000000..c988620b --- /dev/null +++ b/js/sdk/src/internal/events/eventManager.ts @@ -0,0 +1,155 @@ +import { Logger } from "../../interface"; +import { NotFoundAPIError } from "../apiService"; +import { Events } from "./interface"; + +const DEFAULT_POLLING_INTERVAL_IN_SECONDS = 30; +const FIBONACCI_LIST = [1, 1, 2, 3, 5, 8, 13]; + +/** + * `fullRefresh` is true when the event manager has requested a full + * refresh of the data. That can happen if there is too many events + * to be processed or the last event ID is too old. + */ +type Listener = (events: T[], fullRefresh: boolean) => Promise; + +/** + * Event manager general helper that is responsible for fetching events + * from the server and notifying listeners about the events. + * + * The specific implementation of fetching the events from the API must + * be passed as dependency and can be used for any type of events that + * supports the same structure. + * + * The manager will not start fetching events until the `start` method is + * called. Once started, the manager will fetch events in a loop with + * a timeout between each fetch. The default timeout is 30 seconds and + * additional jitter is used in case of failure. + * + * Example of usage: + * + * ```typescript + * const manager = new EventManager( + * () => apiService.getLatestEventId(), + * (eventId) => apiService.getEvents(eventId), + * ); + * + * manager.addListener((events, fullRefresh) => { + * // Process the events + * }); + * + * manager.start(); + * ``` + */ +export class EventManager { + private lastestEventId?: string; + private timeoutHandle?: ReturnType; + private processPromise?: Promise; + private listeners: Listener[] = []; + private retryIndex: number = 0; + + pollingIntervalInSeconds = DEFAULT_POLLING_INTERVAL_IN_SECONDS; + + constructor( + private getLatestEventId: () => Promise, + private getEvents: (eventId: string) => Promise>, + private eventsProcessed: (lastEventId: string) => Promise, + private log?: Logger, + ) { + this.getLatestEventId = getLatestEventId; + this.getEvents = getEvents; + } + + addListener(callback: Listener): void { + this.listeners.push(callback); + } + + start(): void { + this.stop(); + this.processPromise = this.processEvents(); + } + + private async processEvents() { + try { + if (!this.lastestEventId) { + this.lastestEventId = await this.getLatestEventId(); + } else { + while (true) { + let result; + try { + result = await this.getEvents(this.lastestEventId); + } catch (error: unknown) { + // If last event ID is not found, we need to refresh the data. + // Caller is notified via standard event update with refresh flag. + if (error instanceof NotFoundAPIError) { + result = { + lastEventId: await this.getLatestEventId(), + more: false, + refresh: true, + events: [], + }; + } else { + // Any other error is considered as a failure and we will retry + // with backoff policy. + throw error; + } + } + await this.notifyListeners(result); + this.lastestEventId = result.lastEventId; + if (!result.more) { + break; + } + } + } + this.retryIndex = 0; + } catch (error: unknown) { + this.log?.error(`Failed to process events: ${error instanceof Error ? error.message : error} (retry ${this.retryIndex}, last event ID: ${this.lastestEventId})`); + this.retryIndex++; + } + + this.timeoutHandle = setTimeout(() => { + this.processPromise = this.processEvents(); + }, this.nextPollTimeout); + }; + + private async notifyListeners(result: Events): Promise { + if (result.events.length === 0 && !result.refresh) { + return; + } + + for (const listener of this.listeners) { + try { + await listener(result.events, result.refresh); + } catch (error: unknown) { + this.log?.error(`Failed to process events: ${error instanceof Error ? error.message : error} (last event ID: ${result.lastEventId}, refresh: ${result.refresh})`); + throw error; + } + } + + await this.eventsProcessed(result.lastEventId); + } + + /** + * Polling timeout is using exponential backoff with Fibonacci sequence. + * + * The timeout is public for testing purposes only. + */ + get nextPollTimeout(): number { + const retryIndex = Math.min(this.retryIndex, FIBONACCI_LIST.length - 1); + return this.pollingIntervalInSeconds * 1000 * FIBONACCI_LIST[retryIndex]; + } + + async stop(): Promise { + if (this.processPromise) { + try { + await this.processPromise; + } catch {} + } + + if (!this.timeoutHandle) { + return; + } + + clearTimeout(this.timeoutHandle); + this.timeoutHandle = undefined; + } +} diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 2718c245..46619a03 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -1,38 +1,100 @@ -import { DriveAPIService } from "../apiService/index.js"; - -export interface DriveEventsService { - subscribeToRemoteDataUpdates: () => void, - registerHandler: (callback: (event: DriveEvent) => Promise) => void, - lastUsedVolume: (volumeId: string) => void, -}; - -// TODO: implement event handling, generic for both core+volume events -export function events(apiService: DriveAPIService): DriveEventsService { - return { - // TODO: exposed to public, starts listening to core+volume events - // TODO: core should listen only to minimum events possible - // TODO: volume should listen: own always, others with limitations as per RFC - subscribeToRemoteDataUpdates: () => {}, - - // TODO: internal only, other modules can react to events - // TODO: events module will wait for event to be processed - if its failing, it will not move forward - registerHandler: (callback: (event: DriveEvent) => Promise) => {}, - // TODO: helper that other modules can help say what volume is more important - lastUsedVolume: (volumeId: string) => {}, +import { ProtonDriveCache } from "../../cache"; +import { Logger } from "../../interface"; +import { DriveAPIService } from "../apiService"; +import { DriveListener } from "./interface"; +import { EventsAPIService } from "./apiService"; +import { EventsCache } from "./cache"; +import { CoreEventManager } from "./coreEventManager"; +import { VolumeEventManager } from "./volumeEventManager"; + +export { DriveEvent, DriveEventType } from "./interface"; + +const OWN_VOLUME_POLLING_INTERVAL = 30; +const OTHER_VOLUME_POLLING_INTERVAL = 60; + +/** + * Service for listening to drive events. The service is responsible for + * managing the subscriptions to the events and notifying the listeners + * about the new events. + */ +export class DriveEventsService { + private apiService: EventsAPIService; + private cache: EventsCache; + private subscribedToRemoteDataUpdates: boolean = false; + private listeners: DriveListener[] = []; + private coreEvents: CoreEventManager; + private volumesEvents: { [volumeId: string]: any }; + + constructor(apiService: DriveAPIService, driveEntitiesCache: ProtonDriveCache, private log?: Logger) { + this.apiService = new EventsAPIService(apiService); + this.cache = new EventsCache(driveEntitiesCache); + this.log = log; + + // TODO: Allow to pass own core events manager from the public interface. + this.coreEvents = new CoreEventManager(this.apiService, this.cache, this.log); + this.volumesEvents = {}; } -} -export type DriveEvent = { - type: 'node_created' | 'node_updated' | 'node_updated_metadata', - nodeUid: string, - parentNodeUid: string, - // TODO: needs RFC how we can pass it from events system efficiently without computing whole object - isTrashed: boolean, - isShared: boolean, -} | { - type: 'node_deleted', - nodeUid: string, - parentNodeUid: string, -} | { - type: 'share_with_me_updated', + /** + * Loads all the subscribed volumes (including core events) from the + * cache and starts listening to their events. Any additional volume + * that is subscribed to later will be automatically started. + */ + async subscribeToRemoteDataUpdates(): Promise { + if (this.subscribedToRemoteDataUpdates) { + return; + } + + await this.loadSubscribedVolumeEventServices(); + + this.subscribedToRemoteDataUpdates = true; + this.coreEvents.startSubscription(); + Object.values(this.volumesEvents).forEach((volumeEvents) => volumeEvents.startSubscription()); + } + + /** + * Subscribe to given volume. The volume will be polled for events + * with the polling interval depending on the type of the volume. + * Own volumes are polled with highest frequency, while others are + * polled with lower frequency depending on the total number of + * subsciptions. + * + * @param isOwnVolume Owned volumes are polled with higher frequency. + */ + async listenToVolume(volumeId: string, isOwnVolume = false): Promise { + await this.loadSubscribedVolumeEventServices(); + + if (this.volumesEvents[volumeId]) { + return; + } + const volumeEvents = new VolumeEventManager(this.apiService, this.cache, volumeId, this.log); + this.volumesEvents[volumeId] = volumeEvents; + + // TODO: Use dynamic algorithm to determine polling interval for non-own volumes. + volumeEvents.setPollingInterval(isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL); + if (this.subscribedToRemoteDataUpdates) { + volumeEvents.startSubscription(); + } + } + + private async loadSubscribedVolumeEventServices() { + for (const volumeId of await this.cache.getSubscribedVolumeIds()) { + if (!this.volumesEvents[volumeId]) { + this.volumesEvents[volumeId] = new VolumeEventManager(this.apiService, this.cache, volumeId, this.log); + } + } + } + + /** + * Listen to the drive events. The listener will be called with the + * new events as they arrive. + * + * One call always provides events from withing the same volume. The + * second argument of the callback `fullRefreshVolumeId` is thus single + * ID and if multiple volumes must be fully refreshed, client will + * receive multiple calls. + */ + addListener(callback: DriveListener): void { + this.listeners.push(callback); + } } diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts new file mode 100644 index 00000000..b53393fb --- /dev/null +++ b/js/sdk/src/internal/events/interface.ts @@ -0,0 +1,47 @@ +/** + * Callback that accepts list of Drive events and flag whether no + * event should be processed, but rather full cache refresh should be + * performed. + * + * @param fullRefreshVolumeId - ID of the volume that should be fully refreshed. + */ +export type DriveListener = (events: DriveEvent[], fullRefreshVolumeId?: string) => Promise; + +/** + * Generic internal event interface representing a list of events + * with metadata about the last event ID, whether there are more + * events to fetch, or whether the listener should refresh its state. + */ +export type Events = { + lastEventId: string, + more: boolean, + refresh: boolean, + events: T[], +} + +/** + * Internal event interface representing a list of specific Drive events. + */ +export type DriveEvents = Events; + +export type DriveEvent = { + type: DriveEventType.NodeCreated | DriveEventType.NodeUpdated | DriveEventType.NodeUpdatedMetadata, + nodeUid: string, + parentNodeUid: string, + isTrashed: boolean, + isShared: boolean, +} | { + type: DriveEventType.NodeDeleted, + nodeUid: string, + parentNodeUid: string, +} | { + type: DriveEventType.ShareWithMeUpdated, +} + +export enum DriveEventType { + NodeCreated = 'node_created', + NodeUpdated = 'node_updated', + NodeUpdatedMetadata = 'node_updated_metadata', + NodeDeleted = 'node_deleted', + ShareWithMeUpdated = 'share_with_me_updated', +} diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts new file mode 100644 index 00000000..f683b200 --- /dev/null +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -0,0 +1,69 @@ +import { Logger } from "../../interface"; +import { EventsAPIService } from "./apiService"; +import { EventsCache } from "./cache"; +import { DriveEvent, DriveListener } from "./interface"; +import { EventManager } from "./eventManager"; + +/** + * Combines API and event manager to provide a service for listening to + * volume events. Volume events are all about nodes updates. Whenever + * there is update to the node metadata or content, the event is emitted. + */ +export class VolumeEventManager { + private manager: EventManager; + + constructor(private apiService: EventsAPIService, private cache: EventsCache, private volumeId: string, log?: Logger) { + this.apiService = apiService; + this.volumeId = volumeId; + + this.manager = new EventManager( + () => this.getLastEventId(), + (eventId) => this.apiService.getVolumeEvents(volumeId, eventId), + (lastEventId) => this.cache.setLastEventId(volumeId, lastEventId, this.manager.pollingIntervalInSeconds), + log, + ); + this.cache.getPollingIntervalInSeconds(volumeId) + .then((pollingIntervalInSeconds) => { + if (pollingIntervalInSeconds) { + this.manager.pollingIntervalInSeconds = pollingIntervalInSeconds; + } + }) + .catch(() => {}); + } + + private async getLastEventId(): Promise { + const lastEventId = await this.cache.getLastEventId(this.volumeId); + if (lastEventId) { + return lastEventId; + } + return this.apiService.getVolumeLatestEventId(this.volumeId); + } + + /** + * There is a limit how many volume subscribtions can be active at + * the same time. The manager of all volume managers should set the + * intervals for each volume accordingly depending on the volume + * type or the total number of subscriptions. + */ + setPollingInterval(pollingIntervalInSeconds: number): void { + this.manager.pollingIntervalInSeconds = pollingIntervalInSeconds; + } + + startSubscription(): void { + this.manager.start(); + } + + stopSubscription(): void { + this.manager.stop(); + } + + addListener(callback: DriveListener): void { + this.manager.addListener(async (events, fullRefresh) => { + if (fullRefresh) { + await callback([], this.volumeId); + } else { + await callback(events); + } + }); + } +} diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 3d859747..a651164a 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,6 +1,6 @@ import { Logger, NodeType, MemberRole, NodeResult } from "../../interface"; import { DriveAPIService, drivePaths } from "../apiService"; -import { splitNodeUid, makeNodeUid } from "./nodeUid"; +import { splitNodeUid, makeNodeUid } from "../uids"; import { EncryptedNode } from "./interface"; type PostLoadLinksMetadataRequest = Extract['content']['application/json']; diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts new file mode 100644 index 00000000..4835c3b9 --- /dev/null +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -0,0 +1,262 @@ +import { DriveEvent, DriveEventType } from "../events"; +import { updateCacheByEvent, notifyListenersByEvent } from "./events"; +import { DecryptedNode } from "./interface"; +import { NodesCache } from "./cache"; +import { NodesAccess } from "./nodesAccess"; + +describe("updateCacheByEvent", () => { + let cache: NodesCache; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error No need to implement all methods for mocking + cache = { + getNode: jest.fn(), + setNode: jest.fn(), + removeNodes: jest.fn(), + }; + }); + + describe('NodeCreated event', () => { + const event = { + type: DriveEventType.NodeCreated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + }; + + it("should not update cache by node create event", async () => { + await updateCacheByEvent(event, cache); + + expect(cache.getNode).toHaveBeenCalledTimes(0); + expect(cache.setNode).toHaveBeenCalledTimes(0); + }); + }); + + describe('NodeUpdated event', () => { + const event = { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + }; + + it("should update cache if present in cache", async () => { + cache.getNode = jest.fn(() => Promise.resolve({ uid: '123' } as DecryptedNode)); + + await updateCacheByEvent(event, cache); + + expect(cache.getNode).toHaveBeenCalledTimes(1); + expect(cache.setNode).toHaveBeenCalledTimes(1); + expect(cache.setNode).toHaveBeenCalledWith({ uid: '123', isStale: true }); + }); + + it("should skip if missing in cache", async () => { + cache.getNode = jest.fn(() => Promise.reject(new Error('Missing in the cache'))); + + await updateCacheByEvent(event, cache); + + expect(cache.getNode).toHaveBeenCalledTimes(1); + expect(cache.setNode).toHaveBeenCalledTimes(0); + }); + + it("should remove from cache if not possible to set", async () => { + cache.getNode = jest.fn(() => Promise.resolve({ uid: '123' } as DecryptedNode)); + cache.setNode = jest.fn(() => Promise.reject(new Error('Cannot set node'))); + + await updateCacheByEvent(event, cache); + + expect(cache.getNode).toHaveBeenCalledTimes(1); + expect(cache.removeNodes).toHaveBeenCalledTimes(1); + }); + + it("should throw if remove fails", async () => { + cache.getNode = jest.fn(() => Promise.resolve({ uid: '123' } as DecryptedNode)); + cache.setNode = jest.fn(() => Promise.reject(new Error('Cannot set node'))); + cache.removeNodes = jest.fn(() => Promise.reject(new Error('Cannot remove node'))); + + await expect(updateCacheByEvent(event, cache)).rejects.toThrow('Cannot set node'); + }); + }); + + describe('NodeDeleted event', () => { + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + } + + it("should remove node from cache", async () => { + await updateCacheByEvent(event, cache); + + expect(cache.removeNodes).toHaveBeenCalledTimes(1); + expect(cache.removeNodes).toHaveBeenCalledWith([event.nodeUid]); + }); + }); +}); + +describe("notifyListenersByEvent", () => { + let cache: NodesCache; + let nodesAccess: NodesAccess; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error No need to implement all methods for mocking + cache = { + getNode: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + nodesAccess = { + getNode: jest.fn(() => Promise.resolve({ uid: 'nodeUid' } as DecryptedNode)), + }; + }); + + describe('update event', () => { + it("should notify listeners by parentNodeUid", async () => { + const event = { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + }; + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); + expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); + }); + + it("should notify listeners by isTrashed", async () => { + const event = { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: true, + isShared: false, + }; + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); + expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); + }); + + it("should notify listeners by isShared", async () => { + const event = { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: true, + }; + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); + expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); + }); + + it("should not notify listeners if neither condition match", async () => { + const event = { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + }; + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'lalalala', callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(0); + expect(nodesAccess.getNode).toHaveBeenCalledTimes(0); + }); + }); + + describe('delete event', () => { + it("should notify listeners by parentNodeUid", async () => { + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + }; + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); + }); + + it("should notify listeners by isTrashed from cache", async () => { + cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeUid', trashedDate: new Date() } as DecryptedNode)); + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + }; + + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); + }); + + it("should notify listeners by isShared from cache", async () => { + cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeUid', isShared: true } as DecryptedNode)); + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + }; + + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); + }); + + it("should not notify listeners if cache is missing node", async () => { + cache.getNode = jest.fn(() => Promise.reject(new Error('Missing in the cache'))); + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + }; + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + it("should not notify listeners if neither condition match", async () => { + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + }; + const listener = jest.fn(); + + await notifyListenersByEvent(event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'lalalala', callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(0); + }); + }); + +}); diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 7322abbc..a6d40acf 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -1,7 +1,28 @@ -import { NodeEventCallback } from "../../interface/index"; -import { DriveEventsService } from "../events/index"; +import { Logger, NodeEventCallback } from "../../interface"; +import { DriveEventsService, DriveEvent, DriveEventType } from "../events"; +import { DecryptedNode } from "./interface"; import { NodesCache } from "./cache"; +import { NodesAccess } from "./nodesAccess"; +type Listeners = { + /** + * Condition for the listener to be notified about the event. + * + * The condition is a function that receives the event information + * and returns true if the listener should be notified about the + * event. + */ + condition: (nodeEventInfo: NodeEventInfo) => boolean, + callback: NodeEventCallback, +}[]; + +/** + * Minimal information about the event that is used for listener + * condition. The information is used to determine if the listener + * should be notified about the event. + * + * This must come from the API response to volume events. + */ type NodeEventInfo = { parentNodeUid: string, isTrashed?: boolean, @@ -17,75 +38,22 @@ type NodeEventInfo = { * any update for trashed nodes. */ export class NodesEvents { - private listeners: { condition: (nodeEventInfo: NodeEventInfo) => boolean, callback: NodeEventCallback }[] = []; - - constructor( - private cache: NodesCache, - private events: DriveEventsService, - ) { - this.cache = cache; - this.events = events; + private listeners: Listeners = []; - // TODO: handler for saving to internal cache - // errors should not be ignored until event is processed - how to give up after some time? - events.registerHandler(async (event) => { - if (event.type === 'node_created') { - try { - const parentNode = await cache.getNode(event.parentNodeUid); - // TODO: do not fetch and decrypt, only save to cache there is new node - } catch (err) { - // TODO: ignore if missing in cache - throw err; - } - } - if (event.type === 'node_updated' || event.type === 'node_updated_metadata') { - try { - const node = await cache.getNode(event.nodeUid); - node.isStale = true; - await cache.setNode(node); - } catch (err) { - // TODO: ignore if missing in cache - throw err; - } - } - if (event.type === 'node_deleted') { - try { - await cache.removeNodes([event.nodeUid]); - } catch (err) { - // TODO: ignore if missing in cache - throw err; - } + constructor(events: DriveEventsService, cache: NodesCache, nodesAccess: NodesAccess, log?: Logger) { + events.addListener(async (events) => { + for (const event of events) { + await updateCacheByEvent(event, cache, log); } }); - // TODO: ignore errors if this doesn't work so events can continue - // but log them and how to report to the caller? - events.registerHandler(async (event) => { - if (event.type === 'node_created' || event.type === 'node_updated' || event.type === 'node_updated_metadata') { - await Promise.all(this.listeners.map(async ({ condition, callback }) => { - if (condition(event)) { - // TODO: do fetch and decrypt, not only cache - const node = await cache.getNode(event.nodeUid); - callback({ type: 'update', uid: node.uid, node: node as any }); - } - })); - } - if (event.type === 'node_deleted') { - await Promise.all(this.listeners.map(async ({ condition, callback }) => { - if (condition(event)) { - callback({ type: 'remove', uid: event.nodeUid }); - } - })); + events.addListener(async (events) => { + for (const event of events) { + await notifyListenersByEvent(event, this.listeners, cache, nodesAccess, log); } }); } - - // TODO: transform internal events to outside events that also fetches whole object and decrypts it - // TODO: hook it up after the cache is updated from above - - // TODO: subscrition to shared by me or trashed nodes needs fetch of every node (to get sharing or trashing info), but not necessarily decryption if not needed node - // TODO: shared by me should be handled in sharing module? subscribeToSharedNodesByMe(callback: NodeEventCallback) { this.listeners.push({ condition: ({ isShared }) => isShared || false, callback }); } @@ -94,8 +62,126 @@ export class NodesEvents { this.listeners.push({ condition: ({ isTrashed }) => isTrashed || false, callback }); } - // TODO: subscription to children needs info about parent - if parent is matching, it will fetch and decrypt subscribeToChildren(parentNodeUid: string, callback: NodeEventCallback) { this.listeners.push({ condition: ({ parentNodeUid: parent }) => parent === parentNodeUid, callback }); } } + +/** + * For given event, update the cache accordingly. + * + * The function is responsible for updating the cache based on the + * event received from the DriveEventsService. The cache metadata + * are not updated, only the nodes are marked as stale to be + * fetched and decrypted again when requested by the client. + * + * If the node is not found in the cache, the event is silently + * skipped as the node will be fetched and decrypted when requested + * by the client. + * + * If the node cannot be updated in the cache, the node is removed + * from the cache to not block the client. If the node is not possible + * to remove, the function throws an error. + * + * @throws Only if the node is not possible to remove from the cache. + */ +export async function updateCacheByEvent(event: DriveEvent, cache: NodesCache, log?: Logger) { + // NodeCreated event is ignored as we do not want to fetch and + // decrypt the node immediately. The node will be fetched and + // decrypted when requested by the client. + if (event.type === DriveEventType.NodeCreated) { + log?.debug(`Skipping node create event`); + } + if (event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) { + let node; + // getNode can fail if the node is not found or if it is + // corrupted. In later case, it will be automatically + // removed from cache. In both cases, lets skip the event + // silently as once requested by client, the node will + // be cached again. + try { + node = await cache.getNode(event.nodeUid); + } catch (error: unknown) { + log?.debug(`Skipping node update event (node not in the cache): ${error}`); + } + if (node) { + node.isStale = true; + try { + await cache.setNode(node); + } catch (setNodeError: unknown) { + log?.error(`Skipping node update event (failed to update): ${setNodeError}`); + // If updating node in the cache is failing, lets remove it + // to not block the whole client. If the node is not possible + // to remove, lets throw at this point as cache is in very + // bad state by this point and the rest of the code would start + // to break randomly. + try { + await cache.removeNodes([event.nodeUid]); + } catch (removeNodeError: unknown) { + log?.error(`Skipping node update event (failed to remove after failed update): ${removeNodeError}`); + // removeNodeError is automatic correction algorithm. + // If that fails, lets throw the original error as that + // is the real problem. + throw setNodeError; + } + } + } + } + if (event.type === DriveEventType.NodeDeleted) { + // removeNodes can fail removing children. + // We do not want to stop processing other events in such + // a case. Lets log the error and continue. + try { + await cache.removeNodes([event.nodeUid]); + } catch (error: unknown) { + log?.error(`Skipping node delete event: ${error}`); + } + } +} + +/** + * For given event, notify the listeners accordingly. + * + * The function is responsible for notifying the listeners about the + * event received from the DriveEventsService. The listeners are + * connected with events based on the condition, such as parent node + * uid for listening to children updates. + * + * The function is responsible for fetching and decrypting the latest + * version of the node metadata. If the node is not found, the event + * is silently skipped as the node will be fetched and decrypted when + * requested by the client. + * + * @throws Only if the client's callback throws. + */ +export async function notifyListenersByEvent(event: DriveEvent, listeners: Listeners, cache: NodesCache, nodesAccess: NodesAccess, log?: Logger) { + if (event.type === DriveEventType.NodeCreated || event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) { + const subscribedListeners = listeners.filter(({ condition }) => condition(event)); + if (subscribedListeners.length) { + let node; + try { + node = await nodesAccess.getNode(event.nodeUid); + } catch (error: unknown) { + log?.error(`Skipping node update event to listener: ${error}`); + return; + } + subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node })); + } + } + + if (event.type === DriveEventType.NodeDeleted) { + let node: DecryptedNode; + try { + node = await cache.getNode(event.nodeUid); + } catch {} + + const subscribedListeners = listeners.filter(({ condition }) => condition({ + isShared: node?.isShared || false, + isTrashed: !!node?.trashedDate || false, + ...event, + })); + if (subscribedListeners.length) { + subscribedListeners.forEach(({ callback }) => callback({ type: 'remove', uid: event.nodeUid })); + } + } +} diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 6faeb1af..b33d2876 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -31,15 +31,19 @@ export function initNodesModule( driveCrypto: DriveCrypto, driveEvents: DriveEventsService, sharesService: SharesService, - logger?: Logger, + log?: Logger, ) { - const api = new NodeAPIService(apiService, logger); - const cache = new NodesCache(driveEntitiesCache, logger); + const api = new NodeAPIService(apiService, log); + const cache = new NodesCache(driveEntitiesCache, log); const cryptoCache = new NodesCryptoCache(driveCryptoCache); const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); + const nodesEvents = new NodesEvents(driveEvents, cache, nodesAccess, log); + // TODO: Events are sent to the client once event is received from API + // If change is done locally, it will take a time to show up if client + // is waiting with UI update to events. Thus we need to emit events + // right away. const nodesManager = new NodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccess); - const nodesEvents = new NodesEvents(cache, driveEvents); return { access: nodesAccess, diff --git a/js/sdk/src/internal/nodes/manager.ts b/js/sdk/src/internal/nodes/manager.ts index 9bcfae78..e53b2317 100644 --- a/js/sdk/src/internal/nodes/manager.ts +++ b/js/sdk/src/internal/nodes/manager.ts @@ -1,11 +1,11 @@ import { MemberRole, NodeType, NodeResult, resultOk } from "../../interface"; +import { makeNodeUid } from "../uids"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { SharesService, DecryptedNode } from "./interface"; import { NodesAccess } from "./nodesAccess"; -import { makeNodeUid } from "./nodeUid"; const BATCH_LOADING = 10; diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts new file mode 100644 index 00000000..5eb4be98 --- /dev/null +++ b/js/sdk/src/internal/uids.ts @@ -0,0 +1,13 @@ +export function makeNodeUid(volumeId: string, nodeId: string) { + // TODO: format of UID + return `volume:${volumeId};node:${nodeId}`; +} + +export function splitNodeUid(nodeUid: string) { + // TODO: validation + const [ volumeId, nodeId ] = nodeUid.split(';'); + return { + volumeId: volumeId.slice('volume:'.length), + nodeId: nodeId.slice('node:'.length), + }; +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 7bcc3214..e9f71506 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -4,7 +4,7 @@ import { DriveCrypto } from './crypto'; import { initSharesModule } from './internal/shares'; import { initNodesModule } from './internal/nodes'; import { sharing as sharingModule } from './internal/sharing'; -import { events as eventsModule } from './internal/events'; +import { DriveEventsService } from './internal/events'; import { upload as uploadModule } from './internal/upload'; import { getConfig } from './config'; import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; @@ -35,7 +35,7 @@ export class ProtonDriveClient implements Partial { const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); - const events = eventsModule(apiService); + const events = new DriveEventsService(apiService, entitiesCache, getLogger?.('events')); const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); this.sharing = sharingModule(apiService, account, cryptoModule, this.nodes.access); From ddfaffe24b9b72526b021c21b2daa246e9598969 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Feb 2025 06:45:59 +0000 Subject: [PATCH 007/791] Fix CLI --- js/sdk/.eslintrc.js | 2 -- js/sdk/package.json | 3 +- js/sdk/src/crypto/openPGPSerialisation.ts | 9 ++--- js/sdk/src/internal/apiService/apiService.ts | 1 + js/sdk/src/internal/events/index.ts | 4 +-- js/sdk/src/internal/nodes/apiService.test.ts | 4 +-- js/sdk/src/internal/nodes/apiService.ts | 8 ++--- js/sdk/src/internal/shares/cryptoCache.ts | 20 +++++------ js/sdk/src/internal/shares/cryptoService.ts | 37 ++++++++++++-------- js/sdk/src/internal/shares/interface.ts | 10 +++--- js/sdk/src/internal/shares/manager.ts | 30 ++++++++-------- 11 files changed, 68 insertions(+), 60 deletions(-) diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index 94767af6..b206ce66 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -16,8 +16,6 @@ module.exports = { { files: [ "*.test.ts", - "**/nodes/events.ts", - "**/events/**/*", "**/sharing/**/*", "**/upload/**/*", ], diff --git a/js/sdk/package.json b/js/sdk/package.json index bdeccd3d..20351d63 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -10,7 +10,8 @@ "generate-types": "openapi-typescript ../../api/openapi-drive.json -o ./src/internal/apiService/driveTypes.ts && openapi-typescript ../../api/openapi-core.json -o ./src/internal/apiService/coreTypes.ts", "lint": "eslint src --ext .ts --cache --ignore-pattern '**/apiService/*Types.ts'", "pretty": "prettier --write $(find src -type f -name '*.ts')", - "test": "jest --runInBand --no-cache", + "test": "jest", + "test:ci": "jest --runInBand --no-cache", "test:watch": "jest --watch --coverage=false" }, "dependencies": { diff --git a/js/sdk/src/crypto/openPGPSerialisation.ts b/js/sdk/src/crypto/openPGPSerialisation.ts index 664bc2f6..17e7d06f 100644 --- a/js/sdk/src/crypto/openPGPSerialisation.ts +++ b/js/sdk/src/crypto/openPGPSerialisation.ts @@ -2,7 +2,9 @@ import { PrivateKey, SessionKey } from './interface'; import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; export function serializePrivateKey(key: PrivateKey): string { - return key.armor(); + // TODO: Implement this with real pmcrypto/CryptoProxy. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return key as any; } export function deserializePrivateKey(armoredKey: string): Promise { @@ -11,9 +13,8 @@ export function deserializePrivateKey(armoredKey: string): Promise { // Maybe this will not be even needed if we solve serialising differently (probably we should). //import { readPrivateKey } from 'pmcrypto'; //return readPrivateKey({ armoredKey }); - return Promise.resolve({ - armor: () => armoredKey, - }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return armoredKey as any; } export function serializeSessionKey(key: SessionKey): string { diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index c8650e5a..7f5359c7 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -38,6 +38,7 @@ export class DriveAPIService { headers: new Headers({ "Language": this.language, }), + body: JSON.stringify(data), }), signal); if (response.ok) { diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 46619a03..f60fa1d0 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -23,7 +23,7 @@ export class DriveEventsService { private subscribedToRemoteDataUpdates: boolean = false; private listeners: DriveListener[] = []; private coreEvents: CoreEventManager; - private volumesEvents: { [volumeId: string]: any }; + private volumesEvents: { [volumeId: string]: VolumeEventManager }; constructor(apiService: DriveAPIService, driveEntitiesCache: ProtonDriveCache, private log?: Logger) { this.apiService = new EventsAPIService(apiService); @@ -59,7 +59,7 @@ export class DriveEventsService { * polled with lower frequency depending on the total number of * subsciptions. * - * @param isOwnVolume Owned volumes are polled with higher frequency. + * @param isOwnVolume - Owned volumes are polled with higher frequency. */ async listenToVolume(volumeId: string, isOwnVolume = false): Promise { await this.loadSubscribedVolumeEventServices(); diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index de4802fe..15ec32a5 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -7,7 +7,7 @@ function generateAPIFileNode(linkOverrides = {}, overrides = {}) { return { Link: { ...node.Link, - Type: 1, + Type: 2, MIMEType: 'text', ...linkOverrides, }, @@ -28,7 +28,7 @@ function generateAPIFolderNode(linkOverrides = {}, overrides = {}) { return { Link: { ...node.Link, - Type: 2, + Type: 1, MIMEType: 'Folder', ...linkOverrides, }, diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index a651164a..4994fe13 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -50,7 +50,7 @@ export class NodeAPIService { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId("getNodes", nodeIds); - const response = await this.apiService.post(`drive/volumes/${volumeId}/links`, { + const response = await this.apiService.post(`drive/v2/volumes/${volumeId}/links`, { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); @@ -63,7 +63,7 @@ export class NodeAPIService { // Basic node metadata uid: makeNodeUid(volumeId, link.Link.LinkID), parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, - type: link.Link.Type === 1 ? NodeType.File : NodeType.Folder, + type: link.Link.Type === 1 ? NodeType.Folder : NodeType.File, mimeType: link.Link.MIMEType || undefined, createdDate: new Date(link.Link.CreateTime), trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime) : undefined, @@ -82,7 +82,7 @@ export class NodeAPIService { armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, } - if (link.Link.Type === 1 && link.File && link.ActiveRevision) { + if (link.Link.Type === 2 && link.File && link.ActiveRevision) { return { ...baseNodeMetadata, encryptedCrypto: { @@ -98,7 +98,7 @@ export class NodeAPIService { }, } } - if (link.Link.Type === 2 && link.Folder) { + if (link.Link.Type === 1 && link.Folder) { return { ...baseNodeMetadata, encryptedCrypto: { diff --git a/js/sdk/src/internal/shares/cryptoCache.ts b/js/sdk/src/internal/shares/cryptoCache.ts index c7d07e1f..ee2106d1 100644 --- a/js/sdk/src/internal/shares/cryptoCache.ts +++ b/js/sdk/src/internal/shares/cryptoCache.ts @@ -1,6 +1,6 @@ import { serializePrivateKey, deserializePrivateKey, serializeSessionKey, deserializeSessionKey } from "../../crypto"; import { ProtonDriveCache } from "../../cache"; -import { DecryptedShareCrypto } from "./interface"; +import { DecryptedShareKey } from "./interface"; /** * Provides caching for share crypto material. @@ -18,15 +18,15 @@ export class SharesCryptoCache { this.driveCache = driveCache; } - async setShareKey(shareId: string, keys: DecryptedShareCrypto): Promise { - await this.driveCache.setEntity(getCacheUid(shareId), serializeShareKey(keys)); + async setShareKey(shareId: string, key: DecryptedShareKey): Promise { + await this.driveCache.setEntity(getCacheUid(shareId), serializeShareKey(key)); } - async getShareKey(shareId: string): Promise { + async getShareKey(shareId: string): Promise { const shareKeyData = await this.driveCache.getEntity(getCacheUid(shareId)); try { - const keys = await deserializeShareKey(shareKeyData); - return keys; + const key = await deserializeShareKey(shareKeyData); + return key; } catch (error: unknown) { try { await this.removeShareKey([shareId]); @@ -47,15 +47,15 @@ function getCacheUid(shareId: string) { return `shareKey-${shareId}`; } -function serializeShareKey(keys: DecryptedShareCrypto) { +function serializeShareKey(key: DecryptedShareKey) { // TODO: verify how we want to serialize keys return JSON.stringify({ - key: serializePrivateKey(keys.key), - sessionKey: serializeSessionKey(keys.sessionKey), + key: serializePrivateKey(key.key), + sessionKey: serializeSessionKey(key.sessionKey), }); } -async function deserializeShareKey(shareKeyData: string): Promise { +async function deserializeShareKey(shareKeyData: string): Promise { const result = JSON.parse(shareKeyData); if (!result || typeof result !== 'object') { throw new Error('Invalid share keys data'); diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index b3a261d2..7e4d53bf 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -1,6 +1,6 @@ -import { ProtonDriveAccount } from "../../interface"; +import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError } from "../../interface"; import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto"; -import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareCrypto } from "./interface"; +import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareKey } from "./interface"; /** * Provides crypto operations for share keys. @@ -19,28 +19,28 @@ export class SharesCryptoService { } async generateVolumeBootstrap(addressKey: PrivateKey): Promise<{ - shareKey: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareCrypto }, + shareKey: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareKey }, rootNode: { - keys: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareCrypto }, + key: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareKey }, encryptedName: string, armoredHashKey: string, } }> { const shareKey = await this.driveCrypto.generateKey([addressKey], addressKey); - const rootNodeKeys = await this.driveCrypto.generateKey([shareKey.decrypted.key], addressKey); + const rootNodeKey = await this.driveCrypto.generateKey([shareKey.decrypted.key], addressKey); const { armoredNodeName } = await this.driveCrypto.encryptNodeName('root', shareKey.decrypted.key, addressKey); - const { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKeys.decrypted.key); + const { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKey.decrypted.key); return { shareKey, rootNode: { - keys: rootNodeKeys, + key: rootNodeKey, encryptedName: armoredNodeName, armoredHashKey, }, } } - async decryptRootShare(share: EncryptedRootShare): Promise { + async decryptRootShare(share: EncryptedRootShare): Promise<{ share: DecryptedRootShare, key: DecryptedShareKey }> { const addressPrivateKeys = await this.account.getOwnPrivateKeys(share.addressId); const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); @@ -52,17 +52,24 @@ export class SharesCryptoService { addressPublicKeys, ) - if (verified !== VERIFICATION_STATUS.SIGNED_AND_VALID) { - // TODO: error object and message - throw new Error('Failed to verify share passphrase'); - } + const author: Result = verified === VERIFICATION_STATUS.SIGNED_AND_VALID + ? resultOk(share.creatorEmail) + : resultError({ + claimedAuthor: share.creatorEmail, + error: verified === VERIFICATION_STATUS.SIGNED_AND_INVALID + ? `Verification signature failed` + : `Missing signature`, + }); return { - ...share, - decryptedCrypto: { + share: { + ...share, + author, + }, + key: { key, sessionKey, - } + }, } } } diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index 48ec8b66..6d3f8bdd 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -1,4 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; +import { Result, UnverifiedAuthorError } from "../../interface"; /** * Internal interface providing basic identification of volume and its root @@ -11,7 +12,7 @@ import { PrivateKey, SessionKey } from "../../crypto"; * know what is the root share or node, thus we want to keep this for both * volumes or any type of share. */ -interface VolumeShareNodeIDs { +export interface VolumeShareNodeIDs { volumeId: string; shareId: string; rootNodeId: string; @@ -32,7 +33,6 @@ export type Volume = { * Internal share interface. */ type BaseShare = { - creatorEmail: string; /** * Address ID is set only when user is member of the share. * Owner or invitee of share with higher access in the tree @@ -56,6 +56,7 @@ interface BaseRootShare extends BaseShare { * Outside of the module, the decrypted share interface should be used. */ export interface EncryptedShare extends BaseShare { + creatorEmail: string; encryptedCrypto: EncryptedShareCrypto; } @@ -65,6 +66,7 @@ export interface EncryptedShare extends BaseShare { * Outside of the module, the decrypted share interface should be used. */ export interface EncryptedRootShare extends BaseRootShare { + creatorEmail: string; encryptedCrypto: EncryptedShareCrypto; } @@ -72,7 +74,7 @@ export interface EncryptedRootShare extends BaseRootShare { * Interface holding decrypted share metadata. */ export interface DecryptedRootShare extends BaseRootShare { - decryptedCrypto: DecryptedShareCrypto; + author: Result, } export interface EncryptedShareCrypto { @@ -81,7 +83,7 @@ export interface EncryptedShareCrypto { armoredPassphraseSignature: string; } -export interface DecryptedShareCrypto { +export interface DecryptedShareKey { key: PrivateKey; sessionKey: SessionKey; } diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index b0a2e3b3..0edc763f 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -1,9 +1,11 @@ import { ProtonDriveAccount } from "../../interface"; +import { PrivateKey } from "../../crypto"; import { NotFoundAPIError } from "../apiService"; import { SharesAPIService } from "./apiService"; import { SharesCache } from "./cache"; import { SharesCryptoCache } from "./cryptoCache"; import { SharesCryptoService } from "./cryptoService"; +import { VolumeShareNodeIDs } from "./interface"; /** * Provides high-level actions for managing shares. @@ -19,11 +21,7 @@ export class SharesManager { // Those IDs are required very often, so it is better to keep them in memory. // The IDs are not cached in the cache module, as we want to always fetch // them from the API, and not from the this.cache. - private myFilesIds: { - volumeId: string; - shareId: string; - rootNodeId: string; - } | null = null; + private myFilesIds?: VolumeShareNodeIDs; constructor( private apiService: SharesAPIService, @@ -44,7 +42,7 @@ export class SharesManager { * * If the default volume or My files section doesn't exist, it creates it. */ - async getMyFilesIDs() { + async getMyFilesIDs(): Promise { if (this.myFilesIds) { return this.myFilesIds; } @@ -55,13 +53,13 @@ export class SharesManager { // Once any place needs IDs for My files, it will most likely // need also the keys for decrypting the tree. It is better to // decrypt the share here right away. - const myFilesShare = await this.cryptoService.decryptRootShare(encryptedShare); - await this.cryptoCache.setShareKey(myFilesShare.shareId, myFilesShare.decryptedCrypto); + const { share: myFilesShare, key } = await this.cryptoService.decryptRootShare(encryptedShare); + await this.cryptoCache.setShareKey(myFilesShare.shareId, key); await this.cache.setVolume({ volumeId: myFilesShare.volumeId, shareId: myFilesShare.shareId, rootNodeId: myFilesShare.rootNodeId, - creatorEmail: myFilesShare.creatorEmail, + creatorEmail: encryptedShare.creatorEmail, }); return { @@ -88,7 +86,7 @@ export class SharesManager { * * @throws If the volume cannot be created (e.g., one already exists). */ - async createVolume() { + async createVolume(): Promise { const { addressKey, addressId, addressKeyId } = await this.account.getOwnPrimaryKey(); const bootstrap = await this.cryptoService.generateVolumeBootstrap(addressKey); const myFilesIds = await this.apiService.createVolume( @@ -98,7 +96,7 @@ export class SharesManager { ...bootstrap.shareKey.encrypted, }, { - ...bootstrap.rootNode.keys.encrypted, + ...bootstrap.rootNode.key.encrypted, encryptedName: bootstrap.rootNode.encryptedName, armoredHashKey: bootstrap.rootNode.armoredHashKey, }, @@ -116,19 +114,19 @@ export class SharesManager { * @returns The private key for the share. * @throws If the share is not found or cannot be decrypted, or cached. */ - async getSharePrivateKey(shareId: string) { + async getSharePrivateKey(shareId: string): Promise { const keys = await this.cryptoCache.getShareKey(shareId); if (keys) { return keys.key; } const encryptedShare = await this.apiService.getRootShare(shareId); - const share = await this.cryptoService.decryptRootShare(encryptedShare); - await this.cryptoCache.setShareKey(share.shareId, share.decryptedCrypto); - return share.decryptedCrypto.key; + const { key } = await this.cryptoService.decryptRootShare(encryptedShare); + await this.cryptoCache.setShareKey(shareId, key); + return key.key; } - async getVolumeEmailKey(volumeId: string) { + async getVolumeEmailKey(volumeId: string): Promise<{ email: string, key: PrivateKey }> { const volume = await this.cache.getVolume(volumeId); if (volume) { return { From d47c3018a285b39a7b472a9d560b801393d9c349 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Feb 2025 06:51:59 +0000 Subject: [PATCH 008/791] Avoid caching crypto keys as string --- js/sdk/src/cache/interface.ts | 16 ++--- js/sdk/src/cache/memoryCache.test.ts | 2 +- js/sdk/src/cache/memoryCache.ts | 12 ++-- js/sdk/src/crypto/index.ts | 1 - js/sdk/src/crypto/interface.ts | 17 +---- js/sdk/src/crypto/openPGPSerialisation.ts | 43 ------------ js/sdk/src/interface/index.ts | 15 ++++- js/sdk/src/internal/events/cache.test.ts | 2 +- js/sdk/src/internal/events/cache.ts | 4 +- js/sdk/src/internal/events/index.ts | 5 +- js/sdk/src/internal/nodes/cache.test.ts | 2 +- js/sdk/src/internal/nodes/cache.ts | 8 +-- js/sdk/src/internal/nodes/cryptoCache.test.ts | 43 +++--------- js/sdk/src/internal/nodes/cryptoCache.ts | 66 +++---------------- js/sdk/src/internal/nodes/index.ts | 11 ++-- js/sdk/src/internal/shares/cache.test.ts | 2 +- js/sdk/src/internal/shares/cache.ts | 4 +- .../src/internal/shares/cryptoCache.test.ts | 50 +------------- js/sdk/src/internal/shares/cryptoCache.ts | 54 ++------------- js/sdk/src/internal/shares/index.ts | 7 +- 20 files changed, 75 insertions(+), 289 deletions(-) delete mode 100644 js/sdk/src/crypto/openPGPSerialisation.ts diff --git a/js/sdk/src/cache/interface.ts b/js/sdk/src/cache/interface.ts index 9e4b88fe..dba99661 100644 --- a/js/sdk/src/cache/interface.ts +++ b/js/sdk/src/cache/interface.ts @@ -1,4 +1,4 @@ -export interface ProtonDriveCacheConstructor { +export interface ProtonDriveCacheConstructor { /** * Initialize the cache. * @@ -13,10 +13,10 @@ export interface ProtonDriveCacheConstructor { * * @param usedTagKeysBySDK - Example of tags: ["trashed", "shared", "parentUid"] */ - new (usedTagKeysBySDK: string[]): ProtonDriveCache, + new (usedTagKeysBySDK: string[]): ProtonDriveCache, } -export interface ProtonDriveCache { +export interface ProtonDriveCache { /** * Re-creates the whole persistent cache. @@ -67,14 +67,14 @@ export interface ProtonDriveCache { * @param tags - Clear metadata about the entity used for filtering. It is intended to store efficiently for fast look-up. * @throws Exception if `key` from `tags` is not one of the tag keys provided from `usedTagKeysBySDK` in constructor. */ - setEntity(uid: string, data: string, tags?: { [ key: string ]: string }): Promise, + setEntity(uid: string, data: T, tags?: { [ key: string ]: string }): Promise, /** * Returns the data of the entity stored locally. * * @throws Exception if entity is not present. */ - getEntity(uid: string): Promise, + getEntity(uid: string): Promise, /** * Generator providing the data of the entities stored locally for given @@ -82,7 +82,7 @@ export interface ProtonDriveCache { * * No exception is thrown when data is missing. */ - iterateEntities(uids: string[]): AsyncGenerator, + iterateEntities(uids: string[]): AsyncGenerator>, /** * Generator providing the data of the entities stored locally for given @@ -100,7 +100,7 @@ export interface ProtonDriveCache { * @param value - The tag value, for example `"abc123"` * @throws Exception if `key` is not one of the tag keys provided from `usedTagKeysBySDK` in constructor */ - iterateEntitiesByTag(key: string, value: string): AsyncGenerator, + iterateEntitiesByTag(key: string, value: string): AsyncGenerator>, /** * Removes completely the entity stored locally from the database. @@ -110,4 +110,4 @@ export interface ProtonDriveCache { removeEntities(uids: string[]): Promise, } -export type EntityResult = {uid: string, ok: true, data: string} | {uid: string, ok: false, error: string}; +export type EntityResult = {uid: string, ok: true, data: T} | {uid: string, ok: false, error: string}; diff --git a/js/sdk/src/cache/memoryCache.test.ts b/js/sdk/src/cache/memoryCache.test.ts index 80c8aabb..c41181db 100644 --- a/js/sdk/src/cache/memoryCache.test.ts +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -1,7 +1,7 @@ import { MemoryCache } from "./memoryCache"; describe('MemoryCache', () => { - let cache: MemoryCache; + let cache: MemoryCache; beforeEach(() => { cache = new MemoryCache(['tag1', 'tag2']); diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index 7dab2cbb..d81382d6 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -1,6 +1,6 @@ import type { ProtonDriveCache, EntityResult } from './interface.js'; -type KeyValueCache = { [ uid: string ]: string }; +type KeyValueCache = { [ uid: string ]: T }; type TagsCache = { [ key: string ]: { [ value: string ]: string[] } }; /** @@ -10,8 +10,8 @@ type TagsCache = { [ key: string ]: { [ value: string ]: string[] } }; * development only. It is not recommended to use this cache in production * environments. */ -export class MemoryCache implements ProtonDriveCache { - private entities: KeyValueCache; +export class MemoryCache implements ProtonDriveCache { + private entities: KeyValueCache; private entitiesByTag: TagsCache; constructor(usedTagKeysBySDK: string[]) { @@ -26,7 +26,7 @@ export class MemoryCache implements ProtonDriveCache { this.entities = {}; } - async setEntity(uid: string, data: string, tags?: { [ key: string ]: string }) { + async setEntity(uid: string, data: T, tags?: { [ key: string ]: string }) { this.entities[uid] = data; if (tags) { for (const key in tags) { @@ -51,7 +51,7 @@ export class MemoryCache implements ProtonDriveCache { return data; } - async *iterateEntities(uids: string[]): AsyncGenerator { + async *iterateEntities(uids: string[]): AsyncGenerator> { for (const uid of uids) { try { const data = await this.getEntity(uid); @@ -62,7 +62,7 @@ export class MemoryCache implements ProtonDriveCache { } } - async *iterateEntitiesByTag(key: string, value: string): AsyncGenerator { + async *iterateEntitiesByTag(key: string, value: string): AsyncGenerator> { const tag = this.entitiesByTag[key]; if (!tag) { throw Error('Tag is not recognised'); diff --git a/js/sdk/src/crypto/index.ts b/js/sdk/src/crypto/index.ts index 9e028269..9943ad5d 100644 --- a/js/sdk/src/crypto/index.ts +++ b/js/sdk/src/crypto/index.ts @@ -2,4 +2,3 @@ export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interfa export { VERIFICATION_STATUS } from './interface'; export { DriveCrypto } from './driveCrypto'; export { OpenPGPCryptoWithCryptoProxy } from './openPGPCrypto'; -export { serializePrivateKey, deserializePrivateKey, serializeSessionKey, deserializeSessionKey, serializeHashKey, deserializeHashKey } from './openPGPSerialisation'; diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index e374b6a6..c6bce22a 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -1,18 +1,7 @@ // TODO: Re-export them from openpgp/CryptoProxy directly. -// Depeding on openpgp requires additional setup for tests, so we can't do it yet. -export type PrivateKey = { - armor(): string; -}; - -export type PublicKey = { - armor(): string; -}; - -export type SessionKey = { - data: Uint8Array, - algorithm: string, - aeadAlgorithm?: string, -}; +export type PrivateKey = object; +export type PublicKey = object; +export type SessionKey = object; export enum VERIFICATION_STATUS { NOT_SIGNED = 0, diff --git a/js/sdk/src/crypto/openPGPSerialisation.ts b/js/sdk/src/crypto/openPGPSerialisation.ts deleted file mode 100644 index 17e7d06f..00000000 --- a/js/sdk/src/crypto/openPGPSerialisation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { PrivateKey, SessionKey } from './interface'; -import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; - -export function serializePrivateKey(key: PrivateKey): string { - // TODO: Implement this with real pmcrypto/CryptoProxy. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return key as any; -} - -export function deserializePrivateKey(armoredKey: string): Promise { - // TODO: Implement this with real pmcrypto/CryptoProxy. - // Depeding on openpgp requires additional setup for tests, so we can't do it yet. - // Maybe this will not be even needed if we solve serialising differently (probably we should). - //import { readPrivateKey } from 'pmcrypto'; - //return readPrivateKey({ armoredKey }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return armoredKey as any; -} - -export function serializeSessionKey(key: SessionKey): string { - return JSON.stringify({ - ...key, - data: uint8ArrayToBase64String(key.data), - }); -} - -export function deserializeSessionKey(jsonKey: string): SessionKey { - const result = JSON.parse(jsonKey); - const data = base64StringToUint8Array(result.data); - return { - data, - algorithm: result.algorithm, - aeadAlgorithm: result.aeadAlgorithm, - } -} - -export function serializeHashKey(key: Uint8Array): string { - return uint8ArrayToBase64String(key); -} - -export function deserializeHashKey(jsonKey: string): Uint8Array { - return base64StringToUint8Array(jsonKey); -} diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index e883cb83..05332ab0 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -1,5 +1,5 @@ import { ProtonDriveCache } from '../cache'; -import { OpenPGPCrypto } from '../crypto'; +import { OpenPGPCrypto, PrivateKey, SessionKey } from '../crypto'; import { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig, GetLogger, Metrics } from './constructor'; import { Devices } from './devices'; import { Download } from './download'; @@ -20,9 +20,18 @@ export type { ProtonInvitation, NonProtonInvitation, NonProtonInvitationState, M export { ShareRole } from './sharing'; export type { Fileuploader, UploadController, Thumbnail, ThumbnailType, UploadMetadata } from './upload'; +export type ProtonDriveEntitiesCache = ProtonDriveCache; +export type ProtonDriveCryptoCache = ProtonDriveCache; +export type CachedCryptoMaterial = { + passphrase?: string, + key: PrivateKey, + sessionKey: SessionKey, + hashKey?: Uint8Array, +}; + export interface ProtonDriveClientContructorParameters { - entitiesCache: ProtonDriveCache, - cryptoCache: ProtonDriveCache, + entitiesCache: ProtonDriveEntitiesCache, + cryptoCache: ProtonDriveCryptoCache, account: ProtonDriveAccount, httpClient: ProtonDriveHTTPClient, getLogger?: GetLogger, diff --git a/js/sdk/src/internal/events/cache.test.ts b/js/sdk/src/internal/events/cache.test.ts index e96c119b..ee9947c2 100644 --- a/js/sdk/src/internal/events/cache.test.ts +++ b/js/sdk/src/internal/events/cache.test.ts @@ -2,7 +2,7 @@ import { MemoryCache } from "../../cache"; import { EventsCache } from "./cache"; describe("EventsCache", () => { - let memoryCache: MemoryCache; + let memoryCache: MemoryCache; let cache: EventsCache; beforeEach(() => { diff --git a/js/sdk/src/internal/events/cache.ts b/js/sdk/src/internal/events/cache.ts index 9551d35d..40302908 100644 --- a/js/sdk/src/internal/events/cache.ts +++ b/js/sdk/src/internal/events/cache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveCache } from "../../cache"; +import { ProtonDriveEntitiesCache } from "../../interface"; type CachedEventsData = { // Key is either a volume ID for volume events or 'core' for core events. @@ -19,7 +19,7 @@ export class EventsCache { */ private events?: CachedEventsData; - constructor(private driveCache: ProtonDriveCache) { + constructor(private driveCache: ProtonDriveEntitiesCache) { this.driveCache = driveCache; } diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index f60fa1d0..8f139233 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -1,5 +1,4 @@ -import { ProtonDriveCache } from "../../cache"; -import { Logger } from "../../interface"; +import { ProtonDriveEntitiesCache, Logger } from "../../interface"; import { DriveAPIService } from "../apiService"; import { DriveListener } from "./interface"; import { EventsAPIService } from "./apiService"; @@ -25,7 +24,7 @@ export class DriveEventsService { private coreEvents: CoreEventManager; private volumesEvents: { [volumeId: string]: VolumeEventManager }; - constructor(apiService: DriveAPIService, driveEntitiesCache: ProtonDriveCache, private log?: Logger) { + constructor(apiService: DriveAPIService, driveEntitiesCache: ProtonDriveEntitiesCache, private log?: Logger) { this.apiService = new EventsAPIService(apiService); this.cache = new EventsCache(driveEntitiesCache); this.log = log; diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 9cab73dc..dbbd64ab 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -57,7 +57,7 @@ async function verifyNodesCache(cache: NodesCache, expectedNodes: string[], expe } describe('nodesCache', () => { - let memoryCache: MemoryCache; + let memoryCache: MemoryCache; let cache: NodesCache; beforeEach(() => { diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 69cdbf95..9eb45a5d 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -1,5 +1,5 @@ -import { ProtonDriveCache, EntityResult } from "../../cache"; -import { Logger } from "../../interface"; +import { EntityResult } from "../../cache"; +import { ProtonDriveEntitiesCache, Logger } from "../../interface"; import { DecryptedNode } from "./interface"; export enum CACHE_TAG_KEYS { @@ -21,7 +21,7 @@ type DecryptedNodeResult = ( * The cache of node metadata should not contain any crypto material. */ export class NodesCache { - constructor(private driveCache: ProtonDriveCache, private logger?: Logger) { + constructor(private driveCache: ProtonDriveEntitiesCache, private logger?: Logger) { this.driveCache = driveCache; this.logger = logger; } @@ -134,7 +134,7 @@ export class NodesCache { * Converts result from the cache with cache UID and data to result of node * with node UID and DecryptedNode. */ - private async convertCacheResult(result: EntityResult): Promise { + private async convertCacheResult(result: EntityResult): Promise { let nodeUid; try { nodeUid = getNodeUid(result.uid); diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index e8dfbe70..7672fe8f 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -1,21 +1,10 @@ import { PrivateKey, SessionKey } from "../../crypto"; import { MemoryCache } from "../../cache"; +import { CachedCryptoMaterial } from "../../interface"; import { NodesCryptoCache } from "./cryptoCache"; -jest.mock('../../crypto/openPGPSerialisation', () => ({ - serializePrivateKey: jest.fn((value) => value), - deserializePrivateKey: jest.fn((value) => value), - serializeSessionKey: jest.fn((value) => value), - deserializeSessionKey: jest.fn((value) => { - if (value === 'badSessionKey') { - throw new Error('Bad session key'); - } - return value; - }), -})); - describe('nodesCryptoCache', () => { - let memoryCache: MemoryCache; + let memoryCache: MemoryCache; let cache: NodesCryptoCache; const generatePrivateKey = (name: string) => { @@ -28,8 +17,10 @@ describe('nodesCryptoCache', () => { beforeEach(() => { memoryCache = new MemoryCache([]); - memoryCache.setEntity('nodeKeys-badKeysObject', 'aaa'); - memoryCache.setEntity('nodeKeys-badSessionKey', '{ "passphrase": "pass", "key": "aaa", "sessionKey": "badSessionKey" }'); + memoryCache.setEntity('nodeKeys-missingPassphrase', { + key: 'privateKey', + sessionKey: 'sessionKey', + } as any); cache = new NodesCryptoCache(memoryCache); }); @@ -84,30 +75,14 @@ describe('nodesCryptoCache', () => { it('should throw an error when retrieving a bad keys and remove the key', async () => { try { - await cache.getNodeKeys('badKeysObject'); - throw new Error('Should have thrown an error'); - } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialize node keys: Unexpected token \'a\', \"aaa\" is not valid JSON'); - } - - try { - await memoryCache.getEntity('nodeKeys-badKeysObject'); - throw new Error('Should have thrown an error'); - } catch (error) { - expect(`${error}`).toBe('Error: Entity not found'); - } - }); - - it('should throw an error when retrieving a bad session key and remove the key', async () => { - try { - await cache.getNodeKeys('badSessionKey'); + await cache.getNodeKeys('missingPassphrase'); throw new Error('Should have thrown an error'); } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialize node keys: Invalid node session key: Bad session key'); + expect(`${error}`).toBe('Error: Failed to deserialize node keys: missing passphrase'); } try { - await memoryCache.getEntity('nodeKeys-badSessingKey'); + await memoryCache.getEntity('nodeKeys-missingPassphrase'); throw new Error('Should have thrown an error'); } catch (error) { expect(`${error}`).toBe('Error: Entity not found'); diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts index aedb0102..7d03344a 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -1,5 +1,4 @@ -import { ProtonDriveCache } from "../../cache"; -import { serializePrivateKey, deserializePrivateKey, serializeSessionKey, deserializeSessionKey, serializeHashKey, deserializeHashKey } from "../../crypto"; +import { ProtonDriveCryptoCache } from "../../interface"; import { DecryptedNodeKeys } from "./interface"; /** @@ -9,30 +8,29 @@ import { DecryptedNodeKeys } from "./interface"; * crypto material. */ export class NodesCryptoCache { - constructor(private driveCache: ProtonDriveCache) { + constructor(private driveCache: ProtonDriveCryptoCache) { this.driveCache = driveCache; } async setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys): Promise { const cacheUid = getCacheUid(nodeUid); - const nodeKeysData = serializeNodeKeys(keys); - this.driveCache.setEntity(cacheUid, nodeKeysData); + this.driveCache.setEntity(cacheUid, keys); } async getNodeKeys(nodeUid: string): Promise { const nodeKeysData = await this.driveCache.getEntity(getCacheUid(nodeUid)); - try { - const keys = await deserializeNodeKeys(nodeKeysData); - return keys; - } catch (error: unknown) { + if (!nodeKeysData.passphrase) { try { await this.removeNodeKeys([nodeUid]); } catch { // TODO: log error } - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Failed to deserialize node keys: ${errorMessage}`); + throw new Error(`Failed to deserialize node keys: missing passphrase`); } + return { + ...nodeKeysData, + passphrase: nodeKeysData.passphrase, + }; } async removeNodeKeys(nodeUids: string[]): Promise { @@ -44,49 +42,3 @@ export class NodesCryptoCache { function getCacheUid(nodeUid: string) { return `nodeKeys-${nodeUid}`; } - -function serializeNodeKeys(keys: DecryptedNodeKeys) { - // TODO: verify how we want to serialize keys - return JSON.stringify({ - passphrase: keys.passphrase, - key: serializePrivateKey(keys.key), - sessionKey: serializeSessionKey(keys.sessionKey), - hashKey: keys.hashKey ? serializeHashKey(keys.hashKey) : undefined, - }); -} - -async function deserializeNodeKeys(shareKeyData: string): Promise { - const result = JSON.parse(shareKeyData); - if (!result || typeof result !== 'object') { - throw new Error('Invalid node keys data'); - } - - let key, sessionKey, hashKey; - - if (!result.passphrase || typeof result.passphrase !== 'string') { - throw new Error('Invalid node passphrase'); - } - const passphrase = result.passphrase; - try { - key = await deserializePrivateKey(result.key); - } catch (error: unknown) { - throw new Error(`Invalid node private key: ${error instanceof Error ? error.message : error}`); - } - try { - sessionKey = deserializeSessionKey(result.sessionKey); - } catch (error: unknown) { - throw new Error(`Invalid node session key: ${error instanceof Error ? error.message : error}`); - } - try { - hashKey = result.hashKey ? deserializeHashKey(result.hashKey) : undefined; - } catch (error: unknown) { - throw new Error(`Invalid node hash key: ${error instanceof Error ? error.message : error}`); - } - - return { - passphrase, - key, - sessionKey, - hashKey, - }; -} diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index b33d2876..d6a6a3ae 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -1,8 +1,7 @@ import { DriveAPIService } from "../apiService"; -import { ProtonDriveCache } from "../../cache"; import { DriveCrypto } from "../../crypto"; import { DriveEventsService } from "../events"; -import { Logger, ProtonDriveAccount } from "../../interface"; +import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, Logger, ProtonDriveAccount } from "../../interface"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache"; import { NodesEvents } from "./events"; @@ -25,8 +24,8 @@ export type { DecryptedNode } from "./interface"; */ export function initNodesModule( apiService: DriveAPIService, - driveEntitiesCache: ProtonDriveCache, - driveCryptoCache: ProtonDriveCache, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, account: ProtonDriveAccount, driveCrypto: DriveCrypto, driveEvents: DriveEventsService, @@ -54,8 +53,8 @@ export function initNodesModule( export function initPublicNodesModule( apiService: DriveAPIService, - driveEntitiesCache: ProtonDriveCache, - driveCryptoCache: ProtonDriveCache, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, driveCrypto: DriveCrypto, sharesService: SharesService, ) { diff --git a/js/sdk/src/internal/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts index 0e7daeaf..3e98a0f8 100644 --- a/js/sdk/src/internal/shares/cache.test.ts +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -2,7 +2,7 @@ import { MemoryCache } from "../../cache"; import { SharesCache } from "./cache"; describe('sharesCache', () => { - let memoryCache: MemoryCache; + let memoryCache: MemoryCache; let cache: SharesCache; beforeEach(() => { diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index c2d636d8..b92218c3 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveCache } from "../../cache"; +import { ProtonDriveEntitiesCache } from "../../interface"; import { Volume } from "./interface"; /** @@ -7,7 +7,7 @@ import { Volume } from "./interface"; * The cache is responsible for serialising and deserialising volume metadata. */ export class SharesCache { - constructor(private driveCache: ProtonDriveCache) { + constructor(private driveCache: ProtonDriveEntitiesCache) { this.driveCache = driveCache; } diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts index 5f12234f..503103e2 100644 --- a/js/sdk/src/internal/shares/cryptoCache.test.ts +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -1,21 +1,10 @@ import { PrivateKey, SessionKey } from "../../crypto"; import { MemoryCache } from "../../cache"; +import { CachedCryptoMaterial } from "../../interface"; import { SharesCryptoCache } from "./cryptoCache"; -jest.mock('../../crypto/openPGPSerialisation', () => ({ - serializePrivateKey: jest.fn((value) => value), - deserializePrivateKey: jest.fn((value) => value), - serializeSessionKey: jest.fn((value) => value), - deserializeSessionKey: jest.fn((value) => { - if (value === 'badSessionKey') { - throw new Error('Bad session key'); - } - return value; - }), -})); - describe('sharesCryptoCache', () => { - let memoryCache: MemoryCache; + let memoryCache: MemoryCache; let cache: SharesCryptoCache; const generatePrivateKey = (name: string) => { @@ -28,9 +17,6 @@ describe('sharesCryptoCache', () => { beforeEach(() => { memoryCache = new MemoryCache([]); - memoryCache.setEntity('shareKey-badKeysObject', 'aaa'); - memoryCache.setEntity('shareKey-badSessionKey', '{ "key": "aaa", "sessionKey": "badSessionKey" }'); - cache = new SharesCryptoCache(memoryCache); }); @@ -81,36 +67,4 @@ describe('sharesCryptoCache', () => { expect(`${error}`).toBe('Error: Entity not found'); } }); - - it('should throw an error when retrieving a bad keys and remove the key', async () => { - try { - await cache.getShareKey('badKeysObject'); - throw new Error('Should have thrown an error'); - } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialize share keys: Unexpected token \'a\', \"aaa\" is not valid JSON'); - } - - try { - await memoryCache.getEntity('shareKey-badKeysObject'); - throw new Error('Should have thrown an error'); - } catch (error) { - expect(`${error}`).toBe('Error: Entity not found'); - } - }); - - it('should throw an error when retrieving a bad session key and remove the key', async () => { - try { - await cache.getShareKey('badSessionKey'); - throw new Error('Should have thrown an error'); - } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialize share keys: Invalid share session key: Bad session key'); - } - - try { - await memoryCache.getEntity('shareKey-badSessingKey'); - throw new Error('Should have thrown an error'); - } catch (error) { - expect(`${error}`).toBe('Error: Entity not found'); - } - }); }); \ No newline at end of file diff --git a/js/sdk/src/internal/shares/cryptoCache.ts b/js/sdk/src/internal/shares/cryptoCache.ts index ee2106d1..b381b136 100644 --- a/js/sdk/src/internal/shares/cryptoCache.ts +++ b/js/sdk/src/internal/shares/cryptoCache.ts @@ -1,5 +1,4 @@ -import { serializePrivateKey, deserializePrivateKey, serializeSessionKey, deserializeSessionKey } from "../../crypto"; -import { ProtonDriveCache } from "../../cache"; +import { ProtonDriveCryptoCache } from "../../interface"; import { DecryptedShareKey } from "./interface"; /** @@ -14,28 +13,16 @@ import { DecryptedShareKey } from "./interface"; * only the root node, thus share cache is not needed. */ export class SharesCryptoCache { - constructor(private driveCache: ProtonDriveCache) { + constructor(private driveCache: ProtonDriveCryptoCache) { this.driveCache = driveCache; } async setShareKey(shareId: string, key: DecryptedShareKey): Promise { - await this.driveCache.setEntity(getCacheUid(shareId), serializeShareKey(key)); + await this.driveCache.setEntity(getCacheUid(shareId), key); } async getShareKey(shareId: string): Promise { - const shareKeyData = await this.driveCache.getEntity(getCacheUid(shareId)); - try { - const key = await deserializeShareKey(shareKeyData); - return key; - } catch (error: unknown) { - try { - await this.removeShareKey([shareId]); - } catch { - // TODO: log error - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Failed to deserialize share keys: ${errorMessage}`); - } + return this.driveCache.getEntity(getCacheUid(shareId)); } async removeShareKey(shareIds: string[]): Promise { @@ -46,36 +33,3 @@ export class SharesCryptoCache { function getCacheUid(shareId: string) { return `shareKey-${shareId}`; } - -function serializeShareKey(key: DecryptedShareKey) { - // TODO: verify how we want to serialize keys - return JSON.stringify({ - key: serializePrivateKey(key.key), - sessionKey: serializeSessionKey(key.sessionKey), - }); -} - -async function deserializeShareKey(shareKeyData: string): Promise { - const result = JSON.parse(shareKeyData); - if (!result || typeof result !== 'object') { - throw new Error('Invalid share keys data'); - } - - let key, sessionKey; - - try { - key = await deserializePrivateKey(result.key); - } catch (error: unknown) { - throw new Error(`Invalid share private key: ${error instanceof Error ? error.message : error}`); - } - try { - sessionKey = deserializeSessionKey(result.sessionKey); - } catch (error: unknown) { - throw new Error(`Invalid share session key: ${error instanceof Error ? error.message : error}`); - } - - return { - key, - sessionKey, - }; -} diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts index 12a3d48f..e1ba9cd4 100644 --- a/js/sdk/src/internal/shares/index.ts +++ b/js/sdk/src/internal/shares/index.ts @@ -1,7 +1,6 @@ -import { ProtonDriveAccount } from "../../interface"; +import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount } from "../../interface"; import { DriveCrypto } from '../../crypto'; import { DriveAPIService } from "../apiService"; -import { ProtonDriveCache } from "../../cache"; import { SharesAPIService } from "./apiService"; import { SharesCryptoCache } from "./cryptoCache"; import { SharesCache } from "./cache"; @@ -19,8 +18,8 @@ import { SharesManager } from "./manager"; */ export function initSharesModule( apiService: DriveAPIService, - driveEntitiesCache: ProtonDriveCache, - driveCryptoCache: ProtonDriveCache, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, account: ProtonDriveAccount, crypto: DriveCrypto, ) { From 99dc145a70e0c2606def134d9431091f665f1ba0 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Feb 2025 06:55:00 +0000 Subject: [PATCH 009/791] Fix crypto interface --- js/sdk/package-lock.json | 62 +----------------- js/sdk/package.json | 1 - js/sdk/src/crypto/interface.ts | 11 ++-- js/sdk/src/crypto/openPGPCrypto.ts | 66 ++++++++++++-------- js/sdk/src/index.ts | 5 ++ js/sdk/src/internal/nodes/apiService.test.ts | 15 ++++- js/sdk/src/internal/nodes/apiService.ts | 7 ++- js/sdk/src/internal/nodes/index.ts | 1 + js/sdk/src/internal/nodes/manager.ts | 6 +- js/sdk/src/internal/shares/manager.ts | 10 +-- js/sdk/src/protonDriveClient.ts | 4 +- js/sdk/src/protonDrivePublicClient.ts | 5 +- 12 files changed, 84 insertions(+), 109 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index e70cd1e7..e068288a 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.1", "license": "GPL-3.0", "dependencies": { - "pmcrypto": "npm:@protontech/pmcrypto@~8.1.3", "ttag": "^1.8.7" }, "devDependencies": { @@ -2361,18 +2360,6 @@ "resolve": "~1.22.2" } }, - "node_modules/@noble/hashes": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", - "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2408,23 +2395,6 @@ "node": ">= 8" } }, - "node_modules/@openpgp/web-stream-tools": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@openpgp/web-stream-tools/-/web-stream-tools-0.1.3.tgz", - "integrity": "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ==", - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "typescript": ">=4.2" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -5902,13 +5872,6 @@ "node": ">=6" } }, - "node_modules/jsmimeparser": { - "name": "@protontech/jsmimeparser", - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@protontech/jsmimeparser/-/jsmimeparser-3.0.1.tgz", - "integrity": "sha512-bi0RBkritKep1cKnQ6U1538++aQ+7XZxG5Uzm4ZivvP7FTE3iaOA5lm0CCFbSoQ5e8KtvjI5KR+Vj6apXhyhXQ==", - "license": "MIT" - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6886,29 +6849,6 @@ "node": ">=4" } }, - "node_modules/pmcrypto": { - "name": "@protontech/pmcrypto", - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/@protontech/pmcrypto/-/pmcrypto-8.1.3.tgz", - "integrity": "sha512-KtrYr2z4BlKY27+k43hpCizLmloSbYrA7BeYUl0ET2zRvXAGUo89czZ/QN8PbcVGnbOeFWU56tnMhCUudxCx+Q==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.6.0", - "@openpgp/web-stream-tools": "~0.1.3", - "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.1", - "openpgp": "npm:@protontech/openpgp@~6.0.2-patch.1" - } - }, - "node_modules/pmcrypto/node_modules/openpgp": { - "name": "@protontech/openpgp", - "version": "6.0.2-patch.1", - "resolved": "https://registry.npmjs.org/@protontech/openpgp/-/openpgp-6.0.2-patch.1.tgz", - "integrity": "sha512-3lVr60/gmVEkfrJsPbbIq7BAcXabJuPM8V7wtH04brLYVAxMKOpDi96C0XQyOURAAYiHYXKkGvuR4cwoNy9WBw==", - "license": "LGPL-3.0+", - "engines": { - "node": ">= 18.0.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7815,7 +7755,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "devOptional": true, + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/js/sdk/package.json b/js/sdk/package.json index 20351d63..872ba45a 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -15,7 +15,6 @@ "test:watch": "jest --watch --coverage=false" }, "dependencies": { - "pmcrypto": "npm:@protontech/pmcrypto@~8.1.3", "ttag": "^1.8.7" }, "devDependencies": { diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index c6bce22a..356151b9 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -1,7 +1,10 @@ -// TODO: Re-export them from openpgp/CryptoProxy directly. -export type PrivateKey = object; -export type PublicKey = object; -export type SessionKey = object; +// TODO: Use CryptoProxy once available. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type PublicKey = any; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PrivateKey extends PublicKey {}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SessionKey = any; export enum VERIFICATION_STATUS { NOT_SIGNED = 0, diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 6238eca3..1594a3f8 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -6,22 +6,35 @@ import { uint8ArrayToBase64String } from './utils'; * clients/packages/crypto/lib/proxy/proxy.ts. */ interface OpenPGPCryptoProxy { - generateKey: (options: { userIDs: { name: string }[], type: string, curve: string }) => Promise<{ privateKey: PrivateKey, publicKey: PublicKey }>, - exportPrivateKey: (options: { key: { privateKey: PrivateKey }, passphrase: string }) => Promise, + generateKey: (options: { userIDs: { name: string }[], type: 'ecc', curve: 'ed25519' }) => Promise, + exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string }) => Promise, importPrivateKey: (options: { armoredKey: string, passphrase: string }) => Promise, generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, decryptSessionKey: (options: { armoredMessage: string, decryptionKeys: PrivateKey[] }) => Promise, - encryptMessage: OpenPGPCryptoProxyEncryptMessage, - decryptMessage: OpenPGPCryptoProxyDecryptMessage, -} - -interface OpenPGPCryptoProxyEncryptMessage { - (options: { textData?: string, binaryData?: Uint8Array, sessionKey?: SessionKey, signingKeys?: PrivateKey, encryptionKeys?: PublicKey[], detached?: boolean }): Promise<{ message: string, signature: string }>; - (options: { format: 'binary', binaryData: Uint8Array, sessionKey: SessionKey, signingKeys: PrivateKey, encryptionKeys?: PublicKey[], detached: boolean }): Promise<{ message: Uint8Array, signature: Uint8Array }>; -} -interface OpenPGPCryptoProxyDecryptMessage { - (options: { armoredMessage: string, signature: string, sessionKeys: SessionKey, verificationKeys: PublicKey[] }): Promise<{ data: string, verified: VERIFICATION_STATUS }>; - (options: { format: 'binary', armoredMessage?: string, binaryMessage?: Uint8Array, signature?: string, binarySignature?: Uint8Array, sessionKeys?: SessionKey, decryptionKeys?: PrivateKey[], verificationKeys: PublicKey[] }): Promise<{ data: Uint8Array, verified: VERIFICATION_STATUS }>; + encryptMessage: (options: { + format?: 'armored' | 'binary', + binaryData: Uint8Array, + sessionKey?: SessionKey, + encryptionKeys: PrivateKey[], + signingKeys?: PrivateKey, + detached?: boolean, + }) => Promise<{ + message: string | Uint8Array, + signature?: string | Uint8Array, + }>, + decryptMessage: (options: { + format: 'utf8' | 'binary', + armoredMessage?: string, + binaryMessage?: Uint8Array, + armoredSignature?: string, + binarySignature?: Uint8Array, + sessionKeys?: SessionKey, + decryptionKeys?: PrivateKey[], + verificationKeys: PublicKey[] + }) => Promise<{ + data: Uint8Array | string, + verified: VERIFICATION_STATUS + }>, } /** @@ -44,20 +57,21 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } async generateKey(passphrase: string) { - const key = await this.cryptoProxy.generateKey({ + const privateKey = await this.cryptoProxy.generateKey({ userIDs: [{ name: 'Drive key' }], type: 'ecc', + // @ts-expect-error The interface doesnt officially accept it anymore, but legacy is still supported. curve: 'ed25519Legacy', }); const armoredKey = await this.cryptoProxy.exportPrivateKey({ - key, + privateKey, passphrase, }); return { armoredKey, - privateKey: key.privateKey, + privateKey, }; } @@ -72,7 +86,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { encryptionKeys, }); return { - armoredData, + armoredData: armoredData as string, } } @@ -91,7 +105,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { detached: false, }); return { - encryptedData + encryptedData: encryptedData as Uint8Array, }; } @@ -107,7 +121,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { detached: false, }); return { - armoredData + armoredData: armoredData as string, }; } @@ -126,8 +140,8 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { detached: true, }); return { - encryptedData, - signature, + encryptedData: encryptedData as Uint8Array, + signature: signature as Uint8Array, } } @@ -145,8 +159,8 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { detached: true, }); return { - armoredData, - armoredSignature, + armoredData: armoredData as string, + armoredSignature: armoredSignature as string, } } @@ -191,7 +205,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }); return { - data, + data: data as Uint8Array, verified, } } @@ -204,14 +218,14 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { ) { const { data, verified } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, - signature: armoredSignature, + armoredSignature, sessionKeys: sessionKey, verificationKeys, format: 'binary', }); return { - data, + data: data as Uint8Array, verified, } } diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 307b6f12..6e99f258 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -4,3 +4,8 @@ export { OpenPGPCryptoWithCryptoProxy, OpenPGPCrypto } from './crypto/index.js'; export { ProtonDriveClient } from './protonDriveClient.js'; export { ProtonDrivePhotosClient } from './protonDrivePhotosClient.js'; export { ProtonDrivePublicClient } from './protonDrivePublicClient.js'; + +import { CACHE_TAG_KEYS as NODES_CACHE_TAG_KEYS } from './internal/nodes'; + +// TODO: Better would be if SDK could call it on the cache itself +export const CACHE_TAG_KEYS = Object.values(NODES_CACHE_TAG_KEYS); diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 15ec32a5..41e51e6d 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -105,7 +105,7 @@ function generateNode() { uid: "volume:volumeId;node:linkId", parentUid: "volume:volumeId;node:parentLinkId", - createdDate: new Date(123456789), + createdDate: new Date(123456789000), trashedDate: undefined, shareId: undefined, @@ -148,7 +148,7 @@ describe("nodeAPIService", () => { })); const nodes = await api.getNodes(['volume:volumeId;node:nodeId']); - expect(nodes).toEqual([expectedNode]); + expect(nodes).toStrictEqual([expectedNode]); } it('should get folder node', async () => { @@ -207,6 +207,17 @@ describe("nodeAPIService", () => { }), ); }); + + it('should get trashed file node', async () => { + await testGetNodes( + generateAPIFileNode({ + TrashTime: 123456, + }), + generateFileNode({ + trashedDate: new Date(123456000) + }), + ); + }); }); describe('trashNodes', () => { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 4994fe13..9d1ff649 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -65,8 +65,8 @@ export class NodeAPIService { parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, type: link.Link.Type === 1 ? NodeType.Folder : NodeType.File, mimeType: link.Link.MIMEType || undefined, - createdDate: new Date(link.Link.CreateTime), - trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime) : undefined, + createdDate: new Date(link.Link.CreateTime*1000), + trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, // Sharing node metadata shareId: link.SharingSummary?.ShareID || undefined, @@ -121,7 +121,7 @@ export class NodeAPIService { let anchor = ""; while (true) { - const response = await this.apiService.get(`drive/volumes/${volumeId}/folders/${nodeId}/children?AnchorID=${anchor}`, signal); + const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/folders/${nodeId}/children?${anchor ? `AnchorID=${anchor}` : ''}`, signal); for (const linkID of response.LinkIDs) { yield makeNodeUid(volumeId, linkID); } @@ -318,6 +318,7 @@ function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { function sharingSummaryToDirectMemberRole(sharingSummary: PostLoadLinksMetadataResponse['Links'][0]['SharingSummary'], logger?: Logger): MemberRole { switch (sharingSummary?.ShareAccess.Permissions) { + case undefined: case 4: return MemberRole.Viewer; case 6: diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index d6a6a3ae..0deed719 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -12,6 +12,7 @@ import { NodesAccess } from "./nodesAccess"; import { NodesManager } from "./manager"; export type { DecryptedNode } from "./interface"; +export { CACHE_TAG_KEYS } from "./cache"; /** * Provides facade for the whole nodes module. diff --git a/js/sdk/src/internal/nodes/manager.ts b/js/sdk/src/internal/nodes/manager.ts index e53b2317..ae1efcbb 100644 --- a/js/sdk/src/internal/nodes/manager.ts +++ b/js/sdk/src/internal/nodes/manager.ts @@ -46,7 +46,7 @@ export class NodesManager { // Ensure the parent is loaded and up-to-date. const parentNode = await this.nodesAccess.getNode(parentNodeUid); - const batchLoading = new BatchNodesLoading(this.nodesAccess.loadNodes); + const batchLoading = new BatchNodesLoading((nodeUids) => this.nodesAccess.loadNodes(nodeUids)); for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { let node; try { @@ -63,7 +63,7 @@ export class NodesManager { async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.shareService.getMyFilesIDs(); - const batchLoading = new BatchNodesLoading(this.nodesAccess.loadNodes); + const batchLoading = new BatchNodesLoading((nodeUids) => this.nodesAccess.loadNodes(nodeUids)); for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { let node; try { @@ -79,7 +79,7 @@ export class NodesManager { } async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const batchLoading = new BatchNodesLoading(this.nodesAccess.loadNodes); + const batchLoading = new BatchNodesLoading((nodeUids) => this.nodesAccess.loadNodes(nodeUids)); for await (const result of this.cache.iterateNodes(nodeUids)) { if (result.ok && !result.node.isStale) { yield result.node; diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 0edc763f..c5590750 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -115,10 +115,12 @@ export class SharesManager { * @throws If the share is not found or cannot be decrypted, or cached. */ async getSharePrivateKey(shareId: string): Promise { - const keys = await this.cryptoCache.getShareKey(shareId); - if (keys) { - return keys.key; - } + try { + const keys = await this.cryptoCache.getShareKey(shareId); + if (keys) { + return keys.key; + } + } catch {} const encryptedShare = await this.apiService.getRootShare(shareId); const { key } = await this.cryptoService.decryptRootShare(encryptedShare); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index e9f71506..e0ef78fa 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -53,11 +53,11 @@ export class ProtonDriveClient implements Partial { } async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { - return convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); + yield* convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); } async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { - return convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); + yield* convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); } async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings) { diff --git a/js/sdk/src/protonDrivePublicClient.ts b/js/sdk/src/protonDrivePublicClient.ts index 50874893..5cb120b1 100644 --- a/js/sdk/src/protonDrivePublicClient.ts +++ b/js/sdk/src/protonDrivePublicClient.ts @@ -44,11 +44,10 @@ export class ProtonDrivePublicClient implements ProtonDrivePublicClientInterface } async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { - return convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); + yield* convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); } async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { - return convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); + yield* convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); } - } From 19462edc08fd3b8617d2ac7099fad2f09f993ffb Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Feb 2025 12:00:55 +0000 Subject: [PATCH 010/791] Add nodes management to public interface and CLI --- js/sdk/src/interface/constructor.ts | 5 +-- js/sdk/src/interface/nodes.ts | 2 +- js/sdk/src/internal/apiService/index.ts | 1 + js/sdk/src/internal/errors.ts | 3 ++ js/sdk/src/internal/nodes/apiService.test.ts | 8 ++--- js/sdk/src/internal/nodes/apiService.ts | 3 +- js/sdk/src/internal/nodes/cache.test.ts | 4 +-- js/sdk/src/internal/nodes/cache.ts | 7 ++-- js/sdk/src/internal/nodes/manager.ts | 36 ++++++++++++++++---- js/sdk/src/protonDriveClient.ts | 28 +++++++++++++++ 10 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 js/sdk/src/internal/errors.ts diff --git a/js/sdk/src/interface/constructor.ts b/js/sdk/src/interface/constructor.ts index 2c26a60f..79e61b77 100644 --- a/js/sdk/src/interface/constructor.ts +++ b/js/sdk/src/interface/constructor.ts @@ -3,8 +3,9 @@ import { PrivateKey, PublicKey } from '../crypto'; export interface ProtonDriveAccount { getOwnPrimaryKey(): Promise<{ email: string, addressKey: PrivateKey, addressId: string, addressKeyId: string }>, - getOwnPrivateKey(addressId: string): Promise, - getOwnPrivateKeys(addressId: string): Promise, + // TODO: do we want to break it down to email vs address ID methods? + getOwnPrivateKey(emailOrAddressId: string): Promise, + getOwnPrivateKeys(emailOrAddressId: string): Promise, getPublicKeys(email: string): Promise, } diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 6cb8a831..1857e9a8 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -69,7 +69,7 @@ export interface Nodes { export interface NodesManagement { createFolder(parentNodeUid: NodeOrUid, name: string): Promise, renameNode(nodeUid: NodeOrUid, newName: string): Promise, - moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal): Promise, + moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator, trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, } diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 4aeaa65b..5263e925 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -1,4 +1,5 @@ export { DriveAPIService } from './apiService'; export { paths as drivePaths } from './driveTypes'; export { paths as corePaths } from './coreTypes'; +export { ErrorCode } from './errorCodes'; export * from './errors'; diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts new file mode 100644 index 00000000..5195a9cf --- /dev/null +++ b/js/sdk/src/internal/errors.ts @@ -0,0 +1,3 @@ +export class AbortError extends Error { + name = 'AbortError'; +} diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 41e51e6d..43bf9b35 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -1,5 +1,5 @@ import { MemberRole, NodeType } from "../../interface"; -import { DriveAPIService } from "../apiService"; +import { DriveAPIService, ErrorCode } from "../apiService"; import { NodeAPIService } from './apiService'; function generateAPIFileNode(linkOverrides = {}, overrides = {}) { @@ -228,7 +228,7 @@ describe("nodeAPIService", () => { { LinkID: 'nodeId1', Response: { - Code: 1000, + Code: ErrorCode.OK, } }, { @@ -258,7 +258,7 @@ describe("nodeAPIService", () => { { LinkID: 'nodeId1', Response: { - Code: 1000, + Code: ErrorCode.OK, } }, { @@ -303,7 +303,7 @@ describe("nodeAPIService", () => { { LinkID: 'nodeId1', Response: { - Code: 1000, + Code: ErrorCode.OK, } }, { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 9d1ff649..2c7d9b4e 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -284,7 +284,6 @@ export class NodeAPIService { hash: string, encryptedExtendedAttributes: string, }, - signal?: AbortSignal, ): Promise { const { volumeId, nodeId: parentId } = splitNodeUid(parentUid); @@ -301,7 +300,7 @@ export class NodeAPIService { Name: newNode.encryptedName, Hash: newNode.hash, XAttr: newNode.encryptedExtendedAttributes, - }, signal); + }); return response.Folder.ID; } diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index dbbd64ab..24361288 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -12,7 +12,7 @@ function generateNode(uid: string, parentUid='root', params: Partial { @@ -76,6 +78,7 @@ export class NodesManager { yield* batchLoading.loadNode(nodeUid, signal); } } + yield* batchLoading.loadRest(signal); } async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { @@ -87,9 +90,10 @@ export class NodesManager { yield* batchLoading.loadNode(result.uid, signal); } } + yield* batchLoading.loadRest(signal); } - async renameNode(nodeUid: string, newName: string): Promise { + async renameNode(nodeUid: string, newName: string): Promise { const node = await this.nodesAccess.getNode(nodeUid); const parentKeys = await this.nodesAccess.getParentKeys(node); @@ -113,18 +117,24 @@ export class NodesManager { hash: hash, } ); - await this.cache.setNode({ + const newNode: DecryptedNode = { ...node, name: resultOk(newName), nameAuthor: resultOk(signatureEmail), hash, - }); + } + await this.cache.setNode(newNode); + return newNode; } - async* moveNodes(nodeUids: string[], newParentUid: string): AsyncGenerator { + // Improvement requested: move nodes in parallel + async* moveNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator { for (const nodeUid of nodeUids) { + if (signal?.aborted) { + throw new AbortError('Move operation aborted'); + } try { - await this.moveNode(nodeUid, newParentUid); + await this.moveNode(nodeUid, newParentNodeUid); yield { uid: nodeUid, ok: true, @@ -247,7 +257,7 @@ export class NodesManager { await this.cache.removeNodes(deletedNodeUids); } - async createFolder(parentNodeUid: string, folderName: string, signal?: AbortSignal): Promise { + async createFolder(parentNodeUid: string, folderName: string): Promise { const parentNode = await this.nodesAccess.getNode(parentNodeUid); const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); if (!parentKeys.hashKey) { @@ -264,7 +274,7 @@ export class NodesManager { encryptedName: encryptedCrypto.encryptedName, hash: encryptedCrypto.hash, encryptedExtendedAttributes: encryptedCrypto.folder.encryptedExtendedAttributes || "", // TODO - }, signal); + }); const node: DecryptedNode = { // Internal metadata @@ -335,4 +345,16 @@ class BatchNodesLoading { this.nodesToFetch = []; } } + + async *loadRest(signal?: AbortSignal) { + if (this.nodesToFetch.length === 0) { + return; + } + + const nodes = await this.loadNodes(this.nodesToFetch, signal); + for (const node of nodes) { + yield node; + } + this.nodesToFetch = []; + } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index e0ef78fa..5dbda495 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -56,10 +56,38 @@ export class ProtonDriveClient implements Partial { yield* convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); } + async* iterateTrashedNodes(signal?: AbortSignal) { + yield* convertInternalNodeIterator(this.nodes.management.iterateTrashedNodes(signal)); + } + async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { yield* convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); } + async renameNode(nodeUid: NodeOrUid, newName: string) { + return this.nodes.management.renameNode(getUid(nodeUid), newName); + } + + async* moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal) { + yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); + } + + async* trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + yield* this.nodes.management.trashNodes(getUids(nodeUids), signal); + } + + async* restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + yield* this.nodes.management.restoreNodes(getUids(nodeUids), signal); + } + + async* deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal); + } + + async createFolder(parentNodeUid: NodeOrUid, name: string) { + return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name)); + } + async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings) { return this.sharing.shareNode(getUid(nodeUid), settings); } From 7134efe83dbe1782feeeebfb4a89c93fb681e727 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Feb 2025 12:06:20 +0000 Subject: [PATCH 011/791] Seed integration tests for CLIs --- .gitignore | 2 +- js/sdk/jest.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a556cf0a..379a21ee 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ node_modules tsconfig.tsbuildinfo # JS CLI -bin +js/cli/proton-drive auth.txt diff --git a/js/sdk/jest.config.js b/js/sdk/jest.config.js index b7b164ba..3de7f151 100644 --- a/js/sdk/jest.config.js +++ b/js/sdk/jest.config.js @@ -1,6 +1,6 @@ module.exports = { moduleDirectories: ['/node_modules', 'node_modules'], - testPathIgnorePatterns: ['/tests'], + testPathIgnorePatterns: [], collectCoverage: false, transformIgnorePatterns: [], transform: { From 0a4889c1e208792cdae016918b64824ed76030b7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Feb 2025 12:07:00 +0000 Subject: [PATCH 012/791] Add tests for shares manager --- js/sdk/src/internal/nodes/manager.ts | 1 + js/sdk/src/internal/shares/manager.test.ts | 174 +++++++++++++++++++++ js/sdk/src/internal/shares/manager.ts | 20 ++- 3 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 js/sdk/src/internal/shares/manager.test.ts diff --git a/js/sdk/src/internal/nodes/manager.ts b/js/sdk/src/internal/nodes/manager.ts index 4c105fbe..7b1a70d1 100644 --- a/js/sdk/src/internal/nodes/manager.ts +++ b/js/sdk/src/internal/nodes/manager.ts @@ -63,6 +63,7 @@ export class NodesManager { yield* batchLoading.loadRest(signal); } + // Improvement requested: keep status of loaded trash and leverage cache. async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.shareService.getMyFilesIDs(); const batchLoading = new BatchNodesLoading((nodeUids) => this.nodesAccess.loadNodes(nodeUids)); diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts new file mode 100644 index 00000000..832e9343 --- /dev/null +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -0,0 +1,174 @@ +import { ProtonDriveAccount } from "../../interface"; +import { NotFoundAPIError } from "../apiService"; +import { SharesAPIService } from "./apiService"; +import { SharesCache } from "./cache"; +import { SharesCryptoCache } from "./cryptoCache"; +import { SharesCryptoService } from "./cryptoService"; +import { SharesManager } from "./manager"; + +describe("SharesManager", () => { + let apiService: SharesAPIService; + let cache: SharesCache; + let cryptoCache: SharesCryptoCache; + let cryptoService: SharesCryptoService; + let account: ProtonDriveAccount; + + let manager: SharesManager; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = { + getMyFiles: jest.fn(), + getRootShare: jest.fn(), + getShare: jest.fn(), + getVolume: jest.fn(), + createVolume: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cache = { + setVolume: jest.fn(), + getVolume: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cryptoCache = { + setShareKey: jest.fn(), + getShareKey: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + generateVolumeBootstrap: jest.fn(), + decryptRootShare: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + account = { + getOwnPrimaryKey: jest.fn(), + getOwnPrivateKey: jest.fn(), + } + + manager = new SharesManager(apiService, cache, cryptoCache, cryptoService, account); + }); + + describe("getMyFilesIDs", () => { + const myFilesShare = { + shareId: "myFilesShareId", + volumeId: "myFilesVolumeId", + rootNodeId: "myFilesRootNodeId", + }; + + it("should load My files IDs once", async () => { + const encryptedShare = { + share: myFilesShare, + creatorEmail: "email", + }; + const key = { + key: "privateKey", + sessionKey: "sessionKey", + }; + + apiService.getMyFiles = jest.fn().mockResolvedValue(encryptedShare); + cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ share: myFilesShare, key }); + + // Calling twice to check if it loads only once. + await manager.getMyFilesIDs(); + const result = await manager.getMyFilesIDs(); + + expect(result).toStrictEqual(myFilesShare); + expect(apiService.getMyFiles).toHaveBeenCalledTimes(1); + expect(cryptoService.decryptRootShare).toHaveBeenCalledTimes(1); + expect(cryptoCache.setShareKey).toHaveBeenCalledWith(myFilesShare.shareId, key); + expect(cache.setVolume).toHaveBeenCalledWith({ + ...myFilesShare, + creatorEmail: encryptedShare.creatorEmail, + }); + expect(apiService.createVolume).not.toHaveBeenCalled(); + }); + + it("should create volume when My files section doesn't exist", async () => { + apiService.getMyFiles = jest.fn().mockRejectedValue(new NotFoundAPIError("no active volume", 0)); + account.getOwnPrimaryKey = jest.fn().mockResolvedValue({ addressKey: "addressKey" }); + cryptoService.generateVolumeBootstrap = jest.fn().mockResolvedValue({ + shareKey: { + encrypted: "encrypted share key", + decrypted: "decrypted share key", + }, + rootNode: { + key: { + encrypted: "encrypted root key", + }, + } + }); + apiService.createVolume = jest.fn().mockResolvedValue(myFilesShare); + + const result = await manager.getMyFilesIDs(); + + expect(result).toStrictEqual(myFilesShare); + expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); + expect(cryptoCache.setShareKey).toHaveBeenCalledWith("myFilesShareId", "decrypted share key"); + }); + + it("should throw on unknown error", async () => { + apiService.getMyFiles = jest.fn().mockRejectedValue(new Error("Some error")); + + expect(manager.getMyFilesIDs()).rejects.toThrow("Some error"); + expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); + expect(apiService.createVolume).not.toHaveBeenCalled(); + + }); + }); + + describe("getSharePrivateKey", () => { + it("should return cached private key", async () => { + cryptoCache.getShareKey = jest.fn().mockResolvedValue({ key: "cachedPrivateKey" }); + + const result = await manager.getSharePrivateKey("shareId"); + + expect(result).toBe("cachedPrivateKey"); + }); + + it("should load private key if not in cache", async () => { + cryptoCache.getShareKey = jest.fn().mockRejectedValue(new Error('not found')); + apiService.getRootShare = jest.fn().mockResolvedValue({ shareId: "shareId" }); + cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ key: { key: "privateKey" } }); + + const result = await manager.getSharePrivateKey("shareId"); + + expect(result).toBe("privateKey"); + expect(cryptoCache.setShareKey).toHaveBeenCalledWith("shareId", { key: "privateKey" }); + }); + }); + + describe("getVolumeEmailKey", () => { + it("should return cached volume email key", async () => { + cache.getVolume = jest.fn().mockResolvedValue({ creatorEmail: "email" }); + account.getOwnPrivateKey = jest.fn().mockResolvedValue("creatorKey"); + + const result = await manager.getVolumeEmailKey("volumeId"); + + expect(result).toEqual({ + email: "email", + key: "creatorKey", + }); + }); + + it("should load volume email key if not in cache", async () => { + const share = { + volumeId: "volumeId", + shareId: "shareId", + rootNodeId: "rootNodeId", + creatorEmail: "email", + } + cache.getVolume = jest.fn().mockRejectedValue(new Error('not found')); + apiService.getVolume = jest.fn().mockResolvedValue({ shareId: "shareId" }); + apiService.getShare = jest.fn().mockResolvedValue(share); + account.getOwnPrivateKey = jest.fn().mockResolvedValue("creatorKey"); + + const result = await manager.getVolumeEmailKey("volumeId"); + + expect(result).toEqual({ + email: "email", + key: "creatorKey", + }); + expect(cache.setVolume).toHaveBeenCalledWith(share); + }); + }); +}); diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index c5590750..d8d64ca7 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -62,12 +62,12 @@ export class SharesManager { creatorEmail: encryptedShare.creatorEmail, }); - return { + this.myFilesIds = { volumeId: myFilesShare.volumeId, shareId: myFilesShare.shareId, rootNodeId: myFilesShare.rootNodeId, }; - + return this.myFilesIds; } catch (error: unknown) { if (error instanceof NotFoundAPIError) { return this.createVolume(); @@ -116,10 +116,8 @@ export class SharesManager { */ async getSharePrivateKey(shareId: string): Promise { try { - const keys = await this.cryptoCache.getShareKey(shareId); - if (keys) { - return keys.key; - } + const { key } = await this.cryptoCache.getShareKey(shareId); + return key; } catch {} const encryptedShare = await this.apiService.getRootShare(shareId); @@ -129,13 +127,13 @@ export class SharesManager { } async getVolumeEmailKey(volumeId: string): Promise<{ email: string, key: PrivateKey }> { - const volume = await this.cache.getVolume(volumeId); - if (volume) { + try { + const { creatorEmail } = await this.cache.getVolume(volumeId); return { - email: volume.creatorEmail, - key: await this.account.getOwnPrivateKey(volume.creatorEmail), + email: creatorEmail, + key: await this.account.getOwnPrivateKey(creatorEmail), }; - } + } catch {} const { shareId } = await this.apiService.getVolume(volumeId); const share = await this.apiService.getShare(shareId); From 69d96f3a3f1ea2478bb71170e110cae7ec352d83 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 12 Feb 2025 11:00:36 +0000 Subject: [PATCH 013/791] Simplify cache tags --- js/sdk/src/cache/interface.ts | 25 ++++----- js/sdk/src/cache/memoryCache.test.ts | 24 ++++++--- js/sdk/src/cache/memoryCache.ts | 52 ++++++------------- js/sdk/src/index.ts | 5 -- js/sdk/src/internal/events/cache.test.ts | 2 +- js/sdk/src/internal/nodes/cache.test.ts | 4 +- js/sdk/src/internal/nodes/cache.ts | 16 +++--- js/sdk/src/internal/nodes/cryptoCache.test.ts | 2 +- js/sdk/src/internal/nodes/index.ts | 1 - js/sdk/src/internal/shares/cache.test.ts | 2 +- .../src/internal/shares/cryptoCache.test.ts | 2 +- 11 files changed, 58 insertions(+), 77 deletions(-) diff --git a/js/sdk/src/cache/interface.ts b/js/sdk/src/cache/interface.ts index dba99661..776ea780 100644 --- a/js/sdk/src/cache/interface.ts +++ b/js/sdk/src/cache/interface.ts @@ -5,15 +5,12 @@ export interface ProtonDriveCacheConstructor { * The local database should follow document-based structure. The SDK does * serialisation and data is not intended to be read by 3rd party. The SDK, * however, provides also clear fields in form of tags that is used for - * search. Local database should have indexes, columns, or other structure - * for easier look-up. The list of used tags by the SDK is passed via - * `usedTagKeysBySDK` parameter. + * search. Local database should have index or other structure for easier + * look-up. * * See {@link setEntity} for more details how tags are used. - * - * @param usedTagKeysBySDK - Example of tags: ["trashed", "shared", "parentUid"] */ - new (usedTagKeysBySDK: string[]): ProtonDriveCache, + new (): ProtonDriveCache, } export interface ProtonDriveCache { @@ -32,13 +29,13 @@ export interface ProtonDriveCache { * The `data` doesn't include any keys. It is up to the client store this * information privately. * - * The `tags` is an object that should be stored properly for fast look-up. - * The SDK provides list of used tags by the SDK in the {@link "constructor"}. + * The `tags` is a list of strings that should be stored properly for fast + * look-up. * * @example Usage by the SDK * ```ts - * await cache.setEntity("node-abc42", "{ node abc42 serialised data }", { "parentUid": "abc123", "shared": "withMe" }); - * await Array.fromAsync(cache.iterateEntitiesByTag("parentUid", "abc123")); // returns ["node-abc42"] + * await cache.setEntity("node-abc42", "{ node abc42 serialised data }", ["parentUid:abc123", "sharedWithMe"] }); + * await Array.fromAsync(cache.iterateEntitiesByTag("parentUid:abc123")); // returns ["node-abc42"] * await cache.getEntity("node-abc42"); // returns "{ node abc42 serialised data }" * await Array.fromAsync(cache.iterateEntities(["node-abc42"])); // returns ["{ node abc42 serialised data }"] * ``` @@ -67,7 +64,7 @@ export interface ProtonDriveCache { * @param tags - Clear metadata about the entity used for filtering. It is intended to store efficiently for fast look-up. * @throws Exception if `key` from `tags` is not one of the tag keys provided from `usedTagKeysBySDK` in constructor. */ - setEntity(uid: string, data: T, tags?: { [ key: string ]: string }): Promise, + setEntity(uid: string, data: T, tags?: string[] ): Promise, /** * Returns the data of the entity stored locally. @@ -96,11 +93,9 @@ export interface ProtonDriveCache { * await Array.fromAsync(cache.iterateEntitiesByTag("parentUid", "abc123")); // returns ["node-abc42"] * ``` * - * @param key - The tag key, for example `parentUid` - * @param value - The tag value, for example `"abc123"` - * @throws Exception if `key` is not one of the tag keys provided from `usedTagKeysBySDK` in constructor + * @param tag - The tag, for example `parentUid:abc123` */ - iterateEntitiesByTag(key: string, value: string): AsyncGenerator>, + iterateEntitiesByTag(tag: string): AsyncGenerator>, /** * Removes completely the entity stored locally from the database. diff --git a/js/sdk/src/cache/memoryCache.test.ts b/js/sdk/src/cache/memoryCache.test.ts index c41181db..624f623c 100644 --- a/js/sdk/src/cache/memoryCache.test.ts +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -4,10 +4,10 @@ describe('MemoryCache', () => { let cache: MemoryCache; beforeEach(() => { - cache = new MemoryCache(['tag1', 'tag2']); + cache = new MemoryCache(); - cache.setEntity('uid1', 'data1', { tag1: 'hello' }); - cache.setEntity('uid2', 'data2', { tag2: 'world' }); + cache.setEntity('uid1', 'data1', ['tag1:hello', 'tag2:world']); + cache.setEntity('uid2', 'data2', ['tag2:world']); cache.setEntity('uid3', 'data3'); }); @@ -47,7 +47,19 @@ describe('MemoryCache', () => { it('should iterate over entities by tag', async () => { const results = []; - for await (const result of cache.iterateEntitiesByTag('tag1', 'hello')) { + for await (const result of cache.iterateEntitiesByTag('tag2:world')) { + results.push(result); + } + + expect(results).toEqual([ + { uid: 'uid1', ok: true, data: 'data1' }, + { uid: 'uid2', ok: true, data: 'data2' }, + ]); + }); + + it('should iterate over entities with multiple tags by tag', async () => { + const results = []; + for await (const result of cache.iterateEntitiesByTag('tag1:hello')) { results.push(result); } @@ -58,7 +70,7 @@ describe('MemoryCache', () => { it('should iterate over entities by empty tag', async () => { const results = []; - for await (const result of cache.iterateEntitiesByTag('tag1', 'nonexistent')) { + for await (const result of cache.iterateEntitiesByTag('nonexistent')) { results.push(result); } @@ -97,7 +109,7 @@ describe('MemoryCache', () => { ]); const results2 = []; - for await (const result of cache.iterateEntitiesByTag('tag1', 'hello')) { + for await (const result of cache.iterateEntitiesByTag('tag1:hello')) { results2.push(result); } expect(results2).toEqual([]); diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index d81382d6..77e1bed6 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -1,7 +1,7 @@ import type { ProtonDriveCache, EntityResult } from './interface.js'; type KeyValueCache = { [ uid: string ]: T }; -type TagsCache = { [ key: string ]: { [ value: string ]: string[] } }; +type TagsCache = { [ tag: string ]: string[] }; /** * In-memory cache implementation for Proton Drive SDK. @@ -11,38 +11,25 @@ type TagsCache = { [ key: string ]: { [ value: string ]: string[] } }; * environments. */ export class MemoryCache implements ProtonDriveCache { - private entities: KeyValueCache; - private entitiesByTag: TagsCache; - - constructor(usedTagKeysBySDK: string[]) { - this.entities = {}; - this.entitiesByTag = usedTagKeysBySDK.reduce((acc, key) => { - acc[key] = {}; - return acc; - }, {} as TagsCache); - } + private entities: KeyValueCache = {}; + private entitiesByTag: TagsCache = {}; async purge() { this.entities = {}; } - async setEntity(uid: string, data: T, tags?: { [ key: string ]: string }) { + async setEntity(uid: string, data: T, tags?: string[]) { this.entities[uid] = data; if (tags) { - for (const key in tags) { - const value = tags[key]; - const tag = this.entitiesByTag[key]; - if (!tag) { - throw Error('Tag is not recognised'); - } - if (!tag[value]) { - tag[value] = []; + for (const tag of tags) { + if (!this.entitiesByTag[tag]) { + this.entitiesByTag[tag] = []; } - tag[value].push(uid); + this.entitiesByTag[tag].push(uid); } } } - + async getEntity(uid: string) { const data = this.entities[uid]; if (!data) { @@ -62,13 +49,8 @@ export class MemoryCache implements ProtonDriveCache { } } - async *iterateEntitiesByTag(key: string, value: string): AsyncGenerator> { - const tag = this.entitiesByTag[key]; - if (!tag) { - throw Error('Tag is not recognised'); - } - - const uids = tag[value]; + async *iterateEntitiesByTag(tag: string): AsyncGenerator> { + const uids = this.entitiesByTag[tag]; if (!uids) { return; } @@ -81,13 +63,11 @@ export class MemoryCache implements ProtonDriveCache { async removeEntities(uids: string[]) { for (const uid of uids) { delete this.entities[uid]; - Object.values(this.entitiesByTag).forEach((tag) => { - Object.values(tag).forEach((uids) => { - const index = uids.indexOf(uid); - if (index !== -1) { - uids.splice(index, 1); - } - }); + Object.values(this.entitiesByTag).forEach((uids) => { + const index = uids.indexOf(uid); + if (index !== -1) { + uids.splice(index, 1); + } }); } } diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 6e99f258..307b6f12 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -4,8 +4,3 @@ export { OpenPGPCryptoWithCryptoProxy, OpenPGPCrypto } from './crypto/index.js'; export { ProtonDriveClient } from './protonDriveClient.js'; export { ProtonDrivePhotosClient } from './protonDrivePhotosClient.js'; export { ProtonDrivePublicClient } from './protonDrivePublicClient.js'; - -import { CACHE_TAG_KEYS as NODES_CACHE_TAG_KEYS } from './internal/nodes'; - -// TODO: Better would be if SDK could call it on the cache itself -export const CACHE_TAG_KEYS = Object.values(NODES_CACHE_TAG_KEYS); diff --git a/js/sdk/src/internal/events/cache.test.ts b/js/sdk/src/internal/events/cache.test.ts index ee9947c2..5e48bbdb 100644 --- a/js/sdk/src/internal/events/cache.test.ts +++ b/js/sdk/src/internal/events/cache.test.ts @@ -6,7 +6,7 @@ describe("EventsCache", () => { let cache: EventsCache; beforeEach(() => { - memoryCache = new MemoryCache([]); + memoryCache = new MemoryCache(); cache = new EventsCache(memoryCache); }); diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 24361288..7c3c3dff 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -61,9 +61,9 @@ describe('nodesCache', () => { let cache: NodesCache; beforeEach(() => { - memoryCache = new MemoryCache([CACHE_TAG_KEYS.ParentUid, CACHE_TAG_KEYS.Trashed]); + memoryCache = new MemoryCache(); memoryCache.setEntity('node-root', JSON.stringify(generateNode('root', ''))); - memoryCache.setEntity('node-badObject', 'aaa', { [CACHE_TAG_KEYS.ParentUid]: 'root' }); + memoryCache.setEntity('node-badObject', 'aaa', [`${CACHE_TAG_KEYS.ParentUid}:root`]); cache = new NodesCache(memoryCache); }); diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 04d2f591..a995b54c 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -3,8 +3,8 @@ import { ProtonDriveEntitiesCache, Logger } from "../../interface"; import { DecryptedNode } from "./interface"; export enum CACHE_TAG_KEYS { - ParentUid = 'parentUid', - Trashed = 'trashed', + ParentUid = 'nodeParentUid', + Trashed = 'nodeTrashed', } type DecryptedNodeResult = ( @@ -30,12 +30,12 @@ export class NodesCache { const key = getCacheUid(node.uid); const nodeData = serialiseNode(node); - const tags: { [ key: string ]: string } = {}; + const tags = []; if (node.parentUid) { - tags[CACHE_TAG_KEYS.ParentUid] = node.parentUid; + tags.push(`${CACHE_TAG_KEYS.ParentUid}:${node.parentUid}`) } if (node.trashedDate) { - tags[CACHE_TAG_KEYS.Trashed] = 'true'; + tags.push(`${CACHE_TAG_KEYS.Trashed}`) } await this.driveCache.setEntity(key, nodeData, tags); @@ -94,7 +94,7 @@ export class NodesCache { private async getRecursiveChildrenCacheUids(parentNodeUid: string): Promise { const cacheUids = []; - for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.ParentUid, parentNodeUid)) { + for await (const result of this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`)) { cacheUids.push(result.uid); const childrenCacheUids = await this.getRecursiveChildrenCacheUids(getNodeUid(result.uid)); cacheUids.push(...childrenCacheUids); @@ -113,7 +113,7 @@ export class NodesCache { } async *iterateChildren(parentNodeUid: string): AsyncGenerator { - for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.ParentUid, parentNodeUid)) { + for await (const result of this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`)) { const node = await this.convertCacheResult(result); if (node) { yield node; @@ -122,7 +122,7 @@ export class NodesCache { } async *iterateTrashedNodes(): AsyncGenerator { - for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.Trashed, 'true')) { + for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.Trashed)) { const node = await this.convertCacheResult(result); if (node) { yield node; diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index 7672fe8f..7ca8aa4e 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -16,7 +16,7 @@ describe('nodesCryptoCache', () => { } beforeEach(() => { - memoryCache = new MemoryCache([]); + memoryCache = new MemoryCache(); memoryCache.setEntity('nodeKeys-missingPassphrase', { key: 'privateKey', sessionKey: 'sessionKey', diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 0deed719..d6a6a3ae 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -12,7 +12,6 @@ import { NodesAccess } from "./nodesAccess"; import { NodesManager } from "./manager"; export type { DecryptedNode } from "./interface"; -export { CACHE_TAG_KEYS } from "./cache"; /** * Provides facade for the whole nodes module. diff --git a/js/sdk/src/internal/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts index 3e98a0f8..c3386141 100644 --- a/js/sdk/src/internal/shares/cache.test.ts +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -6,7 +6,7 @@ describe('sharesCache', () => { let cache: SharesCache; beforeEach(() => { - memoryCache = new MemoryCache([]); + memoryCache = new MemoryCache(); memoryCache.setEntity('volume-badObject', 'aaa'); cache = new SharesCache(memoryCache); diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts index 503103e2..c507b6ad 100644 --- a/js/sdk/src/internal/shares/cryptoCache.test.ts +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -16,7 +16,7 @@ describe('sharesCryptoCache', () => { } beforeEach(() => { - memoryCache = new MemoryCache([]); + memoryCache = new MemoryCache(); cache = new SharesCryptoCache(memoryCache); }); From 52f1adc51a1263616bcc9db138a40c3296878959 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 17 Feb 2025 12:37:34 +0000 Subject: [PATCH 014/791] Seed sharing and photos modules --- js/sdk/.eslintrc.js | 1 + js/sdk/src/internal/batchLoading.test.ts | 58 ++++ js/sdk/src/internal/batchLoading.ts | 73 +++++ js/sdk/src/internal/events/apiService.ts | 13 +- js/sdk/src/internal/events/interface.ts | 4 + js/sdk/src/internal/nodes/events.test.ts | 24 +- js/sdk/src/internal/nodes/events.ts | 4 - js/sdk/src/internal/nodes/index.ts | 8 +- js/sdk/src/internal/nodes/interface.ts | 4 +- js/sdk/src/internal/nodes/nodeUid.ts | 13 - js/sdk/src/internal/nodes/nodesAccess.test.ts | 16 -- js/sdk/src/internal/nodes/nodesAccess.ts | 62 +++- .../nodes/{manager.ts => nodesManagement.ts} | 124 +------- js/sdk/src/internal/photos/albums.ts | 29 ++ js/sdk/src/internal/photos/apiService.ts | 16 ++ js/sdk/src/internal/photos/cache.ts | 11 + js/sdk/src/internal/photos/index.ts | 23 ++ js/sdk/src/internal/photos/interface.ts | 6 + js/sdk/src/internal/photos/photosTimeline.ts | 18 ++ js/sdk/src/internal/sharing/apiService.ts | 99 +++++-- js/sdk/src/internal/sharing/cache.test.ts | 99 +++++++ js/sdk/src/internal/sharing/cache.ts | 107 +++++++ js/sdk/src/internal/sharing/cryptoService.ts | 36 +-- js/sdk/src/internal/sharing/events.test.ts | 267 ++++++++++++++++++ js/sdk/src/internal/sharing/events.ts | 144 ++++++++++ js/sdk/src/internal/sharing/index.ts | 60 ++-- js/sdk/src/internal/sharing/interface.ts | 43 ++- .../internal/sharing/sharingAccess.test.ts | 96 +++++++ js/sdk/src/internal/sharing/sharingAccess.ts | 92 ++++-- .../src/internal/sharing/sharingManagement.ts | 85 +++--- js/sdk/src/internal/uids.ts | 4 + js/sdk/src/protonDriveClient.ts | 22 +- js/sdk/src/protonDrivePhotosClient.ts | 43 ++- js/sdk/src/protonDrivePublicClient.ts | 4 +- js/sdk/src/transformers.ts | 22 +- 35 files changed, 1410 insertions(+), 320 deletions(-) create mode 100644 js/sdk/src/internal/batchLoading.test.ts create mode 100644 js/sdk/src/internal/batchLoading.ts delete mode 100644 js/sdk/src/internal/nodes/nodeUid.ts rename js/sdk/src/internal/nodes/{manager.ts => nodesManagement.ts} (66%) create mode 100644 js/sdk/src/internal/photos/albums.ts create mode 100644 js/sdk/src/internal/photos/apiService.ts create mode 100644 js/sdk/src/internal/photos/cache.ts create mode 100644 js/sdk/src/internal/photos/index.ts create mode 100644 js/sdk/src/internal/photos/interface.ts create mode 100644 js/sdk/src/internal/photos/photosTimeline.ts create mode 100644 js/sdk/src/internal/sharing/cache.test.ts create mode 100644 js/sdk/src/internal/sharing/cache.ts create mode 100644 js/sdk/src/internal/sharing/events.test.ts create mode 100644 js/sdk/src/internal/sharing/events.ts create mode 100644 js/sdk/src/internal/sharing/sharingAccess.test.ts diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index b206ce66..f5429a09 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -18,6 +18,7 @@ module.exports = { "*.test.ts", "**/sharing/**/*", "**/upload/**/*", + "**/photos/**/*", ], rules: { // Any is used during prototyping - remove once all the types are available to fix all the places. diff --git a/js/sdk/src/internal/batchLoading.test.ts b/js/sdk/src/internal/batchLoading.test.ts new file mode 100644 index 00000000..37124e8e --- /dev/null +++ b/js/sdk/src/internal/batchLoading.test.ts @@ -0,0 +1,58 @@ +import { BatchLoading } from './batchLoading'; + +describe("BatchLoading", () => { + let batchLoading: BatchLoading; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should load in batches with loadItems", async () => { + const loadItems = jest.fn((items: string[]) => Promise.resolve(items.map((item) => `loaded:${item}`))); + + batchLoading = new BatchLoading({ loadItems, batchSize: 2 }); + + const result = []; + for (const item of ["a", "b", "c", "d", "e"]) { + for await (const loadedItem of batchLoading.load(item)) { + result.push(loadedItem); + } + } + for await (const loadedItem of batchLoading.loadRest()) { + result.push(loadedItem); + } + + + expect(result).toEqual(["loaded:a", "loaded:b", "loaded:c", "loaded:d", "loaded:e"]); + expect(loadItems).toHaveBeenCalledTimes(3); + expect(loadItems).toHaveBeenNthCalledWith(1, ["a", "b"]); + expect(loadItems).toHaveBeenNthCalledWith(2, ["c", "d"]); + expect(loadItems).toHaveBeenNthCalledWith(3, ["e"]); + }); + + it("should load in batches with iterateItems", async () => { + const iterateItems = jest.fn(async function* (items: string[]) { + for (const item of items) { + yield `loaded:${item}`; + } + }); + + batchLoading = new BatchLoading({ iterateItems, batchSize: 2 }); + + const result = []; + for (const item of ["a", "b", "c", "d", "e"]) { + for await (const loadedItem of batchLoading.load(item)) { + result.push(loadedItem); + } + } + for await (const loadedItem of batchLoading.loadRest()) { + result.push(loadedItem); + } + + expect(result).toEqual(["loaded:a", "loaded:b", "loaded:c", "loaded:d", "loaded:e"]); + expect(iterateItems).toHaveBeenCalledTimes(3); + expect(iterateItems).toHaveBeenNthCalledWith(1, ["a", "b"]); + expect(iterateItems).toHaveBeenNthCalledWith(2, ["c", "d"]); + expect(iterateItems).toHaveBeenNthCalledWith(3, ["e"]); + }); +}); diff --git a/js/sdk/src/internal/batchLoading.ts b/js/sdk/src/internal/batchLoading.ts new file mode 100644 index 00000000..0ff4babd --- /dev/null +++ b/js/sdk/src/internal/batchLoading.ts @@ -0,0 +1,73 @@ +const DEFAULT_BATCH_LOADING = 10; + +/** + * Helper class for batch loading items. + * + * The class is responsible for fetching items in batches. Any call to + * `load` will add the item to the batch (without fetching anything), + * and if the batch reaches the limit, it will fetch the items and yield + * them transparently to the caller. + * + * Example: + * + * ```typescript + * const batchLoading = new BatchLoading({ loadItems: loadNodesCallback }); + * for (const nodeUid of nodeUids) { + * for await (const node of batchLoading.load(nodeUid)) { + * console.log(node); + * } + * } + * for await (const node of batchLoading.loadRest()) { + * console.log(node); + * } + * ``` + */ +export class BatchLoading { + private batchSize = DEFAULT_BATCH_LOADING; + private iterateItems: (ids: ID[]) => AsyncGenerator; + + private itemsToFetch: ID[]; + + constructor(options: { + loadItems?: (ids: ID[]) => Promise, + iterateItems?: (ids: ID[]) => AsyncGenerator, + batchSize?: number, + }) { + this.itemsToFetch = []; + + if (options.loadItems) { + const loadItems = options.loadItems; + this.iterateItems = async function* (ids: ID[]) { + for (const item of await loadItems(ids)) { + yield item; + } + } + } else if (options.iterateItems) { + this.iterateItems = options.iterateItems; + } else { + throw new Error('Either loadItems or iterateItems must be provided'); + } + + if (options.batchSize) { + this.batchSize = options.batchSize; + } + } + + async *load(nodeUid: ID) { + this.itemsToFetch.push(nodeUid); + + if (this.itemsToFetch.length >= this.batchSize) { + yield* this.iterateItems(this.itemsToFetch); + this.itemsToFetch = []; + } + } + + async *loadRest() { + if (this.itemsToFetch.length === 0) { + return; + } + + yield* this.iterateItems(this.itemsToFetch); + this.itemsToFetch = []; + } +} diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index 2841dd11..5cca3882 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -62,17 +62,27 @@ export class EventsAPIService { lastEventId: result.EventID, more: result.More === 1, refresh: result.Refresh === 1, - events: result.Events.map((event) => { + events: result.Events.map((event): DriveEvent => { const type = VOLUME_EVENT_TYPE_MAP[event.EventType]; const link = event.Link as Extract; const uids = { nodeUid: makeNodeUid(volumeId, event.Link.LinkID), parentNodeUid: makeNodeUid(volumeId, link.ParentLinkID as string), } + // VOLUME_EVENT_TYPE_MAP will never return this event type. + // It is here to satisfy the type checker. It is safe to do. + if (type === DriveEventType.ShareWithMeUpdated) { + return { + type, + }; + } if (type === DriveEventType.NodeDeleted) { return { type, ...uids, + isTrashed: !!link.Trashed, + isShared: link.SharingDetails?.ShareID !== undefined, + isOwnVolume: false, // TODO } } return { @@ -80,6 +90,7 @@ export class EventsAPIService { ...uids, isTrashed: !!link.Trashed, isShared: link.SharingDetails?.ShareID !== undefined, + isOwnVolume: false, // TODO }; }), }; diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index b53393fb..ccc6a710 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -30,10 +30,14 @@ export type DriveEvent = { parentNodeUid: string, isTrashed: boolean, isShared: boolean, + isOwnVolume: boolean, } | { type: DriveEventType.NodeDeleted, nodeUid: string, parentNodeUid: string, + isTrashed?: boolean, + isShared?: boolean, + isOwnVolume: boolean, } | { type: DriveEventType.ShareWithMeUpdated, } diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index 4835c3b9..996bb8b3 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -19,12 +19,13 @@ describe("updateCacheByEvent", () => { }); describe('NodeCreated event', () => { - const event = { + const event: DriveEvent = { type: DriveEventType.NodeCreated, nodeUid: "nodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: false, + isOwnVolume: true, }; it("should not update cache by node create event", async () => { @@ -36,12 +37,13 @@ describe("updateCacheByEvent", () => { }); describe('NodeUpdated event', () => { - const event = { + const event: DriveEvent = { type: DriveEventType.NodeUpdated, nodeUid: "nodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: false, + isOwnVolume: true, }; it("should update cache if present in cache", async () => { @@ -87,6 +89,7 @@ describe("updateCacheByEvent", () => { type: DriveEventType.NodeDeleted, nodeUid: "nodeUid", parentNodeUid: "parentUid", + isOwnVolume: true, } it("should remove node from cache", async () => { @@ -117,12 +120,13 @@ describe("notifyListenersByEvent", () => { describe('update event', () => { it("should notify listeners by parentNodeUid", async () => { - const event = { + const event: DriveEvent = { type: DriveEventType.NodeUpdated, nodeUid: "nodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: false, + isOwnVolume: true, }; const listener = jest.fn(); @@ -134,12 +138,13 @@ describe("notifyListenersByEvent", () => { }); it("should notify listeners by isTrashed", async () => { - const event = { + const event: DriveEvent = { type: DriveEventType.NodeUpdated, nodeUid: "nodeUid", parentNodeUid: "parentUid", isTrashed: true, isShared: false, + isOwnVolume: true, }; const listener = jest.fn(); @@ -151,12 +156,13 @@ describe("notifyListenersByEvent", () => { }); it("should notify listeners by isShared", async () => { - const event = { + const event: DriveEvent = { type: DriveEventType.NodeUpdated, nodeUid: "nodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: true, + isOwnVolume: true, }; const listener = jest.fn(); @@ -168,12 +174,13 @@ describe("notifyListenersByEvent", () => { }); it("should not notify listeners if neither condition match", async () => { - const event = { + const event: DriveEvent = { type: DriveEventType.NodeUpdated, nodeUid: "nodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: false, + isOwnVolume: true, }; const listener = jest.fn(); @@ -190,6 +197,7 @@ describe("notifyListenersByEvent", () => { type: DriveEventType.NodeDeleted, nodeUid: "nodeUid", parentNodeUid: "parentUid", + isOwnVolume: true, }; const listener = jest.fn(); @@ -205,6 +213,7 @@ describe("notifyListenersByEvent", () => { type: DriveEventType.NodeDeleted, nodeUid: "nodeUid", parentNodeUid: "parentUid", + isOwnVolume: true, }; const listener = jest.fn(); @@ -221,6 +230,7 @@ describe("notifyListenersByEvent", () => { type: DriveEventType.NodeDeleted, nodeUid: "nodeUid", parentNodeUid: "parentUid", + isOwnVolume: true, }; const listener = jest.fn(); @@ -237,6 +247,7 @@ describe("notifyListenersByEvent", () => { type: DriveEventType.NodeDeleted, nodeUid: "nodeUid", parentNodeUid: "parentUid", + isOwnVolume: true, }; const listener = jest.fn(); @@ -250,6 +261,7 @@ describe("notifyListenersByEvent", () => { type: DriveEventType.NodeDeleted, nodeUid: "nodeUid", parentNodeUid: "parentUid", + isOwnVolume: true, }; const listener = jest.fn(); diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index a6d40acf..60fa9a14 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -54,10 +54,6 @@ export class NodesEvents { }); } - subscribeToSharedNodesByMe(callback: NodeEventCallback) { - this.listeners.push({ condition: ({ isShared }) => isShared || false, callback }); - } - subscribeToTrashedNodes(callback: NodeEventCallback) { this.listeners.push({ condition: ({ isTrashed }) => isTrashed || false, callback }); } diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index d6a6a3ae..d35df567 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -9,7 +9,7 @@ import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { SharesService, DecryptedNode } from "./interface"; import { NodesAccess } from "./nodesAccess"; -import { NodesManager } from "./manager"; +import { NodesManagement } from "./nodesManagement"; export type { DecryptedNode } from "./interface"; @@ -42,7 +42,7 @@ export function initNodesModule( // If change is done locally, it will take a time to show up if client // is waiting with UI update to events. Thus we need to emit events // right away. - const nodesManager = new NodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccess); + const nodesManager = new NodesManagement(api, cache, cryptoCache, cryptoService, nodesAccess); return { access: nodesAccess, @@ -65,13 +65,13 @@ export function initPublicNodesModule( // @ts-expect-error TODO const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); - const nodesManager = new NodesManager(api, cache, cryptoCache, cryptoService, sharesService, nodesAccess); + const nodesManagement = new NodesManagement(api, cache, cryptoCache, cryptoService, nodesAccess); return { // TODO: use public root node, not my files // eslint-disable-next-line @typescript-eslint/no-unused-vars getPublicRootNode: async (token: string, password: string, customPassword?: string): Promise => { return {} as DecryptedNode }, access: nodesAccess, - management: nodesManager, + management: nodesManagement, } } diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 42957635..544e6876 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { Result, InvalidNameError, AnonymousUser, UnverifiedAuthorError, MemberRole, NodeType, Revision } from "../../interface"; +import { NodeEntity, Result, InvalidNameError, AnonymousUser, UnverifiedAuthorError, MemberRole, NodeType, Revision } from "../../interface"; /** * Internal common node interface for both encrypted or decrypted node. @@ -63,7 +63,7 @@ export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { /** * Interface holding decrypted node metadata. */ -export interface DecryptedNode extends BaseNode { +export interface DecryptedNode extends BaseNode, NodeEntity { // Internal metadata isStale: boolean; diff --git a/js/sdk/src/internal/nodes/nodeUid.ts b/js/sdk/src/internal/nodes/nodeUid.ts deleted file mode 100644 index 5eb4be98..00000000 --- a/js/sdk/src/internal/nodes/nodeUid.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function makeNodeUid(volumeId: string, nodeId: string) { - // TODO: format of UID - return `volume:${volumeId};node:${nodeId}`; -} - -export function splitNodeUid(nodeUid: string) { - // TODO: validation - const [ volumeId, nodeId ] = nodeUid.split(';'); - return { - volumeId: volumeId.slice('volume:'.length), - nodeId: nodeId.slice('node:'.length), - }; -} diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 2de82855..59ca680e 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -116,20 +116,4 @@ describe('nodesAccess', () => { expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); }); }); - - it('should load node without accessing cache first', async () => { - const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; - const decryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedNode; - const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; - - apiService.getNodes = jest.fn(() => Promise.resolve([encryptedNode])); - cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedNode, keys: decryptedKeys })); - - const result = await access.loadNodes(['nodeId']); - expect(result).toEqual([decryptedNode]); - expect(cache.getNode).not.toHaveBeenCalled(); - expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); - expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('nodeId', decryptedKeys); - }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 87ebbeba..a5dd6c51 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -3,6 +3,8 @@ import { NodesCache } from "./cache" import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { SharesService, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; +import { BatchLoading } from "../batchLoading"; +import { makeNodeUid } from "../uids"; /** * Provides access to node metadata. @@ -25,6 +27,12 @@ export class NodesAccess { this.shareService = shareService; } + async getMyFilesRootFolder() { + const { volumeId, rootNodeId } = await this.shareService.getMyFilesIDs(); + const nodeUid = makeNodeUid(volumeId, rootNodeId); + return this.getNode(nodeUid); + } + async getNode(nodeUid: string): Promise { let cachedNode; try { @@ -39,12 +47,64 @@ export class NodesAccess { return node; } + // Improvement requested: keep status of loaded children and leverage cache. + async *iterateChildren(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + // Ensure the parent is loaded and up-to-date. + const parentNode = await this.getNode(parentNodeUid); + + const batchLoading = new BatchLoading({ loadItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { + let node; + try { + node = await this.cache.getNode(nodeUid); + } catch {} + + if (node && !node.isStale) { + yield node; + } else { + yield* batchLoading.load(nodeUid); + } + } + yield* batchLoading.loadRest(); + } + + // Improvement requested: keep status of loaded trash and leverage cache. + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.shareService.getMyFilesIDs(); + const batchLoading = new BatchLoading({ loadItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { + let node; + try { + node = await this.cache.getNode(nodeUid); + } catch {} + + if (node && !node.isStale) { + yield node; + } else { + yield* batchLoading.load(nodeUid); + } + } + yield* batchLoading.loadRest(); + } + + async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const batchLoading = new BatchLoading({ loadItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + for await (const result of this.cache.iterateNodes(nodeUids)) { + if (result.ok && !result.node.isStale) { + yield result.node; + } else { + yield* batchLoading.load(result.uid); + } + } + yield* batchLoading.loadRest(); + } + private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { const encryptedNode = await this.apiService.getNode(nodeUid); return this.decryptNode(encryptedNode); } - async loadNodes(nodeUids: string[], signal?: AbortSignal): Promise { + private async loadNodes(nodeUids: string[], signal?: AbortSignal): Promise { // TODO: batching const encryptedNodes = await this.apiService.getNodes(nodeUids, signal); const results = await Promise.all(encryptedNodes.map((encryptedNode) => this.decryptNode(encryptedNode))); diff --git a/js/sdk/src/internal/nodes/manager.ts b/js/sdk/src/internal/nodes/nodesManagement.ts similarity index 66% rename from js/sdk/src/internal/nodes/manager.ts rename to js/sdk/src/internal/nodes/nodesManagement.ts index 7b1a70d1..6db336d6 100644 --- a/js/sdk/src/internal/nodes/manager.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -1,15 +1,12 @@ import { MemberRole, NodeType, NodeResult, resultOk } from "../../interface"; -import { makeNodeUid } from "../uids"; import { AbortError } from "../errors"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; -import { SharesService, DecryptedNode } from "./interface"; +import { DecryptedNode } from "./interface"; import { NodesAccess } from "./nodesAccess"; -const BATCH_LOADING = 10; - /** * Provides high-level actions for managing nodes. * @@ -19,81 +16,21 @@ const BATCH_LOADING = 10; * This module uses other modules providing low-level operations, such * as API service, cache, crypto service, etc. */ -export class NodesManager { +export class NodesManagement { constructor( private apiService: NodeAPIService, private cache: NodesCache, private cryptoCache: NodesCryptoCache, private cryptoService: NodesCryptoService, - private shareService: SharesService, private nodesAccess: NodesAccess, ) { this.apiService = apiService; this.cache = cache; this.cryptoCache = cryptoCache; this.cryptoService = cryptoService; - this.shareService = shareService; this.nodesAccess = nodesAccess; } - async getMyFilesRootFolder() { - const { volumeId, rootNodeId } = await this.shareService.getMyFilesIDs(); - const nodeUid = makeNodeUid(volumeId, rootNodeId); - return this.nodesAccess.getNode(nodeUid); - } - - // Improvement requested: keep status of loaded children and leverage cache. - async *iterateChildren(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { - // Ensure the parent is loaded and up-to-date. - const parentNode = await this.nodesAccess.getNode(parentNodeUid); - - const batchLoading = new BatchNodesLoading((nodeUids) => this.nodesAccess.loadNodes(nodeUids)); - for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { - let node; - try { - node = await this.cache.getNode(nodeUid); - } catch {} - - if (node && !node.isStale) { - yield node; - } else { - yield* batchLoading.loadNode(nodeUid, signal); - } - } - yield* batchLoading.loadRest(signal); - } - - // Improvement requested: keep status of loaded trash and leverage cache. - async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { - const { volumeId } = await this.shareService.getMyFilesIDs(); - const batchLoading = new BatchNodesLoading((nodeUids) => this.nodesAccess.loadNodes(nodeUids)); - for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { - let node; - try { - node = await this.cache.getNode(nodeUid); - } catch {} - - if (node && !node.isStale) { - yield node; - } else { - yield* batchLoading.loadNode(nodeUid, signal); - } - } - yield* batchLoading.loadRest(signal); - } - - async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const batchLoading = new BatchNodesLoading((nodeUids) => this.nodesAccess.loadNodes(nodeUids)); - for await (const result of this.cache.iterateNodes(nodeUids)) { - if (result.ok && !result.node.isStale) { - yield result.node; - } else { - yield* batchLoading.loadNode(result.uid, signal); - } - } - yield* batchLoading.loadRest(signal); - } - async renameNode(nodeUid: string, newName: string): Promise { const node = await this.nodesAccess.getNode(nodeUid); const parentKeys = await this.nodesAccess.getParentKeys(node); @@ -198,7 +135,7 @@ export class NodesManager { async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodesPerParent = new Map(); - for await (const node of this.iterateNodes(nodeUids, signal)) { + for await (const node of this.nodesAccess.iterateNodes(nodeUids, signal)) { if (!node.parentUid) { throw new Error('Trashing root nodes is not supported'); } @@ -228,7 +165,7 @@ export class NodesManager { } async* restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodes = await Array.fromAsync(this.iterateNodes(nodeUids, signal)); + const nodes = await Array.fromAsync(this.nodesAccess.iterateNodes(nodeUids, signal)); for await (const result of this.apiService.restoreNodes(nodeUids, signal)) { if (result.ok) { @@ -306,56 +243,3 @@ export class NodesManager { return node; } } - -/** - * Helper class for batch loading nodes. - * - * The class is responsible for fetching nodes in batches. Any call to - * `loadNode` will add the node to the batch (without fetching anything), - * and if the batch reaches the limit, it will fetch the nodes and yield - * them transparently to the caller. - * - * Example: - * - * ```typescript - * const batchLoading = new BatchNodesLoading(loadNodesCallback); - * for (const nodeUid of nodeUids) { - * for await (const node of batchLoading.loadNode(nodeUid)) { - * console.log(node); - * } - * } - * ``` - */ -class BatchNodesLoading { - private nodesToFetch: string[]; - private loadNodes: (nodeUids: string[], signal?: AbortSignal) => Promise; - - constructor(loadNodes: (nodeUids: string[]) => Promise) { - this.nodesToFetch = []; - this.loadNodes = loadNodes; - } - - async *loadNode(nodeUid: string, signal?: AbortSignal) { - this.nodesToFetch.push(nodeUid); - - if (this.nodesToFetch.length >= BATCH_LOADING) { - const nodes = await this.loadNodes(this.nodesToFetch, signal); - for (const node of nodes) { - yield node; - } - this.nodesToFetch = []; - } - } - - async *loadRest(signal?: AbortSignal) { - if (this.nodesToFetch.length === 0) { - return; - } - - const nodes = await this.loadNodes(this.nodesToFetch, signal); - for (const node of nodes) { - yield node; - } - this.nodesToFetch = []; - } -} diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts new file mode 100644 index 00000000..18700132 --- /dev/null +++ b/js/sdk/src/internal/photos/albums.ts @@ -0,0 +1,29 @@ +import { PhotosAPIService } from "./apiService"; +import { PhotosCache } from "./cache"; +import { NodesService } from "./interface"; + +export class Albums { + constructor( + private apiService: PhotosAPIService, + private cache: PhotosCache, + private nodesService: NodesService, + ) { + this.apiService = apiService; + this.cache = cache; + this.nodesService = nodesService; + } + + async* iterateAlbums() { + for await (const album of this.apiService.iterateAlbums()) { + const node = await this.nodesService.getNode(album.uid); + yield { + node, + } + } + } + + async createAlbum(albumName: string) { + const albumdUid = this.apiService.createAlbum(albumName); + this.cache.setAlbum(albumdUid); + } +} diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts new file mode 100644 index 00000000..ec2d0f31 --- /dev/null +++ b/js/sdk/src/internal/photos/apiService.ts @@ -0,0 +1,16 @@ +import { DriveAPIService } from "../apiService/index.js"; + +export class PhotosAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; + } + + async* iterateTimeline(): AsyncGenerator { + } + + async* iterateAlbums(): AsyncGenerator { + } + + async createAlbum(object: any): Promise { + } +} diff --git a/js/sdk/src/internal/photos/cache.ts b/js/sdk/src/internal/photos/cache.ts new file mode 100644 index 00000000..dfc9363d --- /dev/null +++ b/js/sdk/src/internal/photos/cache.ts @@ -0,0 +1,11 @@ +import { ProtonDriveEntitiesCache } from "../../interface"; + +export class PhotosCache { + constructor(private driveCache: ProtonDriveEntitiesCache) { + this.driveCache = driveCache; + } + + setAlbum(album: any) { + this.driveCache.setEntity(album.uid, album); + } +} diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts new file mode 100644 index 00000000..e91af5d4 --- /dev/null +++ b/js/sdk/src/internal/photos/index.ts @@ -0,0 +1,23 @@ +import { DriveAPIService } from "../apiService"; +import { ProtonDriveEntitiesCache } from "../../interface"; +import { PhotosAPIService } from "./apiService"; +import { PhotosCache } from "./cache"; +import { PhotosTimeline } from "./photosTimeline"; +import { Albums } from "./albums"; +import { NodesService } from "./interface"; + +export function initPhotosModule( + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, + nodesService: NodesService, +) { + const api = new PhotosAPIService(apiService); + const cache = new PhotosCache(driveEntitiesCache); + const timeline = new PhotosTimeline(api, cache, nodesService); + const albums = new Albums(api, cache, nodesService); + + return { + timeline, + albums, + } +} diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts new file mode 100644 index 00000000..351e0184 --- /dev/null +++ b/js/sdk/src/internal/photos/interface.ts @@ -0,0 +1,6 @@ +import { NodeEntity } from "../../interface"; + +export interface NodesService { + getNode(nodeUid: string): Promise; + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; +} diff --git a/js/sdk/src/internal/photos/photosTimeline.ts b/js/sdk/src/internal/photos/photosTimeline.ts new file mode 100644 index 00000000..d7ff50b5 --- /dev/null +++ b/js/sdk/src/internal/photos/photosTimeline.ts @@ -0,0 +1,18 @@ +import { PhotosAPIService } from "./apiService"; +import { PhotosCache } from "./cache"; +import { NodesService } from "./interface"; + +export class PhotosTimeline { + constructor( + private apiService: PhotosAPIService, + private cache: PhotosCache, + private nodesService: NodesService, + ) { + this.apiService = apiService; + this.cache = cache; + this.nodesService = nodesService; + } + + async getTimelineStructure() { + } +} diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index aa7f148d..cba3a286 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -1,31 +1,94 @@ -import { DriveAPIService } from "../apiService/index.js"; +import { NodeType } from "../../interface"; +import { DriveAPIService, drivePaths } from "../apiService"; +import { makeNodeUid, makeInvitationUid } from "../uids"; +import { EncryptedBookmark } from "./interface"; -export function sharingAPIService(apiService: DriveAPIService) { - // TODO: types - async function *iterateSharedNodes(volumeId: string): any { - // TODO: /drive/v2/volumes/{volumeID}/shares +type GetSharedNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/shares']['get']['responses']['200']['content']['application/json']; + +type GetSharedWithMeNodesResponse = drivePaths['/drive/v2/sharedwithme']['get']['responses']['200']['content']['application/json']; + +type GetInvitationsResponse = drivePaths['/drive/v2/shares/invitations']['get']['responses']['200']['content']['application/json']; + +type GetSharedBookmarksResponse = drivePaths['/drive/v2/shared-bookmarks']['get']['responses']['200']['content']['application/json']; + +export class SharingAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; } - async function *iterateSharedWithMe(): any { - // TODO: /drive/v2/sharedwithme + async *iterateSharedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { + let anchor = ""; + while (true) { + const response = await this.apiService.get(`drive/v2/volumes/{volumeID}/shares?${anchor ? `AnchorID=${anchor}` : ''}`, signal); + for (const link of response.Links) { + yield makeNodeUid(volumeId, link.LinkID); + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } } - async function *iterateInvitations() { - // TODO: /drive/v2/shares/invitations + async *iterateSharedWithMeNodeUids(signal?: AbortSignal): AsyncGenerator { + let anchor = ""; + while (true) { + const response = await this.apiService.get(`drive/v2/sharedwithme?${anchor ? `AnchorID=${anchor}` : ''}`, signal); + for (const link of response.Links) { + yield makeNodeUid(link.VolumeID, link.LinkID); + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } } - async function *iterateBookmarks() { - // TODO: /drive/v2/shared-bookmarks + async *iterateInvitationUids(signal?: AbortSignal): AsyncGenerator { + let anchor = ""; + while (true) { + const response = await this.apiService.get(`drive/v2/shares/invitations?${anchor ? `AnchorID=${anchor}` : ''}`, signal); + for (const invitation of response.Invitations) { + yield makeInvitationUid(invitation.VolumeID, invitation.InvitationID); + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } } - async function inviteProtonUser(object: any) { + async *iterateBookmarks(signal?: AbortSignal): AsyncGenerator { + const response = await this.apiService.get(`drive/v2/shared-bookmarks`, signal); + for (const bookmark of response.Bookmarks) { + yield { + tokenId: bookmark.Token.Token, + createdDate: new Date(bookmark.CreateTime*1000), + share: { + armoredKey: bookmark.Token.ShareKey, + armoredPassphrase: bookmark.Token.SharePassphrase, + }, + url: { + encryptedUrlPassword: bookmark.EncryptedUrlPassword || undefined, + base64SharePasswordSalt: bookmark.Token.SharePasswordSalt, + }, + node: { + type: bookmark.Token.LinkType === 1 ? NodeType.Folder : NodeType.File, + mimeType: bookmark.Token.MIMEType, + encryptedName: bookmark.Token.Name, + armoredKey: bookmark.Token.NodeKey, + armoredNodePassphrase: bookmark.Token.NodePassphrase, + file: { + base64ContentKeyPacket: bookmark.Token.ContentKeyPacket || undefined, + }, + }, + } + } } - return { - iterateSharedNodes, - iterateSharedWithMe, - iterateInvitations, - iterateBookmarks, - inviteProtonUser, + async inviteProtonUser(object: any) { } } diff --git a/js/sdk/src/internal/sharing/cache.test.ts b/js/sdk/src/internal/sharing/cache.test.ts new file mode 100644 index 00000000..1a6b3abe --- /dev/null +++ b/js/sdk/src/internal/sharing/cache.test.ts @@ -0,0 +1,99 @@ +import { MemoryCache } from "../../cache"; +import { SharingCache } from "./cache"; + +describe("SharingCache", () => { + let memoryCache: MemoryCache; + let cache: SharingCache; + + beforeEach(() => { + memoryCache = new MemoryCache(); + cache = new SharingCache(memoryCache); + }); + + describe("set and get shared by me nodes", () => { + it("should set node uids", async () => { + await cache.setSharedByMeNodeUids(["nodeUid"]); + + const result = await cache.getSharedByMeNodeUids(); + + expect(result).toEqual(["nodeUid"]); + }); + }); + + describe("addSharedByMeNodeUid", () => { + it("should throw if adding before setting", async () => { + try { + await cache.addSharedByMeNodeUid("nodeUid"); + fail("Should have thrown an error"); + } catch (error) { + expect(`${error}`).toBe("Error: Calling add before setting the loaded items"); + } + }); + + it("should add node uid", async () => { + cache.setSharedByMeNodeUids(["nodeUid"]); + const spy = jest.spyOn(memoryCache, 'setEntity'); + + await cache.addSharedByMeNodeUid("newNodeUid"); + + const result = await cache.getSharedByMeNodeUids(); + expect(result).toEqual(["nodeUid", "newNodeUid"]); + expect(spy).toHaveBeenCalled(); + }); + + it("should not add duplicate node uid", async () => { + cache.setSharedByMeNodeUids(["nodeUid"]); + const spy = jest.spyOn(memoryCache, 'setEntity'); + + await cache.addSharedByMeNodeUid("nodeUid"); + await cache.addSharedByMeNodeUid("nodeUid"); + + const result = await cache.getSharedByMeNodeUids(); + expect(result).toEqual(["nodeUid"]); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe("removeSharedByMeNodeUid", () => { + it("should throw if removing before setting", async () => { + try { + await cache.removeSharedByMeNodeUid("nodeUid"); + fail("Should have thrown an error"); + } catch (error) { + expect(`${error}`).toBe("Error: Calling remove before setting the loaded items"); + } + }); + + it("should remove node uid", async () => { + cache.setSharedByMeNodeUids(["nodeUid"]); + const spy = jest.spyOn(memoryCache, 'setEntity'); + + await cache.removeSharedByMeNodeUid("nodeUid"); + + const result = await cache.getSharedByMeNodeUids(); + expect(result).toEqual([]); + expect(spy).toHaveBeenCalled(); + }); + + it("should handle removing of missing node uid", async () => { + cache.setSharedByMeNodeUids([]); + const spy = jest.spyOn(memoryCache, 'setEntity'); + + await cache.removeSharedByMeNodeUid("nodeUid"); + + const result = await cache.getSharedByMeNodeUids(); + expect(result).toEqual([]); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe("set and get shared with me nodes", () => { + it("should set node uids", async () => { + await cache.setSharedWithMeNodeUids(["nodeUid"]); + + const result = await cache.getSharedWithMeNodeUids(); + + expect(result).toEqual(["nodeUid"]); + }); + }); +}); diff --git a/js/sdk/src/internal/sharing/cache.ts b/js/sdk/src/internal/sharing/cache.ts new file mode 100644 index 00000000..5209be69 --- /dev/null +++ b/js/sdk/src/internal/sharing/cache.ts @@ -0,0 +1,107 @@ +import { ProtonDriveEntitiesCache } from "../../interface"; +import { SharingType } from "./interface"; + +/** + * Provides caching for shared by me and with me listings. + * + * The cache is responsible for serialising and deserialising the node + * UIDs for each sharing type. Also, ensuring that only full lists are + * cached. + */ +export class SharingCache { + /** + * Locally cached data to avoid unnecessary reads from the cache. + */ + private cache: Map = new Map(); + + constructor(private driveCache: ProtonDriveEntitiesCache) { + this.driveCache = driveCache; + } + + async getSharedByMeNodeUids(): Promise { + return this.getNodeUids(SharingType.SharedByMe); + } + + async addSharedByMeNodeUid(nodeUid: string): Promise { + return this.addNodeUid(SharingType.SharedByMe, nodeUid); + } + + async removeSharedByMeNodeUid(nodeUid: string): Promise { + return this.removeNodeUid(SharingType.SharedByMe, nodeUid); + } + + async setSharedByMeNodeUids(nodeUids: string[]): Promise { + return this.setNodeUids(SharingType.SharedByMe, nodeUids); + } + + async getSharedWithMeNodeUids(): Promise { + return this.getNodeUids(SharingType.sharedWithMe); + } + + async setSharedWithMeNodeUids(nodeUids: string[] | undefined): Promise { + return this.setNodeUids(SharingType.sharedWithMe, nodeUids); + } + + /** + * @throws Error if the cache is not set yet. First, the cache should be + * set by calling `setNodeUids` after full loading of the list. + */ + private async addNodeUid(type: SharingType, nodeUid: string): Promise { + let nodeUids; + try { + nodeUids = await this.getNodeUids(type); + } catch { + throw new Error('Calling add before setting the loaded items'); + } + const set = new Set(nodeUids); + if (set.has(nodeUid)) { + return; + } + set.add(nodeUid); + await this.setNodeUids(type, [...set]); + } + + /** + * @throws Error if the cache is not set yet. First, the cache should be + * set by calling `setNodeUids` after full loading of the list. + */ + private async removeNodeUid(type: SharingType, nodeUid: string): Promise { + let nodeUids; + try { + nodeUids = await this.getNodeUids(type); + } catch { + throw new Error('Calling remove before setting the loaded items'); + } + const set = new Set(nodeUids); + if (!set.has(nodeUid)) { + return; + } + set.delete(nodeUid); + await this.setNodeUids(type, [...set]); + } + + private async getNodeUids(type: SharingType): Promise { + let nodeUids = this.cache.get(type); + if (nodeUids) { + return nodeUids; + } + + const nodeUidsString = await this.driveCache.getEntity(`sharing-${type}-nodeUids`); + nodeUids = nodeUidsString.split(','); + this.cache.set(type, nodeUids); + return nodeUids; + } + + /** + * @param nodeUids - Passing `undefined` will remove the cache. + */ + private async setNodeUids(type: SharingType, nodeUids: string[] | undefined): Promise { + if (nodeUids) { + this.cache.set(type, nodeUids); + await this.driveCache.setEntity(`sharing-${type}-nodeUids`, nodeUids.join(',')); + } else { + this.cache.delete(type); + await this.driveCache.removeEntities([`sharing-${type}-nodeUids`]); + } + } +} diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 0f04b34e..e521fdf0 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -1,22 +1,28 @@ -import { PrivateKey } from '../../crypto/index'; +import { DriveCrypto, PrivateKey } from '../../crypto'; +import { ProtonDriveAccount } from "../../interface"; -import { ProtonDriveAccount } from "../../interface/index.js"; -import { DriveCrypto } from "../../crypto/index.js"; +export class SharingCryptoService { + constructor( + private driveCrypto: DriveCrypto, + private account: ProtonDriveAccount, + ) { + this.driveCrypto = driveCrypto; + this.account = account; + } -export function sharingCryptoService(driveCrypto: DriveCrypto, account: ProtonDriveAccount) { // TODO: types - async function generateKeys(nodeKey: PrivateKey, addressKey: PrivateKey): Promise { - return driveCrypto.generateKey([nodeKey, addressKey], addressKey); + async generateKeys(nodeKey: PrivateKey, addressKey: PrivateKey): Promise { + return this.driveCrypto.generateKey([nodeKey, addressKey], addressKey); }; // TODO: types - async function decryptShareKeys(share: any, nodeKey: PrivateKey): Promise { + async decryptShareKeys(share: any, nodeKey: PrivateKey): Promise { // TODO: use correct address keys - const addressPrivateKeys = await account.getOwnPrivateKeys(share.addressId); - const addressPublicKeys = await account.getPublicKeys(share.creatorEmail); + const addressPrivateKeys = await this.account.getOwnPrivateKeys(share.addressId); + const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); // TODO: use verified - const { key, sessionKey } = await driveCrypto.decryptKey( + const { key, sessionKey } = await this.driveCrypto.decryptKey( share.encryptedCrypto.armoredKey, share.encryptedCrypto.armoredPassphrase, share.encryptedCrypto.armoredPassphraseSignature, @@ -30,15 +36,9 @@ export function sharingCryptoService(driveCrypto: DriveCrypto, account: ProtonDr } // TODO: types - async function encryptInvitation(email: string): Promise { + async encryptInvitation(email: string): Promise { // TODO - const publicKey = await account.getPublicKeys(email); + const publicKey = await this.account.getPublicKeys(email); return publicKey; }; - - return { - generateKeys, - decryptShareKeys, - encryptInvitation, - } } diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts new file mode 100644 index 00000000..6241ef57 --- /dev/null +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -0,0 +1,267 @@ +import { DriveEvent, DriveEventType } from "../events"; +import { SharesService, NodesService, SharingType } from "./interface"; +import { SharingCache } from "./cache"; +import { handleSharedByMeNodes, handleSharedWithMeNodes } from "./events"; +import { SharingAccess } from "./sharingAccess"; + +describe("handleSharedByMeNodes", () => { + let cache: SharingCache; + let nodesService: NodesService; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error No need to implement all methods for mocking + cache = { + addSharedByMeNodeUid: jest.fn(), + removeSharedByMeNodeUid: jest.fn(), + setSharedWithMeNodeUids: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + getNode: jest.fn().mockResolvedValue({ uid: 'nodeUid' }), + }; + }); + + const testCases: { + title: string, + existingNodeUids: string[], + event: DriveEvent, + added: boolean, + removed: boolean, + }[] = [ + { + title: "should add if new own shared node is created", + existingNodeUids: [], + event: { + type: DriveEventType.NodeCreated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: true, + isOwnVolume: true, + }, + added: true, + removed: false, + }, + { + title: "should not add if new shared node is not own", + existingNodeUids: [], + event: { + type: DriveEventType.NodeCreated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: true, + isOwnVolume: false, + }, + added: false, + removed: false, + }, + { + title: "should not add if new own node is not shared", + existingNodeUids: [], + event: { + type: DriveEventType.NodeCreated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + isOwnVolume: true, + }, + added: false, + removed: false, + }, + { + title: "should add if own node is updated and shared", + existingNodeUids: [], + event: { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: true, + isOwnVolume: true, + }, + added: true, + removed: false, + }, + { + title: "should add/update if shared node is updated", + existingNodeUids: ["nodeUid"], + event: { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: true, + isOwnVolume: true, + }, + added: true, + removed: false, + }, + { + title: "should remove if shared node is un-shared", + existingNodeUids: ["nodeUid"], + event: { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + isOwnVolume: true, + }, + added: false, + removed: true, + }, + { + title: "should not remove if non-shared node is updated", + existingNodeUids: [], + event: { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + isOwnVolume: true, + }, + added: false, + removed: false, + }, + { + title: "should remove if shared node is deleted", + existingNodeUids: ["nodeUid"], + event: { + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isOwnVolume: true, + }, + added: false, + removed: true, + }, + { + title: "should not remove if non-shared node is deleted", + existingNodeUids: [], + event: { + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isOwnVolume: true, + }, + added: false, + removed: false, + }, + ]; + + describe("with listeners", () => { + testCases.map(({ title, existingNodeUids, event, added, removed }) => { + it(title, async () => { + cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(existingNodeUids); + const listener = jest.fn(); + const listeners = [{ type: SharingType.SharedByMe, callback: listener }]; + + await handleSharedByMeNodes(event, cache, listeners, nodesService); + + if (added) { + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); + expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); + } else { + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + } + if (removed) { + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); + expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); + } else { + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); + } + if (!added && !removed) { + expect(listener).not.toHaveBeenCalled(); + } + + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + }); + }); + + describe("without listeners", () => { + testCases.map(({ title, existingNodeUids, event, added, removed }) => { + it(title, async () => { + cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(existingNodeUids); + const listener = jest.fn(); + const listeners = [{ type: SharingType.sharedWithMe, callback: listener }]; + + await handleSharedByMeNodes(event, cache, listeners, nodesService); + + if (added) { + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); + } else { + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + } + if (removed) { + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); + } else { + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); + } + + expect(listener).not.toHaveBeenCalled(); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + }); + }); +}); + +describe("handleSharedWithMeNodes", () => { + let cache: SharingCache; + let sharingAccess: SharingAccess; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error No need to implement all methods for mocking + cache = { + getSharedWithMeNodeUids: jest.fn(), + setSharedWithMeNodeUids: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + sharingAccess = { + iterateSharedNodesWithMe: jest.fn(), + }; + }); + + it("should only update cache", async () => { + const event: DriveEvent = { + type: DriveEventType.ShareWithMeUpdated, + }; + + await handleSharedWithMeNodes(event, cache, [], sharingAccess); + + expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); + expect(cache.getSharedWithMeNodeUids).not.toHaveBeenCalled(); + expect(sharingAccess.iterateSharedNodesWithMe).not.toHaveBeenCalled(); + }); + + it("should update cache and notify listener", async () => { + cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(["nodeUid1", "nodeUid4"]); + sharingAccess.iterateSharedNodesWithMe = jest.fn().mockImplementation(async function* () { + yield { uid: "nodeUid1" }; + yield { uid: "nodeUid2" }; + yield { uid: "nodeUid3" }; + }); + const listener = jest.fn(); + const event: DriveEvent = { + type: DriveEventType.ShareWithMeUpdated, + }; + + await handleSharedWithMeNodes(event, cache, [{ type: SharingType.sharedWithMe, callback: listener }], sharingAccess); + + expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); + expect(cache.getSharedWithMeNodeUids).toHaveBeenCalled(); + expect(sharingAccess.iterateSharedNodesWithMe).toHaveBeenCalled(); + expect(listener).toHaveBeenCalledTimes(4); + expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid1', node: { uid: 'nodeUid1'} }); + expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid2', node: { uid: 'nodeUid2'} }); + expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid3', node: { uid: 'nodeUid3'} }); + expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid4' }); + }); +}); diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts new file mode 100644 index 00000000..d0a0bd5a --- /dev/null +++ b/js/sdk/src/internal/sharing/events.ts @@ -0,0 +1,144 @@ +import { NodeEventCallback, Logger } from "../../interface"; +import { DriveEventsService, DriveEvent, DriveEventType } from "../events"; +import { SharingCache } from "./cache"; +import { SharingType, NodesService } from "./interface"; +import { SharingAccess } from "./sharingAccess"; + +type Listeners = { + type: SharingType, + callback: NodeEventCallback, +}[]; + +/** + * Provides both event handling and subscription mechanism for user. + * + * The service is responsible for handling events regarding sharing listing + * from the DriveEventsService, and for providing a subscription mechanism + * for the user to listen to updates of specific group of nodes, such as + * any update to list of shared with me nodes. + */ +export class SharingEvents { + private listeners: Listeners = []; + + constructor(events: DriveEventsService, cache: SharingCache, nodesService: NodesService, sharingAccess: SharingAccess, log?: Logger) { + events.addListener(async (events) => { + for (const event of events) { + await handleSharedByMeNodes(event, cache, this.listeners, nodesService, log); + await handleSharedWithMeNodes(event, cache, this.listeners, sharingAccess); + } + }); + } + + subscribeToSharedNodesByMe(callback: NodeEventCallback) { + this.listeners.push({ type: SharingType.SharedByMe, callback }); + } + + subscribeToSharedNodesWithMe(callback: NodeEventCallback) { + this.listeners.push({ type: SharingType.sharedWithMe, callback }); + } +} + +/** + * Update cache and notify listeners accordingly for any updates + * to nodes that are shared by me. + * + * Any node create or update that is being shared, is automatically + * added to the cache and the listeners are notified about the + * update of the node. + * + * Any node delete or update that is not being shared, and the cache + * includes the node, is removed from the cache and the listeners are + * notified about the removal of the node. + * + * @throws Only if the client's callback throws. + */ +export async function handleSharedByMeNodes(event: DriveEvent, cache: SharingCache, listeners: Listeners, nodesService: NodesService, log?: Logger) { + if (event.type === DriveEventType.ShareWithMeUpdated || !event.isOwnVolume) { + return; + } + + const subscribedListeners = listeners.filter(({ type }) => type === SharingType.SharedByMe); + + if ([DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.NodeUpdatedMetadata].includes(event.type) && event.isShared) { + try { + await cache.addSharedByMeNodeUid(event.nodeUid); + } catch (error: unknown) { + log?.error(`Skipping shared by me node cache update: ${error}`); + } + let node; + try { + node = await nodesService.getNode(event.nodeUid); + } catch (error: unknown) { + log?.error(`Skipping shared by me node update event to listener: ${error}`); + return; + } + subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node })); + } + + if ( + ((event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) && !event.isShared) + || event.type === DriveEventType.NodeDeleted + ) { + let nodeWasShared = false; + try { + const cachedNodeUids = await cache.getSharedByMeNodeUids(); + nodeWasShared = cachedNodeUids.includes(event.nodeUid); + } catch { + // Cache can be empty. + } + + if (nodeWasShared) { + try { + await cache.removeSharedByMeNodeUid(event.nodeUid); + } catch (error: unknown) { + log?.error(`Skipping shared by me node cache remove: ${error}`); + } + subscribedListeners.forEach(({ callback }) => callback({ type: 'remove', uid: event.nodeUid })); + } + } +} + +/** + * Update cache and notify listeners accordingly for any updates + * to nodes that are shared with me. + * + * There is only one event type that is relevant for shared with me + * nodes, which is the ShareWithMeUpdated event. The event is triggered + * when the list of shared with me nodes is updated. + * + * The cache is cleared and re-populated fully when the client + * requests the list of shared with me, or is actively listening. + * + * If the client listenes to shared with me updates, the client receives + * update to the full list of shared with me nodes, including remove + * updates for nodes that are no longer shared with me, but was before. + * + * @throws Only if the client's callback throws. + */ +export async function handleSharedWithMeNodes(event: DriveEvent, cache: SharingCache, listeners: Listeners, sharingAccess: SharingAccess) { + if (event.type !== DriveEventType.ShareWithMeUpdated) { + return; + } + + let cachedNodeUids: string[] = []; + const subscribedListeners = listeners.filter(({ type }) => type === SharingType.sharedWithMe); + if (subscribedListeners.length) { + cachedNodeUids = await cache.getSharedWithMeNodeUids(); + } + + // Clearing the cache must be first, sharingAccess is no-op if cache is set. + await cache.setSharedWithMeNodeUids(undefined); + + if (subscribedListeners.length) { + const nodeUids = []; + for await (const node of sharingAccess.iterateSharedNodesWithMe()) { + nodeUids.push(node.uid); + subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node })); + } + for (const nodeUid of cachedNodeUids) { + if (!nodeUids.includes(nodeUid)) { + subscribedListeners.forEach(({ callback }) => callback({ type: 'remove', uid: nodeUid })); + } + } + } +} diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index 18d41a5d..c24cdec8 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -1,28 +1,37 @@ -import { ProtonDriveAccount, ShareNodeSettings, ShareRole, ShareResult, UnshareNodeSettings } from "../../interface/index.js"; -import { DriveCrypto } from '../../crypto/index.js'; -import { DriveAPIService } from "../apiService/index.js"; -import { sharingAPIService } from "./apiService.js"; -import { sharingCryptoService } from "./cryptoService.js"; -import { sharingAccess } from "./sharingAccess.js"; -import { sharingManagement } from "./sharingManagement.js"; -import { NodesService } from "./interface.js"; +import { ProtonDriveAccount, ShareNodeSettings, ShareRole, ShareResult, UnshareNodeSettings, ProtonDriveEntitiesCache, Logger } from "../../interface"; +import { DriveCrypto } from '../../crypto'; +import { DriveAPIService } from "../apiService"; +import { DriveEventsService } from "../events"; +import { SharingAPIService } from "./apiService"; +import { SharingCache } from "./cache"; +import { SharingCryptoService } from "./cryptoService"; +import { SharingEvents } from "./events"; +import { SharingAccess } from "./sharingAccess"; +import { SharingManagement } from "./sharingManagement"; +import { SharesService, NodesService } from "./interface"; -export function sharing( +export function initSharingModule( apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, account: ProtonDriveAccount, crypto: DriveCrypto, + driveEvents: DriveEventsService, + sharesService: SharesService, nodesService: NodesService, + log?: Logger, ) { - const api = sharingAPIService(apiService); - const cryptoService = sharingCryptoService(crypto, account); - const sharingAccessFunctions = sharingAccess(api, cryptoService, nodesService); - const sharingManagementFunctions = sharingManagement(api, cryptoService, account); + const api = new SharingAPIService(apiService); + const cache = new SharingCache(driveEntitiesCache); + const cryptoService = new SharingCryptoService(crypto, account); + const sharingAccess = new SharingAccess(api, cache, sharesService, nodesService); + const sharingEvents = new SharingEvents(driveEvents, cache, nodesService, sharingAccess, log); + const sharingManagement = new SharingManagement(api, cryptoService, account); // TODO: facade to convert high-level interface with object to low-level calls async function shareNode(nodeUid: string, settings: ShareNodeSettings) { - let currentSharing = await sharingManagementFunctions.getSharingInfo(nodeUid); + let currentSharing = await sharingManagement.getSharingInfo(nodeUid); if (!currentSharing) { - currentSharing = await sharingManagementFunctions.createShare(nodeUid); + currentSharing = await sharingManagement.createShare(nodeUid); } for (const user of settings.protonUsers || []) { @@ -31,40 +40,40 @@ export function sharing( if (currentSharing.protonInitations[email].role === role) { continue; } - sharingManagementFunctions.updateInvitationPermissions(currentSharing.shareId, currentSharing.protonUsers[email].invitationId, role); + sharingManagement.updateInvitationPermissions(currentSharing.shareId, currentSharing.protonUsers[email].invitationId, role); continue; } - sharingManagementFunctions.inviteProtonUser(currentSharing.shareId, email, role); + sharingManagement.inviteProtonUser(currentSharing.shareId, email, role); } // TODO: return all the objects return {} as ShareResult; } async function unshareNode(nodeUid: string, settings?: UnshareNodeSettings) { - const currentSharing = await sharingManagementFunctions.getSharingInfo(nodeUid); + const currentSharing = await sharingManagement.getSharingInfo(nodeUid); if (!currentSharing) { return; } if (!settings) { - return sharingManagementFunctions.deleteShare(currentSharing.shareId); + return sharingManagement.deleteShare(currentSharing.shareId); } if (settings.publicLink === 'remove') { - await sharingManagementFunctions.removeSharedLink(currentSharing.shareId); + await sharingManagement.removeSharedLink(currentSharing.shareId); } for (const user of settings.users || []) { const invitationId = currentSharing.protonInitations[user]?.invitationId; if (invitationId) { - sharingManagementFunctions.deleteInvitation(currentSharing.shareId, invitationId); + sharingManagement.deleteInvitation(currentSharing.shareId, invitationId); continue; } const externalInvitationId = currentSharing.nonProtonInvitations[user]?.invitationId; if (externalInvitationId) { - sharingManagementFunctions.deleteExternalInvitation(currentSharing.shareId, externalInvitationId); + sharingManagement.deleteExternalInvitation(currentSharing.shareId, externalInvitationId); continue; } const memberId = currentSharing.members[user]?.memberId; if (memberId) { - sharingManagementFunctions.removeMember(currentSharing.shareId, memberId); + sharingManagement.removeMember(currentSharing.shareId, memberId); continue; } } @@ -73,8 +82,9 @@ export function sharing( } return { - ...sharingAccessFunctions, + access: sharingAccess, + events: sharingEvents, shareNode, unshareNode, - } + }; } diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index c140d552..5fe7f19d 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -1,3 +1,44 @@ +import { NodeEntity, NodeType, MemberRole } from "../../interface"; + +export enum SharingType { + SharedByMe = 'sharedByMe', + sharedWithMe = 'sharedWithMe', +} + +export interface EncryptedBookmark { + tokenId: string; + createdDate: Date; + share: { + armoredKey: string; + armoredPassphrase: string; + }; + url: { + encryptedUrlPassword?: string; + base64SharePasswordSalt: string; + }; + node: { + type: NodeType; + mimeType?: string; + encryptedName: string; + armoredKey: string; + armoredNodePassphrase: string; + file: { + base64ContentKeyPacket?: string; + }; + }; +} + +/** + * Interface describing the dependencies to the shares module. + */ +export interface SharesService { + getMyFilesIDs(): Promise<{ volumeId: string }>, +} + +/** + * Interface describing the dependencies to the nodes module. + */ export interface NodesService { - getNode(nodeUid: string): Promise, + getNode(nodeUid: string): Promise, + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts new file mode 100644 index 00000000..cbdc4feb --- /dev/null +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -0,0 +1,96 @@ +import { SharingAPIService } from "./apiService"; +import { SharingCache } from "./cache"; +import { SharesService, NodesService } from "./interface"; + +import { SharingAccess } from "./sharingAccess"; + +describe("SharingAccess", () => { + let apiService: SharingAPIService; + let cache: SharingCache; + let sharesService: SharesService; + let nodesService: NodesService; + + let sharingAccess: SharingAccess; + + const nodeUids = Array.from({ length: 15 }, (_, i) => `nodeUid${i}`); + const nodes = nodeUids.map((nodeUid) => ({ nodeUid })); + const nodeUidsIterator = async function* () { + for (const nodeUid of nodeUids) { + yield nodeUid; + } + } + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = { + iterateSharedNodeUids: jest.fn().mockImplementation(() => nodeUidsIterator()), + iterateSharedWithMeNodeUids: jest.fn().mockImplementation(() => nodeUidsIterator()), + } + // @ts-expect-error No need to implement all methods for mocking + cache = { + setSharedByMeNodeUids: jest.fn(), + setSharedWithMeNodeUids: jest.fn(), + } + sharesService = { + getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: "volumeId" }), + } + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + iterateNodes: jest.fn().mockImplementation(async function* (nodeUids) { + for (const node of nodes) { + if (nodeUids.includes(node.nodeUid)) { + yield node; + } + } + }), + } + + sharingAccess = new SharingAccess(apiService, cache, sharesService, nodesService); + }); + + describe("iterateSharedNodes", () => { + it("should iterate from cache", async () => { + cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(nodeUids); + + const result = await Array.fromAsync(sharingAccess.iterateSharedNodes()); + + expect(result).toEqual(nodes); + expect(apiService.iterateSharedNodeUids).not.toHaveBeenCalled(); + expect(cache.setSharedByMeNodeUids).not.toHaveBeenCalled(); + }); + + it("should iterate from API", async () => { + cache.getSharedByMeNodeUids = jest.fn().mockRejectedValue(new Error('Not cached')); + + const result = await Array.fromAsync(sharingAccess.iterateSharedNodes()); + + expect(result).toEqual(nodes); + expect(apiService.iterateSharedNodeUids).toHaveBeenCalledWith("volumeId", undefined); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // 15 / 10 per batch + expect(cache.setSharedByMeNodeUids).toHaveBeenCalledWith(nodeUids); + }); + }); + + describe("iterateSharedNodesWithMe", () => { + it("should iterate from cache", async () => { + cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(nodeUids); + + const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe()); + + expect(result).toEqual(nodes); + expect(apiService.iterateSharedWithMeNodeUids).not.toHaveBeenCalled(); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it("should iterate from API", async () => { + cache.getSharedWithMeNodeUids = jest.fn().mockRejectedValue(new Error('Not cached')); + + const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe()); + + expect(result).toEqual(nodes); + expect(apiService.iterateSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // 15 / 10 per batch + expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(nodeUids); + }); + }); +}); diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 8476104d..151e2fb1 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -1,43 +1,79 @@ -import { ProtonDriveAccount } from "../../interface/index.js"; -import { sharingAPIService } from "./apiService.js"; -import { sharingCryptoService } from "./cryptoService.js"; -import { NodesService } from "./interface.js"; +import { NodeEntity } from "../../interface"; +import { BatchLoading } from "../batchLoading"; +import { SharingAPIService } from "./apiService"; +import { SharingCache } from "./cache"; +import { SharesService, NodesService } from "./interface"; -export function sharingAccess( - apiService: ReturnType, - cryptoService: ReturnType, - nodesService: NodesService, -) { - async function* iterateSharedNodes() { - // TODO: get volume from shares module - const volumeId = 'myFiles'; - for await (const sharedNode of apiService.iterateSharedNodes(volumeId)) { - yield await nodesService.getNode(sharedNode.nodeUid); +export class SharingAccess { + constructor( + private apiService: SharingAPIService, + private cache: SharingCache, + private sharesService: SharesService, + private nodesService: NodesService, + ) { + this.apiService = apiService; + this.cache = cache; + this.sharesService = sharesService; + this.nodesService = nodesService; + } + + async* iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { + try { + const nodeUids = await this.cache.getSharedByMeNodeUids(); + yield* this.iterateSharedNodesFromCache(nodeUids, signal); + } catch { + const { volumeId } = await this.sharesService.getMyFilesIDs(); + const nodeUidsIterator = this.apiService.iterateSharedNodeUids(volumeId, signal); + yield* this.iterateSharedNodesFromAPI(nodeUidsIterator, (nodeUids) => this.cache.setSharedByMeNodeUids(nodeUids), signal); + } + } + + async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { + try { + const nodeUids = await this.cache.getSharedWithMeNodeUids(); + yield* this.iterateSharedNodesFromCache(nodeUids, signal); + } catch { + const nodeUidsIterator = this.apiService.iterateSharedWithMeNodeUids(signal); + yield* this.iterateSharedNodesFromAPI(nodeUidsIterator, (nodeUids) => this.cache.setSharedWithMeNodeUids(nodeUids), signal); } } - async function* iterateSharedNodesWithMe() { - for await (const sharedNode of apiService.iterateSharedWithMe()) { - yield await nodesService.getNode(sharedNode.nodeUid); + private async* iterateSharedNodesFromCache(nodeUids: string[], signal?: AbortSignal) { + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.nodesService.iterateNodes(nodeUids, signal) }); + for (const nodeUid of nodeUids) { + yield* batchLoading.load(nodeUid); } + yield* batchLoading.loadRest(); } - async function* iterateInvitations() { - for await (const invitation of apiService.iterateInvitations()) { - yield invitation; + private async* iterateSharedNodesFromAPI( + nodeUidsIterator: AsyncGenerator, + setCache: (nodeUids: string[]) => Promise, + signal?: AbortSignal, + ): AsyncGenerator { + const loadedNodeUids = []; + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.nodesService.iterateNodes(nodeUids, signal) }); + for await (const nodeUid of nodeUidsIterator) { + loadedNodeUids.push(nodeUid); + yield* batchLoading.load(nodeUid); } + yield* batchLoading.loadRest(); + // Set cache only at the end. Once there is anything in the cache, + // it will be used instead of requesting the data from the API. + await setCache(loadedNodeUids); } - async function* iterateSharedBookmarks() { - for await (const bookmark of apiService.iterateBookmarks()) { - yield bookmark; + // TODO: return decrypted invitations + async* iterateInvitations(signal?: AbortSignal): AsyncGenerator { + for await (const invitationUid of this.apiService.iterateInvitationUids(signal)) { + yield invitationUid; } } - return { - iterateSharedNodes, - iterateSharedNodesWithMe, - iterateInvitations, - iterateSharedBookmarks, + // TODO: return decrypted bookmarks + async* iterateSharedBookmarks(signal?: AbortSignal): AsyncGenerator { + for await (const bookmark of this.apiService.iterateBookmarks(signal)) { + yield bookmark.tokenId; + } } } diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index f422d559..920be95a 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -1,61 +1,46 @@ -import { ProtonDriveAccount, ShareRole } from "../../interface/index.js"; -import { sharingAPIService } from "./apiService.js"; -import { sharingCryptoService } from "./cryptoService.js"; +import { ProtonDriveAccount, ShareRole } from "../../interface"; +import { SharingAPIService } from "./apiService"; +import { SharingCryptoService } from "./cryptoService"; -export function sharingManagement( - apiService: ReturnType, - cryptoService: ReturnType, - account: ProtonDriveAccount, -) { - async function createShare(nodeUid: string): Promise {} - async function deleteShare(shareId: string): Promise {} - async function getSharingInfo(shareId: string): Promise {} +export class SharingManagement { + constructor( + private apiService: SharingAPIService, + private cryptoService: SharingCryptoService, + private account: ProtonDriveAccount, + ) { + this.apiService = apiService; + this.cryptoService = cryptoService; + this.account = account; + } + + async createShare(nodeUid: string): Promise {} + async deleteShare(shareId: string): Promise {} + async getSharingInfo(shareId: string): Promise {} // Direct invitations - async function inviteProtonUser(shareId: string, email: string, role: ShareRole): Promise { - const invitation = await cryptoService.encryptInvitation(email); - await apiService.inviteProtonUser({ invitation }); + async inviteProtonUser(shareId: string, email: string, role: ShareRole): Promise { + const invitation = await this.cryptoService.encryptInvitation(email); + await this.apiService.inviteProtonUser({ invitation }); } - async function updateInvitationPermissions(shareId: string, invitationId: string, role: ShareRole): Promise {} - async function resendInvitationEmail(shareId: string, invitationId: string): Promise {} - async function deleteInvitation(shareId: string, invitationId: string): Promise {} - + async updateInvitationPermissions(shareId: string, invitationId: string, role: ShareRole): Promise {} + async resendInvitationEmail(shareId: string, invitationId: string): Promise {} + async deleteInvitation(shareId: string, invitationId: string): Promise {} + // Direct external invitations - async function inviteExternalUser(shareId: string, email: string, role: ShareRole): Promise {} - async function updateExternalInvitationPermissions(shareId: string, invitationId: string, role: ShareRole): Promise {} - async function resendExternalInvitationEmail(shareId: string, invitationId: string): Promise {} - async function deleteExternalInvitation(shareId: string, invitationId: string): Promise {} + async inviteExternalUser(shareId: string, email: string, role: ShareRole): Promise {} + async updateExternalInvitationPermissions(shareId: string, invitationId: string, role: ShareRole): Promise {} + async resendExternalInvitationEmail(shareId: string, invitationId: string): Promise {} + async deleteExternalInvitation(shareId: string, invitationId: string): Promise {} - async function convertExternalInvitationsToInternal(): Promise {} + async convertExternalInvitationsToInternal(): Promise {} // Direct members - async function removeMember(shareId: string, memberId: string): Promise {} - async function updateMemberPermissions(shareId: string, memberId: string): Promise {} + async removeMember(shareId: string, memberId: string): Promise {} + async updateMemberPermissions(shareId: string, memberId: string): Promise {} // For URL - async function shareViaLink(nodeUid: string): Promise {} - async function updateSharedLink(nodeUid: string, options: any): Promise {} - async function getPublicLink(nodeUid: string): Promise {} - async function removeSharedLink(nodeUid: string): Promise {} - - return { - createShare, - deleteShare, - getSharingInfo, - inviteProtonUser, - updateInvitationPermissions, - resendInvitationEmail, - deleteInvitation, - inviteExternalUser, - updateExternalInvitationPermissions, - resendExternalInvitationEmail, - deleteExternalInvitation, - convertExternalInvitationsToInternal, - removeMember, - updateMemberPermissions, - shareViaLink, - updateSharedLink, - getPublicLink, - removeSharedLink, - } + async shareViaLink(nodeUid: string): Promise {} + async updateSharedLink(nodeUid: string, options: any): Promise {} + async getPublicLink(nodeUid: string): Promise {} + async removeSharedLink(nodeUid: string): Promise {} } diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index 5eb4be98..7ec56870 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -11,3 +11,7 @@ export function splitNodeUid(nodeUid: string) { nodeId: nodeId.slice('node:'.length), }; } + +export function makeInvitationUid(volumeId: string, invitationId: string) { + return `volume:${volumeId};invitation:${invitationId}`; +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 5dbda495..30e3514e 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -3,7 +3,7 @@ import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, Node import { DriveCrypto } from './crypto'; import { initSharesModule } from './internal/shares'; import { initNodesModule } from './internal/nodes'; -import { sharing as sharingModule } from './internal/sharing'; +import { initSharingModule } from './internal/sharing'; import { DriveEventsService } from './internal/events'; import { upload as uploadModule } from './internal/upload'; import { getConfig } from './config'; @@ -11,7 +11,7 @@ import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterato export class ProtonDriveClient implements Partial { private nodes: ReturnType; - private sharing: ReturnType; + private sharing: ReturnType; private upload: ReturnType; constructor({ @@ -38,7 +38,7 @@ export class ProtonDriveClient implements Partial { const events = new DriveEventsService(apiService, entitiesCache, getLogger?.('events')); const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); - this.sharing = sharingModule(apiService, account, cryptoModule, this.nodes.access); + this.sharing = initSharingModule(apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access, getLogger?.('sharing')); this.upload = uploadModule(apiService, cryptoModule, this.nodes.access); } @@ -49,19 +49,19 @@ export class ProtonDriveClient implements Partial { } async getMyFilesRootFolder() { - return convertInternalNodePromise(this.nodes.management.getMyFilesRootFolder()); + return convertInternalNodePromise(this.nodes.access.getMyFilesRootFolder()); } async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { - yield* convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); + yield* convertInternalNodeIterator(this.nodes.access.iterateChildren(getUid(parentNodeUid), signal)); } async* iterateTrashedNodes(signal?: AbortSignal) { - yield* convertInternalNodeIterator(this.nodes.management.iterateTrashedNodes(signal)); + yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { - yield* convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); + yield* convertInternalNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } async renameNode(nodeUid: NodeOrUid, newName: string) { @@ -88,6 +88,14 @@ export class ProtonDriveClient implements Partial { return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name)); } + async* iterateSharedNodes(signal?: AbortSignal) { + return convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); + } + + async* iterateSharedNodesWithMe(signal?: AbortSignal) { + return convertInternalNodeIterator(this.sharing.access.iterateSharedNodesWithMe(signal)); + } + async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings) { return this.sharing.shareNode(getUid(nodeUid), settings); } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 8f128a20..50a4d5c4 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -1,5 +1,44 @@ +import { DriveAPIService } from './internal/apiService'; +import { ProtonDriveClientContructorParameters } from './interface'; +import { DriveCrypto } from './crypto'; +import { initSharesModule } from './internal/shares'; +import { initNodesModule } from './internal/nodes'; +import { initPhotosModule } from './internal/photos'; +import { DriveEventsService } from './internal/events'; +import { getConfig } from './config'; + // TODO: this is only example, on background it use drive internals, but it exposes nice interface for photos export class ProtonDrivePhotosClient { + private nodes: ReturnType; + private photos: ReturnType; + + constructor({ + httpClient, + entitiesCache, + cryptoCache, + account, + getLogger, + config, + metrics, // eslint-disable-line @typescript-eslint/no-unused-vars + openPGPCryptoModule, + acceptNoGuaranteeWithCustomModules, + }: ProtonDriveClientContructorParameters) { + if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { + // TODO: define errors and use here + throw Error('TODO'); + } + const cryptoModule = new DriveCrypto(openPGPCryptoModule); + + const fullConfig = getConfig(config); + + const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + + const events = new DriveEventsService(apiService, entitiesCache, getLogger?.('events')); + const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); + this.nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); + this.photos = initPhotosModule(apiService, entitiesCache, this.nodes.access); + } + // Timeline or album view iterateTimelinePhotos() {} // returns only UIDs and dates - used to show grid and scrolling iterateAlbumPhotos() {} // same as above but for album @@ -7,7 +46,9 @@ export class ProtonDrivePhotosClient { getPhoto() {} // returns full photo details // Album management - createAlbum() {} + createAlbum(albumName: string) { + return this.photos.albums.createAlbum(albumName); + } renameAlbum() {} shareAlbum() {} deleteAlbum() {} diff --git a/js/sdk/src/protonDrivePublicClient.ts b/js/sdk/src/protonDrivePublicClient.ts index 5cb120b1..ab134a48 100644 --- a/js/sdk/src/protonDrivePublicClient.ts +++ b/js/sdk/src/protonDrivePublicClient.ts @@ -44,10 +44,10 @@ export class ProtonDrivePublicClient implements ProtonDrivePublicClientInterface } async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { - yield* convertInternalNodeIterator(this.nodes.management.iterateChildren(getUid(parentNodeUid), signal)); + yield* convertInternalNodeIterator(this.nodes.access.iterateChildren(getUid(parentNodeUid), signal)); } async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { - yield* convertInternalNodeIterator(this.nodes.management.iterateNodes(getUids(nodeUids), signal)); + yield* convertInternalNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } } diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 1edda900..411ef27f 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,6 +1,22 @@ import { NodeOrUid, NodeEntity as PublicNode } from './interface'; import { DecryptedNode as InternalNode } from './internal/nodes'; +type InternalPartialNode = Pick< + InternalNode, + 'uid' | + 'parentUid' | + 'name' | + 'keyAuthor' | + 'nameAuthor' | + 'directMemberRole' | + 'type' | + 'mimeType' | + 'isShared' | + 'createdDate' | + 'trashedDate' | + 'activeRevision' +>; + export function getUid(nodeUid: NodeOrUid): string { if (typeof nodeUid === "string") { return nodeUid; @@ -12,18 +28,18 @@ export function getUids(nodeUids: NodeOrUid[]): string[] { return nodeUids.map(getUid); } -export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { +export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { for await (const node of nodeIterator) { yield convertInternalNode(node); } } -export async function convertInternalNodePromise(nodePromise: Promise): Promise { +export async function convertInternalNodePromise(nodePromise: Promise): Promise { const node = await nodePromise; return convertInternalNode(node); } -export function convertInternalNode(node: InternalNode): PublicNode { +export function convertInternalNode(node: InternalPartialNode): PublicNode { return { uid: node.uid, parentUid: node.parentUid, From 46547496ecfdae1d7ab9214951f83de455b67b36 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 17 Feb 2025 12:45:47 +0000 Subject: [PATCH 015/791] Add caching for CLI --- .gitignore | 1 + js/sdk/src/internal/nodes/cache.test.ts | 10 ++++++ js/sdk/src/internal/nodes/cache.ts | 17 ++++++++++ js/sdk/src/internal/nodes/events.test.ts | 7 ++++ js/sdk/src/internal/nodes/events.ts | 5 ++- js/sdk/src/internal/nodes/nodesAccess.test.ts | 14 ++++++++ js/sdk/src/internal/nodes/nodesAccess.ts | 33 ++++++++++++++----- 7 files changed, 77 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 379a21ee..e48b4fab 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tsconfig.tsbuildinfo # JS CLI js/cli/proton-drive auth.txt +cache*.sqlite diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 7c3c3dff..7b1d370c 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -165,4 +165,14 @@ describe('nodesCache', () => { const nodeUids = result.map(({ uid }) => uid); expect(nodeUids).toStrictEqual(['node1b', 'node1c-beta', 'node2b']); }); + + it('should set and unset children loaded state', async () => { + expect(await cache.isFolderChildrenLoaded('node1')).toBe(false); + + await cache.setFolderChildrenLoaded('node1'); + expect(await cache.isFolderChildrenLoaded('node1')).toBe(true); + + await cache.resetFolderChildrenLoaded('node1'); + expect(await cache.isFolderChildrenLoaded('node1')).toBe(false); + }); }); \ No newline at end of file diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index a995b54c..b342801c 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -162,6 +162,23 @@ export class NodesCache { }; } } + + async setFolderChildrenLoaded(nodeUid: string): Promise { + this.driveCache.setEntity(`node-children-${nodeUid}`, 'loaded'); + } + + async resetFolderChildrenLoaded(nodeUid: string): Promise { + await this.driveCache.removeEntities([`node-children-${nodeUid}`]); + } + + async isFolderChildrenLoaded(nodeUid: string): Promise { + try { + await this.driveCache.getEntity(`node-children-${nodeUid}`); + return true; + } catch { + return false; + } + } } function getCacheUid(nodeUid: string) { diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index 996bb8b3..ee86e3c1 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -15,6 +15,7 @@ describe("updateCacheByEvent", () => { getNode: jest.fn(), setNode: jest.fn(), removeNodes: jest.fn(), + resetFolderChildrenLoaded: jest.fn(), }; }); @@ -34,6 +35,12 @@ describe("updateCacheByEvent", () => { expect(cache.getNode).toHaveBeenCalledTimes(0); expect(cache.setNode).toHaveBeenCalledTimes(0); }); + + it("should reset parent loaded state", async () => { + await updateCacheByEvent(event, cache); + + expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); + }); }); describe('NodeUpdated event', () => { diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 60fa9a14..511d2d22 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -86,7 +86,10 @@ export async function updateCacheByEvent(event: DriveEvent, cache: NodesCache, l // decrypt the node immediately. The node will be fetched and // decrypted when requested by the client. if (event.type === DriveEventType.NodeCreated) { - log?.debug(`Skipping node create event`); + // We do not have partial nodes in the cache, so we don't + // add it. If new node is not added, we need to reset the + // children loaded flag to force refetch when requested. + await cache.resetFolderChildrenLoaded(event.parentNodeUid); } if (event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) { let node; diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 59ca680e..69a8ae25 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -116,4 +116,18 @@ describe('nodesAccess', () => { expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); }); }); + + describe('getNodeKeys', () => { + it('should load node if not cached', async () => { + cryptoCache.getNodeKeys = jest.fn(() => Promise.reject(new Error('Entity not found'))); + apiService.getNode = jest.fn(() => Promise.reject(new Error('API called'))); + + try { + await access.getNodeKeys('nodeId'); + throw new Error('Expected error'); + } catch (error: unknown) { + expect(`${error}`).toBe('Error: API called'); + } + }); + }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index a5dd6c51..8b08fa85 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -47,12 +47,25 @@ export class NodesAccess { return node; } - // Improvement requested: keep status of loaded children and leverage cache. async *iterateChildren(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { // Ensure the parent is loaded and up-to-date. const parentNode = await this.getNode(parentNodeUid); - const batchLoading = new BatchLoading({ loadItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + + const areChildrenCached = await this.cache.isFolderChildrenLoaded(parentNodeUid); + if (areChildrenCached) { + for await (const node of this.cache.iterateChildren(parentNodeUid)) { + if (node.ok && !node.node.isStale) { + yield node.node; + } else { + yield* batchLoading.load(node.uid); + } + } + yield* batchLoading.loadRest(); + return; + } + for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { let node; try { @@ -66,12 +79,13 @@ export class NodesAccess { } } yield* batchLoading.loadRest(); + await this.cache.setFolderChildrenLoaded(parentNodeUid); } // Improvement requested: keep status of loaded trash and leverage cache. async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.shareService.getMyFilesIDs(); - const batchLoading = new BatchLoading({ loadItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { let node; try { @@ -88,7 +102,7 @@ export class NodesAccess { } async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const batchLoading = new BatchLoading({ loadItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); for await (const result of this.cache.iterateNodes(nodeUids)) { if (result.ok && !result.node.isStale) { yield result.node; @@ -104,11 +118,12 @@ export class NodesAccess { return this.decryptNode(encryptedNode); } - private async loadNodes(nodeUids: string[], signal?: AbortSignal): Promise { - // TODO: batching + private async* loadNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const encryptedNodes = await this.apiService.getNodes(nodeUids, signal); - const results = await Promise.all(encryptedNodes.map((encryptedNode) => this.decryptNode(encryptedNode))); - return results.map(({ node }) => node); + for (const encryptedNode of encryptedNodes) { + const { node } = await this.decryptNode(encryptedNode); + yield node; + } } private async decryptNode(encryptedNode: EncryptedNode): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { @@ -136,7 +151,7 @@ export class NodesAccess { async getNodeKeys(nodeUid: string): Promise { try { - return this.cryptoCache.getNodeKeys(nodeUid); + return await this.cryptoCache.getNodeKeys(nodeUid); } catch { const { keys } = await this.loadNode(nodeUid); if (!keys) { From ed6fc76fdeb22e834555eaff8b82e604894e19b0 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 17 Feb 2025 12:52:53 +0000 Subject: [PATCH 016/791] Add error handling in API service --- .../internal/apiService/apiService.test.ts | 217 ++++++++++++++++++ js/sdk/src/internal/apiService/apiService.ts | 211 +++++++++++++++-- js/sdk/src/internal/apiService/errorCodes.ts | 6 + js/sdk/src/internal/apiService/errors.test.ts | 56 +++++ js/sdk/src/internal/apiService/errors.ts | 16 +- js/sdk/src/internal/apiService/wait.ts | 7 + 6 files changed, 492 insertions(+), 21 deletions(-) create mode 100644 js/sdk/src/internal/apiService/apiService.test.ts create mode 100644 js/sdk/src/internal/apiService/errors.test.ts create mode 100644 js/sdk/src/internal/apiService/wait.ts diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts new file mode 100644 index 00000000..00038560 --- /dev/null +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -0,0 +1,217 @@ +import { ProtonDriveHTTPClient } from "../../interface/index.js"; +import { DriveAPIService } from './apiService'; +import { HTTPErrorCode, ErrorCode } from './errorCodes'; + +jest.useFakeTimers(); + +function generateOkResponse() { + return new Response(JSON.stringify({ Code: ErrorCode.OK }), { status: HTTPErrorCode.OK }); +} + +describe("DriveAPIService", () => { + let httpClient: ProtonDriveHTTPClient; + let api: DriveAPIService; + + beforeEach(() => { + jest.runAllTimersAsync(); + + httpClient = { + fetch: jest.fn(() => Promise.resolve(generateOkResponse())), + }; + api = new DriveAPIService(httpClient, 'http://drive.proton.me', 'en'); + }) + + describe("should make", () => { + it("GET request", async () => { + const result = await api.get('test'); + expect(result).toEqual({ Code: ErrorCode.OK }); + await expectToBeCalledWith('GET'); + }); + + it("POST request", async () => { + const result = await api.post('test', { data: 'test' }); + expect(result).toEqual({ Code: ErrorCode.OK }); + await expectToBeCalledWith('POST', { data: 'test' }); + }); + + it("PUT request", async () => { + const result = await api.put('test', { data: 'test' }); + expect(result).toEqual({ Code: ErrorCode.OK }); + await expectToBeCalledWith('PUT', { data: 'test' }); + }); + + async function expectToBeCalledWith(method: string, data?: object) { + // @ts-expect-error: Fetch is mock. + const request = httpClient.fetch.mock.calls[0][0]; + expect(request.method).toEqual(method); + expect(Array.from(request.headers.entries())).toEqual(Array.from(new Headers({ + "Accept": "application/vnd.protonmail.v1+json", + "Content-Type": "application/json", + "Language": 'en', + }).entries())); + expect(await request.text()).toEqual(data ? JSON.stringify(data) : ""); + } + }); + + describe("should throw", () => { + it("APIHTTPError on 4xx response without JSON body", async () => { + httpClient.fetch = jest.fn(() => Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not found' }))); + await expect(api.get('test')).rejects.toThrow(new Error('Not found')); + }); + + it("APIError on 4xx response with JSON body", async () => { + httpClient.fetch = jest.fn(() => Promise.resolve(new Response(JSON.stringify({ Code: 42, Error: 'General error' }), { status: 422 }))); + await expect(api.get('test')).rejects.toThrow('General error'); + }); + }); + + describe("should retry", () => { + it("on offline error", async () => { + const error = new Error('Network offline'); + error.name = 'OfflineError'; + httpClient.fetch = jest.fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(generateOkResponse()); + + const result = api.get('test'); + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expect(httpClient.fetch).toHaveBeenCalledTimes(3); + }); + + it("on timeout error", async () => { + const error = new Error('Timeouted'); + error.name = 'TimeoutError'; + httpClient.fetch = jest.fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(generateOkResponse()); + + const result = api.get('test'); + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expect(httpClient.fetch).toHaveBeenCalledTimes(3); + }); + + it("on general error", async () => { + httpClient.fetch = jest.fn() + .mockRejectedValueOnce(new Error('Error')) + .mockResolvedValueOnce(generateOkResponse()); + + const result = api.get('test'); + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expect(httpClient.fetch).toHaveBeenCalledTimes(2); + }); + + it("only once on general error", async () => { + httpClient.fetch = jest.fn() + .mockRejectedValueOnce(new Error('First error')) + .mockRejectedValueOnce(new Error('Second error')) + .mockResolvedValueOnce(generateOkResponse()); + + const result = api.get('test'); + + await expect(result).rejects.toThrow("Second error"); + expect(httpClient.fetch).toHaveBeenCalledTimes(2); + }); + + it("on 429 response", async () => { + httpClient.fetch = jest.fn() + .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })) + .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })) + .mockResolvedValueOnce(generateOkResponse()); + + const result = api.get('test'); + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expect(httpClient.fetch).toHaveBeenCalledTimes(3); + }); + + it("on 5xx response", async () => { + httpClient.fetch = jest.fn() + .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })) + .mockResolvedValueOnce(generateOkResponse()); + + const result = api.get('test'); + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expect(httpClient.fetch).toHaveBeenCalledTimes(2); + }); + + it("only once on 5xx response", async () => { + httpClient.fetch = jest.fn() + .mockResolvedValue(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })); + + const result = api.get('test'); + + await expect(result).rejects.toThrow("Some error"); + expect(httpClient.fetch).toHaveBeenCalledTimes(2); + }); + }); + + describe("should handle subsequent errors", () => { + it("limit 429 errors", async () => { + httpClient.fetch = jest.fn() + .mockResolvedValue(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })); + + for (let i = 0; i < 20; i++) { + await api.get('test').catch(() => {}); + } + + await expect(api.get('test')).rejects.toThrow("Too many requests limit reached"); + expect(httpClient.fetch).toHaveBeenCalledTimes(50); + }); + + it("do not limit 429s when some pass", async () => { + let attempt = 0; + httpClient.fetch = jest.fn() + .mockImplementation(() => { + if (attempt++ % 5 === 0) { + return generateOkResponse(); + } + return new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' }); + }); + + for (let i = 0; i < 20; i++) { + await api.get('test').catch(() => {}); + } + + await expect(api.get('test')).resolves.toEqual({ Code: ErrorCode.OK }); + // 20 calls * 5 retries till OK response + 1 last successful call + expect(httpClient.fetch).toHaveBeenCalledTimes(101); + }); + + it("limit server errors", async () => { + httpClient.fetch = jest.fn() + .mockResolvedValue(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })); + + for (let i = 0; i < 20; i++) { + await api.get('test').catch(() => {}); + } + + await expect(api.get('test')).rejects.toThrow("Server errors limit reached"); + expect(httpClient.fetch).toHaveBeenCalledTimes(10); + }); + + it("do not limit server errors when some pass", async () => { + let attempt = 0; + httpClient.fetch = jest.fn() + .mockImplementation(() => { + if (attempt++ % 5 === 0) { + return generateOkResponse(); + } + return new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' }); + }); + + for (let i = 0; i < 20; i++) { + await api.get('test').catch(() => {}); + } + + await expect(api.get('test')).rejects.toThrow("Some error"); + // 15 erroring calls * 2 attempts + 5 successful calls + expect(httpClient.fetch).toHaveBeenCalledTimes(35); + }); + }); +}); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 7f5359c7..33c6767a 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -1,14 +1,78 @@ import { ProtonDriveHTTPClient, Logger } from "../../interface/index.js"; -import { ErrorCode } from './errorCodes'; -import { apiErrorFactory, APIError } from './errors'; +import { HTTPErrorCode, ErrorCode } from './errorCodes'; +import { apiErrorFactory, AbortError, APIError } from './errors'; +import { waitSeconds } from './wait'; + +/** + * How many subsequent 429 errors are allowed before we stop further requests. + */ +const TOO_MANY_SUBSEQUENT_429_ERRORS = 50; + +/** + * For how long the API service should cool down after reaching the limit + * of subsequent 429 errors. + */ +const TOO_MANY_SUBSEQUENT_429_ERRORS_TIMEOUT_IN_SECONDS = 60; + +/** + * How many subsequent 5xx errors are allowed before we stop further requests. + */ +const TOO_MANY_SUBSEQUENT_SERVER_ERRORS = 10; + +/** + * For how long the API service should cool down after reaching the limit + * of subsequent 5xx errors. + */ +const TOO_MANY_SUBSEQUENT_SERVER_ERRORS_TIMEOUT_IN_SECONDS = 60; + +/** + * After how long to re-try after 5xx or timeout error. + */ +const SERVER_ERROR_RETRY_DELAY_SECONDS = 1; + +/** + * After how long to re-try after offline error. + */ +const OFFLINE_RETRY_DELAY_SECONDS = 5; + +/** + * After how long to re-try after 429 error without specified retry-after header. + */ +const DEFAULT_429_RETRY_DELAY_SECONDS = 10; + +/** + * After how long to re-try after general error. + */ +const GENERAL_RETRY_DELAY_SECONDS = 1; /** * Provides API communication used withing the Drive SDK. - * - * The service is responsible for handling general headers, errors, conversion - * or rate limiting. + * + * The service is responsible for handling general headers, errors, conversion, + * rate limiting, or basic re-tries. + * + * Error handling includes: + * + * * exception from HTTP client + * * retry on offline exc. (with delay from OFFLINE_RETRY_DELAY_SECONDS) + * * retry on timeout exc. (with delay from SERVER_ERROR_RETRY_DELAY_SECONDS) + * * retry ONCE on any exc. (with delay from GENERAL_RETRY_DELAY_SECONDS) + * * HTTP status 429 + * * retry (with delay from `retry-after` header or DEFAULT_429_RETRY_DELAY_SECONDS) + * * if too many subsequent 429s, stop further requests (defined in TOO_MANY_SUBSEQUENT_429_ERRORS) + * * when limit is reached, cool down for TOO_MANY_SUBSEQUENT_429_ERRORS_TIMEOUT_IN_SECONDS + * * HTTP status 5xx + * * retry ONCE (with delay from SERVER_ERROR_RETRY_DELAY_SECONDS) + * * if too many subsequent 5xxs, stop further requests (defined in TOO_MANY_SUBSEQUENT_SERVER_ERRORS) + * * when limit is reached, cool down for TOO_MANY_SUBSEQUENT_SERVER_ERRORS_TIMEOUT_IN_SECONDS */ export class DriveAPIService { + private subsequentTooManyRequestsCounter = 0; + private lastTooManyRequestsErrorAt?: number; + + private subsequentServerErrorsCounter = 0; + private lastServerErrorAt?: number; + constructor(private httpClient: ProtonDriveHTTPClient, private baseUrl: string, private language: string, private logger?: Logger) { this.httpClient = httpClient; this.baseUrl = baseUrl; @@ -16,30 +80,73 @@ export class DriveAPIService { this.logger = logger; } - async get(url: string, signal?: AbortSignal): Promise { + async get(url: string, signal?: AbortSignal): Promise { return this.makeRequest(url, 'GET', undefined, signal); }; - async post(url: string, data: Request, signal?: AbortSignal): Promise { + async post(url: string, data: RequestPayload, signal?: AbortSignal): Promise { return this.makeRequest(url, 'POST', data, signal); }; - async put(url: string, data: Request, signal?: AbortSignal): Promise { + async put(url: string, data: RequestPayload, signal?: AbortSignal): Promise { return this.makeRequest(url, 'PUT', data, signal); }; - // TODO: rate limit implementation - private async makeRequest(url: string, method = 'GET', data?: Request, signal?: AbortSignal) { + // TODO: add priority header + // u=2 for interactive (user doing action, e.g., create folder), + // u=4 for normal (user secondary action, e.g., refresh children listing), + // u=5 for background (e.g., upload, download) + // u=7 for optional (e.g., metrics, telemetry) + private async makeRequest( + url: string, + method = 'GET', + data?: RequestPayload, + signal?: AbortSignal, + attempt = 0 + ): Promise { + if (signal?.aborted) { + throw new AbortError('Request aborted'); + } + this.logger?.debug(`${method} ${url}`); - const response = await this.httpClient.fetch(new Request(`${this.baseUrl}/${url}`, { - method: method || 'GET', - // TODO: set SDK-specific headers (accept: json, language, SDK version) - headers: new Headers({ - "Language": this.language, - }), - body: JSON.stringify(data), - }), signal); + if (this.hasReachedServerErrorLimit) { + throw new APIError('Server errors limit reached'); + } + if (this.hasReachedTooManyRequestsErrorLimit) { + throw new APIError('Too many requests limit reached'); + } + + let response; + try { + response = await this.httpClient.fetch(new Request(`${this.baseUrl}/${url}`, { + method: method || 'GET', + // TODO: set SDK version (or set via http client at init?) + headers: new Headers({ + "Accept": "application/vnd.protonmail.v1+json", + "Content-Type": "application/json", + "Language": this.language, + }), + body: JSON.stringify(data), + }), signal); + } catch (error: unknown) { + if (error instanceof Error) { + if (error.name === 'OfflineError') { + await waitSeconds(OFFLINE_RETRY_DELAY_SECONDS); + return this.makeRequest(url, method, data, signal, attempt+1); + } + + if (error.name === 'TimeoutError') { + await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); + return this.makeRequest(url, method, data, signal, attempt+1); + } + } + if (attempt === 0) { + await waitSeconds(GENERAL_RETRY_DELAY_SECONDS); + return this.makeRequest(url, method, data, signal, attempt+1); + } + throw error; + } if (response.ok) { this.logger?.info(`${method} ${url}: ${response.status}`); @@ -47,12 +154,42 @@ export class DriveAPIService { this.logger?.warn(`${method} ${url}: ${response.status}`); } + if (response.status === HTTPErrorCode.TOO_MANY_REQUESTS) { + // TODO: emit event to the client + this.tooManyRequestsErrorHappened(); + const timeout = parseInt(response.headers.get('retry-after') || '0', DEFAULT_429_RETRY_DELAY_SECONDS); + await waitSeconds(timeout); + return this.makeRequest(url, method, data, signal, attempt+1); + } else { + this.clearSubsequentTooManyRequestsError(); + } + + // Automatically re-try 5xx glitches on the server, but only once + // and report the incident so it can be followed up. + if (response.status >= 500) { + this.serverErrorHappened(); + + if (attempt > 0) { + this.logger?.warn(`${method} ${url}: ${response.status} - retry failed`); + } else { + await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); + return this.makeRequest(url, method, data, signal, attempt+1); + } + } else { + if (attempt > 0) { + // TODO: send to metrics + this.logger?.warn(`${method} ${url}: ${response.status} - retry helped`); + } + this.clearSubsequentServerErrors(); + } + try { const result = await response.json(); + if (!response.ok || result.Code !== ErrorCode.OK) { throw apiErrorFactory({ response, result }); } - return result as Response; + return result as ResponsePayload; } catch (error: unknown) { if (error instanceof APIError) { throw error; @@ -60,4 +197,40 @@ export class DriveAPIService { throw apiErrorFactory({ response }); } } + + private get hasReachedTooManyRequestsErrorLimit(): boolean { + const secondsSinceLast429Error = (Date.now() - (this.lastTooManyRequestsErrorAt || Date.now())) / 1000; + return ( + this.subsequentTooManyRequestsCounter >= TOO_MANY_SUBSEQUENT_429_ERRORS && + secondsSinceLast429Error < TOO_MANY_SUBSEQUENT_429_ERRORS_TIMEOUT_IN_SECONDS + ) + } + + private tooManyRequestsErrorHappened() { + this.subsequentTooManyRequestsCounter++; + this.lastTooManyRequestsErrorAt = Date.now(); + } + + private clearSubsequentTooManyRequestsError() { + this.subsequentTooManyRequestsCounter = 0; + this.lastTooManyRequestsErrorAt = undefined; + } + + private get hasReachedServerErrorLimit(): boolean { + const secondsSinceLastServerError = (Date.now() - (this.lastServerErrorAt || Date.now())) / 1000; + return ( + this.subsequentServerErrorsCounter >= TOO_MANY_SUBSEQUENT_SERVER_ERRORS && + secondsSinceLastServerError < TOO_MANY_SUBSEQUENT_SERVER_ERRORS_TIMEOUT_IN_SECONDS + ) + } + + private serverErrorHappened() { + this.subsequentServerErrorsCounter++; + this.lastServerErrorAt = Date.now(); + } + + private clearSubsequentServerErrors() { + this.subsequentServerErrorsCounter = 0; + this.lastServerErrorAt = undefined; + } } diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index 4b122669..d99c5b68 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -1,3 +1,9 @@ +export const enum HTTPErrorCode { + OK = 200, + TOO_MANY_REQUESTS = 429, + INTERNAL_SERVER_ERROR = 500, +} + export const enum ErrorCode { OK = 1000, NOT_EXISTS = 2501, diff --git a/js/sdk/src/internal/apiService/errors.test.ts b/js/sdk/src/internal/apiService/errors.test.ts new file mode 100644 index 00000000..3942d0f6 --- /dev/null +++ b/js/sdk/src/internal/apiService/errors.test.ts @@ -0,0 +1,56 @@ +import { apiErrorFactory } from "./errors"; +import * as errors from "./errors"; +import { ErrorCode } from './errorCodes'; + +function mockAPIResponseAndResult(options: { + httpStatusCode?: number, + httpStatusText?: string, + code: number, + message?: string, +}) { + const { + httpStatusCode = 422, + httpStatusText = 'Unprocessable Entity', + code, + message = 'API error', + } = options; + + const result = { Code: code, Error: message }; + const response = new Response(JSON.stringify(result), { status: httpStatusCode, statusText: httpStatusText }); + + return { response, result }; +} + +describe("apiErrorFactory should return", () => { + it("generic APIHTTPError when there is no specifc body", () => { + const response = new Response('', { status: 404, statusText: 'Not found' }); + const error = apiErrorFactory({ response }); + expect(error).toBeInstanceOf(errors.APIHTTPError); + expect(error.message).toBe("Not found"); + expect((error as errors.APIHTTPError).statusCode).toBe(404); + }); + + it("generic APICodeError when there is body even if wrong", () => { + const result = {}; + const response = new Response('', { status: 422 }); + const error = apiErrorFactory({ response, result }); + expectAPICodeError(error, 0, 'Unknown error'); + }); + + it("generic APICodeError when there is body but not specific handle", () => { + const error = apiErrorFactory(mockAPIResponseAndResult({ code: 42, message: 'General error' })); + expectAPICodeError(error, 42, 'General error'); + }); + + it("NotFoundAPIError when code is ErrorCode.NOT_EXISTS", () => { + const error = apiErrorFactory(mockAPIResponseAndResult({ code: ErrorCode.NOT_EXISTS, message: 'Not found' })); + expect(error).toBeInstanceOf(errors.NotFoundAPIError); + expectAPICodeError(error, ErrorCode.NOT_EXISTS, 'Not found'); + }); +}); + +function expectAPICodeError(error: Error, code: number, message: string) { + expect(error).toBeInstanceOf(errors.APICodeError); + expect(error.message).toBe(message); + expect((error as errors.APICodeError).code).toBe(code); +} diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 5e9f8961..9d1345bf 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -16,9 +16,17 @@ export function apiErrorFactory({ response, result }: { response: Response, resu } } -export class APIError extends Error {} +export class AbortError extends Error { + name = 'AbortError'; +} + +export class APIError extends Error { + name = 'APIError'; +} export class APIHTTPError extends APIError { + name = 'APIHTTPError'; + public statusCode: number; constructor(message: string, statusCode: number) { @@ -28,6 +36,8 @@ export class APIHTTPError extends APIError { } export class APICodeError extends APIError { + name = 'APICodeError'; + public code: number; constructor(message: string, code: number) { @@ -36,4 +46,6 @@ export class APICodeError extends APIError { } } -export class NotFoundAPIError extends APICodeError {} +export class NotFoundAPIError extends APICodeError { + name = 'NotFoundAPIError'; +} diff --git a/js/sdk/src/internal/apiService/wait.ts b/js/sdk/src/internal/apiService/wait.ts new file mode 100644 index 00000000..bac3920a --- /dev/null +++ b/js/sdk/src/internal/apiService/wait.ts @@ -0,0 +1,7 @@ +export async function waitSeconds(seconds: number){ + return wait(seconds * 1000); +} + +export async function wait(miliseconds: number){ + return new Promise((resolve) => setTimeout(resolve, miliseconds)); +} From f4a99c4119e12abad28b55addbd268298cd6f4b2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 18 Feb 2025 09:07:32 +0000 Subject: [PATCH 017/791] E2E Set up user management --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e48b4fab..95992761 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ tsconfig.tsbuildinfo js/cli/proton-drive auth.txt cache*.sqlite + +# Tests +tests/storage From 81e8ffcb6e159afdd05fcc19cb18db80906d5b87 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 18 Feb 2025 16:02:37 +0100 Subject: [PATCH 018/791] add unit tests for nodes managers --- js/sdk/src/internal/nodes/nodesAccess.test.ts | 184 ++++++++++++++++++ .../internal/nodes/nodesManagement.test.ts | 134 +++++++++++++ js/sdk/src/internal/nodes/nodesManagement.ts | 13 +- 3 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 js/sdk/src/internal/nodes/nodesManagement.test.ts diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 69a8ae25..18484a2a 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -19,11 +19,15 @@ describe('nodesAccess', () => { apiService = { getNode: jest.fn(), getNodes: jest.fn(), + iterateChildrenNodeUids: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking cache = { getNode: jest.fn(), setNode: jest.fn(), + iterateChildren: jest.fn().mockImplementation(async function* () {}), + isFolderChildrenLoaded: jest.fn().mockResolvedValue(false), + setFolderChildrenLoaded: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking cryptoCache = { @@ -91,6 +95,186 @@ describe('nodesAccess', () => { }); }); + describe('iterate methods', () => { + beforeEach(() => { + cryptoCache.getNodeKeys = jest.fn().mockImplementation((uid: string) => Promise.resolve({ key: 'key' } as any as DecryptedNodeKeys)); + cryptoService.decryptNode = jest.fn().mockImplementation((encryptedNode: EncryptedNode) => Promise.resolve({ + node: { uid: encryptedNode.uid, isStale: false } as DecryptedNode, + keys: { key: 'key' } as any as DecryptedNodeKeys, + })); + }); + + describe('iterateChildren', () => { + const parentNode = { uid: 'parentUid', isStale: false } as DecryptedNode; + const node1 = { uid: 'node1', isStale: false } as DecryptedNode; + const node2 = { uid: 'node2', isStale: false } as DecryptedNode; + const node3 = { uid: 'node3', isStale: false } as DecryptedNode; + const node4 = { uid: 'node4', isStale: false } as DecryptedNode; + + beforeEach(() => { + cache.getNode = jest.fn().mockResolvedValue(parentNode); + }); + + it('should serve fully from cache', async () => { + cache.isFolderChildrenLoaded = jest.fn().mockResolvedValue(true); + cache.iterateChildren = jest.fn().mockImplementation(async function* () { + yield { ok: true, node: node1 }; + yield { ok: true, node: node2 }; + yield { ok: true, node: node3 }; + yield { ok: true, node: node4 }; + }); + + const result = await Array.fromAsync(access.iterateChildren('parentUid')); + expect(result).toEqual([node1, node2, node3, node4]); + expect(apiService.iterateChildrenNodeUids).not.toHaveBeenCalled(); + expect(apiService.getNodes).not.toHaveBeenCalled(); + }); + + it('should serve children from cache and load stale nodes only', async () => { + cache.isFolderChildrenLoaded = jest.fn().mockResolvedValue(true); + cache.iterateChildren = jest.fn().mockImplementation(async function* () { + yield { ok: true, uid: node1.uid, node: node1 }; + yield { ok: true, uid: node2.uid, node: { ...node2, isStale: true } }; + yield { ok: true, uid: node3.uid, node: { ...node3, isStale: true } }; + yield { ok: true, uid: node4.uid, node: node4 }; + }); + apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + uids.map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)) + )); + + const result = await Array.fromAsync(access.iterateChildren('parentUid')); + expect(result).toEqual([node1, node4, node2, node3]); + expect(apiService.getNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); + expect(cryptoService.decryptNode).toHaveBeenCalledTimes(2); + expect(cache.setNode).toHaveBeenCalledTimes(2); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(2); + }); + + it('should load children uids and serve nodes from cache', async () => { + apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { + yield 'node1'; + yield 'node2'; + yield 'node3'; + yield 'node4'; + }); + cache.getNode = jest.fn().mockImplementation((uid: string) => ({ uid, isStale: false })); + + const result = await Array.fromAsync(access.iterateChildren('parentUid')); + expect(result).toEqual([node1, node2, node3, node4]); + expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); + expect(apiService.getNodes).not.toHaveBeenCalled(); + expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); + }); + + it('should load from API', async () => { + apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { + yield 'node1'; + yield 'node2'; + yield 'node3'; + yield 'node4'; + }); + cache.getNode = jest.fn().mockImplementation((uid: string) => { + if (uid === parentNode.uid) { + return parentNode; + } + throw new Error('Entity not found'); + }); + apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + uids.map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)) + )); + + const result = await Array.fromAsync(access.iterateChildren('parentUid')); + expect(result).toEqual([node1, node2, node3, node4]); + expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); + expect(apiService.getNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); + expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); + expect(cache.setNode).toHaveBeenCalledTimes(4); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); + expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); + }); + }); + + describe('iterateTrashedNodes', () => { + const volumeId = 'volumeId'; + const node1 = { uid: 'node1', isStale: false } as DecryptedNode; + const node2 = { uid: 'node2', isStale: false } as DecryptedNode; + const node3 = { uid: 'node3', isStale: false } as DecryptedNode; + const node4 = { uid: 'node4', isStale: false } as DecryptedNode; + + beforeEach(() => { + shareService.getMyFilesIDs = jest.fn().mockResolvedValue({ volumeId }); + apiService.iterateTrashedNodeUids = jest.fn().mockImplementation(async function* () { + yield node1.uid; + yield node2.uid; + yield node3.uid; + yield node4.uid; + }); + }); + + it('should load trashed nodes and serve nodes from cache', async () => { + cache.getNode = jest.fn().mockImplementation((uid: string) => ({ uid, isStale: false })); + + const result = await Array.fromAsync(access.iterateTrashedNodes()); + expect(result).toEqual([node1, node2, node3, node4]); + expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); + expect(apiService.getNodes).not.toHaveBeenCalled(); + }); + + it('should load from API', async () => { + cache.getNode = jest.fn().mockImplementation((uid: string) => { + throw new Error('Entity not found'); + }); + apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + uids.map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)) + )); + + const result = await Array.fromAsync(access.iterateTrashedNodes()); + expect(result).toEqual([node1, node2, node3, node4]); + expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); + expect(apiService.getNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); + expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); + expect(cache.setNode).toHaveBeenCalledTimes(4); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); + }); + }); + + describe('iterateNodes', () => { + const node1 = { uid: 'node1', isStale: false } as DecryptedNode; + const node2 = { uid: 'node2', isStale: false } as DecryptedNode; + const node3 = { uid: 'node3', isStale: false } as DecryptedNode; + const node4 = { uid: 'node4', isStale: false } as DecryptedNode; + + it('should serve fully from cache', async () => { + cache.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { ok: true, node: node1 }; + yield { ok: true, node: node2 }; + yield { ok: true, node: node3 }; + yield { ok: true, node: node4 }; + }); + + const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); + expect(result).toEqual([node1, node2, node3, node4]); + expect(apiService.getNodes).not.toHaveBeenCalled(); + }); + + it('should load from API', async () => { + cache.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { ok: true, node: node1 }; + yield { ok: false, uid: 'node2' }; + yield { ok: false, uid: 'node3' }; + yield { ok: true, node: node4 }; + }); + apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + uids.map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)) + )); + + const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); + expect(result).toEqual([node1, node4, node2, node3]); + expect(apiService.getNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); + }); + }); + }); + describe('getParentKeys', () => { it('should get share parent keys', async () => { shareService.getSharePrivateKey = jest.fn(() => Promise.resolve('shareKey' as any as PrivateKey)); diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts new file mode 100644 index 00000000..31f704e7 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -0,0 +1,134 @@ +import { NodeAPIService } from "./apiService"; +import { NodesCache } from "./cache" +import { NodesCryptoCache } from "./cryptoCache"; +import { NodesCryptoService } from "./cryptoService"; +import { NodesAccess } from './nodesAccess'; +import { DecryptedNode } from './interface'; +import { NodesManagement } from './nodesManagement'; + +describe('NodesManagement', () => { + let apiService: NodeAPIService; + let cache: NodesCache; + let cryptoCache: NodesCryptoCache; + let cryptoService: NodesCryptoService; + let nodesAccess: NodesAccess; + let management: NodesManagement; + + const nodes: { [uid: string]: DecryptedNode } = { + nodeUid: { + uid: 'nodeUid', + parentUid: 'parentUid', + name: { ok: true, value: 'old name' }, + keyAuthor: { ok: true, value: 'keyAauthor' }, + nameAuthor: { ok: true, value: 'nameAuthor' }, + hash: 'hash', + mimeType: 'mimeType', + } as DecryptedNode, + parentUid: { + uid: 'parentUid', + name: { ok: true, value: 'parent' }, + } as DecryptedNode, + newParentUid: { + uid: 'newParentUid', + name: { ok: true, value: 'new parent' }, + } as DecryptedNode, + }; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = { + renameNode: jest.fn(), + moveNode: jest.fn(), + trashNodes: jest.fn(), + restoreNodes: jest.fn(), + deleteNodes: jest.fn(), + createFolder: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cache = { + setNode: jest.fn(), + removeNodes: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cryptoCache = { + setNodeKeys: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + encryptNewName: jest.fn().mockResolvedValue({ + signatureEmail: 'newSignatureEmail', + armoredNodeName: 'newArmoredNodeName', + hash: 'newHash', + }), + moveNode: jest.fn(), + createFolder: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + nodesAccess = { + getNode: jest.fn().mockImplementation((uid: string) => nodes[uid]), + getNodeKeys: jest.fn().mockImplementation((uid) => ({ + key: `${uid}-key`, + hashKey: `${uid}-hashKey`, + })), + getParentKeys: jest.fn().mockImplementation(({ uid }) => ({ + key: `${nodes[uid].parentUid}-key`, + hashKey: `${nodes[uid].parentUid}-hashKey`, + })), + iterateNodes: jest.fn(), + } + + management = new NodesManagement(apiService, cache, cryptoCache, cryptoService, nodesAccess); + }); + + it('renameNode manages rename and updates cache', async () => { + const newNode = await management.renameNode('nodeUid', 'new name'); + expect(newNode).toEqual({ + ...nodes.nodeUid, + name: { ok: true, value: 'new name' }, + nameAuthor: { ok: true, value: 'newSignatureEmail' }, + hash: 'newHash', + }); + expect(cryptoService.encryptNewName).toHaveBeenCalledWith(nodes.nodeUid, { + key: 'parentUid-key', + hashKey: 'parentUid-hashKey', + }, 'new name'); + expect(apiService.renameNode).toHaveBeenCalledWith( + nodes.nodeUid.uid, + { hash: nodes.nodeUid.hash }, + { encryptedName: 'newArmoredNodeName', nameSignatureEmail: 'newSignatureEmail', hash: 'newHash' } + ); + expect(cache.setNode).toHaveBeenCalledWith(newNode); + }); + + it('moveNode manages move and updates cache', async () => { + const encryptedCrypto = { + encryptedName: 'movedArmoredNodeName', + hash: 'movedHash', + armoredNodePassphrase: 'movedArmoredNodePassphrase', + armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature', + signatureEmail: 'movedSignatureEmail', + nameSignatureEmail: 'movedNameSignatureEmail', + } + cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); + + const newNode = await management.moveNode('nodeUid', 'newParentNodeUid'); + expect(newNode).toEqual({ + ...nodes.nodeUid, + parentUid: 'newParentNodeUid', + hash: 'movedHash', + keyAuthor: { ok: true, value: 'movedSignatureEmail' }, + nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, + }); + expect(apiService.moveNode).toHaveBeenCalledWith( + 'nodeUid', + { + hash: nodes.nodeUid.hash, + }, + { + parentUid: 'newParentNodeUid', + ...encryptedCrypto + }, + ); + expect(cache.setNode).toHaveBeenCalledWith(newNode); + }); +}); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 6db336d6..d729b252 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -87,7 +87,7 @@ export class NodesManagement { } } - async moveNode(nodeUid: string, newParentUid: string): Promise { + async moveNode(nodeUid: string, newParentUid: string): Promise { const [node, newParentNode] = await Promise.all([ this.nodesAccess.getNode(nodeUid), this.nodesAccess.getNode(newParentUid), @@ -111,7 +111,7 @@ export class NodesManagement { { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, ); await this.apiService.moveNode( - nodeUid, + nodeUid, { hash: node.hash, }, @@ -126,10 +126,15 @@ export class NodesManagement { // TODO: content hash } ); - await this.cache.setNode({ + const newNode: DecryptedNode = { ...node, parentUid: newParentUid, - }); + hash: encryptedCrypto.hash, + keyAuthor: resultOk(encryptedCrypto.signatureEmail), + nameAuthor: resultOk(encryptedCrypto.nameSignatureEmail), + }; + await this.cache.setNode(newNode); + return newNode; } async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { From 3f676f2c356ec819cc1013758f07c67aacc8caf0 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Feb 2025 08:51:28 +0000 Subject: [PATCH 019/791] Add authentication and account functions, with CLI --- cs/.editorconfig | 209 ++++++++++ cs/.gitignore | 357 ++++++++++++++++++ cs/.globalconfig | 146 +++++++ cs/Directory.Build.props | 59 +++ cs/sdk/src/.globalconfig | 2 + cs/sdk/src/Directory.Packages.props | 16 + cs/sdk/src/Proton.Sdk/AccountApiClients.cs | 12 + cs/sdk/src/Proton.Sdk/Addresses/Address.cs | 13 + cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs | 11 + cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs | 19 + .../src/Proton.Sdk/Addresses/AddressKeyId.cs | 11 + .../Proton.Sdk/Addresses/AddressOperations.cs | 238 ++++++++++++ .../src/Proton.Sdk/Addresses/AddressStatus.cs | 8 + .../Proton.Sdk/Addresses/Api/AddressDto.cs | 17 + .../Addresses/Api/AddressKeyCapabilities.cs | 9 + .../Proton.Sdk/Addresses/Api/AddressKeyDto.cs | 29 ++ .../Addresses/Api/AddressListResponse.cs | 8 + .../Addresses/Api/AddressResponse.cs | 8 + .../Addresses/Api/AddressesApiClient.cs | 23 ++ .../Addresses/Api/IAddressesApiClient.cs | 8 + cs/sdk/src/Proton.Sdk/Api/ApiClientFactory.cs | 10 + cs/sdk/src/Proton.Sdk/Api/ApiResponse.cs | 13 + .../src/Proton.Sdk/Api/IApiClientFactory.cs | 13 + cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs | 56 +++ .../Api/AuthenticationApiClient.cs | 100 +++++ .../Api/AuthenticationRequest.cs | 18 + .../Api/AuthenticationResponse.cs | 33 ++ .../Api/IAuthenticationApiClient.cs | 31 ++ .../Authentication/Api/ModulusResponse.cs | 12 + .../Authentication/Api/ScopesResponse.cs | 8 + .../Api/SecondFactorParameters.cs | 11 + .../Api/SecondFactorValidationRequest.cs | 9 + .../Api/SessionInitiationRequest.cs | 6 + .../Api/SessionInitiationResponse.cs | 19 + .../Api/SessionRefreshRequest.cs | 16 + .../Api/SessionRefreshResponse.cs | 18 + .../Authentication/AuthorizationHandler.cs | 55 +++ .../Proton.Sdk/Authentication/PasswordMode.cs | 7 + .../Proton.Sdk/Authentication/SessionId.cs | 11 + .../Authentication/TokenCredential.cs | 105 ++++++ .../Proton.Sdk/Caching/AccountEntityCache.cs | 16 + .../Proton.Sdk/Caching/AccountSecretCache.cs | 27 ++ cs/sdk/src/Proton.Sdk/Caching/ICache.cs | 16 + cs/sdk/src/Proton.Sdk/Caching/NullCache.cs | 25 ++ .../src/Proton.Sdk/Caching/PublicKeyCache.cs | 22 ++ .../Proton.Sdk/Caching/SessionSecretCache.cs | 14 + .../Cryptography/IPgpArmoredBlock.cs | 6 + .../Cryptography/PgpArmoredMessage.cs | 16 + .../Cryptography/PgpArmoredPrivateKey.cs | 16 + .../Cryptography/PgpArmoredPublicKey.cs | 16 + .../Cryptography/PgpArmoredSignature.cs | 16 + .../src/Proton.Sdk/Events/Api/AddressEvent.cs | 15 + .../src/Proton.Sdk/Events/Api/EventAction.cs | 9 + .../Events/Api/EventListResponse.cs | 24 ++ .../Proton.Sdk/Events/Api/EventsApiClient.cs | 24 ++ .../Events/Api/EventsRefreshMask.cs | 9 + .../Events/Api/LatestEventResponse.cs | 10 + cs/sdk/src/Proton.Sdk/Events/EventId.cs | 11 + .../Http/CryptographyTimeProvisionHandler.cs | 31 ++ .../src/Proton.Sdk/Http/HttpApiCallBuilder.cs | 115 ++++++ .../Proton.Sdk/Http/HttpClientExtensions.cs | 22 ++ .../Http/HttpRequestHeadersExtensions.cs | 14 + .../Http/HttpRequestMessageFactory.cs | 62 +++ .../Http/HttpResponseMessageExtensions.cs | 42 +++ .../Http/SocketsHttpHandlerExtensions.cs | 33 ++ .../Http/TlsRemoteCertificateValidator.cs | 77 ++++ .../Keys/Api/AddressPublicKeyListResponse.cs | 19 + .../src/Proton.Sdk/Keys/Api/IKeysApiClient.cs | 8 + cs/sdk/src/Proton.Sdk/Keys/Api/KeySalt.cs | 12 + .../Keys/Api/KeySaltListResponse.cs | 8 + .../src/Proton.Sdk/Keys/Api/KeysApiClient.cs | 23 ++ .../src/Proton.Sdk/Keys/Api/PublicKeyEntry.cs | 12 + .../Keys/Api/PublicKeyListAddress.cs | 6 + .../Proton.Sdk/Keys/Api/PublicKeyStatus.cs | 8 + cs/sdk/src/Proton.Sdk/MemoryProvider.cs | 23 ++ cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 44 +++ cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 115 ++++++ cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs | 41 ++ cs/sdk/src/Proton.Sdk/ProtonApiException.cs | 31 ++ .../src/Proton.Sdk/ProtonApiException{T}.cs | 30 ++ cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 263 +++++++++++++ .../Proton.Sdk/ProtonClientConfiguration.cs | 20 + .../ProtonClientConfigurationExtensions.cs | 111 ++++++ cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs | 18 + .../BooleanToIntegerJsonConverter.cs | 18 + .../EpochSecondsJsonConverter.cs | 18 + .../ForgivingBytesToHexJsonConverter.cs | 56 +++ .../src/Proton.Sdk/Serialization/IStrongId.cs | 10 + .../PgpArmoredBlockJsonConverter.cs | 53 +++ .../ProtonApiSerializerContext.cs | 34 ++ .../Serialization/StrongIdConverter.cs | 19 + .../Proton.Sdk/Users/Api/IUsersApiClient.cs | 6 + .../src/Proton.Sdk/Users/Api/Subscriptions.cs | 9 + cs/sdk/src/Proton.Sdk/Users/Api/User.cs | 36 ++ cs/sdk/src/Proton.Sdk/Users/Api/UserKey.cs | 23 ++ .../src/Proton.Sdk/Users/Api/UserResponse.cs | 8 + .../Proton.Sdk/Users/Api/UsersApiClient.cs | 16 + .../src/Proton.Sdk/Users/DelinquentState.cs | 10 + cs/sdk/src/Proton.Sdk/Users/Services.cs | 9 + cs/sdk/src/Proton.Sdk/Users/UserId.cs | 11 + cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs | 11 + cs/sdk/src/Proton.Sdk/Users/UserType.cs | 8 + 102 files changed, 3589 insertions(+) create mode 100644 cs/.editorconfig create mode 100644 cs/.gitignore create mode 100644 cs/.globalconfig create mode 100644 cs/Directory.Build.props create mode 100644 cs/sdk/src/.globalconfig create mode 100644 cs/sdk/src/Directory.Packages.props create mode 100644 cs/sdk/src/Proton.Sdk/AccountApiClients.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/Address.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/AddressStatus.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/Api/AddressDto.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyCapabilities.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/Api/AddressListResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/Api/AddressResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/Api/AddressesApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/Api/IAddressesApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Api/ApiClientFactory.cs create mode 100644 cs/sdk/src/Proton.Sdk/Api/ApiResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs create mode 100644 cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationRequest.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/IAuthenticationApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/ModulusResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/ScopesResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorParameters.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorValidationRequest.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationRequest.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshRequest.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/PasswordMode.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs create mode 100644 cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/ICache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/NullCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs create mode 100644 cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs create mode 100644 cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs create mode 100644 cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs create mode 100644 cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs create mode 100644 cs/sdk/src/Proton.Sdk/Events/Api/AddressEvent.cs create mode 100644 cs/sdk/src/Proton.Sdk/Events/Api/EventAction.cs create mode 100644 cs/sdk/src/Proton.Sdk/Events/Api/EventListResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Events/Api/EventsApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Events/Api/EventsRefreshMask.cs create mode 100644 cs/sdk/src/Proton.Sdk/Events/Api/LatestEventResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Events/EventId.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpClientExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageFactory.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs create mode 100644 cs/sdk/src/Proton.Sdk/Keys/Api/AddressPublicKeyListResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Keys/Api/IKeysApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Keys/Api/KeySalt.cs create mode 100644 cs/sdk/src/Proton.Sdk/Keys/Api/KeySaltListResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Keys/Api/KeysApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyEntry.cs create mode 100644 cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyListAddress.cs create mode 100644 cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyStatus.cs create mode 100644 cs/sdk/src/Proton.Sdk/MemoryProvider.cs create mode 100644 cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj create mode 100644 cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs create mode 100644 cs/sdk/src/Proton.Sdk/ProtonApiException.cs create mode 100644 cs/sdk/src/Proton.Sdk/ProtonApiException{T}.cs create mode 100644 cs/sdk/src/Proton.Sdk/ProtonApiSession.cs create mode 100644 cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs create mode 100644 cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/BooleanToIntegerJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/EpochSecondsJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/StrongIdConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/Api/IUsersApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/Api/Subscriptions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/Api/User.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/Api/UserKey.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/Api/UserResponse.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/Api/UsersApiClient.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/DelinquentState.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/Services.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/UserId.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs create mode 100644 cs/sdk/src/Proton.Sdk/Users/UserType.cs diff --git a/cs/.editorconfig b/cs/.editorconfig new file mode 100644 index 00000000..a9508694 --- /dev/null +++ b/cs/.editorconfig @@ -0,0 +1,209 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +[**/obj/**.cs] +generated_code = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = true + +#### .NET Coding Conventions #### + +# Make build follow IDE severities +dotnet_analyzer_diagnostic.severity = default + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:suggestion + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = true +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# Namespace declarations +csharp_style_namespace_declarations = file_scoped:warning + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = false +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case diff --git a/cs/.gitignore b/cs/.gitignore new file mode 100644 index 00000000..a1996f32 --- /dev/null +++ b/cs/.gitignore @@ -0,0 +1,357 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ar]tifacts/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +# *.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- Backup*.rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Artifacts folder +/artifacts + +# vcpkg +vcpkg_installed/ + +# Visual Studio project launch settings +**/Properties/launchSettings.json + +build/output +.vscode + +# macOS +*.DS_Store +.AppleDouble +.LSOverride diff --git a/cs/.globalconfig b/cs/.globalconfig new file mode 100644 index 00000000..01e77d9b --- /dev/null +++ b/cs/.globalconfig @@ -0,0 +1,146 @@ +is_global = true + +stylecop.layout.allowConsecutiveUsings = true +stylecop.layout.allowDoWhileOnClosingBrace = true + +dotnet_diagnostic.CA1032.severity = warning +dotnet_diagnostic.CA1711.severity = suggestion +dotnet_diagnostic.CA2000.severity = suggestion +dotnet_diagnostic.CA2201.severity = suggestion +dotnet_diagnostic.CA2215.severity = warning + +# StyleCop - Special +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SpecialRules.md + +dotnet_diagnostic.SA0001.severity = none + +# StyleCop - Spacing +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SpacingRules.md + +dotnet_diagnostic.SA1009.severity = suggestion + +# StyleCop - Readability +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/ReadabilityRules.md + +dotnet_diagnostic.SA1101.severity = none +dotnet_diagnostic.SA1200.severity = none +dotnet_diagnostic.SA1216.severity = none +dotnet_diagnostic.SA1217.severity = none +dotnet_diagnostic.SA1413.severity = none +dotnet_diagnostic.SA1502.severity = none +dotnet_diagnostic.SA1516.severity = none + +# StyleCop - Documentation +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/DocumentationRules.md + +dotnet_diagnostic.SA1600.severity = none +dotnet_diagnostic.SA1601.severity = none +dotnet_diagnostic.SA1602.severity = none +dotnet_diagnostic.SA1604.severity = none +dotnet_diagnostic.SA1605.severity = none +dotnet_diagnostic.SA1606.severity = none +dotnet_diagnostic.SA1607.severity = none +dotnet_diagnostic.SA1608.severity = none +dotnet_diagnostic.SA1610.severity = none +dotnet_diagnostic.SA1611.severity = none +dotnet_diagnostic.SA1612.severity = none +dotnet_diagnostic.SA1613.severity = none +dotnet_diagnostic.SA1614.severity = none +dotnet_diagnostic.SA1615.severity = none +dotnet_diagnostic.SA1616.severity = none +dotnet_diagnostic.SA1617.severity = none +dotnet_diagnostic.SA1618.severity = none +dotnet_diagnostic.SA1619.severity = none +dotnet_diagnostic.SA1620.severity = none +dotnet_diagnostic.SA1621.severity = none +dotnet_diagnostic.SA1622.severity = none +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.SA1624.severity = none +dotnet_diagnostic.SA1625.severity = none +dotnet_diagnostic.SA1626.severity = none +dotnet_diagnostic.SA1627.severity = none +dotnet_diagnostic.SA1629.severity = none +dotnet_diagnostic.SA1633.severity = none +dotnet_diagnostic.SA1634.severity = none +dotnet_diagnostic.SA1635.severity = none +dotnet_diagnostic.SA1636.severity = none +dotnet_diagnostic.SA1637.severity = none +dotnet_diagnostic.SA1638.severity = none +dotnet_diagnostic.SA1640.severity = none +dotnet_diagnostic.SA1641.severity = none +dotnet_diagnostic.SA1642.severity = none +dotnet_diagnostic.SA1643.severity = none +dotnet_diagnostic.SA1648.severity = none + +# StyleCop - Alternative +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/AlternativeRules.md + +dotnet_diagnostic.SX1101.severity = warning +dotnet_diagnostic.SX1309.severity = warning +dotnet_diagnostic.SA1309.severity = none + +# Roslynator +dotnet_diagnostic.RCS1037.severity = none # Redundant with SA1028 +dotnet_diagnostic.RCS1194.severity = none # Redundant with CA1032 + +# To discuss, remove to enable: + +# IDE0058: Expression value is never used +dotnet_diagnostic.IDE0058.severity = none + +# CA1806: Do not ignore method results +dotnet_diagnostic.CA1806.severity = none + +# SA1005: Single line comments should begin with single space +dotnet_diagnostic.SA1005.severity = suggestion + +# SA1108: Block statements should not contain embedded comments +dotnet_diagnostic.SA1108.severity = suggestion + +# SA1111: Closing parenthesis should be on line of last parameter +dotnet_diagnostic.SA1111.severity = suggestion + +# SA1122: Use string.Empty for empty strings +dotnet_diagnostic.SA1122.severity = suggestion + +# SA1127: Generic type constraints should be on their own line +dotnet_diagnostic.SA1127.severity = suggestion + +# SA1128: Put constructor initializers on their own line +dotnet_diagnostic.SA1128.severity = suggestion + +# SA1201: Elements should appear in the correct order +dotnet_diagnostic.SA1201.severity = suggestion + +# SA1202: Elements should be ordered by access +dotnet_diagnostic.SA1202.severity = suggestion + +# SA1204: Static elements should appear before instance elements +dotnet_diagnostic.SA1204.severity = suggestion + +# SA1214: Readonly fields should appear before non-readonly fields +dotnet_diagnostic.SA1214.severity = suggestion + +# SA1408: Conditional expressions should declare precedence +dotnet_diagnostic.SA1408.severity = suggestion + +# SA1503: Braces should not be omitted +dotnet_diagnostic.SA1503.severity = suggestion + +# SA1512: Single-line comments should not be followed by blank line +dotnet_diagnostic.SA1512.severity = suggestion + +# SA1515: Single-line comment should be preceded by blank line +dotnet_diagnostic.SA1515.severity = suggestion + +# SA1519: Braces should not be omitted from multi-line child statement +dotnet_diagnostic.SA1519.severity = suggestion + +# RCS1139: Add summary element to documentation comment. +dotnet_diagnostic.RCS1139.severity = suggestion + +# RCS1181: Convert comment to documentation comment. +dotnet_diagnostic.RCS1181.severity = none + +# S1135: Complete the task associated to this 'TODO' comment +dotnet_diagnostic.S1135.severity = suggestion \ No newline at end of file diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props new file mode 100644 index 00000000..7206c0c4 --- /dev/null +++ b/cs/Directory.Build.props @@ -0,0 +1,59 @@ + + + + net9.0 + true + + + linux-x64 + + Proton Drive + Proton AG + Proton AG + 0.0.1 + © 2025 Proton AG + Proton Drive + + enable + enable + en + true + true + + lib + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/cs/sdk/src/.globalconfig b/cs/sdk/src/.globalconfig new file mode 100644 index 00000000..112c82f1 --- /dev/null +++ b/cs/sdk/src/.globalconfig @@ -0,0 +1,2 @@ +# For library projects, ConfigureAwait(false) is strongly recommended +dotnet_diagnostic.CA2007.severity = warning \ No newline at end of file diff --git a/cs/sdk/src/Directory.Packages.props b/cs/sdk/src/Directory.Packages.props new file mode 100644 index 00000000..440af80b --- /dev/null +++ b/cs/sdk/src/Directory.Packages.props @@ -0,0 +1,16 @@ + + + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/cs/sdk/src/Proton.Sdk/AccountApiClients.cs b/cs/sdk/src/Proton.Sdk/AccountApiClients.cs new file mode 100644 index 00000000..903daf58 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/AccountApiClients.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Keys.Api; +using Proton.Sdk.Users.Api; + +namespace Proton.Sdk; + +internal sealed class AccountApiClients(HttpClient httpClient) +{ + public IKeysApiClient Keys { get; } = new KeysApiClient(httpClient); + public IUsersApiClient Users { get; } = new UsersApiClient(httpClient); + public IAddressesApiClient Addresses { get; } = new AddressesApiClient(httpClient); +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Address.cs b/cs/sdk/src/Proton.Sdk/Addresses/Address.cs new file mode 100644 index 00000000..09f87c27 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Address.cs @@ -0,0 +1,13 @@ +namespace Proton.Sdk.Addresses; + +public sealed class Address(AddressId id, int order, string emailAddress, AddressStatus status, IReadOnlyList keys, int primaryKeyIndex) +{ + public AddressId Id { get; } = id; + public int Order { get; } = order; + public string EmailAddress { get; } = emailAddress; + public AddressStatus Status { get; } = status; + public IReadOnlyList Keys { get; } = keys; + public int PrimaryKeyIndex { get; } = primaryKeyIndex; + + public AddressKey PrimaryKey => Keys[PrimaryKeyIndex]; +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs new file mode 100644 index 00000000..218900bf --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Addresses; + +public readonly record struct AddressId(string Value) : IStrongId +{ + public static implicit operator AddressId(string value) + { + return new AddressId(value); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs new file mode 100644 index 00000000..12788df8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs @@ -0,0 +1,19 @@ +using Proton.Sdk.Addresses.Api; + +namespace Proton.Sdk.Addresses; + +public sealed class AddressKey( + AddressId addressId, + AddressKeyId addressKeyId, + bool isPrimary, + bool isActive, + bool isAllowedForEncryption, + bool isAllowedForVerification) +{ + public AddressId AddressId { get; } = addressId; + public AddressKeyId AddressKeyId { get; } = addressKeyId; + public bool IsPrimary { get; } = isPrimary; + public bool IsActive { get; } = isActive; + public bool IsAllowedForEncryption { get; } = isAllowedForEncryption; + public bool IsAllowedForVerification { get; } = isAllowedForVerification; +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs new file mode 100644 index 00000000..0974cd34 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Addresses.Api; + +public readonly record struct AddressKeyId(string Value) : IStrongId +{ + public static implicit operator AddressKeyId(string value) + { + return new AddressKeyId(value); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs new file mode 100644 index 00000000..d7b23942 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -0,0 +1,238 @@ +using CommunityToolkit.HighPerformance; +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Api; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Keys.Api; + +namespace Proton.Sdk.Addresses; + +internal static class AddressOperations +{ + public static async ValueTask> GetAllAsync(ProtonAccountClient client, CancellationToken cancellationToken) + { + var addressListResponse = await client.Api.Addresses.GetAddressesAsync(cancellationToken).ConfigureAwait(false); + + var addresses = new List
(addressListResponse.Addresses.Count); + + var userKeys = await client.GetUserKeysAsync(cancellationToken).ConfigureAwait(false); + + foreach (var dto in addressListResponse.Addresses) + { + try + { + var address = await ConvertFromDtoAsync(client, dto, userKeys, cancellationToken).ConfigureAwait(false); + + addresses.Add(address); + } + catch (Exception e) + { + client.Logger.LogError(e, "Failed to load address {AddressId}", dto.Id); + } + } + + return addresses; + } + + public static async ValueTask
GetAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) + { + var userKeys = await client.GetUserKeysAsync(cancellationToken).ConfigureAwait(false); + + var response = await client.Api.Addresses.GetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); + + var address = await ConvertFromDtoAsync(client, response.Address, userKeys, cancellationToken).ConfigureAwait(false); + + return address; + } + + public static async ValueTask
GetDefaultAsync(ProtonAccountClient client, CancellationToken cancellationToken) + { + var addresses = await GetAllAsync(client, cancellationToken).ConfigureAwait(false); + + if (addresses.Count == 0) + { + throw new ProtonApiException("User has no address"); + } + + addresses.Sort((a, b) => a.Order.CompareTo(b.Order)); + + return addresses[0]; + } + + public static async ValueTask
ConvertFromDtoAsync( + ProtonAccountClient client, + AddressDto dto, + IReadOnlyList userKeys, + CancellationToken cancellationToken) + { + int? primaryKeyIndex = null; + + var keys = new List(dto.Keys.Count); + var unlockedKeys = new List(dto.Keys.Count); + var keyIndex = 0; + + foreach (var keyDto in dto.Keys) + { + if (!keyDto.IsActive) + { + continue; + } + + try + { + PgpPrivateKey unlockedKey; + + if (keyDto is { Token: not null, Signature: not null }) + { + var passphrase = GetAddressKeyTokenPassphrase(keyDto.Token.Value, keyDto.Signature.Value, userKeys); + unlockedKey = PgpPrivateKey.ImportAndUnlock(keyDto.PrivateKey, passphrase.Span); + } + else + { + var passphrase = await client.SessionSecretCache.TryGetPasswordDerivedKeyPassphraseAsync(keyDto.Id, cancellationToken) + .ConfigureAwait(false); + + if (passphrase is null) + { + client.Logger.LogWarning("No passphrase found for address key {UserKeyId}", keyDto.Id); + continue; + } + + unlockedKey = PgpPrivateKey.ImportAndUnlock(keyDto.PrivateKey, passphrase.Value.Span); + } + + unlockedKeys.Add(unlockedKey); + } + catch + { + // TODO: log that + continue; + } + + var key = new AddressKey( + dto.Id, + keyDto.Id, + keyDto.IsPrimary, + keyDto.IsActive, + (keyDto.Capabilities & AddressKeyCapabilities.IsAllowedForEncryption) != 0, + (keyDto.Capabilities & AddressKeyCapabilities.IsAllowedForSignatureVerification) != 0); + + keys.Add(key); + + if (keyDto.IsPrimary) + { + primaryKeyIndex = keyIndex; + } + + ++keyIndex; + } + + if (primaryKeyIndex is null) + { + throw new ProtonApiException($"Address {dto.Id} has no primary key"); + } + + await client.SecretCache.SetAddressKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); + + return new Address(dto.Id, dto.Order, dto.Email, dto.Status, keys.AsReadOnly(), primaryKeyIndex.Value); + } + + public static async ValueTask> GetKeysAsync( + ProtonAccountClient client, + AddressId addressId, + CancellationToken cancellationToken) + { + var addressKeys = await client.SecretCache.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false) + ?? await GetAddressKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + return addressKeys; + } + + public static async ValueTask GetPrimaryKeyAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) + { + var address = await GetAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + var addressKeys = await GetKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + return addressKeys[address.PrimaryKeyIndex]; + } + + public static async ValueTask> GetPublicKeysAsync( + ProtonAccountClient client, + string emailAddress, + CancellationToken cancellationToken) + { + if (!client.PublicKeyCache.TryGetPublicKeys(emailAddress, out var cachedPublicKeys)) + { + try + { + var publicKeysResponse = await client.Api.Keys.GetActivePublicKeysAsync(emailAddress, cancellationToken).ConfigureAwait(false); + + var publicKeys = new List(publicKeysResponse.Address.Keys.Count); + + for (var keyIndex = 0; keyIndex < publicKeys.Count; ++keyIndex) + { + var keyEntry = publicKeysResponse.Address.Keys[keyIndex]; + if (!keyEntry.Status.HasFlag(PublicKeyStatus.IsNotCompromised)) + { + continue; + } + + var publicKey = PgpPublicKey.Import(keyEntry.PublicKey); + + publicKeys.Add(publicKey); + } + + client.PublicKeyCache.SetPublicKeys(emailAddress, publicKeys); + + cachedPublicKeys = publicKeys; + } + catch (ProtonApiException e) when (e.Code is ResponseCode.UnknownAddress) + { + client.Logger.LogError(e, "Unknown address {EmailAddress}", emailAddress); + + cachedPublicKeys = []; + } + } + + return cachedPublicKeys; + } + + private static ReadOnlyMemory GetAddressKeyTokenPassphrase( + PgpArmoredMessage token, + PgpArmoredSignature signature, + IReadOnlyList userKeys) + { + var userKeyRing = new PgpPrivateKeyRing(userKeys); + using var decryptingStream = PgpDecryptingStream.Open(token.Bytes.AsStream(), userKeyRing, signature, userKeyRing); + + using var passphraseStream = new MemoryStream(); + decryptingStream.CopyTo(passphraseStream); + + // TODO: avoid another allocation + return passphraseStream.ToArray(); + } + + private static async ValueTask> GetAddressKeysAsync( + ProtonAccountClient client, + AddressId addressId, + CancellationToken cancellationToken) + { + var addressKeys = await client.SecretCache.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + + if (addressKeys is null) + { + await GetAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + addressKeys = await client.SecretCache.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + + if (addressKeys is null) + { + throw new ProtonApiException($"Could not get address keys for address {addressId}"); + } + } + + return addressKeys; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressStatus.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressStatus.cs new file mode 100644 index 00000000..01483a43 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressStatus.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Addresses; + +public enum AddressStatus +{ + Disabled = 0, + Enabled = 1, + Deleting = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressDto.cs b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressDto.cs new file mode 100644 index 00000000..8d7e6ee2 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressDto.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Addresses.Api; + +internal sealed record AddressDto +{ + [JsonPropertyName("ID")] + public required AddressId Id { get; init; } + + public required string Email { get; init; } + + public required AddressStatus Status { get; init; } + + public required int Order { get; init; } + + public required IReadOnlyList Keys { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyCapabilities.cs b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyCapabilities.cs new file mode 100644 index 00000000..a7360ead --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyCapabilities.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Addresses.Api; + +[Flags] +public enum AddressKeyCapabilities +{ + None = 0, + IsAllowedForSignatureVerification = 1, + IsAllowedForEncryption = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs new file mode 100644 index 00000000..909f1389 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Addresses.Api; + +internal sealed class AddressKeyDto +{ + [JsonPropertyName("ID")] + public required string Id { get; init; } + + public required int Version { get; init; } + + public PgpArmoredPrivateKey PrivateKey { get; init; } + + public PgpArmoredMessage? Token { get; init; } + + public PgpArmoredSignature? Signature { get; init; } + + [JsonPropertyName("Primary")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsPrimary { get; init; } + + [JsonPropertyName("Active")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsActive { get; init; } + + public required AddressKeyCapabilities Capabilities { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressListResponse.cs b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressListResponse.cs new file mode 100644 index 00000000..7b0aea6f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressListResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Sdk.Addresses.Api; + +internal sealed class AddressListResponse : ApiResponse +{ + public required IReadOnlyList Addresses { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressResponse.cs b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressResponse.cs new file mode 100644 index 00000000..b94e9010 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Sdk.Addresses.Api; + +internal sealed class AddressResponse : ApiResponse +{ + public required AddressDto Address { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressesApiClient.cs b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressesApiClient.cs new file mode 100644 index 00000000..83b0a9c6 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressesApiClient.cs @@ -0,0 +1,23 @@ +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Addresses.Api; + +internal sealed class AddressesApiClient(HttpClient httpClient) : IAddressesApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetAddressesAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.AddressListResponse) + .GetAsync("core/v4/addresses", cancellationToken).ConfigureAwait(false); + } + + public async Task GetAddressAsync(AddressId id, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.AddressResponse) + .GetAsync($"core/v4/addresses/{id}", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/IAddressesApiClient.cs b/cs/sdk/src/Proton.Sdk/Addresses/Api/IAddressesApiClient.cs new file mode 100644 index 00000000..4cb9e8b0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Api/IAddressesApiClient.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Addresses.Api; + +internal interface IAddressesApiClient +{ + Task GetAddressesAsync(CancellationToken cancellationToken); + + Task GetAddressAsync(AddressId id, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/ApiClientFactory.cs b/cs/sdk/src/Proton.Sdk/Api/ApiClientFactory.cs new file mode 100644 index 00000000..0709f969 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/ApiClientFactory.cs @@ -0,0 +1,10 @@ +namespace Proton.Sdk.Api; + +internal sealed class ApiClientFactory : IApiClientFactory +{ + private ApiClientFactory() + { + } + + public static IApiClientFactory Instance { get; set; } = new ApiClientFactory(); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/ApiResponse.cs b/cs/sdk/src/Proton.Sdk/Api/ApiResponse.cs new file mode 100644 index 00000000..eb3c8c6a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/ApiResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Api; + +internal class ApiResponse +{ + public required ResponseCode Code { get; init; } + + [JsonPropertyName("Error")] + public string? ErrorMessage { get; init; } + + public bool IsSuccess => Code is ResponseCode.Success; +} diff --git a/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs b/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs new file mode 100644 index 00000000..6168bce8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs @@ -0,0 +1,13 @@ +using Proton.Sdk.Authentication.Api; +using Proton.Sdk.Keys.Api; + +namespace Proton.Sdk.Api; + +internal interface IApiClientFactory +{ + public IAuthenticationApiClient CreateAuthenticationApiClient(HttpClient httpClient, Uri refreshRedirectUri) + => new AuthenticationApiClient(httpClient, refreshRedirectUri); + + public IKeysApiClient CreateKeysApiClient(HttpClient httpClient) + => new KeysApiClient(httpClient); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs b/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs new file mode 100644 index 00000000..41b95653 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs @@ -0,0 +1,56 @@ +using System.Net; + +namespace Proton.Sdk.Api; + +public enum ResponseCode +{ + Unknown = 0, + + Unauthorized = HttpStatusCode.Unauthorized, + Forbidden = HttpStatusCode.Forbidden, + RequestTimeout = HttpStatusCode.RequestTimeout, + + Success = 1000, + MultipleResponses = 1001, + ParentDoesNotExist = 2000, + InvalidValue = 2001, + InvalidEncryptedIdFormat = 2061, + AlreadyExists = 2500, + DoesNotExist = 2501, + Timeout = 2503, + IncompatibleState = 2511, + InvalidApp = 5002, + OutdatedApp = 5003, + Offline = 7001, + IncorrectLoginCredentials = 8002, + + /// + /// Account is disabled + /// + AccountDeleted = 10002, + + /// + /// Account is disabled due to abuse or fraud + /// + AccountDisabled = 10003, + + InvalidRefreshToken = 10013, + + /// + /// Free account + /// + NoActiveSubscription = 22110, + + UnknownAddress = 33102, + + ProtonDriveUnknown = 200000, + InsufficientQuota = ProtonDriveUnknown + 1, + InsufficientSpace = ProtonDriveUnknown + 2, + MaxFileSizeForFreeUser = ProtonDriveUnknown + 3, + TooManyChildren = ProtonDriveUnknown + 300, + + CustomCode = 10000000, + SocketError = CustomCode + 1, + SessionRefreshFailed = CustomCode + 3, + SrpError = CustomCode + 4, +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationApiClient.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationApiClient.cs new file mode 100644 index 00000000..627a458a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationApiClient.cs @@ -0,0 +1,100 @@ +using Proton.Cryptography.Srp; +using Proton.Sdk.Api; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Authentication.Api; + +internal sealed class AuthenticationApiClient(HttpClient httpClient, Uri refreshRedirectUri) : IAuthenticationApiClient +{ + private readonly Uri _refreshRedirectUri = refreshRedirectUri; + + private readonly HttpClient _httpClient = httpClient; + + public async Task InitiateSessionAsync(string username, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.SessionInitiationResponse) + .PostAsync( + "auth/v4/info", + new SessionInitiationRequest(username), + ProtonApiSerializerContext.Default.SessionInitiationRequest, + cancellationToken).ConfigureAwait(false); + } + + public async Task AuthenticateAsync( + SessionInitiationResponse initiationResponse, + SrpClientHandshake srpClientHandshake, + string username, + CancellationToken cancellationToken) + { + var request = new AuthenticationRequest + { + ClientEphemeral = srpClientHandshake.Ephemeral, + ClientProof = srpClientHandshake.Proof, + SrpSessionId = initiationResponse.SrpSessionId, + Username = username, + }; + + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.AuthenticationResponse) + .PostAsync("auth/v4", request, ProtonApiSerializerContext.Default.AuthenticationRequest, cancellationToken).ConfigureAwait(false); + } + + public async Task ValidateSecondFactorAsync(string secondFactorCode, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ScopesResponse) + .PostAsync( + "auth/v4/2fa", + new SecondFactorValidationRequest(secondFactorCode), + ProtonApiSerializerContext.Default.SecondFactorValidationRequest, + cancellationToken) + .ConfigureAwait(false); + } + + public async Task EndSessionAsync() + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .DeleteAsync("auth/v4", CancellationToken.None).ConfigureAwait(false); + } + + public async Task EndSessionAsync(string sessionId, string accessToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .DeleteAsync("auth/v4", sessionId, accessToken, CancellationToken.None).ConfigureAwait(false); + } + + public async Task RefreshSessionAsync( + SessionId sessionId, + string accessToken, + string refreshToken, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.SessionRefreshResponse) + .PostAsync( + "auth/v4/refresh", + sessionId, + accessToken, + new SessionRefreshRequest(refreshToken, "token", "refresh_token", _refreshRedirectUri), + ProtonApiSerializerContext.Default.SessionRefreshRequest, + cancellationToken).ConfigureAwait(false); + } + + public async Task GetScopesAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ScopesResponse) + .GetAsync("auth/v4/scopes", cancellationToken).ConfigureAwait(false); + } + + public async Task GetRandomSrpModulusAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ModulusResponse) + .GetAsync("auth/v4/modulus", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationRequest.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationRequest.cs new file mode 100644 index 00000000..4ef7113a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Authentication.Api; + +internal sealed class AuthenticationRequest +{ + public required string Username { get; init; } + + public required ReadOnlyMemory ClientEphemeral { get; init; } + + public required ReadOnlyMemory ClientProof { get; init; } + + [JsonPropertyName("SRPSession")] + public required string SrpSessionId { get; init; } + + [JsonPropertyName("TwoFactorCode")] + public string? SecondFactorCode { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationResponse.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationResponse.cs new file mode 100644 index 00000000..5b2ef396 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationResponse.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; +using Proton.Sdk.Events; +using Proton.Sdk.Users; + +namespace Proton.Sdk.Authentication.Api; + +internal sealed class AuthenticationResponse : ApiResponse +{ + [JsonPropertyName("UID")] + public required SessionId SessionId { get; init; } + + [JsonPropertyName("UserID")] + public required UserId UserId { get; init; } + + [JsonPropertyName("EventID")] + public EventId? EventId { get; init; } + + public required ReadOnlyMemory ServerProof { get; init; } + + public required string TokenType { get; init; } + + public required string AccessToken { get; init; } + + public required string RefreshToken { get; init; } + + public required IReadOnlyList Scopes { get; init; } + + public required PasswordMode PasswordMode { get; init; } + + [JsonPropertyName("2FA")] + public SecondFactorParameters? SecondFactorParameters { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/IAuthenticationApiClient.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/IAuthenticationApiClient.cs new file mode 100644 index 00000000..9a5da5a9 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/IAuthenticationApiClient.cs @@ -0,0 +1,31 @@ +using Proton.Cryptography.Srp; +using Proton.Sdk.Api; + +namespace Proton.Sdk.Authentication.Api; + +internal interface IAuthenticationApiClient +{ + Task InitiateSessionAsync(string username, CancellationToken cancellationToken); + + Task AuthenticateAsync( + SessionInitiationResponse initiationResponse, + SrpClientHandshake srpClientHandshake, + string username, + CancellationToken cancellationToken); + + Task ValidateSecondFactorAsync(string secondFactorCode, CancellationToken cancellationToken); + + Task EndSessionAsync(); + + Task EndSessionAsync(string sessionId, string accessToken); + + Task RefreshSessionAsync( + SessionId sessionId, + string accessToken, + string refreshToken, + CancellationToken cancellationToken); + + Task GetScopesAsync(CancellationToken cancellationToken); + + Task GetRandomSrpModulusAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/ModulusResponse.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/ModulusResponse.cs new file mode 100644 index 00000000..6cd598fe --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/ModulusResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Sdk.Authentication.Api; + +internal sealed class ModulusResponse : ApiResponse +{ + public required string Modulus { get; set; } + + [JsonPropertyName("ModulusID")] + public required string ModulusId { get; set; } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/ScopesResponse.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/ScopesResponse.cs new file mode 100644 index 00000000..0e805fa8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/ScopesResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Sdk.Authentication.Api; + +internal sealed class ScopesResponse : ApiResponse +{ + public required IReadOnlyList Scopes { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorParameters.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorParameters.cs new file mode 100644 index 00000000..3bd4862c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorParameters.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Authentication.Api; + +public readonly struct SecondFactorParameters +{ + [JsonPropertyName("Enabled")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsEnabled { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorValidationRequest.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorValidationRequest.cs new file mode 100644 index 00000000..f4182d66 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorValidationRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Authentication.Api; + +internal readonly struct SecondFactorValidationRequest(string secondFactorCode) +{ + [JsonPropertyName("TwoFactorCode")] + public string SecondFactorCode => secondFactorCode; +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationRequest.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationRequest.cs new file mode 100644 index 00000000..ed97eeb8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationRequest.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Authentication.Api; + +internal readonly struct SessionInitiationRequest(string username) +{ + public string Username => username; +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationResponse.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationResponse.cs new file mode 100644 index 00000000..38d25fda --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Sdk.Authentication.Api; + +internal sealed class SessionInitiationResponse : ApiResponse +{ + public required int Version { get; init; } + + // TODO: make this ReadOnlyMemory + public required string Modulus { get; init; } + + public required ReadOnlyMemory ServerEphemeral { get; init; } + + public required ReadOnlyMemory Salt { get; init; } + + [JsonPropertyName("SRPSession")] + public required string SrpSessionId { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshRequest.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshRequest.cs new file mode 100644 index 00000000..18558698 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Authentication.Api; + +internal readonly struct SessionRefreshRequest(string refreshToken, string responseType, string grantType, Uri redirectUri) +{ + public string RefreshToken { get; } = refreshToken; + + [JsonInclude] + public string ResponseType => responseType; + + public string GrantType => grantType; + + [JsonPropertyName("RedirectURI")] + public Uri RedirectUri => redirectUri; +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshResponse.cs b/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshResponse.cs new file mode 100644 index 00000000..35ca3bde --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Sdk.Authentication.Api; + +internal sealed class SessionRefreshResponse : ApiResponse +{ + public required string AccessToken { get; init; } + + public string? TokenType { get; init; } + + public required IReadOnlyList Scopes { get; init; } + + [JsonPropertyName("UID")] + public required SessionId SessionId { get; init; } + + public required string RefreshToken { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs b/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs new file mode 100644 index 00000000..95e33718 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Proton.Sdk.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Authentication; + +internal sealed class AuthorizationHandler(ProtonApiSession session) : DelegatingHandler +{ + private const string SessionIdHeaderName = "x-pm-uid"; + + private readonly ProtonApiSession _session = session; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Add(SessionIdHeaderName, _session.SessionId.ToString()); + + var accessToken = await _session.TokenCredential.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + + var response = await SendWithTokenAsync(request, accessToken, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + response = await HandleUnauthorizedAsync(request, response, accessToken, cancellationToken).ConfigureAwait(false); + } + + return response; + } + + private async Task HandleUnauthorizedAsync( + HttpRequestMessage request, + HttpResponseMessage response, + string rejectedAccessToken, + CancellationToken cancellationToken) + { + var apiResponse = await response.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken).ConfigureAwait(false); + + if (apiResponse?.Code is ResponseCode.AccountDeleted or ResponseCode.AccountDisabled) + { + return response; + } + + var accessToken = await _session.TokenCredential.GetRefreshedAccessTokenAsync(rejectedAccessToken, cancellationToken).ConfigureAwait(false); + + return await SendWithTokenAsync(request, accessToken, cancellationToken).ConfigureAwait(false); + } + + private Task SendWithTokenAsync(HttpRequestMessage request, string accessToken, CancellationToken cancellationToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/PasswordMode.cs b/cs/sdk/src/Proton.Sdk/Authentication/PasswordMode.cs new file mode 100644 index 00000000..e4d1d261 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/PasswordMode.cs @@ -0,0 +1,7 @@ +namespace Proton.Sdk.Authentication; + +public enum PasswordMode +{ + Single = 1, + Dual = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs b/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs new file mode 100644 index 00000000..3bc99fa9 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Authentication; + +public readonly record struct SessionId(string Value) : IStrongId +{ + public static implicit operator SessionId(string value) + { + return new SessionId(value); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs new file mode 100644 index 00000000..843384ee --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.Logging; +using Proton.Sdk.Api; +using Proton.Sdk.Authentication.Api; + +namespace Proton.Sdk.Authentication; + +public sealed class TokenCredential +{ + private readonly IAuthenticationApiClient _client; + private readonly SessionId _sessionId; + private readonly ILogger _logger; + + private Lazy> _tokensTask; + + internal TokenCredential(IAuthenticationApiClient client, SessionId sessionId, string accessToken, string refreshToken, ILogger logger) + { + _client = client; + _sessionId = sessionId; + _logger = logger; + + _tokensTask = new Lazy>(Task.FromResult((accessToken, refreshToken))); + } + + public event Action? TokensRefreshed; + public event Action? RefreshTokenExpired; + + public Task<(string AccessToken, string RefreshToken)> GetTokensAsync(CancellationToken cancellationToken) + { + return _tokensTask.Value.WaitAsync(cancellationToken); + } + + public async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + var (accessToken, _) = await _tokensTask.Value.WaitAsync(cancellationToken).ConfigureAwait(false); + return accessToken; + } + + public async Task GetRefreshedAccessTokenAsync(string rejectedAccessToken, CancellationToken cancellationToken) + { + var currentTokensTask = _tokensTask; + + var (currentAccessToken, currentRefreshToken) = await currentTokensTask.Value.WaitAsync(cancellationToken).ConfigureAwait(false); + + var isLikelyAlreadyRefreshedToken = currentAccessToken != rejectedAccessToken; + if (isLikelyAlreadyRefreshedToken) + { + return currentAccessToken; + } + + var refreshedTokensTask = new Lazy>( + async () => + { + try + { + _logger.Log(LogLevel.Debug, "Refreshing token for {SessionId}", _sessionId); + var response = await _client.RefreshSessionAsync(_sessionId, currentAccessToken, currentRefreshToken, cancellationToken) + .ConfigureAwait(false); + + return (response.AccessToken, response.RefreshToken); + } + catch (ProtonApiException ex) when (ex.Code == ResponseCode.InvalidRefreshToken) + { + throw; + } + catch + { + // Return expired access token to allow refreshing again later + return (currentAccessToken, currentRefreshToken); + } + }); + + var tokensTaskReplaced = Interlocked.CompareExchange(ref _tokensTask, refreshedTokensTask, currentTokensTask) == currentTokensTask; + + try + { + var result = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + + if (tokensTaskReplaced) + { + OnTokensRefreshed(); + } + + return result; + } + catch (ProtonApiException ex) when (ex.Code == ResponseCode.InvalidRefreshToken) + { + if (tokensTaskReplaced) + { + OnRefreshTokenExpired(); + } + + throw; + } + } + + private void OnTokensRefreshed() + { + TokensRefreshed?.Invoke(); + } + + private void OnRefreshTokenExpired() + { + RefreshTokenExpired?.Invoke(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs new file mode 100644 index 00000000..2509117b --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs @@ -0,0 +1,16 @@ +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Caching; + +internal sealed class AccountEntityCache(ICache underlyingCache) +{ + public ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask TryGetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + { + return ValueTask.FromResult(default(Address)); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs new file mode 100644 index 00000000..16280c81 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs @@ -0,0 +1,27 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Caching; + +internal sealed class AccountSecretCache(ICache> underlyingCache) +{ + public ValueTask SetUserKeysAsync(IEnumerable userKeys, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public ValueTask?> TryGetUserKeysAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public ValueTask SetAddressKeysAsync(IEnumerable unlockedKeys, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public ValueTask?> TryGetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/ICache.cs b/cs/sdk/src/Proton.Sdk/Caching/ICache.cs new file mode 100644 index 00000000..2edac1ff --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/ICache.cs @@ -0,0 +1,16 @@ +namespace Proton.Sdk.Caching; + +public interface ICache + where TKey : notnull +{ + ValueTask SetAsync(TKey key, TValue value, CancellationToken cancellationToken); + + ValueTask GetOrCreateAsync( + TKey key, + TValue value, + Func> factory, + IEnumerable? tags = null, + CancellationToken cancellationToken = default); + + IAsyncEnumerable QueryAsync(IEnumerable tags, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/NullCache.cs b/cs/sdk/src/Proton.Sdk/Caching/NullCache.cs new file mode 100644 index 00000000..98b8baca --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/NullCache.cs @@ -0,0 +1,25 @@ +namespace Proton.Sdk.Caching; + +internal sealed class NullCache : ICache + where TKey : notnull +{ + public ValueTask SetAsync(TKey key, TValue value, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask GetOrCreateAsync( + TKey key, + TValue value, + Func> factory, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) + { + return factory.Invoke(key, cancellationToken); + } + + public IAsyncEnumerable QueryAsync(IEnumerable tags, CancellationToken cancellationToken) + { + return AsyncEnumerable.Empty(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs b/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs new file mode 100644 index 00000000..a6cb0150 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Caching.Memory; +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Caching; + +internal sealed class PublicKeyCache +{ + public const int NumberOfMinutesBeforeExpiration = 30; + + private readonly IMemoryCache _memoryCache = new MemoryCache(new MemoryCacheOptions()); + + public void SetPublicKeys(string emailAddress, IReadOnlyList publicKeys) + { + _memoryCache.Set(emailAddress, publicKeys, TimeSpan.FromMinutes(NumberOfMinutesBeforeExpiration)); + } + + public bool TryGetPublicKeys(string emailAddress, [MaybeNullWhen(false)] out IReadOnlyList publicKeys) + { + return _memoryCache.TryGetValue(emailAddress, out publicKeys); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs new file mode 100644 index 00000000..9cb96dad --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs @@ -0,0 +1,14 @@ +namespace Proton.Sdk.Caching; + +internal sealed class SessionSecretCache(ICache> underlyingCache) +{ + public ValueTask SetPasswordDerivedKeyPassphraseAsync(string keyId, ReadOnlyMemory passphrase, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask?> TryGetPasswordDerivedKeyPassphraseAsync(string keyId, CancellationToken cancellationToken) + { + return default; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs b/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs new file mode 100644 index 00000000..b100eeaa --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Cryptography; + +internal interface IPgpArmoredBlock +{ + ReadOnlyMemory Bytes { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs new file mode 100644 index 00000000..6762f8d1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredMessage(ReadOnlyMemory bytes) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = bytes; + + public static implicit operator PgpArmoredMessage(Memory bytes) => new(bytes); + public static implicit operator PgpArmoredMessage(ReadOnlyMemory bytes) => new(bytes); + public static implicit operator PgpArmoredMessage(ArraySegment bytes) => new(bytes); + + public static implicit operator Stream(PgpArmoredMessage key) => key.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredMessage key) => key.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredMessage key) => key.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs new file mode 100644 index 00000000..94783aef --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredPrivateKey(ReadOnlyMemory bytes) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = bytes; + + public static implicit operator PgpArmoredPrivateKey(Memory bytes) => new(bytes); + public static implicit operator PgpArmoredPrivateKey(ReadOnlyMemory bytes) => new(bytes); + public static implicit operator PgpArmoredPrivateKey(ArraySegment bytes) => new(bytes); + + public static implicit operator Stream(PgpArmoredPrivateKey key) => key.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredPrivateKey key) => key.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredPrivateKey key) => key.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs new file mode 100644 index 00000000..40db1ae1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredPublicKey(ReadOnlyMemory bytes) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = bytes; + + public static implicit operator PgpArmoredPublicKey(Memory bytes) => new(bytes); + public static implicit operator PgpArmoredPublicKey(ReadOnlyMemory bytes) => new(bytes); + public static implicit operator PgpArmoredPublicKey(ArraySegment bytes) => new(bytes); + + public static implicit operator Stream(PgpArmoredPublicKey key) => key.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredPublicKey key) => key.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredPublicKey key) => key.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs new file mode 100644 index 00000000..b5e84711 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredSignature(ReadOnlyMemory bytes) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = bytes; + + public static implicit operator PgpArmoredSignature(Memory bytes) => new(bytes); + public static implicit operator PgpArmoredSignature(ReadOnlyMemory bytes) => new(bytes); + public static implicit operator PgpArmoredSignature(ArraySegment bytes) => new(bytes); + + public static implicit operator Stream(PgpArmoredSignature key) => key.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredSignature key) => key.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredSignature key) => key.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/AddressEvent.cs b/cs/sdk/src/Proton.Sdk/Events/Api/AddressEvent.cs new file mode 100644 index 00000000..2edefffb --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Events/Api/AddressEvent.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Addresses.Api; + +namespace Proton.Sdk.Events.Api; + +internal sealed class AddressEvent +{ + public required EventAction Action { get; init; } + + [JsonPropertyName("ID")] + public required AddressId AddressId { get; init; } + + public AddressDto? Address { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/EventAction.cs b/cs/sdk/src/Proton.Sdk/Events/Api/EventAction.cs new file mode 100644 index 00000000..d1cb3cb4 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Events/Api/EventAction.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Events.Api; + +internal enum EventAction +{ + Delete = 0, + Create = 1, + Update = 2, + UpdateFlags = 3, +} diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/EventListResponse.cs b/cs/sdk/src/Proton.Sdk/Events/Api/EventListResponse.cs new file mode 100644 index 00000000..c2e4414a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Events/Api/EventListResponse.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Events.Api; + +internal sealed class EventListResponse : ApiResponse +{ + [JsonPropertyName("EventID")] + public required EventId LastEventId { get; init; } + + [JsonPropertyName("More")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool MoreEntriesExist { get; init; } + + [JsonPropertyName("Refresh")] + public EventsRefreshMask RefreshMask { get; init; } + + public IReadOnlyList? AddressEvents { get; init; } + + public long? UsedSpace { get; init; } + + public long? UsedDriveSpace { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/EventsApiClient.cs b/cs/sdk/src/Proton.Sdk/Events/Api/EventsApiClient.cs new file mode 100644 index 00000000..46934404 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Events/Api/EventsApiClient.cs @@ -0,0 +1,24 @@ +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Events.Api; + +// TODO: make sure that we don't listen to core events twice when Drive will need them to listen to "shared with me" events +internal readonly struct EventsApiClient(HttpClient httpClient) +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetLatestEventAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.LatestEventResponse) + .GetAsync("core/v6/events/latest", cancellationToken).ConfigureAwait(false); + } + + public async Task GetEventsAsync(EventId baselineEventId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.EventListResponse) + .GetAsync($"core/v6/events/{baselineEventId}", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/EventsRefreshMask.cs b/cs/sdk/src/Proton.Sdk/Events/Api/EventsRefreshMask.cs new file mode 100644 index 00000000..c61e8f9a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Events/Api/EventsRefreshMask.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Events.Api; + +[Flags] +internal enum EventsRefreshMask : byte +{ + None = 0, + Mail = 1, + Contacts = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/LatestEventResponse.cs b/cs/sdk/src/Proton.Sdk/Events/Api/LatestEventResponse.cs new file mode 100644 index 00000000..e0266f67 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Events/Api/LatestEventResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Sdk.Events.Api; + +internal sealed class LatestEventResponse : ApiResponse +{ + [JsonPropertyName("EventID")] + public required EventId EventId { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Events/EventId.cs b/cs/sdk/src/Proton.Sdk/Events/EventId.cs new file mode 100644 index 00000000..e97060b0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Events/EventId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Events; + +public readonly record struct EventId(string Value) : IStrongId +{ + public static implicit operator EventId(string value) + { + return new EventId(value); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs b/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs new file mode 100644 index 00000000..3fa7de92 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs @@ -0,0 +1,31 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Http; + +internal sealed class CryptographyTimeProvisionHandler : DelegatingHandler +{ + private readonly CryptographyTimeProvider _cryptographyTimeProvider = new(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var responseMessage = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (responseMessage.Headers.Date is { } time) + { + _cryptographyTimeProvider.ServerTime = time; + PgpEnvironment.DefaultTimeProviderOverride = _cryptographyTimeProvider; + } + + return responseMessage; + } + + private sealed class CryptographyTimeProvider : TimeProvider + { + public DateTimeOffset ServerTime { get; set; } + + public override DateTimeOffset GetUtcNow() + { + return ServerTime; + } + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs new file mode 100644 index 00000000..55a1259d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -0,0 +1,115 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Proton.Sdk.Api; +using Proton.Sdk.Authentication; + +namespace Proton.Sdk.Http; + +// TODO: add unit tests +internal readonly struct HttpApiCallBuilder + where TFailure : ApiResponse +{ + private readonly HttpClient _httpClient; + private readonly JsonTypeInfo _successTypeInfo; + private readonly JsonTypeInfo _failureTypeInfo; + + internal HttpApiCallBuilder(HttpClient httpClient, JsonTypeInfo successTypeInfo, JsonTypeInfo failureTypeInfo) + { + _httpClient = httpClient; + _successTypeInfo = successTypeInfo; + _failureTypeInfo = failureTypeInfo; + } + + public async ValueTask GetAsync(string requestUri, CancellationToken cancellationToken, byte[]? operationId = null) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Get, requestUri); + + if (operationId is not null) + { + requestMessage.Options.Set(new HttpRequestOptionsKey("ShouldBePassedWithOperationId"), operationId); + } + + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetAsync(string requestUri, string sessionId, string accessToken, CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Get, requestUri, sessionId, accessToken); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask PostAsync( + string requestUri, + TRequestBody body, + JsonTypeInfo bodyTypeInfo, + CancellationToken cancellationToken, + byte[]? operationId = null) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, requestUri, body, bodyTypeInfo); + + if (operationId is not null) + { + requestMessage.Options.Set(new HttpRequestOptionsKey("ShouldBePassedWithOperationId"), operationId); + } + + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask PostAsync( + string requestUri, + SessionId sessionId, + string accessToken, + TRequestBody body, + JsonTypeInfo bodyTypeInfo, + CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, requestUri, sessionId, accessToken, body, bodyTypeInfo); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask PostAsync(string requestUri, HttpContent content, CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, requestUri, content); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask PutAsync( + string requestUri, + TRequestBody body, + JsonTypeInfo bodyTypeInfo, + CancellationToken cancellationToken, + byte[]? operationId = null) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Put, requestUri, body, bodyTypeInfo); + + if (operationId is not null) + { + requestMessage.Options.Set(new HttpRequestOptionsKey("ShouldBePassedWithOperationId"), operationId); + } + + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DeleteAsync(string requestUri, CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Delete, requestUri); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DeleteAsync(string requestUri, string sessionId, string accessToken, CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Delete, requestUri, sessionId, accessToken); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask SendAsync(HttpRequestMessage requestMessage, CancellationToken cancellationToken) + { + var responseMessage = await _httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + + await responseMessage.EnsureApiSuccessAsync(_failureTypeInfo, cancellationToken).ConfigureAwait(false); + + return await responseMessage.Content.ReadFromJsonAsync(_successTypeInfo, cancellationToken) + .ConfigureAwait(false) ?? throw new JsonException(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpClientExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpClientExtensions.cs new file mode 100644 index 00000000..40b3b6b3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpClientExtensions.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization.Metadata; +using Proton.Sdk.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Http; + +internal static class HttpClientExtensions +{ + public static HttpApiCallBuilder Expecting(this HttpClient httpClient, JsonTypeInfo successTypeInfo) + { + return new HttpApiCallBuilder(httpClient, successTypeInfo, ProtonApiSerializerContext.Default.ApiResponse); + } + + public static HttpApiCallBuilder Expecting( + this HttpClient httpClient, + JsonTypeInfo successTypeInfo, + JsonTypeInfo failureTypeInfo) + where TFailure : ApiResponse + { + return new HttpApiCallBuilder(httpClient, successTypeInfo, failureTypeInfo); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs new file mode 100644 index 00000000..d49e2340 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs @@ -0,0 +1,14 @@ +using System.Net.Http.Headers; + +namespace Proton.Sdk.Http; + +internal static class HttpRequestHeadersExtensions +{ + private const string ContentType = "application/vnd.protonmail.api+json"; + + public static void AddApiRequestHeaders(this HttpRequestHeaders headerCollection) + { + // TODO: Add Accept-Language header + headerCollection.Accept.Add(new MediaTypeWithQualityHeaderValue(ContentType)); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageFactory.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageFactory.cs new file mode 100644 index 00000000..30704651 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageFactory.cs @@ -0,0 +1,62 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization.Metadata; +using Proton.Sdk.Authentication; + +namespace Proton.Sdk.Http; + +internal static class HttpRequestMessageFactory +{ + public static HttpRequestMessage Create(HttpMethod method, string uri) + { + return new HttpRequestMessage(method, uri); + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, string accessToken) + { + var result = Create(method, uri); + result.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return result; + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, string sessionId, string accessToken) + { + var result = Create(method, uri, accessToken); + result.Headers.Add("x-pm-uid", sessionId); + return result; + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, TBody body, JsonTypeInfo bodyTypeInfo) + { + var result = Create(method, uri); + result.Content = JsonContent.Create(body, bodyTypeInfo); + return result; + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, string accessToken, TBody body, JsonTypeInfo bodyTypeInfo) + { + var result = Create(method, uri, body, bodyTypeInfo); + result.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return result; + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, HttpContent content) + { + var result = Create(method, uri); + result.Content = content; + return result; + } + + public static HttpRequestMessage Create( + HttpMethod method, + string uri, + SessionId sessionId, + string accessToken, + TBody body, + JsonTypeInfo bodyTypeInfo) + { + var result = Create(method, uri, accessToken, body, bodyTypeInfo); + result.Headers.Add("x-pm-uid", sessionId.ToString()); + return result; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs new file mode 100644 index 00000000..b3e0a503 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs @@ -0,0 +1,42 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Proton.Sdk.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Http; + +internal static class HttpResponseMessageExtensions +{ + // TODO: add unit test + public static async Task EnsureApiSuccessAsync( + this HttpResponseMessage responseMessage, + JsonTypeInfo failureTypeInfo, + CancellationToken cancellationToken) + where TFailure : ApiResponse + { + switch (responseMessage.StatusCode) + { + case HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict: + { + var response = await responseMessage.Content.ReadFromJsonAsync(failureTypeInfo, cancellationToken) + .ConfigureAwait(false) ?? throw new JsonException(); + + throw new ProtonApiException(responseMessage.StatusCode, response); + } + + case HttpStatusCode.BadRequest or HttpStatusCode.TooManyRequests: + { + var response = await responseMessage.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken) + .ConfigureAwait(false) ?? throw new JsonException(); + + throw new ProtonApiException(responseMessage.StatusCode, response); + } + + default: + responseMessage.EnsureSuccessStatusCode(); + break; + } + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs new file mode 100644 index 00000000..e08b810f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs @@ -0,0 +1,33 @@ +using System.Net; +using System.Net.Security; +using Microsoft.Extensions.DependencyInjection; + +namespace Proton.Sdk.Http; + +internal static class SocketsHttpHandlerExtensions +{ + public static SocketsHttpHandler AddAutomaticDecompression(this SocketsHttpHandler handler) + { + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli; + return handler; + } + + public static SocketsHttpHandler ConfigureCookies(this SocketsHttpHandler handler, CookieContainer cookieContainer) + { + handler.CookieContainer = cookieContainer; + return handler; + } + + /// + /// Configures the to apply server certificate public key pinning for an . + /// + /// The . + /// An that can be used to configure the client. + public static SocketsHttpHandler AddTlsPinning(this SocketsHttpHandler handler) + { + handler.SslOptions.RemoteCertificateValidationCallback = + (_, certificate, chain, sslPolicyErrors) => sslPolicyErrors == SslPolicyErrors.None && TlsRemoteCertificateValidator.Validate(certificate, chain); + + return handler; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs b/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs new file mode 100644 index 00000000..d2de1c3d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs @@ -0,0 +1,77 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Proton.Sdk.Http; + +internal static class TlsRemoteCertificateValidator +{ + private static readonly IReadOnlyCollection KnownPublicKeySha256Digests = + [ + Convert.FromBase64String("CT56BhOTmj5ZIPgb/xD5mH8rY3BLo/MlhP7oPyJUEDo="), + Convert.FromBase64String("35Dx28/uzN3LeltkCBQ8RHK0tlNSa2kCpCRGNp34Gxc="), + Convert.FromBase64String("qYIukVc63DEITct8sFT7ebIq5qsWmuscaIKeJx+5J5A="), + ]; + + public static bool Validate(X509Certificate? certificate, X509Chain? chain) + { + if (certificate == null || chain == null) + { + return false; + } + + var certificateIsValid = IsValid(certificate); + + // TODO: TLS certificate pinning report + + // Ignore other potential SSL policy errors if the certificate is valid. + return certificateIsValid; + } + + private static bool IsValid(X509Certificate certificate) + { + using var certificate2 = new X509Certificate2(certificate); + Span hashDigestBuffer = stackalloc byte[SHA256.HashSizeInBytes]; + if (!TryGetPublicKeySha256Digest(certificate2, hashDigestBuffer)) + { + return false; + } + + var validHashFound = false; +#pragma warning disable S3267 // False positive: https://github.com/SonarSource/sonar-dotnet/issues/8430 + foreach (var knownPublicKeyHashDigest in KnownPublicKeySha256Digests) +#pragma warning restore S3267 + { + if (knownPublicKeyHashDigest.AsSpan().SequenceEqual(hashDigestBuffer)) + { + validHashFound = true; + break; + } + } + + return validHashFound; + } + + private static bool TryGetPublicKeySha256Digest(X509Certificate2 certificate, Span outputBuffer) + { + var publicKey = certificate.GetRSAPublicKey() as AsymmetricAlgorithm + ?? certificate.GetDSAPublicKey() + ?? throw new NotSupportedException("No supported key algorithm"); + + // Expected length of public key info is around 550 bytes + var publicKeyInfoBuffer = ArrayPool.Shared.Rent(1024); + + try + { + var publishKeyInfo = publicKey.TryExportSubjectPublicKeyInfo(publicKeyInfoBuffer, out var publicKeyInfoLength) + ? publicKeyInfoBuffer.AsSpan()[..publicKeyInfoLength] + : publicKey.ExportSubjectPublicKeyInfo(); + + return SHA256.TryHashData(publishKeyInfo, outputBuffer, out _); + } + finally + { + ArrayPool.Shared.Return(publicKeyInfoBuffer); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/AddressPublicKeyListResponse.cs b/cs/sdk/src/Proton.Sdk/Keys/Api/AddressPublicKeyListResponse.cs new file mode 100644 index 00000000..84b52ea1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Keys/Api/AddressPublicKeyListResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Keys.Api; + +internal sealed class AddressPublicKeyListResponse : ApiResponse +{ + public required PublicKeyListAddress Address { get; init; } + + public IReadOnlyList? Warnings { get; init; } + + [JsonPropertyName("ProtonMX")] + public required bool IsProtonMxDomain { get; init; } + + [JsonPropertyName("IsProton")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsProtonAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/IKeysApiClient.cs b/cs/sdk/src/Proton.Sdk/Keys/Api/IKeysApiClient.cs new file mode 100644 index 00000000..cd8717df --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Keys/Api/IKeysApiClient.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Keys.Api; + +internal interface IKeysApiClient +{ + Task GetActivePublicKeysAsync(string emailAddress, CancellationToken cancellationToken); + + Task GetKeySaltsAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/KeySalt.cs b/cs/sdk/src/Proton.Sdk/Keys/Api/KeySalt.cs new file mode 100644 index 00000000..e7b90737 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Keys/Api/KeySalt.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Keys.Api; + +public sealed class KeySalt +{ + [JsonPropertyName("ID")] + public required string KeyId { get; init; } + + [JsonPropertyName("KeySalt")] + public required ReadOnlyMemory Value { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/KeySaltListResponse.cs b/cs/sdk/src/Proton.Sdk/Keys/Api/KeySaltListResponse.cs new file mode 100644 index 00000000..1b6ba191 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Keys/Api/KeySaltListResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Sdk.Keys.Api; + +internal sealed class KeySaltListResponse : ApiResponse +{ + public required IReadOnlyList KeySalts { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/KeysApiClient.cs b/cs/sdk/src/Proton.Sdk/Keys/Api/KeysApiClient.cs new file mode 100644 index 00000000..0dbc92e7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Keys/Api/KeysApiClient.cs @@ -0,0 +1,23 @@ +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Keys.Api; + +internal sealed class KeysApiClient(HttpClient httpClient) : IKeysApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetActivePublicKeysAsync(string emailAddress, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.AddressPublicKeyListResponse) + .GetAsync($"core/v4/keys/all?InternalOnly=1&Email={emailAddress}", cancellationToken).ConfigureAwait(false); + } + + public async Task GetKeySaltsAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.KeySaltListResponse) + .GetAsync("core/v4/keys/salts", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyEntry.cs b/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyEntry.cs new file mode 100644 index 00000000..d2416741 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyEntry.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Keys.Api; + +internal sealed class PublicKeyEntry +{ + [JsonPropertyName("Flags")] + public required PublicKeyStatus Status { get; init; } + + public required PgpArmoredPublicKey PublicKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyListAddress.cs b/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyListAddress.cs new file mode 100644 index 00000000..c2971f23 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyListAddress.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Keys.Api; + +internal sealed record PublicKeyListAddress +{ + public required IReadOnlyList Keys { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyStatus.cs b/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyStatus.cs new file mode 100644 index 00000000..bb2bfc7d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyStatus.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Keys.Api; + +[Flags] +internal enum PublicKeyStatus +{ + IsNotCompromised = 1, + IsNotObsolete = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/MemoryProvider.cs b/cs/sdk/src/Proton.Sdk/MemoryProvider.cs new file mode 100644 index 00000000..9bebdfcf --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/MemoryProvider.cs @@ -0,0 +1,23 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using CommunityToolkit.HighPerformance.Buffers; + +namespace Proton.Sdk; +internal static class MemoryProvider +{ + private const int MaxStackBufferSize = 256; + + public static bool GetHeapMemoryIfTooLargeForStack(int size, [MaybeNullWhen(false)] out IMemoryOwner heapMemoryOwner) + where T : struct + { + if ((size * Unsafe.SizeOf()) <= MaxStackBufferSize) + { + heapMemoryOwner = null; + return false; + } + + heapMemoryOwner = MemoryOwner.Allocate(size); + return true; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj new file mode 100644 index 00000000..ea44ccf3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -0,0 +1,44 @@ + + + + Authentication Session Account + Package that provides the means to authenticate with the Proton API and get user account information. + true + true + snupkg + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs new file mode 100644 index 00000000..4d9c14ff --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; +using Proton.Sdk.Caching; + +namespace Proton.Sdk; + +public sealed class ProtonAccountClient +{ + public ProtonAccountClient(ProtonApiSession session) + : this(new AccountApiClients(session.GetHttpClient()), session.ClientConfiguration, session.SecretCache) + { + } + + internal ProtonAccountClient(AccountApiClients apiClients, ProtonClientConfiguration configuration, SessionSecretCache sessionSecretCache) + { + Api = apiClients; + Logger = configuration.LoggerFactory.CreateLogger(); + SessionSecretCache = sessionSecretCache; + EntityCache = new AccountEntityCache(configuration.EntityCache); + SecretCache = new AccountSecretCache(configuration.SecretCache); + } + + internal AccountApiClients Api { get; } + + internal AccountEntityCache EntityCache { get; } + internal AccountSecretCache SecretCache { get; } + internal SessionSecretCache SessionSecretCache { get; } + internal PublicKeyCache PublicKeyCache { get; } = new(); + + internal ILogger Logger { get; } + + public ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + { + return AddressOperations.GetAsync(this, addressId, cancellationToken); + } + + public ValueTask> GetAddressesAsync(CancellationToken cancellationToken) + { + return AddressOperations.GetAllAsync(this, cancellationToken); + } + + public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) + { + return AddressOperations.GetDefaultAsync(this, cancellationToken); + } + + internal async Task> GetUserKeysAsync(CancellationToken cancellationToken) + { + var userKeys = await SecretCache.TryGetUserKeysAsync(cancellationToken).ConfigureAwait(false); + + if (userKeys is null) + { + await RefreshUserKeysAsync(cancellationToken).ConfigureAwait(false); + } + + userKeys = await SecretCache.TryGetUserKeysAsync(cancellationToken).ConfigureAwait(false); + + if (userKeys is null) + { + throw new ProtonApiException("No active user key was found."); + } + + return userKeys; + } + + internal ValueTask> GetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + { + return AddressOperations.GetKeysAsync(this, addressId, cancellationToken); + } + + internal ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken) + { + return AddressOperations.GetPrimaryKeyAsync(this, addressId, cancellationToken); + } + + internal ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) + { + return AddressOperations.GetPublicKeysAsync(this, emailAddress, cancellationToken); + } + + private async ValueTask RefreshUserKeysAsync(CancellationToken cancellationToken) + { + var response = await Api.Users.GetAuthenticatedUserAsync(cancellationToken).ConfigureAwait(false); + + var unlockedKeys = new List(response.User.Keys.Count); + + foreach (var userKey in response.User.Keys) + { + if (!userKey.IsActive) + { + continue; + } + + var passphrase = await SessionSecretCache.TryGetPasswordDerivedKeyPassphraseAsync(userKey.Id.Value, cancellationToken).ConfigureAwait(false); + + if (passphrase is null) + { + Logger.LogWarning("No passphrase found for user key {UserKeyId}", userKey.Id); + continue; + } + + var unlockedUserKey = PgpPrivateKey.ImportAndUnlock(userKey.PrivateKey.Bytes.Span, passphrase.Value.Span); + + unlockedKeys.Add(unlockedUserKey); + } + + if (unlockedKeys.Count == 0) + { + throw new ProtonApiException("No active user key was found."); + } + + await SecretCache.SetUserKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs b/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs new file mode 100644 index 00000000..b46219c4 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; +using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Authentication; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Events; +using Proton.Sdk.Serialization; +using Proton.Sdk.Users; + +namespace Proton.Sdk; + +internal static class ProtonApiDefaults +{ + public static Uri BaseUrl { get; } = new("https://drive-api.proton.me/"); + + public static Uri RefreshRedirectUri { get; } = new("https://proton.me"); + + public static JsonSerializerOptions GetSerializerOptions() + { + return new JsonSerializerOptions + { + Converters = + { + new PgpArmoredBlockJsonConverter(PgpBlockType.Message, bytes => new PgpArmoredMessage(bytes)), + new PgpArmoredBlockJsonConverter(PgpBlockType.Signature, bytes => new PgpArmoredSignature(bytes)), + new PgpArmoredBlockJsonConverter(PgpBlockType.PublicKey, bytes => new PgpArmoredPublicKey(bytes)), + new PgpArmoredBlockJsonConverter(PgpBlockType.PrivateKey, bytes => new PgpArmoredPrivateKey(bytes)), + new StrongIdConverter(), + new StrongIdConverter(), + new StrongIdConverter(), + new StrongIdConverter(), + new StrongIdConverter(), + new StrongIdConverter(), + }, +#if DEBUG + WriteIndented = true, +#endif + }; + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiException.cs b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs new file mode 100644 index 00000000..65945bcf --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs @@ -0,0 +1,31 @@ +using System.Net; +using Proton.Sdk.Api; + +namespace Proton.Sdk; + +public class ProtonApiException : Exception +{ + public ProtonApiException() + { + } + + public ProtonApiException(string message) + : base(message) + { + } + + public ProtonApiException(string message, Exception innerException) + : base(message, innerException) + { + } + + internal ProtonApiException(HttpStatusCode statusCode, ApiResponse response) + : this($"{response.Code}: {response.ErrorMessage}") + { + Code = response.Code; + TransportCode = (int)statusCode; + } + + public ResponseCode Code { get; } + public int TransportCode { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiException{T}.cs b/cs/sdk/src/Proton.Sdk/ProtonApiException{T}.cs new file mode 100644 index 00000000..172eb533 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonApiException{T}.cs @@ -0,0 +1,30 @@ +using System.Net; +using Proton.Sdk.Api; + +namespace Proton.Sdk; + +internal sealed class ProtonApiException : ProtonApiException + where T : ApiResponse +{ + public ProtonApiException() + { + } + + public ProtonApiException(string message) + : base(message) + { + } + + public ProtonApiException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ProtonApiException(HttpStatusCode statusCode, T response) + : base(statusCode, response) + { + Response = response; + } + + public T? Response { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs new file mode 100644 index 00000000..fbd83b02 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -0,0 +1,263 @@ +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Srp; +using Proton.Sdk.Api; +using Proton.Sdk.Authentication; +using Proton.Sdk.Authentication.Api; +using Proton.Sdk.Caching; +using Proton.Sdk.Keys.Api; +using Proton.Sdk.Users; + +namespace Proton.Sdk; + +public sealed class ProtonApiSession +{ + private readonly HttpClient _httpClient; + + private bool _isEnded; + private Action? _ended; + private IAuthenticationApiClient? _authenticationApi; + private IKeysApiClient? _keysApi; + + internal ProtonApiSession( + SessionId sessionId, + string username, + UserId userId, + TokenCredential tokenCredential, + IEnumerable scopes, + bool isWaitingForSecondFactorCode, + PasswordMode passwordMode, + ProtonClientConfiguration clientConfiguration) + { + _httpClient = clientConfiguration.GetHttpClient(this); + + Username = username; + UserId = userId; + SessionId = sessionId; + TokenCredential = tokenCredential; + Scopes = scopes.ToArray().AsReadOnly(); + IsWaitingForSecondFactorCode = isWaitingForSecondFactorCode; + PasswordMode = passwordMode; + ClientConfiguration = clientConfiguration; + SecretCache = new SessionSecretCache(clientConfiguration.SecretCache); + } + + public event Action? Ended + { + add + { + _ended += value; + TokenCredential.RefreshTokenExpired -= OnRefreshTokenExpired; + TokenCredential.RefreshTokenExpired += OnRefreshTokenExpired; + } + remove + { + _ended -= value; + TokenCredential.RefreshTokenExpired -= OnRefreshTokenExpired; + } + } + + public SessionId SessionId { get; } + + public string Username { get; } + + public UserId UserId { get; } + + public TokenCredential TokenCredential { get; } + + public IReadOnlyList Scopes { get; private set; } + + public bool IsWaitingForSecondFactorCode { get; private set; } + + public PasswordMode PasswordMode { get; } + + internal ProtonClientConfiguration ClientConfiguration { get; } + + internal SessionSecretCache SecretCache { get; } + + private IAuthenticationApiClient AuthenticationApi + => _authenticationApi ??= ApiClientFactory.Instance.CreateAuthenticationApiClient(_httpClient, ClientConfiguration.RefreshRedirectUri); + + private IKeysApiClient KeysApi => _keysApi ??= new KeysApiClient(_httpClient); + + public static Task BeginAsync(string username, ReadOnlyMemory password, string appVersion, CancellationToken cancellationToken) + { + return BeginAsync(username, password, appVersion, new ProtonClientOptions(), cancellationToken); + } + + public static async Task BeginAsync( + string username, + ReadOnlyMemory password, + string appVersion, + ProtonClientOptions options, + CancellationToken cancellationToken) + { + var configuration = new ProtonClientConfiguration(appVersion, options); + + var authApiClient = ApiClientFactory.Instance.CreateAuthenticationApiClient(configuration.GetHttpClient(), configuration.RefreshRedirectUri); + + var sessionInitResponse = await authApiClient.InitiateSessionAsync(username, cancellationToken).ConfigureAwait(false); + + var srpClient = SrpClient.Create( + username, + password.Span, + sessionInitResponse.Salt.Span, + sessionInitResponse.Modulus, + SrpClient.GetDefaultModulusVerificationKey()); + + var srpClientHandshake = srpClient.ComputeHandshake(sessionInitResponse.ServerEphemeral.Span, 2048); + + var authResponse = await authApiClient.AuthenticateAsync(sessionInitResponse, srpClientHandshake, username, cancellationToken) + .ConfigureAwait(false); + + var tokenCredential = new TokenCredential( + authApiClient, + authResponse.SessionId, + authResponse.AccessToken, + authResponse.RefreshToken, + configuration.LoggerFactory.CreateLogger()); + + var session = new ProtonApiSession( + authResponse.SessionId, + username, + authResponse.UserId, + tokenCredential, + authResponse.Scopes, + authResponse.SecondFactorParameters?.IsEnabled == true, + authResponse.PasswordMode, + configuration); + + if (session is { IsWaitingForSecondFactorCode: false, PasswordMode: PasswordMode.Single }) + { + try + { + await session.ApplyDataPasswordAsync(password, cancellationToken).ConfigureAwait(false); + } + catch + { + // Ignore + // TODO: log that + } + } + + return session; + } + + public static ProtonApiSession Resume( + string appVersion, + SessionId sessionId, + string username, + UserId userId, + string accessToken, + string refreshToken, + IEnumerable scopes, + bool isWaitingForSecondFactorCode, + PasswordMode passwordMode, + ProtonClientOptions? options = null) + { + var configuration = new ProtonClientConfiguration(appVersion, options); + + var logger = configuration.LoggerFactory.CreateLogger(); + + var tokenCredential = new TokenCredential( + ApiClientFactory.Instance.CreateAuthenticationApiClient(configuration.GetHttpClient(), configuration.RefreshRedirectUri), + sessionId, + accessToken, + refreshToken, + configuration.LoggerFactory.CreateLogger()); + + var session = new ProtonApiSession( + sessionId, + username, + userId, + tokenCredential, + scopes, + isWaitingForSecondFactorCode, + passwordMode, + configuration); + + logger.Log(LogLevel.Information, "Session {SessionId} was resumed", session.SessionId); + + return session; + } + + public static async Task EndAsync(string id, string accessToken, string appVersion, ProtonClientOptions? options = null) + { + var configuration = new ProtonClientConfiguration(appVersion, options); + + var authApiClient = ApiClientFactory.Instance.CreateAuthenticationApiClient(configuration.GetHttpClient(), configuration.RefreshRedirectUri); + + await authApiClient.EndSessionAsync(id, accessToken).ConfigureAwait(false); + } + + public async Task ApplySecondFactorCodeAsync(string secondFactorCode, CancellationToken cancellationToken) + { + var response = await AuthenticationApi.ValidateSecondFactorAsync(secondFactorCode, cancellationToken).ConfigureAwait(false); + + IsWaitingForSecondFactorCode = false; + Scopes = response.Scopes; + } + + public async Task ApplyDataPasswordAsync(ReadOnlyMemory password, CancellationToken cancellationToken) + { + var response = await KeysApi.GetKeySaltsAsync(cancellationToken).ConfigureAwait(false); + + foreach (var keySalt in response.KeySalts) + { + if (keySalt.Value.IsEmpty) + { + continue; + } + + var passphrase = DeriveSecretFromPassword(password.Span, keySalt.Value.Span); + + await SecretCache.SetPasswordDerivedKeyPassphraseAsync(keySalt.KeyId, passphrase, cancellationToken).ConfigureAwait(false); + } + } + + public async Task RefreshScopesAsync(CancellationToken cancellationToken) + { + var scopesResponse = await AuthenticationApi.GetScopesAsync(cancellationToken).ConfigureAwait(false); + + Scopes = scopesResponse.Scopes; + } + + public async Task EndAsync() + { + if (_isEnded) + { + return true; + } + + var response = await AuthenticationApi.EndSessionAsync().ConfigureAwait(false); + + if (response.IsSuccess) + { + _isEnded = true; + + _ended?.Invoke(); + } + + return _isEnded; + } + + internal HttpClient GetHttpClient(string? baseRoutePath = null, TimeSpan? attemptTimeout = null) + { + return baseRoutePath is null && attemptTimeout is null + ? _httpClient + : ClientConfiguration.GetHttpClient(this, baseRoutePath, attemptTimeout); + } + + private static ReadOnlyMemory DeriveSecretFromPassword(ReadOnlySpan password, ReadOnlySpan salt) + { + var hashDigest = SrpClient.HashPassword(password, salt).AsMemory(); + + // Skip the first 29 characters which include the algorithm type, the number of rounds and the salt. + return hashDigest[29..]; + } + + private void OnRefreshTokenExpired() + { + _isEnded = true; + _ended?.Invoke(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs new file mode 100644 index 00000000..5b68c755 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Proton.Sdk.Caching; + +namespace Proton.Sdk; + +internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientOptions? options) +{ + public Uri BaseUrl { get; } = options?.BaseUrl ?? ProtonApiDefaults.BaseUrl; + public string AppVersion { get; } = appVersion; + public string UserAgent { get; } = options?.UserAgent ?? string.Empty; + public bool DisableTlsCertificatePinning { get; } = options?.DisableTlsCertificatePinning ?? false; + public bool IgnoreSslCertificateErrors { get; } = options?.IgnoreSslCertificateErrors ?? false; + public Func? CustomHttpMessageHandlerFactory { get; } = options?.CustomHttpMessageHandlerFactory; + public ICache> SecretCache { get; } = options?.SecretCache ?? new NullCache>(); + public ICache EntityCache { get; } = options?.EntityCache ?? new NullCache(); + public ILoggerFactory LoggerFactory { get; } = options?.LoggerFactory ?? NullLoggerFactory.Instance; + public Uri RefreshRedirectUri { get; } = options?.RefreshRedirectUri ?? ProtonApiDefaults.RefreshRedirectUri; + public string? BindingsLanguage { get; } = options?.BindingsLanguage; +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs new file mode 100644 index 00000000..fe1b1075 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; +using Polly; +using Proton.Sdk.Authentication; +using Proton.Sdk.Http; + +namespace Proton.Sdk; + +internal static class ProtonClientConfigurationExtensions +{ + private static readonly CookieContainer CookieContainer = new(); + + public static HttpClient GetHttpClient( + this ProtonClientConfiguration config, + ProtonApiSession? session = null, + string? baseRoutePath = null, + TimeSpan? attemptTimeout = null) + { + var baseAddress = config.BaseUrl + (baseRoutePath ?? string.Empty); + + var services = new ServiceCollection(); + + services.AddSingleton(config.LoggerFactory); + + services.ConfigureHttpClientDefaults( + builder => + { + builder.UseSocketsHttpHandler( + (handler, _) => + { + handler.AddAutomaticDecompression(); + handler.ConfigureCookies(CookieContainer); + + if (config.IgnoreSslCertificateErrors) + { +#pragma warning disable S4830 // Certificates are intentionally not verified + handler.SslOptions.RemoteCertificateValidationCallback += (_, _, _, _) => true; +#pragma warning restore S4830 + } + else if (!config.DisableTlsCertificatePinning) + { + handler.AddTlsPinning(); + } + }); + + if (config.CustomHttpMessageHandlerFactory is not null) + { + builder.AddHttpMessageHandler(() => config.CustomHttpMessageHandlerFactory.Invoke()); + } + + builder.AddHttpMessageHandler(() => new CryptographyTimeProvisionHandler()); + + builder.AddStandardResilienceHandler( + options => + { + if (attemptTimeout is not null) + { + options.AttemptTimeout.Timeout = attemptTimeout.Value; + options.CircuitBreaker.SamplingDuration = options.AttemptTimeout.Timeout * 3; + } + + options.Retry.ShouldRetryAfterHeader = true; + options.Retry.Delay = TimeSpan.FromSeconds(2.5); + options.Retry.BackoffType = DelayBackoffType.Exponential; + options.Retry.UseJitter = true; + options.Retry.MaxRetryAttempts = 4; + + var totalTimeout = (options.AttemptTimeout.Timeout + options.Retry.Delay) * options.Retry.MaxRetryAttempts * 1.5; + options.TotalRequestTimeout = new HttpTimeoutStrategyOptions { Timeout = totalTimeout }; + + options.CircuitBreaker.FailureRatio = 0.5; + }); + + if (session is not null) + { + builder.AddHttpMessageHandler(() => new AuthorizationHandler(session)); + } + + builder.ConfigureHttpClient( + httpClient => + { + var executingAssembly = Assembly.GetExecutingAssembly(); + var versionAttribute = executingAssembly.GetCustomAttribute(); + var sdkVersion = versionAttribute?.InformationalVersion + ?? executingAssembly.GetName().Version?.ToString(fieldCount: 3) + ?? "0.0.0"; + + var bindingsSuffix = config.BindingsLanguage is not null + ? "-" + config.BindingsLanguage.ToLowerInvariant() + : string.Empty; + + var sdkTechnicalStack = "dotnet" + bindingsSuffix; + + httpClient.BaseAddress = new Uri(baseAddress); + httpClient.DefaultRequestHeaders.Add("x-pm-appversion", config.AppVersion); + httpClient.DefaultRequestHeaders.Add("x-pm-drive-sdk-version", $"{sdkTechnicalStack}@{sdkVersion}"); + + if (!string.IsNullOrEmpty(config.UserAgent)) + { + httpClient.DefaultRequestHeaders.Add("User-Agent", config.UserAgent); + } + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + return serviceProvider.GetRequiredService(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs new file mode 100644 index 00000000..6630c2c8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; +using Proton.Sdk.Caching; + +namespace Proton.Sdk; + +public sealed class ProtonClientOptions() +{ + public Uri? BaseUrl { get; set; } + public string? UserAgent { get; set; } + public bool? DisableTlsCertificatePinning { get; set; } + public bool? IgnoreSslCertificateErrors { get; set; } + public Func? CustomHttpMessageHandlerFactory { get; set; } + public ICache>? SecretCache { get; set; } + public ICache? EntityCache { get; set; } + public ILoggerFactory? LoggerFactory { get; set; } + internal Uri? RefreshRedirectUri { get; set; } + internal string? BindingsLanguage { get; set; } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/BooleanToIntegerJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/BooleanToIntegerJsonConverter.cs new file mode 100644 index 00000000..9079629e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/BooleanToIntegerJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class BooleanToIntegerJsonConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var number = reader.GetInt32(); + return number != 0; + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value ? 1 : 0); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/EpochSecondsJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/EpochSecondsJsonConverter.cs new file mode 100644 index 00000000..96188182 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/EpochSecondsJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class EpochSecondsJsonConverter : JsonConverter +{ + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var number = reader.GetInt64(); + return DateTimeOffset.FromUnixTimeSeconds(number).DateTime; + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteNumberValue(new DateTimeOffset(value).ToUnixTimeSeconds()); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs new file mode 100644 index 00000000..def3e83b --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs @@ -0,0 +1,56 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class ForgivingBytesToHexJsonConverter : JsonConverter> +{ + public override ReadOnlyMemory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.ValueSpan.Length == 0) + { + return ReadOnlyMemory.Empty; + } + + var maxCharacterCount = Encoding.UTF8.GetMaxCharCount(reader.ValueSpan.Length); + var characterBuffer = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxCharacterCount, out var charactersHeapMemoryOwner) + ? charactersHeapMemoryOwner.Memory.Span + : stackalloc char[maxCharacterCount]; + + using (charactersHeapMemoryOwner) + { + var characterCount = reader.CopyString(characterBuffer); + + try + { + return Convert.FromHexString(characterBuffer[..characterCount]); + } + catch + { + // TODO: Use some explicit fallback mechanism on the DTO attribute instead, and make this converter non-forgiving + return ReadOnlyMemory.Empty; + } + } + } + + public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, JsonSerializerOptions options) + { + if (value.Length == 0) + { + return; + } + + var maxCharacterCount = value.Length * 2; + var characterBuffer = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxCharacterCount, out var charactersHeapMemoryOwner) + ? charactersHeapMemoryOwner.Memory.Span + : stackalloc char[maxCharacterCount]; + + if (!Convert.TryToHexStringLower(value.Span, characterBuffer, out var byteCount)) + { + throw new JsonException("Could not convert to hex string"); + } + + writer.WriteStringValue(characterBuffer[..byteCount]); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs b/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs new file mode 100644 index 00000000..0cd73f0a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs @@ -0,0 +1,10 @@ +namespace Proton.Sdk.Serialization; + +internal interface IStrongId + where T : IStrongId +{ + public string Value { get; } + + public static virtual implicit operator string(T id) => id.Value; + public static abstract implicit operator T(string value); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs new file mode 100644 index 00000000..dea88ed6 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs @@ -0,0 +1,53 @@ +using System.Buffers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredBlockJsonConverter(PgpBlockType blockType, Func, T> factory) : JsonConverter + where T : IPgpArmoredBlock +{ + private readonly PgpBlockType _blockType = blockType; + private readonly Func, T> _factory = factory; + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException($"Unexpected token type '{reader.TokenType}', expected '{nameof(JsonTokenType.String)}'"); + } + + var buffer = ArrayPool.Shared.Rent(reader.ValueSpan.Length); + + try + { + var numberOfBytesCopied = reader.CopyString(buffer); + + var decodedBlock = PgpArmorDecoder.Decode(buffer.AsSpan()[..numberOfBytesCopied]); + + return _factory.Invoke(decodedBlock); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var buffer = ArrayPool.Shared.Rent(PgpArmorEncoder.GetMaxLengthAfterEncoding(value.Bytes.Length)); + + try + { + var numberOfBytesWritten = PgpArmorEncoder.Encode(value.Bytes.Span, _blockType, buffer); + + writer.WriteStringValue(buffer.AsSpan()[..numberOfBytesWritten]); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs new file mode 100644 index 00000000..db904fad --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Api; +using Proton.Sdk.Authentication.Api; +using Proton.Sdk.Events.Api; +using Proton.Sdk.Keys.Api; +using Proton.Sdk.Users.Api; + +namespace Proton.Sdk.Serialization; + +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(SessionInitiationRequest))] +[JsonSerializable(typeof(SessionInitiationResponse))] +[JsonSerializable(typeof(AuthenticationRequest))] +[JsonSerializable(typeof(AuthenticationResponse))] +[JsonSerializable(typeof(SecondFactorValidationRequest))] +[JsonSerializable(typeof(ScopesResponse))] +[JsonSerializable(typeof(SessionRefreshRequest))] +[JsonSerializable(typeof(SessionRefreshResponse))] +[JsonSerializable(typeof(UserResponse))] +[JsonSerializable(typeof(AddressListResponse))] +[JsonSerializable(typeof(AddressResponse))] +[JsonSerializable(typeof(AddressPublicKeyListResponse))] +[JsonSerializable(typeof(ModulusResponse))] +[JsonSerializable(typeof(KeySaltListResponse))] +[JsonSerializable(typeof(LatestEventResponse))] +[JsonSerializable(typeof(EventListResponse))] +internal partial class ProtonApiSerializerContext : JsonSerializerContext +{ + static ProtonApiSerializerContext() + { + Default = new ProtonApiSerializerContext(ProtonApiDefaults.GetSerializerOptions()); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/StrongIdConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdConverter.cs new file mode 100644 index 00000000..02461102 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdConverter.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class StrongIdConverter : JsonConverter + where T : struct, IStrongId +{ + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value ?? default(T); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/IUsersApiClient.cs b/cs/sdk/src/Proton.Sdk/Users/Api/IUsersApiClient.cs new file mode 100644 index 00000000..6938ab08 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/Api/IUsersApiClient.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Users.Api; + +internal interface IUsersApiClient +{ + Task GetAuthenticatedUserAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/Subscriptions.cs b/cs/sdk/src/Proton.Sdk/Users/Api/Subscriptions.cs new file mode 100644 index 00000000..0c4bbbc2 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/Api/Subscriptions.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Users.Api; + +[Flags] +internal enum Subscriptions +{ + None = 0, + Mail = 1, + Vpn = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/User.cs b/cs/sdk/src/Proton.Sdk/Users/Api/User.cs new file mode 100644 index 00000000..c210e04f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/Api/User.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Users.Api; + +internal sealed class User +{ + [JsonPropertyName("ID")] + public required UserId Id { get; init; } + + public required string Name { get; init; } + public required string DisplayName { get; init; } + + [JsonPropertyName("Email")] + public required string EmailAddress { get; init; } + + public UserType Type { get; init; } + + public required long MaxSpace { get; init; } + public required long UsedSpace { get; init; } + + [JsonPropertyName("Private")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsPrivate { get; init; } + + [JsonPropertyName("Subscribed")] + public required Subscriptions Subscriptions { get; init; } + + [JsonPropertyName("Services")] + public required Services ActiveServices { get; init; } + + [JsonPropertyName("Delinquent")] + public DelinquentState DelinquentState { get; init; } + + public required IReadOnlyList Keys { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/UserKey.cs b/cs/sdk/src/Proton.Sdk/Users/Api/UserKey.cs new file mode 100644 index 00000000..827879b1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/Api/UserKey.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Users.Api; + +internal sealed class UserKey +{ + [JsonPropertyName("ID")] + public required UserKeyId Id { get; init; } + + public required int Version { get; init; } + + public required PgpArmoredPrivateKey PrivateKey { get; init; } + + [JsonPropertyName("Primary")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsPrimary { get; init; } + + [JsonPropertyName("Active")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsActive { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/UserResponse.cs b/cs/sdk/src/Proton.Sdk/Users/Api/UserResponse.cs new file mode 100644 index 00000000..9714dea8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/Api/UserResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Sdk.Users.Api; + +internal sealed class UserResponse : ApiResponse +{ + public required User User { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/UsersApiClient.cs b/cs/sdk/src/Proton.Sdk/Users/Api/UsersApiClient.cs new file mode 100644 index 00000000..fb01d45a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/Api/UsersApiClient.cs @@ -0,0 +1,16 @@ +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Users.Api; + +internal sealed class UsersApiClient(HttpClient httpClient) : IUsersApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetAuthenticatedUserAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.UserResponse) + .GetAsync("core/v4/users", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/DelinquentState.cs b/cs/sdk/src/Proton.Sdk/Users/DelinquentState.cs new file mode 100644 index 00000000..a4997bee --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/DelinquentState.cs @@ -0,0 +1,10 @@ +namespace Proton.Sdk.Users; + +public enum DelinquentState +{ + Paid = 0, + Available = 1, + Overdue = 2, + Delinquent = 3, + NotReceived = 4, +} diff --git a/cs/sdk/src/Proton.Sdk/Users/Services.cs b/cs/sdk/src/Proton.Sdk/Users/Services.cs new file mode 100644 index 00000000..48bfe599 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/Services.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Users; + +[Flags] +internal enum Services +{ + None = 0, + Mail = 1, + Vpn = 4, +} diff --git a/cs/sdk/src/Proton.Sdk/Users/UserId.cs b/cs/sdk/src/Proton.Sdk/Users/UserId.cs new file mode 100644 index 00000000..f9f297b6 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/UserId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Users; + +public readonly record struct UserId(string Value) : IStrongId +{ + public static implicit operator UserId(string value) + { + return new UserId(value); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs b/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs new file mode 100644 index 00000000..aa6302a2 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Users; + +public readonly record struct UserKeyId(string Value) : IStrongId +{ + public static implicit operator UserKeyId(string value) + { + return new UserKeyId(value); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/UserType.cs b/cs/sdk/src/Proton.Sdk/Users/UserType.cs new file mode 100644 index 00000000..83fc3cce --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/UserType.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Users; + +public enum UserType +{ + Proton = 1, + Managed = 2, + External = 3, +} From 2adeffda01196a5d86b762e55785554af394d0a7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Feb 2025 10:30:03 +0000 Subject: [PATCH 020/791] Add revisions management --- js/sdk/src/crypto/driveCrypto.ts | 20 ++ js/sdk/src/interface/nodes.ts | 8 +- js/sdk/src/internal/apiService/apiService.ts | 10 +- js/sdk/src/internal/nodes/apiService.test.ts | 7 +- js/sdk/src/internal/nodes/apiService.ts | 51 ++++- .../src/internal/nodes/cryptoService.test.ts | 184 ++++++++++++++++-- js/sdk/src/internal/nodes/cryptoService.ts | 102 ++++++++-- .../internal/nodes/extendedAttributes.test.ts | 107 ++++++++++ .../src/internal/nodes/extendedAttributes.ts | 133 +++++++++++++ js/sdk/src/internal/nodes/index.ts | 11 +- js/sdk/src/internal/nodes/interface.ts | 50 ++++- js/sdk/src/internal/nodes/nodesAccess.test.ts | 26 ++- js/sdk/src/internal/nodes/nodesAccess.ts | 42 +++- js/sdk/src/internal/nodes/nodesManagement.ts | 1 - js/sdk/src/internal/nodes/nodesRevisions.ts | 46 +++++ js/sdk/src/internal/uids.ts | 15 ++ 16 files changed, 750 insertions(+), 63 deletions(-) create mode 100644 js/sdk/src/internal/nodes/extendedAttributes.test.ts create mode 100644 js/sdk/src/internal/nodes/extendedAttributes.ts create mode 100644 js/sdk/src/internal/nodes/nodesRevisions.ts diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 67be877a..409d18c2 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -273,4 +273,24 @@ export class DriveCrypto { verified, }; } + + async decryptExtendedAttributes( + armoreExtendedAttributes: string, + decryptionKey: PrivateKey, + verificationKeys: PublicKey[], + ): Promise<{ + extendedAttributes: string, + verified: VERIFICATION_STATUS, + }> { + const { data: decryptedExtendedAttributes, verified } = await this.openPGPCrypto.decryptArmoredAndVerify( + armoreExtendedAttributes, + [decryptionKey], + verificationKeys, + ); + + return { + extendedAttributes: new TextDecoder().decode(decryptedExtendedAttributes), + verified, + }; + } } diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 1857e9a8..0dc8de48 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -13,7 +13,7 @@ export type NodeEntity = { isShared: boolean, createdDate: Date, // created on server date trashedDate?: Date, - activeRevision: Result // null for folders + activeRevision?: Result, } export type InvalidNameError = { @@ -43,12 +43,14 @@ export enum MemberRole { export type Revision = { uid: string, state: RevisionState, + createdDate: Date, // created on server date + author: Result, claimedSize?: number, claimedModificationTime?: Date, - claimedDigests: { + claimedDigests?: { sha1?: string, }, - claimedAdditionalMetadata: object, + claimedAdditionalMetadata?: object, } export enum RevisionState { diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 33c6767a..1342af95 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -84,7 +84,7 @@ export class DriveAPIService { return this.makeRequest(url, 'GET', undefined, signal); }; - async post(url: string, data: RequestPayload, signal?: AbortSignal): Promise { + async post(url: string, data?: RequestPayload, signal?: AbortSignal): Promise { return this.makeRequest(url, 'POST', data, signal); }; @@ -92,6 +92,10 @@ export class DriveAPIService { return this.makeRequest(url, 'PUT', data, signal); }; + async delete(url: string, signal?: AbortSignal): Promise { + return this.makeRequest(url, 'DELETE', undefined, signal); + }; + // TODO: add priority header // u=2 for interactive (user doing action, e.g., create folder), // u=4 for normal (user secondary action, e.g., refresh children listing), @@ -126,8 +130,8 @@ export class DriveAPIService { "Accept": "application/vnd.protonmail.v1+json", "Content-Type": "application/json", "Language": this.language, - }), - body: JSON.stringify(data), + }), body: data && JSON.stringify(data), + }), signal); } catch (error: unknown) { if (error instanceof Error) { diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 43bf9b35..263b19e4 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -17,6 +17,8 @@ function generateAPIFileNode(linkOverrides = {}, overrides = {}) { }, ActiveRevision: { RevisionID: 'revisionId', + CreateTime: 1234567890, + SignatureEmail: 'revSigEmail', XAttr: '{}', }, ...overrides, @@ -73,7 +75,10 @@ function generateFileNode(overrides = {}) { armoredContentKeyPacketSignature: "contentKeyPacketSig", }, activeRevision: { - id: "revisionId", + uid: "volume:volumeId;node:linkId;revision:revisionId", + state: "active", + createdDate: new Date(1234567890000), + signatureEmail: "revSigEmail", encryptedExtendedAttributes: "{}", }, }, diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 2c7d9b4e..1fa33826 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,7 +1,8 @@ import { Logger, NodeType, MemberRole, NodeResult } from "../../interface"; +import { RevisionState } from "../../interface/nodes"; import { DriveAPIService, drivePaths } from "../apiService"; -import { splitNodeUid, makeNodeUid } from "../uids"; -import { EncryptedNode } from "./interface"; +import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid } from "../uids"; +import { EncryptedNode, EncryptedRevision } from "./interface"; type PostLoadLinksMetadataRequest = Extract['content']['application/json']; type PostLoadLinksMetadataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; @@ -28,6 +29,17 @@ type PostDeleteNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/de type PostCreateFolderRequest = Extract['content']['application/json']; type PostCreateFolderResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['responses']['200']['content']['application/json']; +type GetRevisionsResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions']['get']['responses']['200']['content']['application/json']; +enum APIRevisionState { + Draft = 0, + Active = 1, + Obsolete = 2, +} + +type PostRestoreRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore']['post']['responses']['202']['content']['application/json']; + +type DeleteRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['delete']['responses']['200']['content']['application/json']; + /** * Provides API communication for fetching and manipulating nodes metadata. * @@ -92,7 +104,10 @@ export class NodeAPIService { armoredContentKeyPacketSignature: link.File.ContentKeyPacketSignature || undefined, }, activeRevision: { - id: link.ActiveRevision.RevisionID, + uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.ActiveRevision.RevisionID), + state: RevisionState.Active, + createdDate: new Date(link.ActiveRevision.CreateTime*1000), + signatureEmail: link.ActiveRevision.SignatureEmail || undefined, encryptedExtendedAttributes: link.ActiveRevision.XAttr || undefined, }, }, @@ -304,6 +319,36 @@ export class NodeAPIService { return response.Folder.ID; } + + async getRevisions(nodeUid: string, signal?: AbortSignal): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + + const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, signal); + return response.Revisions + .filter((revision) => revision.State === APIRevisionState.Active || revision.State === APIRevisionState.Obsolete) + .map((revision) => ({ + uid: makeNodeRevisionUid(volumeId, nodeId, revision.ID), + state: revision.State === APIRevisionState.Active ? RevisionState.Active : RevisionState.Superseded, + createdDate: new Date(revision.CreateTime*1000), + signatureEmail: revision.SignatureEmail || undefined, + encryptedExtendedAttributes: revision.XAttr || undefined, + })); + } + + async restoreRevision(nodeRevisionUid: string): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + await this.apiService.post< + undefined, + PostRestoreRevisionResponse + >(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}/restore`); + } + + async deleteRevision(nodeRevisionUid: string): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + await this.apiService.delete(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); + } } function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { volumeId: string }[]): string { diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 35dbe3a4..1e8b10d9 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -29,6 +29,10 @@ describe("nodesCryptoService", () => { hashKey: new Uint8Array(), verified: VERIFICATION_STATUS.SIGNED_AND_VALID, })), + decryptExtendedAttributes: jest.fn(async () => Promise.resolve({ + extendedAttributes: "{}", + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + })), encryptNodeName: jest.fn(async () => Promise.resolve({ armoredNodeName: "armoredName", })), @@ -64,11 +68,10 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: true, value: "name" }, keyAuthor: { ok: true, value: "signatureEmail" }, nameAuthor: { ok: true, value: "signatureEmail" }, - activeRevision: { ok: true, value: null }, + activeRevision: undefined, }, keys: { passphrase: "pass", @@ -98,11 +101,10 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: true, value: "name" }, keyAuthor: { ok: true, value: "signatureEmail" }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: { ok: true, value: null }, + activeRevision: undefined, }, keys: { passphrase: "pass", @@ -128,6 +130,7 @@ describe("nodesCryptoService", () => { armoredNodePassphraseSignature: "armoredNodePassphraseSignature", folder: { armoredHashKey: "armoredHashKey", + encryptedExtendedAttributes: "encryptedExtendedAttributes", } }, } as EncryptedNode, @@ -136,11 +139,13 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: true, value: "name" }, keyAuthor: { ok: true, value: "signatureEmail" }, nameAuthor: { ok: true, value: "signatureEmail" }, - activeRevision: { ok: true, value: null }, + activeRevision: undefined, + folder: { + extendedAttributes: "{}", + }, }, keys: { passphrase: "pass", @@ -177,11 +182,13 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: true, value: "name" }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing key signature" } }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: { ok: true, value: null }, + activeRevision: undefined, + folder: { + extendedAttributes: undefined, + }, }, keys: { passphrase: "pass", @@ -216,11 +223,13 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: true, value: "name" }, keyAuthor: { ok: true, value: "signatureEmail" }, nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Verification of name signature failed" } }, - activeRevision: { ok: true, value: null }, + activeRevision: undefined, + folder: { + extendedAttributes: undefined, + }, }, keys: { passphrase: "pass", @@ -255,11 +264,13 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: true, value: "name" }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Verification of hash key signature failed" } }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: { ok: true, value: null }, + activeRevision: undefined, + folder: { + extendedAttributes: undefined, + }, }, keys: { passphrase: "pass", @@ -300,11 +311,55 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: true, value: "name" }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing key signature" } }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: { ok: true, value: null }, + activeRevision: undefined, + folder: { + extendedAttributes: undefined, + }, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: new Uint8Array(), + }, + }); + }); + + it("should decrypt folder node with signature validation error on extended attributes", async () => { + driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.resolve({ + extendedAttributes: "{}", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + folder: { + armoredHashKey: "armoredHashKey", + encryptedExtendedAttributes: "encryptedExtendedAttributes", + } + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + name: { ok: true, value: "name" }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Verification of extended attributes signature failed" } }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, + activeRevision: undefined, + folder: { + extendedAttributes: "{}", + }, }, keys: { passphrase: "pass", @@ -315,6 +370,103 @@ describe("nodesCryptoService", () => { }); }); + it("should decrypt file node", async () => { + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "signatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + file: { + base64ContentKeyPacket: "base64ContentKeyPacket", + }, + activeRevision: { + uid: "revisionUid", + state: "active", + signatureEmail: "revisionSignatureEmail", + encryptedExtendedAttributes: "encryptedExtendedAttributes", + }, + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "signatureEmail" }, + activeRevision: { ok: true, value: { + uid: "revisionUid", + state: "active", + createdDate: undefined, + extendedAttributes: "{}", + author: { ok: true, value: "revisionSignatureEmail" }, + } }, + folder: undefined, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: undefined, + }, + }); + }); + + it("should decrypt file node with signature validation error on extended attribute", async () => { + driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.resolve({ + extendedAttributes: "{}", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode( + { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "signatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + file: { + base64ContentKeyPacket: "base64ContentKeyPacket", + }, + activeRevision: { + uid: "revisionUid", + state: "active", + signatureEmail: "revisionSignatureEmail", + encryptedExtendedAttributes: "encryptedExtendedAttributes", + }, + }, + } as EncryptedNode, + "parentKey" as unknown as PrivateKey + ); + + expect(result).toEqual({ + node: { + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "signatureEmail" }, + activeRevision: { ok: true, value: { + uid: "revisionUid", + state: "active", + createdDate: undefined, + extendedAttributes: "{}", + author: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Verification of extended attributes signature failed" } }, + } }, + folder: undefined, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + sessionKey: "sessionKey", + hashKey: undefined, + }, + }); + }); + it("should handle decrypt of node with key decryption issue", async () => { driveCrypto.decryptKey = jest.fn(async () => Promise.reject(new Error("Decryption error"))); @@ -336,7 +488,6 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: false, error: { name: "", error: "Failed to decrypt node key: Decryption error"} }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Failed to decrypt node key: Decryption error" } }, nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Failed to decrypt node key: Decryption error" } }, @@ -363,11 +514,10 @@ describe("nodesCryptoService", () => { expect(result).toEqual({ node: { - isStale: false, name: { ok: false, error: { name: "", error: "Decryption error" } }, keyAuthor: { ok: true, value: "signatureEmail" }, nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, - activeRevision: { ok: true, value: null }, + activeRevision: undefined, }, keys: { passphrase: "pass", diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index dfb09776..5a4ef214 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,6 +1,6 @@ import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; import { resultOk, resultError, Result, InvalidNameError, AnonymousUser, UnverifiedAuthorError, ProtonDriveAccount } from "../../interface"; -import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedNode, DecryptedNodeKeys, SharesService } from "./interface"; +import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, SharesService, EncryptedRevision, DecryptedRevision } from "./interface"; // TODO: Switch to CryptoProxy module once available. import { importHmacKey, computeHmacSignature } from "./hmac"; @@ -26,7 +26,7 @@ export class NodesCryptoService { this.shareService = shareService; } - async decryptNode(node: EncryptedNode, parentKey: PrivateKey): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { + async decryptNode(node: EncryptedNode, parentKey: PrivateKey): Promise<{ node: DecryptedUnparsedNode, keys?: DecryptedNodeKeys }> { const commonNodeMetadata = { ...node, encryptedCrypto: undefined, @@ -59,7 +59,6 @@ export class NodesCryptoService { return { node: { ...commonNodeMetadata, - isStale: false, name: resultError({ name: '', error: errorMessage, @@ -81,21 +80,61 @@ export class NodesCryptoService { let hashKey; let hashKeyAuthor; + let folder; + let folderExtendedAttributesAuthor; if ("folder" in node.encryptedCrypto) { const hashKeyResult = await this.decryptHashKey(node, key, keyVerificationKeys); hashKey = hashKeyResult.hashKey; hashKeyAuthor = hashKeyResult.author; + + const extendedAttributesResult = await this.decryptExtendedAttributes( + node.encryptedCrypto.folder.encryptedExtendedAttributes, + key, + keyVerificationKeys, + node.encryptedCrypto.signatureEmail + ); + folder = { + extendedAttributes: extendedAttributesResult.extendedAttributes, + }; + folderExtendedAttributesAuthor = extendedAttributesResult.author; + } + + let activeRevision: Result | undefined; + if ("file" in node.encryptedCrypto) { + try { + activeRevision = resultOk(await this.decryptRevision(node.encryptedCrypto.activeRevision, key, parentKey)); + } catch (error: unknown) { + const errorMessage = `Failed to decrypt active revision: ${error instanceof Error ? error.message : 'Unknown error'}`; + activeRevision = resultError(new Error(errorMessage)); + } + } + + // If key signature verificaiton failed, prefer returning error from + // the key directly. If key signature is ok but not hash or folder + // extended attributes, return that error instead. Only if all the + // signatures using the same signature email are ok, return OK. + let finalKeyAuthor; + if (!keyAuthor.ok) { + finalKeyAuthor = keyAuthor; + } + if (!finalKeyAuthor && hashKeyAuthor && !hashKeyAuthor.ok) { + finalKeyAuthor = hashKeyAuthor; + } + if (!finalKeyAuthor && folderExtendedAttributesAuthor && !folderExtendedAttributesAuthor.ok) { + finalKeyAuthor = folderExtendedAttributesAuthor; + } + if (!finalKeyAuthor) { + finalKeyAuthor = keyAuthor; } return { node: { ...commonNodeMetadata, - isStale: false, name, - // If key signature verificaiton failed, prefer showing error from the key directly. - keyAuthor: keyAuthor.ok && hashKeyAuthor && !hashKeyAuthor.ok ? hashKeyAuthor : keyAuthor, + keyAuthor: finalKeyAuthor, nameAuthor, - activeRevision: resultOk(null), // TODO: Decrypt extended attributes + activeRevision, + folder, }, keys: { passphrase, @@ -106,7 +145,7 @@ export class NodesCryptoService { }; }; - async decryptKey(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PublicKey[]): Promise, }> { const key = await this.driveCrypto.decryptKey( @@ -125,7 +164,7 @@ export class NodesCryptoService { }; }; - async decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PrivateKey[]): Promise<{ + private async decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PrivateKey[]): Promise<{ name: Result, author: Result, }> { @@ -158,7 +197,7 @@ export class NodesCryptoService { } }; - async decryptHashKey(node: EncryptedNode, nodeKey: PrivateKey, addressKeys: PublicKey[]): Promise<{ + private async decryptHashKey(node: EncryptedNode, nodeKey: PrivateKey, addressKeys: PublicKey[]): Promise<{ hashKey: Uint8Array, author: Result, }> { @@ -178,6 +217,47 @@ export class NodesCryptoService { } } + async decryptRevision(encryptedRevision: EncryptedRevision, nodeKey: PrivateKey, parentKey: PrivateKey): Promise { + const verificationKeys = encryptedRevision.signatureEmail + ? await this.account.getPublicKeys(encryptedRevision.signatureEmail) + : [parentKey]; + + const { + extendedAttributes, + author, + } = await this.decryptExtendedAttributes(encryptedRevision.encryptedExtendedAttributes, nodeKey, verificationKeys, encryptedRevision.signatureEmail); + + return { + uid: encryptedRevision.uid, + state: encryptedRevision.state, + createdDate: encryptedRevision.createdDate, + author, + extendedAttributes, + } + } + + private async decryptExtendedAttributes(encryptedExtendedAttributes: string | undefined, nodeKey: PrivateKey, addressKeys: PublicKey[], signatureEmail?: string): Promise<{ + extendedAttributes?: string, + author: Result, + }> { + if (!encryptedExtendedAttributes) { + return { + author: handleClaimedAuthor('key', VERIFICATION_STATUS.SIGNED_AND_VALID, signatureEmail), + } + } + + const { extendedAttributes, verified } = await this.driveCrypto.decryptExtendedAttributes( + encryptedExtendedAttributes, + nodeKey, + addressKeys, + ); + + return { + extendedAttributes, + author: handleClaimedAuthor('extended attributes', verified, signatureEmail), + } + } + async createFolder(parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, name: string): Promise<{ encryptedCrypto: Required & { hash: string }, keys: DecryptedNodeKeys, @@ -263,7 +343,7 @@ export class NodesCryptoService { }; } - async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { + private async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { const key = await importHmacKey(parentHashKey); const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts new file mode 100644 index 00000000..2f5b5c5d --- /dev/null +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -0,0 +1,107 @@ +import { FileExtendedAttributesParsed, FolderExtendedAttributes, parseFileExtendedAttributes, parseFolderExtendedAttributes } from './extendedAttributes'; + +const emptyExtendedAttributes = { + claimedSize: undefined, + claimedModificationTime: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, +}; + +describe('extended attrbiutes', () => { + describe('should parses file attributes', () => { + const testCases: [string, FileExtendedAttributesParsed][] = [ + ['', {}], + ['{}', {}], + ['a', {}], + [ + '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000"}}', + { + claimedModificationTime: new Date(1234567890000), + claimedSize: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + }, + ], + [ + '{"Common": {"Size": 123}}', + { + claimedModificationTime: undefined, + claimedSize: 123, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + }, + ], + [ + '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000", "Size": 123, "BlockSizes": [1, 2, 3]}}', + { + claimedModificationTime: new Date(1234567890000), + claimedSize: 123, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + }, + ], + [ + '{"Common": {"ModificationTime": "aa", "Size": 123}}', + { + claimedModificationTime: undefined, + claimedSize: 123, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + }, + ], + [ + '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000", "Size": "aaa"}}', + { + claimedModificationTime: new Date(1234567890000), + claimedSize: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + }, + ], + [ + '{"Common": {"Digests": {}}}', + { + claimedModificationTime: undefined, + claimedSize: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + }, + ], + [ + '{"Common": {"Digests": {"SHA1": null}}}', + { + claimedModificationTime: undefined, + claimedSize: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + }, + ], + [ + '{"Common": {"Digests": {"SHA1": "abcdef"}}}', + { + claimedModificationTime: undefined, + claimedSize: undefined, + claimedDigests: {sha1: "abcdef"}, + claimedAdditionalMetadata: undefined, + }, + ], + [ + '{"Common": {}, "Media": {}}', + { + claimedModificationTime: undefined, + claimedSize: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: { + Media: {}, + }, + }, + ], + ]; + testCases.forEach(([input, expectedAttributes]) => { + it(`should parse ${input}`, () => { + const output = parseFileExtendedAttributes(input); + expect(output).toMatchObject(expectedAttributes); + }) + }); + }); +}); diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts new file mode 100644 index 00000000..f90e2f57 --- /dev/null +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -0,0 +1,133 @@ +import { Logger } from "../../interface"; + +interface FolderExtendedAttributesSchema { + Common?: { + ModificationTime?: string; + }; +} + +interface FileExtendedAttributesSchema { + Common?: { + ModificationTime?: string; + Size?: number; + BlockSizes?: number[]; + Digests?: { + SHA1: string; + }; + }; + Location?: { + Latitude?: number; + Longitude?: number; + }; + Camera?: { + CaptureTime?: string; + Device?: string; + Orientation?: number; + SubjectCoordinates?: { + Top?: number; + Left?: number; + Bottom?: number; + Right?: number; + }; + }; + Media?: { + Width?: number; + Height?: number; + Duration?: number; + }; +} + +export interface FolderExtendedAttributes { + claimedModificationTime?: Date, +} + +export interface FileExtendedAttributesParsed { + claimedSize?: number, + claimedModificationTime?: Date, + claimedDigests?: { + sha1?: string, + }, + claimedAdditionalMetadata?: object, +} + +export function parseFolderExtendedAttributes(extendedAttributes?: string, log?: Logger): FolderExtendedAttributes { + if (!extendedAttributes) { + return {}; + } + + try { + const parsed = JSON.parse(extendedAttributes) as FolderExtendedAttributesSchema; + return { + claimedModificationTime: parseModificationTime(parsed, log), + }; + } catch (error: unknown) { + log?.error(`Failed to parse extended attributes: ${error instanceof Error ? error.message : error}`); + return {}; + } +} + +export function parseFileExtendedAttributes(extendedAttributes?: string, log?: Logger): FileExtendedAttributesParsed { + if (!extendedAttributes) { + return {} + } + + try { + const parsed = JSON.parse(extendedAttributes) as FolderExtendedAttributesSchema; + + const claimedAdditionalMetadata = { ...parsed }; + delete claimedAdditionalMetadata.Common; + + return { + claimedSize: parseSize(parsed, log), + claimedModificationTime: parseModificationTime(parsed, log), + claimedDigests: parseDigests(parsed, log), + claimedAdditionalMetadata: Object.keys(claimedAdditionalMetadata).length ? claimedAdditionalMetadata : undefined, + }; + } catch (error: unknown) { + log?.error(`Failed to parse extended attributes: ${error instanceof Error ? error.message : error}`); + return {}; + } +} + +function parseSize(xattr?: FileExtendedAttributesSchema, log?: Logger): number | undefined { + const size = xattr?.Common?.Size; + if (size === undefined) { + return undefined; + } + if (typeof size !== 'number') { + log?.warn(`XAttr file size "${size}" is not valid`); + return undefined; + } + return size; +} + +function parseModificationTime(xattr?: FolderExtendedAttributesSchema | FolderExtendedAttributesSchema, log?: Logger): Date | undefined { + const modificationTime = xattr?.Common?.ModificationTime; + if (modificationTime === undefined) { + return undefined; + } + const modificationDate = new Date(modificationTime); + // This is the best way to check if date is "Invalid Date". :shrug: + if (JSON.stringify(modificationDate) === 'null') { + log?.warn(`XAttr modification time "${modificationTime}" is not valid`); + return undefined; + } + return modificationDate; +} + +function parseDigests(xattr?: FileExtendedAttributesSchema, log?: Logger): { sha1: string } | undefined { + const digests = xattr?.Common?.Digests; + if (digests === undefined || digests.SHA1 === undefined) { + return undefined; + } + + const sha1 = digests.SHA1; + if (typeof sha1 !== 'string') { + log?.warn(`XAttr digest SHA1 "${sha1}" is not valid`); + return undefined; + } + + return { + sha1, + }; +} diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index d35df567..b0c86682 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -10,6 +10,7 @@ import { NodesCryptoService } from "./cryptoService"; import { SharesService, DecryptedNode } from "./interface"; import { NodesAccess } from "./nodesAccess"; import { NodesManagement } from "./nodesManagement"; +import { NodesRevisons } from "./nodesRevisions"; export type { DecryptedNode } from "./interface"; @@ -36,19 +37,21 @@ export function initNodesModule( const cache = new NodesCache(driveEntitiesCache, log); const cryptoCache = new NodesCryptoCache(driveCryptoCache); const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); - const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); + const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService, log); const nodesEvents = new NodesEvents(driveEvents, cache, nodesAccess, log); // TODO: Events are sent to the client once event is received from API // If change is done locally, it will take a time to show up if client // is waiting with UI update to events. Thus we need to emit events // right away. - const nodesManager = new NodesManagement(api, cache, cryptoCache, cryptoService, nodesAccess); + const nodesManagement = new NodesManagement(api, cache, cryptoCache, cryptoService, nodesAccess); + const nodesRevisions = new NodesRevisons(api, cryptoService, nodesAccess, log); return { access: nodesAccess, - management: nodesManager, + management: nodesManagement, + revisions: nodesRevisions, events: nodesEvents, - } + }; } export function initPublicNodesModule( diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 544e6876..f7a23c74 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,5 +1,6 @@ import { PrivateKey, SessionKey } from "../../crypto"; import { NodeEntity, Result, InvalidNameError, AnonymousUser, UnverifiedAuthorError, MemberRole, NodeType, Revision } from "../../interface"; +import { RevisionState } from "../../interface/nodes"; /** * Internal common node interface for both encrypted or decrypted node. @@ -47,10 +48,7 @@ export interface EncryptedNodeFileCrypto extends EncryptedNodeCrypto { base64ContentKeyPacket: string; armoredContentKeyPacketSignature?: string; }; - activeRevision: { - id: string; - encryptedExtendedAttributes?: string; - }; + activeRevision: EncryptedRevision; } export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { @@ -60,17 +58,35 @@ export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { }; } +/** + * Interface used only internally in the nodes module. + * + * Outside of the module, the decrypted node interface should be used. + * + * This interface is holding decrypted node metadata that is not yet parsed, + * such as extended attributes. + */ +export interface DecryptedUnparsedNode extends BaseNode { + keyAuthor: Result, + nameAuthor: Result, + name: Result, + activeRevision?: Result, + folder?: { + extendedAttributes?: string, + }, +} + /** * Interface holding decrypted node metadata. */ -export interface DecryptedNode extends BaseNode, NodeEntity { +export interface DecryptedNode extends Omit, NodeEntity { // Internal metadata isStale: boolean; - keyAuthor: Result, - nameAuthor: Result, - name: Result, - activeRevision: Result, // null for folders + activeRevision?: Result, + folder?: { + claimedModificationTime?: Date, + }, } export interface DecryptedNodeKeys { @@ -80,6 +96,22 @@ export interface DecryptedNodeKeys { hashKey?: Uint8Array; } +interface BaseRevision { + uid: string; + state: RevisionState; + createdDate: Date; // created on the server +} + +export interface EncryptedRevision extends BaseRevision { + signatureEmail?: string; + encryptedExtendedAttributes?: string; +} + +export interface DecryptedRevision extends BaseRevision { + author: Result, + extendedAttributes?: string, +} + /** * Interface describing the dependencies to the shares module. */ diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 18484a2a..0bd9c5a8 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -4,7 +4,7 @@ import { NodesCache } from "./cache" import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { NodesAccess } from './nodesAccess'; -import { SharesService, DecryptedNode, EncryptedNode, DecryptedNodeKeys } from "./interface"; +import { SharesService, DecryptedNode, DecryptedUnparsedNode, EncryptedNode, DecryptedNodeKeys } from "./interface"; describe('nodesAccess', () => { let apiService: NodeAPIService; @@ -58,16 +58,22 @@ describe('nodesAccess', () => { it('should get node from API when cahce is stale', async () => { const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; - const decryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedNode; + const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedUnparsedNode; + const decryptedNode = { + ...decryptedUnparsedNode, + isStale: false, + activeRevision: undefined, + folder: undefined, + } as DecryptedNode; const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeId', isStale: true } as DecryptedNode)); apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedNode, keys: decryptedKeys })); + cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); const result = await access.getNode('nodeId'); - expect(result).toBe(decryptedNode); + expect(result).toEqual(decryptedNode); expect(apiService.getNode).toHaveBeenCalledWith('nodeId'); expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('parentUid'); expect(cryptoService.decryptNode).toHaveBeenCalledWith(encryptedNode, 'parentKey'); @@ -77,16 +83,22 @@ describe('nodesAccess', () => { it('should get node from API missing cache', async () => { const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; - const decryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedNode; + const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedUnparsedNode; + const decryptedNode = { + ...decryptedUnparsedNode, + isStale: false, + activeRevision: undefined, + folder: undefined, + } as DecryptedNode; const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; cache.getNode = jest.fn(() => Promise.reject(new Error('Entity not found'))); apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedNode, keys: decryptedKeys })); + cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); const result = await access.getNode('nodeId'); - expect(result).toBe(decryptedNode); + expect(result).toEqual(decryptedNode); expect(apiService.getNode).toHaveBeenCalledWith('nodeId'); expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('parentUid'); expect(cryptoService.decryptNode).toHaveBeenCalledWith(encryptedNode, 'parentKey'); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 8b08fa85..98fab723 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,10 +1,12 @@ +import { Logger, NodeType, resultOk } from "../../interface"; +import { BatchLoading } from "../batchLoading"; +import { makeNodeUid } from "../uids"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache" import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; -import { SharesService, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; -import { BatchLoading } from "../batchLoading"; -import { makeNodeUid } from "../uids"; +import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from "./extendedAttributes"; +import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; /** * Provides access to node metadata. @@ -19,12 +21,14 @@ export class NodesAccess { private cryptoCache: NodesCryptoCache, private cryptoService: NodesCryptoService, private shareService: SharesService, + private log?: Logger, ) { this.apiService = apiService; this.cache = cache; this.cryptoCache = cryptoCache; this.cryptoService = cryptoService; this.shareService = shareService; + this.log = log; } async getMyFilesRootFolder() { @@ -128,7 +132,8 @@ export class NodesAccess { private async decryptNode(encryptedNode: EncryptedNode): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { const { key: parentKey } = await this.getParentKeys(encryptedNode); - const { node, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); + const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); + const node = await this.parseNode(unparsedNode); this.cache.setNode(node); if (keys) { this.cryptoCache.setNodeKeys(node.uid, keys); @@ -136,6 +141,35 @@ export class NodesAccess { return { node, keys }; } + private async parseNode(unparsedNode: DecryptedUnparsedNode): Promise { + if (unparsedNode.type === NodeType.File) { + const extendedAttributes = unparsedNode.activeRevision?.ok ? parseFileExtendedAttributes(unparsedNode.activeRevision.value.extendedAttributes, this.log) : undefined; + + return { + ...unparsedNode, + isStale: false, + activeRevision: !unparsedNode.activeRevision?.ok ? unparsedNode.activeRevision : resultOk({ + uid: unparsedNode.activeRevision.value.uid, + state: unparsedNode.activeRevision.value.state, + createdDate: unparsedNode.activeRevision.value.createdDate, + author: unparsedNode.activeRevision.value.author, + ...extendedAttributes, + }), + folder: undefined, + } + } + + const extendedAttributes = unparsedNode.folder?.extendedAttributes ? parseFolderExtendedAttributes(unparsedNode.folder.extendedAttributes, this.log) : undefined; + return { + ...unparsedNode, + isStale: false, + activeRevision: undefined, + folder: extendedAttributes ? { + ...extendedAttributes, + } : undefined, + } + } + async getParentKeys(node: Pick): Promise> { if (node.parentUid) { return this.getNodeKeys(node.parentUid); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index d729b252..9c2988a5 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -240,7 +240,6 @@ export class NodesManagement { keyAuthor: resultOk(encryptedCrypto.signatureEmail), nameAuthor: resultOk(encryptedCrypto.signatureEmail), name: resultOk(folderName), - activeRevision: resultOk(null), } await this.cache.setNode(node); diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts new file mode 100644 index 00000000..402010fd --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -0,0 +1,46 @@ +import { Logger, Revision } from "../../interface"; +import { NodeAPIService } from "./apiService"; +import { NodesCryptoService } from "./cryptoService"; +import { NodesAccess } from "./nodesAccess"; +import { parseFileExtendedAttributes } from "./extendedAttributes"; + +/** + * Provides access to revisions metadata. + */ +export class NodesRevisons { + constructor( + private apiService: NodeAPIService, + private cryptoService: NodesCryptoService, + private nodesAccess: NodesAccess, + private log?: Logger, + ) { + this.apiService = apiService; + this.cryptoService = cryptoService; + this.nodesAccess = nodesAccess; + this.log = log; + } + + async* iterateRevisions(nodeUid: string, signal?: AbortSignal): AsyncGenerator { + const node = await this.nodesAccess.getNode(nodeUid); + const { key: parentKey } = await this.nodesAccess.getParentKeys(node); + const { key } = await this.nodesAccess.getNodeKeys(nodeUid); + + const encryptedRevisions = await this.apiService.getRevisions(nodeUid, signal); + for (const encryptedRevision of encryptedRevisions) { + const revision = await this.cryptoService.decryptRevision(encryptedRevision, key, parentKey); + const extendedAttributes = parseFileExtendedAttributes(revision.extendedAttributes, this.log); + yield { + ...revision, + ...extendedAttributes, + }; + } + } + + async restoreRevision(nodeRevisionUid: string): Promise { + await this.apiService.restoreRevision(nodeRevisionUid); + } + + async deleteRevision(nodeRevisionUid: string): Promise { + await this.apiService.deleteRevision(nodeRevisionUid); + } +} diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index 7ec56870..7a8cad1b 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -15,3 +15,18 @@ export function splitNodeUid(nodeUid: string) { export function makeInvitationUid(volumeId: string, invitationId: string) { return `volume:${volumeId};invitation:${invitationId}`; } + +export function makeNodeRevisionUid(volumeId: string, nodeUid: string, revisionId: string) { + // TODO: format of UID + return `volume:${volumeId};node:${nodeUid};revision:${revisionId}`; +} + +export function splitNodeRevisionUid(nodeRevisionUid: string) { + // TODO: validation + const [ volumeId, nodeId, revisionId ] = nodeRevisionUid.split(';'); + return { + volumeId: volumeId.slice('volume:'.length), + nodeId: nodeId.slice('node:'.length), + revisionId: revisionId.slice('revision:'.length), + }; +} From a46929e076601ed7b46ab1ef1012bc0ebbfc69ae Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Feb 2025 14:01:34 +0100 Subject: [PATCH 021/791] update package name --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 872ba45a..dfa46f8c 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,5 +1,5 @@ { - "name": "proton-drive", + "name": "proton-drive-sdk", "version": "0.0.1", "description": "Proton Drive SDK", "license": "GPL-3.0", From f5cf14312110fcdc781b9bfe475dc8172ef44251 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Feb 2025 12:50:49 +0000 Subject: [PATCH 022/791] Implement more CLI commands --- js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/nodes.ts | 8 +++++--- js/sdk/src/internal/nodes/cache.test.ts | 4 ++-- js/sdk/src/internal/nodes/cache.ts | 2 +- js/sdk/src/internal/nodes/cryptoService.ts | 12 ++++++------ js/sdk/src/internal/nodes/interface.ts | 8 ++++---- js/sdk/src/internal/sharing/apiService.ts | 2 +- js/sdk/src/protonDriveClient.ts | 16 ++++++++++++++-- 8 files changed, 34 insertions(+), 20 deletions(-) diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 05332ab0..4fe79167 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -14,7 +14,7 @@ export type { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig, GetL export type { Device, DeviceOrUid } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; -export type { NodeEntity, InvalidNameError, UnverifiedAuthorError, AnonymousUser, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; +export type { Author, NodeEntity, InvalidNameError, UnverifiedAuthorError, AnonymousUser, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole } from './nodes'; export type { ProtonInvitation, NonProtonInvitation, NonProtonInvitationState, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, ShareResult } from './sharing'; export { ShareRole } from './sharing'; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 0dc8de48..6bfedf58 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -1,12 +1,14 @@ import { Result } from './result.js'; +export type Author = Result; + // Note: Node is reserved by JS/DOM, thus we need exception how the entity is called export type NodeEntity = { uid: string, parentUid?: string, name: Result, - keyAuthor: Result, - nameAuthor: Result, + keyAuthor: Author, + nameAuthor: Author, directMemberRole: MemberRole, type: NodeType, mimeType?: string, @@ -44,7 +46,7 @@ export type Revision = { uid: string, state: RevisionState, createdDate: Date, // created on server date - author: Result, + author: Author, claimedSize?: number, claimedModificationTime?: Date, claimedDigests?: { diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 7b1d370c..2951bb8d 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -139,11 +139,11 @@ describe('nodesCache', () => { expect(nodeUids).toStrictEqual(['node1', 'node2']); }); - it('should iterate children', async () => { + it('should iterate children without trashed items', async () => { await generateTreeStructure(cache); const result = await Array.fromAsync(cache.iterateChildren('node1')); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['node1a', 'node1b', 'node1c']); + expect(nodeUids).toStrictEqual(['node1a', 'node1c']); }); it('should iterate children and silently remove a corrupted node', async () => { diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index b342801c..dfbd3f14 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -115,7 +115,7 @@ export class NodesCache { async *iterateChildren(parentNodeUid: string): AsyncGenerator { for await (const result of this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`)) { const node = await this.convertCacheResult(result); - if (node) { + if (node && (!node.ok || !node.node.trashedDate)) { yield node; } } diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 5a4ef214..d81388a0 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,5 +1,5 @@ import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { resultOk, resultError, Result, InvalidNameError, AnonymousUser, UnverifiedAuthorError, ProtonDriveAccount } from "../../interface"; +import { resultOk, resultError, Result, InvalidNameError, Author, ProtonDriveAccount } from "../../interface"; import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, SharesService, EncryptedRevision, DecryptedRevision } from "./interface"; // TODO: Switch to CryptoProxy module once available. @@ -146,7 +146,7 @@ export class NodesCryptoService { }; private async decryptKey(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PublicKey[]): Promise, + author: Author, }> { const key = await this.driveCrypto.decryptKey( node.encryptedCrypto.armoredKey, @@ -166,7 +166,7 @@ export class NodesCryptoService { private async decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PrivateKey[]): Promise<{ name: Result, - author: Result, + author: Author, }> { const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail || node.encryptedCrypto.signatureEmail; @@ -199,7 +199,7 @@ export class NodesCryptoService { private async decryptHashKey(node: EncryptedNode, nodeKey: PrivateKey, addressKeys: PublicKey[]): Promise<{ hashKey: Uint8Array, - author: Result, + author: Author, }> { if (!("folder" in node.encryptedCrypto)) { throw new Error('Node is not a folder'); @@ -238,7 +238,7 @@ export class NodesCryptoService { private async decryptExtendedAttributes(encryptedExtendedAttributes: string | undefined, nodeKey: PrivateKey, addressKeys: PublicKey[], signatureEmail?: string): Promise<{ extendedAttributes?: string, - author: Result, + author: Author, }> { if (!encryptedExtendedAttributes) { return { @@ -351,7 +351,7 @@ export class NodesCryptoService { } } -function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string): Result { +function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string): Author { if (!claimedAuthor) { return resultOk(null); // Anonymous user } diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index f7a23c74..f393b63d 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { NodeEntity, Result, InvalidNameError, AnonymousUser, UnverifiedAuthorError, MemberRole, NodeType, Revision } from "../../interface"; +import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, Revision } from "../../interface"; import { RevisionState } from "../../interface/nodes"; /** @@ -67,8 +67,8 @@ export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { * such as extended attributes. */ export interface DecryptedUnparsedNode extends BaseNode { - keyAuthor: Result, - nameAuthor: Result, + keyAuthor: Author, + nameAuthor: Author, name: Result, activeRevision?: Result, folder?: { @@ -108,7 +108,7 @@ export interface EncryptedRevision extends BaseRevision { } export interface DecryptedRevision extends BaseRevision { - author: Result, + author: Author, extendedAttributes?: string, } diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index cba3a286..2bcaa608 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -19,7 +19,7 @@ export class SharingAPIService { async *iterateSharedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { let anchor = ""; while (true) { - const response = await this.apiService.get(`drive/v2/volumes/{volumeID}/shares?${anchor ? `AnchorID=${anchor}` : ''}`, signal); + const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/shares?${anchor ? `AnchorID=${anchor}` : ''}`, signal); for (const link of response.Links) { yield makeNodeUid(volumeId, link.LinkID); } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 30e3514e..16d057f5 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -88,12 +88,24 @@ export class ProtonDriveClient implements Partial { return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name)); } + async* iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal) { + yield* this.nodes.revisions.iterateRevisions(getUid(nodeUid), signal); + } + + async restoreRevision(revisionUid: string) { + await this.nodes.revisions.restoreRevision(revisionUid); + } + + async deleteRevision(revisionUid: string) { + await this.nodes.revisions.deleteRevision(revisionUid); + } + async* iterateSharedNodes(signal?: AbortSignal) { - return convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); + yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); } async* iterateSharedNodesWithMe(signal?: AbortSignal) { - return convertInternalNodeIterator(this.sharing.access.iterateSharedNodesWithMe(signal)); + yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodesWithMe(signal)); } async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings) { From 5cc97ed3e4d8f5ea9c4d8eeb8f0d20bdc5a63fb7 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 25 Feb 2025 14:34:47 +0100 Subject: [PATCH 023/791] fix CI cache paths --- js/sdk/package-lock.json | 2036 +++++++++++++++++++++++++++----------- 1 file changed, 1467 insertions(+), 569 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index e068288a..f5a981f6 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,11 +1,11 @@ { - "name": "proton-drive", + "name": "proton-drive-sdk", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "proton-drive", + "name": "proton-drive-sdk", "version": "0.0.1", "license": "GPL-3.0", "dependencies": { @@ -32,6 +32,7 @@ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -45,6 +46,7 @@ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -55,30 +57,32 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -94,13 +98,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -114,6 +119,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.25.9" }, @@ -122,12 +128,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.9", + "@babel/compat-data": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -138,17 +145,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", - "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz", + "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.25.9", + "@babel/traverse": "^7.26.9", "semver": "^6.3.1" }, "engines": { @@ -163,6 +171,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "regexpu-core": "^6.2.0", @@ -180,6 +189,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", @@ -196,6 +206,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -209,6 +220,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -222,6 +234,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", @@ -239,6 +252,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.25.9" }, @@ -247,10 +261,11 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -260,6 +275,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-wrap-function": "^7.25.9", @@ -273,14 +289,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", - "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", + "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/traverse": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -294,6 +311,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -307,6 +325,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -316,6 +335,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -325,6 +345,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -334,6 +355,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", "@babel/traverse": "^7.25.9", @@ -344,25 +366,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", + "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" @@ -376,6 +400,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/traverse": "^7.25.9" @@ -392,6 +417,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -407,6 +433,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -422,6 +449,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", @@ -439,6 +467,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/traverse": "^7.25.9" @@ -455,6 +484,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" }, @@ -467,6 +497,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -479,6 +510,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -491,6 +523,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -503,6 +536,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -518,6 +552,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -533,6 +568,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -548,6 +584,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -560,6 +597,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -572,6 +610,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -587,6 +626,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -599,6 +639,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -611,6 +652,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -623,6 +665,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -635,6 +678,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -647,6 +691,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -659,6 +704,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -674,6 +720,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -689,6 +736,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -704,6 +752,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -720,6 +769,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -731,14 +781,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", - "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", + "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-remap-async-to-generator": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/traverse": "^7.26.8" }, "engines": { "node": ">=6.9.0" @@ -752,6 +803,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", @@ -765,12 +817,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", - "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz", + "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -784,6 +837,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -799,6 +853,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -815,6 +870,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -831,6 +887,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-compilation-targets": "^7.25.9", @@ -846,20 +903,12 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-classes/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/template": "^7.25.9" @@ -876,6 +925,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -891,6 +941,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -907,6 +958,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -922,6 +974,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -938,6 +991,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -953,6 +1007,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -968,6 +1023,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -979,12 +1035,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", - "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", + "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { @@ -999,6 +1056,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", @@ -1016,6 +1074,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1031,6 +1090,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1046,6 +1106,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1061,6 +1122,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1076,6 +1138,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -1092,6 +1155,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.26.0", "@babel/helper-plugin-utils": "^7.25.9" @@ -1108,6 +1172,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", @@ -1126,6 +1191,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -1142,6 +1208,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -1158,6 +1225,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1169,12 +1237,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", - "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", + "version": "7.26.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz", + "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -1188,6 +1257,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1203,6 +1273,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", @@ -1220,6 +1291,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-replace-supers": "^7.25.9" @@ -1236,6 +1308,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1251,6 +1324,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" @@ -1267,6 +1341,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1282,6 +1357,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -1298,6 +1374,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-create-class-features-plugin": "^7.25.9", @@ -1315,6 +1392,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1330,6 +1408,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "regenerator-transform": "^0.15.2" @@ -1346,6 +1425,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -1362,6 +1442,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1377,6 +1458,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1392,6 +1474,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" @@ -1408,6 +1491,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1419,12 +1503,13 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", - "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", + "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -1434,12 +1519,13 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", - "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz", + "integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -1449,14 +1535,15 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.3.tgz", - "integrity": "sha512-6+5hpdr6mETwSKjmJUdYw0EIkATiQhnELWlE3kJFBwSg/BGIVwVaVbX+gOXBCdc7Ln1RXZxyWGecIXhUfnl7oA==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz", + "integrity": "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9" }, @@ -1472,6 +1559,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1487,6 +1575,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -1503,6 +1592,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -1519,6 +1609,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" @@ -1531,14 +1622,15 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", - "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", + "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/compat-data": "^7.26.8", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", @@ -1550,9 +1642,9 @@ "@babel/plugin-syntax-import-attributes": "^7.26.0", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.25.9", - "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.26.8", "@babel/plugin-transform-async-to-generator": "^7.25.9", - "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.26.5", "@babel/plugin-transform-block-scoping": "^7.25.9", "@babel/plugin-transform-class-properties": "^7.25.9", "@babel/plugin-transform-class-static-block": "^7.26.0", @@ -1563,21 +1655,21 @@ "@babel/plugin-transform-duplicate-keys": "^7.25.9", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", "@babel/plugin-transform-dynamic-import": "^7.25.9", - "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.26.3", "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.26.9", "@babel/plugin-transform-function-name": "^7.25.9", "@babel/plugin-transform-json-strings": "^7.25.9", "@babel/plugin-transform-literals": "^7.25.9", "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", "@babel/plugin-transform-member-expression-literals": "^7.25.9", "@babel/plugin-transform-modules-amd": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.26.3", "@babel/plugin-transform-modules-systemjs": "^7.25.9", "@babel/plugin-transform-modules-umd": "^7.25.9", "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", "@babel/plugin-transform-new-target": "^7.25.9", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", "@babel/plugin-transform-numeric-separator": "^7.25.9", "@babel/plugin-transform-object-rest-spread": "^7.25.9", "@babel/plugin-transform-object-super": "^7.25.9", @@ -1593,17 +1685,17 @@ "@babel/plugin-transform-shorthand-properties": "^7.25.9", "@babel/plugin-transform-spread": "^7.25.9", "@babel/plugin-transform-sticky-regex": "^7.25.9", - "@babel/plugin-transform-template-literals": "^7.25.9", - "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.26.8", + "@babel/plugin-transform-typeof-symbol": "^7.26.7", "@babel/plugin-transform-unicode-escapes": "^7.25.9", "@babel/plugin-transform-unicode-property-regex": "^7.25.9", "@babel/plugin-transform-unicode-regex": "^7.25.9", "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.38.1", + "core-js-compat": "^3.40.0", "semver": "^6.3.1" }, "engines": { @@ -1618,6 +1710,7 @@ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", @@ -1632,6 +1725,7 @@ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-validator-option": "^7.25.9", @@ -1647,10 +1741,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", "dev": true, + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1658,37 +1753,33 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1696,38 +1787,439 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "node_modules/@babel/types": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -1735,7 +2227,7 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">=18" @@ -1746,6 +2238,7 @@ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -1764,6 +2257,7 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -1773,6 +2267,7 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1791,43 +2286,39 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1835,11 +2326,25 @@ "node": "*" } }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1850,6 +2355,7 @@ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, + "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -1864,6 +2370,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1874,6 +2381,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1886,6 +2394,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -1899,13 +2408,15 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, + "license": "ISC", "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -1922,6 +2433,7 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, + "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } @@ -1931,6 +2443,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -1944,6 +2457,7 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -1957,6 +2471,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^4.1.0" }, @@ -1969,6 +2484,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, + "license": "MIT", "dependencies": { "p-try": "^2.0.0" }, @@ -1984,6 +2500,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^2.2.0" }, @@ -1996,6 +2513,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2005,6 +2523,7 @@ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2014,6 +2533,7 @@ "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -2031,6 +2551,7 @@ "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", "@jest/reporters": "^29.7.0", @@ -2078,6 +2599,7 @@ "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", @@ -2093,6 +2615,7 @@ "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.7.0", "jest-snapshot": "^29.7.0" @@ -2106,6 +2629,7 @@ "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, + "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3" }, @@ -2118,6 +2642,7 @@ "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", @@ -2135,6 +2660,7 @@ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -2150,6 +2676,7 @@ "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", "dev": true, + "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^29.7.0", @@ -2193,6 +2720,7 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, + "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -2205,6 +2733,7 @@ "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "callsites": "^3.0.0", @@ -2219,6 +2748,7 @@ "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", "@jest/types": "^29.6.3", @@ -2234,6 +2764,7 @@ "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", "graceful-fs": "^4.2.9", @@ -2249,6 +2780,7 @@ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", @@ -2275,6 +2807,7 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -2292,6 +2825,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -2306,6 +2840,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -2315,6 +2850,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -2323,13 +2859,15 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2346,13 +2884,15 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", "integrity": "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@microsoft/tsdoc-config": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.0.tgz", "integrity": "sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg==", "dev": true, + "license": "MIT", "dependencies": { "@microsoft/tsdoc": "0.15.0", "ajv": "~8.12.0", @@ -2360,11 +2900,36 @@ "resolve": "~1.22.2" } }, + "node_modules/@microsoft/tsdoc-config/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2378,6 +2943,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } @@ -2387,6 +2953,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -2412,34 +2979,40 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@redocly/config": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.20.1.tgz", - "integrity": "sha512-TYiTDtuItiv95YMsrRxyCs1HKLrDPtTvpaD3+kDKXBnFDeJuYKZ+eHXpCr6YeN4inxfVBs7DLhHsQcs9srddyQ==", + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.20.3.tgz", + "integrity": "sha512-Nyyv1Bj7GgYwj/l46O0nkH1GTKWbO3Ixe7KFcn021aZipkZd+z8Vlu1BwkhqtVgivcKaClaExtWU/lDHkjBzag==", "dev": true, "license": "MIT" }, "node_modules/@redocly/openapi-core": { - "version": "1.27.2", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.27.2.tgz", - "integrity": "sha512-qVrDc27DHpeO2NRCMeRdb4299nijKQE3BY0wrA+WUHlOLScorIi/y7JzammLk22IaTvjR9Mv9aTAdjE1aUwJnA==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.30.0.tgz", + "integrity": "sha512-ZZc+FXKoQXJ9cOR7qRKHxOfKOsGCj2wSodklKdtM2FofzyjzvIwn1rksD5+9iJxvHuORPOPv3ppAHcM+iMr/Ag==", "dev": true, "license": "MIT", "dependencies": { "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.20.1", "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "minimatch": "^5.0.1", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "yaml-ast-parser": "0.0.43" }, "engines": { - "node": ">=14.19.0", - "npm": ">=7.0.0" + "node": ">=18.17.0", + "npm": ">=9.5.0" } }, "node_modules/@redocly/openapi-core/node_modules/minimatch": { @@ -2456,85 +3029,94 @@ } }, "node_modules/@shikijs/core": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.26.1.tgz", - "integrity": "sha512-yeo7sG+WZQblKPclUOKRPwkv1PyoHYkJ4gP9DzhFJbTdueKR7wYTI1vfF/bFi1NTgc545yG/DzvVhZgueVOXMA==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", + "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/engine-javascript": "1.26.1", - "@shikijs/engine-oniguruma": "1.26.1", - "@shikijs/types": "1.26.1", + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "node_modules/@shikijs/engine-javascript": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.26.1.tgz", - "integrity": "sha512-CRhA0b8CaSLxS0E9A4Bzcb3LKBNpykfo9F85ozlNyArxjo2NkijtiwrJZ6eHa+NT5I9Kox2IXVdjUsP4dilsmw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz", + "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/types": "1.26.1", + "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", - "oniguruma-to-es": "0.10.0" + "oniguruma-to-es": "^2.2.0" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.26.1.tgz", - "integrity": "sha512-F5XuxN1HljLuvfXv7d+mlTkV7XukC1cawdtOo+7pKgPD83CAB1Sf8uHqP3PK0u7njFH0ZhoXE1r+0JzEgAQ+kg==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz", + "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/types": "1.26.1", + "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "node_modules/@shikijs/langs": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.26.1.tgz", - "integrity": "sha512-oz/TQiIqZejEIZbGtn68hbJijAOTtYH4TMMSWkWYozwqdpKR3EXgILneQy26WItmJjp3xVspHdiUxUCws4gtuw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz", + "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/types": "1.26.1" + "@shikijs/types": "1.29.2" } }, "node_modules/@shikijs/themes": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.26.1.tgz", - "integrity": "sha512-JDxVn+z+wgLCiUhBGx2OQrLCkKZQGzNH3nAxFir4PjUcYiyD8Jdms9izyxIogYmSwmoPTatFTdzyrRKbKlSfPA==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz", + "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/types": "1.26.1" + "@shikijs/types": "1.29.2" } }, "node_modules/@shikijs/types": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.26.1.tgz", - "integrity": "sha512-d4B00TKKAMaHuFYgRf3L0gwtvqpW4hVdVwKcZYbBfAAQXspgkbWqnFfuFl3MDH6gLbsubOcr+prcnsqah3ny7Q==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz", + "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==", "dev": true, + "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.1.tgz", - "integrity": "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg==", - "dev": true + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } @@ -2544,6 +3126,7 @@ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0" } @@ -2563,6 +3146,7 @@ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -2576,6 +3160,7 @@ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" } @@ -2585,6 +3170,7 @@ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, + "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -2595,6 +3181,7 @@ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.20.7" } @@ -2654,9 +3241,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.4.tgz", - "integrity": "sha512-5kz9ScmzBdzTgB/3susoCgfqNDzBjvLL4taparufgSvlwjdLy6UyUy9T/tCpYd2GIdIilCatC4iSQS0QSYHt0w==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2671,6 +3258,7 @@ "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -2680,6 +3268,7 @@ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "*" } @@ -2702,13 +3291,15 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, + "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -2718,6 +3309,7 @@ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" } @@ -2727,6 +3319,7 @@ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -2771,6 +3364,7 @@ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "*" } @@ -2786,13 +3380,15 @@ "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "version": "22.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", + "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.20.0" } @@ -2845,13 +3441,15 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/ws": { "version": "7.4.7", @@ -2868,6 +3466,7 @@ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, + "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } @@ -2876,23 +3475,25 @@ "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", - "integrity": "sha512-tJzcVyvvb9h/PB96g30MpxACd9IrunT7GF9wfA9/0TJ1LxGOJx1TdPzSbBBnNED7K9Ka8ybJsnEpiXPktolTLg==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz", + "integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/type-utils": "8.19.1", - "@typescript-eslint/utils": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/type-utils": "8.25.0", + "@typescript-eslint/utils": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2908,16 +3509,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.1.tgz", - "integrity": "sha512-67gbfv8rAwawjYx3fYArwldTQKoYfezNUT4D5ioWetr/xCrxXxvleo3uuiFuKfejipvq+og7mjz3b0G2bVyUCw==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz", + "integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/typescript-estree": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "debug": "^4.3.4" }, "engines": { @@ -2933,13 +3535,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.1.tgz", - "integrity": "sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz", + "integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1" + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2950,15 +3553,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.1.tgz", - "integrity": "sha512-Rp7k9lhDKBMRJB/nM9Ksp1zs4796wVNyihG9/TU9R6KCJDNkQbc2EOKjrBtLYh3396ZdpXLtr/MkaSEmNMtykw==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz", + "integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.19.1", - "@typescript-eslint/utils": "8.19.1", + "@typescript-eslint/typescript-estree": "8.25.0", + "@typescript-eslint/utils": "8.25.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2973,10 +3577,11 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.1.tgz", - "integrity": "sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz", + "integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2986,19 +3591,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.1.tgz", - "integrity": "sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", + "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3012,10 +3618,11 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -3024,15 +3631,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.1.tgz", - "integrity": "sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz", + "integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1" + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/typescript-estree": "8.25.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3047,12 +3655,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.1.tgz", - "integrity": "sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz", + "integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/types": "8.25.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -3068,6 +3677,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3076,10 +3686,11 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" }, "node_modules/@web/dev-server-core": { "version": "0.7.5", @@ -3122,15 +3733,15 @@ } }, "node_modules/@web/dev-server-esbuild": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-1.0.3.tgz", - "integrity": "sha512-oImN4/cpyfQC8+JcCx61M7WIo09zE2aDMFuwh+brqxuNXIBRQ+hnRGQK7fEIZSQeWWT5dFrWmH4oYZfqzCAlfQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-1.0.4.tgz", + "integrity": "sha512-ia1LxBwwRiQBYhJ7/RtLenHyPjzle3SvTw3jOZaeGv8UGXVPOkQV8fR05caOtW/DPPZaZovNAybzRKVnNiYIZg==", "dev": true, "license": "MIT", "dependencies": { "@mdn/browser-compat-data": "^4.0.0", "@web/dev-server-core": "^0.7.4", - "esbuild": "^0.24.0", + "esbuild": "^0.25.0", "parse5": "^6.0.1", "ua-parser-js": "^1.0.33" }, @@ -3171,6 +3782,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3183,6 +3795,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -3198,14 +3811,15 @@ } }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" }, "funding": { @@ -3228,6 +3842,7 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -3238,23 +3853,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3264,6 +3868,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3279,6 +3884,7 @@ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, + "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -3291,13 +3897,15 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -3319,6 +3927,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", @@ -3335,6 +3944,7 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", @@ -3351,6 +3961,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -3366,6 +3977,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", "dev": true, + "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", "@babel/helper-define-polyfill-provider": "^0.6.3", @@ -3376,13 +3988,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", - "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2", - "core-js-compat": "^3.38.0" + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3393,6 +4006,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3" }, @@ -3405,6 +4019,7 @@ "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", @@ -3431,6 +4046,7 @@ "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "dev": true, + "license": "MIT", "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" @@ -3446,13 +4062,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3462,6 +4080,7 @@ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { "fill-range": "^7.1.1" }, @@ -3470,9 +4089,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", - "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "dev": true, "funding": [ { @@ -3488,6 +4107,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -3506,6 +4126,7 @@ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "node-int64": "^0.4.0" } @@ -3514,7 +4135,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cache-content-type": { "version": "1.0.1", @@ -3531,9 +4153,9 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3566,6 +4188,7 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -3575,14 +4198,15 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001690", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", - "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", + "version": "1.0.30001700", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", + "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", "dev": true, "funding": [ { @@ -3597,13 +4221,15 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -3614,6 +4240,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3637,6 +4264,7 @@ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -3646,6 +4274,7 @@ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -3656,6 +4285,7 @@ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -3688,21 +4318,24 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/cjs-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", - "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", - "dev": true + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -3727,6 +4360,7 @@ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, + "license": "MIT", "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -3736,13 +4370,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3754,7 +4390,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/colorette": { "version": "1.4.0", @@ -3768,6 +4405,7 @@ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -3777,7 +4415,8 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/content-disposition": { "version": "0.5.4", @@ -3806,7 +4445,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cookies": { "version": "0.9.1", @@ -3823,12 +4463,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.39.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", - "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz", + "integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==", "dev": true, + "license": "MIT", "dependencies": { - "browserslist": "^4.24.2" + "browserslist": "^4.24.3" }, "funding": { "type": "opencollective", @@ -3840,6 +4481,7 @@ "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -3861,6 +4503,7 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3875,6 +4518,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -3892,6 +4536,7 @@ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", "dev": true, + "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, @@ -3912,13 +4557,15 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -3945,6 +4592,7 @@ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -3965,6 +4613,7 @@ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3974,6 +4623,7 @@ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "dev": true, + "license": "MIT", "dependencies": { "dequal": "^2.0.0" }, @@ -3987,6 +4637,7 @@ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -3996,6 +4647,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -4026,16 +4678,18 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.78", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.78.tgz", - "integrity": "sha512-UmwIt7HRKN1rsJfddG5UG7rCTCTAKoS9JeOy/R0zSenAyaZ8SU3RuXlwcratxhdxGRNpk03iq8O7BA3W7ibLVw==", - "dev": true + "version": "1.5.104", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.104.tgz", + "integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==", + "dev": true, + "license": "ISC" }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -4047,13 +4701,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/emoji-regex-xs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/encodeurl": { "version": "1.0.2", @@ -4070,6 +4726,7 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -4082,6 +4739,7 @@ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "dev": true, + "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } @@ -4114,9 +4772,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.1.tgz", - "integrity": "sha512-BPOBuyUF9QIVhuNLhbToCLHP6+0MHwZ7xLBkPPCZqK4JmpJgGnv10035STzzQwFpqdzNFMB3irvDI63IagvDwA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { @@ -4127,9 +4785,9 @@ } }, "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4140,31 +4798,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -4172,6 +4830,7 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -4188,6 +4847,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -4201,6 +4861,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4256,6 +4917,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.3.0.tgz", "integrity": "sha512-0MuFdBrrJVBjT/gyhkP2BqpD0np1NxNLfQ38xXDlSs/KVVpKI2A6vN7jx2Rve/CyUsvOsMGwp9KKrinv7q9g3A==", "dev": true, + "license": "MIT", "dependencies": { "@microsoft/tsdoc": "0.15.0", "@microsoft/tsdoc-config": "0.17.0" @@ -4266,6 +4928,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -4282,6 +4945,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -4289,43 +4953,39 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4333,11 +4993,25 @@ "node": "*" } }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -4355,6 +5029,7 @@ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, + "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -4368,6 +5043,7 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -4380,6 +5056,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -4392,6 +5069,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -4401,6 +5079,7 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -4420,6 +5099,7 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -4452,6 +5132,7 @@ "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", @@ -4467,13 +5148,15 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -4490,6 +5173,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -4501,19 +5185,22 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "dev": true, + "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -4523,6 +5210,7 @@ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "bser": "2.1.1" } @@ -4532,6 +5220,7 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -4544,6 +5233,7 @@ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -4556,6 +5246,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -4572,6 +5263,7 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -4582,10 +5274,11 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", - "dev": true + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" }, "node_modules/fresh": { "version": "0.5.2", @@ -4601,13 +5294,30 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4617,6 +5327,7 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -4626,23 +5337,24 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, "node_modules/get-intrinsic": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", - "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -4660,6 +5372,7 @@ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.0.0" } @@ -4683,6 +5396,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -4696,6 +5410,7 @@ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -4716,6 +5431,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -4728,6 +5444,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4738,6 +5455,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4746,18 +5464,13 @@ } }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, + "license": "MIT", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, "node_modules/gopd": { @@ -4777,19 +5490,22 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -4828,6 +5544,7 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -4836,10 +5553,11 @@ } }, "node_modules/hast-util-to-html": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.4.tgz", - "integrity": "sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", "dev": true, + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", @@ -4848,7 +5566,7 @@ "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", - "property-information": "^6.0.0", + "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" @@ -4863,6 +5581,7 @@ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", "dev": true, + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" }, @@ -4875,13 +5594,15 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -4947,6 +5668,7 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=10.17.0" } @@ -4956,15 +5678,17 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -4981,6 +5705,7 @@ "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, + "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -5000,6 +5725,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -5023,6 +5749,7 @@ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, + "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -5032,19 +5759,22 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, + "license": "MIT", "dependencies": { "hasown": "^2.0.2" }, @@ -5060,6 +5790,7 @@ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5069,6 +5800,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -5078,6 +5810,7 @@ "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5106,6 +5839,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, + "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -5118,6 +5852,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -5127,6 +5862,7 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -5155,6 +5891,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -5179,13 +5916,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=8" } @@ -5195,6 +5934,7 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", @@ -5207,10 +5947,11 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5223,6 +5964,7 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -5237,6 +5979,7 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", @@ -5251,6 +5994,7 @@ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -5264,6 +6008,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5290,6 +6035,7 @@ "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", "dev": true, + "license": "MIT", "dependencies": { "execa": "^5.0.0", "jest-util": "^29.7.0", @@ -5304,6 +6050,7 @@ "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -5335,6 +6082,7 @@ "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", "@jest/test-result": "^29.7.0", @@ -5368,6 +6116,7 @@ "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/test-sequencer": "^29.7.0", @@ -5413,6 +6162,7 @@ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", @@ -5428,6 +6178,7 @@ "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, + "license": "MIT", "dependencies": { "detect-newline": "^3.0.0" }, @@ -5440,6 +6191,7 @@ "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -5456,6 +6208,7 @@ "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -5473,6 +6226,7 @@ "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -5482,6 +6236,7 @@ "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", @@ -5507,6 +6262,7 @@ "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", "dev": true, + "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" @@ -5520,6 +6276,7 @@ "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", @@ -5535,6 +6292,7 @@ "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", @@ -5555,6 +6313,7 @@ "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -5569,6 +6328,7 @@ "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -5586,6 +6346,7 @@ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -5595,6 +6356,7 @@ "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "graceful-fs": "^4.2.9", @@ -5615,6 +6377,7 @@ "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", "dev": true, + "license": "MIT", "dependencies": { "jest-regex-util": "^29.6.3", "jest-snapshot": "^29.7.0" @@ -5628,6 +6391,7 @@ "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", "@jest/environment": "^29.7.0", @@ -5660,6 +6424,7 @@ "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -5693,6 +6458,7 @@ "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", @@ -5720,10 +6486,11 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5736,6 +6503,7 @@ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -5753,6 +6521,7 @@ "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", @@ -5770,6 +6539,7 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -5782,6 +6552,7 @@ "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", "dev": true, + "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", @@ -5801,6 +6572,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -5816,6 +6588,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -5830,7 +6603,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-levenshtein": { "version": "1.1.6", @@ -5846,13 +6620,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5865,6 +6641,7 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -5876,31 +6653,36 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "dev": true, + "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -5926,6 +6708,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -5935,14 +6718,15 @@ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/koa": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", - "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", + "integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6049,6 +6833,7 @@ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6058,6 +6843,7 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -6070,13 +6856,15 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "dev": true, + "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } @@ -6086,6 +6874,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -6100,19 +6889,22 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^3.0.2" } @@ -6121,13 +6913,15 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.5.3" }, @@ -6139,10 +6933,11 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -6155,6 +6950,7 @@ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "tmpl": "1.0.5" } @@ -6164,6 +6960,7 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -6191,6 +6988,7 @@ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", "dev": true, + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -6211,7 +7009,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/media-typer": { "version": "0.3.0", @@ -6227,13 +7026,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } @@ -6253,6 +7054,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" @@ -6272,7 +7074,8 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromark-util-sanitize-uri": { "version": "2.0.1", @@ -6289,6 +7092,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", @@ -6309,7 +7113,8 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromark-util-types": { "version": "2.0.1", @@ -6325,13 +7130,15 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -6368,6 +7175,7 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6377,6 +7185,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6391,13 +7200,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/negotiator": { "version": "0.6.3", @@ -6409,69 +7220,26 @@ "node": ">= 0.6" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6481,6 +7249,7 @@ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.0.0" }, @@ -6506,6 +7275,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, + "license": "ISC", "dependencies": { "wrappy": "1" } @@ -6515,6 +7285,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -6526,10 +7297,11 @@ } }, "node_modules/oniguruma-to-es": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-0.10.0.tgz", - "integrity": "sha512-zapyOUOCJxt+xhiNRPPMtfJkHGsZ98HHB9qJEkdT8BGytO/+kpe4m1Ngf0MzbzTmhacn11w9yGeDP6tzDhnCdg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", + "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", @@ -6543,13 +7315,13 @@ "dev": true }, "node_modules/openapi-typescript": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.5.2.tgz", - "integrity": "sha512-W/QXuQz0Fa3bGY6LKoqTCgrSX+xI/ST+E5RXo2WBmp3WwgXCWKDJPHv5GZmElF4yLCccnqYsakBDOJikHZYGRw==", + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.6.1.tgz", + "integrity": "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag==", "dev": true, "license": "MIT", "dependencies": { - "@redocly/openapi-core": "^1.27.0", + "@redocly/openapi-core": "^1.28.0", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.1.0", @@ -6595,9 +7367,9 @@ } }, "node_modules/openapi-typescript/node_modules/type-fest": { - "version": "4.32.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.32.0.tgz", - "integrity": "sha512-rfgpoi08xagF3JSdtJlCwMq9DGNDE0IMh3Mkpc1wUypg9vPi786AiqeBBKcqvIkq42azsBM85N490fyZjeUftw==", + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.35.0.tgz", + "integrity": "sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -6612,6 +7384,7 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -6629,6 +7402,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -6644,6 +7418,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -6659,6 +7434,7 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6668,6 +7444,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -6680,6 +7457,7 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -6715,6 +7493,7 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -6724,6 +7503,7 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6733,6 +7513,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -6741,19 +7522,22 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -6766,6 +7550,7 @@ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 6" } @@ -6775,6 +7560,7 @@ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, + "license": "MIT", "dependencies": { "find-up": "^4.0.0" }, @@ -6787,6 +7573,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -6800,6 +7587,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^4.1.0" }, @@ -6812,6 +7600,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, + "license": "MIT", "dependencies": { "p-try": "^2.0.0" }, @@ -6827,6 +7616,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^2.2.0" }, @@ -6837,7 +7627,8 @@ "node_modules/plural-forms": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/plural-forms/-/plural-forms-0.5.5.tgz", - "integrity": "sha512-rJw4xp22izsfJOVqta5Hyvep2lR3xPkFUtj7dyQtpf/FbxUiX7PQCajTn2EHDRylizH5N/Uqqodfdu22I0ju+g==" + "integrity": "sha512-rJw4xp22izsfJOVqta5Hyvep2lR3xPkFUtj7dyQtpf/FbxUiX7PQCajTn2EHDRylizH5N/Uqqodfdu22I0ju+g==", + "license": "MIT" }, "node_modules/pluralize": { "version": "8.0.0", @@ -6854,15 +7645,17 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", + "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -6878,6 +7671,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -6892,6 +7686,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -6904,6 +7699,7 @@ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", "dev": true, + "license": "MIT", "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" @@ -6913,10 +7709,11 @@ } }, "node_modules/property-information": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", - "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", + "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -6927,6 +7724,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6936,6 +7734,7 @@ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6954,7 +7753,8 @@ "type": "opencollective", "url": "https://opencollective.com/fast-check" } - ] + ], + "license": "MIT" }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -6974,18 +7774,20 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/readdirp": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz", - "integrity": "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", "engines": { @@ -7000,13 +7802,15 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", "dev": true, + "license": "MIT", "dependencies": { "regenerate": "^1.4.2" }, @@ -7014,11 +7818,19 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, "node_modules/regenerator-transform": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.4" } @@ -7028,6 +7840,7 @@ "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", "dev": true, + "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" } @@ -7037,6 +7850,7 @@ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", "dev": true, + "license": "MIT", "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" @@ -7046,13 +7860,15 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/regexpu-core": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", "dev": true, + "license": "MIT", "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.0", @@ -7069,13 +7885,15 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/regjsparser": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.0.2" }, @@ -7088,6 +7906,7 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -7100,6 +7919,7 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7109,23 +7929,28 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, + "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7135,6 +7960,7 @@ "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, + "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" }, @@ -7147,6 +7973,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -7156,6 +7983,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -7219,6 +8047,7 @@ "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -7228,6 +8057,7 @@ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true, + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -7239,6 +8069,7 @@ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -7268,6 +8099,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -7316,6 +8148,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -7332,6 +8165,7 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -7344,22 +8178,24 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/shiki": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.26.1.tgz", - "integrity": "sha512-Gqg6DSTk3wYqaZ5OaYtzjcdxcBvX5kCy24yvRJEgjT5U+WHlmqCThLuBUx0juyxQBi+6ug53IGeuQS07DWwpcw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", + "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/core": "1.26.1", - "@shikijs/engine-javascript": "1.26.1", - "@shikijs/engine-oniguruma": "1.26.1", - "@shikijs/langs": "1.26.1", - "@shikijs/themes": "1.26.1", - "@shikijs/types": "1.26.1", + "@shikijs/core": "1.29.2", + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/langs": "1.29.2", + "@shikijs/themes": "1.29.2", + "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } @@ -7368,19 +8204,22 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -7390,6 +8229,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -7399,6 +8239,7 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -7409,6 +8250,7 @@ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -7418,13 +8260,15 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -7437,6 +8281,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -7456,6 +8301,7 @@ "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, + "license": "MIT", "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -7469,6 +8315,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -7483,6 +8330,7 @@ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", "dev": true, + "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" @@ -7497,6 +8345,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -7509,6 +8358,7 @@ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -7518,6 +8368,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -7527,6 +8378,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -7539,6 +8391,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -7551,6 +8404,7 @@ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7563,6 +8417,7 @@ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, + "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -7577,6 +8432,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7587,6 +8443,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7598,19 +8455,22 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -7633,16 +8493,18 @@ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, + "license": "MIT", "engines": { "node": ">=18.12" }, @@ -7664,6 +8526,7 @@ "version": "1.8.7", "resolved": "https://registry.npmjs.org/ttag/-/ttag-1.8.7.tgz", "integrity": "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA==", + "license": "MIT", "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" @@ -7673,6 +8536,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, @@ -7687,6 +8551,7 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -7699,15 +8564,17 @@ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -7734,6 +8601,7 @@ "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.11.tgz", "integrity": "sha512-sFEgRRtrcDl2FxVP58Ze++ZK2UQAEvtvvH8rRlig1Ja3o7dDaMHmaBfvJmdGnNEFaLTpQsN8dpvZaTqJSu/Ugw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "lunr": "^2.3.9", "markdown-it": "^14.1.0", @@ -7756,6 +8624,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7795,19 +8664,22 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -7817,6 +8689,7 @@ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "dev": true, + "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" @@ -7830,6 +8703,7 @@ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -7839,6 +8713,7 @@ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -7848,6 +8723,7 @@ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, @@ -7861,6 +8737,7 @@ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, @@ -7874,6 +8751,7 @@ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, @@ -7887,6 +8765,7 @@ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", @@ -7902,6 +8781,7 @@ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" @@ -7912,9 +8792,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", + "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", "dev": true, "funding": [ { @@ -7930,9 +8810,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -7946,6 +8827,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -7962,6 +8844,7 @@ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, + "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -7986,6 +8869,7 @@ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" @@ -8000,6 +8884,7 @@ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" @@ -8014,6 +8899,7 @@ "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "makeerror": "1.0.12" } @@ -8023,6 +8909,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -8038,6 +8925,7 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8047,6 +8935,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -8063,13 +8952,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/write-file-atomic": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -8105,6 +8996,7 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } @@ -8113,13 +9005,15 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/yaml": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", "dev": true, + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -8139,6 +9033,7 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -8157,6 +9052,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } @@ -8176,6 +9072,7 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -8188,6 +9085,7 @@ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" From 30ce8673eb65366df43847e45102f02967b0798f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Feb 2025 11:17:39 +0000 Subject: [PATCH 024/791] Update user management --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 95992761..a10f0ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ cache*.sqlite # Tests tests/storage + +# Test reporter +tests/test-report.xml \ No newline at end of file From 5bb04161880a8799413fed73ecd7ea8691dca2ef Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Feb 2025 08:46:49 +0000 Subject: [PATCH 025/791] Add node name validation --- js/sdk/src/internal/errors.ts | 8 ++++ js/sdk/src/internal/nodes/nodesAccess.test.ts | 42 ++++++++++++++----- js/sdk/src/internal/nodes/nodesAccess.ts | 15 ++++++- js/sdk/src/internal/nodes/nodesManagement.ts | 5 +++ js/sdk/src/internal/nodes/validations.ts | 26 ++++++++++++ 5 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 js/sdk/src/internal/nodes/validations.ts diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index 5195a9cf..6f489b94 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -1,3 +1,11 @@ export class AbortError extends Error { name = 'AbortError'; } + +export class SDKError extends Error { + name = 'SDKError'; +} + +export class ValidationError extends SDKError { + name = 'ValidationError'; +} diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 0bd9c5a8..c2f97b94 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -58,9 +58,10 @@ describe('nodesAccess', () => { it('should get node from API when cahce is stale', async () => { const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; - const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedUnparsedNode; + const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid', name: { ok: true, value: 'name' } } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, + name: { ok: true, value: 'name' }, isStale: false, activeRevision: undefined, folder: undefined, @@ -83,9 +84,10 @@ describe('nodesAccess', () => { it('should get node from API missing cache', async () => { const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; - const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid' } as DecryptedUnparsedNode; + const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid', name: { ok: true, value: 'name' } } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, + name: { ok: true, value: 'name' }, isStale: false, activeRevision: undefined, folder: undefined, @@ -105,13 +107,31 @@ describe('nodesAccess', () => { expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('nodeId', decryptedKeys); }); + + it('should validate node name', async () => { + const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; + const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid', name: { ok: true, value: 'foo/bar' } } as DecryptedUnparsedNode; + const decryptedNode = { + ...decryptedUnparsedNode, + name: { ok: false, error: { name: 'foo/bar', error: "Name must not contain the character '/'" } }, + } as DecryptedNode; + const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; + + cache.getNode = jest.fn(() => Promise.reject(new Error('Entity not found'))); + apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); + + const result = await access.getNode('nodeId'); + expect(result).toMatchObject(decryptedNode); + }); }); describe('iterate methods', () => { beforeEach(() => { cryptoCache.getNodeKeys = jest.fn().mockImplementation((uid: string) => Promise.resolve({ key: 'key' } as any as DecryptedNodeKeys)); cryptoService.decryptNode = jest.fn().mockImplementation((encryptedNode: EncryptedNode) => Promise.resolve({ - node: { uid: encryptedNode.uid, isStale: false } as DecryptedNode, + node: { uid: encryptedNode.uid, isStale: false, name: { ok: true, value: 'name' } } as DecryptedNode, keys: { key: 'key' } as any as DecryptedNodeKeys, })); }); @@ -137,7 +157,7 @@ describe('nodesAccess', () => { }); const result = await Array.fromAsync(access.iterateChildren('parentUid')); - expect(result).toEqual([node1, node2, node3, node4]); + expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).not.toHaveBeenCalled(); expect(apiService.getNodes).not.toHaveBeenCalled(); }); @@ -155,7 +175,7 @@ describe('nodesAccess', () => { )); const result = await Array.fromAsync(access.iterateChildren('parentUid')); - expect(result).toEqual([node1, node4, node2, node3]); + expect(result).toMatchObject([node1, node4, node2, node3]); expect(apiService.getNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(2); expect(cache.setNode).toHaveBeenCalledTimes(2); @@ -172,7 +192,7 @@ describe('nodesAccess', () => { cache.getNode = jest.fn().mockImplementation((uid: string) => ({ uid, isStale: false })); const result = await Array.fromAsync(access.iterateChildren('parentUid')); - expect(result).toEqual([node1, node2, node3, node4]); + expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); expect(apiService.getNodes).not.toHaveBeenCalled(); expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); @@ -196,7 +216,7 @@ describe('nodesAccess', () => { )); const result = await Array.fromAsync(access.iterateChildren('parentUid')); - expect(result).toEqual([node1, node2, node3, node4]); + expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); expect(apiService.getNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); @@ -227,7 +247,7 @@ describe('nodesAccess', () => { cache.getNode = jest.fn().mockImplementation((uid: string) => ({ uid, isStale: false })); const result = await Array.fromAsync(access.iterateTrashedNodes()); - expect(result).toEqual([node1, node2, node3, node4]); + expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); expect(apiService.getNodes).not.toHaveBeenCalled(); }); @@ -241,7 +261,7 @@ describe('nodesAccess', () => { )); const result = await Array.fromAsync(access.iterateTrashedNodes()); - expect(result).toEqual([node1, node2, node3, node4]); + expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); expect(apiService.getNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); @@ -265,7 +285,7 @@ describe('nodesAccess', () => { }); const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); - expect(result).toEqual([node1, node2, node3, node4]); + expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.getNodes).not.toHaveBeenCalled(); }); @@ -281,7 +301,7 @@ describe('nodesAccess', () => { )); const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); - expect(result).toEqual([node1, node4, node2, node3]); + expect(result).toMatchObject([node1, node4, node2, node3]); expect(apiService.getNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 98fab723..29a8404d 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,4 +1,4 @@ -import { Logger, NodeType, resultOk } from "../../interface"; +import { Logger, NodeType, resultError, resultOk } from "../../interface"; import { BatchLoading } from "../batchLoading"; import { makeNodeUid } from "../uids"; import { NodeAPIService } from "./apiService"; @@ -7,6 +7,7 @@ import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from "./extendedAttributes"; import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; +import { validateNodeName } from "./validations"; /** * Provides access to node metadata. @@ -142,6 +143,18 @@ export class NodesAccess { } private async parseNode(unparsedNode: DecryptedUnparsedNode): Promise { + if (unparsedNode.name.ok) { + try { + validateNodeName(unparsedNode.name.value); + } catch (error: unknown) { + this.log?.warn('Node name validation failed', error); + unparsedNode.name = resultError({ + name: unparsedNode.name.value, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + if (unparsedNode.type === NodeType.File) { const extendedAttributes = unparsedNode.activeRevision?.ok ? parseFileExtendedAttributes(unparsedNode.activeRevision.value.extendedAttributes, this.log) : undefined; diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 9c2988a5..e1a46e96 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -6,6 +6,7 @@ import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { DecryptedNode } from "./interface"; import { NodesAccess } from "./nodesAccess"; +import { validateNodeName } from "./validations"; /** * Provides high-level actions for managing nodes. @@ -32,6 +33,8 @@ export class NodesManagement { } async renameNode(nodeUid: string, newName: string): Promise { + validateNodeName(newName); + const node = await this.nodesAccess.getNode(nodeUid); const parentKeys = await this.nodesAccess.getParentKeys(node); @@ -201,6 +204,8 @@ export class NodesManagement { } async createFolder(parentNodeUid: string, folderName: string): Promise { + validateNodeName(folderName); + const parentNode = await this.nodesAccess.getNode(parentNodeUid); const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); if (!parentKeys.hashKey) { diff --git a/js/sdk/src/internal/nodes/validations.ts b/js/sdk/src/internal/nodes/validations.ts new file mode 100644 index 00000000..193145f6 --- /dev/null +++ b/js/sdk/src/internal/nodes/validations.ts @@ -0,0 +1,26 @@ +import { c, msgid } from 'ttag'; + +import { ValidationError } from '../errors'; + +const MAX_NODE_NAME_LENGTH = 255; + +/** + * @throws {Error} if the name is empty, long, or includes slash in the name. + */ +export function validateNodeName(name: string): void { + if (!name) { + throw new ValidationError(c('Validation Error').t`Name must not be empty`); + } + if (name.length > MAX_NODE_NAME_LENGTH) { + throw new ValidationError( + c('Validation Error').ngettext( + msgid`Name must be ${MAX_NODE_NAME_LENGTH} character long at most`, + `Name must be ${MAX_NODE_NAME_LENGTH} characters long at most`, + MAX_NODE_NAME_LENGTH + ) + ); + } + if (name.includes('/')) { + throw new ValidationError(c('Validation Error').t`Name must not contain the character '/'`); + } +} From 53ab929b616d4a4b29f3fcdf5fff1a28b255c65c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Feb 2025 13:21:06 +0100 Subject: [PATCH 026/791] fix existing node management commands --- js/sdk/src/config.ts | 4 +- js/sdk/src/internal/apiService/errorCodes.ts | 2 + js/sdk/src/internal/apiService/errors.ts | 5 ++ js/sdk/src/internal/nodes/apiService.test.ts | 3 +- js/sdk/src/internal/nodes/apiService.ts | 37 +++++---- js/sdk/src/internal/nodes/cryptoService.ts | 2 +- .../internal/nodes/nodesManagement.test.ts | 83 ++++++++++++++----- js/sdk/src/internal/nodes/nodesManagement.ts | 51 +++++------- js/sdk/src/internal/nodes/validations.ts | 2 +- 9 files changed, 119 insertions(+), 70 deletions(-) diff --git a/js/sdk/src/config.ts b/js/sdk/src/config.ts index 129dbc22..17b49372 100644 --- a/js/sdk/src/config.ts +++ b/js/sdk/src/config.ts @@ -2,9 +2,9 @@ import { ProtonDriveConfig } from './interface/index.js'; export function getConfig(config?: ProtonDriveConfig) { return { - baseUrl: config?.baseUrl || 'https://drive.proton.me/api', - language: config?.language || 'en', // TODO: add defaults for all fields ...config, + baseUrl: config?.baseUrl ? `https://${config.baseUrl}/api` : 'https://drive.proton.me/api', + language: config?.language || 'en', }; } diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index d99c5b68..f7061d63 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -5,6 +5,8 @@ export const enum HTTPErrorCode { } export const enum ErrorCode { + NOT_FOUND = 404, OK = 1000, + OK_MANY = 1001, NOT_EXISTS = 2501, } diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 9d1345bf..1f560085 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -9,6 +9,11 @@ export function apiErrorFactory({ response, result }: { response: Response, resu // error or code set which next lines should handle. const [code, message] = [result.Code || 0, result.Error || "Unknown error"]; switch (code) { + // Backend doesn't return 404 for not found resources, this is only + // when the API endpoint is not found. Lets add the URL in the error + // message so we can debug it easier. + case ErrorCode.NOT_FOUND: + return new NotFoundAPIError(`${message}: ${response.url}`, code); case ErrorCode.NOT_EXISTS: return new NotFoundAPIError(message, code); default: diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 263b19e4..b40d1108 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -246,8 +246,7 @@ describe("nodeAPIService", () => { ], })); - const parentUid = 'volume:volumeId;node:parentLinkId'; - const result = await Array.fromAsync(api.trashNodes(parentUid, ['volume:volumeId;node:nodeId1', 'volume:volumeId;node:nodeId2'])); + const result = await Array.fromAsync(api.trashNodes(['volume:volumeId;node:nodeId1', 'volume:volumeId;node:nodeId2'])); expect(result).toEqual([ { uid: 'volume:volumeId;node:nodeId1', ok: true }, { uid: 'volume:volumeId;node:nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 1fa33826..b408ce3b 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,6 +1,6 @@ import { Logger, NodeType, MemberRole, NodeResult } from "../../interface"; import { RevisionState } from "../../interface/nodes"; -import { DriveAPIService, drivePaths } from "../apiService"; +import { DriveAPIService, drivePaths, ErrorCode } from "../apiService"; import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid } from "../uids"; import { EncryptedNode, EncryptedRevision } from "./interface"; @@ -210,9 +210,9 @@ export class NodeAPIService { parentUid: string, armoredNodePassphrase: string, armoredNodePassphraseSignature?: string, - signatureEmail: string, + signatureEmail?: string, encryptedName: string, - nameSignatureEmail: string, + nameSignatureEmail?: string, hash: string, contentHash?: string, }, @@ -224,12 +224,15 @@ export class NodeAPIService { await this.apiService.put< Omit, PutMoveNodeResponse - >(`/drive/v2/volumes/${volumeId}/links/${nodeId}/move`, { + >(`drive/v2/volumes/${volumeId}/links/${nodeId}/move`, { ParentLinkID: newParentNodeId, NodePassphrase: newNode.armoredNodePassphrase, - NodePassphraseSignature: newNode.armoredNodePassphraseSignature || null, + // @ts-expect-error: API accepts NodePassphraseSignature as optional. + NodePassphraseSignature: newNode.armoredNodePassphraseSignature, + // @ts-expect-error: API accepts SignatureEmail as optional. SignatureEmail: newNode.signatureEmail, Name: newNode.encryptedName, + // @ts-expect-error: API accepts NameSignatureEmail as optional. NameSignatureEmail: newNode.nameSignatureEmail, Hash: newNode.hash, OriginalHash: oldNode.hash, @@ -237,17 +240,15 @@ export class NodeAPIService { }, signal); } - // Improvement requested: API without requiring parent node (to delete any nodes). // Improvement requested: split into multiple calls for many nodes. - async* trashNodes(parentNodeUid: string, nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); - + async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); + const volumeId = assertAndGetSingleVolumeId("trashNodes", nodeIds); const response = await this.apiService.post< PostTrashNodesRequest, PostTrashNodesResponse - >(`/drive/v2/volumes/${volumeId}/folders/${parentNodeId}/trash_multiple`, { + >(`drive/v2/volumes/${volumeId}/trash_multiple`, { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); @@ -263,7 +264,7 @@ export class NodeAPIService { const response = await this.apiService.put< PutRestoreNodesRequest, PutRestoreNodesResponse - >(`/drive/v2/volumes/${volumeId}/trash/restore_multiple`, { + >(`drive/v2/volumes/${volumeId}/trash/restore_multiple`, { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); @@ -279,7 +280,7 @@ export class NodeAPIService { const response = await this.apiService.post< PostDeleteNodesRequest, PostDeleteNodesResponse - >(`/drive/v2/volumes/${volumeId}/trash/delete_multiple`, { + >(`drive/v2/volumes/${volumeId}/trash/delete_multiple`, { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); @@ -297,7 +298,7 @@ export class NodeAPIService { signatureEmail: string, encryptedName: string, hash: string, - encryptedExtendedAttributes: string, + encryptedExtendedAttributes?: string, }, ): Promise { const { volumeId, nodeId: parentId } = splitNodeUid(parentUid); @@ -305,7 +306,7 @@ export class NodeAPIService { const response = await this.apiService.post< PostCreateFolderRequest, PostCreateFolderResponse - >(`/drive/v2/volumes/${volumeId}/folders`, { + >(`drive/v2/volumes/${volumeId}/folders`, { ParentLinkID: parentId, NodeKey: newNode.armoredKey, NodeHashKey: newNode.armoredHashKey, @@ -314,6 +315,7 @@ export class NodeAPIService { SignatureEmail: newNode.signatureEmail, Name: newNode.encryptedName, Hash: newNode.hash, + // @ts-expect-error: API accepts XAttr as optional. XAttr: newNode.encryptedExtendedAttributes, }); @@ -341,13 +343,13 @@ export class NodeAPIService { await this.apiService.post< undefined, PostRestoreRevisionResponse - >(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}/restore`); + >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}/restore`); } async deleteRevision(nodeRevisionUid: string): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); - await this.apiService.delete(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); + await this.apiService.delete(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); } } @@ -388,7 +390,8 @@ function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: const errors = new Map(); responses.forEach((response) => { - if (response.Response.Code !== 1000 || response.Response.Error) { + const okResponse = response.Response.Code === ErrorCode.OK || response.Response.Code === ErrorCode.OK_MANY; + if (!okResponse || response.Response.Error) { const nodeUid = makeNodeUid(volumeId, response.LinkID); errors.set(nodeUid, response.Response.Error || 'Unknown error'); } diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index d81388a0..669a52e4 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -283,7 +283,7 @@ export class NodesCryptoService { armoredNodePassphrase: nodeKeys.encrypted.armoredPassphrase, armoredNodePassphraseSignature: nodeKeys.encrypted.armoredPassphraseSignature, folder: { - encryptedExtendedAttributes: '', + encryptedExtendedAttributes: undefined, armoredHashKey, }, signatureEmail: email, diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 31f704e7..8a72578b 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -14,27 +14,38 @@ describe('NodesManagement', () => { let nodesAccess: NodesAccess; let management: NodesManagement; - const nodes: { [uid: string]: DecryptedNode } = { - nodeUid: { - uid: 'nodeUid', - parentUid: 'parentUid', - name: { ok: true, value: 'old name' }, - keyAuthor: { ok: true, value: 'keyAauthor' }, - nameAuthor: { ok: true, value: 'nameAuthor' }, - hash: 'hash', - mimeType: 'mimeType', - } as DecryptedNode, - parentUid: { - uid: 'parentUid', - name: { ok: true, value: 'parent' }, - } as DecryptedNode, - newParentUid: { - uid: 'newParentUid', - name: { ok: true, value: 'new parent' }, - } as DecryptedNode, - }; + let nodes: { [uid: string]: DecryptedNode }; beforeEach(() => { + nodes = { + nodeUid: { + uid: 'nodeUid', + parentUid: 'parentUid', + name: { ok: true, value: 'old name' }, + keyAuthor: { ok: true, value: 'keyAauthor' }, + nameAuthor: { ok: true, value: 'nameAuthor' }, + hash: 'hash', + mimeType: 'mimeType', + } as DecryptedNode, + anonymousNodeUid: { + uid: 'anonymousNodeUid', + parentUid: 'parentUid', + name: { ok: true, value: 'old name' }, + keyAuthor: { ok: true, value: null }, + nameAuthor: { ok: true, value: 'nameAuthor' }, + hash: 'hash', + mimeType: 'mimeType', + } as DecryptedNode, + parentUid: { + uid: 'parentUid', + name: { ok: true, value: 'parent' }, + } as DecryptedNode, + newParentUid: { + uid: 'newParentUid', + name: { ok: true, value: 'new parent' }, + } as DecryptedNode, + }; + // @ts-expect-error No need to implement all methods for mocking apiService = { renameNode: jest.fn(), @@ -124,6 +135,40 @@ describe('NodesManagement', () => { { hash: nodes.nodeUid.hash, }, + { + parentUid: 'newParentNodeUid', + ...encryptedCrypto, + armoredNodePassphraseSignature: undefined, + signatureEmail: undefined, + }, + ); + expect(cache.setNode).toHaveBeenCalledWith(newNode); + }); + + it('moveNode manages move of anonymous node', async () => { + const encryptedCrypto = { + encryptedName: 'movedArmoredNodeName', + hash: 'movedHash', + armoredNodePassphrase: 'movedArmoredNodePassphrase', + armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature', + signatureEmail: 'movedSignatureEmail', + nameSignatureEmail: 'movedNameSignatureEmail', + } + cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); + + const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid'); + expect(newNode).toEqual({ + ...nodes.anonymousNodeUid, + parentUid: 'newParentNodeUid', + hash: 'movedHash', + keyAuthor: { ok: true, value: 'movedSignatureEmail' }, + nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, + }); + expect(apiService.moveNode).toHaveBeenCalledWith( + 'anonymousNodeUid', + { + hash: nodes.nodeUid.hash, + }, { parentUid: 'newParentNodeUid', ...encryptedCrypto diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index e1a46e96..b0114ebb 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -113,16 +113,25 @@ export class NodesManagement { newParentNode, { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, ); + + // Node could be uploaded or renamed by anonymous user and thus have + // missing signatures that must be added to the move request. + // Node passphrase and signature email must be passed if and only if + // the the signatures are missing (key author is null). + const anonymousKey = node.keyAuthor.ok && node.keyAuthor.value === null; + const keySignatureProperties = !anonymousKey ? {} : { + signatureEmail: encryptedCrypto.signatureEmail, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + } await this.apiService.moveNode( nodeUid, { hash: node.hash, }, { + ...keySignatureProperties, parentUid: newParentUid, armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, - armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, - signatureEmail: encryptedCrypto.signatureEmail, encryptedName: encryptedCrypto.encryptedName, nameSignatureEmail: encryptedCrypto.nameSignatureEmail, hash: encryptedCrypto.hash, @@ -141,34 +150,20 @@ export class NodesManagement { } async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodesPerParent = new Map(); - - for await (const node of this.nodesAccess.iterateNodes(nodeUids, signal)) { - if (!node.parentUid) { - throw new Error('Trashing root nodes is not supported'); - } - const nodes = nodesPerParent.get(node.parentUid); - if (nodes) { - nodes.push(node); - } else { - nodesPerParent.set(node.parentUid, [node]); - } - } + const nodes = await Array.fromAsync(this.nodesAccess.iterateNodes(nodeUids, signal)); - for (const [parentNodeUid, nodes] of nodesPerParent) { - for await (const result of this.apiService.trashNodes(parentNodeUid, nodes.map(node => node.uid), signal)) { - if (result.ok) { - const node = nodes.find(node => node.uid === result.uid); - if (node) { - await this.cache.setNode({ - ...node, - trashedDate: new Date(), - }); - } + for await (const result of this.apiService.trashNodes(nodeUids, signal)) { + if (result.ok) { + const node = nodes.find(node => node.uid === result.uid); + if (node) { + await this.cache.setNode({ + ...node, + trashedDate: new Date(), + }); } - - yield result; } + + yield result; } } @@ -221,7 +216,7 @@ export class NodesManagement { signatureEmail: encryptedCrypto.signatureEmail, encryptedName: encryptedCrypto.encryptedName, hash: encryptedCrypto.hash, - encryptedExtendedAttributes: encryptedCrypto.folder.encryptedExtendedAttributes || "", // TODO + encryptedExtendedAttributes: encryptedCrypto.folder.encryptedExtendedAttributes, // TODO }); const node: DecryptedNode = { diff --git a/js/sdk/src/internal/nodes/validations.ts b/js/sdk/src/internal/nodes/validations.ts index 193145f6..50c543dd 100644 --- a/js/sdk/src/internal/nodes/validations.ts +++ b/js/sdk/src/internal/nodes/validations.ts @@ -5,7 +5,7 @@ import { ValidationError } from '../errors'; const MAX_NODE_NAME_LENGTH = 255; /** - * @throws {Error} if the name is empty, long, or includes slash in the name. + * @throws Error if the name is empty, long, or includes slash in the name. */ export function validateNodeName(name: string): void { if (!name) { From 95bb6caad25cc3d9d6779db606a5c3c45e015ab0 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 4 Mar 2025 13:36:46 +0000 Subject: [PATCH 027/791] Add caching of secrets and entities --- cs/sdk/src/Directory.Packages.props | 1 + .../Proton.Sdk/Addresses/AddressOperations.cs | 184 +++++----- .../Proton.Sdk/Addresses/Api/AddressKeyDto.cs | 2 +- .../Proton.Sdk/Caching/AccountEntityCache.cs | 72 +++- .../Proton.Sdk/Caching/AccountSecretCache.cs | 43 ++- .../Caching/CacheRepositoryExtensions.cs | 39 +++ cs/sdk/src/Proton.Sdk/Caching/ICache.cs | 16 - .../Proton.Sdk/Caching/ICacheRepository.cs | 16 + cs/sdk/src/Proton.Sdk/Caching/NullCache.cs | 25 -- .../Proton.Sdk/Caching/NullCacheRepository.cs | 41 +++ .../Proton.Sdk/Caching/SessionSecretCache.cs | 25 +- .../Caching/SqliteCacheRepository.cs | 320 ++++++++++++++++++ cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 1 + cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 80 ++--- cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs | 35 +- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 43 ++- .../Proton.Sdk/ProtonClientConfiguration.cs | 6 +- cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs | 6 +- cs/sdk/src/Proton.Sdk/ProtonSessionOptions.cs | 12 + ...cs => PgpArmoredBlockJsonConverterBase.cs} | 13 +- .../PgpArmoredMessageJsonConverter.cs | 11 + .../PgpArmoredPrivateKeyJsonConverter.cs | 11 + .../PgpArmoredPublicKeyJsonConverter.cs | 11 + .../PgpArmoredSignatureJsonConverter.cs | 11 + .../PgpPrivateKeyJsonConverter.cs | 20 ++ .../ProtonApiSerializerContext.cs | 31 +- .../ProtonEntitySerializerContext.cs | 25 ++ ...dConverter.cs => StrongIdJsonConverter.cs} | 2 +- 28 files changed, 848 insertions(+), 254 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Caching/ICache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Caching/NullCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs create mode 100644 cs/sdk/src/Proton.Sdk/ProtonSessionOptions.cs rename cs/sdk/src/Proton.Sdk/Serialization/{PgpArmoredBlockJsonConverter.cs => PgpArmoredBlockJsonConverterBase.cs} (79%) create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredMessageJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpPrivateKeyJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/ProtonEntitySerializerContext.cs rename cs/sdk/src/Proton.Sdk/Serialization/{StrongIdConverter.cs => StrongIdJsonConverter.cs} (87%) diff --git a/cs/sdk/src/Directory.Packages.props b/cs/sdk/src/Directory.Packages.props index 440af80b..7460c651 100644 --- a/cs/sdk/src/Directory.Packages.props +++ b/cs/sdk/src/Directory.Packages.props @@ -3,6 +3,7 @@ true + diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index d7b23942..6afed366 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -10,57 +10,133 @@ namespace Proton.Sdk.Addresses; internal static class AddressOperations { - public static async ValueTask> GetAllAsync(ProtonAccountClient client, CancellationToken cancellationToken) + public static async ValueTask> GetAddressesAsync(ProtonAccountClient client, CancellationToken cancellationToken) { - var addressListResponse = await client.Api.Addresses.GetAddressesAsync(cancellationToken).ConfigureAwait(false); + var result = await client.EntityCache.TryGetCurrentUserAddressesAsync(cancellationToken).ConfigureAwait(false); - var addresses = new List
(addressListResponse.Addresses.Count); + if (result is null) + { + var addressListResponse = await client.Api.Addresses.GetAddressesAsync(cancellationToken).ConfigureAwait(false); - var userKeys = await client.GetUserKeysAsync(cancellationToken).ConfigureAwait(false); + var addresses = new List
(addressListResponse.Addresses.Count); - foreach (var dto in addressListResponse.Addresses) - { - try - { - var address = await ConvertFromDtoAsync(client, dto, userKeys, cancellationToken).ConfigureAwait(false); + var userKeys = await client.GetUserKeysAsync(cancellationToken).ConfigureAwait(false); - addresses.Add(address); - } - catch (Exception e) + foreach (var dto in addressListResponse.Addresses) { - client.Logger.LogError(e, "Failed to load address {AddressId}", dto.Id); + try + { + var address = await ConvertFromDtoAsync(client, dto, userKeys, cancellationToken).ConfigureAwait(false); + + addresses.Add(address); + } + catch (Exception e) + { + client.Logger.LogError(e, "Failed to load address {AddressId}", dto.Id); + } } + + await client.EntityCache.SetCurrentUserAddressesAsync(addresses, cancellationToken).ConfigureAwait(false); + + result = addresses; } - return addresses; + return result; } public static async ValueTask
GetAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) { - var userKeys = await client.GetUserKeysAsync(cancellationToken).ConfigureAwait(false); + var address = await client.EntityCache.TryGetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); + + if (address is null) + { + var userKeys = await client.GetUserKeysAsync(cancellationToken).ConfigureAwait(false); - var response = await client.Api.Addresses.GetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Addresses.GetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); - var address = await ConvertFromDtoAsync(client, response.Address, userKeys, cancellationToken).ConfigureAwait(false); + address = await ConvertFromDtoAsync(client, response.Address, userKeys, cancellationToken).ConfigureAwait(false); + + await client.EntityCache.SetAddressAsync(address, cancellationToken).ConfigureAwait(false); + } return address; } public static async ValueTask
GetDefaultAsync(ProtonAccountClient client, CancellationToken cancellationToken) { - var addresses = await GetAllAsync(client, cancellationToken).ConfigureAwait(false); + var addresses = await GetAddressesAsync(client, cancellationToken).ConfigureAwait(false); if (addresses.Count == 0) { throw new ProtonApiException("User has no address"); } - addresses.Sort((a, b) => a.Order.CompareTo(b.Order)); + return addresses.OrderBy(x => x.Order).First(); + } - return addresses[0]; + public static async ValueTask> GetKeysAsync( + ProtonAccountClient client, + AddressId addressId, + CancellationToken cancellationToken) + { + var addressKeys = await client.SecretCache.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false) + ?? await GetAddressKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + return addressKeys; + } + + public static async ValueTask GetPrimaryKeyAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) + { + // TODO: use cache + var address = await GetAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + var addressKeys = await GetKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + return addressKeys[address.PrimaryKeyIndex]; } - public static async ValueTask
ConvertFromDtoAsync( + public static async ValueTask> GetPublicKeysAsync( + ProtonAccountClient client, + string emailAddress, + CancellationToken cancellationToken) + { + if (!client.PublicKeyCache.TryGetPublicKeys(emailAddress, out var cachedPublicKeys)) + { + try + { + var publicKeysResponse = await client.Api.Keys.GetActivePublicKeysAsync(emailAddress, cancellationToken).ConfigureAwait(false); + + var publicKeys = new List(publicKeysResponse.Address.Keys.Count); + + for (var keyIndex = 0; keyIndex < publicKeys.Count; ++keyIndex) + { + var keyEntry = publicKeysResponse.Address.Keys[keyIndex]; + if (!keyEntry.Status.HasFlag(PublicKeyStatus.IsNotCompromised)) + { + continue; + } + + var publicKey = PgpPublicKey.Import(keyEntry.PublicKey); + + publicKeys.Add(publicKey); + } + + client.PublicKeyCache.SetPublicKeys(emailAddress, publicKeys); + + cachedPublicKeys = publicKeys; + } + catch (ProtonApiException e) when (e.Code is ResponseCode.UnknownAddress) + { + client.Logger.LogError(e, "Unknown address {EmailAddress}", emailAddress); + + cachedPublicKeys = []; + } + } + + return cachedPublicKeys; + } + + private static async ValueTask
ConvertFromDtoAsync( ProtonAccountClient client, AddressDto dto, IReadOnlyList userKeys, @@ -90,8 +166,7 @@ public static async ValueTask
ConvertFromDtoAsync( } else { - var passphrase = await client.SessionSecretCache.TryGetPasswordDerivedKeyPassphraseAsync(keyDto.Id, cancellationToken) - .ConfigureAwait(false); + var passphrase = await client.SessionSecretCache.TryGetAccountKeyPassphraseAsync(keyDto.Id.Value, cancellationToken).ConfigureAwait(false); if (passphrase is null) { @@ -133,72 +208,11 @@ public static async ValueTask
ConvertFromDtoAsync( throw new ProtonApiException($"Address {dto.Id} has no primary key"); } - await client.SecretCache.SetAddressKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); + await client.SecretCache.SetAddressKeysAsync(dto.Id, unlockedKeys, cancellationToken).ConfigureAwait(false); return new Address(dto.Id, dto.Order, dto.Email, dto.Status, keys.AsReadOnly(), primaryKeyIndex.Value); } - public static async ValueTask> GetKeysAsync( - ProtonAccountClient client, - AddressId addressId, - CancellationToken cancellationToken) - { - var addressKeys = await client.SecretCache.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false) - ?? await GetAddressKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); - - return addressKeys; - } - - public static async ValueTask GetPrimaryKeyAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) - { - var address = await GetAsync(client, addressId, cancellationToken).ConfigureAwait(false); - - var addressKeys = await GetKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); - - return addressKeys[address.PrimaryKeyIndex]; - } - - public static async ValueTask> GetPublicKeysAsync( - ProtonAccountClient client, - string emailAddress, - CancellationToken cancellationToken) - { - if (!client.PublicKeyCache.TryGetPublicKeys(emailAddress, out var cachedPublicKeys)) - { - try - { - var publicKeysResponse = await client.Api.Keys.GetActivePublicKeysAsync(emailAddress, cancellationToken).ConfigureAwait(false); - - var publicKeys = new List(publicKeysResponse.Address.Keys.Count); - - for (var keyIndex = 0; keyIndex < publicKeys.Count; ++keyIndex) - { - var keyEntry = publicKeysResponse.Address.Keys[keyIndex]; - if (!keyEntry.Status.HasFlag(PublicKeyStatus.IsNotCompromised)) - { - continue; - } - - var publicKey = PgpPublicKey.Import(keyEntry.PublicKey); - - publicKeys.Add(publicKey); - } - - client.PublicKeyCache.SetPublicKeys(emailAddress, publicKeys); - - cachedPublicKeys = publicKeys; - } - catch (ProtonApiException e) when (e.Code is ResponseCode.UnknownAddress) - { - client.Logger.LogError(e, "Unknown address {EmailAddress}", emailAddress); - - cachedPublicKeys = []; - } - } - - return cachedPublicKeys; - } - private static ReadOnlyMemory GetAddressKeyTokenPassphrase( PgpArmoredMessage token, PgpArmoredSignature signature, diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs index 909f1389..857ce53c 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs @@ -7,7 +7,7 @@ namespace Proton.Sdk.Addresses.Api; internal sealed class AddressKeyDto { [JsonPropertyName("ID")] - public required string Id { get; init; } + public required AddressKeyId Id { get; init; } public required int Version { get; init; } diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs index 2509117b..80aba5fb 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs @@ -1,16 +1,76 @@ -using Proton.Sdk.Addresses; +using System.Text.Json; +using Proton.Sdk.Addresses; +using Proton.Sdk.Serialization; namespace Proton.Sdk.Caching; -internal sealed class AccountEntityCache(ICache underlyingCache) +internal sealed class AccountEntityCache(ICacheRepository repository) { - public ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken) + private static readonly string[] CurrentUserAddressTags = ["current-user-address"]; + + private readonly ICacheRepository _repository = repository; + + public async ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken) + { + var value = JsonSerializer.Serialize(address, ProtonEntitySerializerContext.Default.Address); + + await _repository.SetAsync(GetAddressCacheKey(address.Id), value, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask TryGetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(addressId.Value, cancellationToken).ConfigureAwait(false); + + return value is not null ? JsonSerializer.Deserialize(value, ProtonEntitySerializerContext.Default.Address) : null; + } + + public async ValueTask SetCurrentUserAddressesAsync(IEnumerable
addresses, CancellationToken cancellationToken) { - return ValueTask.CompletedTask; + foreach (var address in addresses) + { + var value = JsonSerializer.Serialize(address, ProtonEntitySerializerContext.Default.Address); + + await _repository.SetAsync(GetAddressCacheKey(address.Id), value, CurrentUserAddressTags, cancellationToken).ConfigureAwait(false); + } + + await _repository.MarkTagAsCompleteAsync(CurrentUserAddressTags[0], cancellationToken).ConfigureAwait(false); + } + + public async ValueTask?> TryGetCurrentUserAddressesAsync(CancellationToken cancellationToken) + { + if (!await _repository.GetTagIsCompleteAsync(CurrentUserAddressTags[0], cancellationToken).ConfigureAwait(false)) + { + return null; + } + + var values = _repository.GetByTagsAsync(CurrentUserAddressTags, cancellationToken); + + var addresses = new List
(); + + await foreach (var value in values.ConfigureAwait(false)) + { + try + { + var address = JsonSerializer.Deserialize(value, ProtonEntitySerializerContext.Default.Address); + if (address is null) + { + return null; + } + + addresses.Add(address); + } + catch + { + // There is something wrong with the cache, pretend that it did not have the information, to incite the caller to refresh it + return null; + } + } + + return addresses; } - public ValueTask TryGetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + private static string GetAddressCacheKey(AddressId addressId) { - return ValueTask.FromResult(default(Address)); + return $"address:{addressId}"; } } diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs index 16280c81..214bfa77 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs @@ -1,27 +1,50 @@ -using Proton.Cryptography.Pgp; +using System.Text.Json; +using Proton.Cryptography.Pgp; using Proton.Sdk.Addresses; +using Proton.Sdk.Serialization; namespace Proton.Sdk.Caching; -internal sealed class AccountSecretCache(ICache> underlyingCache) +internal sealed class AccountSecretCache(ICacheRepository repository) { - public ValueTask SetUserKeysAsync(IEnumerable userKeys, CancellationToken cancellationToken) + private const string UserKeysCacheKey = "account:user-keys"; + + private readonly ICacheRepository _repository = repository; + + public async ValueTask SetUserKeysAsync(IEnumerable unlockedKeys, CancellationToken cancellationToken) { - throw new NotImplementedException(); + var serializedValue = JsonSerializer.Serialize(unlockedKeys, ProtonEntitySerializerContext.Default.IEnumerablePgpPrivateKey); + + await _repository.SetAsync(UserKeysCacheKey, serializedValue, cancellationToken).ConfigureAwait(false); } - public ValueTask?> TryGetUserKeysAsync(CancellationToken cancellationToken) + public async ValueTask?> TryGetUserKeysAsync(CancellationToken cancellationToken) { - throw new NotImplementedException(); + var serializedValue = await _repository.TryGetAsync(UserKeysCacheKey, cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, ProtonEntitySerializerContext.Default.PgpPrivateKeyArray) + : null; } - public ValueTask SetAddressKeysAsync(IEnumerable unlockedKeys, CancellationToken cancellationToken) + public async ValueTask SetAddressKeysAsync(AddressId addressId, IEnumerable unlockedKeys, CancellationToken cancellationToken) { - throw new NotImplementedException(); + var serializedValue = JsonSerializer.Serialize(unlockedKeys, ProtonEntitySerializerContext.Default.IEnumerablePgpPrivateKey); + + await _repository.SetAsync(GetAddressKeysCacheKey(addressId), serializedValue, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask?> TryGetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetAddressKeysCacheKey(addressId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, ProtonEntitySerializerContext.Default.PgpPrivateKeyArray) + : null; } - public ValueTask?> TryGetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + private static string GetAddressKeysCacheKey(AddressId addressId) { - throw new NotImplementedException(); + return $"account:address:{addressId}:keys"; } } diff --git a/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs new file mode 100644 index 00000000..57875cdb --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs @@ -0,0 +1,39 @@ +namespace Proton.Sdk.Caching; + +internal static class CacheRepositoryExtensions +{ + private const string CompleteTagCacheKeyFormat = "cache:complete-tag:{0}"; + + public static ValueTask SetAsync(this ICacheRepository repository, string key, string value, CancellationToken cancellationToken) + { + return repository.SetAsync(key, value, [], cancellationToken); + } + + /// + /// Creates a cache entry that serves as a hint that querying by the given tag will return complete information. + /// + /// + /// This marking indicates that the results of a query by the given tag reflect the complete "truth" related to that tag at a point in time. + /// Consequently, if that marking is present and the query by that tag returns an empty set, then that emptiness is the information, rather than a lack of information in cache. + /// + public static async ValueTask MarkTagAsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) + { + var cacheKey = string.Format(CompleteTagCacheKeyFormat, tag); + + await repository.SetAsync(cacheKey, string.Empty, cancellationToken).ConfigureAwait(false); + } + + public static async ValueTask GetTagIsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) + { + var cacheKey = string.Format(CompleteTagCacheKeyFormat, tag); + + return await repository.TryGetAsync(cacheKey, cancellationToken).ConfigureAwait(false) is not null; + } + + public static async ValueTask UnmarkTagAsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) + { + var cacheKey = string.Format(CompleteTagCacheKeyFormat, tag); + + await repository.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/ICache.cs b/cs/sdk/src/Proton.Sdk/Caching/ICache.cs deleted file mode 100644 index 2edac1ff..00000000 --- a/cs/sdk/src/Proton.Sdk/Caching/ICache.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Proton.Sdk.Caching; - -public interface ICache - where TKey : notnull -{ - ValueTask SetAsync(TKey key, TValue value, CancellationToken cancellationToken); - - ValueTask GetOrCreateAsync( - TKey key, - TValue value, - Func> factory, - IEnumerable? tags = null, - CancellationToken cancellationToken = default); - - IAsyncEnumerable QueryAsync(IEnumerable tags, CancellationToken cancellationToken); -} diff --git a/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs new file mode 100644 index 00000000..98ece901 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs @@ -0,0 +1,16 @@ +namespace Proton.Sdk.Caching; + +public interface ICacheRepository : IAsyncDisposable +{ + ValueTask SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken); + + ValueTask RemoveAsync(string key, CancellationToken cancellationToken); + + ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken); + + ValueTask ClearAsync(); + + ValueTask TryGetAsync(string key, CancellationToken cancellationToken); + + IAsyncEnumerable GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/NullCache.cs b/cs/sdk/src/Proton.Sdk/Caching/NullCache.cs deleted file mode 100644 index 98b8baca..00000000 --- a/cs/sdk/src/Proton.Sdk/Caching/NullCache.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Proton.Sdk.Caching; - -internal sealed class NullCache : ICache - where TKey : notnull -{ - public ValueTask SetAsync(TKey key, TValue value, CancellationToken cancellationToken) - { - return ValueTask.CompletedTask; - } - - public ValueTask GetOrCreateAsync( - TKey key, - TValue value, - Func> factory, - IEnumerable? tags = null, - CancellationToken cancellationToken = default) - { - return factory.Invoke(key, cancellationToken); - } - - public IAsyncEnumerable QueryAsync(IEnumerable tags, CancellationToken cancellationToken) - { - return AsyncEnumerable.Empty(); - } -} diff --git a/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs new file mode 100644 index 00000000..335ad565 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs @@ -0,0 +1,41 @@ +namespace Proton.Sdk.Caching; + +internal sealed class NullCacheRepository : ICacheRepository +{ + public static readonly NullCacheRepository Instance = new(); + + public ValueTask SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask RemoveAsync(string key, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask ClearAsync() + { + return ValueTask.CompletedTask; + } + + public ValueTask TryGetAsync(string key, CancellationToken cancellationToken) + { + return ValueTask.FromResult(default(string?)); + } + + public IAsyncEnumerable GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) + { + return AsyncEnumerable.Empty(); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs index 9cb96dad..1c730120 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs @@ -1,14 +1,29 @@ namespace Proton.Sdk.Caching; -internal sealed class SessionSecretCache(ICache> underlyingCache) +internal sealed class SessionSecretCache(ICacheRepository repository) { - public ValueTask SetPasswordDerivedKeyPassphraseAsync(string keyId, ReadOnlyMemory passphrase, CancellationToken cancellationToken) + private readonly ICacheRepository _repository = repository; + + public async ValueTask SetAccountKeyPassphraseAsync(string keyId, ReadOnlyMemory passphrase, CancellationToken cancellationToken) + { + var cacheKey = GetAccountPassphraseCacheKey(keyId); + + var serializedValue = Convert.ToBase64String(passphrase.Span); + + await _repository.SetAsync(cacheKey, serializedValue, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask?> TryGetAccountKeyPassphraseAsync(string keyId, CancellationToken cancellationToken) { - return ValueTask.CompletedTask; + var cacheKey = GetAccountPassphraseCacheKey(keyId); + + var serializedValue = await _repository.TryGetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + + return serializedValue is not null ? Convert.FromBase64String(serializedValue) : null; } - public ValueTask?> TryGetPasswordDerivedKeyPassphraseAsync(string keyId, CancellationToken cancellationToken) + private static string GetAccountPassphraseCacheKey(string keyId) { - return default; + return $"account:passphrase:{keyId}"; } } diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs new file mode 100644 index 00000000..afd2db52 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -0,0 +1,320 @@ +using System.Data; +using Microsoft.Data.Sqlite; + +namespace Proton.Sdk.Caching; + +internal sealed class SqliteCacheRepository : ICacheRepository, IDisposable +{ + private readonly SqliteConnection _connection; + + private SqliteCacheRepository(SqliteConnection connection) + { + _connection = connection; + } + + public static SqliteCacheRepository OpenInMemory() + { + var connectionStringBuilder = new SqliteConnectionStringBuilder + { + DataSource = Guid.NewGuid().ToString(), + Mode = SqliteOpenMode.Memory, + Cache = SqliteCacheMode.Shared, + }; + + return Open(connectionStringBuilder); + } + + public static SqliteCacheRepository OpenFile(string path) + { + var connectionStringBuilder = new SqliteConnectionStringBuilder + { + DataSource = path, + }; + + return Open(connectionStringBuilder); + } + + ValueTask ICacheRepository.SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) + { + try + { + Set(key, value, tags); + + return ValueTask.CompletedTask; + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + ValueTask ICacheRepository.RemoveAsync(string key, CancellationToken cancellationToken) + { + try + { + Remove(key); + + return ValueTask.CompletedTask; + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + ValueTask ICacheRepository.RemoveByTagAsync(string tag, CancellationToken cancellationToken) + { + try + { + RemoveByTag(tag); + + return ValueTask.CompletedTask; + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + public ValueTask ClearAsync() + { + try + { + Clear(); + + return ValueTask.CompletedTask; + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + ValueTask ICacheRepository.TryGetAsync(string key, CancellationToken cancellationToken) + { + try + { + return ValueTask.FromResult(TryGet(key)); + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + IAsyncEnumerable ICacheRepository.GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) + { + return GetByTags(tags).ToAsyncEnumerable(); + } + + ValueTask IAsyncDisposable.DisposeAsync() + { + Dispose(); + + return ValueTask.CompletedTask; + } + + public void Set(string key, string value, IEnumerable tags) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var transaction = connection.BeginTransaction(deferred: true); + + using var command = connection.CreateCommand(); + + command.Transaction = transaction; + command.CommandText = + """ + INSERT INTO Entries (Key, Value) + VALUES (@key, @value) + ON CONFLICT (Key) DO UPDATE SET Value = @value + """; + + command.Parameters.AddWithValue("@key", key); + command.Parameters.AddWithValue("@value", value); + + command.ExecuteNonQuery(); + + command.CommandText = "DELETE FROM Tags WHERE Key = @key"; + + command.ExecuteNonQuery(); + + command.CommandText = "INSERT INTO Tags (Tag, Key) VALUES (@tag, @key)"; + + var tagParameter = command.CreateParameter(); + tagParameter.ParameterName = "@tag"; + command.Parameters.Add(tagParameter); + + foreach (var tag in tags) + { + tagParameter.Value = tag; + + command.ExecuteNonQuery(); + } + + transaction.Commit(); + } + + public void Remove(string key) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + command.CommandText = "DELETE FROM Entries WHERE Key = @key"; + command.Parameters.AddWithValue("@key", key); + + command.ExecuteNonQuery(); + } + + public void RemoveByTag(string tag) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + command.CommandText = "DELETE FROM Entries AS e WHERE EXISTS (SELECT 1 FROM Tags WHERE Tag = @tag AND Key = e.Key)"; + command.Parameters.AddWithValue("@tag", tag); + + command.ExecuteNonQuery(); + } + + public void Clear() + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + command.CommandText = "DELETE FROM Entries"; + + command.ExecuteNonQuery(); + } + + public string? TryGet(string key) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + command.CommandText = "SELECT Value FROM Entries WHERE Key = @key"; + command.Parameters.AddWithValue("@key", key); + + var reader = command.ExecuteReader(); + + return reader.Read() ? reader.GetFieldValue("Value") : null; + } + + public IEnumerable GetByTags(IEnumerable tags) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + + connection.Open(); + + using var command = connection.CreateCommand(); + + command.Connection = connection; + + var i = 0; + foreach (var tag in tags) + { + command.Parameters.AddWithValue($"@tag{i++}", tag); + } + + var inClause = string.Join(", ", command.Parameters.Cast().Select(x => x.ParameterName)); + + command.CommandText = + $""" + SELECT Value + FROM Entries + WHERE Key IN ( + SELECT t.Key + FROM Tags t + WHERE t.Tag IN ({inClause}) + GROUP BY t.Key + HAVING COUNT(DISTINCT t.Tag) = @tagCount + ); + """; + + command.Parameters.AddWithValue("@tagCount", command.Parameters.Count); + + using var reader = command.ExecuteReader(); + + while (reader.Read()) + { + yield return reader.GetString(0); + } + } + + public void Dispose() + { + _connection.Dispose(); + } + + private static SqliteCacheRepository Open(SqliteConnectionStringBuilder connectionStringBuilder) + { + var connectionString = connectionStringBuilder.ConnectionString; + + var connection = new SqliteConnection(connectionString); + + try + { + connection.Open(); + + InitializeDatabase(connection); + + return new SqliteCacheRepository(connection); + } + catch + { + connection.Dispose(); + throw; + } + } + + private static void InitializeDatabase(SqliteConnection connection) + { + using var command = connection.CreateCommand(); + + command.CommandText = "PRAGMA journal_mode = 'wal'"; + + command.ExecuteNonQuery(); + + command.CommandText = + """ + CREATE TABLE IF NOT EXISTS Entries ( + Key TEXT NOT NULL, + Value TEXT NOT NULL, + PRIMARY KEY (Key) + ) + """; + + command.ExecuteNonQuery(); + + command.CommandText = + """ + CREATE TABLE IF NOT EXISTS Tags ( + Tag TEXT NOT NULL, + Key TEXT NOT NULL, + PRIMARY KEY (Tag, Key), + FOREIGN KEY (Key) REFERENCES Entries(Key) ON DELETE CASCADE + ) + """; + + command.ExecuteNonQuery(); + + command.CommandText = + """ + CREATE TABLE IF NOT EXISTS QueryableTags ( + Tag TEXT NOT NULL, + PRIMARY KEY (Tag) + ) + """; + + command.ExecuteNonQuery(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index ea44ccf3..d457d360 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -9,6 +9,7 @@ + diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs index 4d9c14ff..d29ee091 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -17,8 +17,8 @@ internal ProtonAccountClient(AccountApiClients apiClients, ProtonClientConfigura Api = apiClients; Logger = configuration.LoggerFactory.CreateLogger(); SessionSecretCache = sessionSecretCache; - EntityCache = new AccountEntityCache(configuration.EntityCache); - SecretCache = new AccountSecretCache(configuration.SecretCache); + EntityCache = new AccountEntityCache(configuration.EntityCacheRepository); + SecretCache = new AccountSecretCache(configuration.SecretCacheRepository); } internal AccountApiClients Api { get; } @@ -35,9 +35,9 @@ public ValueTask
GetAddressAsync(AddressId addressId, CancellationToken return AddressOperations.GetAsync(this, addressId, cancellationToken); } - public ValueTask> GetAddressesAsync(CancellationToken cancellationToken) + public ValueTask> GetAddressesAsync(CancellationToken cancellationToken) { - return AddressOperations.GetAllAsync(this, cancellationToken); + return AddressOperations.GetAddressesAsync(this, cancellationToken); } public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) @@ -45,20 +45,44 @@ public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationT return AddressOperations.GetDefaultAsync(this, cancellationToken); } - internal async Task> GetUserKeysAsync(CancellationToken cancellationToken) + internal async ValueTask> GetUserKeysAsync(CancellationToken cancellationToken) { var userKeys = await SecretCache.TryGetUserKeysAsync(cancellationToken).ConfigureAwait(false); if (userKeys is null) { - await RefreshUserKeysAsync(cancellationToken).ConfigureAwait(false); - } + var response = await Api.Users.GetAuthenticatedUserAsync(cancellationToken).ConfigureAwait(false); - userKeys = await SecretCache.TryGetUserKeysAsync(cancellationToken).ConfigureAwait(false); + var unlockedKeys = new List(response.User.Keys.Count); - if (userKeys is null) - { - throw new ProtonApiException("No active user key was found."); + foreach (var userKey in response.User.Keys) + { + if (!userKey.IsActive) + { + continue; + } + + var passphrase = await SessionSecretCache.TryGetAccountKeyPassphraseAsync(userKey.Id.Value, cancellationToken).ConfigureAwait(false); + + if (passphrase is null) + { + Logger.LogWarning("No passphrase found for user key {UserKeyId}", userKey.Id); + continue; + } + + var unlockedUserKey = PgpPrivateKey.ImportAndUnlock(userKey.PrivateKey.Bytes.Span, passphrase.Value.Span); + + unlockedKeys.Add(unlockedUserKey); + } + + if (unlockedKeys.Count == 0) + { + throw new ProtonApiException("No active user key was found."); + } + + await SecretCache.SetUserKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); + + userKeys = unlockedKeys; } return userKeys; @@ -78,38 +102,4 @@ internal ValueTask> GetAddressPublicKeysAsync(string { return AddressOperations.GetPublicKeysAsync(this, emailAddress, cancellationToken); } - - private async ValueTask RefreshUserKeysAsync(CancellationToken cancellationToken) - { - var response = await Api.Users.GetAuthenticatedUserAsync(cancellationToken).ConfigureAwait(false); - - var unlockedKeys = new List(response.User.Keys.Count); - - foreach (var userKey in response.User.Keys) - { - if (!userKey.IsActive) - { - continue; - } - - var passphrase = await SessionSecretCache.TryGetPasswordDerivedKeyPassphraseAsync(userKey.Id.Value, cancellationToken).ConfigureAwait(false); - - if (passphrase is null) - { - Logger.LogWarning("No passphrase found for user key {UserKeyId}", userKey.Id); - continue; - } - - var unlockedUserKey = PgpPrivateKey.ImportAndUnlock(userKey.PrivateKey.Bytes.Span, passphrase.Value.Span); - - unlockedKeys.Add(unlockedUserKey); - } - - if (unlockedKeys.Count == 0) - { - throw new ProtonApiException("No active user key was found."); - } - - await SecretCache.SetUserKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); - } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs b/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs index b46219c4..4c3fde8c 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs @@ -1,41 +1,8 @@ -using System.Text.Json; -using Proton.Cryptography.Pgp; -using Proton.Sdk.Addresses; -using Proton.Sdk.Addresses.Api; -using Proton.Sdk.Authentication; -using Proton.Sdk.Cryptography; -using Proton.Sdk.Events; -using Proton.Sdk.Serialization; -using Proton.Sdk.Users; - -namespace Proton.Sdk; +namespace Proton.Sdk; internal static class ProtonApiDefaults { public static Uri BaseUrl { get; } = new("https://drive-api.proton.me/"); public static Uri RefreshRedirectUri { get; } = new("https://proton.me"); - - public static JsonSerializerOptions GetSerializerOptions() - { - return new JsonSerializerOptions - { - Converters = - { - new PgpArmoredBlockJsonConverter(PgpBlockType.Message, bytes => new PgpArmoredMessage(bytes)), - new PgpArmoredBlockJsonConverter(PgpBlockType.Signature, bytes => new PgpArmoredSignature(bytes)), - new PgpArmoredBlockJsonConverter(PgpBlockType.PublicKey, bytes => new PgpArmoredPublicKey(bytes)), - new PgpArmoredBlockJsonConverter(PgpBlockType.PrivateKey, bytes => new PgpArmoredPrivateKey(bytes)), - new StrongIdConverter(), - new StrongIdConverter(), - new StrongIdConverter(), - new StrongIdConverter(), - new StrongIdConverter(), - new StrongIdConverter(), - }, -#if DEBUG - WriteIndented = true, -#endif - }; - } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index fbd83b02..33f2052e 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -38,7 +38,7 @@ internal ProtonApiSession( IsWaitingForSecondFactorCode = isWaitingForSecondFactorCode; PasswordMode = passwordMode; ClientConfiguration = clientConfiguration; - SecretCache = new SessionSecretCache(clientConfiguration.SecretCache); + SecretCache = new SessionSecretCache(clientConfiguration.SecretCacheRepository); } public event Action? Ended @@ -79,16 +79,16 @@ private IAuthenticationApiClient AuthenticationApi private IKeysApiClient KeysApi => _keysApi ??= new KeysApiClient(_httpClient); - public static Task BeginAsync(string username, ReadOnlyMemory password, string appVersion, CancellationToken cancellationToken) + public static ValueTask BeginAsync(string username, ReadOnlyMemory password, string appVersion, CancellationToken cancellationToken) { - return BeginAsync(username, password, appVersion, new ProtonClientOptions(), cancellationToken); + return BeginAsync(username, password, appVersion, new ProtonSessionOptions(), cancellationToken); } - public static async Task BeginAsync( + public static async ValueTask BeginAsync( string username, ReadOnlyMemory password, string appVersion, - ProtonClientOptions options, + ProtonSessionOptions options, CancellationToken cancellationToken) { var configuration = new ProtonClientConfiguration(appVersion, options); @@ -143,7 +143,32 @@ public static async Task BeginAsync( } public static ProtonApiSession Resume( + SessionId sessionId, + string username, + UserId userId, + string accessToken, + string refreshToken, + IEnumerable scopes, + bool isWaitingForSecondFactorCode, + PasswordMode passwordMode, string appVersion, + ICacheRepository secretCacheRepository) + { + return Resume( + sessionId, + username, + userId, + accessToken, + refreshToken, + scopes, + isWaitingForSecondFactorCode, + passwordMode, + appVersion, + secretCacheRepository, + new ProtonClientOptions()); + } + + public static ProtonApiSession Resume( SessionId sessionId, string username, UserId userId, @@ -152,8 +177,12 @@ public static ProtonApiSession Resume( IEnumerable scopes, bool isWaitingForSecondFactorCode, PasswordMode passwordMode, - ProtonClientOptions? options = null) + string appVersion, + ICacheRepository secretCacheRepository, + ProtonClientOptions options) { + options = options with { SecretCacheRepository = secretCacheRepository }; + var configuration = new ProtonClientConfiguration(appVersion, options); var logger = configuration.LoggerFactory.CreateLogger(); @@ -210,7 +239,7 @@ public async Task ApplyDataPasswordAsync(ReadOnlyMemory password, Cancella var passphrase = DeriveSecretFromPassword(password.Span, keySalt.Value.Span); - await SecretCache.SetPasswordDerivedKeyPassphraseAsync(keySalt.KeyId, passphrase, cancellationToken).ConfigureAwait(false); + await SecretCache.SetAccountKeyPassphraseAsync(keySalt.KeyId, passphrase, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs index 5b68c755..0bdb4864 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs @@ -4,7 +4,7 @@ namespace Proton.Sdk; -internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientOptions? options) +internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientOptions? options = null) { public Uri BaseUrl { get; } = options?.BaseUrl ?? ProtonApiDefaults.BaseUrl; public string AppVersion { get; } = appVersion; @@ -12,8 +12,8 @@ internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientO public bool DisableTlsCertificatePinning { get; } = options?.DisableTlsCertificatePinning ?? false; public bool IgnoreSslCertificateErrors { get; } = options?.IgnoreSslCertificateErrors ?? false; public Func? CustomHttpMessageHandlerFactory { get; } = options?.CustomHttpMessageHandlerFactory; - public ICache> SecretCache { get; } = options?.SecretCache ?? new NullCache>(); - public ICache EntityCache { get; } = options?.EntityCache ?? new NullCache(); + public ICacheRepository SecretCacheRepository { get; } = options?.SecretCacheRepository ?? SqliteCacheRepository.OpenInMemory(); + public ICacheRepository EntityCacheRepository { get; } = options?.EntityCacheRepository ?? SqliteCacheRepository.OpenInMemory(); public ILoggerFactory LoggerFactory { get; } = options?.LoggerFactory ?? NullLoggerFactory.Instance; public Uri RefreshRedirectUri { get; } = options?.RefreshRedirectUri ?? ProtonApiDefaults.RefreshRedirectUri; public string? BindingsLanguage { get; } = options?.BindingsLanguage; diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs index 6630c2c8..82d4070f 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs @@ -3,16 +3,16 @@ namespace Proton.Sdk; -public sealed class ProtonClientOptions() +public record ProtonClientOptions { public Uri? BaseUrl { get; set; } public string? UserAgent { get; set; } public bool? DisableTlsCertificatePinning { get; set; } public bool? IgnoreSslCertificateErrors { get; set; } public Func? CustomHttpMessageHandlerFactory { get; set; } - public ICache>? SecretCache { get; set; } - public ICache? EntityCache { get; set; } + public ICacheRepository? EntityCacheRepository { get; set; } public ILoggerFactory? LoggerFactory { get; set; } + internal ICacheRepository? SecretCacheRepository { get; set; } internal Uri? RefreshRedirectUri { get; set; } internal string? BindingsLanguage { get; set; } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonSessionOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonSessionOptions.cs new file mode 100644 index 00000000..7de91ac6 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonSessionOptions.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Caching; + +namespace Proton.Sdk; + +public sealed record ProtonSessionOptions : ProtonClientOptions +{ + public new ICacheRepository? SecretCacheRepository + { + get => base.SecretCacheRepository; + set => base.SecretCacheRepository = value; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs similarity index 79% rename from cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs rename to cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs index dea88ed6..9c2be343 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs @@ -6,12 +6,9 @@ namespace Proton.Sdk.Serialization; -internal sealed class PgpArmoredBlockJsonConverter(PgpBlockType blockType, Func, T> factory) : JsonConverter +internal abstract class PgpArmoredBlockJsonConverterBase : JsonConverter where T : IPgpArmoredBlock { - private readonly PgpBlockType _blockType = blockType; - private readonly Func, T> _factory = factory; - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.String) @@ -27,7 +24,7 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial var decodedBlock = PgpArmorDecoder.Decode(buffer.AsSpan()[..numberOfBytesCopied]); - return _factory.Invoke(decodedBlock); + return CreateValue(decodedBlock); } finally { @@ -41,7 +38,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions try { - var numberOfBytesWritten = PgpArmorEncoder.Encode(value.Bytes.Span, _blockType, buffer); + var numberOfBytesWritten = PgpArmorEncoder.Encode(value.Bytes.Span, BlockType, buffer); writer.WriteStringValue(buffer.AsSpan()[..numberOfBytesWritten]); } @@ -50,4 +47,8 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions ArrayPool.Shared.Return(buffer); } } + + protected abstract PgpBlockType BlockType { get; } + + protected abstract T CreateValue(ReadOnlyMemory bytes); } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredMessageJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredMessageJsonConverter.cs new file mode 100644 index 00000000..b7ed872d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredMessageJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredMessageJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.Message; + + protected override PgpArmoredMessage CreateValue(ReadOnlyMemory bytes) => new(bytes); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs new file mode 100644 index 00000000..69e8c5d7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredPrivateKeyJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.PrivateKey; + + protected override PgpArmoredPrivateKey CreateValue(ReadOnlyMemory bytes) => new(bytes); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs new file mode 100644 index 00000000..fce2f0cb --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredPublicKeyJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.PublicKey; + + protected override PgpArmoredPublicKey CreateValue(ReadOnlyMemory bytes) => new(bytes); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs new file mode 100644 index 00000000..29783749 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredSignatureJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.Signature; + + protected override PgpArmoredSignature CreateValue(ReadOnlyMemory bytes) => new(bytes); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpPrivateKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpPrivateKeyJsonConverter.cs new file mode 100644 index 00000000..2da2d7ad --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpPrivateKeyJsonConverter.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpPrivateKeyJsonConverter : JsonConverter +{ + public override PgpPrivateKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var bytes = reader.GetBytesFromBase64(); + + return PgpPrivateKey.Import(bytes); + } + + public override void Write(Utf8JsonWriter writer, PgpPrivateKey value, JsonSerializerOptions options) + { + writer.WriteBase64StringValue(value.ToBytes()); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs index db904fad..f8a55145 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs @@ -1,13 +1,36 @@ using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; using Proton.Sdk.Addresses.Api; using Proton.Sdk.Api; +using Proton.Sdk.Authentication; using Proton.Sdk.Authentication.Api; +using Proton.Sdk.Events; using Proton.Sdk.Events.Api; using Proton.Sdk.Keys.Api; +using Proton.Sdk.Users; using Proton.Sdk.Users.Api; namespace Proton.Sdk.Serialization; +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to pre-processor directive +[JsonSourceGenerationOptions( +#if DEBUG + WriteIndented = true, +#endif + Converters = + [ + typeof(PgpArmoredMessageJsonConverter), + typeof(PgpArmoredSignatureJsonConverter), + typeof(PgpArmoredPrivateKeyJsonConverter), + typeof(PgpArmoredPublicKeyJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(SessionInitiationRequest))] [JsonSerializable(typeof(SessionInitiationResponse))] @@ -25,10 +48,4 @@ namespace Proton.Sdk.Serialization; [JsonSerializable(typeof(KeySaltListResponse))] [JsonSerializable(typeof(LatestEventResponse))] [JsonSerializable(typeof(EventListResponse))] -internal partial class ProtonApiSerializerContext : JsonSerializerContext -{ - static ProtonApiSerializerContext() - { - Default = new ProtonApiSerializerContext(ProtonApiDefaults.GetSerializerOptions()); - } -} +internal sealed partial class ProtonApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ProtonEntitySerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/ProtonEntitySerializerContext.cs new file mode 100644 index 00000000..dea96c55 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/ProtonEntitySerializerContext.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; +using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Authentication; +using Proton.Sdk.Events; +using Proton.Sdk.Users; + +namespace Proton.Sdk.Serialization; + +[JsonSourceGenerationOptions( + Converters = + [ + typeof(PgpPrivateKeyJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + ])] +[JsonSerializable(typeof(Address))] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(PgpPrivateKey[]))] +internal sealed partial class ProtonEntitySerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/StrongIdConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs similarity index 87% rename from cs/sdk/src/Proton.Sdk/Serialization/StrongIdConverter.cs rename to cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs index 02461102..db3ac883 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/StrongIdConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs @@ -3,7 +3,7 @@ namespace Proton.Sdk.Serialization; -internal sealed class StrongIdConverter : JsonConverter +internal sealed class StrongIdJsonConverter : JsonConverter where T : struct, IStrongId { public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) From 2a2d5cf6e854a31e6f0ac939a017ea8ca542af05 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 4 Mar 2025 14:45:18 +0000 Subject: [PATCH 028/791] Fix updating tags in cache --- js/sdk/src/cache/memoryCache.test.ts | 18 ++++++++++++++++++ js/sdk/src/cache/memoryCache.ts | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/js/sdk/src/cache/memoryCache.test.ts b/js/sdk/src/cache/memoryCache.test.ts index 624f623c..73f716d0 100644 --- a/js/sdk/src/cache/memoryCache.test.ts +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -1,3 +1,4 @@ +import { EntityResult } from "./interface"; import { MemoryCache } from "./memoryCache"; describe('MemoryCache', () => { @@ -21,6 +22,23 @@ describe('MemoryCache', () => { expect(result).toBe(data); }); + it('should update an entity with tags - remove old and add new tags', async () => { + const uid = 'newuid'; + + await cache.setEntity(uid, 'data1', ['tag1', 'tag2']); + await cache.setEntity(uid, 'data2', ['tag2', 'tag3']); + + const result = await cache.getEntity(uid); + expect(result).toBe('data2'); + + const tag1 = await Array.fromAsync(cache.iterateEntitiesByTag('tag1')); + expect(tag1).toEqual([]); + const tag2 = await Array.fromAsync(cache.iterateEntitiesByTag('tag2')); + expect(tag2).toEqual([{ uid, ok: true, data: 'data2' }]); + const tag3 = await Array.fromAsync(cache.iterateEntitiesByTag('tag3')); + expect(tag3).toEqual([{ uid, ok: true, data: 'data2' }]); + }); + it('should throw an error when retrieving a non-existing entity', async () => { const uid = 'newuid'; diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index 77e1bed6..5ea17b4a 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -20,6 +20,14 @@ export class MemoryCache implements ProtonDriveCache { async setEntity(uid: string, data: T, tags?: string[]) { this.entities[uid] = data; + + for (const tag of Object.keys(this.entitiesByTag)) { + const index = this.entitiesByTag[tag].indexOf(uid); + if (index !== -1) { + this.entitiesByTag[tag].splice(index, 1); + } + } + if (tags) { for (const tag of tags) { if (!this.entitiesByTag[tag]) { From c2ec0ecbc9b575c3c4762c3d9525aa572a31249a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 5 Mar 2025 06:23:10 +0000 Subject: [PATCH 029/791] Implement sharing module --- js/sdk/.eslintrc.js | 1 - js/sdk/src/crypto/driveCrypto.ts | 131 ++++- js/sdk/src/crypto/interface.ts | 14 +- js/sdk/src/crypto/openPGPCrypto.ts | 42 +- js/sdk/src/interface/constructor.ts | 18 +- js/sdk/src/interface/index.ts | 6 +- js/sdk/src/interface/sharing.ts | 59 ++- js/sdk/src/internal/apiService/index.ts | 1 + .../src/internal/apiService/transformers.ts | 43 ++ js/sdk/src/internal/nodes/apiService.test.ts | 3 +- js/sdk/src/internal/nodes/apiService.ts | 27 +- js/sdk/src/internal/nodes/cache.ts | 3 +- .../src/internal/nodes/cryptoService.test.ts | 2 +- js/sdk/src/internal/nodes/cryptoService.ts | 18 +- js/sdk/src/internal/nodes/interface.ts | 6 +- js/sdk/src/internal/nodes/nodesAccess.ts | 17 + js/sdk/src/internal/nodes/nodesManagement.ts | 2 +- js/sdk/src/internal/shares/apiService.ts | 4 + js/sdk/src/internal/shares/cache.test.ts | 1 + js/sdk/src/internal/shares/cache.ts | 3 +- js/sdk/src/internal/shares/cryptoService.ts | 4 +- js/sdk/src/internal/shares/index.ts | 2 + js/sdk/src/internal/shares/interface.ts | 12 +- js/sdk/src/internal/shares/manager.test.ts | 21 +- js/sdk/src/internal/shares/manager.ts | 32 +- js/sdk/src/internal/sharing/apiService.ts | 285 ++++++++++- js/sdk/src/internal/sharing/cryptoService.ts | 246 ++++++++- js/sdk/src/internal/sharing/index.ts | 70 +-- js/sdk/src/internal/sharing/interface.ts | 91 +++- .../internal/sharing/sharingAccess.test.ts | 10 +- js/sdk/src/internal/sharing/sharingAccess.ts | 48 +- .../sharing/sharingManagement.test.ts | 471 ++++++++++++++++++ .../src/internal/sharing/sharingManagement.ts | 413 ++++++++++++++- js/sdk/src/internal/uids.ts | 28 +- js/sdk/src/protonDriveClient.ts | 28 +- 35 files changed, 1930 insertions(+), 232 deletions(-) create mode 100644 js/sdk/src/internal/apiService/transformers.ts create mode 100644 js/sdk/src/internal/sharing/sharingManagement.test.ts diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index f5429a09..07c237fc 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -16,7 +16,6 @@ module.exports = { { files: [ "*.test.ts", - "**/sharing/**/*", "**/upload/**/*", "**/photos/**/*", ], diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 409d18c2..57240ba1 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,4 +1,11 @@ -import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface.js'; +import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; +import { uint8ArrayToBase64String } from './utils'; + +enum SIGNING_CONTEXTS { + SHARING_INVITER = 'drive.share-member.inviter', + SHARING_INVITER_EXTERNAL_INVITATION = 'drive.share-member.external-invitation', + SHARING_MEMBER = 'drive.share-member.member', +} /** * Drive crypto layer to provide general operations for Drive crypto. @@ -124,10 +131,7 @@ export class DriveCrypto { sessionKey: SessionKey, verified: VERIFICATION_STATUS, }> { - const sessionKey = await this.openPGPCrypto.decryptSessionKey( - armoredPassphrase, - decryptionKeys, - ); + const sessionKey = await this.decryptSessionKey(armoredPassphrase, decryptionKeys); const { data: decryptedPassphrase, verified } = await this.openPGPCrypto.decryptArmoredAndVerifyDetached( armoredPassphrase, @@ -150,6 +154,62 @@ export class DriveCrypto { }; } + /** + * It encrypts session key with provided encryption key. + */ + async encryptSessionKey( + sessionKey: SessionKey, + encryptionKey: PublicKey, + ): Promise<{ + base64KeyPacket: string, + }> { + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(sessionKey, encryptionKey); + return { + base64KeyPacket: uint8ArrayToBase64String(keyPacket), + } + } + + /** + * It decrypts session key from armored data. + * + * `decryptionKeys` are used to decrypt the session key from the `armoredData`. + */ + async decryptSessionKey( + armoredData: string, + decryptionKeys: PrivateKey[], + ): Promise { + const sessionKey = await this.openPGPCrypto.decryptSessionKey( + armoredData, + decryptionKeys, + ); + return sessionKey; + } + + /** + * It decrypts key similarly like `decryptKey`, but without signature + * verification. This is used for invitations. + */ + async decryptUnsignedKey( + armoredKey: string, + armoredPassphrase: string, + decryptionKeys: PrivateKey[], + ): Promise { + const { data: decryptedPassphrase } = await this.openPGPCrypto.decryptArmoredAndVerify( + armoredPassphrase, + decryptionKeys, + [], + ); + + const passphrase = new TextDecoder().decode(decryptedPassphrase); + + const key = await this.openPGPCrypto.decryptKey( + armoredKey, + passphrase, + ); + + return key; + } + /** * It encrypts and armors signature with provided session and encryption keys. */ @@ -293,4 +353,65 @@ export class DriveCrypto { verified, }; } + + async encryptInvitation( + shareSessionKey: SessionKey, + encryptionKey: PublicKey, + signingKey: PrivateKey, + ): Promise<{ + base64KeyPacket: string, + base64KeyPacketSignature: string, + }> { + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(shareSessionKey, encryptionKey); + const { signature: keyPacketSignature } = await this.openPGPCrypto.sign( + keyPacket, + signingKey, + SIGNING_CONTEXTS.SHARING_INVITER, + ) + return { + base64KeyPacket: uint8ArrayToBase64String(keyPacket), + base64KeyPacketSignature: uint8ArrayToBase64String(keyPacketSignature), + } + } + + async acceptInvitation( + base64KeyPacket: string, + signingKey: PrivateKey, + ): Promise<{ + base64SessionKeySignature: string, + }> { + const sessionKey = await this.decryptSessionKey( + base64KeyPacket, + signingKey, + ); + + const { signature } = await this.openPGPCrypto.sign( + sessionKey.data, + signingKey, + SIGNING_CONTEXTS.SHARING_MEMBER, + ); + + return { + base64SessionKeySignature: uint8ArrayToBase64String(signature), + } + } + + async encryptExternalInvitation( + shareSessionKey: SessionKey, + signingKey: PrivateKey, + inviteeEmail: string, + ): Promise<{ + base64ExternalInvitationSignature: string, + }> { + const data = inviteeEmail.concat('|').concat(uint8ArrayToBase64String(shareSessionKey.data)); + + const { signature: externalInviationSignature } = await this.openPGPCrypto.sign( + new TextEncoder().encode(data), + signingKey, + SIGNING_CONTEXTS.SHARING_INVITER_EXTERNAL_INVITATION, + ) + return { + base64ExternalInvitationSignature: uint8ArrayToBase64String(externalInviationSignature), + } + } } diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 356151b9..7ffb9317 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -33,6 +33,10 @@ export interface OpenPGPCrypto { generateSessionKey: (encryptionKeys: PrivateKey[]) => Promise, + encryptSessionKey: (sessionKey: SessionKey, encryptionKeys: PublicKey[]) => Promise<{ + keyPacket: Uint8Array, + }>, + /** * Generate a new key pair locked by a passphrase. * @@ -88,8 +92,16 @@ export interface OpenPGPCrypto { armoredSignature: string, }>, + sign: ( + data: Uint8Array, + signingKey: PrivateKey, + signatureContext: string, + ) => Promise<{ + signature: Uint8Array, + }>, + decryptSessionKey: ( - armoredPassphrase: string, + armoredData: string, decryptionKeys: PrivateKey[], ) => Promise, diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 1594a3f8..ffba321c 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -10,6 +10,7 @@ interface OpenPGPCryptoProxy { exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string }) => Promise, importPrivateKey: (options: { armoredKey: string, passphrase: string }) => Promise, generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, + encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys: PublicKey[] }) => Promise, decryptSessionKey: (options: { armoredMessage: string, decryptionKeys: PrivateKey[] }) => Promise, encryptMessage: (options: { format?: 'armored' | 'binary', @@ -30,11 +31,18 @@ interface OpenPGPCryptoProxy { binarySignature?: Uint8Array, sessionKeys?: SessionKey, decryptionKeys?: PrivateKey[], - verificationKeys: PublicKey[] + verificationKeys: PublicKey[], }) => Promise<{ data: Uint8Array | string, verified: VERIFICATION_STATUS }>, + signMessage: (options: { + format: 'binary', + binaryData: Uint8Array, + signingKeys: PrivateKey[], + detached: boolean, + context: { critical: boolean, value: string }, + }) => Promise, } /** @@ -56,6 +64,17 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return this.cryptoProxy.generateSessionKey({ recipientKeys: encryptionKeys }); } + async encryptSessionKey(sessionKey: SessionKey, encryptionKeys: PublicKey[]) { + const keyPacket = await this.cryptoProxy.encryptSessionKey({ + ...sessionKey, + format: 'binary', + encryptionKeys, + }); + return { + keyPacket + }; + } + async generateKey(passphrase: string) { const privateKey = await this.cryptoProxy.generateKey({ userIDs: [{ name: 'Drive key' }], @@ -164,12 +183,29 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } } + async sign( + data: Uint8Array, + signingKeys: PrivateKey[], + signatureContext: string, + ) { + const signature = await this.cryptoProxy.signMessage({ + binaryData: data, + signingKeys, + detached: true, + format: 'binary', + context: { critical: true, value: signatureContext }, + }); + return { + signature + }; + } + async decryptSessionKey( - armoredPassphrase: string, + armoredData: string, decryptionKeys: PrivateKey[], ) { const sessionKey = await this.cryptoProxy.decryptSessionKey({ - armoredMessage: armoredPassphrase, + armoredMessage: armoredData, decryptionKeys, }); diff --git a/js/sdk/src/interface/constructor.ts b/js/sdk/src/interface/constructor.ts index 79e61b77..4841060b 100644 --- a/js/sdk/src/interface/constructor.ts +++ b/js/sdk/src/interface/constructor.ts @@ -2,13 +2,25 @@ import { PrivateKey, PublicKey } from '../crypto'; export interface ProtonDriveAccount { - getOwnPrimaryKey(): Promise<{ email: string, addressKey: PrivateKey, addressId: string, addressKeyId: string }>, + getOwnPrimaryAddress(): Promise, // TODO: do we want to break it down to email vs address ID methods? - getOwnPrivateKey(emailOrAddressId: string): Promise, - getOwnPrivateKeys(emailOrAddressId: string): Promise, + getOwnAddress(emailOrAddressId: string): Promise, getPublicKeys(email: string): Promise, } +export interface ProtonDriveAccountAddress { + email: string, + addressId: string, + primaryKey: { + id: string, + key: PrivateKey, + }, + keys: { + id: string, + key: PrivateKey, + }[], +} + export interface ProtonDriveHTTPClient { fetch(request: Request, signal?: AbortSignal): Promise, } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 4fe79167..f054cdbc 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -10,14 +10,14 @@ import { Upload } from './upload'; export type { Result } from './result'; export { resultOk, resultError } from './result'; -export type { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig, GetLogger, Logger, Metrics, MetricsShareType, MetricsUploadErrorType, MetricsDownloadErrorType } from './constructor'; +export type { ProtonDriveAccount, ProtonDriveAccountAddress, ProtonDriveHTTPClient, ProtonDriveConfig, GetLogger, Logger, Metrics, MetricsShareType, MetricsUploadErrorType, MetricsDownloadErrorType } from './constructor'; export type { Device, DeviceOrUid } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; export type { Author, NodeEntity, InvalidNameError, UnverifiedAuthorError, AnonymousUser, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole } from './nodes'; -export type { ProtonInvitation, NonProtonInvitation, NonProtonInvitationState, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, ShareResult } from './sharing'; -export { ShareRole } from './sharing'; +export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; +export { NonProtonInvitationState } from './sharing'; export type { Fileuploader, UploadController, Thumbnail, ThumbnailType, UploadMetadata } from './upload'; export type ProtonDriveEntitiesCache = ProtonDriveCache; diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 5801d7db..47430cec 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -1,22 +1,25 @@ import { Result } from './result.js'; -import { NodeEntity, NodeOrUid, MemberRole, InvalidNameError, UnverifiedAuthorError } from './nodes.js'; +import { NodeEntity, NodeOrUid, NodeType, MemberRole, InvalidNameError, UnverifiedAuthorError } from './nodes.js'; -export type ProtonInvitation = { +export type Member = { uid: string, - nodeName: Result, invitedDate: Date, addedByEmail: Result, inviteeEmail: string, role: MemberRole, } -export type NonProtonInvitation = { - uid: string, - nodeName: Result, - invitedDate: Date, - addedByEmail: Result, - inviteeEmail: string, - role: MemberRole, +export type ProtonInvitation = Member; + +export type ProtonInvitationWithNode = ProtonInvitation & { + node: { + name: Result, + type: NodeType, + mimeType?: string, + }, +} + +export type NonProtonInvitation = ProtonInvitation & { state: NonProtonInvitationState, } @@ -25,14 +28,6 @@ export enum NonProtonInvitationState { UserRegistered = "userRegistered", } -export type Member = { - uid: string, - invitedDate: Date, - addedByEmail: Result, - inviteeEmail: string, - role: MemberRole, -} - export type PublicLink = { uid: string, createDate: string, @@ -55,7 +50,7 @@ export type NonProtonInvitationOrUid = NonProtonInvitation | string; export type BookmarkOrUid = Bookmark | string; export interface Sharing { - iterateInvitations(signal?: AbortSignal): Promise, + iterateInvitations(signal?: AbortSignal): AsyncGenerator, acceptInvitation(invitationUid: ProtonInvitationOrUid): Promise, rejectInvitation(invitationUid: ProtonInvitationOrUid): Promise, @@ -68,36 +63,38 @@ export interface Sharing { } export interface SharingManagement { + getSharingInfo(nodeUid: NodeOrUid): Promise, shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings): Promise, - unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings): Promise, + unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings): Promise, resendInvitation(invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid): Promise, } export type ShareNodeSettings = { protonUsers?: ShareMembersSettings, nonProtonUsers?: ShareMembersSettings, - publicLink?: boolean | { - role: ShareRole, - customPassword?: string | null | undefined, - expiration?: Date | null | undefined, - } + publicLink?: SharePublicLinkSettings, + emailOptions?: { + message?: string, + includeNodeName?: boolean, + }, } export type ShareMembersSettings = string[] | { email: string, - role: ShareRole, + role: MemberRole, }[]; -export enum ShareRole { - VIEW = 'view', - EDIT = 'edit', +export type SharePublicLinkSettings = boolean |{ + role: MemberRole, + customPassword?: string | null | undefined, + expiration?: Date | null | undefined, }; export type ShareResult = { - protonInitations: ProtonInvitation[], + protonInvitations: ProtonInvitation[], nonProtonInvitations: NonProtonInvitation[], members: Member[], - publicLink?: PublicLink + publicLink?: PublicLink, } export type UnshareNodeSettings = { diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 5263e925..7d8630cc 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -2,4 +2,5 @@ export { DriveAPIService } from './apiService'; export { paths as drivePaths } from './driveTypes'; export { paths as corePaths } from './coreTypes'; export { ErrorCode } from './errorCodes'; +export { nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from './transformers'; export * from './errors'; diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts new file mode 100644 index 00000000..1f5347bf --- /dev/null +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -0,0 +1,43 @@ +import { Logger, NodeType, MemberRole } from "../../interface"; + +export function nodeTypeNumberToNodeType(nodeTypeNumber: number, logger?: Logger): NodeType { + switch (nodeTypeNumber) { + case 1: + return NodeType.Folder; + case 2: + return NodeType.File; + default: + logger?.warn(`Unknown node type: ${nodeTypeNumber}`); + return NodeType.File; + } +} + +export function permissionsToDirectMemberRole(permissionsNumber?: number, logger?: Logger): MemberRole { + switch (permissionsNumber) { + case undefined: + case 4: + return MemberRole.Viewer; + case 6: + return MemberRole.Editor; + case 22: + return MemberRole.Admin; + default: + // User have access to the data, thus at minimum it can view. + logger?.warn(`Unknown sharing permissions: ${permissionsNumber}`); + return MemberRole.Viewer; + } +} + +export function memberRoleToPermission(memberRole: MemberRole): 4 | 6 | 22 { + if (memberRole === MemberRole.Inherited) { + throw new Error("Cannot convert inherited role to permission"); + } + switch (memberRole) { + case MemberRole.Viewer: + return 4; + case MemberRole.Editor: + return 6; + case MemberRole.Admin: + return 22; + } +} diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index b40d1108..457a410a 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -105,8 +105,8 @@ function generateFolderNode(overrides = {}) { function generateNode() { return { - volumeId: "volumeId", hash: "nameHash", + encryptedName: "encName", uid: "volume:volumeId;node:linkId", parentUid: "volume:volumeId;node:parentLinkId", @@ -121,7 +121,6 @@ function generateNode() { armoredKey: "nodeKey", armoredNodePassphrase: "nodePass", armoredNodePassphraseSignature: "nodePassSig", - encryptedName: "encName", nameSignatureEmail: "nameSigEmail", signatureEmail: "sigEmail", }, diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index b408ce3b..987fe5a0 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,6 +1,6 @@ -import { Logger, NodeType, MemberRole, NodeResult } from "../../interface"; +import { Logger, NodeResult } from "../../interface"; import { RevisionState } from "../../interface/nodes"; -import { DriveAPIService, drivePaths, ErrorCode } from "../apiService"; +import { DriveAPIService, drivePaths, ErrorCode, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid } from "../uids"; import { EncryptedNode, EncryptedRevision } from "./interface"; @@ -69,13 +69,13 @@ export class NodeAPIService { const nodes = response.Links.map((link) => { const baseNodeMetadata = { // Internal metadata - volumeId, hash: link.Link.NameHash || undefined, + encryptedName: link.Link.Name, // Basic node metadata uid: makeNodeUid(volumeId, link.Link.LinkID), parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, - type: link.Link.Type === 1 ? NodeType.Folder : NodeType.File, + type: nodeTypeNumberToNodeType(link.Link.Type), mimeType: link.Link.MIMEType || undefined, createdDate: new Date(link.Link.CreateTime*1000), trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, @@ -83,10 +83,9 @@ export class NodeAPIService { // Sharing node metadata shareId: link.SharingSummary?.ShareID || undefined, isShared: !!link.SharingSummary, - directMemberRole: sharingSummaryToDirectMemberRole(link.SharingSummary, this.logger), + directMemberRole: permissionsToDirectMemberRole(link.SharingSummary?.ShareAccess.Permissions, this.logger), } const baseCryptoNodeMetadata = { - encryptedName: link.Link.Name, signatureEmail: link.Link.SignatureEmail || undefined, nameSignatureEmail: link.Link.NameSignatureEmail || undefined, armoredKey: link.Link.NodeKey, @@ -362,22 +361,6 @@ function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { return volumeId; } -function sharingSummaryToDirectMemberRole(sharingSummary: PostLoadLinksMetadataResponse['Links'][0]['SharingSummary'], logger?: Logger): MemberRole { - switch (sharingSummary?.ShareAccess.Permissions) { - case undefined: - case 4: - return MemberRole.Viewer; - case 6: - return MemberRole.Editor; - case 22: - return MemberRole.Admin; - default: - // User have access to the data, thus at minimum it can view. - logger?.warn(`Unknown sharing permissions: ${sharingSummary?.ShareAccess.Permissions}`); - return MemberRole.Viewer; - } -} - type LinkResponse = { LinkID: string, Response: { diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index dfbd3f14..cf7f90ec 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -206,8 +206,7 @@ function deserialiseNode(nodeData: string): DecryptedNode { (typeof node.mimeType !== 'string' && node.mimeType !== undefined) || typeof node.isShared !== 'boolean' || !node.createdDate || typeof node.createdDate !== 'string' || - (typeof node.trashedDate !== 'string' && node.trashedDate !== undefined) || - !node.volumeId || typeof node.volumeId !== 'string' + (typeof node.trashedDate !== 'string' && node.trashedDate !== undefined) ) { throw new Error(`Invalid node data: ${nodeData}`); } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 1e8b10d9..56c32268 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -45,7 +45,7 @@ describe("nodesCryptoService", () => { sharesService = { getVolumeEmailKey: jest.fn(async () => ({ email: "email", - key: "key" as unknown as PrivateKey, + addressKey: "key" as unknown as PrivateKey, })), }; diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 669a52e4..01afd76a 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -4,6 +4,7 @@ import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, Decryp // TODO: Switch to CryptoProxy module once available. import { importHmacKey, computeHmacSignature } from "./hmac"; +import { splitNodeUid } from "../uids"; /** * Provides crypto operations for nodes metadata. @@ -172,7 +173,7 @@ export class NodesCryptoService { try { const { name, verified } = await this.driveCrypto.decryptNodeName( - node.encryptedCrypto.encryptedName, + node.encryptedName, parentKey, verificationKeys, ); @@ -197,6 +198,10 @@ export class NodesCryptoService { } }; + async getNameSessionKey(node: DecryptedNode, parentKey: PrivateKey): Promise { + return this.driveCrypto.decryptSessionKey(node.encryptedName, parentKey); + } + private async decryptHashKey(node: EncryptedNode, nodeKey: PrivateKey, addressKeys: PublicKey[]): Promise<{ hashKey: Uint8Array, author: Author, @@ -259,10 +264,11 @@ export class NodesCryptoService { } async createFolder(parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, name: string): Promise<{ - encryptedCrypto: Required & { hash: string }, + encryptedCrypto: Required & { encryptedName: string, hash: string }, keys: DecryptedNodeKeys, }> { - const { email, key: addressKey } = await this.shareService.getVolumeEmailKey(parentNode.volumeId); + const { volumeId } = splitNodeUid(parentNode.uid); + const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); const [ nodeKeys, { armoredNodeName }, @@ -303,7 +309,8 @@ export class NodesCryptoService { armoredNodeName: string, hash: string, }> { - const { email, key: addressKey } = await this.shareService.getVolumeEmailKey(node.volumeId); + const { volumeId } = splitNodeUid(node.uid); + const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, parentKeys.key, addressKey); const hash = await this.generateLookupHash(newName, parentKeys.hashKey); return { @@ -328,7 +335,8 @@ export class NodesCryptoService { throw new Error('Cannot move node without a valid name, please rename the node first'); } - const { email, key: addressKey } = await this.shareService.getVolumeEmailKey(parentNode.volumeId); + const { volumeId } = splitNodeUid(parentNode.uid); + const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, parentKeys.key, addressKey); const hash = await this.generateLookupHash(node.name.value, parentKeys.hashKey); const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.sessionKey, [parentKeys.key], addressKey); diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index f393b63d..f1ffff92 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -7,8 +7,8 @@ import { RevisionState } from "../../interface/nodes"; */ interface BaseNode { // Internal metadata - volumeId: string; hash?: string; // root node doesn't have any hash + encryptedName: string; // Basic node metadata uid: string; @@ -34,8 +34,6 @@ export interface EncryptedNode extends BaseNode { } export interface EncryptedNodeCrypto { - encryptedName: string; - signatureEmail?: string; nameSignatureEmail?: string; armoredKey: string; @@ -118,5 +116,5 @@ export interface DecryptedRevision extends BaseRevision { export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string, rootNodeId: string }>, getSharePrivateKey(shareId: string): Promise, - getVolumeEmailKey(volumeId: string): Promise<{ email: string, key: PrivateKey }>, + getVolumeEmailKey(volumeId: string): Promise<{ email: string, addressKey: PrivateKey }>, } diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 29a8404d..2e3b36c4 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,3 +1,4 @@ +import { PrivateKey, SessionKey } from "../../crypto"; import { Logger, NodeType, resultError, resultOk } from "../../interface"; import { BatchLoading } from "../batchLoading"; import { makeNodeUid } from "../uids"; @@ -208,4 +209,20 @@ export class NodesAccess { return keys; } } + + async getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ + key: PrivateKey, + passphraseSessionKey: SessionKey, + nameSessionKey: SessionKey, + }> { + const node = await this.getNode(nodeUid); + const { key: parentKey } = await this.getParentKeys(node); + const { key, sessionKey: passphraseSessionKey } = await this.getNodeKeys(nodeUid); + const nameSessionKey = await this.cryptoService.getNameSessionKey(node, parentKey); + return { + key, + passphraseSessionKey, + nameSessionKey, + }; + } } diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index b0114ebb..8716ee5b 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -221,8 +221,8 @@ export class NodesManagement { const node: DecryptedNode = { // Internal metadata - volumeId: parentNode.volumeId, hash: encryptedCrypto.hash, + encryptedName: encryptedCrypto.encryptedName, // Basic node metadata uid: nodeUid, diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index 774c37a7..8e6c9d7f 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -1,4 +1,5 @@ import { DriveAPIService, drivePaths } from "../apiService"; +import { makeMemberUid } from "../uids"; import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto } from "./interface"; type PostCreateVolumeRequest = Extract['content']['application/json']; @@ -152,5 +153,8 @@ function convertSharePayload(response: GetShareResponse): EncryptedShare { armoredPassphrase: response.Passphrase, armoredPassphraseSignature: response.PassphraseSignature, }, + membership: response.Memberships?.[0] ? { + memberUid: makeMemberUid(response.ShareID, response.Memberships[0].MemberID), + } : undefined, }; } diff --git a/js/sdk/src/internal/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts index c3386141..1b91f975 100644 --- a/js/sdk/src/internal/shares/cache.test.ts +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -19,6 +19,7 @@ describe('sharesCache', () => { shareId: 'share1', rootNodeId: 'node1', creatorEmail: 'email', + addressId: 'address1', }; await cache.setVolume(volume); diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index b92218c3..366bc50e 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -53,7 +53,8 @@ function deserializeVolume(shareData: string): Volume { !volume.volumeId || typeof volume.volumeId !== 'string' || !volume.shareId || typeof volume.shareId !== 'string' || !volume.rootNodeId || typeof volume.rootNodeId !== 'string' || - !volume.creatorEmail || typeof volume.creatorEmail !== 'string' + !volume.creatorEmail || typeof volume.creatorEmail !== 'string' || + !volume.addressId || typeof volume.addressId !== 'string' ) { throw new Error('Invalid volume data'); } diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 7e4d53bf..749db37d 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -41,14 +41,14 @@ export class SharesCryptoService { } async decryptRootShare(share: EncryptedRootShare): Promise<{ share: DecryptedRootShare, key: DecryptedShareKey }> { - const addressPrivateKeys = await this.account.getOwnPrivateKeys(share.addressId); + const { keys: addressKeys } = await this.account.getOwnAddress(share.addressId); const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); const { key, sessionKey, verified } = await this.driveCrypto.decryptKey( share.encryptedCrypto.armoredKey, share.encryptedCrypto.armoredPassphrase, share.encryptedCrypto.armoredPassphraseSignature, - addressPrivateKeys, + addressKeys.map(({ key }) => key), addressPublicKeys, ) diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts index e1ba9cd4..1b368cbf 100644 --- a/js/sdk/src/internal/shares/index.ts +++ b/js/sdk/src/internal/shares/index.ts @@ -7,6 +7,8 @@ import { SharesCache } from "./cache"; import { SharesCryptoService } from "./cryptoService"; import { SharesManager } from "./manager"; +export type { EncryptedShare } from "./interface"; + /** * Provides facade for the whole shares module. * diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index 6d3f8bdd..c47936e8 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -20,13 +20,14 @@ export interface VolumeShareNodeIDs { export type Volume = { /** - * Creator email comes from the default share. + * Creator email and address ID come from the default share. * * The idea is to keep this information synced, so whenever we check - * cached volume information, we have creator email at hand for any - * verification checks. + * cached volume information, we have creator details at hand for any + * verification checks or creation needs. */ creatorEmail: string; + addressId: string; } & VolumeShareNodeIDs; /** @@ -58,6 +59,11 @@ interface BaseRootShare extends BaseShare { export interface EncryptedShare extends BaseShare { creatorEmail: string; encryptedCrypto: EncryptedShareCrypto; + membership?: ShareMembership; +} + +interface ShareMembership { + memberUid: string; } /** diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts index 832e9343..73538f57 100644 --- a/js/sdk/src/internal/shares/manager.test.ts +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -41,8 +41,8 @@ describe("SharesManager", () => { } // @ts-expect-error No need to implement all methods for mocking account = { - getOwnPrimaryKey: jest.fn(), - getOwnPrivateKey: jest.fn(), + getOwnPrimaryAddress: jest.fn(), + getOwnAddress: jest.fn(), } manager = new SharesManager(apiService, cache, cryptoCache, cryptoService, account); @@ -85,7 +85,7 @@ describe("SharesManager", () => { it("should create volume when My files section doesn't exist", async () => { apiService.getMyFiles = jest.fn().mockRejectedValue(new NotFoundAPIError("no active volume", 0)); - account.getOwnPrimaryKey = jest.fn().mockResolvedValue({ addressKey: "addressKey" }); + account.getOwnPrimaryAddress = jest.fn().mockResolvedValue({ primaryKey: { key: "addressKey" } }); cryptoService.generateVolumeBootstrap = jest.fn().mockResolvedValue({ shareKey: { encrypted: "encrypted share key", @@ -139,14 +139,15 @@ describe("SharesManager", () => { describe("getVolumeEmailKey", () => { it("should return cached volume email key", async () => { - cache.getVolume = jest.fn().mockResolvedValue({ creatorEmail: "email" }); - account.getOwnPrivateKey = jest.fn().mockResolvedValue("creatorKey"); + cache.getVolume = jest.fn().mockResolvedValue({ addressId: "addressId" }); + account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKey: { key: "addressKey" } }); const result = await manager.getVolumeEmailKey("volumeId"); expect(result).toEqual({ + addressId: "addressId", email: "email", - key: "creatorKey", + addressKey: "addressKey", }); }); @@ -156,17 +157,19 @@ describe("SharesManager", () => { shareId: "shareId", rootNodeId: "rootNodeId", creatorEmail: "email", + addressId: "addressId", } cache.getVolume = jest.fn().mockRejectedValue(new Error('not found')); apiService.getVolume = jest.fn().mockResolvedValue({ shareId: "shareId" }); - apiService.getShare = jest.fn().mockResolvedValue(share); - account.getOwnPrivateKey = jest.fn().mockResolvedValue("creatorKey"); + apiService.getRootShare = jest.fn().mockResolvedValue(share); + account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKey: { key: "addressKey" } }); const result = await manager.getVolumeEmailKey("volumeId"); expect(result).toEqual({ + addressId: "addressId", email: "email", - key: "creatorKey", + addressKey: "addressKey", }); expect(cache.setVolume).toHaveBeenCalledWith(share); }); diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index d8d64ca7..3debe622 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -5,7 +5,7 @@ import { SharesAPIService } from "./apiService"; import { SharesCache } from "./cache"; import { SharesCryptoCache } from "./cryptoCache"; import { SharesCryptoService } from "./cryptoService"; -import { VolumeShareNodeIDs } from "./interface"; +import { VolumeShareNodeIDs, EncryptedShare } from "./interface"; /** * Provides high-level actions for managing shares. @@ -60,6 +60,7 @@ export class SharesManager { shareId: myFilesShare.shareId, rootNodeId: myFilesShare.rootNodeId, creatorEmail: encryptedShare.creatorEmail, + addressId: encryptedShare.addressId, }); this.myFilesIds = { @@ -87,12 +88,12 @@ export class SharesManager { * @throws If the volume cannot be created (e.g., one already exists). */ async createVolume(): Promise { - const { addressKey, addressId, addressKeyId } = await this.account.getOwnPrimaryKey(); - const bootstrap = await this.cryptoService.generateVolumeBootstrap(addressKey); + const { addressId, primaryKey } = await this.account.getOwnPrimaryAddress(); + const bootstrap = await this.cryptoService.generateVolumeBootstrap(primaryKey.key); const myFilesIds = await this.apiService.createVolume( { addressId, - addressKeyId, + addressKeyId: primaryKey.id, ...bootstrap.shareKey.encrypted, }, { @@ -126,28 +127,37 @@ export class SharesManager { return key.key; } - async getVolumeEmailKey(volumeId: string): Promise<{ email: string, key: PrivateKey }> { + async getVolumeEmailKey(volumeId: string): Promise<{ email: string, addressId: string, addressKey: PrivateKey }> { try { - const { creatorEmail } = await this.cache.getVolume(volumeId); + const { addressId } = await this.cache.getVolume(volumeId); + const address = await this.account.getOwnAddress(addressId); return { - email: creatorEmail, - key: await this.account.getOwnPrivateKey(creatorEmail), + email: address.email, + addressId, + addressKey: address.primaryKey.key, }; } catch {} const { shareId } = await this.apiService.getVolume(volumeId); - const share = await this.apiService.getShare(shareId); + const share = await this.apiService.getRootShare(shareId); await this.cache.setVolume({ volumeId: share.volumeId, shareId: share.shareId, rootNodeId: share.rootNodeId, creatorEmail: share.creatorEmail, + addressId: share.addressId, }); + const address = await this.account.getOwnAddress(share.addressId); return { - email: share.creatorEmail, - key: await this.account.getOwnPrivateKey(share.creatorEmail), + email: address.email, + addressId: share.addressId, + addressKey: address.primaryKey.key, }; } + + async loadEncryptedShare(shareId: string): Promise { + return this.apiService.getShare(shareId); + } } diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 2bcaa608..661ce2c9 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -1,7 +1,7 @@ -import { NodeType } from "../../interface"; -import { DriveAPIService, drivePaths } from "../apiService"; -import { makeNodeUid, makeInvitationUid } from "../uids"; -import { EncryptedBookmark } from "./interface"; +import { NodeType, MemberRole, NonProtonInvitationState } from "../../interface"; +import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from "../apiService"; +import { makeNodeUid, splitNodeUid, makeInvitationUid, splitInvitationUid, makeMemberUid, splitMemberUid } from "../uids"; +import { EncryptedInvitationRequest, EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedBookmark, EncryptedExternalInvitationRequest } from "./interface"; type GetSharedNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/shares']['get']['responses']['200']['content']['application/json']; @@ -9,8 +9,43 @@ type GetSharedWithMeNodesResponse = drivePaths['/drive/v2/sharedwithme']['get'][ type GetInvitationsResponse = drivePaths['/drive/v2/shares/invitations']['get']['responses']['200']['content']['application/json']; +type GetInvitationDetailsResponse = drivePaths['/drive/v2/shares/invitations/{invitationID}']['get']['responses']['200']['content']['application/json']; + +type PostAcceptInvitationRequest = Extract['content']['application/json']; +type PostAcceptInvitationResponse = drivePaths['/drive/v2/shares/invitations/{invitationID}/accept']['post']['responses']['200']['content']['application/json']; + type GetSharedBookmarksResponse = drivePaths['/drive/v2/shared-bookmarks']['get']['responses']['200']['content']['application/json']; +type GetShareInvitations = drivePaths['/drive/v2/shares/{shareID}/invitations']['get']['responses']['200']['content']['application/json']; + +type GetShareExternalInvitations = drivePaths['/drive/v2/shares/{shareID}/external-invitations']['get']['responses']['200']['content']['application/json']; + +type GetShareMembers = drivePaths['/drive/v2/shares/{shareID}/members']['get']['responses']['200']['content']['application/json']; + +type PostCreateShareRequest = Extract['content']['application/json']; +type PostCreateShareResponse = drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; + +type PostInviteProtonUserRequest = Extract['content']['application/json']; +type PostInviteProtonUserResponse = drivePaths['/drive/v2/shares/{shareID}/invitations']['post']['responses']['200']['content']['application/json']; + +type PutUpdateInvitationRequest = Extract['content']['application/json']; +type PutUpdateInvitationResponse = drivePaths['/drive/v2/shares/{shareID}/invitations/{invitationID}']['put']['responses']['200']['content']['application/json']; + +type PostInviteExternalUserRequest = Extract['content']['application/json']; +type PostInviteExternalUserResponse = drivePaths['/drive/v2/shares/{shareID}/external-invitations']['post']['responses']['200']['content']['application/json']; + +type PutUpdateExternalInvitationRequest = Extract['content']['application/json']; +type PutUpdateExternalInvitationResponse = drivePaths['/drive/v2/shares/{shareID}/external-invitations/{invitationID}']['put']['responses']['200']['content']['application/json']; + +type PostUpdateMemberRequest = Extract['content']['application/json']; +type PostUpdateMemberResponse = drivePaths['/drive/v2/shares/{shareID}/members/{memberID}']['put']['responses']['200']['content']['application/json']; + +/** + * Provides API communication for fetching and managing sharing. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ export class SharingAPIService { constructor(private apiService: DriveAPIService) { this.apiService = apiService; @@ -51,7 +86,7 @@ export class SharingAPIService { while (true) { const response = await this.apiService.get(`drive/v2/shares/invitations?${anchor ? `AnchorID=${anchor}` : ''}`, signal); for (const invitation of response.Invitations) { - yield makeInvitationUid(invitation.VolumeID, invitation.InvitationID); + yield makeInvitationUid(invitation.ShareID, invitation.InvitationID); } if (!response.More || !response.AnchorID) { @@ -61,6 +96,45 @@ export class SharingAPIService { } } + async getInvitation(invitationUid: string): Promise { + const { invitationId } = splitInvitationUid(invitationUid); + const response = await this.apiService.get(`drive/v2/shares/invitations/${invitationId}`); + return { + uid: invitationUid, + addedByEmail: response.Invitation.InviterEmail, + inviteeEmail: response.Invitation.InviteeEmail, + base64KeyPacket: response.Invitation.KeyPacket, + base64KeyPacketSignature: response.Invitation.KeyPacketSignature, + invitedDate: new Date(response.Invitation.CreateTime*1000), + role: permissionsToDirectMemberRole(response.Invitation.Permissions), + share: { + armoredKey: response.Share.ShareKey, + armoredPassphrase: response.Share.Passphrase, + creatorEmail: response.Share.CreatorEmail, + }, + node: { + type: nodeTypeNumberToNodeType(response.Link.Type), + mimeType: response.Link.MIMEType || undefined, + encryptedName: response.Link.Name, + }, + } + } + + async acceptInvitation(invitationUid: string, base64SessionKeySignature: string): Promise { + const { invitationId } = splitInvitationUid(invitationUid); + await this.apiService.post< + PostAcceptInvitationRequest, + PostAcceptInvitationResponse + >(`drive/v2/shares/invitations/${invitationId}/accept`, { + SessionKeySignature: base64SessionKeySignature, + }); + } + + async rejectInvitation(invitationUid: string): Promise { + const { invitationId } = splitInvitationUid(invitationUid); + await this.apiService.post(`drive/v2/shares/invitations/${invitationId}/reject`); + } + async *iterateBookmarks(signal?: AbortSignal): AsyncGenerator { const response = await this.apiService.get(`drive/v2/shared-bookmarks`, signal); for (const bookmark of response.Bookmarks) { @@ -89,6 +163,205 @@ export class SharingAPIService { } } - async inviteProtonUser(object: any) { + async deleteBookmark(tokenId: string): Promise { + await this.apiService.delete(`drive/v2/urls/${tokenId}/bookmark`); + } + + async getShareInvitations(shareId: string): Promise { + const response = await this.apiService.get(`drive/v2/shares/${shareId}/invitations`); + return response.Invitations.map((invitation) => { + return convertInternalInvitation(shareId, invitation); + }); + } + + async getShareExternalInvitations(shareId: string): Promise { + const response = await this.apiService.get(`drive/v2/shares/${shareId}/external-invitations`); + return response.ExternalInvitations.map((invitation) => { + return convertExternalInvitaiton(shareId, invitation); + }); + } + + async getShareMembers(shareId: string): Promise { + const response = await this.apiService.get(`drive/v2/shares/${shareId}/members`); + return response.Members.map((member) => { + return { + uid: makeMemberUid(shareId, member.MemberID), + addedByEmail: member.InviterEmail, + inviteeEmail: member.Email, + base64KeyPacket: member.KeyPacket, + base64KeyPacketSignature: member.KeyPacketSignature, + invitedDate: new Date(member.CreateTime*1000), + role: permissionsToDirectMemberRole(member.Permissions), + } + }); + } + + async createStandardShare( + nodeUid: string, + addressId: string, + shareKey: { + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + }, + node: { + base64PassphraseKeyPacket: string, + base64NameKeyPacket: string, + }, + ): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const response = await this.apiService.post< + PostCreateShareRequest, + PostCreateShareResponse + >(`drive/volumes/${volumeId}/shares`, { + RootLinkID: nodeId, + AddressID: addressId, + Name: 'New Share', + ShareKey: shareKey.armoredKey, + SharePassphrase: shareKey.armoredPassphrase, + SharePassphraseSignature: shareKey.armoredPassphraseSignature, + PassphraseKeyPacket: node.base64PassphraseKeyPacket, + NameKeyPacket: node.base64NameKeyPacket, + + }); + return response.Share.ID; + } + + async deleteShare(shareId: string): Promise { + await this.apiService.delete(`drive/shares/${shareId}?Force=1`); + } + + async inviteProtonUser( + shareId: string, + invitation: EncryptedInvitationRequest, + emailDetails: { message?: string, nodeName?: string } = {}, + ): Promise { + const response = await this.apiService.post< + PostInviteProtonUserRequest, + PostInviteProtonUserResponse + >(`drive/v2/shares/${shareId}/invitations`, { + Invitation: { + InviterEmail: invitation.addedByEmail, + InviteeEmail: invitation.inviteeEmail, + Permissions: memberRoleToPermission(invitation.role), + KeyPacket: invitation.base64KeyPacket, + KeyPacketSignature: invitation.base64KeyPacketSignature, + ExternalInvitationID: null, + }, + EmailDetails: { + Message: emailDetails.message, + ItemName: emailDetails.nodeName, + }, + }); + return convertInternalInvitation(shareId, response.Invitation); + } + + async updateInvitation( + invitationUid: string, + invitation: { role: MemberRole }, + ): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.put< + PutUpdateInvitationRequest, + PutUpdateInvitationResponse + >(`drive/v2/shares/${shareId}/invitations/${invitationId}`, { + Permissions: memberRoleToPermission(invitation.role), + }); + } + + async resendInvitationEmail(invitationUid: string): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.post(`drive/v2/shares/${shareId}/invitations/${invitationId}/sendemail`); + } + + async deleteInvitation(invitationUid: string): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.delete(`drive/v2/shares/${shareId}/invitations/${invitationId}`); + } + + async inviteExternalUser( + shareId: string, + invitation: EncryptedExternalInvitationRequest, + emailDetails: { message?: string, nodeName?: string } = {}, + ): Promise { + const response = await this.apiService.post< + PostInviteExternalUserRequest, + PostInviteExternalUserResponse + >(`drive/v2/shares/${shareId}/external-invitations`, { + ExternalInvitation: { + InviterAddressID: invitation.inviterAddressId, + InviteeEmail: invitation.inviteeEmail, + Permissions: memberRoleToPermission(invitation.role), + ExternalInvitationSignature: invitation.base64Signature, + }, + EmailDetails: { + Message: emailDetails.message, + ItemName: emailDetails.nodeName, + }, + }); + return convertExternalInvitaiton(shareId, response.ExternalInvitation); + } + + async updateExternalInvitation( + invitationUid: string, + invitation: { role: MemberRole }, + ): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.put< + PutUpdateExternalInvitationRequest, + PutUpdateExternalInvitationResponse + >(`drive/v2/shares/${shareId}/external-invitations/${invitationId}`, { + Permissions: memberRoleToPermission(invitation.role), + }); + } + + async resendExternalInvitationEmail(invitationUid: string): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.post(`drive/v2/shares/${shareId}/external-invitations/${invitationId}/sendemail`); + } + + async deleteExternalInvitation(invitationUid: string): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.delete(`drive/v2/shares/${shareId}/external-invitations/${invitationId}`); + } + + async updateMember(memberUid: string, member: { role: MemberRole }): Promise { + const { shareId, memberId } = splitMemberUid(memberUid); + await this.apiService.put< + PostUpdateMemberRequest, + PostUpdateMemberResponse + >(`drive/v2/shares/${shareId}/members/${memberId}`, { + Permissions: memberRoleToPermission(member.role), + }); + } + + async removeMember(memberUid: string): Promise { + const { shareId, memberId } = splitMemberUid(memberUid); + await this.apiService.delete(`drive/v2/shares/${shareId}/members/${memberId}`); + } +} + +function convertInternalInvitation(shareId: string, invitation: GetShareInvitations['Invitations'][0]): EncryptedInvitation { + return { + uid: makeInvitationUid(shareId, invitation.InvitationID), + addedByEmail: invitation.InviterEmail, + inviteeEmail: invitation.InviteeEmail, + invitedDate: new Date(invitation.CreateTime*1000), + role: permissionsToDirectMemberRole(invitation.Permissions), + base64KeyPacket: invitation.KeyPacket, + base64KeyPacketSignature: invitation.KeyPacketSignature, + } +} + +function convertExternalInvitaiton(shareId: string, invitation: GetShareExternalInvitations['ExternalInvitations'][0]): EncryptedExternalInvitation { + const state = invitation.State === 1 ? NonProtonInvitationState.Pending : NonProtonInvitationState.UserRegistered; + return { + uid: makeInvitationUid(shareId, invitation.ExternalInvitationID), + addedByEmail: invitation.InviterEmail, + inviteeEmail: invitation.InviteeEmail, + invitedDate: new Date(invitation.CreateTime*1000), + role: permissionsToDirectMemberRole(invitation.Permissions), + base64Signature: invitation.ExternalInvitationSignature, + state, } } diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index e521fdf0..9d8db69a 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -1,6 +1,14 @@ -import { DriveCrypto, PrivateKey } from '../../crypto'; -import { ProtonDriveAccount } from "../../interface"; +import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; +import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, InvalidNameError, resultError, resultOk } from "../../interface"; +import { EncryptedShare } from "../shares"; +import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember } from "./interface"; +/** + * Provides crypto operations for sharing. + * + * The sharing crypto service is responsible for encrypting and decrypting + * shares, invitations, etc. + */ export class SharingCryptoService { constructor( private driveCrypto: DriveCrypto, @@ -10,35 +18,237 @@ export class SharingCryptoService { this.account = account; } - // TODO: types - async generateKeys(nodeKey: PrivateKey, addressKey: PrivateKey): Promise { - return this.driveCrypto.generateKey([nodeKey, addressKey], addressKey); + /** + * Generates a share key for a standard share used for sharing with other users. + * + * Standard share, in contrast to a root share, is encrypted with node key and + * can be managed by any admin. + */ + async generateShareKeys( + nodeKeys: { + key: PrivateKey + passphraseSessionKey: SessionKey, + nameSessionKey: SessionKey, + }, + addressKey: PrivateKey, + ): Promise<{ + shareKey: { + encrypted: { + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + }, + decrypted: { + key: PrivateKey, + sessionKey: SessionKey, + }, + }, + base64PpassphraseKeyPacket: string, + base64NameKeyPacket: string, + }> { + const shareKey = await this.driveCrypto.generateKey([nodeKeys.key, addressKey], addressKey); + + const { base64KeyPacket: base64PpassphraseKeyPacket } = await this.driveCrypto.encryptSessionKey( + nodeKeys.passphraseSessionKey, + shareKey.decrypted.key, + ); + const { base64KeyPacket: base64NameKeyPacket } = await this.driveCrypto.encryptSessionKey( + nodeKeys.nameSessionKey, + shareKey.decrypted.key, + ); + + return { + shareKey, + base64PpassphraseKeyPacket, + base64NameKeyPacket, + }; }; - // TODO: types - async decryptShareKeys(share: any, nodeKey: PrivateKey): Promise { - // TODO: use correct address keys - const addressPrivateKeys = await this.account.getOwnPrivateKeys(share.addressId); + /** + * Decrypts a share using the node key. + * + * The share is encrypted with the node key and can be managed by any admin. + * + * Old shares are encrypted with address key only and thus available only + * to owners. `decryptShare` automatically tries to decrypt the share with + * address keys as fallback if available. + */ + async decryptShare(share: EncryptedShare, nodeKey: PrivateKey): Promise<{ + author: Author, + key: PrivateKey, + sessionKey: SessionKey, + }> { + // All standard shares should be encrypted with node key. + // Using node key is essential so any admin can manage the share. + // Old shares are encrypted with address key only and thus available + // only to owners. Adding address keys (if available) is a fallback + // solution until all shares are migrated. + const decryptionKeys = [nodeKey]; + if (share.addressId) { + const address = await this.account.getOwnAddress(share.addressId); + decryptionKeys.push(...address.keys.map(({ key }) => key)); + } const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); - // TODO: use verified - const { key, sessionKey } = await this.driveCrypto.decryptKey( + const { key, sessionKey, verified } = await this.driveCrypto.decryptKey( share.encryptedCrypto.armoredKey, share.encryptedCrypto.armoredPassphrase, share.encryptedCrypto.armoredPassphraseSignature, - addressPrivateKeys, + decryptionKeys, addressPublicKeys, - ) + ) + + const author: Result = verified === VERIFICATION_STATUS.SIGNED_AND_VALID + ? resultOk(share.creatorEmail) + : resultError({ + claimedAuthor: share.creatorEmail, + error: verified === VERIFICATION_STATUS.SIGNED_AND_INVALID + ? `Verification signature failed` + : `Missing signature`, + }); + return { + author, key, sessionKey, } } - // TODO: types - async encryptInvitation(email: string): Promise { - // TODO - const publicKey = await this.account.getPublicKeys(email); - return publicKey; + /** + * Encrypts an invitation for sharing a node with another user. + * + * `inviteeEmail` is used to load public key of the invitee and used to + * encrypt share's session key. `inviterKey` is used to sign the invitation. + */ + async encryptInvitation( + shareSessionKey: SessionKey, + inviterKey: PrivateKey, + inviteeEmail: string, + ): Promise<{ + base64KeyPacket: string, + base64KeyPacketSignature: string, + }> { + const inviteePublicKey = await this.account.getPublicKeys(inviteeEmail); + const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKey, inviterKey) + return result; }; + + /** + * Decrypts and verifies an invitation and node's name. + */ + async decryptInvitationWithNode(encryptedInvitation: EncryptedInvitationWithNode): Promise { + const { primaryKey: { key: inviteeKey } } = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); + + const shareKey = await this.driveCrypto.decryptUnsignedKey( + encryptedInvitation.share.armoredKey, + encryptedInvitation.share.armoredPassphrase, + inviteeKey, + ); + + let nodeName: Result; + try { + const result = await this.driveCrypto.decryptNodeName( + encryptedInvitation.node.encryptedName, + shareKey, + [], + ); + nodeName = resultOk(result.name); + } catch (error: unknown) { + const errorMessage = `Failed to decrypt node name: ${error instanceof Error ? error.message : 'Unknown error'}`; + nodeName = resultError({ name: '', error: errorMessage }); + } + + return { + ...await this.decryptInvitation(encryptedInvitation), + node: { + name: nodeName, + type: encryptedInvitation.node.type, + mimeType: encryptedInvitation.node.mimeType, + }, + } + } + + /** + * Verifies an invitation. + */ + async decryptInvitation(encryptedInvitation: EncryptedInvitation): Promise { + // TODO: verify addedByEmail (current client doesnt do this) + const addedByEmail: Result = resultOk(encryptedInvitation.addedByEmail); + + return { + uid: encryptedInvitation.uid, + invitedDate: encryptedInvitation.invitedDate, + addedByEmail: addedByEmail, + inviteeEmail: encryptedInvitation.inviteeEmail, + role: encryptedInvitation.role, + }; + } + + /** + * Accepts an invitation by signing the session key by invitee. + */ + async acceptInvitation(encryptedInvitation: EncryptedInvitationWithNode): Promise<{ + base64SessionKeySignature: string, + }> { + const { primaryKey: { key: inviteeKey } } = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); + const result = await this.driveCrypto.acceptInvitation( + encryptedInvitation.base64KeyPacket, + inviteeKey, + ); + return result; + } + + /** + * Encrypts an external invitation for sharing a node with another user. + * + * `inviteeEmail` is used to sign the invitation with `inviterKey`. + * + * External invitations are used to share nodes with users who are not + * registered with Proton Drive. The external invitation then requires + * the invitee to sign up to create key. Then it can be followed by + * regular invitation flow. + */ + async encryptExternalInvitation( + shareSessionKey: SessionKey, + inviterKey: PrivateKey, + inviteeEmail: string, + ): Promise<{ + base64ExternalInvitationSignature: string, + }> { + const result = await this.driveCrypto.encryptExternalInvitation(shareSessionKey, inviterKey, inviteeEmail); + return result; + } + + /** + * Verifies an external invitation. + */ + async decryptExternalInvitation(encryptedInvitation: EncryptedExternalInvitation): Promise { + // TODO: verify addedByEmail (current client doesnt do this) + const addedByEmail: Result = resultOk(encryptedInvitation.addedByEmail); + + return { + uid: encryptedInvitation.uid, + invitedDate: encryptedInvitation.invitedDate, + addedByEmail: addedByEmail, + inviteeEmail: encryptedInvitation.inviteeEmail, + role: encryptedInvitation.role, + state: encryptedInvitation.state, + }; + } + + /** + * Verifies a member. + */ + async decryptMember(encryptedMember: EncryptedMember): Promise { + // TODO: verify addedByEmail (current client doesnt do this) + const addedByEmail: Result = resultOk(encryptedMember.addedByEmail); + + return { + uid: encryptedMember.uid, + invitedDate: encryptedMember.invitedDate, + addedByEmail: addedByEmail, + inviteeEmail: encryptedMember.inviteeEmail, + role: encryptedMember.role, + }; + } } diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index c24cdec8..c996a509 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -1,4 +1,4 @@ -import { ProtonDriveAccount, ShareNodeSettings, ShareRole, ShareResult, UnshareNodeSettings, ProtonDriveEntitiesCache, Logger } from "../../interface"; +import { ProtonDriveAccount, ProtonDriveEntitiesCache, Logger } from "../../interface"; import { DriveCrypto } from '../../crypto'; import { DriveAPIService } from "../apiService"; import { DriveEventsService } from "../events"; @@ -10,6 +10,13 @@ import { SharingAccess } from "./sharingAccess"; import { SharingManagement } from "./sharingManagement"; import { SharesService, NodesService } from "./interface"; +/** + * Provides facade for the whole sharing module. + * + * The sharing module is responsible for handling invitations, bookmarks, + * standard shares, listing shared nodes, etc. It includes API communication, + * encryption, decryption, caching, and event handling. + */ export function initSharingModule( apiService: DriveAPIService, driveEntitiesCache: ProtonDriveEntitiesCache, @@ -23,68 +30,13 @@ export function initSharingModule( const api = new SharingAPIService(apiService); const cache = new SharingCache(driveEntitiesCache); const cryptoService = new SharingCryptoService(crypto, account); - const sharingAccess = new SharingAccess(api, cache, sharesService, nodesService); + const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); const sharingEvents = new SharingEvents(driveEvents, cache, nodesService, sharingAccess, log); - const sharingManagement = new SharingManagement(api, cryptoService, account); - - // TODO: facade to convert high-level interface with object to low-level calls - async function shareNode(nodeUid: string, settings: ShareNodeSettings) { - let currentSharing = await sharingManagement.getSharingInfo(nodeUid); - if (!currentSharing) { - currentSharing = await sharingManagement.createShare(nodeUid); - } - - for (const user of settings.protonUsers || []) { - const { email, role } = typeof user === "string" ? { email: user, role: ShareRole.VIEW } : user; - if (currentSharing.protonInitations[email]) { - if (currentSharing.protonInitations[email].role === role) { - continue; - } - sharingManagement.updateInvitationPermissions(currentSharing.shareId, currentSharing.protonUsers[email].invitationId, role); - continue; - } - sharingManagement.inviteProtonUser(currentSharing.shareId, email, role); - } - // TODO: return all the objects - return {} as ShareResult; - } - - async function unshareNode(nodeUid: string, settings?: UnshareNodeSettings) { - const currentSharing = await sharingManagement.getSharingInfo(nodeUid); - if (!currentSharing) { - return; - } - if (!settings) { - return sharingManagement.deleteShare(currentSharing.shareId); - } - if (settings.publicLink === 'remove') { - await sharingManagement.removeSharedLink(currentSharing.shareId); - } - for (const user of settings.users || []) { - const invitationId = currentSharing.protonInitations[user]?.invitationId; - if (invitationId) { - sharingManagement.deleteInvitation(currentSharing.shareId, invitationId); - continue; - } - const externalInvitationId = currentSharing.nonProtonInvitations[user]?.invitationId; - if (externalInvitationId) { - sharingManagement.deleteExternalInvitation(currentSharing.shareId, externalInvitationId); - continue; - } - const memberId = currentSharing.members[user]?.memberId; - if (memberId) { - sharingManagement.removeMember(currentSharing.shareId, memberId); - continue; - } - } - // TODO: return all the objects - return {} as ShareResult; - } + const sharingManagement = new SharingManagement(api, cryptoService, sharesService, nodesService, log); return { access: sharingAccess, events: sharingEvents, - shareNode, - unshareNode, + management: sharingManagement, }; } diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 5fe7f19d..e46062ab 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -1,10 +1,89 @@ -import { NodeEntity, NodeType, MemberRole } from "../../interface"; +import { NodeEntity, NodeType, MemberRole, NonProtonInvitationState } from "../../interface"; +import { PrivateKey, SessionKey } from "../../crypto"; +import { EncryptedShare } from "../shares"; export enum SharingType { SharedByMe = 'sharedByMe', sharedWithMe = 'sharedWithMe', } +/** + * Internal interface for creating new invitation. + */ +export interface EncryptedInvitationRequest { + addedByEmail: string; + inviteeEmail: string; + base64KeyPacket: string; + base64KeyPacketSignature: string; + role: MemberRole; +} + +/** + * Internal interface of existing invitation on the API. + * + * This interface is used only for managing the invitations. For listing + * invitations with node metadata, see `EncryptedInvitationWithNode`. + */ +export interface EncryptedInvitation extends EncryptedInvitationRequest { + uid: string; + invitedDate: Date; +} + +/** + * Internal interface of existing invitation with the share and node metadata. + * + * Invitation with node is used for listing shared nodes with me, so it includes + * what is being shared as well. + */ +export interface EncryptedInvitationWithNode extends EncryptedInvitation { + share: { + armoredKey: string; + armoredPassphrase: string; + creatorEmail: string; + }; + node: { + type: NodeType; + mimeType?: string; + encryptedName: string; + } +} + +/** + * Internal interface for creating new external invitation. + */ +export interface EncryptedExternalInvitationRequest { + inviterAddressId: string; + inviteeEmail: string; + role: MemberRole; + base64Signature: string; +} + +/** + * Internal interface of existing external invitation on the API. + */ +export interface EncryptedExternalInvitation extends Omit { + uid: string; + invitedDate: Date; + addedByEmail: string; + state: NonProtonInvitationState; +} + +/** + * Internal interface of existing member on the API. + */ +export interface EncryptedMember { + uid: string; + invitedDate: Date; + addedByEmail: string; + inviteeEmail: string; + role: MemberRole; + base64KeyPacket: string; + base64KeyPacketSignature: string; +} + +/** + * Internal interface of existing member with the share and node metadata. + */ export interface EncryptedBookmark { tokenId: string; createdDate: Date; @@ -33,12 +112,20 @@ export interface EncryptedBookmark { */ export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string }>, + getVolumeEmailKey(volumeId: string): Promise<{ addressId: string, email: string, addressKey: PrivateKey }>, + loadEncryptedShare(shareId: string): Promise, } /** * Interface describing the dependencies to the nodes module. */ export interface NodesService { - getNode(nodeUid: string): Promise, + getNode(nodeUid: string): Promise, + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, sessionKey: SessionKey }>, + getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ + key: PrivateKey, + passphraseSessionKey: SessionKey, + nameSessionKey: SessionKey, + }>, iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index cbdc4feb..8e436f36 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -1,12 +1,13 @@ import { SharingAPIService } from "./apiService"; import { SharingCache } from "./cache"; +import { SharingCryptoService } from "./cryptoService"; import { SharesService, NodesService } from "./interface"; - import { SharingAccess } from "./sharingAccess"; describe("SharingAccess", () => { let apiService: SharingAPIService; let cache: SharingCache; + let cryptoService: SharingCryptoService; let sharesService: SharesService; let nodesService: NodesService; @@ -31,6 +32,11 @@ describe("SharingAccess", () => { setSharedByMeNodeUids: jest.fn(), setSharedWithMeNodeUids: jest.fn(), } + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + decryptInvitation: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking sharesService = { getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: "volumeId" }), } @@ -45,7 +51,7 @@ describe("SharingAccess", () => { }), } - sharingAccess = new SharingAccess(apiService, cache, sharesService, nodesService); + sharingAccess = new SharingAccess(apiService, cache, cryptoService, sharesService, nodesService); }); describe("iterateSharedNodes", () => { diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 151e2fb1..f087776e 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -1,18 +1,28 @@ -import { NodeEntity } from "../../interface"; +import { NodeEntity, ProtonInvitationWithNode } from "../../interface"; import { BatchLoading } from "../batchLoading"; import { SharingAPIService } from "./apiService"; import { SharingCache } from "./cache"; +import { SharingCryptoService } from "./cryptoService"; import { SharesService, NodesService } from "./interface"; +/** + * Provides high-level actions for access shared nodes. + * + * The manager is responsible for listing shared by me, shared with me, + * invitations, bookmarks, etc., including API communication, encryption, + * decryption, and caching. + */ export class SharingAccess { constructor( private apiService: SharingAPIService, private cache: SharingCache, + private cryptoService: SharingCryptoService, private sharesService: SharesService, private nodesService: NodesService, ) { this.apiService = apiService; this.cache = cache; + this.cryptoService = cryptoService; this.sharesService = sharesService; this.nodesService = nodesService; } @@ -63,17 +73,47 @@ export class SharingAccess { await setCache(loadedNodeUids); } - // TODO: return decrypted invitations - async* iterateInvitations(signal?: AbortSignal): AsyncGenerator { + async removeSharedNodeWithMe(nodeUid: string): Promise { + const node = await this.nodesService.getNode(nodeUid); + if (!node.shareId) { + return; + } + + const share = await this.sharesService.loadEncryptedShare(node.shareId); + const memberUid = share.membership?.memberUid; + if (!memberUid) { + throw new Error('Share without membership cannot be removed'); + } + + await this.apiService.removeMember(memberUid); + } + + async* iterateInvitations(signal?: AbortSignal): AsyncGenerator { for await (const invitationUid of this.apiService.iterateInvitationUids(signal)) { - yield invitationUid; + const encryptedInvitation = await this.apiService.getInvitation(invitationUid); + const invitation = await this.cryptoService.decryptInvitationWithNode(encryptedInvitation); + yield invitation; } } + async acceptInvitation(invitationUid: string): Promise { + const encryptedInvitation = await this.apiService.getInvitation(invitationUid); + const { base64SessionKeySignature } = await this.cryptoService.acceptInvitation(encryptedInvitation); + await this.apiService.acceptInvitation(invitationUid, base64SessionKeySignature); + } + + async rejectInvitation(invitationUid: string): Promise { + await this.apiService.rejectInvitation(invitationUid); + } + // TODO: return decrypted bookmarks async* iterateSharedBookmarks(signal?: AbortSignal): AsyncGenerator { for await (const bookmark of this.apiService.iterateBookmarks(signal)) { yield bookmark.tokenId; } } + + async deleteBookmark(tokenId: string): Promise { + await this.apiService.deleteBookmark(tokenId); + } } diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts new file mode 100644 index 00000000..3e9371f8 --- /dev/null +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -0,0 +1,471 @@ +import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonInvitation, resultOk } from "../../interface"; +import { SharingAPIService } from "./apiService"; +import { SharingCryptoService } from "./cryptoService"; +import { SharesService, NodesService } from "./interface"; +import { SharingManagement } from "./sharingManagement"; + +describe("SharingManagement", () => { + let apiService: SharingAPIService; + let cryptoService: SharingCryptoService; + let sharesService: SharesService; + let nodesService: NodesService; + + let sharingManagement: SharingManagement; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = { + getShareInvitations: jest.fn().mockResolvedValue([]), + getShareExternalInvitations: jest.fn().mockResolvedValue([]), + getShareMembers: jest.fn().mockResolvedValue([]), + inviteProtonUser: jest.fn().mockImplementation((_, invitation) => ({ + ...invitation, + uid: "created-invitation", + })), + updateInvitation: jest.fn(), + deleteInvitation: jest.fn(), + inviteExternalUser: jest.fn().mockImplementation((_, invitation) => ({ + ...invitation, + uid: "created-external-invitation", + state: NonProtonInvitationState.Pending, + })), + updateExternalInvitation: jest.fn(), + deleteExternalInvitation: jest.fn(), + updateMember: jest.fn(), + removeMember: jest.fn(), + } + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + decryptShare: jest.fn().mockImplementation((share) => share), + decryptInvitation: jest.fn().mockImplementation((invitation) => invitation), + decryptExternalInvitation: jest.fn().mockImplementation((invitation) => invitation), + decryptMember: jest.fn().mockImplementation((member) => member), + encryptInvitation: jest.fn().mockImplementation((invitation) => invitation), + encryptExternalInvitation: jest.fn().mockImplementation((invitation) => ({ + ...invitation, + base64ExternalInvitationSignature: "extenral-signature", + })), + } + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getVolumeEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressKey: "volume-key" }), + loadEncryptedShare: jest.fn().mockResolvedValue({ id: "shareId" }), + } + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + getNode: jest.fn().mockImplementation((nodeUid) => ({ nodeUid, shareId: "shareId", name: { ok: true, value: "name" } })), + getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: "node-key" })), + } + + sharingManagement = new SharingManagement(apiService, cryptoService, sharesService, nodesService); + }); + + describe("getSharingInfo", () => { + it("should return empty sharing info for unshared node", async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ nodeUid: "nodeUid", shareId: undefined }); + const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + + expect(sharingInfo).toEqual(undefined); + expect(apiService.getShareInvitations).not.toHaveBeenCalled(); + expect(apiService.getShareExternalInvitations).not.toHaveBeenCalled(); + expect(apiService.getShareMembers).not.toHaveBeenCalled(); + }); + + it("should return invitations", async () => { + const invitation = { uid: "invitaiton", addedByEmail: "email" }; + apiService.getShareInvitations = jest.fn().mockResolvedValue([ + invitation, + ]); + + const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [], + members: [], + publicLink: undefined, + }); + expect(cryptoService.decryptInvitation).toHaveBeenCalledWith(invitation); + }); + + it("should return external invitations", async () => { + const externalInvitation = { uid: "external-invitation", addedByEmail: "email" }; + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([ + externalInvitation, + ]); + + const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [externalInvitation], + members: [], + publicLink: undefined, + }); + expect(cryptoService.decryptExternalInvitation).toHaveBeenCalledWith(externalInvitation); + }); + + it("should return members", async () => { + const member = { uid: "member", addedByEmail: "email" }; + apiService.getShareMembers = jest.fn().mockResolvedValue([ + member, + ]); + + const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [], + members: [member], + publicLink: undefined, + }); + expect(cryptoService.decryptMember).toHaveBeenCalledWith(member); + }); + }); + + describe("shareNode", () => { + const nodeUid = "volume:volumeId;node:nodeUid"; + + let invitation: ProtonInvitation; + let externalInvitation: NonProtonInvitation; + let member: Member; + + beforeEach(async () => { + invitation = { + uid: "invitation", + addedByEmail: resultOk("added-email"), + inviteeEmail: "internal-email", + role: MemberRole.Viewer, + invitedDate: new Date(), + }; + externalInvitation = { + uid: "external-invitation", + addedByEmail: resultOk("added-email"), + inviteeEmail: "external-email", + role: MemberRole.Viewer, + invitedDate: new Date(), + state: NonProtonInvitationState.Pending, + }; + member = { + uid: "member", + addedByEmail: resultOk("added-email"), + inviteeEmail: "member-email", + role: MemberRole.Viewer, + invitedDate: new Date(), + }; + + apiService.getShareInvitations = jest.fn().mockResolvedValue([ + invitation, + ]); + + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([ + externalInvitation, + ]); + + apiService.getShareMembers = jest.fn().mockResolvedValue([ + member, + ]); + }); + + describe("invitations", () => { + it("should share node with proton email with default role", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: ["email"] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation, { + uid: "created-invitation", + addedByEmail: { ok: true, value: "volume-email" }, + inviteeEmail: "email", + role: "viewer", + }], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).toHaveBeenCalled(); + }); + + it("should share node with proton email with specific role", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "email", role: MemberRole.Editor }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation, { + uid: "created-invitation", + addedByEmail: { ok: true, value: "volume-email" }, + inviteeEmail: "email", + role: "editor", + }], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).toHaveBeenCalled(); + }); + + it("should update existing role", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "internal-email", role: MemberRole.Editor }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [{ + ...invitation, + role: "editor", + }], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + }); + + it("should be no-op if no change", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "internal-email", role: MemberRole.Viewer }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + }); + }); + + describe("external invitations", () => { + it("should share node with external email with default role", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: ["email"] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation, { + uid: "created-external-invitation", + addedByEmail: { ok: true, value: "volume-email" }, + inviteeEmail: "email", + role: "viewer", + state: "pending", + }], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteExternalUser).toHaveBeenCalled(); + }); + + it("should share node with external email with specific role", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "email", role: MemberRole.Editor }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation, { + uid: "created-external-invitation", + addedByEmail: { ok: true, value: "volume-email" }, + inviteeEmail: "email", + role: "editor", + state: "pending", + }], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteExternalUser).toHaveBeenCalled(); + }); + + it("should update existing role", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "external-email", role: MemberRole.Editor }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [{ + ...externalInvitation, + role: "editor", + }], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateExternalInvitation).toHaveBeenCalled(); + expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); + }); + + it("should be no-op if no change", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "external-email", role: MemberRole.Viewer }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); + }); + }); + + describe("members", () => { + it("should update member via proton user", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "member-email", role: MemberRole.Editor }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [{ + ...member, + role: "editor", + }], + publicLink: undefined, + }); + expect(apiService.updateMember).toHaveBeenCalled(); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + }); + + it("should be no-op if no change via proton user", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "member-email", role: MemberRole.Viewer }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateMember).not.toHaveBeenCalled(); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + }); + + it("should update member via non-proton user", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "member-email", role: MemberRole.Editor }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [{ + ...member, + role: "editor", + }], + publicLink: undefined, + }); + expect(apiService.updateMember).toHaveBeenCalled(); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + }); + + it("should be no-op if no change via non-proton user", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "member-email", role: MemberRole.Viewer }] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateMember).not.toHaveBeenCalled(); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + }); + }); + }); + + describe("unsahreNode", () => { + const nodeUid = "volume:volumeId;node:nodeUid"; + + let invitation: ProtonInvitation; + let externalInvitation: NonProtonInvitation; + let member: Member; + + beforeEach(async () => { + invitation = { + uid: "invitation", + addedByEmail: resultOk("added-email"), + inviteeEmail: "internal-email", + role: MemberRole.Viewer, + invitedDate: new Date(), + }; + externalInvitation = { + uid: "external-invitation", + addedByEmail: resultOk("added-email"), + inviteeEmail: "external-email", + role: MemberRole.Viewer, + invitedDate: new Date(), + state: NonProtonInvitationState.Pending, + }; + member = { + uid: "member", + addedByEmail: resultOk("added-email"), + inviteeEmail: "member-email", + role: MemberRole.Viewer, + invitedDate: new Date(), + }; + + apiService.getShareInvitations = jest.fn().mockResolvedValue([ + invitation, + ]); + + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([ + externalInvitation, + ]); + + apiService.getShareMembers = jest.fn().mockResolvedValue([ + member, + ]); + }); + + it("should delete invitation", async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["internal-email"] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.deleteInvitation).toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + }); + + it("should delete external invitation", async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["external-email"] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [], + members: [member], + publicLink: undefined, + }); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + }); + + it("should remove member", async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["member-email"] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [], + publicLink: undefined, + }); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).toHaveBeenCalled(); + }); + + it("should be no-op if not shared with email", async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["non-existing-email"] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 920be95a..423f22ac 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -1,46 +1,405 @@ -import { ProtonDriveAccount, ShareRole } from "../../interface"; +import { SessionKey } from "../../crypto"; +import { Logger, PublicLink, MemberRole, ShareNodeSettings, UnshareNodeSettings, SharePublicLinkSettings, ShareResult, ProtonInvitation, NonProtonInvitation, Member, resultOk } from "../../interface"; +import { splitNodeUid } from "../uids"; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; +import { SharesService, NodesService } from "./interface"; +interface InternalShareResult extends ShareResult { + share: Share; + nodeName: string; +} + +interface Share { + volumeId: string; + shareId: string; + sessionKey: SessionKey; +} + +interface EmailOptions { + message?: string; + nodeName?: string; +} + +/** + * Provides high-level actions for managing sharing. + * + * The manager is responsible for sharing and unsharing nodes, and providing + * sharing details of nodes. + */ export class SharingManagement { constructor( private apiService: SharingAPIService, private cryptoService: SharingCryptoService, - private account: ProtonDriveAccount, + private sharesService: SharesService, + private nodesService: NodesService, + private log?: Logger, ) { this.apiService = apiService; this.cryptoService = cryptoService; - this.account = account; + this.sharesService = sharesService; + this.nodesService = nodesService; + this.log = log; + } + + async getSharingInfo(nodeUid: string): Promise { + const node = await this.nodesService.getNode(nodeUid); + if (!node.shareId) { + return; + } + + const [ protonInvitations, nonProtonInvitations, members, publicLink ] = await Promise.all([ + Array.fromAsync(this.iterateShareInvitations(node.shareId)), + Array.fromAsync(this.iterateShareExternalInvitations(node.shareId)), + Array.fromAsync(this.iterateShareMembers(node.shareId)), + this.getPublicLink(node.shareId), + ]); + + return { + protonInvitations, + nonProtonInvitations, + members, + publicLink, + } } - async createShare(nodeUid: string): Promise {} - async deleteShare(shareId: string): Promise {} - async getSharingInfo(shareId: string): Promise {} + private async* iterateShareInvitations(shareId: string): AsyncGenerator { + const invitations = await this.apiService.getShareInvitations(shareId); + for (const invitation of invitations) { + yield this.cryptoService.decryptInvitation(invitation); + } + } + + private async* iterateShareExternalInvitations(shareId: string): AsyncGenerator { + const invitations = await this.apiService.getShareExternalInvitations(shareId); + for (const invitation of invitations) { + yield this.cryptoService.decryptExternalInvitation(invitation); + } + } + + private async* iterateShareMembers(shareId: string): AsyncGenerator { + const members = await this.apiService.getShareMembers(shareId); + for (const member of members) { + yield this.cryptoService.decryptMember(member); + } + } - // Direct invitations - async inviteProtonUser(shareId: string, email: string, role: ShareRole): Promise { - const invitation = await this.cryptoService.encryptInvitation(email); - await this.apiService.inviteProtonUser({ invitation }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private async getPublicLink(shareId: string): Promise { + // TODO + return undefined; } - async updateInvitationPermissions(shareId: string, invitationId: string, role: ShareRole): Promise {} - async resendInvitationEmail(shareId: string, invitationId: string): Promise {} - async deleteInvitation(shareId: string, invitationId: string): Promise {} - // Direct external invitations - async inviteExternalUser(shareId: string, email: string, role: ShareRole): Promise {} - async updateExternalInvitationPermissions(shareId: string, invitationId: string, role: ShareRole): Promise {} - async resendExternalInvitationEmail(shareId: string, invitationId: string): Promise {} - async deleteExternalInvitation(shareId: string, invitationId: string): Promise {} + async shareNode(nodeUid: string, settings: ShareNodeSettings): Promise { + let currentSharing = await this.getInternalSharingInfo(nodeUid); + if (!currentSharing) { + const node = await this.nodesService.getNode(nodeUid); + const share = await this.createShare(nodeUid); + currentSharing = { + share, + nodeName: node.name.ok ? node.name.value : node.name.error.name, + protonInvitations: [], + nonProtonInvitations: [], + members: [], + publicLink: undefined, + }; + } + + const emailOptions: EmailOptions = { + message: settings.emailOptions?.message, + nodeName: settings.emailOptions?.includeNodeName ? currentSharing.nodeName : undefined, + } + + for (const user of settings.protonUsers || []) { + const { email, role } = typeof user === "string" + ? { email: user, role: MemberRole.Viewer } + : user; + + const existingInvitation = currentSharing.protonInvitations.find((invitation) => invitation.inviteeEmail === email); + if (existingInvitation) { + if (existingInvitation.role === role) { + this.log?.debug(`Invitation for ${email} already exists with role ${role} to node ${nodeUid}`); + continue; + } + this.log?.debug(`Invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`); + await this.updateInvitation(existingInvitation.uid, role); + existingInvitation.role = role; + continue; + } + + const existingMember = currentSharing.members.find((member) => member.inviteeEmail === email); + if (existingMember) { + if (existingMember.role === role) { + this.log?.debug(`Member ${email} already exists with role ${role} to node ${nodeUid}`); + continue; + } + this.log?.debug(`Member ${email} already exists, updating role to ${role} to node ${nodeUid}`); + await this.updateMember(existingMember.uid, role); + existingMember.role = role; + continue; + } + + this.log?.debug(`Inviting user ${email} with role ${role} to node ${nodeUid}`); + const invitation = await this.inviteProtonUser(currentSharing.share, email, role, emailOptions); + currentSharing.protonInvitations.push(invitation); + } - async convertExternalInvitationsToInternal(): Promise {} + for (const user of settings.nonProtonUsers || []) { + const { email, role } = typeof user === "string" + ? { email: user, role: MemberRole.Viewer } + : user; - // Direct members - async removeMember(shareId: string, memberId: string): Promise {} - async updateMemberPermissions(shareId: string, memberId: string): Promise {} + const existingExternalInvitation = currentSharing.nonProtonInvitations.find((invitation) => invitation.inviteeEmail === email); + if (existingExternalInvitation) { + if (existingExternalInvitation.role === role) { + this.log?.debug(`External invitation for ${email} already exists with role ${role} to node ${nodeUid}`); + continue; + } + this.log?.debug(`External invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`); + await this.updateExternalInvitation(existingExternalInvitation.uid, role); + existingExternalInvitation.role = role; + continue; + } + + const existingMember = currentSharing.members.find((member) => member.inviteeEmail === email); + if (existingMember) { + if (existingMember.role === role) { + this.log?.debug(`Member ${email} already exists with role ${role} to node ${nodeUid}`); + continue; + } + this.log?.debug(`Member ${email} already exists, updating role to ${role} to node ${nodeUid}`); + await this.updateMember(existingMember.uid, role); + existingMember.role = role; + continue; + } + + this.log?.debug(`Inviting external user ${email} with role ${role} to node ${nodeUid}`); + const invitation = await this.inviteExternalUser(currentSharing.share, email, role, emailOptions); + currentSharing.nonProtonInvitations.push(invitation); + } + + if (settings.publicLink) { + const options = settings.publicLink === true + ? { role: MemberRole.Viewer } + : settings.publicLink; + + if (currentSharing.publicLink) { + this.log?.debug(`Updating public link with options ${options} to node ${nodeUid}`); + await this.updateSharedLink(currentSharing.share, options); + } else { + this.log?.debug(`Sharing via public link with options ${options} to node ${nodeUid}`); + await this.shareViaLink(currentSharing.share, options); + } + } + + return { + protonInvitations: currentSharing.protonInvitations, + nonProtonInvitations: currentSharing.nonProtonInvitations, + members: currentSharing.members, + publicLink: currentSharing.publicLink, + }; + } + + async unshareNode(nodeUid: string, settings?: UnshareNodeSettings): Promise { + const currentSharing = await this.getInternalSharingInfo(nodeUid); + if (!currentSharing) { + return; + } + + if (!settings) { + this.log?.debug(`Unsharing node ${nodeUid}`); + await this.deleteShare(currentSharing.share.shareId); + return; + } + + for (const userEmail of settings.users || []) { + const existingInvitation = currentSharing.protonInvitations.find((invitation) => invitation.inviteeEmail === userEmail); + if (existingInvitation) { + this.log?.debug(`Deleting invitation for ${userEmail} to node ${nodeUid}`); + await this.deleteInvitation(existingInvitation.uid); + currentSharing.protonInvitations = currentSharing.protonInvitations.filter((invitation) => invitation.uid !== existingInvitation.uid); + continue; + } + + const existingExternalInvitation = currentSharing.nonProtonInvitations.find((invitation) => invitation.inviteeEmail === userEmail); + if (existingExternalInvitation) { + this.log?.debug(`Deleting external invitation for ${userEmail} to node ${nodeUid}`); + await this.deleteExternalInvitation(existingExternalInvitation.uid); + currentSharing.nonProtonInvitations = currentSharing.nonProtonInvitations.filter((invitation) => invitation.uid !== existingExternalInvitation.uid); + continue; + } + + const existingMember = currentSharing.members.find((member) => member.inviteeEmail === userEmail); + if (existingMember) { + this.log?.debug(`Removing member ${userEmail} to node ${nodeUid}`); + await this.removeMember(existingMember.uid); + currentSharing.members = currentSharing.members.filter((member) => member.uid !== existingMember.uid); + continue; + } + + this.log?.debug(`User ${userEmail} not found in sharing info for node ${nodeUid}`); + } + + if (settings.publicLink === 'remove') { + this.log?.debug(`Removing public link to node ${nodeUid}`); + await this.removeSharedLink(currentSharing.share); + currentSharing.publicLink = undefined; + } + + return { + protonInvitations: currentSharing.protonInvitations, + nonProtonInvitations: currentSharing.nonProtonInvitations, + members: currentSharing.members, + publicLink: currentSharing.publicLink, + }; + } + + private async getInternalSharingInfo(nodeUid: string): Promise { + const node = await this.nodesService.getNode(nodeUid); + if (!node.shareId) { + return; + } + const sharingInfo = await this.getSharingInfo(nodeUid); + if (!sharingInfo) { + return; + } + + const { volumeId } = splitNodeUid(nodeUid); + const { key: nodeKey } = await this.nodesService.getNodeKeys(nodeUid); + const encryptedShare = await this.sharesService.loadEncryptedShare(node.shareId); + const { sessionKey } = await this.cryptoService.decryptShare(encryptedShare, nodeKey); + + return { + ...sharingInfo, + share: { + volumeId, + shareId: node.shareId, + sessionKey: sessionKey, + }, + nodeName: node.name.ok ? node.name.value : node.name.error.name, + } + } - // For URL - async shareViaLink(nodeUid: string): Promise {} - async updateSharedLink(nodeUid: string, options: any): Promise {} - async getPublicLink(nodeUid: string): Promise {} - async removeSharedLink(nodeUid: string): Promise {} + // TODO: update nodes cache with new shareId + private async createShare(nodeUid: string): Promise { + const node = await this.nodesService.getNode(nodeUid); + if (!node.parentUid) { + throw new Error("Cannot share root node"); + } + + const { volumeId } = splitNodeUid(nodeUid); + const { addressId, addressKey } = await this.sharesService.getVolumeEmailKey(volumeId); + + const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(nodeUid); + const keys = await this.cryptoService.generateShareKeys(nodeKeys, addressKey); + const shareId = await this.apiService.createStandardShare( + nodeUid, + addressId, + keys.shareKey.encrypted, + { + base64PassphraseKeyPacket: keys.base64PpassphraseKeyPacket, + base64NameKeyPacket: keys.base64NameKeyPacket, + }, + ); + + return { + volumeId, + shareId, + sessionKey: keys.shareKey.decrypted.sessionKey, + } + } + + // TODO: update nodes cache with deleted shareId + private async deleteShare(shareId: string): Promise { + await this.apiService.deleteShare(shareId); + } + + private async inviteProtonUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { + const inviter = await this.sharesService.getVolumeEmailKey(share.volumeId); + const invitationCrypto = await this.cryptoService.encryptInvitation(share.sessionKey, inviter.addressKey, inviteeEmail); + + const encryptedInvitation = await this.apiService.inviteProtonUser(share.shareId, { + addedByEmail: inviter.email, + inviteeEmail: inviteeEmail, + role, + ...invitationCrypto, + }, emailOptions); + + return { + ...encryptedInvitation, + addedByEmail: resultOk(encryptedInvitation.addedByEmail), + }; + } + + private async updateInvitation(invitationUid: string, role: MemberRole): Promise { + await this.apiService.updateInvitation(invitationUid, { role }); + } + + async resendInvitationEmail(invitationUid: string): Promise { + await this.apiService.resendInvitationEmail(invitationUid); + } + + private async deleteInvitation(invitationUid: string): Promise { + await this.apiService.deleteInvitation(invitationUid); + } + + private async inviteExternalUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { + const inviter = await this.sharesService.getVolumeEmailKey(share.volumeId); + const invitationCrypto = await this.cryptoService.encryptExternalInvitation(share.sessionKey, inviter.addressKey, inviteeEmail); + + const encryptedInvitation = await this.apiService.inviteExternalUser(share.shareId, { + inviterAddressId: inviter.addressId, + inviteeEmail: inviteeEmail, + role, + base64Signature: invitationCrypto.base64ExternalInvitationSignature, + }, emailOptions); + + return { + uid: encryptedInvitation.uid, + invitedDate: encryptedInvitation.invitedDate, + addedByEmail: resultOk(inviter.email), + inviteeEmail, + role, + state: encryptedInvitation.state, + }; + } + + private async updateExternalInvitation(invitationUid: string, role: MemberRole): Promise { + await this.apiService.updateExternalInvitation(invitationUid, { role }); + } + + async resendExternalInvitationEmail(invitationUid: string): Promise { + await this.apiService.resendExternalInvitationEmail(invitationUid); + } + + private async deleteExternalInvitation(invitationUid: string): Promise { + await this.apiService.deleteExternalInvitation(invitationUid); + } + + private async convertExternalInvitationsToInternal(): Promise { + // TODO + } + + private async removeMember(memberUid: string): Promise { + await this.apiService.removeMember(memberUid); + } + + private async updateMember(memberUid: string, role: MemberRole): Promise { + await this.apiService.updateMember(memberUid, { role }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private async shareViaLink(share: Share, options: SharePublicLinkSettings): Promise { + // TODO + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private async updateSharedLink(share: Share, options: SharePublicLinkSettings): Promise { + // TODO + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private async removeSharedLink(share: Share): Promise { + // TODO + } } diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index 7a8cad1b..00ef0dce 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -12,8 +12,32 @@ export function splitNodeUid(nodeUid: string) { }; } -export function makeInvitationUid(volumeId: string, invitationId: string) { - return `volume:${volumeId};invitation:${invitationId}`; +export function makeInvitationUid(shareId: string, invitationId: string) { + // TODO: format of UID + return `share:${shareId};invitation:${invitationId}`; +} + +export function splitInvitationUid(invitationUid: string) { + // TODO: validation + const [ shareId, invitationId ] = invitationUid.split(';'); + return { + shareId: shareId.slice('share:'.length), + invitationId: invitationId.slice('invitation:'.length), + }; +} + +export function makeMemberUid(shareId: string, memberId: string) { + // TODO: format of UID + return `share:${shareId};member:${memberId}`; +} + +export function splitMemberUid(memberUid: string) { + // TODO: validation + const [ shareId, memberId ] = memberUid.split(';'); + return { + shareId: shareId.slice('share:'.length), + memberId: memberId.slice('member:'.length), + }; } export function makeNodeRevisionUid(volumeId: string, nodeUid: string, revisionId: string) { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 16d057f5..6dbe1bc5 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,5 +1,5 @@ import { DriveAPIService } from './internal/apiService'; -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, ShareNodeSettings, UploadMetadata } from './interface'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, ShareNodeSettings, UnshareNodeSettings, UploadMetadata } from './interface'; import { DriveCrypto } from './crypto'; import { initSharesModule } from './internal/shares'; import { initNodesModule } from './internal/nodes'; @@ -107,9 +107,33 @@ export class ProtonDriveClient implements Partial { async* iterateSharedNodesWithMe(signal?: AbortSignal) { yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodesWithMe(signal)); } + + async removeSharedNodeWithMe(nodeUid: NodeOrUid) { + await this.sharing.access.removeSharedNodeWithMe(getUid(nodeUid)); + } + + async* iterateInvitations(signal?: AbortSignal) { + yield* this.sharing.access.iterateInvitations(signal); + } + + async acceptInvitation(invitationId: string) { + await this.sharing.access.acceptInvitation(invitationId); + } + + async rejectInvitation(invitationId: string) { + await this.sharing.access.rejectInvitation(invitationId); + } + + async getSharingInfo(nodeUid: NodeOrUid) { + return this.sharing.management.getSharingInfo(getUid(nodeUid)); + } async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings) { - return this.sharing.shareNode(getUid(nodeUid), settings); + return this.sharing.management.shareNode(getUid(nodeUid), settings); + } + + async unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings) { + return this.sharing.management.unshareNode(getUid(nodeUid), settings); } async getFileUploader(nodeUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) { From 7c927f46c884a97b14afdb6833e68f0990badd0d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 3 Mar 2025 15:32:58 +0100 Subject: [PATCH 030/791] add API services for upload and download --- js/sdk/.eslintrc.js | 1 - js/sdk/src/crypto/index.ts | 1 + js/sdk/src/interface/download.ts | 6 +- js/sdk/src/internal/download/apiService.ts | 43 ++++ js/sdk/src/internal/download/cryptoService.ts | 10 + .../src/internal/download/fileDownloader.ts | 63 +++++ js/sdk/src/internal/download/index.ts | 27 +++ js/sdk/src/internal/download/interface.ts | 7 + js/sdk/src/internal/upload/apiService.ts | 218 +++++++++++++++++- js/sdk/src/internal/upload/cryptoService.ts | 18 +- js/sdk/src/internal/upload/fileUploader.ts | 10 +- js/sdk/src/internal/upload/index.ts | 34 +-- js/sdk/src/internal/upload/interface.ts | 6 +- js/sdk/src/internal/upload/queue.ts | 1 + js/sdk/src/protonDriveClient.ts | 13 +- 15 files changed, 417 insertions(+), 41 deletions(-) create mode 100644 js/sdk/src/internal/download/apiService.ts create mode 100644 js/sdk/src/internal/download/cryptoService.ts create mode 100644 js/sdk/src/internal/download/fileDownloader.ts create mode 100644 js/sdk/src/internal/download/index.ts create mode 100644 js/sdk/src/internal/download/interface.ts diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index 07c237fc..a9b752d9 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -16,7 +16,6 @@ module.exports = { { files: [ "*.test.ts", - "**/upload/**/*", "**/photos/**/*", ], rules: { diff --git a/js/sdk/src/crypto/index.ts b/js/sdk/src/crypto/index.ts index 9943ad5d..c5072ec9 100644 --- a/js/sdk/src/crypto/index.ts +++ b/js/sdk/src/crypto/index.ts @@ -2,3 +2,4 @@ export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interfa export { VERIFICATION_STATUS } from './interface'; export { DriveCrypto } from './driveCrypto'; export { OpenPGPCryptoWithCryptoProxy } from './openPGPCrypto'; +export { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index 98dcb2d8..66ebb5e4 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -11,9 +11,9 @@ export interface Download { } export interface FileDownloader { - getClaimedSizeInBytes(): number, - writeToStream(streamFactory: WritableStream, onProgress: (uploadedBytes: number) => void): DownloadController, - unsafeWriteToStream(streamFactory: WritableStream, onProgress: (uploadedBytes: number) => void): DownloadController, + getClaimedSizeInBytes(): number | undefined, + writeToStream(streamFactory: WritableStream, onProgress: (writtenBytes: number) => void): DownloadController, + unsafeWriteToStream(streamFactory: WritableStream, onProgress: (writtenBytes: number) => void): DownloadController, } export interface DownloadController { diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts new file mode 100644 index 00000000..59b7c970 --- /dev/null +++ b/js/sdk/src/internal/download/apiService.ts @@ -0,0 +1,43 @@ +import { DriveAPIService, drivePaths } from "../apiService"; +import { splitNodeRevisionUid } from "../uids"; + +const BLOCKS_PAGE_SIZE = 50; + +type GetRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; + +export class DownloadAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; + } + + async* iterateRevisionBlocks(nodeRevisionUid: string, signal?: AbortSignal): AsyncGenerator<{ + bareUrl: string, + index: number, + hash: string, + token: string, + }> { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + let fromBlockIndex = 1; + while (true) { + const result = await this.apiService.get( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?PageSize=${BLOCKS_PAGE_SIZE}&FromBlockIndex=${fromBlockIndex}`, + signal, + ); + + if (result.Revision.Blocks.length === 0) { + break; + } + + for (const block of result.Revision.Blocks) { + yield { + bareUrl: block.BareURL as string, + index: block.Index, + hash: block.Hash, + token: block.Token as string, + }; + fromBlockIndex = block.Index + 1; + } + } + } +} diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts new file mode 100644 index 00000000..2e0caadb --- /dev/null +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -0,0 +1,10 @@ +import { DriveCrypto } from "../../crypto"; + +export class DownloadCryptoService { + constructor(private driveCrypto: DriveCrypto) { + this.driveCrypto = driveCrypto; + } + + async decryptBlock() { + } +} diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts new file mode 100644 index 00000000..fe747fe5 --- /dev/null +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -0,0 +1,63 @@ +import { PrivateKey } from "../../crypto"; +import { NodeEntity } from "../../interface"; +import { DownloadAPIService } from "./apiService"; +import { DownloadCryptoService } from "./cryptoService"; + +export class FileDownloader { + constructor( + private apiService: DownloadAPIService, + private cryptoService: DownloadCryptoService, + private nodeKey: PrivateKey, + private node: NodeEntity, + private signal?: AbortSignal, + ) { + this.apiService = apiService; + this.cryptoService = cryptoService; + this.nodeKey = nodeKey; + this.node = node; + this.signal = signal; + } + + getClaimedSizeInBytes(): number | undefined { + if (this.node.activeRevision?.ok) { + return this.node.activeRevision.value.claimedSize; + } + } + + writeToStream(stream: WritableStream, onProgress: (writtenBytes: number) => void): DownloadController { + const controller = new DownloadController(); + void this.downloadToStream(controller, stream, onProgress); + return controller; + } + + unsafeWriteToStream(stream: WritableStream, onProgress: (writtenBytes: number) => void): DownloadController { + const controller = new DownloadController(); + void this.downloadToStream(controller, stream, onProgress); + return controller; + } + + private async downloadToStream(controller: DownloadController, stream: WritableStream, onProgress: (writtenBytes: number) => void): Promise { + if (!this.node.activeRevision?.ok || !this.node.activeRevision.value) { + throw new Error("Node has no active revision"); + } + + // TODO + const nodeRevisionsUid = this.node.activeRevision.value.uid; + const writer = stream.getWriter(); + for await (const block of this.apiService.iterateRevisionBlocks(nodeRevisionsUid, this.signal)) { + await writer.write(block.bareUrl); + onProgress(block.bareUrl.length); + } + } +} + +class DownloadController { + async pause(): Promise { + } + + async resume(): Promise { + } + + async completion(): Promise { + } +} diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts new file mode 100644 index 00000000..b204fa26 --- /dev/null +++ b/js/sdk/src/internal/download/index.ts @@ -0,0 +1,27 @@ +import { DriveCrypto } from "../../crypto"; +import { DriveAPIService } from "../apiService"; +import { DownloadAPIService } from "./apiService"; +import { DownloadCryptoService } from "./cryptoService"; +import { NodesService } from "./interface"; +import { FileDownloader } from "./fileDownloader"; + +export function initDownloadModule( + apiService: DriveAPIService, + driveCrypto: DriveCrypto, + nodesService: NodesService, +) { + const api = new DownloadAPIService(apiService); + const cryptoService = new DownloadCryptoService(driveCrypto); + + async function getFileDownloader(nodeUid: string, signal?: AbortSignal) { + const { key } = await nodesService.getNodeKeys(nodeUid); + const node = await nodesService.getNode(nodeUid); + return new FileDownloader(api, cryptoService, key, node, signal); + } + + return { + getFileDownloader, + } +} + + diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts new file mode 100644 index 00000000..a101d4c3 --- /dev/null +++ b/js/sdk/src/internal/download/interface.ts @@ -0,0 +1,7 @@ +import { PrivateKey, SessionKey } from "../../crypto"; +import { NodeEntity } from "../../interface"; + +export interface NodesService { + getNode(nodeUid: string): Promise, + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, sessionKey: SessionKey }>, +} diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index c0f85956..e75d3570 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -1,10 +1,218 @@ -import { DriveAPIService } from "../apiService/index.js"; +import { base64StringToUint8Array, uint8ArrayToBase64String } from "../../crypto"; +import { DriveAPIService, drivePaths } from "../apiService"; +import { splitNodeUid, makeNodeUid, splitNodeRevisionUid, makeNodeRevisionUid } from "../uids"; -export function uploadAPIService(apiService: DriveAPIService) { - async function createDraft(parentNodeUid: string, name: string): Promise { +type PostCheckAvailableHashesRequest = Extract['content']['application/json']; +type PostCheckAvailableHashesResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['responses']['200']['content']['application/json']; + +type PostCreateDraftRequest = Extract['content']['application/json']; +type PostCreateDraftResponse = drivePaths['/drive/v2/volumes/{volumeID}/files']['post']['responses']['200']['content']['application/json']; + +type PostCreateDraftRevisionRequest = Extract['content']['application/json']; +type PostCreateDraftRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions']['post']['responses']['200']['content']['application/json']; + +type GetVerificationDataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification']['get']['responses']['200']['content']['application/json']; + +type PostRequestBlockUploadRequest = Extract['content']['application/json']; +type PostRequestBlockUploadResponse = drivePaths['/drive/blocks']['post']['responses']['200']['content']['application/json']; + +type PostCommitRevisionRequest = Extract['content']['application/json']; +type PostCommitRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['put']['responses']['200']['content']['application/json']; + +export class UploadAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; + } + + async checkAvailableHashes(nodeUid: string, hashes: string[]): Promise<{ + availalbleHashes: string[], + pendingHashes: { + hash: string, + revisionUid: string, + clientUid?: string, + }[], + }> { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const result = await this.apiService.post< + PostCheckAvailableHashesRequest, + PostCheckAvailableHashesResponse + >(`drive/v2/volumes/${volumeId}/links/${nodeId}/checkAvailableHashes`, { + Hashes: hashes, + ClientUID: null, + }); + + return { + availalbleHashes: result.AvailableHashes, + pendingHashes: result.PendingHashes.map((hash) => ({ + hash: hash.Hash, + revisionUid: makeNodeRevisionUid(volumeId, hash.LinkID, hash.RevisionID), + clientUid: hash.ClientUID || undefined, + })), + } + } + + async createDraft(parentNodeUid: string, node: { + armoredEncryptedName: string, + hash: string, + mimeType: string, + clientUID?: string, + intendedUploadSize?: number, + armoredNodeKey: string, + armoredNodePassphrase: string, + armoredNodePassphraseSignature: string, + armoredContentKeyPacket: string, + armoredContentKeyPacketSignature: string, + signatureEmail: string, + }): Promise<{ + nodeUid: string, + nodeRevisionUid: string, + }> { + const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); + const result = await this.apiService.post< + PostCreateDraftRequest, + PostCreateDraftResponse + >(`drive/v2/volumes/${volumeId}/files`, { + ParentLinkID: parentNodeId, + Name: node.armoredEncryptedName, + Hash: node.hash, + MIMEType: node.mimeType, + ClientUID: node.clientUID || null, + IntendedUploadSize: node.intendedUploadSize || null, + NodeKey: node.armoredNodeKey, + NodePassphrase: node.armoredNodePassphrase, + NodePassphraseSignature: node.armoredNodePassphraseSignature, + ContentKeyPacket: node.armoredContentKeyPacket, + ContentKeyPacketSignature: node.armoredContentKeyPacketSignature, + SignatureAddress: node.signatureEmail, + }); + + return { + nodeUid: makeNodeUid(volumeId, result.File.ID), + nodeRevisionUid: makeNodeRevisionUid(volumeId, result.File.ID, result.File.RevisionID), + } + } + + async createDraftRevision(nodeUid: string, revision: { + currentRevisionUid: string, + clientUID?: string, + intendedUploadSize?: number, + }): Promise<{ + nodeRevisionsUid: string, + }> { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const { revisionId: currentRevisionId } = splitNodeRevisionUid(revision.currentRevisionUid); + + const result = await this.apiService.post< + PostCreateDraftRevisionRequest, + PostCreateDraftRevisionResponse + >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, { + CurrentRevisionID: currentRevisionId, + ClientUID: revision.clientUID || null, + IntendedUploadSize: revision.intendedUploadSize || null, + }); + + return { + nodeRevisionsUid: makeNodeRevisionUid(volumeId, nodeId, result.Revision.ID), + } + } + + async getVerificationData(draftNodeRevisionUid: string): Promise<{ + verificationCode: Uint8Array, + }> { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + const result = await this.apiService.get< + GetVerificationDataResponse + >(`drive/v2/volumes/${volumeId}/links/${nodeId}/revisions/${revisionId}/verification`); + + return { + verificationCode: base64StringToUint8Array(result.VerificationCode), + } + } + + async requestBlockUpload(draftNodeRevisionUid: string, addressId: string, blocks: { + content: { + index: number, + hash: Uint8Array, + armoredSignature: string, + size: number, + verificationToken: Uint8Array, + }[], + thumbnail: { + hash: Uint8Array, + size: number, + type: 1 | 2, + }[], + }): Promise<{ + blockTokens: { + barUrl: string, + index: number, + token: string, + }[], + thumbnailTokens: { + bareUrl: string, + token: string, + }[], + }> { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + const result = await this.apiService.post< + // FIXME: Deprected fields but not properly marked in the types. + Omit, + PostRequestBlockUploadResponse + >('drive/blocks', { + AddressID: addressId, + LinkID: nodeId, + ShareID: volumeId, // TODO!!! + RevisionID: revisionId, + BlockList: blocks.content.map((block) => ({ + Index: block.index, + Hash: uint8ArrayToBase64String(block.hash), + EncSignature: block.armoredSignature, + Size: block.size, + Verifier: { + Token: uint8ArrayToBase64String(block.verificationToken), + }, + })), + ThumbnailList: blocks.thumbnail.map((block) => ({ + Hash: uint8ArrayToBase64String(block.hash), + Size: block.size, + Type: block.type, + })), + }); + + return { + blockTokens: result.UploadLinks.map((link) => ({ + barUrl: link.BareURL, + index: link.Index, + token: link.Token, + })), + thumbnailTokens: (result.ThumbnailLinks || []).map((link) => ({ + bareUrl: link.BareURL, + thumbnailType: link.ThumbnailType, + token: link.Token, + })), + }; + } + + async commitDraftRevision(draftNodeRevisionUid: string, options: { + armoredManifestSignature: string, + signatureEmail: string, + armoredEncryptedExtendedAttributes: string, + }): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + await this.apiService.put< + // FIXME: Deprected fields but not properly marked in the types. + Omit, + PostCommitRevisionResponse + >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, { + ManifestSignature: options.armoredManifestSignature, + SignatureAddress: options.signatureEmail, + XAttr: options.armoredEncryptedExtendedAttributes, + Photo: null, // TODO + }); } - return { - createDraft, + async deleteDraftRevision(draftNodeRevisionUid: string): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + this.apiService.delete(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); } } diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 8850b477..8f9e3198 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -1,14 +1,14 @@ -import { DriveCrypto } from "../../crypto/index.js"; +import { DriveCrypto, PrivateKey } from "../../crypto"; -export function uploadCryptoService(driveCrypto: DriveCrypto) { - // TODO: types - async function generateKeys(parentKey: any) { - }; +export class UploadCryptoService { + constructor(private driveCrypto: DriveCrypto) { + this.driveCrypto = driveCrypto; + } - async function generateHash() { + async generateKey(parentKey: PrivateKey) { + return this.driveCrypto.generateKey(parentKey, parentKey); }; - return { - generateKeys, - } + private async generateHash() { + }; } diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index c9364a79..b932e2b1 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -1,16 +1,20 @@ -import { Thumbnail } from "../../interface/index.js"; +import { PrivateKey } from "../../crypto"; +import { Thumbnail } from "../../interface"; export class Fileuploader { private controller: UploadController; - constructor(queue: any, nodeKey: any, draft: any) { - this.controller = new UploadController(draft.nodeUid); + constructor(nodeKey: PrivateKey, draftNodeRevisionUid: string) { + this.controller = new UploadController(draftNodeRevisionUid); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController { // TODO return this.controller; } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController { // TODO return this.controller; diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 0dbc511c..78a03e2b 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -1,10 +1,10 @@ -import { DriveAPIService } from "../apiService/index.js"; -import { DriveCrypto } from "../../crypto/index.js"; -import { uploadAPIService } from "./apiService.js"; -import { uploadCryptoService } from "./cryptoService.js"; -import { UploadQueue } from "./queue.js"; -import { NodesService } from "./interface.js"; -import { Fileuploader } from "./fileUploader.js"; +import { DriveAPIService } from "../apiService"; +import { DriveCrypto } from "../../crypto"; +import { UploadAPIService } from "./apiService"; +import { UploadCryptoService } from "./cryptoService"; +import { UploadQueue } from "./queue"; +import { NodesService } from "./interface"; +import { Fileuploader } from "./fileUploader"; type UploadMetadata = { mimeType: string, @@ -12,13 +12,13 @@ type UploadMetadata = { additionalMetadata?: object, } -export function upload( +export function initUploadModule( apiService: DriveAPIService, driveCrypto: DriveCrypto, nodesService: NodesService, ) { - const api = uploadAPIService(apiService); - const cryptoService = uploadCryptoService(driveCrypto); + const api = new UploadAPIService(apiService); + const cryptoService = new UploadCryptoService(driveCrypto); const queue = new UploadQueue(); @@ -29,16 +29,18 @@ export function upload( signal?: AbortSignal ) { await queue.waitForCapacity(metadata.expectedSize, signal); - const parentKey = nodesService.getNodeKeys(parentFolderUid); - const nodeKeys = cryptoService.generateKeys(parentKey); + const parentKey = await nodesService.getNodeKeys(parentFolderUid); + const nodeKey = await cryptoService.generateKey(parentKey); // TODO: encrypt name etc. - const draft = api.createDraft(parentFolderUid, name); - return new Fileuploader(queue, nodeKeys, draft); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nodeData: any = { + name, + } + const { nodeRevisionUid } = await api.createDraft(parentFolderUid, nodeData); + return new Fileuploader(nodeKey, nodeRevisionUid); } return { getFileUploader, } } - - diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index d9960a33..a101d4c3 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,3 +1,7 @@ +import { PrivateKey, SessionKey } from "../../crypto"; +import { NodeEntity } from "../../interface"; + export interface NodesService { - getNodeKeys(nodeUid: string): Promise, + getNode(nodeUid: string): Promise, + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, sessionKey: SessionKey }>, } diff --git a/js/sdk/src/internal/upload/queue.ts b/js/sdk/src/internal/upload/queue.ts index 230886f3..159a283e 100644 --- a/js/sdk/src/internal/upload/queue.ts +++ b/js/sdk/src/internal/upload/queue.ts @@ -2,6 +2,7 @@ export class UploadQueue { constructor() { } + // eslint-disable-next-line @typescript-eslint/no-unused-vars async waitForCapacity(expectedSize: number, signal?: AbortSignal) { } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 6dbe1bc5..099d9d18 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -4,15 +4,17 @@ import { DriveCrypto } from './crypto'; import { initSharesModule } from './internal/shares'; import { initNodesModule } from './internal/nodes'; import { initSharingModule } from './internal/sharing'; +import { initDownloadModule } from './internal/download'; +import { initUploadModule } from './internal/upload'; import { DriveEventsService } from './internal/events'; -import { upload as uploadModule } from './internal/upload'; import { getConfig } from './config'; import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; export class ProtonDriveClient implements Partial { private nodes: ReturnType; private sharing: ReturnType; - private upload: ReturnType; + private download: ReturnType; + private upload: ReturnType; constructor({ httpClient, @@ -39,7 +41,8 @@ export class ProtonDriveClient implements Partial { const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); this.sharing = initSharingModule(apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access, getLogger?.('sharing')); - this.upload = uploadModule(apiService, cryptoModule, this.nodes.access); + this.download = initDownloadModule(apiService, cryptoModule, this.nodes.access); + this.upload = initUploadModule(apiService, cryptoModule, this.nodes.access); } // TODO @@ -136,6 +139,10 @@ export class ProtonDriveClient implements Partial { return this.sharing.management.unshareNode(getUid(nodeUid), settings); } + async getFileDownloader(nodeUid: NodeOrUid, signal?: AbortSignal) { + return this.download.getFileDownloader(getUid(nodeUid), signal); + } + async getFileUploader(nodeUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) { return this.upload.getFileUploader(getUid(nodeUid), name, metadata, signal); } From cd6170ee9c0770d033e1c49cd701fc0e83022ee4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 5 Mar 2025 08:49:36 +0100 Subject: [PATCH 031/791] fix nodes management --- js/sdk/src/internal/apiService/apiService.ts | 4 ++-- js/sdk/src/internal/apiService/errorCodes.ts | 5 +++++ js/sdk/src/internal/apiService/index.ts | 2 +- js/sdk/src/internal/nodes/apiService.test.ts | 2 +- js/sdk/src/internal/nodes/apiService.ts | 9 ++++----- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 1342af95..4cb71622 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -1,5 +1,5 @@ import { ProtonDriveHTTPClient, Logger } from "../../interface/index.js"; -import { HTTPErrorCode, ErrorCode } from './errorCodes'; +import { HTTPErrorCode, isCodeOk } from './errorCodes'; import { apiErrorFactory, AbortError, APIError } from './errors'; import { waitSeconds } from './wait'; @@ -190,7 +190,7 @@ export class DriveAPIService { try { const result = await response.json(); - if (!response.ok || result.Code !== ErrorCode.OK) { + if (!response.ok || !isCodeOk(result.Code)) { throw apiErrorFactory({ response, result }); } return result as ResponsePayload; diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index f7061d63..cb6ffc12 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -4,9 +4,14 @@ export const enum HTTPErrorCode { INTERNAL_SERVER_ERROR = 500, } +export function isCodeOk(code: number): boolean { + return code === ErrorCode.OK || code === ErrorCode.OK_MANY || code === ErrorCode.OK_ASYNC; +} + export const enum ErrorCode { NOT_FOUND = 404, OK = 1000, OK_MANY = 1001, + OK_ASYNC = 1002, NOT_EXISTS = 2501, } diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 7d8630cc..c4d68a76 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -1,6 +1,6 @@ export { DriveAPIService } from './apiService'; export { paths as drivePaths } from './driveTypes'; export { paths as corePaths } from './coreTypes'; -export { ErrorCode } from './errorCodes'; +export { ErrorCode, isCodeOk } from './errorCodes'; export { nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from './transformers'; export * from './errors'; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 457a410a..722001b0 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -284,7 +284,7 @@ describe("nodeAPIService", () => { expect(result).toEqual([ { uid: 'volume:volumeId;node:nodeId1', ok: true }, { uid: 'volume:volumeId;node:nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, - { uid: 'volume:volumeId;node:nodeId3', ok: false, error: 'Unknown error' }, + { uid: 'volume:volumeId;node:nodeId3', ok: false, error: 'Unknown error 2000' }, ]); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 987fe5a0..e07a7a4b 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,6 +1,6 @@ import { Logger, NodeResult } from "../../interface"; import { RevisionState } from "../../interface/nodes"; -import { DriveAPIService, drivePaths, ErrorCode, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; +import { DriveAPIService, drivePaths, isCodeOk, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid } from "../uids"; import { EncryptedNode, EncryptedRevision } from "./interface"; @@ -318,7 +318,7 @@ export class NodeAPIService { XAttr: newNode.encryptedExtendedAttributes, }); - return response.Folder.ID; + return makeNodeUid(volumeId, response.Folder.ID); } async getRevisions(nodeUid: string, signal?: AbortSignal): Promise { @@ -373,10 +373,9 @@ function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: const errors = new Map(); responses.forEach((response) => { - const okResponse = response.Response.Code === ErrorCode.OK || response.Response.Code === ErrorCode.OK_MANY; - if (!okResponse || response.Response.Error) { + if (!response.Response.Code || !isCodeOk(response.Response.Code) || response.Response.Error) { const nodeUid = makeNodeUid(volumeId, response.LinkID); - errors.set(nodeUid, response.Response.Error || 'Unknown error'); + errors.set(nodeUid, response.Response.Error || `Unknown error ${response.Response.Code}`); } }); From dce6de79ccfa6acb736f11712bebc70deec68e23 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 14 Feb 2025 11:34:53 +0100 Subject: [PATCH 032/791] propose telemetry --- js/sdk/src/telemetry.ts | 303 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 js/sdk/src/telemetry.ts diff --git a/js/sdk/src/telemetry.ts b/js/sdk/src/telemetry.ts new file mode 100644 index 00000000..284e440c --- /dev/null +++ b/js/sdk/src/telemetry.ts @@ -0,0 +1,303 @@ +export interface LogRecord { + time: Date, + level: LogLevel; + loggerName: string; + message: string; + error?: Error; +} + +export enum LogLevel { + DEBUG = 'DEBUG', + INFO = 'INFO', + WARNING = 'WARNING', + ERROR = 'ERROR', +} + +export interface LogFormatter { + format(log: LogRecord): string; +} + +export interface LogHandler { + log(log: LogRecord): void; +} + +export interface MetricRecord { + time: Date, + event: T; +} + +type MetricEvent = { + name: string; +} + +export interface MetricHandler { + onEvent(metric: MetricRecord): void; +} + +/** + * Telemetry class that logs messages and metrics. + * + * Example: + * + * ```typescript + * const memoryLogHandler = new MemoryLogHandler(); + * + * interface MetricEvents = { + * name: string, + * value: number, + * } + * class OwnMetricHandler implements MetricHandler { + * onEvent(metric: MetricRecord) { + * // Process metric event + * } + * } + * + * const telemetry = new Telemetry({ + * // Enable debug logging + * logFilter: new LogFilter({ level: LogLevel.DEBUG }), + * // Log to console and memory + * logHandlers: [new ConsoleLogHandler(), memoryLogHandler], + * // Log to console and own handler to process further + * metricHandlers: [new ConsoleMetricHandler(), ownMetricHandler], + * }); + * + * const logger = telemetry.getLogger('myLogger'); + * logger.debug('Debug message'); + * + * telemetry.logEvent({ name: 'somethingHappened', value: 42 }); + * + * const logs = memoryLogHandler.getLogs(); + * // Process logs + * ``` + * + * @param logFilter - Log filter to filter logs based on log level, default INFO + * @param logHandlers - Log handlers to use for logging, see LogHandler implementations + * @param metricHandlers - Metric handlers to use for logging, see MetricHandler implementations + */ +export class Telemetry { + private logFilter: LogFilter; + private logHandlers: LogHandler[]; + private metricHandlers: MetricHandler[]; + + constructor( + options?: { + logFilter?: LogFilter, + logHandlers?: LogHandler[], + metricHandlers?: MetricHandler[], + } + ) { + this.logFilter = options?.logFilter || new LogFilter(); + this.logHandlers = options?.logHandlers || [new ConsoleLogHandler()]; + this.metricHandlers = options?.metricHandlers || [new ConsoleMetricHandler()]; + } + + getLogger(name: string): Logger { + return new Logger(name, this.logFilter, this.logHandlers); + } + + logEvent(event: T): void { + const metric = { + time: new Date(), + event, + }; + this.metricHandlers.forEach(handler => handler.onEvent(metric)); + } +} + +/** + * Logger class that logs messages with different levels. + * + * @param name - Name of the logger + * @param handlers - Log handlers to use for logging, see LogHandler implementations + */ +class Logger { + constructor(private name: string, private filter: LogFilter, private handlers: LogHandler[]) { + this.name = name; + this.filter = filter; + this.handlers = handlers; + } + + debug(message: string) { + this.log({ + time: new Date(), + level: LogLevel.DEBUG, + loggerName: this.name, + message, + }); + } + + info(message: string) { + this.log({ + time: new Date(), + level: LogLevel.INFO, + loggerName: this.name, + message, + }); + } + + warning(message: string) { + this.log({ + time: new Date(), + level: LogLevel.WARNING, + loggerName: this.name, + message, + }); + } + + error(message: string, error?: Error) { + this.log({ + time: new Date(), + level: LogLevel.ERROR, + loggerName: this.name, + message, + error, + }); + } + + private log(log: LogRecord) { + if (!this.filter.filter(log)) { + return; + } + this.handlers.forEach(handler => handler.log(log)); + } +} + +/** + * Filter logs based on log level. It can be configured by global level or + * per logger level. + * + * @param globalLevel - Global log level, default INFO + * @param loggerLevels - Log levels for specific loggers, default empty + */ +export class LogFilter { + private logLevelMap = { + 'DEBUG': 0, + 'INFO': 1, + 'WARNING': 2, + 'ERROR': 3, + } + + private globalLevel: number; + private loggerLevels: { [loggerName: string]: number }; + + constructor(private options?: { + globalLevel?: LogLevel, + loggerLevels?: { [loggerName: string]: LogLevel }, + }) { + this.globalLevel = this.logLevelMap[options?.globalLevel || LogLevel.INFO]; + this.loggerLevels = Object.fromEntries(Object.entries(options?.loggerLevels || {}) + .map(([loggerName, level]) => [loggerName, this.logLevelMap[level]])); + } + + /** + * @returns False if the log should be ignored. + */ + filter(log: LogRecord) { + const logLevel = this.logLevelMap[log.level]; + if (logLevel < this.globalLevel) { + return false; + } + const loggerLevel = this.loggerLevels[log.loggerName] || 0; + if (logLevel < loggerLevel) { + return false; + } + return true; + } +} + +/** + * Log handler that logs to console. + * + * @param formatter - Formatter to use for log messages, default BasicLogFormatter + */ +export class ConsoleLogHandler implements LogHandler { + private logLevelMap = { + 'DEBUG': console.debug, + 'INFO': console.info, + 'WARNING': console.warn, + 'ERROR': console.error, + } + + private formatter: LogFormatter; + + constructor(formatter?: LogFormatter) { + this.formatter = formatter || new BasicLogFormatter(); + } + + log(log: LogRecord) { + const message = this.formatter.format(log); + this.logLevelMap[log.level](message); + } +} + +/** + * Log handler that stores logs in memory with option to retrieve later. + * + * Useful for keeping logs around and retrieve them on demand when an error + * occures. + * + * @param formatter - Formatter to use for log messages, default JSONLogFormatter + * @param maxLogs - Maximum number of logs to store, default 10000 + */ +export class MemoryLogHandler implements LogHandler { + private logs: string[] = []; + + private formatter: LogFormatter; + + constructor(formatter?: LogFormatter, private maxLogs = 10000) { + this.formatter = formatter || new JSONLogFormatter(); + this.maxLogs = maxLogs; + } + + log(log: LogRecord) { + const message = this.formatter.format(log); + this.logs.push(message); + + if (this.logs.length > this.maxLogs) { + this.logs.shift(); + } + } + + getLogs() { + return this.logs; + } + + clear() { + this.logs = []; + } +} + +/** + * Formatter that formats logs as JSON. + * + * Useful for machine processing. + */ +export class JSONLogFormatter implements LogFormatter { + format(log: LogRecord) { + if (log.error instanceof Error) { + return JSON.stringify({ + ...log, + error: log.error.message, + stack: log.error.stack, + }); + } + return JSON.stringify(log); + } +} + +/** + * Formatter that formats logs as plain text. + * + * Useful for human reading. + */ +export class BasicLogFormatter implements LogFormatter { + format(log: LogRecord) { + return `${log.time.toISOString()} ${log.level} [${log.loggerName}] ${log.message}${log.error && `\nError: ${log.error.message}\nStack:\n${log.error.stack}`}`; + } +} + +class ConsoleMetricHandler implements MetricHandler { + onEvent(metric: MetricRecord) { + console.info(`${metric.time.toISOString()} INFO [metric] ${metric.event.name} ${JSON.stringify({ ...metric.event, name: undefined })}`); + } +} From c3ab3f73a33c11060b91d981eb5d134695f9c5c0 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 6 Mar 2025 05:53:16 +0000 Subject: [PATCH 033/791] Add folder extended attributes support --- js/sdk/src/crypto/driveCrypto.ts | 17 ++++++ js/sdk/src/interface/nodes.ts | 3 + js/sdk/src/internal/nodes/apiService.test.ts | 8 +-- js/sdk/src/internal/nodes/apiService.ts | 10 ++-- .../src/internal/nodes/cryptoService.test.ts | 8 +-- js/sdk/src/internal/nodes/cryptoService.ts | 17 ++++-- .../internal/nodes/extendedAttributes.test.ts | 56 ++++++++++++++++--- .../src/internal/nodes/extendedAttributes.ts | 16 ++++++ js/sdk/src/internal/nodes/interface.ts | 4 +- js/sdk/src/internal/nodes/nodesManagement.ts | 16 ++++-- js/sdk/src/protonDriveClient.ts | 4 +- js/sdk/src/transformers.ts | 4 +- 12 files changed, 129 insertions(+), 34 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 57240ba1..9aff2c53 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -334,6 +334,23 @@ export class DriveCrypto { }; } + async encryptExtendedAttributes( + extendedAttributes: string, + encryptionKey: PrivateKey, + signingKey: PrivateKey, + ): Promise<{ + armoredExtendedAttributes: string, + }> { + const { armoredData: armoredExtendedAttributes } = await this.openPGPCrypto.encryptAndSignArmored( + new TextEncoder().encode(extendedAttributes), + [encryptionKey], + signingKey, + ); + return { + armoredExtendedAttributes, + }; + } + async decryptExtendedAttributes( armoreExtendedAttributes: string, decryptionKey: PrivateKey, diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 6bfedf58..b83dff9c 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -16,6 +16,9 @@ export type NodeEntity = { createdDate: Date, // created on server date trashedDate?: Date, activeRevision?: Result, + folder?: { + claimedModificationTime?: Date, + }, } export type InvalidNameError = { diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 722001b0..47c03968 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -19,7 +19,7 @@ function generateAPIFileNode(linkOverrides = {}, overrides = {}) { RevisionID: 'revisionId', CreateTime: 1234567890, SignatureEmail: 'revSigEmail', - XAttr: '{}', + XAttr: '{file}', }, ...overrides, }; @@ -35,7 +35,7 @@ function generateAPIFolderNode(linkOverrides = {}, overrides = {}) { ...linkOverrides, }, Folder: { - XAttr: '{}', + XAttr: '{folder}', NodeHashKey: 'nodeHashKey', }, ...overrides, @@ -79,7 +79,7 @@ function generateFileNode(overrides = {}) { state: "active", createdDate: new Date(1234567890000), signatureEmail: "revSigEmail", - encryptedExtendedAttributes: "{}", + armoredExtendedAttributes: "{file}", }, }, ...overrides @@ -96,7 +96,7 @@ function generateFolderNode(overrides = {}) { ...node.encryptedCrypto, folder: { armoredHashKey: "nodeHashKey", - encryptedExtendedAttributes: "{}", + armoredExtendedAttributes: "{folder}", }, }, ...overrides diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index e07a7a4b..88d0f749 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -107,7 +107,7 @@ export class NodeAPIService { state: RevisionState.Active, createdDate: new Date(link.ActiveRevision.CreateTime*1000), signatureEmail: link.ActiveRevision.SignatureEmail || undefined, - encryptedExtendedAttributes: link.ActiveRevision.XAttr || undefined, + armoredExtendedAttributes: link.ActiveRevision.XAttr || undefined, }, }, } @@ -118,7 +118,7 @@ export class NodeAPIService { encryptedCrypto: { ...baseCryptoNodeMetadata, folder: { - encryptedExtendedAttributes: link.Folder.XAttr || undefined, + armoredExtendedAttributes: link.Folder.XAttr || undefined, armoredHashKey: link.Folder.NodeHashKey as string, }, }, @@ -297,7 +297,7 @@ export class NodeAPIService { signatureEmail: string, encryptedName: string, hash: string, - encryptedExtendedAttributes?: string, + armoredExtendedAttributes?: string, }, ): Promise { const { volumeId, nodeId: parentId } = splitNodeUid(parentUid); @@ -314,8 +314,8 @@ export class NodeAPIService { SignatureEmail: newNode.signatureEmail, Name: newNode.encryptedName, Hash: newNode.hash, - // @ts-expect-error: API accepts XAttr as optional. - XAttr: newNode.encryptedExtendedAttributes, + // @ts-expect-error: XAttr is optional as undefined. + XAttr: newNode.armoredExtendedAttributes, }); return makeNodeUid(volumeId, response.Folder.ID); diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 56c32268..56b8aed8 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -130,7 +130,7 @@ describe("nodesCryptoService", () => { armoredNodePassphraseSignature: "armoredNodePassphraseSignature", folder: { armoredHashKey: "armoredHashKey", - encryptedExtendedAttributes: "encryptedExtendedAttributes", + armoredExtendedAttributes: "encryptedExtendedAttributes", } }, } as EncryptedNode, @@ -344,7 +344,7 @@ describe("nodesCryptoService", () => { armoredNodePassphraseSignature: "armoredNodePassphraseSignature", folder: { armoredHashKey: "armoredHashKey", - encryptedExtendedAttributes: "encryptedExtendedAttributes", + armoredExtendedAttributes: "encryptedExtendedAttributes", } }, } as EncryptedNode, @@ -386,7 +386,7 @@ describe("nodesCryptoService", () => { uid: "revisionUid", state: "active", signatureEmail: "revisionSignatureEmail", - encryptedExtendedAttributes: "encryptedExtendedAttributes", + armoredExtendedAttributes: "encryptedExtendedAttributes", }, }, } as EncryptedNode, @@ -437,7 +437,7 @@ describe("nodesCryptoService", () => { uid: "revisionUid", state: "active", signatureEmail: "revisionSignatureEmail", - encryptedExtendedAttributes: "encryptedExtendedAttributes", + armoredExtendedAttributes: "encryptedExtendedAttributes", }, }, } as EncryptedNode, diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 01afd76a..7cad4e47 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -89,7 +89,7 @@ export class NodesCryptoService { hashKeyAuthor = hashKeyResult.author; const extendedAttributesResult = await this.decryptExtendedAttributes( - node.encryptedCrypto.folder.encryptedExtendedAttributes, + node.encryptedCrypto.folder.armoredExtendedAttributes, key, keyVerificationKeys, node.encryptedCrypto.signatureEmail @@ -230,7 +230,7 @@ export class NodesCryptoService { const { extendedAttributes, author, - } = await this.decryptExtendedAttributes(encryptedRevision.encryptedExtendedAttributes, nodeKey, verificationKeys, encryptedRevision.signatureEmail); + } = await this.decryptExtendedAttributes(encryptedRevision.armoredExtendedAttributes, nodeKey, verificationKeys, encryptedRevision.signatureEmail); return { uid: encryptedRevision.uid, @@ -263,7 +263,12 @@ export class NodesCryptoService { } } - async createFolder(parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, name: string): Promise<{ + async createFolder( + parentNode: DecryptedNode, + parentKeys: { key: PrivateKey, hashKey: Uint8Array }, + name: string, + extendedAttributes?: string, + ): Promise<{ encryptedCrypto: Required & { encryptedName: string, hash: string }, keys: DecryptedNodeKeys, }> { @@ -281,6 +286,10 @@ export class NodesCryptoService { const { armoredHashKey, hashKey } = await this.driveCrypto.generateHashKey(nodeKeys.decrypted.key); + const { armoredExtendedAttributes } = extendedAttributes + ? await this.driveCrypto.encryptExtendedAttributes(extendedAttributes, nodeKeys.decrypted.key, addressKey) + : { armoredExtendedAttributes: undefined }; + return { encryptedCrypto: { encryptedName: armoredNodeName, @@ -289,7 +298,7 @@ export class NodesCryptoService { armoredNodePassphrase: nodeKeys.encrypted.armoredPassphrase, armoredNodePassphraseSignature: nodeKeys.encrypted.armoredPassphraseSignature, folder: { - encryptedExtendedAttributes: undefined, + armoredExtendedAttributes: armoredExtendedAttributes, armoredHashKey, }, signatureEmail: email, diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index 2f5b5c5d..742a83f6 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -1,13 +1,53 @@ -import { FileExtendedAttributesParsed, FolderExtendedAttributes, parseFileExtendedAttributes, parseFolderExtendedAttributes } from './extendedAttributes'; - -const emptyExtendedAttributes = { - claimedSize: undefined, - claimedModificationTime: undefined, - claimedDigests: undefined, - claimedAdditionalMetadata: undefined, -}; +import { FolderExtendedAttributes, FileExtendedAttributesParsed, generateFolderExtendedAttributes, parseFolderExtendedAttributes, parseFileExtendedAttributes } from './extendedAttributes'; describe('extended attrbiutes', () => { + describe('should generate folder attributes', () => { + const testCases: [Date | undefined, string | undefined][] = [ + [undefined, undefined], + [new Date(1234567890000), '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z"}}'], + ]; + testCases.forEach(([input, expectedAttributes]) => { + it(`should generate ${input}`, () => { + const output = generateFolderExtendedAttributes(input); + expect(output).toBe(expectedAttributes); + }) + }); + }); + + describe('should parse folder attributes', () => { + const testCases: [string, FolderExtendedAttributes][] = [ + ['', {}], + ['{}', {}], + ['a', {}], + [ + '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000"}}', + { + claimedModificationTime: new Date(1234567890000), + }, + ], + [ + '{"Common": {"ModificationTime": "aa"}}', + {}, + ], + [ + '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000", "Size": 123}}', + { + claimedModificationTime: new Date(1234567890000), + }, + ], + [ + '{"Common": {"Whatever": 123}}', + {}, + ], + ]; + testCases.forEach(([input, expectedAttributes]) => { + it(`should parse ${input}`, () => { + const output = parseFolderExtendedAttributes(input); + expect(output).toMatchObject(expectedAttributes); + }) + }); + }); + describe('should parses file attributes', () => { const testCases: [string, FileExtendedAttributesParsed][] = [ ['', {}], diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index f90e2f57..dcdaab6e 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -50,6 +50,22 @@ export interface FileExtendedAttributesParsed { claimedAdditionalMetadata?: object, } +export function generateFolderExtendedAttributes(claimedModificationTime?: Date): string | undefined { + if (!claimedModificationTime) { + return undefined; + } + return JSON.stringify({ + Common: { + ModificationTime: dateToIsoString(claimedModificationTime), + }, + }); +} + +function dateToIsoString(date: Date) { + const isDateValid = !Number.isNaN(date.getTime()); + return isDateValid ? date.toISOString() : undefined; +} + export function parseFolderExtendedAttributes(extendedAttributes?: string, log?: Logger): FolderExtendedAttributes { if (!extendedAttributes) { return {}; diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index f1ffff92..0a207e06 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -51,7 +51,7 @@ export interface EncryptedNodeFileCrypto extends EncryptedNodeCrypto { export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { folder: { - encryptedExtendedAttributes?: string; + armoredExtendedAttributes?: string; armoredHashKey: string; }; } @@ -102,7 +102,7 @@ interface BaseRevision { export interface EncryptedRevision extends BaseRevision { signatureEmail?: string; - encryptedExtendedAttributes?: string; + armoredExtendedAttributes?: string; } export interface DecryptedRevision extends BaseRevision { diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 8716ee5b..ccfc25ce 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -7,6 +7,7 @@ import { NodesCryptoService } from "./cryptoService"; import { DecryptedNode } from "./interface"; import { NodesAccess } from "./nodesAccess"; import { validateNodeName } from "./validations"; +import { generateFolderExtendedAttributes } from "./extendedAttributes"; /** * Provides high-level actions for managing nodes. @@ -198,7 +199,7 @@ export class NodesManagement { await this.cache.removeNodes(deletedNodeUids); } - async createFolder(parentNodeUid: string, folderName: string): Promise { + async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { validateNodeName(folderName); const parentNode = await this.nodesAccess.getNode(parentNodeUid); @@ -207,7 +208,14 @@ export class NodesManagement { throw new Error('Creating folders in non-folders is not supported'); } - const { encryptedCrypto, keys } = await this.cryptoService.createFolder(parentNode, { key: parentKeys.key, hashKey: parentKeys.hashKey }, folderName); + const extendedAttributes = generateFolderExtendedAttributes(modificationTime); + + const { encryptedCrypto, keys } = await this.cryptoService.createFolder( + parentNode, + { key: parentKeys.key, hashKey: parentKeys.hashKey }, + folderName, + extendedAttributes, + ); const nodeUid = await this.apiService.createFolder(parentNodeUid, { armoredKey: encryptedCrypto.armoredKey, armoredHashKey: encryptedCrypto.folder.armoredHashKey, @@ -216,7 +224,7 @@ export class NodesManagement { signatureEmail: encryptedCrypto.signatureEmail, encryptedName: encryptedCrypto.encryptedName, hash: encryptedCrypto.hash, - encryptedExtendedAttributes: encryptedCrypto.folder.encryptedExtendedAttributes, // TODO + armoredExtendedAttributes: encryptedCrypto.folder.armoredExtendedAttributes, }); const node: DecryptedNode = { @@ -233,7 +241,7 @@ export class NodesManagement { // Share node metadata isShared: false, - directMemberRole: MemberRole.Admin, // TODO + directMemberRole: MemberRole.Inherited, // Decrypted metadata isStale: false, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 099d9d18..7cf22c31 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -87,8 +87,8 @@ export class ProtonDriveClient implements Partial { yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal); } - async createFolder(parentNodeUid: NodeOrUid, name: string) { - return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name)); + async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date) { + return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime)); } async* iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal) { diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 411ef27f..fdbe5c11 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -14,7 +14,8 @@ type InternalPartialNode = Pick< 'isShared' | 'createdDate' | 'trashedDate' | - 'activeRevision' + 'activeRevision' | + 'folder' >; export function getUid(nodeUid: NodeOrUid): string { @@ -53,5 +54,6 @@ export function convertInternalNode(node: InternalPartialNode): PublicNode { createdDate: node.createdDate, trashedDate: node.trashedDate, activeRevision: node.activeRevision, + folder: node.folder, }; } From 83b13e03fb6df5f767c47c74bc52622141495633 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 6 Mar 2025 06:08:48 +0000 Subject: [PATCH 034/791] handle refresh event --- js/sdk/src/internal/nodes/cache.test.ts | 62 ++++++++++++++++--------- js/sdk/src/internal/nodes/cache.ts | 29 ++++++++++-- js/sdk/src/internal/nodes/events.ts | 7 ++- js/sdk/src/internal/sharing/cache.ts | 2 +- js/sdk/src/internal/sharing/events.ts | 15 +++++- 5 files changed, 87 insertions(+), 28 deletions(-) diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 2951bb8d..e370cb5f 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -3,10 +3,10 @@ import { NodeType, MemberRole } from "../../interface"; import { CACHE_TAG_KEYS, NodesCache } from "./cache"; import { DecryptedNode } from "./interface"; -function generateNode(uid: string, parentUid='root', params: Partial = {}): DecryptedNode { +function generateNode(uid: string, parentUid='root', params: Partial & { volumeId?: string } = {}): DecryptedNode { return { - uid, - parentUid, + uid: `volume:${params.volumeId || "volumeId"};node:${uid}`, + parentUid: `volume:${params.volumeId || "volumeId"};node:${parentUid}`, directMemberRole: MemberRole.Admin, type: NodeType.File, mimeType: "text", @@ -14,6 +14,7 @@ function generateNode(uid: string, parentUid='root', params: Partial { beforeEach(() => { memoryCache = new MemoryCache(); - memoryCache.setEntity('node-root', JSON.stringify(generateNode('root', ''))); + memoryCache.setEntity('node-volume:volumeId;node:root', JSON.stringify(generateNode('root', ''))); memoryCache.setEntity('node-badObject', 'aaa', [`${CACHE_TAG_KEYS.ParentUid}:root`]); cache = new NodesCache(memoryCache); @@ -104,7 +107,7 @@ describe('nodesCache', () => { it('should remove node without children', async () => { await generateTreeStructure(cache); - await cache.removeNodes(['node3']); + await cache.removeNodes(['volume:volumeId;node:node3']); await verifyNodesCache( cache, ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b'], @@ -114,7 +117,7 @@ describe('nodesCache', () => { it('should remove node and its children', async () => { await generateTreeStructure(cache); - await cache.removeNodes(['node2']); + await cache.removeNodes(['volume:volumeId;node:node2']); await verifyNodesCache( cache, ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node3'], @@ -124,7 +127,7 @@ describe('nodesCache', () => { it('should remove node and its children recursively', async () => { await generateTreeStructure(cache); - await cache.removeNodes(['node1']); + await cache.removeNodes(['volume:volumeId;node:node1']); await verifyNodesCache( cache, ['node2', 'node2a', 'node2b', 'node3'], @@ -134,24 +137,24 @@ describe('nodesCache', () => { it('should iterate requested nodes', async () => { await generateTreeStructure(cache); - const result = await Array.fromAsync(cache.iterateNodes(['node1', 'node2'])); + const result = await Array.fromAsync(cache.iterateNodes(['volume:volumeId;node:node1', 'volume:volumeId;node:node2'])); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['node1', 'node2']); + expect(nodeUids).toStrictEqual(['volume:volumeId;node:node1', 'volume:volumeId;node:node2']); }); it('should iterate children without trashed items', async () => { await generateTreeStructure(cache); - const result = await Array.fromAsync(cache.iterateChildren('node1')); + const result = await Array.fromAsync(cache.iterateChildren('volume:volumeId;node:node1')); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['node1a', 'node1c']); + expect(nodeUids).toStrictEqual(['volume:volumeId;node:node1a', 'volume:volumeId;node:node1c']); }); it('should iterate children and silently remove a corrupted node', async () => { await generateTreeStructure(cache); // badObject has root as parent. - const result = await Array.fromAsync(cache.iterateChildren('root')); + const result = await Array.fromAsync(cache.iterateChildren('volume:volumeId;node:root')); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['node1', 'node2', 'node3']); + expect(nodeUids).toStrictEqual(['volume:volumeId;node:node1', 'volume:volumeId;node:node2', 'volume:volumeId;node:node3']); await verifyNodesCache( cache, ['root', 'node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b', 'node3'], @@ -163,16 +166,31 @@ describe('nodesCache', () => { await generateTreeStructure(cache); const result = await Array.fromAsync(cache.iterateTrashedNodes()); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['node1b', 'node1c-beta', 'node2b']); + expect(nodeUids).toStrictEqual(['volume:volumeId;node:node1b', 'volume:volumeId;node:node1c-beta', 'volume:volumeId;node:node2b']); }); it('should set and unset children loaded state', async () => { - expect(await cache.isFolderChildrenLoaded('node1')).toBe(false); + expect(await cache.isFolderChildrenLoaded('volume:volumeId;node:node1')).toBe(false); + + await cache.setFolderChildrenLoaded('volume:volumeId;node:node1'); + expect(await cache.isFolderChildrenLoaded('volume:volumeId;node:node1')).toBe(true); - await cache.setFolderChildrenLoaded('node1'); - expect(await cache.isFolderChildrenLoaded('node1')).toBe(true); + await cache.resetFolderChildrenLoaded('volume:volumeId;node:node1'); + expect(await cache.isFolderChildrenLoaded('volume:volumeId;node:node1')).toBe(false); + }); - await cache.resetFolderChildrenLoaded('node1'); - expect(await cache.isFolderChildrenLoaded('node1')).toBe(false); + it('should set nodes from the volume as stale', async () => { + await generateTreeStructure(cache); + await cache.setNodesStaleFromVolume('volumeId'); + + const staleNodeUids = ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b', 'node3'] + .map((uid) => `volume:volumeId;node:${uid}`); + const result = await Array.fromAsync(cache.iterateNodes([...staleNodeUids, 'volume:volume2;node:root-otherVolume'])); + const got = result.map((item) => ({ uid: item.uid, isStale: item.ok ? item.node.isStale : item.error })); + const expected = [ + ...staleNodeUids.map((uid) => ({ uid, isStale: true })), + { uid: 'volume:volume2;node:root-otherVolume', isStale: false }, + ]; + expect(got).toEqual(expected); }); -}); \ No newline at end of file +}); diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index cf7f90ec..308aebe8 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -1,5 +1,6 @@ import { EntityResult } from "../../cache"; import { ProtonDriveEntitiesCache, Logger } from "../../interface"; +import { splitNodeUid } from "../uids"; import { DecryptedNode } from "./interface"; export enum CACHE_TAG_KEYS { @@ -29,8 +30,9 @@ export class NodesCache { async setNode(node: DecryptedNode): Promise { const key = getCacheUid(node.uid); const nodeData = serialiseNode(node); - - const tags = []; + const { volumeId } = splitNodeUid(node.uid); + + const tags = [`volume:${volumeId}`]; if (node.parentUid) { tags.push(`${CACHE_TAG_KEYS.ParentUid}:${node.parentUid}`) } @@ -52,6 +54,26 @@ export class NodesCache { } } + /** + * Set all nodes on given node as stale. This is useful when we + * get refresh event from the server and we thus don't know + * which nodes were up-to-date anymore. + */ + async setNodesStaleFromVolume(volumeId: string): Promise { + for await (const result of this.driveCache.iterateEntitiesByTag(`volume:${volumeId}`)) { + const node = await this.convertCacheResult(result); + if (node && node.ok) { + node.node.isStale = true; + await this.setNode(node.node); + } + } + + // Force all calls to children UIDs to be re-fetched. + for await (const result of this.driveCache.iterateEntitiesByTag(`children-volume:${volumeId}`)) { + await this.driveCache.removeEntities([result.uid]); + } + } + /** * Remove corrupted node never throws, but it logs so we can know * about issues and fix them. It is crucial to remove corrupted @@ -164,7 +186,8 @@ export class NodesCache { } async setFolderChildrenLoaded(nodeUid: string): Promise { - this.driveCache.setEntity(`node-children-${nodeUid}`, 'loaded'); + const { volumeId } = splitNodeUid(nodeUid); + this.driveCache.setEntity(`node-children-${nodeUid}`, 'loaded', [`children-volume:${volumeId}`]); } async resetFolderChildrenLoaded(nodeUid: string): Promise { diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 511d2d22..76f06d1e 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -41,7 +41,12 @@ export class NodesEvents { private listeners: Listeners = []; constructor(events: DriveEventsService, cache: NodesCache, nodesAccess: NodesAccess, log?: Logger) { - events.addListener(async (events) => { + events.addListener(async (events, fullRefreshVolumeId) => { + if (fullRefreshVolumeId) { + await cache.setNodesStaleFromVolume(fullRefreshVolumeId); + return; + } + for (const event of events) { await updateCacheByEvent(event, cache, log); } diff --git a/js/sdk/src/internal/sharing/cache.ts b/js/sdk/src/internal/sharing/cache.ts index 5209be69..acd43e48 100644 --- a/js/sdk/src/internal/sharing/cache.ts +++ b/js/sdk/src/internal/sharing/cache.ts @@ -30,7 +30,7 @@ export class SharingCache { return this.removeNodeUid(SharingType.SharedByMe, nodeUid); } - async setSharedByMeNodeUids(nodeUids: string[]): Promise { + async setSharedByMeNodeUids(nodeUids: string[] | undefined): Promise { return this.setNodeUids(SharingType.SharedByMe, nodeUids); } diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index d0a0bd5a..d132ad0a 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -21,7 +21,20 @@ export class SharingEvents { private listeners: Listeners = []; constructor(events: DriveEventsService, cache: SharingCache, nodesService: NodesService, sharingAccess: SharingAccess, log?: Logger) { - events.addListener(async (events) => { + events.addListener(async (events, fullRefreshVolumeId) => { + // Technically we need to refresh only the shared by me nodes for + // own volume, and shared with me nodes only when the event comes + // as core refresh event is converted to it. + // We can optimise later, for now we refresh everything to make + // it simpler. The cache is smart enough to not do unnecessary + // requests to the API and refresh on web is rare without + // persistant cache for now. + if (fullRefreshVolumeId) { + await cache.setSharedByMeNodeUids(undefined); + await cache.setSharedWithMeNodeUids(undefined); + return + } + for (const event of events) { await handleSharedByMeNodes(event, cache, this.listeners, nodesService, log); await handleSharedWithMeNodes(event, cache, this.listeners, sharingAccess); From d44645bc1deef304f2cb5ddb4565dba7ae899395 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Mar 2025 15:08:58 +0100 Subject: [PATCH 035/791] update uids format --- js/sdk/src/internal/nodes/apiService.test.ts | 32 +++++----- js/sdk/src/internal/nodes/cache.test.ts | 46 +++++++------- .../sharing/sharingManagement.test.ts | 4 +- js/sdk/src/internal/uids.ts | 63 +++++++++---------- 4 files changed, 70 insertions(+), 75 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 47c03968..d6bccd83 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -75,7 +75,7 @@ function generateFileNode(overrides = {}) { armoredContentKeyPacketSignature: "contentKeyPacketSig", }, activeRevision: { - uid: "volume:volumeId;node:linkId;revision:revisionId", + uid: "volumeId~linkId~revisionId", state: "active", createdDate: new Date(1234567890000), signatureEmail: "revSigEmail", @@ -108,8 +108,8 @@ function generateNode() { hash: "nameHash", encryptedName: "encName", - uid: "volume:volumeId;node:linkId", - parentUid: "volume:volumeId;node:parentLinkId", + uid: "volumeId~linkId", + parentUid: "volumeId~parentLinkId", createdDate: new Date(123456789000), trashedDate: undefined, @@ -151,7 +151,7 @@ describe("nodeAPIService", () => { Links: [mockedLink], })); - const nodes = await api.getNodes(['volume:volumeId;node:nodeId']); + const nodes = await api.getNodes(['volumeId~nodeId']); expect(nodes).toStrictEqual([expectedNode]); } @@ -245,10 +245,10 @@ describe("nodeAPIService", () => { ], })); - const result = await Array.fromAsync(api.trashNodes(['volume:volumeId;node:nodeId1', 'volume:volumeId;node:nodeId2'])); + const result = await Array.fromAsync(api.trashNodes(['volumeId~nodeId1', 'volumeId~nodeId2'])); expect(result).toEqual([ - { uid: 'volume:volumeId;node:nodeId1', ok: true }, - { uid: 'volume:volumeId;node:nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, + { uid: 'volumeId~nodeId1', ok: true }, + { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, ]); }); }); @@ -280,17 +280,17 @@ describe("nodeAPIService", () => { ], })); - const result = await Array.fromAsync(api.restoreNodes(['volume:volumeId;node:nodeId1', 'volume:volumeId;node:nodeId2', 'volume:volumeId;node:nodeId3'])); + const result = await Array.fromAsync(api.restoreNodes(['volumeId~nodeId1', 'volumeId~nodeId2', 'volumeId~nodeId3'])); expect(result).toEqual([ - { uid: 'volume:volumeId;node:nodeId1', ok: true }, - { uid: 'volume:volumeId;node:nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, - { uid: 'volume:volumeId;node:nodeId3', ok: false, error: 'Unknown error 2000' }, + { uid: 'volumeId~nodeId1', ok: true }, + { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, + { uid: 'volumeId~nodeId3', ok: false, error: 'Unknown error 2000' }, ]); }); it('should fail restoring from multiple volumes', async () => { try { - await Array.fromAsync(api.restoreNodes(['volume:volumeId1;node:nodeId1', 'volume:volumeId2;node:nodeId2'])); + await Array.fromAsync(api.restoreNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); throw new Error('Should have thrown'); } catch (error: any) { expect(error.message).toEqual('restoreNodes does not support multiple volumes'); @@ -319,16 +319,16 @@ describe("nodeAPIService", () => { ], })); - const result = await Array.fromAsync(api.deleteNodes(['volume:volumeId;node:nodeId1', 'volume:volumeId;node:nodeId2'])); + const result = await Array.fromAsync(api.deleteNodes(['volumeId~nodeId1', 'volumeId~nodeId2'])); expect(result).toEqual([ - { uid: 'volume:volumeId;node:nodeId1', ok: true }, - { uid: 'volume:volumeId;node:nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, + { uid: 'volumeId~nodeId1', ok: true }, + { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, ]); }); it('should fail deleting nodes from multiple volumes', async () => { try { - await Array.fromAsync(api.deleteNodes(['volume:volumeId1;node:nodeId1', 'volume:volumeId2;node:nodeId2'])); + await Array.fromAsync(api.deleteNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); throw new Error('Should have thrown'); } catch (error: any) { expect(error.message).toEqual('deleteNodes does not support multiple volumes'); diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index e370cb5f..c6775ebb 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -5,8 +5,8 @@ import { DecryptedNode } from "./interface"; function generateNode(uid: string, parentUid='root', params: Partial & { volumeId?: string } = {}): DecryptedNode { return { - uid: `volume:${params.volumeId || "volumeId"};node:${uid}`, - parentUid: `volume:${params.volumeId || "volumeId"};node:${parentUid}`, + uid: `${params.volumeId || "volumeId"}~:${uid}`, + parentUid: `${params.volumeId || "volumeId"}~:${parentUid}`, directMemberRole: MemberRole.Admin, type: NodeType.File, mimeType: "text", @@ -43,7 +43,7 @@ async function generateTreeStructure(cache: NodesCache) { async function verifyNodesCache(cache: NodesCache, expectedNodes: string[], expectedMissingNodes: string[]) { for (const nodeUid of expectedNodes) { try { - await cache.getNode(`volume:volumeId;node:${nodeUid}`); + await cache.getNode(`volumeId~:${nodeUid}`); } catch (error) { throw new Error(`${nodeUid} should be in the cache: ${error}`); } @@ -51,7 +51,7 @@ async function verifyNodesCache(cache: NodesCache, expectedNodes: string[], expe for (const nodeUid of expectedMissingNodes) { try { - await cache.getNode(`volume:volumeId;node:${nodeUid}`); + await cache.getNode(`volumeId~:${nodeUid}`); throw new Error(`${nodeUid} should not be in the cache`); } catch (error) { expect(`${error}`).toBe('Error: Entity not found'); @@ -65,7 +65,7 @@ describe('nodesCache', () => { beforeEach(() => { memoryCache = new MemoryCache(); - memoryCache.setEntity('node-volume:volumeId;node:root', JSON.stringify(generateNode('root', ''))); + memoryCache.setEntity('node-volumeId~:root', JSON.stringify(generateNode('root', ''))); memoryCache.setEntity('node-badObject', 'aaa', [`${CACHE_TAG_KEYS.ParentUid}:root`]); cache = new NodesCache(memoryCache); @@ -107,7 +107,7 @@ describe('nodesCache', () => { it('should remove node without children', async () => { await generateTreeStructure(cache); - await cache.removeNodes(['volume:volumeId;node:node3']); + await cache.removeNodes(['volumeId~:node3']); await verifyNodesCache( cache, ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b'], @@ -117,7 +117,7 @@ describe('nodesCache', () => { it('should remove node and its children', async () => { await generateTreeStructure(cache); - await cache.removeNodes(['volume:volumeId;node:node2']); + await cache.removeNodes(['volumeId~:node2']); await verifyNodesCache( cache, ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node3'], @@ -127,7 +127,7 @@ describe('nodesCache', () => { it('should remove node and its children recursively', async () => { await generateTreeStructure(cache); - await cache.removeNodes(['volume:volumeId;node:node1']); + await cache.removeNodes(['volumeId~:node1']); await verifyNodesCache( cache, ['node2', 'node2a', 'node2b', 'node3'], @@ -137,24 +137,24 @@ describe('nodesCache', () => { it('should iterate requested nodes', async () => { await generateTreeStructure(cache); - const result = await Array.fromAsync(cache.iterateNodes(['volume:volumeId;node:node1', 'volume:volumeId;node:node2'])); + const result = await Array.fromAsync(cache.iterateNodes(['volumeId~:node1', 'volumeId~:node2'])); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['volume:volumeId;node:node1', 'volume:volumeId;node:node2']); + expect(nodeUids).toStrictEqual(['volumeId~:node1', 'volumeId~:node2']); }); it('should iterate children without trashed items', async () => { await generateTreeStructure(cache); - const result = await Array.fromAsync(cache.iterateChildren('volume:volumeId;node:node1')); + const result = await Array.fromAsync(cache.iterateChildren('volumeId~:node1')); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['volume:volumeId;node:node1a', 'volume:volumeId;node:node1c']); + expect(nodeUids).toStrictEqual(['volumeId~:node1a', 'volumeId~:node1c']); }); it('should iterate children and silently remove a corrupted node', async () => { await generateTreeStructure(cache); // badObject has root as parent. - const result = await Array.fromAsync(cache.iterateChildren('volume:volumeId;node:root')); + const result = await Array.fromAsync(cache.iterateChildren('volumeId~:root')); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['volume:volumeId;node:node1', 'volume:volumeId;node:node2', 'volume:volumeId;node:node3']); + expect(nodeUids).toStrictEqual(['volumeId~:node1', 'volumeId~:node2', 'volumeId~:node3']); await verifyNodesCache( cache, ['root', 'node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b', 'node3'], @@ -166,17 +166,17 @@ describe('nodesCache', () => { await generateTreeStructure(cache); const result = await Array.fromAsync(cache.iterateTrashedNodes()); const nodeUids = result.map(({ uid }) => uid); - expect(nodeUids).toStrictEqual(['volume:volumeId;node:node1b', 'volume:volumeId;node:node1c-beta', 'volume:volumeId;node:node2b']); + expect(nodeUids).toStrictEqual(['volumeId~:node1b', 'volumeId~:node1c-beta', 'volumeId~:node2b']); }); it('should set and unset children loaded state', async () => { - expect(await cache.isFolderChildrenLoaded('volume:volumeId;node:node1')).toBe(false); + expect(await cache.isFolderChildrenLoaded('volumeId~:node1')).toBe(false); - await cache.setFolderChildrenLoaded('volume:volumeId;node:node1'); - expect(await cache.isFolderChildrenLoaded('volume:volumeId;node:node1')).toBe(true); + await cache.setFolderChildrenLoaded('volumeId~:node1'); + expect(await cache.isFolderChildrenLoaded('volumeId~:node1')).toBe(true); - await cache.resetFolderChildrenLoaded('volume:volumeId;node:node1'); - expect(await cache.isFolderChildrenLoaded('volume:volumeId;node:node1')).toBe(false); + await cache.resetFolderChildrenLoaded('volumeId~:node1'); + expect(await cache.isFolderChildrenLoaded('volumeId~:node1')).toBe(false); }); it('should set nodes from the volume as stale', async () => { @@ -184,12 +184,12 @@ describe('nodesCache', () => { await cache.setNodesStaleFromVolume('volumeId'); const staleNodeUids = ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b', 'node3'] - .map((uid) => `volume:volumeId;node:${uid}`); - const result = await Array.fromAsync(cache.iterateNodes([...staleNodeUids, 'volume:volume2;node:root-otherVolume'])); + .map((uid) => `volumeId~:${uid}`); + const result = await Array.fromAsync(cache.iterateNodes([...staleNodeUids, 'volume2~:root-otherVolume'])); const got = result.map((item) => ({ uid: item.uid, isStale: item.ok ? item.node.isStale : item.error })); const expected = [ ...staleNodeUids.map((uid) => ({ uid, isStale: true })), - { uid: 'volume:volume2;node:root-otherVolume', isStale: false }, + { uid: 'volume2~:root-otherVolume', isStale: false }, ]; expect(got).toEqual(expected); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 3e9371f8..094e4327 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -124,7 +124,7 @@ describe("SharingManagement", () => { }); describe("shareNode", () => { - const nodeUid = "volume:volumeId;node:nodeUid"; + const nodeUid = "volumeId~nodeUid"; let invitation: ProtonInvitation; let externalInvitation: NonProtonInvitation; @@ -369,7 +369,7 @@ describe("SharingManagement", () => { }); describe("unsahreNode", () => { - const nodeUid = "volume:volumeId;node:nodeUid"; + const nodeUid = "volumeId~nodeUid"; let invitation: ProtonInvitation; let externalInvitation: NonProtonInvitation; diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index 00ef0dce..3af5f89d 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -1,56 +1,51 @@ export function makeNodeUid(volumeId: string, nodeId: string) { - // TODO: format of UID - return `volume:${volumeId};node:${nodeId}`; + return `${volumeId}~${nodeId}`; } export function splitNodeUid(nodeUid: string) { - // TODO: validation - const [ volumeId, nodeId ] = nodeUid.split(';'); - return { - volumeId: volumeId.slice('volume:'.length), - nodeId: nodeId.slice('node:'.length), - }; + const parts = nodeUid.split('~'); + if (parts.length !== 2) { + throw new Error(`"${nodeUid}" is not valid node UID`); + } + const [ volumeId, nodeId ] = parts; + return { volumeId, nodeId }; } export function makeInvitationUid(shareId: string, invitationId: string) { - // TODO: format of UID - return `share:${shareId};invitation:${invitationId}`; + return `${shareId}~${invitationId}`; } export function splitInvitationUid(invitationUid: string) { - // TODO: validation - const [ shareId, invitationId ] = invitationUid.split(';'); - return { - shareId: shareId.slice('share:'.length), - invitationId: invitationId.slice('invitation:'.length), - }; + const parts = invitationUid.split('~'); + if (parts.length !== 2) { + throw new Error(`"${invitationUid}" is not valid invitation UID`); + } + const [ shareId, invitationId ] = parts; + return { shareId, invitationId }; } export function makeMemberUid(shareId: string, memberId: string) { - // TODO: format of UID - return `share:${shareId};member:${memberId}`; + return `${shareId}~${memberId}`; } export function splitMemberUid(memberUid: string) { - // TODO: validation - const [ shareId, memberId ] = memberUid.split(';'); - return { - shareId: shareId.slice('share:'.length), - memberId: memberId.slice('member:'.length), - }; + const parts = memberUid.split('~'); + if (parts.length !== 2) { + throw new Error(`"${memberUid}" is not valid member UID`); + } + const [ shareId, memberId ] = parts; + return { shareId, memberId }; } -export function makeNodeRevisionUid(volumeId: string, nodeUid: string, revisionId: string) { - // TODO: format of UID - return `volume:${volumeId};node:${nodeUid};revision:${revisionId}`; +export function makeNodeRevisionUid(volumeId: string, nodeId: string, revisionId: string) { + return `${volumeId}~${nodeId}~${revisionId}`; } export function splitNodeRevisionUid(nodeRevisionUid: string) { - // TODO: validation - const [ volumeId, nodeId, revisionId ] = nodeRevisionUid.split(';'); - return { - volumeId: volumeId.slice('volume:'.length), - nodeId: nodeId.slice('node:'.length), - revisionId: revisionId.slice('revision:'.length), - }; + const parts = nodeRevisionUid.split('~'); + if (parts.length !== 3) { + throw new Error(`"${nodeRevisionUid}" is not valid node revision UID`); + } + const [ volumeId, nodeId, revisionId ] = parts; + return { volumeId, nodeId, revisionId }; } From 1c88a70437c641baa7e90495169d5d0d93b63ce1 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Mar 2025 14:33:36 +0100 Subject: [PATCH 036/791] update cache naming --- .gitignore | 1 + js/sdk/src/cache/interface.ts | 19 +++---- js/sdk/src/cache/memoryCache.test.ts | 84 ++++++++++++++-------------- js/sdk/src/cache/memoryCache.ts | 48 ++++++++-------- js/sdk/src/internal/nodes/cache.ts | 12 ++-- 5 files changed, 81 insertions(+), 83 deletions(-) diff --git a/.gitignore b/.gitignore index a10f0ee9..f2cb78a8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ tsconfig.tsbuildinfo js/cli/proton-drive auth.txt cache*.sqlite +*.bun-build # Tests tests/storage diff --git a/js/sdk/src/cache/interface.ts b/js/sdk/src/cache/interface.ts index 776ea780..50c12583 100644 --- a/js/sdk/src/cache/interface.ts +++ b/js/sdk/src/cache/interface.ts @@ -26,9 +26,6 @@ export interface ProtonDriveCache { /** * Adds or updates entity in the local database. * - * The `data` doesn't include any keys. It is up to the client store this - * information privately. - * * The `tags` is a list of strings that should be stored properly for fast * look-up. * @@ -59,27 +56,27 @@ export interface ProtonDriveCache { * } * ``` * - * @param uid - UID is internal ID controlled by the SDK. It combines type and ID of the entity. - * @param data - Serialised JSON object controlled by the SDK. It is not intended for use outside of the SDK. + * @param key - Key is internal ID controlled by the SDK. It combines type and ID of the entity. + * @param value - Serialised JSON object controlled by the SDK. It is not intended for use outside of the SDK. * @param tags - Clear metadata about the entity used for filtering. It is intended to store efficiently for fast look-up. * @throws Exception if `key` from `tags` is not one of the tag keys provided from `usedTagKeysBySDK` in constructor. */ - setEntity(uid: string, data: T, tags?: string[] ): Promise, + setEntity(key: string, value: T, tags?: string[] ): Promise, /** * Returns the data of the entity stored locally. * * @throws Exception if entity is not present. */ - getEntity(uid: string): Promise, + getEntity(key: string): Promise, /** * Generator providing the data of the entities stored locally for given - * list of UIDs. + * list of keys. * * No exception is thrown when data is missing. */ - iterateEntities(uids: string[]): AsyncGenerator>, + iterateEntities(keys: string[]): AsyncGenerator>, /** * Generator providing the data of the entities stored locally for given @@ -102,7 +99,7 @@ export interface ProtonDriveCache { * * It is no-op if entity is not present. */ - removeEntities(uids: string[]): Promise, + removeEntities(keys: string[]): Promise, } -export type EntityResult = {uid: string, ok: true, data: T} | {uid: string, ok: false, error: string}; +export type EntityResult = {key: string, ok: true, value: T} | {key: string, ok: false, error: string}; diff --git a/js/sdk/src/cache/memoryCache.test.ts b/js/sdk/src/cache/memoryCache.test.ts index 73f716d0..694edb98 100644 --- a/js/sdk/src/cache/memoryCache.test.ts +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -7,43 +7,43 @@ describe('MemoryCache', () => { beforeEach(() => { cache = new MemoryCache(); - cache.setEntity('uid1', 'data1', ['tag1:hello', 'tag2:world']); - cache.setEntity('uid2', 'data2', ['tag2:world']); - cache.setEntity('uid3', 'data3'); + cache.setEntity('key1', 'value1', ['tag1:hello', 'tag2:world']); + cache.setEntity('key2', 'value2', ['tag2:world']); + cache.setEntity('key3', 'value3'); }); it('should store and retrieve an entity', async () => { - const uid = 'newuid'; - const data = 'newdata'; + const key = 'newkey'; + const value = 'newvalue'; - await cache.setEntity(uid, data); - const result = await cache.getEntity(uid); + await cache.setEntity(key, value); + const result = await cache.getEntity(key); - expect(result).toBe(data); + expect(result).toBe(value); }); it('should update an entity with tags - remove old and add new tags', async () => { - const uid = 'newuid'; + const key = 'newkey'; - await cache.setEntity(uid, 'data1', ['tag1', 'tag2']); - await cache.setEntity(uid, 'data2', ['tag2', 'tag3']); + await cache.setEntity(key, 'value1', ['tag1', 'tag2']); + await cache.setEntity(key, 'value2', ['tag2', 'tag3']); - const result = await cache.getEntity(uid); - expect(result).toBe('data2'); + const result = await cache.getEntity(key); + expect(result).toBe('value2'); const tag1 = await Array.fromAsync(cache.iterateEntitiesByTag('tag1')); expect(tag1).toEqual([]); const tag2 = await Array.fromAsync(cache.iterateEntitiesByTag('tag2')); - expect(tag2).toEqual([{ uid, ok: true, data: 'data2' }]); + expect(tag2).toEqual([{ key, ok: true, value: 'value2' }]); const tag3 = await Array.fromAsync(cache.iterateEntitiesByTag('tag3')); - expect(tag3).toEqual([{ uid, ok: true, data: 'data2' }]); + expect(tag3).toEqual([{ key, ok: true, value: 'value2' }]); }); it('should throw an error when retrieving a non-existing entity', async () => { - const uid = 'newuid'; + const key = 'newkey'; try { - await cache.getEntity(uid); + await cache.getEntity(key); fail('Should have thrown an error'); } catch (error) { expect(`${error}`).toBe('Error: Entity not found'); @@ -52,14 +52,14 @@ describe('MemoryCache', () => { it('should iterate over entities', async () => { const results = []; - for await (const result of cache.iterateEntities(['uid1', 'uid2', 'uid100'])) { + for await (const result of cache.iterateEntities(['key1', 'key2', 'key100'])) { results.push(result); } expect(results).toEqual([ - { uid: 'uid1', ok: true, data: 'data1' }, - { uid: 'uid2', ok: true, data: 'data2' }, - { uid: 'uid100', ok: false, error: 'Error: Entity not found' }, + { key: 'key1', ok: true, value: 'value1' }, + { key: 'key2', ok: true, value: 'value2' }, + { key: 'key100', ok: false, error: 'Error: Entity not found' }, ]); }); @@ -70,8 +70,8 @@ describe('MemoryCache', () => { } expect(results).toEqual([ - { uid: 'uid1', ok: true, data: 'data1' }, - { uid: 'uid2', ok: true, data: 'data2' }, + { key: 'key1', ok: true, value: 'value1' }, + { key: 'key2', ok: true, value: 'value2' }, ]); }); @@ -82,7 +82,7 @@ describe('MemoryCache', () => { } expect(results).toEqual([ - { uid: 'uid1', ok: true, data: 'data1' }, + { key: 'key1', ok: true, value: 'value1' }, ]); }); @@ -96,34 +96,34 @@ describe('MemoryCache', () => { }); it('should iterate over entities with concurrent changes to the same set', async () => { - const iterator = cache.iterateEntities(['uid1', 'uid2', 'uid3']); + const iterator = cache.iterateEntities(['key1', 'key2', 'key3']); const results: string[] = []; - const { value: { uid: uid1 } } = await iterator.next(); - results.push(uid1); - cache.removeEntities([uid1]); + const { value: { key: key1 } } = await iterator.next(); + results.push(key1); + cache.removeEntities([key1]); - let value = await iterator.next(); // uid2 - results.push(value.value.uid); + let value = await iterator.next(); // key2 + results.push(value.value.key); - value = await iterator.next(); // uid3 - results.push(value.value.uid); + value = await iterator.next(); // key3 + results.push(value.value.key); - expect(results).toEqual(['uid1', 'uid2', 'uid3']); + expect(results).toEqual(['key1', 'key2', 'key3']); }); it('should remove entities', async () => { - await cache.removeEntities(['uid1', 'uid3']); + await cache.removeEntities(['key1', 'key3']); const results = []; - for await (const result of cache.iterateEntities(['uid1', 'uid2', 'uid3'])) { + for await (const result of cache.iterateEntities(['key1', 'key2', 'key3'])) { results.push(result); } expect(results).toEqual([ - { uid: 'uid1', ok: false, error: 'Error: Entity not found' }, - { uid: 'uid2', ok: true, data: 'data2' }, - { uid: 'uid3', ok: false, error: 'Error: Entity not found' }, + { key: 'key1', ok: false, error: 'Error: Entity not found' }, + { key: 'key2', ok: true, value: 'value2' }, + { key: 'key3', ok: false, error: 'Error: Entity not found' }, ]); const results2 = []; @@ -137,14 +137,14 @@ describe('MemoryCache', () => { await cache.purge(); const results = []; - for await (const result of cache.iterateEntities(['uid1', 'uid2', 'uid3'])) { + for await (const result of cache.iterateEntities(['key1', 'key2', 'key3'])) { results.push(result); } expect(results).toEqual([ - { uid: 'uid1', ok: false, error: 'Error: Entity not found' }, - { uid: 'uid2', ok: false, error: 'Error: Entity not found' }, - { uid: 'uid3', ok: false, error: 'Error: Entity not found' }, + { key: 'key1', ok: false, error: 'Error: Entity not found' }, + { key: 'key2', ok: false, error: 'Error: Entity not found' }, + { key: 'key3', ok: false, error: 'Error: Entity not found' }, ]); }); }); diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index 5ea17b4a..a4a3150d 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -1,6 +1,6 @@ import type { ProtonDriveCache, EntityResult } from './interface.js'; -type KeyValueCache = { [ uid: string ]: T }; +type KeyValueCache = { [ key: string ]: T }; type TagsCache = { [ tag: string ]: string[] }; /** @@ -18,11 +18,11 @@ export class MemoryCache implements ProtonDriveCache { this.entities = {}; } - async setEntity(uid: string, data: T, tags?: string[]) { - this.entities[uid] = data; + async setEntity(key: string, value: T, tags?: string[]) { + this.entities[key] = value; for (const tag of Object.keys(this.entitiesByTag)) { - const index = this.entitiesByTag[tag].indexOf(uid); + const index = this.entitiesByTag[tag].indexOf(key); if (index !== -1) { this.entitiesByTag[tag].splice(index, 1); } @@ -33,48 +33,48 @@ export class MemoryCache implements ProtonDriveCache { if (!this.entitiesByTag[tag]) { this.entitiesByTag[tag] = []; } - this.entitiesByTag[tag].push(uid); + this.entitiesByTag[tag].push(key); } } } - async getEntity(uid: string) { - const data = this.entities[uid]; - if (!data) { + async getEntity(key: string) { + const value = this.entities[key]; + if (!value) { throw Error('Entity not found'); } - return data; + return value; } - async *iterateEntities(uids: string[]): AsyncGenerator> { - for (const uid of uids) { + async *iterateEntities(keys: string[]): AsyncGenerator> { + for (const key of keys) { try { - const data = await this.getEntity(uid); - yield { uid, ok: true, data }; + const value = await this.getEntity(key); + yield { key, ok: true, value }; } catch (error) { - yield { uid, ok: false, error: `${error}` }; + yield { key, ok: false, error: `${error}` }; } } } async *iterateEntitiesByTag(tag: string): AsyncGenerator> { - const uids = this.entitiesByTag[tag]; - if (!uids) { + const keys = this.entitiesByTag[tag]; + if (!keys) { return; } - // Pass copy of UIDs so concurrent changes to the cache do not affect + // Pass copy of keys so concurrent changes to the cache do not affect // results from iterating entities. - yield* this.iterateEntities([...uids]); + yield* this.iterateEntities([...keys]); } - async removeEntities(uids: string[]) { - for (const uid of uids) { - delete this.entities[uid]; - Object.values(this.entitiesByTag).forEach((uids) => { - const index = uids.indexOf(uid); + async removeEntities(keys: string[]) { + for (const key of keys) { + delete this.entities[key]; + Object.values(this.entitiesByTag).forEach((tagKeys) => { + const index = tagKeys.indexOf(key); if (index !== -1) { - uids.splice(index, 1); + tagKeys.splice(index, 1); } }); } diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 308aebe8..d6ea82a6 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -70,7 +70,7 @@ export class NodesCache { // Force all calls to children UIDs to be re-fetched. for await (const result of this.driveCache.iterateEntitiesByTag(`children-volume:${volumeId}`)) { - await this.driveCache.removeEntities([result.uid]); + await this.driveCache.removeEntities([result.key]); } } @@ -117,8 +117,8 @@ export class NodesCache { private async getRecursiveChildrenCacheUids(parentNodeUid: string): Promise { const cacheUids = []; for await (const result of this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`)) { - cacheUids.push(result.uid); - const childrenCacheUids = await this.getRecursiveChildrenCacheUids(getNodeUid(result.uid)); + cacheUids.push(result.key); + const childrenCacheUids = await this.getRecursiveChildrenCacheUids(getNodeUid(result.key)); cacheUids.push(...childrenCacheUids); } return cacheUids; @@ -159,15 +159,15 @@ export class NodesCache { private async convertCacheResult(result: EntityResult): Promise { let nodeUid; try { - nodeUid = getNodeUid(result.uid); + nodeUid = getNodeUid(result.key); } catch (error: unknown) { - await this.removeCorruptedNode({ cacheUid: result.uid }, error) + await this.removeCorruptedNode({ cacheUid: result.key }, error) return null; } if (result.ok) { let node; try { - node = deserialiseNode(result.data) + node = deserialiseNode(result.value) } catch (error: unknown) { await this.removeCorruptedNode({ nodeUid }, error); return null; From 78430a6668375a368aa45a6f2a785767820c6c0d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 11 Mar 2025 11:45:58 +0000 Subject: [PATCH 037/791] Implement telemetry --- js/sdk/src/index.ts | 11 +- js/sdk/src/interface/constructor.ts | 51 ------- js/sdk/src/interface/index.ts | 10 +- js/sdk/src/interface/telemetry.ts | 84 +++++++++++ .../internal/apiService/apiService.test.ts | 7 +- js/sdk/src/internal/apiService/apiService.ts | 19 +-- js/sdk/src/internal/download/index.ts | 2 - .../src/internal/events/coreEventManager.ts | 4 +- .../src/internal/events/eventManager.test.ts | 2 + js/sdk/src/internal/events/eventManager.ts | 7 +- js/sdk/src/internal/events/index.ts | 23 ++- .../src/internal/events/volumeEventManager.ts | 4 +- js/sdk/src/internal/nodes/apiService.test.ts | 3 +- js/sdk/src/internal/nodes/apiService.ts | 4 +- js/sdk/src/internal/nodes/cache.test.ts | 3 +- js/sdk/src/internal/nodes/cache.ts | 10 +- .../src/internal/nodes/cryptoService.test.ts | 86 +++++++++-- js/sdk/src/internal/nodes/cryptoService.ts | 82 ++++++++++- js/sdk/src/internal/nodes/events.test.ts | 37 ++--- js/sdk/src/internal/nodes/events.ts | 20 +-- .../internal/nodes/extendedAttributes.test.ts | 5 +- .../src/internal/nodes/extendedAttributes.ts | 28 ++-- js/sdk/src/internal/nodes/index.ts | 43 ++---- js/sdk/src/internal/nodes/nodesAccess.test.ts | 3 +- js/sdk/src/internal/nodes/nodesAccess.ts | 19 ++- js/sdk/src/internal/nodes/nodesRevisions.ts | 6 +- js/sdk/src/internal/shares/apiService.ts | 1 + js/sdk/src/internal/shares/cache.test.ts | 3 +- js/sdk/src/internal/shares/cache.ts | 7 +- .../src/internal/shares/cryptoService.test.ts | 134 ++++++++++++++++++ js/sdk/src/internal/shares/cryptoService.ts | 75 ++++++++-- js/sdk/src/internal/shares/index.ts | 9 +- js/sdk/src/internal/shares/interface.ts | 1 + js/sdk/src/internal/shares/manager.test.ts | 3 +- js/sdk/src/internal/shares/manager.ts | 10 +- js/sdk/src/internal/sharing/events.test.ts | 7 +- js/sdk/src/internal/sharing/events.ts | 12 +- js/sdk/src/internal/sharing/index.ts | 8 +- .../sharing/sharingManagement.test.ts | 3 +- .../src/internal/sharing/sharingManagement.ts | 40 +++--- js/sdk/src/protonDriveClient.ts | 57 ++++++-- js/sdk/src/protonDrivePhotosClient.ts | 16 ++- js/sdk/src/protonDrivePublicClient.ts | 53 ------- js/sdk/src/telemetry.ts | 20 ++- js/sdk/src/tests/logger.ts | 10 ++ js/sdk/src/tests/telemetry.ts | 9 ++ 46 files changed, 723 insertions(+), 328 deletions(-) create mode 100644 js/sdk/src/interface/telemetry.ts create mode 100644 js/sdk/src/internal/shares/cryptoService.test.ts delete mode 100644 js/sdk/src/protonDrivePublicClient.ts create mode 100644 js/sdk/src/tests/logger.ts create mode 100644 js/sdk/src/tests/telemetry.ts diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 307b6f12..42226f79 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -1,6 +1,5 @@ -export * from './interface/index.js'; -export * from './cache/index.js'; -export { OpenPGPCryptoWithCryptoProxy, OpenPGPCrypto } from './crypto/index.js'; -export { ProtonDriveClient } from './protonDriveClient.js'; -export { ProtonDrivePhotosClient } from './protonDrivePhotosClient.js'; -export { ProtonDrivePublicClient } from './protonDrivePublicClient.js'; +export * from './interface'; +export * from './cache'; +export { OpenPGPCryptoWithCryptoProxy, OpenPGPCrypto } from './crypto'; +export { ProtonDriveClient } from './protonDriveClient'; +export { ProtonDrivePhotosClient } from './protonDrivePhotosClient'; diff --git a/js/sdk/src/interface/constructor.ts b/js/sdk/src/interface/constructor.ts index 4841060b..d59e54ef 100644 --- a/js/sdk/src/interface/constructor.ts +++ b/js/sdk/src/interface/constructor.ts @@ -25,15 +25,6 @@ export interface ProtonDriveHTTPClient { fetch(request: Request, signal?: AbortSignal): Promise, } -export type GetLogger = (name: string) => Logger; - -export interface Logger { - debug(msg: string, ...x: any[]): void; // eslint-disable-line @typescript-eslint/no-explicit-any - info(msg: string, ...x: any[]): void; // eslint-disable-line @typescript-eslint/no-explicit-any - warn(msg: string, ...x: any[]): void; // eslint-disable-line @typescript-eslint/no-explicit-any - error(msg: string, ...x: any[]): void; // eslint-disable-line @typescript-eslint/no-explicit-any -} - export type ProtonDriveConfig = { baseUrl?: string, language?: string, @@ -43,45 +34,3 @@ export type ProtonDriveConfig = { downloadTimeout?: number, downloadQueueLimitItems?: number, } - -export type MetricsShareType = 'main' | 'device' | 'shared' | 'shared_public' | 'photo'; -export type MetricsUploadErrorType = - 'free_space_exceeded' | - 'too_many_children' | - 'network_error' | - 'server_error' | - 'integrity_error' | - 'rate_limited' | - '4xx' | - '5xx' | - 'unknown'; -export type MetricsDownloadErrorType = - 'server_error' | - 'network_error' | - 'decryption_error' | - 'rate_limited' | - '4xx' | - '5xx' | - 'unknown'; - -export interface Metrics { - uploadSucceeded(shareType: MetricsShareType, retry: boolean, uploadedSize: number, fileSize: number): void, - uploadFailed(shareType: MetricsShareType, retry: boolean, uploadedSize: number, fileSize: number, error: MetricsUploadErrorType): void, - - downloadSucceeded(shareType: MetricsShareType, retry: boolean, downloadedSize: number, fileSize: number): void, - downloadFailed(shareType: MetricsShareType, retry: boolean, downloadedSize: number, fileSize: number, error: MetricsDownloadErrorType): void, - - decryptionFailed( - shareType: MetricsShareType, - entity: 'share' | 'node' | 'content', - fromBefore2024?: boolean - ): void, - varificationFailed( - shareType: MetricsShareType, - verificationKey: 'ShareAddress' | 'NameSignatureEmail' | 'SignatureEmail' | 'NodeKey' | 'other', - addressMatchingDefaultShare?: boolean, - fromBefore2024?: boolean - ): void, - - numberOfVolumeEventsSubscriptionsChanged(number: number): void, -} diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index f054cdbc..a0183c13 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -1,16 +1,17 @@ import { ProtonDriveCache } from '../cache'; import { OpenPGPCrypto, PrivateKey, SessionKey } from '../crypto'; -import { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig, GetLogger, Metrics } from './constructor'; +import { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig } from './constructor'; import { Devices } from './devices'; import { Download } from './download'; import { Events } from './events'; import { Nodes, NodesManagement, TrashManagement, Revisions } from './nodes'; import { Sharing, SharingManagement } from './sharing'; +import { Telemetry, MetricEvent } from './telemetry'; import { Upload } from './upload'; export type { Result } from './result'; export { resultOk, resultError } from './result'; -export type { ProtonDriveAccount, ProtonDriveAccountAddress, ProtonDriveHTTPClient, ProtonDriveConfig, GetLogger, Logger, Metrics, MetricsShareType, MetricsUploadErrorType, MetricsDownloadErrorType } from './constructor'; +export type { ProtonDriveAccount, ProtonDriveAccountAddress, ProtonDriveHTTPClient, ProtonDriveConfig } from './constructor'; export type { Device, DeviceOrUid } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; @@ -18,8 +19,10 @@ export type { Author, NodeEntity, InvalidNameError, UnverifiedAuthorError, Anony export { NodeType, MemberRole } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; +export type { Telemetry, Logger, MetricUploadEvent, MetricDownloadEvent, MetricDecryptionErrorEvent, MetricVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent } from './telemetry'; export type { Fileuploader, UploadController, Thumbnail, ThumbnailType, UploadMetadata } from './upload'; +export type ProtonDriveTelemetry = Telemetry; export type ProtonDriveEntitiesCache = ProtonDriveCache; export type ProtonDriveCryptoCache = ProtonDriveCache; export type CachedCryptoMaterial = { @@ -34,9 +37,8 @@ export interface ProtonDriveClientContructorParameters { cryptoCache: ProtonDriveCryptoCache, account: ProtonDriveAccount, httpClient: ProtonDriveHTTPClient, - getLogger?: GetLogger, config?: ProtonDriveConfig, - metrics?: Metrics, + telemetry?: ProtonDriveTelemetry, openPGPCryptoModule: OpenPGPCrypto, acceptNoGuaranteeWithCustomModules?: boolean, }; diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts new file mode 100644 index 00000000..782c7e47 --- /dev/null +++ b/js/sdk/src/interface/telemetry.ts @@ -0,0 +1,84 @@ +export interface Telemetry { + getLogger: (name: string) => Logger, + logEvent: (event: MetricEvent) => void, +} + +export interface Logger { + debug(msg: string): void; // eslint-disable-line @typescript-eslint/no-explicit-any + info(msg: string): void; // eslint-disable-line @typescript-eslint/no-explicit-any + warn(msg: string): void; // eslint-disable-line @typescript-eslint/no-explicit-any + error(msg: string, error?: unknown): void; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +export type MetricEvent = + MetricAPIRetrySucceededEvent | + MetricUploadEvent | + MetricDownloadEvent | + MetricDecryptionErrorEvent | + MetricVerificationErrorEvent | + MetricVolumeEventsSubscriptionsChangedEvent; + +export interface MetricAPIRetrySucceededEvent { + eventName: 'apiRetrySucceeded', + url: string, + failedAttempts: number, +}; + +export interface MetricUploadEvent { + eventName: 'upload', + context: MetricContext, + retry: boolean, + uploadedSize: number, + fileSize: number, + error?: MetricsUploadErrorType, +}; +export type MetricsUploadErrorType = + 'free_space_exceeded' | + 'too_many_children' | + 'network_error' | + 'server_error' | + 'integrity_error' | + 'rate_limited' | + '4xx' | + '5xx' | + 'unknown'; + +export interface MetricDownloadEvent { + eventName: 'download', + context: MetricContext, + retry: boolean, + downloadedSize: number, + fileSize: number, + error?: MetricsDownloadErrorType, +}; +export type MetricsDownloadErrorType = + 'server_error' | + 'network_error' | + 'decryption_error' | + 'rate_limited' | + '4xx' | + '5xx' | + 'unknown'; + +export interface MetricDecryptionErrorEvent { + eventName: 'decryptionError', + context: MetricContext, + entity: 'share' | 'node' | 'content', + fromBefore2024?: boolean, + error?: unknown, +}; + +export interface MetricVerificationErrorEvent { + eventName: 'verificationError', + context: MetricContext, + verificationKey: 'ShareAddress' | 'NodeKey' | 'SignatureEmail' | 'NameSignatureEmail' | 'other', + addressMatchingDefaultShare?: boolean, + fromBefore2024?: boolean, +}; + +export interface MetricVolumeEventsSubscriptionsChangedEvent { + eventName: 'volumeEventsSubscriptionsChanged', + numberOfVolumeSubscriptions: number, +}; + +export type MetricContext = 'own_volume' | 'shared' | 'shared_public' | 'photo'; diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 00038560..b18bcb41 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -1,4 +1,5 @@ -import { ProtonDriveHTTPClient } from "../../interface/index.js"; +import { ProtonDriveHTTPClient } from "../../interface"; +import { getMockTelemetry } from "../../tests/telemetry"; import { DriveAPIService } from './apiService'; import { HTTPErrorCode, ErrorCode } from './errorCodes'; @@ -18,8 +19,8 @@ describe("DriveAPIService", () => { httpClient = { fetch: jest.fn(() => Promise.resolve(generateOkResponse())), }; - api = new DriveAPIService(httpClient, 'http://drive.proton.me', 'en'); - }) + api = new DriveAPIService(getMockTelemetry(), httpClient, 'http://drive.proton.me', 'en'); + }); describe("should make", () => { it("GET request", async () => { diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 4cb71622..343cc26e 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -1,4 +1,4 @@ -import { ProtonDriveHTTPClient, Logger } from "../../interface/index.js"; +import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from "../../interface"; import { HTTPErrorCode, isCodeOk } from './errorCodes'; import { apiErrorFactory, AbortError, APIError } from './errors'; import { waitSeconds } from './wait'; @@ -73,11 +73,14 @@ export class DriveAPIService { private subsequentServerErrorsCounter = 0; private lastServerErrorAt?: number; - constructor(private httpClient: ProtonDriveHTTPClient, private baseUrl: string, private language: string, private logger?: Logger) { + private logger: Logger; + + constructor(private telemetry: ProtonDriveTelemetry, private httpClient: ProtonDriveHTTPClient, private baseUrl: string, private language: string) { this.httpClient = httpClient; this.baseUrl = baseUrl; this.language = language; - this.logger = logger; + this.telemetry = telemetry; + this.logger = telemetry.getLogger('api'); } async get(url: string, signal?: AbortSignal): Promise { @@ -153,9 +156,9 @@ export class DriveAPIService { } if (response.ok) { - this.logger?.info(`${method} ${url}: ${response.status}`); + this.logger.info(`${method} ${url}: ${response.status}`); } else { - this.logger?.warn(`${method} ${url}: ${response.status}`); + this.logger.warn(`${method} ${url}: ${response.status}`); } if (response.status === HTTPErrorCode.TOO_MANY_REQUESTS) { @@ -174,15 +177,15 @@ export class DriveAPIService { this.serverErrorHappened(); if (attempt > 0) { - this.logger?.warn(`${method} ${url}: ${response.status} - retry failed`); + this.logger.warn(`${method} ${url}: ${response.status} - retry failed`); } else { await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); return this.makeRequest(url, method, data, signal, attempt+1); } } else { if (attempt > 0) { - // TODO: send to metrics - this.logger?.warn(`${method} ${url}: ${response.status} - retry helped`); + this.telemetry.logEvent({ eventName: 'apiRetrySucceeded', failedAttempts: attempt, url }); + this.logger.warn(`${method} ${url}: ${response.status} - retry helped`); } this.clearSubsequentServerErrors(); } diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts index b204fa26..5a682355 100644 --- a/js/sdk/src/internal/download/index.ts +++ b/js/sdk/src/internal/download/index.ts @@ -23,5 +23,3 @@ export function initDownloadModule( getFileDownloader, } } - - diff --git a/js/sdk/src/internal/events/coreEventManager.ts b/js/sdk/src/internal/events/coreEventManager.ts index 571394f9..695c3a39 100644 --- a/js/sdk/src/internal/events/coreEventManager.ts +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -18,14 +18,14 @@ import { EventManager } from "./eventManager"; export class CoreEventManager { private manager: EventManager; - constructor(private apiService: EventsAPIService, private cache: EventsCache, log?: Logger) { + constructor(logger: Logger, private apiService: EventsAPIService, private cache: EventsCache) { this.apiService = apiService; this.manager = new EventManager( + logger, () => this.getLastEventId(), (eventId) => this.apiService.getCoreEvents(eventId), (lastEventId) => this.cache.setLastEventId('core', lastEventId, this.manager.pollingIntervalInSeconds), - log, ); } diff --git a/js/sdk/src/internal/events/eventManager.test.ts b/js/sdk/src/internal/events/eventManager.test.ts index 7fafc2a8..e5dedd6b 100644 --- a/js/sdk/src/internal/events/eventManager.test.ts +++ b/js/sdk/src/internal/events/eventManager.test.ts @@ -1,3 +1,4 @@ +import { getMockLogger } from "../../tests/logger"; import { NotFoundAPIError } from "../apiService"; import { EventManager } from "./eventManager"; @@ -23,6 +24,7 @@ describe("EventManager", () => { })); manager = new EventManager( + getMockLogger(), getLastEventIdMock, getEventsMock, eventsProcessedMock, diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts index c988620b..6f48da9d 100644 --- a/js/sdk/src/internal/events/eventManager.ts +++ b/js/sdk/src/internal/events/eventManager.ts @@ -50,11 +50,12 @@ export class EventManager { pollingIntervalInSeconds = DEFAULT_POLLING_INTERVAL_IN_SECONDS; constructor( + private logger: Logger, private getLatestEventId: () => Promise, private getEvents: (eventId: string) => Promise>, private eventsProcessed: (lastEventId: string) => Promise, - private log?: Logger, ) { + this.logger = logger; this.getLatestEventId = getLatestEventId; this.getEvents = getEvents; } @@ -102,7 +103,7 @@ export class EventManager { } this.retryIndex = 0; } catch (error: unknown) { - this.log?.error(`Failed to process events: ${error instanceof Error ? error.message : error} (retry ${this.retryIndex}, last event ID: ${this.lastestEventId})`); + this.logger.error(`Failed to process events: ${error instanceof Error ? error.message : error} (retry ${this.retryIndex}, last event ID: ${this.lastestEventId})`); this.retryIndex++; } @@ -120,7 +121,7 @@ export class EventManager { try { await listener(result.events, result.refresh); } catch (error: unknown) { - this.log?.error(`Failed to process events: ${error instanceof Error ? error.message : error} (last event ID: ${result.lastEventId}, refresh: ${result.refresh})`); + this.logger.error(`Failed to process events: ${error instanceof Error ? error.message : error} (last event ID: ${result.lastEventId}, refresh: ${result.refresh})`); throw error; } } diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 8f139233..ba73048c 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -1,4 +1,4 @@ -import { ProtonDriveEntitiesCache, Logger } from "../../interface"; +import { ProtonDriveEntitiesCache, Logger, ProtonDriveTelemetry } from "../../interface"; import { DriveAPIService } from "../apiService"; import { DriveListener } from "./interface"; import { EventsAPIService } from "./apiService"; @@ -23,14 +23,16 @@ export class DriveEventsService { private listeners: DriveListener[] = []; private coreEvents: CoreEventManager; private volumesEvents: { [volumeId: string]: VolumeEventManager }; + private logger: Logger; - constructor(apiService: DriveAPIService, driveEntitiesCache: ProtonDriveEntitiesCache, private log?: Logger) { + constructor(private telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, driveEntitiesCache: ProtonDriveEntitiesCache) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('events'); this.apiService = new EventsAPIService(apiService); this.cache = new EventsCache(driveEntitiesCache); - this.log = log; // TODO: Allow to pass own core events manager from the public interface. - this.coreEvents = new CoreEventManager(this.apiService, this.cache, this.log); + this.coreEvents = new CoreEventManager(this.logger, this.apiService, this.cache); this.volumesEvents = {}; } @@ -45,6 +47,7 @@ export class DriveEventsService { } await this.loadSubscribedVolumeEventServices(); + this.sendNumberOfVolumSubscriptionsToTelemetry(); this.subscribedToRemoteDataUpdates = true; this.coreEvents.startSubscription(); @@ -66,24 +69,32 @@ export class DriveEventsService { if (this.volumesEvents[volumeId]) { return; } - const volumeEvents = new VolumeEventManager(this.apiService, this.cache, volumeId, this.log); + const volumeEvents = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId); this.volumesEvents[volumeId] = volumeEvents; // TODO: Use dynamic algorithm to determine polling interval for non-own volumes. volumeEvents.setPollingInterval(isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL); if (this.subscribedToRemoteDataUpdates) { volumeEvents.startSubscription(); + this.sendNumberOfVolumSubscriptionsToTelemetry(); } } private async loadSubscribedVolumeEventServices() { for (const volumeId of await this.cache.getSubscribedVolumeIds()) { if (!this.volumesEvents[volumeId]) { - this.volumesEvents[volumeId] = new VolumeEventManager(this.apiService, this.cache, volumeId, this.log); + this.volumesEvents[volumeId] = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId); } } } + private async sendNumberOfVolumSubscriptionsToTelemetry() { + this.telemetry.logEvent({ + eventName: 'volumeEventsSubscriptionsChanged', + numberOfVolumeSubscriptions: Object.keys(this.volumesEvents).length, + }); + } + /** * Listen to the drive events. The listener will be called with the * new events as they arrive. diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index f683b200..d90cf350 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -12,15 +12,15 @@ import { EventManager } from "./eventManager"; export class VolumeEventManager { private manager: EventManager; - constructor(private apiService: EventsAPIService, private cache: EventsCache, private volumeId: string, log?: Logger) { + constructor(logger: Logger, private apiService: EventsAPIService, private cache: EventsCache, private volumeId: string) { this.apiService = apiService; this.volumeId = volumeId; this.manager = new EventManager( + logger, () => this.getLastEventId(), (eventId) => this.apiService.getVolumeEvents(volumeId, eventId), (lastEventId) => this.cache.setLastEventId(volumeId, lastEventId, this.manager.pollingIntervalInSeconds), - log, ); this.cache.getPollingIntervalInSeconds(volumeId) .then((pollingIntervalInSeconds) => { diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index d6bccd83..066d5a96 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -1,4 +1,5 @@ import { MemberRole, NodeType } from "../../interface"; +import { getMockLogger } from "../../tests/logger"; import { DriveAPIService, ErrorCode } from "../apiService"; import { NodeAPIService } from './apiService'; @@ -141,7 +142,7 @@ describe("nodeAPIService", () => { put: jest.fn(), }; - api = new NodeAPIService(apiMock); + api = new NodeAPIService(getMockLogger(), apiMock); }); describe('getNodes', () => { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 88d0f749..bb2a0d88 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -47,9 +47,9 @@ type DeleteRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{li * and vice versa. It should not contain any business logic. */ export class NodeAPIService { - constructor(private apiService: DriveAPIService, private logger?: Logger) { - this.apiService = apiService; + constructor(private logger: Logger, private apiService: DriveAPIService) { this.logger = logger; + this.apiService = apiService; } async getNode(nodeUid: string, signal?: AbortSignal): Promise { diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index c6775ebb..afc4609d 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -1,5 +1,6 @@ import { MemoryCache } from "../../cache"; import { NodeType, MemberRole } from "../../interface"; +import { getMockLogger } from "../../tests/logger"; import { CACHE_TAG_KEYS, NodesCache } from "./cache"; import { DecryptedNode } from "./interface"; @@ -68,7 +69,7 @@ describe('nodesCache', () => { memoryCache.setEntity('node-volumeId~:root', JSON.stringify(generateNode('root', ''))); memoryCache.setEntity('node-badObject', 'aaa', [`${CACHE_TAG_KEYS.ParentUid}:root`]); - cache = new NodesCache(memoryCache); + cache = new NodesCache(getMockLogger(), memoryCache); }); it('should store and retrieve node', async () => { diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index d6ea82a6..2c952446 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -22,9 +22,9 @@ type DecryptedNodeResult = ( * The cache of node metadata should not contain any crypto material. */ export class NodesCache { - constructor(private driveCache: ProtonDriveEntitiesCache, private logger?: Logger) { - this.driveCache = driveCache; + constructor(private logger: Logger, private driveCache: ProtonDriveEntitiesCache) { this.logger = logger; + this.driveCache = driveCache; } async setNode(node: DecryptedNode): Promise { @@ -81,7 +81,7 @@ export class NodesCache { * fix issues and do not bother user with it. */ private async removeCorruptedNode({ nodeUid, cacheUid }: { nodeUid?: string, cacheUid?: string }, corruptionError: unknown): Promise { - this.logger?.error(`Removing corrupted nodes from the cache: ${corruptionError instanceof Error ? corruptionError.message : corruptionError}`); + this.logger.error(`Removing corrupted nodes from the cache`, corruptionError); try { if (nodeUid) { await this.removeNodes([nodeUid]); @@ -92,7 +92,7 @@ export class NodesCache { // The node will not be returned, thus SDK will re-fetch // and re-cache it. Setting it again should then fix the // problem. - this.logger?.warn(`Failed to remove corrupted node from the cache: ${removingError instanceof Error ? removingError.message : removingError}`); + this.logger.warn(`Failed to remove corrupted node from the cache: ${removingError instanceof Error ? removingError.message : removingError}`); } } @@ -109,7 +109,7 @@ export class NodesCache { await this.driveCache.removeEntities(childrenCacheUids); } catch (error: unknown) { // TODO: Should we throw here to the client? - this.logger?.error(`Failed to remove children from the cache: ${error instanceof Error ? error.message : error}`); + this.logger.error(`Failed to remove children from the cache`, error); } } } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 56b8aed8..2fcec171 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,9 +1,11 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { ProtonDriveAccount } from "../../interface"; +import { ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; +import { getMockTelemetry } from "../../tests/telemetry"; import { EncryptedNode, SharesService } from "./interface"; import { NodesCryptoService } from "./cryptoService"; describe("nodesCryptoService", () => { + let telemetry: ProtonDriveTelemetry; let driveCrypto: DriveCrypto; let account: ProtonDriveAccount; let sharesService: SharesService; @@ -13,6 +15,7 @@ describe("nodesCryptoService", () => { beforeEach(() => { jest.clearAllMocks(); + telemetry = getMockTelemetry(); // @ts-expect-error No need to implement all methods for mocking driveCrypto = { decryptKey: jest.fn(async () => Promise.resolve({ @@ -49,12 +52,13 @@ describe("nodesCryptoService", () => { })), }; - cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); + cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); }); it("should decrypt node with same author everywhere", async () => { const result = await cryptoService.decryptNode( { + uid: "volumeId:nodeId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "signatureEmail", @@ -66,7 +70,7 @@ describe("nodesCryptoService", () => { "parentKey" as unknown as PrivateKey ); - expect(result).toEqual({ + expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, keyAuthor: { ok: true, value: "signatureEmail" }, @@ -83,6 +87,7 @@ describe("nodesCryptoService", () => { expect(account.getPublicKeys).toHaveBeenCalledTimes(1); expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(telemetry.logEvent).not.toHaveBeenCalled(); }); it("should decrypt node with different authors", async () => { @@ -117,6 +122,7 @@ describe("nodesCryptoService", () => { expect(account.getPublicKeys).toHaveBeenCalledTimes(2); expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); expect(account.getPublicKeys).toHaveBeenCalledWith("nameSignatureEmail"); + expect(telemetry.logEvent).not.toHaveBeenCalled(); }); it("should decrypt folder node", async () => { @@ -154,6 +160,7 @@ describe("nodesCryptoService", () => { hashKey: new Uint8Array(), }, }); + expect(telemetry.logEvent).not.toHaveBeenCalled(); }); it("should decrypt folder node with signature validation error on key", async () => { @@ -166,6 +173,7 @@ describe("nodesCryptoService", () => { const result = await cryptoService.decryptNode( { + uid: "volumeId~nodeId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -180,7 +188,7 @@ describe("nodesCryptoService", () => { "parentKey" as unknown as PrivateKey ); - expect(result).toEqual({ + expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing key signature" } }, @@ -197,6 +205,13 @@ describe("nodesCryptoService", () => { hashKey: new Uint8Array(), }, }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: "verificationError", + context: "own_volume", + fromBefore2024: false, + verificationKey: "SignatureEmail", + addressMatchingDefaultShare: false, + }); }); it("should decrypt folder node with signature validation error on name", async () => { @@ -207,6 +222,7 @@ describe("nodesCryptoService", () => { const result = await cryptoService.decryptNode( { + uid: "volumeId~nodeId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -221,7 +237,7 @@ describe("nodesCryptoService", () => { "parentKey" as unknown as PrivateKey ); - expect(result).toEqual({ + expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, keyAuthor: { ok: true, value: "signatureEmail" }, @@ -238,6 +254,13 @@ describe("nodesCryptoService", () => { hashKey: new Uint8Array(), }, }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: "verificationError", + context: "own_volume", + fromBefore2024: false, + verificationKey: "NameSignatureEmail", + addressMatchingDefaultShare: false, + }); }); it("should decrypt folder node with signature validation error on hash key", async () => { @@ -248,6 +271,7 @@ describe("nodesCryptoService", () => { const result = await cryptoService.decryptNode( { + uid: "volumeId~nodeId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -262,7 +286,7 @@ describe("nodesCryptoService", () => { "parentKey" as unknown as PrivateKey ); - expect(result).toEqual({ + expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Verification of hash key signature failed" } }, @@ -279,6 +303,13 @@ describe("nodesCryptoService", () => { hashKey: new Uint8Array(), }, }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: "verificationError", + context: "own_volume", + fromBefore2024: false, + verificationKey: "NodeKey", + addressMatchingDefaultShare: false, + }); }); it("should decrypt folder node with signature validation error on key and hash key", async () => { @@ -295,6 +326,7 @@ describe("nodesCryptoService", () => { const result = await cryptoService.decryptNode( { + uid: "volumeId~nodeId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -309,7 +341,7 @@ describe("nodesCryptoService", () => { "parentKey" as unknown as PrivateKey ); - expect(result).toEqual({ + expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing key signature" } }, @@ -326,6 +358,14 @@ describe("nodesCryptoService", () => { hashKey: new Uint8Array(), }, }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: "verificationError", + context: "own_volume", + fromBefore2024: false, + verificationKey: "SignatureEmail", + addressMatchingDefaultShare: false, + }); + expect(telemetry.logEvent).toHaveBeenCalledTimes(1); }); it("should decrypt folder node with signature validation error on extended attributes", async () => { @@ -336,6 +376,7 @@ describe("nodesCryptoService", () => { const result = await cryptoService.decryptNode( { + uid: "volumeId~nodeId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -351,7 +392,7 @@ describe("nodesCryptoService", () => { "parentKey" as unknown as PrivateKey ); - expect(result).toEqual({ + expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Verification of extended attributes signature failed" } }, @@ -368,6 +409,7 @@ describe("nodesCryptoService", () => { hashKey: new Uint8Array(), }, }); + expect(telemetry.logEvent).not.toHaveBeenCalled(); }); it("should decrypt file node", async () => { @@ -414,6 +456,7 @@ describe("nodesCryptoService", () => { hashKey: undefined, }, }); + expect(telemetry.logEvent).not.toHaveBeenCalled(); }); it("should decrypt file node with signature validation error on extended attribute", async () => { @@ -465,13 +508,16 @@ describe("nodesCryptoService", () => { hashKey: undefined, }, }); + expect(telemetry.logEvent).not.toHaveBeenCalled(); }); it("should handle decrypt of node with key decryption issue", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.reject(new Error("Decryption error"))); + const error = new Error("Decryption error"); + driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode( { + uid: "volumeId~nodeId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -486,7 +532,7 @@ describe("nodesCryptoService", () => { "parentKey" as unknown as PrivateKey ); - expect(result).toEqual({ + expect(result).toMatchObject({ node: { name: { ok: false, error: { name: "", error: "Failed to decrypt node key: Decryption error"} }, keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Failed to decrypt node key: Decryption error" } }, @@ -494,13 +540,22 @@ describe("nodesCryptoService", () => { activeRevision: { ok: false, error: new Error("Failed to decrypt node key: Decryption error") }, }, }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: "decryptionError", + context: "own_volume", + entity: "node", + fromBefore2024: false, + error, + }); }); it("should handle decrypt of node with name decryption issue", async () => { - driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(new Error("Decryption error"))); + const error = new Error("Decryption error"); + driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode( { + uid: "volumeId~nodeId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -512,7 +567,7 @@ describe("nodesCryptoService", () => { "parentKey" as unknown as PrivateKey ); - expect(result).toEqual({ + expect(result).toMatchObject({ node: { name: { ok: false, error: { name: "", error: "Decryption error" } }, keyAuthor: { ok: true, value: "signatureEmail" }, @@ -526,5 +581,12 @@ describe("nodesCryptoService", () => { hashKey: undefined, }, }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: "decryptionError", + context: "own_volume", + entity: "node", + fromBefore2024: false, + error, + }); }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 7cad4e47..e2e5ed4b 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,5 +1,5 @@ import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { resultOk, resultError, Result, InvalidNameError, Author, ProtonDriveAccount } from "../../interface"; +import { resultOk, resultError, Result, InvalidNameError, Author, ProtonDriveAccount, ProtonDriveTelemetry, Logger } from "../../interface"; import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, SharesService, EncryptedRevision, DecryptedRevision } from "./interface"; // TODO: Switch to CryptoProxy module once available. @@ -17,11 +17,19 @@ import { splitNodeUid } from "../uids"; * The service owns the logic to switch between old and new crypto model. */ export class NodesCryptoService { + private logger: Logger; + + private reportedDecryptionErrors = new Set(); + private reportedVerificationErrors = new Set(); + constructor( + private telemetry: ProtonDriveTelemetry, private driveCrypto: DriveCrypto, private account: ProtonDriveAccount, private shareService: SharesService, ) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('nodes-crypto'); this.driveCrypto = driveCrypto; this.account = account; this.shareService = shareService; @@ -56,6 +64,7 @@ export class NodesCryptoService { sessionKey = keyResult.sessionKey; keyAuthor = keyResult.author; } catch (error: unknown) { + this.reportDecryptionError(node, error); const errorMessage = `Failed to decrypt node key: ${error instanceof Error ? error.message : 'Unknown error'}`; return { node: { @@ -161,7 +170,7 @@ export class NodesCryptoService { passphrase: key.passphrase, key: key.key, sessionKey: key.sessionKey, - author: handleClaimedAuthor('key', key.verified, node.encryptedCrypto.signatureEmail), + author: await this.handleClaimedAuthor(node, 'SignatureEmail', 'key', key.verified, node.encryptedCrypto.signatureEmail), }; }; @@ -180,9 +189,10 @@ export class NodesCryptoService { return { name: resultOk(name), - author: handleClaimedAuthor('name', verified, nameSignatureEmail), + author: await this.handleClaimedAuthor(node, 'NameSignatureEmail', 'name', verified, nameSignatureEmail), } } catch (error: unknown) { + this.reportDecryptionError(node, error); // TODO: Translation const message = error instanceof Error ? error.message : 'Unknown error'; return { @@ -218,7 +228,7 @@ export class NodesCryptoService { return { hashKey, - author: handleClaimedAuthor('hash key', verified, node.encryptedCrypto.signatureEmail), + author: await this.handleClaimedAuthor(node, 'NodeKey', 'hash key', verified, node.encryptedCrypto.signatureEmail), } } @@ -366,6 +376,70 @@ export class NodesCryptoService { const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); return arrayToHexString(signature); } + + private async handleClaimedAuthor( + node: EncryptedNode, + verificationKey: 'NodeKey' | 'SignatureEmail' | 'NameSignatureEmail', + signatureType: string, + verified: VERIFICATION_STATUS, + claimedAuthor?: string + ): Promise { + const author = handleClaimedAuthor(signatureType, verified, claimedAuthor); + if (!author.ok) { + await this.reportVerificationError(node, verificationKey, claimedAuthor); + } + return author; + } + + private async reportVerificationError( + node: EncryptedNode, + verificationKey: 'NodeKey' | 'SignatureEmail' | 'NameSignatureEmail', + claimedAuthor?: string, + ) { + if (this.reportedVerificationErrors.has(node.uid)) { + return; + } + + const fromBefore2024 = node.createdDate < new Date('2024-01-01'); + + let addressMatchingDefaultShare = undefined; + try { + const { volumeId } = splitNodeUid(node.uid); + const { email } = await this.shareService.getVolumeEmailKey(volumeId); + addressMatchingDefaultShare = claimedAuthor ? claimedAuthor === email : undefined; + } catch (error: unknown) { + this.logger.error('Failed to check if claimed author matches default share', error); + } + + this.logger.error(`Failed to verify node ${node.uid} using ${verificationKey} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); + + this.telemetry.logEvent({ + eventName: 'verificationError', + context: 'own_volume', // TODO: add context to the node + verificationKey, + addressMatchingDefaultShare, + fromBefore2024, + }); + this.reportedVerificationErrors.add(node.uid); + } + + private reportDecryptionError(node: EncryptedNode, error?: unknown) { + if (this.reportedDecryptionErrors.has(node.uid)) { + return; + } + + const fromBefore2024 = node.createdDate < new Date('2024-01-01'); + this.logger.error(`Failed to decrypt node ${node.uid} (from before 2024: ${fromBefore2024})`, error); + + this.telemetry.logEvent({ + eventName: 'decryptionError', + context: 'own_volume', // TODO: add context to the node + entity: 'node', + fromBefore2024, + error, + }); + this.reportedDecryptionErrors.add(node.uid); + } } function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string): Author { diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index ee86e3c1..d8423acb 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -1,3 +1,4 @@ +import { getMockLogger } from "../../tests/logger"; import { DriveEvent, DriveEventType } from "../events"; import { updateCacheByEvent, notifyListenersByEvent } from "./events"; import { DecryptedNode } from "./interface"; @@ -5,6 +6,8 @@ import { NodesCache } from "./cache"; import { NodesAccess } from "./nodesAccess"; describe("updateCacheByEvent", () => { + const logger = getMockLogger(); + let cache: NodesCache; beforeEach(() => { @@ -30,14 +33,14 @@ describe("updateCacheByEvent", () => { }; it("should not update cache by node create event", async () => { - await updateCacheByEvent(event, cache); + await updateCacheByEvent(logger, event, cache); expect(cache.getNode).toHaveBeenCalledTimes(0); expect(cache.setNode).toHaveBeenCalledTimes(0); }); it("should reset parent loaded state", async () => { - await updateCacheByEvent(event, cache); + await updateCacheByEvent(logger, event, cache); expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); }); @@ -56,7 +59,7 @@ describe("updateCacheByEvent", () => { it("should update cache if present in cache", async () => { cache.getNode = jest.fn(() => Promise.resolve({ uid: '123' } as DecryptedNode)); - await updateCacheByEvent(event, cache); + await updateCacheByEvent(logger, event, cache); expect(cache.getNode).toHaveBeenCalledTimes(1); expect(cache.setNode).toHaveBeenCalledTimes(1); @@ -66,7 +69,7 @@ describe("updateCacheByEvent", () => { it("should skip if missing in cache", async () => { cache.getNode = jest.fn(() => Promise.reject(new Error('Missing in the cache'))); - await updateCacheByEvent(event, cache); + await updateCacheByEvent(logger, event, cache); expect(cache.getNode).toHaveBeenCalledTimes(1); expect(cache.setNode).toHaveBeenCalledTimes(0); @@ -76,7 +79,7 @@ describe("updateCacheByEvent", () => { cache.getNode = jest.fn(() => Promise.resolve({ uid: '123' } as DecryptedNode)); cache.setNode = jest.fn(() => Promise.reject(new Error('Cannot set node'))); - await updateCacheByEvent(event, cache); + await updateCacheByEvent(logger, event, cache); expect(cache.getNode).toHaveBeenCalledTimes(1); expect(cache.removeNodes).toHaveBeenCalledTimes(1); @@ -87,7 +90,7 @@ describe("updateCacheByEvent", () => { cache.setNode = jest.fn(() => Promise.reject(new Error('Cannot set node'))); cache.removeNodes = jest.fn(() => Promise.reject(new Error('Cannot remove node'))); - await expect(updateCacheByEvent(event, cache)).rejects.toThrow('Cannot set node'); + await expect(updateCacheByEvent(logger, event, cache)).rejects.toThrow('Cannot set node'); }); }); @@ -100,7 +103,7 @@ describe("updateCacheByEvent", () => { } it("should remove node from cache", async () => { - await updateCacheByEvent(event, cache); + await updateCacheByEvent(logger, event, cache); expect(cache.removeNodes).toHaveBeenCalledTimes(1); expect(cache.removeNodes).toHaveBeenCalledWith([event.nodeUid]); @@ -109,6 +112,8 @@ describe("updateCacheByEvent", () => { }); describe("notifyListenersByEvent", () => { + const logger = getMockLogger(); + let cache: NodesCache; let nodesAccess: NodesAccess; @@ -137,7 +142,7 @@ describe("notifyListenersByEvent", () => { }; const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); @@ -155,7 +160,7 @@ describe("notifyListenersByEvent", () => { }; const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); @@ -173,7 +178,7 @@ describe("notifyListenersByEvent", () => { }; const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); @@ -191,7 +196,7 @@ describe("notifyListenersByEvent", () => { }; const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'lalalala', callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'lalalala', callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(0); expect(nodesAccess.getNode).toHaveBeenCalledTimes(0); @@ -208,7 +213,7 @@ describe("notifyListenersByEvent", () => { }; const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); @@ -225,7 +230,7 @@ describe("notifyListenersByEvent", () => { const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); @@ -242,7 +247,7 @@ describe("notifyListenersByEvent", () => { const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); @@ -258,7 +263,7 @@ describe("notifyListenersByEvent", () => { }; const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(0); }); @@ -272,7 +277,7 @@ describe("notifyListenersByEvent", () => { }; const listener = jest.fn(); - await notifyListenersByEvent(event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'lalalala', callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'lalalala', callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(0); }); diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 76f06d1e..29ec76d8 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -40,7 +40,7 @@ type NodeEventInfo = { export class NodesEvents { private listeners: Listeners = []; - constructor(events: DriveEventsService, cache: NodesCache, nodesAccess: NodesAccess, log?: Logger) { + constructor(logger: Logger, events: DriveEventsService, cache: NodesCache, nodesAccess: NodesAccess) { events.addListener(async (events, fullRefreshVolumeId) => { if (fullRefreshVolumeId) { await cache.setNodesStaleFromVolume(fullRefreshVolumeId); @@ -48,13 +48,13 @@ export class NodesEvents { } for (const event of events) { - await updateCacheByEvent(event, cache, log); + await updateCacheByEvent(logger, event, cache); } }); events.addListener(async (events) => { for (const event of events) { - await notifyListenersByEvent(event, this.listeners, cache, nodesAccess, log); + await notifyListenersByEvent(logger, event, this.listeners, cache, nodesAccess); } }); } @@ -86,7 +86,7 @@ export class NodesEvents { * * @throws Only if the node is not possible to remove from the cache. */ -export async function updateCacheByEvent(event: DriveEvent, cache: NodesCache, log?: Logger) { +export async function updateCacheByEvent(logger: Logger, event: DriveEvent, cache: NodesCache) { // NodeCreated event is ignored as we do not want to fetch and // decrypt the node immediately. The node will be fetched and // decrypted when requested by the client. @@ -106,14 +106,14 @@ export async function updateCacheByEvent(event: DriveEvent, cache: NodesCache, l try { node = await cache.getNode(event.nodeUid); } catch (error: unknown) { - log?.debug(`Skipping node update event (node not in the cache): ${error}`); + logger.debug(`Skipping node update event (node not in the cache): ${error}`); } if (node) { node.isStale = true; try { await cache.setNode(node); } catch (setNodeError: unknown) { - log?.error(`Skipping node update event (failed to update): ${setNodeError}`); + logger.error(`Skipping node update event (failed to update)`, setNodeError); // If updating node in the cache is failing, lets remove it // to not block the whole client. If the node is not possible // to remove, lets throw at this point as cache is in very @@ -122,7 +122,7 @@ export async function updateCacheByEvent(event: DriveEvent, cache: NodesCache, l try { await cache.removeNodes([event.nodeUid]); } catch (removeNodeError: unknown) { - log?.error(`Skipping node update event (failed to remove after failed update): ${removeNodeError}`); + logger.error(`Skipping node update event (failed to remove after failed update)`, removeNodeError); // removeNodeError is automatic correction algorithm. // If that fails, lets throw the original error as that // is the real problem. @@ -138,7 +138,7 @@ export async function updateCacheByEvent(event: DriveEvent, cache: NodesCache, l try { await cache.removeNodes([event.nodeUid]); } catch (error: unknown) { - log?.error(`Skipping node delete event: ${error}`); + logger.error(`Skipping node delete event:`, error); } } } @@ -158,7 +158,7 @@ export async function updateCacheByEvent(event: DriveEvent, cache: NodesCache, l * * @throws Only if the client's callback throws. */ -export async function notifyListenersByEvent(event: DriveEvent, listeners: Listeners, cache: NodesCache, nodesAccess: NodesAccess, log?: Logger) { +export async function notifyListenersByEvent(logger: Logger, event: DriveEvent, listeners: Listeners, cache: NodesCache, nodesAccess: NodesAccess) { if (event.type === DriveEventType.NodeCreated || event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) { const subscribedListeners = listeners.filter(({ condition }) => condition(event)); if (subscribedListeners.length) { @@ -166,7 +166,7 @@ export async function notifyListenersByEvent(event: DriveEvent, listeners: Liste try { node = await nodesAccess.getNode(event.nodeUid); } catch (error: unknown) { - log?.error(`Skipping node update event to listener: ${error}`); + logger.error(`Skipping node update event to listener`, error); return; } subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node })); diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index 742a83f6..4943ef42 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -1,3 +1,4 @@ +import { getMockLogger } from "../../tests/logger"; import { FolderExtendedAttributes, FileExtendedAttributesParsed, generateFolderExtendedAttributes, parseFolderExtendedAttributes, parseFileExtendedAttributes } from './extendedAttributes'; describe('extended attrbiutes', () => { @@ -42,7 +43,7 @@ describe('extended attrbiutes', () => { ]; testCases.forEach(([input, expectedAttributes]) => { it(`should parse ${input}`, () => { - const output = parseFolderExtendedAttributes(input); + const output = parseFolderExtendedAttributes(getMockLogger(), input); expect(output).toMatchObject(expectedAttributes); }) }); @@ -139,7 +140,7 @@ describe('extended attrbiutes', () => { ]; testCases.forEach(([input, expectedAttributes]) => { it(`should parse ${input}`, () => { - const output = parseFileExtendedAttributes(input); + const output = parseFileExtendedAttributes(getMockLogger(), input); expect(output).toMatchObject(expectedAttributes); }) }); diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index dcdaab6e..cf208381 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -66,7 +66,7 @@ function dateToIsoString(date: Date) { return isDateValid ? date.toISOString() : undefined; } -export function parseFolderExtendedAttributes(extendedAttributes?: string, log?: Logger): FolderExtendedAttributes { +export function parseFolderExtendedAttributes(logger: Logger, extendedAttributes?: string): FolderExtendedAttributes { if (!extendedAttributes) { return {}; } @@ -74,15 +74,15 @@ export function parseFolderExtendedAttributes(extendedAttributes?: string, log?: try { const parsed = JSON.parse(extendedAttributes) as FolderExtendedAttributesSchema; return { - claimedModificationTime: parseModificationTime(parsed, log), + claimedModificationTime: parseModificationTime(logger, parsed), }; } catch (error: unknown) { - log?.error(`Failed to parse extended attributes: ${error instanceof Error ? error.message : error}`); + logger.error(`Failed to parse extended attributes`, error); return {}; } } -export function parseFileExtendedAttributes(extendedAttributes?: string, log?: Logger): FileExtendedAttributesParsed { +export function parseFileExtendedAttributes(logger: Logger, extendedAttributes?: string): FileExtendedAttributesParsed { if (!extendedAttributes) { return {} } @@ -94,30 +94,30 @@ export function parseFileExtendedAttributes(extendedAttributes?: string, log?: L delete claimedAdditionalMetadata.Common; return { - claimedSize: parseSize(parsed, log), - claimedModificationTime: parseModificationTime(parsed, log), - claimedDigests: parseDigests(parsed, log), + claimedSize: parseSize(logger, parsed), + claimedModificationTime: parseModificationTime(logger, parsed), + claimedDigests: parseDigests(logger, parsed), claimedAdditionalMetadata: Object.keys(claimedAdditionalMetadata).length ? claimedAdditionalMetadata : undefined, }; } catch (error: unknown) { - log?.error(`Failed to parse extended attributes: ${error instanceof Error ? error.message : error}`); + logger.error(`Failed to parse extended attributes`, error); return {}; } } -function parseSize(xattr?: FileExtendedAttributesSchema, log?: Logger): number | undefined { +function parseSize(logger: Logger, xattr?: FileExtendedAttributesSchema): number | undefined { const size = xattr?.Common?.Size; if (size === undefined) { return undefined; } if (typeof size !== 'number') { - log?.warn(`XAttr file size "${size}" is not valid`); + logger.warn(`XAttr file size "${size}" is not valid`); return undefined; } return size; } -function parseModificationTime(xattr?: FolderExtendedAttributesSchema | FolderExtendedAttributesSchema, log?: Logger): Date | undefined { +function parseModificationTime(logger: Logger, xattr?: FolderExtendedAttributesSchema | FolderExtendedAttributesSchema): Date | undefined { const modificationTime = xattr?.Common?.ModificationTime; if (modificationTime === undefined) { return undefined; @@ -125,13 +125,13 @@ function parseModificationTime(xattr?: FolderExtendedAttributesSchema | FolderEx const modificationDate = new Date(modificationTime); // This is the best way to check if date is "Invalid Date". :shrug: if (JSON.stringify(modificationDate) === 'null') { - log?.warn(`XAttr modification time "${modificationTime}" is not valid`); + logger.warn(`XAttr modification time "${modificationTime}" is not valid`); return undefined; } return modificationDate; } -function parseDigests(xattr?: FileExtendedAttributesSchema, log?: Logger): { sha1: string } | undefined { +function parseDigests(logger: Logger, xattr?: FileExtendedAttributesSchema): { sha1: string } | undefined { const digests = xattr?.Common?.Digests; if (digests === undefined || digests.SHA1 === undefined) { return undefined; @@ -139,7 +139,7 @@ function parseDigests(xattr?: FileExtendedAttributesSchema, log?: Logger): { sha const sha1 = digests.SHA1; if (typeof sha1 !== 'string') { - log?.warn(`XAttr digest SHA1 "${sha1}" is not valid`); + logger.warn(`XAttr digest SHA1 "${sha1}" is not valid`); return undefined; } diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index b0c86682..43a1a947 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -1,13 +1,13 @@ import { DriveAPIService } from "../apiService"; import { DriveCrypto } from "../../crypto"; import { DriveEventsService } from "../events"; -import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, Logger, ProtonDriveAccount } from "../../interface"; +import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache"; import { NodesEvents } from "./events"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; -import { SharesService, DecryptedNode } from "./interface"; +import { SharesService } from "./interface"; import { NodesAccess } from "./nodesAccess"; import { NodesManagement } from "./nodesManagement"; import { NodesRevisons } from "./nodesRevisions"; @@ -24,6 +24,7 @@ export type { DecryptedNode } from "./interface"; * interact with the nodes. */ export function initNodesModule( + telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, driveEntitiesCache: ProtonDriveEntitiesCache, driveCryptoCache: ProtonDriveCryptoCache, @@ -31,20 +32,19 @@ export function initNodesModule( driveCrypto: DriveCrypto, driveEvents: DriveEventsService, sharesService: SharesService, - log?: Logger, ) { - const api = new NodeAPIService(apiService, log); - const cache = new NodesCache(driveEntitiesCache, log); + const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); + const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(driveCryptoCache); - const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); - const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService, log); - const nodesEvents = new NodesEvents(driveEvents, cache, nodesAccess, log); + const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); + const nodesAccess = new NodesAccess(telemetry.getLogger('nodes'), api, cache, cryptoCache, cryptoService, sharesService); + const nodesEvents = new NodesEvents(telemetry.getLogger('nodes-events'), driveEvents, cache, nodesAccess); // TODO: Events are sent to the client once event is received from API // If change is done locally, it will take a time to show up if client // is waiting with UI update to events. Thus we need to emit events // right away. const nodesManagement = new NodesManagement(api, cache, cryptoCache, cryptoService, nodesAccess); - const nodesRevisions = new NodesRevisons(api, cryptoService, nodesAccess, log); + const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); return { access: nodesAccess, @@ -53,28 +53,3 @@ export function initNodesModule( events: nodesEvents, }; } - -export function initPublicNodesModule( - apiService: DriveAPIService, - driveEntitiesCache: ProtonDriveEntitiesCache, - driveCryptoCache: ProtonDriveCryptoCache, - driveCrypto: DriveCrypto, - sharesService: SharesService, -) { - // TODO: create public node API service - const api = new NodeAPIService(apiService); - const cache = new NodesCache(driveEntitiesCache); - const cryptoCache = new NodesCryptoCache(driveCryptoCache); - // @ts-expect-error TODO - const cryptoService = new NodesCryptoService(driveCrypto, account, sharesService); - const nodesAccess = new NodesAccess(api, cache, cryptoCache, cryptoService, sharesService); - const nodesManagement = new NodesManagement(api, cache, cryptoCache, cryptoService, nodesAccess); - - return { - // TODO: use public root node, not my files - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getPublicRootNode: async (token: string, password: string, customPassword?: string): Promise => { return {} as DecryptedNode }, - access: nodesAccess, - management: nodesManagement, - } -} diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index c2f97b94..0058d9ba 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -1,3 +1,4 @@ +import { getMockLogger } from "../../tests/logger"; import { PrivateKey } from "../../crypto"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache" @@ -43,7 +44,7 @@ describe('nodesAccess', () => { getSharePrivateKey: jest.fn(), }; - access = new NodesAccess(apiService, cache, cryptoCache, cryptoService, shareService); + access = new NodesAccess(getMockLogger(), apiService, cache, cryptoCache, cryptoService, shareService); }); describe('getNode', () => { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 2e3b36c4..fa7f886b 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -18,19 +18,19 @@ import { validateNodeName } from "./validations"; */ export class NodesAccess { constructor( + private logger: Logger, private apiService: NodeAPIService, private cache: NodesCache, private cryptoCache: NodesCryptoCache, private cryptoService: NodesCryptoService, private shareService: SharesService, - private log?: Logger, ) { + this.logger = logger; this.apiService = apiService; this.cache = cache; this.cryptoCache = cryptoCache; this.cryptoService = cryptoService; this.shareService = shareService; - this.log = log; } async getMyFilesRootFolder() { @@ -49,6 +49,8 @@ export class NodesAccess { return cachedNode; } + this.logger.debug(`Node ${nodeUid} is ${cachedNode?.isStale ? 'stale' : 'not cached'}`); + const { node } = await this.loadNode(nodeUid); return node; } @@ -72,6 +74,7 @@ export class NodesAccess { return; } + this.logger.debug(`Folder ${parentNodeUid} children are not cached`); for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { let node; try { @@ -81,6 +84,7 @@ export class NodesAccess { if (node && !node.isStale) { yield node; } else { + this.logger.debug(`Node ${nodeUid} from ${parentNodeUid} is ${node?.isStale ? 'stale' : 'not cached'}`); yield* batchLoading.load(nodeUid); } } @@ -101,6 +105,7 @@ export class NodesAccess { if (node && !node.isStale) { yield node; } else { + this.logger.debug(`Node ${nodeUid} trom trash is ${node?.isStale ? 'stale' : 'not cached'}`); yield* batchLoading.load(nodeUid); } } @@ -148,7 +153,7 @@ export class NodesAccess { try { validateNodeName(unparsedNode.name.value); } catch (error: unknown) { - this.log?.warn('Node name validation failed', error); + this.logger.warn(`Node name validation failed: ${error instanceof Error ? error.message : error}`); unparsedNode.name = resultError({ name: unparsedNode.name.value, error: error instanceof Error ? error.message : 'Unknown error', @@ -157,7 +162,9 @@ export class NodesAccess { } if (unparsedNode.type === NodeType.File) { - const extendedAttributes = unparsedNode.activeRevision?.ok ? parseFileExtendedAttributes(unparsedNode.activeRevision.value.extendedAttributes, this.log) : undefined; + const extendedAttributes = unparsedNode.activeRevision?.ok + ? parseFileExtendedAttributes(this.logger, unparsedNode.activeRevision.value.extendedAttributes) + : undefined; return { ...unparsedNode, @@ -173,7 +180,9 @@ export class NodesAccess { } } - const extendedAttributes = unparsedNode.folder?.extendedAttributes ? parseFolderExtendedAttributes(unparsedNode.folder.extendedAttributes, this.log) : undefined; + const extendedAttributes = unparsedNode.folder?.extendedAttributes + ? parseFolderExtendedAttributes(this.logger, unparsedNode.folder.extendedAttributes) + : undefined; return { ...unparsedNode, isStale: false, diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index 402010fd..8be94372 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -9,15 +9,15 @@ import { parseFileExtendedAttributes } from "./extendedAttributes"; */ export class NodesRevisons { constructor( + private logger: Logger, private apiService: NodeAPIService, private cryptoService: NodesCryptoService, private nodesAccess: NodesAccess, - private log?: Logger, ) { + this.logger = logger; this.apiService = apiService; this.cryptoService = cryptoService; this.nodesAccess = nodesAccess; - this.log = log; } async* iterateRevisions(nodeUid: string, signal?: AbortSignal): AsyncGenerator { @@ -28,7 +28,7 @@ export class NodesRevisons { const encryptedRevisions = await this.apiService.getRevisions(nodeUid, signal); for (const encryptedRevision of encryptedRevisions) { const revision = await this.cryptoService.decryptRevision(encryptedRevision, key, parentKey); - const extendedAttributes = parseFileExtendedAttributes(revision.extendedAttributes, this.log); + const extendedAttributes = parseFileExtendedAttributes(this.logger, revision.extendedAttributes); yield { ...revision, ...extendedAttributes, diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index 8e6c9d7f..5769fe28 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -148,6 +148,7 @@ function convertSharePayload(response: GetShareResponse): EncryptedShare { shareId: response.ShareID, rootNodeId: response.LinkID, creatorEmail: response.Creator, + createdDate: response.CreateTime ? new Date(response.CreateTime*1000) : undefined, encryptedCrypto: { armoredKey: response.Key, armoredPassphrase: response.Passphrase, diff --git a/js/sdk/src/internal/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts index 1b91f975..16a1565b 100644 --- a/js/sdk/src/internal/shares/cache.test.ts +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -1,4 +1,5 @@ import { MemoryCache } from "../../cache"; +import { getMockLogger } from "../../tests/logger"; import { SharesCache } from "./cache"; describe('sharesCache', () => { @@ -9,7 +10,7 @@ describe('sharesCache', () => { memoryCache = new MemoryCache(); memoryCache.setEntity('volume-badObject', 'aaa'); - cache = new SharesCache(memoryCache); + cache = new SharesCache(getMockLogger(), memoryCache); }); it('should store and retrieve volume', async () => { diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index 366bc50e..fc1d211c 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveEntitiesCache } from "../../interface"; +import { ProtonDriveEntitiesCache, Logger } from "../../interface"; import { Volume } from "./interface"; /** @@ -7,7 +7,8 @@ import { Volume } from "./interface"; * The cache is responsible for serialising and deserialising volume metadata. */ export class SharesCache { - constructor(private driveCache: ProtonDriveEntitiesCache) { + constructor(private logger: Logger, private driveCache: ProtonDriveEntitiesCache) { + this.logger = logger; this.driveCache = driveCache; } @@ -27,7 +28,7 @@ export class SharesCache { try { await this.removeVolume(volumeId); } catch { - // TODO: log error + this.logger.error('Failed to remove invalid volume from cache'); } throw new Error(`Failed to deserialize volume: ${error instanceof Error ? error.message : error}`); } diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts new file mode 100644 index 00000000..5dcbce5b --- /dev/null +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -0,0 +1,134 @@ +import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; +import { ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; +import { getMockTelemetry } from "../../tests/telemetry"; +import { EncryptedRootShare } from "./interface"; +import { SharesCryptoService } from "./cryptoService"; + +describe("SharesCryptoService", () => { + let telemetry: ProtonDriveTelemetry; + let driveCrypto: DriveCrypto; + let account: ProtonDriveAccount; + let cryptoService: SharesCryptoService; + + beforeEach(() => { + telemetry = getMockTelemetry(); + // @ts-expect-error No need to implement all methods for mocking + driveCrypto = { + decryptKey: jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + sessionKey: "sessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + })), + }; + account = { + // @ts-expect-error No need to implement full response for mocking + getOwnAddress: jest.fn(async () => ({ + keys: [{ key: "addressKey" as unknown as PrivateKey }], + })), + getPublicKeys: jest.fn(async () => []), + }; + cryptoService = new SharesCryptoService(telemetry, driveCrypto, account); + }); + + it("should decrypt root share", async () => { + const result = await cryptoService.decryptRootShare( + { + shareId: "shareId", + addressId: "addressId", + creatorEmail: "signatureEmail", + encryptedCrypto: { + armoredKey: "armoredKey", + armoredPassphrase: "armoredPassphrase", + armoredPassphraseSignature: "armoredPassphraseSignature", + }, + } as EncryptedRootShare, + ); + + expect(result).toMatchObject({ + share: { + shareId: "shareId", + author: { ok: true, value: "signatureEmail" }, + }, + key: { + key: "decryptedKey", + sessionKey: "sessionKey", + }, + }); + + expect(account.getOwnAddress).toHaveBeenCalledWith("addressId"); + expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(telemetry.logEvent).not.toHaveBeenCalled(); + }); + + it("should decrypt root share with signiture verification error", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + sessionKey: "sessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + })); + + const result = await cryptoService.decryptRootShare( + { + shareId: "shareId", + addressId: "addressId", + creatorEmail: "signatureEmail", + encryptedCrypto: { + armoredKey: "armoredKey", + armoredPassphrase: "armoredPassphrase", + armoredPassphraseSignature: "armoredPassphraseSignature", + }, + } as EncryptedRootShare, + ); + + expect(result).toMatchObject({ + share: { + shareId: "shareId", + author: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature" } }, + }, + key: { + key: "decryptedKey", + sessionKey: "sessionKey", + }, + }); + + expect(account.getOwnAddress).toHaveBeenCalledWith("addressId"); + expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: 'verificationError', + context: 'own_volume', + verificationKey: 'ShareAddress', + addressMatchingDefaultShare: undefined, + fromBefore2024: undefined, + }); + }); + + it("should handle decrypt issue of root share", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); + + const result = cryptoService.decryptRootShare( + { + shareId: "shareId", + addressId: "addressId", + creatorEmail: "signatureEmail", + encryptedCrypto: { + armoredKey: "armoredKey", + armoredPassphrase: "armoredPassphrase", + armoredPassphraseSignature: "armoredPassphraseSignature", + }, + } as EncryptedRootShare, + ); + + await expect(result).rejects.toThrow(error); + + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: 'decryptionError', + context: 'own_volume', + entity: 'share', + fromBefore2024: undefined, + error, + }); + }); +}); diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 749db37d..946d5364 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -1,4 +1,4 @@ -import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError } from "../../interface"; +import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger } from "../../interface"; import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto"; import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareKey } from "./interface"; @@ -13,7 +13,14 @@ import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, Decrypted * The service owns the logic to switch between old and new crypto model. */ export class SharesCryptoService { - constructor(private driveCrypto: DriveCrypto, private account: ProtonDriveAccount) { + private logger: Logger; + + private reportedDecryptionErrors = new Set(); + private reportedVerificationErrors = new Set(); + + constructor(private telemetry: ProtonDriveTelemetry, private driveCrypto: DriveCrypto, private account: ProtonDriveAccount) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('shares-crypto'); this.driveCrypto = driveCrypto; this.account = account; } @@ -44,13 +51,22 @@ export class SharesCryptoService { const { keys: addressKeys } = await this.account.getOwnAddress(share.addressId); const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); - const { key, sessionKey, verified } = await this.driveCrypto.decryptKey( - share.encryptedCrypto.armoredKey, - share.encryptedCrypto.armoredPassphrase, - share.encryptedCrypto.armoredPassphraseSignature, - addressKeys.map(({ key }) => key), - addressPublicKeys, - ) + let key, sessionKey, verified; + try { + const result = await this.driveCrypto.decryptKey( + share.encryptedCrypto.armoredKey, + share.encryptedCrypto.armoredPassphrase, + share.encryptedCrypto.armoredPassphraseSignature, + addressKeys.map(({ key }) => key), + addressPublicKeys, + ) + key = result.key; + sessionKey = result.sessionKey; + verified = result.verified; + } catch (error: unknown) { + this.reportDecryptionError(share, error); + throw error; + } const author: Result = verified === VERIFICATION_STATUS.SIGNED_AND_VALID ? resultOk(share.creatorEmail) @@ -61,6 +77,10 @@ export class SharesCryptoService { : `Missing signature`, }); + if (!author.ok) { + await this.reportVerificationError(share); + } + return { share: { ...share, @@ -72,4 +92,41 @@ export class SharesCryptoService { }, } } + + private reportDecryptionError(share: EncryptedRootShare, error?: unknown) { + if (this.reportedDecryptionErrors.has(share.shareId)) { + return; + } + + const fromBefore2024 = share.createdDate ? share.createdDate < new Date('2024-01-01') : undefined; + this.logger.error(`Failed to decrypt share ${share.shareId} (from before 2024: ${fromBefore2024})`, error); + + this.telemetry.logEvent({ + eventName: 'decryptionError', + context: 'own_volume', // TODO: add context to the share + entity: 'share', + fromBefore2024, + error, + }); + this.reportedDecryptionErrors.add(share.shareId); + } + + private async reportVerificationError(share: EncryptedRootShare) { + if (this.reportedVerificationErrors.has(share.shareId)) { + return; + } + + const fromBefore2024 = share.createdDate ? share.createdDate < new Date('2024-01-01') : undefined; + const addressMatchingDefaultShare = undefined; // TODO: check if claimed author matches default share + this.logger.error(`Failed to verify share ${share.shareId} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); + + this.telemetry.logEvent({ + eventName: 'verificationError', + context: 'own_volume', // TODO: add context to the share + verificationKey: 'ShareAddress', + addressMatchingDefaultShare, + fromBefore2024, + }); + this.reportedVerificationErrors.add(share.shareId); + } } diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts index 1b368cbf..d78cd151 100644 --- a/js/sdk/src/internal/shares/index.ts +++ b/js/sdk/src/internal/shares/index.ts @@ -1,4 +1,4 @@ -import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount } from "../../interface"; +import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; import { DriveCrypto } from '../../crypto'; import { DriveAPIService } from "../apiService"; import { SharesAPIService } from "./apiService"; @@ -19,6 +19,7 @@ export type { EncryptedShare } from "./interface"; * interact with the shares. */ export function initSharesModule( + telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, driveEntitiesCache: ProtonDriveEntitiesCache, driveCryptoCache: ProtonDriveCryptoCache, @@ -26,9 +27,9 @@ export function initSharesModule( crypto: DriveCrypto, ) { const api = new SharesAPIService(apiService); - const cache = new SharesCache(driveEntitiesCache); + const cache = new SharesCache(telemetry.getLogger('shares-cache'), driveEntitiesCache); const cryptoCache = new SharesCryptoCache(driveCryptoCache); - const cryptoService = new SharesCryptoService(crypto, account); - const sharesManager = new SharesManager(api, cache, cryptoCache, cryptoService, account); + const cryptoService = new SharesCryptoService(telemetry, crypto, account); + const sharesManager = new SharesManager(telemetry.getLogger('shares'), api, cache, cryptoCache, cryptoService, account); return sharesManager; } diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index c47936e8..1515b51e 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -40,6 +40,7 @@ type BaseShare = { * might not have this field set. */ addressId?: string; + createdDate?: Date; } & VolumeShareNodeIDs; interface BaseRootShare extends BaseShare { diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts index 73538f57..051aad0d 100644 --- a/js/sdk/src/internal/shares/manager.test.ts +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -1,4 +1,5 @@ import { ProtonDriveAccount } from "../../interface"; +import { getMockLogger } from "../../tests/logger"; import { NotFoundAPIError } from "../apiService"; import { SharesAPIService } from "./apiService"; import { SharesCache } from "./cache"; @@ -45,7 +46,7 @@ describe("SharesManager", () => { getOwnAddress: jest.fn(), } - manager = new SharesManager(apiService, cache, cryptoCache, cryptoService, account); + manager = new SharesManager(getMockLogger(), apiService, cache, cryptoCache, cryptoService, account); }); describe("getMyFilesIDs", () => { diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 3debe622..9a3bb6f4 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -1,4 +1,4 @@ -import { ProtonDriveAccount } from "../../interface"; +import { Logger, ProtonDriveAccount } from "../../interface"; import { PrivateKey } from "../../crypto"; import { NotFoundAPIError } from "../apiService"; import { SharesAPIService } from "./apiService"; @@ -24,12 +24,14 @@ export class SharesManager { private myFilesIds?: VolumeShareNodeIDs; constructor( + private logger: Logger, private apiService: SharesAPIService, private cache: SharesCache, private cryptoCache: SharesCryptoCache, private cryptoService: SharesCryptoService, private account: ProtonDriveAccount, ) { + this.logger = logger; this.apiService = apiService; this.cache = cache; this.cryptoCache = cryptoCache; @@ -71,8 +73,10 @@ export class SharesManager { return this.myFilesIds; } catch (error: unknown) { if (error instanceof NotFoundAPIError) { + this.logger.warn('Active volume not found, creating a new one'); return this.createVolume(); } + this.logger.error('Failed to get active volume', error); throw error; } } @@ -87,7 +91,7 @@ export class SharesManager { * * @throws If the volume cannot be created (e.g., one already exists). */ - async createVolume(): Promise { + private async createVolume(): Promise { const { addressId, primaryKey } = await this.account.getOwnPrimaryAddress(); const bootstrap = await this.cryptoService.generateVolumeBootstrap(primaryKey.key); const myFilesIds = await this.apiService.createVolume( @@ -138,6 +142,8 @@ export class SharesManager { }; } catch {} + this.logger.debug(`Volume key for ${volumeId} is not cached`); + const { shareId } = await this.apiService.getVolume(volumeId); const share = await this.apiService.getRootShare(shareId); diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index 6241ef57..a548fd9b 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -1,5 +1,6 @@ +import { getMockLogger } from "../../tests/logger"; import { DriveEvent, DriveEventType } from "../events"; -import { SharesService, NodesService, SharingType } from "./interface"; +import { NodesService, SharingType } from "./interface"; import { SharingCache } from "./cache"; import { handleSharedByMeNodes, handleSharedWithMeNodes } from "./events"; import { SharingAccess } from "./sharingAccess"; @@ -161,7 +162,7 @@ describe("handleSharedByMeNodes", () => { const listener = jest.fn(); const listeners = [{ type: SharingType.SharedByMe, callback: listener }]; - await handleSharedByMeNodes(event, cache, listeners, nodesService); + await handleSharedByMeNodes(getMockLogger(), event, cache, listeners, nodesService); if (added) { expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); @@ -191,7 +192,7 @@ describe("handleSharedByMeNodes", () => { const listener = jest.fn(); const listeners = [{ type: SharingType.sharedWithMe, callback: listener }]; - await handleSharedByMeNodes(event, cache, listeners, nodesService); + await handleSharedByMeNodes(getMockLogger(), event, cache, listeners, nodesService); if (added) { expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index d132ad0a..7d6f1de1 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -20,7 +20,7 @@ type Listeners = { export class SharingEvents { private listeners: Listeners = []; - constructor(events: DriveEventsService, cache: SharingCache, nodesService: NodesService, sharingAccess: SharingAccess, log?: Logger) { + constructor(logger: Logger, events: DriveEventsService, cache: SharingCache, nodesService: NodesService, sharingAccess: SharingAccess) { events.addListener(async (events, fullRefreshVolumeId) => { // Technically we need to refresh only the shared by me nodes for // own volume, and shared with me nodes only when the event comes @@ -36,7 +36,7 @@ export class SharingEvents { } for (const event of events) { - await handleSharedByMeNodes(event, cache, this.listeners, nodesService, log); + await handleSharedByMeNodes(logger, event, cache, this.listeners, nodesService); await handleSharedWithMeNodes(event, cache, this.listeners, sharingAccess); } }); @@ -65,7 +65,7 @@ export class SharingEvents { * * @throws Only if the client's callback throws. */ -export async function handleSharedByMeNodes(event: DriveEvent, cache: SharingCache, listeners: Listeners, nodesService: NodesService, log?: Logger) { +export async function handleSharedByMeNodes(logger: Logger, event: DriveEvent, cache: SharingCache, listeners: Listeners, nodesService: NodesService) { if (event.type === DriveEventType.ShareWithMeUpdated || !event.isOwnVolume) { return; } @@ -76,13 +76,13 @@ export async function handleSharedByMeNodes(event: DriveEvent, cache: SharingCac try { await cache.addSharedByMeNodeUid(event.nodeUid); } catch (error: unknown) { - log?.error(`Skipping shared by me node cache update: ${error}`); + logger.error(`Skipping shared by me node cache update`, error); } let node; try { node = await nodesService.getNode(event.nodeUid); } catch (error: unknown) { - log?.error(`Skipping shared by me node update event to listener: ${error}`); + logger.error(`Skipping shared by me node update event to listener`, error); return; } subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node })); @@ -104,7 +104,7 @@ export async function handleSharedByMeNodes(event: DriveEvent, cache: SharingCac try { await cache.removeSharedByMeNodeUid(event.nodeUid); } catch (error: unknown) { - log?.error(`Skipping shared by me node cache remove: ${error}`); + logger.error(`Skipping shared by me node cache remove`, error); } subscribedListeners.forEach(({ callback }) => callback({ type: 'remove', uid: event.nodeUid })); } diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index c996a509..8158204b 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -1,4 +1,4 @@ -import { ProtonDriveAccount, ProtonDriveEntitiesCache, Logger } from "../../interface"; +import { ProtonDriveAccount, ProtonDriveEntitiesCache, ProtonDriveTelemetry } from "../../interface"; import { DriveCrypto } from '../../crypto'; import { DriveAPIService } from "../apiService"; import { DriveEventsService } from "../events"; @@ -18,6 +18,7 @@ import { SharesService, NodesService } from "./interface"; * encryption, decryption, caching, and event handling. */ export function initSharingModule( + telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, driveEntitiesCache: ProtonDriveEntitiesCache, account: ProtonDriveAccount, @@ -25,14 +26,13 @@ export function initSharingModule( driveEvents: DriveEventsService, sharesService: SharesService, nodesService: NodesService, - log?: Logger, ) { const api = new SharingAPIService(apiService); const cache = new SharingCache(driveEntitiesCache); const cryptoService = new SharingCryptoService(crypto, account); const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); - const sharingEvents = new SharingEvents(driveEvents, cache, nodesService, sharingAccess, log); - const sharingManagement = new SharingManagement(api, cryptoService, sharesService, nodesService, log); + const sharingEvents = new SharingEvents(telemetry.getLogger('sharing-events'), driveEvents, cache, nodesService, sharingAccess); + const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, sharesService, nodesService); return { access: sharingAccess, diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 094e4327..0df9bdd9 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -1,3 +1,4 @@ +import { getMockLogger } from "../../tests/logger"; import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonInvitation, resultOk } from "../../interface"; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; @@ -57,7 +58,7 @@ describe("SharingManagement", () => { getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: "node-key" })), } - sharingManagement = new SharingManagement(apiService, cryptoService, sharesService, nodesService); + sharingManagement = new SharingManagement(getMockLogger(), apiService, cryptoService, sharesService, nodesService); }); describe("getSharingInfo", () => { diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 423f22ac..342f0f8f 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -29,17 +29,17 @@ interface EmailOptions { */ export class SharingManagement { constructor( + private logger: Logger, private apiService: SharingAPIService, private cryptoService: SharingCryptoService, private sharesService: SharesService, private nodesService: NodesService, - private log?: Logger, ) { + this.logger = logger; this.apiService = apiService; this.cryptoService = cryptoService; this.sharesService = sharesService; this.nodesService = nodesService; - this.log = log; } async getSharingInfo(nodeUid: string): Promise { @@ -118,10 +118,10 @@ export class SharingManagement { const existingInvitation = currentSharing.protonInvitations.find((invitation) => invitation.inviteeEmail === email); if (existingInvitation) { if (existingInvitation.role === role) { - this.log?.debug(`Invitation for ${email} already exists with role ${role} to node ${nodeUid}`); + this.logger.info(`Invitation for ${email} already exists with role ${role} to node ${nodeUid}`); continue; } - this.log?.debug(`Invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`); + this.logger.info(`Invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`); await this.updateInvitation(existingInvitation.uid, role); existingInvitation.role = role; continue; @@ -130,16 +130,16 @@ export class SharingManagement { const existingMember = currentSharing.members.find((member) => member.inviteeEmail === email); if (existingMember) { if (existingMember.role === role) { - this.log?.debug(`Member ${email} already exists with role ${role} to node ${nodeUid}`); + this.logger.info(`Member ${email} already exists with role ${role} to node ${nodeUid}`); continue; } - this.log?.debug(`Member ${email} already exists, updating role to ${role} to node ${nodeUid}`); + this.logger.info(`Member ${email} already exists, updating role to ${role} to node ${nodeUid}`); await this.updateMember(existingMember.uid, role); existingMember.role = role; continue; } - this.log?.debug(`Inviting user ${email} with role ${role} to node ${nodeUid}`); + this.logger.info(`Inviting user ${email} with role ${role} to node ${nodeUid}`); const invitation = await this.inviteProtonUser(currentSharing.share, email, role, emailOptions); currentSharing.protonInvitations.push(invitation); } @@ -152,10 +152,10 @@ export class SharingManagement { const existingExternalInvitation = currentSharing.nonProtonInvitations.find((invitation) => invitation.inviteeEmail === email); if (existingExternalInvitation) { if (existingExternalInvitation.role === role) { - this.log?.debug(`External invitation for ${email} already exists with role ${role} to node ${nodeUid}`); + this.logger.info(`External invitation for ${email} already exists with role ${role} to node ${nodeUid}`); continue; } - this.log?.debug(`External invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`); + this.logger.info(`External invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`); await this.updateExternalInvitation(existingExternalInvitation.uid, role); existingExternalInvitation.role = role; continue; @@ -164,16 +164,16 @@ export class SharingManagement { const existingMember = currentSharing.members.find((member) => member.inviteeEmail === email); if (existingMember) { if (existingMember.role === role) { - this.log?.debug(`Member ${email} already exists with role ${role} to node ${nodeUid}`); + this.logger.info(`Member ${email} already exists with role ${role} to node ${nodeUid}`); continue; } - this.log?.debug(`Member ${email} already exists, updating role to ${role} to node ${nodeUid}`); + this.logger.info(`Member ${email} already exists, updating role to ${role} to node ${nodeUid}`); await this.updateMember(existingMember.uid, role); existingMember.role = role; continue; } - this.log?.debug(`Inviting external user ${email} with role ${role} to node ${nodeUid}`); + this.logger.info(`Inviting external user ${email} with role ${role} to node ${nodeUid}`); const invitation = await this.inviteExternalUser(currentSharing.share, email, role, emailOptions); currentSharing.nonProtonInvitations.push(invitation); } @@ -184,10 +184,10 @@ export class SharingManagement { : settings.publicLink; if (currentSharing.publicLink) { - this.log?.debug(`Updating public link with options ${options} to node ${nodeUid}`); + this.logger.info(`Updating public link with options ${options} to node ${nodeUid}`); await this.updateSharedLink(currentSharing.share, options); } else { - this.log?.debug(`Sharing via public link with options ${options} to node ${nodeUid}`); + this.logger.info(`Sharing via public link with options ${options} to node ${nodeUid}`); await this.shareViaLink(currentSharing.share, options); } } @@ -207,7 +207,7 @@ export class SharingManagement { } if (!settings) { - this.log?.debug(`Unsharing node ${nodeUid}`); + this.logger.info(`Unsharing node ${nodeUid}`); await this.deleteShare(currentSharing.share.shareId); return; } @@ -215,7 +215,7 @@ export class SharingManagement { for (const userEmail of settings.users || []) { const existingInvitation = currentSharing.protonInvitations.find((invitation) => invitation.inviteeEmail === userEmail); if (existingInvitation) { - this.log?.debug(`Deleting invitation for ${userEmail} to node ${nodeUid}`); + this.logger.info(`Deleting invitation for ${userEmail} to node ${nodeUid}`); await this.deleteInvitation(existingInvitation.uid); currentSharing.protonInvitations = currentSharing.protonInvitations.filter((invitation) => invitation.uid !== existingInvitation.uid); continue; @@ -223,7 +223,7 @@ export class SharingManagement { const existingExternalInvitation = currentSharing.nonProtonInvitations.find((invitation) => invitation.inviteeEmail === userEmail); if (existingExternalInvitation) { - this.log?.debug(`Deleting external invitation for ${userEmail} to node ${nodeUid}`); + this.logger.info(`Deleting external invitation for ${userEmail} to node ${nodeUid}`); await this.deleteExternalInvitation(existingExternalInvitation.uid); currentSharing.nonProtonInvitations = currentSharing.nonProtonInvitations.filter((invitation) => invitation.uid !== existingExternalInvitation.uid); continue; @@ -231,17 +231,17 @@ export class SharingManagement { const existingMember = currentSharing.members.find((member) => member.inviteeEmail === userEmail); if (existingMember) { - this.log?.debug(`Removing member ${userEmail} to node ${nodeUid}`); + this.logger.info(`Removing member ${userEmail} to node ${nodeUid}`); await this.removeMember(existingMember.uid); currentSharing.members = currentSharing.members.filter((member) => member.uid !== existingMember.uid); continue; } - this.log?.debug(`User ${userEmail} not found in sharing info for node ${nodeUid}`); + this.logger.info(`User ${userEmail} not found in sharing info for node ${nodeUid}`); } if (settings.publicLink === 'remove') { - this.log?.debug(`Removing public link to node ${nodeUid}`); + this.logger.info(`Removing public link to node ${nodeUid}`); await this.removeSharedLink(currentSharing.share); currentSharing.publicLink = undefined; } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 7cf22c31..e1416aba 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,6 +1,6 @@ -import { DriveAPIService } from './internal/apiService'; -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, ShareNodeSettings, UnshareNodeSettings, UploadMetadata } from './interface'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger } from './interface'; import { DriveCrypto } from './crypto'; +import { DriveAPIService } from './internal/apiService'; import { initSharesModule } from './internal/shares'; import { initNodesModule } from './internal/nodes'; import { initSharingModule } from './internal/sharing'; @@ -9,8 +9,10 @@ import { initUploadModule } from './internal/upload'; import { DriveEventsService } from './internal/events'; import { getConfig } from './config'; import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; +import { Telemetry } from './telemetry'; export class ProtonDriveClient implements Partial { + private logger: Logger; private nodes: ReturnType; private sharing: ReturnType; private download: ReturnType; @@ -21,12 +23,16 @@ export class ProtonDriveClient implements Partial { entitiesCache, cryptoCache, account, - getLogger, config, - metrics, // eslint-disable-line @typescript-eslint/no-unused-vars + telemetry, openPGPCryptoModule, acceptNoGuaranteeWithCustomModules, }: ProtonDriveClientContructorParameters) { + if (!telemetry) { + telemetry = new Telemetry(); + } + this.logger = telemetry.getLogger('interface'); + if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { // TODO: define errors and use here throw Error('TODO'); @@ -35,12 +41,12 @@ export class ProtonDriveClient implements Partial { const fullConfig = getConfig(config); - const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + const apiService = new DriveAPIService(telemetry, httpClient, fullConfig.baseUrl, fullConfig.language); - const events = new DriveEventsService(apiService, entitiesCache, getLogger?.('events')); - const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); - this.nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); - this.sharing = initSharingModule(apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access, getLogger?.('sharing')); + const events = new DriveEventsService(telemetry, apiService, entitiesCache); + const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); + this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); + this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access); this.download = initDownloadModule(apiService, cryptoModule, this.nodes.access); this.upload = initUploadModule(apiService, cryptoModule, this.nodes.access); } @@ -48,102 +54,131 @@ export class ProtonDriveClient implements Partial { // TODO // eslint-disable-next-line @typescript-eslint/no-unused-vars async getNodeUid(shareId: string, nodeId: string) { + this.logger.info(`Getting node UID for share ${shareId} and node ${nodeId}`); return Promise.resolve("") } async getMyFilesRootFolder() { + this.logger.info('Getting my files root folder'); return convertInternalNodePromise(this.nodes.access.getMyFilesRootFolder()); } async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { + this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); yield* convertInternalNodeIterator(this.nodes.access.iterateChildren(getUid(parentNodeUid), signal)); } async* iterateTrashedNodes(signal?: AbortSignal) { + this.logger.info('Iterating trashed nodes'); yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + this.logger.info(`Iterating ${nodeUids.length} nodes`); yield* convertInternalNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } async renameNode(nodeUid: NodeOrUid, newName: string) { + this.logger.info(`Renaming node ${nodeUid} to ${newName}`); return this.nodes.management.renameNode(getUid(nodeUid), newName); } async* moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal) { + this.logger.info(`Moving ${nodeUids.length} nodes to ${newParentNodeUid}`); yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); } async* trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + this.logger.info(`Trashing ${nodeUids.length} nodes`); yield* this.nodes.management.trashNodes(getUids(nodeUids), signal); } async* restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + this.logger.info(`Restoring ${nodeUids.length} nodes`); yield* this.nodes.management.restoreNodes(getUids(nodeUids), signal); } async* deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + this.logger.info(`Deleting ${nodeUids.length} nodes`); yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal); } async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date) { + this.logger.info(`Creating folder ${name} in ${getUid(parentNodeUid)}`); return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime)); } async* iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal) { + this.logger.info(`Iterating revisions of ${getUid(nodeUid)}`); yield* this.nodes.revisions.iterateRevisions(getUid(nodeUid), signal); } async restoreRevision(revisionUid: string) { + this.logger.info(`Restoring revision ${revisionUid}`); await this.nodes.revisions.restoreRevision(revisionUid); } async deleteRevision(revisionUid: string) { + this.logger.info(`Deleting revision ${revisionUid}`); await this.nodes.revisions.deleteRevision(revisionUid); } async* iterateSharedNodes(signal?: AbortSignal) { + this.logger.info('Iterating shared nodes by me'); yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); } async* iterateSharedNodesWithMe(signal?: AbortSignal) { + this.logger.info('Iterating shared nodes with me'); yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodesWithMe(signal)); } async removeSharedNodeWithMe(nodeUid: NodeOrUid) { + this.logger.info(`Removing shared node with me ${getUid(nodeUid)}`); await this.sharing.access.removeSharedNodeWithMe(getUid(nodeUid)); } async* iterateInvitations(signal?: AbortSignal) { + this.logger.info('Iterating invitations'); yield* this.sharing.access.iterateInvitations(signal); } async acceptInvitation(invitationId: string) { + this.logger.info(`Accepting invitation ${invitationId}`); await this.sharing.access.acceptInvitation(invitationId); } async rejectInvitation(invitationId: string) { + this.logger.info(`Rejecting invitation ${invitationId}`); await this.sharing.access.rejectInvitation(invitationId); } async getSharingInfo(nodeUid: NodeOrUid) { + this.logger.info(`Getting sharing info for ${getUid(nodeUid)}`); return this.sharing.management.getSharingInfo(getUid(nodeUid)); } async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings) { + this.logger.info(`Sharing node ${getUid(nodeUid)}`); return this.sharing.management.shareNode(getUid(nodeUid), settings); } async unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings) { + if (!settings) { + this.logger.info(`Unsharing node ${getUid(nodeUid)}`); + } else { + this.logger.info(`Partially unsharing ${getUid(nodeUid)}`); + } return this.sharing.management.unshareNode(getUid(nodeUid), settings); } async getFileDownloader(nodeUid: NodeOrUid, signal?: AbortSignal) { + this.logger.info(`Getting file downloader for ${getUid(nodeUid)}`); return this.download.getFileDownloader(getUid(nodeUid), signal); } - async getFileUploader(nodeUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) { - return this.upload.getFileUploader(getUid(nodeUid), name, metadata, signal); + async getFileUploader(parentFolderUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) { + this.logger.info(`Getting file uploader for parent ${getUid(parentFolderUid)}`); + return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 50a4d5c4..bb02664d 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -6,6 +6,7 @@ import { initNodesModule } from './internal/nodes'; import { initPhotosModule } from './internal/photos'; import { DriveEventsService } from './internal/events'; import { getConfig } from './config'; +import { Telemetry } from './telemetry'; // TODO: this is only example, on background it use drive internals, but it exposes nice interface for photos export class ProtonDrivePhotosClient { @@ -17,12 +18,15 @@ export class ProtonDrivePhotosClient { entitiesCache, cryptoCache, account, - getLogger, config, - metrics, // eslint-disable-line @typescript-eslint/no-unused-vars + telemetry, openPGPCryptoModule, acceptNoGuaranteeWithCustomModules, }: ProtonDriveClientContructorParameters) { + if (!telemetry) { + telemetry = new Telemetry(); + } + if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { // TODO: define errors and use here throw Error('TODO'); @@ -31,11 +35,11 @@ export class ProtonDrivePhotosClient { const fullConfig = getConfig(config); - const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); + const apiService = new DriveAPIService(telemetry, httpClient, fullConfig.baseUrl, fullConfig.language); - const events = new DriveEventsService(apiService, entitiesCache, getLogger?.('events')); - const shares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); - this.nodes = initNodesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares, getLogger?.('nodes')); + const events = new DriveEventsService(telemetry, apiService, entitiesCache); + const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); + this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); this.photos = initPhotosModule(apiService, entitiesCache, this.nodes.access); } diff --git a/js/sdk/src/protonDrivePublicClient.ts b/js/sdk/src/protonDrivePublicClient.ts deleted file mode 100644 index ab134a48..00000000 --- a/js/sdk/src/protonDrivePublicClient.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { DriveAPIService } from './internal/apiService'; -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, NodeEntity } from './interface'; -import { DriveCrypto } from './crypto'; -import { initSharesModule } from './internal/shares'; -import { initPublicNodesModule } from './internal/nodes'; -import { getConfig } from './config'; -import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; - -interface ProtonDrivePublicClientInterface extends Partial { - getPublicRootNode(token: string, password: string, customPassword?: string): Promise, -} - -export class ProtonDrivePublicClient implements ProtonDrivePublicClientInterface { - private nodes: ReturnType; - - constructor({ - httpClient, - entitiesCache, - cryptoCache, - account, - getLogger, - config, - metrics, // eslint-disable-line @typescript-eslint/no-unused-vars - openPGPCryptoModule, - acceptNoGuaranteeWithCustomModules, - }: ProtonDriveClientContructorParameters) { - if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { - // TODO: define errors and use here - throw Error('TODO'); - } - const cryptoModule = new DriveCrypto(openPGPCryptoModule); - - const fullConfig = getConfig(config); - - const apiService = new DriveAPIService(httpClient, fullConfig.baseUrl, fullConfig.language, getLogger?.('api')); - - // TODO: public sharing module - const publicShares = initSharesModule(apiService, entitiesCache, cryptoCache, account, cryptoModule); - this.nodes = initPublicNodesModule(apiService, entitiesCache, cryptoCache, cryptoModule, publicShares); - } - - async getPublicRootNode(token: string, password: string, customPassword?: string) { - return convertInternalNodePromise(this.nodes.getPublicRootNode(token, password, customPassword)); - } - - async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { - yield* convertInternalNodeIterator(this.nodes.access.iterateChildren(getUid(parentNodeUid), signal)); - } - - async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { - yield* convertInternalNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); - } -} diff --git a/js/sdk/src/telemetry.ts b/js/sdk/src/telemetry.ts index 284e440c..84056141 100644 --- a/js/sdk/src/telemetry.ts +++ b/js/sdk/src/telemetry.ts @@ -3,7 +3,7 @@ export interface LogRecord { level: LogLevel; loggerName: string; message: string; - error?: Error; + error?: unknown; } export enum LogLevel { @@ -27,7 +27,7 @@ export interface MetricRecord { } type MetricEvent = { - name: string; + eventName: string; } export interface MetricHandler { @@ -135,7 +135,7 @@ class Logger { }); } - warning(message: string) { + warn(message: string) { this.log({ time: new Date(), level: LogLevel.WARNING, @@ -144,7 +144,7 @@ class Logger { }); } - error(message: string, error?: Error) { + error(message: string, error?: unknown) { this.log({ time: new Date(), level: LogLevel.ERROR, @@ -180,7 +180,7 @@ export class LogFilter { private globalLevel: number; private loggerLevels: { [loggerName: string]: number }; - constructor(private options?: { + constructor(options?: { globalLevel?: LogLevel, loggerLevels?: { [loggerName: string]: LogLevel }, }) { @@ -292,12 +292,18 @@ export class JSONLogFormatter implements LogFormatter { */ export class BasicLogFormatter implements LogFormatter { format(log: LogRecord) { - return `${log.time.toISOString()} ${log.level} [${log.loggerName}] ${log.message}${log.error && `\nError: ${log.error.message}\nStack:\n${log.error.stack}`}`; + let errorDetails = ''; + if (log.error) { + errorDetails = log.error instanceof Error + ? `\nError: ${log.error.message}\nStack:\n${log.error.stack}` + : `\nError: ${log.error}`; + } + return `${log.time.toISOString()} ${log.level} [${log.loggerName}] ${log.message}${errorDetails}`; } } class ConsoleMetricHandler implements MetricHandler { onEvent(metric: MetricRecord) { - console.info(`${metric.time.toISOString()} INFO [metric] ${metric.event.name} ${JSON.stringify({ ...metric.event, name: undefined })}`); + console.info(`${metric.time.toISOString()} INFO [metric] ${metric.event.eventName} ${JSON.stringify({ ...metric.event, name: undefined })}`); } } diff --git a/js/sdk/src/tests/logger.ts b/js/sdk/src/tests/logger.ts new file mode 100644 index 00000000..36a1e51c --- /dev/null +++ b/js/sdk/src/tests/logger.ts @@ -0,0 +1,10 @@ +import { Logger } from '../interface'; + +export function getMockLogger(): Logger { + return { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } +} diff --git a/js/sdk/src/tests/telemetry.ts b/js/sdk/src/tests/telemetry.ts new file mode 100644 index 00000000..78a1743c --- /dev/null +++ b/js/sdk/src/tests/telemetry.ts @@ -0,0 +1,9 @@ +import { ProtonDriveTelemetry } from "../interface"; +import { getMockLogger } from "./logger"; + +export function getMockTelemetry(): ProtonDriveTelemetry { + return { + getLogger: getMockLogger, + logEvent: jest.fn(), + }; +} From cbaee64cb932c7500bc5b83b3935d41f478f5a8c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 12 Mar 2025 07:47:24 +0100 Subject: [PATCH 038/791] parametrize CLI with env variables --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f2cb78a8..ee6e23aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + # Docs public @@ -10,10 +12,11 @@ tsconfig.tsbuildinfo js/cli/proton-drive auth.txt cache*.sqlite +*.log *.bun-build # Tests tests/storage # Test reporter -tests/test-report.xml \ No newline at end of file +tests/test-report.xml From 0f62bffc9efcf19c2429dac07d40baf99835469f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 12 Mar 2025 09:06:43 +0100 Subject: [PATCH 039/791] fix cache for CLI --- js/sdk/src/cache/interface.ts | 3 +-- js/sdk/src/cache/memoryCache.test.ts | 4 ++-- js/sdk/src/cache/memoryCache.ts | 2 +- js/sdk/src/internal/nodes/cache.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/cache/interface.ts b/js/sdk/src/cache/interface.ts index 50c12583..0bcda516 100644 --- a/js/sdk/src/cache/interface.ts +++ b/js/sdk/src/cache/interface.ts @@ -14,14 +14,13 @@ export interface ProtonDriveCacheConstructor { } export interface ProtonDriveCache { - /** * Re-creates the whole persistent cache. * * The SDK can call this when there is some inconsistency and it is better * to start from scratch rather than fix it. */ - purge(): Promise, + clear(): Promise, /** * Adds or updates entity in the local database. diff --git a/js/sdk/src/cache/memoryCache.test.ts b/js/sdk/src/cache/memoryCache.test.ts index 694edb98..8938eaf3 100644 --- a/js/sdk/src/cache/memoryCache.test.ts +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -133,8 +133,8 @@ describe('MemoryCache', () => { expect(results2).toEqual([]); }); - it('should purge the cache', async () => { - await cache.purge(); + it('should clear the cache', async () => { + await cache.clear(); const results = []; for await (const result of cache.iterateEntities(['key1', 'key2', 'key3'])) { diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index a4a3150d..8d064644 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -14,7 +14,7 @@ export class MemoryCache implements ProtonDriveCache { private entities: KeyValueCache = {}; private entitiesByTag: TagsCache = {}; - async purge() { + async clear() { this.entities = {}; } diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 2c952446..5ac546d1 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -210,7 +210,7 @@ function getCacheUid(nodeUid: string) { function getNodeUid(cacheUid: string) { if (!cacheUid.startsWith('node-')) { - throw new Error('Unexpected cached node uid'); + throw new Error(`Unexpected cached node uid "${cacheUid}"`); } return cacheUid.substring(5); } From 559456ca1e8fc6ef21db36a5cae0559a2c9c8a55 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 12 Mar 2025 13:00:57 +0100 Subject: [PATCH 040/791] add comment for decrypted node keys interface --- js/sdk/src/internal/nodes/interface.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 0a207e06..5b218547 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -87,9 +87,20 @@ export interface DecryptedNode extends Omit Date: Mon, 10 Mar 2025 11:28:05 +0100 Subject: [PATCH 041/791] update parent UID with move event --- js/sdk/src/internal/events/index.ts | 2 +- js/sdk/src/internal/nodes/events.test.ts | 2 +- js/sdk/src/internal/nodes/events.ts | 4 + js/sdk/src/internal/nodes/index.test.ts | 132 +++++++++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 js/sdk/src/internal/nodes/index.test.ts diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index ba73048c..31168f7a 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -6,7 +6,7 @@ import { EventsCache } from "./cache"; import { CoreEventManager } from "./coreEventManager"; import { VolumeEventManager } from "./volumeEventManager"; -export { DriveEvent, DriveEventType } from "./interface"; +export { DriveEvent, DriveEventType, DriveListener } from "./interface"; const OWN_VOLUME_POLLING_INTERVAL = 30; const OTHER_VOLUME_POLLING_INTERVAL = 60; diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index d8423acb..0756114b 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -63,7 +63,7 @@ describe("updateCacheByEvent", () => { expect(cache.getNode).toHaveBeenCalledTimes(1); expect(cache.setNode).toHaveBeenCalledTimes(1); - expect(cache.setNode).toHaveBeenCalledWith({ uid: '123', isStale: true }); + expect(cache.setNode).toHaveBeenCalledWith({ uid: '123', isStale: true, parentUid: "parentUid" }); }); it("should skip if missing in cache", async () => { diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 29ec76d8..0620f672 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -110,6 +110,10 @@ export async function updateCacheByEvent(logger: Logger, event: DriveEvent, cach } if (node) { node.isStale = true; + // We need to update the parentUid as the node might have + // been moved to another parent. This is important for + // children iteration. + node.parentUid = event.parentNodeUid; try { await cache.setNode(node); } catch (setNodeError: unknown) { diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts new file mode 100644 index 00000000..b5930964 --- /dev/null +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -0,0 +1,132 @@ +import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount, MemberRole, NodeType } from "../../interface"; +import { DriveCrypto } from "../../crypto"; +import { MemoryCache } from "../../cache"; +import { DriveAPIService } from "../apiService"; +import { DriveEventsService, DriveListener, DriveEvent, DriveEventType } from "../events"; +import { makeNodeUid } from "../uids"; +import { SharesService, DecryptedNode } from "./interface"; +import { initNodesModule } from './index'; + +function generateSerializedNode(uid: string, parentUid = 'volumeId~root', params: Partial = {}): string { + return JSON.stringify(generateNode(uid, parentUid, params)); +} + +function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial = {}): DecryptedNode { + return { + uid, + parentUid, + directMemberRole: MemberRole.Admin, + type: NodeType.File, + mimeType: "text", + isShared: false, + createdDate: new Date(), + trashedDate: undefined, + isStale: false, + ...params, + } as DecryptedNode; +} + +describe('nodesModules integration tests', () => { + let apiService: DriveAPIService; + let driveEntitiesCache: ProtonDriveEntitiesCache; + let driveCryptoCache: ProtonDriveCryptoCache; + let account: ProtonDriveAccount; + let driveCrypto: DriveCrypto; + let eventCallbacks: DriveListener[]; + let driveEvents: DriveEventsService; + let sharesService: SharesService; + let nodesModule: ReturnType; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = {} + driveEntitiesCache = new MemoryCache(); + driveCryptoCache = new MemoryCache(); + // @ts-expect-error No need to implement all methods for mocking + account = {} + // @ts-expect-error No need to implement all methods for mocking + driveCrypto = {} + eventCallbacks = []; + // @ts-expect-error No need to implement all methods for mocking + driveEvents = { + addListener: jest.fn().mockImplementation((callback) => { + eventCallbacks.push(callback); + }), + } + // @ts-expect-error No need to implement all methods for mocking + sharesService = {} + + nodesModule = initNodesModule( + apiService, + driveEntitiesCache, + driveCryptoCache, + account, + driveCrypto, + driveEvents, + sharesService, + ); + }); + + test('should move node from one folder to another after move event', async () => { + // Prepare two folders (original and target) and a node in the original folder. + const originalFolderUid = makeNodeUid('volumeId', 'originalFolder'); + await driveEntitiesCache.setEntity(`node-${originalFolderUid}`, generateSerializedNode(originalFolderUid)); + await driveEntitiesCache.setEntity(`node-children-${originalFolderUid}`, 'loaded'); + + const targetFolderUid = makeNodeUid('volumeId', 'targetFolder'); + await driveEntitiesCache.setEntity(`node-${targetFolderUid}`, generateSerializedNode(targetFolderUid)); + await driveEntitiesCache.setEntity(`node-children-${targetFolderUid}`, 'loaded'); + + const nodeUid = makeNodeUid('volumeId', 'node1'); + await driveEntitiesCache.setEntity(`node-${nodeUid}`, generateSerializedNode(nodeUid, originalFolderUid), [`nodeParentUid:${originalFolderUid}`]); + + // Mock the API services to return the moved node. + // This is called when listing the children of the target folder after + // move event (when node marked as stale). + apiService.post = jest.fn().mockImplementation(async (url, body) => { + expect(url).toBe(`drive/v2/volumes/volumeId/links`); + return { + Links: [{ + Link: { + LinkID: 'node1', + ParentLinkID: 'targetFolder', + NameHash: 'hash', + Type: 2, + }, + File: {}, + ActiveRevision: {}, + }], + }; + }); + jest.spyOn(nodesModule.access, 'getParentKeys').mockResolvedValue({key: 'privateKey'}); + + // Verify the inital state before move event is sent. + const originalBeforeMove = await Array.fromAsync(nodesModule.access.iterateChildren(originalFolderUid)); + expect(originalBeforeMove).toMatchObject([{ uid: nodeUid, parentUid: originalFolderUid }]); + + const targetBeforeMove = await Array.fromAsync(nodesModule.access.iterateChildren(targetFolderUid)); + expect(targetBeforeMove).toMatchObject([]); + + // Send the move event that updates the cache. + const events: DriveEvent[] = [ + { + type: DriveEventType.NodeUpdated, + nodeUid, + parentNodeUid: targetFolderUid, + isTrashed: false, + isShared: false, + isOwnVolume: true, + }, + ] + await Promise.all(eventCallbacks.map((callback) => callback(events))); + + // Verify the state after the move event, including when API service is called. + const originalAfterMove = await Array.fromAsync(nodesModule.access.iterateChildren(originalFolderUid)); + expect(originalAfterMove).toMatchObject([]); + expect(apiService.post).not.toHaveBeenCalled(); + + const targetAfterMove = await Array.fromAsync(nodesModule.access.iterateChildren(targetFolderUid)); + expect(targetAfterMove).toMatchObject([{ uid: nodeUid, parentUid: targetFolderUid }]); + expect(apiService.post).toHaveBeenCalledTimes(1); + }); +}); From 07c03a6ef946675c97c9c77e8719680287078826 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Mar 2025 11:33:34 +0100 Subject: [PATCH 042/791] remove old tags array if its empty --- js/sdk/src/cache/memoryCache.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index 8d064644..e100da42 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -25,6 +25,9 @@ export class MemoryCache implements ProtonDriveCache { const index = this.entitiesByTag[tag].indexOf(key); if (index !== -1) { this.entitiesByTag[tag].splice(index, 1); + if (this.entitiesByTag[tag].length === 0) { + delete this.entitiesByTag[tag]; + } } } From bbfbe49b4a1cac7b4c87aa560572d760f6cce1f4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 14 Mar 2025 11:41:32 +0100 Subject: [PATCH 043/791] fix integration test --- js/sdk/src/internal/nodes/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index b5930964..16176a9e 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -1,6 +1,7 @@ import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount, MemberRole, NodeType } from "../../interface"; import { DriveCrypto } from "../../crypto"; import { MemoryCache } from "../../cache"; +import { getMockTelemetry } from "../../tests/telemetry"; import { DriveAPIService } from "../apiService"; import { DriveEventsService, DriveListener, DriveEvent, DriveEventType } from "../events"; import { makeNodeUid } from "../uids"; @@ -57,6 +58,7 @@ describe('nodesModules integration tests', () => { sharesService = {} nodesModule = initNodesModule( + getMockTelemetry(), apiService, driveEntitiesCache, driveCryptoCache, From e18ef6e3151dfcb805802cce4dd27b9acef63c56 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 11 Mar 2025 13:35:56 +0100 Subject: [PATCH 044/791] update docs --- js/sdk/src/cache/memoryCache.ts | 2 +- js/sdk/src/config.ts | 2 +- js/sdk/src/crypto/index.ts | 2 +- js/sdk/src/crypto/openPGPCrypto.ts | 2 +- js/sdk/src/index.ts | 26 +- .../interface/{constructor.ts => account.ts} | 15 - js/sdk/src/interface/author.ts | 29 ++ js/sdk/src/interface/devices.ts | 2 +- js/sdk/src/interface/download.ts | 4 +- js/sdk/src/interface/events.ts | 4 +- js/sdk/src/interface/httpClient.ts | 12 + js/sdk/src/interface/index.ts | 24 +- js/sdk/src/interface/nodes.ts | 73 ++--- js/sdk/src/interface/sharing.ts | 32 +- js/sdk/src/interface/upload.ts | 2 +- .../src/internal/nodes/cryptoService.test.ts | 4 +- js/sdk/src/internal/nodes/cryptoService.ts | 4 +- js/sdk/src/internal/nodes/interface.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- js/sdk/src/internal/photos/apiService.ts | 2 +- js/sdk/src/protonDriveClient.ts | 303 +++++++++++++++--- js/sdk/src/protonDrivePhotosClient.ts | 12 +- 22 files changed, 401 insertions(+), 159 deletions(-) rename js/sdk/src/interface/{constructor.ts => account.ts} (61%) create mode 100644 js/sdk/src/interface/author.ts create mode 100644 js/sdk/src/interface/httpClient.ts diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index e100da42..97b3d349 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -1,4 +1,4 @@ -import type { ProtonDriveCache, EntityResult } from './interface.js'; +import type { ProtonDriveCache, EntityResult } from './interface'; type KeyValueCache = { [ key: string ]: T }; type TagsCache = { [ tag: string ]: string[] }; diff --git a/js/sdk/src/config.ts b/js/sdk/src/config.ts index 17b49372..556a563f 100644 --- a/js/sdk/src/config.ts +++ b/js/sdk/src/config.ts @@ -1,4 +1,4 @@ -import { ProtonDriveConfig } from './interface/index.js'; +import { ProtonDriveConfig } from './interface'; export function getConfig(config?: ProtonDriveConfig) { return { diff --git a/js/sdk/src/crypto/index.ts b/js/sdk/src/crypto/index.ts index c5072ec9..bd61f6d5 100644 --- a/js/sdk/src/crypto/index.ts +++ b/js/sdk/src/crypto/index.ts @@ -1,5 +1,5 @@ export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interface'; export { VERIFICATION_STATUS } from './interface'; export { DriveCrypto } from './driveCrypto'; -export { OpenPGPCryptoWithCryptoProxy } from './openPGPCrypto'; +export { OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy } from './openPGPCrypto'; export { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index ffba321c..165a47d1 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -5,7 +5,7 @@ import { uint8ArrayToBase64String } from './utils'; * Interface matching CryptoProxy interface from client's monorepo: * clients/packages/crypto/lib/proxy/proxy.ts. */ -interface OpenPGPCryptoProxy { +export interface OpenPGPCryptoProxy { generateKey: (options: { userIDs: { name: string }[], type: 'ecc', curve: 'ed25519' }) => Promise, exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string }) => Promise, importPrivateKey: (options: { armoredKey: string, passphrase: string }) => Promise, diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 42226f79..0871f5e2 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -1,5 +1,27 @@ +/** + * Use only what is exported here. This is the public supported API of the SDK. + */ + +import { makeNodeUid } from './internal/uids'; + export * from './interface'; export * from './cache'; -export { OpenPGPCryptoWithCryptoProxy, OpenPGPCrypto } from './crypto'; +export { OpenPGPCrypto, OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy } from './crypto'; export { ProtonDriveClient } from './protonDriveClient'; -export { ProtonDrivePhotosClient } from './protonDrivePhotosClient'; + +/** + * Provides the node UID for the given raw volume and node IDs. + * + * This is required only for the internal implementation to provide + * backward compatibility with the old Drive web setup. + * + * If you are having share ID, use `ProtonDriveClient::getNodeUid` instead. + * + * @deprecated This method is not part of the public API. + * @param volumeId - Volume of the node. + * @param nodeId - Node/link ID (not UID). + * @returns The node UID. + */ +export function generateNodeUid(volumeId: string, nodeId: string) { + return makeNodeUid(volumeId, nodeId); +} diff --git a/js/sdk/src/interface/constructor.ts b/js/sdk/src/interface/account.ts similarity index 61% rename from js/sdk/src/interface/constructor.ts rename to js/sdk/src/interface/account.ts index d59e54ef..006b1d8d 100644 --- a/js/sdk/src/interface/constructor.ts +++ b/js/sdk/src/interface/account.ts @@ -1,4 +1,3 @@ - import { PrivateKey, PublicKey } from '../crypto'; export interface ProtonDriveAccount { @@ -20,17 +19,3 @@ export interface ProtonDriveAccountAddress { key: PrivateKey, }[], } - -export interface ProtonDriveHTTPClient { - fetch(request: Request, signal?: AbortSignal): Promise, -} - -export type ProtonDriveConfig = { - baseUrl?: string, - language?: string, - observabilityEnabled?: boolean, - uploadTimeout?: number, - uploadQueueLimitItems?: number, - downloadTimeout?: number, - downloadQueueLimitItems?: number, -} diff --git a/js/sdk/src/interface/author.ts b/js/sdk/src/interface/author.ts new file mode 100644 index 00000000..1e6f32ed --- /dev/null +++ b/js/sdk/src/interface/author.ts @@ -0,0 +1,29 @@ +import { Result } from './result'; + +/** + * Author with verification status. + * + * It can be either a string (email) or an anonymous user. + * + * If author cannot be verified, the result is failure with an error. + * The client can still get claimed author from the error object, but + * it must be used with caution. + */ +export type Author = Result; + +/** + * Anonymous user. Used when user shares folder publicly and anonymous + * users can access the folder and upload new files without being logged in. + */ +export type AnonymousUser = null; + +/** + * Unverified author. + * + * If author cannot be verified, the result is this object containing + * the claimed author and the verification error. + */ +export type UnverifiedAuthorError = { + claimedAuthor?: string, + error: string, +} diff --git a/js/sdk/src/interface/devices.ts b/js/sdk/src/interface/devices.ts index 89011ef9..6c07e2ab 100644 --- a/js/sdk/src/interface/devices.ts +++ b/js/sdk/src/interface/devices.ts @@ -1,4 +1,4 @@ -import { Result } from './result.js'; +import { Result } from './result'; export type Device = { uid: string, diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index 66ebb5e4..6834ae0c 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -1,5 +1,5 @@ -import { NodeOrUid } from './nodes.js'; -import { ThumbnailType } from './upload.js'; +import { NodeOrUid } from './nodes'; +import { ThumbnailType } from './upload'; export interface Download { getFileDownloader(node: NodeOrUid, signal?: AbortSignal): Promise, diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index 78b146ca..4b1784c3 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -1,5 +1,5 @@ -import { Device } from './devices.js'; -import { NodeEntity, NodeOrUid } from './nodes.js'; +import { Device } from './devices'; +import { NodeEntity, NodeOrUid } from './nodes'; export interface Events { subscribeToRemoteDataUpdates(): void, diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts new file mode 100644 index 00000000..e07d6e1e --- /dev/null +++ b/js/sdk/src/interface/httpClient.ts @@ -0,0 +1,12 @@ +export interface ProtonDriveHTTPClient { + fetch(request: Request, signal?: AbortSignal): Promise, +} + +export type ProtonDriveConfig = { + baseUrl?: string, + language?: string, + uploadTimeout?: number, + uploadQueueLimitItems?: number, + downloadTimeout?: number, + downloadQueueLimitItems?: number, +} diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index a0183c13..1ac3280d 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -1,25 +1,26 @@ import { ProtonDriveCache } from '../cache'; import { OpenPGPCrypto, PrivateKey, SessionKey } from '../crypto'; -import { ProtonDriveAccount, ProtonDriveHTTPClient, ProtonDriveConfig } from './constructor'; +import { ProtonDriveAccount } from './account'; import { Devices } from './devices'; import { Download } from './download'; import { Events } from './events'; -import { Nodes, NodesManagement, TrashManagement, Revisions } from './nodes'; -import { Sharing, SharingManagement } from './sharing'; +import { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; import { Telemetry, MetricEvent } from './telemetry'; import { Upload } from './upload'; export type { Result } from './result'; export { resultOk, resultError } from './result'; -export type { ProtonDriveAccount, ProtonDriveAccountAddress, ProtonDriveHTTPClient, ProtonDriveConfig } from './constructor'; +export type { ProtonDriveAccount, ProtonDriveAccountAddress } from './account'; +export type { Author,UnverifiedAuthorError, AnonymousUser } from './author'; export type { Device, DeviceOrUid } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; -export type { Author, NodeEntity, InvalidNameError, UnverifiedAuthorError, AnonymousUser, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; -export { NodeType, MemberRole } from './nodes'; +export type { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; +export type { NodeEntity, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; +export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; -export type { Telemetry, Logger, MetricUploadEvent, MetricDownloadEvent, MetricDecryptionErrorEvent, MetricVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent } from './telemetry'; +export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent, MetricContext } from './telemetry'; export type { Fileuploader, UploadController, Thumbnail, ThumbnailType, UploadMetadata } from './upload'; export type ProtonDriveTelemetry = Telemetry; @@ -33,14 +34,15 @@ export type CachedCryptoMaterial = { }; export interface ProtonDriveClientContructorParameters { + httpClient: ProtonDriveHTTPClient, entitiesCache: ProtonDriveEntitiesCache, cryptoCache: ProtonDriveCryptoCache, account: ProtonDriveAccount, - httpClient: ProtonDriveHTTPClient, + openPGPCryptoModule: OpenPGPCrypto, config?: ProtonDriveConfig, telemetry?: ProtonDriveTelemetry, - openPGPCryptoModule: OpenPGPCrypto, - acceptNoGuaranteeWithCustomModules?: boolean, }; -export interface ProtonDriveClientInterface extends Devices, Download, Events, Nodes, NodesManagement, TrashManagement, Revisions, Sharing, SharingManagement, Upload {}; +// Helper interface to make sure that all methods are correctly implemented eventually. +// In the end this will be deleted and the ProtonDriveClient will implement all methods directly. +export interface ProtonDriveClientInterface extends Devices, Download, Events, Upload {}; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index b83dff9c..57509ab4 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -1,19 +1,39 @@ -import { Result } from './result.js'; - -export type Author = Result; +import { Result } from './result'; +import { Author } from './author'; // Note: Node is reserved by JS/DOM, thus we need exception how the entity is called export type NodeEntity = { uid: string, parentUid?: string, name: Result, + /** + * Author of the node key. + * + * Person who created the node and keys for it. If user A uploads the file + * and user B renames the file and uploads new revision, name and content + * author is user B, while key author stays to user A who has forever + * option to decrypt latest versions. + */ keyAuthor: Author, + /** + * Author of the name. + * + * Person who named the file. If user A uploads the file and user B renames + * the file, key and content author is user A, while name author is user B. + */ nameAuthor: Author, directMemberRole: MemberRole, type: NodeType, mimeType?: string, + /** + * Whether the node is shared. If true, the node is shared with at least + * one user, or via public link. + */ isShared: boolean, - createdDate: Date, // created on server date + /** + * Created on server date. + */ + createdDate: Date, trashedDate?: Date, activeRevision?: Result, folder?: { @@ -22,17 +42,13 @@ export type NodeEntity = { } export type InvalidNameError = { - name: string, // placeholder instead of node name + /** + * Placeholder instead of node name that client can use to display. + */ + name: string, error: string, } -export type UnverifiedAuthorError = { - claimedAuthor?: string, - error: string, -} - -export type AnonymousUser = null; - export enum NodeType { File = "file", Folder = "folder", @@ -49,7 +65,7 @@ export type Revision = { uid: string, state: RevisionState, createdDate: Date, // created on server date - author: Author, + contentAuthor: Author, claimedSize?: number, claimedModificationTime?: Date, claimedDigests?: { @@ -66,31 +82,6 @@ export enum RevisionState { export type NodeOrUid = NodeEntity | string; export type RevisionOrUid = Revision | string; -export interface Nodes { - getNodeUid(shareId: string, nodeId: string): Promise; // deprected right away - getMyFilesRootFolder(): Promise, - iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator, - iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, -} - -export interface NodesManagement { - createFolder(parentNodeUid: NodeOrUid, name: string): Promise, - renameNode(nodeUid: NodeOrUid, newName: string): Promise, - moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator, - trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, - restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, -} - -export interface TrashManagement { - iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator, - deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator, - emptyTrash(): Promise, -} - -export interface Revisions { - iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator, - restoreRevision(revisionUid: RevisionOrUid): Promise, - deleteRevision(revisionUid: RevisionOrUid): Promise, -} - -export type NodeResult = {uid: string, ok: true} | {uid: string, ok: false, error: string}; +export type NodeResult = + {uid: string, ok: true} | + {uid: string, ok: false, error: string}; diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 47430cec..0d177b68 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -1,5 +1,6 @@ -import { Result } from './result.js'; -import { NodeEntity, NodeOrUid, NodeType, MemberRole, InvalidNameError, UnverifiedAuthorError } from './nodes.js'; +import { Result } from './result'; +import { UnverifiedAuthorError } from './author'; +import { NodeType, MemberRole, InvalidNameError } from './nodes'; export type Member = { uid: string, @@ -40,35 +41,18 @@ export type PublicLink = { export type Bookmark = { uid: string, - nodeName: Result, bookmarkedDate: Date, - rootNodeUid: string, + node: { + name: Result, + type: NodeType, + mimeType?: string, + }, } export type ProtonInvitationOrUid = ProtonInvitation | string; export type NonProtonInvitationOrUid = NonProtonInvitation | string; export type BookmarkOrUid = Bookmark | string; -export interface Sharing { - iterateInvitations(signal?: AbortSignal): AsyncGenerator, - acceptInvitation(invitationUid: ProtonInvitationOrUid): Promise, - rejectInvitation(invitationUid: ProtonInvitationOrUid): Promise, - - iterateSharedNodes(signal?: AbortSignal): AsyncGenerator, - iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator, - leaveSharedNode(nodeUid: NodeOrUid): void, - - iterateBookmarks(signal?: AbortSignal): AsyncGenerator, - removeBookmark(bookmarkUid: BookmarkOrUid): Promise, -} - -export interface SharingManagement { - getSharingInfo(nodeUid: NodeOrUid): Promise, - shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings): Promise, - unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings): Promise, - resendInvitation(invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid): Promise, -} - export type ShareNodeSettings = { protonUsers?: ShareMembersSettings, nonProtonUsers?: ShareMembersSettings, diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index 9c292953..df9d10e1 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -1,4 +1,4 @@ -import { NodeOrUid } from './nodes.js'; +import { NodeOrUid } from './nodes'; export interface Upload { getFileUploader( diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 2fcec171..136490de 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -445,7 +445,7 @@ describe("nodesCryptoService", () => { state: "active", createdDate: undefined, extendedAttributes: "{}", - author: { ok: true, value: "revisionSignatureEmail" }, + contentAuthor: { ok: true, value: "revisionSignatureEmail" }, } }, folder: undefined, }, @@ -497,7 +497,7 @@ describe("nodesCryptoService", () => { state: "active", createdDate: undefined, extendedAttributes: "{}", - author: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Verification of extended attributes signature failed" } }, + contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Verification of extended attributes signature failed" } }, } }, folder: undefined, }, diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index e2e5ed4b..e6d28a5a 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -239,14 +239,14 @@ export class NodesCryptoService { const { extendedAttributes, - author, + author: contentAuthor, } = await this.decryptExtendedAttributes(encryptedRevision.armoredExtendedAttributes, nodeKey, verificationKeys, encryptedRevision.signatureEmail); return { uid: encryptedRevision.uid, state: encryptedRevision.state, createdDate: encryptedRevision.createdDate, - author, + contentAuthor, extendedAttributes, } } diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 5b218547..d5a551e5 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -117,7 +117,7 @@ export interface EncryptedRevision extends BaseRevision { } export interface DecryptedRevision extends BaseRevision { - author: Author, + contentAuthor: Author, extendedAttributes?: string, } diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index fa7f886b..26e0efa4 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -173,7 +173,7 @@ export class NodesAccess { uid: unparsedNode.activeRevision.value.uid, state: unparsedNode.activeRevision.value.state, createdDate: unparsedNode.activeRevision.value.createdDate, - author: unparsedNode.activeRevision.value.author, + contentAuthor: unparsedNode.activeRevision.value.contentAuthor, ...extendedAttributes, }), folder: undefined, diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index ec2d0f31..b40d6019 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,4 +1,4 @@ -import { DriveAPIService } from "../apiService/index.js"; +import { DriveAPIService } from "../apiService"; export class PhotosAPIService { constructor(private apiService: DriveAPIService) { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index e1416aba..37f28067 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,4 +1,4 @@ -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger } from './interface'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, NodeEntity, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger, NodeResult, Revision, ProtonInvitationWithNode, ShareResult, NonProtonInvitationOrUid, ProtonInvitationOrUid } from './interface'; import { DriveCrypto } from './crypto'; import { DriveAPIService } from './internal/apiService'; import { initSharesModule } from './internal/shares'; @@ -11,6 +11,13 @@ import { getConfig } from './config'; import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; import { Telemetry } from './telemetry'; +/** + * ProtonDriveClient is the main interface for the ProtonDrive SDK. + * + * The client provides high-level operations for managing nodes, sharing, + * and downloading/uploading files. It is the main entry point for using + * the ProtonDrive SDK. + */ export class ProtonDriveClient implements Partial { private logger: Logger; private nodes: ReturnType; @@ -23,26 +30,18 @@ export class ProtonDriveClient implements Partial { entitiesCache, cryptoCache, account, + openPGPCryptoModule, config, telemetry, - openPGPCryptoModule, - acceptNoGuaranteeWithCustomModules, }: ProtonDriveClientContructorParameters) { if (!telemetry) { telemetry = new Telemetry(); } this.logger = telemetry.getLogger('interface'); - if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { - // TODO: define errors and use here - throw Error('TODO'); - } - const cryptoModule = new DriveCrypto(openPGPCryptoModule); - const fullConfig = getConfig(config); - + const cryptoModule = new DriveCrypto(openPGPCryptoModule); const apiService = new DriveAPIService(telemetry, httpClient, fullConfig.baseUrl, fullConfig.language); - const events = new DriveEventsService(telemetry, apiService, entitiesCache); const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); @@ -51,119 +50,340 @@ export class ProtonDriveClient implements Partial { this.upload = initUploadModule(apiService, cryptoModule, this.nodes.access); } - // TODO - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getNodeUid(shareId: string, nodeId: string) { + /** + * Provides the node UID for the given raw share and node IDs. + * + * This is required only for the internal implementation to provide + * backward compatibility with the old Drive web setup. + * + * If you are having volume ID, use `generateNodeUid` instead. + * + * @deprecated This method is not part of the public API. + * @param shareId - Context share of the node. + * @param nodeId - Node/link ID (not UID). + * @returns The node UID. + */ + async getNodeUid(shareId: string, nodeId: string): Promise { this.logger.info(`Getting node UID for share ${shareId} and node ${nodeId}`); - return Promise.resolve("") + throw new Error('Method not implemented'); } - async getMyFilesRootFolder() { + /** + * @returns The root folder to My files section of the user. + */ + async getMyFilesRootFolder(): Promise { this.logger.info('Getting my files root folder'); return convertInternalNodePromise(this.nodes.access.getMyFilesRootFolder()); } - async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal) { + /** + * Iterates the children of the given parent node. + * + * The output is not sorted and the order of the children is not guaranteed. + * + * @param parentNodeUid - Node entity or its UID string. + * @param signal - Signal to abort the operation. + * @returns An async generator of the children of the given parent node. + */ + async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); yield* convertInternalNodeIterator(this.nodes.access.iterateChildren(getUid(parentNodeUid), signal)); } - async* iterateTrashedNodes(signal?: AbortSignal) { + /** + * Iterates the trashed nodes. + * + * The list of trashed nodes is not cached and is fetched from the server + * on each call. The node data itself are served from cached if available. + * + * The output is not sorted and the order of the trashed nodes is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the trashed nodes. + */ + async* iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } - async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + /** + * Iterates the nodes by their UIDs. + * + * The output is not sorted and the order of the nodes is not guaranteed. + * + * @param nodeUids - List of node entities or their UIDs. + * @param signal - Signal to abort the operation. + * @returns An async generator of the nodes. + */ + async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating ${nodeUids.length} nodes`); yield* convertInternalNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } - async renameNode(nodeUid: NodeOrUid, newName: string) { + /** + * Rename the node. + * + * @param nodeUid - Node entity or its UID string. + * @returns The updated node entity. + * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. + * @throws {@link Error} If another node with the same name already exists. + */ + async renameNode(nodeUid: NodeOrUid, newName: string): Promise { this.logger.info(`Renaming node ${nodeUid} to ${newName}`); - return this.nodes.management.renameNode(getUid(nodeUid), newName); + return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); } - async* moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal) { + /** + * Move the nodes to a new parent node. + * + * The operation is performed node by node and the results are yielded + * as they are available. Order of the results is not guaranteed. + * + * If one of the nodes fails to move, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * Only move withing the same section is supported at this moment. + * That means that the new parent node must be in the same section + * as the nodes being moved. E.g., moving from My files to Shared with + * me is not supported yet. + * + * @param nodeUids - List of node entities or their UIDs. + * @param newParentNodeUid - Node entity or its UID string. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the move operation + */ + async* moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Moving ${nodeUids.length} nodes to ${newParentNodeUid}`); yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); } - async* trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + /** + * Trash the nodes. + * + * The operation is performed in batches and the results are yielded + * as they are available. Order of the results is not guaranteed. + * + * If one of the nodes fails to trash, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * @param nodeUids - List of node entities or their UIDs. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the trash operation + */ + async* trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Trashing ${nodeUids.length} nodes`); yield* this.nodes.management.trashNodes(getUids(nodeUids), signal); } - async* restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + /** + * Restore the nodes from the trash to their original place. + * + * The operation is performed in batches and the results are yielded + * as they are available. Order of the results is not guaranteed. + * + * If one of the nodes fails to restore, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * @param nodeUids - List of node entities or their UIDs. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the restore operation + */ + async* restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Restoring ${nodeUids.length} nodes`); yield* this.nodes.management.restoreNodes(getUids(nodeUids), signal); } - async* deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal) { + /** + * Delete the nodes permanently. + * + * The operation is performed in batches and the results are yielded + * as they are available. Order of the results is not guaranteed. + * + * If one of the nodes fails to delete, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * @param nodeUids - List of node entities or their UIDs. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the delete operation + */ + async* deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Deleting ${nodeUids.length} nodes`); yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal); } - async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date) { + async emptyTrash(): Promise { + this.logger.info('Emptying trash'); + throw new Error('Method not implemented'); + } + + /** + * Create a new folder. + * + * The folder is created in the given parent node. + * + * @param parentNodeUid - Node entity or its UID string of the parent folder. + * @param name - Name of the new folder. + * @param modificationTime - Optional modification time of the folder. + * @returns The created node entity. + * @throws {@link Error} If the parent node is not a folder. + * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. + * @throws {@link Error} If another node with the same name already exists. + */ + async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise { this.logger.info(`Creating folder ${name} in ${getUid(parentNodeUid)}`); return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime)); } - async* iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal) { + /** + * Iterates the revisions of given node. + * + * The list of node revisions is not cached and is fetched and decrypted + * from the server on each call. + * + * The output is sorted by the revision date in descending order (newest + * first). + * + * @param nodeUid - Node entity or its UID string. + * @param signal - Signal to abort the operation. + * @returns An async generator of the node revisions. + */ + async* iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating revisions of ${getUid(nodeUid)}`); yield* this.nodes.revisions.iterateRevisions(getUid(nodeUid), signal); } - async restoreRevision(revisionUid: string) { + /** + * Restore the node to the given revision. + * + * @param revisionUid - UID of the revision to restore. + */ + async restoreRevision(revisionUid: string): Promise { this.logger.info(`Restoring revision ${revisionUid}`); await this.nodes.revisions.restoreRevision(revisionUid); } - async deleteRevision(revisionUid: string) { + /** + * Delete the revision. + * + * @param revisionUid - UID of the revision to delete. + */ + async deleteRevision(revisionUid: string): Promise { this.logger.info(`Deleting revision ${revisionUid}`); await this.nodes.revisions.deleteRevision(revisionUid); } - async* iterateSharedNodes(signal?: AbortSignal) { + /** + * Iterates the nodes shared by the user. + * + * The output is not sorted and the order of the shared nodes is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the shared nodes. + */ + async* iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes by me'); yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); } - async* iterateSharedNodesWithMe(signal?: AbortSignal) { + /** + * Iterates the nodes shared with the user. + * + * The output is not sorted and the order of the shared nodes is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the shared nodes. + */ + async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes with me'); yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodesWithMe(signal)); } - async removeSharedNodeWithMe(nodeUid: NodeOrUid) { - this.logger.info(`Removing shared node with me ${getUid(nodeUid)}`); + /** + * Leave shared node that was previously shared with the user. + * + * @param nodeUid - Node entity or its UID string. + */ + async leaveSharedNode(nodeUid: NodeOrUid): Promise { + this.logger.info(`Leaving shared node with me ${getUid(nodeUid)}`); await this.sharing.access.removeSharedNodeWithMe(getUid(nodeUid)); } - async* iterateInvitations(signal?: AbortSignal) { + /** + * Iterates the invitations to shared nodes. + * + * The output is not sorted and the order of the invitations is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the invitations. + */ + async* iterateInvitations(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating invitations'); yield* this.sharing.access.iterateInvitations(signal); } - async acceptInvitation(invitationId: string) { + /** + * Accept the invitation to the shared node. + * + * @param invitationId - Invitation entity or its UID string. + */ + async acceptInvitation(invitationId: string): Promise { this.logger.info(`Accepting invitation ${invitationId}`); await this.sharing.access.acceptInvitation(invitationId); } - async rejectInvitation(invitationId: string) { + /** + * Reject the invitation to the shared node. + * + * @param invitationId - Invitation entity or its UID string. + */ + async rejectInvitation(invitationId: string): Promise { this.logger.info(`Rejecting invitation ${invitationId}`); await this.sharing.access.rejectInvitation(invitationId); } - async getSharingInfo(nodeUid: NodeOrUid) { + /** + * Get sharing info of the node. + * + * The sharing info contains the list of invitations, members, + * public link and permission for each. + * + * The sharing info is not cached and is fetched from the server + * on each call. + * + * @param nodeUid - Node entity or its UID string. + * @returns The sharing info of the node. Undefined if not shared. + */ + async getSharingInfo(nodeUid: NodeOrUid): Promise { this.logger.info(`Getting sharing info for ${getUid(nodeUid)}`); return this.sharing.management.getSharingInfo(getUid(nodeUid)); } - async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings) { + /** + * Share or update sharing of the node. + * + * If the node is already shared, the sharing settings are updated. + * If the member is already present but with different role, the role + * is updated. If the sharing settings is identical, the sharing info + * is returned without any change. + * + * @param nodeUid - Node entity or its UID string. + * @param settings - Settings for sharing the node. + * @returns The updated sharing info of the node. + */ + async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings): Promise { this.logger.info(`Sharing node ${getUid(nodeUid)}`); return this.sharing.management.shareNode(getUid(nodeUid), settings); } - async unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings) { + /** + * Unshare the node, completely or partially. + * + * @param nodeUid - Node entity or its UID string. + * @param settings - Settings for unsharing the node. If not provided, the node + * is unshared completely. + * @returns The updated sharing info of the node. Undefined if unshared completely. + */ + async unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings): Promise { if (!settings) { this.logger.info(`Unsharing node ${getUid(nodeUid)}`); } else { @@ -172,6 +392,11 @@ export class ProtonDriveClient implements Partial { return this.sharing.management.unshareNode(getUid(nodeUid), settings); } + async resendInvitation(invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid): Promise { + this.logger.info(`Resending invitation ${invitationUid}`); + throw new Error('Method not implemented'); + } + async getFileDownloader(nodeUid: NodeOrUid, signal?: AbortSignal) { this.logger.info(`Getting file downloader for ${getUid(nodeUid)}`); return this.download.getFileDownloader(getUid(nodeUid), signal); diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index bb02664d..e04512f5 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -18,25 +18,17 @@ export class ProtonDrivePhotosClient { entitiesCache, cryptoCache, account, + openPGPCryptoModule, config, telemetry, - openPGPCryptoModule, - acceptNoGuaranteeWithCustomModules, }: ProtonDriveClientContructorParameters) { if (!telemetry) { telemetry = new Telemetry(); } - if (openPGPCryptoModule && !acceptNoGuaranteeWithCustomModules) { - // TODO: define errors and use here - throw Error('TODO'); - } - const cryptoModule = new DriveCrypto(openPGPCryptoModule); - const fullConfig = getConfig(config); - + const cryptoModule = new DriveCrypto(openPGPCryptoModule); const apiService = new DriveAPIService(telemetry, httpClient, fullConfig.baseUrl, fullConfig.language); - const events = new DriveEventsService(telemetry, apiService, entitiesCache); const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); From edf38aa536f0a5ba2f380bcfe6e1c9adf7ec9953 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 13 Mar 2025 11:46:03 +0100 Subject: [PATCH 045/791] update error handling to follow documentation --- js/sdk/src/crypto/openPGPCrypto.ts | 1 - js/sdk/src/errors.ts | 110 ++++++++++++++++++ js/sdk/src/index.ts | 1 + .../internal/apiService/apiService.test.ts | 4 +- js/sdk/src/internal/apiService/apiService.ts | 17 ++- js/sdk/src/internal/apiService/errorCodes.ts | 17 +++ js/sdk/src/internal/apiService/errors.ts | 45 ++++--- .../src/internal/apiService/transformers.ts | 9 +- js/sdk/src/internal/batchLoading.ts | 1 + .../src/internal/download/fileDownloader.ts | 10 +- js/sdk/src/internal/errors.ts | 25 ++-- js/sdk/src/internal/events/index.ts | 2 +- js/sdk/src/internal/nodes/apiService.test.ts | 4 +- js/sdk/src/internal/nodes/apiService.ts | 24 ++-- js/sdk/src/internal/nodes/cryptoCache.test.ts | 3 +- js/sdk/src/internal/nodes/cryptoCache.ts | 19 +-- .../src/internal/nodes/cryptoService.test.ts | 12 +- js/sdk/src/internal/nodes/cryptoService.ts | 46 ++++---- js/sdk/src/internal/nodes/index.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.ts | 47 ++++++-- js/sdk/src/internal/nodes/nodesManagement.ts | 17 +-- js/sdk/src/internal/nodes/validations.ts | 8 +- js/sdk/src/internal/shares/cache.ts | 7 +- js/sdk/src/internal/shares/cryptoService.ts | 5 +- js/sdk/src/internal/sharing/apiService.ts | 64 +++++----- js/sdk/src/internal/sharing/cache.ts | 2 + js/sdk/src/internal/sharing/cryptoService.ts | 15 +-- js/sdk/src/internal/sharing/index.ts | 2 +- js/sdk/src/internal/sharing/sharingAccess.ts | 5 +- .../src/internal/sharing/sharingManagement.ts | 5 +- js/sdk/src/internal/upload/apiService.ts | 2 +- 31 files changed, 374 insertions(+), 157 deletions(-) create mode 100644 js/sdk/src/errors.ts diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 165a47d1..fab5aedd 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -210,7 +210,6 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }); if (!sessionKey) { - // TODO: error type & message throw new Error('Could not decrypt session key'); } diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts new file mode 100644 index 00000000..b9ca6f1c --- /dev/null +++ b/js/sdk/src/errors.ts @@ -0,0 +1,110 @@ +/** + * Base class for all SDK errors. + * + * This class can be used for catching all SDK errors. The error should have + * translated message in the `message` property that should be shown to the + * user without any modification. + * + * No retries should be done as that is already handled by the SDK. + * + * When SDK throws an error and it is not `SDKError`, it is unhandled error + * by the SDK and usually indicates bug in the SDK. Please, report it. + */ +export class SDKError extends Error { + name = 'SDKError'; +} + +/** + * Error thrown when the operation is aborted. + * + * This error is thrown when the operation is aborted by the user. + * For example, by calling `abort()` on the `AbortSignal`. + */ +export class AbortError extends SDKError { + name = 'AbortError'; +} + +/** + * Error thrown when the validation fails. + * + * This error is thrown when the validation of the input fails. + * Validation can be done on the client side or on the server side. + * + * For example, on the client, it can be thrown when the node name doesn't + * follow the required format, etc., while on the server side, it can be thrown + * when there is not enough permissions, etc. + */ +export class ValidationError extends SDKError { + name = 'ValidationError'; + + /** + * Internal API code. + * + * Use only for debugging purposes. + */ + public readonly code?: number; + + constructor(message: string, code?: number) { + super(message); + this.code = code; + } +} + +/** + * Error thrown when the API call fails. + * + * This error covers both HTTP errors and API errors. SDK automatically + * retries the request before the error is thrown. The sepcific algorithm + * used for retries depends on the type of the error. + * + * Client should not retry the request when this error is thrown. + */ +export class ServerError extends SDKError { + name = 'ServerError'; + + /** + * HTTP status code of the response. + * + * Use only for debugging purposes. + */ + public readonly statusCode?: number; + /** + * Internal API code. + * + * Use only for debugging purposes. + */ + public readonly code?: number; +} + +/** + * Error thrown when the client makes too many requests to the API. + * + * SDK is configured to stay below the rate limits, but it can still happen if + * client is running multiple SDKs in parallel, or if the rate limits are + * changed on the server side. + * + * SDK automatically retries the request before the error is thrown after + * waiting for the required time specified by the server. + * + * Client should slow down calling SDK when this error is thrown. + * + * You can be also notified about the rate limits by the `speedLimited` event. + * See `onMessage` method on the SDK class for more details. + */ +export class RateLimitedError extends ServerError { + name = 'RateLimitedError'; + + code = 429; +} + +/** + * Error thrown when the client is not connected to the internet. + * + * Client should check the internet connection when this error is thrown. + * + * You can also be notified about the connection status by the `offline` event + * See `onMessage` method on the SDK class for more details. + */ +export class ConnectionError extends SDKError { + name = 'ConnectionError'; +} diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 0871f5e2..097c1052 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -6,6 +6,7 @@ import { makeNodeUid } from './internal/uids'; export * from './interface'; export * from './cache'; +export * from './errors'; export { OpenPGPCrypto, OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy } from './crypto'; export { ProtonDriveClient } from './protonDriveClient'; diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index b18bcb41..1dc1b435 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -161,7 +161,7 @@ describe("DriveAPIService", () => { await api.get('test').catch(() => {}); } - await expect(api.get('test')).rejects.toThrow("Too many requests limit reached"); + await expect(api.get('test')).rejects.toThrow("Too many server requests, please try again later"); expect(httpClient.fetch).toHaveBeenCalledTimes(50); }); @@ -192,7 +192,7 @@ describe("DriveAPIService", () => { await api.get('test').catch(() => {}); } - await expect(api.get('test')).rejects.toThrow("Server errors limit reached"); + await expect(api.get('test')).rejects.toThrow("Too many server errors, please try again later"); expect(httpClient.fetch).toHaveBeenCalledTimes(10); }); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 343cc26e..457a09d4 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -1,6 +1,9 @@ +import { c } from 'ttag'; + import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from "../../interface"; +import { AbortError, ServerError, RateLimitedError } from '../../errors'; import { HTTPErrorCode, isCodeOk } from './errorCodes'; -import { apiErrorFactory, AbortError, APIError } from './errors'; +import { apiErrorFactory } from './errors'; import { waitSeconds } from './wait'; /** @@ -112,16 +115,18 @@ export class DriveAPIService { attempt = 0 ): Promise { if (signal?.aborted) { - throw new AbortError('Request aborted'); + throw new AbortError(c('Error').t`Request aborted`); } - this.logger?.debug(`${method} ${url}`); + this.logger.debug(`${method} ${url}`); if (this.hasReachedServerErrorLimit) { - throw new APIError('Server errors limit reached'); + this.logger.warn('Server errors limit reached'); + throw new ServerError(c('Error').t`Too many server errors, please try again later`); } if (this.hasReachedTooManyRequestsErrorLimit) { - throw new APIError('Too many requests limit reached'); + this.logger.warn('Too many requests limit reached'); + throw new RateLimitedError(c('Error').t`Too many server requests, please try again later`); } let response; @@ -198,7 +203,7 @@ export class DriveAPIService { } return result as ResponsePayload; } catch (error: unknown) { - if (error instanceof APIError) { + if (error instanceof ServerError) { throw error; } throw apiErrorFactory({ response }); diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index cb6ffc12..e9d335b7 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -13,5 +13,22 @@ export const enum ErrorCode { OK = 1000, OK_MANY = 1001, OK_ASYNC = 1002, + NOT_ENOUGH_PERMISSIONS = 2011, + NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS = 2026, + // Following codes takes name from the API documentation. + ALREADY_EXISTS = 2500, NOT_EXISTS = 2501, + INSUFFICIENT_QUOTA = 200001, + INSUFFICIENT_SPACE = 200002, + MAX_FILE_SIZE_FOR_FREE_USER = 200003, + MAX_PUBLIC_EDIT_MODE_FOR_FREE_USER = 200004, + INSUFFICIENT_VOLUME_QUOTA= 200100, + INSUFFICIENT_DEVICE_QUOTA= 200101, + ALREADY_MEMBER_OF_SHARE_IN_VOLUME_WITH_ANOTHER_ADDRESS = 200201, + TOO_MANY_CHILDREN = 200300, + NESTING_TOO_DEEP = 200301, + INSUFFICIENT_INVITATION_QUOTA = 200600, + INSUFFICIENT_SHARE_QUOTA = 200601, + INSUFFICIENT_SHARE_JOINED_QUOTA = 200602, + INSUFFICIENT_BOOKMARKS_QUOTA = 200800, } diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 1f560085..91dc02bd 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -1,13 +1,16 @@ +import { c } from 'ttag'; + +import { ServerError, ValidationError } from '../../errors'; import { ErrorCode } from './errorCodes'; -export function apiErrorFactory({ response, result }: { response: Response, result?: unknown }): APIError { +export function apiErrorFactory({ response, result }: { response: Response, result?: unknown }): ServerError { if (!result) { return new APIHTTPError(response.statusText, response.status); } // @ts-expect-error: Result from API can be any JSON that might not have // error or code set which next lines should handle. - const [code, message] = [result.Code || 0, result.Error || "Unknown error"]; + const [code, message] = [result.Code || 0, result.Error || c('Error').t`Unknown error`]; switch (code) { // Backend doesn't return 404 for not found resources, this is only // when the API endpoint is not found. Lets add the URL in the error @@ -16,23 +19,37 @@ export function apiErrorFactory({ response, result }: { response: Response, resu return new NotFoundAPIError(`${message}: ${response.url}`, code); case ErrorCode.NOT_EXISTS: return new NotFoundAPIError(message, code); + // ValidationError should be only when it is clearly user input error, + // otherwise it should be ServerError. + // Here we convert only general enough codes. Specific cases that are + // not clear from the code itself must be handled by each module + // separately. + case ErrorCode.NOT_ENOUGH_PERMISSIONS: + case ErrorCode.NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS: + case ErrorCode.ALREADY_EXISTS: + case ErrorCode.INSUFFICIENT_QUOTA: + case ErrorCode.INSUFFICIENT_SPACE: + case ErrorCode.MAX_FILE_SIZE_FOR_FREE_USER: + case ErrorCode.MAX_PUBLIC_EDIT_MODE_FOR_FREE_USER: + case ErrorCode.INSUFFICIENT_VOLUME_QUOTA: + case ErrorCode.INSUFFICIENT_DEVICE_QUOTA: + case ErrorCode.ALREADY_MEMBER_OF_SHARE_IN_VOLUME_WITH_ANOTHER_ADDRESS: + case ErrorCode.TOO_MANY_CHILDREN: + case ErrorCode.NESTING_TOO_DEEP: + case ErrorCode.INSUFFICIENT_INVITATION_QUOTA: + case ErrorCode.INSUFFICIENT_SHARE_QUOTA: + case ErrorCode.INSUFFICIENT_SHARE_JOINED_QUOTA: + case ErrorCode.INSUFFICIENT_BOOKMARKS_QUOTA: + return new ValidationError(message, code); default: return new APICodeError(message, code); } } -export class AbortError extends Error { - name = 'AbortError'; -} - -export class APIError extends Error { - name = 'APIError'; -} - -export class APIHTTPError extends APIError { +export class APIHTTPError extends ServerError { name = 'APIHTTPError'; - public statusCode: number; + public readonly statusCode: number; constructor(message: string, statusCode: number) { super(message); @@ -40,10 +57,10 @@ export class APIHTTPError extends APIError { } } -export class APICodeError extends APIError { +export class APICodeError extends ServerError { name = 'APICodeError'; - public code: number; + public readonly code: number; constructor(message: string, code: number) { super(message); diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts index 1f5347bf..4cb1f045 100644 --- a/js/sdk/src/internal/apiService/transformers.ts +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -1,18 +1,18 @@ import { Logger, NodeType, MemberRole } from "../../interface"; -export function nodeTypeNumberToNodeType(nodeTypeNumber: number, logger?: Logger): NodeType { +export function nodeTypeNumberToNodeType(logger: Logger, nodeTypeNumber: number): NodeType { switch (nodeTypeNumber) { case 1: return NodeType.Folder; case 2: return NodeType.File; default: - logger?.warn(`Unknown node type: ${nodeTypeNumber}`); + logger.warn(`Unknown node type: ${nodeTypeNumber}`); return NodeType.File; } } -export function permissionsToDirectMemberRole(permissionsNumber?: number, logger?: Logger): MemberRole { +export function permissionsToDirectMemberRole(logger: Logger, permissionsNumber?: number): MemberRole { switch (permissionsNumber) { case undefined: case 4: @@ -23,13 +23,14 @@ export function permissionsToDirectMemberRole(permissionsNumber?: number, logger return MemberRole.Admin; default: // User have access to the data, thus at minimum it can view. - logger?.warn(`Unknown sharing permissions: ${permissionsNumber}`); + logger.warn(`Unknown sharing permissions: ${permissionsNumber}`); return MemberRole.Viewer; } } export function memberRoleToPermission(memberRole: MemberRole): 4 | 6 | 22 { if (memberRole === MemberRole.Inherited) { + // This is developer error. throw new Error("Cannot convert inherited role to permission"); } switch (memberRole) { diff --git a/js/sdk/src/internal/batchLoading.ts b/js/sdk/src/internal/batchLoading.ts index 0ff4babd..61b86954 100644 --- a/js/sdk/src/internal/batchLoading.ts +++ b/js/sdk/src/internal/batchLoading.ts @@ -45,6 +45,7 @@ export class BatchLoading { } else if (options.iterateItems) { this.iterateItems = options.iterateItems; } else { + // This is developer error. throw new Error('Either loadItems or iterateItems must be provided'); } diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index fe747fe5..c5e30d37 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -1,5 +1,8 @@ +import { c } from 'ttag'; + import { PrivateKey } from "../../crypto"; -import { NodeEntity } from "../../interface"; +import { ValidationError } from "../../errors"; +import { NodeEntity, NodeType } from "../../interface"; import { DownloadAPIService } from "./apiService"; import { DownloadCryptoService } from "./cryptoService"; @@ -37,8 +40,11 @@ export class FileDownloader { } private async downloadToStream(controller: DownloadController, stream: WritableStream, onProgress: (writtenBytes: number) => void): Promise { + if (this.node.type === NodeType.Folder) { + throw new ValidationError(c("Error").t`Cannot download a folder`); + } if (!this.node.activeRevision?.ok || !this.node.activeRevision.value) { - throw new Error("Node has no active revision"); + throw new ValidationError(c("Error").t`File has no active revision`); } // TODO diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index 6f489b94..edf0d769 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -1,11 +1,22 @@ -export class AbortError extends Error { - name = 'AbortError'; -} +import { c } from 'ttag'; + +import { VERIFICATION_STATUS } from "../crypto"; -export class SDKError extends Error { - name = 'SDKError'; +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : c('Error').t`Unknown error`; } -export class ValidationError extends SDKError { - name = 'ValidationError'; +/** + * @param signatureType - Must be translated before calling this function. + */ +export function getVerificationMessage(verified: VERIFICATION_STATUS, signatureType?: string): string { + if (signatureType) { + return verified === VERIFICATION_STATUS.SIGNED_AND_INVALID + ? c('Error').t`Signature verification for ${signatureType} failed` + : c('Error').t`Missing signature for ${signatureType}`; + } + + return verified === VERIFICATION_STATUS.SIGNED_AND_INVALID + ? c('Error').t`Signature verification failed` + : c('Error').t`Missing signature`; } diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 31168f7a..dae49584 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -72,7 +72,7 @@ export class DriveEventsService { const volumeEvents = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId); this.volumesEvents[volumeId] = volumeEvents; - // TODO: Use dynamic algorithm to determine polling interval for non-own volumes. + // FIXME: Use dynamic algorithm to determine polling interval for non-own volumes. volumeEvents.setPollingInterval(isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL); if (this.subscribedToRemoteDataUpdates) { volumeEvents.startSubscription(); diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 066d5a96..150e1f47 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -294,7 +294,7 @@ describe("nodeAPIService", () => { await Array.fromAsync(api.restoreNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); throw new Error('Should have thrown'); } catch (error: any) { - expect(error.message).toEqual('restoreNodes does not support multiple volumes'); + expect(error.message).toEqual('Restoring items from multiple sections is not allowed'); } }); }); @@ -332,7 +332,7 @@ describe("nodeAPIService", () => { await Array.fromAsync(api.deleteNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); throw new Error('Should have thrown'); } catch (error: any) { - expect(error.message).toEqual('deleteNodes does not support multiple volumes'); + expect(error.message).toEqual('Deleting items from multiple sections is not allowed'); } }); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index bb2a0d88..189c3944 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,3 +1,6 @@ +import { c } from "ttag"; + +import { ValidationError } from "../../errors"; import { Logger, NodeResult } from "../../interface"; import { RevisionState } from "../../interface/nodes"; import { DriveAPIService, drivePaths, isCodeOk, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; @@ -60,7 +63,7 @@ export class NodeAPIService { // Improvement requested: support multiple volumes. async getNodes(nodeUids: string[], signal?: AbortSignal): Promise { const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId("getNodes", nodeIds); + const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Loading items`, nodeIds); const response = await this.apiService.post(`drive/v2/volumes/${volumeId}/links`, { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), @@ -75,7 +78,7 @@ export class NodeAPIService { // Basic node metadata uid: makeNodeUid(volumeId, link.Link.LinkID), parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, - type: nodeTypeNumberToNodeType(link.Link.Type), + type: nodeTypeNumberToNodeType(this.logger, link.Link.Type), mimeType: link.Link.MIMEType || undefined, createdDate: new Date(link.Link.CreateTime*1000), trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, @@ -83,7 +86,7 @@ export class NodeAPIService { // Sharing node metadata shareId: link.SharingSummary?.ShareID || undefined, isShared: !!link.SharingSummary, - directMemberRole: permissionsToDirectMemberRole(link.SharingSummary?.ShareAccess.Permissions, this.logger), + directMemberRole: permissionsToDirectMemberRole(this.logger, link.SharingSummary?.ShareAccess.Permissions), } const baseCryptoNodeMetadata = { signatureEmail: link.Link.SignatureEmail || undefined, @@ -124,6 +127,7 @@ export class NodeAPIService { }, } } + // TODO: do not fail if one node is wrong throw new Error(`Unknown node type: ${link.Link.Type}`); }); return nodes; @@ -242,7 +246,7 @@ export class NodeAPIService { // Improvement requested: split into multiple calls for many nodes. async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId("trashNodes", nodeIds); + const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Trashing items`, nodeIds); const response = await this.apiService.post< PostTrashNodesRequest, @@ -251,14 +255,14 @@ export class NodeAPIService { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); - // TODO: remove `as` when backend fixes OpenAPI schema. + // FIXME: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } // Improvement requested: split into multiple calls for many nodes. async* restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId("restoreNodes", nodeIds); + const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Restoring items`, nodeIds); const response = await this.apiService.put< PutRestoreNodesRequest, @@ -267,14 +271,14 @@ export class NodeAPIService { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); - // TODO: remove `as` when backend fixes OpenAPI schema. + // FIXME: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } // Improvement requested: split into multiple calls for many nodes. async* deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId("deleteNodes", nodeIds); + const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Deleting items`, nodeIds); const response = await this.apiService.post< PostDeleteNodesRequest, @@ -283,7 +287,7 @@ export class NodeAPIService { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); - // TODO: remove `as` when backend fixes OpenAPI schema. + // FIXME: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } @@ -355,7 +359,7 @@ export class NodeAPIService { function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { volumeId: string }[]): string { const uniqueVolumeIds = new Set(nodeIds.map(({ volumeId }) => volumeId)); if (uniqueVolumeIds.size !== 1) { - throw new Error(`${operationForErrorMessage} does not support multiple volumes`); + throw new ValidationError(c('Error').t`${operationForErrorMessage} from multiple sections is not allowed`); } const volumeId = nodeIds[0].volumeId; return volumeId; diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index 7ca8aa4e..7b361dce 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -1,6 +1,7 @@ import { PrivateKey, SessionKey } from "../../crypto"; import { MemoryCache } from "../../cache"; import { CachedCryptoMaterial } from "../../interface"; +import { getMockLogger } from "../../tests/logger"; import { NodesCryptoCache } from "./cryptoCache"; describe('nodesCryptoCache', () => { @@ -22,7 +23,7 @@ describe('nodesCryptoCache', () => { sessionKey: 'sessionKey', } as any); - cache = new NodesCryptoCache(memoryCache); + cache = new NodesCryptoCache(getMockLogger(), memoryCache); }); it('should store and retrieve keys', async () => { diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts index 7d03344a..45810a93 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveCryptoCache } from "../../interface"; +import { ProtonDriveCryptoCache, Logger } from "../../interface"; import { DecryptedNodeKeys } from "./interface"; /** @@ -8,22 +8,25 @@ import { DecryptedNodeKeys } from "./interface"; * crypto material. */ export class NodesCryptoCache { - constructor(private driveCache: ProtonDriveCryptoCache) { + constructor(private logger: Logger, private driveCache: ProtonDriveCryptoCache) { + this.logger = logger; this.driveCache = driveCache; } async setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys): Promise { - const cacheUid = getCacheUid(nodeUid); + const cacheUid = getCacheKey(nodeUid); this.driveCache.setEntity(cacheUid, keys); } async getNodeKeys(nodeUid: string): Promise { - const nodeKeysData = await this.driveCache.getEntity(getCacheUid(nodeUid)); + const nodeKeysData = await this.driveCache.getEntity(getCacheKey(nodeUid)); if (!nodeKeysData.passphrase) { try { await this.removeNodeKeys([nodeUid]); - } catch { - // TODO: log error + } catch (removingError: unknown) { + // The node keys will not be returned, thus SDK will re-fetch + // and re-cache it. Setting it again should then fix the problem. + this.logger.warn(`Failed to remove corrupted node keys from the cache: ${removingError instanceof Error ? removingError.message : removingError}`); } throw new Error(`Failed to deserialize node keys: missing passphrase`); } @@ -34,11 +37,11 @@ export class NodesCryptoCache { } async removeNodeKeys(nodeUids: string[]): Promise { - const cacheUids = nodeUids.map(getCacheUid); + const cacheUids = nodeUids.map(getCacheKey); await this.driveCache.removeEntities(cacheUids); } } -function getCacheUid(nodeUid: string) { +function getCacheKey(nodeUid: string) { return `nodeKeys-${nodeUid}`; } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 136490de..9450bf75 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -191,7 +191,7 @@ describe("nodesCryptoService", () => { expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing key signature" } }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, activeRevision: undefined, folder: { @@ -241,7 +241,7 @@ describe("nodesCryptoService", () => { node: { name: { ok: true, value: "name" }, keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Verification of name signature failed" } }, + nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Signature verification for name failed" } }, activeRevision: undefined, folder: { extendedAttributes: undefined, @@ -289,7 +289,7 @@ describe("nodesCryptoService", () => { expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Verification of hash key signature failed" } }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for hash key failed" } }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, activeRevision: undefined, folder: { @@ -344,7 +344,7 @@ describe("nodesCryptoService", () => { expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing key signature" } }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, activeRevision: undefined, folder: { @@ -395,7 +395,7 @@ describe("nodesCryptoService", () => { expect(result).toMatchObject({ node: { name: { ok: true, value: "name" }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Verification of extended attributes signature failed" } }, + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for attributes failed" } }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, activeRevision: undefined, folder: { @@ -497,7 +497,7 @@ describe("nodesCryptoService", () => { state: "active", createdDate: undefined, extendedAttributes: "{}", - contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Verification of extended attributes signature failed" } }, + contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Signature verification for attributes failed" } }, } }, folder: undefined, }, diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index e6d28a5a..f3677d4f 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,10 +1,13 @@ +import { c } from 'ttag'; + import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; import { resultOk, resultError, Result, InvalidNameError, Author, ProtonDriveAccount, ProtonDriveTelemetry, Logger } from "../../interface"; +import { ValidationError } from '../../errors'; +import { getErrorMessage, getVerificationMessage } from "../errors"; +import { splitNodeUid } from "../uids"; import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, SharesService, EncryptedRevision, DecryptedRevision } from "./interface"; - -// TODO: Switch to CryptoProxy module once available. +// FIXME: Switch to CryptoProxy module once available. import { importHmacKey, computeHmacSignature } from "./hmac"; -import { splitNodeUid } from "../uids"; /** * Provides crypto operations for nodes metadata. @@ -65,7 +68,7 @@ export class NodesCryptoService { keyAuthor = keyResult.author; } catch (error: unknown) { this.reportDecryptionError(node, error); - const errorMessage = `Failed to decrypt node key: ${error instanceof Error ? error.message : 'Unknown error'}`; + const errorMessage = c('Error').t`Failed to decrypt node key: ${getErrorMessage(error)}`; return { node: { ...commonNodeMetadata, @@ -114,7 +117,7 @@ export class NodesCryptoService { try { activeRevision = resultOk(await this.decryptRevision(node.encryptedCrypto.activeRevision, key, parentKey)); } catch (error: unknown) { - const errorMessage = `Failed to decrypt active revision: ${error instanceof Error ? error.message : 'Unknown error'}`; + const errorMessage = c('Error').t`Failed to decrypt active revision: ${getErrorMessage(error)}`; activeRevision = resultError(new Error(errorMessage)); } } @@ -170,7 +173,7 @@ export class NodesCryptoService { passphrase: key.passphrase, key: key.key, sessionKey: key.sessionKey, - author: await this.handleClaimedAuthor(node, 'SignatureEmail', 'key', key.verified, node.encryptedCrypto.signatureEmail), + author: await this.handleClaimedAuthor(node, 'SignatureEmail', c('Property').t`key`, key.verified, node.encryptedCrypto.signatureEmail), }; }; @@ -189,20 +192,19 @@ export class NodesCryptoService { return { name: resultOk(name), - author: await this.handleClaimedAuthor(node, 'NameSignatureEmail', 'name', verified, nameSignatureEmail), + author: await this.handleClaimedAuthor(node, 'NameSignatureEmail', c('Property').t`name`, verified, nameSignatureEmail), } } catch (error: unknown) { this.reportDecryptionError(node, error); - // TODO: Translation - const message = error instanceof Error ? error.message : 'Unknown error'; + const errorMessage = getErrorMessage(error); return { name: resultError({ name: '', - error: message, + error: errorMessage, }), author: resultError({ claimedAuthor: nameSignatureEmail, - error: message, + error: errorMessage, }), } } @@ -217,6 +219,7 @@ export class NodesCryptoService { author: Author, }> { if (!("folder" in node.encryptedCrypto)) { + // This is developer error. throw new Error('Node is not a folder'); } @@ -228,7 +231,7 @@ export class NodesCryptoService { return { hashKey, - author: await this.handleClaimedAuthor(node, 'NodeKey', 'hash key', verified, node.encryptedCrypto.signatureEmail), + author: await this.handleClaimedAuthor(node, 'NodeKey', c('Property').t`hash key`, verified, node.encryptedCrypto.signatureEmail), } } @@ -257,7 +260,7 @@ export class NodesCryptoService { }> { if (!encryptedExtendedAttributes) { return { - author: handleClaimedAuthor('key', VERIFICATION_STATUS.SIGNED_AND_VALID, signatureEmail), + author: handleClaimedAuthor(c('Property').t`key`, VERIFICATION_STATUS.SIGNED_AND_VALID, signatureEmail), } } @@ -269,7 +272,7 @@ export class NodesCryptoService { return { extendedAttributes, - author: handleClaimedAuthor('extended attributes', verified, signatureEmail), + author: handleClaimedAuthor(c('Property').t`attributes`, verified, signatureEmail), } } @@ -348,10 +351,10 @@ export class NodesCryptoService { nameSignatureEmail: string, }> { if (!parentKeys.hashKey) { - throw new Error('Moving nodes to a non-folder is not supported'); + throw new ValidationError('Moving item to a non-folder is not allowed'); } if (!node.name.ok) { - throw new Error('Cannot move node without a valid name, please rename the node first'); + throw new ValidationError('Cannot move item without a valid name, please rename the item first'); } const { volumeId } = splitNodeUid(parentNode.uid); @@ -442,22 +445,21 @@ export class NodesCryptoService { } } +/** + * @param signatureType - Must be translated before calling this function. + */ function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string): Author { if (!claimedAuthor) { return resultOk(null); // Anonymous user } - + if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { return resultOk(claimedAuthor); } - // TODO: Translation - const error = verified === VERIFICATION_STATUS.SIGNED_AND_INVALID - ? `Verification of ${signatureType} signature failed` - : `Missing ${signatureType} signature`; return resultError({ claimedAuthor: claimedAuthor, - error, + error: getVerificationMessage(verified, signatureType), }); } diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 43a1a947..6c4251f6 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -35,7 +35,7 @@ export function initNodesModule( ) { const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); - const cryptoCache = new NodesCryptoCache(driveCryptoCache); + const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); const nodesAccess = new NodesAccess(telemetry.getLogger('nodes'), api, cache, cryptoCache, cryptoService, sharesService); const nodesEvents = new NodesEvents(telemetry.getLogger('nodes-events'), driveEvents, cache, nodesAccess); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 26e0efa4..7b0f835a 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,5 +1,8 @@ +import { c } from 'ttag'; + import { PrivateKey, SessionKey } from "../../crypto"; import { Logger, NodeType, resultError, resultOk } from "../../interface"; +import { SDKError } from "../../errors"; import { BatchLoading } from "../batchLoading"; import { makeNodeUid } from "../uids"; import { NodeAPIService } from "./apiService"; @@ -10,6 +13,10 @@ import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from "./ex import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; import { validateNodeName } from "./validations"; +class NodeKeysDecryptionError extends SDKError { + name = 'NodeKeysDecryptionError'; +} + /** * Provides access to node metadata. * @@ -141,9 +148,17 @@ export class NodesAccess { const { key: parentKey } = await this.getParentKeys(encryptedNode); const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); const node = await this.parseNode(unparsedNode); - this.cache.setNode(node); + try { + this.cache.setNode(node); + } catch (error: unknown) { + this.logger.error(`Failed to cache node ${node.uid}`, error); + } if (keys) { - this.cryptoCache.setNodeKeys(node.uid, keys); + try { + this.cryptoCache.setNodeKeys(node.uid, keys); + } catch (error: unknown) { + this.logger.error(`Failed to cache node keys ${node.uid}`, error); + } } return { node, keys }; } @@ -195,15 +210,26 @@ export class NodesAccess { async getParentKeys(node: Pick): Promise> { if (node.parentUid) { - return this.getNodeKeys(node.parentUid); - } - if (!node.shareId) { - // TODO: better error message - throw new Error('Node tree has no parent to access the keys'); + try { + return await this.getNodeKeys(node.parentUid); + } catch (error: unknown) { + if (error instanceof NodeKeysDecryptionError) { + // Change the error message to be more specific. + // Original error message is referring to node, while here + // it referes to as parent to follow the method context. + throw new NodeKeysDecryptionError(c('Error').t`Parent cannot be decrypted`); + } + throw error; + } } - return { - key: await this.shareService.getSharePrivateKey(node.shareId), + if (node.shareId) { + return { + key: await this.shareService.getSharePrivateKey(node.shareId), + } } + // This is bug that should not happen. + // API cannot provide node without parent or share. + throw new Error('Node has neither parent node nor share'); } async getNodeKeys(nodeUid: string): Promise { @@ -212,8 +238,7 @@ export class NodesAccess { } catch { const { keys } = await this.loadNode(nodeUid); if (!keys) { - // TODO: better error message - throw new Error('Parent node cannot be decrypted'); + throw new NodeKeysDecryptionError(c('Error').t`Item cannot be decrypted`); } return keys; } diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index ccfc25ce..9c2cea00 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -1,5 +1,8 @@ +import { c } from 'ttag'; + import { MemberRole, NodeType, NodeResult, resultOk } from "../../interface"; -import { AbortError } from "../errors"; +import { AbortError, ValidationError } from "../../errors"; +import { getErrorMessage } from '../errors'; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache"; import { NodesCryptoCache } from "./cryptoCache"; @@ -40,7 +43,7 @@ export class NodesManagement { const parentKeys = await this.nodesAccess.getParentKeys(node); if (!node.hash || !parentKeys.hashKey) { - throw new Error('Renaming root nodes is not supported') + throw new ValidationError(c('Error').t`Renaming root item is not allowed`) } const { @@ -73,7 +76,7 @@ export class NodesManagement { async* moveNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator { for (const nodeUid of nodeUids) { if (signal?.aborted) { - throw new AbortError('Move operation aborted'); + throw new AbortError(c('Error').t`Move operation aborted`); } try { await this.moveNode(nodeUid, newParentNodeUid); @@ -85,7 +88,7 @@ export class NodesManagement { yield { uid: nodeUid, ok: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error), } } } @@ -102,10 +105,10 @@ export class NodesManagement { ]); if (!node.hash) { - throw new Error('Moving root nodes is not supported'); + throw new ValidationError(c('Error').t`Moving root item is not allowed`); } if (!newParentKeys.hashKey) { - throw new Error('Moving nodes to a non-folder is not supported'); + throw new ValidationError(c('Error').t`Moving item to a non-folder is not allowed`); } const encryptedCrypto = await this.cryptoService.moveNode( @@ -205,7 +208,7 @@ export class NodesManagement { const parentNode = await this.nodesAccess.getNode(parentNodeUid); const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); if (!parentKeys.hashKey) { - throw new Error('Creating folders in non-folders is not supported'); + throw new ValidationError(c('Error').t`Creating folders in non-folders is not allowed`); } const extendedAttributes = generateFolderExtendedAttributes(modificationTime); diff --git a/js/sdk/src/internal/nodes/validations.ts b/js/sdk/src/internal/nodes/validations.ts index 50c543dd..0133b905 100644 --- a/js/sdk/src/internal/nodes/validations.ts +++ b/js/sdk/src/internal/nodes/validations.ts @@ -1,6 +1,6 @@ import { c, msgid } from 'ttag'; -import { ValidationError } from '../errors'; +import { ValidationError } from '../../errors'; const MAX_NODE_NAME_LENGTH = 255; @@ -9,11 +9,11 @@ const MAX_NODE_NAME_LENGTH = 255; */ export function validateNodeName(name: string): void { if (!name) { - throw new ValidationError(c('Validation Error').t`Name must not be empty`); + throw new ValidationError(c('Error').t`Name must not be empty`); } if (name.length > MAX_NODE_NAME_LENGTH) { throw new ValidationError( - c('Validation Error').ngettext( + c('Error').ngettext( msgid`Name must be ${MAX_NODE_NAME_LENGTH} character long at most`, `Name must be ${MAX_NODE_NAME_LENGTH} characters long at most`, MAX_NODE_NAME_LENGTH @@ -21,6 +21,6 @@ export function validateNodeName(name: string): void { ); } if (name.includes('/')) { - throw new ValidationError(c('Validation Error').t`Name must not contain the character '/'`); + throw new ValidationError(c('Error').t`Name must not contain the character '/'`); } } diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index fc1d211c..40e126cc 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -1,4 +1,5 @@ import { ProtonDriveEntitiesCache, Logger } from "../../interface"; +import { getErrorMessage } from "../errors"; import { Volume } from "./interface"; /** @@ -27,10 +28,10 @@ export class SharesCache { } catch (error: unknown) { try { await this.removeVolume(volumeId); - } catch { - this.logger.error('Failed to remove invalid volume from cache'); + } catch (removingError: unknown) { + this.logger.error('Failed to remove invalid volume from cache', removingError); } - throw new Error(`Failed to deserialize volume: ${error instanceof Error ? error.message : error}`); + throw new Error(`Failed to deserialize volume: ${getErrorMessage(error)}`); } } diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 946d5364..71a21826 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -1,5 +1,6 @@ import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger } from "../../interface"; import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto"; +import { getVerificationMessage } from "../errors"; import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareKey } from "./interface"; /** @@ -72,9 +73,7 @@ export class SharesCryptoService { ? resultOk(share.creatorEmail) : resultError({ claimedAuthor: share.creatorEmail, - error: verified === VERIFICATION_STATUS.SIGNED_AND_INVALID - ? `Verification signature failed` - : `Missing signature`, + error: getVerificationMessage(verified), }); if (!author.ok) { diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 661ce2c9..9976671c 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -1,4 +1,4 @@ -import { NodeType, MemberRole, NonProtonInvitationState } from "../../interface"; +import { NodeType, MemberRole, NonProtonInvitationState, Logger } from "../../interface"; import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from "../apiService"; import { makeNodeUid, splitNodeUid, makeInvitationUid, splitInvitationUid, makeMemberUid, splitMemberUid } from "../uids"; import { EncryptedInvitationRequest, EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedBookmark, EncryptedExternalInvitationRequest } from "./interface"; @@ -47,7 +47,8 @@ type PostUpdateMemberResponse = drivePaths['/drive/v2/shares/{shareID}/members/{ * and vice versa. It should not contain any business logic. */ export class SharingAPIService { - constructor(private apiService: DriveAPIService) { + constructor(private logger: Logger, private apiService: DriveAPIService) { + this.logger = logger; this.apiService = apiService; } @@ -106,14 +107,14 @@ export class SharingAPIService { base64KeyPacket: response.Invitation.KeyPacket, base64KeyPacketSignature: response.Invitation.KeyPacketSignature, invitedDate: new Date(response.Invitation.CreateTime*1000), - role: permissionsToDirectMemberRole(response.Invitation.Permissions), + role: permissionsToDirectMemberRole(this.logger, response.Invitation.Permissions), share: { armoredKey: response.Share.ShareKey, armoredPassphrase: response.Share.Passphrase, creatorEmail: response.Share.CreatorEmail, }, node: { - type: nodeTypeNumberToNodeType(response.Link.Type), + type: nodeTypeNumberToNodeType(this.logger, response.Link.Type), mimeType: response.Link.MIMEType || undefined, encryptedName: response.Link.Name, }, @@ -170,14 +171,14 @@ export class SharingAPIService { async getShareInvitations(shareId: string): Promise { const response = await this.apiService.get(`drive/v2/shares/${shareId}/invitations`); return response.Invitations.map((invitation) => { - return convertInternalInvitation(shareId, invitation); + return this.convertInternalInvitation(shareId, invitation); }); } async getShareExternalInvitations(shareId: string): Promise { const response = await this.apiService.get(`drive/v2/shares/${shareId}/external-invitations`); return response.ExternalInvitations.map((invitation) => { - return convertExternalInvitaiton(shareId, invitation); + return this.convertExternalInvitaiton(shareId, invitation); }); } @@ -191,7 +192,7 @@ export class SharingAPIService { base64KeyPacket: member.KeyPacket, base64KeyPacketSignature: member.KeyPacketSignature, invitedDate: new Date(member.CreateTime*1000), - role: permissionsToDirectMemberRole(member.Permissions), + role: permissionsToDirectMemberRole(this.logger, member.Permissions), } }); } @@ -253,7 +254,7 @@ export class SharingAPIService { ItemName: emailDetails.nodeName, }, }); - return convertInternalInvitation(shareId, response.Invitation); + return this.convertInternalInvitation(shareId, response.Invitation); } async updateInvitation( @@ -299,7 +300,7 @@ export class SharingAPIService { ItemName: emailDetails.nodeName, }, }); - return convertExternalInvitaiton(shareId, response.ExternalInvitation); + return this.convertExternalInvitaiton(shareId, response.ExternalInvitation); } async updateExternalInvitation( @@ -339,29 +340,30 @@ export class SharingAPIService { const { shareId, memberId } = splitMemberUid(memberUid); await this.apiService.delete(`drive/v2/shares/${shareId}/members/${memberId}`); } -} -function convertInternalInvitation(shareId: string, invitation: GetShareInvitations['Invitations'][0]): EncryptedInvitation { - return { - uid: makeInvitationUid(shareId, invitation.InvitationID), - addedByEmail: invitation.InviterEmail, - inviteeEmail: invitation.InviteeEmail, - invitedDate: new Date(invitation.CreateTime*1000), - role: permissionsToDirectMemberRole(invitation.Permissions), - base64KeyPacket: invitation.KeyPacket, - base64KeyPacketSignature: invitation.KeyPacketSignature, + private convertInternalInvitation(shareId: string, invitation: GetShareInvitations['Invitations'][0]): EncryptedInvitation { + return { + uid: makeInvitationUid(shareId, invitation.InvitationID), + addedByEmail: invitation.InviterEmail, + inviteeEmail: invitation.InviteeEmail, + invitedDate: new Date(invitation.CreateTime*1000), + role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), + base64KeyPacket: invitation.KeyPacket, + base64KeyPacketSignature: invitation.KeyPacketSignature, + } } -} - -function convertExternalInvitaiton(shareId: string, invitation: GetShareExternalInvitations['ExternalInvitations'][0]): EncryptedExternalInvitation { - const state = invitation.State === 1 ? NonProtonInvitationState.Pending : NonProtonInvitationState.UserRegistered; - return { - uid: makeInvitationUid(shareId, invitation.ExternalInvitationID), - addedByEmail: invitation.InviterEmail, - inviteeEmail: invitation.InviteeEmail, - invitedDate: new Date(invitation.CreateTime*1000), - role: permissionsToDirectMemberRole(invitation.Permissions), - base64Signature: invitation.ExternalInvitationSignature, - state, + + private convertExternalInvitaiton(shareId: string, invitation: GetShareExternalInvitations['ExternalInvitations'][0]): EncryptedExternalInvitation { + const state = invitation.State === 1 ? NonProtonInvitationState.Pending : NonProtonInvitationState.UserRegistered; + return { + uid: makeInvitationUid(shareId, invitation.ExternalInvitationID), + addedByEmail: invitation.InviterEmail, + inviteeEmail: invitation.InviteeEmail, + invitedDate: new Date(invitation.CreateTime*1000), + role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), + base64Signature: invitation.ExternalInvitationSignature, + state, + } } + } diff --git a/js/sdk/src/internal/sharing/cache.ts b/js/sdk/src/internal/sharing/cache.ts index acd43e48..478dce50 100644 --- a/js/sdk/src/internal/sharing/cache.ts +++ b/js/sdk/src/internal/sharing/cache.ts @@ -51,6 +51,7 @@ export class SharingCache { try { nodeUids = await this.getNodeUids(type); } catch { + // This is developer error. throw new Error('Calling add before setting the loaded items'); } const set = new Set(nodeUids); @@ -70,6 +71,7 @@ export class SharingCache { try { nodeUids = await this.getNodeUids(type); } catch { + // This is developer error. throw new Error('Calling remove before setting the loaded items'); } const set = new Set(nodeUids); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 9d8db69a..cec36702 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -1,5 +1,8 @@ +import { c } from 'ttag'; + import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, InvalidNameError, resultError, resultOk } from "../../interface"; +import { getErrorMessage, getVerificationMessage } from "../errors"; import { EncryptedShare } from "../shares"; import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember } from "./interface"; @@ -102,9 +105,7 @@ export class SharingCryptoService { ? resultOk(share.creatorEmail) : resultError({ claimedAuthor: share.creatorEmail, - error: verified === VERIFICATION_STATUS.SIGNED_AND_INVALID - ? `Verification signature failed` - : `Missing signature`, + error: getVerificationMessage(verified), }); return { @@ -154,7 +155,7 @@ export class SharingCryptoService { ); nodeName = resultOk(result.name); } catch (error: unknown) { - const errorMessage = `Failed to decrypt node name: ${error instanceof Error ? error.message : 'Unknown error'}`; + const errorMessage = c('Error').t`Failed to decrypt item name: ${getErrorMessage(error)}`; nodeName = resultError({ name: '', error: errorMessage }); } @@ -172,7 +173,7 @@ export class SharingCryptoService { * Verifies an invitation. */ async decryptInvitation(encryptedInvitation: EncryptedInvitation): Promise { - // TODO: verify addedByEmail (current client doesnt do this) + // FIXME: verify addedByEmail (current client doesnt do this) const addedByEmail: Result = resultOk(encryptedInvitation.addedByEmail); return { @@ -223,7 +224,7 @@ export class SharingCryptoService { * Verifies an external invitation. */ async decryptExternalInvitation(encryptedInvitation: EncryptedExternalInvitation): Promise { - // TODO: verify addedByEmail (current client doesnt do this) + // FIXME: verify addedByEmail (current client doesnt do this) const addedByEmail: Result = resultOk(encryptedInvitation.addedByEmail); return { @@ -240,7 +241,7 @@ export class SharingCryptoService { * Verifies a member. */ async decryptMember(encryptedMember: EncryptedMember): Promise { - // TODO: verify addedByEmail (current client doesnt do this) + // FIXME: verify addedByEmail (current client doesnt do this) const addedByEmail: Result = resultOk(encryptedMember.addedByEmail); return { diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index 8158204b..bbda6b8a 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -27,7 +27,7 @@ export function initSharingModule( sharesService: SharesService, nodesService: NodesService, ) { - const api = new SharingAPIService(apiService); + const api = new SharingAPIService(telemetry.getLogger('sharing-api'), apiService); const cache = new SharingCache(driveEntitiesCache); const cryptoService = new SharingCryptoService(crypto, account); const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index f087776e..fc38666a 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -1,4 +1,7 @@ +import { c } from 'ttag'; + import { NodeEntity, ProtonInvitationWithNode } from "../../interface"; +import { ValidationError } from "../../errors"; import { BatchLoading } from "../batchLoading"; import { SharingAPIService } from "./apiService"; import { SharingCache } from "./cache"; @@ -82,7 +85,7 @@ export class SharingAccess { const share = await this.sharesService.loadEncryptedShare(node.shareId); const memberUid = share.membership?.memberUid; if (!memberUid) { - throw new Error('Share without membership cannot be removed'); + throw new ValidationError(c('Error').t`You can leave only item that is shared with you`); } await this.apiService.removeMember(memberUid); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 342f0f8f..94792be6 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -1,4 +1,7 @@ +import { c } from 'ttag'; + import { SessionKey } from "../../crypto"; +import { ValidationError } from "../../errors"; import { Logger, PublicLink, MemberRole, ShareNodeSettings, UnshareNodeSettings, SharePublicLinkSettings, ShareResult, ProtonInvitation, NonProtonInvitation, Member, resultOk } from "../../interface"; import { splitNodeUid } from "../uids"; import { SharingAPIService } from "./apiService"; @@ -284,7 +287,7 @@ export class SharingManagement { private async createShare(nodeUid: string): Promise { const node = await this.nodesService.getNode(nodeUid); if (!node.parentUid) { - throw new Error("Cannot share root node"); + throw new ValidationError(c('Error').t`Cannot share root folder`); } const { volumeId } = splitNodeUid(nodeUid); diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index e75d3570..4bbea5ec 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -207,7 +207,7 @@ export class UploadAPIService { ManifestSignature: options.armoredManifestSignature, SignatureAddress: options.signatureEmail, XAttr: options.armoredEncryptedExtendedAttributes, - Photo: null, // TODO + Photo: null, // FIXME }); } From 54ca97ef62d1de6d0d5f4b423ae87b0947c72e87 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 14 Mar 2025 13:49:17 +0000 Subject: [PATCH 046/791] Add volume operations --- cs/sdk/src/Directory.Packages.props | 1 + .../Proton.Drive.Sdk/AccountClientAdapter.cs | 20 +++++ .../Proton.Drive.Sdk/Api/DriveApiClients.cs | 8 ++ .../Proton.Drive.Sdk/Api/IDriveApiClients.cs | 8 ++ .../Caching/DriveClientCache.cs | 11 +++ .../Caching/DriveEntityCache.cs | 23 ++++++ .../Caching/DriveSecretCache.cs | 74 ++++++++++++++++++ .../Caching/IDriveClientCache.cs | 7 ++ .../Caching/IDriveEntityCache.cs | 9 +++ .../Caching/IDriveSecretCache.cs | 15 ++++ .../Cryptography/CryptoGenerator.cs | 32 ++++++++ cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs | 10 +++ cs/sdk/src/Proton.Drive.Sdk/LinkId.cs | 11 +++ cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs | 20 +++++ .../Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 25 ++++++ .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 35 +++++++++ .../Proton.Drive.Sdk/ProtonDriveDefaults.cs | 6 ++ .../DriveApiSerializerContext.cs | 15 ++++ .../DriveEntitySerializerContext.cs | 17 ++++ cs/sdk/src/Proton.Drive.Sdk/ShareId.cs | 11 +++ .../Volumes/Api/IVolumesApiClient.cs | 6 ++ .../Volumes/Api/VolumeCreationParameters.cs | 30 ++++++++ .../Volumes/Api/VolumeCreationResponse.cs | 8 ++ .../Proton.Drive.Sdk/Volumes/Api/VolumeDto.cs | 18 +++++ .../Volumes/Api/VolumeRoot.cs | 12 +++ .../Volumes/Api/VolumesApiClient.cs | 16 ++++ cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs | 21 +++++ .../src/Proton.Drive.Sdk/Volumes/VolumeId.cs | 11 +++ .../Volumes/VolumeOperations.cs | 68 ++++++++++++++++ .../Proton.Drive.Sdk/Volumes/VolumeState.cs | 10 +++ cs/sdk/src/Proton.Sdk/AccountApiClients.cs | 12 --- .../src/Proton.Sdk/Addresses/AddressKeyId.cs | 2 +- .../Proton.Sdk/Addresses/AddressOperations.cs | 24 +++--- .../src/Proton.Sdk/Api/AccountApiClients.cs | 12 +++ .../src/Proton.Sdk/Api/IAccountApiClients.cs | 12 +++ .../src/Proton.Sdk/Api/IApiClientFactory.cs | 10 ++- .../Proton.Sdk/Caching/AccountClientCache.cs | 12 +++ .../Proton.Sdk/Caching/AccountEntityCache.cs | 61 ++++----------- .../Proton.Sdk/Caching/AccountSecretCache.cs | 22 +++--- .../Caching/CacheRepositoryExtensions.cs | 77 ++++++++++++++++--- .../Proton.Sdk/Caching/IAccountClientCache.cs | 9 +++ .../Proton.Sdk/Caching/IAccountEntityCache.cs | 12 +++ .../Proton.Sdk/Caching/IAccountSecretCache.cs | 13 ++++ .../Proton.Sdk/Caching/ICacheRepository.cs | 2 +- .../src/Proton.Sdk/Caching/IPublicKeyCache.cs | 10 +++ .../Proton.Sdk/Caching/ISessionSecretCache.cs | 7 ++ .../Proton.Sdk/Caching/NullCacheRepository.cs | 4 +- .../src/Proton.Sdk/Caching/PublicKeyCache.cs | 1 + .../Proton.Sdk/Caching/SessionSecretCache.cs | 6 +- .../Caching/SqliteCacheRepository.cs | 8 +- cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 6 +- cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 27 ++++--- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 2 +- ...t.cs => AccountEntitySerializerContext.cs} | 6 +- .../Serialization/SecretsSerializerContext.cs | 13 ++++ 55 files changed, 803 insertions(+), 125 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/DriveClientCache.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveClientCache.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/LinkId.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitySerializerContext.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ShareId.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/IVolumesApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationParameters.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeRoot.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumesApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeState.cs delete mode 100644 cs/sdk/src/Proton.Sdk/AccountApiClients.cs create mode 100644 cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs create mode 100644 cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/AccountClientCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/IAccountClientCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/IAccountEntityCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/IAccountSecretCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/IPublicKeyCache.cs create mode 100644 cs/sdk/src/Proton.Sdk/Caching/ISessionSecretCache.cs rename cs/sdk/src/Proton.Sdk/Serialization/{ProtonEntitySerializerContext.cs => AccountEntitySerializerContext.cs} (70%) create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs diff --git a/cs/sdk/src/Directory.Packages.props b/cs/sdk/src/Directory.Packages.props index 7460c651..c7c64d02 100644 --- a/cs/sdk/src/Directory.Packages.props +++ b/cs/sdk/src/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs new file mode 100644 index 00000000..6c8cf762 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs @@ -0,0 +1,20 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk; + +internal sealed class AccountClientAdapter(ProtonApiSession session) : IAccountClient +{ + private readonly ProtonAccountClient _client = new(session); + + public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) + { + return _client.GetDefaultAddressAsync(cancellationToken); + } + + public ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken) + { + return _client.GetAddressPrimaryKeyAsync(addressId, cancellationToken); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs new file mode 100644 index 00000000..e8d8e9ae --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -0,0 +1,8 @@ +using Proton.Drive.Sdk.Volumes.Api; + +namespace Proton.Drive.Sdk.Api; + +internal sealed class DriveApiClients(HttpClient httpClient) : IDriveApiClients +{ + public IVolumesApiClient Volumes { get; } = new VolumesApiClient(httpClient); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs new file mode 100644 index 00000000..0177408f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs @@ -0,0 +1,8 @@ +using Proton.Drive.Sdk.Volumes.Api; + +namespace Proton.Drive.Sdk.Api; + +internal interface IDriveApiClients +{ + IVolumesApiClient Volumes { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveClientCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveClientCache.cs new file mode 100644 index 00000000..805b2ad1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveClientCache.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Caching; + +namespace Proton.Drive.Sdk.Caching; + +internal sealed class DriveClientCache( + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository) : IDriveClientCache +{ + public IDriveEntityCache Entities { get; } = new DriveEntityCache(entityCacheRepository); + public IDriveSecretCache Secrets { get; } = new DriveSecretCache(secretCacheRepository); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs new file mode 100644 index 00000000..a4e44eb7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -0,0 +1,23 @@ +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Caching; + +namespace Proton.Drive.Sdk.Caching; + +internal sealed class DriveEntityCache(ICacheRepository repository) : IDriveEntityCache +{ + private const string MainVolumeIdCacheKey = "volume:main:id"; + + private readonly ICacheRepository _repository = repository; + + public ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken) + { + return _repository.SetAsync(MainVolumeIdCacheKey, volumeId.Value, cancellationToken); + } + + public async ValueTask TryGetMainVolumeIdAsync(CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(MainVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); + + return value is not null ? (VolumeId?)value : null; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs new file mode 100644 index 00000000..87f7e42f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Caching; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Caching; + +internal sealed class DriveSecretCache(ICacheRepository repository) : IDriveSecretCache +{ + private readonly ICacheRepository _repository = repository; + + public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(shareKey, SecretsSerializerContext.Default.PgpPrivateKey); + + return _repository.SetAsync(GetShareKeyCacheKey(shareId), serializedValue, cancellationToken); + } + + public async ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetShareKeyCacheKey(shareId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, SecretsSerializerContext.Default.PgpPrivateKey) + : null; + } + + public ValueTask SetNodeKeyAsync(NodeUid nodeId, PgpPrivateKey nodeKey, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(nodeKey, SecretsSerializerContext.Default.PgpPrivateKey); + + return _repository.SetAsync(GetNodeKeyCacheKey(nodeId), serializedValue, cancellationToken); + } + + public async ValueTask TryGetNodeKeyAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetNodeKeyCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, SecretsSerializerContext.Default.PgpPrivateKey) + : null; + } + + public ValueTask SetFolderHashKeyAsync(NodeUid nodeId, ReadOnlySpan folderHashKey, CancellationToken cancellationToken) + { + var serializedValue = Convert.ToBase64String(folderHashKey); + + return _repository.SetAsync(GetFolderHashKeyCacheKey(nodeId), serializedValue, cancellationToken); + } + + public async ValueTask?> TryGetFolderHashKeyAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetFolderHashKeyCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? Convert.FromBase64String(serializedValue) + : null; + } + + private static string GetShareKeyCacheKey(ShareId shareId) + { + return $"share:{shareId}:key"; + } + + private static string GetNodeKeyCacheKey(NodeUid nodeId) + { + return $"node:{nodeId}:key"; + } + + private static string GetFolderHashKeyCacheKey(NodeUid nodeId) + { + return $"node:{nodeId}:hash-key"; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveClientCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveClientCache.cs new file mode 100644 index 00000000..363b1bc7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveClientCache.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Caching; + +internal interface IDriveClientCache +{ + IDriveEntityCache Entities { get; } + IDriveSecretCache Secrets { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs new file mode 100644 index 00000000..a240a08b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -0,0 +1,9 @@ +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Caching; + +internal interface IDriveEntityCache +{ + ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); + ValueTask TryGetMainVolumeIdAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs new file mode 100644 index 00000000..010dac47 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -0,0 +1,15 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Caching; + +internal interface IDriveSecretCache +{ + ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken); + ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken); + + ValueTask SetNodeKeyAsync(NodeUid nodeId, PgpPrivateKey nodeKey, CancellationToken cancellationToken); + ValueTask TryGetNodeKeyAsync(NodeUid nodeId, CancellationToken cancellationToken); + + ValueTask SetFolderHashKeyAsync(NodeUid nodeId, ReadOnlySpan folderHashKey, CancellationToken cancellationToken); + ValueTask?> TryGetFolderHashKeyAsync(NodeUid nodeId, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs new file mode 100644 index 00000000..75291ab0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs @@ -0,0 +1,32 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Cryptography; + +internal static class CryptoGenerator +{ + private const int PassphraseMaxUtf8Length = ((PassphraseRandomBytesLength + 2) / 3) * 4; + private const int PassphraseRandomBytesLength = 32; + + public static int PassphraseBufferRequiredLength => PassphraseMaxUtf8Length; + public static int FolderHashKeyLength => 32; + + public static ReadOnlySpan GeneratePassphrase(Span buffer) + { + var randomBytes = buffer[..PassphraseRandomBytesLength]; + RandomNumberGenerator.Fill(randomBytes); + Base64.EncodeToUtf8InPlace(buffer, PassphraseRandomBytesLength, out var length); + return buffer[..length]; + } + + public static PgpPrivateKey GeneratePrivateKey() + { + return PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default); + } + + public static void GenerateFolderHashKey(Span hashKeyBuffer) + { + RandomNumberGenerator.Fill(hashKeyBuffer); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs new file mode 100644 index 00000000..02db72c3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs @@ -0,0 +1,10 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk; + +internal interface IAccountClient +{ + ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken); + ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/LinkId.cs b/cs/sdk/src/Proton.Drive.Sdk/LinkId.cs new file mode 100644 index 00000000..b5187af9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/LinkId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk; + +public readonly record struct LinkId(string Value) : IStrongId +{ + public static implicit operator LinkId(string value) + { + return new LinkId(value); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs new file mode 100644 index 00000000..9eb20884 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs @@ -0,0 +1,20 @@ +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk; + +public readonly record struct NodeUid +{ + internal NodeUid(VolumeId volumeId, LinkId linkId) + { + VolumeId = volumeId; + LinkId = linkId; + } + + internal VolumeId VolumeId { get; } + internal LinkId LinkId { get; } + + public override string ToString() + { + return $"{VolumeId}~{LinkId}"; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj new file mode 100644 index 00000000..f10e565a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -0,0 +1,25 @@ + + + + Cloud Storage Volume Folder File + Package that provides the means to interact with the Proton Drive services. + true + true + snupkg + + + + + + + + + + + + + + + + + diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs new file mode 100644 index 00000000..f613e83c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -0,0 +1,35 @@ +using Proton.Drive.Sdk.Api; +using Proton.Drive.Sdk.Caching; +using Proton.Sdk; + +namespace Proton.Drive.Sdk; + +public sealed class ProtonDriveClient +{ + private const int ApiTimeoutSeconds = 15; + + /// + /// Creates a new instance of . + /// + /// Authenticated API session. + public ProtonDriveClient(ProtonApiSession session) + : this( + new AccountClientAdapter(session), + new DriveApiClients(session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds))), + new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository)) + { + } + + internal ProtonDriveClient(IAccountClient accountClient, IDriveApiClients apiClients, IDriveClientCache cache) + { + Account = accountClient; + Api = apiClients; + Cache = cache; + } + + internal IAccountClient Account { get; } + + internal IDriveApiClients Api { get; } + + internal IDriveClientCache Cache { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs new file mode 100644 index 00000000..802a21a9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk; + +internal static class ProtonDriveDefaults +{ + public const string DriveBaseRoute = "drive/"; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs new file mode 100644 index 00000000..027d423d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Volumes.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +[JsonSerializable(typeof(VolumeCreationParameters))] +[JsonSerializable(typeof(VolumeCreationResponse))] +internal partial class DriveApiSerializerContext : JsonSerializerContext +{ + static DriveApiSerializerContext() + { + Default = new DriveApiSerializerContext(ProtonApiSerializerContext.Default.Options); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitySerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitySerializerContext.cs new file mode 100644 index 00000000..6824d027 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitySerializerContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Addresses; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +[JsonSourceGenerationOptions( + Converters = + [ + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + typeof(StrongIdJsonConverter), + ])] +[JsonSerializable(typeof(Volume[]))] +internal partial class DriveEntitySerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/ShareId.cs b/cs/sdk/src/Proton.Drive.Sdk/ShareId.cs new file mode 100644 index 00000000..f1ad95c7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ShareId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk; + +public readonly record struct ShareId(string Value) : IStrongId +{ + public static implicit operator ShareId(string value) + { + return new ShareId(value); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/IVolumesApiClient.cs new file mode 100644 index 00000000..2db7ead6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/IVolumesApiClient.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Volumes.Api; + +internal interface IVolumesApiClient +{ + Task CreateVolumeAsync(VolumeCreationParameters parameters, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationParameters.cs new file mode 100644 index 00000000..3d02324f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationParameters.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Volumes.Api; + +internal sealed class VolumeCreationParameters +{ + [JsonPropertyName("AddressID")] + public required string AddressId { get; init; } + + public required PgpArmoredPrivateKey ShareKey { get; init; } + + [JsonPropertyName("SharePassphrase")] + public required PgpArmoredMessage ShareKeyPassphrase { get; init; } + + [JsonPropertyName("SharePassphraseSignature")] + public required PgpArmoredSignature ShareKeyPassphraseSignature { get; init; } + + public required PgpArmoredMessage FolderName { get; init; } + + public required PgpArmoredPrivateKey FolderKey { get; init; } + + [JsonPropertyName("FolderPassphrase")] + public required PgpArmoredMessage FolderKeyPassphrase { get; init; } + + [JsonPropertyName("FolderPassphraseSignature")] + public required PgpArmoredSignature FolderKeyPassphraseSignature { get; init; } + + public required PgpArmoredMessage FolderHashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationResponse.cs new file mode 100644 index 00000000..5e46772d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Volumes.Api; + +internal sealed class VolumeCreationResponse : ApiResponse +{ + public required VolumeDto Volume { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeDto.cs new file mode 100644 index 00000000..d1590c16 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeDto.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Volumes.Api; + +internal sealed class VolumeDto +{ + [JsonPropertyName("VolumeID")] + public required VolumeId Id { get; set; } + + public long? MaxSpace { get; init; } + + public required long UsedSpace { get; init; } + + public required VolumeState State { get; init; } + + [JsonPropertyName("Share")] + public required VolumeRoot Root { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeRoot.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeRoot.cs new file mode 100644 index 00000000..77af1c30 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeRoot.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Volumes.Api; + +internal sealed class VolumeRoot +{ + [JsonPropertyName("ShareID")] + public required ShareId ShareId { get; init; } + + [JsonPropertyName("LinkID")] + public required LinkId LinkId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumesApiClient.cs new file mode 100644 index 00000000..4c1406ec --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumesApiClient.cs @@ -0,0 +1,16 @@ +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Volumes.Api; + +internal sealed class VolumesApiClient(HttpClient httpClient) : IVolumesApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task CreateVolumeAsync(VolumeCreationParameters parameters, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.VolumeCreationResponse) + .PostAsync("volumes", parameters, DriveApiSerializerContext.Default.VolumeCreationParameters, cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs new file mode 100644 index 00000000..aa4edc15 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs @@ -0,0 +1,21 @@ +using Proton.Drive.Sdk.Volumes.Api; + +namespace Proton.Drive.Sdk.Volumes; + +internal sealed class Volume(VolumeId id, ShareId rootShareId, LinkId rootFolderId, VolumeState state, long? maxSpace) +{ + internal Volume(VolumeDto dto) + : this(dto.Id, dto.Root.ShareId, dto.Root.LinkId, dto.State, dto.MaxSpace) + { + } + + public VolumeId Id { get; } = id; + + public ShareId RootShareId { get; } = rootShareId; + + public LinkId RootFolderId { get; } = rootFolderId; + + public VolumeState State { get; } = state; + + public long? MaxSpace { get; } = maxSpace; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs new file mode 100644 index 00000000..ea7fdb2a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Volumes; + +public readonly record struct VolumeId(string Value) : IStrongId +{ + public static implicit operator VolumeId(string value) + { + return new VolumeId(value); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs new file mode 100644 index 00000000..70c7893e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -0,0 +1,68 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Volumes.Api; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk.Volumes; + +internal static class VolumeOperations +{ + internal static async ValueTask CreateAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var defaultAddress = await client.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); + + using var addressKey = await client.Account.GetAddressPrimaryKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); + + Span folderHashKey = stackalloc byte[CryptoGenerator.FolderHashKeyLength]; + CryptoGenerator.GenerateFolderHashKey(folderHashKey); + + var parameters = GetCreationParameters(defaultAddress.Id, addressKey, folderHashKey, out var rootShareKey, out var rootFolderKey); + + var response = await client.Api.Volumes.CreateVolumeAsync(parameters, cancellationToken).ConfigureAwait(false); + + var volume = new Volume(response.Volume); + + await client.Cache.Entities.SetMainVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetNodeKeyAsync(new NodeUid(volume.Id, volume.RootFolderId), rootFolderKey, cancellationToken).ConfigureAwait(false); + + return volume; + } + + private static VolumeCreationParameters GetCreationParameters( + AddressId addressId, + PgpPrivateKey addressKey, + ReadOnlySpan folderHashKey, + out PgpPrivateKey rootShareKey, + out PgpPrivateKey rootFolderKey) + { + const string folderName = "root"; + + rootShareKey = CryptoGenerator.GeneratePrivateKey(); + Span shareKeyPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var shareKeyPassphrase = CryptoGenerator.GeneratePassphrase(shareKeyPassphraseBuffer); + using var lockedShareKey = rootShareKey.Lock(shareKeyPassphrase); + + var encryptedShareKeyPassphrase = rootShareKey.EncryptAndSign(shareKeyPassphrase, addressKey, out var shareKeyPassphraseSignature); + + rootFolderKey = CryptoGenerator.GeneratePrivateKey(); + Span folderKeyPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var folderKeyPassphrase = CryptoGenerator.GeneratePassphrase(folderKeyPassphraseBuffer); + using var lockedFolderKey = rootFolderKey.Lock(folderKeyPassphrase); + + var encryptedFolderKeyPassphrase = rootFolderKey.EncryptAndSign(folderKeyPassphrase, addressKey, out var folderKeyPassphraseSignature); + + return new VolumeCreationParameters + { + AddressId = addressId.Value, + ShareKey = lockedShareKey.ToBytes(), + ShareKeyPassphrase = encryptedShareKeyPassphrase, + ShareKeyPassphraseSignature = shareKeyPassphraseSignature, + FolderName = rootShareKey.EncryptAndSignText(folderName, addressKey), + FolderKey = lockedFolderKey.ToBytes(), + FolderKeyPassphrase = encryptedFolderKeyPassphrase, + FolderKeyPassphraseSignature = folderKeyPassphraseSignature, + FolderHashKey = rootFolderKey.EncryptAndSign(folderHashKey, addressKey), + }; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeState.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeState.cs new file mode 100644 index 00000000..b222df68 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeState.cs @@ -0,0 +1,10 @@ +namespace Proton.Drive.Sdk.Volumes; + +public enum VolumeState +{ + None = 0, + Active = 1, + Deleted = 2, + Locked = 3, + Restored = 4, +} diff --git a/cs/sdk/src/Proton.Sdk/AccountApiClients.cs b/cs/sdk/src/Proton.Sdk/AccountApiClients.cs deleted file mode 100644 index 903daf58..00000000 --- a/cs/sdk/src/Proton.Sdk/AccountApiClients.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Proton.Sdk.Addresses.Api; -using Proton.Sdk.Keys.Api; -using Proton.Sdk.Users.Api; - -namespace Proton.Sdk; - -internal sealed class AccountApiClients(HttpClient httpClient) -{ - public IKeysApiClient Keys { get; } = new KeysApiClient(httpClient); - public IUsersApiClient Users { get; } = new UsersApiClient(httpClient); - public IAddressesApiClient Addresses { get; } = new AddressesApiClient(httpClient); -} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs index 0974cd34..07ce687f 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs @@ -1,6 +1,6 @@ using Proton.Sdk.Serialization; -namespace Proton.Sdk.Addresses.Api; +namespace Proton.Sdk.Addresses; public readonly record struct AddressKeyId(string Value) : IStrongId { diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 6afed366..6aa67f2b 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -12,7 +12,7 @@ internal static class AddressOperations { public static async ValueTask> GetAddressesAsync(ProtonAccountClient client, CancellationToken cancellationToken) { - var result = await client.EntityCache.TryGetCurrentUserAddressesAsync(cancellationToken).ConfigureAwait(false); + var result = await client.Cache.Entities.TryGetCurrentUserAddressesAsync(cancellationToken).ConfigureAwait(false); if (result is null) { @@ -36,7 +36,7 @@ public static async ValueTask> GetAddressesAsync(ProtonAc } } - await client.EntityCache.SetCurrentUserAddressesAsync(addresses, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetCurrentUserAddressesAsync(addresses, cancellationToken).ConfigureAwait(false); result = addresses; } @@ -46,7 +46,7 @@ public static async ValueTask> GetAddressesAsync(ProtonAc public static async ValueTask
GetAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) { - var address = await client.EntityCache.TryGetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); + var address = await client.Cache.Entities.TryGetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); if (address is null) { @@ -56,7 +56,7 @@ public static async ValueTask
GetAsync(ProtonAccountClient client, Addr address = await ConvertFromDtoAsync(client, response.Address, userKeys, cancellationToken).ConfigureAwait(false); - await client.EntityCache.SetAddressAsync(address, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetAddressAsync(address, cancellationToken).ConfigureAwait(false); } return address; @@ -79,7 +79,7 @@ public static async ValueTask> GetKeysAsync( AddressId addressId, CancellationToken cancellationToken) { - var addressKeys = await client.SecretCache.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false) + var addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false) ?? await GetAddressKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); return addressKeys; @@ -100,7 +100,7 @@ public static async ValueTask> GetPublicKeysAsync( string emailAddress, CancellationToken cancellationToken) { - if (!client.PublicKeyCache.TryGetPublicKeys(emailAddress, out var cachedPublicKeys)) + if (!client.Cache.PublicKeys.TryGetPublicKeys(emailAddress, out var cachedPublicKeys)) { try { @@ -121,7 +121,7 @@ public static async ValueTask> GetPublicKeysAsync( publicKeys.Add(publicKey); } - client.PublicKeyCache.SetPublicKeys(emailAddress, publicKeys); + client.Cache.PublicKeys.SetPublicKeys(emailAddress, publicKeys); cachedPublicKeys = publicKeys; } @@ -166,7 +166,9 @@ private static async ValueTask
ConvertFromDtoAsync( } else { - var passphrase = await client.SessionSecretCache.TryGetAccountKeyPassphraseAsync(keyDto.Id.Value, cancellationToken).ConfigureAwait(false); + var passphrase = await client.Cache.SessionSecrets.TryGetAccountKeyPassphraseAsync( + keyDto.Id.Value, + cancellationToken).ConfigureAwait(false); if (passphrase is null) { @@ -208,7 +210,7 @@ private static async ValueTask
ConvertFromDtoAsync( throw new ProtonApiException($"Address {dto.Id} has no primary key"); } - await client.SecretCache.SetAddressKeysAsync(dto.Id, unlockedKeys, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetAddressKeysAsync(dto.Id, unlockedKeys, cancellationToken).ConfigureAwait(false); return new Address(dto.Id, dto.Order, dto.Email, dto.Status, keys.AsReadOnly(), primaryKeyIndex.Value); } @@ -233,13 +235,13 @@ private static async ValueTask> GetAddressKeysAsync AddressId addressId, CancellationToken cancellationToken) { - var addressKeys = await client.SecretCache.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + var addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); if (addressKeys is null) { await GetAsync(client, addressId, cancellationToken).ConfigureAwait(false); - addressKeys = await client.SecretCache.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); if (addressKeys is null) { diff --git a/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs b/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs new file mode 100644 index 00000000..ad12b235 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Keys.Api; +using Proton.Sdk.Users.Api; + +namespace Proton.Sdk.Api; + +internal sealed class AccountApiClients(HttpClient httpClient) : IAccountApiClients +{ + public IKeysApiClient Keys { get; } = ApiClientFactory.Instance.CreateKeysApiClient(httpClient); + public IUsersApiClient Users { get; } = ApiClientFactory.Instance.CreateUsersApiClient(httpClient); + public IAddressesApiClient Addresses { get; } = ApiClientFactory.Instance.CreateAddressesApiClient(httpClient); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs b/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs new file mode 100644 index 00000000..7685a553 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Keys.Api; +using Proton.Sdk.Users.Api; + +namespace Proton.Sdk.Api; + +internal interface IAccountApiClients +{ + IKeysApiClient Keys { get; } + IUsersApiClient Users { get; } + IAddressesApiClient Addresses { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs b/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs index 6168bce8..c8ef0467 100644 --- a/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs @@ -1,5 +1,7 @@ -using Proton.Sdk.Authentication.Api; +using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Authentication.Api; using Proton.Sdk.Keys.Api; +using Proton.Sdk.Users.Api; namespace Proton.Sdk.Api; @@ -10,4 +12,10 @@ public IAuthenticationApiClient CreateAuthenticationApiClient(HttpClient httpCli public IKeysApiClient CreateKeysApiClient(HttpClient httpClient) => new KeysApiClient(httpClient); + + public IUsersApiClient CreateUsersApiClient(HttpClient httpClient) + => new UsersApiClient(httpClient); + + public IAddressesApiClient CreateAddressesApiClient(HttpClient httpClient) + => new AddressesApiClient(httpClient); } diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountClientCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountClientCache.cs new file mode 100644 index 00000000..76db91c1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountClientCache.cs @@ -0,0 +1,12 @@ +namespace Proton.Sdk.Caching; + +internal sealed class AccountClientCache( + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository, + ISessionSecretCache sessionSecretCache) : IAccountClientCache +{ + public IAccountEntityCache Entities { get; } = new AccountEntityCache(entityCacheRepository); + public IAccountSecretCache Secrets { get; } = new AccountSecretCache(secretCacheRepository); + public ISessionSecretCache SessionSecrets { get; } = sessionSecretCache; + public IPublicKeyCache PublicKeys { get; } = new PublicKeyCache(); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs index 80aba5fb..e26d1574 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs @@ -4,69 +4,42 @@ namespace Proton.Sdk.Caching; -internal sealed class AccountEntityCache(ICacheRepository repository) +internal sealed class AccountEntityCache(ICacheRepository repository) : IAccountEntityCache { - private static readonly string[] CurrentUserAddressTags = ["current-user-address"]; + private static readonly string[] CurrentUserAddressTags = ["user:current:address"]; private readonly ICacheRepository _repository = repository; - public async ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken) + public ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken) { - var value = JsonSerializer.Serialize(address, ProtonEntitySerializerContext.Default.Address); + var value = JsonSerializer.Serialize(address, AccountEntitySerializerContext.Default.Address); - await _repository.SetAsync(GetAddressCacheKey(address.Id), value, cancellationToken).ConfigureAwait(false); + return _repository.SetAsync(GetAddressCacheKey(address.Id), value, cancellationToken); } public async ValueTask TryGetAddressAsync(AddressId addressId, CancellationToken cancellationToken) { - var value = await _repository.TryGetAsync(addressId.Value, cancellationToken).ConfigureAwait(false); + var value = await _repository.TryGetAsync(GetAddressCacheKey(addressId), cancellationToken).ConfigureAwait(false); - return value is not null ? JsonSerializer.Deserialize(value, ProtonEntitySerializerContext.Default.Address) : null; + return value is not null ? JsonSerializer.Deserialize(value, AccountEntitySerializerContext.Default.Address) : null; } public async ValueTask SetCurrentUserAddressesAsync(IEnumerable
addresses, CancellationToken cancellationToken) { - foreach (var address in addresses) - { - var value = JsonSerializer.Serialize(address, ProtonEntitySerializerContext.Default.Address); - - await _repository.SetAsync(GetAddressCacheKey(address.Id), value, CurrentUserAddressTags, cancellationToken).ConfigureAwait(false); - } - - await _repository.MarkTagAsCompleteAsync(CurrentUserAddressTags[0], cancellationToken).ConfigureAwait(false); + await _repository.SetCompleteCollection( + addresses, + address => GetAddressCacheKey(address.Id), + CurrentUserAddressTags, + AccountEntitySerializerContext.Default.Address, + cancellationToken).ConfigureAwait(false); } public async ValueTask?> TryGetCurrentUserAddressesAsync(CancellationToken cancellationToken) { - if (!await _repository.GetTagIsCompleteAsync(CurrentUserAddressTags[0], cancellationToken).ConfigureAwait(false)) - { - return null; - } - - var values = _repository.GetByTagsAsync(CurrentUserAddressTags, cancellationToken); - - var addresses = new List
(); - - await foreach (var value in values.ConfigureAwait(false)) - { - try - { - var address = JsonSerializer.Deserialize(value, ProtonEntitySerializerContext.Default.Address); - if (address is null) - { - return null; - } - - addresses.Add(address); - } - catch - { - // There is something wrong with the cache, pretend that it did not have the information, to incite the caller to refresh it - return null; - } - } - - return addresses; + return await _repository.TryGetCompleteCollection( + CurrentUserAddressTags, + AccountEntitySerializerContext.Default.Address, + cancellationToken).ConfigureAwait(false); } private static string GetAddressCacheKey(AddressId addressId) diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs index 214bfa77..b5d8b6df 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs @@ -5,17 +5,17 @@ namespace Proton.Sdk.Caching; -internal sealed class AccountSecretCache(ICacheRepository repository) +internal sealed class AccountSecretCache(ICacheRepository repository) : IAccountSecretCache { - private const string UserKeysCacheKey = "account:user-keys"; + private const string UserKeysCacheKey = "user:current:keys"; private readonly ICacheRepository _repository = repository; - public async ValueTask SetUserKeysAsync(IEnumerable unlockedKeys, CancellationToken cancellationToken) + public ValueTask SetUserKeysAsync(IEnumerable unlockedKeys, CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(unlockedKeys, ProtonEntitySerializerContext.Default.IEnumerablePgpPrivateKey); + var serializedValue = JsonSerializer.Serialize(unlockedKeys, SecretsSerializerContext.Default.IEnumerablePgpPrivateKey); - await _repository.SetAsync(UserKeysCacheKey, serializedValue, cancellationToken).ConfigureAwait(false); + return _repository.SetAsync(UserKeysCacheKey, serializedValue, cancellationToken); } public async ValueTask?> TryGetUserKeysAsync(CancellationToken cancellationToken) @@ -23,15 +23,15 @@ public async ValueTask SetUserKeysAsync(IEnumerable unlockedKeys, var serializedValue = await _repository.TryGetAsync(UserKeysCacheKey, cancellationToken).ConfigureAwait(false); return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, ProtonEntitySerializerContext.Default.PgpPrivateKeyArray) + ? JsonSerializer.Deserialize(serializedValue, SecretsSerializerContext.Default.PgpPrivateKeyArray) : null; } - public async ValueTask SetAddressKeysAsync(AddressId addressId, IEnumerable unlockedKeys, CancellationToken cancellationToken) + public ValueTask SetAddressKeysAsync(AddressId addressId, IEnumerable unlockedKeys, CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(unlockedKeys, ProtonEntitySerializerContext.Default.IEnumerablePgpPrivateKey); + var serializedValue = JsonSerializer.Serialize(unlockedKeys, SecretsSerializerContext.Default.IEnumerablePgpPrivateKey); - await _repository.SetAsync(GetAddressKeysCacheKey(addressId), serializedValue, cancellationToken).ConfigureAwait(false); + return _repository.SetAsync(GetAddressKeysCacheKey(addressId), serializedValue, cancellationToken); } public async ValueTask?> TryGetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) @@ -39,12 +39,12 @@ public async ValueTask SetAddressKeysAsync(AddressId addressId, IEnumerable(serializedValue, ProtonEntitySerializerContext.Default.PgpPrivateKeyArray) + ? JsonSerializer.Deserialize(serializedValue, SecretsSerializerContext.Default.PgpPrivateKeyArray) : null; } private static string GetAddressKeysCacheKey(AddressId addressId) { - return $"account:address:{addressId}:keys"; + return $"address:{addressId}:keys"; } } diff --git a/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs index 57875cdb..a899276c 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs @@ -1,14 +1,76 @@ -namespace Proton.Sdk.Caching; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace Proton.Sdk.Caching; internal static class CacheRepositoryExtensions { - private const string CompleteTagCacheKeyFormat = "cache:complete-tag:{0}"; + private const string CompleteTagCacheKeyFormat = "cache:tag:{0}:complete"; public static ValueTask SetAsync(this ICacheRepository repository, string key, string value, CancellationToken cancellationToken) { return repository.SetAsync(key, value, [], cancellationToken); } + public static async ValueTask SetCompleteCollection( + this ICacheRepository repository, + IEnumerable values, + Func getCacheKeyFunction, + IReadOnlyList tags, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken) + { + foreach (var value in values) + { + var serializedValue = JsonSerializer.Serialize(value, jsonTypeInfo); + + var cacheKey = getCacheKeyFunction.Invoke(value); + + await repository.SetAsync(cacheKey, serializedValue, tags, cancellationToken).ConfigureAwait(false); + } + + await repository.MarkTagAsCompleteAsync(tags[0], cancellationToken).ConfigureAwait(false); + } + + public static async ValueTask?> TryGetCompleteCollection( + this ICacheRepository repository, + IReadOnlyList tags, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken) + { + if (!await repository.GetTagIsCompleteAsync(tags[0], cancellationToken).ConfigureAwait(false)) + { + return null; + } + + var entries = repository.GetByTagsAsync(tags, cancellationToken); + + var deserializedValues = new List(); + + await foreach (var entry in entries.ConfigureAwait(false)) + { + try + { + var deserializedValue = JsonSerializer.Deserialize(entry.Value, jsonTypeInfo); + if (deserializedValue is null) + { + return null; + } + + deserializedValues.Add(deserializedValue); + } + catch + { + // There is something wrong with the cache, remove the problematic entry, and return null to incite the caller to refresh the collection + await repository.RemoveAsync(entry.Key, cancellationToken).ConfigureAwait(false); + + return null; + } + } + + return deserializedValues; + } + /// /// Creates a cache entry that serves as a hint that querying by the given tag will return complete information. /// @@ -16,24 +78,17 @@ public static ValueTask SetAsync(this ICacheRepository repository, string key, s /// This marking indicates that the results of a query by the given tag reflect the complete "truth" related to that tag at a point in time. /// Consequently, if that marking is present and the query by that tag returns an empty set, then that emptiness is the information, rather than a lack of information in cache. /// - public static async ValueTask MarkTagAsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) + private static async ValueTask MarkTagAsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) { var cacheKey = string.Format(CompleteTagCacheKeyFormat, tag); await repository.SetAsync(cacheKey, string.Empty, cancellationToken).ConfigureAwait(false); } - public static async ValueTask GetTagIsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) + private static async ValueTask GetTagIsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) { var cacheKey = string.Format(CompleteTagCacheKeyFormat, tag); return await repository.TryGetAsync(cacheKey, cancellationToken).ConfigureAwait(false) is not null; } - - public static async ValueTask UnmarkTagAsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) - { - var cacheKey = string.Format(CompleteTagCacheKeyFormat, tag); - - await repository.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); - } } diff --git a/cs/sdk/src/Proton.Sdk/Caching/IAccountClientCache.cs b/cs/sdk/src/Proton.Sdk/Caching/IAccountClientCache.cs new file mode 100644 index 00000000..d72453a0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/IAccountClientCache.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Caching; + +internal interface IAccountClientCache +{ + IAccountEntityCache Entities { get; } + IAccountSecretCache Secrets { get; } + ISessionSecretCache SessionSecrets { get; } + IPublicKeyCache PublicKeys { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/IAccountEntityCache.cs b/cs/sdk/src/Proton.Sdk/Caching/IAccountEntityCache.cs new file mode 100644 index 00000000..3eae8f26 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/IAccountEntityCache.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Caching; + +internal interface IAccountEntityCache +{ + ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken); + ValueTask TryGetAddressAsync(AddressId addressId, CancellationToken cancellationToken); + + ValueTask SetCurrentUserAddressesAsync(IEnumerable
addresses, CancellationToken cancellationToken); + ValueTask?> TryGetCurrentUserAddressesAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/IAccountSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/IAccountSecretCache.cs new file mode 100644 index 00000000..2867e61e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/IAccountSecretCache.cs @@ -0,0 +1,13 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Caching; + +internal interface IAccountSecretCache +{ + ValueTask SetUserKeysAsync(IEnumerable unlockedKeys, CancellationToken cancellationToken); + ValueTask?> TryGetUserKeysAsync(CancellationToken cancellationToken); + + ValueTask SetAddressKeysAsync(AddressId addressId, IEnumerable unlockedKeys, CancellationToken cancellationToken); + ValueTask?> TryGetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs index 98ece901..924b798a 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs @@ -12,5 +12,5 @@ public interface ICacheRepository : IAsyncDisposable ValueTask TryGetAsync(string key, CancellationToken cancellationToken); - IAsyncEnumerable GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken); + IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Sdk/Caching/IPublicKeyCache.cs b/cs/sdk/src/Proton.Sdk/Caching/IPublicKeyCache.cs new file mode 100644 index 00000000..ba021e8f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/IPublicKeyCache.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Caching; + +internal interface IPublicKeyCache +{ + void SetPublicKeys(string emailAddress, IReadOnlyList publicKeys); + bool TryGetPublicKeys(string emailAddress, [MaybeNullWhen(false)] out IReadOnlyList publicKeys); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/ISessionSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/ISessionSecretCache.cs new file mode 100644 index 00000000..37030462 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/ISessionSecretCache.cs @@ -0,0 +1,7 @@ +namespace Proton.Sdk.Caching; + +internal interface ISessionSecretCache +{ + ValueTask SetAccountKeyPassphraseAsync(string keyId, ReadOnlyMemory passphrase, CancellationToken cancellationToken); + ValueTask?> TryGetAccountKeyPassphraseAsync(string keyId, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs index 335ad565..0229fcef 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs @@ -29,9 +29,9 @@ public ValueTask ClearAsync() return ValueTask.FromResult(default(string?)); } - public IAsyncEnumerable GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) + public IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) { - return AsyncEnumerable.Empty(); + return AsyncEnumerable.Empty<(string, string)>(); } public ValueTask DisposeAsync() diff --git a/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs b/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs index a6cb0150..30c7a015 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs @@ -5,6 +5,7 @@ namespace Proton.Sdk.Caching; internal sealed class PublicKeyCache + : IPublicKeyCache { public const int NumberOfMinutesBeforeExpiration = 30; diff --git a/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs index 1c730120..8af934c9 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs @@ -1,16 +1,16 @@ namespace Proton.Sdk.Caching; -internal sealed class SessionSecretCache(ICacheRepository repository) +internal sealed class SessionSecretCache(ICacheRepository repository) : ISessionSecretCache { private readonly ICacheRepository _repository = repository; - public async ValueTask SetAccountKeyPassphraseAsync(string keyId, ReadOnlyMemory passphrase, CancellationToken cancellationToken) + public ValueTask SetAccountKeyPassphraseAsync(string keyId, ReadOnlyMemory passphrase, CancellationToken cancellationToken) { var cacheKey = GetAccountPassphraseCacheKey(keyId); var serializedValue = Convert.ToBase64String(passphrase.Span); - await _repository.SetAsync(cacheKey, serializedValue, cancellationToken).ConfigureAwait(false); + return _repository.SetAsync(cacheKey, serializedValue, cancellationToken); } public async ValueTask?> TryGetAccountKeyPassphraseAsync(string keyId, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index afd2db52..2d3ad277 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -102,7 +102,7 @@ public ValueTask ClearAsync() } } - IAsyncEnumerable ICacheRepository.GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) + IAsyncEnumerable<(string Key, string Value)> ICacheRepository.GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) { return GetByTags(tags).ToAsyncEnumerable(); } @@ -209,7 +209,7 @@ public void Clear() return reader.Read() ? reader.GetFieldValue("Value") : null; } - public IEnumerable GetByTags(IEnumerable tags) + public IEnumerable<(string Key, string Value)> GetByTags(IEnumerable tags) { using var connection = new SqliteConnection(_connection.ConnectionString); @@ -229,7 +229,7 @@ public IEnumerable GetByTags(IEnumerable tags) command.CommandText = $""" - SELECT Value + SELECT Key, Value FROM Entries WHERE Key IN ( SELECT t.Key @@ -246,7 +246,7 @@ HAVING COUNT(DISTINCT t.Tag) = @tagCount while (reader.Read()) { - yield return reader.GetString(0); + yield return (reader.GetString(0), reader.GetString(1)); } } diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index d457d360..0b007117 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -19,11 +19,9 @@ - - + + - - diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs index d29ee091..3ae7c1ea 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Sdk.Addresses; +using Proton.Sdk.Api; using Proton.Sdk.Caching; namespace Proton.Sdk; @@ -8,25 +9,23 @@ namespace Proton.Sdk; public sealed class ProtonAccountClient { public ProtonAccountClient(ProtonApiSession session) - : this(new AccountApiClients(session.GetHttpClient()), session.ClientConfiguration, session.SecretCache) + : this( + new AccountApiClients(session.GetHttpClient()), + new AccountClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository, session.SecretCache), + session.ClientConfiguration.LoggerFactory.CreateLogger()) { } - internal ProtonAccountClient(AccountApiClients apiClients, ProtonClientConfiguration configuration, SessionSecretCache sessionSecretCache) + internal ProtonAccountClient(IAccountApiClients apiClients, IAccountClientCache cache, ILogger logger) { Api = apiClients; - Logger = configuration.LoggerFactory.CreateLogger(); - SessionSecretCache = sessionSecretCache; - EntityCache = new AccountEntityCache(configuration.EntityCacheRepository); - SecretCache = new AccountSecretCache(configuration.SecretCacheRepository); + Cache = cache; + Logger = logger; } - internal AccountApiClients Api { get; } + internal IAccountApiClients Api { get; } - internal AccountEntityCache EntityCache { get; } - internal AccountSecretCache SecretCache { get; } - internal SessionSecretCache SessionSecretCache { get; } - internal PublicKeyCache PublicKeyCache { get; } = new(); + internal IAccountClientCache Cache { get; } internal ILogger Logger { get; } @@ -47,7 +46,7 @@ public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationT internal async ValueTask> GetUserKeysAsync(CancellationToken cancellationToken) { - var userKeys = await SecretCache.TryGetUserKeysAsync(cancellationToken).ConfigureAwait(false); + var userKeys = await Cache.Secrets.TryGetUserKeysAsync(cancellationToken).ConfigureAwait(false); if (userKeys is null) { @@ -62,7 +61,7 @@ internal async ValueTask> GetUserKeysAsync(Cancella continue; } - var passphrase = await SessionSecretCache.TryGetAccountKeyPassphraseAsync(userKey.Id.Value, cancellationToken).ConfigureAwait(false); + var passphrase = await Cache.SessionSecrets.TryGetAccountKeyPassphraseAsync(userKey.Id.Value, cancellationToken).ConfigureAwait(false); if (passphrase is null) { @@ -80,7 +79,7 @@ internal async ValueTask> GetUserKeysAsync(Cancella throw new ProtonApiException("No active user key was found."); } - await SecretCache.SetUserKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); + await Cache.Secrets.SetUserKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); userKeys = unlockedKeys; } diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index 33f2052e..dafbd9be 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -77,7 +77,7 @@ public event Action? Ended private IAuthenticationApiClient AuthenticationApi => _authenticationApi ??= ApiClientFactory.Instance.CreateAuthenticationApiClient(_httpClient, ClientConfiguration.RefreshRedirectUri); - private IKeysApiClient KeysApi => _keysApi ??= new KeysApiClient(_httpClient); + private IKeysApiClient KeysApi => _keysApi ??= ApiClientFactory.Instance.CreateKeysApiClient(_httpClient); public static ValueTask BeginAsync(string username, ReadOnlyMemory password, string appVersion, CancellationToken cancellationToken) { diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ProtonEntitySerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitySerializerContext.cs similarity index 70% rename from cs/sdk/src/Proton.Sdk/Serialization/ProtonEntitySerializerContext.cs rename to cs/sdk/src/Proton.Sdk/Serialization/AccountEntitySerializerContext.cs index dea96c55..42b723a9 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ProtonEntitySerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitySerializerContext.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Cryptography.Pgp; using Proton.Sdk.Addresses; using Proton.Sdk.Addresses.Api; using Proton.Sdk.Authentication; @@ -11,7 +10,6 @@ namespace Proton.Sdk.Serialization; [JsonSourceGenerationOptions( Converters = [ - typeof(PgpPrivateKeyJsonConverter), typeof(StrongIdJsonConverter), typeof(StrongIdJsonConverter), typeof(StrongIdJsonConverter), @@ -20,6 +18,4 @@ namespace Proton.Sdk.Serialization; typeof(StrongIdJsonConverter), ])] [JsonSerializable(typeof(Address))] -[JsonSerializable(typeof(IEnumerable))] -[JsonSerializable(typeof(PgpPrivateKey[]))] -internal sealed partial class ProtonEntitySerializerContext : JsonSerializerContext; +internal sealed partial class AccountEntitySerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs new file mode 100644 index 00000000..3eeda16b --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Serialization; + +[JsonSourceGenerationOptions( + Converters = + [ + typeof(PgpPrivateKeyJsonConverter), + ])] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(PgpPrivateKey[]))] +internal sealed partial class SecretsSerializerContext : JsonSerializerContext; From 81aecd8a5fd81c80c3c79a29fd33ad17bd60cef3 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 4 Mar 2025 08:10:22 +0100 Subject: [PATCH 047/791] use new events endpoint --- js/sdk/src/internal/apiService/driveTypes.ts | 2736 +++++++++++------ js/sdk/src/internal/events/apiService.ts | 31 +- js/sdk/src/internal/events/cache.test.ts | 12 +- js/sdk/src/internal/events/cache.ts | 25 +- .../src/internal/events/coreEventManager.ts | 6 +- js/sdk/src/internal/events/index.ts | 5 +- .../src/internal/events/volumeEventManager.ts | 10 +- js/sdk/src/internal/nodes/apiService.ts | 8 +- 8 files changed, 1805 insertions(+), 1028 deletions(-) diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 1eaeb605..1dd2bf72 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -13,6 +13,7 @@ export interface paths { }; get?: never; put?: never; + /** Add photos to an album */ post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple"]; delete?: never; options?: never; @@ -61,33 +62,52 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/albums/{linkID}/children": { + "/drive/photos/volumes/{volumeID}/albums/{linkID}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List photos in album */ - get: operations["get_drive-photos-volumes-{volumeID}-albums-{linkID}-children"]; - put?: never; + get?: never; + /** Update an album */ + put: operations["put_drive-photos-volumes-{volumeID}-albums-{linkID}"]; post?: never; + /** Delete an album */ + delete: operations["delete_drive-photos-volumes-{volumeID}-albums-{linkID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/migrate-legacy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get status of migration from legacy photo share on a regular volume into a new Photo Volume */ + get: operations["get_drive-photos-migrate-legacy"]; + put?: never; + /** Start migration from legacy photo share on a regular volume into a new Photo Volume */ + post: operations["post_drive-photos-migrate-legacy"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/albums/{linkID}": { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/children": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Update an album */ - put: operations["put_drive-photos-volumes-{volumeID}-albums-{linkID}"]; + /** List photos in album */ + get: operations["get_drive-photos-volumes-{volumeID}-albums-{linkID}-children"]; + put?: never; post?: never; delete?: never; options?: never; @@ -95,7 +115,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/urls/{token}/bookmark": { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple": { parameters: { query?: never; header?: never; @@ -104,33 +124,21 @@ export interface paths { }; get?: never; put?: never; - /** - * Create ShareURL Bookmark - * @description It creates a bookmark for the user in an already existing ShareURL. The bookmark would be stored for the current user if the password is encrypted with his/her addressKey - */ - post: operations["post_drive-v2-urls-{token}-bookmark"]; - /** - * Delete ShareURL Bookmark - * @description It soft deletes the bookmark share url, that would be GC later. The user should be the owner of the bookmark. - */ - delete: operations["delete_drive-v2-urls-{token}-bookmark"]; + post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shared-bookmarks": { + "/drive/photos/albums/shared-with-me": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List all Bookmarks - * @description This endpoint would only show active bookmarks from the user doing the request - */ - get: operations["get_drive-v2-shared-bookmarks"]; + get: operations["get_drive-photos-albums-shared-with-me"]; put?: never; post?: never; delete?: never; @@ -139,34 +147,44 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/checklist/get-started": { + "/drive/v2/urls/{token}/bookmark": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get onboarding checklist */ - get: operations["get_drive-v2-checklist-get-started"]; + get?: never; put?: never; - post?: never; - delete?: never; + /** + * Create ShareURL Bookmark + * @description It creates a bookmark for the user in an already existing ShareURL. The bookmark would be stored for the current user if the password is encrypted with his/her addressKey + */ + post: operations["post_drive-v2-urls-{token}-bookmark"]; + /** + * Delete ShareURL Bookmark + * @description It soft deletes the bookmark share url, that would be GC later. The user should be the owner of the bookmark. + */ + delete: operations["delete_drive-v2-urls-{token}-bookmark"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/checklist/get-started/seen-completed-list": { + "/drive/v2/shared-bookmarks": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List all Bookmarks + * @description This endpoint would only show active bookmarks from the user doing the request + */ + get: operations["get_drive-v2-shared-bookmarks"]; put?: never; - /** Mark completed checklist as seen */ - post: operations["post_drive-v2-checklist-get-started-seen-completed-list"]; + post?: never; delete?: never; options?: never; head?: never; @@ -239,7 +257,7 @@ export interface paths { get?: never; put?: never; /** - * Create document. + * Create document * @description Create a new proton document. */ post: operations["post_drive-shares-{shareID}-documents"]; @@ -270,7 +288,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/events/{eventID}": { + "/drive/volumes/{volumeID}/events/latest": { parameters: { query?: never; header?: never; @@ -278,11 +296,10 @@ export interface paths { cookie?: never; }; /** - * Get share events - * @deprecated - * @description Get new events for given share since eventID. Deprecated: Use events per volume instead. + * Get latest volume event + * @description Get latest EventID for a given volume. */ - get: operations["get_drive-shares-{shareID}-events-{eventID}"]; + get: operations["get_drive-volumes-{volumeID}-events-latest"]; put?: never; post?: never; delete?: never; @@ -291,7 +308,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/events/latest": { + "/drive/shares/{shareID}/events/{eventID}": { parameters: { query?: never; header?: never; @@ -299,10 +316,11 @@ export interface paths { cookie?: never; }; /** - * Get latest volume event - * @description Get latest EventID for a given volume. + * List share events + * @deprecated + * @description Get new events for given share since eventID. Deprecated: Use events per volume instead. */ - get: operations["get_drive-volumes-{volumeID}-events-latest"]; + get: operations["get_drive-shares-{shareID}-events-{eventID}"]; put?: never; post?: never; delete?: never; @@ -319,7 +337,7 @@ export interface paths { cookie?: never; }; /** - * Get volume events + * List volume events * @description Get new events for given volume since eventID. */ get: operations["get_drive-volumes-{volumeID}-events-{eventID}"]; @@ -331,15 +349,19 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/links/{linkID}/path": { + "/drive/v2/volumes/{volumeID}/events/{eventID}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Fetch link parentIDs by token */ - get: operations["get_drive-urls-{token}-links-{linkID}-path"]; + /** + * List volume events (v2) + * @description Get new events for given volume since eventID. + * RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0054-light-events/ + */ + get: operations["get_drive-v2-volumes-{volumeID}-events-{eventID}"]; put?: never; post?: never; delete?: never; @@ -448,26 +470,6 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/folders/{linkID}/trash_multiple": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Trash children - * @description Send children to trash - */ - post: operations["post_drive-v2-volumes-{volumeID}-folders-{linkID}-trash_multiple"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/drive/shares/{shareID}/folders/{linkID}/trash_multiple": { parameters: { query?: never; @@ -589,6 +591,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/v2/volumes/{volumeID}/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete multiple (v2) + * @description Permanently delete links, skipping trash. Can only be done for draft links. + */ + post: operations["post_drive-v2-volumes-{volumeID}-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/shares/{shareID}/links/fetch_metadata": { parameters: { query?: never; @@ -643,6 +665,24 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/sanitization/mhk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List folders with missing hash keys */ + get: operations["get_drive-sanitization-mhk"]; + put?: never; + /** List folders with missing hash keys */ + post: operations["post_drive-sanitization-mhk"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/v2/volumes/{volumeID}/links": { parameters: { query?: never; @@ -685,7 +725,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeId}/links/{linkID}/rename": { + "/drive/v2/volumes/{volumeID}/links/{linkID}/rename": { parameters: { query?: never; header?: never; @@ -700,7 +740,7 @@ export interface paths { * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. */ - put: operations["put_drive-v2-volumes-{volumeId}-links-{linkID}-rename"]; + put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-rename"]; post?: never; delete?: never; options?: never; @@ -1136,6 +1176,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/v2/volumes/{volumeID}/trash_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Trash multiple (v2) + * @description Send multiple links to the trash + */ + post: operations["post_drive-v2-volumes-{volumeID}-trash_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/me/active": { parameters: { query?: never; @@ -1173,6 +1233,23 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/v2/checklist/get-started": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get onboarding checklist */ + get: operations["get_drive-v2-checklist-get-started"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/v2/onboarding": { parameters: { query?: never; @@ -1189,6 +1266,23 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/v2/checklist/get-started/seen-completed-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark completed checklist as seen */ + post: operations["post_drive-v2-checklist-get-started-seen-completed-list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/entitlements": { parameters: { query?: never; @@ -1209,20 +1303,17 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/photos": { + "/drive/photos/volumes/{volumeID}/links/{linkID}/tags": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List photos sorted by capture time. - * @description When paginating to secondary pages, the PreviousPageLastLinkID must be provided. - */ - get: operations["get_drive-volumes-{volumeID}-photos"]; + get?: never; put?: never; - post?: never; + /** Add tags to existing photo */ + post: operations["post_drive-photos-volumes-{volumeID}-links-{linkID}-tags"]; delete?: never; options?: never; head?: never; @@ -1238,7 +1329,7 @@ export interface paths { }; get?: never; put?: never; - /** Create a photo share */ + /** Create photo share */ post: operations["post_drive-volumes-{volumeID}-photos-share"]; delete?: never; options?: never; @@ -1257,7 +1348,7 @@ export interface paths { put?: never; post?: never; /** - * Delete an empty photo share. + * Delete empty photo share * @description Can only delete Photo Shares that are empty. */ delete: operations["delete_drive-volumes-{volumeID}-photos-share-{shareID}"]; @@ -1275,7 +1366,7 @@ export interface paths { }; get?: never; put?: never; - /** Find Duplicates */ + /** Find duplicates */ post: operations["post_drive-volumes-{volumeID}-photos-duplicates"]; delete?: never; options?: never; @@ -1283,6 +1374,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/volumes/{volumeID}/photos": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List photos sorted by capture time + * @description When paginating to secondary pages, the PreviousPageLastLinkID must be provided. + */ + get: operations["get_drive-volumes-{volumeID}-photos"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/urls/{token}/files/{linkID}/checkAvailableHashes": { parameters: { query?: never; @@ -1438,6 +1549,23 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/urls/{token}/links/{linkID}/path": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fetch link parentIDs by token */ + get: operations["get_drive-urls-{token}-links-{linkID}-path"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/urls/{token}/links/{linkID}/rename": { parameters: { query?: never; @@ -1518,14 +1646,19 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/my-files": { + "/drive/shares/{shareID}/map": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_drive-v2-shares-my-files"]; + /** + * Search map + * @deprecated + * @description Used only for search on web that does not scale. Should be replaced by better version in the future. + */ + get: operations["get_drive-shares-{shareID}-map"]; put?: never; post?: never; delete?: never; @@ -1534,18 +1667,15 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/links/{linkID}/context": { + "/drive/v2/shares/my-files": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get context share - * @description Gets the highest share, meaning closest to the root, for a link - */ - get: operations["get_drive-volumes-{volumeID}-links-{linkID}-context"]; + /** Bootstrap my files */ + get: operations["get_drive-v2-shares-my-files"]; put?: never; post?: never; delete?: never; @@ -1554,28 +1684,30 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/shares": { + "/drive/shares/{shareID}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get share bootstrap */ + get: operations["get_drive-shares-{shareID}"]; put?: never; + post?: never; /** - * Create a share - * @description Cannot create two shares on the same link - * Throws 422 with code 2500 in case a share already exists + * Delete a standard share by ID + * @description Only standard shares (type 2) can be deleted this way. + * Will throw 422 with body code 2005 if Members, ShareURLs or Invitations are still attached to the share. + * Use Force=1 query param to delete the share together with any attached entities. */ - post: operations["post_drive-volumes-{volumeID}-shares"]; - delete?: never; + delete: operations["delete_drive-shares-{shareID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares": { + "/drive/volumes/{volumeID}/links/{linkID}/context": { parameters: { query?: never; header?: never; @@ -1583,14 +1715,10 @@ export interface paths { cookie?: never; }; /** - * List shares - * @description List shares available to current user. - * - * The results can be restricted to a single address by providing the AddressID query parameter. - * By default, only active shares are shown. - * Passing the ShowAll=1 query parameter will show locked and disabled shares also. + * Get context share + * @description Gets the highest share, meaning closest to the root, for a link */ - get: operations["get_drive-shares"]; + get: operations["get_drive-volumes-{volumeID}-links-{linkID}-context"]; put?: never; post?: never; delete?: never; @@ -1599,28 +1727,28 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/owner": { + "/drive/sanitization/asv": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** List top level ShareIDs for the user Volume in AUTO_RESTORE state. */ + get: operations["get_drive-sanitization-asv"]; put?: never; /** - * Update ownership of a share - * @description Replace the signature and related membership of the share. - * This allows users to change the associated address & key they use for a share, so that they can get rid of it. + * Log Missing Keys error for restore process + * @description Log a Restore Procedure error when Web detects that Keys are missing. */ - post: operations["post_drive-shares-{shareID}-owner"]; + post: operations["post_drive-sanitization-asv"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/map": { + "/drive/shares": { parameters: { query?: never; header?: never; @@ -1628,11 +1756,14 @@ export interface paths { cookie?: never; }; /** - * Search map - * @deprecated - * @description Used only for search on web that does not scale. Should be replaced by better version in the future. + * List shares + * @description List shares available to current user. + * + * The results can be restricted to a single address by providing the AddressID query parameter. + * By default, only active shares are shown. + * Passing the ShowAll=1 query parameter will show locked and disabled shares also. */ - get: operations["get_drive-shares-{shareID}-map"]; + get: operations["get_drive-shares"]; put?: never; post?: never; delete?: never; @@ -1641,24 +1772,22 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}": { + "/drive/shares/{shareID}/owner": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get share bootstrap */ - get: operations["get_drive-shares-{shareID}"]; + get?: never; put?: never; - post?: never; /** - * Delete a share by ID - * @description Only standard shares (type 2) can be deleted this way. - * Will throw 422 with body code 2005 if Members, ShareURLs or Invitations are still attached to the share. - * Use Force=1 query param to delete the share together with any attached entities. + * Update ownership of a share + * @description Replace the signature and related membership of the share. + * This allows users to change the associated address & key they use for a share, so that they can get rid of it. */ - delete: operations["delete_drive-shares-{shareID}"]; + post: operations["post_drive-shares-{shareID}-owner"]; + delete?: never; options?: never; head?: never; patch?: never; @@ -1804,26 +1933,6 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/security": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Performs virus checks on hashes of files received in the request payload. - * @description https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ - */ - post: operations["post_drive-urls-{token}-security"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/drive/shares/{shareID}/urls": { parameters: { query?: never; @@ -1881,6 +1990,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/volumes/{volumeID}/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a standard share + * @description Cannot create two shares on the same link. Throws 422 with code 2500 in case a share already exists. + */ + post: operations["post_drive-volumes-{volumeID}-shares"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/v2/volumes/{volumeID}/shares": { parameters: { query?: never; @@ -2230,7 +2359,11 @@ export interface paths { }; get?: never; put?: never; - /** Performs virus checks on hashes of files received in the request payload. */ + /** + * Scan for malware (direct sharing) + * @description Performs virus checks on hashes of files received in the request payload. + * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ + */ post: operations["post_drive-v2-shares-{shareID}-security"]; delete?: never; options?: never; @@ -2238,6 +2371,27 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/urls/{token}/security": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Scan for malware (public share URL) + * @description Performs virus checks on hashes of files received in the request payload. + * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ + */ + post: operations["post_drive-urls-{token}-security"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/volumes/{volumeID}/thumbnails": { parameters: { query?: never; @@ -2262,14 +2416,11 @@ export interface paths { path?: never; cookie?: never; }; - /** - * Get user settings. - * @description Get the user settings for Drive. - */ + /** Get user settings */ get: operations["get_drive-me-settings"]; /** - * Update user settings. - * @description Update the user settings for Drive. At least one setting must be provided. + * Update user settings + * @description At least one setting must be provided. */ put: operations["put_drive-me-settings"]; post?: never; @@ -2425,6 +2576,32 @@ export interface components { */ Code: 1000; }; + SuccessfulResponse: { + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetMigrationStatusResponseDto: { + OldVolumeID: components["schemas"]["Id2"]; + NewVolumeID?: components["schemas"]["Id2"] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ProcessingResponse: { + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; ListAlbumsResponseDto: { Albums: components["schemas"]["AlbumResponseDto"][]; AnchorID?: string | null; @@ -2439,13 +2616,21 @@ export interface components { ListPhotosAlbumQueryParameters: { /** @default null */ AnchorID: string | null; - Sort?: components["schemas"]["PhotosAlbumListingFilter"]; + /** + * @default Captured + * @enum {string} + */ + Sort: "Captured" | "Added"; /** @default true */ Desc: boolean; - OrderedByCaptureTime: boolean; + /** @default null */ + Tag: components["schemas"]["TagType"] | null; }; - /** @enum {string} */ - PhotosAlbumListingFilter: "Captured" | "Added"; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Favorites
1Screenshots
2Videos
3LivePhotos
4MotionPhotos
5Selfies
6Portraits
7Bursts
8Panoramas
9Raw
+ * @enum {integer} + */ + TagType: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; ListPhotosAlbumResponseDto: { Photos: components["schemas"]["ListPhotosAlbumItemResponseDto"][]; AnchorID?: string | null; @@ -2457,13 +2642,23 @@ export interface components { */ Code: 1000; }; - UpdateAlbumRequestDto: { - CoverLinkID?: components["schemas"]["Id"] | null; - Link?: components["schemas"]["AlbumLinkUpdateDto"] | null; + AcceptedResponse: { + /** + * ProtonResponseCode + * @example 1002 + * @enum {integer} + */ + Code: 1002; + }; + RemovePhotosFromAlbumRequestDto: { + LinkIDs: components["schemas"]["Id"][]; }; /** @description An encrypted ID */ Id: string; - SuccessfulResponse: { + SharedWithMeResponseDto: { + Albums: components["schemas"]["AlbumResponseDto"][]; + AnchorID?: string | null; + More: boolean; /** * ProtonResponseCode * @example 1000 @@ -2471,6 +2666,10 @@ export interface components { */ Code: 1000; }; + UpdateAlbumRequestDto: { + CoverLinkID?: components["schemas"]["Id"] | null; + Link?: components["schemas"]["AlbumLinkUpdateDto"] | null; + }; CreateBookmarkShareURLRequestDto: { BookmarkShareURL: components["schemas"]["BookmarkShareURLRequestDto"]; }; @@ -2492,31 +2691,6 @@ export interface components { */ Code: 1000; }; - ChecklistResponseDto: { - /** @description Array of completed checklist items */ - Items: string[]; - CreatedAt?: number | null; - ExpiresAt?: number | null; - /** @description User already has reward quota */ - UserWasRewarded: boolean; - /** @description Client has displayed completed checklist */ - Seen: boolean; - /** @description Client has completed checklist */ - Completed: boolean; - /** - * Format: float - * @description Amount of storage GB completion reward - */ - RewardInGB: number; - /** @description Checklist should be visible to user */ - Visible: boolean; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; CreateDeviceRequestDto: { Device: components["schemas"]["DeviceDataDto"]; Share: components["schemas"]["ShareDataDto2"]; @@ -2589,7 +2763,7 @@ export interface components { Code: 1000; }; LatestEventIDResponseDto: { - EventID: components["schemas"]["Id"]; + EventID: components["schemas"]["Id2"]; /** * ProtonResponseCode * @example 1000 @@ -2618,8 +2792,14 @@ export interface components { */ Code: 1000; }; - ParentEncryptedLinkIDsResponseDto: { - ParentLinkIDs: string[]; + ListEventsV2ResponseDto: { + Events: components["schemas"]["EventV2ResponseDto"][]; + /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ + EventID: string; + /** @description true if there is more to pull, i.e. there are more events than returned in one call */ + More: boolean; + /** @description true if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync */ + Refresh: boolean; /** * ProtonResponseCode * @example 1000 @@ -2701,7 +2881,7 @@ export interface components { SignatureEmail: string | null; }; CopyLinkResponseDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id2"]; /** * ProtonResponseCode * @example 1000 @@ -2777,7 +2957,7 @@ export interface components { NodeKey: components["schemas"]["PGPPrivateKey"]; }; ListChildrenResponseDto: { - LinkIDs: components["schemas"]["Id"][]; + LinkIDs: components["schemas"]["Id2"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -2827,6 +3007,15 @@ export interface components { */ Code: 1000; }; + ListMissingHashKeyResponseDto: { + NodesWithMissingNodeHashKey: components["schemas"]["ListMissingHashKeyItemDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; LoadLinkDetailsResponseDto: { Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"])[]; /** @@ -2915,6 +3104,9 @@ export interface components { */ MIMEType: string | null; }; + UpdateMissingHashKeyRequestDto: { + NodesWithMissingNodeHashKey: components["schemas"]["UpdateMissingHashKeyItemDto"][]; + }; MoveLinkRequestDto2: { /** @description Name, reusing same session key as previously. */ Name: string; @@ -2928,9 +3120,8 @@ export interface components { /** * Format: email * @description Signature email address used for signing name - * @default null */ - NameSignatureEmail: string | null; + NameSignatureEmail: string; /** * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] * @default null @@ -3036,6 +3227,23 @@ export interface components { */ IntendedUploadSize: number | null; }; + GetRevisionQueryParameters: { + /** + * @description Number of blocks + * @default null + */ + PageSize: number | null; + /** + * @description Block index from which to fetch block list + * @default null + */ + FromBlockIndex: number | null; + /** + * @description Do not generate download URLs for blocks + * @default false + */ + NoBlockUrls: boolean; + }; RestoreRevisionAcceptedResponse: { /** * ProtonResponseCode @@ -3045,8 +3253,8 @@ export interface components { Code: 1002; }; VerificationData: { - VerificationCode: components["schemas"]["BinaryString"]; - ContentKeyPacket: components["schemas"]["BinaryString"]; + VerificationCode: components["schemas"]["BinaryString2"]; + ContentKeyPacket: components["schemas"]["BinaryString2"]; /** * ProtonResponseCode * @example 1000 @@ -3106,9 +3314,24 @@ export interface components { /** @default null */ RevisionID: components["schemas"]["Id"] | null; }; - OnboardingResponseDto: { - /** @description `true` if the user has pending/rejected invitations or user_registered external invitation */ - HasPendingInvitations: boolean; + ChecklistResponseDto: { + /** @description Array of completed checklist items */ + Items: string[]; + CreatedAt?: number | null; + ExpiresAt?: number | null; + /** @description User already has reward quota */ + UserWasRewarded: boolean; + /** @description Client has displayed completed checklist */ + Seen: boolean; + /** @description Client has completed checklist */ + Completed: boolean; + /** + * Format: float + * @description Amount of storage GB completion reward + */ + RewardInGB: number; + /** @description Checklist should be visible to user */ + Visible: boolean; /** * ProtonResponseCode * @example 1000 @@ -3116,8 +3339,9 @@ export interface components { */ Code: 1000; }; - GetEntitlementResponseDto: { - Entitlements: components["schemas"]["EntitlementsDto"]; + OnboardingResponseDto: { + /** @description `true` if the user has pending/rejected invitations or user_registered external invitation */ + HasPendingInvitations: boolean; /** * ProtonResponseCode * @example 1000 @@ -3125,18 +3349,8 @@ export interface components { */ Code: 1000; }; - ListPhotosParameters: { - /** @default true */ - Desc: boolean; - /** @default 500 */ - PageSize: number; - /** @default null */ - PreviousPageLastLinkID: components["schemas"]["Id"] | null; - /** @default null */ - MinimumCaptureTime: number | null; - }; - PhotoListingResponse: { - Photos: components["schemas"]["PhotoListingItemResponse"][]; + GetEntitlementResponseDto: { + Entitlements: components["schemas"]["EntitlementsDto"]; /** * ProtonResponseCode * @example 1000 @@ -3144,6 +3358,9 @@ export interface components { */ Code: 1000; }; + AddTagsRequestDto: { + Tags: components["schemas"]["TagType"][]; + }; CreatePhotoShareResponseDto: { Share: components["schemas"]["ShareResponseDto"]; /** @@ -3166,6 +3383,33 @@ export interface components { */ Code: 1000; }; + ListPhotosParameters: { + /** @default true */ + Desc: boolean; + /** @default 500 */ + PageSize: number; + /** + * @description The link ID of the last photo from the previous page when requesting secondary pages + * @default null + */ + PreviousPageLastLinkID: components["schemas"]["Id"] | null; + /** + * @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) + * @default null + */ + MinimumCaptureTime: number | null; + /** @default null */ + Tag: components["schemas"]["TagType"] | null; + }; + PhotoListingResponse: { + Photos: components["schemas"]["PhotoListingItemResponse"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; CommitAnonymousRevisionDto: { /** @description Signature of the manifest, signed with the `SignatureEmail` */ ManifestSignature: string; @@ -3289,6 +3533,15 @@ export interface components { DeleteChildrenRequestDto: { Links: components["schemas"]["LinkWithAuthorizationTokenDto"][]; }; + ParentEncryptedLinkIDsResponseDto: { + ParentLinkIDs: string[]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; RenameAnonymousLinkRequestDto: { /** @description Name, reusing same session key as previously. */ Name: string; @@ -3335,44 +3588,11 @@ export interface components { */ Code: 1000; }; - MyFilesResponseDto: { - Volume: components["schemas"]["VolumeDto"]; - Share: components["schemas"]["ShareDto"]; - Link: components["schemas"]["LinkDto"]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - GetHighestContextForDocumentResponse: { - ContextShareID: components["schemas"]["Id"]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - CreateShareRequestDto: { - AddressID: components["schemas"]["Id"]; - RootLinkID: components["schemas"]["Id"]; - ShareKey: components["schemas"]["PGPPrivateKey"]; - /** @description Full PGP message containing (optionally) PassphraseNodeKP and SharePassphrase-KP and data-packet (encrypted SharePassphrase) -> in this exact order */ - SharePassphrase: string; - SharePassphraseSignature: components["schemas"]["PGPSignature"]; - /** @description Key packet for passphrase of referenced link's node key passphrase */ - PassphraseKeyPacket: string; - NameKeyPacket: components["schemas"]["BinaryString"]; - /** - * @deprecated - * @default null - */ - Name: string | null; - }; - ListSharesResponseDto: { - Shares: components["schemas"]["ShareResponseDto2"][]; + LinkMapResponse: { + SessionName: string; + More: number; + Total: number; + Links: components["schemas"]["LinkMapItemResponse"][]; /** * ProtonResponseCode * @example 1000 @@ -3380,21 +3600,10 @@ export interface components { */ Code: 1000; }; - TransferInput: { - /** @description The ID of the new address */ - AddressID: string; - /** @description The ID of the new key */ - KeyID: string; - /** @description Armored signature of the share passphrase, signed with the users's address with AddressID. */ - SharePassphraseSignature: string; - /** @description Base64 encoded key packet for the share passphrase, reusing the same session key as previously, and encrypted for the key referenced by the KeyID. */ - MemberKeyPacket: string; - }; - LinkMapResponse: { - SessionName: string; - More: number; - Total: number; - Links: components["schemas"]["LinkMapItemResponse"][]; + MyFilesResponseDto: { + Volume: components["schemas"]["VolumeDto"]; + Share: components["schemas"]["ShareDto"]; + Link: components["schemas"]["LinkDto"]; /** * ProtonResponseCode * @example 1000 @@ -3403,8 +3612,8 @@ export interface components { Code: 1000; }; BootstrapShareResponseDto: { - ShareID: components["schemas"]["Id"]; - VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; Type: components["schemas"]["ShareType"]; State: components["schemas"]["ShareState"]; /** Format: email */ @@ -3412,7 +3621,7 @@ export interface components { Locked?: boolean | null; CreateTime?: number | null; ModifyTime?: number | null; - LinkID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id2"]; /** * @deprecated * @description Deprecated: Use `CreateTime` @@ -3427,9 +3636,9 @@ export interface components { BlockSize: number; /** @deprecated */ VolumeSoftDeleted: boolean; - Key: components["schemas"]["PGPPrivateKey"]; - Passphrase: components["schemas"]["PGPMessage"]; - PassphraseSignature: components["schemas"]["PGPSignature"]; + Key: components["schemas"]["PGPPrivateKey2"]; + Passphrase: components["schemas"]["PGPMessage2"]; + PassphraseSignature: components["schemas"]["PGPSignature2"]; /** @description Address ID of the current user's address for the membership of this share. Can be missing if the user is not a direct member of the share. */ AddressID?: string | null; /** @@ -3444,7 +3653,40 @@ export interface components { * @description Deprecated, use `Memberships` instead */ PossibleKeyPackets: components["schemas"]["KeyPacketResponseDto"][]; - RootLinkRecoveryPassphrase?: components["schemas"]["PGPMessage"] | null; + RootLinkRecoveryPassphrase?: components["schemas"]["PGPMessage2"] | null; + /** + * @deprecated + * @description User for AutoRestoreProcedure, see /sanitization/asv endpoint(s) + * @default false + */ + ForASV: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetHighestContextForDocumentResponse: { + ContextShareID: components["schemas"]["Id2"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListAutoRestoreVolumeRootSharesResponseDto: { + ShareIDs: components["schemas"]["Id2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListSharesResponseDto: { + Shares: components["schemas"]["ShareResponseDto2"][]; /** * ProtonResponseCode * @example 1000 @@ -3452,6 +3694,19 @@ export interface components { */ Code: 1000; }; + LogFailedRestoreProcedureRequestDto: { + Shares: components["schemas"]["FailedRestoreProcedureShareDataDto"][]; + }; + TransferInput: { + /** @description The ID of the new address */ + AddressID: string; + /** @description The ID of the new key */ + KeyID: string; + /** @description Armored signature of the share passphrase, signed with the users's address with AddressID. */ + SharePassphraseSignature: string; + /** @description Base64 encoded key packet for the share passphrase, reusing the same session key as previously, and encrypted for the key referenced by the KeyID. */ + MemberKeyPacket: string; + }; MigrateSharesRequestDto: { /** * @description The sum of PassphraseNodeKeyPacket-pairs and UnreadableShareIDs should not exceed 50 @@ -3466,7 +3721,7 @@ export interface components { }; MigrateSharesResponseDto: { /** @description ShareIDs successfully migrated */ - ShareIDs: components["schemas"]["Id"][]; + ShareIDs: components["schemas"]["Id2"][]; /** @description ShareIDs not migrated with reason and error code */ Errors: components["schemas"]["ShareKPMigrationError"][]; /** @@ -3478,7 +3733,22 @@ export interface components { }; UnmigratedSharesResponseDto: { /** @description ShareIDs that can be migrated */ - ShareIDs: components["schemas"]["Id"][]; + ShareIDs: components["schemas"]["Id2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + InitSRPSessionResponseDto: { + Modulus: string; + ServerEphemeral: components["schemas"]["BinaryString2"]; + UrlPasswordSalt: components["schemas"]["BinaryString2"]; + SRPSession: components["schemas"]["BinaryString2"]; + Version: number; + Flags: number; + IsDoc: boolean; /** * ProtonResponseCode * @example 1000 @@ -3509,13 +3779,9 @@ export interface components { ClientProof: components["schemas"]["BinaryString"]; SRPSession: components["schemas"]["BinaryString"]; }; - SecurityRequestDto: { - Hashes: string[]; - }; - /** @description For each hash from the request, response contains either result or error entry */ - SecurityResponseDto: { - Results: components["schemas"]["SecurityResponseResultDto"][]; - Errors: components["schemas"]["SecurityResponseErrorDto"][]; + GetSharedFileInfoResponseDto: { + ServerProof: components["schemas"]["BinaryString2"]; + Payload: components["schemas"]["GetSharedFileInfoPayloadDto"]; /** * ProtonResponseCode * @example 1000 @@ -3524,7 +3790,7 @@ export interface components { Code: 1000; }; ListShareURLsResponseDto: { - ShareURLs: components["schemas"]["ShareURLResponseDto"][]; + ShareURLs: components["schemas"]["ShareURLResponseDto2"][]; /** @description If the Recursive query parameter is set, also returns the related links and ancestors up to the share as a dictionary by LinkID. */ Links: { [key: string]: components["schemas"]["ExtendedLinkTransformer2"]; @@ -3619,6 +3885,22 @@ export interface components { /** @description List of ShareURL ids to delete. */ ShareURLIDs: components["schemas"]["EncryptedId"][]; }; + CreateShareRequestDto: { + AddressID: components["schemas"]["Id"]; + RootLinkID: components["schemas"]["Id"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + /** @description Full PGP message containing (optionally) PassphraseNodeKP and SharePassphrase-KP and data-packet (encrypted SharePassphrase) -> in this exact order */ + SharePassphrase: string; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description Key packet for passphrase of referenced link's node key passphrase */ + PassphraseKeyPacket: string; + NameKeyPacket: components["schemas"]["BinaryString"]; + /** + * @deprecated + * @default null + */ + Name: string | null; + }; SharedByMeResponseDto: { Links: components["schemas"]["LinkSharedByMeResponseDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ @@ -3632,7 +3914,7 @@ export interface components { */ Code: 1000; }; - SharedWithMeResponseDto: { + SharedWithMeResponseDto2: { Links: components["schemas"]["LinkSharedWithMeResponseDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; @@ -3786,6 +4068,20 @@ export interface components { */ Permissions: 4 | 6 | 22; }; + SecurityRequestDto: { + Hashes: string[]; + }; + /** @description For each hash from the request, response contains either result or error entry */ + SecurityResponseDto: { + Results: components["schemas"]["SecurityResponseResultDto"][]; + Errors: components["schemas"]["SecurityResponseErrorDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; ThumbnailIDsListInput: { /** @description List of encrypted ThumbnailIDs. Maximum 30. */ ThumbnailIDs: components["schemas"]["Id"][]; @@ -3811,21 +4107,10 @@ export interface components { Code: 1000; }; UserSettingsRequest: { - /** - * @description Layout variant to use. 0=list, 1=grid. - * @enum {integer|null} - */ - Layout?: 0 | 1 | null; - /** - * @description Sort order. 1=name asc, 2=size asc, 4=modified asc, -1=name desc, -2=size desc, -4=modified desc - * @enum {integer|null} - */ - Sort?: -4 | -2 | -1 | 1 | 2 | 4 | null; - /** - * @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. - * @enum {integer|null} - */ - RevisionRetentionDays?: 0 | 7 | 30 | 180 | 365 | 3650 | null; + Layout?: components["schemas"]["LayoutSetting"] | null; + Sort?: components["schemas"]["SortSetting"] | null; + /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ + RevisionRetentionDays?: components["schemas"]["RevisionRetentionDays"] | null; /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ B2BPhotosEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ @@ -3898,6 +4183,7 @@ export interface components { AddressKeyID: string; }; AddPhotoToAlbumWithLinkIDResponseDto: Record; + RemovePhotoFromAlbumWithLinkIDResponseDto: Record; ConflictErrorResponseDto: { Details: components["schemas"]["ConflictErrorDetailsDto"]; Error: string; @@ -4106,6 +4392,8 @@ export interface components { /** @description Timestamp Photo-Link was added to this album */ AddedTime?: number; }[]; + /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ + Tags?: number[]; } | null; } & components["schemas"]["LinkTransformer"]; /** Revision */ @@ -4203,82 +4491,6 @@ export interface components { ThumbnailSize: number; Thumbnails: components["schemas"]["ThumbnailTransformer"][]; }; - /** ShareURL */ - ShareURLDownloadTransformer: { - /** @description Share password salt. */ - SharePasswordSalt: string; - /** - * @description Share passphrase. - * @example ----BEGIN PGP MESSAGE----... - */ - SharePassphrase: string; - /** - * @description Share key. - * @example ----BEGIN PGP PRIVATE KEY BLOCK----... - */ - ShareKey: string; - /** - * @description Node passphrase - * @example -----BEGIN PGP MESSAGE-----... - */ - NodePassphrase: string; - /** - * @description Node key. - * @example ----BEGIN PGP PRIVATE KEY BLOCK----... - */ - NodeKey: string; - /** - * @description Name - * @example -----BEGIN PGP MESSAGE-----... - */ - Name: string; - /** @description Size */ - Size: number; - /** - * @deprecated - * @description Download url for thumbnail if present, null otherwise. - * @example https://.../storage/block/123 - */ - ThumbnailURL: string; - /** - * @description MimeType - * @example text/plain - */ - MIMEType: string; - /** @description Expiration time: UNIX timestamp after which this link is no longer accessible. */ - ExpirationTime: number; - /** @description Base64 encoded content key packet. */ - ContentKeyPacket: string; - /** - * @deprecated - * @description Blocks - */ - Blocks: string[]; - /** @description Block Download URLs */ - BlockURLs: { - /** - * @deprecated - * @description Download URL for the block - */ - URL?: string; - /** @description Bare Download URL for the block */ - BareURL?: string; - /** @description Token for the block URL */ - Token?: string; - }[]; - /** @description File properties */ - ThumbnailURLInfo: { - /** - * @deprecated - * @description Download URL for the thumbnail - */ - URL?: string; - /** @description Bare Download URL for the thumbnail */ - BareURL?: string; - /** @description Token for the thumbnail URL */ - Token?: string; - }; - }; ShareURLResponseDto: { Token: string; ShareURLID: components["schemas"]["Id"]; @@ -4369,7 +4581,7 @@ export interface components { NodeHashKey: components["schemas"]["PGPMessage"]; }; PhotoVolumeResponseDto: { - VolumeID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id2"]; CreateTime?: number | null; ModifyTime?: number | null; /** @description Used space in bytes */ @@ -4378,11 +4590,7 @@ export interface components { UploadedBytes: number; State: components["schemas"]["VolumeState"]; Share: components["schemas"]["ShareReferenceResponseDto"]; - /** - * @description Type (1=Regular, 2=Photo) - * @enum {integer} - */ - Type: 1 | 2; + Type: components["schemas"]["VolumeType"]; /** * @description Status of restore task if applicable: * - 0 => done @@ -4393,24 +4601,32 @@ export interface components { */ RestoreStatus: 0 | 1 | -1 | null; }; + /** @description An encrypted ID */ + Id2: string; AlbumResponseDto: { Locked: boolean; LastActivityTime: number; - LinkID: components["schemas"]["Id"]; - VolumeID: components["schemas"]["Id"]; + PhotoCount: number; + LinkID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; /** @default null */ - ShareID: components["schemas"]["Id"] | null; + ShareID: components["schemas"]["Id2"] | null; /** @default null */ - CoverLinkID: components["schemas"]["Id"] | null; + CoverLinkID: components["schemas"]["Id2"] | null; }; ListPhotosAlbumItemResponseDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id2"]; CaptureTime: number; Hash: string; ContentHash: string; RelatedPhotos: components["schemas"]["ListPhotosAlbumRelatedPhotoItemResponseDto"][]; AddedTime: number; IsChildOfAlbum: boolean; + /** + * @description Tags assigned to the photo + * @default [] + */ + Tags: number[]; }; AlbumLinkUpdateDto: { Name?: components["schemas"]["PGPMessage"] | null; @@ -4422,7 +4638,7 @@ export interface components { NameSignatureEmail?: string | null; OriginalHash?: string | null; /** @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) */ - XAttr: string; + XAttr?: string | null; }; BookmarkShareURLRequestDto: { EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; @@ -4430,33 +4646,25 @@ export interface components { AddressKeyID: components["schemas"]["Id"]; }; BookmarkShareURLResponseDto: { - UserID: components["schemas"]["Id"]; + UserID: components["schemas"]["Id2"]; Token: string; - ShareURLID: components["schemas"]["Id"]; - EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + ShareURLID: components["schemas"]["Id2"]; + EncryptedUrlPassword?: components["schemas"]["PGPMessage2"] | null; State: components["schemas"]["BookmarkShareURLState"]; CreateTime: number; ModifyTime: number; }; BookmarkShareURLInfoResponseDto: { - EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + EncryptedUrlPassword?: components["schemas"]["PGPMessage2"] | null; CreateTime: number; Token: components["schemas"]["TokenResponseDto"]; }; DeviceDataDto: { + SyncState: components["schemas"]["DeviceSyncState"]; + Type: components["schemas"]["DeviceType"]; /** - * @description State of sync for that device; 0=>off, 1=>on - * @enum {integer} - */ - SyncState: 0 | 1; - /** - * @description Type of device; 1=>Windows, 2=>MacOs, 3=>Linux - * @enum {integer} - */ - Type: 1 | 2 | 3; - /** - * @deprecated - * @default null + * @deprecated + * @default null */ VolumeID: components["schemas"]["Id"] | null; }; @@ -4474,9 +4682,9 @@ export interface components { Name: string | null; }; DeviceResponseDto: { - DeviceID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; + DeviceID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; DeviceResponseDto2: { Device: components["schemas"]["DeviceDataDto3"]; @@ -4484,16 +4692,12 @@ export interface components { }; DeviceResponseDto3: { Device: components["schemas"]["DeviceDto"]; - ShareID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; DeviceDataDto2: { - /** - * @description State of sync for that device; 0=>off, 1=>on - * @default null - * @enum {integer|null} - */ - SyncState: 0 | 1 | null; + /** @default null */ + SyncState: components["schemas"]["DeviceSyncState"] | null; /** * @description UNIX timestamp when the Device got last synced. Optional * @default null @@ -4516,17 +4720,13 @@ export interface components { /** @description An armored PGP Private Key */ PGPPrivateKey: string; DocumentDetailsDto: { - VolumeID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; - RevisionID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; + RevisionID: components["schemas"]["Id2"]; }; EventResponseDto: { - EventID: components["schemas"]["Id"]; - /** - * @description Event type (0=delete, 1=create, 2=update, 3=update metadata) - * @enum {integer} - */ - EventType: 0 | 1 | 2 | 3; + EventID: components["schemas"]["Id2"]; + EventType: components["schemas"]["EventType"]; /** @description Event creation timestamp */ CreateTime: number; Link: { @@ -4564,6 +4764,11 @@ export interface components { FromParentLinkID?: string; } | null; }; + EventV2ResponseDto: { + EventID: components["schemas"]["Id2"]; + EventType: components["schemas"]["EventType"]; + Link: components["schemas"]["EventLinkDataDto"]; + }; RequestUploadBlockInput: { /** @description Block size in bytes */ Size: number; @@ -4579,11 +4784,7 @@ export interface components { RequestUploadThumbnailInput: { /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ Size: number; - /** - * @description Type of thumbnail : 1=Preview(512), 2=HDPreview(1920), 3=MachineLearning - * @enum {integer} - */ - Type: 1 | 2 | 3; + Type: components["schemas"]["ThumbnailType"]; /** @description Hash of encrypted block, base64 encoded */ Hash: string; }; @@ -4599,25 +4800,26 @@ export interface components { Token: string; /** @deprecated */ URL: string; - /** - * @description Thumbnail type: 1=Preview(512), 2=HDPreview(1920), 3=MachineLearning - * @enum {integer} - */ - ThumbnailType: 1 | 2 | 3; + ThumbnailType: components["schemas"]["ThumbnailType2"]; }; FolderResponseDto: { - ID: components["schemas"]["Id"]; + ID: components["schemas"]["Id2"]; }; /** @description An encrypted ID */ EncryptedId: string; PendingHashResponseDto: { Hash: string; - RevisionID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; ClientUID?: string | null; }; + ListMissingHashKeyItemDto: { + LinkID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + }; FileDetailsDto: { - Link: components["schemas"]["LinkDto"]; + Link: components["schemas"]["LinkDto2"]; File: components["schemas"]["FileDto"]; /** @default null */ ActiveRevision: components["schemas"]["ActiveRevisionDto"] | null; @@ -4627,7 +4829,7 @@ export interface components { Folder: null | null; }; FolderDetailsDto: { - Link: components["schemas"]["LinkDto"]; + Link: components["schemas"]["LinkDto2"]; Folder: components["schemas"]["FolderDto"]; /** @default null */ SharingSummary: components["schemas"]["SharingSummaryDto"] | null; @@ -4636,6 +4838,11 @@ export interface components { /** @default null */ ActiveRevision: null | null; }; + UpdateMissingHashKeyItemDto: { + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + PGPArmoredEncryptedNodeHashKey: components["schemas"]["PGPMessage"]; + }; CommitRevisionPhotoDto: { /** @description Photo capture timestamp */ CaptureTime: number; @@ -4651,17 +4858,24 @@ export interface components { * @default null */ Exif: components["schemas"]["BinaryString"] | null; + /** + * @description List of tags to be assigned to the photo + * @default null + */ + Tags: components["schemas"]["TagType"][] | null; }; BlockTokenDto: { Index: number; Token: string; }; + /** @description Base64 encoded binary data */ + BinaryString2: string; ShareTrashList: { - ShareID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; /** @description List of trashed link IDs for that share */ - LinkIDs: components["schemas"]["Id"][]; + LinkIDs: components["schemas"]["Id2"][]; /** @description List of trashed link's parentLinkIDs */ - ParentIDs: components["schemas"]["Id"][]; + ParentIDs: components["schemas"]["Id2"][]; }; EntitlementsDto: { /** @description Maximum number of days revision history can be kept */ @@ -4671,20 +4885,9 @@ export interface components { /** @description Allow or not the user to create writable ShareURLs */ PublicCollaboration: boolean; }; - PhotoListingItemResponse: { - LinkID: components["schemas"]["Id"]; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ - CaptureTime: number; - /** @description File name hash */ - Hash?: string | null; - /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash?: string | null; - /** @default [] */ - RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; - }; ShareResponseDto: { - ShareID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; FoundDuplicate: { /** @description NameHash of the found duplicate */ @@ -4692,8 +4895,8 @@ export interface components { /** @description ContentHash of the found duplicate */ ContentHash?: string | null; /** - * @description State of the link: 0=draft, 1=active, 2=trashed; Can be null if the Link was deleted - * @enum {integer|null} + * @description Can be null if the Link was deleted + * @enum {unknown|null} */ LinkState?: 0 | 1 | 2 | null; /** @description Client defined UID for the draft. Null if no ClientUID passed, or Revision was already committed. */ @@ -4703,9 +4906,25 @@ export interface components { /** @description RevisionID, null if deleted */ RevisionID: string; }; + PhotoListingItemResponse: { + LinkID: components["schemas"]["Id2"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + /** @description File name hash */ + Hash?: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + /** + * @description Tags assigned to the photo + * @default [] + */ + Tags: number[]; + /** @default [] */ + RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; + }; FileResponseDto: { - ID: components["schemas"]["Id"]; - RevisionID: components["schemas"]["Id"]; + ID: components["schemas"]["Id2"]; + RevisionID: components["schemas"]["Id2"]; ClientUID?: string | null; }; LinkWithAuthorizationTokenDto: { @@ -4727,117 +4946,89 @@ export interface components { ShareURLContext: { /** @description Share ID of the share highest in the tree with permissions */ ContextShareID: string; - ShareURLs: components["schemas"]["ShareURLResponseDto"][]; + ShareURLs: components["schemas"]["ShareURLResponseDto2"][]; /** @description Related link IDs and ancestors up to the share. */ - LinkIDs: components["schemas"]["Id"][]; + LinkIDs: components["schemas"]["Id2"][]; + }; + LinkMapItemResponse: { + Index: number; + LinkID: components["schemas"]["Id2"]; + ParentLinkID?: components["schemas"]["Id2"] | null; + Type: components["schemas"]["NodeType2"]; + Name: components["schemas"]["PGPMessage2"]; + Hash?: string | null; + State: components["schemas"]["LinkState2"]; + Size: number; + MIMEType: string; + CreateTime: number; + ModifyTime: number; + /** @default null */ + NodeKey: components["schemas"]["PGPPrivateKey2"]; + /** @default null */ + NodePassphrase: components["schemas"]["PGPMessage2"]; + /** @default null */ + NodePassphraseSignature: components["schemas"]["PGPSignature2"]; + /** @default null */ + NodeSignatureEmail: string; }; VolumeDto: { - VolumeID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id2"]; UsedSpace: number; }; ShareDto: { - ShareID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; /** Format: email */ CreatorEmail: string; - Key: components["schemas"]["PGPPrivateKey"]; - Passphrase: components["schemas"]["PGPMessage"]; - PassphraseSignature: components["schemas"]["PGPSignature"]; - AddressID: components["schemas"]["Id"]; + Key: components["schemas"]["PGPPrivateKey2"]; + Passphrase: components["schemas"]["PGPMessage2"]; + PassphraseSignature: components["schemas"]["PGPSignature2"]; + AddressID: components["schemas"]["Id2"]; }; LinkDto: { - LinkID: components["schemas"]["Id"]; - /** @enum {integer} */ - Type: 2 | 1; - ParentLinkID?: components["schemas"]["Id"] | null; - /** @enum {integer} */ - State: 0 | 1 | 2; + LinkID: components["schemas"]["Id2"]; + Type: components["schemas"]["NodeType2"]; + ParentLinkID?: components["schemas"]["Id2"] | null; + State: components["schemas"]["LinkState2"]; CreateTime: number; ModifyTime: number; TrashTime?: number | null; - Name: components["schemas"]["PGPMessage"]; + Name: components["schemas"]["PGPMessage2"]; NameHash?: string | null; MIMEType?: string | null; - NodeKey: components["schemas"]["PGPPrivateKey"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey2"]; + NodePassphrase: components["schemas"]["PGPMessage2"]; + NodePassphraseSignature: components["schemas"]["PGPSignature2"]; /** Format: email */ SignatureEmail?: string | null; /** Format: email */ NameSignatureEmail?: string | null; }; - ShareResponseDto2: { - ShareID: components["schemas"]["Id"]; - VolumeID: components["schemas"]["Id"]; - Type: components["schemas"]["ShareType"]; - State: components["schemas"]["ShareState"]; - /** Format: email */ - Creator: string; - Locked?: boolean | null; - CreateTime?: number | null; - ModifyTime?: number | null; - LinkID: components["schemas"]["Id"]; - /** - * @deprecated - * @description Deprecated: Use `CreateTime` - */ - CreationTime?: number | null; - /** @deprecated */ - PermissionsMask: number; - /** @deprecated */ - LinkType: number; - /** @deprecated */ - Flags: number; - /** @deprecated */ - BlockSize: number; - /** @deprecated */ - VolumeSoftDeleted: boolean; - }; - LinkMapItemResponse: { - Index: number; - LinkID: components["schemas"]["Id"]; - ParentLinkID?: components["schemas"]["Id"] | null; - /** @enum {integer} */ - Type: 1 | 2; - Name: components["schemas"]["PGPMessage"]; - Hash?: string | null; - /** - * @description State (1=active, 2=trashed) - * @enum {integer} - */ - State: 1 | 2; - Size: number; - MIMEType: string; - CreateTime: number; - ModifyTime: number; - /** @default null */ - NodeKey: components["schemas"]["PGPPrivateKey"]; - /** @default null */ - NodePassphrase: components["schemas"]["PGPMessage"]; - /** @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - /** @default null */ - NodeSignatureEmail: string; - }; /** - * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueDescription
1Main
2Standard
3Device
4Photo
+ * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
* @enum {integer} */ ShareType: 1 | 2 | 3 | 4; /** - * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
+ * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
4Migrating
33HiddenRestoreVolumeIncident2025
* @enum {integer} */ - ShareState: 1 | 2 | 3; + ShareState: 1 | 2 | 3 | 4 | 33; /** * @description

1=folder, 2=file

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
* @enum {integer} */ NodeType: 1 | 2 | 3; + /** @description An armored PGP Private Key */ + PGPPrivateKey2: string; + /** @description An armored PGP Message */ + PGPMessage2: string; + /** @description An armored PGP Signature */ + PGPSignature2: string; MemberResponseDto: { - MemberID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - AddressID: components["schemas"]["Id"]; - AddressKeyID: components["schemas"]["Id"]; + MemberID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + AddressID: components["schemas"]["Id2"]; + AddressKeyID: components["schemas"]["Id2"]; /** Format: email */ Inviter: string; /** @@ -4868,9 +5059,9 @@ export interface components { Unlockable: boolean | null; }; KeyPacketResponseDto: { - AddressID: components["schemas"]["Id"]; - AddressKeyID: components["schemas"]["Id"]; - KeyPacket: components["schemas"]["BinaryString"]; + AddressID: components["schemas"]["Id2"]; + AddressKeyID: components["schemas"]["Id2"]; + KeyPacket: components["schemas"]["BinaryString2"]; State: components["schemas"]["ShareMemberState"]; /** * @deprecated @@ -4879,6 +5070,37 @@ export interface components { */ Unlockable: boolean | null; }; + ShareResponseDto2: { + ShareID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + Type: components["schemas"]["ShareType"]; + State: components["schemas"]["ShareState"]; + /** Format: email */ + Creator: string; + Locked?: boolean | null; + CreateTime?: number | null; + ModifyTime?: number | null; + LinkID: components["schemas"]["Id2"]; + /** + * @deprecated + * @description Deprecated: Use `CreateTime` + */ + CreationTime?: number | null; + /** @deprecated */ + PermissionsMask: number; + /** @deprecated */ + LinkType: number; + /** @deprecated */ + Flags: number; + /** @deprecated */ + BlockSize: number; + /** @deprecated */ + VolumeSoftDeleted: boolean; + }; + FailedRestoreProcedureShareDataDto: { + ShareID: components["schemas"]["Id"]; + Reason: string; + }; ShareKPMigrationData: { /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ ShareID: string; @@ -4887,7 +5109,7 @@ export interface components { }; /** @description Share unable to be migrated with reason and code; NOT_EXISTS, INCOMPATIBLE_STATE, PERMISSION_DENIED, ENCRYPTION_VERIFICATION_FAILED */ ShareKPMigrationError: { - ShareID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; Error: string; Code: number; }; @@ -4897,20 +5119,16 @@ export interface components { * @example YTZZRH7DA8 */ Token: string; - LinkType: components["schemas"]["NodeType2"]; - LinkID: components["schemas"]["Id"]; - /** - * @description Share password salt - * @example qZBadaNdT8Y1N3== - */ - SharePasswordSalt: string; - SharePassphrase: components["schemas"]["PGPMessage"]; - ShareKey: components["schemas"]["PGPPrivateKey"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodeKey: components["schemas"]["PGPPrivateKey"]; - Name: components["schemas"]["PGPMessage"]; + LinkType: components["schemas"]["NodeType3"]; + LinkID: components["schemas"]["Id2"]; + SharePasswordSalt: components["schemas"]["BinaryString2"]; + SharePassphrase: components["schemas"]["PGPMessage2"]; + ShareKey: components["schemas"]["PGPPrivateKey2"]; + NodePassphrase: components["schemas"]["PGPMessage2"]; + NodeKey: components["schemas"]["PGPPrivateKey2"]; + Name: components["schemas"]["PGPMessage2"]; /** @description Base64 encoded content key packet. Null for folders */ - ContentKeyPacket?: string | null; + ContentKeyPacket?: components["schemas"]["BinaryString2"] | null; /** @example text/plain */ MIMEType: string; /** @@ -4926,7 +5144,7 @@ export interface components { /** @description File properties */ ThumbnailURLInfo?: components["schemas"]["ThumbnailURLInfoResponseDto"] | null; /** @default null */ - NodeHashKey: string | null; + NodeHashKey: components["schemas"]["PGPMessage2"] | null; /** * @description Signature email of the share owner. Only set for a ShareURL with read+write permissions. * @default null @@ -4936,20 +5154,58 @@ export interface components { * @description Only set for a ShareURL with read+write permissions. * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature: components["schemas"]["PGPSignature2"] | null; }; - SecurityResponseResultDto: { - Hash: string; - /** @description Whether file is safe or not, true if yes, false if not */ - Safe: boolean; + GetSharedFileInfoPayloadDto: { + SharePasswordSalt: components["schemas"]["BinaryString2"]; + SharePassphrase: components["schemas"]["PGPMessage2"]; + ShareKey: components["schemas"]["PGPPrivateKey2"]; + NodePassphrase: components["schemas"]["PGPMessage2"]; + NodeKey: components["schemas"]["PGPPrivateKey2"]; + Name: components["schemas"]["PGPMessage2"]; + Size: number; + MIMEType: string; + /** @description UNIX timestamp after which this link is no longer accessible */ + ExpirationTime?: number | null; + ContentKeyPacket: components["schemas"]["BinaryString2"]; + BlockURLs: components["schemas"]["ThumbnailURLInfoResponseDto"][]; + ThumbnailURLInfo: components["schemas"]["ThumbnailURLInfoResponseDto"]; + /** @deprecated */ + Blocks: string[]; + /** @deprecated */ + ThumbnailURL?: string | null; }; - SecurityResponseErrorDto: { - Hash: string; + ShareURLResponseDto2: { + Token: string; + ShareURLID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + /** @description URL to use to access the ShareURL */ + PublicUrl: string; + ExpirationTime?: number | null; + LastAccessTime?: number | null; + CreateTime: number; + MaxAccesses: number; + NumAccesses: number; + Name?: components["schemas"]["PGPMessage2"] | null; + CreatorEmail: string; /** - * @description An error message describing the error, translated. Can be displayed directly to user. - * @example We cannot check this file at present, please proceed with caution + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @enum {integer} */ - Error: string; + Permissions: 4 | 6; + /** @description Bitmap: + * - `1`: FLAG_CUSTOM_PASSWORD, + * - `2`: FLAG_RANDOM_PASSWORD */ + Flags: number; + UrlPasswordSalt: components["schemas"]["BinaryString2"]; + SharePasswordSalt: components["schemas"]["BinaryString2"]; + SRPVerifier: components["schemas"]["BinaryString2"]; + SRPModulusID: components["schemas"]["Id2"]; + Password: components["schemas"]["PGPMessage2"]; + SharePassphraseKeyPacket: components["schemas"]["BinaryString2"]; }; /** Link */ ExtendedLinkTransformer2: { @@ -5150,17 +5406,19 @@ export interface components { /** @description Timestamp Photo-Link was added to this album */ AddedTime?: number; }[]; + /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ + Tags?: number[]; } | null; } & components["schemas"]["LinkTransformer"]; LinkSharedByMeResponseDto: { - ShareID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; - ContextShareID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; + ContextShareID: components["schemas"]["Id2"]; }; LinkSharedWithMeResponseDto: { - VolumeID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; ExternalInvitationRequestDto: { InviterAddressID: components["schemas"]["Id"]; @@ -5183,7 +5441,7 @@ export interface components { ItemName?: string | null; }; ExternalInvitationResponseDto: { - ExternalInvitationID: components["schemas"]["Id"]; + ExternalInvitationID: components["schemas"]["Id2"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -5203,9 +5461,9 @@ export interface components { CreateTime: number; }; UserRegisteredExternalInvitationItemDto: { - VolumeID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - ExternalInvitationID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + ExternalInvitationID: components["schemas"]["Id2"]; }; InvitationRequestDto: { /** Format: email */ @@ -5229,7 +5487,7 @@ export interface components { ExternalInvitationID: components["schemas"]["Id"] | null; }; InvitationResponseDto: { - InvitationID: components["schemas"]["Id"]; + InvitationID: components["schemas"]["Id2"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -5250,32 +5508,31 @@ export interface components { CreateTime: number; }; PendingInvitationItemDto: { - VolumeID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - InvitationID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + InvitationID: components["schemas"]["Id2"]; }; ShareResponseDto3: { - ShareID: components["schemas"]["Id"]; - VolumeID: components["schemas"]["Id"]; - Passphrase: components["schemas"]["PGPMessage"]; - ShareKey: components["schemas"]["PGPPrivateKey"]; + ShareID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + Passphrase: components["schemas"]["PGPMessage2"]; + ShareKey: components["schemas"]["PGPPrivateKey2"]; /** Format: email */ CreatorEmail: string; }; LinkResponseDto: { - /** @enum {integer} */ - Type: 1 | 2; - LinkID: components["schemas"]["Id"]; - Name: components["schemas"]["PGPMessage"]; + Type: components["schemas"]["NodeType2"]; + LinkID: components["schemas"]["Id2"]; + Name: components["schemas"]["PGPMessage2"]; MIMEType?: string | null; }; ContextShareDto: { - VolumeID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; MemberResponseDto2: { - MemberID: components["schemas"]["Id"]; + MemberID: components["schemas"]["Id2"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -5297,32 +5554,34 @@ export interface components { SessionKeySignature: string; CreateTime: number; }; - ThumbnailResponse: { - ThumbnailID: components["schemas"]["Id"]; - BareURL: string; - Token: string; - }; + SecurityResponseResultDto: { + Hash: string; + /** @description Whether file is safe or not, true if yes, false if not */ + Safe: boolean; + }; + SecurityResponseErrorDto: { + Hash: string; + /** + * @description An error message describing the error, translated. Can be displayed directly to user. + * @example We cannot check this file at present, please proceed with caution + */ + Error: string; + }; + ThumbnailResponse: { + ThumbnailID: components["schemas"]["Id2"]; + BareURL: string; + Token: string; + }; ThumbnailErrorResponse: { - ThumbnailID: components["schemas"]["Id"]; + ThumbnailID: components["schemas"]["Id2"]; Error: string; Code: number; }; UserSettings: { - /** - * @description Layout variant to use. 0=list, 1=grid. - * @enum {integer|null} - */ - Layout?: 0 | 1 | null; - /** - * @description Sort order. 1=name asc, 2=size asc, 4=modified asc, -1=name desc, -2=size desc, -4=modified desc - * @enum {integer|null} - */ - Sort?: -4 | -2 | -1 | 1 | 2 | 4 | null; - /** - * @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. - * @enum {integer|null} - */ - RevisionRetentionDays?: 0 | 7 | 30 | 180 | 365 | 3650 | null; + Layout?: components["schemas"]["LayoutSetting2"] | null; + Sort?: components["schemas"]["SortSetting2"] | null; + /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ + RevisionRetentionDays?: components["schemas"]["RevisionRetentionDays2"] | null; /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ B2BPhotosEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ @@ -5331,11 +5590,7 @@ export interface components { DocsCommentsNotificationsIncludeDocumentName?: boolean | null; }; Defaults: { - /** - * @description Number of days revisions should be retained if not defined by the user. Default ALWAYS used for free users, even if different value is set (premium feature). - * @enum {integer} - */ - RevisionRetentionDays: 0 | 7 | 30 | 180 | 365 | 3650; + RevisionRetentionDays: components["schemas"]["RevisionRetentionDays3"]; /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ B2BPhotosEnabled: boolean; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ @@ -5343,8 +5598,23 @@ export interface components { /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. */ DocsCommentsNotificationsIncludeDocumentName: boolean; }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0List
1Grid
+ * @enum {integer} + */ + LayoutSetting: 0 | 1; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
-4ModifiedDesc
-2SizeDesc
-1NameDesc
1NameAsc
2SizeAsc
4ModifiedAsc
+ * @enum {integer} + */ + SortSetting: -4 | -2 | -1 | 1 | 2 | 4; + /** + * @description

Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default.

See values descriptions
See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
+ * @enum {integer} + */ + RevisionRetentionDays: 0 | 7 | 30 | 180 | 365 | 3650; VolumeResponseDto: { - ID: components["schemas"]["Id"]; + ID: components["schemas"]["Id2"]; /** * @deprecated * @description Deprecated, use `CreateTime` instead @@ -5355,7 +5625,7 @@ export interface components { * @default null */ MaxSpace: number | null; - VolumeID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id2"]; CreateTime?: number | null; ModifyTime?: number | null; /** @description Used space in bytes */ @@ -5364,11 +5634,7 @@ export interface components { UploadedBytes: number; State: components["schemas"]["VolumeState"]; Share: components["schemas"]["ShareReferenceResponseDto"]; - /** - * @description Type (1=Regular, 2=Photo) - * @enum {integer} - */ - Type: 1 | 2; + Type: components["schemas"]["VolumeType"]; /** * @description Status of restore task if applicable: * - 0 => done @@ -5555,20 +5821,25 @@ export interface components { ConflictShareID: string; }; AlbumLinkResponseDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id2"]; }; /** - * @description

State (1=Active, 3=Locked)

See values descriptions
See values descriptions
ValueDescription
1Active
3Locked
+ * @description
See values descriptions
See values descriptions
ValueDescription
1Active
3Locked
* @enum {integer} */ VolumeState: 1 | 3; ShareReferenceResponseDto: { - ShareID: components["schemas"]["Id"]; - ID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; + ID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
+ * @enum {integer} + */ + VolumeType: 1 | 2; ListPhotosAlbumRelatedPhotoItemResponseDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id2"]; CaptureTime: number; Hash: string; ContentHash: string; @@ -5578,19 +5849,21 @@ export interface components { * @enum {integer} */ BookmarkShareURLState: 1 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Disabled
1Enabled
+ * @enum {integer} + */ + DeviceSyncState: 0 | 1; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Windows
2MacOS
3Linux
+ * @enum {integer} + */ + DeviceType: 1 | 2 | 3; DeviceDataDto3: { - DeviceID: components["schemas"]["Id"]; - VolumeID: components["schemas"]["Id"]; - /** - * @description State of sync for that device; 0=>off, 1=>on - * @enum {integer} - */ - SyncState: 0 | 1; - /** - * @description Type of device; 1=>Windows, 2=>MacOs, 3=>Linux - * @enum {integer} - */ - Type: 1 | 2 | 3; + DeviceID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + SyncState: components["schemas"]["DeviceSyncState2"]; + Type: components["schemas"]["DeviceType2"]; /** @description UNIX timestamp when the Device got last synced */ LastSyncTime?: number | null; CreateTime: number; @@ -5602,22 +5875,61 @@ export interface components { CreationTime: number; }; ShareDataDto4: { - ShareID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; /** @deprecated */ Name: string; }; DeviceDto: { - DeviceID: components["schemas"]["Id"]; + DeviceID: components["schemas"]["Id2"]; CreateTime: number; ModifyTime?: number | null; - /** @enum {integer} */ - Type: 3 | 2 | 1; + Type: components["schemas"]["DeviceType2"]; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Delete
1Create
2Update
3UpdateMetadata
+ * @enum {integer} + */ + EventType: 0 | 1 | 2 | 3; + EventLinkDataDto: { + LinkID: components["schemas"]["Id2"]; + ParentLinkID?: components["schemas"]["Id2"] | null; + IsShared: boolean; + IsTrashed: boolean; }; Verifier: { /** @description Derived from verificationCode from GET /verification endpoint: base64(xor(verificationCode, padWithZeros(dataPacket, 32))) https://confluence.protontech.ch/x/j_OTC */ Token: string; }; + /** + * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px
2HDPreview1920 px
3MachineLearning
+ * @enum {integer} + */ + ThumbnailType: 1 | 2 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px
2HDPreview1920 px
3MachineLearning
+ * @enum {integer} + */ + ThumbnailType2: 1 | 2 | 3; + LinkDto2: { + LinkID: components["schemas"]["Id"]; + Type: components["schemas"]["NodeType4"]; + ParentLinkID?: components["schemas"]["Id"] | null; + State: components["schemas"]["LinkState3"]; + CreateTime: number; + ModifyTime: number; + TrashTime?: number | null; + Name: components["schemas"]["PGPMessage"]; + NameHash?: string | null; + MIMEType?: string | null; + NodeKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** Format: email */ + SignatureEmail?: string | null; + /** Format: email */ + NameSignatureEmail?: string | null; + }; FileDto: { TotalEncryptedSize: number; ContentKeyPacket: components["schemas"]["BinaryString"]; @@ -5644,8 +5956,13 @@ export interface components { NodeHashKey?: components["schemas"]["PGPMessage"] | null; XAttr?: components["schemas"]["PGPMessage"] | null; }; + /** + * @description

Can be null if the Link was deleted

See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
+ * @enum {integer} + */ + LinkState: 0 | 1 | 2; PhotoListingRelatedItemResponse: { - LinkID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id2"]; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; /** @description File name hash */ @@ -5653,6 +5970,16 @@ export interface components { /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash?: string | null; }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType2: 1 | 2 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
+ * @enum {integer} + */ + LinkState2: 0 | 1 | 2; /** * @description

1=active, 3=locked

See values descriptions
See values descriptions
ValueNameDescription
1Active
2Deleted
3Locked* Locked membership can have two reasons: * * - either the associated address was disabled/deleted, e.g. due to account deletion @@ -5666,7 +5993,7 @@ export interface components { * @description

Types: Folder - 1, File - 2}

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
* @enum {integer} */ - NodeType2: 1 | 2 | 3; + NodeType3: 1 | 2 | 3; ThumbnailURLInfoResponseDto: { /** * @deprecated @@ -5683,10 +6010,49 @@ export interface components { * @enum {integer} */ ExternalInvitationState: 1 | 2 | 4; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0List
1Grid
+ * @enum {integer} + */ + LayoutSetting2: 0 | 1; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
-4ModifiedDesc
-2SizeDesc
-1NameDesc
1NameAsc
2SizeAsc
4ModifiedAsc
+ * @enum {integer} + */ + SortSetting2: -4 | -2 | -1 | 1 | 2 | 4; + /** + * @description

Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default.

See values descriptions
See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
+ * @enum {integer} + */ + RevisionRetentionDays2: 0 | 7 | 30 | 180 | 365 | 3650; + /** + * @description

Number of days revisions should be retained if not defined by the user. Default ALWAYS used for free users, even if different value is set (premium feature).

See values descriptions
See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
+ * @enum {integer} + */ + RevisionRetentionDays3: 0 | 7 | 30 | 180 | 365 | 3650; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Disabled
1Enabled
+ * @enum {integer} + */ + DeviceSyncState2: 0 | 1; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Windows
2MacOS
3Linux
+ * @enum {integer} + */ + DeviceType2: 1 | 2 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType4: 1 | 2 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
+ * @enum {integer} + */ + LinkState3: 0 | 1 | 2; ThumbnailDto: { ThumbnailID: components["schemas"]["Id"]; - /** @enum {integer} */ - Type: 1 | 2; + Type: components["schemas"]["ThumbnailType"]; Hash: string; EncryptedSize: number; }; @@ -5776,6 +6142,8 @@ export interface operations { "application/json": { /** @description Potential codes and their meaning: * - 2501: The album does not exist. + * - 200300: Album has reached the limit of photos. + * - 2000: All main photos have to be sent with related photos. * */ Code: number; }; @@ -5854,13 +6222,223 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200300: Limit of albums per volume reached - * - 2501: a photo share does not exist for this volume - * */ - Code: number; - }; + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: Limit of albums per volume reached + * - 2501: a photo share does not exist for this volume + * */ + Code: number; + }; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes: + * - 2032 + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "post_drive-photos-volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreatePhotoShareRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetPhotoVolumeResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200001: Maximum number of volumes reached for current user + * - 2500: A volume is already active + * - 2500: Cannot create the new Photo volume. Should be migrated from current Photo stream + * - 2001: Invalid PGP message + * - 200501: Operation failed: Please retry + * - 200200: Address not found + * */ + Code: number; + }; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes: + * - 2032 + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "put_drive-photos-volumes-{volumeID}-albums-{linkID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateAlbumRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: a photo share does not exist for this volume + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes: + * - 2032 + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "delete_drive-photos-volumes-{volumeID}-albums-{linkID}": { + parameters: { + query?: { + DeleteAlbumPhotos?: number | null; + }; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200302: Album is not empty. Delete operation would result in data loss. + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-photos-migrate-legacy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 102: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProcessingResponse"]; + }; + }; + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetMigrationStatusResponseDto"]; }; }; /** @description Failed dependency */ @@ -5882,27 +6460,23 @@ export interface operations { }; }; }; - "post_drive-photos-volumes": { + "post_drive-photos-migrate-legacy": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["CreatePhotoShareRequestDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Success */ - 200: { + /** @description Accepted */ + 202: { headers: { - "x-pm-code": 1000; + "x-pm-code": 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetPhotoVolumeResponseDto"]; + "application/json": components["schemas"]["AcceptedResponse"]; }; }; /** @description Unprocessable Entity */ @@ -5913,12 +6487,8 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200001: Maximum number of volumes reached for current user - * - 2500: A volume is already active - * - 2500: Cannot create the new Photo volume. Should be migrated from current Photo stream - * - 2001: Invalid PGP message - * - 200501: Operation failed: Please retry - * - 200200: Address not found + * - 2500: Migration in progress + * - 2501: Share not found * */ Code: number; }; @@ -5947,8 +6517,9 @@ export interface operations { parameters: { query?: { AnchorID?: components["schemas"]["ListPhotosAlbumQueryParameters"]["AnchorID"]; + Sort?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Sort"]; Desc?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Desc"]; - OrderedByCaptureTime?: components["schemas"]["ListPhotosAlbumQueryParameters"]["OrderedByCaptureTime"]; + Tag?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Tag"]; }; header?: never; path: { @@ -5987,30 +6558,33 @@ export interface operations { }; }; }; - "put_drive-photos-volumes-{volumeID}-albums-{linkID}": { + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple": { parameters: { query?: never; header?: never; path: { volumeID: string; - linkID: components["schemas"]["Id"]; + linkID: string; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateAlbumRequestDto"]; + "application/json": components["schemas"]["RemovePhotosFromAlbumRequestDto"]; }; }; responses: { - /** @description Success */ + /** @description Ok */ 200: { headers: { - "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["RemovePhotoFromAlbumWithLinkIDResponseDto"][]; + }; }; }; /** @description Unprocessable Entity */ @@ -6021,27 +6595,46 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 2501: a photo share does not exist for this volume - * - 2011: Insufficient permissions + * - 2500: A volume is already active * */ Code: number; }; }; }; - /** @description Failed dependency */ - 424: { + }; + }; + "get_drive-photos-albums-shared-with-me": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SharedWithMeResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; content: { "application/json": { - /** - * @description Potential codes: - * - 2032 - * - * @enum {integer} - */ - Code: 2032; + /** @description Potential codes and their meaning: + * - 2011: Insufficient permissions + * */ + Code: number; }; }; }; @@ -6196,48 +6789,6 @@ export interface operations { }; }; }; - "get_drive-v2-checklist-get-started": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ChecklistResponseDto"]; - }; - }; - }; - }; - "post_drive-v2-checklist-get-started-seen-completed-list": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SuccessfulResponse"]; - }; - }; - }; - }; "get_drive-devices": { parameters: { query?: never; @@ -6394,7 +6945,7 @@ export interface operations { * - 2501: parent folder was not found * - 2011: the user does not have permissions to create a file in this share * - * @enum {unknown} + * @enum {integer} */ Code: 200300 | 2500 | 2501 | 2011; } | components["schemas"]["ConflictErrorResponseDto"]; @@ -6442,13 +6993,12 @@ export interface operations { }; }; }; - "get_drive-shares-{shareID}-events-{eventID}": { + "get_drive-volumes-{volumeID}-events-latest": { parameters: { query?: never; header?: never; path: { - shareID: string; - eventID: string; + volumeID: string; }; cookie?: never; }; @@ -6461,17 +7011,18 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListEventsResponseDto"]; + "application/json": components["schemas"]["LatestEventIDResponseDto"]; }; }; }; }; - "get_drive-volumes-{volumeID}-events-latest": { + "get_drive-shares-{shareID}-events-{eventID}": { parameters: { query?: never; header?: never; path: { - volumeID: string; + shareID: string; + eventID: string; }; cookie?: never; }; @@ -6484,7 +7035,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LatestEventIDResponseDto"]; + "application/json": components["schemas"]["ListEventsResponseDto"]; }; }; }; @@ -6513,13 +7064,13 @@ export interface operations { }; }; }; - "get_drive-urls-{token}-links-{linkID}-path": { + "get_drive-v2-volumes-{volumeID}-events-{eventID}": { parameters: { query?: never; header?: never; path: { - token: string; - linkID: string; + volumeID: string; + eventID: string; }; cookie?: never; }; @@ -6532,20 +7083,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ParentEncryptedLinkIDsResponseDto"]; - }; - }; - /** @description Unprocessable entity */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 2061: Invalid ID. */ - Code: number; - }; + "application/json": components["schemas"]["ListEventsV2ResponseDto"]; }; }; }; @@ -6620,7 +7158,7 @@ export interface operations { * - 200002: Storage quota exceeded * - 200301: target parent exceeded max folder depth * - * @enum {unknown} + * @enum {integer} */ Code: 200300 | 2501 | 2011 | 2000 | 200002 | 200301; }; @@ -6748,37 +7286,6 @@ export interface operations { }; }; }; - "post_drive-v2-volumes-{volumeID}-folders-{linkID}-trash_multiple": { - parameters: { - query?: never; - header?: never; - path: { - volumeID: string; - linkID: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; - }; - }; - responses: { - /** @description Ok */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; - }; - }; - }; - }; "post_drive-shares-{shareID}-folders-{linkID}-trash_multiple": { parameters: { query?: never; @@ -6985,6 +7492,36 @@ export interface operations { }; }; }; + "post_drive-v2-volumes-{volumeID}-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + }; + }; "post_drive-shares-{shareID}-links-fetch_metadata": { parameters: { query?: never; @@ -7052,18 +7589,64 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: never; + responses: { + /** @description Link */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Link: components["schemas"]["ExtendedLinkTransformer"]; + }; + }; + }; + }; + }; + "get_drive-sanitization-mhk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListMissingHashKeyResponseDto"]; + }; + }; + }; + }; + "post_drive-sanitization-mhk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMissingHashKeyRequestDto"]; + }; + }; responses: { - /** @description Link */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Link: components["schemas"]["ExtendedLinkTransformer"]; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; @@ -7133,12 +7716,12 @@ export interface operations { }; }; }; - "put_drive-v2-volumes-{volumeId}-links-{linkID}-rename": { + "put_drive-v2-volumes-{volumeID}-links-{linkID}-rename": { parameters: { query?: never; header?: never; path: { - volumeId: string; + volumeID: string; linkID: string; }; cookie?: never; @@ -7230,12 +7813,12 @@ export interface operations { "get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: { - /** @description Block index from which to fetch block list */ - FromBlockIndex?: number; /** @description Number of blocks */ - PageSize?: number; + PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + /** @description Block index from which to fetch block list */ + FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: 0 | 1; + NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; }; header?: never; path: { @@ -7296,7 +7879,7 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * */ Code: number; } | components["schemas"]["ConflictErrorResponseDto"]; @@ -7349,12 +7932,12 @@ export interface operations { "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: { - /** @description Block index from which to fetch block list */ - FromBlockIndex?: number; /** @description Number of blocks */ - PageSize?: number; + PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + /** @description Block index from which to fetch block list */ + FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: 0 | 1; + NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; }; header?: never; path: { @@ -7415,7 +7998,7 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * */ Code: number; } | components["schemas"]["ConflictErrorResponseDto"]; @@ -7510,7 +8093,7 @@ export interface operations { * - 200301: max folder depth reached * - 2500: file or folder with same name already exists * - 2501: parent folder was not found - * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200701: A document type cannot create a revision * - 200901: Photos backup is disabled for your account. Please enable it in the settings. * */ @@ -7565,7 +8148,7 @@ export interface operations { * - 200301: max folder depth reached * - 2500: file or folder with same name already exists * - 2501: parent folder was not found - * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200701: A document type cannot create a revision * - 200901: Photos backup is disabled for your account. Please enable it in the settings. * */ @@ -7649,7 +8232,7 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200700: A document type cannot create a revision * */ Code: number; @@ -7732,7 +8315,7 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200700: A document type cannot create a revision * */ Code: number; @@ -8122,6 +8705,36 @@ export interface operations { }; }; }; + "post_drive-v2-volumes-{volumeID}-trash_multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + }; + }; "get_drive-me-active": { parameters: { query?: never; @@ -8171,6 +8784,27 @@ export interface operations { }; }; }; + "get_drive-v2-checklist-get-started": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChecklistResponseDto"]; + }; + }; + }; + }; "get_drive-v2-onboarding": { parameters: { query?: never; @@ -8192,6 +8826,27 @@ export interface operations { }; }; }; + "post_drive-v2-checklist-get-started-seen-completed-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; "get_drive-entitlements": { parameters: { query?: never; @@ -8213,24 +8868,21 @@ export interface operations { }; }; }; - "get_drive-volumes-{volumeID}-photos": { + "post_drive-photos-volumes-{volumeID}-links-{linkID}-tags": { parameters: { - query?: { - /** @description Sort order */ - Desc?: 0 | 1; - PageSize?: number; - /** @description The link ID of the last photo from the previous page when requesting secondary pages */ - PreviousPageLastLinkID?: string; - /** @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) */ - MinimumCaptureTime?: number; - }; + query?: never; header?: never; path: { - volumeID: components["schemas"]["Id"]; + volumeID: string; + linkID: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["AddTagsRequestDto"]; + }; + }; responses: { /** @description Success */ 200: { @@ -8239,7 +8891,24 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PhotoListingResponse"]; + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The link or volume does not exist. + * - 2500: One of the tags is already assigned to the photo. + * - 2011: Only the owner can assign tags to photos. + * - 2000: Cannot assign favorite tag on this endpoint. Please use a dedicated favouring photos endpoint. + * */ + Code: number; + }; }; }; }; @@ -8323,6 +8992,37 @@ export interface operations { }; }; }; + "get_drive-volumes-{volumeID}-photos": { + parameters: { + query?: { + Desc?: components["schemas"]["ListPhotosParameters"]["Desc"]; + PageSize?: components["schemas"]["ListPhotosParameters"]["PageSize"]; + /** @description The link ID of the last photo from the previous page when requesting secondary pages */ + PreviousPageLastLinkID?: components["schemas"]["ListPhotosParameters"]["PreviousPageLastLinkID"]; + /** @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) */ + MinimumCaptureTime?: components["schemas"]["ListPhotosParameters"]["MinimumCaptureTime"]; + Tag?: components["schemas"]["ListPhotosParameters"]["Tag"]; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PhotoListingResponse"]; + }; + }; + }; + }; "post_drive-urls-{token}-files-{linkID}-checkAvailableHashes": { parameters: { query?: never; @@ -8401,7 +9101,7 @@ export interface operations { "application/json": { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions. - * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * */ Code: number; } | components["schemas"]["ConflictErrorResponseDto"]; @@ -8489,7 +9189,7 @@ export interface operations { * - 2501: parent folder was not found * - 2011: The current ShareURL does not have read+write permissions * - * @enum {unknown} + * @enum {integer} */ Code: 200300 | 2500 | 2501 | 2011; } | components["schemas"]["ConflictErrorResponseDto"]; @@ -8552,7 +9252,7 @@ export interface operations { * - 2500: file or folder with same name already exists * - 2501: parent folder was not found * - 2011: The current ShareURL does not have read+write permissions - * - 200003: Max file size limited to 100MB on your plan. Please upgrade. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200701: A document type cannot create a revision * - 200901: Photos backup is disabled for your account. Please enable it in the settings. * */ @@ -8693,6 +9393,43 @@ export interface operations { }; }; }; + "get_drive-urls-{token}-links-{linkID}-path": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ParentEncryptedLinkIDsResponseDto"]; + }; + }; + /** @description Unprocessable entity */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2061: Invalid ID. */ + Code: number; + }; + }; + }; + }; + }; "put_drive-urls-{token}-links-{linkID}-rename": { parameters: { query?: never; @@ -8827,6 +9564,35 @@ export interface operations { }; }; }; + "get_drive-shares-{shareID}-map": { + parameters: { + query?: { + PageSize?: number; + /** @description SessionName provided by previous response */ + SessionName?: string; + /** @description Index value of last element in previous request */ + LastIndex?: number; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LinkMapResponse"]; + }; + }; + }; + }; "get_drive-v2-shares-my-files": { parameters: { query?: never; @@ -8848,13 +9614,12 @@ export interface operations { }; }; }; - "get_drive-volumes-{volumeID}-links-{linkID}-context": { + "get_drive-shares-{shareID}": { parameters: { query?: never; header?: never; path: { - volumeID: string; - linkID: string; + shareID: string; }; cookie?: never; }; @@ -8867,7 +9632,33 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetHighestContextForDocumentResponse"]; + "application/json": components["schemas"]["BootstrapShareResponseDto"]; + }; + }; + }; + }; + "delete_drive-shares-{shareID}": { + parameters: { + query?: { + /** @description Forces the deletion of the share along with attached members and urls */ + Force?: 0 | 1; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -8876,46 +9667,36 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** - * @description 2501: Requested data does not exist or you do not have permission to access it - * - * @enum {integer} - */ - Code: 2501; + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the current user does not have admin permission on this share + * - 2005: the share still has members, a public URL or invitations attached and Force=1 has not been used */ + Code: number; }; }; }; }; }; - "post_drive-volumes-{volumeID}-shares": { + "get_drive-volumes-{volumeID}-links-{linkID}-context": { parameters: { query?: never; header?: never; path: { volumeID: string; + linkID: string; }; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["CreateShareRequestDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Share */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Share: { - /** @description Share ID */ - ID: string; - }; - }; + "application/json": components["schemas"]["GetHighestContextForDocumentResponse"]; }; }; /** @description Unprocessable Entity */ @@ -8924,29 +9705,21 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { - /** @description Potential codes and their meaning: - * - 2501: the link does not exist in the volume - * - 2011: the current user does not have admin permission on this share - * - 2001: the PGP message is not correct - * - 200601: The user has too many shares already. - * */ - Code?: number; + "application/json": { + /** + * @description 2501: Requested data does not exist or you do not have permission to access it + * + * @enum {integer} + */ + Code: 2501; }; }; }; }; }; - "get_drive-shares": { + "get_drive-sanitization-asv": { parameters: { - query?: { - /** @description Encrypted AddressID */ - AddressID?: string; - /** @description Show disabled shares as well, i.e. Shares where the ShareMemberShip for the user is non-active (locked), otherwise only return with active Membership */ - ShowAll?: 0 | 1; - /** @description Filter on Share Type */ - ShareType?: 1 | 2 | 3 | 4; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8960,23 +9733,21 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListSharesResponseDto"]; + "application/json": components["schemas"]["ListAutoRestoreVolumeRootSharesResponseDto"]; }; }; }; }; - "post_drive-shares-{shareID}-owner": { + "post_drive-sanitization-asv": { parameters: { query?: never; header?: never; - path: { - shareID: string; - }; + path?: never; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["TransferInput"]; + "application/json": components["schemas"]["LogFailedRestoreProcedureRequestDto"]; }; }; responses: { @@ -8992,19 +9763,18 @@ export interface operations { }; }; }; - "get_drive-shares-{shareID}-map": { + "get_drive-shares": { parameters: { query?: { - PageSize?: number; - /** @description SessionName provided by previous response */ - SessionName?: string; - /** @description Index value of last element in previous request. Required only if SessionName is provided */ - LastIndex?: number; + /** @description Encrypted AddressID */ + AddressID?: string; + /** @description Show disabled shares as well, i.e. Shares where the ShareMemberShip for the user is non-active (locked), otherwise only return with active Membership */ + ShowAll?: 0 | 1; + /** @description Filter on Share Type */ + ShareType?: 1 | 2 | 3 | 4; }; header?: never; - path: { - shareID: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -9016,12 +9786,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LinkMapResponse"]; + "application/json": components["schemas"]["ListSharesResponseDto"]; }; }; }; }; - "get_drive-shares-{shareID}": { + "post_drive-shares-{shareID}-owner": { parameters: { query?: never; header?: never; @@ -9030,33 +9800,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BootstrapShareResponseDto"]; - }; - }; - }; - }; - "delete_drive-shares-{shareID}": { - parameters: { - query?: { - /** @description Forces the deletion of the share along with attached members and urls */ - Force?: 0 | 1; - }; - header?: never; - path: { - shareID: string; + requestBody?: { + content: { + "application/json": components["schemas"]["TransferInput"]; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Success */ 200: { @@ -9068,20 +9816,6 @@ export interface operations { "application/json": components["schemas"]["SuccessfulResponse"]; }; }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 2011: the current user does not have admin permission on this share - * - 2005: the share still has members, a public URL or invitations attached and Force=1 has not been used */ - Code: number; - }; - }; - }; }; }; "post_drive-migrations-shareaccesswithnode": { @@ -9142,24 +9876,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Share URL */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - /** @example -----BEGIN PGP SIGNED MESSAGE-----... */ - Modulus: string; - ServerEphemeral: string; - UrlPasswordSalt: string; - SRPSession: string; - /** @example 4 */ - Version: number; - /** @example 2 */ - Flags: number; - }; + "application/json": components["schemas"]["InitSRPSessionResponseDto"]; }; }; 422: components["responses"]["ProtonErrorResponse"]; @@ -9281,12 +10005,12 @@ export interface operations { "get_drive-urls-{token}-files-{linkID}": { parameters: { query?: { - /** @description Block index from which to fetch block list */ - FromBlockIndex?: number; /** @description Number of blocks */ - PageSize?: number; + PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + /** @description Block index from which to fetch block list */ + FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: 0 | 1; + NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; }; header?: never; path: { @@ -9327,38 +10051,6 @@ export interface operations { "application/json": components["schemas"]["GetSharedFileInfoRequestDto"]; }; }; - responses: { - /** @description Share URL */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - /** @description SRP server proof, base64 encoded. */ - ServerProof: string; - Payload: components["schemas"]["ShareURLDownloadTransformer"]; - }; - }; - }; - 422: components["responses"]["ProtonErrorResponse"]; - }; - }; - "post_drive-urls-{token}-security": { - parameters: { - query?: never; - header?: never; - path: { - token: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["SecurityRequestDto"]; - }; - }; responses: { /** @description Success */ 200: { @@ -9367,18 +10059,10 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SecurityResponseDto"]; - }; - }; - /** @description Code 2028 if feature is disabled, rate-limited or blocked because of abuse. Code 9001 for HV captcha. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProtonError"]; + "application/json": components["schemas"]["GetSharedFileInfoResponseDto"]; }; }; + 422: components["responses"]["ProtonErrorResponse"]; }; }; "get_drive-shares-{shareID}-urls": { @@ -9532,6 +10216,55 @@ export interface operations { }; }; }; + "post_drive-volumes-{volumeID}-shares": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateShareRequestDto"]; + }; + }; + responses: { + /** @description Share */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Share: { + /** @description Share ID */ + ID: string; + }; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link does not exist in the volume + * - 2011: the current user does not have admin permission on this share + * - 2001: the PGP message is not correct + * - 200601: The user has too many shares already. + * */ + Code?: number; + }; + }; + }; + }; + }; "get_drive-v2-volumes-{volumeID}-shares": { parameters: { query?: { @@ -9575,7 +10308,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SharedWithMeResponseDto"]; + "application/json": components["schemas"]["SharedWithMeResponseDto2"]; }; }; }; @@ -9737,6 +10470,8 @@ export interface operations { * - 2500: an external invitation for this user on this share already exists * - 2026: trying to grant permissions you do not have to a new member * - 2001: the inviter address does not belong to a Proton account or does not belong to the current user + * - 200502: external invitation signature is invalid + * - 200600: maximum number of invitations and members reached for current share * - 2008: inviter email is not the same as the one from the context share */ Code: number; }; @@ -9845,6 +10580,7 @@ export interface operations { * - 2032: sharing is temporarily disabled. * - 200602: The user has joined too many shares already. * - 200201: the user is already member of a share in this volume with another address + * - 200502: session key signature is invalid * - 2000: the address or address key couldn't be found to the invitee email address and user * */ Code: number; @@ -10349,6 +11085,42 @@ export interface operations { }; }; }; + "post_drive-urls-{token}-security": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SecurityRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecurityResponseDto"]; + }; + }; + /** @description Code 2028 if feature is disabled, rate-limited or blocked because of abuse. Code 9001 for HV captcha. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + }; + }; "post_drive-volumes-{volumeID}-thumbnails": { parameters: { query?: never; diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index 5cca3882..ec64af25 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -7,7 +7,7 @@ type GetCoreLatestEventResponse = corePaths['/core/{_version}/events/latest']['g type GetCoreEventResponse = corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json']; type GetVolumeLatestEventResponse = drivePaths['/drive/volumes/{volumeID}/events/latest']['get']['responses']['200']['content']['application/json']; -type GetVokumeEventResponse = drivePaths['/drive/volumes/{volumeID}/events/{eventID}']['get']['responses']['200']['content']['application/json']; +type GetVokumeEventResponse = drivePaths['/drive/v2/volumes/{volumeID}/events/{eventID}']['get']['responses']['200']['content']['application/json']; const VOLUME_EVENT_TYPE_MAP = { 0: DriveEventType.NodeDeleted, @@ -34,7 +34,7 @@ export class EventsAPIService { } async getCoreEvents(eventId: string): Promise { - // TODO: Switch to v6 endpoint. + // TODO: Switch to v6 endpoint: DriveShareRefresh doesnt seem to be part of it. const result = await this.apiService.get(`/core/v5/events/${eventId}?NoMetaData=1`); const events: DriveEvent[] = result.DriveShareRefresh?.Action === 2 ? [ { @@ -55,19 +55,17 @@ export class EventsAPIService { return result.EventID; } - async getVolumeEvents(volumeId: string, eventId: string): Promise { - // TODO: Switch to the new API once it's available - const result = await this.apiService.get(`/drive/volumes/${volumeId}/events/${eventId}`); + async getVolumeEvents(volumeId: string, eventId: string, isOwnVolume = false): Promise { + const result = await this.apiService.get(`/drive/v2/volumes/${volumeId}/events/${eventId}`); return { lastEventId: result.EventID, - more: result.More === 1, - refresh: result.Refresh === 1, + more: result.More, + refresh: result.Refresh, events: result.Events.map((event): DriveEvent => { const type = VOLUME_EVENT_TYPE_MAP[event.EventType]; - const link = event.Link as Extract; const uids = { nodeUid: makeNodeUid(volumeId, event.Link.LinkID), - parentNodeUid: makeNodeUid(volumeId, link.ParentLinkID as string), + parentNodeUid: makeNodeUid(volumeId, event.Link.ParentLinkID as string), } // VOLUME_EVENT_TYPE_MAP will never return this event type. // It is here to satisfy the type checker. It is safe to do. @@ -76,21 +74,12 @@ export class EventsAPIService { type, }; } - if (type === DriveEventType.NodeDeleted) { - return { - type, - ...uids, - isTrashed: !!link.Trashed, - isShared: link.SharingDetails?.ShareID !== undefined, - isOwnVolume: false, // TODO - } - } return { type, ...uids, - isTrashed: !!link.Trashed, - isShared: link.SharingDetails?.ShareID !== undefined, - isOwnVolume: false, // TODO + isTrashed: event.Link.IsTrashed, + isShared: event.Link.IsShared, + isOwnVolume, }; }), }; diff --git a/js/sdk/src/internal/events/cache.test.ts b/js/sdk/src/internal/events/cache.test.ts index 5e48bbdb..040f0e61 100644 --- a/js/sdk/src/internal/events/cache.test.ts +++ b/js/sdk/src/internal/events/cache.test.ts @@ -12,23 +12,23 @@ describe("EventsCache", () => { it("should store and retrieve last event ID", async () => { const key = "volume1"; - await cache.setLastEventId(key, "eventId1", 0); - await cache.setLastEventId(key, "eventId2", 0); + await cache.setLastEventId(key, { lastEventId: "eventId1", pollingIntervalInSeconds: 0, isOwnVolume: true }); + await cache.setLastEventId(key, { lastEventId: "eventId2", pollingIntervalInSeconds: 0, isOwnVolume: true }); const result = await cache.getLastEventId(key); expect(result).toBe("eventId2"); }); it("should store and retrieve polling interval", async () => { const key = "volume1"; - await cache.setLastEventId(key, "lastEventId", 10); - await cache.setLastEventId(key, "lastEventId", 20); + await cache.setLastEventId(key, { lastEventId: "lastEventId", pollingIntervalInSeconds: 10, isOwnVolume: true }); + await cache.setLastEventId(key, { lastEventId: "lastEventId", pollingIntervalInSeconds: 20, isOwnVolume: true }); const result = await cache.getPollingIntervalInSeconds(key); expect(result).toBe(20); }); it("should store and retrieve subscribed volume IDs", async () => { - await cache.setLastEventId("volume1", "lastEventId", 0); - await cache.setLastEventId("volume2", "lastEventId", 0); + await cache.setLastEventId("volume1", { lastEventId: "lastEventId", pollingIntervalInSeconds: 0, isOwnVolume: true }); + await cache.setLastEventId("volume2", { lastEventId: "lastEventId", pollingIntervalInSeconds: 0, isOwnVolume: true }); const result = await cache.getSubscribedVolumeIds(); expect(result).toStrictEqual(["volume1", "volume2"]); }); diff --git a/js/sdk/src/internal/events/cache.ts b/js/sdk/src/internal/events/cache.ts index 40302908..7afc3f39 100644 --- a/js/sdk/src/internal/events/cache.ts +++ b/js/sdk/src/internal/events/cache.ts @@ -2,12 +2,15 @@ import { ProtonDriveEntitiesCache } from "../../interface"; type CachedEventsData = { // Key is either a volume ID for volume events or 'core' for core events. - [key: string]: { - lastEventId: string; - pollingIntervalInSeconds: number; - } + [key: string]: EventsData; }; +interface EventsData { + lastEventId: string; + pollingIntervalInSeconds: number; + isOwnVolume: boolean; +} + /** * Provides caching for events IDs. */ @@ -23,12 +26,9 @@ export class EventsCache { this.driveCache = driveCache; } - async setLastEventId(volumeIdOrCore: string, lastEventId: string, pollingIntervalInSeconds: number): Promise { + async setLastEventId(volumeIdOrCore: string, eventsData: EventsData): Promise { const events = await this.getEvents(); - events[volumeIdOrCore] = { - lastEventId, - pollingIntervalInSeconds, - } + events[volumeIdOrCore] = eventsData; await this.cacheEvents(events); } @@ -46,6 +46,13 @@ export class EventsCache { } } + async isOwnVolume(volumeIdOrCore: string): Promise { + const events = await this.getEvents(); + if (events[volumeIdOrCore]) { + return events[volumeIdOrCore].isOwnVolume; + } + } + async getSubscribedVolumeIds(): Promise { const events = await this.getEvents(); return Object.keys(events).filter((volumeIdOrCore) => volumeIdOrCore !== 'core'); diff --git a/js/sdk/src/internal/events/coreEventManager.ts b/js/sdk/src/internal/events/coreEventManager.ts index 695c3a39..ddda1189 100644 --- a/js/sdk/src/internal/events/coreEventManager.ts +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -25,7 +25,11 @@ export class CoreEventManager { logger, () => this.getLastEventId(), (eventId) => this.apiService.getCoreEvents(eventId), - (lastEventId) => this.cache.setLastEventId('core', lastEventId, this.manager.pollingIntervalInSeconds), + (lastEventId) => this.cache.setLastEventId('core', { + lastEventId, + pollingIntervalInSeconds: this.manager.pollingIntervalInSeconds, + isOwnVolume: false, + }), ); } diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index dae49584..3c6e7f2b 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -69,7 +69,7 @@ export class DriveEventsService { if (this.volumesEvents[volumeId]) { return; } - const volumeEvents = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId); + const volumeEvents = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId, isOwnVolume); this.volumesEvents[volumeId] = volumeEvents; // FIXME: Use dynamic algorithm to determine polling interval for non-own volumes. @@ -83,7 +83,8 @@ export class DriveEventsService { private async loadSubscribedVolumeEventServices() { for (const volumeId of await this.cache.getSubscribedVolumeIds()) { if (!this.volumesEvents[volumeId]) { - this.volumesEvents[volumeId] = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId); + const isOwnVolume = await this.cache.isOwnVolume(volumeId) || false; + this.volumesEvents[volumeId] = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId, isOwnVolume); } } } diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index d90cf350..58c913d5 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -12,15 +12,19 @@ import { EventManager } from "./eventManager"; export class VolumeEventManager { private manager: EventManager; - constructor(logger: Logger, private apiService: EventsAPIService, private cache: EventsCache, private volumeId: string) { + constructor(logger: Logger, private apiService: EventsAPIService, private cache: EventsCache, private volumeId: string, isOwnVolume: boolean) { this.apiService = apiService; this.volumeId = volumeId; this.manager = new EventManager( logger, () => this.getLastEventId(), - (eventId) => this.apiService.getVolumeEvents(volumeId, eventId), - (lastEventId) => this.cache.setLastEventId(volumeId, lastEventId, this.manager.pollingIntervalInSeconds), + (eventId) => this.apiService.getVolumeEvents(volumeId, eventId, isOwnVolume), + (lastEventId) => this.cache.setLastEventId(volumeId, { + lastEventId, + pollingIntervalInSeconds: this.manager.pollingIntervalInSeconds, + isOwnVolume + }), ); this.cache.getPollingIntervalInSeconds(volumeId) .then((pollingIntervalInSeconds) => { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 189c3944..06355c8e 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -14,14 +14,14 @@ type GetChildrenResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders/{lin type GetTrashedNodesResponse = drivePaths['/drive/volumes/{volumeID}/trash']['get']['responses']['200']['content']['application/json']; -type PutRenameNodeRequest = Extract['content']['application/json']; -type PutRenameNodeResponse = drivePaths['/drive/v2/volumes/{volumeId}/links/{linkID}/rename']['put']['responses']['200']['content']['application/json']; +type PutRenameNodeRequest = Extract['content']['application/json']; +type PutRenameNodeResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/rename']['put']['responses']['200']['content']['application/json']; type PutMoveNodeRequest = Extract['content']['application/json']; type PutMoveNodeResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/move']['put']['responses']['200']['content']['application/json']; -type PostTrashNodesRequest = Extract['content']['application/json']; -type PostTrashNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders/{linkID}/trash_multiple']['post']['responses']['200']['content']['application/json']; +type PostTrashNodesRequest = Extract['content']['application/json']; +type PostTrashNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['responses']['200']['content']['application/json']; type PutRestoreNodesRequest = Extract['content']['application/json']; type PutRestoreNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/restore_multiple']['put']['responses']['200']['content']['application/json']; From 8d57d54a0bd5201172ce30893c0ced201146e99c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 17 Mar 2025 06:44:30 +0000 Subject: [PATCH 048/791] Use primary key index in address object --- js/sdk/src/interface/account.ts | 5 +---- js/sdk/src/internal/shares/manager.test.ts | 6 +++--- js/sdk/src/internal/shares/manager.ts | 9 +++++---- js/sdk/src/internal/sharing/cryptoService.ts | 6 ++++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index 006b1d8d..a7f21470 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -10,10 +10,7 @@ export interface ProtonDriveAccount { export interface ProtonDriveAccountAddress { email: string, addressId: string, - primaryKey: { - id: string, - key: PrivateKey, - }, + primaryKeyIndex: number, keys: { id: string, key: PrivateKey, diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts index 051aad0d..cd21220b 100644 --- a/js/sdk/src/internal/shares/manager.test.ts +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -86,7 +86,7 @@ describe("SharesManager", () => { it("should create volume when My files section doesn't exist", async () => { apiService.getMyFiles = jest.fn().mockRejectedValue(new NotFoundAPIError("no active volume", 0)); - account.getOwnPrimaryAddress = jest.fn().mockResolvedValue({ primaryKey: { key: "addressKey" } }); + account.getOwnPrimaryAddress = jest.fn().mockResolvedValue({ primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); cryptoService.generateVolumeBootstrap = jest.fn().mockResolvedValue({ shareKey: { encrypted: "encrypted share key", @@ -141,7 +141,7 @@ describe("SharesManager", () => { describe("getVolumeEmailKey", () => { it("should return cached volume email key", async () => { cache.getVolume = jest.fn().mockResolvedValue({ addressId: "addressId" }); - account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKey: { key: "addressKey" } }); + account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); const result = await manager.getVolumeEmailKey("volumeId"); @@ -163,7 +163,7 @@ describe("SharesManager", () => { cache.getVolume = jest.fn().mockRejectedValue(new Error('not found')); apiService.getVolume = jest.fn().mockResolvedValue({ shareId: "shareId" }); apiService.getRootShare = jest.fn().mockResolvedValue(share); - account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKey: { key: "addressKey" } }); + account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); const result = await manager.getVolumeEmailKey("volumeId"); diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 9a3bb6f4..697e56c9 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -92,11 +92,12 @@ export class SharesManager { * @throws If the volume cannot be created (e.g., one already exists). */ private async createVolume(): Promise { - const { addressId, primaryKey } = await this.account.getOwnPrimaryAddress(); + const address = await this.account.getOwnPrimaryAddress(); + const primaryKey = address.keys[address.primaryKeyIndex]; const bootstrap = await this.cryptoService.generateVolumeBootstrap(primaryKey.key); const myFilesIds = await this.apiService.createVolume( { - addressId, + addressId: address.addressId, addressKeyId: primaryKey.id, ...bootstrap.shareKey.encrypted, }, @@ -138,7 +139,7 @@ export class SharesManager { return { email: address.email, addressId, - addressKey: address.primaryKey.key, + addressKey: address.keys[address.primaryKeyIndex].key, }; } catch {} @@ -159,7 +160,7 @@ export class SharesManager { return { email: address.email, addressId: share.addressId, - addressKey: address.primaryKey.key, + addressKey: address.keys[address.primaryKeyIndex].key, }; } diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index cec36702..b3bdcd29 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -138,7 +138,8 @@ export class SharingCryptoService { * Decrypts and verifies an invitation and node's name. */ async decryptInvitationWithNode(encryptedInvitation: EncryptedInvitationWithNode): Promise { - const { primaryKey: { key: inviteeKey } } = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); + const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); + const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key; const shareKey = await this.driveCrypto.decryptUnsignedKey( encryptedInvitation.share.armoredKey, @@ -191,7 +192,8 @@ export class SharingCryptoService { async acceptInvitation(encryptedInvitation: EncryptedInvitationWithNode): Promise<{ base64SessionKeySignature: string, }> { - const { primaryKey: { key: inviteeKey } } = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); + const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); + const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key; const result = await this.driveCrypto.acceptInvitation( encryptedInvitation.base64KeyPacket, inviteeKey, From 772c41bb8abc482a7b04fbcdd2079e4c4aa1b6f3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 19 Mar 2025 13:24:56 +0000 Subject: [PATCH 049/791] Translate error messages --- js/sdk/src/internal/nodes/apiService.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 06355c8e..d47325e4 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -379,7 +379,7 @@ function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: responses.forEach((response) => { if (!response.Response.Code || !isCodeOk(response.Response.Code) || response.Response.Error) { const nodeUid = makeNodeUid(volumeId, response.LinkID); - errors.set(nodeUid, response.Response.Error || `Unknown error ${response.Response.Code}`); + errors.set(nodeUid, response.Response.Error || c('Error').t`Unknown error ${response.Response.Code}`); } }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 7b0f835a..c575939a 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -171,7 +171,7 @@ export class NodesAccess { this.logger.warn(`Node name validation failed: ${error instanceof Error ? error.message : error}`); unparsedNode.name = resultError({ name: unparsedNode.name.value, - error: error instanceof Error ? error.message : 'Unknown error', + error: error instanceof Error ? error.message : c('Error').t`Unknown error`, }); } } From 8496dbfa8a5999066d5a9ef839b171be34e73508 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Mar 2025 09:13:14 +0000 Subject: [PATCH 050/791] Implement getting root node of My Files --- cs/Directory.Build.props | 2 + .../Proton.Drive.Sdk/AccountClientAdapter.cs | 12 +- .../Proton.Drive.Sdk/Api/DriveApiClients.cs | 6 +- .../Proton.Drive.Sdk/Api/IDriveApiClients.cs | 6 +- .../Api/Links/FileProperties.cs | 12 + .../Proton.Drive.Sdk/Api/Links/FolderDto.cs | 13 + .../Api/Links/FolderProperties.cs | 10 + .../Api/Links/ILinksApiClient.cs | 8 + .../Api/Links/LinkDetailsDto.cs | 13 + .../Api/Links/LinkDetailsRequest.cs | 9 + .../Api/Links/LinkDetailsResponse.cs | 8 + .../src/Proton.Drive.Sdk/Api/Links/LinkDto.cs | 54 ++++ .../src/Proton.Drive.Sdk/Api/Links/LinkId.cs | 25 ++ .../Proton.Drive.Sdk/Api/Links/LinkState.cs | 32 +++ .../Proton.Drive.Sdk/Api/Links/LinkType.cs | 7 + .../Api/Links/LinksApiClient.cs | 18 ++ .../Proton.Drive.Sdk/Api/Links/RevisionDto.cs | 33 +++ .../Api/Links/ThumbnailDto.cs | 16 ++ .../Api/Links/ThumbnailType.cs | 7 + .../Api/Shares/ISharesApiClient.cs | 7 + .../Api/Shares/MemberPermissions.cs | 9 + .../Api/Shares/MemberState.cs | 7 + .../Proton.Drive.Sdk/Api/Shares/ShareDto.cs | 23 ++ .../Proton.Drive.Sdk/Api/Shares/ShareId.cs | 25 ++ .../Api/Shares/ShareMembership.cs | 44 +++ .../Api/Shares/ShareResponse.cs | 55 ++++ .../Api/Shares/ShareResponseV2.cs | 22 ++ .../Proton.Drive.Sdk/Api/Shares/ShareState.cs | 8 + .../Proton.Drive.Sdk/Api/Shares/ShareType.cs | 9 + .../Api/Shares/ShareVolumeDto.cs | 12 + .../Api/Shares/SharesApiClient.cs | 23 ++ .../Api/Volumes/IVolumesApiClient.cs | 6 + .../Api/Volumes/VolumeCreationParameters.cs | 27 ++ .../Volumes}/VolumeCreationResponse.cs | 2 +- .../{Volumes/Api => Api/Volumes}/VolumeDto.cs | 5 +- .../Volumes/VolumeRootDto.cs} | 6 +- .../Api => Api/Volumes}/VolumesApiClient.cs | 4 +- cs/sdk/src/Proton.Drive.Sdk/Author.cs | 8 + cs/sdk/src/Proton.Drive.Sdk/BatchLoader.cs | 62 ++++ .../Caching/DriveEntityCache.cs | 64 ++++- .../Caching/DriveSecretCache.cs | 35 +-- .../Caching/IDriveEntityCache.cs | 14 +- .../Caching/IDriveSecretCache.cs | 10 +- .../Cryptography/CryptoGenerator.cs | 11 +- cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs | 2 + cs/sdk/src/Proton.Drive.Sdk/LinkId.cs | 11 - cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs | 20 -- .../Proton.Drive.Sdk/Nodes/DecryptionError.cs | 12 + .../Nodes/DecryptionException.cs | 26 ++ .../Nodes/DecryptionResult.cs | 34 +++ cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs | 6 + .../src/Proton.Drive.Sdk/Nodes/FileSecrets.cs | 8 + .../src/Proton.Drive.Sdk/Nodes/FolderNode.cs | 5 + .../Proton.Drive.Sdk/Nodes/FolderSecrets.cs | 6 + .../Nodes/InvalidNameError.cs | 7 + cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 22 ++ .../src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs | 271 ++++++++++++++++++ .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 112 ++++++++ .../src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs | 10 + .../src/Proton.Drive.Sdk/Nodes/NodeState.cs | 8 + cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs | 48 ++++ .../Proton.Drive.Sdk/Nodes/RevisionState.cs | 8 + .../Nodes/SessionKeyAndData.cs | 8 + .../Nodes/SignatureVerificationError.cs | 7 + .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 6 + .../DriveApiSerializerContext.cs | 29 +- .../DriveEntitiesSerializerContext.cs | 22 ++ .../DriveEntitySerializerContext.cs | 17 -- .../DriveSecretsSerializerContext.cs | 20 ++ .../Serialization/NodeUidConverter.cs | 18 ++ .../PgpSessionKeyJsonConverter.cs | 20 ++ cs/sdk/src/Proton.Drive.Sdk/ShareId.cs | 11 - cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs | 10 + .../Shares/ShareOperations.cs | 45 +++ .../Volumes/Api/IVolumesApiClient.cs | 6 - .../Volumes/Api/VolumeCreationParameters.cs | 30 -- cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs | 10 +- .../src/Proton.Drive.Sdk/Volumes/VolumeId.cs | 20 +- .../Volumes/VolumeOperations.cs | 82 ++++-- cs/sdk/src/Proton.Sdk/Addresses/Address.cs | 2 - .../Proton.Sdk/Addresses/AddressExtensions.cs | 9 + cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs | 20 +- cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs | 4 +- .../src/Proton.Sdk/Addresses/AddressKeyId.cs | 20 +- .../Proton.Sdk/Addresses/AddressOperations.cs | 59 ++-- .../src/Proton.Sdk/Api/AccountApiClients.cs | 6 +- .../Api => Api/Addresses}/AddressDto.cs | 3 +- .../Addresses}/AddressKeyCapabilities.cs | 2 +- .../Api => Api/Addresses}/AddressKeyDto.cs | 4 +- .../Addresses}/AddressListResponse.cs | 2 +- .../Api => Api/Addresses}/AddressResponse.cs | 2 +- .../Addresses}/AddressesApiClient.cs | 5 +- .../Addresses}/IAddressesApiClient.cs | 4 +- .../AuthenticationApiClient.cs | 4 +- .../Authentication}/AuthenticationRequest.cs | 2 +- .../Authentication}/AuthenticationResponse.cs | 3 +- .../IAuthenticationApiClient.cs | 3 +- .../Authentication}/ModulusResponse.cs | 2 +- .../Authentication}/ScopesResponse.cs | 2 +- .../Authentication}/SecondFactorParameters.cs | 2 +- .../SecondFactorValidationRequest.cs | 2 +- .../SessionInitiationRequest.cs | 2 +- .../SessionInitiationResponse.cs | 2 +- .../Authentication}/SessionRefreshRequest.cs | 2 +- .../Authentication}/SessionRefreshResponse.cs | 3 +- .../Api => Api/Events}/AddressEvent.cs | 4 +- .../{Events/Api => Api/Events}/EventAction.cs | 2 +- .../Api => Api/Events}/EventListResponse.cs | 3 +- .../Api => Api/Events}/EventsApiClient.cs | 5 +- .../Api => Api/Events}/EventsRefreshMask.cs | 2 +- .../Api => Api/Events}/LatestEventResponse.cs | 3 +- .../src/Proton.Sdk/Api/IAccountApiClients.cs | 6 +- .../src/Proton.Sdk/Api/IApiClientFactory.cs | 8 +- .../Keys}/AddressPublicKeyListResponse.cs | 3 +- .../{Keys/Api => Api/Keys}/IKeysApiClient.cs | 2 +- .../{Keys/Api => Api/Keys}/KeySalt.cs | 2 +- .../Api => Api/Keys}/KeySaltListResponse.cs | 2 +- .../{Keys/Api => Api/Keys}/KeysApiClient.cs | 2 +- .../{Keys/Api => Api/Keys}/PublicKeyEntry.cs | 2 +- .../Api => Api/Keys}/PublicKeyListAddress.cs | 2 +- .../{Keys/Api => Api/Keys}/PublicKeyStatus.cs | 2 +- .../Api => Api/Users}/IUsersApiClient.cs | 2 +- .../{Users/Api => Api/Users}/Subscriptions.cs | 2 +- .../Api/User.cs => Api/Users/UserDto.cs} | 7 +- .../UserKey.cs => Api/Users/UserKeyDto.cs} | 5 +- .../{Users/Api => Api/Users}/UserResponse.cs | 4 +- .../Api => Api/Users}/UsersApiClient.cs | 2 +- .../Proton.Sdk/Authentication/SessionId.cs | 20 +- .../Authentication/TokenCredential.cs | 2 +- .../Proton.Sdk/Caching/AccountEntityCache.cs | 8 +- cs/sdk/src/Proton.Sdk/Events/EventId.cs | 20 +- .../src/Proton.Sdk/Http/HttpApiCallBuilder.cs | 23 +- cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 42 +-- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 4 +- .../ProtonClientConfigurationExtensions.cs | 6 +- cs/sdk/src/Proton.Sdk/ResultExtensions.cs | 9 + cs/sdk/src/Proton.Sdk/Result{T,TError}.cs | 29 ++ cs/sdk/src/Proton.Sdk/Result{TError}.cs | 33 +++ .../AccountEntitiesSerializerContext.cs | 8 + .../AccountEntitySerializerContext.cs | 21 -- .../ForgivingBytesToHexJsonConverter.cs | 2 +- .../src/Proton.Sdk/Serialization/IStrongId.cs | 6 +- .../ProtonApiSerializerContext.cs | 12 +- .../Serialization/ResultJsonConverter.cs | 43 +++ .../Serialization/SecretsSerializerContext.cs | 3 + .../Serialization/SerializableResult.cs | 10 + .../Serialization/StrongIdJsonConverter.cs | 2 +- cs/sdk/src/Proton.Sdk/Users/UserId.cs | 20 +- cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs | 20 +- 149 files changed, 1980 insertions(+), 372 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileProperties.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderProperties.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailType.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberPermissions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberState.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembership.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponseV2.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareState.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareType.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationParameters.cs rename cs/sdk/src/Proton.Drive.Sdk/{Volumes/Api => Api/Volumes}/VolumeCreationResponse.cs (78%) rename cs/sdk/src/Proton.Drive.Sdk/{Volumes/Api => Api/Volumes}/VolumeDto.cs (72%) rename cs/sdk/src/Proton.Drive.Sdk/{Volumes/Api/VolumeRoot.cs => Api/Volumes/VolumeRootDto.cs} (59%) rename cs/sdk/src/Proton.Drive.Sdk/{Volumes/Api => Api/Volumes}/VolumesApiClient.cs (72%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Author.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/BatchLoader.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/LinkId.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionResult.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeState.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionState.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/SessionKeyAndData.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitySerializerContext.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/NodeUidConverter.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/ShareId.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/IVolumesApiClient.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationParameters.cs create mode 100644 cs/sdk/src/Proton.Sdk/Addresses/AddressExtensions.cs rename cs/sdk/src/Proton.Sdk/{Addresses/Api => Api/Addresses}/AddressDto.cs (85%) rename cs/sdk/src/Proton.Sdk/{Addresses/Api => Api/Addresses}/AddressKeyCapabilities.cs (77%) rename cs/sdk/src/Proton.Sdk/{Addresses/Api => Api/Addresses}/AddressKeyDto.cs (89%) rename cs/sdk/src/Proton.Sdk/{Addresses/Api => Api/Addresses}/AddressListResponse.cs (81%) rename cs/sdk/src/Proton.Sdk/{Addresses/Api => Api/Addresses}/AddressResponse.cs (79%) rename cs/sdk/src/Proton.Sdk/{Addresses/Api => Api/Addresses}/AddressesApiClient.cs (89%) rename cs/sdk/src/Proton.Sdk/{Addresses/Api => Api/Addresses}/IAddressesApiClient.cs (76%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/AuthenticationApiClient.cs (98%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/AuthenticationRequest.cs (91%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/AuthenticationResponse.cs (92%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/IAuthenticationApiClient.cs (93%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/ModulusResponse.cs (86%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/ScopesResponse.cs (78%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/SecondFactorParameters.cs (86%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/SecondFactorValidationRequest.cs (84%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/SessionInitiationRequest.cs (71%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/SessionInitiationResponse.cs (92%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/SessionRefreshRequest.cs (91%) rename cs/sdk/src/Proton.Sdk/{Authentication/Api => Api/Authentication}/SessionRefreshResponse.cs (85%) rename cs/sdk/src/Proton.Sdk/{Events/Api => Api/Events}/AddressEvent.cs (81%) rename cs/sdk/src/Proton.Sdk/{Events/Api => Api/Events}/EventAction.cs (73%) rename cs/sdk/src/Proton.Sdk/{Events/Api => Api/Events}/EventListResponse.cs (91%) rename cs/sdk/src/Proton.Sdk/{Events/Api => Api/Events}/EventsApiClient.cs (91%) rename cs/sdk/src/Proton.Sdk/{Events/Api => Api/Events}/EventsRefreshMask.cs (73%) rename cs/sdk/src/Proton.Sdk/{Events/Api => Api/Events}/LatestEventResponse.cs (78%) rename cs/sdk/src/Proton.Sdk/{Keys/Api => Api/Keys}/AddressPublicKeyListResponse.cs (90%) rename cs/sdk/src/Proton.Sdk/{Keys/Api => Api/Keys}/IKeysApiClient.cs (87%) rename cs/sdk/src/Proton.Sdk/{Keys/Api => Api/Keys}/KeySalt.cs (88%) rename cs/sdk/src/Proton.Sdk/{Keys/Api => Api/Keys}/KeySaltListResponse.cs (83%) rename cs/sdk/src/Proton.Sdk/{Keys/Api => Api/Keys}/KeysApiClient.cs (96%) rename cs/sdk/src/Proton.Sdk/{Keys/Api => Api/Keys}/PublicKeyEntry.cs (89%) rename cs/sdk/src/Proton.Sdk/{Keys/Api => Api/Keys}/PublicKeyListAddress.cs (77%) rename cs/sdk/src/Proton.Sdk/{Keys/Api => Api/Keys}/PublicKeyStatus.cs (73%) rename cs/sdk/src/Proton.Sdk/{Users/Api => Api/Users}/IUsersApiClient.cs (78%) rename cs/sdk/src/Proton.Sdk/{Users/Api => Api/Users}/Subscriptions.cs (70%) rename cs/sdk/src/Proton.Sdk/{Users/Api/User.cs => Api/Users/UserDto.cs} (85%) rename cs/sdk/src/Proton.Sdk/{Users/Api/UserKey.cs => Api/Users/UserKeyDto.cs} (86%) rename cs/sdk/src/Proton.Sdk/{Users/Api => Api/Users}/UserResponse.cs (50%) rename cs/sdk/src/Proton.Sdk/{Users/Api => Api/Users}/UsersApiClient.cs (93%) create mode 100644 cs/sdk/src/Proton.Sdk/ResultExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Result{T,TError}.cs create mode 100644 cs/sdk/src/Proton.Sdk/Result{TError}.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Serialization/AccountEntitySerializerContext.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 7206c0c4..c62ac0c6 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -20,6 +20,8 @@ true true + false + lib diff --git a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs index 6c8cf762..1b4449c3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs @@ -10,11 +10,21 @@ internal sealed class AccountClientAdapter(ProtonApiSession session) : IAccountC public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) { - return _client.GetDefaultAddressAsync(cancellationToken); + return _client.GetCurrentUserDefaultAddressAsync(cancellationToken); } public ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken) { return _client.GetAddressPrimaryKeyAsync(addressId, cancellationToken); } + + public ValueTask> GetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + { + return _client.GetAddressKeysAsync(addressId, cancellationToken); + } + + public ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) + { + return _client.GetAddressPublicKeysAsync(emailAddress, cancellationToken); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs index e8d8e9ae..665060b9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -1,8 +1,12 @@ -using Proton.Drive.Sdk.Volumes.Api; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Volumes; namespace Proton.Drive.Sdk.Api; internal sealed class DriveApiClients(HttpClient httpClient) : IDriveApiClients { public IVolumesApiClient Volumes { get; } = new VolumesApiClient(httpClient); + public ISharesApiClient Shares { get; } = new SharesApiClient(httpClient); + public ILinksApiClient Links { get; } = new LinksApiClient(httpClient); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs index 0177408f..f3e9725e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs @@ -1,8 +1,12 @@ -using Proton.Drive.Sdk.Volumes.Api; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Volumes; namespace Proton.Drive.Sdk.Api; internal interface IDriveApiClients { IVolumesApiClient Volumes { get; } + ISharesApiClient Shares { get; } + ILinksApiClient Links { get; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileProperties.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileProperties.cs new file mode 100644 index 00000000..5a0ea278 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileProperties.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Links; + +internal readonly struct FileProperties +{ + public required ReadOnlyMemory ContentKeyPacket { get; init; } + + public PgpArmoredSignature? ContentKeyPacketSignature { get; init; } + + public required RevisionDto? ActiveRevision { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderDto.cs new file mode 100644 index 00000000..5b15c125 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderDto.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class FolderDto +{ + [JsonPropertyName("NodeHashKey")] + public required PgpArmoredMessage HashKey { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderProperties.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderProperties.cs new file mode 100644 index 00000000..dec76c83 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderProperties.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Links; + +internal readonly struct FolderProperties +{ + [JsonPropertyName("NodeHashKey")] + public required PgpArmoredMessage HashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs new file mode 100644 index 00000000..ab334ac0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs @@ -0,0 +1,8 @@ +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Links; + +internal interface ILinksApiClient +{ + ValueTask GetLinkDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs new file mode 100644 index 00000000..d3a541f2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs @@ -0,0 +1,13 @@ +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class LinkDetailsDto +{ + public required LinkDto Link { get; init; } + public FolderDto? Folder { get; init; } + + public void Deconstruct(out LinkDto link, out FolderDto? folder) + { + link = Link; + folder = Folder; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs new file mode 100644 index 00000000..f147243c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal readonly struct LinkDetailsRequest(IEnumerable linkIds) +{ + [JsonPropertyName("LinkIDs")] + public IEnumerable LinkIds { get; } = linkIds; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs new file mode 100644 index 00000000..75bdb279 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class LinkDetailsResponse : ApiResponse +{ + public required IReadOnlyList Links { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs new file mode 100644 index 00000000..2fd48e82 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class LinkDto +{ + [JsonPropertyName("LinkID")] + public LinkId Id { get; init; } + + public LinkType Type { get; init; } + + [JsonPropertyName("ParentLinkID")] + public LinkId? ParentId { get; init; } + + public required LinkState State { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + [JsonPropertyName("ModifyTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime ModificationTime { get; init; } + + [JsonPropertyName("Trashed")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public DateTime? TrashTime { get; init; } + + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("NameHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("MIMEType")] + public string? MediaType { get; init; } + + [JsonPropertyName("NodeKey")] + public required PgpArmoredPrivateKey Key { get; init; } + + [JsonPropertyName("NodePassphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("NodePassphraseSignature")] + public PgpArmoredSignature? PassphraseSignature { get; init; } + + [JsonPropertyName("SignatureEmail")] + public string? SignatureEmailAddress { get; init; } + + [JsonPropertyName("NameSignatureEmail")] + public string? NameSignatureEmailAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs new file mode 100644 index 00000000..e666cdd7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +[JsonConverter(typeof(StrongIdJsonConverter))] +internal readonly record struct LinkId : IStrongId +{ + private readonly string? _value; + + internal LinkId(string? value) + { + _value = value; + } + + public static explicit operator LinkId(string? value) + { + return new LinkId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs new file mode 100644 index 00000000..10cb2935 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs @@ -0,0 +1,32 @@ +namespace Proton.Drive.Sdk.Api.Links; + +internal enum LinkState +{ + /// + /// File is created, waiting for the revision to be committed. + /// Automatically garbage collected if no blocks uploaded within last 3 hours. + /// + Draft = 0, + + /// + /// Active + /// + Active = 1, + + /// + /// Trashed + /// + Trashed = 2, + + /// + /// Permanently deleted, waiting for the garbage collection. + /// Should not appear in API responses. + /// + Deleted = 3, + + /// + /// Hidden, being restored from old volume. + /// Should not appear in API responses. + /// + Restoring = 4, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs new file mode 100644 index 00000000..83d8270f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Api.Links; + +internal enum LinkType +{ + Folder = 1, + File = 2, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs new file mode 100644 index 00000000..e47be255 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -0,0 +1,18 @@ +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class LinksApiClient(HttpClient httpClient) : ILinksApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask GetLinkDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.LinkDetailsResponse) + .PostAsync($"v2/volumes/{volumeId}/links", new LinkDetailsRequest(linkIds), DriveApiSerializerContext.Default.LinkDetailsRequest, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs new file mode 100644 index 00000000..f0de1e82 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal class RevisionDto +{ + [JsonPropertyName("ID")] + public required string Id { get; init; } + + [JsonPropertyName("ClientUID")] + public string? ClientId { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + public required long Size { get; init; } + + public PgpArmoredSignature? ManifestSignature { get; init; } + + [JsonPropertyName("SignatureEmail")] + public string? SignatureEmailAddress { get; init; } + + public required RevisionState State { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } + + public IReadOnlyList? Thumbnails { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs new file mode 100644 index 00000000..4921e095 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class ThumbnailDto +{ + [JsonPropertyName("ThumbnailID")] + public string? Id { get; init; } + + public required ThumbnailType Type { get; init; } + + [JsonPropertyName("Hash")] + public required ReadOnlyMemory HashDigest { get; init; } + + public required int Size { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailType.cs new file mode 100644 index 00000000..a9d4c7e3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailType.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Api.Links; + +internal enum ThumbnailType +{ + Thumbnail = 1, + Preview = 2, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs new file mode 100644 index 00000000..d2c68e13 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Api.Shares; + +internal interface ISharesApiClient +{ + ValueTask GetMyFilesShareAsync(CancellationToken cancellationToken); + ValueTask GetShareAsync(ShareId id, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberPermissions.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberPermissions.cs new file mode 100644 index 00000000..7028b020 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberPermissions.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk.Api.Shares; + +[Flags] +public enum MemberPermissions +{ + Write = 2, + Read = 4, + Admin = 16, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberState.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberState.cs new file mode 100644 index 00000000..3a4ad509 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberState.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Api.Shares; + +public enum MemberState +{ + Active = 1, + Locked = 3, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs new file mode 100644 index 00000000..e08d01aa --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareDto +{ + [JsonPropertyName("ShareID")] + public required ShareId Id { get; init; } + + [JsonPropertyName("CreatorEmail")] + public required string CreatorEmailAddress { get; init; } + + public required PgpArmoredPrivateKey Key { get; init; } + + public required PgpArmoredMessage Passphrase { get; init; } + + public required PgpArmoredSignature PassphraseSignature { get; init; } + + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs new file mode 100644 index 00000000..28365e5e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +[JsonConverter(typeof(StrongIdJsonConverter))] +internal readonly record struct ShareId : IStrongId +{ + private readonly string? _value; + + internal ShareId(string? value) + { + _value = value; + } + + public static explicit operator ShareId(string? value) + { + return new ShareId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembership.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembership.cs new file mode 100644 index 00000000..c14e4434 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembership.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareMembership +{ + [JsonPropertyName("MemberID")] + public required string MemberId { get; init; } + + [JsonPropertyName("ShareID")] + public required string ShareId { get; init; } + + [JsonPropertyName("AddressID")] + public required string AddressId { get; init; } + + [JsonPropertyName("AddressKeyID")] + public required string AddressKeyId { get; init; } + + [JsonPropertyName("Inviter")] + public required string InviterEmailAddress { get; init; } + + public required MemberPermissions Permissions { get; init; } + + public required ReadOnlyMemory KeyPacket { get; init; } + + public PgpArmoredSignature? KeyPacketSignature { get; init; } + + public PgpArmoredSignature? SessionKeySignature { get; init; } + + public required MemberState State { get; init; } + + [JsonPropertyName("Unlockable")] + public bool? CanBeUnlocked { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + [JsonPropertyName("ModifyTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime ModificationTime { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs new file mode 100644 index 00000000..f39fc4b9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Addresses; +using Proton.Sdk.Api; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareResponse : ApiResponse +{ + [JsonPropertyName("ShareID")] + public required ShareId Id { get; init; } + + [JsonPropertyName("VolumeID")] + public required VolumeId VolumeId { get; init; } + + public required ShareType Type { get; init; } + + public required ShareState State { get; init; } + + [JsonPropertyName("Creator")] + public required string CreatorEmailAddress { get; init; } + + [JsonPropertyName("Locked")] + public bool IsLocked { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public DateTime? CreationTime { get; init; } + + [JsonPropertyName("ModifyTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public DateTime? ModificationTime { get; init; } + + [JsonPropertyName("LinkID")] + public required LinkId RootLinkId { get; init; } + + [JsonPropertyName("LinkType")] + public required LinkType RootLinkType { get; init; } + + public required PgpArmoredPrivateKey Key { get; init; } + + [JsonPropertyName("Passphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("PassphraseSignature")] + public PgpArmoredSignature? PassphraseSignature { get; init; } + + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } + + public required IReadOnlyList Memberships { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponseV2.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponseV2.cs new file mode 100644 index 00000000..de4523e2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponseV2.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareResponseV2 : ApiResponse +{ + public required ShareVolumeDto Volume { get; init; } + + public required ShareDto Share { get; init; } + + [JsonPropertyName("Link")] + public required LinkDetailsDto LinkDetails { get; init; } + + public void Deconstruct(out ShareVolumeDto volume, out ShareDto share, out LinkDetailsDto linkDetails) + { + volume = Volume; + share = Share; + linkDetails = LinkDetails; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareState.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareState.cs new file mode 100644 index 00000000..22782807 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareState.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Api.Shares; + +public enum ShareState +{ + Active = 1, + Deleted = 2, + Restored = 3, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareType.cs new file mode 100644 index 00000000..a26d9f2f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareType.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk.Api.Shares; + +public enum ShareType +{ + Main = 1, + Standard = 2, + Device = 3, + Photos = 4, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs new file mode 100644 index 00000000..1981e1f1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareVolumeDto +{ + [JsonPropertyName("VolumeID")] + public required VolumeId Id { get; init; } + + public required int UsedSpace { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs new file mode 100644 index 00000000..4370747a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs @@ -0,0 +1,23 @@ +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class SharesApiClient(HttpClient httpClient) : ISharesApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask GetMyFilesShareAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ShareResponseV2) + .GetAsync("v2/shares/my-files", cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetShareAsync(ShareId id, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ShareResponse) + .GetAsync($"shares/{id}", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs new file mode 100644 index 00000000..b55de94d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Api.Volumes; + +internal interface IVolumesApiClient +{ + ValueTask CreateVolumeAsync(VolumeCreationParameters parameters, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationParameters.cs new file mode 100644 index 00000000..e89497d4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationParameters.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeCreationParameters +{ + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } + + public required PgpArmoredPrivateKey ShareKey { get; init; } + + public required PgpArmoredMessage SharePassphrase { get; init; } + + public required PgpArmoredSignature SharePassphraseSignature { get; init; } + + public required PgpArmoredMessage FolderName { get; init; } + + public required PgpArmoredPrivateKey FolderKey { get; init; } + + public required PgpArmoredMessage FolderPassphrase { get; init; } + + public required PgpArmoredSignature FolderPassphraseSignature { get; init; } + + public required PgpArmoredMessage FolderHashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationResponse.cs similarity index 78% rename from cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationResponse.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationResponse.cs index 5e46772d..101282a0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationResponse.cs @@ -1,6 +1,6 @@ using Proton.Sdk.Api; -namespace Proton.Drive.Sdk.Volumes.Api; +namespace Proton.Drive.Sdk.Api.Volumes; internal sealed class VolumeCreationResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs similarity index 72% rename from cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs index d1590c16..61fdeec9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Volumes; -namespace Proton.Drive.Sdk.Volumes.Api; +namespace Proton.Drive.Sdk.Api.Volumes; internal sealed class VolumeDto { @@ -14,5 +15,5 @@ internal sealed class VolumeDto public required VolumeState State { get; init; } [JsonPropertyName("Share")] - public required VolumeRoot Root { get; init; } + public required VolumeRootDto Root { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeRoot.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeRootDto.cs similarity index 59% rename from cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeRoot.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeRootDto.cs index 77af1c30..f39bd539 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeRoot.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeRootDto.cs @@ -1,8 +1,10 @@ using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; -namespace Proton.Drive.Sdk.Volumes.Api; +namespace Proton.Drive.Sdk.Api.Volumes; -internal sealed class VolumeRoot +internal sealed class VolumeRootDto { [JsonPropertyName("ShareID")] public required ShareId ShareId { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs similarity index 72% rename from cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumesApiClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs index 4c1406ec..ff69dae8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs @@ -1,13 +1,13 @@ using Proton.Drive.Sdk.Serialization; using Proton.Sdk.Http; -namespace Proton.Drive.Sdk.Volumes.Api; +namespace Proton.Drive.Sdk.Api.Volumes; internal sealed class VolumesApiClient(HttpClient httpClient) : IVolumesApiClient { private readonly HttpClient _httpClient = httpClient; - public async Task CreateVolumeAsync(VolumeCreationParameters parameters, CancellationToken cancellationToken) + public async ValueTask CreateVolumeAsync(VolumeCreationParameters parameters, CancellationToken cancellationToken) { return await _httpClient .Expecting(DriveApiSerializerContext.Default.VolumeCreationResponse) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Author.cs b/cs/sdk/src/Proton.Drive.Sdk/Author.cs new file mode 100644 index 00000000..9692dcbe --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Author.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk; + +public readonly struct Author(string? emailAddress) +{ + public static readonly Author Anonymous = default; + + public string? EmailAddress { get; } = emailAddress; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/BatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/BatchLoader.cs new file mode 100644 index 00000000..56bb4e3f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/BatchLoader.cs @@ -0,0 +1,62 @@ +using System.Buffers; + +namespace Proton.Drive.Sdk; + +internal sealed class BatchLoader +{ + private const int DefaultBatchSize = 50; + + private readonly ArrayBufferWriter _queueWriter; + + private readonly Func, ValueTask>> _loadFunction; + + public BatchLoader(Func, ValueTask>> loadFunction, int batchSize = DefaultBatchSize) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize); + + _queueWriter = new ArrayBufferWriter(batchSize); + _loadFunction = loadFunction; + } + + /// + /// Queues an item for loading. If the queue size reaches the batch size, calls the load function, clears the queue, and returns the loaded items. + /// Otherwise, returns an empty enumerable. + /// + public async ValueTask> QueueAndTryLoadBatchAsync(TId id) + { + _queueWriter.Write(new ReadOnlySpan(ref id)); + + if (_queueWriter.FreeCapacity > 0) + { + return []; + } + + return await LoadBatchAsync().ConfigureAwait(false); + } + + /// + /// Loads the remaining items in the queue if any, regardless of batch size. + /// Otherwise, returns an empty enumerable. + /// + /// + /// Call this after no more items are expected to be queued. + /// + public async ValueTask> LoadRemainingAsync() + { + if (_queueWriter.WrittenCount == 0) + { + return []; + } + + return await LoadBatchAsync().ConfigureAwait(false); + } + + private async ValueTask> LoadBatchAsync() + { + var result = await _loadFunction.Invoke(_queueWriter.WrittenMemory).ConfigureAwait(false); + + _queueWriter.Clear(); + + return result; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index a4e44eb7..b0ccba1d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -1,4 +1,9 @@ -using Proton.Drive.Sdk.Volumes; +using System.Text.Json; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Caching; namespace Proton.Drive.Sdk.Caching; @@ -6,12 +11,13 @@ namespace Proton.Drive.Sdk.Caching; internal sealed class DriveEntityCache(ICacheRepository repository) : IDriveEntityCache { private const string MainVolumeIdCacheKey = "volume:main:id"; + private const string MyFilesShareIdCacheKey = "share:my-files:id"; private readonly ICacheRepository _repository = repository; public ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken) { - return _repository.SetAsync(MainVolumeIdCacheKey, volumeId.Value, cancellationToken); + return _repository.SetAsync(MainVolumeIdCacheKey, volumeId.ToString(), cancellationToken); } public async ValueTask TryGetMainVolumeIdAsync(CancellationToken cancellationToken) @@ -20,4 +26,58 @@ public ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cance return value is not null ? (VolumeId?)value : null; } + + public ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cancellationToken) + { + return _repository.SetAsync(MyFilesShareIdCacheKey, shareId.ToString(), cancellationToken); + } + + public async ValueTask TryGetMyFilesShareIdAsync(CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(MyFilesShareIdCacheKey, cancellationToken).ConfigureAwait(false); + + return value is not null ? (ShareId)value : null; + } + + public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(share, DriveEntitiesSerializerContext.Default.Share); + + return _repository.SetAsync(GetShareCacheKey(share.Id), serializedValue, cancellationToken); + } + + public async ValueTask TryGetShareAsync(ShareId shareId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetShareCacheKey(shareId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.Share) + : null; + } + + public ValueTask SetNodeAsync(Node node, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(node, DriveEntitiesSerializerContext.Default.Node); + + return _repository.SetAsync(GetNodeCacheKey(node.Id), serializedValue, cancellationToken); + } + + public async ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetNodeCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.Node) + : null; + } + + private static string GetShareCacheKey(ShareId shareId) + { + return $"share:{shareId}"; + } + + private static string GetNodeCacheKey(NodeUid nodeId) + { + return $"node:{nodeId}"; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs index 87f7e42f..d7185240 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -1,5 +1,8 @@ using System.Text.Json; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Serialization; using Proton.Sdk.Caching; using Proton.Sdk.Serialization; @@ -25,35 +28,35 @@ public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, Cance : null; } - public ValueTask SetNodeKeyAsync(NodeUid nodeId, PgpPrivateKey nodeKey, CancellationToken cancellationToken) + public ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets fileSecrets, CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(nodeKey, SecretsSerializerContext.Default.PgpPrivateKey); + var serializedValue = JsonSerializer.Serialize(fileSecrets, SecretsSerializerContext.Default.PgpPrivateKey); - return _repository.SetAsync(GetNodeKeyCacheKey(nodeId), serializedValue, cancellationToken); + return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask TryGetNodeKeyAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetNodeKeyCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + var serializedValue = await _repository.TryGetAsync(GetFileSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, SecretsSerializerContext.Default.PgpPrivateKey) + ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.FileSecrets) : null; } - public ValueTask SetFolderHashKeyAsync(NodeUid nodeId, ReadOnlySpan folderHashKey, CancellationToken cancellationToken) + public ValueTask SetFolderSecretsAsync(NodeUid nodeId, FolderSecrets folderSecrets, CancellationToken cancellationToken) { - var serializedValue = Convert.ToBase64String(folderHashKey); + var serializedValue = JsonSerializer.Serialize(folderSecrets, DriveSecretsSerializerContext.Default.FolderSecrets); - return _repository.SetAsync(GetFolderHashKeyCacheKey(nodeId), serializedValue, cancellationToken); + return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask?> TryGetFolderHashKeyAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetFolderHashKeyCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + var serializedValue = await _repository.TryGetAsync(GetFolderSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); return serializedValue is not null - ? Convert.FromBase64String(serializedValue) + ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.FolderSecrets) : null; } @@ -62,13 +65,13 @@ private static string GetShareKeyCacheKey(ShareId shareId) return $"share:{shareId}:key"; } - private static string GetNodeKeyCacheKey(NodeUid nodeId) + private static string GetFolderSecretsCacheKey(NodeUid nodeId) { - return $"node:{nodeId}:key"; + return $"folder:{nodeId}:secrets"; } - private static string GetFolderHashKeyCacheKey(NodeUid nodeId) + private static string GetFileSecretsCacheKey(NodeUid nodeId) { - return $"node:{nodeId}:hash-key"; + return $"file:{nodeId}:secrets"; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index a240a08b..cd188154 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -1,4 +1,7 @@ -using Proton.Drive.Sdk.Volumes; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; namespace Proton.Drive.Sdk.Caching; @@ -6,4 +9,13 @@ internal interface IDriveEntityCache { ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); ValueTask TryGetMainVolumeIdAsync(CancellationToken cancellationToken); + + ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cancellationToken); + ValueTask TryGetMyFilesShareIdAsync(CancellationToken cancellationToken); + + ValueTask SetShareAsync(Share share, CancellationToken cancellationToken); + ValueTask TryGetShareAsync(ShareId shareId, CancellationToken cancellationToken); + + ValueTask SetNodeAsync(Node node, CancellationToken cancellationToken); + ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs index 010dac47..025c7e09 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -1,4 +1,6 @@ using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; namespace Proton.Drive.Sdk.Caching; @@ -7,9 +9,9 @@ internal interface IDriveSecretCache ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken); ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken); - ValueTask SetNodeKeyAsync(NodeUid nodeId, PgpPrivateKey nodeKey, CancellationToken cancellationToken); - ValueTask TryGetNodeKeyAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets fileSecrets, CancellationToken cancellationToken); + ValueTask TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); - ValueTask SetFolderHashKeyAsync(NodeUid nodeId, ReadOnlySpan folderHashKey, CancellationToken cancellationToken); - ValueTask?> TryGetFolderHashKeyAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask SetFolderSecretsAsync(NodeUid nodeId, FolderSecrets folderSecrets, CancellationToken cancellationToken); + ValueTask TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs index 75291ab0..8af35e6d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs @@ -8,9 +8,9 @@ internal static class CryptoGenerator { private const int PassphraseMaxUtf8Length = ((PassphraseRandomBytesLength + 2) / 3) * 4; private const int PassphraseRandomBytesLength = 32; + private const int FolderHashKeyLength = 32; public static int PassphraseBufferRequiredLength => PassphraseMaxUtf8Length; - public static int FolderHashKeyLength => 32; public static ReadOnlySpan GeneratePassphrase(Span buffer) { @@ -25,8 +25,15 @@ public static PgpPrivateKey GeneratePrivateKey() return PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default); } - public static void GenerateFolderHashKey(Span hashKeyBuffer) + public static byte[] GenerateFolderHashKey() { + var hashKeyBuffer = new byte[FolderHashKeyLength]; RandomNumberGenerator.Fill(hashKeyBuffer); + return hashKeyBuffer; + } + + public static PgpSessionKey GenerateSessionKey() + { + return PgpSessionKey.Generate(); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs index 02db72c3..f7f396a8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs @@ -7,4 +7,6 @@ internal interface IAccountClient { ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken); ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken); + ValueTask> GetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken); + ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/LinkId.cs b/cs/sdk/src/Proton.Drive.Sdk/LinkId.cs deleted file mode 100644 index b5187af9..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/LinkId.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Proton.Sdk.Serialization; - -namespace Proton.Drive.Sdk; - -public readonly record struct LinkId(string Value) : IStrongId -{ - public static implicit operator LinkId(string value) - { - return new LinkId(value); - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs deleted file mode 100644 index 9eb20884..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/NodeUid.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Proton.Drive.Sdk.Volumes; - -namespace Proton.Drive.Sdk; - -public readonly record struct NodeUid -{ - internal NodeUid(VolumeId volumeId, LinkId linkId) - { - VolumeId = volumeId; - LinkId = linkId; - } - - internal VolumeId VolumeId { get; } - internal LinkId LinkId { get; } - - public override string ToString() - { - return $"{VolumeId}~{LinkId}"; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs new file mode 100644 index 00000000..f66b2775 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs @@ -0,0 +1,12 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal class DecryptionError(string message, Author claimedAuthor) + : Error(message) +{ + public Author ClaimedAuthor { get; } = claimedAuthor; + + public DecryptionException ToException() + { + return new DecryptionException(ClaimedAuthor, Message); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionException.cs new file mode 100644 index 00000000..b7a4205f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionException.cs @@ -0,0 +1,26 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed class DecryptionException : Exception +{ + public DecryptionException() + { + } + + public DecryptionException(string? message) + : base(message) + { + } + + public DecryptionException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + public DecryptionException(Author claimedAuthor, string? message) + : this(message) + { + ClaimedAuthor = claimedAuthor; + } + + public Author? ClaimedAuthor { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionResult.cs new file mode 100644 index 00000000..4e8297c8 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionResult.cs @@ -0,0 +1,34 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class DecryptionResult +{ + public static Result, DecryptionError> Success(PgpSessionKey sessionKey, TData data, Author author) + { + return new SessionKeyAndData(sessionKey, (data, author)); + } + + public static Result, DecryptionError> AuthorVerificationFailure( + PgpSessionKey sessionKey, + TData data, + Author claimedAuthor, + string? errorMessage) + { + return new SessionKeyAndData(sessionKey, (data, new SignatureVerificationError(claimedAuthor, errorMessage))); + } + + public static Result, DecryptionError> KeyDecryptionFailure(string errorMessage, Author claimedAuthor) + { + return new DecryptionError(errorMessage, claimedAuthor); + } + + public static Result, DecryptionError> DataDecryptionFailure( + PgpSessionKey sessionKey, + string errorMessage, + Author claimedAuthor) + { + return new SessionKeyAndData(sessionKey, new DecryptionError(errorMessage, claimedAuthor)); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs new file mode 100644 index 00000000..ecba3d82 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +public class Error(string? message) +{ + public string? Message { get; } = message; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs new file mode 100644 index 00000000..be3c8a26 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs @@ -0,0 +1,8 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class FileSecrets : NodeSecrets +{ + public required PgpSessionKey ContentKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs new file mode 100644 index 00000000..64cb66ad --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs @@ -0,0 +1,5 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed class FolderNode : Node +{ +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs new file mode 100644 index 00000000..3c3bccb9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class FolderSecrets : NodeSecrets +{ + public required ReadOnlyMemory HashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs new file mode 100644 index 00000000..cd35b4f9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class InvalidNameError(string name, string message) + : Error(message) +{ + public string Name { get; } = name; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs new file mode 100644 index 00000000..42ace92c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Proton.Sdk; +using Proton.Sdk.Drive; + +namespace Proton.Drive.Sdk.Nodes; + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(FolderNode), typeDiscriminator: "folder")] +public abstract class Node +{ + public required NodeUid Id { get; init; } + + public NodeUid? ParentId { get; init; } + + public required Result Name { get; init; } + + public required Result NameAuthor { get; init; } + + public required NodeState State { get; init; } + + public required Result KeyAuthor { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs new file mode 100644 index 00000000..a10a0f1e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs @@ -0,0 +1,271 @@ +using System.Security.Cryptography; +using System.Text; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Drive; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class NodeCrypto +{ + public static async ValueTask> DecryptNodeAsync( + ProtonDriveClient client, + NodeUid nodeId, + LinkDto link, + FolderDto? folder, + PgpPrivateKey parentNodeKey, + CancellationToken cancellationToken) + { + var state = (NodeState)link.State; + + var passphraseClaimedAuthor = new Author(link.SignatureEmailAddress); + var nameClaimedAuthor = new Author(link.NameSignatureEmailAddress); + + var passphraseDecryptionResult = await DecryptPassphraseAsync( + client, + parentNodeKey, + link.Passphrase, + link.PassphraseSignature, + passphraseClaimedAuthor, + cancellationToken).ConfigureAwait(false); + + if (!passphraseDecryptionResult.TryGetValue(out var passphraseSessionKeyAndData, out var passphraseDecryptionError)) + { + return passphraseDecryptionError; + } + + var (passphraseSessionKey, passphraseDataDecryptionResult) = passphraseSessionKeyAndData; + + if (!passphraseDataDecryptionResult.TryGetValue(out var passphraseDataAndAuthor, out passphraseDecryptionError)) + { + return passphraseDecryptionError; + } + + var (passphrase, passphraseAuthor) = passphraseDataAndAuthor; + + var nameDecryptionResult = await DecryptNameAsync(client, parentNodeKey, link.Name, nameClaimedAuthor, cancellationToken).ConfigureAwait(false); + + PgpSessionKey? nameSessionKey; + Result name; + Result nameAuthor; + if (nameDecryptionResult.TryGetValue(out var nameSessionKeyAndData, out var nameDecryptionError)) + { + nameSessionKey = nameSessionKeyAndData.SessionKey; + var nameDataDecryptionResult = nameSessionKeyAndData.DataDecryptionResult; + + if (nameDataDecryptionResult.TryGetValue(out var nameDataAndAuthor, out nameDecryptionError)) + { + (var nameToValidate, nameAuthor) = nameDataAndAuthor; + + name = ValidateName(nameToValidate); + } + else + { + name = nameDecryptionError; + nameAuthor = new SignatureVerificationError(nameDecryptionError.ClaimedAuthor); + } + } + else + { + nameSessionKey = null; + name = nameDecryptionError; + nameAuthor = new SignatureVerificationError(nameDecryptionError.ClaimedAuthor); + } + + using var key = PgpPrivateKey.ImportAndUnlock(link.Key, passphrase.Span); + + var parentId = link.ParentId is not null ? new NodeUid(nodeId.VolumeId, link.ParentId.Value) : default(NodeUid?); + + if (link.Type is LinkType.Folder) + { + if (folder is null) + { + throw new ProtonApiException("Folder information missing for link of type Folder"); + } + + var folderSecrets = new FolderSecrets + { + HashKey = key.Decrypt(folder.HashKey), + Key = key, + NameSessionKey = nameSessionKey, + PassphraseSessionKey = passphraseSessionKey, + }; + + await client.Cache.Secrets.SetFolderSecretsAsync(nodeId, folderSecrets, cancellationToken).ConfigureAwait(false); + + return new FolderNode + { + Id = nodeId, + ParentId = parentId, + Name = name, + NameAuthor = nameAuthor, + State = state, + KeyAuthor = passphraseAuthor, + }; + } + + // TODO: implement file node decryption + throw new NotImplementedException(); + } + + private static async ValueTask>, DecryptionError>> DecryptPassphraseAsync( + ProtonDriveClient client, + PgpPrivateKey parentNodeKey, + PgpArmoredMessage encryptedPassphrase, + PgpArmoredSignature? signature, + Author claimedAuthor, + CancellationToken cancellationToken) + { + PgpSessionKey sessionKey; + try + { + sessionKey = parentNodeKey.DecryptSessionKey(encryptedPassphrase); + } + catch (Exception e) + { + return DecryptionResult>.KeyDecryptionFailure(e.Message, claimedAuthor); + } + + IReadOnlyList? verificationKeys = null; + string? verificationErrorMessage = null; + + if (signature is not null && claimedAuthor.EmailAddress is not null) + { + try + { + verificationKeys = await client.Account.GetAddressPublicKeysAsync(claimedAuthor.EmailAddress, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + verificationKeys = null; + verificationErrorMessage = e.Message; + } + } + + try + { + ReadOnlyMemory passphrase; + PgpVerificationStatus? verificationStatus; + + if (signature is not null && verificationKeys is not null) + { + passphrase = sessionKey.DecryptAndVerify( + encryptedPassphrase, + signature.Value, + new PgpKeyRing(verificationKeys), + out var verificationResult); + + verificationStatus = verificationResult.Status; + } + else + { + passphrase = sessionKey.Decrypt(encryptedPassphrase); + verificationStatus = PgpVerificationStatus.Ok; + } + + var authorIsVerified = verificationStatus is PgpVerificationStatus.Ok && verificationErrorMessage is null; + + return authorIsVerified + ? DecryptionResult>.Success(sessionKey, passphrase, claimedAuthor) + : DecryptionResult>.AuthorVerificationFailure(sessionKey, passphrase, claimedAuthor, verificationErrorMessage); + } + catch (Exception e) + { + return DecryptionResult>.DataDecryptionFailure(sessionKey, e.Message, claimedAuthor); + } + } + + private static async ValueTask, DecryptionError>> DecryptNameAsync( + ProtonDriveClient client, + PgpPrivateKey parentNodeKey, + PgpArmoredMessage encryptedName, + Author claimedAuthor, + CancellationToken cancellationToken) + { + PgpSessionKey sessionKey; + try + { + sessionKey = parentNodeKey.DecryptSessionKey(encryptedName); + } + catch (Exception e) + { + return DecryptionResult.KeyDecryptionFailure(e.Message, claimedAuthor); + } + + IReadOnlyList? verificationKeys = null; + string? verificationErrorMessage = null; + + if (claimedAuthor.EmailAddress is not null) + { + try + { + verificationKeys = await client.Account.GetAddressPublicKeysAsync(claimedAuthor.EmailAddress, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + verificationKeys = null; + verificationErrorMessage = e.Message; + } + } + + try + { + PgpVerificationStatus? verificationStatus; + + string name; + if (verificationKeys is not null) + { + name = sessionKey.DecryptAndVerifyText(encryptedName, new PgpKeyRing(verificationKeys), out var verificationResult); + verificationStatus = verificationResult.Status; + } + else + { + name = sessionKey.DecryptText(encryptedName); + verificationStatus = PgpVerificationStatus.Ok; + } + + var authorIsVerified = verificationStatus is PgpVerificationStatus.Ok && verificationErrorMessage is null; + + return authorIsVerified + ? DecryptionResult.Success(sessionKey, name, claimedAuthor) + : DecryptionResult.AuthorVerificationFailure(sessionKey, name, claimedAuthor, verificationErrorMessage); + } + catch (Exception e) + { + return DecryptionResult.DataDecryptionFailure(sessionKey, e.Message, claimedAuthor); + } + } + + public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHashKey) + { + var maxNameByteLength = Encoding.UTF8.GetByteCount(name); + var nameBytes = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) + ? nameHeapMemoryOwner.Memory.Span + : stackalloc byte[maxNameByteLength]; + + using (nameHeapMemoryOwner) + { + var nameByteLength = Encoding.UTF8.GetBytes(name, nameBytes); + nameBytes = nameBytes[..nameByteLength]; + + return HMACSHA256.HashData(parentFolderHashKey, nameBytes); + } + } + + private static Result ValidateName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return new InvalidNameError(name, "Name must not be empty"); + } + + if (name.Contains('/')) + { + return new InvalidNameError(name, "Name must not contain the character '/'"); + } + + return name; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs new file mode 100644 index 00000000..4e8cb0b2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -0,0 +1,112 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Caching; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class NodeOperations +{ + internal static async ValueTask GetMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var shareId = await client.Cache.Entities.TryGetMyFilesShareIdAsync(cancellationToken).ConfigureAwait(false); + if (shareId is null) + { + return await GetMyFilesFolderWithoutCacheAsync(client, cancellationToken).ConfigureAwait(false); + } + + var share = await ShareOperations.GetShareAsync(client, shareId.Value, cancellationToken).ConfigureAwait(false); + + var node = await GetNodeAsync(client, share.Id, share.RootFolderId, cancellationToken).ConfigureAwait(false); + + return (FolderNode)node; + } + + private static async ValueTask GetNodeAsync( + ProtonDriveClient client, + ShareId shareId, + NodeUid nodeId, + CancellationToken cancellationToken) + { + var node = await client.Cache.Entities.TryGetNodeAsync(nodeId, cancellationToken).ConfigureAwait(false); + + if (node is null) + { + var response = await client.Api.Links.GetLinkDetailsAsync(nodeId.VolumeId, [nodeId.LinkId], cancellationToken).ConfigureAwait(false); + + var (link, folder) = response.Links[0]; + + // TODO: make this work with nodes other than the root folder by getting the actual parent key instead of always passing the share key + var shareKey = await client.Cache.Secrets.TryGetShareKeyAsync(shareId, cancellationToken).ConfigureAwait(false); + + var decryptionResult = await NodeCrypto.DecryptNodeAsync(client, nodeId, link, folder, shareKey!.Value, cancellationToken).ConfigureAwait(false); + + if (!decryptionResult.TryGetValue(out node, out var decryptionError)) + { + throw decryptionError.ToException(); + } + } + + return node; + } + + private static async ValueTask GetMyFilesFolderWithoutCacheAsync( + ProtonDriveClient client, + CancellationToken cancellationToken) + { + PgpPrivateKey shareKey; + ShareVolumeDto volume; + ShareDto share; + LinkDto link; + FolderDto? folder; + + try + { + (volume, share, (link, folder)) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); + } + catch (ProtonApiException e) when (e.Code == ResponseCode.DoesNotExist) + { + return await CreateMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + } + + shareKey = await ShareOperations.DecryptShareKeyAsync(client, share.Id, share.Key, share.Passphrase, share.AddressId, cancellationToken) + .ConfigureAwait(false); + + var nodeId = new NodeUid(volume.Id, link.Id); + + var decryptionResult = await NodeCrypto.DecryptNodeAsync(client, nodeId, link, folder, shareKey, cancellationToken).ConfigureAwait(false); + + if (!decryptionResult.TryGetValue(out var node, out var decryptionError)) + { + throw decryptionError.ToException(); + } + + var folderNode = (FolderNode)node; + + await SetMyFilesInCacheAsync(client.Cache.Entities, new Share(share.Id, folderNode.Id), folderNode, cancellationToken).ConfigureAwait(false); + + return (FolderNode)node; + } + + private static async ValueTask CreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var (volume, folderNode) = await VolumeOperations.CreateVolumeAsync(client, cancellationToken).ConfigureAwait(false); + + var share = new Share(volume.RootShareId, volume.RootFolderId); + + await SetMyFilesInCacheAsync(client.Cache.Entities, share, folderNode, cancellationToken).ConfigureAwait(false); + + return folderNode; + } + + private static async ValueTask SetMyFilesInCacheAsync(IDriveEntityCache cache, Share share, FolderNode folderNode, CancellationToken cancellationToken) + { + await cache.SetNodeAsync(folderNode, cancellationToken).ConfigureAwait(false); + await cache.SetMyFilesShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); + await cache.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs new file mode 100644 index 00000000..c2c66b46 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs @@ -0,0 +1,10 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +internal class NodeSecrets +{ + public required PgpPrivateKey Key { get; init; } + public required PgpSessionKey PassphraseSessionKey { get; init; } + public required PgpSessionKey? NameSessionKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeState.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeState.cs new file mode 100644 index 00000000..5c5855ea --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeState.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Drive; + +public enum NodeState +{ + Draft = 0, + Active = 1, + Trashed = 2, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs new file mode 100644 index 00000000..6e84cd5b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs @@ -0,0 +1,48 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Nodes; + +[JsonConverter(typeof(NodeUidConverter))] +public readonly record struct NodeUid +{ + internal NodeUid(VolumeId volumeId, LinkId linkId) + { + VolumeId = volumeId; + LinkId = linkId; + } + + internal VolumeId VolumeId { get; } + internal LinkId LinkId { get; } + + public override string ToString() + { + return $"{VolumeId}~{LinkId}"; + } + + public static bool TryParse([NotNullWhen(true)] string? value, out NodeUid result) + { + if (string.IsNullOrEmpty(value)) + { + result = default; + return false; + } + + var separatorIndex = value.IndexOf('~'); + + if (separatorIndex < 0 || separatorIndex >= value.Length - 1) + { + result = default; + return false; + } + + var volumeId = value[..separatorIndex]; + var linkId = value[(separatorIndex + 1)..]; + + result = new NodeUid(new VolumeId(volumeId), new LinkId(linkId)); + return true; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionState.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionState.cs new file mode 100644 index 00000000..f5d66187 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionState.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes; + +public enum RevisionState +{ + Draft = 0, + Active = 1, + Superseded = 2, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SessionKeyAndData.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SessionKeyAndData.cs new file mode 100644 index 00000000..1036dc16 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SessionKeyAndData.cs @@ -0,0 +1,8 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct SessionKeyAndData( + PgpSessionKey SessionKey, + Result<(TData Data, Result Author), DecryptionError> DataDecryptionResult); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs new file mode 100644 index 00000000..3704319b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed class SignatureVerificationError(Author claimedAuthor, string? message = null) + : Error(message) +{ + public Author ClaimedAuthor { get; } = claimedAuthor; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index f613e83c..d23d55cd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -1,5 +1,6 @@ using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Caching; +using Proton.Drive.Sdk.Nodes; using Proton.Sdk; namespace Proton.Drive.Sdk; @@ -32,4 +33,9 @@ internal ProtonDriveClient(IAccountClient accountClient, IDriveApiClients apiCli internal IDriveApiClients Api { get; } internal IDriveClientCache Cache { get; } + + public ValueTask GetMyFilesFolderAsync(CancellationToken cancellationToken) + { + return NodeOperations.GetMyFilesFolderAsync(this, cancellationToken); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 027d423d..b3927068 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -1,15 +1,28 @@ using System.Text.Json.Serialization; -using Proton.Drive.Sdk.Volumes.Api; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Volumes; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Serialization; +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( +#if DEBUG + WriteIndented = true, +#endif + Converters = + [ + typeof(PgpArmoredMessageJsonConverter), + typeof(PgpArmoredSignatureJsonConverter), + typeof(PgpArmoredPrivateKeyJsonConverter), + typeof(PgpArmoredPublicKeyJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(VolumeCreationParameters))] [JsonSerializable(typeof(VolumeCreationResponse))] -internal partial class DriveApiSerializerContext : JsonSerializerContext -{ - static DriveApiSerializerContext() - { - Default = new DriveApiSerializerContext(ProtonApiSerializerContext.Default.Options); - } -} +[JsonSerializable(typeof(LinkDetailsRequest))] +[JsonSerializable(typeof(LinkDetailsResponse))] +[JsonSerializable(typeof(ShareResponse))] +[JsonSerializable(typeof(ShareResponseV2))] +internal sealed partial class DriveApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs new file mode 100644 index 00000000..824337dd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + Converters = + [ + typeof(ResultJsonConverter), + typeof(ResultJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 +[JsonSerializable(typeof(Share))] +[JsonSerializable(typeof(FolderNode))] +[JsonSerializable(typeof(Node))] +[JsonSerializable(typeof(SerializableResult))] +[JsonSerializable(typeof(SerializableResult))] +internal sealed partial class DriveEntitiesSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitySerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitySerializerContext.cs deleted file mode 100644 index 6824d027..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitySerializerContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; -using Proton.Drive.Sdk.Volumes; -using Proton.Sdk.Addresses; -using Proton.Sdk.Serialization; - -namespace Proton.Drive.Sdk.Serialization; - -[JsonSourceGenerationOptions( - Converters = - [ - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - ])] -[JsonSerializable(typeof(Volume[]))] -internal partial class DriveEntitySerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs new file mode 100644 index 00000000..278612f9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + Converters = + [ + typeof(PgpPrivateKeyJsonConverter), + typeof(PgpSessionKeyJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(FolderSecrets))] +[JsonSerializable(typeof(FileSecrets))] +internal sealed partial class DriveSecretsSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/NodeUidConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/NodeUidConverter.cs new file mode 100644 index 00000000..fff0b907 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/NodeUidConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Serialization; + +internal sealed class NodeUidConverter : JsonConverter +{ + public override NodeUid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return NodeUid.TryParse(reader.GetString(), out var value) ? value : default; + } + + public override void Write(Utf8JsonWriter writer, NodeUid value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs new file mode 100644 index 00000000..80fd22c1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Serialization; + +internal sealed class PgpSessionKeyJsonConverter : JsonConverter +{ + public override PgpSessionKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var bytes = reader.GetBytesFromBase64(); + + return PgpSessionKey.Import(bytes, SymmetricCipher.Aes256); + } + + public override void Write(Utf8JsonWriter writer, PgpSessionKey value, JsonSerializerOptions options) + { + writer.WriteBase64StringValue(value.Export().Token); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/ShareId.cs b/cs/sdk/src/Proton.Drive.Sdk/ShareId.cs deleted file mode 100644 index f1ad95c7..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/ShareId.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Proton.Sdk.Serialization; - -namespace Proton.Drive.Sdk; - -public readonly record struct ShareId(string Value) : IStrongId -{ - public static implicit operator ShareId(string value) - { - return new ShareId(value); - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs new file mode 100644 index 00000000..df11328a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs @@ -0,0 +1,10 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Shares; + +internal sealed class Share(ShareId id, NodeUid rootFolderId) +{ + public ShareId Id { get; } = id; + public NodeUid RootFolderId { get; } = rootFolderId; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs new file mode 100644 index 00000000..89fce1fa --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -0,0 +1,45 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Shares; + +internal static class ShareOperations +{ + public static async ValueTask GetShareAsync(ProtonDriveClient client, ShareId shareId, CancellationToken cancellationToken) + { + var share = await client.Cache.Entities.TryGetShareAsync(shareId, cancellationToken).ConfigureAwait(false); + + if (share is null) + { + var response = await client.Api.Shares.GetShareAsync(shareId, cancellationToken).ConfigureAwait(false); + + await DecryptShareKeyAsync(client, shareId, response.Key, response.Passphrase, response.AddressId, cancellationToken).ConfigureAwait(false); + + share = new Share(shareId, new NodeUid(response.VolumeId, response.RootLinkId)); + } + + return share; + } + + public static async ValueTask DecryptShareKeyAsync( + ProtonDriveClient client, + ShareId shareId, + PgpArmoredPrivateKey lockedKey, + PgpArmoredMessage passphraseMessage, + AddressId addressId, + CancellationToken cancellationToken) + { + var addressKeys = await client.Account.GetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + + var passphrase = new PgpPrivateKeyRing(addressKeys).Decrypt(passphraseMessage); + + var key = PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase); + + await client.Cache.Secrets.SetShareKeyAsync(shareId, key, cancellationToken).ConfigureAwait(false); + + return key; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/IVolumesApiClient.cs deleted file mode 100644 index 2db7ead6..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/IVolumesApiClient.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Proton.Drive.Sdk.Volumes.Api; - -internal interface IVolumesApiClient -{ - Task CreateVolumeAsync(VolumeCreationParameters parameters, CancellationToken cancellationToken); -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationParameters.cs deleted file mode 100644 index 3d02324f..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Api/VolumeCreationParameters.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Text.Json.Serialization; -using Proton.Sdk.Cryptography; - -namespace Proton.Drive.Sdk.Volumes.Api; - -internal sealed class VolumeCreationParameters -{ - [JsonPropertyName("AddressID")] - public required string AddressId { get; init; } - - public required PgpArmoredPrivateKey ShareKey { get; init; } - - [JsonPropertyName("SharePassphrase")] - public required PgpArmoredMessage ShareKeyPassphrase { get; init; } - - [JsonPropertyName("SharePassphraseSignature")] - public required PgpArmoredSignature ShareKeyPassphraseSignature { get; init; } - - public required PgpArmoredMessage FolderName { get; init; } - - public required PgpArmoredPrivateKey FolderKey { get; init; } - - [JsonPropertyName("FolderPassphrase")] - public required PgpArmoredMessage FolderKeyPassphrase { get; init; } - - [JsonPropertyName("FolderPassphraseSignature")] - public required PgpArmoredSignature FolderKeyPassphraseSignature { get; init; } - - public required PgpArmoredMessage FolderHashKey { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs index aa4edc15..db18cd37 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs @@ -1,11 +1,13 @@ -using Proton.Drive.Sdk.Volumes.Api; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Nodes; namespace Proton.Drive.Sdk.Volumes; -internal sealed class Volume(VolumeId id, ShareId rootShareId, LinkId rootFolderId, VolumeState state, long? maxSpace) +internal sealed class Volume(VolumeId id, ShareId rootShareId, NodeUid rootFolderId, VolumeState state, long? maxSpace) { internal Volume(VolumeDto dto) - : this(dto.Id, dto.Root.ShareId, dto.Root.LinkId, dto.State, dto.MaxSpace) + : this(dto.Id, dto.Root.ShareId, new NodeUid(dto.Id, dto.Root.LinkId), dto.State, dto.MaxSpace) { } @@ -13,7 +15,7 @@ internal Volume(VolumeDto dto) public ShareId RootShareId { get; } = rootShareId; - public LinkId RootFolderId { get; } = rootFolderId; + public NodeUid RootFolderId { get; } = rootFolderId; public VolumeState State { get; } = state; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs index ea7fdb2a..9c916e16 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs @@ -1,11 +1,25 @@ -using Proton.Sdk.Serialization; +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Volumes; -public readonly record struct VolumeId(string Value) : IStrongId +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct VolumeId : IStrongId { - public static implicit operator VolumeId(string value) + private readonly string? _value; + + internal VolumeId(string? value) + { + _value = value; + } + + public static explicit operator VolumeId(string? value) { return new VolumeId(value); } + + public override string ToString() + { + return _value ?? string.Empty; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 70c7893e..74926653 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -1,68 +1,94 @@ using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Cryptography; -using Proton.Drive.Sdk.Volumes.Api; +using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Addresses; +using Proton.Sdk.Drive; namespace Proton.Drive.Sdk.Volumes; internal static class VolumeOperations { - internal static async ValueTask CreateAsync(ProtonDriveClient client, CancellationToken cancellationToken) + private const string RootFolderName = "root"; + + internal static async ValueTask<(Volume Volume, FolderNode RootFolder)> CreateVolumeAsync(ProtonDriveClient client, CancellationToken cancellationToken) { var defaultAddress = await client.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); using var addressKey = await client.Account.GetAddressPrimaryKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); - Span folderHashKey = stackalloc byte[CryptoGenerator.FolderHashKeyLength]; - CryptoGenerator.GenerateFolderHashKey(folderHashKey); - - var parameters = GetCreationParameters(defaultAddress.Id, addressKey, folderHashKey, out var rootShareKey, out var rootFolderKey); + var parameters = GetCreationParameters(defaultAddress.Id, addressKey, out var rootShareKey, out var rootFolderSecrets); var response = await client.Api.Volumes.CreateVolumeAsync(parameters, cancellationToken).ConfigureAwait(false); var volume = new Volume(response.Volume); + var rootFolder = new FolderNode + { + Id = volume.RootFolderId, + Name = RootFolderName, + NameAuthor = new Author(defaultAddress.EmailAddress), + State = NodeState.Active, + KeyAuthor = new Author(defaultAddress.EmailAddress), + }; + await client.Cache.Entities.SetMainVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); - await client.Cache.Secrets.SetNodeKeyAsync(new NodeUid(volume.Id, volume.RootFolderId), rootFolderKey, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); - return volume; + return (volume, rootFolder); } private static VolumeCreationParameters GetCreationParameters( AddressId addressId, PgpPrivateKey addressKey, - ReadOnlySpan folderHashKey, out PgpPrivateKey rootShareKey, - out PgpPrivateKey rootFolderKey) + out FolderSecrets rootFolderSecrets) { - const string folderName = "root"; - rootShareKey = CryptoGenerator.GeneratePrivateKey(); - Span shareKeyPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; - var shareKeyPassphrase = CryptoGenerator.GeneratePassphrase(shareKeyPassphraseBuffer); - using var lockedShareKey = rootShareKey.Lock(shareKeyPassphrase); - var encryptedShareKeyPassphrase = rootShareKey.EncryptAndSign(shareKeyPassphrase, addressKey, out var shareKeyPassphraseSignature); + rootFolderSecrets = new FolderSecrets + { + Key = CryptoGenerator.GeneratePrivateKey(), + PassphraseSessionKey = CryptoGenerator.GenerateSessionKey(), + NameSessionKey = CryptoGenerator.GenerateSessionKey(), + HashKey = CryptoGenerator.GenerateFolderHashKey(), + }; + + Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var sharePassphrase = CryptoGenerator.GeneratePassphrase(sharePassphraseBuffer); + using var lockedShareKey = rootShareKey.Lock(sharePassphrase); + + var encryptedSharePassphrase = addressKey.EncryptAndSign(sharePassphrase, addressKey, out var sharePassphraseSignature); + + Span folderPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var folderPassphrase = CryptoGenerator.GeneratePassphrase(folderPassphraseBuffer); + using var lockedFolderKey = rootFolderSecrets.Key.Lock(folderPassphrase); + + var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.PassphraseSessionKey); + var encryptedFolderPassphrase = PgpEncrypter.EncryptAndSign( + folderPassphrase, + folderPassphraseEncryptionSecrets, + addressKey, + out var folderPassphraseSignature); - rootFolderKey = CryptoGenerator.GeneratePrivateKey(); - Span folderKeyPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; - var folderKeyPassphrase = CryptoGenerator.GeneratePassphrase(folderKeyPassphraseBuffer); - using var lockedFolderKey = rootFolderKey.Lock(folderKeyPassphrase); + var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.NameSessionKey.Value); + var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); - var encryptedFolderKeyPassphrase = rootFolderKey.EncryptAndSign(folderKeyPassphrase, addressKey, out var folderKeyPassphraseSignature); + var encryptedHashKey = rootFolderSecrets.Key.EncryptAndSign(rootFolderSecrets.HashKey.Span, addressKey); return new VolumeCreationParameters { - AddressId = addressId.Value, + AddressId = addressId, ShareKey = lockedShareKey.ToBytes(), - ShareKeyPassphrase = encryptedShareKeyPassphrase, - ShareKeyPassphraseSignature = shareKeyPassphraseSignature, - FolderName = rootShareKey.EncryptAndSignText(folderName, addressKey), + SharePassphrase = encryptedSharePassphrase, + SharePassphraseSignature = sharePassphraseSignature, + FolderName = encryptedName, FolderKey = lockedFolderKey.ToBytes(), - FolderKeyPassphrase = encryptedFolderKeyPassphrase, - FolderKeyPassphraseSignature = folderKeyPassphraseSignature, - FolderHashKey = rootFolderKey.EncryptAndSign(folderHashKey, addressKey), + FolderPassphrase = encryptedFolderPassphrase, + FolderPassphraseSignature = folderPassphraseSignature, + FolderHashKey = encryptedHashKey, }; } } diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Address.cs b/cs/sdk/src/Proton.Sdk/Addresses/Address.cs index 09f87c27..d1e187a5 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Address.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/Address.cs @@ -8,6 +8,4 @@ public sealed class Address(AddressId id, int order, string emailAddress, Addres public AddressStatus Status { get; } = status; public IReadOnlyList Keys { get; } = keys; public int PrimaryKeyIndex { get; } = primaryKeyIndex; - - public AddressKey PrimaryKey => Keys[PrimaryKeyIndex]; } diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressExtensions.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressExtensions.cs new file mode 100644 index 00000000..cdac7101 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressExtensions.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Addresses; + +public static class AddressExtensions +{ + public static AddressKey GetPrimaryKey(this Address address) + { + return address.Keys[address.PrimaryKeyIndex]; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs index 218900bf..7fe41e36 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs @@ -1,11 +1,25 @@ -using Proton.Sdk.Serialization; +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; namespace Proton.Sdk.Addresses; -public readonly record struct AddressId(string Value) : IStrongId +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct AddressId : IStrongId { - public static implicit operator AddressId(string value) + private readonly string? _value; + + internal AddressId(string? value) + { + _value = value; + } + + public static explicit operator AddressId(string? value) { return new AddressId(value); } + + public override string ToString() + { + return _value ?? string.Empty; + } } diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs index 12788df8..cf578127 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs @@ -1,6 +1,4 @@ -using Proton.Sdk.Addresses.Api; - -namespace Proton.Sdk.Addresses; +namespace Proton.Sdk.Addresses; public sealed class AddressKey( AddressId addressId, diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs index 07ce687f..463c5f6d 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs @@ -1,11 +1,25 @@ -using Proton.Sdk.Serialization; +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; namespace Proton.Sdk.Addresses; -public readonly record struct AddressKeyId(string Value) : IStrongId +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct AddressKeyId : IStrongId { - public static implicit operator AddressKeyId(string value) + private readonly string? _value; + + internal AddressKeyId(string? value) + { + _value = value; + } + + public static explicit operator AddressKeyId(string? value) { return new AddressKeyId(value); } + + public override string ToString() + { + return _value ?? string.Empty; + } } diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 6aa67f2b..8a396d22 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -1,16 +1,16 @@ using CommunityToolkit.HighPerformance; using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; -using Proton.Sdk.Addresses.Api; using Proton.Sdk.Api; +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Keys; using Proton.Sdk.Cryptography; -using Proton.Sdk.Keys.Api; namespace Proton.Sdk.Addresses; internal static class AddressOperations { - public static async ValueTask> GetAddressesAsync(ProtonAccountClient client, CancellationToken cancellationToken) + public static async ValueTask> GetCurrentUserAddressesAsync(ProtonAccountClient client, CancellationToken cancellationToken) { var result = await client.Cache.Entities.TryGetCurrentUserAddressesAsync(cancellationToken).ConfigureAwait(false); @@ -44,7 +44,7 @@ public static async ValueTask> GetAddressesAsync(ProtonAc return result; } - public static async ValueTask
GetAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) + public static async ValueTask
GetAddressAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) { var address = await client.Cache.Entities.TryGetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); @@ -62,9 +62,9 @@ public static async ValueTask
GetAsync(ProtonAccountClient client, Addr return address; } - public static async ValueTask
GetDefaultAsync(ProtonAccountClient client, CancellationToken cancellationToken) + public static async ValueTask
GetCurrentUserDefaultAddressAsync(ProtonAccountClient client, CancellationToken cancellationToken) { - var addresses = await GetAddressesAsync(client, cancellationToken).ConfigureAwait(false); + var addresses = await GetCurrentUserAddressesAsync(client, cancellationToken).ConfigureAwait(false); if (addresses.Count == 0) { @@ -74,23 +74,34 @@ public static async ValueTask
GetDefaultAsync(ProtonAccountClient clien return addresses.OrderBy(x => x.Order).First(); } - public static async ValueTask> GetKeysAsync( + public static async ValueTask> GetAddressKeysAsync( ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) { - var addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false) - ?? await GetAddressKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + var addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + + if (addressKeys is null) + { + await GetAddressAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + + if (addressKeys is null) + { + throw new ProtonApiException($"Could not get address keys for address {addressId}"); + } + } return addressKeys; } - public static async ValueTask GetPrimaryKeyAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) + public static async ValueTask GetAddressPrimaryKeyAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) { // TODO: use cache - var address = await GetAsync(client, addressId, cancellationToken).ConfigureAwait(false); + var address = await GetAddressAsync(client, addressId, cancellationToken).ConfigureAwait(false); - var addressKeys = await GetKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + var addressKeys = await GetAddressKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); return addressKeys[address.PrimaryKeyIndex]; } @@ -167,7 +178,7 @@ private static async ValueTask
ConvertFromDtoAsync( else { var passphrase = await client.Cache.SessionSecrets.TryGetAccountKeyPassphraseAsync( - keyDto.Id.Value, + keyDto.Id.ToString(), cancellationToken).ConfigureAwait(false); if (passphrase is null) @@ -229,26 +240,4 @@ private static ReadOnlyMemory GetAddressKeyTokenPassphrase( // TODO: avoid another allocation return passphraseStream.ToArray(); } - - private static async ValueTask> GetAddressKeysAsync( - ProtonAccountClient client, - AddressId addressId, - CancellationToken cancellationToken) - { - var addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); - - if (addressKeys is null) - { - await GetAsync(client, addressId, cancellationToken).ConfigureAwait(false); - - addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); - - if (addressKeys is null) - { - throw new ProtonApiException($"Could not get address keys for address {addressId}"); - } - } - - return addressKeys; - } } diff --git a/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs b/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs index ad12b235..8ffbf101 100644 --- a/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs +++ b/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs @@ -1,6 +1,6 @@ -using Proton.Sdk.Addresses.Api; -using Proton.Sdk.Keys.Api; -using Proton.Sdk.Users.Api; +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Api.Users; namespace Proton.Sdk.Api; diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressDto.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressDto.cs similarity index 85% rename from cs/sdk/src/Proton.Sdk/Addresses/Api/AddressDto.cs rename to cs/sdk/src/Proton.Sdk/Api/Addresses/AddressDto.cs index 8d7e6ee2..7748d7df 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressDto.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressDto.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; -namespace Proton.Sdk.Addresses.Api; +namespace Proton.Sdk.Api.Addresses; internal sealed record AddressDto { diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyCapabilities.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyCapabilities.cs similarity index 77% rename from cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyCapabilities.cs rename to cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyCapabilities.cs index a7360ead..906c95a2 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyCapabilities.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyCapabilities.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Addresses.Api; +namespace Proton.Sdk.Api.Addresses; [Flags] public enum AddressKeyCapabilities diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs similarity index 89% rename from cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs rename to cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs index 857ce53c..7bf203c6 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressKeyDto.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs @@ -1,8 +1,9 @@ using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; using Proton.Sdk.Cryptography; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Addresses.Api; +namespace Proton.Sdk.Api.Addresses; internal sealed class AddressKeyDto { @@ -25,5 +26,6 @@ internal sealed class AddressKeyDto [JsonConverter(typeof(BooleanToIntegerJsonConverter))] public required bool IsActive { get; init; } + [JsonPropertyName("Flags")] public required AddressKeyCapabilities Capabilities { get; init; } } diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs similarity index 81% rename from cs/sdk/src/Proton.Sdk/Addresses/Api/AddressListResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs index 7b0aea6f..a602e967 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressListResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs @@ -1,6 +1,6 @@ using Proton.Sdk.Api; -namespace Proton.Sdk.Addresses.Api; +namespace Proton.Sdk.Api.Addresses; internal sealed class AddressListResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs similarity index 79% rename from cs/sdk/src/Proton.Sdk/Addresses/Api/AddressResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs index b94e9010..8661cd93 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs @@ -1,6 +1,6 @@ using Proton.Sdk.Api; -namespace Proton.Sdk.Addresses.Api; +namespace Proton.Sdk.Api.Addresses; internal sealed class AddressResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressesApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressesApiClient.cs similarity index 89% rename from cs/sdk/src/Proton.Sdk/Addresses/Api/AddressesApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Addresses/AddressesApiClient.cs index 83b0a9c6..3eb255d9 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Api/AddressesApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressesApiClient.cs @@ -1,7 +1,8 @@ -using Proton.Sdk.Http; +using Proton.Sdk.Addresses; +using Proton.Sdk.Http; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Addresses.Api; +namespace Proton.Sdk.Api.Addresses; internal sealed class AddressesApiClient(HttpClient httpClient) : IAddressesApiClient { diff --git a/cs/sdk/src/Proton.Sdk/Addresses/Api/IAddressesApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/IAddressesApiClient.cs similarity index 76% rename from cs/sdk/src/Proton.Sdk/Addresses/Api/IAddressesApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Addresses/IAddressesApiClient.cs index 4cb9e8b0..5cf93a87 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/Api/IAddressesApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/IAddressesApiClient.cs @@ -1,4 +1,6 @@ -namespace Proton.Sdk.Addresses.Api; +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Api.Addresses; internal interface IAddressesApiClient { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationApiClient.cs similarity index 98% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationApiClient.cs index 627a458a..f2e28cec 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationApiClient.cs @@ -1,9 +1,9 @@ using Proton.Cryptography.Srp; -using Proton.Sdk.Api; +using Proton.Sdk.Authentication; using Proton.Sdk.Http; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal sealed class AuthenticationApiClient(HttpClient httpClient, Uri refreshRedirectUri) : IAuthenticationApiClient { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationRequest.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationRequest.cs similarity index 91% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationRequest.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationRequest.cs index 4ef7113a..d1966957 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationRequest.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal sealed class AuthenticationRequest { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs similarity index 92% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs index 5b2ef396..252f1874 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/AuthenticationResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs @@ -1,9 +1,10 @@ using System.Text.Json.Serialization; using Proton.Sdk.Api; +using Proton.Sdk.Authentication; using Proton.Sdk.Events; using Proton.Sdk.Users; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal sealed class AuthenticationResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/IAuthenticationApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs similarity index 93% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/IAuthenticationApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs index 9a5da5a9..f19d7bdd 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/IAuthenticationApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs @@ -1,7 +1,8 @@ using Proton.Cryptography.Srp; using Proton.Sdk.Api; +using Proton.Sdk.Authentication; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal interface IAuthenticationApiClient { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/ModulusResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs similarity index 86% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/ModulusResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs index 6cd598fe..0834f3e6 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/ModulusResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Proton.Sdk.Api; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal sealed class ModulusResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/ScopesResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs similarity index 78% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/ScopesResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs index 0e805fa8..2b8fd795 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/ScopesResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs @@ -1,6 +1,6 @@ using Proton.Sdk.Api; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal sealed class ScopesResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorParameters.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorParameters.cs similarity index 86% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorParameters.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorParameters.cs index 3bd4862c..3f6dd3eb 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorParameters.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorParameters.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; public readonly struct SecondFactorParameters { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorValidationRequest.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorValidationRequest.cs similarity index 84% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorValidationRequest.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorValidationRequest.cs index f4182d66..439e6444 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/SecondFactorValidationRequest.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorValidationRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal readonly struct SecondFactorValidationRequest(string secondFactorCode) { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationRequest.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationRequest.cs similarity index 71% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationRequest.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationRequest.cs index ed97eeb8..11e5fe4e 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationRequest.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationRequest.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal readonly struct SessionInitiationRequest(string username) { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs similarity index 92% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs index 38d25fda..db52ae09 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionInitiationResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Proton.Sdk.Api; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal sealed class SessionInitiationResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshRequest.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshRequest.cs similarity index 91% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshRequest.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshRequest.cs index 18558698..b0e6e5f0 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshRequest.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal readonly struct SessionRefreshRequest(string refreshToken, string responseType, string grantType, Uri redirectUri) { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs similarity index 85% rename from cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs index 35ca3bde..0db64569 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/Api/SessionRefreshResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs @@ -1,7 +1,8 @@ using System.Text.Json.Serialization; using Proton.Sdk.Api; +using Proton.Sdk.Authentication; -namespace Proton.Sdk.Authentication.Api; +namespace Proton.Sdk.Api.Authentication; internal sealed class SessionRefreshResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/AddressEvent.cs b/cs/sdk/src/Proton.Sdk/Api/Events/AddressEvent.cs similarity index 81% rename from cs/sdk/src/Proton.Sdk/Events/Api/AddressEvent.cs rename to cs/sdk/src/Proton.Sdk/Api/Events/AddressEvent.cs index 2edefffb..b9ae3894 100644 --- a/cs/sdk/src/Proton.Sdk/Events/Api/AddressEvent.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/AddressEvent.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization; using Proton.Sdk.Addresses; -using Proton.Sdk.Addresses.Api; +using Proton.Sdk.Api.Addresses; -namespace Proton.Sdk.Events.Api; +namespace Proton.Sdk.Api.Events; internal sealed class AddressEvent { diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/EventAction.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventAction.cs similarity index 73% rename from cs/sdk/src/Proton.Sdk/Events/Api/EventAction.cs rename to cs/sdk/src/Proton.Sdk/Api/Events/EventAction.cs index d1cb3cb4..b6c3f646 100644 --- a/cs/sdk/src/Proton.Sdk/Events/Api/EventAction.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventAction.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Events.Api; +namespace Proton.Sdk.Api.Events; internal enum EventAction { diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/EventListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs similarity index 91% rename from cs/sdk/src/Proton.Sdk/Events/Api/EventListResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs index c2e4414a..60e9d03c 100644 --- a/cs/sdk/src/Proton.Sdk/Events/Api/EventListResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs @@ -1,8 +1,9 @@ using System.Text.Json.Serialization; using Proton.Sdk.Api; +using Proton.Sdk.Events; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Events.Api; +namespace Proton.Sdk.Api.Events; internal sealed class EventListResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/EventsApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs similarity index 91% rename from cs/sdk/src/Proton.Sdk/Events/Api/EventsApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs index 46934404..52fd6a52 100644 --- a/cs/sdk/src/Proton.Sdk/Events/Api/EventsApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs @@ -1,7 +1,8 @@ -using Proton.Sdk.Http; +using Proton.Sdk.Events; +using Proton.Sdk.Http; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Events.Api; +namespace Proton.Sdk.Api.Events; // TODO: make sure that we don't listen to core events twice when Drive will need them to listen to "shared with me" events internal readonly struct EventsApiClient(HttpClient httpClient) diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/EventsRefreshMask.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventsRefreshMask.cs similarity index 73% rename from cs/sdk/src/Proton.Sdk/Events/Api/EventsRefreshMask.cs rename to cs/sdk/src/Proton.Sdk/Api/Events/EventsRefreshMask.cs index c61e8f9a..5e033ea3 100644 --- a/cs/sdk/src/Proton.Sdk/Events/Api/EventsRefreshMask.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventsRefreshMask.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Events.Api; +namespace Proton.Sdk.Api.Events; [Flags] internal enum EventsRefreshMask : byte diff --git a/cs/sdk/src/Proton.Sdk/Events/Api/LatestEventResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs similarity index 78% rename from cs/sdk/src/Proton.Sdk/Events/Api/LatestEventResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs index e0266f67..64d2ee35 100644 --- a/cs/sdk/src/Proton.Sdk/Events/Api/LatestEventResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs @@ -1,7 +1,8 @@ using System.Text.Json.Serialization; using Proton.Sdk.Api; +using Proton.Sdk.Events; -namespace Proton.Sdk.Events.Api; +namespace Proton.Sdk.Api.Events; internal sealed class LatestEventResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs b/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs index 7685a553..f2d11a66 100644 --- a/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs +++ b/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs @@ -1,6 +1,6 @@ -using Proton.Sdk.Addresses.Api; -using Proton.Sdk.Keys.Api; -using Proton.Sdk.Users.Api; +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Api.Users; namespace Proton.Sdk.Api; diff --git a/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs b/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs index c8ef0467..4bac630a 100644 --- a/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs @@ -1,7 +1,7 @@ -using Proton.Sdk.Addresses.Api; -using Proton.Sdk.Authentication.Api; -using Proton.Sdk.Keys.Api; -using Proton.Sdk.Users.Api; +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Authentication; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Api.Users; namespace Proton.Sdk.Api; diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/AddressPublicKeyListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/AddressPublicKeyListResponse.cs similarity index 90% rename from cs/sdk/src/Proton.Sdk/Keys/Api/AddressPublicKeyListResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Keys/AddressPublicKeyListResponse.cs index 84b52ea1..48668586 100644 --- a/cs/sdk/src/Proton.Sdk/Keys/Api/AddressPublicKeyListResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/AddressPublicKeyListResponse.cs @@ -1,8 +1,7 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Api; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Keys.Api; +namespace Proton.Sdk.Api.Keys; internal sealed class AddressPublicKeyListResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/IKeysApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/IKeysApiClient.cs similarity index 87% rename from cs/sdk/src/Proton.Sdk/Keys/Api/IKeysApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Keys/IKeysApiClient.cs index cd8717df..e62e70b3 100644 --- a/cs/sdk/src/Proton.Sdk/Keys/Api/IKeysApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/IKeysApiClient.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Keys.Api; +namespace Proton.Sdk.Api.Keys; internal interface IKeysApiClient { diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/KeySalt.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySalt.cs similarity index 88% rename from cs/sdk/src/Proton.Sdk/Keys/Api/KeySalt.cs rename to cs/sdk/src/Proton.Sdk/Api/Keys/KeySalt.cs index e7b90737..be318fe4 100644 --- a/cs/sdk/src/Proton.Sdk/Keys/Api/KeySalt.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySalt.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Proton.Sdk.Keys.Api; +namespace Proton.Sdk.Api.Keys; public sealed class KeySalt { diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/KeySaltListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs similarity index 83% rename from cs/sdk/src/Proton.Sdk/Keys/Api/KeySaltListResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs index 1b6ba191..d07dc19e 100644 --- a/cs/sdk/src/Proton.Sdk/Keys/Api/KeySaltListResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs @@ -1,6 +1,6 @@ using Proton.Sdk.Api; -namespace Proton.Sdk.Keys.Api; +namespace Proton.Sdk.Api.Keys; internal sealed class KeySaltListResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/KeysApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/KeysApiClient.cs similarity index 96% rename from cs/sdk/src/Proton.Sdk/Keys/Api/KeysApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Keys/KeysApiClient.cs index 0dbc92e7..53f18256 100644 --- a/cs/sdk/src/Proton.Sdk/Keys/Api/KeysApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/KeysApiClient.cs @@ -1,7 +1,7 @@ using Proton.Sdk.Http; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Keys.Api; +namespace Proton.Sdk.Api.Keys; internal sealed class KeysApiClient(HttpClient httpClient) : IKeysApiClient { diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyEntry.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyEntry.cs similarity index 89% rename from cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyEntry.cs rename to cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyEntry.cs index d2416741..2ff76408 100644 --- a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyEntry.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyEntry.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Proton.Sdk.Cryptography; -namespace Proton.Sdk.Keys.Api; +namespace Proton.Sdk.Api.Keys; internal sealed class PublicKeyEntry { diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyListAddress.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyListAddress.cs similarity index 77% rename from cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyListAddress.cs rename to cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyListAddress.cs index c2971f23..8864cf53 100644 --- a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyListAddress.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyListAddress.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Keys.Api; +namespace Proton.Sdk.Api.Keys; internal sealed record PublicKeyListAddress { diff --git a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyStatus.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyStatus.cs similarity index 73% rename from cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyStatus.cs rename to cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyStatus.cs index bb2bfc7d..7a167ffd 100644 --- a/cs/sdk/src/Proton.Sdk/Keys/Api/PublicKeyStatus.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyStatus.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Keys.Api; +namespace Proton.Sdk.Api.Keys; [Flags] internal enum PublicKeyStatus diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/IUsersApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Users/IUsersApiClient.cs similarity index 78% rename from cs/sdk/src/Proton.Sdk/Users/Api/IUsersApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Users/IUsersApiClient.cs index 6938ab08..f2b0b375 100644 --- a/cs/sdk/src/Proton.Sdk/Users/Api/IUsersApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Users/IUsersApiClient.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Users.Api; +namespace Proton.Sdk.Api.Users; internal interface IUsersApiClient { diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/Subscriptions.cs b/cs/sdk/src/Proton.Sdk/Api/Users/Subscriptions.cs similarity index 70% rename from cs/sdk/src/Proton.Sdk/Users/Api/Subscriptions.cs rename to cs/sdk/src/Proton.Sdk/Api/Users/Subscriptions.cs index 0c4bbbc2..51ca8e53 100644 --- a/cs/sdk/src/Proton.Sdk/Users/Api/Subscriptions.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Users/Subscriptions.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.Users.Api; +namespace Proton.Sdk.Api.Users; [Flags] internal enum Subscriptions diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/User.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UserDto.cs similarity index 85% rename from cs/sdk/src/Proton.Sdk/Users/Api/User.cs rename to cs/sdk/src/Proton.Sdk/Api/Users/UserDto.cs index c210e04f..2b96d637 100644 --- a/cs/sdk/src/Proton.Sdk/Users/Api/User.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UserDto.cs @@ -1,9 +1,10 @@ using System.Text.Json.Serialization; using Proton.Sdk.Serialization; +using Proton.Sdk.Users; -namespace Proton.Sdk.Users.Api; +namespace Proton.Sdk.Api.Users; -internal sealed class User +internal sealed class UserDto { [JsonPropertyName("ID")] public required UserId Id { get; init; } @@ -32,5 +33,5 @@ internal sealed class User [JsonPropertyName("Delinquent")] public DelinquentState DelinquentState { get; init; } - public required IReadOnlyList Keys { get; init; } + public required IReadOnlyList Keys { get; init; } } diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/UserKey.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs similarity index 86% rename from cs/sdk/src/Proton.Sdk/Users/Api/UserKey.cs rename to cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs index 827879b1..ccd2530e 100644 --- a/cs/sdk/src/Proton.Sdk/Users/Api/UserKey.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs @@ -1,10 +1,11 @@ using System.Text.Json.Serialization; using Proton.Sdk.Cryptography; using Proton.Sdk.Serialization; +using Proton.Sdk.Users; -namespace Proton.Sdk.Users.Api; +namespace Proton.Sdk.Api.Users; -internal sealed class UserKey +internal sealed class UserKeyDto { [JsonPropertyName("ID")] public required UserKeyId Id { get; init; } diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/UserResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs similarity index 50% rename from cs/sdk/src/Proton.Sdk/Users/Api/UserResponse.cs rename to cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs index 9714dea8..92d13558 100644 --- a/cs/sdk/src/Proton.Sdk/Users/Api/UserResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs @@ -1,8 +1,8 @@ using Proton.Sdk.Api; -namespace Proton.Sdk.Users.Api; +namespace Proton.Sdk.Api.Users; internal sealed class UserResponse : ApiResponse { - public required User User { get; init; } + public required UserDto User { get; init; } } diff --git a/cs/sdk/src/Proton.Sdk/Users/Api/UsersApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UsersApiClient.cs similarity index 93% rename from cs/sdk/src/Proton.Sdk/Users/Api/UsersApiClient.cs rename to cs/sdk/src/Proton.Sdk/Api/Users/UsersApiClient.cs index fb01d45a..b0614049 100644 --- a/cs/sdk/src/Proton.Sdk/Users/Api/UsersApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UsersApiClient.cs @@ -1,7 +1,7 @@ using Proton.Sdk.Http; using Proton.Sdk.Serialization; -namespace Proton.Sdk.Users.Api; +namespace Proton.Sdk.Api.Users; internal sealed class UsersApiClient(HttpClient httpClient) : IUsersApiClient { diff --git a/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs b/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs index 3bc99fa9..ae7d5047 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs +++ b/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs @@ -1,11 +1,25 @@ -using Proton.Sdk.Serialization; +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; namespace Proton.Sdk.Authentication; -public readonly record struct SessionId(string Value) : IStrongId +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct SessionId : IStrongId { - public static implicit operator SessionId(string value) + private readonly string? _value; + + internal SessionId(string? value) + { + _value = value; + } + + public static explicit operator SessionId(string? value) { return new SessionId(value); } + + public override string ToString() + { + return _value ?? string.Empty; + } } diff --git a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs index 843384ee..1ff29fa9 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs +++ b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; using Proton.Sdk.Api; -using Proton.Sdk.Authentication.Api; +using Proton.Sdk.Api.Authentication; namespace Proton.Sdk.Authentication; diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs index e26d1574..7682a31d 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs @@ -12,7 +12,7 @@ internal sealed class AccountEntityCache(ICacheRepository repository) : IAccount public ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken) { - var value = JsonSerializer.Serialize(address, AccountEntitySerializerContext.Default.Address); + var value = JsonSerializer.Serialize(address, AccountEntitiesSerializerContext.Default.Address); return _repository.SetAsync(GetAddressCacheKey(address.Id), value, cancellationToken); } @@ -21,7 +21,7 @@ public ValueTask SetAddressAsync(Address address, CancellationToken cancellation { var value = await _repository.TryGetAsync(GetAddressCacheKey(addressId), cancellationToken).ConfigureAwait(false); - return value is not null ? JsonSerializer.Deserialize(value, AccountEntitySerializerContext.Default.Address) : null; + return value is not null ? JsonSerializer.Deserialize(value, AccountEntitiesSerializerContext.Default.Address) : null; } public async ValueTask SetCurrentUserAddressesAsync(IEnumerable
addresses, CancellationToken cancellationToken) @@ -30,7 +30,7 @@ await _repository.SetCompleteCollection( addresses, address => GetAddressCacheKey(address.Id), CurrentUserAddressTags, - AccountEntitySerializerContext.Default.Address, + AccountEntitiesSerializerContext.Default.Address, cancellationToken).ConfigureAwait(false); } @@ -38,7 +38,7 @@ await _repository.SetCompleteCollection( { return await _repository.TryGetCompleteCollection( CurrentUserAddressTags, - AccountEntitySerializerContext.Default.Address, + AccountEntitiesSerializerContext.Default.Address, cancellationToken).ConfigureAwait(false); } diff --git a/cs/sdk/src/Proton.Sdk/Events/EventId.cs b/cs/sdk/src/Proton.Sdk/Events/EventId.cs index e97060b0..d8d71adf 100644 --- a/cs/sdk/src/Proton.Sdk/Events/EventId.cs +++ b/cs/sdk/src/Proton.Sdk/Events/EventId.cs @@ -1,11 +1,25 @@ -using Proton.Sdk.Serialization; +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; namespace Proton.Sdk.Events; -public readonly record struct EventId(string Value) : IStrongId +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct EventId : IStrongId { - public static implicit operator EventId(string value) + private readonly string? _value; + + internal EventId(string? value) + { + _value = value; + } + + public static explicit operator EventId(string? value) { return new EventId(value); } + + public override string ToString() + { + return _value ?? string.Empty; + } } diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs index 55a1259d..54f5f1b3 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -21,15 +21,10 @@ internal HttpApiCallBuilder(HttpClient httpClient, JsonTypeInfo succes _failureTypeInfo = failureTypeInfo; } - public async ValueTask GetAsync(string requestUri, CancellationToken cancellationToken, byte[]? operationId = null) + public async ValueTask GetAsync(string requestUri, CancellationToken cancellationToken) { using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Get, requestUri); - if (operationId is not null) - { - requestMessage.Options.Set(new HttpRequestOptionsKey("ShouldBePassedWithOperationId"), operationId); - } - return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); } @@ -43,16 +38,10 @@ public async ValueTask PostAsync( string requestUri, TRequestBody body, JsonTypeInfo bodyTypeInfo, - CancellationToken cancellationToken, - byte[]? operationId = null) + CancellationToken cancellationToken) { using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, requestUri, body, bodyTypeInfo); - if (operationId is not null) - { - requestMessage.Options.Set(new HttpRequestOptionsKey("ShouldBePassedWithOperationId"), operationId); - } - return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); } @@ -78,16 +67,10 @@ public async ValueTask PutAsync( string requestUri, TRequestBody body, JsonTypeInfo bodyTypeInfo, - CancellationToken cancellationToken, - byte[]? operationId = null) + CancellationToken cancellationToken) { using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Put, requestUri, body, bodyTypeInfo); - if (operationId is not null) - { - requestMessage.Options.Set(new HttpRequestOptionsKey("ShouldBePassedWithOperationId"), operationId); - } - return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); } diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs index 3ae7c1ea..254b69b2 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -31,17 +31,32 @@ internal ProtonAccountClient(IAccountApiClients apiClients, IAccountClientCache public ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) { - return AddressOperations.GetAsync(this, addressId, cancellationToken); + return AddressOperations.GetAddressAsync(this, addressId, cancellationToken); } - public ValueTask> GetAddressesAsync(CancellationToken cancellationToken) + public ValueTask> GetCurrentUserAddressesAsync(CancellationToken cancellationToken) { - return AddressOperations.GetAddressesAsync(this, cancellationToken); + return AddressOperations.GetCurrentUserAddressesAsync(this, cancellationToken); } - public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) + public ValueTask
GetCurrentUserDefaultAddressAsync(CancellationToken cancellationToken) { - return AddressOperations.GetDefaultAsync(this, cancellationToken); + return AddressOperations.GetCurrentUserDefaultAddressAsync(this, cancellationToken); + } + + internal ValueTask> GetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + { + return AddressOperations.GetAddressKeysAsync(this, addressId, cancellationToken); + } + + internal ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken) + { + return AddressOperations.GetAddressPrimaryKeyAsync(this, addressId, cancellationToken); + } + + internal ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) + { + return AddressOperations.GetPublicKeysAsync(this, emailAddress, cancellationToken); } internal async ValueTask> GetUserKeysAsync(CancellationToken cancellationToken) @@ -61,7 +76,7 @@ internal async ValueTask> GetUserKeysAsync(Cancella continue; } - var passphrase = await Cache.SessionSecrets.TryGetAccountKeyPassphraseAsync(userKey.Id.Value, cancellationToken).ConfigureAwait(false); + var passphrase = await Cache.SessionSecrets.TryGetAccountKeyPassphraseAsync(userKey.Id.ToString(), cancellationToken).ConfigureAwait(false); if (passphrase is null) { @@ -86,19 +101,4 @@ internal async ValueTask> GetUserKeysAsync(Cancella return userKeys; } - - internal ValueTask> GetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) - { - return AddressOperations.GetKeysAsync(this, addressId, cancellationToken); - } - - internal ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken) - { - return AddressOperations.GetPrimaryKeyAsync(this, addressId, cancellationToken); - } - - internal ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) - { - return AddressOperations.GetPublicKeysAsync(this, emailAddress, cancellationToken); - } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index dafbd9be..fb5b4712 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -1,10 +1,10 @@ using Microsoft.Extensions.Logging; using Proton.Cryptography.Srp; using Proton.Sdk.Api; +using Proton.Sdk.Api.Authentication; +using Proton.Sdk.Api.Keys; using Proton.Sdk.Authentication; -using Proton.Sdk.Authentication.Api; using Proton.Sdk.Caching; -using Proton.Sdk.Keys.Api; using Proton.Sdk.Users; namespace Proton.Sdk; diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index fe1b1075..e957567e 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -58,14 +58,14 @@ public static HttpClient GetHttpClient( if (attemptTimeout is not null) { options.AttemptTimeout.Timeout = attemptTimeout.Value; - options.CircuitBreaker.SamplingDuration = options.AttemptTimeout.Timeout * 3; + options.CircuitBreaker.SamplingDuration = options.AttemptTimeout.Timeout * 2; } options.Retry.ShouldRetryAfterHeader = true; - options.Retry.Delay = TimeSpan.FromSeconds(2.5); + options.Retry.Delay = TimeSpan.FromSeconds(2); options.Retry.BackoffType = DelayBackoffType.Exponential; options.Retry.UseJitter = true; - options.Retry.MaxRetryAttempts = 4; + options.Retry.MaxRetryAttempts = 1; var totalTimeout = (options.AttemptTimeout.Timeout + options.Retry.Delay) * options.Retry.MaxRetryAttempts * 1.5; options.TotalRequestTimeout = new HttpTimeoutStrategyOptions { Timeout = totalTimeout }; diff --git a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs new file mode 100644 index 00000000..f758f641 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk; + +public static class ResultExtensions +{ + public static T? GetValueOrDefault(this Result result, T? defaultValue = default) + { + return result.TryGetValue(out var value, out _) ? value : defaultValue; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs b/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs new file mode 100644 index 00000000..5664af8f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk; + +public sealed class Result : Result +{ + private readonly T? _value; + + public Result(T value) + { + _value = value; + } + + public Result(TError error) + : base(error) + { + _value = default; + } + + public static implicit operator Result(T value) => new(value); + public static implicit operator Result(TError error) => new(error); + + public bool TryGetValue([MaybeNullWhen(false)] out T value, [MaybeNullWhen(true)] out TError error) + { + value = _value; + error = Error; + return IsSuccess; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Result{TError}.cs b/cs/sdk/src/Proton.Sdk/Result{TError}.cs new file mode 100644 index 00000000..8b2f9f83 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Result{TError}.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk; + +public class Result +{ + public static readonly Result Success = new(); + + public Result(TError error) + { + IsSuccess = false; + Error = error; + } + + protected Result() + { + IsSuccess = true; + Error = default; + } + + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + + protected TError? Error { get; } + + public static implicit operator Result(TError error) => new(error); + + public bool TryGetError([MaybeNullWhen(true)] out TError error) + { + error = Error; + return IsFailure; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs new file mode 100644 index 00000000..4e5700d7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Serialization; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(Address))] +internal sealed partial class AccountEntitiesSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitySerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitySerializerContext.cs deleted file mode 100644 index 42b723a9..00000000 --- a/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitySerializerContext.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json.Serialization; -using Proton.Sdk.Addresses; -using Proton.Sdk.Addresses.Api; -using Proton.Sdk.Authentication; -using Proton.Sdk.Events; -using Proton.Sdk.Users; - -namespace Proton.Sdk.Serialization; - -[JsonSourceGenerationOptions( - Converters = - [ - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - ])] -[JsonSerializable(typeof(Address))] -internal sealed partial class AccountEntitySerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs index def3e83b..44e52dbc 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs @@ -8,7 +8,7 @@ internal sealed class ForgivingBytesToHexJsonConverter : JsonConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.ValueSpan.Length == 0) + if (reader.ValueSpan.Length == 0 || reader.TokenType == JsonTokenType.Null) { return ReadOnlyMemory.Empty; } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs b/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs index 0cd73f0a..3a61f547 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs @@ -3,8 +3,8 @@ internal interface IStrongId where T : IStrongId { - public string Value { get; } + public static virtual implicit operator string(T id) => id.ToString(); + public static abstract explicit operator T(string? value); - public static virtual implicit operator string(T id) => id.Value; - public static abstract implicit operator T(string value); + public string ToString(); } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs index f8a55145..5b842b71 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs @@ -1,18 +1,18 @@ using System.Text.Json.Serialization; using Proton.Sdk.Addresses; -using Proton.Sdk.Addresses.Api; using Proton.Sdk.Api; +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Authentication; +using Proton.Sdk.Api.Events; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Api.Users; using Proton.Sdk.Authentication; -using Proton.Sdk.Authentication.Api; using Proton.Sdk.Events; -using Proton.Sdk.Events.Api; -using Proton.Sdk.Keys.Api; using Proton.Sdk.Users; -using Proton.Sdk.Users.Api; namespace Proton.Sdk.Serialization; -#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to pre-processor directive +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines [JsonSourceGenerationOptions( #if DEBUG WriteIndented = true, diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs new file mode 100644 index 00000000..e3ed01f8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class ResultJsonConverter : JsonConverter> +{ + public override Result? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dto = JsonSerializer.Deserialize>(ref reader, options); + + Result? result; + if (dto.Successful) + { + if (dto.Value is null) + { + return null; + } + + result = dto.Value; + } + else + { + if (dto.Error is null) + { + return null; + } + + result = dto.Error; + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) + { + var dto = value.TryGetValue(out var innerValue, out var error) + ? new SerializableResult { Successful = true, Value = innerValue } + : new SerializableResult { Error = error }; + + JsonSerializer.Serialize(writer, dto, options); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs index 3eeda16b..3bf77431 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs @@ -3,11 +3,14 @@ namespace Proton.Sdk.Serialization; +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, Converters = [ typeof(PgpPrivateKeyJsonConverter), ])] +#pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(PgpPrivateKey[]))] internal sealed partial class SecretsSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs b/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs new file mode 100644 index 00000000..167115d3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs @@ -0,0 +1,10 @@ +namespace Proton.Sdk.Serialization; + +internal struct SerializableResult +{ + public bool Successful { get; set; } + + public T? Value { get; set; } + + public TError? Error { get; set; } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs index db3ac883..ffca0b91 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs @@ -9,7 +9,7 @@ internal sealed class StrongIdJsonConverter : JsonConverter public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var value = reader.GetString(); - return value ?? default(T); + return (T)value; } public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) diff --git a/cs/sdk/src/Proton.Sdk/Users/UserId.cs b/cs/sdk/src/Proton.Sdk/Users/UserId.cs index f9f297b6..f7a014ac 100644 --- a/cs/sdk/src/Proton.Sdk/Users/UserId.cs +++ b/cs/sdk/src/Proton.Sdk/Users/UserId.cs @@ -1,11 +1,25 @@ -using Proton.Sdk.Serialization; +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; namespace Proton.Sdk.Users; -public readonly record struct UserId(string Value) : IStrongId +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct UserId : IStrongId { - public static implicit operator UserId(string value) + private readonly string? _value; + + internal UserId(string? value) + { + _value = value; + } + + public static explicit operator UserId(string? value) { return new UserId(value); } + + public override string ToString() + { + return _value ?? string.Empty; + } } diff --git a/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs b/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs index aa6302a2..eb35fd6b 100644 --- a/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs +++ b/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs @@ -1,11 +1,25 @@ -using Proton.Sdk.Serialization; +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; namespace Proton.Sdk.Users; -public readonly record struct UserKeyId(string Value) : IStrongId +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct UserKeyId : IStrongId { - public static implicit operator UserKeyId(string value) + private readonly string? _value; + + internal UserKeyId(string? value) + { + _value = value; + } + + public static explicit operator UserKeyId(string? value) { return new UserKeyId(value); } + + public override string ToString() + { + return _value ?? string.Empty; + } } From fa7ac81a588ee0b4668e8e6ffeb62532a8bf4ff0 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Mar 2025 10:04:59 +0000 Subject: [PATCH 051/791] Update openapi for nodes endpoints --- js/sdk/src/internal/apiService/driveTypes.ts | 1087 +++++++++++++----- js/sdk/src/internal/nodes/apiService.test.ts | 36 +- js/sdk/src/internal/nodes/apiService.ts | 18 +- js/sdk/src/internal/nodes/index.test.ts | 5 +- js/sdk/src/internal/shares/apiService.ts | 2 +- js/sdk/src/internal/upload/apiService.ts | 4 +- 6 files changed, 828 insertions(+), 324 deletions(-) diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 1dd2bf72..c728fffb 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -370,26 +370,6 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/blocks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Request block upload. - * @description Request upload information for a set of blocks. - */ - post: operations["post_drive-blocks"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/drive/volumes/{volumeID}/links/{linkID}/copy": { parameters: { query?: never; @@ -400,7 +380,7 @@ export interface paths { get?: never; put?: never; /** - * Copy a node to a volume. + * Copy a node to a volume * @description Copy a single file to a volume, providing the new parent link ID. */ post: operations["post_drive-volumes-{volumeID}-links-{linkID}-copy"]; @@ -700,6 +680,23 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/volumes/{volumeID}/links/move-multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Move a batch of files, folders or photos. */ + put: operations["put_drive-volumes-{volumeID}-links-move-multiple"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/shares/{shareID}/links/{linkID}/move": { parameters: { query?: never; @@ -1196,6 +1193,66 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request block upload + * @description Request upload information for a set of blocks. + */ + post: operations["post_drive-blocks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/small": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload small file + * @description This does not support anonymous uploads (yet) + */ + post: operations["post_drive-v2-volumes-{volumeID}-files-small"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload small revision + * @description This does not support anonymous uploads (yet) + */ + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/me/active": { parameters: { query?: never; @@ -1314,7 +1371,8 @@ export interface paths { put?: never; /** Add tags to existing photo */ post: operations["post_drive-photos-volumes-{volumeID}-links-{linkID}-tags"]; - delete?: never; + /** Remove tags from existing photo */ + delete: operations["delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags"]; options?: never; head?: never; patch?: never; @@ -2594,13 +2652,13 @@ export interface components { */ Code: 1000; }; - ProcessingResponse: { + AcceptedResponse: { /** * ProtonResponseCode - * @example 1000 + * @example 1002 * @enum {integer} */ - Code: 1000; + Code: 1002; }; ListAlbumsResponseDto: { Albums: components["schemas"]["AlbumResponseDto"][]; @@ -2625,6 +2683,8 @@ export interface components { Desc: boolean; /** @default null */ Tag: components["schemas"]["TagType"] | null; + /** @default false */ + OnlyChildren: boolean; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Favorites
1Screenshots
2Videos
3LivePhotos
4MotionPhotos
5Selfies
6Portraits
7Bursts
8Panoramas
9Raw
@@ -2642,14 +2702,6 @@ export interface components { */ Code: 1000; }; - AcceptedResponse: { - /** - * ProtonResponseCode - * @example 1002 - * @enum {integer} - */ - Code: 1002; - }; RemovePhotosFromAlbumRequestDto: { LinkIDs: components["schemas"]["Id"][]; }; @@ -2807,46 +2859,6 @@ export interface components { */ Code: 1000; }; - RequestUploadInput: { - AddressID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; - RevisionID: components["schemas"]["Id"]; - /** - * @deprecated - * @description Request for thumbnail upload - * @default 0 - */ - Thumbnail: number | null; - /** - * @deprecated - * @description Hash of thumbnail contents - * @default null - */ - ThumbnailHash: string | null; - /** - * @deprecated - * @description Size of thumbnail contents - * @default 0 - */ - ThumbnailSize: number | null; - /** @default [] */ - BlockList: components["schemas"]["RequestUploadBlockInput"][]; - /** @default [] */ - ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; - }; - RequestUploadResponse: { - UploadLinks: components["schemas"]["BlockURL"][]; - /** @deprecated */ - ThumbnailLink?: components["schemas"]["ThumbnailBlockURL"] | null; - ThumbnailLinks?: components["schemas"]["ThumbnailBlockURL"][] | null; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; CopyLinkRequestDto: { /** @description Name, reusing same session key as previously. */ Name: string; @@ -2893,7 +2905,7 @@ export interface components { /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; /** - * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @description Extended attributes encrypted with link key * @default null */ XAttr: string | null; @@ -2939,7 +2951,7 @@ export interface components { /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; /** - * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @description Extended attributes encrypted with link key * @default null */ XAttr: string | null; @@ -3025,6 +3037,24 @@ export interface components { */ Code: 1000; }; + MoveLinkBatchRequestDto: { + ParentLinkID: components["schemas"]["Id"]; + Links: components["schemas"]["MoveLinkInBatchRequestDto"][]; + /** + * Format: email + * @description Signature email address used for signing name + * @default null + */ + NameSignatureEmail: string | null; + /** + * Format: email + * @description Signature email address used for the NodePassphrase. + * @default null + */ + SignatureEmail: string | null; + /** @default null */ + NewShareID: components["schemas"]["Id"] | null; + }; MoveLinkRequestDto: { /** @description Name, reusing same session key as previously. */ Name: string; @@ -3140,8 +3170,7 @@ export interface components { SignatureEmail: string | null; }; CommitRevisionDto: { - /** @description Signature of the manifest, signed with the `SignatureAddress` */ - ManifestSignature: string; + ManifestSignature: components["schemas"]["PGPSignature"]; /** * Format: email * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. @@ -3154,14 +3183,11 @@ export interface components { */ BlockNumber: number | null; /** - * @description File extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @description Extended attributes encrypted with link key * @default null */ XAttr: string | null; - /** - * @description Photo attributes - * @default null - */ + /** @default null */ Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; /** * @deprecated @@ -3280,6 +3306,63 @@ export interface components { */ Code: 1000; }; + RequestUploadInput: { + AddressID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + /** @default null */ + VolumeID: components["schemas"]["Id"] | null; + /** + * @deprecated + * @description Deprecated, pass VolumeID instead + * @default null + */ + ShareID: components["schemas"]["Id"] | null; + /** + * @deprecated + * @description Request for thumbnail upload + * @default 0 + */ + Thumbnail: number | null; + /** + * @deprecated + * @description Hash of thumbnail contents + * @default null + */ + ThumbnailHash: string | null; + /** + * @deprecated + * @description Size of thumbnail contents + * @default 0 + */ + ThumbnailSize: number | null; + /** @default [] */ + BlockList: components["schemas"]["RequestUploadBlockInput"][]; + /** @default [] */ + ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + }; + RequestUploadResponse: { + UploadLinks: components["schemas"]["BlockURL"][]; + /** @deprecated */ + ThumbnailLink?: components["schemas"]["ThumbnailBlockURL"] | null; + ThumbnailLinks?: components["schemas"]["ThumbnailBlockURL"][] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + SmallUploadResponseDto: { + LinkID: components["schemas"]["Id2"]; + RevisionID: components["schemas"]["Id2"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; AbuseReportDto: { /** * @description Reported ShareURL, complete including fragment @@ -3410,15 +3493,17 @@ export interface components { */ Code: 1000; }; + RemoveTagsRequestDto: { + Tags: components["schemas"]["TagType"][]; + }; CommitAnonymousRevisionDto: { - /** @description Signature of the manifest, signed with the `SignatureEmail` */ - ManifestSignature: string; + ManifestSignature: components["schemas"]["PGPSignature"]; /** * Format: email * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. */ SignatureEmail?: string | null; - /** @description File extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) */ + /** @description Extended attributes encrypted with link key */ XAttr: string; /** * @description Photo attributes @@ -3515,7 +3600,7 @@ export interface components { /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; /** - * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) + * @description Extended attributes encrypted with link key * @default null */ XAttr: string | null; @@ -3603,7 +3688,7 @@ export interface components { MyFilesResponseDto: { Volume: components["schemas"]["VolumeDto"]; Share: components["schemas"]["ShareDto"]; - Link: components["schemas"]["LinkDto"]; + Link: components["schemas"]["FolderDetailsDto2"]; /** * ProtonResponseCode * @example 1000 @@ -4280,10 +4365,7 @@ export interface components { * @enum {integer} */ UrlsExpired: 0 | 1; - /** - * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) - * @example -----BEGIN PGP MESSAGE-----... - */ + /** @description Extended attributes encrypted with link key */ XAttr: string | null; /** @description File properties */ FileProperties: { @@ -4299,10 +4381,7 @@ export interface components { CreateTime?: number; /** @description Size of revision (in bytes) */ Size?: number; - /** - * @description Signature of the manifest, signed with SignatureEmail - * @example -----BEGIN PGP SIGNATURE-----... - */ + /** @description Signature of the manifest, signed with SignatureEmail */ ManifestSignature?: string; /** * Format: email @@ -4352,10 +4431,7 @@ export interface components { }; } | null; FolderProperties: { - /** - * @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) - * @example -----BEGIN PGP MESSAGE----- - */ + /** @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) */ NodeHashKey?: string; } | null; /** @description ProtonDocument properties; optional */ @@ -4373,10 +4449,7 @@ export interface components { LastActivityTime?: number; /** @description Amount of photos in album */ PhotoCount?: number; - /** - * @description Node hash key - * @example -----BEGIN PGP MESSAGE----- - */ + /** @description Node hash key */ NodeHashKey?: string; } | null; /** @description Photo properties; optional */ @@ -4455,13 +4528,13 @@ export interface components { * Format: email * @description User's email associated with the share and used to sign the manifest and block contents. */ - SignatureEmail?: string | null; + SignatureEmail: string; /** * Format: email * @deprecated * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature */ - SignatureAddress?: string | null; + SignatureAddress: string; /** * @description State (0=Draft, 1=Active, 2=Obsolete) * @enum {integer} @@ -4491,29 +4564,75 @@ export interface components { ThumbnailSize: number; Thumbnails: components["schemas"]["ThumbnailTransformer"][]; }; - ShareURLResponseDto: { - Token: string; - ShareURLID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - /** @description URL to use to access the ShareURL */ - PublicUrl: string; - ExpirationTime?: number | null; - LastAccessTime?: number | null; - CreateTime: number; - MaxAccesses: number; - NumAccesses: number; - Name?: components["schemas"]["PGPMessage"] | null; - CreatorEmail: string; + SmallFileUploadMetadataRequestDto: { + Name: components["schemas"]["PGPMessage"]; + NameHash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; /** - * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * - * @enum {integer} + * Format: email + * @description Address used to sign passphrase, name, manifest, block, and xAttr. Is null for anonymous users. */ - Permissions: 4 | 6; - /** @description Bitmap: - * - `1`: FLAG_CUSTOM_PASSWORD, + SignatureEmail?: string | null; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @example text/plain */ + MIMEType: string; + ContentKeyPacket: components["schemas"]["BinaryString"]; + /** @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. */ + ContentKeyPacketSignature?: string | null; + ManifestSignature: components["schemas"]["PGPSignature"]; + /** @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. */ + ContentBlockEncSignature?: string | null; + ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; + /** + * @description Extended attributes encrypted with link key + * @default null + */ + XAttr: string | null; + /** @default null */ + Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; + }; + SmallRevisionUploadMetadataRequestDto: { + CurrentRevisionID: components["schemas"]["Id"]; + /** + * Format: email + * @description Address used to sign manifest, block, and xAttr. Is null for anonymous users. + */ + SignatureEmail?: string | null; + ManifestSignature: components["schemas"]["PGPSignature"]; + /** @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. */ + ContentBlockEncSignature?: components["schemas"]["PGPMessage"] | null; + ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; + /** + * @description File extended attributes encrypted with link key + * @default null + */ + XAttr: string | null; + }; + ShareURLResponseDto: { + Token: string; + ShareURLID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + /** @description URL to use to access the ShareURL */ + PublicUrl: string; + ExpirationTime?: number | null; + LastAccessTime?: number | null; + CreateTime: number; + MaxAccesses: number; + NumAccesses: number; + Name?: components["schemas"]["PGPMessage"] | null; + CreatorEmail: string; + /** + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @enum {integer} + */ + Permissions: 4 | 6; + /** @description Bitmap: + * - `1`: FLAG_CUSTOM_PASSWORD, * - `2`: FLAG_RANDOM_PASSWORD */ Flags: number; UrlPasswordSalt: components["schemas"]["BinaryString"]; @@ -4558,8 +4677,8 @@ export interface components { NodeKey: components["schemas"]["PGPPrivateKey"]; /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; - /** @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) */ - XAttr: string; + /** @description Extended attributes encrypted with link key */ + XAttr?: string | null; }; AlbumShortResponseDto: { Link: components["schemas"]["AlbumLinkResponseDto"]; @@ -4637,7 +4756,7 @@ export interface components { */ NameSignatureEmail?: string | null; OriginalHash?: string | null; - /** @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) */ + /** @description Extended attributes encrypted with link key */ XAttr?: string | null; }; BookmarkShareURLRequestDto: { @@ -4769,39 +4888,6 @@ export interface components { EventType: components["schemas"]["EventType"]; Link: components["schemas"]["EventLinkDataDto"]; }; - RequestUploadBlockInput: { - /** @description Block size in bytes */ - Size: number; - /** @description Index of block in list (must be consecutive starting at 1) */ - Index: number; - /** @description Encrypted PGP Signature of the raw block content */ - EncSignature: string; - /** @description Hash of encrypted block, base64 encoded */ - Hash: string; - /** @default null */ - Verifier: components["schemas"]["Verifier"] | null; - }; - RequestUploadThumbnailInput: { - /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ - Size: number; - Type: components["schemas"]["ThumbnailType"]; - /** @description Hash of encrypted block, base64 encoded */ - Hash: string; - }; - BlockURL: { - BareURL: string; - Token: string; - /** @deprecated */ - URL: string; - Index: number; - }; - ThumbnailBlockURL: { - BareURL: string; - Token: string; - /** @deprecated */ - URL: string; - ThumbnailType: components["schemas"]["ThumbnailType2"]; - }; FolderResponseDto: { ID: components["schemas"]["Id2"]; }; @@ -4819,24 +4905,48 @@ export interface components { ShareID: components["schemas"]["Id2"]; }; FileDetailsDto: { - Link: components["schemas"]["LinkDto2"]; + Link: components["schemas"]["LinkDto"]; File: components["schemas"]["FileDto"]; /** @default null */ - ActiveRevision: components["schemas"]["ActiveRevisionDto"] | null; + Sharing: components["schemas"]["SharingDto"] | null; /** @default null */ - SharingSummary: components["schemas"]["SharingSummaryDto"] | null; + Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ Folder: null | null; }; FolderDetailsDto: { - Link: components["schemas"]["LinkDto2"]; + Link: components["schemas"]["LinkDto"]; Folder: components["schemas"]["FolderDto"]; /** @default null */ - SharingSummary: components["schemas"]["SharingSummaryDto"] | null; + Sharing: components["schemas"]["SharingDto"] | null; /** @default null */ - File: null | null; + Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ - ActiveRevision: null | null; + File: null | null; + }; + MoveLinkInBatchRequestDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, passphrase should be unchanged, only key packet is used. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + /** + * @description Current name hash before move operation. Used to prevent race conditions. + * @default null + */ + OriginalHash: string | null; + /** + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; }; UpdateMissingHashKeyItemDto: { LinkID: components["schemas"]["Id"]; @@ -4846,7 +4956,7 @@ export interface components { CommitRevisionPhotoDto: { /** @description Photo capture timestamp */ CaptureTime: number; - /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ + /** @description Photo content hash, lowercase hex representation of HMAC SHA256 of SHA1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ ContentHash: string; /** * @description Main photo LinkID reference. Pass null if none. @@ -4854,7 +4964,8 @@ export interface components { */ MainPhotoLinkID: string | null; /** - * @description Base64 encoded Photo Exif Data, encrypted with Node SessionKey (ContentKeyPacket), signed with user addressKey and signature context "drive.photo.exif" + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead * @default null */ Exif: components["schemas"]["BinaryString"] | null; @@ -4877,6 +4988,39 @@ export interface components { /** @description List of trashed link's parentLinkIDs */ ParentIDs: components["schemas"]["Id2"][]; }; + RequestUploadBlockInput: { + /** @description Block size in bytes */ + Size: number; + /** @description Index of block in list (must be consecutive starting at 1) */ + Index: number; + /** @description Encrypted PGP Signature of the raw block content */ + EncSignature: string; + /** @description Hash of encrypted block, base64 encoded */ + Hash: string; + /** @default null */ + Verifier: components["schemas"]["Verifier"] | null; + }; + RequestUploadThumbnailInput: { + /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ + Size: number; + Type: components["schemas"]["ThumbnailType"]; + /** @description Hash of encrypted block, base64 encoded */ + Hash: string; + }; + BlockURL: { + BareURL: string; + Token: string; + /** @deprecated */ + URL: string; + Index: number; + }; + ThumbnailBlockURL: { + BareURL: string; + Token: string; + /** @deprecated */ + URL: string; + ThumbnailType: components["schemas"]["ThumbnailType2"]; + }; EntitlementsDto: { /** @description Maximum number of days revision history can be kept */ MaxRevisionCount: number; @@ -4984,24 +5128,15 @@ export interface components { PassphraseSignature: components["schemas"]["PGPSignature2"]; AddressID: components["schemas"]["Id2"]; }; - LinkDto: { - LinkID: components["schemas"]["Id2"]; - Type: components["schemas"]["NodeType2"]; - ParentLinkID?: components["schemas"]["Id2"] | null; - State: components["schemas"]["LinkState2"]; - CreateTime: number; - ModifyTime: number; - TrashTime?: number | null; - Name: components["schemas"]["PGPMessage2"]; - NameHash?: string | null; - MIMEType?: string | null; - NodeKey: components["schemas"]["PGPPrivateKey2"]; - NodePassphrase: components["schemas"]["PGPMessage2"]; - NodePassphraseSignature: components["schemas"]["PGPSignature2"]; - /** Format: email */ - SignatureEmail?: string | null; - /** Format: email */ - NameSignatureEmail?: string | null; + FolderDetailsDto2: { + Link: components["schemas"]["LinkDto2"]; + Folder: components["schemas"]["FolderDto2"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto2"] | null; + /** @default null */ + Membership: components["schemas"]["MembershipDto2"] | null; + /** @default null */ + File: null | null; }; /** * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
@@ -5294,10 +5429,7 @@ export interface components { * @enum {integer} */ UrlsExpired: 0 | 1; - /** - * @description Extended attributes encrypted with link key (https://confluence.protontech.ch/display/DRV/Extended+attributes) - * @example -----BEGIN PGP MESSAGE-----... - */ + /** @description Extended attributes encrypted with link key */ XAttr: string | null; /** @description File properties */ FileProperties: { @@ -5313,10 +5445,7 @@ export interface components { CreateTime?: number; /** @description Size of revision (in bytes) */ Size?: number; - /** - * @description Signature of the manifest, signed with SignatureEmail - * @example -----BEGIN PGP SIGNATURE-----... - */ + /** @description Signature of the manifest, signed with SignatureEmail */ ManifestSignature?: string; /** * Format: email @@ -5366,10 +5495,7 @@ export interface components { }; } | null; FolderProperties: { - /** - * @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) - * @example -----BEGIN PGP MESSAGE----- - */ + /** @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) */ NodeHashKey?: string; } | null; /** @description ProtonDocument properties; optional */ @@ -5387,10 +5513,7 @@ export interface components { LastActivityTime?: number; /** @description Amount of photos in album */ PhotoCount?: number; - /** - * @description Node hash key - * @example -----BEGIN PGP MESSAGE----- - */ + /** @description Node hash key */ NodeHashKey?: string; } | null; /** @description Photo properties; optional */ @@ -5588,6 +5711,8 @@ export interface components { DocsCommentsNotificationsEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsIncludeDocumentName?: boolean | null; + /** @description Default order and visibility of Photo Tags. */ + PhotoTags?: number[] | null; }; Defaults: { RevisionRetentionDays: components["schemas"]["RevisionRetentionDays3"]; @@ -5597,6 +5722,8 @@ export interface components { DocsCommentsNotificationsEnabled: boolean; /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. */ DocsCommentsNotificationsIncludeDocumentName: boolean; + /** @description Default order and visibility of Photo Tags. */ + PhotoTags: number[]; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0List
1Grid
@@ -5703,8 +5830,11 @@ export interface components { MainPhotoLinkID: string | null; /** @description File name hash */ Hash: string; - /** @description Base64 encoded Photo Exif Data, encrypted with Node SessionKey (ContentKeyPacket), signed with user addressKey */ - Exif: string | null; + /** + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead + */ + Exif?: string | null; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash: string | null; /** @description LinkIDs of related Photos if there are any */ @@ -5808,8 +5938,11 @@ export interface components { MainPhotoLinkID: string | null; /** @description File name hash */ Hash: string; - /** @description Base64 encoded Photo Exif Data, encrypted with Node SessionKey (ContentKeyPacket), signed with user addressKey */ - Exif: string | null; + /** + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead + */ + Exif?: string | null; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash: string | null; /** @description LinkIDs of related Photos if there are any */ @@ -5897,21 +6030,7 @@ export interface components { IsShared: boolean; IsTrashed: boolean; }; - Verifier: { - /** @description Derived from verificationCode from GET /verification endpoint: base64(xor(verificationCode, padWithZeros(dataPacket, 32))) https://confluence.protontech.ch/x/j_OTC */ - Token: string; - }; - /** - * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px
2HDPreview1920 px
3MachineLearning
- * @enum {integer} - */ - ThumbnailType: 1 | 2 | 3; - /** - * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px
2HDPreview1920 px
3MachineLearning
- * @enum {integer} - */ - ThumbnailType2: 1 | 2 | 3; - LinkDto2: { + LinkDto: { LinkID: components["schemas"]["Id"]; Type: components["schemas"]["NodeType4"]; ParentLinkID?: components["schemas"]["Id"] | null; @@ -5921,7 +6040,6 @@ export interface components { TrashTime?: number | null; Name: components["schemas"]["PGPMessage"]; NameHash?: string | null; - MIMEType?: string | null; NodeKey: components["schemas"]["PGPPrivateKey"]; NodePassphrase: components["schemas"]["PGPMessage"]; NodePassphraseSignature: components["schemas"]["PGPSignature"]; @@ -5933,29 +6051,44 @@ export interface components { FileDto: { TotalEncryptedSize: number; ContentKeyPacket: components["schemas"]["BinaryString"]; + MediaType?: string | null; + ActiveRevision?: components["schemas"]["ActiveRevisionDto"] | null; ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; }; - ActiveRevisionDto: { - RevisionID: components["schemas"]["Id"]; - CreateTime: number; - EncryptedSize: number; - /** @description Signature of the manifest, signed with the `SignatureEmail` */ - ManifestSignature?: components["schemas"]["PGPSignature"] | null; - XAttr?: components["schemas"]["PGPMessage"] | null; - Thumbnails: components["schemas"]["ThumbnailDto"][]; - Photo?: components["schemas"]["PhotoDto"] | null; - /** Format: email */ - SignatureEmail?: string | null; - }; - SharingSummaryDto: { + SharingDto: { ShareID: components["schemas"]["Id"]; ShareURLID?: components["schemas"]["Id"] | null; - ShareAccess: components["schemas"]["ShareAccessDto"]; + }; + MembershipDto: { + ShareID: components["schemas"]["Id"]; + MembershipID: components["schemas"]["Id"]; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; }; FolderDto: { NodeHashKey?: components["schemas"]["PGPMessage"] | null; XAttr?: components["schemas"]["PGPMessage"] | null; }; + Verifier: { + Token: components["schemas"]["BinaryString"]; + }; + /** + * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px, max. 65536 bytes in encrypted size
2HDPreview1920 px, max. 1048576 bytes in encrypted size
3MachineLearningmax. 65536 bytes in encrypted size
+ * @enum {integer} + */ + ThumbnailType: 1 | 2 | 3; + /** + * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px, max. 65536 bytes in encrypted size
2HDPreview1920 px, max. 1048576 bytes in encrypted size
3MachineLearningmax. 65536 bytes in encrypted size
+ * @enum {integer} + */ + ThumbnailType2: 1 | 2 | 3; /** * @description

Can be null if the Link was deleted

See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
* @enum {integer} @@ -5980,6 +6113,45 @@ export interface components { * @enum {integer} */ LinkState2: 0 | 1 | 2; + LinkDto2: { + LinkID: components["schemas"]["Id2"]; + Type: components["schemas"]["NodeType2"]; + ParentLinkID?: components["schemas"]["Id2"] | null; + State: components["schemas"]["LinkState2"]; + CreateTime: number; + ModifyTime: number; + TrashTime?: number | null; + Name: components["schemas"]["PGPMessage2"]; + NameHash?: string | null; + NodeKey: components["schemas"]["PGPPrivateKey2"]; + NodePassphrase: components["schemas"]["PGPMessage2"]; + NodePassphraseSignature: components["schemas"]["PGPSignature2"]; + /** Format: email */ + SignatureEmail?: string | null; + /** Format: email */ + NameSignatureEmail?: string | null; + }; + FolderDto2: { + NodeHashKey?: components["schemas"]["PGPMessage2"] | null; + XAttr?: components["schemas"]["PGPMessage2"] | null; + }; + SharingDto2: { + ShareID: components["schemas"]["Id2"]; + ShareURLID?: components["schemas"]["Id2"] | null; + }; + MembershipDto2: { + ShareID: components["schemas"]["Id2"]; + MembershipID: components["schemas"]["Id2"]; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; /** * @description

1=active, 3=locked

See values descriptions
See values descriptions
ValueNameDescription
1Active
2Deleted
3Locked* Locked membership can have two reasons: * * - either the associated address was disabled/deleted, e.g. due to account deletion @@ -6050,6 +6222,17 @@ export interface components { * @enum {integer} */ LinkState3: 0 | 1 | 2; + ActiveRevisionDto: { + RevisionID: components["schemas"]["Id"]; + CreateTime: number; + EncryptedSize: number; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + Thumbnails: components["schemas"]["ThumbnailDto"][]; + Photo?: components["schemas"]["PhotoDto"] | null; + /** Format: email */ + SignatureEmail?: string | null; + }; ThumbnailDto: { ThumbnailID: components["schemas"]["Id"]; Type: components["schemas"]["ThumbnailType"]; @@ -6062,18 +6245,6 @@ export interface components { ContentHash?: string | null; RelatedPhotosLinkIDs: components["schemas"]["Id"][]; }; - ShareAccessDto: { - MembershipID: components["schemas"]["Id"]; - /** - * @description Permission bitfield, valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * - * @enum {integer} - */ - Permissions: 4 | 6 | 22; - }; }; responses: { /** @description Plain success response without additional information */ @@ -6361,6 +6532,9 @@ export interface operations { "application/json": { /** * @description Potential codes: + * - 2501: File or folder not found + * - 2001: Invalid PGP message + * - 200501: Operation failed: Please retry * - 2032 * * @enum {integer} @@ -6374,7 +6548,8 @@ export interface operations { "delete_drive-photos-volumes-{volumeID}-albums-{linkID}": { parameters: { query?: { - DeleteAlbumPhotos?: number | null; + /** @description Whether or not to delete the album even with direct children. */ + DeleteAlbumPhotos?: 0 | 1 | null; }; header?: never; path: { @@ -6422,23 +6597,23 @@ export interface operations { requestBody?: never; responses: { /** @description Success */ - 102: { + 200: { headers: { "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProcessingResponse"]; + "application/json": components["schemas"]["GetMigrationStatusResponseDto"]; }; }; - /** @description Success */ - 200: { + /** @description Accepted */ + 202: { headers: { - "x-pm-code": 1000; + "x-pm-code": 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetMigrationStatusResponseDto"]; + "application/json": components["schemas"]["AcceptedResponse"]; }; }; /** @description Failed dependency */ @@ -6520,6 +6695,7 @@ export interface operations { Sort?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Sort"]; Desc?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Desc"]; Tag?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Tag"]; + OnlyChildren?: components["schemas"]["ListPhotosAlbumQueryParameters"]["OnlyChildren"]; }; header?: never; path: { @@ -7059,47 +7235,22 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListEventsResponseDto"]; - }; - }; - }; - }; - "get_drive-v2-volumes-{volumeID}-events-{eventID}": { - parameters: { - query?: never; - header?: never; - path: { - volumeID: string; - eventID: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ListEventsV2ResponseDto"]; + "application/json": components["schemas"]["ListEventsResponseDto"]; }; }; }; }; - "post_drive-blocks": { + "get_drive-v2-volumes-{volumeID}-events-{eventID}": { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["RequestUploadInput"]; + path: { + volumeID: string; + eventID: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -7108,7 +7259,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RequestUploadResponse"]; + "application/json": components["schemas"]["ListEventsV2ResponseDto"]; }; }; }; @@ -7678,6 +7829,51 @@ export interface operations { }; }; }; + "put_drive-volumes-{volumeID}-links-move-multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MoveLinkBatchRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["responses"]["ProtonSuccessResponse"][]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The volume does not exist. + * - 2000: All main photos have to be sent with related photos. + * */ + Code: number; + }; + }; + }; + }; + }; "put_drive-shares-{shareID}-links-{linkID}-move": { parameters: { query?: never; @@ -8735,6 +8931,272 @@ export interface operations { }; }; }; + "post_drive-blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RequestUploadInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestUploadResponse"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files-small": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** @example POST /drive/v2/volumes/{volumeID}/files/small + * Content-Type: multipart/form-data; boundary=[SOME_BOUNDARY] + * Content-Length: [ACTUAL_CONTENT_LENGTH] + * + * --[SOME_BOUNDARY] + * Content-Type: application/json + * Content-Disposition: form-data; name="Metadata" + * + * { + * "Name": "string", + * "NameHash": "string", + * "ParentLinkID": "string", + * "MIMEType": "string", + * // ... remaining metadata, see SmallFileUploadMetadataRequestDto schema + * } + * + * --[SOME_BOUNDARY] + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ContentBlock" + * + * + * --[SOME_BOUNDARY] + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ThumbnailBlockType_1" + * + * + * --[SOME_BOUNDARY]-- + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ThumbnailBlockType_2" + * + * + * --[SOME_BOUNDARY]-- */ + "multipart/form-data": { + Metadata: components["schemas"]["SmallFileUploadMetadataRequestDto"]; + /** + * Format: binary + * @description The encrypted binary data of the file content. This is optional as 0-byte files do not have a block. + */ + ContentBlock?: string; + /** + * Format: binary + * @description The encrypted binary data for the Preview thumbnail. This is optional. + */ + ThumbnailBlockType_1?: string; + /** + * Format: binary + * @description The encrypted binary data for the HDPreview thumbnail. This is optional. + */ + ThumbnailBlockType_2?: string; + /** + * Format: binary + * @description The encrypted binary data for the MachineLearning thumbnail. This is optional. + */ + ThumbnailBlockType_3?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmallUploadResponseDto"]; + }; + }; + /** @description Bad request, the metadata does not pass validation. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + /** @description Conflict, there is a name hash collision with another link in the same folder. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The parent link does not exist or is trashed + * - 2011: The user does not have write permission on the link + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200002: Storage quota exceeded + * - 2001: PGP data is not correct + * - 200701: A document type cannot create a revision + * - 2511: A photo link is missing photo metadata + * - 200300: max folder size reached + * - 200901: Photos backup is disabled for your account. Please enable it in the settings. + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** @example POST /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small + * Content-Type: multipart/form-data; boundary=[SOME_BOUNDARY] + * Content-Length: [ACTUAL_CONTENT_LENGTH] + * + * --[SOME_BOUNDARY] + * Content-Type: application/json + * Content-Disposition: form-data; name="Metadata" + * + * { + * "CurrentRevisionID": string, + * "SignatureEmail": "string", + * "ManifestSignature": "string", + * "BlockEncSignature": "string", + * // ... remaining metadata, see SmallRevisionUploadMetadataRequestDto schema + * } + * + * --[SOME_BOUNDARY] + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ContentBlock" + * + * + * --[SOME_BOUNDARY] + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ThumbnailBlockType_1" + * + * + * --[SOME_BOUNDARY]-- + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ThumbnailBlockType_2" + * + * + * --[SOME_BOUNDARY]-- */ + "multipart/form-data": { + Metadata: components["schemas"]["SmallRevisionUploadMetadataRequestDto"]; + /** + * Format: binary + * @description The encrypted binary data of the file content. This is optional as 0-byte files do not have a block. + */ + ContentBlock?: string; + /** + * Format: binary + * @description The encrypted binary data for the Preview thumbnail. This is optional. + */ + ThumbnailBlockType_1?: string; + /** + * Format: binary + * @description The encrypted binary data for the HDPreview thumbnail. This is optional. + */ + ThumbnailBlockType_2?: string; + /** + * Format: binary + * @description The encrypted binary data for the MachineLearning thumbnail. This is optional. + */ + ThumbnailBlockType_3?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmallUploadResponseDto"]; + }; + }; + /** @description Bad request, the metadata does not pass validation. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + /** @description Conflict, the passed CurrentRevisionID is no longer up to date. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The link does not exist or is trashed + * - 2011: The user does not have write permission on the link + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200002: Storage quota exceeded + * - 2001: PGP data is not correct + * - 200700: A document type cannot create a revision + * - 2511: A photo link cannot have multiple revisions + * */ + Code: number; + }; + }; + }; + }; + }; "get_drive-me-active": { parameters: { query?: never; @@ -8913,6 +9375,49 @@ export interface operations { }; }; }; + "delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RemoveTagsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The link or volume does not exist. + * - 2011: Only the owner can assign tags to photos. + * */ + Code: number; + }; + }; + }; + }; + }; "post_drive-volumes-{volumeID}-photos-share": { parameters: { query?: never; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 150e1f47..ea0a83b1 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -9,18 +9,18 @@ function generateAPIFileNode(linkOverrides = {}, overrides = {}) { Link: { ...node.Link, Type: 2, - MIMEType: 'text', ...linkOverrides, }, File: { + MediaType: 'text', ContentKeyPacket: 'contentKeyPacket', ContentKeyPacketSignature: 'contentKeyPacketSig', - }, - ActiveRevision: { - RevisionID: 'revisionId', - CreateTime: 1234567890, - SignatureEmail: 'revSigEmail', - XAttr: '{file}', + ActiveRevision: { + RevisionID: 'revisionId', + CreateTime: 1234567890, + SignatureEmail: 'revSigEmail', + XAttr: '{file}', + }, }, ...overrides, }; @@ -32,7 +32,6 @@ function generateAPIFolderNode(linkOverrides = {}, overrides = {}) { Link: { ...node.Link, Type: 1, - MIMEType: 'Folder', ...linkOverrides, }, Folder: { @@ -92,7 +91,6 @@ function generateFolderNode(overrides = {}) { return { ...node, type: NodeType.Folder, - mimeType: "Folder", encryptedCrypto: { ...node.encryptedCrypto, folder: { @@ -180,12 +178,12 @@ describe("nodeAPIService", () => { it('should get shared node', async () => { await testGetNodes( generateAPIFolderNode({}, { - SharingSummary: { + Sharing: { ShareID: 'shareId', - ShareAccess: { - Permissions: 22, - }, - } + }, + Membership: { + Permissions: 22, + }, }), generateFolderNode({ isShared: true, @@ -198,12 +196,12 @@ describe("nodeAPIService", () => { it('should get shared node with unknown permissions', async () => { await testGetNodes( generateAPIFolderNode({}, { - SharingSummary: { + Sharing: { ShareID: 'shareId', - ShareAccess: { - Permissions: 42, - }, - } + }, + Membership: { + Permissions: 42, + }, }), generateFolderNode({ isShared: true, diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index d47325e4..60f37a60 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -79,14 +79,13 @@ export class NodeAPIService { uid: makeNodeUid(volumeId, link.Link.LinkID), parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, type: nodeTypeNumberToNodeType(this.logger, link.Link.Type), - mimeType: link.Link.MIMEType || undefined, createdDate: new Date(link.Link.CreateTime*1000), trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, // Sharing node metadata - shareId: link.SharingSummary?.ShareID || undefined, - isShared: !!link.SharingSummary, - directMemberRole: permissionsToDirectMemberRole(this.logger, link.SharingSummary?.ShareAccess.Permissions), + shareId: link.Sharing?.ShareID || undefined, + isShared: !!link.Sharing, + directMemberRole: permissionsToDirectMemberRole(this.logger, link.Membership?.Permissions), } const baseCryptoNodeMetadata = { signatureEmail: link.Link.SignatureEmail || undefined, @@ -96,9 +95,10 @@ export class NodeAPIService { armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, } - if (link.Link.Type === 2 && link.File && link.ActiveRevision) { + if (link.Link.Type === 2 && link.File && link.File.ActiveRevision) { return { ...baseNodeMetadata, + mimeType: link.File.MediaType || undefined, encryptedCrypto: { ...baseCryptoNodeMetadata, file: { @@ -106,11 +106,11 @@ export class NodeAPIService { armoredContentKeyPacketSignature: link.File.ContentKeyPacketSignature || undefined, }, activeRevision: { - uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.ActiveRevision.RevisionID), + uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.File.ActiveRevision.RevisionID), state: RevisionState.Active, - createdDate: new Date(link.ActiveRevision.CreateTime*1000), - signatureEmail: link.ActiveRevision.SignatureEmail || undefined, - armoredExtendedAttributes: link.ActiveRevision.XAttr || undefined, + createdDate: new Date(link.File.ActiveRevision.CreateTime*1000), + signatureEmail: link.File.ActiveRevision.SignatureEmail || undefined, + armoredExtendedAttributes: link.File.ActiveRevision.XAttr || undefined, }, }, } diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index 16176a9e..3378ea50 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -95,8 +95,9 @@ describe('nodesModules integration tests', () => { NameHash: 'hash', Type: 2, }, - File: {}, - ActiveRevision: {}, + File: { + ActiveRevision: {}, + }, }], }; }); diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index 5769fe28..f37930e5 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -28,7 +28,7 @@ export class SharesAPIService { return { volumeId: response.Volume.VolumeID, shareId: response.Share.ShareID, - rootNodeId: response.Link.LinkID, + rootNodeId: response.Link.Link.LinkID, creatorEmail: response.Share.CreatorEmail, encryptedCrypto: { armoredKey: response.Share.Key, diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 4bbea5ec..14256d50 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -156,12 +156,12 @@ export class UploadAPIService { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); const result = await this.apiService.post< // FIXME: Deprected fields but not properly marked in the types. - Omit, + Omit, PostRequestBlockUploadResponse >('drive/blocks', { AddressID: addressId, + VolumeID: volumeId, LinkID: nodeId, - ShareID: volumeId, // TODO!!! RevisionID: revisionId, BlockList: blocks.content.map((block) => ({ Index: block.index, From 52a1bfa2ed3972c4f718baf1ac7c204bbcb5bd40 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 25 Mar 2025 14:26:39 +0000 Subject: [PATCH 052/791] E2E Improvements --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ee6e23aa..7ac9e425 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ cache*.sqlite tests/storage # Test reporter -tests/test-report.xml +tests/test-results From cd3c2867570c3c2996c86288f6f80054a46defae Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 31 Mar 2025 12:23:30 +0000 Subject: [PATCH 053/791] Implement download module --- js/sdk/src/crypto/driveCrypto.ts | 94 ++++- js/sdk/src/crypto/interface.ts | 37 ++ js/sdk/src/crypto/openPGPCrypto.ts | 92 ++++- js/sdk/src/errors.ts | 32 ++ js/sdk/src/interface/download.ts | 25 +- js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/telemetry.ts | 4 +- js/sdk/src/internal/apiService/apiService.ts | 122 ++++-- js/sdk/src/internal/apiService/errorCodes.ts | 1 + js/sdk/src/internal/apiService/index.ts | 3 +- .../src/internal/apiService/observerStream.ts | 10 + js/sdk/src/internal/download/apiService.ts | 78 +++- js/sdk/src/internal/download/controller.ts | 22 + js/sdk/src/internal/download/cryptoService.ts | 81 +++- .../internal/download/fileDownloader.test.ts | 384 ++++++++++++++++++ .../src/internal/download/fileDownloader.ts | 265 ++++++++++-- js/sdk/src/internal/download/index.ts | 35 +- js/sdk/src/internal/download/interface.ts | 19 +- js/sdk/src/internal/download/queue.ts | 30 ++ .../src/internal/download/telemetry.test.ts | 125 ++++++ js/sdk/src/internal/download/telemetry.ts | 100 +++++ js/sdk/src/internal/download/wait.test.ts | 21 + js/sdk/src/internal/download/wait.ts | 18 + js/sdk/src/internal/nodes/cryptoCache.test.ts | 8 +- .../src/internal/nodes/cryptoService.test.ts | 34 +- js/sdk/src/internal/nodes/cryptoService.ts | 35 +- js/sdk/src/internal/nodes/interface.ts | 5 +- js/sdk/src/internal/nodes/nodesAccess.ts | 4 +- .../src/internal/shares/cryptoCache.test.ts | 8 +- .../src/internal/shares/cryptoService.test.ts | 8 +- js/sdk/src/internal/shares/cryptoService.ts | 6 +- js/sdk/src/internal/shares/interface.ts | 2 +- js/sdk/src/internal/sharing/cryptoService.ts | 8 +- js/sdk/src/internal/sharing/interface.ts | 2 +- .../src/internal/sharing/sharingManagement.ts | 12 +- js/sdk/src/internal/upload/interface.ts | 2 +- js/sdk/src/protonDriveClient.ts | 45 +- js/sdk/src/telemetry.ts | 38 +- 38 files changed, 1647 insertions(+), 170 deletions(-) create mode 100644 js/sdk/src/internal/apiService/observerStream.ts create mode 100644 js/sdk/src/internal/download/controller.ts create mode 100644 js/sdk/src/internal/download/fileDownloader.test.ts create mode 100644 js/sdk/src/internal/download/queue.ts create mode 100644 js/sdk/src/internal/download/telemetry.test.ts create mode 100644 js/sdk/src/internal/download/telemetry.ts create mode 100644 js/sdk/src/internal/download/wait.test.ts create mode 100644 js/sdk/src/internal/download/wait.ts diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 9aff2c53..e4031927 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,5 +1,5 @@ import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; -import { uint8ArrayToBase64String } from './utils'; +import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; enum SIGNING_CONTEXTS { SHARING_INVITER = 'drive.share-member.inviter', @@ -47,18 +47,18 @@ export class DriveCrypto { decrypted: { passphrase: string, key: PrivateKey, - sessionKey: SessionKey, + passphraseSessionKey: SessionKey, }, }> { const passphrase = this.openPGPCrypto.generatePassphrase(); - const [{ privateKey, armoredKey }, sessionKey] = await Promise.all([ + const [{ privateKey, armoredKey }, passphraseSessionKey] = await Promise.all([ this.openPGPCrypto.generateKey(passphrase), this.openPGPCrypto.generateSessionKey(encryptionKeys), ]); const { armoredPassphrase, armoredPassphraseSignature } = await this.encryptPassphrase( passphrase, - sessionKey, + passphraseSessionKey, encryptionKeys, signingKey, ); @@ -72,7 +72,7 @@ export class DriveCrypto { decrypted: { passphrase, key: privateKey, - sessionKey, + passphraseSessionKey, }, }; }; @@ -128,15 +128,15 @@ export class DriveCrypto { ): Promise<{ passphrase: string, key: PrivateKey, - sessionKey: SessionKey, + passphraseSessionKey: SessionKey, verified: VERIFICATION_STATUS, }> { - const sessionKey = await this.decryptSessionKey(armoredPassphrase, decryptionKeys); + const passphraseSessionKey = await this.decryptSessionKey(armoredPassphrase, decryptionKeys); const { data: decryptedPassphrase, verified } = await this.openPGPCrypto.decryptArmoredAndVerifyDetached( armoredPassphrase, armoredPassphraseSignature, - sessionKey, + passphraseSessionKey, verificationKeys, ); @@ -149,7 +149,7 @@ export class DriveCrypto { return { passphrase, key, - sessionKey, + passphraseSessionKey, verified, }; } @@ -178,13 +178,42 @@ export class DriveCrypto { armoredData: string, decryptionKeys: PrivateKey[], ): Promise { - const sessionKey = await this.openPGPCrypto.decryptSessionKey( + const sessionKey = await this.openPGPCrypto.decryptArmoredSessionKey( armoredData, decryptionKeys, ); return sessionKey; } + async decryptAndVerifySessionKey( + base64data: string, + armoredSignature: string | undefined, + decryptionKeys: PrivateKey[], + verificationKeys: PublicKey[], + ): Promise<{ + sessionKey: SessionKey, + verified?: VERIFICATION_STATUS, + }> { + + const data = base64StringToUint8Array(base64data); + + const sessionKey = await this.openPGPCrypto.decryptSessionKey( + data, + decryptionKeys, + ); + + let verified; + if (armoredSignature) { + const result = await this.openPGPCrypto.verify(sessionKey.data, armoredSignature, verificationKeys); + verified = result.verified; + } + + return { + sessionKey, + verified, + } + } + /** * It decrypts key similarly like `decryptKey`, but without signature * verification. This is used for invitations. @@ -431,4 +460,49 @@ export class DriveCrypto { base64ExternalInvitationSignature: uint8ArrayToBase64String(externalInviationSignature), } } + + async decryptBlock( + encryptedBlock: Uint8Array, + armoredSignature: string | undefined, + decryptionKey: PrivateKey, + sessionKey: SessionKey, + verificationKeys?: PublicKey[], + ): Promise<{ + decryptedBlock: Uint8Array, + verified: VERIFICATION_STATUS, + }> { + const signature = armoredSignature ? await this.openPGPCrypto.decryptArmored( + armoredSignature, + [decryptionKey], + ) : undefined; + + const { data: decryptedBlock, verified } = await this.openPGPCrypto.decryptAndVerifyDetached( + encryptedBlock, + signature, + sessionKey, + verificationKeys, + ); + + return { + decryptedBlock, + verified, + }; + } + + async verifyManifest( + manifest: Uint8Array, + armoredSignature: string, + verificationKeys: PublicKey[], + ): Promise<{ + verified: VERIFICATION_STATUS, + }> { + const { verified } = await this.openPGPCrypto.verify( + manifest, + armoredSignature, + verificationKeys, + ); + return { + verified, + } + } } diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 7ffb9317..f94e45d2 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -100,7 +100,20 @@ export interface OpenPGPCrypto { signature: Uint8Array, }>, + verify: ( + data: Uint8Array, + armoredSignature: string, + verificationKeys: PublicKey[], + ) => Promise<{ + verified: VERIFICATION_STATUS, + }>, + decryptSessionKey: ( + data: Uint8Array, + decryptionKeys: PrivateKey[], + ) => Promise, + + decryptArmoredSessionKey: ( armoredData: string, decryptionKeys: PrivateKey[], ) => Promise, @@ -110,6 +123,30 @@ export interface OpenPGPCrypto { passphrase: string, ) => Promise, + decryptAndVerify( + data: Uint8Array, + sessionKey: SessionKey, + verificationKeys: PublicKey[], + ): Promise<{ + data: Uint8Array, + verified: VERIFICATION_STATUS, + }>, + + decryptAndVerifyDetached( + data: Uint8Array, + signature: Uint8Array | undefined, + sessionKey: SessionKey, + verificationKeys?: PublicKey[], + ): Promise<{ + data: Uint8Array, + verified: VERIFICATION_STATUS, + }>, + + decryptArmored( + armoredData: string, + decryptionKeys: PrivateKey[], + ): Promise, + decryptArmoredAndVerify: ( armoredData: string, decryptionKeys: PrivateKey[], diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index fab5aedd..a2740495 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -11,7 +11,7 @@ export interface OpenPGPCryptoProxy { importPrivateKey: (options: { armoredKey: string, passphrase: string }) => Promise, generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys: PublicKey[] }) => Promise, - decryptSessionKey: (options: { armoredMessage: string, decryptionKeys: PrivateKey[] }) => Promise, + decryptSessionKey: (options: { armoredMessage?: string, binaryMessage?: Uint8Array, decryptionKeys: PrivateKey[] }) => Promise, encryptMessage: (options: { format?: 'armored' | 'binary', binaryData: Uint8Array, @@ -31,7 +31,7 @@ export interface OpenPGPCryptoProxy { binarySignature?: Uint8Array, sessionKeys?: SessionKey, decryptionKeys?: PrivateKey[], - verificationKeys: PublicKey[], + verificationKeys?: PublicKey[], }) => Promise<{ data: Uint8Array | string, verified: VERIFICATION_STATUS @@ -43,6 +43,13 @@ export interface OpenPGPCryptoProxy { detached: boolean, context: { critical: boolean, value: string }, }) => Promise, + verifyMessage: (options: { + binaryData: Uint8Array, + armoredSignature: string, + verificationKeys: PublicKey[], + }) => Promise<{ + verified: VERIFICATION_STATUS, + }>, } /** @@ -200,7 +207,38 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } + async verify( + data: Uint8Array, + armoredSignature: string, + verificationKeys: PublicKey[], + ) { + const { verified } = await this.cryptoProxy.verifyMessage({ + binaryData: data, + armoredSignature, + verificationKeys, + }); + return { + verified + }; + } + async decryptSessionKey( + data: Uint8Array, + decryptionKeys: PrivateKey[], + ) { + const sessionKey = await this.cryptoProxy.decryptSessionKey({ + binaryMessage: data, + decryptionKeys, + }); + + if (!sessionKey) { + throw new Error('Could not decrypt session key'); + } + + return sessionKey; + } + + async decryptArmoredSessionKey( armoredData: string, decryptionKeys: PrivateKey[], ) { @@ -227,6 +265,56 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return key; } + async decryptAndVerify( + data: Uint8Array, + sessionKey: SessionKey, + verificationKeys: PublicKey[], + ) { + const { data: decryptedData, verified } = await this.cryptoProxy.decryptMessage({ + binaryMessage: data, + sessionKeys: sessionKey, + verificationKeys, + format: 'binary', + }); + + return { + data: decryptedData as Uint8Array, + verified, + } + } + + async decryptAndVerifyDetached( + data: Uint8Array, + signature: Uint8Array | undefined, + sessionKey: SessionKey, + verificationKeys?: PublicKey[], + ) { + const { data: decryptedData, verified } = await this.cryptoProxy.decryptMessage({ + binaryMessage: data, + binarySignature: signature, + sessionKeys: sessionKey, + verificationKeys, + format: 'binary', + }); + + return { + data: decryptedData as Uint8Array, + verified, + } + } + + async decryptArmored( + armoredData: string, + decryptionKeys: PrivateKey[], + ) { + const { data } = await this.cryptoProxy.decryptMessage({ + armoredMessage: armoredData, + decryptionKeys, + format: 'binary', + }); + return data as Uint8Array; + } + async decryptArmoredAndVerify( armoredData: string, decryptionKeys: PrivateKey[], diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index b9ca6f1c..ff746707 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -1,3 +1,5 @@ +import { c } from 'ttag'; + /** * Base class for all SDK errors. * @@ -22,6 +24,10 @@ export class SDKError extends Error { */ export class AbortError extends SDKError { name = 'AbortError'; + + constructor(message?: string) { + super(message || c('Error').t`Operation aborted`); + } } /** @@ -108,3 +114,29 @@ export class RateLimitedError extends ServerError { export class ConnectionError extends SDKError { name = 'ConnectionError'; } + +/** + * Error thrown when the decryption fails. + * + * Client should report this error to the user and report bug report. + * + * In most cases, there is no decryption error. Every decryption error should + * be not exposed but set as empty value on the node, for example. But in the + * case of the file content, if block cannot be decrypted, decryption error + * is thrown. + */ +export class DecryptionError extends SDKError { + name = 'DecryptionError'; +} + +/** + * Error thrown when the data integrity check fails. + * + * Client should report this error to the user and report bug report. + * + * For example, it can happen when hashes don't match, etc. In some cases, + * SDK allows to run command without verification checks for debug purposes. + */ +export class IntegrityError extends SDKError { + name = 'IntegrityError'; +} diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index 6834ae0c..a9b6dcfd 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -11,9 +11,30 @@ export interface Download { } export interface FileDownloader { + /** + * Get the claimed size of the file in bytes. + * + * This provides total clear-text size of the file. This is encrypted + * information that is not known to the Proton Drive and thus it is + * explicitely stated as claimed only and must be treated that way. + * It can be wrong or missing completely. + */ getClaimedSizeInBytes(): number | undefined, - writeToStream(streamFactory: WritableStream, onProgress: (writtenBytes: number) => void): DownloadController, - unsafeWriteToStream(streamFactory: WritableStream, onProgress: (writtenBytes: number) => void): DownloadController, + + /** + * Download, decrypt and verify the content from the server and write + * to the provided stream. + * + * @param onProgress - Callback that is called with the number of downloaded bytes + */ + writeToStream(streamFactory: WritableStream, onProgress: (downloadedBytes: number) => void): DownloadController, + + /** + * Same as `writeToStream` but without verification checks. + * + * Use this only for debugging purposes. + */ + unsafeWriteToStream(streamFactory: WritableStream, onProgress: (downloadedBytes: number) => void): DownloadController, } export interface DownloadController { diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 1ac3280d..2230b2f4 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -29,7 +29,7 @@ export type ProtonDriveCryptoCache = ProtonDriveCache; export type CachedCryptoMaterial = { passphrase?: string, key: PrivateKey, - sessionKey: SessionKey, + passphraseSessionKey: SessionKey, hashKey?: Uint8Array, }; diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 782c7e47..03918b93 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -46,15 +46,15 @@ export type MetricsUploadErrorType = export interface MetricDownloadEvent { eventName: 'download', context: MetricContext, - retry: boolean, downloadedSize: number, - fileSize: number, + claimedFileSize?: number, error?: MetricsDownloadErrorType, }; export type MetricsDownloadErrorType = 'server_error' | 'network_error' | 'decryption_error' | + 'integrity_error' | 'rate_limited' | '4xx' | '5xx' | diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 457a09d4..7a372a25 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -102,23 +102,80 @@ export class DriveAPIService { return this.makeRequest(url, 'DELETE', undefined, signal); }; + private async makeRequest( + url: string, + method = 'GET', + data?: RequestPayload, + signal?: AbortSignal, + ): Promise { + const request = new Request(`${this.baseUrl}/${url}`, { + method: method || 'GET', + // TODO: set SDK version (or set via http client at init?) + headers: new Headers({ + "Accept": "application/vnd.protonmail.v1+json", + "Content-Type": "application/json", + "Language": this.language, + }), + body: data && JSON.stringify(data), + }); + + const response = await this.fetch(request, signal); + + try { + const result = await response.json(); + + if (!response.ok || !isCodeOk(result.Code)) { + throw apiErrorFactory({ response, result }); + } + return result as ResponsePayload; + } catch (error: unknown) { + if (error instanceof ServerError) { + throw error; + } + throw apiErrorFactory({ response }); + } + } + + async getBlockStream(baseUrl: string, token: string, signal?: AbortSignal): Promise> { + const response = await this.makeStorageRequest('GET', baseUrl, token, signal); + if (!response.body) { + throw new Error(c('Error').t`File download failed due to empty response`); + } + return response.body; + } + + private async makeStorageRequest(method: 'GET' | 'POST', url: string, token: string, signal?: AbortSignal): Promise { + const request = new Request(`${url}`, { + method, + credentials: 'omit', + headers: new Headers({ + "pm-storage-token": token, + }), + }); + + const response = await this.fetch(request, signal); + + if (response.status >= 400) { + throw apiErrorFactory({ response }); + } + return response; + } + // TODO: add priority header // u=2 for interactive (user doing action, e.g., create folder), // u=4 for normal (user secondary action, e.g., refresh children listing), // u=5 for background (e.g., upload, download) // u=7 for optional (e.g., metrics, telemetry) - private async makeRequest( - url: string, - method = 'GET', - data?: RequestPayload, + private async fetch( + request: Request, signal?: AbortSignal, attempt = 0 - ): Promise { + ): Promise { if (signal?.aborted) { throw new AbortError(c('Error').t`Request aborted`); } - this.logger.debug(`${method} ${url}`); + this.logger.debug(`${request.method} ${request.url}`); if (this.hasReachedServerErrorLimit) { this.logger.warn('Server errors limit reached'); @@ -131,39 +188,34 @@ export class DriveAPIService { let response; try { - response = await this.httpClient.fetch(new Request(`${this.baseUrl}/${url}`, { - method: method || 'GET', - // TODO: set SDK version (or set via http client at init?) - headers: new Headers({ - "Accept": "application/vnd.protonmail.v1+json", - "Content-Type": "application/json", - "Language": this.language, - }), body: data && JSON.stringify(data), - - }), signal); + response = await this.httpClient.fetch(request, signal); } catch (error: unknown) { if (error instanceof Error) { if (error.name === 'OfflineError') { + this.logger.info(`${request.method} ${request.url}: Offline error, retrying`); await waitSeconds(OFFLINE_RETRY_DELAY_SECONDS); - return this.makeRequest(url, method, data, signal, attempt+1); + return this.fetch(request, signal, attempt+1); } if (error.name === 'TimeoutError') { + this.logger.warn(`${request.method} ${request.url}: Timeout error, retrying`); await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); - return this.makeRequest(url, method, data, signal, attempt+1); + return this.fetch(request, signal, attempt+1); } } if (attempt === 0) { + this.logger.error(`${request.method} ${request.url}: failed, retrying once`, error); await waitSeconds(GENERAL_RETRY_DELAY_SECONDS); - return this.makeRequest(url, method, data, signal, attempt+1); + return this.fetch(request, signal, attempt+1); } + this.logger.error(`${request.method} ${request.url}: failed`, error); throw error; } if (response.ok) { - this.logger.info(`${method} ${url}: ${response.status}`); + this.logger.info(`${request.method} ${request.url}: ${response.status}`); } else { - this.logger.warn(`${method} ${url}: ${response.status}`); + this.logger.warn(`${request.method} ${request.url}: ${response.status}`); } if (response.status === HTTPErrorCode.TOO_MANY_REQUESTS) { @@ -171,7 +223,7 @@ export class DriveAPIService { this.tooManyRequestsErrorHappened(); const timeout = parseInt(response.headers.get('retry-after') || '0', DEFAULT_429_RETRY_DELAY_SECONDS); await waitSeconds(timeout); - return this.makeRequest(url, method, data, signal, attempt+1); + return this.fetch(request, signal, attempt+1); } else { this.clearSubsequentTooManyRequestsError(); } @@ -182,32 +234,24 @@ export class DriveAPIService { this.serverErrorHappened(); if (attempt > 0) { - this.logger.warn(`${method} ${url}: ${response.status} - retry failed`); + this.logger.warn(`${request.method} ${request.url}: ${response.status} - retry failed`); } else { await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); - return this.makeRequest(url, method, data, signal, attempt+1); + return this.fetch(request, signal, attempt+1); } } else { if (attempt > 0) { - this.telemetry.logEvent({ eventName: 'apiRetrySucceeded', failedAttempts: attempt, url }); - this.logger.warn(`${method} ${url}: ${response.status} - retry helped`); + this.telemetry.logEvent({ + eventName: 'apiRetrySucceeded', + failedAttempts: attempt, + url: request.url, + }); + this.logger.warn(`${request.method} ${request.url}: ${response.status} - retry helped`); } this.clearSubsequentServerErrors(); } - try { - const result = await response.json(); - - if (!response.ok || !isCodeOk(result.Code)) { - throw apiErrorFactory({ response, result }); - } - return result as ResponsePayload; - } catch (error: unknown) { - if (error instanceof ServerError) { - throw error; - } - throw apiErrorFactory({ response }); - } + return response; } private get hasReachedTooManyRequestsErrorLimit(): boolean { diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index e9d335b7..c3ea996d 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -1,5 +1,6 @@ export const enum HTTPErrorCode { OK = 200, + NOT_FOUND = 404, TOO_MANY_REQUESTS = 429, INTERNAL_SERVER_ERROR = 500, } diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index c4d68a76..b35a37b5 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -1,6 +1,7 @@ export { DriveAPIService } from './apiService'; export { paths as drivePaths } from './driveTypes'; export { paths as corePaths } from './coreTypes'; -export { ErrorCode, isCodeOk } from './errorCodes'; +export { HTTPErrorCode, ErrorCode, isCodeOk } from './errorCodes'; export { nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from './transformers'; +export { ObserverStream } from './observerStream'; export * from './errors'; diff --git a/js/sdk/src/internal/apiService/observerStream.ts b/js/sdk/src/internal/apiService/observerStream.ts new file mode 100644 index 00000000..ec5c7330 --- /dev/null +++ b/js/sdk/src/internal/apiService/observerStream.ts @@ -0,0 +1,10 @@ +export class ObserverStream extends TransformStream { + constructor(fn?: (chunk: Uint8Array) => void) { + super({ + transform(chunk, controller) { + fn?.(chunk); + controller.enqueue(chunk); + }, + }); + } +} diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index 59b7c970..ace3a706 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -1,7 +1,8 @@ -import { DriveAPIService, drivePaths } from "../apiService"; +import { DriveAPIService, drivePaths, ObserverStream } from "../apiService"; import { splitNodeRevisionUid } from "../uids"; +import { BlockMetadata } from "./interface"; -const BLOCKS_PAGE_SIZE = 50; +const BLOCKS_PAGE_SIZE = 20; type GetRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; @@ -10,34 +11,83 @@ export class DownloadAPIService { this.apiService = apiService; } - async* iterateRevisionBlocks(nodeRevisionUid: string, signal?: AbortSignal): AsyncGenerator<{ - bareUrl: string, - index: number, - hash: string, - token: string, - }> { + async* iterateRevisionBlocks(nodeRevisionUid: string, signal?: AbortSignal, fromBlockIndex = 1): AsyncGenerator< + { type: 'manifestSignature', armoredManifestSignature?: string } | + { type: 'thumbnail', base64sha256Hash: string } | + { type: 'block' } & BlockMetadata + > { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); - - let fromBlockIndex = 1; + while (true) { + if (signal?.aborted) { + break; + } + const result = await this.apiService.get( `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?PageSize=${BLOCKS_PAGE_SIZE}&FromBlockIndex=${fromBlockIndex}`, signal, ); + if (fromBlockIndex === 1) { + yield { + type: 'manifestSignature', + armoredManifestSignature: result.Revision.ManifestSignature || undefined, + }; + + if (result.Revision.Thumbnails.length > 0) { + for (const block of result.Revision.Thumbnails) { + yield { + type: 'thumbnail', + base64sha256Hash: block.Hash, + } + } + } + } + if (result.Revision.Blocks.length === 0) { break; } for (const block of result.Revision.Blocks) { yield { - bareUrl: block.BareURL as string, - index: block.Index, - hash: block.Hash, - token: block.Token as string, + type: 'block', + ...transformBlock(block), }; fromBlockIndex = block.Index + 1; } } } + + async getRevisionBlockToken(nodeRevisionUid: string, blockIndex: number, signal?: AbortSignal): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + const result = await this.apiService.get( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?PageSize=1&FromBlockIndex=${blockIndex}`, + signal, + ); + + const block = result.Revision.Blocks[0]; + return transformBlock(block); + } + + async downloadBlock(baseUrl: string, token: string, onProgress?: (downloadedBytes: number) => void, signal?: AbortSignal): Promise { + const rawBlockStream = await this.apiService.getBlockStream(baseUrl, token, signal); + const progressStream = new ObserverStream((value) => { + onProgress?.(value.length); + }); + const blockStream = rawBlockStream.pipeThrough(progressStream); + const encryptedBlock = new Uint8Array(await new Response(blockStream).arrayBuffer()); + return encryptedBlock; + } +} + +function transformBlock(block: GetRevisionResponse['Revision']['Blocks'][0]): BlockMetadata { + return { + index: block.Index, + bareUrl: block.BareURL as string, + token: block.Token as string, + base64sha256Hash: block.Hash, + signatureEmail: block.SignatureEmail || undefined, + armoredSignature: block.EncSignature || undefined, + }; } diff --git a/js/sdk/src/internal/download/controller.ts b/js/sdk/src/internal/download/controller.ts new file mode 100644 index 00000000..b3e01e37 --- /dev/null +++ b/js/sdk/src/internal/download/controller.ts @@ -0,0 +1,22 @@ +import { waitForCondition } from './wait'; + +export class DownloadController { + private paused = false; + public promise?: Promise; + + async waitWhilePaused(): Promise { + await waitForCondition(() => !this.paused); + } + + pause(): void { + this.paused = true; + } + + resume(): void { + this.paused = false; + } + + async completion(): Promise { + await this.promise; + } +} diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index 2e0caadb..487ed6d8 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -1,10 +1,85 @@ -import { DriveCrypto } from "../../crypto"; +import { c } from 'ttag'; + +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, uint8ArrayToBase64String, VERIFICATION_STATUS } from "../../crypto"; +import { ProtonDriveAccount, Revision } from "../../interface"; +import { DecryptionError, IntegrityError } from "../../errors"; +import { getErrorMessage } from "../errors"; +import { RevisionKeys } from "./interface"; export class DownloadCryptoService { - constructor(private driveCrypto: DriveCrypto) { + constructor(private driveCrypto: DriveCrypto, private account: ProtonDriveAccount) { + this.account = account; this.driveCrypto = driveCrypto; } - async decryptBlock() { + async getRevisionKeys(nodeKey: { key: PrivateKey, contentKeyPacketSessionKey: SessionKey }, revision: Revision): Promise { + const verificationKeys = await this.getRevisionVerificationKeys(revision); + return { + ...nodeKey, + verificationKeys, + } + } + + async decryptBlock(encryptedBlock: Uint8Array, armoredSignature: string, revisionKeys: RevisionKeys): Promise { + let decryptedBlock; + try { + // We do not verify signatures on blocks. We only verify + // the signature on the revision content key packet and + // the manifest of the revision. + // We plan to drop signatures of individual blocks + // completely in the future. Any issue on the blocks + // should be considered serious integrity issue. + const result = await this.driveCrypto.decryptBlock( + encryptedBlock, + armoredSignature, + revisionKeys.key, + revisionKeys.contentKeyPacketSessionKey, + revisionKeys.verificationKeys, + ); + decryptedBlock = result.decryptedBlock; + } catch (error: unknown) { + throw new DecryptionError(c('Error').t`Failed to decrypt block: ${getErrorMessage(error)}`); + } + + return decryptedBlock; + } + + async verifyBlockIntegrity(encryptedBlock: Uint8Array, base64sha256Hash: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', encryptedBlock); + const expectedHash = uint8ArrayToBase64String(new Uint8Array(digest)); + + if (expectedHash !== base64sha256Hash) { + throw new IntegrityError(c('Error').t`Data integrity check of one part failed`); + } + } + + async verifyManifest(revision: Revision, nodeKey: PrivateKey, allBlockHashes: Uint8Array[], armoredManifestSignature?: string): Promise { + const verificationKeys = await this.getRevisionVerificationKeys(revision) || nodeKey; + const hash = mergeUint8Arrays(allBlockHashes); + + if (!armoredManifestSignature) { + throw new IntegrityError(c('Error').t`Missing integrity signature`); + } + + const { verified } = await this.driveCrypto.verifyManifest(hash, armoredManifestSignature, verificationKeys); + if (verified !== VERIFICATION_STATUS.SIGNED_AND_VALID) { + throw new IntegrityError(c('Error').t`Date integrity check failed`); + } + } + + private async getRevisionVerificationKeys(revision: Revision): Promise { + const signatureEmail = revision.contentAuthor.ok ? revision.contentAuthor.value : revision.contentAuthor.error.claimedAuthor; + return signatureEmail ? await this.account.getPublicKeys(signatureEmail) : undefined; } } + +function mergeUint8Arrays(arrays: Uint8Array[]) { + const length = arrays.reduce((sum, arr) => sum + arr.length, 0); + const chunksAll = new Uint8Array(length); + arrays.reduce((position, arr) => { + chunksAll.set(arr, position); + return position + arr.length; + }, 0); + return chunksAll; +} + diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts new file mode 100644 index 00000000..e6e97bdc --- /dev/null +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -0,0 +1,384 @@ +import { ValidationError } from '../../errors'; +import { NodeEntity, NodeType, Revision } from '../../interface'; +import { FileDownloader } from './fileDownloader'; +import { DownloadTelemetry } from './telemetry'; +import { DownloadAPIService } from './apiService'; +import { DownloadCryptoService } from './cryptoService'; +import { DownloadController } from './controller'; +import { APIHTTPError, HTTPErrorCode } from '../apiService'; + +function mockBlockDownload(_: string, token: string, onProgress: (downloadedBytes: number) => void) { + const index = parseInt(token.slice(5, 6)); + const array = new Uint8Array(index); + for (let i = 0; i < index; i++) { + array[i] = i; + } + + onProgress(array.length); + return array; +} + +describe('FileDownloader', () => { + let telemetry: DownloadTelemetry; + let apiService: DownloadAPIService; + let cryptoService: DownloadCryptoService; + let controller: DownloadController; + let node: NodeEntity; + let nodeKey: { key: string; contentKeyPacketSessionKey?: string }; + let revision: Revision; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + telemetry = { + getLoggerForNode: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + downloadInitFailed: jest.fn(), + downloadFailed: jest.fn(), + downloadFinished: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + apiService = { + iterateRevisionBlocks: jest.fn().mockImplementation(async function* () { + yield { type: 'manifestSignature', armoredManifestSignature: 'manifestSignature' }; + yield { type: 'thumbnail', base64sha256Hash: 'aGFzaDA=' }; + yield { type: 'block', index: 1, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 2, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + yield { type: 'block', index: 3, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; + }), + getRevisionBlockToken: jest.fn().mockImplementation(async (_, blockIndex: number) => ({ + index: blockIndex, + bareUrl: 'url', + token: `token${blockIndex}-refreshed`, + base64sha256Hash: `hash${blockIndex}`, + })), + // By default, return a block of length equal to the index number. + downloadBlock: jest.fn().mockImplementation(mockBlockDownload), + }; + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + getRevisionKeys: jest.fn().mockImplementation(async () => ({ + key: 'privateKey', + contentKeyPacketSessionKey: 'contentSessionKey', + verificationKeys: 'verificationKeys', + })), + decryptBlock: jest.fn().mockImplementation(async (encryptedBlock) => encryptedBlock), + verifyBlockIntegrity: jest.fn().mockResolvedValue(undefined), + verifyManifest: jest.fn().mockResolvedValue(undefined), + }; + controller = new DownloadController(); + + node = { + uid: 'nodeUid', + type: NodeType.File, + activeRevision: { + ok: true, + value: { + uid: 'revisionUid', + claimedSize: 1024, + }, + }, + } as NodeEntity; + + nodeKey = { + key: 'privateKey', + contentKeyPacketSessionKey: 'sessionKey', + }; + + revision = { + uid: 'revisionUid', + claimedSize: 1024, + } as Revision; + }); + + describe('constructor', () => { + it('should throw an error if the node is a folder', () => { + node.type = NodeType.Folder; + + expect(() => { + new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node); + }).toThrow(ValidationError); + }); + + it('should throw an error if the node has no active revision', () => { + node.activeRevision = undefined; + + expect(() => { + new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node); + }).toThrow(ValidationError); + }); + + it('should throw an error if the node key has no content key', () => { + nodeKey.contentKeyPacketSessionKey = undefined; + + expect(() => { + new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node); + }).toThrow(ValidationError); + }); + + it('should initialize correctly', () => { + const downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node); + + expect(downloader.getClaimedSizeInBytes()).toBe(revision.claimedSize); + }); + }); + + describe('writeToStream', () => { + let onProgress: (downloadedBytes: number) => void; + let onFinish: () => void; + + let downloader: FileDownloader; + let writer: WritableStreamDefaultWriter; + let stream: WritableStream; + + const verifySuccess = async () => { + const controller = downloader.writeToStream(stream, onProgress); + await controller.completion(); + + expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); + expect(cryptoService.verifyManifest).toHaveBeenCalledTimes(1); + expect(writer.close).toHaveBeenCalledTimes(1); + expect(writer.abort).not.toHaveBeenCalled(); + expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); + expect(telemetry.downloadFinished).toHaveBeenCalledWith(6); // 3 blocks of length 1, 2, 3. + expect(telemetry.downloadFailed).not.toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalledTimes(1); + } + + const verifyFailure = async (error: string, downloadedBytes: number | undefined) => { + const controller = downloader.writeToStream(stream, onProgress); + + await expect(controller.completion()).rejects.toThrow(error); + + expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); + expect(writer.close).not.toHaveBeenCalled(); + expect(writer.abort).toHaveBeenCalledTimes(1); + expect(telemetry.downloadFinished).not.toHaveBeenCalled(); + expect(telemetry.downloadFailed).toHaveBeenCalledTimes(1); + expect(telemetry.downloadFailed).toHaveBeenCalledWith( + new Error(error), + downloadedBytes === undefined ? expect.anything() : downloadedBytes, + revision.claimedSize, + ); + expect(onFinish).toHaveBeenCalledTimes(1); + }; + + const verifyOnProgress = async (downloadedBytes: number[]) => { + expect(onProgress).toHaveBeenCalledTimes(downloadedBytes.length); + for (let i = 0; i < downloadedBytes.length; i++) { + expect(onProgress).toHaveBeenNthCalledWith(i + 1, downloadedBytes[i]); + } + }; + + beforeEach(() => { + onProgress = jest.fn(); + onFinish = jest.fn(); + + // @ts-expect-error Mocking WritableStreamDefaultWriter + writer = { + write: jest.fn(), + close: jest.fn(), + abort: jest.fn(), + } + // @ts-expect-error Mocking WritableStream + stream = { + getWriter: () => writer, + } + downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node, undefined, onFinish); + }); + + it('should reject two download starts', async () => { + downloader.writeToStream(stream, onProgress); + expect(() => downloader.writeToStream(stream, onProgress)).toThrow('Download already started'); + expect(() => downloader.unsafeWriteToStream(stream, onProgress)).toThrow('Download already started'); + }); + + it('should start a download and write to the stream', async () => { + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, 2, 3]); + }); + + it('should handle failure when iterating blocks', async () => { + apiService.iterateRevisionBlocks = jest.fn().mockImplementation(async function* () { + throw new Error('Failed to iterate blocks'); + }); + + await verifyFailure('Failed to iterate blocks', 0); + }); + + it('should handle failure when downloading block', async () => { + apiService.downloadBlock = jest.fn().mockImplementation(async function () { + throw new Error('Failed to download block'); + }); + + await verifyFailure('Failed to download block', 0); + }); + + it('should handle one time-off failure when downloading block', async () => { + let count = 0; + apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { + if (count === 0) { + count++; + onProgress?.(1); // Simulate the failure happens after some progress. + throw new Error('Failed to download block'); + } + return mockBlockDownload(bareUrl, token, onProgress); + }); + + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, -1, 1, 2, 3]); + }); + + it('should handle expired token when downloading block', async () => { + let count = 0; + apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { + if (count === 0) { + count++; + throw new APIHTTPError('Expired token', HTTPErrorCode.NOT_FOUND); + } + return mockBlockDownload(bareUrl, token, onProgress); + }); + + await verifySuccess(); + expect(apiService.getRevisionBlockToken).toHaveBeenCalledTimes(1); + expect(apiService.getRevisionBlockToken).toHaveBeenCalledWith('revisionUid', 1, undefined); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, 2, 3]); + }); + + it('should handle failure when veryfing block', async () => { + cryptoService.verifyBlockIntegrity = jest.fn().mockImplementation(async function () { + throw new Error('Failed to verify block'); + }); + + await verifyFailure('Failed to verify block', undefined); + }); + + it('should handle one time-off failure when veryfing block', async () => { + let count = 0; + cryptoService.verifyBlockIntegrity = jest.fn().mockImplementation(async function () { + if (count === 0) { + count++; + throw new Error('Failed to verify block'); + } + }); + + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(4); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, -1, 1, 2, 3]); + }); + + it('should handle failure when decrypting block', async () => { + cryptoService.decryptBlock = jest.fn().mockImplementation(async function () { + throw new Error('Failed to decrypt block'); + }); + + await verifyFailure('Failed to decrypt block', undefined); + }); + + it('should handle one time-off failure when decrypting block', async () => { + let count = 0; + cryptoService.decryptBlock = jest.fn().mockImplementation(async function (encryptedBlock) { + if (count === 0) { + count++; + throw new Error('Failed to decrypt block'); + } + return encryptedBlock; + }); + + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(4); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(4); + await verifyOnProgress([1, -1, 1, 2, 3]); + }); + + it('should handle failure when writing to the stream', async () => { + writer.write = jest.fn().mockImplementation(async function () { + throw new Error('Failed to write data'); + }); + + await verifyFailure('Failed to write data', undefined); + }); + + it('should handle one time-off failure when writing to the stream', async () => { + let count = 0; + writer.write = jest.fn().mockImplementation(async function () { + if (count === 0) { + count++; + throw new Error('Failed to write data'); + } + }); + + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, 2, 3]); + }); + + it('should handle failure when veryfing manifest', async () => { + cryptoService.verifyManifest = jest.fn().mockImplementation(async function () { + throw new Error('Failed to verify manifest'); + }); + + await verifyFailure('Failed to verify manifest', 6); // All blocks of length 1, 2, 3. + }); + }); + + describe('unsafeWriteToStream', () => { + let onProgress: (downloadedBytes: number) => void; + let onFinish: () => void; + + let downloader: FileDownloader; + let writer: WritableStreamDefaultWriter; + let stream: WritableStream; + + beforeEach(() => { + onProgress = jest.fn(); + onFinish = jest.fn(); + + // @ts-expect-error Mocking WritableStreamDefaultWriter + writer = { + write: jest.fn(), + close: jest.fn(), + abort: jest.fn(), + } + // @ts-expect-error Mocking WritableStream + stream = { + getWriter: () => writer, + } + downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node, undefined, onFinish); + }); + + it('should skip verification steps', async () => { + const controller = downloader.unsafeWriteToStream(stream, onProgress); + await controller.completion(); + + expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); + expect(cryptoService.verifyManifest).not.toHaveBeenCalled(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + expect(cryptoService.verifyBlockIntegrity).not.toHaveBeenCalled(); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + expect(writer.close).toHaveBeenCalledTimes(1); + expect(writer.abort).not.toHaveBeenCalled(); + expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); + expect(telemetry.downloadFinished).toHaveBeenCalledWith(6); // 3 blocks of length 1, 2, 3. + expect(telemetry.downloadFailed).not.toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index c5e30d37..e5cf1c6e 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -1,69 +1,268 @@ import { c } from 'ttag'; -import { PrivateKey } from "../../crypto"; +import { PrivateKey, SessionKey, base64StringToUint8Array } from "../../crypto"; import { ValidationError } from "../../errors"; -import { NodeEntity, NodeType } from "../../interface"; +import { Logger, NodeEntity, NodeType, Revision } from "../../interface"; +import { LoggerWithPrefix } from "../../telemetry"; +import { APIHTTPError, HTTPErrorCode } from '../apiService'; import { DownloadAPIService } from "./apiService"; +import { DownloadController } from './controller'; import { DownloadCryptoService } from "./cryptoService"; +import { BlockMetadata, RevisionKeys } from './interface'; +import { DownloadTelemetry } from './telemetry'; + +/** + * Maximum number of blocks that can be downloaded at the same time + * for a single file. This is to prevent downloading too many blocks + * at the same time and running out of memory. + */ +const MAX_DOWNLOAD_BLOCK_SIZE = 10; export class FileDownloader { + private logger: Logger; + + private nodeKey: { key: PrivateKey, contentKeyPacketSessionKey: SessionKey } + private revision: Revision; + + private controller: DownloadController; + private nextBlockIndex = 1; + private ongoingDownloads = new Map, + decryptedBufferedBlock?: Uint8Array, + }>(); + constructor( + private telemetry: DownloadTelemetry, private apiService: DownloadAPIService, private cryptoService: DownloadCryptoService, - private nodeKey: PrivateKey, - private node: NodeEntity, + nodeKey: { key: PrivateKey, contentKeyPacketSessionKey?: SessionKey }, + node: NodeEntity, private signal?: AbortSignal, + private onFinish?: () => void, ) { + this.telemetry = telemetry; + this.logger = telemetry.getLoggerForNode(node.uid); this.apiService = apiService; this.cryptoService = cryptoService; - this.nodeKey = nodeKey; - this.node = node; this.signal = signal; + this.onFinish = onFinish; + + if (node.type === NodeType.Folder) { + throw new ValidationError(c("Error").t`Cannot download a folder`); + } + if (!node.activeRevision?.ok || !node.activeRevision.value) { + throw new ValidationError(c("Error").t`File has no active revision`); + } + if (!nodeKey.contentKeyPacketSessionKey) { + throw new ValidationError(c("Error").t`File has no content key`); + } + + this.nodeKey = { + key: nodeKey.key, + contentKeyPacketSessionKey: nodeKey.contentKeyPacketSessionKey, + }; + this.revision = node.activeRevision.value; + + this.controller = new DownloadController(); } getClaimedSizeInBytes(): number | undefined { - if (this.node.activeRevision?.ok) { - return this.node.activeRevision.value.claimedSize; - } + return this.revision.claimedSize; } - writeToStream(stream: WritableStream, onProgress: (writtenBytes: number) => void): DownloadController { - const controller = new DownloadController(); - void this.downloadToStream(controller, stream, onProgress); - return controller; + writeToStream(stream: WritableStream, onProgress: (downloadedBytes: number) => void): DownloadController { + if (this.controller.promise) { + throw new Error(`Download already started`); + } + this.controller.promise = this.downloadToStream(stream, onProgress); + return this.controller; } - unsafeWriteToStream(stream: WritableStream, onProgress: (writtenBytes: number) => void): DownloadController { - const controller = new DownloadController(); - void this.downloadToStream(controller, stream, onProgress); - return controller; + unsafeWriteToStream(stream: WritableStream, onProgress: (downloadedBytes: number) => void): DownloadController { + if (this.controller.promise) { + throw new Error(`Download already started`); + } + const ignoreIntegrityErrors = true; + this.controller.promise = this.downloadToStream(stream, onProgress, ignoreIntegrityErrors); + return this.controller; } - private async downloadToStream(controller: DownloadController, stream: WritableStream, onProgress: (writtenBytes: number) => void): Promise { - if (this.node.type === NodeType.Folder) { - throw new ValidationError(c("Error").t`Cannot download a folder`); + private async downloadToStream( + stream: WritableStream, + onProgress?: (writtenBytes: number) => void, + ignoreIntegrityErrors = false, + ): Promise { + const writer = stream.getWriter(); + const cryptoKeys = await this.cryptoService.getRevisionKeys(this.nodeKey, this.revision); + + // File progress is tracked for telemetry - to track at what + // point the download failed. + let fileProgress = 0; + + // Collection of all block hashes for manifest verification. + // This includes both thumbnail and regular blocks. + const allBlockHashes: Uint8Array[] = []; + let armoredManifestSignature: string | undefined; + + try { + this.logger.info(`Starting download`); + for await (const blockMetadata of this.apiService.iterateRevisionBlocks(this.revision.uid, this.signal)) { + if (blockMetadata.type === 'manifestSignature') { + armoredManifestSignature = blockMetadata.armoredManifestSignature; + continue; + } + + allBlockHashes.push(base64StringToUint8Array(blockMetadata.base64sha256Hash)); + if (blockMetadata.type === 'thumbnail') { + continue; + } + + await this.controller.waitWhilePaused(); + + const downloadPromise = this.downloadBlock( + blockMetadata, + ignoreIntegrityErrors, + cryptoKeys, + (downloadedBytes) => { + fileProgress += downloadedBytes; + onProgress?.(downloadedBytes); + }, + ); + this.ongoingDownloads.set(blockMetadata.index, { downloadPromise }); + + await this.waitForDownloadCapacity(); + await this.flushCompletedBlocks(writer); + } + + this.logger.debug(`All blocks downloading, waiting for them to finish`); + await Promise.all(this.downloadPromises); + await this.flushCompletedBlocks(writer); + + if (this.ongoingDownloads.size > 0) { + this.logger.error(`Some blocks were not downloaded: ${this.ongoingDownloads.keys()}`); + // This is a bug in the algorithm. + throw new Error(`Some blocks were not downloaded`); + } + + if (ignoreIntegrityErrors) { + this.logger.warn('Skipping manifest check'); + } else { + this.logger.debug(`Verifying manifest`); + await this.cryptoService.verifyManifest(this.revision, this.nodeKey.key, allBlockHashes, armoredManifestSignature); + } + + await writer.close(); + this.telemetry.downloadFinished(fileProgress); + this.logger.info(`Download succeeded`); + } catch (error: unknown) { + this.logger.error(`Download failed`, error); + this.telemetry.downloadFailed(error, fileProgress, this.getClaimedSizeInBytes()); + await writer.abort(); + throw error; + } finally { + this.logger.debug(`Download cleanup`); + this.onFinish?.(); } - if (!this.node.activeRevision?.ok || !this.node.activeRevision.value) { - throw new ValidationError(c("Error").t`File has no active revision`); + } + + private async downloadBlock( + blockMetadata: BlockMetadata, + ignoreIntegrityErrors: boolean, + cryptoKeys: RevisionKeys, + onProgress: (downloadedBytes: number) => void, + ) { + const logger = new LoggerWithPrefix(this.logger, `block ${blockMetadata.index}`); + logger.info(`Download started`); + + let blockProgress = 0; + let decryptedBlock: Uint8Array | null = null; + let retries = 0; + + while (!decryptedBlock) { + logger.debug(`Downloading`); + await this.controller.waitWhilePaused(); + try { + const encryptedBlock = await this.apiService.downloadBlock(blockMetadata.bareUrl, blockMetadata.token, (downloadedBytes) => { + blockProgress += downloadedBytes; + onProgress?.(downloadedBytes); + }, this.signal); + + if (ignoreIntegrityErrors) { + logger.warn('Skipping hash check'); + } else { + logger.debug(`Verifying hash`); + await this.cryptoService.verifyBlockIntegrity(encryptedBlock, blockMetadata.base64sha256Hash); + } + + logger.debug(`Decrypting`); + decryptedBlock = await this.cryptoService.decryptBlock(encryptedBlock, blockMetadata.armoredSignature!, cryptoKeys); + } catch (error) { + if (blockProgress !== 0) { + onProgress?.(-blockProgress); + blockProgress = 0; + } + + if (error instanceof APIHTTPError && error.statusCode === HTTPErrorCode.NOT_FOUND) { + logger.warn(`Token expired, fetching new token and retrying`); + blockMetadata = await this.apiService.getRevisionBlockToken(this.revision.uid, blockMetadata.index, this.signal); + continue; + } + + // Download can fail for various reasons, for example integrity + // can fail due to bitflips. We want to retry and solve the issue + // seamlessly for the user. We retry only once, because we don't + // want to get stuck in a loop. + if (retries === 0) { + logger.error(`Download failed, retrying`, error); + retries++; + continue; + } + + logger.error(`Download failed`, error); + throw error; + } } - // TODO - const nodeRevisionsUid = this.node.activeRevision.value.uid; - const writer = stream.getWriter(); - for await (const block of this.apiService.iterateRevisionBlocks(nodeRevisionsUid, this.signal)) { - await writer.write(block.bareUrl); - onProgress(block.bareUrl.length); + this.ongoingDownloads.get(blockMetadata.index)!.decryptedBufferedBlock = decryptedBlock; + logger.info(`Downloaded`); + } + + private async waitForDownloadCapacity() { + if (this.ongoingDownloads.size >= MAX_DOWNLOAD_BLOCK_SIZE) { + this.logger.info(`Download limit reached, waiting for next block to be finished`); + + // We need to ensure the next block is downloaded, otherwise the + // buffer will still be full. + while (!this.isNextBlockDownloaded) { + // Promise.race is used to ensure if any block fails, the error is + // thrown up the chain and we dont end up in stuck loop here waiting + // for the next block to be ready. + await Promise.race(this.downloadPromises); + } } } -} -class DownloadController { - async pause(): Promise { + private async flushCompletedBlocks(writer: WritableStreamDefaultWriter) { + this.logger.debug(`Flushing completed blocks`); + while (this.isNextBlockDownloaded) { + const decryptedBlock = this.ongoingDownloads.get(this.nextBlockIndex)!.decryptedBufferedBlock!; + this.logger.info(`Flushing completed block ${this.nextBlockIndex}`); + try { + await writer.write(decryptedBlock); + } catch (error) { + this.logger.error(`Failed to write block, retrying once`, error); + await writer.write(decryptedBlock); + } + this.ongoingDownloads.delete(this.nextBlockIndex); + this.nextBlockIndex++; + } } - async resume(): Promise { + private get downloadPromises() { + return this.ongoingDownloads.values().map(({ downloadPromise }) => downloadPromise); } - async completion(): Promise { + private get isNextBlockDownloaded() { + return !!this.ongoingDownloads.get(this.nextBlockIndex)?.decryptedBufferedBlock; } } diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts index 5a682355..5755a532 100644 --- a/js/sdk/src/internal/download/index.ts +++ b/js/sdk/src/internal/download/index.ts @@ -1,22 +1,49 @@ import { DriveCrypto } from "../../crypto"; +import { ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; import { DriveAPIService } from "../apiService"; import { DownloadAPIService } from "./apiService"; import { DownloadCryptoService } from "./cryptoService"; import { NodesService } from "./interface"; import { FileDownloader } from "./fileDownloader"; +import { DownloadQueue } from "./queue"; +import { DownloadTelemetry } from "./telemetry"; export function initDownloadModule( + telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, driveCrypto: DriveCrypto, + account: ProtonDriveAccount, nodesService: NodesService, ) { + const queue = new DownloadQueue(); const api = new DownloadAPIService(apiService); - const cryptoService = new DownloadCryptoService(driveCrypto); + const cryptoService = new DownloadCryptoService(driveCrypto, account); + const downloadTelemetry = new DownloadTelemetry(telemetry); async function getFileDownloader(nodeUid: string, signal?: AbortSignal) { - const { key } = await nodesService.getNodeKeys(nodeUid); - const node = await nodesService.getNode(nodeUid); - return new FileDownloader(api, cryptoService, key, node, signal); + await queue.waitForCapacity(signal); + + let node, nodeKey; + try { + node = await nodesService.getNode(nodeUid); + nodeKey = await nodesService.getNodeKeys(nodeUid); + } catch (error: unknown) { + queue.releaseCapacity(); + downloadTelemetry.downloadInitFailed(error); + throw error; + } + + const onFinish = () => queue.releaseCapacity(); + + return new FileDownloader( + downloadTelemetry, + api, + cryptoService, + nodeKey, + node, + signal, + onFinish, + ); } return { diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index a101d4c3..5dcfc095 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,7 +1,22 @@ -import { PrivateKey, SessionKey } from "../../crypto"; +import { PrivateKey, PublicKey, SessionKey } from "../../crypto"; import { NodeEntity } from "../../interface"; +export type BlockMetadata = { + index: number, + bareUrl: string, + token: string, + base64sha256Hash: string, + signatureEmail?: string, + armoredSignature?: string, +}; + +export type RevisionKeys = { + key: PrivateKey, + contentKeyPacketSessionKey: SessionKey, + verificationKeys?: PublicKey[], +} + export interface NodesService { getNode(nodeUid: string): Promise, - getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, sessionKey: SessionKey }>, + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, contentKeyPacketSessionKey?: SessionKey; }>, } diff --git a/js/sdk/src/internal/download/queue.ts b/js/sdk/src/internal/download/queue.ts new file mode 100644 index 00000000..8cc82142 --- /dev/null +++ b/js/sdk/src/internal/download/queue.ts @@ -0,0 +1,30 @@ +import { waitForCondition } from './wait'; + +/** + * A queue that limits the number of concurrent downloads. + * + * This is used to limit the number of concurrent downloads to avoid + * overloading the server, or get rate limited. + * + * Each file download consumes memory and is limited by the number of + * concurrent block downloads for each file. + * + * This queue is straitforward and does not have any priority mechanism + * or other features, such as limiting total number of blocks being + * downloaded. That is something we want to add in the future to be + * more performant for many small file downloads. + */ +const MAX_CONCURRENT_DOWNLOADS = 5; + +export class DownloadQueue { + private capacity = 0; + + async waitForCapacity(signal?: AbortSignal) { + await waitForCondition(() => this.capacity < MAX_CONCURRENT_DOWNLOADS, signal); + this.capacity++; + } + + releaseCapacity() { + this.capacity--; + } +} diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts new file mode 100644 index 00000000..633938ee --- /dev/null +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -0,0 +1,125 @@ +import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from '../../errors'; +import { ProtonDriveTelemetry } from '../../interface'; +import { APIHTTPError } from '../apiService'; +import { DownloadTelemetry } from './telemetry'; + +describe('DownloadTelemetry', () => { + let mockTelemetry: jest.Mocked; + let downloadTelemetry: DownloadTelemetry; + + beforeEach(() => { + mockTelemetry = { + logEvent: jest.fn(), + getLogger: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }), + } as unknown as jest.Mocked; + + downloadTelemetry = new DownloadTelemetry(mockTelemetry); + }); + + it('should log failure during init (excludes file size)', () => { + downloadTelemetry.downloadInitFailed(new Error('Failed')); + + expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + eventName: "download", + context: "own_volume", + downloadedSize: 0, + error: "unknown", + }); + }); + + it('should log failure download', () => { + downloadTelemetry.downloadFailed(new Error('Failed'), 123, 456); + + expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + eventName: "download", + context: "own_volume", + downloadedSize: 123, + claimedFileSize: 456, + error: "unknown", + }); + }); + + it('should log successful download (excludes error)', () => { + downloadTelemetry.downloadFinished(500); + + expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + eventName: "download", + context: "own_volume", + downloadedSize: 500, + claimedFileSize: 500, + }); + }); + + describe('detect error category', () => { + const verifyErrorCategory = (error: string) => { + expect(mockTelemetry.logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + error, + }) + ); + } + + it('should ignore ValidationError', () => { + const error = new ValidationError('Validation error'); + downloadTelemetry.downloadFailed(error, 100, 200); + expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); + }); + + it('should ignore AbortError', () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + downloadTelemetry.downloadFailed(error, 100, 200); + + expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); + }); + + it('should detect "rate_limited" error for RateLimitedError', () => { + const error = new RateLimitedError('Rate limited'); + downloadTelemetry.downloadFailed(error, 100, 200); + verifyErrorCategory('rate_limited'); + }); + + it('should detect "decryption_error" for DecryptionError', () => { + const error = new DecryptionError('Decryption failed'); + downloadTelemetry.downloadFailed(error, 100, 200); + verifyErrorCategory('decryption_error'); + }); + + it('should detect "integrity_error" for IntegrityError', () => { + const error = new IntegrityError('Integrity check failed'); + downloadTelemetry.downloadFailed(error, 100, 200); + verifyErrorCategory('integrity_error'); + }); + + it('should detect "4xx" error for APIHTTPError with 4xx status code', () => { + const error = new APIHTTPError('Client error', 404); + downloadTelemetry.downloadFailed(error, 100, 200); + verifyErrorCategory('4xx'); + }); + + it('should detect "5xx" error for APIHTTPError with 5xx status code', () => { + const error = new APIHTTPError('Server error', 500); + downloadTelemetry.downloadFailed(error, 100, 200); + verifyErrorCategory('5xx'); + }); + + it('should detect "server_error" for TimeoutError', () => { + const error = new Error('Timeout'); + error.name = 'TimeoutError'; + downloadTelemetry.downloadFailed(error, 100, 200); + verifyErrorCategory('server_error'); + }); + + it('should detect "network_error" for NetworkError', () => { + const error = new Error('Network error'); + error.name = 'NetworkError'; + downloadTelemetry.downloadFailed(error, 100, 200); + verifyErrorCategory('network_error'); + }); + }); +}); diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts new file mode 100644 index 00000000..0daa66d2 --- /dev/null +++ b/js/sdk/src/internal/download/telemetry.ts @@ -0,0 +1,100 @@ +import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from "../../errors"; +import { ProtonDriveTelemetry, MetricsDownloadErrorType } from "../../interface"; +import { LoggerWithPrefix } from "../../telemetry"; +import { APIHTTPError } from '../apiService'; + +export class DownloadTelemetry { + constructor(private telemetry: ProtonDriveTelemetry) { + this.telemetry = telemetry; + } + + getLoggerForNode(nodeUid: string) { + const logger = this.telemetry.getLogger("download"); + return new LoggerWithPrefix(logger, `node ${nodeUid}`); + } + + downloadInitFailed(error: unknown) { + const errorCategory = getErrorCategory(error); + + // No error category means ignored error from telemetry. + // For example, aborted request. + if (!errorCategory) { + return; + } + + this.sendTelemetry({ + downloadedSize: 0, + error: errorCategory, + }); + } + + downloadFailed(error: unknown, downloadedSize: number, claimedFileSize?: number) { + const errorCategory = getErrorCategory(error); + + // No error category means ignored error from telemetry. + // For example, aborted request. + if (!errorCategory) { + return; + } + + this.sendTelemetry({ + downloadedSize, + claimedFileSize, + error: errorCategory, + }); + } + + downloadFinished(downloadedSize: number) { + this.sendTelemetry({ + downloadedSize, + claimedFileSize: downloadedSize, + }); + } + + private sendTelemetry(options: { + downloadedSize: number, + claimedFileSize?: number, + error?: MetricsDownloadErrorType, + }) { + this.telemetry.logEvent({ + eventName: 'download', + context: 'own_volume', // TODO: pass context + ...options, + }); + } +} + +function getErrorCategory(error: unknown): MetricsDownloadErrorType | undefined { + if (error instanceof ValidationError) { + return undefined; + } + if (error instanceof RateLimitedError) { + return 'rate_limited'; + } + if (error instanceof DecryptionError) { + return 'decryption_error'; + } + if (error instanceof IntegrityError) { + return 'integrity_error'; + } + if (error instanceof APIHTTPError) { + if (error.statusCode >= 400 && error.statusCode < 500) { + return '4xx'; + } + if (error.statusCode >= 500) { + return '5xx'; + } + } + if (error instanceof Error) { + if (error.name === 'TimeoutError') { + return 'server_error'; + } + if (error.name === 'OfflineError' || error.name === 'NetworkError' || error.message?.toLowerCase() === 'network error') { + return 'network_error'; + } + if (error.name === 'AbortError') { + return undefined; + } + } + return 'unknown'; +} diff --git a/js/sdk/src/internal/download/wait.test.ts b/js/sdk/src/internal/download/wait.test.ts new file mode 100644 index 00000000..c76348dc --- /dev/null +++ b/js/sdk/src/internal/download/wait.test.ts @@ -0,0 +1,21 @@ +import { waitForCondition } from "./wait"; + +describe('waitForCondition', () => { + it('should resolve immediately if condition is met', async () => { + const callback = jest.fn().mockReturnValue(true); + await waitForCondition(callback); + expect(callback).toHaveBeenCalled(); + }); + + it('should resolve after condition is met', async () => { + const callback = jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + await waitForCondition(callback); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should reject if signal is aborted', async () => { + const signal = { aborted: true } as any as AbortSignal; + const callback = jest.fn().mockReturnValue(false); + await expect(waitForCondition(callback, signal)).rejects.toThrow('aborted'); + }); +}); diff --git a/js/sdk/src/internal/download/wait.ts b/js/sdk/src/internal/download/wait.ts new file mode 100644 index 00000000..e770db7d --- /dev/null +++ b/js/sdk/src/internal/download/wait.ts @@ -0,0 +1,18 @@ +import { AbortError } from "../../errors"; + +const WAIT_TIME = 50; + +export function waitForCondition(callback: () => boolean, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const waitForCondition = () => { + if (signal?.aborted) { + return reject(new AbortError()); + } + if (callback()) { + return resolve(); + } + setTimeout(waitForCondition, WAIT_TIME); + }; + waitForCondition(); + }); +} diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index 7b361dce..a9ca7965 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -28,7 +28,7 @@ describe('nodesCryptoCache', () => { it('should store and retrieve keys', async () => { const nodeId = 'newNodeId'; - const keys = { passphrase: 'pass', key: generatePrivateKey('privateKey'), sessionKey: generateSessionKey('sessionKey'), hashKey: undefined }; + const keys = { passphrase: 'pass', key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey'), hashKey: undefined }; await cache.setNodeKeys(nodeId, keys); const result = await cache.getNodeKeys(nodeId); @@ -38,8 +38,8 @@ describe('nodesCryptoCache', () => { it('should replace and retrieve new keys', async () => { const nodeId = 'newNodeId'; - const keys1 = { passphrase: 'pass', key: generatePrivateKey('privateKey1'), sessionKey: generateSessionKey('sessionKey1'), hashKey: undefined }; - const keys2 = { passphrase: 'pass', key: generatePrivateKey('privateKey2'), sessionKey: generateSessionKey('sessionKey2'), hashKey: undefined }; + const keys1 = { passphrase: 'pass', key: generatePrivateKey('privateKey1'), passphraseSessionKey: generateSessionKey('sessionKey1'), hashKey: undefined }; + const keys2 = { passphrase: 'pass', key: generatePrivateKey('privateKey2'), passphraseSessionKey: generateSessionKey('sessionKey2'), hashKey: undefined }; await cache.setNodeKeys(nodeId, keys1); await cache.setNodeKeys(nodeId, keys2); @@ -50,7 +50,7 @@ describe('nodesCryptoCache', () => { it('should remove keys', async () => { const nodeId = 'newNodeId'; - const keys = { passphrase: 'pass', key: generatePrivateKey('privateKey'), sessionKey: generateSessionKey('sessionKey'), hashKey: undefined }; + const keys = { passphrase: 'pass', key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey'), hashKey: undefined }; await cache.setNodeKeys(nodeId, keys); await cache.removeNodeKeys([nodeId]); diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 9450bf75..e1fe285e 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -21,7 +21,7 @@ describe("nodesCryptoService", () => { decryptKey: jest.fn(async () => Promise.resolve({ passphrase: "pass", key: "decryptedKey" as unknown as PrivateKey, - sessionKey: "sessionKey" as unknown as SessionKey, + passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, verified: VERIFICATION_STATUS.SIGNED_AND_VALID, })), decryptNodeName: jest.fn(async () => Promise.resolve({ @@ -39,6 +39,10 @@ describe("nodesCryptoService", () => { encryptNodeName: jest.fn(async () => Promise.resolve({ armoredNodeName: "armoredName", })), + decryptAndVerifySessionKey: jest.fn(async () => Promise.resolve({ + sessionKey: "contentKeyPacketSessionKey", + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + })), }; // @ts-expect-error No need to implement all methods for mocking account = { @@ -80,7 +84,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: undefined, }, }); @@ -114,7 +118,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: undefined, }, }); @@ -156,7 +160,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: new Uint8Array(), }, }); @@ -167,7 +171,7 @@ describe("nodesCryptoService", () => { driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ passphrase: "pass", key: "decryptedKey" as unknown as PrivateKey, - sessionKey: "sessionKey" as unknown as SessionKey, + passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, verified: VERIFICATION_STATUS.NOT_SIGNED, })); @@ -201,7 +205,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: new Uint8Array(), }, }); @@ -250,7 +254,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: new Uint8Array(), }, }); @@ -299,7 +303,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: new Uint8Array(), }, }); @@ -316,7 +320,7 @@ describe("nodesCryptoService", () => { driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ passphrase: "pass", key: "decryptedKey" as unknown as PrivateKey, - sessionKey: "sessionKey" as unknown as SessionKey, + passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, verified: VERIFICATION_STATUS.NOT_SIGNED, })); driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ @@ -354,7 +358,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: new Uint8Array(), }, }); @@ -405,7 +409,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: new Uint8Array(), }, }); @@ -452,7 +456,8 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", + contentKeyPacketSessionKey: "contentKeyPacketSessionKey", hashKey: undefined, }, }); @@ -504,7 +509,8 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", + contentKeyPacketSessionKey: "contentKeyPacketSessionKey", hashKey: undefined, }, }); @@ -577,7 +583,7 @@ describe("nodesCryptoService", () => { keys: { passphrase: "pass", key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "passphraseSessionKey", hashKey: undefined, }, }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index f3677d4f..bce4c245 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -59,12 +59,12 @@ export class NodesCryptoService { : [parentKey]; } - let passphrase, key, sessionKey, keyAuthor; + let passphrase, key, passphraseSessionKey, keyAuthor; try { const keyResult = await this.decryptKey(node, parentKey, keyVerificationKeys); passphrase = keyResult.passphrase; key = keyResult.key; - sessionKey = keyResult.sessionKey; + passphraseSessionKey = keyResult.passphraseSessionKey; keyAuthor = keyResult.author; } catch (error: unknown) { this.reportDecryptionError(node, error); @@ -113,6 +113,8 @@ export class NodesCryptoService { } let activeRevision: Result | undefined; + let contentKeyPacketSessionKey; + let contentKeyPacketAuthor; if ("file" in node.encryptedCrypto) { try { activeRevision = resultOk(await this.decryptRevision(node.encryptedCrypto.activeRevision, key, parentKey)); @@ -120,6 +122,21 @@ export class NodesCryptoService { const errorMessage = c('Error').t`Failed to decrypt active revision: ${getErrorMessage(error)}`; activeRevision = resultError(new Error(errorMessage)); } + + const keySessionKeyResult = await this.driveCrypto.decryptAndVerifySessionKey( + node.encryptedCrypto.file.base64ContentKeyPacket, + node.encryptedCrypto.file.armoredContentKeyPacketSignature, + key, + keyVerificationKeys + ); + contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; + contentKeyPacketAuthor = keySessionKeyResult.verified && await this.handleClaimedAuthor( + node, + 'SignatureEmail', + c('Property').t`content key`, + keySessionKeyResult.verified, + node.encryptedCrypto.signatureEmail, + ); } // If key signature verificaiton failed, prefer returning error from @@ -130,6 +147,9 @@ export class NodesCryptoService { if (!keyAuthor.ok) { finalKeyAuthor = keyAuthor; } + if (!finalKeyAuthor && contentKeyPacketAuthor && !contentKeyPacketAuthor.ok) { + finalKeyAuthor = contentKeyPacketAuthor; + } if (!finalKeyAuthor && hashKeyAuthor && !hashKeyAuthor.ok) { finalKeyAuthor = hashKeyAuthor; } @@ -152,7 +172,8 @@ export class NodesCryptoService { keys: { passphrase, key, - sessionKey, + passphraseSessionKey, + contentKeyPacketSessionKey, hashKey, }, }; @@ -172,7 +193,7 @@ export class NodesCryptoService { return { passphrase: key.passphrase, key: key.key, - sessionKey: key.sessionKey, + passphraseSessionKey: key.passphraseSessionKey, author: await this.handleClaimedAuthor(node, 'SignatureEmail', c('Property').t`key`, key.verified, node.encryptedCrypto.signatureEmail), }; }; @@ -320,7 +341,7 @@ export class NodesCryptoService { keys: { passphrase: nodeKeys.decrypted.passphrase, key: nodeKeys.decrypted.key, - sessionKey: nodeKeys.decrypted.sessionKey, + passphraseSessionKey: nodeKeys.decrypted.passphraseSessionKey, hashKey, }, }; @@ -342,7 +363,7 @@ export class NodesCryptoService { }; }; - async moveNode(node: DecryptedNode, keys: { passphrase: string, sessionKey: SessionKey }, parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }): Promise<{ + async moveNode(node: DecryptedNode, keys: { passphrase: string, passphraseSessionKey: SessionKey }, parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }): Promise<{ encryptedName: string, hash: string, armoredNodePassphrase: string, @@ -361,7 +382,7 @@ export class NodesCryptoService { const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, parentKeys.key, addressKey); const hash = await this.generateLookupHash(node.name.value, parentKeys.hashKey); - const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.sessionKey, [parentKeys.key], addressKey); + const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], addressKey); return { encryptedName: armoredNodeName, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index d5a551e5..9650a876 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -99,9 +99,8 @@ export interface DecryptedNode extends Omit { const node = await this.getNode(nodeUid); const { key: parentKey } = await this.getParentKeys(node); - const { key, sessionKey: passphraseSessionKey } = await this.getNodeKeys(nodeUid); + const { key, passphraseSessionKey, contentKeyPacketSessionKey } = await this.getNodeKeys(nodeUid); const nameSessionKey = await this.cryptoService.getNameSessionKey(node, parentKey); return { key, passphraseSessionKey, + contentKeyPacketSessionKey, nameSessionKey, }; } diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts index c507b6ad..23dcaa61 100644 --- a/js/sdk/src/internal/shares/cryptoCache.test.ts +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -22,7 +22,7 @@ describe('sharesCryptoCache', () => { it('should store and retrieve keys', async () => { const shareId = 'newShareId'; - const keys = { key: generatePrivateKey('privateKey'), sessionKey: generateSessionKey('sessionKey') }; + const keys = { key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey') }; await cache.setShareKey(shareId, keys); const result = await cache.getShareKey(shareId); @@ -32,8 +32,8 @@ describe('sharesCryptoCache', () => { it('should replace and retrieve new keys', async () => { const shareId = 'newShareId'; - const keys1 = { key: generatePrivateKey('privateKey1'), sessionKey: generateSessionKey('sessionKey1') }; - const keys2 = { key: generatePrivateKey('privateKey2'), sessionKey: generateSessionKey('sessionKey2') }; + const keys1 = { key: generatePrivateKey('privateKey1'), passphraseSessionKey: generateSessionKey('sessionKey1') }; + const keys2 = { key: generatePrivateKey('privateKey2'), passphraseSessionKey: generateSessionKey('sessionKey2') }; await cache.setShareKey(shareId, keys1); await cache.setShareKey(shareId, keys2); @@ -44,7 +44,7 @@ describe('sharesCryptoCache', () => { it('should remove keys', async () => { const shareId = 'newShareId'; - const keys = { key: generatePrivateKey('privateKey'), sessionKey: generateSessionKey('sessionKey') }; + const keys = { key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey') }; await cache.setShareKey(shareId, keys); await cache.removeShareKey([shareId]); diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts index 5dcbce5b..236beab7 100644 --- a/js/sdk/src/internal/shares/cryptoService.test.ts +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -17,7 +17,7 @@ describe("SharesCryptoService", () => { decryptKey: jest.fn(async () => Promise.resolve({ passphrase: "pass", key: "decryptedKey" as unknown as PrivateKey, - sessionKey: "sessionKey" as unknown as SessionKey, + passphraseSessionKey: "sessionKey" as unknown as SessionKey, verified: VERIFICATION_STATUS.SIGNED_AND_VALID, })), }; @@ -52,7 +52,7 @@ describe("SharesCryptoService", () => { }, key: { key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "sessionKey", }, }); @@ -65,7 +65,7 @@ describe("SharesCryptoService", () => { driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ passphrase: "pass", key: "decryptedKey" as unknown as PrivateKey, - sessionKey: "sessionKey" as unknown as SessionKey, + passphraseSessionKey: "sessionKey" as unknown as SessionKey, verified: VERIFICATION_STATUS.NOT_SIGNED, })); @@ -89,7 +89,7 @@ describe("SharesCryptoService", () => { }, key: { key: "decryptedKey", - sessionKey: "sessionKey", + passphraseSessionKey: "sessionKey", }, }); diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 71a21826..9473d044 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -52,7 +52,7 @@ export class SharesCryptoService { const { keys: addressKeys } = await this.account.getOwnAddress(share.addressId); const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); - let key, sessionKey, verified; + let key, passphraseSessionKey, verified; try { const result = await this.driveCrypto.decryptKey( share.encryptedCrypto.armoredKey, @@ -62,7 +62,7 @@ export class SharesCryptoService { addressPublicKeys, ) key = result.key; - sessionKey = result.sessionKey; + passphraseSessionKey = result.passphraseSessionKey; verified = result.verified; } catch (error: unknown) { this.reportDecryptionError(share, error); @@ -87,7 +87,7 @@ export class SharesCryptoService { }, key: { key, - sessionKey, + passphraseSessionKey, }, } } diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index 1515b51e..4e6e5e32 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -92,5 +92,5 @@ export interface EncryptedShareCrypto { export interface DecryptedShareKey { key: PrivateKey; - sessionKey: SessionKey; + passphraseSessionKey: SessionKey; } diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index b3bdcd29..26daf322 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -43,7 +43,7 @@ export class SharingCryptoService { }, decrypted: { key: PrivateKey, - sessionKey: SessionKey, + passphraseSessionKey: SessionKey, }, }, base64PpassphraseKeyPacket: string, @@ -79,7 +79,7 @@ export class SharingCryptoService { async decryptShare(share: EncryptedShare, nodeKey: PrivateKey): Promise<{ author: Author, key: PrivateKey, - sessionKey: SessionKey, + passphraseSessionKey: SessionKey, }> { // All standard shares should be encrypted with node key. // Using node key is essential so any admin can manage the share. @@ -93,7 +93,7 @@ export class SharingCryptoService { } const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); - const { key, sessionKey, verified } = await this.driveCrypto.decryptKey( + const { key, passphraseSessionKey, verified } = await this.driveCrypto.decryptKey( share.encryptedCrypto.armoredKey, share.encryptedCrypto.armoredPassphrase, share.encryptedCrypto.armoredPassphraseSignature, @@ -111,7 +111,7 @@ export class SharingCryptoService { return { author, key, - sessionKey, + passphraseSessionKey, } } diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index e46062ab..bf4327bb 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -121,7 +121,7 @@ export interface SharesService { */ export interface NodesService { getNode(nodeUid: string): Promise, - getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, sessionKey: SessionKey }>, + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey }>, getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ key: PrivateKey, passphraseSessionKey: SessionKey, diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 94792be6..8958789f 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -16,7 +16,7 @@ interface InternalShareResult extends ShareResult { interface Share { volumeId: string; shareId: string; - sessionKey: SessionKey; + passphraseSessionKey: SessionKey; } interface EmailOptions { @@ -270,14 +270,14 @@ export class SharingManagement { const { volumeId } = splitNodeUid(nodeUid); const { key: nodeKey } = await this.nodesService.getNodeKeys(nodeUid); const encryptedShare = await this.sharesService.loadEncryptedShare(node.shareId); - const { sessionKey } = await this.cryptoService.decryptShare(encryptedShare, nodeKey); + const { passphraseSessionKey } = await this.cryptoService.decryptShare(encryptedShare, nodeKey); return { ...sharingInfo, share: { volumeId, shareId: node.shareId, - sessionKey: sessionKey, + passphraseSessionKey: passphraseSessionKey, }, nodeName: node.name.ok ? node.name.value : node.name.error.name, } @@ -308,7 +308,7 @@ export class SharingManagement { return { volumeId, shareId, - sessionKey: keys.shareKey.decrypted.sessionKey, + passphraseSessionKey: keys.shareKey.decrypted.passphraseSessionKey, } } @@ -319,7 +319,7 @@ export class SharingManagement { private async inviteProtonUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { const inviter = await this.sharesService.getVolumeEmailKey(share.volumeId); - const invitationCrypto = await this.cryptoService.encryptInvitation(share.sessionKey, inviter.addressKey, inviteeEmail); + const invitationCrypto = await this.cryptoService.encryptInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); const encryptedInvitation = await this.apiService.inviteProtonUser(share.shareId, { addedByEmail: inviter.email, @@ -348,7 +348,7 @@ export class SharingManagement { private async inviteExternalUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { const inviter = await this.sharesService.getVolumeEmailKey(share.volumeId); - const invitationCrypto = await this.cryptoService.encryptExternalInvitation(share.sessionKey, inviter.addressKey, inviteeEmail); + const invitationCrypto = await this.cryptoService.encryptExternalInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); const encryptedInvitation = await this.apiService.inviteExternalUser(share.shareId, { inviterAddressId: inviter.addressId, diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index a101d4c3..ca66a43c 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -3,5 +3,5 @@ import { NodeEntity } from "../../interface"; export interface NodesService { getNode(nodeUid: string): Promise, - getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, sessionKey: SessionKey }>, + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, passphraseSessionKey: SessionKey }>, } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 37f28067..552f3082 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,4 +1,4 @@ -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, NodeEntity, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger, NodeResult, Revision, ProtonInvitationWithNode, ShareResult, NonProtonInvitationOrUid, ProtonInvitationOrUid } from './interface'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, NodeEntity, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger, NodeResult, Revision, ProtonInvitationWithNode, ShareResult, NonProtonInvitationOrUid, ProtonInvitationOrUid, FileDownloader } from './interface'; import { DriveCrypto } from './crypto'; import { DriveAPIService } from './internal/apiService'; import { initSharesModule } from './internal/shares'; @@ -46,7 +46,7 @@ export class ProtonDriveClient implements Partial { const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access); - this.download = initDownloadModule(apiService, cryptoModule, this.nodes.access); + this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.nodes.access); this.upload = initUploadModule(apiService, cryptoModule, this.nodes.access); } @@ -397,7 +397,46 @@ export class ProtonDriveClient implements Partial { throw new Error('Method not implemented'); } - async getFileDownloader(nodeUid: NodeOrUid, signal?: AbortSignal) { + /** + * Get the file downloader to download the node content. + * + * The number of ongoing downloads is limited. If the limit is reached, + * the download is queued and started when the slot is available. It is + * recommended to not start too many downloads at once to avoid having + * many open promises. + * + * The file downloader is not reusable. If the download is interrupted, + * a new file downloader must be created. + * + * Before download, the authorship of the node should be checked and + * reported to the user if there is any signature issue, notably on the + * content author on the revision. + * + * Client should not automatically retry the download if it fails. The + * download should be initiated by the user again. The downloader does + * automatically retry the download if it fails due to network issues, + * or if the server is temporarily unavailable. + * + * Once download is initiated, the download can fail, besides network + * issues etc., only when there is integrity error. It should be considered + * a bug and reported to the Drive developers. The SDK provides option + * to bypass integrity checks, but that should be used only for debugging + * purposes, not available to the end users. + * + * Example usage: + * + * ```typescript + * const downloader = await client.getFileDownloader(nodeUid, signal); + * const claimedSize = fileDownloader.getClaimedSizeInBytes(); + * const downloadController = fileDownloader.writeToStream(stream, (downloadedBytes) => { ... }); + * + * signalController.abort(); // to cancel + * downloadController.pause(); // to pause + * downloadController.resume(); // to resume + * await downloadController.completion(); // to await completion + * ``` + */ + async getFileDownloader(nodeUid: NodeOrUid, signal?: AbortSignal): Promise { this.logger.info(`Getting file downloader for ${getUid(nodeUid)}`); return this.download.getFileDownloader(getUid(nodeUid), signal); } diff --git a/js/sdk/src/telemetry.ts b/js/sdk/src/telemetry.ts index 84056141..11b13d10 100644 --- a/js/sdk/src/telemetry.ts +++ b/js/sdk/src/telemetry.ts @@ -1,3 +1,5 @@ +import { Logger as LoggerInterface } from './interface'; + export interface LogRecord { time: Date, level: LogLevel; @@ -26,7 +28,7 @@ export interface MetricRecord { event: T; } -type MetricEvent = { +export type MetricEvent = { eventName: string; } @@ -162,6 +164,40 @@ class Logger { } } +/** + * Logger class that logs messages with a prefix. + * + * Example: + * + * ```typescript + * const logger = new Logger('myLogger', new LogFilter(), [new ConsoleLogHandler()]); + * const loggerWithPrefix = new LoggerWithPrefix(logger, 'prefix'); + * loggerWithPrefix.info('Info message'); + * ``` + */ +export class LoggerWithPrefix { + constructor(private logger: LoggerInterface, private prefix: string) { + this.logger = logger; + this.prefix = prefix; + } + + info(message: string) { + this.logger.info(`${this.prefix}: ${message}`); + } + + debug(message: string) { + this.logger.debug(`${this.prefix}: ${message}`); + } + + warn(message: string) { + this.logger.warn(`${this.prefix}: ${message}`); + } + + error(message: string, error?: unknown) { + this.logger.error(`${this.prefix}: ${message}`, error); + } +} + /** * Filter logs based on log level. It can be configured by global level or * per logger level. From 1b695e946657a2cf1af410841f55f525780c253d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 1 Apr 2025 07:35:43 +0000 Subject: [PATCH 054/791] Handle 404 properly --- js/sdk/src/internal/apiService/errorCodes.ts | 1 - js/sdk/src/internal/apiService/errors.test.ts | 7 +++++++ js/sdk/src/internal/apiService/errors.ts | 12 +++++------- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index c3ea996d..0065d667 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -10,7 +10,6 @@ export function isCodeOk(code: number): boolean { } export const enum ErrorCode { - NOT_FOUND = 404, OK = 1000, OK_MANY = 1001, OK_ASYNC = 1002, diff --git a/js/sdk/src/internal/apiService/errors.test.ts b/js/sdk/src/internal/apiService/errors.test.ts index 3942d0f6..a9121e84 100644 --- a/js/sdk/src/internal/apiService/errors.test.ts +++ b/js/sdk/src/internal/apiService/errors.test.ts @@ -30,6 +30,13 @@ describe("apiErrorFactory should return", () => { expect((error as errors.APIHTTPError).statusCode).toBe(404); }); + it("generic APIHTTPError when there 404 both in status code and body code", () => { + const error = apiErrorFactory(mockAPIResponseAndResult({ httpStatusCode: 404, httpStatusText: 'Path not found', code: 404, message: 'Not found' })); + expect(error).toBeInstanceOf(errors.APIHTTPError); + expect(error.message).toBe("Path not found"); + expect((error as errors.APIHTTPError).statusCode).toBe(404); + }); + it("generic APICodeError when there is body even if wrong", () => { const result = {}; const response = new Response('', { status: 422 }); diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 91dc02bd..5b51cd95 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -1,10 +1,13 @@ import { c } from 'ttag'; import { ServerError, ValidationError } from '../../errors'; -import { ErrorCode } from './errorCodes'; +import { ErrorCode, HTTPErrorCode } from './errorCodes'; export function apiErrorFactory({ response, result }: { response: Response, result?: unknown }): ServerError { - if (!result) { + // Backend responses with 404 both in the response and body code. + // In such a case we want to stick to APIHTTPError to be very clear + // it is not NotFoundAPIError. + if (response.status === HTTPErrorCode.NOT_FOUND || !result) { return new APIHTTPError(response.statusText, response.status); } @@ -12,11 +15,6 @@ export function apiErrorFactory({ response, result }: { response: Response, resu // error or code set which next lines should handle. const [code, message] = [result.Code || 0, result.Error || c('Error').t`Unknown error`]; switch (code) { - // Backend doesn't return 404 for not found resources, this is only - // when the API endpoint is not found. Lets add the URL in the error - // message so we can debug it easier. - case ErrorCode.NOT_FOUND: - return new NotFoundAPIError(`${message}: ${response.url}`, code); case ErrorCode.NOT_EXISTS: return new NotFoundAPIError(message, code); // ValidationError should be only when it is clearly user input error, From 413175ebd221a06ffce0fd171abd1ffc8521a923 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 2 Apr 2025 09:54:18 +0000 Subject: [PATCH 055/791] Merge internal and external users on sharing node interface --- js/sdk/src/interface/account.ts | 1 + js/sdk/src/interface/sharing.ts | 3 +- js/sdk/src/internal/sharing/index.ts | 2 +- .../sharing/sharingManagement.test.ts | 78 +++++++++++++++---- .../src/internal/sharing/sharingManagement.ts | 34 +++++--- 5 files changed, 92 insertions(+), 26 deletions(-) diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index a7f21470..5b68c31e 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -4,6 +4,7 @@ export interface ProtonDriveAccount { getOwnPrimaryAddress(): Promise, // TODO: do we want to break it down to email vs address ID methods? getOwnAddress(emailOrAddressId: string): Promise, + hasProtonAccount(email: string): Promise, getPublicKeys(email: string): Promise, } diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 0d177b68..d954e52f 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -54,8 +54,7 @@ export type NonProtonInvitationOrUid = NonProtonInvitation | string; export type BookmarkOrUid = Bookmark | string; export type ShareNodeSettings = { - protonUsers?: ShareMembersSettings, - nonProtonUsers?: ShareMembersSettings, + users?: ShareMembersSettings, publicLink?: SharePublicLinkSettings, emailOptions?: { message?: string, diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index bbda6b8a..a3db6180 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -32,7 +32,7 @@ export function initSharingModule( const cryptoService = new SharingCryptoService(crypto, account); const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); const sharingEvents = new SharingEvents(telemetry.getLogger('sharing-events'), driveEvents, cache, nodesService, sharingAccess); - const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, sharesService, nodesService); + const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, account, sharesService, nodesService); return { access: sharingAccess, diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 0df9bdd9..59b8ea8c 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -1,5 +1,5 @@ import { getMockLogger } from "../../tests/logger"; -import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonInvitation, resultOk } from "../../interface"; +import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonDriveAccount, ProtonInvitation, resultOk } from "../../interface"; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; import { SharesService, NodesService } from "./interface"; @@ -8,6 +8,7 @@ import { SharingManagement } from "./sharingManagement"; describe("SharingManagement", () => { let apiService: SharingAPIService; let cryptoService: SharingCryptoService; + let accountService: ProtonDriveAccount; let sharesService: SharesService; let nodesService: NodesService; @@ -48,6 +49,10 @@ describe("SharingManagement", () => { })), } // @ts-expect-error No need to implement all methods for mocking + accountService = { + hasProtonAccount: jest.fn().mockResolvedValue(true), + } + // @ts-expect-error No need to implement all methods for mocking sharesService = { getVolumeEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressKey: "volume-key" }), loadEncryptedShare: jest.fn().mockResolvedValue({ id: "shareId" }), @@ -58,7 +63,7 @@ describe("SharingManagement", () => { getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: "node-key" })), } - sharingManagement = new SharingManagement(getMockLogger(), apiService, cryptoService, sharesService, nodesService); + sharingManagement = new SharingManagement(getMockLogger(), apiService, cryptoService, accountService, sharesService, nodesService); }); describe("getSharingInfo", () => { @@ -169,8 +174,12 @@ describe("SharingManagement", () => { }); describe("invitations", () => { + beforeEach(() => { + accountService.hasProtonAccount = jest.fn().mockResolvedValue(true); + }); + it("should share node with proton email with default role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: ["email"] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email"] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation, { @@ -188,7 +197,7 @@ describe("SharingManagement", () => { }); it("should share node with proton email with specific role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "email", role: MemberRole.Editor }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "email", role: MemberRole.Editor }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation, { @@ -206,7 +215,7 @@ describe("SharingManagement", () => { }); it("should update existing role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "internal-email", role: MemberRole.Editor }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "internal-email", role: MemberRole.Editor }] }); expect(sharingInfo).toEqual({ protonInvitations: [{ @@ -222,7 +231,7 @@ describe("SharingManagement", () => { }); it("should be no-op if no change", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "internal-email", role: MemberRole.Viewer }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "internal-email", role: MemberRole.Viewer }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -236,8 +245,12 @@ describe("SharingManagement", () => { }); describe("external invitations", () => { + beforeEach(() => { + accountService.hasProtonAccount = jest.fn().mockResolvedValue(false); + }); + it("should share node with external email with default role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: ["email"] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email"] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -256,7 +269,7 @@ describe("SharingManagement", () => { }); it("should share node with external email with specific role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "email", role: MemberRole.Editor }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "email", role: MemberRole.Editor }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -275,7 +288,7 @@ describe("SharingManagement", () => { }); it("should update existing role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "external-email", role: MemberRole.Editor }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "external-email", role: MemberRole.Editor }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -291,7 +304,7 @@ describe("SharingManagement", () => { }); it("should be no-op if no change", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "external-email", role: MemberRole.Viewer }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "external-email", role: MemberRole.Viewer }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -304,9 +317,46 @@ describe("SharingManagement", () => { }); }); + describe("mix of internal and external invitations", () => { + beforeEach(() => { + accountService.hasProtonAccount = jest.fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + }); + + it("should share node with proton and external email with default role", async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email", "email2"] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation, { + uid: "created-invitation", + addedByEmail: { ok: true, value: "volume-email" }, + inviteeEmail: "email", + role: "viewer", + }], + nonProtonInvitations: [externalInvitation, { + uid: "created-external-invitation", + addedByEmail: { ok: true, value: "volume-email" }, + inviteeEmail: "email2", + role: "viewer", + state: "pending", + }], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).toHaveBeenCalledWith("shareId", expect.objectContaining({ + inviteeEmail: "email", + }), expect.anything()); + expect(apiService.inviteExternalUser).toHaveBeenCalledWith("shareId", expect.objectContaining({ + inviteeEmail: "email2", + }), expect.anything()); + }); + }); + describe("members", () => { it("should update member via proton user", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "member-email", role: MemberRole.Editor }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "member-email", role: MemberRole.Editor }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -323,7 +373,7 @@ describe("SharingManagement", () => { }); it("should be no-op if no change via proton user", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { protonUsers: [{ email: "member-email", role: MemberRole.Viewer }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "member-email", role: MemberRole.Viewer }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -337,7 +387,7 @@ describe("SharingManagement", () => { }); it("should update member via non-proton user", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "member-email", role: MemberRole.Editor }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "member-email", role: MemberRole.Editor }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -354,7 +404,7 @@ describe("SharingManagement", () => { }); it("should be no-op if no change via non-proton user", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { nonProtonUsers: [{ email: "member-email", role: MemberRole.Viewer }] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "member-email", role: MemberRole.Viewer }] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 8958789f..c5a30d29 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -2,7 +2,7 @@ import { c } from 'ttag'; import { SessionKey } from "../../crypto"; import { ValidationError } from "../../errors"; -import { Logger, PublicLink, MemberRole, ShareNodeSettings, UnshareNodeSettings, SharePublicLinkSettings, ShareResult, ProtonInvitation, NonProtonInvitation, Member, resultOk } from "../../interface"; +import { Logger, PublicLink, MemberRole, ShareNodeSettings, UnshareNodeSettings, SharePublicLinkSettings, ShareResult, ProtonInvitation, NonProtonInvitation, Member, resultOk, ProtonDriveAccount } from "../../interface"; import { splitNodeUid } from "../uids"; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; @@ -35,12 +35,14 @@ export class SharingManagement { private logger: Logger, private apiService: SharingAPIService, private cryptoService: SharingCryptoService, + private account: ProtonDriveAccount, private sharesService: SharesService, private nodesService: NodesService, ) { this.logger = logger; this.apiService = apiService; this.cryptoService = cryptoService; + this.account = account; this.sharesService = sharesService; this.nodesService = nodesService; } @@ -94,6 +96,24 @@ export class SharingManagement { } async shareNode(nodeUid: string, settings: ShareNodeSettings): Promise { + // Check what users are Proton users before creating share + // so if this fails, we don't create empty share. + const protonUsers = []; + const nonProtonUsers = []; + if (settings.users) { + for (const user of settings.users) { + const { email, role } = typeof user === "string" + ? { email: user, role: MemberRole.Viewer } + : user; + const isProtonUser = await this.account.hasProtonAccount(email); + if (isProtonUser) { + protonUsers.push({ email, role }); + } else { + nonProtonUsers.push({ email, role }); + } + } + } + let currentSharing = await this.getInternalSharingInfo(nodeUid); if (!currentSharing) { const node = await this.nodesService.getNode(nodeUid); @@ -113,10 +133,8 @@ export class SharingManagement { nodeName: settings.emailOptions?.includeNodeName ? currentSharing.nodeName : undefined, } - for (const user of settings.protonUsers || []) { - const { email, role } = typeof user === "string" - ? { email: user, role: MemberRole.Viewer } - : user; + for (const user of protonUsers) { + const { email, role } = user; const existingInvitation = currentSharing.protonInvitations.find((invitation) => invitation.inviteeEmail === email); if (existingInvitation) { @@ -147,10 +165,8 @@ export class SharingManagement { currentSharing.protonInvitations.push(invitation); } - for (const user of settings.nonProtonUsers || []) { - const { email, role } = typeof user === "string" - ? { email: user, role: MemberRole.Viewer } - : user; + for (const user of nonProtonUsers) { + const { email, role } = user; const existingExternalInvitation = currentSharing.nonProtonInvitations.find((invitation) => invitation.inviteeEmail === email); if (existingExternalInvitation) { From 2cec36959ac9879ca082ae129720602dae7443aa Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 3 Apr 2025 13:23:21 +0000 Subject: [PATCH 056/791] Fix direct member role --- js/sdk/src/internal/apiService/transformers.ts | 1 + js/sdk/src/internal/nodes/apiService.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts index 4cb1f045..dc0518ff 100644 --- a/js/sdk/src/internal/apiService/transformers.ts +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -15,6 +15,7 @@ export function nodeTypeNumberToNodeType(logger: Logger, nodeTypeNumber: number) export function permissionsToDirectMemberRole(logger: Logger, permissionsNumber?: number): MemberRole { switch (permissionsNumber) { case undefined: + return MemberRole.Inherited; case 4: return MemberRole.Viewer; case 6: diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index ea0a83b1..92c35161 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -114,7 +114,7 @@ function generateNode() { shareId: undefined, isShared: false, - directMemberRole: MemberRole.Viewer, + directMemberRole: MemberRole.Inherited, encryptedCrypto: { armoredKey: "nodeKey", From e922c33e20e80a372bd7e47a91605803170eeaa4 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 7 Apr 2025 11:11:58 +0000 Subject: [PATCH 057/791] Finalise config object --- js/sdk/src/config.ts | 5 ++--- js/sdk/src/interface/httpClient.ts | 15 +++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/js/sdk/src/config.ts b/js/sdk/src/config.ts index 556a563f..e622a034 100644 --- a/js/sdk/src/config.ts +++ b/js/sdk/src/config.ts @@ -1,10 +1,9 @@ import { ProtonDriveConfig } from './interface'; -export function getConfig(config?: ProtonDriveConfig) { +export function getConfig(config?: ProtonDriveConfig): Required { return { - // TODO: add defaults for all fields ...config, - baseUrl: config?.baseUrl ? `https://${config.baseUrl}/api` : 'https://drive.proton.me/api', + baseUrl: config?.baseUrl ? `https://${config.baseUrl}` : 'https://drive-api.proton.me', language: config?.language || 'en', }; } diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts index e07d6e1e..d8165de7 100644 --- a/js/sdk/src/interface/httpClient.ts +++ b/js/sdk/src/interface/httpClient.ts @@ -3,10 +3,17 @@ export interface ProtonDriveHTTPClient { } export type ProtonDriveConfig = { + /** + * The base URL for the Proton Drive (without schema). + * + * If not provided, defaults to 'drive-api.proton.me'. + */ baseUrl?: string, + + /** + * The language to use for error messages. + * + * If not provided, defaults to 'en'. + */ language?: string, - uploadTimeout?: number, - uploadQueueLimitItems?: number, - downloadTimeout?: number, - downloadQueueLimitItems?: number, } From ba278322615eb86ad7cc393d038f7dfe87d522c4 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 8 Apr 2025 11:33:15 +0000 Subject: [PATCH 058/791] Implement download of specific revision --- .../internal/download/fileDownloader.test.ts | 53 ++------------- .../src/internal/download/fileDownloader.ts | 33 ++-------- js/sdk/src/internal/download/index.ts | 66 +++++++++++++++++-- js/sdk/src/internal/download/interface.ts | 6 +- js/sdk/src/internal/download/telemetry.ts | 4 +- js/sdk/src/internal/nodes/apiService.ts | 30 +++++++-- js/sdk/src/internal/nodes/nodesRevisions.ts | 16 +++++ js/sdk/src/internal/uids.ts | 5 ++ js/sdk/src/protonDriveClient.ts | 14 +++- js/sdk/src/transformers.ts | 6 +- 10 files changed, 138 insertions(+), 95 deletions(-) diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index e6e97bdc..1c99ba85 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -23,14 +23,13 @@ describe('FileDownloader', () => { let apiService: DownloadAPIService; let cryptoService: DownloadCryptoService; let controller: DownloadController; - let node: NodeEntity; - let nodeKey: { key: string; contentKeyPacketSessionKey?: string }; + let nodeKey: { key: string; contentKeyPacketSessionKey: string }; let revision: Revision; beforeEach(() => { // @ts-expect-error No need to implement all methods for mocking telemetry = { - getLoggerForNode: jest.fn().mockReturnValue({ + getLoggerForRevision: jest.fn().mockReturnValue({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), @@ -71,18 +70,6 @@ describe('FileDownloader', () => { }; controller = new DownloadController(); - node = { - uid: 'nodeUid', - type: NodeType.File, - activeRevision: { - ok: true, - value: { - uid: 'revisionUid', - claimedSize: 1024, - }, - }, - } as NodeEntity; - nodeKey = { key: 'privateKey', contentKeyPacketSessionKey: 'sessionKey', @@ -93,39 +80,7 @@ describe('FileDownloader', () => { claimedSize: 1024, } as Revision; }); - - describe('constructor', () => { - it('should throw an error if the node is a folder', () => { - node.type = NodeType.Folder; - - expect(() => { - new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node); - }).toThrow(ValidationError); - }); - - it('should throw an error if the node has no active revision', () => { - node.activeRevision = undefined; - - expect(() => { - new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node); - }).toThrow(ValidationError); - }); - it('should throw an error if the node key has no content key', () => { - nodeKey.contentKeyPacketSessionKey = undefined; - - expect(() => { - new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node); - }).toThrow(ValidationError); - }); - - it('should initialize correctly', () => { - const downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node); - - expect(downloader.getClaimedSizeInBytes()).toBe(revision.claimedSize); - }); - }); - describe('writeToStream', () => { let onProgress: (downloadedBytes: number) => void; let onFinish: () => void; @@ -187,7 +142,7 @@ describe('FileDownloader', () => { stream = { getWriter: () => writer, } - downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node, undefined, onFinish); + downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, revision, undefined, onFinish); }); it('should reject two download starts', async () => { @@ -361,7 +316,7 @@ describe('FileDownloader', () => { stream = { getWriter: () => writer, } - downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, node, undefined, onFinish); + downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, revision, undefined, onFinish); }); it('should skip verification steps', async () => { diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index e5cf1c6e..69e8b210 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -1,8 +1,5 @@ -import { c } from 'ttag'; - import { PrivateKey, SessionKey, base64StringToUint8Array } from "../../crypto"; -import { ValidationError } from "../../errors"; -import { Logger, NodeEntity, NodeType, Revision } from "../../interface"; +import { Logger, Revision } from "../../interface"; import { LoggerWithPrefix } from "../../telemetry"; import { APIHTTPError, HTTPErrorCode } from '../apiService'; import { DownloadAPIService } from "./apiService"; @@ -21,9 +18,6 @@ const MAX_DOWNLOAD_BLOCK_SIZE = 10; export class FileDownloader { private logger: Logger; - private nodeKey: { key: PrivateKey, contentKeyPacketSessionKey: SessionKey } - private revision: Revision; - private controller: DownloadController; private nextBlockIndex = 1; private ongoingDownloads = new Map void, ) { this.telemetry = telemetry; - this.logger = telemetry.getLoggerForNode(node.uid); + this.logger = telemetry.getLoggerForRevision(revision.uid); this.apiService = apiService; this.cryptoService = cryptoService; + this.nodeKey = nodeKey; + this.revision = revision; this.signal = signal; this.onFinish = onFinish; - - if (node.type === NodeType.Folder) { - throw new ValidationError(c("Error").t`Cannot download a folder`); - } - if (!node.activeRevision?.ok || !node.activeRevision.value) { - throw new ValidationError(c("Error").t`File has no active revision`); - } - if (!nodeKey.contentKeyPacketSessionKey) { - throw new ValidationError(c("Error").t`File has no content key`); - } - - this.nodeKey = { - key: nodeKey.key, - contentKeyPacketSessionKey: nodeKey.contentKeyPacketSessionKey, - }; - this.revision = node.activeRevision.value; - this.controller = new DownloadController(); } diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts index 5755a532..9ce6d28b 100644 --- a/js/sdk/src/internal/download/index.ts +++ b/js/sdk/src/internal/download/index.ts @@ -1,12 +1,16 @@ +import { c } from 'ttag'; + import { DriveCrypto } from "../../crypto"; -import { ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; +import { ValidationError } from "../../errors"; +import { ProtonDriveAccount, ProtonDriveTelemetry, NodeType } from "../../interface"; import { DriveAPIService } from "../apiService"; import { DownloadAPIService } from "./apiService"; import { DownloadCryptoService } from "./cryptoService"; -import { NodesService } from "./interface"; +import { NodesService, RevisionsService } from "./interface"; import { FileDownloader } from "./fileDownloader"; import { DownloadQueue } from "./queue"; import { DownloadTelemetry } from "./telemetry"; +import { makeNodeUidFromRevisionUid } from "../uids"; export function initDownloadModule( telemetry: ProtonDriveTelemetry, @@ -14,6 +18,7 @@ export function initDownloadModule( driveCrypto: DriveCrypto, account: ProtonDriveAccount, nodesService: NodesService, + revisionsService: RevisionsService, ) { const queue = new DownloadQueue(); const api = new DownloadAPIService(apiService); @@ -27,6 +32,55 @@ export function initDownloadModule( try { node = await nodesService.getNode(nodeUid); nodeKey = await nodesService.getNodeKeys(nodeUid); + + if (node.type === NodeType.Folder) { + throw new ValidationError(c("Error").t`Cannot download a folder`); + } + if (!nodeKey.contentKeyPacketSessionKey) { + throw new ValidationError(c("Error").t`File has no content key`); + } + if (!node.activeRevision?.ok || !node.activeRevision.value) { + throw new ValidationError(c("Error").t`File has no active revision`); + } + } catch (error: unknown) { + queue.releaseCapacity(); + downloadTelemetry.downloadInitFailed(error); + throw error; + } + + const onFinish = () => queue.releaseCapacity(); + + return new FileDownloader( + downloadTelemetry, + api, + cryptoService, + { + key: nodeKey.key, + contentKeyPacketSessionKey: nodeKey.contentKeyPacketSessionKey, + }, + node.activeRevision.value, + signal, + onFinish, + ); + } + + async function getFileRevisionDownloader(nodeRevisionUid: string, signal?: AbortSignal) { + await queue.waitForCapacity(signal); + + const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid); + + let node, nodeKey, revision; + try { + node = await nodesService.getNode(nodeUid); + nodeKey = await nodesService.getNodeKeys(nodeUid); + revision = await revisionsService.getRevision(nodeRevisionUid); + + if (node.type === NodeType.Folder) { + throw new ValidationError(c("Error").t`Cannot download a folder`); + } + if (!nodeKey.contentKeyPacketSessionKey) { + throw new ValidationError(c("Error").t`File has no content key`); + } } catch (error: unknown) { queue.releaseCapacity(); downloadTelemetry.downloadInitFailed(error); @@ -39,8 +93,11 @@ export function initDownloadModule( downloadTelemetry, api, cryptoService, - nodeKey, - node, + { + key: nodeKey.key, + contentKeyPacketSessionKey: nodeKey.contentKeyPacketSessionKey, + }, + revision, signal, onFinish, ); @@ -48,5 +105,6 @@ export function initDownloadModule( return { getFileDownloader, + getFileRevisionDownloader, } } diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index 5dcfc095..95e93260 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, PublicKey, SessionKey } from "../../crypto"; -import { NodeEntity } from "../../interface"; +import { NodeEntity, Revision } from "../../interface"; export type BlockMetadata = { index: number, @@ -20,3 +20,7 @@ export interface NodesService { getNode(nodeUid: string): Promise, getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, contentKeyPacketSessionKey?: SessionKey; }>, } + +export interface RevisionsService { + getRevision(nodeRevisionUid: string): Promise, +} diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index 0daa66d2..3c266d62 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -8,9 +8,9 @@ export class DownloadTelemetry { this.telemetry = telemetry; } - getLoggerForNode(nodeUid: string) { + getLoggerForRevision(revisionUid: string) { const logger = this.telemetry.getLogger("download"); - return new LoggerWithPrefix(logger, `node ${nodeUid}`); + return new LoggerWithPrefix(logger, `revision ${revisionUid}`); } downloadInitFailed(error: unknown) { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 60f37a60..44df15a1 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -32,6 +32,7 @@ type PostDeleteNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/de type PostCreateFolderRequest = Extract['content']['application/json']; type PostCreateFolderResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['responses']['200']['content']['application/json']; +type GetRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; type GetRevisionsResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions']['get']['responses']['200']['content']['application/json']; enum APIRevisionState { Draft = 0, @@ -325,19 +326,20 @@ export class NodeAPIService { return makeNodeUid(volumeId, response.Folder.ID); } + async getRevision(nodeRevisionUid: string, signal?: AbortSignal): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?NoBlockUrls=true`, signal); + return transformRevisionResponse(volumeId, nodeId, response.Revision); + } + async getRevisions(nodeUid: string, signal?: AbortSignal): Promise { const { volumeId, nodeId } = splitNodeUid(nodeUid); const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, signal); return response.Revisions .filter((revision) => revision.State === APIRevisionState.Active || revision.State === APIRevisionState.Obsolete) - .map((revision) => ({ - uid: makeNodeRevisionUid(volumeId, nodeId, revision.ID), - state: revision.State === APIRevisionState.Active ? RevisionState.Active : RevisionState.Superseded, - createdDate: new Date(revision.CreateTime*1000), - signatureEmail: revision.SignatureEmail || undefined, - encryptedExtendedAttributes: revision.XAttr || undefined, - })); + .map((revision) => transformRevisionResponse(volumeId, nodeId, revision)); } async restoreRevision(nodeRevisionUid: string): Promise { @@ -392,3 +394,17 @@ function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: } } } + +function transformRevisionResponse( + volumeId: string, + nodeId: string, + revision: GetRevisionResponse['Revision'] | GetRevisionsResponse['Revisions'][0], +): EncryptedRevision { + return { + uid: makeNodeRevisionUid(volumeId, nodeId, revision.ID), + state: revision.State === APIRevisionState.Active ? RevisionState.Active : RevisionState.Superseded, + createdDate: new Date(revision.CreateTime*1000), + signatureEmail: revision.SignatureEmail || undefined, + armoredExtendedAttributes: revision.XAttr || undefined, + } +} diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index 8be94372..eff285df 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -1,4 +1,5 @@ import { Logger, Revision } from "../../interface"; +import { makeNodeUidFromRevisionUid } from "../uids"; import { NodeAPIService } from "./apiService"; import { NodesCryptoService } from "./cryptoService"; import { NodesAccess } from "./nodesAccess"; @@ -20,6 +21,21 @@ export class NodesRevisons { this.nodesAccess = nodesAccess; } + async getRevision(nodeRevisionUid: string): Promise { + const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid); + const node = await this.nodesAccess.getNode(nodeUid); + const { key: parentKey } = await this.nodesAccess.getParentKeys(node); + const { key } = await this.nodesAccess.getNodeKeys(nodeUid); + + const encryptedRevision = await this.apiService.getRevision(nodeRevisionUid); + const revision = await this.cryptoService.decryptRevision(encryptedRevision, key, parentKey); + const extendedAttributes = parseFileExtendedAttributes(this.logger, revision.extendedAttributes); + return { + ...revision, + ...extendedAttributes, + }; + } + async* iterateRevisions(nodeUid: string, signal?: AbortSignal): AsyncGenerator { const node = await this.nodesAccess.getNode(nodeUid); const { key: parentKey } = await this.nodesAccess.getParentKeys(node); diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index 3af5f89d..d5677345 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -49,3 +49,8 @@ export function splitNodeRevisionUid(nodeRevisionUid: string) { const [ volumeId, nodeId, revisionId ] = parts; return { volumeId, nodeId, revisionId }; } + +export function makeNodeUidFromRevisionUid(nodeRevisionUid: string) { + const { volumeId, nodeId } = splitNodeRevisionUid(nodeRevisionUid); + return makeNodeUid(volumeId, nodeId); +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 552f3082..f53e96ba 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -46,7 +46,7 @@ export class ProtonDriveClient implements Partial { const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access); - this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.nodes.access); + this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.nodes.access, this.nodes.revisions); this.upload = initUploadModule(apiService, cryptoModule, this.nodes.access); } @@ -398,7 +398,9 @@ export class ProtonDriveClient implements Partial { } /** - * Get the file downloader to download the node content. + * Get the file downloader to download the node content of the active + * revision. For downloading specific revision of the file, use + * `getFileRevisionDownloader`. * * The number of ongoing downloads is limited. If the limit is reached, * the download is queued and started when the slot is available. It is @@ -441,6 +443,14 @@ export class ProtonDriveClient implements Partial { return this.download.getFileDownloader(getUid(nodeUid), signal); } + /** + * Same as `getFileDownloader`, but for a specific revision of the file. + */ + async getFileRevisionDownloader(nodeRevisionUid: string, signal?: AbortSignal): Promise { + this.logger.info(`Getting file revision downloader for ${getUid(nodeRevisionUid)}`); + return this.download.getFileRevisionDownloader(nodeRevisionUid, signal); + } + async getFileUploader(parentFolderUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) { this.logger.info(`Getting file uploader for parent ${getUid(parentFolderUid)}`); return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index fdbe5c11..6099195f 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,4 +1,4 @@ -import { NodeOrUid, NodeEntity as PublicNode } from './interface'; +import { NodeEntity as PublicNode } from './interface'; import { DecryptedNode as InternalNode } from './internal/nodes'; type InternalPartialNode = Pick< @@ -18,14 +18,14 @@ type InternalPartialNode = Pick< 'folder' >; -export function getUid(nodeUid: NodeOrUid): string { +export function getUid(nodeUid: string | { uid: string }): string { if (typeof nodeUid === "string") { return nodeUid; } return nodeUid.uid; } -export function getUids(nodeUids: NodeOrUid[]): string[] { +export function getUids(nodeUids: (string | { uid: string })[]): string[] { return nodeUids.map(getUid); } From 6e93edbf4afc05e98ebb01b72d2213fa3881f1fe Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 8 Apr 2025 11:33:37 +0000 Subject: [PATCH 059/791] Implement get and delete public link --- js/sdk/package-lock.json | 7 ++ js/sdk/package.json | 1 + js/sdk/src/crypto/driveCrypto.ts | 11 ++ js/sdk/src/interface/sharing.ts | 7 +- js/sdk/src/internal/sharing/apiService.ts | 38 ++++++- js/sdk/src/internal/sharing/cryptoService.ts | 107 +++++++++++++++++- js/sdk/src/internal/sharing/interface.ts | 14 +++ .../sharing/sharingManagement.test.ts | 90 +++++++++++++-- .../src/internal/sharing/sharingManagement.ts | 59 ++++++++-- js/sdk/src/internal/uids.ts | 13 +++ 10 files changed, 322 insertions(+), 25 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index f5a981f6..e20c65f2 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "GPL-3.0", "dependencies": { + "bcryptjs": "^2.4.3", "ttag": "^1.8.7" }, "devDependencies": { @@ -4065,6 +4066,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", diff --git a/js/sdk/package.json b/js/sdk/package.json index dfa46f8c..36feb1b8 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -15,6 +15,7 @@ "test:watch": "jest --watch --coverage=false" }, "dependencies": { + "bcryptjs": "^2.4.3", "ttag": "^1.8.7" }, "devDependencies": { diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index e4031927..7acb257c 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -505,4 +505,15 @@ export class DriveCrypto { verified, } } + + async decryptShareUrlPassword( + armoredPassword: string, + decryptionKeys: PrivateKey[], + ): Promise { + const password = await this.openPGPCrypto.decryptArmored( + armoredPassword, + decryptionKeys, + ); + return new TextDecoder().decode(password); + } } diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index d954e52f..8fc0208d 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -31,12 +31,11 @@ export enum NonProtonInvitationState { export type PublicLink = { uid: string, - createDate: string, + createDate: Date, role: MemberRole, url: string, - password: string, - customPassword: string, - expirationDate: Date, + customPassword?: string, + expireDate?: Date, } export type Bookmark = { diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 9976671c..dabaa1c5 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -1,7 +1,7 @@ import { NodeType, MemberRole, NonProtonInvitationState, Logger } from "../../interface"; import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from "../apiService"; -import { makeNodeUid, splitNodeUid, makeInvitationUid, splitInvitationUid, makeMemberUid, splitMemberUid } from "../uids"; -import { EncryptedInvitationRequest, EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedBookmark, EncryptedExternalInvitationRequest } from "./interface"; +import { makeNodeUid, splitNodeUid, makeInvitationUid, splitInvitationUid, makeMemberUid, splitMemberUid, makePublicLinkUid, splitPublicLinkUid } from "../uids"; +import { EncryptedInvitationRequest, EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedBookmark, EncryptedExternalInvitationRequest, EncryptedPublicLink } from "./interface"; type GetSharedNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/shares']['get']['responses']['200']['content']['application/json']; @@ -40,6 +40,8 @@ type PutUpdateExternalInvitationResponse = drivePaths['/drive/v2/shares/{shareID type PostUpdateMemberRequest = Extract['content']['application/json']; type PostUpdateMemberResponse = drivePaths['/drive/v2/shares/{shareID}/members/{memberID}']['put']['responses']['200']['content']['application/json']; +type GetShareUrlsResponse = drivePaths['/drive/shares/{shareID}/urls']['get']['responses']['200']['content']['application/json']; + /** * Provides API communication for fetching and managing sharing. * @@ -341,6 +343,37 @@ export class SharingAPIService { await this.apiService.delete(`drive/v2/shares/${shareId}/members/${memberId}`); } + async getPublicLink(shareId: string): Promise { + const response = await this.apiService.get(`drive/shares/${shareId}/urls`); + + if (!response.ShareURLs || response.ShareURLs.length === 0) { + return undefined; + } + if (response.ShareURLs.length > 1) { + this.logger.warn('Multiple share URLs found, using the first one'); + } + const shareUrl = response.ShareURLs[0]; + + return { + uid: makePublicLinkUid(shareUrl.ShareID, shareUrl.ShareURLID), + createDate: new Date(shareUrl.CreateTime*1000), + expireDate: shareUrl.ExpirationTime ? new Date(shareUrl.ExpirationTime*1000) : undefined, + role: permissionsToDirectMemberRole(this.logger, shareUrl.Permissions), + flags: shareUrl.Flags, + creatorEmail: shareUrl.CreatorEmail, + publicUrl: shareUrl.PublicUrl, + armoredUrlPassword: shareUrl.Password, + urlPasswordSalt: shareUrl.UrlPasswordSalt, + base64SharePassphraseKeyPacket: shareUrl.SharePassphraseKeyPacket, + sharePassphraseSalt: shareUrl.SharePasswordSalt, + }; + } + + async removePublicLink(publicLinkUid: string): Promise { + const { shareId, publicLinkId } = splitPublicLinkUid(publicLinkUid); + await this.apiService.delete(`drive/shares/${shareId}/urls/${publicLinkId}`); + } + private convertInternalInvitation(shareId: string, invitation: GetShareInvitations['Invitations'][0]): EncryptedInvitation { return { uid: makeInvitationUid(shareId, invitation.InvitationID), @@ -365,5 +398,4 @@ export class SharingAPIService { state, } } - } diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 26daf322..eb63b94a 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -1,10 +1,27 @@ +import bcrypt from 'bcryptjs'; import { c } from 'ttag'; -import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; -import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, InvalidNameError, resultError, resultOk } from "../../interface"; +import { DriveCrypto, PrivateKey, SessionKey, uint8ArrayToBase64String, VERIFICATION_STATUS } from '../../crypto'; +import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, InvalidNameError, resultError, resultOk, PublicLink } from "../../interface"; import { getErrorMessage, getVerificationMessage } from "../errors"; import { EncryptedShare } from "../shares"; -import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember } from "./interface"; +import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink } from "./interface"; + +// Version 2 of bcrypt with 2**10 rounds. +// https://en.wikipedia.org/wiki/Bcrypt#Description +const BCRYPT_PREFIX = '$2y$10$'; + +const PUBLIC_LINK_GENERATED_PASSWORD_LENGTH = 12; + +// We do not support management of legacy public links anymore (that is no +// flag or bit 1). But we still need to support to read the legacy public +// link. +enum PublicLinkFlags { + Legacy = 0, + CustomPassword = 1, + GeneratedPasswordIncluded = 2, + GeneratedPasswordWithCustomPassword = 3, +} /** * Provides crypto operations for sharing. @@ -254,4 +271,88 @@ export class SharingCryptoService { role: encryptedMember.role, }; } + + async encryptPublicLink(): Promise { + const password = await this.generatePassword(); + await this.computeKeySaltAndPassphrase(password); + // TODO: finish creation of public links + } + + private async generatePassword(): Promise { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const values = crypto.getRandomValues(new Uint32Array(length)); + + let result = ''; + for (let i = 0; i < length; i++) { + result += charset[values[i] % charset.length]; + } + + return result; + } + + private async computeKeySaltAndPassphrase(password: string) { + if (!password) { + throw new Error('Password required.'); + } + + const salt = crypto.getRandomValues(new Uint8Array(16)); + const hash: string = await bcrypt.hash(password, BCRYPT_PREFIX + bcrypt.encodeBase64(salt, 16)); + // Remove bcrypt prefix and salt (first 29 characters) + const passphrase = hash.slice(29); + + return { + base64Salt: uint8ArrayToBase64String(salt), + passphrase, + } + }; + + async decryptPublicLink(shareAddressId: string, encryptedPublicLink: EncryptedPublicLink): Promise { + const address = await this.account.getOwnAddress(shareAddressId); + const addressKeys = address.keys.map(({ key }) => key); + + const { password, customPassword } = await this.decryptShareUrlPassword( + encryptedPublicLink, + addressKeys, + ); + + return { + uid: encryptedPublicLink.uid, + createDate: encryptedPublicLink.createDate, + expireDate: encryptedPublicLink.expireDate, + role: encryptedPublicLink.role, + url: `${encryptedPublicLink.publicUrl}#${password}`, + customPassword, + } + } + + private async decryptShareUrlPassword( + encryptedPublicLink: EncryptedPublicLink, + addressKeys: PrivateKey[], + ): Promise<{ + password: string, + customPassword?: string, + }> { + const password = await this.driveCrypto.decryptShareUrlPassword( + encryptedPublicLink.armoredUrlPassword, + addressKeys, + ); + + switch (encryptedPublicLink.flags) { + // This is legacy that is not supported anymore. + // Availalbe only for reading. + case PublicLinkFlags.Legacy: + case PublicLinkFlags.CustomPassword: + return { + password, + } + case PublicLinkFlags.GeneratedPasswordIncluded: + case PublicLinkFlags.GeneratedPasswordWithCustomPassword: + return { + password: password.substring(0, PUBLIC_LINK_GENERATED_PASSWORD_LENGTH), + customPassword: password.substring(PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) || undefined, + } + default: + throw new Error(`Unsupported public link with flags: ${encryptedPublicLink.flags}`); + } + } } diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index bf4327bb..70785de1 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -107,6 +107,20 @@ export interface EncryptedBookmark { }; } +export interface EncryptedPublicLink { + uid: string, + createDate: Date, + expireDate?: Date, + role: MemberRole, + flags: number, + creatorEmail: string, + publicUrl: string, + armoredUrlPassword: string, + urlPasswordSalt: string, + base64SharePassphraseKeyPacket: string, + sharePassphraseSalt: string, +} + /** * Interface describing the dependencies to the shares module. */ diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 59b8ea8c..1bf471b0 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -1,5 +1,5 @@ import { getMockLogger } from "../../tests/logger"; -import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonDriveAccount, ProtonInvitation, resultOk } from "../../interface"; +import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonDriveAccount, ProtonInvitation, PublicLink, resultOk } from "../../interface"; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; import { SharesService, NodesService } from "./interface"; @@ -35,6 +35,9 @@ describe("SharingManagement", () => { deleteExternalInvitation: jest.fn(), updateMember: jest.fn(), removeMember: jest.fn(), + getPublicLink: jest.fn().mockResolvedValue(undefined), + removePublicLink: jest.fn(), + deleteShare: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking cryptoService = { @@ -47,6 +50,7 @@ describe("SharingManagement", () => { ...invitation, base64ExternalInvitationSignature: "extenral-signature", })), + decryptPublicLink: jest.fn().mockImplementation((_, publicLink) => publicLink), } // @ts-expect-error No need to implement all methods for mocking accountService = { @@ -55,7 +59,7 @@ describe("SharingManagement", () => { // @ts-expect-error No need to implement all methods for mocking sharesService = { getVolumeEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressKey: "volume-key" }), - loadEncryptedShare: jest.fn().mockResolvedValue({ id: "shareId" }), + loadEncryptedShare: jest.fn().mockResolvedValue({ id: "shareId", addressId: "addressId" }), } // @ts-expect-error No need to implement all methods for mocking nodesService = { @@ -127,6 +131,23 @@ describe("SharingManagement", () => { }); expect(cryptoService.decryptMember).toHaveBeenCalledWith(member); }); + + it("should return public link", async () => { + const publicLink = { + uid: 'shared~publicLink', + } + apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); + + const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [], + members: [], + publicLink: publicLink, + }); + expect(cryptoService.decryptPublicLink).toHaveBeenCalledWith("addressId", publicLink); + }); }); describe("shareNode", () => { @@ -425,6 +446,7 @@ describe("SharingManagement", () => { let invitation: ProtonInvitation; let externalInvitation: NonProtonInvitation; let member: Member; + let publicLink: PublicLink; beforeEach(async () => { invitation = { @@ -449,18 +471,23 @@ describe("SharingManagement", () => { role: MemberRole.Viewer, invitedDate: new Date(), }; + publicLink = { + uid: "publicLink", + createDate: new Date(), + role: MemberRole.Viewer, + url: "url", + } apiService.getShareInvitations = jest.fn().mockResolvedValue([ invitation, ]); - apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([ externalInvitation, ]); - apiService.getShareMembers = jest.fn().mockResolvedValue([ member, ]); + apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); }); it("should delete invitation", async () => { @@ -470,11 +497,13 @@ describe("SharingManagement", () => { protonInvitations: [], nonProtonInvitations: [externalInvitation], members: [member], - publicLink: undefined, + publicLink, }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); expect(apiService.deleteInvitation).toHaveBeenCalled(); expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); }); it("should delete external invitation", async () => { @@ -484,11 +513,13 @@ describe("SharingManagement", () => { protonInvitations: [invitation], nonProtonInvitations: [], members: [member], - publicLink: undefined, + publicLink, }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); expect(apiService.deleteInvitation).not.toHaveBeenCalled(); expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); }); it("should remove member", async () => { @@ -498,25 +529,70 @@ describe("SharingManagement", () => { protonInvitations: [invitation], nonProtonInvitations: [externalInvitation], members: [], - publicLink: undefined, + publicLink, }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); expect(apiService.deleteInvitation).not.toHaveBeenCalled(); expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); }); it("should be no-op if not shared with email", async () => { const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["non-existing-email"] }); + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink, + }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); + }); + + it("should remove public link", async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { publicLink: "remove" }); + expect(sharingInfo).toEqual({ protonInvitations: [invitation], nonProtonInvitations: [externalInvitation], members: [member], publicLink: undefined, }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); expect(apiService.deleteInvitation).not.toHaveBeenCalled(); expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).toHaveBeenCalled(); + }); + + it("should remove share if all is removed", async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid); + + expect(sharingInfo).toEqual(undefined); + expect(apiService.deleteShare).toHaveBeenCalled(); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); + }); + + it("should remove share if everything is manually removed", async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { + users: ["internal-email", "external-email", "member-email"], + publicLink: "remove", + }); + + expect(sharingInfo).toEqual(undefined); + expect(apiService.deleteShare).toHaveBeenCalled(); + expect(apiService.deleteInvitation).toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); + expect(apiService.removeMember).toHaveBeenCalled(); + expect(apiService.removePublicLink).toHaveBeenCalled(); }); }); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index c5a30d29..aa552202 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -4,6 +4,7 @@ import { SessionKey } from "../../crypto"; import { ValidationError } from "../../errors"; import { Logger, PublicLink, MemberRole, ShareNodeSettings, UnshareNodeSettings, SharePublicLinkSettings, ShareResult, ProtonInvitation, NonProtonInvitation, Member, resultOk, ProtonDriveAccount } from "../../interface"; import { splitNodeUid } from "../uids"; +import { getErrorMessage } from '../errors'; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; import { SharesService, NodesService } from "./interface"; @@ -89,10 +90,26 @@ export class SharingManagement { } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars private async getPublicLink(shareId: string): Promise { - // TODO - return undefined; + // FIXME: address ID is not set when user is not member of the share. + // Users cannot manage other shares yet (admin role is not supported + // yet). But owners will stop being members and we need to keep this + // working. Simple solution is to use the volume email key address ID + // as that should be used for public links, but some older links that + // did not follow this logic might not be able to decrypt once the + // owner is not member by default. + const encryptedShare = await this.sharesService.loadEncryptedShare(shareId); + let addressId = encryptedShare.addressId; + if (!addressId) { + const volumeEmailKey = await this.sharesService.getVolumeEmailKey(encryptedShare.volumeId); + addressId = volumeEmailKey.addressId; + } + + const encryptedPublicLink = await this.apiService.getPublicLink(shareId); + if (!encryptedPublicLink) { + return; + } + return this.cryptoService.decryptPublicLink(addressId, encryptedPublicLink); } async shareNode(nodeUid: string, settings: ShareNodeSettings): Promise { @@ -260,11 +277,38 @@ export class SharingManagement { } if (settings.publicLink === 'remove') { - this.logger.info(`Removing public link to node ${nodeUid}`); - await this.removeSharedLink(currentSharing.share); + if (currentSharing.publicLink) { + this.logger.info(`Removing public link to node ${nodeUid}`); + await this.removeSharedLink(currentSharing.publicLink.uid); + } else { + this.logger.info(`Public link not found for node ${nodeUid}`); + } currentSharing.publicLink = undefined; } + if ( + currentSharing.protonInvitations.length === 0 && + currentSharing.nonProtonInvitations.length === 0 && + currentSharing.members.length === 0 && + !currentSharing.publicLink + ) { + // Technically it is not needed to delete the share explicitly + // as it will be deleted when the last member is removed by the + // backend, but that might take a while and it is better to + // update local state immediately. + this.logger.info(`Deleting share ${currentSharing.share.shareId} for node ${nodeUid}`); + try { + await this.deleteShare(currentSharing.share.shareId); + } catch (error: unknown) { + // If deleting the share fails, we don't want to throw an error + // as it might be a race condition that other client updated + // the share and it is not empty. + // If share is truly empty, backend will delete it eventually. + this.logger.warn(`Failed to delete share ${currentSharing.share.shareId} for node ${nodeUid}: ${getErrorMessage(error)}`); + } + return; + } + return { protonInvitations: currentSharing.protonInvitations, nonProtonInvitations: currentSharing.nonProtonInvitations, @@ -417,8 +461,7 @@ export class SharingManagement { // TODO } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private async removeSharedLink(share: Share): Promise { - // TODO + private async removeSharedLink(publicLinkUid: string): Promise { + await this.apiService.removePublicLink(publicLinkUid); } } diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index d5677345..faf15e3f 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -37,6 +37,19 @@ export function splitMemberUid(memberUid: string) { return { shareId, memberId }; } +export function makePublicLinkUid(shareId: string, publicLinkId: string) { + return `${shareId}~${publicLinkId}`; +} + +export function splitPublicLinkUid(publicLinkUid: string) { + const parts = publicLinkUid.split('~'); + if (parts.length !== 2) { + throw new Error(`"${publicLinkUid}" is not valid public link UID`); + } + const [ shareId, publicLinkId ] = parts; + return { shareId, publicLinkId }; +} + export function makeNodeRevisionUid(volumeId: string, nodeId: string, revisionId: string) { return `${volumeId}~${nodeId}~${revisionId}`; } From 0c70cc47eafbe5d6321dba239bc00b5166424d8e Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 8 Apr 2025 12:20:39 +0000 Subject: [PATCH 060/791] Implement degraded node --- js/sdk/src/interface/events.ts | 4 +- js/sdk/src/interface/index.ts | 4 +- js/sdk/src/interface/nodes.ts | 61 +- js/sdk/src/interface/telemetry.ts | 21 +- .../internal/download/fileDownloader.test.ts | 3 +- js/sdk/src/internal/download/interface.ts | 10 +- .../src/internal/nodes/cryptoService.test.ts | 961 +++++++++--------- js/sdk/src/internal/nodes/cryptoService.ts | 169 +-- js/sdk/src/internal/nodes/events.test.ts | 16 +- js/sdk/src/internal/nodes/events.ts | 3 +- js/sdk/src/internal/nodes/interface.ts | 3 +- js/sdk/src/internal/nodes/nodesRevisions.ts | 8 +- js/sdk/src/internal/photos/interface.ts | 6 +- .../src/internal/shares/cryptoService.test.ts | 4 +- js/sdk/src/internal/shares/cryptoService.ts | 4 +- js/sdk/src/internal/sharing/events.test.ts | 16 +- js/sdk/src/internal/sharing/events.ts | 5 +- js/sdk/src/internal/sharing/interface.ts | 7 +- js/sdk/src/internal/sharing/sharingAccess.ts | 13 +- js/sdk/src/internal/upload/interface.ts | 2 - js/sdk/src/protonDriveClient.ts | 18 +- js/sdk/src/transformers.ts | 49 +- 22 files changed, 744 insertions(+), 643 deletions(-) diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index 4b1784c3..c3d57a73 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -1,5 +1,5 @@ import { Device } from './devices'; -import { NodeEntity, NodeOrUid } from './nodes'; +import { MaybeNode, NodeOrUid } from './nodes'; export interface Events { subscribeToRemoteDataUpdates(): void, @@ -19,7 +19,7 @@ export type NodeEventCallback = (nodeEvent: NodeEvent) => void; export type NodeEvent = { type: 'update', uid: string, - node: NodeEntity, + node: MaybeNode, } | { type: 'remove', uid: string, diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 2230b2f4..82b3b11b 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -16,11 +16,11 @@ export type { Device, DeviceOrUid } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; export type { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; -export type { NodeEntity, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; +export type { MaybeNode, NodeEntity, DegradedNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; -export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent, MetricContext } from './telemetry'; +export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent, MetricContext } from './telemetry'; export type { Fileuploader, UploadController, Thumbnail, ThumbnailType, UploadMetadata } from './upload'; export type ProtonDriveTelemetry = Telemetry; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 57509ab4..48251198 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -1,11 +1,31 @@ import { Result } from './result'; import { Author } from './author'; -// Note: Node is reserved by JS/DOM, thus we need exception how the entity is called +/** + * Node representing a file or folder in the system. + * + * This covers both happy path and degraded path. It is used in the SDK to + * represent the node in a way that is easy to work with. Whenever any field + * cannot be decrypted, it is returned as `DegradedNode` type. + */ +export type MaybeNode = Result; + +/** + * Node representing a file or folder in the system. + * + * This is a happy path representation of the node. It is used in the SDK to + * represent the node in a way that is easy to work with. Whenever any field + * cannot be decrypted, it is returned as `DegradedNode` type. + * + * SDK never returns this entity directly but wrapped in `MaybeNode`. + * + * Note on naming: Node is reserved by JS/DOM, thus we need exception how the + * entity is called. + */ export type NodeEntity = { uid: string, parentUid?: string, - name: Result, + name: string, /** * Author of the node key. * @@ -35,12 +55,45 @@ export type NodeEntity = { */ createdDate: Date, trashedDate?: Date, - activeRevision?: Result, + activeRevision?: Revision, folder?: { claimedModificationTime?: Date, }, } +/** + * Degraded node representing a file or folder in the system. + * + * This is a degraded path representation of the node. It is used in the SDK to + * represent the node in a way that is easy to work with. Whenever any field + * cannot be decrypted, it is returned as `DegradedNode` type. + * + * SDK never returns this entity directly but wrapped in `MaybeNode`. + * + * The node can be still used around, but it is not guaranteed that all + * properties are decrypted, or that all actions can be performed on it. + * + * For example, if the node has issue decrypting the name, the name will be + * set es `InvalidNameError` and potentially rename or move actions will not be + * possible, but download and upload new revision will still work. + */ +export type DegradedNode = Omit & { + name: Result, + activeRevision?: Result, + /** + * If the error is not related to any specific field, it is set here. + * + * For example, if the node has issue decrypting the name, the name will be + * set es `InvalidNameError` that includes the error, while this will be + * empty. + * + * On the other hand, if the node has issue decrypting the node key, but + * the name is still working, this will include the node key error, while + * the name will be set to the decrypted value. + */ + errors?: unknown[], +} + export type InvalidNameError = { /** * Placeholder instead of node name that client can use to display. @@ -79,7 +132,7 @@ export enum RevisionState { Superseded = "superseded", } -export type NodeOrUid = NodeEntity | string; +export type NodeOrUid = MaybeNode | NodeEntity | DegradedNode | string; export type RevisionOrUid = Revision | string; export type NodeResult = diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 03918b93..678efcb2 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -63,18 +63,35 @@ export type MetricsDownloadErrorType = export interface MetricDecryptionErrorEvent { eventName: 'decryptionError', context: MetricContext, - entity: 'share' | 'node' | 'content', + field: MetricsDecryptionErrorField, fromBefore2024?: boolean, error?: unknown, }; +export type MetricsDecryptionErrorField = + 'shareKey' | + 'nodeKey' | + 'nodeName' | + 'nodeHashKey' | + 'nodeFolderExtendedAttributes' | + 'nodeActiveRevision' | + 'nodeContentKey' | + 'content'; export interface MetricVerificationErrorEvent { eventName: 'verificationError', context: MetricContext, - verificationKey: 'ShareAddress' | 'NodeKey' | 'SignatureEmail' | 'NameSignatureEmail' | 'other', + field: MetricVerificationErrorField, addressMatchingDefaultShare?: boolean, fromBefore2024?: boolean, }; +export type MetricVerificationErrorField = + 'shareKey' | + 'nodeKey' | + 'nodeName' | + 'nodeHashKey' | + 'nodeExtendedAttributes' | + 'nodeContentKey' | + 'content'; export interface MetricVolumeEventsSubscriptionsChangedEvent { eventName: 'volumeEventsSubscriptionsChanged', diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index 1c99ba85..432915b4 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -1,5 +1,4 @@ -import { ValidationError } from '../../errors'; -import { NodeEntity, NodeType, Revision } from '../../interface'; +import { Revision } from '../../interface'; import { FileDownloader } from './fileDownloader'; import { DownloadTelemetry } from './telemetry'; import { DownloadAPIService } from './apiService'; diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index 95e93260..f364c51f 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, PublicKey, SessionKey } from "../../crypto"; -import { NodeEntity, Revision } from "../../interface"; +import { NodeType, Result, Revision } from "../../interface"; export type BlockMetadata = { index: number, @@ -17,10 +17,16 @@ export type RevisionKeys = { } export interface NodesService { - getNode(nodeUid: string): Promise, + getNode(nodeUid: string): Promise, getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, contentKeyPacketSessionKey?: SessionKey; }>, } +export interface NodesServiceNode { + uid: string, + type: NodeType, + activeRevision?: Result, +} + export interface RevisionsService { getRevision(nodeRevisionUid: string): Promise, } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index e1fe285e..28137ee9 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,7 +1,7 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; +import { ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from "../../interface"; import { getMockTelemetry } from "../../tests/telemetry"; -import { EncryptedNode, SharesService } from "./interface"; +import { DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from "./interface"; import { NodesCryptoService } from "./cryptoService"; describe("nodesCryptoService", () => { @@ -59,540 +59,499 @@ describe("nodesCryptoService", () => { cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); }); - it("should decrypt node with same author everywhere", async () => { - const result = await cryptoService.decryptNode( - { - uid: "volumeId:nodeId", - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "signatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toMatchObject({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "signatureEmail" }, - activeRevision: undefined, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: undefined, - }, - }); - - expect(account.getPublicKeys).toHaveBeenCalledTimes(1); - expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); - expect(telemetry.logEvent).not.toHaveBeenCalled(); - }); - - it("should decrypt node with different authors", async () => { - const result = await cryptoService.decryptNode( - { - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toEqual({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: undefined, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: undefined, - }, - }); - - expect(account.getPublicKeys).toHaveBeenCalledTimes(2); - expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); - expect(account.getPublicKeys).toHaveBeenCalledWith("nameSignatureEmail"); - expect(telemetry.logEvent).not.toHaveBeenCalled(); - }); - - it("should decrypt folder node", async () => { - const result = await cryptoService.decryptNode( - { - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "signatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - folder: { - armoredHashKey: "armoredHashKey", - armoredExtendedAttributes: "encryptedExtendedAttributes", - } - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toEqual({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "signatureEmail" }, - activeRevision: undefined, - folder: { - extendedAttributes: "{}", - }, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: new Uint8Array(), - }, - }); - expect(telemetry.logEvent).not.toHaveBeenCalled(); - }); + const parentKey = "parentKey" as unknown as PrivateKey; - it("should decrypt folder node with signature validation error on key", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.NOT_SIGNED, - })); - - const result = await cryptoService.decryptNode( - { - uid: "volumeId~nodeId", - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - folder: { - armoredHashKey: "armoredHashKey", - } - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toMatchObject({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: undefined, - folder: { - extendedAttributes: undefined, - }, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: new Uint8Array(), - }, - }); + function verifyLogEventVerificationError(options = {}) { + expect(telemetry.logEvent).toHaveBeenCalledTimes(1); expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: "verificationError", context: "own_volume", fromBefore2024: false, - verificationKey: "SignatureEmail", addressMatchingDefaultShare: false, + ...options, }); - }); + } - it("should decrypt folder node with signature validation error on name", async () => { - driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ - name: "name", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); - - const result = await cryptoService.decryptNode( - { - uid: "volumeId~nodeId", - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - folder: { - armoredHashKey: "armoredHashKey", - } - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toMatchObject({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Signature verification for name failed" } }, - activeRevision: undefined, - folder: { - extendedAttributes: undefined, - }, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: new Uint8Array(), - }, - }); + function verifyLogEventDecryptionError(options = {}) { + expect(telemetry.logEvent).toHaveBeenCalledTimes(1); expect(telemetry.logEvent).toHaveBeenCalledWith({ - eventName: "verificationError", + eventName: "decryptionError", context: "own_volume", fromBefore2024: false, - verificationKey: "NameSignatureEmail", - addressMatchingDefaultShare: false, + ...options, }); - }); - - it("should decrypt folder node with signature validation error on hash key", async () => { - driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ - hashKey: new Uint8Array(), - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); - - const result = await cryptoService.decryptNode( - { - uid: "volumeId~nodeId", - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - folder: { - armoredHashKey: "armoredHashKey", - } - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toMatchObject({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for hash key failed" } }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: undefined, + } + + describe("folder node", () => { + const encryptedNode = { + uid: "volumeId~nodeId", + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", folder: { - extendedAttributes: undefined, + armoredHashKey: "armoredHashKey", + armoredExtendedAttributes: "folderArmoredExtendedAttributes", }, }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: new Uint8Array(), - }, - }); - expect(telemetry.logEvent).toHaveBeenCalledWith({ - eventName: "verificationError", - context: "own_volume", - fromBefore2024: false, - verificationKey: "NodeKey", - addressMatchingDefaultShare: false, - }); - }); - - it("should decrypt folder node with signature validation error on key and hash key", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.NOT_SIGNED, - })); - driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ - hashKey: new Uint8Array(), - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); - - const result = await cryptoService.decryptNode( - { - uid: "volumeId~nodeId", - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + } as EncryptedNode; + + function verifyResult( + result: { node: DecryptedUnparsedNode, keys?: DecryptedNodeKeys }, + expectedNode: Partial = {}, + expectedKeys: Partial | 'noKeys' = {}, + ) { + expect(result).toMatchObject({ + node: { + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, folder: { - armoredHashKey: "armoredHashKey", - } + extendedAttributes: "{}", + }, + activeRevision: undefined, + errors: undefined, + ...expectedNode, }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toMatchObject({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: undefined, - folder: { - extendedAttributes: undefined, + ...expectedKeys === 'noKeys' ? {} : { + keys: { + passphrase: "pass", + key: "decryptedKey", + passphraseSessionKey: "passphraseSessionKey", + hashKey: new Uint8Array(), + ...expectedKeys, + } }, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: new Uint8Array(), - }, - }); - expect(telemetry.logEvent).toHaveBeenCalledWith({ - eventName: "verificationError", - context: "own_volume", - fromBefore2024: false, - verificationKey: "SignatureEmail", - addressMatchingDefaultShare: false, + }); + } + + describe("should decrypt successfuly", () => { + it("same author everywhere", async () => { + const encryptedNode = { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "signatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + folder: { + armoredHashKey: "armoredHashKey", + armoredExtendedAttributes: "folderArmoredExtendedAttributes", + }, + }, + } as EncryptedNode; + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "signatureEmail" }, + }); + + expect(account.getPublicKeys).toHaveBeenCalledTimes(1); + expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(telemetry.logEvent).not.toHaveBeenCalled(); + }); + + it("different authors on key and name", async () => { + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result); + expect(account.getPublicKeys).toHaveBeenCalledTimes(2); + expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(account.getPublicKeys).toHaveBeenCalledWith("nameSignatureEmail"); + expect(telemetry.logEvent).not.toHaveBeenCalled(); + }); }); - expect(telemetry.logEvent).toHaveBeenCalledTimes(1); - }); - it("should decrypt folder node with signature validation error on extended attributes", async () => { - driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.resolve({ - extendedAttributes: "{}", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); - - const result = await cryptoService.decryptNode( - { - uid: "volumeId~nodeId", - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - folder: { - armoredHashKey: "armoredHashKey", - armoredExtendedAttributes: "encryptedExtendedAttributes", - } - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toMatchObject({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for attributes failed" } }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, - activeRevision: undefined, - folder: { + describe("should decrypt with verification issues", () => { + it("on node key", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeKey', + }); + }); + + it("on node name", async () => { + driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ + name: "name", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Signature verification for name failed" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeName', + }); + }); + + it("on hash key", async () => { + driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for hash key failed" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeHashKey', + }); + }); + + it("on node key and hash key reports error from node key", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + })); + driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeKey', + }); + }); + + it("on folder extended attributes", async () => { + driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.resolve({ extendedAttributes: "{}", - }, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: new Uint8Array(), - }, + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for attributes failed" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeExtendedAttributes', + }); + }); + }); + + describe("should decrypt with decryption issues", () => { + it("on node key", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Failed to decrypt node key: Decryption error" } }, + errors: [new Error("Decryption error")], + folder: undefined, + }, 'noKeys'); + verifyLogEventDecryptionError({ + field: 'nodeKey', + error, + }); + }); + + it("on node name", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + name: { ok: false, error: { name: "", error: "Decryption error" } }, + nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, + }, 'noKeys'); + verifyLogEventDecryptionError({ + field: 'nodeName', + error, + }); + }); + + it("on hash key", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + errors: [error], + }, 'noKeys'); + verifyLogEventDecryptionError({ + field: 'nodeHashKey', + error, + }); + }); + + it("on folder extended attributes", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + folder: undefined, + errors: [error], + }, 'noKeys'); + verifyLogEventDecryptionError({ + field: 'nodeFolderExtendedAttributes', + error, + }); + }); }); - expect(telemetry.logEvent).not.toHaveBeenCalled(); }); - it("should decrypt file node", async () => { - const result = await cryptoService.decryptNode( - { - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "signatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - file: { - base64ContentKeyPacket: "base64ContentKeyPacket", - }, - activeRevision: { - uid: "revisionUid", - state: "active", - signatureEmail: "revisionSignatureEmail", - armoredExtendedAttributes: "encryptedExtendedAttributes", - }, + describe("file node", () => { + const encryptedNode = { + uid: "volumeId~nodeId", + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + file: { + base64ContentKeyPacket: "base64ContentKeyPacket", }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toEqual({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "signatureEmail" }, - activeRevision: { ok: true, value: { + activeRevision: { uid: "revisionUid", state: "active", - createdDate: undefined, - extendedAttributes: "{}", - contentAuthor: { ok: true, value: "revisionSignatureEmail" }, - } }, - folder: undefined, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - contentKeyPacketSessionKey: "contentKeyPacketSessionKey", - hashKey: undefined, + signatureEmail: "revisionSignatureEmail", + armoredExtendedAttributes: "encryptedExtendedAttributes", + }, }, - }); - expect(telemetry.logEvent).not.toHaveBeenCalled(); - }); - - it("should decrypt file node with signature validation error on extended attribute", async () => { - driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.resolve({ - extendedAttributes: "{}", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); - - const result = await cryptoService.decryptNode( - { - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "signatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - file: { - base64ContentKeyPacket: "base64ContentKeyPacket", - }, - activeRevision: { + } as EncryptedNode; + + function verifyResult( + result: { node: DecryptedUnparsedNode, keys?: DecryptedNodeKeys }, + expectedNode: Partial = {}, + expectedKeys: Partial | 'noKeys' = {}, + ) { + expect(result).toMatchObject({ + node: { + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, + folder: undefined, + activeRevision: { ok: true, value: { uid: "revisionUid", - state: "active", - signatureEmail: "revisionSignatureEmail", - armoredExtendedAttributes: "encryptedExtendedAttributes", + state: RevisionState.Active, + createdDate: undefined, + extendedAttributes: "{}", + contentAuthor: { ok: true, value: "revisionSignatureEmail" }, + } }, + errors: undefined, + ...expectedNode, + }, + ...expectedKeys === 'noKeys' ? {} : { + keys: { + passphrase: "pass", + key: "decryptedKey", + passphraseSessionKey: "passphraseSessionKey", + hashKey: undefined, + contentKeyPacketSessionKey: "contentKeyPacketSessionKey", + ...expectedKeys, }, }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toEqual({ - node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "signatureEmail" }, - activeRevision: { ok: true, value: { - uid: "revisionUid", - state: "active", - createdDate: undefined, - extendedAttributes: "{}", - contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Signature verification for attributes failed" } }, - } }, - folder: undefined, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - contentKeyPacketSessionKey: "contentKeyPacketSessionKey", - hashKey: undefined, - }, - }); - expect(telemetry.logEvent).not.toHaveBeenCalled(); - }); + }); + } + + describe("should decrypt successfuly", () => { + it("same author everywhere", async () => { + const encryptedNode = { + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "signatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + file: { + base64ContentKeyPacket: "base64ContentKeyPacket", + }, + activeRevision: { + uid: "revisionUid", + state: "active", + signatureEmail: "signatureEmail", + armoredExtendedAttributes: "encryptedExtendedAttributes", + }, + }, + } as EncryptedNode; - it("should handle decrypt of node with key decryption issue", async () => { - const error = new Error("Decryption error"); - driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); - - const result = await cryptoService.decryptNode( - { - uid: "volumeId~nodeId", - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - folder: { - armoredHashKey: "armoredHashKey", - } - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toMatchObject({ - node: { - name: { ok: false, error: { name: "", error: "Failed to decrypt node key: Decryption error"} }, - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Failed to decrypt node key: Decryption error" } }, - nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Failed to decrypt node key: Decryption error" } }, - activeRevision: { ok: false, error: new Error("Failed to decrypt node key: Decryption error") }, - }, - }); - expect(telemetry.logEvent).toHaveBeenCalledWith({ - eventName: "decryptionError", - context: "own_volume", - entity: "node", - fromBefore2024: false, - error, + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "signatureEmail" }, + activeRevision: { ok: true, value: { + uid: "revisionUid", + state: RevisionState.Active, + // @ts-expect-error Ignore mocked data. + createdDate: undefined, + extendedAttributes: "{}", + contentAuthor: { ok: true, value: "signatureEmail" }, + } }, + }); + + expect(account.getPublicKeys).toHaveBeenCalledTimes(2); // node + revision + expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(telemetry.logEvent).not.toHaveBeenCalled(); + }); + + it("different authors on key and name", async () => { + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result); + expect(account.getPublicKeys).toHaveBeenCalledTimes(3); + expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(account.getPublicKeys).toHaveBeenCalledWith("nameSignatureEmail"); + expect(account.getPublicKeys).toHaveBeenCalledWith("revisionSignatureEmail"); + expect(telemetry.logEvent).not.toHaveBeenCalled(); + }); }); - }); - it("should handle decrypt of node with name decryption issue", async () => { - const error = new Error("Decryption error"); - driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(error)); - - const result = await cryptoService.decryptNode( - { - uid: "volumeId~nodeId", - encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", - }, - } as EncryptedNode, - "parentKey" as unknown as PrivateKey - ); - - expect(result).toMatchObject({ - node: { - name: { ok: false, error: { name: "", error: "Decryption error" } }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, - activeRevision: undefined, - }, - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: undefined, - }, + describe("should decrypt with verification issues", () => { + it("on node key", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeKey', + }); + }); + + it("on node name", async () => { + driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ + name: "name", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Signature verification for name failed" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeName', + }); + }); + + it("on folder extended attributes", async () => { + driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.resolve({ + extendedAttributes: "{}", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + activeRevision: { ok: true, value: { + uid: "revisionUid", + extendedAttributes: "{}", + state: RevisionState.Active, + // @ts-expect-error Ignore mocked data. + createdDate: undefined, + contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Signature verification for attributes failed" } }, + } }, + }); + verifyLogEventVerificationError({ + field: 'nodeExtendedAttributes', + }); + }); + + it("on content key packet", async () => { + driveCrypto.decryptAndVerifySessionKey = jest.fn(async () => Promise.resolve({ + sessionKey: "contentKeyPacketSessionKey", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for content key failed" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeContentKey', + }); + }); }); - expect(telemetry.logEvent).toHaveBeenCalledWith({ - eventName: "decryptionError", - context: "own_volume", - entity: "node", - fromBefore2024: false, - error, + + describe("should decrypt with decryption issues", () => { + it("on node key", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Failed to decrypt node key: Decryption error" } }, + activeRevision: { ok: false, error: new Error('Failed to decrypt node key: Decryption error') }, + errors: [new Error("Decryption error")], + folder: undefined, + }, 'noKeys'); + verifyLogEventDecryptionError({ + field: 'nodeKey', + error, + }); + }); + + it("on node name", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + name: { ok: false, error: { name: "", error: "Decryption error" } }, + nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, + }, 'noKeys'); + verifyLogEventDecryptionError({ + field: 'nodeName', + error, + }); + }); + + it("on file extended attributes", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + activeRevision: { ok: false, error: new Error('Failed to decrypt active revision: Decryption error') }, + }); + verifyLogEventDecryptionError({ + field: 'nodeActiveRevision', + error, + }); + }); + + it("on content key packet", async () => { + const error = new Error("Decryption error"); + driveCrypto.decryptAndVerifySessionKey = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: 'Failed to decrypt content key: Decryption error' } }, + errors: [error], + }, { + contentKeyPacketSessionKey: undefined, + }); + verifyLogEventDecryptionError({ + field: 'nodeContentKey', + error, + }); + }); }); }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index bce4c245..659ed41e 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { resultOk, resultError, Result, InvalidNameError, Author, ProtonDriveAccount, ProtonDriveTelemetry, Logger } from "../../interface"; +import { resultOk, resultError, Result, InvalidNameError, Author, ProtonDriveAccount, ProtonDriveTelemetry, Logger, MetricsDecryptionErrorField, MetricVerificationErrorField } from "../../interface"; import { ValidationError } from '../../errors'; import { getErrorMessage, getVerificationMessage } from "../errors"; import { splitNodeUid } from "../uids"; @@ -44,9 +44,13 @@ export class NodesCryptoService { encryptedCrypto: undefined, } + const signatureEmailKeys = node.encryptedCrypto.signatureEmail + ? await this.account.getPublicKeys(node.encryptedCrypto.signatureEmail) + : []; + // Anonymous uploads (without signature email set) use parent key instead. const keyVerificationKeys = node.encryptedCrypto.signatureEmail - ? await this.account.getPublicKeys(node.encryptedCrypto.signatureEmail) + ? signatureEmailKeys : [parentKey]; let nameVerificationKeys; @@ -59,57 +63,71 @@ export class NodesCryptoService { : [parentKey]; } + const { name, author: nameAuthor } = await this.decryptName(node, parentKey, nameVerificationKeys); + let passphrase, key, passphraseSessionKey, keyAuthor; try { - const keyResult = await this.decryptKey(node, parentKey, keyVerificationKeys); + const keyResult = await this.decryptKey(node, parentKey, signatureEmailKeys); passphrase = keyResult.passphrase; key = keyResult.key; passphraseSessionKey = keyResult.passphraseSessionKey; keyAuthor = keyResult.author; } catch (error: unknown) { - this.reportDecryptionError(node, error); + this.reportDecryptionError(node, 'nodeKey', error); const errorMessage = c('Error').t`Failed to decrypt node key: ${getErrorMessage(error)}`; return { node: { ...commonNodeMetadata, - name: resultError({ - name: '', - error: errorMessage, - }), + name, keyAuthor: resultError({ claimedAuthor: node.encryptedCrypto.signatureEmail, error: errorMessage, }), - nameAuthor: resultError({ - claimedAuthor: nameSignatureEmail, - error: errorMessage, - }), - activeRevision: resultError(new Error(errorMessage)), - } + nameAuthor, + activeRevision: "file" in node.encryptedCrypto + ? resultError(new Error(errorMessage)) + : undefined, + folder: undefined, + errors: [error], + }, } } - const { name, author: nameAuthor } = await this.decryptName(node, parentKey, nameVerificationKeys); + const errors = []; let hashKey; let hashKeyAuthor; let folder; let folderExtendedAttributesAuthor; if ("folder" in node.encryptedCrypto) { - const hashKeyResult = await this.decryptHashKey(node, key, keyVerificationKeys); - hashKey = hashKeyResult.hashKey; - hashKeyAuthor = hashKeyResult.author; + try { + const hashKeyResult = await this.decryptHashKey(node, key, signatureEmailKeys); + hashKey = hashKeyResult.hashKey; + hashKeyAuthor = hashKeyResult.author; + } catch (error: unknown) { + this.reportDecryptionError(node, 'nodeHashKey', error); + errors.push(error); + } - const extendedAttributesResult = await this.decryptExtendedAttributes( - node.encryptedCrypto.folder.armoredExtendedAttributes, - key, - keyVerificationKeys, - node.encryptedCrypto.signatureEmail - ); - folder = { - extendedAttributes: extendedAttributesResult.extendedAttributes, - }; - folderExtendedAttributesAuthor = extendedAttributesResult.author; + try { + const folderExtendedAttributesVerificationKeys = node.encryptedCrypto.signatureEmail + ? signatureEmailKeys + : [key]; + const extendedAttributesResult = await this.decryptExtendedAttributes( + node, + node.encryptedCrypto.folder.armoredExtendedAttributes, + key, + folderExtendedAttributesVerificationKeys, + node.encryptedCrypto.signatureEmail + ); + folder = { + extendedAttributes: extendedAttributesResult.extendedAttributes, + }; + folderExtendedAttributesAuthor = extendedAttributesResult.author; + } catch (error: unknown) { + this.reportDecryptionError(node, 'nodeFolderExtendedAttributes', error); + errors.push(error); + } } let activeRevision: Result | undefined; @@ -117,26 +135,40 @@ export class NodesCryptoService { let contentKeyPacketAuthor; if ("file" in node.encryptedCrypto) { try { - activeRevision = resultOk(await this.decryptRevision(node.encryptedCrypto.activeRevision, key, parentKey)); + activeRevision = resultOk(await this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key)); } catch (error: unknown) { + this.reportDecryptionError(node, 'nodeActiveRevision', error); const errorMessage = c('Error').t`Failed to decrypt active revision: ${getErrorMessage(error)}`; activeRevision = resultError(new Error(errorMessage)); } - const keySessionKeyResult = await this.driveCrypto.decryptAndVerifySessionKey( - node.encryptedCrypto.file.base64ContentKeyPacket, - node.encryptedCrypto.file.armoredContentKeyPacketSignature, - key, - keyVerificationKeys - ); - contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; - contentKeyPacketAuthor = keySessionKeyResult.verified && await this.handleClaimedAuthor( - node, - 'SignatureEmail', - c('Property').t`content key`, - keySessionKeyResult.verified, - node.encryptedCrypto.signatureEmail, - ); + try { + const keySessionKeyResult = await this.driveCrypto.decryptAndVerifySessionKey( + node.encryptedCrypto.file.base64ContentKeyPacket, + node.encryptedCrypto.file.armoredContentKeyPacketSignature, + key, + // Content key packet is signed with the node key, but + // in the past some clients signed with the address key. + [key, ...keyVerificationKeys], + ); + + contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; + contentKeyPacketAuthor = keySessionKeyResult.verified && await this.handleClaimedAuthor( + node, + 'nodeContentKey', + c('Property').t`content key`, + keySessionKeyResult.verified, + node.encryptedCrypto.signatureEmail, + ); + } catch (error: unknown) { + this.reportDecryptionError(node, 'nodeContentKey', error); + const errorMessage = c('Error').t`Failed to decrypt content key: ${getErrorMessage(error)}`; + contentKeyPacketAuthor = resultError({ + claimedAuthor: node.encryptedCrypto.signatureEmail, + error: errorMessage, + }); + errors.push(error); + } } // If key signature verificaiton failed, prefer returning error from @@ -168,6 +200,7 @@ export class NodesCryptoService { nameAuthor, activeRevision, folder, + errors: errors.length ? errors : undefined, }, keys: { passphrase, @@ -194,7 +227,7 @@ export class NodesCryptoService { passphrase: key.passphrase, key: key.key, passphraseSessionKey: key.passphraseSessionKey, - author: await this.handleClaimedAuthor(node, 'SignatureEmail', c('Property').t`key`, key.verified, node.encryptedCrypto.signatureEmail), + author: await this.handleClaimedAuthor(node, 'nodeKey', c('Property').t`key`, key.verified, node.encryptedCrypto.signatureEmail), }; }; @@ -213,10 +246,10 @@ export class NodesCryptoService { return { name: resultOk(name), - author: await this.handleClaimedAuthor(node, 'NameSignatureEmail', c('Property').t`name`, verified, nameSignatureEmail), + author: await this.handleClaimedAuthor(node, 'nodeName', c('Property').t`name`, verified, nameSignatureEmail), } } catch (error: unknown) { - this.reportDecryptionError(node, error); + this.reportDecryptionError(node, 'nodeName', error); const errorMessage = getErrorMessage(error); return { name: resultError({ @@ -252,19 +285,25 @@ export class NodesCryptoService { return { hashKey, - author: await this.handleClaimedAuthor(node, 'NodeKey', c('Property').t`hash key`, verified, node.encryptedCrypto.signatureEmail), + author: await this.handleClaimedAuthor(node, 'nodeHashKey', c('Property').t`hash key`, verified, node.encryptedCrypto.signatureEmail), } } - async decryptRevision(encryptedRevision: EncryptedRevision, nodeKey: PrivateKey, parentKey: PrivateKey): Promise { + async decryptRevision(nodeUid: string, encryptedRevision: EncryptedRevision, nodeKey: PrivateKey): Promise { const verificationKeys = encryptedRevision.signatureEmail ? await this.account.getPublicKeys(encryptedRevision.signatureEmail) - : [parentKey]; + : [nodeKey]; const { extendedAttributes, author: contentAuthor, - } = await this.decryptExtendedAttributes(encryptedRevision.armoredExtendedAttributes, nodeKey, verificationKeys, encryptedRevision.signatureEmail); + } = await this.decryptExtendedAttributes( + {uid: nodeUid, createdDate: encryptedRevision.createdDate}, + encryptedRevision.armoredExtendedAttributes, + nodeKey, + verificationKeys, + encryptedRevision.signatureEmail, + ); return { uid: encryptedRevision.uid, @@ -275,13 +314,19 @@ export class NodesCryptoService { } } - private async decryptExtendedAttributes(encryptedExtendedAttributes: string | undefined, nodeKey: PrivateKey, addressKeys: PublicKey[], signatureEmail?: string): Promise<{ + private async decryptExtendedAttributes( + node: {uid: string, createdDate: Date}, + encryptedExtendedAttributes: string | undefined, + nodeKey: PrivateKey, + addressKeys: PublicKey[], + signatureEmail?: string, + ): Promise<{ extendedAttributes?: string, author: Author, }> { if (!encryptedExtendedAttributes) { return { - author: handleClaimedAuthor(c('Property').t`key`, VERIFICATION_STATUS.SIGNED_AND_VALID, signatureEmail), + author: resultOk(signatureEmail) as Author, } } @@ -293,7 +338,7 @@ export class NodesCryptoService { return { extendedAttributes, - author: handleClaimedAuthor(c('Property').t`attributes`, verified, signatureEmail), + author: await this.handleClaimedAuthor(node, "nodeExtendedAttributes", c('Property').t`attributes`, verified, signatureEmail), } } @@ -402,22 +447,22 @@ export class NodesCryptoService { } private async handleClaimedAuthor( - node: EncryptedNode, - verificationKey: 'NodeKey' | 'SignatureEmail' | 'NameSignatureEmail', + node: { uid: string, createdDate: Date }, + field: MetricVerificationErrorField, signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string ): Promise { const author = handleClaimedAuthor(signatureType, verified, claimedAuthor); if (!author.ok) { - await this.reportVerificationError(node, verificationKey, claimedAuthor); + await this.reportVerificationError(node, field, claimedAuthor); } return author; } private async reportVerificationError( - node: EncryptedNode, - verificationKey: 'NodeKey' | 'SignatureEmail' | 'NameSignatureEmail', + node: { uid: string, createdDate: Date }, + field: MetricVerificationErrorField, claimedAuthor?: string, ) { if (this.reportedVerificationErrors.has(node.uid)) { @@ -435,19 +480,19 @@ export class NodesCryptoService { this.logger.error('Failed to check if claimed author matches default share', error); } - this.logger.error(`Failed to verify node ${node.uid} using ${verificationKey} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); + this.logger.error(`Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); this.telemetry.logEvent({ eventName: 'verificationError', context: 'own_volume', // TODO: add context to the node - verificationKey, + field, addressMatchingDefaultShare, fromBefore2024, }); this.reportedVerificationErrors.add(node.uid); } - private reportDecryptionError(node: EncryptedNode, error?: unknown) { + private reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) { if (this.reportedDecryptionErrors.has(node.uid)) { return; } @@ -458,7 +503,7 @@ export class NodesCryptoService { this.telemetry.logEvent({ eventName: 'decryptionError', context: 'own_volume', // TODO: add context to the node - entity: 'node', + field, fromBefore2024, error, }); diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index 0756114b..49cd3f57 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -15,7 +15,7 @@ describe("updateCacheByEvent", () => { // @ts-expect-error No need to implement all methods for mocking cache = { - getNode: jest.fn(), + getNode: jest.fn(() => Promise.resolve({ uid: '123', name: { ok: true, value: 'name' } } as DecryptedNode)), setNode: jest.fn(), removeNodes: jest.fn(), resetFolderChildrenLoaded: jest.fn(), @@ -57,13 +57,11 @@ describe("updateCacheByEvent", () => { }; it("should update cache if present in cache", async () => { - cache.getNode = jest.fn(() => Promise.resolve({ uid: '123' } as DecryptedNode)); - await updateCacheByEvent(logger, event, cache); expect(cache.getNode).toHaveBeenCalledTimes(1); expect(cache.setNode).toHaveBeenCalledTimes(1); - expect(cache.setNode).toHaveBeenCalledWith({ uid: '123', isStale: true, parentUid: "parentUid" }); + expect(cache.setNode).toHaveBeenCalledWith(expect.objectContaining({ uid: '123', isStale: true, parentUid: "parentUid" })); }); it("should skip if missing in cache", async () => { @@ -76,7 +74,6 @@ describe("updateCacheByEvent", () => { }); it("should remove from cache if not possible to set", async () => { - cache.getNode = jest.fn(() => Promise.resolve({ uid: '123' } as DecryptedNode)); cache.setNode = jest.fn(() => Promise.reject(new Error('Cannot set node'))); await updateCacheByEvent(logger, event, cache); @@ -86,7 +83,6 @@ describe("updateCacheByEvent", () => { }); it("should throw if remove fails", async () => { - cache.getNode = jest.fn(() => Promise.resolve({ uid: '123' } as DecryptedNode)); cache.setNode = jest.fn(() => Promise.reject(new Error('Cannot set node'))); cache.removeNodes = jest.fn(() => Promise.reject(new Error('Cannot remove node'))); @@ -126,7 +122,7 @@ describe("notifyListenersByEvent", () => { }; // @ts-expect-error No need to implement all methods for mocking nodesAccess = { - getNode: jest.fn(() => Promise.resolve({ uid: 'nodeUid' } as DecryptedNode)), + getNode: jest.fn(() => Promise.resolve({ uid: 'nodeUid', name: { ok: true, value: 'name' } } as DecryptedNode)), }; }); @@ -145,7 +141,7 @@ describe("notifyListenersByEvent", () => { await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); }); @@ -163,7 +159,7 @@ describe("notifyListenersByEvent", () => { await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); }); @@ -181,7 +177,7 @@ describe("notifyListenersByEvent", () => { await notifyListenersByEvent(logger, event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); }); diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 0620f672..d6fef387 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -1,4 +1,5 @@ import { Logger, NodeEventCallback } from "../../interface"; +import { convertInternalNode } from "../../transformers"; import { DriveEventsService, DriveEvent, DriveEventType } from "../events"; import { DecryptedNode } from "./interface"; import { NodesCache } from "./cache"; @@ -173,7 +174,7 @@ export async function notifyListenersByEvent(logger: Logger, event: DriveEvent, logger.error(`Skipping node update event to listener`, error); return; } - subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node })); + subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node: convertInternalNode(node) })); } } diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 9650a876..6f3cd753 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -72,12 +72,13 @@ export interface DecryptedUnparsedNode extends BaseNode { folder?: { extendedAttributes?: string, }, + errors?: unknown[], } /** * Interface holding decrypted node metadata. */ -export interface DecryptedNode extends Omit, NodeEntity { +export interface DecryptedNode extends Omit, Omit { // Internal metadata isStale: boolean; diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index eff285df..fb8f86a0 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -23,12 +23,10 @@ export class NodesRevisons { async getRevision(nodeRevisionUid: string): Promise { const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid); - const node = await this.nodesAccess.getNode(nodeUid); - const { key: parentKey } = await this.nodesAccess.getParentKeys(node); const { key } = await this.nodesAccess.getNodeKeys(nodeUid); const encryptedRevision = await this.apiService.getRevision(nodeRevisionUid); - const revision = await this.cryptoService.decryptRevision(encryptedRevision, key, parentKey); + const revision = await this.cryptoService.decryptRevision(nodeUid, encryptedRevision, key); const extendedAttributes = parseFileExtendedAttributes(this.logger, revision.extendedAttributes); return { ...revision, @@ -37,13 +35,11 @@ export class NodesRevisons { } async* iterateRevisions(nodeUid: string, signal?: AbortSignal): AsyncGenerator { - const node = await this.nodesAccess.getNode(nodeUid); - const { key: parentKey } = await this.nodesAccess.getParentKeys(node); const { key } = await this.nodesAccess.getNodeKeys(nodeUid); const encryptedRevisions = await this.apiService.getRevisions(nodeUid, signal); for (const encryptedRevision of encryptedRevisions) { - const revision = await this.cryptoService.decryptRevision(encryptedRevision, key, parentKey); + const revision = await this.cryptoService.decryptRevision(nodeUid, encryptedRevision, key); const extendedAttributes = parseFileExtendedAttributes(this.logger, revision.extendedAttributes); yield { ...revision, diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 351e0184..2defb9e7 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -1,6 +1,6 @@ -import { NodeEntity } from "../../interface"; +import { DecryptedNode } from "../nodes"; export interface NodesService { - getNode(nodeUid: string): Promise; - iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; + getNode(nodeUid: string): Promise; + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts index 236beab7..11a2f6ec 100644 --- a/js/sdk/src/internal/shares/cryptoService.test.ts +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -98,7 +98,7 @@ describe("SharesCryptoService", () => { expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: 'verificationError', context: 'own_volume', - verificationKey: 'ShareAddress', + field: 'shareKey', addressMatchingDefaultShare: undefined, fromBefore2024: undefined, }); @@ -126,7 +126,7 @@ describe("SharesCryptoService", () => { expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: 'decryptionError', context: 'own_volume', - entity: 'share', + field: 'shareKey', fromBefore2024: undefined, error, }); diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 9473d044..9a276c9c 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -103,7 +103,7 @@ export class SharesCryptoService { this.telemetry.logEvent({ eventName: 'decryptionError', context: 'own_volume', // TODO: add context to the share - entity: 'share', + field: 'shareKey', fromBefore2024, error, }); @@ -122,7 +122,7 @@ export class SharesCryptoService { this.telemetry.logEvent({ eventName: 'verificationError', context: 'own_volume', // TODO: add context to the share - verificationKey: 'ShareAddress', + field: 'shareKey', addressMatchingDefaultShare, fromBefore2024, }); diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index a548fd9b..2b2e029b 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -20,7 +20,7 @@ describe("handleSharedByMeNodes", () => { }; // @ts-expect-error No need to implement all methods for mocking nodesService = { - getNode: jest.fn().mockResolvedValue({ uid: 'nodeUid' }), + getNode: jest.fn().mockResolvedValue({ uid: 'nodeUid', name: { ok: true, value: 'name' } }), }; }); @@ -166,7 +166,7 @@ describe("handleSharedByMeNodes", () => { if (added) { expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); - expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid', node: { uid: 'nodeUid'} }); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); } else { expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); } @@ -245,9 +245,9 @@ describe("handleSharedWithMeNodes", () => { it("should update cache and notify listener", async () => { cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(["nodeUid1", "nodeUid4"]); sharingAccess.iterateSharedNodesWithMe = jest.fn().mockImplementation(async function* () { - yield { uid: "nodeUid1" }; - yield { uid: "nodeUid2" }; - yield { uid: "nodeUid3" }; + yield { uid: "nodeUid1", name: { ok: true, value: "name1" } }; + yield { uid: "nodeUid2", name: { ok: true, value: "name2" } }; + yield { uid: "nodeUid3", name: { ok: true, value: "name3" } }; }); const listener = jest.fn(); const event: DriveEvent = { @@ -260,9 +260,9 @@ describe("handleSharedWithMeNodes", () => { expect(cache.getSharedWithMeNodeUids).toHaveBeenCalled(); expect(sharingAccess.iterateSharedNodesWithMe).toHaveBeenCalled(); expect(listener).toHaveBeenCalledTimes(4); - expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid1', node: { uid: 'nodeUid1'} }); - expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid2', node: { uid: 'nodeUid2'} }); - expect(listener).toHaveBeenCalledWith({ type: 'update', uid: 'nodeUid3', node: { uid: 'nodeUid3'} }); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid1' })); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid2' })); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid3' })); expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid4' }); }); }); diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index 7d6f1de1..ab99f16d 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -1,4 +1,5 @@ import { NodeEventCallback, Logger } from "../../interface"; +import { convertInternalNode } from "../../transformers"; import { DriveEventsService, DriveEvent, DriveEventType } from "../events"; import { SharingCache } from "./cache"; import { SharingType, NodesService } from "./interface"; @@ -85,7 +86,7 @@ export async function handleSharedByMeNodes(logger: Logger, event: DriveEvent, c logger.error(`Skipping shared by me node update event to listener`, error); return; } - subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node })); + subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node: convertInternalNode(node) })); } if ( @@ -146,7 +147,7 @@ export async function handleSharedWithMeNodes(event: DriveEvent, cache: SharingC const nodeUids = []; for await (const node of sharingAccess.iterateSharedNodesWithMe()) { nodeUids.push(node.uid); - subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node })); + subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node: convertInternalNode(node) })); } for (const nodeUid of cachedNodeUids) { if (!nodeUids.includes(nodeUid)) { diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 70785de1..acace401 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -1,6 +1,7 @@ -import { NodeEntity, NodeType, MemberRole, NonProtonInvitationState } from "../../interface"; +import { NodeType, MemberRole, NonProtonInvitationState } from "../../interface"; import { PrivateKey, SessionKey } from "../../crypto"; import { EncryptedShare } from "../shares"; +import { DecryptedNode } from "../nodes"; export enum SharingType { SharedByMe = 'sharedByMe', @@ -134,12 +135,12 @@ export interface SharesService { * Interface describing the dependencies to the nodes module. */ export interface NodesService { - getNode(nodeUid: string): Promise, + getNode(nodeUid: string): Promise, getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey }>, getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ key: PrivateKey, passphraseSessionKey: SessionKey, nameSessionKey: SessionKey, }>, - iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index fc38666a..661ab7e8 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -1,7 +1,8 @@ import { c } from 'ttag'; -import { NodeEntity, ProtonInvitationWithNode } from "../../interface"; +import { ProtonInvitationWithNode } from "../../interface"; import { ValidationError } from "../../errors"; +import { DecryptedNode } from "../nodes"; import { BatchLoading } from "../batchLoading"; import { SharingAPIService } from "./apiService"; import { SharingCache } from "./cache"; @@ -30,7 +31,7 @@ export class SharingAccess { this.nodesService = nodesService; } - async* iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { + async* iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { try { const nodeUids = await this.cache.getSharedByMeNodeUids(); yield* this.iterateSharedNodesFromCache(nodeUids, signal); @@ -41,7 +42,7 @@ export class SharingAccess { } } - async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { + async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { try { const nodeUids = await this.cache.getSharedWithMeNodeUids(); yield* this.iterateSharedNodesFromCache(nodeUids, signal); @@ -52,7 +53,7 @@ export class SharingAccess { } private async* iterateSharedNodesFromCache(nodeUids: string[], signal?: AbortSignal) { - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.nodesService.iterateNodes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.nodesService.iterateNodes(nodeUids, signal) }); for (const nodeUid of nodeUids) { yield* batchLoading.load(nodeUid); } @@ -63,9 +64,9 @@ export class SharingAccess { nodeUidsIterator: AsyncGenerator, setCache: (nodeUids: string[]) => Promise, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { const loadedNodeUids = []; - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.nodesService.iterateNodes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.nodesService.iterateNodes(nodeUids, signal) }); for await (const nodeUid of nodeUidsIterator) { loadedNodeUids.push(nodeUid); yield* batchLoading.load(nodeUid); diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index ca66a43c..92f0ee15 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,7 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { NodeEntity } from "../../interface"; export interface NodesService { - getNode(nodeUid: string): Promise, getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, passphraseSessionKey: SessionKey }>, } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index f53e96ba..0ba3af57 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,4 +1,4 @@ -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, NodeEntity, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger, NodeResult, Revision, ProtonInvitationWithNode, ShareResult, NonProtonInvitationOrUid, ProtonInvitationOrUid, FileDownloader } from './interface'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, MaybeNode, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger, NodeResult, Revision, ProtonInvitationWithNode, ShareResult, NonProtonInvitationOrUid, ProtonInvitationOrUid, FileDownloader } from './interface'; import { DriveCrypto } from './crypto'; import { DriveAPIService } from './internal/apiService'; import { initSharesModule } from './internal/shares'; @@ -71,7 +71,7 @@ export class ProtonDriveClient implements Partial { /** * @returns The root folder to My files section of the user. */ - async getMyFilesRootFolder(): Promise { + async getMyFilesRootFolder(): Promise { this.logger.info('Getting my files root folder'); return convertInternalNodePromise(this.nodes.access.getMyFilesRootFolder()); } @@ -85,7 +85,7 @@ export class ProtonDriveClient implements Partial { * @param signal - Signal to abort the operation. * @returns An async generator of the children of the given parent node. */ - async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); yield* convertInternalNodeIterator(this.nodes.access.iterateChildren(getUid(parentNodeUid), signal)); } @@ -101,7 +101,7 @@ export class ProtonDriveClient implements Partial { * @param signal - Signal to abort the operation. * @returns An async generator of the trashed nodes. */ - async* iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + async* iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } @@ -115,7 +115,7 @@ export class ProtonDriveClient implements Partial { * @param signal - Signal to abort the operation. * @returns An async generator of the nodes. */ - async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating ${nodeUids.length} nodes`); yield* convertInternalNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } @@ -128,7 +128,7 @@ export class ProtonDriveClient implements Partial { * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. * @throws {@link Error} If another node with the same name already exists. */ - async renameNode(nodeUid: NodeOrUid, newName: string): Promise { + async renameNode(nodeUid: NodeOrUid, newName: string): Promise { this.logger.info(`Renaming node ${nodeUid} to ${newName}`); return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); } @@ -229,7 +229,7 @@ export class ProtonDriveClient implements Partial { * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. * @throws {@link Error} If another node with the same name already exists. */ - async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise { + async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise { this.logger.info(`Creating folder ${name} in ${getUid(parentNodeUid)}`); return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime)); } @@ -280,7 +280,7 @@ export class ProtonDriveClient implements Partial { * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. */ - async* iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { + async* iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes by me'); yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); } @@ -293,7 +293,7 @@ export class ProtonDriveClient implements Partial { * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. */ - async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { + async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes with me'); yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodesWithMe(signal)); } diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 6099195f..1e06e933 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,4 +1,4 @@ -import { NodeEntity as PublicNode } from './interface'; +import { MaybeNode as PublicMaybeNode, NodeEntity as PublicNodeEntity, DegradedNode as PublicDegradedNode, Result, resultOk, resultError } from './interface'; import { DecryptedNode as InternalNode } from './internal/nodes'; type InternalPartialNode = Pick< @@ -15,36 +15,46 @@ type InternalPartialNode = Pick< 'createdDate' | 'trashedDate' | 'activeRevision' | - 'folder' + 'folder' | + 'errors' >; -export function getUid(nodeUid: string | { uid: string }): string { +type NodeUid = string | { uid: string } | Result<{ uid: string }, { uid: string }>; + +export function getUid(nodeUid: NodeUid): string { if (typeof nodeUid === "string") { return nodeUid; } - return nodeUid.uid; + // Directly passed NodeEntity or DegradedNode that has UID directly. + if ('uid' in nodeUid) { + return nodeUid.uid; + } + // MaybeNode that can be either NodeEntity or DegradedNode. + if (nodeUid.ok) { + return nodeUid.value.uid; + } + return nodeUid.error.uid; } -export function getUids(nodeUids: (string | { uid: string })[]): string[] { +export function getUids(nodeUids: NodeUid[]): string[] { return nodeUids.map(getUid); } -export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { +export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { for await (const node of nodeIterator) { yield convertInternalNode(node); } } -export async function convertInternalNodePromise(nodePromise: Promise): Promise { +export async function convertInternalNodePromise(nodePromise: Promise): Promise { const node = await nodePromise; return convertInternalNode(node); } -export function convertInternalNode(node: InternalPartialNode): PublicNode { - return { +export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode { + const baseNodeMetadata = { uid: node.uid, parentUid: node.parentUid, - name: node.name, keyAuthor: node.keyAuthor, nameAuthor: node.nameAuthor, directMemberRole: node.directMemberRole, @@ -53,7 +63,24 @@ export function convertInternalNode(node: InternalPartialNode): PublicNode { isShared: node.isShared, createdDate: node.createdDate, trashedDate: node.trashedDate, - activeRevision: node.activeRevision, folder: node.folder, }; + + const name = node.name; + const activeRevision = node.activeRevision; + + if (node.errors?.length || !name.ok || (activeRevision && !activeRevision.ok)) { + return resultError({ + ...baseNodeMetadata, + name, + activeRevision, + errors: node.errors, + } as PublicDegradedNode); + } + + return resultOk({ + ...baseNodeMetadata, + name: name.value, + activeRevision: activeRevision?.value, + } as PublicNodeEntity); } From 6279dbf998bf9cce6fd78613775fcbe7a4810a8e Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 8 Apr 2025 12:25:40 +0000 Subject: [PATCH 061/791] Implement MaybeMissingNode --- js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/nodes.ts | 15 ++++++ js/sdk/src/internal/nodes/nodesAccess.test.ts | 53 +++++++++++++++++++ js/sdk/src/internal/nodes/nodesAccess.ts | 26 +++++++-- js/sdk/src/internal/nodes/nodesManagement.ts | 6 ++- js/sdk/src/internal/photos/interface.ts | 3 +- js/sdk/src/internal/sharing/interface.ts | 4 +- js/sdk/src/internal/sharing/sharingAccess.ts | 16 ++++-- js/sdk/src/protonDriveClient.ts | 8 +-- js/sdk/src/transformers.ts | 12 ++++- 10 files changed, 128 insertions(+), 17 deletions(-) diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 82b3b11b..1d94a7a9 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -16,7 +16,7 @@ export type { Device, DeviceOrUid } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; export type { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; -export type { MaybeNode, NodeEntity, DegradedNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; +export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 48251198..fb71484d 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -10,6 +10,21 @@ import { Author } from './author'; */ export type MaybeNode = Result; +/** + * Node representing a file or folder in the system, or missing node. + * + * In most cases, SDK returns `MaybeNode`, but in some specific cases, when + * client is requesting specific nodes, SDK must return `MissingNode` type + * to indicate the case when the node is not available. That can be when + * the node does not exist, or when the node is not available for the user + * (e.g. unshared with the user). + */ +export type MaybeMissingNode = Result; + +export type MissingNode = { + missingUid: string, +}; + /** * Node representing a file or folder in the system. * diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 0058d9ba..f38cf443 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -29,6 +29,7 @@ describe('nodesAccess', () => { iterateChildren: jest.fn().mockImplementation(async function* () {}), isFolderChildrenLoaded: jest.fn().mockResolvedValue(false), setFolderChildrenLoaded: jest.fn(), + removeNodes: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking cryptoCache = { @@ -225,6 +226,28 @@ describe('nodesAccess', () => { expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); }); + + it('should remove from cache if missing on API', async () => { + apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { + yield 'node1'; + yield 'node2'; + yield 'node3'; + }); + cache.getNode = jest.fn().mockImplementation((uid: string) => { + if (uid === parentNode.uid) { + return parentNode; + } + throw new Error('Entity not found'); + }); + apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + // Skip first node - make it missing. + uids.slice(1).map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)) + )); + + const result = await Array.fromAsync(access.iterateChildren('parentUid')); + expect(result).toMatchObject([node2, node3]); + expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); + }); }); describe('iterateTrashedNodes', () => { @@ -269,6 +292,20 @@ describe('nodesAccess', () => { expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); }); + + it('should remove from cache if missing on API', async () => { + cache.getNode = jest.fn().mockImplementation((uid: string) => { + throw new Error('Entity not found'); + }); + apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + // Skip first node - make it missing. + uids.slice(1).map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)) + )); + + const result = await Array.fromAsync(access.iterateTrashedNodes()); + expect(result).toMatchObject([node2, node3, node4]); + expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); + }); }); describe('iterateNodes', () => { @@ -305,6 +342,22 @@ describe('nodesAccess', () => { expect(result).toMatchObject([node1, node4, node2, node3]); expect(apiService.getNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); }); + + it('should remove from cache if missing on API and return back to caller', async () => { + cache.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { ok: false, uid: 'node1' }; + yield { ok: false, uid: 'node2' }; + yield { ok: false, uid: 'node3' }; + }); + apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + // Skip first node - make it missing. + uids.slice(1).map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)) + )); + + const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3'])); + expect(result).toMatchObject([node2, node3, {missingUid: 'node1'}]); + expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); + }); }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 45d925c1..2157b8ba 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey } from "../../crypto"; -import { Logger, NodeType, resultError, resultOk } from "../../interface"; +import { Logger, MissingNode, NodeType, resultError, resultOk } from "../../interface"; import { SDKError } from "../../errors"; import { BatchLoading } from "../batchLoading"; import { makeNodeUid } from "../uids"; @@ -119,8 +119,8 @@ export class NodesAccess { yield* batchLoading.loadRest(); } - async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, signal) }); for await (const result of this.cache.iterateNodes(nodeUids)) { if (result.ok && !result.node.isStale) { yield result.node; @@ -137,11 +137,31 @@ export class NodesAccess { } private async* loadNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + for await (const result of this.loadNodesWithMissingReport(nodeUids, signal)) { + if ('missingUid' in result) { + continue; + } + yield result; + } + } + + private async* loadNodesWithMissingReport(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const encryptedNodes = await this.apiService.getNodes(nodeUids, signal); for (const encryptedNode of encryptedNodes) { const { node } = await this.decryptNode(encryptedNode); yield node; } + + const returnedNodeUids = encryptedNodes.map(({ uid }) => uid); + const missingNodeUids = nodeUids.filter((nodeUid) => !returnedNodeUids.includes(nodeUid)); + + if (missingNodeUids.length) { + this.logger.debug(`Removing ${missingNodeUids.length} nodes from cache not existing on the API anymore`); + await this.cache.removeNodes(missingNodeUids); + for (const missingNodeUid of missingNodeUids) { + yield { missingUid: missingNodeUid }; + } + } } private async decryptNode(encryptedNode: EncryptedNode): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 9c2cea00..e3db9dd0 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -154,7 +154,8 @@ export class NodesManagement { } async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodes = await Array.fromAsync(this.nodesAccess.iterateNodes(nodeUids, signal)); + const nodesOrMissing = await Array.fromAsync(this.nodesAccess.iterateNodes(nodeUids, signal)); + const nodes = nodesOrMissing.filter(node => !('missingUid' in node)) as DecryptedNode[]; for await (const result of this.apiService.trashNodes(nodeUids, signal)) { if (result.ok) { @@ -172,7 +173,8 @@ export class NodesManagement { } async* restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodes = await Array.fromAsync(this.nodesAccess.iterateNodes(nodeUids, signal)); + const nodesOrMissing = await Array.fromAsync(this.nodesAccess.iterateNodes(nodeUids, signal)); + const nodes = nodesOrMissing.filter(node => !('missingUid' in node)) as DecryptedNode[]; for await (const result of this.apiService.restoreNodes(nodeUids, signal)) { if (result.ok) { diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 2defb9e7..363d03c4 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -1,6 +1,7 @@ +import { MissingNode } from "../../interface"; import { DecryptedNode } from "../nodes"; export interface NodesService { getNode(nodeUid: string): Promise; - iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index acace401..94c5986a 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -1,4 +1,4 @@ -import { NodeType, MemberRole, NonProtonInvitationState } from "../../interface"; +import { NodeType, MemberRole, NonProtonInvitationState, MissingNode } from "../../interface"; import { PrivateKey, SessionKey } from "../../crypto"; import { EncryptedShare } from "../shares"; import { DecryptedNode } from "../nodes"; @@ -142,5 +142,5 @@ export interface NodesService { passphraseSessionKey: SessionKey, nameSessionKey: SessionKey, }>, - iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 661ab7e8..b0e59237 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -52,8 +52,8 @@ export class SharingAccess { } } - private async* iterateSharedNodesFromCache(nodeUids: string[], signal?: AbortSignal) { - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.nodesService.iterateNodes(nodeUids, signal) }); + private async* iterateSharedNodesFromCache(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal) }); for (const nodeUid of nodeUids) { yield* batchLoading.load(nodeUid); } @@ -66,7 +66,7 @@ export class SharingAccess { signal?: AbortSignal, ): AsyncGenerator { const loadedNodeUids = []; - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.nodesService.iterateNodes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal) }); for await (const nodeUid of nodeUidsIterator) { loadedNodeUids.push(nodeUid); yield* batchLoading.load(nodeUid); @@ -77,6 +77,16 @@ export class SharingAccess { await setCache(loadedNodeUids); } + private async* iterateNodesAndIgnoreMissingOnes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const nodeGenerator = this.nodesService.iterateNodes(nodeUids, signal); + for await (const node of nodeGenerator) { + if ('missingUid' in node) { + continue; + } + yield node; + } + } + async removeSharedNodeWithMe(nodeUid: string): Promise { const node = await this.nodesService.getNode(nodeUid); if (!node.shareId) { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 0ba3af57..153d8c1e 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,4 +1,4 @@ -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, MaybeNode, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger, NodeResult, Revision, ProtonInvitationWithNode, ShareResult, NonProtonInvitationOrUid, ProtonInvitationOrUid, FileDownloader } from './interface'; +import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, MaybeNode, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger, NodeResult, Revision, ProtonInvitationWithNode, ShareResult, NonProtonInvitationOrUid, ProtonInvitationOrUid, FileDownloader, MaybeMissingNode } from './interface'; import { DriveCrypto } from './crypto'; import { DriveAPIService } from './internal/apiService'; import { initSharesModule } from './internal/shares'; @@ -8,7 +8,7 @@ import { initDownloadModule } from './internal/download'; import { initUploadModule } from './internal/upload'; import { DriveEventsService } from './internal/events'; import { getConfig } from './config'; -import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; +import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator, convertInternalMissingNodeIterator } from './transformers'; import { Telemetry } from './telemetry'; /** @@ -115,9 +115,9 @@ export class ProtonDriveClient implements Partial { * @param signal - Signal to abort the operation. * @returns An async generator of the nodes. */ - async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating ${nodeUids.length} nodes`); - yield* convertInternalNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); + yield* convertInternalMissingNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } /** diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 1e06e933..b06ce300 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,4 +1,4 @@ -import { MaybeNode as PublicMaybeNode, NodeEntity as PublicNodeEntity, DegradedNode as PublicDegradedNode, Result, resultOk, resultError } from './interface'; +import { MaybeNode as PublicMaybeNode, MaybeMissingNode as PublicMaybeMissingNode, NodeEntity as PublicNodeEntity, DegradedNode as PublicDegradedNode, Result, resultOk, resultError, MissingNode } from './interface'; import { DecryptedNode as InternalNode } from './internal/nodes'; type InternalPartialNode = Pick< @@ -46,6 +46,16 @@ export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator< } } +export async function *convertInternalMissingNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { + for await (const node of nodeIterator) { + if ('missingUid' in node) { + yield resultError(node); + } else { + yield convertInternalNode(node); + } + } +} + export async function convertInternalNodePromise(nodePromise: Promise): Promise { const node = await nodePromise; return convertInternalNode(node); From aea955a01c8d6a74f6643b4ca0e538c87b047b09 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Apr 2025 08:54:43 +0000 Subject: [PATCH 062/791] Implement upload module --- js/sdk/src/crypto/driveCrypto.test.ts | 24 + js/sdk/src/crypto/driveCrypto.ts | 117 +++- js/sdk/src/{internal/nodes => crypto}/hmac.ts | 0 js/sdk/src/crypto/interface.ts | 7 + js/sdk/src/crypto/openPGPCrypto.ts | 23 +- js/sdk/src/errors.ts | 26 + js/sdk/src/interface/index.ts | 3 +- js/sdk/src/interface/telemetry.ts | 7 +- js/sdk/src/interface/upload.ts | 9 +- js/sdk/src/internal/apiService/apiService.ts | 25 +- js/sdk/src/internal/apiService/errors.ts | 30 +- js/sdk/src/internal/apiService/wait.ts | 7 - js/sdk/src/internal/download/controller.ts | 2 +- js/sdk/src/internal/download/cryptoService.ts | 17 +- js/sdk/src/internal/download/queue.ts | 2 +- js/sdk/src/internal/nodes/cryptoService.ts | 29 +- .../src/internal/nodes/extendedAttributes.ts | 12 + js/sdk/src/internal/nodes/index.ts | 1 + js/sdk/src/internal/upload/apiService.ts | 100 +++- js/sdk/src/internal/upload/blockVerifier.ts | 40 ++ .../internal/upload/chunkStreamReader.test.ts | 89 +++ .../src/internal/upload/chunkStreamReader.ts | 43 ++ js/sdk/src/internal/upload/controller.ts | 25 + js/sdk/src/internal/upload/cryptoService.ts | 154 ++++- .../src/internal/upload/fileUploader.test.ts | 468 +++++++++++++++ js/sdk/src/internal/upload/fileUploader.ts | 533 +++++++++++++++++- js/sdk/src/internal/upload/index.ts | 69 ++- js/sdk/src/internal/upload/interface.ts | 91 ++- js/sdk/src/internal/upload/manager.test.ts | 269 +++++++++ js/sdk/src/internal/upload/manager.ts | 210 +++++++ js/sdk/src/internal/upload/queue.ts | 29 +- js/sdk/src/internal/upload/telemetry.test.ts | 120 ++++ js/sdk/src/internal/upload/telemetry.ts | 98 ++++ js/sdk/src/internal/utils.ts | 9 + .../src/internal/{download => }/wait.test.ts | 0 js/sdk/src/internal/{download => }/wait.ts | 10 +- js/sdk/src/protonDriveClient.ts | 53 +- 37 files changed, 2592 insertions(+), 159 deletions(-) create mode 100644 js/sdk/src/crypto/driveCrypto.test.ts rename js/sdk/src/{internal/nodes => crypto}/hmac.ts (100%) delete mode 100644 js/sdk/src/internal/apiService/wait.ts create mode 100644 js/sdk/src/internal/upload/blockVerifier.ts create mode 100644 js/sdk/src/internal/upload/chunkStreamReader.test.ts create mode 100644 js/sdk/src/internal/upload/chunkStreamReader.ts create mode 100644 js/sdk/src/internal/upload/controller.ts create mode 100644 js/sdk/src/internal/upload/fileUploader.test.ts create mode 100644 js/sdk/src/internal/upload/manager.test.ts create mode 100644 js/sdk/src/internal/upload/manager.ts create mode 100644 js/sdk/src/internal/upload/telemetry.test.ts create mode 100644 js/sdk/src/internal/upload/telemetry.ts create mode 100644 js/sdk/src/internal/utils.ts rename js/sdk/src/internal/{download => }/wait.test.ts (100%) rename js/sdk/src/internal/{download => }/wait.ts (64%) diff --git a/js/sdk/src/crypto/driveCrypto.test.ts b/js/sdk/src/crypto/driveCrypto.test.ts new file mode 100644 index 00000000..466d1d9b --- /dev/null +++ b/js/sdk/src/crypto/driveCrypto.test.ts @@ -0,0 +1,24 @@ +import { arrayToHexString } from "./driveCrypto"; + +describe("arrayToHexString", () => { + it("should convert a Uint8Array to a hex string", () => { + const input = new Uint8Array([0, 255, 16, 32]); + const expectedOutput = "00ff1020"; + const result = arrayToHexString(input); + expect(result).toBe(expectedOutput); + }); + + it("should handle an empty Uint8Array", () => { + const input = new Uint8Array([]); + const expectedOutput = ""; + const result = arrayToHexString(input); + expect(result).toBe(expectedOutput); + }); + + it("should handle a Uint8Array with one element", () => { + const input = new Uint8Array([1]); + const expectedOutput = "01"; + const result = arrayToHexString(input); + expect(result).toBe(expectedOutput); + }); +}); diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 7acb257c..be2093a0 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,5 +1,7 @@ import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; +// FIXME: Switch to CryptoProxy module once available. +import { importHmacKey, computeHmacSignature } from "./hmac"; enum SIGNING_CONTEXTS { SHARING_INVITER = 'drive.share-member.inviter', @@ -77,6 +79,38 @@ export class DriveCrypto { }; }; + /** + * It generates content key from node key for encrypting file blocks. + * + * @param encryptionKey - Its own node key. + * @returns Object with serialised key packet and decrypted session key. + */ + async generateContentKey( + encryptionKey: PrivateKey, + ): Promise<{ + encrypted: { + base64ContentKeyPacket: string, + armoredContentKeyPacketSignature: string, + }, + decrypted: { + contentKeyPacketSessionKey: SessionKey, + }, + }> { + const contentKeyPacketSessionKey = await this.openPGPCrypto.generateSessionKey([encryptionKey]); + const { signature: armoredContentKeyPacketSignature } = await this.openPGPCrypto.signArmored(contentKeyPacketSessionKey.data, [encryptionKey]); + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(contentKeyPacketSessionKey, [encryptionKey]); + + return { + encrypted: { + base64ContentKeyPacket: uint8ArrayToBase64String(keyPacket), + armoredContentKeyPacketSignature, + }, + decrypted: { + contentKeyPacketSessionKey, + } + }; + } + /** * It encrypts passphrase with provided session and encryption keys. * This should be used only for re-encrypting the passphrase with @@ -163,7 +197,7 @@ export class DriveCrypto { ): Promise<{ base64KeyPacket: string, }> { - const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(sessionKey, encryptionKey); + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(sessionKey, [encryptionKey]); return { base64KeyPacket: uint8ArrayToBase64String(keyPacket), } @@ -286,6 +320,13 @@ export class DriveCrypto { } } + async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { + const key = await importHmacKey(parentHashKey); + + const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); + return arrayToHexString(signature); + } + /** * It converts node name into bytes array and encrypts and signs * with provided keys. @@ -408,7 +449,7 @@ export class DriveCrypto { base64KeyPacket: string, base64KeyPacketSignature: string, }> { - const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(shareSessionKey, encryptionKey); + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(shareSessionKey, [encryptionKey]); const { signature: keyPacketSignature } = await this.openPGPCrypto.sign( keyPacket, signingKey, @@ -461,6 +502,49 @@ export class DriveCrypto { } } + async encryptThumbnailBlock( + thumbnailData: Uint8Array, + sessionKey: SessionKey, + signingKey: PrivateKey, + ): Promise<{ + encryptedData: Uint8Array, + }> { + const { encryptedData } = await this.openPGPCrypto.encryptAndSign( + thumbnailData, + sessionKey, + [], // Thumbnails use the session key so we do not send encryption key. + signingKey, + ); + + return { + encryptedData, + }; + } + + async encryptBlock( + blockData: Uint8Array, + encryptionKey: PrivateKey, + sessionKey: SessionKey, + signingKey: PrivateKey, + ): Promise<{ + encryptedData: Uint8Array, + armoredSignature: string, + }> { + const { encryptedData, signature } = await this.openPGPCrypto.encryptAndSignDetached( + blockData, + sessionKey, + [], // Blocks use the session key so we do not send encryption key. + signingKey, + ); + + const { armoredSignature } = await this.encryptSignature(signature, encryptionKey, sessionKey); + + return { + encryptedData, + armoredSignature, + }; + } + async decryptBlock( encryptedBlock: Uint8Array, armoredSignature: string | undefined, @@ -489,6 +573,21 @@ export class DriveCrypto { }; } + async signManifest( + manifest: Uint8Array, + signingKey: PrivateKey, + ): Promise<{ + armoredManifestSignature: string, + }> { + const { signature: armoredManifestSignature } = await this.openPGPCrypto.signArmored( + manifest, + signingKey, + ); + return { + armoredManifestSignature, + } + } + async verifyManifest( manifest: Uint8Array, armoredSignature: string, @@ -517,3 +616,17 @@ export class DriveCrypto { return new TextDecoder().decode(password); } } + +/** + * Convert an array of 8-bit integers to a hex string + * @param bytes - Array of 8-bit integers to convert + * @returns Hexadecimal representation of the array + */ +export const arrayToHexString = (bytes: Uint8Array) => { + const hexAlphabet = '0123456789abcdef'; + let s = ''; + bytes.forEach((v) => { + s += hexAlphabet[v >> 4] + hexAlphabet[v & 15]; + }); + return s; +}; diff --git a/js/sdk/src/internal/nodes/hmac.ts b/js/sdk/src/crypto/hmac.ts similarity index 100% rename from js/sdk/src/internal/nodes/hmac.ts rename to js/sdk/src/crypto/hmac.ts diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index f94e45d2..11b7078f 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -100,6 +100,13 @@ export interface OpenPGPCrypto { signature: Uint8Array, }>, + signArmored: ( + data: Uint8Array, + signingKey: PrivateKey, + ) => Promise<{ + signature: string, + }>, + verify: ( data: Uint8Array, armoredSignature: string, diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index a2740495..00b5953b 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -37,12 +37,12 @@ export interface OpenPGPCryptoProxy { verified: VERIFICATION_STATUS }>, signMessage: (options: { - format: 'binary', + format: 'binary' | 'armored', binaryData: Uint8Array, signingKeys: PrivateKey[], detached: boolean, - context: { critical: boolean, value: string }, - }) => Promise, + context?: { critical: boolean, value: string }, + }) => Promise, verifyMessage: (options: { binaryData: Uint8Array, armoredSignature: string, @@ -203,7 +203,22 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { context: { critical: true, value: signatureContext }, }); return { - signature + signature: signature as Uint8Array, + }; + } + + async signArmored( + data: Uint8Array, + signingKeys: PrivateKey[], + ) { + const signature = await this.cryptoProxy.signMessage({ + binaryData: data, + signingKeys, + detached: true, + format: 'armored', + }); + return { + signature: signature as string, }; } diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index ff746707..a3388d41 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -56,6 +56,25 @@ export class ValidationError extends SDKError { } } +/** + * Error thrown when the node already exists. + * + * This error is thrown when the node with the same name already exists in the + * parent folder. The client should ask the user to replace the existing node + * or choose another name. The available name is provided in the `availableName` + * property (that will contain original name with the index that can be used). + */ +export class NodeAlreadyExistsValidationError extends ValidationError { + name = 'NodeAlreadyExistsValidationError'; + + public readonly availableName: string; + + constructor(message: string, code: number, availableName: string) { + super(message, code); + this.availableName = availableName; + } +} + /** * Error thrown when the API call fails. * @@ -139,4 +158,11 @@ export class DecryptionError extends SDKError { */ export class IntegrityError extends SDKError { name = 'IntegrityError'; + + public readonly debug?: object; + + constructor(message: string, debug?: object) { + super(message); + this.debug = debug; + } } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 1d94a7a9..deb19f24 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -21,7 +21,8 @@ export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent, MetricContext } from './telemetry'; -export type { Fileuploader, UploadController, Thumbnail, ThumbnailType, UploadMetadata } from './upload'; +export type { Fileuploader, UploadController, Thumbnail, UploadMetadata } from './upload'; +export { ThumbnailType } from './upload'; export type ProtonDriveTelemetry = Telemetry; export type ProtonDriveEntitiesCache = ProtonDriveCache; diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 678efcb2..3ca972ac 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -27,16 +27,13 @@ export interface MetricAPIRetrySucceededEvent { export interface MetricUploadEvent { eventName: 'upload', context: MetricContext, - retry: boolean, uploadedSize: number, - fileSize: number, + expectedSize: number, error?: MetricsUploadErrorType, }; export type MetricsUploadErrorType = - 'free_space_exceeded' | - 'too_many_children' | - 'network_error' | 'server_error' | + 'network_error' | 'integrity_error' | 'rate_limited' | '4xx' | diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index df9d10e1..30dadd87 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -18,12 +18,13 @@ export interface Upload { export type UploadMetadata = { mimeType: string, expectedSize: number, + modificationTime?: Date, additionalMetadata?: object, }; export interface Fileuploader { - writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController, - writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController, + writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): UploadController, + writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): UploadController, } export interface UploadController { @@ -38,6 +39,6 @@ export type Thumbnail = { } export enum ThumbnailType { - THUMBNAIL_TYPE_1 = 1, - THUMBNAIL_TYPE_2 = 2, + Type1 = 1, + Type2 = 2, } diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 7a372a25..2369df16 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -1,10 +1,10 @@ import { c } from 'ttag'; import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from "../../interface"; -import { AbortError, ServerError, RateLimitedError } from '../../errors'; +import { AbortError, ServerError, RateLimitedError, SDKError } from '../../errors'; +import { waitSeconds } from '../wait'; import { HTTPErrorCode, isCodeOk } from './errorCodes'; import { apiErrorFactory } from './errors'; -import { waitSeconds } from './wait'; /** * How many subsequent 429 errors are allowed before we stop further requests. @@ -129,7 +129,7 @@ export class DriveAPIService { } return result as ResponsePayload; } catch (error: unknown) { - if (error instanceof ServerError) { + if (error instanceof SDKError) { throw error; } throw apiErrorFactory({ response }); @@ -137,26 +137,39 @@ export class DriveAPIService { } async getBlockStream(baseUrl: string, token: string, signal?: AbortSignal): Promise> { - const response = await this.makeStorageRequest('GET', baseUrl, token, signal); + const response = await this.makeStorageRequest('GET', baseUrl, token, undefined, signal); if (!response.body) { throw new Error(c('Error').t`File download failed due to empty response`); } return response.body; } - private async makeStorageRequest(method: 'GET' | 'POST', url: string, token: string, signal?: AbortSignal): Promise { + async postBlockStream(baseUrl: string, token: string, data: BodyInit, signal?: AbortSignal): Promise { + await this.makeStorageRequest('POST', baseUrl, token, data, signal); + } + + private async makeStorageRequest(method: 'GET' | 'POST', url: string, token: string, body?: BodyInit, signal?: AbortSignal): Promise { const request = new Request(`${url}`, { method, credentials: 'omit', headers: new Headers({ "pm-storage-token": token, }), + body, }); const response = await this.fetch(request, signal); if (response.status >= 400) { - throw apiErrorFactory({ response }); + try { + const result = await response.json(); + throw apiErrorFactory({ response, result }); + } catch (error: unknown) { + if (error instanceof SDKError) { + throw error; + } + throw apiErrorFactory({ response }); + } } return response; } diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 5b51cd95..aab3cbff 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -11,9 +11,26 @@ export function apiErrorFactory({ response, result }: { response: Response, resu return new APIHTTPError(response.statusText, response.status); } - // @ts-expect-error: Result from API can be any JSON that might not have - // error or code set which next lines should handle. - const [code, message] = [result.Code || 0, result.Error || c('Error').t`Unknown error`]; + const typedResult = result as { + Code?: number; + Error?: string; + exception?: string; + message?: string; + file?: string; + line?: number; + trace?: object; + }; + + const [code, message] = [typedResult.Code || 0, typedResult.Error || c('Error').t`Unknown error`]; + + const debug = typedResult.exception ? { + exception: typedResult.exception, + message: typedResult.message, + file: typedResult.file, + line: typedResult.line, + trace: typedResult.trace, + } : undefined; + switch (code) { case ErrorCode.NOT_EXISTS: return new NotFoundAPIError(message, code); @@ -40,7 +57,7 @@ export function apiErrorFactory({ response, result }: { response: Response, resu case ErrorCode.INSUFFICIENT_BOOKMARKS_QUOTA: return new ValidationError(message, code); default: - return new APICodeError(message, code); + return new APICodeError(message, code, debug); } } @@ -60,9 +77,12 @@ export class APICodeError extends ServerError { public readonly code: number; - constructor(message: string, code: number) { + public readonly debug?: object; + + constructor(message: string, code: number, debug?: object) { super(message); this.code = code; + this.debug = debug; } } diff --git a/js/sdk/src/internal/apiService/wait.ts b/js/sdk/src/internal/apiService/wait.ts deleted file mode 100644 index bac3920a..00000000 --- a/js/sdk/src/internal/apiService/wait.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function waitSeconds(seconds: number){ - return wait(seconds * 1000); -} - -export async function wait(miliseconds: number){ - return new Promise((resolve) => setTimeout(resolve, miliseconds)); -} diff --git a/js/sdk/src/internal/download/controller.ts b/js/sdk/src/internal/download/controller.ts index b3e01e37..bd6af3e6 100644 --- a/js/sdk/src/internal/download/controller.ts +++ b/js/sdk/src/internal/download/controller.ts @@ -1,4 +1,4 @@ -import { waitForCondition } from './wait'; +import { waitForCondition } from '../wait'; export class DownloadController { private paused = false; diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index 487ed6d8..f9e34a76 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -4,6 +4,7 @@ import { DriveCrypto, PrivateKey, PublicKey, SessionKey, uint8ArrayToBase64Strin import { ProtonDriveAccount, Revision } from "../../interface"; import { DecryptionError, IntegrityError } from "../../errors"; import { getErrorMessage } from "../errors"; +import { mergeUint8Arrays } from "../utils"; import { RevisionKeys } from "./interface"; export class DownloadCryptoService { @@ -49,7 +50,10 @@ export class DownloadCryptoService { const expectedHash = uint8ArrayToBase64String(new Uint8Array(digest)); if (expectedHash !== base64sha256Hash) { - throw new IntegrityError(c('Error').t`Data integrity check of one part failed`); + throw new IntegrityError(c('Error').t`Data integrity check of one part failed`, { + expectedHash, + actualHash: base64sha256Hash, + }); } } @@ -72,14 +76,3 @@ export class DownloadCryptoService { return signatureEmail ? await this.account.getPublicKeys(signatureEmail) : undefined; } } - -function mergeUint8Arrays(arrays: Uint8Array[]) { - const length = arrays.reduce((sum, arr) => sum + arr.length, 0); - const chunksAll = new Uint8Array(length); - arrays.reduce((position, arr) => { - chunksAll.set(arr, position); - return position + arr.length; - }, 0); - return chunksAll; -} - diff --git a/js/sdk/src/internal/download/queue.ts b/js/sdk/src/internal/download/queue.ts index 8cc82142..8190efcb 100644 --- a/js/sdk/src/internal/download/queue.ts +++ b/js/sdk/src/internal/download/queue.ts @@ -1,4 +1,4 @@ -import { waitForCondition } from './wait'; +import { waitForCondition } from '../wait'; /** * A queue that limits the number of concurrent downloads. diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 659ed41e..0a0e01a1 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -6,8 +6,6 @@ import { ValidationError } from '../../errors'; import { getErrorMessage, getVerificationMessage } from "../errors"; import { splitNodeUid } from "../uids"; import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, SharesService, EncryptedRevision, DecryptedRevision } from "./interface"; -// FIXME: Switch to CryptoProxy module once available. -import { importHmacKey, computeHmacSignature } from "./hmac"; /** * Provides crypto operations for nodes metadata. @@ -360,7 +358,7 @@ export class NodesCryptoService { ] = await Promise.all([ this.driveCrypto.generateKey([parentKeys.key], addressKey), this.driveCrypto.encryptNodeName(name, parentKeys.key, addressKey), - this.generateLookupHash(name, parentKeys.hashKey), + this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), ]); const { armoredHashKey, hashKey } = await this.driveCrypto.generateHashKey(nodeKeys.decrypted.key); @@ -400,7 +398,7 @@ export class NodesCryptoService { const { volumeId } = splitNodeUid(node.uid); const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, parentKeys.key, addressKey); - const hash = await this.generateLookupHash(newName, parentKeys.hashKey); + const hash = await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey); return { signatureEmail: email, armoredNodeName, @@ -426,7 +424,7 @@ export class NodesCryptoService { const { volumeId } = splitNodeUid(parentNode.uid); const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, parentKeys.key, addressKey); - const hash = await this.generateLookupHash(node.name.value, parentKeys.hashKey); + const hash = await this.driveCrypto.generateLookupHash(node.name.value, parentKeys.hashKey); const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], addressKey); return { @@ -439,13 +437,6 @@ export class NodesCryptoService { }; } - private async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { - const key = await importHmacKey(parentHashKey); - - const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); - return arrayToHexString(signature); - } - private async handleClaimedAuthor( node: { uid: string, createdDate: Date }, field: MetricVerificationErrorField, @@ -528,17 +519,3 @@ function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATU error: getVerificationMessage(verified, signatureType), }); } - -/** - * Convert an array of 8-bit integers to a hex string - * @param bytes - Array of 8-bit integers to convert - * @returns Hexadecimal representation of the array - */ -export const arrayToHexString = (bytes: Uint8Array) => { - const hexAlphabet = '0123456789abcdef'; - let s = ''; - bytes.forEach((v) => { - s += hexAlphabet[v >> 4] + hexAlphabet[v & 15]; - }); - return s; -}; diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index cf208381..1194e163 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -82,6 +82,18 @@ export function parseFolderExtendedAttributes(logger: Logger, extendedAttributes } } +export function generateFileExtendedAttributes(claimedModificationTime?: Date): string | undefined { + if (!claimedModificationTime) { + return undefined; + } + // TODO: Add support for other attributes + return JSON.stringify({ + Common: { + ModificationTime: dateToIsoString(claimedModificationTime), + }, + }); +} + export function parseFileExtendedAttributes(logger: Logger, extendedAttributes?: string): FileExtendedAttributesParsed { if (!extendedAttributes) { return {} diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 6c4251f6..7456aff2 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -13,6 +13,7 @@ import { NodesManagement } from "./nodesManagement"; import { NodesRevisons } from "./nodesRevisions"; export type { DecryptedNode } from "./interface"; +export { generateFileExtendedAttributes } from "./extendedAttributes"; /** * Provides facade for the whole nodes module. diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 14256d50..c2c0597f 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -1,6 +1,10 @@ +import { c } from "ttag"; + import { base64StringToUint8Array, uint8ArrayToBase64String } from "../../crypto"; -import { DriveAPIService, drivePaths } from "../apiService"; +import { APICodeError, DriveAPIService, drivePaths, isCodeOk } from "../apiService"; import { splitNodeUid, makeNodeUid, splitNodeRevisionUid, makeNodeRevisionUid } from "../uids"; +import { UploadTokens } from "./interface"; +import { ThumbnailType } from "../../interface"; type PostCheckAvailableHashesRequest = Extract['content']['application/json']; type PostCheckAvailableHashesResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['responses']['200']['content']['application/json']; @@ -19,24 +23,28 @@ type PostRequestBlockUploadResponse = drivePaths['/drive/blocks']['post']['respo type PostCommitRevisionRequest = Extract['content']['application/json']; type PostCommitRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['put']['responses']['200']['content']['application/json']; +type PostDeleteNodesRequest = Extract['content']['application/json']; +type PostDeleteNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/delete_multiple']['post']['responses']['200']['content']['application/json']; + export class UploadAPIService { constructor(private apiService: DriveAPIService) { this.apiService = apiService; } - async checkAvailableHashes(nodeUid: string, hashes: string[]): Promise<{ + async checkAvailableHashes(parentNodeUid: string, hashes: string[]): Promise<{ availalbleHashes: string[], pendingHashes: { hash: string, + nodeUid: string, revisionUid: string, clientUid?: string, }[], }> { - const { volumeId, nodeId } = splitNodeUid(nodeUid); + const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); const result = await this.apiService.post< PostCheckAvailableHashesRequest, PostCheckAvailableHashesResponse - >(`drive/v2/volumes/${volumeId}/links/${nodeId}/checkAvailableHashes`, { + >(`drive/v2/volumes/${volumeId}/links/${parentNodeId}/checkAvailableHashes`, { Hashes: hashes, ClientUID: null, }); @@ -45,6 +53,7 @@ export class UploadAPIService { availalbleHashes: result.AvailableHashes, pendingHashes: result.PendingHashes.map((hash) => ({ hash: hash.Hash, + nodeUid: makeNodeUid(volumeId, hash.LinkID), revisionUid: makeNodeRevisionUid(volumeId, hash.LinkID, hash.RevisionID), clientUid: hash.ClientUID || undefined, })), @@ -60,7 +69,7 @@ export class UploadAPIService { armoredNodeKey: string, armoredNodePassphrase: string, armoredNodePassphraseSignature: string, - armoredContentKeyPacket: string, + base64ContentKeyPacket: string, armoredContentKeyPacketSignature: string, signatureEmail: string, }): Promise<{ @@ -81,7 +90,7 @@ export class UploadAPIService { NodeKey: node.armoredNodeKey, NodePassphrase: node.armoredNodePassphrase, NodePassphraseSignature: node.armoredNodePassphraseSignature, - ContentKeyPacket: node.armoredContentKeyPacket, + ContentKeyPacket: node.base64ContentKeyPacket, ContentKeyPacketSignature: node.armoredContentKeyPacketSignature, SignatureAddress: node.signatureEmail, }); @@ -118,6 +127,7 @@ export class UploadAPIService { async getVerificationData(draftNodeRevisionUid: string): Promise<{ verificationCode: Uint8Array, + base64ContentKeyPacket: string, }> { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); const result = await this.apiService.get< @@ -126,33 +136,24 @@ export class UploadAPIService { return { verificationCode: base64StringToUint8Array(result.VerificationCode), + base64ContentKeyPacket: result.ContentKeyPacket, } } async requestBlockUpload(draftNodeRevisionUid: string, addressId: string, blocks: { - content: { + contentBlocks: { index: number, hash: Uint8Array, + encryptedSize: number, armoredSignature: string, - size: number, verificationToken: Uint8Array, }[], - thumbnail: { + thumbnails?: { + type: ThumbnailType, hash: Uint8Array, - size: number, - type: 1 | 2, - }[], - }): Promise<{ - blockTokens: { - barUrl: string, - index: number, - token: string, - }[], - thumbnailTokens: { - bareUrl: string, - token: string, + encryptedSize: number, }[], - }> { + }): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); const result = await this.apiService.post< // FIXME: Deprected fields but not properly marked in the types. @@ -163,31 +164,32 @@ export class UploadAPIService { VolumeID: volumeId, LinkID: nodeId, RevisionID: revisionId, - BlockList: blocks.content.map((block) => ({ + BlockList: blocks.contentBlocks.map((block) => ({ Index: block.index, Hash: uint8ArrayToBase64String(block.hash), EncSignature: block.armoredSignature, - Size: block.size, + Size: block.encryptedSize, Verifier: { Token: uint8ArrayToBase64String(block.verificationToken), }, })), - ThumbnailList: blocks.thumbnail.map((block) => ({ + ThumbnailList: (blocks.thumbnails || []).map((block) => ({ Hash: uint8ArrayToBase64String(block.hash), - Size: block.size, + Size: block.encryptedSize, Type: block.type, })), }); return { blockTokens: result.UploadLinks.map((link) => ({ - barUrl: link.BareURL, index: link.Index, + bareUrl: link.BareURL, token: link.Token, })), thumbnailTokens: (result.ThumbnailLinks || []).map((link) => ({ + // We can type as ThumbnailType because we are passing the type in the request. + type: link.ThumbnailType as ThumbnailType, bareUrl: link.BareURL, - thumbnailType: link.ThumbnailType, token: link.Token, })), }; @@ -196,7 +198,7 @@ export class UploadAPIService { async commitDraftRevision(draftNodeRevisionUid: string, options: { armoredManifestSignature: string, signatureEmail: string, - armoredEncryptedExtendedAttributes: string, + armoredExtendedAttributes?: string, }): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); await this.apiService.put< @@ -206,13 +208,51 @@ export class UploadAPIService { >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, { ManifestSignature: options.armoredManifestSignature, SignatureAddress: options.signatureEmail, - XAttr: options.armoredEncryptedExtendedAttributes, + XAttr: options.armoredExtendedAttributes || null, Photo: null, // FIXME }); } + async deleteDraft(draftNodeUid: string): Promise { + const { volumeId, nodeId } = splitNodeUid(draftNodeUid); + + const response = await this.apiService.post< + PostDeleteNodesRequest, + PostDeleteNodesResponse + >(`drive/v2/volumes/${volumeId}/delete_multiple`, { + LinkIDs: [nodeId], + }); + + const code = response.Responses?.[0].Response.Code || 0; + if (!isCodeOk(code)) { + throw new APICodeError(c('Error').t`Unknown error ${code}`, code) + } + } + async deleteDraftRevision(draftNodeRevisionUid: string): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); this.apiService.delete(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); } + + async uploadBlock(url: string, token: string, block: Uint8Array, onProgress?: (uploadedBytes: number) => void, signal?: AbortSignal): Promise { + const formData = new FormData(); + formData.append("Block", new Blob([block]), "blob"); + + await this.apiService.postBlockStream(url, token, formData, signal); + + // FIXME: implement onProgress properly + // One option is to use ObserverStream, same as for download. + // That requires ReadableStream. FormData can be converted + // to text via `new Response(formData).text()` and that can + // be converted to stream. But there are more details to handle. + // For example, validation on backend is very sensitive and + // we must ensure to implement correctly what fetch does by + // default, or that re-trying streams needs to re-create the + // stream first. + // Other option is to use XMLHttpRequest, but that is not + // supported in all environments (Bun for example) and that + // would require additional requirement on the HTTP client + // interface. + onProgress?.(block.length); + } } diff --git a/js/sdk/src/internal/upload/blockVerifier.ts b/js/sdk/src/internal/upload/blockVerifier.ts new file mode 100644 index 00000000..d824b237 --- /dev/null +++ b/js/sdk/src/internal/upload/blockVerifier.ts @@ -0,0 +1,40 @@ +import { PrivateKey, SessionKey } from "../../crypto"; +import { UploadAPIService } from "./apiService"; +import { UploadCryptoService } from "./cryptoService"; + +export class BlockVerifier { + private verificationCode?: Uint8Array; + private contentKeyPacketSessionKey?: SessionKey; + + constructor( + private apiService: UploadAPIService, + private cryptoService: UploadCryptoService, + private nodeKey: PrivateKey, + private draftNodeRevisionUid: string, + ) { + this.apiService = apiService; + this.cryptoService = cryptoService; + this.draftNodeRevisionUid = draftNodeRevisionUid; + } + + async loadVerificationData() { + const result = await this.apiService.getVerificationData(this.draftNodeRevisionUid); + this.verificationCode = result.verificationCode; + this.contentKeyPacketSessionKey = await this.cryptoService.getContentKeyPacketSessionKey(this.nodeKey, result.base64ContentKeyPacket); + } + + async verifyBlock(encryptedBlock: Uint8Array): Promise<{ + verificationToken: Uint8Array, + }> { + if (!this.verificationCode || !this.contentKeyPacketSessionKey) { + throw new Error('Verifying block before loading verification data'); + } + + return this.cryptoService.verifyBlock( + this.nodeKey, + this.contentKeyPacketSessionKey, + this.verificationCode, + encryptedBlock, + ); + } +} diff --git a/js/sdk/src/internal/upload/chunkStreamReader.test.ts b/js/sdk/src/internal/upload/chunkStreamReader.test.ts new file mode 100644 index 00000000..5840942d --- /dev/null +++ b/js/sdk/src/internal/upload/chunkStreamReader.test.ts @@ -0,0 +1,89 @@ +import { ChunkStreamReader } from "./chunkStreamReader"; + +describe("ChunkStreamReader", () => { + let stream: ReadableStream; + + beforeEach(() => { + stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.enqueue(new Uint8Array([4, 5, 6])); + controller.enqueue(new Uint8Array([7, 8, 9])); + controller.enqueue(new Uint8Array([10, 11, 12])); + controller.close(); + }, + }); + }); + + it("should yield chunks as enqueued if matching the size", async () => { + const reader = new ChunkStreamReader(stream, 3); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(4); + expect(chunks[0]).toEqual(new Uint8Array([1, 2, 3])); + expect(chunks[1]).toEqual(new Uint8Array([4, 5, 6])); + expect(chunks[2]).toEqual(new Uint8Array([7, 8, 9])); + expect(chunks[3]).toEqual(new Uint8Array([10, 11, 12])); + }); + + it("should yield smaller chunks than enqueued chunks", async () => { + const reader = new ChunkStreamReader(stream, 2); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(6); + expect(chunks[0]).toEqual(new Uint8Array([1, 2])); + expect(chunks[1]).toEqual(new Uint8Array([3, 4])); + expect(chunks[2]).toEqual(new Uint8Array([5, 6])); + expect(chunks[3]).toEqual(new Uint8Array([7, 8])); + expect(chunks[4]).toEqual(new Uint8Array([9, 10])); + expect(chunks[5]).toEqual(new Uint8Array([11, 12])); + }); + + it("should yield bigger chunks than enqueued chunks", async () => { + const reader = new ChunkStreamReader(stream, 4); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(3); + expect(chunks[0]).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(chunks[1]).toEqual(new Uint8Array([5, 6, 7, 8])); + expect(chunks[2]).toEqual(new Uint8Array([9, 10, 11, 12])); + }); + + it("should yield last incomplete chunk", async () => { + const reader = new ChunkStreamReader(stream, 5); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(3); + expect(chunks[0]).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + expect(chunks[1]).toEqual(new Uint8Array([6, 7, 8, 9, 10])); + expect(chunks[2]).toEqual(new Uint8Array([11, 12])); + }); + + it("should yield as one big chunk", async () => { + const reader = new ChunkStreamReader(stream, 100); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(1); + expect(chunks[0]).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])); + }); +}); diff --git a/js/sdk/src/internal/upload/chunkStreamReader.ts b/js/sdk/src/internal/upload/chunkStreamReader.ts new file mode 100644 index 00000000..bdc47f32 --- /dev/null +++ b/js/sdk/src/internal/upload/chunkStreamReader.ts @@ -0,0 +1,43 @@ +export class ChunkStreamReader { + private reader: ReadableStreamDefaultReader; + + private chunkSize: number; + + constructor(stream: ReadableStream, chunkSize: number) { + this.reader = stream.getReader(); + this.chunkSize = chunkSize; + } + + async *iterateChunks() { + let buffer = new Uint8Array(this.chunkSize); + + let position = 0; + while (true) { + const { done, value } = await this.reader.read(); + if (done) { + break; + } + + let remainingValue = value; + while (remainingValue.length > 0) { + if (position + remainingValue.length < this.chunkSize) { + buffer.set(remainingValue, position); + position += remainingValue.length; + break; + } + + const remainingToFillBuffer = this.chunkSize - position; + buffer.set(remainingValue.slice(0, remainingToFillBuffer), position); + yield buffer; + + buffer = new Uint8Array(this.chunkSize); + position = 0; + remainingValue = remainingValue.slice(remainingToFillBuffer); + } + } + + if (position > 0) { + yield buffer.slice(0, position); + } + } +} diff --git a/js/sdk/src/internal/upload/controller.ts b/js/sdk/src/internal/upload/controller.ts new file mode 100644 index 00000000..a3ca8f8b --- /dev/null +++ b/js/sdk/src/internal/upload/controller.ts @@ -0,0 +1,25 @@ +import { waitForCondition } from '../wait'; + +export class UploadController { + private paused = false; + public promise?: Promise; + + async waitIfPaused(): Promise { + await waitForCondition(() => !this.paused); + } + + pause(): void { + this.paused = true; + } + + resume(): void { + this.paused = false; + } + + async completion(): Promise { + if (!this.promise) { + throw new Error('UploadController.completion() called before upload started'); + } + return await this.promise; + } +} diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 8f9e3198..fda4ee60 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -1,14 +1,154 @@ -import { DriveCrypto, PrivateKey } from "../../crypto"; +import { Thumbnail } from "../../interface"; +import { DriveCrypto, PrivateKey, SessionKey } from "../../crypto"; +import { splitNodeUid } from "../uids"; +import { EncryptedBlock, EncryptedThumbnail, NodeCrypto, NodeRevisionDraftKeys, SharesService } from "./interface"; export class UploadCryptoService { - constructor(private driveCrypto: DriveCrypto) { + constructor( + private driveCrypto: DriveCrypto, + private shareService: SharesService, + ) { this.driveCrypto = driveCrypto; + this.shareService = shareService; } - async generateKey(parentKey: PrivateKey) { - return this.driveCrypto.generateKey(parentKey, parentKey); - }; + async generateFileCrypto( + parentUid: string, + parentKeys: { key: PrivateKey, hashKey: Uint8Array }, + name: string, + ): Promise { + const { volumeId } = splitNodeUid(parentUid); + const signatureAddress = await this.shareService.getVolumeEmailKey(volumeId); + const [ + nodeKeys, + { armoredNodeName }, + hash, + ] = await Promise.all([ + this.driveCrypto.generateKey([parentKeys.key], signatureAddress.addressKey), + this.driveCrypto.encryptNodeName(name, parentKeys.key, signatureAddress.addressKey), + this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), + ]); - private async generateHash() { - }; + const contentKey = await this.driveCrypto.generateContentKey(nodeKeys.decrypted.key); + + return { + nodeKeys, + contentKey, + encryptedNode: { + encryptedName: armoredNodeName, + hash, + }, + signatureAddress, + }; + } + + async generateNameHashes(parentHashKey: Uint8Array, names: string[]): Promise<{ name: string, hash: string }[]> { + return Promise.all(names.map(async (name) => ({ + name, + hash: await this.driveCrypto.generateLookupHash(name, parentHashKey) + }))); + } + + async encryptThumbnail(nodeRevisionDraftKeys: NodeRevisionDraftKeys, thumbnail: Thumbnail): Promise { + const { encryptedData } = await this.driveCrypto.encryptThumbnailBlock( + thumbnail.thumbnail, + nodeRevisionDraftKeys.contentKeyPacketSessionKey, + nodeRevisionDraftKeys.signatureAddress.addressKey, + ) + + const digest = await crypto.subtle.digest('SHA-256', encryptedData); + + return { + type: thumbnail.type, + encryptedData: encryptedData, + originalSize: thumbnail.thumbnail.length, + encryptedSize: encryptedData.length, + hash: new Uint8Array(digest), + } + } + + async encryptBlock( + verifyBlock: (encryptedBlock: Uint8Array) => Promise<{ verificationToken: Uint8Array }>, + nodeRevisionDraftKeys: NodeRevisionDraftKeys, + block: Uint8Array, + index: number, + ): Promise { + const { encryptedData, armoredSignature } = await this.driveCrypto.encryptBlock( + block, + nodeRevisionDraftKeys.key, + nodeRevisionDraftKeys.contentKeyPacketSessionKey, + nodeRevisionDraftKeys.signatureAddress.addressKey, + ); + + const digest = await crypto.subtle.digest('SHA-256', encryptedData); + const { verificationToken } = await verifyBlock(encryptedData); + + return { + index, + encryptedData, + armoredSignature, + verificationToken, + originalSize: block.length, + encryptedSize: encryptedData.length, + hash: new Uint8Array(digest), + } + } + + async commitFile(nodeRevisionDraftKeys: NodeRevisionDraftKeys, manifest: Uint8Array, extendedAttributes?: string): Promise<{ + armoredManifestSignature: string, + signatureEmail: string, + armoredExtendedAttributes?: string, + }> { + const { armoredManifestSignature } = await this.driveCrypto.signManifest(manifest, nodeRevisionDraftKeys.signatureAddress.addressKey); + + const { armoredExtendedAttributes } = extendedAttributes + ? await this.driveCrypto.encryptExtendedAttributes(extendedAttributes, nodeRevisionDraftKeys.key, nodeRevisionDraftKeys.signatureAddress.addressKey) + : { armoredExtendedAttributes: undefined }; + + return { + armoredManifestSignature, + signatureEmail: nodeRevisionDraftKeys.signatureAddress.email, + armoredExtendedAttributes, + } + } + + async getContentKeyPacketSessionKey(nodeKey: PrivateKey, base64ContentKeyPacket: string): Promise { + const { sessionKey } = await this.driveCrypto.decryptAndVerifySessionKey( + base64ContentKeyPacket, + undefined, + nodeKey, + [], + ); + return sessionKey; + } + + async verifyBlock( + nodeKey: PrivateKey, + contentKeyPacketSessionKey: SessionKey, + verificationCode: Uint8Array, + encryptedData: Uint8Array, + ): Promise<{ + verificationToken: Uint8Array, + }> { + // Attempt to decrypt data block, to try to detect bitflips / bad hardware + // + // We don't check the signature as it is an expensive operation, + // and we don't need to here as we always have the manifest signature + // + // Additionally, we use the key provided by the verification endpoint, to + // ensure the correct key was used to encrypt the data + await this.driveCrypto.decryptBlock( + encryptedData, + undefined, + nodeKey, + contentKeyPacketSessionKey, + ); + + // The verifier requires a 0-padded data packet, so we can + // access the array directly and fall back to 0. + const verificationToken = verificationCode.map((value, index) => value ^ (encryptedData[index] || 0)); + return { + verificationToken, + } + } } diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts new file mode 100644 index 00000000..afa81271 --- /dev/null +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -0,0 +1,468 @@ +import { Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; +import { APIHTTPError, HTTPErrorCode } from '../apiService'; +import { Fileuploader } from './fileUploader'; +import { UploadTelemetry } from './telemetry'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { UploadController } from './controller'; +import { BlockVerifier } from './blockVerifier'; +import { NodeRevisionDraft } from './interface'; + +async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise, _: any, block: Uint8Array, index: number) { + await verifyBlock(block); + return { + index, + encryptedData: block, + armoredSignature: 'signature', + verificationToken: 'verificationToken', + originalSize: block.length, + encryptedSize: block.length + 10000, + hash: 'blockHash', + }; +} + +function mockUploadBlock(_: string, __: string, encryptedBlock: Uint8Array, onProgress: (uploadedBytes: number) => void) { + onProgress(encryptedBlock.length); +} + +describe('FileUploader', () => { + let telemetry: UploadTelemetry; + let apiService: jest.Mocked; + let cryptoService: UploadCryptoService; + let blockVerifier: BlockVerifier; + let revisionDraft: NodeRevisionDraft; + let metadata: UploadMetadata; + let controller: UploadController; + let onFinish: () => Promise; + let abortController: AbortController; + + let uploader: Fileuploader; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + telemetry = { + getLoggerForRevision: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + uploadFailed: jest.fn(), + uploadFinished: jest.fn(), + }; + + // @ts-expect-error No need to implement all methods for mocking + apiService = { + requestBlockUpload: jest.fn().mockImplementation((_, __, blocks) => ({ + blockTokens: blocks.contentBlocks.map((block: { index: number }) => ({ + index: block.index, + bareUrl: `bareUrl/block:${block.index}`, + token: `token/block:${block.index}`, + })), + thumbnailTokens: (blocks.thumbnails || []).map((thumbnail: { type: number }) => ({ + type: thumbnail.type, + bareUrl: `bareUrl/thumbnail:${thumbnail.type}`, + token: `token/thumbnail:${thumbnail.type}`, + })), + })), + commitDraftRevision: jest.fn().mockResolvedValue(undefined), + uploadBlock: jest.fn().mockImplementation(mockUploadBlock), + }; + + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + encryptThumbnail: jest.fn().mockImplementation(async (_, thumbnail) => ({ + type: thumbnail.type, + encryptedData: thumbnail.thumbnail, + originalSize: thumbnail.thumbnail.length, + encryptedSize: thumbnail.thumbnail + 1000, + hash: 'thumbnailHash', + })), + encryptBlock: jest.fn().mockImplementation(mockEncryptBlock), + commitFile: jest.fn().mockResolvedValue('commitCrypto'), + }; + + // @ts-expect-error No need to implement all methods for mocking + blockVerifier = { + verifyBlock: jest.fn().mockResolvedValue(undefined), + }; + + revisionDraft = { + nodeRevisionUid: 'revisionUid', + nodeKeys: { + signatureAddress: { addressId: 'addressId' }, + }, + } as NodeRevisionDraft; + + metadata = { + // 3 blocks: 4 + 4 + 2 MB + expectedSize: 10 * 1024 * 1024, + } as UploadMetadata; + + controller = new UploadController(); + onFinish = jest.fn(); + abortController = new AbortController(); + + uploader = new Fileuploader( + telemetry, + apiService, + cryptoService, + blockVerifier, + revisionDraft, + metadata, + onFinish, + abortController.signal, + ); + }); + + describe('writeFile', () => { + it('should set modification time if not set', () => { + // @ts-expect-error Ignore mocking File + const file = { + lastModified: 123456789, + stream: jest.fn().mockReturnValue('stream'), + } as File; + const thumbnails: Thumbnail[] = []; + const onProgress = jest.fn(); + + const writeStreamSpy = jest.spyOn(uploader, 'writeStream').mockReturnValue(controller); + + uploader.writeFile(file, thumbnails, onProgress); + + expect(metadata.modificationTime).toEqual(new Date(123456789)); + expect(writeStreamSpy).toHaveBeenCalledWith('stream', thumbnails, onProgress); + }); + }); + + describe('writeStream', () => { + let uploadStreamSpy: jest.SpyInstance; + beforeEach(() => { + uploadStreamSpy = jest.spyOn(uploader as any, 'uploadStream').mockResolvedValue('revisionUid'); + }); + + it('should throw an error if upload already started', () => { + uploader.writeStream(new ReadableStream(), [], jest.fn()); + + expect(() => { + uploader.writeStream(new ReadableStream(), [], jest.fn()); + }).toThrow('Upload already started'); + }); + + it('should start the upload process', async () => { + const stream = new ReadableStream(); + const thumbnails: Thumbnail[] = []; + const onProgress = jest.fn(); + + uploader.writeStream(stream, thumbnails, onProgress); + expect(uploadStreamSpy).toHaveBeenCalledWith(stream, thumbnails, onProgress); + }); + }); + + describe('uploadStream', () => { + let thumbnails: Thumbnail[]; + let thumbnailSize: number; + + let onProgress: (uploadedBytes: number) => void; + let stream: ReadableStream; + + const verifySuccess = async () => { + const controller = uploader.writeStream(stream, thumbnails, onProgress); + await controller.completion(); + + expect(cryptoService.commitFile).toHaveBeenCalledTimes(1); + expect(apiService.commitDraftRevision).toHaveBeenCalledTimes(1); + expect(apiService.commitDraftRevision).toHaveBeenCalledWith(revisionDraft.nodeRevisionUid, 'commitCrypto'); + expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); + expect(telemetry.uploadFinished).toHaveBeenCalledWith(metadata.expectedSize + thumbnailSize); + expect(telemetry.uploadFailed).not.toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledWith(false); + }; + + const verifyFailure = async (error: string, uploadedBytes: number | undefined, expectedSize = metadata.expectedSize) => { + const controller = uploader.writeStream(stream, thumbnails, onProgress); + await expect(controller.completion()).rejects.toThrow(error); + + expect(telemetry.uploadFinished).not.toHaveBeenCalled(); + expect(telemetry.uploadFailed).toHaveBeenCalledTimes(1); + expect(telemetry.uploadFailed).toHaveBeenCalledWith( + new Error(error), + uploadedBytes === undefined ? expect.anything() : uploadedBytes, + expectedSize, + ); + expect(onFinish).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledWith(true); + }; + + const verifyOnProgress = async (uploadedBytes: number[]) => { + expect(onProgress).toHaveBeenCalledTimes(uploadedBytes.length); + for (let i = 0; i < uploadedBytes.length; i++) { + expect(onProgress).toHaveBeenNthCalledWith(i + 1, uploadedBytes[i]); + } + }; + + beforeEach(() => { + onProgress = jest.fn(); + thumbnails = [ + { + type: ThumbnailType.Type1, + thumbnail: new Uint8Array(1024), + } + ]; + thumbnailSize = thumbnails.reduce((acc, thumbnail) => acc + thumbnail.thumbnail.length, 0); + stream = new ReadableStream({ + start(controller) { + const chunkSize = 1024; + const chunkCount = metadata.expectedSize / chunkSize; + for (let i = 1; i <= chunkCount; i++) { + controller.enqueue(new Uint8Array(chunkSize)); + } + controller.close(); + }, + }); + }); + + it("should upload successfully", async () => { + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + expect(apiService.uploadBlock).toHaveBeenCalledTimes(4); // 3 blocks + 1 thumbnail + expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(3); // 3 blocks + await verifyOnProgress([thumbnailSize, 4*1024*1024, 4*1024*1024, 2*1024*1024]); + }); + + it("should upload successfully empty file without thumbnail", async () => { + metadata = { + expectedSize: 0, + } as UploadMetadata; + stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + thumbnails = []; + thumbnailSize = 0; + uploader = new Fileuploader( + telemetry, + apiService, + cryptoService, + blockVerifier, + revisionDraft, + metadata, + onFinish, + ); + + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(0); + expect(apiService.uploadBlock).toHaveBeenCalledTimes(0); + expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(0); + await verifyOnProgress([]); + }); + + it("should upload successfully empty file with thumbnail", async () => { + metadata = { + expectedSize: 0, + } as UploadMetadata; + stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + uploader = new Fileuploader( + telemetry, + apiService, + cryptoService, + blockVerifier, + revisionDraft, + metadata, + onFinish, + ); + + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + expect(apiService.uploadBlock).toHaveBeenCalledTimes(1); + expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(0); + await verifyOnProgress([thumbnailSize]); + }); + + it('should handle failure when encrypting thumbnails', async () => { + cryptoService.encryptThumbnail = jest.fn().mockImplementation(async function () { + throw new Error('Failed to encrypt thumbnail'); + }); + + await verifyFailure('Failed to encrypt thumbnail', 0); + expect(cryptoService.encryptThumbnail).toHaveBeenCalledTimes(1); + }); + + it('should handle failure when encrypting block', async () => { + cryptoService.encryptBlock = jest.fn().mockImplementation(async function () { + throw new Error('Failed to encrypt block'); + }); + + // Encrypting thumbnails is before blocks, thus it can be uploaded before failure. + await verifyFailure('Failed to encrypt block', 1024); + // 1 block + 1 retry, others are skipped + expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(2); + }); + + it('should handle one time-off failure when encrypting block', async () => { + let count = 0; + cryptoService.encryptBlock = jest.fn().mockImplementation(async function (verifyBlock, keys, block, index) { + if (count === 0) { + count++; + throw new Error('Failed to encrypt block'); + } + return mockEncryptBlock(verifyBlock, keys, block, index); + }); + + await verifySuccess(); + // 1 block + 1 retry + 2 other blocks without retry + expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(4); + await verifyOnProgress([thumbnailSize, 4*1024*1024, 4*1024*1024, 2*1024*1024]); + }); + + it('should handle failure when requesting tokens', async () => { + apiService.requestBlockUpload = jest.fn().mockImplementation(async function () { + throw new Error('Failed to request tokens'); + }); + + await verifyFailure('Failed to request tokens', 0); + }); + + it('should handle failure when uploading thumbnail', async () => { + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/thumbnail:1') { + throw new Error('Failed to upload thumbnail'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + // 10 MB uploaded as blocks still uploaded + await verifyFailure('Failed to upload thumbnail', 10 * 1024 * 1024); + }); + + it('should handle one time-off failure when uploading thubmnail', async () => { + let count = 0; + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/thumbnail:1' && count === 0) { + count++; + throw new Error('Failed to upload thumbnail'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + // 3 blocks + 1 retry + 1 thumbnail + expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); + await verifyOnProgress([4*1024*1024, 4*1024*1024, 2*1024*1024, 1024]); + }); + + it('should handle failure when uploading block', async () => { + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:3') { + throw new Error('Failed to upload block'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + // ~8 MB uploaded as 2 first blocks + 1 thumbnail still uploaded + await verifyFailure('Failed to upload block', 8 * 1024 * 1024 + 1024); + }); + + it('should handle one time-off failure when uploading block', async () => { + let count = 0; + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:2' && count === 0) { + count++; + throw new Error('Failed to upload block'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + // 3 blocks + 1 retry + 1 thumbnail + expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); + await verifyOnProgress([1024, 4*1024*1024, 2*1024*1024, 4*1024*1024]); + }); + + it('should handle expired token when uploading block', async () => { + let count = 0; + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:2' && count === 0) { + count++; + throw new APIHTTPError('Expired token', HTTPErrorCode.NOT_FOUND); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + await verifySuccess(); + // 1 for first try + 1 for retry + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(2); + expect(apiService.requestBlockUpload).toHaveBeenCalledWith( + revisionDraft.nodeRevisionUid, + revisionDraft.nodeKeys.signatureAddress.addressId, + { + contentBlocks: [ + { + index: 2, + encryptedSize: 4 * 1024 * 1024 + 10000, + hash: 'blockHash', + armoredSignature: 'signature', + verificationToken: 'verificationToken', + } + ], + }, + ); + // 3 blocks + 1 retry + 1 thumbnail + expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); + await verifyOnProgress([1024, 4*1024*1024, 2*1024*1024, 4*1024*1024]); + }); + + it('should handle abortion', async () => { + const error = new Error('Aborted'); + const controller = uploader.writeStream(stream, thumbnails, onProgress); + abortController.abort(error); + await controller.completion(); + expect(apiService.uploadBlock.mock.calls[0][4]?.aborted).toBe(true); + }); + + describe('verifyIntegrity', () => { + it('should throw an error if block count does not match', async () => { + uploader = new Fileuploader( + telemetry, + apiService, + cryptoService, + blockVerifier, + revisionDraft, + { + // Fake expected size to break verification + expectedSize: 1 * 1024 * 1024 + 1024, + mimeType: '', + }, + onFinish, + ); + + await verifyFailure( + 'Some file parts failed to upload', + 10 * 1024 * 1024 + 1024, + 1 * 1024 * 1024 + 1024, + ); + }); + + it('should throw an error if file size does not match', async () => { + cryptoService.encryptBlock = jest.fn().mockImplementation(async (_, __, block, index) => ({ + index, + encryptedData: block, + armoredSignature: 'signature', + verificationToken: 'verificationToken', + originalSize: 0, // Fake original size to break verification + encryptedSize: block.length + 10000, + hash: 'blockHash', + })); + + await verifyFailure('Some file bytes failed to upload', 10 * 1024 * 1024 + 1024); + }); + }); + }); +}); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index b932e2b1..f0240a99 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -1,40 +1,529 @@ -import { PrivateKey } from "../../crypto"; -import { Thumbnail } from "../../interface"; +import { c } from "ttag"; +import { Thumbnail, Logger, ThumbnailType, UploadMetadata } from "../../interface"; +import { IntegrityError } from "../../errors"; +import { LoggerWithPrefix } from "../../telemetry"; +import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from "../apiService"; +import { getErrorMessage } from "../errors"; +import { generateFileExtendedAttributes } from "../nodes"; +import { mergeUint8Arrays } from "../utils"; +import { waitForCondition } from '../wait'; +import { UploadAPIService } from "./apiService"; +import { BlockVerifier } from "./blockVerifier"; +import { UploadController } from './controller'; +import { UploadCryptoService } from "./cryptoService"; +import { NodeRevisionDraft, EncryptedBlock, EncryptedThumbnail, EncryptedBlockMetadata } from "./interface"; +import { UploadTelemetry } from './telemetry'; +import { ChunkStreamReader } from './chunkStreamReader'; + +/** + * File chunk size in bytes representing the size of each block. + */ +const FILE_CHUNK_SIZE = 4 * 1024 * 1024; + +/** + * Maximum number of blocks that can be buffered before upload. + * This is to prevent using too much memory. + */ +const MAX_BUFFERED_BLOCKS = 15; + +/** + * Maximum number of blocks that can be uploaded at the same time. + * This is to prevent overloading the server with too many requests. + */ +const MAX_UPLOADING_BLOCKS = 5; + +/** + * Maximum number of retries for block encryption. + * This is to automatically retry random errors that can happen + * during encryption, for example bitflips. + */ +const MAX_BLOCK_ENCRYPTION_RETRIES = 1; + +/** + * Maximum number of retries for block upload. + * This is to ensure we don't end up in an infinite loop. + */ +const MAX_BLOCK_UPLOAD_RETRIES = 3; + +/** + * Fileuploader is responsible for uploading file content to the server. + * + * It handles the encryption of file blocks and thumbnails, as well as + * the upload process itself. It manages the upload queue and ensures + * that the upload process is efficient and does not overload the server. + */ export class Fileuploader { + private logger: Logger; + private controller: UploadController; + private abortController: AbortController; + + private encryptedThumbnails = new Map(); + private encryptedBlocks = new Map(); + private encryptionFinished = false; + + private ongoingUploads = new Map, + encryptedBlock: EncryptedBlock | EncryptedThumbnail, + }>(); + private uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = []; + private uploadedBlocks: ({ index: number } & EncryptedBlockMetadata)[] = []; - constructor(nodeKey: PrivateKey, draftNodeRevisionUid: string) { - this.controller = new UploadController(draftNodeRevisionUid); + constructor( + private telemetry: UploadTelemetry, + private apiService: UploadAPIService, + private cryptoService: UploadCryptoService, + private blockVerifier: BlockVerifier, + private revisionDraft: NodeRevisionDraft, + private metadata: UploadMetadata, + private onFinish: (failure: boolean) => Promise, + private signal?: AbortSignal, + ) { + this.telemetry = telemetry; + this.logger = telemetry.getLoggerForRevision(revisionDraft.nodeRevisionUid); + this.apiService = apiService; + this.cryptoService = cryptoService; + this.blockVerifier = blockVerifier; + this.revisionDraft = revisionDraft; + this.metadata = metadata; + this.onFinish = onFinish; + + this.signal = signal; + this.abortController = new AbortController(); + if (signal) { + signal.addEventListener('abort', () => { + this.abortController.abort(); + }); + } + + this.controller = new UploadController(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController { - // TODO - return this.controller; + writeFile(fileObject: File, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): UploadController { + if (this.controller.promise) { + throw new Error(`Upload already started`); + } + if (!this.metadata.mimeType) { + this.metadata.mimeType = fileObject.type; + } + if (!this.metadata.expectedSize) { + this.metadata.expectedSize = fileObject.size; + } + if (!this.metadata.modificationTime) { + this.metadata.modificationTime = new Date(fileObject.lastModified); + } + return this.writeStream(fileObject.stream(), thumbnails, onProgress); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress: (uploadedBytes: number) => void): UploadController { - // TODO + writeStream(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): UploadController { + if (this.controller.promise) { + throw new Error(`Upload already started`); + } + this.controller.promise = this.uploadStream(stream, thumbnails, onProgress); return this.controller; } -} -class UploadController { - private draftNodeUid: string; + private async uploadStream(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { + let failure = false; + + // File progress is tracked for telemetry - to track at what + // point the download failed. + let fileProgress = 0; + + try { + this.logger.info(`Starting upload`); + await this.encryptAndUploadBlocks(stream, thumbnails, (uploadedBytes) => { + fileProgress += uploadedBytes; + onProgress?.(uploadedBytes); + }) + + this.logger.debug(`All blocks uploaded, committing`); + await this.commitFile(thumbnails); + + this.telemetry.uploadFinished(fileProgress); + this.logger.info(`Upload succeeded`); + } catch (error: unknown) { + failure = true; + this.logger.error(`Upload failed`, error); + this.telemetry.uploadFailed(error, fileProgress, this.metadata.expectedSize); + throw error; + } finally { + this.logger.debug(`Upload cleanup`); + await this.onFinish(failure); + } + + return this.revisionDraft.nodeRevisionUid; + } + + private async encryptAndUploadBlocks(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void) { + // We await for the encryption of thumbnails to finish before + // starting the upload. This is because we need to request the + // upload tokens for the thumbnails with the first blocks. + await this.encryptThumbnails(thumbnails); + + // Encrypting blocks and uploading them is done in parallel. + // For that reason, we want to await for the encryption later. + // However, jest complains if encryptBlock rejects asynchronously. + // For that reason we handle manually to save error to the variable + // and throw if set after we await for the encryption. + let encryptionError; + const encryptBlocksPromise = this.encryptBlocks(stream).catch((error) => { + encryptionError = error; + this.abortUpload(error); + }); + + while (!encryptionError) { + await this.controller.waitIfPaused(); + await this.waitForUploadCapacityAndBufferedBlocks(); + + if (this.isEncryptionFullyFinished) { + break; + } + + await this.requestAndInitiateUpload(onProgress); + + if (this.isEncryptionFullyFinished) { + break; + } + } + + this.logger.debug(`All blocks uploading, waiting for them to finish`); + // Technically this is finished as while-block above will break + // when encryption is finished. But in case of error there could + // be a race condition that would cause the encryptionError to + // not be set yet. + await encryptBlocksPromise; + if (encryptionError) { + throw encryptionError; + } + await Promise.all(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); + } + + private async commitFile(thumbnails: Thumbnail[]) { + this.verifyIntegrity(thumbnails); + const extendedAttributes = generateFileExtendedAttributes(this.metadata.modificationTime); + const nodeCommitCrypto = await this.cryptoService.commitFile(this.revisionDraft.nodeKeys, this.manifest, extendedAttributes); + await this.apiService.commitDraftRevision(this.revisionDraft.nodeRevisionUid, nodeCommitCrypto); + } + + private async encryptThumbnails(thumbnails: Thumbnail[]) { + if (new Set(thumbnails.map(({ type }) => type)).size !== thumbnails.length) { + throw new Error(`Duplicate thumbnail types`); + } + + for (const thumbnail of thumbnails) { + this.logger.debug(`Encrypting thumbnail ${thumbnail.type}`); + const encryptedThumbnail = await this.cryptoService.encryptThumbnail(this.revisionDraft.nodeKeys, thumbnail); + this.encryptedThumbnails.set(thumbnail.type, encryptedThumbnail); + } + } + + private async encryptBlocks(stream: ReadableStream) { + try { + let index = 0; + const reader = new ChunkStreamReader(stream, FILE_CHUNK_SIZE); + for await (const block of reader.iterateChunks()) { + index++; + + await this.controller.waitIfPaused(); + await this.waitForBufferCapacity(); + + this.logger.debug(`Encrypting block ${index}`); + let attempt = 0; + let encryptedBlock; + while (!encryptedBlock) { + attempt++; + + try { + encryptedBlock = await this.cryptoService.encryptBlock( + (encryptedBlock) => this.blockVerifier.verifyBlock(encryptedBlock), + this.revisionDraft.nodeKeys, + block, + index, + ); + } catch (error: unknown) { + if (attempt <= MAX_BLOCK_ENCRYPTION_RETRIES) { + this.logger.warn(`Block encryption failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } - constructor(draftNodeUid: string) { - this.draftNodeUid = draftNodeUid; + this.logger.error(`Failed to encrypt block ${index}`, error); + throw error; + } + } + this.encryptedBlocks.set(index, encryptedBlock); + } + } finally { + this.encryptionFinished = true; + } } - pause(): void {} + private async requestAndInitiateUpload(onProgress?: (uploadedBytes: number) => void): Promise { + this.logger.info(`Requesting upload tokens for ${this.encryptedBlocks.size} blocks`); + const uploadTokens = await this.apiService.requestBlockUpload( + this.revisionDraft.nodeRevisionUid, + this.revisionDraft.nodeKeys.signatureAddress.addressId, + { + contentBlocks: Array.from(this.encryptedBlocks.values().map(block => ({ + index: block.index, + encryptedSize: block.encryptedSize, + hash: block.hash, + armoredSignature: block.armoredSignature, + verificationToken: block.verificationToken, + }))), + thumbnails: Array.from(this.encryptedThumbnails.values().map(block => ({ + type: block.type, + encryptedSize: block.encryptedSize, + hash: block.hash, + }))), + }, + ); + + for (const thumbnailToken of uploadTokens.thumbnailTokens) { + const encryptedThumbnail = this.encryptedThumbnails.get(thumbnailToken.type); + if (!encryptedThumbnail) { + throw new Error(`Thumbnail ${thumbnailToken.type} not found`); + } - resume(): void {} + this.encryptedThumbnails.delete(thumbnailToken.type); + + const uploadKey = `thumbnail:${thumbnailToken.type}`; + this.ongoingUploads.set(uploadKey, { + uploadPromise: this.uploadThumbnail( + thumbnailToken, + encryptedThumbnail, + onProgress, + ).finally(() => { + this.ongoingUploads.delete(uploadKey); + }), + encryptedBlock: encryptedThumbnail, + }); + } + + for (const blockToken of uploadTokens.blockTokens) { + const encryptedBlock = this.encryptedBlocks.get(blockToken.index); + if (!encryptedBlock) { + throw new Error(`Block ${blockToken.index} not found`); + } + + this.encryptedBlocks.delete(blockToken.index); + + const uploadKey = `block:${blockToken.index}`; + this.ongoingUploads.set(uploadKey, { + uploadPromise: this.uploadBlock( + blockToken, + encryptedBlock, + onProgress, + ).finally(() => { + this.ongoingUploads.delete(uploadKey); + }), + encryptedBlock, + }); + } + } + + private async uploadThumbnail( + uploadToken: { bareUrl: string, token: string }, + encryptedThumbnail: EncryptedThumbnail, + onProgress?: (uploadedBytes: number) => void, + ) { + const logger = new LoggerWithPrefix(this.logger, `thubmnail ${uploadToken.token}`); + logger.info(`Upload started`); + + let blockProgress = 0; + let attempt = 0; + + while (true) { + attempt++; + try { + logger.debug(`Uploading`); + await this.apiService.uploadBlock( + uploadToken.bareUrl, + uploadToken.token, + encryptedThumbnail.encryptedData, + (uploadedBytes) => { + blockProgress += uploadedBytes; + onProgress?.(uploadedBytes); + }, + this.abortController.signal, + ) + this.uploadedThumbnails.push({ + type: encryptedThumbnail.type, + hash: encryptedThumbnail.hash, + encryptedSize: encryptedThumbnail.encryptedSize, + originalSize: encryptedThumbnail.originalSize, + }) + break; + } catch (error: unknown) { + if (blockProgress !== 0) { + onProgress?.(-blockProgress); + blockProgress = 0; + } + + // Note: We don't handle token expiration for thumbnails, because + // the API requires the thumbnails to be requested with the first + // upload block request. Thumbnails are tiny, so this edge case + // should be very rare and considering it is the beginning of the + // upload, the whole retry is cheap. + + // Upload can fail for various reasons, for example integrity + // can fail due to bitflips. We want to retry and solve the issue + // seamlessly for the user. We retry only once, because we don't + // want to get stuck in a loop. + if (attempt <= MAX_BLOCK_UPLOAD_RETRIES) { + logger.warn(`Upload failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } + + logger.error(`Upload failed`, error); + this.abortUpload(error); + throw error; + } + } + + logger.info(`Uploaded`); + } + + private async uploadBlock( + uploadToken: { index: number, bareUrl: string, token: string }, + encryptedBlock: EncryptedBlock, + onProgress?: (uploadedBytes: number) => void, + ) { + const logger = new LoggerWithPrefix(this.logger, `block ${uploadToken.index}:${uploadToken.token}`); + logger.info(`Upload started`); + + let blockProgress = 0; + let attempt = 0; + + while (true) { + attempt++; + try { + logger.debug(`Uploading`); + await this.apiService.uploadBlock( + uploadToken.bareUrl, + uploadToken.token, + encryptedBlock.encryptedData, + (uploadedBytes) => { + blockProgress += uploadedBytes; + onProgress?.(uploadedBytes); + }, + this.abortController.signal, + ) + this.uploadedBlocks.push({ + index: encryptedBlock.index, + hash: encryptedBlock.hash, + encryptedSize: encryptedBlock.encryptedSize, + originalSize: encryptedBlock.originalSize, + }) + break; + } catch (error: unknown) { + if (blockProgress !== 0) { + onProgress?.(-blockProgress); + blockProgress = 0; + } + + if ( + (error instanceof APIHTTPError && error.statusCode === HTTPErrorCode.NOT_FOUND) || + (error instanceof NotFoundAPIError) + ) { + logger.warn(`Token expired, fetching new token and retrying`); + const uploadTokens = await this.apiService.requestBlockUpload( + this.revisionDraft.nodeRevisionUid, + this.revisionDraft.nodeKeys.signatureAddress.addressId, + { + contentBlocks: [{ + index: encryptedBlock.index, + encryptedSize: encryptedBlock.encryptedSize, + hash: encryptedBlock.hash, + armoredSignature: encryptedBlock.armoredSignature, + verificationToken: encryptedBlock.verificationToken, + }], + }, + ); + uploadToken = uploadTokens.blockTokens[0]; + continue; + } + + // Upload can fail for various reasons, for example integrity + // can fail due to bitflips. We want to retry and solve the issue + // seamlessly for the user. We retry only once, because we don't + // want to get stuck in a loop. + if (attempt <= MAX_BLOCK_UPLOAD_RETRIES) { + logger.warn(`Upload failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } + + logger.error(`Upload failed`, error); + this.abortUpload(error); + throw error; + } + } + + logger.info(`Uploaded`); + } + + private async waitForBufferCapacity() { + if (this.encryptedBlocks.size >= MAX_BUFFERED_BLOCKS) { + await waitForCondition(() => this.encryptedBlocks.size < MAX_BUFFERED_BLOCKS); + } + } + + private async waitForUploadCapacityAndBufferedBlocks() { + while (this.ongoingUploads.size >= MAX_UPLOADING_BLOCKS) { + await Promise.race(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); + } + await waitForCondition(() => this.encryptedBlocks.size > 0 || this.encryptionFinished); + } + + private verifyIntegrity(thumbnails: Thumbnail[]) { + const expectedBlockCount = Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0); + if (this.uploadedBlockCount !== expectedBlockCount) { + throw new IntegrityError(c('Error').t`Some file parts failed to upload`, { + uploadedBlockCount: this.uploadedBlockCount, + expectedBlockCount, + }); + } + if (this.uploadedOriginalFileSize !== this.metadata.expectedSize) { + throw new IntegrityError(c('Error').t`Some file bytes failed to upload`, { + uploadedOriginalFileSize: this.uploadedOriginalFileSize, + expectedFileSize: this.metadata.expectedSize, + }); + } + } + + /** + * Check if the encryption is fully finished. + * This means that all blocks and thumbnails have been encrypted and + * requested to be uploaded, and there are no more blocks or thumbnails + * to encrypt and upload. + */ + private get isEncryptionFullyFinished(): boolean { + return this.encryptionFinished && this.encryptedBlocks.size === 0 && this.encryptedThumbnails.size === 0; + } + + private get uploadedBlockCount(): number { + return this.uploadedBlocks.length + this.uploadedThumbnails.length; + } + + private get uploadedOriginalFileSize(): number { + return this.uploadedBlocks.reduce((sum, { originalSize }) => sum + originalSize, 0); + } + + private get manifest(): Uint8Array { + this.uploadedThumbnails.sort((a, b) => a.type - b.type); + this.uploadedBlocks.sort((a, b) => a.index - b.index); + const hashes = [ + ...this.uploadedThumbnails.map(({ hash }) => hash), + ...this.uploadedBlocks.map(({ hash }) => hash), + ]; + return mergeUint8Arrays(hashes); + } - async completion(): Promise { - // TODO: wait for upload to be finished - // TODO: once completed, its not draft anymore - return this.draftNodeUid; + private async abortUpload(error: unknown) { + if (this.abortController.signal.aborted || this.signal?.aborted) { + return; + } + this.abortController.abort(error); } } diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 78a03e2b..a1eec481 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -1,24 +1,33 @@ +import { ProtonDriveTelemetry, UploadMetadata } from "../../interface"; import { DriveAPIService } from "../apiService"; import { DriveCrypto } from "../../crypto"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; import { UploadQueue } from "./queue"; -import { NodesService } from "./interface"; +import { NodesService, SharesService } from "./interface"; import { Fileuploader } from "./fileUploader"; +import { UploadTelemetry } from "./telemetry"; +import { UploadManager } from "./manager"; +import { BlockVerifier } from "./blockVerifier"; -type UploadMetadata = { - mimeType: string, - expectedSize: number, - additionalMetadata?: object, -} - +/** + * Provides facade for the upload module. + * + * The upload module is responsible for handling file uploads, including + * metadata generation, content upload, API communication, encryption, + * and verifications. + */ export function initUploadModule( + telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, driveCrypto: DriveCrypto, + sharesService: SharesService, nodesService: NodesService, ) { const api = new UploadAPIService(apiService); - const cryptoService = new UploadCryptoService(driveCrypto); + const cryptoService = new UploadCryptoService(driveCrypto, sharesService); + const uploadTelemetry = new UploadTelemetry(telemetry); + const manager = new UploadManager(telemetry, api, cryptoService, nodesService); const queue = new UploadQueue(); @@ -26,18 +35,42 @@ export function initUploadModule( parentFolderUid: string, name: string, metadata: UploadMetadata, - signal?: AbortSignal + signal?: AbortSignal, ) { - await queue.waitForCapacity(metadata.expectedSize, signal); - const parentKey = await nodesService.getNodeKeys(parentFolderUid); - const nodeKey = await cryptoService.generateKey(parentKey); - // TODO: encrypt name etc. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nodeData: any = { - name, + await queue.waitForCapacity(signal); + + let revisionDraft, blockVerifier; + try { + revisionDraft = await manager.createDraftNode(parentFolderUid, name, metadata); + + blockVerifier = new BlockVerifier(api, cryptoService, revisionDraft.nodeKeys.key, revisionDraft.nodeRevisionUid); + await blockVerifier.loadVerificationData(); + } catch (error: unknown) { + queue.releaseCapacity(); + if (revisionDraft) { + await manager.deleteDraftNode(revisionDraft.nodeUid); + } + uploadTelemetry.uploadInitFailed(error, metadata.expectedSize); + throw error; } - const { nodeRevisionUid } = await api.createDraft(parentFolderUid, nodeData); - return new Fileuploader(nodeKey, nodeRevisionUid); + + const onFinish = async (failure: boolean) => { + queue.releaseCapacity(); + if (failure) { + await manager.deleteDraftNode(revisionDraft.nodeUid); + } + } + + return new Fileuploader( + uploadTelemetry, + api, + cryptoService, + blockVerifier, + revisionDraft, + metadata, + onFinish, + signal, + ); } return { diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 92f0ee15..88452497 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,5 +1,94 @@ import { PrivateKey, SessionKey } from "../../crypto"; +import { ThumbnailType } from "../../interface"; +export type NodeRevisionDraft = { + nodeUid: string, + nodeRevisionUid: string, + nodeKeys: NodeRevisionDraftKeys, +} + +export type NodeRevisionDraftKeys = { + key: PrivateKey, + contentKeyPacketSessionKey: SessionKey, + signatureAddress: NodeCryptoSignatureAddress, +} + +export type NodeCrypto = { + nodeKeys: { + encrypted: { + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + }, + decrypted: { + passphrase: string, + key: PrivateKey, + passphraseSessionKey: SessionKey, + }, + }, + contentKey: { + encrypted: { + base64ContentKeyPacket: string, + armoredContentKeyPacketSignature: string, + }, + decrypted: { + contentKeyPacketSessionKey: SessionKey, + }, + }, + encryptedNode: { + encryptedName: string, + hash: string, + }, + signatureAddress: NodeCryptoSignatureAddress, +} + +export type NodeCryptoSignatureAddress = { + email: string, + addressId: string, + addressKey: PrivateKey, +} + +export type EncryptedBlockMetadata = { + encryptedSize: number, + originalSize: number, + hash: Uint8Array, +} + +export type EncryptedBlock = EncryptedBlockMetadata & { + index: number, + encryptedData: Uint8Array, + armoredSignature: string, + verificationToken: Uint8Array, +} + +export type EncryptedThumbnail = EncryptedBlockMetadata & { + type: ThumbnailType, + encryptedData: Uint8Array, +} + +export type UploadTokens = { + blockTokens: { + index: number, + bareUrl: string, + token: string, + }[], + thumbnailTokens: { + type: ThumbnailType, + bareUrl: string, + token: string, + }[], +} + +/** + * Interface describing the dependencies to the nodes module. + */ export interface NodesService { - getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, passphraseSessionKey: SessionKey }>, + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, passphraseSessionKey: SessionKey, hashKey?: Uint8Array }>, +} + +/** + * Interface describing the dependencies to the shares module. + */ +export interface SharesService { + getVolumeEmailKey(volumeId: string): Promise<{ email: string, addressId: string, addressKey: PrivateKey }>, } diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts new file mode 100644 index 00000000..1be91542 --- /dev/null +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -0,0 +1,269 @@ +import { ValidationError } from "../../errors"; +import { ProtonDriveTelemetry, UploadMetadata } from "../../interface"; +import { getMockTelemetry } from "../../tests/telemetry"; +import { ErrorCode } from "../apiService"; +import { UploadAPIService } from "./apiService"; +import { UploadCryptoService } from "./cryptoService"; +import { NodesService } from "./interface"; +import { UploadManager } from './manager'; + +describe("UploadManager", () => { + let telemetry: ProtonDriveTelemetry; + let apiService: UploadAPIService; + let cryptoService: UploadCryptoService; + let nodesService: NodesService; + + let manager: UploadManager; + + beforeEach(() => { + telemetry = getMockTelemetry(); + // @ts-expect-error No need to implement all methods for mocking + apiService = { + createDraft: jest.fn().mockResolvedValue({ + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + }), + deleteDraft: jest.fn(), + checkAvailableHashes: jest.fn().mockResolvedValue({ + availalbleHashes: ["name1Hash"], + pendingHashes: [], + }), + } + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + generateFileCrypto: jest.fn().mockResolvedValue({ + nodeKeys: { + decrypted: { key: 'newNode:key' }, + encrypted: { + armoredKey: 'newNode:armoredKey', + armoredPassphrase: 'newNode:armoredPassphrase', + armoredPassphraseSignature: 'newNode:armoredPassphraseSignature', + }, + }, + contentKey: { + decrypted: { contentKeyPacketSessionKey: 'newNode:ContentKeyPacketSessionKey' }, + encrypted: { + base64ContentKeyPacket: 'newNode:base64ContentKeyPacket', + armoredContentKeyPacketSignature: 'newNode:armoredContentKeyPacketSignature', + }, + }, + encryptedNode: { + encryptedName: "newNode:encryptedName", + hash: "newNode:hash", + }, + signatureAddress: { + email: "signatureEmail", + }, + }), + generateNameHashes: jest.fn().mockResolvedValue([{ + name: "name1", + hash: "name1Hash", + }, { + name: "name2", + hash: "name2Hash", + }, { + name: "name3", + hash: "name3Hash", + }]), + } + nodesService = { + getNodeKeys: jest.fn().mockResolvedValue({ + hashKey: 'parentNode:hashKey', + key: 'parentNode:nodekey', + }), + } + + manager = new UploadManager(telemetry, apiService, cryptoService, nodesService); + }); + + describe("createDraftNode", () => { + it("should fail to create node in non-folder parent", async () => { + nodesService.getNodeKeys = jest.fn().mockResolvedValue({ hashKey: undefined }); + + const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); + await expect(result).rejects.toThrow("Creating folders in non-folders is not allowed"); + }); + + it("should create draft node", async () => { + const result = await manager.createDraftNode("parentUid", "name", { + mimeType: "myMimeType", + expectedSize: 123456, + } as UploadMetadata); + + expect(result).toEqual({ + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + nodeKeys: { + key: "newNode:key", + contentKeyPacketSessionKey: "newNode:ContentKeyPacketSessionKey", + signatureAddress: { + email: "signatureEmail", + }, + }, + }); + expect(apiService.createDraft).toHaveBeenCalledWith("parentUid", { + armoredEncryptedName: "newNode:encryptedName", + hash: "newNode:hash", + mimeType: "myMimeType", + intendedUploadSize: 123456, + armoredNodeKey: "newNode:armoredKey", + armoredNodePassphrase: "newNode:armoredPassphrase", + armoredNodePassphraseSignature: "newNode:armoredPassphraseSignature", + base64ContentKeyPacket: "newNode:base64ContentKeyPacket", + armoredContentKeyPacketSignature: "newNode:armoredContentKeyPacketSignature", + signatureEmail: "signatureEmail", + }); + expect(apiService.checkAvailableHashes).not.toHaveBeenCalled(); + }); + + it("should handle existing draft by deleting and trying again", async () => { + let hashChecked = false; + apiService.createDraft = jest.fn().mockImplementation(() => { + if (!hashChecked) { + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS); + } + return { + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + }; + }); + + apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { + if (!hashChecked) { + hashChecked = true; + return { + availalbleHashes: ["name1Hash"], + pendingHashes: [{ + hash: "newNode:hash", + nodeUid: "nodeUidToDelete" + }], + } + } + return { + availalbleHashes: ["name1Hash"], + pendingHashes: [], + } + }); + + const result = await manager.createDraftNode("parentUid", "name", {} as UploadMetadata); + + expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); + expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + nodeKeys: { + key: "newNode:key", + contentKeyPacketSessionKey: "newNode:ContentKeyPacketSessionKey", + signatureAddress: { + email: "signatureEmail", + }, + }, + }); + expect(apiService.deleteDraft).toHaveBeenCalledWith("nodeUidToDelete"); + }); + + it("should handle error when deleting existing draft", async () => { + let hashChecked = false; + apiService.createDraft = jest.fn().mockImplementation(() => { + if (!hashChecked) { + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS); + } + return { + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + }; + }); + apiService.deleteDraft = jest.fn().mockImplementation(() => { + throw new Error("Failed to delete draft"); + }); + + apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { + if (!hashChecked) { + hashChecked = true; + return { + availalbleHashes: ["name1Hash"], + pendingHashes: [{ + hash: "newNode:hash", + nodeUid: "nodeUidToDelete" + }], + } + } + return { + availalbleHashes: ["name1Hash"], + pendingHashes: [], + } + }); + + const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); + + await expect(result).rejects.toThrow("Draft already exists"); + expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); + expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); + }); + + it("should handle existing name by providing available name", async () => { + let count = 0; + apiService.createDraft = jest.fn().mockImplementation(() => { + if (count === 0) { + count++; + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS); + } + return { + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + }; + }); + + const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); + + await expect(result).rejects.toThrow("Draft already exists"); + expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); + + try { + await result; + } catch (error: any) { + expect(error.availableName).toBe("name1"); + } + }); + + it("should handle existing name by providing available name when there is too many conflicts", async () => { + let hashChecked = false; + apiService.createDraft = jest.fn().mockImplementation(() => { + if (!hashChecked) { + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS); + } + return { + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + }; + }); + + apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { + if (!hashChecked) { + hashChecked = true; + return { + // First page has no available hashes + availalbleHashes: [], + pendingHashes: [], + } + } + return { + availalbleHashes: ["name3Hash"], + pendingHashes: [], + } + }); + + const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); + + await expect(result).rejects.toThrow("Draft already exists"); + expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(2); + + try { + await result; + } catch (error: any) { + expect(error.availableName).toBe("name3"); + } + }); + }); +}); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts new file mode 100644 index 00000000..64a267fa --- /dev/null +++ b/js/sdk/src/internal/upload/manager.ts @@ -0,0 +1,210 @@ +import { c } from "ttag"; + +import { Logger, ProtonDriveTelemetry, UploadMetadata } from "../../interface"; +import { ValidationError, NodeAlreadyExistsValidationError } from "../../errors"; +import { UploadAPIService } from "./apiService"; +import { UploadCryptoService } from "./cryptoService"; +import { NodeRevisionDraft, NodesService, NodeCrypto } from "./interface"; +import { ErrorCode } from "../apiService"; + +/** + * UploadManager is responsible for creating and deleting draft nodes + * on the server. It handles the creation of draft nodes, including + * generating the necessary cryptographic keys and metadata. + */ +export class UploadManager { + private logger: Logger; + + constructor( + telemetry: ProtonDriveTelemetry, + private apiService: UploadAPIService, + private cryptoService: UploadCryptoService, + private nodesService: NodesService, + ) { + this.logger = telemetry.getLogger('upload'); + this.apiService = apiService; + this.cryptoService = cryptoService; + this.nodesService = nodesService; + } + + async createDraftNode(parentFolderUid: string, name: string, metadata: UploadMetadata): Promise { + const parentKeys = await this.nodesService.getNodeKeys(parentFolderUid); + if (!parentKeys.hashKey) { + throw new ValidationError(c('Error').t`Creating folders in non-folders is not allowed`); + } + + const generatedNodeCrypto = await this.cryptoService.generateFileCrypto( + parentFolderUid, + { key: parentKeys.key, hashKey: parentKeys.hashKey }, + name, + ); + + const { nodeUid, nodeRevisionUid } = await this.createDraftOnAPI( + parentFolderUid, + parentKeys.hashKey, + name, + metadata, + generatedNodeCrypto, + ); + + return { + nodeUid, + nodeRevisionUid, + nodeKeys: { + key: generatedNodeCrypto.nodeKeys.decrypted.key, + contentKeyPacketSessionKey: generatedNodeCrypto.contentKey.decrypted.contentKeyPacketSessionKey, + signatureAddress: generatedNodeCrypto.signatureAddress, + }, + }; + } + + private async createDraftOnAPI( + parentFolderUid: string, + parentHashKey: Uint8Array, + name: string, + metadata: UploadMetadata, + generatedNodeCrypto: NodeCrypto, + ): Promise<{ + nodeUid: string, + nodeRevisionUid: string, + }> { + try { + const result = await this.apiService.createDraft(parentFolderUid, { + armoredEncryptedName: generatedNodeCrypto.encryptedNode.encryptedName, + hash: generatedNodeCrypto.encryptedNode.hash, + mimeType: metadata.mimeType, + intendedUploadSize: metadata.expectedSize, + armoredNodeKey: generatedNodeCrypto.nodeKeys.encrypted.armoredKey, + armoredNodePassphrase: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphrase, + armoredNodePassphraseSignature: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphraseSignature, + base64ContentKeyPacket: generatedNodeCrypto.contentKey.encrypted.base64ContentKeyPacket, + armoredContentKeyPacketSignature: generatedNodeCrypto.contentKey.encrypted.armoredContentKeyPacketSignature, + signatureEmail: generatedNodeCrypto.signatureAddress.email, + // TODO: client UID + }); + return result; + } catch (error: unknown) { + if (error instanceof ValidationError) { + if (error.code === ErrorCode.ALREADY_EXISTS) { + this.logger.info(`Node with given name already exists`); + const availableName = await this.findAvailableName( + parentFolderUid, + parentHashKey, + name, + generatedNodeCrypto.encryptedNode.hash, + ); + + // If there is existing draft created by this client, + // automatically delete it and try to create a new one + // with the same name again. + if (availableName.existingDraftNodeUid) { + let deleteFailed = false; + try { + this.logger.warn(`Deleting existing draft node ${availableName.existingDraftNodeUid}`); + await this.apiService.deleteDraft(availableName.existingDraftNodeUid); + } catch (deleteDraftError: unknown) { + // Do not throw, let return the next available name to the client. + deleteFailed = true; + this.logger.error('Failed to delete existing draft node', deleteDraftError); + } + if (!deleteFailed) { + return this.createDraftOnAPI(parentFolderUid, parentHashKey, name, metadata, generatedNodeCrypto); + } + } + + // If there is existing node, return special error + // that includes the available name the client can use. + throw new NodeAlreadyExistsValidationError( + error.message, + error.code, + availableName.availableName, + ); + } + } + throw error; + } + } + + private async findAvailableName(parentFolderUid: string, parentHashKey: Uint8Array, name: string, nameHash: string): Promise<{ + availableName: string, + existingDraftNodeUid?: string, + }> { + const [namePart, extension] = splitExtension(name); + + const batchSize = 10; + let startIndex = 1; + while (true) { + const namesToCheck = []; + for (let i = startIndex; i < startIndex + batchSize; i++) { + namesToCheck.push(joinNameAndExtension(namePart, i, extension)); + } + + const hashesToCheck = await this.cryptoService.generateNameHashes(parentHashKey, namesToCheck); + + const { pendingHashes, availalbleHashes } = await this.apiService.checkAvailableHashes( + parentFolderUid, + [ + ...hashesToCheck.map(({ hash }) => hash), + // Adding the current name hash to get the existing draft + // node UID if it exists. + ...startIndex ? [nameHash] : [], + ], + ); + + if (!availalbleHashes.length) { + startIndex += batchSize; + continue; + } + + const availableHash = hashesToCheck.find(({ hash }) => hash === availalbleHashes[0]); + if (!availableHash) { + throw Error('Backend returned unexpected hash'); + } + + // TODO: use client UID to ensure its own pending draft + const ownPendingHash = pendingHashes.find(({ hash }) => hash === nameHash); + return { + availableName: availableHash.name, + existingDraftNodeUid: ownPendingHash?.nodeUid, + } + } + } + + async deleteDraftNode(nodeUid: string): Promise { + try { + await this.apiService.deleteDraft(nodeUid); + } catch (error: unknown) { + // Only log the error but do not fail the operation as we are + // deleting draft only when somethign fails and original error + // will bubble up. + this.logger.error('Failed to delete draft node', error); + } + } +} + +/** + * Split a filename into `[name, extension]` + */ +function splitExtension(filename = ''): [string, string] { + const endIdx = filename.lastIndexOf('.'); + if (endIdx === -1 || endIdx === filename.length-1) { + return [filename, '']; + } + return [filename.slice(0, endIdx), filename.slice(endIdx + 1)]; +}; + +/** + * Join a filename into `name (index).extension` + */ +function joinNameAndExtension(name: string, index: number, extension: string): string { + if (!name && !extension) { + return `(${index})`; + } + if (!name) { + return `(${index}).${extension}`; + } + if (!extension) { + return `${name} (${index})`; + } + return `${name} (${index}).${extension}`; +} diff --git a/js/sdk/src/internal/upload/queue.ts b/js/sdk/src/internal/upload/queue.ts index 159a283e..850500b0 100644 --- a/js/sdk/src/internal/upload/queue.ts +++ b/js/sdk/src/internal/upload/queue.ts @@ -1,8 +1,31 @@ +import { waitForCondition } from '../wait'; + +/** + * A queue that limits the number of concurrent uploads. + * + * This is used to limit the number of concurrent uploads to avoid + * overloading the server, or get rate limited. + * + * Each file upload consumes memory and is limited by the number of + * concurrent block uploads for each file. + * + * This queue is straitforward and does not have any priority mechanism + * or other features, such as limiting total number of blocks being + * uploaded. That is something we want to add in the future to be + * more performant for many small file uploads. + */ +const MAX_CONCURRENT_UPLOADS = 5; + export class UploadQueue { - constructor() { + private capacity = 0; + + // FIXME: use expected size to control the size of the queue + async waitForCapacity(signal?: AbortSignal) { + await waitForCondition(() => this.capacity < MAX_CONCURRENT_UPLOADS, signal); + this.capacity++; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async waitForCapacity(expectedSize: number, signal?: AbortSignal) { + releaseCapacity() { + this.capacity--; } } diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts new file mode 100644 index 00000000..536cbd00 --- /dev/null +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -0,0 +1,120 @@ +import { RateLimitedError, ValidationError, IntegrityError } from '../../errors'; +import { ProtonDriveTelemetry } from '../../interface'; +import { APIHTTPError } from '../apiService'; +import { UploadTelemetry } from './telemetry'; + +describe('UploadTelemetry', () => { + let mockTelemetry: jest.Mocked; + let uploadTelemetry: UploadTelemetry; + + beforeEach(() => { + mockTelemetry = { + logEvent: jest.fn(), + getLogger: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }), + } as unknown as jest.Mocked; + + uploadTelemetry = new UploadTelemetry(mockTelemetry); + }); + + it('should log failure during init (excludes uploaded size)', () => { + uploadTelemetry.uploadInitFailed(new Error('Failed'), 1000); + + expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + eventName: "upload", + context: "own_volume", + uploadedSize: 0, + expectedSize: 1000, + error: "unknown", + }); + }); + + it('should log failure upload', () => { + uploadTelemetry.uploadFailed(new Error('Failed'), 500, 1000); + + expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + eventName: "upload", + context: "own_volume", + uploadedSize: 500, + expectedSize: 1000, + error: "unknown", + }); + }); + + it('should log successful upload (excludes error)', () => { + uploadTelemetry.uploadFinished(1000); + + expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + eventName: "upload", + context: "own_volume", + uploadedSize: 1000, + expectedSize: 1000, + }); + }); + + describe('detect error category', () => { + const verifyErrorCategory = (error: string) => { + expect(mockTelemetry.logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + error, + }) + ); + }; + + it('should ignore ValidationError', () => { + const error = new ValidationError('Validation error'); + uploadTelemetry.uploadFailed(error, 500, 1000); + expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); + }); + + it('should ignore AbortError', () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + uploadTelemetry.uploadFailed(error, 500, 1000); + + expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); + }); + + it('should detect "rate_limited" error for RateLimitedError', () => { + const error = new RateLimitedError('Rate limited'); + uploadTelemetry.uploadFailed(error, 500, 1000); + verifyErrorCategory('rate_limited'); + }); + + it('should detect "integrity_error" for IntegrityError', () => { + const error = new IntegrityError('Integrity check failed'); + uploadTelemetry.uploadFailed(error, 500, 1000); + verifyErrorCategory('integrity_error'); + }); + + it('should detect "4xx" error for APIHTTPError with 4xx status code', () => { + const error = new APIHTTPError('Client error', 404); + uploadTelemetry.uploadFailed(error, 500, 1000); + verifyErrorCategory('4xx'); + }); + + it('should detect "5xx" error for APIHTTPError with 5xx status code', () => { + const error = new APIHTTPError('Server error', 500); + uploadTelemetry.uploadFailed(error, 500, 1000); + verifyErrorCategory('5xx'); + }); + + it('should detect "server_error" for TimeoutError', () => { + const error = new Error('Timeout'); + error.name = 'TimeoutError'; + uploadTelemetry.uploadFailed(error, 500, 1000); + verifyErrorCategory('server_error'); + }); + + it('should detect "network_error" for NetworkError', () => { + const error = new Error('Network error'); + error.name = 'NetworkError'; + uploadTelemetry.uploadFailed(error, 500, 1000); + verifyErrorCategory('network_error'); + }); + }); +}); \ No newline at end of file diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts new file mode 100644 index 00000000..04b42eb5 --- /dev/null +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -0,0 +1,98 @@ +import { RateLimitedError, ValidationError, IntegrityError } from "../../errors"; +import { ProtonDriveTelemetry, MetricsUploadErrorType } from "../../interface"; +import { LoggerWithPrefix } from "../../telemetry"; +import { APIHTTPError } from '../apiService'; + +export class UploadTelemetry { + constructor(private telemetry: ProtonDriveTelemetry) { + this.telemetry = telemetry; + } + + getLoggerForRevision(revisionUid: string) { + const logger = this.telemetry.getLogger("upload"); + return new LoggerWithPrefix(logger, `revision ${revisionUid}`); + } + + uploadInitFailed(error: unknown, expectedSize: number) { + const errorCategory = getErrorCategory(error); + + // No error category means ignored error from telemetry. + // For example, aborted request. + if (!errorCategory) { + return; + } + + this.sendTelemetry({ + uploadedSize: 0, + expectedSize, + error: errorCategory, + }); + } + + uploadFailed(error: unknown, uploadedSize: number, expectedSize: number) { + const errorCategory = getErrorCategory(error); + + // No error category means ignored error from telemetry. + // For example, aborted request. + if (!errorCategory) { + return; + } + + this.sendTelemetry({ + uploadedSize, + expectedSize, + error: errorCategory, + }); + } + + uploadFinished(uploadedSize: number) { + this.sendTelemetry({ + uploadedSize, + expectedSize: uploadedSize, + }); + } + + private sendTelemetry(options: { + uploadedSize: number, + expectedSize: number, + error?: MetricsUploadErrorType, + }) { + this.telemetry.logEvent({ + eventName: 'upload', + context: 'own_volume', // TODO: pass context + ...options, + }); + } +} + +function getErrorCategory(error: unknown): MetricsUploadErrorType | undefined { + if (error instanceof ValidationError) { + return undefined; + } + if (error instanceof RateLimitedError) { + return 'rate_limited'; + } + if (error instanceof IntegrityError) { + return 'integrity_error'; + } + if (error instanceof APIHTTPError) { + if (error.statusCode >= 400 && error.statusCode < 500) { + return '4xx'; + } + if (error.statusCode >= 500) { + return '5xx'; + } + } + if (error instanceof Error) { + if (error.name === 'TimeoutError') { + return 'server_error'; + } + if (error.name === 'OfflineError' || error.name === 'NetworkError' || error.message?.toLowerCase() === 'network error') { + return 'network_error'; + } + if (error.name === 'AbortError') { + return undefined; + } + } + return 'unknown'; +} diff --git a/js/sdk/src/internal/utils.ts b/js/sdk/src/internal/utils.ts new file mode 100644 index 00000000..0c3959ce --- /dev/null +++ b/js/sdk/src/internal/utils.ts @@ -0,0 +1,9 @@ +export function mergeUint8Arrays(arrays: Uint8Array[]) { + const length = arrays.reduce((sum, arr) => sum + arr.length, 0); + const chunksAll = new Uint8Array(length); + arrays.reduce((position, arr) => { + chunksAll.set(arr, position); + return position + arr.length; + }, 0); + return chunksAll; +} diff --git a/js/sdk/src/internal/download/wait.test.ts b/js/sdk/src/internal/wait.test.ts similarity index 100% rename from js/sdk/src/internal/download/wait.test.ts rename to js/sdk/src/internal/wait.test.ts diff --git a/js/sdk/src/internal/download/wait.ts b/js/sdk/src/internal/wait.ts similarity index 64% rename from js/sdk/src/internal/download/wait.ts rename to js/sdk/src/internal/wait.ts index e770db7d..6e9f6ab5 100644 --- a/js/sdk/src/internal/download/wait.ts +++ b/js/sdk/src/internal/wait.ts @@ -1,4 +1,4 @@ -import { AbortError } from "../../errors"; +import { AbortError } from "../errors"; const WAIT_TIME = 50; @@ -16,3 +16,11 @@ export function waitForCondition(callback: () => boolean, signal?: AbortSignal) waitForCondition(); }); } + +export async function waitSeconds(seconds: number){ + return wait(seconds * 1000); +} + +export async function wait(miliseconds: number){ + return new Promise((resolve) => setTimeout(resolve, miliseconds)); +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 153d8c1e..f8d14a6a 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,4 +1,22 @@ -import { ProtonDriveClientContructorParameters, ProtonDriveClientInterface, NodeOrUid, MaybeNode, ShareNodeSettings, UnshareNodeSettings, UploadMetadata, Logger, NodeResult, Revision, ProtonInvitationWithNode, ShareResult, NonProtonInvitationOrUid, ProtonInvitationOrUid, FileDownloader, MaybeMissingNode } from './interface'; +import { + Logger, + ProtonDriveClientContructorParameters, + ProtonDriveClientInterface, + NodeOrUid, + MaybeNode, + MaybeMissingNode, + NodeResult, + Revision, + ShareNodeSettings, + UnshareNodeSettings, + ProtonInvitationOrUid, + NonProtonInvitationOrUid, + ProtonInvitationWithNode, + ShareResult, + UploadMetadata, + FileDownloader, + Fileuploader, +} from './interface'; import { DriveCrypto } from './crypto'; import { DriveAPIService } from './internal/apiService'; import { initSharesModule } from './internal/shares'; @@ -47,7 +65,7 @@ export class ProtonDriveClient implements Partial { this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access); this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.nodes.access, this.nodes.revisions); - this.upload = initUploadModule(apiService, cryptoModule, this.nodes.access); + this.upload = initUploadModule(telemetry, apiService, cryptoModule, shares, this.nodes.access); } /** @@ -451,7 +469,36 @@ export class ProtonDriveClient implements Partial { return this.download.getFileRevisionDownloader(nodeRevisionUid, signal); } - async getFileUploader(parentFolderUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal) { + /** + * Get the file uploader to upload a new file. For uploading a new + * revision, use `getFileRevisionUploader` instead. + * + * The number of ongoing uploads is limited. If the limit is reached, + * the upload is queued and started when the slot is available. It is + * recommended to not start too many uploads at once to avoid having + * many open promises. + * + * The file uploader is not reusable. If the upload is interrupted, + * a new file uploader must be created. + * + * Client should not automatically retry the upload if it fails. The + * upload should be initiated by the user again. The uploader does + * automatically retry the upload if it fails due to network issues, + * or if the server is temporarily unavailable. + * + * Example usage: + * + * ```typescript + * const uploader = await client.getFileUploader(parentFolderUid, name, metadata, signal); + * const uploadController = uploader.writeStream(stream, thumbnails, (uploadedBytes) => { ... }); + * + * signalController.abort(); // to cancel + * uploadController.pause(); // to pause + * uploadController.resume(); // to resume + * const nodeUid = await uploadController.completion(); // to await completion + * ``` + */ + async getFileUploader(parentFolderUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal): Promise { this.logger.info(`Getting file uploader for parent ${getUid(parentFolderUid)}`); return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } From 76c01f67b6fccd4384a745f8a120cef378f187c3 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Apr 2025 08:55:18 +0000 Subject: [PATCH 063/791] Add SDK version to headers --- js/sdk/src/index.ts | 1 + js/sdk/src/internal/apiService/apiService.test.ts | 1 + js/sdk/src/internal/apiService/apiService.ts | 3 ++- js/sdk/src/version.ts | 3 +++ 4 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 js/sdk/src/version.ts diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 097c1052..d812832f 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -9,6 +9,7 @@ export * from './cache'; export * from './errors'; export { OpenPGPCrypto, OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy } from './crypto'; export { ProtonDriveClient } from './protonDriveClient'; +export { VERSION } from './version'; /** * Provides the node UID for the given raw volume and node IDs. diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 1dc1b435..1db624d4 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -49,6 +49,7 @@ describe("DriveAPIService", () => { "Accept": "application/vnd.protonmail.v1+json", "Content-Type": "application/json", "Language": 'en', + "x-pm-drive-sdk-version": `js@${process.env.npm_package_version}`, }).entries())); expect(await request.text()).toEqual(data ? JSON.stringify(data) : ""); } diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 2369df16..61065834 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -1,5 +1,6 @@ import { c } from 'ttag'; +import { VERSION } from "../../version"; import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from "../../interface"; import { AbortError, ServerError, RateLimitedError, SDKError } from '../../errors'; import { waitSeconds } from '../wait'; @@ -110,11 +111,11 @@ export class DriveAPIService { ): Promise { const request = new Request(`${this.baseUrl}/${url}`, { method: method || 'GET', - // TODO: set SDK version (or set via http client at init?) headers: new Headers({ "Accept": "application/vnd.protonmail.v1+json", "Content-Type": "application/json", "Language": this.language, + "x-pm-drive-sdk-version": `js@${VERSION}`, }), body: data && JSON.stringify(data), }); diff --git a/js/sdk/src/version.ts b/js/sdk/src/version.ts new file mode 100644 index 00000000..86d9e6a2 --- /dev/null +++ b/js/sdk/src/version.ts @@ -0,0 +1,3 @@ +import packageJson from '../package.json' with { type: "json"}; + +export const VERSION = packageJson.version; From 5045295c0f0356ebada4b90b7fe124e436c17d62 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Apr 2025 10:39:41 +0000 Subject: [PATCH 064/791] Update docs examples --- js/sdk/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 js/sdk/README.md diff --git a/js/sdk/README.md b/js/sdk/README.md new file mode 100644 index 00000000..2c6efa2b --- /dev/null +++ b/js/sdk/README.md @@ -0,0 +1,17 @@ +# Drive SDK for web + +Use only what is exported by the library. This is the public supported API of the SDK. Anything else is internal implementation that can change without warning. + +Start by creating instance of the `ProtonDriveClient`. That instance has then available many methods to access nodes, devices, upload and download content, or manage sharing. + +```js +import { ProtonDriveClient, MemoryCache, OpenPGPCryptoWithCryptoProxy } from 'proton-drive-sdk'; + +const sdk = new ProtonDriveClient({ + httpClient, + entitiesCache: new MemoryCache(), + cryptoCache: new MemoryCache(), + account, + openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoProxy), +}); +``` From 4cdb25995b1182b92f970afb826557146a58e7a5 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Apr 2025 08:12:05 +0200 Subject: [PATCH 065/791] handle unknown nodes --- js/sdk/src/internal/nodes/apiService.test.ts | 52 ++++-- js/sdk/src/internal/nodes/apiService.ts | 150 ++++++++++-------- js/sdk/src/internal/nodes/nodesAccess.test.ts | 50 +++--- js/sdk/src/internal/nodes/nodesAccess.ts | 7 +- 4 files changed, 153 insertions(+), 106 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 92c35161..a03a28c2 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -143,40 +143,40 @@ describe("nodeAPIService", () => { api = new NodeAPIService(getMockLogger(), apiMock); }); - describe('getNodes', () => { - async function testGetNodes(mockedLink: any, expectedNode: any) { + describe('iterateNodes', () => { + async function testIterateNodes(mockedLink: any, expectedNode: any) { // @ts-expect-error Mocking for testing purposes apiMock.post = jest.fn(async () => Promise.resolve({ Links: [mockedLink], })); - const nodes = await api.getNodes(['volumeId~nodeId']); + const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'])); expect(nodes).toStrictEqual([expectedNode]); } it('should get folder node', async () => { - await testGetNodes( + await testIterateNodes( generateAPIFolderNode(), generateFolderNode(), ); }); it('should get root folder node', async () => { - await testGetNodes( + await testIterateNodes( generateAPIFolderNode({ ParentLinkID: null }), generateFolderNode({ parentUid: undefined }), ); }); it('should get file node', async () => { - await testGetNodes( + await testIterateNodes( generateAPIFileNode(), generateFileNode(), ); }); it('should get shared node', async () => { - await testGetNodes( + await testIterateNodes( generateAPIFolderNode({}, { Sharing: { ShareID: 'shareId', @@ -194,7 +194,7 @@ describe("nodeAPIService", () => { }); it('should get shared node with unknown permissions', async () => { - await testGetNodes( + await testIterateNodes( generateAPIFolderNode({}, { Sharing: { ShareID: 'shareId', @@ -212,7 +212,7 @@ describe("nodeAPIService", () => { }); it('should get trashed file node', async () => { - await testGetNodes( + await testIterateNodes( generateAPIFileNode({ TrashTime: 123456, }), @@ -221,6 +221,40 @@ describe("nodeAPIService", () => { }), ); }); + + it('should get all recognised nodes before throwing error', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async () => Promise.resolve({ + Links: [ + generateAPIFolderNode(), + // Type 42 is not recognised - should throw error. + generateAPIFolderNode({ Type: 42 }), + // Type 43 is not recognised - should throw error. + generateAPIFileNode({ Type: 43 }), + generateAPIFileNode(), + ], + })); + + const generator = api.iterateNodes(['volumeId~nodeId']); + + const node1 = await generator.next(); + expect(node1.value).toStrictEqual(generateFolderNode()); + + // Second node is actually third, second is skipped and throwed at the end. + const node2 = await generator.next(); + expect(node2.value).toStrictEqual(generateFileNode()); + + const node3 = generator.next(); + expect(node3).rejects.toThrow('Failed to load some nodes'); + try { + await node3; + } catch (error: any) { + expect(error.cause).toEqual([ + new Error('Unknown node type: 42'), + new Error('Unknown node type: 43'), + ]); + } + }); }); describe('trashNodes', () => { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 44df15a1..65bbfbdc 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,6 +1,6 @@ import { c } from "ttag"; -import { ValidationError } from "../../errors"; +import { SDKError, ValidationError } from "../../errors"; import { Logger, NodeResult } from "../../interface"; import { RevisionState } from "../../interface/nodes"; import { DriveAPIService, drivePaths, isCodeOk, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; @@ -57,12 +57,15 @@ export class NodeAPIService { } async getNode(nodeUid: string, signal?: AbortSignal): Promise { - const nodes = await this.getNodes([nodeUid], signal); - return nodes[0]; + const nodesGenerator = this.iterateNodes([nodeUid], signal); + const result = await nodesGenerator.next(); + nodesGenerator.return("finish"); + return result.value; } // Improvement requested: support multiple volumes. - async getNodes(nodeUids: string[], signal?: AbortSignal): Promise { + // Improvement requested: split into multiple calls for many nodes. + async* iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Loading items`, nodeIds); @@ -70,68 +73,24 @@ export class NodeAPIService { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); - const nodes = response.Links.map((link) => { - const baseNodeMetadata = { - // Internal metadata - hash: link.Link.NameHash || undefined, - encryptedName: link.Link.Name, - - // Basic node metadata - uid: makeNodeUid(volumeId, link.Link.LinkID), - parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, - type: nodeTypeNumberToNodeType(this.logger, link.Link.Type), - createdDate: new Date(link.Link.CreateTime*1000), - trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, - - // Sharing node metadata - shareId: link.Sharing?.ShareID || undefined, - isShared: !!link.Sharing, - directMemberRole: permissionsToDirectMemberRole(this.logger, link.Membership?.Permissions), - } - const baseCryptoNodeMetadata = { - signatureEmail: link.Link.SignatureEmail || undefined, - nameSignatureEmail: link.Link.NameSignatureEmail || undefined, - armoredKey: link.Link.NodeKey, - armoredNodePassphrase: link.Link.NodePassphrase, - armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, + // If the API returns node that is not recognised, it is returned as + // an error, but first all nodes that are recognised are yielded. + // Thus we capture all errors and throw them at the end of iteration. + const errors = []; + + for (const link of response.Links) { + try { + yield linkToEncryptedNode(this.logger, volumeId, link); + } catch (error: unknown) { + this.logger.error(`Failed to transform node ${link.Link.LinkID}`, error); + errors.push(error); } + } - if (link.Link.Type === 2 && link.File && link.File.ActiveRevision) { - return { - ...baseNodeMetadata, - mimeType: link.File.MediaType || undefined, - encryptedCrypto: { - ...baseCryptoNodeMetadata, - file: { - base64ContentKeyPacket: link.File.ContentKeyPacket, - armoredContentKeyPacketSignature: link.File.ContentKeyPacketSignature || undefined, - }, - activeRevision: { - uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.File.ActiveRevision.RevisionID), - state: RevisionState.Active, - createdDate: new Date(link.File.ActiveRevision.CreateTime*1000), - signatureEmail: link.File.ActiveRevision.SignatureEmail || undefined, - armoredExtendedAttributes: link.File.ActiveRevision.XAttr || undefined, - }, - }, - } - } - if (link.Link.Type === 1 && link.Folder) { - return { - ...baseNodeMetadata, - encryptedCrypto: { - ...baseCryptoNodeMetadata, - folder: { - armoredExtendedAttributes: link.Folder.XAttr || undefined, - armoredHashKey: link.Folder.NodeHashKey as string, - }, - }, - } - } - // TODO: do not fail if one node is wrong - throw new Error(`Unknown node type: ${link.Link.Type}`); - }); - return nodes; + if (errors.length) { + this.logger.warn(`Failed to load ${errors.length} nodes`); + throw new SDKError(c('Error').t`Failed to load some nodes`, { cause: errors }); + } } // Improvement requested: load next page sooner before all IDs are yielded. @@ -395,6 +354,69 @@ function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: } } +function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLinksMetadataResponse['Links'][0]): EncryptedNode { + const baseNodeMetadata = { + // Internal metadata + hash: link.Link.NameHash || undefined, + encryptedName: link.Link.Name, + + // Basic node metadata + uid: makeNodeUid(volumeId, link.Link.LinkID), + parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, + type: nodeTypeNumberToNodeType(logger, link.Link.Type), + createdDate: new Date(link.Link.CreateTime*1000), + trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, + + // Sharing node metadata + shareId: link.Sharing?.ShareID || undefined, + isShared: !!link.Sharing, + directMemberRole: permissionsToDirectMemberRole(logger, link.Membership?.Permissions), + } + const baseCryptoNodeMetadata = { + signatureEmail: link.Link.SignatureEmail || undefined, + nameSignatureEmail: link.Link.NameSignatureEmail || undefined, + armoredKey: link.Link.NodeKey, + armoredNodePassphrase: link.Link.NodePassphrase, + armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, + } + + if (link.Link.Type === 1 && link.Folder) { + return { + ...baseNodeMetadata, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + folder: { + armoredExtendedAttributes: link.Folder.XAttr || undefined, + armoredHashKey: link.Folder.NodeHashKey as string, + }, + }, + } + } + + if (link.Link.Type === 2 && link.File && link.File.ActiveRevision) { + return { + ...baseNodeMetadata, + mimeType: link.File.MediaType || undefined, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + file: { + base64ContentKeyPacket: link.File.ContentKeyPacket, + armoredContentKeyPacketSignature: link.File.ContentKeyPacketSignature || undefined, + }, + activeRevision: { + uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.File.ActiveRevision.RevisionID), + state: RevisionState.Active, + createdDate: new Date(link.File.ActiveRevision.CreateTime*1000), + signatureEmail: link.File.ActiveRevision.SignatureEmail || undefined, + armoredExtendedAttributes: link.File.ActiveRevision.XAttr || undefined, + }, + }, + } + } + + throw new Error(`Unknown node type: ${link.Link.Type}`); +} + function transformRevisionResponse( volumeId: string, nodeId: string, diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index f38cf443..6418ff76 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -19,7 +19,9 @@ describe('nodesAccess', () => { // @ts-expect-error No need to implement all methods for mocking apiService = { getNode: jest.fn(), - getNodes: jest.fn(), + iterateNodes: jest.fn().mockImplementation(async function* (uids: string[]) { + yield* uids.map((uid => ({ uid, parentUid: 'parentUid' } as EncryptedNode))); + }), iterateChildrenNodeUids: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking @@ -161,7 +163,7 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateChildren('parentUid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).not.toHaveBeenCalled(); - expect(apiService.getNodes).not.toHaveBeenCalled(); + expect(apiService.iterateNodes).not.toHaveBeenCalled(); }); it('should serve children from cache and load stale nodes only', async () => { @@ -172,13 +174,10 @@ describe('nodesAccess', () => { yield { ok: true, uid: node3.uid, node: { ...node3, isStale: true } }; yield { ok: true, uid: node4.uid, node: node4 }; }); - apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( - uids.map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)) - )); const result = await Array.fromAsync(access.iterateChildren('parentUid')); expect(result).toMatchObject([node1, node4, node2, node3]); - expect(apiService.getNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(2); expect(cache.setNode).toHaveBeenCalledTimes(2); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(2); @@ -196,7 +195,7 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateChildren('parentUid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); - expect(apiService.getNodes).not.toHaveBeenCalled(); + expect(apiService.iterateNodes).not.toHaveBeenCalled(); expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); }); @@ -213,14 +212,11 @@ describe('nodesAccess', () => { } throw new Error('Entity not found'); }); - apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( - uids.map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)) - )); const result = await Array.fromAsync(access.iterateChildren('parentUid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); - expect(apiService.getNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); @@ -239,10 +235,10 @@ describe('nodesAccess', () => { } throw new Error('Entity not found'); }); - apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { // Skip first node - make it missing. - uids.slice(1).map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)) - )); + yield* uids.slice(1).map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)); + }); const result = await Array.fromAsync(access.iterateChildren('parentUid')); expect(result).toMatchObject([node2, node3]); @@ -273,21 +269,18 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateTrashedNodes()); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); - expect(apiService.getNodes).not.toHaveBeenCalled(); + expect(apiService.iterateNodes).not.toHaveBeenCalled(); }); it('should load from API', async () => { cache.getNode = jest.fn().mockImplementation((uid: string) => { throw new Error('Entity not found'); }); - apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( - uids.map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)) - )); const result = await Array.fromAsync(access.iterateTrashedNodes()); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); - expect(apiService.getNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); @@ -297,10 +290,10 @@ describe('nodesAccess', () => { cache.getNode = jest.fn().mockImplementation((uid: string) => { throw new Error('Entity not found'); }); - apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { // Skip first node - make it missing. - uids.slice(1).map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)) - )); + yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)); + }); const result = await Array.fromAsync(access.iterateTrashedNodes()); expect(result).toMatchObject([node2, node3, node4]); @@ -324,7 +317,7 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); expect(result).toMatchObject([node1, node2, node3, node4]); - expect(apiService.getNodes).not.toHaveBeenCalled(); + expect(apiService.iterateNodes).not.toHaveBeenCalled(); }); it('should load from API', async () => { @@ -334,13 +327,10 @@ describe('nodesAccess', () => { yield { ok: false, uid: 'node3' }; yield { ok: true, node: node4 }; }); - apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( - uids.map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)) - )); const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); expect(result).toMatchObject([node1, node4, node2, node3]); - expect(apiService.getNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); }); it('should remove from cache if missing on API and return back to caller', async () => { @@ -349,10 +339,10 @@ describe('nodesAccess', () => { yield { ok: false, uid: 'node2' }; yield { ok: false, uid: 'node3' }; }); - apiService.getNodes = jest.fn().mockImplementation((uids: string[]) => Promise.resolve( + apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { // Skip first node - make it missing. - uids.slice(1).map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)) - )); + yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)); + }); const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3'])); expect(result).toMatchObject([node2, node3, {missingUid: 'node1'}]); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 2157b8ba..94ef44c1 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -146,13 +146,14 @@ export class NodesAccess { } private async* loadNodesWithMissingReport(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const encryptedNodes = await this.apiService.getNodes(nodeUids, signal); - for (const encryptedNode of encryptedNodes) { + const returnedNodeUids: string[] = []; + + for await (const encryptedNode of this.apiService.iterateNodes(nodeUids, signal)) { + returnedNodeUids.push(encryptedNode.uid); const { node } = await this.decryptNode(encryptedNode); yield node; } - const returnedNodeUids = encryptedNodes.map(({ uid }) => uid); const missingNodeUids = nodeUids.filter((nodeUid) => !returnedNodeUids.includes(nodeUid)); if (missingNodeUids.length) { From 236381ae303f23f9e8408c309589f4bc835ef6f9 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Apr 2025 11:00:48 +0000 Subject: [PATCH 066/791] Handle parent decryption errors --- js/sdk/src/internal/nodes/nodesAccess.test.ts | 54 +++++++++++++++++++ js/sdk/src/internal/nodes/nodesAccess.ts | 43 +++++++++++---- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 6418ff76..820249a8 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -1,5 +1,6 @@ import { getMockLogger } from "../../tests/logger"; import { PrivateKey } from "../../crypto"; +import { DecryptionError } from "../../errors"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache" import { NodesCryptoCache } from "./cryptoCache"; @@ -348,6 +349,59 @@ describe('nodesAccess', () => { expect(result).toMatchObject([node2, node3, {missingUid: 'node1'}]); expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); }); + + it('should return degraded node if parent cannot be decrypted', async () => { + cache.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { ok: false, uid: 'node1' }; + yield { ok: false, uid: 'node2' }; + yield { ok: false, uid: 'node3' }; + }); + const encryptedCrypto = { + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'nameSignatureEmail', + }; + apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { + yield* uids.map((uid) => ({ + uid, + parentUid: `parentUidFor:${uid}`, + encryptedCrypto, + } as EncryptedNode)); + }); + const decryptionError = new DecryptionError('Parent cannot be decrypted'); + jest.spyOn(access, 'getParentKeys').mockImplementation(async ({ parentUid }) => { + if (parentUid === 'parentUidFor:node1') { + throw decryptionError; + } + return { + key: 'parentKey', + }; + }); + + const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3'])); + expect(result).toEqual([ + { + ...node1, + encryptedCrypto, + parentUid: 'parentUidFor:node1', + name: { ok: false, error: { name: '', error: decryptionError.message } }, + keyAuthor: { ok: false, error: { claimedAuthor: 'signatureEmail', error: decryptionError.message } }, + nameAuthor: { ok: false, error: { claimedAuthor: 'nameSignatureEmail', error: decryptionError.message } }, + errors: [decryptionError], + }, + { + ...node2, + name: { ok: true, value: 'name' }, + folder: undefined, + activeRevision: undefined, + }, + { + ...node3, + name: { ok: true, value: 'name' }, + folder: undefined, + activeRevision: undefined, + }, + ]); + }); }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 94ef44c1..a610d822 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -2,7 +2,8 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey } from "../../crypto"; import { Logger, MissingNode, NodeType, resultError, resultOk } from "../../interface"; -import { SDKError } from "../../errors"; +import { DecryptionError } from "../../errors"; +import { getErrorMessage } from '../errors'; import { BatchLoading } from "../batchLoading"; import { makeNodeUid } from "../uids"; import { NodeAPIService } from "./apiService"; @@ -13,10 +14,6 @@ import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from "./ex import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; import { validateNodeName } from "./validations"; -class NodeKeysDecryptionError extends SDKError { - name = 'NodeKeysDecryptionError'; -} - /** * Provides access to node metadata. * @@ -166,7 +163,35 @@ export class NodesAccess { } private async decryptNode(encryptedNode: EncryptedNode): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { - const { key: parentKey } = await this.getParentKeys(encryptedNode); + let parentKey; + try { + const parentKeys = await this.getParentKeys(encryptedNode); + parentKey = parentKeys.key; + } catch (error: unknown) { + if (error instanceof DecryptionError) { + return { + node: { + ...encryptedNode, + isStale: false, + name: resultError({ + name: '', + error: getErrorMessage(error), + }), + keyAuthor: resultError({ + claimedAuthor: encryptedNode.encryptedCrypto.signatureEmail, + error: getErrorMessage(error), + }), + nameAuthor: resultError({ + claimedAuthor: encryptedNode.encryptedCrypto.nameSignatureEmail, + error: getErrorMessage(error), + }), + errors: [error], + }, + }; + } + throw error; + } + const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); const node = await this.parseNode(unparsedNode); try { @@ -234,11 +259,11 @@ export class NodesAccess { try { return await this.getNodeKeys(node.parentUid); } catch (error: unknown) { - if (error instanceof NodeKeysDecryptionError) { + if (error instanceof DecryptionError) { // Change the error message to be more specific. // Original error message is referring to node, while here // it referes to as parent to follow the method context. - throw new NodeKeysDecryptionError(c('Error').t`Parent cannot be decrypted`); + throw new DecryptionError(c('Error').t`Parent cannot be decrypted`); } throw error; } @@ -259,7 +284,7 @@ export class NodesAccess { } catch { const { keys } = await this.loadNode(nodeUid); if (!keys) { - throw new NodeKeysDecryptionError(c('Error').t`Item cannot be decrypted`); + throw new DecryptionError(c('Error').t`Item cannot be decrypted`); } return keys; } From dca08bd3a37f395c245e4fcee43ebfca6ed5c40d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Apr 2025 11:31:20 +0000 Subject: [PATCH 067/791] Rename SDKError to ProtonDriveError --- js/sdk/src/errors.ts | 18 +++++++++--------- js/sdk/src/internal/apiService/apiService.ts | 6 +++--- js/sdk/src/internal/nodes/apiService.ts | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index a3388d41..ba76719e 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -9,11 +9,11 @@ import { c } from 'ttag'; * * No retries should be done as that is already handled by the SDK. * - * When SDK throws an error and it is not `SDKError`, it is unhandled error + * When SDK throws an error and it is not `ProtonDriveError`, it is unhandled error * by the SDK and usually indicates bug in the SDK. Please, report it. */ -export class SDKError extends Error { - name = 'SDKError'; +export class ProtonDriveError extends Error { + name = 'ProtonDriveError'; } /** @@ -22,7 +22,7 @@ export class SDKError extends Error { * This error is thrown when the operation is aborted by the user. * For example, by calling `abort()` on the `AbortSignal`. */ -export class AbortError extends SDKError { +export class AbortError extends ProtonDriveError { name = 'AbortError'; constructor(message?: string) { @@ -40,7 +40,7 @@ export class AbortError extends SDKError { * follow the required format, etc., while on the server side, it can be thrown * when there is not enough permissions, etc. */ -export class ValidationError extends SDKError { +export class ValidationError extends ProtonDriveError { name = 'ValidationError'; /** @@ -84,7 +84,7 @@ export class NodeAlreadyExistsValidationError extends ValidationError { * * Client should not retry the request when this error is thrown. */ -export class ServerError extends SDKError { +export class ServerError extends ProtonDriveError { name = 'ServerError'; /** @@ -130,7 +130,7 @@ export class RateLimitedError extends ServerError { * You can also be notified about the connection status by the `offline` event * See `onMessage` method on the SDK class for more details. */ -export class ConnectionError extends SDKError { +export class ConnectionError extends ProtonDriveError { name = 'ConnectionError'; } @@ -144,7 +144,7 @@ export class ConnectionError extends SDKError { * case of the file content, if block cannot be decrypted, decryption error * is thrown. */ -export class DecryptionError extends SDKError { +export class DecryptionError extends ProtonDriveError { name = 'DecryptionError'; } @@ -156,7 +156,7 @@ export class DecryptionError extends SDKError { * For example, it can happen when hashes don't match, etc. In some cases, * SDK allows to run command without verification checks for debug purposes. */ -export class IntegrityError extends SDKError { +export class IntegrityError extends ProtonDriveError { name = 'IntegrityError'; public readonly debug?: object; diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 61065834..587ae33e 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -2,7 +2,7 @@ import { c } from 'ttag'; import { VERSION } from "../../version"; import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from "../../interface"; -import { AbortError, ServerError, RateLimitedError, SDKError } from '../../errors'; +import { AbortError, ServerError, RateLimitedError, ProtonDriveError } from '../../errors'; import { waitSeconds } from '../wait'; import { HTTPErrorCode, isCodeOk } from './errorCodes'; import { apiErrorFactory } from './errors'; @@ -130,7 +130,7 @@ export class DriveAPIService { } return result as ResponsePayload; } catch (error: unknown) { - if (error instanceof SDKError) { + if (error instanceof ProtonDriveError) { throw error; } throw apiErrorFactory({ response }); @@ -166,7 +166,7 @@ export class DriveAPIService { const result = await response.json(); throw apiErrorFactory({ response, result }); } catch (error: unknown) { - if (error instanceof SDKError) { + if (error instanceof ProtonDriveError) { throw error; } throw apiErrorFactory({ response }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 65bbfbdc..ccb98617 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,6 +1,6 @@ import { c } from "ttag"; -import { SDKError, ValidationError } from "../../errors"; +import { ProtonDriveError, ValidationError } from "../../errors"; import { Logger, NodeResult } from "../../interface"; import { RevisionState } from "../../interface/nodes"; import { DriveAPIService, drivePaths, isCodeOk, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; @@ -89,7 +89,7 @@ export class NodeAPIService { if (errors.length) { this.logger.warn(`Failed to load ${errors.length} nodes`); - throw new SDKError(c('Error').t`Failed to load some nodes`, { cause: errors }); + throw new ProtonDriveError(c('Error').t`Failed to load some nodes`, { cause: errors }); } } From 60c34baa063261096100f8203775f7db3f5c5644 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Apr 2025 12:28:17 +0000 Subject: [PATCH 068/791] Implement devices --- js/sdk/src/crypto/driveCrypto.ts | 16 +- js/sdk/src/crypto/interface.ts | 1 + js/sdk/src/crypto/openPGPCrypto.ts | 2 + js/sdk/src/interface/devices.ts | 19 ++- js/sdk/src/interface/index.ts | 4 +- js/sdk/src/internal/devices/apiService.ts | 139 ++++++++++++++++++ js/sdk/src/internal/devices/cryptoService.ts | 65 ++++++++ js/sdk/src/internal/devices/index.ts | 31 ++++ js/sdk/src/internal/devices/interface.ts | 27 ++++ js/sdk/src/internal/devices/manager.test.ts | 129 ++++++++++++++++ js/sdk/src/internal/devices/manager.ts | 113 ++++++++++++++ js/sdk/src/internal/nodes/apiService.ts | 6 +- js/sdk/src/internal/nodes/cryptoService.ts | 15 +- .../internal/nodes/nodesManagement.test.ts | 13 +- js/sdk/src/internal/nodes/nodesManagement.ts | 14 +- js/sdk/src/internal/shares/cryptoService.ts | 2 +- js/sdk/src/internal/shares/manager.ts | 9 +- js/sdk/src/internal/uids.ts | 13 ++ js/sdk/src/internal/upload/cryptoService.ts | 2 +- js/sdk/src/protonDriveClient.ts | 52 +++++++ 20 files changed, 641 insertions(+), 31 deletions(-) create mode 100644 js/sdk/src/internal/devices/apiService.ts create mode 100644 js/sdk/src/internal/devices/cryptoService.ts create mode 100644 js/sdk/src/internal/devices/index.ts create mode 100644 js/sdk/src/internal/devices/interface.ts create mode 100644 js/sdk/src/internal/devices/manager.test.ts create mode 100644 js/sdk/src/internal/devices/manager.ts diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index be2093a0..ae5f2e3d 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -311,6 +311,7 @@ export class DriveCrypto { const { armoredData: armoredHashKey } = await this.openPGPCrypto.encryptAndSignArmored( hashKey, + undefined, [encryptionAndSigningKey], encryptionAndSigningKey, ); @@ -330,17 +331,27 @@ export class DriveCrypto { /** * It converts node name into bytes array and encrypts and signs * with provided keys. + * + * The function accepts either encryption or session key. Use encryption + * key if you want to encrypt the name for the new node. Use session key + * if you want to encrypt the new name for the existing node. */ async encryptNodeName( nodeName: string, - encryptionKey: PrivateKey, + sessionKey: SessionKey | undefined, + encryptionKey: PrivateKey | undefined, signingKey: PrivateKey, ): Promise<{ armoredNodeName: string, }> { + if (!sessionKey && !encryptionKey) { + throw new Error('Neither session nor encryption key provided for encrypting node name'); + } + const { armoredData: armoredNodeName } = await this.openPGPCrypto.encryptAndSignArmored( new TextEncoder().encode(nodeName), - [encryptionKey], + sessionKey, + encryptionKey ? [encryptionKey] : [], signingKey, ); return { @@ -413,6 +424,7 @@ export class DriveCrypto { }> { const { armoredData: armoredExtendedAttributes } = await this.openPGPCrypto.encryptAndSignArmored( new TextEncoder().encode(extendedAttributes), + undefined, [encryptionKey], signingKey, ); diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 11b7078f..1c20211f 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -66,6 +66,7 @@ export interface OpenPGPCrypto { encryptAndSignArmored: ( data: Uint8Array, + sessionKey: SessionKey, encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) => Promise<{ diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 00b5953b..1a6db26f 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -137,12 +137,14 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async encryptAndSignArmored( data: Uint8Array, + sessionKey: SessionKey, encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, encryptionKeys, + sessionKey, signingKeys: signingKey, detached: false, }); diff --git a/js/sdk/src/interface/devices.ts b/js/sdk/src/interface/devices.ts index 6c07e2ab..e1686d23 100644 --- a/js/sdk/src/interface/devices.ts +++ b/js/sdk/src/interface/devices.ts @@ -1,16 +1,19 @@ import { Result } from './result'; +import { InvalidNameError } from './nodes'; export type Device = { uid: string, - name: Result, + type: DeviceType, + name: Result, rootFolderUid: string, + createdDate: Date, + lastSyncDate?: Date; } -export type DeviceOrUid = Device | string; - -export interface Devices { - iterateDevices(signal?: AbortSignal): AsyncGenerator, - createDevice(name: string): Promise, - renameDevice(deviceOrUid: DeviceOrUid, name: string): Promise, - deleteDevice(deviceOrUid: DeviceOrUid): Promise, +export enum DeviceType { + Windows = 'Windows', + MacOS = 'MacOS', + Linux = 'Linux', } + +export type DeviceOrUid = Device | string; diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index deb19f24..d841ca16 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -1,7 +1,6 @@ import { ProtonDriveCache } from '../cache'; import { OpenPGPCrypto, PrivateKey, SessionKey } from '../crypto'; import { ProtonDriveAccount } from './account'; -import { Devices } from './devices'; import { Download } from './download'; import { Events } from './events'; import { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; @@ -13,6 +12,7 @@ export { resultOk, resultError } from './result'; export type { ProtonDriveAccount, ProtonDriveAccountAddress } from './account'; export type { Author,UnverifiedAuthorError, AnonymousUser } from './author'; export type { Device, DeviceOrUid } from './devices'; +export { DeviceType } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; export type { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; @@ -46,4 +46,4 @@ export interface ProtonDriveClientContructorParameters { // Helper interface to make sure that all methods are correctly implemented eventually. // In the end this will be deleted and the ProtonDriveClient will implement all methods directly. -export interface ProtonDriveClientInterface extends Devices, Download, Events, Upload {}; +export interface ProtonDriveClientInterface extends Download, Events, Upload {}; diff --git a/js/sdk/src/internal/devices/apiService.ts b/js/sdk/src/internal/devices/apiService.ts new file mode 100644 index 00000000..cebc6188 --- /dev/null +++ b/js/sdk/src/internal/devices/apiService.ts @@ -0,0 +1,139 @@ +import { DeviceType } from "../../interface"; +import { DriveAPIService, drivePaths } from "../apiService"; +import { makeDeviceUid, makeNodeUid, splitDeviceUid } from "../uids"; +import { DeviceMetadata } from "./interface"; + +type GetDevicesResponse = drivePaths['/drive/devices']['get']['responses']['200']['content']['application/json']; + +type PostCreateDeviceRequest = Extract['content']['application/json']; +type PostCreateDeviceResponse = drivePaths['/drive/devices']['post']['responses']['200']['content']['application/json']; + +type PutUpdateDeviceRequest = Extract['content']['application/json']; +type PutUpdateDeviceResponse = drivePaths['/drive/devices/{deviceID}']['put']['responses']['200']['content']['application/json']; + +/** + * Provides API communication for managing devices. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ +export class DevicesAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; + } + + async getDevices(signal?: AbortSignal): Promise { + const response = await this.apiService.get('drive/devices', signal); + return response.Devices.map((device) => ({ + uid: makeDeviceUid(device.Device.VolumeID, device.Device.DeviceID), + type: deviceTypeNumberToEnum(device.Device.Type), + rootFolderUid: makeNodeUid(device.Device.VolumeID, device.Share.LinkID), + createdDate: new Date(device.Device.CreateTime*1000), + lastSyncDate: device.Device.LastSyncTime ? new Date(device.Device.LastSyncTime*1000) : undefined, + hasDeprecatedName: !!device.Share.Name, + })); + } + + /** + * Originally the device name was on the share of the device. + * This was changed to be on the root node of the device instead. + * Old devices will still have the name on the share and when + * the client renames the device, it must be removed on the device. + */ + async removeNameFromDevice(deviceUid: string): Promise { + const { deviceId } = splitDeviceUid(deviceUid); + await this.apiService.put< + // Web clients do not update Device fields, that is only for desktop clients. + Omit, + PutUpdateDeviceResponse + >( + `drive/devices/${deviceId}`, + { + Share: { Name: "" }, + }, + ); + } + + async createDevice( + device: { + volumeId: string, + type: DeviceType, + }, + share: { + addressId: string, + addressKeyId: string, + armoredKey: string, + armoredSharePassphrase: string, + armoredSharePassphraseSignature: string, + }, + node: { + encryptedName: string, + armoredKey: string, + armoredNodePassphrase: string, + armoredNodePassphraseSignature: string, + armoredHashKey: string, + } + ): Promise { + const response = await this.apiService.post('drive/devices', { + // @ts-expect-error VolumeID is deprecated. + Device: { + Type: deviceTypeEnumToNumber(device.type), + SyncState: 0, + }, + // @ts-expect-error Name is deprecated. + Share: { + AddressID: share.addressId, + AddressKeyID: share.addressKeyId, + Key: share.armoredKey, + Passphrase: share.armoredSharePassphrase, + PassphraseSignature: share.armoredSharePassphraseSignature, + }, + Link: { + Name: node.encryptedName, + NodeKey: node.armoredKey, + NodePassphrase: node.armoredNodePassphrase, + NodePassphraseSignature: node.armoredNodePassphraseSignature, + NodeHashKey: node.armoredHashKey, + } + }); + + return { + uid: makeDeviceUid(device.volumeId, response.Device.DeviceID), + type: device.type, + rootFolderUid: makeNodeUid(device.volumeId, response.Device.LinkID), + createdDate: new Date(), + hasDeprecatedName: false, + } + } + + async deleteDevice(deviceUid: string): Promise { + const { deviceId } = splitDeviceUid(deviceUid); + await this.apiService.delete(`drive/devices/${deviceId}`); + } +} + +function deviceTypeNumberToEnum(deviceType: 1 | 2 | 3): DeviceType { + switch (deviceType) { + case 1: + return DeviceType.Windows; + case 2: + return DeviceType.MacOS; + case 3: + return DeviceType.Linux; + default: + throw new Error(`Unknown device type: ${deviceType}`); + } +} + +function deviceTypeEnumToNumber(deviceType: DeviceType): 1 | 2 | 3 { + switch (deviceType.toLowerCase()) { + case DeviceType.Windows.toLowerCase(): + return 1; + case DeviceType.MacOS.toLowerCase(): + return 2; + case DeviceType.Linux.toLowerCase(): + return 3; + default: + throw new Error(`Unknown device type: ${deviceType}`); + } +} diff --git a/js/sdk/src/internal/devices/cryptoService.ts b/js/sdk/src/internal/devices/cryptoService.ts new file mode 100644 index 00000000..d1617fc4 --- /dev/null +++ b/js/sdk/src/internal/devices/cryptoService.ts @@ -0,0 +1,65 @@ +import { DriveCrypto } from '../../crypto'; +import { SharesService } from './interface'; + +/** + * Provides crypto operations for devices. + */ +export class DevicesCryptoService { + constructor( + private driveCrypto: DriveCrypto, + private sharesService: SharesService, + ) { + this.driveCrypto = driveCrypto; + this.sharesService = sharesService; + } + + async createDevice(volumeId: string, deviceName: string): Promise<{ + address: { + addressId: string, + addressKeyId: string, + }, + shareKey: { + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + }, + node: { + key: { + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string, + }, + encryptedName: string, + armoredHashKey: string, + } + }> { + const address = await this.sharesService.getVolumeEmailKey(volumeId); + const addressKey = address.addressKey; + + const shareKey = await this.driveCrypto.generateKey([addressKey], addressKey); + const rootNodeKey = await this.driveCrypto.generateKey([shareKey.decrypted.key], addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName(deviceName, undefined, shareKey.decrypted.key, addressKey); + const { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKey.decrypted.key); + + return { + address: { + addressId: address.addressId, + addressKeyId: address.addressKeyId, + }, + shareKey: { + armoredKey: shareKey.encrypted.armoredKey, + armoredPassphrase: shareKey.encrypted.armoredPassphrase, + armoredPassphraseSignature: shareKey.encrypted.armoredPassphraseSignature, + }, + node: { + key: { + armoredKey: rootNodeKey.encrypted.armoredKey, + armoredPassphrase: rootNodeKey.encrypted.armoredPassphrase, + armoredPassphraseSignature: rootNodeKey.encrypted.armoredPassphraseSignature, + }, + encryptedName: armoredNodeName, + armoredHashKey, + } + }; + }; +} diff --git a/js/sdk/src/internal/devices/index.ts b/js/sdk/src/internal/devices/index.ts new file mode 100644 index 00000000..16a88835 --- /dev/null +++ b/js/sdk/src/internal/devices/index.ts @@ -0,0 +1,31 @@ +import { DriveCrypto } from "../../crypto"; +import { ProtonDriveTelemetry } from "../../interface"; +import { DriveAPIService } from "../apiService"; +import { DevicesAPIService } from "./apiService"; +import { DevicesCryptoService } from "./cryptoService"; +import { SharesService, NodesService, NodesManagementService } from "./interface"; +import { DevicesManager } from "./manager"; + +/** + * Provides facade for the whole devices module. + * + * The devices module is responsible for handling devices metadata, including + * API communication, encryption, decryption, caching, and event handling. + * + * This facade provides internal interface that other modules can use to + * interact with the devices. + */ +export function initDevicesModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveCrypto: DriveCrypto, + sharesService: SharesService, + nodesService: NodesService, + nodesManagementService: NodesManagementService, +) { + const api = new DevicesAPIService(apiService); + const cryptoService = new DevicesCryptoService(driveCrypto, sharesService); + const manager = new DevicesManager(telemetry.getLogger('devices'), api, cryptoService, sharesService, nodesService, nodesManagementService); + + return manager; +} diff --git a/js/sdk/src/internal/devices/interface.ts b/js/sdk/src/internal/devices/interface.ts new file mode 100644 index 00000000..f5d9f768 --- /dev/null +++ b/js/sdk/src/internal/devices/interface.ts @@ -0,0 +1,27 @@ +import { PrivateKey } from "../../crypto"; +import { DeviceType, MissingNode } from "../../interface"; +import { DecryptedNode } from "../nodes"; + +export type DeviceMetadata = { + uid: string, + type: DeviceType + rootFolderUid: string, + createdDate: Date, + lastSyncDate?: Date; + hasDeprecatedName: boolean; +} + +export interface SharesService { + getMyFilesIDs(): Promise<{ volumeId: string }>; + getVolumeEmailKey(volumeId: string): Promise<{ addressId: string, email: string, addressKey: PrivateKey, addressKeyId: string }>, +} + +export interface NodesService { + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; +} + +export interface NodesManagementService { + renameNode(nodeUid: string, newName: string, options: { + allowRenameRootNode: boolean, + }): Promise; +} diff --git a/js/sdk/src/internal/devices/manager.test.ts b/js/sdk/src/internal/devices/manager.test.ts new file mode 100644 index 00000000..e3afe891 --- /dev/null +++ b/js/sdk/src/internal/devices/manager.test.ts @@ -0,0 +1,129 @@ +import { Device, DeviceType, Logger } from '../../interface'; +import { ValidationError } from '../../errors'; +import { getMockLogger } from '../../tests/logger'; +import { DevicesAPIService } from './apiService'; +import { DevicesCryptoService } from './cryptoService'; +import { SharesService, NodesService, NodesManagementService, DeviceMetadata } from './interface'; +import { DevicesManager } from './manager'; + +describe('DevicesManager', () => { + let logger: Logger; + let apiService: jest.Mocked; + let cryptoService: jest.Mocked; + let sharesService: jest.Mocked; + let nodesService: jest.Mocked; + let nodesManagementService: jest.Mocked; + let manager: DevicesManager; + + beforeEach(() => { + logger = getMockLogger(); + // @ts-expect-error No need to implement all methods for mocking + apiService = { + createDevice: jest.fn(), + getDevices: jest.fn(), + removeNameFromDevice: jest.fn(), + deleteDevice: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + createDevice: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getMyFilesIDs: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + nodesService = {}; + nodesManagementService = { + renameNode: jest.fn(), + }; + + manager = new DevicesManager( + logger, + apiService, + cryptoService, + sharesService, + nodesService, + nodesManagementService, + ); + }); + + it('creates device', async () => { + const volumeId = 'volume123'; + const name = 'Test Device'; + const deviceType = DeviceType.Linux; + const address = { addressId: 'address123', addressKeyId: 'key123' }; + const shareKey = { armoredKey: 'armoredKey', armoredPassphrase: 'passphrase', armoredPassphraseSignature: 'signature' }; + const node = { encryptedName: 'encryptedName', key: { armoredKey: 'nodeKey', armoredPassphrase: 'nodePassphrase', armoredPassphraseSignature: 'nodeSignature' }, armoredHashKey: 'hashKey' }; + const createdDevice = { uid: 'device123', rootFolderUid: 'rootFolder123', type: deviceType } as DeviceMetadata; + + sharesService.getMyFilesIDs.mockResolvedValue({ volumeId }); + cryptoService.createDevice.mockResolvedValue({ address, shareKey, node }); + apiService.createDevice.mockResolvedValue(createdDevice); + + const result = await manager.createDevice(name, deviceType); + + expect(sharesService.getMyFilesIDs).toHaveBeenCalled(); + expect(cryptoService.createDevice).toHaveBeenCalledWith(volumeId, name); + expect(apiService.createDevice).toHaveBeenCalledWith( + { volumeId, type: deviceType }, + { + addressId: address.addressId, + addressKeyId: address.addressKeyId, + armoredKey: shareKey.armoredKey, + armoredSharePassphrase: shareKey.armoredPassphrase, + armoredSharePassphraseSignature: shareKey.armoredPassphraseSignature, + }, + { + encryptedName: node.encryptedName, + armoredKey: node.key.armoredKey, + armoredNodePassphrase: node.key.armoredPassphrase, + armoredNodePassphraseSignature: node.key.armoredPassphraseSignature, + armoredHashKey: node.armoredHashKey, + } + ); + expect(result).toEqual({ ...createdDevice, name: { ok: true, value: name } }); + }); + + it('renames device with deprecated name', async () => { + const deviceUid = 'device123'; + const name = 'New Device Name'; + const device = { uid: deviceUid, rootFolderUid: 'rootFolder123', hasDeprecatedName: true } as DeviceMetadata; + + apiService.getDevices.mockResolvedValue([device]); + + const result = await manager.renameDevice(deviceUid, name); + + expect(apiService.getDevices).toHaveBeenCalled(); + expect(apiService.removeNameFromDevice).toHaveBeenCalledWith(deviceUid); + expect(nodesManagementService.renameNode).toHaveBeenCalledWith(device.rootFolderUid, name, { allowRenameRootNode: true }); + expect(result).toEqual({ ...device, name: { ok: true, value: name } }); + }); + + it('renames device without deprecated name', async () => { + const deviceUid = 'device123'; + const name = 'New Device Name'; + const device = { uid: deviceUid, rootFolderUid: 'rootFolder123', hasDeprecatedName: false } as DeviceMetadata; + + apiService.getDevices.mockResolvedValue([device]); + + const result = await manager.renameDevice(deviceUid, name); + + expect(apiService.getDevices).toHaveBeenCalled(); + expect(apiService.removeNameFromDevice).not.toHaveBeenCalled(); + expect(nodesManagementService.renameNode).toHaveBeenCalledWith(device.rootFolderUid, name, { allowRenameRootNode: true }); + expect(result).toEqual({ ...device, name: { ok: true, value: name } }); + }); + + it('renames non-existing device', async () => { + const deviceUid = 'nonexistentDevice'; + const name = 'New Device Name'; + + apiService.getDevices.mockResolvedValue([]); + + await expect(manager.renameDevice(deviceUid, name)).rejects.toThrow(ValidationError); + expect(apiService.getDevices).toHaveBeenCalled(); + expect(apiService.removeNameFromDevice).not.toHaveBeenCalled(); + expect(nodesManagementService.renameNode).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/js/sdk/src/internal/devices/manager.ts b/js/sdk/src/internal/devices/manager.ts new file mode 100644 index 00000000..3fa23bda --- /dev/null +++ b/js/sdk/src/internal/devices/manager.ts @@ -0,0 +1,113 @@ +import { c } from 'ttag'; + +import { ValidationError } from '../../errors'; +import { Device, DeviceType, Logger, resultOk } from '../../interface'; +import { DevicesAPIService } from './apiService'; +import { DevicesCryptoService } from './cryptoService'; +import { DeviceMetadata, NodesManagementService, NodesService, SharesService } from './interface'; + +export class DevicesManager { + constructor( + private logger: Logger, + private apiService: DevicesAPIService, + private cryptoService: DevicesCryptoService, + private sharesService: SharesService, + private nodesService: NodesService, + private nodesManagementService: NodesManagementService, + ) { + this.logger = logger; + this.apiService = apiService; + this.cryptoService = cryptoService; + this.sharesService = sharesService; + this.nodesService = nodesService; + this.nodesManagementService = nodesManagementService; + } + + async *iterateDevices(signal?: AbortSignal): AsyncGenerator { + const devices = await this.apiService.getDevices(signal); + + const nodeUidToDevice = new Map(); + for (const device of devices) { + nodeUidToDevice.set(device.rootFolderUid, device); + } + + for await (const node of this.nodesService.iterateNodes(Array.from(nodeUidToDevice.keys()), signal)) { + if ('missingUid' in node) { + continue; + } + + const device = nodeUidToDevice.get(node.uid); + if (device) { + yield { + ...device, + name: node.name, + }; + } + } + } + + async createDevice(name: string, deviceType: DeviceType): Promise { + const { volumeId } = await this.sharesService.getMyFilesIDs(); + const { address, shareKey, node } = await this.cryptoService.createDevice(volumeId, name); + + const device = await this.apiService.createDevice( + { + volumeId, + type: deviceType, + }, + { + addressId: address.addressId, + addressKeyId: address.addressKeyId, + armoredKey: shareKey.armoredKey, + armoredSharePassphrase: shareKey.armoredPassphrase, + armoredSharePassphraseSignature: shareKey.armoredPassphraseSignature, + }, + { + encryptedName: node.encryptedName, + armoredKey: node.key.armoredKey, + armoredNodePassphrase: node.key.armoredPassphrase, + armoredNodePassphraseSignature: node.key.armoredPassphraseSignature, + armoredHashKey: node.armoredHashKey, + }, + ); + return { + ...device, + name: resultOk(name), + }; + } + + async renameDevice(deviceUid: string, name: string): Promise { + const device = await this.getDeviceMetadata(deviceUid); + + if (device.hasDeprecatedName) { + this.logger.info("Removing deprecated name from device"); + try { + await this.apiService.removeNameFromDevice(deviceUid); + } catch (error: unknown) { + this.logger.error('Failed to remove name from device', error); + } + } + + await this.nodesManagementService.renameNode(device.rootFolderUid, name, { + allowRenameRootNode: true, + }); + + return { + ...device, + name: resultOk(name), + } + } + + private async getDeviceMetadata(deviceUid: string): Promise { + const devices = await this.apiService.getDevices(); + const device = devices.find(device => device.uid === deviceUid); + if (!device) { + throw new ValidationError(c('Error').t`Device not found`); + } + return device; + } + + async deleteDevice(deviceUid: string): Promise { + await this.apiService.deleteDevice(deviceUid); + } +} diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index ccb98617..3533124d 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -142,12 +142,12 @@ export class NodeAPIService { async renameNode( nodeUid: string, originalNode: { - hash: string, + hash?: string, }, newNode: { encryptedName: string, nameSignatureEmail: string, - hash: string, + hash?: string, }, signal?: AbortSignal, ): Promise { @@ -160,7 +160,7 @@ export class NodeAPIService { Name: newNode.encryptedName, NameSignatureEmail: newNode.nameSignatureEmail, Hash: newNode.hash, - OriginalHash: originalNode.hash, + OriginalHash: originalNode.hash || null, }, signal); } diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 0a0e01a1..34cb659a 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -357,7 +357,8 @@ export class NodesCryptoService { hash, ] = await Promise.all([ this.driveCrypto.generateKey([parentKeys.key], addressKey), - this.driveCrypto.encryptNodeName(name, parentKeys.key, addressKey), + + this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, addressKey), this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), ]); @@ -390,15 +391,17 @@ export class NodesCryptoService { }; } - async encryptNewName(node: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, newName: string): Promise<{ + async encryptNewName(node: DecryptedNode, nodeNameSessionKey: SessionKey, parentHashKey: Uint8Array | undefined, newName: string): Promise<{ signatureEmail: string, armoredNodeName: string, - hash: string, + hash?: string, }> { const { volumeId } = splitNodeUid(node.uid); const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); - const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, parentKeys.key, addressKey); - const hash = await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, nodeNameSessionKey, undefined, addressKey); + const hash = parentHashKey + ? await this.driveCrypto.generateLookupHash(newName, parentHashKey) + : undefined; return { signatureEmail: email, armoredNodeName, @@ -423,7 +426,7 @@ export class NodesCryptoService { const { volumeId } = splitNodeUid(parentNode.uid); const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); - const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, parentKeys.key, addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, undefined, parentKeys.key, addressKey); const hash = await this.driveCrypto.generateLookupHash(node.name.value, parentKeys.hashKey); const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], addressKey); diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 8a72578b..f14a353f 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -86,6 +86,9 @@ describe('NodesManagement', () => { hashKey: `${nodes[uid].parentUid}-hashKey`, })), iterateNodes: jest.fn(), + getNodePrivateAndSessionKeys: jest.fn().mockResolvedValue({ + nameSessionKey: 'nameSessionKey', + }), } management = new NodesManagement(apiService, cache, cryptoCache, cryptoService, nodesAccess); @@ -99,10 +102,12 @@ describe('NodesManagement', () => { nameAuthor: { ok: true, value: 'newSignatureEmail' }, hash: 'newHash', }); - expect(cryptoService.encryptNewName).toHaveBeenCalledWith(nodes.nodeUid, { - key: 'parentUid-key', - hashKey: 'parentUid-hashKey', - }, 'new name'); + expect(cryptoService.encryptNewName).toHaveBeenCalledWith( + nodes.nodeUid, + 'nameSessionKey', + 'parentUid-hashKey', + 'new name', + ); expect(apiService.renameNode).toHaveBeenCalledWith( nodes.nodeUid.uid, { hash: nodes.nodeUid.hash }, diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index e3db9dd0..c7a54892 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -36,13 +36,14 @@ export class NodesManagement { this.nodesAccess = nodesAccess; } - async renameNode(nodeUid: string, newName: string): Promise { + async renameNode(nodeUid: string, newName: string, options = { allowRenameRootNode: false }): Promise { validateNodeName(newName); const node = await this.nodesAccess.getNode(nodeUid); + const { nameSessionKey: nodeNameSessionKey } = await this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid); const parentKeys = await this.nodesAccess.getParentKeys(node); - if (!node.hash || !parentKeys.hashKey) { + if (!options.allowRenameRootNode && (!node.hash || !parentKeys.hashKey)) { throw new ValidationError(c('Error').t`Renaming root item is not allowed`) } @@ -50,7 +51,14 @@ export class NodesManagement { signatureEmail, armoredNodeName, hash, - } = await this.cryptoService.encryptNewName(node, { key: parentKeys.key, hashKey: parentKeys.hashKey }, newName); + } = await this.cryptoService.encryptNewName(node, nodeNameSessionKey, parentKeys.hashKey, newName); + + // Because hash is optional, lets ensure we have it unless explicitely + // allowed to rename root node. + if (!options.allowRenameRootNode && !hash) { + throw new Error("Node hash not generated"); + } + await this.apiService.renameNode( nodeUid, { diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 9a276c9c..8ea70b06 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -36,7 +36,7 @@ export class SharesCryptoService { }> { const shareKey = await this.driveCrypto.generateKey([addressKey], addressKey); const rootNodeKey = await this.driveCrypto.generateKey([shareKey.decrypted.key], addressKey); - const { armoredNodeName } = await this.driveCrypto.encryptNodeName('root', shareKey.decrypted.key, addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName('root', undefined, shareKey.decrypted.key, addressKey); const { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKey.decrypted.key); return { shareKey, diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 697e56c9..33337ebe 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -132,7 +132,12 @@ export class SharesManager { return key.key; } - async getVolumeEmailKey(volumeId: string): Promise<{ email: string, addressId: string, addressKey: PrivateKey }> { + async getVolumeEmailKey(volumeId: string): Promise<{ + email: string, + addressId: string, + addressKey: PrivateKey, + addressKeyId: string, + }> { try { const { addressId } = await this.cache.getVolume(volumeId); const address = await this.account.getOwnAddress(addressId); @@ -140,6 +145,7 @@ export class SharesManager { email: address.email, addressId, addressKey: address.keys[address.primaryKeyIndex].key, + addressKeyId: address.keys[address.primaryKeyIndex].id, }; } catch {} @@ -161,6 +167,7 @@ export class SharesManager { email: address.email, addressId: share.addressId, addressKey: address.keys[address.primaryKeyIndex].key, + addressKeyId: address.keys[address.primaryKeyIndex].id, }; } diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index faf15e3f..2a8dcca8 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -1,3 +1,16 @@ +export function makeDeviceUid(volumeId: string, deviceId: string) { + return `${volumeId}~${deviceId}`; +} + +export function splitDeviceUid(deviceUid: string) { + const parts = deviceUid.split('~'); + if (parts.length !== 2) { + throw new Error(`"${deviceUid}" is not valid device UID`); + } + const [ volumeId, deviceId ] = parts; + return { volumeId, deviceId }; +} + export function makeNodeUid(volumeId: string, nodeId: string) { return `${volumeId}~${nodeId}`; } diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index fda4ee60..f654acfa 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -25,7 +25,7 @@ export class UploadCryptoService { hash, ] = await Promise.all([ this.driveCrypto.generateKey([parentKeys.key], signatureAddress.addressKey), - this.driveCrypto.encryptNodeName(name, parentKeys.key, signatureAddress.addressKey), + this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, signatureAddress.addressKey), this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), ]); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index f8d14a6a..273fae75 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -13,6 +13,9 @@ import { NonProtonInvitationOrUid, ProtonInvitationWithNode, ShareResult, + Device, + DeviceType, + DeviceOrUid, UploadMetadata, FileDownloader, Fileuploader, @@ -28,6 +31,7 @@ import { DriveEventsService } from './internal/events'; import { getConfig } from './config'; import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator, convertInternalMissingNodeIterator } from './transformers'; import { Telemetry } from './telemetry'; +import { initDevicesModule } from './internal/devices'; /** * ProtonDriveClient is the main interface for the ProtonDrive SDK. @@ -42,6 +46,7 @@ export class ProtonDriveClient implements Partial { private sharing: ReturnType; private download: ReturnType; private upload: ReturnType; + private devices: ReturnType; constructor({ httpClient, @@ -66,6 +71,7 @@ export class ProtonDriveClient implements Partial { this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access); this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.nodes.access, this.nodes.revisions); this.upload = initUploadModule(telemetry, apiService, cryptoModule, shares, this.nodes.access); + this.devices = initDevicesModule(telemetry, apiService, cryptoModule, shares, this.nodes.access, this.nodes.management); } /** @@ -502,4 +508,50 @@ export class ProtonDriveClient implements Partial { this.logger.info(`Getting file uploader for parent ${getUid(parentFolderUid)}`); return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } + + /** + * Iterates the devices of the user. + * + * The output is not sorted and the order of the devices is not guaranteed. + * + * @returns An async generator of devices. + */ + async* iterateDevices(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating devices'); + yield* this.devices.iterateDevices(signal); + } + + /** + * Creates a new device. + * + * @param nodeUid - Device entity or its UID string. + * @returns The created device entity. + * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. + */ + async createDevice(name: string, deviceType: DeviceType): Promise { + this.logger.info(`Creating device of type ${deviceType}`); + return this.devices.createDevice(name, deviceType); + } + + /** + * Renames a device. + * + * @param deviceOrUid - Device entity or its UID string. + * @returns The updated device entity. + * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. + */ + async renameDevice(deviceOrUid: DeviceOrUid, name: string): Promise { + this.logger.info(`Renaming device ${getUid(deviceOrUid)}`); + return this.devices.renameDevice(getUid(deviceOrUid), name); + } + + /** + * Deletes a device. + * + * @param deviceOrUid - Device entity or its UID string. + */ + async deleteDevice(deviceOrUid: DeviceOrUid): Promise { + this.logger.info(`Deleting device ${getUid(deviceOrUid)}`); + await this.devices.deleteDevice(getUid(deviceOrUid)); + } } From 7b48274b7cc67b155b22373b341ba7b3d07ac20c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 15 Apr 2025 05:18:35 +0000 Subject: [PATCH 069/791] implement downloading thumbnails --- js/sdk/src/crypto/driveCrypto.ts | 19 ++ js/sdk/src/interface/download.ts | 12 - js/sdk/src/interface/index.ts | 12 +- js/sdk/src/interface/thumbnail.ts | 14 + js/sdk/src/interface/upload.ts | 27 +- js/sdk/src/internal/download/apiService.ts | 56 +++- js/sdk/src/internal/download/cryptoService.ts | 16 ++ js/sdk/src/internal/download/index.ts | 17 +- js/sdk/src/internal/download/interface.ts | 4 +- .../download/thumbnailDownloader.test.ts | 226 ++++++++++++++++ .../internal/download/thumbnailDownloader.ts | 250 ++++++++++++++++++ js/sdk/src/internal/nodes/apiService.test.ts | 1 + js/sdk/src/internal/nodes/apiService.ts | 15 +- js/sdk/src/internal/nodes/cryptoService.ts | 1 + js/sdk/src/internal/nodes/interface.ts | 10 +- js/sdk/src/internal/nodes/nodesAccess.ts | 1 + js/sdk/src/internal/uids.ts | 75 +++--- js/sdk/src/protonDriveClient.ts | 19 +- 18 files changed, 680 insertions(+), 95 deletions(-) create mode 100644 js/sdk/src/interface/thumbnail.ts create mode 100644 js/sdk/src/internal/download/thumbnailDownloader.test.ts create mode 100644 js/sdk/src/internal/download/thumbnailDownloader.ts diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index ae5f2e3d..6e436cab 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -533,6 +533,25 @@ export class DriveCrypto { }; } + async decryptThumbnailBlock( + encryptedThumbnail: Uint8Array, + sessionKey: SessionKey, + verificationKeys: PublicKey[], + ): Promise<{ + decryptedThumbnail: Uint8Array, + verified: VERIFICATION_STATUS, + }> { + const { data: decryptedThumbnail, verified } = await this.openPGPCrypto.decryptAndVerify( + encryptedThumbnail, + sessionKey, + verificationKeys, + ); + return { + decryptedThumbnail, + verified, + }; + } + async encryptBlock( blockData: Uint8Array, encryptionKey: PrivateKey, diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index a9b6dcfd..06b0e0ea 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -1,15 +1,3 @@ -import { NodeOrUid } from './nodes'; -import { ThumbnailType } from './upload'; - -export interface Download { - getFileDownloader(node: NodeOrUid, signal?: AbortSignal): Promise, - - iterateThumbnails(nodeUids: NodeOrUid[], thumbnailType: ThumbnailType, signal?: AbortSignal): AsyncGenerator<{ - nodeUid: string, - thumbnail: Uint8Array, - }>, -} - export interface FileDownloader { /** * Get the claimed size of the file in bytes. diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index d841ca16..5cffc3c3 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -1,11 +1,8 @@ import { ProtonDriveCache } from '../cache'; import { OpenPGPCrypto, PrivateKey, SessionKey } from '../crypto'; import { ProtonDriveAccount } from './account'; -import { Download } from './download'; -import { Events } from './events'; import { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; import { Telemetry, MetricEvent } from './telemetry'; -import { Upload } from './upload'; export type { Result } from './result'; export { resultOk, resultError } from './result'; @@ -21,8 +18,9 @@ export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent, MetricContext } from './telemetry'; -export type { Fileuploader, UploadController, Thumbnail, UploadMetadata } from './upload'; -export { ThumbnailType } from './upload'; +export type { Fileuploader, UploadController, UploadMetadata } from './upload'; +export type { Thumbnail, ThumbnailResult } from './thumbnail'; +export { ThumbnailType } from './thumbnail'; export type ProtonDriveTelemetry = Telemetry; export type ProtonDriveEntitiesCache = ProtonDriveCache; @@ -43,7 +41,3 @@ export interface ProtonDriveClientContructorParameters { config?: ProtonDriveConfig, telemetry?: ProtonDriveTelemetry, }; - -// Helper interface to make sure that all methods are correctly implemented eventually. -// In the end this will be deleted and the ProtonDriveClient will implement all methods directly. -export interface ProtonDriveClientInterface extends Download, Events, Upload {}; diff --git a/js/sdk/src/interface/thumbnail.ts b/js/sdk/src/interface/thumbnail.ts new file mode 100644 index 00000000..e9e32c52 --- /dev/null +++ b/js/sdk/src/interface/thumbnail.ts @@ -0,0 +1,14 @@ + +export type Thumbnail = { + type: ThumbnailType, + thumbnail: Uint8Array, +} + +export enum ThumbnailType { + Type1 = 1, + Type2 = 2, +} + +export type ThumbnailResult = + {nodeUid: string, ok: true, thumbnail: Uint8Array } | + {nodeUid: string, ok: false, error: string}; diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index 30dadd87..f98006c0 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -1,19 +1,4 @@ -import { NodeOrUid } from './nodes'; - -export interface Upload { - getFileUploader( - parentFolder: NodeOrUid, - name: string, - metadata: UploadMetadata, - signal?: AbortSignal - ): Promise, - - getFileRevisionUploader( - node: NodeOrUid, - metadata: UploadMetadata, - signal?: AbortSignal - ): Promise, -} +import { Thumbnail } from "./thumbnail"; export type UploadMetadata = { mimeType: string, @@ -32,13 +17,3 @@ export interface UploadController { resume(): void, completion(): Promise, } - -export type Thumbnail = { - type: ThumbnailType, - thumbnail: Uint8Array, -} - -export enum ThumbnailType { - Type1 = 1, - Type2 = 2, -} diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index ace3a706..97dfc02e 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -1,11 +1,16 @@ +import { c } from "ttag"; +import { ValidationError } from "../../errors"; import { DriveAPIService, drivePaths, ObserverStream } from "../apiService"; -import { splitNodeRevisionUid } from "../uids"; +import { makeNodeThumbnailUid, splitNodeRevisionUid, splitNodeThumbnailUid } from "../uids"; import { BlockMetadata } from "./interface"; const BLOCKS_PAGE_SIZE = 20; type GetRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; +type PostGetThumbnailsRequest = Extract['content']['application/json']; +type PostGetThumbnailsResponse = drivePaths['/drive/volumes/{volumeID}/thumbnails']['post']['responses']['200']['content']['application/json']; + export class DownloadAPIService { constructor(private apiService: DriveAPIService) { this.apiService = apiService; @@ -79,6 +84,55 @@ export class DownloadAPIService { const encryptedBlock = new Uint8Array(await new Response(blockStream).arrayBuffer()); return encryptedBlock; } + + // Improvement requested: support multiple volumes. + async* iterateThumbnails(thumbnailUids: string[], signal?: AbortSignal): AsyncGenerator< + { uid: string, ok: true, bareUrl: string, token: string } | + { uid: string, ok: false, error: string } + > { + const thumbnailIds = thumbnailUids.map(splitNodeThumbnailUid); + + const uniqueVolumeIds = new Set(thumbnailIds.map(({ volumeId }) => volumeId)); + if (uniqueVolumeIds.size !== 1) { + throw new ValidationError(c('Error').t`Loading thumbnails from multiple sections is not allowed`); + } + const volumeId = thumbnailIds[0].volumeId; + + const result = await this.apiService.post( + `drive/volumes/${volumeId}/thumbnails`, + { + ThumbnailIDs: thumbnailIds.map(({ thumbnailId }) => thumbnailId), + }, + signal, + ); + + console.log("result", result) + + for (const thumbnail of result.Thumbnails) { + const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === thumbnail.ThumbnailID); + if (!id) { + continue; + } + yield { + uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, thumbnail.ThumbnailID), + ok: true, + bareUrl: thumbnail.BareURL, + token: thumbnail.Token, + }; + } + + for (const error of result.Errors) { + const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === error.ThumbnailID); + if (!id) { + continue; + } + yield { + uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, error.ThumbnailID), + ok: false, + error: error.Error, + }; + } + } } function transformBlock(block: GetRevisionResponse['Revision']['Blocks'][0]): BlockMetadata { diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index f9e34a76..4b2e7691 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -45,6 +45,22 @@ export class DownloadCryptoService { return decryptedBlock; } + async decryptThumbnail(thumbnail: Uint8Array, contentKeyPacketSessionKey: SessionKey): Promise { + let decryptedBlock; + try { + const result = await this.driveCrypto.decryptThumbnailBlock( + thumbnail, + contentKeyPacketSessionKey, + [], // We ignore verification for thumbnails. + ); + decryptedBlock = result.decryptedThumbnail; + } catch (error: unknown) { + throw new DecryptionError(c('Error').t`Failed to decrypt thumbnail: ${getErrorMessage(error)}`); + } + + return decryptedBlock; + } + async verifyBlockIntegrity(encryptedBlock: Uint8Array, base64sha256Hash: string): Promise { const digest = await crypto.subtle.digest('SHA-256', encryptedBlock); const expectedHash = uint8ArrayToBase64String(new Uint8Array(digest)); diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts index 9ce6d28b..4e67e656 100644 --- a/js/sdk/src/internal/download/index.ts +++ b/js/sdk/src/internal/download/index.ts @@ -2,7 +2,7 @@ import { c } from 'ttag'; import { DriveCrypto } from "../../crypto"; import { ValidationError } from "../../errors"; -import { ProtonDriveAccount, ProtonDriveTelemetry, NodeType } from "../../interface"; +import { ProtonDriveAccount, ProtonDriveTelemetry, NodeType, ThumbnailType, ThumbnailResult } from "../../interface"; import { DriveAPIService } from "../apiService"; import { DownloadAPIService } from "./apiService"; import { DownloadCryptoService } from "./cryptoService"; @@ -11,6 +11,7 @@ import { FileDownloader } from "./fileDownloader"; import { DownloadQueue } from "./queue"; import { DownloadTelemetry } from "./telemetry"; import { makeNodeUidFromRevisionUid } from "../uids"; +import { ThumbnailDownloader } from './thumbnailDownloader'; export function initDownloadModule( telemetry: ProtonDriveTelemetry, @@ -25,7 +26,7 @@ export function initDownloadModule( const cryptoService = new DownloadCryptoService(driveCrypto, account); const downloadTelemetry = new DownloadTelemetry(telemetry); - async function getFileDownloader(nodeUid: string, signal?: AbortSignal) { + async function getFileDownloader(nodeUid: string, signal?: AbortSignal): Promise { await queue.waitForCapacity(signal); let node, nodeKey; @@ -64,7 +65,7 @@ export function initDownloadModule( ); } - async function getFileRevisionDownloader(nodeRevisionUid: string, signal?: AbortSignal) { + async function getFileRevisionDownloader(nodeRevisionUid: string, signal?: AbortSignal): Promise { await queue.waitForCapacity(signal); const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid); @@ -103,8 +104,18 @@ export function initDownloadModule( ); } + async function *iterateThumbnails( + nodeUids: string[], + thumbnailType?: ThumbnailType, + signal?: AbortSignal, + ): AsyncGenerator { + const thumbnailDownloader = new ThumbnailDownloader(telemetry, nodesService, api, cryptoService); + yield* thumbnailDownloader.iterateThumbnails(nodeUids, thumbnailType, signal); + } + return { getFileDownloader, getFileRevisionDownloader, + iterateThumbnails, } } diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index f364c51f..6eb9e62f 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,5 +1,6 @@ import { PrivateKey, PublicKey, SessionKey } from "../../crypto"; -import { NodeType, Result, Revision } from "../../interface"; +import { NodeType, Result, Revision, MissingNode } from "../../interface"; +import { DecryptedNode } from "../nodes"; export type BlockMetadata = { index: number, @@ -19,6 +20,7 @@ export type RevisionKeys = { export interface NodesService { getNode(nodeUid: string): Promise, getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, contentKeyPacketSessionKey?: SessionKey; }>, + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } export interface NodesServiceNode { diff --git a/js/sdk/src/internal/download/thumbnailDownloader.test.ts b/js/sdk/src/internal/download/thumbnailDownloader.test.ts new file mode 100644 index 00000000..ec4ce47e --- /dev/null +++ b/js/sdk/src/internal/download/thumbnailDownloader.test.ts @@ -0,0 +1,226 @@ +import { ProtonDriveTelemetry } from '../../interface'; +import { getMockTelemetry } from "../../tests/telemetry"; +import { ThumbnailDownloader } from './thumbnailDownloader'; +import { DownloadAPIService } from './apiService'; +import { DownloadCryptoService } from './cryptoService'; +import { NodesService } from './interface'; + +describe('ThumbnailDownloader', () => { + let telemetry: ProtonDriveTelemetry; + let nodesService: NodesService; + let apiService: DownloadAPIService; + let cryptoService: DownloadCryptoService; + let downloader: ThumbnailDownloader; + + beforeEach(() => { + telemetry = getMockTelemetry(); + + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + iterateNodes: jest.fn().mockImplementation(async function* (nodeUids: string[]) { + for (const nodeUid of nodeUids) { + yield { + uid: nodeUid, + type: 'file', + activeRevision: { + ok: true, + value: { + thumbnails: [{ type: 1, uid: `thumb-${nodeUid}` }], + }, + }, + } + } + }), + getNodeKeys: jest.fn().mockReturnValue({ + contentKeyPacketSessionKey: 'contentKeyPacketSessionKey', + }), + } as NodesService; + + // @ts-expect-error No need to implement all methods for mocking + apiService = { + iterateThumbnails: jest.fn().mockImplementation(async function* (thumbnailUids: string[]) { + for (const thumbnailUid of thumbnailUids) { + yield { + uid: thumbnailUid, + ok: true, + bareUrl: `url-${thumbnailUid}`, + token: `token-${thumbnailUid}`, + } + } + }), + downloadBlock: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + } as DownloadAPIService; + + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + decryptThumbnail: jest.fn().mockImplementation(async (thumbnail: Uint8Array) => thumbnail), + } as DownloadCryptoService; + + downloader = new ThumbnailDownloader(telemetry, nodesService, apiService, cryptoService); + }); + + it('should handle all success cases', async () => { + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1', 'node2', 'node3'])); + + expect(results).toEqual([ + { nodeUid: 'node1', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }, + { nodeUid: 'node2', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }, + { nodeUid: 'node3', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }, + ]); + expect(nodesService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3'], undefined); + expect(apiService.iterateThumbnails).toHaveBeenCalledWith(['thumb-node1', 'thumb-node2', 'thumb-node3'], undefined); + expect(nodesService.getNodeKeys).toHaveBeenCalledTimes(3); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptThumbnail).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptThumbnail).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), 'contentKeyPacketSessionKey'); + }); + + it('should handle no requested node', async () => { + const results = await Array.fromAsync(downloader.iterateThumbnails([])); + + expect(results).toEqual([]); + expect(nodesService.iterateNodes).not.toHaveBeenCalled(); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle failure when requesting nodes', async () => { + nodesService.iterateNodes = jest.fn().mockImplementation(() => { + throw new Error('Failed to fetch nodes'); + }); + + const results = Array.fromAsync(downloader.iterateThumbnails(['node1'])); + await expect(results).rejects.toThrow('Failed to fetch nodes'); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle missing node', async () => { + nodesService.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { missingUid: 'node1' }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Node not found' }]); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle node that is not a file', async () => { + nodesService.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { uid: 'node1', type: 'folder' }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Node is not a file' }]); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + + it('should handle node without requested thumbnail', async () => { + nodesService.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { uid: 'node1', type: 'file', activeRevision: { ok: true, value: { thumbnails: [] } } }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Node has no thumbnail' }]); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle API failure to provide token for thumbnail', async () => { + apiService.iterateThumbnails = jest.fn().mockImplementation(async function* () { + yield { uid: 'thumb-node1', ok: false, error: 'Failed to fetch token' }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Failed to fetch token' }]); + expect(apiService.downloadBlock).not.toHaveBeenCalled(); + }); + + it('should handle API providing unexpected thumbnail', async () => { + apiService.iterateThumbnails = jest.fn().mockImplementation(async function* () { + yield { uid: 'thumb-unexpected', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Thumbnail not found' }]); + expect(apiService.downloadBlock).not.toHaveBeenCalled(); + }); + + it('should handle failure when downloading block', async () => { + apiService.downloadBlock = jest.fn().mockRejectedValue(new Error('Failed to download thumbnail')); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Failed to download thumbnail' }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + }); + + it('should handle one-off failure when downloading block', async () => { + let callCount = 0; + apiService.downloadBlock = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('Failed to download block')); + } + return Promise.resolve(new Uint8Array([1, 2, 3])); + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(2); + }); + + it('should handle failure when getting node keys', async () => { + nodesService.getNodeKeys = jest.fn().mockRejectedValue(new Error('Failed to get node keys')); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Failed to get node keys' }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + }); + + it('should handle one-off failure when getting node keys', async () => { + let callCount = 0; + nodesService.getNodeKeys = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('Failed to get node keys')); + } + return Promise.resolve({ contentKeyPacketSessionKey: 'contentKeyPacketSessionKey' }); + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(2); + }); + + it('should handle failure when decrypting block', async () => { + cryptoService.decryptThumbnail = jest.fn().mockRejectedValue(new Error('Failed to decrypt thumbnail')); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Failed to decrypt thumbnail' }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + }); + + it('should handle one-off failure when decrypting block', async () => { + let callCount = 0; + cryptoService.decryptThumbnail = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('Failed to decrypt thumbnail')); + } + return Promise.resolve(new Uint8Array([1, 2, 3])); + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/js/sdk/src/internal/download/thumbnailDownloader.ts b/js/sdk/src/internal/download/thumbnailDownloader.ts new file mode 100644 index 00000000..4800c197 --- /dev/null +++ b/js/sdk/src/internal/download/thumbnailDownloader.ts @@ -0,0 +1,250 @@ +import { c } from 'ttag'; + +import { NodeType, ThumbnailType, ProtonDriveTelemetry, Logger, ThumbnailResult } from "../../interface"; +import { ValidationError } from '../../errors'; +import { LoggerWithPrefix } from '../../telemetry'; +import { DownloadAPIService } from "./apiService"; +import { DownloadCryptoService } from "./cryptoService"; +import { NodesService } from "./interface"; +import { getErrorMessage } from '../errors'; + +/** + * Maximum number of thumbnails that can be downloaded at the same time. + */ +const MAX_DOWNLOAD_THUMBNAILS = 10; + +/** + * Maximum number of retries for thumbnail download and decryption. + */ +const MAX_THUMBNAIL_DOWNLOAD_ATTEMPTS = 2; + +export class ThumbnailDownloader { + private logger: Logger; + + private batchThumbnailToNodeUids = new Map(); + private ongoingDownloads = new Map>(); + private bufferedThumbnails: ( + { nodeUid: string, ok: true, thumbnail: Uint8Array } | + { nodeUid: string, ok: false, error: string } + )[] = []; + + constructor( + telemetry: ProtonDriveTelemetry, + private nodesService: NodesService, + private apiService: DownloadAPIService, + private cryptoService: DownloadCryptoService, + ) { + this.logger = telemetry.getLogger("download"); + this.nodesService = nodesService; + this.apiService = apiService; + this.cryptoService = cryptoService; + } + + async *iterateThumbnails( + nodeUids: string[], + thumbnailType = ThumbnailType.Type1, + signal?: AbortSignal, + ): AsyncGenerator { + if (nodeUids.length === 0) { + return; + } + + for await (const result of this.iterateThumbnailUids(nodeUids, thumbnailType, signal)) { + if (!result.ok) { + yield result; + continue; + } + + this.batchThumbnailToNodeUids.set(result.thumbnailUid, result.nodeUid); + if (this.batchThumbnailToNodeUids.size >= MAX_DOWNLOAD_THUMBNAILS) { + await this.requestBatchedThumbnailDownloads(signal); + } + + while (this.ongoingDownloads.size >= MAX_DOWNLOAD_THUMBNAILS) { + await Promise.race(this.ongoingDownloads.values()); + yield* this.bufferedThumbnails; + this.bufferedThumbnails = []; + } + } + + await this.requestBatchedThumbnailDownloads(signal); + + while (this.ongoingDownloads.size > 0) { + await Promise.race(this.ongoingDownloads.values()); + yield* this.bufferedThumbnails; + this.bufferedThumbnails = []; + } + + yield* this.bufferedThumbnails; + this.bufferedThumbnails = []; + } + + private async *iterateThumbnailUids(nodeUids: string[], thumbnailType: ThumbnailType, signal?: AbortSignal): AsyncGenerator< + { nodeUid: string, ok: true, thumbnailUid: string } | + { nodeUid: string, ok: false, error: string } + > { + for await (const node of this.nodesService.iterateNodes(nodeUids, signal)) { + if ('missingUid' in node) { + yield { + nodeUid: node.missingUid, + ok: false, + error: c("Error").t`Node not found`, + } + continue; + } + if (node.type !== NodeType.File) { + yield { + nodeUid: node.uid, + ok: false, + error: c("Error").t`Node is not a file`, + } + continue; + } + + let thumbnail; + if (node.activeRevision?.ok) { + thumbnail = node.activeRevision.value.thumbnails.find( + (t) => t.type === thumbnailType, + ); + } + if (!thumbnail) { + yield { + nodeUid: node.uid, + ok: false, + error: c("Error").t`Node has no thumbnail`, + } + continue; + } + + yield { + nodeUid: node.uid, + ok: true, + thumbnailUid: thumbnail.uid, + } + } + } + + private async requestBatchedThumbnailDownloads(signal?: AbortSignal) { + if (this.batchThumbnailToNodeUids.size === 0) { + return; + } + + this.logger.debug(`Downloading thumbnail batch of size ${this.batchThumbnailToNodeUids.size}`); + + for await (const downloadResult of this.iterateThumbnailDownloads(signal)) { + if (!downloadResult.ok) { + this.bufferedThumbnails.push({ + nodeUid: downloadResult.nodeUid, + ok: false, + error: downloadResult.error, + }); + continue; + } + + this.ongoingDownloads.set( + downloadResult.nodeUid, + downloadResult.downloadPromise + .then((thumbnail) => { + this.bufferedThumbnails.push({ + nodeUid: downloadResult.nodeUid, + ok: true, + thumbnail, + }); + }) + .catch((error) => { + this.bufferedThumbnails.push({ + nodeUid: downloadResult.nodeUid, + ok: false, + error: getErrorMessage(error), + }); + }) + .finally(() => { + this.ongoingDownloads.delete(downloadResult.nodeUid); + }), + ); + } + + this.batchThumbnailToNodeUids.clear(); + } + + private async *iterateThumbnailDownloads(signal?: AbortSignal): AsyncGenerator< + { nodeUid: string, ok: true, downloadPromise: Promise } | + { nodeUid: string, ok: false, error: string } + > { + const missingThumbnailUids = new Set(this.batchThumbnailToNodeUids.keys()); + + for await (const result of this.apiService.iterateThumbnails( + Array.from(this.batchThumbnailToNodeUids.keys()), + signal, + )) { + const nodeUid = this.batchThumbnailToNodeUids.get(result.uid); + if (!nodeUid) { + this.logger.warn(`Unexpected thumbnail UID ${result.uid} returned from API`); + continue; + } + + missingThumbnailUids.delete(result.uid); + + if (!result.ok) { + yield { + nodeUid, + ok: false, + error: result.error, + } + continue; + } + + yield { + nodeUid, + ok: true, + downloadPromise: this.downloadThumbnail(nodeUid, result.bareUrl, result.token, signal), + } + } + + for (const uid of missingThumbnailUids) { + const nodeUid = this.batchThumbnailToNodeUids.get(uid)!; + this.logger.warn(`Thumbnail UID ${uid} not found in API response`); + yield { + nodeUid, + ok: false, + error: c("Error").t`Thumbnail not found`, + } + } + } + + private async downloadThumbnail(nodeUid: string, bareUrl: string, token: string, signal?: AbortSignal): Promise { + const logger = new LoggerWithPrefix(this.logger, `thumbnail ${token}`); + + let decryptedBlock: Uint8Array | null = null; + let attempt = 0; + + while (!decryptedBlock) { + logger.debug(`Downloading`); + attempt++; + + try { + const [nodeKeys, encryptedBlock] = await Promise.all([ + this.nodesService.getNodeKeys(nodeUid), + this.apiService.downloadBlock(bareUrl, token, undefined, signal), + ]); + + if (!nodeKeys.contentKeyPacketSessionKey) { + throw new ValidationError(c("Error").t`File has no content key`); + } + + logger.debug(`Decrypting`); + decryptedBlock = await this.cryptoService.decryptThumbnail(encryptedBlock, nodeKeys.contentKeyPacketSessionKey); + } catch (error: unknown) { + if (attempt <= MAX_THUMBNAIL_DOWNLOAD_ATTEMPTS) { + logger.warn(`Thumbnail download failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } + + logger.error(`Thumbnail download failed`, error); + throw error; + } + } + + return decryptedBlock; + } +} diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index a03a28c2..e7d91ada 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -80,6 +80,7 @@ function generateFileNode(overrides = {}) { createdDate: new Date(1234567890000), signatureEmail: "revSigEmail", armoredExtendedAttributes: "{file}", + thumbnails: [], }, }, ...overrides diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 3533124d..a32b8d39 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -4,8 +4,8 @@ import { ProtonDriveError, ValidationError } from "../../errors"; import { Logger, NodeResult } from "../../interface"; import { RevisionState } from "../../interface/nodes"; import { DriveAPIService, drivePaths, isCodeOk, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; -import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid } from "../uids"; -import { EncryptedNode, EncryptedRevision } from "./interface"; +import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from "../uids"; +import { EncryptedNode, EncryptedRevision, Thumbnail } from "./interface"; type PostLoadLinksMetadataRequest = Extract['content']['application/json']; type PostLoadLinksMetadataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; @@ -409,6 +409,7 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin createdDate: new Date(link.File.ActiveRevision.CreateTime*1000), signatureEmail: link.File.ActiveRevision.SignatureEmail || undefined, armoredExtendedAttributes: link.File.ActiveRevision.XAttr || undefined, + thumbnails: link.File.ActiveRevision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, link.Link.LinkID, thumbnail)) || [], }, }, } @@ -428,5 +429,15 @@ function transformRevisionResponse( createdDate: new Date(revision.CreateTime*1000), signatureEmail: revision.SignatureEmail || undefined, armoredExtendedAttributes: revision.XAttr || undefined, + thumbnails: revision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, nodeId, thumbnail)) || [], + } +} + +function transformThumbnail(volumeId: string, nodeId: string, thumbnail: { ThumbnailID: string | null, Type: 1 | 2 | 3}): Thumbnail { + return { + // FIXME: Legacy thumbnails didn't have ID but we don't have them anymore. Remove typing once API doc is updated. + uid: makeNodeThumbnailUid(volumeId, nodeId, thumbnail.ThumbnailID as string), + // FIXME: We don't support any other thumbnail type yet. + type: thumbnail.Type as 1 | 2, } } diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 34cb659a..bf1cad2d 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -309,6 +309,7 @@ export class NodesCryptoService { createdDate: encryptedRevision.createdDate, contentAuthor, extendedAttributes, + thumbnails: encryptedRevision.thumbnails, } } diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 6f3cd753..da9d8035 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, Revision } from "../../interface"; +import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType } from "../../interface"; import { RevisionState } from "../../interface/nodes"; /** @@ -82,7 +82,7 @@ export interface DecryptedNode extends Omit, + activeRevision?: Result, folder?: { claimedModificationTime?: Date, }, @@ -109,6 +109,12 @@ interface BaseRevision { uid: string; state: RevisionState; createdDate: Date; // created on the server + thumbnails: Thumbnail[]; +} + +export type Thumbnail = { + uid: string; + type: ThumbnailType; } export interface EncryptedRevision extends BaseRevision { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index a610d822..16f6f031 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -235,6 +235,7 @@ export class NodesAccess { state: unparsedNode.activeRevision.value.state, createdDate: unparsedNode.activeRevision.value.createdDate, contentAuthor: unparsedNode.activeRevision.value.contentAuthor, + thumbnails: unparsedNode.activeRevision.value.thumbnails, ...extendedAttributes, }), folder: undefined, diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index 2a8dcca8..e7fe6ae1 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -12,71 +12,72 @@ export function splitDeviceUid(deviceUid: string) { } export function makeNodeUid(volumeId: string, nodeId: string) { - return `${volumeId}~${nodeId}`; + return makeUid(volumeId, nodeId); } export function splitNodeUid(nodeUid: string) { - const parts = nodeUid.split('~'); - if (parts.length !== 2) { - throw new Error(`"${nodeUid}" is not valid node UID`); - } - const [ volumeId, nodeId ] = parts; + const [volumeId, nodeId] = splitUid(nodeUid, 2, 'node'); return { volumeId, nodeId }; } +export function makeNodeRevisionUid(volumeId: string, nodeId: string, revisionId: string) { + return makeUid(volumeId, nodeId, revisionId); +} + +export function splitNodeRevisionUid(nodeRevisionUid: string) { + const [volumeId, nodeId, revisionId] = splitUid(nodeRevisionUid, 3, 'revision'); + return { volumeId, nodeId, revisionId }; +} + +export function makeNodeUidFromRevisionUid(nodeRevisionUid: string) { + const { volumeId, nodeId } = splitNodeRevisionUid(nodeRevisionUid); + return makeNodeUid(volumeId, nodeId); +} + +export function makeNodeThumbnailUid(volumeId: string, nodeId: string, thumbnailId: string) { + return makeUid(volumeId, nodeId, thumbnailId); +} + +export function splitNodeThumbnailUid(nodeThumbnailUid: string) { + const [volumeId, nodeId, thumbnailId] = splitUid(nodeThumbnailUid, 3, 'thumbnail'); + return { volumeId, nodeId, thumbnailId }; +} + export function makeInvitationUid(shareId: string, invitationId: string) { - return `${shareId}~${invitationId}`; + return makeUid(shareId, invitationId); } export function splitInvitationUid(invitationUid: string) { - const parts = invitationUid.split('~'); - if (parts.length !== 2) { - throw new Error(`"${invitationUid}" is not valid invitation UID`); - } - const [ shareId, invitationId ] = parts; + const [shareId, invitationId] = splitUid(invitationUid, 2, 'invitation'); return { shareId, invitationId }; } export function makeMemberUid(shareId: string, memberId: string) { - return `${shareId}~${memberId}`; + return makeUid(shareId, memberId); } export function splitMemberUid(memberUid: string) { - const parts = memberUid.split('~'); - if (parts.length !== 2) { - throw new Error(`"${memberUid}" is not valid member UID`); - } - const [ shareId, memberId ] = parts; + const [shareId, memberId] = splitUid(memberUid, 2, 'member'); return { shareId, memberId }; } export function makePublicLinkUid(shareId: string, publicLinkId: string) { - return `${shareId}~${publicLinkId}`; + return makeUid(shareId, publicLinkId); } export function splitPublicLinkUid(publicLinkUid: string) { - const parts = publicLinkUid.split('~'); - if (parts.length !== 2) { - throw new Error(`"${publicLinkUid}" is not valid public link UID`); - } - const [ shareId, publicLinkId ] = parts; + const [shareId, publicLinkId] = splitUid(publicLinkUid, 2, 'public link'); return { shareId, publicLinkId }; } -export function makeNodeRevisionUid(volumeId: string, nodeId: string, revisionId: string) { - return `${volumeId}~${nodeId}~${revisionId}`; +function makeUid(...parts: string[]): string { + return parts.join('~'); } -export function splitNodeRevisionUid(nodeRevisionUid: string) { - const parts = nodeRevisionUid.split('~'); - if (parts.length !== 3) { - throw new Error(`"${nodeRevisionUid}" is not valid node revision UID`); +function splitUid(uid: string, expectedParts: number, typeName: string): string[] { + const parts = uid.split('~'); + if (parts.length !== expectedParts) { + throw new Error(`"${uid}" is not a valid ${typeName} UID`); } - const [ volumeId, nodeId, revisionId ] = parts; - return { volumeId, nodeId, revisionId }; -} - -export function makeNodeUidFromRevisionUid(nodeRevisionUid: string) { - const { volumeId, nodeId } = splitNodeRevisionUid(nodeRevisionUid); - return makeNodeUid(volumeId, nodeId); + return parts; } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 273fae75..a0485d0b 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,7 +1,6 @@ import { Logger, ProtonDriveClientContructorParameters, - ProtonDriveClientInterface, NodeOrUid, MaybeNode, MaybeMissingNode, @@ -19,6 +18,8 @@ import { UploadMetadata, FileDownloader, Fileuploader, + ThumbnailType, + ThumbnailResult, } from './interface'; import { DriveCrypto } from './crypto'; import { DriveAPIService } from './internal/apiService'; @@ -40,7 +41,7 @@ import { initDevicesModule } from './internal/devices'; * and downloading/uploading files. It is the main entry point for using * the ProtonDrive SDK. */ -export class ProtonDriveClient implements Partial { +export class ProtonDriveClient { private logger: Logger; private nodes: ReturnType; private sharing: ReturnType; @@ -475,6 +476,20 @@ export class ProtonDriveClient implements Partial { return this.download.getFileRevisionDownloader(nodeRevisionUid, signal); } + /** + * Iterates the thumbnails of the given nodes. + * + * The output is not sorted and the order of the nodes is not guaranteed. + * + * @param nodeUids - List of node entities or their UIDs. + * @param thumbnailType - Type of the thumbnail to download. + * @returns An async generator of the results of the restore operation + */ + async *iterateThumbnails(nodeUids: NodeOrUid[], thumbnailType?: ThumbnailType, signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Iterating ${nodeUids.length} thumbnails`); + yield* this.download.iterateThumbnails(getUids(nodeUids), thumbnailType, signal); + } + /** * Get the file uploader to upload a new file. For uploading a new * revision, use `getFileRevisionUploader` instead. From 0ff28154324c2466ecc240d805ea098f663ae043 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 15 Apr 2025 05:23:51 +0000 Subject: [PATCH 070/791] Add context to metrics --- js/sdk/.eslintrc.js | 1 + js/sdk/src/cache/memoryCache.test.ts | 10 ++-- js/sdk/src/interface/index.ts | 3 +- js/sdk/src/interface/telemetry.ts | 14 +++-- .../internal/apiService/apiService.test.ts | 2 +- .../internal/download/fileDownloader.test.ts | 5 +- .../src/internal/download/fileDownloader.ts | 4 +- js/sdk/src/internal/download/index.ts | 9 +-- js/sdk/src/internal/download/interface.ts | 6 +- .../src/internal/download/telemetry.test.ts | 59 +++++++++++-------- js/sdk/src/internal/download/telemetry.ts | 39 ++++++++---- .../src/internal/events/coreEventManager.ts | 8 +-- .../src/internal/events/eventManager.test.ts | 12 ++-- js/sdk/src/internal/events/eventManager.ts | 4 +- js/sdk/src/internal/events/index.ts | 11 ++-- .../src/internal/events/volumeEventManager.ts | 8 +-- js/sdk/src/internal/nodes/apiService.test.ts | 2 +- js/sdk/src/internal/nodes/apiService.ts | 2 +- js/sdk/src/internal/nodes/cache.test.ts | 6 +- js/sdk/src/internal/nodes/cache.ts | 4 +- js/sdk/src/internal/nodes/cryptoCache.test.ts | 4 +- js/sdk/src/internal/nodes/cryptoCache.ts | 2 +- .../src/internal/nodes/cryptoService.test.ts | 1 + js/sdk/src/internal/nodes/cryptoService.ts | 32 ++++++---- js/sdk/src/internal/nodes/interface.ts | 3 +- js/sdk/src/internal/nodes/nodesAccess.ts | 4 +- js/sdk/src/internal/photos/albums.ts | 2 +- js/sdk/src/internal/photos/cache.ts | 4 +- js/sdk/src/internal/shares/apiService.ts | 17 +++++- js/sdk/src/internal/shares/cache.test.ts | 4 +- js/sdk/src/internal/shares/cache.ts | 2 +- .../src/internal/shares/cryptoService.test.ts | 5 +- js/sdk/src/internal/shares/cryptoService.ts | 22 +++++-- js/sdk/src/internal/shares/interface.ts | 8 +++ js/sdk/src/internal/shares/manager.test.ts | 3 +- js/sdk/src/internal/shares/manager.ts | 14 ++++- js/sdk/src/internal/sharing/cache.test.ts | 8 +-- js/sdk/src/internal/upload/apiService.ts | 2 +- .../src/internal/upload/fileUploader.test.ts | 3 +- js/sdk/src/internal/upload/fileUploader.ts | 10 ++-- js/sdk/src/internal/upload/index.ts | 4 +- js/sdk/src/internal/upload/interface.ts | 3 +- js/sdk/src/internal/upload/telemetry.test.ts | 56 ++++++++++-------- js/sdk/src/internal/upload/telemetry.ts | 36 +++++++---- js/sdk/src/protonDriveClient.ts | 2 +- 45 files changed, 295 insertions(+), 165 deletions(-) diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index a9b752d9..1f223e64 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -11,6 +11,7 @@ module.exports = { }, rules: { "tsdoc/syntax": "warn", + "@typescript-eslint/no-floating-promises": "error", }, overrides: [ { diff --git a/js/sdk/src/cache/memoryCache.test.ts b/js/sdk/src/cache/memoryCache.test.ts index 8938eaf3..5df9b913 100644 --- a/js/sdk/src/cache/memoryCache.test.ts +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -4,12 +4,12 @@ import { MemoryCache } from "./memoryCache"; describe('MemoryCache', () => { let cache: MemoryCache; - beforeEach(() => { + beforeEach(async () => { cache = new MemoryCache(); - cache.setEntity('key1', 'value1', ['tag1:hello', 'tag2:world']); - cache.setEntity('key2', 'value2', ['tag2:world']); - cache.setEntity('key3', 'value3'); + await cache.setEntity('key1', 'value1', ['tag1:hello', 'tag2:world']); + await cache.setEntity('key2', 'value2', ['tag2:world']); + await cache.setEntity('key3', 'value3'); }); it('should store and retrieve an entity', async () => { @@ -101,7 +101,7 @@ describe('MemoryCache', () => { const results: string[] = []; const { value: { key: key1 } } = await iterator.next(); results.push(key1); - cache.removeEntities([key1]); + await cache.removeEntities([key1]); let value = await iterator.next(); // key2 results.push(value.value.key); diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 5cffc3c3..79556521 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -17,7 +17,8 @@ export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; -export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent, MetricContext } from './telemetry'; +export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent } from './telemetry'; +export { MetricContext } from './telemetry'; export type { Fileuploader, UploadController, UploadMetadata } from './upload'; export type { Thumbnail, ThumbnailResult } from './thumbnail'; export { ThumbnailType } from './thumbnail'; diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 3ca972ac..8fe2592a 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -26,7 +26,7 @@ export interface MetricAPIRetrySucceededEvent { export interface MetricUploadEvent { eventName: 'upload', - context: MetricContext, + context?: MetricContext, uploadedSize: number, expectedSize: number, error?: MetricsUploadErrorType, @@ -42,7 +42,7 @@ export type MetricsUploadErrorType = export interface MetricDownloadEvent { eventName: 'download', - context: MetricContext, + context?: MetricContext, downloadedSize: number, claimedFileSize?: number, error?: MetricsDownloadErrorType, @@ -59,7 +59,7 @@ export type MetricsDownloadErrorType = export interface MetricDecryptionErrorEvent { eventName: 'decryptionError', - context: MetricContext, + context?: MetricContext, field: MetricsDecryptionErrorField, fromBefore2024?: boolean, error?: unknown, @@ -76,7 +76,7 @@ export type MetricsDecryptionErrorField = export interface MetricVerificationErrorEvent { eventName: 'verificationError', - context: MetricContext, + context?: MetricContext, field: MetricVerificationErrorField, addressMatchingDefaultShare?: boolean, fromBefore2024?: boolean, @@ -95,4 +95,8 @@ export interface MetricVolumeEventsSubscriptionsChangedEvent { numberOfVolumeSubscriptions: number, }; -export type MetricContext = 'own_volume' | 'shared' | 'shared_public' | 'photo'; +export enum MetricContext { + OwnVolume = 'own_volume', + Shared = 'shared', + SharedPublic = 'shared_public', +}; diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 1db624d4..4ae17b8a 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -14,7 +14,7 @@ describe("DriveAPIService", () => { let api: DriveAPIService; beforeEach(() => { - jest.runAllTimersAsync(); + void jest.runAllTimersAsync(); httpClient = { fetch: jest.fn(() => Promise.resolve(generateOkResponse())), diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index 432915b4..8c31bcbf 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -97,7 +97,7 @@ describe('FileDownloader', () => { expect(writer.close).toHaveBeenCalledTimes(1); expect(writer.abort).not.toHaveBeenCalled(); expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); - expect(telemetry.downloadFinished).toHaveBeenCalledWith(6); // 3 blocks of length 1, 2, 3. + expect(telemetry.downloadFinished).toHaveBeenCalledWith('revisionUid', 6); // 3 blocks of length 1, 2, 3. expect(telemetry.downloadFailed).not.toHaveBeenCalled(); expect(onFinish).toHaveBeenCalledTimes(1); } @@ -113,6 +113,7 @@ describe('FileDownloader', () => { expect(telemetry.downloadFinished).not.toHaveBeenCalled(); expect(telemetry.downloadFailed).toHaveBeenCalledTimes(1); expect(telemetry.downloadFailed).toHaveBeenCalledWith( + 'revisionUid', new Error(error), downloadedBytes === undefined ? expect.anything() : downloadedBytes, revision.claimedSize, @@ -330,7 +331,7 @@ describe('FileDownloader', () => { expect(writer.close).toHaveBeenCalledTimes(1); expect(writer.abort).not.toHaveBeenCalled(); expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); - expect(telemetry.downloadFinished).toHaveBeenCalledWith(6); // 3 blocks of length 1, 2, 3. + expect(telemetry.downloadFinished).toHaveBeenCalledWith('revisionUid', 6); // 3 blocks of length 1, 2, 3. expect(telemetry.downloadFailed).not.toHaveBeenCalled(); expect(onFinish).toHaveBeenCalledTimes(1); }); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 69e8b210..430e5397 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -131,11 +131,11 @@ export class FileDownloader { } await writer.close(); - this.telemetry.downloadFinished(fileProgress); + void this.telemetry.downloadFinished(this.revision.uid, fileProgress); this.logger.info(`Download succeeded`); } catch (error: unknown) { this.logger.error(`Download failed`, error); - this.telemetry.downloadFailed(error, fileProgress, this.getClaimedSizeInBytes()); + void this.telemetry.downloadFailed(this.revision.uid, error, fileProgress, this.getClaimedSizeInBytes()); await writer.abort(); throw error; } finally { diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts index 4e67e656..d4dcc810 100644 --- a/js/sdk/src/internal/download/index.ts +++ b/js/sdk/src/internal/download/index.ts @@ -6,7 +6,7 @@ import { ProtonDriveAccount, ProtonDriveTelemetry, NodeType, ThumbnailType, Thum import { DriveAPIService } from "../apiService"; import { DownloadAPIService } from "./apiService"; import { DownloadCryptoService } from "./cryptoService"; -import { NodesService, RevisionsService } from "./interface"; +import { NodesService, RevisionsService, SharesService } from "./interface"; import { FileDownloader } from "./fileDownloader"; import { DownloadQueue } from "./queue"; import { DownloadTelemetry } from "./telemetry"; @@ -18,13 +18,14 @@ export function initDownloadModule( apiService: DriveAPIService, driveCrypto: DriveCrypto, account: ProtonDriveAccount, + sharesService: SharesService, nodesService: NodesService, revisionsService: RevisionsService, ) { const queue = new DownloadQueue(); const api = new DownloadAPIService(apiService); const cryptoService = new DownloadCryptoService(driveCrypto, account); - const downloadTelemetry = new DownloadTelemetry(telemetry); + const downloadTelemetry = new DownloadTelemetry(telemetry, sharesService); async function getFileDownloader(nodeUid: string, signal?: AbortSignal): Promise { await queue.waitForCapacity(signal); @@ -45,7 +46,7 @@ export function initDownloadModule( } } catch (error: unknown) { queue.releaseCapacity(); - downloadTelemetry.downloadInitFailed(error); + void downloadTelemetry.downloadInitFailed(nodeUid, error); throw error; } @@ -84,7 +85,7 @@ export function initDownloadModule( } } catch (error: unknown) { queue.releaseCapacity(); - downloadTelemetry.downloadInitFailed(error); + void downloadTelemetry.downloadInitFailed(nodeUid, error); throw error; } diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index 6eb9e62f..ffae243e 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, PublicKey, SessionKey } from "../../crypto"; -import { NodeType, Result, Revision, MissingNode } from "../../interface"; +import { NodeType, Result, Revision, MissingNode, MetricContext } from "../../interface"; import { DecryptedNode } from "../nodes"; export type BlockMetadata = { @@ -17,6 +17,10 @@ export type RevisionKeys = { verificationKeys?: PublicKey[], } +export interface SharesService { + getVolumeMetricContext(volumeId: string): Promise, +} + export interface NodesService { getNode(nodeUid: string): Promise, getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, contentKeyPacketSessionKey?: SessionKey; }>, diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts index 633938ee..0fbbd384 100644 --- a/js/sdk/src/internal/download/telemetry.test.ts +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -1,12 +1,17 @@ import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from '../../errors'; import { ProtonDriveTelemetry } from '../../interface'; import { APIHTTPError } from '../apiService'; +import { SharesService } from './interface'; import { DownloadTelemetry } from './telemetry'; describe('DownloadTelemetry', () => { let mockTelemetry: jest.Mocked; + let sharesService: jest.Mocked; let downloadTelemetry: DownloadTelemetry; + const nodeUid = 'volumeId~nodeId'; + const revisionUid = 'volumeId~nodeId~revisionId'; + beforeEach(() => { mockTelemetry = { logEvent: jest.fn(), @@ -18,11 +23,15 @@ describe('DownloadTelemetry', () => { }), } as unknown as jest.Mocked; - downloadTelemetry = new DownloadTelemetry(mockTelemetry); + sharesService = { + getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), + } + + downloadTelemetry = new DownloadTelemetry(mockTelemetry, sharesService); }); - it('should log failure during init (excludes file size)', () => { - downloadTelemetry.downloadInitFailed(new Error('Failed')); + it('should log failure during init (excludes file size)', async () => { + await downloadTelemetry.downloadInitFailed(nodeUid, new Error('Failed')); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "download", @@ -32,8 +41,8 @@ describe('DownloadTelemetry', () => { }); }); - it('should log failure download', () => { - downloadTelemetry.downloadFailed(new Error('Failed'), 123, 456); + it('should log failure download', async () => { + await downloadTelemetry.downloadFailed(revisionUid, new Error('Failed'), 123, 456); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "download", @@ -44,8 +53,8 @@ describe('DownloadTelemetry', () => { }); }); - it('should log successful download (excludes error)', () => { - downloadTelemetry.downloadFinished(500); + it('should log successful download (excludes error)', async () => { + await downloadTelemetry.downloadFinished(revisionUid, 500); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "download", @@ -64,61 +73,61 @@ describe('DownloadTelemetry', () => { ); } - it('should ignore ValidationError', () => { + it('should ignore ValidationError', async () => { const error = new ValidationError('Validation error'); - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); }); - it('should ignore AbortError', () => { + it('should ignore AbortError', async () => { const error = new Error('Aborted'); error.name = 'AbortError'; - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); }); - it('should detect "rate_limited" error for RateLimitedError', () => { + it('should detect "rate_limited" error for RateLimitedError', async () => { const error = new RateLimitedError('Rate limited'); - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('rate_limited'); }); - it('should detect "decryption_error" for DecryptionError', () => { + it('should detect "decryption_error" for DecryptionError', async () => { const error = new DecryptionError('Decryption failed'); - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('decryption_error'); }); - it('should detect "integrity_error" for IntegrityError', () => { + it('should detect "integrity_error" for IntegrityError', async () => { const error = new IntegrityError('Integrity check failed'); - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('integrity_error'); }); - it('should detect "4xx" error for APIHTTPError with 4xx status code', () => { + it('should detect "4xx" error for APIHTTPError with 4xx status code', async () => { const error = new APIHTTPError('Client error', 404); - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('4xx'); }); - it('should detect "5xx" error for APIHTTPError with 5xx status code', () => { + it('should detect "5xx" error for APIHTTPError with 5xx status code', async () => { const error = new APIHTTPError('Server error', 500); - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('5xx'); }); - it('should detect "server_error" for TimeoutError', () => { + it('should detect "server_error" for TimeoutError', async () => { const error = new Error('Timeout'); error.name = 'TimeoutError'; - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('server_error'); }); - it('should detect "network_error" for NetworkError', () => { + it('should detect "network_error" for NetworkError', async () => { const error = new Error('Network error'); error.name = 'NetworkError'; - downloadTelemetry.downloadFailed(error, 100, 200); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('network_error'); }); }); diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index 3c266d62..baeb6169 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -1,19 +1,25 @@ import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from "../../errors"; -import { ProtonDriveTelemetry, MetricsDownloadErrorType } from "../../interface"; +import { ProtonDriveTelemetry, MetricsDownloadErrorType, Logger } from "../../interface"; import { LoggerWithPrefix } from "../../telemetry"; import { APIHTTPError } from '../apiService'; +import { splitNodeRevisionUid, splitNodeUid } from "../uids"; +import { SharesService } from "./interface"; export class DownloadTelemetry { - constructor(private telemetry: ProtonDriveTelemetry) { + private logger: Logger; + + constructor(private telemetry: ProtonDriveTelemetry, private sharesService: SharesService) { this.telemetry = telemetry; + this.logger = this.telemetry.getLogger("download"); + this.sharesService = sharesService; } getLoggerForRevision(revisionUid: string) { - const logger = this.telemetry.getLogger("download"); - return new LoggerWithPrefix(logger, `revision ${revisionUid}`); + return new LoggerWithPrefix(this.logger, `revision ${revisionUid}`); } - downloadInitFailed(error: unknown) { + async downloadInitFailed(nodeUid: string, error: unknown) { + const { volumeId } = splitNodeUid(nodeUid); const errorCategory = getErrorCategory(error); // No error category means ignored error from telemetry. @@ -22,13 +28,14 @@ export class DownloadTelemetry { return; } - this.sendTelemetry({ + await this.sendTelemetry(volumeId, { downloadedSize: 0, error: errorCategory, }); } - downloadFailed(error: unknown, downloadedSize: number, claimedFileSize?: number) { + async downloadFailed(revisionUid: string, error: unknown, downloadedSize: number, claimedFileSize?: number) { + const { volumeId } = splitNodeRevisionUid(revisionUid); const errorCategory = getErrorCategory(error); // No error category means ignored error from telemetry. @@ -37,28 +44,36 @@ export class DownloadTelemetry { return; } - this.sendTelemetry({ + await this.sendTelemetry(volumeId, { downloadedSize, claimedFileSize, error: errorCategory, }); } - downloadFinished(downloadedSize: number) { - this.sendTelemetry({ + async downloadFinished(revisionUid: string, downloadedSize: number) { + const { volumeId } = splitNodeRevisionUid(revisionUid); + await this.sendTelemetry(volumeId, { downloadedSize, claimedFileSize: downloadedSize, }); } - private sendTelemetry(options: { + private async sendTelemetry(volumeId: string, options: { downloadedSize: number, claimedFileSize?: number, error?: MetricsDownloadErrorType, }) { + let context; + try { + context = await this.sharesService.getVolumeMetricContext(volumeId); + } catch (error: unknown) { + this.logger.error('Failed to get metric context', error); + } + this.telemetry.logEvent({ eventName: 'download', - context: 'own_volume', // TODO: pass context + context, ...options, }); } diff --git a/js/sdk/src/internal/events/coreEventManager.ts b/js/sdk/src/internal/events/coreEventManager.ts index ddda1189..75a22ba0 100644 --- a/js/sdk/src/internal/events/coreEventManager.ts +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -41,12 +41,12 @@ export class CoreEventManager { return this.apiService.getCoreLatestEventId(); } - startSubscription(): void { - this.manager.start(); + async startSubscription(): Promise { + await this.manager.start(); } - stopSubscription(): void { - this.manager.stop(); + async stopSubscription(): Promise { + await this.manager.stop(); } addListener(callback: (events: DriveEvent[]) => Promise): void { diff --git a/js/sdk/src/internal/events/eventManager.test.ts b/js/sdk/src/internal/events/eventManager.test.ts index e5dedd6b..34cb1b76 100644 --- a/js/sdk/src/internal/events/eventManager.test.ts +++ b/js/sdk/src/internal/events/eventManager.test.ts @@ -37,7 +37,7 @@ describe("EventManager", () => { }); it("should get latest event ID on first run only", async () => { - manager.start(); + await manager.start(); expect(getLastEventIdMock).toHaveBeenCalledTimes(1); expect(getEventsMock).toHaveBeenCalledTimes(0); expect(listenerMock).toHaveBeenCalledTimes(0); @@ -45,7 +45,7 @@ describe("EventManager", () => { }); it("should notify about events in the next run", async () => { - manager.start(); + await manager.start(); expect(getLastEventIdMock).toHaveBeenCalledTimes(1); expect(getEventsMock).toHaveBeenCalledTimes(0); expect(listenerMock).toHaveBeenCalledTimes(0); @@ -64,7 +64,7 @@ describe("EventManager", () => { refresh: false, events: lastEventId === "eventId1" ? ["event1", "event2"] : ["event3"], })); - manager.start(); + await manager.start(); await jest.runOnlyPendingTimersAsync(); expect(getEventsMock).toHaveBeenCalledTimes(2); expect(listenerMock).toHaveBeenCalledTimes(2); @@ -77,7 +77,7 @@ describe("EventManager", () => { it("should refresh if event does not exist", async () => { getEventsMock.mockImplementation(() => Promise.reject(new NotFoundAPIError('Event not found', 2501))); - manager.start(); + await manager.start(); await jest.runOnlyPendingTimersAsync(); expect(getLastEventIdMock).toHaveBeenCalledTimes(2); expect(listenerMock).toHaveBeenCalledTimes(1); @@ -100,7 +100,7 @@ describe("EventManager", () => { events: ["event1", "event2"], }); }); - manager.start(); + await manager.start(); // First failure. await jest.runOnlyPendingTimersAsync(); @@ -126,7 +126,7 @@ describe("EventManager", () => { }); it("should stop polling", async () => { - manager.start(); + await manager.start(); await manager.stop(); await jest.runOnlyPendingTimersAsync(); expect(getEventsMock).toHaveBeenCalledTimes(0); diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts index 6f48da9d..3c186226 100644 --- a/js/sdk/src/internal/events/eventManager.ts +++ b/js/sdk/src/internal/events/eventManager.ts @@ -64,8 +64,8 @@ export class EventManager { this.listeners.push(callback); } - start(): void { - this.stop(); + async start(): Promise { + await this.stop(); this.processPromise = this.processEvents(); } diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 3c6e7f2b..19d196e7 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -50,8 +50,11 @@ export class DriveEventsService { this.sendNumberOfVolumSubscriptionsToTelemetry(); this.subscribedToRemoteDataUpdates = true; - this.coreEvents.startSubscription(); - Object.values(this.volumesEvents).forEach((volumeEvents) => volumeEvents.startSubscription()); + await this.coreEvents.startSubscription(); + await Promise.all( + Object.values(this.volumesEvents) + .map((volumeEvents) => volumeEvents.startSubscription()) + ); } /** @@ -75,7 +78,7 @@ export class DriveEventsService { // FIXME: Use dynamic algorithm to determine polling interval for non-own volumes. volumeEvents.setPollingInterval(isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL); if (this.subscribedToRemoteDataUpdates) { - volumeEvents.startSubscription(); + await volumeEvents.startSubscription(); this.sendNumberOfVolumSubscriptionsToTelemetry(); } } @@ -89,7 +92,7 @@ export class DriveEventsService { } } - private async sendNumberOfVolumSubscriptionsToTelemetry() { + private sendNumberOfVolumSubscriptionsToTelemetry() { this.telemetry.logEvent({ eventName: 'volumeEventsSubscriptionsChanged', numberOfVolumeSubscriptions: Object.keys(this.volumesEvents).length, diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index 58c913d5..73a83b33 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -53,12 +53,12 @@ export class VolumeEventManager { this.manager.pollingIntervalInSeconds = pollingIntervalInSeconds; } - startSubscription(): void { - this.manager.start(); + async startSubscription(): Promise { + await this.manager.start(); } - stopSubscription(): void { - this.manager.stop(); + async stopSubscription(): Promise { + await this.manager.stop(); } addListener(callback: DriveListener): void { diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index e7d91ada..0d5f9cd4 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -246,7 +246,7 @@ describe("nodeAPIService", () => { expect(node2.value).toStrictEqual(generateFileNode()); const node3 = generator.next(); - expect(node3).rejects.toThrow('Failed to load some nodes'); + await expect(node3).rejects.toThrow('Failed to load some nodes'); try { await node3; } catch (error: any) { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index a32b8d39..838fbde9 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -59,7 +59,7 @@ export class NodeAPIService { async getNode(nodeUid: string, signal?: AbortSignal): Promise { const nodesGenerator = this.iterateNodes([nodeUid], signal); const result = await nodesGenerator.next(); - nodesGenerator.return("finish"); + await nodesGenerator.return("finish"); return result.value; } diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index afc4609d..2d7ee4c8 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -64,10 +64,10 @@ describe('nodesCache', () => { let memoryCache: MemoryCache; let cache: NodesCache; - beforeEach(() => { + beforeEach(async () => { memoryCache = new MemoryCache(); - memoryCache.setEntity('node-volumeId~:root', JSON.stringify(generateNode('root', ''))); - memoryCache.setEntity('node-badObject', 'aaa', [`${CACHE_TAG_KEYS.ParentUid}:root`]); + await memoryCache.setEntity('node-volumeId~:root', JSON.stringify(generateNode('root', ''))); + await memoryCache.setEntity('node-badObject', 'aaa', [`${CACHE_TAG_KEYS.ParentUid}:root`]); cache = new NodesCache(getMockLogger(), memoryCache); }); diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 5ac546d1..ff1ceecd 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -49,7 +49,7 @@ export class NodesCache { try { return deserialiseNode(nodeData); } catch (error: unknown) { - this.removeCorruptedNode({ nodeUid }, error); + await this.removeCorruptedNode({ nodeUid }, error); throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`) } } @@ -187,7 +187,7 @@ export class NodesCache { async setFolderChildrenLoaded(nodeUid: string): Promise { const { volumeId } = splitNodeUid(nodeUid); - this.driveCache.setEntity(`node-children-${nodeUid}`, 'loaded', [`children-volume:${volumeId}`]); + await this.driveCache.setEntity(`node-children-${nodeUid}`, 'loaded', [`children-volume:${volumeId}`]); } async resetFolderChildrenLoaded(nodeUid: string): Promise { diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index a9ca7965..485870ce 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -16,9 +16,9 @@ describe('nodesCryptoCache', () => { return name as unknown as SessionKey } - beforeEach(() => { + beforeEach(async () => { memoryCache = new MemoryCache(); - memoryCache.setEntity('nodeKeys-missingPassphrase', { + await memoryCache.setEntity('nodeKeys-missingPassphrase', { key: 'privateKey', sessionKey: 'sessionKey', } as any); diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts index 45810a93..47a3f1b6 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -15,7 +15,7 @@ export class NodesCryptoCache { async setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys): Promise { const cacheUid = getCacheKey(nodeUid); - this.driveCache.setEntity(cacheUid, keys); + await this.driveCache.setEntity(cacheUid, keys); } async getNodeKeys(nodeUid: string): Promise { diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 28137ee9..6fee5963 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -54,6 +54,7 @@ describe("nodesCryptoService", () => { email: "email", addressKey: "key" as unknown as PrivateKey, })), + getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), }; cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index bf1cad2d..8dff593c 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -71,7 +71,7 @@ export class NodesCryptoService { passphraseSessionKey = keyResult.passphraseSessionKey; keyAuthor = keyResult.author; } catch (error: unknown) { - this.reportDecryptionError(node, 'nodeKey', error); + void this.reportDecryptionError(node, 'nodeKey', error); const errorMessage = c('Error').t`Failed to decrypt node key: ${getErrorMessage(error)}`; return { node: { @@ -103,7 +103,7 @@ export class NodesCryptoService { hashKey = hashKeyResult.hashKey; hashKeyAuthor = hashKeyResult.author; } catch (error: unknown) { - this.reportDecryptionError(node, 'nodeHashKey', error); + void this.reportDecryptionError(node, 'nodeHashKey', error); errors.push(error); } @@ -123,7 +123,7 @@ export class NodesCryptoService { }; folderExtendedAttributesAuthor = extendedAttributesResult.author; } catch (error: unknown) { - this.reportDecryptionError(node, 'nodeFolderExtendedAttributes', error); + void this.reportDecryptionError(node, 'nodeFolderExtendedAttributes', error); errors.push(error); } } @@ -135,7 +135,7 @@ export class NodesCryptoService { try { activeRevision = resultOk(await this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key)); } catch (error: unknown) { - this.reportDecryptionError(node, 'nodeActiveRevision', error); + void this.reportDecryptionError(node, 'nodeActiveRevision', error); const errorMessage = c('Error').t`Failed to decrypt active revision: ${getErrorMessage(error)}`; activeRevision = resultError(new Error(errorMessage)); } @@ -159,7 +159,7 @@ export class NodesCryptoService { node.encryptedCrypto.signatureEmail, ); } catch (error: unknown) { - this.reportDecryptionError(node, 'nodeContentKey', error); + void this.reportDecryptionError(node, 'nodeContentKey', error); const errorMessage = c('Error').t`Failed to decrypt content key: ${getErrorMessage(error)}`; contentKeyPacketAuthor = resultError({ claimedAuthor: node.encryptedCrypto.signatureEmail, @@ -247,7 +247,7 @@ export class NodesCryptoService { author: await this.handleClaimedAuthor(node, 'nodeName', c('Property').t`name`, verified, nameSignatureEmail), } } catch (error: unknown) { - this.reportDecryptionError(node, 'nodeName', error); + void this.reportDecryptionError(node, 'nodeName', error); const errorMessage = getErrorMessage(error); return { name: resultError({ @@ -450,7 +450,7 @@ export class NodesCryptoService { ): Promise { const author = handleClaimedAuthor(signatureType, verified, claimedAuthor); if (!author.ok) { - await this.reportVerificationError(node, field, claimedAuthor); + void this.reportVerificationError(node, field, claimedAuthor); } return author; } @@ -466,11 +466,12 @@ export class NodesCryptoService { const fromBefore2024 = node.createdDate < new Date('2024-01-01'); - let addressMatchingDefaultShare = undefined; + let addressMatchingDefaultShare, context; try { const { volumeId } = splitNodeUid(node.uid); const { email } = await this.shareService.getVolumeEmailKey(volumeId); addressMatchingDefaultShare = claimedAuthor ? claimedAuthor === email : undefined; + context = await this.shareService.getVolumeMetricContext(volumeId); } catch (error: unknown) { this.logger.error('Failed to check if claimed author matches default share', error); } @@ -479,7 +480,7 @@ export class NodesCryptoService { this.telemetry.logEvent({ eventName: 'verificationError', - context: 'own_volume', // TODO: add context to the node + context, field, addressMatchingDefaultShare, fromBefore2024, @@ -487,17 +488,26 @@ export class NodesCryptoService { this.reportedVerificationErrors.add(node.uid); } - private reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) { + private async reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) { if (this.reportedDecryptionErrors.has(node.uid)) { return; } const fromBefore2024 = node.createdDate < new Date('2024-01-01'); + + let context; + try { + const { volumeId } = splitNodeUid(node.uid); + context = await this.shareService.getVolumeMetricContext(volumeId); + } catch (error: unknown) { + this.logger.error('Failed to get metric context', error); + } + this.logger.error(`Failed to decrypt node ${node.uid} (from before 2024: ${fromBefore2024})`, error); this.telemetry.logEvent({ eventName: 'decryptionError', - context: 'own_volume', // TODO: add context to the node + context, field, fromBefore2024, error, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index da9d8035..5ea10d82 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType } from "../../interface"; +import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType, MetricContext } from "../../interface"; import { RevisionState } from "../../interface/nodes"; /** @@ -134,4 +134,5 @@ export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string, rootNodeId: string }>, getSharePrivateKey(shareId: string): Promise, getVolumeEmailKey(volumeId: string): Promise<{ email: string, addressKey: PrivateKey }>, + getVolumeMetricContext(volumeId: string): Promise, } diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 16f6f031..69762958 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -195,13 +195,13 @@ export class NodesAccess { const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); const node = await this.parseNode(unparsedNode); try { - this.cache.setNode(node); + await this.cache.setNode(node); } catch (error: unknown) { this.logger.error(`Failed to cache node ${node.uid}`, error); } if (keys) { try { - this.cryptoCache.setNodeKeys(node.uid, keys); + await this.cryptoCache.setNodeKeys(node.uid, keys); } catch (error: unknown) { this.logger.error(`Failed to cache node keys ${node.uid}`, error); } diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index 18700132..00e668a7 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -24,6 +24,6 @@ export class Albums { async createAlbum(albumName: string) { const albumdUid = this.apiService.createAlbum(albumName); - this.cache.setAlbum(albumdUid); + await this.cache.setAlbum(albumdUid); } } diff --git a/js/sdk/src/internal/photos/cache.ts b/js/sdk/src/internal/photos/cache.ts index dfc9363d..04b50702 100644 --- a/js/sdk/src/internal/photos/cache.ts +++ b/js/sdk/src/internal/photos/cache.ts @@ -5,7 +5,7 @@ export class PhotosCache { this.driveCache = driveCache; } - setAlbum(album: any) { - this.driveCache.setEntity(album.uid, album); + async setAlbum(album: any) { + await this.driveCache.setEntity(album.uid, album); } } diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index f37930e5..8db5f05a 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -1,6 +1,6 @@ import { DriveAPIService, drivePaths } from "../apiService"; import { makeMemberUid } from "../uids"; -import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto } from "./interface"; +import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto, ShareType } from "./interface"; type PostCreateVolumeRequest = Extract['content']['application/json']; type PostCreateVolumeResponse = drivePaths['/drive/volumes']['post']['responses']['200']['content']['application/json']; @@ -36,6 +36,7 @@ export class SharesAPIService { armoredPassphraseSignature: response.Share.PassphraseSignature, }, addressId: response.Share.AddressID, + type: ShareType.Main, }; } @@ -157,5 +158,19 @@ function convertSharePayload(response: GetShareResponse): EncryptedShare { membership: response.Memberships?.[0] ? { memberUid: makeMemberUid(response.ShareID, response.Memberships[0].MemberID), } : undefined, + type: convertShareTypeNumberToEnum(response.Type), }; } + +function convertShareTypeNumberToEnum(type: 1 | 2 | 3 | 4): ShareType { + switch (type) { + case 1: + return ShareType.Main; + case 2: + return ShareType.Standard; + case 3: + return ShareType.Device; + case 4: + return ShareType.Photo; + } +} diff --git a/js/sdk/src/internal/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts index 16a1565b..acb24cc2 100644 --- a/js/sdk/src/internal/shares/cache.test.ts +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -6,9 +6,9 @@ describe('sharesCache', () => { let memoryCache: MemoryCache; let cache: SharesCache; - beforeEach(() => { + beforeEach(async () => { memoryCache = new MemoryCache(); - memoryCache.setEntity('volume-badObject', 'aaa'); + await memoryCache.setEntity('volume-badObject', 'aaa'); cache = new SharesCache(getMockLogger(), memoryCache); }); diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index 40e126cc..4072acc6 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -16,7 +16,7 @@ export class SharesCache { async setVolume(volume: Volume): Promise { const key = getCacheUid(volume.volumeId); const shareData = serializeVolume(volume); - this.driveCache.setEntity(key, shareData); + await this.driveCache.setEntity(key, shareData); } async getVolume(volumeId: string): Promise { diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts index 11a2f6ec..a260d996 100644 --- a/js/sdk/src/internal/shares/cryptoService.test.ts +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -1,7 +1,7 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; import { ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; import { getMockTelemetry } from "../../tests/telemetry"; -import { EncryptedRootShare } from "./interface"; +import { EncryptedRootShare, ShareType } from "./interface"; import { SharesCryptoService } from "./cryptoService"; describe("SharesCryptoService", () => { @@ -42,6 +42,7 @@ describe("SharesCryptoService", () => { armoredPassphrase: "armoredPassphrase", armoredPassphraseSignature: "armoredPassphraseSignature", }, + type: ShareType.Main, } as EncryptedRootShare, ); @@ -79,6 +80,7 @@ describe("SharesCryptoService", () => { armoredPassphrase: "armoredPassphrase", armoredPassphraseSignature: "armoredPassphraseSignature", }, + type: ShareType.Main, } as EncryptedRootShare, ); @@ -118,6 +120,7 @@ describe("SharesCryptoService", () => { armoredPassphrase: "armoredPassphrase", armoredPassphraseSignature: "armoredPassphraseSignature", }, + type: ShareType.Main, } as EncryptedRootShare, ); diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 8ea70b06..69866095 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -1,7 +1,7 @@ -import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger } from "../../interface"; +import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger, MetricContext } from "../../interface"; import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto"; import { getVerificationMessage } from "../errors"; -import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareKey } from "./interface"; +import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareKey, ShareType } from "./interface"; /** * Provides crypto operations for share keys. @@ -102,7 +102,7 @@ export class SharesCryptoService { this.telemetry.logEvent({ eventName: 'decryptionError', - context: 'own_volume', // TODO: add context to the share + context: shareTypeToMetricContext(share.type), field: 'shareKey', fromBefore2024, error, @@ -121,7 +121,7 @@ export class SharesCryptoService { this.telemetry.logEvent({ eventName: 'verificationError', - context: 'own_volume', // TODO: add context to the share + context: shareTypeToMetricContext(share.type), field: 'shareKey', addressMatchingDefaultShare, fromBefore2024, @@ -129,3 +129,17 @@ export class SharesCryptoService { this.reportedVerificationErrors.add(share.shareId); } } + +function shareTypeToMetricContext(shareType: ShareType): MetricContext { + // SDK doesn't support public sharing yet, also public sharing + // doesn't use a share but shareURL, thus we can simplify and + // ignore this case for now. + switch (shareType) { + case ShareType.Main: + case ShareType.Device: + case ShareType.Photo: + return MetricContext.OwnVolume; + case ShareType.Standard: + return MetricContext.Shared; + } +} diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index 4e6e5e32..1975cb4c 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -41,8 +41,16 @@ type BaseShare = { */ addressId?: string; createdDate?: Date; + type: ShareType; } & VolumeShareNodeIDs; +export enum ShareType { + Main = 'main', + Standard = 'standard', + Device = 'device', + Photo = 'photo', +} + interface BaseRootShare extends BaseShare { /** * Address ID is always available for root shares, in contrast diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts index cd21220b..38928fae 100644 --- a/js/sdk/src/internal/shares/manager.test.ts +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -110,10 +110,9 @@ describe("SharesManager", () => { it("should throw on unknown error", async () => { apiService.getMyFiles = jest.fn().mockRejectedValue(new Error("Some error")); - expect(manager.getMyFilesIDs()).rejects.toThrow("Some error"); + await expect(manager.getMyFilesIDs()).rejects.toThrow("Some error"); expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); expect(apiService.createVolume).not.toHaveBeenCalled(); - }); }); diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 33337ebe..79b57ea8 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -1,4 +1,4 @@ -import { Logger, ProtonDriveAccount } from "../../interface"; +import { Logger, MetricContext, ProtonDriveAccount } from "../../interface"; import { PrivateKey } from "../../crypto"; import { NotFoundAPIError } from "../apiService"; import { SharesAPIService } from "./apiService"; @@ -171,6 +171,18 @@ export class SharesManager { }; } + async getVolumeMetricContext(volumeId: string): Promise { + const { volumeId: myVolumeId } = await this.getMyFilesIDs(); + + // SDK doesn't support public sharing yet, also public sharing + // doesn't use a volume but shareURL, thus we can simplify and + // ignore this case for now. + if (volumeId === myVolumeId) { + return MetricContext.OwnVolume; + } + return MetricContext.Shared; + } + async loadEncryptedShare(shareId: string): Promise { return this.apiService.getShare(shareId); } diff --git a/js/sdk/src/internal/sharing/cache.test.ts b/js/sdk/src/internal/sharing/cache.test.ts index 1a6b3abe..ebd51e69 100644 --- a/js/sdk/src/internal/sharing/cache.test.ts +++ b/js/sdk/src/internal/sharing/cache.test.ts @@ -31,7 +31,7 @@ describe("SharingCache", () => { }); it("should add node uid", async () => { - cache.setSharedByMeNodeUids(["nodeUid"]); + await cache.setSharedByMeNodeUids(["nodeUid"]); const spy = jest.spyOn(memoryCache, 'setEntity'); await cache.addSharedByMeNodeUid("newNodeUid"); @@ -42,7 +42,7 @@ describe("SharingCache", () => { }); it("should not add duplicate node uid", async () => { - cache.setSharedByMeNodeUids(["nodeUid"]); + await cache.setSharedByMeNodeUids(["nodeUid"]); const spy = jest.spyOn(memoryCache, 'setEntity'); await cache.addSharedByMeNodeUid("nodeUid"); @@ -65,7 +65,7 @@ describe("SharingCache", () => { }); it("should remove node uid", async () => { - cache.setSharedByMeNodeUids(["nodeUid"]); + await cache.setSharedByMeNodeUids(["nodeUid"]); const spy = jest.spyOn(memoryCache, 'setEntity'); await cache.removeSharedByMeNodeUid("nodeUid"); @@ -76,7 +76,7 @@ describe("SharingCache", () => { }); it("should handle removing of missing node uid", async () => { - cache.setSharedByMeNodeUids([]); + await cache.setSharedByMeNodeUids([]); const spy = jest.spyOn(memoryCache, 'setEntity'); await cache.removeSharedByMeNodeUid("nodeUid"); diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index c2c0597f..58d477fa 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -231,7 +231,7 @@ export class UploadAPIService { async deleteDraftRevision(draftNodeRevisionUid: string): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); - this.apiService.delete(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); + await this.apiService.delete(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); } async uploadBlock(url: string, token: string, block: Uint8Array, onProgress?: (uploadedBytes: number) => void, signal?: AbortSignal): Promise { diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index afa81271..78f84fda 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -173,7 +173,7 @@ describe('FileUploader', () => { expect(apiService.commitDraftRevision).toHaveBeenCalledTimes(1); expect(apiService.commitDraftRevision).toHaveBeenCalledWith(revisionDraft.nodeRevisionUid, 'commitCrypto'); expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); - expect(telemetry.uploadFinished).toHaveBeenCalledWith(metadata.expectedSize + thumbnailSize); + expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); expect(telemetry.uploadFailed).not.toHaveBeenCalled(); expect(onFinish).toHaveBeenCalledTimes(1); expect(onFinish).toHaveBeenCalledWith(false); @@ -186,6 +186,7 @@ describe('FileUploader', () => { expect(telemetry.uploadFinished).not.toHaveBeenCalled(); expect(telemetry.uploadFailed).toHaveBeenCalledTimes(1); expect(telemetry.uploadFailed).toHaveBeenCalledWith( + 'revisionUid', new Error(error), uploadedBytes === undefined ? expect.anything() : uploadedBytes, expectedSize, diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index f0240a99..a9d58e07 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -141,12 +141,12 @@ export class Fileuploader { this.logger.debug(`All blocks uploaded, committing`); await this.commitFile(thumbnails); - this.telemetry.uploadFinished(fileProgress); + void this.telemetry.uploadFinished(this.revisionDraft.nodeRevisionUid, fileProgress); this.logger.info(`Upload succeeded`); } catch (error: unknown) { failure = true; this.logger.error(`Upload failed`, error); - this.telemetry.uploadFailed(error, fileProgress, this.metadata.expectedSize); + void this.telemetry.uploadFailed(this.revisionDraft.nodeRevisionUid, error, fileProgress, this.metadata.expectedSize); throw error; } finally { this.logger.debug(`Upload cleanup`); @@ -170,7 +170,7 @@ export class Fileuploader { let encryptionError; const encryptBlocksPromise = this.encryptBlocks(stream).catch((error) => { encryptionError = error; - this.abortUpload(error); + void this.abortUpload(error); }); while (!encryptionError) { @@ -377,7 +377,7 @@ export class Fileuploader { } logger.error(`Upload failed`, error); - this.abortUpload(error); + await this.abortUpload(error); throw error; } } @@ -455,7 +455,7 @@ export class Fileuploader { } logger.error(`Upload failed`, error); - this.abortUpload(error); + await this.abortUpload(error); throw error; } } diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index a1eec481..75e26c9a 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -26,7 +26,7 @@ export function initUploadModule( ) { const api = new UploadAPIService(apiService); const cryptoService = new UploadCryptoService(driveCrypto, sharesService); - const uploadTelemetry = new UploadTelemetry(telemetry); + const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); const manager = new UploadManager(telemetry, api, cryptoService, nodesService); const queue = new UploadQueue(); @@ -50,7 +50,7 @@ export function initUploadModule( if (revisionDraft) { await manager.deleteDraftNode(revisionDraft.nodeUid); } - uploadTelemetry.uploadInitFailed(error, metadata.expectedSize); + void uploadTelemetry.uploadInitFailed(parentFolderUid, error, metadata.expectedSize); throw error; } diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 88452497..0bdb5efc 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { ThumbnailType } from "../../interface"; +import { MetricContext, ThumbnailType } from "../../interface"; export type NodeRevisionDraft = { nodeUid: string, @@ -91,4 +91,5 @@ export interface NodesService { */ export interface SharesService { getVolumeEmailKey(volumeId: string): Promise<{ email: string, addressId: string, addressKey: PrivateKey }>, + getVolumeMetricContext(volumeId: string): Promise, } diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index 536cbd00..7896dbed 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -1,12 +1,17 @@ import { RateLimitedError, ValidationError, IntegrityError } from '../../errors'; import { ProtonDriveTelemetry } from '../../interface'; import { APIHTTPError } from '../apiService'; +import { SharesService } from './interface'; import { UploadTelemetry } from './telemetry'; describe('UploadTelemetry', () => { let mockTelemetry: jest.Mocked; + let sharesService: jest.Mocked; let uploadTelemetry: UploadTelemetry; + const parentNodeUid = 'volumeId~parentNodeId'; + const revisionUid = 'volumeId~nodeId~revisionId'; + beforeEach(() => { mockTelemetry = { logEvent: jest.fn(), @@ -18,11 +23,16 @@ describe('UploadTelemetry', () => { }), } as unknown as jest.Mocked; - uploadTelemetry = new UploadTelemetry(mockTelemetry); + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), + } + + uploadTelemetry = new UploadTelemetry(mockTelemetry, sharesService); }); - it('should log failure during init (excludes uploaded size)', () => { - uploadTelemetry.uploadInitFailed(new Error('Failed'), 1000); + it('should log failure during init (excludes uploaded size)', async () => { + await uploadTelemetry.uploadInitFailed(parentNodeUid, new Error('Failed'), 1000); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "upload", @@ -33,8 +43,8 @@ describe('UploadTelemetry', () => { }); }); - it('should log failure upload', () => { - uploadTelemetry.uploadFailed(new Error('Failed'), 500, 1000); + it('should log failure upload', async () => { + await uploadTelemetry.uploadFailed(revisionUid, new Error('Failed'), 500, 1000); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "upload", @@ -45,8 +55,8 @@ describe('UploadTelemetry', () => { }); }); - it('should log successful upload (excludes error)', () => { - uploadTelemetry.uploadFinished(1000); + it('should log successful upload (excludes error)', async () => { + await uploadTelemetry.uploadFinished(revisionUid, 1000); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "upload", @@ -65,55 +75,55 @@ describe('UploadTelemetry', () => { ); }; - it('should ignore ValidationError', () => { + it('should ignore ValidationError', async () => { const error = new ValidationError('Validation error'); - uploadTelemetry.uploadFailed(error, 500, 1000); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); }); - it('should ignore AbortError', () => { + it('should ignore AbortError', async () => { const error = new Error('Aborted'); error.name = 'AbortError'; - uploadTelemetry.uploadFailed(error, 500, 1000); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); }); - it('should detect "rate_limited" error for RateLimitedError', () => { + it('should detect "rate_limited" error for RateLimitedError', async () => { const error = new RateLimitedError('Rate limited'); - uploadTelemetry.uploadFailed(error, 500, 1000); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); verifyErrorCategory('rate_limited'); }); - it('should detect "integrity_error" for IntegrityError', () => { + it('should detect "integrity_error" for IntegrityError', async () => { const error = new IntegrityError('Integrity check failed'); - uploadTelemetry.uploadFailed(error, 500, 1000); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); verifyErrorCategory('integrity_error'); }); - it('should detect "4xx" error for APIHTTPError with 4xx status code', () => { + it('should detect "4xx" error for APIHTTPError with 4xx status code', async () => { const error = new APIHTTPError('Client error', 404); - uploadTelemetry.uploadFailed(error, 500, 1000); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); verifyErrorCategory('4xx'); }); - it('should detect "5xx" error for APIHTTPError with 5xx status code', () => { + it('should detect "5xx" error for APIHTTPError with 5xx status code', async () => { const error = new APIHTTPError('Server error', 500); - uploadTelemetry.uploadFailed(error, 500, 1000); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); verifyErrorCategory('5xx'); }); - it('should detect "server_error" for TimeoutError', () => { + it('should detect "server_error" for TimeoutError', async () => { const error = new Error('Timeout'); error.name = 'TimeoutError'; - uploadTelemetry.uploadFailed(error, 500, 1000); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); verifyErrorCategory('server_error'); }); - it('should detect "network_error" for NetworkError', () => { + it('should detect "network_error" for NetworkError', async () => { const error = new Error('Network error'); error.name = 'NetworkError'; - uploadTelemetry.uploadFailed(error, 500, 1000); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); verifyErrorCategory('network_error'); }); }); diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 04b42eb5..8cb1e0dd 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -1,11 +1,17 @@ import { RateLimitedError, ValidationError, IntegrityError } from "../../errors"; -import { ProtonDriveTelemetry, MetricsUploadErrorType } from "../../interface"; +import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from "../../interface"; import { LoggerWithPrefix } from "../../telemetry"; import { APIHTTPError } from '../apiService'; +import { splitNodeUid, splitNodeRevisionUid } from "../uids"; +import { SharesService } from "./interface"; export class UploadTelemetry { - constructor(private telemetry: ProtonDriveTelemetry) { + private logger: Logger; + + constructor(private telemetry: ProtonDriveTelemetry, private sharesService: SharesService) { this.telemetry = telemetry; + this.logger = this.telemetry.getLogger("download"); + this.sharesService = sharesService; } getLoggerForRevision(revisionUid: string) { @@ -13,7 +19,8 @@ export class UploadTelemetry { return new LoggerWithPrefix(logger, `revision ${revisionUid}`); } - uploadInitFailed(error: unknown, expectedSize: number) { + async uploadInitFailed(parentFolderUid: string, error: unknown, expectedSize: number) { + const { volumeId } = splitNodeUid(parentFolderUid); const errorCategory = getErrorCategory(error); // No error category means ignored error from telemetry. @@ -22,14 +29,15 @@ export class UploadTelemetry { return; } - this.sendTelemetry({ + await this.sendTelemetry(volumeId, { uploadedSize: 0, expectedSize, error: errorCategory, }); } - uploadFailed(error: unknown, uploadedSize: number, expectedSize: number) { + async uploadFailed(revisionUid: string, error: unknown, uploadedSize: number, expectedSize: number) { + const { volumeId } = splitNodeRevisionUid(revisionUid); const errorCategory = getErrorCategory(error); // No error category means ignored error from telemetry. @@ -38,28 +46,36 @@ export class UploadTelemetry { return; } - this.sendTelemetry({ + await this.sendTelemetry(volumeId, { uploadedSize, expectedSize, error: errorCategory, }); } - uploadFinished(uploadedSize: number) { - this.sendTelemetry({ + async uploadFinished(revisionUid: string, uploadedSize: number) { + const { volumeId } = splitNodeRevisionUid(revisionUid); + await this.sendTelemetry(volumeId, { uploadedSize, expectedSize: uploadedSize, }); } - private sendTelemetry(options: { + private async sendTelemetry(volumeId: string, options: { uploadedSize: number, expectedSize: number, error?: MetricsUploadErrorType, }) { + let context; + try { + context = await this.sharesService.getVolumeMetricContext(volumeId); + } catch (error: unknown) { + this.logger.error('Failed to get metric context', error); + } + this.telemetry.logEvent({ eventName: 'upload', - context: 'own_volume', // TODO: pass context + context, ...options, }); } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index a0485d0b..5b60536a 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -70,7 +70,7 @@ export class ProtonDriveClient { const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access); - this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.nodes.access, this.nodes.revisions); + this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, shares, this.nodes.access, this.nodes.revisions); this.upload = initUploadModule(telemetry, apiService, cryptoModule, shares, this.nodes.access); this.devices = initDevicesModule(telemetry, apiService, cryptoModule, shares, this.nodes.access, this.nodes.management); } From ccee04e8f54bc2ab77991be6cbfc9775a5576e13 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 16 Apr 2025 12:02:16 +0000 Subject: [PATCH 071/791] Throw when decoding utf8 --- js/sdk/src/crypto/driveCrypto.test.ts | 23 ++++++++++++++++++++++- js/sdk/src/crypto/driveCrypto.ts | 14 +++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.test.ts b/js/sdk/src/crypto/driveCrypto.test.ts index 466d1d9b..7d196770 100644 --- a/js/sdk/src/crypto/driveCrypto.test.ts +++ b/js/sdk/src/crypto/driveCrypto.test.ts @@ -1,4 +1,25 @@ -import { arrayToHexString } from "./driveCrypto"; +import { uint8ArrayToUtf8, arrayToHexString } from "./driveCrypto"; + +describe("uint8ArrayToUtf8", () => { + it("should convert a Uint8Array to a UTF-8 string", () => { + const input = new Uint8Array([72, 101, 108, 108, 111]); + const expectedOutput = "Hello"; + const result = uint8ArrayToUtf8(input); + expect(result).toBe(expectedOutput); + }); + + it("should handle an empty Uint8Array", () => { + const input = new Uint8Array([]); + const expectedOutput = ""; + const result = uint8ArrayToUtf8(input); + expect(result).toBe(expectedOutput); + }); + + it("should throw if input is invalid", () => { + const input = new Uint8Array([887987979887897989]); + expect(() => uint8ArrayToUtf8(input)).toThrow("The encoded data was not valid for encoding utf-8"); + }); +}); describe("arrayToHexString", () => { it("should convert a Uint8Array to a hex string", () => { diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 6e436cab..8e41ed67 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -174,7 +174,7 @@ export class DriveCrypto { verificationKeys, ); - const passphrase = new TextDecoder().decode(decryptedPassphrase); + const passphrase = uint8ArrayToUtf8(decryptedPassphrase); const key = await this.openPGPCrypto.decryptKey( armoredKey, @@ -263,7 +263,7 @@ export class DriveCrypto { [], ); - const passphrase = new TextDecoder().decode(decryptedPassphrase); + const passphrase = uint8ArrayToUtf8(decryptedPassphrase); const key = await this.openPGPCrypto.decryptKey( armoredKey, @@ -379,7 +379,7 @@ export class DriveCrypto { verificationKeys, ); return { - name: new TextDecoder().decode(name), + name: uint8ArrayToUtf8(name), verified, } } @@ -448,7 +448,7 @@ export class DriveCrypto { ); return { - extendedAttributes: new TextDecoder().decode(decryptedExtendedAttributes), + extendedAttributes: uint8ArrayToUtf8(decryptedExtendedAttributes), verified, }; } @@ -644,10 +644,14 @@ export class DriveCrypto { armoredPassword, decryptionKeys, ); - return new TextDecoder().decode(password); + return uint8ArrayToUtf8(password); } } +export function uint8ArrayToUtf8(input: Uint8Array): string { + return new TextDecoder('utf-8', { fatal: true }).decode(input); +} + /** * Convert an array of 8-bit integers to a hex string * @param bytes - Array of 8-bit integers to convert From 18e9ec651fec5fd6d0e733656d0d7e3e00317c51 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 16 Apr 2025 15:15:29 +0000 Subject: [PATCH 072/791] Implement listing of file system --- cs/Directory.Build.props | 2 +- cs/sdk/src/Directory.Packages.props | 16 +- .../Proton.Drive.Sdk/Api/DriveApiClients.cs | 4 +- .../Api/Folders/FolderChildrenResponse.cs | 16 + .../Api/Folders/FoldersApiClient.cs | 24 + .../Api/Folders/IFoldersApiClient.cs | 13 + .../Proton.Drive.Sdk/Api/IDriveApiClients.cs | 4 +- .../Api/Links/ActiveRevisionDto.cs | 29 ++ .../src/Proton.Drive.Sdk/Api/Links/FileDto.cs | 18 + .../Api/Links/LinkDetailsDto.cs | 6 +- .../src/Proton.Drive.Sdk/Api/Links/LinkDto.cs | 3 - .../Proton.Drive.Sdk/Api/Links/RevisionDto.cs | 2 +- .../Api/Links/ShareMembershipSummaryDto.cs | 15 + .../Api/Links/ThumbnailDto.cs | 3 +- .../Proton.Drive.Sdk/Api/Shares/ShareId.cs | 5 +- ...rmissions.cs => ShareMemberPermissions.cs} | 3 +- ...areMembership.cs => ShareMembershipDto.cs} | 4 +- .../Api/Shares/ShareMembershipId.cs | 22 + .../Api/Shares/ShareResponse.cs | 2 +- .../Api/Shares/ShareVolumeDto.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Author.cs | 20 +- .../{BatchLoader.cs => BatchLoaderBase.cs} | 21 +- .../Caching/DriveEntityCache.cs | 18 +- .../Caching/DriveSecretCache.cs | 2 +- .../Caching/IDriveEntityCache.cs | 11 +- .../Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs | 39 ++ .../Nodes/AuthorshipClaimExtensions.cs | 16 + .../Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs | 6 + .../Nodes/CommonExtendedAttributes.cs | 10 + .../Proton.Drive.Sdk/Nodes/DecryptionError.cs | 9 +- .../Nodes/DecryptionException.cs | 26 -- .../Nodes/DecryptionOutput.cs | 11 + .../Nodes/DecryptionResult.cs | 34 -- .../Nodes/DegradedFileNode.cs | 30 ++ .../Nodes/DegradedFileSecrets.cs | 8 + .../Nodes/DegradedFolderNode.cs | 19 + .../Nodes/DegradedFolderSecrets.cs | 6 + .../Proton.Drive.Sdk/Nodes/DegradedNode.cs | 20 + .../Nodes/DegradedNodeAndSecrets.cs | 39 ++ .../Nodes/DegradedNodeSecrets.cs | 10 + cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs | 6 - .../Nodes/ExtendedAttributes.cs | 6 + .../Proton.Drive.Sdk/Nodes/FileDraftNode.cs | 3 + cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs | 8 + .../Nodes/FileOrFileDraftNode.cs | 6 + .../Nodes/FolderChildrenBatchLoader.cs | 37 ++ .../Nodes/FolderProvisionError.cs | 7 + .../Nodes/InvalidNameError.cs | 9 +- .../Nodes/InvalidNodeTypeException.cs | 33 ++ cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 13 +- .../Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs | 39 ++ ...NodeAndSecretsProvisionResultExtensions.cs | 87 ++++ .../src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs | 429 +++++++++++------- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 235 ++++++++-- .../src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs | 2 +- .../src/Proton.Drive.Sdk/Nodes/NodeState.cs | 8 - .../Nodes/PhasedDecryptionOutput.cs | 6 + cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 14 + .../src/Proton.Drive.Sdk/Nodes/RevisionId.cs | 25 + .../Nodes/SessionKeyAndData.cs | 8 - .../src/Proton.Drive.Sdk/Nodes/ShareAndKey.cs | 6 + .../Nodes/SignatureVerificationError.cs | 28 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 7 +- .../src/Proton.Drive.Sdk/ProtonDriveError.cs | 12 + .../DriveApiSerializerContext.cs | 4 + .../DriveEntitiesSerializerContext.cs | 4 +- .../Proton.Drive.Sdk/Shares/ShareCrypto.cs | 28 ++ .../Shares/ShareOperations.cs | 37 +- .../Volumes/VolumeOperations.cs | 14 +- .../Proton.Sdk/Caching/SessionSecretCache.cs | 2 +- .../Caching/SqliteCacheRepository.cs | 2 +- .../Proton.Sdk/Http/ProtonClientTlsPolicy.cs | 8 + cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 6 +- .../Proton.Sdk/ProtonClientConfiguration.cs | 9 +- .../ProtonClientConfigurationExtensions.cs | 16 +- cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs | 4 +- cs/sdk/src/Proton.Sdk/ResultExtensions.cs | 60 ++- cs/sdk/src/Proton.Sdk/Result{T,TError}.cs | 15 +- cs/sdk/src/Proton.Sdk/Result{TError}.cs | 14 +- .../Serialization/ResultJsonConverter.cs | 21 +- .../Serialization/SerializableResult.cs | 8 +- 81 files changed, 1418 insertions(+), 416 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/ActiveRevisionDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/ShareMembershipSummaryDto.cs rename cs/sdk/src/Proton.Drive.Sdk/Api/Shares/{MemberPermissions.cs => ShareMemberPermissions.cs} (67%) rename cs/sdk/src/Proton.Drive.Sdk/Api/Shares/{ShareMembership.cs => ShareMembershipDto.cs} (91%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipId.cs rename cs/sdk/src/Proton.Drive.Sdk/{BatchLoader.cs => BatchLoaderBase.cs} (63%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/CommonExtendedAttributes.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionOutput.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionResult.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileSecrets.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderSecrets.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeSecrets.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributes.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderProvisionError.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeState.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/PhasedDecryptionOutput.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionId.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/SessionKeyAndData.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/ShareAndKey.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/ProtonClientTlsPolicy.cs diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index c62ac0c6..efa1dc8e 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -18,7 +18,7 @@ enable en true - true + false diff --git a/cs/sdk/src/Directory.Packages.props b/cs/sdk/src/Directory.Packages.props index c7c64d02..783bba16 100644 --- a/cs/sdk/src/Directory.Packages.props +++ b/cs/sdk/src/Directory.Packages.props @@ -3,16 +3,16 @@ true - - - - - + + + + + + + + - - - \ No newline at end of file diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs index 665060b9..ad9512d4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -1,4 +1,5 @@ -using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; @@ -9,4 +10,5 @@ internal sealed class DriveApiClients(HttpClient httpClient) : IDriveApiClients public IVolumesApiClient Volumes { get; } = new VolumesApiClient(httpClient); public ISharesApiClient Shares { get; } = new SharesApiClient(httpClient); public ILinksApiClient Links { get; } = new LinksApiClient(httpClient); + public IFoldersApiClient Folders { get; } = new FoldersApiClient(httpClient); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs new file mode 100644 index 00000000..d13d182a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FolderChildrenResponse : ApiResponse +{ + [JsonPropertyName("LinkIDs")] + public required IReadOnlyList LinkIds { get; init; } + + public LinkId? AnchorId { get; init; } + + [JsonPropertyName("More")] + public required bool MoreResultsExist { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs new file mode 100644 index 00000000..e8cc302f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs @@ -0,0 +1,24 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FoldersApiClient(HttpClient httpClient) : IFoldersApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetChildrenAsync( + VolumeId volumeId, + LinkId linkId, + LinkId? anchorId, + CancellationToken cancellationToken) + { + var query = anchorId is not null ? $"?AnchorID={anchorId}" : string.Empty; + + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.FolderChildrenResponse) + .GetAsync($"v2/volumes/{volumeId}/folders/{linkId}/children{query}", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs new file mode 100644 index 00000000..3b56aee1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs @@ -0,0 +1,13 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal interface IFoldersApiClient +{ + Task GetChildrenAsync( + VolumeId volumeId, + LinkId linkId, + LinkId? anchorId, + CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs index f3e9725e..fd40580f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs @@ -1,4 +1,5 @@ -using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; @@ -9,4 +10,5 @@ internal interface IDriveApiClients IVolumesApiClient Volumes { get; } ISharesApiClient Shares { get; } ILinksApiClient Links { get; } + IFoldersApiClient Folders { get; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ActiveRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ActiveRevisionDto.cs new file mode 100644 index 00000000..a05d6091 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ActiveRevisionDto.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class ActiveRevisionDto +{ + [JsonPropertyName("RevisionID")] + public required RevisionId Id { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + [JsonPropertyName("EncryptedSize")] + public required long StorageQuotaConsumption { get; init; } + + public PgpArmoredSignature? ManifestSignature { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } + + public IReadOnlyList? Thumbnails { get; init; } + + [JsonPropertyName("SignatureEmail")] + public string? SignatureEmailAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileDto.cs new file mode 100644 index 00000000..497b4de1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileDto.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class FileDto +{ + public required string MediaType { get; init; } + + [JsonPropertyName("TotalEncryptedSize")] + public required long TotalStorageQuotaUsage { get; init; } + + public required ReadOnlyMemory ContentKeyPacket { get; init; } + + public PgpArmoredSignature ContentKeyPacketSignature { get; init; } + + public ActiveRevisionDto? ActiveRevision { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs index d3a541f2..bf1ab4a4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs @@ -4,10 +4,14 @@ internal sealed class LinkDetailsDto { public required LinkDto Link { get; init; } public FolderDto? Folder { get; init; } + public FileDto? File { get; init; } + public ShareMembershipSummaryDto? Membership { get; init; } - public void Deconstruct(out LinkDto link, out FolderDto? folder) + public void Deconstruct(out LinkDto link, out FolderDto? folder, out FileDto? file, out ShareMembershipSummaryDto? membership) { link = Link; folder = Folder; + file = File; + membership = Membership; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs index 2fd48e82..a4903f30 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs @@ -34,9 +34,6 @@ internal sealed class LinkDto [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] public required ReadOnlyMemory NameHashDigest { get; init; } - [JsonPropertyName("MIMEType")] - public string? MediaType { get; init; } - [JsonPropertyName("NodeKey")] public required PgpArmoredPrivateKey Key { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs index f0de1e82..0ce4f761 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk.Api.Links; -internal class RevisionDto +internal sealed class RevisionDto { [JsonPropertyName("ID")] public required string Id { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ShareMembershipSummaryDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ShareMembershipSummaryDto.cs new file mode 100644 index 00000000..916020e5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ShareMembershipSummaryDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class ShareMembershipSummaryDto +{ + [JsonPropertyName("ShareID")] + public required ShareId ShareId { get; init; } + + [JsonPropertyName("MembershipID")] + public required ShareMembershipId MembershipId { get; init; } + + public required ShareMemberPermissions Permissions { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs index 4921e095..0f71afe2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs @@ -12,5 +12,6 @@ internal sealed class ThumbnailDto [JsonPropertyName("Hash")] public required ReadOnlyMemory HashDigest { get; init; } - public required int Size { get; init; } + [JsonPropertyName("EncryptedSize")] + public required int StorageQuotaUsage { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs index 28365e5e..91394c46 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs @@ -13,10 +13,7 @@ internal ShareId(string? value) _value = value; } - public static explicit operator ShareId(string? value) - { - return new ShareId(value); - } + public static explicit operator ShareId(string? value) => new(value); public override string ToString() { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberPermissions.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMemberPermissions.cs similarity index 67% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberPermissions.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMemberPermissions.cs index 7028b020..2643d641 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberPermissions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMemberPermissions.cs @@ -1,8 +1,9 @@ namespace Proton.Drive.Sdk.Api.Shares; [Flags] -public enum MemberPermissions +public enum ShareMemberPermissions { + None = 0, Write = 2, Read = 4, Admin = 16, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembership.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs similarity index 91% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembership.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs index c14e4434..5247fc0b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembership.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Api.Shares; -internal sealed class ShareMembership +internal sealed class ShareMembershipDto { [JsonPropertyName("MemberID")] public required string MemberId { get; init; } @@ -21,7 +21,7 @@ internal sealed class ShareMembership [JsonPropertyName("Inviter")] public required string InviterEmailAddress { get; init; } - public required MemberPermissions Permissions { get; init; } + public required ShareMemberPermissions Permissions { get; init; } public required ReadOnlyMemory KeyPacket { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipId.cs new file mode 100644 index 00000000..81e88a0b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipId.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +[JsonConverter(typeof(StrongIdJsonConverter))] +internal readonly record struct ShareMembershipId : IStrongId +{ + private readonly string? _value; + + internal ShareMembershipId(string? value) + { + _value = value; + } + + public static explicit operator ShareMembershipId(string? value) => new(value); + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs index f39fc4b9..796a0ba7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs @@ -51,5 +51,5 @@ internal sealed class ShareResponse : ApiResponse [JsonPropertyName("AddressID")] public required AddressId AddressId { get; init; } - public required IReadOnlyList Memberships { get; init; } + public required IReadOnlyList Memberships { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs index 1981e1f1..907fab7f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs @@ -8,5 +8,5 @@ internal sealed class ShareVolumeDto [JsonPropertyName("VolumeID")] public required VolumeId Id { get; init; } - public required int UsedSpace { get; init; } + public required long UsedSpace { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Author.cs b/cs/sdk/src/Proton.Drive.Sdk/Author.cs index 9692dcbe..1f14f534 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Author.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Author.cs @@ -1,8 +1,22 @@ -namespace Proton.Drive.Sdk; +using System.Diagnostics.CodeAnalysis; -public readonly struct Author(string? emailAddress) +namespace Proton.Drive.Sdk; + +public readonly record struct Author { public static readonly Author Anonymous = default; - public string? EmailAddress { get; } = emailAddress; + public string? EmailAddress { get; init; } + + public bool TryGetIdentity([MaybeNullWhen(false)] out string emailAddress) + { + if (EmailAddress is null) + { + emailAddress = null; + return false; + } + + emailAddress = EmailAddress; + return true; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/BatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs similarity index 63% rename from cs/sdk/src/Proton.Drive.Sdk/BatchLoader.cs rename to cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs index 56bb4e3f..cf564a21 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/BatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs @@ -2,27 +2,24 @@ namespace Proton.Drive.Sdk; -internal sealed class BatchLoader +internal abstract class BatchLoaderBase { private const int DefaultBatchSize = 50; private readonly ArrayBufferWriter _queueWriter; - private readonly Func, ValueTask>> _loadFunction; - - public BatchLoader(Func, ValueTask>> loadFunction, int batchSize = DefaultBatchSize) + protected BatchLoaderBase(int batchSize = DefaultBatchSize) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize); _queueWriter = new ArrayBufferWriter(batchSize); - _loadFunction = loadFunction; } /// /// Queues an item for loading. If the queue size reaches the batch size, calls the load function, clears the queue, and returns the loaded items. /// Otherwise, returns an empty enumerable. /// - public async ValueTask> QueueAndTryLoadBatchAsync(TId id) + public async ValueTask> QueueAndTryLoadBatchAsync(TId id, CancellationToken cancellationToken) { _queueWriter.Write(new ReadOnlySpan(ref id)); @@ -31,7 +28,7 @@ public async ValueTask> QueueAndTryLoadBatchAsync(TId id) return []; } - return await LoadBatchAsync().ConfigureAwait(false); + return await LoadQueuedBatchAsync(cancellationToken).ConfigureAwait(false); } /// @@ -41,19 +38,21 @@ public async ValueTask> QueueAndTryLoadBatchAsync(TId id) /// /// Call this after no more items are expected to be queued. /// - public async ValueTask> LoadRemainingAsync() + public async ValueTask> LoadRemainingAsync(CancellationToken cancellationToken) { if (_queueWriter.WrittenCount == 0) { return []; } - return await LoadBatchAsync().ConfigureAwait(false); + return await LoadQueuedBatchAsync(cancellationToken).ConfigureAwait(false); } - private async ValueTask> LoadBatchAsync() + protected abstract ValueTask> LoadBatchAsync(ReadOnlyMemory ids, CancellationToken cancellationToken); + + private async ValueTask> LoadQueuedBatchAsync(CancellationToken cancellationToken) { - var result = await _loadFunction.Invoke(_queueWriter.WrittenMemory).ConfigureAwait(false); + var result = await LoadBatchAsync(_queueWriter.WrittenMemory, cancellationToken).ConfigureAwait(false); _queueWriter.Clear(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index b0ccba1d..886f825c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -4,6 +4,7 @@ using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; using Proton.Sdk.Caching; namespace Proton.Drive.Sdk.Caching; @@ -55,19 +56,26 @@ public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) : null; } - public ValueTask SetNodeAsync(Node node, CancellationToken cancellationToken) + public ValueTask SetNodeAsync( + NodeUid nodeId, + Result nodeProvisionResult, + ShareId? membershipShareId, + ReadOnlyMemory nameHashDigest, + CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(node, DriveEntitiesSerializerContext.Default.Node); + var serializedValue = JsonSerializer.Serialize( + new CachedNodeInfo(nodeProvisionResult, membershipShareId, nameHashDigest), + DriveEntitiesSerializerContext.Default.CachedNodeInfo); - return _repository.SetAsync(GetNodeCacheKey(node.Id), serializedValue, cancellationToken); + return _repository.SetAsync(GetNodeCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken) { var serializedValue = await _repository.TryGetAsync(GetNodeCacheKey(nodeId), cancellationToken).ConfigureAwait(false); return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.Node) + ? JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.CachedNodeInfo) : null; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs index d7185240..66318ed5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -30,7 +30,7 @@ public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, Cance public ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets fileSecrets, CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(fileSecrets, SecretsSerializerContext.Default.PgpPrivateKey); + var serializedValue = JsonSerializer.Serialize(fileSecrets, DriveSecretsSerializerContext.Default.FileSecrets); return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index cd188154..ee1cf5f3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -2,6 +2,7 @@ using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; namespace Proton.Drive.Sdk.Caching; @@ -16,6 +17,12 @@ internal interface IDriveEntityCache ValueTask SetShareAsync(Share share, CancellationToken cancellationToken); ValueTask TryGetShareAsync(ShareId shareId, CancellationToken cancellationToken); - ValueTask SetNodeAsync(Node node, CancellationToken cancellationToken); - ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask SetNodeAsync( + NodeUid nodeId, + Result nodeProvisionResult, + ShareId? membershipShareId, + ReadOnlyMemory nameHashDigest, + CancellationToken cancellationToken); + + ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs new file mode 100644 index 00000000..ea1fe65f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs @@ -0,0 +1,39 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly struct AuthorshipClaim(Author author, IReadOnlyList keys, string? keyRetrievalErrorMessage = null) +{ + private readonly IReadOnlyList _keys = keys; + + public Author Author { get; } = author; + + public string? KeyRetrievalErrorMessage { get; } = keyRetrievalErrorMessage; + + public static async ValueTask CreateAsync( + ProtonDriveClient client, + string? claimedAuthorEmailAddress, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(claimedAuthorEmailAddress)) + { + return new AuthorshipClaim(Author.Anonymous, []); + } + + try + { + var keys = await client.Account.GetAddressPublicKeysAsync(claimedAuthorEmailAddress, cancellationToken).ConfigureAwait(false); + + return new AuthorshipClaim(new Author { EmailAddress = claimedAuthorEmailAddress }, keys); + } + catch (Exception e) + { + return new AuthorshipClaim(new Author { EmailAddress = claimedAuthorEmailAddress }, [], e.Message); + } + } + + public PgpKeyRing GetKeyRing(PgpPrivateKey anonymousFallbackKey) + { + return Author != Author.Anonymous ? new PgpKeyRing(_keys) : anonymousFallbackKey; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs new file mode 100644 index 00000000..797c8f19 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs @@ -0,0 +1,16 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class AuthorshipClaimExtensions +{ + public static Result ToAuthorshipResult( + this AuthorshipClaim authorshipClaim, + PgpVerificationResult verificationResult) + { + return verificationResult.Status is PgpVerificationStatus.Ok + ? authorshipClaim.Author + : new SignatureVerificationError(authorshipClaim.Author, verificationResult.Status, authorshipClaim.KeyRetrievalErrorMessage); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs new file mode 100644 index 00000000..cbaf56b3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs @@ -0,0 +1,6 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct CachedNodeInfo(Result NodeProvisionResult, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CommonExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/CommonExtendedAttributes.cs new file mode 100644 index 00000000..7e9f0bd4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/CommonExtendedAttributes.cs @@ -0,0 +1,10 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class CommonExtendedAttributes +{ + public long? Size { get; init; } + + public DateTime? ModificationTime { get; init; } + + public IReadOnlyList? BlockSizes { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs index f66b2775..7efed7e0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs @@ -1,12 +1,7 @@ namespace Proton.Drive.Sdk.Nodes; -internal class DecryptionError(string message, Author claimedAuthor) - : Error(message) +internal sealed class DecryptionError(string message, Author claimedAuthor, ProtonDriveError? innerError = null) + : ProtonDriveError(message, innerError) { public Author ClaimedAuthor { get; } = claimedAuthor; - - public DecryptionException ToException() - { - return new DecryptionException(ClaimedAuthor, Message); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionException.cs deleted file mode 100644 index b7a4205f..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionException.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -public sealed class DecryptionException : Exception -{ - public DecryptionException() - { - } - - public DecryptionException(string? message) - : base(message) - { - } - - public DecryptionException(string? message, Exception? innerException) - : base(message, innerException) - { - } - - public DecryptionException(Author claimedAuthor, string? message) - : this(message) - { - ClaimedAuthor = claimedAuthor; - } - - public Author? ClaimedAuthor { get; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionOutput.cs new file mode 100644 index 00000000..67c269e2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionOutput.cs @@ -0,0 +1,11 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct DecryptionOutput(TData Data, Result Author) +{ + public static implicit operator DecryptionOutput?((TData Data, Result Author) output) + { + return new DecryptionOutput(output.Data, output.Author); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionResult.cs deleted file mode 100644 index 4e8297c8..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionResult.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Proton.Cryptography.Pgp; -using Proton.Sdk; - -namespace Proton.Drive.Sdk.Nodes; - -internal static class DecryptionResult -{ - public static Result, DecryptionError> Success(PgpSessionKey sessionKey, TData data, Author author) - { - return new SessionKeyAndData(sessionKey, (data, author)); - } - - public static Result, DecryptionError> AuthorVerificationFailure( - PgpSessionKey sessionKey, - TData data, - Author claimedAuthor, - string? errorMessage) - { - return new SessionKeyAndData(sessionKey, (data, new SignatureVerificationError(claimedAuthor, errorMessage))); - } - - public static Result, DecryptionError> KeyDecryptionFailure(string errorMessage, Author claimedAuthor) - { - return new DecryptionError(errorMessage, claimedAuthor); - } - - public static Result, DecryptionError> DataDecryptionFailure( - PgpSessionKey sessionKey, - string errorMessage, - Author claimedAuthor) - { - return new SessionKeyAndData(sessionKey, new DecryptionError(errorMessage, claimedAuthor)); - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs new file mode 100644 index 00000000..23dfc9a6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs @@ -0,0 +1,30 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed class DegradedFileNode : DegradedNode +{ + public required string MediaType { get; init; } + + public required Revision? ActiveRevision { get; init; } + + public required long TotalStorageQuotaUsage { get; init; } + + public FileNode ToNode(string substituteName, Revision substituteRevision) + { + return new FileNode + { + Id = Id, + ParentId = ParentId, + MediaType = MediaType, + Name = Name.TryGetValue(out var name) + ? name + : substituteName, + NameAuthor = NameAuthor, + Author = Author, + ActiveRevision = ActiveRevision ?? substituteRevision, + IsTrashed = false, + TotalStorageQuotaUsage = TotalStorageQuotaUsage, + }; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileSecrets.cs new file mode 100644 index 00000000..9953e114 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileSecrets.cs @@ -0,0 +1,8 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class DegradedFileSecrets : DegradedNodeSecrets +{ + public required PgpSessionKey? ContentKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs new file mode 100644 index 00000000..c87093f7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs @@ -0,0 +1,19 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed class DegradedFolderNode : DegradedNode +{ + public FolderNode ToNode(string substituteName) + { + return new FolderNode + { + Id = Id, + ParentId = ParentId, + Name = Name.TryGetValue(out var name) ? name : substituteName, + NameAuthor = NameAuthor, + IsTrashed = IsTrashed, + Author = Author, + }; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderSecrets.cs new file mode 100644 index 00000000..901e46ca --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderSecrets.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class DegradedFolderSecrets : DegradedNodeSecrets +{ + public required ReadOnlyMemory? HashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs new file mode 100644 index 00000000..99c54d0a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs @@ -0,0 +1,20 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +public abstract class DegradedNode +{ + public required NodeUid Id { get; init; } + + public required NodeUid? ParentId { get; init; } + + public required Result Name { get; init; } + + public required Result NameAuthor { get; init; } + + public required bool IsTrashed { get; init; } + + public required Result Author { get; init; } + + public required IReadOnlyList Errors { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs new file mode 100644 index 00000000..b82b69c2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly struct DegradedNodeAndSecrets +{ + private readonly (DegradedFileNode Node, DegradedFileSecrets Secrets)? _fileAndSecrets; + private readonly (DegradedFolderNode Node, DegradedFolderSecrets Secrets)? _folderAndSecrets; + + public DegradedNodeAndSecrets(DegradedFileNode node, DegradedFileSecrets secrets) + { + _fileAndSecrets = (node, secrets); + } + + public DegradedNodeAndSecrets(DegradedFolderNode node, DegradedFolderSecrets secrets) + { + _folderAndSecrets = (node, secrets); + } + + public bool TryGetFileElseFolder( + [MaybeNullWhen(false)] out DegradedFileNode fileNode, + [MaybeNullWhen(false)] out DegradedFileSecrets fileSecrets, + [MaybeNullWhen(true)] out DegradedFolderNode folderNode, + [MaybeNullWhen(true)] out DegradedFolderSecrets folderSecrets) + { + if (_fileAndSecrets is null) + { + (folderNode, folderSecrets) = _folderAndSecrets!.Value; + fileNode = null; + fileSecrets = null; + return false; + } + + (fileNode, fileSecrets) = _fileAndSecrets.Value; + folderNode = null; + folderSecrets = null; + return true; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeSecrets.cs new file mode 100644 index 00000000..b643970b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeSecrets.cs @@ -0,0 +1,10 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +internal class DegradedNodeSecrets +{ + public required PgpPrivateKey? Key { get; init; } + public required PgpSessionKey? PassphraseSessionKey { get; init; } + public required PgpSessionKey? NameSessionKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs deleted file mode 100644 index ecba3d82..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Error.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -public class Error(string? message) -{ - public string? Message { get; } = message; -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributes.cs new file mode 100644 index 00000000..56793154 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributes.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal readonly struct ExtendedAttributes +{ + public CommonExtendedAttributes? Common { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs new file mode 100644 index 00000000..a91c620d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class FileDraftNode : FileOrFileDraftNode; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs new file mode 100644 index 00000000..295d2712 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed class FileNode : FileOrFileDraftNode +{ + public required Revision ActiveRevision { get; init; } + + public required long TotalStorageQuotaUsage { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs new file mode 100644 index 00000000..9aa71665 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +public abstract class FileOrFileDraftNode : Node +{ + public required string MediaType { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs new file mode 100644 index 00000000..3e6472ae --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs @@ -0,0 +1,37 @@ +using System.Runtime.InteropServices; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class FolderChildrenBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey parentKey) + : BatchLoaderBase> +{ + private readonly ProtonDriveClient _client = client; + private readonly VolumeId _volumeId = volumeId; + private readonly PgpPrivateKey _parentKey = parentKey; + + protected override async ValueTask>> LoadBatchAsync( + ReadOnlyMemory ids, + CancellationToken cancellationToken) + { + var response = await _client.Api.Links.GetLinkDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); + + var nodeResults = new List>(ids.Length); + + foreach (var linkDetails in response.Links) + { + var nodeId = new NodeUid(_volumeId, linkDetails.Link.Id); + + var nodeAndSecretsResult = await NodeCrypto.DecryptNodeAsync(_client, nodeId, linkDetails, _parentKey, cancellationToken).ConfigureAwait(false); + + var nodeResult = nodeAndSecretsResult.ToNodeResult(); + + nodeResults.Add(nodeResult); + } + + return nodeResults; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderProvisionError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderProvisionError.cs new file mode 100644 index 00000000..8239c641 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderProvisionError.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed class FolderProvisionError(DegradedFolderNode degradedNode, string? message, ProtonDriveError? innerError = null) + : ProtonDriveError(message, innerError) +{ + public DegradedFolderNode DegradedNode { get; } = degradedNode; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs index cd35b4f9..9f0a66d0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs @@ -1,7 +1,10 @@ -namespace Proton.Drive.Sdk.Nodes; +using Proton.Sdk; -internal sealed class InvalidNameError(string name, string message) - : Error(message) +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class InvalidNameError(string name, Result author, string message) + : ProtonDriveError(message) { public string Name { get; } = name; + public Result Author { get; } = author; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs new file mode 100644 index 00000000..ef6dbba0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs @@ -0,0 +1,33 @@ +using Proton.Drive.Sdk.Api.Links; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed class InvalidNodeTypeException : Exception +{ + public InvalidNodeTypeException(string message) + : base(message) + { + } + + public InvalidNodeTypeException(string message, Exception innerException) + : base(message, innerException) + { + } + + public InvalidNodeTypeException() + { + } + + internal InvalidNodeTypeException(NodeUid nodeId, LinkType actualType) + : this(GetMessage(nodeId, actualType)) + { + } + + internal static string GetMessage(NodeUid nodeId, LinkType actualType) + { + return $"Expected node \"{nodeId}\" to be of type {GetExpectedTypeString(actualType)}, but is of type {GetActualTypeString(actualType)} instead."; + } + + private static string GetActualTypeString(LinkType actualType) => actualType is LinkType.File ? "file" : "folder"; + private static string GetExpectedTypeString(LinkType actualType) => actualType is LinkType.File ? "folder" : "file"; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index 42ace92c..63ae596c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -1,22 +1,23 @@ using System.Text.Json.Serialization; using Proton.Sdk; -using Proton.Sdk.Drive; namespace Proton.Drive.Sdk.Nodes; [JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] [JsonDerivedType(typeof(FolderNode), typeDiscriminator: "folder")] +[JsonDerivedType(typeof(FileNode), typeDiscriminator: "file")] +[JsonDerivedType(typeof(FileDraftNode), typeDiscriminator: "fileDraft")] public abstract class Node { public required NodeUid Id { get; init; } - public NodeUid? ParentId { get; init; } + public required NodeUid? ParentId { get; init; } - public required Result Name { get; init; } + public required string Name { get; init; } - public required Result NameAuthor { get; init; } + public required bool IsTrashed { get; init; } - public required NodeState State { get; init; } + public required Result NameAuthor { get; init; } - public required Result KeyAuthor { get; init; } + public required Result Author { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs new file mode 100644 index 00000000..e2bf42ed --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly struct NodeAndSecrets +{ + private readonly (FileNode Node, FileSecrets Secrets)? _fileAndSecrets; + private readonly (FolderNode Node, FolderSecrets Secrets)? _folderAndSecrets; + + public NodeAndSecrets(FileNode node, FileSecrets secrets) + { + _fileAndSecrets = (node, secrets); + } + + public NodeAndSecrets(FolderNode node, FolderSecrets secrets) + { + _folderAndSecrets = (node, secrets); + } + + public bool TryGetFileElseFolder( + [MaybeNullWhen(false)] out FileNode fileNode, + [MaybeNullWhen(false)] out FileSecrets fileSecrets, + [MaybeNullWhen(true)] out FolderNode folderNode, + [MaybeNullWhen(true)] out FolderSecrets folderSecrets) + { + if (_fileAndSecrets is null) + { + (folderNode, folderSecrets) = _folderAndSecrets!.Value; + fileNode = null; + fileSecrets = null; + return false; + } + + (fileNode, fileSecrets) = _fileAndSecrets.Value; + folderNode = null; + folderSecrets = null; + return true; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs new file mode 100644 index 00000000..c1e0b283 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs @@ -0,0 +1,87 @@ +using System.Diagnostics.CodeAnalysis; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class NodeAndSecretsProvisionResultExtensions +{ + public static Node GetNodeOrThrow(this Result provisionResult) + { + var nodeAndSecrets = provisionResult.GetValueOrThrow(); + + return nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) + ? fileNode + : folderNode; + } + + public static FolderNode GetFolderNodeOrThrow(this Result provisionResult) + { + var nodeAndSecrets = provisionResult.GetValueOrThrow(); + + if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _)) + { + throw new InvalidNodeTypeException(fileNode.Id, LinkType.File); + } + + return folderNode; + } + + public static FolderSecrets GetFolderSecretsOrThrow(this Result provisionResult) + { + var nodeAndSecrets = provisionResult.GetValueOrThrow(); + + if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) + { + throw new InvalidNodeTypeException(fileNode.Id, LinkType.File); + } + + return folderSecrets; + } + + public static bool TryGetFolderKeyElseError( + this Result provisionResult, + [NotNullWhen(true)] out PgpPrivateKey? folderKey, + [MaybeNullWhen(true)] out ProtonDriveError error) + { + if (!provisionResult.TryGetValueElseError(out var nodeAndSecrets, out var degradedNodeAndSecrets)) + { + if (degradedNodeAndSecrets.TryGetFileElseFolder(out var degradedFileNode, out _, out var degradedFolderNode, out var degradedFolderSecrets)) + { + folderKey = null; + error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(degradedFileNode.Id, LinkType.File)); + return false; + } + + if (degradedFolderSecrets.Key is null) + { + folderKey = null; + error = degradedFolderNode.Errors[0]; + return false; + } + + folderKey = degradedFolderSecrets.Key; + error = null; + return true; + } + + if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) + { + folderKey = null; + error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(fileNode.Id, LinkType.File)); + return false; + } + + folderKey = folderSecrets.Key; + error = null; + return true; + } + + public static Result ToNodeResult(this Result provisionResult) + { + return provisionResult.Convert( + x => x.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? (Node)fileNode : folderNode, + x => x.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? (DegradedNode)fileNode : folderNode); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs index a10a0f1e..1ddda703 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs @@ -1,271 +1,384 @@ -using System.Security.Cryptography; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; using Proton.Sdk; using Proton.Sdk.Cryptography; -using Proton.Sdk.Drive; namespace Proton.Drive.Sdk.Nodes; internal static class NodeCrypto { - public static async ValueTask> DecryptNodeAsync( + public static async ValueTask> DecryptNodeAsync( ProtonDriveClient client, - NodeUid nodeId, - LinkDto link, - FolderDto? folder, - PgpPrivateKey parentNodeKey, + NodeUid id, + LinkDetailsDto linkDetails, + Result parentKeyResult, CancellationToken cancellationToken) { - var state = (NodeState)link.State; + var (link, folder, file, membership) = linkDetails; - var passphraseClaimedAuthor = new Author(link.SignatureEmailAddress); - var nameClaimedAuthor = new Author(link.NameSignatureEmailAddress); + var parentId = link.ParentId is not null ? new NodeUid(id.VolumeId, link.ParentId.Value) : (NodeUid?)null; - var passphraseDecryptionResult = await DecryptPassphraseAsync( - client, - parentNodeKey, - link.Passphrase, - link.PassphraseSignature, - passphraseClaimedAuthor, - cancellationToken).ConfigureAwait(false); + var nodeAuthorshipClaim = await AuthorshipClaim.CreateAsync(client, link.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); - if (!passphraseDecryptionResult.TryGetValue(out var passphraseSessionKeyAndData, out var passphraseDecryptionError)) - { - return passphraseDecryptionError; - } + var nameAuthorshipClaim = link.NameSignatureEmailAddress != link.SignatureEmailAddress + ? await AuthorshipClaim.CreateAsync(client, link.NameSignatureEmailAddress, cancellationToken).ConfigureAwait(false) + : nodeAuthorshipClaim; + + Result, ProtonDriveError> nameResult; + PhasedDecryptionOutput>? passphraseOutput; + DecryptionError? passphraseError; + ProtonDriveError? parentKeyError; - var (passphraseSessionKey, passphraseDataDecryptionResult) = passphraseSessionKeyAndData; + if (parentKeyResult.TryGetValueElseError(out var parentKey, out var parentNodeKeyInnerError)) + { + nameResult = DecryptName(link.Name, parentKey, nameAuthorshipClaim); - if (!passphraseDataDecryptionResult.TryGetValue(out var passphraseDataAndAuthor, out passphraseDecryptionError)) + (passphraseOutput, passphraseError) = DecryptPassphrase(parentKey, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); + } + else { - return passphraseDecryptionError; + parentKeyError = new ProtonDriveError("Decryption key unavailable", parentNodeKeyInnerError); + + nameResult = parentKeyError; + passphraseOutput = null; + passphraseError = null; } - var (passphrase, passphraseAuthor) = passphraseDataAndAuthor; + var nameOutput = nameResult.GetValueOrDefault(); - var nameDecryptionResult = await DecryptNameAsync(client, parentNodeKey, link.Name, nameClaimedAuthor, cancellationToken).ConfigureAwait(false); + var (nodeKey, nodeKeyError) = UnlockNodeKey(link.Key, passphraseOutput?.Data); - PgpSessionKey? nameSessionKey; - Result name; - Result nameAuthor; - if (nameDecryptionResult.TryGetValue(out var nameSessionKeyAndData, out var nameDecryptionError)) + if (link.Type is LinkType.Folder) { - nameSessionKey = nameSessionKeyAndData.SessionKey; - var nameDataDecryptionResult = nameSessionKeyAndData.DataDecryptionResult; + var (hashKeyOutput, hashKeyError) = DecryptHashKey(folder?.HashKey, nodeKey, nodeAuthorshipClaim); - if (nameDataDecryptionResult.TryGetValue(out var nameDataAndAuthor, out nameDecryptionError)) + if (nameOutput is null || nodeKey is null || passphraseOutput is null || hashKeyOutput is null) { - (var nameToValidate, nameAuthor) = nameDataAndAuthor; - - name = ValidateName(nameToValidate); + var degradedFolderNode = new DegradedFolderNode + { + Id = id, + ParentId = parentId, + Name = nameResult.Convert(x => x.Data), + NameAuthor = null!, + IsTrashed = link.State is LinkState.Trashed, + Author = null!, + Errors = null!, + }; + + var degradedFolderSecrets = new DegradedFolderSecrets + { + HashKey = hashKeyOutput?.Data, + Key = nodeKey, + NameSessionKey = nameOutput?.SessionKey, + PassphraseSessionKey = passphraseOutput?.SessionKey, + }; + + // TODO: cache secrets + throw new NotImplementedException(); } - else + + var folderSecrets = new FolderSecrets { - name = nameDecryptionError; - nameAuthor = new SignatureVerificationError(nameDecryptionError.ClaimedAuthor); - } + HashKey = hashKeyOutput.Value.Data, + Key = nodeKey.Value, + NameSessionKey = nameOutput.Value.SessionKey, + PassphraseSessionKey = passphraseOutput.Value.SessionKey, + }; + + await client.Cache.Secrets.SetFolderSecretsAsync(id, folderSecrets, cancellationToken).ConfigureAwait(false); + + var folderNode = new FolderNode + { + Id = id, + ParentId = parentId, + Name = nameOutput.Value.Data, + NameAuthor = nameOutput.Value.Author, + Author = passphraseOutput.Value.Author, // TODO: combine with signature error from name hash key + IsTrashed = link.State is LinkState.Trashed, + }; + + await client.Cache.Entities.SetNodeAsync(id, folderNode, membership?.ShareId, link.NameHashDigest, cancellationToken).ConfigureAwait(false); + + return new NodeAndSecrets(folderNode, folderSecrets); } - else + + if (file is null) { - nameSessionKey = null; - name = nameDecryptionError; - nameAuthor = new SignatureVerificationError(nameDecryptionError.ClaimedAuthor); + // TODO: handle missing file information with degraded node + throw new NotImplementedException(); } - using var key = PgpPrivateKey.ImportAndUnlock(link.Key, passphrase.Span); + if (link.State is LinkState.Draft) + { + // We don't currently expect draft nodes + throw new NotSupportedException(); + } - var parentId = link.ParentId is not null ? new NodeUid(nodeId.VolumeId, link.ParentId.Value) : default(NodeUid?); + if (file.ActiveRevision is null) + { + // TODO: handle missing revision information with degraded node + throw new NotImplementedException(); + } - if (link.Type is LinkType.Folder) + var contentKey = nodeKey?.DecryptSessionKey(file.ContentKeyPacket.Span); + + // TODO: verify content key packet signature + + var (extendedAttributesOutput, extendedAttributesError) = + DecryptExtendedAttributes(file.ActiveRevision.ExtendedAttributes, nodeKey, nodeAuthorshipClaim); + + if (nameOutput is null || nodeKey is null || passphraseOutput is null || contentKey is null || extendedAttributesError is not null) { - if (folder is null) + var degradedFileNode = new DegradedFileNode { - throw new ProtonApiException("Folder information missing for link of type Folder"); - } + Id = default, + ParentId = null, + Name = nameResult.Convert(x => x.Data), + NameAuthor = default, + IsTrashed = false, + Author = default, + MediaType = file.MediaType, + ActiveRevision = null, + TotalStorageQuotaUsage = file.TotalStorageQuotaUsage, + Errors = null!, + }; - var folderSecrets = new FolderSecrets + var degradedFileSecrets = new DegradedFileSecrets { - HashKey = key.Decrypt(folder.HashKey), - Key = key, - NameSessionKey = nameSessionKey, - PassphraseSessionKey = passphraseSessionKey, + Key = nodeKey, + PassphraseSessionKey = passphraseOutput?.SessionKey, + NameSessionKey = nameOutput?.SessionKey, + ContentKey = contentKey, }; - await client.Cache.Secrets.SetFolderSecretsAsync(nodeId, folderSecrets, cancellationToken).ConfigureAwait(false); + // TODO: cache secrets + throw new NotImplementedException(); + } + + var fileSecrets = new FileSecrets + { + Key = nodeKey.Value, + PassphraseSessionKey = passphraseOutput.Value.SessionKey, + NameSessionKey = nameOutput.Value.SessionKey, + ContentKey = contentKey.Value, + }; + + await client.Cache.Secrets.SetFileSecretsAsync(id, fileSecrets, cancellationToken).ConfigureAwait(false); + + var extendedAttributes = extendedAttributesOutput?.Data; - return new FolderNode + var fileNode = new FileNode + { + Id = id, + ParentId = parentId, + Name = nameOutput.Value.Data, + IsTrashed = link.State is LinkState.Trashed, + NameAuthor = nameOutput.Value.Author, + Author = passphraseOutput.Value.Author, // TODO: combine with signature error from content key + MediaType = file.MediaType, + ActiveRevision = new Revision { - Id = nodeId, - ParentId = parentId, - Name = name, - NameAuthor = nameAuthor, - State = state, - KeyAuthor = passphraseAuthor, - }; - } + Id = file.ActiveRevision.Id, + CreationTime = file.ActiveRevision.CreationTime, + StorageQuotaConsumption = file.ActiveRevision.StorageQuotaConsumption, + ClaimedSize = extendedAttributes?.Common?.Size, + ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, + Thumbnails = [], // TODO: thumbnails + MetadataAuthor = extendedAttributesOutput?.Author, + }, + TotalStorageQuotaUsage = file.TotalStorageQuotaUsage, + }; + + await client.Cache.Entities.SetNodeAsync(id, fileNode, membership?.ShareId, link.NameHashDigest, cancellationToken).ConfigureAwait(false); + + return new NodeAndSecrets(fileNode, fileSecrets); + } + + public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHashKey) + { + var maxNameByteLength = Encoding.UTF8.GetByteCount(name); + var nameBytes = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) + ? nameHeapMemoryOwner.Memory.Span + : stackalloc byte[maxNameByteLength]; - // TODO: implement file node decryption - throw new NotImplementedException(); + using (nameHeapMemoryOwner) + { + var nameByteLength = Encoding.UTF8.GetBytes(name, nameBytes); + nameBytes = nameBytes[..nameByteLength]; + + return HMACSHA256.HashData(parentFolderHashKey, nameBytes); + } } - private static async ValueTask>, DecryptionError>> DecryptPassphraseAsync( - ProtonDriveClient client, + private static (PhasedDecryptionOutput>? Output, DecryptionError? Error) DecryptPassphrase( PgpPrivateKey parentNodeKey, PgpArmoredMessage encryptedPassphrase, PgpArmoredSignature? signature, - Author claimedAuthor, - CancellationToken cancellationToken) + AuthorshipClaim authorshipClaim) { - PgpSessionKey sessionKey; try { - sessionKey = parentNodeKey.DecryptSessionKey(encryptedPassphrase); + var passphrase = DecryptMessage(encryptedPassphrase, signature, parentNodeKey, authorshipClaim, out var sessionKey, out var author); + + return (new PhasedDecryptionOutput>(sessionKey, passphrase, author), null); } catch (Exception e) { - return DecryptionResult>.KeyDecryptionFailure(e.Message, claimedAuthor); + return (null, new DecryptionError(e.Message, authorshipClaim.Author)); } + } - IReadOnlyList? verificationKeys = null; - string? verificationErrorMessage = null; - - if (signature is not null && claimedAuthor.EmailAddress is not null) + private static (PgpPrivateKey? NodeKey, string? ErrorMessage) UnlockNodeKey(PgpArmoredPrivateKey lockedKey, ReadOnlyMemory? passphrase) + { + if (passphrase is null) { - try - { - verificationKeys = await client.Account.GetAddressPublicKeysAsync(claimedAuthor.EmailAddress, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - verificationKeys = null; - verificationErrorMessage = e.Message; - } + return (null, null); } try { - ReadOnlyMemory passphrase; - PgpVerificationStatus? verificationStatus; + var nodeKey = PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase.Value.Span); - if (signature is not null && verificationKeys is not null) - { - passphrase = sessionKey.DecryptAndVerify( - encryptedPassphrase, - signature.Value, - new PgpKeyRing(verificationKeys), - out var verificationResult); + return (nodeKey, null); + } + catch (Exception e) + { + return (null, e.Message); + } + } - verificationStatus = verificationResult.Status; - } - else - { - passphrase = sessionKey.Decrypt(encryptedPassphrase); - verificationStatus = PgpVerificationStatus.Ok; - } + private static (DecryptionOutput>? Output, DecryptionError? Error) DecryptHashKey( + PgpArmoredMessage? encryptedHashKey, + PgpPrivateKey? nodeKey, + AuthorshipClaim authorshipClaim) + { + if (nodeKey is null) + { + return (Output: null, Error: null); + } - var authorIsVerified = verificationStatus is PgpVerificationStatus.Ok && verificationErrorMessage is null; + if (encryptedHashKey is null) + { + return (Output: null, new DecryptionError("Folder information missing for link of type Folder", authorshipClaim.Author)); + } - return authorIsVerified - ? DecryptionResult>.Success(sessionKey, passphrase, claimedAuthor) - : DecryptionResult>.AuthorVerificationFailure(sessionKey, passphrase, claimedAuthor, verificationErrorMessage); + try + { + var hashKey = DecryptMessage(encryptedHashKey.Value, detachedSignature: null, nodeKey.Value, authorshipClaim, out _, out var author); + + return ((hashKey, author), null); } catch (Exception e) { - return DecryptionResult>.DataDecryptionFailure(sessionKey, e.Message, claimedAuthor); + return (Output: null, new DecryptionError(e.Message, authorshipClaim.Author)); } } - private static async ValueTask, DecryptionError>> DecryptNameAsync( - ProtonDriveClient client, - PgpPrivateKey parentNodeKey, + private static Result, ProtonDriveError> DecryptName( PgpArmoredMessage encryptedName, - Author claimedAuthor, - CancellationToken cancellationToken) + PgpPrivateKey parentNodeKey, + AuthorshipClaim authorshipClaim) { - PgpSessionKey sessionKey; try { - sessionKey = parentNodeKey.DecryptSessionKey(encryptedName); + var nameUtf8Bytes = DecryptMessage(encryptedName, detachedSignature: null, parentNodeKey, authorshipClaim, out var sessionKey, out var author); + + var name = Encoding.UTF8.GetString(nameUtf8Bytes); + + return ValidateName(name, author, out var invalidNameError) + ? new PhasedDecryptionOutput(sessionKey, name, author) + : invalidNameError; } catch (Exception e) { - return DecryptionResult.KeyDecryptionFailure(e.Message, claimedAuthor); + return new DecryptionError(e.Message, authorshipClaim.Author); } + } - IReadOnlyList? verificationKeys = null; - string? verificationErrorMessage = null; + private static (DecryptionOutput? Output, DecryptionError? Error) DecryptExtendedAttributes( + PgpArmoredMessage? encryptedExtendedAttributes, + PgpPrivateKey? nodeKey, + AuthorshipClaim authorshipClaim) + { + if (encryptedExtendedAttributes is null) + { + return (Output: null, Error: null); + } - if (claimedAuthor.EmailAddress is not null) + if (nodeKey is null) { - try - { - verificationKeys = await client.Account.GetAddressPublicKeysAsync(claimedAuthor.EmailAddress, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - verificationKeys = null; - verificationErrorMessage = e.Message; - } + return (Output: null, Error: null); } try { - PgpVerificationStatus? verificationStatus; + var serializedExtendedAttributes = DecryptMessage( + encryptedExtendedAttributes.Value, + detachedSignature: null, + nodeKey.Value, + authorshipClaim, + out _, + out var author); - string name; - if (verificationKeys is not null) + try { - name = sessionKey.DecryptAndVerifyText(encryptedName, new PgpKeyRing(verificationKeys), out var verificationResult); - verificationStatus = verificationResult.Status; + var extendedAttributes = JsonSerializer.Deserialize(serializedExtendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); + + return ((extendedAttributes, author), Error: null); } - else + catch (Exception e) { - name = sessionKey.DecryptText(encryptedName); - verificationStatus = PgpVerificationStatus.Ok; + return (Output: null, new DecryptionError($"Failed to deserialize extended attributes: {e.Message}", authorshipClaim.Author)); } - - var authorIsVerified = verificationStatus is PgpVerificationStatus.Ok && verificationErrorMessage is null; - - return authorIsVerified - ? DecryptionResult.Success(sessionKey, name, claimedAuthor) - : DecryptionResult.AuthorVerificationFailure(sessionKey, name, claimedAuthor, verificationErrorMessage); } catch (Exception e) { - return DecryptionResult.DataDecryptionFailure(sessionKey, e.Message, claimedAuthor); + return (Output: null, new DecryptionError(e.Message, authorshipClaim.Author)); } } - public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHashKey) + private static ArraySegment DecryptMessage( + PgpArmoredMessage encryptedMessage, + PgpArmoredSignature? detachedSignature, + PgpPrivateKey decryptionKey, + AuthorshipClaim authorshipClaim, + out PgpSessionKey sessionKey, + out Result author) { - var maxNameByteLength = Encoding.UTF8.GetByteCount(name); - var nameBytes = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) - ? nameHeapMemoryOwner.Memory.Span - : stackalloc byte[maxNameByteLength]; + sessionKey = decryptionKey.DecryptSessionKey(encryptedMessage); - using (nameHeapMemoryOwner) - { - var nameByteLength = Encoding.UTF8.GetBytes(name, nameBytes); - nameBytes = nameBytes[..nameByteLength]; + var verificationKeyRing = authorshipClaim.GetKeyRing(anonymousFallbackKey: decryptionKey); - return HMACSHA256.HashData(parentFolderHashKey, nameBytes); - } + var plaintext = detachedSignature is not null + ? sessionKey.DecryptAndVerify(encryptedMessage.Bytes.Span, detachedSignature.Value.Bytes.Span, verificationKeyRing, out var verificationResult) + : sessionKey.DecryptAndVerify(encryptedMessage, verificationKeyRing, out verificationResult); + + author = authorshipClaim.ToAuthorshipResult(verificationResult); + + return plaintext; } - private static Result ValidateName(string name) + // TODO: find a more suitable place to put this validation than in a class that claims to be about cryptography + private static bool ValidateName(string name, Result author, [MaybeNullWhen(true)] out InvalidNameError error) { if (string.IsNullOrEmpty(name)) { - return new InvalidNameError(name, "Name must not be empty"); + error = new InvalidNameError(name, author, "Name must not be empty"); + return false; } if (name.Contains('/')) { - return new InvalidNameError(name, "Name must not contain the character '/'"); + error = new InvalidNameError(name, author, "Name must not contain the character '/'"); + return false; } - return name; + error = null; + return true; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 4e8cb0b2..3c365720 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -1,4 +1,5 @@ -using Proton.Cryptography.Pgp; +using System.Runtime.CompilerServices; +using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Caching; @@ -11,85 +12,244 @@ namespace Proton.Drive.Sdk.Nodes; internal static class NodeOperations { - internal static async ValueTask GetMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + public static async ValueTask GetMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { var shareId = await client.Cache.Entities.TryGetMyFilesShareIdAsync(cancellationToken).ConfigureAwait(false); if (shareId is null) { - return await GetMyFilesFolderWithoutCacheAsync(client, cancellationToken).ConfigureAwait(false); + return await GetFreshMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); } - var share = await ShareOperations.GetShareAsync(client, shareId.Value, cancellationToken).ConfigureAwait(false); + var shareAndKey = await ShareOperations.GetShareAsync(client, shareId.Value, cancellationToken).ConfigureAwait(false); - var node = await GetNodeAsync(client, share.Id, share.RootFolderId, cancellationToken).ConfigureAwait(false); + var node = await GetNodeAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, cancellationToken).ConfigureAwait(false); return (FolderNode)node; } - private static async ValueTask GetNodeAsync( + public static async IAsyncEnumerable> EnumerateFolderChildrenAsync( ProtonDriveClient client, - ShareId shareId, - NodeUid nodeId, - CancellationToken cancellationToken) + NodeUid folderId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var node = await client.Cache.Entities.TryGetNodeAsync(nodeId, cancellationToken).ConfigureAwait(false); + var anchorId = default(LinkId?); + var mustTryMoreResults = true; - if (node is null) - { - var response = await client.Api.Links.GetLinkDetailsAsync(nodeId.VolumeId, [nodeId.LinkId], cancellationToken).ConfigureAwait(false); + var folderSecrets = await GetFolderSecretsAsync(client, folderId, cancellationToken).ConfigureAwait(false); - var (link, folder) = response.Links[0]; + var batchLoader = new FolderChildrenBatchLoader(client, folderId.VolumeId, folderSecrets.Key); - // TODO: make this work with nodes other than the root folder by getting the actual parent key instead of always passing the share key - var shareKey = await client.Cache.Secrets.TryGetShareKeyAsync(shareId, cancellationToken).ConfigureAwait(false); + while (mustTryMoreResults) + { + var response = await client.Api.Folders.GetChildrenAsync(folderId.VolumeId, folderId.LinkId, anchorId, cancellationToken).ConfigureAwait(false); - var decryptionResult = await NodeCrypto.DecryptNodeAsync(client, nodeId, link, folder, shareKey!.Value, cancellationToken).ConfigureAwait(false); + mustTryMoreResults = response.MoreResultsExist; + anchorId = response.AnchorId; - if (!decryptionResult.TryGetValue(out node, out var decryptionError)) + foreach (var childLinkId in response.LinkIds) { - throw decryptionError.ToException(); + var childId = new NodeUid(folderId.VolumeId, childLinkId); + + var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(childId, cancellationToken).ConfigureAwait(false); + + if (cachedChildNodeInfo is null) + { + foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(childLinkId, cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + else + { + yield return cachedChildNodeInfo.Value.NodeProvisionResult; + } } } - return node; + foreach (var node in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + { + yield return node; + } + } + + private static async ValueTask GetFolderSecretsAsync(ProtonDriveClient client, NodeUid folderId, CancellationToken cancellationToken) + { + var folderSecrets = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderId, cancellationToken).ConfigureAwait(false); + + if (folderSecrets is null) + { + var nodeProvisionResult = await GetFreshNodeAndSecretsAsync(client, folderId, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + + folderSecrets = nodeProvisionResult.GetFolderSecretsOrThrow(); + } + + return folderSecrets; + } + + private static async ValueTask GetNodeAsync( + ProtonDriveClient client, + NodeUid nodeId, + ShareAndKey? knownShareAndKey, + CancellationToken cancellationToken) + { + var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(nodeId, cancellationToken).ConfigureAwait(false); + + if (cachedNodeInfo is not var (nodeResult, _, _)) + { + var nodeAndSecretsResult = await GetFreshNodeAndSecretsAsync(client, nodeId, knownShareAndKey, cancellationToken).ConfigureAwait(false); + + nodeResult = nodeAndSecretsResult.ToNodeResult(); + } + + return nodeResult.GetValueOrThrow(); } - private static async ValueTask GetMyFilesFolderWithoutCacheAsync( + private static async ValueTask GetFreshMyFilesFolderAsync( ProtonDriveClient client, CancellationToken cancellationToken) { PgpPrivateKey shareKey; ShareVolumeDto volume; ShareDto share; - LinkDto link; - FolderDto? folder; + LinkDetailsDto linkDetails; try { - (volume, share, (link, folder)) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); + (volume, share, linkDetails) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); } catch (ProtonApiException e) when (e.Code == ResponseCode.DoesNotExist) { return await CreateMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); } - shareKey = await ShareOperations.DecryptShareKeyAsync(client, share.Id, share.Key, share.Passphrase, share.AddressId, cancellationToken) + shareKey = await ShareCrypto.DecryptShareKeyAsync(client, share.Id, share.Key, share.Passphrase, share.AddressId, cancellationToken) .ConfigureAwait(false); - var nodeId = new NodeUid(volume.Id, link.Id); + var nodeId = new NodeUid(volume.Id, linkDetails.Link.Id); + + var nodeProvisionResult = await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetails, shareKey, cancellationToken).ConfigureAwait(false); + + var folderNode = nodeProvisionResult.GetFolderNodeOrThrow(); + + await SetMyFilesInCacheAsync(client.Cache.Entities, new Share(share.Id, folderNode.Id), folderNode, cancellationToken).ConfigureAwait(false); + + return folderNode; + } + + private static async ValueTask> GetFreshNodeAndSecretsAsync( + ProtonDriveClient client, + NodeUid nodeId, + ShareAndKey? knownShareAndKey, + CancellationToken cancellationToken) + { + var response = await client.Api.Links.GetLinkDetailsAsync(nodeId.VolumeId, [nodeId.LinkId], cancellationToken).ConfigureAwait(false); + + var linkDetails = response.Links[0]; - var decryptionResult = await NodeCrypto.DecryptNodeAsync(client, nodeId, link, folder, shareKey, cancellationToken).ConfigureAwait(false); + var parentKeyResult = await GetParentKeyAsync( + client, + nodeId.VolumeId, + linkDetails.Link.ParentId, + knownShareAndKey, + linkDetails.Membership?.ShareId, + cancellationToken).ConfigureAwait(false); - if (!decryptionResult.TryGetValue(out var node, out var decryptionError)) + return await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetails, parentKeyResult, cancellationToken).ConfigureAwait(false); + } + + private static async Task> GetParentKeyAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkId? parentId, + ShareAndKey? shareAndKeyToUse, + ShareId? childMembershipShareId, + CancellationToken cancellationToken) + { + if (childMembershipShareId is not null && childMembershipShareId == shareAndKeyToUse?.Share.Id) { - throw decryptionError.ToException(); + return shareAndKeyToUse.Value.Key; } - var folderNode = (FolderNode)node; + var currentId = parentId; + var currentMembershipShareId = childMembershipShareId; - await SetMyFilesInCacheAsync(client.Cache.Entities, new Share(share.Id, folderNode.Id), folderNode, cancellationToken).ConfigureAwait(false); + var linkAncestry = new Stack(8); - return (FolderNode)node; + PgpPrivateKey? lastKey = null; + + try + { + while (currentId is not null) + { + if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) + { + lastKey = shareKeyToUse; + break; + } + + var folderSecrets = await client.Cache.Secrets.TryGetFolderSecretsAsync(new NodeUid(volumeId, currentId.Value), cancellationToken) + .ConfigureAwait(false); + + if (folderSecrets is not null) + { + lastKey = folderSecrets.Key; + break; + } + + var linkDetailsResponse = await client.Api.Links.GetLinkDetailsAsync(volumeId, [currentId.Value], cancellationToken).ConfigureAwait(false); + + var linkDetails = linkDetailsResponse.Links[0]; + + linkAncestry.Push(linkDetails); + + var (link, _, _, membership) = linkDetails; + + currentId = link.ParentId; + + currentMembershipShareId = membership?.ShareId; + } + } + catch (Exception e) + { + return new ProtonDriveError(e.Message); + } + + if (lastKey is not { } currentParentKey) + { + if (shareAndKeyToUse is not null) + { + currentParentKey = shareAndKeyToUse.Value.Key; + } + else + { + if (currentMembershipShareId is null) + { + return new ProtonDriveError("No membership available to access node"); + } + + (_, currentParentKey) = await ShareOperations.GetShareAsync(client, currentMembershipShareId.Value, cancellationToken).ConfigureAwait(false); + } + } + + while (linkAncestry.TryPop(out var ancestorLinkDetails)) + { + var decryptionResult = await NodeCrypto.DecryptNodeAsync( + client, + new NodeUid(volumeId, ancestorLinkDetails.Link.Id), + ancestorLinkDetails, + currentParentKey, + cancellationToken).ConfigureAwait(false); + + if (!decryptionResult.TryGetFolderKeyElseError(out var folderKey, out var error)) + { + // TODO: wrap error for more context? + return error; + } + + currentParentKey = folderKey.Value; + } + + return currentParentKey; } private static async ValueTask CreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) @@ -103,9 +263,16 @@ private static async ValueTask CreateMyFilesFolderAsync(ProtonDriveC return folderNode; } - private static async ValueTask SetMyFilesInCacheAsync(IDriveEntityCache cache, Share share, FolderNode folderNode, CancellationToken cancellationToken) + private static async ValueTask SetMyFilesInCacheAsync( + IDriveEntityCache cache, + Share share, + FolderNode folderNode, + CancellationToken cancellationToken) { - await cache.SetNodeAsync(folderNode, cancellationToken).ConfigureAwait(false); + // The My Files root folder never has siblings and does not need a name hash digest + var nameHashDigest = ReadOnlyMemory.Empty; + + await cache.SetNodeAsync(folderNode.Id, folderNode, share.Id, nameHashDigest, cancellationToken).ConfigureAwait(false); await cache.SetMyFilesShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); await cache.SetShareAsync(share, cancellationToken).ConfigureAwait(false); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs index c2c66b46..79aa9c89 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs @@ -6,5 +6,5 @@ internal class NodeSecrets { public required PgpPrivateKey Key { get; init; } public required PgpSessionKey PassphraseSessionKey { get; init; } - public required PgpSessionKey? NameSessionKey { get; init; } + public required PgpSessionKey NameSessionKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeState.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeState.cs deleted file mode 100644 index 5c5855ea..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeState.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Proton.Sdk.Drive; - -public enum NodeState -{ - Draft = 0, - Active = 1, - Trashed = 2, -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhasedDecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhasedDecryptionOutput.cs new file mode 100644 index 00000000..70e125b6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhasedDecryptionOutput.cs @@ -0,0 +1,6 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct PhasedDecryptionOutput(PgpSessionKey SessionKey, TData Data, Result Author); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs new file mode 100644 index 00000000..3496ae6b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -0,0 +1,14 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed class Revision +{ + public required RevisionId Id { get; init; } + public required DateTime CreationTime { get; init; } + public required long StorageQuotaConsumption { get; init; } + public required long? ClaimedSize { get; init; } + public required DateTime? ClaimedModificationTime { get; init; } + public required IReadOnlyList> Thumbnails { get; init; } + public required Result? MetadataAuthor { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionId.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionId.cs new file mode 100644 index 00000000..a53e7621 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Nodes; + +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct RevisionId : IStrongId +{ + private readonly string? _value; + + internal RevisionId(string? value) + { + _value = value; + } + + public static explicit operator RevisionId(string? value) + { + return new RevisionId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SessionKeyAndData.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SessionKeyAndData.cs deleted file mode 100644 index 1036dc16..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SessionKeyAndData.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Proton.Cryptography.Pgp; -using Proton.Sdk; - -namespace Proton.Drive.Sdk.Nodes; - -internal readonly record struct SessionKeyAndData( - PgpSessionKey SessionKey, - Result<(TData Data, Result Author), DecryptionError> DataDecryptionResult); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ShareAndKey.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ShareAndKey.cs new file mode 100644 index 00000000..d1b40d6e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ShareAndKey.cs @@ -0,0 +1,6 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct ShareAndKey(Share Share, PgpPrivateKey Key); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs index 3704319b..5ea9aed8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs @@ -1,7 +1,31 @@ -namespace Proton.Drive.Sdk.Nodes; +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; +namespace Proton.Drive.Sdk.Nodes; + +[method: JsonConstructor] public sealed class SignatureVerificationError(Author claimedAuthor, string? message = null) - : Error(message) + : ProtonDriveError(message) { + public SignatureVerificationError(Author claimedAuthor, PgpVerificationStatus? verificationStatus = null, string? message = null) + : this(claimedAuthor, GetMessage(verificationStatus, message)) + { + } + public Author ClaimedAuthor { get; } = claimedAuthor; + + private static string GetMessage(PgpVerificationStatus? verificationStatus, string? message) + { + if (!string.IsNullOrEmpty(message)) + { + return message; + } + + if (verificationStatus is null) + { + return "Authorship could not be verified"; + } + + return $"Verification resulted in unsuccessful status: {verificationStatus}"; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index d23d55cd..b1f854f0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -7,7 +7,7 @@ namespace Proton.Drive.Sdk; public sealed class ProtonDriveClient { - private const int ApiTimeoutSeconds = 15; + private const int ApiTimeoutSeconds = 20; /// /// Creates a new instance of . @@ -38,4 +38,9 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio { return NodeOperations.GetMyFilesFolderAsync(this, cancellationToken); } + + public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) + { + return NodeOperations.EnumerateFolderChildrenAsync(this, folderId, cancellationToken); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs new file mode 100644 index 00000000..383dc54f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk; + +public class ProtonDriveError(string? message, ProtonDriveError? innerError = null) +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; } = message; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ProtonDriveError? InnerError { get; } = innerError; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index b3927068..5a98bf38 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -1,7 +1,9 @@ using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Serialization; @@ -23,6 +25,8 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(VolumeCreationResponse))] [JsonSerializable(typeof(LinkDetailsRequest))] [JsonSerializable(typeof(LinkDetailsResponse))] +[JsonSerializable(typeof(ExtendedAttributes))] [JsonSerializable(typeof(ShareResponse))] [JsonSerializable(typeof(ShareResponseV2))] +[JsonSerializable(typeof(FolderChildrenResponse))] internal sealed partial class DriveApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs index 824337dd..86872485 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -12,11 +12,13 @@ namespace Proton.Drive.Sdk.Serialization; [ typeof(ResultJsonConverter), typeof(ResultJsonConverter), + typeof(ResultJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(Share))] [JsonSerializable(typeof(FolderNode))] -[JsonSerializable(typeof(Node))] +[JsonSerializable(typeof(CachedNodeInfo))] [JsonSerializable(typeof(SerializableResult))] [JsonSerializable(typeof(SerializableResult))] +[JsonSerializable(typeof(SerializableResult))] internal sealed partial class DriveEntitiesSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs new file mode 100644 index 00000000..2a2492d1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs @@ -0,0 +1,28 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Shares; + +internal static class ShareCrypto +{ + public static async ValueTask DecryptShareKeyAsync( + ProtonDriveClient client, + ShareId shareId, + PgpArmoredPrivateKey lockedKey, + PgpArmoredMessage passphraseMessage, + AddressId addressId, + CancellationToken cancellationToken) + { + var addressKeys = await client.Account.GetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + + var passphrase = new PgpPrivateKeyRing(addressKeys).Decrypt(passphraseMessage); + + var key = PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase); + + await client.Cache.Secrets.SetShareKeyAsync(shareId, key, cancellationToken).ConfigureAwait(false); + + return key; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index 89fce1fa..0b1a6aa3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -1,45 +1,28 @@ -using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; -using Proton.Sdk.Addresses; -using Proton.Sdk.Cryptography; namespace Proton.Drive.Sdk.Shares; internal static class ShareOperations { - public static async ValueTask GetShareAsync(ProtonDriveClient client, ShareId shareId, CancellationToken cancellationToken) + public static async ValueTask GetShareAsync( + ProtonDriveClient client, + ShareId shareId, + CancellationToken cancellationToken) { var share = await client.Cache.Entities.TryGetShareAsync(shareId, cancellationToken).ConfigureAwait(false); + var shareKey = await client.Cache.Secrets.TryGetShareKeyAsync(shareId, cancellationToken).ConfigureAwait(false); - if (share is null) + if (share is null || shareKey is null) { var response = await client.Api.Shares.GetShareAsync(shareId, cancellationToken).ConfigureAwait(false); - await DecryptShareKeyAsync(client, shareId, response.Key, response.Passphrase, response.AddressId, cancellationToken).ConfigureAwait(false); + shareKey = await ShareCrypto.DecryptShareKeyAsync(client, shareId, response.Key, response.Passphrase, response.AddressId, cancellationToken) + .ConfigureAwait(false); share = new Share(shareId, new NodeUid(response.VolumeId, response.RootLinkId)); } - return share; - } - - public static async ValueTask DecryptShareKeyAsync( - ProtonDriveClient client, - ShareId shareId, - PgpArmoredPrivateKey lockedKey, - PgpArmoredMessage passphraseMessage, - AddressId addressId, - CancellationToken cancellationToken) - { - var addressKeys = await client.Account.GetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); - - var passphrase = new PgpPrivateKeyRing(addressKeys).Decrypt(passphraseMessage); - - var key = PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase); - - await client.Cache.Secrets.SetShareKeyAsync(shareId, key, cancellationToken).ConfigureAwait(false); - - return key; + return new ShareAndKey(share, shareKey.Value); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 74926653..a2bb974a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -3,7 +3,6 @@ using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Addresses; -using Proton.Sdk.Drive; namespace Proton.Drive.Sdk.Volumes; @@ -11,7 +10,9 @@ internal static class VolumeOperations { private const string RootFolderName = "root"; - internal static async ValueTask<(Volume Volume, FolderNode RootFolder)> CreateVolumeAsync(ProtonDriveClient client, CancellationToken cancellationToken) + internal static async ValueTask<(Volume Volume, FolderNode RootFolder)> CreateVolumeAsync( + ProtonDriveClient client, + CancellationToken cancellationToken) { var defaultAddress = await client.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); @@ -26,10 +27,11 @@ internal static class VolumeOperations var rootFolder = new FolderNode { Id = volume.RootFolderId, + ParentId = null, Name = RootFolderName, - NameAuthor = new Author(defaultAddress.EmailAddress), - State = NodeState.Active, - KeyAuthor = new Author(defaultAddress.EmailAddress), + NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, + Author = new Author { EmailAddress = defaultAddress.EmailAddress }, + IsTrashed = false, }; await client.Cache.Entities.SetMainVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); @@ -73,7 +75,7 @@ private static VolumeCreationParameters GetCreationParameters( addressKey, out var folderPassphraseSignature); - var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.NameSessionKey.Value); + var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.NameSessionKey); var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); var encryptedHashKey = rootFolderSecrets.Key.EncryptAndSign(rootFolderSecrets.HashKey.Span, addressKey); diff --git a/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs index 8af934c9..0c926a85 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs @@ -19,7 +19,7 @@ public ValueTask SetAccountKeyPassphraseAsync(string keyId, ReadOnlyMemory var serializedValue = await _repository.TryGetAsync(cacheKey, cancellationToken).ConfigureAwait(false); - return serializedValue is not null ? Convert.FromBase64String(serializedValue) : null; + return serializedValue is not null ? (ReadOnlyMemory?)Convert.FromBase64String(serializedValue) : null; } private static string GetAccountPassphraseCacheKey(string keyId) diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index 2d3ad277..e1d60f6f 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -3,7 +3,7 @@ namespace Proton.Sdk.Caching; -internal sealed class SqliteCacheRepository : ICacheRepository, IDisposable +public sealed class SqliteCacheRepository : ICacheRepository, IDisposable { private readonly SqliteConnection _connection; diff --git a/cs/sdk/src/Proton.Sdk/Http/ProtonClientTlsPolicy.cs b/cs/sdk/src/Proton.Sdk/Http/ProtonClientTlsPolicy.cs new file mode 100644 index 00000000..6e5c1615 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/ProtonClientTlsPolicy.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Http; + +public enum ProtonClientTlsPolicy +{ + Strict = 0, + NoCertificatePinning = 1, + NoCertificateValidation = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs index 254b69b2..c6d5bae1 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -69,6 +69,8 @@ internal async ValueTask> GetUserKeysAsync(Cancella var unlockedKeys = new List(response.User.Keys.Count); + var activeKeyFound = false; + foreach (var userKey in response.User.Keys) { if (!userKey.IsActive) @@ -76,6 +78,8 @@ internal async ValueTask> GetUserKeysAsync(Cancella continue; } + activeKeyFound = true; + var passphrase = await Cache.SessionSecrets.TryGetAccountKeyPassphraseAsync(userKey.Id.ToString(), cancellationToken).ConfigureAwait(false); if (passphrase is null) @@ -91,7 +95,7 @@ internal async ValueTask> GetUserKeysAsync(Cancella if (unlockedKeys.Count == 0) { - throw new ProtonApiException("No active user key was found."); + throw new ProtonApiException(activeKeyFound ? "At least one active user key exists, but none could be unlocked." : "No active user key found"); } await Cache.Secrets.SetUserKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs index 0bdb4864..a47eaf8e 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Proton.Sdk.Caching; +using Proton.Sdk.Http; namespace Proton.Sdk; @@ -9,8 +10,12 @@ internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientO public Uri BaseUrl { get; } = options?.BaseUrl ?? ProtonApiDefaults.BaseUrl; public string AppVersion { get; } = appVersion; public string UserAgent { get; } = options?.UserAgent ?? string.Empty; - public bool DisableTlsCertificatePinning { get; } = options?.DisableTlsCertificatePinning ?? false; - public bool IgnoreSslCertificateErrors { get; } = options?.IgnoreSslCertificateErrors ?? false; + + public ProtonClientTlsPolicy TlsPolicy { get; } = + options?.TlsPolicy is { } tlsPolicy && Enum.IsDefined(tlsPolicy) + ? tlsPolicy + : ProtonClientTlsPolicy.Strict; + public Func? CustomHttpMessageHandlerFactory { get; } = options?.CustomHttpMessageHandlerFactory; public ICacheRepository SecretCacheRepository { get; } = options?.SecretCacheRepository ?? SqliteCacheRepository.OpenInMemory(); public ICacheRepository EntityCacheRepository { get; } = options?.EntityCacheRepository ?? SqliteCacheRepository.OpenInMemory(); diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index e957567e..561353b9 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -22,8 +22,6 @@ public static HttpClient GetHttpClient( var services = new ServiceCollection(); - services.AddSingleton(config.LoggerFactory); - services.ConfigureHttpClientDefaults( builder => { @@ -33,15 +31,17 @@ public static HttpClient GetHttpClient( handler.AddAutomaticDecompression(); handler.ConfigureCookies(CookieContainer); - if (config.IgnoreSslCertificateErrors) + switch (config.TlsPolicy) { + case ProtonClientTlsPolicy.Strict: + handler.AddTlsPinning(); + break; + + case ProtonClientTlsPolicy.NoCertificateValidation: #pragma warning disable S4830 // Certificates are intentionally not verified - handler.SslOptions.RemoteCertificateValidationCallback += (_, _, _, _) => true; + handler.SslOptions.RemoteCertificateValidationCallback += (_, _, _, _) => true; #pragma warning restore S4830 - } - else if (!config.DisableTlsCertificatePinning) - { - handler.AddTlsPinning(); + break; } }); diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs index 82d4070f..14efd3b3 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Proton.Sdk.Caching; +using Proton.Sdk.Http; namespace Proton.Sdk; @@ -7,8 +8,7 @@ public record ProtonClientOptions { public Uri? BaseUrl { get; set; } public string? UserAgent { get; set; } - public bool? DisableTlsCertificatePinning { get; set; } - public bool? IgnoreSslCertificateErrors { get; set; } + public ProtonClientTlsPolicy? TlsPolicy { get; set; } public Func? CustomHttpMessageHandlerFactory { get; set; } public ICacheRepository? EntityCacheRepository { get; set; } public ILoggerFactory? LoggerFactory { get; set; } diff --git a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs index f758f641..fdc1874b 100644 --- a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs @@ -1,9 +1,63 @@ -namespace Proton.Sdk; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +namespace Proton.Sdk; public static class ResultExtensions { - public static T? GetValueOrDefault(this Result result, T? defaultValue = default) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Result Convert( + this Result result, + Func convertValue, + Func convertError) { - return result.TryGetValue(out var value, out _) ? value : defaultValue; + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Result Convert( + this Result result, + Func convertValue) + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : error; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Result Convert( + this Result result, + Func convertError) + { + return result.TryGetValueElseError(out var value, out var error) ? value : convertError.Invoke(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T? GetValueOrDefault(this Result result, T? defaultValue = null) + where T : class + { + return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T? GetValueOrDefault(this Result result, T? defaultValue = null) + where T : struct + { + return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T GetValueOrThrow(this Result result) + { + return result.TryGetValueElseError(out var value, out _) ? value : throw new InvalidOperationException("Cannot get value from failed result"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetValue(this Result result, [MaybeNullWhen(false)] out T value) + { + return result.TryGetValueElseError(out value, out _); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetError(this Result result, [MaybeNullWhen(false)] out TError error) + { + return !result.TryGetValueElseError(out _, out error); } } diff --git a/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs b/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs index 5664af8f..6a7e658c 100644 --- a/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs +++ b/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs @@ -2,28 +2,35 @@ namespace Proton.Sdk; -public sealed class Result : Result +public readonly struct Result { private readonly T? _value; + private readonly TError? _error; public Result(T value) { + IsSuccess = true; _value = value; + _error = default; } public Result(TError error) - : base(error) { + IsSuccess = false; + _error = error; _value = default; } + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + public static implicit operator Result(T value) => new(value); public static implicit operator Result(TError error) => new(error); - public bool TryGetValue([MaybeNullWhen(false)] out T value, [MaybeNullWhen(true)] out TError error) + public bool TryGetValueElseError([MaybeNullWhen(false)] out T value, [MaybeNullWhen(true)] out TError error) { value = _value; - error = Error; + error = _error; return IsSuccess; } } diff --git a/cs/sdk/src/Proton.Sdk/Result{TError}.cs b/cs/sdk/src/Proton.Sdk/Result{TError}.cs index 8b2f9f83..b7e02515 100644 --- a/cs/sdk/src/Proton.Sdk/Result{TError}.cs +++ b/cs/sdk/src/Proton.Sdk/Result{TError}.cs @@ -2,32 +2,32 @@ namespace Proton.Sdk; -public class Result +public readonly struct Result { public static readonly Result Success = new(); + private readonly TError? _error; + public Result(TError error) { IsSuccess = false; - Error = error; + _error = error; } - protected Result() + public Result() { IsSuccess = true; - Error = default; + _error = default; } public bool IsSuccess { get; } public bool IsFailure => !IsSuccess; - protected TError? Error { get; } - public static implicit operator Result(TError error) => new(error); public bool TryGetError([MaybeNullWhen(true)] out TError error) { - error = Error; + error = _error; return IsFailure; } } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs index e3ed01f8..8fb15f39 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs @@ -1,20 +1,23 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace Proton.Sdk.Serialization; internal sealed class ResultJsonConverter : JsonConverter> { - public override Result? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var dto = JsonSerializer.Deserialize>(ref reader, options); + var dto = JsonSerializer.Deserialize( + ref reader, + (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableResult))); Result? result; - if (dto.Successful) + if (dto.IsSuccess) { if (dto.Value is null) { - return null; + throw new JsonException("Missing \"Value\" property for success result."); } result = dto.Value; @@ -23,21 +26,21 @@ internal sealed class ResultJsonConverter : JsonConverter value, JsonSerializerOptions options) { - var dto = value.TryGetValue(out var innerValue, out var error) - ? new SerializableResult { Successful = true, Value = innerValue } + var dto = value.TryGetValueElseError(out var innerValue, out var error) + ? new SerializableResult { IsSuccess = true, Value = innerValue } : new SerializableResult { Error = error }; - JsonSerializer.Serialize(writer, dto, options); + JsonSerializer.Serialize(writer, dto, (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableResult))); } } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs b/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs index 167115d3..d9e5b505 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs @@ -1,10 +1,14 @@ -namespace Proton.Sdk.Serialization; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; internal struct SerializableResult { - public bool Successful { get; set; } + public bool IsSuccess { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public T? Value { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public TError? Error { get; set; } } From bbe16a08dee8c30665caaea59bf30fa8e79183e2 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 17 Apr 2025 05:19:11 +0000 Subject: [PATCH 073/791] Rename iterateChildren --- js/sdk/src/internal/nodes/index.test.ts | 8 ++++---- js/sdk/src/internal/nodes/nodesAccess.test.ts | 10 +++++----- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- js/sdk/src/protonDriveClient.ts | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index 3378ea50..c4ddb50d 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -104,10 +104,10 @@ describe('nodesModules integration tests', () => { jest.spyOn(nodesModule.access, 'getParentKeys').mockResolvedValue({key: 'privateKey'}); // Verify the inital state before move event is sent. - const originalBeforeMove = await Array.fromAsync(nodesModule.access.iterateChildren(originalFolderUid)); + const originalBeforeMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(originalFolderUid)); expect(originalBeforeMove).toMatchObject([{ uid: nodeUid, parentUid: originalFolderUid }]); - const targetBeforeMove = await Array.fromAsync(nodesModule.access.iterateChildren(targetFolderUid)); + const targetBeforeMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(targetFolderUid)); expect(targetBeforeMove).toMatchObject([]); // Send the move event that updates the cache. @@ -124,11 +124,11 @@ describe('nodesModules integration tests', () => { await Promise.all(eventCallbacks.map((callback) => callback(events))); // Verify the state after the move event, including when API service is called. - const originalAfterMove = await Array.fromAsync(nodesModule.access.iterateChildren(originalFolderUid)); + const originalAfterMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(originalFolderUid)); expect(originalAfterMove).toMatchObject([]); expect(apiService.post).not.toHaveBeenCalled(); - const targetAfterMove = await Array.fromAsync(nodesModule.access.iterateChildren(targetFolderUid)); + const targetAfterMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(targetFolderUid)); expect(targetAfterMove).toMatchObject([{ uid: nodeUid, parentUid: targetFolderUid }]); expect(apiService.post).toHaveBeenCalledTimes(1); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 820249a8..45ebd234 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -161,7 +161,7 @@ describe('nodesAccess', () => { yield { ok: true, node: node4 }; }); - const result = await Array.fromAsync(access.iterateChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).not.toHaveBeenCalled(); expect(apiService.iterateNodes).not.toHaveBeenCalled(); @@ -176,7 +176,7 @@ describe('nodesAccess', () => { yield { ok: true, uid: node4.uid, node: node4 }; }); - const result = await Array.fromAsync(access.iterateChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); expect(result).toMatchObject([node1, node4, node2, node3]); expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(2); @@ -193,7 +193,7 @@ describe('nodesAccess', () => { }); cache.getNode = jest.fn().mockImplementation((uid: string) => ({ uid, isStale: false })); - const result = await Array.fromAsync(access.iterateChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); expect(apiService.iterateNodes).not.toHaveBeenCalled(); @@ -214,7 +214,7 @@ describe('nodesAccess', () => { throw new Error('Entity not found'); }); - const result = await Array.fromAsync(access.iterateChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); @@ -241,7 +241,7 @@ describe('nodesAccess', () => { yield* uids.slice(1).map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)); }); - const result = await Array.fromAsync(access.iterateChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); expect(result).toMatchObject([node2, node3]); expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 69762958..8fbaeab5 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -59,7 +59,7 @@ export class NodesAccess { return node; } - async *iterateChildren(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + async *iterateFolderChildren(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { // Ensure the parent is loaded and up-to-date. const parentNode = await this.getNode(parentNodeUid); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 5b60536a..8b027777 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -110,9 +110,9 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the children of the given parent node. */ - async* iterateChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + async* iterateFolderChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); - yield* convertInternalNodeIterator(this.nodes.access.iterateChildren(getUid(parentNodeUid), signal)); + yield* convertInternalNodeIterator(this.nodes.access.iterateFolderChildren(getUid(parentNodeUid), signal)); } /** From 35f5ec46e3553052ef8b8c8a524876f3860fbda0 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 17 Apr 2025 07:56:59 +0000 Subject: [PATCH 074/791] Implement creation of folders --- .../Proton.Drive.Sdk/AccountClientAdapter.cs | 18 +- .../Api/Folders/FolderCreationParameters.cs | 11 ++ .../Api/Folders/FolderCreationResponse.cs | 10 + .../Proton.Drive.Sdk/Api/Folders/FolderId.cs | 10 + .../Api/Folders/FoldersApiClient.cs | 17 +- .../Api/Folders/IFoldersApiClient.cs | 8 +- .../Api/Links/ContextShareResponse.cs | 11 ++ .../Api/Links/ILinksApiClient.cs | 2 + .../Api/Links/LinksApiClient.cs | 7 + .../Api/Links/NodeCreationParameters.cs | 29 +++ cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs | 6 +- .../Nodes/FolderOperations.cs | 149 +++++++++++++++ .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 175 ++++++++---------- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 7 +- .../DriveApiSerializerContext.cs | 3 + cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs | 4 +- .../Proton.Drive.Sdk/Shares/ShareCrypto.cs | 11 +- .../Shares/ShareOperations.cs | 14 +- .../Volumes/VolumeOperations.cs | 15 +- .../Proton.Sdk/Addresses/AddressOperations.cs | 21 ++- cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 13 +- 21 files changed, 410 insertions(+), 131 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationParameters.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs index 1b4449c3..1149604f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs @@ -8,19 +8,29 @@ internal sealed class AccountClientAdapter(ProtonApiSession session) : IAccountC { private readonly ProtonAccountClient _client = new(session); + public ValueTask
GetAddressAsync(ProtonDriveClient client, AddressId addressId, CancellationToken cancellationToken) + { + return _client.GetAddressAsync(addressId, cancellationToken); + } + public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) { return _client.GetCurrentUserDefaultAddressAsync(cancellationToken); } - public ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken) + public ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken) + { + return _client.GetAddressPrimaryPrivateKeyAsync(addressId, cancellationToken); + } + + public ValueTask GetAddressPrivateKeyAsync(AddressId addressId, int index, CancellationToken cancellationToken) { - return _client.GetAddressPrimaryKeyAsync(addressId, cancellationToken); + return _client.GetAddressPrivateKeyAsync(addressId, index, cancellationToken); } - public ValueTask> GetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + public ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken) { - return _client.GetAddressKeysAsync(addressId, cancellationToken); + return _client.GetAddressPrivateKeysAsync(addressId, cancellationToken); } public ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationParameters.cs new file mode 100644 index 00000000..49e7a2d7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationParameters.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FolderCreationParameters : NodeCreationParameters +{ + [JsonPropertyName("NodeHashKey")] + public required PgpArmoredMessage HashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs new file mode 100644 index 00000000..38e28b2f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FolderCreationResponse : ApiResponse +{ + [JsonPropertyName("Folder")] + public required FolderId FolderId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs new file mode 100644 index 00000000..b0265bba --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal readonly struct FolderId +{ + [JsonPropertyName("ID")] + public required LinkId Value { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs index e8cc302f..411a72be 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs @@ -9,11 +9,7 @@ internal sealed class FoldersApiClient(HttpClient httpClient) : IFoldersApiClien { private readonly HttpClient _httpClient = httpClient; - public async Task GetChildrenAsync( - VolumeId volumeId, - LinkId linkId, - LinkId? anchorId, - CancellationToken cancellationToken) + public async ValueTask GetChildrenAsync(VolumeId volumeId, LinkId linkId, LinkId? anchorId, CancellationToken cancellationToken) { var query = anchorId is not null ? $"?AnchorID={anchorId}" : string.Empty; @@ -21,4 +17,15 @@ public async Task GetChildrenAsync( .Expecting(DriveApiSerializerContext.Default.FolderChildrenResponse) .GetAsync($"v2/volumes/{volumeId}/folders/{linkId}/children{query}", cancellationToken).ConfigureAwait(false); } + + public async ValueTask CreateFolderAsync( + VolumeId volumeId, + FolderCreationParameters parameters, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.FolderCreationResponse) + .PostAsync($"v2/volumes/{volumeId}/folders", parameters, DriveApiSerializerContext.Default.FolderCreationParameters, cancellationToken) + .ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs index 3b56aee1..2cce7dd2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs @@ -5,9 +5,7 @@ namespace Proton.Drive.Sdk.Api.Folders; internal interface IFoldersApiClient { - Task GetChildrenAsync( - VolumeId volumeId, - LinkId linkId, - LinkId? anchorId, - CancellationToken cancellationToken); + ValueTask GetChildrenAsync(VolumeId volumeId, LinkId linkId, LinkId? anchorId, CancellationToken cancellationToken); + + ValueTask CreateFolderAsync(VolumeId volumeId, FolderCreationParameters parameters, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs new file mode 100644 index 00000000..870edc91 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal class ContextShareResponse : ApiResponse +{ + [JsonPropertyName("ContextShareID")] + public required ShareId ContextShareId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs index ab334ac0..8bd1e4df 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs @@ -5,4 +5,6 @@ namespace Proton.Drive.Sdk.Api.Links; internal interface ILinksApiClient { ValueTask GetLinkDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken); + + ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs index e47be255..ff90673f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -15,4 +15,11 @@ public async ValueTask GetLinkDetailsAsync(VolumeId volumeI .PostAsync($"v2/volumes/{volumeId}/links", new LinkDetailsRequest(linkIds), DriveApiSerializerContext.Default.LinkDetailsRequest, cancellationToken) .ConfigureAwait(false); } + + public async ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ContextShareResponse) + .GetAsync($"volumes/{volumeId}/links/{linkId}/context", cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs new file mode 100644 index 00000000..2bef1b16 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal abstract class NodeCreationParameters +{ + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("Hash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("ParentLinkID")] + public required LinkId ParentLinkId { get; init; } + + [JsonPropertyName("NodePassphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("NodePassphraseSignature")] + public required PgpArmoredSignature PassphraseSignature { get; init; } + + [JsonPropertyName("SignatureEmail")] + public required string SignatureEmailAddress { get; init; } + + [JsonPropertyName("NodeKey")] + public required PgpArmoredPrivateKey Key { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs index f7f396a8..9c9f5d7c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs @@ -5,8 +5,10 @@ namespace Proton.Drive.Sdk; internal interface IAccountClient { + ValueTask
GetAddressAsync(ProtonDriveClient client, AddressId addressId, CancellationToken cancellationToken); ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken); - ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken); - ValueTask> GetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken); + ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken); + ValueTask GetAddressPrivateKeyAsync(AddressId addressId, int index, CancellationToken cancellationToken); + ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken); ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs new file mode 100644 index 00000000..2f716339 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -0,0 +1,149 @@ +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Shares; +using Proton.Sdk; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class FolderOperations +{ + public static async IAsyncEnumerable> EnumerateFolderChildrenAsync( + ProtonDriveClient client, + NodeUid folderId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var anchorId = default(LinkId?); + var mustTryMoreResults = true; + + var folderSecrets = await GetSecretsAsync(client, folderId, cancellationToken).ConfigureAwait(false); + + var batchLoader = new FolderChildrenBatchLoader(client, folderId.VolumeId, folderSecrets.Key); + + while (mustTryMoreResults) + { + var response = await client.Api.Folders.GetChildrenAsync(folderId.VolumeId, folderId.LinkId, anchorId, cancellationToken).ConfigureAwait(false); + + mustTryMoreResults = response.MoreResultsExist; + anchorId = response.AnchorId; + + foreach (var childLinkId in response.LinkIds) + { + var childId = new NodeUid(folderId.VolumeId, childLinkId); + + var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(childId, cancellationToken).ConfigureAwait(false); + + if (cachedChildNodeInfo is null) + { + foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(childLinkId, cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + else + { + yield return cachedChildNodeInfo.Value.NodeProvisionResult; + } + } + } + + foreach (var node in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + { + yield return node; + } + } + + public static async ValueTask CreateFolderAsync(ProtonDriveClient client, NodeUid parentId, string name, CancellationToken cancellationToken) + { + var parentSecrets = await GetSecretsAsync(client, parentId, cancellationToken).ConfigureAwait(false); + + var membershipAddress = await GetMembershipAddressAsync(client, parentId, cancellationToken).ConfigureAwait(false); + + var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + + var hashKey = RandomNumberGenerator.GetBytes(32); + + NodeOperations.GetCommonCreationParameters( + name, + parentSecrets.Key, + parentSecrets.HashKey.Span, + signingKey, + out var key, + out var nameSessionKey, + out var passphraseSessionKey, + out var encryptedName, + out var nameHashDigest, + out var encryptedKeyPassphrase, + out var keyPassphraseSignature, + out var armoredKey); + + var parameters = new FolderCreationParameters + { + Name = encryptedName, + NameHashDigest = nameHashDigest, + ParentLinkId = parentId.LinkId, + Passphrase = encryptedKeyPassphrase, + PassphraseSignature = keyPassphraseSignature, + SignatureEmailAddress = membershipAddress.EmailAddress, + Key = armoredKey, + HashKey = key.EncryptAndSign(hashKey, key), + }; + + var response = await client.Api.Folders.CreateFolderAsync(parentId.VolumeId, parameters, cancellationToken).ConfigureAwait(false); + + var folderId = new NodeUid(parentId.VolumeId, response.FolderId.Value); + + var folderSecrets = new FolderSecrets + { + Key = key, + PassphraseSessionKey = passphraseSessionKey, + NameSessionKey = nameSessionKey, + HashKey = hashKey, + }; + + await client.Cache.Secrets.SetFolderSecretsAsync(folderId, folderSecrets, cancellationToken).ConfigureAwait(false); + + var author = new Author { EmailAddress = membershipAddress.EmailAddress }; + + var folderNode = new FolderNode + { + Id = folderId, + ParentId = parentId, + Name = name, + IsTrashed = false, + NameAuthor = author, + Author = author, + }; + + await client.Cache.Entities.SetNodeAsync(folderId, folderNode, membershipShareId: null, nameHashDigest, cancellationToken).ConfigureAwait(false); + + return folderNode; + } + + internal static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid folderId, CancellationToken cancellationToken) + { + var folderSecrets = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderId, cancellationToken).ConfigureAwait(false); + + if (folderSecrets is null) + { + var nodeProvisionResult = await NodeOperations.GetFreshNodeAndSecretsAsync(client, folderId, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + + folderSecrets = nodeProvisionResult.GetFolderSecretsOrThrow(); + } + + return folderSecrets; + } + + private static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid parentId, CancellationToken cancellationToken) + { + // TODO: try to get the information from cache first + var response = await client.Api.Links.GetContextShareAsync(parentId.VolumeId, parentId.LinkId, cancellationToken).ConfigureAwait(false); + + var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); + + return await client.Account.GetAddressAsync(client, share.MembershipAddressId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 3c365720..75591323 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -1,8 +1,9 @@ -using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Caching; +using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; using Proton.Sdk; @@ -27,63 +28,56 @@ public static async ValueTask GetMyFilesFolderAsync(ProtonDriveClien return (FolderNode)node; } - public static async IAsyncEnumerable> EnumerateFolderChildrenAsync( - ProtonDriveClient client, - NodeUid folderId, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + public static void GetCommonCreationParameters( + string name, + PgpPrivateKey parentFolderKey, + ReadOnlySpan parentFolderHashKey, + PgpPrivateKey signingKey, + out PgpPrivateKey key, + out PgpSessionKey nameSessionKey, + out PgpSessionKey passphraseSessionKey, + out ArraySegment encryptedName, + out ArraySegment nameHashDigest, + out ArraySegment encryptedKeyPassphrase, + out ArraySegment passphraseSignature, + out ArraySegment lockedKeyBytes) { - var anchorId = default(LinkId?); - var mustTryMoreResults = true; - - var folderSecrets = await GetFolderSecretsAsync(client, folderId, cancellationToken).ConfigureAwait(false); - - var batchLoader = new FolderChildrenBatchLoader(client, folderId.VolumeId, folderSecrets.Key); - - while (mustTryMoreResults) - { - var response = await client.Api.Folders.GetChildrenAsync(folderId.VolumeId, folderId.LinkId, anchorId, cancellationToken).ConfigureAwait(false); + key = PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default); + nameSessionKey = PgpSessionKey.Generate(); - mustTryMoreResults = response.MoreResultsExist; - anchorId = response.AnchorId; + Span passphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var passphrase = CryptoGenerator.GeneratePassphrase(passphraseBuffer); - foreach (var childLinkId in response.LinkIds) - { - var childId = new NodeUid(folderId.VolumeId, childLinkId); + passphraseSessionKey = PgpSessionKey.Generate(); + var passphraseEncryptionSecrets = new EncryptionSecrets(parentFolderKey, passphraseSessionKey); - var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(childId, cancellationToken).ConfigureAwait(false); + encryptedKeyPassphrase = PgpEncrypter.EncryptAndSign(passphrase, passphraseEncryptionSecrets, signingKey, out passphraseSignature); - if (cachedChildNodeInfo is null) - { - foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(childLinkId, cancellationToken).ConfigureAwait(false)) - { - yield return nodeResult; - } - } - else - { - yield return cachedChildNodeInfo.Value.NodeProvisionResult; - } - } - } + using var lockedKey = key.Lock(passphrase); + lockedKeyBytes = lockedKey.ToBytes(); - foreach (var node in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) - { - yield return node; - } + GetNameParameters(name, parentFolderKey, parentFolderHashKey, nameSessionKey, signingKey, out encryptedName, out nameHashDigest); } - private static async ValueTask GetFolderSecretsAsync(ProtonDriveClient client, NodeUid folderId, CancellationToken cancellationToken) + public static async ValueTask> GetFreshNodeAndSecretsAsync( + ProtonDriveClient client, + NodeUid nodeId, + ShareAndKey? knownShareAndKey, + CancellationToken cancellationToken) { - var folderSecrets = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderId, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Links.GetLinkDetailsAsync(nodeId.VolumeId, [nodeId.LinkId], cancellationToken).ConfigureAwait(false); - if (folderSecrets is null) - { - var nodeProvisionResult = await GetFreshNodeAndSecretsAsync(client, folderId, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + var linkDetails = response.Links[0]; - folderSecrets = nodeProvisionResult.GetFolderSecretsOrThrow(); - } + var parentKeyResult = await GetParentKeyAsync( + client, + nodeId.VolumeId, + linkDetails.Link.ParentId, + knownShareAndKey, + linkDetails.Membership?.ShareId, + cancellationToken).ConfigureAwait(false); - return folderSecrets; + return await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetails, parentKeyResult, cancellationToken).ConfigureAwait(false); } private static async ValueTask GetNodeAsync( @@ -104,59 +98,41 @@ private static async ValueTask GetNodeAsync( return nodeResult.GetValueOrThrow(); } - private static async ValueTask GetFreshMyFilesFolderAsync( - ProtonDriveClient client, - CancellationToken cancellationToken) + private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { - PgpPrivateKey shareKey; - ShareVolumeDto volume; - ShareDto share; - LinkDetailsDto linkDetails; + ShareVolumeDto volumeDto; + ShareDto shareDto; + LinkDetailsDto linkDetailsDto; try { - (volume, share, linkDetails) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); + (volumeDto, shareDto, linkDetailsDto) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); } catch (ProtonApiException e) when (e.Code == ResponseCode.DoesNotExist) { return await CreateMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); } - shareKey = await ShareCrypto.DecryptShareKeyAsync(client, share.Id, share.Key, share.Passphrase, share.AddressId, cancellationToken) - .ConfigureAwait(false); + var nodeId = new NodeUid(volumeDto.Id, linkDetailsDto.Link.Id); - var nodeId = new NodeUid(volume.Id, linkDetails.Link.Id); + var (share, shareKey) = await ShareCrypto.DecryptShareAsync( + client, + shareDto.Id, + shareDto.Key, + shareDto.Passphrase, + shareDto.AddressId, + nodeId, + cancellationToken).ConfigureAwait(false); - var nodeProvisionResult = await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetails, shareKey, cancellationToken).ConfigureAwait(false); + var nodeProvisionResult = await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetailsDto, shareKey, cancellationToken).ConfigureAwait(false); var folderNode = nodeProvisionResult.GetFolderNodeOrThrow(); - await SetMyFilesInCacheAsync(client.Cache.Entities, new Share(share.Id, folderNode.Id), folderNode, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetMyFilesShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); return folderNode; } - private static async ValueTask> GetFreshNodeAndSecretsAsync( - ProtonDriveClient client, - NodeUid nodeId, - ShareAndKey? knownShareAndKey, - CancellationToken cancellationToken) - { - var response = await client.Api.Links.GetLinkDetailsAsync(nodeId.VolumeId, [nodeId.LinkId], cancellationToken).ConfigureAwait(false); - - var linkDetails = response.Links[0]; - - var parentKeyResult = await GetParentKeyAsync( - client, - nodeId.VolumeId, - linkDetails.Link.ParentId, - knownShareAndKey, - linkDetails.Membership?.ShareId, - cancellationToken).ConfigureAwait(false); - - return await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetails, parentKeyResult, cancellationToken).ConfigureAwait(false); - } - private static async Task> GetParentKeyAsync( ProtonDriveClient client, VolumeId volumeId, @@ -252,28 +228,35 @@ private static async Task> GetParentKeyA return currentParentKey; } - private static async ValueTask CreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + private static void GetNameParameters( + string name, + PgpPrivateKey parentFolderKey, + ReadOnlySpan parentFolderHashKey, + PgpSessionKey nameSessionKey, + PgpPrivateKey signingKey, + out ArraySegment encryptedName, + out ArraySegment nameHashDigest) { - var (volume, folderNode) = await VolumeOperations.CreateVolumeAsync(client, cancellationToken).ConfigureAwait(false); + var maxNameByteLength = Encoding.UTF8.GetByteCount(name); + var nameBytes = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) + ? nameHeapMemoryOwner.Memory.Span + : stackalloc byte[maxNameByteLength]; - var share = new Share(volume.RootShareId, volume.RootFolderId); + using (nameHeapMemoryOwner) + { + var nameByteLength = Encoding.UTF8.GetBytes(name, nameBytes); + nameBytes = nameBytes[..nameByteLength]; - await SetMyFilesInCacheAsync(client.Cache.Entities, share, folderNode, cancellationToken).ConfigureAwait(false); + encryptedName = PgpEncrypter.EncryptAndSignText(name, new EncryptionSecrets(parentFolderKey, nameSessionKey), signingKey); - return folderNode; + nameHashDigest = HMACSHA256.HashData(parentFolderHashKey, nameBytes); + } } - private static async ValueTask SetMyFilesInCacheAsync( - IDriveEntityCache cache, - Share share, - FolderNode folderNode, - CancellationToken cancellationToken) + private static async ValueTask CreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { - // The My Files root folder never has siblings and does not need a name hash digest - var nameHashDigest = ReadOnlyMemory.Empty; + var (_, _, folderNode) = await VolumeOperations.CreateVolumeAsync(client, cancellationToken).ConfigureAwait(false); - await cache.SetNodeAsync(folderNode.Id, folderNode, share.Id, nameHashDigest, cancellationToken).ConfigureAwait(false); - await cache.SetMyFilesShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); - await cache.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + return folderNode; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index b1f854f0..67a0fc1c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -39,8 +39,13 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio return NodeOperations.GetMyFilesFolderAsync(this, cancellationToken); } + public ValueTask CreateFolderAsync(NodeUid parentId, string name, CancellationToken cancellationToken) + { + return FolderOperations.CreateFolderAsync(this, parentId, name, cancellationToken); + } + public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) { - return NodeOperations.EnumerateFolderChildrenAsync(this, folderId, cancellationToken); + return FolderOperations.EnumerateFolderChildrenAsync(this, folderId, cancellationToken); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 5a98bf38..2f67212b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -28,5 +28,8 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(ExtendedAttributes))] [JsonSerializable(typeof(ShareResponse))] [JsonSerializable(typeof(ShareResponseV2))] +[JsonSerializable(typeof(ContextShareResponse))] [JsonSerializable(typeof(FolderChildrenResponse))] +[JsonSerializable(typeof(FolderCreationParameters))] +[JsonSerializable(typeof(FolderCreationResponse))] internal sealed partial class DriveApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs index df11328a..1ad67465 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs @@ -1,10 +1,12 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Addresses; namespace Proton.Drive.Sdk.Shares; -internal sealed class Share(ShareId id, NodeUid rootFolderId) +internal sealed class Share(ShareId id, NodeUid rootFolderId, AddressId membershipAddressId) { public ShareId Id { get; } = id; public NodeUid RootFolderId { get; } = rootFolderId; + public AddressId MembershipAddressId { get; } = membershipAddressId; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs index 2a2492d1..2930b6df 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs @@ -1,5 +1,6 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Addresses; using Proton.Sdk.Cryptography; @@ -7,22 +8,26 @@ namespace Proton.Drive.Sdk.Shares; internal static class ShareCrypto { - public static async ValueTask DecryptShareKeyAsync( + public static async ValueTask<(Share Share, PgpPrivateKey Key)> DecryptShareAsync( ProtonDriveClient client, ShareId shareId, PgpArmoredPrivateKey lockedKey, PgpArmoredMessage passphraseMessage, AddressId addressId, + NodeUid rootFolderId, CancellationToken cancellationToken) { - var addressKeys = await client.Account.GetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + var addressKeys = await client.Account.GetAddressPrivateKeysAsync(addressId, cancellationToken).ConfigureAwait(false); var passphrase = new PgpPrivateKeyRing(addressKeys).Decrypt(passphraseMessage); var key = PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase); + var share = new Share(shareId, rootFolderId, addressId); + await client.Cache.Secrets.SetShareKeyAsync(shareId, key, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); - return key; + return (share, key); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index 0b1a6aa3..a31a5d39 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -17,10 +17,18 @@ public static async ValueTask GetShareAsync( { var response = await client.Api.Shares.GetShareAsync(shareId, cancellationToken).ConfigureAwait(false); - shareKey = await ShareCrypto.DecryptShareKeyAsync(client, shareId, response.Key, response.Passphrase, response.AddressId, cancellationToken) - .ConfigureAwait(false); + var rootFolderId = new NodeUid(response.VolumeId, response.RootLinkId); - share = new Share(shareId, new NodeUid(response.VolumeId, response.RootLinkId)); + (_, shareKey) = await ShareCrypto.DecryptShareAsync( + client, + shareId, + response.Key, + response.Passphrase, + response.AddressId, + rootFolderId, + cancellationToken).ConfigureAwait(false); + + share = new Share(shareId, new NodeUid(response.VolumeId, response.RootLinkId), response.AddressId); } return new ShareAndKey(share, shareKey.Value); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index a2bb974a..6f78a04e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -2,6 +2,7 @@ using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; using Proton.Sdk.Addresses; namespace Proton.Drive.Sdk.Volumes; @@ -10,13 +11,13 @@ internal static class VolumeOperations { private const string RootFolderName = "root"; - internal static async ValueTask<(Volume Volume, FolderNode RootFolder)> CreateVolumeAsync( + internal static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreateVolumeAsync( ProtonDriveClient client, CancellationToken cancellationToken) { var defaultAddress = await client.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); - using var addressKey = await client.Account.GetAddressPrimaryKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); + var addressKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); var parameters = GetCreationParameters(defaultAddress.Id, addressKey, out var rootShareKey, out var rootFolderSecrets); @@ -24,6 +25,8 @@ internal static class VolumeOperations var volume = new Volume(response.Volume); + var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id); + var rootFolder = new FolderNode { Id = volume.RootFolderId, @@ -34,12 +37,18 @@ internal static class VolumeOperations IsTrashed = false, }; + // The volume root folder never has siblings and does not need a name hash digest + var nameHashDigest = ReadOnlyMemory.Empty; + await client.Cache.Entities.SetMainVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share.Id, nameHashDigest, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetMyFilesShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); await client.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); await client.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); - return (volume, rootFolder); + return (volume, share, rootFolder); } private static VolumeCreationParameters GetCreationParameters( diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 8a396d22..81e8b183 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -74,7 +74,7 @@ public static async ValueTask
GetCurrentUserDefaultAddressAsync(ProtonA return addresses.OrderBy(x => x.Order).First(); } - public static async ValueTask> GetAddressKeysAsync( + public static async ValueTask> GetAddressPrivateKeysAsync( ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) @@ -96,16 +96,29 @@ public static async ValueTask> GetAddressKeysAsync( return addressKeys; } - public static async ValueTask GetAddressPrimaryKeyAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) + public static async ValueTask GetAddressPrimaryPrivateKeyAsync( + ProtonAccountClient client, + AddressId addressId, + CancellationToken cancellationToken) { - // TODO: use cache var address = await GetAddressAsync(client, addressId, cancellationToken).ConfigureAwait(false); - var addressKeys = await GetAddressKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + var addressKeys = await GetAddressPrivateKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); return addressKeys[address.PrimaryKeyIndex]; } + public static async ValueTask GetAddressPrivateKeyAsync( + ProtonAccountClient client, + AddressId addressId, + int index, + CancellationToken cancellationToken) + { + var addressKeys = await GetAddressPrivateKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + return addressKeys[index]; + } + public static async ValueTask> GetPublicKeysAsync( ProtonAccountClient client, string emailAddress, diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs index c6d5bae1..0c4b46db 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -44,14 +44,19 @@ public ValueTask
GetCurrentUserDefaultAddressAsync(CancellationToken ca return AddressOperations.GetCurrentUserDefaultAddressAsync(this, cancellationToken); } - internal ValueTask> GetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + internal ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken) { - return AddressOperations.GetAddressKeysAsync(this, addressId, cancellationToken); + return AddressOperations.GetAddressPrivateKeysAsync(this, addressId, cancellationToken); } - internal ValueTask GetAddressPrimaryKeyAsync(AddressId addressId, CancellationToken cancellationToken) + internal ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken) { - return AddressOperations.GetAddressPrimaryKeyAsync(this, addressId, cancellationToken); + return AddressOperations.GetAddressPrimaryPrivateKeyAsync(this, addressId, cancellationToken); + } + + public ValueTask GetAddressPrivateKeyAsync(AddressId addressId, int index, CancellationToken cancellationToken) + { + return AddressOperations.GetAddressPrivateKeyAsync(this, addressId, index, cancellationToken); } internal ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) From 571addcf7367a326510172b68b41e145fd4273e3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 23 Apr 2025 07:36:22 +0000 Subject: [PATCH 075/791] Fix import of types --- js/sdk/src/crypto/index.ts | 3 ++- js/sdk/src/internal/apiService/index.ts | 4 ++-- js/sdk/src/internal/events/index.ts | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/js/sdk/src/crypto/index.ts b/js/sdk/src/crypto/index.ts index bd61f6d5..8ed5d30f 100644 --- a/js/sdk/src/crypto/index.ts +++ b/js/sdk/src/crypto/index.ts @@ -1,5 +1,6 @@ export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interface'; export { VERIFICATION_STATUS } from './interface'; export { DriveCrypto } from './driveCrypto'; -export { OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy } from './openPGPCrypto'; +export type { OpenPGPCryptoProxy } from './openPGPCrypto'; +export { OpenPGPCryptoWithCryptoProxy } from './openPGPCrypto'; export { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index b35a37b5..493f142a 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -1,6 +1,6 @@ export { DriveAPIService } from './apiService'; -export { paths as drivePaths } from './driveTypes'; -export { paths as corePaths } from './coreTypes'; +export type { paths as drivePaths } from './driveTypes'; +export type { paths as corePaths } from './coreTypes'; export { HTTPErrorCode, ErrorCode, isCodeOk } from './errorCodes'; export { nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from './transformers'; export { ObserverStream } from './observerStream'; diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 19d196e7..f77db17f 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -6,7 +6,8 @@ import { EventsCache } from "./cache"; import { CoreEventManager } from "./coreEventManager"; import { VolumeEventManager } from "./volumeEventManager"; -export { DriveEvent, DriveEventType, DriveListener } from "./interface"; +export type { DriveEvent, DriveListener } from "./interface"; +export { DriveEventType } from "./interface"; const OWN_VOLUME_POLLING_INTERVAL = 30; const OTHER_VOLUME_POLLING_INTERVAL = 60; From 04bc7c81d012af1f4db2bc8b4fae3dbc558afcf9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 14 Apr 2025 15:56:17 +0200 Subject: [PATCH 076/791] implement uploading new revisions --- js/sdk/src/internal/upload/apiService.ts | 4 +- js/sdk/src/internal/upload/index.ts | 48 +++++++++++++++++++++- js/sdk/src/internal/upload/interface.ts | 16 +++++++- js/sdk/src/internal/upload/manager.test.ts | 15 +++++-- js/sdk/src/internal/upload/manager.ts | 47 +++++++++++++++++++-- js/sdk/src/protonDriveClient.ts | 8 ++++ 6 files changed, 126 insertions(+), 12 deletions(-) diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 58d477fa..ae09210c 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -106,7 +106,7 @@ export class UploadAPIService { clientUID?: string, intendedUploadSize?: number, }): Promise<{ - nodeRevisionsUid: string, + nodeRevisionUid: string, }> { const { volumeId, nodeId } = splitNodeUid(nodeUid); const { revisionId: currentRevisionId } = splitNodeRevisionUid(revision.currentRevisionUid); @@ -121,7 +121,7 @@ export class UploadAPIService { }); return { - nodeRevisionsUid: makeNodeRevisionUid(volumeId, nodeId, result.Revision.ID), + nodeRevisionUid: makeNodeRevisionUid(volumeId, nodeId, result.Revision.ID), } } diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 75e26c9a..5bbc9d8d 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -26,8 +26,9 @@ export function initUploadModule( ) { const api = new UploadAPIService(apiService); const cryptoService = new UploadCryptoService(driveCrypto, sharesService); + const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); - const manager = new UploadManager(telemetry, api, cryptoService, nodesService); + const manager = new UploadManager(telemetry, api, cryptoService, sharesService, nodesService); const queue = new UploadQueue(); @@ -36,7 +37,7 @@ export function initUploadModule( name: string, metadata: UploadMetadata, signal?: AbortSignal, - ) { + ): Promise { await queue.waitForCapacity(signal); let revisionDraft, blockVerifier; @@ -73,7 +74,50 @@ export function initUploadModule( ); } + async function getFileRevisionUploader( + nodeUid: string, + metadata: UploadMetadata, + signal?: AbortSignal, + ): Promise { + await queue.waitForCapacity(signal); + + let revisionDraft, blockVerifier; + try { + revisionDraft = await manager.createDraftRevision(nodeUid, metadata); + + blockVerifier = new BlockVerifier(api, cryptoService, revisionDraft.nodeKeys.key, revisionDraft.nodeRevisionUid); + await blockVerifier.loadVerificationData(); + } catch (error: unknown) { + queue.releaseCapacity(); + if (revisionDraft) { + await manager.deleteDraftRevision(revisionDraft.nodeRevisionUid); + } + void uploadTelemetry.uploadInitFailed(nodeUid, error, metadata.expectedSize); + throw error; + } + + const onFinish = async (failure: boolean) => { + queue.releaseCapacity(); + if (failure) { + await manager.deleteDraftNode(revisionDraft.nodeUid); + } + } + + return new Fileuploader( + uploadTelemetry, + api, + cryptoService, + blockVerifier, + revisionDraft, + metadata, + onFinish, + signal, + ); + + } + return { getFileUploader, + getFileRevisionUploader, } } diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 0bdb5efc..1ba8e225 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,5 +1,6 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { MetricContext, ThumbnailType } from "../../interface"; + +import { MetricContext, ThumbnailType, Result, Revision } from "../../interface"; export type NodeRevisionDraft = { nodeUid: string, @@ -83,7 +84,18 @@ export type UploadTokens = { * Interface describing the dependencies to the nodes module. */ export interface NodesService { - getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, passphraseSessionKey: SessionKey, hashKey?: Uint8Array }>, + getNode(nodeUid: string): Promise, + getNodeKeys(nodeUid: string): Promise<{ + key: PrivateKey, + passphraseSessionKey: SessionKey, + contentKeyPacketSessionKey?: SessionKey, + hashKey?: Uint8Array, + }>, +} + +export interface NodesServiceNode { + uid: string, + activeRevision?: Result, } /** diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 1be91542..d3d7265c 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -4,13 +4,14 @@ import { getMockTelemetry } from "../../tests/telemetry"; import { ErrorCode } from "../apiService"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { NodesService } from "./interface"; +import { SharesService, NodesService } from "./interface"; import { UploadManager } from './manager'; describe("UploadManager", () => { let telemetry: ProtonDriveTelemetry; let apiService: UploadAPIService; let cryptoService: UploadCryptoService; + let sharesService: SharesService; let nodesService: NodesService; let manager: UploadManager; @@ -66,6 +67,14 @@ describe("UploadManager", () => { hash: "name3Hash", }]), } + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getVolumeEmailKey: jest.fn().mockResolvedValue({ + email: "signatureEmail", + addressId: "addressId", + }), + } + // @ts-expect-error No need to implement all methods for mocking nodesService = { getNodeKeys: jest.fn().mockResolvedValue({ hashKey: 'parentNode:hashKey', @@ -73,7 +82,7 @@ describe("UploadManager", () => { }), } - manager = new UploadManager(telemetry, apiService, cryptoService, nodesService); + manager = new UploadManager(telemetry, apiService, cryptoService, sharesService, nodesService); }); describe("createDraftNode", () => { @@ -81,7 +90,7 @@ describe("UploadManager", () => { nodesService.getNodeKeys = jest.fn().mockResolvedValue({ hashKey: undefined }); const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); - await expect(result).rejects.toThrow("Creating folders in non-folders is not allowed"); + await expect(result).rejects.toThrow("Creating files in non-folders is not allowed"); }); it("should create draft node", async () => { diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 64a267fa..fd022373 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -2,10 +2,11 @@ import { c } from "ttag"; import { Logger, ProtonDriveTelemetry, UploadMetadata } from "../../interface"; import { ValidationError, NodeAlreadyExistsValidationError } from "../../errors"; +import { ErrorCode } from "../apiService"; +import { splitNodeUid } from "../uids"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { NodeRevisionDraft, NodesService, NodeCrypto } from "./interface"; -import { ErrorCode } from "../apiService"; +import { NodeRevisionDraft, NodesService, NodeCrypto, SharesService } from "./interface"; /** * UploadManager is responsible for creating and deleting draft nodes @@ -19,18 +20,20 @@ export class UploadManager { telemetry: ProtonDriveTelemetry, private apiService: UploadAPIService, private cryptoService: UploadCryptoService, + private sharesService: SharesService, private nodesService: NodesService, ) { this.logger = telemetry.getLogger('upload'); this.apiService = apiService; this.cryptoService = cryptoService; + this.sharesService = sharesService; this.nodesService = nodesService; } async createDraftNode(parentFolderUid: string, name: string, metadata: UploadMetadata): Promise { const parentKeys = await this.nodesService.getNodeKeys(parentFolderUid); if (!parentKeys.hashKey) { - throw new ValidationError(c('Error').t`Creating folders in non-folders is not allowed`); + throw new ValidationError(c('Error').t`Creating files in non-folders is not allowed`); } const generatedNodeCrypto = await this.cryptoService.generateFileCrypto( @@ -180,6 +183,44 @@ export class UploadManager { this.logger.error('Failed to delete draft node', error); } } + + async createDraftRevision(nodeUid: string, metadata: UploadMetadata): Promise { + const node = await this.nodesService.getNode(nodeUid); + const nodeKeys = await this.nodesService.getNodeKeys(nodeUid); + + if (!node.activeRevision?.ok || !nodeKeys.contentKeyPacketSessionKey) { + throw new ValidationError(c('Error').t`Creating revisions in non-files is not allowed`); + } + + const { volumeId } = splitNodeUid(nodeUid); + const signatureAddress = await this.sharesService.getVolumeEmailKey(volumeId); + + const { nodeRevisionUid } = await this.apiService.createDraftRevision(nodeUid, { + currentRevisionUid: node.activeRevision.value.uid, + intendedUploadSize: metadata.expectedSize, + }); + + return { + nodeUid, + nodeRevisionUid, + nodeKeys: { + key: nodeKeys.key, + contentKeyPacketSessionKey: nodeKeys.contentKeyPacketSessionKey, + signatureAddress: signatureAddress, + }, + } + } + + async deleteDraftRevision(nodeRevisionUid: string): Promise { + try { + await this.apiService.deleteDraftRevision(nodeRevisionUid); + } catch (error: unknown) { + // Only log the error but do not fail the operation as we are + // deleting draft only when somethign fails and original error + // will bubble up. + this.logger.error('Failed to delete draft node revision', error); + } + } } /** diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 8b027777..5679bf8c 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -524,6 +524,14 @@ export class ProtonDriveClient { return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } + /** + * Same as `getFileUploader`, but for a uploading new revision of the file. + */ + async getFileRevisionUploader(nodeUid: NodeOrUid, metadata: UploadMetadata, signal?: AbortSignal): Promise { + this.logger.info(`Getting file revision uploader for ${getUid(nodeUid)}`); + return this.upload.getFileRevisionUploader(getUid(nodeUid), metadata, signal); + } + /** * Iterates the devices of the user. * From 4a12c0ed8c417e9e1da6a0d573c57ed21da1a542 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 23 Apr 2025 15:19:21 +0200 Subject: [PATCH 077/791] Rename date fields --- js/sdk/src/interface/devices.ts | 2 +- js/sdk/src/interface/nodes.ts | 8 ++++---- js/sdk/src/interface/sharing.ts | 12 ++++++------ js/sdk/src/interface/upload.ts | 2 +- js/sdk/src/internal/devices/apiService.ts | 6 +++--- js/sdk/src/internal/devices/interface.ts | 4 ++-- js/sdk/src/internal/nodes/apiService.test.ts | 10 +++++----- js/sdk/src/internal/nodes/apiService.ts | 10 +++++----- js/sdk/src/internal/nodes/cache.test.ts | 12 ++++++------ js/sdk/src/internal/nodes/cache.ts | 14 +++++++------- .../src/internal/nodes/cryptoService.test.ts | 6 +++--- js/sdk/src/internal/nodes/cryptoService.ts | 14 +++++++------- js/sdk/src/internal/nodes/events.test.ts | 2 +- js/sdk/src/internal/nodes/events.ts | 2 +- js/sdk/src/internal/nodes/index.test.ts | 6 +++--- js/sdk/src/internal/nodes/interface.ts | 8 ++++---- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- .../src/internal/nodes/nodesManagement.test.ts | 4 ++-- js/sdk/src/internal/nodes/nodesManagement.ts | 8 ++++---- js/sdk/src/internal/shares/apiService.ts | 2 +- js/sdk/src/internal/shares/cryptoService.ts | 4 ++-- js/sdk/src/internal/shares/interface.ts | 2 +- js/sdk/src/internal/sharing/apiService.ts | 18 +++++++++--------- js/sdk/src/internal/sharing/cryptoService.ts | 12 ++++++------ js/sdk/src/internal/sharing/interface.ts | 16 ++++++++-------- .../internal/sharing/sharingManagement.test.ts | 14 +++++++------- .../src/internal/sharing/sharingManagement.ts | 2 +- js/sdk/src/internal/upload/apiService.ts | 4 ++-- .../src/internal/upload/fileUploader.test.ts | 2 +- js/sdk/src/internal/upload/fileUploader.ts | 4 ++-- js/sdk/src/internal/upload/manager.test.ts | 4 ++-- js/sdk/src/internal/upload/manager.ts | 2 +- js/sdk/src/transformers.ts | 12 ++++++------ 33 files changed, 115 insertions(+), 115 deletions(-) diff --git a/js/sdk/src/interface/devices.ts b/js/sdk/src/interface/devices.ts index e1686d23..88ee669c 100644 --- a/js/sdk/src/interface/devices.ts +++ b/js/sdk/src/interface/devices.ts @@ -6,7 +6,7 @@ export type Device = { type: DeviceType, name: Result, rootFolderUid: string, - createdDate: Date, + creationTime: Date, lastSyncDate?: Date; } diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index fb71484d..9d0d2d6f 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -59,7 +59,7 @@ export type NodeEntity = { nameAuthor: Author, directMemberRole: MemberRole, type: NodeType, - mimeType?: string, + mediaType?: string, /** * Whether the node is shared. If true, the node is shared with at least * one user, or via public link. @@ -68,8 +68,8 @@ export type NodeEntity = { /** * Created on server date. */ - createdDate: Date, - trashedDate?: Date, + creationTime: Date, + trashTime?: Date, activeRevision?: Revision, folder?: { claimedModificationTime?: Date, @@ -132,7 +132,7 @@ export enum MemberRole { export type Revision = { uid: string, state: RevisionState, - createdDate: Date, // created on server date + creationTime: Date, // created on server date contentAuthor: Author, claimedSize?: number, claimedModificationTime?: Date, diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 8fc0208d..888157db 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -4,7 +4,7 @@ import { NodeType, MemberRole, InvalidNameError } from './nodes'; export type Member = { uid: string, - invitedDate: Date, + invitationTime: Date, addedByEmail: Result, inviteeEmail: string, role: MemberRole, @@ -16,7 +16,7 @@ export type ProtonInvitationWithNode = ProtonInvitation & { node: { name: Result, type: NodeType, - mimeType?: string, + mediaType?: string, }, } @@ -31,20 +31,20 @@ export enum NonProtonInvitationState { export type PublicLink = { uid: string, - createDate: Date, + creationTime: Date, role: MemberRole, url: string, customPassword?: string, - expireDate?: Date, + expirationTime?: Date, } export type Bookmark = { uid: string, - bookmarkedDate: Date, + bookmarkTime: Date, node: { name: Result, type: NodeType, - mimeType?: string, + mediaType?: string, }, } diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index f98006c0..96bfcd9e 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -1,7 +1,7 @@ import { Thumbnail } from "./thumbnail"; export type UploadMetadata = { - mimeType: string, + mediaType: string, expectedSize: number, modificationTime?: Date, additionalMetadata?: object, diff --git a/js/sdk/src/internal/devices/apiService.ts b/js/sdk/src/internal/devices/apiService.ts index cebc6188..1e8ad2fb 100644 --- a/js/sdk/src/internal/devices/apiService.ts +++ b/js/sdk/src/internal/devices/apiService.ts @@ -28,8 +28,8 @@ export class DevicesAPIService { uid: makeDeviceUid(device.Device.VolumeID, device.Device.DeviceID), type: deviceTypeNumberToEnum(device.Device.Type), rootFolderUid: makeNodeUid(device.Device.VolumeID, device.Share.LinkID), - createdDate: new Date(device.Device.CreateTime*1000), - lastSyncDate: device.Device.LastSyncTime ? new Date(device.Device.LastSyncTime*1000) : undefined, + creationTime: new Date(device.Device.CreateTime*1000), + lastSyncTime: device.Device.LastSyncTime ? new Date(device.Device.LastSyncTime*1000) : undefined, hasDeprecatedName: !!device.Share.Name, })); } @@ -101,7 +101,7 @@ export class DevicesAPIService { uid: makeDeviceUid(device.volumeId, response.Device.DeviceID), type: device.type, rootFolderUid: makeNodeUid(device.volumeId, response.Device.LinkID), - createdDate: new Date(), + creationTime: new Date(), hasDeprecatedName: false, } } diff --git a/js/sdk/src/internal/devices/interface.ts b/js/sdk/src/internal/devices/interface.ts index f5d9f768..457826b5 100644 --- a/js/sdk/src/internal/devices/interface.ts +++ b/js/sdk/src/internal/devices/interface.ts @@ -6,8 +6,8 @@ export type DeviceMetadata = { uid: string, type: DeviceType rootFolderUid: string, - createdDate: Date, - lastSyncDate?: Date; + creationTime: Date, + lastSyncTime?: Date; hasDeprecatedName: boolean; } diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 0d5f9cd4..86f7346f 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -67,7 +67,7 @@ function generateFileNode(overrides = {}) { return { ...node, type: NodeType.File, - mimeType: "text", + mediaType: "text", encryptedCrypto: { ...node.encryptedCrypto, file: { @@ -77,7 +77,7 @@ function generateFileNode(overrides = {}) { activeRevision: { uid: "volumeId~linkId~revisionId", state: "active", - createdDate: new Date(1234567890000), + creationTime: new Date(1234567890000), signatureEmail: "revSigEmail", armoredExtendedAttributes: "{file}", thumbnails: [], @@ -110,8 +110,8 @@ function generateNode() { uid: "volumeId~linkId", parentUid: "volumeId~parentLinkId", - createdDate: new Date(123456789000), - trashedDate: undefined, + creationTime: new Date(123456789000), + trashTime: undefined, shareId: undefined, isShared: false, @@ -218,7 +218,7 @@ describe("nodeAPIService", () => { TrashTime: 123456, }), generateFileNode({ - trashedDate: new Date(123456000) + trashTime: new Date(123456000) }), ); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 838fbde9..b18f6e77 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -364,8 +364,8 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin uid: makeNodeUid(volumeId, link.Link.LinkID), parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, type: nodeTypeNumberToNodeType(logger, link.Link.Type), - createdDate: new Date(link.Link.CreateTime*1000), - trashedDate: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, + creationTime: new Date(link.Link.CreateTime*1000), + trashTime: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, // Sharing node metadata shareId: link.Sharing?.ShareID || undefined, @@ -396,7 +396,7 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin if (link.Link.Type === 2 && link.File && link.File.ActiveRevision) { return { ...baseNodeMetadata, - mimeType: link.File.MediaType || undefined, + mediaType: link.File.MediaType || undefined, encryptedCrypto: { ...baseCryptoNodeMetadata, file: { @@ -406,7 +406,7 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin activeRevision: { uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.File.ActiveRevision.RevisionID), state: RevisionState.Active, - createdDate: new Date(link.File.ActiveRevision.CreateTime*1000), + creationTime: new Date(link.File.ActiveRevision.CreateTime*1000), signatureEmail: link.File.ActiveRevision.SignatureEmail || undefined, armoredExtendedAttributes: link.File.ActiveRevision.XAttr || undefined, thumbnails: link.File.ActiveRevision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, link.Link.LinkID, thumbnail)) || [], @@ -426,7 +426,7 @@ function transformRevisionResponse( return { uid: makeNodeRevisionUid(volumeId, nodeId, revision.ID), state: revision.State === APIRevisionState.Active ? RevisionState.Active : RevisionState.Superseded, - createdDate: new Date(revision.CreateTime*1000), + creationTime: new Date(revision.CreateTime*1000), signatureEmail: revision.SignatureEmail || undefined, armoredExtendedAttributes: revision.XAttr || undefined, thumbnails: revision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, nodeId, thumbnail)) || [], diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 2d7ee4c8..4c89999d 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -10,10 +10,10 @@ function generateNode(uid: string, parentUid='root', params: Partial { for await (const result of this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`)) { const node = await this.convertCacheResult(result); - if (node && (!node.ok || !node.node.trashedDate)) { + if (node && (!node.ok || !node.node.trashTime)) { yield node; } } @@ -226,16 +226,16 @@ function deserialiseNode(nodeData: string): DecryptedNode { !node.uid || typeof node.uid !== 'string' || !node.directMemberRole || typeof node.directMemberRole !== 'string' || !node.type || typeof node.type !== 'string' || - (typeof node.mimeType !== 'string' && node.mimeType !== undefined) || + (typeof node.mediaType !== 'string' && node.mediaType !== undefined) || typeof node.isShared !== 'boolean' || - !node.createdDate || typeof node.createdDate !== 'string' || - (typeof node.trashedDate !== 'string' && node.trashedDate !== undefined) + !node.creationTime || typeof node.creationTime !== 'string' || + (typeof node.trashTime !== 'string' && node.trashTime !== undefined) ) { throw new Error(`Invalid node data: ${nodeData}`); } return { ...node, - createdDate: new Date(node.createdDate), - trashedDate: node.trashedDate ? new Date(node.trashedDate) : undefined, + creationTime: new Date(node.creationTime), + trashTime: node.trashTime ? new Date(node.trashTime) : undefined, }; } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 6fee5963..ded47c0c 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -348,7 +348,7 @@ describe("nodesCryptoService", () => { activeRevision: { ok: true, value: { uid: "revisionUid", state: RevisionState.Active, - createdDate: undefined, + creationTime: undefined, extendedAttributes: "{}", contentAuthor: { ok: true, value: "revisionSignatureEmail" }, } }, @@ -397,7 +397,7 @@ describe("nodesCryptoService", () => { uid: "revisionUid", state: RevisionState.Active, // @ts-expect-error Ignore mocked data. - createdDate: undefined, + creationTime: undefined, extendedAttributes: "{}", contentAuthor: { ok: true, value: "signatureEmail" }, } }, @@ -465,7 +465,7 @@ describe("nodesCryptoService", () => { extendedAttributes: "{}", state: RevisionState.Active, // @ts-expect-error Ignore mocked data. - createdDate: undefined, + creationTime: undefined, contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Signature verification for attributes failed" } }, } }, }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 8dff593c..e2b6fb27 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -296,7 +296,7 @@ export class NodesCryptoService { extendedAttributes, author: contentAuthor, } = await this.decryptExtendedAttributes( - {uid: nodeUid, createdDate: encryptedRevision.createdDate}, + {uid: nodeUid, creationTime: encryptedRevision.creationTime}, encryptedRevision.armoredExtendedAttributes, nodeKey, verificationKeys, @@ -306,7 +306,7 @@ export class NodesCryptoService { return { uid: encryptedRevision.uid, state: encryptedRevision.state, - createdDate: encryptedRevision.createdDate, + creationTime: encryptedRevision.creationTime, contentAuthor, extendedAttributes, thumbnails: encryptedRevision.thumbnails, @@ -314,7 +314,7 @@ export class NodesCryptoService { } private async decryptExtendedAttributes( - node: {uid: string, createdDate: Date}, + node: {uid: string, creationTime: Date}, encryptedExtendedAttributes: string | undefined, nodeKey: PrivateKey, addressKeys: PublicKey[], @@ -442,7 +442,7 @@ export class NodesCryptoService { } private async handleClaimedAuthor( - node: { uid: string, createdDate: Date }, + node: { uid: string, creationTime: Date }, field: MetricVerificationErrorField, signatureType: string, verified: VERIFICATION_STATUS, @@ -456,7 +456,7 @@ export class NodesCryptoService { } private async reportVerificationError( - node: { uid: string, createdDate: Date }, + node: { uid: string, creationTime: Date }, field: MetricVerificationErrorField, claimedAuthor?: string, ) { @@ -464,7 +464,7 @@ export class NodesCryptoService { return; } - const fromBefore2024 = node.createdDate < new Date('2024-01-01'); + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); let addressMatchingDefaultShare, context; try { @@ -493,7 +493,7 @@ export class NodesCryptoService { return; } - const fromBefore2024 = node.createdDate < new Date('2024-01-01'); + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); let context; try { diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index 49cd3f57..22f461c8 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -216,7 +216,7 @@ describe("notifyListenersByEvent", () => { }); it("should notify listeners by isTrashed from cache", async () => { - cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeUid', trashedDate: new Date() } as DecryptedNode)); + cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeUid', trashTime: new Date() } as DecryptedNode)); const event: DriveEvent = { type: DriveEventType.NodeDeleted, nodeUid: "nodeUid", diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index d6fef387..41a9e603 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -186,7 +186,7 @@ export async function notifyListenersByEvent(logger: Logger, event: DriveEvent, const subscribedListeners = listeners.filter(({ condition }) => condition({ isShared: node?.isShared || false, - isTrashed: !!node?.trashedDate || false, + isTrashed: !!node?.trashTime || false, ...event, })); if (subscribedListeners.length) { diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index c4ddb50d..edeb24c8 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -18,10 +18,10 @@ function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial< parentUid, directMemberRole: MemberRole.Admin, type: NodeType.File, - mimeType: "text", + mediaType: "text", isShared: false, - createdDate: new Date(), - trashedDate: undefined, + creationTime: new Date(), + trashTime: undefined, isStale: false, ...params, } as DecryptedNode; diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 5ea10d82..e295253c 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -14,9 +14,9 @@ interface BaseNode { uid: string; parentUid?: string; type: NodeType; - mimeType?: string; - createdDate: Date; // created on the server - trashedDate?: Date; + mediaType?: string; + creationTime: Date; // created on the server + trashTime?: Date; // Share node metadata shareId?: string; @@ -108,7 +108,7 @@ export interface DecryptedNodeKeys { interface BaseRevision { uid: string; state: RevisionState; - createdDate: Date; // created on the server + creationTime: Date; // created on the server thumbnails: Thumbnail[]; } diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 8fbaeab5..ddaa56a8 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -233,7 +233,7 @@ export class NodesAccess { activeRevision: !unparsedNode.activeRevision?.ok ? unparsedNode.activeRevision : resultOk({ uid: unparsedNode.activeRevision.value.uid, state: unparsedNode.activeRevision.value.state, - createdDate: unparsedNode.activeRevision.value.createdDate, + creationTime: unparsedNode.activeRevision.value.creationTime, contentAuthor: unparsedNode.activeRevision.value.contentAuthor, thumbnails: unparsedNode.activeRevision.value.thumbnails, ...extendedAttributes, diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index f14a353f..4a3d9bca 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -25,7 +25,7 @@ describe('NodesManagement', () => { keyAuthor: { ok: true, value: 'keyAauthor' }, nameAuthor: { ok: true, value: 'nameAuthor' }, hash: 'hash', - mimeType: 'mimeType', + mediaType: 'mediaType', } as DecryptedNode, anonymousNodeUid: { uid: 'anonymousNodeUid', @@ -34,7 +34,7 @@ describe('NodesManagement', () => { keyAuthor: { ok: true, value: null }, nameAuthor: { ok: true, value: 'nameAuthor' }, hash: 'hash', - mimeType: 'mimeType', + mediaType: 'mediaType', } as DecryptedNode, parentUid: { uid: 'parentUid', diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index c7a54892..447ed745 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -171,7 +171,7 @@ export class NodesManagement { if (node) { await this.cache.setNode({ ...node, - trashedDate: new Date(), + trashTime: new Date(), }); } } @@ -190,7 +190,7 @@ export class NodesManagement { if (node) { await this.cache.setNode({ ...node, - trashedDate: undefined, + trashTime: undefined, }); } } @@ -249,8 +249,8 @@ export class NodesManagement { uid: nodeUid, parentUid: parentNodeUid, type: NodeType.Folder, - mimeType: "Folder", - createdDate: new Date(), + mediaType: "Folder", + creationTime: new Date(), // Share node metadata isShared: false, diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index 8db5f05a..a48a118f 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -149,7 +149,7 @@ function convertSharePayload(response: GetShareResponse): EncryptedShare { shareId: response.ShareID, rootNodeId: response.LinkID, creatorEmail: response.Creator, - createdDate: response.CreateTime ? new Date(response.CreateTime*1000) : undefined, + creationTime: response.CreateTime ? new Date(response.CreateTime*1000) : undefined, encryptedCrypto: { armoredKey: response.Key, armoredPassphrase: response.Passphrase, diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 69866095..f41e9a91 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -97,7 +97,7 @@ export class SharesCryptoService { return; } - const fromBefore2024 = share.createdDate ? share.createdDate < new Date('2024-01-01') : undefined; + const fromBefore2024 = share.creationTime ? share.creationTime < new Date('2024-01-01') : undefined; this.logger.error(`Failed to decrypt share ${share.shareId} (from before 2024: ${fromBefore2024})`, error); this.telemetry.logEvent({ @@ -115,7 +115,7 @@ export class SharesCryptoService { return; } - const fromBefore2024 = share.createdDate ? share.createdDate < new Date('2024-01-01') : undefined; + const fromBefore2024 = share.creationTime ? share.creationTime < new Date('2024-01-01') : undefined; const addressMatchingDefaultShare = undefined; // TODO: check if claimed author matches default share this.logger.error(`Failed to verify share ${share.shareId} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index 1975cb4c..1a2b8cc5 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -40,7 +40,7 @@ type BaseShare = { * might not have this field set. */ addressId?: string; - createdDate?: Date; + creationTime?: Date; type: ShareType; } & VolumeShareNodeIDs; diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index dabaa1c5..9d89c014 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -108,7 +108,7 @@ export class SharingAPIService { inviteeEmail: response.Invitation.InviteeEmail, base64KeyPacket: response.Invitation.KeyPacket, base64KeyPacketSignature: response.Invitation.KeyPacketSignature, - invitedDate: new Date(response.Invitation.CreateTime*1000), + invitationTime: new Date(response.Invitation.CreateTime*1000), role: permissionsToDirectMemberRole(this.logger, response.Invitation.Permissions), share: { armoredKey: response.Share.ShareKey, @@ -117,7 +117,7 @@ export class SharingAPIService { }, node: { type: nodeTypeNumberToNodeType(this.logger, response.Link.Type), - mimeType: response.Link.MIMEType || undefined, + mediaType: response.Link.MIMEType || undefined, encryptedName: response.Link.Name, }, } @@ -143,7 +143,7 @@ export class SharingAPIService { for (const bookmark of response.Bookmarks) { yield { tokenId: bookmark.Token.Token, - createdDate: new Date(bookmark.CreateTime*1000), + creationTime: new Date(bookmark.CreateTime*1000), share: { armoredKey: bookmark.Token.ShareKey, armoredPassphrase: bookmark.Token.SharePassphrase, @@ -154,7 +154,7 @@ export class SharingAPIService { }, node: { type: bookmark.Token.LinkType === 1 ? NodeType.Folder : NodeType.File, - mimeType: bookmark.Token.MIMEType, + mediaType: bookmark.Token.MIMEType, encryptedName: bookmark.Token.Name, armoredKey: bookmark.Token.NodeKey, armoredNodePassphrase: bookmark.Token.NodePassphrase, @@ -193,7 +193,7 @@ export class SharingAPIService { inviteeEmail: member.Email, base64KeyPacket: member.KeyPacket, base64KeyPacketSignature: member.KeyPacketSignature, - invitedDate: new Date(member.CreateTime*1000), + invitationTime: new Date(member.CreateTime*1000), role: permissionsToDirectMemberRole(this.logger, member.Permissions), } }); @@ -356,8 +356,8 @@ export class SharingAPIService { return { uid: makePublicLinkUid(shareUrl.ShareID, shareUrl.ShareURLID), - createDate: new Date(shareUrl.CreateTime*1000), - expireDate: shareUrl.ExpirationTime ? new Date(shareUrl.ExpirationTime*1000) : undefined, + creationTime: new Date(shareUrl.CreateTime*1000), + expirationTime: shareUrl.ExpirationTime ? new Date(shareUrl.ExpirationTime*1000) : undefined, role: permissionsToDirectMemberRole(this.logger, shareUrl.Permissions), flags: shareUrl.Flags, creatorEmail: shareUrl.CreatorEmail, @@ -379,7 +379,7 @@ export class SharingAPIService { uid: makeInvitationUid(shareId, invitation.InvitationID), addedByEmail: invitation.InviterEmail, inviteeEmail: invitation.InviteeEmail, - invitedDate: new Date(invitation.CreateTime*1000), + invitationTime: new Date(invitation.CreateTime*1000), role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), base64KeyPacket: invitation.KeyPacket, base64KeyPacketSignature: invitation.KeyPacketSignature, @@ -392,7 +392,7 @@ export class SharingAPIService { uid: makeInvitationUid(shareId, invitation.ExternalInvitationID), addedByEmail: invitation.InviterEmail, inviteeEmail: invitation.InviteeEmail, - invitedDate: new Date(invitation.CreateTime*1000), + invitationTime: new Date(invitation.CreateTime*1000), role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), base64Signature: invitation.ExternalInvitationSignature, state, diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index eb63b94a..9fe21c81 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -182,7 +182,7 @@ export class SharingCryptoService { node: { name: nodeName, type: encryptedInvitation.node.type, - mimeType: encryptedInvitation.node.mimeType, + mediaType: encryptedInvitation.node.mediaType, }, } } @@ -196,7 +196,7 @@ export class SharingCryptoService { return { uid: encryptedInvitation.uid, - invitedDate: encryptedInvitation.invitedDate, + invitationTime: encryptedInvitation.invitationTime, addedByEmail: addedByEmail, inviteeEmail: encryptedInvitation.inviteeEmail, role: encryptedInvitation.role, @@ -248,7 +248,7 @@ export class SharingCryptoService { return { uid: encryptedInvitation.uid, - invitedDate: encryptedInvitation.invitedDate, + invitationTime: encryptedInvitation.invitationTime, addedByEmail: addedByEmail, inviteeEmail: encryptedInvitation.inviteeEmail, role: encryptedInvitation.role, @@ -265,7 +265,7 @@ export class SharingCryptoService { return { uid: encryptedMember.uid, - invitedDate: encryptedMember.invitedDate, + invitationTime: encryptedMember.invitationTime, addedByEmail: addedByEmail, inviteeEmail: encryptedMember.inviteeEmail, role: encryptedMember.role, @@ -317,8 +317,8 @@ export class SharingCryptoService { return { uid: encryptedPublicLink.uid, - createDate: encryptedPublicLink.createDate, - expireDate: encryptedPublicLink.expireDate, + creationTime: encryptedPublicLink.creationTime, + expirationTime: encryptedPublicLink.expirationTime, role: encryptedPublicLink.role, url: `${encryptedPublicLink.publicUrl}#${password}`, customPassword, diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 94c5986a..a7d80a5a 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -27,7 +27,7 @@ export interface EncryptedInvitationRequest { */ export interface EncryptedInvitation extends EncryptedInvitationRequest { uid: string; - invitedDate: Date; + invitationTime: Date; } /** @@ -44,7 +44,7 @@ export interface EncryptedInvitationWithNode extends EncryptedInvitation { }; node: { type: NodeType; - mimeType?: string; + mediaType?: string; encryptedName: string; } } @@ -64,7 +64,7 @@ export interface EncryptedExternalInvitationRequest { */ export interface EncryptedExternalInvitation extends Omit { uid: string; - invitedDate: Date; + invitationTime: Date; addedByEmail: string; state: NonProtonInvitationState; } @@ -74,7 +74,7 @@ export interface EncryptedExternalInvitation extends Omit { addedByEmail: resultOk("added-email"), inviteeEmail: "internal-email", role: MemberRole.Viewer, - invitedDate: new Date(), + invitationTime: new Date(), }; externalInvitation = { uid: "external-invitation", addedByEmail: resultOk("added-email"), inviteeEmail: "external-email", role: MemberRole.Viewer, - invitedDate: new Date(), + invitationTime: new Date(), state: NonProtonInvitationState.Pending, }; member = { @@ -178,7 +178,7 @@ describe("SharingManagement", () => { addedByEmail: resultOk("added-email"), inviteeEmail: "member-email", role: MemberRole.Viewer, - invitedDate: new Date(), + invitationTime: new Date(), }; apiService.getShareInvitations = jest.fn().mockResolvedValue([ @@ -454,14 +454,14 @@ describe("SharingManagement", () => { addedByEmail: resultOk("added-email"), inviteeEmail: "internal-email", role: MemberRole.Viewer, - invitedDate: new Date(), + invitationTime: new Date(), }; externalInvitation = { uid: "external-invitation", addedByEmail: resultOk("added-email"), inviteeEmail: "external-email", role: MemberRole.Viewer, - invitedDate: new Date(), + invitationTime: new Date(), state: NonProtonInvitationState.Pending, }; member = { @@ -469,11 +469,11 @@ describe("SharingManagement", () => { addedByEmail: resultOk("added-email"), inviteeEmail: "member-email", role: MemberRole.Viewer, - invitedDate: new Date(), + invitationTime: new Date(), }; publicLink = { uid: "publicLink", - createDate: new Date(), + creationTime: new Date(), role: MemberRole.Viewer, url: "url", } diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index aa552202..fda9e9df 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -419,7 +419,7 @@ export class SharingManagement { return { uid: encryptedInvitation.uid, - invitedDate: encryptedInvitation.invitedDate, + invitationTime: encryptedInvitation.invitationTime, addedByEmail: resultOk(inviter.email), inviteeEmail, role, diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index ae09210c..8ac8d719 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -63,7 +63,7 @@ export class UploadAPIService { async createDraft(parentNodeUid: string, node: { armoredEncryptedName: string, hash: string, - mimeType: string, + mediaType: string, clientUID?: string, intendedUploadSize?: number, armoredNodeKey: string, @@ -84,7 +84,7 @@ export class UploadAPIService { ParentLinkID: parentNodeId, Name: node.armoredEncryptedName, Hash: node.hash, - MIMEType: node.mimeType, + MIMEType: node.mediaType, ClientUID: node.clientUID || null, IntendedUploadSize: node.intendedUploadSize || null, NodeKey: node.armoredNodeKey, diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index 78f84fda..1cca9038 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -439,7 +439,7 @@ describe('FileUploader', () => { { // Fake expected size to break verification expectedSize: 1 * 1024 * 1024 + 1024, - mimeType: '', + mediaType: '', }, onFinish, ); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index a9d58e07..2d41c240 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -104,8 +104,8 @@ export class Fileuploader { if (this.controller.promise) { throw new Error(`Upload already started`); } - if (!this.metadata.mimeType) { - this.metadata.mimeType = fileObject.type; + if (!this.metadata.mediaType) { + this.metadata.mediaType = fileObject.type; } if (!this.metadata.expectedSize) { this.metadata.expectedSize = fileObject.size; diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index d3d7265c..62be6c56 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -95,7 +95,7 @@ describe("UploadManager", () => { it("should create draft node", async () => { const result = await manager.createDraftNode("parentUid", "name", { - mimeType: "myMimeType", + mediaType: "myMimeType", expectedSize: 123456, } as UploadMetadata); @@ -113,7 +113,7 @@ describe("UploadManager", () => { expect(apiService.createDraft).toHaveBeenCalledWith("parentUid", { armoredEncryptedName: "newNode:encryptedName", hash: "newNode:hash", - mimeType: "myMimeType", + mediaType: "myMimeType", intendedUploadSize: 123456, armoredNodeKey: "newNode:armoredKey", armoredNodePassphrase: "newNode:armoredPassphrase", diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index fd022373..55ab1f18 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -75,7 +75,7 @@ export class UploadManager { const result = await this.apiService.createDraft(parentFolderUid, { armoredEncryptedName: generatedNodeCrypto.encryptedNode.encryptedName, hash: generatedNodeCrypto.encryptedNode.hash, - mimeType: metadata.mimeType, + mediaType: metadata.mediaType, intendedUploadSize: metadata.expectedSize, armoredNodeKey: generatedNodeCrypto.nodeKeys.encrypted.armoredKey, armoredNodePassphrase: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphrase, diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index b06ce300..a28d5cad 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -10,10 +10,10 @@ type InternalPartialNode = Pick< 'nameAuthor' | 'directMemberRole' | 'type' | - 'mimeType' | + 'mediaType' | 'isShared' | - 'createdDate' | - 'trashedDate' | + 'creationTime' | + 'trashTime' | 'activeRevision' | 'folder' | 'errors' @@ -69,10 +69,10 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode nameAuthor: node.nameAuthor, directMemberRole: node.directMemberRole, type: node.type, - mimeType: node.mimeType, + mediaType: node.mediaType, isShared: node.isShared, - createdDate: node.createdDate, - trashedDate: node.trashedDate, + creationTime: node.creationTime, + trashTime: node.trashTime, folder: node.folder, }; From 7fb28b3a8f761227e2c78f5ac6b56270b51b1d9d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 24 Apr 2025 05:24:33 +0000 Subject: [PATCH 078/791] Fail decryption if cannot load address key --- .../src/internal/nodes/cryptoService.test.ts | 14 +++++++ js/sdk/src/internal/nodes/nodesAccess.test.ts | 38 +++++++++++++++++++ js/sdk/src/internal/nodes/nodesAccess.ts | 16 ++++++-- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index ded47c0c..50b12d18 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -311,6 +311,13 @@ describe("nodesCryptoService", () => { }); }); }); + + it("should fail when keys cannot be loaded", async () => { + account.getPublicKeys = jest.fn().mockRejectedValue(new Error("Failed to load keys")); + + const result = cryptoService.decryptNode(encryptedNode, parentKey); + await expect(result).rejects.toThrow("Failed to load keys"); + }); }); describe("file node", () => { @@ -554,5 +561,12 @@ describe("nodesCryptoService", () => { }); }); }); + + it("should fail when keys cannot be loaded", async () => { + account.getPublicKeys = jest.fn().mockRejectedValue(new Error("Failed to load keys")); + + const result = cryptoService.decryptNode(encryptedNode, parentKey); + await expect(result).rejects.toThrow("Failed to load keys"); + }); }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 45ebd234..a1a90467 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -245,6 +245,44 @@ describe('nodesAccess', () => { expect(result).toMatchObject([node2, node3]); expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); }); + + it('should yield all decryptable children before throwing error', async () => { + apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { + yield 'node1'; + yield 'node2'; + yield 'node3'; + }); + cache.getNode = jest.fn().mockImplementation((uid: string) => { + if (uid === parentNode.uid) { + return parentNode; + } + throw new Error('Entity not found'); + }); + cryptoService.decryptNode = jest.fn().mockImplementation((encryptedNode: EncryptedNode) => { + if (encryptedNode.uid === 'node2') { + throw new DecryptionError('Decryption failed'); + } + return Promise.resolve({ + node: { uid: encryptedNode.uid, isStale: false, name: { ok: true, value: 'name' } } as DecryptedNode, + keys: { key: 'key' } as any as DecryptedNodeKeys, + }); + }); + + const generator = access.iterateChildren('parentUid'); + const node1 = await generator.next(); + expect(node1.value).toMatchObject({ uid: 'node1' }); + const node2 = await generator.next(); + expect(node2.value).toMatchObject({ uid: 'node3' }); + const node3 = generator.next(); + await expect(node3).rejects.toThrow('Failed to decrypt some nodes'); + try { + await node3; + } catch (error: any) { + expect(error.cause).toEqual([ + new DecryptionError('Decryption failed'), + ]); + } + }) }); describe('iterateTrashedNodes', () => { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index ddaa56a8..fe518976 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -2,7 +2,7 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey } from "../../crypto"; import { Logger, MissingNode, NodeType, resultError, resultOk } from "../../interface"; -import { DecryptionError } from "../../errors"; +import { DecryptionError, ProtonDriveError } from "../../errors"; import { getErrorMessage } from '../errors'; import { BatchLoading } from "../batchLoading"; import { makeNodeUid } from "../uids"; @@ -144,11 +144,21 @@ export class NodesAccess { private async* loadNodesWithMissingReport(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const returnedNodeUids: string[] = []; + const errors = []; for await (const encryptedNode of this.apiService.iterateNodes(nodeUids, signal)) { returnedNodeUids.push(encryptedNode.uid); - const { node } = await this.decryptNode(encryptedNode); - yield node; + try { + const { node } = await this.decryptNode(encryptedNode); + yield node; + } catch (error: unknown) { + errors.push(error); + } + } + + if (errors.length > 0) { + this.logger.error(`Failed to decrypt ${errors.length} nodes`, errors); + throw new ProtonDriveError(c('Error').t`Failed to decrypt some nodes`, { cause: errors }); } const missingNodeUids = nodeUids.filter((nodeUid) => !returnedNodeUids.includes(nodeUid)); From a1fcb9723c28cfeaa428892a40bcb6652150a445 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 24 Apr 2025 08:05:52 +0200 Subject: [PATCH 079/791] fix test after rebase --- js/sdk/src/internal/nodes/nodesAccess.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index a1a90467..a597bd54 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -268,7 +268,7 @@ describe('nodesAccess', () => { }); }); - const generator = access.iterateChildren('parentUid'); + const generator = access.iterateFolderChildren('parentUid'); const node1 = await generator.next(); expect(node1.value).toMatchObject({ uid: 'node1' }); const node2 = await generator.next(); From 4c47985467d6fbb22c88fb71b77417416871fe45 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 25 Apr 2025 10:23:36 +0000 Subject: [PATCH 080/791] Implement file upload --- cs/Directory.Build.props | 6 +- .../Proton.Drive.Sdk/Api/DriveApiClients.cs | 6 +- .../Api/{Links => Files}/ActiveRevisionDto.cs | 2 +- .../src/Proton.Drive.Sdk/Api/Files/Block.cs | 18 ++ .../Api/Files/BlockCreationParameters.cs | 19 ++ .../Api/Files/BlockRequestResponse.cs | 13 + .../Api/Files/BlockUploadRequestParameters.cs | 28 ++ .../Api/Files/BlockUploadTarget.cs | 11 + .../Api/Files/BlockUploadUrl.cs | 11 + .../Files/BlockVerificationInputResponse.cs | 8 + .../Api/Files/BlockVerificationOutput.cs | 6 + .../Api/Files/FileCreationIdentifiers.cs | 14 + .../Api/Files/FileCreationParameters.cs | 20 ++ .../Api/Files/FileCreationResponse.cs | 10 + .../Api/{Links => Files}/FileDto.cs | 2 +- .../Api/Files/FilesApiClient.cs | 56 ++++ .../Api/Files/IFilesApiClient.cs | 20 ++ .../Files/IRevisionVerificationApiClient.cs | 14 + .../Api/Files/RevisionConflict.cs | 20 ++ .../Api/Files/RevisionConflictResponse.cs | 10 + .../Api/{Links => Files}/RevisionDto.cs | 2 +- .../Api/Files/RevisionUpdateParameters.cs | 15 ++ .../Proton.Drive.Sdk/Api/Files/Thumbnail.cs | 16 ++ .../Api/Files/ThumbnailBlock.cs | 14 + .../Api/Files/ThumbnailBlockUploadTarget.cs | 9 + .../Api/Files/ThumbnailCreationParameters.cs | 13 + .../Api/{Links => Files}/ThumbnailDto.cs | 2 +- .../Api/{Links => Files}/ThumbnailType.cs | 2 +- .../Api/{Links => Folders}/FolderDto.cs | 2 +- .../Proton.Drive.Sdk/Api/IDriveApiClients.cs | 6 +- .../Api/Links/FileProperties.cs | 12 - .../Api/Links/FolderProperties.cs | 10 - .../Api/Links/LinkDetailsDto.cs | 5 +- .../Api/Links/NodeCreationParameters.cs | 2 +- .../Api/Storage/IStorageApiClient.cs | 9 + .../Api/Storage/StorageApiClient.cs | 47 ++++ .../Caching/DriveEntityCache.cs | 11 + .../Caching/IDriveEntityCache.cs | 3 + .../Proton.Drive.Sdk/FifoFlexibleSemaphore.cs | 89 +++++++ .../src/Proton.Drive.Sdk/IntegerExtensions.cs | 9 + .../Nodes/DegradedFileNode.cs | 4 +- .../Nodes/DegradedFolderNode.cs | 4 +- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 106 ++++++++ .../src/Proton.Drive.Sdk/Nodes/FileSample.cs | 7 + .../Nodes/FileSamplePurpose.cs | 7 + .../Nodes/FolderOperations.cs | 22 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 4 +- ...NodeAndSecretsProvisionResultExtensions.cs | 18 +- .../src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs | 10 +- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 37 ++- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs | 31 +-- cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 2 +- .../Nodes/RevisionOperations.cs | 32 +++ .../src/Proton.Drive.Sdk/Nodes/RevisionUid.cs | 41 +++ .../Nodes/Upload/BlockUploader.cs | 241 ++++++++++++++++++ .../Nodes/Upload/FileUploader.cs | 107 ++++++++ .../Nodes/Upload/RevisionWriter.cs | 240 +++++++++++++++++ .../Nodes/Upload/RevisionWriterExtensions.cs | 52 ++++ .../Nodes/Upload/UploadController.cs | 18 ++ .../Upload/Verification/BitwiseOperations.cs | 36 +++ .../Upload/Verification/BlockVerifier.cs | 66 +++++ .../NodeKeyAndSessionKeyMismatchException.cs | 23 ++ ...essionKeyAndDataPacketMismatchException.cs | 18 ++ .../Upload/Verification/VerificationToken.cs | 34 +++ .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 61 ++++- .../RecyclableMemoryStreamExtensions.cs | 16 ++ .../DriveApiSerializerContext.cs | 8 + .../Serialization/ICompositeUid.cs | 31 +++ .../Serialization/NodeUidConverter.cs | 18 -- .../Serialization/UidJsonConverter.cs | 18 ++ .../src/Proton.Drive.Sdk/StreamExtensions.cs | 48 ++++ .../Volumes/VolumeOperations.cs | 4 +- .../src/Proton.Sdk/Http/HttpApiCallBuilder.cs | 8 +- 73 files changed, 1810 insertions(+), 134 deletions(-) rename cs/sdk/src/Proton.Drive.Sdk/Api/{Links => Files}/ActiveRevisionDto.cs (95%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationParameters.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockRequestResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationParameters.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs rename cs/sdk/src/Proton.Drive.Sdk/Api/{Links => Files}/FileDto.cs (92%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflictResponse.cs rename cs/sdk/src/Proton.Drive.Sdk/Api/{Links => Files}/RevisionDto.cs (95%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateParameters.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/Thumbnail.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationParameters.cs rename cs/sdk/src/Proton.Drive.Sdk/Api/{Links => Files}/ThumbnailDto.cs (91%) rename cs/sdk/src/Proton.Drive.Sdk/Api/{Links => Files}/ThumbnailType.cs (62%) rename cs/sdk/src/Proton.Drive.Sdk/Api/{Links => Folders}/FolderDto.cs (88%) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileProperties.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderProperties.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/IntegerExtensions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSample.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSamplePurpose.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BitwiseOperations.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/ICompositeUid.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/NodeUidConverter.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/UidJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/StreamExtensions.cs diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index efa1dc8e..b2ea5f4f 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -20,7 +20,7 @@ true - false + false lib @@ -42,7 +42,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -52,7 +52,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs index ad9512d4..8ea53ebb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -1,6 +1,8 @@ -using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Storage; using Proton.Drive.Sdk.Api.Volumes; namespace Proton.Drive.Sdk.Api; @@ -11,4 +13,6 @@ internal sealed class DriveApiClients(HttpClient httpClient) : IDriveApiClients public ISharesApiClient Shares { get; } = new SharesApiClient(httpClient); public ILinksApiClient Links { get; } = new LinksApiClient(httpClient); public IFoldersApiClient Folders { get; } = new FoldersApiClient(httpClient); + public IFilesApiClient Files { get; } = new FilesApiClient(httpClient); + public IStorageApiClient Storage { get; } = new StorageApiClient(httpClient); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ActiveRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs similarity index 95% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Links/ActiveRevisionDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs index a05d6091..f194028f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ActiveRevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs @@ -3,7 +3,7 @@ using Proton.Sdk.Cryptography; using Proton.Sdk.Serialization; -namespace Proton.Drive.Sdk.Api.Links; +namespace Proton.Drive.Sdk.Api.Files; internal sealed class ActiveRevisionDto { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs new file mode 100644 index 00000000..b967a57c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class Block +{ + public required int Index { get; init; } + + [JsonPropertyName("URL")] + public required string Url { get; init; } + + [JsonPropertyName("EncSignature")] + public PgpArmoredMessage? EncryptedSignature { get; init; } + + [JsonPropertyName("SignatureEmail")] + public string? SignatureEmailAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationParameters.cs new file mode 100644 index 00000000..abd2bc09 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationParameters.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockCreationParameters +{ + public required int Index { get; init; } + public required int Size { get; init; } + + [JsonPropertyName("EncSignature")] + public required PgpArmoredMessage EncryptedSignature { get; init; } + + [JsonPropertyName("Hash")] + public required ReadOnlyMemory HashDigest { get; init; } + + [JsonPropertyName("Verifier")] + public required BlockVerificationOutput VerificationOutput { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockRequestResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockRequestResponse.cs new file mode 100644 index 00000000..19f65639 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockRequestResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockRequestResponse : ApiResponse +{ + [JsonPropertyName("UploadLinks")] + public required IReadOnlyList UploadTargets { get; set; } + + [JsonPropertyName("ThumbnailLinks")] + public required IReadOnlyList ThumbnailUploadTargets { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs new file mode 100644 index 00000000..7b89136f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockUploadRequestParameters +{ + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } + + [JsonPropertyName("VolumeID")] + public required VolumeId VolumeId { get; init; } + + [JsonPropertyName("LinkID")] + public required LinkId LinkId { get; init; } + + [JsonPropertyName("RevisionID")] + public required RevisionId RevisionId { get; init; } + + [JsonPropertyName("BlockList")] + public required IReadOnlyList Blocks { get; init; } + + [JsonPropertyName("ThumbnailList")] + public required IReadOnlyList Thumbnails { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs new file mode 100644 index 00000000..1cefe921 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal class BlockUploadTarget +{ + [JsonPropertyName("BareURL")] + public required string BareUrl { get; set; } + + public required string Token { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs new file mode 100644 index 00000000..1a1a3b7f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockUploadUrl +{ + public required string Token { get; init; } + + [JsonPropertyName("URL")] + public required string Value { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs new file mode 100644 index 00000000..a9532c68 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed record BlockVerificationInputResponse +{ + public required ReadOnlyMemory VerificationCode { get; init; } + + public required ReadOnlyMemory ContentKeyPacket { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs new file mode 100644 index 00000000..929e5037 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Api.Files; + +public readonly struct BlockVerificationOutput +{ + public required ReadOnlyMemory Token { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs new file mode 100644 index 00000000..0c3e480d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class FileCreationIdentifiers +{ + [JsonPropertyName("ID")] + public required LinkId LinkId { get; init; } + + [JsonPropertyName("RevisionID")] + public required RevisionId RevisionId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationParameters.cs new file mode 100644 index 00000000..51f6595a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationParameters.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class FileCreationParameters : NodeCreationParameters +{ + [JsonPropertyName("MIMEType")] + public required string MediaType { get; init; } + + public required ReadOnlyMemory ContentKeyPacket { get; init; } + + public required PgpArmoredSignature ContentKeyPacketSignature { get; init; } + + [JsonPropertyName("ClientUID")] + public string? ClientUid { get; init; } + + public long? IntendedUploadSize { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs new file mode 100644 index 00000000..f2349b45 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class FileCreationResponse : ApiResponse +{ + [JsonPropertyName("File")] + public required FileCreationIdentifiers Identifiers { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs similarity index 92% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs index 497b4de1..34e6804b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Proton.Sdk.Cryptography; -namespace Proton.Drive.Sdk.Api.Links; +namespace Proton.Drive.Sdk.Api.Files; internal sealed class FileDto { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs new file mode 100644 index 00000000..d718afcb --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -0,0 +1,56 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class FilesApiClient(HttpClient httpClient) : IFilesApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask CreateFileAsync(VolumeId volumeId, FileCreationParameters parameters, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.FileCreationResponse, DriveApiSerializerContext.Default.RevisionConflictResponse) + .PostAsync($"v2/volumes/{volumeId}/files", parameters, DriveApiSerializerContext.Default.FileCreationParameters, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask RequestBlockUploadAsync(BlockUploadRequestParameters parameters, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.BlockRequestResponse) + .PostAsync("blocks", parameters, DriveApiSerializerContext.Default.BlockUploadRequestParameters, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask UpdateRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + RevisionUpdateParameters parameters, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .PutAsync( + $"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}", + parameters, + DriveApiSerializerContext.Default.RevisionUpdateParameters, + cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetVerificationInputAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.BlockVerificationInputResponse) + .GetAsync($"v2/volumes/{volumeId}/links/{linkId}/revisions/{revisionId}/verification", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs new file mode 100644 index 00000000..6ebcc241 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -0,0 +1,20 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal interface IFilesApiClient : IRevisionVerificationApiClient +{ + ValueTask CreateFileAsync(VolumeId volumeId, FileCreationParameters parameters, CancellationToken cancellationToken); + + ValueTask RequestBlockUploadAsync(BlockUploadRequestParameters parameters, CancellationToken cancellationToken); + + ValueTask UpdateRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + RevisionUpdateParameters parameters, + CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs new file mode 100644 index 00000000..5101aba7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs @@ -0,0 +1,14 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Files; + +internal interface IRevisionVerificationApiClient +{ + public ValueTask GetVerificationInputAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs new file mode 100644 index 00000000..9ba225bc --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionConflict +{ + [JsonPropertyName("ConflictLinkID")] + public LinkId? LinkId { get; init; } + + [JsonPropertyName("ConflictRevisionID")] + public RevisionId? RevisionId { get; init; } + + [JsonPropertyName("ConflictDraftRevisionID")] + public RevisionId? DraftRevisionId { get; init; } + + [JsonPropertyName("ConflictDraftClientUID")] + public string? DraftClientUid { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflictResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflictResponse.cs new file mode 100644 index 00000000..e7e2d012 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflictResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionConflictResponse : ApiResponse +{ + [JsonPropertyName("Details")] + public required RevisionConflict Conflict { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs similarity index 95% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs index 0ce4f761..f9536412 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs @@ -3,7 +3,7 @@ using Proton.Sdk.Cryptography; using Proton.Sdk.Serialization; -namespace Proton.Drive.Sdk.Api.Links; +namespace Proton.Drive.Sdk.Api.Files; internal sealed class RevisionDto { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateParameters.cs new file mode 100644 index 00000000..ddb6e03e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateParameters.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionUpdateParameters +{ + public required PgpArmoredSignature ManifestSignature { get; init; } + + [JsonPropertyName("SignatureAddress")] + public required string SignatureEmailAddress { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Thumbnail.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Thumbnail.cs new file mode 100644 index 00000000..e53fe61e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Thumbnail.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class Thumbnail +{ + [JsonPropertyName("ThumbnailID")] + public required string Id { get; init; } + + public required int Type { get; init; } + + [JsonPropertyName("BaseURL")] + public required ReadOnlyMemory HashDigest { get; init; } + + public required int Size { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs new file mode 100644 index 00000000..fa04393d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailBlock +{ + [JsonPropertyName("ThumbnailID")] + public required string Id { get; init; } + + [JsonPropertyName("BareURL")] + public required string BareUrl { get; init; } + + public required string Token { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs new file mode 100644 index 00000000..324cb0a3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailBlockUploadTarget : BlockUploadTarget +{ + [JsonPropertyName("ThumbnailType")] + public required ThumbnailType Type { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationParameters.cs new file mode 100644 index 00000000..c76d6d2f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationParameters.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailCreationParameters +{ + public required int Size { get; init; } + + public required ThumbnailType Type { get; init; } + + [JsonPropertyName("Hash")] + public required ReadOnlyMemory HashDigest { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs similarity index 91% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs index 0f71afe2..56644c5e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Proton.Drive.Sdk.Api.Links; +namespace Proton.Drive.Sdk.Api.Files; internal sealed class ThumbnailDto { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs similarity index 62% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailType.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs index a9d4c7e3..6841ce5b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ThumbnailType.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs @@ -1,4 +1,4 @@ -namespace Proton.Drive.Sdk.Api.Links; +namespace Proton.Drive.Sdk.Api.Files; internal enum ThumbnailType { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs similarity index 88% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs index 5b15c125..c5b2f367 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Proton.Sdk.Cryptography; -namespace Proton.Drive.Sdk.Api.Links; +namespace Proton.Drive.Sdk.Api.Folders; internal sealed class FolderDto { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs index fd40580f..ea810c85 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs @@ -1,6 +1,8 @@ -using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Storage; using Proton.Drive.Sdk.Api.Volumes; namespace Proton.Drive.Sdk.Api; @@ -11,4 +13,6 @@ internal interface IDriveApiClients ISharesApiClient Shares { get; } ILinksApiClient Links { get; } IFoldersApiClient Folders { get; } + IFilesApiClient Files { get; } + IStorageApiClient Storage { get; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileProperties.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileProperties.cs deleted file mode 100644 index 5a0ea278..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FileProperties.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Proton.Sdk.Cryptography; - -namespace Proton.Drive.Sdk.Api.Links; - -internal readonly struct FileProperties -{ - public required ReadOnlyMemory ContentKeyPacket { get; init; } - - public PgpArmoredSignature? ContentKeyPacketSignature { get; init; } - - public required RevisionDto? ActiveRevision { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderProperties.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderProperties.cs deleted file mode 100644 index dec76c83..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/FolderProperties.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text.Json.Serialization; -using Proton.Sdk.Cryptography; - -namespace Proton.Drive.Sdk.Api.Links; - -internal readonly struct FolderProperties -{ - [JsonPropertyName("NodeHashKey")] - public required PgpArmoredMessage HashKey { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs index bf1ab4a4..433d8530 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs @@ -1,4 +1,7 @@ -namespace Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Folders; + +namespace Proton.Drive.Sdk.Api.Links; internal sealed class LinkDetailsDto { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs index 2bef1b16..a677c030 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs @@ -21,7 +21,7 @@ internal abstract class NodeCreationParameters [JsonPropertyName("NodePassphraseSignature")] public required PgpArmoredSignature PassphraseSignature { get; init; } - [JsonPropertyName("SignatureEmail")] + [JsonPropertyName("SignatureAddress")] public required string SignatureEmailAddress { get; init; } [JsonPropertyName("NodeKey")] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs new file mode 100644 index 00000000..9d316b95 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs @@ -0,0 +1,9 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Storage; + +internal interface IStorageApiClient +{ + ValueTask UploadBlobAsync(string baseUrl, string token, Stream stream, Action? onProgress, CancellationToken cancellationToken); + ValueTask GetBlobStreamAsync(string url, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs new file mode 100644 index 00000000..309c2929 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -0,0 +1,47 @@ +using System.Net.Http.Headers; +using System.Net.Mime; +using Proton.Sdk.Api; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Storage; + +internal sealed class StorageApiClient(HttpClient httpClient) : IStorageApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask UploadBlobAsync( + string baseUrl, + string token, + Stream stream, + Action? onProgress, + CancellationToken cancellationToken) + { + using var blobContent = new StreamContent(stream); + blobContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "Block", FileName = "blob" }; + blobContent.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet); + + using var multipartContent = new MultipartFormDataContent("-----------------------------" + Guid.NewGuid().ToString("N")); + multipartContent.Add(blobContent); + + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, baseUrl, multipartContent); + requestMessage.Headers.Add("pm-storage-token", token); + + var response = await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + + onProgress?.Invoke(stream.Position); + + return response; + } + + public async ValueTask GetBlobStreamAsync(string url, CancellationToken cancellationToken) + { + var blobResponse = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + await blobResponse.EnsureApiSuccessAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken).ConfigureAwait(false); + + return await blobResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 886f825c..741d53da 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -11,11 +11,22 @@ namespace Proton.Drive.Sdk.Caching; internal sealed class DriveEntityCache(ICacheRepository repository) : IDriveEntityCache { + private const string ClientUidKey = "client:id"; private const string MainVolumeIdCacheKey = "volume:main:id"; private const string MyFilesShareIdCacheKey = "share:my-files:id"; private readonly ICacheRepository _repository = repository; + public ValueTask SetClientUidAsync(string clientUid, CancellationToken cancellationToken) + { + return _repository.SetAsync(ClientUidKey, clientUid, cancellationToken); + } + + public ValueTask TryGetClientUidAsync(CancellationToken cancellationToken) + { + return _repository.TryGetAsync(ClientUidKey, cancellationToken); + } + public ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken) { return _repository.SetAsync(MainVolumeIdCacheKey, volumeId.ToString(), cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index ee1cf5f3..685cb9b0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -8,6 +8,9 @@ namespace Proton.Drive.Sdk.Caching; internal interface IDriveEntityCache { + ValueTask SetClientUidAsync(string clientUid, CancellationToken cancellationToken); + ValueTask TryGetClientUidAsync(CancellationToken cancellationToken); + ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); ValueTask TryGetMainVolumeIdAsync(CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs new file mode 100644 index 00000000..783afe62 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Logging; + +namespace Proton.Drive.Sdk; + +/// +/// Acts as a semaphore that acts in a first in / first out manner, can increment and decrement its count by more than 1, and can be entered as long as the count before the increment is less than the maximum. +/// +internal sealed class FifoFlexibleSemaphore +{ + private readonly ILogger _logger; + private readonly int _maximumCount; + private readonly Queue<(int Increment, TaskCompletionSource TaskCompletionSource)> _waitingQueue = new(); + + private int _currentCount; + + public FifoFlexibleSemaphore(int maximumCount, ILogger logger) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCount); + + _maximumCount = maximumCount; + _currentCount = 0; + _logger = logger; + } + + public ValueTask EnterAsync(int increment, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegative(increment); + + _logger.LogTrace($"FifoFlexibleSemaphore.EnterAsync called with {nameof(increment)} {{Increment}}", increment); + + TaskCompletionSource tcs; + lock (_waitingQueue) + { + if (_currentCount < _maximumCount) + { + _currentCount += increment; + return ValueTask.CompletedTask; + } + + tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _waitingQueue.Enqueue((increment, tcs)); + } + + var cancellationTokenRegistration = cancellationToken.Register(() => tcs.TrySetCanceled()); + + if (cancellationToken.IsCancellationRequested) + { + cancellationTokenRegistration.Dispose(); + return ValueTask.FromCanceled(cancellationToken); + } + + return WaitAsync(); + + async ValueTask WaitAsync() + { + await using (cancellationTokenRegistration.ConfigureAwait(false)) + { + await tcs.Task.ConfigureAwait(false); + } + } + } + + public void Release(int decrement) + { + ArgumentOutOfRangeException.ThrowIfNegative(decrement); + + _logger.LogTrace($"FifoFlexibleSemaphore.Release called with {nameof(decrement)} {{Decrement}}", decrement); + + lock (_waitingQueue) + { + _currentCount -= decrement; + + if (_currentCount < 0) + { + _currentCount = 0; + } + + while (_currentCount < _maximumCount && _waitingQueue.TryDequeue(out var queuedEntry)) + { + var (increment, taskCompletionSource) = queuedEntry; + + if (taskCompletionSource.TrySetResult()) + { + _currentCount += increment; + } + } + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/IntegerExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/IntegerExtensions.cs new file mode 100644 index 00000000..a6da5214 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/IntegerExtensions.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk; + +internal static class IntegerExtensions +{ + internal static long DivideAndRoundUp(this long dividend, long divisor) + { + return (dividend + divisor - 1) / divisor; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs index 23dfc9a6..2765c529 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs @@ -14,8 +14,8 @@ public FileNode ToNode(string substituteName, Revision substituteRevision) { return new FileNode { - Id = Id, - ParentId = ParentId, + Uid = Id, + ParentUid = ParentId, MediaType = MediaType, Name = Name.TryGetValue(out var name) ? name diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs index c87093f7..d8eff860 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs @@ -8,8 +8,8 @@ public FolderNode ToNode(string substituteName) { return new FolderNode { - Id = Id, - ParentId = ParentId, + Uid = Id, + ParentUid = ParentId, Name = Name.TryGetValue(out var name) ? name : substituteName, NameAuthor = NameAuthor, IsTrashed = IsTrashed, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs new file mode 100644 index 00000000..28db0ab8 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -0,0 +1,106 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class FileOperations +{ + public static async Task<(RevisionUid RevisionUid, FileSecrets FileSecrets)> CreateOrGetExistingDraftAsync( + ProtonDriveClient client, + NodeUid parentUid, + string name, + string mediaType, + CancellationToken cancellationToken) + { + var parentSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + + var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + + var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + + NodeOperations.GetCommonCreationParameters( + name, + parentSecrets.Key, + parentSecrets.HashKey.Span, + signingKey, + out var key, + out var nameSessionKey, + out var passphraseSessionKey, + out var encryptedName, + out var nameHashDigest, + out var encryptedKeyPassphrase, + out var passphraseSignature, + out var lockedKeyBytes); + + var contentKey = PgpSessionKey.Generate(); + var (contentKeyToken, _) = contentKey.Export(); + + var clientUid = await client.GetClientUidAsync(cancellationToken).ConfigureAwait(false); + + var parameters = new FileCreationParameters + { + ClientUid = clientUid, + Name = encryptedName, + NameHashDigest = nameHashDigest, + ParentLinkId = parentUid.LinkId, + Passphrase = encryptedKeyPassphrase, + PassphraseSignature = passphraseSignature, + SignatureEmailAddress = membershipAddress.EmailAddress, + Key = lockedKeyBytes, + MediaType = mediaType, + ContentKeyPacket = key.EncryptSessionKey(contentKey), + ContentKeyPacketSignature = key.Sign(contentKeyToken), + }; + + FileSecrets fileSecrets; + RevisionUid draftRevisionUid; + try + { + var response = await client.Api.Files.CreateFileAsync(parentUid.VolumeId, parameters, cancellationToken).ConfigureAwait(false); + + var draftNodeUid = new NodeUid(parentUid.VolumeId, response.Identifiers.LinkId); + draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); + + fileSecrets = new FileSecrets + { + Key = key, + PassphraseSessionKey = passphraseSessionKey, + NameSessionKey = nameSessionKey, + ContentKey = contentKey, + }; + + await client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); + } + catch (ProtonApiException ex) + when (ex.Response is { Conflict: { LinkId: not null, DraftClientUid: not null, DraftRevisionId: not null } }) + { + if (ex.Response.Conflict.DraftClientUid != clientUid) + { + throw; + } + + var draftNodeUid = new NodeUid(parentUid.VolumeId, ex.Response.Conflict.LinkId.Value); + draftRevisionUid = new RevisionUid(draftNodeUid, ex.Response.Conflict.DraftRevisionId.Value); + + fileSecrets = await GetSecretsAsync(client, draftNodeUid, cancellationToken).ConfigureAwait(false); + } + + return (draftRevisionUid, fileSecrets); + } + + public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) + { + var fileSecrets = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); + + if (fileSecrets is null) + { + var nodeProvisionResult = await NodeOperations.GetFreshNodeAndSecretsAsync(client, fileUid, knownShareAndKey: null, cancellationToken) + .ConfigureAwait(false); + + fileSecrets = nodeProvisionResult.GetFileSecretsOrThrow(); + } + + return fileSecrets; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSample.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSample.cs new file mode 100644 index 00000000..87b9049d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSample.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed class FileSample(FileSamplePurpose purpose, ArraySegment content) +{ + public FileSamplePurpose Purpose { get; } = purpose; + public ArraySegment Content { get; } = content; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSamplePurpose.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSamplePurpose.cs new file mode 100644 index 00000000..fcf45958 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSamplePurpose.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +public enum FileSamplePurpose +{ + Thumbnail = 1, + Preview = 2, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 2f716339..77fa9457 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -3,15 +3,13 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Shares; using Proton.Sdk; -using Proton.Sdk.Addresses; namespace Proton.Drive.Sdk.Nodes; internal static class FolderOperations { - public static async IAsyncEnumerable> EnumerateFolderChildrenAsync( + public static async IAsyncEnumerable> EnumerateChildrenAsync( ProtonDriveClient client, NodeUid folderId, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -56,11 +54,11 @@ public static async IAsyncEnumerable> EnumerateFolder } } - public static async ValueTask CreateFolderAsync(ProtonDriveClient client, NodeUid parentId, string name, CancellationToken cancellationToken) + public static async ValueTask CreateAsync(ProtonDriveClient client, NodeUid parentId, string name, CancellationToken cancellationToken) { var parentSecrets = await GetSecretsAsync(client, parentId, cancellationToken).ConfigureAwait(false); - var membershipAddress = await GetMembershipAddressAsync(client, parentId, cancellationToken).ConfigureAwait(false); + var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, parentId, cancellationToken).ConfigureAwait(false); var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); @@ -110,8 +108,8 @@ public static async ValueTask CreateFolderAsync(ProtonDriveClient cl var folderNode = new FolderNode { - Id = folderId, - ParentId = parentId, + Uid = folderId, + ParentUid = parentId, Name = name, IsTrashed = false, NameAuthor = author, @@ -136,14 +134,4 @@ internal static async ValueTask GetSecretsAsync(ProtonDriveClient return folderSecrets; } - - private static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid parentId, CancellationToken cancellationToken) - { - // TODO: try to get the information from cache first - var response = await client.Api.Links.GetContextShareAsync(parentId.VolumeId, parentId.LinkId, cancellationToken).ConfigureAwait(false); - - var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); - - return await client.Account.GetAddressAsync(client, share.MembershipAddressId, cancellationToken).ConfigureAwait(false); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index 63ae596c..d2854c1b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -9,9 +9,9 @@ namespace Proton.Drive.Sdk.Nodes; [JsonDerivedType(typeof(FileDraftNode), typeDiscriminator: "fileDraft")] public abstract class Node { - public required NodeUid Id { get; init; } + public required NodeUid Uid { get; init; } - public required NodeUid? ParentId { get; init; } + public required NodeUid? ParentUid { get; init; } public required string Name { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs index c1e0b283..761f3a79 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs @@ -22,7 +22,7 @@ public static FolderNode GetFolderNodeOrThrow(this Result provisionResult) + { + var nodeAndSecrets = provisionResult.GetValueOrThrow(); + + if (!nodeAndSecrets.TryGetFileElseFolder(out _, out var fileSecrets, out var folderNode, out _)) + { + throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); + } + + return fileSecrets; + } + public static bool TryGetFolderKeyElseError( this Result provisionResult, [NotNullWhen(true)] out PgpPrivateKey? folderKey, @@ -69,7 +81,7 @@ public static bool TryGetFolderKeyElseError( if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) { folderKey = null; - error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(fileNode.Id, LinkType.File)); + error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(fileNode.Uid, LinkType.File)); return false; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs index 1ddda703..03e8a5e1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs @@ -94,8 +94,8 @@ public static async ValueTask> De var folderNode = new FolderNode { - Id = id, - ParentId = parentId, + Uid = id, + ParentUid = parentId, Name = nameOutput.Value.Data, NameAuthor = nameOutput.Value.Author, Author = passphraseOutput.Value.Author, // TODO: combine with signature error from name hash key @@ -174,8 +174,8 @@ public static async ValueTask> De var fileNode = new FileNode { - Id = id, - ParentId = parentId, + Uid = id, + ParentUid = parentId, Name = nameOutput.Value.Data, IsTrashed = link.State is LinkState.Trashed, NameAuthor = nameOutput.Value.Author, @@ -189,7 +189,7 @@ public static async ValueTask> De ClaimedSize = extendedAttributes?.Common?.Size, ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, Thumbnails = [], // TODO: thumbnails - MetadataAuthor = extendedAttributesOutput?.Author, + ContentAuthor = extendedAttributesOutput?.Author, }, TotalStorageQuotaUsage = file.TotalStorageQuotaUsage, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 75591323..697667af 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -7,6 +7,7 @@ using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; using Proton.Sdk; +using Proton.Sdk.Addresses; using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Nodes; @@ -28,6 +29,24 @@ public static async ValueTask GetMyFilesFolderAsync(ProtonDriveClien return (FolderNode)node; } + public static async ValueTask GetNodeAsync( + ProtonDriveClient client, + NodeUid nodeId, + ShareAndKey? knownShareAndKey, + CancellationToken cancellationToken) + { + var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(nodeId, cancellationToken).ConfigureAwait(false); + + if (cachedNodeInfo is not var (nodeResult, _, _)) + { + var nodeAndSecretsResult = await GetFreshNodeAndSecretsAsync(client, nodeId, knownShareAndKey, cancellationToken).ConfigureAwait(false); + + nodeResult = nodeAndSecretsResult.ToNodeResult(); + } + + return nodeResult.GetValueOrThrow(); + } + public static void GetCommonCreationParameters( string name, PgpPrivateKey parentFolderKey, @@ -80,22 +99,14 @@ public static async ValueTask> Ge return await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetails, parentKeyResult, cancellationToken).ConfigureAwait(false); } - private static async ValueTask GetNodeAsync( - ProtonDriveClient client, - NodeUid nodeId, - ShareAndKey? knownShareAndKey, - CancellationToken cancellationToken) + public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) { - var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(nodeId, cancellationToken).ConfigureAwait(false); + // TODO: try to get the information from cache first + var response = await client.Api.Links.GetContextShareAsync(nodeUid.VolumeId, nodeUid.LinkId, cancellationToken).ConfigureAwait(false); - if (cachedNodeInfo is not var (nodeResult, _, _)) - { - var nodeAndSecretsResult = await GetFreshNodeAndSecretsAsync(client, nodeId, knownShareAndKey, cancellationToken).ConfigureAwait(false); + var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); - nodeResult = nodeAndSecretsResult.ToNodeResult(); - } - - return nodeResult.GetValueOrThrow(); + return await client.Account.GetAddressAsync(client, share.MembershipAddressId, cancellationToken).ConfigureAwait(false); } private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs index 6e84cd5b..ed648904 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs @@ -6,8 +6,8 @@ namespace Proton.Drive.Sdk.Nodes; -[JsonConverter(typeof(NodeUidConverter))] -public readonly record struct NodeUid +[JsonConverter(typeof(UidJsonConverter))] +public readonly record struct NodeUid : ICompositeUid { internal NodeUid(VolumeId volumeId, LinkId linkId) { @@ -23,26 +23,15 @@ public override string ToString() return $"{VolumeId}~{LinkId}"; } - public static bool TryParse([NotNullWhen(true)] string? value, out NodeUid result) + static bool ICompositeUid.TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out NodeUid? uid) { - if (string.IsNullOrEmpty(value)) - { - result = default; - return false; - } - - var separatorIndex = value.IndexOf('~'); - - if (separatorIndex < 0 || separatorIndex >= value.Length - 1) - { - result = default; - return false; - } - - var volumeId = value[..separatorIndex]; - var linkId = value[(separatorIndex + 1)..]; - - result = new NodeUid(new VolumeId(volumeId), new LinkId(linkId)); + uid = new NodeUid(new VolumeId(baseUidString), new LinkId(relativeIdString)); return true; } + + internal void Deconstruct(out VolumeId volumeId, out LinkId linkId) + { + volumeId = VolumeId; + linkId = LinkId; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index 3496ae6b..a9d69c0f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -10,5 +10,5 @@ public sealed class Revision public required long? ClaimedSize { get; init; } public required DateTime? ClaimedModificationTime { get; init; } public required IReadOnlyList> Thumbnails { get; init; } - public required Result? MetadataAuthor { get; init; } + public required Result? ContentAuthor { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs new file mode 100644 index 00000000..68695c8a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -0,0 +1,32 @@ +using Proton.Drive.Sdk.Nodes.Upload; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class RevisionOperations +{ + public static async ValueTask OpenForWritingAsync( + ProtonDriveClient client, + RevisionUid revisionUid, + FileSecrets fileSecrets, + Action releaseBlocksAction, + CancellationToken cancellationToken) + { + var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + + await client.BlockUploader.FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + const int targetBlockSize = RevisionWriter.DefaultBlockSize; + + return new RevisionWriter( + client, + revisionUid, + fileSecrets.Key, + fileSecrets.ContentKey, + signingKey, + membershipAddress, + releaseBlocksAction, + targetBlockSize, + targetBlockSize * 3 / 2); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs new file mode 100644 index 00000000..4f2a785d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Nodes; + +[JsonConverter(typeof(UidJsonConverter))] +public readonly record struct RevisionUid : ICompositeUid +{ + internal RevisionUid(NodeUid nodeUid, RevisionId revisionId) + { + NodeUid = nodeUid; + RevisionId = revisionId; + } + + internal NodeUid NodeUid { get; } + internal RevisionId RevisionId { get; } + + public override string ToString() + { + return $"{NodeUid.VolumeId}-{NodeUid.LinkId}~{RevisionId}"; + } + + static bool ICompositeUid.TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out RevisionUid? uid) + { + if (!ICompositeUid.TryParse(baseUidString, out var nodeUid)) + { + uid = null; + return false; + } + + uid = new RevisionUid(nodeUid.Value, new RevisionId(relativeIdString)); + return true; + } + + internal void Deconstruct(out NodeUid nodeUid, out RevisionId revisionId) + { + nodeUid = NodeUid; + revisionId = RevisionId; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs new file mode 100644 index 00000000..3b952051 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -0,0 +1,241 @@ +using System.Buffers; +using System.Diagnostics; +using System.Net; +using System.Security.Cryptography; +using Microsoft.IO; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Nodes.Upload.Verification; +using Proton.Sdk; +using Proton.Sdk.Addresses; +using Proton.Sdk.Api; +using Proton.Sdk.Drive; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal sealed class BlockUploader +{ + private readonly ProtonDriveClient _client; + + internal BlockUploader(ProtonDriveClient client, int maxDegreeOfParallelism) + { + _client = client; + MaxDegreeOfParallelism = maxDegreeOfParallelism; + BlockSemaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism); + } + + public int MaxDegreeOfParallelism { get; } + + public SemaphoreSlim FileSemaphore { get; } = new(1, 1); + public SemaphoreSlim BlockSemaphore { get; } + + public async Task UploadContentAsync( + NodeUid fileUid, + RevisionId revisionId, + int index, + PgpSessionKey contentKey, + PgpPrivateKey signingKey, + AddressId membershipAddressId, + PgpKey signatureEncryptionKey, + Stream plainDataStream, + BlockVerifier verifier, + byte[] plainDataPrefix, + int plainDataPrefixLength, + Action onBlockProgress, + Action releaseBlocksAction, + CancellationToken cancellationToken) + { + try + { + try + { + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) + { + var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + await using (signatureStream.ConfigureAwait(false)) + { + byte[] sha256Digest; + + await using (plainDataStream.ConfigureAwait(false)) + { + using var sha256 = SHA256.Create(); + + var hashingStream = new CryptoStream(dataPacketStream, sha256, CryptoStreamMode.Write, leaveOpen: true); + + await using (hashingStream.ConfigureAwait(false)) + { + var signatureEncryptingStream = signatureEncryptionKey.OpenEncryptingStream(signatureStream); + + await using (signatureEncryptingStream.ConfigureAwait(false)) + { + var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signatureEncryptingStream, signingKey); + + await using (encryptingStream.ConfigureAwait(false)) + { + await plainDataStream.CopyToAsync(encryptingStream, cancellationToken).ConfigureAwait(false); + } + } + } + + sha256Digest = sha256.Hash ?? []; + } + + // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, + // leading to a garbage signature. + var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; + + // TODO: retry upon verification failure + var verificationToken = verifier.VerifyBlock(dataPacketStream.GetFirstBytes(128), plainDataPrefix.AsSpan()[..plainDataPrefixLength]); + + var parameters = new BlockUploadRequestParameters + { + VolumeId = fileUid.VolumeId, + LinkId = fileUid.LinkId, + RevisionId = revisionId, + AddressId = membershipAddressId, + Blocks = + [ + new BlockCreationParameters + { + Index = index, + Size = (int)dataPacketStream.Length, + HashDigest = sha256Digest, + EncryptedSignature = signature, + VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, + }, + ], + Thumbnails = [], + }; + + await UploadBlobAsync(parameters, dataPacketStream, onBlockProgress, cancellationToken).ConfigureAwait(false); + + return sha256Digest; + } + } + } + finally + { + try + { + BlockSemaphore.Release(); + } + finally + { + releaseBlocksAction.Invoke(1); + } + } + } + finally + { + ArrayPool.Shared.Return(plainDataPrefix); + } + } + + public async Task UploadThumbnailAsync( + NodeUid fileUid, + RevisionId revisionId, + PgpSessionKey contentKey, + PgpPrivateKey signingKey, + AddressId membershipAddressId, + FileSample sample, + Action? onProgress, + CancellationToken cancellationToken) + { + try + { + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) + { + using var sha256 = SHA256.Create(); + + var hashingStream = new CryptoStream(dataPacketStream, sha256, CryptoStreamMode.Write, leaveOpen: true); + + await using (hashingStream.ConfigureAwait(false)) + { + var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signingKey); + + await using (encryptingStream.ConfigureAwait(false)) + { + await encryptingStream.WriteAsync(sample.Content, cancellationToken).ConfigureAwait(false); + } + } + + var sha256Digest = sha256.Hash ?? []; + + var parameters = new BlockUploadRequestParameters + { + VolumeId = fileUid.VolumeId, + LinkId = fileUid.LinkId, + RevisionId = revisionId, + AddressId = membershipAddressId, + Blocks = [], + Thumbnails = + [ + new ThumbnailCreationParameters + { + Size = (int)dataPacketStream.Length, + Type = (ThumbnailType)sample.Purpose, + HashDigest = sha256Digest, + }, + ], + }; + + await UploadBlobAsync(parameters, dataPacketStream, onProgress, cancellationToken).ConfigureAwait(false); + + return sha256Digest; + } + } + finally + { + _client.RevisionCreationSemaphore.Release(1); + } + } + + private async ValueTask UploadBlobAsync( + BlockUploadRequestParameters parameters, + RecyclableMemoryStream dataPacketStream, + Action? onProgress, + CancellationToken cancellationToken) + { +#pragma warning disable S3236 // FP: https://community.sonarsource.com/t/false-positive-on-s3236-when-calling-debug-assert-with-message/138761/6 + Debug.Assert(parameters.Thumbnails.Count + parameters.Blocks.Count == 1, "Block upload request should be for only one block, content or thumbnail"); +#pragma warning restore S3236 // Caller information arguments should not be provided explicitly + + var remainingNumberOfAttempts = 2; + + while (remainingNumberOfAttempts >= 1) + { + try + { + // TODO: request multiple blocks at once + var uploadRequestResponse = await _client.Api.Files.RequestBlockUploadAsync(parameters, cancellationToken).ConfigureAwait(false); + + var uploadTarget = parameters.Thumbnails.Count == 0 ? uploadRequestResponse.UploadTargets[0] : uploadRequestResponse.ThumbnailUploadTargets[0]; + + dataPacketStream.Seek(0, SeekOrigin.Begin); + + await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Token, dataPacketStream, onProgress, cancellationToken) + .ConfigureAwait(false); + + remainingNumberOfAttempts = 0; + } + catch (Exception e) when ((UrlExpired(e) || BlobAlreadyUploaded(e)) && remainingNumberOfAttempts >= 2) + { + --remainingNumberOfAttempts; + } + } + + return; + + static bool UrlExpired(Exception e) => e is HttpRequestException { StatusCode: HttpStatusCode.NotFound }; + + // This can happen if the previous successful upload response was not received/processed, + // which could happen for instance if the connection was interrupted just as the success was being sent back. + // The HTTP client's resilience logic will kick in and retry the blob upload at the same URL + // without handing control back to register a new block at the same index with its own new URL, + // causing the back-end to reject the upload with this error. + static bool BlobAlreadyUploaded(Exception e) => e is ProtonApiException { Code: ResponseCode.AlreadyExists }; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs new file mode 100644 index 00000000..a918fa31 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -0,0 +1,107 @@ +using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +public sealed class FileUploader : IDisposable +{ + private readonly ProtonDriveClient _client; + private readonly string _name; + private readonly string _mediaType; + private readonly DateTimeOffset? _lastModificationTime; + + private volatile int _remainingNumberOfBlocks; + + internal FileUploader( + ProtonDriveClient client, + string name, + string mediaType, + DateTimeOffset? lastModificationTime, + int expectedNumberOfBlocks) + { + _client = client; + _name = name; + _mediaType = mediaType; + _lastModificationTime = lastModificationTime; + _remainingNumberOfBlocks = expectedNumberOfBlocks; + } + + public UploadController UploadStream( + NodeUid parentFolderUid, + Stream contentInputStream, + IEnumerable samples, + bool createNewRevisionIfExists, + Action onProgress, + CancellationToken cancellationToken) + { + var task = UploadStreamAsync(parentFolderUid, contentInputStream, samples, createNewRevisionIfExists, onProgress, cancellationToken); + + return new UploadController(task); + } + + public void Dispose() + { + if (_remainingNumberOfBlocks <= 0) + { + return; + } + + _client.RevisionCreationSemaphore.Release(_remainingNumberOfBlocks); + _remainingNumberOfBlocks = 0; + } + + private async Task UploadStreamAsync( + NodeUid parentFolderUid, + Stream contentInputStream, + IEnumerable samples, + bool createNewRevisionIfExists, + Action onProgress, + CancellationToken cancellationToken) + { + RevisionUid draftRevisionUid; + FileSecrets fileSecrets; + try + { + (draftRevisionUid, fileSecrets) = await FileOperations.CreateOrGetExistingDraftAsync(_client, parentFolderUid, _name, _mediaType, cancellationToken) + .ConfigureAwait(false); + } + catch (ProtonApiException ex) + when (createNewRevisionIfExists && ex.Response is { Conflict: { LinkId: not null, RevisionId: not null, DraftRevisionId: null } }) + { + throw new NotImplementedException("Uploading new revision not yet implemented"); + } + + await UploadAsync( + draftRevisionUid, + fileSecrets, + contentInputStream, + samples, + _lastModificationTime, + onProgress, + cancellationToken).ConfigureAwait(false); + } + + private async ValueTask UploadAsync( + RevisionUid revisionUid, + FileSecrets fileSecrets, + Stream contentInputStream, + IEnumerable samples, + DateTimeOffset? lastModificationTime, + Action onProgress, + CancellationToken cancellationToken) + { + using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionUid, fileSecrets, ReleaseBlocks, cancellationToken) + .ConfigureAwait(false); + + await revisionWriter.WriteAsync(contentInputStream, samples, lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); + } + + private void ReleaseBlocks(int numberOfBlocks) + { + var newRemainingNumberOfBlocks = Interlocked.Add(ref _remainingNumberOfBlocks, -numberOfBlocks); + + var amountToRelease = Math.Max(newRemainingNumberOfBlocks >= 0 ? numberOfBlocks : newRemainingNumberOfBlocks + numberOfBlocks, 0); + + _client.RevisionCreationSemaphore.Release(amountToRelease); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs new file mode 100644 index 00000000..3e4d59b7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -0,0 +1,240 @@ +using System.Buffers; +using System.Text.Json; +using Microsoft.IO; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Nodes.Upload.Verification; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +public sealed class RevisionWriter : IDisposable +{ + public const int DefaultBlockSize = 1 << 22; // 4 MiB + + private readonly ProtonDriveClient _client; + private readonly NodeUid _fileUid; + private readonly RevisionId _revisionId; + private readonly PgpPrivateKey _fileKey; + private readonly PgpSessionKey _contentKey; + private readonly PgpPrivateKey _signingKey; + private readonly Address _membershipAddress; + private readonly Action _releaseBlocksAction; + + private readonly int _targetBlockSize; + private readonly int _maxBlockSize; + + private bool _semaphoreReleased; + + internal RevisionWriter( + ProtonDriveClient client, + RevisionUid revisionUid, + PgpPrivateKey fileKey, + PgpSessionKey contentKey, + PgpPrivateKey signingKey, + Address membershipAddress, + Action releaseBlocksAction, + int targetBlockSize = DefaultBlockSize, + int maxBlockSize = DefaultBlockSize) + { + _client = client; + (_fileUid, _revisionId) = revisionUid; + _fileKey = fileKey; + _contentKey = contentKey; + _signingKey = signingKey; + _membershipAddress = membershipAddress; + _releaseBlocksAction = releaseBlocksAction; + _targetBlockSize = targetBlockSize; + _maxBlockSize = maxBlockSize; + } + + public async ValueTask WriteAsync( + Stream contentInputStream, + IEnumerable samples, + DateTimeOffset? lastModificationTime, + Action onProgress, + CancellationToken cancellationToken) + { + long numberOfBytesUploaded = 0; + + var signingEmailAddress = _membershipAddress.EmailAddress; + + var uploadTasks = new Queue>(_client.BlockUploader.MaxDegreeOfParallelism); + var blockIndex = 0; + + // TODO: provide capacity + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + ArraySegment manifestSignature; + var blockSizes = new List(8); + + await using (manifestStream.ConfigureAwait(false)) + { + var blockVerifier = await BlockVerifier.CreateAsync(_client.Api.Files, _fileUid, _revisionId, _fileKey, cancellationToken).ConfigureAwait(false); + + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + try + { + try + { + foreach (var sample in samples) + { + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + + var uploadTask = _client.BlockUploader.UploadThumbnailAsync( + _fileUid, + _revisionId, + _contentKey, + _signingKey, + _membershipAddress.Id, + sample, + onProgress: null, + cancellationTokenSource.Token); + + uploadTasks.Enqueue(uploadTask); + } + + if (contentInputStream.Length > 0) + { + do + { + var plainDataPrefix = ArrayPool.Shared.Rent(blockVerifier.DataPacketPrefixMaxLength); + try + { + var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + await contentInputStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plainDataPrefix, cancellationTokenSource.Token) + .ConfigureAwait(false); + + blockSizes.Add((int)plainDataStream.Length); + + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + + plainDataStream.Seek(0, SeekOrigin.Begin); + + var uploadTask = _client.BlockUploader.UploadContentAsync( + _fileUid, + _revisionId, + ++blockIndex, + _contentKey, + _signingKey, + _membershipAddress.Id, + _fileKey, + plainDataStream, + blockVerifier, + plainDataPrefix, + (int)Math.Min(blockVerifier.DataPacketPrefixMaxLength, plainDataStream.Length), + progress => + { + numberOfBytesUploaded += progress; + onProgress(numberOfBytesUploaded, contentInputStream.Length); + }, + _releaseBlocksAction, + cancellationTokenSource.Token); + + uploadTasks.Enqueue(uploadTask); + } + catch + { + ArrayPool.Shared.Return(plainDataPrefix); + throw; + } + } while (contentInputStream.Position < contentInputStream.Length); + } + } + finally + { + _client.BlockUploader.FileSemaphore.Release(); + _semaphoreReleased = true; + } + + while (uploadTasks.Count > 0) + { + await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); + } + } + catch + { + await cancellationTokenSource.CancelAsync().ConfigureAwait(false); + + try + { + await Task.WhenAll(uploadTasks).ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + } + + throw; + } + + manifestStream.Seek(0, SeekOrigin.Begin); + + manifestSignature = await _signingKey.SignAsync(manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + } + + var parameters = GetRevisionUpdateParameters(contentInputStream, lastModificationTime, blockSizes, manifestSignature, signingEmailAddress); + + await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, parameters, cancellationToken).ConfigureAwait(false); + } + + public void Dispose() + { + if (!_semaphoreReleased) + { + _client.BlockUploader.FileSemaphore.Release(); + } + } + + private static async ValueTask AddNextBlockToManifestAsync(Queue> uploadTasks, RecyclableMemoryStream manifestStream) + { + var sha256Digest = await uploadTasks.Dequeue().ConfigureAwait(false); + + await manifestStream.WriteAsync(sha256Digest).ConfigureAwait(false); + } + + private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTasks, RecyclableMemoryStream manifestStream, CancellationToken cancellationToken) + { + if (!await _client.BlockUploader.BlockSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + if (uploadTasks.Count > 0) + { + await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); + } + + await _client.BlockUploader.BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + private RevisionUpdateParameters GetRevisionUpdateParameters( + Stream contentInputStream, + DateTimeOffset? lastModificationTime, + IReadOnlyList blockSizes, + ArraySegment manifestSignature, + string signinEmailAddress) + { + var extendedAttributes = new ExtendedAttributes + { + Common = new CommonExtendedAttributes + { + Size = contentInputStream.Length, + ModificationTime = lastModificationTime?.UtcDateTime, + BlockSizes = blockSizes, + }, + }; + + var extendedAttributesUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(extendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); + + var encryptedExtendedAttributes = _fileKey.EncryptAndSign(extendedAttributesUtf8Bytes, _signingKey, outputCompression: PgpCompression.Default); + + return new RevisionUpdateParameters + { + ManifestSignature = manifestSignature, + SignatureEmailAddress = signinEmailAddress, + ExtendedAttributes = encryptedExtendedAttributes, + }; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs new file mode 100644 index 00000000..030ef2c9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs @@ -0,0 +1,52 @@ +using Proton.Sdk.Drive; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +public static class RevisionWriterExtensions +{ + public static ValueTask WriteAsync( + this RevisionWriter revisionWriter, + Stream contentStream, + DateTimeOffset? lastModificationTime, + Action onProgress, + CancellationToken cancellationToken) + { + return revisionWriter.WriteAsync(contentStream, [], lastModificationTime, onProgress, cancellationToken); + } + + public static ValueTask WriteAsync( + this RevisionWriter revisionWriter, + Stream contentStream, + DateTime lastModificationTime, + Action onProgress, + CancellationToken cancellationToken) + { + return revisionWriter.WriteAsync(contentStream, [], new DateTimeOffset(lastModificationTime), onProgress, cancellationToken); + } + + public static ValueTask WriteAsync( + this RevisionWriter revisionWriter, + Stream contentStream, + IEnumerable samples, + DateTime lastModificationTime, + Action onProgress, + CancellationToken cancellationToken) + { + return revisionWriter.WriteAsync(contentStream, samples, new DateTimeOffset(lastModificationTime), onProgress, cancellationToken); + } + + public static async ValueTask WriteAsync( + this RevisionWriter writer, + string targetFilePath, + DateTime lastModificationTime, + Action onProgress, + CancellationToken cancellationToken) + { + var fileStream = File.Open(targetFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + await using (fileStream) + { + await WriteAsync(writer, fileStream, lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs new file mode 100644 index 00000000..eefe36d4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +public sealed class UploadController(Task uploadTask) +{ + public Task Completion { get; } = uploadTask; + + public void Pause() + { + // TODO + throw new NotImplementedException(); + } + + public void Resume() + { + // TODO + throw new NotImplementedException(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BitwiseOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BitwiseOperations.cs new file mode 100644 index 00000000..e81161a3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BitwiseOperations.cs @@ -0,0 +1,36 @@ +using System.Numerics; +using System.Runtime.InteropServices; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +internal static class BitwiseOperations +{ + public static byte[] Xor(ReadOnlySpan a, ReadOnlySpan b) + { + if (b.Length != a.Length) + { + throw new ArgumentException("Memory segments must have the same length", nameof(b)); + } + + var result = new byte[a.Length]; + + var vectorChunks = b.Length / Vector.Count; + var vectorChunksBound = vectorChunks * Vector.Count; + + var aVectors = MemoryMarshal.Cast>(a[..vectorChunksBound]); + var bVectors = MemoryMarshal.Cast>(b[..vectorChunksBound]); + var resultVectors = MemoryMarshal.Cast>(result.AsSpan()[..vectorChunksBound]); + + for (var i = 0; i < aVectors.Length; ++i) + { + resultVectors[i] = aVectors[i] ^ bVectors[i]; + } + + for (var i = vectorChunksBound; i < b.Length; ++i) + { + result[i] = (byte)(a[i] ^ b[i]); + } + + return result; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs new file mode 100644 index 00000000..012a754f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs @@ -0,0 +1,66 @@ +using CommunityToolkit.HighPerformance; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +internal sealed class BlockVerifier +{ + private const int MaxVerificationLength = 16; + + private readonly PgpSessionKey _sessionKey; + private readonly ReadOnlyMemory _verificationCode; + + private BlockVerifier(PgpSessionKey sessionKey, ReadOnlyMemory verificationCode) + { + _sessionKey = sessionKey; + _verificationCode = verificationCode; + } + + public int DataPacketPrefixMaxLength => _verificationCode.Length; + + public static async Task CreateAsync( + IRevisionVerificationApiClient client, + NodeUid fileUid, + RevisionId revisionId, + PgpPrivateKey key, + CancellationToken cancellationToken) + { + var verificationInput = await client.GetVerificationInputAsync(fileUid.VolumeId, fileUid.LinkId, revisionId, cancellationToken).ConfigureAwait(false); + + PgpSessionKey sessionKey; + try + { + sessionKey = key.DecryptSessionKey(verificationInput.ContentKeyPacket.Span); + } + catch (Exception e) + { + throw new NodeKeyAndSessionKeyMismatchException(e); + } + + return new BlockVerifier(sessionKey, verificationInput.VerificationCode); + } + + public VerificationToken VerifyBlock(ReadOnlyMemory dataPacketPrefix, ReadOnlySpan plainDataPrefix) + { + var verificationLength = Math.Min(MaxVerificationLength, plainDataPrefix.Length); + using var decryptingStream = PgpDecryptingStream.Open(dataPacketPrefix.AsStream(), _sessionKey); + + Span buffer = stackalloc byte[verificationLength]; + + try + { + var numberOfBytesRead = decryptingStream.Read(buffer); + if (!plainDataPrefix.StartsWith(buffer[..numberOfBytesRead])) + { + throw new SessionKeyAndDataPacketMismatchException(); + } + } + catch + { + throw new SessionKeyAndDataPacketMismatchException(); + } + + return VerificationToken.Create(_verificationCode.Span, dataPacketPrefix.Span); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs new file mode 100644 index 00000000..4d4ece5e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs @@ -0,0 +1,23 @@ +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +public sealed class NodeKeyAndSessionKeyMismatchException : Exception +{ + public NodeKeyAndSessionKeyMismatchException(string message) + : base(message) + { + } + + public NodeKeyAndSessionKeyMismatchException(string message, Exception innerException) + : base(message, innerException) + { + } + + public NodeKeyAndSessionKeyMismatchException() + { + } + + public NodeKeyAndSessionKeyMismatchException(Exception innerException) + : base(string.Empty, innerException) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs new file mode 100644 index 00000000..594170e6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +public sealed class SessionKeyAndDataPacketMismatchException : Exception +{ + public SessionKeyAndDataPacketMismatchException(string message) + : base(message) + { + } + + public SessionKeyAndDataPacketMismatchException(string message, Exception innerException) + : base(message, innerException) + { + } + + public SessionKeyAndDataPacketMismatchException() + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs new file mode 100644 index 00000000..f1275cf6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs @@ -0,0 +1,34 @@ +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +public readonly struct VerificationToken +{ + private readonly ReadOnlyMemory _data; + + private VerificationToken(ReadOnlyMemory data) + { + _data = data; + } + + public static VerificationToken Create(ReadOnlySpan verificationCode, ReadOnlySpan dataPacketPrefix) + { + // In the unlikely event that the back-end decides to increase the length of the verification code such that it may exceed + // the length of the data packet prefix, we have padding logic to deal with it, as per the agreed verification protocol. + var dataPacketPrefixForToken = GetPaddedOrTruncatedBytes(dataPacketPrefix, verificationCode.Length); + + return new VerificationToken(BitwiseOperations.Xor(verificationCode, dataPacketPrefixForToken)); + } + + public ReadOnlyMemory AsReadOnlyMemory() => _data; + + private static ReadOnlySpan GetPaddedOrTruncatedBytes(ReadOnlySpan originalBytes, int length) + { + if (originalBytes.Length >= length) + { + return originalBytes[..length]; + } + + var result = new byte[length]; + originalBytes.CopyTo(result); + return result; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 67a0fc1c..5bb31e45 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -1,12 +1,17 @@ +using Microsoft.Extensions.Logging; +using Microsoft.IO; using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk; namespace Proton.Drive.Sdk; public sealed class ProtonDriveClient { + private const int MinDegreeOfBlockTransferParallelism = 2; + private const int MaxDegreeOfBlockTransferParallelism = 6; private const int ApiTimeoutSeconds = 20; /// @@ -17,22 +22,38 @@ public ProtonDriveClient(ProtonApiSession session) : this( new AccountClientAdapter(session), new DriveApiClients(session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds))), - new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository)) + new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), + session.ClientConfiguration.LoggerFactory) { } - internal ProtonDriveClient(IAccountClient accountClient, IDriveApiClients apiClients, IDriveClientCache cache) + internal ProtonDriveClient(IAccountClient accountClient, IDriveApiClients apiClients, IDriveClientCache cache, ILoggerFactory loggerFactory) { Account = accountClient; Api = apiClients; Cache = cache; + Logger = loggerFactory.CreateLogger(); + + var maxDegreeOfBlockTransferParallelism = Math.Max( + Math.Min(Environment.ProcessorCount / 2, MaxDegreeOfBlockTransferParallelism), + MinDegreeOfBlockTransferParallelism); + + var maxDegreeOfBlockProcessingParallelism = maxDegreeOfBlockTransferParallelism + Math.Min(Math.Max(maxDegreeOfBlockTransferParallelism / 2, 2), 4); + + BlockUploader = new BlockUploader(this, maxDegreeOfBlockTransferParallelism); + RevisionCreationSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger()); } - internal IAccountClient Account { get; } + internal static RecyclableMemoryStreamManager MemoryStreamManager { get; } = new(); + internal IAccountClient Account { get; } internal IDriveApiClients Api { get; } - internal IDriveClientCache Cache { get; } + internal ILogger Logger { get; } + + internal FifoFlexibleSemaphore RevisionCreationSemaphore { get; } + + internal BlockUploader BlockUploader { get; } public ValueTask GetMyFilesFolderAsync(CancellationToken cancellationToken) { @@ -41,11 +62,39 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio public ValueTask CreateFolderAsync(NodeUid parentId, string name, CancellationToken cancellationToken) { - return FolderOperations.CreateFolderAsync(this, parentId, name, cancellationToken); + return FolderOperations.CreateAsync(this, parentId, name, cancellationToken); } public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) { - return FolderOperations.EnumerateFolderChildrenAsync(this, folderId, cancellationToken); + return FolderOperations.EnumerateChildrenAsync(this, folderId, cancellationToken); + } + + public async ValueTask GetFileUploaderAsync( + string name, + string mediaType, + DateTime? lastModificationTime, + long size, + CancellationToken cancellationToken) + { + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); + + await RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); + + return new FileUploader(this, name, mediaType, lastModificationTime, expectedNumberOfBlocks); + } + + internal async ValueTask GetClientUidAsync(CancellationToken cancellationToken) + { + var clientUid = await Cache.Entities.TryGetClientUidAsync(cancellationToken).ConfigureAwait(false); + + if (clientUid is null) + { + clientUid = Guid.NewGuid().ToString("N"); + + await Cache.Entities.SetClientUidAsync(clientUid, cancellationToken).ConfigureAwait(false); + } + + return clientUid; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs new file mode 100644 index 00000000..1d612717 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs @@ -0,0 +1,16 @@ +using System.Buffers; +using Microsoft.IO; + +namespace Proton.Sdk.Drive; + +public static class RecyclableMemoryStreamExtensions +{ + public static ReadOnlyMemory GetFirstBytes(this RecyclableMemoryStream stream, long maxLength) + { + var sequence = stream.GetReadOnlySequence(); + + return sequence.First.Length >= maxLength + ? sequence.First + : sequence.Slice(0, Math.Min(maxLength, sequence.Length)).ToArray(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 2f67212b..ce355798 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; @@ -32,4 +33,11 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(FolderChildrenResponse))] [JsonSerializable(typeof(FolderCreationParameters))] [JsonSerializable(typeof(FolderCreationResponse))] +[JsonSerializable(typeof(FileCreationParameters))] +[JsonSerializable(typeof(FileCreationResponse))] +[JsonSerializable(typeof(RevisionConflictResponse))] +[JsonSerializable(typeof(BlockUploadRequestParameters))] +[JsonSerializable(typeof(BlockRequestResponse))] +[JsonSerializable(typeof(RevisionUpdateParameters))] +[JsonSerializable(typeof(BlockVerificationInputResponse))] internal sealed partial class DriveApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/ICompositeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/ICompositeUid.cs new file mode 100644 index 00000000..2b5a13a2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/ICompositeUid.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Drive.Sdk.Serialization; + +internal interface ICompositeUid + where TUid : struct, ICompositeUid +{ + static abstract bool TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out TUid? uid); + + static bool TryParse([NotNullWhen(true)] string? value, [NotNullWhen(true)] out TUid? result) + { + if (string.IsNullOrEmpty(value)) + { + result = null; + return false; + } + + var separatorIndex = value.LastIndexOf('~'); + + if (separatorIndex < 0 || separatorIndex >= value.Length - 1) + { + result = null; + return false; + } + + var baseUidString = value[..separatorIndex]; + var relativeIdString = value[(separatorIndex + 1)..]; + + return TUid.TryCreate(baseUidString, relativeIdString, out result); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/NodeUidConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/NodeUidConverter.cs deleted file mode 100644 index fff0b907..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/NodeUidConverter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Proton.Drive.Sdk.Nodes; - -namespace Proton.Drive.Sdk.Serialization; - -internal sealed class NodeUidConverter : JsonConverter -{ - public override NodeUid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return NodeUid.TryParse(reader.GetString(), out var value) ? value : default; - } - - public override void Write(Utf8JsonWriter writer, NodeUid value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.ToString()); - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/UidJsonConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/UidJsonConverter.cs new file mode 100644 index 00000000..0095b3cd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/UidJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +internal sealed class UidJsonConverter : JsonConverter + where TUid : struct, ICompositeUid +{ + public override TUid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return ICompositeUid.TryParse(reader.GetString(), out var uid) ? uid.Value : default; + } + + public override void Write(Utf8JsonWriter writer, TUid value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/StreamExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/StreamExtensions.cs new file mode 100644 index 00000000..6c6df24d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/StreamExtensions.cs @@ -0,0 +1,48 @@ +using System.Buffers; + +namespace Proton.Drive.Sdk; + +internal static class StreamExtensions +{ + private const int CopyBufferSize = 81_920; + + public static async Task PartiallyCopyToAsync( + this Stream source, + Stream destination, + int lengthToCopy, + Memory sampleOutput, + CancellationToken cancellationToken) + { + var copyBuffer = ArrayPool.Shared.Rent(Math.Min(lengthToCopy, CopyBufferSize)); + try + { + var remainingLengthToCopy = lengthToCopy; + int numberOfBytesRead; + do + { + var copyBufferMemory = copyBuffer.AsMemory(0, Math.Min(remainingLengthToCopy, copyBuffer.Length)); + + numberOfBytesRead = await source.ReadAsync(copyBufferMemory, cancellationToken).ConfigureAwait(false); + + var readBytes = copyBuffer.AsMemory()[..numberOfBytesRead]; + + if (sampleOutput.Length > 0) + { + var lengthForSample = Math.Min(readBytes.Length, sampleOutput.Length); + readBytes.Span[..lengthForSample].CopyTo(sampleOutput.Span); + sampleOutput = sampleOutput[lengthForSample..]; + } + + await destination.WriteAsync(readBytes, cancellationToken).ConfigureAwait(false); + + remainingLengthToCopy -= numberOfBytesRead; + } while (numberOfBytesRead > 0 && remainingLengthToCopy > 0); + + return lengthToCopy - remainingLengthToCopy; + } + finally + { + ArrayPool.Shared.Return(copyBuffer); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 6f78a04e..d70899c4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -29,8 +29,8 @@ internal static class VolumeOperations var rootFolder = new FolderNode { - Id = volume.RootFolderId, - ParentId = null, + Uid = volume.RootFolderId, + ParentUid = null, Name = RootFolderName, NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, Author = new Author { EmailAddress = defaultAddress.EmailAddress }, diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs index 54f5f1b3..78c8ea84 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -57,12 +57,6 @@ public async ValueTask PostAsync( return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); } - public async ValueTask PostAsync(string requestUri, HttpContent content, CancellationToken cancellationToken) - { - using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, requestUri, content); - return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); - } - public async ValueTask PutAsync( string requestUri, TRequestBody body, @@ -86,7 +80,7 @@ public async ValueTask DeleteAsync(string requestUri, string sessionId return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); } - private async ValueTask SendAsync(HttpRequestMessage requestMessage, CancellationToken cancellationToken) + public async ValueTask SendAsync(HttpRequestMessage requestMessage, CancellationToken cancellationToken) { var responseMessage = await _httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); From ede7a44dacbc56b835c05e72f7341426f87e2cd6 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 25 Apr 2025 13:46:50 +0200 Subject: [PATCH 081/791] Add and apply guideline for FIXME and TODO comments --- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs | 16 ++++++++-------- .../src/Proton.Drive.Sdk/Nodes/NodeOperations.cs | 2 +- .../Nodes/Upload/BlockUploader.cs | 4 ++-- .../Nodes/Upload/UploadController.cs | 4 ++-- .../Proton.Sdk/Addresses/AddressOperations.cs | 2 +- .../src/Proton.Sdk/Api/Events/EventsApiClient.cs | 2 +- cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs | 2 +- .../Http/HttpRequestHeadersExtensions.cs | 2 +- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 2 +- js/sdk/src/crypto/driveCrypto.ts | 2 +- js/sdk/src/crypto/interface.ts | 2 +- js/sdk/src/interface/account.ts | 2 +- js/sdk/src/internal/apiService/apiService.ts | 4 ++-- js/sdk/src/internal/events/apiService.ts | 2 +- js/sdk/src/internal/events/index.ts | 4 ++-- js/sdk/src/internal/nodes/apiService.ts | 10 +++++----- js/sdk/src/internal/nodes/cache.ts | 2 +- js/sdk/src/internal/nodes/extendedAttributes.ts | 2 +- js/sdk/src/internal/nodes/index.ts | 2 +- js/sdk/src/internal/nodes/nodesManagement.ts | 2 +- js/sdk/src/internal/shares/cryptoService.ts | 2 +- js/sdk/src/internal/sharing/cryptoService.ts | 8 ++++---- js/sdk/src/internal/sharing/sharingAccess.ts | 2 +- js/sdk/src/internal/sharing/sharingManagement.ts | 12 ++++++------ js/sdk/src/internal/upload/apiService.ts | 8 ++++---- js/sdk/src/internal/upload/manager.ts | 4 ++-- js/sdk/src/internal/upload/queue.ts | 2 +- js/sdk/src/protonDrivePhotosClient.ts | 2 +- js/sdk/typings/index.d.ts | 2 +- 29 files changed, 56 insertions(+), 56 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs index 03e8a5e1..eb48c558 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs @@ -78,7 +78,7 @@ public static async ValueTask> De PassphraseSessionKey = passphraseOutput?.SessionKey, }; - // TODO: cache secrets + // FIXME: cache secrets throw new NotImplementedException(); } @@ -98,7 +98,7 @@ public static async ValueTask> De ParentUid = parentId, Name = nameOutput.Value.Data, NameAuthor = nameOutput.Value.Author, - Author = passphraseOutput.Value.Author, // TODO: combine with signature error from name hash key + Author = passphraseOutput.Value.Author, // FIXME: combine with signature error from name hash key IsTrashed = link.State is LinkState.Trashed, }; @@ -109,7 +109,7 @@ public static async ValueTask> De if (file is null) { - // TODO: handle missing file information with degraded node + // FIXME: handle missing file information with degraded node throw new NotImplementedException(); } @@ -121,13 +121,13 @@ public static async ValueTask> De if (file.ActiveRevision is null) { - // TODO: handle missing revision information with degraded node + // FIXME: handle missing revision information with degraded node throw new NotImplementedException(); } var contentKey = nodeKey?.DecryptSessionKey(file.ContentKeyPacket.Span); - // TODO: verify content key packet signature + // FIXME: verify content key packet signature var (extendedAttributesOutput, extendedAttributesError) = DecryptExtendedAttributes(file.ActiveRevision.ExtendedAttributes, nodeKey, nodeAuthorshipClaim); @@ -156,7 +156,7 @@ public static async ValueTask> De ContentKey = contentKey, }; - // TODO: cache secrets + // FIXME: cache secrets throw new NotImplementedException(); } @@ -179,7 +179,7 @@ public static async ValueTask> De Name = nameOutput.Value.Data, IsTrashed = link.State is LinkState.Trashed, NameAuthor = nameOutput.Value.Author, - Author = passphraseOutput.Value.Author, // TODO: combine with signature error from content key + Author = passphraseOutput.Value.Author, // FIXME: combine with signature error from content key MediaType = file.MediaType, ActiveRevision = new Revision { @@ -188,7 +188,7 @@ public static async ValueTask> De StorageQuotaConsumption = file.ActiveRevision.StorageQuotaConsumption, ClaimedSize = extendedAttributes?.Common?.Size, ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, - Thumbnails = [], // TODO: thumbnails + Thumbnails = [], // FIXME: thumbnails ContentAuthor = extendedAttributesOutput?.Author, }, TotalStorageQuotaUsage = file.TotalStorageQuotaUsage, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 697667af..8ae7176a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -101,7 +101,7 @@ public static async ValueTask> Ge public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) { - // TODO: try to get the information from cache first + // FIXME: try to get the information from cache first var response = await client.Api.Links.GetContextShareAsync(nodeUid.VolumeId, nodeUid.LinkId, cancellationToken).ConfigureAwait(false); var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 3b952051..71279f52 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -86,7 +86,7 @@ public async Task UploadContentAsync( // leading to a garbage signature. var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; - // TODO: retry upon verification failure + // FIXME: retry upon verification failure var verificationToken = verifier.VerifyBlock(dataPacketStream.GetFirstBytes(128), plainDataPrefix.AsSpan()[..plainDataPrefixLength]); var parameters = new BlockUploadRequestParameters @@ -209,7 +209,7 @@ private async ValueTask UploadBlobAsync( { try { - // TODO: request multiple blocks at once + // FIXME: request multiple blocks at once var uploadRequestResponse = await _client.Api.Files.RequestBlockUploadAsync(parameters, cancellationToken).ConfigureAwait(false); var uploadTarget = parameters.Thumbnails.Count == 0 ? uploadRequestResponse.UploadTargets[0] : uploadRequestResponse.ThumbnailUploadTargets[0]; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index eefe36d4..cb62434b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -6,13 +6,13 @@ public sealed class UploadController(Task uploadTask) public void Pause() { - // TODO + // FIXME throw new NotImplementedException(); } public void Resume() { - // TODO + // FIXME throw new NotImplementedException(); } } diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 81e8b183..d2168f29 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -207,7 +207,7 @@ private static async ValueTask
ConvertFromDtoAsync( } catch { - // TODO: log that + // FIXME: log that continue; } diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs index 52fd6a52..d38b9461 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs @@ -4,7 +4,7 @@ namespace Proton.Sdk.Api.Events; -// TODO: make sure that we don't listen to core events twice when Drive will need them to listen to "shared with me" events +// FIXME: make sure that we don't listen to core events twice when Drive will need them to listen to "shared with me" events internal readonly struct EventsApiClient(HttpClient httpClient) { private readonly HttpClient _httpClient = httpClient; diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs index 78c8ea84..a74a12d8 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -6,7 +6,7 @@ namespace Proton.Sdk.Http; -// TODO: add unit tests +// FIXME: add unit tests internal readonly struct HttpApiCallBuilder where TFailure : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs index d49e2340..b72235b8 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs @@ -8,7 +8,7 @@ internal static class HttpRequestHeadersExtensions public static void AddApiRequestHeaders(this HttpRequestHeaders headerCollection) { - // TODO: Add Accept-Language header + // FIXME: Add Accept-Language header headerCollection.Accept.Add(new MediaTypeWithQualityHeaderValue(ContentType)); } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index fb5b4712..57d1fef8 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -135,7 +135,7 @@ public static async ValueTask BeginAsync( catch { // Ignore - // TODO: log that + // FIXME: log that } } diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 8e41ed67..6dd9e139 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,6 +1,6 @@ import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; -// FIXME: Switch to CryptoProxy module once available. +// TODO: Switch to CryptoProxy module once available. import { importHmacKey, computeHmacSignature } from "./hmac"; enum SIGNING_CONTEXTS { diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 1c20211f..90574b57 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -1,4 +1,4 @@ -// TODO: Use CryptoProxy once available. +// FIXME: Use CryptoProxy once available. // eslint-disable-next-line @typescript-eslint/no-explicit-any export type PublicKey = any; // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index 5b68c31e..61038ade 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -2,7 +2,7 @@ import { PrivateKey, PublicKey } from '../crypto'; export interface ProtonDriveAccount { getOwnPrimaryAddress(): Promise, - // TODO: do we want to break it down to email vs address ID methods? + // FIXME: do we want to break it down to email vs address ID methods? getOwnAddress(emailOrAddressId: string): Promise, hasProtonAccount(email: string): Promise, getPublicKeys(email: string): Promise, diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 587ae33e..504f4b14 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -175,7 +175,7 @@ export class DriveAPIService { return response; } - // TODO: add priority header + // FIXME: add priority header // u=2 for interactive (user doing action, e.g., create folder), // u=4 for normal (user secondary action, e.g., refresh children listing), // u=5 for background (e.g., upload, download) @@ -233,7 +233,7 @@ export class DriveAPIService { } if (response.status === HTTPErrorCode.TOO_MANY_REQUESTS) { - // TODO: emit event to the client + // FIXME: emit event to the client this.tooManyRequestsErrorHappened(); const timeout = parseInt(response.headers.get('retry-after') || '0', DEFAULT_429_RETRY_DELAY_SECONDS); await waitSeconds(timeout); diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index ec64af25..5cb0bf01 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -34,7 +34,7 @@ export class EventsAPIService { } async getCoreEvents(eventId: string): Promise { - // TODO: Switch to v6 endpoint: DriveShareRefresh doesnt seem to be part of it. + // FIXME: Switch to v6 endpoint: DriveShareRefresh doesnt seem to be part of it. const result = await this.apiService.get(`/core/v5/events/${eventId}?NoMetaData=1`); const events: DriveEvent[] = result.DriveShareRefresh?.Action === 2 ? [ { diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index f77db17f..f79e4189 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -32,7 +32,7 @@ export class DriveEventsService { this.apiService = new EventsAPIService(apiService); this.cache = new EventsCache(driveEntitiesCache); - // TODO: Allow to pass own core events manager from the public interface. + // FIXME: Allow to pass own core events manager from the public interface. this.coreEvents = new CoreEventManager(this.logger, this.apiService, this.cache); this.volumesEvents = {}; } @@ -76,7 +76,7 @@ export class DriveEventsService { const volumeEvents = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId, isOwnVolume); this.volumesEvents[volumeId] = volumeEvents; - // FIXME: Use dynamic algorithm to determine polling interval for non-own volumes. + // TODO: Use dynamic algorithm to determine polling interval for non-own volumes. volumeEvents.setPollingInterval(isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL); if (this.subscribedToRemoteDataUpdates) { await volumeEvents.startSubscription(); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index b18f6e77..39ef5cdc 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -215,7 +215,7 @@ export class NodeAPIService { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); - // FIXME: remove `as` when backend fixes OpenAPI schema. + // TODO: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } @@ -231,7 +231,7 @@ export class NodeAPIService { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); - // FIXME: remove `as` when backend fixes OpenAPI schema. + // TODO: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } @@ -247,7 +247,7 @@ export class NodeAPIService { LinkIDs: nodeIds.map(({ nodeId }) => nodeId), }, signal); - // FIXME: remove `as` when backend fixes OpenAPI schema. + // TODO: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } @@ -435,9 +435,9 @@ function transformRevisionResponse( function transformThumbnail(volumeId: string, nodeId: string, thumbnail: { ThumbnailID: string | null, Type: 1 | 2 | 3}): Thumbnail { return { - // FIXME: Legacy thumbnails didn't have ID but we don't have them anymore. Remove typing once API doc is updated. + // TODO: Legacy thumbnails didn't have ID but we don't have them anymore. Remove typing once API doc is updated. uid: makeNodeThumbnailUid(volumeId, nodeId, thumbnail.ThumbnailID as string), - // FIXME: We don't support any other thumbnail type yet. + // TODO: We don't support any other thumbnail type yet. type: thumbnail.Type as 1 | 2, } } diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 36fbccf9..12183891 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -108,7 +108,7 @@ export class NodesCache { childrenCacheUids.reverse(); await this.driveCache.removeEntities(childrenCacheUids); } catch (error: unknown) { - // TODO: Should we throw here to the client? + // FIXME: Should we throw here to the client? this.logger.error(`Failed to remove children from the cache`, error); } } diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index 1194e163..cc1e338e 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -86,7 +86,7 @@ export function generateFileExtendedAttributes(claimedModificationTime?: Date): if (!claimedModificationTime) { return undefined; } - // TODO: Add support for other attributes + // FIXME: Add support for other attributes return JSON.stringify({ Common: { ModificationTime: dateToIsoString(claimedModificationTime), diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 7456aff2..6233f4e5 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -40,7 +40,7 @@ export function initNodesModule( const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); const nodesAccess = new NodesAccess(telemetry.getLogger('nodes'), api, cache, cryptoCache, cryptoService, sharesService); const nodesEvents = new NodesEvents(telemetry.getLogger('nodes-events'), driveEvents, cache, nodesAccess); - // TODO: Events are sent to the client once event is received from API + // FIXME: Events are sent to the client once event is received from API // If change is done locally, it will take a time to show up if client // is waiting with UI update to events. Thus we need to emit events // right away. diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 447ed745..fbef74ed 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -147,7 +147,7 @@ export class NodesManagement { encryptedName: encryptedCrypto.encryptedName, nameSignatureEmail: encryptedCrypto.nameSignatureEmail, hash: encryptedCrypto.hash, - // TODO: content hash + // FIXME: content hash } ); const newNode: DecryptedNode = { diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index f41e9a91..bf939515 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -116,7 +116,7 @@ export class SharesCryptoService { } const fromBefore2024 = share.creationTime ? share.creationTime < new Date('2024-01-01') : undefined; - const addressMatchingDefaultShare = undefined; // TODO: check if claimed author matches default share + const addressMatchingDefaultShare = undefined; // FIXME: check if claimed author matches default share this.logger.error(`Failed to verify share ${share.shareId} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); this.telemetry.logEvent({ diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 9fe21c81..f3ffd6a6 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -191,7 +191,7 @@ export class SharingCryptoService { * Verifies an invitation. */ async decryptInvitation(encryptedInvitation: EncryptedInvitation): Promise { - // FIXME: verify addedByEmail (current client doesnt do this) + // TODO: verify addedByEmail (current client doesnt do this) const addedByEmail: Result = resultOk(encryptedInvitation.addedByEmail); return { @@ -243,7 +243,7 @@ export class SharingCryptoService { * Verifies an external invitation. */ async decryptExternalInvitation(encryptedInvitation: EncryptedExternalInvitation): Promise { - // FIXME: verify addedByEmail (current client doesnt do this) + // TODO: verify addedByEmail (current client doesnt do this) const addedByEmail: Result = resultOk(encryptedInvitation.addedByEmail); return { @@ -260,7 +260,7 @@ export class SharingCryptoService { * Verifies a member. */ async decryptMember(encryptedMember: EncryptedMember): Promise { - // FIXME: verify addedByEmail (current client doesnt do this) + // TODO: verify addedByEmail (current client doesnt do this) const addedByEmail: Result = resultOk(encryptedMember.addedByEmail); return { @@ -275,7 +275,7 @@ export class SharingCryptoService { async encryptPublicLink(): Promise { const password = await this.generatePassword(); await this.computeKeySaltAndPassphrase(password); - // TODO: finish creation of public links + // FIXME: finish creation of public links } private async generatePassword(): Promise { diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index b0e59237..c3a6cbee 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -120,7 +120,7 @@ export class SharingAccess { await this.apiService.rejectInvitation(invitationUid); } - // TODO: return decrypted bookmarks + // FIXME: return decrypted bookmarks async* iterateSharedBookmarks(signal?: AbortSignal): AsyncGenerator { for await (const bookmark of this.apiService.iterateBookmarks(signal)) { yield bookmark.tokenId; diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index fda9e9df..2d8d6d4d 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -91,7 +91,7 @@ export class SharingManagement { } private async getPublicLink(shareId: string): Promise { - // FIXME: address ID is not set when user is not member of the share. + // TODO: address ID is not set when user is not member of the share. // Users cannot manage other shares yet (admin role is not supported // yet). But owners will stop being members and we need to keep this // working. Simple solution is to use the volume email key address ID @@ -343,7 +343,7 @@ export class SharingManagement { } } - // TODO: update nodes cache with new shareId + // FIXME: update nodes cache with new shareId private async createShare(nodeUid: string): Promise { const node = await this.nodesService.getNode(nodeUid); if (!node.parentUid) { @@ -372,7 +372,7 @@ export class SharingManagement { } } - // TODO: update nodes cache with deleted shareId + // FIXME: update nodes cache with deleted shareId private async deleteShare(shareId: string): Promise { await this.apiService.deleteShare(shareId); } @@ -440,7 +440,7 @@ export class SharingManagement { } private async convertExternalInvitationsToInternal(): Promise { - // TODO + // FIXME } private async removeMember(memberUid: string): Promise { @@ -453,12 +453,12 @@ export class SharingManagement { // eslint-disable-next-line @typescript-eslint/no-unused-vars private async shareViaLink(share: Share, options: SharePublicLinkSettings): Promise { - // TODO + // FIXME } // eslint-disable-next-line @typescript-eslint/no-unused-vars private async updateSharedLink(share: Share, options: SharePublicLinkSettings): Promise { - // TODO + // FIXME } private async removeSharedLink(publicLinkUid: string): Promise { diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 8ac8d719..5960a8e0 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -156,7 +156,7 @@ export class UploadAPIService { }): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); const result = await this.apiService.post< - // FIXME: Deprected fields but not properly marked in the types. + // TODO: Deprected fields but not properly marked in the types. Omit, PostRequestBlockUploadResponse >('drive/blocks', { @@ -202,14 +202,14 @@ export class UploadAPIService { }): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); await this.apiService.put< - // FIXME: Deprected fields but not properly marked in the types. + // TODO: Deprected fields but not properly marked in the types. Omit, PostCommitRevisionResponse >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, { ManifestSignature: options.armoredManifestSignature, SignatureAddress: options.signatureEmail, XAttr: options.armoredExtendedAttributes || null, - Photo: null, // FIXME + Photo: null, // TODO }); } @@ -240,7 +240,7 @@ export class UploadAPIService { await this.apiService.postBlockStream(url, token, formData, signal); - // FIXME: implement onProgress properly + // TODO: implement onProgress properly // One option is to use ObserverStream, same as for download. // That requires ReadableStream. FormData can be converted // to text via `new Response(formData).text()` and that can diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 55ab1f18..6e666e9c 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -83,7 +83,7 @@ export class UploadManager { base64ContentKeyPacket: generatedNodeCrypto.contentKey.encrypted.base64ContentKeyPacket, armoredContentKeyPacketSignature: generatedNodeCrypto.contentKey.encrypted.armoredContentKeyPacketSignature, signatureEmail: generatedNodeCrypto.signatureAddress.email, - // TODO: client UID + // FIXME: client UID }); return result; } catch (error: unknown) { @@ -164,7 +164,7 @@ export class UploadManager { throw Error('Backend returned unexpected hash'); } - // TODO: use client UID to ensure its own pending draft + // FIXME: use client UID to ensure its own pending draft const ownPendingHash = pendingHashes.find(({ hash }) => hash === nameHash); return { availableName: availableHash.name, diff --git a/js/sdk/src/internal/upload/queue.ts b/js/sdk/src/internal/upload/queue.ts index 850500b0..d93203ce 100644 --- a/js/sdk/src/internal/upload/queue.ts +++ b/js/sdk/src/internal/upload/queue.ts @@ -19,7 +19,7 @@ const MAX_CONCURRENT_UPLOADS = 5; export class UploadQueue { private capacity = 0; - // FIXME: use expected size to control the size of the queue + // TODO: use expected size to control the size of the queue async waitForCapacity(signal?: AbortSignal) { await waitForCondition(() => this.capacity < MAX_CONCURRENT_UPLOADS, signal); this.capacity++; diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index e04512f5..33b2f5e4 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -8,7 +8,7 @@ import { DriveEventsService } from './internal/events'; import { getConfig } from './config'; import { Telemetry } from './telemetry'; -// TODO: this is only example, on background it use drive internals, but it exposes nice interface for photos +// FIXME: this is only example, on background it use drive internals, but it exposes nice interface for photos export class ProtonDrivePhotosClient { private nodes: ReturnType; private photos: ReturnType; diff --git a/js/sdk/typings/index.d.ts b/js/sdk/typings/index.d.ts index 76fe5848..1f8961cb 100644 --- a/js/sdk/typings/index.d.ts +++ b/js/sdk/typings/index.d.ts @@ -1,2 +1,2 @@ -// TODO: Problem with importing pmcrypto - md5.js has no typing +// FIXME: Problem with importing pmcrypto - md5.js has no typing declare module '*'; From 541d9cb88dac354264385a374064ffa94df77847 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 28 Apr 2025 12:48:55 +0000 Subject: [PATCH 082/791] Implement and expose events --- js/sdk/src/errors.ts | 4 +- js/sdk/src/interface/events.ts | 18 +- js/sdk/src/interface/index.ts | 3 +- .../internal/apiService/apiService.test.ts | 41 ++- js/sdk/src/internal/apiService/apiService.ts | 25 +- js/sdk/src/internal/events/apiService.ts | 10 +- .../src/internal/events/coreEventManager.ts | 3 +- .../src/internal/events/eventManager.test.ts | 31 ++- js/sdk/src/internal/events/eventManager.ts | 30 +- js/sdk/src/internal/events/index.ts | 36 ++- .../src/internal/events/volumeEventManager.ts | 3 +- js/sdk/src/internal/nodes/events.test.ts | 153 +++++++++-- js/sdk/src/internal/nodes/events.ts | 56 +++- js/sdk/src/internal/sdkEvents.test.ts | 55 ++++ js/sdk/src/internal/sdkEvents.ts | 52 ++++ js/sdk/src/internal/sharing/events.ts | 22 +- js/sdk/src/protonDriveClient.ts | 256 +++++++++++++++++- js/sdk/src/protonDrivePhotosClient.ts | 4 +- 18 files changed, 677 insertions(+), 125 deletions(-) create mode 100644 js/sdk/src/internal/sdkEvents.test.ts create mode 100644 js/sdk/src/internal/sdkEvents.ts diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index ba76719e..3cbe9227 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -113,8 +113,8 @@ export class ServerError extends ProtonDriveError { * * Client should slow down calling SDK when this error is thrown. * - * You can be also notified about the rate limits by the `speedLimited` event. - * See `onMessage` method on the SDK class for more details. + * You can be also notified about the rate limits by the `requestsThrottled` + * event. See `onMessage` method on the SDK class for more details. */ export class RateLimitedError extends ServerError { name = 'RateLimitedError'; diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index c3d57a73..a3657904 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -1,17 +1,5 @@ import { Device } from './devices'; -import { MaybeNode, NodeOrUid } from './nodes'; - -export interface Events { - subscribeToRemoteDataUpdates(): void, - - subscribeToDevices(callback: DeviceEventCallback): () => void, - subscribeToSharedNodesByMe(callback: NodeEventCallback): () => void, - subscribeToSharedNodesWithMe(callback: NodeEventCallback): () => void, - subscribeToTrashedNodes(callback: NodeEventCallback): () => void, - subscribeToChildren(parentNodeUid: NodeOrUid, callback: NodeEventCallback): () => void, - - onMessage(eventName: SDKEvent, callback: () => void): () => void, -} +import { MaybeNode } from './nodes'; export type DeviceEventCallback = (deviceEvent: DeviceEvent) => void; export type NodeEventCallback = (nodeEvent: NodeEvent) => void; @@ -37,6 +25,6 @@ export type DeviceEvent = { export enum SDKEvent { TransfersPaused = "transfersPaused", TransfersResumed = "transfersResumed", - SpeedLimited = "speedLimited", - SpeedResumed = "speedResumed", + RequestsThrottled = "requestsThrottled", + RequestsUnthrottled = "requestsUnthrottled", } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 79556521..5d570b2b 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -11,7 +11,8 @@ export type { Author,UnverifiedAuthorError, AnonymousUser } from './author'; export type { Device, DeviceOrUid } from './devices'; export { DeviceType } from './devices'; export type { FileDownloader, DownloadController } from './download'; -export type { NodeEvent, DeviceEvent, SDKEvent, DeviceEventCallback, NodeEventCallback } from './events'; +export type { NodeEvent, DeviceEvent, DeviceEventCallback, NodeEventCallback } from './events'; +export { SDKEvent } from './events'; export type { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 4ae17b8a..7ad36639 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -1,5 +1,6 @@ -import { ProtonDriveHTTPClient } from "../../interface"; +import { ProtonDriveHTTPClient, SDKEvent } from "../../interface"; import { getMockTelemetry } from "../../tests/telemetry"; +import { SDKEvents } from "../sdkEvents"; import { DriveAPIService } from './apiService'; import { HTTPErrorCode, ErrorCode } from './errorCodes'; @@ -10,18 +11,33 @@ function generateOkResponse() { } describe("DriveAPIService", () => { + let sdkEvents: SDKEvents; let httpClient: ProtonDriveHTTPClient; let api: DriveAPIService; beforeEach(() => { void jest.runAllTimersAsync(); + // @ts-expect-error: No need to implement all methods for mocking + sdkEvents = { + transfersPaused: jest.fn(), + transfersResumed: jest.fn(), + requestsThrottled: jest.fn(), + requestsUnthrottled: jest.fn(), + } httpClient = { fetch: jest.fn(() => Promise.resolve(generateOkResponse())), }; - api = new DriveAPIService(getMockTelemetry(), httpClient, 'http://drive.proton.me', 'en'); + api = new DriveAPIService(getMockTelemetry(), sdkEvents, httpClient, 'http://drive.proton.me', 'en'); }); + function expectSDKEvents(...events: SDKEvent[]) { + expect(sdkEvents.transfersPaused).toHaveBeenCalledTimes(events.includes(SDKEvent.TransfersPaused) ? 1 : 0); + expect(sdkEvents.transfersResumed).toHaveBeenCalledTimes(events.includes(SDKEvent.TransfersResumed) ? 1 : 0); + expect(sdkEvents.requestsThrottled).toHaveBeenCalledTimes(events.includes(SDKEvent.RequestsThrottled) ? 1 : 0); + expect(sdkEvents.requestsUnthrottled).toHaveBeenCalledTimes(events.includes(SDKEvent.RequestsUnthrottled) ? 1 : 0); + } + describe("should make", () => { it("GET request", async () => { const result = await api.get('test'); @@ -52,6 +68,7 @@ describe("DriveAPIService", () => { "x-pm-drive-sdk-version": `js@${process.env.npm_package_version}`, }).entries())); expect(await request.text()).toEqual(data ? JSON.stringify(data) : ""); + expectSDKEvents(); } }); @@ -59,11 +76,13 @@ describe("DriveAPIService", () => { it("APIHTTPError on 4xx response without JSON body", async () => { httpClient.fetch = jest.fn(() => Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not found' }))); await expect(api.get('test')).rejects.toThrow(new Error('Not found')); + expectSDKEvents(); }); it("APIError on 4xx response with JSON body", async () => { httpClient.fetch = jest.fn(() => Promise.resolve(new Response(JSON.stringify({ Code: 42, Error: 'General error' }), { status: 422 }))); await expect(api.get('test')).rejects.toThrow('General error'); + expectSDKEvents(); }); }); @@ -80,6 +99,7 @@ describe("DriveAPIService", () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetch).toHaveBeenCalledTimes(3); + expectSDKEvents(); }); it("on timeout error", async () => { @@ -94,6 +114,7 @@ describe("DriveAPIService", () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetch).toHaveBeenCalledTimes(3); + expectSDKEvents(); }); it("on general error", async () => { @@ -105,6 +126,7 @@ describe("DriveAPIService", () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expectSDKEvents(); }); it("only once on general error", async () => { @@ -117,6 +139,7 @@ describe("DriveAPIService", () => { await expect(result).rejects.toThrow("Second error"); expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expectSDKEvents(); }); it("on 429 response", async () => { @@ -129,6 +152,8 @@ describe("DriveAPIService", () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetch).toHaveBeenCalledTimes(3); + // No event is sent on random 429, only if limit of too many subsequent 429s is reached. + expectSDKEvents(); }); it("on 5xx response", async () => { @@ -140,6 +165,7 @@ describe("DriveAPIService", () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expectSDKEvents(); }); it("only once on 5xx response", async () => { @@ -150,6 +176,7 @@ describe("DriveAPIService", () => { await expect(result).rejects.toThrow("Some error"); expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expectSDKEvents(); }); }); @@ -164,6 +191,13 @@ describe("DriveAPIService", () => { await expect(api.get('test')).rejects.toThrow("Too many server requests, please try again later"); expect(httpClient.fetch).toHaveBeenCalledTimes(50); + expectSDKEvents(SDKEvent.RequestsThrottled); + + // SDK will not send any requests for 60 seconds. + jest.advanceTimersByTime(90 * 1000); + httpClient.fetch = jest.fn().mockResolvedValue(generateOkResponse()); + await api.get('test'); + expect(sdkEvents.requestsThrottled).toHaveBeenCalledTimes(1); }); it("do not limit 429s when some pass", async () => { @@ -183,6 +217,7 @@ describe("DriveAPIService", () => { await expect(api.get('test')).resolves.toEqual({ Code: ErrorCode.OK }); // 20 calls * 5 retries till OK response + 1 last successful call expect(httpClient.fetch).toHaveBeenCalledTimes(101); + expectSDKEvents(); }); it("limit server errors", async () => { @@ -195,6 +230,7 @@ describe("DriveAPIService", () => { await expect(api.get('test')).rejects.toThrow("Too many server errors, please try again later"); expect(httpClient.fetch).toHaveBeenCalledTimes(10); + expectSDKEvents(); }); it("do not limit server errors when some pass", async () => { @@ -214,6 +250,7 @@ describe("DriveAPIService", () => { await expect(api.get('test')).rejects.toThrow("Some error"); // 15 erroring calls * 2 attempts + 5 successful calls expect(httpClient.fetch).toHaveBeenCalledTimes(35); + expectSDKEvents(); }); }); }); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 504f4b14..27dd0ded 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -4,6 +4,7 @@ import { VERSION } from "../../version"; import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from "../../interface"; import { AbortError, ServerError, RateLimitedError, ProtonDriveError } from '../../errors'; import { waitSeconds } from '../wait'; +import { SDKEvents } from '../sdkEvents'; import { HTTPErrorCode, isCodeOk } from './errorCodes'; import { apiErrorFactory } from './errors'; @@ -79,12 +80,19 @@ export class DriveAPIService { private logger: Logger; - constructor(private telemetry: ProtonDriveTelemetry, private httpClient: ProtonDriveHTTPClient, private baseUrl: string, private language: string) { + constructor( + private telemetry: ProtonDriveTelemetry, + private sdkEvents: SDKEvents, + private httpClient: ProtonDriveHTTPClient, + private baseUrl: string, + private language: string, + ) { + this.logger = telemetry.getLogger('api'); + this.sdkEvents = sdkEvents; this.httpClient = httpClient; this.baseUrl = baseUrl; this.language = language; this.telemetry = telemetry; - this.logger = telemetry.getLogger('api'); } async get(url: string, signal?: AbortSignal): Promise { @@ -233,7 +241,6 @@ export class DriveAPIService { } if (response.status === HTTPErrorCode.TOO_MANY_REQUESTS) { - // FIXME: emit event to the client this.tooManyRequestsErrorHappened(); const timeout = parseInt(response.headers.get('retry-after') || '0', DEFAULT_429_RETRY_DELAY_SECONDS); await waitSeconds(timeout); @@ -279,9 +286,21 @@ export class DriveAPIService { private tooManyRequestsErrorHappened() { this.subsequentTooManyRequestsCounter++; this.lastTooManyRequestsErrorAt = Date.now(); + + // Do not emit event if there is first few 429 errors, only when + // the client is very limited. This is generic event and it doesn't + // take into account that various endpoints can be rate limited + // independently. + if (this.subsequentTooManyRequestsCounter >= TOO_MANY_SUBSEQUENT_429_ERRORS) { + this.sdkEvents.requestsThrottled(); + } } private clearSubsequentTooManyRequestsError() { + if (this.subsequentTooManyRequestsCounter >= TOO_MANY_SUBSEQUENT_429_ERRORS) { + this.sdkEvents.requestsUnthrottled(); + } + this.subsequentTooManyRequestsCounter = 0; this.lastTooManyRequestsErrorAt = undefined; } diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index 5cb0bf01..56e0ea04 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -29,13 +29,13 @@ export class EventsAPIService { } async getCoreLatestEventId(): Promise { - const result = await this.apiService.get(`/core/v5/events/latest`); + const result = await this.apiService.get(`core/v4/events/latest`); return result.EventID as string; } async getCoreEvents(eventId: string): Promise { - // FIXME: Switch to v6 endpoint: DriveShareRefresh doesnt seem to be part of it. - const result = await this.apiService.get(`/core/v5/events/${eventId}?NoMetaData=1`); + // TODO: Switch to v6 endpoint: DriveShareRefresh doesnt seem to be part of it. + const result = await this.apiService.get(`core/v5/events/${eventId}`); const events: DriveEvent[] = result.DriveShareRefresh?.Action === 2 ? [ { type: DriveEventType.ShareWithMeUpdated, @@ -51,12 +51,12 @@ export class EventsAPIService { } async getVolumeLatestEventId(volumeId: string): Promise { - const result = await this.apiService.get(`/drive/volumes/${volumeId}/events/latest`); + const result = await this.apiService.get(`drive/volumes/${volumeId}/events/latest`); return result.EventID; } async getVolumeEvents(volumeId: string, eventId: string, isOwnVolume = false): Promise { - const result = await this.apiService.get(`/drive/v2/volumes/${volumeId}/events/${eventId}`); + const result = await this.apiService.get(`drive/v2/volumes/${volumeId}/events/${eventId}`); return { lastEventId: result.EventID, more: result.More, diff --git a/js/sdk/src/internal/events/coreEventManager.ts b/js/sdk/src/internal/events/coreEventManager.ts index 75a22ba0..f2bf8069 100644 --- a/js/sdk/src/internal/events/coreEventManager.ts +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -1,4 +1,5 @@ import { Logger } from "../../interface"; +import { LoggerWithPrefix } from "../../telemetry"; import { EventsAPIService } from "./apiService"; import { EventsCache } from "./cache"; import { DriveEvent, DriveEventType } from "./interface"; @@ -22,7 +23,7 @@ export class CoreEventManager { this.apiService = apiService; this.manager = new EventManager( - logger, + new LoggerWithPrefix(logger, `core`), () => this.getLastEventId(), (eventId) => this.apiService.getCoreEvents(eventId), (lastEventId) => this.cache.setLastEventId('core', { diff --git a/js/sdk/src/internal/events/eventManager.test.ts b/js/sdk/src/internal/events/eventManager.test.ts index 34cb1b76..cd25e2fa 100644 --- a/js/sdk/src/internal/events/eventManager.test.ts +++ b/js/sdk/src/internal/events/eventManager.test.ts @@ -9,7 +9,7 @@ describe("EventManager", () => { const getLastEventIdMock = jest.fn(); const getEventsMock = jest.fn(); - const eventsProcessedMock = jest.fn(); + const updateLatestEventIdMock = jest.fn(); const listenerMock = jest.fn(); beforeEach(() => { @@ -27,7 +27,7 @@ describe("EventManager", () => { getMockLogger(), getLastEventIdMock, getEventsMock, - eventsProcessedMock, + updateLatestEventIdMock, ); manager.addListener(listenerMock); }); @@ -41,7 +41,8 @@ describe("EventManager", () => { expect(getLastEventIdMock).toHaveBeenCalledTimes(1); expect(getEventsMock).toHaveBeenCalledTimes(0); expect(listenerMock).toHaveBeenCalledTimes(0); - expect(eventsProcessedMock).toHaveBeenCalledTimes(0); + expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); + expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId1'); }); it("should notify about events in the next run", async () => { @@ -49,12 +50,14 @@ describe("EventManager", () => { expect(getLastEventIdMock).toHaveBeenCalledTimes(1); expect(getEventsMock).toHaveBeenCalledTimes(0); expect(listenerMock).toHaveBeenCalledTimes(0); - expect(eventsProcessedMock).toHaveBeenCalledTimes(0); + expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); + expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId1'); + updateLatestEventIdMock.mockClear(); await jest.runOnlyPendingTimersAsync(); expect(getEventsMock).toHaveBeenCalledTimes(1); expect(listenerMock).toHaveBeenCalledTimes(1); - expect(eventsProcessedMock).toHaveBeenCalledTimes(1); - expect(eventsProcessedMock).toHaveBeenCalledWith('eventId2'); + expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); + expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId2'); }); it("should continue with more events", async () => { @@ -70,9 +73,10 @@ describe("EventManager", () => { expect(listenerMock).toHaveBeenCalledTimes(2); expect(listenerMock).toHaveBeenCalledWith(["event1", "event2"], false); expect(listenerMock).toHaveBeenCalledWith(["event3"], false); - expect(eventsProcessedMock).toHaveBeenCalledTimes(2); - expect(eventsProcessedMock).toHaveBeenCalledWith('eventId2'); - expect(eventsProcessedMock).toHaveBeenCalledWith('eventId3'); + expect(updateLatestEventIdMock).toHaveBeenCalledTimes(3); + expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId1'); + expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId2'); + expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId3'); }); it("should refresh if event does not exist", async () => { @@ -82,8 +86,8 @@ describe("EventManager", () => { expect(getLastEventIdMock).toHaveBeenCalledTimes(2); expect(listenerMock).toHaveBeenCalledTimes(1); expect(listenerMock).toHaveBeenCalledWith([], true); - expect(eventsProcessedMock).toHaveBeenCalledTimes(1); - expect(eventsProcessedMock).toHaveBeenCalledWith('eventId1'); + expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); + expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId1'); }); it("should retry on error", async () => { @@ -101,6 +105,7 @@ describe("EventManager", () => { }); }); await manager.start(); + updateLatestEventIdMock.mockClear(); // First failure. await jest.runOnlyPendingTimersAsync(); @@ -121,8 +126,8 @@ describe("EventManager", () => { await jest.runOnlyPendingTimersAsync(); expect(listenerMock).toHaveBeenCalledTimes(1); expect(listenerMock).toHaveBeenCalledWith(["event1", "event2"], false); - expect(eventsProcessedMock).toHaveBeenCalledTimes(1); - expect(eventsProcessedMock).toHaveBeenCalledWith('eventId2'); + expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); + expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId2'); }); it("should stop polling", async () => { diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts index 3c186226..a51a852f 100644 --- a/js/sdk/src/internal/events/eventManager.ts +++ b/js/sdk/src/internal/events/eventManager.ts @@ -29,6 +29,7 @@ type Listener = (events: T[], fullRefresh: boolean) => Promise; * * ```typescript * const manager = new EventManager( + * logger, * () => apiService.getLatestEventId(), * (eventId) => apiService.getEvents(eventId), * ); @@ -41,7 +42,7 @@ type Listener = (events: T[], fullRefresh: boolean) => Promise; * ``` */ export class EventManager { - private lastestEventId?: string; + private latestEventId?: string; private timeoutHandle?: ReturnType; private processPromise?: Promise; private listeners: Listener[] = []; @@ -53,11 +54,12 @@ export class EventManager { private logger: Logger, private getLatestEventId: () => Promise, private getEvents: (eventId: string) => Promise>, - private eventsProcessed: (lastEventId: string) => Promise, + private updateLatestEventId: (lastEventId: string) => Promise, ) { this.logger = logger; this.getLatestEventId = getLatestEventId; this.getEvents = getEvents; + this.updateLatestEventId = updateLatestEventId; } addListener(callback: Listener): void { @@ -65,23 +67,26 @@ export class EventManager { } async start(): Promise { + this.logger.info(`Starting event manager with polling interval ${this.pollingIntervalInSeconds} seconds`); await this.stop(); this.processPromise = this.processEvents(); } private async processEvents() { try { - if (!this.lastestEventId) { - this.lastestEventId = await this.getLatestEventId(); + if (!this.latestEventId) { + this.latestEventId = await this.getLatestEventId(); + await this.updateLatestEventId(this.latestEventId); } else { while (true) { let result; try { - result = await this.getEvents(this.lastestEventId); + result = await this.getEvents(this.latestEventId); } catch (error: unknown) { // If last event ID is not found, we need to refresh the data. // Caller is notified via standard event update with refresh flag. if (error instanceof NotFoundAPIError) { + this.logger.warn(`Last event ID not found, refreshing data`); result = { lastEventId: await this.getLatestEventId(), more: false, @@ -95,7 +100,10 @@ export class EventManager { } } await this.notifyListeners(result); - this.lastestEventId = result.lastEventId; + if (result.lastEventId !== this.latestEventId) { + await this.updateLatestEventId(result.lastEventId); + this.latestEventId = result.lastEventId; + } if (!result.more) { break; } @@ -103,7 +111,7 @@ export class EventManager { } this.retryIndex = 0; } catch (error: unknown) { - this.logger.error(`Failed to process events: ${error instanceof Error ? error.message : error} (retry ${this.retryIndex}, last event ID: ${this.lastestEventId})`); + this.logger.error(`Failed to process events: ${error instanceof Error ? error.message : error} (retry ${this.retryIndex}, last event ID: ${this.latestEventId})`); this.retryIndex++; } @@ -116,6 +124,11 @@ export class EventManager { if (result.events.length === 0 && !result.refresh) { return; } + if (!this.listeners.length) { + return; + } + + this.logger.debug(`Notifying listeners about ${result.events.length} events`); for (const listener of this.listeners) { try { @@ -125,8 +138,6 @@ export class EventManager { throw error; } } - - await this.eventsProcessed(result.lastEventId); } /** @@ -141,6 +152,7 @@ export class EventManager { async stop(): Promise { if (this.processPromise) { + this.logger.info(`Stopping event manager`); try { await this.processPromise; } catch {} diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index f79e4189..1a5b05a6 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -48,7 +48,7 @@ export class DriveEventsService { } await this.loadSubscribedVolumeEventServices(); - this.sendNumberOfVolumSubscriptionsToTelemetry(); + this.sendNumberOfVolumeSubscriptionsToTelemetry(); this.subscribedToRemoteDataUpdates = true; await this.coreEvents.startSubscription(); @@ -63,7 +63,7 @@ export class DriveEventsService { * with the polling interval depending on the type of the volume. * Own volumes are polled with highest frequency, while others are * polled with lower frequency depending on the total number of - * subsciptions. + * subscriptions. * * @param isOwnVolume - Owned volumes are polled with higher frequency. */ @@ -73,14 +73,14 @@ export class DriveEventsService { if (this.volumesEvents[volumeId]) { return; } - const volumeEvents = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId, isOwnVolume); - this.volumesEvents[volumeId] = volumeEvents; + this.logger.debug(`Creating volume event manager for volume ${volumeId}`); + const manager = this.createVolumeEventManager(volumeId, isOwnVolume); - // TODO: Use dynamic algorithm to determine polling interval for non-own volumes. - volumeEvents.setPollingInterval(isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL); + // FIXME: Use dynamic algorithm to determine polling interval for non-own volumes. + manager.setPollingInterval(isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL); if (this.subscribedToRemoteDataUpdates) { - await volumeEvents.startSubscription(); - this.sendNumberOfVolumSubscriptionsToTelemetry(); + await manager.startSubscription(); + this.sendNumberOfVolumeSubscriptionsToTelemetry(); } } @@ -88,18 +88,27 @@ export class DriveEventsService { for (const volumeId of await this.cache.getSubscribedVolumeIds()) { if (!this.volumesEvents[volumeId]) { const isOwnVolume = await this.cache.isOwnVolume(volumeId) || false; - this.volumesEvents[volumeId] = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId, isOwnVolume); + this.createVolumeEventManager(volumeId, isOwnVolume); } } } - private sendNumberOfVolumSubscriptionsToTelemetry() { + private sendNumberOfVolumeSubscriptionsToTelemetry() { this.telemetry.logEvent({ eventName: 'volumeEventsSubscriptionsChanged', numberOfVolumeSubscriptions: Object.keys(this.volumesEvents).length, }); } + private createVolumeEventManager(volumeId: string, isOwnVolume: boolean): VolumeEventManager { + const manager = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId, isOwnVolume); + for (const listener of this.listeners) { + manager.addListener(listener); + } + this.volumesEvents[volumeId] = manager; + return manager; + } + /** * Listen to the drive events. The listener will be called with the * new events as they arrive. @@ -110,6 +119,13 @@ export class DriveEventsService { * receive multiple calls. */ addListener(callback: DriveListener): void { + // Add new listener to the list for any new event manager. this.listeners.push(callback); + + // Add new listener to all existings managers. + this.coreEvents.addListener(callback); + for (const volumeEvents of Object.values(this.volumesEvents)) { + volumeEvents.addListener(callback); + } } } diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index 73a83b33..6a696fc0 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -1,4 +1,5 @@ import { Logger } from "../../interface"; +import { LoggerWithPrefix } from "../../telemetry"; import { EventsAPIService } from "./apiService"; import { EventsCache } from "./cache"; import { DriveEvent, DriveListener } from "./interface"; @@ -17,7 +18,7 @@ export class VolumeEventManager { this.volumeId = volumeId; this.manager = new EventManager( - logger, + new LoggerWithPrefix(logger, `volume ${volumeId}`), () => this.getLastEventId(), (eventId) => this.apiService.getVolumeEvents(volumeId, eventId, isOwnVolume), (lastEventId) => this.cache.setLastEventId(volumeId, { diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index 22f461c8..8863adcd 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -1,6 +1,6 @@ import { getMockLogger } from "../../tests/logger"; -import { DriveEvent, DriveEventType } from "../events"; -import { updateCacheByEvent, notifyListenersByEvent } from "./events"; +import { DriveEventsService, DriveEvent, DriveEventType } from "../events"; +import { NodesEvents, updateCacheByEvent, deleteFromCacheByEvent, notifyListenersByEvent } from "./events"; import { DecryptedNode } from "./interface"; import { NodesCache } from "./cache"; import { NodesAccess } from "./nodesAccess"; @@ -15,7 +15,11 @@ describe("updateCacheByEvent", () => { // @ts-expect-error No need to implement all methods for mocking cache = { - getNode: jest.fn(() => Promise.resolve({ uid: '123', name: { ok: true, value: 'name' } } as DecryptedNode)), + getNode: jest.fn(() => Promise.resolve({ + uid: '123', + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + } as DecryptedNode)), setNode: jest.fn(), removeNodes: jest.fn(), resetFolderChildrenLoaded: jest.fn(), @@ -99,7 +103,7 @@ describe("updateCacheByEvent", () => { } it("should remove node from cache", async () => { - await updateCacheByEvent(logger, event, cache); + await deleteFromCacheByEvent(logger, event, cache); expect(cache.removeNodes).toHaveBeenCalledTimes(1); expect(cache.removeNodes).toHaveBeenCalledWith([event.nodeUid]); @@ -118,7 +122,11 @@ describe("notifyListenersByEvent", () => { // @ts-expect-error No need to implement all methods for mocking cache = { - getNode: jest.fn(), + getNode: jest.fn(() => Promise.resolve({ + uid: '123', + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + } as DecryptedNode)), }; // @ts-expect-error No need to implement all methods for mocking nodesAccess = { @@ -127,7 +135,7 @@ describe("notifyListenersByEvent", () => { }); describe('update event', () => { - it("should notify listeners by parentNodeUid", async () => { + it("should notify listeners by parentNodeUid when there is update", async () => { const event: DriveEvent = { type: DriveEventType.NodeUpdated, nodeUid: "nodeUid", @@ -143,9 +151,29 @@ describe("notifyListenersByEvent", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); + expect(cache.getNode).toHaveBeenCalledTimes(0); + }); + + it("should notify listeners by parentNodeUid when it is moved to another parent", async () => { + const event: DriveEvent = { + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "newParentUid", + isTrashed: false, + isShared: false, + isOwnVolume: true, + }; + const listener = jest.fn(); + + await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'remove', uid: 'nodeUid' })); + expect(nodesAccess.getNode).toHaveBeenCalledTimes(0); + expect(cache.getNode).toHaveBeenCalledTimes(1); }); - it("should notify listeners by isTrashed", async () => { + it("should notify listeners by isTrashed when there is update", async () => { const event: DriveEvent = { type: DriveEventType.NodeUpdated, nodeUid: "nodeUid", @@ -161,24 +189,32 @@ describe("notifyListenersByEvent", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); + expect(cache.getNode).toHaveBeenCalledTimes(0); }); - it("should notify listeners by isShared", async () => { + it("should notify listeners by isTrashed when it is moved out of trash", async () => { + cache.getNode = jest.fn(() => Promise.resolve({ + uid: '123', + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + trashTime: new Date(), + } as DecryptedNode)); const event: DriveEvent = { type: DriveEventType.NodeUpdated, nodeUid: "nodeUid", parentNodeUid: "parentUid", isTrashed: false, - isShared: true, + isShared: false, isOwnVolume: true, }; const listener = jest.fn(); - await notifyListenersByEvent(logger, event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); + await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); - expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'remove', uid: 'nodeUid' })); + expect(nodesAccess.getNode).toHaveBeenCalledTimes(0); + expect(cache.getNode).toHaveBeenCalledTimes(1); }); it("should not notify listeners if neither condition match", async () => { @@ -232,23 +268,6 @@ describe("notifyListenersByEvent", () => { expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); }); - it("should notify listeners by isShared from cache", async () => { - cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeUid', isShared: true } as DecryptedNode)); - const event: DriveEvent = { - type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isOwnVolume: true, - }; - - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ isShared }) => !!isShared, callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); - }); - it("should not notify listeners if cache is missing node", async () => { cache.getNode = jest.fn(() => Promise.reject(new Error('Missing in the cache'))); const event: DriveEvent = { @@ -278,5 +297,81 @@ describe("notifyListenersByEvent", () => { expect(listener).toHaveBeenCalledTimes(0); }); }); +}); + +describe("NodesEvents integration", () => { + const logger = getMockLogger(); + + let eventsService: DriveEventsService; + let eventsServiceCallback; + let cache: NodesCache; + let nodesAccess: NodesAccess; + let listener: jest.Mock; + let events: NodesEvents; + + beforeEach(() => { + jest.clearAllMocks(); + // @ts-expect-error No need to implement all methods for mocking + eventsService = { + addListener: jest.fn((callback) => { + eventsServiceCallback = callback; + }), + } + // @ts-expect-error No need to implement all methods for mocking + cache = { + getNode: jest.fn(() => Promise.resolve({ + uid: 'nodeUid', + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + trashTime: new Date(), + } as DecryptedNode)), + setNode: jest.fn(), + removeNodes: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + nodesAccess = { + getNode: jest.fn(() => Promise.resolve({ + uid: 'nodeUid', + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + } as DecryptedNode)), + }; + listener = jest.fn(); + events = new NodesEvents(logger, eventsService, cache, nodesAccess); + events.subscribeToTrashedNodes(listener); + }); + + it("should send remove to trash listener when node is restored from trash", async () => { + await eventsServiceCallback!([{ + type: DriveEventType.NodeUpdated, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + isOwnVolume: true, + } as DriveEvent]); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); + expect(cache.setNode).toHaveBeenCalledTimes(1); + expect(cache.setNode).toHaveBeenCalledWith(expect.objectContaining({ uid: 'nodeUid', isStale: true })); + }); + + it("should send remove to trash listener when node is deleted", async () => { + await eventsServiceCallback!([{ + type: DriveEventType.NodeDeleted, + nodeUid: "nodeUid", + parentNodeUid: "parentUid", + isTrashed: false, + isShared: false, + isOwnVolume: true, + } as DriveEvent]); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); + expect(cache.setNode).toHaveBeenCalledTimes(0); + expect(cache.removeNodes).toHaveBeenCalledTimes(1); + expect(cache.removeNodes).toHaveBeenCalledWith(['nodeUid']); + }); }); diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 41a9e603..3bc53318 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -25,9 +25,8 @@ type Listeners = { * This must come from the API response to volume events. */ type NodeEventInfo = { - parentNodeUid: string, + parentNodeUid?: string, isTrashed?: boolean, - isShared?: boolean, } /** @@ -49,23 +48,36 @@ export class NodesEvents { } for (const event of events) { - await updateCacheByEvent(logger, event, cache); - } - }); - - events.addListener(async (events) => { - for (const event of events) { - await notifyListenersByEvent(logger, event, this.listeners, cache, nodesAccess); + try { + await updateCacheByEvent(logger, event, cache); + } catch (error: unknown) { + logger.error(`Failed to update cache`, error); + } + try { + await notifyListenersByEvent(logger, event, this.listeners, cache, nodesAccess); + } catch (error: unknown) { + logger.error(`Failed to notifiy listeners`, error); + } + // Delete must come last as it will remove the node from the cache + // and we need to first know local status of the node to properly + // notify the listeners. + await deleteFromCacheByEvent(logger, event, cache); } }); } subscribeToTrashedNodes(callback: NodeEventCallback) { this.listeners.push({ condition: ({ isTrashed }) => isTrashed || false, callback }); + return () => { + this.listeners = this.listeners.filter(listener => listener.callback !== callback); + } } subscribeToChildren(parentNodeUid: string, callback: NodeEventCallback) { this.listeners.push({ condition: ({ parentNodeUid: parent }) => parent === parentNodeUid, callback }); + return () => { + this.listeners = this.listeners.filter(listener => listener.callback !== callback); + } } } @@ -136,6 +148,13 @@ export async function updateCacheByEvent(logger: Logger, event: DriveEvent, cach } } } +} + +/** + * For given event, delete the node from the cache if it is + * deleted. + */ +export async function deleteFromCacheByEvent(logger: Logger, event: DriveEvent, cache: NodesCache) { if (event.type === DriveEventType.NodeDeleted) { // removeNodes can fail removing children. // We do not want to stop processing other events in such @@ -164,8 +183,14 @@ export async function updateCacheByEvent(logger: Logger, event: DriveEvent, cach * @throws Only if the client's callback throws. */ export async function notifyListenersByEvent(logger: Logger, event: DriveEvent, listeners: Listeners, cache: NodesCache, nodesAccess: NodesAccess) { - if (event.type === DriveEventType.NodeCreated || event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) { - const subscribedListeners = listeners.filter(({ condition }) => condition(event)); + if (event.type === DriveEventType.ShareWithMeUpdated) { + return; + } + + const subscribedListeners = listeners.filter(({ condition }) => condition(event)); + const eventMatchingCondition = subscribedListeners.length > 0; + + if ([DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.NodeUpdatedMetadata].includes(event.type) && eventMatchingCondition) { if (subscribedListeners.length) { let node; try { @@ -178,17 +203,20 @@ export async function notifyListenersByEvent(logger: Logger, event: DriveEvent, } } - if (event.type === DriveEventType.NodeDeleted) { + if ( + ((event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) && !eventMatchingCondition) + || event.type === DriveEventType.NodeDeleted + ) { let node: DecryptedNode; try { node = await cache.getNode(event.nodeUid); } catch {} const subscribedListeners = listeners.filter(({ condition }) => condition({ - isShared: node?.isShared || false, + parentNodeUid: node?.parentUid, isTrashed: !!node?.trashTime || false, - ...event, })); + if (subscribedListeners.length) { subscribedListeners.forEach(({ callback }) => callback({ type: 'remove', uid: event.nodeUid })); } diff --git a/js/sdk/src/internal/sdkEvents.test.ts b/js/sdk/src/internal/sdkEvents.test.ts new file mode 100644 index 00000000..63db5f4a --- /dev/null +++ b/js/sdk/src/internal/sdkEvents.test.ts @@ -0,0 +1,55 @@ +import { SDKEvent } from "../interface"; +import { SDKEvents } from "./sdkEvents"; + +describe("SDKEvents", () => { + let sdkEvents: SDKEvents; + let logger: { debug: jest.Mock }; + + beforeEach(() => { + logger = { debug: jest.fn() }; + sdkEvents = new SDKEvents({ getLogger: () => logger } as any); + }); + + it("should log when no listeners are present for an event", () => { + sdkEvents.requestsThrottled(); + + expect(logger.debug).toHaveBeenCalledWith("No listeners for event: requestsThrottled"); + }); + + it("should emit an event to its listeners", () => { + const requestsThrottledListener = jest.fn(); + sdkEvents.addListener(SDKEvent.RequestsThrottled, requestsThrottledListener); + const requestsUnthrottledListener = jest.fn(); + sdkEvents.addListener(SDKEvent.RequestsUnthrottled, requestsUnthrottledListener); + + sdkEvents.requestsThrottled(); + + expect(requestsThrottledListener).toHaveBeenCalled(); + expect(requestsUnthrottledListener).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith("Emitting event: requestsThrottled"); + }); + + it("should emit an event to multiple listeners", () => { + const requestsThrottledListener1 = jest.fn(); + const requestsThrottledListener2 = jest.fn(); + sdkEvents.addListener(SDKEvent.RequestsThrottled, requestsThrottledListener1); + sdkEvents.addListener(SDKEvent.RequestsThrottled, requestsThrottledListener2); + + sdkEvents.requestsThrottled(); + + expect(requestsThrottledListener1).toHaveBeenCalled(); + expect(requestsThrottledListener2).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith("Emitting event: requestsThrottled"); + }); + + it("should not emit after unsubsribe", () => { + const callback = jest.fn(); + const unsubscribe = sdkEvents.addListener(SDKEvent.RequestsThrottled, callback); + + sdkEvents.requestsThrottled(); + unsubscribe(); + sdkEvents.requestsThrottled(); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/sdk/src/internal/sdkEvents.ts b/js/sdk/src/internal/sdkEvents.ts new file mode 100644 index 00000000..65ede84c --- /dev/null +++ b/js/sdk/src/internal/sdkEvents.ts @@ -0,0 +1,52 @@ +import { ProtonDriveTelemetry, Logger, SDKEvent } from "../interface"; + +export class SDKEvents { + private logger: Logger; + private listeners: Map void)[]> = new Map(); + + constructor(telemetry: ProtonDriveTelemetry) { + this.logger = telemetry.getLogger('sdk-events'); + } + + addListener(eventName: SDKEvent, callback: () => void): () => void { + this.listeners.set(eventName, [ + ...(this.listeners.get(eventName) || []), + callback, + ]); + + return () => { + this.listeners.set( + eventName, + this.listeners.get(eventName)?.filter((cb) => cb !== callback) || [] + ); + } + } + + transfersPaused(): void { + this.emit(SDKEvent.TransfersPaused); + } + + transfersResumed(): void { + this.emit(SDKEvent.TransfersResumed); + } + + requestsThrottled(): void { + this.emit(SDKEvent.RequestsThrottled); + } + + requestsUnthrottled(): void { + this.emit(SDKEvent.RequestsUnthrottled); + } + + private emit(eventName: SDKEvent): void { + if (!this.listeners.get(eventName)?.length) { + this.logger.debug(`No listeners for event: ${eventName}`); + return; + } + + this.logger.debug(`Emitting event: ${eventName}`); + this.listeners + .get(eventName) + ?.forEach((callback) => callback()); + } +} diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index ab99f16d..f5bbcd0f 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -45,10 +45,16 @@ export class SharingEvents { subscribeToSharedNodesByMe(callback: NodeEventCallback) { this.listeners.push({ type: SharingType.SharedByMe, callback }); + return () => { + this.listeners = this.listeners.filter(listener => listener.callback !== callback); + } } subscribeToSharedNodesWithMe(callback: NodeEventCallback) { this.listeners.push({ type: SharingType.sharedWithMe, callback }); + return () => { + this.listeners = this.listeners.filter(listener => listener.callback !== callback); + } } } @@ -79,14 +85,16 @@ export async function handleSharedByMeNodes(logger: Logger, event: DriveEvent, c } catch (error: unknown) { logger.error(`Skipping shared by me node cache update`, error); } - let node; - try { - node = await nodesService.getNode(event.nodeUid); - } catch (error: unknown) { - logger.error(`Skipping shared by me node update event to listener`, error); - return; + if (subscribedListeners.length) { + let node; + try { + node = await nodesService.getNode(event.nodeUid); + } catch (error: unknown) { + logger.error(`Skipping shared by me node update event to listener`, error); + return; + } + subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node: convertInternalNode(node) })); } - subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node: convertInternalNode(node) })); } if ( diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 5679bf8c..95e3a912 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -20,6 +20,9 @@ import { Fileuploader, ThumbnailType, ThumbnailResult, + SDKEvent, + DeviceEventCallback, + NodeEventCallback, } from './interface'; import { DriveCrypto } from './crypto'; import { DriveAPIService } from './internal/apiService'; @@ -29,10 +32,12 @@ import { initSharingModule } from './internal/sharing'; import { initDownloadModule } from './internal/download'; import { initUploadModule } from './internal/upload'; import { DriveEventsService } from './internal/events'; +import { SDKEvents } from './internal/sdkEvents'; import { getConfig } from './config'; -import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator, convertInternalMissingNodeIterator } from './transformers'; +import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator, convertInternalMissingNodeIterator, convertInternalNode } from './transformers'; import { Telemetry } from './telemetry'; import { initDevicesModule } from './internal/devices'; +import { splitNodeUid } from './internal/uids'; /** * ProtonDriveClient is the main interface for the ProtonDrive SDK. @@ -43,6 +48,9 @@ import { initDevicesModule } from './internal/devices'; */ export class ProtonDriveClient { private logger: Logger; + private sdkEvents: SDKEvents; + private events: DriveEventsService; + private shares: ReturnType; private nodes: ReturnType; private sharing: ReturnType; private download: ReturnType; @@ -64,15 +72,215 @@ export class ProtonDriveClient { this.logger = telemetry.getLogger('interface'); const fullConfig = getConfig(config); + this.sdkEvents = new SDKEvents(telemetry); const cryptoModule = new DriveCrypto(openPGPCryptoModule); - const apiService = new DriveAPIService(telemetry, httpClient, fullConfig.baseUrl, fullConfig.language); - const events = new DriveEventsService(telemetry, apiService, entitiesCache); - const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); - this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); - this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, events, shares, this.nodes.access); - this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, shares, this.nodes.access, this.nodes.revisions); - this.upload = initUploadModule(telemetry, apiService, cryptoModule, shares, this.nodes.access); - this.devices = initDevicesModule(telemetry, apiService, cryptoModule, shares, this.nodes.access, this.nodes.management); + const apiService = new DriveAPIService(telemetry, this.sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); + this.events = new DriveEventsService(telemetry, apiService, entitiesCache); + this.shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); + this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, this.events, this.shares); + this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, this.events, this.shares, this.nodes.access); + this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.shares, this.nodes.access, this.nodes.revisions); + this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access); + this.devices = initDevicesModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.management); + } + + /** + * Subscribes to the general SDK events. + * + * This is not connected to the remote data updates. For that, use + * and see `subscribeToRemoteDataUpdates`. + * + * @param eventName - SDK event name. + * @param callback - Callback to be called when the event is emitted. + * @returns Callback to unsubscribe from the event. + */ + onMessage(eventName: SDKEvent, callback: () => void): () => void { + this.logger.debug(`Subscribing to event ${eventName}`); + return this.sdkEvents.addListener(eventName, callback); + } + + /** + * Subscribes to the remote data updates. + * + * By default, SDK doesn't subscribe to remote data updates. If you + * cache the data locally, you need to call this method so the SDK + * keeps the local cache in sync with the remote data. + * + * Only one instance of the SDK should subscribe to remote data updates. + * + * Once subscribed, the SDK will poll for events for core user events and + * for own data at minimum. Updates to nodes from other users are polled + * with lower frequency depending on the number of subscriptions, and only + * after accessing them for the first time via `iterateSharedNodesWithMe`. + */ + async subscribeToRemoteDataUpdates(): Promise { + this.logger.debug('Subscribing to remote data updates'); + await this.events.subscribeToRemoteDataUpdates(); + + const { volumeId } = await this.shares.getMyFilesIDs(); + await this.events.listenToVolume(volumeId, true); + } + + /** + * Subscribe to updates of the devices. + * + * Clients should subscribe to this before beginning to list devices + * to ensure that updates are reflected once a device is in the cache. + * Subscribing before listing is also required to ensure that devices + * that are created during the listing will be recognized. + * + * ```typescript + * const unsubscribe = sdk.subscribeToDevices((event) => { + * if (event.type === 'update') { + * // Update the device in the UI + * } else if (event.type === 'remove') { + * // Remove the device from the UI + * } + * }); + * + * const devices = await Array.fromAsync(sdk.iterateDevices()); + * // Render the devices in the UI + * + * // Unsubscribe from the updates when the component is unmounted + * unsubscribe(); + * ``` + * + * @param callback - Callback to be called when the event is emitted. + * @returns Callback to unsubscribe from the event. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + subscribeToDevices(callback: DeviceEventCallback): () => void { + this.logger.debug('Subscribing to devices'); + throw new Error('Method not implemented'); + } + + /** + * Subscribe to updates of the children of the given parent node. + * + * Clients should subscribe to this before beginning to list children + * to ensure that updates are reflected once a node is in the cache. + * Subscribing before listing is also required to ensure that nodes + * that are created during the listing will be recognized. + * + * ```typescript + * const unsubscribe = sdk.subscribeToChildren(parentNodeUid, (event) => { + * if (event.type === 'update') { + * // Update the node in the UI + * } else if (event.type === 'remove') { + * // Remove the node from the UI + * } + * }); + * + * const nodes = await Array.fromAsync(sdk.iterateChildren(parentNodeUid)); + * // Render the nodes in the UI + * + * // Unsubscribe from the updates when the component is unmounted + * unsubscribe(); + * ``` + * + * @param parentNodeUid - Node entity or its UID string. + * @param callback - Callback to be called when the event is emitted. + * @returns Callback to unsubscribe from the event. + */ + subscribeToFolder(parentNodeUid: NodeOrUid, callback: NodeEventCallback): () => void { + this.logger.debug(`Subscribing to children of ${getUid(parentNodeUid)}`); + return this.nodes.events.subscribeToChildren(getUid(parentNodeUid), callback); + } + + /** + * Subscribe to updates of the trashed nodes. + * + * Clients should subscribe to this before beginning to list trashed + * nodes to ensure that updates are reflected once a node is in the cache. + * Subscribing before listing is also required to ensure that nodes + * that are trashed during the listing will be recognized. + * + * ```typescript + * const unsubscribe = sdk.subscribeToTrashedNodes((event) => { + * if (event.type === 'update') { + * // Update the node in the UI + * } else if (event.type === 'remove') { + * // Remove the node from the UI + * } + * }); + * + * const nodes = await Array.fromAsync(sdk.iterateTrashedNodes()); + * // Render the nodes in the UI + * + * // Unsubscribe from the updates when the component is unmounted + * unsubscribe(); + * ``` + * + * @param callback - Callback to be called when the event is emitted. + * @returns Callback to unsubscribe from the event. + */ + subscribeToTrashedNodes(callback: NodeEventCallback): () => void { + this.logger.debug('Subscribing to trashed nodes'); + return this.nodes.events.subscribeToTrashedNodes(callback); + } + + /** + * Subscribe to updates of the nodes shared by the user. + * + * Clients should subscribe to this before beginning to list shared + * nodes to ensure that updates are reflected once a node is in the cache. + * Subscribing before listing is also required to ensure that nodes + * that are shared during the listing will be recognized. + * + * ```typescript + * const unsubscribe = sdk.subscribeToSharedNodesByMe((event) => { + * if (event.type === 'update') { + * // Update the node in the UI + * } else if (event.type === 'remove') { + * // Remove the node from the UI + * } + * }); + * + * const nodes = await Array.fromAsync(sdk.iterateSharedNodes()); + * // Render the nodes in the UI + * + * // Unsubscribe from the updates when the component is unmounted + * unsubscribe(); + * ``` + * + * @param callback - Callback to be called when the event is emitted. + * @returns Callback to unsubscribe from the event. + */ + subscribeToSharedNodesByMe(callback: NodeEventCallback): () => void { + this.logger.debug('Subscribing to shared nodes by me'); + return this.sharing.events.subscribeToSharedNodesByMe(callback); + } + + /** + * Subscribe to updates of the nodes shared with the user. + * + * Clients should subscribe to this before beginning to list shared + * nodes to ensure that updates are reflected once a node is in the cache. + * Subscribing before listing is also required to ensure that nodes + * that are shared during the listing will be recognized. + * + * ```typescript + * const unsubscribe = sdk.subscribeToSharedNodesWithMe((event) => { + * if (event.type === 'update') { + * // Update the node in the UI + * } else if (event.type === 'remove') { + * // Remove the node from the UI + * } + * }); + * + * const nodes = await Array.fromAsync(sdk.iterateSharedNodesWithMe()); + * // Render the nodes in the UI + * + * // Unsubscribe from the updates when the component is unmounted + * unsubscribe(); + * ``` + * + * @param callback - Callback to be called when the event is emitted. + * @returns Callback to unsubscribe from the event. + */ + subscribeToSharedNodesWithMe(callback: NodeEventCallback): () => void { + this.logger.debug('Subscribing to shared nodes with me'); + return this.sharing.events.subscribeToSharedNodesWithMe(callback); } /** @@ -106,6 +314,8 @@ export class ProtonDriveClient { * * The output is not sorted and the order of the children is not guaranteed. * + * You can listen to updates via `subscribeToChildren`. + * * @param parentNodeUid - Node entity or its UID string. * @param signal - Signal to abort the operation. * @returns An async generator of the children of the given parent node. @@ -123,6 +333,8 @@ export class ProtonDriveClient { * * The output is not sorted and the order of the trashed nodes is not guaranteed. * + * You can listen to updates via `subscribeToTrashedNodes`. + * * @param signal - Signal to abort the operation. * @returns An async generator of the trashed nodes. */ @@ -301,6 +513,8 @@ export class ProtonDriveClient { * Iterates the nodes shared by the user. * * The output is not sorted and the order of the shared nodes is not guaranteed. + * + * You can listen to updates via `subscribeToSharedNodesByMe`. * * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. @@ -314,15 +528,31 @@ export class ProtonDriveClient { * Iterates the nodes shared with the user. * * The output is not sorted and the order of the shared nodes is not guaranteed. + * + * At the end of the iteration, if `subscribeToRemoteDataUpdates` was called, + * the SDK will listen to updates for the shared nodes to keep the local cache + * in sync with the remote data. + * + * You can listen to updates via `subscribeToSharedNodesWithMe`. * * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. */ async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes with me'); - yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodesWithMe(signal)); + + const uniqueVolumeIds = new Set(); + for await (const node of this.sharing.access.iterateSharedNodesWithMe(signal)) { + yield convertInternalNode(node); + const { volumeId } = splitNodeUid(node.uid); + uniqueVolumeIds.add(volumeId); + } + + for (const volumeId of uniqueVolumeIds) { + await this.events.listenToVolume(volumeId, false); + } } - + /** * Leave shared node that was previously shared with the user. * @@ -536,7 +766,9 @@ export class ProtonDriveClient { * Iterates the devices of the user. * * The output is not sorted and the order of the devices is not guaranteed. - * + * + * You can listen to updates via `subscribeToDevices`. + * * @returns An async generator of devices. */ async* iterateDevices(signal?: AbortSignal): AsyncGenerator { diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 33b2f5e4..a68bb3df 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -5,6 +5,7 @@ import { initSharesModule } from './internal/shares'; import { initNodesModule } from './internal/nodes'; import { initPhotosModule } from './internal/photos'; import { DriveEventsService } from './internal/events'; +import { SDKEvents } from './internal/sdkEvents'; import { getConfig } from './config'; import { Telemetry } from './telemetry'; @@ -27,8 +28,9 @@ export class ProtonDrivePhotosClient { } const fullConfig = getConfig(config); + const sdkEvents = new SDKEvents(telemetry); const cryptoModule = new DriveCrypto(openPGPCryptoModule); - const apiService = new DriveAPIService(telemetry, httpClient, fullConfig.baseUrl, fullConfig.language); + const apiService = new DriveAPIService(telemetry, sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); const events = new DriveEventsService(telemetry, apiService, entitiesCache); const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); From 7baa312d0f3ece91bbe5867d200801766953268f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 May 2025 07:59:48 +0000 Subject: [PATCH 083/791] Implement file download --- .gitignore | 4 + .../Api/Files/ActiveRevisionDto.cs | 3 +- .../Api/Files/BlockListingRevisionDto.cs | 6 + .../Api/Files/BlockUploadRequestParameters.cs | 1 - .../Api/Files/FileCreationIdentifiers.cs | 1 - .../Api/Files/FilesApiClient.cs | 17 +- .../Api/Files/IFilesApiClient.cs | 10 +- .../Files/IRevisionVerificationApiClient.cs | 1 - .../Api/Files/RevisionConflict.cs | 1 - .../Proton.Drive.Sdk/Api/Files/RevisionDto.cs | 2 +- .../{Nodes => Api/Files}/RevisionId.cs | 4 +- .../Api/Files/RevisionResponse.cs | 8 + .../Api/Files/ThumbnailDto.cs | 2 +- .../Api/Files/ThumbnailDtoV2.cs | 17 + .../Nodes/Download/BlockDownloader.cs | 78 +++++ .../Nodes/Download/DownloadController.cs | 18 + .../FileContentsDecryptionException.cs | 23 ++ .../Nodes/Download/FileDownloader.cs | 49 +++ .../NodeMetadataDecryptionException.cs | 26 ++ .../Nodes/Download/NodeMetadataPart.cs | 13 + .../Nodes/Download/RevisionReader.cs | 328 ++++++++++++++++++ .../Nodes/InvalidNodeTypeException.cs | 2 +- .../src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 2 +- .../Nodes/RevisionOperations.cs | 27 +- .../src/Proton.Drive.Sdk/Nodes/RevisionUid.cs | 3 +- .../NodeKeyAndSessionKeyMismatchException.cs | 2 +- ...essionKeyAndDataPacketMismatchException.cs | 2 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 15 +- .../Proton.Drive.Sdk/ProtonDriveException.cs | 18 + .../DriveApiSerializerContext.cs | 1 + .../Proton.Sdk/Addresses/AddressOperations.cs | 15 +- 32 files changed, 670 insertions(+), 31 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs rename cs/sdk/src/Proton.Drive.Sdk/{Nodes => Api/Files}/RevisionId.cs (81%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileContentsDecryptionException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataDecryptionException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataPart.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ProtonDriveException.cs diff --git a/.gitignore b/.gitignore index 7ac9e425..76be1749 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# macOS .DS_Store # Docs @@ -15,6 +16,9 @@ cache*.sqlite *.log *.bun-build +# VS Code +.vs + # Tests tests/storage diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs index f194028f..484e31e0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Cryptography; using Proton.Sdk.Serialization; @@ -22,7 +21,7 @@ internal sealed class ActiveRevisionDto [JsonPropertyName("XAttr")] public PgpArmoredMessage? ExtendedAttributes { get; init; } - public IReadOnlyList? Thumbnails { get; init; } + public IReadOnlyList? Thumbnails { get; init; } [JsonPropertyName("SignatureEmail")] public string? SignatureEmailAddress { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs new file mode 100644 index 00000000..2c674b4e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockListingRevisionDto : RevisionDto +{ + public required IReadOnlyList Blocks { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs index 7b89136f..7d5980b1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Addresses; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs index 0c3e480d..da3a9545 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Nodes; namespace Proton.Drive.Sdk.Api.Files; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index d718afcb..2cf42f35 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -1,5 +1,4 @@ using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Api; @@ -53,4 +52,20 @@ public async ValueTask GetVerificationInputAsync .Expecting(DriveApiSerializerContext.Default.BlockVerificationInputResponse) .GetAsync($"v2/volumes/{volumeId}/links/{linkId}/revisions/{revisionId}/verification", cancellationToken).ConfigureAwait(false); } + + public async ValueTask GetRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + int fromBlockIndex, + int pageSize, + bool withoutBlockUrls, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.RevisionResponse) + .GetAsync( + $"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}?FromBlockIndex={fromBlockIndex}&PageSize={pageSize}&NoBlockUrls={(withoutBlockUrls ? 1 : 0)}", + cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs index 6ebcc241..2d4e8c8c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -1,5 +1,4 @@ using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Api; @@ -17,4 +16,13 @@ ValueTask UpdateRevisionAsync( RevisionId revisionId, RevisionUpdateParameters parameters, CancellationToken cancellationToken); + + public ValueTask GetRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + int fromBlockIndex, + int pageSize, + bool withoutBlockUrls, + CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs index 5101aba7..7399e9a2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs @@ -1,5 +1,4 @@ using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Volumes; namespace Proton.Drive.Sdk.Api.Files; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs index 9ba225bc..3d506723 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Nodes; namespace Proton.Drive.Sdk.Api.Files; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs index f9536412..daa50d9b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal sealed class RevisionDto +internal class RevisionDto { [JsonPropertyName("ID")] public required string Id { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs similarity index 81% rename from cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionId.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs index a53e7621..94b97259 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionId.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs @@ -1,10 +1,10 @@ using System.Text.Json.Serialization; using Proton.Sdk.Serialization; -namespace Proton.Drive.Sdk.Nodes; +namespace Proton.Drive.Sdk.Api.Files; [JsonConverter(typeof(StrongIdJsonConverter))] -public readonly record struct RevisionId : IStrongId +internal readonly record struct RevisionId : IStrongId { private readonly string? _value; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs new file mode 100644 index 00000000..5367d4cc --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionResponse : ApiResponse +{ + public required BlockListingRevisionDto Revision { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs index 56644c5e..7d68a4d1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs @@ -12,6 +12,6 @@ internal sealed class ThumbnailDto [JsonPropertyName("Hash")] public required ReadOnlyMemory HashDigest { get; init; } - [JsonPropertyName("EncryptedSize")] + [JsonPropertyName("Size")] public required int StorageQuotaUsage { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs new file mode 100644 index 00000000..eee9cc3a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailDtoV2 +{ + [JsonPropertyName("ThumbnailID")] + public string? Id { get; init; } + + public required ThumbnailType Type { get; init; } + + [JsonPropertyName("Hash")] + public required ReadOnlyMemory HashDigest { get; init; } + + [JsonPropertyName("EncryptedSize")] + public required int StorageQuotaUsage { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs new file mode 100644 index 00000000..f901356b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes.Download; + +internal sealed class BlockDownloader +{ + private readonly ProtonDriveClient _client; + + internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) + { + _client = client; + MaxDegreeOfParallelism = maxDegreeOfParallelism; + BlockSemaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism); + } + + public int MaxDegreeOfParallelism { get; } + + public SemaphoreSlim FileSemaphore { get; } = new(1, 1); + public SemaphoreSlim BlockSemaphore { get; } + + public async ValueTask<(ReadOnlyMemory HashDigest, PgpVerificationStatus VerificationStatus)> DownloadAsync( + string url, + PgpSessionKey contentKey, + ReadOnlyMemory? encryptedSignature, + PgpPrivateKey signatureDecryptionKey, + PgpKeyRing verificationKeyRing, + Stream outputStream, + CancellationToken cancellationToken) + { + using var sha256 = SHA256.Create(); + + var blobStream = await _client.Api.Storage.GetBlobStreamAsync(url, cancellationToken).ConfigureAwait(false); + + var hashingStream = new CryptoStream(blobStream, sha256, CryptoStreamMode.Read); + + // TODO: use array pool for decrypted signature + ArraySegment? signature; + + try + { + signature = encryptedSignature is not null ? (ArraySegment?)signatureDecryptionKey.Decrypt(encryptedSignature.Value.Span) : null; + } + catch (CryptographicException e) + { + throw new NodeMetadataDecryptionException(NodeMetadataPart.BlockSignature, e); + } + + PgpVerificationStatus verificationStatus; + + try + { + await using (hashingStream.ConfigureAwait(false)) + { + var decryptingStream = signature is not null + ? contentKey.OpenDecryptingAndVerifyingStream(hashingStream, signature.Value, verificationKeyRing) + : contentKey.OpenDecryptingStream(hashingStream); + + await using (decryptingStream.ConfigureAwait(false)) + { + await decryptingStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + + using var verificationResult = decryptingStream.GetVerificationResult(); + + verificationStatus = verificationResult.Status; + } + } + } + catch (CryptographicException e) + { + throw new FileContentsDecryptionException(e); + } + + sha256.TransformFinalBlock([], 0, 0); + + return (sha256.Hash, verificationStatus); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs new file mode 100644 index 00000000..9675dd10 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed class DownloadController(Task downloadTask) +{ + public Task Completion { get; } = downloadTask; + + public void Pause() + { + // FIXME + throw new NotImplementedException(); + } + + public void Resume() + { + // FIXME + throw new NotImplementedException(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileContentsDecryptionException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileContentsDecryptionException.cs new file mode 100644 index 00000000..c778a8e4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileContentsDecryptionException.cs @@ -0,0 +1,23 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed class FileContentsDecryptionException : ProtonDriveException +{ + public FileContentsDecryptionException() + { + } + + public FileContentsDecryptionException(string message) + : base(message) + { + } + + public FileContentsDecryptionException(string message, Exception innerException) + : base(message, innerException) + { + } + + public FileContentsDecryptionException(Exception innerException) + : this("Failed to decrypt file contents", innerException) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs new file mode 100644 index 00000000..5d007bce --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -0,0 +1,49 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed class FileDownloader : IDisposable +{ + private readonly ProtonDriveClient _client; + private readonly RevisionUid _revisionUid; + private volatile int _remainingNumberOfBlocksToList; + + internal FileDownloader(ProtonDriveClient client, RevisionUid revisionUid) + { + _client = client; + _revisionUid = revisionUid; + } + + public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + { + var task = DownloadToStreamAsync(contentOutputStream, onProgress, cancellationToken); + + return new DownloadController(task); + } + + public void Dispose() + { + if (_remainingNumberOfBlocksToList <= 0) + { + return; + } + + _client.BlockListingSemaphore.Release(_remainingNumberOfBlocksToList); + _remainingNumberOfBlocksToList = 0; + } + + private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + { + using var revisionReader = await RevisionOperations.OpenForReadingAsync(_client, _revisionUid, ReleaseBlockListing, cancellationToken) + .ConfigureAwait(false); + + await revisionReader.ReadAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); + } + + private void ReleaseBlockListing(int numberOfBlockListings) + { + var newRemainingNumberOfBlocks = Interlocked.Add(ref _remainingNumberOfBlocksToList, -numberOfBlockListings); + + var amountToRelease = Math.Max(newRemainingNumberOfBlocks >= 0 ? numberOfBlockListings : newRemainingNumberOfBlocks + numberOfBlockListings, 0); + + _client.BlockListingSemaphore.Release(amountToRelease); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataDecryptionException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataDecryptionException.cs new file mode 100644 index 00000000..e66ba0dd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataDecryptionException.cs @@ -0,0 +1,26 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed class NodeMetadataDecryptionException : Exception +{ + public NodeMetadataDecryptionException() + { + } + + public NodeMetadataDecryptionException(string message) + : base(message) + { + } + + public NodeMetadataDecryptionException(string message, Exception innerException) + : base(message, innerException) + { + } + + internal NodeMetadataDecryptionException(NodeMetadataPart part, Exception innerException) + : base($"Failed to decrypt node metadata: {part.ToString()}", innerException) + { + Part = part; + } + + internal NodeMetadataPart Part { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataPart.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataPart.cs new file mode 100644 index 00000000..5fad1ebb --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataPart.cs @@ -0,0 +1,13 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +internal enum NodeMetadataPart +{ + Key = 0, + Passphrase = 1, + Name = 2, + ExtendedAttributes = 3, + ContentKey = 4, + HashKey = 5, + BlockSignature = 6, + Thumbnail = 7, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs new file mode 100644 index 00000000..7db4ebd4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -0,0 +1,328 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; + +namespace Proton.Drive.Sdk.Nodes.Download; + +internal sealed class RevisionReader : IDisposable +{ + public const int BlockPageSize = 10; + public const int MinBlockIndex = 1; + + private readonly ProtonDriveClient _client; + private readonly NodeUid _fileUid; + private readonly RevisionId _revisionId; + private readonly PgpPrivateKey _fileKey; + private readonly PgpSessionKey _contentKey; + private readonly BlockListingRevisionDto _revisionDto; + private readonly Action _releaseBlockListingAction; + + private bool _semaphoreReleased; + + private long _totalProgress; + + internal RevisionReader( + ProtonDriveClient client, + RevisionUid revisionUid, + PgpPrivateKey fileKey, + PgpSessionKey contentKey, + BlockListingRevisionDto revisionDto, + Action releaseBlockListingAction) + { + _client = client; + _fileUid = revisionUid.NodeUid; + _revisionId = revisionUid.RevisionId; + _fileKey = fileKey; + _contentKey = contentKey; + _revisionDto = revisionDto; + _releaseBlockListingAction = releaseBlockListingAction; + } + + public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + { + var downloadTasks = new Queue>(_client.BlockDownloader.MaxDegreeOfParallelism); + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + await using (manifestStream) + { + if (_revisionDto.Thumbnails is { } thumbnails) + { + foreach (var sha256Digest in thumbnails.Select(x => x.HashDigest)) + { + manifestStream.Write(sha256Digest.Span); + } + } + + try + { + try + { + await foreach (var (block, _) in GetBlocksAsync(cancellationToken).ConfigureAwait(false)) + { + if (!await _client.BlockDownloader.BlockSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + if (downloadTasks.Count > 0) + { + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); + } + + await _client.BlockDownloader.BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + var downloadTask = DownloadBlockAsync(block, contentOutputStream, cancellationToken); + + downloadTasks.Enqueue(downloadTask); + } + } + finally + { + _client.BlockDownloader.FileSemaphore.Release(); + _semaphoreReleased = true; + } + + while (downloadTasks.Count > 0) + { + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken).ConfigureAwait(false); + } + } + catch when (downloadTasks.Count > 0) + { + try + { + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + finally + { + _client.BlockDownloader.BlockSemaphore.Release(downloadTasks.Count); + } + + throw; + } + + manifestStream.Seek(0, SeekOrigin.Begin); + + var manifestVerificationStatus = await VerifyManifestAsync(manifestStream, cancellationToken).ConfigureAwait(false); + + if (manifestVerificationStatus is not PgpVerificationStatus.Ok) + { + _client.Logger.LogError( + "Manifest verification failed for file with UID \"{FileUid}\": {VerificationStatus}", + _fileUid, + manifestVerificationStatus); + + throw new ProtonDriveException("File authenticity check failed"); + } + } + } + + public void Dispose() + { + if (!_semaphoreReleased) + { + _client.BlockDownloader.FileSemaphore.Release(); + } + } + + private async Task WriteNextBlockToOutputAsync( + Queue> downloadTasks, + Stream outputStream, + Stream manifestStream, + Action onProgress, + CancellationToken cancellationToken) + { + var downloadTask = downloadTasks.Dequeue(); + + try + { + var downloadResult = await downloadTask.ConfigureAwait(false); + + var downloadedStream = downloadResult.Stream; + + try + { + if (downloadResult.VerificationStatus is not PgpVerificationStatus.Ok) + { + _client.Logger.LogWarning( + "Verification failed for block #{Index} of file with UID \"{FileUid}\": {VerificationStatus}", + downloadResult.Index, + _fileUid, + downloadResult.VerificationStatus); + } + + manifestStream.Write(downloadResult.Sha256Digest.Span); + + if (downloadResult.IsIntermediateStream) + { + downloadedStream.Seek(0, SeekOrigin.Begin); + + await downloadedStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + } + + _totalProgress += downloadedStream.Length; + + onProgress(_totalProgress, _revisionDto.Size); + } + finally + { + if (downloadResult.IsIntermediateStream) + { + await downloadedStream.DisposeAsync().ConfigureAwait(false); + } + } + } + finally + { + _client.BlockDownloader.BlockSemaphore.Release(); + } + } + + private async Task DownloadBlockAsync(Block block, Stream contentOutputStream, CancellationToken cancellationToken) + { + Stream blockOutputStream; + bool isIntermediateStream; + + if (block.Index == 1) + { + blockOutputStream = contentOutputStream; + isIntermediateStream = false; + } + else + { + blockOutputStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + isIntermediateStream = true; + } + + var signatureVerificationKeyRing = !string.IsNullOrEmpty(block.SignatureEmailAddress) + ? new PgpKeyRing(await _client.Account.GetAddressPublicKeysAsync(block.SignatureEmailAddress, cancellationToken).ConfigureAwait(false)) + : new PgpKeyRing(_fileKey); + + var (hashDigest, verificationStatus) = await _client.BlockDownloader.DownloadAsync( + block.Url, + _contentKey, + block.EncryptedSignature, + _fileKey, + signatureVerificationKeyRing, + blockOutputStream, + cancellationToken).ConfigureAwait(false); + + return new BlockDownloadResult(block.Index, blockOutputStream, isIntermediateStream, hashDigest, verificationStatus); + } + + private async IAsyncEnumerable<(Block Value, bool IsLast)> GetBlocksAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + try + { + var mustTryNextPageOfBlocks = true; + var nextExpectedIndex = 1; + var outstandingBlock = default(Block); + var currentPageBlocks = new List(BlockPageSize); + + var revisionDto = _revisionDto; + + while (mustTryNextPageOfBlocks) + { + currentPageBlocks.Clear(); + + cancellationToken.ThrowIfCancellationRequested(); + + if (revisionDto.Blocks.Count == 0) + { + break; + } + + mustTryNextPageOfBlocks = revisionDto.Blocks.Count >= BlockPageSize; + + currentPageBlocks.AddRange(revisionDto.Blocks); + currentPageBlocks.Sort((a, b) => a.Index.CompareTo(b.Index)); + + var blocksExceptLast = currentPageBlocks.Take(currentPageBlocks.Count - 1); + var blocksToReturn = outstandingBlock is not null ? blocksExceptLast.Prepend(outstandingBlock) : blocksExceptLast; + + outstandingBlock = currentPageBlocks[^1]; + var lastKnownIndex = outstandingBlock.Index; + + foreach (var block in blocksToReturn) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (block.Index != nextExpectedIndex) + { + _client.Logger.LogError("Missing block #{BlockIndex} on file with UID \"{FileUid}\"", block.Index, _fileUid); + + throw new ProtonDriveException("File contents are incomplete"); + } + + ++nextExpectedIndex; + + yield return (block, false); + } + + if (mustTryNextPageOfBlocks) + { + var revisionResponse = + await _client.Api.Files.GetRevisionAsync( + _fileUid.VolumeId, + _fileUid.LinkId, + _revisionId, + lastKnownIndex + 1, + BlockPageSize, + false, + cancellationToken).ConfigureAwait(false); + + revisionDto = revisionResponse.Revision; + } + } + + if (outstandingBlock is not null) + { + cancellationToken.ThrowIfCancellationRequested(); + + yield return (outstandingBlock, true); + } + } + finally + { + _releaseBlockListingAction.Invoke(1); + } + } + + private async Task VerifyManifestAsync(Stream manifestStream, CancellationToken cancellationToken) + { + if (_revisionDto.ManifestSignature is null) + { + return PgpVerificationStatus.NotSigned; + } + + if (string.IsNullOrEmpty(_revisionDto.SignatureEmailAddress)) + { + return PgpVerificationStatus.NoVerifier; + } + + var verificationKeys = await _client.Account.GetAddressPublicKeysAsync(_revisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + + if (verificationKeys.Count == 0) + { + return PgpVerificationStatus.NoVerifier; + } + + var verificationResult = new PgpKeyRing(verificationKeys).Verify(manifestStream, _revisionDto.ManifestSignature.Value); + + return verificationResult.Status; + } + + private readonly struct BlockDownloadResult( + int index, + Stream stream, + bool isIntermediateStream, + ReadOnlyMemory sha256Digest, + PgpVerificationStatus verificationStatus) + { + public int Index { get; } = index; + public Stream Stream { get; } = stream; + public bool IsIntermediateStream { get; } = isIntermediateStream; + public ReadOnlyMemory Sha256Digest { get; } = sha256Digest; + public PgpVerificationStatus VerificationStatus { get; } = verificationStatus; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs index ef6dbba0..03a01520 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed class InvalidNodeTypeException : Exception +public sealed class InvalidNodeTypeException : ProtonDriveException { public InvalidNodeTypeException(string message) : base(message) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs index eb48c558..ca2cfc71 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs @@ -183,7 +183,7 @@ public static async ValueTask> De MediaType = file.MediaType, ActiveRevision = new Revision { - Id = file.ActiveRevision.Id, + Uid = new RevisionUid(id, file.ActiveRevision.Id), CreationTime = file.ActiveRevision.CreationTime, StorageQuotaConsumption = file.ActiveRevision.StorageQuotaConsumption, ClaimedSize = extendedAttributes?.Common?.Size, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index a9d69c0f..bcd4f424 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Nodes; public sealed class Revision { - public required RevisionId Id { get; init; } + public required RevisionUid Uid { get; init; } public required DateTime CreationTime { get; init; } public required long StorageQuotaConsumption { get; init; } public required long? ClaimedSize { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index 68695c8a..6a02cf18 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -1,4 +1,5 @@ -using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Upload; namespace Proton.Drive.Sdk.Nodes; @@ -29,4 +30,28 @@ public static async ValueTask OpenForWritingAsync( targetBlockSize, targetBlockSize * 3 / 2); } + + internal static async ValueTask OpenForReadingAsync( + ProtonDriveClient client, + RevisionUid revisionUid, + Action releaseBlockListingAction, + CancellationToken cancellationToken) + { + var fileSecrets = await FileOperations.GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + + var (fileUid, revisionId) = revisionUid; + + var revisionResponse = await client.Api.Files.GetRevisionAsync( + fileUid.VolumeId, + fileUid.LinkId, + revisionId, + RevisionReader.MinBlockIndex, + RevisionReader.BlockPageSize, + false, + cancellationToken).ConfigureAwait(false); + + await client.BlockDownloader.FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + return new RevisionReader(client, revisionUid, fileSecrets.Key, fileSecrets.ContentKey, revisionResponse.Revision, releaseBlockListingAction); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs index 4f2a785d..ded9df87 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Serialization; namespace Proton.Drive.Sdk.Nodes; @@ -18,7 +19,7 @@ internal RevisionUid(NodeUid nodeUid, RevisionId revisionId) public override string ToString() { - return $"{NodeUid.VolumeId}-{NodeUid.LinkId}~{RevisionId}"; + return $"{NodeUid}~{RevisionId}"; } static bool ICompositeUid.TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out RevisionUid? uid) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs index 4d4ece5e..7d482842 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes.Upload.Verification; -public sealed class NodeKeyAndSessionKeyMismatchException : Exception +public sealed class NodeKeyAndSessionKeyMismatchException : ProtonDriveException { public NodeKeyAndSessionKeyMismatchException(string message) : base(message) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs index 594170e6..960de69d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes.Upload.Verification; -public sealed class SessionKeyAndDataPacketMismatchException : Exception +public sealed class SessionKeyAndDataPacketMismatchException : ProtonDriveException { public SessionKeyAndDataPacketMismatchException(string message) : base(message) diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 5bb31e45..a2280eec 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -3,6 +3,7 @@ using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk; @@ -40,8 +41,11 @@ internal ProtonDriveClient(IAccountClient accountClient, IDriveApiClients apiCli var maxDegreeOfBlockProcessingParallelism = maxDegreeOfBlockTransferParallelism + Math.Min(Math.Max(maxDegreeOfBlockTransferParallelism / 2, 2), 4); - BlockUploader = new BlockUploader(this, maxDegreeOfBlockTransferParallelism); RevisionCreationSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger()); + BlockListingSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger()); + + BlockUploader = new BlockUploader(this, maxDegreeOfBlockTransferParallelism); + BlockDownloader = new BlockDownloader(this, maxDegreeOfBlockTransferParallelism); } internal static RecyclableMemoryStreamManager MemoryStreamManager { get; } = new(); @@ -52,8 +56,10 @@ internal ProtonDriveClient(IAccountClient accountClient, IDriveApiClients apiCli internal ILogger Logger { get; } internal FifoFlexibleSemaphore RevisionCreationSemaphore { get; } + internal FifoFlexibleSemaphore BlockListingSemaphore { get; } internal BlockUploader BlockUploader { get; } + internal BlockDownloader BlockDownloader { get; } public ValueTask GetMyFilesFolderAsync(CancellationToken cancellationToken) { @@ -84,6 +90,13 @@ public async ValueTask GetFileUploaderAsync( return new FileUploader(this, name, mediaType, lastModificationTime, expectedNumberOfBlocks); } + public async Task GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) + { + await BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); + + return new FileDownloader(this, revisionUid); + } + internal async ValueTask GetClientUidAsync(CancellationToken cancellationToken) { var clientUid = await Cache.Entities.TryGetClientUidAsync(cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveException.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveException.cs new file mode 100644 index 00000000..47cb20a6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveException.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk; + +public class ProtonDriveException : Exception +{ + public ProtonDriveException(string message) + : base(message) + { + } + + public ProtonDriveException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ProtonDriveException() + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index ce355798..2191c0d1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -40,4 +40,5 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(BlockRequestResponse))] [JsonSerializable(typeof(RevisionUpdateParameters))] [JsonSerializable(typeof(BlockVerificationInputResponse))] +[JsonSerializable(typeof(RevisionResponse))] internal sealed partial class DriveApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index d2168f29..6c78a360 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -132,18 +132,11 @@ public static async ValueTask> GetPublicKeysAsync( var publicKeys = new List(publicKeysResponse.Address.Keys.Count); - for (var keyIndex = 0; keyIndex < publicKeys.Count; ++keyIndex) - { - var keyEntry = publicKeysResponse.Address.Keys[keyIndex]; - if (!keyEntry.Status.HasFlag(PublicKeyStatus.IsNotCompromised)) - { - continue; - } - - var publicKey = PgpPublicKey.Import(keyEntry.PublicKey); + var publicKeyQuery = publicKeysResponse.Address.Keys + .Where(x => x.Status.HasFlag(PublicKeyStatus.IsNotCompromised)) + .Select(x => PgpPublicKey.Import(x.PublicKey)); - publicKeys.Add(publicKey); - } + publicKeys.AddRange(publicKeyQuery); client.Cache.PublicKeys.SetPublicKeys(emailAddress, publicKeys); From 6793f5781bda50fa6fdd645bd6757a08e9b43aa9 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 May 2025 12:46:49 +0200 Subject: [PATCH 084/791] support newer pmcrypto with verificationStatus instead of verified --- js/sdk/src/crypto/openPGPCrypto.ts | 40 +++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 1a6db26f..6862b4c0 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -34,7 +34,10 @@ export interface OpenPGPCryptoProxy { verificationKeys?: PublicKey[], }) => Promise<{ data: Uint8Array | string, - verified: VERIFICATION_STATUS + // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. + // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. + verified?: VERIFICATION_STATUS, + verificationStatus?: VERIFICATION_STATUS, }>, signMessage: (options: { format: 'binary' | 'armored', @@ -48,7 +51,10 @@ export interface OpenPGPCryptoProxy { armoredSignature: string, verificationKeys: PublicKey[], }) => Promise<{ - verified: VERIFICATION_STATUS, + // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. + // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. + verified?: VERIFICATION_STATUS, + verificationStatus?: VERIFICATION_STATUS, }>, } @@ -229,13 +235,15 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { armoredSignature: string, verificationKeys: PublicKey[], ) { - const { verified } = await this.cryptoProxy.verifyMessage({ + const { verified, verificationStatus } = await this.cryptoProxy.verifyMessage({ binaryData: data, armoredSignature, verificationKeys, }); return { - verified + // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. + // Proper typing is too complex, it will be removed to support only newer pmcrypto. + verified: verified || verificationStatus!, }; } @@ -287,7 +295,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, verificationKeys: PublicKey[], ) { - const { data: decryptedData, verified } = await this.cryptoProxy.decryptMessage({ + const { data: decryptedData, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ binaryMessage: data, sessionKeys: sessionKey, verificationKeys, @@ -296,7 +304,9 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { data: decryptedData as Uint8Array, - verified, + // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. + // Proper typing is too complex, it will be removed to support only newer pmcrypto. + verified: verified || verificationStatus!, } } @@ -306,7 +316,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, verificationKeys?: PublicKey[], ) { - const { data: decryptedData, verified } = await this.cryptoProxy.decryptMessage({ + const { data: decryptedData, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ binaryMessage: data, binarySignature: signature, sessionKeys: sessionKey, @@ -316,7 +326,9 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { data: decryptedData as Uint8Array, - verified, + // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. + // Proper typing is too complex, it will be removed to support only newer pmcrypto. + verified: verified || verificationStatus!, } } @@ -337,7 +349,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { decryptionKeys: PrivateKey[], verificationKeys: PublicKey[], ) { - const { data, verified } = await this.cryptoProxy.decryptMessage({ + const { data, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, decryptionKeys, verificationKeys, @@ -346,7 +358,9 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { data: data as Uint8Array, - verified, + // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. + // Proper typing is too complex, it will be removed to support only newer pmcrypto. + verified: verified || verificationStatus!, } } @@ -356,7 +370,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, verificationKeys: PublicKey[], ) { - const { data, verified } = await this.cryptoProxy.decryptMessage({ + const { data, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, armoredSignature, sessionKeys: sessionKey, @@ -366,7 +380,9 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { data: data as Uint8Array, - verified, + // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. + // Proper typing is too complex, it will be removed to support only newer pmcrypto. + verified: verified || verificationStatus!, } } } From d99dcd56df126e0781feb81ce462face7f2a8a38 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 May 2025 12:54:27 +0000 Subject: [PATCH 085/791] Implement move --- cs/sdk/src/Directory.Packages.props | 2 +- ...nParameters.cs => BlockCreationRequest.cs} | 2 +- ...rs.cs => BlockUploadPreparationRequest.cs} | 6 +- ...e.cs => BlockUploadPreparationResponse.cs} | 2 +- ...onParameters.cs => FileCreationRequest.cs} | 2 +- .../src/Proton.Drive.Sdk/Api/Files/FileDto.cs | 3 +- .../Api/Files/FilesApiClient.cs | 16 +- .../Api/Files/IFilesApiClient.cs | 6 +- ...Parameters.cs => RevisionUpdateRequest.cs} | 2 +- ...ameters.cs => ThumbnailCreationRequest.cs} | 2 +- ...Parameters.cs => FolderCreationRequest.cs} | 2 +- .../Api/Folders/FoldersApiClient.cs | 4 +- .../Api/Folders/IFoldersApiClient.cs | 2 +- .../Api/Links/ILinksApiClient.cs | 9 +- .../Api/Links/LinksApiClient.cs | 28 +- .../Api/Links/MoveMultipleLinksItem.cs | 28 ++ .../Api/Links/MoveMultipleLinksRequest.cs | 19 + .../Api/Links/MoveSingleLinkRequest.cs | 35 ++ ...onParameters.cs => NodeCreationRequest.cs} | 2 +- .../Api/Links/RenameLinkRequest.cs | 24 ++ .../Api/Volumes/IVolumesApiClient.cs | 2 +- ...Parameters.cs => VolumeCreationRequest.cs} | 2 +- .../Api/Volumes/VolumesApiClient.cs | 4 +- .../Caching/DriveEntityCache.cs | 2 +- .../Caching/DriveSecretCache.cs | 35 +- .../Caching/IDriveEntityCache.cs | 2 +- .../Caching/IDriveSecretCache.cs | 13 +- .../Nodes/AuthorshipClaimExtensions.cs | 17 +- .../Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs | 5 +- .../Nodes/Cryptography/DecryptionError.cs | 6 + .../Nodes/Cryptography/DecryptionOutput.cs | 3 + .../Cryptography/FileDecryptionResult.cs | 12 + .../Cryptography/FolderDecryptionResult.cs | 9 + .../Cryptography/LinkDecryptionResult.cs | 13 + .../Nodes/Cryptography/NodeCrypto.cs | 306 ++++++++++++++ .../Cryptography/PhasedDecryptionOutput.cs | 10 + .../Proton.Drive.Sdk/Nodes/DecryptionError.cs | 7 - .../Nodes/DecryptionOutput.cs | 11 - .../Nodes/DegradedFileMetadata.cs | 9 + .../Nodes/DegradedFileNode.cs | 4 +- .../Nodes/DegradedFolderMetadata.cs | 9 + .../Nodes/DegradedFolderNode.cs | 4 +- .../Proton.Drive.Sdk/Nodes/DegradedNode.cs | 10 +- .../Nodes/DegradedNodeAndSecrets.cs | 2 +- .../Nodes/DegradedNodeMetadata.cs | 52 +++ .../Nodes/DtoToMetadataConverter.cs | 338 +++++++++++++++ .../Proton.Drive.Sdk/Nodes/FileDraftNode.cs | 2 +- .../Proton.Drive.Sdk/Nodes/FileMetadata.cs | 5 + cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs | 2 +- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 12 +- .../Nodes/FileOrFileDraftNode.cs | 2 +- .../Nodes/FolderChildrenBatchLoader.cs | 15 +- .../Proton.Drive.Sdk/Nodes/FolderMetadata.cs | 5 + .../src/Proton.Drive.Sdk/Nodes/FolderNode.cs | 2 +- .../Nodes/FolderOperations.cs | 14 +- .../Nodes/InvalidNameError.cs | 7 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 6 +- .../Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs | 39 -- ...NodeAndSecretsProvisionResultExtensions.cs | 99 ----- .../src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs | 384 ------------------ .../Proton.Drive.Sdk/Nodes/NodeMetadata.cs | 60 +++ .../Nodes/NodeMetadataResultExtensions.cs | 100 +++++ .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 353 ++++++++++------ .../src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs | 7 +- .../Nodes/PhasedDecryptionOutput.cs | 6 - cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 2 +- .../Nodes/SignatureVerificationError.cs | 9 +- .../Nodes/Upload/BlockUploader.cs | 20 +- .../Nodes/Upload/RevisionWriter.cs | 8 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 11 +- .../DriveApiSerializerContext.cs | 15 +- .../DriveEntitiesSerializerContext.cs | 12 +- .../DriveSecretsSerializerContext.cs | 9 +- .../Proton.Drive.Sdk/Shares/ShareCrypto.cs | 3 - .../Volumes/VolumeOperations.cs | 8 +- .../Proton.Sdk/Addresses/AddressOperations.cs | 5 +- .../Cryptography/IPgpArmoredBlock.cs | 11 +- .../Cryptography/PgpArmoredMessage.cs | 8 +- .../Cryptography/PgpArmoredPrivateKey.cs | 8 +- .../Cryptography/PgpArmoredPublicKey.cs | 8 +- .../Cryptography/PgpArmoredSignature.cs | 8 +- .../Http/TlsRemoteCertificateValidator.cs | 2 - cs/sdk/src/Proton.Sdk/ProtonApiException.cs | 6 +- .../{Result{T,TError}.cs => RefResult.cs} | 16 +- cs/sdk/src/Proton.Sdk/RefResultExtensions.cs | 84 ++++ cs/sdk/src/Proton.Sdk/ResultExtensions.cs | 63 --- .../PgpArmoredBlockJsonConverterBase.cs | 8 +- .../Serialization/RefResultJsonConverter.cs | 39 ++ .../Serialization/ResultJsonConverter.cs | 46 --- .../Serialization/SerializableRefResult.cs | 16 + .../Serialization/SerializableResult.cs | 14 - .../Serialization/SerializableValResult.cs | 16 + .../Serialization/ValResultJsonConverter.cs | 44 ++ cs/sdk/src/Proton.Sdk/ValResult.cs | 38 ++ cs/sdk/src/Proton.Sdk/ValResultExtensions.cs | 86 ++++ 95 files changed, 1858 insertions(+), 968 deletions(-) rename cs/sdk/src/Proton.Drive.Sdk/Api/Files/{BlockCreationParameters.cs => BlockCreationRequest.cs} (92%) rename cs/sdk/src/Proton.Drive.Sdk/Api/Files/{BlockUploadRequestParameters.cs => BlockUploadPreparationRequest.cs} (73%) rename cs/sdk/src/Proton.Drive.Sdk/Api/Files/{BlockRequestResponse.cs => BlockUploadPreparationResponse.cs} (84%) rename cs/sdk/src/Proton.Drive.Sdk/Api/Files/{FileCreationParameters.cs => FileCreationRequest.cs} (88%) rename cs/sdk/src/Proton.Drive.Sdk/Api/Files/{RevisionUpdateParameters.cs => RevisionUpdateRequest.cs} (89%) rename cs/sdk/src/Proton.Drive.Sdk/Api/Files/{ThumbnailCreationParameters.cs => ThumbnailCreationRequest.cs} (84%) rename cs/sdk/src/Proton.Drive.Sdk/Api/Folders/{FolderCreationParameters.cs => FolderCreationRequest.cs} (77%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs rename cs/sdk/src/Proton.Drive.Sdk/Api/Links/{NodeCreationParameters.cs => NodeCreationRequest.cs} (95%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs rename cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/{VolumeCreationParameters.cs => VolumeCreationRequest.cs} (94%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionError.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionOutput.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionOutput.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileMetadata.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderMetadata.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeMetadata.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileMetadata.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderMetadata.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadata.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/PhasedDecryptionOutput.cs rename cs/sdk/src/Proton.Sdk/{Result{T,TError}.cs => RefResult.cs} (59%) create mode 100644 cs/sdk/src/Proton.Sdk/RefResultExtensions.cs delete mode 100644 cs/sdk/src/Proton.Sdk/ResultExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/SerializableRefResult.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/SerializableValResult.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/ValResult.cs create mode 100644 cs/sdk/src/Proton.Sdk/ValResultExtensions.cs diff --git a/cs/sdk/src/Directory.Packages.props b/cs/sdk/src/Directory.Packages.props index 783bba16..eca292da 100644 --- a/cs/sdk/src/Directory.Packages.props +++ b/cs/sdk/src/Directory.Packages.props @@ -10,7 +10,7 @@ - + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationRequest.cs similarity index 92% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationRequest.cs index abd2bc09..060e18f1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationRequest.cs @@ -3,7 +3,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal sealed class BlockCreationParameters +internal sealed class BlockCreationRequest { public required int Index { get; init; } public required int Size { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs similarity index 73% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs index 7d5980b1..2e100508 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadRequestParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal sealed class BlockUploadRequestParameters +internal sealed class BlockUploadPreparationRequest { [JsonPropertyName("AddressID")] public required AddressId AddressId { get; init; } @@ -20,8 +20,8 @@ internal sealed class BlockUploadRequestParameters public required RevisionId RevisionId { get; init; } [JsonPropertyName("BlockList")] - public required IReadOnlyList Blocks { get; init; } + public required IReadOnlyList Blocks { get; init; } [JsonPropertyName("ThumbnailList")] - public required IReadOnlyList Thumbnails { get; init; } + public required IReadOnlyList Thumbnails { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockRequestResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs similarity index 84% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockRequestResponse.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs index 19f65639..e13089e0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockRequestResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs @@ -3,7 +3,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal sealed class BlockRequestResponse : ApiResponse +internal sealed class BlockUploadPreparationResponse : ApiResponse { [JsonPropertyName("UploadLinks")] public required IReadOnlyList UploadTargets { get; set; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs similarity index 88% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs index 51f6595a..81014866 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal sealed class FileCreationParameters : NodeCreationParameters +internal sealed class FileCreationRequest : NodeCreationRequest { [JsonPropertyName("MIMEType")] public required string MediaType { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs index 34e6804b..ab9142f9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs @@ -12,7 +12,8 @@ internal sealed class FileDto public required ReadOnlyMemory ContentKeyPacket { get; init; } - public PgpArmoredSignature ContentKeyPacketSignature { get; init; } + [JsonPropertyName("ContentKeyPacketSignature")] + public PgpArmoredSignature ContentKeySignature { get; init; } public ActiveRevisionDto? ActiveRevision { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index 2cf42f35..22331ab2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -11,34 +11,34 @@ internal sealed class FilesApiClient(HttpClient httpClient) : IFilesApiClient { private readonly HttpClient _httpClient = httpClient; - public async ValueTask CreateFileAsync(VolumeId volumeId, FileCreationParameters parameters, CancellationToken cancellationToken) + public async ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken) { return await _httpClient .Expecting(DriveApiSerializerContext.Default.FileCreationResponse, DriveApiSerializerContext.Default.RevisionConflictResponse) - .PostAsync($"v2/volumes/{volumeId}/files", parameters, DriveApiSerializerContext.Default.FileCreationParameters, cancellationToken) + .PostAsync($"v2/volumes/{volumeId}/files", request, DriveApiSerializerContext.Default.FileCreationRequest, cancellationToken) .ConfigureAwait(false); } - public async ValueTask RequestBlockUploadAsync(BlockUploadRequestParameters parameters, CancellationToken cancellationToken) + public async ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken) { return await _httpClient - .Expecting(DriveApiSerializerContext.Default.BlockRequestResponse) - .PostAsync("blocks", parameters, DriveApiSerializerContext.Default.BlockUploadRequestParameters, cancellationToken).ConfigureAwait(false); + .Expecting(DriveApiSerializerContext.Default.BlockUploadPreparationResponse) + .PostAsync("blocks", request, DriveApiSerializerContext.Default.BlockUploadPreparationRequest, cancellationToken).ConfigureAwait(false); } public async ValueTask UpdateRevisionAsync( VolumeId volumeId, LinkId linkId, RevisionId revisionId, - RevisionUpdateParameters parameters, + RevisionUpdateRequest request, CancellationToken cancellationToken) { return await _httpClient .Expecting(ProtonApiSerializerContext.Default.ApiResponse) .PutAsync( $"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}", - parameters, - DriveApiSerializerContext.Default.RevisionUpdateParameters, + request, + DriveApiSerializerContext.Default.RevisionUpdateRequest, cancellationToken).ConfigureAwait(false); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs index 2d4e8c8c..3e39ad6a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -6,15 +6,15 @@ namespace Proton.Drive.Sdk.Api.Files; internal interface IFilesApiClient : IRevisionVerificationApiClient { - ValueTask CreateFileAsync(VolumeId volumeId, FileCreationParameters parameters, CancellationToken cancellationToken); + ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken); - ValueTask RequestBlockUploadAsync(BlockUploadRequestParameters parameters, CancellationToken cancellationToken); + ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken); ValueTask UpdateRevisionAsync( VolumeId volumeId, LinkId linkId, RevisionId revisionId, - RevisionUpdateParameters parameters, + RevisionUpdateRequest request, CancellationToken cancellationToken); public ValueTask GetRevisionAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs similarity index 89% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs index ddb6e03e..92869748 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs @@ -3,7 +3,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal sealed class RevisionUpdateParameters +internal sealed class RevisionUpdateRequest { public required PgpArmoredSignature ManifestSignature { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs similarity index 84% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs index c76d6d2f..aea7d6f0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal sealed class ThumbnailCreationParameters +internal sealed class ThumbnailCreationRequest { public required int Size { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs similarity index 77% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs index 49e7a2d7..49b1bb5d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Api.Folders; -internal sealed class FolderCreationParameters : NodeCreationParameters +internal sealed class FolderCreationRequest : NodeCreationRequest { [JsonPropertyName("NodeHashKey")] public required PgpArmoredMessage HashKey { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs index 411a72be..301483ba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs @@ -20,12 +20,12 @@ public async ValueTask GetChildrenAsync(VolumeId volumeI public async ValueTask CreateFolderAsync( VolumeId volumeId, - FolderCreationParameters parameters, + FolderCreationRequest request, CancellationToken cancellationToken) { return await _httpClient .Expecting(DriveApiSerializerContext.Default.FolderCreationResponse) - .PostAsync($"v2/volumes/{volumeId}/folders", parameters, DriveApiSerializerContext.Default.FolderCreationParameters, cancellationToken) + .PostAsync($"v2/volumes/{volumeId}/folders", request, DriveApiSerializerContext.Default.FolderCreationRequest, cancellationToken) .ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs index 2cce7dd2..403507e5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs @@ -7,5 +7,5 @@ internal interface IFoldersApiClient { ValueTask GetChildrenAsync(VolumeId volumeId, LinkId linkId, LinkId? anchorId, CancellationToken cancellationToken); - ValueTask CreateFolderAsync(VolumeId volumeId, FolderCreationParameters parameters, CancellationToken cancellationToken); + ValueTask CreateFolderAsync(VolumeId volumeId, FolderCreationRequest request, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs index 8bd1e4df..55d25c2f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs @@ -1,10 +1,17 @@ using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Api.Links; internal interface ILinksApiClient { - ValueTask GetLinkDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken); + ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken); ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken); + + ValueTask MoveAsync(VolumeId volumeId, LinkId linkId, MoveSingleLinkRequest request, CancellationToken cancellationToken); + + ValueTask MoveMultipleAsync(VolumeId volumeId, MoveMultipleLinksRequest request, CancellationToken cancellationToken); + + ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs index ff90673f..c975dd23 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -1,6 +1,8 @@ using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; using Proton.Sdk.Http; +using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Api.Links; @@ -8,7 +10,7 @@ internal sealed class LinksApiClient(HttpClient httpClient) : ILinksApiClient { private readonly HttpClient _httpClient = httpClient; - public async ValueTask GetLinkDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken) + public async ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken) { return await _httpClient .Expecting(DriveApiSerializerContext.Default.LinkDetailsResponse) @@ -22,4 +24,28 @@ public async ValueTask GetContextShareAsync(VolumeId volum .Expecting(DriveApiSerializerContext.Default.ContextShareResponse) .GetAsync($"volumes/{volumeId}/links/{linkId}/context", cancellationToken).ConfigureAwait(false); } + + public async ValueTask MoveAsync(VolumeId volumeId, LinkId linkId, MoveSingleLinkRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .PutAsync($"v2/volumes/{volumeId}/links/{linkId}/move", request, DriveApiSerializerContext.Default.MoveSingleLinkRequest, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask MoveMultipleAsync(VolumeId volumeId, MoveMultipleLinksRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .PutAsync($"volumes/{volumeId}/links/move-multiple", request, DriveApiSerializerContext.Default.MoveMultipleLinksRequest, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .PutAsync($"v2/volumes/{volumeId}/links/{linkId}/rename", request, DriveApiSerializerContext.Default.RenameLinkRequest, cancellationToken) + .ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs new file mode 100644 index 00000000..8806bb72 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class MoveMultipleLinksItem +{ + [JsonPropertyName("LinkID")] + public required LinkId LinkId { get; init; } + + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("NodePassphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("Hash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("OriginalHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory OriginalNameHashDigest { get; init; } + + [JsonPropertyName("NodePassphraseSignature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required PgpArmoredSignature? PassphraseSignature { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs new file mode 100644 index 00000000..38c4cbd7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class MoveMultipleLinksRequest +{ + [JsonPropertyName("ParentLinkID")] + public required LinkId ParentLinkId { get; init; } + + [JsonPropertyName("Links")] + public required IReadOnlyList Batch { get; init; } + + [JsonPropertyName("NameSignatureEmail")] + public required string NameSignatureEmailAddress { get; init; } + + [JsonPropertyName("SignatureEmail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? SignatureEmailAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs new file mode 100644 index 00000000..96114135 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class MoveSingleLinkRequest +{ + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("NodePassphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("Hash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("ParentLinkID")] + public required LinkId ParentLinkId { get; init; } + + [JsonPropertyName("OriginalHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory OriginalNameHashDigest { get; init; } + + [JsonPropertyName("NameSignatureEmail")] + public required string NameSignatureEmailAddress { get; init; } + + [JsonPropertyName("NodePassphraseSignature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required PgpArmoredSignature? PassphraseSignature { get; init; } + + [JsonPropertyName("SignatureEmail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? SignatureEmailAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs similarity index 95% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs index a677c030..2d7eb3ac 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Api.Links; -internal abstract class NodeCreationParameters +internal abstract class NodeCreationRequest { public required PgpArmoredMessage Name { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs new file mode 100644 index 00000000..e50219c0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class RenameLinkRequest +{ + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("Hash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("NameSignatureEmail")] + public required string NameSignatureEmailAddress { get; init; } + + [JsonPropertyName("MIMEType")] + public required string MediaType { get; set; } + + [JsonPropertyName("OriginalHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory OriginalNameHashDigest { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs index b55de94d..0ad7ad43 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs @@ -2,5 +2,5 @@ internal interface IVolumesApiClient { - ValueTask CreateVolumeAsync(VolumeCreationParameters parameters, CancellationToken cancellationToken); + ValueTask CreateVolumeAsync(VolumeCreationRequest request, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs similarity index 94% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs index e89497d4..d23c1a95 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Api.Volumes; -internal sealed class VolumeCreationParameters +internal sealed class VolumeCreationRequest { [JsonPropertyName("AddressID")] public required AddressId AddressId { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs index ff69dae8..e5de47f8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs @@ -7,10 +7,10 @@ internal sealed class VolumesApiClient(HttpClient httpClient) : IVolumesApiClien { private readonly HttpClient _httpClient = httpClient; - public async ValueTask CreateVolumeAsync(VolumeCreationParameters parameters, CancellationToken cancellationToken) + public async ValueTask CreateVolumeAsync(VolumeCreationRequest request, CancellationToken cancellationToken) { return await _httpClient .Expecting(DriveApiSerializerContext.Default.VolumeCreationResponse) - .PostAsync("volumes", parameters, DriveApiSerializerContext.Default.VolumeCreationParameters, cancellationToken).ConfigureAwait(false); + .PostAsync("volumes", request, DriveApiSerializerContext.Default.VolumeCreationRequest, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 741d53da..485dd442 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -69,7 +69,7 @@ public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) public ValueTask SetNodeAsync( NodeUid nodeId, - Result nodeProvisionResult, + RefResult nodeProvisionResult, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs index 66318ed5..1364861a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -3,6 +3,7 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Serialization; +using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.Serialization; @@ -28,35 +29,45 @@ public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, Cance : null; } - public ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets fileSecrets, CancellationToken cancellationToken) + public ValueTask SetFolderSecretsAsync( + NodeUid nodeId, + RefResult secretsProvisionResult, + CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(fileSecrets, DriveSecretsSerializerContext.Default.FileSecrets); + var serializedValue = JsonSerializer.Serialize( + secretsProvisionResult, + DriveSecretsSerializerContext.Default.NullableRefResultFolderSecretsDegradedFolderSecrets); - return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); + return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetFileSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + var serializedValue = await _repository.TryGetAsync(GetFolderSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.FileSecrets) + ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableRefResultFolderSecretsDegradedFolderSecrets) : null; } - public ValueTask SetFolderSecretsAsync(NodeUid nodeId, FolderSecrets folderSecrets, CancellationToken cancellationToken) + public ValueTask SetFileSecretsAsync( + NodeUid nodeId, + RefResult secretsProvisionResult, + CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(folderSecrets, DriveSecretsSerializerContext.Default.FolderSecrets); + var serializedValue = JsonSerializer.Serialize( + secretsProvisionResult, + DriveSecretsSerializerContext.Default.NullableRefResultFileSecretsDegradedFileSecrets); - return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); + return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetFolderSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + var serializedValue = await _repository.TryGetAsync(GetFileSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.FolderSecrets) + ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableRefResultFileSecretsDegradedFileSecrets) : null; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index 685cb9b0..c5e58988 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -22,7 +22,7 @@ internal interface IDriveEntityCache ValueTask SetNodeAsync( NodeUid nodeId, - Result nodeProvisionResult, + RefResult nodeProvisionResult, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest, CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs index 025c7e09..ba334581 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -1,6 +1,7 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; namespace Proton.Drive.Sdk.Caching; @@ -9,9 +10,13 @@ internal interface IDriveSecretCache ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken); ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken); - ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets fileSecrets, CancellationToken cancellationToken); - ValueTask TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask SetFolderSecretsAsync( + NodeUid nodeId, + RefResult secretsProvisionResult, + CancellationToken cancellationToken); - ValueTask SetFolderSecretsAsync(NodeUid nodeId, FolderSecrets folderSecrets, CancellationToken cancellationToken); - ValueTask TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + + ValueTask SetFileSecretsAsync(NodeUid nodeId, RefResult secretsProvisionResult, CancellationToken cancellationToken); + ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs index 797c8f19..7574b4fe 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs @@ -1,16 +1,21 @@ -using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Nodes.Cryptography; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; internal static class AuthorshipClaimExtensions { - public static Result ToAuthorshipResult( + public static ValResult ToAuthorshipResult( this AuthorshipClaim authorshipClaim, - PgpVerificationResult verificationResult) + AuthorshipVerificationFailure? verificationFailure) { - return verificationResult.Status is PgpVerificationStatus.Ok - ? authorshipClaim.Author - : new SignatureVerificationError(authorshipClaim.Author, verificationResult.Status, authorshipClaim.KeyRetrievalErrorMessage); + if (verificationFailure is not null) + { + var errorMessage = authorshipClaim.KeyRetrievalErrorMessage ?? verificationFailure.Value.Message; + + return new SignatureVerificationError(authorshipClaim.Author, verificationFailure.Value.Status, errorMessage); + } + + return authorshipClaim.Author; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs index cbaf56b3..5dfab8e8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs @@ -3,4 +3,7 @@ namespace Proton.Drive.Sdk.Nodes; -internal readonly record struct CachedNodeInfo(Result NodeProvisionResult, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); +internal readonly record struct CachedNodeInfo( + RefResult NodeProvisionResult, + ShareId? MembershipShareId, + ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionError.cs new file mode 100644 index 00000000..671e0b9e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionError.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal sealed class DecryptionError(string message, ProtonDriveError? innerError = null) + : ProtonDriveError(message, innerError) +{ +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionOutput.cs new file mode 100644 index 00000000..fb26bd15 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionOutput.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal readonly record struct DecryptionOutput(TData Data, AuthorshipVerificationFailure? AuthorshipVerificationFailure = null); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs new file mode 100644 index 00000000..866a2c61 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs @@ -0,0 +1,12 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal sealed class FileDecryptionResult +{ + public required LinkDecryptionResult Link { get; init; } + public required ValResult, string?> ContentKey { get; init; } + public required ValResult, string?> ExtendedAttributes { get; init; } + public required AuthorshipClaim ContentAuthorshipClaim { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs new file mode 100644 index 00000000..4e357c36 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs @@ -0,0 +1,9 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal sealed class FolderDecryptionResult +{ + public required LinkDecryptionResult Link { get; init; } + public required ValResult>, string?> HashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs new file mode 100644 index 00000000..affe231f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs @@ -0,0 +1,13 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal sealed class LinkDecryptionResult +{ + public required ValResult>, string> Passphrase { get; init; } + public required AuthorshipClaim NodeAuthorshipClaim { get; init; } + public required ValResult, string> Name { get; init; } + public required AuthorshipClaim NameAuthorshipClaim { get; init; } + public required ValResult NodeKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs new file mode 100644 index 00000000..d5f15def --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -0,0 +1,306 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal static class NodeCrypto +{ + public static async ValueTask DecryptFolderAsync( + ProtonDriveClient client, + LinkDto link, + FolderDto folder, + ValResult parentKeyResult, + CancellationToken cancellationToken) + { + var linkDecryptionResult = await DecryptLinkAsync(client, link, parentKeyResult, cancellationToken).ConfigureAwait(false); + + var hashKeyResult = DecryptHashKey(folder.HashKey, linkDecryptionResult.NodeKey.GetValueOrDefault(), linkDecryptionResult.NodeAuthorshipClaim); + + return new FolderDecryptionResult + { + Link = linkDecryptionResult, + HashKey = hashKeyResult, + }; + } + + public static async ValueTask DecryptFileAsync( + ProtonDriveClient client, + LinkDto linkDto, + FileDto fileDto, + ActiveRevisionDto activeRevisionDto, + ValResult parentKeyResult, + CancellationToken cancellationToken) + { + var contentAuthorshipClaim = + await AuthorshipClaim.CreateAsync(client, activeRevisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + + var linkDecryptionResult = await DecryptLinkAsync(client, linkDto, parentKeyResult, cancellationToken).ConfigureAwait(false); + + var nodeKey = linkDecryptionResult.NodeKey.GetValueOrDefault(); + + var contentKeyDecryptionResult = DecryptContentKey( + nodeKey, + fileDto.ContentKeyPacket, + fileDto.ContentKeySignature, + linkDecryptionResult.NodeAuthorshipClaim); + + var extendedAttributesResult = DecryptExtendedAttributes(activeRevisionDto.ExtendedAttributes, nodeKey, contentAuthorshipClaim); + + return new FileDecryptionResult + { + Link = linkDecryptionResult, + ContentKey = contentKeyDecryptionResult, + ExtendedAttributes = extendedAttributesResult, + ContentAuthorshipClaim = contentAuthorshipClaim, + }; + } + + public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHashKey) + { + var maxNameByteLength = Encoding.UTF8.GetByteCount(name); + var nameBytes = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) + ? nameHeapMemoryOwner.Memory.Span + : stackalloc byte[maxNameByteLength]; + + using (nameHeapMemoryOwner) + { + var nameByteLength = Encoding.UTF8.GetBytes(name, nameBytes); + nameBytes = nameBytes[..nameByteLength]; + + return HMACSHA256.HashData(parentFolderHashKey, nameBytes); + } + } + + private static async ValueTask DecryptLinkAsync( + ProtonDriveClient client, + LinkDto link, + ValResult parentKeyResult, + CancellationToken cancellationToken) + { + var nodeAuthorshipClaim = await AuthorshipClaim.CreateAsync(client, link.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + + var nameAuthorshipClaim = link.NameSignatureEmailAddress != link.SignatureEmailAddress + ? await AuthorshipClaim.CreateAsync(client, link.NameSignatureEmailAddress, cancellationToken).ConfigureAwait(false) + : nodeAuthorshipClaim; + + ValResult, string> nameResult; + ValResult>, string> passphraseResult; + + if (parentKeyResult.TryGetValueElseError(out var parentKey, out var parentNodeKeyInnerError)) + { + nameResult = DecryptName(link.Name, parentKey.Value, nameAuthorshipClaim); + passphraseResult = DecryptPassphrase(parentKey.Value, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); + } + else + { + var errorMessage = parentNodeKeyInnerError.Message ?? "Decryption key unavailable"; + nameResult = errorMessage; + passphraseResult = errorMessage; + } + + var nodeKeyResult = UnlockNodeKey(link.Key, passphraseResult.GetValueOrDefault()?.Data); + + return new LinkDecryptionResult + { + Passphrase = passphraseResult, + NodeAuthorshipClaim = nodeAuthorshipClaim, + Name = nameResult, + NameAuthorshipClaim = nameAuthorshipClaim, + NodeKey = nodeKeyResult, + }; + } + + private static ValResult>, string> DecryptPassphrase( + PgpPrivateKey parentNodeKey, + PgpArmoredMessage encryptedPassphrase, + PgpArmoredSignature? signature, + AuthorshipClaim authorshipClaim) + { + try + { + var passphrase = DecryptMessage(encryptedPassphrase, signature, parentNodeKey, authorshipClaim, out var sessionKey, out var author); + + return new PhasedDecryptionOutput>(sessionKey, passphrase, author); + } + catch (Exception e) + { + return e.Message; + } + } + + private static ValResult UnlockNodeKey(PgpArmoredPrivateKey lockedKey, ReadOnlyMemory? passphrase) + { + if (passphrase is null) + { + return null; + } + + try + { + return PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase.Value.Span); + } + catch (Exception e) + { + return e.Message; + } + } + + private static ValResult, string> DecryptName( + PgpArmoredMessage encryptedName, + PgpPrivateKey parentNodeKey, + AuthorshipClaim authorshipClaim) + { + try + { + var nameUtf8Bytes = DecryptMessage(encryptedName, detachedSignature: null, parentNodeKey, authorshipClaim, out var sessionKey, out var author); + + var name = Encoding.UTF8.GetString(nameUtf8Bytes); + + return new PhasedDecryptionOutput(sessionKey, name, author); + } + catch (Exception e) + { + return e.Message; + } + } + + private static ValResult>, string?> DecryptHashKey( + PgpArmoredMessage? encryptedHashKey, + PgpPrivateKey? nodeKey, + AuthorshipClaim authorshipClaim) + { + if (nodeKey is null) + { + return null; + } + + if (encryptedHashKey is null) + { + return "Folder information missing for link of type Folder"; + } + + try + { + var hashKey = DecryptMessage(encryptedHashKey.Value, detachedSignature: null, nodeKey.Value, authorshipClaim, out _, out var author); + + return new DecryptionOutput>(hashKey, author); + } + catch (Exception e) + { + return e.Message; + } + } + + private static ValResult, string?> DecryptContentKey( + PgpPrivateKey? nodeKey, + ReadOnlyMemory contentKeyPacket, + PgpArmoredSignature contentKeySignature, + AuthorshipClaim nodeAuthorshipClaim) + { + if (nodeKey is null) + { + return null; + } + + PgpSessionKey contentKey; + try + { + contentKey = nodeKey.Value.DecryptSessionKey(contentKeyPacket.Span); + } + catch (Exception e) + { + return e.Message; + } + + var verificationKeyRing = nodeAuthorshipClaim.GetKeyRing(nodeKey.Value); + + AuthorshipVerificationFailure? verificationFailure; + try + { + var verificationResult = verificationKeyRing.Verify(contentKey.Export().Token, contentKeySignature); + + verificationFailure = verificationResult.Status is not PgpVerificationStatus.Ok + ? new AuthorshipVerificationFailure(verificationResult.Status) + : null; + } + catch (Exception e) + { + verificationFailure = new AuthorshipVerificationFailure(PgpVerificationStatus.Failed, e.Message); + } + + return new DecryptionOutput(contentKey, verificationFailure); + } + + private static ValResult, string?> DecryptExtendedAttributes( + PgpArmoredMessage? encryptedExtendedAttributes, + PgpPrivateKey? nodeKey, + AuthorshipClaim authorshipClaim) + { + if (encryptedExtendedAttributes is null) + { + return new DecryptionOutput(null); + } + + if (nodeKey is null) + { + return null; + } + + try + { + var serializedExtendedAttributes = DecryptMessage( + encryptedExtendedAttributes.Value, + detachedSignature: null, + nodeKey.Value, + authorshipClaim, + out _, + out var author); + + try + { + var extendedAttributes = JsonSerializer.Deserialize(serializedExtendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); + + return new DecryptionOutput(extendedAttributes, author); + } + catch (Exception e) + { + return $"Failed to deserialize extended attributes: {e.Message}"; + } + } + catch (Exception e) + { + return e.Message; + } + } + + private static ArraySegment DecryptMessage( + PgpArmoredMessage encryptedMessage, + PgpArmoredSignature? detachedSignature, + PgpPrivateKey decryptionKey, + AuthorshipClaim authorshipClaim, + out PgpSessionKey sessionKey, + out AuthorshipVerificationFailure? authorshipVerificationFailure) + { + sessionKey = decryptionKey.DecryptSessionKey(encryptedMessage); + + var verificationKeyRing = authorshipClaim.GetKeyRing(anonymousFallbackKey: decryptionKey); + + var plaintext = detachedSignature is not null + ? sessionKey.DecryptAndVerify(encryptedMessage.Bytes.Span, detachedSignature.Value.Bytes.Span, verificationKeyRing, out var verificationResult) + : sessionKey.DecryptAndVerify(encryptedMessage, verificationKeyRing, out verificationResult); + + authorshipVerificationFailure = verificationResult.Status is not PgpVerificationStatus.Ok + ? new AuthorshipVerificationFailure(verificationResult.Status) + : null; + + return plaintext; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs new file mode 100644 index 00000000..1360ec9b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs @@ -0,0 +1,10 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal readonly record struct PhasedDecryptionOutput( + PgpSessionKey SessionKey, + TData Data, + AuthorshipVerificationFailure? AuthorshipVerificationFailure = null); + +internal readonly record struct AuthorshipVerificationFailure(PgpVerificationStatus Status, string? Message = null); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs deleted file mode 100644 index 7efed7e0..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionError.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -internal sealed class DecryptionError(string message, Author claimedAuthor, ProtonDriveError? innerError = null) - : ProtonDriveError(message, innerError) -{ - public Author ClaimedAuthor { get; } = claimedAuthor; -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionOutput.cs deleted file mode 100644 index 67c269e2..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DecryptionOutput.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Proton.Sdk; - -namespace Proton.Drive.Sdk.Nodes; - -internal readonly record struct DecryptionOutput(TData Data, Result Author) -{ - public static implicit operator DecryptionOutput?((TData Data, Result Author) output) - { - return new DecryptionOutput(output.Data, output.Author); - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileMetadata.cs new file mode 100644 index 00000000..c320efe7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileMetadata.cs @@ -0,0 +1,9 @@ +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed record DegradedFileMetadata( + DegradedFileNode Node, + DegradedFileSecrets Secrets, + ShareId? MembershipShareId, + ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs index 2765c529..9f538c58 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs @@ -14,8 +14,8 @@ public FileNode ToNode(string substituteName, Revision substituteRevision) { return new FileNode { - Uid = Id, - ParentUid = ParentId, + Uid = Uid, + ParentUid = ParentUid, MediaType = MediaType, Name = Name.TryGetValue(out var name) ? name diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderMetadata.cs new file mode 100644 index 00000000..bc7902a9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderMetadata.cs @@ -0,0 +1,9 @@ +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed record DegradedFolderMetadata( + DegradedFolderNode Node, + DegradedFolderSecrets Secrets, + ShareId? MembershipShareId, + ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs index d8eff860..67c552dc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs @@ -8,8 +8,8 @@ public FolderNode ToNode(string substituteName) { return new FolderNode { - Uid = Id, - ParentUid = ParentId, + Uid = Uid, + ParentUid = ParentUid, Name = Name.TryGetValue(out var name) ? name : substituteName, NameAuthor = NameAuthor, IsTrashed = IsTrashed, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs index 99c54d0a..054ec5d5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs @@ -4,17 +4,17 @@ namespace Proton.Drive.Sdk.Nodes; public abstract class DegradedNode { - public required NodeUid Id { get; init; } + public required NodeUid Uid { get; init; } - public required NodeUid? ParentId { get; init; } + public required NodeUid? ParentUid { get; init; } - public required Result Name { get; init; } + public required RefResult Name { get; init; } - public required Result NameAuthor { get; init; } + public required ValResult NameAuthor { get; init; } public required bool IsTrashed { get; init; } - public required Result Author { get; init; } + public required ValResult Author { get; init; } public required IReadOnlyList Errors { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs index b82b69c2..51f5196a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Nodes; -internal readonly struct DegradedNodeAndSecrets +internal sealed class DegradedNodeAndSecrets { private readonly (DegradedFileNode Node, DegradedFileSecrets Secrets)? _fileAndSecrets; private readonly (DegradedFolderNode Node, DegradedFolderSecrets Secrets)? _folderAndSecrets; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeMetadata.cs new file mode 100644 index 00000000..09a16dc6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeMetadata.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class DegradedNodeMetadata +{ + private readonly (DegradedFileNode Node, DegradedFileSecrets Secrets)? _fileAndSecrets; + private readonly (DegradedFolderNode Node, DegradedFolderSecrets Secrets)? _folderAndSecrets; + + public DegradedNodeMetadata(DegradedFileNode node, DegradedFileSecrets secrets, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest) + { + _fileAndSecrets = (node, secrets); + MembershipShareId = membershipShareId; + NameHashDigest = nameHashDigest; + } + + public DegradedNodeMetadata(DegradedFolderNode node, DegradedFolderSecrets secrets, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest) + { + _folderAndSecrets = (node, secrets); + MembershipShareId = membershipShareId; + NameHashDigest = nameHashDigest; + } + + public DegradedNode Node => _fileAndSecrets?.Node ?? (DegradedNode)_folderAndSecrets!.Value.Node; + public DegradedNodeSecrets Secrets => _fileAndSecrets?.Secrets ?? (DegradedNodeSecrets)_folderAndSecrets!.Value.Secrets; + public ShareId? MembershipShareId { get; } + public ReadOnlyMemory NameHashDigest { get; } + + public static DegradedNodeMetadata FromFile(DegradedFileMetadata m) => new(m.Node, m.Secrets, m.MembershipShareId, m.NameHashDigest); + public static DegradedNodeMetadata FromFolder(DegradedFolderMetadata m) => new(m.Node, m.Secrets, m.MembershipShareId, m.NameHashDigest); + + public bool TryGetFileElseFolder( + [MaybeNullWhen(false)] out DegradedFileNode fileNode, + [MaybeNullWhen(false)] out DegradedFileSecrets fileSecrets, + [MaybeNullWhen(true)] out DegradedFolderNode folderNode, + [MaybeNullWhen(true)] out DegradedFolderSecrets folderSecrets) + { + if (_fileAndSecrets is null) + { + (folderNode, folderSecrets) = _folderAndSecrets!.Value; + fileNode = null; + fileSecrets = null; + return false; + } + + (fileNode, fileSecrets) = _fileAndSecrets.Value; + folderNode = null; + folderSecrets = null; + return true; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs new file mode 100644 index 00000000..e2a93e2c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -0,0 +1,338 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes.Cryptography; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class DtoToMetadataConverter +{ + public static async Task> ConvertDtoToNodeMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + ShareAndKey? knownShareAndKey, + CancellationToken cancellationToken) + { + var parentKeyResult = await GetParentKeyAsync( + client, + volumeId, + linkDetailsDto.Link.ParentId, + knownShareAndKey, + linkDetailsDto.Membership?.ShareId, + cancellationToken).ConfigureAwait(false); + + return await ConvertDtoToNodeMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false); + } + + public static async Task> ConvertDtoToNodeMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + ValResult parentKeyResult, + CancellationToken cancellationToken) + { + var linkType = linkDetailsDto.Link.Type; + + return linkType switch + { + LinkType.Folder => + (await ConvertDtoToFolderMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false)) + .ConvertVal(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), + + LinkType.File => + (await ConvertDtoToFileMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false)) + .ConvertVal(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), + + // FIXME: handle other existing node types, and determine a way for forward compatibility or degraded result in case a new node type is introduced + _ => throw new NotSupportedException($"Link type {linkType} is not supported."), + }; + } + + public static async ValueTask> ConvertDtoToFolderMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + ValResult parentKeyResult, + CancellationToken cancellationToken) + { + var (linkDto, folderDto, _, membershipDto) = linkDetailsDto; + + if (folderDto is null) + { + // FIXME: handle missing file information with degraded node + throw new InvalidOperationException("Node is a file, but file properties are missing"); + } + + var uid = new NodeUid(volumeId, linkDto.Id); + var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; + + var decryptionResult = await NodeCrypto.DecryptFolderAsync(client, linkDto, folderDto, parentKeyResult, cancellationToken).ConfigureAwait(false); + + if (!NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey) + || !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey) + || !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput) + || !decryptionResult.HashKey.TryGetValue(out var hashKeyOutput)) + { + // FIXME: complete degraded node and cache it + var degradedNode = new DegradedFolderNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = default, + IsTrashed = linkDto.State is LinkState.Trashed, + Author = default, + Errors = null!, // FIXME + }; + + // FIXME: cache secrets + var degradedSecrets = new DegradedFolderSecrets + { + Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), + PassphraseSessionKey = decryptionResult.Link.Passphrase.GetValueOrDefault()?.SessionKey, + NameSessionKey = nameSessionKey, + HashKey = decryptionResult.HashKey.GetValueOrDefault()?.Data, + }; + + throw new NotImplementedException(); + } + + var secrets = new FolderSecrets + { + Key = nodeKey.Value, + PassphraseSessionKey = passphraseOutput.Value.SessionKey, + NameSessionKey = nameSessionKey.Value, + HashKey = hashKeyOutput.Value.Data, + PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous ? passphraseOutput.Value.Data : null, + }; + + await client.Cache.Secrets.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + + var node = new FolderNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameOutput.Value.Data, + NameAuthor = decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure), + + // FIXME: combine with verification failure from name hash key + Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.Value.AuthorshipVerificationFailure), + IsTrashed = linkDto.State is LinkState.Trashed, + }; + + await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + + return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); + } + + public static async Task> ConvertDtoToFileMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + ValResult parentKeyResult, + CancellationToken cancellationToken) + { + var (linkDto, _, fileDto, membershipDto) = linkDetailsDto; + + if (fileDto is null) + { + // FIXME: handle missing file information with degraded node + throw new InvalidOperationException("Node is a file, but file properties are missing"); + } + + if (linkDto.State is LinkState.Draft) + { + // We don't currently expect draft nodes + throw new NotSupportedException("Draft nodes are not supported"); + } + + if (fileDto.ActiveRevision is not { } activeRevisionDto) + { + // FIXME: handle missing revision information with degraded node + throw new InvalidOperationException("Node is a non-draft file, but active revision properties are missing"); + } + + var uid = new NodeUid(volumeId, linkDto.Id); + var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; + + var decryptionResult = await NodeCrypto.DecryptFileAsync(client, linkDto, fileDto, activeRevisionDto, parentKeyResult, cancellationToken) + .ConfigureAwait(false); + + if (!NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey) + || !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey) + || !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput) + || !decryptionResult.ExtendedAttributes.TryGetValue(out var extendedAttributesOutput) + || !decryptionResult.ContentKey.TryGetValue(out var contentKeyOutput)) + { + // FIXME: complete degraded node and cache it + var degradedNode = new DegradedFileNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = default, + IsTrashed = linkDto.State is LinkState.Trashed, + Author = default, + MediaType = fileDto.MediaType, + ActiveRevision = null, + TotalStorageQuotaUsage = fileDto.TotalStorageQuotaUsage, + Errors = null!, + }; + + // FIXME: cache secrets + var degradedSecrets = new DegradedFileSecrets + { + Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), + PassphraseSessionKey = decryptionResult.Link.Passphrase.GetValueOrDefault()?.SessionKey, + NameSessionKey = nameSessionKey, + ContentKey = decryptionResult.ContentKey.GetValueOrDefault()?.Data, + }; + + throw new NotImplementedException(); + } + + var secrets = new FileSecrets + { + Key = nodeKey.Value, + PassphraseSessionKey = passphraseOutput.Value.SessionKey, + NameSessionKey = nameSessionKey.Value, + ContentKey = contentKeyOutput.Value.Data, + PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous + ? passphraseOutput.Value.Data + : (ReadOnlyMemory?)null, + }; + + await client.Cache.Secrets.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + + var extendedAttributes = extendedAttributesOutput.Value.Data; + + var node = new FileNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameOutput.Value.Data, + NameAuthor = decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure), + + // FIXME: combine with verification failure from name hash key + Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.Value.AuthorshipVerificationFailure), + IsTrashed = linkDto.State is LinkState.Trashed, + MediaType = fileDto.MediaType, + ActiveRevision = new Revision + { + Uid = new RevisionUid(uid, activeRevisionDto.Id), + CreationTime = activeRevisionDto.CreationTime, + StorageQuotaConsumption = activeRevisionDto.StorageQuotaConsumption, + ClaimedSize = extendedAttributes?.Common?.Size, + ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, + Thumbnails = [], // FIXME: thumbnails + ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.Value.AuthorshipVerificationFailure), + }, + TotalStorageQuotaUsage = fileDto.TotalStorageQuotaUsage, + }; + + await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + + return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); + } + + private static async ValueTask> GetParentKeyAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkId? parentId, + ShareAndKey? shareAndKeyToUse, + ShareId? childMembershipShareId, + CancellationToken cancellationToken) + { + if (childMembershipShareId is not null && childMembershipShareId == shareAndKeyToUse?.Share.Id) + { + return shareAndKeyToUse.Value.Key; + } + + var currentId = parentId; + var currentMembershipShareId = childMembershipShareId; + + var linkAncestry = new Stack(8); + + PgpPrivateKey? lastKey = null; + + try + { + while (currentId is not null) + { + if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) + { + lastKey = shareKeyToUse; + break; + } + + var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(new NodeUid(volumeId, currentId.Value), cancellationToken) + .ConfigureAwait(false); + + var folderKey = folderSecretsResult?.Merge(x => x.Key, x => x.Key); + + if (folderKey is not null) + { + lastKey = folderKey.Value; + break; + } + + var linkDetailsResponse = await client.Api.Links.GetDetailsAsync(volumeId, [currentId.Value], cancellationToken).ConfigureAwait(false); + + var linkDetails = linkDetailsResponse.Links[0]; + + linkAncestry.Push(linkDetails); + + var (link, _, _, membership) = linkDetails; + + currentId = link.ParentId; + + currentMembershipShareId = membership?.ShareId; + } + } + catch (Exception e) + { + return new ProtonDriveError(e.Message); + } + + if (lastKey is not { } currentParentKey) + { + if (shareAndKeyToUse is not null) + { + currentParentKey = shareAndKeyToUse.Value.Key; + } + else + { + if (currentMembershipShareId is null) + { + return new ProtonDriveError("No membership available to access node"); + } + + (_, currentParentKey) = await ShareOperations.GetShareAsync(client, currentMembershipShareId.Value, cancellationToken).ConfigureAwait(false); + } + } + + while (linkAncestry.TryPop(out var ancestorLinkDetails)) + { + var decryptionResult = await ConvertDtoToNodeMetadataAsync( + client, + volumeId, + ancestorLinkDetails, + currentParentKey, + cancellationToken).ConfigureAwait(false); + + if (!decryptionResult.TryGetFolderKeyElseError(out var folderKey, out var error)) + { + // TODO: wrap error for more context? + return error; + } + + currentParentKey = folderKey.Value; + } + + return currentParentKey; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs index a91c620d..6627df15 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs @@ -1,3 +1,3 @@ namespace Proton.Drive.Sdk.Nodes; -internal sealed class FileDraftNode : FileOrFileDraftNode; +internal sealed record FileDraftNode : FileOrFileDraftNode; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileMetadata.cs new file mode 100644 index 00000000..0e09ad8d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileMetadata.cs @@ -0,0 +1,5 @@ +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct FileMetadata(FileNode Node, FileSecrets Secrets, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs index 295d2712..65fd349a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed class FileNode : FileOrFileDraftNode +public sealed record FileNode : FileOrFileDraftNode { public required Revision ActiveRevision { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 28db0ab8..9f6a077d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -38,7 +38,7 @@ internal static class FileOperations var clientUid = await client.GetClientUidAsync(cancellationToken).ConfigureAwait(false); - var parameters = new FileCreationParameters + var request = new FileCreationRequest { ClientUid = clientUid, Name = encryptedName, @@ -57,7 +57,7 @@ internal static class FileOperations RevisionUid draftRevisionUid; try { - var response = await client.Api.Files.CreateFileAsync(parentUid.VolumeId, parameters, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Files.CreateFileAsync(parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); var draftNodeUid = new NodeUid(parentUid.VolumeId, response.Identifiers.LinkId); draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); @@ -91,14 +91,16 @@ internal static class FileOperations public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) { - var fileSecrets = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); + var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); + + var fileSecrets = fileSecretsResult?.GetValueOrDefault(); if (fileSecrets is null) { - var nodeProvisionResult = await NodeOperations.GetFreshNodeAndSecretsAsync(client, fileUid, knownShareAndKey: null, cancellationToken) + var metadataResult = await NodeOperations.GetFreshNodeMetadataAsync(client, fileUid, knownShareAndKey: null, cancellationToken) .ConfigureAwait(false); - fileSecrets = nodeProvisionResult.GetFileSecretsOrThrow(); + fileSecrets = metadataResult.GetFileSecretsOrThrow(); } return fileSecrets; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs index 9aa71665..771c12f5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes; -public abstract class FileOrFileDraftNode : Node +public abstract record FileOrFileDraftNode : Node { public required string MediaType { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs index 3e6472ae..1395faec 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs @@ -7,27 +7,26 @@ namespace Proton.Drive.Sdk.Nodes; internal sealed class FolderChildrenBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey parentKey) - : BatchLoaderBase> + : BatchLoaderBase> { private readonly ProtonDriveClient _client = client; private readonly VolumeId _volumeId = volumeId; private readonly PgpPrivateKey _parentKey = parentKey; - protected override async ValueTask>> LoadBatchAsync( + protected override async ValueTask>> LoadBatchAsync( ReadOnlyMemory ids, CancellationToken cancellationToken) { - var response = await _client.Api.Links.GetLinkDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); + var response = await _client.Api.Links.GetDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); - var nodeResults = new List>(ids.Length); + var nodeResults = new List>(ids.Length); foreach (var linkDetails in response.Links) { - var nodeId = new NodeUid(_volumeId, linkDetails.Link.Id); + var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync(_client, _volumeId, linkDetails, _parentKey, cancellationToken) + .ConfigureAwait(false); - var nodeAndSecretsResult = await NodeCrypto.DecryptNodeAsync(_client, nodeId, linkDetails, _parentKey, cancellationToken).ConfigureAwait(false); - - var nodeResult = nodeAndSecretsResult.ToNodeResult(); + var nodeResult = nodeMetadataResult.ToNodeResult(); nodeResults.Add(nodeResult); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderMetadata.cs new file mode 100644 index 00000000..f5ece066 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderMetadata.cs @@ -0,0 +1,5 @@ +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct FolderMetadata(FolderNode Node, FolderSecrets Secrets, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs index 64cb66ad..cbe1a0e6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs @@ -1,5 +1,5 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed class FolderNode : Node +public sealed record FolderNode : Node { } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 77fa9457..78dc335f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -9,7 +9,7 @@ namespace Proton.Drive.Sdk.Nodes; internal static class FolderOperations { - public static async IAsyncEnumerable> EnumerateChildrenAsync( + public static async IAsyncEnumerable> EnumerateChildrenAsync( ProtonDriveClient client, NodeUid folderId, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -78,7 +78,7 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, out var keyPassphraseSignature, out var armoredKey); - var parameters = new FolderCreationParameters + var request = new FolderCreationRequest { Name = encryptedName, NameHashDigest = nameHashDigest, @@ -90,7 +90,7 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, HashKey = key.EncryptAndSign(hashKey, key), }; - var response = await client.Api.Folders.CreateFolderAsync(parentId.VolumeId, parameters, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Folders.CreateFolderAsync(parentId.VolumeId, request, cancellationToken).ConfigureAwait(false); var folderId = new NodeUid(parentId.VolumeId, response.FolderId.Value); @@ -121,13 +121,15 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, return folderNode; } - internal static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid folderId, CancellationToken cancellationToken) + public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid folderId, CancellationToken cancellationToken) { - var folderSecrets = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderId, cancellationToken).ConfigureAwait(false); + var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderId, cancellationToken).ConfigureAwait(false); + + var folderSecrets = folderSecretsResult?.GetValueOrDefault(); if (folderSecrets is null) { - var nodeProvisionResult = await NodeOperations.GetFreshNodeAndSecretsAsync(client, folderId, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + var nodeProvisionResult = await NodeOperations.GetFreshNodeMetadataAsync(client, folderId, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); folderSecrets = nodeProvisionResult.GetFolderSecretsOrThrow(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs index 9f0a66d0..1cc72329 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs @@ -1,10 +1,7 @@ -using Proton.Sdk; +namespace Proton.Drive.Sdk.Nodes; -namespace Proton.Drive.Sdk.Nodes; - -internal sealed class InvalidNameError(string name, Result author, string message) +internal sealed class InvalidNameError(string name, string message) : ProtonDriveError(message) { public string Name { get; } = name; - public Result Author { get; } = author; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index d2854c1b..5ee14cc5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -7,7 +7,7 @@ namespace Proton.Drive.Sdk.Nodes; [JsonDerivedType(typeof(FolderNode), typeDiscriminator: "folder")] [JsonDerivedType(typeof(FileNode), typeDiscriminator: "file")] [JsonDerivedType(typeof(FileDraftNode), typeDiscriminator: "fileDraft")] -public abstract class Node +public abstract record Node { public required NodeUid Uid { get; init; } @@ -17,7 +17,7 @@ public abstract class Node public required bool IsTrashed { get; init; } - public required Result NameAuthor { get; init; } + public required ValResult NameAuthor { get; init; } - public required Result Author { get; init; } + public required ValResult Author { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs deleted file mode 100644 index e2bf42ed..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecrets.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Proton.Drive.Sdk.Nodes; - -internal readonly struct NodeAndSecrets -{ - private readonly (FileNode Node, FileSecrets Secrets)? _fileAndSecrets; - private readonly (FolderNode Node, FolderSecrets Secrets)? _folderAndSecrets; - - public NodeAndSecrets(FileNode node, FileSecrets secrets) - { - _fileAndSecrets = (node, secrets); - } - - public NodeAndSecrets(FolderNode node, FolderSecrets secrets) - { - _folderAndSecrets = (node, secrets); - } - - public bool TryGetFileElseFolder( - [MaybeNullWhen(false)] out FileNode fileNode, - [MaybeNullWhen(false)] out FileSecrets fileSecrets, - [MaybeNullWhen(true)] out FolderNode folderNode, - [MaybeNullWhen(true)] out FolderSecrets folderSecrets) - { - if (_fileAndSecrets is null) - { - (folderNode, folderSecrets) = _folderAndSecrets!.Value; - fileNode = null; - fileSecrets = null; - return false; - } - - (fileNode, fileSecrets) = _fileAndSecrets.Value; - folderNode = null; - folderSecrets = null; - return true; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs deleted file mode 100644 index 761f3a79..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeAndSecretsProvisionResultExtensions.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk.Api.Links; -using Proton.Sdk; - -namespace Proton.Drive.Sdk.Nodes; - -internal static class NodeAndSecretsProvisionResultExtensions -{ - public static Node GetNodeOrThrow(this Result provisionResult) - { - var nodeAndSecrets = provisionResult.GetValueOrThrow(); - - return nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) - ? fileNode - : folderNode; - } - - public static FolderNode GetFolderNodeOrThrow(this Result provisionResult) - { - var nodeAndSecrets = provisionResult.GetValueOrThrow(); - - if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _)) - { - throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); - } - - return folderNode; - } - - public static FolderSecrets GetFolderSecretsOrThrow(this Result provisionResult) - { - var nodeAndSecrets = provisionResult.GetValueOrThrow(); - - if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) - { - throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); - } - - return folderSecrets; - } - - public static FileSecrets GetFileSecretsOrThrow(this Result provisionResult) - { - var nodeAndSecrets = provisionResult.GetValueOrThrow(); - - if (!nodeAndSecrets.TryGetFileElseFolder(out _, out var fileSecrets, out var folderNode, out _)) - { - throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); - } - - return fileSecrets; - } - - public static bool TryGetFolderKeyElseError( - this Result provisionResult, - [NotNullWhen(true)] out PgpPrivateKey? folderKey, - [MaybeNullWhen(true)] out ProtonDriveError error) - { - if (!provisionResult.TryGetValueElseError(out var nodeAndSecrets, out var degradedNodeAndSecrets)) - { - if (degradedNodeAndSecrets.TryGetFileElseFolder(out var degradedFileNode, out _, out var degradedFolderNode, out var degradedFolderSecrets)) - { - folderKey = null; - error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(degradedFileNode.Id, LinkType.File)); - return false; - } - - if (degradedFolderSecrets.Key is null) - { - folderKey = null; - error = degradedFolderNode.Errors[0]; - return false; - } - - folderKey = degradedFolderSecrets.Key; - error = null; - return true; - } - - if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) - { - folderKey = null; - error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(fileNode.Uid, LinkType.File)); - return false; - } - - folderKey = folderSecrets.Key; - error = null; - return true; - } - - public static Result ToNodeResult(this Result provisionResult) - { - return provisionResult.Convert( - x => x.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? (Node)fileNode : folderNode, - x => x.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? (DegradedNode)fileNode : folderNode); - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs deleted file mode 100644 index ca2cfc71..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeCrypto.cs +++ /dev/null @@ -1,384 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Serialization; -using Proton.Sdk; -using Proton.Sdk.Cryptography; - -namespace Proton.Drive.Sdk.Nodes; - -internal static class NodeCrypto -{ - public static async ValueTask> DecryptNodeAsync( - ProtonDriveClient client, - NodeUid id, - LinkDetailsDto linkDetails, - Result parentKeyResult, - CancellationToken cancellationToken) - { - var (link, folder, file, membership) = linkDetails; - - var parentId = link.ParentId is not null ? new NodeUid(id.VolumeId, link.ParentId.Value) : (NodeUid?)null; - - var nodeAuthorshipClaim = await AuthorshipClaim.CreateAsync(client, link.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); - - var nameAuthorshipClaim = link.NameSignatureEmailAddress != link.SignatureEmailAddress - ? await AuthorshipClaim.CreateAsync(client, link.NameSignatureEmailAddress, cancellationToken).ConfigureAwait(false) - : nodeAuthorshipClaim; - - Result, ProtonDriveError> nameResult; - PhasedDecryptionOutput>? passphraseOutput; - DecryptionError? passphraseError; - ProtonDriveError? parentKeyError; - - if (parentKeyResult.TryGetValueElseError(out var parentKey, out var parentNodeKeyInnerError)) - { - nameResult = DecryptName(link.Name, parentKey, nameAuthorshipClaim); - - (passphraseOutput, passphraseError) = DecryptPassphrase(parentKey, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); - } - else - { - parentKeyError = new ProtonDriveError("Decryption key unavailable", parentNodeKeyInnerError); - - nameResult = parentKeyError; - passphraseOutput = null; - passphraseError = null; - } - - var nameOutput = nameResult.GetValueOrDefault(); - - var (nodeKey, nodeKeyError) = UnlockNodeKey(link.Key, passphraseOutput?.Data); - - if (link.Type is LinkType.Folder) - { - var (hashKeyOutput, hashKeyError) = DecryptHashKey(folder?.HashKey, nodeKey, nodeAuthorshipClaim); - - if (nameOutput is null || nodeKey is null || passphraseOutput is null || hashKeyOutput is null) - { - var degradedFolderNode = new DegradedFolderNode - { - Id = id, - ParentId = parentId, - Name = nameResult.Convert(x => x.Data), - NameAuthor = null!, - IsTrashed = link.State is LinkState.Trashed, - Author = null!, - Errors = null!, - }; - - var degradedFolderSecrets = new DegradedFolderSecrets - { - HashKey = hashKeyOutput?.Data, - Key = nodeKey, - NameSessionKey = nameOutput?.SessionKey, - PassphraseSessionKey = passphraseOutput?.SessionKey, - }; - - // FIXME: cache secrets - throw new NotImplementedException(); - } - - var folderSecrets = new FolderSecrets - { - HashKey = hashKeyOutput.Value.Data, - Key = nodeKey.Value, - NameSessionKey = nameOutput.Value.SessionKey, - PassphraseSessionKey = passphraseOutput.Value.SessionKey, - }; - - await client.Cache.Secrets.SetFolderSecretsAsync(id, folderSecrets, cancellationToken).ConfigureAwait(false); - - var folderNode = new FolderNode - { - Uid = id, - ParentUid = parentId, - Name = nameOutput.Value.Data, - NameAuthor = nameOutput.Value.Author, - Author = passphraseOutput.Value.Author, // FIXME: combine with signature error from name hash key - IsTrashed = link.State is LinkState.Trashed, - }; - - await client.Cache.Entities.SetNodeAsync(id, folderNode, membership?.ShareId, link.NameHashDigest, cancellationToken).ConfigureAwait(false); - - return new NodeAndSecrets(folderNode, folderSecrets); - } - - if (file is null) - { - // FIXME: handle missing file information with degraded node - throw new NotImplementedException(); - } - - if (link.State is LinkState.Draft) - { - // We don't currently expect draft nodes - throw new NotSupportedException(); - } - - if (file.ActiveRevision is null) - { - // FIXME: handle missing revision information with degraded node - throw new NotImplementedException(); - } - - var contentKey = nodeKey?.DecryptSessionKey(file.ContentKeyPacket.Span); - - // FIXME: verify content key packet signature - - var (extendedAttributesOutput, extendedAttributesError) = - DecryptExtendedAttributes(file.ActiveRevision.ExtendedAttributes, nodeKey, nodeAuthorshipClaim); - - if (nameOutput is null || nodeKey is null || passphraseOutput is null || contentKey is null || extendedAttributesError is not null) - { - var degradedFileNode = new DegradedFileNode - { - Id = default, - ParentId = null, - Name = nameResult.Convert(x => x.Data), - NameAuthor = default, - IsTrashed = false, - Author = default, - MediaType = file.MediaType, - ActiveRevision = null, - TotalStorageQuotaUsage = file.TotalStorageQuotaUsage, - Errors = null!, - }; - - var degradedFileSecrets = new DegradedFileSecrets - { - Key = nodeKey, - PassphraseSessionKey = passphraseOutput?.SessionKey, - NameSessionKey = nameOutput?.SessionKey, - ContentKey = contentKey, - }; - - // FIXME: cache secrets - throw new NotImplementedException(); - } - - var fileSecrets = new FileSecrets - { - Key = nodeKey.Value, - PassphraseSessionKey = passphraseOutput.Value.SessionKey, - NameSessionKey = nameOutput.Value.SessionKey, - ContentKey = contentKey.Value, - }; - - await client.Cache.Secrets.SetFileSecretsAsync(id, fileSecrets, cancellationToken).ConfigureAwait(false); - - var extendedAttributes = extendedAttributesOutput?.Data; - - var fileNode = new FileNode - { - Uid = id, - ParentUid = parentId, - Name = nameOutput.Value.Data, - IsTrashed = link.State is LinkState.Trashed, - NameAuthor = nameOutput.Value.Author, - Author = passphraseOutput.Value.Author, // FIXME: combine with signature error from content key - MediaType = file.MediaType, - ActiveRevision = new Revision - { - Uid = new RevisionUid(id, file.ActiveRevision.Id), - CreationTime = file.ActiveRevision.CreationTime, - StorageQuotaConsumption = file.ActiveRevision.StorageQuotaConsumption, - ClaimedSize = extendedAttributes?.Common?.Size, - ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, - Thumbnails = [], // FIXME: thumbnails - ContentAuthor = extendedAttributesOutput?.Author, - }, - TotalStorageQuotaUsage = file.TotalStorageQuotaUsage, - }; - - await client.Cache.Entities.SetNodeAsync(id, fileNode, membership?.ShareId, link.NameHashDigest, cancellationToken).ConfigureAwait(false); - - return new NodeAndSecrets(fileNode, fileSecrets); - } - - public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHashKey) - { - var maxNameByteLength = Encoding.UTF8.GetByteCount(name); - var nameBytes = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) - ? nameHeapMemoryOwner.Memory.Span - : stackalloc byte[maxNameByteLength]; - - using (nameHeapMemoryOwner) - { - var nameByteLength = Encoding.UTF8.GetBytes(name, nameBytes); - nameBytes = nameBytes[..nameByteLength]; - - return HMACSHA256.HashData(parentFolderHashKey, nameBytes); - } - } - - private static (PhasedDecryptionOutput>? Output, DecryptionError? Error) DecryptPassphrase( - PgpPrivateKey parentNodeKey, - PgpArmoredMessage encryptedPassphrase, - PgpArmoredSignature? signature, - AuthorshipClaim authorshipClaim) - { - try - { - var passphrase = DecryptMessage(encryptedPassphrase, signature, parentNodeKey, authorshipClaim, out var sessionKey, out var author); - - return (new PhasedDecryptionOutput>(sessionKey, passphrase, author), null); - } - catch (Exception e) - { - return (null, new DecryptionError(e.Message, authorshipClaim.Author)); - } - } - - private static (PgpPrivateKey? NodeKey, string? ErrorMessage) UnlockNodeKey(PgpArmoredPrivateKey lockedKey, ReadOnlyMemory? passphrase) - { - if (passphrase is null) - { - return (null, null); - } - - try - { - var nodeKey = PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase.Value.Span); - - return (nodeKey, null); - } - catch (Exception e) - { - return (null, e.Message); - } - } - - private static (DecryptionOutput>? Output, DecryptionError? Error) DecryptHashKey( - PgpArmoredMessage? encryptedHashKey, - PgpPrivateKey? nodeKey, - AuthorshipClaim authorshipClaim) - { - if (nodeKey is null) - { - return (Output: null, Error: null); - } - - if (encryptedHashKey is null) - { - return (Output: null, new DecryptionError("Folder information missing for link of type Folder", authorshipClaim.Author)); - } - - try - { - var hashKey = DecryptMessage(encryptedHashKey.Value, detachedSignature: null, nodeKey.Value, authorshipClaim, out _, out var author); - - return ((hashKey, author), null); - } - catch (Exception e) - { - return (Output: null, new DecryptionError(e.Message, authorshipClaim.Author)); - } - } - - private static Result, ProtonDriveError> DecryptName( - PgpArmoredMessage encryptedName, - PgpPrivateKey parentNodeKey, - AuthorshipClaim authorshipClaim) - { - try - { - var nameUtf8Bytes = DecryptMessage(encryptedName, detachedSignature: null, parentNodeKey, authorshipClaim, out var sessionKey, out var author); - - var name = Encoding.UTF8.GetString(nameUtf8Bytes); - - return ValidateName(name, author, out var invalidNameError) - ? new PhasedDecryptionOutput(sessionKey, name, author) - : invalidNameError; - } - catch (Exception e) - { - return new DecryptionError(e.Message, authorshipClaim.Author); - } - } - - private static (DecryptionOutput? Output, DecryptionError? Error) DecryptExtendedAttributes( - PgpArmoredMessage? encryptedExtendedAttributes, - PgpPrivateKey? nodeKey, - AuthorshipClaim authorshipClaim) - { - if (encryptedExtendedAttributes is null) - { - return (Output: null, Error: null); - } - - if (nodeKey is null) - { - return (Output: null, Error: null); - } - - try - { - var serializedExtendedAttributes = DecryptMessage( - encryptedExtendedAttributes.Value, - detachedSignature: null, - nodeKey.Value, - authorshipClaim, - out _, - out var author); - - try - { - var extendedAttributes = JsonSerializer.Deserialize(serializedExtendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); - - return ((extendedAttributes, author), Error: null); - } - catch (Exception e) - { - return (Output: null, new DecryptionError($"Failed to deserialize extended attributes: {e.Message}", authorshipClaim.Author)); - } - } - catch (Exception e) - { - return (Output: null, new DecryptionError(e.Message, authorshipClaim.Author)); - } - } - - private static ArraySegment DecryptMessage( - PgpArmoredMessage encryptedMessage, - PgpArmoredSignature? detachedSignature, - PgpPrivateKey decryptionKey, - AuthorshipClaim authorshipClaim, - out PgpSessionKey sessionKey, - out Result author) - { - sessionKey = decryptionKey.DecryptSessionKey(encryptedMessage); - - var verificationKeyRing = authorshipClaim.GetKeyRing(anonymousFallbackKey: decryptionKey); - - var plaintext = detachedSignature is not null - ? sessionKey.DecryptAndVerify(encryptedMessage.Bytes.Span, detachedSignature.Value.Bytes.Span, verificationKeyRing, out var verificationResult) - : sessionKey.DecryptAndVerify(encryptedMessage, verificationKeyRing, out verificationResult); - - author = authorshipClaim.ToAuthorshipResult(verificationResult); - - return plaintext; - } - - // TODO: find a more suitable place to put this validation than in a class that claims to be about cryptography - private static bool ValidateName(string name, Result author, [MaybeNullWhen(true)] out InvalidNameError error) - { - if (string.IsNullOrEmpty(name)) - { - error = new InvalidNameError(name, author, "Name must not be empty"); - return false; - } - - if (name.Contains('/')) - { - error = new InvalidNameError(name, author, "Name must not contain the character '/'"); - return false; - } - - error = null; - return true; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadata.cs new file mode 100644 index 00000000..051e7018 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadata.cs @@ -0,0 +1,60 @@ +using System.Diagnostics.CodeAnalysis; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly struct NodeMetadata +{ + private readonly (FileNode Node, FileSecrets Secrets)? _fileAndSecrets; + private readonly (FolderNode Node, FolderSecrets Secrets)? _folderAndSecrets; + + public NodeMetadata(FileNode node, FileSecrets secrets, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest) + { + _fileAndSecrets = (node, secrets); + MembershipShareId = membershipShareId; + NameHashDigest = nameHashDigest; + } + + public NodeMetadata(FolderNode node, FolderSecrets secrets, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest) + { + _folderAndSecrets = (node, secrets); + MembershipShareId = membershipShareId; + NameHashDigest = nameHashDigest; + } + + public Node Node => _fileAndSecrets?.Node ?? (Node)_folderAndSecrets!.Value.Node; + public NodeSecrets Secrets => _fileAndSecrets?.Secrets ?? (NodeSecrets)_folderAndSecrets!.Value.Secrets; + public ShareId? MembershipShareId { get; } + public ReadOnlyMemory NameHashDigest { get; } + + public static NodeMetadata FromFile(FileMetadata m) => new(m.Node, m.Secrets, m.MembershipShareId, m.NameHashDigest); + public static NodeMetadata FromFolder(FolderMetadata m) => new(m.Node, m.Secrets, m.MembershipShareId, m.NameHashDigest); + + public bool TryGetFileElseFolder( + [MaybeNullWhen(false)] out FileNode fileNode, + [MaybeNullWhen(false)] out FileSecrets fileSecrets, + [MaybeNullWhen(true)] out FolderNode folderNode, + [MaybeNullWhen(true)] out FolderSecrets folderSecrets) + { + if (_fileAndSecrets is null) + { + (folderNode, folderSecrets) = _folderAndSecrets!.Value; + fileNode = null; + fileSecrets = null; + return false; + } + + (fileNode, fileSecrets) = _fileAndSecrets.Value; + folderNode = null; + folderSecrets = null; + return true; + } + + public void Deconstruct(out Node node, out NodeSecrets secrets, out ShareId? membershipShareId, out ReadOnlyMemory nameHashDigest) + { + node = Node; + secrets = Secrets; + membershipShareId = MembershipShareId; + nameHashDigest = NameHashDigest; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs new file mode 100644 index 00000000..3a3dd407 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs @@ -0,0 +1,100 @@ +using System.Diagnostics.CodeAnalysis; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class NodeMetadataResultExtensions +{ + public static Node GetNodeOrThrow(this ValResult metadataResult) + { + var metadata = metadataResult.GetValueOrThrow(); + + return metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? fileNode : folderNode; + } + + public static FolderNode GetFolderNodeOrThrow(this ValResult metadataResult) + { + var metadata = metadataResult.GetValueOrThrow(); + + if (metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _)) + { + throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); + } + + return folderNode; + } + + public static FolderSecrets GetFolderSecretsOrThrow(this ValResult metadataResult) + { + var metadata = metadataResult.GetValueOrThrow(); + + if (metadata.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) + { + throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); + } + + return folderSecrets; + } + + public static FileSecrets GetFileSecretsOrThrow(this ValResult metadataResult) + { + var metadata = metadataResult.GetValueOrThrow(); + + if (!metadata.TryGetFileElseFolder(out _, out var fileSecrets, out var folderNode, out _)) + { + throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); + } + + return fileSecrets; + } + + public static bool TryGetFolderKeyElseError( + this ValResult metadataResult, + [NotNullWhen(true)] out PgpPrivateKey? folderKey, + [MaybeNullWhen(true)] out ProtonDriveError error) + { + if (!metadataResult.TryGetValueElseError(out var nodeAndSecrets, out var degradedNodeAndSecrets)) + { + if (degradedNodeAndSecrets.TryGetFileElseFolder(out var degradedFileNode, out _, out var degradedFolderNode, out var degradedFolderSecrets)) + { + folderKey = null; + error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(degradedFileNode.Uid, LinkType.File)); + return false; + } + + if (degradedFolderSecrets.Key is null) + { + folderKey = null; + error = degradedFolderNode.Errors[0]; + return false; + } + + folderKey = degradedFolderSecrets.Key; + error = null; + return true; + } + + if (nodeAndSecrets.Value.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) + { + folderKey = null; + error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(fileNode.Uid, LinkType.File)); + return false; + } + + folderKey = folderSecrets.Key; + error = null; + return true; + } + + public static RefResult ToNodeResult(this ValResult metadataResult) + { + return metadataResult.ConvertRef(metadata => metadata.Node, metadata => metadata.Node); + } + + public static RefResult ToSecretsResult(this ValResult metadataResult) + { + return metadataResult.ConvertRef(metadata => metadata.Secrets, metadata => metadata.Secrets); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 8ae7176a..e6f52457 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -1,9 +1,11 @@ -using System.Security.Cryptography; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; using System.Text; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Nodes.Cryptography; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; using Proton.Sdk; @@ -24,27 +26,26 @@ public static async ValueTask GetMyFilesFolderAsync(ProtonDriveClien var shareAndKey = await ShareOperations.GetShareAsync(client, shareId.Value, cancellationToken).ConfigureAwait(false); - var node = await GetNodeAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, cancellationToken).ConfigureAwait(false); + var metadata = await GetNodeMetadataAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, cancellationToken).ConfigureAwait(false); - return (FolderNode)node; + return (FolderNode)metadata.Node; } - public static async ValueTask GetNodeAsync( + public static async ValueTask GetNodeMetadataAsync( ProtonDriveClient client, - NodeUid nodeId, + NodeUid uid, ShareAndKey? knownShareAndKey, CancellationToken cancellationToken) { - var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(nodeId, cancellationToken).ConfigureAwait(false); + var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); - if (cachedNodeInfo is not var (nodeResult, _, _)) - { - var nodeAndSecretsResult = await GetFreshNodeAndSecretsAsync(client, nodeId, knownShareAndKey, cancellationToken).ConfigureAwait(false); + var metadataResult = cachedNodeInfo is not null + ? await GetNodeMetadataAsync(client, uid, cachedNodeInfo.Value, cancellationToken).ConfigureAwait(false) + : null; - nodeResult = nodeAndSecretsResult.ToNodeResult(); - } + metadataResult ??= await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); - return nodeResult.GetValueOrThrow(); + return metadataResult.Value.GetValueOrThrow(); } public static void GetCommonCreationParameters( @@ -78,165 +79,227 @@ public static void GetCommonCreationParameters( GetNameParameters(name, parentFolderKey, parentFolderHashKey, nameSessionKey, signingKey, out encryptedName, out nameHashDigest); } - public static async ValueTask> GetFreshNodeAndSecretsAsync( + public static async ValueTask> GetFreshNodeMetadataAsync( ProtonDriveClient client, - NodeUid nodeId, + NodeUid uid, ShareAndKey? knownShareAndKey, CancellationToken cancellationToken) { - var response = await client.Api.Links.GetLinkDetailsAsync(nodeId.VolumeId, [nodeId.LinkId], cancellationToken).ConfigureAwait(false); - - var linkDetails = response.Links[0]; - - var parentKeyResult = await GetParentKeyAsync( - client, - nodeId.VolumeId, - linkDetails.Link.ParentId, - knownShareAndKey, - linkDetails.Membership?.ShareId, - cancellationToken).ConfigureAwait(false); + var response = await client.Api.Links.GetDetailsAsync(uid.VolumeId, [uid.LinkId], cancellationToken).ConfigureAwait(false); - return await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetails, parentKeyResult, cancellationToken).ConfigureAwait(false); + return await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync(client, uid.VolumeId, response.Links[0], knownShareAndKey, cancellationToken) + .ConfigureAwait(false); } - public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) + public static async Task MoveSingleAsync( + ProtonDriveClient client, + NodeUid uid, + NodeUid newParentUid, + string? newName, + CancellationToken cancellationToken) { // FIXME: try to get the information from cache first - var response = await client.Api.Links.GetContextShareAsync(nodeUid.VolumeId, nodeUid.LinkId, cancellationToken).ConfigureAwait(false); + var membershipAddress = await GetMembershipAddressAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); - var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); + using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - return await client.Account.GetAddressAsync(client, share.MembershipAddressId, cancellationToken).ConfigureAwait(false); - } - - private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) - { - ShareVolumeDto volumeDto; - ShareDto shareDto; - LinkDetailsDto linkDetailsDto; + var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); - try + if (uid == newParentUid) { - (volumeDto, shareDto, linkDetailsDto) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"Node {uid} cannot be moved onto itself"); } - catch (ProtonApiException e) when (e.Code == ResponseCode.DoesNotExist) + + if (uid.VolumeId != newParentUid.VolumeId) { - return await CreateMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"Node {uid} cannot have destination node {newParentUid} as parent as they are not on the same volume"); } - var nodeId = new NodeUid(volumeDto.Id, linkDetailsDto.Link.Id); + var (originNode, originSecrets, membershipShareId, originNameHashDigest) = await GetNodeMetadataAsync(client, uid, null, cancellationToken) + .ConfigureAwait(false); - var (share, shareKey) = await ShareCrypto.DecryptShareAsync( - client, - shareDto.Id, - shareDto.Key, - shareDto.Passphrase, - shareDto.AddressId, - nodeId, - cancellationToken).ConfigureAwait(false); + GetNameParameters( + newName ?? originNode.Name, // FIXME: validate name + destinationFolderSecrets.Key, + destinationFolderSecrets.HashKey.Span, + originSecrets.NameSessionKey, + signingKey, + out var encryptedName, + out var nameHashDigest); - var nodeProvisionResult = await NodeCrypto.DecryptNodeAsync(client, nodeId, linkDetailsDto, shareKey, cancellationToken).ConfigureAwait(false); + var passphraseKeyPacket = destinationFolderSecrets.Key.EncryptSessionKey(originSecrets.PassphraseSessionKey); - var folderNode = nodeProvisionResult.GetFolderNodeOrThrow(); + ReadOnlyMemory? passphraseSignature = null; + string? signatureEmailAddress = null; - await client.Cache.Entities.SetMyFilesShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); + if (originSecrets.PassphraseForAnonymousMove is not null) + { + passphraseSignature = signingKey.Sign(originSecrets.PassphraseForAnonymousMove.Value.Span); + signatureEmailAddress = membershipAddress.EmailAddress; + } - return folderNode; + var request = new MoveSingleLinkRequest + { + Name = encryptedName, + Passphrase = passphraseKeyPacket, + NameHashDigest = nameHashDigest, + ParentLinkId = newParentUid.LinkId, + OriginalNameHashDigest = originNameHashDigest, + NameSignatureEmailAddress = membershipAddress.EmailAddress, + PassphraseSignature = passphraseSignature, + SignatureEmailAddress = signatureEmailAddress, + }; + + await client.Api.Links.MoveAsync(newParentUid.VolumeId, uid.LinkId, request, cancellationToken).ConfigureAwait(false); + + var newNode = originNode with { ParentUid = newParentUid, Name = newName ?? originNode.Name }; + + await client.Cache.Entities.SetNodeAsync(uid, newNode, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); } - private static async Task> GetParentKeyAsync( + // For future use + public static async Task MoveMultipleAsync( ProtonDriveClient client, - VolumeId volumeId, - LinkId? parentId, - ShareAndKey? shareAndKeyToUse, - ShareId? childMembershipShareId, + IEnumerable uids, + NodeUid newParentUid, + string? newName, CancellationToken cancellationToken) { - if (childMembershipShareId is not null && childMembershipShareId == shareAndKeyToUse?.Share.Id) - { - return shareAndKeyToUse.Value.Key; - } + // FIXME: try to get the information from cache first + var membershipAddress = await GetMembershipAddressAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); - var currentId = parentId; - var currentMembershipShareId = childMembershipShareId; + using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var linkAncestry = new Stack(8); + var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); - PgpPrivateKey? lastKey = null; + var batch = new List(); - try + foreach (var uid in uids) { - while (currentId is not null) + if (uid.VolumeId != newParentUid.VolumeId) { - if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) - { - lastKey = shareKeyToUse; - break; - } + throw new InvalidOperationException($"Node {uid} cannot have destination node {newParentUid} as parent as they are not on the same volume"); + } - var folderSecrets = await client.Cache.Secrets.TryGetFolderSecretsAsync(new NodeUid(volumeId, currentId.Value), cancellationToken) - .ConfigureAwait(false); + var (originNode, originSecrets, _, originNameHashDigest) = await GetNodeMetadataAsync(client, uid, null, cancellationToken) + .ConfigureAwait(false); - if (folderSecrets is not null) - { - lastKey = folderSecrets.Key; - break; - } + GetNameParameters( + newName ?? originNode.Name, // FIXME: validate name + destinationFolderSecrets.Key, + destinationFolderSecrets.HashKey.Span, + originSecrets.NameSessionKey, + signingKey, + out var encryptedName, + out var nameHashDigest); - var linkDetailsResponse = await client.Api.Links.GetLinkDetailsAsync(volumeId, [currentId.Value], cancellationToken).ConfigureAwait(false); + var passphraseKeyPacket = destinationFolderSecrets.Key.EncryptSessionKey(originSecrets.PassphraseSessionKey); - var linkDetails = linkDetailsResponse.Links[0]; + var itemRequest = new MoveMultipleLinksItem + { + LinkId = uid.LinkId, + Passphrase = passphraseKeyPacket, + Name = encryptedName, + NameHashDigest = nameHashDigest, + OriginalNameHashDigest = originNameHashDigest, + PassphraseSignature = null, // FIXME: sign with parent node key if anonymously-uploaded file + }; + + batch.Add(itemRequest); + } - linkAncestry.Push(linkDetails); + var batchRequest = new MoveMultipleLinksRequest + { + ParentLinkId = newParentUid.LinkId, + Batch = batch, + NameSignatureEmailAddress = membershipAddress.EmailAddress, + SignatureEmailAddress = null, // FIXME: specify for anonymously-uploaded files + }; - var (link, _, _, membership) = linkDetails; + await client.Api.Links.MoveMultipleAsync(newParentUid.VolumeId, batchRequest, cancellationToken).ConfigureAwait(false); - currentId = link.ParentId; + // FIXME: update cache + } - currentMembershipShareId = membership?.ShareId; - } - } - catch (Exception e) + public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) + { + // FIXME: try to get the information from cache first + var response = await client.Api.Links.GetContextShareAsync(nodeUid.VolumeId, nodeUid.LinkId, cancellationToken).ConfigureAwait(false); + + var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); + + return await client.Account.GetAddressAsync(client, share.MembershipAddressId, cancellationToken).ConfigureAwait(false); + } + + public static bool ValidateName( + ValResult, string> decryptionResult, + [NotNullWhen(true)] out PhasedDecryptionOutput? nameOutput, + out RefResult nameResult, + [NotNullWhen(true)] out PgpSessionKey? sessionKey) + { + if (!decryptionResult.TryGetValueElseError(out nameOutput, out var decryptionErrorMessage)) { - return new ProtonDriveError(e.Message); + nameOutput = null; + nameResult = new DecryptionError(decryptionErrorMessage); + sessionKey = null; + return false; } - if (lastKey is not { } currentParentKey) - { - if (shareAndKeyToUse is not null) - { - currentParentKey = shareAndKeyToUse.Value.Key; - } - else - { - if (currentMembershipShareId is null) - { - return new ProtonDriveError("No membership available to access node"); - } + sessionKey = nameOutput.Value.SessionKey; - (_, currentParentKey) = await ShareOperations.GetShareAsync(client, currentMembershipShareId.Value, cancellationToken).ConfigureAwait(false); - } + var name = nameOutput.Value.Data; + + if (string.IsNullOrEmpty(name)) + { + nameResult = new InvalidNameError(name, "Name must not be empty"); + return false; } - while (linkAncestry.TryPop(out var ancestorLinkDetails)) + if (name.Contains('/')) { - var decryptionResult = await NodeCrypto.DecryptNodeAsync( - client, - new NodeUid(volumeId, ancestorLinkDetails.Link.Id), - ancestorLinkDetails, - currentParentKey, - cancellationToken).ConfigureAwait(false); - - if (!decryptionResult.TryGetFolderKeyElseError(out var folderKey, out var error)) - { - // TODO: wrap error for more context? - return error; - } + nameResult = new InvalidNameError(name, "Name must not contain the character '/'"); + return false; + } - currentParentKey = folderKey.Value; + nameResult = name; + return true; + } + + private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + ShareVolumeDto volumeDto; + ShareDto shareDto; + LinkDetailsDto linkDetailsDto; + + try + { + (volumeDto, shareDto, linkDetailsDto) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); } + catch (ProtonApiException e) when (e.Code == ResponseCode.DoesNotExist) + { + return await CreateMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + } + + await client.Cache.Entities.SetMyFilesShareIdAsync(shareDto.Id, cancellationToken).ConfigureAwait(false); + + var nodeUid = new NodeUid(volumeDto.Id, linkDetailsDto.Link.Id); + + var (share, shareKey) = await ShareCrypto.DecryptShareAsync( + client, + shareDto.Id, + shareDto.Key, + shareDto.Passphrase, + shareDto.AddressId, + nodeUid, + cancellationToken).ConfigureAwait(false); + + await client.Cache.Secrets.SetShareKeyAsync(share.Id, shareKey, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); - return currentParentKey; + var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync(client, volumeDto.Id, linkDetailsDto, shareKey, cancellationToken) + .ConfigureAwait(false); + + return metadataResult.GetValueOrThrow().Node; } private static void GetNameParameters( @@ -264,6 +327,56 @@ private static void GetNameParameters( } } + private static async ValueTask?> GetNodeMetadataAsync( + ProtonDriveClient client, + NodeUid uid, + CachedNodeInfo cachedNodeInfo, + CancellationToken cancellationToken) + { + if (!cachedNodeInfo.NodeProvisionResult.TryGetValueElseError(out var node, out var degradedNode)) + { + switch (degradedNode) + { + case DegradedFolderNode degradedFolderNode: + var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(uid, cancellationToken).ConfigureAwait(false); + + return folderSecretsResult is not null && folderSecretsResult.Value.TryGetError(out var degradedFolderSecrets) + ? new DegradedNodeMetadata(degradedFolderNode, degradedFolderSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) + : (ValResult?)null; + + case DegradedFileNode degradedFileNode: + var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(uid, cancellationToken).ConfigureAwait(false); + + return fileSecretsResult is not null && fileSecretsResult.Value.TryGetError(out var degradedFileSecrets) + ? new DegradedNodeMetadata(degradedFileNode, degradedFileSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) + : (ValResult?)null; + + default: + throw new InvalidOperationException($"Degraded node type \"{node?.GetType().Name}\" is not supported"); + } + } + + switch (node) + { + case FolderNode folderNode: + var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(uid, cancellationToken).ConfigureAwait(false); + + return folderSecretsResult is not null && folderSecretsResult.Value.TryGetValue(out var folderSecrets) + ? new NodeMetadata(folderNode, folderSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) + : null; + + case FileNode fileNode: + var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(uid, cancellationToken).ConfigureAwait(false); + + return fileSecretsResult is not null && fileSecretsResult.Value.TryGetValue(out var fileSecrets) + ? new NodeMetadata(fileNode, fileSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) + : null; + + default: + throw new InvalidOperationException($"Node type \"{node.GetType().Name}\" is not supported"); + } + } + private static async ValueTask CreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { var (_, _, folderNode) = await VolumeOperations.CreateVolumeAsync(client, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs index 79aa9c89..820170a7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs @@ -1,4 +1,5 @@ -using Proton.Cryptography.Pgp; +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; namespace Proton.Drive.Sdk.Nodes; @@ -7,4 +8,8 @@ internal class NodeSecrets public required PgpPrivateKey Key { get; init; } public required PgpSessionKey PassphraseSessionKey { get; init; } public required PgpSessionKey NameSessionKey { get; init; } + + [JsonPropertyName("passphrase")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ReadOnlyMemory? PassphraseForAnonymousMove { get; set; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhasedDecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhasedDecryptionOutput.cs deleted file mode 100644 index 70e125b6..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhasedDecryptionOutput.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Proton.Cryptography.Pgp; -using Proton.Sdk; - -namespace Proton.Drive.Sdk.Nodes; - -internal readonly record struct PhasedDecryptionOutput(PgpSessionKey SessionKey, TData Data, Result Author); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index bcd4f424..16527eec 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -10,5 +10,5 @@ public sealed class Revision public required long? ClaimedSize { get; init; } public required DateTime? ClaimedModificationTime { get; init; } public required IReadOnlyList> Thumbnails { get; init; } - public required Result? ContentAuthor { get; init; } + public required ValResult? ContentAuthor { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs index 5ea9aed8..da13822a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs @@ -21,11 +21,8 @@ private static string GetMessage(PgpVerificationStatus? verificationStatus, stri return message; } - if (verificationStatus is null) - { - return "Authorship could not be verified"; - } - - return $"Verification resulted in unsuccessful status: {verificationStatus}"; + return verificationStatus is not null + ? $"Verification resulted in unsuccessful status: {verificationStatus}" + : "Authorship could not be verified"; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 71279f52..37e0951b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -89,7 +89,7 @@ public async Task UploadContentAsync( // FIXME: retry upon verification failure var verificationToken = verifier.VerifyBlock(dataPacketStream.GetFirstBytes(128), plainDataPrefix.AsSpan()[..plainDataPrefixLength]); - var parameters = new BlockUploadRequestParameters + var request = new BlockUploadPreparationRequest { VolumeId = fileUid.VolumeId, LinkId = fileUid.LinkId, @@ -97,7 +97,7 @@ public async Task UploadContentAsync( AddressId = membershipAddressId, Blocks = [ - new BlockCreationParameters + new BlockCreationRequest { Index = index, Size = (int)dataPacketStream.Length, @@ -109,7 +109,7 @@ public async Task UploadContentAsync( Thumbnails = [], }; - await UploadBlobAsync(parameters, dataPacketStream, onBlockProgress, cancellationToken).ConfigureAwait(false); + await UploadBlobAsync(request, dataPacketStream, onBlockProgress, cancellationToken).ConfigureAwait(false); return sha256Digest; } @@ -164,7 +164,7 @@ public async Task UploadThumbnailAsync( var sha256Digest = sha256.Hash ?? []; - var parameters = new BlockUploadRequestParameters + var request = new BlockUploadPreparationRequest { VolumeId = fileUid.VolumeId, LinkId = fileUid.LinkId, @@ -173,7 +173,7 @@ public async Task UploadThumbnailAsync( Blocks = [], Thumbnails = [ - new ThumbnailCreationParameters + new ThumbnailCreationRequest { Size = (int)dataPacketStream.Length, Type = (ThumbnailType)sample.Purpose, @@ -182,7 +182,7 @@ public async Task UploadThumbnailAsync( ], }; - await UploadBlobAsync(parameters, dataPacketStream, onProgress, cancellationToken).ConfigureAwait(false); + await UploadBlobAsync(request, dataPacketStream, onProgress, cancellationToken).ConfigureAwait(false); return sha256Digest; } @@ -194,13 +194,13 @@ public async Task UploadThumbnailAsync( } private async ValueTask UploadBlobAsync( - BlockUploadRequestParameters parameters, + BlockUploadPreparationRequest request, RecyclableMemoryStream dataPacketStream, Action? onProgress, CancellationToken cancellationToken) { #pragma warning disable S3236 // FP: https://community.sonarsource.com/t/false-positive-on-s3236-when-calling-debug-assert-with-message/138761/6 - Debug.Assert(parameters.Thumbnails.Count + parameters.Blocks.Count == 1, "Block upload request should be for only one block, content or thumbnail"); + Debug.Assert(request.Thumbnails.Count + request.Blocks.Count == 1, "Block upload request should be for only one block, content or thumbnail"); #pragma warning restore S3236 // Caller information arguments should not be provided explicitly var remainingNumberOfAttempts = 2; @@ -210,9 +210,9 @@ private async ValueTask UploadBlobAsync( try { // FIXME: request multiple blocks at once - var uploadRequestResponse = await _client.Api.Files.RequestBlockUploadAsync(parameters, cancellationToken).ConfigureAwait(false); + var uploadRequestResponse = await _client.Api.Files.PrepareBlockUploadAsync(request, cancellationToken).ConfigureAwait(false); - var uploadTarget = parameters.Thumbnails.Count == 0 ? uploadRequestResponse.UploadTargets[0] : uploadRequestResponse.ThumbnailUploadTargets[0]; + var uploadTarget = request.Thumbnails.Count == 0 ? uploadRequestResponse.UploadTargets[0] : uploadRequestResponse.ThumbnailUploadTargets[0]; dataPacketStream.Seek(0, SeekOrigin.Begin); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 3e4d59b7..210f8764 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -176,9 +176,9 @@ await contentInputStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, manifestSignature = await _signingKey.SignAsync(manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); } - var parameters = GetRevisionUpdateParameters(contentInputStream, lastModificationTime, blockSizes, manifestSignature, signingEmailAddress); + var request = GetRevisionUpdateRequest(contentInputStream, lastModificationTime, blockSizes, manifestSignature, signingEmailAddress); - await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, parameters, cancellationToken).ConfigureAwait(false); + await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, request, cancellationToken).ConfigureAwait(false); } public void Dispose() @@ -209,7 +209,7 @@ private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTask } } - private RevisionUpdateParameters GetRevisionUpdateParameters( + private RevisionUpdateRequest GetRevisionUpdateRequest( Stream contentInputStream, DateTimeOffset? lastModificationTime, IReadOnlyList blockSizes, @@ -230,7 +230,7 @@ private RevisionUpdateParameters GetRevisionUpdateParameters( var encryptedExtendedAttributes = _fileKey.EncryptAndSign(extendedAttributesUtf8Bytes, _signingKey, outputCompression: PgpCompression.Default); - return new RevisionUpdateParameters + return new RevisionUpdateRequest { ManifestSignature = manifestSignature, SignatureEmailAddress = signinEmailAddress, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index a2280eec..0bda2773 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -71,7 +71,7 @@ public ValueTask CreateFolderAsync(NodeUid parentId, string name, Ca return FolderOperations.CreateAsync(this, parentId, name, cancellationToken); } - public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) + public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) { return FolderOperations.EnumerateChildrenAsync(this, folderId, cancellationToken); } @@ -97,6 +97,15 @@ public async Task GetFileDownloaderAsync(RevisionUid revisionUid return new FileDownloader(this, revisionUid); } + public async ValueTask MoveNodesAsync(IEnumerable uids, NodeUid newParentFolderUid, CancellationToken cancellationToken) + { + // FIXME: finalize the implementation that uses the batch move endpoint, and use it instead of this naïve code + foreach (var uid in uids) + { + await NodeOperations.MoveSingleAsync(this, uid, newParentFolderUid, newName: null, cancellationToken).ConfigureAwait(false); + } + } + internal async ValueTask GetClientUidAsync(CancellationToken cancellationToken) { var clientUid = await Cache.Entities.TryGetClientUidAsync(cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 2191c0d1..18b066d5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -22,7 +22,7 @@ namespace Proton.Drive.Sdk.Serialization; typeof(PgpArmoredPublicKeyJsonConverter), ])] #pragma warning restore SA1114, SA1118 -[JsonSerializable(typeof(VolumeCreationParameters))] +[JsonSerializable(typeof(VolumeCreationRequest))] [JsonSerializable(typeof(VolumeCreationResponse))] [JsonSerializable(typeof(LinkDetailsRequest))] [JsonSerializable(typeof(LinkDetailsResponse))] @@ -31,14 +31,17 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(ShareResponseV2))] [JsonSerializable(typeof(ContextShareResponse))] [JsonSerializable(typeof(FolderChildrenResponse))] -[JsonSerializable(typeof(FolderCreationParameters))] +[JsonSerializable(typeof(FolderCreationRequest))] [JsonSerializable(typeof(FolderCreationResponse))] -[JsonSerializable(typeof(FileCreationParameters))] +[JsonSerializable(typeof(FileCreationRequest))] [JsonSerializable(typeof(FileCreationResponse))] [JsonSerializable(typeof(RevisionConflictResponse))] -[JsonSerializable(typeof(BlockUploadRequestParameters))] -[JsonSerializable(typeof(BlockRequestResponse))] -[JsonSerializable(typeof(RevisionUpdateParameters))] +[JsonSerializable(typeof(BlockUploadPreparationRequest))] +[JsonSerializable(typeof(BlockUploadPreparationResponse))] +[JsonSerializable(typeof(RevisionUpdateRequest))] [JsonSerializable(typeof(BlockVerificationInputResponse))] [JsonSerializable(typeof(RevisionResponse))] +[JsonSerializable(typeof(MoveSingleLinkRequest))] +[JsonSerializable(typeof(MoveMultipleLinksRequest))] +[JsonSerializable(typeof(RenameLinkRequest))] internal sealed partial class DriveApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs index 86872485..e2820da3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -10,15 +10,15 @@ namespace Proton.Drive.Sdk.Serialization; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, Converters = [ - typeof(ResultJsonConverter), - typeof(ResultJsonConverter), - typeof(ResultJsonConverter), + typeof(RefResultJsonConverter), + typeof(ValResultJsonConverter), + typeof(RefResultJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(Share))] [JsonSerializable(typeof(FolderNode))] [JsonSerializable(typeof(CachedNodeInfo))] -[JsonSerializable(typeof(SerializableResult))] -[JsonSerializable(typeof(SerializableResult))] -[JsonSerializable(typeof(SerializableResult))] +[JsonSerializable(typeof(SerializableRefResult))] +[JsonSerializable(typeof(SerializableValResult))] +[JsonSerializable(typeof(SerializableRefResult))] internal sealed partial class DriveEntitiesSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs index 278612f9..8ed9f08f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Serialization; @@ -12,9 +13,13 @@ namespace Proton.Drive.Sdk.Serialization; [ typeof(PgpPrivateKeyJsonConverter), typeof(PgpSessionKeyJsonConverter), + typeof(RefResultJsonConverter), + typeof(RefResultJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(IEnumerable))] -[JsonSerializable(typeof(FolderSecrets))] -[JsonSerializable(typeof(FileSecrets))] +[JsonSerializable(typeof(RefResult?))] +[JsonSerializable(typeof(RefResult?))] +[JsonSerializable(typeof(SerializableRefResult))] +[JsonSerializable(typeof(SerializableRefResult))] internal sealed partial class DriveSecretsSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs index 2930b6df..a1bfbf8b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs @@ -25,9 +25,6 @@ internal static class ShareCrypto var share = new Share(shareId, rootFolderId, addressId); - await client.Cache.Secrets.SetShareKeyAsync(shareId, key, cancellationToken).ConfigureAwait(false); - await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); - return (share, key); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index d70899c4..29447f45 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -19,9 +19,9 @@ internal static class VolumeOperations var addressKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); - var parameters = GetCreationParameters(defaultAddress.Id, addressKey, out var rootShareKey, out var rootFolderSecrets); + var request = GetCreationRequest(defaultAddress.Id, addressKey, out var rootShareKey, out var rootFolderSecrets); - var response = await client.Api.Volumes.CreateVolumeAsync(parameters, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Volumes.CreateVolumeAsync(request, cancellationToken).ConfigureAwait(false); var volume = new Volume(response.Volume); @@ -51,7 +51,7 @@ internal static class VolumeOperations return (volume, share, rootFolder); } - private static VolumeCreationParameters GetCreationParameters( + private static VolumeCreationRequest GetCreationRequest( AddressId addressId, PgpPrivateKey addressKey, out PgpPrivateKey rootShareKey, @@ -89,7 +89,7 @@ private static VolumeCreationParameters GetCreationParameters( var encryptedHashKey = rootFolderSecrets.Key.EncryptAndSign(rootFolderSecrets.HashKey.Span, addressKey); - return new VolumeCreationParameters + return new VolumeCreationRequest { AddressId = addressId, ShareKey = lockedShareKey.ToBytes(), diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 6c78a360..40da4b36 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -1,5 +1,4 @@ -using CommunityToolkit.HighPerformance; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Sdk.Api; using Proton.Sdk.Api.Addresses; @@ -238,7 +237,7 @@ private static ReadOnlyMemory GetAddressKeyTokenPassphrase( IReadOnlyList userKeys) { var userKeyRing = new PgpPrivateKeyRing(userKeys); - using var decryptingStream = PgpDecryptingStream.Open(token.Bytes.AsStream(), userKeyRing, signature, userKeyRing); + using var decryptingStream = PgpDecryptingStream.Open(token, userKeyRing, signature, userKeyRing); using var passphraseStream = new MemoryStream(); decryptingStream.CopyTo(passphraseStream); diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs b/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs index b100eeaa..ab2a557d 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs @@ -1,6 +1,13 @@ -namespace Proton.Sdk.Cryptography; +using CommunityToolkit.HighPerformance; -internal interface IPgpArmoredBlock +namespace Proton.Sdk.Cryptography; + +internal interface IPgpArmoredBlock + where T : IPgpArmoredBlock { ReadOnlyMemory Bytes { get; } + + static virtual implicit operator Stream(T block) => block.Bytes.AsStream(); + static virtual implicit operator ReadOnlyMemory(T block) => block.Bytes; + static virtual implicit operator ReadOnlySpan(T block) => block.Bytes.Span; } diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs index 6762f8d1..d0c5f1b7 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs @@ -2,7 +2,7 @@ namespace Proton.Sdk.Cryptography; -internal readonly struct PgpArmoredMessage(ReadOnlyMemory bytes) : IPgpArmoredBlock +internal readonly struct PgpArmoredMessage(ReadOnlyMemory bytes) : IPgpArmoredBlock { public ReadOnlyMemory Bytes { get; } = bytes; @@ -10,7 +10,7 @@ internal readonly struct PgpArmoredMessage(ReadOnlyMemory bytes) : IPgpArm public static implicit operator PgpArmoredMessage(ReadOnlyMemory bytes) => new(bytes); public static implicit operator PgpArmoredMessage(ArraySegment bytes) => new(bytes); - public static implicit operator Stream(PgpArmoredMessage key) => key.Bytes.AsStream(); - public static implicit operator ReadOnlyMemory(PgpArmoredMessage key) => key.Bytes; - public static implicit operator ReadOnlySpan(PgpArmoredMessage key) => key.Bytes.Span; + public static implicit operator Stream(PgpArmoredMessage block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredMessage block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredMessage block) => block.Bytes.Span; } diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs index 94783aef..8413ad6d 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs @@ -2,7 +2,7 @@ namespace Proton.Sdk.Cryptography; -internal readonly struct PgpArmoredPrivateKey(ReadOnlyMemory bytes) : IPgpArmoredBlock +internal readonly struct PgpArmoredPrivateKey(ReadOnlyMemory bytes) : IPgpArmoredBlock { public ReadOnlyMemory Bytes { get; } = bytes; @@ -10,7 +10,7 @@ internal readonly struct PgpArmoredPrivateKey(ReadOnlyMemory bytes) : IPgp public static implicit operator PgpArmoredPrivateKey(ReadOnlyMemory bytes) => new(bytes); public static implicit operator PgpArmoredPrivateKey(ArraySegment bytes) => new(bytes); - public static implicit operator Stream(PgpArmoredPrivateKey key) => key.Bytes.AsStream(); - public static implicit operator ReadOnlyMemory(PgpArmoredPrivateKey key) => key.Bytes; - public static implicit operator ReadOnlySpan(PgpArmoredPrivateKey key) => key.Bytes.Span; + public static implicit operator Stream(PgpArmoredPrivateKey block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredPrivateKey block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredPrivateKey block) => block.Bytes.Span; } diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs index 40db1ae1..e5df84e8 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs @@ -2,7 +2,7 @@ namespace Proton.Sdk.Cryptography; -internal readonly struct PgpArmoredPublicKey(ReadOnlyMemory bytes) : IPgpArmoredBlock +internal readonly struct PgpArmoredPublicKey(ReadOnlyMemory bytes) : IPgpArmoredBlock { public ReadOnlyMemory Bytes { get; } = bytes; @@ -10,7 +10,7 @@ internal readonly struct PgpArmoredPublicKey(ReadOnlyMemory bytes) : IPgpA public static implicit operator PgpArmoredPublicKey(ReadOnlyMemory bytes) => new(bytes); public static implicit operator PgpArmoredPublicKey(ArraySegment bytes) => new(bytes); - public static implicit operator Stream(PgpArmoredPublicKey key) => key.Bytes.AsStream(); - public static implicit operator ReadOnlyMemory(PgpArmoredPublicKey key) => key.Bytes; - public static implicit operator ReadOnlySpan(PgpArmoredPublicKey key) => key.Bytes.Span; + public static implicit operator Stream(PgpArmoredPublicKey block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredPublicKey block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredPublicKey block) => block.Bytes.Span; } diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs index b5e84711..b9281e5c 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs @@ -2,7 +2,7 @@ namespace Proton.Sdk.Cryptography; -internal readonly struct PgpArmoredSignature(ReadOnlyMemory bytes) : IPgpArmoredBlock +internal readonly struct PgpArmoredSignature(ReadOnlyMemory bytes) : IPgpArmoredBlock { public ReadOnlyMemory Bytes { get; } = bytes; @@ -10,7 +10,7 @@ internal readonly struct PgpArmoredSignature(ReadOnlyMemory bytes) : IPgpA public static implicit operator PgpArmoredSignature(ReadOnlyMemory bytes) => new(bytes); public static implicit operator PgpArmoredSignature(ArraySegment bytes) => new(bytes); - public static implicit operator Stream(PgpArmoredSignature key) => key.Bytes.AsStream(); - public static implicit operator ReadOnlyMemory(PgpArmoredSignature key) => key.Bytes; - public static implicit operator ReadOnlySpan(PgpArmoredSignature key) => key.Bytes.Span; + public static implicit operator Stream(PgpArmoredSignature block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredSignature block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredSignature block) => block.Bytes.Span; } diff --git a/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs b/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs index d2de1c3d..e1e5e1c2 100644 --- a/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs +++ b/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs @@ -38,9 +38,7 @@ private static bool IsValid(X509Certificate certificate) } var validHashFound = false; -#pragma warning disable S3267 // False positive: https://github.com/SonarSource/sonar-dotnet/issues/8430 foreach (var knownPublicKeyHashDigest in KnownPublicKeySha256Digests) -#pragma warning restore S3267 { if (knownPublicKeyHashDigest.AsSpan().SequenceEqual(hashDigestBuffer)) { diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiException.cs b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs index 65945bcf..ac2370dc 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiException.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs @@ -9,18 +9,18 @@ public ProtonApiException() { } - public ProtonApiException(string message) + public ProtonApiException(string? message) : base(message) { } - public ProtonApiException(string message, Exception innerException) + public ProtonApiException(string? message, Exception? innerException) : base(message, innerException) { } internal ProtonApiException(HttpStatusCode statusCode, ApiResponse response) - : this($"{response.Code}: {response.ErrorMessage}") + : this(response.ErrorMessage) { Code = response.Code; TransportCode = (int)statusCode; diff --git a/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs b/cs/sdk/src/Proton.Sdk/RefResult.cs similarity index 59% rename from cs/sdk/src/Proton.Sdk/Result{T,TError}.cs rename to cs/sdk/src/Proton.Sdk/RefResult.cs index 6a7e658c..29e7b7f9 100644 --- a/cs/sdk/src/Proton.Sdk/Result{T,TError}.cs +++ b/cs/sdk/src/Proton.Sdk/RefResult.cs @@ -2,30 +2,32 @@ namespace Proton.Sdk; -public readonly struct Result +public readonly struct RefResult + where T : class? + where TError : class? { private readonly T? _value; private readonly TError? _error; - public Result(T value) + public RefResult(T value) { IsSuccess = true; _value = value; - _error = default; + _error = null; } - public Result(TError error) + public RefResult(TError error) { IsSuccess = false; _error = error; - _value = default; + _value = null; } public bool IsSuccess { get; } public bool IsFailure => !IsSuccess; - public static implicit operator Result(T value) => new(value); - public static implicit operator Result(TError error) => new(error); + public static implicit operator RefResult(T value) => new(value); + public static implicit operator RefResult(TError error) => new(error); public bool TryGetValueElseError([MaybeNullWhen(false)] out T value, [MaybeNullWhen(true)] out TError error) { diff --git a/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs b/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs new file mode 100644 index 00000000..34938c86 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs @@ -0,0 +1,84 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Proton.Sdk; + +public static class RefResultExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T? GetValueOrDefault(this RefResult result, T? defaultValue = null) + where T : class? + where TError : class? + { + return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T GetValueOrThrow(this RefResult result) + where T : class? + where TError : class? + { + return result.TryGetValueElseError(out var value, out _) ? value : throw new InvalidOperationException("Cannot get value from failed result"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetValue(this RefResult result, [MaybeNullWhen(false)] out T value) + where T : class? + where TError : class? + { + return result.TryGetValueElseError(out value, out _); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TError? GetErrorOrDefault(this RefResult result, TError? defaultError = null) + where T : class? + where TError : class? + { + return result.TryGetValueElseError(out _, out var error) ? defaultError : error; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetError(this RefResult result, [MaybeNullWhen(false)] out TError error) + where T : class? + where TError : class? + { + return !result.TryGetValueElseError(out _, out error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TMerged Merge( + this RefResult result, + Func convertValue, + Func convertError) + where T : class? + where TError : class? + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValResult ConvertVal( + this RefResult result, + Func convertValue, + Func convertError) + where T : class? + where TError : class? + where TOther : struct + where TOtherError : class? + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static RefResult ConvertRef( + this RefResult result, + Func convertValue, + Func convertError) + where T : class? + where TError : class? + where TOther : class? + where TOtherError : class? + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); + } +} diff --git a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs deleted file mode 100644 index fdc1874b..00000000 --- a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; - -namespace Proton.Sdk; -public static class ResultExtensions -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Result Convert( - this Result result, - Func convertValue, - Func convertError) - { - return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Result Convert( - this Result result, - Func convertValue) - { - return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : error; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Result Convert( - this Result result, - Func convertError) - { - return result.TryGetValueElseError(out var value, out var error) ? value : convertError.Invoke(error); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T? GetValueOrDefault(this Result result, T? defaultValue = null) - where T : class - { - return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T? GetValueOrDefault(this Result result, T? defaultValue = null) - where T : struct - { - return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T GetValueOrThrow(this Result result) - { - return result.TryGetValueElseError(out var value, out _) ? value : throw new InvalidOperationException("Cannot get value from failed result"); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryGetValue(this Result result, [MaybeNullWhen(false)] out T value) - { - return result.TryGetValueElseError(out value, out _); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryGetError(this Result result, [MaybeNullWhen(false)] out TError error) - { - return !result.TryGetValueElseError(out _, out error); - } -} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs index 9c2be343..37cfa747 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs @@ -7,8 +7,10 @@ namespace Proton.Sdk.Serialization; internal abstract class PgpArmoredBlockJsonConverterBase : JsonConverter - where T : IPgpArmoredBlock + where T : IPgpArmoredBlock { + protected abstract PgpBlockType BlockType { get; } + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.String) @@ -38,7 +40,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions try { - var numberOfBytesWritten = PgpArmorEncoder.Encode(value.Bytes.Span, BlockType, buffer); + var numberOfBytesWritten = PgpArmorEncoder.Encode(value, BlockType, buffer); writer.WriteStringValue(buffer.AsSpan()[..numberOfBytesWritten]); } @@ -48,7 +50,5 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions } } - protected abstract PgpBlockType BlockType { get; } - protected abstract T CreateValue(ReadOnlyMemory bytes); } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs new file mode 100644 index 00000000..0e6b2be1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Proton.Sdk.Serialization; + +internal sealed class RefResultJsonConverter : JsonConverter> + where T : class? + where TError : class? +{ + public override RefResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dto = JsonSerializer.Deserialize( + ref reader, + (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableRefResult))); + + RefResult? result; + if (dto.IsSuccess) + { + result = dto.Value ?? throw new JsonException("Missing \"Value\" property for success result."); + } + else + { + result = dto.Error ?? throw new JsonException("Missing \"Error\" property for failure result."); + } + + return result.Value; + } + + public override void Write(Utf8JsonWriter writer, RefResult value, JsonSerializerOptions options) + { + var dto = value.TryGetValueElseError(out var innerValue, out var error) + ? new SerializableRefResult { IsSuccess = true, Value = innerValue } + : new SerializableRefResult { Error = error }; + + var jsonTypeInfo = (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableRefResult)); + JsonSerializer.Serialize(writer, dto, jsonTypeInfo); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs deleted file mode 100644 index 8fb15f39..00000000 --- a/cs/sdk/src/Proton.Sdk/Serialization/ResultJsonConverter.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; - -namespace Proton.Sdk.Serialization; - -internal sealed class ResultJsonConverter : JsonConverter> -{ - public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var dto = JsonSerializer.Deserialize( - ref reader, - (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableResult))); - - Result? result; - if (dto.IsSuccess) - { - if (dto.Value is null) - { - throw new JsonException("Missing \"Value\" property for success result."); - } - - result = dto.Value; - } - else - { - if (dto.Error is null) - { - throw new JsonException("Missing \"Error\" property for failure result."); - } - - result = dto.Error; - } - - return result.Value; - } - - public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) - { - var dto = value.TryGetValueElseError(out var innerValue, out var error) - ? new SerializableResult { IsSuccess = true, Value = innerValue } - : new SerializableResult { Error = error }; - - JsonSerializer.Serialize(writer, dto, (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableResult))); - } -} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SerializableRefResult.cs b/cs/sdk/src/Proton.Sdk/Serialization/SerializableRefResult.cs new file mode 100644 index 00000000..761f3cfd --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/SerializableRefResult.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal struct SerializableRefResult + where T : class? + where TError : class? +{ + public bool IsSuccess { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public T? Value { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TError? Error { get; set; } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs b/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs deleted file mode 100644 index d9e5b505..00000000 --- a/cs/sdk/src/Proton.Sdk/Serialization/SerializableResult.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Proton.Sdk.Serialization; - -internal struct SerializableResult -{ - public bool IsSuccess { get; set; } - - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public T? Value { get; set; } - - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public TError? Error { get; set; } -} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SerializableValResult.cs b/cs/sdk/src/Proton.Sdk/Serialization/SerializableValResult.cs new file mode 100644 index 00000000..a6d7f2bd --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/SerializableValResult.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal struct SerializableValResult + where T : struct + where TError : class? +{ + public bool IsSuccess { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public T? Value { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TError? Error { get; set; } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs new file mode 100644 index 00000000..c76b12c7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Proton.Sdk.Serialization; + +internal sealed class ValResultJsonConverter : JsonConverter> + where T : struct + where TError : class? +{ + public override ValResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dto = JsonSerializer.Deserialize( + ref reader, + (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableValResult))); + + ValResult? result; + if (dto.IsSuccess) + { + if (dto.Value is null) + { + throw new JsonException("Missing \"Value\" property for success result."); + } + + result = dto.Value; + } + else + { + result = dto.Error ?? throw new JsonException("Missing \"Error\" property for failure result."); + } + + return result.Value; + } + + public override void Write(Utf8JsonWriter writer, ValResult value, JsonSerializerOptions options) + { + var dto = value.TryGetValueElseError(out var innerValue, out var error) + ? new SerializableValResult { IsSuccess = true, Value = innerValue.Value } + : new SerializableValResult { Error = error }; + + var jsonTypeInfo = (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableValResult)); + JsonSerializer.Serialize(writer, dto, jsonTypeInfo); + } +} diff --git a/cs/sdk/src/Proton.Sdk/ValResult.cs b/cs/sdk/src/Proton.Sdk/ValResult.cs new file mode 100644 index 00000000..bdb09039 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ValResult.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk; + +public readonly struct ValResult + where T : struct + where TError : class? +{ + private readonly T? _value; + private readonly TError? _error; + + public ValResult(T value) + { + IsSuccess = true; + _value = value; + _error = null; + } + + public ValResult(TError error) + { + IsSuccess = false; + _error = error; + _value = null; + } + + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + + public static implicit operator ValResult(T value) => new(value); + public static implicit operator ValResult(TError error) => new(error); + + public bool TryGetValueElseError([NotNullWhen(true)] out T? value, [MaybeNullWhen(true)] out TError error) + { + value = _value; + error = _error; + return IsSuccess; + } +} diff --git a/cs/sdk/src/Proton.Sdk/ValResultExtensions.cs b/cs/sdk/src/Proton.Sdk/ValResultExtensions.cs new file mode 100644 index 00000000..346859f5 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ValResultExtensions.cs @@ -0,0 +1,86 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Proton.Sdk; + +public static class ValResultExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T? GetValueOrDefault(this ValResult result, T? defaultValue = null) + where T : struct + where TError : class? + { + return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T GetValueOrThrow(this ValResult result) + where T : struct + where TError : class? + { + return result.TryGetValueElseError(out var value, out _) + ? value.Value + : throw new InvalidOperationException("Cannot get value from failed result"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetValue(this ValResult result, [NotNullWhen(true)] out T? value) + where T : struct + where TError : class? + { + return result.TryGetValueElseError(out value, out _); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TError? GetErrorOrDefault(this ValResult result, TError? defaultError = null) + where T : struct + where TError : class? + { + return result.TryGetValueElseError(out _, out var error) ? defaultError : error; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetError(this ValResult result, [MaybeNullWhen(false)] out TError error) + where T : struct + where TError : class? + { + return !result.TryGetValueElseError(out _, out error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TMerged Merge( + this ValResult result, + Func convertValue, + Func convertError) + where T : struct + where TError : class? + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value.Value) : convertError.Invoke(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValResult ConvertVal( + this ValResult result, + Func convertValue, + Func convertError) + where T : struct + where TError : class? + where TOther : struct + where TOtherError : class? + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value.Value) : convertError.Invoke(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static RefResult ConvertRef( + this ValResult result, + Func convertValue, + Func convertError) + where T : struct + where TError : class? + where TOther : class? + where TOtherError : class? + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value.Value) : convertError.Invoke(error); + } +} From 39fd1c8d492c5202e72414c940c763bb058b491c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 9 May 2025 11:40:10 +0200 Subject: [PATCH 086/791] Implement rename --- .../Api/Links/RenameLinkRequest.cs | 2 +- .../Nodes/FolderOperations.cs | 3 +- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 51 ++++++++++++++++++- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 9 +++- cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs | 2 +- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs index e50219c0..1a3908e4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs @@ -16,7 +16,7 @@ internal sealed class RenameLinkRequest public required string NameSignatureEmailAddress { get; init; } [JsonPropertyName("MIMEType")] - public required string MediaType { get; set; } + public required string? MediaType { get; set; } [JsonPropertyName("OriginalHash")] [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 78dc335f..b7557470 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -129,7 +129,8 @@ public static async ValueTask GetSecretsAsync(ProtonDriveClient c if (folderSecrets is null) { - var nodeProvisionResult = await NodeOperations.GetFreshNodeMetadataAsync(client, folderId, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + var nodeProvisionResult = await NodeOperations.GetFreshNodeMetadataAsync(client, folderId, knownShareAndKey: null, cancellationToken) + .ConfigureAwait(false); folderSecrets = nodeProvisionResult.GetFolderSecretsOrThrow(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index e6f52457..ce954750 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -31,6 +31,11 @@ public static async ValueTask GetMyFilesFolderAsync(ProtonDriveClien return (FolderNode)metadata.Node; } + public static ValueTask GetNodeMetadataAsync(ProtonDriveClient client, NodeUid uid, CancellationToken cancellationToken) + { + return GetNodeMetadataAsync(client, uid, knownShareAndKey: null, cancellationToken); + } + public static async ValueTask GetNodeMetadataAsync( ProtonDriveClient client, NodeUid uid, @@ -91,7 +96,7 @@ public static async ValueTask> Get .ConfigureAwait(false); } - public static async Task MoveSingleAsync( + public static async ValueTask MoveSingleAsync( ProtonDriveClient client, NodeUid uid, NodeUid newParentUid, @@ -221,6 +226,50 @@ public static async Task MoveMultipleAsync( // FIXME: update cache } + public static async ValueTask RenameAsync( + ProtonDriveClient client, + NodeUid uid, + string newName, + string? newMediaType, + CancellationToken cancellationToken) + { + // TODO: support renaming degraded nodes + var (node, secrets, membershipShareId, originalNameHashDigest) = await GetNodeMetadataAsync(client, uid, cancellationToken).ConfigureAwait(false); + + if (node.ParentUid is not { } parentUid) + { + throw new InvalidOperationException("Cannot rename root node"); + } + + var membershipAddress = await GetMembershipAddressAsync(client, uid, cancellationToken).ConfigureAwait(false); + + var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + + var parentFolderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + + GetNameParameters( + newName, // FIXME: validate name + parentFolderSecrets.Key, + parentFolderSecrets.HashKey.Span, + secrets.NameSessionKey, + signingKey, + out var encryptedName, + out var nameHashDigest); + + var parameters = new RenameLinkRequest + { + Name = encryptedName, + NameHashDigest = nameHashDigest, + NameSignatureEmailAddress = membershipAddress.EmailAddress, + MediaType = newMediaType, + OriginalNameHashDigest = originalNameHashDigest, + }; + + await client.Api.Links.RenameAsync(uid.VolumeId, uid.LinkId, parameters, cancellationToken).ConfigureAwait(false); + + await client.Cache.Entities.SetNodeAsync(uid, node with { Name = newName }, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); + } + public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) { // FIXME: try to get the information from cache first diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 0bda2773..8feb4255 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -90,7 +90,7 @@ public async ValueTask GetFileUploaderAsync( return new FileUploader(this, name, mediaType, lastModificationTime, expectedNumberOfBlocks); } - public async Task GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) + public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) { await BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); @@ -103,7 +103,12 @@ public async ValueTask MoveNodesAsync(IEnumerable uids, NodeUid newPare foreach (var uid in uids) { await NodeOperations.MoveSingleAsync(this, uid, newParentFolderUid, newName: null, cancellationToken).ConfigureAwait(false); - } + } + } + + public ValueTask RenameNodeAsync(NodeUid uid, string newName, string? newMediaType, CancellationToken cancellationToken) + { + return NodeOperations.RenameAsync(this, uid, newName, newMediaType, cancellationToken); } internal async ValueTask GetClientUidAsync(CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs b/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs index 41b95653..d9d7d6ea 100644 --- a/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs +++ b/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs @@ -12,7 +12,7 @@ public enum ResponseCode Success = 1000, MultipleResponses = 1001, - ParentDoesNotExist = 2000, + InvalidRequirements = 2000, InvalidValue = 2001, InvalidEncryptedIdFormat = 2061, AlreadyExists = 2500, From 78cf5d51d6881441e054ac95ceb19d4b7da0130a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 May 2025 09:12:17 +0200 Subject: [PATCH 087/791] fix exporting types --- js/sdk/.eslintrc.js | 1 + js/sdk/src/index.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index 1f223e64..d7ef218f 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -12,6 +12,7 @@ module.exports = { rules: { "tsdoc/syntax": "warn", "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/consistent-type-exports": "error", }, overrides: [ { diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index d812832f..f1098b13 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -7,7 +7,8 @@ import { makeNodeUid } from './internal/uids'; export * from './interface'; export * from './cache'; export * from './errors'; -export { OpenPGPCrypto, OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy } from './crypto'; +export type { OpenPGPCrypto, OpenPGPCryptoProxy } from './crypto'; +export { OpenPGPCryptoWithCryptoProxy } from './crypto'; export { ProtonDriveClient } from './protonDriveClient'; export { VERSION } from './version'; From f0a4fd26692a7fbd296f9a77297476c6150a7a58 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 May 2025 08:18:31 +0000 Subject: [PATCH 088/791] log deferred API calls and document it for restoring revisions --- js/sdk/src/internal/apiService/apiService.ts | 5 ++++- js/sdk/src/internal/apiService/errorCodes.ts | 4 ++++ js/sdk/src/internal/apiService/index.ts | 2 +- js/sdk/src/protonDriveClient.ts | 5 +++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 27dd0ded..e3844438 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -5,7 +5,7 @@ import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from "../../inter import { AbortError, ServerError, RateLimitedError, ProtonDriveError } from '../../errors'; import { waitSeconds } from '../wait'; import { SDKEvents } from '../sdkEvents'; -import { HTTPErrorCode, isCodeOk } from './errorCodes'; +import { HTTPErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; import { apiErrorFactory } from './errors'; /** @@ -136,6 +136,9 @@ export class DriveAPIService { if (!response.ok || !isCodeOk(result.Code)) { throw apiErrorFactory({ response, result }); } + if (isCodeOkAsync(result.Code)) { + this.logger.info(`${request.method} ${request.url}: deferred action`); + } return result as ResponsePayload; } catch (error: unknown) { if (error instanceof ProtonDriveError) { diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index 0065d667..a67bb034 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -9,6 +9,10 @@ export function isCodeOk(code: number): boolean { return code === ErrorCode.OK || code === ErrorCode.OK_MANY || code === ErrorCode.OK_ASYNC; } +export function isCodeOkAsync(code: number): boolean { + return code === ErrorCode.OK_ASYNC; +} + export const enum ErrorCode { OK = 1000, OK_MANY = 1001, diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 493f142a..1d00815e 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -1,7 +1,7 @@ export { DriveAPIService } from './apiService'; export type { paths as drivePaths } from './driveTypes'; export type { paths as corePaths } from './coreTypes'; -export { HTTPErrorCode, ErrorCode, isCodeOk } from './errorCodes'; +export { HTTPErrorCode, ErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; export { nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from './transformers'; export { ObserverStream } from './observerStream'; export * from './errors'; diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 95e3a912..ac930e4e 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -492,6 +492,11 @@ export class ProtonDriveClient { /** * Restore the node to the given revision. * + * Warning: Restoring revisions might be accepted by the server but not + * applied. If the client re-loads list of revisions quickly after the + * restore, the change might not be visible. Update the UI optimistically to + * reflect the change. + * * @param revisionUid - UID of the revision to restore. */ async restoreRevision(revisionUid: string): Promise { From 23ee9e697a275127e2be5eeb93f79c2b692fffc6 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 May 2025 09:35:16 +0200 Subject: [PATCH 089/791] fix type of curve for generateKey --- js/sdk/src/crypto/openPGPCrypto.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 6862b4c0..48de29cb 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -6,7 +6,7 @@ import { uint8ArrayToBase64String } from './utils'; * clients/packages/crypto/lib/proxy/proxy.ts. */ export interface OpenPGPCryptoProxy { - generateKey: (options: { userIDs: { name: string }[], type: 'ecc', curve: 'ed25519' }) => Promise, + generateKey: (options: { userIDs: { name: string }[], type: 'ecc', curve: 'ed25519Legacy' }) => Promise, exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string }) => Promise, importPrivateKey: (options: { armoredKey: string, passphrase: string }) => Promise, generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, @@ -92,7 +92,6 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { const privateKey = await this.cryptoProxy.generateKey({ userIDs: [{ name: 'Drive key' }], type: 'ecc', - // @ts-expect-error The interface doesnt officially accept it anymore, but legacy is still supported. curve: 'ed25519Legacy', }); From 39e6837eb21ae4a2179a7f48d4a69709ba42562c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 May 2025 13:41:41 +0000 Subject: [PATCH 090/791] add experimental getNodeUrl --- js/sdk/src/internal/nodes/mediaTypes.ts | 10 ++++++ js/sdk/src/internal/nodes/nodesAccess.test.ts | 35 +++++++++++++++++++ js/sdk/src/internal/nodes/nodesAccess.ts | 26 +++++++++++++- js/sdk/src/protonDriveClient.ts | 17 +++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 js/sdk/src/internal/nodes/mediaTypes.ts diff --git a/js/sdk/src/internal/nodes/mediaTypes.ts b/js/sdk/src/internal/nodes/mediaTypes.ts new file mode 100644 index 00000000..7cd68364 --- /dev/null +++ b/js/sdk/src/internal/nodes/mediaTypes.ts @@ -0,0 +1,10 @@ +const PROTON_DOC_MEDIA_TYPE = 'application/vnd.proton.doc'; +const PROTON_SHEET_MEDIA_TYPE = 'application/vnd.proton.sheet'; + +export function isProtonDocument(mediaType?: string) { + return mediaType === PROTON_DOC_MEDIA_TYPE; +} + +export function isProtonSheet(mediaType?: string) { + return mediaType === PROTON_SHEET_MEDIA_TYPE; +} diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index a597bd54..9108a802 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -7,6 +7,7 @@ import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { NodesAccess } from './nodesAccess'; import { SharesService, DecryptedNode, DecryptedUnparsedNode, EncryptedNode, DecryptedNodeKeys } from "./interface"; +import { NodeType } from "../../interface"; describe('nodesAccess', () => { let apiService: NodeAPIService; @@ -482,4 +483,38 @@ describe('nodesAccess', () => { } }); }); + + describe('getNodeUrl', () => { + const nodeUid = 'volumeId~nodeId'; + + it('should return node URL of document', async () => { + jest.spyOn(access, 'getNode').mockReturnValue(Promise.resolve({ mediaType: 'application/vnd.proton.doc' } as any as DecryptedNode)); + + const result = await access.getNodeUrl(nodeUid); + expect(result).toBe('https://docs.proton.me/doc?type=doc&mode=open&volumeId=volumeId&linkId=nodeId'); + }); + + it('should return node URL of sheet', async () => { + jest.spyOn(access, 'getNode').mockReturnValue(Promise.resolve({ mediaType: 'application/vnd.proton.sheet' } as any as DecryptedNode)); + + const result = await access.getNodeUrl(nodeUid); + expect(result).toBe('https://docs.proton.me/doc?type=sheet&mode=open&volumeId=volumeId&linkId=nodeId'); + }); + + it('should return node URL of image', async () => { + jest.spyOn(access, 'getNode').mockReturnValue(Promise.resolve({ type: NodeType.File } as any as DecryptedNode)); + jest.spyOn(access as any, 'getRootNode').mockReturnValue(Promise.resolve({ shareId: 'shareId', type: NodeType.Folder } as any as DecryptedNode)); + + const result = await access.getNodeUrl(nodeUid); + expect(result).toBe('https://drive.proton.me/shareId/file/nodeId'); + }); + + it('should return node URL of folder', async () => { + jest.spyOn(access, 'getNode').mockReturnValue(Promise.resolve({ type: NodeType.Folder } as any as DecryptedNode)); + jest.spyOn(access as any, 'getRootNode').mockReturnValue(Promise.resolve({ shareId: 'shareId', type: NodeType.Folder } as any as DecryptedNode)); + + const result = await access.getNodeUrl(nodeUid); + expect(result).toBe('https://drive.proton.me/shareId/folder/nodeId'); + }); + }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index fe518976..57741580 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -5,7 +5,7 @@ import { Logger, MissingNode, NodeType, resultError, resultOk } from "../../inte import { DecryptionError, ProtonDriveError } from "../../errors"; import { getErrorMessage } from '../errors'; import { BatchLoading } from "../batchLoading"; -import { makeNodeUid } from "../uids"; +import { makeNodeUid, splitNodeUid } from "../uids"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache" import { NodesCryptoCache } from "./cryptoCache"; @@ -13,6 +13,7 @@ import { NodesCryptoService } from "./cryptoService"; import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from "./extendedAttributes"; import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; import { validateNodeName } from "./validations"; +import { isProtonDocument, isProtonSheet } from './mediaTypes'; /** * Provides access to node metadata. @@ -318,4 +319,27 @@ export class NodesAccess { nameSessionKey, }; } + + async getNodeUrl(nodeUid: string): Promise { + const node = await this.getNode(nodeUid); + if (isProtonDocument(node.mediaType) || isProtonSheet(node.mediaType)) { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const type = isProtonDocument(node.mediaType) ? 'doc' : 'sheet'; + return `https://docs.proton.me/doc?type=${type}&mode=open&volumeId=${volumeId}&linkId=${nodeId}`; + } + + const rootNode = await this.getRootNode(nodeUid); + if (!rootNode.shareId) { + throw new ProtonDriveError(c('Error').t`Node is not accessible`); + } + const { nodeId } = splitNodeUid(nodeUid); + const type = node.type === NodeType.File ? 'file' : 'folder'; + + return `https://drive.proton.me/${rootNode.shareId}/${type}/${nodeId}`; + } + + private async getRootNode(nodeUid: string): Promise { + const node = await this.getNode(nodeUid); + return node.parentUid ? this.getRootNode(node.parentUid) : node; + }; } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index ac930e4e..cf87950e 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -57,6 +57,17 @@ export class ProtonDriveClient { private upload: ReturnType; private devices: ReturnType; + public experimental: { + /** + * Experimental feature to return the URL of the node. + * + * Use it when you want to open the node in the ProtonDrive web app. + * + * It has hardcoded URLs to open in production client only. + */ + getNodeUrl: (nodeUid: NodeOrUid) => Promise; + }; + constructor({ httpClient, entitiesCache, @@ -82,6 +93,12 @@ export class ProtonDriveClient { this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.shares, this.nodes.access, this.nodes.revisions); this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access); this.devices = initDevicesModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.management); + this.experimental = { + getNodeUrl: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); + return this.nodes.access.getNodeUrl(getUid(nodeUid)); + }, + } } /** From 073830775286f2fa74e28d7968b70a2b35ac0b6d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 May 2025 11:06:44 +0200 Subject: [PATCH 091/791] update HTTP client interface to accept Request data instead of Request object --- js/sdk/src/interface/httpClient.ts | 19 ++++++- js/sdk/src/interface/index.ts | 2 +- .../internal/apiService/apiService.test.ts | 57 ++++++++++--------- js/sdk/src/internal/apiService/apiService.ts | 57 ++++++++++++------- js/sdk/src/internal/upload/apiService.ts | 17 +----- 5 files changed, 84 insertions(+), 68 deletions(-) diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts index d8165de7..788d093a 100644 --- a/js/sdk/src/interface/httpClient.ts +++ b/js/sdk/src/interface/httpClient.ts @@ -1,5 +1,22 @@ export interface ProtonDriveHTTPClient { - fetch(request: Request, signal?: AbortSignal): Promise, + fetchJson(options: ProtonDriveHTTPClientJsonOptions): Promise; + fetchBlob(options: ProtonDriveHTTPClientBlobOptions): Promise; +} + +export type ProtonDriveHTTPClientJsonOptions = ProtonDriveHTTPClientBaseOptions & { + json?: object, +} + +export type ProtonDriveHTTPClientBlobOptions = ProtonDriveHTTPClientBaseOptions & { + body?: XMLHttpRequestBodyInit, + onProgress?: (progress: number) => void, +} + +type ProtonDriveHTTPClientBaseOptions = { + url: string, + method: string, + headers: Headers, + signal?: AbortSignal, } export type ProtonDriveConfig = { diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 5d570b2b..723d03e5 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -13,7 +13,7 @@ export { DeviceType } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, DeviceEventCallback, NodeEventCallback } from './events'; export { SDKEvent } from './events'; -export type { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; +export type { ProtonDriveHTTPClient, ProtonDriveHTTPClientJsonOptions, ProtonDriveHTTPClientBlobOptions, ProtonDriveConfig } from './httpClient'; export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 7ad36639..ac742b23 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -26,7 +26,8 @@ describe("DriveAPIService", () => { requestsUnthrottled: jest.fn(), } httpClient = { - fetch: jest.fn(() => Promise.resolve(generateOkResponse())), + fetchJson: jest.fn(() => Promise.resolve(generateOkResponse())), + fetchBlob: jest.fn(() => Promise.resolve(generateOkResponse())), }; api = new DriveAPIService(getMockTelemetry(), sdkEvents, httpClient, 'http://drive.proton.me', 'en'); }); @@ -59,7 +60,7 @@ describe("DriveAPIService", () => { async function expectToBeCalledWith(method: string, data?: object) { // @ts-expect-error: Fetch is mock. - const request = httpClient.fetch.mock.calls[0][0]; + const request = httpClient.fetchJson.mock.calls[0][0]; expect(request.method).toEqual(method); expect(Array.from(request.headers.entries())).toEqual(Array.from(new Headers({ "Accept": "application/vnd.protonmail.v1+json", @@ -67,20 +68,20 @@ describe("DriveAPIService", () => { "Language": 'en', "x-pm-drive-sdk-version": `js@${process.env.npm_package_version}`, }).entries())); - expect(await request.text()).toEqual(data ? JSON.stringify(data) : ""); + expect(await request.json).toEqual(data); expectSDKEvents(); } }); describe("should throw", () => { it("APIHTTPError on 4xx response without JSON body", async () => { - httpClient.fetch = jest.fn(() => Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not found' }))); + httpClient.fetchJson = jest.fn(() => Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not found' }))); await expect(api.get('test')).rejects.toThrow(new Error('Not found')); expectSDKEvents(); }); it("APIError on 4xx response with JSON body", async () => { - httpClient.fetch = jest.fn(() => Promise.resolve(new Response(JSON.stringify({ Code: 42, Error: 'General error' }), { status: 422 }))); + httpClient.fetchJson = jest.fn(() => Promise.resolve(new Response(JSON.stringify({ Code: 42, Error: 'General error' }), { status: 422 }))); await expect(api.get('test')).rejects.toThrow('General error'); expectSDKEvents(); }); @@ -90,7 +91,7 @@ describe("DriveAPIService", () => { it("on offline error", async () => { const error = new Error('Network offline'); error.name = 'OfflineError'; - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockRejectedValueOnce(error) .mockRejectedValueOnce(error) .mockResolvedValueOnce(generateOkResponse()); @@ -98,14 +99,14 @@ describe("DriveAPIService", () => { const result = api.get('test'); await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); - expect(httpClient.fetch).toHaveBeenCalledTimes(3); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); expectSDKEvents(); }); it("on timeout error", async () => { const error = new Error('Timeouted'); error.name = 'TimeoutError'; - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockRejectedValueOnce(error) .mockRejectedValueOnce(error) .mockResolvedValueOnce(generateOkResponse()); @@ -113,24 +114,24 @@ describe("DriveAPIService", () => { const result = api.get('test'); await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); - expect(httpClient.fetch).toHaveBeenCalledTimes(3); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); expectSDKEvents(); }); it("on general error", async () => { - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockRejectedValueOnce(new Error('Error')) .mockResolvedValueOnce(generateOkResponse()); const result = api.get('test'); await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); - expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); }); it("only once on general error", async () => { - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockRejectedValueOnce(new Error('First error')) .mockRejectedValueOnce(new Error('Second error')) .mockResolvedValueOnce(generateOkResponse()); @@ -138,12 +139,12 @@ describe("DriveAPIService", () => { const result = api.get('test'); await expect(result).rejects.toThrow("Second error"); - expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); }); it("on 429 response", async () => { - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })) .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })) .mockResolvedValueOnce(generateOkResponse()); @@ -151,38 +152,38 @@ describe("DriveAPIService", () => { const result = api.get('test'); await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); - expect(httpClient.fetch).toHaveBeenCalledTimes(3); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); // No event is sent on random 429, only if limit of too many subsequent 429s is reached. expectSDKEvents(); }); it("on 5xx response", async () => { - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })) .mockResolvedValueOnce(generateOkResponse()); const result = api.get('test'); await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); - expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); }); it("only once on 5xx response", async () => { - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockResolvedValue(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })); const result = api.get('test'); await expect(result).rejects.toThrow("Some error"); - expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); }); }); describe("should handle subsequent errors", () => { it("limit 429 errors", async () => { - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockResolvedValue(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })); for (let i = 0; i < 20; i++) { @@ -190,19 +191,19 @@ describe("DriveAPIService", () => { } await expect(api.get('test')).rejects.toThrow("Too many server requests, please try again later"); - expect(httpClient.fetch).toHaveBeenCalledTimes(50); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(50); expectSDKEvents(SDKEvent.RequestsThrottled); // SDK will not send any requests for 60 seconds. jest.advanceTimersByTime(90 * 1000); - httpClient.fetch = jest.fn().mockResolvedValue(generateOkResponse()); + httpClient.fetchJson = jest.fn().mockResolvedValue(generateOkResponse()); await api.get('test'); expect(sdkEvents.requestsThrottled).toHaveBeenCalledTimes(1); }); it("do not limit 429s when some pass", async () => { let attempt = 0; - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockImplementation(() => { if (attempt++ % 5 === 0) { return generateOkResponse(); @@ -216,12 +217,12 @@ describe("DriveAPIService", () => { await expect(api.get('test')).resolves.toEqual({ Code: ErrorCode.OK }); // 20 calls * 5 retries till OK response + 1 last successful call - expect(httpClient.fetch).toHaveBeenCalledTimes(101); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(101); expectSDKEvents(); }); it("limit server errors", async () => { - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockResolvedValue(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })); for (let i = 0; i < 20; i++) { @@ -229,13 +230,13 @@ describe("DriveAPIService", () => { } await expect(api.get('test')).rejects.toThrow("Too many server errors, please try again later"); - expect(httpClient.fetch).toHaveBeenCalledTimes(10); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(10); expectSDKEvents(); }); it("do not limit server errors when some pass", async () => { let attempt = 0; - httpClient.fetch = jest.fn() + httpClient.fetchJson = jest.fn() .mockImplementation(() => { if (attempt++ % 5 === 0) { return generateOkResponse(); @@ -249,7 +250,7 @@ describe("DriveAPIService", () => { await expect(api.get('test')).rejects.toThrow("Some error"); // 15 erroring calls * 2 attempts + 5 successful calls - expect(httpClient.fetch).toHaveBeenCalledTimes(35); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(35); expectSDKEvents(); }); }); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index e3844438..3213d6d0 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -117,18 +117,20 @@ export class DriveAPIService { data?: RequestPayload, signal?: AbortSignal, ): Promise { - const request = new Request(`${this.baseUrl}/${url}`, { - method: method || 'GET', + const request = { + url: `${this.baseUrl}/${url}`, + method, headers: new Headers({ "Accept": "application/vnd.protonmail.v1+json", "Content-Type": "application/json", "Language": this.language, "x-pm-drive-sdk-version": `js@${VERSION}`, }), - body: data && JSON.stringify(data), - }); + json: data || undefined, + signal, + } - const response = await this.fetch(request, signal); + const response = await this.fetch(request, () => this.httpClient.fetchJson(request)); try { const result = await response.json(); @@ -149,28 +151,39 @@ export class DriveAPIService { } async getBlockStream(baseUrl: string, token: string, signal?: AbortSignal): Promise> { - const response = await this.makeStorageRequest('GET', baseUrl, token, undefined, signal); + const response = await this.makeStorageRequest('GET', baseUrl, token, undefined, undefined, signal); if (!response.body) { throw new Error(c('Error').t`File download failed due to empty response`); } return response.body; } - async postBlockStream(baseUrl: string, token: string, data: BodyInit, signal?: AbortSignal): Promise { - await this.makeStorageRequest('POST', baseUrl, token, data, signal); + async postBlockStream(baseUrl: string, token: string, data: XMLHttpRequestBodyInit, onProgress?: (uploadedBytes: number) => void, signal?: AbortSignal): Promise { + await this.makeStorageRequest('POST', baseUrl, token, data, onProgress, signal); } - private async makeStorageRequest(method: 'GET' | 'POST', url: string, token: string, body?: BodyInit, signal?: AbortSignal): Promise { - const request = new Request(`${url}`, { + private async makeStorageRequest( + method: 'GET' | 'POST', + url: string, + token: string, + body?: XMLHttpRequestBodyInit, + onProgress?: (uploadedBytes: number) => void, + signal?: AbortSignal, + ): Promise { + const request = { + url, method, - credentials: 'omit', headers: new Headers({ "pm-storage-token": token, + "Language": this.language, + "x-pm-drive-sdk-version": `js@${VERSION}`, }), body, - }); + onProgress, + signal, + }; - const response = await this.fetch(request, signal); + const response = await this.fetch(request, () => this.httpClient.fetchBlob(request)); if (response.status >= 400) { try { @@ -192,11 +205,11 @@ export class DriveAPIService { // u=5 for background (e.g., upload, download) // u=7 for optional (e.g., metrics, telemetry) private async fetch( - request: Request, - signal?: AbortSignal, + request: { method: string, url: string, signal?: AbortSignal }, + callback: () => Promise, attempt = 0 ): Promise { - if (signal?.aborted) { + if (request.signal?.aborted) { throw new AbortError(c('Error').t`Request aborted`); } @@ -213,25 +226,25 @@ export class DriveAPIService { let response; try { - response = await this.httpClient.fetch(request, signal); + response = await callback(); } catch (error: unknown) { if (error instanceof Error) { if (error.name === 'OfflineError') { this.logger.info(`${request.method} ${request.url}: Offline error, retrying`); await waitSeconds(OFFLINE_RETRY_DELAY_SECONDS); - return this.fetch(request, signal, attempt+1); + return this.fetch(request, callback, attempt+1); } if (error.name === 'TimeoutError') { this.logger.warn(`${request.method} ${request.url}: Timeout error, retrying`); await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); - return this.fetch(request, signal, attempt+1); + return this.fetch(request, callback, attempt+1); } } if (attempt === 0) { this.logger.error(`${request.method} ${request.url}: failed, retrying once`, error); await waitSeconds(GENERAL_RETRY_DELAY_SECONDS); - return this.fetch(request, signal, attempt+1); + return this.fetch(request, callback, attempt+1); } this.logger.error(`${request.method} ${request.url}: failed`, error); throw error; @@ -247,7 +260,7 @@ export class DriveAPIService { this.tooManyRequestsErrorHappened(); const timeout = parseInt(response.headers.get('retry-after') || '0', DEFAULT_429_RETRY_DELAY_SECONDS); await waitSeconds(timeout); - return this.fetch(request, signal, attempt+1); + return this.fetch(request, callback, attempt+1); } else { this.clearSubsequentTooManyRequestsError(); } @@ -261,7 +274,7 @@ export class DriveAPIService { this.logger.warn(`${request.method} ${request.url}: ${response.status} - retry failed`); } else { await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); - return this.fetch(request, signal, attempt+1); + return this.fetch(request, callback, attempt+1); } } else { if (attempt > 0) { diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 5960a8e0..283a4294 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -238,21 +238,6 @@ export class UploadAPIService { const formData = new FormData(); formData.append("Block", new Blob([block]), "blob"); - await this.apiService.postBlockStream(url, token, formData, signal); - - // TODO: implement onProgress properly - // One option is to use ObserverStream, same as for download. - // That requires ReadableStream. FormData can be converted - // to text via `new Response(formData).text()` and that can - // be converted to stream. But there are more details to handle. - // For example, validation on backend is very sensitive and - // we must ensure to implement correctly what fetch does by - // default, or that re-trying streams needs to re-create the - // stream first. - // Other option is to use XMLHttpRequest, but that is not - // supported in all environments (Bun for example) and that - // would require additional requirement on the HTTP client - // interface. - onProgress?.(block.length); + await this.apiService.postBlockStream(url, token, formData, onProgress, signal); } } From b564adff07cd652bba3108d56e99780d4f722cc1 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 May 2025 05:37:55 +0000 Subject: [PATCH 092/791] use error when name cannot be decrypted --- js/sdk/src/interface/devices.ts | 2 +- js/sdk/src/interface/nodes.ts | 10 ++++++---- js/sdk/src/interface/sharing.ts | 4 ++-- js/sdk/src/internal/nodes/cryptoService.test.ts | 4 ++-- js/sdk/src/internal/nodes/cryptoService.ts | 9 +++------ js/sdk/src/internal/nodes/interface.ts | 5 +++-- js/sdk/src/internal/nodes/nodesAccess.test.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.ts | 11 +++++------ js/sdk/src/internal/sharing/cryptoService.ts | 6 +++--- 9 files changed, 26 insertions(+), 27 deletions(-) diff --git a/js/sdk/src/interface/devices.ts b/js/sdk/src/interface/devices.ts index 88ee669c..20bc2e97 100644 --- a/js/sdk/src/interface/devices.ts +++ b/js/sdk/src/interface/devices.ts @@ -4,7 +4,7 @@ import { InvalidNameError } from './nodes'; export type Device = { uid: string, type: DeviceType, - name: Result, + name: Result, rootFolderUid: string, creationTime: Date, lastSyncDate?: Date; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 9d0d2d6f..723dfc4b 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -89,18 +89,17 @@ export type NodeEntity = { * properties are decrypted, or that all actions can be performed on it. * * For example, if the node has issue decrypting the name, the name will be - * set es `InvalidNameError` and potentially rename or move actions will not be + * set as `Error` and potentially rename or move actions will not be * possible, but download and upload new revision will still work. */ export type DegradedNode = Omit & { - name: Result, + name: Result, activeRevision?: Result, /** * If the error is not related to any specific field, it is set here. * * For example, if the node has issue decrypting the name, the name will be - * set es `InvalidNameError` that includes the error, while this will be - * empty. + * set as `Error` while this will be empty. * * On the other hand, if the node has issue decrypting the node key, but * the name is still working, this will include the node key error, while @@ -109,6 +108,9 @@ export type DegradedNode = Omit & { errors?: unknown[], } +/** + * Invalid name error represents node name that includes invalid characters. + */ export type InvalidNameError = { /** * Placeholder instead of node name that client can use to display. diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 888157db..863fdfc4 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -14,7 +14,7 @@ export type ProtonInvitation = Member; export type ProtonInvitationWithNode = ProtonInvitation & { node: { - name: Result, + name: Result, type: NodeType, mediaType?: string, }, @@ -42,7 +42,7 @@ export type Bookmark = { uid: string, bookmarkTime: Date, node: { - name: Result, + name: Result, type: NodeType, mediaType?: string, }, diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 50b12d18..c971d864 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -273,7 +273,7 @@ describe("nodesCryptoService", () => { const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - name: { ok: false, error: { name: "", error: "Decryption error" } }, + name: { ok: false, error }, nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, }, 'noKeys'); verifyLogEventDecryptionError({ @@ -521,7 +521,7 @@ describe("nodesCryptoService", () => { const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - name: { ok: false, error: { name: "", error: "Decryption error" } }, + name: { ok: false, error }, nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, }, 'noKeys'); verifyLogEventDecryptionError({ diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index e2b6fb27..5e03fb6b 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { resultOk, resultError, Result, InvalidNameError, Author, ProtonDriveAccount, ProtonDriveTelemetry, Logger, MetricsDecryptionErrorField, MetricVerificationErrorField } from "../../interface"; +import { resultOk, resultError, Result, Author, ProtonDriveAccount, ProtonDriveTelemetry, Logger, MetricsDecryptionErrorField, MetricVerificationErrorField } from "../../interface"; import { ValidationError } from '../../errors'; import { getErrorMessage, getVerificationMessage } from "../errors"; import { splitNodeUid } from "../uids"; @@ -230,7 +230,7 @@ export class NodesCryptoService { }; private async decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PrivateKey[]): Promise<{ - name: Result, + name: Result, author: Author, }> { const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail || node.encryptedCrypto.signatureEmail; @@ -250,10 +250,7 @@ export class NodesCryptoService { void this.reportDecryptionError(node, 'nodeName', error); const errorMessage = getErrorMessage(error); return { - name: resultError({ - name: '', - error: errorMessage, - }), + name: resultError(new Error(errorMessage)), author: resultError({ claimedAuthor: nameSignatureEmail, error: errorMessage, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index e295253c..4f3b4818 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -67,7 +67,7 @@ export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { export interface DecryptedUnparsedNode extends BaseNode { keyAuthor: Author, nameAuthor: Author, - name: Result, + name: Result, activeRevision?: Result, folder?: { extendedAttributes?: string, @@ -78,9 +78,10 @@ export interface DecryptedUnparsedNode extends BaseNode { /** * Interface holding decrypted node metadata. */ -export interface DecryptedNode extends Omit, Omit { +export interface DecryptedNode extends Omit, Omit { // Internal metadata isStale: boolean; + name: Result, activeRevision?: Result, folder?: { diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 9108a802..f43c1661 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -422,7 +422,7 @@ describe('nodesAccess', () => { ...node1, encryptedCrypto, parentUid: 'parentUidFor:node1', - name: { ok: false, error: { name: '', error: decryptionError.message } }, + name: { ok: false, error: decryptionError }, keyAuthor: { ok: false, error: { claimedAuthor: 'signatureEmail', error: decryptionError.message } }, nameAuthor: { ok: false, error: { claimedAuthor: 'nameSignatureEmail', error: decryptionError.message } }, errors: [decryptionError], diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 57741580..09d91a25 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey } from "../../crypto"; -import { Logger, MissingNode, NodeType, resultError, resultOk } from "../../interface"; +import { InvalidNameError, Logger, MissingNode, NodeType, Result, resultError, resultOk } from "../../interface"; import { DecryptionError, ProtonDriveError } from "../../errors"; import { getErrorMessage } from '../errors'; import { BatchLoading } from "../batchLoading"; @@ -184,10 +184,7 @@ export class NodesAccess { node: { ...encryptedNode, isStale: false, - name: resultError({ - name: '', - error: getErrorMessage(error), - }), + name: resultError(error), keyAuthor: resultError({ claimedAuthor: encryptedNode.encryptedCrypto.signatureEmail, error: getErrorMessage(error), @@ -221,12 +218,13 @@ export class NodesAccess { } private async parseNode(unparsedNode: DecryptedUnparsedNode): Promise { + let nodeName: Result = unparsedNode.name; if (unparsedNode.name.ok) { try { validateNodeName(unparsedNode.name.value); } catch (error: unknown) { this.logger.warn(`Node name validation failed: ${error instanceof Error ? error.message : error}`); - unparsedNode.name = resultError({ + nodeName = resultError({ name: unparsedNode.name.value, error: error instanceof Error ? error.message : c('Error').t`Unknown error`, }); @@ -258,6 +256,7 @@ export class NodesAccess { : undefined; return { ...unparsedNode, + name: nodeName, isStale: false, activeRevision: undefined, folder: extendedAttributes ? { diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index f3ffd6a6..f1023aea 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs'; import { c } from 'ttag'; import { DriveCrypto, PrivateKey, SessionKey, uint8ArrayToBase64String, VERIFICATION_STATUS } from '../../crypto'; -import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, InvalidNameError, resultError, resultOk, PublicLink } from "../../interface"; +import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, resultError, resultOk, PublicLink } from "../../interface"; import { getErrorMessage, getVerificationMessage } from "../errors"; import { EncryptedShare } from "../shares"; import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink } from "./interface"; @@ -164,7 +164,7 @@ export class SharingCryptoService { inviteeKey, ); - let nodeName: Result; + let nodeName: Result; try { const result = await this.driveCrypto.decryptNodeName( encryptedInvitation.node.encryptedName, @@ -174,7 +174,7 @@ export class SharingCryptoService { nodeName = resultOk(result.name); } catch (error: unknown) { const errorMessage = c('Error').t`Failed to decrypt item name: ${getErrorMessage(error)}`; - nodeName = resultError({ name: '', error: errorMessage }); + nodeName = resultError(new Error(errorMessage)); } return { From d9972d763bd7f9e7a3d76409d98abf2f9798599f Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 May 2025 14:26:51 +0200 Subject: [PATCH 093/791] support iterating nodes over multiple volumes --- js/sdk/src/internal/nodes/apiService.test.ts | 18 +++++++++++ js/sdk/src/internal/nodes/apiService.ts | 33 +++++++++++++------- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 86f7346f..ca052374 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -256,6 +256,24 @@ describe("nodeAPIService", () => { ]); } }); + + it('should get nodes across various volumes', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async (url) => Promise.resolve({ + Links: [ + generateAPIFolderNode({ + LinkID: url.includes('volumeId1') ? 'nodeId1' : 'nodeId2', + ParentLinkID: url.includes('volumeId1') ? 'parentNodeId1' : 'parentNodeId2', + }), + ], + })); + + const nodes = await Array.fromAsync(api.iterateNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); + expect(nodes).toStrictEqual([ + generateFolderNode({ uid: 'volumeId1~nodeId1', parentUid: 'volumeId1~parentNodeId1' }), + generateFolderNode({ uid: 'volumeId2~nodeId2', parentUid: 'volumeId2~parentNodeId2' }), + ]); + }); }); describe('trashNodes', () => { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 39ef5cdc..e867f362 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -63,30 +63,39 @@ export class NodeAPIService { return result.value; } - // Improvement requested: support multiple volumes. // Improvement requested: split into multiple calls for many nodes. async* iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Loading items`, nodeIds); + const allNodeIds = nodeUids.map(splitNodeUid); - const response = await this.apiService.post(`drive/v2/volumes/${volumeId}/links`, { - LinkIDs: nodeIds.map(({ nodeId }) => nodeId), - }, signal); + const nodeIdsByVolumeId = new Map(); + for (const { volumeId, nodeId } of allNodeIds) { + if (!nodeIdsByVolumeId.has(volumeId)) { + nodeIdsByVolumeId.set(volumeId, []); + } + nodeIdsByVolumeId.get(volumeId)?.push(nodeId); + } // If the API returns node that is not recognised, it is returned as // an error, but first all nodes that are recognised are yielded. // Thus we capture all errors and throw them at the end of iteration. const errors = []; - for (const link of response.Links) { - try { - yield linkToEncryptedNode(this.logger, volumeId, link); - } catch (error: unknown) { - this.logger.error(`Failed to transform node ${link.Link.LinkID}`, error); - errors.push(error); + for (const [volumeId, nodeIds] of nodeIdsByVolumeId.entries()) { + const response = await this.apiService.post(`drive/v2/volumes/${volumeId}/links`, { + LinkIDs: nodeIds, + }, signal); + + for (const link of response.Links) { + try { + yield linkToEncryptedNode(this.logger, volumeId, link); + } catch (error: unknown) { + this.logger.error(`Failed to transform node ${link.Link.LinkID}`, error); + errors.push(error); + } } } + if (errors.length) { this.logger.warn(`Failed to load ${errors.length} nodes`); throw new ProtonDriveError(c('Error').t`Failed to load some nodes`, { cause: errors }); From 8b2aa8af4d6881fa00493d3bb9ac76c3042b5ad2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 May 2025 14:29:20 +0200 Subject: [PATCH 094/791] support verification of anonymous files --- .../src/internal/nodes/cryptoService.test.ts | 133 ++++++++++++++++++ js/sdk/src/internal/nodes/cryptoService.ts | 31 ++-- 2 files changed, 152 insertions(+), 12 deletions(-) diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index c971d864..108b0aa7 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -86,6 +86,7 @@ describe("nodesCryptoService", () => { describe("folder node", () => { const encryptedNode = { uid: "volumeId~nodeId", + parentUid: "volumeId~parentId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -323,6 +324,7 @@ describe("nodesCryptoService", () => { describe("file node", () => { const encryptedNode = { uid: "volumeId~nodeId", + parentUid: "volumeId~parentId", encryptedCrypto: { signatureEmail: "signatureEmail", nameSignatureEmail: "nameSignatureEmail", @@ -569,4 +571,135 @@ describe("nodesCryptoService", () => { await expect(result).rejects.toThrow("Failed to load keys"); }); }); + + describe("anonymous node", () => { + const encryptedNode = { + uid: "volumeId~nodeId", + parentUid: "volumeId~parentId", + encryptedCrypto: { + signatureEmail: undefined, + nameSignatureEmail: undefined, + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + file: { + base64ContentKeyPacket: "base64ContentKeyPacket", + }, + activeRevision: { + uid: "revisionUid", + state: "active", + signatureEmail: "revisionSignatureEmail", + armoredExtendedAttributes: "encryptedExtendedAttributes", + }, + }, + } as EncryptedNode; + + const encryptedNodeWithoutParent = { + ...encryptedNode, + parentUid: undefined, + } + + function verifyResult( + result: { node: DecryptedUnparsedNode, keys?: DecryptedNodeKeys }, + expectedNode: Partial = {}, + expectedKeys: Partial | 'noKeys' = {}, + ) { + expect(result).toMatchObject({ + node: { + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, + folder: undefined, + activeRevision: { ok: true, value: { + uid: "revisionUid", + state: RevisionState.Active, + creationTime: undefined, + extendedAttributes: "{}", + contentAuthor: { ok: true, value: "revisionSignatureEmail" }, + } }, + errors: undefined, + ...expectedNode, + }, + ...expectedKeys === 'noKeys' ? {} : { + keys: { + passphrase: "pass", + key: "decryptedKey", + passphraseSessionKey: "passphraseSessionKey", + hashKey: undefined, + contentKeyPacketSessionKey: "contentKeyPacketSessionKey", + ...expectedKeys, + }, + }, + }); + } + + describe("should decrypt with verification issues", () => { + it("on node key and name with access to parent node", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ + name: "name", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: false, error: { claimedAuthor: undefined, error: "Signature verification for key failed" } }, + nameAuthor: { ok: false, error: { claimedAuthor: undefined, error: "Signature verification for name failed" } }, + }); + verifyLogEventVerificationError({ + field: 'nodeName', + addressMatchingDefaultShare: undefined, + }); + expect(driveCrypto.decryptKey).toHaveBeenCalledWith( + encryptedNode.encryptedCrypto.armoredKey, + encryptedNode.encryptedCrypto.armoredNodePassphrase, + encryptedNode.encryptedCrypto.armoredNodePassphraseSignature, + [parentKey], + [parentKey], + ); + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith( + encryptedNode.encryptedName, + parentKey, + [parentKey], + ); + }); + + it("on anonymous node key and name without access to parent node", async () => { + driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ + passphrase: "pass", + key: "decryptedKey" as unknown as PrivateKey, + passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ + name: "name", + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + })); + + const result = await cryptoService.decryptNode(encryptedNodeWithoutParent, parentKey); + verifyResult(result, { + keyAuthor: { ok: true, value: null }, + nameAuthor: { ok: true, value: null }, + }); + expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(driveCrypto.decryptKey).toHaveBeenCalledWith( + encryptedNode.encryptedCrypto.armoredKey, + encryptedNode.encryptedCrypto.armoredNodePassphrase, + encryptedNode.encryptedCrypto.armoredNodePassphraseSignature, + [parentKey], + [], + ); + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith( + encryptedNode.encryptedName, + parentKey, + [], + ); + }); + }); + }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 5e03fb6b..8f3bd200 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { resultOk, resultError, Result, Author, ProtonDriveAccount, ProtonDriveTelemetry, Logger, MetricsDecryptionErrorField, MetricVerificationErrorField } from "../../interface"; +import { resultOk, resultError, Result, Author, AnonymousUser, ProtonDriveAccount, ProtonDriveTelemetry, Logger, MetricsDecryptionErrorField, MetricVerificationErrorField } from "../../interface"; import { ValidationError } from '../../errors'; import { getErrorMessage, getVerificationMessage } from "../errors"; import { splitNodeUid } from "../uids"; @@ -46,10 +46,16 @@ export class NodesCryptoService { ? await this.account.getPublicKeys(node.encryptedCrypto.signatureEmail) : []; + // Parent key is node or share key. Anonymous files are signed with + // the node parent key. If the anonymous file is shared directly, + // there is no access to the parent key. In that case, the verification + // is skipped. + const nodeParentKeys = node.parentUid ? [parentKey] : []; + // Anonymous uploads (without signature email set) use parent key instead. const keyVerificationKeys = node.encryptedCrypto.signatureEmail ? signatureEmailKeys - : [parentKey]; + : nodeParentKeys; let nameVerificationKeys; const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail || node.encryptedCrypto.signatureEmail; @@ -58,14 +64,14 @@ export class NodesCryptoService { } else { nameVerificationKeys = nameSignatureEmail ? await this.account.getPublicKeys(nameSignatureEmail) - : [parentKey]; + : nodeParentKeys; } const { name, author: nameAuthor } = await this.decryptName(node, parentKey, nameVerificationKeys); let passphrase, key, passphraseSessionKey, keyAuthor; try { - const keyResult = await this.decryptKey(node, parentKey, signatureEmailKeys); + const keyResult = await this.decryptKey(node, parentKey, keyVerificationKeys); passphrase = keyResult.passphrase; key = keyResult.key; passphraseSessionKey = keyResult.passphraseSessionKey; @@ -225,7 +231,7 @@ export class NodesCryptoService { passphrase: key.passphrase, key: key.key, passphraseSessionKey: key.passphraseSessionKey, - author: await this.handleClaimedAuthor(node, 'nodeKey', c('Property').t`key`, key.verified, node.encryptedCrypto.signatureEmail), + author: await this.handleClaimedAuthor(node, 'nodeKey', c('Property').t`key`, key.verified, node.encryptedCrypto.signatureEmail, verificationKeys.length === 0), }; }; @@ -244,7 +250,7 @@ export class NodesCryptoService { return { name: resultOk(name), - author: await this.handleClaimedAuthor(node, 'nodeName', c('Property').t`name`, verified, nameSignatureEmail), + author: await this.handleClaimedAuthor(node, 'nodeName', c('Property').t`name`, verified, nameSignatureEmail, verificationKeys.length === 0), } } catch (error: unknown) { void this.reportDecryptionError(node, 'nodeName', error); @@ -443,9 +449,10 @@ export class NodesCryptoService { field: MetricVerificationErrorField, signatureType: string, verified: VERIFICATION_STATUS, - claimedAuthor?: string + claimedAuthor?: string, + notAvailableVerificationKeys = false, ): Promise { - const author = handleClaimedAuthor(signatureType, verified, claimedAuthor); + const author = handleClaimedAuthor(signatureType, verified, claimedAuthor, notAvailableVerificationKeys); if (!author.ok) { void this.reportVerificationError(node, field, claimedAuthor); } @@ -516,13 +523,13 @@ export class NodesCryptoService { /** * @param signatureType - Must be translated before calling this function. */ -function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string): Author { - if (!claimedAuthor) { - return resultOk(null); // Anonymous user +function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string, notAvailableVerificationKeys = false): Author { + if (!claimedAuthor && notAvailableVerificationKeys) { + return resultOk(null as AnonymousUser); } if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { - return resultOk(claimedAuthor); + return resultOk(claimedAuthor || (null as AnonymousUser)); } return resultError({ From d2c72fbceb37a72fcb54ed53f004e2b4431135ea Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 21 May 2025 08:18:51 +0200 Subject: [PATCH 095/791] generate digest and other attributes during upload --- js/sdk/package-lock.json | 13 ++++++++ js/sdk/package.json | 1 + .../internal/nodes/extendedAttributes.test.ts | 27 +++++++++++++++- .../src/internal/nodes/extendedAttributes.ts | 31 +++++++++++++++---- js/sdk/src/internal/upload/digests.ts | 18 +++++++++++ js/sdk/src/internal/upload/fileUploader.ts | 12 ++++++- 6 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 js/sdk/src/internal/upload/digests.ts diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index e20c65f2..d8b5e839 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "GPL-3.0", "dependencies": { + "@noble/hashes": "^1.8.0", "bcryptjs": "^2.4.3", "ttag": "^1.8.7" }, @@ -2925,6 +2926,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/js/sdk/package.json b/js/sdk/package.json index 36feb1b8..cc86350b 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -15,6 +15,7 @@ "test:watch": "jest --watch --coverage=false" }, "dependencies": { + "@noble/hashes": "^1.8.0", "bcryptjs": "^2.4.3", "ttag": "^1.8.7" }, diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index 4943ef42..8bb017bb 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -1,5 +1,5 @@ import { getMockLogger } from "../../tests/logger"; -import { FolderExtendedAttributes, FileExtendedAttributesParsed, generateFolderExtendedAttributes, parseFolderExtendedAttributes, parseFileExtendedAttributes } from './extendedAttributes'; +import { FolderExtendedAttributes, FileExtendedAttributesParsed, generateFolderExtendedAttributes, generateFileExtendedAttributes, parseFolderExtendedAttributes, parseFileExtendedAttributes } from './extendedAttributes'; describe('extended attrbiutes', () => { describe('should generate folder attributes', () => { @@ -49,6 +49,31 @@ describe('extended attrbiutes', () => { }); }); + describe('should generate file attributes', () => { + const testCases: [object, string | undefined][] = [ + [{}, undefined], + [{modificationTime: new Date(1234567890000)}, '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z"}}'], + [{size: 1234}, '{"Common":{"Size":1234}}'], + [{blockSizes: [4,4,4,2]}, '{"Common":{"BlockSizes":[4,4,4,2]}}'], + [{digests: {sha1: 'abcdef'}}, '{"Common":{"Digests":{"SHA1":"abcdef"}}}'], + [ + { + modificationTime: new Date(1234567890000), + size: 1234, + blockSizes: [4, 4, 4, 2], + digests: {sha1: 'abcdef'}, + }, + '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z","Size":1234,"BlockSizes":[4,4,4,2],"Digests":{"SHA1":"abcdef"}}}', + ], + ]; + testCases.forEach(([input, expectedAttributes]) => { + it(`should generate ${input}`, () => { + const output = generateFileExtendedAttributes(input); + expect(output).toBe(expectedAttributes); + }) + }); + }); + describe('should parses file attributes', () => { const testCases: [string, FileExtendedAttributesParsed][] = [ ['', {}], diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index cc1e338e..b7eadeb8 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -82,15 +82,34 @@ export function parseFolderExtendedAttributes(logger: Logger, extendedAttributes } } -export function generateFileExtendedAttributes(claimedModificationTime?: Date): string | undefined { - if (!claimedModificationTime) { +export function generateFileExtendedAttributes(options: { + modificationTime?: Date, + size?: number, + blockSizes?: number[], + digests?: { + sha1?: string, + }, +}): string | undefined { + const commonAttributes: FileExtendedAttributesSchema['Common'] = {}; + if (options.modificationTime) { + commonAttributes.ModificationTime = dateToIsoString(options.modificationTime); + } + if (options.size) { + commonAttributes.Size = options.size; + } + if (options.blockSizes) { + commonAttributes.BlockSizes = options.blockSizes; + } + if (options.digests?.sha1) { + commonAttributes.Digests = { + SHA1: options.digests.sha1, + }; + } + if (!Object.keys(commonAttributes).length) { return undefined; } - // FIXME: Add support for other attributes return JSON.stringify({ - Common: { - ModificationTime: dateToIsoString(claimedModificationTime), - }, + Common: commonAttributes, }); } diff --git a/js/sdk/src/internal/upload/digests.ts b/js/sdk/src/internal/upload/digests.ts new file mode 100644 index 00000000..9592300a --- /dev/null +++ b/js/sdk/src/internal/upload/digests.ts @@ -0,0 +1,18 @@ +import { sha1 } from "@noble/hashes/legacy"; +import { bytesToHex } from '@noble/hashes/utils'; + +export class UploadDigests { + constructor(private digestSha1 = sha1.create()) { + this.digestSha1 = digestSha1; + } + + update(data: Uint8Array): void { + this.digestSha1.update(data); + } + + digests(): { sha1: string } { + return { + sha1: bytesToHex(this.digestSha1.digest()), + } + } +} diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 2d41c240..12b02dbd 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -12,6 +12,7 @@ import { UploadAPIService } from "./apiService"; import { BlockVerifier } from "./blockVerifier"; import { UploadController } from './controller'; import { UploadCryptoService } from "./cryptoService"; +import { UploadDigests } from "./digests"; import { NodeRevisionDraft, EncryptedBlock, EncryptedThumbnail, EncryptedBlockMetadata } from "./interface"; import { UploadTelemetry } from './telemetry'; import { ChunkStreamReader } from './chunkStreamReader'; @@ -56,6 +57,7 @@ const MAX_BLOCK_UPLOAD_RETRIES = 3; export class Fileuploader { private logger: Logger; + private digests: UploadDigests; private controller: UploadController; private abortController: AbortController; @@ -97,6 +99,7 @@ export class Fileuploader { }); } + this.digests = new UploadDigests(); this.controller = new UploadController(); } @@ -202,7 +205,12 @@ export class Fileuploader { private async commitFile(thumbnails: Thumbnail[]) { this.verifyIntegrity(thumbnails); - const extendedAttributes = generateFileExtendedAttributes(this.metadata.modificationTime); + const extendedAttributes = generateFileExtendedAttributes({ + modificationTime: this.metadata.modificationTime, + size: this.metadata.expectedSize, + blockSizes: Array.from(this.uploadedBlocks.values().map(block => block.originalSize)), + digests: this.digests.digests(), + }); const nodeCommitCrypto = await this.cryptoService.commitFile(this.revisionDraft.nodeKeys, this.manifest, extendedAttributes); await this.apiService.commitDraftRevision(this.revisionDraft.nodeRevisionUid, nodeCommitCrypto); } @@ -226,6 +234,8 @@ export class Fileuploader { for await (const block of reader.iterateChunks()) { index++; + this.digests.update(block); + await this.controller.waitIfPaused(); await this.waitForBufferCapacity(); From 6b145c3d1271145ec48fe459c011c80e5daf1bd7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 May 2025 10:54:09 +0200 Subject: [PATCH 096/791] fix generating extended attributes with zero size --- js/sdk/src/internal/nodes/extendedAttributes.test.ts | 4 ++++ js/sdk/src/internal/nodes/extendedAttributes.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index 8bb017bb..073abf21 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -53,8 +53,12 @@ describe('extended attrbiutes', () => { const testCases: [object, string | undefined][] = [ [{}, undefined], [{modificationTime: new Date(1234567890000)}, '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z"}}'], + [{size: undefined}, undefined], + [{size: 0}, '{"Common":{"Size":0}}'], [{size: 1234}, '{"Common":{"Size":1234}}'], + [{blockSizes: []}, undefined], [{blockSizes: [4,4,4,2]}, '{"Common":{"BlockSizes":[4,4,4,2]}}'], + [{digests: {}}, undefined], [{digests: {sha1: 'abcdef'}}, '{"Common":{"Digests":{"SHA1":"abcdef"}}}'], [ { diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index b7eadeb8..748c20d1 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -94,10 +94,10 @@ export function generateFileExtendedAttributes(options: { if (options.modificationTime) { commonAttributes.ModificationTime = dateToIsoString(options.modificationTime); } - if (options.size) { + if (options.size !== undefined) { commonAttributes.Size = options.size; } - if (options.blockSizes) { + if (options.blockSizes?.length) { commonAttributes.BlockSizes = options.blockSizes; } if (options.digests?.sha1) { From 4affc43ce21f997f38273cac3af2fd738ea86bb3 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 27 May 2025 12:42:14 +0000 Subject: [PATCH 097/791] update local cache after action --- .../internal/download/thumbnailDownloader.ts | 2 +- js/sdk/src/internal/events/interface.ts | 4 +- js/sdk/src/internal/nodes/cryptoService.ts | 10 +-- js/sdk/src/internal/nodes/events.test.ts | 6 ++ js/sdk/src/internal/nodes/events.ts | 51 ++++++++++++- js/sdk/src/internal/nodes/index.ts | 6 +- js/sdk/src/internal/nodes/interface.ts | 11 +-- .../internal/nodes/nodesManagement.test.ts | 23 +++--- js/sdk/src/internal/nodes/nodesManagement.ts | 18 ++--- js/sdk/src/internal/sharing/index.ts | 5 +- js/sdk/src/internal/sharing/interface.ts | 7 ++ .../sharing/sharingManagement.test.ts | 72 +++++++++++++++++-- .../src/internal/sharing/sharingManagement.ts | 16 +++-- .../src/internal/upload/fileUploader.test.ts | 41 ++++++++--- js/sdk/src/internal/upload/fileUploader.ts | 23 ++++-- js/sdk/src/internal/upload/index.ts | 7 +- js/sdk/src/internal/upload/interface.ts | 16 +++++ js/sdk/src/internal/upload/manager.test.ts | 21 +++++- js/sdk/src/internal/upload/manager.ts | 72 ++++++++++++++++++- js/sdk/src/protonDriveClient.ts | 4 +- 20 files changed, 340 insertions(+), 75 deletions(-) diff --git a/js/sdk/src/internal/download/thumbnailDownloader.ts b/js/sdk/src/internal/download/thumbnailDownloader.ts index 4800c197..d0523b37 100644 --- a/js/sdk/src/internal/download/thumbnailDownloader.ts +++ b/js/sdk/src/internal/download/thumbnailDownloader.ts @@ -103,7 +103,7 @@ export class ThumbnailDownloader { let thumbnail; if (node.activeRevision?.ok) { - thumbnail = node.activeRevision.value.thumbnails.find( + thumbnail = node.activeRevision.value.thumbnails?.find( (t) => t.type === thumbnailType, ); } diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index ccc6a710..742a0186 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -27,14 +27,14 @@ export type DriveEvents = Events; export type DriveEvent = { type: DriveEventType.NodeCreated | DriveEventType.NodeUpdated | DriveEventType.NodeUpdatedMetadata, nodeUid: string, - parentNodeUid: string, + parentNodeUid?: string, isTrashed: boolean, isShared: boolean, isOwnVolume: boolean, } | { type: DriveEventType.NodeDeleted, nodeUid: string, - parentNodeUid: string, + parentNodeUid?: string, isTrashed?: boolean, isShared?: boolean, isOwnVolume: boolean, diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 8f3bd200..ee5fcf17 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -5,7 +5,7 @@ import { resultOk, resultError, Result, Author, AnonymousUser, ProtonDriveAccoun import { ValidationError } from '../../errors'; import { getErrorMessage, getVerificationMessage } from "../errors"; import { splitNodeUid } from "../uids"; -import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, SharesService, EncryptedRevision, DecryptedRevision } from "./interface"; +import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, SharesService, EncryptedRevision, DecryptedUnparsedRevision } from "./interface"; /** * Provides crypto operations for nodes metadata. @@ -58,7 +58,7 @@ export class NodesCryptoService { : nodeParentKeys; let nameVerificationKeys; - const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail || node.encryptedCrypto.signatureEmail; + const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail; if (nameSignatureEmail === node.encryptedCrypto.signatureEmail) { nameVerificationKeys = keyVerificationKeys; } else { @@ -134,7 +134,7 @@ export class NodesCryptoService { } } - let activeRevision: Result | undefined; + let activeRevision: Result | undefined; let contentKeyPacketSessionKey; let contentKeyPacketAuthor; if ("file" in node.encryptedCrypto) { @@ -239,7 +239,7 @@ export class NodesCryptoService { name: Result, author: Author, }> { - const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail || node.encryptedCrypto.signatureEmail; + const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail; try { const { name, verified } = await this.driveCrypto.decryptNodeName( @@ -290,7 +290,7 @@ export class NodesCryptoService { } } - async decryptRevision(nodeUid: string, encryptedRevision: EncryptedRevision, nodeKey: PrivateKey): Promise { + async decryptRevision(nodeUid: string, encryptedRevision: EncryptedRevision, nodeKey: PrivateKey): Promise { const verificationKeys = encryptedRevision.signatureEmail ? await this.account.getPublicKeys(encryptedRevision.signatureEmail) : [nodeKey]; diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index 8863adcd..f823297c 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -48,6 +48,12 @@ describe("updateCacheByEvent", () => { expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); }); + + it("should skip reset parent loaded state if parent missing", async () => { + await updateCacheByEvent(logger, { ...event, parentNodeUid: undefined }, cache); + + expect(cache.resetFolderChildrenLoaded).not.toHaveBeenCalled(); + }); }); describe('NodeUpdated event', () => { diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 3bc53318..a2da4f9d 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -40,7 +40,11 @@ type NodeEventInfo = { export class NodesEvents { private listeners: Listeners = []; - constructor(logger: Logger, events: DriveEventsService, cache: NodesCache, nodesAccess: NodesAccess) { + constructor(private logger: Logger, events: DriveEventsService, private cache: NodesCache, private nodesAccess: NodesAccess) { + this.logger = logger; + this.cache = cache; + this.nodesAccess = nodesAccess; + events.addListener(async (events, fullRefreshVolumeId) => { if (fullRefreshVolumeId) { await cache.setNodesStaleFromVolume(fullRefreshVolumeId); @@ -79,6 +83,47 @@ export class NodesEvents { this.listeners = this.listeners.filter(listener => listener.callback !== callback); } } + + async nodeCreated(node: DecryptedNode): Promise { + await this.cache.setNode(node); + void this.notifyListenersByNode(node, DriveEventType.NodeCreated); + } + + async nodeUpdated(partialNode: { uid: string } & Partial): Promise { + const originalNode = await this.cache.getNode(partialNode.uid); + const updatedNode = { + ...originalNode, + ...partialNode, + } + + await this.cache.setNode(updatedNode); + void this.notifyListenersByNode(updatedNode, DriveEventType.NodeUpdated); + } + + async nodesDeleted(nodeUids: string[]): Promise { + try { + for await (const originalNode of this.cache.iterateNodes(nodeUids)) { + if (originalNode.ok) { + void this.notifyListenersByNode(originalNode.node, DriveEventType.NodeDeleted); + } + } + } catch {} + + await this.cache.removeNodes(nodeUids); + } + + private async notifyListenersByNode(node: DecryptedNode, eventType: DriveEventType.NodeCreated | DriveEventType.NodeUpdated | DriveEventType.NodeDeleted) { + const event: DriveEvent = { + type: eventType, + nodeUid: node.uid, + parentNodeUid: node.parentUid, + isOwnVolume: true, + isTrashed: !!node.trashTime, + isShared: node.isShared, + }; + await notifyListenersByEvent(this.logger, event, this.listeners, this.cache, this.nodesAccess); + + } } /** @@ -107,7 +152,9 @@ export async function updateCacheByEvent(logger: Logger, event: DriveEvent, cach // We do not have partial nodes in the cache, so we don't // add it. If new node is not added, we need to reset the // children loaded flag to force refetch when requested. - await cache.resetFolderChildrenLoaded(event.parentNodeUid); + if (event.parentNodeUid) { + await cache.resetFolderChildrenLoaded(event.parentNodeUid); + } } if (event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) { let node; diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 6233f4e5..47fd978c 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -40,11 +40,7 @@ export function initNodesModule( const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); const nodesAccess = new NodesAccess(telemetry.getLogger('nodes'), api, cache, cryptoCache, cryptoService, sharesService); const nodesEvents = new NodesEvents(telemetry.getLogger('nodes-events'), driveEvents, cache, nodesAccess); - // FIXME: Events are sent to the client once event is received from API - // If change is done locally, it will take a time to show up if client - // is waiting with UI update to events. Thus we need to emit events - // right away. - const nodesManagement = new NodesManagement(api, cache, cryptoCache, cryptoService, nodesAccess); + const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess, nodesEvents); const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); return { diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 4f3b4818..d4fe702b 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,6 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType, MetricContext } from "../../interface"; -import { RevisionState } from "../../interface/nodes"; +import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType, MetricContext, Revision, RevisionState } from "../../interface"; /** * Internal common node interface for both encrypted or decrypted node. @@ -68,7 +67,7 @@ export interface DecryptedUnparsedNode extends BaseNode { keyAuthor: Author, nameAuthor: Author, name: Result, - activeRevision?: Result, + activeRevision?: Result, folder?: { extendedAttributes?: string, }, @@ -123,11 +122,15 @@ export interface EncryptedRevision extends BaseRevision { armoredExtendedAttributes?: string; } -export interface DecryptedRevision extends BaseRevision { +export interface DecryptedUnparsedRevision extends BaseRevision { contentAuthor: Author, extendedAttributes?: string, } +export interface DecryptedRevision extends Revision { + thumbnails?: Thumbnail[]; +} + /** * Interface describing the dependencies to the shares module. */ diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 4a3d9bca..d92ad2fa 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -1,17 +1,17 @@ import { NodeAPIService } from "./apiService"; -import { NodesCache } from "./cache" import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { NodesAccess } from './nodesAccess'; +import { NodesEvents } from './events'; import { DecryptedNode } from './interface'; import { NodesManagement } from './nodesManagement'; describe('NodesManagement', () => { let apiService: NodeAPIService; - let cache: NodesCache; let cryptoCache: NodesCryptoCache; let cryptoService: NodesCryptoService; let nodesAccess: NodesAccess; + let nodesEvents: NodesEvents; let management: NodesManagement; let nodes: { [uid: string]: DecryptedNode }; @@ -56,11 +56,6 @@ describe('NodesManagement', () => { createFolder: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking - cache = { - setNode: jest.fn(), - removeNodes: jest.fn(), - } - // @ts-expect-error No need to implement all methods for mocking cryptoCache = { setNodeKeys: jest.fn(), } @@ -90,8 +85,14 @@ describe('NodesManagement', () => { nameSessionKey: 'nameSessionKey', }), } + // @ts-expect-error No need to implement all methods for mocking + nodesEvents = { + nodeCreated: jest.fn(), + nodeUpdated: jest.fn(), + nodesDeleted: jest.fn(), + } - management = new NodesManagement(apiService, cache, cryptoCache, cryptoService, nodesAccess); + management = new NodesManagement(apiService, cryptoCache, cryptoService, nodesAccess, nodesEvents); }); it('renameNode manages rename and updates cache', async () => { @@ -113,7 +114,7 @@ describe('NodesManagement', () => { { hash: nodes.nodeUid.hash }, { encryptedName: 'newArmoredNodeName', nameSignatureEmail: 'newSignatureEmail', hash: 'newHash' } ); - expect(cache.setNode).toHaveBeenCalledWith(newNode); + expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith(newNode); }); it('moveNode manages move and updates cache', async () => { @@ -147,7 +148,7 @@ describe('NodesManagement', () => { signatureEmail: undefined, }, ); - expect(cache.setNode).toHaveBeenCalledWith(newNode); + expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith(newNode); }); it('moveNode manages move of anonymous node', async () => { @@ -179,6 +180,6 @@ describe('NodesManagement', () => { ...encryptedCrypto }, ); - expect(cache.setNode).toHaveBeenCalledWith(newNode); + expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith(newNode); }); }); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index fbef74ed..34cbf5aa 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -4,9 +4,9 @@ import { MemberRole, NodeType, NodeResult, resultOk } from "../../interface"; import { AbortError, ValidationError } from "../../errors"; import { getErrorMessage } from '../errors'; import { NodeAPIService } from "./apiService"; -import { NodesCache } from "./cache"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; +import { NodesEvents } from './events'; import { DecryptedNode } from "./interface"; import { NodesAccess } from "./nodesAccess"; import { validateNodeName } from "./validations"; @@ -24,16 +24,16 @@ import { generateFolderExtendedAttributes } from "./extendedAttributes"; export class NodesManagement { constructor( private apiService: NodeAPIService, - private cache: NodesCache, private cryptoCache: NodesCryptoCache, private cryptoService: NodesCryptoService, private nodesAccess: NodesAccess, + private nodesEvents: NodesEvents, ) { this.apiService = apiService; - this.cache = cache; this.cryptoCache = cryptoCache; this.cryptoService = cryptoService; this.nodesAccess = nodesAccess; + this.nodesEvents = nodesEvents; } async renameNode(nodeUid: string, newName: string, options = { allowRenameRootNode: false }): Promise { @@ -76,7 +76,7 @@ export class NodesManagement { nameAuthor: resultOk(signatureEmail), hash, } - await this.cache.setNode(newNode); + await this.nodesEvents.nodeUpdated(newNode); return newNode; } @@ -157,7 +157,7 @@ export class NodesManagement { keyAuthor: resultOk(encryptedCrypto.signatureEmail), nameAuthor: resultOk(encryptedCrypto.nameSignatureEmail), }; - await this.cache.setNode(newNode); + await this.nodesEvents.nodeUpdated(newNode); return newNode; } @@ -169,7 +169,7 @@ export class NodesManagement { if (result.ok) { const node = nodes.find(node => node.uid === result.uid); if (node) { - await this.cache.setNode({ + await this.nodesEvents.nodeUpdated({ ...node, trashTime: new Date(), }); @@ -188,7 +188,7 @@ export class NodesManagement { if (result.ok) { const node = nodes.find(node => node.uid === result.uid); if (node) { - await this.cache.setNode({ + await this.nodesEvents.nodeUpdated({ ...node, trashTime: undefined, }); @@ -209,7 +209,7 @@ export class NodesManagement { yield result; } - await this.cache.removeNodes(deletedNodeUids); + await this.nodesEvents.nodesDeleted(deletedNodeUids); } async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { @@ -263,7 +263,7 @@ export class NodesManagement { name: resultOk(folderName), } - await this.cache.setNode(node); + await this.nodesEvents.nodeCreated(node); await this.cryptoCache.setNodeKeys(nodeUid, keys); return node; } diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index a3db6180..ecc4943b 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -8,7 +8,7 @@ import { SharingCryptoService } from "./cryptoService"; import { SharingEvents } from "./events"; import { SharingAccess } from "./sharingAccess"; import { SharingManagement } from "./sharingManagement"; -import { SharesService, NodesService } from "./interface"; +import { SharesService, NodesService, NodesEvents } from "./interface"; /** * Provides facade for the whole sharing module. @@ -26,13 +26,14 @@ export function initSharingModule( driveEvents: DriveEventsService, sharesService: SharesService, nodesService: NodesService, + nodesEvents: NodesEvents, ) { const api = new SharingAPIService(telemetry.getLogger('sharing-api'), apiService); const cache = new SharingCache(driveEntitiesCache); const cryptoService = new SharingCryptoService(crypto, account); const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); const sharingEvents = new SharingEvents(telemetry.getLogger('sharing-events'), driveEvents, cache, nodesService, sharingAccess); - const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, account, sharesService, nodesService); + const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, account, sharesService, nodesService, nodesEvents); return { access: sharingAccess, diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index a7d80a5a..5c3cf1d5 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -144,3 +144,10 @@ export interface NodesService { }>, iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } + +/** + * Interface describing the dependencies to the nodes module. + */ +export interface NodesEvents { + nodeUpdated(partialNode: { uid: string, shareId: string | undefined, isShared: boolean }): Promise, +} diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 7fe1a414..c994bf8b 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -2,7 +2,7 @@ import { getMockLogger } from "../../tests/logger"; import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonDriveAccount, ProtonInvitation, PublicLink, resultOk } from "../../interface"; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService } from "./interface"; +import { SharesService, NodesService, NodesEvents } from "./interface"; import { SharingManagement } from "./sharingManagement"; describe("SharingManagement", () => { @@ -11,12 +11,14 @@ describe("SharingManagement", () => { let accountService: ProtonDriveAccount; let sharesService: SharesService; let nodesService: NodesService; + let nodesEvents: NodesEvents; let sharingManagement: SharingManagement; beforeEach(() => { // @ts-expect-error No need to implement all methods for mocking apiService = { + createStandardShare: jest.fn().mockReturnValue("newShareId"), getShareInvitations: jest.fn().mockResolvedValue([]), getShareExternalInvitations: jest.fn().mockResolvedValue([]), getShareMembers: jest.fn().mockResolvedValue([]), @@ -41,11 +43,12 @@ describe("SharingManagement", () => { } // @ts-expect-error No need to implement all methods for mocking cryptoService = { + generateShareKeys: jest.fn().mockResolvedValue({ shareKey: { encrypted: "encrypted-key", decrypted: { passphraseSessionKey: "pass-session-key", } } }), decryptShare: jest.fn().mockImplementation((share) => share), decryptInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptExternalInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptMember: jest.fn().mockImplementation((member) => member), - encryptInvitation: jest.fn().mockImplementation((invitation) => invitation), + encryptInvitation: jest.fn().mockImplementation(() => {}), encryptExternalInvitation: jest.fn().mockImplementation((invitation) => ({ ...invitation, base64ExternalInvitationSignature: "extenral-signature", @@ -65,9 +68,13 @@ describe("SharingManagement", () => { nodesService = { getNode: jest.fn().mockImplementation((nodeUid) => ({ nodeUid, shareId: "shareId", name: { ok: true, value: "name" } })), getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: "node-key" })), + getNodePrivateAndSessionKeys: jest.fn().mockImplementation((nodeUid) => ({})), + } + nodesEvents = { + nodeUpdated: jest.fn(), } - sharingManagement = new SharingManagement(getMockLogger(), apiService, cryptoService, accountService, sharesService, nodesService); + sharingManagement = new SharingManagement(getMockLogger(), apiService, cryptoService, accountService, sharesService, nodesService, nodesEvents); }); describe("getSharingInfo", () => { @@ -150,7 +157,36 @@ describe("SharingManagement", () => { }); }); - describe("shareNode", () => { + describe("shareNode with share creation", () => { + const nodeUid = "volumeId~nodeUid"; + + it("should create share if no exists", async () => { + nodesService.getNode = jest.fn().mockImplementation((nodeUid) => ({ nodeUid, parentUid: 'parentUid', name: { ok: true, value: "name" } })); + + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email"] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [{ + uid: "created-invitation", + addedByEmail: { ok: true, value: "volume-email" }, + inviteeEmail: "email", + role: "viewer", + }], + nonProtonInvitations: [], + members: [], + publicLink: undefined, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith({ + uid: nodeUid, + shareId: "newShareId", + isShared: true, + }); + }); + }) + + describe("shareNode with share re-use", () => { const nodeUid = "volumeId~nodeUid"; let invitation: ProtonInvitation; @@ -215,6 +251,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should share node with proton email with specific role", async () => { @@ -233,6 +270,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should update existing role", async () => { @@ -249,6 +287,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if no change", async () => { @@ -262,6 +301,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); @@ -287,6 +327,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should share node with external email with specific role", async () => { @@ -306,6 +347,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should update existing role", async () => { @@ -322,6 +364,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateExternalInvitation).toHaveBeenCalled(); expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if no change", async () => { @@ -335,6 +378,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); @@ -372,6 +416,7 @@ describe("SharingManagement", () => { expect(apiService.inviteExternalUser).toHaveBeenCalledWith("shareId", expect.objectContaining({ inviteeEmail: "email2", }), expect.anything()); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); @@ -391,6 +436,7 @@ describe("SharingManagement", () => { expect(apiService.updateMember).toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if no change via proton user", async () => { @@ -405,6 +451,7 @@ describe("SharingManagement", () => { expect(apiService.updateMember).not.toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should update member via non-proton user", async () => { @@ -422,6 +469,7 @@ describe("SharingManagement", () => { expect(apiService.updateMember).toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if no change via non-proton user", async () => { @@ -436,6 +484,7 @@ describe("SharingManagement", () => { expect(apiService.updateMember).not.toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); }); @@ -504,6 +553,7 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should delete external invitation", async () => { @@ -520,6 +570,7 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should remove member", async () => { @@ -536,6 +587,7 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if not shared with email", async () => { @@ -552,6 +604,7 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should remove public link", async () => { @@ -568,6 +621,7 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should remove share if all is removed", async () => { @@ -579,6 +633,11 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith({ + uid: nodeUid, + shareId: undefined, + isShared: false, + }); }); it("should remove share if everything is manually removed", async () => { @@ -593,6 +652,11 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); expect(apiService.removeMember).toHaveBeenCalled(); expect(apiService.removePublicLink).toHaveBeenCalled(); + expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith({ + uid: nodeUid, + shareId: undefined, + isShared: false, + }); }); }); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 2d8d6d4d..19e8473a 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -7,7 +7,7 @@ import { splitNodeUid } from "../uids"; import { getErrorMessage } from '../errors'; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService } from "./interface"; +import { SharesService, NodesService, NodesEvents } from "./interface"; interface InternalShareResult extends ShareResult { share: Share; @@ -39,6 +39,7 @@ export class SharingManagement { private account: ProtonDriveAccount, private sharesService: SharesService, private nodesService: NodesService, + private nodesEvents: NodesEvents, ) { this.logger = logger; this.apiService = apiService; @@ -46,6 +47,7 @@ export class SharingManagement { this.account = account; this.sharesService = sharesService; this.nodesService = nodesService; + this.nodesEvents = nodesEvents; } async getSharingInfo(nodeUid: string): Promise { @@ -244,7 +246,7 @@ export class SharingManagement { if (!settings) { this.logger.info(`Unsharing node ${nodeUid}`); - await this.deleteShare(currentSharing.share.shareId); + await this.deleteShare(nodeUid, currentSharing.share.shareId); return; } @@ -298,7 +300,7 @@ export class SharingManagement { // update local state immediately. this.logger.info(`Deleting share ${currentSharing.share.shareId} for node ${nodeUid}`); try { - await this.deleteShare(currentSharing.share.shareId); + await this.deleteShare(nodeUid, currentSharing.share.shareId); } catch (error: unknown) { // If deleting the share fails, we don't want to throw an error // as it might be a race condition that other client updated @@ -343,7 +345,6 @@ export class SharingManagement { } } - // FIXME: update nodes cache with new shareId private async createShare(nodeUid: string): Promise { const node = await this.nodesService.getNode(nodeUid); if (!node.parentUid) { @@ -365,6 +366,8 @@ export class SharingManagement { }, ); + await this.nodesEvents.nodeUpdated({ uid: nodeUid, shareId, isShared: true }); + return { volumeId, shareId, @@ -372,9 +375,10 @@ export class SharingManagement { } } - // FIXME: update nodes cache with deleted shareId - private async deleteShare(shareId: string): Promise { + private async deleteShare(nodeUid: string, shareId: string): Promise { await this.apiService.deleteShare(shareId); + + await this.nodesEvents.nodeUpdated({ uid: nodeUid, shareId: undefined, isShared: false }); } private async inviteProtonUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index 1cca9038..f78832ae 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -1,12 +1,13 @@ import { Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; -import { Fileuploader } from './fileUploader'; +import { FILE_CHUNK_SIZE, Fileuploader } from './fileUploader'; import { UploadTelemetry } from './telemetry'; import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; import { UploadController } from './controller'; import { BlockVerifier } from './blockVerifier'; import { NodeRevisionDraft } from './interface'; +import { UploadManager } from './manager'; async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise, _: any, block: Uint8Array, index: number) { await verifyBlock(block); @@ -29,6 +30,7 @@ describe('FileUploader', () => { let telemetry: UploadTelemetry; let apiService: jest.Mocked; let cryptoService: UploadCryptoService; + let uploadManager: UploadManager; let blockVerifier: BlockVerifier; let revisionDraft: NodeRevisionDraft; let metadata: UploadMetadata; @@ -55,9 +57,9 @@ describe('FileUploader', () => { apiService = { requestBlockUpload: jest.fn().mockImplementation((_, __, blocks) => ({ blockTokens: blocks.contentBlocks.map((block: { index: number }) => ({ - index: block.index, - bareUrl: `bareUrl/block:${block.index}`, - token: `token/block:${block.index}`, + index: block.index, + bareUrl: `bareUrl/block:${block.index}`, + token: `token/block:${block.index}`, })), thumbnailTokens: (blocks.thumbnails || []).map((thumbnail: { type: number }) => ({ type: thumbnail.type, @@ -65,7 +67,6 @@ describe('FileUploader', () => { token: `token/thumbnail:${thumbnail.type}`, })), })), - commitDraftRevision: jest.fn().mockResolvedValue(undefined), uploadBlock: jest.fn().mockImplementation(mockUploadBlock), }; @@ -79,7 +80,11 @@ describe('FileUploader', () => { hash: 'thumbnailHash', })), encryptBlock: jest.fn().mockImplementation(mockEncryptBlock), - commitFile: jest.fn().mockResolvedValue('commitCrypto'), + }; + + // @ts-expect-error No need to implement all methods for mocking + uploadManager = { + commitDraft: jest.fn().mockResolvedValue(undefined), }; // @ts-expect-error No need to implement all methods for mocking @@ -107,6 +112,7 @@ describe('FileUploader', () => { telemetry, apiService, cryptoService, + uploadManager, blockVerifier, revisionDraft, metadata, @@ -169,9 +175,23 @@ describe('FileUploader', () => { const controller = uploader.writeStream(stream, thumbnails, onProgress); await controller.completion(); - expect(cryptoService.commitFile).toHaveBeenCalledTimes(1); - expect(apiService.commitDraftRevision).toHaveBeenCalledTimes(1); - expect(apiService.commitDraftRevision).toHaveBeenCalledWith(revisionDraft.nodeRevisionUid, 'commitCrypto'); + expect(uploadManager.commitDraft).toHaveBeenCalledTimes(1); + expect(uploadManager.commitDraft).toHaveBeenCalledWith( + revisionDraft, + expect.anything(), + metadata, + { + size: metadata.expectedSize, + blockSizes: metadata.expectedSize ? [ + ...Array(Math.floor(metadata.expectedSize / FILE_CHUNK_SIZE)).fill(FILE_CHUNK_SIZE), + metadata.expectedSize % FILE_CHUNK_SIZE + ] : [], + modificationTime: undefined, + digests: { + sha1: expect.anything(), + } + } + ); expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); expect(telemetry.uploadFailed).not.toHaveBeenCalled(); @@ -246,6 +266,7 @@ describe('FileUploader', () => { telemetry, apiService, cryptoService, + uploadManager, blockVerifier, revisionDraft, metadata, @@ -272,6 +293,7 @@ describe('FileUploader', () => { telemetry, apiService, cryptoService, + uploadManager, blockVerifier, revisionDraft, metadata, @@ -434,6 +456,7 @@ describe('FileUploader', () => { telemetry, apiService, cryptoService, + uploadManager, blockVerifier, revisionDraft, { diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 12b02dbd..b2ddbf5f 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -5,7 +5,6 @@ import { IntegrityError } from "../../errors"; import { LoggerWithPrefix } from "../../telemetry"; import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from "../apiService"; import { getErrorMessage } from "../errors"; -import { generateFileExtendedAttributes } from "../nodes"; import { mergeUint8Arrays } from "../utils"; import { waitForCondition } from '../wait'; import { UploadAPIService } from "./apiService"; @@ -16,11 +15,12 @@ import { UploadDigests } from "./digests"; import { NodeRevisionDraft, EncryptedBlock, EncryptedThumbnail, EncryptedBlockMetadata } from "./interface"; import { UploadTelemetry } from './telemetry'; import { ChunkStreamReader } from './chunkStreamReader'; +import { UploadManager } from "./manager"; /** * File chunk size in bytes representing the size of each block. */ -const FILE_CHUNK_SIZE = 4 * 1024 * 1024; +export const FILE_CHUNK_SIZE = 4 * 1024 * 1024; /** * Maximum number of blocks that can be buffered before upload. @@ -76,6 +76,7 @@ export class Fileuploader { private telemetry: UploadTelemetry, private apiService: UploadAPIService, private cryptoService: UploadCryptoService, + private uploadManager: UploadManager, private blockVerifier: BlockVerifier, private revisionDraft: NodeRevisionDraft, private metadata: UploadMetadata, @@ -205,14 +206,22 @@ export class Fileuploader { private async commitFile(thumbnails: Thumbnail[]) { this.verifyIntegrity(thumbnails); - const extendedAttributes = generateFileExtendedAttributes({ + + const uploadedBlocks = Array.from(this.uploadedBlocks.values()); + uploadedBlocks.sort((a, b) => a.index - b.index); + + const extendedAttributes = { modificationTime: this.metadata.modificationTime, size: this.metadata.expectedSize, - blockSizes: Array.from(this.uploadedBlocks.values().map(block => block.originalSize)), + blockSizes: uploadedBlocks.map(block => block.originalSize), digests: this.digests.digests(), - }); - const nodeCommitCrypto = await this.cryptoService.commitFile(this.revisionDraft.nodeKeys, this.manifest, extendedAttributes); - await this.apiService.commitDraftRevision(this.revisionDraft.nodeRevisionUid, nodeCommitCrypto); + }; + await this.uploadManager.commitDraft( + this.revisionDraft, + this.manifest, + this.metadata, + extendedAttributes, + ); } private async encryptThumbnails(thumbnails: Thumbnail[]) { diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 5bbc9d8d..0934fe8f 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -4,7 +4,7 @@ import { DriveCrypto } from "../../crypto"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; import { UploadQueue } from "./queue"; -import { NodesService, SharesService } from "./interface"; +import { NodesService, NodesEvents, SharesService } from "./interface"; import { Fileuploader } from "./fileUploader"; import { UploadTelemetry } from "./telemetry"; import { UploadManager } from "./manager"; @@ -23,12 +23,13 @@ export function initUploadModule( driveCrypto: DriveCrypto, sharesService: SharesService, nodesService: NodesService, + nodesEvents: NodesEvents, ) { const api = new UploadAPIService(apiService); const cryptoService = new UploadCryptoService(driveCrypto, sharesService); const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); - const manager = new UploadManager(telemetry, api, cryptoService, sharesService, nodesService); + const manager = new UploadManager(telemetry, api, cryptoService, sharesService, nodesService, nodesEvents); const queue = new UploadQueue(); @@ -66,6 +67,7 @@ export function initUploadModule( uploadTelemetry, api, cryptoService, + manager, blockVerifier, revisionDraft, metadata, @@ -107,6 +109,7 @@ export function initUploadModule( uploadTelemetry, api, cryptoService, + manager, blockVerifier, revisionDraft, metadata, diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 1ba8e225..5e7ea4e6 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,11 +1,19 @@ import { PrivateKey, SessionKey } from "../../crypto"; import { MetricContext, ThumbnailType, Result, Revision } from "../../interface"; +import { DecryptedNode } from "../nodes"; export type NodeRevisionDraft = { nodeUid: string, nodeRevisionUid: string, nodeKeys: NodeRevisionDraftKeys, + // newNodeInfo is set only when revision is created with the new node. + newNodeInfo?: { + parentUid: string, + name: string, + encryptedName: string, + hash: string, + } } export type NodeRevisionDraftKeys = { @@ -93,6 +101,14 @@ export interface NodesService { }>, } +/** + * Interface describing the dependencies to the nodes module. + */ +export interface NodesEvents { + nodeCreated(node: DecryptedNode): Promise, + nodeUpdated(partialNode: { uid: string, activeRevision: Result }): Promise, +} + export interface NodesServiceNode { uid: string, activeRevision?: Result, diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 62be6c56..b8ba1783 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -4,7 +4,7 @@ import { getMockTelemetry } from "../../tests/telemetry"; import { ErrorCode } from "../apiService"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { SharesService, NodesService } from "./interface"; +import { SharesService, NodesService, NodesEvents } from "./interface"; import { UploadManager } from './manager'; describe("UploadManager", () => { @@ -13,6 +13,7 @@ describe("UploadManager", () => { let cryptoService: UploadCryptoService; let sharesService: SharesService; let nodesService: NodesService; + let nodesEvents: NodesEvents; let manager: UploadManager; @@ -81,8 +82,12 @@ describe("UploadManager", () => { key: 'parentNode:nodekey', }), } + nodesEvents = { + nodeCreated: jest.fn(), + nodeUpdated: jest.fn(), + } - manager = new UploadManager(telemetry, apiService, cryptoService, sharesService, nodesService); + manager = new UploadManager(telemetry, apiService, cryptoService, sharesService, nodesService, nodesEvents); }); describe("createDraftNode", () => { @@ -109,6 +114,12 @@ describe("UploadManager", () => { email: "signatureEmail", }, }, + newNodeInfo: { + parentUid: "parentUid", + name: "name", + encryptedName: "newNode:encryptedName", + hash: "newNode:hash", + }, }); expect(apiService.createDraft).toHaveBeenCalledWith("parentUid", { armoredEncryptedName: "newNode:encryptedName", @@ -168,6 +179,12 @@ describe("UploadManager", () => { email: "signatureEmail", }, }, + newNodeInfo: { + parentUid: "parentUid", + name: "name", + encryptedName: "newNode:encryptedName", + hash: "newNode:hash", + }, }); expect(apiService.deleteDraft).toHaveBeenCalledWith("nodeUidToDelete"); }); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 6e666e9c..82ed49e8 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -1,12 +1,13 @@ import { c } from "ttag"; -import { Logger, ProtonDriveTelemetry, UploadMetadata } from "../../interface"; +import { Logger, MemberRole, NodeType, ProtonDriveTelemetry, resultOk, Revision, RevisionState, UploadMetadata } from "../../interface"; import { ValidationError, NodeAlreadyExistsValidationError } from "../../errors"; import { ErrorCode } from "../apiService"; +import { DecryptedNode, generateFileExtendedAttributes } from "../nodes"; import { splitNodeUid } from "../uids"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { NodeRevisionDraft, NodesService, NodeCrypto, SharesService } from "./interface"; +import { NodeRevisionDraft, NodesService, NodesEvents, NodeCrypto, SharesService } from "./interface"; /** * UploadManager is responsible for creating and deleting draft nodes @@ -22,6 +23,7 @@ export class UploadManager { private cryptoService: UploadCryptoService, private sharesService: SharesService, private nodesService: NodesService, + private nodesEvents: NodesEvents, ) { this.logger = telemetry.getLogger('upload'); this.apiService = apiService; @@ -58,6 +60,12 @@ export class UploadManager { contentKeyPacketSessionKey: generatedNodeCrypto.contentKey.decrypted.contentKeyPacketSessionKey, signatureAddress: generatedNodeCrypto.signatureAddress, }, + newNodeInfo: { + parentUid: parentFolderUid, + name, + encryptedName: generatedNodeCrypto.encryptedNode.encryptedName, + hash: generatedNodeCrypto.encryptedNode.hash, + }, }; } @@ -221,6 +229,66 @@ export class UploadManager { this.logger.error('Failed to delete draft node revision', error); } } + + async commitDraft( + nodeRevisionDraft: NodeRevisionDraft, + manifest: Uint8Array, + metadata: UploadMetadata, + extendedAttributes: { + modificationTime?: Date, + size?: number, + blockSizes?: number[], + digests?: { + sha1?: string, + }, + }, + ): Promise { + const generatedExtendedAttributes = generateFileExtendedAttributes(extendedAttributes); + const nodeCommitCrypto = await this.cryptoService.commitFile(nodeRevisionDraft.nodeKeys, manifest, generatedExtendedAttributes); + await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto); + + const activeRevision = resultOk({ + uid: nodeRevisionDraft.nodeRevisionUid, + state: RevisionState.Active, + creationTime: new Date(), + contentAuthor: resultOk(nodeCommitCrypto.signatureEmail), + claimedSize: metadata.expectedSize, + claimedModificationTime: extendedAttributes.modificationTime, + claimedDigests: { + sha1: extendedAttributes.digests?.sha1, + }, + }); + if (nodeRevisionDraft.newNodeInfo) { + const node: DecryptedNode = { + // Internal metadata + hash: nodeRevisionDraft.newNodeInfo.hash, + encryptedName: nodeRevisionDraft.newNodeInfo.encryptedName, + + // Basic node metadata + uid: nodeRevisionDraft.nodeUid, + parentUid: nodeRevisionDraft.newNodeInfo.parentUid, + type: NodeType.File, + mediaType: metadata.mediaType, + creationTime: new Date(), + + // Share node metadata + isShared: false, + directMemberRole: MemberRole.Inherited, + + // Decrypted metadata + isStale: false, + keyAuthor: resultOk(nodeRevisionDraft.nodeKeys.signatureAddress.email), + nameAuthor: resultOk(nodeRevisionDraft.nodeKeys.signatureAddress.email), + name: resultOk(nodeRevisionDraft.newNodeInfo.name), + } + await this.nodesEvents.nodeCreated(node); + } else { + await this.nodesEvents.nodeUpdated({ + uid: nodeRevisionDraft.nodeUid, + activeRevision, + }); + } + } } /** diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index cf87950e..3124123d 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -89,9 +89,9 @@ export class ProtonDriveClient { this.events = new DriveEventsService(telemetry, apiService, entitiesCache); this.shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, this.events, this.shares); - this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, this.events, this.shares, this.nodes.access); + this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, this.events, this.shares, this.nodes.access, this.nodes.events); this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.shares, this.nodes.access, this.nodes.revisions); - this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access); + this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.events); this.devices = initDevicesModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.management); this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { From 2197478ad8adc120b71d043c64ecf21b40b12a0a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 27 May 2025 15:37:23 +0200 Subject: [PATCH 098/791] update FIXMEs --- js/sdk/src/crypto/interface.ts | 2 +- js/sdk/src/interface/account.ts | 1 - js/sdk/src/internal/apiService/apiService.ts | 2 +- js/sdk/src/internal/nodes/cache.ts | 1 - js/sdk/src/protonDrivePhotosClient.ts | 2 +- js/sdk/typings/index.d.ts | 2 +- 6 files changed, 4 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 90574b57..1c20211f 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -1,4 +1,4 @@ -// FIXME: Use CryptoProxy once available. +// TODO: Use CryptoProxy once available. // eslint-disable-next-line @typescript-eslint/no-explicit-any export type PublicKey = any; // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index 61038ade..401ab8c6 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -2,7 +2,6 @@ import { PrivateKey, PublicKey } from '../crypto'; export interface ProtonDriveAccount { getOwnPrimaryAddress(): Promise, - // FIXME: do we want to break it down to email vs address ID methods? getOwnAddress(emailOrAddressId: string): Promise, hasProtonAccount(email: string): Promise, getPublicKeys(email: string): Promise, diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 3213d6d0..43d9827f 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -199,7 +199,7 @@ export class DriveAPIService { return response; } - // FIXME: add priority header + // TODO: add priority header // u=2 for interactive (user doing action, e.g., create folder), // u=4 for normal (user secondary action, e.g., refresh children listing), // u=5 for background (e.g., upload, download) diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 12183891..7082fd3c 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -108,7 +108,6 @@ export class NodesCache { childrenCacheUids.reverse(); await this.driveCache.removeEntities(childrenCacheUids); } catch (error: unknown) { - // FIXME: Should we throw here to the client? this.logger.error(`Failed to remove children from the cache`, error); } } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index a68bb3df..1267e912 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -9,7 +9,7 @@ import { SDKEvents } from './internal/sdkEvents'; import { getConfig } from './config'; import { Telemetry } from './telemetry'; -// FIXME: this is only example, on background it use drive internals, but it exposes nice interface for photos +// TODO: this is only example, on background it use drive internals, but it exposes nice interface for photos export class ProtonDrivePhotosClient { private nodes: ReturnType; private photos: ReturnType; diff --git a/js/sdk/typings/index.d.ts b/js/sdk/typings/index.d.ts index 1f8961cb..76fe5848 100644 --- a/js/sdk/typings/index.d.ts +++ b/js/sdk/typings/index.d.ts @@ -1,2 +1,2 @@ -// FIXME: Problem with importing pmcrypto - md5.js has no typing +// TODO: Problem with importing pmcrypto - md5.js has no typing declare module '*'; From 2d15b34d2286c718752b4dc5117f5b7ff6b61fae Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 May 2025 11:34:23 +0200 Subject: [PATCH 099/791] avoid console logs --- js/sdk/.eslintrc.js | 1 + js/sdk/src/internal/download/apiService.ts | 2 -- js/sdk/src/telemetry.ts | 9 +++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index d7ef218f..53a647cd 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -11,6 +11,7 @@ module.exports = { }, rules: { "tsdoc/syntax": "warn", + "no-console": "error", "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/consistent-type-exports": "error", }, diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index 97dfc02e..39932d16 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -106,8 +106,6 @@ export class DownloadAPIService { signal, ); - console.log("result", result) - for (const thumbnail of result.Thumbnails) { const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === thumbnail.ThumbnailID); if (!id) { diff --git a/js/sdk/src/telemetry.ts b/js/sdk/src/telemetry.ts index 11b13d10..63b022db 100644 --- a/js/sdk/src/telemetry.ts +++ b/js/sdk/src/telemetry.ts @@ -248,10 +248,10 @@ export class LogFilter { */ export class ConsoleLogHandler implements LogHandler { private logLevelMap = { - 'DEBUG': console.debug, - 'INFO': console.info, - 'WARNING': console.warn, - 'ERROR': console.error, + 'DEBUG': console.debug, // eslint-disable-line no-console + 'INFO': console.info, // eslint-disable-line no-console + 'WARNING': console.warn, // eslint-disable-line no-console + 'ERROR': console.error, // eslint-disable-line no-console } private formatter: LogFormatter; @@ -340,6 +340,7 @@ export class BasicLogFormatter implements LogFormatter { class ConsoleMetricHandler implements MetricHandler { onEvent(metric: MetricRecord) { + // eslint-disable-next-line no-console console.info(`${metric.time.toISOString()} INFO [metric] ${metric.event.eventName} ${JSON.stringify({ ...metric.event, name: undefined })}`); } } From 2232548ef5af919d575cfce4785fc39327abaca9 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 May 2025 11:37:07 +0200 Subject: [PATCH 100/791] fix caching node after upload --- js/sdk/src/internal/upload/manager.test.ts | 96 +++++++++++++++++++++- js/sdk/src/internal/upload/manager.ts | 2 + 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index b8ba1783..ebb6d837 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -1,5 +1,5 @@ import { ValidationError } from "../../errors"; -import { ProtonDriveTelemetry, UploadMetadata } from "../../interface"; +import { NodeType, ProtonDriveTelemetry, RevisionState, UploadMetadata } from "../../interface"; import { getMockTelemetry } from "../../tests/telemetry"; import { ErrorCode } from "../apiService"; import { UploadAPIService } from "./apiService"; @@ -30,6 +30,7 @@ describe("UploadManager", () => { availalbleHashes: ["name1Hash"], pendingHashes: [], }), + commitDraftRevision: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking cryptoService = { @@ -67,6 +68,11 @@ describe("UploadManager", () => { name: "name3", hash: "name3Hash", }]), + commitFile: jest.fn().mockResolvedValue({ + armoredManifestSignature: "newNode:armoredManifestSignature", + signatureEmail: "signatureEmail", + armoredExtendedAttributes: "newNode:armoredExtendedAttributes", + }), } // @ts-expect-error No need to implement all methods for mocking sharesService = { @@ -292,4 +298,92 @@ describe("UploadManager", () => { } }); }); + + describe("commit draft", () => { + const nodeRevisionDraft = { + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + nodeKeys: { + key: "newNode:key", + contentKeyPacketSessionKey: "newNode:contentKeyPacketSessionKey", + signatureAddress: { + email: "signatureEmail", + addressId: "addressId", + addressKey: "addressKey", + }, + }, + }; + const manifest = new Uint8Array([1, 2, 3]); + const metadata = { + mediaType: "myMimeType", + expectedSize: 123456, + }; + const extendedAttributes = { + modificationTime: new Date(), + digests: { + sha1: "sha1", + } + }; + + it("should commit revision draft", async () => { + await manager.commitDraft( + nodeRevisionDraft, + manifest, + metadata, + extendedAttributes, + ); + + expect(cryptoService.commitFile).toHaveBeenCalledWith(nodeRevisionDraft.nodeKeys, manifest, expect.anything()); + expect(apiService.commitDraftRevision).toHaveBeenCalledWith(nodeRevisionDraft.nodeRevisionUid, expect.anything()); + expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith({ + uid: "newNode:nodeUid", + activeRevision: { + ok: true, + value: { + uid: "newNode:nodeRevisionUid", + state: RevisionState.Active, + creationTime: expect.any(Date), + contentAuthor: { ok: true, value: "signatureEmail" }, + claimedSize: 123456, + claimedModificationTime: extendedAttributes.modificationTime, + claimedDigests: { + sha1: "sha1", + }, + }, + }, + }); + }) + + it("should commit node draft", async () => { + const nodeRevisionDraftWithNewNodeInfo = { + ...nodeRevisionDraft, + newNodeInfo: { + parentUid: "parentUid", + name: "newNode:name", + encryptedName: "newNode:encryptedName", + hash: "newNode:hash", + } + } + await manager.commitDraft( + nodeRevisionDraftWithNewNodeInfo, + manifest, + metadata, + extendedAttributes, + ); + + expect(cryptoService.commitFile).toHaveBeenCalledWith(nodeRevisionDraft.nodeKeys, manifest, expect.anything()); + expect(apiService.commitDraftRevision).toHaveBeenCalledWith(nodeRevisionDraft.nodeRevisionUid, expect.anything()); + expect(nodesEvents.nodeCreated).toHaveBeenCalledWith(expect.objectContaining({ + uid: "newNode:nodeUid", + parentUid: "parentUid", + type: NodeType.File, + activeRevision: { + ok: true, + value: expect.objectContaining({ + uid: "newNode:nodeRevisionUid", + }), + }, + })); + }); + }); }); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 82ed49e8..ded9dd2e 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -280,6 +280,8 @@ export class UploadManager { keyAuthor: resultOk(nodeRevisionDraft.nodeKeys.signatureAddress.email), nameAuthor: resultOk(nodeRevisionDraft.nodeKeys.signatureAddress.email), name: resultOk(nodeRevisionDraft.newNodeInfo.name), + + activeRevision, } await this.nodesEvents.nodeCreated(node); } else { From 5bf81763ae4c9ae7b29eecd781385d3bd4757781 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 27 May 2025 15:18:25 +0200 Subject: [PATCH 101/791] unset blocks after upload --- js/sdk/src/internal/upload/fileUploader.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index b2ddbf5f..55699f83 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -300,7 +300,7 @@ export class Fileuploader { ); for (const thumbnailToken of uploadTokens.thumbnailTokens) { - const encryptedThumbnail = this.encryptedThumbnails.get(thumbnailToken.type); + let encryptedThumbnail = this.encryptedThumbnails.get(thumbnailToken.type); if (!encryptedThumbnail) { throw new Error(`Thumbnail ${thumbnailToken.type} not found`); } @@ -315,13 +315,16 @@ export class Fileuploader { onProgress, ).finally(() => { this.ongoingUploads.delete(uploadKey); + + // Help the garbage collector to clean up the memory. + encryptedThumbnail = undefined; }), encryptedBlock: encryptedThumbnail, }); } for (const blockToken of uploadTokens.blockTokens) { - const encryptedBlock = this.encryptedBlocks.get(blockToken.index); + let encryptedBlock = this.encryptedBlocks.get(blockToken.index); if (!encryptedBlock) { throw new Error(`Block ${blockToken.index} not found`); } @@ -336,6 +339,9 @@ export class Fileuploader { onProgress, ).finally(() => { this.ongoingUploads.delete(uploadKey); + + // Help the garbage collector to clean up the memory. + encryptedBlock = undefined; }), encryptedBlock, }); From a6afdb436ee99d7957801dff4cd7eb1d1a0bf92c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 28 May 2025 06:59:43 +0200 Subject: [PATCH 102/791] add storage size to the node interface --- js/sdk/src/interface/nodes.ts | 11 +++++++++++ js/sdk/src/internal/nodes/apiService.test.ts | 4 ++++ js/sdk/src/internal/nodes/apiService.ts | 3 +++ js/sdk/src/internal/nodes/cryptoService.ts | 1 + js/sdk/src/internal/nodes/interface.ts | 2 ++ js/sdk/src/internal/nodes/nodesAccess.ts | 1 + js/sdk/src/internal/upload/fileUploader.test.ts | 10 +++++++--- js/sdk/src/internal/upload/fileUploader.ts | 2 ++ js/sdk/src/internal/upload/manager.ts | 3 +++ js/sdk/src/transformers.ts | 2 ++ 10 files changed, 36 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 723dfc4b..0e2269fe 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -70,6 +70,10 @@ export type NodeEntity = { */ creationTime: Date, trashTime?: Date, + /** + * Total size of all revisions, encrypted size on the server. + */ + totalStorageSize?: number, activeRevision?: Revision, folder?: { claimedModificationTime?: Date, @@ -136,6 +140,13 @@ export type Revision = { state: RevisionState, creationTime: Date, // created on server date contentAuthor: Author, + /** + * Encrypted size of the revision, as stored on the server. + */ + storageSize: number, + /** + * Raw size of the revision, as stored in extended attributes. + */ claimedSize?: number, claimedModificationTime?: Date, claimedDigests?: { diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index ca052374..379ee603 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -15,11 +15,13 @@ function generateAPIFileNode(linkOverrides = {}, overrides = {}) { MediaType: 'text', ContentKeyPacket: 'contentKeyPacket', ContentKeyPacketSignature: 'contentKeyPacketSig', + TotalEncryptedSize: 42, ActiveRevision: { RevisionID: 'revisionId', CreateTime: 1234567890, SignatureEmail: 'revSigEmail', XAttr: '{file}', + EncryptedSize: 12, }, }, ...overrides, @@ -68,6 +70,7 @@ function generateFileNode(overrides = {}) { ...node, type: NodeType.File, mediaType: "text", + totalStorageSize: 42, encryptedCrypto: { ...node.encryptedCrypto, file: { @@ -78,6 +81,7 @@ function generateFileNode(overrides = {}) { uid: "volumeId~linkId~revisionId", state: "active", creationTime: new Date(1234567890000), + storageSize: 12, signatureEmail: "revSigEmail", armoredExtendedAttributes: "{file}", thumbnails: [], diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index e867f362..4ecbca65 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -405,6 +405,7 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin if (link.Link.Type === 2 && link.File && link.File.ActiveRevision) { return { ...baseNodeMetadata, + totalStorageSize: link.File.TotalEncryptedSize, mediaType: link.File.MediaType || undefined, encryptedCrypto: { ...baseCryptoNodeMetadata, @@ -416,6 +417,7 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.File.ActiveRevision.RevisionID), state: RevisionState.Active, creationTime: new Date(link.File.ActiveRevision.CreateTime*1000), + storageSize: link.File.ActiveRevision.EncryptedSize, signatureEmail: link.File.ActiveRevision.SignatureEmail || undefined, armoredExtendedAttributes: link.File.ActiveRevision.XAttr || undefined, thumbnails: link.File.ActiveRevision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, link.Link.LinkID, thumbnail)) || [], @@ -436,6 +438,7 @@ function transformRevisionResponse( uid: makeNodeRevisionUid(volumeId, nodeId, revision.ID), state: revision.State === APIRevisionState.Active ? RevisionState.Active : RevisionState.Superseded, creationTime: new Date(revision.CreateTime*1000), + storageSize: revision.Size, signatureEmail: revision.SignatureEmail || undefined, armoredExtendedAttributes: revision.XAttr || undefined, thumbnails: revision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, nodeId, thumbnail)) || [], diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index ee5fcf17..c4f900ca 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -310,6 +310,7 @@ export class NodesCryptoService { uid: encryptedRevision.uid, state: encryptedRevision.state, creationTime: encryptedRevision.creationTime, + storageSize: encryptedRevision.storageSize, contentAuthor, extendedAttributes, thumbnails: encryptedRevision.thumbnails, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index d4fe702b..369ea7de 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -16,6 +16,7 @@ interface BaseNode { mediaType?: string; creationTime: Date; // created on the server trashTime?: Date; + totalStorageSize?: number; // Share node metadata shareId?: string; @@ -109,6 +110,7 @@ interface BaseRevision { uid: string; state: RevisionState; creationTime: Date; // created on the server + storageSize: number; thumbnails: Thumbnail[]; } diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 09d91a25..a6ab0cce 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -243,6 +243,7 @@ export class NodesAccess { uid: unparsedNode.activeRevision.value.uid, state: unparsedNode.activeRevision.value.state, creationTime: unparsedNode.activeRevision.value.creationTime, + storageSize: unparsedNode.activeRevision.value.storageSize, contentAuthor: unparsedNode.activeRevision.value.contentAuthor, thumbnails: unparsedNode.activeRevision.value.thumbnails, ...extendedAttributes, diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index f78832ae..fe5e2cf2 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -9,6 +9,8 @@ import { BlockVerifier } from './blockVerifier'; import { NodeRevisionDraft } from './interface'; import { UploadManager } from './manager'; +const BLOCK_ENCRYPTION_OVERHEAD = 10000; + async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise, _: any, block: Uint8Array, index: number) { await verifyBlock(block); return { @@ -17,7 +19,7 @@ async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise { const controller = uploader.writeStream(stream, thumbnails, onProgress); await controller.completion(); + const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); expect(uploadManager.commitDraft).toHaveBeenCalledTimes(1); expect(uploadManager.commitDraft).toHaveBeenCalledWith( revisionDraft, @@ -183,14 +186,15 @@ describe('FileUploader', () => { { size: metadata.expectedSize, blockSizes: metadata.expectedSize ? [ - ...Array(Math.floor(metadata.expectedSize / FILE_CHUNK_SIZE)).fill(FILE_CHUNK_SIZE), + ...Array(numberOfExpectedBlocks - 1).fill(FILE_CHUNK_SIZE), metadata.expectedSize % FILE_CHUNK_SIZE ] : [], modificationTime: undefined, digests: { sha1: expect.anything(), } - } + }, + metadata.expectedSize + numberOfExpectedBlocks * BLOCK_ENCRYPTION_OVERHEAD, ); expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 55699f83..a4ff4aac 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -216,11 +216,13 @@ export class Fileuploader { blockSizes: uploadedBlocks.map(block => block.originalSize), digests: this.digests.digests(), }; + const encryptedSize = uploadedBlocks.reduce((sum, block) => sum + block.encryptedSize, 0); await this.uploadManager.commitDraft( this.revisionDraft, this.manifest, this.metadata, extendedAttributes, + encryptedSize, ); } diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index ded9dd2e..0428cacc 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -242,6 +242,7 @@ export class UploadManager { sha1?: string, }, }, + encryptedSize: number, ): Promise { const generatedExtendedAttributes = generateFileExtendedAttributes(extendedAttributes); const nodeCommitCrypto = await this.cryptoService.commitFile(nodeRevisionDraft.nodeKeys, manifest, generatedExtendedAttributes); @@ -251,6 +252,7 @@ export class UploadManager { uid: nodeRevisionDraft.nodeRevisionUid, state: RevisionState.Active, creationTime: new Date(), + storageSize: encryptedSize, contentAuthor: resultOk(nodeCommitCrypto.signatureEmail), claimedSize: metadata.expectedSize, claimedModificationTime: extendedAttributes.modificationTime, @@ -270,6 +272,7 @@ export class UploadManager { type: NodeType.File, mediaType: metadata.mediaType, creationTime: new Date(), + totalStorageSize: encryptedSize, // Share node metadata isShared: false, diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index a28d5cad..d79c9a58 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -16,6 +16,7 @@ type InternalPartialNode = Pick< 'trashTime' | 'activeRevision' | 'folder' | + 'totalStorageSize' | 'errors' >; @@ -73,6 +74,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode isShared: node.isShared, creationTime: node.creationTime, trashTime: node.trashTime, + totalStorageSize: node.totalStorageSize, folder: node.folder, }; From 79f57ece9801e9bca09b24d6086d981e7ba4eb5c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 30 May 2025 13:17:49 +0200 Subject: [PATCH 103/791] fix upload tests --- js/sdk/src/internal/upload/manager.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index ebb6d837..022be95a 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -331,6 +331,7 @@ describe("UploadManager", () => { manifest, metadata, extendedAttributes, + 1234567, ); expect(cryptoService.commitFile).toHaveBeenCalledWith(nodeRevisionDraft.nodeKeys, manifest, expect.anything()); @@ -344,6 +345,7 @@ describe("UploadManager", () => { state: RevisionState.Active, creationTime: expect.any(Date), contentAuthor: { ok: true, value: "signatureEmail" }, + storageSize: 1234567, claimedSize: 123456, claimedModificationTime: extendedAttributes.modificationTime, claimedDigests: { @@ -369,6 +371,7 @@ describe("UploadManager", () => { manifest, metadata, extendedAttributes, + 1234567, ); expect(cryptoService.commitFile).toHaveBeenCalledWith(nodeRevisionDraft.nodeKeys, manifest, expect.anything()); @@ -377,10 +380,12 @@ describe("UploadManager", () => { uid: "newNode:nodeUid", parentUid: "parentUid", type: NodeType.File, + totalStorageSize: 1234567, activeRevision: { ok: true, value: expect.objectContaining({ uid: "newNode:nodeRevisionUid", + storageSize: 1234567, }), }, })); From 8fcddc4fa6ae3cb43b2cf7e75e9742b7ebdd90e5 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 30 May 2025 13:28:23 +0000 Subject: [PATCH 104/791] Implement trash management --- .../Api/Links/ILinksApiClient.cs | 15 +++ .../Api/Links/LinkIdResponsePair.cs | 6 + .../Api/Links/LinksApiClient.cs | 41 ++++++ .../Api/Links/MultipleLinksNullaryRequest.cs | 9 ++ .../Api/Storage/StorageApiClient.cs | 1 + .../Api/Volumes/IVolumesApiClient.cs | 9 +- .../Api/Volumes/ShareTrashDto.cs | 13 ++ .../Api/Volumes/VolumeTrashResponse.cs | 10 ++ .../Api/Volumes/VolumesApiClient.cs | 17 +++ .../Nodes/DegradedFileNode.cs | 3 +- .../Nodes/DegradedFolderNode.cs | 4 +- .../Proton.Drive.Sdk/Nodes/DegradedNode.cs | 4 +- .../Nodes/DtoToMetadataConverter.cs | 8 +- .../Nodes/FolderOperations.cs | 44 +++---- cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 2 +- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 119 +++++++++++++++++- .../Nodes/NodeResultExtensions.cs | 38 ++++++ cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs | 5 + .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 28 ++++- .../DriveApiSerializerContext.cs | 4 + .../Volumes/VolumeOperations.cs | 73 ++++++++++- .../Volumes/VolumeTrashBatchLoader.cs | 57 +++++++++ .../Proton.Sdk/Api/AggregateApiResponse.cs | 6 + cs/sdk/src/Proton.Sdk/RefResultExtensions.cs | 10 +- 24 files changed, 486 insertions(+), 40 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/MultipleLinksNullaryRequest.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/ShareTrashDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs create mode 100644 cs/sdk/src/Proton.Sdk/Api/AggregateApiResponse.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs index 55d25c2f..933174b0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs @@ -14,4 +14,19 @@ internal interface ILinksApiClient ValueTask MoveMultipleAsync(VolumeId volumeId, MoveMultipleLinksRequest request, CancellationToken cancellationToken); ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken); + + ValueTask> TrashMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken); + + ValueTask> DeleteMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken); + + ValueTask> RestoreMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs new file mode 100644 index 00000000..9ba8cd62 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal readonly record struct LinkIdResponsePair([property: JsonPropertyName("LinkID")] LinkId LinkId, ApiResponse Response); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs index c975dd23..aaa2c2e0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -48,4 +48,45 @@ public async ValueTask RenameAsync(VolumeId volumeId, LinkId linkId .PutAsync($"v2/volumes/{volumeId}/links/{linkId}/rename", request, DriveApiSerializerContext.Default.RenameLinkRequest, cancellationToken) .ConfigureAwait(false); } + + public async ValueTask> TrashMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) + .PostAsync($"v2/volumes/{volumeId}/trash_multiple", request, DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask> DeleteMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) + .PostAsync( + $"v2/volumes/{volumeId}/trash/delete_multiple", + request, + DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, + cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask> RestoreMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) + .PutAsync( + $"v2/volumes/{volumeId}/trash/restore_multiple", + request, + DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, + cancellationToken) + .ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MultipleLinksNullaryRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MultipleLinksNullaryRequest.cs new file mode 100644 index 00000000..88e8f7d5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MultipleLinksNullaryRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal readonly struct MultipleLinksNullaryRequest +{ + [JsonPropertyName("LinkIDs")] + public IEnumerable LinkIds { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index 309c2929..5e678c19 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -27,6 +27,7 @@ public async ValueTask UploadBlobAsync( using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, baseUrl, multipartContent); requestMessage.Headers.Add("pm-storage-token", token); + // TODO: investigate what happens with the stream in case of a retry after a failure, is there a seek back to its beginning? var response = await _httpClient .Expecting(ProtonApiSerializerContext.Default.ApiResponse) .SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs index 0ad7ad43..171d608b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs @@ -1,6 +1,13 @@ -namespace Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Volumes; internal interface IVolumesApiClient { ValueTask CreateVolumeAsync(VolumeCreationRequest request, CancellationToken cancellationToken); + + ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken); + + ValueTask EmptyTrashAsync(VolumeId volumeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/ShareTrashDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/ShareTrashDto.cs new file mode 100644 index 00000000..397bec8b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/ShareTrashDto.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal readonly record struct ShareTrashDto( + [property: JsonPropertyName("ShareID")] + ShareId ShareId, + [property: JsonPropertyName("LinkIDs")] + IReadOnlyList LinkIds, + [property: JsonPropertyName("ParentIDs")] + IReadOnlyList ParentIds); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs new file mode 100644 index 00000000..34299bce --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeTrashResponse : ApiResponse +{ + [JsonPropertyName("Trash")] + public IReadOnlyList TrashByShare { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs index e5de47f8..b4a9f206 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs @@ -1,5 +1,8 @@ using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; using Proton.Sdk.Http; +using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Api.Volumes; @@ -13,4 +16,18 @@ public async ValueTask CreateVolumeAsync(VolumeCreationR .Expecting(DriveApiSerializerContext.Default.VolumeCreationResponse) .PostAsync("volumes", request, DriveApiSerializerContext.Default.VolumeCreationRequest, cancellationToken).ConfigureAwait(false); } + + public async ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.VolumeTrashResponse) + .GetAsync($"volumes/{volumeId}/trash?pageSize={pageSize}&page={page}", cancellationToken).ConfigureAwait(false); + } + + public async ValueTask EmptyTrashAsync(VolumeId volumeId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .DeleteAsync("volumes/trash", cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs index 9f538c58..6a462bf4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed class DegradedFileNode : DegradedNode +public sealed record DegradedFileNode : DegradedNode { public required string MediaType { get; init; } @@ -23,7 +23,6 @@ public FileNode ToNode(string substituteName, Revision substituteRevision) NameAuthor = NameAuthor, Author = Author, ActiveRevision = ActiveRevision ?? substituteRevision, - IsTrashed = false, TotalStorageQuotaUsage = TotalStorageQuotaUsage, }; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs index 67c552dc..c915e41c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed class DegradedFolderNode : DegradedNode +public sealed record DegradedFolderNode : DegradedNode { public FolderNode ToNode(string substituteName) { @@ -12,7 +12,7 @@ public FolderNode ToNode(string substituteName) ParentUid = ParentUid, Name = Name.TryGetValue(out var name) ? name : substituteName, NameAuthor = NameAuthor, - IsTrashed = IsTrashed, + TrashTime = TrashTime, Author = Author, }; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs index 054ec5d5..8f46a375 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Nodes; -public abstract class DegradedNode +public abstract record DegradedNode { public required NodeUid Uid { get; init; } @@ -12,7 +12,7 @@ public abstract class DegradedNode public required ValResult NameAuthor { get; init; } - public required bool IsTrashed { get; init; } + public DateTime? TrashTime { get; init; } public required ValResult Author { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index e2a93e2c..a9c57d5e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -84,7 +84,7 @@ public static async ValueTask> ParentUid = parentUid, Name = nameResult, NameAuthor = default, - IsTrashed = linkDto.State is LinkState.Trashed, + TrashTime = linkDto.TrashTime, Author = default, Errors = null!, // FIXME }; @@ -121,7 +121,7 @@ public static async ValueTask> // FIXME: combine with verification failure from name hash key Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.Value.AuthorshipVerificationFailure), - IsTrashed = linkDto.State is LinkState.Trashed, + TrashTime = linkDto.TrashTime, }; await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); @@ -175,7 +175,7 @@ public static async Task> ConvertD ParentUid = parentUid, Name = nameResult, NameAuthor = default, - IsTrashed = linkDto.State is LinkState.Trashed, + TrashTime = linkDto.TrashTime, Author = default, MediaType = fileDto.MediaType, ActiveRevision = null, @@ -219,7 +219,7 @@ public static async Task> ConvertD // FIXME: combine with verification failure from name hash key Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.Value.AuthorshipVerificationFailure), - IsTrashed = linkDto.State is LinkState.Trashed, + TrashTime = linkDto.TrashTime, MediaType = fileDto.MediaType, ActiveRevision = new Revision { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index b7557470..38a21141 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -11,28 +11,29 @@ internal static class FolderOperations { public static async IAsyncEnumerable> EnumerateChildrenAsync( ProtonDriveClient client, - NodeUid folderId, + NodeUid folderUid, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var anchorId = default(LinkId?); + var anchorLinkId = default(LinkId?); var mustTryMoreResults = true; - var folderSecrets = await GetSecretsAsync(client, folderId, cancellationToken).ConfigureAwait(false); + var folderSecrets = await GetSecretsAsync(client, folderUid, cancellationToken).ConfigureAwait(false); - var batchLoader = new FolderChildrenBatchLoader(client, folderId.VolumeId, folderSecrets.Key); + var batchLoader = new FolderChildrenBatchLoader(client, folderUid.VolumeId, folderSecrets.Key); while (mustTryMoreResults) { - var response = await client.Api.Folders.GetChildrenAsync(folderId.VolumeId, folderId.LinkId, anchorId, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Folders.GetChildrenAsync(folderUid.VolumeId, folderUid.LinkId, anchorLinkId, cancellationToken) + .ConfigureAwait(false); mustTryMoreResults = response.MoreResultsExist; - anchorId = response.AnchorId; + anchorLinkId = response.AnchorId; foreach (var childLinkId in response.LinkIds) { - var childId = new NodeUid(folderId.VolumeId, childLinkId); + var childUid = new NodeUid(folderUid.VolumeId, childLinkId); - var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(childId, cancellationToken).ConfigureAwait(false); + var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(childUid, cancellationToken).ConfigureAwait(false); if (cachedChildNodeInfo is null) { @@ -54,11 +55,11 @@ public static async IAsyncEnumerable> EnumerateChi } } - public static async ValueTask CreateAsync(ProtonDriveClient client, NodeUid parentId, string name, CancellationToken cancellationToken) + public static async ValueTask CreateAsync(ProtonDriveClient client, NodeUid parentUid, string name, CancellationToken cancellationToken) { - var parentSecrets = await GetSecretsAsync(client, parentId, cancellationToken).ConfigureAwait(false); + var parentSecrets = await GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); - var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, parentId, cancellationToken).ConfigureAwait(false); + var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, parentUid, cancellationToken).ConfigureAwait(false); var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); @@ -82,7 +83,7 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, { Name = encryptedName, NameHashDigest = nameHashDigest, - ParentLinkId = parentId.LinkId, + ParentLinkId = parentUid.LinkId, Passphrase = encryptedKeyPassphrase, PassphraseSignature = keyPassphraseSignature, SignatureEmailAddress = membershipAddress.EmailAddress, @@ -90,9 +91,9 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, HashKey = key.EncryptAndSign(hashKey, key), }; - var response = await client.Api.Folders.CreateFolderAsync(parentId.VolumeId, request, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Folders.CreateFolderAsync(parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); - var folderId = new NodeUid(parentId.VolumeId, response.FolderId.Value); + var folderUid = new NodeUid(parentUid.VolumeId, response.FolderId.Value); var folderSecrets = new FolderSecrets { @@ -102,34 +103,33 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, HashKey = hashKey, }; - await client.Cache.Secrets.SetFolderSecretsAsync(folderId, folderSecrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFolderSecretsAsync(folderUid, folderSecrets, cancellationToken).ConfigureAwait(false); var author = new Author { EmailAddress = membershipAddress.EmailAddress }; var folderNode = new FolderNode { - Uid = folderId, - ParentUid = parentId, + Uid = folderUid, + ParentUid = parentUid, Name = name, - IsTrashed = false, NameAuthor = author, Author = author, }; - await client.Cache.Entities.SetNodeAsync(folderId, folderNode, membershipShareId: null, nameHashDigest, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetNodeAsync(folderUid, folderNode, membershipShareId: null, nameHashDigest, cancellationToken).ConfigureAwait(false); return folderNode; } - public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid folderId, CancellationToken cancellationToken) + public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid folderUid, CancellationToken cancellationToken) { - var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderId, cancellationToken).ConfigureAwait(false); + var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderUid, cancellationToken).ConfigureAwait(false); var folderSecrets = folderSecretsResult?.GetValueOrDefault(); if (folderSecrets is null) { - var nodeProvisionResult = await NodeOperations.GetFreshNodeMetadataAsync(client, folderId, knownShareAndKey: null, cancellationToken) + var nodeProvisionResult = await NodeOperations.GetFreshNodeMetadataAsync(client, folderUid, knownShareAndKey: null, cancellationToken) .ConfigureAwait(false); folderSecrets = nodeProvisionResult.GetFolderSecretsOrThrow(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index 5ee14cc5..b4cd17c5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -15,7 +15,7 @@ public abstract record Node public required string Name { get; init; } - public required bool IsTrashed { get; init; } + public DateTime? TrashTime { get; init; } public required ValResult NameAuthor { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index ce954750..4be8eafc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; using Proton.Cryptography.Pgp; @@ -16,6 +17,8 @@ namespace Proton.Drive.Sdk.Nodes; internal static class NodeOperations { + private const int MaximumBatchCount = 150; + public static async ValueTask GetMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { var shareId = await client.Cache.Entities.TryGetMyFilesShareIdAsync(cancellationToken).ConfigureAwait(false); @@ -270,6 +273,120 @@ public static async ValueTask RenameAsync( await client.Cache.Entities.SetNodeAsync(uid, node with { Name = newName }, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); } + public static async ValueTask>> TrashAsync( + ProtonDriveClient client, + IEnumerable uids, + CancellationToken cancellationToken) + { + var uidsByVolumeId = uids.GroupBy(x => x.VolumeId); + + var results = new ConcurrentDictionary>(); + + var tasks = uidsByVolumeId.Select(async uidGroup => + { + foreach (var batch in uidGroup.Select(x => x.LinkId).Chunk(MaximumBatchCount)) + { + var request = new MultipleLinksNullaryRequest { LinkIds = batch }; + + var aggregateResponse = await client.Api.Links.TrashMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + + foreach (var (linkId, response) in aggregateResponse.Responses) + { + var uid = new NodeUid(uidGroup.Key, linkId); + + var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); + + if (cachedNodeInfo is var (nodeProvisionResult, membershipShareId, nameHashDigest)) + { + // TODO: have the back-end return the trash time so that the cached value be exactly the same + var newNodeProvisionResult = nodeProvisionResult.ConvertRef( + node => node with { TrashTime = DateTime.UtcNow }, + degradedNode => degradedNode with { TrashTime = DateTime.UtcNow }); + + await client.Cache.Entities.SetNodeAsync(uid, newNodeProvisionResult, membershipShareId, nameHashDigest, cancellationToken) + .ConfigureAwait(false); + } + + var result = response.IsSuccess ? Result.Success : response.ErrorMessage; + + results.TryAdd(uid, result); + } + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + return results; + } + + public static async ValueTask>> DeleteAsync( + ProtonDriveClient client, + IEnumerable uids, + CancellationToken cancellationToken) + { + var uidsByVolumeId = uids.GroupBy(x => x.VolumeId); + + var results = new ConcurrentDictionary>(); + + var tasks = uidsByVolumeId.Select(async uidGroup => + { + foreach (var batch in uidGroup.Select(x => x.LinkId).Chunk(MaximumBatchCount)) + { + var request = new MultipleLinksNullaryRequest { LinkIds = batch }; + + var aggregateResponse = await client.Api.Links.DeleteMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + + foreach (var (linkId, response) in aggregateResponse.Responses) + { + var uid = new NodeUid(uidGroup.Key, linkId); + + var result = response.IsSuccess ? Result.Success : response.ErrorMessage; + + results.TryAdd(uid, result); + } + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + return results; + } + + public static async ValueTask>> RestoreAsync( + ProtonDriveClient client, + IEnumerable uids, + CancellationToken cancellationToken) + { + var uidsByVolumeId = uids.GroupBy(x => x.VolumeId); + + var results = new ConcurrentDictionary>(); + + var tasks = uidsByVolumeId.Select(async uidGroup => + { + foreach (var batch in uidGroup.Select(x => x.LinkId).Chunk(MaximumBatchCount)) + { + var request = new MultipleLinksNullaryRequest { LinkIds = batch }; + + var aggregateResponse = await client.Api.Links.RestoreMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + + foreach (var (linkId, response) in aggregateResponse.Responses) + { + var uid = new NodeUid(uidGroup.Key, linkId); + + var result = response.IsSuccess ? Result.Success : response.ErrorMessage; + + results.TryAdd(uid, result); + } + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + // FIXME: remove trash time from cache + + return results; + } + public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) { // FIXME: try to get the information from cache first diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs new file mode 100644 index 00000000..b43be225 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +public static class NodeResultExtensions +{ + public static bool TryGetFileElseFolder( + this RefResult nodeResult, + [NotNullWhen(true)] out RefResult? fileResult, + [NotNullWhen(false)] out RefResult? folderResult) + { + if (!nodeResult.TryGetValueElseError(out var node, out var degradedNode)) + { + if (degradedNode is DegradedFolderNode degradedFolderNode) + { + fileResult = null; + folderResult = degradedFolderNode; + return false; + } + + fileResult = (DegradedFileNode)degradedNode; + folderResult = null; + return true; + } + + if (node is FolderNode folderNode) + { + fileResult = null; + folderResult = folderNode; + return false; + } + + fileResult = (FileNode)node; + folderResult = null; + return true; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs index ed648904..b8a6ff6b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs @@ -23,6 +23,11 @@ public override string ToString() return $"{VolumeId}~{LinkId}"; } + public static bool TryParse(string s, [NotNullWhen(true)] out NodeUid? result) + { + return ICompositeUid.TryParse(s, out result); + } + static bool ICompositeUid.TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out NodeUid? uid) { uid = new NodeUid(new VolumeId(baseUidString), new LinkId(relativeIdString)); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 8feb4255..dc5770ff 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -5,6 +5,7 @@ using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Drive.Sdk.Volumes; using Proton.Sdk; namespace Proton.Drive.Sdk; @@ -103,7 +104,7 @@ public async ValueTask MoveNodesAsync(IEnumerable uids, NodeUid newPare foreach (var uid in uids) { await NodeOperations.MoveSingleAsync(this, uid, newParentFolderUid, newName: null, cancellationToken).ConfigureAwait(false); - } + } } public ValueTask RenameNodeAsync(NodeUid uid, string newName, string? newMediaType, CancellationToken cancellationToken) @@ -111,6 +112,31 @@ public ValueTask RenameNodeAsync(NodeUid uid, string newName, string? newMediaTy return NodeOperations.RenameAsync(this, uid, newName, newMediaType, cancellationToken); } + public ValueTask>> TrashNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.TrashAsync(this, uids, cancellationToken); + } + + public ValueTask>> DeleteNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.DeleteAsync(this, uids, cancellationToken); + } + + public ValueTask>> RestoreNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.RestoreAsync(this, uids, cancellationToken); + } + + public IAsyncEnumerable> EnumerateTrashAsync(CancellationToken cancellationToken) + { + return VolumeOperations.EnumerateTrashAsync(this, cancellationToken); + } + + public ValueTask EmptyTrashAsync(CancellationToken cancellationToken) + { + return VolumeOperations.EmptyTrashAsync(this, cancellationToken); + } + internal async ValueTask GetClientUidAsync(CancellationToken cancellationToken) { var clientUid = await Cache.Entities.TryGetClientUidAsync(cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 18b066d5..15e0369a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -5,6 +5,7 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Api; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Serialization; @@ -44,4 +45,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(MoveSingleLinkRequest))] [JsonSerializable(typeof(MoveMultipleLinksRequest))] [JsonSerializable(typeof(RenameLinkRequest))] +[JsonSerializable(typeof(MultipleLinksNullaryRequest))] +[JsonSerializable(typeof(AggregateApiResponse))] +[JsonSerializable(typeof(VolumeTrashResponse))] internal sealed partial class DriveApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 29447f45..2321fd2b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -1,8 +1,10 @@ -using Proton.Cryptography.Pgp; +using System.Runtime.CompilerServices; +using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; +using Proton.Sdk; using Proton.Sdk.Addresses; namespace Proton.Drive.Sdk.Volumes; @@ -10,8 +12,9 @@ namespace Proton.Drive.Sdk.Volumes; internal static class VolumeOperations { private const string RootFolderName = "root"; + private const int TrashPageSize = 500; - internal static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreateVolumeAsync( + public static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreateVolumeAsync( ProtonDriveClient client, CancellationToken cancellationToken) { @@ -34,7 +37,6 @@ internal static class VolumeOperations Name = RootFolderName, NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, Author = new Author { EmailAddress = defaultAddress.EmailAddress }, - IsTrashed = false, }; // The volume root folder never has siblings and does not need a name hash digest @@ -51,6 +53,63 @@ internal static class VolumeOperations return (volume, share, rootFolder); } + public static async IAsyncEnumerable> EnumerateTrashAsync( + ProtonDriveClient client, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var volumeId = await GetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); + + var page = 0; + var mustTryMoreResults = true; + + while (mustTryMoreResults) + { + var response = await client.Api.Volumes.GetTrashAsync(volumeId, TrashPageSize, page, cancellationToken).ConfigureAwait(false); + + mustTryMoreResults = response.TrashByShare.Sum(x => x.LinkIds.Count) == TrashPageSize; + + foreach (var (shareId, linkIds, _) in response.TrashByShare) + { + var (_, shareKey) = await ShareOperations.GetShareAsync(client, shareId, cancellationToken).ConfigureAwait(false); + + var batchLoader = new VolumeTrashBatchLoader(client, volumeId, shareKey); + + foreach (var linkId in linkIds) + { + var uid = new NodeUid(volumeId, linkId); + + var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); + + if (cachedNodeInfo is null) + { + foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + else + { + yield return cachedNodeInfo.Value.NodeProvisionResult; + } + } + + foreach (var node in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + { + yield return node; + } + } + + ++page; + } + } + + public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var volumeId = await GetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); + + await client.Api.Volumes.EmptyTrashAsync(volumeId, cancellationToken).ConfigureAwait(false); + } + private static VolumeCreationRequest GetCreationRequest( AddressId addressId, PgpPrivateKey addressKey, @@ -102,4 +161,12 @@ private static VolumeCreationRequest GetCreationRequest( FolderHashKey = encryptedHashKey, }; } + + private static async ValueTask GetMainVolumeIdAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + // TODO: optimize this, which is overkill to just get the volume ID + var myFilesFolder = await NodeOperations.GetMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + + return myFilesFolder.Uid.VolumeId; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs new file mode 100644 index 00000000..0178ed11 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -0,0 +1,57 @@ +using System.Runtime.InteropServices; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Volumes; + +internal sealed class VolumeTrashBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey shareKey) + : BatchLoaderBase> +{ + private readonly ProtonDriveClient _client = client; + private readonly VolumeId _volumeId = volumeId; + private readonly PgpPrivateKey _shareKey = shareKey; + + private readonly Dictionary _parentKeys = new(); + + protected override async ValueTask>> LoadBatchAsync( + ReadOnlyMemory ids, + CancellationToken cancellationToken) + { + var response = await _client.Api.Links.GetDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); + + var nodeResults = new List>(ids.Length); + + foreach (var linkDetails in response.Links) + { + PgpPrivateKey parentKey; + + if (linkDetails.Link.ParentId is { } parentId) + { + if (!_parentKeys.TryGetValue(parentId, out parentKey)) + { + var folderSecrets = await FolderOperations.GetSecretsAsync(_client, new NodeUid(_volumeId, parentId), cancellationToken) + .ConfigureAwait(false); + + parentKey = folderSecrets.Key; + + _parentKeys[parentId] = parentKey; + } + } + else + { + parentKey = _shareKey; + } + + var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync(_client, _volumeId, linkDetails, parentKey, cancellationToken) + .ConfigureAwait(false); + + var nodeResult = nodeMetadataResult.ToNodeResult(); + + nodeResults.Add(nodeResult); + } + + return nodeResults; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/AggregateApiResponse.cs b/cs/sdk/src/Proton.Sdk/Api/AggregateApiResponse.cs new file mode 100644 index 00000000..b3b75b8d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/AggregateApiResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api; + +internal sealed class AggregateApiResponse : ApiResponse +{ + public required IReadOnlyList Responses { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs b/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs index 34938c86..77b65507 100644 --- a/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs @@ -6,7 +6,15 @@ namespace Proton.Sdk; public static class RefResultExtensions { [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T? GetValueOrDefault(this RefResult result, T? defaultValue = null) + public static T? GetValueOrDefault(this RefResult result) + where T : class? + where TError : class? + { + return result.TryGetValueElseError(out var value, out _) ? value : null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T GetValueOrDefault(this RefResult result, T defaultValue) where T : class? where TError : class? { From b618640c413b9e4f7e70f3e9505d7537127d2913 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 May 2025 13:18:02 +0200 Subject: [PATCH 105/791] add timeout to API service --- js/sdk/src/interface/httpClient.ts | 6 +++ .../internal/apiService/apiService.test.ts | 38 ++++++++++++++++--- js/sdk/src/internal/apiService/apiService.ts | 12 ++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts index 788d093a..d433ad12 100644 --- a/js/sdk/src/interface/httpClient.ts +++ b/js/sdk/src/interface/httpClient.ts @@ -16,6 +16,12 @@ type ProtonDriveHTTPClientBaseOptions = { url: string, method: string, headers: Headers, + /** + * The timeout in milliseconds. + * + * When timeout is reached, the request will be aborted with TimeoutError. + */ + timeoutMs: number, signal?: AbortSignal, } diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index ac742b23..4f83a87a 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -27,7 +27,7 @@ describe("DriveAPIService", () => { } httpClient = { fetchJson: jest.fn(() => Promise.resolve(generateOkResponse())), - fetchBlob: jest.fn(() => Promise.resolve(generateOkResponse())), + fetchBlob: jest.fn(() => Promise.resolve(new Response(new Uint8Array([1, 2, 3])))), }; api = new DriveAPIService(getMockTelemetry(), sdkEvents, httpClient, 'http://drive.proton.me', 'en'); }); @@ -43,25 +43,26 @@ describe("DriveAPIService", () => { it("GET request", async () => { const result = await api.get('test'); expect(result).toEqual({ Code: ErrorCode.OK }); - await expectToBeCalledWith('GET'); + await expectFetchJsonToBeCalledWith('GET'); }); it("POST request", async () => { const result = await api.post('test', { data: 'test' }); expect(result).toEqual({ Code: ErrorCode.OK }); - await expectToBeCalledWith('POST', { data: 'test' }); + await expectFetchJsonToBeCalledWith('POST', { data: 'test' }); }); it("PUT request", async () => { const result = await api.put('test', { data: 'test' }); expect(result).toEqual({ Code: ErrorCode.OK }); - await expectToBeCalledWith('PUT', { data: 'test' }); + await expectFetchJsonToBeCalledWith('PUT', { data: 'test' }); }); - async function expectToBeCalledWith(method: string, data?: object) { + async function expectFetchJsonToBeCalledWith(method: string, data?: object) { // @ts-expect-error: Fetch is mock. const request = httpClient.fetchJson.mock.calls[0][0]; expect(request.method).toEqual(method); + expect(request.timeoutMs).toEqual(30000); expect(Array.from(request.headers.entries())).toEqual(Array.from(new Headers({ "Accept": "application/vnd.protonmail.v1+json", "Content-Type": "application/json", @@ -71,6 +72,33 @@ describe("DriveAPIService", () => { expect(await request.json).toEqual(data); expectSDKEvents(); } + + it("storage GET request", async () => { + const stream = await api.getBlockStream('test', 'token'); + const result = await Array.fromAsync(stream); + expect(result).toEqual([new Uint8Array([1, 2, 3])]); + await expectFetchBlobToBeCalledWith('GET'); + }); + + it("storage POST request", async () => { + const data = new Blob(); + await api.postBlockStream('test', 'token', data); + await expectFetchBlobToBeCalledWith('POST', data); + }); + + async function expectFetchBlobToBeCalledWith(method: string, data?: object) { + // @ts-expect-error: Fetch is mock. + const request = httpClient.fetchBlob.mock.calls[0][0]; + expect(request.method).toEqual(method); + expect(request.timeoutMs).toEqual(90000); + expect(Array.from(request.headers.entries())).toEqual(Array.from(new Headers({ + "pm-storage-token": 'token', + "Language": 'en', + "x-pm-drive-sdk-version": `js@${process.env.npm_package_version}`, + }).entries())); + expect(request.body).toEqual(data); + expectSDKEvents(); + } }); describe("should throw", () => { diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 43d9827f..ad5cbb4e 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -8,6 +8,16 @@ import { SDKEvents } from '../sdkEvents'; import { HTTPErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; import { apiErrorFactory } from './errors'; +/** + * The default timeout in milliseconds for all API requests (metadata). + */ +const DEFAULT_TIMEOUT_MS = 30000; + +/** + * The default timeout in milliseconds for all storage requests (file content). + */ +const DEFAULT_STORAGE_TIMEOUT_MS = 90000; + /** * How many subsequent 429 errors are allowed before we stop further requests. */ @@ -127,6 +137,7 @@ export class DriveAPIService { "x-pm-drive-sdk-version": `js@${VERSION}`, }), json: data || undefined, + timeoutMs: DEFAULT_TIMEOUT_MS, signal, } @@ -180,6 +191,7 @@ export class DriveAPIService { }), body, onProgress, + timeoutMs: DEFAULT_STORAGE_TIMEOUT_MS, signal, }; From 1b2975fd9094f08b0ac3f8e83a0bd39231f6c239 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 May 2025 12:52:32 +0200 Subject: [PATCH 106/791] updated comment regarding content hash being only for photo nodes --- js/sdk/src/internal/nodes/nodesManagement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 34cbf5aa..332a77d7 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -147,7 +147,7 @@ export class NodesManagement { encryptedName: encryptedCrypto.encryptedName, nameSignatureEmail: encryptedCrypto.nameSignatureEmail, hash: encryptedCrypto.hash, - // FIXME: content hash + // TODO: When moving photos, we need to pass content hash. } ); const newNode: DecryptedNode = { From 199901c11ce43cc6c166a0a325507a1dc0863aeb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 28 May 2025 08:53:16 +0200 Subject: [PATCH 107/791] handle missing public address --- js/sdk/src/interface/account.ts | 20 +++++++++++++++++ js/sdk/src/internal/errors.test.ts | 21 ++++++++++++++++++ js/sdk/src/internal/errors.ts | 22 ++++++++++++------- .../src/internal/nodes/cryptoService.test.ts | 2 +- js/sdk/src/internal/nodes/cryptoService.ts | 2 +- 5 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 js/sdk/src/internal/errors.test.ts diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index 401ab8c6..4dc8a623 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -1,9 +1,29 @@ import { PrivateKey, PublicKey } from '../crypto'; export interface ProtonDriveAccount { + /** + * Get own primary address. + * + * @throws Error If there is no primary address. + */ getOwnPrimaryAddress(): Promise, + /** + * Get own address by email or addressId. + * + * @throws Error If there is no address with given email or addressId. + */ getOwnAddress(emailOrAddressId: string): Promise, + /** + * Returns whether given email can be used to share files with Proton Drive. + */ hasProtonAccount(email: string): Promise, + /** + * Get public keys for given email. + * + * Does not throw if there is no public key for given email, but returns empty array. + * + * @throws Error Only if there is an error while fetching keys. + */ getPublicKeys(email: string): Promise, } diff --git a/js/sdk/src/internal/errors.test.ts b/js/sdk/src/internal/errors.test.ts new file mode 100644 index 00000000..7c2c8d9a --- /dev/null +++ b/js/sdk/src/internal/errors.test.ts @@ -0,0 +1,21 @@ +import { VERIFICATION_STATUS } from '../crypto'; +import { getVerificationMessage } from './errors'; + +describe('getVerificationMessage', () => { + const testCases: [VERIFICATION_STATUS, string | undefined, boolean, string][] = [ + [VERIFICATION_STATUS.NOT_SIGNED, 'type', false, 'Missing signature for type'], + [VERIFICATION_STATUS.NOT_SIGNED, undefined, false, 'Missing signature'], + [VERIFICATION_STATUS.NOT_SIGNED, 'type', true, 'Missing signature for type'], + [VERIFICATION_STATUS.NOT_SIGNED, undefined, true, 'Missing signature'], + [VERIFICATION_STATUS.SIGNED_AND_INVALID, 'type', false, 'Signature verification for type failed'], + [VERIFICATION_STATUS.SIGNED_AND_INVALID, undefined, false, 'Signature verification failed'], + [VERIFICATION_STATUS.SIGNED_AND_INVALID, 'type', true, 'Verification keys for type are not available'], + [VERIFICATION_STATUS.SIGNED_AND_INVALID, undefined, true, 'Verification keys are not available'], + ]; + + for (const [status, type, notAvailable, expected] of testCases) { + it(`returns correct message for status ${status} with type ${type} and notAvailable ${notAvailable}`, () => { + expect(getVerificationMessage(status, type, notAvailable)).toBe(expected); + }); + } +}); diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index edf0d769..5bb99ec0 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -9,14 +9,20 @@ export function getErrorMessage(error: unknown): string { /** * @param signatureType - Must be translated before calling this function. */ -export function getVerificationMessage(verified: VERIFICATION_STATUS, signatureType?: string): string { - if (signatureType) { - return verified === VERIFICATION_STATUS.SIGNED_AND_INVALID - ? c('Error').t`Signature verification for ${signatureType} failed` - : c('Error').t`Missing signature for ${signatureType}`; +export function getVerificationMessage(verified: VERIFICATION_STATUS, signatureType?: string, notAvailableVerificationKeys = false): string { + if (verified === VERIFICATION_STATUS.NOT_SIGNED) { + return signatureType + ? c('Error').t`Missing signature for ${signatureType}` + : c('Error').t`Missing signature`; } - return verified === VERIFICATION_STATUS.SIGNED_AND_INVALID - ? c('Error').t`Signature verification failed` - : c('Error').t`Missing signature`; + if (notAvailableVerificationKeys) { + return signatureType + ? c('Error').t`Verification keys for ${signatureType} are not available` + : c('Error').t`Verification keys are not available`; + } + + return signatureType + ? c('Error').t`Signature verification for ${signatureType} failed` + : c('Error').t`Signature verification failed`; } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 108b0aa7..2cfcf04e 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -46,7 +46,7 @@ describe("nodesCryptoService", () => { }; // @ts-expect-error No need to implement all methods for mocking account = { - getPublicKeys: jest.fn(async () => []), + getPublicKeys: jest.fn(async () => ["public key"]), }; // @ts-expect-error No need to implement all methods for mocking sharesService = { diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index c4f900ca..a49e6fe5 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -535,6 +535,6 @@ function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATU return resultError({ claimedAuthor: claimedAuthor, - error: getVerificationMessage(verified, signatureType), + error: getVerificationMessage(verified, signatureType, notAvailableVerificationKeys), }); } From 38f680f5a8eeec12badf8b879dec482a8f545f56 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Jun 2025 04:40:03 +0000 Subject: [PATCH 108/791] fix getting address key --- js/sdk/src/internal/devices/cryptoService.ts | 4 +- js/sdk/src/internal/devices/interface.ts | 2 +- js/sdk/src/internal/devices/manager.test.ts | 2 +- js/sdk/src/internal/devices/manager.ts | 2 +- .../src/internal/nodes/cryptoService.test.ts | 72 ++++++++++--------- js/sdk/src/internal/nodes/cryptoService.ts | 33 +++++---- js/sdk/src/internal/nodes/interface.ts | 10 ++- js/sdk/src/internal/nodes/nodesAccess.ts | 19 ++++- .../internal/nodes/nodesManagement.test.ts | 15 +++- js/sdk/src/internal/nodes/nodesManagement.ts | 13 ++-- js/sdk/src/internal/shares/cryptoService.ts | 4 +- js/sdk/src/internal/shares/manager.test.ts | 45 ++++++++++-- js/sdk/src/internal/shares/manager.ts | 36 ++++++++-- js/sdk/src/internal/sharing/cryptoService.ts | 4 +- js/sdk/src/internal/sharing/interface.ts | 7 +- .../sharing/sharingManagement.test.ts | 8 +-- .../src/internal/sharing/sharingManagement.ts | 24 ++----- js/sdk/src/internal/upload/cryptoService.ts | 11 ++- js/sdk/src/internal/upload/index.ts | 4 +- js/sdk/src/internal/upload/interface.ts | 7 +- js/sdk/src/internal/upload/manager.test.ts | 16 ++--- js/sdk/src/internal/upload/manager.ts | 8 +-- js/sdk/src/internal/upload/telemetry.test.ts | 1 - 23 files changed, 219 insertions(+), 128 deletions(-) diff --git a/js/sdk/src/internal/devices/cryptoService.ts b/js/sdk/src/internal/devices/cryptoService.ts index d1617fc4..0249dfd2 100644 --- a/js/sdk/src/internal/devices/cryptoService.ts +++ b/js/sdk/src/internal/devices/cryptoService.ts @@ -13,7 +13,7 @@ export class DevicesCryptoService { this.sharesService = sharesService; } - async createDevice(volumeId: string, deviceName: string): Promise<{ + async createDevice(deviceName: string): Promise<{ address: { addressId: string, addressKeyId: string, @@ -33,7 +33,7 @@ export class DevicesCryptoService { armoredHashKey: string, } }> { - const address = await this.sharesService.getVolumeEmailKey(volumeId); + const address = await this.sharesService.getMyFilesShareMemberEmailKey(); const addressKey = address.addressKey; const shareKey = await this.driveCrypto.generateKey([addressKey], addressKey); diff --git a/js/sdk/src/internal/devices/interface.ts b/js/sdk/src/internal/devices/interface.ts index 457826b5..e504749d 100644 --- a/js/sdk/src/internal/devices/interface.ts +++ b/js/sdk/src/internal/devices/interface.ts @@ -13,7 +13,7 @@ export type DeviceMetadata = { export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string }>; - getVolumeEmailKey(volumeId: string): Promise<{ addressId: string, email: string, addressKey: PrivateKey, addressKeyId: string }>, + getMyFilesShareMemberEmailKey(): Promise<{ addressId: string, email: string, addressKey: PrivateKey, addressKeyId: string }>, } export interface NodesService { diff --git a/js/sdk/src/internal/devices/manager.test.ts b/js/sdk/src/internal/devices/manager.test.ts index e3afe891..74226ca7 100644 --- a/js/sdk/src/internal/devices/manager.test.ts +++ b/js/sdk/src/internal/devices/manager.test.ts @@ -64,7 +64,7 @@ describe('DevicesManager', () => { const result = await manager.createDevice(name, deviceType); expect(sharesService.getMyFilesIDs).toHaveBeenCalled(); - expect(cryptoService.createDevice).toHaveBeenCalledWith(volumeId, name); + expect(cryptoService.createDevice).toHaveBeenCalledWith(name); expect(apiService.createDevice).toHaveBeenCalledWith( { volumeId, type: deviceType }, { diff --git a/js/sdk/src/internal/devices/manager.ts b/js/sdk/src/internal/devices/manager.ts index 3fa23bda..3ebb5d61 100644 --- a/js/sdk/src/internal/devices/manager.ts +++ b/js/sdk/src/internal/devices/manager.ts @@ -48,7 +48,7 @@ export class DevicesManager { async createDevice(name: string, deviceType: DeviceType): Promise { const { volumeId } = await this.sharesService.getMyFilesIDs(); - const { address, shareKey, node } = await this.cryptoService.createDevice(volumeId, name); + const { address, shareKey, node } = await this.cryptoService.createDevice(name); const device = await this.apiService.createDevice( { diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 2cfcf04e..6220bda3 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -50,7 +50,7 @@ describe("nodesCryptoService", () => { }; // @ts-expect-error No need to implement all methods for mocking sharesService = { - getVolumeEmailKey: jest.fn(async () => ({ + getMyFilesShareMemberEmailKey: jest.fn(async () => ({ email: "email", addressKey: "key" as unknown as PrivateKey, })), @@ -354,13 +354,15 @@ describe("nodesCryptoService", () => { keyAuthor: { ok: true, value: "signatureEmail" }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, folder: undefined, - activeRevision: { ok: true, value: { - uid: "revisionUid", - state: RevisionState.Active, - creationTime: undefined, - extendedAttributes: "{}", - contentAuthor: { ok: true, value: "revisionSignatureEmail" }, - } }, + activeRevision: { + ok: true, value: { + uid: "revisionUid", + state: RevisionState.Active, + creationTime: undefined, + extendedAttributes: "{}", + contentAuthor: { ok: true, value: "revisionSignatureEmail" }, + } + }, errors: undefined, ...expectedNode, }, @@ -402,14 +404,16 @@ describe("nodesCryptoService", () => { verifyResult(result, { keyAuthor: { ok: true, value: "signatureEmail" }, nameAuthor: { ok: true, value: "signatureEmail" }, - activeRevision: { ok: true, value: { - uid: "revisionUid", - state: RevisionState.Active, - // @ts-expect-error Ignore mocked data. - creationTime: undefined, - extendedAttributes: "{}", - contentAuthor: { ok: true, value: "signatureEmail" }, - } }, + activeRevision: { + ok: true, value: { + uid: "revisionUid", + state: RevisionState.Active, + // @ts-expect-error Ignore mocked data. + creationTime: undefined, + extendedAttributes: "{}", + contentAuthor: { ok: true, value: "signatureEmail" }, + } + }, }); expect(account.getPublicKeys).toHaveBeenCalledTimes(2); // node + revision @@ -469,14 +473,16 @@ describe("nodesCryptoService", () => { const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - activeRevision: { ok: true, value: { - uid: "revisionUid", - extendedAttributes: "{}", - state: RevisionState.Active, - // @ts-expect-error Ignore mocked data. - creationTime: undefined, - contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Signature verification for attributes failed" } }, - } }, + activeRevision: { + ok: true, value: { + uid: "revisionUid", + extendedAttributes: "{}", + state: RevisionState.Active, + // @ts-expect-error Ignore mocked data. + creationTime: undefined, + contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Signature verification for attributes failed" } }, + } + }, }); verifyLogEventVerificationError({ field: 'nodeExtendedAttributes', @@ -571,7 +577,7 @@ describe("nodesCryptoService", () => { await expect(result).rejects.toThrow("Failed to load keys"); }); }); - + describe("anonymous node", () => { const encryptedNode = { uid: "volumeId~nodeId", @@ -610,13 +616,15 @@ describe("nodesCryptoService", () => { keyAuthor: { ok: true, value: "signatureEmail" }, nameAuthor: { ok: true, value: "nameSignatureEmail" }, folder: undefined, - activeRevision: { ok: true, value: { - uid: "revisionUid", - state: RevisionState.Active, - creationTime: undefined, - extendedAttributes: "{}", - contentAuthor: { ok: true, value: "revisionSignatureEmail" }, - } }, + activeRevision: { + ok: true, value: { + uid: "revisionUid", + state: RevisionState.Active, + creationTime: undefined, + extendedAttributes: "{}", + contentAuthor: { ok: true, value: "revisionSignatureEmail" }, + } + }, errors: undefined, ...expectedNode, }, diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index a49e6fe5..112189f8 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -299,7 +299,7 @@ export class NodesCryptoService { extendedAttributes, author: contentAuthor, } = await this.decryptExtendedAttributes( - {uid: nodeUid, creationTime: encryptedRevision.creationTime}, + { uid: nodeUid, creationTime: encryptedRevision.creationTime }, encryptedRevision.armoredExtendedAttributes, nodeKey, verificationKeys, @@ -318,7 +318,7 @@ export class NodesCryptoService { } private async decryptExtendedAttributes( - node: {uid: string, creationTime: Date}, + node: { uid: string, creationTime: Date }, encryptedExtendedAttributes: string | undefined, nodeKey: PrivateKey, addressKeys: PublicKey[], @@ -346,16 +346,15 @@ export class NodesCryptoService { } async createFolder( - parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, + address: { email: string, addressKey: PrivateKey }, name: string, extendedAttributes?: string, ): Promise<{ encryptedCrypto: Required & { encryptedName: string, hash: string }, keys: DecryptedNodeKeys, }> { - const { volumeId } = splitNodeUid(parentNode.uid); - const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); + const { email, addressKey } = address; const [ nodeKeys, { armoredNodeName }, @@ -396,13 +395,17 @@ export class NodesCryptoService { }; } - async encryptNewName(node: DecryptedNode, nodeNameSessionKey: SessionKey, parentHashKey: Uint8Array | undefined, newName: string): Promise<{ + async encryptNewName( + nodeNameSessionKey: SessionKey, + address: { email: string, addressKey: PrivateKey }, + parentHashKey: Uint8Array | undefined, + newName: string, + ): Promise<{ signatureEmail: string, armoredNodeName: string, hash?: string, }> { - const { volumeId } = splitNodeUid(node.uid); - const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); + const { email, addressKey } = address; const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, nodeNameSessionKey, undefined, addressKey); const hash = parentHashKey ? await this.driveCrypto.generateLookupHash(newName, parentHashKey) @@ -414,7 +417,12 @@ export class NodesCryptoService { }; }; - async moveNode(node: DecryptedNode, keys: { passphrase: string, passphraseSessionKey: SessionKey }, parentNode: DecryptedNode, parentKeys: { key: PrivateKey, hashKey: Uint8Array }): Promise<{ + async moveNode( + node: DecryptedNode, + keys: { passphrase: string, passphraseSessionKey: SessionKey }, + parentKeys: { key: PrivateKey, hashKey: Uint8Array }, + address: { email: string, addressKey: PrivateKey }, + ): Promise<{ encryptedName: string, hash: string, armoredNodePassphrase: string, @@ -429,8 +437,7 @@ export class NodesCryptoService { throw new ValidationError('Cannot move item without a valid name, please rename the item first'); } - const { volumeId } = splitNodeUid(parentNode.uid); - const { email, addressKey } = await this.shareService.getVolumeEmailKey(volumeId); + const { email, addressKey } = address; const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, undefined, parentKeys.key, addressKey); const hash = await this.driveCrypto.generateLookupHash(node.name.value, parentKeys.hashKey); const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], addressKey); @@ -474,7 +481,7 @@ export class NodesCryptoService { let addressMatchingDefaultShare, context; try { const { volumeId } = splitNodeUid(node.uid); - const { email } = await this.shareService.getVolumeEmailKey(volumeId); + const { email } = await this.shareService.getMyFilesShareMemberEmailKey(); addressMatchingDefaultShare = claimedAuthor ? claimedAuthor === email : undefined; context = await this.shareService.getVolumeMetricContext(volumeId); } catch (error: unknown) { @@ -536,5 +543,5 @@ function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATU return resultError({ claimedAuthor: claimedAuthor, error: getVerificationMessage(verified, signatureType, notAvailableVerificationKeys), - }); + }); } diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 369ea7de..e1bcbac7 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -139,6 +139,14 @@ export interface DecryptedRevision extends Revision { export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string, rootNodeId: string }>, getSharePrivateKey(shareId: string): Promise, - getVolumeEmailKey(volumeId: string): Promise<{ email: string, addressKey: PrivateKey }>, + getMyFilesShareMemberEmailKey(): Promise<{ + email: string, + }>, + getContextShareMemberEmailKey(shareId: string): Promise<{ + email: string, + addressId: string, + addressKey: PrivateKey, + addressKeyId: string, + }>, getVolumeMetricContext(volumeId: string): Promise, } diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index a6ab0cce..c9b92c88 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -48,7 +48,7 @@ export class NodesAccess { let cachedNode; try { cachedNode = await this.cache.getNode(nodeUid); - } catch {} + } catch { } if (cachedNode && !cachedNode.isStale) { return cachedNode; @@ -84,7 +84,7 @@ export class NodesAccess { let node; try { node = await this.cache.getNode(nodeUid); - } catch {} + } catch { } if (node && !node.isStale) { yield node; @@ -105,7 +105,7 @@ export class NodesAccess { let node; try { node = await this.cache.getNode(nodeUid); - } catch {} + } catch { } if (node && !node.isStale) { yield node; @@ -320,6 +320,19 @@ export class NodesAccess { }; } + async getRootNodeEmailKey(nodeUid: string): Promise<{ + email: string, + addressId: string, + addressKey: PrivateKey, + addressKeyId: string, + }> { + const rootNode = await this.getRootNode(nodeUid); + if (!rootNode.shareId) { + throw new Error(`Node "${nodeUid}" is not accessible - missing root shareId`); + } + return this.shareService.getContextShareMemberEmailKey(rootNode.shareId); + } + async getNodeUrl(nodeUid: string): Promise { const node = await this.getNode(nodeUid); if (isProtonDocument(node.mediaType) || isProtonSheet(node.mediaType)) { diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index d92ad2fa..4c89dd00 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -75,6 +75,8 @@ describe('NodesManagement', () => { getNodeKeys: jest.fn().mockImplementation((uid) => ({ key: `${uid}-key`, hashKey: `${uid}-hashKey`, + passphrase: `${uid}-passphrase`, + passphraseSessionKey: `${uid}-passphraseSessionKey`, })), getParentKeys: jest.fn().mockImplementation(({ uid }) => ({ key: `${nodes[uid].parentUid}-key`, @@ -84,6 +86,7 @@ describe('NodesManagement', () => { getNodePrivateAndSessionKeys: jest.fn().mockResolvedValue({ nameSessionKey: 'nameSessionKey', }), + getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: "root-email", addressKey: "root-key" }), } // @ts-expect-error No need to implement all methods for mocking nodesEvents = { @@ -97,15 +100,17 @@ describe('NodesManagement', () => { it('renameNode manages rename and updates cache', async () => { const newNode = await management.renameNode('nodeUid', 'new name'); + expect(newNode).toEqual({ ...nodes.nodeUid, name: { ok: true, value: 'new name' }, nameAuthor: { ok: true, value: 'newSignatureEmail' }, hash: 'newHash', }); + expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('nodeUid'); expect(cryptoService.encryptNewName).toHaveBeenCalledWith( - nodes.nodeUid, 'nameSessionKey', + { email: "root-email", addressKey: "root-key" }, 'parentUid-hashKey', 'new name', ); @@ -129,6 +134,7 @@ describe('NodesManagement', () => { cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); const newNode = await management.moveNode('nodeUid', 'newParentNodeUid'); + expect(newNode).toEqual({ ...nodes.nodeUid, parentUid: 'newParentNodeUid', @@ -136,6 +142,13 @@ describe('NodesManagement', () => { keyAuthor: { ok: true, value: 'movedSignatureEmail' }, nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, }); + expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid'); + expect(cryptoService.moveNode).toHaveBeenCalledWith( + nodes.nodeUid, + expect.objectContaining({ passphrase: 'nodeUid-passphrase', passphraseSessionKey: 'nodeUid-passphraseSessionKey' }), + expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), + { email: "root-email", addressKey: "root-key" }, + ); expect(apiService.moveNode).toHaveBeenCalledWith( 'nodeUid', { diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 332a77d7..7197dbbe 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -42,6 +42,7 @@ export class NodesManagement { const node = await this.nodesAccess.getNode(nodeUid); const { nameSessionKey: nodeNameSessionKey } = await this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid); const parentKeys = await this.nodesAccess.getParentKeys(node); + const address = await this.nodesAccess.getRootNodeEmailKey(nodeUid); if (!options.allowRenameRootNode && (!node.hash || !parentKeys.hashKey)) { throw new ValidationError(c('Error').t`Renaming root item is not allowed`) @@ -51,7 +52,7 @@ export class NodesManagement { signatureEmail, armoredNodeName, hash, - } = await this.cryptoService.encryptNewName(node, nodeNameSessionKey, parentKeys.hashKey, newName); + } = await this.cryptoService.encryptNewName(nodeNameSessionKey, address, parentKeys.hashKey, newName); // Because hash is optional, lets ensure we have it unless explicitely // allowed to rename root node. @@ -103,9 +104,9 @@ export class NodesManagement { } async moveNode(nodeUid: string, newParentUid: string): Promise { - const [node, newParentNode] = await Promise.all([ + const [node, address] = await Promise.all([ this.nodesAccess.getNode(nodeUid), - this.nodesAccess.getNode(newParentUid), + this.nodesAccess.getRootNodeEmailKey(newParentUid), ]); const [keys, newParentKeys] = await Promise.all([ this.nodesAccess.getNodeKeys(nodeUid), @@ -122,8 +123,8 @@ export class NodesManagement { const encryptedCrypto = await this.cryptoService.moveNode( node, keys, - newParentNode, { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, + address, ); // Node could be uploaded or renamed by anonymous user and thus have @@ -215,17 +216,17 @@ export class NodesManagement { async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { validateNodeName(folderName); - const parentNode = await this.nodesAccess.getNode(parentNodeUid); const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); if (!parentKeys.hashKey) { throw new ValidationError(c('Error').t`Creating folders in non-folders is not allowed`); } + const address = await this.nodesAccess.getRootNodeEmailKey(parentNodeUid); const extendedAttributes = generateFolderExtendedAttributes(modificationTime); const { encryptedCrypto, keys } = await this.cryptoService.createFolder( - parentNode, { key: parentKeys.key, hashKey: parentKeys.hashKey }, + address, folderName, extendedAttributes, ); diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index bf939515..eff8db6b 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -116,14 +116,12 @@ export class SharesCryptoService { } const fromBefore2024 = share.creationTime ? share.creationTime < new Date('2024-01-01') : undefined; - const addressMatchingDefaultShare = undefined; // FIXME: check if claimed author matches default share - this.logger.error(`Failed to verify share ${share.shareId} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); + this.logger.error(`Failed to verify share ${share.shareId} (from before 2024: ${fromBefore2024})`); this.telemetry.logEvent({ eventName: 'verificationError', context: shareTypeToMetricContext(share.type), field: 'shareKey', - addressMatchingDefaultShare, fromBefore2024, }); this.reportedVerificationErrors.add(share.shareId); diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts index 38928fae..64925dd5 100644 --- a/js/sdk/src/internal/shares/manager.test.ts +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -5,6 +5,7 @@ import { SharesAPIService } from "./apiService"; import { SharesCache } from "./cache"; import { SharesCryptoCache } from "./cryptoCache"; import { SharesCryptoService } from "./cryptoService"; +import { VolumeShareNodeIDs } from "./interface"; import { SharesManager } from "./manager"; describe("SharesManager", () => { @@ -65,14 +66,14 @@ describe("SharesManager", () => { key: "privateKey", sessionKey: "sessionKey", }; - + apiService.getMyFiles = jest.fn().mockResolvedValue(encryptedShare); cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ share: myFilesShare, key }); - + // Calling twice to check if it loads only once. await manager.getMyFilesIDs(); const result = await manager.getMyFilesIDs(); - + expect(result).toStrictEqual(myFilesShare); expect(apiService.getMyFiles).toHaveBeenCalledTimes(1); expect(cryptoService.decryptRootShare).toHaveBeenCalledTimes(1); @@ -137,12 +138,13 @@ describe("SharesManager", () => { }); }); - describe("getVolumeEmailKey", () => { + describe("getMyFilesShareMemberEmailKey", () => { it("should return cached volume email key", async () => { + jest.spyOn(manager, 'getMyFilesIDs').mockResolvedValue({ volumeId: "volumeId" } as VolumeShareNodeIDs); cache.getVolume = jest.fn().mockResolvedValue({ addressId: "addressId" }); account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); - const result = await manager.getVolumeEmailKey("volumeId"); + const result = await manager.getMyFilesShareMemberEmailKey(); expect(result).toEqual({ addressId: "addressId", @@ -152,6 +154,7 @@ describe("SharesManager", () => { }); it("should load volume email key if not in cache", async () => { + jest.spyOn(manager, 'getMyFilesIDs').mockResolvedValue({ volumeId: "volumeId" } as VolumeShareNodeIDs); const share = { volumeId: "volumeId", shareId: "shareId", @@ -164,7 +167,7 @@ describe("SharesManager", () => { apiService.getRootShare = jest.fn().mockResolvedValue(share); account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); - const result = await manager.getVolumeEmailKey("volumeId"); + const result = await manager.getMyFilesShareMemberEmailKey(); expect(result).toEqual({ addressId: "addressId", @@ -174,4 +177,34 @@ describe("SharesManager", () => { expect(cache.setVolume).toHaveBeenCalledWith(share); }); }); + + describe("getContextShareMemberEmailKey", () => { + it("should load share email key only once", async () => { + const share = { + volumeId: "volumeId", + shareId: "shareId", + rootNodeId: "rootNodeId", + creatorEmail: "creatorEmail", + addressId: "addressId", + } + apiService.getRootShare = jest.fn().mockResolvedValue(share); + account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); + + const result = await manager.getContextShareMemberEmailKey("shareId"); + + expect(result).toEqual({ + addressId: "addressId", + email: "email", + addressKey: "addressKey", + }); + expect(apiService.getRootShare).toHaveBeenCalledTimes(1); + expect(account.getOwnAddress).toHaveBeenCalledTimes(1); + + const result2 = await manager.getContextShareMemberEmailKey("shareId"); + + expect(result2).toEqual(result); + expect(apiService.getRootShare).toHaveBeenCalledTimes(1); + expect(account.getOwnAddress).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 79b57ea8..8e5f0c9a 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -5,7 +5,7 @@ import { SharesAPIService } from "./apiService"; import { SharesCache } from "./cache"; import { SharesCryptoCache } from "./cryptoCache"; import { SharesCryptoService } from "./cryptoService"; -import { VolumeShareNodeIDs, EncryptedShare } from "./interface"; +import { VolumeShareNodeIDs, EncryptedShare, EncryptedRootShare } from "./interface"; /** * Provides high-level actions for managing shares. @@ -23,6 +23,8 @@ export class SharesManager { // them from the API, and not from the this.cache. private myFilesIds?: VolumeShareNodeIDs; + private rootShares: Map = new Map(); + constructor( private logger: Logger, private apiService: SharesAPIService, @@ -124,7 +126,7 @@ export class SharesManager { try { const { key } = await this.cryptoCache.getShareKey(shareId); return key; - } catch {} + } catch { } const encryptedShare = await this.apiService.getRootShare(shareId); const { key } = await this.cryptoService.decryptRootShare(encryptedShare); @@ -132,12 +134,14 @@ export class SharesManager { return key.key; } - async getVolumeEmailKey(volumeId: string): Promise<{ + async getMyFilesShareMemberEmailKey(): Promise<{ email: string, addressId: string, addressKey: PrivateKey, addressKeyId: string, }> { + const { volumeId } = await this.getMyFilesIDs(); + try { const { addressId } = await this.cache.getVolume(volumeId); const address = await this.account.getOwnAddress(addressId); @@ -147,9 +151,7 @@ export class SharesManager { addressKey: address.keys[address.primaryKeyIndex].key, addressKeyId: address.keys[address.primaryKeyIndex].id, }; - } catch {} - - this.logger.debug(`Volume key for ${volumeId} is not cached`); + } catch { } const { shareId } = await this.apiService.getVolume(volumeId); const share = await this.apiService.getRootShare(shareId); @@ -171,6 +173,28 @@ export class SharesManager { }; } + async getContextShareMemberEmailKey(shareId: string): Promise<{ + email: string, + addressId: string, + addressKey: PrivateKey, + addressKeyId: string, + }> { + let encryptedShare = this.rootShares.get(shareId); + if (!encryptedShare) { + encryptedShare = await this.apiService.getRootShare(shareId); + this.rootShares.set(shareId, encryptedShare); + } + + const address = await this.account.getOwnAddress(encryptedShare.addressId); + + return { + email: address.email, + addressId: encryptedShare.addressId, + addressKey: address.keys[address.primaryKeyIndex].key, + addressKeyId: address.keys[address.primaryKeyIndex].id, + }; + } + async getVolumeMetricContext(volumeId: string): Promise { const { volumeId: myVolumeId } = await this.getMyFilesIDs(); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index f1023aea..411aba91 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -306,8 +306,8 @@ export class SharingCryptoService { } }; - async decryptPublicLink(shareAddressId: string, encryptedPublicLink: EncryptedPublicLink): Promise { - const address = await this.account.getOwnAddress(shareAddressId); + async decryptPublicLink(encryptedPublicLink: EncryptedPublicLink): Promise { + const address = await this.account.getOwnAddress(encryptedPublicLink.creatorEmail); const addressKeys = address.keys.map(({ key }) => key); const { password, customPassword } = await this.decryptShareUrlPassword( diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 5c3cf1d5..dc54d306 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -127,7 +127,6 @@ export interface EncryptedPublicLink { */ export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string }>, - getVolumeEmailKey(volumeId: string): Promise<{ addressId: string, email: string, addressKey: PrivateKey }>, loadEncryptedShare(shareId: string): Promise, } @@ -142,6 +141,12 @@ export interface NodesService { passphraseSessionKey: SessionKey, nameSessionKey: SessionKey, }>, + getRootNodeEmailKey(nodeUid: string): Promise<{ + email: string, + addressId: string, + addressKey: PrivateKey, + addressKeyId: string, + }>, iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index c994bf8b..02943d87 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -48,12 +48,12 @@ describe("SharingManagement", () => { decryptInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptExternalInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptMember: jest.fn().mockImplementation((member) => member), - encryptInvitation: jest.fn().mockImplementation(() => {}), + encryptInvitation: jest.fn().mockImplementation(() => { }), encryptExternalInvitation: jest.fn().mockImplementation((invitation) => ({ ...invitation, base64ExternalInvitationSignature: "extenral-signature", })), - decryptPublicLink: jest.fn().mockImplementation((_, publicLink) => publicLink), + decryptPublicLink: jest.fn().mockImplementation((publicLink) => publicLink), } // @ts-expect-error No need to implement all methods for mocking accountService = { @@ -61,7 +61,6 @@ describe("SharingManagement", () => { } // @ts-expect-error No need to implement all methods for mocking sharesService = { - getVolumeEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressKey: "volume-key" }), loadEncryptedShare: jest.fn().mockResolvedValue({ id: "shareId", addressId: "addressId" }), } // @ts-expect-error No need to implement all methods for mocking @@ -69,6 +68,7 @@ describe("SharingManagement", () => { getNode: jest.fn().mockImplementation((nodeUid) => ({ nodeUid, shareId: "shareId", name: { ok: true, value: "name" } })), getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: "node-key" })), getNodePrivateAndSessionKeys: jest.fn().mockImplementation((nodeUid) => ({})), + getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressKey: "volume-key" }), } nodesEvents = { nodeUpdated: jest.fn(), @@ -153,7 +153,7 @@ describe("SharingManagement", () => { members: [], publicLink: publicLink, }); - expect(cryptoService.decryptPublicLink).toHaveBeenCalledWith("addressId", publicLink); + expect(cryptoService.decryptPublicLink).toHaveBeenCalledWith(publicLink); }); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 19e8473a..2b285bb1 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -56,7 +56,7 @@ export class SharingManagement { return; } - const [ protonInvitations, nonProtonInvitations, members, publicLink ] = await Promise.all([ + const [protonInvitations, nonProtonInvitations, members, publicLink] = await Promise.all([ Array.fromAsync(this.iterateShareInvitations(node.shareId)), Array.fromAsync(this.iterateShareExternalInvitations(node.shareId)), Array.fromAsync(this.iterateShareMembers(node.shareId)), @@ -93,25 +93,11 @@ export class SharingManagement { } private async getPublicLink(shareId: string): Promise { - // TODO: address ID is not set when user is not member of the share. - // Users cannot manage other shares yet (admin role is not supported - // yet). But owners will stop being members and we need to keep this - // working. Simple solution is to use the volume email key address ID - // as that should be used for public links, but some older links that - // did not follow this logic might not be able to decrypt once the - // owner is not member by default. - const encryptedShare = await this.sharesService.loadEncryptedShare(shareId); - let addressId = encryptedShare.addressId; - if (!addressId) { - const volumeEmailKey = await this.sharesService.getVolumeEmailKey(encryptedShare.volumeId); - addressId = volumeEmailKey.addressId; - } - const encryptedPublicLink = await this.apiService.getPublicLink(shareId); if (!encryptedPublicLink) { return; } - return this.cryptoService.decryptPublicLink(addressId, encryptedPublicLink); + return this.cryptoService.decryptPublicLink(encryptedPublicLink); } async shareNode(nodeUid: string, settings: ShareNodeSettings): Promise { @@ -352,7 +338,7 @@ export class SharingManagement { } const { volumeId } = splitNodeUid(nodeUid); - const { addressId, addressKey } = await this.sharesService.getVolumeEmailKey(volumeId); + const { addressId, addressKey } = await this.nodesService.getRootNodeEmailKey(nodeUid); const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(nodeUid); const keys = await this.cryptoService.generateShareKeys(nodeKeys, addressKey); @@ -382,7 +368,7 @@ export class SharingManagement { } private async inviteProtonUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { - const inviter = await this.sharesService.getVolumeEmailKey(share.volumeId); + const inviter = await this.nodesService.getRootNodeEmailKey(share.volumeId); const invitationCrypto = await this.cryptoService.encryptInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); const encryptedInvitation = await this.apiService.inviteProtonUser(share.shareId, { @@ -411,7 +397,7 @@ export class SharingManagement { } private async inviteExternalUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { - const inviter = await this.sharesService.getVolumeEmailKey(share.volumeId); + const inviter = await this.nodesService.getRootNodeEmailKey(share.volumeId); const invitationCrypto = await this.cryptoService.encryptExternalInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); const encryptedInvitation = await this.apiService.inviteExternalUser(share.shareId, { diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index f654acfa..292aaaac 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -1,15 +1,14 @@ import { Thumbnail } from "../../interface"; import { DriveCrypto, PrivateKey, SessionKey } from "../../crypto"; -import { splitNodeUid } from "../uids"; -import { EncryptedBlock, EncryptedThumbnail, NodeCrypto, NodeRevisionDraftKeys, SharesService } from "./interface"; +import { EncryptedBlock, EncryptedThumbnail, NodeCrypto, NodeRevisionDraftKeys, NodesService } from "./interface"; export class UploadCryptoService { constructor( private driveCrypto: DriveCrypto, - private shareService: SharesService, + private nodesService: NodesService, ) { this.driveCrypto = driveCrypto; - this.shareService = shareService; + this.nodesService = nodesService; } async generateFileCrypto( @@ -17,8 +16,8 @@ export class UploadCryptoService { parentKeys: { key: PrivateKey, hashKey: Uint8Array }, name: string, ): Promise { - const { volumeId } = splitNodeUid(parentUid); - const signatureAddress = await this.shareService.getVolumeEmailKey(volumeId); + const signatureAddress = await this.nodesService.getRootNodeEmailKey(parentUid); + const [ nodeKeys, { armoredNodeName }, diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 0934fe8f..4df8c937 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -26,10 +26,10 @@ export function initUploadModule( nodesEvents: NodesEvents, ) { const api = new UploadAPIService(apiService); - const cryptoService = new UploadCryptoService(driveCrypto, sharesService); + const cryptoService = new UploadCryptoService(driveCrypto, nodesService); const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); - const manager = new UploadManager(telemetry, api, cryptoService, sharesService, nodesService, nodesEvents); + const manager = new UploadManager(telemetry, api, cryptoService, nodesService, nodesEvents); const queue = new UploadQueue(); diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 5e7ea4e6..1801e173 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -99,6 +99,12 @@ export interface NodesService { contentKeyPacketSessionKey?: SessionKey, hashKey?: Uint8Array, }>, + getRootNodeEmailKey(nodeUid: string): Promise<{ + email: string, + addressId: string, + addressKey: PrivateKey, + addressKeyId: string, + }>, } /** @@ -118,6 +124,5 @@ export interface NodesServiceNode { * Interface describing the dependencies to the shares module. */ export interface SharesService { - getVolumeEmailKey(volumeId: string): Promise<{ email: string, addressId: string, addressKey: PrivateKey }>, getVolumeMetricContext(volumeId: string): Promise, } diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 022be95a..b65ffcd7 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -4,14 +4,13 @@ import { getMockTelemetry } from "../../tests/telemetry"; import { ErrorCode } from "../apiService"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { SharesService, NodesService, NodesEvents } from "./interface"; +import { NodesService, NodesEvents } from "./interface"; import { UploadManager } from './manager'; describe("UploadManager", () => { let telemetry: ProtonDriveTelemetry; let apiService: UploadAPIService; let cryptoService: UploadCryptoService; - let sharesService: SharesService; let nodesService: NodesService; let nodesEvents: NodesEvents; @@ -75,25 +74,22 @@ describe("UploadManager", () => { }), } // @ts-expect-error No need to implement all methods for mocking - sharesService = { - getVolumeEmailKey: jest.fn().mockResolvedValue({ - email: "signatureEmail", - addressId: "addressId", - }), - } - // @ts-expect-error No need to implement all methods for mocking nodesService = { getNodeKeys: jest.fn().mockResolvedValue({ hashKey: 'parentNode:hashKey', key: 'parentNode:nodekey', }), + getRootNodeEmailKey: jest.fn().mockResolvedValue({ + email: "signatureEmail", + addressId: "addressId", + }), } nodesEvents = { nodeCreated: jest.fn(), nodeUpdated: jest.fn(), } - manager = new UploadManager(telemetry, apiService, cryptoService, sharesService, nodesService, nodesEvents); + manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, nodesEvents); }); describe("createDraftNode", () => { diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 0428cacc..ebe33513 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -4,10 +4,9 @@ import { Logger, MemberRole, NodeType, ProtonDriveTelemetry, resultOk, Revision, import { ValidationError, NodeAlreadyExistsValidationError } from "../../errors"; import { ErrorCode } from "../apiService"; import { DecryptedNode, generateFileExtendedAttributes } from "../nodes"; -import { splitNodeUid } from "../uids"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { NodeRevisionDraft, NodesService, NodesEvents, NodeCrypto, SharesService } from "./interface"; +import { NodeRevisionDraft, NodesService, NodesEvents, NodeCrypto } from "./interface"; /** * UploadManager is responsible for creating and deleting draft nodes @@ -21,14 +20,12 @@ export class UploadManager { telemetry: ProtonDriveTelemetry, private apiService: UploadAPIService, private cryptoService: UploadCryptoService, - private sharesService: SharesService, private nodesService: NodesService, private nodesEvents: NodesEvents, ) { this.logger = telemetry.getLogger('upload'); this.apiService = apiService; this.cryptoService = cryptoService; - this.sharesService = sharesService; this.nodesService = nodesService; } @@ -200,8 +197,7 @@ export class UploadManager { throw new ValidationError(c('Error').t`Creating revisions in non-files is not allowed`); } - const { volumeId } = splitNodeUid(nodeUid); - const signatureAddress = await this.sharesService.getVolumeEmailKey(volumeId); + const signatureAddress = await this.nodesService.getRootNodeEmailKey(nodeUid); const { nodeRevisionUid } = await this.apiService.createDraftRevision(nodeUid, { currentRevisionUid: node.activeRevision.value.uid, diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index 7896dbed..9a97c583 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -23,7 +23,6 @@ describe('UploadTelemetry', () => { }), } as unknown as jest.Mocked; - // @ts-expect-error No need to implement all methods for mocking sharesService = { getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), } From 0c6a42934a76bd3d2ffb98d14006cb0e60b18742 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 30 May 2025 08:01:09 +0200 Subject: [PATCH 109/791] reuse array buffer --- js/sdk/src/internal/upload/chunkStreamReader.test.ts | 10 +++++----- js/sdk/src/internal/upload/chunkStreamReader.ts | 12 +++++++++--- js/sdk/src/internal/upload/fileUploader.ts | 11 ++++++++++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/js/sdk/src/internal/upload/chunkStreamReader.test.ts b/js/sdk/src/internal/upload/chunkStreamReader.test.ts index 5840942d..1eec5351 100644 --- a/js/sdk/src/internal/upload/chunkStreamReader.test.ts +++ b/js/sdk/src/internal/upload/chunkStreamReader.test.ts @@ -20,7 +20,7 @@ describe("ChunkStreamReader", () => { const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { - chunks.push(chunk); + chunks.push(new Uint8Array(chunk)); } expect(chunks.length).toBe(4); @@ -35,7 +35,7 @@ describe("ChunkStreamReader", () => { const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { - chunks.push(chunk); + chunks.push(new Uint8Array(chunk)); } expect(chunks.length).toBe(6); @@ -52,7 +52,7 @@ describe("ChunkStreamReader", () => { const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { - chunks.push(chunk); + chunks.push(new Uint8Array(chunk)); } expect(chunks.length).toBe(3); @@ -66,7 +66,7 @@ describe("ChunkStreamReader", () => { const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { - chunks.push(chunk); + chunks.push(new Uint8Array(chunk)); } expect(chunks.length).toBe(3); @@ -80,7 +80,7 @@ describe("ChunkStreamReader", () => { const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { - chunks.push(chunk); + chunks.push(new Uint8Array(chunk)); } expect(chunks.length).toBe(1); diff --git a/js/sdk/src/internal/upload/chunkStreamReader.ts b/js/sdk/src/internal/upload/chunkStreamReader.ts index bdc47f32..861163cc 100644 --- a/js/sdk/src/internal/upload/chunkStreamReader.ts +++ b/js/sdk/src/internal/upload/chunkStreamReader.ts @@ -1,3 +1,10 @@ +/** + * This class is used to read a stream in chunks. + * + * WARNING: The chunks are reused to avoid allocating new memory for each chunk. + * Ensure that the previous chunk is fully read before reading the next chunk. + * If you need to keep previous chunks, copy them to a new array. + */ export class ChunkStreamReader { private reader: ReadableStreamDefaultReader; @@ -8,8 +15,8 @@ export class ChunkStreamReader { this.chunkSize = chunkSize; } - async *iterateChunks() { - let buffer = new Uint8Array(this.chunkSize); + async *iterateChunks(): AsyncGenerator { + const buffer = new Uint8Array(this.chunkSize); let position = 0; while (true) { @@ -30,7 +37,6 @@ export class ChunkStreamReader { buffer.set(remainingValue.slice(0, remainingToFillBuffer), position); yield buffer; - buffer = new Uint8Array(this.chunkSize); position = 0; remainingValue = remainingValue.slice(remainingToFillBuffer); } diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index a4ff4aac..9f69b268 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -154,6 +154,15 @@ export class Fileuploader { throw error; } finally { this.logger.debug(`Upload cleanup`); + + // Help the garbage collector to clean up the memory. + this.encryptedBlocks.clear(); + this.encryptedThumbnails.clear(); + this.ongoingUploads.clear(); + this.uploadedBlocks = []; + this.uploadedThumbnails = []; + this.encryptionFinished = false; + await this.onFinish(failure); } @@ -300,7 +309,7 @@ export class Fileuploader { }))), }, ); - + for (const thumbnailToken of uploadTokens.thumbnailTokens) { let encryptedThumbnail = this.encryptedThumbnails.get(thumbnailToken.type); if (!encryptedThumbnail) { From 2813d6a948dec6c3a2231cbcf1974648eb4ac1a9 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 3 Jun 2025 13:18:45 +0200 Subject: [PATCH 110/791] add experimental getDocsKey --- js/sdk/src/protonDriveClient.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 3124123d..2a2c508c 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -24,7 +24,7 @@ import { DeviceEventCallback, NodeEventCallback, } from './interface'; -import { DriveCrypto } from './crypto'; +import { DriveCrypto, SessionKey } from './crypto'; import { DriveAPIService } from './internal/apiService'; import { initSharesModule } from './internal/shares'; import { initNodesModule } from './internal/nodes'; @@ -66,6 +66,12 @@ export class ProtonDriveClient { * It has hardcoded URLs to open in production client only. */ getNodeUrl: (nodeUid: NodeOrUid) => Promise; + /** + * Experimental feature to get the docs key for a node. + * + * This is used by Docs app to encrypt and decrypt document updates. + */ + getDocsKey: (nodeUid: NodeOrUid) => Promise; }; constructor({ @@ -84,7 +90,7 @@ export class ProtonDriveClient { const fullConfig = getConfig(config); this.sdkEvents = new SDKEvents(telemetry); - const cryptoModule = new DriveCrypto(openPGPCryptoModule); + const cryptoModule = new DriveCrypto(openPGPCryptoModule); const apiService = new DriveAPIService(telemetry, this.sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); this.events = new DriveEventsService(telemetry, apiService, entitiesCache); this.shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); @@ -98,6 +104,14 @@ export class ProtonDriveClient { this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); return this.nodes.access.getNodeUrl(getUid(nodeUid)); }, + getDocsKey: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting docs keys for ${getUid(nodeUid)}`); + const keys = await this.nodes.access.getNodeKeys(getUid(nodeUid)); + if (!keys.contentKeyPacketSessionKey) { + throw new Error('Node does not have a content key packet session key'); + } + return keys.contentKeyPacketSessionKey; + }, } } From 78c38a1e218ff179505e38ca4500a3040c12173f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 30 May 2025 15:33:06 +0200 Subject: [PATCH 111/791] configuration for npm package publishing --- .gitignore | 1 + js/sdk/package.json | 18 ++++++++++++++---- js/sdk/src/version.ts | 5 +++-- js/sdk/tsconfig.json | 13 +++++++++---- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 76be1749..bc898eaa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ public node_modules .eslintcache tsconfig.tsbuildinfo +dist # JS CLI js/cli/proton-drive diff --git a/js/sdk/package.json b/js/sdk/package.json index cc86350b..bd99c0d1 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,10 +1,17 @@ { - "name": "proton-drive-sdk", - "version": "0.0.1", + "name": "@proton/drive-sdk", + "version": "0.0.4", "description": "Proton Drive SDK", "license": "GPL-3.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src" + ], "scripts": { - "check-types": "tsc", + "build": "rm -rf dist && tsc", + "check-types": "tsc --noEmit", "generate-doc:interface": "typedoc src/index.ts --out ${OUTPUT_PATH}", "generate-doc:internal": "typedoc src/**/*.ts --out ${OUTPUT_PATH}", "generate-types": "openapi-typescript ../../api/openapi-drive.json -o ./src/internal/apiService/driveTypes.ts && openapi-typescript ../../api/openapi-core.json -o ./src/internal/apiService/coreTypes.ts", @@ -33,5 +40,8 @@ "prettier": "^3.4.2", "typedoc": "^0.26.11", "typescript": "^5.6.3" + }, + "publishConfig": { + "registry": "https://nexus.protontech.ch/repository/drive-npm/" } -} +} \ No newline at end of file diff --git a/js/sdk/src/version.ts b/js/sdk/src/version.ts index 86d9e6a2..03115e03 100644 --- a/js/sdk/src/version.ts +++ b/js/sdk/src/version.ts @@ -1,3 +1,4 @@ -import packageJson from '../package.json' with { type: "json"}; +import { version } from '../package.json'; + +export const VERSION = version; -export const VERSION = packageJson.version; diff --git a/js/sdk/tsconfig.json b/js/sdk/tsconfig.json index b7399cb0..65afe414 100644 --- a/js/sdk/tsconfig.json +++ b/js/sdk/tsconfig.json @@ -4,15 +4,20 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "incremental": true, - "module": "esnext", - "moduleResolution": "bundler", - "noEmit": true, + "module": "commonjs", + "moduleResolution": "node", + "noEmit": false, "noImplicitAny": true, // Many variables are unused during prototyping - uncomment later once more modules are implemented. //"noUnusedLocals": true, "strict": true, "skipLibCheck": true, "target": "esnext", + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "resolveJsonModule": true }, "include": [ "src/**/*.ts", @@ -23,4 +28,4 @@ "**/coreTypes.ts", "**/driveTypes.ts" ], -} +} \ No newline at end of file From 7fcbd5e1989a4d8e2ad9ce34441baa5af178822d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Jun 2025 08:45:12 +0200 Subject: [PATCH 112/791] add block verification telemetry --- js/sdk/src/interface/telemetry.ts | 6 +++ js/sdk/src/internal/upload/cryptoService.ts | 23 +++++++--- .../src/internal/upload/fileUploader.test.ts | 45 +++++++++++++------ js/sdk/src/internal/upload/fileUploader.ts | 11 +++++ js/sdk/src/internal/upload/telemetry.ts | 7 +++ 5 files changed, 71 insertions(+), 21 deletions(-) diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 8fe2592a..5d71c8c9 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -16,6 +16,7 @@ export type MetricEvent = MetricDownloadEvent | MetricDecryptionErrorEvent | MetricVerificationErrorEvent | + MetricBlockVerificationErrorEvent | MetricVolumeEventsSubscriptionsChangedEvent; export interface MetricAPIRetrySucceededEvent { @@ -90,6 +91,11 @@ export type MetricVerificationErrorField = 'nodeContentKey' | 'content'; +export interface MetricBlockVerificationErrorEvent { + eventName: 'blockVerificationError', + retryHelped: boolean, +}; + export interface MetricVolumeEventsSubscriptionsChangedEvent { eventName: 'volumeEventsSubscriptionsChanged', numberOfVolumeSubscriptions: number, diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 292aaaac..2eaab290 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -1,5 +1,8 @@ -import { Thumbnail } from "../../interface"; +import { c } from "ttag"; + import { DriveCrypto, PrivateKey, SessionKey } from "../../crypto"; +import { IntegrityError } from "../../errors"; +import { Thumbnail } from "../../interface"; import { EncryptedBlock, EncryptedThumbnail, NodeCrypto, NodeRevisionDraftKeys, NodesService } from "./interface"; export class UploadCryptoService { @@ -136,12 +139,18 @@ export class UploadCryptoService { // // Additionally, we use the key provided by the verification endpoint, to // ensure the correct key was used to encrypt the data - await this.driveCrypto.decryptBlock( - encryptedData, - undefined, - nodeKey, - contentKeyPacketSessionKey, - ); + try { + await this.driveCrypto.decryptBlock( + encryptedData, + undefined, + nodeKey, + contentKeyPacketSessionKey, + ); + } catch (error) { + throw new IntegrityError(c('Error').t`Data integrity check of one part failed`, { + error, + }); + } // The verifier requires a 0-padded data packet, so we can // access the array directly and fall back to 0. diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index fe5e2cf2..e95246be 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -8,6 +8,7 @@ import { UploadController } from './controller'; import { BlockVerifier } from './blockVerifier'; import { NodeRevisionDraft } from './interface'; import { UploadManager } from './manager'; +import { IntegrityError } from '../../errors'; const BLOCK_ENCRYPTION_OVERHEAD = 10000; @@ -51,6 +52,7 @@ describe('FileUploader', () => { warn: jest.fn(), error: jest.fn(), }), + logBlockVerificationError: jest.fn(), uploadFailed: jest.fn(), uploadFinished: jest.fn(), }; @@ -225,7 +227,7 @@ describe('FileUploader', () => { expect(onProgress).toHaveBeenNthCalledWith(i + 1, uploadedBytes[i]); } }; - + beforeEach(() => { onProgress = jest.fn(); thumbnails = [ @@ -252,11 +254,12 @@ describe('FileUploader', () => { expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); expect(apiService.uploadBlock).toHaveBeenCalledTimes(4); // 3 blocks + 1 thumbnail expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(3); // 3 blocks - await verifyOnProgress([thumbnailSize, 4*1024*1024, 4*1024*1024, 2*1024*1024]); + expect(telemetry.logBlockVerificationError).not.toHaveBeenCalled(); + await verifyOnProgress([thumbnailSize, 4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024]); }); it("should upload successfully empty file without thumbnail", async () => { - metadata = { + metadata = { expectedSize: 0, } as UploadMetadata; stream = new ReadableStream({ @@ -285,7 +288,7 @@ describe('FileUploader', () => { }); it("should upload successfully empty file with thumbnail", async () => { - metadata = { + metadata = { expectedSize: 0, } as UploadMetadata; stream = new ReadableStream({ @@ -319,7 +322,7 @@ describe('FileUploader', () => { await verifyFailure('Failed to encrypt thumbnail', 0); expect(cryptoService.encryptThumbnail).toHaveBeenCalledTimes(1); }); - + it('should handle failure when encrypting block', async () => { cryptoService.encryptBlock = jest.fn().mockImplementation(async function () { throw new Error('Failed to encrypt block'); @@ -330,7 +333,7 @@ describe('FileUploader', () => { // 1 block + 1 retry, others are skipped expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(2); }); - + it('should handle one time-off failure when encrypting block', async () => { let count = 0; cryptoService.encryptBlock = jest.fn().mockImplementation(async function (verifyBlock, keys, block, index) { @@ -344,9 +347,9 @@ describe('FileUploader', () => { await verifySuccess(); // 1 block + 1 retry + 2 other blocks without retry expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(4); - await verifyOnProgress([thumbnailSize, 4*1024*1024, 4*1024*1024, 2*1024*1024]); + await verifyOnProgress([thumbnailSize, 4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024]); }); - + it('should handle failure when requesting tokens', async () => { apiService.requestBlockUpload = jest.fn().mockImplementation(async function () { throw new Error('Failed to request tokens'); @@ -381,7 +384,7 @@ describe('FileUploader', () => { expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); // 3 blocks + 1 retry + 1 thumbnail expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); - await verifyOnProgress([4*1024*1024, 4*1024*1024, 2*1024*1024, 1024]); + await verifyOnProgress([4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 1024]); }); it('should handle failure when uploading block', async () => { @@ -410,7 +413,7 @@ describe('FileUploader', () => { expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); // 3 blocks + 1 retry + 1 thumbnail expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); - await verifyOnProgress([1024, 4*1024*1024, 2*1024*1024, 4*1024*1024]); + await verifyOnProgress([1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024]); }); it('should handle expired token when uploading block', async () => { @@ -443,7 +446,7 @@ describe('FileUploader', () => { ); // 3 blocks + 1 retry + 1 thumbnail expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); - await verifyOnProgress([1024, 4*1024*1024, 2*1024*1024, 4*1024*1024]); + await verifyOnProgress([1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024]); }); it('should handle abortion', async () => { @@ -455,6 +458,20 @@ describe('FileUploader', () => { }); describe('verifyIntegrity', () => { + it('should report block verification error', async () => { + blockVerifier.verifyBlock = jest.fn().mockRejectedValue(new IntegrityError('Block verification error')); + await verifyFailure('Block verification error', 1024); + expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(false); + }); + + it('should report block verification error when retry helped', async () => { + blockVerifier.verifyBlock = jest.fn().mockRejectedValueOnce(new IntegrityError('Block verification error')).mockResolvedValue({ + verificationToken: new Uint8Array(), + }); + await verifySuccess(); + expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(true); + }); + it('should throw an error if block count does not match', async () => { uploader = new Fileuploader( telemetry, @@ -470,14 +487,14 @@ describe('FileUploader', () => { }, onFinish, ); - + await verifyFailure( 'Some file parts failed to upload', 10 * 1024 * 1024 + 1024, 1 * 1024 * 1024 + 1024, ); }); - + it('should throw an error if file size does not match', async () => { cryptoService.encryptBlock = jest.fn().mockImplementation(async (_, __, block, index) => ({ index, @@ -488,7 +505,7 @@ describe('FileUploader', () => { encryptedSize: block.length + 10000, hash: 'blockHash', })); - + await verifyFailure('Some file bytes failed to upload', 10 * 1024 * 1024 + 1024); }); }); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 9f69b268..436d3209 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -261,6 +261,7 @@ export class Fileuploader { this.logger.debug(`Encrypting block ${index}`); let attempt = 0; + let integrityError = false; let encryptedBlock; while (!encryptedBlock) { attempt++; @@ -272,13 +273,23 @@ export class Fileuploader { block, index, ); + if (integrityError) { + void this.telemetry.logBlockVerificationError(true); + } } catch (error: unknown) { + if (error instanceof IntegrityError) { + integrityError = true; + } + if (attempt <= MAX_BLOCK_ENCRYPTION_RETRIES) { this.logger.warn(`Block encryption failed #${attempt}, retrying: ${getErrorMessage(error)}`); continue; } this.logger.error(`Failed to encrypt block ${index}`, error); + if (integrityError) { + void this.telemetry.logBlockVerificationError(false); + } throw error; } } diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 8cb1e0dd..0cccd601 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -19,6 +19,13 @@ export class UploadTelemetry { return new LoggerWithPrefix(logger, `revision ${revisionUid}`); } + logBlockVerificationError(retryHelped: boolean) { + this.telemetry.logEvent({ + eventName: 'blockVerificationError', + retryHelped, + }); + } + async uploadInitFailed(parentFolderUid: string, error: unknown, expectedSize: number) { const { volumeId } = splitNodeUid(parentFolderUid); const errorCategory = getErrorCategory(error); From 409a598ad488151ce0ac28d09fa540a9dd467517 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 6 Jun 2025 11:06:34 +0000 Subject: [PATCH 113/791] add getNode method --- js/sdk/src/protonDriveClient.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 2a2c508c..dec7cdc5 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -388,6 +388,17 @@ export class ProtonDriveClient { yield* convertInternalMissingNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } + /** + * Get the node by its UID. + * + * @param nodeUid - Node entity or its UID string. + * @returns The node entity. + */ + async getNode(nodeUid: NodeOrUid): Promise { + this.logger.info(`Getting node ${getUid(nodeUid)}`); + return convertInternalNodePromise(this.nodes.access.getNode(getUid(nodeUid))); + } + /** * Rename the node. * From 614a94ce0094de8ae012204cdece54483025abe7 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Jun 2025 16:10:24 +0200 Subject: [PATCH 114/791] Update JS package version to 0.0.5 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index bd99c0d1..dc90680a 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@proton/drive-sdk", - "version": "0.0.4", + "version": "0.0.5", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 305d575f0b5f06b68ca8fff0a00980a50ab9554a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Jun 2025 15:39:53 +0000 Subject: [PATCH 115/791] Allow to pass either single or multiple key to match CryptoProxy Api --- js/sdk/package.json | 2 +- js/sdk/src/crypto/driveCrypto.ts | 10 +++---- js/sdk/src/crypto/interface.ts | 22 +++++++------- js/sdk/src/crypto/openPGPCrypto.ts | 30 +++++++++---------- .../internal/download/fileDownloader.test.ts | 4 +-- .../src/internal/nodes/cryptoService.test.ts | 2 +- js/sdk/src/internal/nodes/index.test.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 2 +- js/sdk/src/internal/sharing/cryptoService.ts | 4 +-- .../src/internal/sharing/sharingManagement.ts | 6 ++-- js/sdk/src/internal/upload/manager.test.ts | 4 +-- 11 files changed, 44 insertions(+), 44 deletions(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index dc90680a..8a0dad80 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@proton/drive-sdk", - "version": "0.0.5", + "version": "0.0.6", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 6dd9e139..e959dd16 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -210,7 +210,7 @@ export class DriveCrypto { */ async decryptSessionKey( armoredData: string, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], ): Promise { const sessionKey = await this.openPGPCrypto.decryptArmoredSessionKey( armoredData, @@ -222,7 +222,7 @@ export class DriveCrypto { async decryptAndVerifySessionKey( base64data: string, armoredSignature: string | undefined, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], verificationKeys: PublicKey[], ): Promise<{ sessionKey: SessionKey, @@ -255,7 +255,7 @@ export class DriveCrypto { async decryptUnsignedKey( armoredKey: string, armoredPassphrase: string, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], ): Promise { const { data: decryptedPassphrase } = await this.openPGPCrypto.decryptArmoredAndVerify( armoredPassphrase, @@ -461,7 +461,7 @@ export class DriveCrypto { base64KeyPacket: string, base64KeyPacketSignature: string, }> { - const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(shareSessionKey, [encryptionKey]); + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(shareSessionKey, encryptionKey); const { signature: keyPacketSignature } = await this.openPGPCrypto.sign( keyPacket, signingKey, @@ -622,7 +622,7 @@ export class DriveCrypto { async verifyManifest( manifest: Uint8Array, armoredSignature: string, - verificationKeys: PublicKey[], + verificationKeys: PublicKey | PublicKey[], ): Promise<{ verified: VERIFICATION_STATUS, }> { diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 1c20211f..9a8c2276 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -1,6 +1,6 @@ // TODO: Use CryptoProxy once available. // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type PublicKey = any; +export type PublicKey = object; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface PrivateKey extends PublicKey {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -33,7 +33,7 @@ export interface OpenPGPCrypto { generateSessionKey: (encryptionKeys: PrivateKey[]) => Promise, - encryptSessionKey: (sessionKey: SessionKey, encryptionKeys: PublicKey[]) => Promise<{ + encryptSessionKey: (sessionKey: SessionKey, encryptionKeys: PublicKey | PublicKey[]) => Promise<{ keyPacket: Uint8Array, }>, @@ -111,19 +111,19 @@ export interface OpenPGPCrypto { verify: ( data: Uint8Array, armoredSignature: string, - verificationKeys: PublicKey[], + verificationKeys: PublicKey | PublicKey[], ) => Promise<{ verified: VERIFICATION_STATUS, }>, decryptSessionKey: ( data: Uint8Array, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], ) => Promise, decryptArmoredSessionKey: ( armoredData: string, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], ) => Promise, decryptKey: ( @@ -134,7 +134,7 @@ export interface OpenPGPCrypto { decryptAndVerify( data: Uint8Array, sessionKey: SessionKey, - verificationKeys: PublicKey[], + verificationKeys: PublicKey | PublicKey[], ): Promise<{ data: Uint8Array, verified: VERIFICATION_STATUS, @@ -144,7 +144,7 @@ export interface OpenPGPCrypto { data: Uint8Array, signature: Uint8Array | undefined, sessionKey: SessionKey, - verificationKeys?: PublicKey[], + verificationKeys?: PublicKey | PublicKey[], ): Promise<{ data: Uint8Array, verified: VERIFICATION_STATUS, @@ -152,13 +152,13 @@ export interface OpenPGPCrypto { decryptArmored( armoredData: string, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], ): Promise, decryptArmoredAndVerify: ( armoredData: string, - decryptionKeys: PrivateKey[], - verificationKeys: PublicKey[], + decryptionKeys: PrivateKey | PrivateKey[], + verificationKeys: PublicKey | PublicKey[], ) => Promise<{ data: Uint8Array, verified: VERIFICATION_STATUS, @@ -168,7 +168,7 @@ export interface OpenPGPCrypto { armoredData: string, armoredSignature: string, sessionKey: SessionKey, - verificationKeys: PublicKey[], + verificationKeys: PublicKey | PublicKey[], ) => Promise<{ data: Uint8Array, verified: VERIFICATION_STATUS, diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 48de29cb..fcf2b0e6 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -11,7 +11,7 @@ export interface OpenPGPCryptoProxy { importPrivateKey: (options: { armoredKey: string, passphrase: string }) => Promise, generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys: PublicKey[] }) => Promise, - decryptSessionKey: (options: { armoredMessage?: string, binaryMessage?: Uint8Array, decryptionKeys: PrivateKey[] }) => Promise, + decryptSessionKey: (options: { armoredMessage?: string, binaryMessage?: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[] }) => Promise, encryptMessage: (options: { format?: 'armored' | 'binary', binaryData: Uint8Array, @@ -30,8 +30,8 @@ export interface OpenPGPCryptoProxy { armoredSignature?: string, binarySignature?: Uint8Array, sessionKeys?: SessionKey, - decryptionKeys?: PrivateKey[], - verificationKeys?: PublicKey[], + decryptionKeys?:PrivateKey | PrivateKey[], + verificationKeys?: PublicKey | PublicKey[], }) => Promise<{ data: Uint8Array | string, // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. @@ -42,14 +42,14 @@ export interface OpenPGPCryptoProxy { signMessage: (options: { format: 'binary' | 'armored', binaryData: Uint8Array, - signingKeys: PrivateKey[], + signingKeys: PrivateKey | PrivateKey[], detached: boolean, context?: { critical: boolean, value: string }, }) => Promise, verifyMessage: (options: { binaryData: Uint8Array, armoredSignature: string, - verificationKeys: PublicKey[], + verificationKeys: PublicKey | PublicKey[], }) => Promise<{ // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. @@ -77,7 +77,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return this.cryptoProxy.generateSessionKey({ recipientKeys: encryptionKeys }); } - async encryptSessionKey(sessionKey: SessionKey, encryptionKeys: PublicKey[]) { + async encryptSessionKey(sessionKey: SessionKey, encryptionKeys: PublicKey | PublicKey[]) { const keyPacket = await this.cryptoProxy.encryptSessionKey({ ...sessionKey, format: 'binary', @@ -199,7 +199,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async sign( data: Uint8Array, - signingKeys: PrivateKey[], + signingKeys: PrivateKey | PrivateKey[], signatureContext: string, ) { const signature = await this.cryptoProxy.signMessage({ @@ -216,7 +216,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async signArmored( data: Uint8Array, - signingKeys: PrivateKey[], + signingKeys: PrivateKey | PrivateKey[], ) { const signature = await this.cryptoProxy.signMessage({ binaryData: data, @@ -232,7 +232,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async verify( data: Uint8Array, armoredSignature: string, - verificationKeys: PublicKey[], + verificationKeys: PublicKey | PublicKey[], ) { const { verified, verificationStatus } = await this.cryptoProxy.verifyMessage({ binaryData: data, @@ -248,7 +248,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async decryptSessionKey( data: Uint8Array, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], ) { const sessionKey = await this.cryptoProxy.decryptSessionKey({ binaryMessage: data, @@ -264,7 +264,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async decryptArmoredSessionKey( armoredData: string, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], ) { const sessionKey = await this.cryptoProxy.decryptSessionKey({ armoredMessage: armoredData, @@ -333,7 +333,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async decryptArmored( armoredData: string, - decryptionKeys: PrivateKey[], + decryptionKeys: PrivateKey | PrivateKey[], ) { const { data } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, @@ -345,8 +345,8 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async decryptArmoredAndVerify( armoredData: string, - decryptionKeys: PrivateKey[], - verificationKeys: PublicKey[], + decryptionKeys: PrivateKey | PrivateKey[], + verificationKeys: PublicKey| PublicKey[], ) { const { data, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, @@ -367,7 +367,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { armoredData: string, armoredSignature: string, sessionKey: SessionKey, - verificationKeys: PublicKey[], + verificationKeys: PublicKey| PublicKey[], ) { const { data, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index 8c31bcbf..24e9fe00 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -22,7 +22,7 @@ describe('FileDownloader', () => { let apiService: DownloadAPIService; let cryptoService: DownloadCryptoService; let controller: DownloadController; - let nodeKey: { key: string; contentKeyPacketSessionKey: string }; + let nodeKey: { key: object; contentKeyPacketSessionKey: string }; let revision: Revision; beforeEach(() => { @@ -70,7 +70,7 @@ describe('FileDownloader', () => { controller = new DownloadController(); nodeKey = { - key: 'privateKey', + key: {_idx: 32131}, contentKeyPacketSessionKey: 'sessionKey', }; diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 6220bda3..6e5788eb 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -46,7 +46,7 @@ describe("nodesCryptoService", () => { }; // @ts-expect-error No need to implement all methods for mocking account = { - getPublicKeys: jest.fn(async () => ["public key"]), + getPublicKeys: jest.fn(async () => [{_idx: 21312}]), }; // @ts-expect-error No need to implement all methods for mocking sharesService = { diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index edeb24c8..b13b5e9c 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -101,7 +101,7 @@ describe('nodesModules integration tests', () => { }], }; }); - jest.spyOn(nodesModule.access, 'getParentKeys').mockResolvedValue({key: 'privateKey'}); + jest.spyOn(nodesModule.access, 'getParentKeys').mockResolvedValue({key: {_idx: 32131}}); // Verify the inital state before move event is sent. const originalBeforeMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(originalFolderUid)); diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index f43c1661..f3a5ef43 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -412,7 +412,7 @@ describe('nodesAccess', () => { throw decryptionError; } return { - key: 'parentKey', + key: {_idx: 32132}, }; }); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 411aba91..835e434a 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -146,8 +146,8 @@ export class SharingCryptoService { base64KeyPacket: string, base64KeyPacketSignature: string, }> { - const inviteePublicKey = await this.account.getPublicKeys(inviteeEmail); - const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKey, inviterKey) + const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail); + const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKeys[0], inviterKey) return result; }; diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 2b285bb1..075227df 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -166,7 +166,7 @@ export class SharingManagement { } this.logger.info(`Inviting user ${email} with role ${role} to node ${nodeUid}`); - const invitation = await this.inviteProtonUser(currentSharing.share, email, role, emailOptions); + const invitation = await this.inviteProtonUser(nodeUid, currentSharing.share, email, role, emailOptions); currentSharing.protonInvitations.push(invitation); } @@ -367,8 +367,8 @@ export class SharingManagement { await this.nodesEvents.nodeUpdated({ uid: nodeUid, shareId: undefined, isShared: false }); } - private async inviteProtonUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { - const inviter = await this.nodesService.getRootNodeEmailKey(share.volumeId); + private async inviteProtonUser(nodeUid: string, share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { + const inviter = await this.nodesService.getRootNodeEmailKey(nodeUid); const invitationCrypto = await this.cryptoService.encryptInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); const encryptedInvitation = await this.apiService.inviteProtonUser(share.shareId, { diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index b65ffcd7..def828e7 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -300,13 +300,13 @@ describe("UploadManager", () => { nodeUid: "newNode:nodeUid", nodeRevisionUid: "newNode:nodeRevisionUid", nodeKeys: { - key: "newNode:key", + key: {_idx: 32321}, contentKeyPacketSessionKey: "newNode:contentKeyPacketSessionKey", signatureAddress: { email: "signatureEmail", addressId: "addressId", addressKey: "addressKey", - }, + } as any, }, }; const manifest = new Uint8Array([1, 2, 3]); From 78e433361391bd769634c09b095da8c7464a92d3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Jun 2025 12:53:06 +0000 Subject: [PATCH 116/791] Pass nameSessionKey to moveNode --- js/sdk/package.json | 4 +- .../src/internal/nodes/cryptoService.test.ts | 105 +++++++++++++++++- js/sdk/src/internal/nodes/cryptoService.ts | 4 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 41 ++++++- js/sdk/src/internal/nodes/nodesAccess.ts | 4 +- .../internal/nodes/nodesManagement.test.ts | 33 +++++- js/sdk/src/internal/nodes/nodesManagement.ts | 3 +- 7 files changed, 178 insertions(+), 16 deletions(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 8a0dad80..c4aa5ad4 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@proton/drive-sdk", - "version": "0.0.6", + "version": "0.0.7", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", @@ -44,4 +44,4 @@ "publishConfig": { "registry": "https://nexus.protontech.ch/repository/drive-npm/" } -} \ No newline at end of file +} diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 6e5788eb..e17faa6d 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,7 +1,7 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from "../../interface"; +import { DegradedNode, MaybeNode, ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from "../../interface"; import { getMockTelemetry } from "../../tests/telemetry"; -import { DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from "./interface"; +import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from "./interface"; import { NodesCryptoService } from "./cryptoService"; describe("nodesCryptoService", () => { @@ -710,4 +710,105 @@ describe("nodesCryptoService", () => { }); }); }); + + describe('moveNode', () => { + it('should encrypt node data for move operation', async () => { + const node = { + name: { ok: true, value: 'testFile.txt' }, + } as DecryptedNode; + const keys = { + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + nameSessionKey: 'nameSessionKey' as any, + }; + const parentKeys = { + key: 'newParentKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + const address = { + email: 'test@example.com', + addressKey: 'addressKey' as any, + }; + driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({ + armoredNodeName: 'encryptedNodeName', + }); + driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('newHash'); + driveCrypto.encryptPassphrase = jest.fn().mockResolvedValue({ + armoredPassphrase: 'encryptedPassphrase', + armoredPassphraseSignature: 'passphraseSignature', + }); + + const result = await cryptoService.moveNode(node, keys, parentKeys, address); + + expect(result).toEqual({ + encryptedName: 'encryptedNodeName', + hash: 'newHash', + armoredNodePassphrase: 'encryptedPassphrase', + armoredNodePassphraseSignature: 'passphraseSignature', + signatureEmail: 'test@example.com', + nameSignatureEmail: 'test@example.com', + }); + + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'testFile.txt', + keys.nameSessionKey, + parentKeys.key, + address.addressKey + ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith( + 'testFile.txt', + parentKeys.hashKey + ); + expect(driveCrypto.encryptPassphrase).toHaveBeenCalledWith( + keys.passphrase, + keys.passphraseSessionKey, + [parentKeys.key], + address.addressKey + ); + }); + + it('should throw error when moving to non-folder', async () => { + const node = { + name: { ok: true, value: 'testFile.txt' }, + } as DecryptedNode; + const keys = { + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + nameSessionKey: 'nameSessionKey' as any, + }; + const parentKeys = { + key: 'newParentKey' as any, + hashKey: undefined, + } as any; + const address = { + email: 'test@example.com', + addressKey: 'addressKey' as any, + }; + + await expect(cryptoService.moveNode(node, keys, parentKeys, address)) + .rejects.toThrow('Moving item to a non-folder is not allowed'); + }); + + it('should throw error when node has invalid name', async () => { + const node = { + name: { ok: false, error: 'Invalid name' }, + } as any; + const keys = { + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + nameSessionKey: 'nameSessionKey' as any, + }; + const parentKeys = { + key: 'newParentKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + const address = { + email: 'test@example.com', + addressKey: 'addressKey' as any, + }; + + await expect(cryptoService.moveNode(node, keys, parentKeys, address)) + .rejects.toThrow('Cannot move item without a valid name, please rename the item first'); + }); + }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 112189f8..6ca569da 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -419,7 +419,7 @@ export class NodesCryptoService { async moveNode( node: DecryptedNode, - keys: { passphrase: string, passphraseSessionKey: SessionKey }, + keys: { passphrase: string, passphraseSessionKey: SessionKey, nameSessionKey: SessionKey }, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, address: { email: string, addressKey: PrivateKey }, ): Promise<{ @@ -438,7 +438,7 @@ export class NodesCryptoService { } const { email, addressKey } = address; - const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, undefined, parentKeys.key, addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, keys.nameSessionKey, parentKeys.key, addressKey); const hash = await this.driveCrypto.generateLookupHash(node.name.value, parentKeys.hashKey); const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], addressKey); diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index f3a5ef43..9e2fb0c2 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -447,7 +447,7 @@ describe('nodesAccess', () => { describe('getParentKeys', () => { it('should get share parent keys', async () => { shareService.getSharePrivateKey = jest.fn(() => Promise.resolve('shareKey' as any as PrivateKey)); - + const result = await access.getParentKeys({ shareId: 'shareId', parentUid: undefined }); expect(result).toEqual({ key: 'shareKey' }); expect(cryptoCache.getNodeKeys).not.toHaveBeenCalled(); @@ -455,7 +455,7 @@ describe('nodesAccess', () => { it('should get node parent keys', async () => { cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - + const result = await access.getParentKeys({ shareId: undefined, parentUid: 'parentUid' }); expect(result).toEqual({ key: 'parentKey' }); expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); @@ -463,7 +463,7 @@ describe('nodesAccess', () => { it('should get node parent keys even if share is set', async () => { cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - + const result = await access.getParentKeys({ shareId: 'shareId', parentUid: 'parentUid' }); expect(result).toEqual({ key: 'parentKey' }); expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); @@ -484,6 +484,41 @@ describe('nodesAccess', () => { }); }); + describe('getNodePrivateAndSessionKeys', () => { + it('should return all node keys and session keys', async () => { + const nodeUid = 'nodeUid'; + const node = { + uid: nodeUid, + parentUid: 'parentUid', + encryptedName: 'encryptedName', + } as DecryptedNode; + + jest.spyOn(access, 'getNode').mockResolvedValue(node); + jest.spyOn(access, 'getParentKeys').mockResolvedValue({ key: 'parentKey' } as any); + jest.spyOn(access, 'getNodeKeys').mockResolvedValue({ + key: 'nodeKey', + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + contentKeyPacketSessionKey: 'nodeContentKeyPacketSessionKey', + } as any); + cryptoService.getNameSessionKey = jest.fn().mockResolvedValue('nameSessionKey'); + + const result = await access.getNodePrivateAndSessionKeys(nodeUid); + + expect(result).toEqual({ + key: 'nodeKey', + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + contentKeyPacketSessionKey: 'nodeContentKeyPacketSessionKey', + nameSessionKey: 'nameSessionKey', + }); + expect(access.getNode).toHaveBeenCalledWith(nodeUid); + expect(access.getParentKeys).toHaveBeenCalledWith(node); + expect(access.getNodeKeys).toHaveBeenCalledWith(nodeUid); + expect(cryptoService.getNameSessionKey).toHaveBeenCalledWith(node, 'parentKey'); + }); + }); + describe('getNodeUrl', () => { const nodeUid = 'volumeId~nodeId'; diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index c9b92c88..202230da 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -304,16 +304,18 @@ export class NodesAccess { async getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ key: PrivateKey, + passphrase: string, passphraseSessionKey: SessionKey, contentKeyPacketSessionKey: SessionKey, nameSessionKey: SessionKey, }> { const node = await this.getNode(nodeUid); const { key: parentKey } = await this.getParentKeys(node); - const { key, passphraseSessionKey, contentKeyPacketSessionKey } = await this.getNodeKeys(nodeUid); + const { key, passphrase, passphraseSessionKey, contentKeyPacketSessionKey } = await this.getNodeKeys(nodeUid); const nameSessionKey = await this.cryptoService.getNameSessionKey(node, parentKey); return { key, + passphrase, passphraseSessionKey, contentKeyPacketSessionKey, nameSessionKey, diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 4c89dd00..bf61186a 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -83,9 +83,13 @@ describe('NodesManagement', () => { hashKey: `${nodes[uid].parentUid}-hashKey`, })), iterateNodes: jest.fn(), - getNodePrivateAndSessionKeys: jest.fn().mockResolvedValue({ - nameSessionKey: 'nameSessionKey', - }), + getNodePrivateAndSessionKeys: jest.fn().mockImplementation((uid) => Promise.resolve({ + key: `${uid}-key`, + passphrase: `${uid}-passphrase`, + passphraseSessionKey: `${uid}-passphraseSessionKey`, + contentKeyPacketSessionKey: `${uid}-contentKeyPacketSessionKey`, + nameSessionKey: `${uid}-nameSessionKey`, + })), getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: "root-email", addressKey: "root-key" }), } // @ts-expect-error No need to implement all methods for mocking @@ -109,7 +113,7 @@ describe('NodesManagement', () => { }); expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('nodeUid'); expect(cryptoService.encryptNewName).toHaveBeenCalledWith( - 'nameSessionKey', + 'nodeUid-nameSessionKey', { email: "root-email", addressKey: "root-key" }, 'parentUid-hashKey', 'new name', @@ -145,7 +149,13 @@ describe('NodesManagement', () => { expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid'); expect(cryptoService.moveNode).toHaveBeenCalledWith( nodes.nodeUid, - expect.objectContaining({ passphrase: 'nodeUid-passphrase', passphraseSessionKey: 'nodeUid-passphraseSessionKey' }), + expect.objectContaining({ + key: 'nodeUid-key', + passphrase: 'nodeUid-passphrase', + passphraseSessionKey: 'nodeUid-passphraseSessionKey', + contentKeyPacketSessionKey: 'nodeUid-contentKeyPacketSessionKey', + nameSessionKey: 'nodeUid-nameSessionKey' + }), expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), { email: "root-email", addressKey: "root-key" }, ); @@ -176,6 +186,19 @@ describe('NodesManagement', () => { cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid'); + + expect(cryptoService.moveNode).toHaveBeenCalledWith( + nodes.anonymousNodeUid, + expect.objectContaining({ + key: 'anonymousNodeUid-key', + passphrase: 'anonymousNodeUid-passphrase', + passphraseSessionKey: 'anonymousNodeUid-passphraseSessionKey', + contentKeyPacketSessionKey: 'anonymousNodeUid-contentKeyPacketSessionKey', + nameSessionKey: 'anonymousNodeUid-nameSessionKey' + }), + expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), + { email: "root-email", addressKey: "root-key" }, + ); expect(newNode).toEqual({ ...nodes.anonymousNodeUid, parentUid: 'newParentNodeUid', diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 7197dbbe..b287e3e2 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -108,8 +108,9 @@ export class NodesManagement { this.nodesAccess.getNode(nodeUid), this.nodesAccess.getRootNodeEmailKey(newParentUid), ]); + const [keys, newParentKeys] = await Promise.all([ - this.nodesAccess.getNodeKeys(nodeUid), + this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid), this.nodesAccess.getNodeKeys(newParentUid), ]); From dc3c7894572c53ba41aea1253ae1ce9a533b7bbf Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Jun 2025 15:33:49 +0200 Subject: [PATCH 117/791] signMessage accept signatureContext and not context --- js/sdk/src/crypto/openPGPCrypto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index fcf2b0e6..5fbe6d22 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -44,7 +44,7 @@ export interface OpenPGPCryptoProxy { binaryData: Uint8Array, signingKeys: PrivateKey | PrivateKey[], detached: boolean, - context?: { critical: boolean, value: string }, + signatureContext?: { critical: boolean, value: string }, }) => Promise, verifyMessage: (options: { binaryData: Uint8Array, @@ -207,7 +207,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { signingKeys, detached: true, format: 'binary', - context: { critical: true, value: signatureContext }, + signatureContext: { critical: true, value: signatureContext }, }); return { signature: signature as Uint8Array, From 4d35314cae3b759a1ccdd2e6363765e1b170e228 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Jun 2025 16:02:13 +0000 Subject: [PATCH 118/791] Update type of CryptoProxy --- js/sdk/src/crypto/openPGPCrypto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 5fbe6d22..1c63689d 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -7,8 +7,8 @@ import { uint8ArrayToBase64String } from './utils'; */ export interface OpenPGPCryptoProxy { generateKey: (options: { userIDs: { name: string }[], type: 'ecc', curve: 'ed25519Legacy' }) => Promise, - exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string }) => Promise, - importPrivateKey: (options: { armoredKey: string, passphrase: string }) => Promise, + exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string | null }) => Promise, + importPrivateKey: (options: { armoredKey: string, passphrase: string | null }) => Promise, generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys: PublicKey[] }) => Promise, decryptSessionKey: (options: { armoredMessage?: string, binaryMessage?: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[] }) => Promise, From 81dde2e4ee70e394fabc5132ffa1cd75e6e95bd0 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Jun 2025 16:13:14 +0000 Subject: [PATCH 119/791] Make use of incremental build of tsc --- js/sdk/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index c4aa5ad4..6960e804 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -10,7 +10,8 @@ "src" ], "scripts": { - "build": "rm -rf dist && tsc", + "build": "tsc", + "build:ci": "rm -rf dist && tsc", "check-types": "tsc --noEmit", "generate-doc:interface": "typedoc src/index.ts --out ${OUTPUT_PATH}", "generate-doc:internal": "typedoc src/**/*.ts --out ${OUTPUT_PATH}", From 977ccbf535b70ddb035e12e94f2246fb7d90368a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Jun 2025 18:00:20 +0200 Subject: [PATCH 120/791] use nodeUid for external invite instead of volumeId --- js/sdk/src/internal/sharing/sharingManagement.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 075227df..b24b407d 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -198,7 +198,7 @@ export class SharingManagement { } this.logger.info(`Inviting external user ${email} with role ${role} to node ${nodeUid}`); - const invitation = await this.inviteExternalUser(currentSharing.share, email, role, emailOptions); + const invitation = await this.inviteExternalUser(nodeUid, currentSharing.share, email, role, emailOptions); currentSharing.nonProtonInvitations.push(invitation); } @@ -396,8 +396,8 @@ export class SharingManagement { await this.apiService.deleteInvitation(invitationUid); } - private async inviteExternalUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { - const inviter = await this.nodesService.getRootNodeEmailKey(share.volumeId); + private async inviteExternalUser(nodeUid: string, share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { + const inviter = await this.nodesService.getRootNodeEmailKey(nodeUid); const invitationCrypto = await this.cryptoService.encryptExternalInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); const encryptedInvitation = await this.apiService.inviteExternalUser(share.shareId, { From 182225fabaa883d20f2ccc411adb31ef27b7d0fa Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Jun 2025 09:36:26 +0000 Subject: [PATCH 121/791] Bump to js/v0.0.8 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 6960e804..2202527d 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@proton/drive-sdk", - "version": "0.0.7", + "version": "0.0.8", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 921aa89d3ceed8ac0f88c3fe3a031e825e5a9c92 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Jun 2025 08:42:03 +0000 Subject: [PATCH 122/791] Create type structure for keys --- js/sdk/jest.config.js | 2 +- js/sdk/jest.transform.js | 4 - js/sdk/package-lock.json | 274 +++++++++++++++++- js/sdk/package.json | 4 +- js/sdk/src/crypto/interface.ts | 54 +++- js/sdk/src/crypto/openPGPCrypto.ts | 4 +- .../internal/download/fileDownloader.test.ts | 4 +- .../src/internal/nodes/cryptoService.test.ts | 12 +- js/sdk/src/internal/nodes/cryptoService.ts | 4 +- js/sdk/src/internal/nodes/index.test.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 4 +- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- js/sdk/src/internal/upload/manager.test.ts | 4 +- js/sdk/tsconfig.json | 2 +- 14 files changed, 338 insertions(+), 38 deletions(-) delete mode 100644 js/sdk/jest.transform.js diff --git a/js/sdk/jest.config.js b/js/sdk/jest.config.js index 3de7f151..74b32032 100644 --- a/js/sdk/jest.config.js +++ b/js/sdk/jest.config.js @@ -4,7 +4,7 @@ module.exports = { collectCoverage: false, transformIgnorePatterns: [], transform: { - '^.+\\.(m?js|tsx?)$': '/jest.transform.js', + '^.+\\.(t|j)sx?$': '@swc/jest', }, moduleNameMapper: {}, reporters: ['default'], diff --git a/js/sdk/jest.transform.js b/js/sdk/jest.transform.js deleted file mode 100644 index 997b831d..00000000 --- a/js/sdk/jest.transform.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = require('babel-jest').default.createTransformer({ - presets: ['@babel/preset-env', '@babel/preset-typescript'], - plugins: [], -}); diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index d8b5e839..cc073db8 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { - "name": "proton-drive-sdk", - "version": "0.0.1", + "name": "@proton/drive-sdk", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "proton-drive-sdk", - "version": "0.0.1", + "name": "@proton/drive-sdk", + "version": "0.0.8", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", @@ -16,6 +16,8 @@ "devDependencies": { "@babel/preset-env": "^7.26.0", "@babel/preset-typescript": "^7.26.0", + "@swc/core": "^1.12.3", + "@swc/jest": "^0.2.38", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^8.19.1", @@ -2596,6 +2598,19 @@ } } }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -3145,6 +3160,250 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@swc/core": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.3.tgz", + "integrity": "sha512-c4NeXW8P3gPqcFwtm+4aH+F2Cj5KJLMiLaKhSj3mpv19glq+jmekomdktAw/VHyjsXlsmouOeNWrk8rVlkCRsg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.23" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.12.3", + "@swc/core-darwin-x64": "1.12.3", + "@swc/core-linux-arm-gnueabihf": "1.12.3", + "@swc/core-linux-arm64-gnu": "1.12.3", + "@swc/core-linux-arm64-musl": "1.12.3", + "@swc/core-linux-x64-gnu": "1.12.3", + "@swc/core-linux-x64-musl": "1.12.3", + "@swc/core-win32-arm64-msvc": "1.12.3", + "@swc/core-win32-ia32-msvc": "1.12.3", + "@swc/core-win32-x64-msvc": "1.12.3" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.3.tgz", + "integrity": "sha512-QCV9vQ/s27AMxm8j8MTDL/nDoiEMrANiENRrWnb0Fxvz/O39CajPVShp/W7HlOkzt1GYtUXPdQJpSKylugfrWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.12.3.tgz", + "integrity": "sha512-LylCMfzGhdvl5tyKaTT9ePetHUX7wSsST7hxWiHzS+cUMj7FnhcfdEr6kcNVT7y1RJn3fCvuv7T98ZB+T2q3HA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.3.tgz", + "integrity": "sha512-DQODb7S+q+pwQY41Azcavwb2rb4rGxP70niScRDxB9X68hHOM9D0w9fxzC+Nr3AHcPSmVJUYUIiq5h38O5hVgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.3.tgz", + "integrity": "sha512-nTxtJSq78AjeaQBueYImoFBs5j7qXbgOxtirpyt8jE29NQBd0VFzDzRBhkr6I9jq0hNiChgMkqBN4eUkEQjytg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.3.tgz", + "integrity": "sha512-lBGvC5UgPSxqLr/y1NZxQhyRQ7nXy3/Ec1Z47YNXtqtpKiG1EcOGPyS0UZgwiYQkXqq8NBFMHnyHmpKnXTvRDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.3.tgz", + "integrity": "sha512-61wZ8hwxNYzBY9MCWB50v90ICzdIhOuPk1O1qXswz9AXw5O6iQStEBHQ1rozPkfQ/rmhepk0pOf/6LCwssJOwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.3.tgz", + "integrity": "sha512-NNeBiTpCgWt80vumTKVoaj6Fa/ZjUcaNQNM7np3PIgB8EbuXfyztboV7vUxpkmD/lUgsk8GlEFYViHvo6VMefQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.3.tgz", + "integrity": "sha512-fxraM7exaPb1/W0CoHW45EFNOQUQh0nonBEcNFm2iv095mziBwttyxZyQBoDkQocpkd5NtsZw3xW5FTBPnn+Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.3.tgz", + "integrity": "sha512-FFIhMPXIDjRcewomwbYGPvem7Fj76AsuzbRahnAyp+OzJwrrtxVmra/kyUCfj4kix7vdGByY0WvVfiVCf5b7Mg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.3.tgz", + "integrity": "sha512-Sf4iSg+IYT5AzFSDDmii08DfeKcvtkVxIuo+uS8BJMbiLjFNjgMkkVlBthknGyJcSK15ncg9248XjnM4jU8DZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/jest": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.38.tgz", + "integrity": "sha512-HMoZgXWMqChJwffdDjvplH53g9G2ALQes3HKXDEdliB/b85OQ0CTSbxG8VSeCwiAn7cOaDVEt4mwmZvbHcS52w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, + "node_modules/@swc/types": { + "version": "0.1.23", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz", + "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@types/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", @@ -6710,6 +6969,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", diff --git a/js/sdk/package.json b/js/sdk/package.json index 2202527d..6d00de47 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -28,8 +28,8 @@ "ttag": "^1.8.7" }, "devDependencies": { - "@babel/preset-env": "^7.26.0", - "@babel/preset-typescript": "^7.26.0", + "@swc/core": "^1.12.3", + "@swc/jest": "^0.2.38", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^8.19.1", diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 9a8c2276..e3bbf7b4 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -1,10 +1,48 @@ // TODO: Use CryptoProxy once available. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type PublicKey = object; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface PrivateKey extends PublicKey {}; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type SessionKey = any; +export interface PublicKey { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly _idx: any; + readonly _keyContentHash: [string, string]; + + getVersion(): number; + getFingerprint(): string; + getSHA256Fingerprints(): string[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKeyID(): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKeyIDs(): any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAlgorithmInfo(): any; + getCreationTime(): Date; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isPrivate: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isPrivateKeyV4: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isPrivateKeyV6: any; + getExpirationTime(): Date | number | null; + getUserIDs(): string[]; + isWeak(): boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + equals(otherKey: any, ignoreOtherCerts?: boolean): boolean; + subkeys: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAlgorithmInfo(): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKeyID(): any; + }[]; +} + +export interface PrivateKey extends PublicKey { + readonly _dummyType: 'private'; +} +export interface SessionKey { + data: Uint8Array; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + algorithm: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aeadAlgorithm?: any; +} export enum VERIFICATION_STATUS { NOT_SIGNED = 0, @@ -66,7 +104,7 @@ export interface OpenPGPCrypto { encryptAndSignArmored: ( data: Uint8Array, - sessionKey: SessionKey, + sessionKey: SessionKey | undefined, encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) => Promise<{ @@ -103,7 +141,7 @@ export interface OpenPGPCrypto { signArmored: ( data: Uint8Array, - signingKey: PrivateKey, + signingKey: PrivateKey | PrivateKey[], ) => Promise<{ signature: string, }>, diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 1c63689d..cd0b4c95 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -10,7 +10,7 @@ export interface OpenPGPCryptoProxy { exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string | null }) => Promise, importPrivateKey: (options: { armoredKey: string, passphrase: string | null }) => Promise, generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, - encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys: PublicKey[] }) => Promise, + encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys: PublicKey | PublicKey[] }) => Promise, decryptSessionKey: (options: { armoredMessage?: string, binaryMessage?: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[] }) => Promise, encryptMessage: (options: { format?: 'armored' | 'binary', @@ -142,7 +142,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async encryptAndSignArmored( data: Uint8Array, - sessionKey: SessionKey, + sessionKey: SessionKey | undefined, encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) { diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index 24e9fe00..9d6ee82c 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -142,7 +142,7 @@ describe('FileDownloader', () => { stream = { getWriter: () => writer, } - downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, revision, undefined, onFinish); + downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey as any, revision, undefined, onFinish); }); it('should reject two download starts', async () => { @@ -316,7 +316,7 @@ describe('FileDownloader', () => { stream = { getWriter: () => writer, } - downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey, revision, undefined, onFinish); + downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey as any, revision, undefined, onFinish); }); it('should skip verification steps', async () => { diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index e17faa6d..f86b7278 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -16,7 +16,6 @@ describe("nodesCryptoService", () => { jest.clearAllMocks(); telemetry = getMockTelemetry(); - // @ts-expect-error No need to implement all methods for mocking driveCrypto = { decryptKey: jest.fn(async () => Promise.resolve({ passphrase: "pass", @@ -39,13 +38,14 @@ describe("nodesCryptoService", () => { encryptNodeName: jest.fn(async () => Promise.resolve({ armoredNodeName: "armoredName", })), + // @ts-expect-error No need to implement all methods for mocking decryptAndVerifySessionKey: jest.fn(async () => Promise.resolve({ sessionKey: "contentKeyPacketSessionKey", verified: VERIFICATION_STATUS.SIGNED_AND_VALID, })), }; - // @ts-expect-error No need to implement all methods for mocking account = { + // @ts-expect-error No need to implement all methods for mocking getPublicKeys: jest.fn(async () => [{_idx: 21312}]), }; // @ts-expect-error No need to implement all methods for mocking @@ -493,7 +493,7 @@ describe("nodesCryptoService", () => { driveCrypto.decryptAndVerifySessionKey = jest.fn(async () => Promise.resolve({ sessionKey: "contentKeyPacketSessionKey", verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + }) as any); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { @@ -738,7 +738,7 @@ describe("nodesCryptoService", () => { armoredPassphraseSignature: 'passphraseSignature', }); - const result = await cryptoService.moveNode(node, keys, parentKeys, address); + const result = await cryptoService.moveNode(node, keys as any, parentKeys, address); expect(result).toEqual({ encryptedName: 'encryptedNodeName', @@ -785,7 +785,7 @@ describe("nodesCryptoService", () => { addressKey: 'addressKey' as any, }; - await expect(cryptoService.moveNode(node, keys, parentKeys, address)) + await expect(cryptoService.moveNode(node, keys as any, parentKeys, address)) .rejects.toThrow('Moving item to a non-folder is not allowed'); }); @@ -807,7 +807,7 @@ describe("nodesCryptoService", () => { addressKey: 'addressKey' as any, }; - await expect(cryptoService.moveNode(node, keys, parentKeys, address)) + await expect(cryptoService.moveNode(node, keys as any, parentKeys, address)) .rejects.toThrow('Cannot move item without a valid name, please rename the item first'); }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 6ca569da..8a3ac293 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -14,7 +14,7 @@ import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, Decryp * metadata. It should export high-level actions only, such as "decrypt node" * instead of low-level operations like "decrypt node key". Low-level operations * should be kept private to the module. - * + * * The service owns the logic to switch between old and new crypto model. */ export class NodesCryptoService { @@ -235,7 +235,7 @@ export class NodesCryptoService { }; }; - private async decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PrivateKey[]): Promise<{ + private async decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PublicKey[]): Promise<{ name: Result, author: Author, }> { diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index b13b5e9c..4b8dad5c 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -101,7 +101,7 @@ describe('nodesModules integration tests', () => { }], }; }); - jest.spyOn(nodesModule.access, 'getParentKeys').mockResolvedValue({key: {_idx: 32131}}); + jest.spyOn(nodesModule.access, 'getParentKeys').mockResolvedValue({ key: { _idx: 32131 } } as any); // Verify the inital state before move event is sent. const originalBeforeMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(originalFolderUid)); diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 9e2fb0c2..6731f957 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -413,8 +413,8 @@ describe('nodesAccess', () => { } return { key: {_idx: 32132}, - }; - }); + } as any; + } ); const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3'])); expect(result).toEqual([ diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 202230da..e7f7f82c 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -306,7 +306,7 @@ export class NodesAccess { key: PrivateKey, passphrase: string, passphraseSessionKey: SessionKey, - contentKeyPacketSessionKey: SessionKey, + contentKeyPacketSessionKey?: SessionKey, nameSessionKey: SessionKey, }> { const node = await this.getNode(nodeUid); diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index def828e7..820d07e5 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -323,7 +323,7 @@ describe("UploadManager", () => { it("should commit revision draft", async () => { await manager.commitDraft( - nodeRevisionDraft, + nodeRevisionDraft as any, manifest, metadata, extendedAttributes, @@ -363,7 +363,7 @@ describe("UploadManager", () => { } } await manager.commitDraft( - nodeRevisionDraftWithNewNodeInfo, + nodeRevisionDraftWithNewNodeInfo as any, manifest, metadata, extendedAttributes, diff --git a/js/sdk/tsconfig.json b/js/sdk/tsconfig.json index 65afe414..5ec6422c 100644 --- a/js/sdk/tsconfig.json +++ b/js/sdk/tsconfig.json @@ -28,4 +28,4 @@ "**/coreTypes.ts", "**/driveTypes.ts" ], -} \ No newline at end of file +} From 912a0c6b2689915885385440320a6287dafe1abd Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Jun 2025 12:09:45 +0000 Subject: [PATCH 123/791] L10N-4186 Add test/extract job ttag --- js/sdk/package-lock.json | 2733 +++++++++++++++-- js/sdk/package.json | 6 +- js/sdk/src/internal/download/cryptoService.ts | 6 +- js/sdk/src/internal/nodes/cryptoService.ts | 9 +- js/sdk/src/internal/sharing/cryptoService.ts | 3 +- js/sdk/tasks/linter.mjs | 200 ++ 6 files changed, 2707 insertions(+), 250 deletions(-) create mode 100644 js/sdk/tasks/linter.mjs diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index cc073db8..fe64cfc7 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -14,8 +14,6 @@ "ttag": "^1.8.7" }, "devDependencies": { - "@babel/preset-env": "^7.26.0", - "@babel/preset-typescript": "^7.26.0", "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", "@types/jest": "^29.5.14", @@ -24,9 +22,11 @@ "@web/dev-server-esbuild": "^1.0.3", "eslint": "^8.57.1", "eslint-plugin-tsdoc": "^0.3.0", + "glob": "^11.0.3", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", "prettier": "^3.4.2", + "ttag-cli": "^1.10.18", "typedoc": "^0.26.11", "typescript": "^5.6.3" } @@ -46,15 +46,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "devOptional": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -102,14 +102,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", - "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", + "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -119,13 +119,13 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -149,18 +149,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz", - "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.26.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.26.9", + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", "semver": "^6.3.1" }, "engines": { @@ -206,28 +206,28 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", - "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -252,22 +252,22 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", - "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", "engines": { @@ -293,15 +293,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", - "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.26.5" + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -311,23 +311,23 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", - "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -335,19 +335,19 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -384,13 +384,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -483,6 +483,112 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.27.1.tgz", + "integrity": "sha512-DTxe4LBPrtFdsWzgpmbBKevg3e9PBy+dXRt19kSbucbZvL2uqtdqwwpluL1jfxYE0wIDTFp1nTy/q6gNLsxXrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-decorators": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", + "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", + "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.6.0.tgz", + "integrity": "sha512-kj4gkZ6qUggkprRq3Uh5KP8XnE1MdIO0J7MhdDX8+rAbB6dJ2UrensGIS+0NPZAaaJ1Vr0PN6oLUgXMU1uMcSg==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-optional-chaining": "^7.2.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", @@ -551,6 +657,51 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", + "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", + "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", @@ -610,13 +761,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", - "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1038,6 +1189,23 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", @@ -1407,6 +1575,75 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.27.1.tgz", + "integrity": "sha512-p9+Vl3yuHPmkirRrg021XiP+EETmPMQTLr6Ayjj85RLNEbb3Eya/4VI0vAdzQG9SEAl2Lnt7fy5lZyMzjYoZQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-regenerator": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", @@ -1709,6 +1946,24 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-flow": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz", + "integrity": "sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-flow-strip-types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -1724,18 +1979,19 @@ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@babel/preset-typescript": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", - "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", + "node_modules/@babel/preset-react": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz", + "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-syntax-jsx": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.25.9", - "@babel/plugin-transform-typescript": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.27.1", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1748,7 +2004,7 @@ "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -1758,32 +2014,32 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", - "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1792,14 +2048,14 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -2415,57 +2671,183 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": "20 || >=22" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "dev": true, "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, "bin": { "js-yaml": "bin/js-yaml.js" } @@ -2732,6 +3114,52 @@ } } }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -3526,6 +3954,16 @@ "@types/send": "*" } }, + "node_modules/@types/formidable": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-1.2.8.tgz", + "integrity": "sha512-6psvrUy5VDYb+yaPJReF1WrRsz+FBwyJutK9Twz1Efa27tm07bARNIkK2B8ZPWq80dXqpKfrxTO96xrtPp+AuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3666,6 +4104,13 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/parse5": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", @@ -4036,6 +4481,19 @@ "node": ">=18.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -4195,6 +4653,21 @@ "@babel/core": "^7.8.0" } }, + "node_modules/babel-plugin-const-enum": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-const-enum/-/babel-plugin-const-enum-1.2.0.tgz", + "integrity": "sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-typescript": "^7.3.3", + "@babel/traverse": "^7.16.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -4245,6 +4718,23 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", @@ -4287,6 +4777,122 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-ttag": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/babel-plugin-ttag/-/babel-plugin-ttag-1.8.16.tgz", + "integrity": "sha512-UmA4KAvg3K1nzTBaqWox945CS3C1zjJu6lGZjmbOYW3NO2ps6mlIm8fnj9wjzNm2Y2nzUuD73aiAK9Rd3vTZgQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@babel/generator": "^7.12.5", + "@babel/template": "^7.10.4", + "@babel/types": "^7.12.6", + "ajv": "6.12.3", + "babel-plugin-macros": "^2.8.0", + "dedent": "1.5.1", + "gettext-parser": "6.0.0", + "mkdirp": "^1.0.4", + "plural-forms": "^0.5.3" + } + }, + "node_modules/babel-plugin-ttag/node_modules/ajv": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz", + "integrity": "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/babel-plugin-ttag/node_modules/babel-plugin-macros": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", + "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "cosmiconfig": "^6.0.0", + "resolve": "^1.12.0" + } + }, + "node_modules/babel-plugin-ttag/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-ttag/node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/babel-plugin-ttag/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-ttag/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/babel-preset-const-enum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-preset-const-enum/-/babel-preset-const-enum-1.0.0.tgz", + "integrity": "sha512-DHfcv3mkgIqPaFODzig3Esb91cCqZlnImSl7VAwJDtIsqJvx4H08kpl051um68gjqnAXg5up5nnn6NK+Cq0blA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "babel-plugin-const-enum": "^1.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", @@ -4338,9 +4944,30 @@ "dev": true, "license": "MIT" }, - "node_modules/bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, @@ -4410,6 +5037,31 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4417,6 +5069,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cache-content-type": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", @@ -4466,7 +5128,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -4609,6 +5271,29 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-1.3.1.tgz", + "integrity": "sha512-1QL4544moEsDVH9T/l6Cemov/37iv1RtoKf7NJ04A60+4MREXNfx/QvavbH6QoGdsD4N4Mwy49cmaINR/o2mdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4645,6 +5330,19 @@ "node": ">= 0.12.0" } }, + "node_modules/co-body": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-5.2.0.tgz", + "integrity": "sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inflation": "^2.0.0", + "qs": "^6.4.0", + "raw-body": "^2.2.0", + "type-is": "^1.6.14" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -4755,6 +5453,35 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4810,6 +5537,16 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -4949,6 +5686,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5000,6 +5744,16 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -5017,7 +5771,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -5353,6 +6107,13 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5373,6 +6134,26 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5559,6 +6340,54 @@ "dev": true, "license": "ISC" }, + "node_modules/foreachasync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", + "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==", + "dev": true, + "license": "Apache2" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formidable": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", + "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", + "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -5595,7 +6424,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5683,23 +6512,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gettext-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-6.0.0.tgz", + "integrity": "sha512-eWFsR78gc/eKnzDgc919Us3cbxQbzxK1L8vAIZrKMQqOUgULyeqmczNlBjTlVTk2FaB7nV9C1oobd/PGBOqNmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.4", + "encoding": "^0.1.13", + "readable-stream": "^4.1.0", + "safe-buffer": "^5.2.1" + } + }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5718,28 +6562,20 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": "*" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globals": { @@ -5779,6 +6615,29 @@ "dev": true, "license": "MIT" }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5822,7 +6681,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5952,6 +6811,50 @@ "node": ">=10.17.0" } }, + "node_modules/hunspell-spellchecker": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hunspell-spellchecker/-/hunspell-spellchecker-1.0.2.tgz", + "integrity": "sha512-4DwmFAvlz+ChsqLDsZT2cwBsYNXh+oWboemxXtafwKIyItq52xfR4e4kr017sLAoPaSYVofSOvPUfmOAhXyYvw==", + "dev": true, + "license": "Apache 2", + "bin": { + "hunspell-tojson": "bin/hunspell-tojson.js" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5966,7 +6869,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -6022,6 +6925,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflation": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz", + "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6045,14 +6958,14 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -6178,6 +7091,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/isbinaryfile": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.4.tgz", @@ -6282,7 +7205,23 @@ "node": ">=8" } }, - "node_modules/jest": { + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", @@ -6436,6 +7375,52 @@ } } }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -6732,6 +7717,52 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", @@ -6899,7 +7930,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -6939,7 +7970,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -7044,6 +8075,18 @@ "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" } }, + "node_modules/koa-body": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/koa-body/-/koa-body-4.2.0.tgz", + "integrity": "sha512-wdGu7b9amk4Fnk/ytH8GuWwfs4fsB5iNkY8kZPpgQVb04QZSv85T0M8reb+cJmvLE8cjPYvBzRikD3s6qz8OoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/formidable": "^1.0.31", + "co-body": "^5.1.1", + "formidable": "^1.1.1" + } + }, "node_modules/koa-compose": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", @@ -7075,6 +8118,24 @@ "etag": "^1.8.1" } }, + "node_modules/koa-router": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/koa-router/-/koa-router-9.4.0.tgz", + "integrity": "sha512-RO/Y8XqSNM2J5vQeDaBI/7iRpL50C9QEudY4d3T4D1A2VMKLH0swmfjxDFPiIpVDLuNN6mVD9zBI1eFTHB6QaA==", + "deprecated": "**IMPORTANT 10x+ PERFORMANCE UPGRADE**: Please upgrade to v12.0.1+ as we have fixed an issue with debuglog causing 10x slower router benchmark performance, see https://github.com/koajs/router/pull/173", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "koa-compose": "^4.1.0", + "methods": "^1.1.2", + "path-to-regexp": "^6.1.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/koa-send": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", @@ -7142,7 +8203,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/linkify-it": { @@ -7178,6 +8239,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mapvalues": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz", + "integrity": "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7185,6 +8253,89 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", + "integrity": "sha512-mmPrW0Fh2fxOzdBbFv4g1m6pR72haFLPJ2G5SJEELf1y+iaQrDG6cWCPjy54RHYbZAt7X+ls690Kw62AdWXBzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7325,6 +8476,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -7482,6 +8643,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7506,6 +8700,27 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7543,6 +8758,19 @@ "node": ">=8" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7600,6 +8828,19 @@ "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==", "dev": true }, + "node_modules/open": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/openapi-typescript": { "version": "7.6.1", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.6.1.tgz", @@ -7683,53 +8924,156 @@ "node": ">= 0.8.0" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/ora": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-1.3.0.tgz", + "integrity": "sha512-6DFzEwRJxz7o/0K+7ecOLwSaWT5M0xuvb+1knfQbyi+GFk4O9bMX9NdDizLaORMcEy8kZyu3OjYNFItRa4MNOw==", "dev": true, "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "chalk": "^1.1.1", + "cli-cursor": "^2.1.0", + "cli-spinners": "^1.0.0", + "log-symbols": "^1.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/ora/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", "dev": true, "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/ora/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/ora/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -7742,7 +9086,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -7808,14 +9152,58 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -7980,6 +9368,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8005,6 +9403,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "dev": true, + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8042,6 +9447,22 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8063,6 +9484,72 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8070,6 +9557,23 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -8084,6 +9588,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -8108,7 +9622,7 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/regenerator-transform": { @@ -8220,11 +9734,18 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -8268,7 +9789,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -8338,6 +9859,43 @@ "node": ">=10" } }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -8366,44 +9924,90 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, { "type": "consulting", "url": "https://feross.org/support" @@ -8429,6 +10033,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8439,6 +10050,23 @@ "semver": "bin/semver.js" } }, + "node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -8486,6 +10114,82 @@ "@types/hast": "^3.0.4" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8582,6 +10286,16 @@ "node": ">= 0.6" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -8611,6 +10325,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -8639,6 +10369,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -8689,7 +10433,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8698,6 +10442,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svelte": { + "version": "3.59.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz", + "integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -8724,6 +10478,28 @@ "concat-map": "0.0.1" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8744,6 +10520,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -8774,6 +10563,13 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -8818,6 +10614,384 @@ "plural-forms": "^0.5.3" } }, + "node_modules/ttag-cli": { + "version": "1.10.18", + "resolved": "https://registry.npmjs.org/ttag-cli/-/ttag-cli-1.10.18.tgz", + "integrity": "sha512-VSS59YPUP5jGTHVzqOQC1Hxt/02y8hc/O0lsBnLoPSACcyGg7e/wDT7DEFvoSEfgQ8JCov4ZjcGVXTPxC8d2Hg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/generator": "^7.12.5", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-decorators": "^7.12.1", + "@babel/plugin-proposal-export-default-from": "^7.12.1", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/plugin-proposal-optional-chaining": "7.6.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/preset-env": "^7.12.1", + "@babel/preset-flow": "^7.12.1", + "@babel/preset-react": "^7.12.5", + "@babel/preset-typescript": "7.7.0", + "@babel/template": "^7.10.4", + "babel-plugin-ttag": "1.8.16", + "babel-preset-const-enum": "^1.0.0", + "chalk": "^2.4.2", + "cross-spawn": "^5.1.0", + "estree-walker": "^2.0.1", + "gettext-parser": "^6.0.0", + "hunspell-spellchecker": "^1.0.2", + "ignore": "^5.1.8", + "koa": "^2.13.0", + "koa-body": "^4.2.0", + "koa-router": "^9.1.0", + "mkdirp": "^0.5.1", + "node-fetch": "^2.6.1", + "open": "^6.4.0", + "ora": "1.3.0", + "plural-forms": "0.5.3", + "readline-sync": "^1.4.7", + "serialize-javascript": "^4.0.0", + "svelte": "^3.20.1", + "tmp": "0.0.33", + "vue-sfc-parser": "^0.1.2", + "walk": "2.3.9", + "yargs": "^15.4.1" + }, + "bin": { + "ttag": "bin/ttag" + } + }, + "node_modules/ttag-cli/node_modules/@babel/preset-typescript": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.7.0.tgz", + "integrity": "sha512-WZ3qvtAJy8w/i6wqq5PuDnkCUXaLUTHIlJujfGHmHxsT5veAbEdEjl3cC/3nXfyD0bzlWsIiMdUhZgrXjd9QWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-transform-typescript": "^7.7.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/ttag-cli/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ttag-cli/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ttag-cli/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/ttag-cli/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ttag-cli/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ttag-cli/node_modules/cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "node_modules/ttag-cli/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ttag-cli/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ttag-cli/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/ttag-cli/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ttag-cli/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/plural-forms": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/plural-forms/-/plural-forms-0.5.3.tgz", + "integrity": "sha512-t/hkjsTeDwaK9n/z6tUiSHySTC8sPnTiS5YF3Y5p4L+eomzXh7O0vEemkjwb68/82w0Rjw4uED3X84X7vXf9lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ttag-cli/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ttag-cli/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ttag-cli/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ttag-cli/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/ttag-cli/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ttag-cli/node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ttag-cli/node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ttag-cli/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ttag-cli/node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/ttag-cli/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/ttag/node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -9077,6 +11251,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", @@ -9180,6 +11364,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vue-sfc-parser": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/vue-sfc-parser/-/vue-sfc-parser-0.1.2.tgz", + "integrity": "sha512-fvYu4i5oxK4J25qYblmsotMINSY0KhP1LW/ElKaMin4CXQ2UqyjeUgAZaE2Zs1zYpIKGoEuMjEY+lmBghls1WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.mapvalues": "^4.6.0" + } + }, + "node_modules/walk": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.9.tgz", + "integrity": "sha512-nEvC/LocusNlMqpnY76juQYCx7H/ZNhtEQ3qTI+Kbh9zw8nc8jp5v0C3g+V1RNTH7TXrp4YC8qtzk6FN03+lMg==", + "dev": true, + "dependencies": { + "foreachasync": "^3.0.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -9190,6 +11393,24 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9206,6 +11427,13 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9234,6 +11462,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/js/sdk/package.json b/js/sdk/package.json index 6d00de47..76463138 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -20,7 +20,9 @@ "pretty": "prettier --write $(find src -type f -name '*.ts')", "test": "jest", "test:ci": "jest --runInBand --no-cache", - "test:watch": "jest --watch --coverage=false" + "test:watch": "jest --watch --coverage=false", + "lint:ttag": "node tasks/linter.mjs src --verbose", + "extract:ttag": "ttag extract src --output po/template.pot" }, "dependencies": { "@noble/hashes": "^1.8.0", @@ -36,9 +38,11 @@ "@web/dev-server-esbuild": "^1.0.3", "eslint": "^8.57.1", "eslint-plugin-tsdoc": "^0.3.0", + "glob": "^11.0.3", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", "prettier": "^3.4.2", + "ttag-cli": "^1.10.18", "typedoc": "^0.26.11", "typescript": "^5.6.3" }, diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index 4b2e7691..385952cb 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -39,7 +39,8 @@ export class DownloadCryptoService { ); decryptedBlock = result.decryptedBlock; } catch (error: unknown) { - throw new DecryptionError(c('Error').t`Failed to decrypt block: ${getErrorMessage(error)}`); + const message = getErrorMessage(error); + throw new DecryptionError(c('Error').t`Failed to decrypt block: ${message}`); } return decryptedBlock; @@ -55,7 +56,8 @@ export class DownloadCryptoService { ); decryptedBlock = result.decryptedThumbnail; } catch (error: unknown) { - throw new DecryptionError(c('Error').t`Failed to decrypt thumbnail: ${getErrorMessage(error)}`); + const message = getErrorMessage(error); + throw new DecryptionError(c('Error').t`Failed to decrypt thumbnail: ${message}`); } return decryptedBlock; diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 8a3ac293..99d99238 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -78,7 +78,8 @@ export class NodesCryptoService { keyAuthor = keyResult.author; } catch (error: unknown) { void this.reportDecryptionError(node, 'nodeKey', error); - const errorMessage = c('Error').t`Failed to decrypt node key: ${getErrorMessage(error)}`; + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt node key: ${message}`; return { node: { ...commonNodeMetadata, @@ -142,7 +143,8 @@ export class NodesCryptoService { activeRevision = resultOk(await this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key)); } catch (error: unknown) { void this.reportDecryptionError(node, 'nodeActiveRevision', error); - const errorMessage = c('Error').t`Failed to decrypt active revision: ${getErrorMessage(error)}`; + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt active revision: ${message}`; activeRevision = resultError(new Error(errorMessage)); } @@ -166,7 +168,8 @@ export class NodesCryptoService { ); } catch (error: unknown) { void this.reportDecryptionError(node, 'nodeContentKey', error); - const errorMessage = c('Error').t`Failed to decrypt content key: ${getErrorMessage(error)}`; + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt content key: ${message}`; contentKeyPacketAuthor = resultError({ claimedAuthor: node.encryptedCrypto.signatureEmail, error: errorMessage, diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 835e434a..f930e957 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -173,7 +173,8 @@ export class SharingCryptoService { ); nodeName = resultOk(result.name); } catch (error: unknown) { - const errorMessage = c('Error').t`Failed to decrypt item name: ${getErrorMessage(error)}`; + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt item name: ${message}`; nodeName = resultError(new Error(errorMessage)); } diff --git a/js/sdk/tasks/linter.mjs b/js/sdk/tasks/linter.mjs new file mode 100644 index 00000000..6bffe55f --- /dev/null +++ b/js/sdk/tasks/linter.mjs @@ -0,0 +1,200 @@ +/* + * Usage node linter.mjs [arg] + * - arg can be a directory or a single file (default src) + */ +import { readFile } from 'fs/promises'; +import { sync } from 'glob'; +import path from 'path'; + +/** + * @typedef {string} FilePath + * @typedef {'format' | 'usage' | 'backticks' | 'plurals'} BrokenRuleType + * @typedef {{ file:FilePath, line:string, match:string, index:int, type: BrokenRuleType}} BrokenRule + * @typedef { Generator} BrokenRulesIterator + * @typedef { AsyncGenerator} AsyncBrokenRulesIterator + */ + +/** + * Test a rule inside the code and see if we find lines matching it + * @param {RegExp} rule rule to test inside the whole content + * @param {string} content text file content + * @param {BrokenRuleType} type type of rule you filter + * @return {{ errors: BrokenRulesIterator, match: bool}} + */ +function testRule(rule, content, type) { + const matches = content.match(rule); + + /** + * @param {FilePath}file to lint + * @yields {BrokenRule} + */ + function* errors(file) { + if (!matches?.length) { + return; + } + + for (const [index, line] of content.split('\n').entries()) { + const done = new Set(); + for (const match of matches) { + const hasNewLine = match.includes('\n'); + // If multiline matches + const [, string] = match.split('\n'); + const toMatch = hasNewLine ? string : match; + const id = `${line}:${index}${match}`; + if (!line.includes(toMatch) || done.has(id)) { + continue; + } + done.add(id); + yield { + file, + line, + match, + string, + index, + type, + }; + } + } + } + + return { match: matches?.length > 0, errors }; +} + +/** + * Iterate over all your source files and see if we can find broken translations + * @param {string} source source to iterate over (directory or a single file) + * @param {{isVerbose: bool}} + * @returns {AsyncBrokenRulesIterator} + */ +async function* errorIterator(source = 'src', options = { isVerbose: false }) { + const { ext } = path.parse(source); + const files = ext + ? [source] + : sync(path.join(source, '**', '*.{js,jsx,ts,tsx}'), { + ignore: [path.join(source, 'node_modules', '**'), path.join(source, 'dist', '**')], + }); + + for (const file of files) { + if (file.endsWith('.d.ts') || file.includes('tests')) { + continue; + } + if (file.includes('stories')) { + continue; + } + + if (options.isVerbose) { + console.log('[lint]', file); + } + const content = await readFile(file, 'utf-8'); + + const errorsFormat = testRule(/c\(\x27.+\x27\)\.(t|c)\(/g, content, 'format'); + if (errorsFormat.match) { + yield* errorsFormat.errors(file); + } + + const errorsUsage = testRule(/c\(\x27.+\x27\)\.(c\x60|\x60)/g, content, 'usage'); + if (errorsUsage.match) { + yield* errorsUsage.errors(file); + } + + const errorsPlurals = testRule( + /c\(\x27.+\x27\)\.ngettext\(msgid(\x60|\().+(\x60|\)),\s(\x27|\x22)/g, + content, + 'plurals' + ); + if (errorsPlurals.match) { + yield* errorsPlurals.errors(file); + } + + // https://regex101.com/r/cT9edH/1 + const errorsBackticks = testRule(/(?).t() or c().c() + but c().t\`\` +`; + } + if (error.type === 'usage') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: You should not use - c().c\`\` or c().\`\` + but c().t\`\` +`; + } + if (error.type === 'backticks') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: You should not use backticks for the context definition. It is a static string + best to use c(\x27\x27).t\`\` +`; + } + + if (error.type === 'plurals') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: Plural form is - ngettext(msgid\`\`, \`\`, value) +`; + } + + if (error.type === 'newlines') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: Unexpected newline inside the string. +`; + } + + if (error.type === 'numbers') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: Do not translate a string without anything else than numbers and/or spaces. +`; + } +} + +async function main() { + const [, , source] = process.argv; + const isVerbose = process.argv.includes('--verbose'); + let total = 0; + for await (const error of errorIterator(source, { isVerbose })) { + total++; + console.log(formatErrors(error)); + } + + total && console.log(`Found ${total} error(s)`); + + // If total => it means we have error, exit with code 1 + process.exit(+!!total); +} +main(); From ded832d9d62521365b4eac5d313e2954b3c0cda3 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Jun 2025 13:46:48 +0000 Subject: [PATCH 124/791] Update CLI parser --- cs/Directory.Build.props | 2 ++ cs/Directory.Packages.props | 30 +++++++++++++++++++++++++++++ cs/sdk/src/Directory.Packages.props | 18 ----------------- 3 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 cs/Directory.Packages.props delete mode 100644 cs/sdk/src/Directory.Packages.props diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index b2ea5f4f..3f5ede72 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -19,7 +19,9 @@ en true + + false lib diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props new file mode 100644 index 00000000..3a0c26f1 --- /dev/null +++ b/cs/Directory.Packages.props @@ -0,0 +1,30 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cs/sdk/src/Directory.Packages.props b/cs/sdk/src/Directory.Packages.props deleted file mode 100644 index eca292da..00000000 --- a/cs/sdk/src/Directory.Packages.props +++ /dev/null @@ -1,18 +0,0 @@ - - - true - - - - - - - - - - - - - - - \ No newline at end of file From 6665bec93bdce4c75ff95cb315f03154111b1319 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Jun 2025 16:29:58 +0200 Subject: [PATCH 125/791] Update decryption telemetry according to documentation --- js/sdk/src/interface/telemetry.ts | 3 +-- js/sdk/src/internal/nodes/cryptoService.test.ts | 6 +++--- js/sdk/src/internal/nodes/cryptoService.ts | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 5d71c8c9..febf97a9 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -70,8 +70,7 @@ export type MetricsDecryptionErrorField = 'nodeKey' | 'nodeName' | 'nodeHashKey' | - 'nodeFolderExtendedAttributes' | - 'nodeActiveRevision' | + 'nodeExtendedAttributes' | 'nodeContentKey' | 'content'; diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index f86b7278..f19320a5 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -46,7 +46,7 @@ describe("nodesCryptoService", () => { }; account = { // @ts-expect-error No need to implement all methods for mocking - getPublicKeys: jest.fn(async () => [{_idx: 21312}]), + getPublicKeys: jest.fn(async () => [{ _idx: 21312 }]), }; // @ts-expect-error No need to implement all methods for mocking sharesService = { @@ -307,7 +307,7 @@ describe("nodesCryptoService", () => { errors: [error], }, 'noKeys'); verifyLogEventDecryptionError({ - field: 'nodeFolderExtendedAttributes', + field: 'nodeExtendedAttributes', error, }); }); @@ -547,7 +547,7 @@ describe("nodesCryptoService", () => { activeRevision: { ok: false, error: new Error('Failed to decrypt active revision: Decryption error') }, }); verifyLogEventDecryptionError({ - field: 'nodeActiveRevision', + field: 'nodeExtendedAttributes', error, }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 99d99238..9b16c56e 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -130,7 +130,7 @@ export class NodesCryptoService { }; folderExtendedAttributesAuthor = extendedAttributesResult.author; } catch (error: unknown) { - void this.reportDecryptionError(node, 'nodeFolderExtendedAttributes', error); + void this.reportDecryptionError(node, 'nodeExtendedAttributes', error); errors.push(error); } } @@ -142,8 +142,8 @@ export class NodesCryptoService { try { activeRevision = resultOk(await this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key)); } catch (error: unknown) { - void this.reportDecryptionError(node, 'nodeActiveRevision', error); - const message = getErrorMessage(error); + void this.reportDecryptionError(node, 'nodeExtendedAttributes', error); + const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt active revision: ${message}`; activeRevision = resultError(new Error(errorMessage)); } From 2b908b6066e40ef25ff978dbbf949e7cc2360b0b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Jun 2025 08:43:48 +0200 Subject: [PATCH 126/791] implement getNodeUid --- js/sdk/src/protonDriveClient.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index dec7cdc5..a8d7e2af 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -37,7 +37,7 @@ import { getConfig } from './config'; import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator, convertInternalMissingNodeIterator, convertInternalNode } from './transformers'; import { Telemetry } from './telemetry'; import { initDevicesModule } from './internal/devices'; -import { splitNodeUid } from './internal/uids'; +import { makeNodeUid, splitNodeUid } from './internal/uids'; /** * ProtonDriveClient is the main interface for the ProtonDrive SDK. @@ -329,7 +329,8 @@ export class ProtonDriveClient { */ async getNodeUid(shareId: string, nodeId: string): Promise { this.logger.info(`Getting node UID for share ${shareId} and node ${nodeId}`); - throw new Error('Method not implemented'); + const share = await this.shares.loadEncryptedShare(shareId); + return makeNodeUid(share.volumeId, nodeId); } /** From 670d79f0eebc4147f9723accc4990ad7c6c77284 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Jun 2025 07:58:37 +0000 Subject: [PATCH 127/791] Add resend invite implementation --- .../sharing/sharingManagement.test.ts | 63 +++++++++++++++++++ .../src/internal/sharing/sharingManagement.ts | 23 +++++-- js/sdk/src/protonDriveClient.ts | 6 +- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 02943d87..94051e45 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -40,6 +40,8 @@ describe("SharingManagement", () => { getPublicLink: jest.fn().mockResolvedValue(undefined), removePublicLink: jest.fn(), deleteShare: jest.fn(), + resendInvitationEmail: jest.fn(), + resendExternalInvitationEmail: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking cryptoService = { @@ -659,4 +661,65 @@ describe("SharingManagement", () => { }); }); }); + + describe("resendInvitationEmail", () => { + const nodeUid = "volumeId~nodeUid"; + + const invitation: ProtonInvitation = { + uid: "invitation", + addedByEmail: resultOk("added-email"), + inviteeEmail: "internal-email", + role: MemberRole.Viewer, + invitationTime: new Date(), + }; + const externalInvitation: NonProtonInvitation = { + uid: "external-invitation", + addedByEmail: resultOk("added-email"), + inviteeEmail: "external-email", + role: MemberRole.Viewer, + invitationTime: new Date(), + state: NonProtonInvitationState.Pending, + }; + + beforeEach(() => { + apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]); + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); + apiService.getShareMembers = jest.fn().mockResolvedValue([]); + apiService.getPublicLink = jest.fn().mockResolvedValue(undefined); + }); + + it("should resend email for proton invitation", async () => { + await sharingManagement.resendInvitationEmail(nodeUid, invitation.uid); + + expect(apiService.resendInvitationEmail).toHaveBeenCalledWith(invitation.uid); + expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); + }); + + it("should resend email for external invitation", async () => { + await sharingManagement.resendInvitationEmail(nodeUid, externalInvitation.uid); + + expect(apiService.resendExternalInvitationEmail).toHaveBeenCalledWith(externalInvitation.uid); + expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); + }); + + it("should throw error when no sharing found for node", async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ nodeUid, shareId: undefined }); + + await expect( + sharingManagement.resendInvitationEmail(nodeUid, invitation.uid) + ).rejects.toThrow("Node is not shared"); + + expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); + expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); + }); + + it("should log when no invitation found", async () => { + await expect( + sharingManagement.resendInvitationEmail(nodeUid, "non-existent-uid") + ).rejects.toThrow("Invitation not found"); + + expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); + expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); + }); + }); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index b24b407d..d2119737 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -388,8 +388,24 @@ export class SharingManagement { await this.apiService.updateInvitation(invitationUid, { role }); } - async resendInvitationEmail(invitationUid: string): Promise { - await this.apiService.resendInvitationEmail(invitationUid); + async resendInvitationEmail(nodeUid: string, invitationUid: string): Promise { + const currentSharing = await this.getInternalSharingInfo(nodeUid); + + if(!currentSharing) { + throw new ValidationError(c('Error').t`Node is not shared`); + } + + const protonInvite = currentSharing.protonInvitations.find((invitation) => invitation.uid === invitationUid); + if(protonInvite) { + return await this.apiService.resendInvitationEmail(protonInvite.uid); + } + + const nonProtonInvite = currentSharing.nonProtonInvitations.find((invitation) => invitation.uid === invitationUid); + if(nonProtonInvite) { + return await this.apiService.resendExternalInvitationEmail(nonProtonInvite.uid); + } + + throw new ValidationError(c('Error').t`Invitation not found`); } private async deleteInvitation(invitationUid: string): Promise { @@ -421,9 +437,6 @@ export class SharingManagement { await this.apiService.updateExternalInvitation(invitationUid, { role }); } - async resendExternalInvitationEmail(invitationUid: string): Promise { - await this.apiService.resendExternalInvitationEmail(invitationUid); - } private async deleteExternalInvitation(invitationUid: string): Promise { await this.apiService.deleteExternalInvitation(invitationUid); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index a8d7e2af..717cf871 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -695,9 +695,9 @@ export class ProtonDriveClient { return this.sharing.management.unshareNode(getUid(nodeUid), settings); } - async resendInvitation(invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid): Promise { - this.logger.info(`Resending invitation ${invitationUid}`); - throw new Error('Method not implemented'); + async resendInvitation(nodeUid: NodeOrUid, invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid): Promise { + this.logger.info(`Resending invitation ${getUid(invitationUid)}`); + return this.sharing.management.resendInvitationEmail(getUid(nodeUid), getUid(invitationUid)) } /** From e7e02cdb72db1e63f769a1adcbb03bcb5cf450f1 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Jun 2025 11:52:44 +0200 Subject: [PATCH 128/791] release js/v0.0.9 --- js/sdk/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 76463138..542417bc 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@proton/drive-sdk", - "version": "0.0.8", + "version": "0.0.9", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", @@ -49,4 +49,4 @@ "publishConfig": { "registry": "https://nexus.protontech.ch/repository/drive-npm/" } -} +} \ No newline at end of file From 4e5a17cae71cfff66c488cfeb501dbd6427540d7 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Jun 2025 10:50:44 +0200 Subject: [PATCH 129/791] fix download copy --- js/sdk/src/internal/download/cryptoService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index 385952cb..657d48af 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -85,7 +85,7 @@ export class DownloadCryptoService { const { verified } = await this.driveCrypto.verifyManifest(hash, armoredManifestSignature, verificationKeys); if (verified !== VERIFICATION_STATUS.SIGNED_AND_VALID) { - throw new IntegrityError(c('Error').t`Date integrity check failed`); + throw new IntegrityError(c('Error').t`Data integrity check failed`); } } From c351f57c4ea670dc1587fdd537e81ff4619cd420 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Jun 2025 10:58:09 +0000 Subject: [PATCH 130/791] fix stuck loop in download --- .../internal/download/fileDownloader.test.ts | 82 ++++++++++++++----- .../src/internal/download/fileDownloader.ts | 21 ++++- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index 9d6ee82c..8d1d4773 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -3,7 +3,6 @@ import { FileDownloader } from './fileDownloader'; import { DownloadTelemetry } from './telemetry'; import { DownloadAPIService } from './apiService'; import { DownloadCryptoService } from './cryptoService'; -import { DownloadController } from './controller'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; function mockBlockDownload(_: string, token: string, onProgress: (downloadedBytes: number) => void) { @@ -21,7 +20,6 @@ describe('FileDownloader', () => { let telemetry: DownloadTelemetry; let apiService: DownloadAPIService; let cryptoService: DownloadCryptoService; - let controller: DownloadController; let nodeKey: { key: object; contentKeyPacketSessionKey: string }; let revision: Revision; @@ -67,10 +65,9 @@ describe('FileDownloader', () => { verifyBlockIntegrity: jest.fn().mockResolvedValue(undefined), verifyManifest: jest.fn().mockResolvedValue(undefined), }; - controller = new DownloadController(); nodeKey = { - key: {_idx: 32131}, + key: { _idx: 32131 }, contentKeyPacketSessionKey: 'sessionKey', }; @@ -79,7 +76,7 @@ describe('FileDownloader', () => { claimedSize: 1024, } as Revision; }); - + describe('writeToStream', () => { let onProgress: (downloadedBytes: number) => void; let onFinish: () => void; @@ -88,7 +85,9 @@ describe('FileDownloader', () => { let writer: WritableStreamDefaultWriter; let stream: WritableStream; - const verifySuccess = async () => { + const verifySuccess = async ( + fileProgress: number = 6, // 3 blocks of length 1, 2, 3 + ) => { const controller = downloader.writeToStream(stream, onProgress); await controller.completion(); @@ -97,14 +96,14 @@ describe('FileDownloader', () => { expect(writer.close).toHaveBeenCalledTimes(1); expect(writer.abort).not.toHaveBeenCalled(); expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); - expect(telemetry.downloadFinished).toHaveBeenCalledWith('revisionUid', 6); // 3 blocks of length 1, 2, 3. + expect(telemetry.downloadFinished).toHaveBeenCalledWith('revisionUid', fileProgress); expect(telemetry.downloadFailed).not.toHaveBeenCalled(); expect(onFinish).toHaveBeenCalledTimes(1); } const verifyFailure = async (error: string, downloadedBytes: number | undefined) => { const controller = downloader.writeToStream(stream, onProgress); - + await expect(controller.completion()).rejects.toThrow(error); expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); @@ -127,7 +126,7 @@ describe('FileDownloader', () => { expect(onProgress).toHaveBeenNthCalledWith(i + 1, downloadedBytes[i]); } }; - + beforeEach(() => { onProgress = jest.fn(); onFinish = jest.fn(); @@ -158,7 +157,50 @@ describe('FileDownloader', () => { expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); await verifyOnProgress([1, 2, 3]); }); - + + // Use over MAX_DOWNLOAD_BLOCK_SIZE blocks to test that the downloader is not stuck in a loop. + it('should start a download and write to the stream with random order', async () => { + let count = 0; + // Keep first block with high timeout to make sure it is not finished first. + const timeouts = [90, 50, 40, 80, 70, 60, 30, 20, 10, 90, 10]; + + apiService.iterateRevisionBlocks = jest.fn().mockImplementation(async function* () { + yield { type: 'manifestSignature', armoredManifestSignature: 'manifestSignature' }; + yield { type: 'thumbnail', base64sha256Hash: 'aGFzaDA=' }; + yield { type: 'block', index: 1, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 2, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + yield { type: 'block', index: 3, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; + yield { type: 'block', index: 4, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 5, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + yield { type: 'block', index: 6, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; + yield { type: 'block', index: 7, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 8, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + yield { type: 'block', index: 9, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; + yield { type: 'block', index: 10, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 11, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + }) + apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { + await new Promise(resolve => setTimeout(resolve, timeouts[count++])); + return mockBlockDownload(bareUrl, token, onProgress); + }); + + await verifySuccess(21); // Progress is 1 + 2 + 3 + 1 + 2 + 3 + 1 + 2 + 3 + 1 + 2 = 21 + expect(apiService.downloadBlock).toHaveBeenCalledTimes(11); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(11); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(11); + expect(writer.write).toHaveBeenNthCalledWith(1, new Uint8Array([0])); + expect(writer.write).toHaveBeenNthCalledWith(2, new Uint8Array([0, 1])); + expect(writer.write).toHaveBeenNthCalledWith(3, new Uint8Array([0, 1, 2])); + expect(writer.write).toHaveBeenNthCalledWith(4, new Uint8Array([0])); + expect(writer.write).toHaveBeenNthCalledWith(5, new Uint8Array([0, 1])); + expect(writer.write).toHaveBeenNthCalledWith(6, new Uint8Array([0, 1, 2])); + expect(writer.write).toHaveBeenNthCalledWith(7, new Uint8Array([0])); + expect(writer.write).toHaveBeenNthCalledWith(8, new Uint8Array([0, 1])); + expect(writer.write).toHaveBeenNthCalledWith(9, new Uint8Array([0, 1, 2])); + expect(writer.write).toHaveBeenNthCalledWith(10, new Uint8Array([0])); + expect(writer.write).toHaveBeenNthCalledWith(11, new Uint8Array([0, 1])); + }); + it('should handle failure when iterating blocks', async () => { apiService.iterateRevisionBlocks = jest.fn().mockImplementation(async function* () { throw new Error('Failed to iterate blocks'); @@ -166,7 +208,7 @@ describe('FileDownloader', () => { await verifyFailure('Failed to iterate blocks', 0); }); - + it('should handle failure when downloading block', async () => { apiService.downloadBlock = jest.fn().mockImplementation(async function () { throw new Error('Failed to download block'); @@ -174,7 +216,7 @@ describe('FileDownloader', () => { await verifyFailure('Failed to download block', 0); }); - + it('should handle one time-off failure when downloading block', async () => { let count = 0; apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { @@ -192,7 +234,7 @@ describe('FileDownloader', () => { expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); await verifyOnProgress([1, -1, 1, 2, 3]); }); - + it('should handle expired token when downloading block', async () => { let count = 0; apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { @@ -211,7 +253,7 @@ describe('FileDownloader', () => { expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); await verifyOnProgress([1, 2, 3]); }); - + it('should handle failure when veryfing block', async () => { cryptoService.verifyBlockIntegrity = jest.fn().mockImplementation(async function () { throw new Error('Failed to verify block'); @@ -219,7 +261,7 @@ describe('FileDownloader', () => { await verifyFailure('Failed to verify block', undefined); }); - + it('should handle one time-off failure when veryfing block', async () => { let count = 0; cryptoService.verifyBlockIntegrity = jest.fn().mockImplementation(async function () { @@ -235,7 +277,7 @@ describe('FileDownloader', () => { expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); await verifyOnProgress([1, -1, 1, 2, 3]); }); - + it('should handle failure when decrypting block', async () => { cryptoService.decryptBlock = jest.fn().mockImplementation(async function () { throw new Error('Failed to decrypt block'); @@ -243,7 +285,7 @@ describe('FileDownloader', () => { await verifyFailure('Failed to decrypt block', undefined); }); - + it('should handle one time-off failure when decrypting block', async () => { let count = 0; cryptoService.decryptBlock = jest.fn().mockImplementation(async function (encryptedBlock) { @@ -260,7 +302,7 @@ describe('FileDownloader', () => { expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(4); await verifyOnProgress([1, -1, 1, 2, 3]); }); - + it('should handle failure when writing to the stream', async () => { writer.write = jest.fn().mockImplementation(async function () { throw new Error('Failed to write data'); @@ -268,7 +310,7 @@ describe('FileDownloader', () => { await verifyFailure('Failed to write data', undefined); }); - + it('should handle one time-off failure when writing to the stream', async () => { let count = 0; writer.write = jest.fn().mockImplementation(async function () { @@ -284,7 +326,7 @@ describe('FileDownloader', () => { expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); await verifyOnProgress([1, 2, 3]); }); - + it('should handle failure when veryfing manifest', async () => { cryptoService.verifyManifest = jest.fn().mockImplementation(async function () { throw new Error('Failed to verify manifest'); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 430e5397..2db6a20a 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -213,10 +213,23 @@ export class FileDownloader { // We need to ensure the next block is downloaded, otherwise the // buffer will still be full. while (!this.isNextBlockDownloaded) { + // Promise.race never finishes if the passed array is empty. + // It shouldn't happen if at least next block is still not downloaded, + // also JS is single threaded, so it should be impossible to change + // the ongoing downloads in the middle of the loop. It is handled + // just in case something is changed that would affect this part + // without noticing. + const ongoingDownloadPromises = Array.from(this.ongoingDownloadPromises); + if (ongoingDownloadPromises.length === 0) { + break; + } + // Promise.race is used to ensure if any block fails, the error is // thrown up the chain and we dont end up in stuck loop here waiting // for the next block to be ready. - await Promise.race(this.downloadPromises); + // We wait only for the ongoing downloads as if we use all promises, + // some block can be finished and it would result in inifinite loop. + await Promise.race(ongoingDownloadPromises); } } } @@ -241,6 +254,12 @@ export class FileDownloader { return this.ongoingDownloads.values().map(({ downloadPromise }) => downloadPromise); } + private get ongoingDownloadPromises() { + return this.ongoingDownloads.values() + .filter((value) => value.decryptedBufferedBlock === undefined) + .map((value) => value.downloadPromise); + } + private get isNextBlockDownloaded() { return !!this.ongoingDownloads.get(this.nextBlockIndex)?.decryptedBufferedBlock; } From 75aebb9badc72b7d788099c19bc93dd10cf76541 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Jun 2025 05:13:33 +0000 Subject: [PATCH 131/791] add management of public links --- js/sdk/src/crypto/driveCrypto.ts | 41 +++- js/sdk/src/crypto/index.ts | 2 +- js/sdk/src/crypto/interface.ts | 34 ++- js/sdk/src/crypto/openPGPCrypto.ts | 23 ++- js/sdk/src/interface/index.ts | 7 +- js/sdk/src/interface/sharing.ts | 8 +- js/sdk/src/internal/sharing/apiService.ts | 106 ++++++++-- js/sdk/src/internal/sharing/cryptoService.ts | 47 +++-- js/sdk/src/internal/sharing/interface.ts | 23 ++- .../sharing/sharingManagement.test.ts | 194 ++++++++++++++++-- .../src/internal/sharing/sharingManagement.ts | 109 +++++++--- js/sdk/src/protonDriveClient.ts | 3 +- js/sdk/src/protonDrivePhotosClient.ts | 21 +- 13 files changed, 510 insertions(+), 108 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index e959dd16..dc1a4659 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,4 +1,4 @@ -import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; +import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, SRPModule, SRPVerifier, VERIFICATION_STATUS } from './interface'; import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; // TODO: Switch to CryptoProxy module once available. import { importHmacKey, computeHmacSignature } from "./hmac"; @@ -20,8 +20,9 @@ enum SIGNING_CONTEXTS { * call with specific arguments. */ export class DriveCrypto { - constructor(private openPGPCrypto: OpenPGPCrypto) { + constructor(private openPGPCrypto: OpenPGPCrypto, private srpModule: SRPModule) { this.openPGPCrypto = openPGPCrypto; + this.srpModule = srpModule; } /** @@ -203,6 +204,34 @@ export class DriveCrypto { } } + /** + * It encrypts password with provided address key that can be used to + * manage the public link, encrypts share passphrase session key using + * provided bcrypt passphrase and generates SRP verifier. + */ + async encryptPublicLinkPasswordAndSessionKey( + password: string, + addressKey: PrivateKey, + bcryptPassphrase: string, + sharePassphraseSessionKey: SessionKey, + ): Promise<{ + armoredPassword: string, + base64SharePassphraseKeyPacket: string, + srp: SRPVerifier, + }> { + const [{ armoredData: armoredPassword }, { keyPacket }, srp] = await Promise.all([ + this.openPGPCrypto.encryptArmored(new TextEncoder().encode(password), [addressKey]), + this.openPGPCrypto.encryptSessionKeyWithPassword(sharePassphraseSessionKey, bcryptPassphrase), + this.srpModule.getSrpVerifier(password), + ]); + + return { + armoredPassword, + base64SharePassphraseKeyPacket: uint8ArrayToBase64String(keyPacket), + srp, + }; + } + /** * It decrypts session key from armored data. * @@ -228,7 +257,7 @@ export class DriveCrypto { sessionKey: SessionKey, verified?: VERIFICATION_STATUS, }> { - + const data = base64StringToUint8Array(base64data); const sessionKey = await this.openPGPCrypto.decryptSessionKey( @@ -285,8 +314,8 @@ export class DriveCrypto { }> { const { armoredData: armoredSignature } = await this.openPGPCrypto.encryptArmored( signature, - sessionKey, [encryptionKey], + sessionKey, ); return { armoredSignature, @@ -317,7 +346,7 @@ export class DriveCrypto { ); return { armoredHashKey, - hashKey, + hashKey, } } @@ -582,7 +611,7 @@ export class DriveCrypto { decryptionKey: PrivateKey, sessionKey: SessionKey, verificationKeys?: PublicKey[], - ): Promise<{ + ): Promise<{ decryptedBlock: Uint8Array, verified: VERIFICATION_STATUS, }> { diff --git a/js/sdk/src/crypto/index.ts b/js/sdk/src/crypto/index.ts index 8ed5d30f..c8a81970 100644 --- a/js/sdk/src/crypto/index.ts +++ b/js/sdk/src/crypto/index.ts @@ -1,4 +1,4 @@ -export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interface'; +export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, SRPModule, SRPVerifier } from './interface'; export { VERIFICATION_STATUS } from './interface'; export { DriveCrypto } from './driveCrypto'; export type { OpenPGPCryptoProxy } from './openPGPCrypto'; diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index e3bbf7b4..6fa9b992 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -9,7 +9,7 @@ export interface PublicKey { getSHA256Fingerprints(): string[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any getKeyID(): any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any getKeyIDs(): any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any getAlgorithmInfo(): any; @@ -23,10 +23,10 @@ export interface PublicKey { getExpirationTime(): Date | number | null; getUserIDs(): string[]; isWeak(): boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any equals(otherKey: any, ignoreOtherCerts?: boolean): boolean; subkeys: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any getAlgorithmInfo(): any; // eslint-disable-next-line @typescript-eslint/no-explicit-any getKeyID(): any; @@ -36,12 +36,13 @@ export interface PublicKey { export interface PrivateKey extends PublicKey { readonly _dummyType: 'private'; } + export interface SessionKey { - data: Uint8Array; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - algorithm: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - aeadAlgorithm?: any; + data: Uint8Array; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + algorithm: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aeadAlgorithm?: any; } export enum VERIFICATION_STATUS { @@ -50,6 +51,17 @@ export enum VERIFICATION_STATUS { SIGNED_AND_INVALID = 2 } +export interface SRPModule { + getSrpVerifier: (password: string) => Promise, +} + +export type SRPVerifier = { + modulusId: string, + version: number, + salt: string, + verifier: string, +} + /** * OpenPGP crypto layer to provide necessary PGP operations for Drive crypto. * @@ -75,6 +87,10 @@ export interface OpenPGPCrypto { keyPacket: Uint8Array, }>, + encryptSessionKeyWithPassword: (sessionKey: SessionKey, password: string) => Promise<{ + keyPacket: Uint8Array, + }>, + /** * Generate a new key pair locked by a passphrase. * @@ -87,8 +103,8 @@ export interface OpenPGPCrypto { encryptArmored: ( data: Uint8Array, - sessionKey: SessionKey, encryptionKeys: PrivateKey[], + sessionKey?: SessionKey, ) => Promise<{ armoredData: string, }>, diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index cd0b4c95..3588e176 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -7,10 +7,10 @@ import { uint8ArrayToBase64String } from './utils'; */ export interface OpenPGPCryptoProxy { generateKey: (options: { userIDs: { name: string }[], type: 'ecc', curve: 'ed25519Legacy' }) => Promise, - exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string | null }) => Promise, + exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string | null }) => Promise, importPrivateKey: (options: { armoredKey: string, passphrase: string | null }) => Promise, generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, - encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys: PublicKey | PublicKey[] }) => Promise, + encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys?: PublicKey | PublicKey[], passwords?: string[] }) => Promise, decryptSessionKey: (options: { armoredMessage?: string, binaryMessage?: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[] }) => Promise, encryptMessage: (options: { format?: 'armored' | 'binary', @@ -30,7 +30,7 @@ export interface OpenPGPCryptoProxy { armoredSignature?: string, binarySignature?: Uint8Array, sessionKeys?: SessionKey, - decryptionKeys?:PrivateKey | PrivateKey[], + decryptionKeys?: PrivateKey | PrivateKey[], verificationKeys?: PublicKey | PublicKey[], }) => Promise<{ data: Uint8Array | string, @@ -88,6 +88,17 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } + async encryptSessionKeyWithPassword(sessionKey: SessionKey, password: string) { + const keyPacket = await this.cryptoProxy.encryptSessionKey({ + ...sessionKey, + format: 'binary', + passwords: [password], + }); + return { + keyPacket, + }; + } + async generateKey(passphrase: string) { const privateKey = await this.cryptoProxy.generateKey({ userIDs: [{ name: 'Drive key' }], @@ -108,8 +119,8 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async encryptArmored( data: Uint8Array, - sessionKey: SessionKey, encryptionKeys: PrivateKey[], + sessionKey?: SessionKey, ) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, @@ -346,7 +357,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async decryptArmoredAndVerify( armoredData: string, decryptionKeys: PrivateKey | PrivateKey[], - verificationKeys: PublicKey| PublicKey[], + verificationKeys: PublicKey | PublicKey[], ) { const { data, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, @@ -367,7 +378,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { armoredData: string, armoredSignature: string, sessionKey: SessionKey, - verificationKeys: PublicKey| PublicKey[], + verificationKeys: PublicKey | PublicKey[], ) { const { data, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 723d03e5..b96ad700 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -1,5 +1,5 @@ import { ProtonDriveCache } from '../cache'; -import { OpenPGPCrypto, PrivateKey, SessionKey } from '../crypto'; +import { OpenPGPCrypto, PrivateKey, SessionKey, SRPModule } from '../crypto'; import { ProtonDriveAccount } from './account'; import { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; import { Telemetry, MetricEvent } from './telemetry'; @@ -7,7 +7,7 @@ import { Telemetry, MetricEvent } from './telemetry'; export type { Result } from './result'; export { resultOk, resultError } from './result'; export type { ProtonDriveAccount, ProtonDriveAccountAddress } from './account'; -export type { Author,UnverifiedAuthorError, AnonymousUser } from './author'; +export type { Author, UnverifiedAuthorError, AnonymousUser } from './author'; export type { Device, DeviceOrUid } from './devices'; export { DeviceType } from './devices'; export type { FileDownloader, DownloadController } from './download'; @@ -16,7 +16,7 @@ export { SDKEvent } from './events'; export type { ProtonDriveHTTPClient, ProtonDriveHTTPClientJsonOptions, ProtonDriveHTTPClientBlobOptions, ProtonDriveConfig } from './httpClient'; export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; -export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, ShareResult } from './sharing'; +export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, SharePublicLinkSettingsObject, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent } from './telemetry'; export { MetricContext } from './telemetry'; @@ -40,6 +40,7 @@ export interface ProtonDriveClientContructorParameters { cryptoCache: ProtonDriveCryptoCache, account: ProtonDriveAccount, openPGPCryptoModule: OpenPGPCrypto, + srpModule: SRPModule, config?: ProtonDriveConfig, telemetry?: ProtonDriveTelemetry, }; diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 863fdfc4..cdef3744 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -66,10 +66,12 @@ export type ShareMembersSettings = string[] | { role: MemberRole, }[]; -export type SharePublicLinkSettings = boolean |{ +export type SharePublicLinkSettings = boolean | SharePublicLinkSettingsObject; + +export type SharePublicLinkSettingsObject = { role: MemberRole, - customPassword?: string | null | undefined, - expiration?: Date | null | undefined, + customPassword?: string | undefined, + expiration?: Date | undefined, }; export type ShareResult = { diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 9d89c014..cc97cded 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -1,7 +1,8 @@ +import { SRPVerifier } from "../../crypto"; import { NodeType, MemberRole, NonProtonInvitationState, Logger } from "../../interface"; import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from "../apiService"; import { makeNodeUid, splitNodeUid, makeInvitationUid, splitInvitationUid, makeMemberUid, splitMemberUid, makePublicLinkUid, splitPublicLinkUid } from "../uids"; -import { EncryptedInvitationRequest, EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedBookmark, EncryptedExternalInvitationRequest, EncryptedPublicLink } from "./interface"; +import { EncryptedInvitationRequest, EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedBookmark, EncryptedExternalInvitationRequest, EncryptedPublicLink, EncryptedPublicLinkCrypto } from "./interface"; type GetSharedNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/shares']['get']['responses']['200']['content']['application/json']; @@ -42,6 +43,12 @@ type PostUpdateMemberResponse = drivePaths['/drive/v2/shares/{shareID}/members/{ type GetShareUrlsResponse = drivePaths['/drive/shares/{shareID}/urls']['get']['responses']['200']['content']['application/json']; +type PostShareUrlRequest = Extract['content']['application/json']; +type PostShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls']['post']['responses']['200']['content']['application/json']; + +type PutShareUrlRequest = Extract['content']['application/json']; +type PutShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls/{urlID}']['put']['responses']['200']['content']['application/json']; + /** * Provides API communication for fetching and managing sharing. * @@ -61,7 +68,7 @@ export class SharingAPIService { for (const link of response.Links) { yield makeNodeUid(volumeId, link.LinkID); } - + if (!response.More || !response.AnchorID) { break; } @@ -76,7 +83,7 @@ export class SharingAPIService { for (const link of response.Links) { yield makeNodeUid(link.VolumeID, link.LinkID); } - + if (!response.More || !response.AnchorID) { break; } @@ -91,7 +98,7 @@ export class SharingAPIService { for (const invitation of response.Invitations) { yield makeInvitationUid(invitation.ShareID, invitation.InvitationID); } - + if (!response.More || !response.AnchorID) { break; } @@ -108,7 +115,7 @@ export class SharingAPIService { inviteeEmail: response.Invitation.InviteeEmail, base64KeyPacket: response.Invitation.KeyPacket, base64KeyPacketSignature: response.Invitation.KeyPacketSignature, - invitationTime: new Date(response.Invitation.CreateTime*1000), + invitationTime: new Date(response.Invitation.CreateTime * 1000), role: permissionsToDirectMemberRole(this.logger, response.Invitation.Permissions), share: { armoredKey: response.Share.ShareKey, @@ -143,7 +150,7 @@ export class SharingAPIService { for (const bookmark of response.Bookmarks) { yield { tokenId: bookmark.Token.Token, - creationTime: new Date(bookmark.CreateTime*1000), + creationTime: new Date(bookmark.CreateTime * 1000), share: { armoredKey: bookmark.Token.ShareKey, armoredPassphrase: bookmark.Token.SharePassphrase, @@ -193,7 +200,7 @@ export class SharingAPIService { inviteeEmail: member.Email, base64KeyPacket: member.KeyPacket, base64KeyPacketSignature: member.KeyPacketSignature, - invitationTime: new Date(member.CreateTime*1000), + invitationTime: new Date(member.CreateTime * 1000), role: permissionsToDirectMemberRole(this.logger, member.Permissions), } }); @@ -356,8 +363,8 @@ export class SharingAPIService { return { uid: makePublicLinkUid(shareUrl.ShareID, shareUrl.ShareURLID), - creationTime: new Date(shareUrl.CreateTime*1000), - expirationTime: shareUrl.ExpirationTime ? new Date(shareUrl.ExpirationTime*1000) : undefined, + creationTime: new Date(shareUrl.CreateTime * 1000), + expirationTime: shareUrl.ExpirationTime ? new Date(shareUrl.ExpirationTime * 1000) : undefined, role: permissionsToDirectMemberRole(this.logger, shareUrl.Permissions), flags: shareUrl.Flags, creatorEmail: shareUrl.CreatorEmail, @@ -369,6 +376,81 @@ export class SharingAPIService { }; } + async createPublicLink(shareId: string, publicLink: { + creatorEmail: string, + role: MemberRole, + includesCustomPassword: boolean, + expirationDuration?: number, + crypto: EncryptedPublicLinkCrypto, + srp: SRPVerifier, + }): Promise<{ + uid: string, + publicUrl: string, + }> { + if (publicLink.role === MemberRole.Admin) { + throw new Error('Cannot set admin role for public link.'); + } + + const result = await this.apiService.post< + // TODO: Backend type wrongly requires ExpirationTime and Name. + Omit, + PostShareUrlResponse + >(`drive/shares/${shareId}/urls`, { + CreatorEmail: publicLink.creatorEmail, + ...this.generatePublicLinkRequestPayload(publicLink), + }); + return { + uid: makePublicLinkUid(shareId, result.ShareURL.ShareURLID), + publicUrl: result.ShareURL.PublicUrl, + }; + } + + async updatePublicLink(publicLinkUid: string, publicLink: { + role: MemberRole, + includesCustomPassword: boolean, + expirationDuration?: number, + crypto: EncryptedPublicLinkCrypto, + srp: SRPVerifier, + }): Promise { + if (publicLink.role === MemberRole.Admin) { + throw new Error('Cannot set admin role for public link.'); + } + + const { shareId, publicLinkId } = splitPublicLinkUid(publicLinkUid); + + await this.apiService.put< + // TODO: Backend type wrongly requires ExpirationTime and Name. + Omit, + PutShareUrlResponse + >(`drive/shares/${shareId}/urls/${publicLinkId}`, this.generatePublicLinkRequestPayload(publicLink)); + } + + private generatePublicLinkRequestPayload(publicLink: { + role: MemberRole, + includesCustomPassword: boolean, + expirationDuration?: number, + crypto: EncryptedPublicLinkCrypto, + srp: SRPVerifier, + }): Pick { + return { + Permissions: memberRoleToPermission(publicLink.role) as 4 | 6, + Flags: publicLink.includesCustomPassword + ? 3 // Random + custom password set. + : 2, // Random password set. + ExpirationDuration: publicLink.expirationDuration || null, + + SharePasswordSalt: publicLink.crypto.base64SharePasswordSalt, + SharePassphraseKeyPacket: publicLink.crypto.base64SharePassphraseKeyPacket, + Password: publicLink.crypto.armoredPassword, + + UrlPasswordSalt: publicLink.srp.salt, + SRPVerifier: publicLink.srp.verifier, + SRPModulusID: publicLink.srp.modulusId, + + MaxAccesses: 0, // We don't support setting limit. + } + } + async removePublicLink(publicLinkUid: string): Promise { const { shareId, publicLinkId } = splitPublicLinkUid(publicLinkUid); await this.apiService.delete(`drive/shares/${shareId}/urls/${publicLinkId}`); @@ -379,20 +461,20 @@ export class SharingAPIService { uid: makeInvitationUid(shareId, invitation.InvitationID), addedByEmail: invitation.InviterEmail, inviteeEmail: invitation.InviteeEmail, - invitationTime: new Date(invitation.CreateTime*1000), + invitationTime: new Date(invitation.CreateTime * 1000), role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), base64KeyPacket: invitation.KeyPacket, base64KeyPacketSignature: invitation.KeyPacketSignature, } } - + private convertExternalInvitaiton(shareId: string, invitation: GetShareExternalInvitations['ExternalInvitations'][0]): EncryptedExternalInvitation { const state = invitation.State === 1 ? NonProtonInvitationState.Pending : NonProtonInvitationState.UserRegistered; return { uid: makeInvitationUid(shareId, invitation.ExternalInvitationID), addedByEmail: invitation.InviterEmail, inviteeEmail: invitation.InviteeEmail, - invitationTime: new Date(invitation.CreateTime*1000), + invitationTime: new Date(invitation.CreateTime * 1000), role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), base64Signature: invitation.ExternalInvitationSignature, state, diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index f930e957..217866cf 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -1,17 +1,17 @@ import bcrypt from 'bcryptjs'; import { c } from 'ttag'; -import { DriveCrypto, PrivateKey, SessionKey, uint8ArrayToBase64String, VERIFICATION_STATUS } from '../../crypto'; -import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, resultError, resultOk, PublicLink } from "../../interface"; +import { DriveCrypto, PrivateKey, SessionKey, SRPVerifier, uint8ArrayToBase64String, VERIFICATION_STATUS } from '../../crypto'; +import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, resultError, resultOk } from "../../interface"; import { getErrorMessage, getVerificationMessage } from "../errors"; import { EncryptedShare } from "../shares"; -import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink } from "./interface"; +import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink, PublicLinkWithCreatorEmail } from "./interface"; // Version 2 of bcrypt with 2**10 rounds. // https://en.wikipedia.org/wiki/Bcrypt#Description const BCRYPT_PREFIX = '$2y$10$'; -const PUBLIC_LINK_GENERATED_PASSWORD_LENGTH = 12; +export const PUBLIC_LINK_GENERATED_PASSWORD_LENGTH = 12; // We do not support management of legacy public links anymore (that is no // flag or bit 1). But we still need to support to read the legacy public @@ -273,18 +273,36 @@ export class SharingCryptoService { }; } - async encryptPublicLink(): Promise { - const password = await this.generatePassword(); - await this.computeKeySaltAndPassphrase(password); - // FIXME: finish creation of public links + async encryptPublicLink(creatorEmail: string, shareSessionKey: SessionKey, password: string): Promise<{ + crypto: { + base64SharePasswordSalt: string, + base64SharePassphraseKeyPacket: string, + armoredPassword: string, + }, + srp: SRPVerifier, + }> { + const address = await this.account.getOwnAddress(creatorEmail); + const addressKey = address.keys[address.primaryKeyIndex].key; + + const { base64Salt: base64SharePasswordSalt, bcryptPassphrase } = await this.computeKeySaltAndPassphrase(password); + const { base64SharePassphraseKeyPacket, armoredPassword, srp } = await this.driveCrypto.encryptPublicLinkPasswordAndSessionKey(password, addressKey, bcryptPassphrase, shareSessionKey); + + return { + crypto: { + base64SharePasswordSalt, + base64SharePassphraseKeyPacket, + armoredPassword, + }, + srp, + } } - private async generatePassword(): Promise { + async generatePublicLinkPassword(): Promise { const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const values = crypto.getRandomValues(new Uint32Array(length)); + const values = crypto.getRandomValues(new Uint32Array(PUBLIC_LINK_GENERATED_PASSWORD_LENGTH)); let result = ''; - for (let i = 0; i < length; i++) { + for (let i = 0; i < PUBLIC_LINK_GENERATED_PASSWORD_LENGTH; i++) { result += charset[values[i] % charset.length]; } @@ -299,15 +317,15 @@ export class SharingCryptoService { const salt = crypto.getRandomValues(new Uint8Array(16)); const hash: string = await bcrypt.hash(password, BCRYPT_PREFIX + bcrypt.encodeBase64(salt, 16)); // Remove bcrypt prefix and salt (first 29 characters) - const passphrase = hash.slice(29); + const bcryptPassphrase = hash.slice(29); return { base64Salt: uint8ArrayToBase64String(salt), - passphrase, + bcryptPassphrase, } }; - async decryptPublicLink(encryptedPublicLink: EncryptedPublicLink): Promise { + async decryptPublicLink(encryptedPublicLink: EncryptedPublicLink): Promise { const address = await this.account.getOwnAddress(encryptedPublicLink.creatorEmail); const addressKeys = address.keys.map(({ key }) => key); @@ -323,6 +341,7 @@ export class SharingCryptoService { role: encryptedPublicLink.role, url: `${encryptedPublicLink.publicUrl}#${password}`, customPassword, + creatorEmail: encryptedPublicLink.creatorEmail, } } diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index dc54d306..c2f90ea3 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -1,4 +1,4 @@ -import { NodeType, MemberRole, NonProtonInvitationState, MissingNode } from "../../interface"; +import { NodeType, MemberRole, NonProtonInvitationState, MissingNode, ShareResult, PublicLink } from "../../interface"; import { PrivateKey, SessionKey } from "../../crypto"; import { EncryptedShare } from "../shares"; import { DecryptedNode } from "../nodes"; @@ -122,12 +122,32 @@ export interface EncryptedPublicLink { sharePassphraseSalt: string, } +export interface EncryptedPublicLinkCrypto { + base64SharePasswordSalt: string, + base64SharePassphraseKeyPacket: string, + armoredPassword: string, +} + +export interface ShareResultWithCreatorEmail extends ShareResult { + publicLink?: PublicLinkWithCreatorEmail; +} + +export interface PublicLinkWithCreatorEmail extends PublicLink { + creatorEmail: string; +} + + /** * Interface describing the dependencies to the shares module. */ export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string }>, loadEncryptedShare(shareId: string): Promise, + getContextShareMemberEmailKey(shareId: string): Promise<{ + email: string, + addressId: string, + addressKey: PrivateKey, + }>, } /** @@ -145,7 +165,6 @@ export interface NodesService { email: string, addressId: string, addressKey: PrivateKey, - addressKeyId: string, }>, iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 94051e45..bb62e227 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -42,6 +42,11 @@ describe("SharingManagement", () => { deleteShare: jest.fn(), resendInvitationEmail: jest.fn(), resendExternalInvitationEmail: jest.fn(), + createPublicLink: jest.fn().mockResolvedValue({ + uid: "publicLinkUid", + publicUrl: "publicLinkUrl", + }), + updatePublicLink: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking cryptoService = { @@ -56,6 +61,11 @@ describe("SharingManagement", () => { base64ExternalInvitationSignature: "extenral-signature", })), decryptPublicLink: jest.fn().mockImplementation((publicLink) => publicLink), + generatePublicLinkPassword: jest.fn().mockResolvedValue("generatedPassword"), + encryptPublicLink: jest.fn().mockImplementation(() => ({ + crypto: "publicLinkCrypto", + srp: "publicLinkSrp", + })), } // @ts-expect-error No need to implement all methods for mocking accountService = { @@ -63,7 +73,8 @@ describe("SharingManagement", () => { } // @ts-expect-error No need to implement all methods for mocking sharesService = { - loadEncryptedShare: jest.fn().mockResolvedValue({ id: "shareId", addressId: "addressId" }), + loadEncryptedShare: jest.fn().mockResolvedValue({ id: "shareId", addressId: "addressId", creatorEmail: "address@example.com", passphraseSessionKey: "sharePassphraseSessionKey" }), + getContextShareMemberEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressId: "addressId", addressKey: "volume-key" }), } // @ts-expect-error No need to implement all methods for mocking nodesService = { @@ -489,6 +500,165 @@ describe("SharingManagement", () => { expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); + + describe("public link", () => { + it("should share node with public link", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01')); + + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Viewer, + customPassword: undefined, + expiration: undefined, + }, + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: { + uid: "publicLinkUid", + role: MemberRole.Viewer, + url: "publicLinkUrl#generatedPassword", + creationTime: new Date(), + expirationTime: undefined, + customPassword: undefined, + creatorEmail: "volume-email", + }, + }); + expect(cryptoService.generatePublicLinkPassword).toHaveBeenCalled(); + expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith("volume-email", "sharePassphraseSessionKey", "generatedPassword"); + expect(apiService.createPublicLink).toHaveBeenCalledWith("shareId", expect.objectContaining({ + role: MemberRole.Viewer, + includesCustomPassword: false, + expirationDuration: undefined, + crypto: "publicLinkCrypto", + srp: "publicLinkSrp", + })); + }); + + it("should share node with custom password and expiration", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01')); + + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Viewer, + customPassword: "customPassword", + expiration: new Date('2025-01-02'), + }, + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: { + uid: "publicLinkUid", + role: MemberRole.Viewer, + url: "publicLinkUrl#generatedPassword", + creationTime: new Date(), + expirationTime: new Date('2025-01-02'), + customPassword: "customPassword", + creatorEmail: "volume-email", + }, + }); + expect(cryptoService.generatePublicLinkPassword).toHaveBeenCalled(); + expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith("volume-email", "sharePassphraseSessionKey", "generatedPasswordcustomPassword"); + expect(apiService.createPublicLink).toHaveBeenCalledWith("shareId", expect.objectContaining({ + role: MemberRole.Viewer, + includesCustomPassword: true, + expirationDuration: 86400, + crypto: "publicLinkCrypto", + srp: "publicLinkSrp", + })); + }); + + it("should update public link with custom password and expiration", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01')); + + const publicLink = { + uid: 'publicLinkUid', + url: "publicLinkUrl#generatedpas", // Generated password must be 12 chararacters long. + creationTime: new Date('2025-01-01'), + role: MemberRole.Viewer, + customPassword: undefined, + expirationTime: undefined, + creatorEmail: "publicLinkCreatorEmail", + } + apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); + + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Editor, + customPassword: "customPassword", + expiration: new Date('2025-01-02'), + }, + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: { + uid: "publicLinkUid", + role: MemberRole.Editor, + url: "publicLinkUrl#generatedpas", + creationTime: new Date('2025-01-01'), + expirationTime: new Date('2025-01-02'), + customPassword: "customPassword", + creatorEmail: "publicLinkCreatorEmail", + }, + }); + expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith("publicLinkCreatorEmail", "sharePassphraseSessionKey", "generatedpascustomPassword"); + expect(apiService.updatePublicLink).toHaveBeenCalledWith("publicLinkUid", expect.objectContaining({ + role: MemberRole.Editor, + includesCustomPassword: true, + expirationDuration: 86400, + crypto: "publicLinkCrypto", + srp: "publicLinkSrp", + })); + }); + + it("should not allow updating legacy public link", async () => { + apiService.getPublicLink = jest.fn().mockResolvedValue({ + uid: 'publicLinkUid', + url: "publicLinkUrl#aaa", // Legacy public links doesn't have 12 chars. + }); + + await expect(sharingManagement.shareNode(nodeUid, { + publicLink: true, + })).rejects.toThrow("Legacy public link cannot be updated. Please re-create a new public link."); + }); + + it("should not allow updating legacy public link without generated password", async () => { + apiService.getPublicLink = jest.fn().mockResolvedValue({ + uid: 'publicLinkUid', + url: "publicLinkUrl", + }); + + await expect(sharingManagement.shareNode(nodeUid, { + publicLink: true, + })).rejects.toThrow("Legacy public link cannot be updated. Please re-create a new public link."); + }); + + it("should not allow creating public link with expiration in the past", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01')); + + await expect(sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Viewer, + expiration: new Date('2024-01-01'), + }, + })).rejects.toThrow("Expiration date cannot be in the past"); + expect(apiService.createStandardShare).not.toHaveBeenCalled(); + expect(apiService.createPublicLink).not.toHaveBeenCalled(); + }); + }); }); describe("unsahreNode", () => { @@ -666,19 +836,19 @@ describe("SharingManagement", () => { const nodeUid = "volumeId~nodeUid"; const invitation: ProtonInvitation = { - uid: "invitation", - addedByEmail: resultOk("added-email"), - inviteeEmail: "internal-email", - role: MemberRole.Viewer, - invitationTime: new Date(), + uid: "invitation", + addedByEmail: resultOk("added-email"), + inviteeEmail: "internal-email", + role: MemberRole.Viewer, + invitationTime: new Date(), }; const externalInvitation: NonProtonInvitation = { - uid: "external-invitation", - addedByEmail: resultOk("added-email"), - inviteeEmail: "external-email", - role: MemberRole.Viewer, - invitationTime: new Date(), - state: NonProtonInvitationState.Pending, + uid: "external-invitation", + addedByEmail: resultOk("added-email"), + inviteeEmail: "external-email", + role: MemberRole.Viewer, + invitationTime: new Date(), + state: NonProtonInvitationState.Pending, }; beforeEach(() => { diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index d2119737..e83101fd 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -2,14 +2,14 @@ import { c } from 'ttag'; import { SessionKey } from "../../crypto"; import { ValidationError } from "../../errors"; -import { Logger, PublicLink, MemberRole, ShareNodeSettings, UnshareNodeSettings, SharePublicLinkSettings, ShareResult, ProtonInvitation, NonProtonInvitation, Member, resultOk, ProtonDriveAccount } from "../../interface"; +import { Logger, MemberRole, ShareNodeSettings, UnshareNodeSettings, ShareResult, ProtonInvitation, NonProtonInvitation, Member, resultOk, ProtonDriveAccount, SharePublicLinkSettingsObject } from "../../interface"; import { splitNodeUid } from "../uids"; import { getErrorMessage } from '../errors'; import { SharingAPIService } from "./apiService"; -import { SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService, NodesEvents } from "./interface"; +import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from "./cryptoService"; +import { SharesService, NodesService, NodesEvents, ShareResultWithCreatorEmail, PublicLinkWithCreatorEmail } from "./interface"; -interface InternalShareResult extends ShareResult { +interface InternalShareResult extends ShareResultWithCreatorEmail { share: Share; nodeName: string; } @@ -17,6 +17,7 @@ interface InternalShareResult extends ShareResult { interface Share { volumeId: string; shareId: string; + creatorEmail: string; passphraseSessionKey: SessionKey; } @@ -50,7 +51,7 @@ export class SharingManagement { this.nodesEvents = nodesEvents; } - async getSharingInfo(nodeUid: string): Promise { + async getSharingInfo(nodeUid: string): Promise { const node = await this.nodesService.getNode(nodeUid); if (!node.shareId) { return; @@ -92,7 +93,7 @@ export class SharingManagement { } } - private async getPublicLink(shareId: string): Promise { + private async getPublicLink(shareId: string): Promise { const encryptedPublicLink = await this.apiService.getPublicLink(shareId); if (!encryptedPublicLink) { return; @@ -119,6 +120,12 @@ export class SharingManagement { } } + // Check if expiration date is in the past before creating share + // so if this fails, we don't create empty share. + if (typeof settings.publicLink === 'object' && settings.publicLink.expiration && settings.publicLink.expiration < new Date()) { + throw new ValidationError(c('Error').t`Expiration date cannot be in the past`); + } + let currentSharing = await this.getInternalSharingInfo(nodeUid); if (!currentSharing) { const node = await this.nodesService.getNode(nodeUid); @@ -166,7 +173,7 @@ export class SharingManagement { } this.logger.info(`Inviting user ${email} with role ${role} to node ${nodeUid}`); - const invitation = await this.inviteProtonUser(nodeUid, currentSharing.share, email, role, emailOptions); + const invitation = await this.inviteProtonUser(currentSharing.share, email, role, emailOptions); currentSharing.protonInvitations.push(invitation); } @@ -198,7 +205,7 @@ export class SharingManagement { } this.logger.info(`Inviting external user ${email} with role ${role} to node ${nodeUid}`); - const invitation = await this.inviteExternalUser(nodeUid, currentSharing.share, email, role, emailOptions); + const invitation = await this.inviteExternalUser(currentSharing.share, email, role, emailOptions); currentSharing.nonProtonInvitations.push(invitation); } @@ -208,11 +215,11 @@ export class SharingManagement { : settings.publicLink; if (currentSharing.publicLink) { - this.logger.info(`Updating public link with options ${options} to node ${nodeUid}`); - await this.updateSharedLink(currentSharing.share, options); + this.logger.info(`Updating public link with role ${options.role} to node ${nodeUid}`); + currentSharing.publicLink = await this.updateSharedLink(currentSharing.share, currentSharing.publicLink, options); } else { - this.logger.info(`Sharing via public link with options ${options} to node ${nodeUid}`); - await this.shareViaLink(currentSharing.share, options); + this.logger.info(`Sharing via public link with role ${options.role} to node ${nodeUid}`); + currentSharing.publicLink = await this.shareViaLink(currentSharing.share, options); } } @@ -325,6 +332,7 @@ export class SharingManagement { share: { volumeId, shareId: node.shareId, + creatorEmail: encryptedShare.creatorEmail, passphraseSessionKey: passphraseSessionKey, }, nodeName: node.name.ok ? node.name.value : node.name.error.name, @@ -338,7 +346,7 @@ export class SharingManagement { } const { volumeId } = splitNodeUid(nodeUid); - const { addressId, addressKey } = await this.nodesService.getRootNodeEmailKey(nodeUid); + const { email, addressId, addressKey } = await this.nodesService.getRootNodeEmailKey(nodeUid); const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(nodeUid); const keys = await this.cryptoService.generateShareKeys(nodeKeys, addressKey); @@ -357,6 +365,7 @@ export class SharingManagement { return { volumeId, shareId, + creatorEmail: email, passphraseSessionKey: keys.shareKey.decrypted.passphraseSessionKey, } } @@ -367,8 +376,8 @@ export class SharingManagement { await this.nodesEvents.nodeUpdated({ uid: nodeUid, shareId: undefined, isShared: false }); } - private async inviteProtonUser(nodeUid: string, share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { - const inviter = await this.nodesService.getRootNodeEmailKey(nodeUid); + private async inviteProtonUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { + const inviter = await this.sharesService.getContextShareMemberEmailKey(share.shareId); const invitationCrypto = await this.cryptoService.encryptInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); const encryptedInvitation = await this.apiService.inviteProtonUser(share.shareId, { @@ -391,18 +400,18 @@ export class SharingManagement { async resendInvitationEmail(nodeUid: string, invitationUid: string): Promise { const currentSharing = await this.getInternalSharingInfo(nodeUid); - if(!currentSharing) { - throw new ValidationError(c('Error').t`Node is not shared`); + if (!currentSharing) { + throw new ValidationError(c('Error').t`Node is not shared`); } const protonInvite = currentSharing.protonInvitations.find((invitation) => invitation.uid === invitationUid); - if(protonInvite) { - return await this.apiService.resendInvitationEmail(protonInvite.uid); + if (protonInvite) { + return await this.apiService.resendInvitationEmail(protonInvite.uid); } const nonProtonInvite = currentSharing.nonProtonInvitations.find((invitation) => invitation.uid === invitationUid); - if(nonProtonInvite) { - return await this.apiService.resendExternalInvitationEmail(nonProtonInvite.uid); + if (nonProtonInvite) { + return await this.apiService.resendExternalInvitationEmail(nonProtonInvite.uid); } throw new ValidationError(c('Error').t`Invitation not found`); @@ -412,8 +421,8 @@ export class SharingManagement { await this.apiService.deleteInvitation(invitationUid); } - private async inviteExternalUser(nodeUid: string, share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { - const inviter = await this.nodesService.getRootNodeEmailKey(nodeUid); + private async inviteExternalUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { + const inviter = await this.sharesService.getContextShareMemberEmailKey(share.shareId); const invitationCrypto = await this.cryptoService.encryptExternalInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); const encryptedInvitation = await this.apiService.inviteExternalUser(share.shareId, { @@ -454,14 +463,56 @@ export class SharingManagement { await this.apiService.updateMember(memberUid, { role }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private async shareViaLink(share: Share, options: SharePublicLinkSettings): Promise { - // FIXME + private async shareViaLink(share: Share, options: SharePublicLinkSettingsObject): Promise { + const { email: creatorEmail } = await this.sharesService.getContextShareMemberEmailKey(share.shareId); + + const generatedPassword = await this.cryptoService.generatePublicLinkPassword(); + const password = options.customPassword ? `${generatedPassword}${options.customPassword}` : generatedPassword; + + const { crypto, srp } = await this.cryptoService.encryptPublicLink(creatorEmail, share.passphraseSessionKey, password); + const publicLink = await this.apiService.createPublicLink(share.shareId, { + creatorEmail, + role: options.role, + includesCustomPassword: !!options.customPassword, + expirationDuration: options.expiration ? Math.floor((options.expiration.getTime() - Date.now()) / 1000) : undefined, + crypto, + srp, + }); + + return { + uid: publicLink.uid, + creationTime: new Date(), + role: options.role, + url: `${publicLink.publicUrl}#${generatedPassword}`, + customPassword: options.customPassword, + expirationTime: options.expiration, + creatorEmail, + } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private async updateSharedLink(share: Share, options: SharePublicLinkSettings): Promise { - // FIXME + private async updateSharedLink(share: Share, publicLink: PublicLinkWithCreatorEmail, options: SharePublicLinkSettingsObject): Promise { + const generatedPassword = publicLink.url.split('#')[1]; + // Legacy public links didn't have generated password or had various lengths. + if (!generatedPassword || generatedPassword.length !== PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) { + throw new ValidationError(c('Error').t`Legacy public link cannot be updated. Please re-create a new public link.`); + } + const password = options.customPassword ? `${generatedPassword}${options.customPassword}` : generatedPassword; + + const { crypto, srp } = await this.cryptoService.encryptPublicLink(publicLink.creatorEmail, share.passphraseSessionKey, password); + await this.apiService.updatePublicLink(publicLink.uid, { + role: options.role, + includesCustomPassword: !!options.customPassword, + expirationDuration: options.expiration ? Math.floor((options.expiration.getTime() - Date.now()) / 1000) : undefined, + crypto, + srp, + }); + + return { + ...publicLink, + role: options.role, + customPassword: options.customPassword, + expirationTime: options.expiration, + } } private async removeSharedLink(publicLinkUid: string): Promise { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 717cf871..ac736f55 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -80,6 +80,7 @@ export class ProtonDriveClient { cryptoCache, account, openPGPCryptoModule, + srpModule, config, telemetry, }: ProtonDriveClientContructorParameters) { @@ -90,7 +91,7 @@ export class ProtonDriveClient { const fullConfig = getConfig(config); this.sdkEvents = new SDKEvents(telemetry); - const cryptoModule = new DriveCrypto(openPGPCryptoModule); + const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); const apiService = new DriveAPIService(telemetry, this.sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); this.events = new DriveEventsService(telemetry, apiService, entitiesCache); this.shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 1267e912..033c6735 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -20,6 +20,7 @@ export class ProtonDrivePhotosClient { cryptoCache, account, openPGPCryptoModule, + srpModule, config, telemetry, }: ProtonDriveClientContructorParameters) { @@ -29,7 +30,7 @@ export class ProtonDrivePhotosClient { const fullConfig = getConfig(config); const sdkEvents = new SDKEvents(telemetry); - const cryptoModule = new DriveCrypto(openPGPCryptoModule); + const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); const apiService = new DriveAPIService(telemetry, sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); const events = new DriveEventsService(telemetry, apiService, entitiesCache); const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); @@ -38,18 +39,18 @@ export class ProtonDrivePhotosClient { } // Timeline or album view - iterateTimelinePhotos() {} // returns only UIDs and dates - used to show grid and scrolling - iterateAlbumPhotos() {} // same as above but for album - iterateThumbnails() {} // returns thumbnails for passed photos that are visible in the UI - getPhoto() {} // returns full photo details + iterateTimelinePhotos() { } // returns only UIDs and dates - used to show grid and scrolling + iterateAlbumPhotos() { } // same as above but for album + iterateThumbnails() { } // returns thumbnails for passed photos that are visible in the UI + getPhoto() { } // returns full photo details // Album management createAlbum(albumName: string) { return this.photos.albums.createAlbum(albumName); } - renameAlbum() {} - shareAlbum() {} - deleteAlbum() {} - iterateAlbums() {} - addPhotosToAlbum() {} + renameAlbum() { } + shareAlbum() { } + deleteAlbum() { } + iterateAlbums() { } + addPhotosToAlbum() { } } From f620c20a90eec5a0463660ac3a75684bc67b4dcd Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Jun 2025 10:18:59 +0000 Subject: [PATCH 132/791] adding a deprecated shareId prop to the Device object --- js/sdk/package.json | 2 +- js/sdk/src/interface/devices.ts | 2 ++ js/sdk/src/internal/devices/apiService.ts | 3 +++ js/sdk/src/internal/devices/interface.ts | 1 + js/sdk/src/internal/devices/manager.test.ts | 6 +++--- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 542417bc..59c333e6 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@proton/drive-sdk", - "version": "0.0.9", + "version": "0.0.10", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", diff --git a/js/sdk/src/interface/devices.ts b/js/sdk/src/interface/devices.ts index 20bc2e97..2a90f01c 100644 --- a/js/sdk/src/interface/devices.ts +++ b/js/sdk/src/interface/devices.ts @@ -8,6 +8,8 @@ export type Device = { rootFolderUid: string, creationTime: Date, lastSyncDate?: Date; + /** @deprecated to be removed once Volume-based navigation is implemented in web */ + shareId: string; } export enum DeviceType { diff --git a/js/sdk/src/internal/devices/apiService.ts b/js/sdk/src/internal/devices/apiService.ts index 1e8ad2fb..ce770486 100644 --- a/js/sdk/src/internal/devices/apiService.ts +++ b/js/sdk/src/internal/devices/apiService.ts @@ -31,6 +31,8 @@ export class DevicesAPIService { creationTime: new Date(device.Device.CreateTime*1000), lastSyncTime: device.Device.LastSyncTime ? new Date(device.Device.LastSyncTime*1000) : undefined, hasDeprecatedName: !!device.Share.Name, + /** @deprecated to be removed once Volume-based navigation is implemented in web */ + shareId: device.Share.ShareID, })); } @@ -103,6 +105,7 @@ export class DevicesAPIService { rootFolderUid: makeNodeUid(device.volumeId, response.Device.LinkID), creationTime: new Date(), hasDeprecatedName: false, + shareId: response.Device.ShareID, } } diff --git a/js/sdk/src/internal/devices/interface.ts b/js/sdk/src/internal/devices/interface.ts index e504749d..0bad7244 100644 --- a/js/sdk/src/internal/devices/interface.ts +++ b/js/sdk/src/internal/devices/interface.ts @@ -9,6 +9,7 @@ export type DeviceMetadata = { creationTime: Date, lastSyncTime?: Date; hasDeprecatedName: boolean; + shareId: string; } export interface SharesService { diff --git a/js/sdk/src/internal/devices/manager.test.ts b/js/sdk/src/internal/devices/manager.test.ts index 74226ca7..cc42f8e5 100644 --- a/js/sdk/src/internal/devices/manager.test.ts +++ b/js/sdk/src/internal/devices/manager.test.ts @@ -55,7 +55,7 @@ describe('DevicesManager', () => { const address = { addressId: 'address123', addressKeyId: 'key123' }; const shareKey = { armoredKey: 'armoredKey', armoredPassphrase: 'passphrase', armoredPassphraseSignature: 'signature' }; const node = { encryptedName: 'encryptedName', key: { armoredKey: 'nodeKey', armoredPassphrase: 'nodePassphrase', armoredPassphraseSignature: 'nodeSignature' }, armoredHashKey: 'hashKey' }; - const createdDevice = { uid: 'device123', rootFolderUid: 'rootFolder123', type: deviceType } as DeviceMetadata; + const createdDevice = { uid: 'device123', rootFolderUid: 'rootFolder123', type: deviceType, shareId: 'shareid' } as DeviceMetadata; sharesService.getMyFilesIDs.mockResolvedValue({ volumeId }); cryptoService.createDevice.mockResolvedValue({ address, shareKey, node }); @@ -88,7 +88,7 @@ describe('DevicesManager', () => { it('renames device with deprecated name', async () => { const deviceUid = 'device123'; const name = 'New Device Name'; - const device = { uid: deviceUid, rootFolderUid: 'rootFolder123', hasDeprecatedName: true } as DeviceMetadata; + const device = { uid: deviceUid, rootFolderUid: 'rootFolder123', hasDeprecatedName: true, shareId: 'shareid' } as DeviceMetadata; apiService.getDevices.mockResolvedValue([device]); @@ -103,7 +103,7 @@ describe('DevicesManager', () => { it('renames device without deprecated name', async () => { const deviceUid = 'device123'; const name = 'New Device Name'; - const device = { uid: deviceUid, rootFolderUid: 'rootFolder123', hasDeprecatedName: false } as DeviceMetadata; + const device = { uid: deviceUid, rootFolderUid: 'rootFolder123', hasDeprecatedName: false, shareId: 'shareid' } as DeviceMetadata; apiService.getDevices.mockResolvedValue([device]); From 34894da131dbbc0238fbd653f0a1f9d7c924ebf6 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Jun 2025 13:02:44 +0000 Subject: [PATCH 133/791] Prepare for open source --- LICENSE.md | 22 ++++++++++++++++++++-- README.md | 6 ++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index 7b7ce730..c9e43613 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,3 +1,21 @@ -# License +The MIT License -TBD +Copyright (c) 2025 Proton AG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 1726f757..596f51ef 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# Drive SDK +# Proton Drive SDK Copyright (c) 2025 Proton AG -TBD +This repository contains the Proton Drive SDK for JavaScript and C# (coming soon). + +At this point, the SDK is not ready for use. It is a work in progress. ## Contributions From bade72aac6223c0add48aada6e468ab509db5b46 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 2 Jul 2025 09:18:02 +0000 Subject: [PATCH 134/791] Migrate to playwright --- js/sdk/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index fe64cfc7..1e1e2e99 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@proton/drive-sdk", - "version": "0.0.8", + "version": "0.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@proton/drive-sdk", - "version": "0.0.8", + "version": "0.0.9", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", From d14e5233a65726d7354fe9d3fae9be96dd01aa26 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 2 Jul 2025 09:30:17 +0000 Subject: [PATCH 135/791] Add missing re-export of the interface --- js/sdk/src/interface/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index b96ad700..a1811f2b 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -18,7 +18,7 @@ export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, SharePublicLinkSettingsObject, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; -export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent } from './telemetry'; +export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricBlockVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent } from './telemetry'; export { MetricContext } from './telemetry'; export type { Fileuploader, UploadController, UploadMetadata } from './upload'; export type { Thumbnail, ThumbnailResult } from './thumbnail'; From be2a437d54979d2b027b3d0a3c229b043f537d18 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 2 Jul 2025 09:36:27 +0000 Subject: [PATCH 136/791] Switch to public npm registry --- js/sdk/package.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 59c333e6..31ce106e 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,5 +1,5 @@ { - "name": "@proton/drive-sdk", + "name": "@protontech/drive-sdk", "version": "0.0.10", "description": "Proton Drive SDK", "license": "GPL-3.0", @@ -45,8 +45,5 @@ "ttag-cli": "^1.10.18", "typedoc": "^0.26.11", "typescript": "^5.6.3" - }, - "publishConfig": { - "registry": "https://nexus.protontech.ch/repository/drive-npm/" } } \ No newline at end of file From 1b0c8a4b35e461d5ca1fb9997d36e74184015efe Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 3 Jul 2025 13:30:54 +0000 Subject: [PATCH 137/791] Align error categories for upload/download telemetry with definitions --- js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/telemetry.ts | 14 ++++---- js/sdk/src/internal/download/interface.ts | 4 +-- .../src/internal/download/telemetry.test.ts | 32 +++++++++++-------- js/sdk/src/internal/download/telemetry.ts | 13 +++++--- .../src/internal/nodes/cryptoService.test.ts | 4 +-- js/sdk/src/internal/nodes/cryptoService.ts | 12 +++---- js/sdk/src/internal/nodes/interface.ts | 4 +-- .../src/internal/shares/cryptoService.test.ts | 4 +-- js/sdk/src/internal/shares/cryptoService.ts | 14 ++++---- js/sdk/src/internal/shares/manager.ts | 8 ++--- js/sdk/src/internal/upload/interface.ts | 4 +-- js/sdk/src/internal/upload/telemetry.test.ts | 16 ++++++---- js/sdk/src/internal/upload/telemetry.ts | 13 +++++--- 14 files changed, 79 insertions(+), 65 deletions(-) diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index a1811f2b..5b0e1fa1 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -19,7 +19,7 @@ export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, SharePublicLinkSettingsObject, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricBlockVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent } from './telemetry'; -export { MetricContext } from './telemetry'; +export { MetricVolumeType } from './telemetry'; export type { Fileuploader, UploadController, UploadMetadata } from './upload'; export type { Thumbnail, ThumbnailResult } from './thumbnail'; export { ThumbnailType } from './thumbnail'; diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index febf97a9..f0863e30 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -27,10 +27,11 @@ export interface MetricAPIRetrySucceededEvent { export interface MetricUploadEvent { eventName: 'upload', - context?: MetricContext, + volumeType?: MetricVolumeType, uploadedSize: number, expectedSize: number, error?: MetricsUploadErrorType, + originalError?: unknown, }; export type MetricsUploadErrorType = 'server_error' | @@ -38,15 +39,15 @@ export type MetricsUploadErrorType = 'integrity_error' | 'rate_limited' | '4xx' | - '5xx' | 'unknown'; export interface MetricDownloadEvent { eventName: 'download', - context?: MetricContext, + volumeType?: MetricVolumeType, downloadedSize: number, claimedFileSize?: number, error?: MetricsDownloadErrorType, + originalError?: unknown, }; export type MetricsDownloadErrorType = 'server_error' | @@ -55,12 +56,11 @@ export type MetricsDownloadErrorType = 'integrity_error' | 'rate_limited' | '4xx' | - '5xx' | 'unknown'; export interface MetricDecryptionErrorEvent { eventName: 'decryptionError', - context?: MetricContext, + volumeType?: MetricVolumeType, field: MetricsDecryptionErrorField, fromBefore2024?: boolean, error?: unknown, @@ -76,7 +76,7 @@ export type MetricsDecryptionErrorField = export interface MetricVerificationErrorEvent { eventName: 'verificationError', - context?: MetricContext, + volumeType?: MetricVolumeType, field: MetricVerificationErrorField, addressMatchingDefaultShare?: boolean, fromBefore2024?: boolean, @@ -100,7 +100,7 @@ export interface MetricVolumeEventsSubscriptionsChangedEvent { numberOfVolumeSubscriptions: number, }; -export enum MetricContext { +export enum MetricVolumeType { OwnVolume = 'own_volume', Shared = 'shared', SharedPublic = 'shared_public', diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index ffae243e..1634706c 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, PublicKey, SessionKey } from "../../crypto"; -import { NodeType, Result, Revision, MissingNode, MetricContext } from "../../interface"; +import { NodeType, Result, Revision, MissingNode, MetricVolumeType } from "../../interface"; import { DecryptedNode } from "../nodes"; export type BlockMetadata = { @@ -18,7 +18,7 @@ export type RevisionKeys = { } export interface SharesService { - getVolumeMetricContext(volumeId: string): Promise, + getVolumeMetricContext(volumeId: string): Promise, } export interface NodesService { diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts index 0fbbd384..88dd6304 100644 --- a/js/sdk/src/internal/download/telemetry.test.ts +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -31,25 +31,29 @@ describe('DownloadTelemetry', () => { }); it('should log failure during init (excludes file size)', async () => { - await downloadTelemetry.downloadInitFailed(nodeUid, new Error('Failed')); + const error = new Error('Failed'); + await downloadTelemetry.downloadInitFailed(nodeUid, error); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "download", - context: "own_volume", + volumeType: "own_volume", downloadedSize: 0, error: "unknown", + originalError: error, }); }); it('should log failure download', async () => { - await downloadTelemetry.downloadFailed(revisionUid, new Error('Failed'), 123, 456); + const error = new Error('Failed'); + await downloadTelemetry.downloadFailed(revisionUid, error, 123, 456); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "download", - context: "own_volume", + volumeType: "own_volume", downloadedSize: 123, claimedFileSize: 456, error: "unknown", + originalError: error, }); }); @@ -58,7 +62,7 @@ describe('DownloadTelemetry', () => { expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "download", - context: "own_volume", + volumeType: "own_volume", downloadedSize: 500, claimedFileSize: 500, }); @@ -78,12 +82,12 @@ describe('DownloadTelemetry', () => { await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); }); - + it('should ignore AbortError', async () => { const error = new Error('Aborted'); error.name = 'AbortError'; await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); - + expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); }); @@ -92,38 +96,38 @@ describe('DownloadTelemetry', () => { await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('rate_limited'); }); - + it('should detect "decryption_error" for DecryptionError', async () => { const error = new DecryptionError('Decryption failed'); await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('decryption_error'); }); - + it('should detect "integrity_error" for IntegrityError', async () => { const error = new IntegrityError('Integrity check failed'); await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('integrity_error'); }); - + it('should detect "4xx" error for APIHTTPError with 4xx status code', async () => { const error = new APIHTTPError('Client error', 404); await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('4xx'); }); - + it('should detect "5xx" error for APIHTTPError with 5xx status code', async () => { const error = new APIHTTPError('Server error', 500); await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); - verifyErrorCategory('5xx'); + verifyErrorCategory('server_error'); }); - + it('should detect "server_error" for TimeoutError', async () => { const error = new Error('Timeout'); error.name = 'TimeoutError'; await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('server_error'); }); - + it('should detect "network_error" for NetworkError', async () => { const error = new Error('Network error'); error.name = 'NetworkError'; diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index baeb6169..ecedabff 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -31,6 +31,7 @@ export class DownloadTelemetry { await this.sendTelemetry(volumeId, { downloadedSize: 0, error: errorCategory, + originalError: error, }); } @@ -48,6 +49,7 @@ export class DownloadTelemetry { downloadedSize, claimedFileSize, error: errorCategory, + originalError: error, }); } @@ -63,17 +65,18 @@ export class DownloadTelemetry { downloadedSize: number, claimedFileSize?: number, error?: MetricsDownloadErrorType, + originalError?: unknown, }) { - let context; + let volumeType; try { - context = await this.sharesService.getVolumeMetricContext(volumeId); + volumeType = await this.sharesService.getVolumeMetricContext(volumeId); } catch (error: unknown) { - this.logger.error('Failed to get metric context', error); + this.logger.error('Failed to get metric volume type', error); } this.telemetry.logEvent({ eventName: 'download', - context, + volumeType, ...options, }); } @@ -97,7 +100,7 @@ function getErrorCategory(error: unknown): MetricsDownloadErrorType | undefined return '4xx'; } if (error.statusCode >= 500) { - return '5xx'; + return 'server_error'; } } if (error instanceof Error) { diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index f19320a5..633d41a9 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -66,7 +66,7 @@ describe("nodesCryptoService", () => { expect(telemetry.logEvent).toHaveBeenCalledTimes(1); expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: "verificationError", - context: "own_volume", + volumeType: "own_volume", fromBefore2024: false, addressMatchingDefaultShare: false, ...options, @@ -77,7 +77,7 @@ describe("nodesCryptoService", () => { expect(telemetry.logEvent).toHaveBeenCalledTimes(1); expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: "decryptionError", - context: "own_volume", + volumeType: "own_volume", fromBefore2024: false, ...options, }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 9b16c56e..2cf0f539 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -481,12 +481,12 @@ export class NodesCryptoService { const fromBefore2024 = node.creationTime < new Date('2024-01-01'); - let addressMatchingDefaultShare, context; + let addressMatchingDefaultShare, volumeType; try { const { volumeId } = splitNodeUid(node.uid); const { email } = await this.shareService.getMyFilesShareMemberEmailKey(); addressMatchingDefaultShare = claimedAuthor ? claimedAuthor === email : undefined; - context = await this.shareService.getVolumeMetricContext(volumeId); + volumeType = await this.shareService.getVolumeMetricContext(volumeId); } catch (error: unknown) { this.logger.error('Failed to check if claimed author matches default share', error); } @@ -495,7 +495,7 @@ export class NodesCryptoService { this.telemetry.logEvent({ eventName: 'verificationError', - context, + volumeType, field, addressMatchingDefaultShare, fromBefore2024, @@ -510,10 +510,10 @@ export class NodesCryptoService { const fromBefore2024 = node.creationTime < new Date('2024-01-01'); - let context; + let volumeType; try { const { volumeId } = splitNodeUid(node.uid); - context = await this.shareService.getVolumeMetricContext(volumeId); + volumeType = await this.shareService.getVolumeMetricContext(volumeId); } catch (error: unknown) { this.logger.error('Failed to get metric context', error); } @@ -522,7 +522,7 @@ export class NodesCryptoService { this.telemetry.logEvent({ eventName: 'decryptionError', - context, + volumeType, field, fromBefore2024, error, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index e1bcbac7..fd1522e4 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType, MetricContext, Revision, RevisionState } from "../../interface"; +import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType, MetricVolumeType, Revision, RevisionState } from "../../interface"; /** * Internal common node interface for both encrypted or decrypted node. @@ -148,5 +148,5 @@ export interface SharesService { addressKey: PrivateKey, addressKeyId: string, }>, - getVolumeMetricContext(volumeId: string): Promise, + getVolumeMetricContext(volumeId: string): Promise, } diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts index a260d996..5e139aa4 100644 --- a/js/sdk/src/internal/shares/cryptoService.test.ts +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -99,7 +99,7 @@ describe("SharesCryptoService", () => { expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: 'verificationError', - context: 'own_volume', + volumeType: 'own_volume', field: 'shareKey', addressMatchingDefaultShare: undefined, fromBefore2024: undefined, @@ -128,7 +128,7 @@ describe("SharesCryptoService", () => { expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: 'decryptionError', - context: 'own_volume', + volumeType: 'own_volume', field: 'shareKey', fromBefore2024: undefined, error, diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index eff8db6b..36f23c60 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -1,4 +1,4 @@ -import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger, MetricContext } from "../../interface"; +import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger, MetricVolumeType } from "../../interface"; import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto"; import { getVerificationMessage } from "../errors"; import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareKey, ShareType } from "./interface"; @@ -91,7 +91,7 @@ export class SharesCryptoService { }, } } - + private reportDecryptionError(share: EncryptedRootShare, error?: unknown) { if (this.reportedDecryptionErrors.has(share.shareId)) { return; @@ -102,7 +102,7 @@ export class SharesCryptoService { this.telemetry.logEvent({ eventName: 'decryptionError', - context: shareTypeToMetricContext(share.type), + volumeType: shareTypeToMetricContext(share.type), field: 'shareKey', fromBefore2024, error, @@ -120,7 +120,7 @@ export class SharesCryptoService { this.telemetry.logEvent({ eventName: 'verificationError', - context: shareTypeToMetricContext(share.type), + volumeType: shareTypeToMetricContext(share.type), field: 'shareKey', fromBefore2024, }); @@ -128,7 +128,7 @@ export class SharesCryptoService { } } -function shareTypeToMetricContext(shareType: ShareType): MetricContext { +function shareTypeToMetricContext(shareType: ShareType): MetricVolumeType { // SDK doesn't support public sharing yet, also public sharing // doesn't use a share but shareURL, thus we can simplify and // ignore this case for now. @@ -136,8 +136,8 @@ function shareTypeToMetricContext(shareType: ShareType): MetricContext { case ShareType.Main: case ShareType.Device: case ShareType.Photo: - return MetricContext.OwnVolume; + return MetricVolumeType.OwnVolume; case ShareType.Standard: - return MetricContext.Shared; + return MetricVolumeType.Shared; } } diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 8e5f0c9a..d0f654e7 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -1,4 +1,4 @@ -import { Logger, MetricContext, ProtonDriveAccount } from "../../interface"; +import { Logger, MetricVolumeType, ProtonDriveAccount } from "../../interface"; import { PrivateKey } from "../../crypto"; import { NotFoundAPIError } from "../apiService"; import { SharesAPIService } from "./apiService"; @@ -195,16 +195,16 @@ export class SharesManager { }; } - async getVolumeMetricContext(volumeId: string): Promise { + async getVolumeMetricContext(volumeId: string): Promise { const { volumeId: myVolumeId } = await this.getMyFilesIDs(); // SDK doesn't support public sharing yet, also public sharing // doesn't use a volume but shareURL, thus we can simplify and // ignore this case for now. if (volumeId === myVolumeId) { - return MetricContext.OwnVolume; + return MetricVolumeType.OwnVolume; } - return MetricContext.Shared; + return MetricVolumeType.Shared; } async loadEncryptedShare(shareId: string): Promise { diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 1801e173..04d08d23 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,6 +1,6 @@ import { PrivateKey, SessionKey } from "../../crypto"; -import { MetricContext, ThumbnailType, Result, Revision } from "../../interface"; +import { MetricVolumeType, ThumbnailType, Result, Revision } from "../../interface"; import { DecryptedNode } from "../nodes"; export type NodeRevisionDraft = { @@ -124,5 +124,5 @@ export interface NodesServiceNode { * Interface describing the dependencies to the shares module. */ export interface SharesService { - getVolumeMetricContext(volumeId: string): Promise, + getVolumeMetricContext(volumeId: string): Promise, } diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index 9a97c583..86c30fe8 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -31,26 +31,30 @@ describe('UploadTelemetry', () => { }); it('should log failure during init (excludes uploaded size)', async () => { - await uploadTelemetry.uploadInitFailed(parentNodeUid, new Error('Failed'), 1000); + const error = new Error('Failed'); + await uploadTelemetry.uploadInitFailed(parentNodeUid, error, 1000); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "upload", - context: "own_volume", + volumeType: "own_volume", uploadedSize: 0, expectedSize: 1000, error: "unknown", + originalError: error, }); }); it('should log failure upload', async () => { - await uploadTelemetry.uploadFailed(revisionUid, new Error('Failed'), 500, 1000); + const error = new Error('Failed'); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "upload", - context: "own_volume", + volumeType: "own_volume", uploadedSize: 500, expectedSize: 1000, error: "unknown", + originalError: error, }); }); @@ -59,7 +63,7 @@ describe('UploadTelemetry', () => { expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ eventName: "upload", - context: "own_volume", + volumeType: "own_volume", uploadedSize: 1000, expectedSize: 1000, }); @@ -109,7 +113,7 @@ describe('UploadTelemetry', () => { it('should detect "5xx" error for APIHTTPError with 5xx status code', async () => { const error = new APIHTTPError('Server error', 500); await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); - verifyErrorCategory('5xx'); + verifyErrorCategory('server_error'); }); it('should detect "server_error" for TimeoutError', async () => { diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 0cccd601..39ccc0da 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -40,6 +40,7 @@ export class UploadTelemetry { uploadedSize: 0, expectedSize, error: errorCategory, + originalError: error, }); } @@ -57,6 +58,7 @@ export class UploadTelemetry { uploadedSize, expectedSize, error: errorCategory, + originalError: error, }); } @@ -72,17 +74,18 @@ export class UploadTelemetry { uploadedSize: number, expectedSize: number, error?: MetricsUploadErrorType, + originalError?: unknown, }) { - let context; + let volumeType; try { - context = await this.sharesService.getVolumeMetricContext(volumeId); + volumeType = await this.sharesService.getVolumeMetricContext(volumeId); } catch (error: unknown) { - this.logger.error('Failed to get metric context', error); + this.logger.error('Failed to get metric volume type', error); } this.telemetry.logEvent({ eventName: 'upload', - context, + volumeType, ...options, }); } @@ -103,7 +106,7 @@ function getErrorCategory(error: unknown): MetricsUploadErrorType | undefined { return '4xx'; } if (error.statusCode >= 500) { - return '5xx'; + return 'server_error'; } } if (error instanceof Error) { From d1f9473267cce5f3853e14657f4109797b47cbc2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 4 Jul 2025 06:41:54 +0000 Subject: [PATCH 138/791] Use ExpirationTime instead of ExpirationDuration for public link management --- js/sdk/src/internal/sharing/apiService.ts | 20 +++++++++---------- .../sharing/sharingManagement.test.ts | 6 +++--- .../src/internal/sharing/sharingManagement.ts | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index cc97cded..06b017cc 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -51,7 +51,7 @@ type PutShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls/{urlID}']['p /** * Provides API communication for fetching and managing sharing. - * + * * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ @@ -380,7 +380,7 @@ export class SharingAPIService { creatorEmail: string, role: MemberRole, includesCustomPassword: boolean, - expirationDuration?: number, + expirationTime?: number, crypto: EncryptedPublicLinkCrypto, srp: SRPVerifier, }): Promise<{ @@ -392,8 +392,8 @@ export class SharingAPIService { } const result = await this.apiService.post< - // TODO: Backend type wrongly requires ExpirationTime and Name. - Omit, + // TODO: Backend type wrongly requires ExpirationDuration (it should be optional) and Name (it is not used). + Omit, PostShareUrlResponse >(`drive/shares/${shareId}/urls`, { CreatorEmail: publicLink.creatorEmail, @@ -408,7 +408,7 @@ export class SharingAPIService { async updatePublicLink(publicLinkUid: string, publicLink: { role: MemberRole, includesCustomPassword: boolean, - expirationDuration?: number, + expirationTime?: number, crypto: EncryptedPublicLinkCrypto, srp: SRPVerifier, }): Promise { @@ -419,8 +419,8 @@ export class SharingAPIService { const { shareId, publicLinkId } = splitPublicLinkUid(publicLinkUid); await this.apiService.put< - // TODO: Backend type wrongly requires ExpirationTime and Name. - Omit, + // TODO: Backend type wrongly requires ExpirationTime (it should be optional) and Name (it is not used). + Omit & { ExpirationTime: number | null }, PutShareUrlResponse >(`drive/shares/${shareId}/urls/${publicLinkId}`, this.generatePublicLinkRequestPayload(publicLink)); } @@ -428,16 +428,16 @@ export class SharingAPIService { private generatePublicLinkRequestPayload(publicLink: { role: MemberRole, includesCustomPassword: boolean, - expirationDuration?: number, + expirationTime?: number, crypto: EncryptedPublicLinkCrypto, srp: SRPVerifier, - }): Pick { + }): Pick { return { Permissions: memberRoleToPermission(publicLink.role) as 4 | 6, Flags: publicLink.includesCustomPassword ? 3 // Random + custom password set. : 2, // Random password set. - ExpirationDuration: publicLink.expirationDuration || null, + ExpirationTime: publicLink.expirationTime || null, SharePasswordSalt: publicLink.crypto.base64SharePasswordSalt, SharePassphraseKeyPacket: publicLink.crypto.base64SharePassphraseKeyPacket, diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index bb62e227..17fe8488 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -533,7 +533,7 @@ describe("SharingManagement", () => { expect(apiService.createPublicLink).toHaveBeenCalledWith("shareId", expect.objectContaining({ role: MemberRole.Viewer, includesCustomPassword: false, - expirationDuration: undefined, + expirationTime: undefined, crypto: "publicLinkCrypto", srp: "publicLinkSrp", })); @@ -570,7 +570,7 @@ describe("SharingManagement", () => { expect(apiService.createPublicLink).toHaveBeenCalledWith("shareId", expect.objectContaining({ role: MemberRole.Viewer, includesCustomPassword: true, - expirationDuration: 86400, + expirationTime: 1735776000, crypto: "publicLinkCrypto", srp: "publicLinkSrp", })); @@ -617,7 +617,7 @@ describe("SharingManagement", () => { expect(apiService.updatePublicLink).toHaveBeenCalledWith("publicLinkUid", expect.objectContaining({ role: MemberRole.Editor, includesCustomPassword: true, - expirationDuration: 86400, + expirationTime: 1735776000, crypto: "publicLinkCrypto", srp: "publicLinkSrp", })); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index e83101fd..f85bfd38 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -474,7 +474,7 @@ export class SharingManagement { creatorEmail, role: options.role, includesCustomPassword: !!options.customPassword, - expirationDuration: options.expiration ? Math.floor((options.expiration.getTime() - Date.now()) / 1000) : undefined, + expirationTime: options.expiration ? Math.floor(options.expiration.getTime() / 1000) : undefined, crypto, srp, }); @@ -502,7 +502,7 @@ export class SharingManagement { await this.apiService.updatePublicLink(publicLink.uid, { role: options.role, includesCustomPassword: !!options.customPassword, - expirationDuration: options.expiration ? Math.floor((options.expiration.getTime() - Date.now()) / 1000) : undefined, + expirationTime: options.expiration ? Math.floor(options.expiration.getTime() / 1000) : undefined, crypto, srp, }); From f0fe3c567f8e3bf449092cd8dcc9629f3b0ff0e3 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 4 Jul 2025 16:08:11 +0200 Subject: [PATCH 139/791] Add logging of HTTP requests and responses --- cs/Directory.Build.props | 3 --- cs/Directory.Build.targets | 9 +++++++++ .../Proton.Sdk/ProtonClientConfigurationExtensions.cs | 8 ++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 cs/Directory.Build.targets diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 3f5ede72..d1c74fbc 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -4,9 +4,6 @@ net9.0 true - - linux-x64 - Proton Drive Proton AG Proton AG diff --git a/cs/Directory.Build.targets b/cs/Directory.Build.targets new file mode 100644 index 00000000..64b81d91 --- /dev/null +++ b/cs/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + + $(NETCoreSdkRuntimeIdentifier) + $(DefineConstants);WINDOWS + + + diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index 561353b9..cb74555f 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -22,12 +22,18 @@ public static HttpClient GetHttpClient( var services = new ServiceCollection(); + services.AddSingleton(config.LoggerFactory); + services.ConfigureHttpClientDefaults( builder => { + builder.RedactLoggedHeaders(header => header.StartsWith("Auth")); + builder.UseSocketsHttpHandler( (handler, _) => { + handler.PooledConnectionLifetime = TimeSpan.FromMinutes(2); + handler.AddAutomaticDecompression(); handler.ConfigureCookies(CookieContainer); @@ -45,6 +51,8 @@ public static HttpClient GetHttpClient( } }); + builder.SetHandlerLifetime(Timeout.InfiniteTimeSpan); + if (config.CustomHttpMessageHandlerFactory is not null) { builder.AddHttpMessageHandler(() => config.CustomHttpMessageHandlerFactory.Invoke()); From eb7d6619c65bbb89c71645910e198f6699d72412 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 4 Jul 2025 13:29:00 +0200 Subject: [PATCH 140/791] Simplify result types into a single type --- .../Caching/DriveEntityCache.cs | 2 +- .../Caching/DriveSecretCache.cs | 20 ++-- .../Caching/IDriveEntityCache.cs | 2 +- .../Caching/IDriveSecretCache.cs | 8 +- .../Nodes/AuthorshipClaimExtensions.cs | 2 +- .../Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs | 2 +- .../Cryptography/FileDecryptionResult.cs | 4 +- .../Cryptography/FolderDecryptionResult.cs | 2 +- .../Cryptography/LinkDecryptionResult.cs | 6 +- .../Nodes/Cryptography/NodeCrypto.cs | 28 +++--- .../Proton.Drive.Sdk/Nodes/DegradedNode.cs | 6 +- .../Nodes/DtoToMetadataConverter.cs | 52 +++++------ .../Nodes/FolderChildrenBatchLoader.cs | 6 +- .../Nodes/FolderOperations.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 4 +- .../Nodes/NodeMetadataResultExtensions.cs | 20 ++-- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 21 +++-- .../Nodes/NodeResultExtensions.cs | 6 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 2 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 4 +- .../DriveSecretsSerializerContext.cs | 4 +- .../Volumes/VolumeOperations.cs | 2 +- .../Volumes/VolumeTrashBatchLoader.cs | 6 +- cs/sdk/src/Proton.Sdk/RefResultExtensions.cs | 92 ------------------- .../Proton.Sdk/{RefResult.cs => Result.cs} | 16 ++-- cs/sdk/src/Proton.Sdk/ResultExtensions.cs | 62 +++++++++++++ .../Serialization/RefResultJsonConverter.cs | 8 +- .../Serialization/ValResultJsonConverter.cs | 10 +- cs/sdk/src/Proton.Sdk/ValResult.cs | 38 -------- cs/sdk/src/Proton.Sdk/ValResultExtensions.cs | 86 ----------------- 30 files changed, 182 insertions(+), 341 deletions(-) delete mode 100644 cs/sdk/src/Proton.Sdk/RefResultExtensions.cs rename cs/sdk/src/Proton.Sdk/{RefResult.cs => Result.cs} (59%) create mode 100644 cs/sdk/src/Proton.Sdk/ResultExtensions.cs delete mode 100644 cs/sdk/src/Proton.Sdk/ValResult.cs delete mode 100644 cs/sdk/src/Proton.Sdk/ValResultExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 485dd442..741d53da 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -69,7 +69,7 @@ public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) public ValueTask SetNodeAsync( NodeUid nodeId, - RefResult nodeProvisionResult, + Result nodeProvisionResult, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs index 1364861a..94976f79 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -31,43 +31,39 @@ public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, Cance public ValueTask SetFolderSecretsAsync( NodeUid nodeId, - RefResult secretsProvisionResult, + Result secretsProvisionResult, CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize( - secretsProvisionResult, - DriveSecretsSerializerContext.Default.NullableRefResultFolderSecretsDegradedFolderSecrets); + var serializedValue = JsonSerializer.Serialize(secretsProvisionResult, DriveSecretsSerializerContext.Default.ResultFolderSecretsDegradedFolderSecrets); return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { var serializedValue = await _repository.TryGetAsync(GetFolderSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableRefResultFolderSecretsDegradedFolderSecrets) + ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableResultFolderSecretsDegradedFolderSecrets) : null; } public ValueTask SetFileSecretsAsync( NodeUid nodeId, - RefResult secretsProvisionResult, + Result secretsProvisionResult, CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize( - secretsProvisionResult, - DriveSecretsSerializerContext.Default.NullableRefResultFileSecretsDegradedFileSecrets); + var serializedValue = JsonSerializer.Serialize(secretsProvisionResult, DriveSecretsSerializerContext.Default.ResultFileSecretsDegradedFileSecrets); return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { var serializedValue = await _repository.TryGetAsync(GetFileSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableRefResultFileSecretsDegradedFileSecrets) + ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableResultFileSecretsDegradedFileSecrets) : null; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index c5e58988..685cb9b0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -22,7 +22,7 @@ internal interface IDriveEntityCache ValueTask SetNodeAsync( NodeUid nodeId, - RefResult nodeProvisionResult, + Result nodeProvisionResult, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest, CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs index ba334581..d1970666 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -12,11 +12,11 @@ internal interface IDriveSecretCache ValueTask SetFolderSecretsAsync( NodeUid nodeId, - RefResult secretsProvisionResult, + Result secretsProvisionResult, CancellationToken cancellationToken); - ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); - ValueTask SetFileSecretsAsync(NodeUid nodeId, RefResult secretsProvisionResult, CancellationToken cancellationToken); - ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask SetFileSecretsAsync(NodeUid nodeId, Result secretsProvisionResult, CancellationToken cancellationToken); + ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs index 7574b4fe..47dfc81c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk.Nodes; internal static class AuthorshipClaimExtensions { - public static ValResult ToAuthorshipResult( + public static Result ToAuthorshipResult( this AuthorshipClaim authorshipClaim, AuthorshipVerificationFailure? verificationFailure) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs index 5dfab8e8..1dfc4d50 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs @@ -4,6 +4,6 @@ namespace Proton.Drive.Sdk.Nodes; internal readonly record struct CachedNodeInfo( - RefResult NodeProvisionResult, + Result NodeProvisionResult, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs index 866a2c61..900fc0aa 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs @@ -6,7 +6,7 @@ namespace Proton.Drive.Sdk.Nodes.Cryptography; internal sealed class FileDecryptionResult { public required LinkDecryptionResult Link { get; init; } - public required ValResult, string?> ContentKey { get; init; } - public required ValResult, string?> ExtendedAttributes { get; init; } + public required Result, string?> ContentKey { get; init; } + public required Result, string?> ExtendedAttributes { get; init; } public required AuthorshipClaim ContentAuthorshipClaim { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs index 4e357c36..c017812a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs @@ -5,5 +5,5 @@ namespace Proton.Drive.Sdk.Nodes.Cryptography; internal sealed class FolderDecryptionResult { public required LinkDecryptionResult Link { get; init; } - public required ValResult>, string?> HashKey { get; init; } + public required Result>, string?> HashKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs index affe231f..920cf8b6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs @@ -5,9 +5,9 @@ namespace Proton.Drive.Sdk.Nodes.Cryptography; internal sealed class LinkDecryptionResult { - public required ValResult>, string> Passphrase { get; init; } + public required Result>, string> Passphrase { get; init; } public required AuthorshipClaim NodeAuthorshipClaim { get; init; } - public required ValResult, string> Name { get; init; } + public required Result, string> Name { get; init; } public required AuthorshipClaim NameAuthorshipClaim { get; init; } - public required ValResult NodeKey { get; init; } + public required Result NodeKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index d5f15def..8478f966 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -17,7 +17,7 @@ public static async ValueTask DecryptFolderAsync( ProtonDriveClient client, LinkDto link, FolderDto folder, - ValResult parentKeyResult, + Result parentKeyResult, CancellationToken cancellationToken) { var linkDecryptionResult = await DecryptLinkAsync(client, link, parentKeyResult, cancellationToken).ConfigureAwait(false); @@ -36,7 +36,7 @@ public static async ValueTask DecryptFileAsync( LinkDto linkDto, FileDto fileDto, ActiveRevisionDto activeRevisionDto, - ValResult parentKeyResult, + Result parentKeyResult, CancellationToken cancellationToken) { var contentAuthorshipClaim = @@ -82,7 +82,7 @@ public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHa private static async ValueTask DecryptLinkAsync( ProtonDriveClient client, LinkDto link, - ValResult parentKeyResult, + Result parentKeyResult, CancellationToken cancellationToken) { var nodeAuthorshipClaim = await AuthorshipClaim.CreateAsync(client, link.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); @@ -91,13 +91,13 @@ private static async ValueTask DecryptLinkAsync( ? await AuthorshipClaim.CreateAsync(client, link.NameSignatureEmailAddress, cancellationToken).ConfigureAwait(false) : nodeAuthorshipClaim; - ValResult, string> nameResult; - ValResult>, string> passphraseResult; + Result, string> nameResult; + Result>, string> passphraseResult; if (parentKeyResult.TryGetValueElseError(out var parentKey, out var parentNodeKeyInnerError)) { - nameResult = DecryptName(link.Name, parentKey.Value, nameAuthorshipClaim); - passphraseResult = DecryptPassphrase(parentKey.Value, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); + nameResult = DecryptName(link.Name, parentKey, nameAuthorshipClaim); + passphraseResult = DecryptPassphrase(parentKey, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); } else { @@ -106,7 +106,7 @@ private static async ValueTask DecryptLinkAsync( passphraseResult = errorMessage; } - var nodeKeyResult = UnlockNodeKey(link.Key, passphraseResult.GetValueOrDefault()?.Data); + var nodeKeyResult = UnlockNodeKey(link.Key, passphraseResult.Merge(x => (ReadOnlyMemory?)x.Data, _ => null)); return new LinkDecryptionResult { @@ -118,7 +118,7 @@ private static async ValueTask DecryptLinkAsync( }; } - private static ValResult>, string> DecryptPassphrase( + private static Result>, string> DecryptPassphrase( PgpPrivateKey parentNodeKey, PgpArmoredMessage encryptedPassphrase, PgpArmoredSignature? signature, @@ -136,7 +136,7 @@ private static ValResult>, string> D } } - private static ValResult UnlockNodeKey(PgpArmoredPrivateKey lockedKey, ReadOnlyMemory? passphrase) + private static Result UnlockNodeKey(PgpArmoredPrivateKey lockedKey, ReadOnlyMemory? passphrase) { if (passphrase is null) { @@ -153,7 +153,7 @@ private static ValResult>, string> D } } - private static ValResult, string> DecryptName( + private static Result, string> DecryptName( PgpArmoredMessage encryptedName, PgpPrivateKey parentNodeKey, AuthorshipClaim authorshipClaim) @@ -172,7 +172,7 @@ private static ValResult, string> DecryptName( } } - private static ValResult>, string?> DecryptHashKey( + private static Result>, string?> DecryptHashKey( PgpArmoredMessage? encryptedHashKey, PgpPrivateKey? nodeKey, AuthorshipClaim authorshipClaim) @@ -199,7 +199,7 @@ private static ValResult, string> DecryptName( } } - private static ValResult, string?> DecryptContentKey( + private static Result, string?> DecryptContentKey( PgpPrivateKey? nodeKey, ReadOnlyMemory contentKeyPacket, PgpArmoredSignature contentKeySignature, @@ -239,7 +239,7 @@ private static ValResult, string> DecryptName( return new DecryptionOutput(contentKey, verificationFailure); } - private static ValResult, string?> DecryptExtendedAttributes( + private static Result, string?> DecryptExtendedAttributes( PgpArmoredMessage? encryptedExtendedAttributes, PgpPrivateKey? nodeKey, AuthorshipClaim authorshipClaim) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs index 8f46a375..493bcfd4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs @@ -8,13 +8,13 @@ public abstract record DegradedNode public required NodeUid? ParentUid { get; init; } - public required RefResult Name { get; init; } + public required Result Name { get; init; } - public required ValResult NameAuthor { get; init; } + public required Result NameAuthor { get; init; } public DateTime? TrashTime { get; init; } - public required ValResult Author { get; init; } + public required Result Author { get; init; } public required IReadOnlyList Errors { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index a9c57d5e..14284ba6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -10,7 +10,7 @@ namespace Proton.Drive.Sdk.Nodes; internal static class DtoToMetadataConverter { - public static async Task> ConvertDtoToNodeMetadataAsync( + public static async Task> ConvertDtoToNodeMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, @@ -28,11 +28,11 @@ public static async Task> ConvertD return await ConvertDtoToNodeMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false); } - public static async Task> ConvertDtoToNodeMetadataAsync( + public static async Task> ConvertDtoToNodeMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, - ValResult parentKeyResult, + Result parentKeyResult, CancellationToken cancellationToken) { var linkType = linkDetailsDto.Link.Type; @@ -41,22 +41,22 @@ public static async Task> ConvertD { LinkType.Folder => (await ConvertDtoToFolderMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false)) - .ConvertVal(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), + .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), LinkType.File => (await ConvertDtoToFileMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false)) - .ConvertVal(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), + .Convert(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), // FIXME: handle other existing node types, and determine a way for forward compatibility or degraded result in case a new node type is introduced _ => throw new NotSupportedException($"Link type {linkType} is not supported."), }; } - public static async ValueTask> ConvertDtoToFolderMetadataAsync( + public static async ValueTask> ConvertDtoToFolderMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, - ValResult parentKeyResult, + Result parentKeyResult, CancellationToken cancellationToken) { var (linkDto, folderDto, _, membershipDto) = linkDetailsDto; @@ -93,9 +93,9 @@ public static async ValueTask> var degradedSecrets = new DegradedFolderSecrets { Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), - PassphraseSessionKey = decryptionResult.Link.Passphrase.GetValueOrDefault()?.SessionKey, + PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), NameSessionKey = nameSessionKey, - HashKey = decryptionResult.HashKey.GetValueOrDefault()?.Data, + HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), }; throw new NotImplementedException(); @@ -103,11 +103,11 @@ public static async ValueTask> var secrets = new FolderSecrets { - Key = nodeKey.Value, - PassphraseSessionKey = passphraseOutput.Value.SessionKey, + Key = nodeKey, + PassphraseSessionKey = passphraseOutput.SessionKey, NameSessionKey = nameSessionKey.Value, - HashKey = hashKeyOutput.Value.Data, - PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous ? passphraseOutput.Value.Data : null, + HashKey = hashKeyOutput.Data, + PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous ? passphraseOutput.Data : null, }; await client.Cache.Secrets.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); @@ -120,7 +120,7 @@ public static async ValueTask> NameAuthor = decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure), // FIXME: combine with verification failure from name hash key - Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.Value.AuthorshipVerificationFailure), + Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure), TrashTime = linkDto.TrashTime, }; @@ -129,11 +129,11 @@ public static async ValueTask> return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } - public static async Task> ConvertDtoToFileMetadataAsync( + public static async Task> ConvertDtoToFileMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, - ValResult parentKeyResult, + Result parentKeyResult, CancellationToken cancellationToken) { var (linkDto, _, fileDto, membershipDto) = linkDetailsDto; @@ -187,9 +187,9 @@ public static async Task> ConvertD var degradedSecrets = new DegradedFileSecrets { Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), - PassphraseSessionKey = decryptionResult.Link.Passphrase.GetValueOrDefault()?.SessionKey, + PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), NameSessionKey = nameSessionKey, - ContentKey = decryptionResult.ContentKey.GetValueOrDefault()?.Data, + ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), }; throw new NotImplementedException(); @@ -197,18 +197,18 @@ public static async Task> ConvertD var secrets = new FileSecrets { - Key = nodeKey.Value, - PassphraseSessionKey = passphraseOutput.Value.SessionKey, + Key = nodeKey, + PassphraseSessionKey = passphraseOutput.SessionKey, NameSessionKey = nameSessionKey.Value, - ContentKey = contentKeyOutput.Value.Data, + ContentKey = contentKeyOutput.Data, PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous - ? passphraseOutput.Value.Data + ? passphraseOutput.Data : (ReadOnlyMemory?)null, }; await client.Cache.Secrets.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); - var extendedAttributes = extendedAttributesOutput.Value.Data; + var extendedAttributes = extendedAttributesOutput.Data; var node = new FileNode { @@ -218,7 +218,7 @@ public static async Task> ConvertD NameAuthor = decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure), // FIXME: combine with verification failure from name hash key - Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.Value.AuthorshipVerificationFailure), + Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure), TrashTime = linkDto.TrashTime, MediaType = fileDto.MediaType, ActiveRevision = new Revision @@ -229,7 +229,7 @@ public static async Task> ConvertD ClaimedSize = extendedAttributes?.Common?.Size, ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, Thumbnails = [], // FIXME: thumbnails - ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.Value.AuthorshipVerificationFailure), + ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure), }, TotalStorageQuotaUsage = fileDto.TotalStorageQuotaUsage, }; @@ -239,7 +239,7 @@ public static async Task> ConvertD return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } - private static async ValueTask> GetParentKeyAsync( + private static async ValueTask> GetParentKeyAsync( ProtonDriveClient client, VolumeId volumeId, LinkId? parentId, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs index 1395faec..cb81f787 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs @@ -7,19 +7,19 @@ namespace Proton.Drive.Sdk.Nodes; internal sealed class FolderChildrenBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey parentKey) - : BatchLoaderBase> + : BatchLoaderBase> { private readonly ProtonDriveClient _client = client; private readonly VolumeId _volumeId = volumeId; private readonly PgpPrivateKey _parentKey = parentKey; - protected override async ValueTask>> LoadBatchAsync( + protected override async ValueTask>> LoadBatchAsync( ReadOnlyMemory ids, CancellationToken cancellationToken) { var response = await _client.Api.Links.GetDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); - var nodeResults = new List>(ids.Length); + var nodeResults = new List>(ids.Length); foreach (var linkDetails in response.Links) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 38a21141..b40b9c6f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -9,7 +9,7 @@ namespace Proton.Drive.Sdk.Nodes; internal static class FolderOperations { - public static async IAsyncEnumerable> EnumerateChildrenAsync( + public static async IAsyncEnumerable> EnumerateChildrenAsync( ProtonDriveClient client, NodeUid folderUid, [EnumeratorCancellation] CancellationToken cancellationToken = default) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index b4cd17c5..543a1661 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -17,7 +17,7 @@ public abstract record Node public DateTime? TrashTime { get; init; } - public required ValResult NameAuthor { get; init; } + public required Result NameAuthor { get; init; } - public required ValResult Author { get; init; } + public required Result Author { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs index 3a3dd407..77abf024 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs @@ -7,14 +7,14 @@ namespace Proton.Drive.Sdk.Nodes; internal static class NodeMetadataResultExtensions { - public static Node GetNodeOrThrow(this ValResult metadataResult) + public static Node GetNodeOrThrow(this Result metadataResult) { var metadata = metadataResult.GetValueOrThrow(); return metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? fileNode : folderNode; } - public static FolderNode GetFolderNodeOrThrow(this ValResult metadataResult) + public static FolderNode GetFolderNodeOrThrow(this Result metadataResult) { var metadata = metadataResult.GetValueOrThrow(); @@ -26,7 +26,7 @@ public static FolderNode GetFolderNodeOrThrow(this ValResult metadataResult) + public static FolderSecrets GetFolderSecretsOrThrow(this Result metadataResult) { var metadata = metadataResult.GetValueOrThrow(); @@ -38,7 +38,7 @@ public static FolderSecrets GetFolderSecretsOrThrow(this ValResult metadataResult) + public static FileSecrets GetFileSecretsOrThrow(this Result metadataResult) { var metadata = metadataResult.GetValueOrThrow(); @@ -51,7 +51,7 @@ public static FileSecrets GetFileSecretsOrThrow(this ValResult metadataResult, + this Result metadataResult, [NotNullWhen(true)] out PgpPrivateKey? folderKey, [MaybeNullWhen(true)] out ProtonDriveError error) { @@ -76,7 +76,7 @@ public static bool TryGetFolderKeyElseError( return true; } - if (nodeAndSecrets.Value.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) + if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) { folderKey = null; error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(fileNode.Uid, LinkType.File)); @@ -88,13 +88,13 @@ public static bool TryGetFolderKeyElseError( return true; } - public static RefResult ToNodeResult(this ValResult metadataResult) + public static Result ToNodeResult(this Result metadataResult) { - return metadataResult.ConvertRef(metadata => metadata.Node, metadata => metadata.Node); + return metadataResult.Convert(metadata => metadata.Node, metadata => metadata.Node); } - public static RefResult ToSecretsResult(this ValResult metadataResult) + public static Result ToSecretsResult(this Result metadataResult) { - return metadataResult.ConvertRef(metadata => metadata.Secrets, metadata => metadata.Secrets); + return metadataResult.Convert(metadata => metadata.Secrets, metadata => metadata.Secrets); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 4be8eafc..58116a79 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -87,7 +87,7 @@ public static void GetCommonCreationParameters( GetNameParameters(name, parentFolderKey, parentFolderHashKey, nameSessionKey, signingKey, out encryptedName, out nameHashDigest); } - public static async ValueTask> GetFreshNodeMetadataAsync( + public static async ValueTask> GetFreshNodeMetadataAsync( ProtonDriveClient client, NodeUid uid, ShareAndKey? knownShareAndKey, @@ -299,7 +299,7 @@ public static async ValueTask RenameAsync( if (cachedNodeInfo is var (nodeProvisionResult, membershipShareId, nameHashDigest)) { // TODO: have the back-end return the trash time so that the cached value be exactly the same - var newNodeProvisionResult = nodeProvisionResult.ConvertRef( + var newNodeProvisionResult = nodeProvisionResult.Convert( node => node with { TrashTime = DateTime.UtcNow }, degradedNode => degradedNode with { TrashTime = DateTime.UtcNow }); @@ -398,12 +398,12 @@ public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClie } public static bool ValidateName( - ValResult, string> decryptionResult, + Result, string> decryptionResult, [NotNullWhen(true)] out PhasedDecryptionOutput? nameOutput, - out RefResult nameResult, + out Result nameResult, [NotNullWhen(true)] out PgpSessionKey? sessionKey) { - if (!decryptionResult.TryGetValueElseError(out nameOutput, out var decryptionErrorMessage)) + if (!decryptionResult.TryGetValueElseError(out var nameOutputValue, out var decryptionErrorMessage)) { nameOutput = null; nameResult = new DecryptionError(decryptionErrorMessage); @@ -411,9 +411,10 @@ public static bool ValidateName( return false; } - sessionKey = nameOutput.Value.SessionKey; + nameOutput = nameOutputValue; + sessionKey = nameOutputValue.SessionKey; - var name = nameOutput.Value.Data; + var name = nameOutputValue.Data; if (string.IsNullOrEmpty(name)) { @@ -493,7 +494,7 @@ private static void GetNameParameters( } } - private static async ValueTask?> GetNodeMetadataAsync( + private static async ValueTask?> GetNodeMetadataAsync( ProtonDriveClient client, NodeUid uid, CachedNodeInfo cachedNodeInfo, @@ -508,14 +509,14 @@ private static void GetNameParameters( return folderSecretsResult is not null && folderSecretsResult.Value.TryGetError(out var degradedFolderSecrets) ? new DegradedNodeMetadata(degradedFolderNode, degradedFolderSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) - : (ValResult?)null; + : (Result?)null; case DegradedFileNode degradedFileNode: var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(uid, cancellationToken).ConfigureAwait(false); return fileSecretsResult is not null && fileSecretsResult.Value.TryGetError(out var degradedFileSecrets) ? new DegradedNodeMetadata(degradedFileNode, degradedFileSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) - : (ValResult?)null; + : (Result?)null; default: throw new InvalidOperationException($"Degraded node type \"{node?.GetType().Name}\" is not supported"); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs index b43be225..c00f5dd0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs @@ -6,9 +6,9 @@ namespace Proton.Drive.Sdk.Nodes; public static class NodeResultExtensions { public static bool TryGetFileElseFolder( - this RefResult nodeResult, - [NotNullWhen(true)] out RefResult? fileResult, - [NotNullWhen(false)] out RefResult? folderResult) + this Result nodeResult, + [NotNullWhen(true)] out Result? fileResult, + [NotNullWhen(false)] out Result? folderResult) { if (!nodeResult.TryGetValueElseError(out var node, out var degradedNode)) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index 16527eec..bcd4f424 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -10,5 +10,5 @@ public sealed class Revision public required long? ClaimedSize { get; init; } public required DateTime? ClaimedModificationTime { get; init; } public required IReadOnlyList> Thumbnails { get; init; } - public required ValResult? ContentAuthor { get; init; } + public required Result? ContentAuthor { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index dc5770ff..8860187f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -72,7 +72,7 @@ public ValueTask CreateFolderAsync(NodeUid parentId, string name, Ca return FolderOperations.CreateAsync(this, parentId, name, cancellationToken); } - public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) + public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) { return FolderOperations.EnumerateChildrenAsync(this, folderId, cancellationToken); } @@ -127,7 +127,7 @@ public ValueTask RenameNodeAsync(NodeUid uid, string newName, string? newMediaTy return NodeOperations.RestoreAsync(this, uids, cancellationToken); } - public IAsyncEnumerable> EnumerateTrashAsync(CancellationToken cancellationToken) + public IAsyncEnumerable> EnumerateTrashAsync(CancellationToken cancellationToken) { return VolumeOperations.EnumerateTrashAsync(this, cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs index 8ed9f08f..2b908f81 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs @@ -18,8 +18,8 @@ namespace Proton.Drive.Sdk.Serialization; ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(IEnumerable))] -[JsonSerializable(typeof(RefResult?))] -[JsonSerializable(typeof(RefResult?))] +[JsonSerializable(typeof(Result?))] +[JsonSerializable(typeof(Result?))] [JsonSerializable(typeof(SerializableRefResult))] [JsonSerializable(typeof(SerializableRefResult))] internal sealed partial class DriveSecretsSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 2321fd2b..a9302ee5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -53,7 +53,7 @@ internal static class VolumeOperations return (volume, share, rootFolder); } - public static async IAsyncEnumerable> EnumerateTrashAsync( + public static async IAsyncEnumerable> EnumerateTrashAsync( ProtonDriveClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs index 0178ed11..7838f4f2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -7,7 +7,7 @@ namespace Proton.Drive.Sdk.Volumes; internal sealed class VolumeTrashBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey shareKey) - : BatchLoaderBase> + : BatchLoaderBase> { private readonly ProtonDriveClient _client = client; private readonly VolumeId _volumeId = volumeId; @@ -15,13 +15,13 @@ internal sealed class VolumeTrashBatchLoader(ProtonDriveClient client, VolumeId private readonly Dictionary _parentKeys = new(); - protected override async ValueTask>> LoadBatchAsync( + protected override async ValueTask>> LoadBatchAsync( ReadOnlyMemory ids, CancellationToken cancellationToken) { var response = await _client.Api.Links.GetDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); - var nodeResults = new List>(ids.Length); + var nodeResults = new List>(ids.Length); foreach (var linkDetails in response.Links) { diff --git a/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs b/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs deleted file mode 100644 index 77b65507..00000000 --- a/cs/sdk/src/Proton.Sdk/RefResultExtensions.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; - -namespace Proton.Sdk; - -public static class RefResultExtensions -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T? GetValueOrDefault(this RefResult result) - where T : class? - where TError : class? - { - return result.TryGetValueElseError(out var value, out _) ? value : null; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T GetValueOrDefault(this RefResult result, T defaultValue) - where T : class? - where TError : class? - { - return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T GetValueOrThrow(this RefResult result) - where T : class? - where TError : class? - { - return result.TryGetValueElseError(out var value, out _) ? value : throw new InvalidOperationException("Cannot get value from failed result"); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryGetValue(this RefResult result, [MaybeNullWhen(false)] out T value) - where T : class? - where TError : class? - { - return result.TryGetValueElseError(out value, out _); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static TError? GetErrorOrDefault(this RefResult result, TError? defaultError = null) - where T : class? - where TError : class? - { - return result.TryGetValueElseError(out _, out var error) ? defaultError : error; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryGetError(this RefResult result, [MaybeNullWhen(false)] out TError error) - where T : class? - where TError : class? - { - return !result.TryGetValueElseError(out _, out error); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static TMerged Merge( - this RefResult result, - Func convertValue, - Func convertError) - where T : class? - where TError : class? - { - return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static ValResult ConvertVal( - this RefResult result, - Func convertValue, - Func convertError) - where T : class? - where TError : class? - where TOther : struct - where TOtherError : class? - { - return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static RefResult ConvertRef( - this RefResult result, - Func convertValue, - Func convertError) - where T : class? - where TError : class? - where TOther : class? - where TOtherError : class? - { - return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); - } -} diff --git a/cs/sdk/src/Proton.Sdk/RefResult.cs b/cs/sdk/src/Proton.Sdk/Result.cs similarity index 59% rename from cs/sdk/src/Proton.Sdk/RefResult.cs rename to cs/sdk/src/Proton.Sdk/Result.cs index 29e7b7f9..6a7e658c 100644 --- a/cs/sdk/src/Proton.Sdk/RefResult.cs +++ b/cs/sdk/src/Proton.Sdk/Result.cs @@ -2,32 +2,30 @@ namespace Proton.Sdk; -public readonly struct RefResult - where T : class? - where TError : class? +public readonly struct Result { private readonly T? _value; private readonly TError? _error; - public RefResult(T value) + public Result(T value) { IsSuccess = true; _value = value; - _error = null; + _error = default; } - public RefResult(TError error) + public Result(TError error) { IsSuccess = false; _error = error; - _value = null; + _value = default; } public bool IsSuccess { get; } public bool IsFailure => !IsSuccess; - public static implicit operator RefResult(T value) => new(value); - public static implicit operator RefResult(TError error) => new(error); + public static implicit operator Result(T value) => new(value); + public static implicit operator Result(TError error) => new(error); public bool TryGetValueElseError([MaybeNullWhen(false)] out T value, [MaybeNullWhen(true)] out TError error) { diff --git a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs new file mode 100644 index 00000000..6a6e3a71 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs @@ -0,0 +1,62 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Proton.Sdk; + +public static class ResultExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T? GetValueOrDefault(this Result result) + { + return result.TryGetValueElseError(out var value, out _) ? value : default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T GetValueOrDefault(this Result result, T defaultValue) + { + return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T GetValueOrThrow(this Result result) + { + return result.TryGetValueElseError(out var value, out _) ? value : throw new InvalidOperationException("Cannot get value from failed result"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetValue(this Result result, [MaybeNullWhen(false)] out T value) + { + return result.TryGetValueElseError(out value, out _); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TError? GetErrorOrDefault(this Result result, TError? defaultError = null) + where TError : class? + { + return result.TryGetValueElseError(out _, out var error) ? defaultError : error; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetError(this Result result, [MaybeNullWhen(false)] out TError error) + { + return !result.TryGetValueElseError(out _, out error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TMerged Merge( + this Result result, + Func convertValue, + Func convertError) + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Result Convert( + this Result result, + Func convertValue, + Func convertError) + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs index 0e6b2be1..cdac349c 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs @@ -4,17 +4,17 @@ namespace Proton.Sdk.Serialization; -internal sealed class RefResultJsonConverter : JsonConverter> +internal sealed class RefResultJsonConverter : JsonConverter> where T : class? where TError : class? { - public override RefResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var dto = JsonSerializer.Deserialize( ref reader, (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableRefResult))); - RefResult? result; + Result? result; if (dto.IsSuccess) { result = dto.Value ?? throw new JsonException("Missing \"Value\" property for success result."); @@ -27,7 +27,7 @@ public override RefResult Read(ref Utf8JsonReader reader, Type typeTo return result.Value; } - public override void Write(Utf8JsonWriter writer, RefResult value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) { var dto = value.TryGetValueElseError(out var innerValue, out var error) ? new SerializableRefResult { IsSuccess = true, Value = innerValue } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs index c76b12c7..4f2df02a 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs @@ -4,17 +4,17 @@ namespace Proton.Sdk.Serialization; -internal sealed class ValResultJsonConverter : JsonConverter> +internal sealed class ValResultJsonConverter : JsonConverter> where T : struct where TError : class? { - public override ValResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var dto = JsonSerializer.Deserialize( ref reader, (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableValResult))); - ValResult? result; + Result? result; if (dto.IsSuccess) { if (dto.Value is null) @@ -32,10 +32,10 @@ public override ValResult Read(ref Utf8JsonReader reader, Type typeTo return result.Value; } - public override void Write(Utf8JsonWriter writer, ValResult value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) { var dto = value.TryGetValueElseError(out var innerValue, out var error) - ? new SerializableValResult { IsSuccess = true, Value = innerValue.Value } + ? new SerializableValResult { IsSuccess = true, Value = innerValue } : new SerializableValResult { Error = error }; var jsonTypeInfo = (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableValResult)); diff --git a/cs/sdk/src/Proton.Sdk/ValResult.cs b/cs/sdk/src/Proton.Sdk/ValResult.cs deleted file mode 100644 index bdb09039..00000000 --- a/cs/sdk/src/Proton.Sdk/ValResult.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Proton.Sdk; - -public readonly struct ValResult - where T : struct - where TError : class? -{ - private readonly T? _value; - private readonly TError? _error; - - public ValResult(T value) - { - IsSuccess = true; - _value = value; - _error = null; - } - - public ValResult(TError error) - { - IsSuccess = false; - _error = error; - _value = null; - } - - public bool IsSuccess { get; } - public bool IsFailure => !IsSuccess; - - public static implicit operator ValResult(T value) => new(value); - public static implicit operator ValResult(TError error) => new(error); - - public bool TryGetValueElseError([NotNullWhen(true)] out T? value, [MaybeNullWhen(true)] out TError error) - { - value = _value; - error = _error; - return IsSuccess; - } -} diff --git a/cs/sdk/src/Proton.Sdk/ValResultExtensions.cs b/cs/sdk/src/Proton.Sdk/ValResultExtensions.cs deleted file mode 100644 index 346859f5..00000000 --- a/cs/sdk/src/Proton.Sdk/ValResultExtensions.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; - -namespace Proton.Sdk; - -public static class ValResultExtensions -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T? GetValueOrDefault(this ValResult result, T? defaultValue = null) - where T : struct - where TError : class? - { - return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T GetValueOrThrow(this ValResult result) - where T : struct - where TError : class? - { - return result.TryGetValueElseError(out var value, out _) - ? value.Value - : throw new InvalidOperationException("Cannot get value from failed result"); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryGetValue(this ValResult result, [NotNullWhen(true)] out T? value) - where T : struct - where TError : class? - { - return result.TryGetValueElseError(out value, out _); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static TError? GetErrorOrDefault(this ValResult result, TError? defaultError = null) - where T : struct - where TError : class? - { - return result.TryGetValueElseError(out _, out var error) ? defaultError : error; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryGetError(this ValResult result, [MaybeNullWhen(false)] out TError error) - where T : struct - where TError : class? - { - return !result.TryGetValueElseError(out _, out error); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static TMerged Merge( - this ValResult result, - Func convertValue, - Func convertError) - where T : struct - where TError : class? - { - return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value.Value) : convertError.Invoke(error); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static ValResult ConvertVal( - this ValResult result, - Func convertValue, - Func convertError) - where T : struct - where TError : class? - where TOther : struct - where TOtherError : class? - { - return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value.Value) : convertError.Invoke(error); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static RefResult ConvertRef( - this ValResult result, - Func convertValue, - Func convertError) - where T : struct - where TError : class? - where TOther : class? - where TOtherError : class? - { - return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value.Value) : convertError.Invoke(error); - } -} From c4ee442d395332ca15ad45a907478c689a6546f1 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 7 Jul 2025 07:38:13 +0000 Subject: [PATCH 141/791] update user fixtures for easier handling --- js/sdk/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 1e1e2e99..e7757793 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@proton/drive-sdk", - "version": "0.0.9", + "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@proton/drive-sdk", - "version": "0.0.9", + "version": "0.0.10", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", From ba9b115da1849b2cfb830672f52dfe44c6f66345 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 7 Jul 2025 13:58:25 +0000 Subject: [PATCH 142/791] Add integration tests for web SDK using real crypto module --- js/sdk/src/internal/nodes/cryptoService.test.ts | 2 +- js/sdk/src/internal/nodes/cryptoService.ts | 13 +++++++------ js/sdk/src/internal/nodes/nodesManagement.test.ts | 12 ++++++------ js/sdk/src/internal/nodes/nodesManagement.ts | 4 ++-- js/sdk/src/protonDriveClient.ts | 2 +- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 633d41a9..c35e168f 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,5 +1,5 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { DegradedNode, MaybeNode, ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from "../../interface"; +import { ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from "../../interface"; import { getMockTelemetry } from "../../tests/telemetry"; import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from "./interface"; import { NodesCryptoService } from "./cryptoService"; diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 2cf0f539..22a9bee6 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -268,7 +268,7 @@ export class NodesCryptoService { } }; - async getNameSessionKey(node: DecryptedNode, parentKey: PrivateKey): Promise { + async getNameSessionKey(node: { encryptedName: string }, parentKey: PrivateKey): Promise { return this.driveCrypto.decryptSessionKey(node.encryptedName, parentKey); } @@ -364,7 +364,6 @@ export class NodesCryptoService { hash, ] = await Promise.all([ this.driveCrypto.generateKey([parentKeys.key], addressKey), - this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, addressKey), this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), ]); @@ -399,9 +398,9 @@ export class NodesCryptoService { } async encryptNewName( + parentKeys: { key: PrivateKey, hashKey?: Uint8Array }, nodeNameSessionKey: SessionKey, address: { email: string, addressKey: PrivateKey }, - parentHashKey: Uint8Array | undefined, newName: string, ): Promise<{ signatureEmail: string, @@ -409,9 +408,11 @@ export class NodesCryptoService { hash?: string, }> { const { email, addressKey } = address; - const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, nodeNameSessionKey, undefined, addressKey); - const hash = parentHashKey - ? await this.driveCrypto.generateLookupHash(newName, parentHashKey) + + const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, nodeNameSessionKey, parentKeys.key, addressKey); + + const hash = parentKeys.hashKey + ? await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey) : undefined; return { signatureEmail: email, diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index bf61186a..9d006ed3 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -113,9 +113,9 @@ describe('NodesManagement', () => { }); expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('nodeUid'); expect(cryptoService.encryptNewName).toHaveBeenCalledWith( + { key: 'parentUid-key', hashKey: 'parentUid-hashKey' }, 'nodeUid-nameSessionKey', { email: "root-email", addressKey: "root-key" }, - 'parentUid-hashKey', 'new name', ); expect(apiService.renameNode).toHaveBeenCalledWith( @@ -149,9 +149,9 @@ describe('NodesManagement', () => { expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid'); expect(cryptoService.moveNode).toHaveBeenCalledWith( nodes.nodeUid, - expect.objectContaining({ + expect.objectContaining({ key: 'nodeUid-key', - passphrase: 'nodeUid-passphrase', + passphrase: 'nodeUid-passphrase', passphraseSessionKey: 'nodeUid-passphraseSessionKey', contentKeyPacketSessionKey: 'nodeUid-contentKeyPacketSessionKey', nameSessionKey: 'nodeUid-nameSessionKey' @@ -186,12 +186,12 @@ describe('NodesManagement', () => { cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid'); - + expect(cryptoService.moveNode).toHaveBeenCalledWith( nodes.anonymousNodeUid, - expect.objectContaining({ + expect.objectContaining({ key: 'anonymousNodeUid-key', - passphrase: 'anonymousNodeUid-passphrase', + passphrase: 'anonymousNodeUid-passphrase', passphraseSessionKey: 'anonymousNodeUid-passphraseSessionKey', contentKeyPacketSessionKey: 'anonymousNodeUid-contentKeyPacketSessionKey', nameSessionKey: 'anonymousNodeUid-nameSessionKey' diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index b287e3e2..c3fdca16 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -52,7 +52,7 @@ export class NodesManagement { signatureEmail, armoredNodeName, hash, - } = await this.cryptoService.encryptNewName(nodeNameSessionKey, address, parentKeys.hashKey, newName); + } = await this.cryptoService.encryptNewName(parentKeys, nodeNameSessionKey, address, newName); // Because hash is optional, lets ensure we have it unless explicitely // allowed to rename root node. @@ -141,7 +141,7 @@ export class NodesManagement { nodeUid, { hash: node.hash, - }, + }, { ...keySignatureProperties, parentUid: newParentUid, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index ac736f55..5c5bb341 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -410,7 +410,7 @@ export class ProtonDriveClient { * @throws {@link Error} If another node with the same name already exists. */ async renameNode(nodeUid: NodeOrUid, newName: string): Promise { - this.logger.info(`Renaming node ${nodeUid} to ${newName}`); + this.logger.info(`Renaming node ${getUid(nodeUid)} to ${newName}`); return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); } From 489519499c562a52e6b639d54895ed9702e55477 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 07:35:22 +0000 Subject: [PATCH 143/791] Fix parsing node from cache --- js/sdk/src/internal/nodes/cache.test.ts | 43 +++++++++++++++++++++++-- js/sdk/src/internal/nodes/cache.ts | 34 +++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 4c89999d..4ae24de4 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -1,8 +1,8 @@ import { MemoryCache } from "../../cache"; -import { NodeType, MemberRole } from "../../interface"; +import { NodeType, MemberRole, RevisionState, resultOk, Result } from "../../interface"; import { getMockLogger } from "../../tests/logger"; import { CACHE_TAG_KEYS, NodesCache } from "./cache"; -import { DecryptedNode } from "./interface"; +import { DecryptedNode, DecryptedRevision } from "./interface"; function generateNode(uid: string, parentUid='root', params: Partial & { volumeId?: string } = {}): DecryptedNode { return { @@ -16,6 +16,8 @@ function generateNode(uid: string, parentUid='root', params: Partial { expect(result).toStrictEqual(node); }); + it('should store and retrieve folder node', async () => { + const node = generateNode('node1', '', { + folder: { + claimedModificationTime: new Date('2021-01-01'), + }, + }); + + await cache.setNode(node); + const result = await cache.getNode(node.uid); + + expect(result).toStrictEqual({ + ...node, + folder: { + claimedModificationTime: new Date('2021-01-01'), + }, + }); + }); + + it('should store and retrieve node with active revision', async () => { + const activeRevision: Result = resultOk({ + uid: 'revision1', + state: RevisionState.Active, + creationTime: new Date('2021-01-01'), + storageSize: 100, + contentAuthor: resultOk('test@test.com'), + }); + const node = generateNode('node1', '', { activeRevision }); + + await cache.setNode(node); + const result = await cache.getNode(node.uid); + + expect(result).toStrictEqual({ + ...node, + activeRevision, + }); + }); + it('should throw an error when retrieving a non-existing entity', async () => { try { await cache.getNode('nonExistingNodeUid'); diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 7082fd3c..04f574e6 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -1,7 +1,7 @@ import { EntityResult } from "../../cache"; -import { ProtonDriveEntitiesCache, Logger } from "../../interface"; +import { ProtonDriveEntitiesCache, Logger, resultOk, Result } from "../../interface"; import { splitNodeUid } from "../uids"; -import { DecryptedNode } from "./interface"; +import { DecryptedNode, DecryptedRevision } from "./interface"; export enum CACHE_TAG_KEYS { ParentUid = 'nodeParentUid', @@ -228,7 +228,9 @@ function deserialiseNode(nodeData: string): DecryptedNode { (typeof node.mediaType !== 'string' && node.mediaType !== undefined) || typeof node.isShared !== 'boolean' || !node.creationTime || typeof node.creationTime !== 'string' || - (typeof node.trashTime !== 'string' && node.trashTime !== undefined) + (typeof node.trashTime !== 'string' && node.trashTime !== undefined) || + (typeof node.folder !== 'object' && node.folder !== undefined) || + (typeof node.folder?.claimedModificationTime !== 'string' && node.folder?.claimedModificationTime !== undefined) ) { throw new Error(`Invalid node data: ${nodeData}`); } @@ -236,5 +238,31 @@ function deserialiseNode(nodeData: string): DecryptedNode { ...node, creationTime: new Date(node.creationTime), trashTime: node.trashTime ? new Date(node.trashTime) : undefined, + activeRevision: node.activeRevision ? deserialiseRevision(node.activeRevision) : undefined, + folder: node.folder + ? { + ...node.folder, + claimedModificationTime: node.folder.claimedModificationTime ? new Date(node.folder.claimedModificationTime) : undefined, + } + : undefined, }; } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function deserialiseRevision(revision: any): Result { + if ( + (typeof revision !== 'object' && revision !== undefined) || + (typeof revision?.creationTime !== 'string' && revision?.creationTime !== undefined) + ) { + throw new Error(`Invalid revision data: ${revision}`); + } + + if (revision.ok) { + return resultOk({ + ...revision.value, + creationTime: new Date(revision.value.creationTime), + }); + } + + return revision; +} From 32e930be3b6ed1cba16c28b9c084a7c40638f3e9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 07:38:11 +0000 Subject: [PATCH 144/791] Fix returning public revision --- js/sdk/src/internal/nodes/index.ts | 2 +- js/sdk/src/transformers.ts | 32 ++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 47fd978c..b8f0444e 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -12,7 +12,7 @@ import { NodesAccess } from "./nodesAccess"; import { NodesManagement } from "./nodesManagement"; import { NodesRevisons } from "./nodesRevisions"; -export type { DecryptedNode } from "./interface"; +export type { DecryptedNode, DecryptedRevision } from "./interface"; export { generateFileExtendedAttributes } from "./extendedAttributes"; /** diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index d79c9a58..6e810f66 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,5 +1,15 @@ -import { MaybeNode as PublicMaybeNode, MaybeMissingNode as PublicMaybeMissingNode, NodeEntity as PublicNodeEntity, DegradedNode as PublicDegradedNode, Result, resultOk, resultError, MissingNode } from './interface'; -import { DecryptedNode as InternalNode } from './internal/nodes'; +import { + MaybeNode as PublicMaybeNode, + MaybeMissingNode as PublicMaybeMissingNode, + NodeEntity as PublicNodeEntity, + DegradedNode as PublicDegradedNode, + Revision as PublicRevision, + Result, + resultOk, + resultError, + MissingNode, +} from './interface'; +import { DecryptedNode as InternalNode, DecryptedRevision as InternalRevision } from './internal/nodes'; type InternalPartialNode = Pick< InternalNode, @@ -85,7 +95,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode return resultError({ ...baseNodeMetadata, name, - activeRevision, + activeRevision: activeRevision?.ok ? resultOk(convertInternalRevision(activeRevision.value)) : activeRevision, errors: node.errors, } as PublicDegradedNode); } @@ -93,6 +103,20 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode return resultOk({ ...baseNodeMetadata, name: name.value, - activeRevision: activeRevision?.value, + activeRevision: activeRevision?.ok ? convertInternalRevision(activeRevision.value) : undefined, } as PublicNodeEntity); } + +function convertInternalRevision(revision: InternalRevision): PublicRevision { + return { + uid: revision.uid, + state: revision.state, + creationTime: revision.creationTime, + contentAuthor: revision.contentAuthor, + storageSize: revision.storageSize, + claimedSize: revision.claimedSize, + claimedModificationTime: revision.claimedModificationTime, + claimedDigests: revision.claimedDigests, + claimedAdditionalMetadata: revision.claimedAdditionalMetadata, + } +} From eb18b54abdec3a6017b2822418c7ff7a2942cf45 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 07:38:50 +0000 Subject: [PATCH 145/791] Add fallback unknown error message --- js/sdk/src/internal/apiService/errors.test.ts | 8 ++++++++ js/sdk/src/internal/apiService/errors.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/apiService/errors.test.ts b/js/sdk/src/internal/apiService/errors.test.ts index a9121e84..f4e0c26c 100644 --- a/js/sdk/src/internal/apiService/errors.test.ts +++ b/js/sdk/src/internal/apiService/errors.test.ts @@ -30,6 +30,14 @@ describe("apiErrorFactory should return", () => { expect((error as errors.APIHTTPError).statusCode).toBe(404); }); + it("generic APIHTTPError with generic message when there is no specifc statusText", () => { + const response = new Response('', { status: 404, statusText: '' }); + const error = apiErrorFactory({ response }); + expect(error).toBeInstanceOf(errors.APIHTTPError); + expect(error.message).toBe("Unknown error"); + expect((error as errors.APIHTTPError).statusCode).toBe(404); + }); + it("generic APIHTTPError when there 404 both in status code and body code", () => { const error = apiErrorFactory(mockAPIResponseAndResult({ httpStatusCode: 404, httpStatusText: 'Path not found', code: 404, message: 'Not found' })); expect(error).toBeInstanceOf(errors.APIHTTPError); diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index aab3cbff..542efeb5 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -8,7 +8,7 @@ export function apiErrorFactory({ response, result }: { response: Response, resu // In such a case we want to stick to APIHTTPError to be very clear // it is not NotFoundAPIError. if (response.status === HTTPErrorCode.NOT_FOUND || !result) { - return new APIHTTPError(response.statusText, response.status); + return new APIHTTPError(response.statusText || c('Error').t`Unknown error`, response.status); } const typedResult = result as { From 5aacad9f0bbfa395c6cfe0bf1146cc14c8bc16bb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 08:42:20 +0000 Subject: [PATCH 146/791] Add deprecated share ID --- js/sdk/src/interface/nodes.ts | 9 +++++++++ js/sdk/src/transformers.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 0e2269fe..4d1d5a1e 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -65,6 +65,15 @@ export type NodeEntity = { * one user, or via public link. */ isShared: boolean, + /** + * Provides the ID of the share that the node is shared with. + * + * This is required only for the internal implementation to provide + * backward compatibility with the old Drive web setup. + * + * @deprecated This field is not part of the public API. + */ + deprecatedShareId?: string, /** * Created on server date. */ diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 6e810f66..4fc4fb79 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -27,7 +27,8 @@ type InternalPartialNode = Pick< 'activeRevision' | 'folder' | 'totalStorageSize' | - 'errors' + 'errors' | + 'shareId' >; type NodeUid = string | { uid: string } | Result<{ uid: string }, { uid: string }>; @@ -86,6 +87,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode trashTime: node.trashTime, totalStorageSize: node.totalStorageSize, folder: node.folder, + deprecatedShareId: node.shareId, }; const name = node.name; From f1cb415cf5a547111fb40e775b7521d91aeac69a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 09:27:57 +0000 Subject: [PATCH 147/791] Feedback from old MR's --- js/sdk/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index e7757793..d01367cf 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@proton/drive-sdk", + "name": "@protontech/drive-sdk", "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@proton/drive-sdk", + "name": "@protontech/drive-sdk", "version": "0.0.10", "license": "GPL-3.0", "dependencies": { From 4e814be45e7d91d6c89a5985c88378417f0b227f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 09:53:05 +0000 Subject: [PATCH 148/791] i18n: Upgrade translations from crowdin (f1cb415c). --- js/sdk/locales/.locale-state.metadata | 4 + js/sdk/locales/be_BY.json | 209 ++++++++++++++++++++++++++ js/sdk/locales/ca_ES.json | 207 +++++++++++++++++++++++++ js/sdk/locales/config/locales.json | 20 +++ js/sdk/locales/de_DE.json | 207 +++++++++++++++++++++++++ js/sdk/locales/el_GR.json | 207 +++++++++++++++++++++++++ js/sdk/locales/es_ES.json | 207 +++++++++++++++++++++++++ js/sdk/locales/es_LA.json | 207 +++++++++++++++++++++++++ js/sdk/locales/fr_FR.json | 207 +++++++++++++++++++++++++ js/sdk/locales/it_IT.json | 207 +++++++++++++++++++++++++ js/sdk/locales/ko_KR.json | 203 +++++++++++++++++++++++++ js/sdk/locales/nl_NL.json | 207 +++++++++++++++++++++++++ js/sdk/locales/pl_PL.json | 209 ++++++++++++++++++++++++++ js/sdk/locales/pt_BR.json | 207 +++++++++++++++++++++++++ js/sdk/locales/pt_PT.json | 207 +++++++++++++++++++++++++ js/sdk/locales/ro_RO.json | 208 +++++++++++++++++++++++++ js/sdk/locales/ru_RU.json | 209 ++++++++++++++++++++++++++ js/sdk/locales/sk_SK.json | 209 ++++++++++++++++++++++++++ js/sdk/locales/tr_TR.json | 207 +++++++++++++++++++++++++ 19 files changed, 3548 insertions(+) create mode 100644 js/sdk/locales/.locale-state.metadata create mode 100644 js/sdk/locales/be_BY.json create mode 100644 js/sdk/locales/ca_ES.json create mode 100644 js/sdk/locales/config/locales.json create mode 100644 js/sdk/locales/de_DE.json create mode 100644 js/sdk/locales/el_GR.json create mode 100644 js/sdk/locales/es_ES.json create mode 100644 js/sdk/locales/es_LA.json create mode 100644 js/sdk/locales/fr_FR.json create mode 100644 js/sdk/locales/it_IT.json create mode 100644 js/sdk/locales/ko_KR.json create mode 100644 js/sdk/locales/nl_NL.json create mode 100644 js/sdk/locales/pl_PL.json create mode 100644 js/sdk/locales/pt_BR.json create mode 100644 js/sdk/locales/pt_PT.json create mode 100644 js/sdk/locales/ro_RO.json create mode 100644 js/sdk/locales/ru_RU.json create mode 100644 js/sdk/locales/sk_SK.json create mode 100644 js/sdk/locales/tr_TR.json diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata new file mode 100644 index 00000000..0193cb6d --- /dev/null +++ b/js/sdk/locales/.locale-state.metadata @@ -0,0 +1,4 @@ +{ + "project": "fe-drive-sdk", + "locale": "ab71f3ec8d4f22b3ab271f9d56fe8e5e5b66b67b" +} \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json new file mode 100644 index 00000000..e5646c34 --- /dev/null +++ b/js/sdk/locales/be_BY.json @@ -0,0 +1,209 @@ +{ + "headers": { + "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", + "language": "be_BY" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } з некалькіх раздзелаў забаронена" + ], + "Cannot download a folder": [ + "Ðемагчыма Ñпампаваць папку" + ], + "Cannot share root folder": [ + "Ðемагчыма абагуліць каранёвую папку" + ], + "Creating files in non-folders is not allowed": [ + "Забаронена Ñтвараць файлы Ñž папках, ÑÐºÑ–Ñ Ð½Ðµ з'ÑўлÑюцца папкамі" + ], + "Creating folders in non-folders is not allowed": [ + "Забаронена Ñтвараць папкі у Ñлементах, ÑÐºÑ–Ñ Ð½Ðµ з'ÑўлÑюцца папкамі" + ], + "Creating revisions in non-files is not allowed": [ + "Забаронена Ñтвараць Ñ€Ñдакцыі Ñž файлах, ÑÐºÑ–Ñ Ð½Ðµ з'ÑўлÑюцца файламі" + ], + "Data integrity check failed": [ + "Збой праверкі цÑлаÑнаÑці даных" + ], + "Data integrity check of one part failed": [ + "Збой праверкі цÑлаÑнаÑці даных адной чаÑткі" + ], + "Device not found": [ + "Прылада не знойдзена" + ], + "Expiration date cannot be in the past": [ + "ТÑрмін дзеÑÐ½Ð½Ñ Ð½Ðµ можа быць у мінулым" + ], + "Failed to decrypt active revision: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць актыўную Ñ€Ñдакцыю: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць блок: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць ключ змеÑціва: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць назву Ñлемента: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць ключ вузла: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ Ð²ÑƒÐ·Ð»Ñ‹" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць мініÑцюру: ${ message }" + ], + "Failed to load some nodes": [ + "Ðе ўдалоÑÑ Ð·Ð°Ð³Ñ€ÑƒÐ·Ñ–Ñ†ÑŒ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ Ð²ÑƒÐ·Ð»Ñ‹" + ], + "File download failed due to empty response": [ + "Спампоўка файла не атрымалаÑÑ Ð¿Ð° прычыне пуÑтога адказу" + ], + "File has no active revision": [ + "У файла адÑутнічае Ð°ÐºÑ‚Ñ‹ÑžÐ½Ð°Ñ Ñ€ÑдакцыÑ" + ], + "File has no content key": [ + "Файл не мае ключа змеÑціва" + ], + "Invitation not found": [ + "ЗапрашÑнне не знойдзена" + ], + "Item cannot be decrypted": [ + "Ðемагчыма раÑшыфраваць Ñлемент" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Ðемагчыма абнавіць Ñтарую публічную ÑпаÑылку. Калі лаÑка, Ñтварыце новую публічную ÑпаÑылку." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Загрузка мініÑцюр з некалькіх раздзелаў забаронена" + ], + "Missing integrity signature": [ + "ÐдÑутнічае Ð¿Ð¾Ð´Ð¿Ñ–Ñ Ñ†ÑлаÑнаÑці" + ], + "Missing signature": [ + "ÐдÑутнічае подпіÑ" + ], + "Missing signature for ${ signatureType }": [ + "ÐдÑутнічае Ð¿Ð¾Ð´Ð¿Ñ–Ñ Ð´Ð»Ñ ${ signatureType }" + ], + "Move operation aborted": [ + "ÐÐ¿ÐµÑ€Ð°Ñ†Ñ‹Ñ Ð¿ÐµÑ€Ð°Ð¼ÑшчÑÐ½Ð½Ñ Ð¿ÐµÑ€Ð°Ñ€Ð²Ð°Ð½Ð°" + ], + "Moving item to a non-folder is not allowed": [ + "Забаронена перамÑшчаць Ñлементы Ñž папкі, ÑÐºÑ–Ñ Ð½Ðµ з'ÑўлÑюцца папкамі" + ], + "Moving root item is not allowed": [ + "ПерамÑшчаць каранёвы Ñлемент забаронена" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Ðазва не павінна перавышаць даўжыню Ñž ${ MAX_NODE_NAME_LENGTH } Ñімвал", + "Ðазва не павінна перавышаць даўжыню Ñž ${ MAX_NODE_NAME_LENGTH } Ñімвалы", + "Ðазва не павінна перавышаць даўжыню Ñž ${ MAX_NODE_NAME_LENGTH } Ñімвалаў", + "Ðазва не павінна перавышаць даўжыню Ñž ${ MAX_NODE_NAME_LENGTH } Ñімвалаў" + ], + "Name must not be empty": [ + "Ðазва не можа быць пуÑтой" + ], + "Name must not contain the character '/'": [ + "Ðазва не павінна змÑшчаць Ñімвал '/'" + ], + "Node has no thumbnail": [ + "У вузла адÑутнічае мініÑцюра" + ], + "Node is not a file": [ + "Вузел не з'ÑўлÑецца файлам" + ], + "Node is not accessible": [ + "Вузел недаÑтупны" + ], + "Node is not shared": [ + "Вузел не абагулены" + ], + "Node not found": [ + "Вузел не знойдзены" + ], + "Operation aborted": [ + "ÐÐ¿ÐµÑ€Ð°Ñ†Ñ‹Ñ Ð¿ÐµÑ€Ð°Ñ€Ð²Ð°Ð½Ð°" + ], + "Parent cannot be decrypted": [ + "Ðемагчыма раÑшыфраваць бацькоўÑкі вузел" + ], + "Renaming root item is not allowed": [ + "Перайменаванне каранёвага Ñлемента забаронена" + ], + "Request aborted": [ + "Запыт перарваны" + ], + "Signature verification failed": [ + "Ðе ўдалоÑÑ Ð¿Ñ€Ð°Ð²ÐµÑ€Ñ‹Ñ†ÑŒ подпіÑ" + ], + "Signature verification for ${ signatureType } failed": [ + "Збой праверкі подпіÑу Ð´Ð»Ñ ${ signatureType }" + ], + "Some file bytes failed to upload": [ + "Збой Ð·Ð°Ð¿Ð°Ð¼Ð¿Ð¾ÑžÐ²Ð°Ð½Ð½Ñ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ… байтаў файла" + ], + "Some file parts failed to upload": [ + "Збой Ð·Ð°Ð¿Ð°Ð¼Ð¿Ð¾ÑžÐ²Ð°Ð½Ð½Ñ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ… чаÑтак файла" + ], + "Thumbnail not found": [ + "МініÑцюра не знойдзена" + ], + "Too many server errors, please try again later": [ + "Занадта шмат памылак на Ñерверы. Паўтарыце Ñпробу пазней" + ], + "Too many server requests, please try again later": [ + "Занадта шмат запытаў да Ñервера. Паўтарыце Ñпробу пазней" + ], + "Unknown error": [ + "ÐевÑÐ´Ð¾Ð¼Ð°Ñ Ð¿Ð°Ð¼Ñ‹Ð»ÐºÐ°" + ], + "Unknown error ${ code }": [ + "ÐевÑÐ´Ð¾Ð¼Ð°Ñ Ð¿Ð°Ð¼Ñ‹Ð»ÐºÐ° ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "ÐевÑÐ´Ð¾Ð¼Ð°Ñ Ð¿Ð°Ð¼Ñ‹Ð»ÐºÐ° ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Ключы праверкі недаÑтупны" + ], + "Verification keys for ${ signatureType } are not available": [ + "Ключы праверкі Ð´Ð»Ñ ${ signatureType } недаÑтупны" + ], + "You can leave only item that is shared with you": [ + "Ð’Ñ‹ можаце пакінуць толькі Ñлемент, Ñкі абагулены з вамі" + ] + }, + "Operation": { + "Deleting items": [ + "Выдаленне Ñлементаў" + ], + "Restoring items": [ + "Ðднаўленне Ñлементаў" + ], + "Trashing items": [ + "Ð’Ñ‹Ð´Ð°Ð»ÐµÐ½Ñ‹Ñ Ñлементы" + ] + }, + "Property": { + "attributes": [ + "атрыбуты" + ], + "content key": [ + "ключ змеÑціва" + ], + "hash key": [ + "Ñ…Ñш-ключ" + ], + "key": [ + "ключ" + ], + "name": [ + "назва" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json new file mode 100644 index 00000000..b80b9b4a --- /dev/null +++ b/js/sdk/locales/ca_ES.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "ca_ES" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "No es permet ${ operationForErrorMessage } des de diverses seccions" + ], + "Cannot download a folder": [ + "No es pot descarregar una carpeta" + ], + "Cannot share root folder": [ + "No es pot compartir la carpeta arrel" + ], + "Creating files in non-folders is not allowed": [ + "No es permet crear carpetes en llocs que no són carpetes" + ], + "Creating folders in non-folders is not allowed": [ + "No es permet crear carpetes en llocs que no són carpetes" + ], + "Creating revisions in non-files is not allowed": [ + "No es permet crear revisions en elements que no són fitxers" + ], + "Data integrity check failed": [ + "Ha fallat la comprovació d'integritat de les dades" + ], + "Data integrity check of one part failed": [ + "Error amb la comprovació d'integritat de les dades d'una part" + ], + "Device not found": [ + "No s'ha trobat el dispositiu" + ], + "Expiration date cannot be in the past": [ + "La data de caducitat no pot haver passat" + ], + "Failed to decrypt active revision: ${ message }": [ + "No s'ha pogut desxifrar la revisió activa: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "No s'ha pogut desxifrar el bloc: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "No s'ha pogut desxifrar la clau de contingut: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "No s'ha pogut desxifrar el nom de l'element: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "No s'ha pogut desxifrar la clau del node: ${ message }" + ], + "Failed to decrypt some nodes": [ + "No s'han pogut desxifrar alguns nodes" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "No s'ha pogut desxifrar la miniatura: ${ message }" + ], + "Failed to load some nodes": [ + "No s'han pogut carregar alguns nodes" + ], + "File download failed due to empty response": [ + "La descàrrega del fitxer ha fallat a causa d'una resposta buida." + ], + "File has no active revision": [ + "Aquest fitxer no té cap revisió activa." + ], + "File has no content key": [ + "El fitxer no té clau de contingut" + ], + "Invitation not found": [ + "No s'ha pogut trobar la invitació" + ], + "Item cannot be decrypted": [ + "No s'ha pogut desxifrar l'element" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "L'enllaç públic heretat no es pot actualitzar. Torneu a crear-ne un de nou." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "No està permès carregar miniatures de diverses seccions" + ], + "Missing integrity signature": [ + "No s'ha trobat la signatura d'integritat" + ], + "Missing signature": [ + "No s'ha pogut trobar la signatura" + ], + "Missing signature for ${ signatureType }": [ + "Falta la signatura per a ${ signatureType }" + ], + "Move operation aborted": [ + "S'ha cancel·lat l'operació de moviment" + ], + "Moving item to a non-folder is not allowed": [ + "No està permès moure un element a un lloc que no és una carpeta" + ], + "Moving root item is not allowed": [ + "No està permès moure un element arrel" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "El nom ha de tenir com a màxim ${ MAX_NODE_NAME_LENGTH } caràcter", + "El nom ha de tenir com a màxim ${ MAX_NODE_NAME_LENGTH } caràcters" + ], + "Name must not be empty": [ + "El nom no pot estar buit." + ], + "Name must not contain the character '/'": [ + "El nom no pot contenir el caràcter '/'" + ], + "Node has no thumbnail": [ + "Aquest node no té miniatura" + ], + "Node is not a file": [ + "Aquest node no és un fitxer" + ], + "Node is not accessible": [ + "Aquest node no és accessible" + ], + "Node is not shared": [ + "Aquest node no ha estat compartit" + ], + "Node not found": [ + "No s'ha pogut trobar el node" + ], + "Operation aborted": [ + "L'operació s'ha cancel·lat" + ], + "Parent cannot be decrypted": [ + "L'element principal no es pot desxifrar" + ], + "Renaming root item is not allowed": [ + "No està permès canviar el nom d'un element arrel" + ], + "Request aborted": [ + "S'ha cancel·lat la sol·licitud" + ], + "Signature verification failed": [ + "No s'ha pogut verificar la signatura." + ], + "Signature verification for ${ signatureType } failed": [ + "No s'ha pogut verificar la signatura per a ${ signatureType }" + ], + "Some file bytes failed to upload": [ + "Alguns bytes del fitxer no s'han pogut carregar" + ], + "Some file parts failed to upload": [ + "Algunes parts del fitxer no s'han pogut carregar" + ], + "Thumbnail not found": [ + "No s'ha trobat la miniatura" + ], + "Too many server errors, please try again later": [ + "Hi ha hagut massa errors del servidor. Torneu-ho a provar més tard." + ], + "Too many server requests, please try again later": [ + "Hi ha hagut massa peticions del servidor. Torneu-ho a provar més tard." + ], + "Unknown error": [ + "Error desconegut" + ], + "Unknown error ${ code }": [ + "Error desconegut ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Error desconegut ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Les claus de verificació no estan disponibles" + ], + "Verification keys for ${ signatureType } are not available": [ + "Les claus de verificació per a ${ signatureType } no estan disponibles" + ], + "You can leave only item that is shared with you": [ + "Només podeu abandonar un element que us hagi estat compartit." + ] + }, + "Operation": { + "Deleting items": [ + "S'estan eliminant els elements" + ], + "Restoring items": [ + "S'estan restaurant els elements" + ], + "Trashing items": [ + "S'estan movent els elements a la paperera" + ] + }, + "Property": { + "attributes": [ + "atributs" + ], + "content key": [ + "clau de contingut" + ], + "hash key": [ + "clau d'empremta electrònica" + ], + "key": [ + "clau" + ], + "name": [ + "nom" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/config/locales.json b/js/sdk/locales/config/locales.json new file mode 100644 index 00000000..9cd44924 --- /dev/null +++ b/js/sdk/locales/config/locales.json @@ -0,0 +1,20 @@ +{ + "en_US": "English", + "de_DE": "Deutsch", + "fr_FR": "Français", + "nl_NL": "Nederlands", + "es_ES": "Español (España)", + "es_LA": "Español (Latinoamérica)", + "it_IT": "Italiano", + "pl_PL": "Polski", + "pt_BR": "Português (Brasil)", + "ru_RU": "РуÑÑкий", + "tr_TR": "Türkçe", + "ca_ES": "Català", + "pt_PT": "Português (Portugal)", + "ro_RO": "Română", + "sk_SK": "SlovenÄina", + "el_GR": "Ελληνικά", + "be_BY": "БеларуÑкаÑ", + "ko_KR": "한국어" +} \ No newline at end of file diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json new file mode 100644 index 00000000..47ba38db --- /dev/null +++ b/js/sdk/locales/de_DE.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "de_DE" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } aus mehreren Abschnitten ist nicht erlaubt." + ], + "Cannot download a folder": [ + "Ordner kann nicht heruntergeladen werden" + ], + "Cannot share root folder": [ + "Stammordner kann nicht geteilt werden" + ], + "Creating files in non-folders is not allowed": [ + "Erstellen von Dateien in Nicht-Ordnern ist nicht erlaubt" + ], + "Creating folders in non-folders is not allowed": [ + "Erstellen von Ordnern in Nicht-Ordnern ist nicht erlaubt." + ], + "Creating revisions in non-files is not allowed": [ + "Erstellen von Revisionen in Nicht-Dateien ist nicht erlaubt." + ], + "Data integrity check failed": [ + "Datenintegritätsprüfung fehlgeschlagen" + ], + "Data integrity check of one part failed": [ + "Datenintegritätsprüfung eines Teils fehlgeschlagen" + ], + "Device not found": [ + "Gerät nicht gefunden" + ], + "Expiration date cannot be in the past": [ + "Ablaufdatum kann nicht in der Vergangenheit liegen" + ], + "Failed to decrypt active revision: ${ message }": [ + "Konnte aktive Revision nicht entschlüsseln: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Konnte Block nicht entschlüsseln: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Konnte Inhaltsschlüssel nicht entschlüsseln: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Konnte Eintragname nicht entschlüsseln: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Konnte Node-Schlüssel nicht entschlüsseln: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Konnte einige Nodes nicht entschlüsseln" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Konnte Vorschaubild nicht entschlüsseln: ${ message }" + ], + "Failed to load some nodes": [ + "Konnte einige Nodes nicht laden" + ], + "File download failed due to empty response": [ + "Download der Datei ist fehlgeschlagen, weil die Antwort leer war." + ], + "File has no active revision": [ + "Datei hat keine aktive Revision" + ], + "File has no content key": [ + "Datei hat keinen Inhaltsschlüssel" + ], + "Invitation not found": [ + "Einladung nicht gefunden" + ], + "Item cannot be decrypted": [ + "Eintrag kann nicht entschlüsselt werden" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Alter öffentlicher Link kann nicht aktualisiert werden. Bitte erstelle einen neuen öffentlichen Link." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Laden von Vorschaubildern aus mehreren Abschnitten ist nicht erlaubt" + ], + "Missing integrity signature": [ + "Fehlende Integritätssignatur" + ], + "Missing signature": [ + "Fehlende Signatur" + ], + "Missing signature for ${ signatureType }": [ + "Fehlende Signatur für ${ signatureType }" + ], + "Move operation aborted": [ + "Verschiebevorgang abgebrochen" + ], + "Moving item to a non-folder is not allowed": [ + "Verschieben des Eintrags in einen Nicht-Ordner ist nicht erlaubt" + ], + "Moving root item is not allowed": [ + "Verschieben des Stammeintrags ist nicht erlaubt." + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Name darf höchstens ${ MAX_NODE_NAME_LENGTH } Zeichen lang sein", + "Name darf höchstens ${ MAX_NODE_NAME_LENGTH } Zeichen lang sein" + ], + "Name must not be empty": [ + "Name darf nicht leer sein" + ], + "Name must not contain the character '/'": [ + "Name darf das Zeichen \"/\" nicht enthalten." + ], + "Node has no thumbnail": [ + "Node hat kein Vorschaubild" + ], + "Node is not a file": [ + "Node ist keine Datei" + ], + "Node is not accessible": [ + "Node ist nicht erreichbar" + ], + "Node is not shared": [ + "Node ist nicht geteilt" + ], + "Node not found": [ + "Node nicht gefunden" + ], + "Operation aborted": [ + "Vorgang abgebrochen" + ], + "Parent cannot be decrypted": [ + "Übergeordnetes Element kann nicht entschlüsselt werden" + ], + "Renaming root item is not allowed": [ + "Umbenennen des Stammeintrags ist nicht erlaubt." + ], + "Request aborted": [ + "Anfrage abgebrochen" + ], + "Signature verification failed": [ + "Signaturprüfung fehlgeschlagen" + ], + "Signature verification for ${ signatureType } failed": [ + "Signaturverifizierung für ${ signatureType } fehlgeschlagen" + ], + "Some file bytes failed to upload": [ + "Einige Dateibytes konnten nicht hochgeladen werden" + ], + "Some file parts failed to upload": [ + "Einige Dateiteile konnten nicht hochgeladen werden." + ], + "Thumbnail not found": [ + "Vorschaubild nicht gefunden" + ], + "Too many server errors, please try again later": [ + "Zu viele Serverfehler, bitte versuche es später erneut" + ], + "Too many server requests, please try again later": [ + "Zu viele Serveranfragen, bitte versuche es später erneut" + ], + "Unknown error": [ + "Unbekannter Fehler" + ], + "Unknown error ${ code }": [ + "Unbekannter Fehler ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Unbekannter Fehler ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Verifizierungsschlüssel sind nicht verfügbar" + ], + "Verification keys for ${ signatureType } are not available": [ + "Verifizierungsschlüssel für ${ signatureType } sind nicht verfügbar" + ], + "You can leave only item that is shared with you": [ + "Du kannst nur den Eintrag verlassen, der mit dir geteilt wurde." + ] + }, + "Operation": { + "Deleting items": [ + "Einträge löschen" + ], + "Restoring items": [ + "Einträge wiederherstellen" + ], + "Trashing items": [ + "Einträge in den Papierkorb verschieben" + ] + }, + "Property": { + "attributes": [ + "Attribute" + ], + "content key": [ + "Inhaltsschlüssel" + ], + "hash key": [ + "Hash-Schlüssel" + ], + "key": [ + "Schlüssel" + ], + "name": [ + "Name" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json new file mode 100644 index 00000000..78efa57c --- /dev/null +++ b/js/sdk/locales/el_GR.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "el_GR" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } από πολλαπλές ενότητες δεν επιτÏέπεται" + ], + "Cannot download a folder": [ + "Δεν είναι δυνατή η λήψη ενός φακέλου" + ], + "Cannot share root folder": [ + "Δεν είναι δυνατή η κοινοποίηση του κεντÏÎ¹ÎºÎ¿Ï Ï†Î±ÎºÎ­Î»Î¿Ï…" + ], + "Creating files in non-folders is not allowed": [ + "Δεν επιτÏέπεται η δημιουÏγία αÏχείων σε μη-φακέλους." + ], + "Creating folders in non-folders is not allowed": [ + "Δεν επιτÏέπεται η δημιουÏγία φακέλων σε μη-φακέλους." + ], + "Creating revisions in non-files is not allowed": [ + "Η δημιουÏγία αναθεωÏήσεων σε μη αÏχεία δεν επιτÏέπεται" + ], + "Data integrity check failed": [ + "Ο έλεγχος ακεÏαιότητας δεδομένων απέτυχε" + ], + "Data integrity check of one part failed": [ + "Ο έλεγχος ακεÏαιότητας δεδομένων ενός μέÏους απέτυχε" + ], + "Device not found": [ + "Η συσκευή δεν βÏέθηκε" + ], + "Expiration date cannot be in the past": [ + "ΗμεÏομηνία λήξης δεν μποÏεί να είναι στο παÏελθόν" + ], + "Failed to decrypt active revision: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης ενεÏγής αναθεώÏησης: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης μπλοκ: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης ÎºÎ»ÎµÎ¹Î´Î¹Î¿Ï Ï€ÎµÏιεχομένου: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης ονόματος στοιχείου: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης ÎºÎ»ÎµÎ¹Î´Î¹Î¿Ï ÎºÏŒÎ¼Î²Î¿Ï…: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Αποτυχία αποκÏυπτογÏάφησης κάποιων κόμβων" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης μικÏογÏαφίας: ${ message }" + ], + "Failed to load some nodes": [ + "Αποτυχία φόÏτωσης κάποιων κόμβων" + ], + "File download failed due to empty response": [ + "Η λήψη αÏχείου απέτυχε λόγω κενής απόκÏισης" + ], + "File has no active revision": [ + "Το αÏχείο δεν έχει ενεÏγή αναθεώÏηση" + ], + "File has no content key": [ + "Το αÏχείο δεν έχει κλειδί πεÏιεχομένου" + ], + "Invitation not found": [ + "Η Ï€Ïόσκληση δεν βÏέθηκε" + ], + "Item cannot be decrypted": [ + "Το αντικείμενο δεν μποÏεί να αποκÏυπτογÏαφηθεί" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Ο Ï€Î±Î»Î±Î¹Î¿Ï Ï„Ïπου δημόσιος σÏνδεσμος δεν μποÏεί να ενημεÏωθεί. ΠαÏακαλοÏμε δημιουÏγήστε εκ νέου έναν νέο δημόσιο σÏνδεσμο." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Δεν επιτÏέπεται η φόÏτωση μικÏογÏαφιών από πολλαπλές ενότητες." + ], + "Missing integrity signature": [ + "Λείπει η υπογÏαφή ακεÏαιότητας" + ], + "Missing signature": [ + "Λείπει η υπογÏαφή" + ], + "Missing signature for ${ signatureType }": [ + "Λείπει υπογÏαφή για ${ signatureType }" + ], + "Move operation aborted": [ + "Η μετακίνηση ακυÏώθηκε" + ], + "Moving item to a non-folder is not allowed": [ + "Δεν επιτÏέπεται η μετακίνηση στοιχείου σε μη-φάκελο" + ], + "Moving root item is not allowed": [ + "Η μετακίνηση του στοιχείου Ïίζας δεν επιτÏέπεται" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Το όνομα δεν Ï€Ïέπει να υπεÏβαίνει τον ${ MAX_NODE_NAME_LENGTH } χαÏακτήÏα", + "Το όνομα δεν Ï€Ïέπει να υπεÏβαίνει τους ${ MAX_NODE_NAME_LENGTH } χαÏακτήÏες" + ], + "Name must not be empty": [ + "Το όνομα δεν Ï€Ïέπει να είναι κενό" + ], + "Name must not contain the character '/'": [ + "Το όνομα δεν Ï€Ïέπει να πεÏιέχει τον χαÏακτήÏα '/'" + ], + "Node has no thumbnail": [ + "Ο κόμβος δεν έχει μικÏογÏαφία" + ], + "Node is not a file": [ + "Ο κόμβος δεν είναι αÏχείο" + ], + "Node is not accessible": [ + "Ο κόμβος δεν είναι Ï€Ïοσβάσιμος" + ], + "Node is not shared": [ + "Ο κόμβος δεν έχει κοινοποιηθεί" + ], + "Node not found": [ + "Ο κόμβος δεν βÏέθηκε" + ], + "Operation aborted": [ + "Η λειτουÏγία ακυÏώθηκε" + ], + "Parent cannot be decrypted": [ + "Το γονικό αντικείμενο δεν μποÏεί να αποκÏυπτογÏαφηθεί" + ], + "Renaming root item is not allowed": [ + "Δεν επιτÏέπεται η μετονομασία του ÏÎ¹Î¶Î¹ÎºÎ¿Ï ÏƒÏ„Î¿Î¹Ï‡ÎµÎ¯Î¿Ï…" + ], + "Request aborted": [ + "Το αίτημα ακυÏώθηκε" + ], + "Signature verification failed": [ + "Η επαλήθευση υπογÏαφής απέτυχε" + ], + "Signature verification for ${ signatureType } failed": [ + "Αποτυχία επαλήθευσης υπογÏαφής για ${ signatureType }" + ], + "Some file bytes failed to upload": [ + "Κάποια byte αÏχείου απέτυχαν να μεταφοÏτωθοÏν" + ], + "Some file parts failed to upload": [ + "Κάποια μέÏη αÏχείων απέτυχαν να μεταφοÏτωθοÏν" + ], + "Thumbnail not found": [ + "Η μικÏογÏαφία δεν βÏέθηκε" + ], + "Too many server errors, please try again later": [ + "ΥπεÏβολικός αÏιθμός σφαλμάτων διακομιστή, παÏακαλοÏμε δοκιμάστε ξανά αÏγότεÏα" + ], + "Too many server requests, please try again later": [ + "ΥπεÏβολικός αÏιθμός αιτημάτων διακομιστή, παÏακαλοÏμε δοκιμάστε ξανά αÏγότεÏα" + ], + "Unknown error": [ + "Άγνωστο σφάλμα" + ], + "Unknown error ${ code }": [ + "Άγνωστο σφάλμα ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Άγνωστο σφάλμα ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Τα κλειδιά επαλήθευσης δεν είναι διαθέσιμα" + ], + "Verification keys for ${ signatureType } are not available": [ + "Τα κλειδιά επαλήθευσης για ${ signatureType } δεν είναι διαθέσιμα" + ], + "You can leave only item that is shared with you": [ + "ΜποÏείτε να αποχωÏήσετε μόνο από το στοιχείο που έχει κοινοποιηθεί σε εσάς." + ] + }, + "Operation": { + "Deleting items": [ + "ΔιαγÏαφή στοιχείων" + ], + "Restoring items": [ + "ΕπαναφοÏά στοιχείων" + ], + "Trashing items": [ + "Μετακίνηση στοιχείων στα ΔιαγÏαμμένα" + ] + }, + "Property": { + "attributes": [ + "ιδιότητες" + ], + "content key": [ + "κλειδί πεÏιεχομένου" + ], + "hash key": [ + "κλειδί hash" + ], + "key": [ + "κλειδί" + ], + "name": [ + "ονομα" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json new file mode 100644 index 00000000..6f21fcbc --- /dev/null +++ b/js/sdk/locales/es_ES.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "es_ES" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "No se permite ${ operationForErrorMessage } de varias secciones." + ], + "Cannot download a folder": [ + "No se puede descargar una carpeta." + ], + "Cannot share root folder": [ + "No se puede compartir la carpeta principal." + ], + "Creating files in non-folders is not allowed": [ + "No está permitido crear archivos en ubicaciones que no sean carpetas." + ], + "Creating folders in non-folders is not allowed": [ + "No está permitido crear carpetas en elementos que no sean carpetas." + ], + "Creating revisions in non-files is not allowed": [ + "No está permitido crear revisiones en elementos que no son archivos." + ], + "Data integrity check failed": [ + "Error en verificar la integridad de los datos" + ], + "Data integrity check of one part failed": [ + "Error en verificar la integridad de los datos de una parte" + ], + "Device not found": [ + "No se ha encontrado el dispositivo." + ], + "Expiration date cannot be in the past": [ + "La fecha de expiración no puede estar en el pasado." + ], + "Failed to decrypt active revision: ${ message }": [ + "Error al descifrar revisión activa: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Error al descifrar el bloque: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Error al descifrar la clave del contenido: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Error al descifrar el nombre del elemento: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Error al descifrar la clave del nodo: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Error al descifrar algunos nodos" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Error al descifrar la miniatura: ${ message }" + ], + "Failed to load some nodes": [ + "Error al cargar algunos nodos" + ], + "File download failed due to empty response": [ + "Error con la descarga del archivo debido a una respuesta vacía" + ], + "File has no active revision": [ + "El archivo no tiene una revisión activa." + ], + "File has no content key": [ + "El archivo no tiene clave de contenido." + ], + "Invitation not found": [ + "No se ha encontrado la invitación." + ], + "Item cannot be decrypted": [ + "No se puede descifrar el elemento." + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "El enlace público de origen no se puede actualizar. Vuelve a crear un nuevo enlace público." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "No se permite cargar miniaturas de varias secciones." + ], + "Missing integrity signature": [ + "Falta la firma de integridad." + ], + "Missing signature": [ + "Falta la firma." + ], + "Missing signature for ${ signatureType }": [ + "Falta la firma para ${ signatureType }" + ], + "Move operation aborted": [ + "Se ha cancelado el traslado." + ], + "Moving item to a non-folder is not allowed": [ + "No está permitido mover un elemento a una ubicación que no sea una carpeta." + ], + "Moving root item is not allowed": [ + "No se permite mover el elemento principal." + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "El nombre debe tener como máximo ${ MAX_NODE_NAME_LENGTH } carácter.", + "El nombre debe tener como máximo ${ MAX_NODE_NAME_LENGTH } caracteres." + ], + "Name must not be empty": [ + "El nombre no debe estar vacío." + ], + "Name must not contain the character '/'": [ + "El nombre no debe contener el carácter «/»." + ], + "Node has no thumbnail": [ + "El nodo no tiene miniatura." + ], + "Node is not a file": [ + "El nodo no es un archivo." + ], + "Node is not accessible": [ + "El nodo no es accesible." + ], + "Node is not shared": [ + "El nodo no está compartido" + ], + "Node not found": [ + "Nodo no encontrado" + ], + "Operation aborted": [ + "Operación cancelada" + ], + "Parent cannot be decrypted": [ + "No se puede descifrar el elemento principal." + ], + "Renaming root item is not allowed": [ + "No se permite cambiar el nombre del elemento principal." + ], + "Request aborted": [ + "Solicitud cancelada" + ], + "Signature verification failed": [ + "Error al verificar la firma" + ], + "Signature verification for ${ signatureType } failed": [ + "Error en la verificación de la firma para ${ signatureType }" + ], + "Some file bytes failed to upload": [ + "Algunos bytes del archivo no se han podido cargar." + ], + "Some file parts failed to upload": [ + "Algunas partes del archivo no se han podido cargar." + ], + "Thumbnail not found": [ + "Miniatura no encontrada" + ], + "Too many server errors, please try again later": [ + "Demasiados errores del servidor. Inténtalo de nuevo más tarde." + ], + "Too many server requests, please try again later": [ + "Demasiadas solicitudes del servidor. Inténtalo de nuevo más tarde." + ], + "Unknown error": [ + "Error desconocido" + ], + "Unknown error ${ code }": [ + "Error desconocido ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Error desconocido ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Las claves de verificación no están disponibles." + ], + "Verification keys for ${ signatureType } are not available": [ + "Las claves de verificación para ${ signatureType } no están disponibles." + ], + "You can leave only item that is shared with you": [ + "Solo puedes abandonar el elemento que se comparte contigo." + ] + }, + "Operation": { + "Deleting items": [ + "Eliminando elementos" + ], + "Restoring items": [ + "Restaurando elementos" + ], + "Trashing items": [ + "Eliminando elementos" + ] + }, + "Property": { + "attributes": [ + "atributos" + ], + "content key": [ + "clave de contenido" + ], + "hash key": [ + "clave hash" + ], + "key": [ + "clave" + ], + "name": [ + "nombre" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json new file mode 100644 index 00000000..aff5b53d --- /dev/null +++ b/js/sdk/locales/es_LA.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "es_LA" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "No se permite ${ operationForErrorMessage } de varias secciones" + ], + "Cannot download a folder": [ + "No se puede descargar una carpeta." + ], + "Cannot share root folder": [ + "No se puede compartir la carpeta raíz" + ], + "Creating files in non-folders is not allowed": [ + "No está permitido crear archivos en ubicaciones que no son carpetas" + ], + "Creating folders in non-folders is not allowed": [ + "No está permitido crear carpetas en ubicaciones que no son carpetas" + ], + "Creating revisions in non-files is not allowed": [ + "No se permite crear revisiones en elementos que no son archivos" + ], + "Data integrity check failed": [ + "Error en verificar la integridad de los datos" + ], + "Data integrity check of one part failed": [ + "Error en verificar la integridad de los datos de una parte" + ], + "Device not found": [ + "No se encontró el dispositivo" + ], + "Expiration date cannot be in the past": [ + "La fecha de expiración no puede estar en el pasado." + ], + "Failed to decrypt active revision: ${ message }": [ + "No se pudo descifrar la revisión activa: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "No se pudo descifrar el bloque: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "No se pudo descifrar la clave de contenido: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "No se pudo descifrar el nombre del elemento: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "No se pudo descifrar la clave de nodo: ${ message }" + ], + "Failed to decrypt some nodes": [ + "No se pudieron descifrar algunos nodos" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Error al descifrar la miniatura: ${ message }" + ], + "Failed to load some nodes": [ + "Error al cargar algunos nodos" + ], + "File download failed due to empty response": [ + "Error con la descarga del archivo debido a una respuesta vacía" + ], + "File has no active revision": [ + "El archivo no tiene ninguna revisión activa" + ], + "File has no content key": [ + "El archivo no tiene clave de contenido" + ], + "Invitation not found": [ + "Invitación no encontrada" + ], + "Item cannot be decrypted": [ + "El elemento no se puede descifrar" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "El enlace público de origen no se puede actualizar. Vuelva a crear un nuevo enlace público." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "No se permite cargar miniaturas de varias secciones" + ], + "Missing integrity signature": [ + "Falta la firma de integridad" + ], + "Missing signature": [ + "Falta la firma" + ], + "Missing signature for ${ signatureType }": [ + "Falta la firma para ${ signatureType }" + ], + "Move operation aborted": [ + "Se ha cancelado el traslado." + ], + "Moving item to a non-folder is not allowed": [ + "No está permitido mover un elemento a una ubicación que no es una carpeta" + ], + "Moving root item is not allowed": [ + "No se permite mover el elemento principal." + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "El nombre debe tener como máximo ${ MAX_NODE_NAME_LENGTH } caracter", + "El nombre debe tener como máximo ${ MAX_NODE_NAME_LENGTH } caracteres" + ], + "Name must not be empty": [ + "El nombre no debe estar vacío" + ], + "Name must not contain the character '/'": [ + "El nombre no debe contener el caracter «/»." + ], + "Node has no thumbnail": [ + "El nodo no tiene miniatura" + ], + "Node is not a file": [ + "El nodo no es un archivo" + ], + "Node is not accessible": [ + "El nodo no es accesible" + ], + "Node is not shared": [ + "El nodo no está compartido" + ], + "Node not found": [ + "Nodo no encontrado" + ], + "Operation aborted": [ + "Operación cancelada" + ], + "Parent cannot be decrypted": [ + "No se puede descifrar el elemento principal" + ], + "Renaming root item is not allowed": [ + "No se permite cambiar el nombre del elemento principal." + ], + "Request aborted": [ + "Solicitud cancelada" + ], + "Signature verification failed": [ + "Error al verificar la firma" + ], + "Signature verification for ${ signatureType } failed": [ + "Error en la verificación de la firma para ${ signatureType }" + ], + "Some file bytes failed to upload": [ + "Algunos bytes del archivo no se pudieron cargar" + ], + "Some file parts failed to upload": [ + "Algunas partes del archivo no se pudieron cargar" + ], + "Thumbnail not found": [ + "No se encontró la miniatura" + ], + "Too many server errors, please try again later": [ + "Demasiados errores del servidor. Inténtelo de nuevo más tarde." + ], + "Too many server requests, please try again later": [ + "Demasiadas solicitudes de servidor. Inténtelo de nuevo más tarde." + ], + "Unknown error": [ + "Error desconocido" + ], + "Unknown error ${ code }": [ + "Error desconocido ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Error desconocido ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Las claves de verificación no están disponibles" + ], + "Verification keys for ${ signatureType } are not available": [ + "Las claves de verificación para ${ signatureType } no están disponibles" + ], + "You can leave only item that is shared with you": [ + "Solo puede abandonar el elemento que se comparte con usted." + ] + }, + "Operation": { + "Deleting items": [ + "Eliminando elementos" + ], + "Restoring items": [ + "Restaurando elementos" + ], + "Trashing items": [ + "Eliminando elementos" + ] + }, + "Property": { + "attributes": [ + "atributos" + ], + "content key": [ + "clave de contenido" + ], + "hash key": [ + "clave hash" + ], + "key": [ + "clave" + ], + "name": [ + "nombre" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json new file mode 100644 index 00000000..9d50596e --- /dev/null +++ b/js/sdk/locales/fr_FR.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n >= 2);", + "language": "fr_FR" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } à partir de plusieurs sections n'est pas autorisé." + ], + "Cannot download a folder": [ + "Le téléchargement d'un dossier n'a pas abouti." + ], + "Cannot share root folder": [ + "Le partager du dossier principal n'a pas abouti." + ], + "Creating files in non-folders is not allowed": [ + "La création de fichiers dans des éléments qui ne sont pas des dossiers n'est pas autorisée." + ], + "Creating folders in non-folders is not allowed": [ + "La création de dossiers dans des éléments qui ne sont pas des dossiers n'est pas autorisée." + ], + "Creating revisions in non-files is not allowed": [ + "La création de révisions dans des éléments qui ne sont pas des fichiers n'est pas autorisée." + ], + "Data integrity check failed": [ + "La vérification de l'intégrité des données n'a pas abouti." + ], + "Data integrity check of one part failed": [ + "La vérification de l'intégrité des données d'une partie n'a pas abouti." + ], + "Device not found": [ + "L'appareil est introuvable." + ], + "Expiration date cannot be in the past": [ + "La date d\"expiration ne peut pas être antérieure." + ], + "Failed to decrypt active revision: ${ message }": [ + "Le déchiffrement de la révision active n'a pas abouti : ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Le déchiffrement du bloc n'a pas abouti : ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Le déchiffrement de la clé de contenu n'a pas abouti : ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Le déchiffrement du nom de l'élément n'a pas abouti : ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Le déchiffrement de la clé de nÅ“ud n'a pas abouti : ${ message }" + ], + "Failed to decrypt some nodes": [ + "Le déchiffrement de certains nÅ“uds n'a pas abouti." + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Le déchiffrement de la vignette n'a pas abouti : ${ message }" + ], + "Failed to load some nodes": [ + "Le chargement de certains nÅ“uds n'a pas abouti." + ], + "File download failed due to empty response": [ + "Le téléchargement du fichier n'a pas abouti en raison d'une réponse vide." + ], + "File has no active revision": [ + "Le fichier n'a pas de révision active." + ], + "File has no content key": [ + "Le fichier n'a pas de clé de contenu." + ], + "Invitation not found": [ + "L'invitation est introuvable." + ], + "Item cannot be decrypted": [ + "L'élément ne peut pas être déchiffré." + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "L'ancien lien public ne peut pas être mis à jour. Veuillez recréer un nouveau lien public." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Le chargement de vignettes depuis plusieurs sections n'est pas autorisé" + ], + "Missing integrity signature": [ + "Il manque la signature d'intégrité." + ], + "Missing signature": [ + "Il manque la signature." + ], + "Missing signature for ${ signatureType }": [ + "La signature est manquante pour ${ signatureType }." + ], + "Move operation aborted": [ + "Le déplacement a été annulé." + ], + "Moving item to a non-folder is not allowed": [ + "Le déplacement d'un élément vers un élément qui n'est pas un dossier n'est pas autorisé." + ], + "Moving root item is not allowed": [ + "Le déplacement de l'élément principal n'est pas autorisé." + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Le nom doit être composé d'${ MAX_NODE_NAME_LENGTH } caractère au maximum.", + "Le nom doit être composé de ${ MAX_NODE_NAME_LENGTH } caractères au maximum." + ], + "Name must not be empty": [ + "Le nom ne doit pas être vide." + ], + "Name must not contain the character '/'": [ + "Le nom ne doit pas contenir le caractère « / »." + ], + "Node has no thumbnail": [ + "Le nÅ“ud n'a pas de vignette." + ], + "Node is not a file": [ + "Le nÅ“ud n'est pas un fichier." + ], + "Node is not accessible": [ + "Le nÅ“ud n'est pas accessible." + ], + "Node is not shared": [ + "Le nÅ“ud n'est pas partagé." + ], + "Node not found": [ + "Le nÅ“ud est introuvable." + ], + "Operation aborted": [ + "L'opération a été annulée." + ], + "Parent cannot be decrypted": [ + "L'élément principal ne peut pas être déchiffré." + ], + "Renaming root item is not allowed": [ + "La modification du nom de l'élément principal n'est pas autorisée." + ], + "Request aborted": [ + "La requête a été annulée." + ], + "Signature verification failed": [ + "La signature n'a pas pu être vérifiée." + ], + "Signature verification for ${ signatureType } failed": [ + "La vérification de la signature pour ${ signatureType } n'a pas abouti." + ], + "Some file bytes failed to upload": [ + "Certains octets de fichier n'ont pas pu être importés." + ], + "Some file parts failed to upload": [ + "Certaines parties de fichier n'ont pas pu être importées." + ], + "Thumbnail not found": [ + "La vignette n'a pas été trouvée." + ], + "Too many server errors, please try again later": [ + "Trop d'erreurs de serveur. Veuillez réessayer plus tard." + ], + "Too many server requests, please try again later": [ + "Trop de requêtes de serveur. Veuillez réessayer plus tard." + ], + "Unknown error": [ + "Erreur inconnue" + ], + "Unknown error ${ code }": [ + "Erreur inconnue ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Erreur inconnue ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Les clés de vérification ne sont pas disponibles." + ], + "Verification keys for ${ signatureType } are not available": [ + "Les clés de vérification pour ${ signatureType } ne sont pas disponibles." + ], + "You can leave only item that is shared with you": [ + "Vous pouvez seulement quitter l'élément partagé avec vous." + ] + }, + "Operation": { + "Deleting items": [ + "Suppression des éléments" + ], + "Restoring items": [ + "Restauration des éléments" + ], + "Trashing items": [ + "Suppression des éléments" + ] + }, + "Property": { + "attributes": [ + "attributs" + ], + "content key": [ + "clé de contenu" + ], + "hash key": [ + "clé de hachage" + ], + "key": [ + "clé" + ], + "name": [ + "nom" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json new file mode 100644 index 00000000..ce22a5ca --- /dev/null +++ b/js/sdk/locales/it_IT.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "it_IT" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "Non è consentito ${ operationForErrorMessage } da più sezioni" + ], + "Cannot download a folder": [ + "Impossibile scaricare una cartella" + ], + "Cannot share root folder": [ + "Impossibile condividere la cartella principale" + ], + "Creating files in non-folders is not allowed": [ + "Non è consentito creare file in elementi che non sono cartelle." + ], + "Creating folders in non-folders is not allowed": [ + "Non è consentito creare cartelle in elementi che non sono cartelle." + ], + "Creating revisions in non-files is not allowed": [ + "Non è consentito creare revisioni in elementi non-file" + ], + "Data integrity check failed": [ + "Il controllo di integrità dei dati non è riuscito" + ], + "Data integrity check of one part failed": [ + "Il controllo di integrità dei dati di una parte non è riuscito" + ], + "Device not found": [ + "Dispositivo non trovato" + ], + "Expiration date cannot be in the past": [ + "La data di scadenza non può essere nel passato" + ], + "Failed to decrypt active revision: ${ message }": [ + "Impossibile decriptare la revisione attiva: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Impossibile decriptare il blocco: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Impossibile decriptare la chiave di contenuto: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Impossibile decriptare il nome dell'elemento: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Impossibile decriptare la chiave del nodo: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Impossibile decriptare alcuni nodi" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Impossibile decriptare la miniatura: ${ message }" + ], + "Failed to load some nodes": [ + "Impossibile caricare alcuni nodi" + ], + "File download failed due to empty response": [ + "Scaricamento del file fallito a causa di una risposta vuota." + ], + "File has no active revision": [ + "Il file non ha una revisione attiva" + ], + "File has no content key": [ + "Il file non ha una chiave di contenuto" + ], + "Invitation not found": [ + "Invito non trovato" + ], + "Item cannot be decrypted": [ + "Impossibile decriptare l'elemento" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Il link pubblico obsoleto non può essere aggiornato. Per favore, ricrea un nuovo link pubblico." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Non è consentito caricare miniature da più sezioni" + ], + "Missing integrity signature": [ + "Firma di integrità mancante" + ], + "Missing signature": [ + "Firma mancante" + ], + "Missing signature for ${ signatureType }": [ + "Firma mancante per ${ signatureType }" + ], + "Move operation aborted": [ + "Spostamento interrotto" + ], + "Moving item to a non-folder is not allowed": [ + "Non è consentito spostare l'elemento in un elemento che non è una cartella." + ], + "Moving root item is not allowed": [ + "Non è consentito spostare l'elemento radice" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Il nome deve essere lungo al massimo ${ MAX_NODE_NAME_LENGTH } carattere", + "Il nome deve essere lungo al massimo ${ MAX_NODE_NAME_LENGTH } caratteri" + ], + "Name must not be empty": [ + "Nome necessario" + ], + "Name must not contain the character '/'": [ + "Il nome non deve contenere il carattere '/'" + ], + "Node has no thumbnail": [ + "Il nodo non ha alcuna miniatura" + ], + "Node is not a file": [ + "Il nodo non è un file" + ], + "Node is not accessible": [ + "Il nodo non è accessibile" + ], + "Node is not shared": [ + "Il nodo non è condiviso" + ], + "Node not found": [ + "Nodo non trovato" + ], + "Operation aborted": [ + "Operazione annullata" + ], + "Parent cannot be decrypted": [ + "Impossibile decriptare il genitore" + ], + "Renaming root item is not allowed": [ + "Non è consentito rinominare l'elemento radice" + ], + "Request aborted": [ + "Richiesta interrotta" + ], + "Signature verification failed": [ + "Verifica della firma non riuscita" + ], + "Signature verification for ${ signatureType } failed": [ + "Verifica della firma per ${ signatureType } non riuscita" + ], + "Some file bytes failed to upload": [ + "Alcuni byte del file non sono stati caricati" + ], + "Some file parts failed to upload": [ + "Alcune parti di file non sono state caricate" + ], + "Thumbnail not found": [ + "Miniatura non trovata" + ], + "Too many server errors, please try again later": [ + "Troppi errori del server, riprova più tardi" + ], + "Too many server requests, please try again later": [ + "Troppe richieste del server, riprova più tardi" + ], + "Unknown error": [ + "Errore sconosciuto" + ], + "Unknown error ${ code }": [ + "Errore sconosciuto ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Errore sconosciuto ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Le chiavi di verifica non sono disponibili" + ], + "Verification keys for ${ signatureType } are not available": [ + "Le chiavi di verifica per ${ signatureType } non sono disponibili" + ], + "You can leave only item that is shared with you": [ + "Puoi lasciare solo l'elemento che è condiviso con te" + ] + }, + "Operation": { + "Deleting items": [ + "Eliminazione elementi" + ], + "Restoring items": [ + "Ripristino elementi" + ], + "Trashing items": [ + "Cancellazione elementi" + ] + }, + "Property": { + "attributes": [ + "attributi" + ], + "content key": [ + "chiave contenuto" + ], + "hash key": [ + "chiave hash" + ], + "key": [ + "chiave" + ], + "name": [ + "nome" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/ko_KR.json b/js/sdk/locales/ko_KR.json new file mode 100644 index 00000000..d8830552 --- /dev/null +++ b/js/sdk/locales/ko_KR.json @@ -0,0 +1,203 @@ +{ + "headers": { + "plural-forms": "nplurals=1; plural=0;", + "language": "ko_KR" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "다중 섹션으로 ë¶€í„°ì˜ ${ operationForErrorMessage }ì€(는) í—ˆë½ë˜ì§€ 않습니다." + ], + "Cannot download a folder": [ + "í´ë”를 다운로드할 수 ì—†ìŒ" + ], + "Cannot share root folder": [ + "ìƒìœ„ í´ë”를 공유할 수 없습니다" + ], + "Creating files in non-folders is not allowed": [ + "í´ë”ê°€ 아닌 ê³³ì— í´ë”를 ìƒì„±í•  수 없습니다" + ], + "Creating folders in non-folders is not allowed": [ + "í´ë”ê°€ 아닌 ê³³ì— í´ë”를 ìƒì„±í•  수 없습니다." + ], + "Creating revisions in non-files is not allowed": [ + "파ì¼ì´ 아닌 ê³³ì—서는 개정본 ìƒì„±ì´ 허용ë˜ì§€ 않습니다." + ], + "Data integrity check failed": [ + "ë°ì´í„° 무결성 검사 실패" + ], + "Data integrity check of one part failed": [ + "ì¼ë¶€ ë°ì´í„° 무결성 ê²€ì‚¬ì— ì‹¤íŒ¨í–ˆìŠµë‹ˆë‹¤" + ], + "Device not found": [ + "기기를 ì°¾ì„ ìˆ˜ ì—†ìŒ" + ], + "Expiration date cannot be in the past": [ + "만료 ì¼ìžëŠ” ê³¼ê±°ì¼ ìˆ˜ 없습니다" + ], + "Failed to decrypt active revision: ${ message }": [ + "활성 개정본 복호화 실패: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "블ë¡ì„ 복호화할 수 ì—†ìŒ: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "콘í…츠 키 복호화 실패: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "항목 ì´ë¦„ì„ ë³µí˜¸í™”í•  수 ì—†ìŒ: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "노드 키 복호화 실패: ${ message }" + ], + "Failed to decrypt some nodes": [ + "ì¼ë¶€ 노드를 복호화할 수 ì—†ìŒ" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "ì„¬ë‚´ì¼ ë³µí˜¸í™” 실패: ${ message }" + ], + "Failed to load some nodes": [ + "ì¼ë¶€ 로드 불러오기 실패" + ], + "File download failed due to empty response": [ + "빈 ì‘답으로 ì¸í•˜ì—¬ ë‹¤ìš´ë¡œë“œì— ì‹¤íŒ¨í–ˆìŠµë‹ˆë‹¤" + ], + "File has no active revision": [ + "파ì¼ì— 활성 수정 ë²„ì „ì´ ì—†ìŠµë‹ˆë‹¤" + ], + "File has no content key": [ + "파ì¼ì— 콘í…츠 키가 없습니다" + ], + "Invitation not found": [ + "초대를 ì°¾ì„ ìˆ˜ ì—†ìŒ" + ], + "Item cannot be decrypted": [ + "í•­ëª©ì„ ë³µí˜¸í™”í•  수 없습니다" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "레거시 공개 ë§í¬ëŠ” ì—…ë°ì´íŠ¸ê°€ 불가능합니다. 새로운 공개 ë§í¬ë¥¼ 다시 ìƒì„±í•´ 주세요." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "여러 섹션ì—서 ì„¬ë„¤ì¼ ë¡œë“œëŠ” 허용ë˜ì§€ 않습니다." + ], + "Missing integrity signature": [ + "무결성 서명 ì—†ìŒ" + ], + "Missing signature": [ + "서명 누ë½" + ], + "Missing signature for ${ signatureType }": [ + "${ signatureType }ì— ëŒ€í•œ 서명 누ë½" + ], + "Move operation aborted": [ + "ì´ë™ 작업 중단ë¨" + ], + "Moving item to a non-folder is not allowed": [ + "í´ë”ê°€ 아닌 ê³³ì— í•­ëª© ì´ë™ì´ 불가능합니다." + ], + "Moving root item is not allowed": [ + "ìƒìœ„ 항목으로 ì´ë™ì€ 허용ë˜ì§€ 않습니다" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "ì´ë¦„ì€ ìµœëŒ€ ${ MAX_NODE_NAME_LENGTH }글ìžê¹Œì§€ìž…니다." + ], + "Name must not be empty": [ + "ì´ë¦„ì€ ë¹„ì›Œë‘˜ 수 없습니다" + ], + "Name must not contain the character '/'": [ + "ì´ë¦„ì— ë¬¸ìž '/'ì„ í¬í•¨í•  수 없습니다" + ], + "Node has no thumbnail": [ + "ë…¸ë“œì— ì„¬ë‚´ì¼ì´ 없습니다" + ], + "Node is not a file": [ + "노드가 파ì¼ì´ 아닙니다" + ], + "Node is not accessible": [ + "ë…¸ë“œì— ì ‘ê·¼í•  수 없습니다" + ], + "Node is not shared": [ + "노드가 공유ë˜ì§€ 않습니다" + ], + "Node not found": [ + "노드를 ì°¾ì„ ìˆ˜ ì—†ìŒ" + ], + "Operation aborted": [ + "작업 중단ë¨" + ], + "Parent cannot be decrypted": [ + "ìƒìœ„ í•­ëª©ì„ ë³µí˜¸í™”í•  수 없습니다" + ], + "Renaming root item is not allowed": [ + "ìƒìœ„ í•­ëª©ì˜ ì´ë¦„ ë³€ê²½ì´ í—ˆìš©ë˜ì§€ 않습니다" + ], + "Request aborted": [ + "요청 중단ë¨" + ], + "Signature verification failed": [ + "서명 ì¸ì¦ 실패" + ], + "Signature verification for ${ signatureType } failed": [ + "${ signatureType }ì˜ ì„œëª… 확ì¸ì— 실패했습니다" + ], + "Some file bytes failed to upload": [ + "ì¼ë¶€ íŒŒì¼ ë°”ì´íЏ ì—…ë¡œë“œì— ì‹¤íŒ¨í–ˆìŠµë‹ˆë‹¤" + ], + "Some file parts failed to upload": [ + "ì¼ë¶€ íŒŒì¼ ë¶€ë¶„ ì—…ë¡œë“œì— ì‹¤íŒ¨í–ˆìŠµë‹ˆë‹¤" + ], + "Thumbnail not found": [ + "섬내ì¼ì„ ì°¾ì„ ìˆ˜ ì—†ìŒ" + ], + "Too many server errors, please try again later": [ + "너무 ë§Žì€ ì„œë²„ 오류 ë°œìƒ, ë‚˜ì¤‘ì— ë‹¤ì‹œ 시ë„하세요" + ], + "Too many server requests, please try again later": [ + "서버 ìš”ì²­ì´ ë„ˆë¬´ 많습니다. ë‚˜ì¤‘ì— ë‹¤ì‹œ 시ë„í•´ 주세요." + ], + "Unknown error": [ + "알 수 없는 오류" + ], + "Unknown error ${ code }": [ + "알 수 없는 오류 ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "알 수 없는 오류 ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "ì¸ì¦ 키를 사용할 수 없습니다" + ], + "Verification keys for ${ signatureType } are not available": [ + "${ signatureType }ì— ëŒ€í•œ ì¸ì¦ 키를 사용할 수 없습니다" + ], + "You can leave only item that is shared with you": [ + "나와 ê³µìœ ëœ í•­ëª©ì—서만 나갈 수 있습니다." + ] + }, + "Operation": { + "Deleting items": [ + "항목 ì‚­ì œ 중" + ], + "Restoring items": [ + "항목 ë³µì› ì¤‘" + ], + "Trashing items": [ + "í•­ëª©ì„ íœ´ì§€í†µì— ì´ë™ 중" + ] + }, + "Property": { + "content key": [ + "콘í…츠 키" + ], + "hash key": [ + "해시 키" + ], + "key": [ + "키" + ], + "name": [ + "ì´ë¦„" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json new file mode 100644 index 00000000..e2892da5 --- /dev/null +++ b/js/sdk/locales/nl_NL.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "nl_NL" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } uit meerdere secties is niet toegestaan" + ], + "Cannot download a folder": [ + "Kan geen map downloaden" + ], + "Cannot share root folder": [ + "Kan hoofdmap niet delen" + ], + "Creating files in non-folders is not allowed": [ + "Het aanmaken van bestanden in niet-mappen is niet toegestaan" + ], + "Creating folders in non-folders is not allowed": [ + "Het aanmaken van mappen in niet-mappen is niet toegestaan" + ], + "Creating revisions in non-files is not allowed": [ + "Het aanmaken van revisies in niet-mappen is niet toegestaan" + ], + "Data integrity check failed": [ + "Integriteitscontrole van gegevens mislukt" + ], + "Data integrity check of one part failed": [ + "Integriteitscontrole van één onderdeel is mislukt" + ], + "Device not found": [ + "Apparaat niet gevonden" + ], + "Expiration date cannot be in the past": [ + "De vervaldatum kan niet in het verleden liggen" + ], + "Failed to decrypt active revision: ${ message }": [ + "Fout bij het ontsleutelen van actieve revisie: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Fout bij het ontsleutelen van blok: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Fout bij het ontsleutelen van de inhoudssleutel: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Fout bij het ontsleutelen van de itemnaam: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Fout bij het ontsleutelen van de nodesleutel: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Fout bij het ontsleutelen van sommige nodes" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Fout bij het ontsleutelen van de miniatuur: ${ message }" + ], + "Failed to load some nodes": [ + "Fout bij het laden van sommige nodes" + ], + "File download failed due to empty response": [ + "Het downloaden van het bestand is mislukt door een leeg antwoord" + ], + "File has no active revision": [ + "Bestand heeft geen actieve revisie" + ], + "File has no content key": [ + "Bestand heeft geen inhoudssleutel" + ], + "Invitation not found": [ + "Uitnodiging niet gevonden" + ], + "Item cannot be decrypted": [ + "Item kan niet worden ontsleuteld" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "De verouderde openbare koppeling kan niet worden bijgewerkt. Maak opnieuw een openbare koppeling aan." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Laden van miniaturen uit meerdere secties is niet toegestaan" + ], + "Missing integrity signature": [ + "Integriteitshandtekening ontbreekt" + ], + "Missing signature": [ + "Handtekening ontbreekt" + ], + "Missing signature for ${ signatureType }": [ + "Ontbrekende handtekening voor ${ signatureType }" + ], + "Move operation aborted": [ + "Verplaatsactie afgebroken" + ], + "Moving item to a non-folder is not allowed": [ + "Het verplaatsen van een item naar een niet-map is niet toegestaan" + ], + "Moving root item is not allowed": [ + "Het verplaatsen van het hoofditem is niet toegelaten" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Naam mag maximaal ${ MAX_NODE_NAME_LENGTH } teken lang zijn", + "Naam mag maximaal ${ MAX_NODE_NAME_LENGTH } tekens lang zijn" + ], + "Name must not be empty": [ + "Naam mag niet leeg zijn" + ], + "Name must not contain the character '/'": [ + "Naam mag het teken '/' niet bevatten" + ], + "Node has no thumbnail": [ + "Node heeft geen miniatuur" + ], + "Node is not a file": [ + "Node is geen bestand" + ], + "Node is not accessible": [ + "Node is niet toegankelijk" + ], + "Node is not shared": [ + "Node is niet gedeeld" + ], + "Node not found": [ + "Node niet gevonden" + ], + "Operation aborted": [ + "Handeling afgebroken" + ], + "Parent cannot be decrypted": [ + "Bovenliggend onderdeel kan niet worden ontsleuteld" + ], + "Renaming root item is not allowed": [ + "Het hernoemen van het hoofditem is niet toegelaten" + ], + "Request aborted": [ + "Verzoek afgebroken" + ], + "Signature verification failed": [ + "Controle van handtekening mislukt" + ], + "Signature verification for ${ signatureType } failed": [ + "Verificatie van de handtekening voor ${ signatureType } is mislukt" + ], + "Some file bytes failed to upload": [ + "Sommige bestandsbytes konden niet worden geüpload" + ], + "Some file parts failed to upload": [ + "Sommige bestandsonderdelen konden niet worden geüpload" + ], + "Thumbnail not found": [ + "Miniatuur niet gevonden" + ], + "Too many server errors, please try again later": [ + "Te veel serverfouten, probeer het later opnieuw" + ], + "Too many server requests, please try again later": [ + "Te veel serveraanvragen, probeer het later opnieuw" + ], + "Unknown error": [ + "Onbekende fout" + ], + "Unknown error ${ code }": [ + "Onbekende fout ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Onbekende fout ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Verificatiesleutels zijn niet beschikbaar" + ], + "Verification keys for ${ signatureType } are not available": [ + "Verificatiesleutels voor ${ signatureType } zijn niet beschikbaar" + ], + "You can leave only item that is shared with you": [ + "U kunt alleen een item verlaten dat met u wordt gedeeld" + ] + }, + "Operation": { + "Deleting items": [ + "Items verwijderen" + ], + "Restoring items": [ + "Herstellen van items" + ], + "Trashing items": [ + "Items verwijderen" + ] + }, + "Property": { + "attributes": [ + "attributen" + ], + "content key": [ + "inhoudssleutel" + ], + "hash key": [ + "hash sleutel" + ], + "key": [ + "sleutel" + ], + "name": [ + "naam" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/pl_PL.json b/js/sdk/locales/pl_PL.json new file mode 100644 index 00000000..da1e9c6e --- /dev/null +++ b/js/sdk/locales/pl_PL.json @@ -0,0 +1,209 @@ +{ + "headers": { + "plural-forms": "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", + "language": "pl_PL" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } z wielu sekcji nie jest dozwolone" + ], + "Cannot download a folder": [ + "Nie można pobrać folderu" + ], + "Cannot share root folder": [ + "Nie można udostÄ™pnić folderu głównego" + ], + "Creating files in non-folders is not allowed": [ + "Tworzenie plików poza folderami nie jest dozwolone" + ], + "Creating folders in non-folders is not allowed": [ + "Tworzenie folderów poza folderami nie jest dozwolone" + ], + "Creating revisions in non-files is not allowed": [ + "Tworzenie rewizji poza folderami nie jest dozwolone" + ], + "Data integrity check failed": [ + "Sprawdzanie integralnoÅ›ci danych nie powiodÅ‚o siÄ™" + ], + "Data integrity check of one part failed": [ + "Sprawdzanie integralnoÅ›ci danych jednej części nie powiodÅ‚o siÄ™" + ], + "Device not found": [ + "UrzÄ…dzenie nie zostaÅ‚o znalezione" + ], + "Expiration date cannot be in the past": [ + "Data wygaÅ›niÄ™cia nie może być wczeÅ›niejsza" + ], + "Failed to decrypt active revision: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować aktywnej rewizji: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować bloku: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować klucza zawartoÅ›ci: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować nazwy elementu: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować klucza wÄ™zÅ‚a: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Nie udaÅ‚o siÄ™ odszyfrować niektórych wÄ™złów" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować podglÄ…du: ${ message }" + ], + "Failed to load some nodes": [ + "Nie udaÅ‚o siÄ™ zaÅ‚adować niektórych wÄ™złów" + ], + "File download failed due to empty response": [ + "Pobieranie pliku nie powiodÅ‚o siÄ™ z powodu pustej odpowiedzi" + ], + "File has no active revision": [ + "Plik nie ma aktywnej rewizji" + ], + "File has no content key": [ + "Plik nie ma klucza zawartoÅ›ci" + ], + "Invitation not found": [ + "Zaproszenie nie zostaÅ‚o znalezione" + ], + "Item cannot be decrypted": [ + "Nie można odszyfrować elementu" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Starszy typ linku nie może zostać zaktualizowany. Utwórz ponownie nowy link." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Åadowanie podglÄ…dów z wielu sekcji nie jest dozwolone" + ], + "Missing integrity signature": [ + "Brak podpisu integralnoÅ›ci" + ], + "Missing signature": [ + "Brak podpisu" + ], + "Missing signature for ${ signatureType }": [ + "Brak podpisu dla ${ signatureType }" + ], + "Move operation aborted": [ + "Operacja przeniesienia zostaÅ‚a przerwana" + ], + "Moving item to a non-folder is not allowed": [ + "Przenoszenie elementów poza folderami nie jest dozwolone" + ], + "Moving root item is not allowed": [ + "Przenoszenie folderu głównego nie jest dozwolone" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Maksymalna dÅ‚ugość nazwy to ${ MAX_NODE_NAME_LENGTH } znak", + "Maksymalna dÅ‚ugość nazwy to ${ MAX_NODE_NAME_LENGTH } znaki", + "Maksymalna dÅ‚ugość nazwy to ${ MAX_NODE_NAME_LENGTH } znaków", + "Maksymalna dÅ‚ugość nazwy to ${ MAX_NODE_NAME_LENGTH } znaków" + ], + "Name must not be empty": [ + "Nazwa nie może być pusta" + ], + "Name must not contain the character '/'": [ + "Nazwa nie może zawierać znaku '/'" + ], + "Node has no thumbnail": [ + "WÄ™zeÅ‚ nie ma podglÄ…du" + ], + "Node is not a file": [ + "WÄ™zeÅ‚ nie jest plikiem" + ], + "Node is not accessible": [ + "WÄ™zeÅ‚ nie jest dostÄ™pny" + ], + "Node is not shared": [ + "WÄ™zeÅ‚ nie jest udostÄ™pniony" + ], + "Node not found": [ + "WÄ™zeÅ‚ nie zostaÅ‚ znaleziony" + ], + "Operation aborted": [ + "Operacja zostaÅ‚a przerwana" + ], + "Parent cannot be decrypted": [ + "Błąd odszyfrowywania" + ], + "Renaming root item is not allowed": [ + "Zmiana nazwy folderu głównego nie jest dozwolona" + ], + "Request aborted": [ + "Żądanie zostaÅ‚o anulowane" + ], + "Signature verification failed": [ + "Weryfikacja podpisu nie powiodÅ‚a siÄ™" + ], + "Signature verification for ${ signatureType } failed": [ + "Weryfikacja podpisu dla ${ signatureType } nie powiodÅ‚a siÄ™" + ], + "Some file bytes failed to upload": [ + "Nie udaÅ‚o siÄ™ przesÅ‚ać niektórych bajtów pliku" + ], + "Some file parts failed to upload": [ + "Niektóre części pliku nie zostaÅ‚y przesÅ‚ane" + ], + "Thumbnail not found": [ + "Nie znaleziono podglÄ…du" + ], + "Too many server errors, please try again later": [ + "Zbyt wiele błędów serwera. Spróbuj ponownie później" + ], + "Too many server requests, please try again later": [ + "Zbyt wiele żądaÅ„ serwera. Spróbuj ponownie później" + ], + "Unknown error": [ + "WystÄ…piÅ‚ nieznany błąd" + ], + "Unknown error ${ code }": [ + "WystÄ…piÅ‚ nieznany błąd ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "WystÄ…piÅ‚ nieznany błąd ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Klucze weryfikacyjne nie sÄ… dostÄ™pne" + ], + "Verification keys for ${ signatureType } are not available": [ + "Klucze weryfikacyjne dla ${ signatureType } nie sÄ… dostÄ™pne" + ], + "You can leave only item that is shared with you": [ + "Możesz opuÅ›cić tylko element, który zostaÅ‚ Ci udostÄ™pniony" + ] + }, + "Operation": { + "Deleting items": [ + "Usuwanie elementów" + ], + "Restoring items": [ + "Przywracanie elementów" + ], + "Trashing items": [ + "Przenoszenie elementów do kosza" + ] + }, + "Property": { + "attributes": [ + "atrybuty" + ], + "content key": [ + "klucz zawartoÅ›ci" + ], + "hash key": [ + "kryptograficzny skrót klucza" + ], + "key": [ + "klucz" + ], + "name": [ + "nazwa" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json new file mode 100644 index 00000000..79a621a7 --- /dev/null +++ b/js/sdk/locales/pt_BR.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n > 1);", + "language": "pt_BR" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } de várias seções não é permitido." + ], + "Cannot download a folder": [ + "Não é possível baixar uma pasta." + ], + "Cannot share root folder": [ + "Não é possível compartilhar a pasta raiz" + ], + "Creating files in non-folders is not allowed": [ + "Não é permitido criar arquivos em itens que não sejam pastas." + ], + "Creating folders in non-folders is not allowed": [ + "Não é permitido criar pastas em itens que não sejam pastas." + ], + "Creating revisions in non-files is not allowed": [ + "Não é permitido criar revisões em itens que não sejam arquivos." + ], + "Data integrity check failed": [ + "Falha na verificação de integridade dos dados" + ], + "Data integrity check of one part failed": [ + "A verificação de integridade dos dados de uma parte falhou" + ], + "Device not found": [ + "Dispositivo não encontrado" + ], + "Expiration date cannot be in the past": [ + "A data de expiração não pode ser no passado." + ], + "Failed to decrypt active revision: ${ message }": [ + "Erro ao descriptografar a revisão ativa: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Falha ao descriptografar bloco: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Falha ao descriptografar a chave de conteúdo: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Falha ao descriptografar o nome do item: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Falha ao descriptografar a chave do nó: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Falha ao descriptografar alguns nós" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Erro ao descriptografar a miniatura: ${ message }" + ], + "Failed to load some nodes": [ + "Erro ao carregar alguns nós" + ], + "File download failed due to empty response": [ + "Erro ao baixar o arquivo devido a uma resposta vazia" + ], + "File has no active revision": [ + "O arquivo não tem revisão ativa" + ], + "File has no content key": [ + "O arquivo não tem chave de conteúdo." + ], + "Invitation not found": [ + "Convite não encontrado" + ], + "Item cannot be decrypted": [ + "O item não pode ser descriptografado" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "O link público antigo não pode ser atualizado. Crie um novo link público." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Não é permitido carregar miniaturas de várias secções." + ], + "Missing integrity signature": [ + "Assinatura de integridade ausente" + ], + "Missing signature": [ + "Assinatura ausente" + ], + "Missing signature for ${ signatureType }": [ + "Assinatura ausente para ${ signatureType }" + ], + "Move operation aborted": [ + "O deslocamento foi cancelado." + ], + "Moving item to a non-folder is not allowed": [ + "Não é permitido mover um item para um item que não seja uma pasta." + ], + "Moving root item is not allowed": [ + "O deslocamento do elemento principal não é permitido." + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "O nome deve ter no máximo ${ MAX_NODE_NAME_LENGTH } caractere", + "O nome deve ter no máximo ${ MAX_NODE_NAME_LENGTH } caracteres" + ], + "Name must not be empty": [ + "O nome não pode estar vazio" + ], + "Name must not contain the character '/'": [ + "O nome não pode conter o caractere \"/\"" + ], + "Node has no thumbnail": [ + "Nó não tem miniatura" + ], + "Node is not a file": [ + "O nó não é um arquivo" + ], + "Node is not accessible": [ + "O nó não está acessível" + ], + "Node is not shared": [ + "O nó não está compartilhado." + ], + "Node not found": [ + "Nó não encontrado" + ], + "Operation aborted": [ + "Operação abortada" + ], + "Parent cannot be decrypted": [ + "O elemento principal não pode ser descriptografado." + ], + "Renaming root item is not allowed": [ + "Não é permitido alterar o nome do item raiz" + ], + "Request aborted": [ + "Solicitação cancelada" + ], + "Signature verification failed": [ + "Erro na verificação da assinatura" + ], + "Signature verification for ${ signatureType } failed": [ + "Verificação de assinatura para ${ signatureType } falhou" + ], + "Some file bytes failed to upload": [ + "Alguns bytes do arquivo falharam ao enviar" + ], + "Some file parts failed to upload": [ + "Algumas partes do arquivo falharam ao enviar" + ], + "Thumbnail not found": [ + "Miniatura não encontrada" + ], + "Too many server errors, please try again later": [ + "Muitos erros do servidor. Tente novamente mais tarde." + ], + "Too many server requests, please try again later": [ + "Muitas solicitações do servidor. Tente novamente mais tarde." + ], + "Unknown error": [ + "Erro desconhecido" + ], + "Unknown error ${ code }": [ + "Erro desconhecido ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Erro desconhecido ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "As chaves de verificação não estão disponíveis" + ], + "Verification keys for ${ signatureType } are not available": [ + "As chaves de verificação para ${ signatureType } não estão disponíveis" + ], + "You can leave only item that is shared with you": [ + "Você pode sair apenas do item que é compartilhado com você" + ] + }, + "Operation": { + "Deleting items": [ + "Excluindo itens" + ], + "Restoring items": [ + "Restaurando itens" + ], + "Trashing items": [ + "Movendo itens para a lixeira" + ] + }, + "Property": { + "attributes": [ + "atributos" + ], + "content key": [ + "chave de conteúdo" + ], + "hash key": [ + "chave hash" + ], + "key": [ + "chave" + ], + "name": [ + "nome" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/pt_PT.json b/js/sdk/locales/pt_PT.json new file mode 100644 index 00000000..2024470a --- /dev/null +++ b/js/sdk/locales/pt_PT.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "pt_PT" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } de várias seções não é permitido." + ], + "Cannot download a folder": [ + "Não é possível transferir uma pasta." + ], + "Cannot share root folder": [ + "Não é possível partilhar a pasta principal." + ], + "Creating files in non-folders is not allowed": [ + "Não é permitido criar ficheiros em itens que não sejam pastas." + ], + "Creating folders in non-folders is not allowed": [ + "Não é permitido criar pastas em itens que não sejam pastas." + ], + "Creating revisions in non-files is not allowed": [ + "Não é permitido criar revisões em itens que não sejam ficheiros." + ], + "Data integrity check failed": [ + "Erro na verificação de integridade dos dados" + ], + "Data integrity check of one part failed": [ + "Erro na verificação de integridade dos dados de uma parte" + ], + "Device not found": [ + "O dispositivo não foi encontrado." + ], + "Expiration date cannot be in the past": [ + "A data de expiração não pode estar no passado." + ], + "Failed to decrypt active revision: ${ message }": [ + "Erro ao desencriptar a revisão ativa: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Erro ao desencriptar o bloco: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Erro ao desencriptar a chave de conteúdo: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Erro ao desencriptar o nome do item: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Erro ao desencriptar chave do nó: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Erro ao desencriptar alguns nós" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Erro ao desencriptar a miniatura: ${ message }" + ], + "Failed to load some nodes": [ + "Erro ao carregar alguns nós" + ], + "File download failed due to empty response": [ + "Erro na transferência do ficheiro devido a uma resposta vazia" + ], + "File has no active revision": [ + "O ficheiro não tem revisão ativa." + ], + "File has no content key": [ + "O ficheiro não tem chave de conteúdo." + ], + "Invitation not found": [ + "O convite não foi encontrado." + ], + "Item cannot be decrypted": [ + "Não é possível desencriptar o item." + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Não é possível atualizar a ligação pública antiga. Cria uma nova ligação pública." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Não é permitido carregar miniaturas de várias secções." + ], + "Missing integrity signature": [ + "Assinatura de integridade ausente" + ], + "Missing signature": [ + "Assinatura em falta" + ], + "Missing signature for ${ signatureType }": [ + "Assinatura ausente para ${ signatureType }" + ], + "Move operation aborted": [ + "O deslocamento foi cancelado." + ], + "Moving item to a non-folder is not allowed": [ + "Não é permitido mover um item para um item que não seja uma pasta." + ], + "Moving root item is not allowed": [ + "O deslocamento do elemento principal não é permitido." + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "O nome deve ter no máximo ${ MAX_NODE_NAME_LENGTH } carácter.", + "O nome deve ter no máximo ${ MAX_NODE_NAME_LENGTH } carateres." + ], + "Name must not be empty": [ + "O nome não pode estar vazio." + ], + "Name must not contain the character '/'": [ + "O nome não deve conter barras «/»." + ], + "Node has no thumbnail": [ + "O nó não tem miniatura." + ], + "Node is not a file": [ + "O nó não é um ficheiro." + ], + "Node is not accessible": [ + "O nó não está acessível." + ], + "Node is not shared": [ + "O nó não é partilhado." + ], + "Node not found": [ + "O nó não foi encontrado." + ], + "Operation aborted": [ + "Operação cancelada" + ], + "Parent cannot be decrypted": [ + "Não é possível desencriptar o item superior." + ], + "Renaming root item is not allowed": [ + "Não é permitido alterar o nome do item principal." + ], + "Request aborted": [ + "Solicitação cancelada" + ], + "Signature verification failed": [ + "Erro ao verificar a assinatura" + ], + "Signature verification for ${ signatureType } failed": [ + "Erro na verificação de assinatura para ${ signatureType }" + ], + "Some file bytes failed to upload": [ + "Erro ao enviar alguns bytes do ficheiro" + ], + "Some file parts failed to upload": [ + "Erro ao enviar algumas partes do ficheiro" + ], + "Thumbnail not found": [ + "A miniatura não foi encontrada." + ], + "Too many server errors, please try again later": [ + "Demasiados erros do servidor. Tente novamente mais tarde." + ], + "Too many server requests, please try again later": [ + "Demasiadas requisições do servidor. Tente novamente mais tarde." + ], + "Unknown error": [ + "Erro desconhecido" + ], + "Unknown error ${ code }": [ + "Erro desconhecido ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Erro desconhecido ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "As chaves de verificação não estão disponíveis." + ], + "Verification keys for ${ signatureType } are not available": [ + "As chaves de verificação para ${ signatureType } não estão disponíveis." + ], + "You can leave only item that is shared with you": [ + "Só pode deixar o item partilhado consigo." + ] + }, + "Operation": { + "Deleting items": [ + "A eliminar itens" + ], + "Restoring items": [ + "A restaurae itens" + ], + "Trashing items": [ + "A eliminar itens" + ] + }, + "Property": { + "attributes": [ + "atributos" + ], + "content key": [ + "chave de conteúdo" + ], + "hash key": [ + "chave hash" + ], + "key": [ + "chave" + ], + "name": [ + "nome" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json new file mode 100644 index 00000000..d5b90c10 --- /dev/null +++ b/js/sdk/locales/ro_RO.json @@ -0,0 +1,208 @@ +{ + "headers": { + "plural-forms": "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);", + "language": "ro_RO" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } din secÈ›iuni multiple nu este permisă." + ], + "Cannot download a folder": [ + "Nu se poate descărca un folder." + ], + "Cannot share root folder": [ + "Nu se poate partaja folderul rădăcină." + ], + "Creating files in non-folders is not allowed": [ + "Crearea fiÈ™ierelor în non-foldere nu este permisă." + ], + "Creating folders in non-folders is not allowed": [ + "Crearea folderelor în non-foldere nu este permisă." + ], + "Creating revisions in non-files is not allowed": [ + "Crearea reviziilor în non-fiÈ™iere nu este permisă." + ], + "Data integrity check failed": [ + "Verificarea integrității datelor a eÈ™uat." + ], + "Data integrity check of one part failed": [ + "Verificarea integrității datelor pentru o parte a eÈ™uat." + ], + "Device not found": [ + "Dispozitivul nu a fost găsit." + ], + "Expiration date cannot be in the past": [ + "Data de expirare nu poate fi în trecut." + ], + "Failed to decrypt active revision: ${ message }": [ + "Nu s-a reuÈ™it decriptarea reviziei active: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Nu s-a reuÈ™it decriptarea blocului: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Nu s-a reuÈ™it decriptarea cheii de conÈ›inut: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Nu s-a reuÈ™it decriptarea numelui elementului: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Nu s-a reuÈ™it decriptarea cheii nodului: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Nu s-a reuÈ™it decriptarea unor noduri." + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Nu s-a reuÈ™it decriptarea miniaturii: ${ message }" + ], + "Failed to load some nodes": [ + "Nu s-a reuÈ™it încărcarea unor noduri." + ], + "File download failed due to empty response": [ + "Descărcarea fiÈ™ierului a eÈ™uat din cauza unui răspuns gol." + ], + "File has no active revision": [ + "FiÈ™ierul nu are nicio revizie activă." + ], + "File has no content key": [ + "FiÈ™ierul nu are nicio cheie de conÈ›inut." + ], + "Invitation not found": [ + "InvitaÈ›ia nu a fost găsită." + ], + "Item cannot be decrypted": [ + "Articolul nu poate fi decriptat." + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Vechea legătură web publică nu poate fi actualizată. RecreaÈ›i o nouă legătură web publică." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "ÃŽncărcarea miniaturilor din secÈ›iuni multiple nu este permisă." + ], + "Missing integrity signature": [ + "LipseÈ™te semnătura de integritate" + ], + "Missing signature": [ + "Semnătură lipsă." + ], + "Missing signature for ${ signatureType }": [ + "Semnătură lipsă pentru ${ signatureType }" + ], + "Move operation aborted": [ + "Mutarea a fost anulată." + ], + "Moving item to a non-folder is not allowed": [ + "Mutarea elementului într-un non-folder nu este permisă." + ], + "Moving root item is not allowed": [ + "Mutarea rădăcinii nu este permisă." + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Numele trebuie să aibă cel mult ${ MAX_NODE_NAME_LENGTH } caracter.", + "Numele trebuie să aibă cel mult ${ MAX_NODE_NAME_LENGTH } caractere.", + "Numele trebuie să aibă cel mult ${ MAX_NODE_NAME_LENGTH } de caractere." + ], + "Name must not be empty": [ + "Numele nu poate fi gol." + ], + "Name must not contain the character '/'": [ + "Numele nu trebuie să conÈ›ină caracterul „/â€." + ], + "Node has no thumbnail": [ + "Nodul nu are miniatură." + ], + "Node is not a file": [ + "Nodul nu este un fiÈ™ier." + ], + "Node is not accessible": [ + "Nodul nu este accesibil." + ], + "Node is not shared": [ + "Nodul nu este partajat." + ], + "Node not found": [ + "Nod negăsit." + ], + "Operation aborted": [ + "OperaÈ›ie anulată" + ], + "Parent cannot be decrypted": [ + "Părintele nu poate fi decriptat." + ], + "Renaming root item is not allowed": [ + "Redenumirea rădăcinii nu este permisă." + ], + "Request aborted": [ + "Solicitare anulată." + ], + "Signature verification failed": [ + "EÈ™uare verificare semnătură." + ], + "Signature verification for ${ signatureType } failed": [ + "Verificarea semnăturii pentru ${ signatureType } a eÈ™uat." + ], + "Some file bytes failed to upload": [ + "Unii octeÈ›i ai fiÈ™ierului nu s-au încărcat." + ], + "Some file parts failed to upload": [ + "Unele părÈ›i ale fiÈ™ierului nu s-au încărcat." + ], + "Thumbnail not found": [ + "Miniatură negăsită" + ], + "Too many server errors, please try again later": [ + "Prea multe erori de server. ReîncercaÈ›i mai târziu." + ], + "Too many server requests, please try again later": [ + "Prea multe solicitări de server. ReîncercaÈ›i mai târziu." + ], + "Unknown error": [ + "Eroare necunoscută." + ], + "Unknown error ${ code }": [ + "Eroare necunoscută ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Eroare necunoscută ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Cheile de verificare nu sunt disponibile" + ], + "Verification keys for ${ signatureType } are not available": [ + "Cheile de verificare pentru ${ signatureType } nu sunt disponibile." + ], + "You can leave only item that is shared with you": [ + "PuteÈ›i părăsi doar articolul care vi s-a partajat." + ] + }, + "Operation": { + "Deleting items": [ + "Ștergerea articolelor" + ], + "Restoring items": [ + "Restaurare elemente" + ], + "Trashing items": [ + "Se pun în gunoi articolele" + ] + }, + "Property": { + "attributes": [ + "atribute" + ], + "content key": [ + "cheie de conÈ›inut" + ], + "hash key": [ + "cheie criptată" + ], + "key": [ + "cheie" + ], + "name": [ + "nume" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/ru_RU.json b/js/sdk/locales/ru_RU.json new file mode 100644 index 00000000..94272086 --- /dev/null +++ b/js/sdk/locales/ru_RU.json @@ -0,0 +1,209 @@ +{ + "headers": { + "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", + "language": "ru_RU" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } из неÑкольких разделов не допуÑкаетÑÑ" + ], + "Cannot download a folder": [ + "Ðевозможно Ñкачать папку" + ], + "Cannot share root folder": [ + "Ðевозможно поделитьÑÑ ÐºÐ¾Ñ€Ð½ÐµÐ²Ð¾Ð¹ папкой" + ], + "Creating files in non-folders is not allowed": [ + "Создание файлов в Ñлементах, не ÑвлÑющихÑÑ Ð¿Ð°Ð¿ÐºÐ°Ð¼Ð¸, не допуÑкаетÑÑ" + ], + "Creating folders in non-folders is not allowed": [ + "Создание папок в Ñлементах, не ÑвлÑющихÑÑ Ð¿Ð°Ð¿ÐºÐ°Ð¼Ð¸, не допуÑкаетÑÑ" + ], + "Creating revisions in non-files is not allowed": [ + "Создание верÑий в Ñлементах, не ÑвлÑющихÑÑ Ñ„Ð°Ð¹Ð»Ð°Ð¼Ð¸, не допуÑкаетÑÑ" + ], + "Data integrity check failed": [ + "Проверка целоÑтноÑти данных не пройдена" + ], + "Data integrity check of one part failed": [ + "Проверка целоÑтноÑти данных одной из чаÑтей не пройдена" + ], + "Device not found": [ + "УÑтройÑтво не найдено" + ], + "Expiration date cannot be in the past": [ + "Дата Ð¾ÐºÐ¾Ð½Ñ‡Ð°Ð½Ð¸Ñ Ñрока дейÑÑ‚Ð²Ð¸Ñ Ð½Ðµ может быть в прошлом" + ], + "Failed to decrypt active revision: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать активную верÑию: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать блок: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать ключ Ñодержимого: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать Ð¸Ð¼Ñ Ñлемента: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать ключ узла: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Ðе удалоÑÑŒ раÑшифровать некоторые узлы" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать значок: ${ message }" + ], + "Failed to load some nodes": [ + "Ðе удалоÑÑŒ загрузить некоторые узлы" + ], + "File download failed due to empty response": [ + "Ðе удалоÑÑŒ Ñкачать файл из-за пуÑтого ответа" + ], + "File has no active revision": [ + "Файл не имеет активной верÑии" + ], + "File has no content key": [ + "У файла нет ключа Ñодержимого" + ], + "Invitation not found": [ + "Приглашение не найдено" + ], + "Item cannot be decrypted": [ + "Элемент не может быть раÑшифрован" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "УÑтаревшую публичную ÑÑылку невозможно обновить. Создайте новую публичную ÑÑылку." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Загрузка значков из неÑкольких разделов не допуÑкаетÑÑ" + ], + "Missing integrity signature": [ + "ОтÑутÑтвует подпиÑÑŒ целоÑтноÑти" + ], + "Missing signature": [ + "ПодпиÑÑŒ отÑутÑтвует" + ], + "Missing signature for ${ signatureType }": [ + "ОтÑутÑтвует подпиÑÑŒ Ð´Ð»Ñ ${ signatureType }" + ], + "Move operation aborted": [ + "ÐžÐ¿ÐµÑ€Ð°Ñ†Ð¸Ñ Ð¿ÐµÑ€ÐµÐ¼ÐµÑ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€ÐµÑ€Ð²Ð°Ð½Ð°" + ], + "Moving item to a non-folder is not allowed": [ + "Перемещение Ñлемента в Ñлемент, не ÑвлÑющийÑÑ Ð¿Ð°Ð¿ÐºÐ¾Ð¹, не допуÑкаетÑÑ" + ], + "Moving root item is not allowed": [ + "Перемещение корневого Ñлемента не допуÑкаетÑÑ" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Ð˜Ð¼Ñ Ð´Ð¾Ð»Ð¶Ð½Ð¾ ÑоÑтоÑть не более чем из ${ MAX_NODE_NAME_LENGTH } Ñимвола", + "Ð˜Ð¼Ñ Ð´Ð¾Ð»Ð¶Ð½Ð¾ ÑоÑтоÑть не более чем из ${ MAX_NODE_NAME_LENGTH } Ñимволов", + "Ð˜Ð¼Ñ Ð´Ð¾Ð»Ð¶Ð½Ð¾ ÑоÑтоÑть не более чем из ${ MAX_NODE_NAME_LENGTH } Ñимволов", + "Ð˜Ð¼Ñ Ð´Ð¾Ð»Ð¶Ð½Ð¾ ÑоÑтоÑть не более чем из ${ MAX_NODE_NAME_LENGTH } Ñимволов" + ], + "Name must not be empty": [ + "Ðазвание не может быть пуÑтым" + ], + "Name must not contain the character '/'": [ + "Ð˜Ð¼Ñ Ð½Ðµ должно Ñодержать Ñимвол «/»" + ], + "Node has no thumbnail": [ + "У узла нет значка" + ], + "Node is not a file": [ + "Узел не ÑвлÑетÑÑ Ñ„Ð°Ð¹Ð»Ð¾Ð¼" + ], + "Node is not accessible": [ + "Узел недоÑтупен" + ], + "Node is not shared": [ + "Узел не ÑвлÑетÑÑ Ð¾Ð±Ñ‰Ð¸Ð¼" + ], + "Node not found": [ + "Узел не найден" + ], + "Operation aborted": [ + "ÐžÐ¿ÐµÑ€Ð°Ñ†Ð¸Ñ Ð¿Ñ€ÐµÑ€Ð²Ð°Ð½Ð°" + ], + "Parent cannot be decrypted": [ + "РодительÑкий Ñлемент не может быть раÑшифрован" + ], + "Renaming root item is not allowed": [ + "Переименование корневого Ñлемента не допуÑкаетÑÑ" + ], + "Request aborted": [ + "Ð—Ð°Ð¿Ñ€Ð¾Ñ Ð¿Ñ€ÐµÑ€Ð²Ð°Ð½" + ], + "Signature verification failed": [ + "Проверка подпиÑи не удалаÑÑŒ" + ], + "Signature verification for ${ signatureType } failed": [ + "Проверка подпиÑи Ð´Ð»Ñ ${ signatureType } не удалаÑÑŒ" + ], + "Some file bytes failed to upload": [ + "Ðе удалоÑÑŒ загрузить некоторые байты файла" + ], + "Some file parts failed to upload": [ + "Ðе удалоÑÑŒ загрузить некоторые чаÑти файла" + ], + "Thumbnail not found": [ + "Значок не найден" + ], + "Too many server errors, please try again later": [ + "Слишком много ошибок Ñервера, повторите попытку позже" + ], + "Too many server requests, please try again later": [ + "Слишком много запроÑов к Ñерверу, повторите попытку позже" + ], + "Unknown error": [ + "ÐеизвеÑÑ‚Ð½Ð°Ñ Ð¾ÑˆÐ¸Ð±ÐºÐ°" + ], + "Unknown error ${ code }": [ + "ÐеизвеÑÑ‚Ð½Ð°Ñ Ð¾ÑˆÐ¸Ð±ÐºÐ° ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "ÐеизвеÑÑ‚Ð½Ð°Ñ Ð¾ÑˆÐ¸Ð±ÐºÐ° ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Ключи проверки недоÑтупны" + ], + "Verification keys for ${ signatureType } are not available": [ + "Ключи проверки Ð´Ð»Ñ ${ signatureType } недоÑтупны" + ], + "You can leave only item that is shared with you": [ + "Ð’Ñ‹ можете покинуть только тот Ñлемент, к которому вам предоÑтавили доÑтуп" + ] + }, + "Operation": { + "Deleting items": [ + "Удаление Ñлементов" + ], + "Restoring items": [ + "ВоÑÑтановление Ñлементов" + ], + "Trashing items": [ + "Перемещение Ñлементов в корзину" + ] + }, + "Property": { + "attributes": [ + "атрибуты" + ], + "content key": [ + "ключ Ñодержимого" + ], + "hash key": [ + "хеш-ключ" + ], + "key": [ + "ключ" + ], + "name": [ + "название" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json new file mode 100644 index 00000000..e67758fa --- /dev/null +++ b/js/sdk/locales/sk_SK.json @@ -0,0 +1,209 @@ +{ + "headers": { + "plural-forms": "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;", + "language": "sk_SK" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "${ operationForErrorMessage } z viacerých sekcií nie je povolené" + ], + "Cannot download a folder": [ + "Nie je možné stiahnuÅ¥ prieÄinok" + ], + "Cannot share root folder": [ + "Nie je možné zdieľaÅ¥ koreňový prieÄinok" + ], + "Creating files in non-folders is not allowed": [ + "Vytváranie súborov mimo prieÄinkov nie je povolené." + ], + "Creating folders in non-folders is not allowed": [ + "Vytváranie prieÄinkov mimo prieÄinkov nie je povolené." + ], + "Creating revisions in non-files is not allowed": [ + "Vytváranie revízií v nesúborových položkách nie je povolené." + ], + "Data integrity check failed": [ + "Kontrola integrity dát zlyhala" + ], + "Data integrity check of one part failed": [ + "Kontrola integrity dát jednej Äasti zlyhala" + ], + "Device not found": [ + "Zariadenie nebolo nájdené" + ], + "Expiration date cannot be in the past": [ + "Dátum expirácie nemôže byÅ¥ v minulosti" + ], + "Failed to decrypt active revision: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ aktívnu revíziu: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ blok: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ kÄ¾ÃºÄ obsahu: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ názov položky: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ kÄ¾ÃºÄ uzla: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ niektoré uzly" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ miniatúru: ${ message }" + ], + "Failed to load some nodes": [ + "Nepodarilo sa naÄítaÅ¥ niektoré uzly" + ], + "File download failed due to empty response": [ + "Stiahnutie súboru zlyhalo z dôvodu prázdnej odpovede." + ], + "File has no active revision": [ + "Súbor nemá aktívnu revíziu" + ], + "File has no content key": [ + "Súbor nemá kÄ¾ÃºÄ obsahu" + ], + "Invitation not found": [ + "Pozvánka nenájdená" + ], + "Item cannot be decrypted": [ + "Položku nemožno deÅ¡ifrovaÅ¥" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Starý verejný odkaz sa nedá aktualizovaÅ¥. Vytvorte nový verejný odkaz, prosím." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "NaÄítavanie miniatúr z viacerých sekcií nie je povolené" + ], + "Missing integrity signature": [ + "Chýbajúci podpis integrity" + ], + "Missing signature": [ + "Chýbajúci podpis" + ], + "Missing signature for ${ signatureType }": [ + "Chýba podpis pre ${ signatureType }" + ], + "Move operation aborted": [ + "Presun bol preruÅ¡ený" + ], + "Moving item to a non-folder is not allowed": [ + "Presúvanie položky mimo prieÄinka nie je povolené." + ], + "Moving root item is not allowed": [ + "Presúvanie koreňovej položky nie je povolené" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Názov musí maÅ¥ najviac ${ MAX_NODE_NAME_LENGTH } znak", + "Názov musí maÅ¥ najviac ${ MAX_NODE_NAME_LENGTH } znaky", + "Názov musí maÅ¥ najviac ${ MAX_NODE_NAME_LENGTH } znakov", + "Názov musí maÅ¥ najviac ${ MAX_NODE_NAME_LENGTH } znakov" + ], + "Name must not be empty": [ + "Názov nesmie byÅ¥ prázdny" + ], + "Name must not contain the character '/'": [ + "Názov nesmie obsahovaÅ¥ znak '/'" + ], + "Node has no thumbnail": [ + "Uzol nemá miniatúru" + ], + "Node is not a file": [ + "Uzol nie je súbor" + ], + "Node is not accessible": [ + "Uzol nie je dostupný" + ], + "Node is not shared": [ + "Uzol nie je zdieľaný" + ], + "Node not found": [ + "Uzol nebol nájdený" + ], + "Operation aborted": [ + "Operácia preruÅ¡ená" + ], + "Parent cannot be decrypted": [ + "Nadradenú položku nemožno deÅ¡ifrovaÅ¥" + ], + "Renaming root item is not allowed": [ + "Premenovanie koreňovej položky nie je povolené" + ], + "Request aborted": [ + "Požiadavka preruÅ¡ená" + ], + "Signature verification failed": [ + "Overenie podpisu zlyhalo" + ], + "Signature verification for ${ signatureType } failed": [ + "Overenie podpisu pre ${ signatureType } zlyhalo" + ], + "Some file bytes failed to upload": [ + "Niektoré bajty súboru sa nepodarilo nahraÅ¥" + ], + "Some file parts failed to upload": [ + "Niektoré Äasti súboru sa nepodarilo nahraÅ¥" + ], + "Thumbnail not found": [ + "Miniatúra nenájdená" + ], + "Too many server errors, please try again later": [ + "PríliÅ¡ veľa serverových chýb, skúste to neskôr, prosím" + ], + "Too many server requests, please try again later": [ + "PríliÅ¡ veľa serverových požiadaviek, skúste to neskôr, prosím" + ], + "Unknown error": [ + "Neznáma chyba" + ], + "Unknown error ${ code }": [ + "Neznáma chyba ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Neznáma chyba ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "Overovacie kľúÄe nie sú k dispozícii" + ], + "Verification keys for ${ signatureType } are not available": [ + "Overovacie kľúÄe pre ${ signatureType } nie sú k dispozícii" + ], + "You can leave only item that is shared with you": [ + "Môžete opustiÅ¥ iba položku, ktorá je s vami zdieľaná." + ] + }, + "Operation": { + "Deleting items": [ + "Vymazávanie položiek" + ], + "Restoring items": [ + "Obnovovanie položiek" + ], + "Trashing items": [ + "Presúvanie položiek do koÅ¡a" + ] + }, + "Property": { + "attributes": [ + "atribúty" + ], + "content key": [ + "kÄ¾ÃºÄ obsahu" + ], + "hash key": [ + "hash kľúÄ" + ], + "key": [ + "kľúÄ" + ], + "name": [ + "názov" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json new file mode 100644 index 00000000..40f066f6 --- /dev/null +++ b/js/sdk/locales/tr_TR.json @@ -0,0 +1,207 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n > 1);", + "language": "tr_TR" + }, + "contexts": { + "Error": { + "${ operationForErrorMessage } from multiple sections is not allowed": [ + "Birden fazla bölümden ${ operationForErrorMessage } iÅŸlemine izin verilmiyor" + ], + "Cannot download a folder": [ + "Klasör indirilemez" + ], + "Cannot share root folder": [ + "Kök klasör paylaşılamıyor" + ], + "Creating files in non-folders is not allowed": [ + "Klasör olmayan yerlerde dosya oluÅŸturulmasına izin verilmemektedir." + ], + "Creating folders in non-folders is not allowed": [ + "Klasör olmayan yerlerde klasör oluÅŸturulmasına izin verilmemektedir." + ], + "Creating revisions in non-files is not allowed": [ + "Dosya olmayanlarda revizyon oluÅŸturulmasına izin verilmemektedir." + ], + "Data integrity check failed": [ + "Veri bütünlüğü kontrolü baÅŸarısız oldu" + ], + "Data integrity check of one part failed": [ + "Bir parçanın veri bütünlüğü kontrolü baÅŸarısız oldu" + ], + "Device not found": [ + "Aygıt bulunamadı" + ], + "Expiration date cannot be in the past": [ + "Geçerlilik süresi geçmiÅŸ bir tarih olamaz" + ], + "Failed to decrypt active revision: ${ message }": [ + "Etkin düzeltmenin ÅŸifresi çözülemedi: ${ message }" + ], + "Failed to decrypt block: ${ message }": [ + "Blok ÅŸifresi çözülemedi: ${ message }" + ], + "Failed to decrypt content key: ${ message }": [ + "İçerik anahtarının ÅŸifresi çözülemedi: ${ message }" + ], + "Failed to decrypt item name: ${ message }": [ + "Öğe adının ÅŸifresi çözülemedi: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Düğüm anahtarının ÅŸifresi çözülemedi: ${ message }" + ], + "Failed to decrypt some nodes": [ + "Bazı düğümlerin ÅŸifresi çözülemedi." + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Küçük görselin ÅŸifresi çözülemedi: ${ message }" + ], + "Failed to load some nodes": [ + "Bazı düğümler yüklenemedi" + ], + "File download failed due to empty response": [ + "BoÅŸ yanıt nedeniyle dosya indirme baÅŸarısız oldu." + ], + "File has no active revision": [ + "Dosyanın aktif revizyonu bulunmamaktadır." + ], + "File has no content key": [ + "Dosyanın içerik anahtarı yok." + ], + "Invitation not found": [ + "Davet bulunamadı" + ], + "Item cannot be decrypted": [ + "Öğenin ÅŸifresi çözülemez" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Eski herkese açık baÄŸlantı güncellenemez. Lütfen yeni bir herkese açık baÄŸlantı oluÅŸturun." + ], + "Loading thumbnails from multiple sections is not allowed": [ + "Birden fazla bölümden küçük görsellerin yüklenmesine izin verilmemektedir." + ], + "Missing integrity signature": [ + "Bütünlük imzası eksik" + ], + "Missing signature": [ + "İmza eksik" + ], + "Missing signature for ${ signatureType }": [ + "${ signatureType } için imza eksik" + ], + "Move operation aborted": [ + "Taşıma iÅŸlemi iptal edildi" + ], + "Moving item to a non-folder is not allowed": [ + "Öğenin klasör olmayan bir yere taşınmasına izin verilmemektedir." + ], + "Moving root item is not allowed": [ + "Kök öğenin taşınmasına izin verilmiyor" + ], + "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ + "Ad en fazla ${ MAX_NODE_NAME_LENGTH } karakter uzunluÄŸunda olmalıdır.", + "Ad en fazla ${ MAX_NODE_NAME_LENGTH } karakter uzunluÄŸunda olmalıdır." + ], + "Name must not be empty": [ + "Ad boÅŸ olamaz" + ], + "Name must not contain the character '/'": [ + "Ad \"/\" karakterini içermemelidir." + ], + "Node has no thumbnail": [ + "Düğümün küçük resmi yok" + ], + "Node is not a file": [ + "Düğüm bir dosya deÄŸil" + ], + "Node is not accessible": [ + "Düğüme eriÅŸilemiyor" + ], + "Node is not shared": [ + "Düğüm paylaşılmamış" + ], + "Node not found": [ + "Düğüm bulunamadı" + ], + "Operation aborted": [ + "İşlem iptal edildi" + ], + "Parent cannot be decrypted": [ + "Üst öğenin ÅŸifresi çözülemez" + ], + "Renaming root item is not allowed": [ + "Kök öğenin yeniden adlandırılmasına izin verilmiyor." + ], + "Request aborted": [ + "İstek iptal edildi" + ], + "Signature verification failed": [ + "İmza doÄŸrulaması baÅŸarısız oldu" + ], + "Signature verification for ${ signatureType } failed": [ + "${ signatureType } için imza doÄŸrulama baÅŸarısız oldu." + ], + "Some file bytes failed to upload": [ + "Bazı dosya baytları yüklenemedi." + ], + "Some file parts failed to upload": [ + "Bazı dosya parçaları yüklenemedi." + ], + "Thumbnail not found": [ + "Küçük resim bulunamadı" + ], + "Too many server errors, please try again later": [ + "Çok fazla sunucu hatası oluÅŸtu, lütfen daha sonra yeniden deneyin" + ], + "Too many server requests, please try again later": [ + "Çok fazla sayıda sunucu isteÄŸi yapıldı. Lütfen bir süre sonra yeniden deneyin." + ], + "Unknown error": [ + "Bilinmeyen sorun" + ], + "Unknown error ${ code }": [ + "Bilinmeyen hata ${ code }" + ], + "Unknown error ${ response.Response.Code }": [ + "Bilinmeyen hata ${ response.Response.Code }" + ], + "Verification keys are not available": [ + "DoÄŸrulama anahtarları mevcut deÄŸil" + ], + "Verification keys for ${ signatureType } are not available": [ + "${ signatureType } için doÄŸrulama anahtarları mevcut deÄŸil" + ], + "You can leave only item that is shared with you": [ + "Sadece sizinle paylaşılan öğeyi bırakabilirsiniz" + ] + }, + "Operation": { + "Deleting items": [ + "Öğeler siliniyor" + ], + "Restoring items": [ + "Öğeler geri yükleniyor" + ], + "Trashing items": [ + "Öğeler çöpe atılıyor" + ] + }, + "Property": { + "attributes": [ + "özellikler" + ], + "content key": [ + "içerik anahtarı" + ], + "hash key": [ + "karma anahtarı" + ], + "key": [ + "anahtar" + ], + "name": [ + "ad" + ] + } + } +} \ No newline at end of file From f77bb4d072e904e14efdd5acc0248b0cb9c66a54 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 05:55:57 +0000 Subject: [PATCH 149/791] Implement bookmarks management --- js/sdk/src/crypto/driveCrypto.ts | 30 ++++ js/sdk/src/crypto/interface.ts | 6 + js/sdk/src/crypto/openPGPCrypto.ts | 13 ++ js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/sharing.ts | 26 ++- js/sdk/src/interface/telemetry.ts | 1 + .../internal/sharing/cryptoService.test.ts | 148 ++++++++++++++++++ js/sdk/src/internal/sharing/cryptoService.ts | 140 ++++++++++++++++- js/sdk/src/internal/sharing/index.ts | 2 +- js/sdk/src/internal/sharing/interface.ts | 4 + .../internal/sharing/sharingAccess.test.ts | 74 +++++++++ js/sdk/src/internal/sharing/sharingAccess.ts | 34 +++- js/sdk/src/protonDriveClient.ts | 25 +++ 13 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 js/sdk/src/internal/sharing/cryptoService.test.ts diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index dc1a4659..97b454f9 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -232,6 +232,36 @@ export class DriveCrypto { }; } + /** + * It decrypts the key using the password via SRP protocol. + * + * The function follows the same functionality as `decryptKey` but uses SRP + * protocol to decrypt the passphrase of the key. It is used for saved + * public links where user saved the link with password and is not direct + * member of the share. + */ + async decryptKeyWithSrpPassword( + password: string, + salt: string, + armoredKey: string, + armoredPassphrase: string, + ): Promise<{ + key: PrivateKey, + }> { + const keyPassword = await this.srpModule.computeKeyPassword(password, salt); + + const passphrase = await this.openPGPCrypto.decryptArmoredWithPassword(armoredPassphrase, keyPassword); + + const key = await this.openPGPCrypto.decryptKey( + armoredKey, + new TextDecoder().decode(passphrase), + ); + + return { + key, + }; + } + /** * It decrypts session key from armored data. * diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 6fa9b992..b5377bd3 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -53,6 +53,7 @@ export enum VERIFICATION_STATUS { export interface SRPModule { getSrpVerifier: (password: string) => Promise, + computeKeyPassword: (password: string, salt: string) => Promise, } export type SRPVerifier = { @@ -227,4 +228,9 @@ export interface OpenPGPCrypto { data: Uint8Array, verified: VERIFICATION_STATUS, }>, + + decryptArmoredWithPassword( + armoredData: string, + password: string, + ): Promise, } diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 3588e176..80c075d7 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -30,6 +30,7 @@ export interface OpenPGPCryptoProxy { armoredSignature?: string, binarySignature?: Uint8Array, sessionKeys?: SessionKey, + passwords?: string[], decryptionKeys?: PrivateKey | PrivateKey[], verificationKeys?: PublicKey | PublicKey[], }) => Promise<{ @@ -395,4 +396,16 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { verified: verified || verificationStatus!, } } + + async decryptArmoredWithPassword( + armoredData: string, + password: string, + ) { + const { data } = await this.cryptoProxy.decryptMessage({ + armoredMessage: armoredData, + passwords: [password], + format: 'binary', + }); + return data as Uint8Array; + } } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 5b0e1fa1..de2056b2 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -16,7 +16,7 @@ export { SDKEvent } from './events'; export type { ProtonDriveHTTPClient, ProtonDriveHTTPClientJsonOptions, ProtonDriveHTTPClientBlobOptions, ProtonDriveConfig } from './httpClient'; export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; -export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, Bookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, SharePublicLinkSettingsObject, ShareResult } from './sharing'; +export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, MaybeBookmark, Bookmark, DegradedBookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, SharePublicLinkSettingsObject, ShareResult } from './sharing'; export { NonProtonInvitationState } from './sharing'; export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricBlockVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent } from './telemetry'; export { MetricVolumeType } from './telemetry'; diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index cdef3744..5868f632 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -38,16 +38,38 @@ export type PublicLink = { expirationTime?: Date, } +/** + * Bookmark representing a saved link to publicly shared node. + * + * This covers both happy path and degraded path. + */ +export type MaybeBookmark = Result; + export type Bookmark = { uid: string, - bookmarkTime: Date, + creationTime: Date, + url: string, node: { - name: Result, + name: string, type: NodeType, mediaType?: string, }, } +/** + * Degraded bookmark representing a saved link to publicly shared node. + * + * This is a degraded path representation of the bookmark. It is used in the + * SDK to represent the bookmark in a way that is easy to work with. Whenever + * any field cannot be decrypted, it is returned as `DegradedBookmark` type. + */ +export type DegradedBookmark = Omit & { + url: Result, + node: Omit & { + name: Result, + }, +} + export type ProtonInvitationOrUid = ProtonInvitation | string; export type NonProtonInvitationOrUid = NonProtonInvitation | string; export type BookmarkOrUid = Bookmark | string; diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index f0863e30..0bd16914 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -67,6 +67,7 @@ export interface MetricDecryptionErrorEvent { }; export type MetricsDecryptionErrorField = 'shareKey' | + 'shareUrlPassword' | 'nodeKey' | 'nodeName' | 'nodeHashKey' | diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts new file mode 100644 index 00000000..9276725c --- /dev/null +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -0,0 +1,148 @@ +import { DriveCrypto, PrivateKey } from "../../crypto"; +import { MetricVolumeType, NodeType, ProtonDriveAccount, ProtonDriveTelemetry, resultError, resultOk } from "../../interface"; +import { getMockTelemetry } from "../../tests/telemetry"; +import { SharesService } from "./interface"; +import { SharingCryptoService } from "./cryptoService"; + +describe("SharingCryptoService", () => { + let telemetry: ProtonDriveTelemetry; + let driveCrypto: DriveCrypto; + let account: ProtonDriveAccount; + let sharesService: SharesService; + let cryptoService: SharingCryptoService; + + beforeEach(() => { + telemetry = getMockTelemetry(); + // @ts-expect-error No need to implement all methods for mocking + driveCrypto = { + decryptShareUrlPassword: jest.fn().mockResolvedValue("urlPassword"), + decryptKeyWithSrpPassword: jest.fn().mockResolvedValue({ + key: "decryptedKey" as unknown as PrivateKey, + }), + decryptNodeName: jest.fn().mockResolvedValue({ + name: "nodeName", + }), + }; + account = { + // @ts-expect-error No need to implement full response for mocking + getOwnAddress: jest.fn(async () => ({ + keys: [{ key: "addressKey" as unknown as PrivateKey }], + })), + }; + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getMyFilesShareMemberEmailKey: jest.fn().mockResolvedValue({ + addressId: "addressId", + }), + }; + cryptoService = new SharingCryptoService(telemetry, driveCrypto, account, sharesService); + }); + + describe("decryptBookmark", () => { + const encryptedBookmark = { + tokenId: "tokenId", + creationTime: new Date(), + url: { + encryptedUrlPassword: "encryptedUrlPassword", + base64SharePasswordSalt: "base64SharePasswordSalt", + }, + share: { + armoredKey: "armoredKey", + armoredPassphrase: "armoredPassphrase", + }, + node: { + type: NodeType.File, + mediaType: "mediaType", + encryptedName: "encryptedName", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + file: { + base64ContentKeyPacket: "base64ContentKeyPacket", + }, + }, + } + + it("should decrypt bookmark", async () => { + const result = await cryptoService.decryptBookmark(encryptedBookmark); + + expect(result).toMatchObject({ + url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"), + nodeName: resultOk("nodeName"), + }); + expect(driveCrypto.decryptShareUrlPassword).toHaveBeenCalledWith("encryptedUrlPassword", ["addressKey"]); + expect(driveCrypto.decryptKeyWithSrpPassword).toHaveBeenCalledWith("urlPassword", "base64SharePasswordSalt", "armoredKey", "armoredPassphrase"); + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith("encryptedName", "decryptedKey", []); + expect(telemetry.logEvent).not.toHaveBeenCalled(); + }); + + it("should handle undecryptable URL password", async () => { + const error = new Error("Failed to decrypt URL password"); + driveCrypto.decryptShareUrlPassword = jest.fn().mockRejectedValue(error); + + const result = await cryptoService.decryptBookmark(encryptedBookmark); + + expect(result).toMatchObject({ + url: resultError(new Error("Failed to decrypt bookmark password: Failed to decrypt URL password")), + nodeName: resultError(new Error("Failed to decrypt bookmark password: Failed to decrypt URL password")), + }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'shareUrlPassword', + error, + }); + }); + + it("should handle undecryptable share key", async () => { + const error = new Error("Failed to decrypt share key"); + driveCrypto.decryptKeyWithSrpPassword = jest.fn().mockRejectedValue(error); + + const result = await cryptoService.decryptBookmark(encryptedBookmark); + + expect(result).toMatchObject({ + url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"), + nodeName: resultError(new Error("Failed to decrypt bookmark key: Failed to decrypt share key")), + }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'shareKey', + error, + }); + }); + + it("should handle undecryptable node name", async () => { + const error = new Error("Failed to decrypt node name"); + driveCrypto.decryptNodeName = jest.fn().mockRejectedValue(error); + + const result = await cryptoService.decryptBookmark(encryptedBookmark); + + expect(result).toMatchObject({ + url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"), + nodeName: resultError(new Error("Failed to decrypt bookmark name: Failed to decrypt node name")), + }); + expect(telemetry.logEvent).toHaveBeenCalledWith({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'nodeName', + error, + }); + }); + + it("should handle invalid node name", async () => { + driveCrypto.decryptNodeName = jest.fn().mockResolvedValue({ + name: "invalid/name", + }); + + const result = await cryptoService.decryptBookmark(encryptedBookmark); + + expect(result).toMatchObject({ + url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"), + nodeName: resultError({ + name: "invalid/name", + error: "Name must not contain the character '/'", + }), + }); + }); + }); +}); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 217866cf..807e378a 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -2,10 +2,11 @@ import bcrypt from 'bcryptjs'; import { c } from 'ttag'; import { DriveCrypto, PrivateKey, SessionKey, SRPVerifier, uint8ArrayToBase64String, VERIFICATION_STATUS } from '../../crypto'; -import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, resultError, resultOk } from "../../interface"; +import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, resultError, resultOk, InvalidNameError, ProtonDriveTelemetry, MetricVolumeType } from "../../interface"; +import { validateNodeName } from '../nodes/validations'; import { getErrorMessage, getVerificationMessage } from "../errors"; import { EncryptedShare } from "../shares"; -import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink, PublicLinkWithCreatorEmail } from "./interface"; +import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink, PublicLinkWithCreatorEmail, EncryptedBookmark, SharesService } from "./interface"; // Version 2 of bcrypt with 2**10 rounds. // https://en.wikipedia.org/wiki/Bcrypt#Description @@ -31,11 +32,15 @@ enum PublicLinkFlags { */ export class SharingCryptoService { constructor( + private telemetry: ProtonDriveTelemetry, private driveCrypto: DriveCrypto, private account: ProtonDriveAccount, + private sharesService: SharesService, ) { + this.telemetry = telemetry; this.driveCrypto = driveCrypto; this.account = account; + this.sharesService = sharesService; } /** @@ -346,7 +351,7 @@ export class SharingCryptoService { } private async decryptShareUrlPassword( - encryptedPublicLink: EncryptedPublicLink, + encryptedPublicLink: Pick, addressKeys: PrivateKey[], ): Promise<{ password: string, @@ -375,4 +380,133 @@ export class SharingCryptoService { throw new Error(`Unsupported public link with flags: ${encryptedPublicLink.flags}`); } } + + async decryptBookmark(encryptedBookmark: EncryptedBookmark): Promise<{ + url: Result, + nodeName: Result, + }> { + // TODO: Signatures are not checked and not specified in the interface. + // In the future, we will need to add authorship verification. + + let urlPassword: string; + try { + urlPassword = await this.decryptBookmarkUrlPassword(encryptedBookmark); + } catch (originalError: unknown) { + const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`); + return { + url: resultError(error), + nodeName: resultError(error), + }; + } + + // TODO: API should provide the full URL. + const url = resultOk(`https://drive.proton.me/urls/${encryptedBookmark.tokenId}#${urlPassword}`); + + let shareKey: PrivateKey; + try { + shareKey = await this.decryptBookmarkKey(encryptedBookmark, urlPassword); + } catch (originalError: unknown) { + const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`); + return { + url, + nodeName: resultError(error), + }; + } + + const nodeName = await this.decryptBookmarkName(encryptedBookmark, shareKey); + + return { + url, + nodeName, + }; + } + + private async decryptBookmarkUrlPassword(encryptedBookmark: EncryptedBookmark): Promise { + if (!encryptedBookmark.url.encryptedUrlPassword) { + throw new Error(c('Error').t`Bookmark password is not available`); + } + + const { addressId } = await this.sharesService.getMyFilesShareMemberEmailKey(); + const address = await this.account.getOwnAddress(addressId); + const addressKeys = address.keys.map(({ key }) => key); + + try { + // Decrypt the password for the share URL. + const urlPassword = await this.driveCrypto.decryptShareUrlPassword( + encryptedBookmark.url.encryptedUrlPassword, + addressKeys, + ); + + return urlPassword; + } catch (error: unknown) { + this.telemetry.logEvent({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'shareUrlPassword', + error, + }); + + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt bookmark password: ${message}`; + throw new Error(errorMessage); + } + } + + private async decryptBookmarkKey(encryptedBookmark: EncryptedBookmark, urlPassword: string): Promise { + try { + // Use the password to decrypt the share key. + const { key: shareKey } = await this.driveCrypto.decryptKeyWithSrpPassword( + urlPassword, + encryptedBookmark.url.base64SharePasswordSalt, + encryptedBookmark.share.armoredKey, + encryptedBookmark.share.armoredPassphrase, + ); + + return shareKey; + } catch (error: unknown) { + this.telemetry.logEvent({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'shareKey', + error, + }); + + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt bookmark key: ${message}`; + throw new Error(errorMessage); + } + } + + private async decryptBookmarkName(encryptedBookmark: EncryptedBookmark, shareKey: PrivateKey): Promise> { + try { + // Use the share key to decrypt the node name of the bookmark. + const { name } = await this.driveCrypto.decryptNodeName( + encryptedBookmark.node.encryptedName, + shareKey, + [], + ); + + try { + validateNodeName(name); + } catch (error: unknown) { + return resultError({ + name, + error: error instanceof Error ? error.message : c('Error').t`Unknown error`, + }); + } + + return resultOk(name); + } catch (error: unknown) { + this.telemetry.logEvent({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'nodeName', + error, + }); + + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt bookmark name: ${message}`; + return resultError(new Error(errorMessage)); + } + } } diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index ecc4943b..9ab9b32f 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -30,7 +30,7 @@ export function initSharingModule( ) { const api = new SharingAPIService(telemetry.getLogger('sharing-api'), apiService); const cache = new SharingCache(driveEntitiesCache); - const cryptoService = new SharingCryptoService(crypto, account); + const cryptoService = new SharingCryptoService(telemetry, crypto, account, sharesService); const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); const sharingEvents = new SharingEvents(telemetry.getLogger('sharing-events'), driveEvents, cache, nodesService, sharingAccess); const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, account, sharesService, nodesService, nodesEvents); diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index c2f90ea3..6590b1ff 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -143,6 +143,10 @@ export interface PublicLinkWithCreatorEmail extends PublicLink { export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string }>, loadEncryptedShare(shareId: string): Promise, + getMyFilesShareMemberEmailKey(): Promise<{ + addressId: string, + addressKey: PrivateKey, + }>, getContextShareMemberEmailKey(shareId: string): Promise<{ email: string, addressId: string, diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index 8e436f36..5fee572c 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -1,3 +1,4 @@ +import { NodeType, resultError, resultOk } from "../../interface"; import { SharingAPIService } from "./apiService"; import { SharingCache } from "./cache"; import { SharingCryptoService } from "./cryptoService"; @@ -26,6 +27,16 @@ describe("SharingAccess", () => { apiService = { iterateSharedNodeUids: jest.fn().mockImplementation(() => nodeUidsIterator()), iterateSharedWithMeNodeUids: jest.fn().mockImplementation(() => nodeUidsIterator()), + iterateBookmarks: jest.fn().mockImplementation(async function* () { + yield { + tokenId: "tokenId", + creationTime: new Date('2025-01-01'), + node: { + type: NodeType.File, + mediaType: "mediaType", + }, + } + }), } // @ts-expect-error No need to implement all methods for mocking cache = { @@ -35,6 +46,7 @@ describe("SharingAccess", () => { // @ts-expect-error No need to implement all methods for mocking cryptoService = { decryptInvitation: jest.fn(), + decryptBookmark: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking sharesService = { @@ -99,4 +111,66 @@ describe("SharingAccess", () => { expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(nodeUids); }); }); + + describe("iterateBookmarks", () => { + it("should return decrypted bookmark", async () => { + cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ + url: resultOk("url"), + nodeName: resultOk("nodeName"), + }); + + const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); + + expect(result).toEqual([resultOk({ + uid: "tokenId", + creationTime: new Date('2025-01-01'), + url: "url", + node: { + name: "nodeName", + type: NodeType.File, + mediaType: "mediaType", + }, + })]); + }); + + it("should return degraded bookmark if URL password cannot be decrypted", async () => { + cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ + url: resultError("url cannot be decrypted"), + nodeName: resultError("url cannot be decrypted"), + }); + + const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); + + expect(result).toEqual([resultError({ + uid: "tokenId", + creationTime: new Date('2025-01-01'), + url: resultError("url cannot be decrypted"), + node: { + name: resultError("url cannot be decrypted"), + type: NodeType.File, + mediaType: "mediaType", + }, + })]); + }); + + it("should return degraded bookmark if node name cannot be decrypted", async () => { + cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ + url: resultOk("url"), + nodeName: resultError("node name cannot be decrypted"), + }); + + const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); + + expect(result).toEqual([resultError({ + uid: "tokenId", + creationTime: new Date('2025-01-01'), + url: resultOk("url"), + node: { + name: resultError("node name cannot be decrypted"), + type: NodeType.File, + mediaType: "mediaType", + }, + })]); + }); + }); }); diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index c3a6cbee..5af129dd 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { ProtonInvitationWithNode } from "../../interface"; +import { MaybeBookmark, ProtonInvitationWithNode, resultError, resultOk } from "../../interface"; import { ValidationError } from "../../errors"; import { DecryptedNode } from "../nodes"; import { BatchLoading } from "../batchLoading"; @@ -120,14 +120,38 @@ export class SharingAccess { await this.apiService.rejectInvitation(invitationUid); } - // FIXME: return decrypted bookmarks - async* iterateSharedBookmarks(signal?: AbortSignal): AsyncGenerator { + async* iterateBookmarks(signal?: AbortSignal): AsyncGenerator { for await (const bookmark of this.apiService.iterateBookmarks(signal)) { - yield bookmark.tokenId; + const { url, nodeName } = await this.cryptoService.decryptBookmark(bookmark); + + if (!url.ok || !nodeName.ok) { + yield resultError({ + uid: bookmark.tokenId, + creationTime: bookmark.creationTime, + url: url, + node: { + name: nodeName, + type: bookmark.node.type, + mediaType: bookmark.node.mediaType, + } + }); + } else { + yield resultOk({ + uid: bookmark.tokenId, + creationTime: bookmark.creationTime, + url: url.value, + node: { + name: nodeName.value, + type: bookmark.node.type, + mediaType: bookmark.node.mediaType, + } + }); + } } } - async deleteBookmark(tokenId: string): Promise { + async deleteBookmark(bookmarkUid: string): Promise { + const tokenId = bookmarkUid; await this.apiService.deleteBookmark(tokenId); } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 5c5bb341..7815b7b2 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -11,6 +11,8 @@ import { ProtonInvitationOrUid, NonProtonInvitationOrUid, ProtonInvitationWithNode, + MaybeBookmark, + BookmarkOrUid, ShareResult, Device, DeviceType, @@ -645,6 +647,29 @@ export class ProtonDriveClient { await this.sharing.access.rejectInvitation(invitationId); } + /** + * Iterates the shared bookmarks. + * + * The output is not sorted and the order of the bookmarks is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the shared bookmarks. + */ + async* iterateBookmarks(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating shared bookmarks'); + yield* this.sharing.access.iterateBookmarks(signal); + } + + /** + * Remove the shared bookmark. + * + * @param bookmarkOrUid - Bookmark entity or its UID string. + */ + async removeBookmark(bookmarkOrUid: BookmarkOrUid): Promise { + this.logger.info(`Removing bookmark ${getUid(bookmarkOrUid)}`); + await this.sharing.access.deleteBookmark(getUid(bookmarkOrUid)); + } + /** * Get sharing info of the node. * From d34f02d4d19f0ecea4a9cf637096dbf768b3ed18 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 06:00:19 +0000 Subject: [PATCH 150/791] Remove sensitive info from logs --- js/sdk/src/internal/nodes/cryptoService.ts | 2 +- js/sdk/src/protonDriveClient.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 22a9bee6..5e60559f 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -492,7 +492,7 @@ export class NodesCryptoService { this.logger.error('Failed to check if claimed author matches default share', error); } - this.logger.error(`Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); + this.logger.warn(`Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); this.telemetry.logEvent({ eventName: 'verificationError', diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 7815b7b2..f6687ef8 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -412,7 +412,7 @@ export class ProtonDriveClient { * @throws {@link Error} If another node with the same name already exists. */ async renameNode(nodeUid: NodeOrUid, newName: string): Promise { - this.logger.info(`Renaming node ${getUid(nodeUid)} to ${newName}`); + this.logger.info(`Renaming node ${getUid(nodeUid)}`); return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); } @@ -513,7 +513,7 @@ export class ProtonDriveClient { * @throws {@link Error} If another node with the same name already exists. */ async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise { - this.logger.info(`Creating folder ${name} in ${getUid(parentNodeUid)}`); + this.logger.info(`Creating folder in ${getUid(parentNodeUid)}`); return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime)); } From 6b158b688b55e1d6c657c1ca56ad12287c2eba97 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 06:01:12 +0000 Subject: [PATCH 151/791] release js/v0.0.11 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 31ce106e..fadb4fb9 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.0.10", + "version": "0.0.11", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 6344f0774389241df18232cf6f312330848d70c4 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 08:32:43 +0200 Subject: [PATCH 152/791] js/v0.0.12 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index fadb4fb9..97bebe76 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.0.11", + "version": "0.0.12", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 8e283e711949effb517d6631980a35c4b66ba6e3 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 07:05:04 +0000 Subject: [PATCH 153/791] Fix publishing of npm packages --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 97bebe76..f7e8d136 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -11,7 +11,7 @@ ], "scripts": { "build": "tsc", - "build:ci": "rm -rf dist && tsc", + "build:ci": "rm -rf dist tsconfig.tsbuildinfo && tsc", "check-types": "tsc --noEmit", "generate-doc:interface": "typedoc src/index.ts --out ${OUTPUT_PATH}", "generate-doc:internal": "typedoc src/**/*.ts --out ${OUTPUT_PATH}", From bc3286fe3404f1e70f7a36fcab6c8439c72c3bcf Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Jun 2025 07:25:48 +0200 Subject: [PATCH 154/791] add existingNodeUid on NodeAlreadyExistsValidationError --- js/sdk/src/errors.ts | 12 ++++++++++-- js/sdk/src/internal/apiService/errors.ts | 5 +++-- js/sdk/src/internal/upload/manager.test.ts | 7 ++++--- js/sdk/src/internal/upload/manager.ts | 13 +++++++++---- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index 3cbe9227..42811eb0 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -50,9 +50,15 @@ export class ValidationError extends ProtonDriveError { */ public readonly code?: number; - constructor(message: string, code?: number) { + /** + * Additional details about the error provided by the server. + */ + public readonly details?: object; + + constructor(message: string, code?: number, details?: object) { super(message); this.code = code; + this.details = details; } } @@ -68,10 +74,12 @@ export class NodeAlreadyExistsValidationError extends ValidationError { name = 'NodeAlreadyExistsValidationError'; public readonly availableName: string; + public readonly existingNodeUid?: string; - constructor(message: string, code: number, availableName: string) { + constructor(message: string, code: number, availableName: string, existingNodeUid?: string) { super(message, code); this.availableName = availableName; + this.existingNodeUid = existingNodeUid; } } diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 542efeb5..f5f1b6e5 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -14,6 +14,7 @@ export function apiErrorFactory({ response, result }: { response: Response, resu const typedResult = result as { Code?: number; Error?: string; + Details?: object; exception?: string; message?: string; file?: string; @@ -21,7 +22,7 @@ export function apiErrorFactory({ response, result }: { response: Response, resu trace?: object; }; - const [code, message] = [typedResult.Code || 0, typedResult.Error || c('Error').t`Unknown error`]; + const [code, message, details] = [typedResult.Code || 0, typedResult.Error || c('Error').t`Unknown error`, typedResult.Details]; const debug = typedResult.exception ? { exception: typedResult.exception, @@ -55,7 +56,7 @@ export function apiErrorFactory({ response, result }: { response: Response, resu case ErrorCode.INSUFFICIENT_SHARE_QUOTA: case ErrorCode.INSUFFICIENT_SHARE_JOINED_QUOTA: case ErrorCode.INSUFFICIENT_BOOKMARKS_QUOTA: - return new ValidationError(message, code); + return new ValidationError(message, code, details); default: return new APICodeError(message, code, debug); } diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 820d07e5..d2aad3d0 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -235,7 +235,7 @@ describe("UploadManager", () => { apiService.createDraft = jest.fn().mockImplementation(() => { if (count === 0) { count++; - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS); + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { ConflictLinkID: "existingLinkId" }); } return { nodeUid: "newNode:nodeUid", @@ -243,7 +243,7 @@ describe("UploadManager", () => { }; }); - const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); + const result = manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); await expect(result).rejects.toThrow("Draft already exists"); expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); @@ -252,6 +252,7 @@ describe("UploadManager", () => { await result; } catch (error: any) { expect(error.availableName).toBe("name1"); + expect(error.existingNodeUid).toBe("volumeId~existingLinkId"); } }); @@ -300,7 +301,7 @@ describe("UploadManager", () => { nodeUid: "newNode:nodeUid", nodeRevisionUid: "newNode:nodeRevisionUid", nodeKeys: { - key: {_idx: 32321}, + key: { _idx: 32321 }, contentKeyPacketSessionKey: "newNode:contentKeyPacketSessionKey", signatureAddress: { email: "signatureEmail", diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index ebe33513..e870893f 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -7,6 +7,7 @@ import { DecryptedNode, generateFileExtendedAttributes } from "../nodes"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; import { NodeRevisionDraft, NodesService, NodesEvents, NodeCrypto } from "./interface"; +import { makeNodeUid, splitNodeUid } from "../uids"; /** * UploadManager is responsible for creating and deleting draft nodes @@ -120,12 +121,16 @@ export class UploadManager { } } + const typedDetails = error.details as { ConflictLinkID: string } | undefined; + const existingNodeUid = typedDetails ? makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID) : undefined; + // If there is existing node, return special error // that includes the available name the client can use. throw new NodeAlreadyExistsValidationError( error.message, error.code, availableName.availableName, + existingNodeUid, ); } } @@ -261,7 +266,7 @@ export class UploadManager { // Internal metadata hash: nodeRevisionDraft.newNodeInfo.hash, encryptedName: nodeRevisionDraft.newNodeInfo.encryptedName, - + // Basic node metadata uid: nodeRevisionDraft.nodeUid, parentUid: nodeRevisionDraft.newNodeInfo.parentUid, @@ -269,11 +274,11 @@ export class UploadManager { mediaType: metadata.mediaType, creationTime: new Date(), totalStorageSize: encryptedSize, - + // Share node metadata isShared: false, directMemberRole: MemberRole.Inherited, - + // Decrypted metadata isStale: false, keyAuthor: resultOk(nodeRevisionDraft.nodeKeys.signatureAddress.email), @@ -297,7 +302,7 @@ export class UploadManager { */ function splitExtension(filename = ''): [string, string] { const endIdx = filename.lastIndexOf('.'); - if (endIdx === -1 || endIdx === filename.length-1) { + if (endIdx === -1 || endIdx === filename.length - 1) { return [filename, '']; } return [filename.slice(0, endIdx), filename.slice(endIdx + 1)]; From 09aa497ac0afbb287a0db59bd28dd3b919c82178 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Jul 2025 07:37:10 +0000 Subject: [PATCH 155/791] Set admin role for all nodes in own volume --- js/sdk/src/internal/nodes/apiService.test.ts | 15 ++++++++------- js/sdk/src/internal/nodes/apiService.ts | 16 +++++++++------- js/sdk/src/internal/nodes/index.test.ts | 4 +++- js/sdk/src/internal/nodes/nodesAccess.test.ts | 13 +++++++------ js/sdk/src/internal/nodes/nodesAccess.ts | 7 +++++-- 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 379ee603..2d160d38 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -119,7 +119,7 @@ function generateNode() { shareId: undefined, isShared: false, - directMemberRole: MemberRole.Inherited, + directMemberRole: MemberRole.Admin, encryptedCrypto: { armoredKey: "nodeKey", @@ -149,13 +149,13 @@ describe("nodeAPIService", () => { }); describe('iterateNodes', () => { - async function testIterateNodes(mockedLink: any, expectedNode: any) { + async function testIterateNodes(mockedLink: any, expectedNode: any, ownVolumeId = 'volumeId') { // @ts-expect-error Mocking for testing purposes apiMock.post = jest.fn(async () => Promise.resolve({ Links: [mockedLink], })); - const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'])); + const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], ownVolumeId)); expect(nodes).toStrictEqual([expectedNode]); } @@ -213,6 +213,7 @@ describe("nodeAPIService", () => { shareId: 'shareId', directMemberRole: MemberRole.Viewer, }), + 'myVolumeId', ); }); @@ -240,7 +241,7 @@ describe("nodeAPIService", () => { ], })); - const generator = api.iterateNodes(['volumeId~nodeId']); + const generator = api.iterateNodes(['volumeId~nodeId'], 'volumeId'); const node1 = await generator.next(); expect(node1.value).toStrictEqual(generateFolderNode()); @@ -272,10 +273,10 @@ describe("nodeAPIService", () => { ], })); - const nodes = await Array.fromAsync(api.iterateNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); + const nodes = await Array.fromAsync(api.iterateNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'], 'volumeId1')); expect(nodes).toStrictEqual([ - generateFolderNode({ uid: 'volumeId1~nodeId1', parentUid: 'volumeId1~parentNodeId1' }), - generateFolderNode({ uid: 'volumeId2~nodeId2', parentUid: 'volumeId2~parentNodeId2' }), + generateFolderNode({ uid: 'volumeId1~nodeId1', parentUid: 'volumeId1~parentNodeId1', directMemberRole: MemberRole.Admin }), + generateFolderNode({ uid: 'volumeId2~nodeId2', parentUid: 'volumeId2~parentNodeId2', directMemberRole: MemberRole.Inherited }), ]); }); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 4ecbca65..48767e6a 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -2,7 +2,7 @@ import { c } from "ttag"; import { ProtonDriveError, ValidationError } from "../../errors"; import { Logger, NodeResult } from "../../interface"; -import { RevisionState } from "../../interface/nodes"; +import { MemberRole, RevisionState } from "../../interface/nodes"; import { DriveAPIService, drivePaths, isCodeOk, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from "../uids"; import { EncryptedNode, EncryptedRevision, Thumbnail } from "./interface"; @@ -56,15 +56,15 @@ export class NodeAPIService { this.apiService = apiService; } - async getNode(nodeUid: string, signal?: AbortSignal): Promise { - const nodesGenerator = this.iterateNodes([nodeUid], signal); + async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise { + const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, signal); const result = await nodesGenerator.next(); await nodesGenerator.return("finish"); return result.value; } // Improvement requested: split into multiple calls for many nodes. - async* iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async* iterateNodes(nodeUids: string[], ownVolumeId: string, signal?: AbortSignal): AsyncGenerator { const allNodeIds = nodeUids.map(splitNodeUid); const nodeIdsByVolumeId = new Map(); @@ -81,13 +81,15 @@ export class NodeAPIService { const errors = []; for (const [volumeId, nodeIds] of nodeIdsByVolumeId.entries()) { + const isAdmin = volumeId === ownVolumeId; + const response = await this.apiService.post(`drive/v2/volumes/${volumeId}/links`, { LinkIDs: nodeIds, }, signal); for (const link of response.Links) { try { - yield linkToEncryptedNode(this.logger, volumeId, link); + yield linkToEncryptedNode(this.logger, volumeId, link, isAdmin); } catch (error: unknown) { this.logger.error(`Failed to transform node ${link.Link.LinkID}`, error); errors.push(error); @@ -363,7 +365,7 @@ function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: } } -function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLinksMetadataResponse['Links'][0]): EncryptedNode { +function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLinksMetadataResponse['Links'][0], isAdmin: boolean): EncryptedNode { const baseNodeMetadata = { // Internal metadata hash: link.Link.NameHash || undefined, @@ -379,7 +381,7 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin // Sharing node metadata shareId: link.Sharing?.ShareID || undefined, isShared: !!link.Sharing, - directMemberRole: permissionsToDirectMemberRole(logger, link.Membership?.Permissions), + directMemberRole: isAdmin ? MemberRole.Admin : permissionsToDirectMemberRole(logger, link.Membership?.Permissions), } const baseCryptoNodeMetadata = { signatureEmail: link.Link.SignatureEmail || undefined, diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index 4b8dad5c..480188ff 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -55,7 +55,9 @@ describe('nodesModules integration tests', () => { }), } // @ts-expect-error No need to implement all methods for mocking - sharesService = {} + sharesService = { + getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + } nodesModule = initNodesModule( getMockTelemetry(), diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 6731f957..7e4790df 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -46,6 +46,7 @@ describe('nodesAccess', () => { } // @ts-expect-error No need to implement all methods for mocking shareService = { + getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), getSharePrivateKey: jest.fn(), }; @@ -81,7 +82,7 @@ describe('nodesAccess', () => { const result = await access.getNode('nodeId'); expect(result).toEqual(decryptedNode); - expect(apiService.getNode).toHaveBeenCalledWith('nodeId'); + expect(apiService.getNode).toHaveBeenCalledWith('nodeId', 'volumeId'); expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('parentUid'); expect(cryptoService.decryptNode).toHaveBeenCalledWith(encryptedNode, 'parentKey'); expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); @@ -107,7 +108,7 @@ describe('nodesAccess', () => { const result = await access.getNode('nodeId'); expect(result).toEqual(decryptedNode); - expect(apiService.getNode).toHaveBeenCalledWith('nodeId'); + expect(apiService.getNode).toHaveBeenCalledWith('nodeId', 'volumeId'); expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('parentUid'); expect(cryptoService.decryptNode).toHaveBeenCalledWith(encryptedNode, 'parentKey'); expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); @@ -179,7 +180,7 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); expect(result).toMatchObject([node1, node4, node2, node3]); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], 'volumeId', undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(2); expect(cache.setNode).toHaveBeenCalledTimes(2); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(2); @@ -218,7 +219,7 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], 'volumeId', undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); @@ -320,7 +321,7 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateTrashedNodes()); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], volumeId, undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); @@ -370,7 +371,7 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); expect(result).toMatchObject([node1, node4, node2, node3]); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], 'volumeId', undefined); }); it('should remove from cache if missing on API and return back to caller', async () => { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index e7f7f82c..2a270e72 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -130,7 +130,8 @@ export class NodesAccess { } private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { - const encryptedNode = await this.apiService.getNode(nodeUid); + const { volumeId: ownVolumeId } = await this.shareService.getMyFilesIDs(); + const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); return this.decryptNode(encryptedNode); } @@ -147,7 +148,9 @@ export class NodesAccess { const returnedNodeUids: string[] = []; const errors = []; - for await (const encryptedNode of this.apiService.iterateNodes(nodeUids, signal)) { + const { volumeId: ownVolumeId } = await this.shareService.getMyFilesIDs(); + + for await (const encryptedNode of this.apiService.iterateNodes(nodeUids, ownVolumeId, signal)) { returnedNodeUids.push(encryptedNode.uid); try { const { node } = await this.decryptNode(encryptedNode); From 82048611df0ee3f4fdbc2f6e128180b4e326b3bd Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Jul 2025 07:37:37 +0000 Subject: [PATCH 156/791] Filter out photos and albums from shared with me listing --- js/sdk/src/internal/apiService/driveTypes.ts | 1806 +++++++++++++----- js/sdk/src/internal/nodes/apiService.ts | 1 + js/sdk/src/internal/sharing/apiService.ts | 26 +- 3 files changed, 1366 insertions(+), 467 deletions(-) diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index c728fffb..deec1aa5 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -80,18 +80,35 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/photos/migrate-legacy": { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/duplicates": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get status of migration from legacy photo share on a regular volume into a new Photo Volume */ - get: operations["get_drive-photos-migrate-legacy"]; + get?: never; put?: never; - /** Start migration from legacy photo share on a regular volume into a new Photo Volume */ - post: operations["post_drive-photos-migrate-legacy"]; + /** Find duplicates in album */ + post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/tags-migration": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get photo tag migration status */ + get: operations["get_drive-photos-volumes-{volumeID}-tags-migration"]; + put?: never; + /** Update tag migration status */ + post: operations["post_drive-photos-volumes-{volumeID}-tags-migration"]; delete?: never; options?: never; head?: never; @@ -115,6 +132,23 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/photos/volumes/{volumeID}/recover-multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Recover photos from your photo volume */ + put: operations["put_drive-photos-volumes-{volumeID}-recover-multiple"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple": { parameters: { query?: never; @@ -147,6 +181,43 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/volumes/{volumeID}/links/transfer-multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Transfer photos from and to albums + * @deprecated + */ + put: operations["put_drive-volumes-{volumeID}-links-transfer-multiple"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/links/transfer-multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Transfer photos from and to albums */ + put: operations["put_drive-photos-volumes-{volumeID}-links-transfer-multiple"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/v2/urls/{token}/bookmark": { parameters: { query?: never; @@ -370,26 +441,6 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/links/{linkID}/copy": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Copy a node to a volume - * @description Copy a single file to a volume, providing the new parent link ID. - */ - post: operations["post_drive-volumes-{volumeID}-links-{linkID}-copy"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/drive/shares/{shareID}/folders": { parameters: { query?: never; @@ -571,6 +622,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/volumes/{volumeID}/links/{linkID}/copy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Copy a node to a volume + * @description Copy a single file to a volume, providing the new parent link ID. + */ + post: operations["post_drive-volumes-{volumeID}-links-{linkID}-copy"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/v2/volumes/{volumeID}/delete_multiple": { parameters: { query?: never; @@ -711,8 +782,7 @@ export interface paths { * * Clients moving a file or folder MUST reuse the existing session keys * for the name and passphrase as these are also used by shares pointing - * to the link. The passphrase should NOT be changed, only the KeyPacket - * is used. + * to the link. The passphrase should NOT be changed, reusing same session key as previously. */ put: operations["put_drive-shares-{shareID}-links-{linkID}-move"]; post?: never; @@ -781,8 +851,7 @@ export interface paths { * @description Move a file or folder. Client must provide new values for fields encrypted with parent NodeKey. * Clients moving a file or folder MUST reuse the existing session keys * for the name and passphrase as these are also used by shares pointing - * to the link. The passphrase should NOT be changed, only the KeyPacket - * is used. + * to the link. The passphrase should NOT be changed,reusing same session key as previously */ put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-move"]; post?: never; @@ -1099,6 +1168,8 @@ export interface paths { * @deprecated * @description List all trashed items of a given share. * Only used by clients that don’t show photos and devices. Going forward, the volume-based route should be used instead. + * + * CANNOT be used on Photo-Volume -> use volume-trash */ get: operations["get_drive-shares-{shareID}-trash"]; put?: never; @@ -1108,6 +1179,8 @@ export interface paths { * @deprecated * @description Permanently delete all links from trash of a given share. * Only used by clients that don’t show photos and devices. Going forward, the volume-based route should be used instead. + * + * CANNOT be used on Photo-Volume -> use volume-trash */ delete: operations["delete_drive-shares-{shareID}-trash"]; options?: never; @@ -1126,7 +1199,11 @@ export interface paths { get: operations["get_drive-volumes-{volumeID}-trash"]; put?: never; post?: never; - /** Empty volume trash */ + /** + * Empty volume trash + * @description When there are fewer items in trash than a certain threshold, trash will be deleted synchronously returning a 200 HTTP code. + * Otherwise, it will happen async returning a 202 HTTP code. + */ delete: operations["delete_drive-volumes-{volumeID}-trash"]; options?: never; head?: never; @@ -1144,6 +1221,8 @@ export interface paths { /** * Restore items from trash * @description Restore list of links from trash to original location. + * + * /shares endpoint should NOT be used on Photo-Volume -> use volume-trash */ put: operations["put_drive-v2-volumes-{volumeID}-trash-restore_multiple"]; post?: never; @@ -1164,6 +1243,8 @@ export interface paths { /** * Restore items from trash * @description Restore list of links from trash to original location. + * + * /shares endpoint should NOT be used on Photo-Volume -> use volume-trash */ put: operations["put_drive-shares-{shareID}-trash-restore_multiple"]; post?: never; @@ -1387,7 +1468,10 @@ export interface paths { }; get?: never; put?: never; - /** Create photo share */ + /** + * DEPRECATED: Create photo share + * @deprecated + */ post: operations["post_drive-volumes-{volumeID}-photos-share"]; delete?: never; options?: never; @@ -1415,6 +1499,23 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/photos/volumes/{volumeID}/links/{linkID}/favorite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Favorite existing photo */ + post: operations["post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/volumes/{volumeID}/photos/duplicates": { parameters: { query?: never; @@ -1432,6 +1533,24 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/photos/migrate-legacy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get status of migration from legacy photo share on a regular volume into a new Photo Volume */ + get: operations["get_drive-photos-migrate-legacy"]; + put?: never; + /** Start migration from legacy photo share on a regular volume into a new Photo Volume */ + post: operations["post_drive-photos-migrate-legacy"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/volumes/{volumeID}/photos": { parameters: { query?: never; @@ -1452,6 +1571,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/photos/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/xattr": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update xAttr Photo-Link + * @description ONLY for use by iOS, due to a bug in the iOS client, xAttr were not populated for photos, the client can use this endpoint to fix this. + */ + put: operations["put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/urls/{token}/files/{linkID}/checkAvailableHashes": { parameters: { query?: never; @@ -2525,7 +2664,10 @@ export interface paths { cookie?: never; }; get?: never; - /** Delete locked volume */ + /** + * Delete the whole volume if is locked or the locked root shares in the volume. + * @description Web client calls this endpoint when the user decides to delete their locked data and not recover it. + */ put: operations["put_drive-volumes-{volumeID}-delete_locked"]; post?: never; delete?: never; @@ -2562,7 +2704,13 @@ export interface paths { cookie?: never; }; get?: never; - /** Restore locked volume */ + /** + * Restore locked data in volume. + * @description There are two modes: + * 1. When the user has a locked volume and a different active one, you need to restore the locked volume data into the active + * one of the same type (e.g., locked regular into active regular). This is processed async. + * 2. When the user has locked root shares in an active volume, the data is restored within the same volume. This is done synchronous. + */ put: operations["put_drive-volumes-{volumeID}-restore"]; post?: never; delete?: never; @@ -2642,9 +2790,12 @@ export interface components { */ Code: 1000; }; - GetMigrationStatusResponseDto: { - OldVolumeID: components["schemas"]["Id2"]; - NewVolumeID?: components["schemas"]["Id2"] | null; + FindDuplicatesInput: { + /** @description List of Name HMACs to check */ + NameHashes: string[]; + }; + FindDuplicatesOutputCollection: { + DuplicateHashes: components["schemas"]["FoundDuplicate"][]; /** * ProtonResponseCode * @example 1000 @@ -2652,13 +2803,15 @@ export interface components { */ Code: 1000; }; - AcceptedResponse: { + PhotoTagMigrationStatusResponseDto: { + Finished: boolean; + Anchor?: components["schemas"]["PhotoTagMigrationDataDto"] | null; /** * ProtonResponseCode - * @example 1002 + * @example 1000 * @enum {integer} */ - Code: 1002; + Code: 1000; }; ListAlbumsResponseDto: { Albums: components["schemas"]["AlbumResponseDto"][]; @@ -2685,6 +2838,8 @@ export interface components { Tag: components["schemas"]["TagType"] | null; /** @default false */ OnlyChildren: boolean; + /** @default false */ + IncludeTrashed: boolean; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Favorites
1Screenshots
2Videos
3LivePhotos
4MotionPhotos
5Selfies
6Portraits
7Bursts
8Panoramas
9Raw
@@ -2702,9 +2857,28 @@ export interface components { */ Code: 1000; }; + TransferPhotoLinksRequestDto: { + ParentLinkID: components["schemas"]["Id"]; + Links: components["schemas"]["TransferPhotoLinkInBatchRequestDto"][]; + /** + * Format: email + * @description Signature email address used for signing name + */ + NameSignatureEmail: string; + /** + * Format: email + * @description Signature email address used for the NodePassphraseSignature. + * @default null + */ + SignatureEmail: string | null; + }; RemovePhotosFromAlbumRequestDto: { LinkIDs: components["schemas"]["Id"][]; }; + UpdatePhotoTagMigrationStatusRequestDto: { + Finished: boolean; + Anchor: components["schemas"]["PhotoTagMigrationUpdateDto"]; + }; /** @description An encrypted ID */ Id: string; SharedWithMeResponseDto: { @@ -2792,6 +2966,7 @@ export interface components { * @default null */ ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + DocumentType?: components["schemas"]["DocumentType"]; Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; @@ -2859,48 +3034,6 @@ export interface components { */ Code: 1000; }; - CopyLinkRequestDto: { - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Node passphrase, passphrase should be unchanged, only key packet is used. */ - NodePassphrase: string; - /** @description Name hash */ - Hash: string; - /** @description Volume ID to copy to. */ - TargetVolumeID: string; - /** @description New parent link ID to copy to. */ - TargetParentLinkID: string; - /** - * Format: email - * @description Signature email address used for signing name. - */ - NameSignatureEmail: string; - /** - * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] - * @default null - */ - ContentHash: string | null; - /** - * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. - * @default null - */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; - /** - * Format: email - * @description Signature email address used for the NodePassphrase. - * @default null - */ - SignatureEmail: string | null; - }; - CopyLinkResponseDto: { - LinkID: components["schemas"]["Id2"]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; CreateFolderRequestDto: { /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; @@ -3000,18 +3133,70 @@ export interface components { */ Code: 1000; }; - FetchLinksMetadataRequestDto: { + CopyLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + /** @description Volume ID to copy to. */ + TargetVolumeID: string; + /** @description New parent link ID to copy to. */ + TargetParentLinkID: string; /** - * @deprecated - * @description Get thumbnail download URLs - * @default 0 - * @enum {integer} + * Format: email + * @description Signature email address used for signing name. */ - Thumbnails: 0 | 1; - LinkIDs: components["schemas"]["EncryptedId"][]; - }; - FetchLinksMetadataResponseDto: { - Links: components["schemas"]["ExtendedLinkTransformer"][]; + NameSignatureEmail: string; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Signature email address used for the NodePassphraseSignature. + * @default null + */ + SignatureEmail: string | null; + /** + * @description Optional, except when moving a Photo-Link. + * @default null + */ + Photos: components["schemas"]["PhotosDto"] | null; + /** + * @description Only for legacy files (signed by the user). Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @default null + */ + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + /** + * @description Only for legacy folders (signed by the user). Node hash key should be unchanged, just re-signed with the NodeKey. + * @default null + */ + NodeHashKey: string | null; + }; + CopyLinkResponseDto: { + LinkID: components["schemas"]["Id2"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + FetchLinksMetadataRequestDto: { + /** + * @deprecated + * @description Get thumbnail download URLs + * @default 0 + * @enum {integer} + */ + Thumbnails: 0 | 1; + LinkIDs: components["schemas"]["EncryptedId"][]; + }; + FetchLinksMetadataResponseDto: { + Links: components["schemas"]["ExtendedLinkTransformer"][]; /** * ProtonResponseCode * @example 1000 @@ -3029,7 +3214,7 @@ export interface components { Code: 1000; }; LoadLinkDetailsResponseDto: { - Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"])[]; + Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"] | components["schemas"]["AlbumDetailsDto"])[]; /** * ProtonResponseCode * @example 1000 @@ -3048,17 +3233,15 @@ export interface components { NameSignatureEmail: string | null; /** * Format: email - * @description Signature email address used for the NodePassphrase. + * @description Signature email address used for the NodePassphraseSignature. * @default null */ SignatureEmail: string | null; - /** @default null */ - NewShareID: components["schemas"]["Id"] | null; }; MoveLinkRequestDto: { /** @description Name, reusing same session key as previously. */ Name: string; - /** @description Node passphrase, passphrase should be unchanged, only key packet is used. */ + /** @description Node passphrase, reusing same session key as previously. */ NodePassphrase: string; /** @description Name hash */ Hash: string; @@ -3099,7 +3282,7 @@ export interface components { NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; /** * Format: email - * @description Signature email address used for the NodePassphrase. + * @description Signature email address used for the NodePassphraseSignature. * @default null */ SignatureEmail: string | null; @@ -3140,7 +3323,7 @@ export interface components { MoveLinkRequestDto2: { /** @description Name, reusing same session key as previously. */ Name: string; - /** @description Node passphrase, passphrase should be unchanged, only key packet is used. */ + /** @description Node passphrase, reusing same session key as previously. */ NodePassphrase: string; /** @description Name hash */ Hash: string; @@ -3164,7 +3347,7 @@ export interface components { NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; /** * Format: email - * @description Signature email address used for the NodePassphrase. + * @description Signature email address used for the NodePassphraseSignature. * @default null */ SignatureEmail: string | null; @@ -3270,6 +3453,15 @@ export interface components { */ NoBlockUrls: boolean; }; + ListRevisionsResponseDto: { + Revisions: components["schemas"]["RevisionResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; RestoreRevisionAcceptedResponse: { /** * ProtonResponseCode @@ -3444,8 +3636,13 @@ export interface components { AddTagsRequestDto: { Tags: components["schemas"]["TagType"][]; }; - CreatePhotoShareResponseDto: { - Share: components["schemas"]["ShareResponseDto"]; + FavoritePhotoRequestDto: { + PhotoData?: components["schemas"]["FavoritePhotoDataDto"] | null; + }; + FavoritePhotoResponseDto: { + LinkID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + RelatedPhotos: components["schemas"]["FavoriteRelatedPhotoResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -3453,12 +3650,9 @@ export interface components { */ Code: 1000; }; - FindDuplicatesInput: { - /** @description List of Name HMACs to check */ - NameHashes: string[]; - }; - FindDuplicatesOutputCollection: { - DuplicateHashes: components["schemas"]["FoundDuplicate"][]; + GetMigrationStatusResponseDto: { + OldVolumeID: components["schemas"]["Id2"]; + NewVolumeID?: components["schemas"]["Id2"] | null; /** * ProtonResponseCode * @example 1000 @@ -3466,6 +3660,14 @@ export interface components { */ Code: 1000; }; + AcceptedResponse: { + /** + * ProtonResponseCode + * @example 1002 + * @enum {integer} + */ + Code: 1002; + }; ListPhotosParameters: { /** @default true */ Desc: boolean; @@ -3493,9 +3695,19 @@ export interface components { */ Code: 1000; }; + MigrateFromLegacyRequest: Record; RemoveTagsRequestDto: { Tags: components["schemas"]["TagType"][]; }; + UpdateXAttrRequest: { + /** + * Format: email + * @description Signature email address used to sign XAttributes; must be the same as the current revision signatureEmail, cannot be updated + */ + SignatureEmail: string; + /** @description Extended attributes encrypted with link key */ + XAttr: string; + }; CommitAnonymousRevisionDto: { ManifestSignature: components["schemas"]["PGPSignature"]; /** @@ -3701,17 +3913,18 @@ export interface components { VolumeID: components["schemas"]["Id2"]; Type: components["schemas"]["ShareType"]; State: components["schemas"]["ShareState"]; + VolumeType: components["schemas"]["VolumeType"]; /** Format: email */ Creator: string; Locked?: boolean | null; - CreateTime?: number | null; - ModifyTime?: number | null; + CreateTime: number; + ModifyTime: number; LinkID: components["schemas"]["Id2"]; /** * @deprecated * @description Deprecated: Use `CreateTime` */ - CreationTime?: number | null; + CreationTime: number; /** @deprecated */ PermissionsMask: number; LinkType: components["schemas"]["NodeType"]; @@ -3771,7 +3984,7 @@ export interface components { Code: 1000; }; ListSharesResponseDto: { - Shares: components["schemas"]["ShareResponseDto2"][]; + Shares: components["schemas"]["ShareResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -3833,7 +4046,9 @@ export interface components { SRPSession: components["schemas"]["BinaryString2"]; Version: number; Flags: number; + /** @deprecated */ IsDoc: boolean; + VendorType: components["schemas"]["VendorType"]; /** * ProtonResponseCode * @example 1000 @@ -3855,6 +4070,15 @@ export interface components { */ Code: 1000; }; + GetRevisionResponseDto: { + Revision: components["schemas"]["DetailedRevisionResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; GetSharedFileInfoRequestDto: { /** @default 1 */ FromBlockIndex: number; @@ -4086,6 +4310,18 @@ export interface components { */ Code: 1000; }; + ListPendingInvitationQueryParameters: { + AnchorID?: components["schemas"]["Id"] | null; + /** @default 150 */ + PageSize: number; + /** @default null */ + ShareTargetTypes: components["schemas"]["TargetType"][] | null; + }; + /** + * @description
See values descriptions
See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
+ * @enum {integer} + */ + TargetType: 0 | 1 | 2 | 3 | 4 | 5; ListPendingInvitationResponseDto: { Invitations: components["schemas"]["PendingInvitationItemDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ @@ -4101,7 +4337,7 @@ export interface components { }; PendingInvitationResponseDto: { Invitation: components["schemas"]["InvitationResponseDto"]; - Share: components["schemas"]["ShareResponseDto3"]; + Share: components["schemas"]["ShareResponseDto2"]; Link: components["schemas"]["LinkResponseDto"]; /** * ProtonResponseCode @@ -4202,6 +4438,10 @@ export interface components { DocsCommentsNotificationsEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsIncludeDocumentName?: boolean | null; + /** @description Indicates user-preferred font in Proton Docs. */ + DocsFontPreference?: string | null; + /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ + PhotoTags?: components["schemas"]["TagType"][] | null; }; CreateVolumeRequestDto: { /** @description User's Address encrypted ID */ @@ -4245,25 +4485,47 @@ export interface components { */ Code: 1000; }; + /** @description Name, Hash, NodePassphrase and NodePassphraseSignature are required when restoring a regular volume but should not be passed when restoring a photo volume. Please pass them as part of MainShares array to support multiple locked main shares. */ RestoreVolumeDto: { - /** @description Folder name as armored PGP message */ - Name: string; /** Format: email */ SignatureAddress: string; - /** @description Hash of the name */ - Hash: string; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - TargetVolumeID: components["schemas"]["Id"]; + /** + * @deprecated + * @default null + */ + TargetVolumeID: components["schemas"]["Id"] | null; + /** + * @deprecated + * @description Folder name as armored PGP message + * @default null + */ + Name: string | null; + /** + * @deprecated + * @description Hash of the name + */ + Hash?: string | null; + /** + * @deprecated + * @default null + */ + NodePassphrase: components["schemas"]["PGPMessage"] | null; + /** + * @deprecated + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + /** @default [] */ + MainShares: components["schemas"]["RestoreMainShareDto"][]; /** @default [] */ Devices: components["schemas"]["RestoreDeviceDto"][]; /** @default [] */ PhotoShares: components["schemas"]["RestorePhotoShareDto"][]; /** - * @description Node Hash Key should be provided if it needs to be signed because it was unsigned or signed with the address key (legacy). It should be signed with the new parent's node key. If it was properly signed with the parent node key, it should not be updated. Armored PGP message. + * @deprecated * @default null */ - NodeHashKey: string | null; + NodeHashKey: components["schemas"]["PGPMessage"] | null; /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ AddressKeyID: string; }; @@ -4469,101 +4731,21 @@ export interface components { Tags?: number[]; } | null; } & components["schemas"]["LinkTransformer"]; - /** Revision */ - DetailedRevisionTransformer: { - /** @description Block list */ - Blocks: { - /** @description Block index */ - Index: number; - /** @description Encrypted block's sha256 hash, in base64 */ - Hash: string; - /** @description Token for download url */ - Token: string | null; - /** - * @deprecated - * @description Block download url - * @example https://block.example.com/abcd/ - */ - URL?: string | null; - /** - * @description Bare Block download url - * @example https://block.example.com/abcd/ - */ - BareURL: string | null; - /** - * @description Encrypted block signature - * @example -----BEGIN PGP MESSAGE-----... - */ - EncSignature: string | null; - /** - * Format: email - * @description Email used to sign block - */ - SignatureEmail?: string | null; - }[]; - Photo: components["schemas"]["PhotoTransformer2"] | null; - } & components["schemas"]["RevisionTransformer"]; + GetRevisionResponseDto2: { + Revision: components["schemas"]["DetailedRevisionResponseDto2"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; /** @description Conflict, a share already exists for the file or folder. */ ShareConflictErrorResponseDto: { Details: components["schemas"]["ShareConflictErrorDetailsDto"]; Error: string; Code: number; }; - /** Revision */ - RevisionTransformer: { - /** @description Encrypted revision ID */ - ID: string; - /** @description Client managed unique ID */ - ClientUID: string | null; - /** @description Creation time (UNIX timestamp) */ - CreateTime: number; - /** @description Size of revision (in bytes) */ - Size: number; - /** - * @description Manifest signature, signed with the user's address associated with the share, `SignatureEmail` - * @example -----BEGIN PGP SIGNATURE-----... - */ - ManifestSignature: string | null; - /** - * Format: email - * @description User's email associated with the share and used to sign the manifest and block contents. - */ - SignatureEmail: string; - /** - * Format: email - * @deprecated - * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature - */ - SignatureAddress: string; - /** - * @description State (0=Draft, 1=Active, 2=Obsolete) - * @enum {integer} - */ - State: 0 | 1 | 2; - /** - * @description Extended attributes. - * @example -----BEGIN PGP MESSAGE - */ - XAttr: string | null; - /** - * @deprecated - * @description Flag stating if revision has a thumbnail - * @enum {integer} - */ - Thumbnail: 0 | 1; - /** - * @deprecated - * @description Hash for thumbnail - */ - ThumbnailHash?: string | null; - /** - * @deprecated - * @description Size thumbnail in bytes; 0 if no thumbnail present - * @example 512 - */ - ThumbnailSize: number; - Thumbnails: components["schemas"]["ThumbnailTransformer"][]; - }; SmallFileUploadMetadataRequestDto: { Name: components["schemas"]["PGPMessage"]; NameHash: string; @@ -4701,27 +4883,41 @@ export interface components { }; PhotoVolumeResponseDto: { VolumeID: components["schemas"]["Id2"]; - CreateTime?: number | null; - ModifyTime?: number | null; + CreateTime: number; + ModifyTime: number; /** @description Used space in bytes */ UsedSpace: number; DownloadedBytes: number; UploadedBytes: number; State: components["schemas"]["VolumeState"]; Share: components["schemas"]["ShareReferenceResponseDto"]; - Type: components["schemas"]["VolumeType"]; + Type: components["schemas"]["VolumeType2"]; + }; + FoundDuplicate: { + /** @description NameHash of the found duplicate */ + Hash?: string | null; + /** @description ContentHash of the found duplicate */ + ContentHash?: string | null; /** - * @description Status of restore task if applicable: - * - 0 => done - * - 1 => in progress - * - -1 => failed - * @default null - * @enum {integer|null} + * @description Can be null if the Link was deleted + * @enum {unknown|null} */ - RestoreStatus: 0 | 1 | -1 | null; + LinkState?: 0 | 1 | 2 | null; + /** @description Client defined UID for the draft. Null if no ClientUID passed, or Revision was already committed. */ + ClientUID?: string | null; + /** @description LinkID, null if deleted */ + LinkID: string; + /** @description RevisionID, null if deleted */ + RevisionID: string; + }; + PhotoTagMigrationDataDto: { + LastProcessedLinkID: components["schemas"]["Id2"]; + LastProcessedCaptureTime: number; + LastMigrationTimestamp: number; + /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. + * if null, client side migration is expired (client has not checked in for > 1h), any eligible client can continue migration */ + LastClientUID?: string | null; }; - /** @description An encrypted ID */ - Id2: string; AlbumResponseDto: { Locked: boolean; LastActivityTime: number; @@ -4747,6 +4943,34 @@ export interface components { */ Tags: number[]; }; + TransferPhotoLinkInBatchRequestDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + /** @description Current name hash before move operation. Used to prevent race conditions. */ + OriginalHash: string; + /** + * @description Optional, when transferring an Album-Link, required when transferring photos. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + }; + PhotoTagMigrationUpdateDto: { + LastProcessedLinkID: components["schemas"]["Id"]; + LastProcessedCaptureTime: number; + CurrentTimestamp: number; + /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. */ + ClientUID: string; + }; AlbumLinkUpdateDto: { Name?: components["schemas"]["PGPMessage"] | null; Hash?: string | null; @@ -4834,6 +5058,11 @@ export interface components { BinaryString: string; /** @description An armored PGP Signature */ PGPSignature: string; + /** + * @description

Document=1, Sheet=2

See values descriptions
See values descriptions
ValueDescription
1Document
2Sheet
+ * @enum {integer} + */ + DocumentType: 1 | 2; /** @description An armored PGP Message */ PGPMessage: string; /** @description An armored PGP Private Key */ @@ -4843,6 +5072,8 @@ export interface components { LinkID: components["schemas"]["Id2"]; RevisionID: components["schemas"]["Id2"]; }; + /** @description An encrypted ID */ + Id2: string; EventResponseDto: { EventID: components["schemas"]["Id2"]; EventType: components["schemas"]["EventType"]; @@ -4877,7 +5108,10 @@ export interface components { FLAG_RESTORE_COMPLETE?: string; /** @description Restoration has failed for corresponding locked volume */ FLAG_RESTORE_FAILED?: string; - /** @description Revision has been restored for this LinkID */ + /** + * @deprecated + * @description Revision has been restored for this LinkID + */ FLAG_RESTORE_REVISION_COMPLETE?: string; /** @description Parent before the move */ FromParentLinkID?: string; @@ -4899,6 +5133,12 @@ export interface components { LinkID: components["schemas"]["Id2"]; ClientUID?: string | null; }; + PhotosDto: { + /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ + ContentHash: string; + /** @default [] */ + RelatedPhotos: components["schemas"]["RelatedPhotoDto"][]; + }; ListMissingHashKeyItemDto: { LinkID: components["schemas"]["Id2"]; VolumeID: components["schemas"]["Id2"]; @@ -4913,6 +5153,8 @@ export interface components { Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ Folder: null | null; + /** @default null */ + Album: null | null; }; FolderDetailsDto: { Link: components["schemas"]["LinkDto"]; @@ -4923,12 +5165,26 @@ export interface components { Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ File: null | null; + /** @default null */ + Album: null | null; + }; + AlbumDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Album: components["schemas"]["AlbumDto"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto"] | null; + /** @default null */ + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + File: null | null; + /** @default null */ + Folder: null | null; }; MoveLinkInBatchRequestDto: { LinkID: components["schemas"]["Id"]; /** @description Name, reusing same session key as previously. */ Name: string; - /** @description Node passphrase, passphrase should be unchanged, only key packet is used. */ + /** @description Node passphrase, reusing same session key as previously. */ NodePassphrase: string; /** @description Name hash */ Hash: string; @@ -4979,13 +5235,51 @@ export interface components { Index: number; Token: string; }; - /** @description Base64 encoded binary data */ - BinaryString2: string; - ShareTrashList: { - ShareID: components["schemas"]["Id2"]; - /** @description List of trashed link IDs for that share */ - LinkIDs: components["schemas"]["Id2"][]; - /** @description List of trashed link's parentLinkIDs */ + RevisionResponseDto: { + ID: components["schemas"]["Id2"]; + ManifestSignature?: components["schemas"]["PGPSignature2"] | null; + /** @description Size of revision (in bytes) */ + Size: number; + State: components["schemas"]["RevisionState"]; + XAttr?: components["schemas"]["PGPMessage2"] | null; + /** + * @deprecated + * @description Flag stating if revision has a thumbnail + * @enum {integer} + */ + Thumbnail: 0 | 1; + /** @deprecated */ + ThumbnailHash?: components["schemas"]["BinaryString2"] | null; + /** + * @deprecated + * @description Size thumbnail in bytes; 0 if no thumbnail present + */ + ThumbnailSize: number; + Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; + ClientUID?: string | null; + /** @default null */ + CreateTime: number | null; + /** + * Format: email + * @description User's email associated with the share and used to sign the manifest and block contents. + * @default null + */ + SignatureEmail: string | null; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature + * @default null + */ + SignatureAddress: string | null; + }; + /** @description Base64 encoded binary data */ + BinaryString2: string; + ShareTrashList: { + ShareID: components["schemas"]["Id2"]; + /** @description List of trashed link IDs for that share */ + LinkIDs: components["schemas"]["Id2"][]; + /** @description List of trashed link's parentLinkIDs */ ParentIDs: components["schemas"]["Id2"][]; }; RequestUploadBlockInput: { @@ -5029,33 +5323,37 @@ export interface components { /** @description Allow or not the user to create writable ShareURLs */ PublicCollaboration: boolean; }; - ShareResponseDto: { - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - }; - FoundDuplicate: { - /** @description NameHash of the found duplicate */ - Hash?: string | null; - /** @description ContentHash of the found duplicate */ - ContentHash?: string | null; + FavoritePhotoDataDto: { + /** @description Name Hash */ + Hash: string; + Name: string; /** - * @description Can be null if the Link was deleted - * @enum {unknown|null} + * Format: email + * @description Email address used for signing name */ - LinkState?: 0 | 1 | 2 | null; - /** @description Client defined UID for the draft. Null if no ClientUID passed, or Revision was already committed. */ - ClientUID?: string | null; - /** @description LinkID, null if deleted */ - LinkID: string; - /** @description RevisionID, null if deleted */ - RevisionID: string; + NameSignatureEmail: string; + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @description Photo content hash */ + ContentHash: string; + /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ + NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature + */ + SignatureEmail?: string | null; + /** @default [] */ + RelatedPhotos: components["schemas"]["AlbumPhotoLinkDataDto"][]; + }; + FavoriteRelatedPhotoResponseDto: { + LinkID: components["schemas"]["Id2"]; }; PhotoListingItemResponse: { LinkID: components["schemas"]["Id2"]; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; /** @description File name hash */ - Hash?: string | null; + Hash: string; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash?: string | null; /** @@ -5137,6 +5435,8 @@ export interface components { Membership: components["schemas"]["MembershipDto2"] | null; /** @default null */ File: null | null; + /** @default null */ + Album: null | null; }; /** * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
@@ -5144,10 +5444,15 @@ export interface components { */ ShareType: 1 | 2 | 3 | 4; /** - * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
4Migrating
33HiddenRestoreVolumeIncident2025
+ * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
4Migrating
5Migrated
6Locked
* @enum {integer} */ - ShareState: 1 | 2 | 3 | 4 | 33; + ShareState: 1 | 2 | 3 | 4 | 5 | 6; + /** + * @description

1=Regular, 2=Photo

See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
+ * @enum {integer} + */ + VolumeType: 1 | 2; /** * @description

1=folder, 2=file

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
* @enum {integer} @@ -5205,22 +5510,23 @@ export interface components { */ Unlockable: boolean | null; }; - ShareResponseDto2: { + ShareResponseDto: { ShareID: components["schemas"]["Id2"]; VolumeID: components["schemas"]["Id2"]; Type: components["schemas"]["ShareType"]; State: components["schemas"]["ShareState"]; + VolumeType: components["schemas"]["VolumeType"]; /** Format: email */ Creator: string; Locked?: boolean | null; - CreateTime?: number | null; - ModifyTime?: number | null; + CreateTime: number; + ModifyTime: number; LinkID: components["schemas"]["Id2"]; /** * @deprecated * @description Deprecated: Use `CreateTime` */ - CreationTime?: number | null; + CreationTime: number; /** @deprecated */ PermissionsMask: number; /** @deprecated */ @@ -5248,6 +5554,11 @@ export interface components { Error: string; Code: number; }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0ProtonDrive
1ProtonDoc
2ProtonSheet
+ * @enum {integer} + */ + VendorType: 0 | 1 | 2; TokenResponseDto: { /** * @description Url Token @@ -5291,6 +5602,46 @@ export interface components { */ NodePassphraseSignature: components["schemas"]["PGPSignature2"] | null; }; + DetailedRevisionResponseDto: { + Blocks: components["schemas"]["BlockResponseDto"][]; + Photo?: components["schemas"]["PhotoResponseDto"] | null; + ID: components["schemas"]["Id2"]; + ManifestSignature?: components["schemas"]["PGPSignature2"] | null; + /** @description Size of revision (in bytes) */ + Size: number; + State: components["schemas"]["RevisionState"]; + XAttr?: components["schemas"]["PGPMessage2"] | null; + /** + * @deprecated + * @description Flag stating if revision has a thumbnail + * @enum {integer} + */ + Thumbnail: 0 | 1; + /** @deprecated */ + ThumbnailHash?: components["schemas"]["BinaryString2"] | null; + /** + * @deprecated + * @description Size thumbnail in bytes; 0 if no thumbnail present + */ + ThumbnailSize: number; + Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; + ClientUID?: string | null; + /** @default null */ + CreateTime: number | null; + /** + * Format: email + * @description User's email associated with the share and used to sign the manifest and block contents. + * @default null + */ + SignatureEmail: string | null; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature + * @default null + */ + SignatureAddress: string | null; + }; GetSharedFileInfoPayloadDto: { SharePasswordSalt: components["schemas"]["BinaryString2"]; SharePassphrase: components["schemas"]["PGPMessage2"]; @@ -5542,6 +5893,7 @@ export interface components { VolumeID: components["schemas"]["Id2"]; ShareID: components["schemas"]["Id2"]; LinkID: components["schemas"]["Id2"]; + ShareTargetType: components["schemas"]["TargetType2"]; }; ExternalInvitationRequestDto: { InviterAddressID: components["schemas"]["Id"]; @@ -5634,8 +5986,9 @@ export interface components { VolumeID: components["schemas"]["Id2"]; ShareID: components["schemas"]["Id2"]; InvitationID: components["schemas"]["Id2"]; + ShareTargetType: components["schemas"]["TargetType2"]; }; - ShareResponseDto3: { + ShareResponseDto2: { ShareID: components["schemas"]["Id2"]; VolumeID: components["schemas"]["Id2"]; Passphrase: components["schemas"]["PGPMessage2"]; @@ -5711,7 +6064,9 @@ export interface components { DocsCommentsNotificationsEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsIncludeDocumentName?: boolean | null; - /** @description Default order and visibility of Photo Tags. */ + /** @description Indicates user-preferred font in Proton Docs. */ + DocsFontPreference?: string | null; + /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ PhotoTags?: number[] | null; }; Defaults: { @@ -5746,31 +6101,37 @@ export interface components { * @deprecated * @description Deprecated, use `CreateTime` instead */ - CreationTime?: number | null; + CreationTime: number; /** * @deprecated * @default null */ MaxSpace: number | null; VolumeID: components["schemas"]["Id2"]; - CreateTime?: number | null; - ModifyTime?: number | null; + CreateTime: number; + ModifyTime: number; /** @description Used space in bytes */ UsedSpace: number; DownloadedBytes: number; UploadedBytes: number; State: components["schemas"]["VolumeState"]; Share: components["schemas"]["ShareReferenceResponseDto"]; - Type: components["schemas"]["VolumeType"]; + Type: components["schemas"]["VolumeType2"]; + }; + RestoreMainShareDto: { + /** @description ShareID of the existing, locked main share */ + LockedShareID: string; + /** @description Folder name as armored PGP message */ + Name: string; + /** @description Hash of the name */ + Hash: string; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; /** - * @description Status of restore task if applicable: - * - 0 => done - * - 1 => in progress - * - -1 => failed + * @description Node Hash Key should be provided if it needs to be signed because it was unsigned or signed with the address key (legacy). It should be signed with the new parent's node key. If it was properly signed with the parent node key, it should not be updated. Armored PGP message. * @default null - * @enum {integer|null} */ - RestoreStatus: 0 | 1 | -1 | null; + NodeHashKey: string | null; }; RestoreDeviceDto: { /** @description ShareID of the existing share on the old volume */ @@ -5814,8 +6175,7 @@ export interface components { }; /** Thumbnail */ ThumbnailTransformer: { - /** @description Encrypted Thumbnail ID. Will be null for legacy Thumbnails. */ - ThumbnailID: string | null; + ThumbnailID: string; /** @enum {integer} */ Type: 1 | 2 | 3; /** @description Base64 encoded thumbnail-content-hash */ @@ -5888,10 +6248,11 @@ export interface components { */ MIMEType: string; /** - * @description Attributes - * @example 1 + * @deprecated + * @description Always returns 1 + * @enum {integer} */ - Attributes: number; + Attributes: 1; /** * @deprecated * @description Always returns 7, read+write+execute @@ -5930,23 +6291,45 @@ export interface components { /** @description Timestamp, time at which the file was trashed, null if file is not trashed. */ Trashed: number | null; }; - /** Photo */ - PhotoTransformer2: { - LinkID: string; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ - CaptureTime: number; - MainPhotoLinkID: string | null; - /** @description File name hash */ - Hash: string; + DetailedRevisionResponseDto2: { + Blocks: components["schemas"]["BlockResponseDto2"][]; + Photo?: components["schemas"]["PhotoResponseDto2"] | null; + ID: components["schemas"]["Id"]; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + /** @description Size of revision (in bytes) */ + Size: number; + State: components["schemas"]["RevisionState2"]; + XAttr?: components["schemas"]["PGPMessage"] | null; /** * @deprecated - * @description Deprecated: Clients persist exif information in xAttr instead + * @description Flag stating if revision has a thumbnail + * @enum {integer} */ - Exif?: string | null; - /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash: string | null; - /** @description LinkIDs of related Photos if there are any */ - RelatedPhotosLinkIDs: string[]; + Thumbnail: 0 | 1; + /** @deprecated */ + ThumbnailHash?: components["schemas"]["BinaryString"] | null; + /** + * @deprecated + * @description Size thumbnail in bytes; 0 if no thumbnail present + */ + ThumbnailSize: number; + Thumbnails: components["schemas"]["ThumbnailResponseDto2"][]; + ClientUID?: string | null; + /** @default null */ + CreateTime: number | null; + /** + * Format: email + * @description User's email associated with the share and used to sign the manifest and block contents. + * @default null + */ + SignatureEmail: string | null; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature + * @default null + */ + SignatureAddress: string | null; }; ShareConflictErrorDetailsDto: { ConflictLinkID: components["schemas"]["Id"]; @@ -5970,7 +6353,12 @@ export interface components { * @description
See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
* @enum {integer} */ - VolumeType: 1 | 2; + VolumeType2: 1 | 2; + /** + * @description

Can be null if the Link was deleted

See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
+ * @enum {integer} + */ + LinkState: 0 | 1 | 2; ListPhotosAlbumRelatedPhotoItemResponseDto: { LinkID: components["schemas"]["Id2"]; CaptureTime: number; @@ -6000,7 +6388,7 @@ export interface components { /** @description UNIX timestamp when the Device got last synced */ LastSyncTime?: number | null; CreateTime: number; - ModifyTime?: number | null; + ModifyTime: number; /** * @deprecated * @description Deprecated: use `CreateTime` @@ -6016,7 +6404,7 @@ export interface components { DeviceDto: { DeviceID: components["schemas"]["Id2"]; CreateTime: number; - ModifyTime?: number | null; + ModifyTime: number; Type: components["schemas"]["DeviceType2"]; }; /** @@ -6030,6 +6418,17 @@ export interface components { IsShared: boolean; IsTrashed: boolean; }; + RelatedPhotoDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ + ContentHash: string; + }; LinkDto: { LinkID: components["schemas"]["Id"]; Type: components["schemas"]["NodeType4"]; @@ -6076,6 +6475,21 @@ export interface components { NodeHashKey?: components["schemas"]["PGPMessage"] | null; XAttr?: components["schemas"]["PGPMessage"] | null; }; + AlbumDto: { + NodeHashKey?: components["schemas"]["PGPMessage"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Obsolete
+ * @enum {integer} + */ + RevisionState: 0 | 1 | 2; + ThumbnailResponseDto: { + ThumbnailID: components["schemas"]["Id2"]; + Type: components["schemas"]["ThumbnailType2"]; + Hash: components["schemas"]["BinaryString2"]; + Size: number; + }; Verifier: { Token: components["schemas"]["BinaryString"]; }; @@ -6089,17 +6503,12 @@ export interface components { * @enum {integer} */ ThumbnailType2: 1 | 2 | 3; - /** - * @description

Can be null if the Link was deleted

See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
- * @enum {integer} - */ - LinkState: 0 | 1 | 2; PhotoListingRelatedItemResponse: { LinkID: components["schemas"]["Id2"]; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; /** @description File name hash */ - Hash?: string | null; + Hash: string; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash?: string | null; }; @@ -6177,6 +6586,44 @@ export interface components { /** @description Token for the thumbnail URL */ Token?: string | null; }; + BlockResponseDto: { + Index: number; + Hash: components["schemas"]["BinaryString2"]; + Token?: string | null; + /** @deprecated */ + URL?: string | null; + BareURL?: string | null; + EncSignature?: components["schemas"]["PGPMessage2"] | null; + /** + * Format: email + * @description Email used to sign block + */ + SignatureEmail?: string | null; + }; + PhotoResponseDto: { + LinkID: components["schemas"]["Id2"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + MainPhotoLinkID?: components["schemas"]["Id2"] | null; + /** @description File name hash */ + Hash?: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + /** @description LinkIDs of related Photos if there are any */ + RelatedPhotosLinkIDs: components["schemas"]["Id2"][]; + /** + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead + * @default null + */ + Exif: string | null; + }; + /** + * @description

The target type of the Share that is corresponding to this invitation.
+ * This should not be used as source of information to know what NodeType or MIMEType the targeted Share is.

See values descriptions
See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
+ * @enum {integer} + */ + TargetType2: 0 | 1 | 2 | 3 | 4 | 5; /** * @description
See values descriptions
See values descriptions
ValueDescription
1Pending
2UserRegistered
4Deleted
* @enum {integer} @@ -6202,6 +6649,49 @@ export interface components { * @enum {integer} */ RevisionRetentionDays3: 0 | 7 | 30 | 180 | 365 | 3650; + BlockResponseDto2: { + Index: number; + Hash: components["schemas"]["BinaryString"]; + Token?: string | null; + /** @deprecated */ + URL?: string | null; + BareURL?: string | null; + EncSignature?: components["schemas"]["PGPMessage"] | null; + /** + * Format: email + * @description Email used to sign block + */ + SignatureEmail?: string | null; + }; + PhotoResponseDto2: { + LinkID: components["schemas"]["Id"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + MainPhotoLinkID?: components["schemas"]["Id"] | null; + /** @description File name hash */ + Hash?: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + /** @description LinkIDs of related Photos if there are any */ + RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + /** + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead + * @default null + */ + Exif: string | null; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Obsolete
+ * @enum {integer} + */ + RevisionState2: 0 | 1 | 2; + ThumbnailResponseDto2: { + ThumbnailID: components["schemas"]["Id"]; + Type: components["schemas"]["ThumbnailType"]; + Hash: components["schemas"]["BinaryString"]; + Size: number; + }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Disabled
1Enabled
* @enum {integer} @@ -6236,7 +6726,7 @@ export interface components { ThumbnailDto: { ThumbnailID: components["schemas"]["Id"]; Type: components["schemas"]["ThumbnailType"]; - Hash: string; + Hash: components["schemas"]["BinaryString"]; EncryptedSize: number; }; PhotoDto: { @@ -6587,71 +7077,239 @@ export interface operations { }; }; }; - "get_drive-photos-migrate-legacy": { + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates": { parameters: { query?: never; header?: never; - path?: never; + path: { + volumeID: string; + linkID: string; + }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["FindDuplicatesInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FindDuplicatesOutputCollection"]; + }; + }; + }; + }; + "get_drive-photos-volumes-{volumeID}-tags-migration": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PhotoTagMigrationStatusResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: volume does not exist, or is not photo volume + * - 2011: Insufficient permissions, not volume owner + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-photos-volumes-{volumeID}-tags-migration": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdatePhotoTagMigrationStatusRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: volume does not exist, or is not photo volume + * - 2011: Insufficient permissions, not volume owner + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-photos-volumes-{volumeID}-albums-{linkID}-children": { + parameters: { + query?: { + AnchorID?: components["schemas"]["ListPhotosAlbumQueryParameters"]["AnchorID"]; + Sort?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Sort"]; + Desc?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Desc"]; + Tag?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Tag"]; + OnlyChildren?: components["schemas"]["ListPhotosAlbumQueryParameters"]["OnlyChildren"]; + IncludeTrashed?: components["schemas"]["ListPhotosAlbumQueryParameters"]["IncludeTrashed"]; + }; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListPhotosAlbumResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: Volume not found + * - 2501: File or folder not found + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-photos-volumes-{volumeID}-recover-multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; + }; + }; responses: { - /** @description Success */ + /** @description Ok */ 200: { headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GetMigrationStatusResponseDto"]; - }; - }; - /** @description Accepted */ - 202: { - headers: { - "x-pm-code": 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AcceptedResponse"]; + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: { + /** @description Encrypted link ID */ + LinkID?: string; + Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + }[]; + }; }; }; - /** @description Failed dependency */ - 424: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; content: { "application/json": { - /** - * @description Potential codes: - * - 2032 - * - * @enum {integer} - */ - Code: 2032; + /** @description Potential codes and their meaning: + * - 2501: The volume does not exist. + * - 2511: cannot recover photos from a share + * - 2011: cannot move favorite photos from a share + * - 2000: All main photos have to be sent with related photos. + * */ + Code: number; }; }; }; }; }; - "post_drive-photos-migrate-legacy": { + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple": { parameters: { query?: never; header?: never; - path?: never; + path: { + volumeID: string; + linkID: string; + }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["RemovePhotosFromAlbumRequestDto"]; + }; + }; responses: { - /** @description Accepted */ - 202: { + /** @description Ok */ + 200: { headers: { - "x-pm-code": 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AcceptedResponse"]; + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["RemovePhotoFromAlbumWithLinkIDResponseDto"][]; + }; }; }; /** @description Unprocessable Entity */ @@ -6662,46 +7320,21 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 2500: Migration in progress - * - 2501: Share not found + * - 2500: A volume is already active * */ Code: number; }; }; }; - /** @description Failed dependency */ - 424: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** - * @description Potential codes: - * - 2032 - * - * @enum {integer} - */ - Code: 2032; - }; - }; - }; }; }; - "get_drive-photos-volumes-{volumeID}-albums-{linkID}-children": { + "get_drive-photos-albums-shared-with-me": { parameters: { query?: { - AnchorID?: components["schemas"]["ListPhotosAlbumQueryParameters"]["AnchorID"]; - Sort?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Sort"]; - Desc?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Desc"]; - Tag?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Tag"]; - OnlyChildren?: components["schemas"]["ListPhotosAlbumQueryParameters"]["OnlyChildren"]; + AnchorID?: components["schemas"]["Id"] | null; }; header?: never; - path: { - volumeID: string; - linkID: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -6713,7 +7346,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListPhotosAlbumResponseDto"]; + "application/json": components["schemas"]["SharedWithMeResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -6724,8 +7357,6 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 2501: Volume not found - * - 2501: File or folder not found * - 2011: Insufficient permissions * */ Code: number; @@ -6734,19 +7365,18 @@ export interface operations { }; }; }; - "post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple": { + "put_drive-volumes-{volumeID}-links-transfer-multiple": { parameters: { query?: never; header?: never; path: { volumeID: string; - linkID: string; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["RemovePhotosFromAlbumRequestDto"]; + "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; }; }; responses: { @@ -6759,7 +7389,11 @@ export interface operations { "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["RemovePhotoFromAlbumWithLinkIDResponseDto"][]; + Responses?: { + /** @description Encrypted link ID */ + LinkID?: string; + Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + }[]; }; }; }; @@ -6771,7 +7405,9 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 2500: A volume is already active + * - 2501: The volume does not exist. + * - 2511: cannot move favorite photos from a share + * - 2000: All main photos have to be sent with related photos. * */ Code: number; }; @@ -6779,25 +7415,36 @@ export interface operations { }; }; }; - "get_drive-photos-albums-shared-with-me": { + "put_drive-photos-volumes-{volumeID}-links-transfer-multiple": { parameters: { - query?: { - AnchorID?: components["schemas"]["Id"] | null; - }; + query?: never; header?: never; - path?: never; + path: { + volumeID: string; + }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; + }; + }; responses: { - /** @description Success */ + /** @description Ok */ 200: { headers: { - "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SharedWithMeResponseDto"]; + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: { + /** @description Encrypted link ID */ + LinkID?: string; + Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + }[]; + }; }; }; /** @description Unprocessable Entity */ @@ -6808,7 +7455,9 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 2011: Insufficient permissions + * - 2501: The volume does not exist. + * - 2511: cannot move favorite photos from a share + * - 2000: All main photos have to be sent with related photos. * */ Code: number; }; @@ -7264,59 +7913,6 @@ export interface operations { }; }; }; - "post_drive-volumes-{volumeID}-links-{linkID}-copy": { - parameters: { - query?: never; - header?: never; - path: { - volumeID: string; - linkID: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["CopyLinkRequestDto"]; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CopyLinkResponseDto"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** - * @description Potential codes and their meaning: - * - 2011: Copying Proton Docs to another account is not possible yet. - * - 2501: Volume not found - * - 2501: File or folder not found - * - 2501: parent folder was not found - * - 200300: max folder size reached - * - 2011: the user does not have permissions to create a file in this share - * - 2000: the user cannot move or rename root folder - * - 200002: Storage quota exceeded - * - 200301: target parent exceeded max folder depth - * - * @enum {integer} - */ - Code: 200300 | 2501 | 2011 | 2000 | 200002 | 200301; - }; - }; - }; - }; - }; "post_drive-shares-{shareID}-folders": { parameters: { query?: never; @@ -7643,6 +8239,59 @@ export interface operations { }; }; }; + "post_drive-volumes-{volumeID}-links-{linkID}-copy": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CopyLinkRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CopyLinkResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 2011: Copying Proton Docs to another account is not possible yet. + * - 2501: Volume not found + * - 2501: File or folder not found + * - 2501: parent folder was not found + * - 200300: max folder size reached + * - 2011: the user does not have permissions to create a file in this share + * - 2000: the user cannot move or rename root folder + * - 200002: Storage quota exceeded + * - 200301: target parent exceeded max folder depth + * + * @enum {integer} + */ + Code: 200300 | 2501 | 2011 | 2000 | 200002 | 200301; + }; + }; + }; + }; + }; "post_drive-v2-volumes-{volumeID}-delete_multiple": { parameters: { query?: never; @@ -7853,7 +8502,11 @@ export interface operations { "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components["responses"]["ProtonSuccessResponse"][]; + Responses?: { + /** @description Encrypted link ID */ + LinkID?: string; + Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + }[]; }; }; }; @@ -7866,6 +8519,7 @@ export interface operations { "application/json": { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. + * - 2511: cannot move favorite photos from a share * - 2000: All main photos have to be sent with related photos. * */ Code: number; @@ -7902,6 +8556,7 @@ export interface operations { * - 200300: max folder size reached * - 200301: max folder depth reached * - 2500: file or folder with same name already exists + * - 2511: cannot move favorite photos from a share * - 2501: parent folder was not found * */ Code?: number; @@ -7996,6 +8651,7 @@ export interface operations { * - 200300: max folder size reached * - 200301: max folder depth reached * - 2500: file or folder with same name already exists + * - 2511: cannot move favorite photos from a share * - 2501: parent folder was not found * */ Code?: number; @@ -8032,10 +8688,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Revision: components["schemas"]["DetailedRevisionTransformer"]; - }; + "application/json": components["schemas"]["GetRevisionResponseDto2"]; }; }; }; @@ -8151,10 +8804,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Revision: components["schemas"]["DetailedRevisionTransformer"]; - }; + "application/json": components["schemas"]["GetRevisionResponseDto2"]; }; }; }; @@ -8366,16 +9016,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Revisions */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Revisions: components["schemas"]["RevisionTransformer"][]; - }; + "application/json": components["schemas"]["ListRevisionsResponseDto"]; }; }; }; @@ -8449,16 +9097,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Revisions */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Revisions: components["schemas"]["RevisionTransformer"][]; - }; + "application/json": components["schemas"]["ListRevisionsResponseDto"]; }; }; }; @@ -8778,6 +9424,16 @@ export interface operations { "application/json": components["schemas"]["SuccessfulResponse"]; }; }; + /** @description Empty trash queued for async processing */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmptyTrashAcceptedResponse"]; + }; + }; }; }; "get_drive-volumes-{volumeID}-trash": { @@ -8817,7 +9473,17 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Empty volume trash queued for async processing */ + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Empty trash queued for async processing */ 202: { headers: { "x-pm-code": 1002; @@ -9427,11 +10093,28 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["CreatePhotoShareRequestDto"]; + requestBody?: never; + responses: { + /** @description Invalid request; update app */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "delete_drive-volumes-{volumeID}-photos-share-{shareID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + shareID: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -9440,22 +10123,27 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreatePhotoShareResponseDto"]; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - "delete_drive-volumes-{volumeID}-photos-share-{shareID}": { + "post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite": { parameters: { query?: never; header?: never; path: { volumeID: string; - shareID: string; + linkID: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["FavoritePhotoRequestDto"]; + }; + }; responses: { /** @description Success */ 200: { @@ -9464,10 +10152,23 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + "application/json": components["schemas"]["FavoritePhotoResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The link or volume does not exist. + * */ + Code: number; + }; }; }; - 422: components["responses"]["ProtonErrorResponse"]; }; }; "post_drive-volumes-{volumeID}-photos-duplicates": { @@ -9497,6 +10198,110 @@ export interface operations { }; }; }; + "get_drive-photos-migrate-legacy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetMigrationStatusResponseDto"]; + }; + }; + /** @description Accepted */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AcceptedResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The link or volume does not exist. + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-photos-migrate-legacy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateFromLegacyRequest"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AcceptedResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2500: Migration in progress + * - 2501: Share not found + * - 2501: Volume not found + * - 2501: Address not found + * */ + Code: number; + }; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes: + * - 2032 + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; "get_drive-volumes-{volumeID}-photos": { parameters: { query?: { @@ -9528,6 +10333,68 @@ export interface operations { }; }; }; + "put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateXAttrRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2001: Wrong signature email passed + * - 2001: Invalid PGP message + * - 200501: Invalid Key Packet + * */ + Code: number; + }; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes: + * - 2032 + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; "post_drive-urls-{token}-files-{linkID}-checkAvailableHashes": { parameters: { query?: never; @@ -10526,16 +11393,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Revision */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Revision: components["schemas"]["DetailedRevisionTransformer"]; - }; + "application/json": components["schemas"]["GetRevisionResponseDto"]; }; }; 422: components["responses"]["ProtonErrorResponse"]; @@ -11268,8 +12133,9 @@ export interface operations { "get_drive-v2-shares-invitations": { parameters: { query?: { - AnchorID?: components["schemas"]["Id"] | null; - PageSize?: number; + AnchorID?: components["schemas"]["ListPendingInvitationQueryParameters"]["AnchorID"]; + PageSize?: components["schemas"]["ListPendingInvitationQueryParameters"]["PageSize"]; + ShareTargetTypes?: components["schemas"]["ListPendingInvitationQueryParameters"]["ShareTargetTypes"]; }; header?: never; path?: never; @@ -11835,6 +12701,16 @@ export interface operations { "application/json": components["schemas"]["SuccessfulResponse"]; }; }; + /** @description Accepted */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AcceptedResponse"]; + }; + }; 422: components["responses"]["ProtonErrorResponse"]; }; }; diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 48767e6a..d5bfe61b 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -439,6 +439,7 @@ function transformRevisionResponse( return { uid: makeNodeRevisionUid(volumeId, nodeId, revision.ID), state: revision.State === APIRevisionState.Active ? RevisionState.Active : RevisionState.Superseded, + // @ts-expect-error: API doc is wrong, CreateTime is not optional. creationTime: new Date(revision.CreateTime*1000), storageSize: revision.Size, signatureEmail: revision.SignatureEmail || undefined, diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 06b017cc..d5a698e5 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -49,6 +49,14 @@ type PostShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls']['post'][' type PutShareUrlRequest = Extract['content']['application/json']; type PutShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls/{urlID}']['put']['responses']['200']['content']['application/json']; +// We do not support photos and albums yet. +const SUPPORTED_SHARE_TARGET_TYPES = [ + 0, // Root + 1, // Folder + 2, // File + 5, // Proton vendor (documents and sheets) +]; + /** * Provides API communication for fetching and managing sharing. * @@ -81,7 +89,14 @@ export class SharingAPIService { while (true) { const response = await this.apiService.get(`drive/v2/sharedwithme?${anchor ? `AnchorID=${anchor}` : ''}`, signal); for (const link of response.Links) { - yield makeNodeUid(link.VolumeID, link.LinkID); + const nodeUid = makeNodeUid(link.VolumeID, link.LinkID); + + if (!SUPPORTED_SHARE_TARGET_TYPES.includes(link.ShareTargetType)) { + this.logger.warn(`Unsupported share target type ${link.ShareTargetType} for node ${nodeUid}`); + continue; + } + + yield nodeUid; } if (!response.More || !response.AnchorID) { @@ -96,7 +111,14 @@ export class SharingAPIService { while (true) { const response = await this.apiService.get(`drive/v2/shares/invitations?${anchor ? `AnchorID=${anchor}` : ''}`, signal); for (const invitation of response.Invitations) { - yield makeInvitationUid(invitation.ShareID, invitation.InvitationID); + const invitationUid = makeInvitationUid(invitation.ShareID, invitation.InvitationID); + + if (!SUPPORTED_SHARE_TARGET_TYPES.includes(invitation.ShareTargetType)) { + this.logger.warn(`Unsupported share target type ${invitation.ShareTargetType} for invitation ${invitationUid}`); + continue; + } + + yield invitationUid; } if (!response.More || !response.AnchorID) { From 135973e08245c8efcadb91b60134474533b6705a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Jul 2025 12:11:50 +0000 Subject: [PATCH 157/791] Decrypt nodes in parallel --- js/sdk/src/internal/asyncIteratorMap.test.ts | 150 +++++++++++++++++++ js/sdk/src/internal/asyncIteratorMap.ts | 64 ++++++++ js/sdk/src/internal/nodes/nodesAccess.ts | 32 +++- 3 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 js/sdk/src/internal/asyncIteratorMap.test.ts create mode 100644 js/sdk/src/internal/asyncIteratorMap.ts diff --git a/js/sdk/src/internal/asyncIteratorMap.test.ts b/js/sdk/src/internal/asyncIteratorMap.test.ts new file mode 100644 index 00000000..9b405523 --- /dev/null +++ b/js/sdk/src/internal/asyncIteratorMap.test.ts @@ -0,0 +1,150 @@ +import { asyncIteratorMap } from './asyncIteratorMap'; + +// Helper function to create an async generator from array +async function* createAsyncGenerator(items: T[]): AsyncGenerator { + for (const item of items) { + yield item; + } +} + +// Helper function to collect all results from async generator +async function collectResults(asyncGen: AsyncGenerator): Promise { + const results: T[] = []; + for await (const item of asyncGen) { + results.push(item); + } + return results; +} + +describe('asyncIteratorMap', () => { + test('works with empty input', async () => { + const inputGen = createAsyncGenerator([]); + const mapper = async (x: number) => x * 2; + + const mappedGen = asyncIteratorMap(inputGen, mapper); + const results = await collectResults(mappedGen); + + expect(results).toEqual([]); + }); + + test('works with single item', async () => { + const inputGen = createAsyncGenerator([42]); + const mapper = async (x: number) => x * 2; + + const mappedGen = asyncIteratorMap(inputGen, mapper); + const results = await collectResults(mappedGen); + + expect(results).toEqual([84]); + }); + + test('works with 5 values', async () => { + const inputGen = createAsyncGenerator([1, 2, 3, 4, 5]); + const mapper = async (x: number) => x * 2; + + const mappedGen = asyncIteratorMap(inputGen, mapper); + const results = await collectResults(mappedGen); + + expect(results).toEqual([2, 4, 6, 8, 10]); + }); + + test('works with slow mapper - finishes as fast as the longest delay', async () => { + const delays: { [key: number]: number } = { 1: 100, 2: 50, 3: 200, 4: 30, 5: 80 }; + const inputGen = createAsyncGenerator(Object.keys(delays).map(Number)); + + const slowMapper = async (x: number) => { + await new Promise(resolve => setTimeout(resolve, delays[x])); + return x * 2; + }; + + const startTime = Date.now(); + const mappedGen = asyncIteratorMap(inputGen, slowMapper, 5); + const results = await collectResults(mappedGen); + const endTime = Date.now(); + + // Should complete in roughly the time of the longest delay (200ms) plus some overhead + const executionTime = endTime - startTime; + expect(executionTime).toBeGreaterThanOrEqual(200); + expect(executionTime).toBeLessThan(250); + + // Results should be in the order of the delays + expect(results).toEqual([8, 4, 10, 2, 6]); + }); + + test('handles errors from input iterator properly', async () => { + const throwingInputGen = async function*() { + yield 1; + yield 2; + throw new Error('Error providing value: 3'); + } + + const mapper = async (x: number) => x * 2; + + const mappedGen = asyncIteratorMap(throwingInputGen(), mapper); + + const results: number[] = []; + let caughtError: Error | null = null; + + try { + for await (const item of mappedGen) { + results.push(item); + } + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError?.message).toBe('Error providing value: 3'); + expect(results).toEqual([2, 4]); + }); + + test('handles errors from mapper properly', async () => { + const inputGen = createAsyncGenerator([1, 2, 3, 4, 5]); + + const throwingMapper = async (x: number) => { + if (x === 3) { + throw new Error(`Error processing value: ${x}`); + } + return x * 2; + }; + + const mappedGen = asyncIteratorMap(inputGen, throwingMapper); + + const results: number[] = []; + let caughtError: Error | null = null; + + try { + for await (const item of mappedGen) { + results.push(item); + } + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError?.message).toBe('Error processing value: 3'); + expect(results).toEqual([2, 4]); + }); + + test('respects concurrency limit', async () => { + const inputGen = createAsyncGenerator([1, 2, 3, 4, 5, 6, 7, 8]); + + let concurrentExecutions = 0; + let maxConcurrentExecutions = 0; + + const mapper = async (x: number) => { + concurrentExecutions++; + maxConcurrentExecutions = Math.max(maxConcurrentExecutions, concurrentExecutions); + + // Wait for 100ms to simulate work + await new Promise(resolve => setTimeout(resolve, 100)); + + concurrentExecutions--; + return x * 2; + }; + + const concurrencyLimit = 3; + const mappedGen = asyncIteratorMap(inputGen, mapper, concurrencyLimit); + const results = await collectResults(mappedGen); + + expect(maxConcurrentExecutions).toBe(concurrencyLimit); + expect(results).toEqual([2, 4, 6, 8, 10, 12, 14, 16]); + }); +}); diff --git a/js/sdk/src/internal/asyncIteratorMap.ts b/js/sdk/src/internal/asyncIteratorMap.ts new file mode 100644 index 00000000..400b11be --- /dev/null +++ b/js/sdk/src/internal/asyncIteratorMap.ts @@ -0,0 +1,64 @@ +const DEFAULT_CONCURRENCY = 10; + +/** + * Maps values from an input iterator and produces a new iterator. + * The mapper function is not awaited immediately to allow for parallel + * execution. The order of the items in the output iterator is not the + * same as the order of the items in the input iterator. + * + * Any error from the input iterator or the mapper function is propagated + * to the output iterator. + * + * @param inputIterator - The input async iterator. + * @param mapper - The mapper function that maps the input values to output values. + * @param concurrency - The concurrency limit. How many parallel async mapper calls are allowed. + * @returns An async iterator that yields the mapped values. + */ +export async function* asyncIteratorMap( + inputIterator: AsyncGenerator, + mapper: (item: I) => Promise, + concurrency: number = DEFAULT_CONCURRENCY, +): AsyncGenerator { + let done = false; + + const executing = new Set>(); + const results: Array> = []; + + const pump = async () => { + let next; + try { + next = await inputIterator.next(); + } catch (error) { + results.push(Promise.reject(error)); + return; + } + + if (next.done) { + done = true; + return; + } + + const promise = mapper(next.value) + .then((result) => { + results.push(Promise.resolve(result)); + }) + .catch((error) => { + results.push(Promise.reject(error)); + }); + executing.add(promise); + void promise.finally(() => executing.delete(promise)); + }; + + while (!done || executing.size > 0 || results.length > 0) { + while (!done && executing.size < concurrency) { + await pump(); + } + + if (results.length > 0) { + yield await results.shift()!; + } else if (executing.size > 0) { + // Wait for at least one task to complete + await Promise.race(Array.from(executing)); + } + } +} diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 2a270e72..c3f76108 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -3,6 +3,7 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey } from "../../crypto"; import { InvalidNameError, Logger, MissingNode, NodeType, Result, resultError, resultOk } from "../../interface"; import { DecryptionError, ProtonDriveError } from "../../errors"; +import { asyncIteratorMap } from '../asyncIteratorMap'; import { getErrorMessage } from '../errors'; import { BatchLoading } from "../batchLoading"; import { makeNodeUid, splitNodeUid } from "../uids"; @@ -15,6 +16,16 @@ import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, Dec import { validateNodeName } from "./validations"; import { isProtonDocument, isProtonSheet } from './mediaTypes'; +// This is the number of nodes that are loaded in parallel. +// It is a trade-off between initial wait time and overhead of API calls. +const BATCH_LOADING_SIZE = 30; + +// This is the number of nodes that are decrypted in parallel. +// It is a trade-off between performance and memory usage. +// Higher number means more memory usage, but faster decryption. +// Lower number means less memory usage, but slower decryption. +const DECRYPTION_CONCURRENCY = 15; + /** * Provides access to node metadata. * @@ -64,7 +75,7 @@ export class NodesAccess { // Ensure the parent is loaded and up-to-date. const parentNode = await this.getNode(parentNodeUid); - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal), batchSize: BATCH_LOADING_SIZE }); const areChildrenCached = await this.cache.isFolderChildrenLoaded(parentNodeUid); if (areChildrenCached) { @@ -100,7 +111,7 @@ export class NodesAccess { // Improvement requested: keep status of loaded trash and leverage cache. async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.shareService.getMyFilesIDs(); - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal), batchSize: BATCH_LOADING_SIZE }); for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { let node; try { @@ -118,7 +129,7 @@ export class NodesAccess { } async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, signal) }); + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, signal), batchSize: BATCH_LOADING_SIZE }); for await (const result of this.cache.iterateNodes(nodeUids)) { if (result.ok && !result.node.isStale) { yield result.node; @@ -150,13 +161,22 @@ export class NodesAccess { const { volumeId: ownVolumeId } = await this.shareService.getMyFilesIDs(); - for await (const encryptedNode of this.apiService.iterateNodes(nodeUids, ownVolumeId, signal)) { + const encryptedNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, signal); + const decryptNodeMapper = async (encryptedNode: EncryptedNode): Promise> => { returnedNodeUids.push(encryptedNode.uid); try { const { node } = await this.decryptNode(encryptedNode); - yield node; + return resultOk(node); } catch (error: unknown) { - errors.push(error); + return resultError(error); + } + }; + const decryptedNodesIterator = asyncIteratorMap(encryptedNodesIterator, decryptNodeMapper, DECRYPTION_CONCURRENCY); + for await (const node of decryptedNodesIterator) { + if (node.ok) { + yield node.value; + } else { + errors.push(node.error); } } From be5aa3cff1bc52fa8679a811c0a13065b42df540 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Jul 2025 13:29:19 +0000 Subject: [PATCH 158/791] Parse claimedModificationTime on cache --- js/sdk/src/internal/nodes/cache.test.ts | 1 + js/sdk/src/internal/nodes/cache.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 4ae24de4..c6703ed6 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -108,6 +108,7 @@ describe('nodesCache', () => { creationTime: new Date('2021-01-01'), storageSize: 100, contentAuthor: resultOk('test@test.com'), + claimedModificationTime: new Date('2021-02-01') }); const node = generateNode('node1', '', { activeRevision }); diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 04f574e6..7ad45276 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -261,6 +261,7 @@ function deserialiseRevision(revision: any): Result { return resultOk({ ...revision.value, creationTime: new Date(revision.value.creationTime), + claimedModificationTime: new Date(revision.value.claimedModificationTime) }); } From 7475d7afb2a342f0750957a0a4f5025d77aa475f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 4 Jul 2025 11:53:16 +0200 Subject: [PATCH 159/791] Add C exports for API session --- cs/Directory.Build.props | 4 - cs/Directory.Build.targets | 1 + cs/Directory.Packages.props | 2 + cs/headers/module.modulemap | 4 + cs/headers/proton_sdk.h | 67 ++++ .../ExceptionExtensions.cs | 30 ++ .../src/Proton.Sdk.CExports/InteropArray.cs | 48 +++ .../InteropAsyncCallback.cs | 12 + .../InteropAsyncCallbackExtensions.cs | 44 +++ .../InteropCancellationTokenSource.cs | 81 +++++ .../InteropErrorConverter.cs | 55 ++++ .../InteropProtonApiSession.cs | 295 ++++++++++++++++++ ...InteropTokenRefreshedCallbackExtensions.cs | 26 ++ .../InteropTokensRefreshedCallback.cs | 10 + .../Logging/InteropLogCallback.cs | 10 + .../Logging/InteropLogger.cs | 51 +++ .../Logging/InteropLoggerProvider.cs | 51 +++ .../Proton.Sdk.CExports.csproj | 30 ++ .../Proton.Sdk.CExports/ResultExtensions.cs | 52 +++ .../Authentication/AuthorizationHandler.cs | 2 +- .../Authentication/TokenCredential.cs | 17 +- cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 21 +- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 27 ++ cs/sdk/src/protos/account.proto | 140 +++++++++ cs/sdk/src/protos/drive.proto | 13 + 25 files changed, 1062 insertions(+), 31 deletions(-) create mode 100644 cs/headers/module.modulemap create mode 100644 cs/headers/proton_sdk.h create mode 100644 cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropTokensRefreshedCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj create mode 100644 cs/sdk/src/Proton.Sdk.CExports/ResultExtensions.cs create mode 100644 cs/sdk/src/protos/account.proto create mode 100644 cs/sdk/src/protos/drive.proto diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index d1c74fbc..c779adc8 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -16,12 +16,8 @@ en true - - false - - lib diff --git a/cs/Directory.Build.targets b/cs/Directory.Build.targets index 64b81d91..28be5357 100644 --- a/cs/Directory.Build.targets +++ b/cs/Directory.Build.targets @@ -4,6 +4,7 @@ $(NETCoreSdkRuntimeIdentifier) $(DefineConstants);WINDOWS + lib diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 3a0c26f1..e1bd32a8 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -26,5 +26,7 @@ + + \ No newline at end of file diff --git a/cs/headers/module.modulemap b/cs/headers/module.modulemap new file mode 100644 index 00000000..245ecb3d --- /dev/null +++ b/cs/headers/module.modulemap @@ -0,0 +1,4 @@ +module ProtonDriveSdk { + umbrella header "proton_sdk.h" + export * +} diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h new file mode 100644 index 00000000..78e539a2 --- /dev/null +++ b/cs/headers/proton_sdk.h @@ -0,0 +1,67 @@ +#ifndef PROTON_SDK_H +#define PROTON_SDK_H + +#include +#include + +typedef struct { + const uint8_t* pointer; + size_t length; +} ByteArray; + +typedef struct { + const void* state; + void (*on_success)(const void*, ByteArray); + void (*on_failure)(const void*, ByteArray); + intptr_t cancellation_token_source_handle; +} AsyncCallback; + +typedef struct { + const void* state; + void (*callback)(const void*, ByteArray); +} Callback; + +typedef struct { + AsyncCallback async_callback; + Callback progress_callback; +} AsyncCallbackWithProgress; + +intptr_t cancellation_token_source_create(); + +void cancellation_token_source_cancel( + intptr_t cancellation_token_source_handle +); + +void cancellation_token_source_free( + intptr_t cancellation_token_source_handle +); + +int session_begin( + ByteArray request, + AsyncCallback callback +); + +int session_resume( + ByteArray request, + intptr_t* session_handle +); + +int session_renew( + intptr_t old_session_handle, + ByteArray request, + intptr_t* new_session_handle +); + +int session_end( + intptr_t session_handle, + AsyncCallback callback +); + +void session_free(intptr_t session_handle); + +int logger_provider_create( + Callback log_callback, + intptr_t* logger_provider_handle +); + +#endif PROTON_SDK_H diff --git a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs new file mode 100644 index 00000000..4251736e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs @@ -0,0 +1,30 @@ +namespace Proton.Sdk.CExports; + +internal static class ExceptionExtensions +{ + public static Error ToInteropError(this Exception exception, Action setDomainAndCodesFunction) + { + var error = new Error + { + Message = exception.Message, + }; + + var type = exception.GetType().FullName; + if (type is not null) + { + error.Type = type; + } + + var context = exception.StackTrace; + if (context is not null) + { + error.Context = context; + } + + setDomainAndCodesFunction.Invoke(error, exception); + + error.InnerError = exception.InnerException?.ToInteropError(setDomainAndCodesFunction); + + return error; + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs new file mode 100644 index 00000000..6337a285 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs @@ -0,0 +1,48 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropArray(byte* bytes, nint length) +{ + private readonly byte* _bytes = bytes; + private readonly nint _length = length; + + public static InteropArray Null => default; + + public bool IsNullOrEmpty => _bytes is null || _length == 0; + + public static InteropArray FromMemory(ReadOnlyMemory memory) + { + if (memory.Length == 0) + { + return Null; + } + + var interopBytes = NativeMemory.Alloc((nuint)memory.Length); + + memory.Span.CopyTo(new Span(interopBytes, memory.Length)); + + return new InteropArray((byte*)interopBytes, memory.Length); + } + + public byte[] ToArray() + { + return !IsNullOrEmpty ? new ReadOnlySpan(_bytes, (int)_length).ToArray() : []; + } + + public byte[]? ToArrayOrNull() + { + return !IsNullOrEmpty ? new ReadOnlySpan(_bytes, (int)_length).ToArray() : null; + } + + public ReadOnlySpan AsReadOnlySpan() + { + return !IsNullOrEmpty ? new ReadOnlySpan(_bytes, (int)_length) : null; + } + + public void Free() + { + NativeMemory.Free(_bytes); + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallback.cs new file mode 100644 index 00000000..a503c96a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallback.cs @@ -0,0 +1,12 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropAsyncCallback +{ + public readonly void* State; + public readonly delegate* unmanaged[Cdecl] OnSuccess; + public readonly delegate* unmanaged[Cdecl] OnFailure; + public readonly nint CancellationTokenSourceHandle; +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs new file mode 100644 index 00000000..91bc0c43 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs @@ -0,0 +1,44 @@ +namespace Proton.Sdk.CExports; + +internal static class InteropAsyncCallbackExtensions +{ + public static unsafe int InvokeFor(this InteropAsyncCallback callback, Func>> asyncFunction) + { + if (!InteropCancellationTokenSource.TryGetTokenFromHandle(callback.CancellationTokenSourceHandle, out var cancellationToken)) + { + return -1; + } + + Use( + value => callback.OnSuccess(callback.State, value), + error => callback.OnFailure(callback.State, error), + asyncFunction, + cancellationToken); + + return 0; + } + + private static async void Use( + Action onSuccess, + Action onFailure, + Func>> asyncFunction, + CancellationToken cancellationToken) + { + try + { + var result = await asyncFunction.Invoke(cancellationToken).ConfigureAwait(false); + + if (!result.TryGetValueElseError(out var value, out var error)) + { + onFailure(error); + return; + } + + onSuccess(value); + } + catch + { + // TODO: log + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs new file mode 100644 index 00000000..0825ff24 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs @@ -0,0 +1,81 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +internal static class InteropCancellationTokenSource +{ + internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out CancellationTokenSource cancellationTokenSource) + { + var gcHandle = GCHandle.FromIntPtr(handle); + + cancellationTokenSource = gcHandle.Target as CancellationTokenSource; + + return cancellationTokenSource is not null; + } + + internal static bool TryGetTokenFromHandle(nint handle, out CancellationToken cancellationToken) + { + if (handle == 0) + { + cancellationToken = CancellationToken.None; + return true; + } + + if (!TryGetFromHandle(handle, out var cancellationTokenSource)) + { + cancellationToken = CancellationToken.None; + return false; + } + + cancellationToken = cancellationTokenSource.Token; + return true; + } + + [UnmanagedCallersOnly(EntryPoint = "cancellation_token_source_create", CallConvs = [typeof(CallConvCdecl)])] + private static nint NativeCreate() + { + return GCHandle.ToIntPtr(GCHandle.Alloc(new CancellationTokenSource())); + } + + [UnmanagedCallersOnly(EntryPoint = "cancellation_token_source_cancel", CallConvs = [typeof(CallConvCdecl)])] + private static void NativeCancel(nint cancellationTokenSourceHandle) + { + try + { + if (!TryGetFromHandle(cancellationTokenSourceHandle, out var cancellationTokenSource)) + { + return; + } + + cancellationTokenSource.Cancel(); + } + catch + { + // Ignore + } + } + + [UnmanagedCallersOnly(EntryPoint = "cancellation_token_source_free", CallConvs = [typeof(CallConvCdecl)])] + private static void NativeFree(nint cancellationTokenSourceHandle) + { + try + { + var gcHandle = GCHandle.FromIntPtr(cancellationTokenSourceHandle); + + if (gcHandle.Target is not CancellationTokenSource cancellationTokenSource) + { + return; + } + + gcHandle.Free(); + + cancellationTokenSource.Dispose(); + } + catch + { + // Ignore + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs new file mode 100644 index 00000000..19d9432c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs @@ -0,0 +1,55 @@ +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text.Json; +using Polly.Timeout; + +namespace Proton.Sdk.CExports; + +internal static class InteropErrorConverter +{ + public static void SetDomainAndCodes(Error error, Exception exception) + { + switch (exception) + { + case OperationCanceledException: + error.Domain = ErrorDomain.SuccessfulCancellation; + break; + + case ProtonApiException ex: + error.Domain = ErrorDomain.Api; + error.PrimaryCode = (long)ex.Code; + error.SecondaryCode = ex.TransportCode; + break; + + case SocketException ex: + error.Domain = ErrorDomain.Network; + error.PrimaryCode = ex.ErrorCode; + error.SecondaryCode = (long)ex.SocketErrorCode; + break; + + case HttpRequestException ex: + error.Domain = ErrorDomain.Transport; + error.PrimaryCode = (long)ex.HttpRequestError; + error.SecondaryCode = ex.StatusCode is not null ? (long)ex.StatusCode : 0; + break; + + case TimeoutRejectedException: + error.Domain = ErrorDomain.Transport; + error.PrimaryCode = (long)HttpRequestError.ConnectionError; + break; + + case HttpIOException ex: + error.Domain = ErrorDomain.Transport; + error.PrimaryCode = (long)ex.HttpRequestError; + break; + + case JsonException: + error.Domain = ErrorDomain.Serialization; + break; + + case CryptographicException: + error.Domain = ErrorDomain.Cryptography; + break; + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs new file mode 100644 index 00000000..57f4b90b --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs @@ -0,0 +1,295 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Extensions.Logging; +using Proton.Sdk.Authentication; +using Proton.Sdk.Caching; +using Proton.Sdk.CExports.Logging; + +namespace Proton.Sdk.CExports; + +internal static class InteropProtonApiSession +{ + internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out ProtonApiSession session) + { + if (handle == 0) + { + session = null; + return false; + } + + var gcHandle = GCHandle.FromIntPtr(handle); + + session = gcHandle.Target as ProtonApiSession; + + return session is not null; + } + + [UnmanagedCallersOnly(EntryPoint = "session_begin", CallConvs = [typeof(CallConvCdecl)])] + private static int NativeBegin(InteropArray sessionBeginRequestBytes, InteropAsyncCallback callback) + { + try + { + return callback.InvokeFor(ct => InteropBeginAsync(sessionBeginRequestBytes, ct)); + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "session_resume", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int NativeResume(InteropArray requestBytes, nint* sessionHandle) + { + try + { + var request = SessionResumeRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + ILoggerFactory? loggerFactory = null; + + if (request.Options.HasLoggerProviderHandle + && InteropLoggerProvider.TryGetFromHandle((nint)request.Options.LoggerProviderHandle, out var loggerProvider)) + { + loggerFactory = new LoggerFactory([loggerProvider]); + } + + var secretCacheRepository = SqliteCacheRepository.OpenFile(request.SecretCachePath); + + var entityCacheRepository = request.Options.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var options = new Proton.Sdk.ProtonClientOptions + { + BaseUrl = new Uri(request.Options.BaseUrl), + UserAgent = request.Options.UserAgent, + BindingsLanguage = request.Options.BindingsLanguage, + LoggerFactory = loggerFactory, + TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, + EntityCacheRepository = entityCacheRepository, + SecretCacheRepository = secretCacheRepository, + }; + + var passwordMode = request.IsWaitingForDataPassword ? PasswordMode.Dual : PasswordMode.Single; + + var session = ProtonApiSession.Resume( + new Authentication.SessionId(request.SessionId.Value), + request.Username, + new Users.UserId(request.UserId.Value), + request.AccessToken, + request.RefreshToken, + request.Scopes, + request.IsWaitingForSecondFactorCode, + passwordMode, + request.AppVersion, + secretCacheRepository, + options); + + *sessionHandle = GCHandle.ToIntPtr(GCHandle.Alloc(session)); + return 0; + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "session_renew", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int NativeRenew(nint oldSessionHandle, InteropArray sessionRenewRequestBytes, nint* newSessionHandle) + { + try + { + var request = SessionRenewRequest.Parser.ParseFrom(sessionRenewRequestBytes.AsReadOnlySpan()); + + if (!TryGetFromHandle(oldSessionHandle, out var expiredSession)) + { + return -1; + } + + var passwordMode = request.IsWaitingForDataPassword ? PasswordMode.Dual : PasswordMode.Single; + + var session = ProtonApiSession.Renew( + expiredSession, + new Authentication.SessionId(request.SessionId.Value), + request.AccessToken, + request.RefreshToken, + request.Scopes, + request.IsWaitingForSecondFactorCode, + passwordMode); + + *newSessionHandle = GCHandle.ToIntPtr(GCHandle.Alloc(session)); + + return 0; + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "session_end", CallConvs = [typeof(CallConvCdecl), typeof(CallConvMemberFunction)])] + private static int NativeEnd(nint sessionHandle, InteropAsyncCallback callback) + { + try + { + if (!TryGetFromHandle(sessionHandle, out var session)) + { + return -1; + } + + callback.InvokeFor(_ => InteropEndAsync(session)); + + return 0; + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "session_tokens_refreshed_subscribe", CallConvs = [typeof(CallConvCdecl)])] + private static nint NativeSubscribeTokensRefreshed(nint sessionHandle, InteropTokensRefreshedCallback tokensRefreshedCallback) + { + try + { + if (!TryGetFromHandle(sessionHandle, out var session)) + { + return 0; + } + + Action handler = (accessToken, refreshToken) => tokensRefreshedCallback.Invoke(accessToken, refreshToken); + + session.TokenCredential.TokensRefreshed += handler; + + return GCHandle.ToIntPtr(GCHandle.Alloc(handler)); + } + catch + { + return 0; + } + } + + [UnmanagedCallersOnly(EntryPoint = "session_tokens_refreshed_unsubscribe", CallConvs = [typeof(CallConvCdecl)])] + private static int NativeUnsubscribeTokensRefreshed(nint sessionHandle, nint subscriptionHandle) + { + try + { + if (!TryGetFromHandle(sessionHandle, out var session)) + { + return -1; + } + + if (!TryGetTokensExpiredSubscriptionFromHandle(subscriptionHandle, out var handler)) + { + return -1; + } + + session.TokenCredential.TokensRefreshed -= handler; + + return 0; + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "session_free", CallConvs = [typeof(CallConvCdecl)])] + private static void NativeFree(nint handle) + { + try + { + var gcHandle = GCHandle.FromIntPtr(handle); + + if (gcHandle.Target is not ProtonApiSession) + { + return; + } + + gcHandle.Free(); + } + catch + { + // Ignore + } + } + + private static async ValueTask> InteropBeginAsync(InteropArray requestBytes, CancellationToken cancellationToken) + { + try + { + var request = SessionBeginRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + ILoggerFactory? loggerFactory = null; + + if (request.Options.HasLoggerProviderHandle + && InteropLoggerProvider.TryGetFromHandle((nint)request.Options.LoggerProviderHandle, out var loggerProvider)) + { + loggerFactory = new LoggerFactory([loggerProvider]); + } + + var secretCacheRepository = request.HasSecretCachePath + ? SqliteCacheRepository.OpenFile(request.SecretCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var entityCacheRepository = request.Options.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var options = new ProtonSessionOptions + { + BaseUrl = new Uri(request.Options.BaseUrl), + UserAgent = request.Options.UserAgent, + BindingsLanguage = request.Options.BindingsLanguage, + LoggerFactory = loggerFactory, + TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, + EntityCacheRepository = entityCacheRepository, + SecretCacheRepository = secretCacheRepository, + }; + + var session = await ProtonApiSession.BeginAsync( + request.Username, + Encoding.UTF8.GetBytes(request.Password), + request.AppVersion, + options, + cancellationToken).ConfigureAwait(false); + + var handle = GCHandle.ToIntPtr(GCHandle.Alloc(session)); + return ResultExtensions.Success(new IntResponse { Value = handle }); + } + catch (Exception e) + { + return ResultExtensions.Failure(e, InteropErrorConverter.SetDomainAndCodes); + } + } + + private static async ValueTask> InteropEndAsync(ProtonApiSession session) + { + try + { + await session.EndAsync().ConfigureAwait(false); + + return ResultExtensions.Success(); + } + catch (Exception e) + { + return ResultExtensions.Failure(e, InteropErrorConverter.SetDomainAndCodes); + } + } + + private static bool TryGetTokensExpiredSubscriptionFromHandle(nint handle, [MaybeNullWhen(false)] out Action handler) + { + if (handle == 0) + { + handler = null; + return false; + } + + var gcHandle = GCHandle.FromIntPtr(handle); + + handler = gcHandle.Target as Action; + + return handler is not null; + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs new file mode 100644 index 00000000..5f4c89ea --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs @@ -0,0 +1,26 @@ +using Google.Protobuf; + +namespace Proton.Sdk.CExports; + +internal static class InteropTokenRefreshedCallbackExtensions +{ + internal static unsafe void Invoke(this InteropTokensRefreshedCallback callback, string accessToken, string refreshToken) + { + var sessionTokens = new SessionTokens + { + AccessToken = accessToken, + RefreshToken = refreshToken, + }; + + var tokenBytes = InteropArray.FromMemory(sessionTokens.ToByteArray()); + + try + { + callback.OnTokenRefreshed(callback.State, tokenBytes); + } + finally + { + tokenBytes.Free(); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTokensRefreshedCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTokensRefreshedCallback.cs new file mode 100644 index 00000000..45729adf --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropTokensRefreshedCallback.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropTokensRefreshedCallback +{ + public readonly void* State; + public readonly delegate* unmanaged[Cdecl] OnTokenRefreshed; +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs new file mode 100644 index 00000000..aa98aac8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports.Logging; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropLogCallback +{ + public readonly void* State; + public readonly delegate* unmanaged[Cdecl] Invoke; +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs new file mode 100644 index 00000000..b00050b0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -0,0 +1,51 @@ +using System.Runtime.InteropServices; +using Google.Protobuf; +using Microsoft.Extensions.Logging; + +namespace Proton.Sdk.CExports.Logging; + +using EventId = Microsoft.Extensions.Logging.EventId; + +[StructLayout(LayoutKind.Sequential)] +internal sealed class InteropLogger(InteropLogCallback logCallback, string categoryName) : ILogger +{ + private readonly InteropLogCallback _logCallback = logCallback; + private readonly string _categoryName = categoryName; + + public IDisposable BeginScope(TState state) + where TState : notnull + { + // TODO: add support for scopes? + return new DummyDisposable(); + } + + public unsafe void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = formatter.Invoke(state, exception); + var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; + + var messageBytes = InteropArray.FromMemory(logEvent.ToByteArray()); + + try + { + _logCallback.Invoke(_logCallback.State, messageBytes); + } + finally + { + messageBytes.Free(); + } + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + private sealed class DummyDisposable : IDisposable + { + public void Dispose() + { + // do nothing intentionally + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs new file mode 100644 index 00000000..985d9837 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs @@ -0,0 +1,51 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace Proton.Sdk.CExports.Logging; + +internal sealed class InteropLoggerProvider(InteropLogCallback logCallback) : ILoggerProvider +{ + private readonly InteropLogCallback _logCallback = logCallback; + + public ILogger CreateLogger(string categoryName) + { + return new InteropLogger(_logCallback, categoryName); + } + + public void Dispose() + { + // Nothing to do + } + + internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out InteropLoggerProvider session) + { + if (handle == 0) + { + session = null; + return false; + } + + var gcHandle = GCHandle.FromIntPtr(handle); + + session = gcHandle.Target as InteropLoggerProvider; + + return session is not null; + } + + [UnmanagedCallersOnly(EntryPoint = "logger_provider_create", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int InitializeLoggerProvider(InteropLogCallback logCallback, nint* loggerProviderHandle) + { + try + { + var provider = new InteropLoggerProvider(logCallback); + *loggerProviderHandle = GCHandle.ToIntPtr(GCHandle.Alloc(provider)); + return 0; + } + catch + { + return -1; + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj new file mode 100644 index 00000000..e8ffba26 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj @@ -0,0 +1,30 @@ + + + + $(NativeLibPrefix)proton_sdk + net9.0 + enable + enable + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/cs/sdk/src/Proton.Sdk.CExports/ResultExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ResultExtensions.cs new file mode 100644 index 00000000..1d3b815a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/ResultExtensions.cs @@ -0,0 +1,52 @@ +using Google.Protobuf; + +namespace Proton.Sdk.CExports; + +public struct ResultExtensions +{ + internal static Result Success() + { + return new Result(value: InteropArray.Null); + } + + internal static Result Success(IMessage data) + { + return new Result( + value: InteropArray.FromMemory(data.ToByteArray())); + } + + internal static Result Success(int value) + { + return new Result( + value: InteropArray.FromMemory(new IntResponse { Value = value }.ToByteArray())); + } + + internal static Result Success(string value) + { + return new Result( + value: InteropArray.FromMemory(new StringResponse { Value = value }.ToByteArray())); + } + + internal static Result Failure(Exception exception, int defaultCode) + { + if (exception is ProtonApiException protonApiException) + { + return Failure((int)protonApiException.Code, protonApiException.Message); + } + + return Failure(defaultCode, exception.Message); + } + + internal static Result Failure(Exception exception, Action setDomainAndCodesFunction) + { + var error = exception.ToInteropError(setDomainAndCodesFunction); + + return new Result(error: InteropArray.FromMemory(error.ToByteArray())); + } + + private static Result Failure(int code, string message) + { + return new Result( + error: InteropArray.FromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs b/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs index 95e33718..cb0d2a53 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs +++ b/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs @@ -16,7 +16,7 @@ protected override async Task SendAsync(HttpRequestMessage { request.Headers.Add(SessionIdHeaderName, _session.SessionId.ToString()); - var accessToken = await _session.TokenCredential.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + var (accessToken, _) = await _session.TokenCredential.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); var response = await SendWithTokenAsync(request, accessToken, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs index 1ff29fa9..ed686d0b 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs +++ b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs @@ -21,7 +21,7 @@ internal TokenCredential(IAuthenticationApiClient client, SessionId sessionId, s _tokensTask = new Lazy>(Task.FromResult((accessToken, refreshToken))); } - public event Action? TokensRefreshed; + public event Action? TokensRefreshed; public event Action? RefreshTokenExpired; public Task<(string AccessToken, string RefreshToken)> GetTokensAsync(CancellationToken cancellationToken) @@ -29,10 +29,9 @@ internal TokenCredential(IAuthenticationApiClient client, SessionId sessionId, s return _tokensTask.Value.WaitAsync(cancellationToken); } - public async Task GetAccessTokenAsync(CancellationToken cancellationToken) + public async Task<(string AccessToken, string RefreshToken)> GetAccessTokenAsync(CancellationToken cancellationToken) { - var (accessToken, _) = await _tokensTask.Value.WaitAsync(cancellationToken).ConfigureAwait(false); - return accessToken; + return await _tokensTask.Value.WaitAsync(cancellationToken).ConfigureAwait(false); } public async Task GetRefreshedAccessTokenAsync(string rejectedAccessToken, CancellationToken cancellationToken) @@ -73,14 +72,14 @@ public async Task GetRefreshedAccessTokenAsync(string rejectedAccessToke try { - var result = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + var (accessToken, refreshToken) = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); if (tokensTaskReplaced) { - OnTokensRefreshed(); + OnTokensRefreshed(accessToken, refreshToken); } - return result; + return accessToken; } catch (ProtonApiException ex) when (ex.Code == ResponseCode.InvalidRefreshToken) { @@ -93,9 +92,9 @@ public async Task GetRefreshedAccessTokenAsync(string rejectedAccessToke } } - private void OnTokensRefreshed() + private void OnTokensRefreshed(string accessToken, string refreshToken) { - TokensRefreshed?.Invoke(); + TokensRefreshed?.Invoke(accessToken, refreshToken); } private void OnRefreshTokenExpired() diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index 0b007117..33a36ddf 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -19,25 +19,12 @@ - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index 57d1fef8..6e526b3b 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -209,6 +209,33 @@ public static ProtonApiSession Resume( return session; } + public static ProtonApiSession Renew( + ProtonApiSession expiredSession, + SessionId sessionId, + string accessToken, + string refreshToken, + IEnumerable scopes, + bool isWaitingForSecondFactorCode, + PasswordMode passwordMode) + { + var tokenCredential = new TokenCredential( + new AuthenticationApiClient(expiredSession.ClientConfiguration.GetHttpClient(), expiredSession.ClientConfiguration.RefreshRedirectUri), + sessionId, + accessToken, + refreshToken, + expiredSession.ClientConfiguration.LoggerFactory.CreateLogger()); + + return new ProtonApiSession( + sessionId, + expiredSession.Username, + expiredSession.UserId, + tokenCredential, + scopes, + isWaitingForSecondFactorCode, + passwordMode, + expiredSession.ClientConfiguration); + } + public static async Task EndAsync(string id, string accessToken, string appVersion, ProtonClientOptions? options = null) { var configuration = new ProtonClientConfiguration(appVersion, options); diff --git a/cs/sdk/src/protos/account.proto b/cs/sdk/src/protos/account.proto new file mode 100644 index 00000000..b5be0931 --- /dev/null +++ b/cs/sdk/src/protos/account.proto @@ -0,0 +1,140 @@ +syntax = "proto3"; + +option csharp_namespace = "Proton.Sdk.CExports"; + +message LogEvent { + int32 level = 1; + string message = 2; + string category_name = 3; +} + +enum ProtonClientTlsPolicy { + PROTON_CLIENT_TLS_POLICY_STRICT = 0; + PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_PINNING = 1; + PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_VALIDATION = 2; +} + +enum AddressStatus { + ADDRESS_STATUS_DISABLED = 0; + ADDRESS_STATUS_ENABLED = 1; + ADDRESS_STATUS_DELETING = 2; +} + +enum DelinquentState { + DELINQUENT_STATE_PAID = 0; + DELINQUENT_STATE_AVAILABLE = 1; + DELINQUENT_STATE_OVERDUE = 2; + DELINQUENT_STATE_DELINQUENT = 3; + DELINQUENT_STATE_NOT_RECEIVED = 4; +} + +enum UserType { + USER_TYPE_UNKNOWN = 0; + USER_TYPE_PROTON = 1; + USER_TYPE_MANAGED = 2; + USER_TYPE_EXTERNAL = 3; +} + +message ProtonClientOptions { + optional string base_url = 1; + optional string user_agent = 2; + optional string bindings_language = 3; + optional ProtonClientTlsPolicy tls_policy = 4; + optional int64 logger_provider_handle = 5; + optional string entity_cache_path = 6; +} + +message SessionBeginRequest { + string username = 1; + string password = 2; + string app_version = 3; + optional string secret_cache_path = 5; + optional ProtonClientOptions options = 4; +} + +message SessionResumeRequest { + string username = 1; + string app_version = 2; + SessionId session_id = 3; + UserId user_id = 4; + string access_token = 5; + string refresh_token = 6; + repeated string scopes = 7; + bool is_waiting_for_second_factor_code = 8; + bool is_waiting_for_data_password = 9; + string secret_cache_path = 10; + ProtonClientOptions options = 11; +} + +message SessionRenewRequest { + SessionId session_id = 1; + string access_token = 2; + string refresh_token = 3; + repeated string scopes = 4; + bool is_waiting_for_second_factor_code = 5; + bool is_waiting_for_data_password = 6; +} + +message SessionEndRequest { + int64 session_handle = 1; +} + +message SessionTokens { + string access_token = 1; + string refresh_token = 2; +} + +enum ErrorDomain { + Undefined = 0; + SuccessfulCancellation = 1; + Api = 2; + Network = 3; + Transport = 4; + Serialization = 5; + Cryptography = 6; + DataIntegrity = 7; +} + +message Error { + string type = 1; + string message = 2; + ErrorDomain domain = 3; + optional int64 primary_code = 4; + optional int64 secondary_code = 5; + optional string context = 6; + optional Error inner_error = 7; +} + +message StringResponse { + string value = 1; +} + +message IntResponse { + int64 value = 1; +} + +message SessionId { + string value = 1; +} + +message UserId { + string value = 1; +} + +message UserKeyId { + string value = 1; +} + +message AddressId { + string value = 1; +} + +message AddressKeyId { + string value = 1; +} + +message AddressKey { + AddressId address_id = 1; + AddressKeyId address_key_id = 2; + bool is_allowed_for_encryption = 3; +} diff --git a/cs/sdk/src/protos/drive.proto b/cs/sdk/src/protos/drive.proto new file mode 100644 index 00000000..9e107a7d --- /dev/null +++ b/cs/sdk/src/protos/drive.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +option csharp_namespace = "Proton.Sdk.Drive.CExports"; + +import "account.proto"; + +message NodeUid { + string value = 1; +} + +message RevisionUid { + string value = 1; +} From f1055b725c33d277945ca3ce5e85e6acf611110e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 16 Jul 2025 11:21:17 +0000 Subject: [PATCH 160/791] Create draft when starting upload --- js/sdk/src/errors.ts | 4 +- js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/upload.ts | 56 +- .../src/internal/upload/fileUploader.test.ts | 422 ++---------- js/sdk/src/internal/upload/fileUploader.ts | 608 ++++-------------- js/sdk/src/internal/upload/index.ts | 76 +-- js/sdk/src/internal/upload/manager.test.ts | 132 ++-- js/sdk/src/internal/upload/manager.ts | 51 +- .../internal/upload/streamUploader.test.ts | 469 ++++++++++++++ js/sdk/src/internal/upload/streamUploader.ts | 552 ++++++++++++++++ js/sdk/src/protonDriveClient.ts | 9 +- 11 files changed, 1331 insertions(+), 1050 deletions(-) create mode 100644 js/sdk/src/internal/upload/streamUploader.test.ts create mode 100644 js/sdk/src/internal/upload/streamUploader.ts diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index 42811eb0..bcc0ddcb 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -73,12 +73,10 @@ export class ValidationError extends ProtonDriveError { export class NodeAlreadyExistsValidationError extends ValidationError { name = 'NodeAlreadyExistsValidationError'; - public readonly availableName: string; public readonly existingNodeUid?: string; - constructor(message: string, code: number, availableName: string, existingNodeUid?: string) { + constructor(message: string, code: number, existingNodeUid?: string) { super(message, code); - this.availableName = availableName; this.existingNodeUid = existingNodeUid; } } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index de2056b2..085ea098 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -20,7 +20,7 @@ export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, M export { NonProtonInvitationState } from './sharing'; export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricBlockVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent } from './telemetry'; export { MetricVolumeType } from './telemetry'; -export type { Fileuploader, UploadController, UploadMetadata } from './upload'; +export type { FileUploader, FileRevisionUploader, UploadController, UploadMetadata } from './upload'; export type { Thumbnail, ThumbnailResult } from './thumbnail'; export { ThumbnailType } from './thumbnail'; diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index 96bfcd9e..5bd032cb 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -2,14 +2,64 @@ import { Thumbnail } from "./thumbnail"; export type UploadMetadata = { mediaType: string, + /** + * Expected size of the file. + * + * The file size is used to verify the integrity of the file during upload. + * If the expected size does not match the actual size, the upload will + * fail. + */ expectedSize: number, + /** + * Modification time of the file. + * + * The modification time will be encrypted and stored with the file. + */ modificationTime?: Date, + /** + * Additional metadata to be stored with the file. + * + * These metadata must be object that can be serialized to JSON. + * + * The metadata will be encrypted and stored with the file. + */ additionalMetadata?: object, }; -export interface Fileuploader { - writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): UploadController, - writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): UploadController, +export interface FileRevisionUploader { + /** + * Uploads a file from a stream. + * + * The function will resolve to a controller that can be used to pause, + * resume and complete the upload. + * + * The function will reject if the node with the given name already exists. + */ + writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise, + + /** + * Uploads a file from a file object. It is convenient to use this method + * when the file is already in memory. The file object is used to get the + * metadata, such as the media type, size or modification time. + * + * The function will resolve to a controller that can be used to pause, + * resume and complete the upload. + * + * The function will reject if the node with the given name already exists. + */ + writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise, +} + +export interface FileUploader extends FileRevisionUploader { + /** + * Returns the available name for the file. + * + * The function will return a name that includes the original name with the + * available index. The name is guaranteed to be unique in the parent folder. + * + * Example new name: `file (2).txt`. + */ + getAvailableName(): Promise, } export interface UploadController { diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index e95246be..c48b18f3 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -1,6 +1,5 @@ -import { Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; -import { APIHTTPError, HTTPErrorCode } from '../apiService'; -import { FILE_CHUNK_SIZE, Fileuploader } from './fileUploader'; +import { Thumbnail, UploadMetadata } from '../../interface'; +import { FileUploader } from './fileUploader'; import { UploadTelemetry } from './telemetry'; import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; @@ -8,7 +7,6 @@ import { UploadController } from './controller'; import { BlockVerifier } from './blockVerifier'; import { NodeRevisionDraft } from './interface'; import { UploadManager } from './manager'; -import { IntegrityError } from '../../errors'; const BLOCK_ENCRYPTION_OVERHEAD = 10000; @@ -41,7 +39,9 @@ describe('FileUploader', () => { let onFinish: () => Promise; let abortController: AbortController; - let uploader: Fileuploader; + let uploader: FileUploader; + + let startUploadSpy: jest.SpyInstance; beforeEach(() => { // @ts-expect-error No need to implement all methods for mocking @@ -103,411 +103,81 @@ describe('FileUploader', () => { }, } as NodeRevisionDraft; - metadata = { - // 3 blocks: 4 + 4 + 2 MB - expectedSize: 10 * 1024 * 1024, - } as UploadMetadata; + metadata = {} as UploadMetadata; controller = new UploadController(); onFinish = jest.fn(); abortController = new AbortController(); - uploader = new Fileuploader( + uploader = new FileUploader( telemetry, apiService, cryptoService, uploadManager, - blockVerifier, - revisionDraft, + 'parentFolderUid', + 'name', metadata, onFinish, abortController.signal, ); - }); - - describe('writeFile', () => { - it('should set modification time if not set', () => { - // @ts-expect-error Ignore mocking File - const file = { - lastModified: 123456789, - stream: jest.fn().mockReturnValue('stream'), - } as File; - const thumbnails: Thumbnail[] = []; - const onProgress = jest.fn(); - - const writeStreamSpy = jest.spyOn(uploader, 'writeStream').mockReturnValue(controller); - - uploader.writeFile(file, thumbnails, onProgress); - - expect(metadata.modificationTime).toEqual(new Date(123456789)); - expect(writeStreamSpy).toHaveBeenCalledWith('stream', thumbnails, onProgress); - }); - }); - - describe('writeStream', () => { - let uploadStreamSpy: jest.SpyInstance; - beforeEach(() => { - uploadStreamSpy = jest.spyOn(uploader as any, 'uploadStream').mockResolvedValue('revisionUid'); - }); - - it('should throw an error if upload already started', () => { - uploader.writeStream(new ReadableStream(), [], jest.fn()); - - expect(() => { - uploader.writeStream(new ReadableStream(), [], jest.fn()); - }).toThrow('Upload already started'); - }); - - it('should start the upload process', async () => { - const stream = new ReadableStream(); - const thumbnails: Thumbnail[] = []; - const onProgress = jest.fn(); - uploader.writeStream(stream, thumbnails, onProgress); - expect(uploadStreamSpy).toHaveBeenCalledWith(stream, thumbnails, onProgress); - }); + startUploadSpy = jest.spyOn(uploader as any, 'startUpload').mockReturnValue(Promise.resolve('revisionUid')); }); - describe('uploadStream', () => { - let thumbnails: Thumbnail[]; - let thumbnailSize: number; - - let onProgress: (uploadedBytes: number) => void; - let stream: ReadableStream; - - const verifySuccess = async () => { - const controller = uploader.writeStream(stream, thumbnails, onProgress); - await controller.completion(); - - const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); - expect(uploadManager.commitDraft).toHaveBeenCalledTimes(1); - expect(uploadManager.commitDraft).toHaveBeenCalledWith( - revisionDraft, - expect.anything(), - metadata, - { - size: metadata.expectedSize, - blockSizes: metadata.expectedSize ? [ - ...Array(numberOfExpectedBlocks - 1).fill(FILE_CHUNK_SIZE), - metadata.expectedSize % FILE_CHUNK_SIZE - ] : [], - modificationTime: undefined, - digests: { - sha1: expect.anything(), - } - }, - metadata.expectedSize + numberOfExpectedBlocks * BLOCK_ENCRYPTION_OVERHEAD, - ); - expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); - expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); - expect(telemetry.uploadFailed).not.toHaveBeenCalled(); - expect(onFinish).toHaveBeenCalledTimes(1); - expect(onFinish).toHaveBeenCalledWith(false); - }; - - const verifyFailure = async (error: string, uploadedBytes: number | undefined, expectedSize = metadata.expectedSize) => { - const controller = uploader.writeStream(stream, thumbnails, onProgress); - await expect(controller.completion()).rejects.toThrow(error); - - expect(telemetry.uploadFinished).not.toHaveBeenCalled(); - expect(telemetry.uploadFailed).toHaveBeenCalledTimes(1); - expect(telemetry.uploadFailed).toHaveBeenCalledWith( - 'revisionUid', - new Error(error), - uploadedBytes === undefined ? expect.anything() : uploadedBytes, - expectedSize, - ); - expect(onFinish).toHaveBeenCalledTimes(1); - expect(onFinish).toHaveBeenCalledWith(true); - }; - - const verifyOnProgress = async (uploadedBytes: number[]) => { - expect(onProgress).toHaveBeenCalledTimes(uploadedBytes.length); - for (let i = 0; i < uploadedBytes.length; i++) { - expect(onProgress).toHaveBeenNthCalledWith(i + 1, uploadedBytes[i]); - } - }; - - beforeEach(() => { - onProgress = jest.fn(); - thumbnails = [ - { - type: ThumbnailType.Type1, - thumbnail: new Uint8Array(1024), - } - ]; - thumbnailSize = thumbnails.reduce((acc, thumbnail) => acc + thumbnail.thumbnail.length, 0); - stream = new ReadableStream({ - start(controller) { - const chunkSize = 1024; - const chunkCount = metadata.expectedSize / chunkSize; - for (let i = 1; i <= chunkCount; i++) { - controller.enqueue(new Uint8Array(chunkSize)); - } - controller.close(); - }, - }); - }); - - it("should upload successfully", async () => { - await verifySuccess(); - expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); - expect(apiService.uploadBlock).toHaveBeenCalledTimes(4); // 3 blocks + 1 thumbnail - expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(3); // 3 blocks - expect(telemetry.logBlockVerificationError).not.toHaveBeenCalled(); - await verifyOnProgress([thumbnailSize, 4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024]); - }); - - it("should upload successfully empty file without thumbnail", async () => { - metadata = { - expectedSize: 0, - } as UploadMetadata; - stream = new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - thumbnails = []; - thumbnailSize = 0; - uploader = new Fileuploader( - telemetry, - apiService, - cryptoService, - uploadManager, - blockVerifier, - revisionDraft, - metadata, - onFinish, - ); - - await verifySuccess(); - expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(0); - expect(apiService.uploadBlock).toHaveBeenCalledTimes(0); - expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(0); - await verifyOnProgress([]); - }); - - it("should upload successfully empty file with thumbnail", async () => { - metadata = { - expectedSize: 0, - } as UploadMetadata; - stream = new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - uploader = new Fileuploader( - telemetry, - apiService, - cryptoService, - uploadManager, - blockVerifier, - revisionDraft, - metadata, - onFinish, - ); - - await verifySuccess(); - expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); - expect(apiService.uploadBlock).toHaveBeenCalledTimes(1); - expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(0); - await verifyOnProgress([thumbnailSize]); - }); - - it('should handle failure when encrypting thumbnails', async () => { - cryptoService.encryptThumbnail = jest.fn().mockImplementation(async function () { - throw new Error('Failed to encrypt thumbnail'); - }); - - await verifyFailure('Failed to encrypt thumbnail', 0); - expect(cryptoService.encryptThumbnail).toHaveBeenCalledTimes(1); - }); - - it('should handle failure when encrypting block', async () => { - cryptoService.encryptBlock = jest.fn().mockImplementation(async function () { - throw new Error('Failed to encrypt block'); - }); - - // Encrypting thumbnails is before blocks, thus it can be uploaded before failure. - await verifyFailure('Failed to encrypt block', 1024); - // 1 block + 1 retry, others are skipped - expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(2); - }); - - it('should handle one time-off failure when encrypting block', async () => { - let count = 0; - cryptoService.encryptBlock = jest.fn().mockImplementation(async function (verifyBlock, keys, block, index) { - if (count === 0) { - count++; - throw new Error('Failed to encrypt block'); - } - return mockEncryptBlock(verifyBlock, keys, block, index); - }); - - await verifySuccess(); - // 1 block + 1 retry + 2 other blocks without retry - expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(4); - await verifyOnProgress([thumbnailSize, 4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024]); - }); - - it('should handle failure when requesting tokens', async () => { - apiService.requestBlockUpload = jest.fn().mockImplementation(async function () { - throw new Error('Failed to request tokens'); - }); - - await verifyFailure('Failed to request tokens', 0); - }); + describe('writeFile', () => { + // @ts-expect-error Ignore mocking File + const file = { + type: 'image/png', + size: 1000, + lastModified: 123456789, + stream: jest.fn().mockReturnValue('stream'), + } as File; + const thumbnails: Thumbnail[] = []; + const onProgress = jest.fn(); - it('should handle failure when uploading thumbnail', async () => { - apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { - if (token === 'token/thumbnail:1') { - throw new Error('Failed to upload thumbnail'); - } - return mockUploadBlock(bareUrl, token, block, onProgress); - }); + it('should set media type if not set', async () => { + await uploader.writeFile(file, thumbnails, onProgress); - // 10 MB uploaded as blocks still uploaded - await verifyFailure('Failed to upload thumbnail', 10 * 1024 * 1024); + expect(metadata.mediaType).toEqual('image/png'); + expect(startUploadSpy).toHaveBeenCalledWith('stream', thumbnails, onProgress); }); - it('should handle one time-off failure when uploading thubmnail', async () => { - let count = 0; - apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { - if (token === 'token/thumbnail:1' && count === 0) { - count++; - throw new Error('Failed to upload thumbnail'); - } - return mockUploadBlock(bareUrl, token, block, onProgress); - }); + it('should set expected size if not set', async () => { + await uploader.writeFile(file, thumbnails, onProgress); - await verifySuccess(); - expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); - // 3 blocks + 1 retry + 1 thumbnail - expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); - await verifyOnProgress([4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 1024]); + expect(metadata.expectedSize).toEqual(file.size); + expect(startUploadSpy).toHaveBeenCalledWith('stream', thumbnails, onProgress); }); - it('should handle failure when uploading block', async () => { - apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { - if (token === 'token/block:3') { - throw new Error('Failed to upload block'); - } - return mockUploadBlock(bareUrl, token, block, onProgress); - }); + it('should set modification time if not set', async () => { + await uploader.writeFile(file, thumbnails, onProgress); - // ~8 MB uploaded as 2 first blocks + 1 thumbnail still uploaded - await verifyFailure('Failed to upload block', 8 * 1024 * 1024 + 1024); + expect(metadata.modificationTime).toEqual(new Date(123456789)); + expect(startUploadSpy).toHaveBeenCalledWith('stream', thumbnails, onProgress); }); - it('should handle one time-off failure when uploading block', async () => { - let count = 0; - apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { - if (token === 'token/block:2' && count === 0) { - count++; - throw new Error('Failed to upload block'); - } - return mockUploadBlock(bareUrl, token, block, onProgress); - }); + it('should throw an error if upload already started', async () => { + await uploader.writeFile(file, thumbnails, onProgress); - await verifySuccess(); - expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); - // 3 blocks + 1 retry + 1 thumbnail - expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); - await verifyOnProgress([1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024]); + await expect(uploader.writeFile(file, thumbnails, onProgress)).rejects.toThrow('Upload already started'); }); + }); - it('should handle expired token when uploading block', async () => { - let count = 0; - apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { - if (token === 'token/block:2' && count === 0) { - count++; - throw new APIHTTPError('Expired token', HTTPErrorCode.NOT_FOUND); - } - return mockUploadBlock(bareUrl, token, block, onProgress); - }); + describe('writeStream', () => { + const stream = new ReadableStream(); + const thumbnails: Thumbnail[] = []; + const onProgress = jest.fn(); - await verifySuccess(); - // 1 for first try + 1 for retry - expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(2); - expect(apiService.requestBlockUpload).toHaveBeenCalledWith( - revisionDraft.nodeRevisionUid, - revisionDraft.nodeKeys.signatureAddress.addressId, - { - contentBlocks: [ - { - index: 2, - encryptedSize: 4 * 1024 * 1024 + 10000, - hash: 'blockHash', - armoredSignature: 'signature', - verificationToken: 'verificationToken', - } - ], - }, - ); - // 3 blocks + 1 retry + 1 thumbnail - expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); - await verifyOnProgress([1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024]); - }); + it('should start the upload process', async () => { + await uploader.writeStream(stream, thumbnails, onProgress); - it('should handle abortion', async () => { - const error = new Error('Aborted'); - const controller = uploader.writeStream(stream, thumbnails, onProgress); - abortController.abort(error); - await controller.completion(); - expect(apiService.uploadBlock.mock.calls[0][4]?.aborted).toBe(true); + expect(startUploadSpy).toHaveBeenCalledWith(stream, thumbnails, onProgress); }); - describe('verifyIntegrity', () => { - it('should report block verification error', async () => { - blockVerifier.verifyBlock = jest.fn().mockRejectedValue(new IntegrityError('Block verification error')); - await verifyFailure('Block verification error', 1024); - expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(false); - }); - - it('should report block verification error when retry helped', async () => { - blockVerifier.verifyBlock = jest.fn().mockRejectedValueOnce(new IntegrityError('Block verification error')).mockResolvedValue({ - verificationToken: new Uint8Array(), - }); - await verifySuccess(); - expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(true); - }); - - it('should throw an error if block count does not match', async () => { - uploader = new Fileuploader( - telemetry, - apiService, - cryptoService, - uploadManager, - blockVerifier, - revisionDraft, - { - // Fake expected size to break verification - expectedSize: 1 * 1024 * 1024 + 1024, - mediaType: '', - }, - onFinish, - ); - - await verifyFailure( - 'Some file parts failed to upload', - 10 * 1024 * 1024 + 1024, - 1 * 1024 * 1024 + 1024, - ); - }); - - it('should throw an error if file size does not match', async () => { - cryptoService.encryptBlock = jest.fn().mockImplementation(async (_, __, block, index) => ({ - index, - encryptedData: block, - armoredSignature: 'signature', - verificationToken: 'verificationToken', - originalSize: 0, // Fake original size to break verification - encryptedSize: block.length + 10000, - hash: 'blockHash', - })); + it('should throw an error if upload already started', async () => { + await uploader.writeStream(stream, thumbnails, onProgress); - await verifyFailure('Some file bytes failed to upload', 10 * 1024 * 1024 + 1024); - }); + await expect(uploader.writeStream(stream, thumbnails, onProgress)).rejects.toThrow('Upload already started'); }); }); }); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 436d3209..be09506c 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -1,94 +1,37 @@ -import { c } from "ttag"; - -import { Thumbnail, Logger, ThumbnailType, UploadMetadata } from "../../interface"; -import { IntegrityError } from "../../errors"; -import { LoggerWithPrefix } from "../../telemetry"; -import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from "../apiService"; -import { getErrorMessage } from "../errors"; -import { mergeUint8Arrays } from "../utils"; -import { waitForCondition } from '../wait'; +import { Thumbnail, UploadMetadata } from "../../interface"; import { UploadAPIService } from "./apiService"; import { BlockVerifier } from "./blockVerifier"; import { UploadController } from './controller'; import { UploadCryptoService } from "./cryptoService"; -import { UploadDigests } from "./digests"; -import { NodeRevisionDraft, EncryptedBlock, EncryptedThumbnail, EncryptedBlockMetadata } from "./interface"; -import { UploadTelemetry } from './telemetry'; -import { ChunkStreamReader } from './chunkStreamReader'; +import { NodeRevisionDraft } from "./interface"; import { UploadManager } from "./manager"; +import { StreamUploader } from './streamUploader'; +import { UploadTelemetry } from './telemetry'; /** - * File chunk size in bytes representing the size of each block. - */ -export const FILE_CHUNK_SIZE = 4 * 1024 * 1024; - -/** - * Maximum number of blocks that can be buffered before upload. - * This is to prevent using too much memory. - */ -const MAX_BUFFERED_BLOCKS = 15; - -/** - * Maximum number of blocks that can be uploaded at the same time. - * This is to prevent overloading the server with too many requests. - */ -const MAX_UPLOADING_BLOCKS = 5; - -/** - * Maximum number of retries for block encryption. - * This is to automatically retry random errors that can happen - * during encryption, for example bitflips. - */ -const MAX_BLOCK_ENCRYPTION_RETRIES = 1; - -/** - * Maximum number of retries for block upload. - * This is to ensure we don't end up in an infinite loop. - */ -const MAX_BLOCK_UPLOAD_RETRIES = 3; - -/** - * Fileuploader is responsible for uploading file content to the server. - * - * It handles the encryption of file blocks and thumbnails, as well as - * the upload process itself. It manages the upload queue and ensures - * that the upload process is efficient and does not overload the server. + * Uploader is generic class responsible for creating a revision draft + * and initiate the upload process for a file object or a stream. + * + * This class is not meant to be used directly, but rather to be extended + * by `FileUploader` and `FileRevisionUploader`. */ -export class Fileuploader { - private logger: Logger; - - private digests: UploadDigests; - private controller: UploadController; - private abortController: AbortController; - - private encryptedThumbnails = new Map(); - private encryptedBlocks = new Map(); - private encryptionFinished = false; - - private ongoingUploads = new Map, - encryptedBlock: EncryptedBlock | EncryptedThumbnail, - }>(); - private uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = []; - private uploadedBlocks: ({ index: number } & EncryptedBlockMetadata)[] = []; +class Uploader { + protected controller: UploadController; + protected abortController: AbortController; constructor( - private telemetry: UploadTelemetry, - private apiService: UploadAPIService, - private cryptoService: UploadCryptoService, - private uploadManager: UploadManager, - private blockVerifier: BlockVerifier, - private revisionDraft: NodeRevisionDraft, - private metadata: UploadMetadata, - private onFinish: (failure: boolean) => Promise, - private signal?: AbortSignal, + protected telemetry: UploadTelemetry, + protected apiService: UploadAPIService, + protected cryptoService: UploadCryptoService, + protected manager: UploadManager, + protected metadata: UploadMetadata, + protected onFinish: () => void, + protected signal?: AbortSignal, ) { this.telemetry = telemetry; - this.logger = telemetry.getLoggerForRevision(revisionDraft.nodeRevisionUid); this.apiService = apiService; this.cryptoService = cryptoService; - this.blockVerifier = blockVerifier; - this.revisionDraft = revisionDraft; + this.manager = manager; this.metadata = metadata; this.onFinish = onFinish; @@ -100,11 +43,10 @@ export class Fileuploader { }); } - this.digests = new UploadDigests(); this.controller = new UploadController(); } - writeFile(fileObject: File, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): UploadController { + async writeFile(fileObject: File, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { if (this.controller.promise) { throw new Error(`Upload already started`); } @@ -117,460 +59,138 @@ export class Fileuploader { if (!this.metadata.modificationTime) { this.metadata.modificationTime = new Date(fileObject.lastModified); } - return this.writeStream(fileObject.stream(), thumbnails, onProgress); + this.controller.promise = this.startUpload(fileObject.stream(), thumbnails, onProgress); + return this.controller; } - writeStream(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): UploadController { + async writeStream(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { if (this.controller.promise) { throw new Error(`Upload already started`); } - this.controller.promise = this.uploadStream(stream, thumbnails, onProgress); + this.controller.promise = this.startUpload(stream, thumbnails, onProgress); return this.controller; } - private async uploadStream(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { - let failure = false; - - // File progress is tracked for telemetry - to track at what - // point the download failed. - let fileProgress = 0; - - try { - this.logger.info(`Starting upload`); - await this.encryptAndUploadBlocks(stream, thumbnails, (uploadedBytes) => { - fileProgress += uploadedBytes; - onProgress?.(uploadedBytes); - }) - - this.logger.debug(`All blocks uploaded, committing`); - await this.commitFile(thumbnails); - - void this.telemetry.uploadFinished(this.revisionDraft.nodeRevisionUid, fileProgress); - this.logger.info(`Upload succeeded`); - } catch (error: unknown) { - failure = true; - this.logger.error(`Upload failed`, error); - void this.telemetry.uploadFailed(this.revisionDraft.nodeRevisionUid, error, fileProgress, this.metadata.expectedSize); - throw error; - } finally { - this.logger.debug(`Upload cleanup`); - - // Help the garbage collector to clean up the memory. - this.encryptedBlocks.clear(); - this.encryptedThumbnails.clear(); - this.ongoingUploads.clear(); - this.uploadedBlocks = []; - this.uploadedThumbnails = []; - this.encryptionFinished = false; - - await this.onFinish(failure); - } - - return this.revisionDraft.nodeRevisionUid; + protected async startUpload(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { + const uploader = await this.initStreamUploader(); + return uploader.start(stream, thumbnails, onProgress); } - private async encryptAndUploadBlocks(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void) { - // We await for the encryption of thumbnails to finish before - // starting the upload. This is because we need to request the - // upload tokens for the thumbnails with the first blocks. - await this.encryptThumbnails(thumbnails); - - // Encrypting blocks and uploading them is done in parallel. - // For that reason, we want to await for the encryption later. - // However, jest complains if encryptBlock rejects asynchronously. - // For that reason we handle manually to save error to the variable - // and throw if set after we await for the encryption. - let encryptionError; - const encryptBlocksPromise = this.encryptBlocks(stream).catch((error) => { - encryptionError = error; - void this.abortUpload(error); - }); - - while (!encryptionError) { - await this.controller.waitIfPaused(); - await this.waitForUploadCapacityAndBufferedBlocks(); - - if (this.isEncryptionFullyFinished) { - break; - } - - await this.requestAndInitiateUpload(onProgress); + protected async initStreamUploader(): Promise { + const { revisionDraft, blockVerifier } = await this.createRevisionDraft(); - if (this.isEncryptionFullyFinished) { - break; + const onFinish = async (failure: boolean) => { + this.onFinish(); + if (failure) { + await this.manager.deleteDraftNode(revisionDraft.nodeUid); } } - this.logger.debug(`All blocks uploading, waiting for them to finish`); - // Technically this is finished as while-block above will break - // when encryption is finished. But in case of error there could - // be a race condition that would cause the encryptionError to - // not be set yet. - await encryptBlocksPromise; - if (encryptionError) { - throw encryptionError; - } - await Promise.all(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); - } - - private async commitFile(thumbnails: Thumbnail[]) { - this.verifyIntegrity(thumbnails); - - const uploadedBlocks = Array.from(this.uploadedBlocks.values()); - uploadedBlocks.sort((a, b) => a.index - b.index); - - const extendedAttributes = { - modificationTime: this.metadata.modificationTime, - size: this.metadata.expectedSize, - blockSizes: uploadedBlocks.map(block => block.originalSize), - digests: this.digests.digests(), - }; - const encryptedSize = uploadedBlocks.reduce((sum, block) => sum + block.encryptedSize, 0); - await this.uploadManager.commitDraft( - this.revisionDraft, - this.manifest, + return new StreamUploader( + this.telemetry, + this.apiService, + this.cryptoService, + this.manager, + blockVerifier, + revisionDraft, this.metadata, - extendedAttributes, - encryptedSize, + onFinish, + this.signal, ); } - private async encryptThumbnails(thumbnails: Thumbnail[]) { - if (new Set(thumbnails.map(({ type }) => type)).size !== thumbnails.length) { - throw new Error(`Duplicate thumbnail types`); - } - - for (const thumbnail of thumbnails) { - this.logger.debug(`Encrypting thumbnail ${thumbnail.type}`); - const encryptedThumbnail = await this.cryptoService.encryptThumbnail(this.revisionDraft.nodeKeys, thumbnail); - this.encryptedThumbnails.set(thumbnail.type, encryptedThumbnail); - } + protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft, blockVerifier: BlockVerifier }> { + throw new Error('Not implemented'); } +} - private async encryptBlocks(stream: ReadableStream) { - try { - let index = 0; - const reader = new ChunkStreamReader(stream, FILE_CHUNK_SIZE); - for await (const block of reader.iterateChunks()) { - index++; - - this.digests.update(block); - - await this.controller.waitIfPaused(); - await this.waitForBufferCapacity(); - - this.logger.debug(`Encrypting block ${index}`); - let attempt = 0; - let integrityError = false; - let encryptedBlock; - while (!encryptedBlock) { - attempt++; - - try { - encryptedBlock = await this.cryptoService.encryptBlock( - (encryptedBlock) => this.blockVerifier.verifyBlock(encryptedBlock), - this.revisionDraft.nodeKeys, - block, - index, - ); - if (integrityError) { - void this.telemetry.logBlockVerificationError(true); - } - } catch (error: unknown) { - if (error instanceof IntegrityError) { - integrityError = true; - } - - if (attempt <= MAX_BLOCK_ENCRYPTION_RETRIES) { - this.logger.warn(`Block encryption failed #${attempt}, retrying: ${getErrorMessage(error)}`); - continue; - } +/** + * Uploader implementation for a new file. + */ +export class FileUploader extends Uploader { + constructor( + telemetry: UploadTelemetry, + apiService: UploadAPIService, + cryptoService: UploadCryptoService, + manager: UploadManager, + private parentFolderUid: string, + private name: string, + metadata: UploadMetadata, + onFinish: () => void, + signal?: AbortSignal, + ) { + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); - this.logger.error(`Failed to encrypt block ${index}`, error); - if (integrityError) { - void this.telemetry.logBlockVerificationError(false); - } - throw error; - } - } - this.encryptedBlocks.set(index, encryptedBlock); - } - } finally { - this.encryptionFinished = true; - } + this.parentFolderUid = parentFolderUid; + this.name = name; } - private async requestAndInitiateUpload(onProgress?: (uploadedBytes: number) => void): Promise { - this.logger.info(`Requesting upload tokens for ${this.encryptedBlocks.size} blocks`); - const uploadTokens = await this.apiService.requestBlockUpload( - this.revisionDraft.nodeRevisionUid, - this.revisionDraft.nodeKeys.signatureAddress.addressId, - { - contentBlocks: Array.from(this.encryptedBlocks.values().map(block => ({ - index: block.index, - encryptedSize: block.encryptedSize, - hash: block.hash, - armoredSignature: block.armoredSignature, - verificationToken: block.verificationToken, - }))), - thumbnails: Array.from(this.encryptedThumbnails.values().map(block => ({ - type: block.type, - encryptedSize: block.encryptedSize, - hash: block.hash, - }))), - }, - ); + protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft, blockVerifier: BlockVerifier }> { + let revisionDraft, blockVerifier; + try { + revisionDraft = await this.manager.createDraftNode(this.parentFolderUid, this.name, this.metadata); - for (const thumbnailToken of uploadTokens.thumbnailTokens) { - let encryptedThumbnail = this.encryptedThumbnails.get(thumbnailToken.type); - if (!encryptedThumbnail) { - throw new Error(`Thumbnail ${thumbnailToken.type} not found`); + blockVerifier = new BlockVerifier(this.apiService, this.cryptoService, revisionDraft.nodeKeys.key, revisionDraft.nodeRevisionUid); + await blockVerifier.loadVerificationData(); + } catch (error: unknown) { + this.onFinish(); + if (revisionDraft) { + await this.manager.deleteDraftNode(revisionDraft.nodeUid); } - - this.encryptedThumbnails.delete(thumbnailToken.type); - - const uploadKey = `thumbnail:${thumbnailToken.type}`; - this.ongoingUploads.set(uploadKey, { - uploadPromise: this.uploadThumbnail( - thumbnailToken, - encryptedThumbnail, - onProgress, - ).finally(() => { - this.ongoingUploads.delete(uploadKey); - - // Help the garbage collector to clean up the memory. - encryptedThumbnail = undefined; - }), - encryptedBlock: encryptedThumbnail, - }); + void this.telemetry.uploadInitFailed(this.parentFolderUid, error, this.metadata.expectedSize); + throw error; } - for (const blockToken of uploadTokens.blockTokens) { - let encryptedBlock = this.encryptedBlocks.get(blockToken.index); - if (!encryptedBlock) { - throw new Error(`Block ${blockToken.index} not found`); - } - - this.encryptedBlocks.delete(blockToken.index); - - const uploadKey = `block:${blockToken.index}`; - this.ongoingUploads.set(uploadKey, { - uploadPromise: this.uploadBlock( - blockToken, - encryptedBlock, - onProgress, - ).finally(() => { - this.ongoingUploads.delete(uploadKey); - - // Help the garbage collector to clean up the memory. - encryptedBlock = undefined; - }), - encryptedBlock, - }); + return { + revisionDraft, + blockVerifier, } } - private async uploadThumbnail( - uploadToken: { bareUrl: string, token: string }, - encryptedThumbnail: EncryptedThumbnail, - onProgress?: (uploadedBytes: number) => void, - ) { - const logger = new LoggerWithPrefix(this.logger, `thubmnail ${uploadToken.token}`); - logger.info(`Upload started`); - - let blockProgress = 0; - let attempt = 0; - - while (true) { - attempt++; - try { - logger.debug(`Uploading`); - await this.apiService.uploadBlock( - uploadToken.bareUrl, - uploadToken.token, - encryptedThumbnail.encryptedData, - (uploadedBytes) => { - blockProgress += uploadedBytes; - onProgress?.(uploadedBytes); - }, - this.abortController.signal, - ) - this.uploadedThumbnails.push({ - type: encryptedThumbnail.type, - hash: encryptedThumbnail.hash, - encryptedSize: encryptedThumbnail.encryptedSize, - originalSize: encryptedThumbnail.originalSize, - }) - break; - } catch (error: unknown) { - if (blockProgress !== 0) { - onProgress?.(-blockProgress); - blockProgress = 0; - } - - // Note: We don't handle token expiration for thumbnails, because - // the API requires the thumbnails to be requested with the first - // upload block request. Thumbnails are tiny, so this edge case - // should be very rare and considering it is the beginning of the - // upload, the whole retry is cheap. - - // Upload can fail for various reasons, for example integrity - // can fail due to bitflips. We want to retry and solve the issue - // seamlessly for the user. We retry only once, because we don't - // want to get stuck in a loop. - if (attempt <= MAX_BLOCK_UPLOAD_RETRIES) { - logger.warn(`Upload failed #${attempt}, retrying: ${getErrorMessage(error)}`); - continue; - } - - logger.error(`Upload failed`, error); - await this.abortUpload(error); - throw error; - } - } - - logger.info(`Uploaded`); + async getAvailableName(): Promise { + const availableName = await this.manager.findAvailableName(this.parentFolderUid, this.name); + return availableName; } +} - private async uploadBlock( - uploadToken: { index: number, bareUrl: string, token: string }, - encryptedBlock: EncryptedBlock, - onProgress?: (uploadedBytes: number) => void, +/** + * Uploader implementation for a new file revision. + */ +export class FileRevisionUploader extends Uploader { + constructor( + telemetry: UploadTelemetry, + apiService: UploadAPIService, + cryptoService: UploadCryptoService, + manager: UploadManager, + private nodeUid: string, + metadata: UploadMetadata, + onFinish: () => void, + signal?: AbortSignal, ) { - const logger = new LoggerWithPrefix(this.logger, `block ${uploadToken.index}:${uploadToken.token}`); - logger.info(`Upload started`); + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); - let blockProgress = 0; - let attempt = 0; - - while (true) { - attempt++; - try { - logger.debug(`Uploading`); - await this.apiService.uploadBlock( - uploadToken.bareUrl, - uploadToken.token, - encryptedBlock.encryptedData, - (uploadedBytes) => { - blockProgress += uploadedBytes; - onProgress?.(uploadedBytes); - }, - this.abortController.signal, - ) - this.uploadedBlocks.push({ - index: encryptedBlock.index, - hash: encryptedBlock.hash, - encryptedSize: encryptedBlock.encryptedSize, - originalSize: encryptedBlock.originalSize, - }) - break; - } catch (error: unknown) { - if (blockProgress !== 0) { - onProgress?.(-blockProgress); - blockProgress = 0; - } - - if ( - (error instanceof APIHTTPError && error.statusCode === HTTPErrorCode.NOT_FOUND) || - (error instanceof NotFoundAPIError) - ) { - logger.warn(`Token expired, fetching new token and retrying`); - const uploadTokens = await this.apiService.requestBlockUpload( - this.revisionDraft.nodeRevisionUid, - this.revisionDraft.nodeKeys.signatureAddress.addressId, - { - contentBlocks: [{ - index: encryptedBlock.index, - encryptedSize: encryptedBlock.encryptedSize, - hash: encryptedBlock.hash, - armoredSignature: encryptedBlock.armoredSignature, - verificationToken: encryptedBlock.verificationToken, - }], - }, - ); - uploadToken = uploadTokens.blockTokens[0]; - continue; - } - - // Upload can fail for various reasons, for example integrity - // can fail due to bitflips. We want to retry and solve the issue - // seamlessly for the user. We retry only once, because we don't - // want to get stuck in a loop. - if (attempt <= MAX_BLOCK_UPLOAD_RETRIES) { - logger.warn(`Upload failed #${attempt}, retrying: ${getErrorMessage(error)}`); - continue; - } - - logger.error(`Upload failed`, error); - await this.abortUpload(error); - throw error; - } - } - - logger.info(`Uploaded`); + this.nodeUid = nodeUid; } - private async waitForBufferCapacity() { - if (this.encryptedBlocks.size >= MAX_BUFFERED_BLOCKS) { - await waitForCondition(() => this.encryptedBlocks.size < MAX_BUFFERED_BLOCKS); - } - } - - private async waitForUploadCapacityAndBufferedBlocks() { - while (this.ongoingUploads.size >= MAX_UPLOADING_BLOCKS) { - await Promise.race(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); - } - await waitForCondition(() => this.encryptedBlocks.size > 0 || this.encryptionFinished); - } + protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft, blockVerifier: BlockVerifier }> { + let revisionDraft, blockVerifier; + try { + revisionDraft = await this.manager.createDraftRevision(this.nodeUid, this.metadata); - private verifyIntegrity(thumbnails: Thumbnail[]) { - const expectedBlockCount = Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0); - if (this.uploadedBlockCount !== expectedBlockCount) { - throw new IntegrityError(c('Error').t`Some file parts failed to upload`, { - uploadedBlockCount: this.uploadedBlockCount, - expectedBlockCount, - }); - } - if (this.uploadedOriginalFileSize !== this.metadata.expectedSize) { - throw new IntegrityError(c('Error').t`Some file bytes failed to upload`, { - uploadedOriginalFileSize: this.uploadedOriginalFileSize, - expectedFileSize: this.metadata.expectedSize, - }); + blockVerifier = new BlockVerifier(this.apiService, this.cryptoService, revisionDraft.nodeKeys.key, revisionDraft.nodeRevisionUid); + await blockVerifier.loadVerificationData(); + } catch (error: unknown) { + this.onFinish(); + if (revisionDraft) { + await this.manager.deleteDraftRevision(revisionDraft.nodeRevisionUid); + } + void this.telemetry.uploadInitFailed(this.nodeUid, error, this.metadata.expectedSize); + throw error; } - } - - /** - * Check if the encryption is fully finished. - * This means that all blocks and thumbnails have been encrypted and - * requested to be uploaded, and there are no more blocks or thumbnails - * to encrypt and upload. - */ - private get isEncryptionFullyFinished(): boolean { - return this.encryptionFinished && this.encryptedBlocks.size === 0 && this.encryptedThumbnails.size === 0; - } - - private get uploadedBlockCount(): number { - return this.uploadedBlocks.length + this.uploadedThumbnails.length; - } - - private get uploadedOriginalFileSize(): number { - return this.uploadedBlocks.reduce((sum, { originalSize }) => sum + originalSize, 0); - } - - private get manifest(): Uint8Array { - this.uploadedThumbnails.sort((a, b) => a.type - b.type); - this.uploadedBlocks.sort((a, b) => a.index - b.index); - const hashes = [ - ...this.uploadedThumbnails.map(({ hash }) => hash), - ...this.uploadedBlocks.map(({ hash }) => hash), - ]; - return mergeUint8Arrays(hashes); - } - private async abortUpload(error: unknown) { - if (this.abortController.signal.aborted || this.signal?.aborted) { - return; + return { + revisionDraft, + blockVerifier, } - this.abortController.abort(error); } } diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 4df8c937..4456b6fa 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -3,12 +3,11 @@ import { DriveAPIService } from "../apiService"; import { DriveCrypto } from "../../crypto"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { UploadQueue } from "./queue"; +import { FileUploader, FileRevisionUploader } from "./fileUploader"; import { NodesService, NodesEvents, SharesService } from "./interface"; -import { Fileuploader } from "./fileUploader"; -import { UploadTelemetry } from "./telemetry"; import { UploadManager } from "./manager"; -import { BlockVerifier } from "./blockVerifier"; +import { UploadQueue } from "./queue"; +import { UploadTelemetry } from "./telemetry"; /** * Provides facade for the upload module. @@ -33,85 +32,62 @@ export function initUploadModule( const queue = new UploadQueue(); + /** + * Returns a FileUploader instance that can be used to upload a file to + * a parent folder. + * + * This operation does not call the API, it only returns a FileUploader + * instance when the upload queue has capacity. + */ async function getFileUploader( parentFolderUid: string, name: string, metadata: UploadMetadata, signal?: AbortSignal, - ): Promise { + ): Promise { await queue.waitForCapacity(signal); - let revisionDraft, blockVerifier; - try { - revisionDraft = await manager.createDraftNode(parentFolderUid, name, metadata); - - blockVerifier = new BlockVerifier(api, cryptoService, revisionDraft.nodeKeys.key, revisionDraft.nodeRevisionUid); - await blockVerifier.loadVerificationData(); - } catch (error: unknown) { - queue.releaseCapacity(); - if (revisionDraft) { - await manager.deleteDraftNode(revisionDraft.nodeUid); - } - void uploadTelemetry.uploadInitFailed(parentFolderUid, error, metadata.expectedSize); - throw error; - } - - const onFinish = async (failure: boolean) => { + const onFinish = () => { queue.releaseCapacity(); - if (failure) { - await manager.deleteDraftNode(revisionDraft.nodeUid); - } } - return new Fileuploader( + return new FileUploader( uploadTelemetry, api, cryptoService, manager, - blockVerifier, - revisionDraft, + parentFolderUid, + name, metadata, onFinish, signal, ); } + /** + * Returns a FileUploader instance that can be used to upload a new + * revision of a file. + * + * This operation does not call the API, it only returns a + * FileRevisionUploader instance when the upload queue has capacity. + */ async function getFileRevisionUploader( nodeUid: string, metadata: UploadMetadata, signal?: AbortSignal, - ): Promise { + ): Promise { await queue.waitForCapacity(signal); - let revisionDraft, blockVerifier; - try { - revisionDraft = await manager.createDraftRevision(nodeUid, metadata); - - blockVerifier = new BlockVerifier(api, cryptoService, revisionDraft.nodeKeys.key, revisionDraft.nodeRevisionUid); - await blockVerifier.loadVerificationData(); - } catch (error: unknown) { - queue.releaseCapacity(); - if (revisionDraft) { - await manager.deleteDraftRevision(revisionDraft.nodeRevisionUid); - } - void uploadTelemetry.uploadInitFailed(nodeUid, error, metadata.expectedSize); - throw error; - } - - const onFinish = async (failure: boolean) => { + const onFinish = () => { queue.releaseCapacity(); - if (failure) { - await manager.deleteDraftNode(revisionDraft.nodeUid); - } } - return new Fileuploader( + return new FileRevisionUploader( uploadTelemetry, api, cryptoService, manager, - blockVerifier, - revisionDraft, + nodeUid, metadata, onFinish, signal, diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index d2aad3d0..e5f8d14a 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -135,14 +135,18 @@ describe("UploadManager", () => { armoredContentKeyPacketSignature: "newNode:armoredContentKeyPacketSignature", signatureEmail: "signatureEmail", }); - expect(apiService.checkAvailableHashes).not.toHaveBeenCalled(); }); - it("should handle existing draft by deleting and trying again", async () => { - let hashChecked = false; + it("should delete existing draft and trying again", async () => { + let firstCall = true; apiService.createDraft = jest.fn().mockImplementation(() => { - if (!hashChecked) { - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS); + if (firstCall) { + firstCall = false; + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: "existingLinkId", + ConflictDraftRevisionID: "existingDraftRevisionId", + ConflictDraftClientUID: "existingDraftClientUid", + }); } return { nodeUid: "newNode:nodeUid", @@ -150,26 +154,8 @@ describe("UploadManager", () => { }; }); - apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { - if (!hashChecked) { - hashChecked = true; - return { - availalbleHashes: ["name1Hash"], - pendingHashes: [{ - hash: "newNode:hash", - nodeUid: "nodeUidToDelete" - }], - } - } - return { - availalbleHashes: ["name1Hash"], - pendingHashes: [], - } - }); + const result = await manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); - const result = await manager.createDraftNode("parentUid", "name", {} as UploadMetadata); - - expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); expect(result).toEqual({ nodeUid: "newNode:nodeUid", @@ -182,20 +168,25 @@ describe("UploadManager", () => { }, }, newNodeInfo: { - parentUid: "parentUid", + parentUid: "volumeId~parentUid", name: "name", encryptedName: "newNode:encryptedName", hash: "newNode:hash", }, }); - expect(apiService.deleteDraft).toHaveBeenCalledWith("nodeUidToDelete"); + expect(apiService.deleteDraft).toHaveBeenCalledWith("volumeId~existingLinkId"); }); it("should handle error when deleting existing draft", async () => { - let hashChecked = false; + let firstCall = true; apiService.createDraft = jest.fn().mockImplementation(() => { - if (!hashChecked) { - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS); + if (firstCall) { + firstCall = false; + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: "existingLinkId", + ConflictDraftRevisionID: "existingDraftRevisionId", + ConflictDraftClientUID: "existingDraftClientUid", + }); } return { nodeUid: "newNode:nodeUid", @@ -206,71 +197,38 @@ describe("UploadManager", () => { throw new Error("Failed to delete draft"); }); - apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { - if (!hashChecked) { - hashChecked = true; - return { - availalbleHashes: ["name1Hash"], - pendingHashes: [{ - hash: "newNode:hash", - nodeUid: "nodeUidToDelete" - }], - } - } - return { - availalbleHashes: ["name1Hash"], - pendingHashes: [], - } - }); - - const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); - - await expect(result).rejects.toThrow("Draft already exists"); - expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); - expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); - }); - - it("should handle existing name by providing available name", async () => { - let count = 0; - apiService.createDraft = jest.fn().mockImplementation(() => { - if (count === 0) { - count++; - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { ConflictLinkID: "existingLinkId" }); - } - return { - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", - }; - }); - const result = manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); - await expect(result).rejects.toThrow("Draft already exists"); - expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); - try { await result; } catch (error: any) { - expect(error.availableName).toBe("name1"); + expect(error.message).toBe("Draft already exists"); expect(error.existingNodeUid).toBe("volumeId~existingLinkId"); } + expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); }); + }); - it("should handle existing name by providing available name when there is too many conflicts", async () => { - let hashChecked = false; - apiService.createDraft = jest.fn().mockImplementation(() => { - if (!hashChecked) { - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS); - } + describe("findAvailableName", () => { + it("should find available name", async () => { + apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { return { - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", - }; + availalbleHashes: ["name3Hash"], + pendingHashes: [], + } }); + const result = await manager.findAvailableName("parentUid", "name"); + expect(result).toBe("name3"); + expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); + expect(apiService.checkAvailableHashes).toHaveBeenCalledWith("parentUid", ["name1Hash", "name2Hash", "name3Hash"]); + }); + + it("should find available name with multiple pages", async () => { + let firstCall = false; apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { - if (!hashChecked) { - hashChecked = true; + if (!firstCall) { + firstCall = true; return { // First page has no available hashes availalbleHashes: [], @@ -283,16 +241,10 @@ describe("UploadManager", () => { } }); - const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); - - await expect(result).rejects.toThrow("Draft already exists"); + const result = await manager.findAvailableName("parentUid", "name"); + expect(result).toBe("name3"); expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(2); - - try { - await result; - } catch (error: any) { - expect(error.availableName).toBe("name3"); - } + expect(apiService.checkAvailableHashes).toHaveBeenCalledWith("parentUid", ["name1Hash", "name2Hash", "name3Hash"]); }); }); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index e870893f..a78e6866 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -96,23 +96,26 @@ export class UploadManager { if (error instanceof ValidationError) { if (error.code === ErrorCode.ALREADY_EXISTS) { this.logger.info(`Node with given name already exists`); - const availableName = await this.findAvailableName( - parentFolderUid, - parentHashKey, - name, - generatedNodeCrypto.encryptedNode.hash, - ); + + const typedDetails = error.details as { + ConflictLinkID: string, + ConflictRevisionID?: string, + ConflictDraftRevisionID?: string, + ConflictDraftClientUID?: string, + } | undefined; // If there is existing draft created by this client, // automatically delete it and try to create a new one // with the same name again. - if (availableName.existingDraftNodeUid) { + if (typedDetails?.ConflictDraftRevisionID) { + const existingDraftNodeUid = makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID); + let deleteFailed = false; try { - this.logger.warn(`Deleting existing draft node ${availableName.existingDraftNodeUid}`); - await this.apiService.deleteDraft(availableName.existingDraftNodeUid); + this.logger.warn(`Deleting existing draft node ${existingDraftNodeUid}`); + await this.apiService.deleteDraft(existingDraftNodeUid); } catch (deleteDraftError: unknown) { - // Do not throw, let return the next available name to the client. + // Do not throw, let throw the conflict error. deleteFailed = true; this.logger.error('Failed to delete existing draft node', deleteDraftError); } @@ -121,7 +124,6 @@ export class UploadManager { } } - const typedDetails = error.details as { ConflictLinkID: string } | undefined; const existingNodeUid = typedDetails ? makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID) : undefined; // If there is existing node, return special error @@ -129,7 +131,6 @@ export class UploadManager { throw new NodeAlreadyExistsValidationError( error.message, error.code, - availableName.availableName, existingNodeUid, ); } @@ -138,10 +139,12 @@ export class UploadManager { } } - private async findAvailableName(parentFolderUid: string, parentHashKey: Uint8Array, name: string, nameHash: string): Promise<{ - availableName: string, - existingDraftNodeUid?: string, - }> { + async findAvailableName(parentFolderUid: string, name: string): Promise { + const { hashKey: parentHashKey } = await this.nodesService.getNodeKeys(parentFolderUid); + if (!parentHashKey) { + throw new ValidationError(c('Error').t`Creating files in non-folders is not allowed`); + } + const [namePart, extension] = splitExtension(name); const batchSize = 10; @@ -154,14 +157,9 @@ export class UploadManager { const hashesToCheck = await this.cryptoService.generateNameHashes(parentHashKey, namesToCheck); - const { pendingHashes, availalbleHashes } = await this.apiService.checkAvailableHashes( + const { availalbleHashes } = await this.apiService.checkAvailableHashes( parentFolderUid, - [ - ...hashesToCheck.map(({ hash }) => hash), - // Adding the current name hash to get the existing draft - // node UID if it exists. - ...startIndex ? [nameHash] : [], - ], + hashesToCheck.map(({ hash }) => hash), ); if (!availalbleHashes.length) { @@ -174,12 +172,7 @@ export class UploadManager { throw Error('Backend returned unexpected hash'); } - // FIXME: use client UID to ensure its own pending draft - const ownPendingHash = pendingHashes.find(({ hash }) => hash === nameHash); - return { - availableName: availableHash.name, - existingDraftNodeUid: ownPendingHash?.nodeUid, - } + return availableHash.name; } } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts new file mode 100644 index 00000000..c682923a --- /dev/null +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -0,0 +1,469 @@ +import { Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; +import { IntegrityError } from '../../errors'; +import { APIHTTPError, HTTPErrorCode } from '../apiService'; +import { FILE_CHUNK_SIZE, StreamUploader } from './streamUploader'; +import { UploadTelemetry } from './telemetry'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { UploadController } from './controller'; +import { BlockVerifier } from './blockVerifier'; +import { NodeRevisionDraft } from './interface'; +import { UploadManager } from './manager'; + +const BLOCK_ENCRYPTION_OVERHEAD = 10000; + +async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise, _: any, block: Uint8Array, index: number) { + await verifyBlock(block); + return { + index, + encryptedData: block, + armoredSignature: 'signature', + verificationToken: 'verificationToken', + originalSize: block.length, + encryptedSize: block.length + BLOCK_ENCRYPTION_OVERHEAD, + hash: 'blockHash', + }; +} + +function mockUploadBlock(_: string, __: string, encryptedBlock: Uint8Array, onProgress: (uploadedBytes: number) => void) { + onProgress(encryptedBlock.length); +} + +describe('StreamUploader', () => { + let telemetry: UploadTelemetry; + let apiService: jest.Mocked; + let cryptoService: UploadCryptoService; + let uploadManager: UploadManager; + let blockVerifier: BlockVerifier; + let revisionDraft: NodeRevisionDraft; + let metadata: UploadMetadata; + let controller: UploadController; + let onFinish: () => Promise; + let abortController: AbortController; + + let uploader: StreamUploader; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + telemetry = { + getLoggerForRevision: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + logBlockVerificationError: jest.fn(), + uploadFailed: jest.fn(), + uploadFinished: jest.fn(), + }; + + // @ts-expect-error No need to implement all methods for mocking + apiService = { + requestBlockUpload: jest.fn().mockImplementation((_, __, blocks) => ({ + blockTokens: blocks.contentBlocks.map((block: { index: number }) => ({ + index: block.index, + bareUrl: `bareUrl/block:${block.index}`, + token: `token/block:${block.index}`, + })), + thumbnailTokens: (blocks.thumbnails || []).map((thumbnail: { type: number }) => ({ + type: thumbnail.type, + bareUrl: `bareUrl/thumbnail:${thumbnail.type}`, + token: `token/thumbnail:${thumbnail.type}`, + })), + })), + uploadBlock: jest.fn().mockImplementation(mockUploadBlock), + }; + + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + encryptThumbnail: jest.fn().mockImplementation(async (_, thumbnail) => ({ + type: thumbnail.type, + encryptedData: thumbnail.thumbnail, + originalSize: thumbnail.thumbnail.length, + encryptedSize: thumbnail.thumbnail + 1000, + hash: 'thumbnailHash', + })), + encryptBlock: jest.fn().mockImplementation(mockEncryptBlock), + }; + + // @ts-expect-error No need to implement all methods for mocking + uploadManager = { + commitDraft: jest.fn().mockResolvedValue(undefined), + }; + + // @ts-expect-error No need to implement all methods for mocking + blockVerifier = { + verifyBlock: jest.fn().mockResolvedValue(undefined), + }; + + revisionDraft = { + nodeRevisionUid: 'revisionUid', + nodeKeys: { + signatureAddress: { addressId: 'addressId' }, + }, + } as NodeRevisionDraft; + + metadata = { + // 3 blocks: 4 + 4 + 2 MB + expectedSize: 10 * 1024 * 1024, + } as UploadMetadata; + + controller = new UploadController(); + onFinish = jest.fn(); + abortController = new AbortController(); + + uploader = new StreamUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + blockVerifier, + revisionDraft, + metadata, + onFinish, + abortController.signal, + ); + }); + + describe('start', () => { + let thumbnails: Thumbnail[]; + let thumbnailSize: number; + + let onProgress: (uploadedBytes: number) => void; + let stream: ReadableStream; + + const verifySuccess = async () => { + await uploader.start(stream, thumbnails, onProgress); + + const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); + expect(uploadManager.commitDraft).toHaveBeenCalledTimes(1); + expect(uploadManager.commitDraft).toHaveBeenCalledWith( + revisionDraft, + expect.anything(), + metadata, + { + size: metadata.expectedSize, + blockSizes: metadata.expectedSize ? [ + ...Array(numberOfExpectedBlocks - 1).fill(FILE_CHUNK_SIZE), + metadata.expectedSize % FILE_CHUNK_SIZE + ] : [], + modificationTime: undefined, + digests: { + sha1: expect.anything(), + } + }, + metadata.expectedSize + numberOfExpectedBlocks * BLOCK_ENCRYPTION_OVERHEAD, + ); + expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); + expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); + expect(telemetry.uploadFailed).not.toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledWith(false); + }; + + const verifyFailure = async (error: string, uploadedBytes: number | undefined, expectedSize = metadata.expectedSize) => { + const promise = uploader.start(stream, thumbnails, onProgress); + await expect(promise).rejects.toThrow(error); + + expect(telemetry.uploadFinished).not.toHaveBeenCalled(); + expect(telemetry.uploadFailed).toHaveBeenCalledTimes(1); + expect(telemetry.uploadFailed).toHaveBeenCalledWith( + 'revisionUid', + new Error(error), + uploadedBytes === undefined ? expect.anything() : uploadedBytes, + expectedSize, + ); + expect(onFinish).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledWith(true); + }; + + const verifyOnProgress = async (uploadedBytes: number[]) => { + expect(onProgress).toHaveBeenCalledTimes(uploadedBytes.length); + for (let i = 0; i < uploadedBytes.length; i++) { + expect(onProgress).toHaveBeenNthCalledWith(i + 1, uploadedBytes[i]); + } + }; + + beforeEach(() => { + onProgress = jest.fn(); + thumbnails = [ + { + type: ThumbnailType.Type1, + thumbnail: new Uint8Array(1024), + } + ]; + thumbnailSize = thumbnails.reduce((acc, thumbnail) => acc + thumbnail.thumbnail.length, 0); + stream = new ReadableStream({ + start(controller) { + const chunkSize = 1024; + const chunkCount = metadata.expectedSize / chunkSize; + for (let i = 1; i <= chunkCount; i++) { + controller.enqueue(new Uint8Array(chunkSize)); + } + controller.close(); + }, + }); + }); + + it("should upload successfully", async () => { + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + expect(apiService.uploadBlock).toHaveBeenCalledTimes(4); // 3 blocks + 1 thumbnail + expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(3); // 3 blocks + expect(telemetry.logBlockVerificationError).not.toHaveBeenCalled(); + await verifyOnProgress([thumbnailSize, 4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024]); + }); + + it("should upload successfully empty file without thumbnail", async () => { + metadata = { + expectedSize: 0, + } as UploadMetadata; + stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + thumbnails = []; + thumbnailSize = 0; + uploader = new StreamUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + blockVerifier, + revisionDraft, + metadata, + onFinish, + ); + + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(0); + expect(apiService.uploadBlock).toHaveBeenCalledTimes(0); + expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(0); + await verifyOnProgress([]); + }); + + it("should upload successfully empty file with thumbnail", async () => { + metadata = { + expectedSize: 0, + } as UploadMetadata; + stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + uploader = new StreamUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + blockVerifier, + revisionDraft, + metadata, + onFinish, + ); + + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + expect(apiService.uploadBlock).toHaveBeenCalledTimes(1); + expect(blockVerifier.verifyBlock).toHaveBeenCalledTimes(0); + await verifyOnProgress([thumbnailSize]); + }); + + it('should handle failure when encrypting thumbnails', async () => { + cryptoService.encryptThumbnail = jest.fn().mockImplementation(async function () { + throw new Error('Failed to encrypt thumbnail'); + }); + + await verifyFailure('Failed to encrypt thumbnail', 0); + expect(cryptoService.encryptThumbnail).toHaveBeenCalledTimes(1); + }); + + it('should handle failure when encrypting block', async () => { + cryptoService.encryptBlock = jest.fn().mockImplementation(async function () { + throw new Error('Failed to encrypt block'); + }); + + // Encrypting thumbnails is before blocks, thus it can be uploaded before failure. + await verifyFailure('Failed to encrypt block', 1024); + // 1 block + 1 retry, others are skipped + expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(2); + }); + + it('should handle one time-off failure when encrypting block', async () => { + let count = 0; + cryptoService.encryptBlock = jest.fn().mockImplementation(async function (verifyBlock, keys, block, index) { + if (count === 0) { + count++; + throw new Error('Failed to encrypt block'); + } + return mockEncryptBlock(verifyBlock, keys, block, index); + }); + + await verifySuccess(); + // 1 block + 1 retry + 2 other blocks without retry + expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(4); + await verifyOnProgress([thumbnailSize, 4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024]); + }); + + it('should handle failure when requesting tokens', async () => { + apiService.requestBlockUpload = jest.fn().mockImplementation(async function () { + throw new Error('Failed to request tokens'); + }); + + await verifyFailure('Failed to request tokens', 0); + }); + + it('should handle failure when uploading thumbnail', async () => { + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/thumbnail:1') { + throw new Error('Failed to upload thumbnail'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + // 10 MB uploaded as blocks still uploaded + await verifyFailure('Failed to upload thumbnail', 10 * 1024 * 1024); + }); + + it('should handle one time-off failure when uploading thubmnail', async () => { + let count = 0; + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/thumbnail:1' && count === 0) { + count++; + throw new Error('Failed to upload thumbnail'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + // 3 blocks + 1 retry + 1 thumbnail + expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); + await verifyOnProgress([4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 1024]); + }); + + it('should handle failure when uploading block', async () => { + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:3') { + throw new Error('Failed to upload block'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + // ~8 MB uploaded as 2 first blocks + 1 thumbnail still uploaded + await verifyFailure('Failed to upload block', 8 * 1024 * 1024 + 1024); + }); + + it('should handle one time-off failure when uploading block', async () => { + let count = 0; + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:2' && count === 0) { + count++; + throw new Error('Failed to upload block'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + // 3 blocks + 1 retry + 1 thumbnail + expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); + await verifyOnProgress([1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024]); + }); + + it('should handle expired token when uploading block', async () => { + let count = 0; + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:2' && count === 0) { + count++; + throw new APIHTTPError('Expired token', HTTPErrorCode.NOT_FOUND); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + await verifySuccess(); + // 1 for first try + 1 for retry + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(2); + expect(apiService.requestBlockUpload).toHaveBeenCalledWith( + revisionDraft.nodeRevisionUid, + revisionDraft.nodeKeys.signatureAddress.addressId, + { + contentBlocks: [ + { + index: 2, + encryptedSize: 4 * 1024 * 1024 + 10000, + hash: 'blockHash', + armoredSignature: 'signature', + verificationToken: 'verificationToken', + } + ], + }, + ); + // 3 blocks + 1 retry + 1 thumbnail + expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); + await verifyOnProgress([1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024]); + }); + + it('should handle abortion', async () => { + const error = new Error('Aborted'); + const promise = uploader.start(stream, thumbnails, onProgress); + abortController.abort(error); + await promise; + expect(apiService.uploadBlock.mock.calls[0][4]?.aborted).toBe(true); + }); + + describe('verifyIntegrity', () => { + it('should report block verification error', async () => { + blockVerifier.verifyBlock = jest.fn().mockRejectedValue(new IntegrityError('Block verification error')); + await verifyFailure('Block verification error', 1024); + expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(false); + }); + + it('should report block verification error when retry helped', async () => { + blockVerifier.verifyBlock = jest.fn().mockRejectedValueOnce(new IntegrityError('Block verification error')).mockResolvedValue({ + verificationToken: new Uint8Array(), + }); + await verifySuccess(); + expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(true); + }); + + it('should throw an error if block count does not match', async () => { + uploader = new StreamUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + blockVerifier, + revisionDraft, + { + // Fake expected size to break verification + expectedSize: 1 * 1024 * 1024 + 1024, + mediaType: '', + }, + onFinish, + ); + + await verifyFailure( + 'Some file parts failed to upload', + 10 * 1024 * 1024 + 1024, + 1 * 1024 * 1024 + 1024, + ); + }); + + it('should throw an error if file size does not match', async () => { + cryptoService.encryptBlock = jest.fn().mockImplementation(async (_, __, block, index) => ({ + index, + encryptedData: block, + armoredSignature: 'signature', + verificationToken: 'verificationToken', + originalSize: 0, // Fake original size to break verification + encryptedSize: block.length + 10000, + hash: 'blockHash', + })); + + await verifyFailure('Some file bytes failed to upload', 10 * 1024 * 1024 + 1024); + }); + }); + }); +}); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts new file mode 100644 index 00000000..529d1f75 --- /dev/null +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -0,0 +1,552 @@ +import { c } from "ttag"; + +import { Thumbnail, Logger, ThumbnailType, UploadMetadata } from "../../interface"; +import { IntegrityError } from "../../errors"; +import { LoggerWithPrefix } from "../../telemetry"; +import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from "../apiService"; +import { getErrorMessage } from "../errors"; +import { mergeUint8Arrays } from "../utils"; +import { waitForCondition } from '../wait'; +import { UploadAPIService } from "./apiService"; +import { BlockVerifier } from "./blockVerifier"; +import { UploadController } from './controller'; +import { UploadCryptoService } from "./cryptoService"; +import { UploadDigests } from "./digests"; +import { NodeRevisionDraft, EncryptedBlock, EncryptedThumbnail, EncryptedBlockMetadata } from "./interface"; +import { UploadTelemetry } from './telemetry'; +import { ChunkStreamReader } from './chunkStreamReader'; +import { UploadManager } from "./manager"; + +/** + * File chunk size in bytes representing the size of each block. + */ +export const FILE_CHUNK_SIZE = 4 * 1024 * 1024; + +/** + * Maximum number of blocks that can be buffered before upload. + * This is to prevent using too much memory. + */ +const MAX_BUFFERED_BLOCKS = 15; + +/** + * Maximum number of blocks that can be uploaded at the same time. + * This is to prevent overloading the server with too many requests. + */ +const MAX_UPLOADING_BLOCKS = 5; + +/** + * Maximum number of retries for block encryption. + * This is to automatically retry random errors that can happen + * during encryption, for example bitflips. + */ +const MAX_BLOCK_ENCRYPTION_RETRIES = 1; + +/** + * Maximum number of retries for block upload. + * This is to ensure we don't end up in an infinite loop. + */ +const MAX_BLOCK_UPLOAD_RETRIES = 3; + +/** + * StreamUploader is responsible for uploading file content to the server. + * + * It handles the encryption of file blocks and thumbnails, as well as + * the upload process itself. It manages the upload queue and ensures + * that the upload process is efficient and does not overload the server. + */ +export class StreamUploader { + private logger: Logger; + + private digests: UploadDigests; + private controller: UploadController; + private abortController: AbortController; + + private encryptedThumbnails = new Map(); + private encryptedBlocks = new Map(); + private encryptionFinished = false; + + private ongoingUploads = new Map, + encryptedBlock: EncryptedBlock | EncryptedThumbnail, + }>(); + private uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = []; + private uploadedBlocks: ({ index: number } & EncryptedBlockMetadata)[] = []; + + constructor( + private telemetry: UploadTelemetry, + private apiService: UploadAPIService, + private cryptoService: UploadCryptoService, + private uploadManager: UploadManager, + private blockVerifier: BlockVerifier, + private revisionDraft: NodeRevisionDraft, + private metadata: UploadMetadata, + private onFinish: (failure: boolean) => Promise, + private signal?: AbortSignal, + ) { + this.telemetry = telemetry; + this.logger = telemetry.getLoggerForRevision(revisionDraft.nodeRevisionUid); + this.apiService = apiService; + this.cryptoService = cryptoService; + this.blockVerifier = blockVerifier; + this.revisionDraft = revisionDraft; + this.metadata = metadata; + this.onFinish = onFinish; + + this.signal = signal; + this.abortController = new AbortController(); + if (signal) { + signal.addEventListener('abort', () => { + this.abortController.abort(); + }); + } + + this.digests = new UploadDigests(); + this.controller = new UploadController(); + } + + async start(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { + let failure = false; + + // File progress is tracked for telemetry - to track at what + // point the download failed. + let fileProgress = 0; + + try { + this.logger.info(`Starting upload`); + await this.encryptAndUploadBlocks(stream, thumbnails, (uploadedBytes) => { + fileProgress += uploadedBytes; + onProgress?.(uploadedBytes); + }) + + this.logger.debug(`All blocks uploaded, committing`); + await this.commitFile(thumbnails); + + void this.telemetry.uploadFinished(this.revisionDraft.nodeRevisionUid, fileProgress); + this.logger.info(`Upload succeeded`); + } catch (error: unknown) { + failure = true; + this.logger.error(`Upload failed`, error); + void this.telemetry.uploadFailed(this.revisionDraft.nodeRevisionUid, error, fileProgress, this.metadata.expectedSize); + throw error; + } finally { + this.logger.debug(`Upload cleanup`); + + // Help the garbage collector to clean up the memory. + this.encryptedBlocks.clear(); + this.encryptedThumbnails.clear(); + this.ongoingUploads.clear(); + this.uploadedBlocks = []; + this.uploadedThumbnails = []; + this.encryptionFinished = false; + + await this.onFinish(failure); + } + + return this.revisionDraft.nodeRevisionUid; + } + + private async encryptAndUploadBlocks(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void) { + // We await for the encryption of thumbnails to finish before + // starting the upload. This is because we need to request the + // upload tokens for the thumbnails with the first blocks. + await this.encryptThumbnails(thumbnails); + + // Encrypting blocks and uploading them is done in parallel. + // For that reason, we want to await for the encryption later. + // However, jest complains if encryptBlock rejects asynchronously. + // For that reason we handle manually to save error to the variable + // and throw if set after we await for the encryption. + let encryptionError; + const encryptBlocksPromise = this.encryptBlocks(stream).catch((error) => { + encryptionError = error; + void this.abortUpload(error); + }); + + while (!encryptionError) { + await this.controller.waitIfPaused(); + await this.waitForUploadCapacityAndBufferedBlocks(); + + if (this.isEncryptionFullyFinished) { + break; + } + + await this.requestAndInitiateUpload(onProgress); + + if (this.isEncryptionFullyFinished) { + break; + } + } + + this.logger.debug(`All blocks uploading, waiting for them to finish`); + // Technically this is finished as while-block above will break + // when encryption is finished. But in case of error there could + // be a race condition that would cause the encryptionError to + // not be set yet. + await encryptBlocksPromise; + if (encryptionError) { + throw encryptionError; + } + await Promise.all(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); + } + + private async commitFile(thumbnails: Thumbnail[]) { + this.verifyIntegrity(thumbnails); + + const uploadedBlocks = Array.from(this.uploadedBlocks.values()); + uploadedBlocks.sort((a, b) => a.index - b.index); + + const extendedAttributes = { + modificationTime: this.metadata.modificationTime, + size: this.metadata.expectedSize, + blockSizes: uploadedBlocks.map(block => block.originalSize), + digests: this.digests.digests(), + }; + const encryptedSize = uploadedBlocks.reduce((sum, block) => sum + block.encryptedSize, 0); + await this.uploadManager.commitDraft( + this.revisionDraft, + this.manifest, + this.metadata, + extendedAttributes, + encryptedSize, + ); + } + + private async encryptThumbnails(thumbnails: Thumbnail[]) { + if (new Set(thumbnails.map(({ type }) => type)).size !== thumbnails.length) { + throw new Error(`Duplicate thumbnail types`); + } + + for (const thumbnail of thumbnails) { + this.logger.debug(`Encrypting thumbnail ${thumbnail.type}`); + const encryptedThumbnail = await this.cryptoService.encryptThumbnail(this.revisionDraft.nodeKeys, thumbnail); + this.encryptedThumbnails.set(thumbnail.type, encryptedThumbnail); + } + } + + private async encryptBlocks(stream: ReadableStream) { + try { + let index = 0; + const reader = new ChunkStreamReader(stream, FILE_CHUNK_SIZE); + for await (const block of reader.iterateChunks()) { + index++; + + this.digests.update(block); + + await this.controller.waitIfPaused(); + await this.waitForBufferCapacity(); + + this.logger.debug(`Encrypting block ${index}`); + let attempt = 0; + let integrityError = false; + let encryptedBlock; + while (!encryptedBlock) { + attempt++; + + try { + encryptedBlock = await this.cryptoService.encryptBlock( + (encryptedBlock) => this.blockVerifier.verifyBlock(encryptedBlock), + this.revisionDraft.nodeKeys, + block, + index, + ); + if (integrityError) { + void this.telemetry.logBlockVerificationError(true); + } + } catch (error: unknown) { + if (error instanceof IntegrityError) { + integrityError = true; + } + + if (attempt <= MAX_BLOCK_ENCRYPTION_RETRIES) { + this.logger.warn(`Block encryption failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } + + this.logger.error(`Failed to encrypt block ${index}`, error); + if (integrityError) { + void this.telemetry.logBlockVerificationError(false); + } + throw error; + } + } + this.encryptedBlocks.set(index, encryptedBlock); + } + } finally { + this.encryptionFinished = true; + } + } + + private async requestAndInitiateUpload(onProgress?: (uploadedBytes: number) => void): Promise { + this.logger.info(`Requesting upload tokens for ${this.encryptedBlocks.size} blocks`); + const uploadTokens = await this.apiService.requestBlockUpload( + this.revisionDraft.nodeRevisionUid, + this.revisionDraft.nodeKeys.signatureAddress.addressId, + { + contentBlocks: Array.from(this.encryptedBlocks.values().map(block => ({ + index: block.index, + encryptedSize: block.encryptedSize, + hash: block.hash, + armoredSignature: block.armoredSignature, + verificationToken: block.verificationToken, + }))), + thumbnails: Array.from(this.encryptedThumbnails.values().map(block => ({ + type: block.type, + encryptedSize: block.encryptedSize, + hash: block.hash, + }))), + }, + ); + + for (const thumbnailToken of uploadTokens.thumbnailTokens) { + let encryptedThumbnail = this.encryptedThumbnails.get(thumbnailToken.type); + if (!encryptedThumbnail) { + throw new Error(`Thumbnail ${thumbnailToken.type} not found`); + } + + this.encryptedThumbnails.delete(thumbnailToken.type); + + const uploadKey = `thumbnail:${thumbnailToken.type}`; + this.ongoingUploads.set(uploadKey, { + uploadPromise: this.uploadThumbnail( + thumbnailToken, + encryptedThumbnail, + onProgress, + ).finally(() => { + this.ongoingUploads.delete(uploadKey); + + // Help the garbage collector to clean up the memory. + encryptedThumbnail = undefined; + }), + encryptedBlock: encryptedThumbnail, + }); + } + + for (const blockToken of uploadTokens.blockTokens) { + let encryptedBlock = this.encryptedBlocks.get(blockToken.index); + if (!encryptedBlock) { + throw new Error(`Block ${blockToken.index} not found`); + } + + this.encryptedBlocks.delete(blockToken.index); + + const uploadKey = `block:${blockToken.index}`; + this.ongoingUploads.set(uploadKey, { + uploadPromise: this.uploadBlock( + blockToken, + encryptedBlock, + onProgress, + ).finally(() => { + this.ongoingUploads.delete(uploadKey); + + // Help the garbage collector to clean up the memory. + encryptedBlock = undefined; + }), + encryptedBlock, + }); + } + } + + private async uploadThumbnail( + uploadToken: { bareUrl: string, token: string }, + encryptedThumbnail: EncryptedThumbnail, + onProgress?: (uploadedBytes: number) => void, + ) { + const logger = new LoggerWithPrefix(this.logger, `thumbnail type ${encryptedThumbnail.type} to ${uploadToken.token}`); + logger.info(`Upload started`); + + let blockProgress = 0; + let attempt = 0; + + while (true) { + attempt++; + try { + logger.debug(`Uploading`); + await this.apiService.uploadBlock( + uploadToken.bareUrl, + uploadToken.token, + encryptedThumbnail.encryptedData, + (uploadedBytes) => { + blockProgress += uploadedBytes; + onProgress?.(uploadedBytes); + }, + this.abortController.signal, + ) + this.uploadedThumbnails.push({ + type: encryptedThumbnail.type, + hash: encryptedThumbnail.hash, + encryptedSize: encryptedThumbnail.encryptedSize, + originalSize: encryptedThumbnail.originalSize, + }) + break; + } catch (error: unknown) { + if (blockProgress !== 0) { + onProgress?.(-blockProgress); + blockProgress = 0; + } + + // Note: We don't handle token expiration for thumbnails, because + // the API requires the thumbnails to be requested with the first + // upload block request. Thumbnails are tiny, so this edge case + // should be very rare and considering it is the beginning of the + // upload, the whole retry is cheap. + + // Upload can fail for various reasons, for example integrity + // can fail due to bitflips. We want to retry and solve the issue + // seamlessly for the user. We retry only once, because we don't + // want to get stuck in a loop. + if (attempt <= MAX_BLOCK_UPLOAD_RETRIES) { + logger.warn(`Upload failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } + + logger.error(`Upload failed`, error); + await this.abortUpload(error); + throw error; + } + } + + logger.info(`Uploaded`); + } + + private async uploadBlock( + uploadToken: { index: number, bareUrl: string, token: string }, + encryptedBlock: EncryptedBlock, + onProgress?: (uploadedBytes: number) => void, + ) { + const logger = new LoggerWithPrefix(this.logger, `block ${uploadToken.index}:${uploadToken.token}`); + logger.info(`Upload started`); + + let blockProgress = 0; + let attempt = 0; + + while (true) { + attempt++; + try { + logger.debug(`Uploading`); + await this.apiService.uploadBlock( + uploadToken.bareUrl, + uploadToken.token, + encryptedBlock.encryptedData, + (uploadedBytes) => { + blockProgress += uploadedBytes; + onProgress?.(uploadedBytes); + }, + this.abortController.signal, + ) + this.uploadedBlocks.push({ + index: encryptedBlock.index, + hash: encryptedBlock.hash, + encryptedSize: encryptedBlock.encryptedSize, + originalSize: encryptedBlock.originalSize, + }) + break; + } catch (error: unknown) { + if (blockProgress !== 0) { + onProgress?.(-blockProgress); + blockProgress = 0; + } + + if ( + (error instanceof APIHTTPError && error.statusCode === HTTPErrorCode.NOT_FOUND) || + (error instanceof NotFoundAPIError) + ) { + logger.warn(`Token expired, fetching new token and retrying`); + const uploadTokens = await this.apiService.requestBlockUpload( + this.revisionDraft.nodeRevisionUid, + this.revisionDraft.nodeKeys.signatureAddress.addressId, + { + contentBlocks: [{ + index: encryptedBlock.index, + encryptedSize: encryptedBlock.encryptedSize, + hash: encryptedBlock.hash, + armoredSignature: encryptedBlock.armoredSignature, + verificationToken: encryptedBlock.verificationToken, + }], + }, + ); + uploadToken = uploadTokens.blockTokens[0]; + continue; + } + + // Upload can fail for various reasons, for example integrity + // can fail due to bitflips. We want to retry and solve the issue + // seamlessly for the user. We retry only once, because we don't + // want to get stuck in a loop. + if (attempt <= MAX_BLOCK_UPLOAD_RETRIES) { + logger.warn(`Upload failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } + + logger.error(`Upload failed`, error); + await this.abortUpload(error); + throw error; + } + } + + logger.info(`Uploaded`); + } + + private async waitForBufferCapacity() { + if (this.encryptedBlocks.size >= MAX_BUFFERED_BLOCKS) { + await waitForCondition(() => this.encryptedBlocks.size < MAX_BUFFERED_BLOCKS); + } + } + + private async waitForUploadCapacityAndBufferedBlocks() { + while (this.ongoingUploads.size >= MAX_UPLOADING_BLOCKS) { + await Promise.race(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); + } + await waitForCondition(() => this.encryptedBlocks.size > 0 || this.encryptionFinished); + } + + private verifyIntegrity(thumbnails: Thumbnail[]) { + const expectedBlockCount = Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0); + if (this.uploadedBlockCount !== expectedBlockCount) { + throw new IntegrityError(c('Error').t`Some file parts failed to upload`, { + uploadedBlockCount: this.uploadedBlockCount, + expectedBlockCount, + }); + } + if (this.uploadedOriginalFileSize !== this.metadata.expectedSize) { + throw new IntegrityError(c('Error').t`Some file bytes failed to upload`, { + uploadedOriginalFileSize: this.uploadedOriginalFileSize, + expectedFileSize: this.metadata.expectedSize, + }); + } + } + + /** + * Check if the encryption is fully finished. + * This means that all blocks and thumbnails have been encrypted and + * requested to be uploaded, and there are no more blocks or thumbnails + * to encrypt and upload. + */ + private get isEncryptionFullyFinished(): boolean { + return this.encryptionFinished && this.encryptedBlocks.size === 0 && this.encryptedThumbnails.size === 0; + } + + private get uploadedBlockCount(): number { + return this.uploadedBlocks.length + this.uploadedThumbnails.length; + } + + private get uploadedOriginalFileSize(): number { + return this.uploadedBlocks.reduce((sum, { originalSize }) => sum + originalSize, 0); + } + + private get manifest(): Uint8Array { + this.uploadedThumbnails.sort((a, b) => a.type - b.type); + this.uploadedBlocks.sort((a, b) => a.index - b.index); + const hashes = [ + ...this.uploadedThumbnails.map(({ hash }) => hash), + ...this.uploadedBlocks.map(({ hash }) => hash), + ]; + return mergeUint8Arrays(hashes); + } + + private async abortUpload(error: unknown) { + if (this.abortController.signal.aborted || this.signal?.aborted) { + return; + } + this.abortController.abort(error); + } +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index f6687ef8..1f677170 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -19,7 +19,8 @@ import { DeviceOrUid, UploadMetadata, FileDownloader, - Fileuploader, + FileUploader, + FileRevisionUploader, ThumbnailType, ThumbnailResult, SDKEvent, @@ -815,7 +816,7 @@ export class ProtonDriveClient { * * ```typescript * const uploader = await client.getFileUploader(parentFolderUid, name, metadata, signal); - * const uploadController = uploader.writeStream(stream, thumbnails, (uploadedBytes) => { ... }); + * const uploadController = await uploader.writeStream(stream, thumbnails, (uploadedBytes) => { ... }); * * signalController.abort(); // to cancel * uploadController.pause(); // to pause @@ -823,7 +824,7 @@ export class ProtonDriveClient { * const nodeUid = await uploadController.completion(); // to await completion * ``` */ - async getFileUploader(parentFolderUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal): Promise { + async getFileUploader(parentFolderUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal): Promise { this.logger.info(`Getting file uploader for parent ${getUid(parentFolderUid)}`); return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } @@ -831,7 +832,7 @@ export class ProtonDriveClient { /** * Same as `getFileUploader`, but for a uploading new revision of the file. */ - async getFileRevisionUploader(nodeUid: NodeOrUid, metadata: UploadMetadata, signal?: AbortSignal): Promise { + async getFileRevisionUploader(nodeUid: NodeOrUid, metadata: UploadMetadata, signal?: AbortSignal): Promise { this.logger.info(`Getting file revision uploader for ${getUid(nodeUid)}`); return this.upload.getFileRevisionUploader(getUid(nodeUid), metadata, signal); } From da91be652efe3565c0ecdf45d95957b44d2bc219 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 16 Jul 2025 11:26:28 +0000 Subject: [PATCH 161/791] Fix test of asyncIteratorMap --- js/sdk/src/internal/asyncIteratorMap.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/internal/asyncIteratorMap.test.ts b/js/sdk/src/internal/asyncIteratorMap.test.ts index 9b405523..b81417df 100644 --- a/js/sdk/src/internal/asyncIteratorMap.test.ts +++ b/js/sdk/src/internal/asyncIteratorMap.test.ts @@ -63,7 +63,7 @@ describe('asyncIteratorMap', () => { // Should complete in roughly the time of the longest delay (200ms) plus some overhead const executionTime = endTime - startTime; - expect(executionTime).toBeGreaterThanOrEqual(200); + expect(executionTime).toBeGreaterThanOrEqual(195); // We had failures with 199ms - JS is not precise. expect(executionTime).toBeLessThan(250); // Results should be in the order of the delays From 9357e056889f455b05980053974ae99482adeb51 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 18 Jul 2025 10:03:18 +0000 Subject: [PATCH 162/791] Add album node type --- js/sdk/src/interface/nodes.ts | 11 ++++++ .../src/internal/apiService/transformers.ts | 2 + js/sdk/src/internal/nodes/apiService.test.ts | 28 ++++++++++++++ js/sdk/src/internal/nodes/apiService.ts | 9 +++++ .../src/internal/nodes/cryptoService.test.ts | 38 +++++++++++++++++++ js/sdk/src/internal/nodes/interface.ts | 5 ++- 6 files changed, 92 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 4d1d5a1e..70234140 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -135,6 +135,17 @@ export type InvalidNameError = { export enum NodeType { File = "file", Folder = "folder", + /** + * Album is a special type available only in Photos section. + * + * The SDK does not support any album-specific actions, but it can load + * the node and do general operations on it, such as sharing. However, + * you should not rely that anything can work. It is not guaranteed that + * and in the future specific Photos SDK will support albums. + * + * @deprecated This type is not part of the public API. + */ + Album = "album", } export enum MemberRole { diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts index dc0518ff..644bfbb8 100644 --- a/js/sdk/src/internal/apiService/transformers.ts +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -6,6 +6,8 @@ export function nodeTypeNumberToNodeType(logger: Logger, nodeTypeNumber: number) return NodeType.Folder; case 2: return NodeType.File; + case 3: + return NodeType.Album; default: logger.warn(`Unknown node type: ${nodeTypeNumber}`); return NodeType.File; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 2d160d38..e2d1c1c8 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -44,6 +44,18 @@ function generateAPIFolderNode(linkOverrides = {}, overrides = {}) { }; } +function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) { + const node = generateAPINode(); + return { + Link: { + ...node.Link, + Type: 3, + ...linkOverrides, + }, + ...overrides, + }; +} + function generateAPINode() { return { Link: { @@ -107,6 +119,15 @@ function generateFolderNode(overrides = {}) { } } +function generateAlbumNode(overrides = {}) { + const node = generateNode(); + return { + ...node, + type: NodeType.Album, + ...overrides + } +} + function generateNode() { return { hash: "nameHash", @@ -180,6 +201,13 @@ describe("nodeAPIService", () => { ); }); + it('should get album node', async () => { + await testIterateNodes( + generateAPIAlbumNode(), + generateAlbumNode(), + ); + }); + it('should get shared node', async () => { await testIterateNodes( generateAPIFolderNode({}, { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index d5bfe61b..05463141 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -428,6 +428,15 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin } } + if (link.Link.Type === 3) { + return { + ...baseNodeMetadata, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + }, + } + } + throw new Error(`Unknown node type: ${link.Link.Type}`); } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index c35e168f..c0eb29f7 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -578,6 +578,44 @@ describe("nodesCryptoService", () => { }); }); + describe("album node", () => { + const encryptedNode = { + uid: "volumeId~nodeId", + parentUid: "volumeId~parentId", + encryptedCrypto: { + signatureEmail: "signatureEmail", + nameSignatureEmail: "nameSignatureEmail", + armoredKey: "armoredKey", + armoredNodePassphrase: "armoredNodePassphrase", + armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + }, + } as EncryptedNode; + + it("should decrypt successfuly", async () => { + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + + expect(result).toMatchObject({ + node: { + name: { ok: true, value: "name" }, + keyAuthor: { ok: true, value: "signatureEmail" }, + nameAuthor: { ok: true, value: "nameSignatureEmail" }, + folder: undefined, + activeRevision: undefined, + errors: undefined, + }, + keys: { + passphrase: "pass", + key: "decryptedKey", + passphraseSessionKey: "passphraseSessionKey", + hashKey: new Uint8Array(), + } + }); + + expect(account.getPublicKeys).toHaveBeenCalledTimes(2); + expect(telemetry.logEvent).not.toHaveBeenCalled(); + }); + }); + describe("anonymous node", () => { const encryptedNode = { uid: "volumeId~nodeId", diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index fd1522e4..83ad8a6a 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -30,7 +30,7 @@ interface BaseNode { * Outside of the module, the decrypted node interface should be used. */ export interface EncryptedNode extends BaseNode { - encryptedCrypto: EncryptedNodeFolderCrypto | EncryptedNodeFileCrypto; + encryptedCrypto: EncryptedNodeFolderCrypto | EncryptedNodeFileCrypto | EncryptedNodeAlbumCrypto; } export interface EncryptedNodeCrypto { @@ -56,6 +56,9 @@ export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { }; } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface EncryptedNodeAlbumCrypto extends EncryptedNodeCrypto {} + /** * Interface used only internally in the nodes module. * From 16e097a80ecb369c51a5b38f9849812f80de7cf0 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 18 Jul 2025 10:18:02 +0000 Subject: [PATCH 163/791] js/v0.0.13 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index f7e8d136..53488e85 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.0.12", + "version": "0.0.13", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From f09191d6b5325296863429a7e7e3ec9b678c5a30 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 22 Jul 2025 07:13:59 +0000 Subject: [PATCH 164/791] Add prettier --- js/sdk/package-lock.json | 8 ++++---- js/sdk/package.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index d01367cf..f306100e 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -25,7 +25,7 @@ "glob": "^11.0.3", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", - "prettier": "^3.4.2", + "prettier": "^3.6.2", "ttag-cli": "^1.10.18", "typedoc": "^0.26.11", "typescript": "^5.6.3" @@ -9325,9 +9325,9 @@ } }, "node_modules/prettier": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", - "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { diff --git a/js/sdk/package.json b/js/sdk/package.json index 53488e85..82f1c261 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -41,9 +41,9 @@ "glob": "^11.0.3", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", - "prettier": "^3.4.2", + "prettier": "^3.6.2", "ttag-cli": "^1.10.18", "typedoc": "^0.26.11", "typescript": "^5.6.3" } -} \ No newline at end of file +} From 3d13ee15b375cd1dee78625e318720865dfc0c77 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 22 Jul 2025 23:57:30 +0200 Subject: [PATCH 165/791] Support multiple volumes thumbnails --- js/sdk/src/internal/download/apiService.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index 39932d16..1dd44c1f 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -1,5 +1,3 @@ -import { c } from "ttag"; -import { ValidationError } from "../../errors"; import { DriveAPIService, drivePaths, ObserverStream } from "../apiService"; import { makeNodeThumbnailUid, splitNodeRevisionUid, splitNodeThumbnailUid } from "../uids"; import { BlockMetadata } from "./interface"; @@ -85,19 +83,22 @@ export class DownloadAPIService { return encryptedBlock; } - // Improvement requested: support multiple volumes. async* iterateThumbnails(thumbnailUids: string[], signal?: AbortSignal): AsyncGenerator< { uid: string, ok: true, bareUrl: string, token: string } | { uid: string, ok: false, error: string } > { - const thumbnailIds = thumbnailUids.map(splitNodeThumbnailUid); + const splitedThumbnailsIds = thumbnailUids.map(splitNodeThumbnailUid); - const uniqueVolumeIds = new Set(thumbnailIds.map(({ volumeId }) => volumeId)); - if (uniqueVolumeIds.size !== 1) { - throw new ValidationError(c('Error').t`Loading thumbnails from multiple sections is not allowed`); + const thumbnailIdsByVolumeId = new Map(); + for (const { volumeId, thumbnailId, nodeId } of splitedThumbnailsIds) { + if (!thumbnailIdsByVolumeId.has(volumeId)) { + thumbnailIdsByVolumeId.set(volumeId, []); + } + thumbnailIdsByVolumeId.get(volumeId)?.push({ volumeId, thumbnailId, nodeId }); } - const volumeId = thumbnailIds[0].volumeId; + + for (const [volumeId, thumbnailIds] of thumbnailIdsByVolumeId.entries()) { const result = await this.apiService.post( `drive/volumes/${volumeId}/thumbnails`, { @@ -130,6 +131,8 @@ export class DownloadAPIService { error: error.Error, }; } + + } } } From 2db8ab0da657f9edad74fcbb273af5992f73acc3 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 24 Jul 2025 13:54:32 +0000 Subject: [PATCH 166/791] Add NumAccess to publicLink --- js/sdk/package-lock.json | 4 ++-- js/sdk/src/interface/sharing.ts | 1 + js/sdk/src/internal/sharing/apiService.ts | 1 + js/sdk/src/internal/sharing/cryptoService.ts | 1 + js/sdk/src/internal/sharing/interface.ts | 1 + js/sdk/src/internal/sharing/sharingManagement.test.ts | 5 ++++- js/sdk/src/internal/sharing/sharingManagement.ts | 1 + 7 files changed, 11 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index f306100e..baa76e77 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.0.10", + "version": "0.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.0.10", + "version": "0.0.13", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 5868f632..844491c6 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -36,6 +36,7 @@ export type PublicLink = { url: string, customPassword?: string, expirationTime?: Date, + numberOfInitializedDownloads: number } /** diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index d5a698e5..566d58b5 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -391,6 +391,7 @@ export class SharingAPIService { flags: shareUrl.Flags, creatorEmail: shareUrl.CreatorEmail, publicUrl: shareUrl.PublicUrl, + numberOfInitializedDownloads: shareUrl.NumAccesses, armoredUrlPassword: shareUrl.Password, urlPasswordSalt: shareUrl.UrlPasswordSalt, base64SharePassphraseKeyPacket: shareUrl.SharePassphraseKeyPacket, diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 807e378a..9fa42211 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -347,6 +347,7 @@ export class SharingCryptoService { url: `${encryptedPublicLink.publicUrl}#${password}`, customPassword, creatorEmail: encryptedPublicLink.creatorEmail, + numberOfInitializedDownloads: encryptedPublicLink.numberOfInitializedDownloads } } diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 6590b1ff..6e9fec78 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -116,6 +116,7 @@ export interface EncryptedPublicLink { flags: number, creatorEmail: string, publicUrl: string, + numberOfInitializedDownloads: number; armoredUrlPassword: string, urlPasswordSalt: string, base64SharePassphraseKeyPacket: string, diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 17fe8488..1f7b4ff3 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -526,6 +526,7 @@ describe("SharingManagement", () => { expirationTime: undefined, customPassword: undefined, creatorEmail: "volume-email", + numberOfInitializedDownloads: 0, }, }); expect(cryptoService.generatePublicLinkPassword).toHaveBeenCalled(); @@ -563,6 +564,7 @@ describe("SharingManagement", () => { expirationTime: new Date('2025-01-02'), customPassword: "customPassword", creatorEmail: "volume-email", + numberOfInitializedDownloads: 0, }, }); expect(cryptoService.generatePublicLinkPassword).toHaveBeenCalled(); @@ -661,7 +663,7 @@ describe("SharingManagement", () => { }); }); - describe("unsahreNode", () => { + describe("unshareNode", () => { const nodeUid = "volumeId~nodeUid"; let invitation: ProtonInvitation; @@ -697,6 +699,7 @@ describe("SharingManagement", () => { creationTime: new Date(), role: MemberRole.Viewer, url: "url", + numberOfInitializedDownloads: 0, } apiService.getShareInvitations = jest.fn().mockResolvedValue([ diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index f85bfd38..1ba44a0f 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -486,6 +486,7 @@ export class SharingManagement { url: `${publicLink.publicUrl}#${generatedPassword}`, customPassword: options.customPassword, expirationTime: options.expiration, + numberOfInitializedDownloads: 0, creatorEmail, } } From 457b427c16f0732647b92d99fd5ff5cd192e0b30 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 24 Jul 2025 14:00:00 +0000 Subject: [PATCH 167/791] Fix move twice --- js/sdk/src/internal/nodes/nodesManagement.test.ts | 3 +++ js/sdk/src/internal/nodes/nodesManagement.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 9d006ed3..824b71e0 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -108,6 +108,7 @@ describe('NodesManagement', () => { expect(newNode).toEqual({ ...nodes.nodeUid, name: { ok: true, value: 'new name' }, + encryptedName: 'newArmoredNodeName', nameAuthor: { ok: true, value: 'newSignatureEmail' }, hash: 'newHash', }); @@ -142,6 +143,7 @@ describe('NodesManagement', () => { expect(newNode).toEqual({ ...nodes.nodeUid, parentUid: 'newParentNodeUid', + encryptedName: 'movedArmoredNodeName', hash: 'movedHash', keyAuthor: { ok: true, value: 'movedSignatureEmail' }, nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, @@ -202,6 +204,7 @@ describe('NodesManagement', () => { expect(newNode).toEqual({ ...nodes.anonymousNodeUid, parentUid: 'newParentNodeUid', + encryptedName: 'movedArmoredNodeName', hash: 'movedHash', keyAuthor: { ok: true, value: 'movedSignatureEmail' }, nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index c3fdca16..d47c29e6 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -74,6 +74,7 @@ export class NodesManagement { const newNode: DecryptedNode = { ...node, name: resultOk(newName), + encryptedName: armoredNodeName, nameAuthor: resultOk(signatureEmail), hash, } @@ -154,6 +155,7 @@ export class NodesManagement { ); const newNode: DecryptedNode = { ...node, + encryptedName: encryptedCrypto.encryptedName, parentUid: newParentUid, hash: encryptedCrypto.hash, keyAuthor: resultOk(encryptedCrypto.signatureEmail), From 44adcbb6dbe6bf455b76b5974b840e068bb44378 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 24 Jul 2025 14:03:20 +0000 Subject: [PATCH 168/791] Add integration test for moving node --- js/sdk/src/internal/nodes/cryptoService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 5e60559f..f87be457 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -422,7 +422,7 @@ export class NodesCryptoService { }; async moveNode( - node: DecryptedNode, + node: Pick, keys: { passphrase: string, passphraseSessionKey: SessionKey, nameSessionKey: SessionKey }, parentKeys: { key: PrivateKey, hashKey: Uint8Array }, address: { email: string, addressKey: PrivateKey }, From 250a6107578585280e3b907ac9fdc3a6d8e327fe Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 28 Jul 2025 11:35:19 +0000 Subject: [PATCH 169/791] Add support of client UID --- js/sdk/src/config.ts | 19 +++++- js/sdk/src/crypto/openPGPCrypto.ts | 2 + js/sdk/src/errors.ts | 5 +- js/sdk/src/interface/config.ts | 28 +++++++++ js/sdk/src/interface/httpClient.ts | 16 ----- js/sdk/src/interface/index.ts | 6 +- js/sdk/src/interface/upload.ts | 6 ++ js/sdk/src/internal/upload/apiService.ts | 11 ++-- js/sdk/src/internal/upload/index.ts | 5 +- js/sdk/src/internal/upload/manager.test.ts | 69 ++++++++++++++++++++-- js/sdk/src/internal/upload/manager.ts | 20 ++++++- js/sdk/src/protonDriveClient.ts | 2 +- 12 files changed, 152 insertions(+), 37 deletions(-) create mode 100644 js/sdk/src/interface/config.ts diff --git a/js/sdk/src/config.ts b/js/sdk/src/config.ts index e622a034..5c661351 100644 --- a/js/sdk/src/config.ts +++ b/js/sdk/src/config.ts @@ -1,9 +1,24 @@ import { ProtonDriveConfig } from './interface'; -export function getConfig(config?: ProtonDriveConfig): Required { +/** + * Parsed configuration of `ProtonDriveConfig`. + * + * The object should be almost identical to the original config, but making + * some fields required (setting reasonable defaults for the missing fields), + * or changed for easier usage inside of the SDK. + * + * For more property details, see the original config declaration. + */ +type ParsedProtonDriveConfig = { + baseUrl: string, + language: string, + clientUid?: string, +} + +export function getConfig(config?: ProtonDriveConfig): ParsedProtonDriveConfig { return { - ...config, baseUrl: config?.baseUrl ? `https://${config.baseUrl}` : 'https://drive-api.proton.me', language: config?.language || 'en', + clientUid: config?.clientUid, }; } diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 80c075d7..af47bcb1 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -71,6 +71,8 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { generatePassphrase(): string { const value = crypto.getRandomValues(new Uint8Array(32)); + // TODO: Once all clients can use non-ascii bytes, switch to simple + // generating of random bytes without encoding it into base64. return uint8ArrayToBase64String(value); } diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index bcc0ddcb..a6b1c571 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -75,9 +75,12 @@ export class NodeAlreadyExistsValidationError extends ValidationError { public readonly existingNodeUid?: string; - constructor(message: string, code: number, existingNodeUid?: string) { + public readonly ongoingUploadByOtherClient: boolean; + + constructor(message: string, code: number, existingNodeUid?: string, ongoingUploadByOtherClient = false) { super(message, code); this.existingNodeUid = existingNodeUid; + this.ongoingUploadByOtherClient = ongoingUploadByOtherClient; } } diff --git a/js/sdk/src/interface/config.ts b/js/sdk/src/interface/config.ts new file mode 100644 index 00000000..42c0590b --- /dev/null +++ b/js/sdk/src/interface/config.ts @@ -0,0 +1,28 @@ +export type ProtonDriveConfig = { + /** + * The base URL for the Proton Drive (without schema). + * + * If not provided, defaults to 'drive-api.proton.me'. + */ + baseUrl?: string, + + /** + * The language to use for error messages. + * + * If not provided, defaults to 'en'. + */ + language?: string, + + /** + * Client UID is used to identify the client for the upload. + * + * If the upload failed because of the existing draft, the SDK will + * automatically clean up the existing draft and start a new upload. + * If the client UID doesn't match, the SDK throws and then you need + * to explicitely ask the user to override the existing draft. + * + * You can force the upload by setting up + * `overrideExistingDraftByOtherClient` to true. + */ + clientUid?: string, +} diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts index d433ad12..d88db7d2 100644 --- a/js/sdk/src/interface/httpClient.ts +++ b/js/sdk/src/interface/httpClient.ts @@ -24,19 +24,3 @@ type ProtonDriveHTTPClientBaseOptions = { timeoutMs: number, signal?: AbortSignal, } - -export type ProtonDriveConfig = { - /** - * The base URL for the Proton Drive (without schema). - * - * If not provided, defaults to 'drive-api.proton.me'. - */ - baseUrl?: string, - - /** - * The language to use for error messages. - * - * If not provided, defaults to 'en'. - */ - language?: string, -} diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 085ea098..7e8e19ea 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -1,19 +1,21 @@ import { ProtonDriveCache } from '../cache'; import { OpenPGPCrypto, PrivateKey, SessionKey, SRPModule } from '../crypto'; import { ProtonDriveAccount } from './account'; -import { ProtonDriveHTTPClient, ProtonDriveConfig } from './httpClient'; +import { ProtonDriveConfig } from './config'; +import { ProtonDriveHTTPClient } from './httpClient'; import { Telemetry, MetricEvent } from './telemetry'; export type { Result } from './result'; export { resultOk, resultError } from './result'; export type { ProtonDriveAccount, ProtonDriveAccountAddress } from './account'; export type { Author, UnverifiedAuthorError, AnonymousUser } from './author'; +export type { ProtonDriveConfig } from './config'; export type { Device, DeviceOrUid } from './devices'; export { DeviceType } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { NodeEvent, DeviceEvent, DeviceEventCallback, NodeEventCallback } from './events'; export { SDKEvent } from './events'; -export type { ProtonDriveHTTPClient, ProtonDriveHTTPClientJsonOptions, ProtonDriveHTTPClientBlobOptions, ProtonDriveConfig } from './httpClient'; +export type { ProtonDriveHTTPClient, ProtonDriveHTTPClientJsonOptions, ProtonDriveHTTPClientBlobOptions } from './httpClient'; export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, MaybeBookmark, Bookmark, DegradedBookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, SharePublicLinkSettingsObject, ShareResult } from './sharing'; diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index 5bd032cb..8cca7b4f 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -24,6 +24,12 @@ export type UploadMetadata = { * The metadata will be encrypted and stored with the file. */ additionalMetadata?: object, + /** + * If there is an existing draft by another client, the upload will be + * rejected. If user decides to override the existing draft and continue + * with the upload, set this to true. + */ + overrideExistingDraftByOtherClient?: boolean, }; export interface FileRevisionUploader { diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 283a4294..494ab9f5 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -27,8 +27,9 @@ type PostDeleteNodesRequest = Extract(`drive/v2/volumes/${volumeId}/links/${parentNodeId}/checkAvailableHashes`, { Hashes: hashes, - ClientUID: null, + ClientUID: this.clientUid ? [this.clientUid] : null, }); return { @@ -64,7 +65,6 @@ export class UploadAPIService { armoredEncryptedName: string, hash: string, mediaType: string, - clientUID?: string, intendedUploadSize?: number, armoredNodeKey: string, armoredNodePassphrase: string, @@ -85,7 +85,7 @@ export class UploadAPIService { Name: node.armoredEncryptedName, Hash: node.hash, MIMEType: node.mediaType, - ClientUID: node.clientUID || null, + ClientUID: this.clientUid || null, IntendedUploadSize: node.intendedUploadSize || null, NodeKey: node.armoredNodeKey, NodePassphrase: node.armoredNodePassphrase, @@ -103,7 +103,6 @@ export class UploadAPIService { async createDraftRevision(nodeUid: string, revision: { currentRevisionUid: string, - clientUID?: string, intendedUploadSize?: number, }): Promise<{ nodeRevisionUid: string, @@ -116,7 +115,7 @@ export class UploadAPIService { PostCreateDraftRevisionResponse >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, { CurrentRevisionID: currentRevisionId, - ClientUID: revision.clientUID || null, + ClientUID: this.clientUid || null, IntendedUploadSize: revision.intendedUploadSize || null, }); diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 4456b6fa..fa5e0a13 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -23,12 +23,13 @@ export function initUploadModule( sharesService: SharesService, nodesService: NodesService, nodesEvents: NodesEvents, + clientUid?: string, ) { - const api = new UploadAPIService(apiService); + const api = new UploadAPIService(apiService, clientUid); const cryptoService = new UploadCryptoService(driveCrypto, nodesService); const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); - const manager = new UploadManager(telemetry, api, cryptoService, nodesService, nodesEvents); + const manager = new UploadManager(telemetry, api, cryptoService, nodesService, nodesEvents, clientUid); const queue = new UploadQueue(); diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index e5f8d14a..422ed17f 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -16,6 +16,8 @@ describe("UploadManager", () => { let manager: UploadManager; + const clientUid = 'clientUid'; + beforeEach(() => { telemetry = getMockTelemetry(); // @ts-expect-error No need to implement all methods for mocking @@ -89,7 +91,7 @@ describe("UploadManager", () => { nodeUpdated: jest.fn(), } - manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, nodesEvents); + manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, nodesEvents, clientUid); }); describe("createDraftNode", () => { @@ -145,7 +147,7 @@ describe("UploadManager", () => { throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { ConflictLinkID: "existingLinkId", ConflictDraftRevisionID: "existingDraftRevisionId", - ConflictDraftClientUID: "existingDraftClientUid", + ConflictDraftClientUID: clientUid, }); } return { @@ -156,7 +158,6 @@ describe("UploadManager", () => { const result = await manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); - expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); expect(result).toEqual({ nodeUid: "newNode:nodeUid", nodeRevisionUid: "newNode:nodeRevisionUid", @@ -174,9 +175,69 @@ describe("UploadManager", () => { hash: "newNode:hash", }, }); + expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); expect(apiService.deleteDraft).toHaveBeenCalledWith("volumeId~existingLinkId"); }); + it("should not delete existing draft if client UID does not match", async () => { + let firstCall = true; + apiService.createDraft = jest.fn().mockImplementation(() => { + if (firstCall) { + firstCall = false; + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: "existingLinkId", + ConflictDraftRevisionID: "existingDraftRevisionId", + ConflictDraftClientUID: "anotherClientUid", + }); + } + return { + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + }; + }); + + const promise = manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); + + try { + await promise; + } catch (error: any) { + expect(error.message).toBe("Draft already exists"); + expect(error.ongoingUploadByOtherClient).toBe(true); + } + expect(apiService.deleteDraft).not.toHaveBeenCalled(); + }); + + it("should not delete existing draft if client UID is not set", async () => { + const clientUid = undefined; + manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, nodesEvents, clientUid); + + let firstCall = true; + apiService.createDraft = jest.fn().mockImplementation(() => { + if (firstCall) { + firstCall = false; + throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: "existingLinkId", + ConflictDraftRevisionID: "existingDraftRevisionId", + ConflictDraftClientUID: clientUid, + }); + } + return { + nodeUid: "newNode:nodeUid", + nodeRevisionUid: "newNode:nodeRevisionUid", + }; + }); + + const promise = manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); + + try { + await promise; + } catch (error: any) { + expect(error.message).toBe("Draft already exists"); + expect(error.ongoingUploadByOtherClient).toBe(true); + } + expect(apiService.deleteDraft).not.toHaveBeenCalled(); + }); + it("should handle error when deleting existing draft", async () => { let firstCall = true; apiService.createDraft = jest.fn().mockImplementation(() => { @@ -185,7 +246,7 @@ describe("UploadManager", () => { throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { ConflictLinkID: "existingLinkId", ConflictDraftRevisionID: "existingDraftRevisionId", - ConflictDraftClientUID: "existingDraftClientUid", + ConflictDraftClientUID: clientUid, }); } return { diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index a78e6866..ed046c81 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -23,11 +23,13 @@ export class UploadManager { private cryptoService: UploadCryptoService, private nodesService: NodesService, private nodesEvents: NodesEvents, + private clientUid: string | undefined, ) { this.logger = telemetry.getLogger('upload'); this.apiService = apiService; this.cryptoService = cryptoService; this.nodesService = nodesService; + this.clientUid = clientUid; } async createDraftNode(parentFolderUid: string, name: string, metadata: UploadMetadata): Promise { @@ -89,7 +91,6 @@ export class UploadManager { base64ContentKeyPacket: generatedNodeCrypto.contentKey.encrypted.base64ContentKeyPacket, armoredContentKeyPacketSignature: generatedNodeCrypto.contentKey.encrypted.armoredContentKeyPacketSignature, signatureEmail: generatedNodeCrypto.signatureAddress.email, - // FIXME: client UID }); return result; } catch (error: unknown) { @@ -104,15 +105,23 @@ export class UploadManager { ConflictDraftClientUID?: string, } | undefined; + // If the client doesn't specify the client UID, it should + // never be considered own draft. + const isOwnDraftConflict = ( + typedDetails?.ConflictDraftRevisionID && + this.clientUid && + typedDetails?.ConflictDraftClientUID === this.clientUid + ); + // If there is existing draft created by this client, // automatically delete it and try to create a new one // with the same name again. - if (typedDetails?.ConflictDraftRevisionID) { + if (typedDetails?.ConflictDraftRevisionID && (isOwnDraftConflict || metadata.overrideExistingDraftByOtherClient)) { const existingDraftNodeUid = makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID); let deleteFailed = false; try { - this.logger.warn(`Deleting existing draft node ${existingDraftNodeUid}`); + this.logger.warn(`Deleting existing draft node ${existingDraftNodeUid} by ${typedDetails.ConflictDraftClientUID}`); await this.apiService.deleteDraft(existingDraftNodeUid); } catch (deleteDraftError: unknown) { // Do not throw, let throw the conflict error. @@ -124,6 +133,10 @@ export class UploadManager { } } + if (isOwnDraftConflict) { + this.logger.warn(`Existing draft conflict by another client ${typedDetails.ConflictDraftClientUID}`); + } + const existingNodeUid = typedDetails ? makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID) : undefined; // If there is existing node, return special error @@ -132,6 +145,7 @@ export class UploadManager { error.message, error.code, existingNodeUid, + !!typedDetails?.ConflictDraftRevisionID, ); } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 1f677170..7b2a5d6e 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -101,7 +101,7 @@ export class ProtonDriveClient { this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, this.events, this.shares); this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, this.events, this.shares, this.nodes.access, this.nodes.events); this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.shares, this.nodes.access, this.nodes.revisions); - this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.events); + this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.events, fullConfig.clientUid); this.devices = initDevicesModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.management); this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { From d849b835a8d76bb6fcd9fbac2c46481b0e6d8907 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 07:24:32 +0000 Subject: [PATCH 170/791] Add diagnostic tool --- js/sdk/src/cache/index.ts | 1 + js/sdk/src/cache/memoryCache.ts | 2 +- js/sdk/src/cache/nullCache.ts | 38 +++ js/sdk/src/diagnostic/eventsGenerator.ts | 48 ++++ js/sdk/src/diagnostic/httpClient.ts | 80 ++++++ js/sdk/src/diagnostic/index.ts | 38 +++ .../diagnostic/integrityVerificationStream.ts | 56 +++++ js/sdk/src/diagnostic/interface.ts | 158 ++++++++++++ js/sdk/src/diagnostic/sdkDiagnostic.ts | 238 ++++++++++++++++++ js/sdk/src/diagnostic/sdkDiagnosticFull.ts | 40 +++ js/sdk/src/diagnostic/telemetry.ts | 71 ++++++ js/sdk/src/diagnostic/zipGenerators.test.ts | 177 +++++++++++++ js/sdk/src/diagnostic/zipGenerators.ts | 70 ++++++ js/sdk/src/interface/download.ts | 4 +- .../src/internal/download/fileDownloader.ts | 4 +- 15 files changed, 1020 insertions(+), 5 deletions(-) create mode 100644 js/sdk/src/cache/nullCache.ts create mode 100644 js/sdk/src/diagnostic/eventsGenerator.ts create mode 100644 js/sdk/src/diagnostic/httpClient.ts create mode 100644 js/sdk/src/diagnostic/index.ts create mode 100644 js/sdk/src/diagnostic/integrityVerificationStream.ts create mode 100644 js/sdk/src/diagnostic/interface.ts create mode 100644 js/sdk/src/diagnostic/sdkDiagnostic.ts create mode 100644 js/sdk/src/diagnostic/sdkDiagnosticFull.ts create mode 100644 js/sdk/src/diagnostic/telemetry.ts create mode 100644 js/sdk/src/diagnostic/zipGenerators.test.ts create mode 100644 js/sdk/src/diagnostic/zipGenerators.ts diff --git a/js/sdk/src/cache/index.ts b/js/sdk/src/cache/index.ts index 1f780224..3b403d79 100644 --- a/js/sdk/src/cache/index.ts +++ b/js/sdk/src/cache/index.ts @@ -1,2 +1,3 @@ export type { ProtonDriveCache, EntityResult } from './interface'; export { MemoryCache } from './memoryCache'; +export { NullCache } from './nullCache'; diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index 97b3d349..4728edec 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -41,7 +41,7 @@ export class MemoryCache implements ProtonDriveCache { } } - async getEntity(key: string) { + async getEntity(key: string): Promise { const value = this.entities[key]; if (!value) { throw Error('Entity not found'); diff --git a/js/sdk/src/cache/nullCache.ts b/js/sdk/src/cache/nullCache.ts new file mode 100644 index 00000000..db175aa2 --- /dev/null +++ b/js/sdk/src/cache/nullCache.ts @@ -0,0 +1,38 @@ +import type { ProtonDriveCache, EntityResult } from './interface'; + +/** + * Null cache implementation for Proton Drive SDK. + * + * This cache is not caching anything. It can be used to disable the cache. + */ +export class NullCache implements ProtonDriveCache { + async clear() { + // No-op. + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async setEntity(key: string, value: T, tags?: string[]) { + // No-op. + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getEntity(key: string): Promise { + throw Error('Entity not found'); + } + + async *iterateEntities(keys: string[]): AsyncGenerator> { + for (const key of keys) { + yield { key, ok: false, error: 'Entity not found' }; + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async *iterateEntitiesByTag(tag: string): AsyncGenerator> { + // No-op. + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async removeEntities(keys: string[]) { + // No-op. + } +}; diff --git a/js/sdk/src/diagnostic/eventsGenerator.ts b/js/sdk/src/diagnostic/eventsGenerator.ts new file mode 100644 index 00000000..6cf64c6e --- /dev/null +++ b/js/sdk/src/diagnostic/eventsGenerator.ts @@ -0,0 +1,48 @@ +import { DiagnosticResult } from './interface'; + +/** + * A base class for class that should provide diagnostic events + * as a separate generator. Simply inherit from this class and use + * `enqueueEvent` to enqueue the observed events. The events will be + * available via `iterateEvents` generator. + */ +export class EventsGenerator { + private eventQueue: DiagnosticResult[] = []; + private waitingResolvers: Array<() => void> = []; + + protected enqueueEvent(event: DiagnosticResult): void { + this.eventQueue.push(event); + // Notify all waiting generators + const resolvers = this.waitingResolvers.splice(0); + resolvers.forEach(resolve => resolve()); + } + + async* iterateEvents(): AsyncGenerator { + try { + while (true) { + if (this.eventQueue.length === 0) { + await this.waitForEvent(); + } + + while (this.eventQueue.length > 0) { + const event = this.eventQueue.shift(); + if (event) { + yield event; + } + } + } + } finally { + this.waitingResolvers.splice(0); + } + } + + private waitForEvent(): Promise { + return new Promise((resolve) => { + if (this.eventQueue.length > 0) { + resolve(); + } else { + this.waitingResolvers.push(resolve); + } + }); + } +} diff --git a/js/sdk/src/diagnostic/httpClient.ts b/js/sdk/src/diagnostic/httpClient.ts new file mode 100644 index 00000000..90bcfd08 --- /dev/null +++ b/js/sdk/src/diagnostic/httpClient.ts @@ -0,0 +1,80 @@ +import { ProtonDriveHTTPClient, ProtonDriveHTTPClientBlobOptions, ProtonDriveHTTPClientJsonOptions } from "../interface"; +import { EventsGenerator } from './eventsGenerator'; + +/** + * Special HTTP client that is compatible with the SDK. + * + * It is a probe into SDK to observe whats going on and report any suspicious + * behavior. + * + * It should be used only for diagnostic purposes. + */ +export class DiagnosticHTTPClient extends EventsGenerator implements ProtonDriveHTTPClient { + constructor(private httpClient: ProtonDriveHTTPClient) { + super(); + this.httpClient = httpClient; + } + + async fetchJson(options: ProtonDriveHTTPClientJsonOptions): Promise { + try { + const response = await this.httpClient.fetchJson(options); + + if (response.status >= 400 && response.status !== 429) { + try { + const json = await response.json(); + + this.enqueueEvent({ + type: 'http_error', + request: { + url: options.url, + method: options.method, + json: options.json, + }, + response: { + status: response.status, + statusText: response.statusText, + json, + }, + }); + + return new Response(JSON.stringify(json), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (jsonError: unknown) { + this.enqueueEvent({ + type: 'http_error', + request: { + url: options.url, + method: options.method, + json: options.json, + }, + response: { + status: response.status, + statusText: response.statusText, + jsonError, + }, + }); + } + } + + return response; + } catch (error: unknown) { + this.enqueueEvent({ + type: 'http_error', + request: { + url: options.url, + method: options.method, + json: options.json, + }, + error, + }); + throw error; + } + } + + fetchBlob(options: ProtonDriveHTTPClientBlobOptions): Promise { + return this.httpClient.fetchBlob(options); + } +} \ No newline at end of file diff --git a/js/sdk/src/diagnostic/index.ts b/js/sdk/src/diagnostic/index.ts new file mode 100644 index 00000000..2d17ea5a --- /dev/null +++ b/js/sdk/src/diagnostic/index.ts @@ -0,0 +1,38 @@ +import { MemoryCache, NullCache } from "../cache"; +import { ProtonDriveClientContructorParameters } from "../interface"; +import { ProtonDriveClient } from "../protonDriveClient"; +import { DiagnosticHTTPClient } from "./httpClient"; +import { Diagnostic } from "./interface"; +import { SDKDiagnostic } from "./sdkDiagnostic"; +import { FullSDKDiagnostic } from "./sdkDiagnosticFull"; +import { DiagnosticTelemetry } from "./telemetry"; + +export type { Diagnostic, DiagnosticResult } from "./interface"; + +/** + * Initializes the diagnostic tool. It creates the instance of + * ProtonDriveClient with the special probes to observe the logs, + * metrics and HTTP calls; and enforced null/empty cache to always + * start from scratch. + */ +export function initDiagnostic(options: Omit): Diagnostic { + const httpClient = new DiagnosticHTTPClient(options.httpClient); + const telemetry = new DiagnosticTelemetry(); + + const protonDriveClient = new ProtonDriveClient({ + ...options, + httpClient, + // Ensure we always start with a clean state. + // Do not use memory cache as diagnostic should visit each node + // only once and we don't want to grow memory usage. + entitiesCache: new NullCache(), + // However, we need to use memory cache for crypto cache to avoid + // re-fetching the same key for all the children. + cryptoCache: new MemoryCache(), + // Special telemetry that observes the logs and metrics. + telemetry, + }); + + const diagnostic = new SDKDiagnostic(protonDriveClient); + return new FullSDKDiagnostic(diagnostic, telemetry, httpClient); +} diff --git a/js/sdk/src/diagnostic/integrityVerificationStream.ts b/js/sdk/src/diagnostic/integrityVerificationStream.ts new file mode 100644 index 00000000..6638ba74 --- /dev/null +++ b/js/sdk/src/diagnostic/integrityVerificationStream.ts @@ -0,0 +1,56 @@ +import { sha1 } from "@noble/hashes/legacy"; +import { bytesToHex } from '@noble/hashes/utils'; + +/** + * A WritableStream that computes SHA1 hash on the fly. + * The computed SHA1 hash is available after the stream is closed. + */ +export class IntegrityVerificationStream extends WritableStream { + private sha1Hash = sha1.create(); + private _computedSha1: string | undefined = undefined; + private _computedSizeInBytes: number = 0; + private _isClosed = false; + + constructor() { + super({ + start: () => {}, + write: (chunk: Uint8Array) => { + if (this._isClosed) { + throw new Error('Cannot write to a closed stream'); + } + this.sha1Hash.update(chunk); + this._computedSizeInBytes += chunk.length; + }, + close: () => { + if (!this._isClosed) { + this._computedSha1 = bytesToHex(this.sha1Hash.digest()); + this._isClosed = true; + } + }, + abort: () => { + this._isClosed = true; + this._computedSha1 = undefined; + } + }); + } + + /** + * Get the computed SHA1 hash. Only available after the stream is closed. + * @returns The SHA1 hash as a hex string, or null if not yet computed or stream was aborted + */ + get computedSha1(): string | undefined { + return this._computedSha1; + } + + /** + * Get the computed size in bytes. Only available after the stream is closed. + * @returns The size in bytes, or 0 if not yet computed or stream was aborted + */ + get computedSizeInBytes(): number | undefined { + if (!this._isClosed) { + return undefined; + } + return this._computedSizeInBytes; + } + +} \ No newline at end of file diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts new file mode 100644 index 00000000..182cf4d9 --- /dev/null +++ b/js/sdk/src/diagnostic/interface.ts @@ -0,0 +1,158 @@ +import { DegradedNode, MaybeNode, MetricEvent } from "../interface"; +import { LogRecord } from "../telemetry"; + +export interface Diagnostic { + verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator; + verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator; +} + +export type DiagnosticOptions = { + verifyContent?: boolean, + verifyThumbnails?: boolean, +} + +export type DiagnosticResult = + | FatalErrorResult + | SdkErrorResult + | HttpErrorResult + | DegradedNodeResult + | UnverifiedAuthorResult + | ExtendedAttributesErrorResult + | ExtendedAttributesMissingFieldResult + | ContentFileMissingRevisionResult + | ContentIntegrityErrorResult + | ContentDownloadErrorResult + | ThumbnailsErrorResult + | LogErrorResult + | LogWarningResult + | MetricResult; + +// Event representing that fatal error occurred during the diagnostic. +// This error prevents the diagnostic to finish. +export type FatalErrorResult = { + type: 'fatal_error', + message: string, + error?: unknown, +} + +// Event representing that SDK call failed. +// It can be any throwable error from any SDK call. Normally no error should be thrown. +export type SdkErrorResult = { + type: 'sdk_error', + call: string, + error?: unknown, +} + +// Event representing that HTTP call failed. +// It can be any call from the SDK, including validation error. Normally no error should be present. +export type HttpErrorResult = { + type: 'http_error', + request: { + url: string, + method: string, + json: unknown, + }, + // Error if the whole call failed (`fetch` failed). + error?: unknown, + // Response if the response is not 2xx or 3xx. + response?: { + status: number, + statusText: string, + // Either json object or error if the response is not JSON. + json?: object, + jsonError?: unknown, + }, +} + +// Event representing that node has some decryption or other (e.g., invalid name) issues. +export type DegradedNodeResult = { + type: 'degraded_node', + nodeUid: string, + node: DegradedNode, +} + +// Event representing that signature verification failing. +export type UnverifiedAuthorResult = { + type: 'unverified_author', + nodeUid: string, + revisionUid?: string, + authorType: string, + claimedAuthor?: string, + error: string, + node: MaybeNode, +} + +// Event representing that field from the extended attributes is not valid format. +// Currently only `sha1` verification is supported. +export type ExtendedAttributesErrorResult = { + type: 'extended_attributes_error', + nodeUid: string, + revisionUid?: string, + field: 'sha1', + value: string, +} + +// Event representing that field from the extended attributes is missing. +// Currently only `sha1` verification is supported. +export type ExtendedAttributesMissingFieldResult = { + type: 'extended_attributes_missing_field', + nodeUid: string, + revisionUid?: string, + missingField: 'sha1', +} + +// Event representing that file is missing the active revision. +export type ContentFileMissingRevisionResult = { + type: 'content_file_missing_revision', + nodeUid: string, + revisionUid?: string, +} + +// Event representing that file content is not valid - either sha1 or size is not correct. +export type ContentIntegrityErrorResult = { + type: 'content_integrity_error', + nodeUid: string, + revisionUid?: string, + claimedSha1?: string, + computedSha1?: string, + claimedSizeInBytes?: number, + computedSizeInBytes?: number, +} + +// Event representing that downloading the file content failed. +// This can be connection issue or server error. If its integrity issue, +// it should be reported as `ContentIntegrityErrorResult`. +export type ContentDownloadErrorResult = { + type: 'content_download_error', + nodeUid: string, + revisionUid?: string, + error: unknown, +} + +// Event representing that getting the thumbnails failed. +// This can be connection issue or server error. +export type ThumbnailsErrorResult = { + type: 'thumbnails_error', + nodeUid: string, + revisionUid?: string, + message?: string, + error?: unknown, +} + +// Event representing errors logged during the diagnostic. +export type LogErrorResult = { + type: 'log_error', + log: LogRecord, +} + +// Event representing warnings logged during the diagnostic. +export type LogWarningResult = { + type: 'log_warning', + log: LogRecord, +} + +// Event representing metrics logged during the diagnostic. +export type MetricResult = { + type: 'metric', + event: MetricEvent, +} diff --git a/js/sdk/src/diagnostic/sdkDiagnostic.ts b/js/sdk/src/diagnostic/sdkDiagnostic.ts new file mode 100644 index 00000000..cee480ca --- /dev/null +++ b/js/sdk/src/diagnostic/sdkDiagnostic.ts @@ -0,0 +1,238 @@ +import { Author, FileDownloader, MaybeNode, NodeType, Revision, ThumbnailType } from "../interface"; +import { ProtonDriveClient } from "../protonDriveClient"; +import { Diagnostic, DiagnosticOptions, DiagnosticResult } from "./interface"; +import { IntegrityVerificationStream } from "./integrityVerificationStream"; + +/** + * Diagnostic tool that uses SDK to traverse the node tree and verify + * the integrity of the node tree. + * + * It produces only events that can be read by direct SDK invocation. + * To get the full diagnostic, use {@link FullSDKDiagnostic}. + */ +export class SDKDiagnostic implements Diagnostic { + constructor(private protonDriveClient: ProtonDriveClient) { + this.protonDriveClient = protonDriveClient; + } + + async* verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { + let myFilesRootFolder: MaybeNode; + + try { + myFilesRootFolder = await this.protonDriveClient.getMyFilesRootFolder(); + } catch (error: unknown) { + yield { + type: 'fatal_error', + message: `Error getting my files root folder`, + error, + }; + return; + } + + yield* this.verifyNodeTree(myFilesRootFolder, options); + } + + async* verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + const isFolder = getNodeType(node) === NodeType.Folder; + + yield* this.verifyNode(node, options); + + if (isFolder) { + yield* this.verifyNodeChildren(node, options); + } + } + + private async* verifyNode(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + const nodeUid = node.ok ? node.value.uid : node.error.uid; + + if (!node.ok) { + yield { + type: 'degraded_node', + nodeUid, + node: node.error, + }; + } + + const activeRevision = getActiveRevision(node); + const nodeInfo = { + ...getNodeUids(node), + node, + } + + yield* this.verifyAuthor(node.ok ? node.value.keyAuthor : node.error.keyAuthor, { ...nodeInfo, authorType: 'key' }); + yield* this.verifyAuthor(node.ok ? node.value.nameAuthor : node.error.nameAuthor, { ...nodeInfo, authorType: 'name' }); + if (activeRevision) { + yield* this.verifyAuthor(activeRevision.contentAuthor, { ...nodeInfo, authorType: 'content' }); + } + + yield* this.verifyFileExtendedAttributes(node); + + if (options?.verifyContent) { + yield* this.verifyContent(node); + } + if (options?.verifyThumbnails) { + yield* this.verifyThumbnails(node); + } + } + + private async* verifyAuthor(author: Author, info: { nodeUid: string, authorType: string, revisionUid?: string, node: MaybeNode }): AsyncGenerator { + if (!author.ok) { + yield { + type: 'unverified_author', + claimedAuthor: author.error.claimedAuthor, + error: author.error.error, + ...info, + }; + } + } + + private async* verifyFileExtendedAttributes(node: MaybeNode): AsyncGenerator { + const activeRevision = getActiveRevision(node); + + const expectedAttributes = getNodeType(node) === NodeType.File; + + const claimedSha1 = activeRevision?.claimedDigests?.sha1; + if (claimedSha1 && !/^[0-9a-f]{40}$/i.test(claimedSha1)) { + yield { + type: 'extended_attributes_error', + ...getNodeUids(node), + field: 'sha1', + value: claimedSha1, + } + } + + if (expectedAttributes && !claimedSha1) { + yield { + type: 'extended_attributes_missing_field', + ...getNodeUids(node), + missingField: 'sha1', + } + } + } + + private async* verifyContent(node: MaybeNode): AsyncGenerator { + if (getNodeType(node) !== NodeType.File) { + return; + } + const activeRevision = getActiveRevision(node); + if (!activeRevision) { + yield { + type: 'content_file_missing_revision', + nodeUid: node.ok ? node.value.uid : node.error.uid, + } + return; + } + + let downloader: FileDownloader; + try { + downloader = await this.protonDriveClient.getFileRevisionDownloader(activeRevision.uid); + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `getFileRevisionDownloader(${activeRevision.uid})`, + error, + }; + return; + } + + const claimedSha1 = activeRevision.claimedDigests?.sha1; + const claimedSizeInBytes = downloader.getClaimedSizeInBytes(); + + const integrityVerificationStream = new IntegrityVerificationStream(); + const controller = downloader.writeToStream(integrityVerificationStream); + + try { + await controller.completion(); + + const computedSha1 = integrityVerificationStream.computedSha1; + const computedSizeInBytes = integrityVerificationStream.computedSizeInBytes; + if (claimedSha1 !== computedSha1 || claimedSizeInBytes !== computedSizeInBytes) { + yield { + type: 'content_integrity_error', + ...getNodeUids(node), + claimedSha1, + computedSha1, + claimedSizeInBytes, + computedSizeInBytes, + }; + } + } catch (error: unknown) { + yield { + type: 'content_download_error', + ...getNodeUids(node), + error, + }; + } + } + + private async* verifyThumbnails(node: MaybeNode): AsyncGenerator { + if (getNodeType(node) !== NodeType.File) { + return; + } + + const nodeUid = node.ok ? node.value.uid : node.error.uid; + + try { + const result = await Array.fromAsync(this.protonDriveClient.iterateThumbnails([nodeUid], ThumbnailType.Type1)); + + if (result.length === 0) { + yield { + type: 'sdk_error', + call: `iterateThumbnails(${nodeUid})`, + error: new Error('No thumbnails found'), + } + } + // TODO: We should have better way to check if the thumbnail is not expected. + if (!result[0].ok && result[0].error !== 'Node has no thumbnail') { + yield { + type: 'thumbnails_error', + nodeUid, + error: result[0].error, + } + } + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `iterateThumbnails(${nodeUid})`, + error, + } + } + } + + private async* verifyNodeChildren(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + const nodeUid = node.ok ? node.value.uid : node.error.uid; + try { + for await (const child of this.protonDriveClient.iterateFolderChildren(node)) { + yield* this.verifyNodeTree(child, options); + } + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `iterateFolderChildren(${nodeUid})`, + error, + }; + } + } +} + +function getNodeUids(node: MaybeNode): { nodeUid: string, revisionUid?: string } { + const activeRevision = getActiveRevision(node); + return { + nodeUid: node.ok ? node.value.uid : node.error.uid, + revisionUid: activeRevision?.uid, + }; +} + +function getNodeType(node: MaybeNode): NodeType { + return node.ok ? node.value.type : node.error.type; +} + +function getActiveRevision(node: MaybeNode): Revision | undefined { + if (node.ok) { + return node.value.activeRevision; + } + if (node.error.activeRevision?.ok) { + return node.error.activeRevision.value; + } + return undefined; +} diff --git a/js/sdk/src/diagnostic/sdkDiagnosticFull.ts b/js/sdk/src/diagnostic/sdkDiagnosticFull.ts new file mode 100644 index 00000000..0e3e7fbb --- /dev/null +++ b/js/sdk/src/diagnostic/sdkDiagnosticFull.ts @@ -0,0 +1,40 @@ +import { MaybeNode } from "../interface"; +import { DiagnosticHTTPClient } from "./httpClient"; +import { Diagnostic, DiagnosticOptions, DiagnosticResult } from "./interface"; +import { DiagnosticTelemetry } from "./telemetry"; +import { zipGenerators } from "./zipGenerators"; + +/** + * Diagnostic tool that produces full diagnostic, including logs and metrics + * by reading the events from the telemetry and HTTP client. + */ +export class FullSDKDiagnostic implements Diagnostic { + constructor(private diagnostic: Diagnostic, private telemetry: DiagnosticTelemetry, private httpClient: DiagnosticHTTPClient) { + this.diagnostic = diagnostic; + this.telemetry = telemetry; + this.httpClient = httpClient; + } + + async* verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { + yield* this.yieldEvents(this.diagnostic.verifyMyFiles(options)); + } + + async* verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + yield* this.yieldEvents(this.diagnostic.verifyNodeTree(node, options)); + } + + private async* yieldEvents(generator: AsyncGenerator): AsyncGenerator { + yield* zipGenerators( + generator, + this.internalGenerator(), + { stopOnFirstDone: true }, + ); + } + + private async* internalGenerator(): AsyncGenerator { + yield* zipGenerators( + this.telemetry.iterateEvents(), + this.httpClient.iterateEvents(), + ); + } +} diff --git a/js/sdk/src/diagnostic/telemetry.ts b/js/sdk/src/diagnostic/telemetry.ts new file mode 100644 index 00000000..df09285b --- /dev/null +++ b/js/sdk/src/diagnostic/telemetry.ts @@ -0,0 +1,71 @@ +import { MetricEvent } from '../interface'; +import { LogRecord, LogLevel } from '../telemetry'; +import { EventsGenerator } from './eventsGenerator'; + +/** + * Special telemetry that is compatible with the SDK. + * + * It is a probe into SDK to observe whats going on and report any suspicious + * behavior. + * + * It should be used only for diagnostic purposes. + */ +export class DiagnosticTelemetry extends EventsGenerator { + getLogger(name: string): Logger { + return new Logger(name, (log) => { + this.enqueueEvent({ + type: log.level === LogLevel.ERROR ? 'log_error' : 'log_warning', + log, + }); + }); + } + + logEvent(event: MetricEvent): void { + if (event.eventName === 'download' && !event.error) { + return; + } + if (event.eventName === 'volumeEventsSubscriptionsChanged') { + return; + } + + this.enqueueEvent({ + type: 'metric', + event, + }); + } +} + +class Logger { + constructor(private name: string, private callback?: (log: LogRecord) => void) { + this.name = name; + this.callback = callback; + } + + // Debug or info logs are excluded from the diagnostic. + // These logs should not include any suspicious behavior. + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + debug(message: string) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + info(message: string) {} + + warn(message: string) { + this.callback?.({ + time: new Date(), + level: LogLevel.WARNING, + loggerName: this.name, + message, + }); + } + + error(message: string, error?: unknown) { + this.callback?.({ + time: new Date(), + level: LogLevel.ERROR, + loggerName: this.name, + message, + error, + }); + } +} diff --git a/js/sdk/src/diagnostic/zipGenerators.test.ts b/js/sdk/src/diagnostic/zipGenerators.test.ts new file mode 100644 index 00000000..deb240d6 --- /dev/null +++ b/js/sdk/src/diagnostic/zipGenerators.test.ts @@ -0,0 +1,177 @@ +import { zipGenerators } from './zipGenerators'; + +async function* createTimedGenerator(values: { value: T; delay: number }[]): AsyncGenerator { + for (const { value, delay } of values) { + await new Promise(resolve => setTimeout(resolve, delay)); + yield value; + } +} + +async function* createEmptyGenerator(): AsyncGenerator { + return; +} + +describe('zipGenerators', () => { + it('should handle both generators being empty', async () => { + const genA = createEmptyGenerator(); + const genB = createEmptyGenerator(); + + const result: (string | number)[] = []; + const zipGen = zipGenerators(genA, genB); + + for await (const value of zipGen) { + result.push(value); + } + + expect(result).toEqual([]); + }); + + it('should handle one generator being empty (first empty)', async () => { + const genA = createEmptyGenerator(); + const genB = createTimedGenerator([ + { value: 1, delay: 10 }, + { value: 2, delay: 10 }, + ]); + + const result: (string | number)[] = []; + const zipGen = zipGenerators(genA, genB); + + const promise = (async () => { + for await (const value of zipGen) { + result.push(value); + } + })(); + + await promise; + + expect(result).toEqual([1, 2]); + }); + + it('should handle one generator being empty (second empty)', async () => { + const genA = createTimedGenerator([ + { value: 'a', delay: 10 }, + { value: 'b', delay: 10 }, + ]); + const genB = createEmptyGenerator(); + + const result: (string | number)[] = []; + const zipGen = zipGenerators(genA, genB); + + const promise = (async () => { + for await (const value of zipGen) { + result.push(value); + } + })(); + + await promise; + + expect(result).toEqual(['a', 'b']); + }); + + it('should handle both generators with same number of elements yielded at same time', async () => { + const genA = createTimedGenerator([ + { value: 'a1', delay: 10 }, + { value: 'a2', delay: 10 }, + { value: 'a3', delay: 10 }, + ]); + const genB = createTimedGenerator([ + { value: 'b1', delay: 10 }, + { value: 'b2', delay: 10 }, + { value: 'b3', delay: 10 }, + ]); + + const result: string[] = []; + const zipGen = zipGenerators(genA, genB); + + const promise = (async () => { + for await (const value of zipGen) { + result.push(value); + } + })(); + + await promise; + + // Since they yield at the same time, the order depends on Promise.race behavior + // Both values should be present, but order may vary + expect(result).toHaveLength(6); + expect(result).toEqual(expect.arrayContaining(['a1', 'a2', 'a3', 'b1', 'b2', 'b3'])); + }); + + it('should handle generators with different timing - first generator faster', async () => { + const genA = createTimedGenerator([ + { value: 'fast1', delay: 10 }, + { value: 'fast2', delay: 10 }, + { value: 'fast3', delay: 10 }, + ]); + const genB = createTimedGenerator([ + { value: 'slow1', delay: 50 }, + { value: 'slow2', delay: 50 }, + ]); + + const result: string[] = []; + const zipGen = zipGenerators(genA, genB); + + const promise = (async () => { + for await (const value of zipGen) { + result.push(value); + } + })(); + + await promise; + + expect(result).toEqual(['fast1', 'fast2', 'fast3', 'slow1', 'slow2']); + }); + + it('should handle generators with different timing - second generator faster', async () => { + const genA = createTimedGenerator([ + { value: 'slow1', delay: 50 }, + { value: 'slow2', delay: 50 }, + ]); + const genB = createTimedGenerator([ + { value: 'fast1', delay: 10 }, + { value: 'fast2', delay: 10 }, + { value: 'fast3', delay: 10 }, + ]); + + const result: string[] = []; + const zipGen = zipGenerators(genA, genB); + + const promise = (async () => { + for await (const value of zipGen) { + result.push(value); + } + })(); + + await promise; + + expect(result).toEqual(['fast1', 'fast2', 'fast3', 'slow1', 'slow2']); + }); + + it('should handle mixed timing with overlapping yields', async () => { + const genA = createTimedGenerator([ + { value: 'A1', delay: 50 }, + { value: 'A2', delay: 100 }, + { value: 'A3', delay: 100 }, + ]); + const genB = createTimedGenerator([ + { value: 'B1', delay: 100 }, + { value: 'B2', delay: 100 }, + { value: 'B3', delay: 200 }, + ]); + + const result: string[] = []; + const timestamps: number[] = []; + const zipGen = zipGenerators(genA, genB); + + const promise = (async () => { + for await (const value of zipGen) { + result.push(value); + timestamps.push(Date.now()); + } + })(); + + await promise; + + expect(result).toEqual(['A1', 'B1', 'A2', 'B2', 'A3', 'B3']); + }); +}); diff --git a/js/sdk/src/diagnostic/zipGenerators.ts b/js/sdk/src/diagnostic/zipGenerators.ts new file mode 100644 index 00000000..c9746221 --- /dev/null +++ b/js/sdk/src/diagnostic/zipGenerators.ts @@ -0,0 +1,70 @@ +/** + * Zips two generators into one. + * + * The combined generator yields values from both generators in the order they + * are produced. + */ +export async function* zipGenerators( + genA: AsyncGenerator, + genB: AsyncGenerator, + options?: { + stopOnFirstDone?: boolean + }, +): AsyncGenerator { + const { stopOnFirstDone = false } = options || {}; + + const itA = genA[Symbol.asyncIterator](); + const itB = genB[Symbol.asyncIterator](); + + let promiseA: Promise> | undefined = itA.next(); + let promiseB: Promise> | undefined = itB.next(); + + while (promiseA && promiseB) { + const result = await Promise.race([ + promiseA.then(res => ({ source: 'A' as const, result: res })), + promiseB.then(res => ({ source: 'B' as const, result: res })) + ]); + + if (result.source === 'A') { + if (result.result.done) { + promiseA = undefined; + if (stopOnFirstDone) { + break; + } + } else { + yield result.result.value; + promiseA = itA.next(); + } + } else { + if (result.result.done) { + promiseB = undefined; + if (stopOnFirstDone) { + break; + } + } else { + yield result.result.value; + promiseB = itB.next(); + } + } + } + + if (stopOnFirstDone) { + return; + } + + if (promiseA) { + const result = await promiseA; + if (!result.done) { + yield result.value; + } + yield* itA; + } + + if (promiseB) { + const result = await promiseB; + if (!result.done) { + yield result.value; + } + yield* itB; + } +} diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index 06b0e0ea..d93e9727 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -15,14 +15,14 @@ export interface FileDownloader { * * @param onProgress - Callback that is called with the number of downloaded bytes */ - writeToStream(streamFactory: WritableStream, onProgress: (downloadedBytes: number) => void): DownloadController, + writeToStream(streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController, /** * Same as `writeToStream` but without verification checks. * * Use this only for debugging purposes. */ - unsafeWriteToStream(streamFactory: WritableStream, onProgress: (downloadedBytes: number) => void): DownloadController, + unsafeWriteToStream(streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController, } export interface DownloadController { diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 2db6a20a..23346a11 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -49,7 +49,7 @@ export class FileDownloader { return this.revision.claimedSize; } - writeToStream(stream: WritableStream, onProgress: (downloadedBytes: number) => void): DownloadController { + writeToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController { if (this.controller.promise) { throw new Error(`Download already started`); } @@ -57,7 +57,7 @@ export class FileDownloader { return this.controller; } - unsafeWriteToStream(stream: WritableStream, onProgress: (downloadedBytes: number) => void): DownloadController { + unsafeWriteToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController { if (this.controller.promise) { throw new Error(`Download already started`); } From 25bf6bb0adad70ef2fb572518845d63dab4258db Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 12:39:42 +0200 Subject: [PATCH 171/791] js/v0.1.0 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 82f1c261..f057b16b 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.0.13", + "version": "0.1.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 8a38264bd8af64f1bb20fac6e02d59a125a571ec Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 13:22:28 +0000 Subject: [PATCH 172/791] Refactor event manager: --- js/sdk/src/interface/events.ts | 87 ++++- js/sdk/src/interface/index.ts | 6 +- js/sdk/src/interface/nodes.ts | 33 +- js/sdk/src/internal/events/apiService.ts | 53 ++- js/sdk/src/internal/events/cache.test.ts | 47 --- js/sdk/src/internal/events/cache.ts | 80 ---- .../internal/events/coreEventManager.test.ts | 101 +++++ .../src/internal/events/coreEventManager.ts | 65 +--- .../src/internal/events/eventManager.test.ts | 289 +++++++++----- js/sdk/src/internal/events/eventManager.ts | 184 ++++----- js/sdk/src/internal/events/index.ts | 138 +++---- js/sdk/src/internal/events/interface.ts | 85 +++- .../events/volumeEventManager.test.ts | 243 ++++++++++++ .../src/internal/events/volumeEventManager.ts | 108 +++--- js/sdk/src/internal/nodes/cache.ts | 22 +- js/sdk/src/internal/nodes/events.test.ts | 364 ++---------------- js/sdk/src/internal/nodes/events.ts | 298 +++----------- js/sdk/src/internal/nodes/index.test.ts | 137 ------- js/sdk/src/internal/nodes/index.ts | 14 +- js/sdk/src/internal/nodes/interface.ts | 4 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 223 ++++++----- js/sdk/src/internal/nodes/nodesAccess.ts | 41 +- .../internal/nodes/nodesManagement.test.ts | 51 ++- js/sdk/src/internal/nodes/nodesManagement.ts | 40 +- js/sdk/src/internal/shares/cache.ts | 6 +- js/sdk/src/internal/shares/manager.ts | 14 +- js/sdk/src/internal/sharing/cache.ts | 2 +- js/sdk/src/internal/sharing/events.test.ts | 284 +++++--------- js/sdk/src/internal/sharing/events.ts | 198 ++-------- js/sdk/src/internal/sharing/index.ts | 15 +- js/sdk/src/internal/sharing/interface.ts | 7 +- .../sharing/sharingManagement.test.ts | 45 +-- .../src/internal/sharing/sharingManagement.ts | 17 +- js/sdk/src/internal/upload/index.ts | 7 +- js/sdk/src/internal/upload/interface.ts | 2 + js/sdk/src/internal/upload/manager.test.ts | 55 +-- js/sdk/src/internal/upload/manager.ts | 58 +-- .../internal/upload/streamUploader.test.ts | 1 - js/sdk/src/internal/upload/streamUploader.ts | 2 - js/sdk/src/protonDriveClient.ts | 319 ++++----------- js/sdk/src/protonDrivePhotosClient.ts | 7 +- 41 files changed, 1520 insertions(+), 2232 deletions(-) delete mode 100644 js/sdk/src/internal/events/cache.test.ts delete mode 100644 js/sdk/src/internal/events/cache.ts create mode 100644 js/sdk/src/internal/events/coreEventManager.test.ts create mode 100644 js/sdk/src/internal/events/volumeEventManager.test.ts delete mode 100644 js/sdk/src/internal/nodes/index.test.ts diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index a3657904..aacfc2d7 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -1,30 +1,75 @@ -import { Device } from './devices'; -import { MaybeNode } from './nodes'; +export enum SDKEvent { + TransfersPaused = "transfersPaused", + TransfersResumed = "transfersResumed", + RequestsThrottled = "requestsThrottled", + RequestsUnthrottled = "requestsUnthrottled", +} -export type DeviceEventCallback = (deviceEvent: DeviceEvent) => void; -export type NodeEventCallback = (nodeEvent: NodeEvent) => void; +export interface LatestEventIdProvider { + getLatestEventId(treeEventScopeId: string): string | null; +} + +/** + * Callback that accepts list of Drive events and flag whether no + * event should be processed, but rather full cache refresh should be + * performed. + * + * Drive listeners should never throw and be wrapped in a try-catch loop. + * + * @param fullRefreshVolumeId - ID of the volume that should be fully refreshed. + */ +export type DriveListener = (event: DriveEvent) => Promise; + +type NodeCruEventType = DriveEventType.NodeCreated | DriveEventType.NodeUpdated; export type NodeEvent = { - type: 'update', - uid: string, - node: MaybeNode, + type: NodeCruEventType, + nodeUid: string, + parentNodeUid?: string, + isTrashed: boolean, + isShared: boolean, + treeEventScopeId: string, + eventId: string, } | { - type: 'remove', - uid: string, + type: DriveEventType.NodeDeleted, + nodeUid: string, + parentNodeUid?: string, + treeEventScopeId: string, + eventId: string, } -export type DeviceEvent = { - type: 'update', - uid: string, - device: Device, -} | { - type: 'remove', - uid: string, +export type FastForwardEvent = { + type: DriveEventType.FastForward, + treeEventScopeId: string, + eventId: string, } -export enum SDKEvent { - TransfersPaused = "transfersPaused", - TransfersResumed = "transfersResumed", - RequestsThrottled = "requestsThrottled", - RequestsUnthrottled = "requestsUnthrottled", +export type TreeRefreshEvent = { + type: DriveEventType.TreeRefresh, + treeEventScopeId: string, + eventId: string, +} + +export type TreeRemovalEvent = { + type: DriveEventType.TreeRemove, + treeEventScopeId: string, + eventId: 'none', +} + +export type SharedWithMeUpdated = { + type: DriveEventType.SharedWithMeUpdated, + eventId: string, + treeEventScopeId: 'core', +} + +export type DriveEvent = NodeEvent | FastForwardEvent | TreeRefreshEvent | TreeRemovalEvent | FastForwardEvent | SharedWithMeUpdated; + +export enum DriveEventType { + NodeCreated = 'node_created', + NodeUpdated = 'node_updated', + NodeDeleted = 'node_deleted', + SharedWithMeUpdated = 'shared_with_me_updated', + TreeRefresh = 'tree_refresh', + TreeRemove = 'tree_remove', + FastForward = 'fast_forward' } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 7e8e19ea..9bc4bdf9 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -1,5 +1,6 @@ import { ProtonDriveCache } from '../cache'; import { OpenPGPCrypto, PrivateKey, SessionKey, SRPModule } from '../crypto'; +import { LatestEventIdProvider } from '../internal/events/interface'; import { ProtonDriveAccount } from './account'; import { ProtonDriveConfig } from './config'; import { ProtonDriveHTTPClient } from './httpClient'; @@ -13,8 +14,8 @@ export type { ProtonDriveConfig } from './config'; export type { Device, DeviceOrUid } from './devices'; export { DeviceType } from './devices'; export type { FileDownloader, DownloadController } from './download'; -export type { NodeEvent, DeviceEvent, DeviceEventCallback, NodeEventCallback } from './events'; -export { SDKEvent } from './events'; +export type { DriveListener, LatestEventIdProvider, DriveEvent } from './events'; +export { DriveEventType, SDKEvent } from './events'; export type { ProtonDriveHTTPClient, ProtonDriveHTTPClientJsonOptions, ProtonDriveHTTPClientBlobOptions } from './httpClient'; export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; @@ -45,4 +46,5 @@ export interface ProtonDriveClientContructorParameters { srpModule: SRPModule, config?: ProtonDriveConfig, telemetry?: ProtonDriveTelemetry, + latestEventIdProvider?: LatestEventIdProvider }; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 70234140..7148400b 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -3,7 +3,7 @@ import { Author } from './author'; /** * Node representing a file or folder in the system. - * + * * This covers both happy path and degraded path. It is used in the SDK to * represent the node in a way that is easy to work with. Whenever any field * cannot be decrypted, it is returned as `DegradedNode` type. @@ -12,7 +12,7 @@ export type MaybeNode = Result; /** * Node representing a file or folder in the system, or missing node. - * + * * In most cases, SDK returns `MaybeNode`, but in some specific cases, when * client is requesting specific nodes, SDK must return `MissingNode` type * to indicate the case when the node is not available. That can be when @@ -27,11 +27,11 @@ export type MissingNode = { /** * Node representing a file or folder in the system. - * + * * This is a happy path representation of the node. It is used in the SDK to * represent the node in a way that is easy to work with. Whenever any field * cannot be decrypted, it is returned as `DegradedNode` type. - * + * * SDK never returns this entity directly but wrapped in `MaybeNode`. * * Note on naming: Node is reserved by JS/DOM, thus we need exception how the @@ -43,7 +43,7 @@ export type NodeEntity = { name: string, /** * Author of the node key. - * + * * Person who created the node and keys for it. If user A uploads the file * and user B renames the file and uploads new revision, name and content * author is user B, while key author stays to user A who has forever @@ -52,7 +52,7 @@ export type NodeEntity = { keyAuthor: Author, /** * Author of the name. - * + * * Person who named the file. If user A uploads the file and user B renames * the file, key and content author is user A, while name author is user B. */ @@ -87,20 +87,29 @@ export type NodeEntity = { folder?: { claimedModificationTime?: Date, }, + /** + * Provides an ID for the event scope. + * + * By subscribing to events in a scope, all updates to nodes + * withing that scope will be passed to the client. The scope can + * comprise one or more folder trees and will be shared by all + * nodes in the tree. Nodes cannot change scopes. + */ + treeEventScopeId: string, } /** * Degraded node representing a file or folder in the system. - * + * * This is a degraded path representation of the node. It is used in the SDK to * represent the node in a way that is easy to work with. Whenever any field * cannot be decrypted, it is returned as `DegradedNode` type. - * + * * SDK never returns this entity directly but wrapped in `MaybeNode`. - * + * * The node can be still used around, but it is not guaranteed that all * properties are decrypted, or that all actions can be performed on it. - * + * * For example, if the node has issue decrypting the name, the name will be * set as `Error` and potentially rename or move actions will not be * possible, but download and upload new revision will still work. @@ -110,10 +119,10 @@ export type DegradedNode = Omit & { activeRevision?: Result, /** * If the error is not related to any specific field, it is set here. - * + * * For example, if the node has issue decrypting the name, the name will be * set as `Error` while this will be empty. - * + * * On the other hand, if the node has issue decrypting the node key, but * the name is still working, this will include the node key error, while * the name will be set to the decrypted value. diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index 56e0ea04..c76d5ec4 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -1,31 +1,32 @@ -import { Logger } from "../../interface"; import { DriveAPIService, drivePaths, corePaths } from "../apiService"; import { makeNodeUid } from "../uids"; -import { DriveEvents, DriveEvent, DriveEventType } from "./interface"; +import { DriveEventsListWithStatus, DriveEvent, DriveEventType, NodeEvent, NodeEventType } from "./interface"; type GetCoreLatestEventResponse = corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json']; type GetCoreEventResponse = corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json']; type GetVolumeLatestEventResponse = drivePaths['/drive/volumes/{volumeID}/events/latest']['get']['responses']['200']['content']['application/json']; -type GetVokumeEventResponse = drivePaths['/drive/v2/volumes/{volumeID}/events/{eventID}']['get']['responses']['200']['content']['application/json']; +type GetVolumeEventResponse = drivePaths['/drive/v2/volumes/{volumeID}/events/{eventID}']['get']['responses']['200']['content']['application/json']; -const VOLUME_EVENT_TYPE_MAP = { +interface VolumeEventTypeMap { + [key: number]: NodeEventType, +} +const VOLUME_EVENT_TYPE_MAP: VolumeEventTypeMap = { 0: DriveEventType.NodeDeleted, 1: DriveEventType.NodeCreated, 2: DriveEventType.NodeUpdated, - 3: DriveEventType.NodeUpdatedMetadata, + 3: DriveEventType.NodeUpdated, } /** * Provides API communication for fetching events. - * + * * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ export class EventsAPIService { - constructor(private apiService: DriveAPIService, private logger?: Logger) { + constructor(private apiService: DriveAPIService) { this.apiService = apiService; - this.logger = logger; } async getCoreLatestEventId(): Promise { @@ -33,17 +34,19 @@ export class EventsAPIService { return result.EventID as string; } - async getCoreEvents(eventId: string): Promise { - // TODO: Switch to v6 endpoint: DriveShareRefresh doesnt seem to be part of it. + async getCoreEvents(eventId: string): Promise { + // TODO: Switch to v6 endpoint? const result = await this.apiService.get(`core/v5/events/${eventId}`); - const events: DriveEvent[] = result.DriveShareRefresh?.Action === 2 ? [ - { - type: DriveEventType.ShareWithMeUpdated, - } - ] : []; + // in core/v5/events, refresh is always all apps, value 255 + const refresh = result.Refresh > 0; + const events: DriveEvent[] = (refresh || result.DriveShareRefresh?.Action === 2) ? [{ + type: DriveEventType.SharedWithMeUpdated, + eventId: result.EventID, + treeEventScopeId: 'core', + }] : []; return { - lastEventId: result.EventID, + latestEventId: result.EventID, more: result.More === 1, refresh: result.Refresh === 1, events, @@ -55,31 +58,25 @@ export class EventsAPIService { return result.EventID; } - async getVolumeEvents(volumeId: string, eventId: string, isOwnVolume = false): Promise { - const result = await this.apiService.get(`drive/v2/volumes/${volumeId}/events/${eventId}`); + async getVolumeEvents(volumeId: string, eventId: string): Promise { + const result = await this.apiService.get(`drive/v2/volumes/${volumeId}/events/${eventId}`); return { - lastEventId: result.EventID, + latestEventId: result.EventID, more: result.More, refresh: result.Refresh, - events: result.Events.map((event): DriveEvent => { + events: result.Events.map((event): NodeEvent => { const type = VOLUME_EVENT_TYPE_MAP[event.EventType]; const uids = { nodeUid: makeNodeUid(volumeId, event.Link.LinkID), parentNodeUid: makeNodeUid(volumeId, event.Link.ParentLinkID as string), } - // VOLUME_EVENT_TYPE_MAP will never return this event type. - // It is here to satisfy the type checker. It is safe to do. - if (type === DriveEventType.ShareWithMeUpdated) { - return { - type, - }; - } return { type, ...uids, isTrashed: event.Link.IsTrashed, isShared: event.Link.IsShared, - isOwnVolume, + eventId: event.EventID, + treeEventScopeId: volumeId, }; }), }; diff --git a/js/sdk/src/internal/events/cache.test.ts b/js/sdk/src/internal/events/cache.test.ts deleted file mode 100644 index 040f0e61..00000000 --- a/js/sdk/src/internal/events/cache.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { MemoryCache } from "../../cache"; -import { EventsCache } from "./cache"; - -describe("EventsCache", () => { - let memoryCache: MemoryCache; - let cache: EventsCache; - - beforeEach(() => { - memoryCache = new MemoryCache(); - cache = new EventsCache(memoryCache); - }); - - it("should store and retrieve last event ID", async () => { - const key = "volume1"; - await cache.setLastEventId(key, { lastEventId: "eventId1", pollingIntervalInSeconds: 0, isOwnVolume: true }); - await cache.setLastEventId(key, { lastEventId: "eventId2", pollingIntervalInSeconds: 0, isOwnVolume: true }); - const result = await cache.getLastEventId(key); - expect(result).toBe("eventId2"); - }); - - it("should store and retrieve polling interval", async () => { - const key = "volume1"; - await cache.setLastEventId(key, { lastEventId: "lastEventId", pollingIntervalInSeconds: 10, isOwnVolume: true }); - await cache.setLastEventId(key, { lastEventId: "lastEventId", pollingIntervalInSeconds: 20, isOwnVolume: true }); - const result = await cache.getPollingIntervalInSeconds(key); - expect(result).toBe(20); - }); - - it("should store and retrieve subscribed volume IDs", async () => { - await cache.setLastEventId("volume1", { lastEventId: "lastEventId", pollingIntervalInSeconds: 0, isOwnVolume: true }); - await cache.setLastEventId("volume2", { lastEventId: "lastEventId", pollingIntervalInSeconds: 0, isOwnVolume: true }); - const result = await cache.getSubscribedVolumeIds(); - expect(result).toStrictEqual(["volume1", "volume2"]); - }); - - it("should not fail if cache is empty", async () => { - const result = await cache.getLastEventId("volume1"); - expect(result).toBe(undefined); - }); - - it("should call cache only once", async () => { - const spy = jest.spyOn(memoryCache, "getEntity"); - await cache.getLastEventId("volume1"); - await cache.getLastEventId("volume1"); - expect(spy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/js/sdk/src/internal/events/cache.ts b/js/sdk/src/internal/events/cache.ts deleted file mode 100644 index 7afc3f39..00000000 --- a/js/sdk/src/internal/events/cache.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ProtonDriveEntitiesCache } from "../../interface"; - -type CachedEventsData = { - // Key is either a volume ID for volume events or 'core' for core events. - [key: string]: EventsData; -}; - -interface EventsData { - lastEventId: string; - pollingIntervalInSeconds: number; - isOwnVolume: boolean; -} - -/** - * Provides caching for events IDs. - */ -export class EventsCache { - /** - * Locally cached events data to avoid unnecessary reads from the cache. - * Data about last event ID or interval might be accessed often by events - * managers. - */ - private events?: CachedEventsData; - - constructor(private driveCache: ProtonDriveEntitiesCache) { - this.driveCache = driveCache; - } - - async setLastEventId(volumeIdOrCore: string, eventsData: EventsData): Promise { - const events = await this.getEvents(); - events[volumeIdOrCore] = eventsData; - await this.cacheEvents(events); - } - - async getLastEventId(volumeIdOrCore: string): Promise { - const events = await this.getEvents(); - if (events[volumeIdOrCore]) { - return events[volumeIdOrCore].lastEventId; - } - } - - async getPollingIntervalInSeconds(volumeIdOrCore: string): Promise { - const events = await this.getEvents(); - if (events[volumeIdOrCore]) { - return events[volumeIdOrCore].pollingIntervalInSeconds; - } - } - - async isOwnVolume(volumeIdOrCore: string): Promise { - const events = await this.getEvents(); - if (events[volumeIdOrCore]) { - return events[volumeIdOrCore].isOwnVolume; - } - } - - async getSubscribedVolumeIds(): Promise { - const events = await this.getEvents(); - return Object.keys(events).filter((volumeIdOrCore) => volumeIdOrCore !== 'core'); - } - - private async getEvents(): Promise { - if (!this.events) { - this.events = await this.getCachedEvents(); - } - return this.events; - } - - private async getCachedEvents(): Promise { - try { - const events = await this.driveCache.getEntity('events'); - return JSON.parse(events); - } catch {}; - return {}; - } - - private async cacheEvents(events: CachedEventsData): Promise { - this.events = events; - await this.driveCache.setEntity('events', JSON.stringify(events)); - } -} diff --git a/js/sdk/src/internal/events/coreEventManager.test.ts b/js/sdk/src/internal/events/coreEventManager.test.ts new file mode 100644 index 00000000..c5b60b70 --- /dev/null +++ b/js/sdk/src/internal/events/coreEventManager.test.ts @@ -0,0 +1,101 @@ +import { getMockLogger } from "../../tests/logger"; +import { EventsAPIService } from "./apiService"; +import { DriveEvent, DriveEventsListWithStatus, DriveEventType } from "./interface"; +import { CoreEventManager } from "./coreEventManager"; + +describe("CoreEventManager", () => { + let mockApiService: jest.Mocked; + let coreEventManager: CoreEventManager; + const mockLogger = getMockLogger(); + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + mockApiService = { + getCoreLatestEventId: jest.fn(), + getCoreEvents: jest.fn(), + getVolumeLatestEventId: jest.fn(), + getVolumeEvents: jest.fn(), + } as unknown as jest.Mocked; + + coreEventManager = new CoreEventManager(mockLogger, mockApiService); + }); + + describe("getLatestEventId", () => { + it("should return the latest event ID from API service", async () => { + const expectedEventId = "event-123"; + mockApiService.getCoreLatestEventId.mockResolvedValue(expectedEventId); + + const result = await coreEventManager.getLatestEventId(); + + expect(result).toBe(expectedEventId); + expect(mockApiService.getCoreLatestEventId).toHaveBeenCalledTimes(1); + }); + + it("should handle API service errors", async () => { + const error = new Error("API error"); + mockApiService.getCoreLatestEventId.mockRejectedValue(error); + + await expect(coreEventManager.getLatestEventId()).rejects.toThrow("API error"); + expect(mockApiService.getCoreLatestEventId).toHaveBeenCalledTimes(1); + }); + }); + + describe("getEvents", () => { + const eventId = "event1"; + const latestEventId = "event2"; + + it("should yield ShareWithMeUpdated event when refresh is true", async () => { + const mockEvents: DriveEventsListWithStatus = { + latestEventId, + more: false, + refresh: true, + events: [], + }; + mockApiService.getCoreEvents.mockResolvedValue(mockEvents); + + const events = []; + for await (const event of coreEventManager.getEvents(eventId)) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: DriveEventType.SharedWithMeUpdated, + treeEventScopeId: 'core', + eventId: latestEventId, + }); + expect(mockApiService.getCoreEvents).toHaveBeenCalledWith(eventId); + }); + + it("should yield all events when there are actual events", async () => { + const mockEvent1: DriveEvent = { + type: DriveEventType.SharedWithMeUpdated, + eventId: "event-1", + treeEventScopeId: 'core', + }; + const mockEvent2: DriveEvent = { + type: DriveEventType.SharedWithMeUpdated, + eventId: "event-2", + treeEventScopeId: 'core', + }; + const mockEvents: DriveEventsListWithStatus = { + latestEventId, + more: false, + refresh: false, + events: [mockEvent1, mockEvent2], + }; + mockApiService.getCoreEvents.mockResolvedValue(mockEvents); + + const events = []; + for await (const event of coreEventManager.getEvents(eventId)) { + events.push(event); + } + + expect(events).toHaveLength(2); + expect(events[0]).toEqual(mockEvent1); + expect(events[1]).toEqual(mockEvent2); + }); + }); +}); diff --git a/js/sdk/src/internal/events/coreEventManager.ts b/js/sdk/src/internal/events/coreEventManager.ts index f2bf8069..8b3f1018 100644 --- a/js/sdk/src/internal/events/coreEventManager.ts +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -1,9 +1,7 @@ import { Logger } from "../../interface"; import { LoggerWithPrefix } from "../../telemetry"; import { EventsAPIService } from "./apiService"; -import { EventsCache } from "./cache"; -import { DriveEvent, DriveEventType } from "./interface"; -import { EventManager } from "./eventManager"; +import { DriveEvent, DriveEventType, EventManagerInterface } from "./interface"; /** * Combines API and event manager to provide a service for listening to @@ -11,59 +9,36 @@ import { EventManager } from "./eventManager"; * At this moment, Drive listenes only to shares with me updates from core * events. Such even indicates that user was invited to the new share or * that user's membership was removed from existing one and lost access. - * + * * The client might be already using own core events, thus this service * is here only in case the client is not connected to the Proton services * with own implementation. */ -export class CoreEventManager { - private manager: EventManager; - - constructor(logger: Logger, private apiService: EventsAPIService, private cache: EventsCache) { +export class CoreEventManager implements EventManagerInterface { + constructor(private logger: Logger, private apiService: EventsAPIService) { this.apiService = apiService; - this.manager = new EventManager( - new LoggerWithPrefix(logger, `core`), - () => this.getLastEventId(), - (eventId) => this.apiService.getCoreEvents(eventId), - (lastEventId) => this.cache.setLastEventId('core', { - lastEventId, - pollingIntervalInSeconds: this.manager.pollingIntervalInSeconds, - isOwnVolume: false, - }), - ); + this.logger = new LoggerWithPrefix(logger, `core`); } - private async getLastEventId(): Promise { - const lastEventId = await this.cache.getLastEventId('core'); - if (lastEventId) { - return lastEventId; - } - return this.apiService.getCoreLatestEventId(); + async getLatestEventId(): Promise { + return await this.apiService.getCoreLatestEventId(); } - async startSubscription(): Promise { - await this.manager.start(); - } - - async stopSubscription(): Promise { - await this.manager.stop(); + async * getEvents(eventId: string): AsyncIterable { + const events = await this.apiService.getCoreEvents(eventId); + if (events.events.length === 0 && events.latestEventId !== eventId) { + yield { + type: DriveEventType.SharedWithMeUpdated, + treeEventScopeId: 'core', + eventId: events.latestEventId, + }; + return; + } + yield* events.events; } - addListener(callback: (events: DriveEvent[]) => Promise): void { - this.manager.addListener(async (events, fullRefresh) => { - if (events) { - await callback(events); - } - if (fullRefresh) { - // Because only updates about shares that are shared with me - // are listened to from core events, in the case of core full - // refresh, we don't have to refresh anything more than this - // one specific event. - await callback([{ - type: DriveEventType.ShareWithMeUpdated, - }]); - } - }); + getLogger(): Logger { + return this.logger; } } diff --git a/js/sdk/src/internal/events/eventManager.test.ts b/js/sdk/src/internal/events/eventManager.test.ts index cd25e2fa..854da051 100644 --- a/js/sdk/src/internal/events/eventManager.test.ts +++ b/js/sdk/src/internal/events/eventManager.test.ts @@ -1,139 +1,252 @@ import { getMockLogger } from "../../tests/logger"; -import { NotFoundAPIError } from "../apiService"; import { EventManager } from "./eventManager"; +import { DriveEvent, DriveEventType, EventSubscription, UnsubscribeFromEventsSourceError } from "./interface"; jest.useFakeTimers(); +const POLLING_INTERVAL = 1; + describe("EventManager", () => { - let manager: EventManager; - - const getLastEventIdMock = jest.fn(); + let manager: EventManager; + + const getLatestEventIdMock = jest.fn(); const getEventsMock = jest.fn(); - const updateLatestEventIdMock = jest.fn(); const listenerMock = jest.fn(); + const mockLogger = getMockLogger(); + const subscriptions: EventSubscription[] = []; beforeEach(() => { - jest.clearAllMocks(); - - getLastEventIdMock.mockImplementation(() => Promise.resolve("eventId1")); - getEventsMock.mockImplementation(() => Promise.resolve({ - lastEventId: "eventId2", - more: false, - refresh: false, - events: ["event1", "event2"], - })); + const mockEventManager = { + getLogger: () => mockLogger, + getLatestEventId: getLatestEventIdMock, + getEvents: getEventsMock, + }; manager = new EventManager( - getMockLogger(), - getLastEventIdMock, - getEventsMock, - updateLatestEventIdMock, + mockEventManager as any, + POLLING_INTERVAL, + null, ); - manager.addListener(listenerMock); + const subscription = manager.addListener(listenerMock); + subscriptions.push(subscription); }); afterEach(async () => { await manager.stop(); + while (subscriptions.length > 0) { + const subscription = subscriptions.pop(); + subscription?.dispose(); + } + jest.clearAllMocks(); }); - it("should get latest event ID on first run only", async () => { - await manager.start(); - expect(getLastEventIdMock).toHaveBeenCalledTimes(1); - expect(getEventsMock).toHaveBeenCalledTimes(0); - expect(listenerMock).toHaveBeenCalledTimes(0); - expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); - expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId1'); - }); + it("should start polling when started", async () => { + getLatestEventIdMock.mockResolvedValue('EventId1'); + + const mockEvents: DriveEvent[][] = [ + [{ + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'EventId2', + }], + [{ + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'EventId3', + }], + ]; + + getEventsMock.mockImplementationOnce(async function* () { + yield* mockEvents[0]; + }).mockImplementationOnce(async function* () { + yield* mockEvents[1]; + }).mockImplementationOnce(async function* () { + }); - it("should notify about events in the next run", async () => { - await manager.start(); - expect(getLastEventIdMock).toHaveBeenCalledTimes(1); + expect(getLatestEventIdMock).toHaveBeenCalledTimes(0); expect(getEventsMock).toHaveBeenCalledTimes(0); - expect(listenerMock).toHaveBeenCalledTimes(0); - expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); - expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId1'); - updateLatestEventIdMock.mockClear(); + + expect(await manager.start()).toBeUndefined(); + + expect(getLatestEventIdMock).toHaveBeenCalledTimes(1); + expect(getEventsMock).toHaveBeenCalledWith('EventId1'); + await jest.runOnlyPendingTimersAsync(); - expect(getEventsMock).toHaveBeenCalledTimes(1); - expect(listenerMock).toHaveBeenCalledTimes(1); - expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); - expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId2'); + expect(getEventsMock).toHaveBeenCalledTimes(2); + expect(getEventsMock).toHaveBeenCalledWith('EventId2'); }); - it("should continue with more events", async () => { - getEventsMock.mockImplementation((lastEventId: string) => Promise.resolve({ - lastEventId: lastEventId === "eventId1" ? "eventId2" : "eventId3", - more: lastEventId === "eventId1" ? true : false, - refresh: false, - events: lastEventId === "eventId1" ? ["event1", "event2"] : ["event3"], - })); + it("should stop polling when stopped", async () => { + getLatestEventIdMock.mockResolvedValue('eventId1'); + getEventsMock.mockImplementation(async function* () { + yield { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'eventId1', + }; + }); + await manager.start(); await jest.runOnlyPendingTimersAsync(); - expect(getEventsMock).toHaveBeenCalledTimes(2); - expect(listenerMock).toHaveBeenCalledTimes(2); - expect(listenerMock).toHaveBeenCalledWith(["event1", "event2"], false); - expect(listenerMock).toHaveBeenCalledWith(["event3"], false); - expect(updateLatestEventIdMock).toHaveBeenCalledTimes(3); - expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId1'); - expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId2'); - expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId3'); + + const callsBeforeStop = getEventsMock.mock.calls.length; + await manager.stop(); + await jest.runOnlyPendingTimersAsync(); + + // Should not have made additional calls after stopping + expect(getEventsMock).toHaveBeenCalledTimes(callsBeforeStop); }); - it("should refresh if event does not exist", async () => { - getEventsMock.mockImplementation(() => Promise.reject(new NotFoundAPIError('Event not found', 2501))); - await manager.start(); + it("should notify all listeners when getting events", async () => { + getLatestEventIdMock.mockResolvedValue('eventId1'); + + const mockEvents: DriveEvent[] = [ + { + type: DriveEventType.NodeCreated, + nodeUid: 'node1', + parentNodeUid: 'parent1', + isTrashed: false, + isShared: false, + treeEventScopeId: 'volume1', + eventId: 'eventId2', + }, + ]; + + getEventsMock.mockImplementationOnce(async function* () { + yield* mockEvents; + }).mockImplementation(async function* () { + }); + + expect(await manager.start()).toBeUndefined(); await jest.runOnlyPendingTimersAsync(); - expect(getLastEventIdMock).toHaveBeenCalledTimes(2); expect(listenerMock).toHaveBeenCalledTimes(1); - expect(listenerMock).toHaveBeenCalledWith([], true); - expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); - expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId1'); + expect(listenerMock).toHaveBeenNthCalledWith(1, mockEvents[0]); }); - it("should retry on error", async () => { - let index = 0; - getEventsMock.mockImplementation(() => { - index++; - if (index <= 3) { - return Promise.reject(new Error("Error")); + it("should propagate unsubscription errors", async () => { + getLatestEventIdMock.mockImplementation(() => { + throw new UnsubscribeFromEventsSourceError("Not found"); + }); + + await expect(manager.start()).rejects.toThrow(UnsubscribeFromEventsSourceError); + + expect(getLatestEventIdMock).toHaveBeenCalledTimes(1); + expect(listenerMock).toHaveBeenCalledTimes(0); + expect(getEventsMock).toHaveBeenCalledTimes(0); + }); + + it("should continue processing multiple events", async () => { + getLatestEventIdMock.mockResolvedValue('eventId1'); + + const mockEvents: DriveEvent[] = [ + { + type: DriveEventType.NodeCreated, + nodeUid: 'node1', + parentNodeUid: 'parent1', + isTrashed: false, + isShared: false, + treeEventScopeId: 'volume1', + eventId: 'eventId2', + }, + { + type: DriveEventType.NodeCreated, + nodeUid: 'node2', + parentNodeUid: 'parent1', + isTrashed: false, + isShared: false, + treeEventScopeId: 'volume1', + eventId: 'eventId3', } - return Promise.resolve({ - lastEventId: "eventId2", - more: false, - refresh: false, - events: ["event1", "event2"], - }); + ]; + + getEventsMock.mockImplementationOnce(async function* () { + yield* mockEvents; + }).mockImplementation(async function* () { + // Empty generator for subsequent calls }); + await manager.start(); - updateLatestEventIdMock.mockClear(); + await jest.runOnlyPendingTimersAsync(); - // First failure. + expect(listenerMock).toHaveBeenCalledTimes(2); + expect(listenerMock).toHaveBeenNthCalledWith(1, mockEvents[0]); + expect(listenerMock).toHaveBeenNthCalledWith(2, mockEvents[1]); + + getEventsMock.mockImplementationOnce(async function* () { + yield* mockEvents; + }) await jest.runOnlyPendingTimersAsync(); - expect(listenerMock).toHaveBeenCalledTimes(0); - expect(manager.nextPollTimeout).toBe(30000); + expect(listenerMock).toHaveBeenCalledTimes(4); + expect(listenerMock).toHaveBeenNthCalledWith(1, mockEvents[0]); + expect(listenerMock).toHaveBeenNthCalledWith(2, mockEvents[1]); + }); + + it("should retry on error with exponential backoff", async () => { + getLatestEventIdMock.mockResolvedValue('eventId1'); + + let callCount = 0; + getEventsMock.mockImplementation(async function* () { + callCount++; + if (callCount <= 3) { + throw new Error("Network error"); + } + yield { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'eventId3', + }; + }); + + expect(manager['retryIndex']).toEqual(0); + + expect(await manager.start()).toBeUndefined(); + expect(getEventsMock).toHaveBeenCalledTimes(1); + expect(manager['retryIndex']).toEqual(1); - // Second failure. await jest.runOnlyPendingTimersAsync(); - expect(listenerMock).toHaveBeenCalledTimes(0); - expect(manager.nextPollTimeout).toBe(60000); + expect(getEventsMock).toHaveBeenCalledTimes(2); + expect(manager['retryIndex']).toEqual(2); - // Third failure. await jest.runOnlyPendingTimersAsync(); + expect(manager['retryIndex']).toEqual(3); + expect(listenerMock).toHaveBeenCalledTimes(0); - expect(manager.nextPollTimeout).toBe(90000); - // And now it passes. await jest.runOnlyPendingTimersAsync(); expect(listenerMock).toHaveBeenCalledTimes(1); - expect(listenerMock).toHaveBeenCalledWith(["event1", "event2"], false); - expect(updateLatestEventIdMock).toHaveBeenCalledTimes(1); - expect(updateLatestEventIdMock).toHaveBeenCalledWith('eventId2'); + // After success, retry index should reset + expect(manager['retryIndex']).toEqual(0); }); - it("should stop polling", async () => { - await manager.start(); + it("should stop polling when stopped immediately", async () => { + getLatestEventIdMock.mockResolvedValue('eventId1'); + getEventsMock.mockImplementation(async function* () { + yield { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'eventId1', + }; + }); + + expect(await manager.start()).toBeUndefined(); + expect(getEventsMock).toHaveBeenCalledTimes(1); await manager.stop(); await jest.runOnlyPendingTimersAsync(); - expect(getEventsMock).toHaveBeenCalledTimes(0); + + // getEvents should have been called once during start, but not again after stop + expect(getEventsMock).toHaveBeenCalledTimes(1); + }); + + it("should handle empty event streams", async () => { + getLatestEventIdMock.mockResolvedValue('eventId1'); + + getEventsMock.mockImplementation(async function* () { + // Empty generator - no events + }); + + await manager.start(); + await jest.runOnlyPendingTimersAsync(); + + expect(listenerMock).toHaveBeenCalledTimes(0); }); }); diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts index a51a852f..13d53217 100644 --- a/js/sdk/src/internal/events/eventManager.ts +++ b/js/sdk/src/internal/events/eventManager.ts @@ -1,168 +1,122 @@ import { Logger } from "../../interface"; -import { NotFoundAPIError } from "../apiService"; -import { Events } from "./interface"; +import { EventManagerInterface, Event, EventSubscription } from "./interface"; -const DEFAULT_POLLING_INTERVAL_IN_SECONDS = 30; const FIBONACCI_LIST = [1, 1, 2, 3, 5, 8, 13]; -/** - * `fullRefresh` is true when the event manager has requested a full - * refresh of the data. That can happen if there is too many events - * to be processed or the last event ID is too old. - */ -type Listener = (events: T[], fullRefresh: boolean) => Promise; +type Listener = (event: T) => Promise; + /** * Event manager general helper that is responsible for fetching events * from the server and notifying listeners about the events. - * + * * The specific implementation of fetching the events from the API must * be passed as dependency and can be used for any type of events that * supports the same structure. - * + * * The manager will not start fetching events until the `start` method is * called. Once started, the manager will fetch events in a loop with * a timeout between each fetch. The default timeout is 30 seconds and * additional jitter is used in case of failure. - * - * Example of usage: - * - * ```typescript - * const manager = new EventManager( - * logger, - * () => apiService.getLatestEventId(), - * (eventId) => apiService.getEvents(eventId), - * ); - * - * manager.addListener((events, fullRefresh) => { - * // Process the events - * }); - * - * manager.start(); - * ``` */ -export class EventManager { +export class EventManager { + private logger: Logger; private latestEventId?: string; private timeoutHandle?: ReturnType; private processPromise?: Promise; private listeners: Listener[] = []; private retryIndex: number = 0; - pollingIntervalInSeconds = DEFAULT_POLLING_INTERVAL_IN_SECONDS; - constructor( - private logger: Logger, - private getLatestEventId: () => Promise, - private getEvents: (eventId: string) => Promise>, - private updateLatestEventId: (lastEventId: string) => Promise, + private specializedEventManager: EventManagerInterface, + private pollingIntervalInSeconds: number, + latestEventId: string | null, ) { - this.logger = logger; - this.getLatestEventId = getLatestEventId; - this.getEvents = getEvents; - this.updateLatestEventId = updateLatestEventId; + if (latestEventId !== null) { + this.latestEventId = latestEventId; + } + this.logger = specializedEventManager.getLogger(); } - addListener(callback: Listener): void { + async start(): Promise { + if (this.latestEventId === undefined) { + this.latestEventId = await this.specializedEventManager.getLatestEventId(); + } + this.processPromise = this.processEvents(); + } + + addListener(callback: Listener): EventSubscription { this.listeners.push(callback); + return { + dispose: (): void => { + const index = this.listeners.indexOf(callback); + this.listeners.splice(index, 1); + }, + }; } - async start(): Promise { - this.logger.info(`Starting event manager with polling interval ${this.pollingIntervalInSeconds} seconds`); - await this.stop(); - this.processPromise = this.processEvents(); + setPollingInterval(pollingIntervalInSeconds: number): void { + this.pollingIntervalInSeconds = pollingIntervalInSeconds; + } + + async stop(): Promise { + if (this.processPromise) { + this.logger.info(`Stopping event manager`); + try { + await this.processPromise; + } catch (error) { + this.logger.warn(`Failed to stop cleanly: ${error instanceof Error ? error.message : error}`); + } + } + + if (!this.timeoutHandle) { + return; + } + + clearTimeout(this.timeoutHandle); + this.timeoutHandle = undefined; + } + + private async notifyListeners(event: T): Promise { + for (const listener of this.listeners) { + await listener(event); + } } private async processEvents() { + let listenerError; try { - if (!this.latestEventId) { - this.latestEventId = await this.getLatestEventId(); - await this.updateLatestEventId(this.latestEventId); - } else { - while (true) { - let result; - try { - result = await this.getEvents(this.latestEventId); - } catch (error: unknown) { - // If last event ID is not found, we need to refresh the data. - // Caller is notified via standard event update with refresh flag. - if (error instanceof NotFoundAPIError) { - this.logger.warn(`Last event ID not found, refreshing data`); - result = { - lastEventId: await this.getLatestEventId(), - more: false, - refresh: true, - events: [], - }; - } else { - // Any other error is considered as a failure and we will retry - // with backoff policy. - throw error; - } - } - await this.notifyListeners(result); - if (result.lastEventId !== this.latestEventId) { - await this.updateLatestEventId(result.lastEventId); - this.latestEventId = result.lastEventId; - } - if (!result.more) { - break; - } + const events = this.specializedEventManager.getEvents(this.latestEventId!); + for await (const event of events) { + try { + await this.notifyListeners(event); + } catch (internalListenerError) { + listenerError = internalListenerError; + break; } + this.latestEventId = event.eventId; } this.retryIndex = 0; } catch (error: unknown) { + // This could be improved to catch api specific errors and let the listener errors bubble up directly this.logger.error(`Failed to process events: ${error instanceof Error ? error.message : error} (retry ${this.retryIndex}, last event ID: ${this.latestEventId})`); this.retryIndex++; } + if (listenerError) { + throw listenerError; + } this.timeoutHandle = setTimeout(() => { this.processPromise = this.processEvents(); }, this.nextPollTimeout); }; - private async notifyListeners(result: Events): Promise { - if (result.events.length === 0 && !result.refresh) { - return; - } - if (!this.listeners.length) { - return; - } - - this.logger.debug(`Notifying listeners about ${result.events.length} events`); - - for (const listener of this.listeners) { - try { - await listener(result.events, result.refresh); - } catch (error: unknown) { - this.logger.error(`Failed to process events: ${error instanceof Error ? error.message : error} (last event ID: ${result.lastEventId}, refresh: ${result.refresh})`); - throw error; - } - } - } - /** * Polling timeout is using exponential backoff with Fibonacci sequence. - * - * The timeout is public for testing purposes only. */ - get nextPollTimeout(): number { + private get nextPollTimeout(): number { const retryIndex = Math.min(this.retryIndex, FIBONACCI_LIST.length - 1); + // FIXME jitter return this.pollingIntervalInSeconds * 1000 * FIBONACCI_LIST[retryIndex]; } - - async stop(): Promise { - if (this.processPromise) { - this.logger.info(`Stopping event manager`); - try { - await this.processPromise; - } catch {} - } - - if (!this.timeoutHandle) { - return; - } - - clearTimeout(this.timeoutHandle); - this.timeoutHandle = undefined; - } } diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 1a5b05a6..aa508dfc 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -1,16 +1,18 @@ -import { ProtonDriveEntitiesCache, Logger, ProtonDriveTelemetry } from "../../interface"; +import { Logger, ProtonDriveTelemetry } from "../../interface"; import { DriveAPIService } from "../apiService"; -import { DriveListener } from "./interface"; +import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider } from "./interface"; import { EventsAPIService } from "./apiService"; -import { EventsCache } from "./cache"; import { CoreEventManager } from "./coreEventManager"; import { VolumeEventManager } from "./volumeEventManager"; +import { EventManager } from "./eventManager"; +import { SharesManager } from "../shares/manager"; export type { DriveEvent, DriveListener } from "./interface"; export { DriveEventType } from "./interface"; const OWN_VOLUME_POLLING_INTERVAL = 30; const OTHER_VOLUME_POLLING_INTERVAL = 60; +const CORE_POLLING_INTERVAL = 30; /** * Service for listening to drive events. The service is responsible for @@ -19,113 +21,81 @@ const OTHER_VOLUME_POLLING_INTERVAL = 60; */ export class DriveEventsService { private apiService: EventsAPIService; - private cache: EventsCache; - private subscribedToRemoteDataUpdates: boolean = false; - private listeners: DriveListener[] = []; - private coreEvents: CoreEventManager; - private volumesEvents: { [volumeId: string]: VolumeEventManager }; + private coreEvents?: EventManager; + private volumeEventManagers: { [volumeId: string]: EventManager }; private logger: Logger; - constructor(private telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, driveEntitiesCache: ProtonDriveEntitiesCache) { + constructor(private telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, private shareManagement: SharesManager, private cacheEventListeners: DriveListener[] = [], private latestEventIdProvider?: LatestEventIdProvider) { this.telemetry = telemetry; this.logger = telemetry.getLogger('events'); this.apiService = new EventsAPIService(apiService); - this.cache = new EventsCache(driveEntitiesCache); - - // FIXME: Allow to pass own core events manager from the public interface. - this.coreEvents = new CoreEventManager(this.logger, this.apiService, this.cache); - this.volumesEvents = {}; + this.volumeEventManagers = {}; } /** - * Loads all the subscribed volumes (including core events) from the - * cache and starts listening to their events. Any additional volume - * that is subscribed to later will be automatically started. + * Subscribe to drive events. The treeEventScopeId can be obtained from a node. */ - async subscribeToRemoteDataUpdates(): Promise { - if (this.subscribedToRemoteDataUpdates) { - return; - } - - await this.loadSubscribedVolumeEventServices(); - this.sendNumberOfVolumeSubscriptionsToTelemetry(); - - this.subscribedToRemoteDataUpdates = true; - await this.coreEvents.startSubscription(); - await Promise.all( - Object.values(this.volumesEvents) - .map((volumeEvents) => volumeEvents.startSubscription()) - ); - } - - /** - * Subscribe to given volume. The volume will be polled for events - * with the polling interval depending on the type of the volume. - * Own volumes are polled with highest frequency, while others are - * polled with lower frequency depending on the total number of - * subscriptions. - * - * @param isOwnVolume - Owned volumes are polled with higher frequency. - */ - async listenToVolume(volumeId: string, isOwnVolume = false): Promise { - await this.loadSubscribedVolumeEventServices(); - - if (this.volumesEvents[volumeId]) { - return; - } + async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { + const volumeId = treeEventScopeId; this.logger.debug(`Creating volume event manager for volume ${volumeId}`); - const manager = this.createVolumeEventManager(volumeId, isOwnVolume); - - // FIXME: Use dynamic algorithm to determine polling interval for non-own volumes. - manager.setPollingInterval(isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL); - if (this.subscribedToRemoteDataUpdates) { - await manager.startSubscription(); + let manager = this.volumeEventManagers[volumeId]; + let started = true; + if (manager === undefined) { + manager = await this.createVolumeEventManager(volumeId); + this.volumeEventManagers[volumeId] = manager; + started = false; this.sendNumberOfVolumeSubscriptionsToTelemetry(); } + const eventSubscription = manager.addListener(callback); + if (!started) { + await manager.start(); + } + return eventSubscription; } - private async loadSubscribedVolumeEventServices() { - for (const volumeId of await this.cache.getSubscribedVolumeIds()) { - if (!this.volumesEvents[volumeId]) { - const isOwnVolume = await this.cache.isOwnVolume(volumeId) || false; - this.createVolumeEventManager(volumeId, isOwnVolume); + // FIXME: Allow to pass own core events manager from the public interface. + async subscribeToCoreEvents(callback: DriveListener): Promise { + if (this.latestEventIdProvider === null || this.latestEventIdProvider === undefined) { + throw new Error('Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization'); + } + if (this.coreEvents === undefined) { + const coreEventManager = new CoreEventManager(this.logger, this.apiService); + const latestEventId = this.latestEventIdProvider.getLatestEventId('core') ?? null; + this.coreEvents = new EventManager(coreEventManager, CORE_POLLING_INTERVAL, latestEventId); + for (const listener of this.cacheEventListeners) { + this.coreEvents.addListener(listener); } } + const eventSubscription = this.coreEvents.addListener(callback); + await this.coreEvents.start(); + return eventSubscription; } private sendNumberOfVolumeSubscriptionsToTelemetry() { this.telemetry.logEvent({ eventName: 'volumeEventsSubscriptionsChanged', - numberOfVolumeSubscriptions: Object.keys(this.volumesEvents).length, + numberOfVolumeSubscriptions: Object.keys(this.volumeEventManagers).length, }); } - private createVolumeEventManager(volumeId: string, isOwnVolume: boolean): VolumeEventManager { - const manager = new VolumeEventManager(this.logger, this.apiService, this.cache, volumeId, isOwnVolume); - for (const listener of this.listeners) { - manager.addListener(listener); + private async createVolumeEventManager(volumeId: string): Promise> { + if (this.latestEventIdProvider === null || this.latestEventIdProvider === undefined) { + throw new Error('Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization'); + } + const isOwnVolume = await this.shareManagement.isOwnVolume(volumeId); + const pollingInterval = this.getDefaultVolumePollingInterval(isOwnVolume); + const volumeEventManager = new VolumeEventManager(this.logger, this.apiService, volumeId); + const latestEventId = this.latestEventIdProvider.getLatestEventId(volumeId); + const eventManager = new EventManager(volumeEventManager, pollingInterval, latestEventId); + for (const listener of this.cacheEventListeners) { + eventManager.addListener(listener); } - this.volumesEvents[volumeId] = manager; - return manager; + await eventManager.start(); + this.volumeEventManagers[volumeId] = eventManager; + return eventManager; } - /** - * Listen to the drive events. The listener will be called with the - * new events as they arrive. - * - * One call always provides events from withing the same volume. The - * second argument of the callback `fullRefreshVolumeId` is thus single - * ID and if multiple volumes must be fully refreshed, client will - * receive multiple calls. - */ - addListener(callback: DriveListener): void { - // Add new listener to the list for any new event manager. - this.listeners.push(callback); - - // Add new listener to all existings managers. - this.coreEvents.addListener(callback); - for (const volumeEvents of Object.values(this.volumesEvents)) { - volumeEvents.addListener(callback); - } + private getDefaultVolumePollingInterval(isOwnVolume: boolean): number { + return isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL } } diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index 742a0186..f32cc01d 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -1,19 +1,33 @@ +import { Logger } from "../../interface"; + /** * Callback that accepts list of Drive events and flag whether no * event should be processed, but rather full cache refresh should be * performed. - * + * * @param fullRefreshVolumeId - ID of the volume that should be fully refreshed. */ -export type DriveListener = (events: DriveEvent[], fullRefreshVolumeId?: string) => Promise; +export type DriveListener = (event: DriveEvent) => Promise; + +export interface Event { + eventId: string; +} + +export interface EventSubscription { + dispose(): void; +} + +export interface LatestEventIdProvider { + getLatestEventId(treeEventScopeId: string): string | null; +} /** * Generic internal event interface representing a list of events * with metadata about the last event ID, whether there are more * events to fetch, or whether the listener should refresh its state. */ -export type Events = { - lastEventId: string, +export type EventsListWithStatus = { + latestEventId: string, more: boolean, refresh: boolean, events: T[], @@ -22,30 +36,71 @@ export type Events = { /** * Internal event interface representing a list of specific Drive events. */ -export type DriveEvents = Events; +export type DriveEventsListWithStatus = EventsListWithStatus; + +type NodeCruEventType = DriveEventType.NodeCreated | DriveEventType.NodeUpdated; +export type NodeEventType = NodeCruEventType | DriveEventType.NodeDeleted; -export type DriveEvent = { - type: DriveEventType.NodeCreated | DriveEventType.NodeUpdated | DriveEventType.NodeUpdatedMetadata, +export type NodeEvent = { + type: NodeCruEventType, nodeUid: string, parentNodeUid?: string, isTrashed: boolean, isShared: boolean, - isOwnVolume: boolean, + treeEventScopeId: string, + eventId: string, } | { type: DriveEventType.NodeDeleted, nodeUid: string, parentNodeUid?: string, - isTrashed?: boolean, - isShared?: boolean, - isOwnVolume: boolean, -} | { - type: DriveEventType.ShareWithMeUpdated, + treeEventScopeId: string, + eventId: string, } +export type FastForwardEvent = { + type: DriveEventType.FastForward, + treeEventScopeId: string, + eventId: string, +} + +export type TreeRefreshEvent = { + type: DriveEventType.TreeRefresh, + treeEventScopeId: string, + eventId: string, +} + +export type TreeRemovalEvent = { + type: DriveEventType.TreeRemove, + treeEventScopeId: string, + eventId: 'none', +} + +export type SharedWithMeUpdated = { + type: DriveEventType.SharedWithMeUpdated, + eventId: string, + treeEventScopeId: 'core', +} + +export type DriveEvent = NodeEvent | FastForwardEvent | TreeRefreshEvent | TreeRemovalEvent | FastForwardEvent | SharedWithMeUpdated; + export enum DriveEventType { NodeCreated = 'node_created', NodeUpdated = 'node_updated', - NodeUpdatedMetadata = 'node_updated_metadata', NodeDeleted = 'node_deleted', - ShareWithMeUpdated = 'share_with_me_updated', + SharedWithMeUpdated = 'shared_with_me_updated', + TreeRefresh = 'tree_refresh', + TreeRemove = 'tree_remove', + FastForward = 'fast_forward', +} + +/** + * This can happen if all shared nodes in that volume where unshared or if the + * volume was deleted. + */ +export class UnsubscribeFromEventsSourceError extends Error {}; + +export interface EventManagerInterface { + getLatestEventId(): Promise; + getEvents(eventId: string): AsyncIterable; + getLogger(): Logger; } diff --git a/js/sdk/src/internal/events/volumeEventManager.test.ts b/js/sdk/src/internal/events/volumeEventManager.test.ts new file mode 100644 index 00000000..0ddb221e --- /dev/null +++ b/js/sdk/src/internal/events/volumeEventManager.test.ts @@ -0,0 +1,243 @@ +import { getMockLogger } from "../../tests/logger"; +import { NotFoundAPIError } from "../apiService"; +import { EventsAPIService } from "./apiService"; +import { VolumeEventManager } from "./volumeEventManager"; +import { DriveEventsListWithStatus, DriveEventType } from "./interface"; + +jest.mock("./apiService"); + +describe("VolumeEventManager", () => { + let manager: VolumeEventManager; + let mockEventsAPIService: jest.Mocked; + const mockLogger = getMockLogger(); + const volumeId = "volumeId123"; + + beforeEach(() => { + jest.clearAllMocks(); + + mockEventsAPIService = { + getVolumeLatestEventId: jest.fn(), + getVolumeEvents: jest.fn(), + getCoreLatestEventId: jest.fn(), + getCoreEvents: jest.fn(), + } as any; + + manager = new VolumeEventManager( + mockLogger, + mockEventsAPIService, + volumeId + ); + }); + + describe("getLatestEventId", () => { + it("should return the latest event ID from API", async () => { + const expectedEventId = "eventId123"; + mockEventsAPIService.getVolumeLatestEventId.mockResolvedValue(expectedEventId); + + const result = await manager.getLatestEventId(); + + expect(result).toBe(expectedEventId); + expect(mockEventsAPIService.getVolumeLatestEventId).toHaveBeenCalledWith(volumeId); + }); + + it("should throw UnsubscribeFromEventsSourceError when API returns NotFoundAPIError", async () => { + const notFoundError = new NotFoundAPIError("Event not found", 2501); + mockEventsAPIService.getVolumeLatestEventId.mockRejectedValue(notFoundError); + + await expect(manager.getLatestEventId()).rejects.toThrow("Event not found"); + }); + + it("should rethrow other errors", async () => { + const networkError = new Error("Network error"); + mockEventsAPIService.getVolumeLatestEventId.mockRejectedValue(networkError); + + await expect(manager.getLatestEventId()).rejects.toThrow("Network error"); + }); + }); + + describe("getEvents", () => { + it("should yield events from API response", async () => { + const mockEventsResponse: DriveEventsListWithStatus = { + latestEventId: "eventId456", + more: false, + refresh: false, + events: [ + { + type: DriveEventType.NodeCreated, + nodeUid: "node1", + parentNodeUid: "parent1", + isTrashed: false, + isShared: false, + treeEventScopeId: volumeId, + eventId: "eventId456", + } + ], + }; + + mockEventsAPIService.getVolumeEvents.mockResolvedValue(mockEventsResponse); + + const events = []; + for await (const event of manager.getEvents("startEventId")) { + events.push(event); + } + + expect(events).toEqual(mockEventsResponse.events); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenCalledWith(volumeId, "startEventId"); + }); + + it("should continue fetching when more events are available", async () => { + const firstResponse: DriveEventsListWithStatus = { + latestEventId: "eventId2", + more: true, + refresh: false, + events: [ + { + type: DriveEventType.NodeCreated, + nodeUid: "node1", + parentNodeUid: "parent1", + isTrashed: false, + isShared: false, + treeEventScopeId: volumeId, + eventId: "eventId2", + } + ], + }; + + const secondResponse: DriveEventsListWithStatus = { + latestEventId: "eventId3", + more: false, + refresh: false, + events: [ + { + type: DriveEventType.NodeUpdated, + nodeUid: "node2", + parentNodeUid: "parent1", + isTrashed: false, + isShared: false, + treeEventScopeId: volumeId, + eventId: "eventId3", + } + ], + }; + + mockEventsAPIService.getVolumeEvents + .mockResolvedValueOnce(firstResponse) + .mockResolvedValueOnce(secondResponse); + + const events = []; + for await (const event of manager.getEvents("startEventId")) { + events.push(event); + } + + expect(events).toHaveLength(2); + expect(events[0]).toEqual(firstResponse.events[0]); + expect(events[1]).toEqual(secondResponse.events[0]); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenCalledTimes(2); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(1, volumeId, "startEventId"); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(2, volumeId, "eventId2"); + }); + + it("should yield TreeRefresh event when refresh is true", async () => { + const mockEventsResponse: DriveEventsListWithStatus = { + latestEventId: "eventId789", + more: false, + refresh: true, + events: [], + }; + + mockEventsAPIService.getVolumeEvents.mockResolvedValue(mockEventsResponse); + + const events = []; + for await (const event of manager.getEvents("startEventId")) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: DriveEventType.TreeRefresh, + treeEventScopeId: volumeId, + eventId: "eventId789", + }); + }); + + it("should yield FastForward event when no events but eventId changed", async () => { + const mockEventsResponse: DriveEventsListWithStatus = { + latestEventId: "newEventId", + more: false, + refresh: false, + events: [], + }; + + mockEventsAPIService.getVolumeEvents.mockResolvedValue(mockEventsResponse); + + const events = []; + for await (const event of manager.getEvents("oldEventId")) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: DriveEventType.FastForward, + treeEventScopeId: volumeId, + eventId: "newEventId", + }); + }); + + it("should yield TreeRemove event when API returns NotFoundAPIError", async () => { + const notFoundError = new NotFoundAPIError("Volume not found", 2501); + mockEventsAPIService.getVolumeEvents.mockRejectedValue(notFoundError); + + const events = []; + try { + for await (const event of manager.getEvents("startEventId")) { + events.push(event); + } + } catch (error) { + // The error should be re-thrown, but first it should yield a TreeRemove event + expect(error).toBe(notFoundError); + } + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: DriveEventType.TreeRemove, + treeEventScopeId: volumeId, + eventId: 'none', + }); + }); + + it("should rethrow non-NotFoundAPIError errors", async () => { + const networkError = new Error("Network error"); + mockEventsAPIService.getVolumeEvents.mockRejectedValue(networkError); + + const eventGenerator = manager.getEvents("startEventId"); + const eventIterator = eventGenerator[Symbol.asyncIterator](); + await expect(eventIterator.next()).rejects.toThrow("Network error"); + }); + + it("should not yield events when events array is empty and eventId unchanged", async () => { + const mockEventsResponse: DriveEventsListWithStatus = { + latestEventId: "sameEventId", + more: false, + refresh: false, + events: [], + }; + + mockEventsAPIService.getVolumeEvents.mockResolvedValue(mockEventsResponse); + + const events = []; + for await (const event of manager.getEvents("sameEventId")) { + events.push(event); + } + + expect(events).toHaveLength(0); + }); + }); + + describe("getLogger", () => { + it("should return logger with prefix", () => { + const logger = manager.getLogger(); + expect(logger).toBeDefined(); + // The logger should be wrapped with LoggerWithPrefix, but we can't easily test the prefix + }); + }); +}); diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index 6a696fc0..938dad30 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -1,74 +1,76 @@ import { Logger } from "../../interface"; import { LoggerWithPrefix } from "../../telemetry"; import { EventsAPIService } from "./apiService"; -import { EventsCache } from "./cache"; -import { DriveEvent, DriveListener } from "./interface"; -import { EventManager } from "./eventManager"; +import { DriveEvent, DriveEventsListWithStatus, DriveEventType, EventManagerInterface, UnsubscribeFromEventsSourceError } from "./interface"; +import { NotFoundAPIError } from "../apiService"; /** * Combines API and event manager to provide a service for listening to * volume events. Volume events are all about nodes updates. Whenever * there is update to the node metadata or content, the event is emitted. */ -export class VolumeEventManager { - private manager: EventManager; +export class VolumeEventManager implements EventManagerInterface{ - constructor(logger: Logger, private apiService: EventsAPIService, private cache: EventsCache, private volumeId: string, isOwnVolume: boolean) { + constructor(private logger: Logger, private apiService: EventsAPIService, private volumeId: string) { this.apiService = apiService; this.volumeId = volumeId; - - this.manager = new EventManager( - new LoggerWithPrefix(logger, `volume ${volumeId}`), - () => this.getLastEventId(), - (eventId) => this.apiService.getVolumeEvents(volumeId, eventId, isOwnVolume), - (lastEventId) => this.cache.setLastEventId(volumeId, { - lastEventId, - pollingIntervalInSeconds: this.manager.pollingIntervalInSeconds, - isOwnVolume - }), - ); - this.cache.getPollingIntervalInSeconds(volumeId) - .then((pollingIntervalInSeconds) => { - if (pollingIntervalInSeconds) { - this.manager.pollingIntervalInSeconds = pollingIntervalInSeconds; - } - }) - .catch(() => {}); - } - - private async getLastEventId(): Promise { - const lastEventId = await this.cache.getLastEventId(this.volumeId); - if (lastEventId) { - return lastEventId; - } - return this.apiService.getVolumeLatestEventId(this.volumeId); - } - - /** - * There is a limit how many volume subscribtions can be active at - * the same time. The manager of all volume managers should set the - * intervals for each volume accordingly depending on the volume - * type or the total number of subscriptions. - */ - setPollingInterval(pollingIntervalInSeconds: number): void { - this.manager.pollingIntervalInSeconds = pollingIntervalInSeconds; + this.logger = new LoggerWithPrefix(logger, `volume ${volumeId}`); } - async startSubscription(): Promise { - await this.manager.start(); + getLogger(): Logger { + return this.logger; } - async stopSubscription(): Promise { - await this.manager.stop(); + async * getEvents(eventId: string): AsyncIterable { + try { + let events: DriveEventsListWithStatus; + let more = true; + while (more) { + events = await this.apiService.getVolumeEvents(this.volumeId, eventId); + more = events.more; + if (events.refresh) { + yield { + type: DriveEventType.TreeRefresh, + treeEventScopeId: this.volumeId, + eventId: events.latestEventId, + }; + break; + } + // Update to the latest eventId to avoid inactive volumes from getting out of sync + if (events.events.length === 0 && events.latestEventId !== eventId) { + yield { + type: DriveEventType.FastForward, + treeEventScopeId: this.volumeId, + eventId: events.latestEventId, + }; + break; + } + yield* events.events; + eventId = events.latestEventId; + } + } catch (error: unknown) { + if (error instanceof NotFoundAPIError) { + this.logger.info(`Volume events no longer accessible`); + yield { + type: DriveEventType.TreeRemove, + treeEventScopeId: this.volumeId, + // After a TreeRemoval event, polling should stop. + eventId: 'none', + }; + } + throw error; + } } - addListener(callback: DriveListener): void { - this.manager.addListener(async (events, fullRefresh) => { - if (fullRefresh) { - await callback([], this.volumeId); - } else { - await callback(events); + async getLatestEventId(): Promise { + try { + return await this.apiService.getVolumeLatestEventId(this.volumeId); + } catch (error: unknown) { + if (error instanceof NotFoundAPIError) { + this.logger.info(`Volume events no longer accessible`); + throw new UnsubscribeFromEventsSourceError(error.message); } - }); + throw error; + } } } diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 7ad45276..54a5a032 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -6,6 +6,7 @@ import { DecryptedNode, DecryptedRevision } from "./interface"; export enum CACHE_TAG_KEYS { ParentUid = 'nodeParentUid', Trashed = 'nodeTrashed', + Roots = 'nodeRoot', } type DecryptedNodeResult = ( @@ -15,10 +16,10 @@ type DecryptedNodeResult = ( /** * Provides caching for nodes metadata. - * + * * The cache is responsible for serialising and deserialising node metadata, * recording parent-child relationships, and recursively removing nodes. - * + * * The cache of node metadata should not contain any crypto material. */ export class NodesCache { @@ -35,6 +36,8 @@ export class NodesCache { const tags = [`volume:${volumeId}`]; if (node.parentUid) { tags.push(`${CACHE_TAG_KEYS.ParentUid}:${node.parentUid}`) + } else { + tags.push(`${CACHE_TAG_KEYS.Roots}:${volumeId}`) } if (node.trashTime) { tags.push(`${CACHE_TAG_KEYS.Trashed}`) @@ -74,6 +77,17 @@ export class NodesCache { } } + /** + * Remove all entries associated with a volume. + * + * This is needed when a user looses access to a volume. + */ + async removeVolume(volumeId: string): Promise { + for await (const result of this.iterateRootNodeUids(volumeId)) { + await this.removeNodes([result.key]); + } + } + /** * Remove corrupted node never throws, but it logs so we can know * about issues and fix them. It is crucial to remove corrupted @@ -142,6 +156,10 @@ export class NodesCache { } } + async *iterateRootNodeUids(volumeId: string): AsyncGenerator> { + yield* this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.Roots}:${volumeId}`); + } + async *iterateTrashedNodes(): AsyncGenerator { for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.Trashed)) { const node = await this.convertCacheResult(result); diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index f823297c..db9ed806 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -1,14 +1,13 @@ import { getMockLogger } from "../../tests/logger"; -import { DriveEventsService, DriveEvent, DriveEventType } from "../events"; -import { NodesEvents, updateCacheByEvent, deleteFromCacheByEvent, notifyListenersByEvent } from "./events"; +import { DriveEvent, DriveEventType } from "../events"; +import { NodesEventsHandler } from "./events"; import { DecryptedNode } from "./interface"; import { NodesCache } from "./cache"; -import { NodesAccess } from "./nodesAccess"; -describe("updateCacheByEvent", () => { +describe("NodesEventsHandler", () => { const logger = getMockLogger(); - let cache: NodesCache; + let nodesEventsNodesEventsHandler: NodesEventsHandler; beforeEach(() => { jest.clearAllMocks(); @@ -16,368 +15,63 @@ describe("updateCacheByEvent", () => { // @ts-expect-error No need to implement all methods for mocking cache = { getNode: jest.fn(() => Promise.resolve({ - uid: '123', - parentUid: 'parentUid', - name: { ok: true, value: 'name' }, + uid: "nodeUid123", + parentUid: "parentUid", + name: { ok: true, value: "name" }, } as DecryptedNode)), setNode: jest.fn(), removeNodes: jest.fn(), resetFolderChildrenLoaded: jest.fn(), }; + nodesEventsNodesEventsHandler = new NodesEventsHandler(logger, cache); }); - describe('NodeCreated event', () => { + it("should unset the parent listing complete status when a `NodeCreated` event is received.", async () => { const event: DriveEvent = { + eventId: "event1", type: DriveEventType.NodeCreated, nodeUid: "nodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: false, - isOwnVolume: true, + treeEventScopeId: "volume1", }; + await nodesEventsNodesEventsHandler.updateNodesCacheOnEvent(event); - it("should not update cache by node create event", async () => { - await updateCacheByEvent(logger, event, cache); - - expect(cache.getNode).toHaveBeenCalledTimes(0); - expect(cache.setNode).toHaveBeenCalledTimes(0); - }); - - it("should reset parent loaded state", async () => { - await updateCacheByEvent(logger, event, cache); - - expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); - }); - - it("should skip reset parent loaded state if parent missing", async () => { - await updateCacheByEvent(logger, { ...event, parentNodeUid: undefined }, cache); - - expect(cache.resetFolderChildrenLoaded).not.toHaveBeenCalled(); - }); + expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledTimes(1); + expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith("parentUid"); + expect(cache.setNode).toHaveBeenCalledTimes(0); }); - describe('NodeUpdated event', () => { + it("should update the node metadata when a `NodeUpdated` event is received.", async () => { const event: DriveEvent = { type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", + eventId: "event1", + nodeUid: "nodeUid123", parentNodeUid: "parentUid", isTrashed: false, isShared: false, - isOwnVolume: true, + treeEventScopeId: "volume1", }; + await nodesEventsNodesEventsHandler.updateNodesCacheOnEvent(event); - it("should update cache if present in cache", async () => { - await updateCacheByEvent(logger, event, cache); - - expect(cache.getNode).toHaveBeenCalledTimes(1); - expect(cache.setNode).toHaveBeenCalledTimes(1); - expect(cache.setNode).toHaveBeenCalledWith(expect.objectContaining({ uid: '123', isStale: true, parentUid: "parentUid" })); - }); - - it("should skip if missing in cache", async () => { - cache.getNode = jest.fn(() => Promise.reject(new Error('Missing in the cache'))); - - await updateCacheByEvent(logger, event, cache); - - expect(cache.getNode).toHaveBeenCalledTimes(1); - expect(cache.setNode).toHaveBeenCalledTimes(0); - }); - - it("should remove from cache if not possible to set", async () => { - cache.setNode = jest.fn(() => Promise.reject(new Error('Cannot set node'))); - - await updateCacheByEvent(logger, event, cache); - - expect(cache.getNode).toHaveBeenCalledTimes(1); - expect(cache.removeNodes).toHaveBeenCalledTimes(1); - }); - - it("should throw if remove fails", async () => { - cache.setNode = jest.fn(() => Promise.reject(new Error('Cannot set node'))); - cache.removeNodes = jest.fn(() => Promise.reject(new Error('Cannot remove node'))); - - await expect(updateCacheByEvent(logger, event, cache)).rejects.toThrow('Cannot set node'); - }); + expect(cache.getNode).toHaveBeenCalledTimes(1); + expect(cache.setNode).toHaveBeenCalledTimes(1); + expect(cache.setNode).toHaveBeenCalledWith(expect.objectContaining({ uid: 'nodeUid123', isStale: true, parentUid: "parentUid", trashTime: undefined, isShared: false })); }); - describe('NodeDeleted event', () => { + it("should remove node from cache", async () => { const event: DriveEvent = { type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", + eventId: "event1", + nodeUid: "nodeUid123", parentNodeUid: "parentUid", - isOwnVolume: true, - } - - it("should remove node from cache", async () => { - await deleteFromCacheByEvent(logger, event, cache); - - expect(cache.removeNodes).toHaveBeenCalledTimes(1); - expect(cache.removeNodes).toHaveBeenCalledWith([event.nodeUid]); - }); - }); -}); - -describe("notifyListenersByEvent", () => { - const logger = getMockLogger(); - - let cache: NodesCache; - let nodesAccess: NodesAccess; - - beforeEach(() => { - jest.clearAllMocks(); - - // @ts-expect-error No need to implement all methods for mocking - cache = { - getNode: jest.fn(() => Promise.resolve({ - uid: '123', - parentUid: 'parentUid', - name: { ok: true, value: 'name' }, - } as DecryptedNode)), - }; - // @ts-expect-error No need to implement all methods for mocking - nodesAccess = { - getNode: jest.fn(() => Promise.resolve({ uid: 'nodeUid', name: { ok: true, value: 'name' } } as DecryptedNode)), - }; - }); - - describe('update event', () => { - it("should notify listeners by parentNodeUid when there is update", async () => { - const event: DriveEvent = { - type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isTrashed: false, - isShared: false, - isOwnVolume: true, - }; - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); - expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); - expect(cache.getNode).toHaveBeenCalledTimes(0); - }); - - it("should notify listeners by parentNodeUid when it is moved to another parent", async () => { - const event: DriveEvent = { - type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", - parentNodeUid: "newParentUid", - isTrashed: false, - isShared: false, - isOwnVolume: true, - }; - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'remove', uid: 'nodeUid' })); - expect(nodesAccess.getNode).toHaveBeenCalledTimes(0); - expect(cache.getNode).toHaveBeenCalledTimes(1); - }); - - it("should notify listeners by isTrashed when there is update", async () => { - const event: DriveEvent = { - type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isTrashed: true, - isShared: false, - isOwnVolume: true, - }; - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); - expect(nodesAccess.getNode).toHaveBeenCalledTimes(1); - expect(cache.getNode).toHaveBeenCalledTimes(0); - }); - - it("should notify listeners by isTrashed when it is moved out of trash", async () => { - cache.getNode = jest.fn(() => Promise.resolve({ - uid: '123', - parentUid: 'parentUid', - name: { ok: true, value: 'name' }, - trashTime: new Date(), - } as DecryptedNode)); - const event: DriveEvent = { - type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isTrashed: false, - isShared: false, - isOwnVolume: true, - }; - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'remove', uid: 'nodeUid' })); - expect(nodesAccess.getNode).toHaveBeenCalledTimes(0); - expect(cache.getNode).toHaveBeenCalledTimes(1); - }); - - it("should not notify listeners if neither condition match", async () => { - const event: DriveEvent = { - type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isTrashed: false, - isShared: false, - isOwnVolume: true, - }; - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'lalalala', callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(0); - expect(nodesAccess.getNode).toHaveBeenCalledTimes(0); - }); - }); - - describe('delete event', () => { - it("should notify listeners by parentNodeUid", async () => { - const event: DriveEvent = { - type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isOwnVolume: true, - }; - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'parentUid', callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); - }); - - it("should notify listeners by isTrashed from cache", async () => { - cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeUid', trashTime: new Date() } as DecryptedNode)); - const event: DriveEvent = { - type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isOwnVolume: true, - }; - - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); - }); - - it("should not notify listeners if cache is missing node", async () => { - cache.getNode = jest.fn(() => Promise.reject(new Error('Missing in the cache'))); - const event: DriveEvent = { - type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isOwnVolume: true, - }; - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ isTrashed }) => !!isTrashed, callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(0); - }); - - it("should not notify listeners if neither condition match", async () => { - const event: DriveEvent = { - type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isOwnVolume: true, - }; - const listener = jest.fn(); - - await notifyListenersByEvent(logger, event, [{ condition: ({ parentNodeUid }) => parentNodeUid === 'lalalala', callback: listener }], cache, nodesAccess); - - expect(listener).toHaveBeenCalledTimes(0); - }); - }); -}); - -describe("NodesEvents integration", () => { - const logger = getMockLogger(); - - let eventsService: DriveEventsService; - let eventsServiceCallback; - let cache: NodesCache; - let nodesAccess: NodesAccess; - let listener: jest.Mock; - let events: NodesEvents; - - beforeEach(() => { - jest.clearAllMocks(); - - // @ts-expect-error No need to implement all methods for mocking - eventsService = { - addListener: jest.fn((callback) => { - eventsServiceCallback = callback; - }), - } - // @ts-expect-error No need to implement all methods for mocking - cache = { - getNode: jest.fn(() => Promise.resolve({ - uid: 'nodeUid', - parentUid: 'parentUid', - name: { ok: true, value: 'name' }, - trashTime: new Date(), - } as DecryptedNode)), - setNode: jest.fn(), - removeNodes: jest.fn(), - }; - // @ts-expect-error No need to implement all methods for mocking - nodesAccess = { - getNode: jest.fn(() => Promise.resolve({ - uid: 'nodeUid', - parentUid: 'parentUid', - name: { ok: true, value: 'name' }, - } as DecryptedNode)), + treeEventScopeId: "volume1", }; - listener = jest.fn(); - events = new NodesEvents(logger, eventsService, cache, nodesAccess); - events.subscribeToTrashedNodes(listener); - }); - it("should send remove to trash listener when node is restored from trash", async () => { - await eventsServiceCallback!([{ - type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isTrashed: false, - isShared: false, - isOwnVolume: true, - } as DriveEvent]); + await nodesEventsNodesEventsHandler.updateNodesCacheOnEvent(event); - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); - expect(cache.setNode).toHaveBeenCalledTimes(1); - expect(cache.setNode).toHaveBeenCalledWith(expect.objectContaining({ uid: 'nodeUid', isStale: true })); - }); - - it("should send remove to trash listener when node is deleted", async () => { - await eventsServiceCallback!([{ - type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isTrashed: false, - isShared: false, - isOwnVolume: true, - } as DriveEvent]); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); - expect(cache.setNode).toHaveBeenCalledTimes(0); expect(cache.removeNodes).toHaveBeenCalledTimes(1); - expect(cache.removeNodes).toHaveBeenCalledWith(['nodeUid']); + expect(cache.removeNodes).toHaveBeenCalledWith([event.nodeUid]); }); }); diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index a2da4f9d..8e0444f3 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -1,271 +1,63 @@ -import { Logger, NodeEventCallback } from "../../interface"; -import { convertInternalNode } from "../../transformers"; -import { DriveEventsService, DriveEvent, DriveEventType } from "../events"; -import { DecryptedNode } from "./interface"; +import { Logger } from "../../interface"; +import { DriveEvent, DriveEventType } from "../events"; import { NodesCache } from "./cache"; -import { NodesAccess } from "./nodesAccess"; - -type Listeners = { - /** - * Condition for the listener to be notified about the event. - * - * The condition is a function that receives the event information - * and returns true if the listener should be notified about the - * event. - */ - condition: (nodeEventInfo: NodeEventInfo) => boolean, - callback: NodeEventCallback, -}[]; - -/** - * Minimal information about the event that is used for listener - * condition. The information is used to determine if the listener - * should be notified about the event. - * - * This must come from the API response to volume events. - */ -type NodeEventInfo = { - parentNodeUid?: string, - isTrashed?: boolean, -} /** - * Provides both event handling and subscription mechanism for user. - * + * Provides internal event handling. + * * The service is responsible for handling events regarding node metadata - * from the DriveEventsService, and for providing a subscription mechanism - * for the user to listen to updates of specific group of nodes, such as - * any update for trashed nodes. + * from the DriveEventsService. */ -export class NodesEvents { - private listeners: Listeners = []; - - constructor(private logger: Logger, events: DriveEventsService, private cache: NodesCache, private nodesAccess: NodesAccess) { - this.logger = logger; - this.cache = cache; - this.nodesAccess = nodesAccess; +export class NodesEventsHandler { + constructor(private logger: Logger, private cache: NodesCache) { + } - events.addListener(async (events, fullRefreshVolumeId) => { - if (fullRefreshVolumeId) { - await cache.setNodesStaleFromVolume(fullRefreshVolumeId); + async updateNodesCacheOnEvent(event: DriveEvent): Promise { + try { + if (event.type === DriveEventType.TreeRefresh) { + await this.cache.setNodesStaleFromVolume(event.treeEventScopeId); return; } - - for (const event of events) { - try { - await updateCacheByEvent(logger, event, cache); - } catch (error: unknown) { - logger.error(`Failed to update cache`, error); - } - try { - await notifyListenersByEvent(logger, event, this.listeners, cache, nodesAccess); - } catch (error: unknown) { - logger.error(`Failed to notifiy listeners`, error); - } - // Delete must come last as it will remove the node from the cache - // and we need to first know local status of the node to properly - // notify the listeners. - await deleteFromCacheByEvent(logger, event, cache); + if (event.type === DriveEventType.TreeRemove) { + await this.cache.removeVolume(event.treeEventScopeId); + return; } - }); - } - - subscribeToTrashedNodes(callback: NodeEventCallback) { - this.listeners.push({ condition: ({ isTrashed }) => isTrashed || false, callback }); - return () => { - this.listeners = this.listeners.filter(listener => listener.callback !== callback); - } - } - - subscribeToChildren(parentNodeUid: string, callback: NodeEventCallback) { - this.listeners.push({ condition: ({ parentNodeUid: parent }) => parent === parentNodeUid, callback }); - return () => { - this.listeners = this.listeners.filter(listener => listener.callback !== callback); - } - } - - async nodeCreated(node: DecryptedNode): Promise { - await this.cache.setNode(node); - void this.notifyListenersByNode(node, DriveEventType.NodeCreated); - } - - async nodeUpdated(partialNode: { uid: string } & Partial): Promise { - const originalNode = await this.cache.getNode(partialNode.uid); - const updatedNode = { - ...originalNode, - ...partialNode, - } - - await this.cache.setNode(updatedNode); - void this.notifyListenersByNode(updatedNode, DriveEventType.NodeUpdated); - } - - async nodesDeleted(nodeUids: string[]): Promise { - try { - for await (const originalNode of this.cache.iterateNodes(nodeUids)) { - if (originalNode.ok) { - void this.notifyListenersByNode(originalNode.node, DriveEventType.NodeDeleted); + if (event.type === DriveEventType.NodeDeleted) { + await this.cache.removeNodes([event.nodeUid]); + return; + } + if (event.type === DriveEventType.NodeCreated) { + // FIXME Add it to the parent listing even if it's not cached + // so it doesn't need to refetch all children + + // We do not have partial nodes in the cache, so we don't + // add it. If new node is not added, we need to reset the + // children loaded flag to force refetch when requested. + if (event.parentNodeUid) { + await this.cache.resetFolderChildrenLoaded(event.parentNodeUid); } + return; } - } catch {} - - await this.cache.removeNodes(nodeUids); - } - - private async notifyListenersByNode(node: DecryptedNode, eventType: DriveEventType.NodeCreated | DriveEventType.NodeUpdated | DriveEventType.NodeDeleted) { - const event: DriveEvent = { - type: eventType, - nodeUid: node.uid, - parentNodeUid: node.parentUid, - isOwnVolume: true, - isTrashed: !!node.trashTime, - isShared: node.isShared, - }; - await notifyListenersByEvent(this.logger, event, this.listeners, this.cache, this.nodesAccess); - - } -} - -/** - * For given event, update the cache accordingly. - * - * The function is responsible for updating the cache based on the - * event received from the DriveEventsService. The cache metadata - * are not updated, only the nodes are marked as stale to be - * fetched and decrypted again when requested by the client. - * - * If the node is not found in the cache, the event is silently - * skipped as the node will be fetched and decrypted when requested - * by the client. - * - * If the node cannot be updated in the cache, the node is removed - * from the cache to not block the client. If the node is not possible - * to remove, the function throws an error. - * - * @throws Only if the node is not possible to remove from the cache. - */ -export async function updateCacheByEvent(logger: Logger, event: DriveEvent, cache: NodesCache) { - // NodeCreated event is ignored as we do not want to fetch and - // decrypt the node immediately. The node will be fetched and - // decrypted when requested by the client. - if (event.type === DriveEventType.NodeCreated) { - // We do not have partial nodes in the cache, so we don't - // add it. If new node is not added, we need to reset the - // children loaded flag to force refetch when requested. - if (event.parentNodeUid) { - await cache.resetFolderChildrenLoaded(event.parentNodeUid); - } - } - if (event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) { - let node; - // getNode can fail if the node is not found or if it is - // corrupted. In later case, it will be automatically - // removed from cache. In both cases, lets skip the event - // silently as once requested by client, the node will - // be cached again. - try { - node = await cache.getNode(event.nodeUid); - } catch (error: unknown) { - logger.debug(`Skipping node update event (node not in the cache): ${error}`); - } - if (node) { - node.isStale = true; - // We need to update the parentUid as the node might have - // been moved to another parent. This is important for - // children iteration. - node.parentUid = event.parentNodeUid; - try { - await cache.setNode(node); - } catch (setNodeError: unknown) { - logger.error(`Skipping node update event (failed to update)`, setNodeError); - // If updating node in the cache is failing, lets remove it - // to not block the whole client. If the node is not possible - // to remove, lets throw at this point as cache is in very - // bad state by this point and the rest of the code would start - // to break randomly. - try { - await cache.removeNodes([event.nodeUid]); - } catch (removeNodeError: unknown) { - logger.error(`Skipping node update event (failed to remove after failed update)`, removeNodeError); - // removeNodeError is automatic correction algorithm. - // If that fails, lets throw the original error as that - // is the real problem. - throw setNodeError; + if (event.type === DriveEventType.NodeUpdated) { + const node = await this.cache.getNode(event.nodeUid); + node.isStale = true; + node.parentUid = event.parentNodeUid; + node.isShared = event.isShared; + if (event.isTrashed) { + node.trashTime ??= new Date(); + } else { + node.trashTime = undefined; } + await this.cache.setNode(node); } - } - } -} - -/** - * For given event, delete the node from the cache if it is - * deleted. - */ -export async function deleteFromCacheByEvent(logger: Logger, event: DriveEvent, cache: NodesCache) { - if (event.type === DriveEventType.NodeDeleted) { - // removeNodes can fail removing children. - // We do not want to stop processing other events in such - // a case. Lets log the error and continue. - try { - await cache.removeNodes([event.nodeUid]); } catch (error: unknown) { - logger.error(`Skipping node delete event:`, error); - } - } -} - -/** - * For given event, notify the listeners accordingly. - * - * The function is responsible for notifying the listeners about the - * event received from the DriveEventsService. The listeners are - * connected with events based on the condition, such as parent node - * uid for listening to children updates. - * - * The function is responsible for fetching and decrypting the latest - * version of the node metadata. If the node is not found, the event - * is silently skipped as the node will be fetched and decrypted when - * requested by the client. - * - * @throws Only if the client's callback throws. - */ -export async function notifyListenersByEvent(logger: Logger, event: DriveEvent, listeners: Listeners, cache: NodesCache, nodesAccess: NodesAccess) { - if (event.type === DriveEventType.ShareWithMeUpdated) { - return; - } - - const subscribedListeners = listeners.filter(({ condition }) => condition(event)); - const eventMatchingCondition = subscribedListeners.length > 0; - - if ([DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.NodeUpdatedMetadata].includes(event.type) && eventMatchingCondition) { - if (subscribedListeners.length) { - let node; - try { - node = await nodesAccess.getNode(event.nodeUid); - } catch (error: unknown) { - logger.error(`Skipping node update event to listener`, error); - return; + if (error instanceof Error) { + // FIXME throw CacheMissException error and catch it + if (error.message === 'Entity not found') { + return; + } } - subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node: convertInternalNode(node) })); - } - } - - if ( - ((event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) && !eventMatchingCondition) - || event.type === DriveEventType.NodeDeleted - ) { - let node: DecryptedNode; - try { - node = await cache.getNode(event.nodeUid); - } catch {} - - const subscribedListeners = listeners.filter(({ condition }) => condition({ - parentNodeUid: node?.parentUid, - isTrashed: !!node?.trashTime || false, - })); - - if (subscribedListeners.length) { - subscribedListeners.forEach(({ callback }) => callback({ type: 'remove', uid: event.nodeUid })); + this.logger.error(`Failed to update node cache for event: ${event.eventId}`, error); } } } diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts deleted file mode 100644 index 480188ff..00000000 --- a/js/sdk/src/internal/nodes/index.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount, MemberRole, NodeType } from "../../interface"; -import { DriveCrypto } from "../../crypto"; -import { MemoryCache } from "../../cache"; -import { getMockTelemetry } from "../../tests/telemetry"; -import { DriveAPIService } from "../apiService"; -import { DriveEventsService, DriveListener, DriveEvent, DriveEventType } from "../events"; -import { makeNodeUid } from "../uids"; -import { SharesService, DecryptedNode } from "./interface"; -import { initNodesModule } from './index'; - -function generateSerializedNode(uid: string, parentUid = 'volumeId~root', params: Partial = {}): string { - return JSON.stringify(generateNode(uid, parentUid, params)); -} - -function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial = {}): DecryptedNode { - return { - uid, - parentUid, - directMemberRole: MemberRole.Admin, - type: NodeType.File, - mediaType: "text", - isShared: false, - creationTime: new Date(), - trashTime: undefined, - isStale: false, - ...params, - } as DecryptedNode; -} - -describe('nodesModules integration tests', () => { - let apiService: DriveAPIService; - let driveEntitiesCache: ProtonDriveEntitiesCache; - let driveCryptoCache: ProtonDriveCryptoCache; - let account: ProtonDriveAccount; - let driveCrypto: DriveCrypto; - let eventCallbacks: DriveListener[]; - let driveEvents: DriveEventsService; - let sharesService: SharesService; - let nodesModule: ReturnType; - - beforeEach(() => { - // @ts-expect-error No need to implement all methods for mocking - apiService = {} - driveEntitiesCache = new MemoryCache(); - driveCryptoCache = new MemoryCache(); - // @ts-expect-error No need to implement all methods for mocking - account = {} - // @ts-expect-error No need to implement all methods for mocking - driveCrypto = {} - eventCallbacks = []; - // @ts-expect-error No need to implement all methods for mocking - driveEvents = { - addListener: jest.fn().mockImplementation((callback) => { - eventCallbacks.push(callback); - }), - } - // @ts-expect-error No need to implement all methods for mocking - sharesService = { - getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), - } - - nodesModule = initNodesModule( - getMockTelemetry(), - apiService, - driveEntitiesCache, - driveCryptoCache, - account, - driveCrypto, - driveEvents, - sharesService, - ); - }); - - test('should move node from one folder to another after move event', async () => { - // Prepare two folders (original and target) and a node in the original folder. - const originalFolderUid = makeNodeUid('volumeId', 'originalFolder'); - await driveEntitiesCache.setEntity(`node-${originalFolderUid}`, generateSerializedNode(originalFolderUid)); - await driveEntitiesCache.setEntity(`node-children-${originalFolderUid}`, 'loaded'); - - const targetFolderUid = makeNodeUid('volumeId', 'targetFolder'); - await driveEntitiesCache.setEntity(`node-${targetFolderUid}`, generateSerializedNode(targetFolderUid)); - await driveEntitiesCache.setEntity(`node-children-${targetFolderUid}`, 'loaded'); - - const nodeUid = makeNodeUid('volumeId', 'node1'); - await driveEntitiesCache.setEntity(`node-${nodeUid}`, generateSerializedNode(nodeUid, originalFolderUid), [`nodeParentUid:${originalFolderUid}`]); - - // Mock the API services to return the moved node. - // This is called when listing the children of the target folder after - // move event (when node marked as stale). - apiService.post = jest.fn().mockImplementation(async (url, body) => { - expect(url).toBe(`drive/v2/volumes/volumeId/links`); - return { - Links: [{ - Link: { - LinkID: 'node1', - ParentLinkID: 'targetFolder', - NameHash: 'hash', - Type: 2, - }, - File: { - ActiveRevision: {}, - }, - }], - }; - }); - jest.spyOn(nodesModule.access, 'getParentKeys').mockResolvedValue({ key: { _idx: 32131 } } as any); - - // Verify the inital state before move event is sent. - const originalBeforeMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(originalFolderUid)); - expect(originalBeforeMove).toMatchObject([{ uid: nodeUid, parentUid: originalFolderUid }]); - - const targetBeforeMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(targetFolderUid)); - expect(targetBeforeMove).toMatchObject([]); - - // Send the move event that updates the cache. - const events: DriveEvent[] = [ - { - type: DriveEventType.NodeUpdated, - nodeUid, - parentNodeUid: targetFolderUid, - isTrashed: false, - isShared: false, - isOwnVolume: true, - }, - ] - await Promise.all(eventCallbacks.map((callback) => callback(events))); - - // Verify the state after the move event, including when API service is called. - const originalAfterMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(originalFolderUid)); - expect(originalAfterMove).toMatchObject([]); - expect(apiService.post).not.toHaveBeenCalled(); - - const targetAfterMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(targetFolderUid)); - expect(targetAfterMove).toMatchObject([{ uid: nodeUid, parentUid: targetFolderUid }]); - expect(apiService.post).toHaveBeenCalledTimes(1); - }); -}); diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index b8f0444e..a10c32c6 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -1,26 +1,25 @@ import { DriveAPIService } from "../apiService"; import { DriveCrypto } from "../../crypto"; -import { DriveEventsService } from "../events"; import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; import { NodeAPIService } from "./apiService"; import { NodesCache } from "./cache"; -import { NodesEvents } from "./events"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { SharesService } from "./interface"; import { NodesAccess } from "./nodesAccess"; import { NodesManagement } from "./nodesManagement"; import { NodesRevisons } from "./nodesRevisions"; +import { NodesEventsHandler } from "./events"; export type { DecryptedNode, DecryptedRevision } from "./interface"; export { generateFileExtendedAttributes } from "./extendedAttributes"; /** * Provides facade for the whole nodes module. - * + * * The nodes module is responsible for handling node metadata, including * API communication, encryption, decryption, caching, and event handling. - * + * * This facade provides internal interface that other modules can use to * interact with the nodes. */ @@ -31,7 +30,6 @@ export function initNodesModule( driveCryptoCache: ProtonDriveCryptoCache, account: ProtonDriveAccount, driveCrypto: DriveCrypto, - driveEvents: DriveEventsService, sharesService: SharesService, ) { const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); @@ -39,14 +37,14 @@ export function initNodesModule( const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); const nodesAccess = new NodesAccess(telemetry.getLogger('nodes'), api, cache, cryptoCache, cryptoService, sharesService); - const nodesEvents = new NodesEvents(telemetry.getLogger('nodes-events'), driveEvents, cache, nodesAccess); - const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess, nodesEvents); + const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache); + const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess); const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); return { access: nodesAccess, management: nodesManagement, revisions: nodesRevisions, - events: nodesEvents, + eventHandler: nodesEventHandler, }; } diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 83ad8a6a..d9ba9558 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -26,7 +26,7 @@ interface BaseNode { /** * Interface used only internaly in the nodes module. - * + * * Outside of the module, the decrypted node interface should be used. */ export interface EncryptedNode extends BaseNode { @@ -96,7 +96,7 @@ export interface DecryptedNode extends Omit { apiService = { getNode: jest.fn(), iterateNodes: jest.fn().mockImplementation(async function* (uids: string[]) { - yield* uids.map((uid => ({ uid, parentUid: 'parentUid' } as EncryptedNode))); + yield* uids.map((uid => ({ uid, parentUid: 'volumeId~parentNodeId' } as EncryptedNode))); }), iterateChildrenNodeUids: jest.fn(), } @@ -55,49 +55,51 @@ describe('nodesAccess', () => { describe('getNode', () => { it('should get node from cache', async () => { - const node = { uid: 'nodeId', isStale: false } as DecryptedNode; + const node = { uid: 'volumeId~nodeId', isStale: false } as DecryptedNode; cache.getNode = jest.fn(() => Promise.resolve(node)); - const result = await access.getNode('nodeId'); + const result = await access.getNode('volumeId~nodeId'); expect(result).toBe(node); expect(apiService.getNode).not.toHaveBeenCalled(); }); - it('should get node from API when cahce is stale', async () => { - const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; - const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid', name: { ok: true, value: 'name' } } as DecryptedUnparsedNode; + it('should get node from API when cache is stale', async () => { + const encryptedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid' } as EncryptedNode; + const decryptedUnparsedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid', name: { ok: true, value: 'name' } } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, name: { ok: true, value: 'name' }, isStale: false, activeRevision: undefined, folder: undefined, + treeEventScopeId: "volumeId", } as DecryptedNode; const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; - cache.getNode = jest.fn(() => Promise.resolve({ uid: 'nodeId', isStale: true } as DecryptedNode)); + cache.getNode = jest.fn(() => Promise.resolve({ uid: 'volumeId~nodeId', isStale: true } as DecryptedNode)); apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); - const result = await access.getNode('nodeId'); + const result = await access.getNode('volumeId~nodeId'); expect(result).toEqual(decryptedNode); - expect(apiService.getNode).toHaveBeenCalledWith('nodeId', 'volumeId'); - expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('parentUid'); + expect(apiService.getNode).toHaveBeenCalledWith('volumeId~nodeId', 'volumeId'); + expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('volumeId~parentNodeid'); expect(cryptoService.decryptNode).toHaveBeenCalledWith(encryptedNode, 'parentKey'); expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); - expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('nodeId', decryptedKeys); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('volumeId~nodeId', decryptedKeys); }); it('should get node from API missing cache', async () => { - const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; - const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid', name: { ok: true, value: 'name' } } as DecryptedUnparsedNode; + const encryptedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid' } as EncryptedNode; + const decryptedUnparsedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid', name: { ok: true, value: 'name' } } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, name: { ok: true, value: 'name' }, isStale: false, activeRevision: undefined, folder: undefined, + treeEventScopeId: 'volumeId', } as DecryptedNode; const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; @@ -106,21 +108,22 @@ describe('nodesAccess', () => { cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); - const result = await access.getNode('nodeId'); + const result = await access.getNode('volumeId~nodeId'); expect(result).toEqual(decryptedNode); - expect(apiService.getNode).toHaveBeenCalledWith('nodeId', 'volumeId'); - expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('parentUid'); + expect(apiService.getNode).toHaveBeenCalledWith('volumeId~nodeId', 'volumeId'); + expect(cryptoCache.getNodeKeys).toHaveBeenCalledWith('volumeId~parentNodeid'); expect(cryptoService.decryptNode).toHaveBeenCalledWith(encryptedNode, 'parentKey'); expect(cache.setNode).toHaveBeenCalledWith(decryptedNode); - expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('nodeId', decryptedKeys); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledWith('volumeId~nodeId', decryptedKeys); }); it('should validate node name', async () => { - const encryptedNode = { uid: 'nodeId', parentUid: 'parentUid' } as EncryptedNode; - const decryptedUnparsedNode = { uid: 'nodeId', parentUid: 'parentUid', name: { ok: true, value: 'foo/bar' } } as DecryptedUnparsedNode; + const encryptedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid' } as EncryptedNode; + const decryptedUnparsedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid', name: { ok: true, value: 'foo/bar' } } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, name: { ok: false, error: { name: 'foo/bar', error: "Name must not contain the character '/'" } }, + treeEventScopeId: 'volumeId', } as DecryptedNode; const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; @@ -129,7 +132,7 @@ describe('nodesAccess', () => { cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); - const result = await access.getNode('nodeId'); + const result = await access.getNode('volumeId~nodeId'); expect(result).toMatchObject(decryptedNode); }); }); @@ -144,11 +147,11 @@ describe('nodesAccess', () => { }); describe('iterateChildren', () => { - const parentNode = { uid: 'parentUid', isStale: false } as DecryptedNode; - const node1 = { uid: 'node1', isStale: false } as DecryptedNode; - const node2 = { uid: 'node2', isStale: false } as DecryptedNode; - const node3 = { uid: 'node3', isStale: false } as DecryptedNode; - const node4 = { uid: 'node4', isStale: false } as DecryptedNode; + const parentNode = { uid: 'volumeId~parentNodeid', isStale: false } as DecryptedNode; + const node1 = { uid: 'volumeId~node1', isStale: false } as DecryptedNode; + const node2 = { uid: 'volumeId~node2', isStale: false } as DecryptedNode; + const node3 = { uid: 'volumeId~node3', isStale: false } as DecryptedNode; + const node4 = { uid: 'volumeId~node4', isStale: false } as DecryptedNode; beforeEach(() => { cache.getNode = jest.fn().mockResolvedValue(parentNode); @@ -163,7 +166,7 @@ describe('nodesAccess', () => { yield { ok: true, node: node4 }; }); - const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).not.toHaveBeenCalled(); expect(apiService.iterateNodes).not.toHaveBeenCalled(); @@ -178,9 +181,9 @@ describe('nodesAccess', () => { yield { ok: true, uid: node4.uid, node: node4 }; }); - const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node1, node4, node2, node3]); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], 'volumeId', undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith([node2.uid, node3.uid], 'volumeId', undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(2); expect(cache.setNode).toHaveBeenCalledTimes(2); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(2); @@ -188,26 +191,26 @@ describe('nodesAccess', () => { it('should load children uids and serve nodes from cache', async () => { apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { - yield 'node1'; - yield 'node2'; - yield 'node3'; - yield 'node4'; + yield node1.uid; + yield node2.uid; + yield node3.uid; + yield node4.uid; }); cache.getNode = jest.fn().mockImplementation((uid: string) => ({ uid, isStale: false })); - const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node1, node2, node3, node4]); - expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); + expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('volumeId~parentNodeid', undefined); expect(apiService.iterateNodes).not.toHaveBeenCalled(); - expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); + expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('volumeId~parentNodeid'); }); it('should load from API', async () => { apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { - yield 'node1'; - yield 'node2'; - yield 'node3'; - yield 'node4'; + yield node1.uid; + yield node2.uid; + yield node3.uid; + yield node4.uid; }); cache.getNode = jest.fn().mockImplementation((uid: string) => { if (uid === parentNode.uid) { @@ -216,21 +219,21 @@ describe('nodesAccess', () => { throw new Error('Entity not found'); }); - const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node1, node2, node3, node4]); - expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('parentUid', undefined); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], 'volumeId', undefined); + expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('volumeId~parentNodeid', undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], 'volumeId', undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); - expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); + expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('volumeId~parentNodeid'); }); it('should remove from cache if missing on API', async () => { apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { - yield 'node1'; - yield 'node2'; - yield 'node3'; + yield node1.uid; + yield node2.uid; + yield node3.uid; }); cache.getNode = jest.fn().mockImplementation((uid: string) => { if (uid === parentNode.uid) { @@ -243,16 +246,16 @@ describe('nodesAccess', () => { yield* uids.slice(1).map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)); }); - const result = await Array.fromAsync(access.iterateFolderChildren('parentUid')); + const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node2, node3]); - expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); + expect(cache.removeNodes).toHaveBeenCalledWith([node1.uid]); }); it('should yield all decryptable children before throwing error', async () => { apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { - yield 'node1'; - yield 'node2'; - yield 'node3'; + yield 'volumeId~node1'; + yield 'volumeId~node2'; + yield 'volumeId~node3'; }); cache.getNode = jest.fn().mockImplementation((uid: string) => { if (uid === parentNode.uid) { @@ -261,7 +264,7 @@ describe('nodesAccess', () => { throw new Error('Entity not found'); }); cryptoService.decryptNode = jest.fn().mockImplementation((encryptedNode: EncryptedNode) => { - if (encryptedNode.uid === 'node2') { + if (encryptedNode.uid === 'volumeId~node2') { throw new DecryptionError('Decryption failed'); } return Promise.resolve({ @@ -270,11 +273,11 @@ describe('nodesAccess', () => { }); }); - const generator = access.iterateFolderChildren('parentUid'); + const generator = access.iterateFolderChildren('volumeId~parentNodeid'); const node1 = await generator.next(); - expect(node1.value).toMatchObject({ uid: 'node1' }); + expect(node1.value).toMatchObject({ uid: 'volumeId~node1' }); const node2 = await generator.next(); - expect(node2.value).toMatchObject({ uid: 'node3' }); + expect(node2.value).toMatchObject({ uid: 'volumeId~node3' }); const node3 = generator.next(); await expect(node3).rejects.toThrow('Failed to decrypt some nodes'); try { @@ -289,10 +292,10 @@ describe('nodesAccess', () => { describe('iterateTrashedNodes', () => { const volumeId = 'volumeId'; - const node1 = { uid: 'node1', isStale: false } as DecryptedNode; - const node2 = { uid: 'node2', isStale: false } as DecryptedNode; - const node3 = { uid: 'node3', isStale: false } as DecryptedNode; - const node4 = { uid: 'node4', isStale: false } as DecryptedNode; + const node1 = { uid: 'volumeId~node1', isStale: false } as DecryptedNode; + const node2 = { uid: 'volumeId~node2', isStale: false } as DecryptedNode; + const node3 = { uid: 'volumeId~node3', isStale: false } as DecryptedNode; + const node4 = { uid: 'volumeId~node4', isStale: false } as DecryptedNode; beforeEach(() => { shareService.getMyFilesIDs = jest.fn().mockResolvedValue({ volumeId }); @@ -321,7 +324,7 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateTrashedNodes()); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3', 'node4'], volumeId, undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], volumeId, undefined); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); @@ -333,20 +336,20 @@ describe('nodesAccess', () => { }); apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { // Skip first node - make it missing. - yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)); + yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'volumeId~parentNodeid' } as EncryptedNode)); }); const result = await Array.fromAsync(access.iterateTrashedNodes()); expect(result).toMatchObject([node2, node3, node4]); - expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); + expect(cache.removeNodes).toHaveBeenCalledWith(['volumeId~node1']); }); }); describe('iterateNodes', () => { - const node1 = { uid: 'node1', isStale: false } as DecryptedNode; - const node2 = { uid: 'node2', isStale: false } as DecryptedNode; - const node3 = { uid: 'node3', isStale: false } as DecryptedNode; - const node4 = { uid: 'node4', isStale: false } as DecryptedNode; + const node1 = { uid: 'volumeId~node1', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; + const node2 = { uid: 'volumeId~node2', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; + const node3 = { uid: 'volumeId~node3', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; + const node4 = { uid: 'volume~node4', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; it('should serve fully from cache', async () => { cache.iterateNodes = jest.fn().mockImplementation(async function* () { @@ -356,7 +359,7 @@ describe('nodesAccess', () => { yield { ok: true, node: node4 }; }); - const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); + const result = await Array.fromAsync(access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'])); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateNodes).not.toHaveBeenCalled(); }); @@ -364,52 +367,55 @@ describe('nodesAccess', () => { it('should load from API', async () => { cache.iterateNodes = jest.fn().mockImplementation(async function* () { yield { ok: true, node: node1 }; - yield { ok: false, uid: 'node2' }; - yield { ok: false, uid: 'node3' }; + yield { ok: false, uid: 'volumeId~node2' }; + yield { ok: false, uid: 'volumeId~node3' }; yield { ok: true, node: node4 }; }); - const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3', 'node4'])); + const result = await Array.fromAsync(access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'])); expect(result).toMatchObject([node1, node4, node2, node3]); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['node2', 'node3'], 'volumeId', undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith(['volumeId~node2', 'volumeId~node3'], 'volumeId', undefined); }); it('should remove from cache if missing on API and return back to caller', async () => { cache.iterateNodes = jest.fn().mockImplementation(async function* () { - yield { ok: false, uid: 'node1' }; - yield { ok: false, uid: 'node2' }; - yield { ok: false, uid: 'node3' }; + yield { ok: false, uid: 'volumeId~node1' }; + yield { ok: false, uid: 'volumeId~node2' }; + yield { ok: false, uid: 'volumeId~node3' }; }); apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { // Skip first node - make it missing. - yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'parentUid' } as EncryptedNode)); + yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'volumeId~parentNodeid' } as EncryptedNode)); }); - const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3'])); - expect(result).toMatchObject([node2, node3, {missingUid: 'node1'}]); - expect(cache.removeNodes).toHaveBeenCalledWith(['node1']); + const result = await Array.fromAsync(access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3'])); + expect(result).toMatchObject([node2, node3, {missingUid: 'volumeId~node1'}]); + expect(cache.removeNodes).toHaveBeenCalledWith(['volumeId~node1']); }); it('should return degraded node if parent cannot be decrypted', async () => { cache.iterateNodes = jest.fn().mockImplementation(async function* () { - yield { ok: false, uid: 'node1' }; - yield { ok: false, uid: 'node2' }; - yield { ok: false, uid: 'node3' }; + yield { ok: false, uid: 'volumeId~node1' }; + yield { ok: false, uid: 'volumeId~node2' }; + yield { ok: false, uid: 'volumeId~node3' }; }); const encryptedCrypto = { signatureEmail: 'signatureEmail', nameSignatureEmail: 'nameSignatureEmail', }; apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { - yield* uids.map((uid) => ({ + yield* uids.map((uid) => { + const parentUid = uid.replace('node', 'parentOfNode'); + return { uid, - parentUid: `parentUidFor:${uid}`, + parentUid, encryptedCrypto, - } as EncryptedNode)); + } as EncryptedNode + }); }); const decryptionError = new DecryptionError('Parent cannot be decrypted'); jest.spyOn(access, 'getParentKeys').mockImplementation(async ({ parentUid }) => { - if (parentUid === 'parentUidFor:node1') { + if (parentUid === 'volumeId~parentOfNode1') { throw decryptionError; } return { @@ -417,12 +423,12 @@ describe('nodesAccess', () => { } as any; } ); - const result = await Array.fromAsync(access.iterateNodes(['node1', 'node2', 'node3'])); + const result = await Array.fromAsync(access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3'])); expect(result).toEqual([ { ...node1, encryptedCrypto, - parentUid: 'parentUidFor:node1', + parentUid: 'volumeId~parentOfNode1', name: { ok: false, error: decryptionError }, keyAuthor: { ok: false, error: { claimedAuthor: 'signatureEmail', error: decryptionError.message } }, nameAuthor: { ok: false, error: { claimedAuthor: 'nameSignatureEmail', error: decryptionError.message } }, @@ -457,7 +463,7 @@ describe('nodesAccess', () => { it('should get node parent keys', async () => { cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - const result = await access.getParentKeys({ shareId: undefined, parentUid: 'parentUid' }); + const result = await access.getParentKeys({ shareId: undefined, parentUid: 'volumeId~parentNodeid' }); expect(result).toEqual({ key: 'parentKey' }); expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); }); @@ -465,7 +471,7 @@ describe('nodesAccess', () => { it('should get node parent keys even if share is set', async () => { cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - const result = await access.getParentKeys({ shareId: 'shareId', parentUid: 'parentUid' }); + const result = await access.getParentKeys({ shareId: 'shareId', parentUid: 'volume1~parentNodeid' }); expect(result).toEqual({ key: 'parentKey' }); expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); }); @@ -477,7 +483,7 @@ describe('nodesAccess', () => { apiService.getNode = jest.fn(() => Promise.reject(new Error('API called'))); try { - await access.getNodeKeys('nodeId'); + await access.getNodeKeys('volumeId~nodeId'); throw new Error('Expected error'); } catch (error: unknown) { expect(`${error}`).toBe('Error: API called'); @@ -490,7 +496,7 @@ describe('nodesAccess', () => { const nodeUid = 'nodeUid'; const node = { uid: nodeUid, - parentUid: 'parentUid', + parentUid: 'volume1~parentNodeid', encryptedName: 'encryptedName', } as DecryptedNode; @@ -553,4 +559,39 @@ describe('nodesAccess', () => { expect(result).toBe('https://drive.proton.me/shareId/folder/nodeId'); }); }); + + describe('notifyNodeChanged', () => { + it('should mark node as stale', async () => { + const node = { uid: 'volumeId~nodeId', isStale: false } as DecryptedNode; + cache.getNode = jest.fn(() => Promise.resolve(node)); + cache.setNode = jest.fn(); + await access.notifyNodeChanged(node.uid); + expect(cache.getNode).toHaveBeenCalledWith(node.uid); + expect(cache.setNode).toHaveBeenCalledWith({...node, isStale: true}); + }); + it('should update parent if needed', async () => { + const node = { uid: 'volumeId~nodeId', parentUid: 'v1~pn1', isStale: false } as DecryptedNode; + cache.getNode = jest.fn(() => Promise.resolve(node)); + cache.setNode = jest.fn(); + await access.notifyNodeChanged(node.uid, 'v1~pn2'); + expect(cache.getNode).toHaveBeenCalledWith(node.uid); + expect(cache.setNode).toHaveBeenCalledWith({...node, parentUid: 'v1~pn2', isStale: true}); + }); + }); + + describe('notifyChildCreated', () => { + it('should reset parent listing', async () => { + const nodeUid = 'VolumeId1~NodeId1'; + cache.resetFolderChildrenLoaded = jest.fn(); + await access.notifyChildCreated(nodeUid); + expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith(nodeUid); + }); + }); + + describe('notifyNodeDeleted', () => { + it('should reset parent listing', async () => { + await access.notifyNodeDeleted('v1~n1'); + expect(cache.removeNodes).toHaveBeenCalledWith(['v1~n1']); + }); + }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index c3f76108..77a4d901 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -28,7 +28,7 @@ const DECRYPTION_CONCURRENCY = 15; /** * Provides access to node metadata. - * + * * The node access module is responsible for fetching, decrypting and caching * nodes metadata. */ @@ -140,6 +140,42 @@ export class NodesAccess { yield* batchLoading.loadRest(); } + /** + * Call to invalidate the folder listing cache. This should be refactored into a clean + * cache layer once the cache is split off. + */ + async notifyChildCreated(nodeUid: string): Promise { + await this.cache.resetFolderChildrenLoaded(nodeUid); + } + + /** + * Call to invalidate the node cache when a node changes. Parent can be set after a move + * to ensure parent listing of new parent is up to date if cached. + * This should be refactored into a clean cache layer once the cache is split off. + */ + async notifyNodeChanged(nodeUid: string, newParentUid?: string): Promise { + try { + const node = await this.cache.getNode(nodeUid); + if (node.isStale && newParentUid === null) { + return; + } + node.isStale = true; + if (newParentUid) { + node.parentUid = newParentUid; + } + await this.cache.setNode(node); + } catch (error: unknown) { + this.logger.warn(`Failed to set node ${nodeUid} as stale after sharing: ${error}`); + } + } + + /** + * Call to remove a node from cache. This should be refactored when the cache is split off. + */ + async notifyNodeDeleted(nodeUid: string): Promise { + await this.cache.removeNodes([nodeUid]); + } + private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { const { volumeId: ownVolumeId } = await this.shareService.getMyFilesIDs(); const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); @@ -217,6 +253,7 @@ export class NodesAccess { error: getErrorMessage(error), }), errors: [error], + treeEventScopeId: splitNodeUid(encryptedNode.uid).volumeId, }, }; } @@ -272,6 +309,7 @@ export class NodesAccess { ...extendedAttributes, }), folder: undefined, + treeEventScopeId: splitNodeUid(unparsedNode.uid).volumeId, } } @@ -286,6 +324,7 @@ export class NodesAccess { folder: extendedAttributes ? { ...extendedAttributes, } : undefined, + treeEventScopeId: splitNodeUid(unparsedNode.uid).volumeId, } } diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 824b71e0..94487fe9 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -2,16 +2,15 @@ import { NodeAPIService } from "./apiService"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; import { NodesAccess } from './nodesAccess'; -import { NodesEvents } from './events'; import { DecryptedNode } from './interface'; import { NodesManagement } from './nodesManagement'; +import { NodeResult } from "../../interface"; describe('NodesManagement', () => { let apiService: NodeAPIService; let cryptoCache: NodesCryptoCache; let cryptoService: NodesCryptoService; let nodesAccess: NodesAccess; - let nodesEvents: NodesEvents; let management: NodesManagement; let nodes: { [uid: string]: DecryptedNode }; @@ -50,9 +49,15 @@ describe('NodesManagement', () => { apiService = { renameNode: jest.fn(), moveNode: jest.fn(), - trashNodes: jest.fn(), - restoreNodes: jest.fn(), - deleteNodes: jest.fn(), + trashNodes: jest.fn(async function* (uids) { + yield* uids.map((uid) => ({ok: true, uid} as NodeResult)) + }), + restoreNodes: jest.fn(async function* (uids) { + yield* uids.map((uid) => ({ok: true, uid} as NodeResult)) + }), + deleteNodes: jest.fn(async function* (uids) { + yield* uids.map((uid) => ({ok: true, uid} as NodeResult)) + }), createFolder: jest.fn(), } // @ts-expect-error No need to implement all methods for mocking @@ -91,15 +96,11 @@ describe('NodesManagement', () => { nameSessionKey: `${uid}-nameSessionKey`, })), getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: "root-email", addressKey: "root-key" }), - } - // @ts-expect-error No need to implement all methods for mocking - nodesEvents = { - nodeCreated: jest.fn(), - nodeUpdated: jest.fn(), - nodesDeleted: jest.fn(), + notifyNodeChanged: jest.fn(), + notifyNodeDeleted: jest.fn(), } - management = new NodesManagement(apiService, cryptoCache, cryptoService, nodesAccess, nodesEvents); + management = new NodesManagement(apiService, cryptoCache, cryptoService, nodesAccess); }); it('renameNode manages rename and updates cache', async () => { @@ -124,7 +125,7 @@ describe('NodesManagement', () => { { hash: nodes.nodeUid.hash }, { encryptedName: 'newArmoredNodeName', nameSignatureEmail: 'newSignatureEmail', hash: 'newHash' } ); - expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith(newNode); + expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledWith('nodeUid'); }); it('moveNode manages move and updates cache', async () => { @@ -173,7 +174,7 @@ describe('NodesManagement', () => { signatureEmail: undefined, }, ); - expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith(newNode); + expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledWith('nodeUid', 'newParentNodeUid'); }); it('moveNode manages move of anonymous node', async () => { @@ -219,6 +220,26 @@ describe('NodesManagement', () => { ...encryptedCrypto }, ); - expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith(newNode); }); + + it("trashes node and updates cache", async () => { + const uids = ['v1~n1', 'v1~n2']; + const trashed = new Set(); + for await (const node of management.trashNodes(uids)) { + trashed.add(node.uid); + } + expect(trashed).toEqual(new Set(uids)); + expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledTimes(2); + }); + + it("restores node and updates cache", async () => { + const uids = ['v1~n1', 'v1~n2']; + const restored = new Set(); + for await (const node of management.restoreNodes(uids)) { + restored.add(node.uid); + } + expect(restored).toEqual(new Set(uids)); + expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledTimes(2); + }); + }); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index d47c29e6..d8d0eed6 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -6,11 +6,11 @@ import { getErrorMessage } from '../errors'; import { NodeAPIService } from "./apiService"; import { NodesCryptoCache } from "./cryptoCache"; import { NodesCryptoService } from "./cryptoService"; -import { NodesEvents } from './events'; import { DecryptedNode } from "./interface"; import { NodesAccess } from "./nodesAccess"; import { validateNodeName } from "./validations"; import { generateFolderExtendedAttributes } from "./extendedAttributes"; +import { splitNodeUid } from '../uids'; /** * Provides high-level actions for managing nodes. @@ -27,13 +27,11 @@ export class NodesManagement { private cryptoCache: NodesCryptoCache, private cryptoService: NodesCryptoService, private nodesAccess: NodesAccess, - private nodesEvents: NodesEvents, ) { this.apiService = apiService; this.cryptoCache = cryptoCache; this.cryptoService = cryptoService; this.nodesAccess = nodesAccess; - this.nodesEvents = nodesEvents; } async renameNode(nodeUid: string, newName: string, options = { allowRenameRootNode: false }): Promise { @@ -71,6 +69,7 @@ export class NodesManagement { hash: hash, } ); + await this.nodesAccess.notifyNodeChanged(nodeUid); const newNode: DecryptedNode = { ...node, name: resultOk(newName), @@ -78,7 +77,6 @@ export class NodesManagement { nameAuthor: resultOk(signatureEmail), hash, } - await this.nodesEvents.nodeUpdated(newNode); return newNode; } @@ -161,44 +159,24 @@ export class NodesManagement { keyAuthor: resultOk(encryptedCrypto.signatureEmail), nameAuthor: resultOk(encryptedCrypto.nameSignatureEmail), }; - await this.nodesEvents.nodeUpdated(newNode); + await this.nodesAccess.notifyNodeChanged(node.uid, newParentUid); return newNode; } async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodesOrMissing = await Array.fromAsync(this.nodesAccess.iterateNodes(nodeUids, signal)); - const nodes = nodesOrMissing.filter(node => !('missingUid' in node)) as DecryptedNode[]; - for await (const result of this.apiService.trashNodes(nodeUids, signal)) { if (result.ok) { - const node = nodes.find(node => node.uid === result.uid); - if (node) { - await this.nodesEvents.nodeUpdated({ - ...node, - trashTime: new Date(), - }); - } + await this.nodesAccess.notifyNodeChanged(result.uid); } - yield result; } } async* restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodesOrMissing = await Array.fromAsync(this.nodesAccess.iterateNodes(nodeUids, signal)); - const nodes = nodesOrMissing.filter(node => !('missingUid' in node)) as DecryptedNode[]; - for await (const result of this.apiService.restoreNodes(nodeUids, signal)) { if (result.ok) { - const node = nodes.find(node => node.uid === result.uid); - if (node) { - await this.nodesEvents.nodeUpdated({ - ...node, - trashTime: undefined, - }); - } + await this.nodesAccess.notifyNodeChanged(result.uid); } - yield result; } } @@ -209,13 +187,13 @@ export class NodesManagement { for await (const result of this.apiService.deleteNodes(nodeUids, signal)) { if (result.ok) { deletedNodeUids.push(result.uid); + await this.nodesAccess.notifyNodeDeleted(result.uid); } yield result; } - - await this.nodesEvents.nodesDeleted(deletedNodeUids); } + // FIXME create test for create folder async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { validateNodeName(folderName); @@ -244,6 +222,8 @@ export class NodesManagement { armoredExtendedAttributes: encryptedCrypto.folder.armoredExtendedAttributes, }); + await this.nodesAccess.notifyChildCreated(parentNodeUid); + const node: DecryptedNode = { // Internal metadata hash: encryptedCrypto.hash, @@ -265,9 +245,9 @@ export class NodesManagement { keyAuthor: resultOk(encryptedCrypto.signatureEmail), nameAuthor: resultOk(encryptedCrypto.signatureEmail), name: resultOk(folderName), + treeEventScopeId: splitNodeUid(nodeUid).volumeId, } - await this.nodesEvents.nodeCreated(node); await this.cryptoCache.setNodeKeys(nodeUid, keys); return node; } diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index 4072acc6..04ffd734 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -4,8 +4,10 @@ import { Volume } from "./interface"; /** * Provides caching for shares and volume metadata. - * + * * The cache is responsible for serialising and deserialising volume metadata. + * + * This is only intended for the owner's main volume. There is no cache invalidation. */ export class SharesCache { constructor(private logger: Logger, private driveCache: ProtonDriveEntitiesCache) { @@ -61,4 +63,4 @@ function deserializeVolume(shareData: string): Volume { throw new Error('Invalid volume data'); } return volume; -} \ No newline at end of file +} diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index d0f654e7..22b2a2c3 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -43,7 +43,7 @@ export class SharesManager { /** * It returns the IDs of the My files section. - * + * * If the default volume or My files section doesn't exist, it creates it. */ async getMyFilesIDs(): Promise { @@ -85,12 +85,12 @@ export class SharesManager { /** * Creates new default volume for the user. - * + * * It generates the volume bootstrap, creates the volume on the server, * and caches the volume metadata. - * + * * User can have only one default volume. - * + * * @throws If the volume cannot be created (e.g., one already exists). */ private async createVolume(): Promise { @@ -117,7 +117,7 @@ export class SharesManager { * It is a high-level action that retrieves the private key for a share. * If prefers to use the cache, but if the key is not there, it fetches * the share from the API, decrypts it, and caches it. - * + * * @param shareId - The ID of the share. * @returns The private key for the share. * @throws If the share is not found or cannot be decrypted, or cached. @@ -195,6 +195,10 @@ export class SharesManager { }; } + async isOwnVolume(volumeId: string): Promise{ + return (await this.getMyFilesIDs()).volumeId === volumeId; + } + async getVolumeMetricContext(volumeId: string): Promise { const { volumeId: myVolumeId } = await this.getMyFilesIDs(); diff --git a/js/sdk/src/internal/sharing/cache.ts b/js/sdk/src/internal/sharing/cache.ts index 478dce50..23c8d277 100644 --- a/js/sdk/src/internal/sharing/cache.ts +++ b/js/sdk/src/internal/sharing/cache.ts @@ -3,7 +3,7 @@ import { SharingType } from "./interface"; /** * Provides caching for shared by me and with me listings. - * + * * The cache is responsible for serialising and deserialising the node * UIDs for each sharing type. Also, ensuring that only full lists are * cached. diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index 2b2e029b..6f0a4124 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -1,13 +1,16 @@ import { getMockLogger } from "../../tests/logger"; import { DriveEvent, DriveEventType } from "../events"; -import { NodesService, SharingType } from "./interface"; import { SharingCache } from "./cache"; -import { handleSharedByMeNodes, handleSharedWithMeNodes } from "./events"; import { SharingAccess } from "./sharingAccess"; +import { SharingEventHandler } from "./events"; +import { SharesManager } from "../shares/manager"; + +// FIXME: test tree_refresh and tree_remove describe("handleSharedByMeNodes", () => { let cache: SharingCache; - let nodesService: NodesService; + let sharingEventHandler: SharingEventHandler; + let sharesManager: SharesManager; beforeEach(() => { jest.clearAllMocks(); @@ -17,197 +20,105 @@ describe("handleSharedByMeNodes", () => { addSharedByMeNodeUid: jest.fn(), removeSharedByMeNodeUid: jest.fn(), setSharedWithMeNodeUids: jest.fn(), + getSharedByMeNodeUids: jest.fn().mockResolvedValue(["cachedNodeUid"]), }; - // @ts-expect-error No need to implement all methods for mocking - nodesService = { - getNode: jest.fn().mockResolvedValue({ uid: 'nodeUid', name: { ok: true, value: 'name' } }), - }; + sharesManager = { + isOwnVolume: jest.fn(async (volumeId: string) => volumeId === 'MyVolume1'), + } as any; + sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager); }); - const testCases: { - title: string, - existingNodeUids: string[], - event: DriveEvent, - added: boolean, - removed: boolean, - }[] = [ - { - title: "should add if new own shared node is created", - existingNodeUids: [], - event: { + describe("node events trigger cache update", () => { + + it("should add if new own shared node is created", async () => { + const event: DriveEvent = { + eventId: "1", type: DriveEventType.NodeCreated, - nodeUid: "nodeUid", + nodeUid: "newNodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: true, - isOwnVolume: true, - }, - added: true, - removed: false, - }, - { - title: "should not add if new shared node is not own", - existingNodeUids: [], - event: { + treeEventScopeId: "MyVolume1", + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("newNodeUid"); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + + }); + + // FIXME enable when volume ownership is handled + test.skip("should not add if new shared node is not own", async () => { + const event: DriveEvent = { + eventId: "1", type: DriveEventType.NodeCreated, - nodeUid: "nodeUid", + nodeUid: "newNodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: true, - isOwnVolume: false, - }, - added: false, - removed: false, - }, - { - title: "should not add if new own node is not shared", - existingNodeUids: [], - event: { + treeEventScopeId: "NotOwnVolume", + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + + }); + + it("should not add if new own node is not shared", async () => { + const event: DriveEvent = { type: DriveEventType.NodeCreated, - nodeUid: "nodeUid", + nodeUid: "newNodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: false, - isOwnVolume: true, - }, - added: false, - removed: false, - }, - { - title: "should add if own node is updated and shared", - existingNodeUids: [], - event: { - type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isTrashed: false, - isShared: true, - isOwnVolume: true, - }, - added: true, - removed: false, - }, - { - title: "should add/update if shared node is updated", - existingNodeUids: ["nodeUid"], - event: { + eventId: "1", + treeEventScopeId: "MyVolume1", + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it("should add if own node is updated and shared", async () => { + const event: DriveEvent = { type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", + nodeUid: "cachedNodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: true, - isOwnVolume: true, - }, - added: true, - removed: false, - }, - { - title: "should remove if shared node is un-shared", - existingNodeUids: ["nodeUid"], - event: { - type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isTrashed: false, - isShared: false, - isOwnVolume: true, - }, - added: false, - removed: true, - }, - { - title: "should not remove if non-shared node is updated", - existingNodeUids: [], - event: { + eventId: "1", + treeEventScopeId: "MyVolume1", + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("cachedNodeUid"); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it("should remove if shared node is un-shared", async () => { + const event: DriveEvent = { type: DriveEventType.NodeUpdated, - nodeUid: "nodeUid", + nodeUid: "cachedNodeUid", parentNodeUid: "parentUid", isTrashed: false, isShared: false, - isOwnVolume: true, - }, - added: false, - removed: false, - }, - { - title: "should remove if shared node is deleted", - existingNodeUids: ["nodeUid"], - event: { - type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isOwnVolume: true, - }, - added: false, - removed: true, - }, - { - title: "should not remove if non-shared node is deleted", - existingNodeUids: [], - event: { - type: DriveEventType.NodeDeleted, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", - isOwnVolume: true, - }, - added: false, - removed: false, - }, - ]; - - describe("with listeners", () => { - testCases.map(({ title, existingNodeUids, event, added, removed }) => { - it(title, async () => { - cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(existingNodeUids); - const listener = jest.fn(); - const listeners = [{ type: SharingType.SharedByMe, callback: listener }]; - - await handleSharedByMeNodes(getMockLogger(), event, cache, listeners, nodesService); - - if (added) { - expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid' })); - } else { - expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); - } - if (removed) { - expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); - expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid' }); - } else { - expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); - } - if (!added && !removed) { - expect(listener).not.toHaveBeenCalled(); - } - - expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); + eventId: "1", + treeEventScopeId: "MyVolume1", + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith("cachedNodeUid"); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); - }); - - describe("without listeners", () => { - testCases.map(({ title, existingNodeUids, event, added, removed }) => { - it(title, async () => { - cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(existingNodeUids); - const listener = jest.fn(); - const listeners = [{ type: SharingType.sharedWithMe, callback: listener }]; - - await handleSharedByMeNodes(getMockLogger(), event, cache, listeners, nodesService); - - if (added) { - expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); - } else { - expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); - } - if (removed) { - expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith("nodeUid"); - } else { - expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); - } - expect(listener).not.toHaveBeenCalled(); - expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); + it("should remove if shared node is deleted", async () => { + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + nodeUid: "cachedNodeUid", + parentNodeUid: "parentUid", + eventId: "1", + treeEventScopeId: "MyVolume1", + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith("cachedNodeUid"); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); }); }); @@ -215,6 +126,7 @@ describe("handleSharedByMeNodes", () => { describe("handleSharedWithMeNodes", () => { let cache: SharingCache; let sharingAccess: SharingAccess; + let sharesManager: SharesManager; beforeEach(() => { jest.clearAllMocks(); @@ -228,41 +140,23 @@ describe("handleSharedWithMeNodes", () => { sharingAccess = { iterateSharedNodesWithMe: jest.fn(), }; + sharesManager = { + isOwnVolume: jest.fn(async (volumeId: string) => volumeId === 'MyVolume1'), + } as any; }); it("should only update cache", async () => { const event: DriveEvent = { - type: DriveEventType.ShareWithMeUpdated, + type: DriveEventType.SharedWithMeUpdated, + eventId: 'event1', + treeEventScopeId: 'core', }; - await handleSharedWithMeNodes(event, cache, [], sharingAccess); + const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager); + await sharingEventHandler.handleDriveEvent(event); expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); expect(cache.getSharedWithMeNodeUids).not.toHaveBeenCalled(); expect(sharingAccess.iterateSharedNodesWithMe).not.toHaveBeenCalled(); }); - - it("should update cache and notify listener", async () => { - cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(["nodeUid1", "nodeUid4"]); - sharingAccess.iterateSharedNodesWithMe = jest.fn().mockImplementation(async function* () { - yield { uid: "nodeUid1", name: { ok: true, value: "name1" } }; - yield { uid: "nodeUid2", name: { ok: true, value: "name2" } }; - yield { uid: "nodeUid3", name: { ok: true, value: "name3" } }; - }); - const listener = jest.fn(); - const event: DriveEvent = { - type: DriveEventType.ShareWithMeUpdated, - }; - - await handleSharedWithMeNodes(event, cache, [{ type: SharingType.sharedWithMe, callback: listener }], sharingAccess); - - expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); - expect(cache.getSharedWithMeNodeUids).toHaveBeenCalled(); - expect(sharingAccess.iterateSharedNodesWithMe).toHaveBeenCalled(); - expect(listener).toHaveBeenCalledTimes(4); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid1' })); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid2' })); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'update', uid: 'nodeUid3' })); - expect(listener).toHaveBeenCalledWith({ type: 'remove', uid: 'nodeUid4' }); - }); }); diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index f5bbcd0f..f3ce5495 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -1,166 +1,52 @@ -import { NodeEventCallback, Logger } from "../../interface"; -import { convertInternalNode } from "../../transformers"; -import { DriveEventsService, DriveEvent, DriveEventType } from "../events"; +import { Logger } from "../../interface"; +import { DriveEvent, DriveEventType } from "../events"; import { SharingCache } from "./cache"; -import { SharingType, NodesService } from "./interface"; -import { SharingAccess } from "./sharingAccess"; - -type Listeners = { - type: SharingType, - callback: NodeEventCallback, -}[]; - -/** - * Provides both event handling and subscription mechanism for user. - * - * The service is responsible for handling events regarding sharing listing - * from the DriveEventsService, and for providing a subscription mechanism - * for the user to listen to updates of specific group of nodes, such as - * any update to list of shared with me nodes. - */ -export class SharingEvents { - private listeners: Listeners = []; - - constructor(logger: Logger, events: DriveEventsService, cache: SharingCache, nodesService: NodesService, sharingAccess: SharingAccess) { - events.addListener(async (events, fullRefreshVolumeId) => { - // Technically we need to refresh only the shared by me nodes for - // own volume, and shared with me nodes only when the event comes - // as core refresh event is converted to it. - // We can optimise later, for now we refresh everything to make - // it simpler. The cache is smart enough to not do unnecessary - // requests to the API and refresh on web is rare without - // persistant cache for now. - if (fullRefreshVolumeId) { - await cache.setSharedByMeNodeUids(undefined); - await cache.setSharedWithMeNodeUids(undefined); - return +import { SharesService } from "./interface"; + +export class SharingEventHandler { + constructor(private logger: Logger, private cache: SharingCache, private shares: SharesService) { + }; + + /** + * Update cache and notify listeners accordingly for any updates + * to nodes that are shared by me. + * + * Any node create or update that is being shared, is automatically + * added to the cache and the listeners are notified about the + * update of the node. + * + * Any node delete or update that is not being shared, and the cache + * includes the node, is removed from the cache and the listeners are + * notified about the removal of the node. + * + * @throws Only if the client's callback throws. + */ + async handleDriveEvent(event: DriveEvent) { + try { + if (event.type === DriveEventType.SharedWithMeUpdated) { + await this.cache.setSharedWithMeNodeUids(undefined); + return; } - - for (const event of events) { - await handleSharedByMeNodes(logger, event, cache, this.listeners, nodesService); - await handleSharedWithMeNodes(event, cache, this.listeners, sharingAccess); + if (!(await this.shares.isOwnVolume(event.treeEventScopeId))) { + return; } - }); - } - - subscribeToSharedNodesByMe(callback: NodeEventCallback) { - this.listeners.push({ type: SharingType.SharedByMe, callback }); - return () => { - this.listeners = this.listeners.filter(listener => listener.callback !== callback); - } - } - - subscribeToSharedNodesWithMe(callback: NodeEventCallback) { - this.listeners.push({ type: SharingType.sharedWithMe, callback }); - return () => { - this.listeners = this.listeners.filter(listener => listener.callback !== callback); - } - } -} - -/** - * Update cache and notify listeners accordingly for any updates - * to nodes that are shared by me. - * - * Any node create or update that is being shared, is automatically - * added to the cache and the listeners are notified about the - * update of the node. - * - * Any node delete or update that is not being shared, and the cache - * includes the node, is removed from the cache and the listeners are - * notified about the removal of the node. - * - * @throws Only if the client's callback throws. - */ -export async function handleSharedByMeNodes(logger: Logger, event: DriveEvent, cache: SharingCache, listeners: Listeners, nodesService: NodesService) { - if (event.type === DriveEventType.ShareWithMeUpdated || !event.isOwnVolume) { - return; - } - - const subscribedListeners = listeners.filter(({ type }) => type === SharingType.SharedByMe); - - if ([DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.NodeUpdatedMetadata].includes(event.type) && event.isShared) { - try { - await cache.addSharedByMeNodeUid(event.nodeUid); - } catch (error: unknown) { - logger.error(`Skipping shared by me node cache update`, error); - } - if (subscribedListeners.length) { - let node; - try { - node = await nodesService.getNode(event.nodeUid); - } catch (error: unknown) { - logger.error(`Skipping shared by me node update event to listener`, error); + if (event.type === DriveEventType.NodeCreated || event.type == DriveEventType.NodeUpdated) { + if (event.isShared && !event.isTrashed) { + await this.cache.addSharedByMeNodeUid(event.nodeUid); + } else { + await this.cache.removeSharedByMeNodeUid(event.nodeUid); + } return; } - subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node: convertInternalNode(node) })); - } - } - - if ( - ((event.type === DriveEventType.NodeUpdated || event.type === DriveEventType.NodeUpdatedMetadata) && !event.isShared) - || event.type === DriveEventType.NodeDeleted - ) { - let nodeWasShared = false; - try { - const cachedNodeUids = await cache.getSharedByMeNodeUids(); - nodeWasShared = cachedNodeUids.includes(event.nodeUid); - } catch { - // Cache can be empty. - } - - if (nodeWasShared) { - try { - await cache.removeSharedByMeNodeUid(event.nodeUid); - } catch (error: unknown) { - logger.error(`Skipping shared by me node cache remove`, error); + if (event.type === DriveEventType.NodeDeleted) { + await this.cache.removeSharedByMeNodeUid(event.nodeUid); + return; } - subscribedListeners.forEach(({ callback }) => callback({ type: 'remove', uid: event.nodeUid })); - } - } -} - -/** - * Update cache and notify listeners accordingly for any updates - * to nodes that are shared with me. - * - * There is only one event type that is relevant for shared with me - * nodes, which is the ShareWithMeUpdated event. The event is triggered - * when the list of shared with me nodes is updated. - * - * The cache is cleared and re-populated fully when the client - * requests the list of shared with me, or is actively listening. - * - * If the client listenes to shared with me updates, the client receives - * update to the full list of shared with me nodes, including remove - * updates for nodes that are no longer shared with me, but was before. - * - * @throws Only if the client's callback throws. - */ -export async function handleSharedWithMeNodes(event: DriveEvent, cache: SharingCache, listeners: Listeners, sharingAccess: SharingAccess) { - if (event.type !== DriveEventType.ShareWithMeUpdated) { - return; - } - - let cachedNodeUids: string[] = []; - const subscribedListeners = listeners.filter(({ type }) => type === SharingType.sharedWithMe); - if (subscribedListeners.length) { - cachedNodeUids = await cache.getSharedWithMeNodeUids(); - } - - // Clearing the cache must be first, sharingAccess is no-op if cache is set. - await cache.setSharedWithMeNodeUids(undefined); - - if (subscribedListeners.length) { - const nodeUids = []; - for await (const node of sharingAccess.iterateSharedNodesWithMe()) { - nodeUids.push(node.uid); - subscribedListeners.forEach(({ callback }) => callback({ type: 'update', uid: node.uid, node: convertInternalNode(node) })); - } - for (const nodeUid of cachedNodeUids) { - if (!nodeUids.includes(nodeUid)) { - subscribedListeners.forEach(({ callback }) => callback({ type: 'remove', uid: nodeUid })); + if (event.type === DriveEventType.TreeRefresh || event.type === DriveEventType.TreeRemove) { + await this.cache.setSharedWithMeNodeUids(undefined); } + } catch (error: unknown) { + this.logger.error(`Skipping shared by me node cache update`, error); } } } diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index 9ab9b32f..1edbeccc 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -1,18 +1,17 @@ import { ProtonDriveAccount, ProtonDriveEntitiesCache, ProtonDriveTelemetry } from "../../interface"; import { DriveCrypto } from '../../crypto'; import { DriveAPIService } from "../apiService"; -import { DriveEventsService } from "../events"; import { SharingAPIService } from "./apiService"; import { SharingCache } from "./cache"; import { SharingCryptoService } from "./cryptoService"; -import { SharingEvents } from "./events"; import { SharingAccess } from "./sharingAccess"; import { SharingManagement } from "./sharingManagement"; -import { SharesService, NodesService, NodesEvents } from "./interface"; +import { SharesService, NodesService } from "./interface"; +import { SharingEventHandler } from "./events"; /** * Provides facade for the whole sharing module. - * + * * The sharing module is responsible for handling invitations, bookmarks, * standard shares, listing shared nodes, etc. It includes API communication, * encryption, decryption, caching, and event handling. @@ -23,21 +22,19 @@ export function initSharingModule( driveEntitiesCache: ProtonDriveEntitiesCache, account: ProtonDriveAccount, crypto: DriveCrypto, - driveEvents: DriveEventsService, sharesService: SharesService, nodesService: NodesService, - nodesEvents: NodesEvents, ) { const api = new SharingAPIService(telemetry.getLogger('sharing-api'), apiService); const cache = new SharingCache(driveEntitiesCache); const cryptoService = new SharingCryptoService(telemetry, crypto, account, sharesService); const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); - const sharingEvents = new SharingEvents(telemetry.getLogger('sharing-events'), driveEvents, cache, nodesService, sharingAccess); - const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, account, sharesService, nodesService, nodesEvents); + const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, account, sharesService, nodesService); + const sharingEventHandler = new SharingEventHandler(telemetry.getLogger('sharing-event-handler'), cache, sharesService); return { access: sharingAccess, - events: sharingEvents, + eventHandler: sharingEventHandler, management: sharingManagement, }; } diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 6e9fec78..98a04d77 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -21,7 +21,7 @@ export interface EncryptedInvitationRequest { /** * Internal interface of existing invitation on the API. - * + * * This interface is used only for managing the invitations. For listing * invitations with node metadata, see `EncryptedInvitationWithNode`. */ @@ -32,7 +32,7 @@ export interface EncryptedInvitation extends EncryptedInvitationRequest { /** * Internal interface of existing invitation with the share and node metadata. - * + * * Invitation with node is used for listing shared nodes with me, so it includes * what is being shared as well. */ @@ -153,6 +153,7 @@ export interface SharesService { addressId: string, addressKey: PrivateKey, }>, + isOwnVolume(volumeId: string): Promise; } /** @@ -172,8 +173,10 @@ export interface NodesService { addressKey: PrivateKey, }>, iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; + notifyNodeChanged(nodeUid: string): Promise; } +// TODO I think this can be removed /** * Interface describing the dependencies to the nodes module. */ diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 1f7b4ff3..aba5c945 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -2,7 +2,7 @@ import { getMockLogger } from "../../tests/logger"; import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonDriveAccount, ProtonInvitation, PublicLink, resultOk } from "../../interface"; import { SharingAPIService } from "./apiService"; import { SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService, NodesEvents } from "./interface"; +import { SharesService, NodesService } from "./interface"; import { SharingManagement } from "./sharingManagement"; describe("SharingManagement", () => { @@ -11,7 +11,6 @@ describe("SharingManagement", () => { let accountService: ProtonDriveAccount; let sharesService: SharesService; let nodesService: NodesService; - let nodesEvents: NodesEvents; let sharingManagement: SharingManagement; @@ -82,12 +81,10 @@ describe("SharingManagement", () => { getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: "node-key" })), getNodePrivateAndSessionKeys: jest.fn().mockImplementation((nodeUid) => ({})), getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressKey: "volume-key" }), - } - nodesEvents = { - nodeUpdated: jest.fn(), + notifyNodeChanged: jest.fn(), } - sharingManagement = new SharingManagement(getMockLogger(), apiService, cryptoService, accountService, sharesService, nodesService, nodesEvents); + sharingManagement = new SharingManagement(getMockLogger(), apiService, cryptoService, accountService, sharesService, nodesService); }); describe("getSharingInfo", () => { @@ -175,6 +172,7 @@ describe("SharingManagement", () => { it("should create share if no exists", async () => { nodesService.getNode = jest.fn().mockImplementation((nodeUid) => ({ nodeUid, parentUid: 'parentUid', name: { ok: true, value: "name" } })); + nodesService.notifyNodeChanged = jest.fn(); const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email"] }); @@ -191,11 +189,7 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith({ - uid: nodeUid, - shareId: "newShareId", - isShared: true, - }); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(nodeUid); }); }) @@ -264,7 +258,6 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should share node with proton email with specific role", async () => { @@ -283,7 +276,6 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should update existing role", async () => { @@ -300,7 +292,6 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if no change", async () => { @@ -314,7 +305,6 @@ describe("SharingManagement", () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); @@ -340,7 +330,6 @@ describe("SharingManagement", () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should share node with external email with specific role", async () => { @@ -360,7 +349,6 @@ describe("SharingManagement", () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should update existing role", async () => { @@ -377,7 +365,6 @@ describe("SharingManagement", () => { }); expect(apiService.updateExternalInvitation).toHaveBeenCalled(); expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if no change", async () => { @@ -391,7 +378,6 @@ describe("SharingManagement", () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); @@ -429,7 +415,6 @@ describe("SharingManagement", () => { expect(apiService.inviteExternalUser).toHaveBeenCalledWith("shareId", expect.objectContaining({ inviteeEmail: "email2", }), expect.anything()); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); @@ -449,7 +434,6 @@ describe("SharingManagement", () => { expect(apiService.updateMember).toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if no change via proton user", async () => { @@ -464,7 +448,6 @@ describe("SharingManagement", () => { expect(apiService.updateMember).not.toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should update member via non-proton user", async () => { @@ -482,7 +465,6 @@ describe("SharingManagement", () => { expect(apiService.updateMember).toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if no change via non-proton user", async () => { @@ -497,7 +479,6 @@ describe("SharingManagement", () => { expect(apiService.updateMember).not.toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); }); @@ -728,7 +709,6 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should delete external invitation", async () => { @@ -745,7 +725,6 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should remove member", async () => { @@ -762,7 +741,6 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should be no-op if not shared with email", async () => { @@ -779,7 +757,6 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should remove public link", async () => { @@ -796,7 +773,6 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).not.toHaveBeenCalled(); }); it("should remove share if all is removed", async () => { @@ -808,11 +784,7 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith({ - uid: nodeUid, - shareId: undefined, - isShared: false, - }); + expect(nodesService.notifyNodeChanged).toHaveBeenCalled(); }); it("should remove share if everything is manually removed", async () => { @@ -827,11 +799,6 @@ describe("SharingManagement", () => { expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); expect(apiService.removeMember).toHaveBeenCalled(); expect(apiService.removePublicLink).toHaveBeenCalled(); - expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith({ - uid: nodeUid, - shareId: undefined, - isShared: false, - }); }); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 1ba44a0f..70cc267c 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -7,7 +7,7 @@ import { splitNodeUid } from "../uids"; import { getErrorMessage } from '../errors'; import { SharingAPIService } from "./apiService"; import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService, NodesEvents, ShareResultWithCreatorEmail, PublicLinkWithCreatorEmail } from "./interface"; +import { SharesService, NodesService, ShareResultWithCreatorEmail, PublicLinkWithCreatorEmail } from "./interface"; interface InternalShareResult extends ShareResultWithCreatorEmail { share: Share; @@ -40,7 +40,6 @@ export class SharingManagement { private account: ProtonDriveAccount, private sharesService: SharesService, private nodesService: NodesService, - private nodesEvents: NodesEvents, ) { this.logger = logger; this.apiService = apiService; @@ -48,7 +47,6 @@ export class SharingManagement { this.account = account; this.sharesService = sharesService; this.nodesService = nodesService; - this.nodesEvents = nodesEvents; } async getSharingInfo(nodeUid: string): Promise { @@ -239,7 +237,7 @@ export class SharingManagement { if (!settings) { this.logger.info(`Unsharing node ${nodeUid}`); - await this.deleteShare(nodeUid, currentSharing.share.shareId); + await this.deleteShare(currentSharing.share.shareId, nodeUid); return; } @@ -293,7 +291,7 @@ export class SharingManagement { // update local state immediately. this.logger.info(`Deleting share ${currentSharing.share.shareId} for node ${nodeUid}`); try { - await this.deleteShare(nodeUid, currentSharing.share.shareId); + await this.deleteShare(currentSharing.share.shareId, nodeUid); } catch (error: unknown) { // If deleting the share fails, we don't want to throw an error // as it might be a race condition that other client updated @@ -359,9 +357,7 @@ export class SharingManagement { base64NameKeyPacket: keys.base64NameKeyPacket, }, ); - - await this.nodesEvents.nodeUpdated({ uid: nodeUid, shareId, isShared: true }); - + await this.nodesService.notifyNodeChanged(nodeUid); return { volumeId, shareId, @@ -370,10 +366,9 @@ export class SharingManagement { } } - private async deleteShare(nodeUid: string, shareId: string): Promise { + private async deleteShare(shareId: string, nodeUid: string): Promise { await this.apiService.deleteShare(shareId); - - await this.nodesEvents.nodeUpdated({ uid: nodeUid, shareId: undefined, isShared: false }); + await this.nodesService.notifyNodeChanged(nodeUid); } private async inviteProtonUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index fa5e0a13..48b345be 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -4,14 +4,14 @@ import { DriveCrypto } from "../../crypto"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; import { FileUploader, FileRevisionUploader } from "./fileUploader"; -import { NodesService, NodesEvents, SharesService } from "./interface"; +import { NodesService, SharesService } from "./interface"; import { UploadManager } from "./manager"; import { UploadQueue } from "./queue"; import { UploadTelemetry } from "./telemetry"; /** * Provides facade for the upload module. - * + * * The upload module is responsible for handling file uploads, including * metadata generation, content upload, API communication, encryption, * and verifications. @@ -22,14 +22,13 @@ export function initUploadModule( driveCrypto: DriveCrypto, sharesService: SharesService, nodesService: NodesService, - nodesEvents: NodesEvents, clientUid?: string, ) { const api = new UploadAPIService(apiService, clientUid); const cryptoService = new UploadCryptoService(driveCrypto, nodesService); const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); - const manager = new UploadManager(telemetry, api, cryptoService, nodesService, nodesEvents, clientUid); + const manager = new UploadManager(telemetry, api, cryptoService, nodesService, clientUid); const queue = new UploadQueue(); diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 04d08d23..82b21369 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -105,6 +105,7 @@ export interface NodesService { addressKey: PrivateKey, addressKeyId: string, }>, + notifyChildCreated(nodeUid: string): Promise; } /** @@ -117,6 +118,7 @@ export interface NodesEvents { export interface NodesServiceNode { uid: string, + parentUid?: string, activeRevision?: Result, } diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 422ed17f..38301d8d 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -1,10 +1,10 @@ import { ValidationError } from "../../errors"; -import { NodeType, ProtonDriveTelemetry, RevisionState, UploadMetadata } from "../../interface"; +import { ProtonDriveTelemetry, UploadMetadata } from "../../interface"; import { getMockTelemetry } from "../../tests/telemetry"; import { ErrorCode } from "../apiService"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { NodesService, NodesEvents } from "./interface"; +import { NodesService } from "./interface"; import { UploadManager } from './manager'; describe("UploadManager", () => { @@ -12,7 +12,6 @@ describe("UploadManager", () => { let apiService: UploadAPIService; let cryptoService: UploadCryptoService; let nodesService: NodesService; - let nodesEvents: NodesEvents; let manager: UploadManager; @@ -75,8 +74,12 @@ describe("UploadManager", () => { armoredExtendedAttributes: "newNode:armoredExtendedAttributes", }), } - // @ts-expect-error No need to implement all methods for mocking nodesService = { + getNode: jest.fn(async (nodeUid: string) => ({ + uid: nodeUid, + parentUid: 'parentUid', + + })), getNodeKeys: jest.fn().mockResolvedValue({ hashKey: 'parentNode:hashKey', key: 'parentNode:nodekey', @@ -85,13 +88,10 @@ describe("UploadManager", () => { email: "signatureEmail", addressId: "addressId", }), - } - nodesEvents = { - nodeCreated: jest.fn(), - nodeUpdated: jest.fn(), + notifyChildCreated: jest.fn(async (nodeUid: string) => { return }), } - manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, nodesEvents, clientUid); + manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, clientUid); }); describe("createDraftNode", () => { @@ -209,7 +209,7 @@ describe("UploadManager", () => { it("should not delete existing draft if client UID is not set", async () => { const clientUid = undefined; - manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, nodesEvents, clientUid); + manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, clientUid); let firstCall = true; apiService.createDraft = jest.fn().mockImplementation(() => { @@ -341,29 +341,11 @@ describe("UploadManager", () => { manifest, metadata, extendedAttributes, - 1234567, ); expect(cryptoService.commitFile).toHaveBeenCalledWith(nodeRevisionDraft.nodeKeys, manifest, expect.anything()); expect(apiService.commitDraftRevision).toHaveBeenCalledWith(nodeRevisionDraft.nodeRevisionUid, expect.anything()); - expect(nodesEvents.nodeUpdated).toHaveBeenCalledWith({ - uid: "newNode:nodeUid", - activeRevision: { - ok: true, - value: { - uid: "newNode:nodeRevisionUid", - state: RevisionState.Active, - creationTime: expect.any(Date), - contentAuthor: { ok: true, value: "signatureEmail" }, - storageSize: 1234567, - claimedSize: 123456, - claimedModificationTime: extendedAttributes.modificationTime, - claimedDigests: { - sha1: "sha1", - }, - }, - }, - }); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith("parentUid"); }) it("should commit node draft", async () => { @@ -381,24 +363,11 @@ describe("UploadManager", () => { manifest, metadata, extendedAttributes, - 1234567, ); expect(cryptoService.commitFile).toHaveBeenCalledWith(nodeRevisionDraft.nodeKeys, manifest, expect.anything()); expect(apiService.commitDraftRevision).toHaveBeenCalledWith(nodeRevisionDraft.nodeRevisionUid, expect.anything()); - expect(nodesEvents.nodeCreated).toHaveBeenCalledWith(expect.objectContaining({ - uid: "newNode:nodeUid", - parentUid: "parentUid", - type: NodeType.File, - totalStorageSize: 1234567, - activeRevision: { - ok: true, - value: expect.objectContaining({ - uid: "newNode:nodeRevisionUid", - storageSize: 1234567, - }), - }, - })); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith("parentUid"); }); }); }); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index ed046c81..705b17ee 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -1,12 +1,12 @@ import { c } from "ttag"; -import { Logger, MemberRole, NodeType, ProtonDriveTelemetry, resultOk, Revision, RevisionState, UploadMetadata } from "../../interface"; +import { Logger, ProtonDriveTelemetry, UploadMetadata } from "../../interface"; import { ValidationError, NodeAlreadyExistsValidationError } from "../../errors"; import { ErrorCode } from "../apiService"; -import { DecryptedNode, generateFileExtendedAttributes } from "../nodes"; +import { generateFileExtendedAttributes } from "../nodes"; import { UploadAPIService } from "./apiService"; import { UploadCryptoService } from "./cryptoService"; -import { NodeRevisionDraft, NodesService, NodesEvents, NodeCrypto } from "./interface"; +import { NodeRevisionDraft, NodesService, NodeCrypto } from "./interface"; import { makeNodeUid, splitNodeUid } from "../uids"; /** @@ -22,7 +22,6 @@ export class UploadManager { private apiService: UploadAPIService, private cryptoService: UploadCryptoService, private nodesService: NodesService, - private nodesEvents: NodesEvents, private clientUid: string | undefined, ) { this.logger = telemetry.getLogger('upload'); @@ -241,7 +240,7 @@ export class UploadManager { async commitDraft( nodeRevisionDraft: NodeRevisionDraft, manifest: Uint8Array, - metadata: UploadMetadata, + _metadata: UploadMetadata, extendedAttributes: { modificationTime?: Date, size?: number, @@ -250,56 +249,13 @@ export class UploadManager { sha1?: string, }, }, - encryptedSize: number, ): Promise { const generatedExtendedAttributes = generateFileExtendedAttributes(extendedAttributes); const nodeCommitCrypto = await this.cryptoService.commitFile(nodeRevisionDraft.nodeKeys, manifest, generatedExtendedAttributes); await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto); - - const activeRevision = resultOk({ - uid: nodeRevisionDraft.nodeRevisionUid, - state: RevisionState.Active, - creationTime: new Date(), - storageSize: encryptedSize, - contentAuthor: resultOk(nodeCommitCrypto.signatureEmail), - claimedSize: metadata.expectedSize, - claimedModificationTime: extendedAttributes.modificationTime, - claimedDigests: { - sha1: extendedAttributes.digests?.sha1, - }, - }); - if (nodeRevisionDraft.newNodeInfo) { - const node: DecryptedNode = { - // Internal metadata - hash: nodeRevisionDraft.newNodeInfo.hash, - encryptedName: nodeRevisionDraft.newNodeInfo.encryptedName, - - // Basic node metadata - uid: nodeRevisionDraft.nodeUid, - parentUid: nodeRevisionDraft.newNodeInfo.parentUid, - type: NodeType.File, - mediaType: metadata.mediaType, - creationTime: new Date(), - totalStorageSize: encryptedSize, - - // Share node metadata - isShared: false, - directMemberRole: MemberRole.Inherited, - - // Decrypted metadata - isStale: false, - keyAuthor: resultOk(nodeRevisionDraft.nodeKeys.signatureAddress.email), - nameAuthor: resultOk(nodeRevisionDraft.nodeKeys.signatureAddress.email), - name: resultOk(nodeRevisionDraft.newNodeInfo.name), - - activeRevision, - } - await this.nodesEvents.nodeCreated(node); - } else { - await this.nodesEvents.nodeUpdated({ - uid: nodeRevisionDraft.nodeUid, - activeRevision, - }); + const node = await this.nodesService.getNode(nodeRevisionDraft.nodeUid); + if (node.parentUid) { + await this.nodesService.notifyChildCreated(node.parentUid); } } } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index c682923a..4ae0866c 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -152,7 +152,6 @@ describe('StreamUploader', () => { sha1: expect.anything(), } }, - metadata.expectedSize + numberOfExpectedBlocks * BLOCK_ENCRYPTION_OVERHEAD, ); expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 529d1f75..25372d81 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -201,13 +201,11 @@ export class StreamUploader { blockSizes: uploadedBlocks.map(block => block.originalSize), digests: this.digests.digests(), }; - const encryptedSize = uploadedBlocks.reduce((sum, block) => sum + block.encryptedSize, 0); await this.uploadManager.commitDraft( this.revisionDraft, this.manifest, this.metadata, extendedAttributes, - encryptedSize, ); } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 7b2a5d6e..784ccdc3 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -24,8 +24,6 @@ import { ThumbnailType, ThumbnailResult, SDKEvent, - DeviceEventCallback, - NodeEventCallback, } from './interface'; import { DriveCrypto, SessionKey } from './crypto'; import { DriveAPIService } from './internal/apiService'; @@ -34,17 +32,18 @@ import { initNodesModule } from './internal/nodes'; import { initSharingModule } from './internal/sharing'; import { initDownloadModule } from './internal/download'; import { initUploadModule } from './internal/upload'; -import { DriveEventsService } from './internal/events'; +import { DriveEventsService, DriveListener } from './internal/events'; import { SDKEvents } from './internal/sdkEvents'; import { getConfig } from './config'; import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator, convertInternalMissingNodeIterator, convertInternalNode } from './transformers'; import { Telemetry } from './telemetry'; import { initDevicesModule } from './internal/devices'; -import { makeNodeUid, splitNodeUid } from './internal/uids'; +import { makeNodeUid } from './internal/uids'; +import { EventSubscription } from './internal/events/interface'; /** * ProtonDriveClient is the main interface for the ProtonDrive SDK. - * + * * The client provides high-level operations for managing nodes, sharing, * and downloading/uploading files. It is the main entry point for using * the ProtonDrive SDK. @@ -63,9 +62,9 @@ export class ProtonDriveClient { public experimental: { /** * Experimental feature to return the URL of the node. - * + * * Use it when you want to open the node in the ProtonDrive web app. - * + * * It has hardcoded URLs to open in production client only. */ getNodeUrl: (nodeUid: NodeOrUid) => Promise; @@ -86,6 +85,7 @@ export class ProtonDriveClient { srpModule, config, telemetry, + latestEventIdProvider, }: ProtonDriveClientContructorParameters) { if (!telemetry) { telemetry = new Telemetry(); @@ -96,13 +96,16 @@ export class ProtonDriveClient { this.sdkEvents = new SDKEvents(telemetry); const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); const apiService = new DriveAPIService(telemetry, this.sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); - this.events = new DriveEventsService(telemetry, apiService, entitiesCache); this.shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); - this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, this.events, this.shares); - this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, this.events, this.shares, this.nodes.access, this.nodes.events); + this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, this.shares); + this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, this.shares, this.nodes.access); this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.shares, this.nodes.access, this.nodes.revisions); - this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.events, fullConfig.clientUid); + this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, fullConfig.clientUid); this.devices = initDevicesModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.management); + // These are used to keep the internal cache up to date + const cacheEventListeners: DriveListener[] = [this.nodes.eventHandler.updateNodesCacheOnEvent, this.sharing.eventHandler.handleDriveEvent]; + this.events = new DriveEventsService(telemetry, apiService, this.shares, cacheEventListeners, latestEventIdProvider); + this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); @@ -121,7 +124,7 @@ export class ProtonDriveClient { /** * Subscribes to the general SDK events. - * + * * This is not connected to the remote data updates. For that, use * and see `subscribeToRemoteDataUpdates`. * @@ -135,197 +138,39 @@ export class ProtonDriveClient { } /** - * Subscribes to the remote data updates. - * - * By default, SDK doesn't subscribe to remote data updates. If you - * cache the data locally, you need to call this method so the SDK - * keeps the local cache in sync with the remote data. - * - * Only one instance of the SDK should subscribe to remote data updates. + * Subscribes to the remote data updates for all files and folders in a + * tree. * - * Once subscribed, the SDK will poll for events for core user events and - * for own data at minimum. Updates to nodes from other users are polled - * with lower frequency depending on the number of subscriptions, and only - * after accessing them for the first time via `iterateSharedNodesWithMe`. - */ - async subscribeToRemoteDataUpdates(): Promise { - this.logger.debug('Subscribing to remote data updates'); - await this.events.subscribeToRemoteDataUpdates(); - - const { volumeId } = await this.shares.getMyFilesIDs(); - await this.events.listenToVolume(volumeId, true); - } - - /** - * Subscribe to updates of the devices. - * - * Clients should subscribe to this before beginning to list devices - * to ensure that updates are reflected once a device is in the cache. - * Subscribing before listing is also required to ensure that devices - * that are created during the listing will be recognized. - * - * ```typescript - * const unsubscribe = sdk.subscribeToDevices((event) => { - * if (event.type === 'update') { - * // Update the device in the UI - * } else if (event.type === 'remove') { - * // Remove the device from the UI - * } - * }); - * - * const devices = await Array.fromAsync(sdk.iterateDevices()); - * // Render the devices in the UI - * - * // Unsubscribe from the updates when the component is unmounted - * unsubscribe(); - * ``` + * In order to keep local data up to date, the client must call this method + * to receive events on update and to keep the SDK cache in sync. * - * @param callback - Callback to be called when the event is emitted. - * @returns Callback to unsubscribe from the event. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - subscribeToDevices(callback: DeviceEventCallback): () => void { - this.logger.debug('Subscribing to devices'); - throw new Error('Method not implemented'); - } - - /** - * Subscribe to updates of the children of the given parent node. - * - * Clients should subscribe to this before beginning to list children - * to ensure that updates are reflected once a node is in the cache. - * Subscribing before listing is also required to ensure that nodes - * that are created during the listing will be recognized. - * - * ```typescript - * const unsubscribe = sdk.subscribeToChildren(parentNodeUid, (event) => { - * if (event.type === 'update') { - * // Update the node in the UI - * } else if (event.type === 'remove') { - * // Remove the node from the UI - * } - * }); - * - * const nodes = await Array.fromAsync(sdk.iterateChildren(parentNodeUid)); - * // Render the nodes in the UI - * - * // Unsubscribe from the updates when the component is unmounted - * unsubscribe(); - * ``` + * The `treeEventScopeId` can be obtained from node properties. * - * @param parentNodeUid - Node entity or its UID string. - * @param callback - Callback to be called when the event is emitted. - * @returns Callback to unsubscribe from the event. + * Only one instance of the SDK should subscribe to updates. */ - subscribeToFolder(parentNodeUid: NodeOrUid, callback: NodeEventCallback): () => void { - this.logger.debug(`Subscribing to children of ${getUid(parentNodeUid)}`); - return this.nodes.events.subscribeToChildren(getUid(parentNodeUid), callback); + async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { + this.logger.debug('Subscribing to node updates'); + return this.events.subscribeToTreeEvents(treeEventScopeId, callback) } /** - * Subscribe to updates of the trashed nodes. - * - * Clients should subscribe to this before beginning to list trashed - * nodes to ensure that updates are reflected once a node is in the cache. - * Subscribing before listing is also required to ensure that nodes - * that are trashed during the listing will be recognized. - * - * ```typescript - * const unsubscribe = sdk.subscribeToTrashedNodes((event) => { - * if (event.type === 'update') { - * // Update the node in the UI - * } else if (event.type === 'remove') { - * // Remove the node from the UI - * } - * }); - * - * const nodes = await Array.fromAsync(sdk.iterateTrashedNodes()); - * // Render the nodes in the UI - * - * // Unsubscribe from the updates when the component is unmounted - * unsubscribe(); - * ``` + * Subscribes to sharing updates. * - * @param callback - Callback to be called when the event is emitted. - * @returns Callback to unsubscribe from the event. - */ - subscribeToTrashedNodes(callback: NodeEventCallback): () => void { - this.logger.debug('Subscribing to trashed nodes'); - return this.nodes.events.subscribeToTrashedNodes(callback); - } - - /** - * Subscribe to updates of the nodes shared by the user. - * - * Clients should subscribe to this before beginning to list shared - * nodes to ensure that updates are reflected once a node is in the cache. - * Subscribing before listing is also required to ensure that nodes - * that are shared during the listing will be recognized. - * - * ```typescript - * const unsubscribe = sdk.subscribeToSharedNodesByMe((event) => { - * if (event.type === 'update') { - * // Update the node in the UI - * } else if (event.type === 'remove') { - * // Remove the node from the UI - * } - * }); - * - * const nodes = await Array.fromAsync(sdk.iterateSharedNodes()); - * // Render the nodes in the UI - * - * // Unsubscribe from the updates when the component is unmounted - * unsubscribe(); - * ``` - * - * @param callback - Callback to be called when the event is emitted. - * @returns Callback to unsubscribe from the event. + * Only one instance of the SDK should subscribe to updates. */ - subscribeToSharedNodesByMe(callback: NodeEventCallback): () => void { - this.logger.debug('Subscribing to shared nodes by me'); - return this.sharing.events.subscribeToSharedNodesByMe(callback); - } - - /** - * Subscribe to updates of the nodes shared with the user. - * - * Clients should subscribe to this before beginning to list shared - * nodes to ensure that updates are reflected once a node is in the cache. - * Subscribing before listing is also required to ensure that nodes - * that are shared during the listing will be recognized. - * - * ```typescript - * const unsubscribe = sdk.subscribeToSharedNodesWithMe((event) => { - * if (event.type === 'update') { - * // Update the node in the UI - * } else if (event.type === 'remove') { - * // Remove the node from the UI - * } - * }); - * - * const nodes = await Array.fromAsync(sdk.iterateSharedNodesWithMe()); - * // Render the nodes in the UI - * - * // Unsubscribe from the updates when the component is unmounted - * unsubscribe(); - * ``` - * - * @param callback - Callback to be called when the event is emitted. - * @returns Callback to unsubscribe from the event. - */ - subscribeToSharedNodesWithMe(callback: NodeEventCallback): () => void { - this.logger.debug('Subscribing to shared nodes with me'); - return this.sharing.events.subscribeToSharedNodesWithMe(callback); + async subscribeToDriveEvents(callback: DriveListener): Promise { + this.logger.debug('Subscribing to core updates'); + return this.events.subscribeToCoreEvents(callback) } /** * Provides the node UID for the given raw share and node IDs. - * + * * This is required only for the internal implementation to provide * backward compatibility with the old Drive web setup. - * + * * If you are having volume ID, use `generateNodeUid` instead. - * + * * @deprecated This method is not part of the public API. * @param shareId - Context share of the node. * @param nodeId - Node/link ID (not UID). @@ -347,10 +192,8 @@ export class ProtonDriveClient { /** * Iterates the children of the given parent node. - * - * The output is not sorted and the order of the children is not guaranteed. * - * You can listen to updates via `subscribeToChildren`. + * The output is not sorted and the order of the children is not guaranteed. * * @param parentNodeUid - Node entity or its UID string. * @param signal - Signal to abort the operation. @@ -363,13 +206,11 @@ export class ProtonDriveClient { /** * Iterates the trashed nodes. - * + * * The list of trashed nodes is not cached and is fetched from the server * on each call. The node data itself are served from cached if available. - * - * The output is not sorted and the order of the trashed nodes is not guaranteed. * - * You can listen to updates via `subscribeToTrashedNodes`. + * The output is not sorted and the order of the trashed nodes is not guaranteed. * * @param signal - Signal to abort the operation. * @returns An async generator of the trashed nodes. @@ -381,7 +222,7 @@ export class ProtonDriveClient { /** * Iterates the nodes by their UIDs. - * + * * The output is not sorted and the order of the nodes is not guaranteed. * * @param nodeUids - List of node entities or their UIDs. @@ -419,13 +260,13 @@ export class ProtonDriveClient { /** * Move the nodes to a new parent node. - * + * * The operation is performed node by node and the results are yielded * as they are available. Order of the results is not guaranteed. - * + * * If one of the nodes fails to move, the operation continues with the * rest of the nodes. Use `NodeResult` to check the status of the action. - * + * * Only move withing the same section is supported at this moment. * That means that the new parent node must be in the same section * as the nodes being moved. E.g., moving from My files to Shared with @@ -443,10 +284,10 @@ export class ProtonDriveClient { /** * Trash the nodes. - * + * * The operation is performed in batches and the results are yielded * as they are available. Order of the results is not guaranteed. - * + * * If one of the nodes fails to trash, the operation continues with the * rest of the nodes. Use `NodeResult` to check the status of the action. * @@ -461,10 +302,10 @@ export class ProtonDriveClient { /** * Restore the nodes from the trash to their original place. - * + * * The operation is performed in batches and the results are yielded * as they are available. Order of the results is not guaranteed. - * + * * If one of the nodes fails to restore, the operation continues with the * rest of the nodes. Use `NodeResult` to check the status of the action. * @@ -479,10 +320,10 @@ export class ProtonDriveClient { /** * Delete the nodes permanently. - * + * * The operation is performed in batches and the results are yielded * as they are available. Order of the results is not guaranteed. - * + * * If one of the nodes fails to delete, the operation continues with the * rest of the nodes. Use `NodeResult` to check the status of the action. * @@ -502,7 +343,7 @@ export class ProtonDriveClient { /** * Create a new folder. - * + * * The folder is created in the given parent node. * * @param parentNodeUid - Node entity or its UID string of the parent folder. @@ -520,10 +361,10 @@ export class ProtonDriveClient { /** * Iterates the revisions of given node. - * + * * The list of node revisions is not cached and is fetched and decrypted * from the server on each call. - * + * * The output is sorted by the revision date in descending order (newest * first). * @@ -565,8 +406,6 @@ export class ProtonDriveClient { * Iterates the nodes shared by the user. * * The output is not sorted and the order of the shared nodes is not guaranteed. - * - * You can listen to updates via `subscribeToSharedNodesByMe`. * * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. @@ -580,12 +419,10 @@ export class ProtonDriveClient { * Iterates the nodes shared with the user. * * The output is not sorted and the order of the shared nodes is not guaranteed. - * - * At the end of the iteration, if `subscribeToRemoteDataUpdates` was called, - * the SDK will listen to updates for the shared nodes to keep the local cache - * in sync with the remote data. - * - * You can listen to updates via `subscribeToSharedNodesWithMe`. + * + * Clients can subscribe to drive events in order to receive a + * `SharedWithMeUpdated` event when there are changes to the user's + * access to shared nodes. * * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. @@ -593,15 +430,8 @@ export class ProtonDriveClient { async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes with me'); - const uniqueVolumeIds = new Set(); for await (const node of this.sharing.access.iterateSharedNodesWithMe(signal)) { yield convertInternalNode(node); - const { volumeId } = splitNodeUid(node.uid); - uniqueVolumeIds.add(volumeId); - } - - for (const volumeId of uniqueVolumeIds) { - await this.events.listenToVolume(volumeId, false); } } @@ -673,7 +503,7 @@ export class ProtonDriveClient { /** * Get sharing info of the node. - * + * * The sharing info contains the list of invitations, members, * public link and permission for each. * @@ -690,7 +520,7 @@ export class ProtonDriveClient { /** * Share or update sharing of the node. - * + * * If the node is already shared, the sharing settings are updated. * If the member is already present but with different role, the role * is updated. If the sharing settings is identical, the sharing info @@ -731,37 +561,37 @@ export class ProtonDriveClient { * Get the file downloader to download the node content of the active * revision. For downloading specific revision of the file, use * `getFileRevisionDownloader`. - * + * * The number of ongoing downloads is limited. If the limit is reached, * the download is queued and started when the slot is available. It is * recommended to not start too many downloads at once to avoid having * many open promises. - * + * * The file downloader is not reusable. If the download is interrupted, * a new file downloader must be created. - * + * * Before download, the authorship of the node should be checked and * reported to the user if there is any signature issue, notably on the * content author on the revision. - * + * * Client should not automatically retry the download if it fails. The * download should be initiated by the user again. The downloader does * automatically retry the download if it fails due to network issues, - * or if the server is temporarily unavailable. - * + * or if the server is temporarily unavailable. + * * Once download is initiated, the download can fail, besides network * issues etc., only when there is integrity error. It should be considered * a bug and reported to the Drive developers. The SDK provides option * to bypass integrity checks, but that should be used only for debugging * purposes, not available to the end users. - * + * * Example usage: - * + * * ```typescript * const downloader = await client.getFileDownloader(nodeUid, signal); * const claimedSize = fileDownloader.getClaimedSizeInBytes(); * const downloadController = fileDownloader.writeToStream(stream, (downloadedBytes) => { ... }); - * + * * signalController.abort(); // to cancel * downloadController.pause(); // to pause * downloadController.resume(); // to resume @@ -798,26 +628,26 @@ export class ProtonDriveClient { /** * Get the file uploader to upload a new file. For uploading a new * revision, use `getFileRevisionUploader` instead. - * + * * The number of ongoing uploads is limited. If the limit is reached, * the upload is queued and started when the slot is available. It is * recommended to not start too many uploads at once to avoid having * many open promises. - * + * * The file uploader is not reusable. If the upload is interrupted, * a new file uploader must be created. - * + * * Client should not automatically retry the upload if it fails. The * upload should be initiated by the user again. The uploader does * automatically retry the upload if it fails due to network issues, * or if the server is temporarily unavailable. - * + * * Example usage: - * + * * ```typescript * const uploader = await client.getFileUploader(parentFolderUid, name, metadata, signal); * const uploadController = await uploader.writeStream(stream, thumbnails, (uploadedBytes) => { ... }); - * + * * signalController.abort(); // to cancel * uploadController.pause(); // to pause * uploadController.resume(); // to resume @@ -839,10 +669,11 @@ export class ProtonDriveClient { /** * Iterates the devices of the user. - * + * * The output is not sorted and the order of the devices is not guaranteed. * - * You can listen to updates via `subscribeToDevices`. + * New devices can be registered by listening to events in the + * event scope of "My Files" and filtering on nodes with null `ParentLinkId`. * * @returns An async generator of devices. */ @@ -853,7 +684,7 @@ export class ProtonDriveClient { /** * Creates a new device. - * + * * @param nodeUid - Device entity or its UID string. * @returns The created device entity. * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. @@ -865,7 +696,7 @@ export class ProtonDriveClient { /** * Renames a device. - * + * * @param deviceOrUid - Device entity or its UID string. * @returns The updated device entity. * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. @@ -877,7 +708,7 @@ export class ProtonDriveClient { /** * Deletes a device. - * + * * @param deviceOrUid - Device entity or its UID string. */ async deleteDevice(deviceOrUid: DeviceOrUid): Promise { diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 033c6735..0cafebc5 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -1,5 +1,5 @@ import { DriveAPIService } from './internal/apiService'; -import { ProtonDriveClientContructorParameters } from './interface'; +import { DriveListener, ProtonDriveClientContructorParameters } from './interface'; import { DriveCrypto } from './crypto'; import { initSharesModule } from './internal/shares'; import { initNodesModule } from './internal/nodes'; @@ -32,9 +32,10 @@ export class ProtonDrivePhotosClient { const sdkEvents = new SDKEvents(telemetry); const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); const apiService = new DriveAPIService(telemetry, sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); - const events = new DriveEventsService(telemetry, apiService, entitiesCache); const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); - this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, events, shares); + this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, shares); + const cacheEventListeners: DriveListener[] = [this.nodes.eventHandler.updateNodesCacheOnEvent]; + new DriveEventsService(telemetry, apiService, shares, cacheEventListeners); this.photos = initPhotosModule(apiService, entitiesCache, this.nodes.access); } From d3d59987e6422d25766b1b6c10c2230ab1fd5c22 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 13:30:15 +0000 Subject: [PATCH 173/791] Run pretty on all sdk and cli source code --- js/sdk/src/cache/interface.ts | 44 +- js/sdk/src/cache/memoryCache.test.ts | 14 +- js/sdk/src/cache/memoryCache.ts | 8 +- js/sdk/src/cache/nullCache.ts | 2 +- js/sdk/src/config.ts | 10 +- js/sdk/src/crypto/driveCrypto.test.ts | 30 +- js/sdk/src/crypto/driveCrypto.ts | 263 +- js/sdk/src/crypto/hmac.ts | 2 +- js/sdk/src/crypto/interface.ts | 135 +- js/sdk/src/crypto/openPGPCrypto.ts | 168 +- js/sdk/src/crypto/utils.ts | 2 +- js/sdk/src/diagnostic/eventsGenerator.ts | 4 +- js/sdk/src/diagnostic/httpClient.ts | 8 +- js/sdk/src/diagnostic/index.ts | 22 +- .../diagnostic/integrityVerificationStream.ts | 7 +- js/sdk/src/diagnostic/interface.ts | 162 +- js/sdk/src/diagnostic/sdkDiagnostic.ts | 59 +- js/sdk/src/diagnostic/sdkDiagnosticFull.ts | 35 +- js/sdk/src/diagnostic/telemetry.ts | 5 +- js/sdk/src/diagnostic/zipGenerators.test.ts | 2 +- js/sdk/src/diagnostic/zipGenerators.ts | 6 +- js/sdk/src/errors.ts | 42 +- js/sdk/src/index.ts | 6 +- js/sdk/src/interface/account.ts | 20 +- js/sdk/src/interface/author.ts | 12 +- js/sdk/src/interface/config.ts | 8 +- js/sdk/src/interface/devices.ts | 12 +- js/sdk/src/interface/download.ts | 21 +- js/sdk/src/interface/events.ts | 82 +- js/sdk/src/interface/httpClient.ts | 22 +- js/sdk/src/interface/index.ts | 84 +- js/sdk/src/interface/nodes.ts | 96 +- js/sdk/src/interface/result.ts | 4 +- js/sdk/src/interface/sharing.ts | 116 +- js/sdk/src/interface/telemetry.ts | 148 +- js/sdk/src/interface/thumbnail.ts | 11 +- js/sdk/src/interface/upload.ts | 32 +- .../internal/apiService/apiService.test.ts | 185 +- js/sdk/src/internal/apiService/apiService.ts | 66 +- js/sdk/src/internal/apiService/coreTypes.ts | 4937 +++++++++-------- js/sdk/src/internal/apiService/driveTypes.ts | 3690 ++++++------ js/sdk/src/internal/apiService/errorCodes.ts | 8 +- js/sdk/src/internal/apiService/errors.test.ts | 48 +- js/sdk/src/internal/apiService/errors.ts | 24 +- js/sdk/src/internal/apiService/index.ts | 2 +- .../src/internal/apiService/transformers.ts | 4 +- js/sdk/src/internal/asyncIteratorMap.test.ts | 8 +- js/sdk/src/internal/asyncIteratorMap.ts | 2 +- js/sdk/src/internal/batchLoading.test.ts | 27 +- js/sdk/src/internal/batchLoading.ts | 16 +- js/sdk/src/internal/devices/apiService.ts | 109 +- js/sdk/src/internal/devices/cryptoService.ts | 39 +- js/sdk/src/internal/devices/index.ts | 27 +- js/sdk/src/internal/devices/interface.ts | 33 +- js/sdk/src/internal/devices/manager.test.ts | 49 +- js/sdk/src/internal/devices/manager.ts | 6 +- js/sdk/src/internal/download/apiService.ts | 114 +- js/sdk/src/internal/download/cryptoService.ts | 50 +- .../internal/download/fileDownloader.test.ts | 34 +- .../src/internal/download/fileDownloader.ts | 58 +- js/sdk/src/internal/download/index.ts | 38 +- js/sdk/src/internal/download/interface.ts | 40 +- js/sdk/src/internal/download/queue.ts | 6 +- .../src/internal/download/telemetry.test.ts | 22 +- js/sdk/src/internal/download/telemetry.ts | 38 +- .../download/thumbnailDownloader.test.ts | 17 +- .../internal/download/thumbnailDownloader.ts | 75 +- js/sdk/src/internal/errors.ts | 12 +- js/sdk/src/internal/events/apiService.ts | 47 +- .../internal/events/coreEventManager.test.ts | 36 +- .../src/internal/events/coreEventManager.ts | 15 +- .../src/internal/events/eventManager.test.ts | 97 +- js/sdk/src/internal/events/eventManager.ts | 11 +- js/sdk/src/internal/events/index.ts | 38 +- js/sdk/src/internal/events/interface.ts | 86 +- .../events/volumeEventManager.test.ts | 126 +- .../src/internal/events/volumeEventManager.ts | 27 +- js/sdk/src/internal/nodes/apiService.test.ts | 312 +- js/sdk/src/internal/nodes/apiService.ts | 412 +- js/sdk/src/internal/nodes/cache.test.ts | 68 +- js/sdk/src/internal/nodes/cache.ts | 104 +- js/sdk/src/internal/nodes/cryptoCache.test.ts | 48 +- js/sdk/src/internal/nodes/cryptoCache.ts | 15 +- .../src/internal/nodes/cryptoService.test.ts | 843 +-- js/sdk/src/internal/nodes/cryptoService.ts | 258 +- js/sdk/src/internal/nodes/events.test.ts | 66 +- js/sdk/src/internal/nodes/events.ts | 12 +- .../internal/nodes/extendedAttributes.test.ts | 52 +- .../src/internal/nodes/extendedAttributes.ts | 35 +- js/sdk/src/internal/nodes/index.ts | 42 +- js/sdk/src/internal/nodes/interface.ts | 71 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 182 +- js/sdk/src/internal/nodes/nodesAccess.ts | 122 +- .../internal/nodes/nodesManagement.test.ts | 63 +- js/sdk/src/internal/nodes/nodesManagement.ts | 71 +- js/sdk/src/internal/nodes/nodesRevisions.ts | 14 +- js/sdk/src/internal/nodes/validations.ts | 4 +- js/sdk/src/internal/photos/albums.ts | 10 +- js/sdk/src/internal/photos/apiService.ts | 11 +- js/sdk/src/internal/photos/cache.ts | 2 +- js/sdk/src/internal/photos/index.ts | 16 +- js/sdk/src/internal/photos/interface.ts | 4 +- js/sdk/src/internal/photos/photosTimeline.ts | 9 +- js/sdk/src/internal/sdkEvents.test.ts | 20 +- js/sdk/src/internal/sdkEvents.ts | 18 +- js/sdk/src/internal/shares/apiService.ts | 77 +- js/sdk/src/internal/shares/cache.test.ts | 10 +- js/sdk/src/internal/shares/cache.ts | 33 +- .../src/internal/shares/cryptoCache.test.ts | 28 +- js/sdk/src/internal/shares/cryptoCache.ts | 8 +- .../src/internal/shares/cryptoService.test.ts | 146 +- js/sdk/src/internal/shares/cryptoService.ts | 71 +- js/sdk/src/internal/shares/index.ts | 34 +- js/sdk/src/internal/shares/interface.ts | 16 +- js/sdk/src/internal/shares/manager.test.ts | 168 +- js/sdk/src/internal/shares/manager.ts | 38 +- js/sdk/src/internal/sharing/apiService.ts | 456 +- js/sdk/src/internal/sharing/cache.test.ts | 70 +- js/sdk/src/internal/sharing/cache.ts | 4 +- .../internal/sharing/cryptoService.test.ts | 104 +- js/sdk/src/internal/sharing/cryptoService.ts | 204 +- js/sdk/src/internal/sharing/events.test.ts | 93 +- js/sdk/src/internal/sharing/events.ts | 15 +- js/sdk/src/internal/sharing/index.ts | 33 +- js/sdk/src/internal/sharing/interface.ts | 79 +- .../internal/sharing/sharingAccess.test.ts | 136 +- js/sdk/src/internal/sharing/sharingAccess.ts | 60 +- .../sharing/sharingManagement.test.ts | 696 ++- .../src/internal/sharing/sharingManagement.ts | 203 +- js/sdk/src/internal/uids.ts | 2 +- js/sdk/src/internal/upload/apiService.ts | 284 +- js/sdk/src/internal/upload/blockVerifier.ts | 13 +- .../internal/upload/chunkStreamReader.test.ts | 14 +- js/sdk/src/internal/upload/cryptoService.ts | 77 +- js/sdk/src/internal/upload/digests.ts | 4 +- .../src/internal/upload/fileUploader.test.ts | 18 +- js/sdk/src/internal/upload/fileUploader.ts | 56 +- js/sdk/src/internal/upload/index.ts | 27 +- js/sdk/src/internal/upload/interface.ts | 156 +- js/sdk/src/internal/upload/manager.test.ts | 323 +- js/sdk/src/internal/upload/manager.ts | 94 +- js/sdk/src/internal/upload/queue.ts | 6 +- .../internal/upload/streamUploader.test.ts | 66 +- js/sdk/src/internal/upload/streamUploader.ts | 156 +- js/sdk/src/internal/upload/telemetry.test.ts | 22 +- js/sdk/src/internal/upload/telemetry.ts | 40 +- js/sdk/src/internal/wait.test.ts | 2 +- js/sdk/src/internal/wait.ts | 6 +- js/sdk/src/protonDriveClient.ts | 160 +- js/sdk/src/protonDrivePhotosClient.ts | 26 +- js/sdk/src/telemetry.ts | 112 +- js/sdk/src/tests/logger.ts | 2 +- js/sdk/src/tests/telemetry.ts | 4 +- js/sdk/src/transformers.ts | 48 +- js/sdk/src/version.ts | 1 - 155 files changed, 10469 insertions(+), 8887 deletions(-) diff --git a/js/sdk/src/cache/interface.ts b/js/sdk/src/cache/interface.ts index 0bcda516..bbd24cf2 100644 --- a/js/sdk/src/cache/interface.ts +++ b/js/sdk/src/cache/interface.ts @@ -1,33 +1,33 @@ export interface ProtonDriveCacheConstructor { /** * Initialize the cache. - * + * * The local database should follow document-based structure. The SDK does * serialisation and data is not intended to be read by 3rd party. The SDK, * however, provides also clear fields in form of tags that is used for * search. Local database should have index or other structure for easier * look-up. - * + * * See {@link setEntity} for more details how tags are used. */ - new (): ProtonDriveCache, + new (): ProtonDriveCache; } export interface ProtonDriveCache { /** * Re-creates the whole persistent cache. - * + * * The SDK can call this when there is some inconsistency and it is better * to start from scratch rather than fix it. */ - clear(): Promise, + clear(): Promise; /** * Adds or updates entity in the local database. - * + * * The `tags` is a list of strings that should be stored properly for fast * look-up. - * + * * @example Usage by the SDK * ```ts * await cache.setEntity("node-abc42", "{ node abc42 serialised data }", ["parentUid:abc123", "sharedWithMe"] }); @@ -35,7 +35,7 @@ export interface ProtonDriveCache { * await cache.getEntity("node-abc42"); // returns "{ node abc42 serialised data }" * await Array.fromAsync(cache.iterateEntities(["node-abc42"])); // returns ["{ node abc42 serialised data }"] * ``` - * + * * @example Stored data * ```json * { @@ -54,51 +54,51 @@ export interface ProtonDriveCache { * } * } * ``` - * + * * @param key - Key is internal ID controlled by the SDK. It combines type and ID of the entity. * @param value - Serialised JSON object controlled by the SDK. It is not intended for use outside of the SDK. * @param tags - Clear metadata about the entity used for filtering. It is intended to store efficiently for fast look-up. * @throws Exception if `key` from `tags` is not one of the tag keys provided from `usedTagKeysBySDK` in constructor. */ - setEntity(key: string, value: T, tags?: string[] ): Promise, - + setEntity(key: string, value: T, tags?: string[]): Promise; + /** * Returns the data of the entity stored locally. - * + * * @throws Exception if entity is not present. */ - getEntity(key: string): Promise, + getEntity(key: string): Promise; /** * Generator providing the data of the entities stored locally for given * list of keys. - * + * * No exception is thrown when data is missing. */ - iterateEntities(keys: string[]): AsyncGenerator>, + iterateEntities(keys: string[]): AsyncGenerator>; /** * Generator providing the data of the entities stored locally for given * filter option. - * + * * No exception is thrown when data is missing. - * + * * @example Usage by the SDK * ```ts * await cache.setEntity("node-abc42", "{ node abc42 serialised data }", { "parentUid": "abc123", "shared": "withMe" }); * await Array.fromAsync(cache.iterateEntitiesByTag("parentUid", "abc123")); // returns ["node-abc42"] * ``` - * + * * @param tag - The tag, for example `parentUid:abc123` */ - iterateEntitiesByTag(tag: string): AsyncGenerator>, + iterateEntitiesByTag(tag: string): AsyncGenerator>; /** * Removes completely the entity stored locally from the database. - * + * * It is no-op if entity is not present. */ - removeEntities(keys: string[]): Promise, + removeEntities(keys: string[]): Promise; } -export type EntityResult = {key: string, ok: true, value: T} | {key: string, ok: false, error: string}; +export type EntityResult = { key: string; ok: true; value: T } | { key: string; ok: false; error: string }; diff --git a/js/sdk/src/cache/memoryCache.test.ts b/js/sdk/src/cache/memoryCache.test.ts index 5df9b913..1370abbd 100644 --- a/js/sdk/src/cache/memoryCache.test.ts +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -1,5 +1,5 @@ -import { EntityResult } from "./interface"; -import { MemoryCache } from "./memoryCache"; +import { EntityResult } from './interface'; +import { MemoryCache } from './memoryCache'; describe('MemoryCache', () => { let cache: MemoryCache; @@ -81,9 +81,7 @@ describe('MemoryCache', () => { results.push(result); } - expect(results).toEqual([ - { key: 'key1', ok: true, value: 'value1' }, - ]); + expect(results).toEqual([{ key: 'key1', ok: true, value: 'value1' }]); }); it('should iterate over entities by empty tag', async () => { @@ -97,9 +95,11 @@ describe('MemoryCache', () => { it('should iterate over entities with concurrent changes to the same set', async () => { const iterator = cache.iterateEntities(['key1', 'key2', 'key3']); - + const results: string[] = []; - const { value: { key: key1 } } = await iterator.next(); + const { + value: { key: key1 }, + } = await iterator.next(); results.push(key1); await cache.removeEntities([key1]); diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index 4728edec..442ff821 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -1,11 +1,11 @@ import type { ProtonDriveCache, EntityResult } from './interface'; -type KeyValueCache = { [ key: string ]: T }; -type TagsCache = { [ tag: string ]: string[] }; +type KeyValueCache = { [key: string]: T }; +type TagsCache = { [tag: string]: string[] }; /** * In-memory cache implementation for Proton Drive SDK. - * + * * This cache is not persistent and is intended for mostly for testing or * development only. It is not recommended to use this cache in production * environments. @@ -82,4 +82,4 @@ export class MemoryCache implements ProtonDriveCache { }); } } -}; +} diff --git a/js/sdk/src/cache/nullCache.ts b/js/sdk/src/cache/nullCache.ts index db175aa2..fbbd78f6 100644 --- a/js/sdk/src/cache/nullCache.ts +++ b/js/sdk/src/cache/nullCache.ts @@ -35,4 +35,4 @@ export class NullCache implements ProtonDriveCache { async removeEntities(keys: string[]) { // No-op. } -}; +} diff --git a/js/sdk/src/config.ts b/js/sdk/src/config.ts index 5c661351..90122268 100644 --- a/js/sdk/src/config.ts +++ b/js/sdk/src/config.ts @@ -6,14 +6,14 @@ import { ProtonDriveConfig } from './interface'; * The object should be almost identical to the original config, but making * some fields required (setting reasonable defaults for the missing fields), * or changed for easier usage inside of the SDK. - * + * * For more property details, see the original config declaration. */ type ParsedProtonDriveConfig = { - baseUrl: string, - language: string, - clientUid?: string, -} + baseUrl: string; + language: string; + clientUid?: string; +}; export function getConfig(config?: ProtonDriveConfig): ParsedProtonDriveConfig { return { diff --git a/js/sdk/src/crypto/driveCrypto.test.ts b/js/sdk/src/crypto/driveCrypto.test.ts index 7d196770..29b09139 100644 --- a/js/sdk/src/crypto/driveCrypto.test.ts +++ b/js/sdk/src/crypto/driveCrypto.test.ts @@ -1,44 +1,44 @@ -import { uint8ArrayToUtf8, arrayToHexString } from "./driveCrypto"; +import { uint8ArrayToUtf8, arrayToHexString } from './driveCrypto'; -describe("uint8ArrayToUtf8", () => { - it("should convert a Uint8Array to a UTF-8 string", () => { +describe('uint8ArrayToUtf8', () => { + it('should convert a Uint8Array to a UTF-8 string', () => { const input = new Uint8Array([72, 101, 108, 108, 111]); - const expectedOutput = "Hello"; + const expectedOutput = 'Hello'; const result = uint8ArrayToUtf8(input); expect(result).toBe(expectedOutput); }); - it("should handle an empty Uint8Array", () => { + it('should handle an empty Uint8Array', () => { const input = new Uint8Array([]); - const expectedOutput = ""; + const expectedOutput = ''; const result = uint8ArrayToUtf8(input); expect(result).toBe(expectedOutput); }); - it("should throw if input is invalid", () => { + it('should throw if input is invalid', () => { const input = new Uint8Array([887987979887897989]); - expect(() => uint8ArrayToUtf8(input)).toThrow("The encoded data was not valid for encoding utf-8"); + expect(() => uint8ArrayToUtf8(input)).toThrow('The encoded data was not valid for encoding utf-8'); }); }); -describe("arrayToHexString", () => { - it("should convert a Uint8Array to a hex string", () => { +describe('arrayToHexString', () => { + it('should convert a Uint8Array to a hex string', () => { const input = new Uint8Array([0, 255, 16, 32]); - const expectedOutput = "00ff1020"; + const expectedOutput = '00ff1020'; const result = arrayToHexString(input); expect(result).toBe(expectedOutput); }); - it("should handle an empty Uint8Array", () => { + it('should handle an empty Uint8Array', () => { const input = new Uint8Array([]); - const expectedOutput = ""; + const expectedOutput = ''; const result = arrayToHexString(input); expect(result).toBe(expectedOutput); }); - it("should handle a Uint8Array with one element", () => { + it('should handle a Uint8Array with one element', () => { const input = new Uint8Array([1]); - const expectedOutput = "01"; + const expectedOutput = '01'; const result = arrayToHexString(input); expect(result).toBe(expectedOutput); }); diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 97b454f9..c626b221 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,7 +1,15 @@ -import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, SRPModule, SRPVerifier, VERIFICATION_STATUS } from './interface'; +import { + OpenPGPCrypto, + PrivateKey, + PublicKey, + SessionKey, + SRPModule, + SRPVerifier, + VERIFICATION_STATUS, +} from './interface'; import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; // TODO: Switch to CryptoProxy module once available. -import { importHmacKey, computeHmacSignature } from "./hmac"; +import { importHmacKey, computeHmacSignature } from './hmac'; enum SIGNING_CONTEXTS { SHARING_INVITER = 'drive.share-member.inviter', @@ -11,7 +19,7 @@ enum SIGNING_CONTEXTS { /** * Drive crypto layer to provide general operations for Drive crypto. - * + * * This layer focuses on providing general Drive crypto functions. Only * high-level functions that are required on multiple places should be * peresent. E.g., no specific implementation how keys are encrypted, @@ -20,7 +28,10 @@ enum SIGNING_CONTEXTS { * call with specific arguments. */ export class DriveCrypto { - constructor(private openPGPCrypto: OpenPGPCrypto, private srpModule: SRPModule) { + constructor( + private openPGPCrypto: OpenPGPCrypto, + private srpModule: SRPModule, + ) { this.openPGPCrypto = openPGPCrypto; this.srpModule = srpModule; } @@ -28,11 +39,11 @@ export class DriveCrypto { /** * It generates passphrase and key that is encrypted with the * generated passphrase. - * + * * `encrpytionKeys` are used to generate session key, which is * also used to encrypt the passphrase. The encrypted passphrase * is signed with `signingKey`. - * + * * @returns Object with: * - encrypted (armored) data (key, passphrase and passphrase * signature) for sending to the server @@ -43,15 +54,15 @@ export class DriveCrypto { signingKey: PrivateKey, ): Promise<{ encrypted: { - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, - }, + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; decrypted: { - passphrase: string, - key: PrivateKey, - passphraseSessionKey: SessionKey, - }, + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + }; }> { const passphrase = this.openPGPCrypto.generatePassphrase(); const [{ privateKey, armoredKey }, passphraseSessionKey] = await Promise.all([ @@ -78,7 +89,7 @@ export class DriveCrypto { passphraseSessionKey, }, }; - }; + } /** * It generates content key from node key for encrypting file blocks. @@ -86,19 +97,20 @@ export class DriveCrypto { * @param encryptionKey - Its own node key. * @returns Object with serialised key packet and decrypted session key. */ - async generateContentKey( - encryptionKey: PrivateKey, - ): Promise<{ + async generateContentKey(encryptionKey: PrivateKey): Promise<{ encrypted: { - base64ContentKeyPacket: string, - armoredContentKeyPacketSignature: string, - }, + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature: string; + }; decrypted: { - contentKeyPacketSessionKey: SessionKey, - }, + contentKeyPacketSessionKey: SessionKey; + }; }> { const contentKeyPacketSessionKey = await this.openPGPCrypto.generateSessionKey([encryptionKey]); - const { signature: armoredContentKeyPacketSignature } = await this.openPGPCrypto.signArmored(contentKeyPacketSessionKey.data, [encryptionKey]); + const { signature: armoredContentKeyPacketSignature } = await this.openPGPCrypto.signArmored( + contentKeyPacketSessionKey.data, + [encryptionKey], + ); const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(contentKeyPacketSessionKey, [encryptionKey]); return { @@ -108,7 +120,7 @@ export class DriveCrypto { }, decrypted: { contentKeyPacketSessionKey, - } + }, }; } @@ -116,7 +128,7 @@ export class DriveCrypto { * It encrypts passphrase with provided session and encryption keys. * This should be used only for re-encrypting the passphrase with * different key (e.g., moving the node to different parent). - * + * * @returns Object with armored passphrase and passphrase signature. */ async encryptPassphrase( @@ -125,15 +137,16 @@ export class DriveCrypto { encryptionKeys: PrivateKey[], signingKey: PrivateKey, ): Promise<{ - armoredPassphrase: string, - armoredPassphraseSignature: string, + armoredPassphrase: string; + armoredPassphraseSignature: string; }> { - const { armoredData: armoredPassphrase, armoredSignature: armoredPassphraseSignature } = await this.openPGPCrypto.encryptAndSignDetachedArmored( - new TextEncoder().encode(passphrase), - sessionKey, - encryptionKeys, - signingKey, - ); + const { armoredData: armoredPassphrase, armoredSignature: armoredPassphraseSignature } = + await this.openPGPCrypto.encryptAndSignDetachedArmored( + new TextEncoder().encode(passphrase), + sessionKey, + encryptionKeys, + signingKey, + ); return { armoredPassphrase, @@ -143,15 +156,15 @@ export class DriveCrypto { /** * It decrypts key generated via `generateKey`. - * + * * Armored data are passed from the server. `decryptionKeys` are used * to decrypt the session key from the `armoredPassphrase`. Then the * session key is used with `verificationKeys` to decrypt and verify * the passphrase. Finally, the armored key is decrypted. - * + * * Note: The function doesn't throw in case of verification issue. * You have to read `verified` result and act based on that. - * + * * @returns key and sessionKey for crypto usage, and verification status */ async decryptKey( @@ -161,10 +174,10 @@ export class DriveCrypto { decryptionKeys: PrivateKey[], verificationKeys: PublicKey[], ): Promise<{ - passphrase: string, - key: PrivateKey, - passphraseSessionKey: SessionKey, - verified: VERIFICATION_STATUS, + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + verified: VERIFICATION_STATUS; }> { const passphraseSessionKey = await this.decryptSessionKey(armoredPassphrase, decryptionKeys); @@ -177,10 +190,7 @@ export class DriveCrypto { const passphrase = uint8ArrayToUtf8(decryptedPassphrase); - const key = await this.openPGPCrypto.decryptKey( - armoredKey, - passphrase, - ); + const key = await this.openPGPCrypto.decryptKey(armoredKey, passphrase); return { passphrase, key, @@ -196,12 +206,12 @@ export class DriveCrypto { sessionKey: SessionKey, encryptionKey: PublicKey, ): Promise<{ - base64KeyPacket: string, + base64KeyPacket: string; }> { const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(sessionKey, [encryptionKey]); return { base64KeyPacket: uint8ArrayToBase64String(keyPacket), - } + }; } /** @@ -215,9 +225,9 @@ export class DriveCrypto { bcryptPassphrase: string, sharePassphraseSessionKey: SessionKey, ): Promise<{ - armoredPassword: string, - base64SharePassphraseKeyPacket: string, - srp: SRPVerifier, + armoredPassword: string; + base64SharePassphraseKeyPacket: string; + srp: SRPVerifier; }> { const [{ armoredData: armoredPassword }, { keyPacket }, srp] = await Promise.all([ this.openPGPCrypto.encryptArmored(new TextEncoder().encode(password), [addressKey]), @@ -234,28 +244,25 @@ export class DriveCrypto { /** * It decrypts the key using the password via SRP protocol. - * + * * The function follows the same functionality as `decryptKey` but uses SRP * protocol to decrypt the passphrase of the key. It is used for saved * public links where user saved the link with password and is not direct - * member of the share. - */ + * member of the share. + */ async decryptKeyWithSrpPassword( password: string, salt: string, armoredKey: string, armoredPassphrase: string, ): Promise<{ - key: PrivateKey, + key: PrivateKey; }> { const keyPassword = await this.srpModule.computeKeyPassword(password, salt); const passphrase = await this.openPGPCrypto.decryptArmoredWithPassword(armoredPassphrase, keyPassword); - const key = await this.openPGPCrypto.decryptKey( - armoredKey, - new TextDecoder().decode(passphrase), - ); + const key = await this.openPGPCrypto.decryptKey(armoredKey, new TextDecoder().decode(passphrase)); return { key, @@ -264,17 +271,11 @@ export class DriveCrypto { /** * It decrypts session key from armored data. - * + * * `decryptionKeys` are used to decrypt the session key from the `armoredData`. */ - async decryptSessionKey( - armoredData: string, - decryptionKeys: PrivateKey | PrivateKey[], - ): Promise { - const sessionKey = await this.openPGPCrypto.decryptArmoredSessionKey( - armoredData, - decryptionKeys, - ); + async decryptSessionKey(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]): Promise { + const sessionKey = await this.openPGPCrypto.decryptArmoredSessionKey(armoredData, decryptionKeys); return sessionKey; } @@ -284,16 +285,12 @@ export class DriveCrypto { decryptionKeys: PrivateKey | PrivateKey[], verificationKeys: PublicKey[], ): Promise<{ - sessionKey: SessionKey, - verified?: VERIFICATION_STATUS, + sessionKey: SessionKey; + verified?: VERIFICATION_STATUS; }> { - const data = base64StringToUint8Array(base64data); - const sessionKey = await this.openPGPCrypto.decryptSessionKey( - data, - decryptionKeys, - ); + const sessionKey = await this.openPGPCrypto.decryptSessionKey(data, decryptionKeys); let verified; if (armoredSignature) { @@ -304,7 +301,7 @@ export class DriveCrypto { return { sessionKey, verified, - } + }; } /** @@ -324,10 +321,7 @@ export class DriveCrypto { const passphrase = uint8ArrayToUtf8(decryptedPassphrase); - const key = await this.openPGPCrypto.decryptKey( - armoredKey, - passphrase, - ); + const key = await this.openPGPCrypto.decryptKey(armoredKey, passphrase); return key; } @@ -340,7 +334,7 @@ export class DriveCrypto { encryptionKey: PrivateKey, sessionKey: SessionKey, ): Promise<{ - armoredSignature: string, + armoredSignature: string; }> { const { armoredData: armoredSignature } = await this.openPGPCrypto.encryptArmored( signature, @@ -349,18 +343,16 @@ export class DriveCrypto { ); return { armoredSignature, - } + }; } /** * It generates random 32 bytes that are encrypted and signed with * the provided key. */ - async generateHashKey( - encryptionAndSigningKey: PrivateKey, - ): Promise<{ - armoredHashKey: string, - hashKey: Uint8Array, + async generateHashKey(encryptionAndSigningKey: PrivateKey): Promise<{ + armoredHashKey: string; + hashKey: Uint8Array; }> { // Once all clients can use non-ascii bytes, switch to simple // generating of random bytes without encoding it into base64: @@ -377,7 +369,7 @@ export class DriveCrypto { return { armoredHashKey, hashKey, - } + }; } async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { @@ -401,7 +393,7 @@ export class DriveCrypto { encryptionKey: PrivateKey | undefined, signingKey: PrivateKey, ): Promise<{ - armoredNodeName: string, + armoredNodeName: string; }> { if (!sessionKey && !encryptionKey) { throw new Error('Neither session nor encryption key provided for encrypting node name'); @@ -415,12 +407,12 @@ export class DriveCrypto { ); return { armoredNodeName, - } + }; } /** * It decrypts armored node name and verifies embeded signature. - * + * * Note: The function doesn't throw in case of verification issue. * You have to read `verified` result and act based on that. */ @@ -429,8 +421,8 @@ export class DriveCrypto { decryptionKey: PrivateKey, verificationKeys: PublicKey[], ): Promise<{ - name: string, - verified: VERIFICATION_STATUS, + name: string; + verified: VERIFICATION_STATUS; }> { const { data: name, verified } = await this.openPGPCrypto.decryptArmoredAndVerify( armoredNodeName, @@ -440,12 +432,12 @@ export class DriveCrypto { return { name: uint8ArrayToUtf8(name), verified, - } + }; } /** * It decrypts armored node hash key and verifies embeded signature. - * + * * Note: The function doesn't throw in case of verification issue. * You have to read `verified` result and act based on that. */ @@ -454,8 +446,8 @@ export class DriveCrypto { decryptionAndVerificationKey: PrivateKey, extraVerificationKeys: PublicKey[], ): Promise<{ - hashKey: Uint8Array, - verified: VERIFICATION_STATUS, + hashKey: Uint8Array; + verified: VERIFICATION_STATUS; }> { // In the past, we had misunderstanding what key is used to sign hash // key. Originally, it meant to be the node key, which web used for all @@ -479,7 +471,7 @@ export class DriveCrypto { encryptionKey: PrivateKey, signingKey: PrivateKey, ): Promise<{ - armoredExtendedAttributes: string, + armoredExtendedAttributes: string; }> { const { armoredData: armoredExtendedAttributes } = await this.openPGPCrypto.encryptAndSignArmored( new TextEncoder().encode(extendedAttributes), @@ -497,8 +489,8 @@ export class DriveCrypto { decryptionKey: PrivateKey, verificationKeys: PublicKey[], ): Promise<{ - extendedAttributes: string, - verified: VERIFICATION_STATUS, + extendedAttributes: string; + verified: VERIFICATION_STATUS; }> { const { data: decryptedExtendedAttributes, verified } = await this.openPGPCrypto.decryptArmoredAndVerify( armoreExtendedAttributes, @@ -517,31 +509,28 @@ export class DriveCrypto { encryptionKey: PublicKey, signingKey: PrivateKey, ): Promise<{ - base64KeyPacket: string, - base64KeyPacketSignature: string, + base64KeyPacket: string; + base64KeyPacketSignature: string; }> { const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(shareSessionKey, encryptionKey); const { signature: keyPacketSignature } = await this.openPGPCrypto.sign( keyPacket, signingKey, SIGNING_CONTEXTS.SHARING_INVITER, - ) + ); return { base64KeyPacket: uint8ArrayToBase64String(keyPacket), base64KeyPacketSignature: uint8ArrayToBase64String(keyPacketSignature), - } + }; } async acceptInvitation( base64KeyPacket: string, signingKey: PrivateKey, ): Promise<{ - base64SessionKeySignature: string, + base64SessionKeySignature: string; }> { - const sessionKey = await this.decryptSessionKey( - base64KeyPacket, - signingKey, - ); + const sessionKey = await this.decryptSessionKey(base64KeyPacket, signingKey); const { signature } = await this.openPGPCrypto.sign( sessionKey.data, @@ -551,7 +540,7 @@ export class DriveCrypto { return { base64SessionKeySignature: uint8ArrayToBase64String(signature), - } + }; } async encryptExternalInvitation( @@ -559,7 +548,7 @@ export class DriveCrypto { signingKey: PrivateKey, inviteeEmail: string, ): Promise<{ - base64ExternalInvitationSignature: string, + base64ExternalInvitationSignature: string; }> { const data = inviteeEmail.concat('|').concat(uint8ArrayToBase64String(shareSessionKey.data)); @@ -567,10 +556,10 @@ export class DriveCrypto { new TextEncoder().encode(data), signingKey, SIGNING_CONTEXTS.SHARING_INVITER_EXTERNAL_INVITATION, - ) + ); return { base64ExternalInvitationSignature: uint8ArrayToBase64String(externalInviationSignature), - } + }; } async encryptThumbnailBlock( @@ -578,7 +567,7 @@ export class DriveCrypto { sessionKey: SessionKey, signingKey: PrivateKey, ): Promise<{ - encryptedData: Uint8Array, + encryptedData: Uint8Array; }> { const { encryptedData } = await this.openPGPCrypto.encryptAndSign( thumbnailData, @@ -597,8 +586,8 @@ export class DriveCrypto { sessionKey: SessionKey, verificationKeys: PublicKey[], ): Promise<{ - decryptedThumbnail: Uint8Array, - verified: VERIFICATION_STATUS, + decryptedThumbnail: Uint8Array; + verified: VERIFICATION_STATUS; }> { const { data: decryptedThumbnail, verified } = await this.openPGPCrypto.decryptAndVerify( encryptedThumbnail, @@ -617,8 +606,8 @@ export class DriveCrypto { sessionKey: SessionKey, signingKey: PrivateKey, ): Promise<{ - encryptedData: Uint8Array, - armoredSignature: string, + encryptedData: Uint8Array; + armoredSignature: string; }> { const { encryptedData, signature } = await this.openPGPCrypto.encryptAndSignDetached( blockData, @@ -642,13 +631,12 @@ export class DriveCrypto { sessionKey: SessionKey, verificationKeys?: PublicKey[], ): Promise<{ - decryptedBlock: Uint8Array, - verified: VERIFICATION_STATUS, + decryptedBlock: Uint8Array; + verified: VERIFICATION_STATUS; }> { - const signature = armoredSignature ? await this.openPGPCrypto.decryptArmored( - armoredSignature, - [decryptionKey], - ) : undefined; + const signature = armoredSignature + ? await this.openPGPCrypto.decryptArmored(armoredSignature, [decryptionKey]) + : undefined; const { data: decryptedBlock, verified } = await this.openPGPCrypto.decryptAndVerifyDetached( encryptedBlock, @@ -667,15 +655,12 @@ export class DriveCrypto { manifest: Uint8Array, signingKey: PrivateKey, ): Promise<{ - armoredManifestSignature: string, + armoredManifestSignature: string; }> { - const { signature: armoredManifestSignature } = await this.openPGPCrypto.signArmored( - manifest, - signingKey, - ); + const { signature: armoredManifestSignature } = await this.openPGPCrypto.signArmored(manifest, signingKey); return { armoredManifestSignature, - } + }; } async verifyManifest( @@ -683,26 +668,16 @@ export class DriveCrypto { armoredSignature: string, verificationKeys: PublicKey | PublicKey[], ): Promise<{ - verified: VERIFICATION_STATUS, + verified: VERIFICATION_STATUS; }> { - const { verified } = await this.openPGPCrypto.verify( - manifest, - armoredSignature, - verificationKeys, - ); + const { verified } = await this.openPGPCrypto.verify(manifest, armoredSignature, verificationKeys); return { verified, - } + }; } - async decryptShareUrlPassword( - armoredPassword: string, - decryptionKeys: PrivateKey[], - ): Promise { - const password = await this.openPGPCrypto.decryptArmored( - armoredPassword, - decryptionKeys, - ); + async decryptShareUrlPassword(armoredPassword: string, decryptionKeys: PrivateKey[]): Promise { + const password = await this.openPGPCrypto.decryptArmored(armoredPassword, decryptionKeys); return uint8ArrayToUtf8(password); } } diff --git a/js/sdk/src/crypto/hmac.ts b/js/sdk/src/crypto/hmac.ts index ef7020bd..07608809 100644 --- a/js/sdk/src/crypto/hmac.ts +++ b/js/sdk/src/crypto/hmac.ts @@ -9,7 +9,7 @@ type HmacKeyUsage = 'sign' | 'verify'; */ export const importHmacKey = async ( key: Uint8Array, - keyUsage: HmacKeyUsage[] = ['sign', 'verify'] + keyUsage: HmacKeyUsage[] = ['sign', 'verify'], ): Promise => { // From https://datatracker.ietf.org/doc/html/rfc2104: // The key for HMAC can be of any length (keys longer than B bytes are first hashed using H). diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index b5377bd3..1dc5f474 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -48,24 +48,24 @@ export interface SessionKey { export enum VERIFICATION_STATUS { NOT_SIGNED = 0, SIGNED_AND_VALID = 1, - SIGNED_AND_INVALID = 2 + SIGNED_AND_INVALID = 2, } export interface SRPModule { - getSrpVerifier: (password: string) => Promise, - computeKeyPassword: (password: string, salt: string) => Promise, + getSrpVerifier: (password: string) => Promise; + computeKeyPassword: (password: string, salt: string) => Promise; } export type SRPVerifier = { - modulusId: string, - version: number, - salt: string, - verifier: string, -} + modulusId: string; + version: number; + salt: string; + verifier: string; +}; /** * OpenPGP crypto layer to provide necessary PGP operations for Drive crypto. - * + * * This layer focuses on providing general openPGP functions. Every operation * should prefer binary input and output. Ideally, armoring should be done * later in serialisation step, but for now, it is part of the interface to @@ -77,38 +77,44 @@ export type SRPVerifier = { export interface OpenPGPCrypto { /** * Generate a random passphrase. - * + * * 32 random bytes are generated and encoded into a base64 string. */ - generatePassphrase: () => string, + generatePassphrase: () => string; - generateSessionKey: (encryptionKeys: PrivateKey[]) => Promise, + generateSessionKey: (encryptionKeys: PrivateKey[]) => Promise; - encryptSessionKey: (sessionKey: SessionKey, encryptionKeys: PublicKey | PublicKey[]) => Promise<{ - keyPacket: Uint8Array, - }>, + encryptSessionKey: ( + sessionKey: SessionKey, + encryptionKeys: PublicKey | PublicKey[], + ) => Promise<{ + keyPacket: Uint8Array; + }>; - encryptSessionKeyWithPassword: (sessionKey: SessionKey, password: string) => Promise<{ - keyPacket: Uint8Array, - }>, + encryptSessionKeyWithPassword: ( + sessionKey: SessionKey, + password: string, + ) => Promise<{ + keyPacket: Uint8Array; + }>; /** * Generate a new key pair locked by a passphrase. - * + * * The key pair is generated using the Curve25519 algorithm. */ generateKey: (passphrase: string) => Promise<{ - privateKey: PrivateKey, - armoredKey: string, - }>, + privateKey: PrivateKey; + armoredKey: string; + }>; encryptArmored: ( data: Uint8Array, encryptionKeys: PrivateKey[], sessionKey?: SessionKey, ) => Promise<{ - armoredData: string, - }>, + armoredData: string; + }>; encryptAndSign: ( data: Uint8Array, @@ -116,8 +122,8 @@ export interface OpenPGPCrypto { encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) => Promise<{ - encryptedData: Uint8Array, - }>, + encryptedData: Uint8Array; + }>; encryptAndSignArmored: ( data: Uint8Array, @@ -125,8 +131,8 @@ export interface OpenPGPCrypto { encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) => Promise<{ - armoredData: string, - }>, + armoredData: string; + }>; encryptAndSignDetached: ( data: Uint8Array, @@ -134,9 +140,9 @@ export interface OpenPGPCrypto { encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) => Promise<{ - encryptedData: Uint8Array, - signature: Uint8Array, - }>, + encryptedData: Uint8Array; + signature: Uint8Array; + }>; encryptAndSignDetachedArmored: ( data: Uint8Array, @@ -144,56 +150,47 @@ export interface OpenPGPCrypto { encryptionKeys: PrivateKey[], signingKey: PrivateKey, ) => Promise<{ - armoredData: string, - armoredSignature: string, - }>, + armoredData: string; + armoredSignature: string; + }>; sign: ( data: Uint8Array, signingKey: PrivateKey, signatureContext: string, ) => Promise<{ - signature: Uint8Array, - }>, + signature: Uint8Array; + }>; signArmored: ( data: Uint8Array, signingKey: PrivateKey | PrivateKey[], ) => Promise<{ - signature: string, - }>, + signature: string; + }>; verify: ( data: Uint8Array, armoredSignature: string, verificationKeys: PublicKey | PublicKey[], ) => Promise<{ - verified: VERIFICATION_STATUS, - }>, + verified: VERIFICATION_STATUS; + }>; - decryptSessionKey: ( - data: Uint8Array, - decryptionKeys: PrivateKey | PrivateKey[], - ) => Promise, + decryptSessionKey: (data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; - decryptArmoredSessionKey: ( - armoredData: string, - decryptionKeys: PrivateKey | PrivateKey[], - ) => Promise, + decryptArmoredSessionKey: (armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; - decryptKey: ( - armoredKey: string, - passphrase: string, - ) => Promise, + decryptKey: (armoredKey: string, passphrase: string) => Promise; decryptAndVerify( data: Uint8Array, sessionKey: SessionKey, verificationKeys: PublicKey | PublicKey[], ): Promise<{ - data: Uint8Array, - verified: VERIFICATION_STATUS, - }>, + data: Uint8Array; + verified: VERIFICATION_STATUS; + }>; decryptAndVerifyDetached( data: Uint8Array, @@ -201,23 +198,20 @@ export interface OpenPGPCrypto { sessionKey: SessionKey, verificationKeys?: PublicKey | PublicKey[], ): Promise<{ - data: Uint8Array, - verified: VERIFICATION_STATUS, - }>, + data: Uint8Array; + verified: VERIFICATION_STATUS; + }>; - decryptArmored( - armoredData: string, - decryptionKeys: PrivateKey | PrivateKey[], - ): Promise, + decryptArmored(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]): Promise; decryptArmoredAndVerify: ( armoredData: string, decryptionKeys: PrivateKey | PrivateKey[], verificationKeys: PublicKey | PublicKey[], ) => Promise<{ - data: Uint8Array, - verified: VERIFICATION_STATUS, - }>, + data: Uint8Array; + verified: VERIFICATION_STATUS; + }>; decryptArmoredAndVerifyDetached: ( armoredData: string, @@ -225,12 +219,9 @@ export interface OpenPGPCrypto { sessionKey: SessionKey, verificationKeys: PublicKey | PublicKey[], ) => Promise<{ - data: Uint8Array, - verified: VERIFICATION_STATUS, - }>, + data: Uint8Array; + verified: VERIFICATION_STATUS; + }>; - decryptArmoredWithPassword( - armoredData: string, - password: string, - ): Promise, + decryptArmoredWithPassword(armoredData: string, password: string): Promise; } diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index af47bcb1..458fb9cb 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -6,57 +6,63 @@ import { uint8ArrayToBase64String } from './utils'; * clients/packages/crypto/lib/proxy/proxy.ts. */ export interface OpenPGPCryptoProxy { - generateKey: (options: { userIDs: { name: string }[], type: 'ecc', curve: 'ed25519Legacy' }) => Promise, - exportPrivateKey: (options: { privateKey: PrivateKey, passphrase: string | null }) => Promise, - importPrivateKey: (options: { armoredKey: string, passphrase: string | null }) => Promise, - generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise, - encryptSessionKey: (options: SessionKey & { format: 'binary', encryptionKeys?: PublicKey | PublicKey[], passwords?: string[] }) => Promise, - decryptSessionKey: (options: { armoredMessage?: string, binaryMessage?: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[] }) => Promise, + generateKey: (options: { userIDs: { name: string }[]; type: 'ecc'; curve: 'ed25519Legacy' }) => Promise; + exportPrivateKey: (options: { privateKey: PrivateKey; passphrase: string | null }) => Promise; + importPrivateKey: (options: { armoredKey: string; passphrase: string | null }) => Promise; + generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise; + encryptSessionKey: ( + options: SessionKey & { format: 'binary'; encryptionKeys?: PublicKey | PublicKey[]; passwords?: string[] }, + ) => Promise; + decryptSessionKey: (options: { + armoredMessage?: string; + binaryMessage?: Uint8Array; + decryptionKeys: PrivateKey | PrivateKey[]; + }) => Promise; encryptMessage: (options: { - format?: 'armored' | 'binary', - binaryData: Uint8Array, - sessionKey?: SessionKey, - encryptionKeys: PrivateKey[], - signingKeys?: PrivateKey, - detached?: boolean, + format?: 'armored' | 'binary'; + binaryData: Uint8Array; + sessionKey?: SessionKey; + encryptionKeys: PrivateKey[]; + signingKeys?: PrivateKey; + detached?: boolean; }) => Promise<{ - message: string | Uint8Array, - signature?: string | Uint8Array, - }>, + message: string | Uint8Array; + signature?: string | Uint8Array; + }>; decryptMessage: (options: { - format: 'utf8' | 'binary', - armoredMessage?: string, - binaryMessage?: Uint8Array, - armoredSignature?: string, - binarySignature?: Uint8Array, - sessionKeys?: SessionKey, - passwords?: string[], - decryptionKeys?: PrivateKey | PrivateKey[], - verificationKeys?: PublicKey | PublicKey[], + format: 'utf8' | 'binary'; + armoredMessage?: string; + binaryMessage?: Uint8Array; + armoredSignature?: string; + binarySignature?: Uint8Array; + sessionKeys?: SessionKey; + passwords?: string[]; + decryptionKeys?: PrivateKey | PrivateKey[]; + verificationKeys?: PublicKey | PublicKey[]; }) => Promise<{ - data: Uint8Array | string, + data: Uint8Array | string; // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. - verified?: VERIFICATION_STATUS, - verificationStatus?: VERIFICATION_STATUS, - }>, + verified?: VERIFICATION_STATUS; + verificationStatus?: VERIFICATION_STATUS; + }>; signMessage: (options: { - format: 'binary' | 'armored', - binaryData: Uint8Array, - signingKeys: PrivateKey | PrivateKey[], - detached: boolean, - signatureContext?: { critical: boolean, value: string }, - }) => Promise, + format: 'binary' | 'armored'; + binaryData: Uint8Array; + signingKeys: PrivateKey | PrivateKey[]; + detached: boolean; + signatureContext?: { critical: boolean; value: string }; + }) => Promise; verifyMessage: (options: { - binaryData: Uint8Array, - armoredSignature: string, - verificationKeys: PublicKey | PublicKey[], + binaryData: Uint8Array; + armoredSignature: string; + verificationKeys: PublicKey | PublicKey[]; }) => Promise<{ // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. - verified?: VERIFICATION_STATUS, - verificationStatus?: VERIFICATION_STATUS, - }>, + verified?: VERIFICATION_STATUS; + verificationStatus?: VERIFICATION_STATUS; + }>; } /** @@ -87,7 +93,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { encryptionKeys, }); return { - keyPacket + keyPacket, }; } @@ -120,11 +126,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async encryptArmored( - data: Uint8Array, - encryptionKeys: PrivateKey[], - sessionKey?: SessionKey, - ) { + async encryptArmored(data: Uint8Array, encryptionKeys: PrivateKey[], sessionKey?: SessionKey) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, sessionKey, @@ -132,7 +134,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }); return { armoredData: armoredData as string, - } + }; } async encryptAndSign( @@ -189,7 +191,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { encryptedData: encryptedData as Uint8Array, signature: signature as Uint8Array, - } + }; } async encryptAndSignDetachedArmored( @@ -208,14 +210,10 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { armoredData: armoredData as string, armoredSignature: armoredSignature as string, - } + }; } - async sign( - data: Uint8Array, - signingKeys: PrivateKey | PrivateKey[], - signatureContext: string, - ) { + async sign(data: Uint8Array, signingKeys: PrivateKey | PrivateKey[], signatureContext: string) { const signature = await this.cryptoProxy.signMessage({ binaryData: data, signingKeys, @@ -228,10 +226,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async signArmored( - data: Uint8Array, - signingKeys: PrivateKey | PrivateKey[], - ) { + async signArmored(data: Uint8Array, signingKeys: PrivateKey | PrivateKey[]) { const signature = await this.cryptoProxy.signMessage({ binaryData: data, signingKeys, @@ -243,11 +238,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async verify( - data: Uint8Array, - armoredSignature: string, - verificationKeys: PublicKey | PublicKey[], - ) { + async verify(data: Uint8Array, armoredSignature: string, verificationKeys: PublicKey | PublicKey[]) { const { verified, verificationStatus } = await this.cryptoProxy.verifyMessage({ binaryData: data, armoredSignature, @@ -260,10 +251,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async decryptSessionKey( - data: Uint8Array, - decryptionKeys: PrivateKey | PrivateKey[], - ) { + async decryptSessionKey(data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) { const sessionKey = await this.cryptoProxy.decryptSessionKey({ binaryMessage: data, decryptionKeys, @@ -276,10 +264,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return sessionKey; } - async decryptArmoredSessionKey( - armoredData: string, - decryptionKeys: PrivateKey | PrivateKey[], - ) { + async decryptArmoredSessionKey(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]) { const sessionKey = await this.cryptoProxy.decryptSessionKey({ armoredMessage: armoredData, decryptionKeys, @@ -292,10 +277,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return sessionKey; } - async decryptKey( - armoredKey: string, - passphrase: string, - ) { + async decryptKey(armoredKey: string, passphrase: string) { const key = await this.cryptoProxy.importPrivateKey({ armoredKey, passphrase, @@ -303,12 +285,12 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return key; } - async decryptAndVerify( - data: Uint8Array, - sessionKey: SessionKey, - verificationKeys: PublicKey[], - ) { - const { data: decryptedData, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ + async decryptAndVerify(data: Uint8Array, sessionKey: SessionKey, verificationKeys: PublicKey[]) { + const { + data: decryptedData, + verified, + verificationStatus, + } = await this.cryptoProxy.decryptMessage({ binaryMessage: data, sessionKeys: sessionKey, verificationKeys, @@ -320,7 +302,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, - } + }; } async decryptAndVerifyDetached( @@ -329,7 +311,11 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, verificationKeys?: PublicKey[], ) { - const { data: decryptedData, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ + const { + data: decryptedData, + verified, + verificationStatus, + } = await this.cryptoProxy.decryptMessage({ binaryMessage: data, binarySignature: signature, sessionKeys: sessionKey, @@ -342,13 +328,10 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, - } + }; } - async decryptArmored( - armoredData: string, - decryptionKeys: PrivateKey | PrivateKey[], - ) { + async decryptArmored(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]) { const { data } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, decryptionKeys, @@ -374,7 +357,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, - } + }; } async decryptArmoredAndVerifyDetached( @@ -396,13 +379,10 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, - } + }; } - async decryptArmoredWithPassword( - armoredData: string, - password: string, - ) { + async decryptArmoredWithPassword(armoredData: string, password: string) { const { data } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, passwords: [password], diff --git a/js/sdk/src/crypto/utils.ts b/js/sdk/src/crypto/utils.ts index 2b874120..d1102114 100644 --- a/js/sdk/src/crypto/utils.ts +++ b/js/sdk/src/crypto/utils.ts @@ -4,7 +4,7 @@ export function uint8ArrayToBase64String(array: Uint8Array) { return encodeBase64(arrayToBinaryString(array)); } -export function base64StringToUint8Array(string: string){ +export function base64StringToUint8Array(string: string) { return binaryStringToArray(decodeBase64(string) || ''); } diff --git a/js/sdk/src/diagnostic/eventsGenerator.ts b/js/sdk/src/diagnostic/eventsGenerator.ts index 6cf64c6e..34d8274a 100644 --- a/js/sdk/src/diagnostic/eventsGenerator.ts +++ b/js/sdk/src/diagnostic/eventsGenerator.ts @@ -14,10 +14,10 @@ export class EventsGenerator { this.eventQueue.push(event); // Notify all waiting generators const resolvers = this.waitingResolvers.splice(0); - resolvers.forEach(resolve => resolve()); + resolvers.forEach((resolve) => resolve()); } - async* iterateEvents(): AsyncGenerator { + async *iterateEvents(): AsyncGenerator { try { while (true) { if (this.eventQueue.length === 0) { diff --git a/js/sdk/src/diagnostic/httpClient.ts b/js/sdk/src/diagnostic/httpClient.ts index 90bcfd08..7492a134 100644 --- a/js/sdk/src/diagnostic/httpClient.ts +++ b/js/sdk/src/diagnostic/httpClient.ts @@ -1,4 +1,8 @@ -import { ProtonDriveHTTPClient, ProtonDriveHTTPClientBlobOptions, ProtonDriveHTTPClientJsonOptions } from "../interface"; +import { + ProtonDriveHTTPClient, + ProtonDriveHTTPClientBlobOptions, + ProtonDriveHTTPClientJsonOptions, +} from '../interface'; import { EventsGenerator } from './eventsGenerator'; /** @@ -77,4 +81,4 @@ export class DiagnosticHTTPClient extends EventsGenerator implements ProtonDrive fetchBlob(options: ProtonDriveHTTPClientBlobOptions): Promise { return this.httpClient.fetchBlob(options); } -} \ No newline at end of file +} diff --git a/js/sdk/src/diagnostic/index.ts b/js/sdk/src/diagnostic/index.ts index 2d17ea5a..de86f834 100644 --- a/js/sdk/src/diagnostic/index.ts +++ b/js/sdk/src/diagnostic/index.ts @@ -1,13 +1,13 @@ -import { MemoryCache, NullCache } from "../cache"; -import { ProtonDriveClientContructorParameters } from "../interface"; -import { ProtonDriveClient } from "../protonDriveClient"; -import { DiagnosticHTTPClient } from "./httpClient"; -import { Diagnostic } from "./interface"; -import { SDKDiagnostic } from "./sdkDiagnostic"; -import { FullSDKDiagnostic } from "./sdkDiagnosticFull"; -import { DiagnosticTelemetry } from "./telemetry"; +import { MemoryCache, NullCache } from '../cache'; +import { ProtonDriveClientContructorParameters } from '../interface'; +import { ProtonDriveClient } from '../protonDriveClient'; +import { DiagnosticHTTPClient } from './httpClient'; +import { Diagnostic } from './interface'; +import { SDKDiagnostic } from './sdkDiagnostic'; +import { FullSDKDiagnostic } from './sdkDiagnosticFull'; +import { DiagnosticTelemetry } from './telemetry'; -export type { Diagnostic, DiagnosticResult } from "./interface"; +export type { Diagnostic, DiagnosticResult } from './interface'; /** * Initializes the diagnostic tool. It creates the instance of @@ -15,7 +15,9 @@ export type { Diagnostic, DiagnosticResult } from "./interface"; * metrics and HTTP calls; and enforced null/empty cache to always * start from scratch. */ -export function initDiagnostic(options: Omit): Diagnostic { +export function initDiagnostic( + options: Omit, +): Diagnostic { const httpClient = new DiagnosticHTTPClient(options.httpClient); const telemetry = new DiagnosticTelemetry(); diff --git a/js/sdk/src/diagnostic/integrityVerificationStream.ts b/js/sdk/src/diagnostic/integrityVerificationStream.ts index 6638ba74..b428d726 100644 --- a/js/sdk/src/diagnostic/integrityVerificationStream.ts +++ b/js/sdk/src/diagnostic/integrityVerificationStream.ts @@ -1,4 +1,4 @@ -import { sha1 } from "@noble/hashes/legacy"; +import { sha1 } from '@noble/hashes/legacy'; import { bytesToHex } from '@noble/hashes/utils'; /** @@ -30,7 +30,7 @@ export class IntegrityVerificationStream extends WritableStream { abort: () => { this._isClosed = true; this._computedSha1 = undefined; - } + }, }); } @@ -52,5 +52,4 @@ export class IntegrityVerificationStream extends WritableStream { } return this._computedSizeInBytes; } - -} \ No newline at end of file +} diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index 182cf4d9..ca3878fd 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -1,5 +1,5 @@ -import { DegradedNode, MaybeNode, MetricEvent } from "../interface"; -import { LogRecord } from "../telemetry"; +import { DegradedNode, MaybeNode, MetricEvent } from '../interface'; +import { LogRecord } from '../telemetry'; export interface Diagnostic { verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator; @@ -7,11 +7,11 @@ export interface Diagnostic { } export type DiagnosticOptions = { - verifyContent?: boolean, - verifyThumbnails?: boolean, -} + verifyContent?: boolean; + verifyThumbnails?: boolean; +}; -export type DiagnosticResult = +export type DiagnosticResult = | FatalErrorResult | SdkErrorResult | HttpErrorResult @@ -30,129 +30,129 @@ export type DiagnosticResult = // Event representing that fatal error occurred during the diagnostic. // This error prevents the diagnostic to finish. export type FatalErrorResult = { - type: 'fatal_error', - message: string, - error?: unknown, -} + type: 'fatal_error'; + message: string; + error?: unknown; +}; // Event representing that SDK call failed. // It can be any throwable error from any SDK call. Normally no error should be thrown. export type SdkErrorResult = { - type: 'sdk_error', - call: string, - error?: unknown, -} + type: 'sdk_error'; + call: string; + error?: unknown; +}; // Event representing that HTTP call failed. // It can be any call from the SDK, including validation error. Normally no error should be present. export type HttpErrorResult = { - type: 'http_error', + type: 'http_error'; request: { - url: string, - method: string, - json: unknown, - }, + url: string; + method: string; + json: unknown; + }; // Error if the whole call failed (`fetch` failed). - error?: unknown, + error?: unknown; // Response if the response is not 2xx or 3xx. response?: { - status: number, - statusText: string, + status: number; + statusText: string; // Either json object or error if the response is not JSON. - json?: object, - jsonError?: unknown, - }, -} + json?: object; + jsonError?: unknown; + }; +}; // Event representing that node has some decryption or other (e.g., invalid name) issues. export type DegradedNodeResult = { - type: 'degraded_node', - nodeUid: string, - node: DegradedNode, -} + type: 'degraded_node'; + nodeUid: string; + node: DegradedNode; +}; // Event representing that signature verification failing. export type UnverifiedAuthorResult = { - type: 'unverified_author', - nodeUid: string, - revisionUid?: string, - authorType: string, - claimedAuthor?: string, - error: string, - node: MaybeNode, -} + type: 'unverified_author'; + nodeUid: string; + revisionUid?: string; + authorType: string; + claimedAuthor?: string; + error: string; + node: MaybeNode; +}; // Event representing that field from the extended attributes is not valid format. // Currently only `sha1` verification is supported. export type ExtendedAttributesErrorResult = { - type: 'extended_attributes_error', - nodeUid: string, - revisionUid?: string, - field: 'sha1', - value: string, -} + type: 'extended_attributes_error'; + nodeUid: string; + revisionUid?: string; + field: 'sha1'; + value: string; +}; // Event representing that field from the extended attributes is missing. // Currently only `sha1` verification is supported. export type ExtendedAttributesMissingFieldResult = { - type: 'extended_attributes_missing_field', - nodeUid: string, - revisionUid?: string, - missingField: 'sha1', -} + type: 'extended_attributes_missing_field'; + nodeUid: string; + revisionUid?: string; + missingField: 'sha1'; +}; // Event representing that file is missing the active revision. export type ContentFileMissingRevisionResult = { - type: 'content_file_missing_revision', - nodeUid: string, - revisionUid?: string, -} + type: 'content_file_missing_revision'; + nodeUid: string; + revisionUid?: string; +}; // Event representing that file content is not valid - either sha1 or size is not correct. export type ContentIntegrityErrorResult = { - type: 'content_integrity_error', - nodeUid: string, - revisionUid?: string, - claimedSha1?: string, - computedSha1?: string, - claimedSizeInBytes?: number, - computedSizeInBytes?: number, -} + type: 'content_integrity_error'; + nodeUid: string; + revisionUid?: string; + claimedSha1?: string; + computedSha1?: string; + claimedSizeInBytes?: number; + computedSizeInBytes?: number; +}; // Event representing that downloading the file content failed. // This can be connection issue or server error. If its integrity issue, // it should be reported as `ContentIntegrityErrorResult`. export type ContentDownloadErrorResult = { - type: 'content_download_error', - nodeUid: string, - revisionUid?: string, - error: unknown, -} + type: 'content_download_error'; + nodeUid: string; + revisionUid?: string; + error: unknown; +}; // Event representing that getting the thumbnails failed. // This can be connection issue or server error. export type ThumbnailsErrorResult = { - type: 'thumbnails_error', - nodeUid: string, - revisionUid?: string, - message?: string, - error?: unknown, -} + type: 'thumbnails_error'; + nodeUid: string; + revisionUid?: string; + message?: string; + error?: unknown; +}; // Event representing errors logged during the diagnostic. export type LogErrorResult = { - type: 'log_error', - log: LogRecord, -} + type: 'log_error'; + log: LogRecord; +}; // Event representing warnings logged during the diagnostic. export type LogWarningResult = { - type: 'log_warning', - log: LogRecord, -} + type: 'log_warning'; + log: LogRecord; +}; // Event representing metrics logged during the diagnostic. export type MetricResult = { - type: 'metric', - event: MetricEvent, -} + type: 'metric'; + event: MetricEvent; +}; diff --git a/js/sdk/src/diagnostic/sdkDiagnostic.ts b/js/sdk/src/diagnostic/sdkDiagnostic.ts index cee480ca..b42e9cc0 100644 --- a/js/sdk/src/diagnostic/sdkDiagnostic.ts +++ b/js/sdk/src/diagnostic/sdkDiagnostic.ts @@ -1,7 +1,7 @@ -import { Author, FileDownloader, MaybeNode, NodeType, Revision, ThumbnailType } from "../interface"; -import { ProtonDriveClient } from "../protonDriveClient"; -import { Diagnostic, DiagnosticOptions, DiagnosticResult } from "./interface"; -import { IntegrityVerificationStream } from "./integrityVerificationStream"; +import { Author, FileDownloader, MaybeNode, NodeType, Revision, ThumbnailType } from '../interface'; +import { ProtonDriveClient } from '../protonDriveClient'; +import { Diagnostic, DiagnosticOptions, DiagnosticResult } from './interface'; +import { IntegrityVerificationStream } from './integrityVerificationStream'; /** * Diagnostic tool that uses SDK to traverse the node tree and verify @@ -15,7 +15,7 @@ export class SDKDiagnostic implements Diagnostic { this.protonDriveClient = protonDriveClient; } - async* verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { + async *verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { let myFilesRootFolder: MaybeNode; try { @@ -32,7 +32,7 @@ export class SDKDiagnostic implements Diagnostic { yield* this.verifyNodeTree(myFilesRootFolder, options); } - async* verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + async *verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { const isFolder = getNodeType(node) === NodeType.Folder; yield* this.verifyNode(node, options); @@ -42,7 +42,7 @@ export class SDKDiagnostic implements Diagnostic { } } - private async* verifyNode(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + private async *verifyNode(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { const nodeUid = node.ok ? node.value.uid : node.error.uid; if (!node.ok) { @@ -57,10 +57,16 @@ export class SDKDiagnostic implements Diagnostic { const nodeInfo = { ...getNodeUids(node), node, - } - - yield* this.verifyAuthor(node.ok ? node.value.keyAuthor : node.error.keyAuthor, { ...nodeInfo, authorType: 'key' }); - yield* this.verifyAuthor(node.ok ? node.value.nameAuthor : node.error.nameAuthor, { ...nodeInfo, authorType: 'name' }); + }; + + yield* this.verifyAuthor(node.ok ? node.value.keyAuthor : node.error.keyAuthor, { + ...nodeInfo, + authorType: 'key', + }); + yield* this.verifyAuthor(node.ok ? node.value.nameAuthor : node.error.nameAuthor, { + ...nodeInfo, + authorType: 'name', + }); if (activeRevision) { yield* this.verifyAuthor(activeRevision.contentAuthor, { ...nodeInfo, authorType: 'content' }); } @@ -75,7 +81,10 @@ export class SDKDiagnostic implements Diagnostic { } } - private async* verifyAuthor(author: Author, info: { nodeUid: string, authorType: string, revisionUid?: string, node: MaybeNode }): AsyncGenerator { + private async *verifyAuthor( + author: Author, + info: { nodeUid: string; authorType: string; revisionUid?: string; node: MaybeNode }, + ): AsyncGenerator { if (!author.ok) { yield { type: 'unverified_author', @@ -86,7 +95,7 @@ export class SDKDiagnostic implements Diagnostic { } } - private async* verifyFileExtendedAttributes(node: MaybeNode): AsyncGenerator { + private async *verifyFileExtendedAttributes(node: MaybeNode): AsyncGenerator { const activeRevision = getActiveRevision(node); const expectedAttributes = getNodeType(node) === NodeType.File; @@ -98,7 +107,7 @@ export class SDKDiagnostic implements Diagnostic { ...getNodeUids(node), field: 'sha1', value: claimedSha1, - } + }; } if (expectedAttributes && !claimedSha1) { @@ -106,11 +115,11 @@ export class SDKDiagnostic implements Diagnostic { type: 'extended_attributes_missing_field', ...getNodeUids(node), missingField: 'sha1', - } + }; } } - private async* verifyContent(node: MaybeNode): AsyncGenerator { + private async *verifyContent(node: MaybeNode): AsyncGenerator { if (getNodeType(node) !== NodeType.File) { return; } @@ -119,7 +128,7 @@ export class SDKDiagnostic implements Diagnostic { yield { type: 'content_file_missing_revision', nodeUid: node.ok ? node.value.uid : node.error.uid, - } + }; return; } @@ -165,7 +174,7 @@ export class SDKDiagnostic implements Diagnostic { } } - private async* verifyThumbnails(node: MaybeNode): AsyncGenerator { + private async *verifyThumbnails(node: MaybeNode): AsyncGenerator { if (getNodeType(node) !== NodeType.File) { return; } @@ -173,14 +182,16 @@ export class SDKDiagnostic implements Diagnostic { const nodeUid = node.ok ? node.value.uid : node.error.uid; try { - const result = await Array.fromAsync(this.protonDriveClient.iterateThumbnails([nodeUid], ThumbnailType.Type1)); + const result = await Array.fromAsync( + this.protonDriveClient.iterateThumbnails([nodeUid], ThumbnailType.Type1), + ); if (result.length === 0) { yield { type: 'sdk_error', call: `iterateThumbnails(${nodeUid})`, error: new Error('No thumbnails found'), - } + }; } // TODO: We should have better way to check if the thumbnail is not expected. if (!result[0].ok && result[0].error !== 'Node has no thumbnail') { @@ -188,18 +199,18 @@ export class SDKDiagnostic implements Diagnostic { type: 'thumbnails_error', nodeUid, error: result[0].error, - } + }; } } catch (error: unknown) { yield { type: 'sdk_error', call: `iterateThumbnails(${nodeUid})`, error, - } + }; } } - private async* verifyNodeChildren(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + private async *verifyNodeChildren(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { const nodeUid = node.ok ? node.value.uid : node.error.uid; try { for await (const child of this.protonDriveClient.iterateFolderChildren(node)) { @@ -215,7 +226,7 @@ export class SDKDiagnostic implements Diagnostic { } } -function getNodeUids(node: MaybeNode): { nodeUid: string, revisionUid?: string } { +function getNodeUids(node: MaybeNode): { nodeUid: string; revisionUid?: string } { const activeRevision = getActiveRevision(node); return { nodeUid: node.ok ? node.value.uid : node.error.uid, diff --git a/js/sdk/src/diagnostic/sdkDiagnosticFull.ts b/js/sdk/src/diagnostic/sdkDiagnosticFull.ts index 0e3e7fbb..488de8e0 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticFull.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticFull.ts @@ -1,40 +1,37 @@ -import { MaybeNode } from "../interface"; -import { DiagnosticHTTPClient } from "./httpClient"; -import { Diagnostic, DiagnosticOptions, DiagnosticResult } from "./interface"; -import { DiagnosticTelemetry } from "./telemetry"; -import { zipGenerators } from "./zipGenerators"; +import { MaybeNode } from '../interface'; +import { DiagnosticHTTPClient } from './httpClient'; +import { Diagnostic, DiagnosticOptions, DiagnosticResult } from './interface'; +import { DiagnosticTelemetry } from './telemetry'; +import { zipGenerators } from './zipGenerators'; /** * Diagnostic tool that produces full diagnostic, including logs and metrics * by reading the events from the telemetry and HTTP client. */ export class FullSDKDiagnostic implements Diagnostic { - constructor(private diagnostic: Diagnostic, private telemetry: DiagnosticTelemetry, private httpClient: DiagnosticHTTPClient) { + constructor( + private diagnostic: Diagnostic, + private telemetry: DiagnosticTelemetry, + private httpClient: DiagnosticHTTPClient, + ) { this.diagnostic = diagnostic; this.telemetry = telemetry; this.httpClient = httpClient; } - async* verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { + async *verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { yield* this.yieldEvents(this.diagnostic.verifyMyFiles(options)); } - async* verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + async *verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { yield* this.yieldEvents(this.diagnostic.verifyNodeTree(node, options)); } - private async* yieldEvents(generator: AsyncGenerator): AsyncGenerator { - yield* zipGenerators( - generator, - this.internalGenerator(), - { stopOnFirstDone: true }, - ); + private async *yieldEvents(generator: AsyncGenerator): AsyncGenerator { + yield* zipGenerators(generator, this.internalGenerator(), { stopOnFirstDone: true }); } - private async* internalGenerator(): AsyncGenerator { - yield* zipGenerators( - this.telemetry.iterateEvents(), - this.httpClient.iterateEvents(), - ); + private async *internalGenerator(): AsyncGenerator { + yield* zipGenerators(this.telemetry.iterateEvents(), this.httpClient.iterateEvents()); } } diff --git a/js/sdk/src/diagnostic/telemetry.ts b/js/sdk/src/diagnostic/telemetry.ts index df09285b..e46a4935 100644 --- a/js/sdk/src/diagnostic/telemetry.ts +++ b/js/sdk/src/diagnostic/telemetry.ts @@ -36,7 +36,10 @@ export class DiagnosticTelemetry extends EventsGenerator { } class Logger { - constructor(private name: string, private callback?: (log: LogRecord) => void) { + constructor( + private name: string, + private callback?: (log: LogRecord) => void, + ) { this.name = name; this.callback = callback; } diff --git a/js/sdk/src/diagnostic/zipGenerators.test.ts b/js/sdk/src/diagnostic/zipGenerators.test.ts index deb240d6..3633826f 100644 --- a/js/sdk/src/diagnostic/zipGenerators.test.ts +++ b/js/sdk/src/diagnostic/zipGenerators.test.ts @@ -2,7 +2,7 @@ import { zipGenerators } from './zipGenerators'; async function* createTimedGenerator(values: { value: T; delay: number }[]): AsyncGenerator { for (const { value, delay } of values) { - await new Promise(resolve => setTimeout(resolve, delay)); + await new Promise((resolve) => setTimeout(resolve, delay)); yield value; } } diff --git a/js/sdk/src/diagnostic/zipGenerators.ts b/js/sdk/src/diagnostic/zipGenerators.ts index c9746221..26d60ac6 100644 --- a/js/sdk/src/diagnostic/zipGenerators.ts +++ b/js/sdk/src/diagnostic/zipGenerators.ts @@ -8,7 +8,7 @@ export async function* zipGenerators( genA: AsyncGenerator, genB: AsyncGenerator, options?: { - stopOnFirstDone?: boolean + stopOnFirstDone?: boolean; }, ): AsyncGenerator { const { stopOnFirstDone = false } = options || {}; @@ -21,8 +21,8 @@ export async function* zipGenerators( while (promiseA && promiseB) { const result = await Promise.race([ - promiseA.then(res => ({ source: 'A' as const, result: res })), - promiseB.then(res => ({ source: 'B' as const, result: res })) + promiseA.then((res) => ({ source: 'A' as const, result: res })), + promiseB.then((res) => ({ source: 'B' as const, result: res })), ]); if (result.source === 'A') { diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index a6b1c571..18e78d07 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -2,13 +2,13 @@ import { c } from 'ttag'; /** * Base class for all SDK errors. - * + * * This class can be used for catching all SDK errors. The error should have * translated message in the `message` property that should be shown to the * user without any modification. - * + * * No retries should be done as that is already handled by the SDK. - * + * * When SDK throws an error and it is not `ProtonDriveError`, it is unhandled error * by the SDK and usually indicates bug in the SDK. Please, report it. */ @@ -18,7 +18,7 @@ export class ProtonDriveError extends Error { /** * Error thrown when the operation is aborted. - * + * * This error is thrown when the operation is aborted by the user. * For example, by calling `abort()` on the `AbortSignal`. */ @@ -32,7 +32,7 @@ export class AbortError extends ProtonDriveError { /** * Error thrown when the validation fails. - * + * * This error is thrown when the validation of the input fails. * Validation can be done on the client side or on the server side. * @@ -45,7 +45,7 @@ export class ValidationError extends ProtonDriveError { /** * Internal API code. - * + * * Use only for debugging purposes. */ public readonly code?: number; @@ -64,7 +64,7 @@ export class ValidationError extends ProtonDriveError { /** * Error thrown when the node already exists. - * + * * This error is thrown when the node with the same name already exists in the * parent folder. The client should ask the user to replace the existing node * or choose another name. The available name is provided in the `availableName` @@ -86,11 +86,11 @@ export class NodeAlreadyExistsValidationError extends ValidationError { /** * Error thrown when the API call fails. - * + * * This error covers both HTTP errors and API errors. SDK automatically * retries the request before the error is thrown. The sepcific algorithm * used for retries depends on the type of the error. - * + * * Client should not retry the request when this error is thrown. */ export class ServerError extends ProtonDriveError { @@ -98,13 +98,13 @@ export class ServerError extends ProtonDriveError { /** * HTTP status code of the response. - * + * * Use only for debugging purposes. */ public readonly statusCode?: number; /** * Internal API code. - * + * * Use only for debugging purposes. */ public readonly code?: number; @@ -112,16 +112,16 @@ export class ServerError extends ProtonDriveError { /** * Error thrown when the client makes too many requests to the API. - * + * * SDK is configured to stay below the rate limits, but it can still happen if * client is running multiple SDKs in parallel, or if the rate limits are * changed on the server side. - * + * * SDK automatically retries the request before the error is thrown after * waiting for the required time specified by the server. - * + * * Client should slow down calling SDK when this error is thrown. - * + * * You can be also notified about the rate limits by the `requestsThrottled` * event. See `onMessage` method on the SDK class for more details. */ @@ -133,9 +133,9 @@ export class RateLimitedError extends ServerError { /** * Error thrown when the client is not connected to the internet. - * + * * Client should check the internet connection when this error is thrown. - * + * * You can also be notified about the connection status by the `offline` event * See `onMessage` method on the SDK class for more details. */ @@ -145,9 +145,9 @@ export class ConnectionError extends ProtonDriveError { /** * Error thrown when the decryption fails. - * + * * Client should report this error to the user and report bug report. - * + * * In most cases, there is no decryption error. Every decryption error should * be not exposed but set as empty value on the node, for example. But in the * case of the file content, if block cannot be decrypted, decryption error @@ -159,9 +159,9 @@ export class DecryptionError extends ProtonDriveError { /** * Error thrown when the data integrity check fails. - * + * * Client should report this error to the user and report bug report. - * + * * For example, it can happen when hashes don't match, etc. In some cases, * SDK allows to run command without verification checks for debug purposes. */ diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index f1098b13..b3eb4119 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -14,12 +14,12 @@ export { VERSION } from './version'; /** * Provides the node UID for the given raw volume and node IDs. - * + * * This is required only for the internal implementation to provide * backward compatibility with the old Drive web setup. - * + * * If you are having share ID, use `ProtonDriveClient::getNodeUid` instead. - * + * * @deprecated This method is not part of the public API. * @param volumeId - Volume of the node. * @param nodeId - Node/link ID (not UID). diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index 4dc8a623..7596e484 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -6,17 +6,17 @@ export interface ProtonDriveAccount { * * @throws Error If there is no primary address. */ - getOwnPrimaryAddress(): Promise, + getOwnPrimaryAddress(): Promise; /** * Get own address by email or addressId. * * @throws Error If there is no address with given email or addressId. */ - getOwnAddress(emailOrAddressId: string): Promise, + getOwnAddress(emailOrAddressId: string): Promise; /** * Returns whether given email can be used to share files with Proton Drive. */ - hasProtonAccount(email: string): Promise, + hasProtonAccount(email: string): Promise; /** * Get public keys for given email. * @@ -24,15 +24,15 @@ export interface ProtonDriveAccount { * * @throws Error Only if there is an error while fetching keys. */ - getPublicKeys(email: string): Promise, + getPublicKeys(email: string): Promise; } export interface ProtonDriveAccountAddress { - email: string, - addressId: string, - primaryKeyIndex: number, + email: string; + addressId: string; + primaryKeyIndex: number; keys: { - id: string, - key: PrivateKey, - }[], + id: string; + key: PrivateKey; + }[]; } diff --git a/js/sdk/src/interface/author.ts b/js/sdk/src/interface/author.ts index 1e6f32ed..024ee769 100644 --- a/js/sdk/src/interface/author.ts +++ b/js/sdk/src/interface/author.ts @@ -2,9 +2,9 @@ import { Result } from './result'; /** * Author with verification status. - * + * * It can be either a string (email) or an anonymous user. - * + * * If author cannot be verified, the result is failure with an error. * The client can still get claimed author from the error object, but * it must be used with caution. @@ -19,11 +19,11 @@ export type AnonymousUser = null; /** * Unverified author. - * + * * If author cannot be verified, the result is this object containing * the claimed author and the verification error. */ export type UnverifiedAuthorError = { - claimedAuthor?: string, - error: string, -} + claimedAuthor?: string; + error: string; +}; diff --git a/js/sdk/src/interface/config.ts b/js/sdk/src/interface/config.ts index 42c0590b..a74a4af4 100644 --- a/js/sdk/src/interface/config.ts +++ b/js/sdk/src/interface/config.ts @@ -4,14 +4,14 @@ export type ProtonDriveConfig = { * * If not provided, defaults to 'drive-api.proton.me'. */ - baseUrl?: string, + baseUrl?: string; /** * The language to use for error messages. * * If not provided, defaults to 'en'. */ - language?: string, + language?: string; /** * Client UID is used to identify the client for the upload. @@ -24,5 +24,5 @@ export type ProtonDriveConfig = { * You can force the upload by setting up * `overrideExistingDraftByOtherClient` to true. */ - clientUid?: string, -} + clientUid?: string; +}; diff --git a/js/sdk/src/interface/devices.ts b/js/sdk/src/interface/devices.ts index 2a90f01c..e8424f1f 100644 --- a/js/sdk/src/interface/devices.ts +++ b/js/sdk/src/interface/devices.ts @@ -2,15 +2,15 @@ import { Result } from './result'; import { InvalidNameError } from './nodes'; export type Device = { - uid: string, - type: DeviceType, - name: Result, - rootFolderUid: string, - creationTime: Date, + uid: string; + type: DeviceType; + name: Result; + rootFolderUid: string; + creationTime: Date; lastSyncDate?: Date; /** @deprecated to be removed once Volume-based navigation is implemented in web */ shareId: string; -} +}; export enum DeviceType { Windows = 'Windows', diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index d93e9727..e101f6bd 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -1,32 +1,35 @@ export interface FileDownloader { /** * Get the claimed size of the file in bytes. - * + * * This provides total clear-text size of the file. This is encrypted * information that is not known to the Proton Drive and thus it is * explicitely stated as claimed only and must be treated that way. * It can be wrong or missing completely. */ - getClaimedSizeInBytes(): number | undefined, + getClaimedSizeInBytes(): number | undefined; /** * Download, decrypt and verify the content from the server and write * to the provided stream. - * + * * @param onProgress - Callback that is called with the number of downloaded bytes */ - writeToStream(streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController, + writeToStream(streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController; /** * Same as `writeToStream` but without verification checks. - * + * * Use this only for debugging purposes. */ - unsafeWriteToStream(streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController, + unsafeWriteToStream( + streamFactory: WritableStream, + onProgress?: (downloadedBytes: number) => void, + ): DownloadController; } export interface DownloadController { - pause(): void, - resume(): void, - completion(): Promise, + pause(): void; + resume(): void; + completion(): Promise; } diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index aacfc2d7..01b63280 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -1,8 +1,8 @@ export enum SDKEvent { - TransfersPaused = "transfersPaused", - TransfersResumed = "transfersResumed", - RequestsThrottled = "requestsThrottled", - RequestsUnthrottled = "requestsUnthrottled", + TransfersPaused = 'transfersPaused', + TransfersResumed = 'transfersResumed', + RequestsThrottled = 'requestsThrottled', + RequestsUnthrottled = 'requestsUnthrottled', } export interface LatestEventIdProvider { @@ -22,47 +22,55 @@ export type DriveListener = (event: DriveEvent) => Promise; type NodeCruEventType = DriveEventType.NodeCreated | DriveEventType.NodeUpdated; -export type NodeEvent = { - type: NodeCruEventType, - nodeUid: string, - parentNodeUid?: string, - isTrashed: boolean, - isShared: boolean, - treeEventScopeId: string, - eventId: string, -} | { - type: DriveEventType.NodeDeleted, - nodeUid: string, - parentNodeUid?: string, - treeEventScopeId: string, - eventId: string, -} +export type NodeEvent = + | { + type: NodeCruEventType; + nodeUid: string; + parentNodeUid?: string; + isTrashed: boolean; + isShared: boolean; + treeEventScopeId: string; + eventId: string; + } + | { + type: DriveEventType.NodeDeleted; + nodeUid: string; + parentNodeUid?: string; + treeEventScopeId: string; + eventId: string; + }; export type FastForwardEvent = { - type: DriveEventType.FastForward, - treeEventScopeId: string, - eventId: string, -} + type: DriveEventType.FastForward; + treeEventScopeId: string; + eventId: string; +}; export type TreeRefreshEvent = { - type: DriveEventType.TreeRefresh, - treeEventScopeId: string, - eventId: string, -} + type: DriveEventType.TreeRefresh; + treeEventScopeId: string; + eventId: string; +}; export type TreeRemovalEvent = { - type: DriveEventType.TreeRemove, - treeEventScopeId: string, - eventId: 'none', -} + type: DriveEventType.TreeRemove; + treeEventScopeId: string; + eventId: 'none'; +}; export type SharedWithMeUpdated = { - type: DriveEventType.SharedWithMeUpdated, - eventId: string, - treeEventScopeId: 'core', -} + type: DriveEventType.SharedWithMeUpdated; + eventId: string; + treeEventScopeId: 'core'; +}; -export type DriveEvent = NodeEvent | FastForwardEvent | TreeRefreshEvent | TreeRemovalEvent | FastForwardEvent | SharedWithMeUpdated; +export type DriveEvent = + | NodeEvent + | FastForwardEvent + | TreeRefreshEvent + | TreeRemovalEvent + | FastForwardEvent + | SharedWithMeUpdated; export enum DriveEventType { NodeCreated = 'node_created', @@ -71,5 +79,5 @@ export enum DriveEventType { SharedWithMeUpdated = 'shared_with_me_updated', TreeRefresh = 'tree_refresh', TreeRemove = 'tree_remove', - FastForward = 'fast_forward' + FastForward = 'fast_forward', } diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts index d88db7d2..b973f406 100644 --- a/js/sdk/src/interface/httpClient.ts +++ b/js/sdk/src/interface/httpClient.ts @@ -4,23 +4,23 @@ export interface ProtonDriveHTTPClient { } export type ProtonDriveHTTPClientJsonOptions = ProtonDriveHTTPClientBaseOptions & { - json?: object, -} + json?: object; +}; export type ProtonDriveHTTPClientBlobOptions = ProtonDriveHTTPClientBaseOptions & { - body?: XMLHttpRequestBodyInit, - onProgress?: (progress: number) => void, -} + body?: XMLHttpRequestBodyInit; + onProgress?: (progress: number) => void; +}; type ProtonDriveHTTPClientBaseOptions = { - url: string, - method: string, - headers: Headers, + url: string; + method: string; + headers: Headers; /** * The timeout in milliseconds. * * When timeout is reached, the request will be aborted with TimeoutError. */ - timeoutMs: number, - signal?: AbortSignal, -} + timeoutMs: number; + signal?: AbortSignal; +}; diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 9bc4bdf9..5a5c85d7 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -16,12 +16,60 @@ export { DeviceType } from './devices'; export type { FileDownloader, DownloadController } from './download'; export type { DriveListener, LatestEventIdProvider, DriveEvent } from './events'; export { DriveEventType, SDKEvent } from './events'; -export type { ProtonDriveHTTPClient, ProtonDriveHTTPClientJsonOptions, ProtonDriveHTTPClientBlobOptions } from './httpClient'; -export type { MaybeNode, NodeEntity, DegradedNode, MaybeMissingNode, MissingNode, InvalidNameError, Revision, NodeOrUid, RevisionOrUid, NodeResult } from './nodes'; +export type { + ProtonDriveHTTPClient, + ProtonDriveHTTPClientJsonOptions, + ProtonDriveHTTPClientBlobOptions, +} from './httpClient'; +export type { + MaybeNode, + NodeEntity, + DegradedNode, + MaybeMissingNode, + MissingNode, + InvalidNameError, + Revision, + NodeOrUid, + RevisionOrUid, + NodeResult, +} from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; -export type { ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Member, PublicLink, MaybeBookmark, Bookmark, DegradedBookmark, ProtonInvitationOrUid, NonProtonInvitationOrUid, BookmarkOrUid, ShareNodeSettings, UnshareNodeSettings, ShareMembersSettings, SharePublicLinkSettings, SharePublicLinkSettingsObject, ShareResult } from './sharing'; +export type { + ProtonInvitation, + ProtonInvitationWithNode, + NonProtonInvitation, + Member, + PublicLink, + MaybeBookmark, + Bookmark, + DegradedBookmark, + ProtonInvitationOrUid, + NonProtonInvitationOrUid, + BookmarkOrUid, + ShareNodeSettings, + UnshareNodeSettings, + ShareMembersSettings, + SharePublicLinkSettings, + SharePublicLinkSettingsObject, + ShareResult, +} from './sharing'; export { NonProtonInvitationState } from './sharing'; -export type { Telemetry, Logger, MetricAPIRetrySucceededEvent, MetricUploadEvent, MetricsUploadErrorType, MetricDownloadEvent, MetricsDownloadErrorType, MetricDecryptionErrorEvent, MetricsDecryptionErrorField, MetricVerificationErrorEvent, MetricVerificationErrorField, MetricBlockVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent, MetricEvent } from './telemetry'; +export type { + Telemetry, + Logger, + MetricAPIRetrySucceededEvent, + MetricUploadEvent, + MetricsUploadErrorType, + MetricDownloadEvent, + MetricsDownloadErrorType, + MetricDecryptionErrorEvent, + MetricsDecryptionErrorField, + MetricVerificationErrorEvent, + MetricVerificationErrorField, + MetricBlockVerificationErrorEvent, + MetricVolumeEventsSubscriptionsChangedEvent, + MetricEvent, +} from './telemetry'; export { MetricVolumeType } from './telemetry'; export type { FileUploader, FileRevisionUploader, UploadController, UploadMetadata } from './upload'; export type { Thumbnail, ThumbnailResult } from './thumbnail'; @@ -31,20 +79,20 @@ export type ProtonDriveTelemetry = Telemetry; export type ProtonDriveEntitiesCache = ProtonDriveCache; export type ProtonDriveCryptoCache = ProtonDriveCache; export type CachedCryptoMaterial = { - passphrase?: string, - key: PrivateKey, - passphraseSessionKey: SessionKey, - hashKey?: Uint8Array, + passphrase?: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + hashKey?: Uint8Array; }; export interface ProtonDriveClientContructorParameters { - httpClient: ProtonDriveHTTPClient, - entitiesCache: ProtonDriveEntitiesCache, - cryptoCache: ProtonDriveCryptoCache, - account: ProtonDriveAccount, - openPGPCryptoModule: OpenPGPCrypto, - srpModule: SRPModule, - config?: ProtonDriveConfig, - telemetry?: ProtonDriveTelemetry, - latestEventIdProvider?: LatestEventIdProvider -}; + httpClient: ProtonDriveHTTPClient; + entitiesCache: ProtonDriveEntitiesCache; + cryptoCache: ProtonDriveCryptoCache; + account: ProtonDriveAccount; + openPGPCryptoModule: OpenPGPCrypto; + srpModule: SRPModule; + config?: ProtonDriveConfig; + telemetry?: ProtonDriveTelemetry; + latestEventIdProvider?: LatestEventIdProvider; +} diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 7148400b..1a0e0b0c 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -22,7 +22,7 @@ export type MaybeNode = Result; export type MaybeMissingNode = Result; export type MissingNode = { - missingUid: string, + missingUid: string; }; /** @@ -38,9 +38,9 @@ export type MissingNode = { * entity is called. */ export type NodeEntity = { - uid: string, - parentUid?: string, - name: string, + uid: string; + parentUid?: string; + name: string; /** * Author of the node key. * @@ -49,22 +49,22 @@ export type NodeEntity = { * author is user B, while key author stays to user A who has forever * option to decrypt latest versions. */ - keyAuthor: Author, + keyAuthor: Author; /** * Author of the name. * * Person who named the file. If user A uploads the file and user B renames * the file, key and content author is user A, while name author is user B. */ - nameAuthor: Author, - directMemberRole: MemberRole, - type: NodeType, - mediaType?: string, + nameAuthor: Author; + directMemberRole: MemberRole; + type: NodeType; + mediaType?: string; /** * Whether the node is shared. If true, the node is shared with at least * one user, or via public link. */ - isShared: boolean, + isShared: boolean; /** * Provides the ID of the share that the node is shared with. * @@ -73,20 +73,20 @@ export type NodeEntity = { * * @deprecated This field is not part of the public API. */ - deprecatedShareId?: string, + deprecatedShareId?: string; /** * Created on server date. */ - creationTime: Date, - trashTime?: Date, + creationTime: Date; + trashTime?: Date; /** * Total size of all revisions, encrypted size on the server. */ - totalStorageSize?: number, - activeRevision?: Revision, + totalStorageSize?: number; + activeRevision?: Revision; folder?: { - claimedModificationTime?: Date, - }, + claimedModificationTime?: Date; + }; /** * Provides an ID for the event scope. * @@ -95,8 +95,8 @@ export type NodeEntity = { * comprise one or more folder trees and will be shared by all * nodes in the tree. Nodes cannot change scopes. */ - treeEventScopeId: string, -} + treeEventScopeId: string; +}; /** * Degraded node representing a file or folder in the system. @@ -115,8 +115,8 @@ export type NodeEntity = { * possible, but download and upload new revision will still work. */ export type DegradedNode = Omit & { - name: Result, - activeRevision?: Result, + name: Result; + activeRevision?: Result; /** * If the error is not related to any specific field, it is set here. * @@ -127,8 +127,8 @@ export type DegradedNode = Omit & { * the name is still working, this will include the node key error, while * the name will be set to the decrypted value. */ - errors?: unknown[], -} + errors?: unknown[]; +}; /** * Invalid name error represents node name that includes invalid characters. @@ -137,13 +137,13 @@ export type InvalidNameError = { /** * Placeholder instead of node name that client can use to display. */ - name: string, - error: string, -} + name: string; + error: string; +}; export enum NodeType { - File = "file", - Folder = "folder", + File = 'file', + Folder = 'folder', /** * Album is a special type available only in Photos section. * @@ -154,44 +154,42 @@ export enum NodeType { * * @deprecated This type is not part of the public API. */ - Album = "album", + Album = 'album', } export enum MemberRole { - Viewer = "viewer", - Editor = "editor", - Admin = "admin", - Inherited = "inherited", + Viewer = 'viewer', + Editor = 'editor', + Admin = 'admin', + Inherited = 'inherited', } export type Revision = { - uid: string, - state: RevisionState, - creationTime: Date, // created on server date - contentAuthor: Author, + uid: string; + state: RevisionState; + creationTime: Date; // created on server date + contentAuthor: Author; /** * Encrypted size of the revision, as stored on the server. */ - storageSize: number, + storageSize: number; /** * Raw size of the revision, as stored in extended attributes. */ - claimedSize?: number, - claimedModificationTime?: Date, + claimedSize?: number; + claimedModificationTime?: Date; claimedDigests?: { - sha1?: string, - }, - claimedAdditionalMetadata?: object, -} + sha1?: string; + }; + claimedAdditionalMetadata?: object; +}; export enum RevisionState { - Active = "active", - Superseded = "superseded", + Active = 'active', + Superseded = 'superseded', } export type NodeOrUid = MaybeNode | NodeEntity | DegradedNode | string; export type RevisionOrUid = Revision | string; -export type NodeResult = - {uid: string, ok: true} | - {uid: string, ok: false, error: string}; +export type NodeResult = { uid: string; ok: true } | { uid: string; ok: false; error: string }; diff --git a/js/sdk/src/interface/result.ts b/js/sdk/src/interface/result.ts index 3c460091..b43ec69e 100644 --- a/js/sdk/src/interface/result.ts +++ b/js/sdk/src/interface/result.ts @@ -1,6 +1,4 @@ -export type Result = -| { ok: true; value: T } -| { ok: false; error: E }; +export type Result = { ok: true; value: T } | { ok: false; error: E }; export function resultOk(value: T): Result { return { ok: true, value }; diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 844491c6..c571373c 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -3,108 +3,110 @@ import { UnverifiedAuthorError } from './author'; import { NodeType, MemberRole, InvalidNameError } from './nodes'; export type Member = { - uid: string, - invitationTime: Date, - addedByEmail: Result, - inviteeEmail: string, - role: MemberRole, -} + uid: string; + invitationTime: Date; + addedByEmail: Result; + inviteeEmail: string; + role: MemberRole; +}; export type ProtonInvitation = Member; export type ProtonInvitationWithNode = ProtonInvitation & { node: { - name: Result, - type: NodeType, - mediaType?: string, - }, -} + name: Result; + type: NodeType; + mediaType?: string; + }; +}; export type NonProtonInvitation = ProtonInvitation & { - state: NonProtonInvitationState, -} + state: NonProtonInvitationState; +}; export enum NonProtonInvitationState { - Pending = "pending", - UserRegistered = "userRegistered", + Pending = 'pending', + UserRegistered = 'userRegistered', } export type PublicLink = { - uid: string, - creationTime: Date, - role: MemberRole, - url: string, - customPassword?: string, - expirationTime?: Date, - numberOfInitializedDownloads: number -} + uid: string; + creationTime: Date; + role: MemberRole; + url: string; + customPassword?: string; + expirationTime?: Date; + numberOfInitializedDownloads: number; +}; /** * Bookmark representing a saved link to publicly shared node. - * + * * This covers both happy path and degraded path. */ export type MaybeBookmark = Result; export type Bookmark = { - uid: string, - creationTime: Date, - url: string, + uid: string; + creationTime: Date; + url: string; node: { - name: string, - type: NodeType, - mediaType?: string, - }, -} + name: string; + type: NodeType; + mediaType?: string; + }; +}; /** * Degraded bookmark representing a saved link to publicly shared node. - * + * * This is a degraded path representation of the bookmark. It is used in the * SDK to represent the bookmark in a way that is easy to work with. Whenever * any field cannot be decrypted, it is returned as `DegradedBookmark` type. */ export type DegradedBookmark = Omit & { - url: Result, + url: Result; node: Omit & { - name: Result, - }, -} + name: Result; + }; +}; export type ProtonInvitationOrUid = ProtonInvitation | string; export type NonProtonInvitationOrUid = NonProtonInvitation | string; export type BookmarkOrUid = Bookmark | string; export type ShareNodeSettings = { - users?: ShareMembersSettings, - publicLink?: SharePublicLinkSettings, + users?: ShareMembersSettings; + publicLink?: SharePublicLinkSettings; emailOptions?: { - message?: string, - includeNodeName?: boolean, - }, -} + message?: string; + includeNodeName?: boolean; + }; +}; -export type ShareMembersSettings = string[] | { - email: string, - role: MemberRole, -}[]; +export type ShareMembersSettings = + | string[] + | { + email: string; + role: MemberRole; + }[]; export type SharePublicLinkSettings = boolean | SharePublicLinkSettingsObject; export type SharePublicLinkSettingsObject = { - role: MemberRole, - customPassword?: string | undefined, - expiration?: Date | undefined, + role: MemberRole; + customPassword?: string | undefined; + expiration?: Date | undefined; }; export type ShareResult = { - protonInvitations: ProtonInvitation[], - nonProtonInvitations: NonProtonInvitation[], - members: Member[], - publicLink?: PublicLink, -} + protonInvitations: ProtonInvitation[]; + nonProtonInvitations: NonProtonInvitation[]; + members: Member[]; + publicLink?: PublicLink; +}; export type UnshareNodeSettings = { - users?: string[], - publicLink?: 'remove', + users?: string[]; + publicLink?: 'remove'; }; diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 0bd16914..3b1dbf82 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -1,6 +1,6 @@ export interface Telemetry { - getLogger: (name: string) => Logger, - logEvent: (event: MetricEvent) => void, + getLogger: (name: string) => Logger; + logEvent: (event: MetricEvent) => void; } export interface Logger { @@ -11,98 +11,98 @@ export interface Logger { } export type MetricEvent = - MetricAPIRetrySucceededEvent | - MetricUploadEvent | - MetricDownloadEvent | - MetricDecryptionErrorEvent | - MetricVerificationErrorEvent | - MetricBlockVerificationErrorEvent | - MetricVolumeEventsSubscriptionsChangedEvent; + | MetricAPIRetrySucceededEvent + | MetricUploadEvent + | MetricDownloadEvent + | MetricDecryptionErrorEvent + | MetricVerificationErrorEvent + | MetricBlockVerificationErrorEvent + | MetricVolumeEventsSubscriptionsChangedEvent; export interface MetricAPIRetrySucceededEvent { - eventName: 'apiRetrySucceeded', - url: string, - failedAttempts: number, -}; + eventName: 'apiRetrySucceeded'; + url: string; + failedAttempts: number; +} export interface MetricUploadEvent { - eventName: 'upload', - volumeType?: MetricVolumeType, - uploadedSize: number, - expectedSize: number, - error?: MetricsUploadErrorType, - originalError?: unknown, -}; + eventName: 'upload'; + volumeType?: MetricVolumeType; + uploadedSize: number; + expectedSize: number; + error?: MetricsUploadErrorType; + originalError?: unknown; +} export type MetricsUploadErrorType = - 'server_error' | - 'network_error' | - 'integrity_error' | - 'rate_limited' | - '4xx' | - 'unknown'; + | 'server_error' + | 'network_error' + | 'integrity_error' + | 'rate_limited' + | '4xx' + | 'unknown'; export interface MetricDownloadEvent { - eventName: 'download', - volumeType?: MetricVolumeType, - downloadedSize: number, - claimedFileSize?: number, - error?: MetricsDownloadErrorType, - originalError?: unknown, -}; + eventName: 'download'; + volumeType?: MetricVolumeType; + downloadedSize: number; + claimedFileSize?: number; + error?: MetricsDownloadErrorType; + originalError?: unknown; +} export type MetricsDownloadErrorType = - 'server_error' | - 'network_error' | - 'decryption_error' | - 'integrity_error' | - 'rate_limited' | - '4xx' | - 'unknown'; + | 'server_error' + | 'network_error' + | 'decryption_error' + | 'integrity_error' + | 'rate_limited' + | '4xx' + | 'unknown'; export interface MetricDecryptionErrorEvent { - eventName: 'decryptionError', - volumeType?: MetricVolumeType, - field: MetricsDecryptionErrorField, - fromBefore2024?: boolean, - error?: unknown, -}; + eventName: 'decryptionError'; + volumeType?: MetricVolumeType; + field: MetricsDecryptionErrorField; + fromBefore2024?: boolean; + error?: unknown; +} export type MetricsDecryptionErrorField = - 'shareKey' | - 'shareUrlPassword' | - 'nodeKey' | - 'nodeName' | - 'nodeHashKey' | - 'nodeExtendedAttributes' | - 'nodeContentKey' | - 'content'; + | 'shareKey' + | 'shareUrlPassword' + | 'nodeKey' + | 'nodeName' + | 'nodeHashKey' + | 'nodeExtendedAttributes' + | 'nodeContentKey' + | 'content'; export interface MetricVerificationErrorEvent { - eventName: 'verificationError', - volumeType?: MetricVolumeType, - field: MetricVerificationErrorField, - addressMatchingDefaultShare?: boolean, - fromBefore2024?: boolean, -}; + eventName: 'verificationError'; + volumeType?: MetricVolumeType; + field: MetricVerificationErrorField; + addressMatchingDefaultShare?: boolean; + fromBefore2024?: boolean; +} export type MetricVerificationErrorField = - 'shareKey' | - 'nodeKey' | - 'nodeName' | - 'nodeHashKey' | - 'nodeExtendedAttributes' | - 'nodeContentKey' | - 'content'; + | 'shareKey' + | 'nodeKey' + | 'nodeName' + | 'nodeHashKey' + | 'nodeExtendedAttributes' + | 'nodeContentKey' + | 'content'; export interface MetricBlockVerificationErrorEvent { - eventName: 'blockVerificationError', - retryHelped: boolean, -}; + eventName: 'blockVerificationError'; + retryHelped: boolean; +} export interface MetricVolumeEventsSubscriptionsChangedEvent { - eventName: 'volumeEventsSubscriptionsChanged', - numberOfVolumeSubscriptions: number, -}; + eventName: 'volumeEventsSubscriptionsChanged'; + numberOfVolumeSubscriptions: number; +} export enum MetricVolumeType { OwnVolume = 'own_volume', Shared = 'shared', SharedPublic = 'shared_public', -}; +} diff --git a/js/sdk/src/interface/thumbnail.ts b/js/sdk/src/interface/thumbnail.ts index e9e32c52..6476a5dc 100644 --- a/js/sdk/src/interface/thumbnail.ts +++ b/js/sdk/src/interface/thumbnail.ts @@ -1,8 +1,7 @@ - export type Thumbnail = { - type: ThumbnailType, - thumbnail: Uint8Array, -} + type: ThumbnailType; + thumbnail: Uint8Array; +}; export enum ThumbnailType { Type1 = 1, @@ -10,5 +9,5 @@ export enum ThumbnailType { } export type ThumbnailResult = - {nodeUid: string, ok: true, thumbnail: Uint8Array } | - {nodeUid: string, ok: false, error: string}; + | { nodeUid: string; ok: true; thumbnail: Uint8Array } + | { nodeUid: string; ok: false; error: string }; diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index 8cca7b4f..28ebfd7f 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -1,7 +1,7 @@ -import { Thumbnail } from "./thumbnail"; +import { Thumbnail } from './thumbnail'; export type UploadMetadata = { - mediaType: string, + mediaType: string; /** * Expected size of the file. * @@ -9,13 +9,13 @@ export type UploadMetadata = { * If the expected size does not match the actual size, the upload will * fail. */ - expectedSize: number, + expectedSize: number; /** * Modification time of the file. * * The modification time will be encrypted and stored with the file. */ - modificationTime?: Date, + modificationTime?: Date; /** * Additional metadata to be stored with the file. * @@ -23,13 +23,13 @@ export type UploadMetadata = { * * The metadata will be encrypted and stored with the file. */ - additionalMetadata?: object, + additionalMetadata?: object; /** * If there is an existing draft by another client, the upload will be * rejected. If user decides to override the existing draft and continue * with the upload, set this to true. */ - overrideExistingDraftByOtherClient?: boolean, + overrideExistingDraftByOtherClient?: boolean; }; export interface FileRevisionUploader { @@ -41,7 +41,11 @@ export interface FileRevisionUploader { * * The function will reject if the node with the given name already exists. */ - writeStream(stream: ReadableStream, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise, + writeStream( + stream: ReadableStream, + thumnbails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise; /** * Uploads a file from a file object. It is convenient to use this method @@ -53,7 +57,11 @@ export interface FileRevisionUploader { * * The function will reject if the node with the given name already exists. */ - writeFile(fileObject: File, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise, + writeFile( + fileObject: File, + thumnbails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise; } export interface FileUploader extends FileRevisionUploader { @@ -65,11 +73,11 @@ export interface FileUploader extends FileRevisionUploader { * * Example new name: `file (2).txt`. */ - getAvailableName(): Promise, + getAvailableName(): Promise; } export interface UploadController { - pause(): void, - resume(): void, - completion(): Promise, + pause(): void; + resume(): void; + completion(): Promise; } diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 4f83a87a..695c4fbe 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -1,6 +1,6 @@ -import { ProtonDriveHTTPClient, SDKEvent } from "../../interface"; -import { getMockTelemetry } from "../../tests/telemetry"; -import { SDKEvents } from "../sdkEvents"; +import { ProtonDriveHTTPClient, SDKEvent } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { SDKEvents } from '../sdkEvents'; import { DriveAPIService } from './apiService'; import { HTTPErrorCode, ErrorCode } from './errorCodes'; @@ -10,7 +10,7 @@ function generateOkResponse() { return new Response(JSON.stringify({ Code: ErrorCode.OK }), { status: HTTPErrorCode.OK }); } -describe("DriveAPIService", () => { +describe('DriveAPIService', () => { let sdkEvents: SDKEvents; let httpClient: ProtonDriveHTTPClient; let api: DriveAPIService; @@ -24,7 +24,7 @@ describe("DriveAPIService", () => { transfersResumed: jest.fn(), requestsThrottled: jest.fn(), requestsUnthrottled: jest.fn(), - } + }; httpClient = { fetchJson: jest.fn(() => Promise.resolve(generateOkResponse())), fetchBlob: jest.fn(() => Promise.resolve(new Response(new Uint8Array([1, 2, 3])))), @@ -36,23 +36,25 @@ describe("DriveAPIService", () => { expect(sdkEvents.transfersPaused).toHaveBeenCalledTimes(events.includes(SDKEvent.TransfersPaused) ? 1 : 0); expect(sdkEvents.transfersResumed).toHaveBeenCalledTimes(events.includes(SDKEvent.TransfersResumed) ? 1 : 0); expect(sdkEvents.requestsThrottled).toHaveBeenCalledTimes(events.includes(SDKEvent.RequestsThrottled) ? 1 : 0); - expect(sdkEvents.requestsUnthrottled).toHaveBeenCalledTimes(events.includes(SDKEvent.RequestsUnthrottled) ? 1 : 0); + expect(sdkEvents.requestsUnthrottled).toHaveBeenCalledTimes( + events.includes(SDKEvent.RequestsUnthrottled) ? 1 : 0, + ); } - describe("should make", () => { - it("GET request", async () => { + describe('should make', () => { + it('GET request', async () => { const result = await api.get('test'); expect(result).toEqual({ Code: ErrorCode.OK }); await expectFetchJsonToBeCalledWith('GET'); }); - it("POST request", async () => { + it('POST request', async () => { const result = await api.post('test', { data: 'test' }); expect(result).toEqual({ Code: ErrorCode.OK }); await expectFetchJsonToBeCalledWith('POST', { data: 'test' }); }); - it("PUT request", async () => { + it('PUT request', async () => { const result = await api.put('test', { data: 'test' }); expect(result).toEqual({ Code: ErrorCode.OK }); await expectFetchJsonToBeCalledWith('PUT', { data: 'test' }); @@ -63,24 +65,28 @@ describe("DriveAPIService", () => { const request = httpClient.fetchJson.mock.calls[0][0]; expect(request.method).toEqual(method); expect(request.timeoutMs).toEqual(30000); - expect(Array.from(request.headers.entries())).toEqual(Array.from(new Headers({ - "Accept": "application/vnd.protonmail.v1+json", - "Content-Type": "application/json", - "Language": 'en', - "x-pm-drive-sdk-version": `js@${process.env.npm_package_version}`, - }).entries())); + expect(Array.from(request.headers.entries())).toEqual( + Array.from( + new Headers({ + Accept: 'application/vnd.protonmail.v1+json', + 'Content-Type': 'application/json', + Language: 'en', + 'x-pm-drive-sdk-version': `js@${process.env.npm_package_version}`, + }).entries(), + ), + ); expect(await request.json).toEqual(data); expectSDKEvents(); } - it("storage GET request", async () => { + it('storage GET request', async () => { const stream = await api.getBlockStream('test', 'token'); const result = await Array.fromAsync(stream); expect(result).toEqual([new Uint8Array([1, 2, 3])]); await expectFetchBlobToBeCalledWith('GET'); }); - it("storage POST request", async () => { + it('storage POST request', async () => { const data = new Blob(); await api.postBlockStream('test', 'token', data); await expectFetchBlobToBeCalledWith('POST', data); @@ -91,35 +97,44 @@ describe("DriveAPIService", () => { const request = httpClient.fetchBlob.mock.calls[0][0]; expect(request.method).toEqual(method); expect(request.timeoutMs).toEqual(90000); - expect(Array.from(request.headers.entries())).toEqual(Array.from(new Headers({ - "pm-storage-token": 'token', - "Language": 'en', - "x-pm-drive-sdk-version": `js@${process.env.npm_package_version}`, - }).entries())); + expect(Array.from(request.headers.entries())).toEqual( + Array.from( + new Headers({ + 'pm-storage-token': 'token', + Language: 'en', + 'x-pm-drive-sdk-version': `js@${process.env.npm_package_version}`, + }).entries(), + ), + ); expect(request.body).toEqual(data); expectSDKEvents(); } }); - describe("should throw", () => { - it("APIHTTPError on 4xx response without JSON body", async () => { - httpClient.fetchJson = jest.fn(() => Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not found' }))); + describe('should throw', () => { + it('APIHTTPError on 4xx response without JSON body', async () => { + httpClient.fetchJson = jest.fn(() => + Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not found' })), + ); await expect(api.get('test')).rejects.toThrow(new Error('Not found')); expectSDKEvents(); }); - it("APIError on 4xx response with JSON body", async () => { - httpClient.fetchJson = jest.fn(() => Promise.resolve(new Response(JSON.stringify({ Code: 42, Error: 'General error' }), { status: 422 }))); + it('APIError on 4xx response with JSON body', async () => { + httpClient.fetchJson = jest.fn(() => + Promise.resolve(new Response(JSON.stringify({ Code: 42, Error: 'General error' }), { status: 422 })), + ); await expect(api.get('test')).rejects.toThrow('General error'); expectSDKEvents(); }); }); - describe("should retry", () => { - it("on offline error", async () => { + describe('should retry', () => { + it('on offline error', async () => { const error = new Error('Network offline'); error.name = 'OfflineError'; - httpClient.fetchJson = jest.fn() + httpClient.fetchJson = jest + .fn() .mockRejectedValueOnce(error) .mockRejectedValueOnce(error) .mockResolvedValueOnce(generateOkResponse()); @@ -131,10 +146,11 @@ describe("DriveAPIService", () => { expectSDKEvents(); }); - it("on timeout error", async () => { + it('on timeout error', async () => { const error = new Error('Timeouted'); error.name = 'TimeoutError'; - httpClient.fetchJson = jest.fn() + httpClient.fetchJson = jest + .fn() .mockRejectedValueOnce(error) .mockRejectedValueOnce(error) .mockResolvedValueOnce(generateOkResponse()); @@ -146,8 +162,9 @@ describe("DriveAPIService", () => { expectSDKEvents(); }); - it("on general error", async () => { - httpClient.fetchJson = jest.fn() + it('on general error', async () => { + httpClient.fetchJson = jest + .fn() .mockRejectedValueOnce(new Error('Error')) .mockResolvedValueOnce(generateOkResponse()); @@ -158,23 +175,29 @@ describe("DriveAPIService", () => { expectSDKEvents(); }); - it("only once on general error", async () => { - httpClient.fetchJson = jest.fn() + it('only once on general error', async () => { + httpClient.fetchJson = jest + .fn() .mockRejectedValueOnce(new Error('First error')) .mockRejectedValueOnce(new Error('Second error')) .mockResolvedValueOnce(generateOkResponse()); const result = api.get('test'); - await expect(result).rejects.toThrow("Second error"); + await expect(result).rejects.toThrow('Second error'); expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); }); - it("on 429 response", async () => { - httpClient.fetchJson = jest.fn() - .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })) - .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })) + it('on 429 response', async () => { + httpClient.fetchJson = jest + .fn() + .mockResolvedValueOnce( + new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' }), + ) + .mockResolvedValueOnce( + new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' }), + ) .mockResolvedValueOnce(generateOkResponse()); const result = api.get('test'); @@ -185,9 +208,12 @@ describe("DriveAPIService", () => { expectSDKEvents(); }); - it("on 5xx response", async () => { - httpClient.fetchJson = jest.fn() - .mockResolvedValueOnce(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })) + it('on 5xx response', async () => { + httpClient.fetchJson = jest + .fn() + .mockResolvedValueOnce( + new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' }), + ) .mockResolvedValueOnce(generateOkResponse()); const result = api.get('test'); @@ -197,28 +223,34 @@ describe("DriveAPIService", () => { expectSDKEvents(); }); - it("only once on 5xx response", async () => { - httpClient.fetchJson = jest.fn() - .mockResolvedValue(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })); + it('only once on 5xx response', async () => { + httpClient.fetchJson = jest + .fn() + .mockResolvedValue( + new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' }), + ); const result = api.get('test'); - await expect(result).rejects.toThrow("Some error"); + await expect(result).rejects.toThrow('Some error'); expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); }); }); - describe("should handle subsequent errors", () => { - it("limit 429 errors", async () => { - httpClient.fetchJson = jest.fn() - .mockResolvedValue(new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' })); + describe('should handle subsequent errors', () => { + it('limit 429 errors', async () => { + httpClient.fetchJson = jest + .fn() + .mockResolvedValue( + new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' }), + ); for (let i = 0; i < 20; i++) { await api.get('test').catch(() => {}); } - await expect(api.get('test')).rejects.toThrow("Too many server requests, please try again later"); + await expect(api.get('test')).rejects.toThrow('Too many server requests, please try again later'); expect(httpClient.fetchJson).toHaveBeenCalledTimes(50); expectSDKEvents(SDKEvent.RequestsThrottled); @@ -229,15 +261,14 @@ describe("DriveAPIService", () => { expect(sdkEvents.requestsThrottled).toHaveBeenCalledTimes(1); }); - it("do not limit 429s when some pass", async () => { + it('do not limit 429s when some pass', async () => { let attempt = 0; - httpClient.fetchJson = jest.fn() - .mockImplementation(() => { - if (attempt++ % 5 === 0) { - return generateOkResponse(); - } - return new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' }); - }); + httpClient.fetchJson = jest.fn().mockImplementation(() => { + if (attempt++ % 5 === 0) { + return generateOkResponse(); + } + return new Response('', { status: HTTPErrorCode.TOO_MANY_REQUESTS, statusText: 'Some error' }); + }); for (let i = 0; i < 20; i++) { await api.get('test').catch(() => {}); @@ -249,34 +280,36 @@ describe("DriveAPIService", () => { expectSDKEvents(); }); - it("limit server errors", async () => { - httpClient.fetchJson = jest.fn() - .mockResolvedValue(new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' })); + it('limit server errors', async () => { + httpClient.fetchJson = jest + .fn() + .mockResolvedValue( + new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' }), + ); for (let i = 0; i < 20; i++) { await api.get('test').catch(() => {}); } - await expect(api.get('test')).rejects.toThrow("Too many server errors, please try again later"); + await expect(api.get('test')).rejects.toThrow('Too many server errors, please try again later'); expect(httpClient.fetchJson).toHaveBeenCalledTimes(10); expectSDKEvents(); }); - it("do not limit server errors when some pass", async () => { + it('do not limit server errors when some pass', async () => { let attempt = 0; - httpClient.fetchJson = jest.fn() - .mockImplementation(() => { - if (attempt++ % 5 === 0) { - return generateOkResponse(); - } - return new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' }); - }); - + httpClient.fetchJson = jest.fn().mockImplementation(() => { + if (attempt++ % 5 === 0) { + return generateOkResponse(); + } + return new Response('', { status: HTTPErrorCode.INTERNAL_SERVER_ERROR, statusText: 'Some error' }); + }); + for (let i = 0; i < 20; i++) { await api.get('test').catch(() => {}); } - await expect(api.get('test')).rejects.toThrow("Some error"); + await expect(api.get('test')).rejects.toThrow('Some error'); // 15 erroring calls * 2 attempts + 5 successful calls expect(httpClient.fetchJson).toHaveBeenCalledTimes(35); expectSDKEvents(); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index ad5cbb4e..36e31fbf 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; -import { VERSION } from "../../version"; -import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from "../../interface"; +import { VERSION } from '../../version'; +import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from '../../interface'; import { AbortError, ServerError, RateLimitedError, ProtonDriveError } from '../../errors'; import { waitSeconds } from '../wait'; import { SDKEvents } from '../sdkEvents'; @@ -107,19 +107,27 @@ export class DriveAPIService { async get(url: string, signal?: AbortSignal): Promise { return this.makeRequest(url, 'GET', undefined, signal); - }; + } - async post(url: string, data?: RequestPayload, signal?: AbortSignal): Promise { + async post( + url: string, + data?: RequestPayload, + signal?: AbortSignal, + ): Promise { return this.makeRequest(url, 'POST', data, signal); - }; + } - async put(url: string, data: RequestPayload, signal?: AbortSignal): Promise { + async put( + url: string, + data: RequestPayload, + signal?: AbortSignal, + ): Promise { return this.makeRequest(url, 'PUT', data, signal); - }; + } async delete(url: string, signal?: AbortSignal): Promise { return this.makeRequest(url, 'DELETE', undefined, signal); - }; + } private async makeRequest( url: string, @@ -131,15 +139,15 @@ export class DriveAPIService { url: `${this.baseUrl}/${url}`, method, headers: new Headers({ - "Accept": "application/vnd.protonmail.v1+json", - "Content-Type": "application/json", - "Language": this.language, - "x-pm-drive-sdk-version": `js@${VERSION}`, + Accept: 'application/vnd.protonmail.v1+json', + 'Content-Type': 'application/json', + Language: this.language, + 'x-pm-drive-sdk-version': `js@${VERSION}`, }), json: data || undefined, timeoutMs: DEFAULT_TIMEOUT_MS, signal, - } + }; const response = await this.fetch(request, () => this.httpClient.fetchJson(request)); @@ -169,7 +177,13 @@ export class DriveAPIService { return response.body; } - async postBlockStream(baseUrl: string, token: string, data: XMLHttpRequestBodyInit, onProgress?: (uploadedBytes: number) => void, signal?: AbortSignal): Promise { + async postBlockStream( + baseUrl: string, + token: string, + data: XMLHttpRequestBodyInit, + onProgress?: (uploadedBytes: number) => void, + signal?: AbortSignal, + ): Promise { await this.makeStorageRequest('POST', baseUrl, token, data, onProgress, signal); } @@ -185,9 +199,9 @@ export class DriveAPIService { url, method, headers: new Headers({ - "pm-storage-token": token, - "Language": this.language, - "x-pm-drive-sdk-version": `js@${VERSION}`, + 'pm-storage-token': token, + Language: this.language, + 'x-pm-drive-sdk-version': `js@${VERSION}`, }), body, onProgress, @@ -217,9 +231,9 @@ export class DriveAPIService { // u=5 for background (e.g., upload, download) // u=7 for optional (e.g., metrics, telemetry) private async fetch( - request: { method: string, url: string, signal?: AbortSignal }, + request: { method: string; url: string; signal?: AbortSignal }, callback: () => Promise, - attempt = 0 + attempt = 0, ): Promise { if (request.signal?.aborted) { throw new AbortError(c('Error').t`Request aborted`); @@ -244,19 +258,19 @@ export class DriveAPIService { if (error.name === 'OfflineError') { this.logger.info(`${request.method} ${request.url}: Offline error, retrying`); await waitSeconds(OFFLINE_RETRY_DELAY_SECONDS); - return this.fetch(request, callback, attempt+1); + return this.fetch(request, callback, attempt + 1); } if (error.name === 'TimeoutError') { this.logger.warn(`${request.method} ${request.url}: Timeout error, retrying`); await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); - return this.fetch(request, callback, attempt+1); + return this.fetch(request, callback, attempt + 1); } } if (attempt === 0) { this.logger.error(`${request.method} ${request.url}: failed, retrying once`, error); await waitSeconds(GENERAL_RETRY_DELAY_SECONDS); - return this.fetch(request, callback, attempt+1); + return this.fetch(request, callback, attempt + 1); } this.logger.error(`${request.method} ${request.url}: failed`, error); throw error; @@ -272,7 +286,7 @@ export class DriveAPIService { this.tooManyRequestsErrorHappened(); const timeout = parseInt(response.headers.get('retry-after') || '0', DEFAULT_429_RETRY_DELAY_SECONDS); await waitSeconds(timeout); - return this.fetch(request, callback, attempt+1); + return this.fetch(request, callback, attempt + 1); } else { this.clearSubsequentTooManyRequestsError(); } @@ -286,7 +300,7 @@ export class DriveAPIService { this.logger.warn(`${request.method} ${request.url}: ${response.status} - retry failed`); } else { await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); - return this.fetch(request, callback, attempt+1); + return this.fetch(request, callback, attempt + 1); } } else { if (attempt > 0) { @@ -308,7 +322,7 @@ export class DriveAPIService { return ( this.subsequentTooManyRequestsCounter >= TOO_MANY_SUBSEQUENT_429_ERRORS && secondsSinceLast429Error < TOO_MANY_SUBSEQUENT_429_ERRORS_TIMEOUT_IN_SECONDS - ) + ); } private tooManyRequestsErrorHappened() { @@ -338,7 +352,7 @@ export class DriveAPIService { return ( this.subsequentServerErrorsCounter >= TOO_MANY_SUBSEQUENT_SERVER_ERRORS && secondsSinceLastServerError < TOO_MANY_SUBSEQUENT_SERVER_ERRORS_TIMEOUT_IN_SECONDS - ) + ); } private serverErrorHappened() { diff --git a/js/sdk/src/internal/apiService/coreTypes.ts b/js/sdk/src/internal/apiService/coreTypes.ts index bfa3acdf..b112711f 100644 --- a/js/sdk/src/internal/apiService/coreTypes.ts +++ b/js/sdk/src/internal/apiService/coreTypes.ts @@ -4,14 +4,14 @@ */ export interface paths { - "/core/{_version}/addresses/allowAddressDeletion": { + '/core/{_version}/addresses/allowAddressDeletion': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-addresses-allowAddressDeletion"]; + get: operations['get_core-{_version}-addresses-allowAddressDeletion']; put?: never; post?: never; delete?: never; @@ -20,7 +20,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/address/active": { + '/core/{_version}/keys/address/active': { parameters: { query?: never; header?: never; @@ -29,7 +29,7 @@ export interface paths { }; get?: never; /** Update list of active keys per address */ - put: operations["put_core-{_version}-keys-address-active"]; + put: operations['put_core-{_version}-keys-address-active']; post?: never; delete?: never; options?: never; @@ -37,7 +37,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys": { + '/core/{_version}/keys': { parameters: { query?: never; header?: never; @@ -52,19 +52,19 @@ export interface paths { * * Deprecated! Please refer to https://confluence.protontech.ch/pages/viewpage.action?pageId=157816403 to upgrade */ - get: operations["get_core-{_version}-keys"]; + get: operations['get_core-{_version}-keys']; put?: never; /** POST /keys route (Deprecated, AddressKey migration step 1.2) * Only used for address-associated keys, otherwise this would be a backdoor way to change the mailbox password * Does not enforce key list validation. */ - post: operations["post_core-{_version}-keys"]; + post: operations['post_core-{_version}-keys']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/address": { + '/core/{_version}/keys/address': { parameters: { query?: never; header?: never; @@ -78,14 +78,14 @@ export interface paths { * @description Locked route, only used for address-associated keys, * otherwise this would be a backdoor way to change the mailbox password. */ - post: operations["post_core-{_version}-keys-address"]; + post: operations['post_core-{_version}-keys-address']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/group": { + '/core/{_version}/keys/group': { parameters: { query?: never; header?: never; @@ -95,14 +95,14 @@ export interface paths { get?: never; put?: never; /** Create a group key */ - post: operations["post_core-{_version}-keys-group"]; + post: operations['post_core-{_version}-keys-group']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/setup": { + '/core/{_version}/keys/setup': { parameters: { query?: never; header?: never; @@ -115,14 +115,14 @@ export interface paths { * Setup keys for new account, private user. * @description Initial key setup for new private users. */ - post: operations["post_core-{_version}-keys-setup"]; + post: operations['post_core-{_version}-keys-setup']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/{enc_id}/delete": { + '/core/{_version}/keys/{enc_id}/delete': { parameters: { query?: never; header?: never; @@ -135,7 +135,7 @@ export interface paths { * @deprecated * @description Locked route */ - put: operations["put_core-{_version}-keys-{enc_id}-delete"]; + put: operations['put_core-{_version}-keys-{enc_id}-delete']; post?: never; delete?: never; options?: never; @@ -143,7 +143,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/address/{enc_id}/delete": { + '/core/{_version}/keys/address/{enc_id}/delete': { parameters: { query?: never; header?: never; @@ -156,14 +156,14 @@ export interface paths { * Delete address key. * @description Locked route */ - post: operations["post_core-{_version}-keys-address-{enc_id}-delete"]; + post: operations['post_core-{_version}-keys-address-{enc_id}-delete']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/private": { + '/core/{_version}/keys/private': { parameters: { query?: never; header?: never; @@ -178,7 +178,7 @@ export interface paths { * This route can not be used to re-activate keys that we don't have access to, * in that case the route "Activate Key" must be used first. */ - put: operations["put_core-{_version}-keys-private"]; + put: operations['put_core-{_version}-keys-private']; post?: never; delete?: never; options?: never; @@ -186,7 +186,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/images/logo": { + '/core/{_version}/images/logo': { parameters: { query?: never; header?: never; @@ -194,7 +194,7 @@ export interface paths { cookie?: never; }; /** Get logo corresponding to an address or a domain. */ - get: operations["get_core-{_version}-images-logo"]; + get: operations['get_core-{_version}-images-logo']; put?: never; post?: never; delete?: never; @@ -203,7 +203,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/addresses": { + '/core/{_version}/members/{enc_id}/addresses': { parameters: { query?: never; header?: never; @@ -211,7 +211,7 @@ export interface paths { cookie?: never; }; /** Get addresses of a member. */ - get: operations["get_core-{_version}-members-{enc_id}-addresses"]; + get: operations['get_core-{_version}-members-{enc_id}-addresses']; put?: never; /** * Create new address. @@ -229,21 +229,21 @@ export interface paths { * } * ``` */ - post: operations["post_core-{_version}-members-{enc_id}-addresses"]; + post: operations['post_core-{_version}-members-{enc_id}-addresses']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/addresses": { + '/core/{_version}/addresses': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-addresses"]; + get: operations['get_core-{_version}-addresses']; put?: never; /** * Create new address. @@ -261,14 +261,14 @@ export interface paths { * } * ``` */ - post: operations["post_core-{_version}-addresses"]; + post: operations['post_core-{_version}-addresses']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/addresses/available": { + '/core/{_version}/members/addresses/available': { parameters: { query?: never; header?: never; @@ -278,14 +278,14 @@ export interface paths { get?: never; put?: never; /** Validates an address before creation (format and availability). */ - post: operations["post_core-{_version}-members-addresses-available"]; + post: operations['post_core-{_version}-members-addresses-available']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/addresses/order": { + '/core/{_version}/addresses/order': { parameters: { query?: never; header?: never; @@ -294,7 +294,7 @@ export interface paths { }; get?: never; /** Reorder user's addresses. */ - put: operations["put_core-{_version}-addresses-order"]; + put: operations['put_core-{_version}-addresses-order']; post?: never; delete?: never; options?: never; @@ -302,7 +302,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/setup": { + '/core/{_version}/addresses/setup': { parameters: { query?: never; header?: never; @@ -312,14 +312,14 @@ export interface paths { get?: never; put?: never; /** Setup new non-subuser address. */ - post: operations["post_core-{_version}-addresses-setup"]; + post: operations['post_core-{_version}-addresses-setup']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/addresses/canonical": { + '/core/{_version}/addresses/canonical': { parameters: { query?: never; header?: never; @@ -327,7 +327,7 @@ export interface paths { cookie?: never; }; /** Get the canonical form of email addresses. */ - get: operations["get_core-{_version}-addresses-canonical"]; + get: operations['get_core-{_version}-addresses-canonical']; put?: never; post?: never; delete?: never; @@ -336,7 +336,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/{enc_id}": { + '/core/{_version}/addresses/{enc_id}': { parameters: { query?: never; header?: never; @@ -344,12 +344,12 @@ export interface paths { cookie?: never; }; /** Get a single address. */ - get: operations["get_core-{_version}-addresses-{enc_id}"]; + get: operations['get_core-{_version}-addresses-{enc_id}']; /** * Update address. * @description Update display name and/or signature. */ - put: operations["put_core-{_version}-addresses-{enc_id}"]; + put: operations['put_core-{_version}-addresses-{enc_id}']; post?: never; /** * Delete a Disabled Address. @@ -358,13 +358,13 @@ export interface paths { * * Warning - Locked route */ - delete: operations["delete_core-{_version}-addresses-{enc_id}"]; + delete: operations['delete_core-{_version}-addresses-{enc_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/domains/{enc_id}/addresses": { + '/core/{_version}/domains/{enc_id}/addresses': { parameters: { query?: never; header?: never; @@ -372,7 +372,7 @@ export interface paths { cookie?: never; }; /** Get a specific domain's addresses. */ - get: operations["get_core-{_version}-domains-{enc_id}-addresses"]; + get: operations['get_core-{_version}-domains-{enc_id}-addresses']; put?: never; post?: never; delete?: never; @@ -381,7 +381,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/domains/{enc_id}/claimedAddresses": { + '/core/{_version}/domains/{enc_id}/claimedAddresses': { parameters: { query?: never; header?: never; @@ -390,7 +390,7 @@ export interface paths { }; /** Get external addresses belonging to users outside the organization * with the same domain name as the specified domain. */ - get: operations["get_core-{_version}-domains-{enc_id}-claimedAddresses"]; + get: operations['get_core-{_version}-domains-{enc_id}-claimedAddresses']; put?: never; post?: never; delete?: never; @@ -399,7 +399,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/{enc_id}/enable": { + '/core/{_version}/addresses/{enc_id}/enable': { parameters: { query?: never; header?: never; @@ -413,7 +413,7 @@ export interface paths { * * Warning - Locked route */ - put: operations["put_core-{_version}-addresses-{enc_id}-enable"]; + put: operations['put_core-{_version}-addresses-{enc_id}-enable']; post?: never; delete?: never; options?: never; @@ -421,7 +421,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/{enc_id}/disable": { + '/core/{_version}/addresses/{enc_id}/disable': { parameters: { query?: never; header?: never; @@ -435,7 +435,7 @@ export interface paths { * * Warning - Locked route */ - put: operations["put_core-{_version}-addresses-{enc_id}-disable"]; + put: operations['put_core-{_version}-addresses-{enc_id}-disable']; post?: never; delete?: never; options?: never; @@ -443,7 +443,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/{enc_id}/delete": { + '/core/{_version}/addresses/{enc_id}/delete': { parameters: { query?: never; header?: never; @@ -457,7 +457,7 @@ export interface paths { * * Warning - Locked route */ - put: operations["put_core-{_version}-addresses-{enc_id}-delete"]; + put: operations['put_core-{_version}-addresses-{enc_id}-delete']; post?: never; delete?: never; options?: never; @@ -465,7 +465,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/{enc_id}/type": { + '/core/{_version}/addresses/{enc_id}/type': { parameters: { query?: never; header?: never; @@ -477,7 +477,7 @@ export interface paths { * Change address type. * @description As of now it is possible only to convert an external address into a custom address when a domain has been activated. */ - put: operations["put_core-{_version}-addresses-{enc_id}-type"]; + put: operations['put_core-{_version}-addresses-{enc_id}-type']; post?: never; delete?: never; options?: never; @@ -485,7 +485,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/{enc_id}/rename/internal": { + '/core/{_version}/addresses/{enc_id}/rename/internal': { parameters: { query?: never; header?: never; @@ -494,7 +494,7 @@ export interface paths { }; get?: never; /** Rename address keeping the keys, keeping the same clean email */ - put: operations["put_core-{_version}-addresses-{enc_id}-rename-internal"]; + put: operations['put_core-{_version}-addresses-{enc_id}-rename-internal']; post?: never; delete?: never; options?: never; @@ -502,7 +502,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/{enc_id}/rename/external": { + '/core/{_version}/addresses/{enc_id}/rename/external': { parameters: { query?: never; header?: never; @@ -511,7 +511,7 @@ export interface paths { }; get?: never; /** Rename unverified external addresses freely (any change is allowed) */ - put: operations["put_core-{_version}-addresses-{enc_id}-rename-external"]; + put: operations['put_core-{_version}-addresses-{enc_id}-rename-external']; post?: never; delete?: never; options?: never; @@ -519,7 +519,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/addresses/{enc_addressId}/encryption": { + '/core/{_version}/addresses/{enc_addressId}/encryption': { parameters: { query?: never; header?: never; @@ -531,7 +531,7 @@ export interface paths { * Set encryption signature flags. * @description Allows setting "E2EE disabled" or "Do not expect signed" flags, address wide. */ - put: operations["put_core-{_version}-addresses-{enc_addressId}-encryption"]; + put: operations['put_core-{_version}-addresses-{enc_addressId}-encryption']; post?: never; delete?: never; options?: never; @@ -539,7 +539,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/addresses/permissions/organization/switch": { + '/core/{_version}/members/addresses/permissions/organization/switch': { parameters: { query?: never; header?: never; @@ -553,7 +553,7 @@ export interface paths { * Having both PERMISSIONS_SEND_ALL and PERMISSIONS_SEND_ORG in the permissions array is forbidden. * Having both PERMISSIONS_RECEIVE_ALL and PERMISSIONS_RECEIVE_ORG in the permissions array is forbidden. */ - put: operations["put_core-{_version}-members-addresses-permissions-organization-switch"]; + put: operations['put_core-{_version}-members-addresses-permissions-organization-switch']; post?: never; delete?: never; options?: never; @@ -561,7 +561,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{memberId}/saml": { + '/core/{_version}/members/{memberId}/saml': { parameters: { query?: never; header?: never; @@ -570,14 +570,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-members-{memberId}-saml"]; - delete: operations["delete_core-{_version}-members-{memberId}-saml"]; + post: operations['post_core-{_version}-members-{memberId}-saml']; + delete: operations['delete_core-{_version}-members-{memberId}-saml']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{memberId}/devices/{deviceId}": { + '/core/{_version}/members/{memberId}/devices/{deviceId}': { parameters: { query?: never; header?: never; @@ -587,13 +587,13 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations["delete_core-{_version}-members-{memberId}-devices-{deviceId}"]; + delete: operations['delete_core-{_version}-members-{memberId}-devices-{deviceId}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{memberId}/devices": { + '/core/{_version}/members/{memberId}/devices': { parameters: { query?: never; header?: never; @@ -603,20 +603,20 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations["delete_core-{_version}-members-{memberId}-devices"]; + delete: operations['delete_core-{_version}-members-{memberId}-devices']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{id}/devices": { + '/core/{_version}/members/{id}/devices': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-members-{id}-devices"]; + get: operations['get_core-{_version}-members-{id}-devices']; put?: never; post?: never; delete?: never; @@ -625,14 +625,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/devices/pending": { + '/core/{_version}/members/devices/pending': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-members-devices-pending"]; + get: operations['get_core-{_version}-members-devices-pending']; put?: never; post?: never; delete?: never; @@ -641,7 +641,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{memberId}/devices/{deviceId}/reject": { + '/core/{_version}/members/{memberId}/devices/{deviceId}/reject': { parameters: { query?: never; header?: never; @@ -649,7 +649,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-members-{memberId}-devices-{deviceId}-reject"]; + put: operations['put_core-{_version}-members-{memberId}-devices-{deviceId}-reject']; post?: never; delete?: never; options?: never; @@ -657,7 +657,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{memberId}/devices/reset": { + '/core/{_version}/members/{memberId}/devices/reset': { parameters: { query?: never; header?: never; @@ -666,14 +666,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-members-{memberId}-devices-reset"]; + post: operations['post_core-{_version}-members-{memberId}-devices-reset']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/keys": { + '/core/{_version}/members/{enc_id}/keys': { parameters: { query?: never; header?: never; @@ -682,30 +682,30 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-members-{enc_id}-keys"]; + post: operations['post_core-{_version}-members-{enc_id}-keys']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/organizations/scim": { + '/core/{_version}/organizations/scim': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-organizations-scim"]; - put: operations["put_core-{_version}-organizations-scim"]; - post: operations["post_core-{_version}-organizations-scim"]; + get: operations['get_core-{_version}-organizations-scim']; + put: operations['put_core-{_version}-organizations-scim']; + post: operations['post_core-{_version}-organizations-scim']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/user": { + '/core/{_version}/keys/user': { parameters: { query?: never; header?: never; @@ -714,14 +714,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-keys-user"]; + post: operations['post_core-{_version}-keys-user']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/domains": { + '/core/{_version}/domains': { parameters: { query?: never; header?: never; @@ -732,7 +732,7 @@ export interface paths { * Get Domains. * @description Get all domains for this user's organization and check their DNS's */ - get: operations["get_core-{_version}-domains"]; + get: operations['get_core-{_version}-domains']; put?: never; /** * Create Domain. @@ -748,14 +748,14 @@ export interface paths { * } * ``` */ - post: operations["post_core-{_version}-domains"]; + post: operations['post_core-{_version}-domains']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/domains/available": { + '/core/{_version}/domains/available': { parameters: { query?: never; header?: never; @@ -763,7 +763,7 @@ export interface paths { cookie?: never; }; /** Get available domains. */ - get: operations["get_core-{_version}-domains-available"]; + get: operations['get_core-{_version}-domains-available']; put?: never; post?: never; delete?: never; @@ -772,7 +772,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/domains/premium": { + '/core/{_version}/domains/premium': { parameters: { query?: never; header?: never; @@ -780,7 +780,7 @@ export interface paths { cookie?: never; }; /** Get premium domains. */ - get: operations["get_core-{_version}-domains-premium"]; + get: operations['get_core-{_version}-domains-premium']; put?: never; post?: never; delete?: never; @@ -789,7 +789,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/domains/optin": { + '/core/{_version}/domains/optin': { parameters: { query?: never; header?: never; @@ -797,7 +797,7 @@ export interface paths { cookie?: never; }; /** Get opt-in domain if user is eligible. */ - get: operations["get_core-{_version}-domains-optin"]; + get: operations['get_core-{_version}-domains-optin']; put?: never; post?: never; delete?: never; @@ -806,7 +806,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/domains/{enc_id}": { + '/core/{_version}/domains/{enc_id}': { parameters: { query?: never; header?: never; @@ -817,20 +817,20 @@ export interface paths { * Get Domain. * @description Get a specific domains and its check DNS */ - get: operations["get_core-{_version}-domains-{enc_id}"]; + get: operations['get_core-{_version}-domains-{enc_id}']; put?: never; post?: never; /** * Delete Domain. * @description Delete a Domain, locked route */ - delete: operations["delete_core-{_version}-domains-{enc_id}"]; + delete: operations['delete_core-{_version}-domains-{enc_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/domains/{enc_id}/catchall": { + '/core/{_version}/domains/{enc_id}/catchall': { parameters: { query?: never; header?: never; @@ -839,7 +839,7 @@ export interface paths { }; get?: never; /** Set catch-all address, locked route. */ - put: operations["put_core-{_version}-domains-{enc_id}-catchall"]; + put: operations['put_core-{_version}-domains-{enc_id}-catchall']; post?: never; delete?: never; options?: never; @@ -847,7 +847,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/organizations": { + '/core/{_version}/organizations': { parameters: { query?: never; header?: never; @@ -855,7 +855,7 @@ export interface paths { cookie?: never; }; /** Get information of current organization */ - get: operations["get_core-{_version}-organizations"]; + get: operations['get_core-{_version}-organizations']; put?: never; post?: never; delete?: never; @@ -864,7 +864,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/groups/external/{jwt}": { + '/core/{_version}/groups/external/{jwt}': { parameters: { query?: never; header?: never; @@ -872,15 +872,15 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-groups-external-{jwt}"]; + put: operations['put_core-{_version}-groups-external-{jwt}']; post?: never; - delete: operations["delete_core-{_version}-groups-external-{jwt}"]; + delete: operations['delete_core-{_version}-groups-external-{jwt}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/groups/members": { + '/core/{_version}/groups/members': { parameters: { query?: never; header?: never; @@ -889,30 +889,30 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-groups-members"]; + post: operations['post_core-{_version}-groups-members']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/groups": { + '/core/{_version}/groups': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-groups"]; + get: operations['get_core-{_version}-groups']; put?: never; - post: operations["post_core-{_version}-groups"]; + post: operations['post_core-{_version}-groups']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/groups/unsubscribe/{jwt}": { + '/core/{_version}/groups/unsubscribe/{jwt}': { parameters: { query?: never; header?: never; @@ -921,14 +921,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-groups-unsubscribe-{jwt}"]; + post: operations['post_core-{_version}-groups-unsubscribe-{jwt}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/groups/{enc_id}": { + '/core/{_version}/groups/{enc_id}': { parameters: { query?: never; header?: never; @@ -936,15 +936,15 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-groups-{enc_id}"]; + put: operations['put_core-{_version}-groups-{enc_id}']; post?: never; - delete: operations["delete_core-{_version}-groups-{enc_id}"]; + delete: operations['delete_core-{_version}-groups-{enc_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/groups/members/{enc_id}": { + '/core/{_version}/groups/members/{enc_id}': { parameters: { query?: never; header?: never; @@ -954,13 +954,13 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations["delete_core-{_version}-groups-members-{enc_id}"]; + delete: operations['delete_core-{_version}-groups-members-{enc_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/groups/members/{groupMemberId}": { + '/core/{_version}/groups/members/{groupMemberId}': { parameters: { query?: never; header?: never; @@ -968,7 +968,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-groups-members-{groupMemberId}"]; + put: operations['put_core-{_version}-groups-members-{groupMemberId}']; post?: never; delete?: never; options?: never; @@ -976,14 +976,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/v4/groups/members/external/{jwt}": { + '/core/v4/groups/members/external/{jwt}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-v4-groups-members-external-{jwt}"]; + get: operations['get_core-v4-groups-members-external-{jwt}']; put?: never; post?: never; delete?: never; @@ -992,14 +992,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/v4/groups/{group_enc_id}/members": { + '/core/v4/groups/{group_enc_id}/members': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-v4-groups-{group_enc_id}-members"]; + get: operations['get_core-v4-groups-{group_enc_id}-members']; put?: never; post?: never; delete?: never; @@ -1008,14 +1008,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/v4/groups/members/internal": { + '/core/v4/groups/members/internal': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-v4-groups-members-internal"]; + get: operations['get_core-v4-groups-members-internal']; put?: never; post?: never; delete?: never; @@ -1024,7 +1024,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/groups/{enc_id}/reinvite": { + '/core/{_version}/groups/{enc_id}/reinvite': { parameters: { query?: never; header?: never; @@ -1032,7 +1032,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-groups-{enc_id}-reinvite"]; + put: operations['put_core-{_version}-groups-{enc_id}-reinvite']; post?: never; delete?: never; options?: never; @@ -1040,7 +1040,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/groups/members/{groupMemberId}/resume": { + '/core/{_version}/groups/members/{groupMemberId}/resume': { parameters: { query?: never; header?: never; @@ -1048,7 +1048,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-groups-members-{groupMemberId}-resume"]; + put: operations['put_core-{_version}-groups-members-{groupMemberId}-resume']; post?: never; delete?: never; options?: never; @@ -1056,7 +1056,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/invites": { + '/core/{_version}/invites': { parameters: { query?: never; header?: never; @@ -1065,14 +1065,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-invites"]; + post: operations['post_core-{_version}-invites']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/invites/unused": { + '/core/{_version}/invites/unused': { parameters: { query?: never; header?: never; @@ -1081,14 +1081,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-invites-unused"]; + post: operations['post_core-{_version}-invites-unused']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/invites/check": { + '/core/{_version}/invites/check': { parameters: { query?: never; header?: never; @@ -1097,14 +1097,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-invites-check"]; + post: operations['post_core-{_version}-invites-check']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/all": { + '/core/{_version}/keys/all': { parameters: { query?: never; header?: never; @@ -1117,7 +1117,7 @@ export interface paths { * * This route replaces GET /keys. Please refer to https://confluence.protontech.ch/pages/viewpage.action?pageId=157816403 to upgrade */ - get: operations["get_core-{_version}-keys-all"]; + get: operations['get_core-{_version}-keys-all']; put?: never; post?: never; delete?: never; @@ -1126,7 +1126,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/signedkeylists": { + '/core/{_version}/keys/signedkeylists': { parameters: { query?: never; header?: never; @@ -1134,17 +1134,17 @@ export interface paths { cookie?: never; }; /** Get multiple signed key lists for different epochs */ - get: operations["get_core-{_version}-keys-signedkeylists"]; + get: operations['get_core-{_version}-keys-signedkeylists']; put?: never; /** Update signed key list. */ - post: operations["post_core-{_version}-keys-signedkeylists"]; + post: operations['post_core-{_version}-keys-signedkeylists']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/signedkeylist": { + '/core/{_version}/keys/signedkeylist': { parameters: { query?: never; header?: never; @@ -1152,7 +1152,7 @@ export interface paths { cookie?: never; }; /** Get a single signed key lists for a specific epoch */ - get: operations["get_core-{_version}-keys-signedkeylist"]; + get: operations['get_core-{_version}-keys-signedkeylist']; put?: never; post?: never; delete?: never; @@ -1161,7 +1161,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/salts": { + '/core/{_version}/keys/salts': { parameters: { query?: never; header?: never; @@ -1172,7 +1172,7 @@ export interface paths { * Get key salts. * @description Locked route */ - get: operations["get_core-{_version}-keys-salts"]; + get: operations['get_core-{_version}-keys-salts']; put?: never; post?: never; delete?: never; @@ -1181,7 +1181,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/address/{enc_id}": { + '/core/{_version}/keys/address/{enc_id}': { parameters: { query?: never; header?: never; @@ -1190,7 +1190,7 @@ export interface paths { }; get?: never; /** (Migrated keys) Reactivate just an address key */ - put: operations["put_core-{_version}-keys-address-{enc_id}"]; + put: operations['put_core-{_version}-keys-address-{enc_id}']; post?: never; delete?: never; options?: never; @@ -1198,7 +1198,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/address/{enc_id}/subkeys": { + '/core/{_version}/keys/address/{enc_id}/subkeys': { parameters: { query?: never; header?: never; @@ -1207,7 +1207,7 @@ export interface paths { }; get?: never; /** Add subkeys to an existing keypair. */ - put: operations["put_core-{_version}-keys-address-{enc_id}-subkeys"]; + put: operations['put_core-{_version}-keys-address-{enc_id}-subkeys']; post?: never; delete?: never; options?: never; @@ -1215,7 +1215,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/signedkeylists/signature": { + '/core/{_version}/keys/signedkeylists/signature': { parameters: { query?: never; header?: never; @@ -1224,7 +1224,7 @@ export interface paths { }; get?: never; /** Update signed key list signature for a specific revision. */ - put: operations["put_core-{_version}-keys-signedkeylists-signature"]; + put: operations['put_core-{_version}-keys-signedkeylists-signature']; post?: never; delete?: never; options?: never; @@ -1232,7 +1232,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/{enc_id}/primary": { + '/core/{_version}/keys/{enc_id}/primary': { parameters: { query?: never; header?: never; @@ -1245,7 +1245,7 @@ export interface paths { * @description Locked route, only used for address-associated keys, * otherwise this could be a backdoor way to revert to an earlier mailbox password. */ - put: operations["put_core-{_version}-keys-{enc_id}-primary"]; + put: operations['put_core-{_version}-keys-{enc_id}-primary']; post?: never; delete?: never; options?: never; @@ -1253,7 +1253,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/{enc_id}/flags": { + '/core/{_version}/keys/{enc_id}/flags': { parameters: { query?: never; header?: never; @@ -1265,7 +1265,7 @@ export interface paths { * Update key flags. * @description Locked route */ - put: operations["put_core-{_version}-keys-{enc_id}-flags"]; + put: operations['put_core-{_version}-keys-{enc_id}-flags']; post?: never; delete?: never; options?: never; @@ -1273,7 +1273,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/tokens": { + '/core/{_version}/keys/tokens': { parameters: { query?: never; header?: never; @@ -1281,7 +1281,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-keys-tokens"]; + put: operations['put_core-{_version}-keys-tokens']; post?: never; delete?: never; options?: never; @@ -1289,7 +1289,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/user/{enc_id}": { + '/core/{_version}/keys/user/{enc_id}': { parameters: { query?: never; header?: never; @@ -1302,15 +1302,15 @@ export interface paths { * @description Reactivate inactive user key by sending a key copy encrypted with current mailbox password and the list * of address key fingerprints to reactivate. Locked route. */ - put: operations["put_core-{_version}-keys-user-{enc_id}"]; + put: operations['put_core-{_version}-keys-user-{enc_id}']; post?: never; - delete: operations["delete_core-{_version}-keys-user-{enc_id}"]; + delete: operations['delete_core-{_version}-keys-user-{enc_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/private/upgrade": { + '/core/{_version}/keys/private/upgrade': { parameters: { query?: never; header?: never; @@ -1326,14 +1326,14 @@ export interface paths { * This route can not be used to re-activate keys that we don't have access to, * in that case the route "Activate Key" must be used first. */ - post: operations["post_core-{_version}-keys-private-upgrade"]; + post: operations['post_core-{_version}-keys-private-upgrade']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/migrate": { + '/core/{_version}/keys/migrate': { parameters: { query?: never; header?: never; @@ -1345,14 +1345,14 @@ export interface paths { /** Upgrade keys for key migration step 2 * This route can not be used to re-activate keys that we don't have access to, * in that case the route "Activate Key" must be used before or after. */ - post: operations["post_core-{_version}-keys-migrate"]; + post: operations['post_core-{_version}-keys-migrate']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/keys/{enc_id}/activate": { + '/core/{_version}/keys/{enc_id}/activate': { parameters: { query?: never; header?: never; @@ -1362,7 +1362,7 @@ export interface paths { get?: never; /** (Legacy keys) Activate newly-provisioned member address key by sending a key copy encrypted with * current mailbox password. */ - put: operations["put_core-{_version}-keys-{enc_id}-activate"]; + put: operations['put_core-{_version}-keys-{enc_id}-activate']; post?: never; delete?: never; options?: never; @@ -1370,7 +1370,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/{enc_id}": { + '/core/{_version}/keys/{enc_id}': { parameters: { query?: never; header?: never; @@ -1379,7 +1379,7 @@ export interface paths { }; get?: never; /** (Legacy keys) Activate just an address key, when access to the user key is lost */ - put: operations["put_core-{_version}-keys-{enc_id}"]; + put: operations['put_core-{_version}-keys-{enc_id}']; post?: never; delete?: never; options?: never; @@ -1387,7 +1387,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/keys/reset": { + '/core/{_version}/keys/reset': { parameters: { query?: never; header?: never; @@ -1397,14 +1397,14 @@ export interface paths { get?: never; put?: never; /** Install a new key for each address. */ - post: operations["post_core-{_version}-keys-reset"]; + post: operations['post_core-{_version}-keys-reset']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members": { + '/core/{_version}/members': { parameters: { query?: never; header?: never; @@ -1415,7 +1415,7 @@ export interface paths { * Get Members. * @description Get all members of user's organization */ - get: operations["get_core-{_version}-members"]; + get: operations['get_core-{_version}-members']; put?: never; /** * Create a new member. @@ -1423,14 +1423,14 @@ export interface paths { * * phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded */ - post: operations["post_core-{_version}-members"]; + post: operations['post_core-{_version}-members']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/invitations": { + '/core/{_version}/members/invitations': { parameters: { query?: never; header?: never; @@ -1439,14 +1439,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-members-invitations"]; + post: operations['post_core-{_version}-members-invitations']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/invitations/{enc_id}": { + '/core/{_version}/members/invitations/{enc_id}': { parameters: { query?: never; header?: never; @@ -1458,7 +1458,7 @@ export interface paths { * Edit a pending invitation. * @description Locked route */ - put: operations["put_core-{_version}-members-invitations-{enc_id}"]; + put: operations['put_core-{_version}-members-invitations-{enc_id}']; post?: never; delete?: never; options?: never; @@ -1466,7 +1466,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/disable": { + '/core/{_version}/members/{enc_id}/disable': { parameters: { query?: never; header?: never; @@ -1478,7 +1478,7 @@ export interface paths { * Disable a member. * @description Locked route */ - put: operations["put_core-{_version}-members-{enc_id}-disable"]; + put: operations['put_core-{_version}-members-{enc_id}-disable']; post?: never; delete?: never; options?: never; @@ -1486,7 +1486,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/enable": { + '/core/{_version}/members/{enc_id}/enable': { parameters: { query?: never; header?: never; @@ -1498,7 +1498,7 @@ export interface paths { * Enable a member. * @description Locked route */ - put: operations["put_core-{_version}-members-{enc_id}-enable"]; + put: operations['put_core-{_version}-members-{enc_id}-enable']; post?: never; delete?: never; options?: never; @@ -1506,7 +1506,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/quota": { + '/core/{_version}/members/{enc_id}/quota': { parameters: { query?: never; header?: never; @@ -1518,7 +1518,7 @@ export interface paths { * Update disk space quota in bytes. * @description Locked route */ - put: operations["put_core-{_version}-members-{enc_id}-quota"]; + put: operations['put_core-{_version}-members-{enc_id}-quota']; post?: never; delete?: never; options?: never; @@ -1526,7 +1526,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/name": { + '/core/{_version}/members/{enc_id}/name': { parameters: { query?: never; header?: never; @@ -1538,7 +1538,7 @@ export interface paths { * Update member name. * @description Locked route */ - put: operations["put_core-{_version}-members-{enc_id}-name"]; + put: operations['put_core-{_version}-members-{enc_id}-name']; post?: never; delete?: never; options?: never; @@ -1546,7 +1546,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/role": { + '/core/{_version}/members/{enc_id}/role': { parameters: { query?: never; header?: never; @@ -1555,7 +1555,7 @@ export interface paths { }; get?: never; /** Update member role. */ - put: operations["put_core-{_version}-members-{enc_id}-role"]; + put: operations['put_core-{_version}-members-{enc_id}-role']; post?: never; delete?: never; options?: never; @@ -1563,7 +1563,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{memberId}/ai": { + '/core/{_version}/members/{memberId}/ai': { parameters: { query?: never; header?: never; @@ -1572,7 +1572,7 @@ export interface paths { }; get?: never; /** Update AI entitlement for member. */ - put: operations["put_core-{_version}-members-{memberId}-ai"]; + put: operations['put_core-{_version}-members-{memberId}-ai']; post?: never; delete?: never; options?: never; @@ -1580,7 +1580,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/privatize": { + '/core/{_version}/members/{enc_id}/privatize': { parameters: { query?: never; header?: never; @@ -1592,7 +1592,7 @@ export interface paths { * Make account private. * @description Locked route */ - put: operations["put_core-{_version}-members-{enc_id}-privatize"]; + put: operations['put_core-{_version}-members-{enc_id}-privatize']; post?: never; delete?: never; options?: never; @@ -1600,7 +1600,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/me": { + '/core/{_version}/members/me': { parameters: { query?: never; header?: never; @@ -1608,7 +1608,7 @@ export interface paths { cookie?: never; }; /** Get user's member. */ - get: operations["get_core-{_version}-members-me"]; + get: operations['get_core-{_version}-members-me']; put?: never; post?: never; delete?: never; @@ -1617,7 +1617,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/me/unprivatize": { + '/core/{_version}/members/me/unprivatize': { parameters: { query?: never; header?: never; @@ -1625,18 +1625,18 @@ export interface paths { cookie?: never; }; /** Get unprivatization info for self */ - get: operations["get_core-{_version}-members-me-unprivatize"]; + get: operations['get_core-{_version}-members-me-unprivatize']; put?: never; /** Accept member unprivatization */ - post: operations["post_core-{_version}-members-me-unprivatize"]; + post: operations['post_core-{_version}-members-me-unprivatize']; /** Refuse unprivatization for self */ - delete: operations["delete_core-{_version}-members-me-unprivatize"]; + delete: operations['delete_core-{_version}-members-me-unprivatize']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{id}/unprivatize/resend": { + '/core/{_version}/members/{id}/unprivatize/resend': { parameters: { query?: never; header?: never; @@ -1646,14 +1646,14 @@ export interface paths { get?: never; put?: never; /** Resend magic link email */ - post: operations["post_core-{_version}-members-{id}-unprivatize-resend"]; + post: operations['post_core-{_version}-members-{id}-unprivatize-resend']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{id}/unprivatize": { + '/core/{_version}/members/{id}/unprivatize': { parameters: { query?: never; header?: never; @@ -1663,15 +1663,15 @@ export interface paths { get?: never; put?: never; /** Request unprivatization to existing member. */ - post: operations["post_core-{_version}-members-{id}-unprivatize"]; + post: operations['post_core-{_version}-members-{id}-unprivatize']; /** Cancel unprivatization for member */ - delete: operations["delete_core-{_version}-members-{id}-unprivatize"]; + delete: operations['delete_core-{_version}-members-{id}-unprivatize']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}": { + '/core/{_version}/members/{enc_id}': { parameters: { query?: never; header?: never; @@ -1679,27 +1679,27 @@ export interface paths { cookie?: never; }; /** Get a specific member. */ - get: operations["get_core-{_version}-members-{enc_id}"]; + get: operations['get_core-{_version}-members-{enc_id}']; put?: never; post?: never; /** * Delete a member. * @description Remove member, deletes user if not PM user, locked route. */ - delete: operations["delete_core-{_version}-members-{enc_id}"]; + delete: operations['delete_core-{_version}-members-{enc_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/details": { + '/core/{_version}/members/{enc_id}/details': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-members-{enc_id}-details"]; + get: operations['get_core-{_version}-members-{enc_id}-details']; put?: never; post?: never; delete?: never; @@ -1708,14 +1708,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/authlog": { + '/core/{_version}/members/{enc_id}/authlog': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-members-{enc_id}-authlog"]; + get: operations['get_core-{_version}-members-{enc_id}-authlog']; put?: never; post?: never; delete?: never; @@ -1724,7 +1724,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/require2fa": { + '/core/{_version}/members/{enc_id}/require2fa': { parameters: { query?: never; header?: never; @@ -1733,16 +1733,16 @@ export interface paths { }; get?: never; /** Enforce two-factor for a member based on the current organization two-factor grace period setting, locked route */ - put: operations["put_core-{_version}-members-{enc_id}-require2fa"]; + put: operations['put_core-{_version}-members-{enc_id}-require2fa']; post?: never; /** Do not enforce two-factor for a member, locked route */ - delete: operations["delete_core-{_version}-members-{enc_id}-require2fa"]; + delete: operations['delete_core-{_version}-members-{enc_id}-require2fa']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/permissions/forwarding": { + '/core/{_version}/members/{enc_id}/permissions/forwarding': { parameters: { query?: never; header?: never; @@ -1752,15 +1752,15 @@ export interface paths { get?: never; put?: never; /** Allow member to use Email Forwarding */ - post: operations["post_core-{_version}-members-{enc_id}-permissions-forwarding"]; + post: operations['post_core-{_version}-members-{enc_id}-permissions-forwarding']; /** Forbid member to use Email Forwarding */ - delete: operations["delete_core-{_version}-members-{enc_id}-permissions-forwarding"]; + delete: operations['delete_core-{_version}-members-{enc_id}-permissions-forwarding']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/permissions": { + '/core/{_version}/members/permissions': { parameters: { query?: never; header?: never; @@ -1769,7 +1769,7 @@ export interface paths { }; get?: never; /** Add or remove Permissions field for a list of MemberIDs */ - put: operations["put_core-{_version}-members-permissions"]; + put: operations['put_core-{_version}-members-permissions']; post?: never; delete?: never; options?: never; @@ -1777,7 +1777,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/keys/setup": { + '/core/{_version}/members/{enc_id}/keys/setup': { parameters: { query?: never; header?: never; @@ -1790,14 +1790,14 @@ export interface paths { * Setup Member Keys. * @description Setup new member keys, locked route. */ - post: operations["post_core-{_version}-members-{enc_id}-keys-setup"]; + post: operations['post_core-{_version}-members-{enc_id}-keys-setup']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/keys/migrate": { + '/core/{_version}/members/{enc_id}/keys/migrate': { parameters: { query?: never; header?: never; @@ -1811,14 +1811,14 @@ export interface paths { * @description This route can not be used to re-activate keys that we don't have access to, * in that case the route "Activate Key" must be used before or after. */ - post: operations["post_core-{_version}-members-{enc_id}-keys-migrate"]; + post: operations['post_core-{_version}-members-{enc_id}-keys-migrate']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/keys/signedkeylists": { + '/core/{_version}/members/{enc_id}/keys/signedkeylists': { parameters: { query?: never; header?: never; @@ -1828,14 +1828,14 @@ export interface paths { get?: never; put?: never; /** Update signed key lists for a subuser. */ - post: operations["post_core-{_version}-members-{enc_id}-keys-signedkeylists"]; + post: operations['post_core-{_version}-members-{enc_id}-keys-signedkeylists']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/keys/unprivatize": { + '/core/{_version}/members/{enc_id}/keys/unprivatize': { parameters: { query?: never; header?: never; @@ -1848,14 +1848,14 @@ export interface paths { * Unprivatize member * @description Can be called from the background provided validation of InvitationData succeeds */ - post: operations["post_core-{_version}-members-{enc_id}-keys-unprivatize"]; + post: operations['post_core-{_version}-members-{enc_id}-keys-unprivatize']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/auth": { + '/core/{_version}/members/{enc_id}/auth': { parameters: { query?: never; header?: never; @@ -1868,14 +1868,14 @@ export interface paths { * Create Session. * @description Login as non-private member, password route */ - post: operations["post_core-{_version}-members-{enc_id}-auth"]; + post: operations['post_core-{_version}-members-{enc_id}-auth']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/sessions": { + '/core/{_version}/members/{enc_id}/sessions': { parameters: { query?: never; header?: never; @@ -1886,24 +1886,24 @@ export interface paths { * Get sessions route. * @description Get active sessions. */ - get: operations["get_core-{_version}-members-{enc_id}-sessions"]; + get: operations['get_core-{_version}-members-{enc_id}-sessions']; put?: never; /** * Create Session. * @description Login as non-private member, password route */ - post: operations["post_core-{_version}-members-{enc_id}-sessions"]; + post: operations['post_core-{_version}-members-{enc_id}-sessions']; /** * Revoke all sessions route. * @description Revoke all access tokens, locked. */ - delete: operations["delete_core-{_version}-members-{enc_id}-sessions"]; + delete: operations['delete_core-{_version}-members-{enc_id}-sessions']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/sessions/{uid}": { + '/core/{_version}/members/{enc_id}/sessions/{uid}': { parameters: { query?: never; header?: never; @@ -1914,13 +1914,13 @@ export interface paths { put?: never; post?: never; /** Revoke a session by UID, locked. */ - delete: operations["delete_core-{_version}-members-{enc_id}-sessions-{uid}"]; + delete: operations['delete_core-{_version}-members-{enc_id}-sessions-{uid}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/organizations/keys": { + '/core/{_version}/organizations/keys': { parameters: { query?: never; header?: never; @@ -1931,24 +1931,24 @@ export interface paths { * Get organization keys. * @description Get PGP keys of the current organization */ - get: operations["get_core-{_version}-organizations-keys"]; + get: operations['get_core-{_version}-organizations-keys']; /** * Create or replace organization keys. * @description Replace current organization keys and member keys */ - put: operations["put_core-{_version}-organizations-keys"]; + put: operations['put_core-{_version}-organizations-keys']; /** * Create or replace organization keys. * @description Replace current organization keys and member keys */ - post: operations["post_core-{_version}-organizations-keys"]; + post: operations['post_core-{_version}-organizations-keys']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/organizations/keys/backup": { + '/core/{_version}/organizations/keys/backup': { parameters: { query?: never; header?: never; @@ -1959,24 +1959,24 @@ export interface paths { * Get backup key. * @description Get current organization backup private key, locked route. */ - get: operations["get_core-{_version}-organizations-keys-backup"]; + get: operations['get_core-{_version}-organizations-keys-backup']; /** * Update backup key. * @description Update current organization backup private key, locked route. */ - put: operations["put_core-{_version}-organizations-keys-backup"]; + put: operations['put_core-{_version}-organizations-keys-backup']; /** * Update backup key. * @description Update current organization backup private key, locked route. */ - post: operations["post_core-{_version}-organizations-keys-backup"]; + post: operations['post_core-{_version}-organizations-keys-backup']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/organizations/name": { + '/core/{_version}/organizations/name': { parameters: { query?: never; header?: never; @@ -1988,7 +1988,7 @@ export interface paths { * Update organization name. * @description Update current organization name, locked route */ - put: operations["put_core-{_version}-organizations-name"]; + put: operations['put_core-{_version}-organizations-name']; post?: never; delete?: never; options?: never; @@ -1996,7 +1996,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/organizations/email": { + '/core/{_version}/organizations/email': { parameters: { query?: never; header?: never; @@ -2008,7 +2008,7 @@ export interface paths { * Update organization email. * @description Update current organization email, locked route. */ - put: operations["put_core-{_version}-organizations-email"]; + put: operations['put_core-{_version}-organizations-email']; post?: never; delete?: never; options?: never; @@ -2016,7 +2016,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/organizations/2fa": { + '/core/{_version}/organizations/2fa': { parameters: { query?: never; header?: never; @@ -2025,7 +2025,7 @@ export interface paths { }; get?: never; /** Update current organization two-factor grace period setting, locked route */ - put: operations["put_core-{_version}-organizations-2fa"]; + put: operations['put_core-{_version}-organizations-2fa']; post?: never; delete?: never; options?: never; @@ -2033,7 +2033,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/organizations/require2fa": { + '/core/{_version}/organizations/require2fa': { parameters: { query?: never; header?: never; @@ -2042,16 +2042,16 @@ export interface paths { }; get?: never; /** Enforce current organization two-factor authentication for a specific group of members, locked route */ - put: operations["put_core-{_version}-organizations-require2fa"]; + put: operations['put_core-{_version}-organizations-require2fa']; post?: never; /** Remove current organization two-factor authentication enforcement, locked route */ - delete: operations["delete_core-{_version}-organizations-require2fa"]; + delete: operations['delete_core-{_version}-organizations-require2fa']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/organizations/keys/activate": { + '/core/{_version}/organizations/keys/activate': { parameters: { query?: never; header?: never; @@ -2063,7 +2063,7 @@ export interface paths { * Activate organization private key. * @description Update inactive private key with new copy encrypted with current mailbox password, locked route. */ - put: operations["put_core-{_version}-organizations-keys-activate"]; + put: operations['put_core-{_version}-organizations-keys-activate']; post?: never; delete?: never; options?: never; @@ -2071,7 +2071,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/organizations/membership": { + '/core/{_version}/organizations/membership': { parameters: { query?: never; header?: never; @@ -2085,13 +2085,13 @@ export interface paths { * Leave organization. * @description Lets a member delete themselves from an organization. */ - delete: operations["delete_core-{_version}-organizations-membership"]; + delete: operations['delete_core-{_version}-organizations-membership']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/organizations/2fa/remind": { + '/core/{_version}/organizations/2fa/remind': { parameters: { query?: never; header?: never; @@ -2101,14 +2101,14 @@ export interface paths { get?: never; put?: never; /** Send a 2FA reminder email to all members without 2FA set. */ - post: operations["post_core-{_version}-organizations-2fa-remind"]; + post: operations['post_core-{_version}-organizations-2fa-remind']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/organizations/keys/migrate": { + '/core/{_version}/organizations/keys/migrate': { parameters: { query?: never; header?: never; @@ -2118,22 +2118,22 @@ export interface paths { get?: never; put?: never; /** Migrate organization key. */ - post: operations["post_core-{_version}-organizations-keys-migrate"]; + post: operations['post_core-{_version}-organizations-keys-migrate']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/organizations/keys/signature": { + '/core/{_version}/organizations/keys/signature': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-organizations-keys-signature"]; - put: operations["put_core-{_version}-organizations-keys-signature"]; + get: operations['get_core-{_version}-organizations-keys-signature']; + put: operations['put_core-{_version}-organizations-keys-signature']; post?: never; delete?: never; options?: never; @@ -2141,7 +2141,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/organizations/logo/{logo_id}": { + '/core/{_version}/organizations/logo/{logo_id}': { parameters: { query?: never; header?: never; @@ -2149,7 +2149,7 @@ export interface paths { cookie?: never; }; /** Having {enc_id} in the route allows us to cache the logo without invalidating the cache when a new logo is uploaded */ - get: operations["get_core-{_version}-organizations-logo-{logo_id}"]; + get: operations['get_core-{_version}-organizations-logo-{logo_id}']; put?: never; post?: never; delete?: never; @@ -2158,15 +2158,15 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/organizations/settings": { + '/core/{_version}/organizations/settings': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-organizations-settings"]; - put: operations["put_core-{_version}-organizations-settings"]; + get: operations['get_core-{_version}-organizations-settings']; + put: operations['put_core-{_version}-organizations-settings']; post?: never; delete?: never; options?: never; @@ -2174,7 +2174,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/organizations/settings/logo": { + '/core/{_version}/organizations/settings/logo': { parameters: { query?: never; header?: never; @@ -2183,14 +2183,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-organizations-settings-logo"]; - delete: operations["delete_core-{_version}-organizations-settings-logo"]; + post: operations['post_core-{_version}-organizations-settings-logo']; + delete: operations['delete_core-{_version}-organizations-settings-logo']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/captcha": { + '/core/{_version}/captcha': { parameters: { query?: never; header?: never; @@ -2201,7 +2201,7 @@ export interface paths { * Captcha page. * @deprecated */ - get: operations["get_core-{_version}-captcha"]; + get: operations['get_core-{_version}-captcha']; put?: never; post?: never; delete?: never; @@ -2210,7 +2210,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/resources/captcha": { + '/core/{_version}/resources/captcha': { parameters: { query?: never; header?: never; @@ -2218,7 +2218,7 @@ export interface paths { cookie?: never; }; /** Captcha page. */ - get: operations["get_core-{_version}-resources-captcha"]; + get: operations['get_core-{_version}-resources-captcha']; put?: never; post?: never; delete?: never; @@ -2227,7 +2227,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/resources/zendesk": { + '/core/{_version}/resources/zendesk': { parameters: { query?: never; header?: never; @@ -2235,7 +2235,7 @@ export interface paths { cookie?: never; }; /** Zendesk chat. */ - get: operations["get_core-{_version}-resources-zendesk"]; + get: operations['get_core-{_version}-resources-zendesk']; put?: never; post?: never; delete?: never; @@ -2244,7 +2244,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/saml/setup/fields": { + '/core/{_version}/saml/setup/fields': { parameters: { query?: never; header?: never; @@ -2253,14 +2253,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-saml-setup-fields"]; + post: operations['post_core-{_version}-saml-setup-fields']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/saml/setup/xml": { + '/core/{_version}/saml/setup/xml': { parameters: { query?: never; header?: never; @@ -2269,14 +2269,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-saml-setup-xml"]; + post: operations['post_core-{_version}-saml-setup-xml']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/saml/setup/url": { + '/core/{_version}/saml/setup/url': { parameters: { query?: never; header?: never; @@ -2285,21 +2285,21 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-saml-setup-url"]; + post: operations['post_core-{_version}-saml-setup-url']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/saml/configs": { + '/core/{_version}/saml/configs': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-saml-configs"]; + get: operations['get_core-{_version}-saml-configs']; put?: never; post?: never; delete?: never; @@ -2308,14 +2308,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/saml/configs/{enc_id}": { + '/core/{_version}/saml/configs/{enc_id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-saml-configs-{enc_id}"]; + get: operations['get_core-{_version}-saml-configs-{enc_id}']; put?: never; post?: never; delete?: never; @@ -2324,7 +2324,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/saml/configs/{enc_id}/fields": { + '/core/{_version}/saml/configs/{enc_id}/fields': { parameters: { query?: never; header?: never; @@ -2332,7 +2332,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-saml-configs-{enc_id}-fields"]; + put: operations['put_core-{_version}-saml-configs-{enc_id}-fields']; post?: never; delete?: never; options?: never; @@ -2340,7 +2340,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/saml/configs/{enc_id}/delete": { + '/core/{_version}/saml/configs/{enc_id}/delete': { parameters: { query?: never; header?: never; @@ -2348,7 +2348,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-saml-configs-{enc_id}-delete"]; + put: operations['put_core-{_version}-saml-configs-{enc_id}-delete']; post?: never; delete?: never; options?: never; @@ -2356,14 +2356,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/saml/sp/info": { + '/core/{_version}/saml/sp/info': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-saml-sp-info"]; + get: operations['get_core-{_version}-saml-sp-info']; put?: never; post?: never; delete?: never; @@ -2372,14 +2372,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/saml/edugain/info": { + '/core/{_version}/saml/edugain/info': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-saml-edugain-info"]; + get: operations['get_core-{_version}-saml-edugain-info']; put?: never; post?: never; delete?: never; @@ -2388,14 +2388,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/saml/edugain/info/{domainName}": { + '/core/{_version}/saml/edugain/info/{domainName}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-saml-edugain-info-{domainName}"]; + get: operations['get_core-{_version}-saml-edugain-info-{domainName}']; put?: never; post?: never; delete?: never; @@ -2404,7 +2404,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/saml/metadata": { + '/core/{_version}/saml/metadata': { parameters: { query?: never; header?: never; @@ -2412,7 +2412,7 @@ export interface paths { cookie?: never; }; /** Get the XML representation of the Service Provider metadata. */ - get: operations["get_core-{_version}-saml-metadata"]; + get: operations['get_core-{_version}-saml-metadata']; put?: never; post?: never; delete?: never; @@ -2421,7 +2421,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings": { + '/core/{_version}/settings': { parameters: { query?: never; header?: never; @@ -2429,7 +2429,7 @@ export interface paths { cookie?: never; }; /** Get general settings. */ - get: operations["get_core-{_version}-settings"]; + get: operations['get_core-{_version}-settings']; put?: never; post?: never; delete?: never; @@ -2438,7 +2438,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/password": { + '/core/{_version}/settings/password': { parameters: { query?: never; header?: never; @@ -2447,7 +2447,7 @@ export interface paths { }; get?: never; /** Update login password. Only called in 2-password mode (or onboarding to 2-password mode). */ - put: operations["put_core-{_version}-settings-password"]; + put: operations['put_core-{_version}-settings-password']; post?: never; delete?: never; options?: never; @@ -2455,7 +2455,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/password/upgrade": { + '/core/{_version}/settings/password/upgrade': { parameters: { query?: never; header?: never; @@ -2467,7 +2467,7 @@ export interface paths { * Upgrade Password. * @description Upgrade login password on login if version < 4. */ - put: operations["put_core-{_version}-settings-password-upgrade"]; + put: operations['put_core-{_version}-settings-password-upgrade']; post?: never; delete?: never; options?: never; @@ -2475,7 +2475,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/email": { + '/core/{_version}/settings/email': { parameters: { query?: never; header?: never; @@ -2483,7 +2483,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-email"]; + put: operations['put_core-{_version}-settings-email']; post?: never; delete?: never; options?: never; @@ -2491,7 +2491,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/email/verify": { + '/core/{_version}/settings/email/verify': { parameters: { query?: never; header?: never; @@ -2501,14 +2501,14 @@ export interface paths { get?: never; put?: never; /** Verify associated email address. */ - post: operations["post_core-{_version}-settings-email-verify"]; + post: operations['post_core-{_version}-settings-email-verify']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/email/notify": { + '/core/{_version}/settings/email/notify': { parameters: { query?: never; header?: never; @@ -2517,7 +2517,7 @@ export interface paths { }; get?: never; /** Toggle email notifications. */ - put: operations["put_core-{_version}-settings-email-notify"]; + put: operations['put_core-{_version}-settings-email-notify']; post?: never; delete?: never; options?: never; @@ -2525,7 +2525,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/email/reset": { + '/core/{_version}/settings/email/reset': { parameters: { query?: never; header?: never; @@ -2534,7 +2534,7 @@ export interface paths { }; get?: never; /** Enable or disable login password reset by email. */ - put: operations["put_core-{_version}-settings-email-reset"]; + put: operations['put_core-{_version}-settings-email-reset']; post?: never; delete?: never; options?: never; @@ -2542,7 +2542,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/phone": { + '/core/{_version}/settings/phone': { parameters: { query?: never; header?: never; @@ -2550,7 +2550,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-phone"]; + put: operations['put_core-{_version}-settings-phone']; post?: never; delete?: never; options?: never; @@ -2558,7 +2558,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/phone/verify": { + '/core/{_version}/settings/phone/verify': { parameters: { query?: never; header?: never; @@ -2568,14 +2568,14 @@ export interface paths { get?: never; put?: never; /** Verify associated phone number. */ - post: operations["post_core-{_version}-settings-phone-verify"]; + post: operations['post_core-{_version}-settings-phone-verify']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/phone/notify": { + '/core/{_version}/settings/phone/notify': { parameters: { query?: never; header?: never; @@ -2584,7 +2584,7 @@ export interface paths { }; get?: never; /** Toggle phone notifications. */ - put: operations["put_core-{_version}-settings-phone-notify"]; + put: operations['put_core-{_version}-settings-phone-notify']; post?: never; delete?: never; options?: never; @@ -2592,7 +2592,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/phone/reset": { + '/core/{_version}/settings/phone/reset': { parameters: { query?: never; header?: never; @@ -2601,7 +2601,7 @@ export interface paths { }; get?: never; /** Enable or disable login password reset by phone. */ - put: operations["put_core-{_version}-settings-phone-reset"]; + put: operations['put_core-{_version}-settings-phone-reset']; post?: never; delete?: never; options?: never; @@ -2609,7 +2609,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/locale": { + '/core/{_version}/settings/locale': { parameters: { query?: never; header?: never; @@ -2617,7 +2617,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-locale"]; + put: operations['put_core-{_version}-settings-locale']; post?: never; delete?: never; options?: never; @@ -2625,7 +2625,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/logauth": { + '/core/{_version}/settings/logauth': { parameters: { query?: never; header?: never; @@ -2634,7 +2634,7 @@ export interface paths { }; get?: never; /** Update authentication logging. */ - put: operations["put_core-{_version}-settings-logauth"]; + put: operations['put_core-{_version}-settings-logauth']; post?: never; delete?: never; options?: never; @@ -2642,7 +2642,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/devicerecovery": { + '/core/{_version}/settings/devicerecovery': { parameters: { query?: never; header?: never; @@ -2651,7 +2651,7 @@ export interface paths { }; get?: never; /** Update device recovery enabled preference. */ - put: operations["put_core-{_version}-settings-devicerecovery"]; + put: operations['put_core-{_version}-settings-devicerecovery']; post?: never; delete?: never; options?: never; @@ -2659,7 +2659,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/news": { + '/core/{_version}/settings/news': { parameters: { query?: never; header?: never; @@ -2671,16 +2671,16 @@ export interface paths { * Update newsletter subscription. * @deprecated */ - put: operations["put_core-{_version}-settings-news"]; + put: operations['put_core-{_version}-settings-news']; post?: never; delete?: never; options?: never; head?: never; /** Patch newsletter subscription. */ - patch: operations["patch_core-{_version}-settings-news"]; + patch: operations['patch_core-{_version}-settings-news']; trace?: never; }; - "/core/{_version}/settings/news/external": { + '/core/{_version}/settings/news/external': { parameters: { query?: never; header?: never; @@ -2688,21 +2688,21 @@ export interface paths { cookie?: never; }; /** Get newsletter subscription status as external user. */ - get: operations["get_core-{_version}-settings-news-external"]; + get: operations['get_core-{_version}-settings-news-external']; /** * Update newsletter subscription as external user. * @deprecated */ - put: operations["put_core-{_version}-settings-news-external"]; + put: operations['put_core-{_version}-settings-news-external']; post?: never; delete?: never; options?: never; head?: never; /** Patch newsletter subscription as external user. */ - patch: operations["patch_core-{_version}-settings-news-external"]; + patch: operations['patch_core-{_version}-settings-news-external']; trace?: never; }; - "/core/{_version}/settings/density": { + '/core/{_version}/settings/density': { parameters: { query?: never; header?: never; @@ -2711,7 +2711,7 @@ export interface paths { }; get?: never; /** Update the mail list density. */ - put: operations["put_core-{_version}-settings-density"]; + put: operations['put_core-{_version}-settings-density']; post?: never; delete?: never; options?: never; @@ -2719,7 +2719,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/invoicetext": { + '/core/{_version}/settings/invoicetext': { parameters: { query?: never; header?: never; @@ -2728,7 +2728,7 @@ export interface paths { }; get?: never; /** Update invoice user-defined text. */ - put: operations["put_core-{_version}-settings-invoicetext"]; + put: operations['put_core-{_version}-settings-invoicetext']; post?: never; delete?: never; options?: never; @@ -2736,7 +2736,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/2fa/codes": { + '/core/{_version}/settings/2fa/codes': { parameters: { query?: never; header?: never; @@ -2749,14 +2749,14 @@ export interface paths { * Regenerate recovery codes. * @description Replace current recovery codes with new ones. */ - post: operations["post_core-{_version}-settings-2fa-codes"]; + post: operations['post_core-{_version}-settings-2fa-codes']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/2fa/totp": { + '/core/{_version}/settings/2fa/totp': { parameters: { query?: never; header?: never; @@ -2764,16 +2764,16 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-2fa-totp"]; + put: operations['put_core-{_version}-settings-2fa-totp']; /** Signup for TOTP. */ - post: operations["post_core-{_version}-settings-2fa-totp"]; + post: operations['post_core-{_version}-settings-2fa-totp']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/2fa": { + '/core/{_version}/settings/2fa': { parameters: { query?: never; header?: never; @@ -2782,16 +2782,16 @@ export interface paths { }; get?: never; /** Disable all the 2FA methods. */ - put: operations["put_core-{_version}-settings-2fa"]; + put: operations['put_core-{_version}-settings-2fa']; /** Signup for TOTP. */ - post: operations["post_core-{_version}-settings-2fa"]; + post: operations['post_core-{_version}-settings-2fa']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/2fa/reset": { + '/core/{_version}/settings/2fa/reset': { parameters: { query?: never; header?: never; @@ -2804,14 +2804,14 @@ export interface paths { * Request Reset 2FA. * @description Reset all 2FA methods to disabled state. */ - post: operations["post_core-{_version}-settings-2fa-reset"]; + post: operations['post_core-{_version}-settings-2fa-reset']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/2fa/register": { + '/core/{_version}/settings/2fa/register': { parameters: { query?: never; header?: never; @@ -2819,17 +2819,17 @@ export interface paths { cookie?: never; }; /** Get a challenge for registration of a FIDO2 credential. */ - get: operations["get_core-{_version}-settings-2fa-register"]; + get: operations['get_core-{_version}-settings-2fa-register']; put?: never; /** Register a FIDO2 credential. */ - post: operations["post_core-{_version}-settings-2fa-register"]; + post: operations['post_core-{_version}-settings-2fa-register']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/2fa/{credentialID}/remove": { + '/core/{_version}/settings/2fa/{credentialID}/remove': { parameters: { query?: never; header?: never; @@ -2839,14 +2839,14 @@ export interface paths { get?: never; put?: never; /** Remove a FIDO2 credential. */ - post: operations["post_core-{_version}-settings-2fa-{credentialID}-remove"]; + post: operations['post_core-{_version}-settings-2fa-{credentialID}-remove']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/2fa/{credentialID}/rename": { + '/core/{_version}/settings/2fa/{credentialID}/rename': { parameters: { query?: never; header?: never; @@ -2855,7 +2855,7 @@ export interface paths { }; get?: never; /** Rename a FIDO2 credential. */ - put: operations["put_core-{_version}-settings-2fa-{credentialID}-rename"]; + put: operations['put_core-{_version}-settings-2fa-{credentialID}-rename']; post?: never; delete?: never; options?: never; @@ -2863,7 +2863,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/hide-side-panel": { + '/core/{_version}/settings/hide-side-panel': { parameters: { query?: never; header?: never; @@ -2872,7 +2872,7 @@ export interface paths { }; get?: never; /** Update HideSidePanel for the current client. */ - put: operations["put_core-{_version}-settings-hide-side-panel"]; + put: operations['put_core-{_version}-settings-hide-side-panel']; post?: never; delete?: never; options?: never; @@ -2880,7 +2880,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/username": { + '/core/{_version}/settings/username': { parameters: { query?: never; header?: never; @@ -2889,7 +2889,7 @@ export interface paths { }; get?: never; /** Set username for external ProtonAccount. */ - put: operations["put_core-{_version}-settings-username"]; + put: operations['put_core-{_version}-settings-username']; post?: never; delete?: never; options?: never; @@ -2897,7 +2897,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/theme": { + '/core/{_version}/settings/theme': { parameters: { query?: never; header?: never; @@ -2905,7 +2905,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-theme"]; + put: operations['put_core-{_version}-settings-theme']; post?: never; delete?: never; options?: never; @@ -2913,7 +2913,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/themetype": { + '/core/{_version}/settings/themetype': { parameters: { query?: never; header?: never; @@ -2921,7 +2921,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-themetype"]; + put: operations['put_core-{_version}-settings-themetype']; post?: never; delete?: never; options?: never; @@ -2929,7 +2929,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/weekstart": { + '/core/{_version}/settings/weekstart': { parameters: { query?: never; header?: never; @@ -2937,7 +2937,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-weekstart"]; + put: operations['put_core-{_version}-settings-weekstart']; post?: never; delete?: never; options?: never; @@ -2945,7 +2945,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/dateformat": { + '/core/{_version}/settings/dateformat': { parameters: { query?: never; header?: never; @@ -2953,7 +2953,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-dateformat"]; + put: operations['put_core-{_version}-settings-dateformat']; post?: never; delete?: never; options?: never; @@ -2961,7 +2961,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/timeformat": { + '/core/{_version}/settings/timeformat': { parameters: { query?: never; header?: never; @@ -2969,7 +2969,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-timeformat"]; + put: operations['put_core-{_version}-settings-timeformat']; post?: never; delete?: never; options?: never; @@ -2977,7 +2977,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/welcome": { + '/core/{_version}/settings/welcome': { parameters: { query?: never; header?: never; @@ -2985,7 +2985,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-welcome"]; + put: operations['put_core-{_version}-settings-welcome']; post?: never; delete?: never; options?: never; @@ -2993,7 +2993,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/earlyaccess": { + '/core/{_version}/settings/earlyaccess': { parameters: { query?: never; header?: never; @@ -3002,7 +3002,7 @@ export interface paths { }; get?: never; /** Update BetaFlags. */ - put: operations["put_core-{_version}-settings-earlyaccess"]; + put: operations['put_core-{_version}-settings-earlyaccess']; post?: never; delete?: never; options?: never; @@ -3010,7 +3010,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/flags": { + '/core/{_version}/settings/flags': { parameters: { query?: never; header?: never; @@ -3018,7 +3018,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-settings-flags"]; + put: operations['put_core-{_version}-settings-flags']; post?: never; delete?: never; options?: never; @@ -3026,7 +3026,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/telemetry": { + '/core/{_version}/settings/telemetry': { parameters: { query?: never; header?: never; @@ -3035,7 +3035,7 @@ export interface paths { }; get?: never; /** Update telemetry enabled preference. */ - put: operations["put_core-{_version}-settings-telemetry"]; + put: operations['put_core-{_version}-settings-telemetry']; post?: never; delete?: never; options?: never; @@ -3043,7 +3043,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/crashreports": { + '/core/{_version}/settings/crashreports': { parameters: { query?: never; header?: never; @@ -3052,7 +3052,7 @@ export interface paths { }; get?: never; /** Update crash reports enabled preference. */ - put: operations["put_core-{_version}-settings-crashreports"]; + put: operations['put_core-{_version}-settings-crashreports']; post?: never; delete?: never; options?: never; @@ -3060,7 +3060,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/highsecurity": { + '/core/{_version}/settings/highsecurity': { parameters: { query?: never; header?: never; @@ -3073,18 +3073,18 @@ export interface paths { * High Security program - enable * @description https://confluence.protontech.ch/display/MSA/High+Security+Program */ - post: operations["post_core-{_version}-settings-highsecurity"]; + post: operations['post_core-{_version}-settings-highsecurity']; /** * High Security program - disable * @description https://confluence.protontech.ch/display/MSA/High+Security+Program */ - delete: operations["delete_core-{_version}-settings-highsecurity"]; + delete: operations['delete_core-{_version}-settings-highsecurity']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/breachalerts": { + '/core/{_version}/settings/breachalerts': { parameters: { query?: never; header?: never; @@ -3097,18 +3097,18 @@ export interface paths { * Breach Alert - enable * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting */ - post: operations["post_core-{_version}-settings-breachalerts"]; + post: operations['post_core-{_version}-settings-breachalerts']; /** * Breach Alert - disable * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting */ - delete: operations["delete_core-{_version}-settings-breachalerts"]; + delete: operations['delete_core-{_version}-settings-breachalerts']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/sessionaccountrecovery": { + '/core/{_version}/settings/sessionaccountrecovery': { parameters: { query?: never; header?: never; @@ -3117,7 +3117,7 @@ export interface paths { }; get?: never; /** Update session account recovery preference. */ - put: operations["put_core-{_version}-settings-sessionaccountrecovery"]; + put: operations['put_core-{_version}-settings-sessionaccountrecovery']; post?: never; delete?: never; options?: never; @@ -3125,7 +3125,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/ai-assistant-flags": { + '/core/{_version}/settings/ai-assistant-flags': { parameters: { query?: never; header?: never; @@ -3134,7 +3134,7 @@ export interface paths { }; get?: never; /** Update setting to enable or disable AI Assistant. */ - put: operations["put_core-{_version}-settings-ai-assistant-flags"]; + put: operations['put_core-{_version}-settings-ai-assistant-flags']; post?: never; delete?: never; options?: never; @@ -3142,7 +3142,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/news/unsubscribe": { + '/core/{_version}/settings/news/unsubscribe': { parameters: { query?: never; header?: never; @@ -3151,21 +3151,21 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-settings-news-unsubscribe"]; + post: operations['post_core-{_version}-settings-news-unsubscribe']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/support/schedulecall": { + '/core/{_version}/support/schedulecall': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-support-schedulecall"]; + get: operations['get_core-{_version}-support-schedulecall']; put?: never; post?: never; delete?: never; @@ -3174,7 +3174,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{memberId}/lumo": { + '/core/{_version}/members/{memberId}/lumo': { parameters: { query?: never; header?: never; @@ -3182,7 +3182,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-members-{memberId}-lumo"]; + put: operations['put_core-{_version}-members-{memberId}-lumo']; post?: never; delete?: never; options?: never; @@ -3190,7 +3190,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/product-disabled": { + '/core/{_version}/settings/product-disabled': { parameters: { query?: never; header?: never; @@ -3199,7 +3199,7 @@ export interface paths { }; get?: never; /** Update setting to enable or disable specific product for all platforms. */ - put: operations["put_core-{_version}-settings-product-disabled"]; + put: operations['put_core-{_version}-settings-product-disabled']; post?: never; delete?: never; options?: never; @@ -3207,7 +3207,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/delete": { + '/core/{_version}/users/delete': { parameters: { query?: never; header?: never; @@ -3228,18 +3228,18 @@ export interface paths { * * > 5. Managed user in a multi-user organization (non-proton): you can’t delete yourself */ - get: operations["get_core-{_version}-users-delete"]; + get: operations['get_core-{_version}-users-delete']; /** Delete self, will invalidate API access token. */ - put: operations["put_core-{_version}-users-delete"]; + put: operations['put_core-{_version}-users-delete']; post?: never; /** Delete self, will invalidate API access token. */ - delete: operations["delete_core-{_version}-users-delete"]; + delete: operations['delete_core-{_version}-users-delete']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/users/reset": { + '/core/{_version}/users/reset': { parameters: { query?: never; header?: never; @@ -3247,7 +3247,7 @@ export interface paths { cookie?: never; }; /** Get available reset methods and account type. */ - get: operations["get_core-{_version}-users-reset"]; + get: operations['get_core-{_version}-users-reset']; put?: never; post?: never; delete?: never; @@ -3256,7 +3256,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users": { + '/core/{_version}/users': { parameters: { query?: never; header?: never; @@ -3325,20 +3325,20 @@ export interface paths { * } * ``` */ - get: operations["get_core-{_version}-users"]; + get: operations['get_core-{_version}-users']; put?: never; /** * Create a user or ProtonID user with a 3rd party email as username. * @description TODO(fsalathe): Refactor this function into a service [refactor] */ - post: operations["post_core-{_version}-users"]; + post: operations['post_core-{_version}-users']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/users/external": { + '/core/{_version}/users/external': { parameters: { query?: never; header?: never; @@ -3351,14 +3351,14 @@ export interface paths { * Create a user or ProtonID user with a 3rd party email as username. * @description TODO(fsalathe): Refactor this function into a service [refactor] */ - post: operations["post_core-{_version}-users-external"]; + post: operations['post_core-{_version}-users-external']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/users/check": { + '/core/{_version}/users/check': { parameters: { query?: never; header?: never; @@ -3367,7 +3367,7 @@ export interface paths { }; get?: never; /** Check user creation token validity. */ - put: operations["put_core-{_version}-users-check"]; + put: operations['put_core-{_version}-users-check']; post?: never; delete?: never; options?: never; @@ -3375,7 +3375,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/availableExternal": { + '/core/{_version}/users/availableExternal': { parameters: { query?: never; header?: never; @@ -3383,7 +3383,7 @@ export interface paths { cookie?: never; }; /** Check if username already taken. */ - get: operations["get_core-{_version}-users-availableExternal"]; + get: operations['get_core-{_version}-users-availableExternal']; put?: never; post?: never; delete?: never; @@ -3392,7 +3392,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/available": { + '/core/{_version}/users/available': { parameters: { query?: never; header?: never; @@ -3400,7 +3400,7 @@ export interface paths { cookie?: never; }; /** Check if username already taken. */ - get: operations["get_core-{_version}-users-available"]; + get: operations['get_core-{_version}-users-available']; put?: never; post?: never; delete?: never; @@ -3409,7 +3409,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/available/{username}": { + '/core/{_version}/users/available/{username}': { parameters: { query?: never; header?: never; @@ -3417,7 +3417,7 @@ export interface paths { cookie?: never; }; /** @deprecated */ - get: operations["get_core-{_version}-users-available-{username}"]; + get: operations['get_core-{_version}-users-available-{username}']; put?: never; post?: never; delete?: never; @@ -3426,7 +3426,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/direct": { + '/core/{_version}/users/direct': { parameters: { query?: never; header?: never; @@ -3434,7 +3434,7 @@ export interface paths { cookie?: never; }; /** Deprecated. Placeholder left in place for handling old clients. */ - get: operations["get_core-{_version}-users-direct"]; + get: operations['get_core-{_version}-users-direct']; put?: never; post?: never; delete?: never; @@ -3443,7 +3443,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/code": { + '/core/{_version}/users/code': { parameters: { query?: never; header?: never; @@ -3453,14 +3453,14 @@ export interface paths { get?: never; put?: never; /** Send a verification code. */ - post: operations["post_core-{_version}-users-code"]; + post: operations['post_core-{_version}-users-code']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/users/lock": { + '/core/{_version}/users/lock': { parameters: { query?: never; header?: never; @@ -3469,7 +3469,7 @@ export interface paths { }; get?: never; /** Lock sensitive settings for keys/organization. */ - put: operations["put_core-{_version}-users-lock"]; + put: operations['put_core-{_version}-users-lock']; post?: never; delete?: never; options?: never; @@ -3477,7 +3477,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/unlock": { + '/core/{_version}/users/unlock': { parameters: { query?: never; header?: never; @@ -3486,7 +3486,7 @@ export interface paths { }; get?: never; /** Unlock sensitive settings for keys/organization. */ - put: operations["put_core-{_version}-users-unlock"]; + put: operations['put_core-{_version}-users-unlock']; post?: never; delete?: never; options?: never; @@ -3494,7 +3494,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/password": { + '/core/{_version}/users/password': { parameters: { query?: never; header?: never; @@ -3503,7 +3503,7 @@ export interface paths { }; get?: never; /** Unlock password changes. */ - put: operations["put_core-{_version}-users-password"]; + put: operations['put_core-{_version}-users-password']; post?: never; delete?: never; options?: never; @@ -3511,7 +3511,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/captcha/{token}": { + '/core/{_version}/users/captcha/{token}': { parameters: { query?: never; header?: never; @@ -3519,7 +3519,7 @@ export interface paths { cookie?: never; }; /** Get captcha (javascript) (hv1). */ - get: operations["get_core-{_version}-users-captcha-{token}"]; + get: operations['get_core-{_version}-users-captcha-{token}']; put?: never; post?: never; delete?: never; @@ -3528,14 +3528,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/disable/{jwt}": { + '/core/{_version}/users/disable/{jwt}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-users-disable-{jwt}"]; + get: operations['get_core-{_version}-users-disable-{jwt}']; put?: never; post?: never; delete?: never; @@ -3544,19 +3544,19 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/members/{enc_id}/vpn": { + '/core/{_version}/members/{enc_id}/vpn': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-members-{enc_id}-vpn"]; + get: operations['get_core-{_version}-members-{enc_id}-vpn']; /** * Update max number of VPNs for member. * @description Update number of maximum VPN connections, locked route. */ - put: operations["put_core-{_version}-members-{enc_id}-vpn"]; + put: operations['put_core-{_version}-members-{enc_id}-vpn']; post?: never; delete?: never; options?: never; @@ -3564,7 +3564,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/v4/features": { + '/core/v4/features': { parameters: { query?: never; header?: never; @@ -3576,17 +3576,17 @@ export interface paths { * @description TypeScript typing files: * https://gitlab.protontech.ch/ProtonMail/Slim-API/-/blob/develop/bundles/FeatureBundle/tests/Mock/Feature.ts */ - get: operations["get_core-v4-features"]; + get: operations['get_core-v4-features']; put?: never; /** Add a new feature definition. */ - post: operations["post_core-v4-features"]; + post: operations['post_core-v4-features']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/v4/features/{id}": { + '/core/v4/features/{id}': { parameters: { query?: never; header?: never; @@ -3595,7 +3595,7 @@ export interface paths { }; get?: never; /** Update feature configuration. */ - put: operations["put_core-v4-features-{id}"]; + put: operations['put_core-v4-features-{id}']; post?: never; delete?: never; options?: never; @@ -3603,7 +3603,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/v4/features/{featureID}": { + '/core/v4/features/{featureID}': { parameters: { query?: never; header?: never; @@ -3614,13 +3614,13 @@ export interface paths { put?: never; post?: never; /** Remove a feature definition. */ - delete: operations["delete_core-v4-features-{featureID}"]; + delete: operations['delete_core-v4-features-{featureID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/v4/features/{code}": { + '/core/v4/features/{code}': { parameters: { query?: never; header?: never; @@ -3628,7 +3628,7 @@ export interface paths { cookie?: never; }; /** Get a single feature by its code. */ - get: operations["get_core-v4-features-{code}"]; + get: operations['get_core-v4-features-{code}']; put?: never; post?: never; delete?: never; @@ -3637,7 +3637,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/v4/features/{code}/value": { + '/core/v4/features/{code}/value': { parameters: { query?: never; header?: never; @@ -3646,16 +3646,16 @@ export interface paths { }; get?: never; /** Set the value of a single feature by its code. */ - put: operations["put_core-v4-features-{code}-value"]; + put: operations['put_core-v4-features-{code}-value']; post?: never; /** Clear the value of a single feature by its code. */ - delete: operations["delete_core-v4-features-{code}-value"]; + delete: operations['delete_core-v4-features-{code}-value']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/v4/features/{code}/user/value": { + '/core/v4/features/{code}/user/value': { parameters: { query?: never; header?: never; @@ -3664,7 +3664,7 @@ export interface paths { }; get?: never; /** Set the value of a single feature by its code for a given list of users (selected by ID or Username). */ - put: operations["put_core-v4-features-{code}-user-value"]; + put: operations['put_core-v4-features-{code}-user-value']; post?: never; delete?: never; options?: never; @@ -3672,7 +3672,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/auth/info": { + '/core/{_version}/auth/info': { parameters: { query?: never; header?: never; @@ -3682,14 +3682,14 @@ export interface paths { get?: never; put?: never; /** Set up SRP authentication request. */ - post: operations["post_core-{_version}-auth-info"]; + post: operations['post_core-{_version}-auth-info']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/auth/sso/{token}": { + '/core/{_version}/auth/sso/{token}': { parameters: { query?: never; header?: never; @@ -3697,7 +3697,7 @@ export interface paths { cookie?: never; }; /** Initiate SSO flow using token from POST /auth/info */ - get: operations["get_core-{_version}-auth-sso-{token}"]; + get: operations['get_core-{_version}-auth-sso-{token}']; put?: never; post?: never; delete?: never; @@ -3706,7 +3706,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/auth/saml": { + '/core/{_version}/auth/saml': { parameters: { query?: never; header?: never; @@ -3716,14 +3716,14 @@ export interface paths { get?: never; put?: never; /** HTTP-POST binding for SAML authentication. Only to be called by an IdP. */ - post: operations["post_core-{_version}-auth-saml"]; + post: operations['post_core-{_version}-auth-saml']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/auth": { + '/core/{_version}/auth': { parameters: { query?: never; header?: never; @@ -3733,15 +3733,15 @@ export interface paths { get?: never; put?: never; /** Authenticate. */ - post: operations["post_core-{_version}-auth"]; + post: operations['post_core-{_version}-auth']; /** Revoke a token. */ - delete: operations["delete_core-{_version}-auth"]; + delete: operations['delete_core-{_version}-auth']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/auth/jwt": { + '/core/{_version}/auth/jwt': { parameters: { query?: never; header?: never; @@ -3751,14 +3751,14 @@ export interface paths { get?: never; put?: never; /** Authenticate using pre-issued JWT. */ - post: operations["post_core-{_version}-auth-jwt"]; + post: operations['post_core-{_version}-auth-jwt']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/auth/2fa": { + '/core/{_version}/auth/2fa': { parameters: { query?: never; header?: never; @@ -3768,14 +3768,14 @@ export interface paths { get?: never; put?: never; /** Submit second factor. */ - post: operations["post_core-{_version}-auth-2fa"]; + post: operations['post_core-{_version}-auth-2fa']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/auth/modulus": { + '/core/{_version}/auth/modulus': { parameters: { query?: never; header?: never; @@ -3783,7 +3783,7 @@ export interface paths { cookie?: never; }; /** Get random SRP modulus. */ - get: operations["get_core-{_version}-auth-modulus"]; + get: operations['get_core-{_version}-auth-modulus']; put?: never; post?: never; delete?: never; @@ -3792,7 +3792,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/auth/scopes": { + '/core/{_version}/auth/scopes': { parameters: { query?: never; header?: never; @@ -3803,7 +3803,7 @@ export interface paths { * Get the current user scopes. * @description Note that the bitmap of scopes is a string to avoid truncations of big numbers. */ - get: operations["get_core-{_version}-auth-scopes"]; + get: operations['get_core-{_version}-auth-scopes']; put?: never; post?: never; delete?: never; @@ -3812,7 +3812,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/auth/refresh": { + '/core/{_version}/auth/refresh': { parameters: { query?: never; header?: never; @@ -3836,14 +3836,14 @@ export interface paths { * } * ``` */ - post: operations["post_core-{_version}-auth-refresh"]; + post: operations['post_core-{_version}-auth-refresh']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/auth/cookies": { + '/core/{_version}/auth/cookies': { parameters: { query?: never; header?: never; @@ -3858,14 +3858,14 @@ export interface paths { * For non-persistent sessions cookie expiration is set to 0 and the client should garbage collect them at the end * of the session. */ - post: operations["post_core-{_version}-auth-cookies"]; + post: operations['post_core-{_version}-auth-cookies']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/auth/credentialless": { + '/core/{_version}/auth/credentialless': { parameters: { query?: never; header?: never; @@ -3875,14 +3875,14 @@ export interface paths { get?: never; put?: never; /** Create and authenticate a credential-less user. */ - post: operations["post_core-{_version}-auth-credentialless"]; + post: operations['post_core-{_version}-auth-credentialless']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/mnemonic": { + '/core/{_version}/settings/mnemonic': { parameters: { query?: never; header?: never; @@ -3894,13 +3894,13 @@ export interface paths { * @description Returns the mnemonic keyring and its encryption salt, to allow re-enabling user keys if a logged in user * remembers an old mnemonic. */ - get: operations["get_core-{_version}-settings-mnemonic"]; + get: operations['get_core-{_version}-settings-mnemonic']; /** * Update or set mnemonic. * @description This route allows submission of a new mnemonic or update an existing mnemonic, alongside a backup keyring. * If a keyring already exists the keys will be merged (newer replaces older). */ - put: operations["put_core-{_version}-settings-mnemonic"]; + put: operations['put_core-{_version}-settings-mnemonic']; post?: never; delete?: never; options?: never; @@ -3908,7 +3908,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/settings/mnemonic/reset": { + '/core/{_version}/settings/mnemonic/reset': { parameters: { query?: never; header?: never; @@ -3919,7 +3919,7 @@ export interface paths { * Get mnemonic keyring to restore keys. * @description Returns the mnemonic keyring and its encryption salt, to allow re-enabling user keys in the reset flow. */ - get: operations["get_core-{_version}-settings-mnemonic-reset"]; + get: operations['get_core-{_version}-settings-mnemonic-reset']; put?: never; /** * Reset account using a mnemonic. @@ -3927,14 +3927,14 @@ export interface paths { * to allow resetting an account. This will change the session's scopes to the regular user's scopes. * It logs out other sessions for security reasons. */ - post: operations["post_core-{_version}-settings-mnemonic-reset"]; + post: operations['post_core-{_version}-settings-mnemonic-reset']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/mnemonic/disable": { + '/core/{_version}/settings/mnemonic/disable': { parameters: { query?: never; header?: never; @@ -3948,14 +3948,14 @@ export interface paths { * @description To re-enable it's needed to submit a new mnemonic via PUT /settings/mnemonic. * This route removes the PASSWORD scope from the token. */ - post: operations["post_core-{_version}-settings-mnemonic-disable"]; + post: operations['post_core-{_version}-settings-mnemonic-disable']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/mnemonic/reactivate": { + '/core/{_version}/settings/mnemonic/reactivate': { parameters: { query?: never; header?: never; @@ -3972,7 +3972,7 @@ export interface paths { * It will work only if the mnemonic needs to be (re) activated and is to be prompted automatically (i.e. for * states MNEMONIC_ENABLED and MNEMONIC_OUTDATED). */ - put: operations["put_core-{_version}-settings-mnemonic-reactivate"]; + put: operations['put_core-{_version}-settings-mnemonic-reactivate']; post?: never; delete?: never; options?: never; @@ -3980,7 +3980,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/pushes": { + '/core/{_version}/pushes': { parameters: { query?: never; header?: never; @@ -3993,7 +3993,7 @@ export interface paths { * @description List of active notifications for the current logged user. * Can be used by the clients to always know what should still be showed as active notification. */ - get: operations["get_core-{_version}-pushes"]; + get: operations['get_core-{_version}-pushes']; put?: never; post?: never; delete?: never; @@ -4002,7 +4002,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/pushes/active": { + '/core/{_version}/pushes/active': { parameters: { query?: never; header?: never; @@ -4014,7 +4014,7 @@ export interface paths { * @description List of active notifications for the current logged user. * Can be used by the clients to always know what should still be showed as active notification. */ - get: operations["get_core-{_version}-pushes-active"]; + get: operations['get_core-{_version}-pushes-active']; put?: never; post?: never; delete?: never; @@ -4023,7 +4023,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/pushes/active/session": { + '/core/{_version}/pushes/active/session': { parameters: { query?: never; header?: never; @@ -4035,7 +4035,7 @@ export interface paths { * @description List of active notifications for the current logged user using the current session. * Can be used by the clients to always know what should still be showed as active notification. */ - get: operations["get_core-{_version}-pushes-active-session"]; + get: operations['get_core-{_version}-pushes-active-session']; put?: never; post?: never; delete?: never; @@ -4044,7 +4044,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/pushes/{enc_id}": { + '/core/{_version}/pushes/{enc_id}': { parameters: { query?: never; header?: never; @@ -4058,13 +4058,13 @@ export interface paths { * Delete the given push. * @description If the session belongs to a family, the pushes for the whole session family will be deleted. */ - delete: operations["delete_core-{_version}-pushes-{enc_id}"]; + delete: operations['delete_core-{_version}-pushes-{enc_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/referrals": { + '/core/{_version}/referrals': { parameters: { query?: never; header?: never; @@ -4072,17 +4072,17 @@ export interface paths { cookie?: never; }; /** List current user referrals. */ - get: operations["get_core-{_version}-referrals"]; + get: operations['get_core-{_version}-referrals']; put?: never; /** Send referral invitation by email to a list of recipients. */ - post: operations["post_core-{_version}-referrals"]; + post: operations['post_core-{_version}-referrals']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/referrals/status": { + '/core/{_version}/referrals/status': { parameters: { query?: never; header?: never; @@ -4090,7 +4090,7 @@ export interface paths { cookie?: never; }; /** Current user referral status. */ - get: operations["get_core-{_version}-referrals-status"]; + get: operations['get_core-{_version}-referrals-status']; put?: never; post?: never; delete?: never; @@ -4099,7 +4099,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/referrals/identifiers/{identifier}": { + '/core/{_version}/referrals/identifiers/{identifier}': { parameters: { query?: never; header?: never; @@ -4107,7 +4107,7 @@ export interface paths { cookie?: never; }; /** Check if referrer identifier exists */ - get: operations["get_core-{_version}-referrals-identifiers-{identifier}"]; + get: operations['get_core-{_version}-referrals-identifiers-{identifier}']; put?: never; post?: never; delete?: never; @@ -4116,7 +4116,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/devices": { + '/core/{_version}/devices': { parameters: { query?: never; header?: never; @@ -4128,18 +4128,18 @@ export interface paths { /** Register device. The registering will delete any duplicate having the same (UserID, Product, DeviceToken) from * different sessions. If the registering is done from a session already having a registered device, the existing * device will be replaced with the new one. */ - post: operations["post_core-{_version}-devices"]; + post: operations['post_core-{_version}-devices']; /** * Unregister device. * @description > Note: Please use the `DELETE /core/v4/devices` route */ - delete: operations["delete_core-{_version}-devices"]; + delete: operations['delete_core-{_version}-devices']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/betas/{client_id}": { + '/core/{_version}/betas/{client_id}': { parameters: { query?: never; header?: never; @@ -4147,18 +4147,18 @@ export interface paths { cookie?: never; }; /** Get a specific beta registration. */ - get: operations["get_core-{_version}-betas-{client_id}"]; + get: operations['get_core-{_version}-betas-{client_id}']; /** Create or update beta registration. */ - put: operations["put_core-{_version}-betas-{client_id}"]; + put: operations['put_core-{_version}-betas-{client_id}']; post?: never; /** Delete a specific beta registration. */ - delete: operations["delete_core-{_version}-betas-{client_id}"]; + delete: operations['delete_core-{_version}-betas-{client_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/betas": { + '/core/{_version}/betas': { parameters: { query?: never; header?: never; @@ -4166,17 +4166,17 @@ export interface paths { cookie?: never; }; /** Get all beta registrations. */ - get: operations["get_core-{_version}-betas"]; + get: operations['get_core-{_version}-betas']; put?: never; post?: never; /** Delete all beta registrations. */ - delete: operations["delete_core-{_version}-betas"]; + delete: operations['delete_core-{_version}-betas']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/geofeed/geofeed.csv": { + '/core/{_version}/geofeed/geofeed.csv': { parameters: { query?: never; header?: never; @@ -4184,7 +4184,7 @@ export interface paths { cookie?: never; }; /** Get a CSV export for GeoFeed. */ - get: operations["get_core-{_version}-geofeed-geofeed-csv"]; + get: operations['get_core-{_version}-geofeed-geofeed-csv']; put?: never; post?: never; delete?: never; @@ -4193,7 +4193,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/geofeed/geofeed-public.csv": { + '/core/{_version}/geofeed/geofeed-public.csv': { parameters: { query?: never; header?: never; @@ -4201,7 +4201,7 @@ export interface paths { cookie?: never; }; /** Get geofeed data containing only the custom admin-set data */ - get: operations["get_core-{_version}-geofeed-geofeed-public-csv"]; + get: operations['get_core-{_version}-geofeed-geofeed-public-csv']; put?: never; post?: never; delete?: never; @@ -4210,7 +4210,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/load": { + '/core/{_version}/load': { parameters: { query?: never; header?: never; @@ -4222,21 +4222,21 @@ export interface paths { * @description Placeholder route for app pages and modals that are loaded by front-end JavaScript instead of * obtained via a GET request. */ - get: operations["get_core-{_version}-load"]; + get: operations['get_core-{_version}-load']; put?: never; /** * Placeholder route. * @description Placeholder route for app pages and modals that are loaded by front-end JavaScript instead of * obtained via a GET request. */ - post: operations["post_core-{_version}-load"]; + post: operations['post_core-{_version}-load']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/logs/auth": { + '/core/{_version}/logs/auth': { parameters: { query?: never; header?: never; @@ -4244,17 +4244,17 @@ export interface paths { cookie?: never; }; /** Get authentication logs. */ - get: operations["get_core-{_version}-logs-auth"]; + get: operations['get_core-{_version}-logs-auth']; put?: never; post?: never; /** Delete all authentication logs. */ - delete: operations["delete_core-{_version}-logs-auth"]; + delete: operations['delete_core-{_version}-logs-auth']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/metrics": { + '/core/{_version}/metrics': { parameters: { query?: never; header?: never; @@ -4262,20 +4262,20 @@ export interface paths { cookie?: never; }; /** Send Simple Metrics. */ - get: operations["get_core-{_version}-metrics"]; + get: operations['get_core-{_version}-metrics']; put?: never; /** * Send Metrics Report. * @description The `Data` key can contain anything, that is what will be saved in the log (as context). */ - post: operations["post_core-{_version}-metrics"]; + post: operations['post_core-{_version}-metrics']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/settings/recovery/secret": { + '/core/{_version}/settings/recovery/secret': { parameters: { query?: never; header?: never; @@ -4288,18 +4288,18 @@ export interface paths { * Set secret when empty. * @description This route allows submission of new secrets when they are empty for the primary user key. */ - post: operations["post_core-{_version}-settings-recovery-secret"]; + post: operations['post_core-{_version}-settings-recovery-secret']; /** * Reset secrets to the null state, in case the files are (suspect) compromised. * @description To re-enable it's needed to submit new secrets. */ - delete: operations["delete_core-{_version}-settings-recovery-secret"]; + delete: operations['delete_core-{_version}-settings-recovery-secret']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/form/{portal_id}/{form_id}": { + '/core/{_version}/reports/form/{portal_id}/{form_id}': { parameters: { query?: never; header?: never; @@ -4309,14 +4309,14 @@ export interface paths { get?: never; put?: never; /** Please refer to the Hubspot API docs for this route: https://legacydocs.hubspot.com/docs/methods/forms/submit_form */ - post: operations["post_core-{_version}-reports-form-{portal_id}-{form_id}"]; + post: operations['post_core-{_version}-reports-form-{portal_id}-{form_id}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/bug": { + '/core/{_version}/reports/bug': { parameters: { query?: never; header?: never; @@ -4381,14 +4381,14 @@ export interface paths { * ----WebKitFormBoundary7MA4YWxkTrZu0gW * ``` */ - post: operations["post_core-{_version}-reports-bug"]; + post: operations['post_core-{_version}-reports-bug']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/bug/attachments": { + '/core/{_version}/reports/bug/attachments': { parameters: { query?: never; header?: never; @@ -4412,14 +4412,14 @@ export interface paths { * ----WebKitFormBoundary7MA4YWxkTrZu0gW * ``` */ - post: operations["post_core-{_version}-reports-bug-attachments"]; + post: operations['post_core-{_version}-reports-bug-attachments']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/bug/{ticketId}": { + '/core/{_version}/reports/bug/{ticketId}': { parameters: { query?: never; header?: never; @@ -4430,13 +4430,13 @@ export interface paths { put?: never; post?: never; /** Solve ticket */ - delete: operations["delete_core-{_version}-reports-bug-{ticketId}"]; + delete: operations['delete_core-{_version}-reports-bug-{ticketId}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/abuse": { + '/core/{_version}/reports/abuse': { parameters: { query?: never; header?: never; @@ -4476,14 +4476,14 @@ export interface paths { * ----WebKitFormBoundary7MA4YWxkTrZu0gW * ``` */ - post: operations["post_core-{_version}-reports-abuse"]; + post: operations['post_core-{_version}-reports-abuse']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/crash": { + '/core/{_version}/reports/crash': { parameters: { query?: never; header?: never; @@ -4493,14 +4493,14 @@ export interface paths { get?: never; put?: never; /** Report a client crash. */ - post: operations["post_core-{_version}-reports-crash"]; + post: operations['post_core-{_version}-reports-crash']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/sentry/api/{id}/{type}": { + '/core/{_version}/reports/sentry/api/{id}/{type}': { parameters: { query?: never; header?: never; @@ -4520,14 +4520,14 @@ export interface paths { * https://SENTRY_PUBLIC_KEY@api.protonmail.ch/core/v4/reports/sentry/{sentry_project_id} * */ - post: operations["post_core-{_version}-reports-sentry-api-{id}-{type}"]; + post: operations['post_core-{_version}-reports-sentry-api-{id}-{type}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/sentry/api/{id}/{type}/": { + '/core/{_version}/reports/sentry/api/{id}/{type}/': { parameters: { query?: never; header?: never; @@ -4547,14 +4547,14 @@ export interface paths { * https://SENTRY_PUBLIC_KEY@api.protonmail.ch/core/v4/reports/sentry/{sentry_project_id} * */ - post: operations["post_core-{_version}-reports-sentry-api-{id}-{type}"]; + post: operations['post_core-{_version}-reports-sentry-api-{id}-{type}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/phishing": { + '/core/{_version}/reports/phishing': { parameters: { query?: never; header?: never; @@ -4564,14 +4564,14 @@ export interface paths { get?: never; put?: never; /** Report a phishing email. */ - post: operations["post_core-{_version}-reports-phishing"]; + post: operations['post_core-{_version}-reports-phishing']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/spam": { + '/core/{_version}/reports/spam': { parameters: { query?: never; header?: never; @@ -4581,14 +4581,14 @@ export interface paths { get?: never; put?: never; /** Report spam. */ - post: operations["post_core-{_version}-reports-spam"]; + post: operations['post_core-{_version}-reports-spam']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reports/cancel-plan": { + '/core/{_version}/reports/cancel-plan': { parameters: { query?: never; header?: never; @@ -4597,14 +4597,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-reports-cancel-plan"]; + post: operations['post_core-{_version}-reports-cancel-plan']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reset/{username}/{token}": { + '/core/{_version}/reset/{username}/{token}': { parameters: { query?: never; header?: never; @@ -4612,7 +4612,7 @@ export interface paths { cookie?: never; }; /** Validate reset token. */ - get: operations["get_core-{_version}-reset-{username}-{token}"]; + get: operations['get_core-{_version}-reset-{username}-{token}']; put?: never; post?: never; delete?: never; @@ -4621,7 +4621,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/reset": { + '/core/{_version}/reset': { parameters: { query?: never; header?: never; @@ -4631,14 +4631,14 @@ export interface paths { get?: never; put?: never; /** Request login reset token. */ - post: operations["post_core-{_version}-reset"]; + post: operations['post_core-{_version}-reset']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/reset/username": { + '/core/{_version}/reset/username': { parameters: { query?: never; header?: never; @@ -4648,21 +4648,21 @@ export interface paths { get?: never; put?: never; /** Send usernames to notification email. */ - post: operations["post_core-{_version}-reset-username"]; + post: operations['post_core-{_version}-reset-username']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/system/config": { + '/core/{_version}/system/config': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-system-config"]; + get: operations['get_core-{_version}-system-config']; put?: never; post?: never; delete?: never; @@ -4671,14 +4671,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/system/version": { + '/core/{_version}/system/version': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-system-version"]; + get: operations['get_core-{_version}-system-version']; put?: never; post?: never; delete?: never; @@ -4687,14 +4687,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/exception": { + '/core/{_version}/tests/exception': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-tests-exception"]; + get: operations['get_core-{_version}-tests-exception']; put?: never; post?: never; delete?: never; @@ -4703,14 +4703,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/error": { + '/core/{_version}/tests/error': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-tests-error"]; + get: operations['get_core-{_version}-tests-error']; put?: never; post?: never; delete?: never; @@ -4719,14 +4719,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/notice": { + '/core/{_version}/tests/notice': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-tests-notice"]; + get: operations['get_core-{_version}-tests-notice']; put?: never; post?: never; delete?: never; @@ -4735,7 +4735,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/memoryLeak": { + '/core/{_version}/tests/memoryLeak': { parameters: { query?: never; header?: never; @@ -4743,7 +4743,7 @@ export interface paths { cookie?: never; }; /** Simulate a memory leak. */ - get: operations["get_core-{_version}-tests-memoryLeak"]; + get: operations['get_core-{_version}-tests-memoryLeak']; put?: never; post?: never; delete?: never; @@ -4752,14 +4752,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/logger": { + '/core/{_version}/tests/logger': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-tests-logger"]; + get: operations['get_core-{_version}-tests-logger']; put?: never; post?: never; delete?: never; @@ -4768,14 +4768,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/logger/observability": { + '/core/{_version}/tests/logger/observability': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-tests-logger-observability"]; + get: operations['get_core-{_version}-tests-logger-observability']; put?: never; post?: never; delete?: never; @@ -4784,7 +4784,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/ping": { + '/core/{_version}/tests/ping': { parameters: { query?: never; header?: never; @@ -4796,7 +4796,7 @@ export interface paths { * @description More info about when to use this route: * https://confluence.protontech.ch/display/CP/When+and+How+to+Retry+API+Requests */ - get: operations["get_core-{_version}-tests-ping"]; + get: operations['get_core-{_version}-tests-ping']; put?: never; post?: never; delete?: never; @@ -4805,7 +4805,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/version": { + '/core/{_version}/tests/version': { parameters: { query?: never; header?: never; @@ -4813,7 +4813,7 @@ export interface paths { cookie?: never; }; /** @deprecated */ - get: operations["get_core-{_version}-tests-version"]; + get: operations['get_core-{_version}-tests-version']; put?: never; post?: never; delete?: never; @@ -4822,7 +4822,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/tests/stream": { + '/core/{_version}/tests/stream': { parameters: { query?: never; header?: never; @@ -4830,7 +4830,7 @@ export interface paths { cookie?: never; }; /** Test endpoint to check streaming capabilities */ - get: operations["get_core-{_version}-tests-stream"]; + get: operations['get_core-{_version}-tests-stream']; put?: never; post?: never; delete?: never; @@ -4839,14 +4839,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/update": { + '/core/{_version}/update': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-update"]; + get: operations['get_core-{_version}-update']; put?: never; post?: never; delete?: never; @@ -4855,7 +4855,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/invitations": { + '/core/{_version}/users/invitations': { parameters: { query?: never; header?: never; @@ -4863,7 +4863,7 @@ export interface paths { cookie?: never; }; /** Gets organization invitations sent to a user. */ - get: operations["get_core-{_version}-users-invitations"]; + get: operations['get_core-{_version}-users-invitations']; put?: never; post?: never; delete?: never; @@ -4872,7 +4872,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/users/invitations/{enc_id}/reject": { + '/core/{_version}/users/invitations/{enc_id}/reject': { parameters: { query?: never; header?: never; @@ -4882,14 +4882,14 @@ export interface paths { get?: never; put?: never; /** Rejects an invitation. */ - post: operations["post_core-{_version}-users-invitations-{enc_id}-reject"]; + post: operations['post_core-{_version}-users-invitations-{enc_id}-reject']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/users/invitations/{enc_id}/accept": { + '/core/{_version}/users/invitations/{enc_id}/accept': { parameters: { query?: never; header?: never; @@ -4899,14 +4899,14 @@ export interface paths { get?: never; put?: never; /** Accepts an invitation. */ - post: operations["post_core-{_version}-users-invitations-{enc_id}-accept"]; + post: operations['post_core-{_version}-users-invitations-{enc_id}-accept']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/validate/email": { + '/core/{_version}/validate/email': { parameters: { query?: never; header?: never; @@ -4916,14 +4916,14 @@ export interface paths { get?: never; put?: never; /** Validate email address. */ - post: operations["post_core-{_version}-validate-email"]; + post: operations['post_core-{_version}-validate-email']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/validate/phone": { + '/core/{_version}/validate/phone': { parameters: { query?: never; header?: never; @@ -4933,14 +4933,14 @@ export interface paths { get?: never; put?: never; /** Validate phone number. */ - post: operations["post_core-{_version}-validate-phone"]; + post: operations['post_core-{_version}-validate-phone']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verification/ownership/{token}": { + '/core/{_version}/verification/ownership/{token}': { parameters: { query?: never; header?: never; @@ -4948,17 +4948,17 @@ export interface paths { cookie?: never; }; /** Get details of a given Ownership Verification. */ - get: operations["get_core-{_version}-verification-ownership-{token}"]; + get: operations['get_core-{_version}-verification-ownership-{token}']; put?: never; /** Request ownership verification. */ - post: operations["post_core-{_version}-verification-ownership-{token}"]; + post: operations['post_core-{_version}-verification-ownership-{token}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verification/ownership-email/{token}": { + '/core/{_version}/verification/ownership-email/{token}': { parameters: { query?: never; header?: never; @@ -4966,17 +4966,17 @@ export interface paths { cookie?: never; }; /** Get details of a given Ownership Verification. */ - get: operations["get_core-{_version}-verification-ownership-email-{token}"]; + get: operations['get_core-{_version}-verification-ownership-email-{token}']; put?: never; /** Request ownership verification. */ - post: operations["post_core-{_version}-verification-ownership-email-{token}"]; + post: operations['post_core-{_version}-verification-ownership-email-{token}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verification/ownership-sms/{token}": { + '/core/{_version}/verification/ownership-sms/{token}': { parameters: { query?: never; header?: never; @@ -4984,17 +4984,17 @@ export interface paths { cookie?: never; }; /** Get details of a given Ownership Verification. */ - get: operations["get_core-{_version}-verification-ownership-sms-{token}"]; + get: operations['get_core-{_version}-verification-ownership-sms-{token}']; put?: never; /** Request ownership verification. */ - post: operations["post_core-{_version}-verification-ownership-sms-{token}"]; + post: operations['post_core-{_version}-verification-ownership-sms-{token}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verification/ownership/{token}/{code}": { + '/core/{_version}/verification/ownership/{token}/{code}': { parameters: { query?: never; header?: never; @@ -5004,14 +5004,14 @@ export interface paths { get?: never; put?: never; /** Request ownership verification. */ - post: operations["post_core-{_version}-verification-ownership-{token}-{code}"]; + post: operations['post_core-{_version}-verification-ownership-{token}-{code}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verification/ownership-email/{token}/{code}": { + '/core/{_version}/verification/ownership-email/{token}/{code}': { parameters: { query?: never; header?: never; @@ -5021,14 +5021,14 @@ export interface paths { get?: never; put?: never; /** Request ownership verification. */ - post: operations["post_core-{_version}-verification-ownership-email-{token}-{code}"]; + post: operations['post_core-{_version}-verification-ownership-email-{token}-{code}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verification/ownership-sms/{token}/{code}": { + '/core/{_version}/verification/ownership-sms/{token}/{code}': { parameters: { query?: never; header?: never; @@ -5038,21 +5038,21 @@ export interface paths { get?: never; put?: never; /** Request ownership verification. */ - post: operations["post_core-{_version}-verification-ownership-sms-{token}-{code}"]; + post: operations['post_core-{_version}-verification-ownership-sms-{token}-{code}']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/v6/events/{id}": { + '/core/v6/events/{id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-v6-events-{id}"]; + get: operations['get_core-v6-events-{id}']; put?: never; post?: never; delete?: never; @@ -5061,14 +5061,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/events/latest": { + '/core/{_version}/events/latest': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-events-latest"]; + get: operations['get_core-{_version}-events-latest']; put?: never; post?: never; delete?: never; @@ -5077,7 +5077,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/events/{id}": { + '/core/{_version}/events/{id}': { parameters: { query?: never; header?: never; @@ -5088,7 +5088,7 @@ export interface paths { * Get events since ID. * @description Get a list of models to refresh for each event type. */ - get: operations["get_core-{_version}-events-{id}"]; + get: operations['get_core-{_version}-events-{id}']; put?: never; post?: never; delete?: never; @@ -5097,7 +5097,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/v4/events/{id}": { + '/core/v4/events/{id}': { parameters: { query?: never; header?: never; @@ -5109,7 +5109,7 @@ export interface paths { * @deprecated * @description Get a list of models to refresh for each event type. */ - get: operations["get_core-v4-events-{id}"]; + get: operations['get_core-v4-events-{id}']; put?: never; post?: never; delete?: never; @@ -5118,7 +5118,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/feedback": { + '/core/{_version}/feedback': { parameters: { query?: never; header?: never; @@ -5128,21 +5128,21 @@ export interface paths { get?: never; put?: never; /** Log general user feedback. */ - post: operations["post_core-{_version}-feedback"]; + post: operations['post_core-{_version}-feedback']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/checklist/get-started": { + '/core/{_version}/checklist/get-started': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-checklist-get-started"]; + get: operations['get_core-{_version}-checklist-get-started']; put?: never; post?: never; delete?: never; @@ -5151,14 +5151,14 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/checklist/paying-user": { + '/core/{_version}/checklist/paying-user': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_core-{_version}-checklist-paying-user"]; + get: operations['get_core-{_version}-checklist-paying-user']; put?: never; post?: never; delete?: never; @@ -5167,7 +5167,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/checklist/get-started/seen-completed-list": { + '/core/{_version}/checklist/get-started/seen-completed-list': { parameters: { query?: never; header?: never; @@ -5176,14 +5176,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-checklist-get-started-seen-completed-list"]; + post: operations['post_core-{_version}-checklist-get-started-seen-completed-list']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/checklist/paying-user/hide": { + '/core/{_version}/checklist/paying-user/hide': { parameters: { query?: never; header?: never; @@ -5192,14 +5192,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-checklist-paying-user-hide"]; + post: operations['post_core-{_version}-checklist-paying-user-hide']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/checklist/paying-user/seen-completed-list": { + '/core/{_version}/checklist/paying-user/seen-completed-list': { parameters: { query?: never; header?: never; @@ -5208,14 +5208,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-checklist-paying-user-seen-completed-list"]; + post: operations['post_core-{_version}-checklist-paying-user-seen-completed-list']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/checklist/get-started/init": { + '/core/{_version}/checklist/get-started/init': { parameters: { query?: never; header?: never; @@ -5224,14 +5224,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-checklist-get-started-init"]; + post: operations['post_core-{_version}-checklist-get-started-init']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/checklist/paying-user/init": { + '/core/{_version}/checklist/paying-user/init': { parameters: { query?: never; header?: never; @@ -5240,14 +5240,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_core-{_version}-checklist-paying-user-init"]; + post: operations['post_core-{_version}-checklist-paying-user-init']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/checklist/check-item": { + '/core/{_version}/checklist/check-item': { parameters: { query?: never; header?: never; @@ -5255,7 +5255,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-checklist-check-item"]; + put: operations['put_core-{_version}-checklist-check-item']; post?: never; delete?: never; options?: never; @@ -5263,7 +5263,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/checklist/update-display": { + '/core/{_version}/checklist/update-display': { parameters: { query?: never; header?: never; @@ -5271,7 +5271,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-checklist-update-display"]; + put: operations['put_core-{_version}-checklist-update-display']; post?: never; delete?: never; options?: never; @@ -5279,7 +5279,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/verify/send": { + '/core/{_version}/verify/send': { parameters: { query?: never; header?: never; @@ -5289,14 +5289,14 @@ export interface paths { get?: never; put?: never; /** Send a verification link. */ - post: operations["post_core-{_version}-verify-send"]; + post: operations['post_core-{_version}-verify-send']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verify/validate": { + '/core/{_version}/verify/validate': { parameters: { query?: never; header?: never; @@ -5306,15 +5306,15 @@ export interface paths { get?: never; put?: never; /** Validate JWT token. */ - post: operations["post_core-{_version}-verify-validate"]; + post: operations['post_core-{_version}-verify-validate']; /** Validate JWT token. */ - delete: operations["delete_core-{_version}-verify-validate"]; + delete: operations['delete_core-{_version}-verify-validate']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verify/email": { + '/core/{_version}/verify/email': { parameters: { query?: never; header?: never; @@ -5324,14 +5324,14 @@ export interface paths { get?: never; put?: never; /** Trigger ownership verification using email only. */ - post: operations["post_core-{_version}-verify-email"]; + post: operations['post_core-{_version}-verify-email']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verify/phone": { + '/core/{_version}/verify/phone': { parameters: { query?: never; header?: never; @@ -5341,14 +5341,14 @@ export interface paths { get?: never; put?: never; /** Trigger ownership verification on phone number only. */ - post: operations["post_core-{_version}-verify-phone"]; + post: operations['post_core-{_version}-verify-phone']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verify/reauth/email": { + '/core/{_version}/verify/reauth/email': { parameters: { query?: never; header?: never; @@ -5358,14 +5358,14 @@ export interface paths { get?: never; put?: never; /** Re-authenticate by verifying email and add Password scope to the session if the verification is successful. */ - post: operations["post_core-{_version}-verify-reauth-email"]; + post: operations['post_core-{_version}-verify-reauth-email']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/verify/reauth/phone": { + '/core/{_version}/verify/reauth/phone': { parameters: { query?: never; header?: never; @@ -5375,14 +5375,14 @@ export interface paths { get?: never; put?: never; /** Re-authenticate by verifying phone and add Password scope to the session if the verification is successful. */ - post: operations["post_core-{_version}-verify-reauth-phone"]; + post: operations['post_core-{_version}-verify-reauth-phone']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/notifications": { + '/core/{_version}/notifications': { parameters: { query?: never; header?: never; @@ -5390,7 +5390,7 @@ export interface paths { cookie?: never; }; /** Get all the notifications. */ - get: operations["get_core-{_version}-notifications"]; + get: operations['get_core-{_version}-notifications']; put?: never; post?: never; delete?: never; @@ -5399,7 +5399,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/v4/labels/{enc_id}": { + '/core/v4/labels/{enc_id}': { parameters: { query?: never; header?: never; @@ -5413,10 +5413,10 @@ export interface paths { options?: never; head?: never; /** Patch existing label. */ - patch: operations["patch_core-v4-labels-{enc_id}"]; + patch: operations['patch_core-v4-labels-{enc_id}']; trace?: never; }; - "/core/v4/labels/by-ids": { + '/core/v4/labels/by-ids': { parameters: { query?: never; header?: never; @@ -5426,14 +5426,14 @@ export interface paths { get?: never; put?: never; /** Get user labels by IDs. */ - post: operations["post_core-v4-labels-by-ids"]; + post: operations['post_core-v4-labels-by-ids']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/labels": { + '/core/{_version}/labels': { parameters: { query?: never; header?: never; @@ -5441,18 +5441,18 @@ export interface paths { cookie?: never; }; /** Get user's labels. */ - get: operations["get_core-{_version}-labels"]; + get: operations['get_core-{_version}-labels']; put?: never; /** Create new label. */ - post: operations["post_core-{_version}-labels"]; + post: operations['post_core-{_version}-labels']; /** Delete multiple labels. */ - delete: operations["delete_core-{_version}-labels"]; + delete: operations['delete_core-{_version}-labels']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/labels/available": { + '/core/{_version}/labels/available': { parameters: { query?: never; header?: never; @@ -5468,7 +5468,7 @@ export interface paths { * * The name can't be a reserved name like `Inbox`, `Sent`, ... */ - get: operations["get_core-{_version}-labels-available"]; + get: operations['get_core-{_version}-labels-available']; put?: never; post?: never; delete?: never; @@ -5477,7 +5477,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/labels/order": { + '/core/{_version}/labels/order': { parameters: { query?: never; header?: never; @@ -5486,7 +5486,7 @@ export interface paths { }; get?: never; /** Change label priority. */ - put: operations["put_core-{_version}-labels-order"]; + put: operations['put_core-{_version}-labels-order']; post?: never; delete?: never; options?: never; @@ -5494,7 +5494,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/labels/order/tree/{startLabelId}": { + '/core/{_version}/labels/order/tree/{startLabelId}': { parameters: { query?: never; header?: never; @@ -5502,7 +5502,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["put_core-{_version}-labels-order-tree-{startLabelId}"]; + put: operations['put_core-{_version}-labels-order-tree-{startLabelId}']; post?: never; delete?: never; options?: never; @@ -5510,7 +5510,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/labels/{id}": { + '/core/{_version}/labels/{id}': { parameters: { query?: never; header?: never; @@ -5519,7 +5519,7 @@ export interface paths { }; get?: never; /** Update existing label. */ - put: operations["put_core-{_version}-labels-{id}"]; + put: operations['put_core-{_version}-labels-{id}']; post?: never; delete?: never; options?: never; @@ -5527,7 +5527,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/labels/{enc_id}": { + '/core/{_version}/labels/{enc_id}': { parameters: { query?: never; header?: never; @@ -5538,13 +5538,13 @@ export interface paths { put?: never; post?: never; /** Delete a label. */ - delete: operations["delete_core-{_version}-labels-{enc_id}"]; + delete: operations['delete_core-{_version}-labels-{enc_id}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/core/{_version}/labels/{enc_labelID}/detach": { + '/core/{_version}/labels/{enc_labelID}/detach': { parameters: { query?: never; header?: never; @@ -5556,7 +5556,7 @@ export interface paths { * Detach messages from the label. * @description Remove the label from all messages that have it. It deletes the MessageLabels entries in the db. */ - put: operations["put_core-{_version}-labels-{enc_labelID}-detach"]; + put: operations['put_core-{_version}-labels-{enc_labelID}-detach']; post?: never; delete?: never; options?: never; @@ -5564,7 +5564,7 @@ export interface paths { patch?: never; trace?: never; }; - "/core/{_version}/images": { + '/core/{_version}/images': { parameters: { query?: never; header?: never; @@ -5572,7 +5572,7 @@ export interface paths { cookie?: never; }; /** Get image through proxy. */ - get: operations["get_core-{_version}-images"]; + get: operations['get_core-{_version}-images']; put?: never; post?: never; delete?: never; @@ -5591,7 +5591,7 @@ export interface components { */ ResponseCodeSuccess: 1000; ProtonSuccess: { - Code: components["schemas"]["ResponseCodeSuccess"]; + Code: components['schemas']['ResponseCodeSuccess']; }; ProtonError: { /** ErrorCode */ @@ -5616,11 +5616,11 @@ export interface components { DownloadTokenExpirationTimeInSec?: 1800; }; CreateLegacyKeyInput: { - AddressID: components["schemas"]["EncryptedId"]; - PrivateKey: components["schemas"]["PGPPrivateKey"]; + AddressID: components['schemas']['EncryptedId']; + PrivateKey: components['schemas']['PGPPrivateKey']; /** @example 1 */ Primary?: number | null; - SignedKeyList: components["schemas"]["SignedKeyListInput"]; + SignedKeyList: components['schemas']['SignedKeyListInput']; AddressForwardingID: Record; /** @default null */ GroupMemberID: Record | null; @@ -5651,9 +5651,9 @@ export interface components { * @example -----BEGIN PGP MESSAGE-----.* */ OrgActivationToken: string; - AddressKeys: components["schemas"]["AddressKeyInput5"][]; - Auth: components["schemas"]["AuthInput2"]; - AddressList: components["schemas"]["KTAddressListTransformer"]; + AddressKeys: components['schemas']['AddressKeyInput5'][]; + Auth: components['schemas']['AuthInput2']; + AddressList: components['schemas']['KTAddressListTransformer']; /** * @description base64 encoded AES-GCM encrypted secret using the DeviceSecret as key * @example dzOtLW5psxgB8oNc8On...oFRykab4EW1ka3GtQPF9x @@ -5661,7 +5661,7 @@ export interface components { EncryptedSecret: string; }; SignedKeyListInputWrapper: { - SignedKeyList: components["schemas"]["SignedKeyListInput"]; + SignedKeyList: components['schemas']['SignedKeyListInput']; }; UpdateKeyInput: { /** @example */ @@ -5683,7 +5683,7 @@ export interface components { * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ OrganizationKey: string; - Auth: components["schemas"]["AuthInput2"]; + Auth: components['schemas']['AuthInput2']; /** * @description Optional, for inline re-authentication * @example @@ -5704,7 +5704,7 @@ export interface components { * @example 123456 or recovery code */ TwoFactorCode: string; - FIDO2: components["schemas"]["Fido2Input"]; + FIDO2: components['schemas']['Fido2Input']; /** * @description Required only when the session is SSO, base64 encoded AES-GCM encrypted secret using the DeviceSecret as key * @example @@ -5734,7 +5734,7 @@ export interface components { * @default light * @enum {string} */ - Mode: "light" | "dark"; + Mode: 'light' | 'dark'; /** * The bimi-selector of the message * @default default @@ -5752,7 +5752,7 @@ export interface components { * @default null * @enum {string|null} */ - Format: "png" | null; + Format: 'png' | null; ComputedAddress: string; }; CreateAddressInput: { @@ -5775,14 +5775,14 @@ export interface components { Signature: string; MemberID: Record; RequesterMemberId?: number | null; - AddressList: components["schemas"]["KTAddressListTransformer"]; + AddressList: components['schemas']['KTAddressListTransformer']; }; ReorderAddressesInput: { /** @description Will amend the order of addresses with the order of the corresponding AddressIDs */ AddressIDs: string[]; }; AddressListInput: { - AddressList: components["schemas"]["KTAddressListTransformer"]; + AddressList: components['schemas']['KTAddressListTransformer']; }; ChangeAddressTypeInput: { /** @@ -5791,7 +5791,7 @@ export interface components { */ Type: number; /** @default null */ - SignedKeyList: components["schemas"]["SignedKeyListInput"] | null; + SignedKeyList: components['schemas']['SignedKeyListInput'] | null; }; RenameUnverifiedAddressInput: { /** @example me */ @@ -5801,7 +5801,7 @@ export interface components { * @example funoccupied.com */ Domain: string; - AddressList: components["schemas"]["KTAddressListTransformer"]; + AddressList: components['schemas']['KTAddressListTransformer']; AddressKeys: { /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ ID?: string; @@ -5826,7 +5826,7 @@ export interface components { Encrypt: number; /** @example 1 */ Sign: number; - SignedKeyList: components["schemas"]["KTKeyList"]; + SignedKeyList: components['schemas']['KTKeyList']; }; AddressIdsInput: { /** @description List of encrypted addressIDs */ @@ -5837,10 +5837,10 @@ export interface components { /** @description An encrypted ID */ Id: string; ResetAuthDevicesInput: { - AuthDeviceID: components["schemas"]["Id"]; - EncryptedSecret: components["schemas"]["BinaryString"]; + AuthDeviceID: components['schemas']['Id']; + EncryptedSecret: components['schemas']['BinaryString']; /** @description List of re-encrypted user keys secret to random generated secret (32 bytes, then hex encoded) */ - UserKeys: components["schemas"]["ResetAuthDevicesUserKeyDto"][]; + UserKeys: components['schemas']['ResetAuthDevicesUserKeyDto'][]; }; CreateMemberKeysInput: { /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ @@ -5892,7 +5892,7 @@ export interface components { * @example */ AddressID?: string | null; - AddressList: components["schemas"]["KTAddressListTransformer"]; + AddressList: components['schemas']['KTAddressListTransformer']; }; OffsetPagination: { /** The page size */ @@ -5912,29 +5912,29 @@ export interface components { Code: 1000; }; AddGroupMemberRequest: { - Type: components["schemas"]["GroupMemberType"]; - GroupID: components["schemas"]["Id"]; + Type: components['schemas']['GroupMemberType']; + GroupID: components['schemas']['Id']; Email: string; - AddressSignaturePacket: components["schemas"]["PGPSignature"]; - GroupMemberAddressPrivateKey?: components["schemas"]["PGPPrivateKey"] | null; - ActivationToken?: components["schemas"]["PGPMessage"] | null; - ProxyInstances: components["schemas"]["GroupProxyInstance"][]; - Token?: components["schemas"]["PGPMessage"] | null; - Signature?: components["schemas"]["PGPSignature"] | null; + AddressSignaturePacket: components['schemas']['PGPSignature']; + GroupMemberAddressPrivateKey?: components['schemas']['PGPPrivateKey'] | null; + ActivationToken?: components['schemas']['PGPMessage'] | null; + ProxyInstances: components['schemas']['GroupProxyInstance'][]; + Token?: components['schemas']['PGPMessage'] | null; + Signature?: components['schemas']['PGPSignature'] | null; }; CreateGroupRequest: { Email: string; Name: string; - Permissions: components["schemas"]["GroupPermissions"]; - Flags: components["schemas"]["GroupFlags"]; + Permissions: components['schemas']['GroupPermissions']; + Flags: components['schemas']['GroupFlags']; /** @default */ Description: string; }; EditGroupMemberRequest: { - Permissions: components["schemas"]["GroupMemberPermissions"]; + Permissions: components['schemas']['GroupMemberPermissions']; }; ExternalGroupMembershipsResponse: { - Memberships: components["schemas"]["ExternalGroupMembership"][]; + Memberships: components['schemas']['ExternalGroupMembership'][]; Total: number; /** * ProtonResponseCode @@ -5946,7 +5946,7 @@ export interface components { /** @description An encrypted ID */ EncryptedId: string; GroupMembersResponse: { - Members: components["schemas"]["GroupMember"][]; + Members: components['schemas']['GroupMember'][]; Total: number; /** * ProtonResponseCode @@ -5956,7 +5956,7 @@ export interface components { Code: 1000; }; InternalGroupMembershipsResponse: { - Memberships: components["schemas"]["InternalGroupMembership"][]; + Memberships: components['schemas']['InternalGroupMembership'][]; Total: number; /** * ProtonResponseCode @@ -5979,9 +5979,9 @@ export interface components { */ Email: string | null; /** @default null */ - Permissions: components["schemas"]["GroupPermissions"] | null; + Permissions: components['schemas']['GroupPermissions'] | null; /** @default null */ - Flags: components["schemas"]["GroupFlags"] | null; + Flags: components['schemas']['GroupFlags'] | null; /** @default null */ Description: string | null; }; @@ -5992,11 +5992,11 @@ export interface components { UpdateFlagsInput: { /** @example 1 */ Flags: number; - SignedKeyList: components["schemas"]["SignedKeyListInput"]; + SignedKeyList: components['schemas']['SignedKeyListInput']; }; ReplaceAddressTokensInput: { /** @description List of address key tokens encrypted to the primary user key */ - AddressKeyTokens: components["schemas"]["AddressKeyToken"][]; + AddressKeyTokens: components['schemas']['AddressKeyToken'][]; }; MigrateKeyInput: { AddressKeys: { @@ -6009,18 +6009,18 @@ export interface components { /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ Signature?: string; }[]; - SignedKeyLists: components["schemas"]["SignedKeyListInput"][]; + SignedKeyLists: components['schemas']['SignedKeyListInput'][]; }; LegacyKeyInput: { /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ PrivateKey: string; - SignedKeyList: components["schemas"]["SignedKeyListInput"]; + SignedKeyList: components['schemas']['SignedKeyListInput']; }; ReactivateUserKeyInput: { /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ PrivateKey: string; AddressKeyFingerprints: string[]; - SignedKeyLists: components["schemas"]["SignedKeyListInput"][]; + SignedKeyLists: components['schemas']['SignedKeyListInput'][]; }; ResetUserKeyInput: { /** @@ -6055,10 +6055,10 @@ export interface components { * @example ----BEGIN PGP SIGNATURE-----.* */ Signature?: string; - SignedKeyList?: components["schemas"]["SignedKeyListInput"]; + SignedKeyList?: components['schemas']['SignedKeyListInput']; }[]; - Auth: components["schemas"]["AuthInput2"]; - AddressList: components["schemas"]["KTAddressListTransformer"]; + Auth: components['schemas']['AuthInput2']; + AddressList: components['schemas']['KTAddressListTransformer']; /** @default null */ OrgPrimaryUserKey: string | null; /** @default null */ @@ -6077,15 +6077,15 @@ export interface components { /** @example 0 */ MaxVPN: number; /** @description Either 1=PROTON or 2=MANAGED (default) */ - Type?: components["schemas"]["UserType"] | null; + Type?: components['schemas']['UserType'] | null; /** * @description Use only if type is 1=PROTON * @example user_name */ Username: string; /** @description Invitation object if created using magic link */ - Invitation?: components["schemas"]["MagicLinkInvitationInput"] | null; - Auth: components["schemas"]["AuthInfoInput"]; + Invitation?: components['schemas']['MagicLinkInvitationInput'] | null; + Auth: components['schemas']['AuthInfoInput']; /** * @default 0 * @enum {integer} @@ -6116,8 +6116,8 @@ export interface components { }; AcceptMemberUnprivatizationInput: { /** @description The user keys encrypted to the token contained in OrgActivationToken */ - OrgUserKeys: components["schemas"]["PGPPrivateKey"][]; - OrgActivationToken: components["schemas"]["PGPMessage"]; + OrgUserKeys: components['schemas']['PGPPrivateKey'][]; + OrgActivationToken: components['schemas']['PGPMessage']; }; RequestMemberUnprivatizationInput: { /** @@ -6125,13 +6125,13 @@ export interface components { * @example {"Address":"member@internal-domain.com", "Revision":2} */ InvitationData: string; - InvitationSignature: components["schemas"]["PGPSignature"]; + InvitationSignature: components['schemas']['PGPSignature']; }; MemberManagePermissionsDto: { /** @description List of MemberIds */ Ids: string[]; - Permission: components["schemas"]["MemberPermission"]; - Action: components["schemas"]["MemberPermissionAction"]; + Permission: components['schemas']['MemberPermission']; + Action: components['schemas']['MemberPermissionAction']; }; UpdateMemberKeysInput: { /** @@ -6140,7 +6140,7 @@ export interface components { * @example cmFuZGJhc2U2NHN0cmluZw== */ KeySalt: string; - UserKey: components["schemas"]["UserKeyInput"]; + UserKey: components['schemas']['UserKeyInput']; AddressKeys: { /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ AddressID?: string; @@ -6159,16 +6159,16 @@ export interface components { Signature?: string; }; }[]; - AddressList: components["schemas"]["KTAddressListTransformer"]; - Auth: components["schemas"]["AuthInfoInput2"]; + AddressList: components['schemas']['KTAddressListTransformer']; + Auth: components['schemas']['AuthInfoInput2']; }; UnprivatizeMemberInput: { /** @deprecated */ - UserKey?: components["schemas"]["UnprivatizeMemberUserKeyDto"] | null; + UserKey?: components['schemas']['UnprivatizeMemberUserKeyDto'] | null; /** @description All active member's user keys, with a signed and encrypted token to access them via the org key */ - UserKeys?: components["schemas"]["UnprivatizeMemberUserKeyDto"][] | null; + UserKeys?: components['schemas']['UnprivatizeMemberUserKeyDto'][] | null; /** @description A token and signature for each address key to access them via the org key */ - AddressKeys: components["schemas"]["UnprivatizeMemberAddressKeyDto"][]; + AddressKeys: components['schemas']['UnprivatizeMemberAddressKeyDto'][]; }; UpdateOrganizationKeyBackupInput: { /** @@ -6359,23 +6359,23 @@ export interface components { * @default null * @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- */ - Token: components["schemas"]["PGPMessage"] | null; + Token: components['schemas']['PGPMessage'] | null; /** * @description Signature of the token made by the user key of the current user * @default null * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ - Signature: components["schemas"]["PGPSignature"] | null; + Signature: components['schemas']['PGPSignature'] | null; /** * @description Invite all other private admins to the new key * @default null */ - AdminInvitations: components["schemas"]["ReplaceOrganizationKeyInvitationDto"][] | null; + AdminInvitations: components['schemas']['ReplaceOrganizationKeyInvitationDto'][] | null; /** * @description Activate new key for all other non-private admins * @default null */ - AdminActivations: components["schemas"]["ReplaceOrganizationKeyActivationDto"][] | null; + AdminActivations: components['schemas']['ReplaceOrganizationKeyActivationDto'][] | null; }; ActivateOrganizationKeyInput: { /** @@ -6399,23 +6399,23 @@ export interface components { Signature: string | null; }; MigrateOrganizationKeysInput: { - PrivateKey: components["schemas"]["PGPPrivateKey"]; - Token: components["schemas"]["PGPMessage"]; - Signature: components["schemas"]["PGPSignature"]; + PrivateKey: components['schemas']['PGPPrivateKey']; + Token: components['schemas']['PGPMessage']; + Signature: components['schemas']['PGPSignature']; /** * @description Activate key for other active private admins * @default null */ - AdminInvitations: components["schemas"]["MigrateOrganizationKeyInvitationDto"][] | null; + AdminInvitations: components['schemas']['MigrateOrganizationKeyInvitationDto'][] | null; /** * @description Activate new key for all other non-private admins * @default null */ - AdminActivations: components["schemas"]["MigrateOrganizationKeyActivationDto"][] | null; + AdminActivations: components['schemas']['MigrateOrganizationKeyActivationDto'][] | null; }; UpdateOrgKeyFingerprintSignatureInput: { - Signature: components["schemas"]["PGPSignature"]; - AddressID: components["schemas"]["Id"]; + Signature: components['schemas']['PGPSignature']; + AddressID: components['schemas']['Id']; }; OrganizationSettings: { /** @@ -6500,7 +6500,7 @@ export interface components { * @default [] */ EdugainAffiliations: string[]; - SsoId?: components["schemas"]["Id"] | null; + SsoId?: components['schemas']['Id'] | null; SendingSubject: boolean; }; SsoXml: { @@ -6676,7 +6676,7 @@ export interface components { }; /** AIAssistantFlagsInput */ AIAssistantFlagsInput: { - AIAssistantFlags: components["schemas"]["AIAssistantFlags"]; + AIAssistantFlags: components['schemas']['AIAssistantFlags']; }; UpdateMemberLumoEntitlementInput: { /** @enum {integer} */ @@ -6710,22 +6710,22 @@ export interface components { * Format: base64 * @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ - "random-id-1"?: string; + 'random-id-1'?: string; /** * Format: base64 * @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ - "random-id-2"?: string; + 'random-id-2'?: string; /** * Format: base64 * @example */ - "random-id-3"?: string; + 'random-id-3'?: string; /** * Format: base64 * @example */ - "random-id-4"?: string; + 'random-id-4'?: string; } | null; }; SendInvitationsInput: { @@ -6735,13 +6735,13 @@ export interface components { RegisterDeviceInput: { /** @example 2335fcc381ef78a20e580065...515f4e8 */ DeviceToken: string; - Environment: components["schemas"]["Environment"]; + Environment: components['schemas']['Environment']; /** @default null */ - PublicKey: components["schemas"]["PGPPublicKey"] | null; + PublicKey: components['schemas']['PGPPublicKey'] | null; /** @default null */ - PingNotificationStatus: components["schemas"]["PingNotificationStatus"] | null; + PingNotificationStatus: components['schemas']['PingNotificationStatus'] | null; /** @default null */ - PushNotificationStatus: components["schemas"]["PushNotificationStatus"] | null; + PushNotificationStatus: components['schemas']['PushNotificationStatus'] | null; }; UploadAttachment: { /** @@ -6751,7 +6751,7 @@ export interface components { Token: string; /** @description The body of attachment */ Body: string; - Product: components["schemas"]["Product"]; + Product: components['schemas']['Product']; }; CancelPlanReport: { /** @@ -6784,16 +6784,16 @@ export interface components { }; Stream: { /** @default null */ - Users: components["schemas"]["EventCollectionOutput"]; - Addresses: components["schemas"]["EventCollectionOutput"]; - Settings: components["schemas"]["EventCollectionOutput"]; + Users: components['schemas']['EventCollectionOutput']; + Addresses: components['schemas']['EventCollectionOutput']; + Settings: components['schemas']['EventCollectionOutput']; /** @default null */ - IncomingDefaults: components["schemas"]["EventCollectionOutput"]; + IncomingDefaults: components['schemas']['EventCollectionOutput']; /** true if there is more events to pull */ More: boolean; /** true if all data should be refreshed */ Refresh: boolean; - EventID: components["schemas"]["Id"]; + EventID: components['schemas']['Id']; /** * ProtonResponseCode * @example 1000 @@ -6837,7 +6837,7 @@ export interface components { Notify: 1 | 0 | null; }; LabelIDs: { - LabelIDs: components["schemas"]["LabelID"][]; + LabelIDs: components['schemas']['LabelID'][]; }; /** Signed Key List */ KTKeyList: { @@ -6915,7 +6915,7 @@ export interface components { * @example 70376905 */ UsedSpace: number; - ProductUsedSpace: components["schemas"]["UserUsage"]; + ProductUsedSpace: components['schemas']['UserUsage']; /** @description 1 when the user's member has an AI seat, 0 otherwise */ NumAI: number; /** @description the number of lumo seats attributed to the user, 0 otherwise */ @@ -6950,18 +6950,18 @@ export interface components { * @example 5 */ Services: number; - Delinquent: components["schemas"]["DelinquentState"]; - Keys: components["schemas"]["UserKey"]; + Delinquent: components['schemas']['DelinquentState']; + Keys: components['schemas']['UserKey']; Flags: { protected?: boolean; - "onboard-checklist-storage-granted"?: boolean; - "has-temporary-password"?: boolean; - "test-account"?: boolean; - "no-login"?: boolean; - "recovery-attempt"?: boolean; + 'onboard-checklist-storage-granted'?: boolean; + 'has-temporary-password'?: boolean; + 'test-account'?: boolean; + 'no-login'?: boolean; + 'recovery-attempt'?: boolean; sso?: boolean; /** @description User have no or only external addresses */ - "no-proton-address"?: boolean; + 'no-proton-address'?: boolean; }; }; UserKey: { @@ -7071,8 +7071,8 @@ export interface components { * @example true */ ProtonMX: Record; - SignedKeyList: components["schemas"]["KTKeyList"]; - Keys: components["schemas"]["AddressKey"][]; + SignedKeyList: components['schemas']['KTKeyList']; + Keys: components['schemas']['AddressKey'][]; /** * @description Bitflags representing noencrypt/nosign * @example 48 @@ -7104,11 +7104,11 @@ export interface components { }; }; AuthDeviceOutput: { - ID: components["schemas"]["Id"]; - State: components["schemas"]["AuthDeviceState"]; + ID: components['schemas']['Id']; + State: components['schemas']['AuthDeviceState']; /** @description The device name */ Name: string; - LocalizedClientName: components["schemas"]["TranslatedStringInterface"]; + LocalizedClientName: components['schemas']['TranslatedStringInterface']; /** @description The device platform */ Platform?: string | null; /** @@ -7132,9 +7132,9 @@ export interface components { */ LastActivityTime: string; /** @description PGP message encrypted to the AddressID containing a 64-char random hex-encoded token */ - ActivationToken?: components["schemas"]["PGPMessage"] | null; - ActivationAddressID?: components["schemas"]["Id"] | null; - MemberID?: components["schemas"]["Id"] | null; + ActivationToken?: components['schemas']['PGPMessage'] | null; + ActivationAddressID?: components['schemas']['Id'] | null; + MemberID?: components['schemas']['Id'] | null; /** * @description DeviceToken of the created device * @example wfih0367aa7dc0359bf5c42d15a93e6c @@ -7225,30 +7225,30 @@ export interface components { }; Flags: { /** @description If the domain is intended to be used for custom addresses */ - "mail-intent"?: boolean; + 'mail-intent'?: boolean; /** @description If the domain is intended to be used for SSO integration */ - "sso-intent"?: boolean; + 'sso-intent'?: boolean; }; }; /** GroupMemberResponse */ GroupMemberResponse: { - ID: components["schemas"]["Id"]; - Type: components["schemas"]["GroupMemberType"]; - State: components["schemas"]["GroupMemberState"]; + ID: components['schemas']['Id']; + Type: components['schemas']['GroupMemberType']; + State: components['schemas']['GroupMemberState']; CreateTime: number; - GroupID: components["schemas"]["Id"]; - AddressID?: components["schemas"]["Id"] | null; + GroupID: components['schemas']['Id']; + AddressID?: components['schemas']['Id'] | null; Email?: string | null; - Permissions: components["schemas"]["GroupMemberPermissions"]; + Permissions: components['schemas']['GroupMemberPermissions']; }; /** GroupResponse */ GroupResponse: { - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; Name: string; Address: unknown[]; - Permissions: components["schemas"]["GroupPermissions"]; + Permissions: components['schemas']['GroupPermissions']; CreateTime: number; - Flags: components["schemas"]["GroupFlags"]; + Flags: components['schemas']['GroupFlags']; Description?: string | null; }; MemberInfo: { @@ -7304,7 +7304,7 @@ export interface components { * @description bit map: 1=TOTP, 2=FIDO2 * @example 3 */ - "2faStatus": number; + '2faStatus': number; Keys: string[]; /** @example -----BEGIN PUBLIC KEY BLOCK-----.*-----END PUBLIC KEY BLOCK----- */ PublicKey: string; @@ -7329,9 +7329,9 @@ export interface components { UpdateMemberRoleInput: { Role: number; /** @default null */ - OrganizationKeyInvitation: components["schemas"]["OrganizationKeyInvitationDto"] | null; + OrganizationKeyInvitation: components['schemas']['OrganizationKeyInvitationDto'] | null; /** @default null */ - OrganizationKeyActivation: components["schemas"]["OrganizationKeyActivationDto"] | null; + OrganizationKeyActivation: components['schemas']['OrganizationKeyActivationDto'] | null; }; GetMemberUnprivatizationOutput: { /** @description State of the Unprivatization (0: declined), 1: pending, 2: ready */ @@ -7342,15 +7342,15 @@ export interface components { */ InvitationData?: string | null; /** @description InvitationData signed with org key */ - InvitationSignature?: components["schemas"]["PGPSignature"] | null; + InvitationSignature?: components['schemas']['PGPSignature'] | null; /** @description Email to send the invitation to */ InvitationEmail?: string | null; /** @description Administrator email */ AdminEmail: string; /** @description Fingerprint of the org key signed with primary address key */ - OrgKeyFingerprintSignature?: components["schemas"]["PGPSignature"] | null; + OrgKeyFingerprintSignature?: components['schemas']['PGPSignature'] | null; /** @description Organization public key */ - OrgPublicKey?: components["schemas"]["PGPPublicKey"] | null; + OrgPublicKey?: components['schemas']['PGPPublicKey'] | null; /** @description Whether the member should remain private after creation or be unprivatized */ PrivateIntent: boolean; }; @@ -7369,7 +7369,7 @@ export interface components { * @example 1683644736 */ Time: number; - Status: components["schemas"]["AuthLogStatus"]; + Status: components['schemas']['AuthLogStatus']; /** * @description Various values. See AuthLogEvent constants. * @example 23 @@ -7396,7 +7396,7 @@ export interface components { * See AuthLogProtection enum for possible values. * @example 1 */ - Protection?: components["schemas"]["AuthLogProtection"] | null; + Protection?: components['schemas']['AuthLogProtection'] | null; /** * @description Localized description of protection applied. * Can be missing. Only present if user has High Security enabled. @@ -7467,7 +7467,7 @@ export interface components { * @description 0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation * @example 1 */ - AccessToOrgKey?: components["schemas"]["MemberOrgKeyStatus"] | null; + AccessToOrgKey?: components['schemas']['MemberOrgKeyStatus'] | null; /** @description Whether the organization has passwordless keys or not */ Passwordless: boolean; }; @@ -7547,7 +7547,7 @@ export interface components { /** @example 0 */ Reset?: number; }; - "2FA": { + '2FA': { /** * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both * @example 3 @@ -7573,7 +7573,7 @@ export interface components { Compromised?: number; }[]; /** @description Contains the user's currently registered FIDO2 credentials. */ - RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + RegisteredKeys?: components['schemas']['Fido2RegisteredKey'][]; }; /** * @description Bitmap informing which news the user is subscribed to: @@ -7602,7 +7602,7 @@ export interface components { * @example 0 */ Density: number; - Theme: components["schemas"]["Theme2"]; + Theme: components['schemas']['Theme2']; /** @example 1 */ ThemeType: number; /** @@ -7766,7 +7766,7 @@ export interface components { */ Mode: string; SessionUID: string; - Session?: components["schemas"]["Session"] | null; + Session?: components['schemas']['Session'] | null; UserID: number; UserName: string; MaxTier: number; @@ -7791,7 +7791,7 @@ export interface components { * @example enumeration * @enum {string} */ - Type: "boolean" | "integer" | "float" | "string" | "enumeration" | "mixed"; + Type: 'boolean' | 'integer' | 'float' | 'string' | 'enumeration' | 'mixed'; /** @example 1 */ Minimum: Record; /** @example 100 */ @@ -7890,22 +7890,22 @@ export interface components { * Format: base64 * @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ - "random-id-1"?: string; + 'random-id-1'?: string; /** * Format: base64 * @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ - "random-id-2"?: string; + 'random-id-2'?: string; /** * Format: base64 * @example */ - "random-id-3"?: string; + 'random-id-3'?: string; /** * Format: base64 * @example */ - "random-id-4"?: string; + 'random-id-4'?: string; } | null; /** * @deprecated @@ -7986,7 +7986,7 @@ export interface components { EmailsAvailable: number; }; GetUserInvitationsOutput: { - UserInvitations: components["schemas"]["GetUserInvitationOutput"][]; + UserInvitations: components['schemas']['GetUserInvitationOutput'][]; }; GetUserInvitationOutput: { /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ @@ -7999,10 +7999,10 @@ export interface components { OrganizationName: string; /** @example family2022 | passfamily2024 */ OrganizationPlanName: string; - Validation: components["schemas"]["AcceptInvitationValidation"]; + Validation: components['schemas']['AcceptInvitationValidation']; }; EventInfo: { - Code: components["schemas"]["ResponseCodeSuccess"]; + Code: components['schemas']['ResponseCodeSuccess']; /** * Format: byte * @example ACXDmTaBub14w== @@ -8028,7 +8028,7 @@ export interface components { * @enum {integer} */ Action?: 0 | 1 | 2 | 3; - Message?: components["schemas"]["MessageInfo"] & { + Message?: components['schemas']['MessageInfo'] & { /** @deprecated */ LabelIDsAdded?: string[]; /** @deprecated */ @@ -8043,88 +8043,88 @@ export interface components { Conversation?: { /** @example AJuSqm0qvIL4LSMR9LWsqNO...a2OlAU_Iqr2Qcducsz-ZA== */ AddressID?: string; - } & components["schemas"]["Conversation"] & { - LabelIDsAdded?: string[]; - LabelIDsRemoved?: string[]; - /** - * @deprecated - * @description Not available in the Events API - */ - LabelIDs?: string[]; - } & components["schemas"]["AttachmentsMetadata"]; + } & components['schemas']['Conversation'] & { + LabelIDsAdded?: string[]; + LabelIDsRemoved?: string[]; + /** + * @deprecated + * @description Not available in the Events API + */ + LabelIDs?: string[]; + } & components['schemas']['AttachmentsMetadata']; }[]; Importers: { /** @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEhjBbUPDMHGU699fw== */ ID?: string; /** @example 1 */ Action?: number; - Importer?: components["schemas"]["ImporterTransformer"]; + Importer?: components['schemas']['ImporterTransformer']; }[]; ImportReports: { /** @example ARy95iNxhniEgYJrRrGv...F49RxFpMqWE_ZGDXEvGV2CEkA== */ ID?: string; /** @example 1 */ Action?: number; - ImportReport?: components["schemas"]["ImportReportTransformer"]; + ImportReport?: components['schemas']['ImportReportTransformer']; }[]; Contacts: { /** @example afeaefaeTaBub14w== */ ID?: string; /** @example 1 */ Action?: number; - Contact?: components["schemas"]["Contact"]; + Contact?: components['schemas']['Contact']; }[]; ContactEmails: { /** @example sadfaACXDmTaBub14w== */ ID?: string; /** @example 1 */ Action?: number; - ContactEmail?: components["schemas"]["ContactEmail"]; + ContactEmail?: components['schemas']['ContactEmail']; }[]; Filters: { /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ ID?: string; /** @example 1 */ Action?: number; - Filter?: components["schemas"]["FilterOutput"]; + Filter?: components['schemas']['FilterOutput']; }[]; IncomingDefaults: { /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ ID?: string; /** @example 1 */ Action?: number; - Filter?: components["schemas"]["IncomingDefault"]; + Filter?: components['schemas']['IncomingDefault']; }[]; OrgIncomingDefaults: { /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ ID?: string; /** @example 1 */ Action?: number; - OrgIncomingDefault?: components["schemas"]["IncomingDefaultResponse"]; + OrgIncomingDefault?: components['schemas']['IncomingDefaultResponse']; }[]; Labels: { /** @example sadfaACXDmTaBub14w== */ ID?: string; /** @example 1 */ Action?: number; - Label?: components["schemas"]["Label"]; + Label?: components['schemas']['Label']; }[]; - Subscription: components["schemas"]["Subscription"]; - User: components["schemas"]["User"] & { - AccountRecovery?: components["schemas"]["AccountRecoveryAttempt"]; + Subscription: components['schemas']['Subscription']; + User: components['schemas']['User'] & { + AccountRecovery?: components['schemas']['AccountRecoveryAttempt']; }; - UserSettings: components["schemas"]["UserSettingsTransformer"]; - MailSettings: components["schemas"]["Response"]; + UserSettings: components['schemas']['UserSettingsTransformer']; + MailSettings: components['schemas']['Response']; VPNSettings: { /** @example test-group */ GroupID?: string; - } & components["schemas"]["VPNSettings"]; + } & components['schemas']['VPNSettings']; Invoices: { /** @example IlnTbqicN-...-4NvrrIc6GLvDv28aKYVRRrSgEFhR_zhlkA== */ ID?: string; /** @example 1 */ Action?: number; - Invoice?: components["schemas"]["Invoice"]; + Invoice?: components['schemas']['Invoice']; }[]; Members: { /** @example LO9aACXDmTaBub14w== */ @@ -8160,23 +8160,23 @@ export interface components { ID?: string; /** @example 2 */ Action?: number; - Domain?: components["schemas"]["DomainTransformer"]; + Domain?: components['schemas']['DomainTransformer']; }[]; - Addresses: components["schemas"]["AddressUser"][]; - SignedAddressList?: components["schemas"]["KTAddressListTransformer"] | null; + Addresses: components['schemas']['AddressUser'][]; + SignedAddressList?: components['schemas']['KTAddressListTransformer'] | null; IncomingAddressForwardings: { /** @example 9aACXDmTaBub14w== */ ID?: string; /** @example 2 */ Action?: number; - IncomingAddressForwarding?: components["schemas"]["IncomingAddressForwardingResponse"]; + IncomingAddressForwarding?: components['schemas']['IncomingAddressForwardingResponse']; }[]; OutgoingAddressForwardings: { /** @example 9aACXDmTaBub14w== */ ID?: string; /** @example 2 */ Action?: number; - OutgoingAddressForwarding?: components["schemas"]["OutgoingAddressForwardingResponse"]; + OutgoingAddressForwarding?: components['schemas']['OutgoingAddressForwardingResponse']; }[]; Organization: { /** @example E-Corp */ @@ -8255,28 +8255,28 @@ export interface components { * @example 70376905 */ UsedSpace: number; - ProductUsedSpace: components["schemas"]["UserUsage"]; + ProductUsedSpace: components['schemas']['UserUsage']; VPNProfiles: { /** @example q_9v-GXEPLagg81jsUz2mHQ== */ ID?: string; /** @example 2 */ Action?: number; - VPNProfile?: components["schemas"]["VPNProfile"]; + VPNProfile?: components['schemas']['VPNProfile']; }[]; - LogicalServers: components["schemas"]["VPNLogical"]; + LogicalServers: components['schemas']['VPNLogical']; Calendars: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ ID?: string; /** @example 1 */ Action?: number; - Calendar?: components["schemas"]["CalendarWithMemberWithFlagsOutput"]; + Calendar?: components['schemas']['CalendarWithMemberWithFlagsOutput']; }[]; CalendarMembers: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ ID?: string; /** @example 1 */ Action?: number; - Member?: components["schemas"]["MemberWithFlagsOutput"]; + Member?: components['schemas']['MemberWithFlagsOutput']; }[]; Pushes: { /** @example 1H8EGg3J1QpSDL6K8hGs...hrHx6nnGQ== */ @@ -8292,53 +8292,53 @@ export interface components { */ Type?: string; }[]; - Notifications: components["schemas"]["EventLoopNotificationTransformer"][]; - CalendarUserSettings: components["schemas"]["UserSettingsTransformer2"]; + Notifications: components['schemas']['EventLoopNotificationTransformer'][]; + CalendarUserSettings: components['schemas']['UserSettingsTransformer2']; Wallets: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ ID?: string; /** @example 1 */ Action?: number; - Wallet?: components["schemas"]["WalletOutput"]; + Wallet?: components['schemas']['WalletOutput']; }[]; WalletAccounts: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ ID?: string; /** @example 1 */ Action?: number; - WalletAccount?: components["schemas"]["WalletAccountOutput"]; + WalletAccount?: components['schemas']['WalletAccountOutput']; }[]; WalletBitcoinAddresses: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ ID?: string; /** @example 1 */ Action?: number; - WalletBitcoinAddress?: components["schemas"]["WalletBitcoinAddressOutput"]; + WalletBitcoinAddress?: components['schemas']['WalletBitcoinAddressOutput']; }[]; WalletKeys: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ ID?: string; /** @example 1 */ Action?: number; - WalletKey?: components["schemas"]["WalletKeyOutput"]; + WalletKey?: components['schemas']['WalletKeyOutput']; }[]; WalletSettings: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ ID?: string; /** @example 1 */ Action?: number; - WalletSettings?: components["schemas"]["WalletSettingsOutput"]; + WalletSettings?: components['schemas']['WalletSettingsOutput']; }[]; WalletTransactions: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ ID?: string; /** @example 1 */ Action?: number; - WalletTransaction?: components["schemas"]["WalletTransactionOutput"]; + WalletTransaction?: components['schemas']['WalletTransactionOutput']; }[]; - WalletUserSettings: components["schemas"]["WalletUserSettingsOutput"]; + WalletUserSettings: components['schemas']['WalletUserSettingsOutput']; Notices: string[]; - } & components["schemas"]["DriveShareRefreshCoreEventService"]; + } & components['schemas']['DriveShareRefreshCoreEventService']; NotificationVersionTransformer: { /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ NotificationID: string; @@ -8416,7 +8416,7 @@ export interface components { Token: string; /** @example -----BEGIN PGP SIGNATURE-----.* */ Signature: string; - SignedKeyList: components["schemas"]["SignedKeyListInput"]; + SignedKeyList: components['schemas']['SignedKeyListInput']; /** @example 3 */ Revision: Record; }; @@ -8445,8 +8445,8 @@ export interface components { /** @description Base64 encoded binary data */ BinaryString: string; ResetAuthDevicesUserKeyDto: { - ID: components["schemas"]["EncryptedId"]; - PrivateKey: components["schemas"]["PGPPrivateKey"]; + ID: components['schemas']['EncryptedId']; + PrivateKey: components['schemas']['PGPPrivateKey']; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Internal
1External
2InternalTypeExternal
@@ -8479,45 +8479,45 @@ export interface components { */ GroupMemberPermissions: 0 | 1 | 2 | 3; ExternalGroupMembership: { - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; /** Format: date-time */ CreateTime: string; - State: components["schemas"]["GroupMemberState"]; - Type: components["schemas"]["GroupMemberType"]; + State: components['schemas']['GroupMemberState']; + Type: components['schemas']['GroupMemberType']; Email?: string | null; - Permissions: components["schemas"]["GroupMemberPermissions"]; + Permissions: components['schemas']['GroupMemberPermissions']; /** Format: date-time */ JoinTime?: string | null; - Group: components["schemas"]["GroupMembershipGroup"]; + Group: components['schemas']['GroupMembershipGroup']; }; GroupMember: { - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; /** Format: date-time */ CreateTime: string; - GroupID: components["schemas"]["Id"]; - State: components["schemas"]["GroupMemberState"]; - Type: components["schemas"]["GroupMemberType"]; - AddressID?: components["schemas"]["Id"] | null; + GroupID: components['schemas']['Id']; + State: components['schemas']['GroupMemberState']; + Type: components['schemas']['GroupMemberType']; + AddressID?: components['schemas']['Id'] | null; Email?: string | null; - Permissions: components["schemas"]["GroupMemberPermissions"]; + Permissions: components['schemas']['GroupMemberPermissions']; }; InternalGroupMembership: { - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; /** Format: date-time */ CreateTime: string; - State: components["schemas"]["GroupMemberState"]; - Type: components["schemas"]["GroupMemberType"]; - AddressId?: components["schemas"]["Id"] | null; + State: components['schemas']['GroupMemberState']; + Type: components['schemas']['GroupMemberType']; + AddressId?: components['schemas']['Id'] | null; Email?: string | null; - Permissions: components["schemas"]["GroupMemberPermissions"]; + Permissions: components['schemas']['GroupMemberPermissions']; /** Format: date-time */ JoinTime?: string | null; - TokenKeyPacket?: components["schemas"]["BinaryString"] | null; - TokenSignaturePacket?: components["schemas"]["BinaryString"] | null; - AddressSignaturePacket?: components["schemas"]["BinaryString"] | null; - Group: components["schemas"]["GroupMembershipGroup"]; - ForwardingKeys: components["schemas"]["ForwardingKeys"]; - GroupID: components["schemas"]["Id"]; + TokenKeyPacket?: components['schemas']['BinaryString'] | null; + TokenSignaturePacket?: components['schemas']['BinaryString'] | null; + AddressSignaturePacket?: components['schemas']['BinaryString'] | null; + Group: components['schemas']['GroupMembershipGroup']; + ForwardingKeys: components['schemas']['ForwardingKeys']; + GroupID: components['schemas']['Id']; }; AddressKeyToken: { /** @@ -8547,7 +8547,7 @@ export interface components { * @example {"Address":"member@internal-domain.com", "Revision":2} */ Data: Record; - Signature?: components["schemas"]["PGPSignature"] | null; + Signature?: components['schemas']['PGPSignature'] | null; /** * @description The email to send an invitation to * @example some.user@example.com @@ -8567,8 +8567,8 @@ export interface components { */ MemberPermissionAction: 0 | 1; UserKeyInput: { - PrivateKey: components["schemas"]["PGPPrivateKey"]; - OrgPrivateKey: components["schemas"]["PGPPrivateKey"]; + PrivateKey: components['schemas']['PGPPrivateKey']; + OrgPrivateKey: components['schemas']['PGPPrivateKey']; /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ OrgToken: string; }; @@ -8586,35 +8586,35 @@ export interface components { Verifier: string; }; UnprivatizeMemberUserKeyDto: { - OrgPrivateKey: components["schemas"]["PGPPrivateKey"]; - OrgToken: components["schemas"]["PGPMessage"]; + OrgPrivateKey: components['schemas']['PGPPrivateKey']; + OrgToken: components['schemas']['PGPMessage']; }; UnprivatizeMemberAddressKeyDto: { - AddressKeyID: components["schemas"]["Id"]; - OrgTokenKeyPacket: components["schemas"]["BinaryString"]; - OrgSignature: components["schemas"]["PGPSignature"]; + AddressKeyID: components['schemas']['Id']; + OrgTokenKeyPacket: components['schemas']['BinaryString']; + OrgSignature: components['schemas']['PGPSignature']; }; ReplaceOrganizationKeyInvitationDto: { - MemberID: components["schemas"]["Id"]; - TokenKeyPacket: components["schemas"]["BinaryString"]; - Signature: components["schemas"]["PGPSignature"]; - SignatureAddressID: components["schemas"]["Id"]; - EncryptionAddressID: components["schemas"]["Id"]; + MemberID: components['schemas']['Id']; + TokenKeyPacket: components['schemas']['BinaryString']; + Signature: components['schemas']['PGPSignature']; + SignatureAddressID: components['schemas']['Id']; + EncryptionAddressID: components['schemas']['Id']; }; ReplaceOrganizationKeyActivationDto: { - MemberID: components["schemas"]["Id"]; - TokenKeyPacket: components["schemas"]["BinaryString"]; - Signature: components["schemas"]["PGPSignature"]; + MemberID: components['schemas']['Id']; + TokenKeyPacket: components['schemas']['BinaryString']; + Signature: components['schemas']['PGPSignature']; }; MigrateOrganizationKeyInvitationDto: { - MemberID: components["schemas"]["Id"]; - TokenKeyPacket: components["schemas"]["BinaryString"]; - Signature: components["schemas"]["PGPSignature"]; + MemberID: components['schemas']['Id']; + TokenKeyPacket: components['schemas']['BinaryString']; + Signature: components['schemas']['PGPSignature']; }; MigrateOrganizationKeyActivationDto: { - MemberID: components["schemas"]["Id"]; - TokenKeyPacket: components["schemas"]["BinaryString"]; - Signature: components["schemas"]["PGPSignature"]; + MemberID: components['schemas']['Id']; + TokenKeyPacket: components['schemas']['BinaryString']; + Signature: components['schemas']['PGPSignature']; }; /** * @description

Possible values:
- 0: Unset
- 1: Off
- 2: Server-Only
- 3: Client-Only

See values descriptions
See values descriptions
ValueDescription
0Unset
1Off
2ServerOnly
3ClientOnly
@@ -8643,7 +8643,7 @@ export interface components { * @enum {integer} */ Product: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; - EventCollectionOutput: components["schemas"]["EventOutput"][]; + EventCollectionOutput: components['schemas']['EventOutput'][]; /** @description An encrypted Label ID and default integer Label ID */ LabelID: string; /** Product used space */ @@ -8755,19 +8755,19 @@ export interface components { */ GroupMemberState: 0 | 1 | 2 | 3 | 4; OrganizationKeyInvitationDto: { - TokenKeyPacket: components["schemas"]["BinaryString"]; + TokenKeyPacket: components['schemas']['BinaryString']; /** @description Signature of the token key packet by the inviters address key */ Signature: string; - SignatureAddressID: components["schemas"]["EncryptedId"]; - EncryptionAddressID: components["schemas"]["EncryptedId"]; + SignatureAddressID: components['schemas']['EncryptedId']; + EncryptionAddressID: components['schemas']['EncryptedId']; }; OrganizationKeyActivationDto: { - TokenKeyPacket: components["schemas"]["BinaryString"]; + TokenKeyPacket: components['schemas']['BinaryString']; /** @description Signature of the token key packet by the user key of the member */ Signature: string; }; /** @enum {string} */ - AuthLogStatus: "success" | "attempt" | "failure"; + AuthLogStatus: 'success' | 'attempt' | 'failure'; /** * @description

ID of protection applied.
* Can be missing. Only present if user has High Security enabled.
@@ -8836,10 +8836,10 @@ export interface components { * @example Me */ SenderName: string; - Sender: components["schemas"]["Sender"]; - ToList: components["schemas"]["Recipient"]; - CcList: components["schemas"]["Recipient"]; - BccList: components["schemas"]["Recipient"]; + Sender: components['schemas']['Sender']; + ToList: components['schemas']['Recipient']; + CcList: components['schemas']['Recipient']; + BccList: components['schemas']['Recipient']; /** @example 1433890289 */ Time: number; /** @example 1433890289 */ @@ -8912,8 +8912,8 @@ export interface components { * @example 8198 */ Flags: number; - AttachmentInfo: components["schemas"]["GroupedAttachmentsCount"]; - AttachmentsMetadata: components["schemas"]["Metadata"][]; + AttachmentInfo: components['schemas']['GroupedAttachmentsCount']; + AttachmentsMetadata: components['schemas']['Metadata'][]; /** * @deprecated * @description Deprecated, check Sender.* properties @@ -8952,9 +8952,9 @@ export interface components { */ Subject: string; /** @description The list of senders */ - Senders: components["schemas"]["Sender2"][]; + Senders: components['schemas']['Sender2'][]; /** @description The list of recipients */ - Recipients: components["schemas"]["Recipient2"][]; + Recipients: components['schemas']['Recipient2'][]; /** * @description The number of messages in the conversation. * @example 5 @@ -9024,8 +9024,8 @@ export interface components { BimiSelector?: string | null; }; AttachmentsMetadata: { - AttachmentInfo: components["schemas"]["GroupedAttachmentsCount2"]; - AttachmentsMetadata: components["schemas"]["Metadata2"][]; + AttachmentInfo: components['schemas']['GroupedAttachmentsCount2']; + AttachmentsMetadata: components['schemas']['Metadata2'][]; }; /** Importer */ ImporterTransformer: { @@ -9072,13 +9072,13 @@ export interface components { /** @example 76844 */ INBOX: number; /** @example 0 */ - "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043D\u044B\u0435": number; + '\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043D\u044B\u0435': number; /** @example 0 */ - "\u0427\u0435\u0440\u043D\u043E\u0432\u0438\u043A\u0438": number; + '\u0427\u0435\u0440\u043D\u043E\u0432\u0438\u043A\u0438': number; /** @example 0 */ - "INBOX/Social": number; + 'INBOX/Social': number; /** @example 0 */ - "INBOX/Newsletters": number; + 'INBOX/Newsletters': number; }; /** ImportReport */ ImportReportTransformer: { @@ -9177,11 +9177,11 @@ export interface components { */ ModifyTime: number; /** @description List of emails, only included when returning one record */ - ContactEmails: components["schemas"]["ContactEmail"][]; + ContactEmails: components['schemas']['ContactEmail'][]; /** @description Labels on Contact, ignore, maybe future feature */ LabelIDs: string[]; /** @description Only included when returning one record */ - Cards: components["schemas"]["ContactData"][]; + Cards: components['schemas']['ContactData'][]; }; ContactEmail: { /** @@ -9217,7 +9217,7 @@ export interface components { IsProton: number; }; FilterOutput: { - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; Name: string; /** @example 1 */ Status: number; @@ -9231,7 +9231,7 @@ export interface components { * keep; * } */ Sieve: string; - Tree: components["schemas"]["Tree"]; + Tree: components['schemas']['Tree']; /** @example 1 */ Version: number; }; @@ -9261,7 +9261,7 @@ export interface components { Currency: string; /** @example 1500 */ Amount: number; - Plans: components["schemas"]["Plan"][]; + Plans: components['schemas']['Plan'][]; /** @example 1 */ Renew: boolean; }; @@ -9743,29 +9743,29 @@ export interface components { }; /** IncomingAddressForwardingResponse */ IncomingAddressForwardingResponse: { - ID: components["schemas"]["Id"]; - Type: components["schemas"]["AddressForwardingType"]; - State: components["schemas"]["AddressForwardingState"]; + ID: components['schemas']['Id']; + Type: components['schemas']['AddressForwardingType']; + State: components['schemas']['AddressForwardingState']; /** When an email is received by forwarderEmail, it will be forwarded to forwardeeEmail or forwardeeAddressID */ ForwarderEmail: string; - ForwardeeAddressID: components["schemas"]["Id"]; + ForwardeeAddressID: components['schemas']['Id']; CreateTime: number; /** The forwarding keys encrypted to the tokens. They are present only for encrypted forwarding * in the pending state. To activate the forwarding all of them must be re-encrypted to the user * keys and added to the correct address keyring. */ - ForwardingKeys: components["schemas"]["ActivationForwardingKey"][]; - Filter?: components["schemas"]["AddressForwardingFilter"] | null; + ForwardingKeys: components['schemas']['ActivationForwardingKey'][]; + Filter?: components['schemas']['AddressForwardingFilter'] | null; }; /** OutgoingAddressForwardingResponse */ OutgoingAddressForwardingResponse: { - ID: components["schemas"]["Id"]; - Type: components["schemas"]["AddressForwardingType"]; - State: components["schemas"]["AddressForwardingState"]; - ForwarderAddressID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; + Type: components['schemas']['AddressForwardingType']; + State: components['schemas']['AddressForwardingState']; + ForwarderAddressID: components['schemas']['Id']; /** The final email address to forward messages to * */ ForwardeeEmail: string; CreateTime: number; - Filter?: components["schemas"]["AddressForwardingFilter"] | null; + Filter?: components['schemas']['AddressForwardingFilter'] | null; }; VPNProfile: Record; VPNLogical: { @@ -9835,7 +9835,7 @@ export interface components { * @example Stockholm */ City?: number | null; - Servers: components["schemas"]["VPNServerTransformerInterface"][]; + Servers: components['schemas']['VPNServerTransformerInterface'][]; /** * @description Describe in a spiritual way how much the logical server is loaded * @example 0 @@ -9861,10 +9861,10 @@ export interface components { Score: Record; }; CalendarWithMemberWithFlagsOutput: { - Members: components["schemas"]["MemberWithFlagsOutput"][]; - ID: components["schemas"]["Id"]; - Type: components["schemas"]["CalendarType"]; - Owner: components["schemas"]["CalendarOwner"]; + Members: components['schemas']['MemberWithFlagsOutput'][]; + ID: components['schemas']['Id']; + Type: components['schemas']['CalendarType']; + Owner: components['schemas']['CalendarOwner']; /** Format: date-time */ CreateTime: string; }; @@ -9874,7 +9874,7 @@ export interface components { * @example 1 */ Flags: number; - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; /** * @description Flags bitmap:
- `1`: Super-owner
- `2`: Owner
- `4`: Admin
- `8`: Read member list
- `16`: Write events
- `32`: Read events (full details)
- `64`: Availability view only
* @example 63 @@ -9882,8 +9882,8 @@ export interface components { Permissions: number; /** @example andy@pm.me */ Email: string; - AddressId: components["schemas"]["Id"]; - CalendarId: components["schemas"]["Id"]; + AddressId: components['schemas']['Id']; + CalendarId: components['schemas']['Id']; /** @example Organizational Calendar */ Name: string; /** @example This text describes the calendar */ @@ -9979,7 +9979,7 @@ export interface components { ShareBusySchedule: number; }; WalletOutput: { - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; /** * @description 1 if the wallet has a passphrase * @example 0 @@ -9995,13 +9995,13 @@ export interface components { * @description Encrypted wallet mnemonic with the WalletKey, in base64 format * @example */ - Mnemonic?: components["schemas"]["BinaryString"] | null; + Mnemonic?: components['schemas']['BinaryString'] | null; /** * @description Unique identifier of the mnemonic, using the first 4 bytes of the master public key hash * @example 912914fb */ Fingerprint?: string | null; - Name: components["schemas"]["BinaryString"]; + Name: components['schemas']['BinaryString']; /** * @description Order of priority * @example 1 @@ -10012,9 +10012,9 @@ export interface components { * @description Encrypted wallet public key with the WalletKey, in base64 format, only if on-chain watch-only * @example */ - PublicKey?: components["schemas"]["BinaryString"] | null; - Status: components["schemas"]["WalletStatus"]; - Type: components["schemas"]["WalletType"]; + PublicKey?: components['schemas']['BinaryString'] | null; + Status: components['schemas']['WalletStatus']; + Type: components['schemas']['WalletType']; /** * @description Set to 1 if wallet key needs to be rotated * @example 0 @@ -10027,15 +10027,15 @@ export interface components { Legacy: number; }; WalletAccountOutput: { - ID: components["schemas"]["Id"]; - WalletID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; + WalletID: components['schemas']['Id']; /** * @description Preferred fiat currency * @example CHF */ FiatCurrency: string; - DerivationPath: components["schemas"]["DerivationPath"]; - Label: components["schemas"]["BinaryString"]; + DerivationPath: components['schemas']['DerivationPath']; + Label: components['schemas']['BinaryString']; /** @description The index number that wallet last used to create address */ LastUsedIndex: number; /** @@ -10048,23 +10048,23 @@ export interface components { * @example 1 */ Priority: number; - ScriptType: components["schemas"]["ScriptType"]; + ScriptType: components['schemas']['ScriptType']; Addresses: unknown[]; }; WalletBitcoinAddressOutput: { - ID: components["schemas"]["Id"]; - WalletID: components["schemas"]["Id"]; - WalletAccountID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; + WalletID: components['schemas']['Id']; + WalletAccountID: components['schemas']['Id']; Fetched: number; Used: number; /** @default null */ - BitcoinAddress: components["schemas"]["BitcoinAddress"] | null; + BitcoinAddress: components['schemas']['BitcoinAddress'] | null; /** * @description Detached signature of the bitcoin address * @default null * @example -----BEGIN PGP SIGNATURE-----... */ - BitcoinAddressSignature: components["schemas"]["PGPSignature"] | null; + BitcoinAddressSignature: components['schemas']['PGPSignature'] | null; /** * @description Index of the bitcoin address * @default null @@ -10073,9 +10073,9 @@ export interface components { BitcoinAddressIndex: number | null; }; WalletKeyOutput: { - ID: components["schemas"]["Id"]; - WalletID: components["schemas"]["Id"]; - UserKeyID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; + WalletID: components['schemas']['Id']; + UserKeyID: components['schemas']['Id']; /** * @description Encrypted AES-GCM 256 key used to encrypt the mnemonic or public key, as armored PGP * @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- @@ -10088,7 +10088,7 @@ export interface components { WalletKeySignature: string; }; WalletSettingsOutput: { - WalletID: components["schemas"]["Id"]; + WalletID: components['schemas']['Id']; /** * @description Hide accounts, only used for on-chain wallet * @example 0 @@ -10116,10 +10116,10 @@ export interface components { ShowWalletRecovery: boolean; }; WalletTransactionOutput: { - ID: components["schemas"]["Id"]; - WalletID: components["schemas"]["Id"]; - WalletAccountID: components["schemas"]["Id"]; - TransactionID: components["schemas"]["PGPMessage"]; + ID: components['schemas']['Id']; + WalletID: components['schemas']['Id']; + WalletAccountID: components['schemas']['Id']; + TransactionID: components['schemas']['PGPMessage']; /** * @description Unix timestamp of when the transaction got created in Proton Wallet or confirmed in blockchain for incoming ones * @example 1707287982 @@ -10131,20 +10131,20 @@ export interface components { IsPrivate: number; /** @description Set to 1 if user did not want to reveal its identify during sending */ IsAnonymous: number; - Type: components["schemas"]["TransactionType"]; - HashedTransactionID?: components["schemas"]["BinaryString"] | null; + Type: components['schemas']['TransactionType']; + HashedTransactionID?: components['schemas']['BinaryString'] | null; /** @default null */ - Label: components["schemas"]["BinaryString"] | null; + Label: components['schemas']['BinaryString'] | null; /** @default null */ - ExchangeRate: components["schemas"]["ExchangeRateOutput"] | null; + ExchangeRate: components['schemas']['ExchangeRateOutput'] | null; /** @default null */ - Sender: components["schemas"]["PGPMessage"] | null; + Sender: components['schemas']['PGPMessage'] | null; /** @default null */ - ToList: components["schemas"]["PGPMessage"] | null; + ToList: components['schemas']['PGPMessage'] | null; /** @default null */ - Subject: components["schemas"]["PGPMessage"] | null; + Subject: components['schemas']['PGPMessage'] | null; /** @default null */ - Body: components["schemas"]["PGPMessage"] | null; + Body: components['schemas']['PGPMessage'] | null; }; WalletUserSettingsOutput: { /** @@ -10200,17 +10200,17 @@ export interface components { }; }; GroupMembershipGroup: { - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; Name: string; Address: string; }; ForwardingKeys: { - PrivateKey?: components["schemas"]["PGPPrivateKey"] | null; - ActivationToken?: components["schemas"]["PGPMessage"] | null; + PrivateKey?: components['schemas']['PGPPrivateKey'] | null; + ActivationToken?: components['schemas']['PGPMessage'] | null; }; EventOutput: { - ID?: components["schemas"]["Id"] | null; - Action: components["schemas"]["EventAction"]; + ID?: components['schemas']['Id'] | null; + Action: components['schemas']['EventAction']; }; Sender: { /** @example foo@protonmail.dev */ @@ -10267,46 +10267,46 @@ export interface components { /** @description Attachment counts grouped by the MIME type and disposition. * Listed types here are an example */ GroupedAttachmentsCount: { - "image/jpeg": { + 'image/jpeg': { /** @example 2 */ inline?: number; /** @example 1 */ attachment?: number; }; - "text/calendar": { + 'text/calendar': { /** @example 1 */ attachment?: number; }; }; Metadata: { - ID: components["schemas"]["Id2"]; + ID: components['schemas']['Id2']; Name?: string | null; Size: number; MIMEType: string; - Disposition?: components["schemas"]["Disposition"] | null; + Disposition?: components['schemas']['Disposition'] | null; }; Sender2: Record; Recipient2: Record; /** @description Attachment counts grouped by the MIME type and disposition. * Listed types here are an example */ GroupedAttachmentsCount2: { - "image/jpeg": { + 'image/jpeg': { /** @example 2 */ inline?: number; /** @example 1 */ attachment?: number; }; - "text/calendar": { + 'text/calendar': { /** @example 1 */ attachment?: number; }; }; Metadata2: { - ID: components["schemas"]["Id3"]; + ID: components['schemas']['Id3']; Name?: string | null; Size: number; MIMEType: string; - Disposition?: components["schemas"]["Disposition2"] | null; + Disposition?: components['schemas']['Disposition2'] | null; }; ContactData: { /** @@ -10412,9 +10412,9 @@ export interface components { }; /** AddressForwardingFilter */ AddressForwardingFilter: { - Tree: components["schemas"]["Tree"]; + Tree: components['schemas']['Tree']; Sieve: string; - Version: components["schemas"]["SieveVersion"]; + Version: components['schemas']['SieveVersion']; }; VPNServerTransformerInterface: { /** @@ -10575,7 +10575,7 @@ export interface components { */ TransactionType: 1 | 2 | 3 | 4; ExchangeRateOutput: { - ID: components["schemas"]["Id"]; + ID: components['schemas']['Id']; /** * @description Bitcoin unit of the exchange rate * @example BTC @@ -10615,11 +10615,11 @@ export interface components { /** @description An encrypted ID */ Id2: string; /** @enum {string} */ - Disposition: "attachment" | "inline"; + Disposition: 'attachment' | 'inline'; /** @description An encrypted ID */ Id3: string; /** @enum {string} */ - Disposition2: "attachment" | "inline"; + Disposition2: 'attachment' | 'inline'; /** * @description

See values descriptions
See values descriptions
ValueDescription
2V2
* @enum {integer} @@ -10631,11 +10631,11 @@ export interface components { ProtonSuccessResponse: { headers: { /** @description The same as the body code */ - "X-Pm-Code"?: 1000; + 'X-Pm-Code'?: 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonSuccess"]; + 'application/json': components['schemas']['ProtonSuccess']; }; }; /** @description General Error */ @@ -10644,7 +10644,7 @@ export interface components { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonError"]; + 'application/json': components['schemas']['ProtonError']; }; }; }; @@ -10655,7 +10655,7 @@ export interface components { } export type $defs = Record; export interface operations { - "get_core-{_version}-addresses-allowAddressDeletion": { + 'get_core-{_version}-addresses-allowAddressDeletion': { parameters: { query?: never; header?: never; @@ -10674,7 +10674,7 @@ export interface operations { }; }; }; - "put_core-{_version}-keys-address-active": { + 'put_core-{_version}-keys-address-active': { parameters: { query?: never; header?: never; @@ -10685,7 +10685,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description The address ID * @example ACXDmTa...Bub14w== @@ -10719,15 +10719,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SignedKeyList?: components["schemas"]["KTKeyList"] | null; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SignedKeyList?: components['schemas']['KTKeyList'] | null; }; }; }; }; }; - "get_core-{_version}-keys": { + 'get_core-{_version}-keys': { parameters: { query?: { Email?: string; @@ -10747,8 +10747,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description 1:Internal, 2:External * @example 1 @@ -10778,7 +10778,7 @@ export interface operations { */ Source?: 0 | 1 | 2; }[]; - SignedKeyList?: components["schemas"]["KTKeyList"]; + SignedKeyList?: components['schemas']['KTKeyList']; /** @example [] */ Warnings?: string[]; /** @@ -10791,7 +10791,7 @@ export interface operations { }; }; }; - "post_core-{_version}-keys": { + 'post_core-{_version}-keys': { parameters: { query?: never; header?: never; @@ -10802,7 +10802,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateLegacyKeyInput"]; + 'application/json': components['schemas']['CreateLegacyKeyInput']; }; }; responses: { @@ -10812,8 +10812,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Key?: { /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ ID?: string; @@ -10840,7 +10840,7 @@ export interface operations { }; }; }; - "post_core-{_version}-keys-address": { + 'post_core-{_version}-keys-address': { parameters: { query?: never; header?: never; @@ -10851,7 +10851,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example xRvCGwFq_TW7...i8FtJaGyFEq0g== */ AddressID?: string; /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ @@ -10880,8 +10880,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Key?: { /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ ID?: string; @@ -10910,7 +10910,7 @@ export interface operations { }; }; }; - "post_core-{_version}-keys-group": { + 'post_core-{_version}-keys-group': { parameters: { query?: never; header?: never; @@ -10921,7 +10921,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example xRvCGwFq_TW7...i8FtJaGyFEq0g== */ AddressID?: string; /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ @@ -10946,8 +10946,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Key?: { /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ ID?: string; @@ -10972,7 +10972,7 @@ export interface operations { }; }; }; - "post_core-{_version}-keys-setup": { + 'post_core-{_version}-keys-setup': { parameters: { query?: { /** @@ -10989,7 +10989,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SetupKeyInput"]; + 'application/json': components['schemas']['SetupKeyInput']; }; }; responses: { @@ -10999,10 +10999,10 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - User?: components["schemas"]["User"] & { - Keys?: components["schemas"]["UserKey"] & { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + User?: components['schemas']['User'] & { + Keys?: components['schemas']['UserKey'] & { /** @example 3 */ Flags?: number; }; @@ -11024,7 +11024,7 @@ export interface operations { }; }; }; - "put_core-{_version}-keys-{enc_id}-delete": { + 'put_core-{_version}-keys-{enc_id}-delete': { parameters: { query?: never; header?: never; @@ -11040,7 +11040,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SignedKeyListInputWrapper"]; + 'application/json': components['schemas']['SignedKeyListInputWrapper']; }; }; responses: { @@ -11050,14 +11050,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-keys-address-{enc_id}-delete": { + 'post_core-{_version}-keys-address-{enc_id}-delete': { parameters: { query?: never; header?: never; @@ -11073,7 +11073,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SignedKeyListInputWrapper"]; + 'application/json': components['schemas']['SignedKeyListInputWrapper']; }; }; responses: { @@ -11083,14 +11083,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-keys-private": { + 'put_core-{_version}-keys-private': { parameters: { query?: never; header?: never; @@ -11101,7 +11101,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateKeyInput"]; + 'application/json': components['schemas']['UpdateKeyInput']; }; }; responses: { @@ -11111,8 +11111,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -11123,21 +11123,21 @@ export interface operations { }; }; }; - "get_core-{_version}-images-logo": { + 'get_core-{_version}-images-logo': { parameters: { query?: { /** @example noreply%40amazon.com */ - Address?: components["schemas"]["LogoRequest"]["Address"]; + Address?: components['schemas']['LogoRequest']['Address']; /** @example amazon.com */ - Domain?: components["schemas"]["LogoRequest"]["Domain"]; + Domain?: components['schemas']['LogoRequest']['Domain']; /** @example 64 */ - Size?: components["schemas"]["LogoRequest"]["Size"]; - Mode?: components["schemas"]["LogoRequest"]["Mode"]; - BimiSelector?: components["schemas"]["LogoRequest"]["BimiSelector"]; + Size?: components['schemas']['LogoRequest']['Size']; + Mode?: components['schemas']['LogoRequest']['Mode']; + BimiSelector?: components['schemas']['LogoRequest']['BimiSelector']; /** @example 2 */ - MaxScaleUpFactor?: components["schemas"]["LogoRequest"]["MaxScaleUpFactor"]; - Format?: components["schemas"]["LogoRequest"]["Format"]; - ComputedAddress?: components["schemas"]["LogoRequest"]["ComputedAddress"]; + MaxScaleUpFactor?: components['schemas']['LogoRequest']['MaxScaleUpFactor']; + Format?: components['schemas']['LogoRequest']['Format']; + ComputedAddress?: components['schemas']['LogoRequest']['ComputedAddress']; }; header?: never; path: { @@ -11153,7 +11153,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/octet-stream": string; + 'application/octet-stream': string; }; }; /** @description Return an empty image when we cannot find a valid logo */ @@ -11165,7 +11165,7 @@ export interface operations { }; }; }; - "get_core-{_version}-members-{enc_id}-addresses": { + 'get_core-{_version}-members-{enc_id}-addresses': { parameters: { query?: { /** @@ -11199,15 +11199,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Addresses?: components["schemas"]["AddressUser"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Addresses?: components['schemas']['AddressUser'][]; }; }; }; }; }; - "post_core-{_version}-members-{enc_id}-addresses": { + 'post_core-{_version}-members-{enc_id}-addresses': { parameters: { query?: never; header?: never; @@ -11219,7 +11219,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateAddressInput"]; + 'application/json': components['schemas']['CreateAddressInput']; }; }; responses: { @@ -11229,9 +11229,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Address?: components["schemas"]["AddressUser"] & { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Address?: components['schemas']['AddressUser'] & { /** @example Fred */ MemberName?: string; /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ @@ -11242,11 +11242,11 @@ export interface operations { }; }; }; - "get_core-{_version}-addresses": { + 'get_core-{_version}-addresses': { parameters: { query?: { - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path: { @@ -11262,16 +11262,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Addresses?: components["schemas"]["AddressUser"][]; - SignedAddressList?: components["schemas"]["KTAddressListTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Addresses?: components['schemas']['AddressUser'][]; + SignedAddressList?: components['schemas']['KTAddressListTransformer']; }; }; }; }; }; - "post_core-{_version}-addresses": { + 'post_core-{_version}-addresses': { parameters: { query?: never; header?: never; @@ -11282,7 +11282,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateAddressInput"]; + 'application/json': components['schemas']['CreateAddressInput']; }; }; responses: { @@ -11292,9 +11292,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Address?: components["schemas"]["AddressUser"] & { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Address?: components['schemas']['AddressUser'] & { /** @example Fred */ MemberName?: string; /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ @@ -11305,7 +11305,7 @@ export interface operations { }; }; }; - "post_core-{_version}-members-addresses-available": { + 'post_core-{_version}-members-addresses-available': { parameters: { query?: never; header?: never; @@ -11316,7 +11316,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateAddressInput"]; + 'application/json': components['schemas']['CreateAddressInput']; }; }; responses: { @@ -11329,7 +11329,7 @@ export interface operations { }; }; }; - "put_core-{_version}-addresses-order": { + 'put_core-{_version}-addresses-order': { parameters: { query?: never; header?: never; @@ -11340,7 +11340,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ReorderAddressesInput"]; + 'application/json': components['schemas']['ReorderAddressesInput']; }; }; responses: { @@ -11350,14 +11350,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-addresses-setup": { + 'post_core-{_version}-addresses-setup': { parameters: { query?: never; header?: never; @@ -11368,7 +11368,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateAddressInput"]; + 'application/json': components['schemas']['CreateAddressInput']; }; }; responses: { @@ -11378,8 +11378,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Address?: { /** @example vuGSa1zsx0kV0jsfhX_xKSDQ0dvcLdMduA_c2c9fhaC1ZYCZKe8gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ ID?: string; @@ -11420,7 +11420,7 @@ export interface operations { }; }; }; - "get_core-{_version}-addresses-canonical": { + 'get_core-{_version}-addresses-canonical': { parameters: { query?: { /** @description The list of email addresses, limited to maximum 100. They must be url encoded. */ @@ -11440,14 +11440,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 1001 */ Code?: number; Responses?: { /** @example john.doe+friend@gmail.com */ Email?: string; Response?: { - Code?: components["schemas"]["ResponseCodeSuccess"]; + Code?: components['schemas']['ResponseCodeSuccess']; /** @example johndoe@gmail.com */ CanonicalEmail?: string; }; @@ -11457,7 +11457,7 @@ export interface operations { }; }; }; - "get_core-{_version}-addresses-{enc_id}": { + 'get_core-{_version}-addresses-{enc_id}': { parameters: { query?: never; header?: never; @@ -11479,15 +11479,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Address?: components["schemas"]["AddressUser"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Address?: components['schemas']['AddressUser']; }; }; }; }; }; - "put_core-{_version}-addresses-{enc_id}": { + 'put_core-{_version}-addresses-{enc_id}': { parameters: { query?: never; header?: never; @@ -11503,7 +11503,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateAddressInput"]; + 'application/json': components['schemas']['UpdateAddressInput']; }; }; responses: { @@ -11513,14 +11513,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-addresses-{enc_id}": { + 'delete_core-{_version}-addresses-{enc_id}': { parameters: { query?: never; header?: never; @@ -11536,7 +11536,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressListInput"]; + 'application/json': components['schemas']['AddressListInput']; }; }; responses: { @@ -11546,14 +11546,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-domains-{enc_id}-addresses": { + 'get_core-{_version}-domains-{enc_id}-addresses': { parameters: { query?: { /** @@ -11587,8 +11587,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Addresses?: (components["schemas"]["AddressUser"] & { + 'application/json': { + Addresses?: (components['schemas']['AddressUser'] & { /** * @description whether this is the catch-all address for this domain * @example 0 @@ -11606,7 +11606,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Domain does not exist */ @@ -11617,7 +11617,7 @@ export interface operations { }; }; }; - "get_core-{_version}-domains-{enc_id}-claimedAddresses": { + 'get_core-{_version}-domains-{enc_id}-claimedAddresses': { parameters: { query?: { /** @@ -11651,8 +11651,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Addresses?: { /** @example john.doe+friend@mydomain.com */ Email?: string; @@ -11666,7 +11666,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Domain does not exist */ @@ -11677,7 +11677,7 @@ export interface operations { }; }; }; - "put_core-{_version}-addresses-{enc_id}-enable": { + 'put_core-{_version}-addresses-{enc_id}-enable': { parameters: { query?: never; header?: never; @@ -11693,7 +11693,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressListInput"]; + 'application/json': components['schemas']['AddressListInput']; }; }; responses: { @@ -11703,14 +11703,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-addresses-{enc_id}-disable": { + 'put_core-{_version}-addresses-{enc_id}-disable': { parameters: { query?: never; header?: never; @@ -11726,7 +11726,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressListInput"]; + 'application/json': components['schemas']['AddressListInput']; }; }; responses: { @@ -11736,14 +11736,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-addresses-{enc_id}-delete": { + 'put_core-{_version}-addresses-{enc_id}-delete': { parameters: { query?: never; header?: never; @@ -11759,7 +11759,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressListInput"]; + 'application/json': components['schemas']['AddressListInput']; }; }; responses: { @@ -11769,14 +11769,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-addresses-{enc_id}-type": { + 'put_core-{_version}-addresses-{enc_id}-type': { parameters: { query?: never; header?: never; @@ -11792,7 +11792,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ChangeAddressTypeInput"]; + 'application/json': components['schemas']['ChangeAddressTypeInput']; }; }; responses: { @@ -11802,14 +11802,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-addresses-{enc_id}-rename-internal": { + 'put_core-{_version}-addresses-{enc_id}-rename-internal': { parameters: { query?: never; header?: never; @@ -11825,7 +11825,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example john.doe */ Local?: string; AddressKeys?: { @@ -11844,14 +11844,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-addresses-{enc_id}-rename-external": { + 'put_core-{_version}-addresses-{enc_id}-rename-external': { parameters: { query?: never; header?: never; @@ -11867,7 +11867,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RenameUnverifiedAddressInput"]; + 'application/json': components['schemas']['RenameUnverifiedAddressInput']; }; }; responses: { @@ -11877,14 +11877,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-addresses-{enc_addressId}-encryption": { + 'put_core-{_version}-addresses-{enc_addressId}-encryption': { parameters: { query?: never; header?: never; @@ -11901,7 +11901,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateEncryptionSignatureFlagsInput"]; + 'application/json': components['schemas']['UpdateEncryptionSignatureFlagsInput']; }; }; responses: { @@ -11911,14 +11911,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-members-addresses-permissions-organization-switch": { + 'put_core-{_version}-members-addresses-permissions-organization-switch': { parameters: { query?: never; header?: never; @@ -11929,7 +11929,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressIdsInput"]; + 'application/json': components['schemas']['AddressIdsInput']; }; }; responses: { @@ -11939,22 +11939,22 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["SwitchAddressesOrganizationPermissionsTransformer"][]; + Responses?: components['schemas']['SwitchAddressesOrganizationPermissionsTransformer'][]; }; }; }; }; }; - "post_core-{_version}-members-{memberId}-saml": { + 'post_core-{_version}-members-{memberId}-saml': { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components["schemas"]["Id"]; + memberId: components['schemas']['Id']; }; cookie?: never; }; @@ -11966,20 +11966,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-members-{memberId}-saml": { + 'delete_core-{_version}-members-{memberId}-saml': { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components["schemas"]["Id"]; + memberId: components['schemas']['Id']; }; cookie?: never; }; @@ -11991,21 +11991,21 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-members-{memberId}-devices-{deviceId}": { + 'delete_core-{_version}-members-{memberId}-devices-{deviceId}': { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components["schemas"]["Id"]; - deviceId: components["schemas"]["Id"]; + memberId: components['schemas']['Id']; + deviceId: components['schemas']['Id']; }; cookie?: never; }; @@ -12017,20 +12017,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-members-{memberId}-devices": { + 'delete_core-{_version}-members-{memberId}-devices': { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components["schemas"]["Id"]; + memberId: components['schemas']['Id']; }; cookie?: never; }; @@ -12042,14 +12042,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-members-{id}-devices": { + 'get_core-{_version}-members-{id}-devices': { parameters: { query?: never; header?: never; @@ -12060,7 +12060,7 @@ export interface operations { */ memberid: string; _version: string; - id: components["schemas"]["Id"]; + id: components['schemas']['Id']; }; cookie?: never; }; @@ -12072,15 +12072,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + AuthDevices?: components['schemas']['AuthDeviceOutput'][]; }; }; }; }; }; - "get_core-{_version}-members-devices-pending": { + 'get_core-{_version}-members-devices-pending': { parameters: { query?: never; header?: never; @@ -12097,15 +12097,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + AuthDevices?: components['schemas']['AuthDeviceOutput'][]; }; }; }; }; }; - "put_core-{_version}-members-{memberId}-devices-{deviceId}-reject": { + 'put_core-{_version}-members-{memberId}-devices-{deviceId}-reject': { parameters: { query?: never; header?: never; @@ -12119,9 +12119,9 @@ export interface operations { * @description the device id * @example ACXDmTaBub14w== */ - deviceId: components["schemas"]["Id"]; + deviceId: components['schemas']['Id']; _version: string; - memberId: components["schemas"]["Id"]; + memberId: components['schemas']['Id']; }; cookie?: never; }; @@ -12133,14 +12133,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-members-{memberId}-devices-reset": { + 'post_core-{_version}-members-{memberId}-devices-reset': { parameters: { query?: never; header?: never; @@ -12151,13 +12151,13 @@ export interface operations { */ memberid: string; _version: string; - memberId: components["schemas"]["Id"]; + memberId: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["ResetAuthDevicesInput"]; + 'application/json': components['schemas']['ResetAuthDevicesInput']; }; }; responses: { @@ -12167,14 +12167,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-members-{enc_id}-keys": { + 'post_core-{_version}-members-{enc_id}-keys': { parameters: { query?: never; header?: never; @@ -12186,7 +12186,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateMemberKeysInput"]; + 'application/json': components['schemas']['CreateMemberKeysInput']; }; }; responses: { @@ -12196,8 +12196,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; MemberKey?: { /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ ID?: string; @@ -12222,7 +12222,7 @@ export interface operations { }; }; }; - "get_core-{_version}-organizations-scim": { + 'get_core-{_version}-organizations-scim': { parameters: { query?: never; header?: never; @@ -12241,7 +12241,7 @@ export interface operations { }; }; }; - "put_core-{_version}-organizations-scim": { + 'put_core-{_version}-organizations-scim': { parameters: { query?: never; header?: never; @@ -12252,7 +12252,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateScimTenantInput"]; + 'application/json': components['schemas']['UpdateScimTenantInput']; }; }; responses: { @@ -12264,7 +12264,7 @@ export interface operations { }; }; }; - "post_core-{_version}-organizations-scim": { + 'post_core-{_version}-organizations-scim': { parameters: { query?: never; header?: never; @@ -12275,7 +12275,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateScimTenantInput"]; + 'application/json': components['schemas']['CreateScimTenantInput']; }; }; responses: { @@ -12287,7 +12287,7 @@ export interface operations { }; }; }; - "post_core-{_version}-keys-user": { + 'post_core-{_version}-keys-user': { parameters: { query?: never; header?: never; @@ -12298,7 +12298,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddNewUserKeyInput"]; + 'application/json': components['schemas']['AddNewUserKeyInput']; }; }; responses: { @@ -12308,8 +12308,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ KeyID?: string; }; @@ -12317,7 +12317,7 @@ export interface operations { }; }; }; - "get_core-{_version}-domains": { + 'get_core-{_version}-domains': { parameters: { query?: never; header?: never; @@ -12334,15 +12334,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Domains?: components["schemas"]["DomainTransformer"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Domains?: components['schemas']['DomainTransformer'][]; }; }; }; }; }; - "post_core-{_version}-domains": { + 'post_core-{_version}-domains': { parameters: { query?: never; header?: never; @@ -12353,7 +12353,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateDomainInput"]; + 'application/json': components['schemas']['CreateDomainInput']; }; }; responses: { @@ -12363,15 +12363,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Domain?: components["schemas"]["DomainTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Domain?: components['schemas']['DomainTransformer']; }; }; }; }; }; - "get_core-{_version}-domains-available": { + 'get_core-{_version}-domains-available': { parameters: { query?: { Type?: string | null; @@ -12390,15 +12390,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Domains?: string[]; }; }; }; }; }; - "get_core-{_version}-domains-premium": { + 'get_core-{_version}-domains-premium': { parameters: { query?: never; header?: never; @@ -12415,15 +12415,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Domains?: string[]; }; }; }; }; }; - "get_core-{_version}-domains-optin": { + 'get_core-{_version}-domains-optin': { parameters: { query?: never; header?: never; @@ -12440,8 +12440,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example proton.me */ Domain?: string; }; @@ -12449,7 +12449,7 @@ export interface operations { }; }; }; - "get_core-{_version}-domains-{enc_id}": { + 'get_core-{_version}-domains-{enc_id}': { parameters: { query?: never; header?: never; @@ -12471,15 +12471,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Domain?: components["schemas"]["DomainTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Domain?: components['schemas']['DomainTransformer']; }; }; }; }; }; - "delete_core-{_version}-domains-{enc_id}": { + 'delete_core-{_version}-domains-{enc_id}': { parameters: { query?: never; header?: never; @@ -12501,14 +12501,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-domains-{enc_id}-catchall": { + 'put_core-{_version}-domains-{enc_id}-catchall': { parameters: { query?: never; header?: never; @@ -12524,7 +12524,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateCatchAllAddressInput"]; + 'application/json': components['schemas']['UpdateCatchAllAddressInput']; }; }; responses: { @@ -12534,14 +12534,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-organizations": { + 'get_core-{_version}-organizations': { parameters: { query?: never; header?: never; @@ -12558,8 +12558,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Organization?: { /** @example My Org */ Name?: string; @@ -12662,10 +12662,10 @@ export interface operations { }; }; }; - "put_core-{_version}-groups-external-{jwt}": { + 'put_core-{_version}-groups-external-{jwt}': { parameters: { query?: { - GroupID?: components["schemas"]["Id"] | null; + GroupID?: components['schemas']['Id'] | null; }; header?: never; path: { @@ -12679,19 +12679,19 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "delete_core-{_version}-groups-external-{jwt}": { + 'delete_core-{_version}-groups-external-{jwt}': { parameters: { query?: { - GroupID?: components["schemas"]["Id"] | null; + GroupID?: components['schemas']['Id'] | null; }; header?: never; path: { @@ -12705,16 +12705,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "post_core-{_version}-groups-members": { + 'post_core-{_version}-groups-members': { parameters: { query?: never; header?: never; @@ -12725,7 +12725,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddGroupMemberRequest"]; + 'application/json': components['schemas']['AddGroupMemberRequest']; }; }; responses: { @@ -12735,15 +12735,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - GroupMember?: components["schemas"]["GroupMemberResponse"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + GroupMember?: components['schemas']['GroupMemberResponse']; }; }; }; }; }; - "get_core-{_version}-groups": { + 'get_core-{_version}-groups': { parameters: { query?: never; header?: never; @@ -12760,16 +12760,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Total?: number; - Groups?: components["schemas"]["GroupResponse"][]; + Groups?: components['schemas']['GroupResponse'][]; }; }; }; }; }; - "post_core-{_version}-groups": { + 'post_core-{_version}-groups': { parameters: { query?: never; header?: never; @@ -12780,7 +12780,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateGroupRequest"]; + 'application/json': components['schemas']['CreateGroupRequest']; }; }; responses: { @@ -12790,18 +12790,18 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Group?: components["schemas"]["GroupResponse"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Group?: components['schemas']['GroupResponse']; }; }; }; }; }; - "post_core-{_version}-groups-unsubscribe-{jwt}": { + 'post_core-{_version}-groups-unsubscribe-{jwt}': { parameters: { query?: { - GroupID?: components["schemas"]["Id"] | null; + GroupID?: components['schemas']['Id'] | null; }; header?: never; path: { @@ -12815,28 +12815,28 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "put_core-{_version}-groups-{enc_id}": { + 'put_core-{_version}-groups-{enc_id}': { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components["schemas"]["EncryptedId"]; + enc_id: components['schemas']['EncryptedId']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateGroupRequest"]; + 'application/json': components['schemas']['UpdateGroupRequest']; }; }; responses: { @@ -12846,21 +12846,21 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Group?: components["schemas"]["GroupResponse"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Group?: components['schemas']['GroupResponse']; }; }; }; }; }; - "delete_core-{_version}-groups-{enc_id}": { + 'delete_core-{_version}-groups-{enc_id}': { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components["schemas"]["Id"]; + enc_id: components['schemas']['Id']; }; cookie?: never; }; @@ -12872,20 +12872,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-groups-members-{enc_id}": { + 'delete_core-{_version}-groups-members-{enc_id}': { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components["schemas"]["Id"]; + enc_id: components['schemas']['Id']; }; cookie?: never; }; @@ -12897,26 +12897,26 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-groups-members-{groupMemberId}": { + 'put_core-{_version}-groups-members-{groupMemberId}': { parameters: { query?: never; header?: never; path: { _version: string; - groupMemberId: components["schemas"]["Id"]; + groupMemberId: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["EditGroupMemberRequest"]; + 'application/json': components['schemas']['EditGroupMemberRequest']; }; }; responses: { @@ -12926,15 +12926,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - GroupMember?: components["schemas"]["GroupMemberResponse"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + GroupMember?: components['schemas']['GroupMemberResponse']; }; }; }; }; }; - "get_core-v4-groups-members-external-{jwt}": { + 'get_core-v4-groups-members-external-{jwt}': { parameters: { query?: never; header?: never; @@ -12948,24 +12948,24 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ExternalGroupMembershipsResponse"]; + 'application/json': components['schemas']['ExternalGroupMembershipsResponse']; }; }; }; }; - "get_core-v4-groups-{group_enc_id}-members": { + 'get_core-v4-groups-{group_enc_id}-members': { parameters: { query?: { - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path: { - group_enc_id: components["schemas"]["EncryptedId"]; + group_enc_id: components['schemas']['EncryptedId']; }; cookie?: never; }; @@ -12974,16 +12974,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GroupMembersResponse"]; + 'application/json': components['schemas']['GroupMembersResponse']; }; }; }; }; - "get_core-v4-groups-members-internal": { + 'get_core-v4-groups-members-internal': { parameters: { query?: never; header?: never; @@ -12995,22 +12995,22 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InternalGroupMembershipsResponse"]; + 'application/json': components['schemas']['InternalGroupMembershipsResponse']; }; }; }; }; - "put_core-{_version}-groups-{enc_id}-reinvite": { + 'put_core-{_version}-groups-{enc_id}-reinvite': { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components["schemas"]["Id"]; + enc_id: components['schemas']['Id']; }; cookie?: never; }; @@ -13022,20 +13022,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-groups-members-{groupMemberId}-resume": { + 'put_core-{_version}-groups-members-{groupMemberId}-resume': { parameters: { query?: never; header?: never; path: { _version: string; - groupMemberId: components["schemas"]["Id"]; + groupMemberId: components['schemas']['Id']; }; cookie?: never; }; @@ -13047,15 +13047,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - GroupMember?: components["schemas"]["GroupMemberResponse"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + GroupMember?: components['schemas']['GroupMemberResponse']; }; }; }; }; }; - "post_core-{_version}-invites": { + 'post_core-{_version}-invites': { parameters: { query?: never; header?: never; @@ -13066,7 +13066,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example notification@email */ Email?: string; /** @@ -13081,16 +13081,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "post_core-{_version}-invites-unused": { + 'post_core-{_version}-invites-unused': { parameters: { query?: never; header?: never; @@ -13109,7 +13109,7 @@ export interface operations { }; }; }; - "post_core-{_version}-invites-check": { + 'post_core-{_version}-invites-check': { parameters: { query?: never; header?: never; @@ -13128,7 +13128,7 @@ export interface operations { }; }; }; - "get_core-{_version}-keys-all": { + 'get_core-{_version}-keys-all': { parameters: { query: { /** @@ -13140,7 +13140,7 @@ export interface operations { * @description If 1, it will not perform any external lookup, and only provide information from the Proton DB * @example 1 */ - InternalOnly?: "0" | "1"; + InternalOnly?: '0' | '1'; }; header?: never; path: { @@ -13156,7 +13156,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Success code * @example 1000 @@ -13188,7 +13188,7 @@ export interface operations { Source?: number; }[]; /** @description Signed metadata to verify the public key list */ - SignedKeyList?: components["schemas"]["KTKeyList"] | null; + SignedKeyList?: components['schemas']['KTKeyList'] | null; }; /** @description Information about the catch all address itself, if it exists. This can be null if the address keys are valid */ CatchAll?: { @@ -13216,7 +13216,7 @@ export interface operations { Source?: number; }[]; /** @description Signed metadata to verify the public key list */ - SignedKeyList?: components["schemas"]["KTKeyList"] | null; + SignedKeyList?: components['schemas']['KTKeyList'] | null; } | null; /** @description Any other key that cannot be verified, such as Proton legacy keys or WKD. This can be null if there are none. */ Unverified?: { @@ -13266,7 +13266,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Error code 33102 corresponds to a failed lookup. It is returned only when (a) internal only lookup is requested and the user does not exist or (b) when the address is routed towards an internal domain (with valid MX records) and it does not exist internally * @example 33102 @@ -13277,7 +13277,7 @@ export interface operations { }; }; }; - "get_core-{_version}-keys-signedkeylists": { + 'get_core-{_version}-keys-signedkeylists': { parameters: { query?: { /** @deprecated */ @@ -13305,15 +13305,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SignedKeyLists?: components["schemas"]["KTKeyList"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SignedKeyLists?: components['schemas']['KTKeyList'][]; }; }; }; }; }; - "post_core-{_version}-keys-signedkeylists": { + 'post_core-{_version}-keys-signedkeylists': { parameters: { query?: never; header?: never; @@ -13324,7 +13324,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressKeyInput3"]; + 'application/json': components['schemas']['AddressKeyInput3']; }; }; responses: { @@ -13334,15 +13334,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SignedKeyList?: components["schemas"]["KTKeyList"] | null; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SignedKeyList?: components['schemas']['KTKeyList'] | null; }; }; }; }; }; - "get_core-{_version}-keys-signedkeylist": { + 'get_core-{_version}-keys-signedkeylist': { parameters: { query?: { /** @deprecated */ @@ -13365,15 +13365,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SignedKeyList?: components["schemas"]["KTKeyList"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SignedKeyList?: components['schemas']['KTKeyList']; }; }; }; }; }; - "get_core-{_version}-keys-salts": { + 'get_core-{_version}-keys-salts': { parameters: { query?: never; header?: never; @@ -13390,8 +13390,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; KeySalts?: { /** @example */ ID?: string; @@ -13403,7 +13403,7 @@ export interface operations { }; }; }; - "put_core-{_version}-keys-address-{enc_id}": { + 'put_core-{_version}-keys-address-{enc_id}': { parameters: { query?: never; header?: never; @@ -13419,7 +13419,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressKeyInput"]; + 'application/json': components['schemas']['AddressKeyInput']; }; }; responses: { @@ -13429,14 +13429,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-keys-address-{enc_id}-subkeys": { + 'put_core-{_version}-keys-address-{enc_id}-subkeys': { parameters: { query?: never; header?: never; @@ -13448,7 +13448,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressKeyInput2"]; + 'application/json': components['schemas']['AddressKeyInput2']; }; }; responses: { @@ -13458,14 +13458,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-keys-signedkeylists-signature": { + 'put_core-{_version}-keys-signedkeylists-signature': { parameters: { query?: never; header?: never; @@ -13476,7 +13476,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddressKeyInput4"]; + 'application/json': components['schemas']['AddressKeyInput4']; }; }; responses: { @@ -13486,14 +13486,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-keys-{enc_id}-primary": { + 'put_core-{_version}-keys-{enc_id}-primary': { parameters: { query?: never; header?: never; @@ -13509,7 +13509,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SignedKeyListInputWrapper"]; + 'application/json': components['schemas']['SignedKeyListInputWrapper']; }; }; responses: { @@ -13519,14 +13519,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-keys-{enc_id}-flags": { + 'put_core-{_version}-keys-{enc_id}-flags': { parameters: { query?: never; header?: never; @@ -13542,7 +13542,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateFlagsInput"]; + 'application/json': components['schemas']['UpdateFlagsInput']; }; }; responses: { @@ -13552,14 +13552,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-keys-tokens": { + 'put_core-{_version}-keys-tokens': { parameters: { query?: never; header?: never; @@ -13570,7 +13570,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ReplaceAddressTokensInput"]; + 'application/json': components['schemas']['ReplaceAddressTokensInput']; }; }; responses: { @@ -13580,8 +13580,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -13594,7 +13594,7 @@ export interface operations { }; }; }; - "put_core-{_version}-keys-user-{enc_id}": { + 'put_core-{_version}-keys-user-{enc_id}': { parameters: { query?: never; header?: never; @@ -13610,7 +13610,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ReactivateUserKeyInput"]; + 'application/json': components['schemas']['ReactivateUserKeyInput']; }; }; responses: { @@ -13620,14 +13620,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-keys-user-{enc_id}": { + 'delete_core-{_version}-keys-user-{enc_id}': { parameters: { query?: never; header?: never; @@ -13649,8 +13649,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -13663,7 +13663,7 @@ export interface operations { }; }; }; - "post_core-{_version}-keys-private-upgrade": { + 'post_core-{_version}-keys-private-upgrade': { parameters: { query?: never; header?: never; @@ -13674,7 +13674,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example */ KeySalt?: string; Keys?: { @@ -13700,7 +13700,7 @@ export interface operations { Signature?: string; }[]; SignedKeyLists?: { - "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { + 'CasdiSFq_TW7i8FtJGuQyFEq0=='?: { /** @example JSON.stringify([{"SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353","Primary": 1,"Flags": 3}]) */ Data?: string; /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ @@ -13732,14 +13732,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-keys-migrate": { + 'post_core-{_version}-keys-migrate': { parameters: { query?: never; header?: never; @@ -13750,7 +13750,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["MigrateKeyInput"]; + 'application/json': components['schemas']['MigrateKeyInput']; }; }; responses: { @@ -13760,14 +13760,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-keys-{enc_id}-activate": { + 'put_core-{_version}-keys-{enc_id}-activate': { parameters: { query?: never; header?: never; @@ -13779,7 +13779,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LegacyKeyInput"]; + 'application/json': components['schemas']['LegacyKeyInput']; }; }; responses: { @@ -13789,14 +13789,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-keys-{enc_id}": { + 'put_core-{_version}-keys-{enc_id}': { parameters: { query?: never; header?: never; @@ -13808,7 +13808,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LegacyKeyInput"]; + 'application/json': components['schemas']['LegacyKeyInput']; }; }; responses: { @@ -13818,14 +13818,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-keys-reset": { + 'post_core-{_version}-keys-reset': { parameters: { query?: never; header?: never; @@ -13836,7 +13836,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ResetUserKeyInput"]; + 'application/json': components['schemas']['ResetUserKeyInput']; }; }; responses: { @@ -13846,14 +13846,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-members": { + 'get_core-{_version}-members': { parameters: { query?: never; header?: never; @@ -13870,15 +13870,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Members?: components["schemas"]["MemberInfo"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Members?: components['schemas']['MemberInfo'][]; }; }; }; }; }; - "post_core-{_version}-members": { + 'post_core-{_version}-members': { parameters: { query?: never; header?: never; @@ -13889,7 +13889,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateMemberInput"]; + 'application/json': components['schemas']['CreateMemberInput']; }; }; responses: { @@ -13899,15 +13899,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Member?: components["schemas"]["MemberInfo"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Member?: components['schemas']['MemberInfo']; }; }; }; }; }; - "post_core-{_version}-members-invitations": { + 'post_core-{_version}-members-invitations': { parameters: { query?: never; header?: never; @@ -13918,7 +13918,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateMemberInvitationInput"]; + 'application/json': components['schemas']['CreateMemberInvitationInput']; }; }; responses: { @@ -13928,15 +13928,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Member?: components["schemas"]["MemberInfo"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Member?: components['schemas']['MemberInfo']; }; }; }; }; }; - "put_core-{_version}-members-invitations-{enc_id}": { + 'put_core-{_version}-members-invitations-{enc_id}': { parameters: { query?: never; header?: never; @@ -13953,7 +13953,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateMemberInvitationInput"]; + 'application/json': components['schemas']['UpdateMemberInvitationInput']; }; }; responses: { @@ -13963,15 +13963,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Member?: components["schemas"]["MemberInfo"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Member?: components['schemas']['MemberInfo']; }; }; }; }; }; - "put_core-{_version}-members-{enc_id}-disable": { + 'put_core-{_version}-members-{enc_id}-disable': { parameters: { query?: never; header?: never; @@ -13994,14 +13994,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-members-{enc_id}-enable": { + 'put_core-{_version}-members-{enc_id}-enable': { parameters: { query?: never; header?: never; @@ -14024,14 +14024,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-members-{enc_id}-quota": { + 'put_core-{_version}-members-{enc_id}-quota': { parameters: { query?: never; header?: never; @@ -14048,7 +14048,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example 9900000000 */ MaxSpace?: number; }; @@ -14061,14 +14061,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-members-{enc_id}-name": { + 'put_core-{_version}-members-{enc_id}-name': { parameters: { query?: never; header?: never; @@ -14085,7 +14085,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example Jason */ Name?: string; }; @@ -14098,14 +14098,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-members-{enc_id}-role": { + 'put_core-{_version}-members-{enc_id}-role': { parameters: { query?: never; header?: never; @@ -14122,7 +14122,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateMemberRoleInput"]; + 'application/json': components['schemas']['UpdateMemberRoleInput']; }; }; responses: { @@ -14132,26 +14132,26 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-members-{memberId}-ai": { + 'put_core-{_version}-members-{memberId}-ai': { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components["schemas"]["Id"]; + memberId: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateMemberAIEntitlementInput"]; + 'application/json': components['schemas']['UpdateMemberAIEntitlementInput']; }; }; responses: { @@ -14163,7 +14163,7 @@ export interface operations { }; }; }; - "put_core-{_version}-members-{enc_id}-privatize": { + 'put_core-{_version}-members-{enc_id}-privatize': { parameters: { query?: never; header?: never; @@ -14186,14 +14186,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-members-me": { + 'get_core-{_version}-members-me': { parameters: { query?: never; header?: never; @@ -14210,15 +14210,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Member?: components["schemas"]["MemberInfo"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Member?: components['schemas']['MemberInfo']; }; }; }; }; }; - "get_core-{_version}-members-me-unprivatize": { + 'get_core-{_version}-members-me-unprivatize': { parameters: { query?: never; header?: never; @@ -14235,14 +14235,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetMemberUnprivatizationOutput"] & { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': components['schemas']['GetMemberUnprivatizationOutput'] & { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-members-me-unprivatize": { + 'post_core-{_version}-members-me-unprivatize': { parameters: { query?: never; header?: never; @@ -14253,7 +14253,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AcceptMemberUnprivatizationInput"]; + 'application/json': components['schemas']['AcceptMemberUnprivatizationInput']; }; }; responses: { @@ -14265,7 +14265,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-members-me-unprivatize": { + 'delete_core-{_version}-members-me-unprivatize': { parameters: { query?: never; header?: never; @@ -14284,13 +14284,13 @@ export interface operations { }; }; }; - "post_core-{_version}-members-{id}-unprivatize-resend": { + 'post_core-{_version}-members-{id}-unprivatize-resend': { parameters: { query?: never; header?: never; path: { _version: string; - id: components["schemas"]["Id"]; + id: components['schemas']['Id']; }; cookie?: never; }; @@ -14304,19 +14304,19 @@ export interface operations { }; }; }; - "post_core-{_version}-members-{id}-unprivatize": { + 'post_core-{_version}-members-{id}-unprivatize': { parameters: { query?: never; header?: never; path: { _version: string; - id: components["schemas"]["Id"]; + id: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["RequestMemberUnprivatizationInput"]; + 'application/json': components['schemas']['RequestMemberUnprivatizationInput']; }; }; responses: { @@ -14328,13 +14328,13 @@ export interface operations { }; }; }; - "delete_core-{_version}-members-{id}-unprivatize": { + 'delete_core-{_version}-members-{id}-unprivatize': { parameters: { query?: never; header?: never; path: { _version: string; - id: components["schemas"]["Id"]; + id: components['schemas']['Id']; }; cookie?: never; }; @@ -14348,7 +14348,7 @@ export interface operations { }; }; }; - "get_core-{_version}-members-{enc_id}": { + 'get_core-{_version}-members-{enc_id}': { parameters: { query?: { /** @@ -14377,15 +14377,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Member?: components["schemas"]["MemberInfo"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Member?: components['schemas']['MemberInfo']; }; }; }; }; }; - "delete_core-{_version}-members-{enc_id}": { + 'delete_core-{_version}-members-{enc_id}': { parameters: { query?: never; header?: never; @@ -14408,14 +14408,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-members-{enc_id}-details": { + 'get_core-{_version}-members-{enc_id}-details': { parameters: { query?: never; header?: never; @@ -14438,7 +14438,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Last login time (unix timestamp) * @example 1654615966 @@ -14464,7 +14464,7 @@ export interface operations { }; }; }; - "get_core-{_version}-members-{enc_id}-authlog": { + 'get_core-{_version}-members-{enc_id}-authlog': { parameters: { query?: { /** @@ -14498,15 +14498,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description List of authentication logs, ordered by "Time" (timestamp of the event) descending */ - Log?: components["schemas"]["AuthLogResponse"][]; + Log?: components['schemas']['AuthLogResponse'][]; }; }; }; }; }; - "put_core-{_version}-members-{enc_id}-require2fa": { + 'put_core-{_version}-members-{enc_id}-require2fa': { parameters: { query?: never; header?: never; @@ -14526,7 +14526,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-members-{enc_id}-require2fa": { + 'delete_core-{_version}-members-{enc_id}-require2fa': { parameters: { query?: never; header?: never; @@ -14546,7 +14546,7 @@ export interface operations { }; }; }; - "post_core-{_version}-members-{enc_id}-permissions-forwarding": { + 'post_core-{_version}-members-{enc_id}-permissions-forwarding': { parameters: { query?: never; header?: never; @@ -14569,14 +14569,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-members-{enc_id}-permissions-forwarding": { + 'delete_core-{_version}-members-{enc_id}-permissions-forwarding': { parameters: { query?: never; header?: never; @@ -14599,14 +14599,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-members-permissions": { + 'put_core-{_version}-members-permissions': { parameters: { query?: never; header?: never; @@ -14617,7 +14617,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["MemberManagePermissionsDto"]; + 'application/json': components['schemas']['MemberManagePermissionsDto']; }; }; responses: { @@ -14627,14 +14627,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-members-{enc_id}-keys-setup": { + 'post_core-{_version}-members-{enc_id}-keys-setup': { parameters: { query?: never; header?: never; @@ -14651,7 +14651,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateMemberKeysInput"]; + 'application/json': components['schemas']['UpdateMemberKeysInput']; }; }; responses: { @@ -14661,8 +14661,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Member?: { /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ ID?: string; @@ -14710,7 +14710,7 @@ export interface operations { }; }; }; - "post_core-{_version}-members-{enc_id}-keys-migrate": { + 'post_core-{_version}-members-{enc_id}-keys-migrate': { parameters: { query?: never; header?: never; @@ -14727,7 +14727,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { AddressKeys?: { /** @example adsft3Ep5P_EWz8WbasdkVAOl_6h== */ ID?: string; @@ -14742,7 +14742,7 @@ export interface operations { }[]; SignedKeyLists?: { /** @description AddressID */ - "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { + 'CasdiSFq_TW7i8FtJGuQyFEq0=='?: { /** @example JSON.stringify([{""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 1,""Flags"": 3}]) */ Data?: string; /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ @@ -14759,14 +14759,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-members-{enc_id}-keys-signedkeylists": { + 'post_core-{_version}-members-{enc_id}-keys-signedkeylists': { parameters: { query?: never; header?: never; @@ -14783,10 +14783,10 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { SignedKeyLists?: { /** @description AddressID */ - "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { + 'CasdiSFq_TW7i8FtJGuQyFEq0=='?: { /** @example JSON.stringify([{""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 1,""Flags"": 3}]) */ Data?: string; /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ @@ -14803,14 +14803,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-members-{enc_id}-keys-unprivatize": { + 'post_core-{_version}-members-{enc_id}-keys-unprivatize': { parameters: { query?: never; header?: never; @@ -14821,13 +14821,13 @@ export interface operations { */ memberid: string; _version: string; - enc_id: components["schemas"]["Id"]; + enc_id: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UnprivatizeMemberInput"]; + 'application/json': components['schemas']['UnprivatizeMemberInput']; }; }; responses: { @@ -14837,14 +14837,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-members-{enc_id}-auth": { + 'post_core-{_version}-members-{enc_id}-auth': { parameters: { query?: never; header?: never; @@ -14856,7 +14856,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description If true, LOCKED and PASSWORD scopes will be assigned to the child session * @example false @@ -14905,8 +14905,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example f3804b2ad70c3992a9496ff07f3e9b93 */ UID?: string; /** @example 0 */ @@ -14926,7 +14926,7 @@ export interface operations { }; }; }; - "get_core-{_version}-members-{enc_id}-sessions": { + 'get_core-{_version}-members-{enc_id}-sessions': { parameters: { query?: never; header?: never; @@ -14944,9 +14944,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Sessions?: (components["schemas"]["Session"] & { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Sessions?: (components['schemas']['Session'] & { /** * @deprecated * @example gony7nIW...KkhhFxA== @@ -14975,7 +14975,7 @@ export interface operations { }; }; }; - "post_core-{_version}-members-{enc_id}-sessions": { + 'post_core-{_version}-members-{enc_id}-sessions': { parameters: { query?: never; header?: never; @@ -14987,7 +14987,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description If true, LOCKED and PASSWORD scopes will be assigned to the child session * @example false @@ -15036,8 +15036,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example f3804b2ad70c3992a9496ff07f3e9b93 */ UID?: string; /** @example 0 */ @@ -15057,7 +15057,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-members-{enc_id}-sessions": { + 'delete_core-{_version}-members-{enc_id}-sessions': { parameters: { query?: never; header?: never; @@ -15075,14 +15075,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-members-{enc_id}-sessions-{uid}": { + 'delete_core-{_version}-members-{enc_id}-sessions-{uid}': { parameters: { query?: never; header?: never; @@ -15101,14 +15101,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-organizations-keys": { + 'get_core-{_version}-organizations-keys': { parameters: { query?: never; header?: never; @@ -15125,12 +15125,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetOrganizationKeysOutput"]; + 'application/json': components['schemas']['GetOrganizationKeysOutput']; }; }; }; }; - "put_core-{_version}-organizations-keys": { + 'put_core-{_version}-organizations-keys': { parameters: { query?: never; header?: never; @@ -15141,7 +15141,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ReplaceOrganizationKeysInput"]; + 'application/json': components['schemas']['ReplaceOrganizationKeysInput']; }; }; responses: { @@ -15151,8 +15151,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -15163,7 +15163,7 @@ export interface operations { }; }; }; - "post_core-{_version}-organizations-keys": { + 'post_core-{_version}-organizations-keys': { parameters: { query?: never; header?: never; @@ -15174,7 +15174,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ReplaceOrganizationKeysInput"]; + 'application/json': components['schemas']['ReplaceOrganizationKeysInput']; }; }; responses: { @@ -15184,8 +15184,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -15196,7 +15196,7 @@ export interface operations { }; }; }; - "get_core-{_version}-organizations-keys-backup": { + 'get_core-{_version}-organizations-keys-backup': { parameters: { query?: never; header?: never; @@ -15213,8 +15213,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ PrivateKey?: string; /** @example 0123456789abcdef */ @@ -15224,7 +15224,7 @@ export interface operations { }; }; }; - "put_core-{_version}-organizations-keys-backup": { + 'put_core-{_version}-organizations-keys-backup': { parameters: { query?: never; header?: never; @@ -15235,7 +15235,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; + 'application/json': components['schemas']['UpdateOrganizationKeyBackupInput']; }; }; responses: { @@ -15245,8 +15245,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -15257,7 +15257,7 @@ export interface operations { }; }; }; - "post_core-{_version}-organizations-keys-backup": { + 'post_core-{_version}-organizations-keys-backup': { parameters: { query?: never; header?: never; @@ -15268,7 +15268,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; + 'application/json': components['schemas']['UpdateOrganizationKeyBackupInput']; }; }; responses: { @@ -15278,8 +15278,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -15290,7 +15290,7 @@ export interface operations { }; }; }; - "put_core-{_version}-organizations-name": { + 'put_core-{_version}-organizations-name': { parameters: { query?: never; header?: never; @@ -15301,7 +15301,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateOrganizationNameInput"]; + 'application/json': components['schemas']['UpdateOrganizationNameInput']; }; }; responses: { @@ -15311,8 +15311,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Organization?: { /** @example E-Corp */ Name?: string; @@ -15372,7 +15372,7 @@ export interface operations { }; }; }; - "put_core-{_version}-organizations-email": { + 'put_core-{_version}-organizations-email': { parameters: { query?: never; header?: never; @@ -15383,7 +15383,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateOrganizationEmailInput"]; + 'application/json': components['schemas']['UpdateOrganizationEmailInput']; }; }; responses: { @@ -15393,8 +15393,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Organization?: { /** @example E-Corp */ Name?: string; @@ -15454,7 +15454,7 @@ export interface operations { }; }; }; - "put_core-{_version}-organizations-2fa": { + 'put_core-{_version}-organizations-2fa': { parameters: { query?: never; header?: never; @@ -15465,7 +15465,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateOrganizationTwoFactorGracePeriodInput"]; + 'application/json': components['schemas']['UpdateOrganizationTwoFactorGracePeriodInput']; }; }; responses: { @@ -15475,8 +15475,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Organization?: { /** @example E-Corp */ Name?: string; @@ -15536,7 +15536,7 @@ export interface operations { }; }; }; - "put_core-{_version}-organizations-require2fa": { + 'put_core-{_version}-organizations-require2fa': { parameters: { query?: never; header?: never; @@ -15547,7 +15547,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description 1 = at least enforced for admin members, 2 = enforced for all members * @example 1 @@ -15563,8 +15563,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Organization?: { /** @example E-Corp */ Name?: string; @@ -15624,7 +15624,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-organizations-require2fa": { + 'delete_core-{_version}-organizations-require2fa': { parameters: { query?: never; header?: never; @@ -15641,8 +15641,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Organization?: { /** @example E-Corp */ Name?: string; @@ -15702,7 +15702,7 @@ export interface operations { }; }; }; - "put_core-{_version}-organizations-keys-activate": { + 'put_core-{_version}-organizations-keys-activate': { parameters: { query?: never; header?: never; @@ -15713,7 +15713,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ActivateOrganizationKeyInput"]; + 'application/json': components['schemas']['ActivateOrganizationKeyInput']; }; }; responses: { @@ -15723,14 +15723,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-organizations-membership": { + 'delete_core-{_version}-organizations-membership': { parameters: { query?: never; header?: never; @@ -15747,14 +15747,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-organizations-2fa-remind": { + 'post_core-{_version}-organizations-2fa-remind': { parameters: { query?: never; header?: never; @@ -15771,14 +15771,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-organizations-keys-migrate": { + 'post_core-{_version}-organizations-keys-migrate': { parameters: { query?: never; header?: never; @@ -15789,7 +15789,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["MigrateOrganizationKeysInput"]; + 'application/json': components['schemas']['MigrateOrganizationKeysInput']; }; }; responses: { @@ -15799,8 +15799,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -15810,7 +15810,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2011 */ Code?: number; /** @example Organization already migrated */ @@ -15820,7 +15820,7 @@ export interface operations { }; }; }; - "get_core-{_version}-organizations-keys-signature": { + 'get_core-{_version}-organizations-keys-signature': { parameters: { query?: never; header?: never; @@ -15837,12 +15837,13 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["GetOrganizationIdentityOutput"]; + 'application/json': components['schemas']['ProtonSuccess'] & + components['schemas']['GetOrganizationIdentityOutput']; }; }; }; }; - "put_core-{_version}-organizations-keys-signature": { + 'put_core-{_version}-organizations-keys-signature': { parameters: { query?: never; header?: never; @@ -15853,7 +15854,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateOrgKeyFingerprintSignatureInput"]; + 'application/json': components['schemas']['UpdateOrgKeyFingerprintSignatureInput']; }; }; responses: { @@ -15865,7 +15866,7 @@ export interface operations { }; }; }; - "get_core-{_version}-organizations-logo-{logo_id}": { + 'get_core-{_version}-organizations-logo-{logo_id}': { parameters: { query?: never; header?: never; @@ -15883,12 +15884,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/octet-stream": string; + 'application/octet-stream': string; }; }; }; }; - "get_core-{_version}-organizations-settings": { + 'get_core-{_version}-organizations-settings': { parameters: { query?: never; header?: never; @@ -15905,12 +15906,13 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["OrganizationSettings2"]; + 'application/json': components['schemas']['ProtonSuccess'] & + components['schemas']['OrganizationSettings2']; }; }; }; }; - "put_core-{_version}-organizations-settings": { + 'put_core-{_version}-organizations-settings': { parameters: { query?: never; header?: never; @@ -15921,7 +15923,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["OrganizationSettings"]; + 'application/json': components['schemas']['OrganizationSettings']; }; }; responses: { @@ -15931,12 +15933,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonSuccess"]; + 'application/json': components['schemas']['ProtonSuccess']; }; }; }; }; - "post_core-{_version}-organizations-settings-logo": { + 'post_core-{_version}-organizations-settings-logo': { parameters: { query?: never; header?: never; @@ -15947,7 +15949,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["OrganizationLogo"]; + 'application/json': components['schemas']['OrganizationLogo']; }; }; responses: { @@ -15957,12 +15959,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonSuccess"]; + 'application/json': components['schemas']['ProtonSuccess']; }; }; }; }; - "delete_core-{_version}-organizations-settings-logo": { + 'delete_core-{_version}-organizations-settings-logo': { parameters: { query?: never; header?: never; @@ -15979,12 +15981,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonSuccess"]; + 'application/json': components['schemas']['ProtonSuccess']; }; }; }; }; - "get_core-{_version}-captcha": { + 'get_core-{_version}-captcha': { parameters: { query: { /** @example 1 */ @@ -15995,7 +15997,7 @@ export interface operations { Token: string; }; header?: { - "x-pm-nonce"?: string | null; + 'x-pm-nonce'?: string | null; host?: string; }; path: { @@ -16014,7 +16016,7 @@ export interface operations { }; }; }; - "get_core-{_version}-resources-captcha": { + 'get_core-{_version}-resources-captcha': { parameters: { query: { /** @example 1 */ @@ -16025,7 +16027,7 @@ export interface operations { Token: string; }; header?: { - "x-pm-nonce"?: string | null; + 'x-pm-nonce'?: string | null; host?: string; }; path: { @@ -16044,14 +16046,14 @@ export interface operations { }; }; }; - "get_core-{_version}-resources-zendesk": { + 'get_core-{_version}-resources-zendesk': { parameters: { query?: { /** @example 83fabdab-1337-4fd7-85c0-39baf5c114fe */ Key?: string; }; header?: { - "x-pm-nonce"?: string | null; + 'x-pm-nonce'?: string | null; }; path: { _version: string; @@ -16069,7 +16071,7 @@ export interface operations { }; }; }; - "post_core-{_version}-saml-setup-fields": { + 'post_core-{_version}-saml-setup-fields': { parameters: { query?: never; header?: never; @@ -16080,7 +16082,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["Sso"]; + 'application/json': components['schemas']['Sso']; }; }; responses: { @@ -16090,14 +16092,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-saml-setup-xml": { + 'post_core-{_version}-saml-setup-xml': { parameters: { query?: never; header?: never; @@ -16108,7 +16110,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SsoXml"]; + 'application/json': components['schemas']['SsoXml']; }; }; responses: { @@ -16118,14 +16120,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-saml-setup-url": { + 'post_core-{_version}-saml-setup-url': { parameters: { query?: never; header?: never; @@ -16136,7 +16138,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SsoUrl"]; + 'application/json': components['schemas']['SsoUrl']; }; }; responses: { @@ -16146,15 +16148,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SSO?: components["schemas"]["SsoTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SSO?: components['schemas']['SsoTransformer']; }; }; }; }; }; - "get_core-{_version}-saml-configs": { + 'get_core-{_version}-saml-configs': { parameters: { query?: never; header?: never; @@ -16171,21 +16173,21 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SSO?: components["schemas"]["SsoTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SSO?: components['schemas']['SsoTransformer']; }; }; }; }; }; - "get_core-{_version}-saml-configs-{enc_id}": { + 'get_core-{_version}-saml-configs-{enc_id}': { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components["schemas"]["Id"]; + enc_id: components['schemas']['Id']; }; cookie?: never; }; @@ -16197,27 +16199,27 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SSO?: components["schemas"]["SsoTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SSO?: components['schemas']['SsoTransformer']; }; }; }; }; }; - "put_core-{_version}-saml-configs-{enc_id}-fields": { + 'put_core-{_version}-saml-configs-{enc_id}-fields': { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components["schemas"]["Id"]; + enc_id: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["Sso"]; + 'application/json': components['schemas']['Sso']; }; }; responses: { @@ -16227,21 +16229,21 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SSO?: components["schemas"]["SsoTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SSO?: components['schemas']['SsoTransformer']; }; }; }; }; }; - "put_core-{_version}-saml-configs-{enc_id}-delete": { + 'put_core-{_version}-saml-configs-{enc_id}-delete': { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components["schemas"]["Id"]; + enc_id: components['schemas']['Id']; }; cookie?: never; }; @@ -16253,15 +16255,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - SSO?: components["schemas"]["SsoTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + SSO?: components['schemas']['SsoTransformer']; }; }; }; }; }; - "get_core-{_version}-saml-sp-info": { + 'get_core-{_version}-saml-sp-info': { parameters: { query?: never; header?: never; @@ -16278,12 +16280,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Info"]; + 'application/json': components['schemas']['Info']; }; }; }; }; - "get_core-{_version}-saml-edugain-info": { + 'get_core-{_version}-saml-edugain-info': { parameters: { query?: never; header?: never; @@ -16300,14 +16302,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-saml-edugain-info-{domainName}": { + 'get_core-{_version}-saml-edugain-info-{domainName}': { parameters: { query?: never; header?: never; @@ -16325,14 +16327,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-saml-metadata": { + 'get_core-{_version}-saml-metadata': { parameters: { query?: never; header?: never; @@ -16349,12 +16351,12 @@ export interface operations { [name: string]: unknown; }; content: { - "text/xml": string; + 'text/xml': string; }; }; }; }; - "get_core-{_version}-settings": { + 'get_core-{_version}-settings': { parameters: { query?: never; header?: never; @@ -16371,15 +16373,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-password": { + 'put_core-{_version}-settings-password': { parameters: { query?: never; header?: never; @@ -16390,7 +16392,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -16444,9 +16446,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -16457,7 +16459,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-password-upgrade": { + 'put_core-{_version}-settings-password-upgrade': { parameters: { query?: never; header?: never; @@ -16468,7 +16470,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { Auth?: { /** @example 4 */ Version?: number; @@ -16489,15 +16491,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-email": { + 'put_core-{_version}-settings-email': { parameters: { query?: never; header?: never; @@ -16508,7 +16510,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -16554,9 +16556,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -16567,7 +16569,7 @@ export interface operations { }; }; }; - "post_core-{_version}-settings-email-verify": { + 'post_core-{_version}-settings-email-verify': { parameters: { query?: never; header?: never; @@ -16578,7 +16580,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example */ Token?: string; }; @@ -16591,15 +16593,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-email-notify": { + 'put_core-{_version}-settings-email-notify': { parameters: { query?: never; header?: never; @@ -16610,7 +16612,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @example 1 * @enum {integer} @@ -16626,15 +16628,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-email-reset": { + 'put_core-{_version}-settings-email-reset': { parameters: { query?: never; header?: never; @@ -16645,7 +16647,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -16694,9 +16696,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -16707,7 +16709,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-phone": { + 'put_core-{_version}-settings-phone': { parameters: { query?: never; header?: never; @@ -16718,7 +16720,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -16764,9 +16766,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -16777,7 +16779,7 @@ export interface operations { }; }; }; - "post_core-{_version}-settings-phone-verify": { + 'post_core-{_version}-settings-phone-verify': { parameters: { query?: never; header?: never; @@ -16788,7 +16790,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example */ Token?: string; }; @@ -16801,15 +16803,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-phone-notify": { + 'put_core-{_version}-settings-phone-notify': { parameters: { query?: never; header?: never; @@ -16820,7 +16822,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @example 1 * @enum {integer} @@ -16836,15 +16838,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-phone-reset": { + 'put_core-{_version}-settings-phone-reset': { parameters: { query?: never; header?: never; @@ -16855,7 +16857,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -16904,9 +16906,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -16917,7 +16919,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-locale": { + 'put_core-{_version}-settings-locale': { parameters: { query?: never; header?: never; @@ -16928,7 +16930,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example en_US */ Locale?: string; }; @@ -16941,15 +16943,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-logauth": { + 'put_core-{_version}-settings-logauth': { parameters: { query?: never; header?: never; @@ -16960,7 +16962,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description 0 = off, 1 = on, 2 = on with IP logging * @example 0 @@ -16976,15 +16978,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-devicerecovery": { + 'put_core-{_version}-settings-devicerecovery': { parameters: { query?: never; header?: never; @@ -16995,7 +16997,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @description possible values:
- 0: disable
- 1: enable */ DeviceRecovery?: number; }; @@ -17008,15 +17010,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-news": { + 'put_core-{_version}-settings-news': { parameters: { query?: never; header?: never; @@ -17027,7 +17029,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateNewsInput"]; + 'application/json': components['schemas']['UpdateNewsInput']; }; }; responses: { @@ -17037,15 +17039,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "patch_core-{_version}-settings-news": { + 'patch_core-{_version}-settings-news': { parameters: { query?: never; header?: never; @@ -17056,7 +17058,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["PatchNewsInput"]; + 'application/json': components['schemas']['PatchNewsInput']; }; }; responses: { @@ -17066,15 +17068,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "get_core-{_version}-settings-news-external": { + 'get_core-{_version}-settings-news-external': { parameters: { query?: never; header?: never; @@ -17091,8 +17093,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; UserSettings?: { /** * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. @@ -17105,7 +17107,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-news-external": { + 'put_core-{_version}-settings-news-external': { parameters: { query?: never; header?: never; @@ -17116,7 +17118,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateNewsInput"]; + 'application/json': components['schemas']['UpdateNewsInput']; }; }; responses: { @@ -17126,8 +17128,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; UserSettings?: { /** * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. @@ -17140,7 +17142,7 @@ export interface operations { }; }; }; - "patch_core-{_version}-settings-news-external": { + 'patch_core-{_version}-settings-news-external': { parameters: { query?: never; header?: never; @@ -17151,7 +17153,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["PatchNewsInput"]; + 'application/json': components['schemas']['PatchNewsInput']; }; }; responses: { @@ -17161,8 +17163,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; UserSettings?: { /** * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. @@ -17175,7 +17177,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-density": { + 'put_core-{_version}-settings-density': { parameters: { query?: never; header?: never; @@ -17186,7 +17188,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description 0:comfortable, 1:compact * @example 0 @@ -17202,15 +17204,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-invoicetext": { + 'put_core-{_version}-settings-invoicetext': { parameters: { query?: never; header?: never; @@ -17221,7 +17223,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Maximum 5 lines * @example Mickey Mouse, Esq. @@ -17238,15 +17240,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "post_core-{_version}-settings-2fa-codes": { + 'post_core-{_version}-settings-2fa-codes': { parameters: { query?: never; header?: never; @@ -17257,7 +17259,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -17301,9 +17303,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -17320,7 +17322,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 10041 */ Code?: number; /** @example Two Factor confirmation failed */ @@ -17331,7 +17333,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-2fa-totp": { + 'put_core-{_version}-settings-2fa-totp': { parameters: { query?: never; header?: never; @@ -17342,7 +17344,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -17386,9 +17388,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -17399,7 +17401,7 @@ export interface operations { }; }; }; - "post_core-{_version}-settings-2fa-totp": { + 'post_core-{_version}-settings-2fa-totp': { parameters: { query?: never; header?: never; @@ -17410,7 +17412,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -17458,9 +17460,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -17477,7 +17479,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 10041 */ Code?: number; /** @example Two Factor confirmation failed */ @@ -17488,7 +17490,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-2fa": { + 'put_core-{_version}-settings-2fa': { parameters: { query?: never; header?: never; @@ -17499,7 +17501,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -17543,9 +17545,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -17556,7 +17558,7 @@ export interface operations { }; }; }; - "post_core-{_version}-settings-2fa": { + 'post_core-{_version}-settings-2fa': { parameters: { query?: never; header?: never; @@ -17567,7 +17569,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -17615,9 +17617,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -17634,7 +17636,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 10041 */ Code?: number; /** @example Two Factor confirmation failed */ @@ -17645,7 +17647,7 @@ export interface operations { }; }; }; - "post_core-{_version}-settings-2fa-reset": { + 'post_core-{_version}-settings-2fa-reset': { parameters: { query?: never; header?: never; @@ -17656,7 +17658,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example user_name */ Username?: string; /** @@ -17674,8 +17676,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -17685,7 +17687,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 19502 */ Code?: number; /** @example Invalid reset token. Please request another token and try again */ @@ -17696,7 +17698,7 @@ export interface operations { }; }; }; - "get_core-{_version}-settings-2fa-register": { + 'get_core-{_version}-settings-2fa-register': { parameters: { query?: { /** @@ -17719,9 +17721,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Contains the user's currently registered FIDO2 credentials. */ - RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + RegisteredKeys?: components['schemas']['Fido2RegisteredKey'][]; /** * @description Refer to the definition of PublicKeyCredentialCreationOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. * @example @@ -17734,7 +17736,7 @@ export interface operations { }; }; }; - "post_core-{_version}-settings-2fa-register": { + 'post_core-{_version}-settings-2fa-register': { parameters: { query?: never; header?: never; @@ -17745,7 +17747,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Refer to the definition of PublicKeyCredentialCreationOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. * @example @@ -17814,9 +17816,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -17827,7 +17829,7 @@ export interface operations { }; }; }; - "post_core-{_version}-settings-2fa-{credentialID}-remove": { + 'post_core-{_version}-settings-2fa-{credentialID}-remove': { parameters: { query?: never; header?: never; @@ -17839,7 +17841,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -17883,9 +17885,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; /** * @description Present only if inline re-authentication is submitted * @example @@ -17896,7 +17898,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-2fa-{credentialID}-rename": { + 'put_core-{_version}-settings-2fa-{credentialID}-rename': { parameters: { query?: never; header?: never; @@ -17908,7 +17910,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description User defined name for the credential. * @example My FIDO2 key @@ -17924,15 +17926,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-hide-side-panel": { + 'put_core-{_version}-settings-hide-side-panel': { parameters: { query?: never; header?: never; @@ -17943,7 +17945,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateHideSidePanelInput"]; + 'application/json': components['schemas']['UpdateHideSidePanelInput']; }; }; responses: { @@ -17953,15 +17955,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-username": { + 'put_core-{_version}-settings-username': { parameters: { query?: never; header?: never; @@ -17972,7 +17974,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @description Length <= 40 */ Username?: string; }; @@ -17985,14 +17987,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-settings-theme": { + 'put_core-{_version}-settings-theme': { parameters: { query?: never; header?: never; @@ -18003,7 +18005,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["Theme"]; + 'application/json': components['schemas']['Theme']; }; }; responses: { @@ -18013,15 +18015,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-themetype": { + 'put_core-{_version}-settings-themetype': { parameters: { query?: never; header?: never; @@ -18032,7 +18034,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example 1 */ ThemeType?: number; }; @@ -18045,15 +18047,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-weekstart": { + 'put_core-{_version}-settings-weekstart': { parameters: { query?: never; header?: never; @@ -18064,7 +18066,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description `0`: Locale default, `1`: Monday, `6`: Saturday, `7`: Sunday * @example 1 @@ -18080,15 +18082,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-dateformat": { + 'put_core-{_version}-settings-dateformat': { parameters: { query?: never; header?: never; @@ -18099,7 +18101,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description 0: Locale default, 1: DD_MM_YYYY, 2: MM_DD_YYYY, 3: YYYY_MM_DD * @example 1 @@ -18115,15 +18117,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-timeformat": { + 'put_core-{_version}-settings-timeformat': { parameters: { query?: never; header?: never; @@ -18134,7 +18136,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description 0: Locale default, 1: 24H, 2: 12H * @example 1 @@ -18150,15 +18152,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-welcome": { + 'put_core-{_version}-settings-welcome': { parameters: { query?: never; header?: never; @@ -18175,8 +18177,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -18186,7 +18188,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2000 */ Code?: number; /** @example Unknown client */ @@ -18197,7 +18199,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-earlyaccess": { + 'put_core-{_version}-settings-earlyaccess': { parameters: { query?: never; header?: never; @@ -18208,7 +18210,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description 0: Disabled, 1: Enabled * @example 1 @@ -18224,8 +18226,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -18235,7 +18237,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2000 */ Code?: number; /** @example Invalid client */ @@ -18246,7 +18248,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-flags": { + 'put_core-{_version}-settings-flags': { parameters: { query?: never; header?: never; @@ -18257,7 +18259,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description 0: Disabled, 1: Enabled * @example 1 @@ -18278,14 +18280,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-settings-telemetry": { + 'put_core-{_version}-settings-telemetry': { parameters: { query?: never; header?: never; @@ -18296,7 +18298,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @description possible values:
- 0: disable
- 1: enable */ Telemetry?: number; }; @@ -18309,15 +18311,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-crashreports": { + 'put_core-{_version}-settings-crashreports': { parameters: { query?: never; header?: never; @@ -18328,7 +18330,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @description possible values:
- 0: disable
- 1: enable */ CrashReports?: number; }; @@ -18341,15 +18343,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "post_core-{_version}-settings-highsecurity": { + 'post_core-{_version}-settings-highsecurity': { parameters: { query?: never; header?: never; @@ -18366,8 +18368,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -18377,7 +18379,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @default 2011 */ Code: number; /** @default You do not have an active subscription */ @@ -18387,7 +18389,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-settings-highsecurity": { + 'delete_core-{_version}-settings-highsecurity': { parameters: { query?: never; header?: never; @@ -18404,14 +18406,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-settings-breachalerts": { + 'post_core-{_version}-settings-breachalerts': { parameters: { query?: never; header?: never; @@ -18428,8 +18430,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -18439,7 +18441,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @default 2011 */ Code: number; /** @default You do not have an active subscription */ @@ -18449,7 +18451,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-settings-breachalerts": { + 'delete_core-{_version}-settings-breachalerts': { parameters: { query?: never; header?: never; @@ -18466,14 +18468,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-settings-sessionaccountrecovery": { + 'put_core-{_version}-settings-sessionaccountrecovery': { parameters: { query?: never; header?: never; @@ -18484,7 +18486,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SessionAccountRecoveryInput"]; + 'application/json': components['schemas']['SessionAccountRecoveryInput']; }; }; responses: { @@ -18494,15 +18496,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "put_core-{_version}-settings-ai-assistant-flags": { + 'put_core-{_version}-settings-ai-assistant-flags': { parameters: { query?: never; header?: never; @@ -18513,7 +18515,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AIAssistantFlagsInput"]; + 'application/json': components['schemas']['AIAssistantFlagsInput']; }; }; responses: { @@ -18523,15 +18525,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - UserSettings?: components["schemas"]["UserSettingsTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + UserSettings?: components['schemas']['UserSettingsTransformer']; }; }; }; }; }; - "post_core-{_version}-settings-news-unsubscribe": { + 'post_core-{_version}-settings-news-unsubscribe': { parameters: { query?: { News?: number; @@ -18548,16 +18550,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "get_core-{_version}-support-schedulecall": { + 'get_core-{_version}-support-schedulecall': { parameters: { query?: never; header?: never; @@ -18574,24 +18576,24 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ScheduleSupportCallOutput"]; + 'application/json': components['schemas']['ScheduleSupportCallOutput']; }; }; }; }; - "put_core-{_version}-members-{memberId}-lumo": { + 'put_core-{_version}-members-{memberId}-lumo': { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components["schemas"]["Id"]; + memberId: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateMemberLumoEntitlementInput"]; + 'application/json': components['schemas']['UpdateMemberLumoEntitlementInput']; }; }; responses: { @@ -18603,7 +18605,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-product-disabled": { + 'put_core-{_version}-settings-product-disabled': { parameters: { query?: never; header?: never; @@ -18614,7 +18616,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ProductDisabledInput"]; + 'application/json': components['schemas']['ProductDisabledInput']; }; }; responses: { @@ -18626,7 +18628,7 @@ export interface operations { }; }; }; - "get_core-{_version}-users-delete": { + 'get_core-{_version}-users-delete': { parameters: { query?: never; header?: never; @@ -18643,14 +18645,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-users-delete": { + 'put_core-{_version}-users-delete': { parameters: { query?: never; header?: never; @@ -18661,7 +18663,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * Format: base64 * @description Optional, for inline re-authentication @@ -18724,8 +18726,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -18736,7 +18738,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-users-delete": { + 'delete_core-{_version}-users-delete': { parameters: { query?: never; header?: never; @@ -18747,7 +18749,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * Format: base64 * @description Optional, for inline re-authentication @@ -18810,8 +18812,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -18822,7 +18824,7 @@ export interface operations { }; }; }; - "get_core-{_version}-users-reset": { + 'get_core-{_version}-users-reset': { parameters: { query: { /** @@ -18845,8 +18847,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description internal or external * @example internal @@ -18859,7 +18861,7 @@ export interface operations { }; }; }; - "get_core-{_version}-users": { + 'get_core-{_version}-users': { parameters: { query?: never; header?: never; @@ -18876,15 +18878,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - User?: components["schemas"]["User"] & { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + User?: components['schemas']['User'] & { /** @example jason@protonmail.ch */ Email?: string; /** @example Jason */ DisplayName?: string; - Keys?: components["schemas"]["UserKey"][]; - AccountRecovery?: components["schemas"]["AccountRecoveryAttempt"]; + Keys?: components['schemas']['UserKey'][]; + AccountRecovery?: components['schemas']['AccountRecoveryAttempt']; }; VerifyMethods?: string[]; }; @@ -18892,7 +18894,7 @@ export interface operations { }; }; }; - "post_core-{_version}-users": { + 'post_core-{_version}-users': { parameters: { query?: never; header?: never; @@ -18903,7 +18905,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example user_name */ Username?: string; /** @example proton.me */ @@ -18950,13 +18952,13 @@ export interface operations { /** @description optional field, frontend fingerprints */ Payload?: { /** @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ - "random-id-1"?: string; + 'random-id-1'?: string; /** @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ - "random-id-2"?: string; + 'random-id-2'?: string; /** @example */ - "random-id-3"?: string; + 'random-id-3'?: string; /** @example */ - "random-id-4"?: string; + 'random-id-4'?: string; }; /** * @deprecated @@ -18974,16 +18976,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - User?: components["schemas"]["User"] & { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + User?: components['schemas']['User'] & { /** @example 1 */ Services?: number; /** @example jason@protonmail.ch */ Email?: string; /** @example Jason */ DisplayName?: string; - Keys?: components["schemas"]["UserKey"][]; + Keys?: components['schemas']['UserKey'][]; /** * @description Token for external account creation. If it matches the created email it will be pre-verified * @example ASD3ldfa.asdfaoa3aw.asdfads @@ -18995,7 +18997,7 @@ export interface operations { }; }; }; - "post_core-{_version}-users-external": { + 'post_core-{_version}-users-external': { parameters: { query?: never; header?: never; @@ -19006,7 +19008,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example user_name */ Username?: string; /** @example proton.me */ @@ -19053,13 +19055,13 @@ export interface operations { /** @description optional field, frontend fingerprints */ Payload?: { /** @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ - "random-id-1"?: string; + 'random-id-1'?: string; /** @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ - "random-id-2"?: string; + 'random-id-2'?: string; /** @example */ - "random-id-3"?: string; + 'random-id-3'?: string; /** @example */ - "random-id-4"?: string; + 'random-id-4'?: string; }; /** * @deprecated @@ -19077,16 +19079,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - User?: components["schemas"]["User"] & { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + User?: components['schemas']['User'] & { /** @example 1 */ Services?: number; /** @example jason@protonmail.ch */ Email?: string; /** @example Jason */ DisplayName?: string; - Keys?: components["schemas"]["UserKey"][]; + Keys?: components['schemas']['UserKey'][]; /** * @description Token for external account creation. If it matches the created email it will be pre-verified * @example ASD3ldfa.asdfaoa3aw.asdfads @@ -19098,7 +19100,7 @@ export interface operations { }; }; }; - "put_core-{_version}-users-check": { + 'put_core-{_version}-users-check': { parameters: { query?: never; header?: never; @@ -19109,7 +19111,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description in case of an invite must be selector:token * @example @@ -19135,14 +19137,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-users-availableExternal": { + 'get_core-{_version}-users-availableExternal': { parameters: { query?: { /** @@ -19156,7 +19158,7 @@ export interface operations { * @description Optional header containing a payment token value. When this value is set and the token is valid, the signup flow is started. * @example 1234567890abcdefghijklmn */ - "X-PM-Payment-Info-Token"?: string; + 'X-PM-Payment-Info-Token'?: string; }; path: { _version: string; @@ -19171,8 +19173,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -19182,7 +19184,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 12106 */ Code?: number; /** @example Username already used */ @@ -19195,7 +19197,7 @@ export interface operations { }; }; }; - "get_core-{_version}-users-available": { + 'get_core-{_version}-users-available': { parameters: { query?: { /** @@ -19223,8 +19225,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -19234,7 +19236,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 12106 */ Code?: number; /** @example Username already used */ @@ -19247,7 +19249,7 @@ export interface operations { }; }; }; - "get_core-{_version}-users-available-{username}": { + 'get_core-{_version}-users-available-{username}': { parameters: { query?: never; header?: never; @@ -19267,7 +19269,7 @@ export interface operations { }; }; }; - "get_core-{_version}-users-direct": { + 'get_core-{_version}-users-direct': { parameters: { query?: { /** @@ -19290,8 +19292,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description 1 if enabled, 0 if disabled--client should show invite form * @example 1 @@ -19303,7 +19305,7 @@ export interface operations { }; }; }; - "post_core-{_version}-users-code": { + 'post_core-{_version}-users-code': { parameters: { query?: never; header?: never; @@ -19314,13 +19316,13 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description email or sms * @example email * @enum {string} */ - Type?: "email" | "sms"; + Type?: 'email' | 'sms'; /** * @description Optional, can use android as well if link support * @example ios @@ -19348,14 +19350,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-users-lock": { + 'put_core-{_version}-users-lock': { parameters: { query?: never; header?: never; @@ -19372,14 +19374,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-users-unlock": { + 'put_core-{_version}-users-unlock': { parameters: { query?: never; header?: never; @@ -19390,7 +19392,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example */ ClientEphemeral?: string; /** @example */ @@ -19412,8 +19414,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example */ ServerProof?: string; }; @@ -19421,7 +19423,7 @@ export interface operations { }; }; }; - "put_core-{_version}-users-password": { + 'put_core-{_version}-users-password': { parameters: { query?: never; header?: never; @@ -19432,7 +19434,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example */ ClientEphemeral?: string; /** @example */ @@ -19475,8 +19477,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example */ ServerProof?: string; }; @@ -19484,11 +19486,11 @@ export interface operations { }; }; }; - "get_core-{_version}-users-captcha-{token}": { + 'get_core-{_version}-users-captcha-{token}': { parameters: { query?: never; header?: { - "x-pm-nonce"?: string | null; + 'x-pm-nonce'?: string | null; }; path: { _version: string; @@ -19506,7 +19508,7 @@ export interface operations { }; }; }; - "get_core-{_version}-users-disable-{jwt}": { + 'get_core-{_version}-users-disable-{jwt}': { parameters: { query?: never; header?: never; @@ -19525,14 +19527,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-members-{enc_id}-vpn": { + 'get_core-{_version}-members-{enc_id}-vpn': { parameters: { query?: { /** @@ -19566,8 +19568,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example */ VPNName?: string; /** @example */ @@ -19577,14 +19579,14 @@ export interface operations { * @example 1654615966 */ LastVPNLogin?: number | null; - ActiveVPNSessions?: components["schemas"]["VPNAuthenticationCertificateDetailedTransformer"][]; - AuthenticationCertificates?: components["schemas"]["VPNAuthenticationCertificateDetailedTransformer"][]; + ActiveVPNSessions?: components['schemas']['VPNAuthenticationCertificateDetailedTransformer'][]; + AuthenticationCertificates?: components['schemas']['VPNAuthenticationCertificateDetailedTransformer'][]; }; }; }; }; }; - "put_core-{_version}-members-{enc_id}-vpn": { + 'put_core-{_version}-members-{enc_id}-vpn': { parameters: { query?: never; header?: never; @@ -19601,7 +19603,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example 2 */ MaxVPN?: number; }; @@ -19614,14 +19616,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-v4-features": { + 'get_core-v4-features': { parameters: { query?: { /** @@ -19671,17 +19673,17 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example 76 */ Total?: number; - Features?: components["schemas"]["FeatureTransformer"][]; + Features?: components['schemas']['FeatureTransformer'][]; }; }; }; }; }; - "post_core-v4-features": { + 'post_core-v4-features': { parameters: { query?: never; header?: never; @@ -19690,14 +19692,14 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example blackFriday */ Code?: string; /** * @example string * @enum {string} */ - Type?: "boolean" | "integer" | "float" | "string" | "enumeration" | "mixed"; + Type?: 'boolean' | 'integer' | 'float' | 'string' | 'enumeration' | 'mixed'; /** @description List of the values if type is enumeration */ Options?: string[]; /** @@ -19725,15 +19727,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Feature?: components["schemas"]["FeatureTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Feature?: components['schemas']['FeatureTransformer']; }; }; }; }; }; - "put_core-v4-features-{id}": { + 'put_core-v4-features-{id}': { parameters: { query?: never; header?: never; @@ -19745,14 +19747,14 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example blackFriday */ Code?: string; /** * @example string * @enum {string} */ - Type?: "boolean" | "integer" | "float" | "string" | "enumeration" | "mixed"; + Type?: 'boolean' | 'integer' | 'float' | 'string' | 'enumeration' | 'mixed'; /** @description List of the values if type is enumeration */ Options?: string[]; /** @@ -19792,9 +19794,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Feature?: components["schemas"]["FeatureTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Feature?: components['schemas']['FeatureTransformer']; }; }; }; @@ -19804,7 +19806,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2001 */ Code?: number; /** @example higher is not one of the possible options among [low, medium, high]. */ @@ -19818,7 +19820,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2026 */ Code?: number; /** @example You're not allowed to modify the value of this feature */ @@ -19832,7 +19834,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -19842,7 +19844,7 @@ export interface operations { }; }; }; - "delete_core-v4-features-{featureID}": { + 'delete_core-v4-features-{featureID}': { parameters: { query?: never; header?: never; @@ -19861,8 +19863,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -19872,7 +19874,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -19882,7 +19884,7 @@ export interface operations { }; }; }; - "get_core-v4-features-{code}": { + 'get_core-v4-features-{code}': { parameters: { query?: never; header?: never; @@ -19899,9 +19901,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Feature?: components["schemas"]["FeatureTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Feature?: components['schemas']['FeatureTransformer']; }; }; }; @@ -19911,7 +19913,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -19921,7 +19923,7 @@ export interface operations { }; }; }; - "put_core-v4-features-{code}-value": { + 'put_core-v4-features-{code}-value': { parameters: { query?: never; header?: never; @@ -19933,7 +19935,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example true */ Value?: Record; }; @@ -19946,9 +19948,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Feature?: components["schemas"]["FeatureTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Feature?: components['schemas']['FeatureTransformer']; }; }; }; @@ -19958,7 +19960,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2001 */ Code?: number; /** @example higher is not one of the possible options among [low, medium, high]. */ @@ -19972,7 +19974,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2026 */ Code?: number; /** @example You're not allowed to modify the value of this feature */ @@ -19986,7 +19988,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -19996,7 +19998,7 @@ export interface operations { }; }; }; - "delete_core-v4-features-{code}-value": { + 'delete_core-v4-features-{code}-value': { parameters: { query?: never; header?: never; @@ -20013,9 +20015,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Feature?: components["schemas"]["FeatureTransformer"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Feature?: components['schemas']['FeatureTransformer']; }; }; }; @@ -20025,7 +20027,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2026 */ Code?: number; /** @example You're not allowed to modify the value of this feature */ @@ -20039,7 +20041,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -20049,7 +20051,7 @@ export interface operations { }; }; }; - "put_core-v4-features-{code}-user-value": { + 'put_core-v4-features-{code}-user-value': { parameters: { query?: never; header?: never; @@ -20061,7 +20063,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example true */ Value?: Record; UserIDs?: number[]; @@ -20076,8 +20078,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Number of touched users * @example 2 @@ -20092,7 +20094,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2001 */ Code?: number; /** @example higher is not one of the possible options among [low, medium, high]. */ @@ -20106,7 +20108,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2026 */ Code?: number; /** @example You're not allowed to modify the value of this feature */ @@ -20120,7 +20122,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -20130,7 +20132,7 @@ export interface operations { }; }; }; - "post_core-{_version}-auth-info": { + 'post_core-{_version}-auth-info': { parameters: { query?: never; header?: never; @@ -20141,7 +20143,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -20154,7 +20156,7 @@ export interface operations { * @example auto * @enum {string} */ - Intent?: "Proton" | "SSO" | "Auto"; + Intent?: 'Proton' | 'SSO' | 'Auto'; /** * @description optional field, to start a testing sso login flow * @example true @@ -20175,38 +20177,40 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** - * @description If Intent SSO or Auto, token to pass to GET /auth/sso/{token} for initiating the SSO flow - * @example a5fd396fcbb - */ - SSOChallengeToken?: string; - } | { - Code?: components["schemas"]["ResponseCodeSuccess"]; - /** @example -----BEGIN PGP SIGNED MESSAGE-----*-----END SIGNATURE----- */ - Modulus?: string; - /** @example */ - ServerEphemeral?: string; - /** @example 4 */ - Version?: number; - /** @example */ - Salt?: string; - /** @example */ - SRPSession?: string; - /** @description Only if already authenticated (not on login) */ - "2FA"?: { - /** - * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both - * @example 3 - */ - Enabled?: number; - FIDO2?: { - /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ - AuthenticationOptions?: Record; - RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; - }; - }; - }; + 'application/json': + | { + /** + * @description If Intent SSO or Auto, token to pass to GET /auth/sso/{token} for initiating the SSO flow + * @example a5fd396fcbb + */ + SSOChallengeToken?: string; + } + | { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example -----BEGIN PGP SIGNED MESSAGE-----*-----END SIGNATURE----- */ + Modulus?: string; + /** @example */ + ServerEphemeral?: string; + /** @example 4 */ + Version?: number; + /** @example */ + Salt?: string; + /** @example */ + SRPSession?: string; + /** @description Only if already authenticated (not on login) */ + '2FA'?: { + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Enabled?: number; + FIDO2?: { + /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ + AuthenticationOptions?: Record; + RegisteredKeys?: components['schemas']['Fido2RegisteredKey'][]; + }; + }; + }; }; }; /** @description Bad Request */ @@ -20215,7 +20219,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Session is not tied to a user and Username is null * @enum {integer} @@ -20234,42 +20238,45 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** - * @description User doesn't have SSO. Returned if Intent=SSO or Intent=Auto on backend's whim - * @enum {integer} - */ - Code?: 8101; - /** @example Email domain not found, please sign in with a password */ - Error?: string; - /** @description Empty */ - Details?: Record; - } | { - /** - * @description User has SSO. Returned if Intent=Proton or Intent=Auto on backend's whim - * @enum {integer} - */ - Code?: 8100; - /** @example Email domain associated to an existing organization. Please sign in with SSO */ - Error?: string; - /** @description Empty */ - Details?: Record; - } | { - /** - * @description Upgrade the app to call the endpoint this way - * @enum {integer} - */ - Code?: 5003; - /** @example You need to update this app in order to perform this operation */ - Error?: string; - /** @description Empty */ - Details?: Record; - }; - }; - }; - }; - }; - "get_core-{_version}-auth-sso-{token}": { + 'application/json': + | { + /** + * @description User doesn't have SSO. Returned if Intent=SSO or Intent=Auto on backend's whim + * @enum {integer} + */ + Code?: 8101; + /** @example Email domain not found, please sign in with a password */ + Error?: string; + /** @description Empty */ + Details?: Record; + } + | { + /** + * @description User has SSO. Returned if Intent=Proton or Intent=Auto on backend's whim + * @enum {integer} + */ + Code?: 8100; + /** @example Email domain associated to an existing organization. Please sign in with SSO */ + Error?: string; + /** @description Empty */ + Details?: Record; + } + | { + /** + * @description Upgrade the app to call the endpoint this way + * @enum {integer} + */ + Code?: 5003; + /** @example You need to update this app in order to perform this operation */ + Error?: string; + /** @description Empty */ + Details?: Record; + }; + }; + }; + }; + }; + 'get_core-{_version}-auth-sso-{token}': { parameters: { query?: { FinalRedirectBaseUrl?: string | null; @@ -20295,7 +20302,7 @@ export interface operations { }; }; }; - "post_core-{_version}-auth-saml": { + 'post_core-{_version}-auth-saml': { parameters: { query?: never; header?: never; @@ -20306,7 +20313,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["IdpResponseVO"]; + 'application/json': components['schemas']['IdpResponseVO']; }; }; responses: { @@ -20318,7 +20325,7 @@ export interface operations { }; }; }; - "post_core-{_version}-auth": { + 'post_core-{_version}-auth': { parameters: { query?: never; header?: never; @@ -20329,7 +20336,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AuthInput"]; + 'application/json': components['schemas']['AuthInput']; }; }; responses: { @@ -20339,8 +20346,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Session unique ID * @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 @@ -20388,7 +20395,7 @@ export interface operations { * @example 0 */ TemporaryPassword?: number; - "2FA"?: { + '2FA'?: { /** * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both * @example 3 @@ -20397,7 +20404,7 @@ export interface operations { FIDO2?: { /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ AuthenticationOptions?: Record; - RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + RegisteredKeys?: components['schemas']['Fido2RegisteredKey'][]; }; }; }; @@ -20405,7 +20412,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-auth": { + 'delete_core-{_version}-auth': { parameters: { query?: { /** @description if 1 log out this child only */ @@ -20427,14 +20434,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-auth-jwt": { + 'post_core-{_version}-auth-jwt': { parameters: { query?: never; header?: never; @@ -20445,7 +20452,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example eyJhbGciOiJIUzI1Ni...yJV_adQssw5c */ Token?: string; /** @@ -20463,8 +20470,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example 3af9b9780014cacb4b72bc5c73c1d7c4bad6c1e3 */ AccessToken?: string; /** @@ -20489,7 +20496,7 @@ export interface operations { }; }; }; - "post_core-{_version}-auth-2fa": { + 'post_core-{_version}-auth-2fa': { parameters: { query?: never; header?: never; @@ -20500,7 +20507,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description either this or the FIDO2 object * @example 123456 or recovery code @@ -20528,8 +20535,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @deprecated * @example full @@ -20541,7 +20548,7 @@ export interface operations { }; }; }; - "get_core-{_version}-auth-modulus": { + 'get_core-{_version}-auth-modulus': { parameters: { query?: never; header?: never; @@ -20558,8 +20565,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example -----BEGIN PGP SIGNED MESSAGE-----.*-----END PGP SIGNATURE----- */ Modulus?: string; /** @example Oq_JB_IkrOx5WlpxzlRPocN3_NhJ80V7DGav77eRtSDkOtLxW2jfI3nUpEqANGpboOyN-GuzEFXadlpxgVp7_g== */ @@ -20569,7 +20576,7 @@ export interface operations { }; }; }; - "get_core-{_version}-auth-scopes": { + 'get_core-{_version}-auth-scopes': { parameters: { query?: never; header?: never; @@ -20586,8 +20593,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @deprecated * @example 217017207043915776 @@ -20599,7 +20606,7 @@ export interface operations { }; }; }; - "post_core-{_version}-auth-refresh": { + 'post_core-{_version}-auth-refresh': { parameters: { query?: never; header?: never; @@ -20610,7 +20617,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example token */ ResponseType?: string; /** @example refresh_token */ @@ -20633,8 +20640,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example abcDecryptedTokenAndNoSaltAndNoPrivateKey123 */ AccessToken?: string; /** @@ -20666,7 +20673,7 @@ export interface operations { }; }; }; - "post_core-{_version}-auth-cookies": { + 'post_core-{_version}-auth-cookies': { parameters: { query?: never; header?: never; @@ -20677,7 +20684,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example token */ ResponseType?: string; /** @example refresh_token */ @@ -20699,8 +20706,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ UID?: string; /** @example 0 */ @@ -20715,7 +20722,7 @@ export interface operations { }; }; }; - "post_core-{_version}-auth-credentialless": { + 'post_core-{_version}-auth-credentialless': { parameters: { query?: never; header?: never; @@ -20726,7 +20733,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateCredentiallessUserInput"]; + 'application/json': components['schemas']['CreateCredentiallessUserInput']; }; }; responses: { @@ -20736,12 +20743,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateCredentiallessUserOutput"]; + 'application/json': components['schemas']['CreateCredentiallessUserOutput']; }; }; }; }; - "get_core-{_version}-settings-mnemonic": { + 'get_core-{_version}-settings-mnemonic': { parameters: { query?: never; header?: never; @@ -20758,8 +20765,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; MnemonicUserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ ID?: string; @@ -20773,7 +20780,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-mnemonic": { + 'put_core-{_version}-settings-mnemonic': { parameters: { query?: never; header?: never; @@ -20784,7 +20791,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { MnemonicUserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ ID?: string; @@ -20794,7 +20801,7 @@ export interface operations { /** @example 1H8EGg3J1Qwk243hf== */ MnemonicSalt?: string; /** @description The new mnemonic SRP verifier */ - MnemonicAuth?: components["schemas"]["AuthInfoInput"]; + MnemonicAuth?: components['schemas']['AuthInfoInput']; /** * @description Optional, for inline re-authentication * @example @@ -20838,8 +20845,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -20850,7 +20857,7 @@ export interface operations { }; }; }; - "get_core-{_version}-settings-mnemonic-reset": { + 'get_core-{_version}-settings-mnemonic-reset': { parameters: { query?: never; header?: never; @@ -20867,8 +20874,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; MnemonicUserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ ID?: string; @@ -20882,7 +20889,7 @@ export interface operations { }; }; }; - "post_core-{_version}-settings-mnemonic-reset": { + 'post_core-{_version}-settings-mnemonic-reset': { parameters: { query?: never; header?: never; @@ -20893,7 +20900,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @description The user keys encrypted with the account password */ UserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ @@ -20924,15 +20931,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Scopes?: string[]; }; }; }; }; }; - "post_core-{_version}-settings-mnemonic-disable": { + 'post_core-{_version}-settings-mnemonic-disable': { parameters: { query?: never; header?: never; @@ -20943,7 +20950,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional, for inline re-authentication * @example @@ -20987,8 +20994,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Present only if inline re-authentication is submitted * @example @@ -20999,7 +21006,7 @@ export interface operations { }; }; }; - "put_core-{_version}-settings-mnemonic-reactivate": { + 'put_core-{_version}-settings-mnemonic-reactivate': { parameters: { query?: never; header?: never; @@ -21010,7 +21017,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { MnemonicUserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ ID?: string; @@ -21020,7 +21027,7 @@ export interface operations { /** @example 1H8EGg3J1Qwk243hf== */ MnemonicSalt?: string; /** @description The new mnemonic SRP verifier */ - MnemonicAuth?: components["schemas"]["AuthInfoInput"]; + MnemonicAuth?: components['schemas']['AuthInfoInput']; }; }; }; @@ -21031,14 +21038,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-pushes": { + 'get_core-{_version}-pushes': { parameters: { query?: { /** @@ -21066,15 +21073,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Pushes?: components["schemas"]["PushTransformer"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Pushes?: components['schemas']['PushTransformer'][]; }; }; }; }; }; - "get_core-{_version}-pushes-active": { + 'get_core-{_version}-pushes-active': { parameters: { query?: { /** @@ -21102,15 +21109,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Pushes?: components["schemas"]["PushTransformer"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Pushes?: components['schemas']['PushTransformer'][]; }; }; }; }; }; - "get_core-{_version}-pushes-active-session": { + 'get_core-{_version}-pushes-active-session': { parameters: { query?: { /** @@ -21138,15 +21145,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Pushes?: components["schemas"]["PushTransformer"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Pushes?: components['schemas']['PushTransformer'][]; }; }; }; }; }; - "delete_core-{_version}-pushes-{enc_id}": { + 'delete_core-{_version}-pushes-{enc_id}': { parameters: { query?: never; header?: never; @@ -21164,15 +21171,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Pushes?: string[]; }; }; }; }; }; - "get_core-{_version}-referrals": { + 'get_core-{_version}-referrals': { parameters: { query?: { /** @description Skip the given number of results */ @@ -21194,16 +21201,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Referrals?: components["schemas"]["ReferralOutput"][]; + 'application/json': { + Referrals?: components['schemas']['ReferralOutput'][]; Total?: number; - Code?: components["schemas"]["ResponseCodeSuccess"]; + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-referrals": { + 'post_core-{_version}-referrals': { parameters: { query?: never; header?: never; @@ -21214,7 +21221,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SendInvitationsInput"]; + 'application/json': components['schemas']['SendInvitationsInput']; }; }; responses: { @@ -21224,15 +21231,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Referrals?: components["schemas"]["ReferralOutput"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Referrals?: components['schemas']['ReferralOutput'][]; }; }; }; }; }; - "get_core-{_version}-referrals-status": { + 'get_core-{_version}-referrals-status': { parameters: { query?: never; header?: never; @@ -21249,15 +21256,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Referrals?: components["schemas"]["ReferralStatus"][]; - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Referrals?: components['schemas']['ReferralStatus'][]; + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-referrals-identifiers-{identifier}": { + 'get_core-{_version}-referrals-identifiers-{identifier}': { parameters: { query?: never; header?: never; @@ -21276,7 +21283,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + 'application/json': unknown; }; }; /** @description The identifier does not exist */ @@ -21285,12 +21292,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + 'application/json': unknown; }; }; }; }; - "post_core-{_version}-devices": { + 'post_core-{_version}-devices': { parameters: { query?: never; header?: never; @@ -21301,7 +21308,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RegisterDeviceInput"]; + 'application/json': components['schemas']['RegisterDeviceInput']; }; }; responses: { @@ -21311,14 +21318,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-devices": { + 'delete_core-{_version}-devices': { parameters: { query?: never; header?: never; @@ -21329,7 +21336,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example 4b3403665fea6... */ DeviceToken?: string; /** @@ -21347,14 +21354,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-betas-{client_id}": { + 'get_core-{_version}-betas-{client_id}': { parameters: { query?: never; header?: never; @@ -21376,8 +21383,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Beta?: { /** @example iOSVPN */ ClientID?: string; @@ -21393,7 +21400,7 @@ export interface operations { }; }; }; - "put_core-{_version}-betas-{client_id}": { + 'put_core-{_version}-betas-{client_id}': { parameters: { query?: never; header?: never; @@ -21409,7 +21416,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example john@example.com */ Email?: string; }; @@ -21422,8 +21429,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Beta?: { /** @example iOSVPN */ ClientID?: string; @@ -21439,7 +21446,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-betas-{client_id}": { + 'delete_core-{_version}-betas-{client_id}': { parameters: { query?: never; header?: never; @@ -21461,14 +21468,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-betas": { + 'get_core-{_version}-betas': { parameters: { query?: never; header?: never; @@ -21485,8 +21492,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Betas?: { /** @example iOSVPN */ ClientID?: string; @@ -21502,7 +21509,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-betas": { + 'delete_core-{_version}-betas': { parameters: { query?: never; header?: never; @@ -21519,14 +21526,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-geofeed-geofeed-csv": { + 'get_core-{_version}-geofeed-geofeed-csv': { parameters: { query?: never; header?: never; @@ -21545,7 +21552,7 @@ export interface operations { }; }; }; - "get_core-{_version}-geofeed-geofeed-public-csv": { + 'get_core-{_version}-geofeed-geofeed-public-csv': { parameters: { query?: never; header?: never; @@ -21564,7 +21571,7 @@ export interface operations { }; }; }; - "get_core-{_version}-load": { + 'get_core-{_version}-load': { parameters: { query?: never; header?: never; @@ -21581,14 +21588,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-load": { + 'post_core-{_version}-load': { parameters: { query?: never; header?: never; @@ -21605,14 +21612,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-logs-auth": { + 'get_core-{_version}-logs-auth': { parameters: { query?: { /** @@ -21644,9 +21651,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Logs?: components["schemas"]["AuthLogResponse"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Logs?: components['schemas']['AuthLogResponse'][]; /** @example 1 */ Total?: number; }; @@ -21654,7 +21661,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-logs-auth": { + 'delete_core-{_version}-logs-auth': { parameters: { query?: never; header?: never; @@ -21671,14 +21678,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-metrics": { + 'get_core-{_version}-metrics': { parameters: { query?: { /** @example signup */ @@ -21702,14 +21709,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-metrics": { + 'post_core-{_version}-metrics': { parameters: { query?: never; header?: never; @@ -21720,12 +21727,12 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @example encrypted_search * @enum {string} */ - Log?: "signup" | "encrypted_search" | "dark_styles"; + Log?: 'signup' | 'encrypted_search' | 'dark_styles'; /** * @description Optional title * @example index @@ -21745,14 +21752,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-settings-recovery-secret": { + 'post_core-{_version}-settings-recovery-secret': { parameters: { query?: never; header?: never; @@ -21763,7 +21770,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Base64-encoded secret, decodes to 32 bytes * @example 1H8EGg3J1...Qwk243hf @@ -21781,14 +21788,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-settings-recovery-secret": { + 'delete_core-{_version}-settings-recovery-secret': { parameters: { query?: never; header?: never; @@ -21805,14 +21812,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-reports-form-{portal_id}-{form_id}": { + 'post_core-{_version}-reports-form-{portal_id}-{form_id}': { parameters: { query?: never; header?: never; @@ -21825,7 +21832,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { fields?: Record; context?: Record; legalConsentOptions?: Record; @@ -21839,14 +21846,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-reports-bug": { + 'post_core-{_version}-reports-bug': { parameters: { query?: never; header?: never; @@ -21857,7 +21864,7 @@ export interface operations { }; requestBody?: { content: { - "multipart/form-data": { + 'multipart/form-data': { /** * @description Client should supply if mobile app, ask user if web app * @example iOS @@ -21976,14 +21983,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-reports-bug-attachments": { + 'post_core-{_version}-reports-bug-attachments': { parameters: { query?: never; header?: never; @@ -21994,7 +22001,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UploadAttachment"]; + 'application/json': components['schemas']['UploadAttachment']; }; }; responses: { @@ -22004,14 +22011,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "delete_core-{_version}-reports-bug-{ticketId}": { + 'delete_core-{_version}-reports-bug-{ticketId}': { parameters: { query?: { RequesterID?: number; @@ -22033,8 +22040,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -22044,7 +22051,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2501 */ Code?: number; /** @example Ticket does not exist */ @@ -22054,7 +22061,7 @@ export interface operations { }; }; }; - "post_core-{_version}-reports-abuse": { + 'post_core-{_version}-reports-abuse': { parameters: { query?: never; header?: never; @@ -22065,7 +22072,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example harassment */ Category?: string; /** @example This person has been harassing me. */ @@ -22090,14 +22097,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-reports-crash": { + 'post_core-{_version}-reports-crash': { parameters: { query?: never; header?: never; @@ -22108,7 +22115,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Optional * @example iOS @@ -22147,7 +22154,7 @@ export interface operations { /** @description Client should supply */ Debug?: { /** @example you want */ - "Whatever JSON"?: string; + 'Whatever JSON'?: string; }; }; }; @@ -22159,14 +22166,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-reports-sentry-api-{id}-{type}": { + 'post_core-{_version}-reports-sentry-api-{id}-{type}': { parameters: { query?: never; header?: never; @@ -22187,7 +22194,7 @@ export interface operations { }; }; }; - "post_core-{_version}-reports-phishing": { + 'post_core-{_version}-reports-phishing': { parameters: { query?: never; header?: never; @@ -22198,7 +22205,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== */ MessageID?: string; /** @@ -22218,14 +22225,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-reports-spam": { + 'post_core-{_version}-reports-spam': { parameters: { query?: never; header?: never; @@ -22244,7 +22251,7 @@ export interface operations { }; }; }; - "post_core-{_version}-reports-cancel-plan": { + 'post_core-{_version}-reports-cancel-plan': { parameters: { query?: never; header?: never; @@ -22255,7 +22262,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CancelPlanReport"]; + 'application/json': components['schemas']['CancelPlanReport']; }; }; responses: { @@ -22265,14 +22272,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-reset-{username}-{token}": { + 'get_core-{_version}-reset-{username}-{token}': { parameters: { query?: never; header?: never; @@ -22296,8 +22303,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @example 1 * @enum {integer} @@ -22309,13 +22316,13 @@ export interface operations { */ SupportPgpV6Keys?: 0 | 1; /** @description NB: PrivateKey is null in keys */ - Addresses?: components["schemas"]["AddressUser"][]; + Addresses?: components['schemas']['AddressUser'][]; }; }; }; }; }; - "post_core-{_version}-reset": { + 'post_core-{_version}-reset': { parameters: { query?: never; header?: never; @@ -22326,7 +22333,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example derp */ Username?: string; /** @@ -22349,8 +22356,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -22360,7 +22367,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 19305 */ Code?: number; /** @example Username and recovery email mismatch */ @@ -22371,7 +22378,7 @@ export interface operations { }; }; }; - "post_core-{_version}-reset-username": { + 'post_core-{_version}-reset-username': { parameters: { query?: never; header?: never; @@ -22382,7 +22389,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description if Phone is not present * @example derp@gmail.com @@ -22403,14 +22410,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-system-config": { + 'get_core-{_version}-system-config': { parameters: { query?: never; header?: never; @@ -22429,7 +22436,7 @@ export interface operations { }; }; }; - "get_core-{_version}-system-version": { + 'get_core-{_version}-system-version': { parameters: { query?: never; header?: never; @@ -22448,7 +22455,7 @@ export interface operations { }; }; }; - "get_core-{_version}-tests-exception": { + 'get_core-{_version}-tests-exception': { parameters: { query?: never; header?: never; @@ -22467,7 +22474,7 @@ export interface operations { }; }; }; - "get_core-{_version}-tests-error": { + 'get_core-{_version}-tests-error': { parameters: { query?: never; header?: never; @@ -22486,7 +22493,7 @@ export interface operations { }; }; }; - "get_core-{_version}-tests-notice": { + 'get_core-{_version}-tests-notice': { parameters: { query?: never; header?: never; @@ -22505,7 +22512,7 @@ export interface operations { }; }; }; - "get_core-{_version}-tests-memoryLeak": { + 'get_core-{_version}-tests-memoryLeak': { parameters: { query?: never; header?: never; @@ -22524,7 +22531,7 @@ export interface operations { }; }; }; - "get_core-{_version}-tests-logger": { + 'get_core-{_version}-tests-logger': { parameters: { query?: never; header?: never; @@ -22543,7 +22550,7 @@ export interface operations { }; }; }; - "get_core-{_version}-tests-logger-observability": { + 'get_core-{_version}-tests-logger-observability': { parameters: { query?: { Level?: number; @@ -22564,7 +22571,7 @@ export interface operations { }; }; }; - "get_core-{_version}-tests-ping": { + 'get_core-{_version}-tests-ping': { parameters: { query?: never; header?: never; @@ -22581,14 +22588,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-tests-version": { + 'get_core-{_version}-tests-version': { parameters: { query?: never; header?: never; @@ -22607,7 +22614,7 @@ export interface operations { }; }; }; - "get_core-{_version}-tests-stream": { + 'get_core-{_version}-tests-stream': { parameters: { query?: never; header?: never; @@ -22626,7 +22633,7 @@ export interface operations { }; }; }; - "get_core-{_version}-update": { + 'get_core-{_version}-update': { parameters: { query?: { /** @example 24m */ @@ -22646,14 +22653,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-users-invitations": { + 'get_core-{_version}-users-invitations': { parameters: { query?: never; header?: never; @@ -22670,14 +22677,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - } & components["schemas"]["GetUserInvitationsOutput"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + } & components['schemas']['GetUserInvitationsOutput']; }; }; }; }; - "post_core-{_version}-users-invitations-{enc_id}-reject": { + 'post_core-{_version}-users-invitations-{enc_id}-reject': { parameters: { query?: never; header?: never; @@ -22695,14 +22702,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-users-invitations-{enc_id}-accept": { + 'post_core-{_version}-users-invitations-{enc_id}-accept': { parameters: { query?: never; header?: never; @@ -22720,8 +22727,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -22731,18 +22738,18 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2011 */ Code?: number; Details?: { - Validation?: components["schemas"]["GetUserInvitationOutput"]; + Validation?: components['schemas']['GetUserInvitationOutput']; }; }; }; }; }; }; - "post_core-{_version}-validate-email": { + 'post_core-{_version}-validate-email': { parameters: { query?: never; header?: never; @@ -22753,7 +22760,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Email address * @example einstein@pm.me @@ -22769,8 +22776,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -22780,7 +22787,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonError"] & { + 'application/json': components['schemas']['ProtonError'] & { /** * @description Email address failed validation * @default 2050 @@ -22791,7 +22798,7 @@ export interface operations { }; }; }; - "post_core-{_version}-validate-phone": { + 'post_core-{_version}-validate-phone': { parameters: { query?: never; header?: never; @@ -22802,7 +22809,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description Phone number * @example +37012345678 @@ -22818,8 +22825,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -22829,7 +22836,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonError"] & { + 'application/json': components['schemas']['ProtonError'] & { /** * @description Phone number failed validation * @default 2058 @@ -22840,7 +22847,7 @@ export interface operations { }; }; }; - "get_core-{_version}-verification-ownership-{token}": { + 'get_core-{_version}-verification-ownership-{token}': { parameters: { query?: never; header?: never; @@ -22860,7 +22867,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verification-ownership-{token}": { + 'post_core-{_version}-verification-ownership-{token}': { parameters: { query?: never; header?: never; @@ -22880,7 +22887,7 @@ export interface operations { }; }; }; - "get_core-{_version}-verification-ownership-email-{token}": { + 'get_core-{_version}-verification-ownership-email-{token}': { parameters: { query?: never; header?: never; @@ -22900,7 +22907,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verification-ownership-email-{token}": { + 'post_core-{_version}-verification-ownership-email-{token}': { parameters: { query?: never; header?: never; @@ -22920,7 +22927,7 @@ export interface operations { }; }; }; - "get_core-{_version}-verification-ownership-sms-{token}": { + 'get_core-{_version}-verification-ownership-sms-{token}': { parameters: { query?: never; header?: never; @@ -22940,7 +22947,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verification-ownership-sms-{token}": { + 'post_core-{_version}-verification-ownership-sms-{token}': { parameters: { query?: never; header?: never; @@ -22960,7 +22967,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verification-ownership-{token}-{code}": { + 'post_core-{_version}-verification-ownership-{token}-{code}': { parameters: { query?: never; header?: never; @@ -22981,7 +22988,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verification-ownership-email-{token}-{code}": { + 'post_core-{_version}-verification-ownership-email-{token}-{code}': { parameters: { query?: never; header?: never; @@ -23002,7 +23009,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verification-ownership-sms-{token}-{code}": { + 'post_core-{_version}-verification-ownership-sms-{token}-{code}': { parameters: { query?: never; header?: never; @@ -23023,12 +23030,12 @@ export interface operations { }; }; }; - "get_core-v6-events-{id}": { + 'get_core-v6-events-{id}': { parameters: { query?: never; header?: never; path: { - id: components["schemas"]["Id"]; + id: components['schemas']['Id']; }; cookie?: never; }; @@ -23037,16 +23044,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Stream"]; + 'application/json': components['schemas']['Stream']; }; }; }; }; - "get_core-{_version}-events-latest": { + 'get_core-{_version}-events-latest': { parameters: { query?: never; header?: never; @@ -23063,8 +23070,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example ACXDmTaBub14w== */ EventID?: string; }; @@ -23072,17 +23079,17 @@ export interface operations { }; }; }; - "get_core-{_version}-events-{id}": { + 'get_core-{_version}-events-{id}': { parameters: { query?: { - MessageCounts?: components["schemas"]["BoolInt"]; - ConversationCounts?: components["schemas"]["BoolInt"]; + MessageCounts?: components['schemas']['BoolInt']; + ConversationCounts?: components['schemas']['BoolInt']; NoMetaData?: unknown[]; }; header?: never; path: { _version: string; - id: components["schemas"]["Id"]; + id: components['schemas']['Id']; }; cookie?: never; }; @@ -23094,22 +23101,22 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventInfo"]; + 'application/json': components['schemas']['EventInfo']; }; }; }; }; - "get_core-v4-events-{id}": { + 'get_core-v4-events-{id}': { parameters: { query?: { MessageCounts?: boolean; ConversationCounts?: boolean; }; header?: { - "x-pm-appversion"?: string; + 'x-pm-appversion'?: string; }; path: { - id: components["schemas"]["Id"]; + id: components['schemas']['Id']; }; cookie?: never; }; @@ -23121,12 +23128,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventInfo"]; + 'application/json': components['schemas']['EventInfo']; }; }; }; }; - "post_core-{_version}-feedback": { + 'post_core-{_version}-feedback': { parameters: { query?: never; header?: never; @@ -23137,7 +23144,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["FeedbackVO"]; + 'application/json': components['schemas']['FeedbackVO']; }; }; responses: { @@ -23147,14 +23154,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "get_core-{_version}-checklist-get-started": { + 'get_core-{_version}-checklist-get-started': { parameters: { query?: never; header?: never; @@ -23171,8 +23178,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @description Array of completed checklist items */ Items?: string[]; /** @description Timestamp of checklist creation */ @@ -23186,7 +23193,7 @@ export interface operations { }; }; }; - "get_core-{_version}-checklist-paying-user": { + 'get_core-{_version}-checklist-paying-user': { parameters: { query?: never; header?: never; @@ -23203,8 +23210,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @description Array of completed checklist items */ Items?: string[]; /** @description Timestamp of checklist creation */ @@ -23214,7 +23221,7 @@ export interface operations { }; }; }; - "post_core-{_version}-checklist-get-started-seen-completed-list": { + 'post_core-{_version}-checklist-get-started-seen-completed-list': { parameters: { query?: never; header?: never; @@ -23231,14 +23238,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-checklist-paying-user-hide": { + 'post_core-{_version}-checklist-paying-user-hide': { parameters: { query?: never; header?: never; @@ -23255,14 +23262,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-checklist-paying-user-seen-completed-list": { + 'post_core-{_version}-checklist-paying-user-seen-completed-list': { parameters: { query?: never; header?: never; @@ -23279,14 +23286,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-checklist-get-started-init": { + 'post_core-{_version}-checklist-get-started-init': { parameters: { query?: never; header?: never; @@ -23303,14 +23310,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-checklist-paying-user-init": { + 'post_core-{_version}-checklist-paying-user-init': { parameters: { query?: never; header?: never; @@ -23327,14 +23334,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-checklist-check-item": { + 'put_core-{_version}-checklist-check-item': { parameters: { query?: never; header?: never; @@ -23345,7 +23352,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example MobileApp */ Item?: string; }; @@ -23358,14 +23365,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-checklist-update-display": { + 'put_core-{_version}-checklist-update-display': { parameters: { query?: never; header?: never; @@ -23376,7 +23383,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example Hidden */ Display?: string; }; @@ -23389,14 +23396,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-verify-send": { + 'post_core-{_version}-verify-send': { parameters: { query?: never; header?: never; @@ -23407,12 +23414,12 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @example external_email * @enum {string} */ - Type?: "external_email, recovery_email"; + Type?: 'external_email, recovery_email'; /** @example me@example.com */ Destination?: string; }; @@ -23425,14 +23432,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "post_core-{_version}-verify-validate": { + 'post_core-{_version}-verify-validate': { parameters: { query?: never; header?: never; @@ -23443,7 +23450,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ JWT?: string; }; @@ -23456,8 +23463,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Previous confirmation state * @example 1 @@ -23468,7 +23475,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-verify-validate": { + 'delete_core-{_version}-verify-validate': { parameters: { query?: never; header?: never; @@ -23479,7 +23486,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ JWT?: string; }; @@ -23492,8 +23499,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Previous confirmation state * @example 1 @@ -23504,7 +23511,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verify-email": { + 'post_core-{_version}-verify-email': { parameters: { query?: never; header?: never; @@ -23521,8 +23528,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Previous confirmation state * @example 1 @@ -23533,7 +23540,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verify-phone": { + 'post_core-{_version}-verify-phone': { parameters: { query?: never; header?: never; @@ -23550,8 +23557,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** * @description Previous confirmation state * @example 1 @@ -23562,7 +23569,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verify-reauth-email": { + 'post_core-{_version}-verify-reauth-email': { parameters: { query?: never; header?: never; @@ -23579,8 +23586,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -23590,7 +23597,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 12087 */ Code?: number; /** @example Invalid or already used token */ @@ -23604,7 +23611,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 9001 */ Code?: number; /** @example Human verification required */ @@ -23614,7 +23621,7 @@ export interface operations { }; }; }; - "post_core-{_version}-verify-reauth-phone": { + 'post_core-{_version}-verify-reauth-phone': { parameters: { query?: never; header?: never; @@ -23631,8 +23638,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -23642,7 +23649,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 12087 */ Code?: number; /** @example Invalid or already used token */ @@ -23656,7 +23663,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 9001 */ Code?: number; /** @example Human verification required */ @@ -23666,7 +23673,7 @@ export interface operations { }; }; }; - "get_core-{_version}-notifications": { + 'get_core-{_version}-notifications': { parameters: { query?: { /** @@ -23674,11 +23681,11 @@ export interface operations { * @example 2 */ WithImageScale?: number; - FullScreenImageSupport?: components["schemas"]["NotificationRequest"]["FullScreenImageSupport"]; - FullScreenImageWidth?: components["schemas"]["NotificationRequest"]["FullScreenImageWidth"]; - FullScreenImageHeight?: components["schemas"]["NotificationRequest"]["FullScreenImageHeight"]; - SupportedFullScreenImageFormats?: components["schemas"]["NotificationRequest"]["SupportedFullScreenImageFormats"]; - Null?: components["schemas"]["NotificationRequest"]["Null"]; + FullScreenImageSupport?: components['schemas']['NotificationRequest']['FullScreenImageSupport']; + FullScreenImageWidth?: components['schemas']['NotificationRequest']['FullScreenImageWidth']; + FullScreenImageHeight?: components['schemas']['NotificationRequest']['FullScreenImageHeight']; + SupportedFullScreenImageFormats?: components['schemas']['NotificationRequest']['SupportedFullScreenImageFormats']; + Null?: components['schemas']['NotificationRequest']['Null']; }; header?: never; path: { @@ -23694,15 +23701,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Notifications?: components["schemas"]["NotificationVersionTransformer"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Notifications?: components['schemas']['NotificationVersionTransformer'][]; }; }; }; }; }; - "patch_core-v4-labels-{enc_id}": { + 'patch_core-v4-labels-{enc_id}': { parameters: { query?: { /** @@ -23719,7 +23726,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["PatchInput"]; + 'application/json': components['schemas']['PatchInput']; }; }; responses: { @@ -23729,9 +23736,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Label?: components["schemas"]["Label"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Label?: components['schemas']['Label']; }; }; }; @@ -23741,7 +23748,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2500 */ Code?: number; /** @example Attribute Expanded should be of type int, null (float given) */ @@ -23751,7 +23758,7 @@ export interface operations { }; }; }; - "post_core-v4-labels-by-ids": { + 'post_core-v4-labels-by-ids': { parameters: { query?: never; header?: never; @@ -23760,7 +23767,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LabelIDs"]; + 'application/json': components['schemas']['LabelIDs']; }; }; responses: { @@ -23770,17 +23777,17 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; Labels?: { - [key: string]: components["schemas"]["Label"]; + [key: string]: components['schemas']['Label']; }; }; }; }; }; }; - "get_core-{_version}-labels": { + 'get_core-{_version}-labels': { parameters: { query?: { /** @@ -23803,15 +23810,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Labels?: components["schemas"]["Label"][]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Labels?: components['schemas']['Label'][]; }; }; }; }; }; - "post_core-{_version}-labels": { + 'post_core-{_version}-labels': { parameters: { query?: never; header?: never; @@ -23822,7 +23829,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description required, cannot be same as an existing label of this Type. Max length is 100 characters * @example Red Label @@ -23876,9 +23883,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Label?: components["schemas"]["Label"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Label?: components['schemas']['Label']; }; }; }; @@ -23888,7 +23895,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2011 */ Code?: number; /** @example Maximum 3 levels in the folder hierarchy */ @@ -23902,7 +23909,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2500 */ Code?: number; /** @example A label or folder with this name already exists */ @@ -23916,7 +23923,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2011 */ Code?: number; /** @example Invalid name */ @@ -23926,7 +23933,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-labels": { + 'delete_core-{_version}-labels': { parameters: { query?: never; header?: never; @@ -23937,7 +23944,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { LabelIDs?: string[]; }; }; @@ -23949,7 +23956,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 1001 */ Code?: number; /** @description Array of responses, one element per label */ @@ -23958,7 +23965,7 @@ export interface operations { /** @example KPlISx5MiML3XcSY-tfNw== */ LabelID?: string; Response?: { - Code?: components["schemas"]["ResponseCodeSuccess"]; + Code?: components['schemas']['ResponseCodeSuccess']; }; }; 1?: { @@ -23981,30 +23988,32 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @default 2000 */ - Code: number; - /** @default The LabelIDs is required */ - Error: string; - Details?: { - /** @default The LabelIDs is required */ - LabelIDs: Record; - }; - } | { - /** @default 2002 */ - Code: number; - /** @default The LabelIDs must be a array */ - Error: string; - Details?: { - /** @default The LabelIDs must be a array */ - LabelIDs: Record; - }; - }; + 'application/json': + | { + /** @default 2000 */ + Code: number; + /** @default The LabelIDs is required */ + Error: string; + Details?: { + /** @default The LabelIDs is required */ + LabelIDs: Record; + }; + } + | { + /** @default 2002 */ + Code: number; + /** @default The LabelIDs must be a array */ + Error: string; + Details?: { + /** @default The LabelIDs must be a array */ + LabelIDs: Record; + }; + }; }; }; }; }; - "get_core-{_version}-labels-available": { + 'get_core-{_version}-labels-available': { parameters: { query: { /** @description The name to check */ @@ -24028,8 +24037,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; @@ -24039,7 +24048,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2011 */ Code?: number; /** @example Maximum 3 levels in the folder hierarchy */ @@ -24053,7 +24062,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2500 */ Code?: number; /** @example A label or folder with this name already exists */ @@ -24067,7 +24076,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2011 */ Code?: number; /** @example Invalid name */ @@ -24077,7 +24086,7 @@ export interface operations { }; }; }; - "put_core-{_version}-labels-order": { + 'put_core-{_version}-labels-order': { parameters: { query?: never; header?: never; @@ -24088,7 +24097,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** @description Will amend the order of labels with the order of the corresponding LabelIDs */ LabelIDs?: string[]; /** @@ -24111,20 +24120,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-labels-order-tree-{startLabelId}": { + 'put_core-{_version}-labels-order-tree-{startLabelId}': { parameters: { query?: never; header?: never; path: { _version: string; - startLabelId: (string & components["schemas"]["Id"]) | null; + startLabelId: (string & components['schemas']['Id']) | null; }; cookie?: never; }; @@ -24136,14 +24145,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-labels-{id}": { + 'put_core-{_version}-labels-{id}': { parameters: { query?: { /** @@ -24166,7 +24175,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": { + 'application/json': { /** * @description required, cannot be same as an existing label of this Type. Max length is 100 characters. * * Must be the same for Message System Folders (Type = 4) @@ -24213,9 +24222,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; - Label?: components["schemas"]["Label"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Label?: components['schemas']['Label']; }; }; }; @@ -24225,7 +24234,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2011 */ Code?: number; /** @example Maximum 3 levels in the folder hierarchy */ @@ -24239,7 +24248,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2500 */ Code?: number; /** @example A sub-folder with this name already exists in the destination folder */ @@ -24249,7 +24258,7 @@ export interface operations { }; }; }; - "delete_core-{_version}-labels-{enc_id}": { + 'delete_core-{_version}-labels-{enc_id}': { parameters: { query?: { /** @@ -24273,14 +24282,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; }; }; }; }; }; - "put_core-{_version}-labels-{enc_labelID}-detach": { + 'put_core-{_version}-labels-{enc_labelID}-detach': { parameters: { query?: { /** @@ -24304,8 +24313,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @example 3 */ NumMessages?: number; }; @@ -24317,27 +24326,29 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @default 2001 */ - Code: number; - /** @default The action can't be performed on this label */ - Error: string; - Details?: { - /** @default LabelID corresponds to a global PM label, which can't be edited by this route */ - LabelID: Record; - }; - } | { - /** @default 2002 */ - Code: number; - /** @default The action can't be performed on this label */ - Error: string; - Details?: { - /** @default LabelID must correspond to a label of the MessageLabel type */ - LabelID: Record; - /** @default Folder */ - LabelTypeReceived: Record; - }; - }; + 'application/json': + | { + /** @default 2001 */ + Code: number; + /** @default The action can't be performed on this label */ + Error: string; + Details?: { + /** @default LabelID corresponds to a global PM label, which can't be edited by this route */ + LabelID: Record; + }; + } + | { + /** @default 2002 */ + Code: number; + /** @default The action can't be performed on this label */ + Error: string; + Details?: { + /** @default LabelID must correspond to a label of the MessageLabel type */ + LabelID: Record; + /** @default Folder */ + LabelTypeReceived: Record; + }; + }; }; }; /** @description Unprocessable Entity */ @@ -24346,7 +24357,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @default 2501 */ Code: number; /** @default Label does not exist */ @@ -24356,7 +24367,7 @@ export interface operations { }; }; }; - "get_core-{_version}-images": { + 'get_core-{_version}-images': { parameters: { query: { /** @@ -24392,11 +24403,11 @@ export interface operations { headers: { /** @description If this header is set, the image is being tracked. * The value of the headers is the service providing the tracking. */ - "X-Pm-Tracker-Provider"?: string; + 'X-Pm-Tracker-Provider'?: string; [name: string]: unknown; }; content: { - "application/octet-stream": string; + 'application/octet-stream': string; }; }; /** @description Return an empty image when we cannot proxy the remote image */ @@ -24412,7 +24423,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2000 */ Code?: number; /** @example The Url is required */ @@ -24426,7 +24437,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @example 2052 */ Code?: number; /** @example The Url is not valid URL */ diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index deec1aa5..92b11d18 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -4,7 +4,7 @@ */ export interface paths { - "/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple": { + '/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple': { parameters: { query?: never; header?: never; @@ -14,14 +14,14 @@ export interface paths { get?: never; put?: never; /** Add photos to an album */ - post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple"]; + post: operations['post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/albums": { + '/drive/photos/volumes/{volumeID}/albums': { parameters: { query?: never; header?: never; @@ -29,17 +29,17 @@ export interface paths { cookie?: never; }; /** List current user albums */ - get: operations["get_drive-photos-volumes-{volumeID}-albums"]; + get: operations['get_drive-photos-volumes-{volumeID}-albums']; put?: never; /** Create an album */ - post: operations["post_drive-photos-volumes-{volumeID}-albums"]; + post: operations['post_drive-photos-volumes-{volumeID}-albums']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/volumes": { + '/drive/photos/volumes': { parameters: { query?: never; header?: never; @@ -55,14 +55,14 @@ export interface paths { * + Photo share for the new Photo Volume * + Adds ShareMember with given Address ID */ - post: operations["post_drive-photos-volumes"]; + post: operations['post_drive-photos-volumes']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/albums/{linkID}": { + '/drive/photos/volumes/{volumeID}/albums/{linkID}': { parameters: { query?: never; header?: never; @@ -71,16 +71,16 @@ export interface paths { }; get?: never; /** Update an album */ - put: operations["put_drive-photos-volumes-{volumeID}-albums-{linkID}"]; + put: operations['put_drive-photos-volumes-{volumeID}-albums-{linkID}']; post?: never; /** Delete an album */ - delete: operations["delete_drive-photos-volumes-{volumeID}-albums-{linkID}"]; + delete: operations['delete_drive-photos-volumes-{volumeID}-albums-{linkID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/albums/{linkID}/duplicates": { + '/drive/photos/volumes/{volumeID}/albums/{linkID}/duplicates': { parameters: { query?: never; header?: never; @@ -90,14 +90,14 @@ export interface paths { get?: never; put?: never; /** Find duplicates in album */ - post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates"]; + post: operations['post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/tags-migration": { + '/drive/photos/volumes/{volumeID}/tags-migration': { parameters: { query?: never; header?: never; @@ -105,17 +105,17 @@ export interface paths { cookie?: never; }; /** Get photo tag migration status */ - get: operations["get_drive-photos-volumes-{volumeID}-tags-migration"]; + get: operations['get_drive-photos-volumes-{volumeID}-tags-migration']; put?: never; /** Update tag migration status */ - post: operations["post_drive-photos-volumes-{volumeID}-tags-migration"]; + post: operations['post_drive-photos-volumes-{volumeID}-tags-migration']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/albums/{linkID}/children": { + '/drive/photos/volumes/{volumeID}/albums/{linkID}/children': { parameters: { query?: never; header?: never; @@ -123,7 +123,7 @@ export interface paths { cookie?: never; }; /** List photos in album */ - get: operations["get_drive-photos-volumes-{volumeID}-albums-{linkID}-children"]; + get: operations['get_drive-photos-volumes-{volumeID}-albums-{linkID}-children']; put?: never; post?: never; delete?: never; @@ -132,7 +132,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/recover-multiple": { + '/drive/photos/volumes/{volumeID}/recover-multiple': { parameters: { query?: never; header?: never; @@ -141,7 +141,7 @@ export interface paths { }; get?: never; /** Recover photos from your photo volume */ - put: operations["put_drive-photos-volumes-{volumeID}-recover-multiple"]; + put: operations['put_drive-photos-volumes-{volumeID}-recover-multiple']; post?: never; delete?: never; options?: never; @@ -149,7 +149,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple": { + '/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple': { parameters: { query?: never; header?: never; @@ -158,21 +158,21 @@ export interface paths { }; get?: never; put?: never; - post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple"]; + post: operations['post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/albums/shared-with-me": { + '/drive/photos/albums/shared-with-me': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_drive-photos-albums-shared-with-me"]; + get: operations['get_drive-photos-albums-shared-with-me']; put?: never; post?: never; delete?: never; @@ -181,7 +181,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/links/transfer-multiple": { + '/drive/volumes/{volumeID}/links/transfer-multiple': { parameters: { query?: never; header?: never; @@ -193,7 +193,7 @@ export interface paths { * Transfer photos from and to albums * @deprecated */ - put: operations["put_drive-volumes-{volumeID}-links-transfer-multiple"]; + put: operations['put_drive-volumes-{volumeID}-links-transfer-multiple']; post?: never; delete?: never; options?: never; @@ -201,7 +201,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/links/transfer-multiple": { + '/drive/photos/volumes/{volumeID}/links/transfer-multiple': { parameters: { query?: never; header?: never; @@ -210,7 +210,7 @@ export interface paths { }; get?: never; /** Transfer photos from and to albums */ - put: operations["put_drive-photos-volumes-{volumeID}-links-transfer-multiple"]; + put: operations['put_drive-photos-volumes-{volumeID}-links-transfer-multiple']; post?: never; delete?: never; options?: never; @@ -218,7 +218,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/urls/{token}/bookmark": { + '/drive/v2/urls/{token}/bookmark': { parameters: { query?: never; header?: never; @@ -231,18 +231,18 @@ export interface paths { * Create ShareURL Bookmark * @description It creates a bookmark for the user in an already existing ShareURL. The bookmark would be stored for the current user if the password is encrypted with his/her addressKey */ - post: operations["post_drive-v2-urls-{token}-bookmark"]; + post: operations['post_drive-v2-urls-{token}-bookmark']; /** * Delete ShareURL Bookmark * @description It soft deletes the bookmark share url, that would be GC later. The user should be the owner of the bookmark. */ - delete: operations["delete_drive-v2-urls-{token}-bookmark"]; + delete: operations['delete_drive-v2-urls-{token}-bookmark']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shared-bookmarks": { + '/drive/v2/shared-bookmarks': { parameters: { query?: never; header?: never; @@ -253,7 +253,7 @@ export interface paths { * List all Bookmarks * @description This endpoint would only show active bookmarks from the user doing the request */ - get: operations["get_drive-v2-shared-bookmarks"]; + get: operations['get_drive-v2-shared-bookmarks']; put?: never; post?: never; delete?: never; @@ -262,7 +262,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/devices": { + '/drive/devices': { parameters: { query?: never; header?: never; @@ -273,17 +273,17 @@ export interface paths { * List devices * @description Gives a list of devices for current user, ordered by creationTime DESC */ - get: operations["get_drive-devices"]; + get: operations['get_drive-devices']; put?: never; /** Create a Device */ - post: operations["post_drive-devices"]; + post: operations['post_drive-devices']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/devices/{deviceID}": { + '/drive/devices/{deviceID}': { parameters: { query?: never; header?: never; @@ -292,16 +292,16 @@ export interface paths { }; get?: never; /** Update device */ - put: operations["put_drive-devices-{deviceID}"]; + put: operations['put_drive-devices-{deviceID}']; post?: never; /** Delete a device */ - delete: operations["delete_drive-devices-{deviceID}"]; + delete: operations['delete_drive-devices-{deviceID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/devices": { + '/drive/v2/devices': { parameters: { query?: never; header?: never; @@ -309,7 +309,7 @@ export interface paths { cookie?: never; }; /** List devices (v2) */ - get: operations["get_drive-v2-devices"]; + get: operations['get_drive-v2-devices']; put?: never; post?: never; delete?: never; @@ -318,7 +318,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/documents": { + '/drive/shares/{shareID}/documents': { parameters: { query?: never; header?: never; @@ -331,14 +331,14 @@ export interface paths { * Create document * @description Create a new proton document. */ - post: operations["post_drive-shares-{shareID}-documents"]; + post: operations['post_drive-shares-{shareID}-documents']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/events/latest": { + '/drive/shares/{shareID}/events/latest': { parameters: { query?: never; header?: never; @@ -350,7 +350,7 @@ export interface paths { * @deprecated * @description Get latest EventID for a given share. Deprecated: Use events per volume instead. */ - get: operations["get_drive-shares-{shareID}-events-latest"]; + get: operations['get_drive-shares-{shareID}-events-latest']; put?: never; post?: never; delete?: never; @@ -359,7 +359,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/events/latest": { + '/drive/volumes/{volumeID}/events/latest': { parameters: { query?: never; header?: never; @@ -370,7 +370,7 @@ export interface paths { * Get latest volume event * @description Get latest EventID for a given volume. */ - get: operations["get_drive-volumes-{volumeID}-events-latest"]; + get: operations['get_drive-volumes-{volumeID}-events-latest']; put?: never; post?: never; delete?: never; @@ -379,7 +379,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/events/{eventID}": { + '/drive/shares/{shareID}/events/{eventID}': { parameters: { query?: never; header?: never; @@ -391,7 +391,7 @@ export interface paths { * @deprecated * @description Get new events for given share since eventID. Deprecated: Use events per volume instead. */ - get: operations["get_drive-shares-{shareID}-events-{eventID}"]; + get: operations['get_drive-shares-{shareID}-events-{eventID}']; put?: never; post?: never; delete?: never; @@ -400,7 +400,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/events/{eventID}": { + '/drive/volumes/{volumeID}/events/{eventID}': { parameters: { query?: never; header?: never; @@ -411,7 +411,7 @@ export interface paths { * List volume events * @description Get new events for given volume since eventID. */ - get: operations["get_drive-volumes-{volumeID}-events-{eventID}"]; + get: operations['get_drive-volumes-{volumeID}-events-{eventID}']; put?: never; post?: never; delete?: never; @@ -420,7 +420,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/events/{eventID}": { + '/drive/v2/volumes/{volumeID}/events/{eventID}': { parameters: { query?: never; header?: never; @@ -432,7 +432,7 @@ export interface paths { * @description Get new events for given volume since eventID. * RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0054-light-events/ */ - get: operations["get_drive-v2-volumes-{volumeID}-events-{eventID}"]; + get: operations['get_drive-v2-volumes-{volumeID}-events-{eventID}']; put?: never; post?: never; delete?: never; @@ -441,7 +441,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/folders": { + '/drive/shares/{shareID}/folders': { parameters: { query?: never; header?: never; @@ -454,14 +454,14 @@ export interface paths { * Create a folder * @description Create a new folder in a given share, under a given folder link. */ - post: operations["post_drive-shares-{shareID}-folders"]; + post: operations['post_drive-shares-{shareID}-folders']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/folders/{linkID}/delete_multiple": { + '/drive/shares/{shareID}/folders/{linkID}/delete_multiple': { parameters: { query?: never; header?: never; @@ -474,14 +474,14 @@ export interface paths { * Delete children * @description Permanently delete children from folder, skipping trash. Can only be done for draft links. */ - post: operations["post_drive-shares-{shareID}-folders-{linkID}-delete_multiple"]; + post: operations['post_drive-shares-{shareID}-folders-{linkID}-delete_multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/folders/{linkID}/children": { + '/drive/shares/{shareID}/folders/{linkID}/children': { parameters: { query?: never; header?: never; @@ -492,7 +492,7 @@ export interface paths { * List folder children * @description List children of a given folder. */ - get: operations["get_drive-shares-{shareID}-folders-{linkID}-children"]; + get: operations['get_drive-shares-{shareID}-folders-{linkID}-children']; put?: never; post?: never; delete?: never; @@ -501,7 +501,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/folders/{linkID}/trash_multiple": { + '/drive/shares/{shareID}/folders/{linkID}/trash_multiple': { parameters: { query?: never; header?: never; @@ -514,14 +514,14 @@ export interface paths { * Trash children * @description Send children to trash */ - post: operations["post_drive-shares-{shareID}-folders-{linkID}-trash_multiple"]; + post: operations['post_drive-shares-{shareID}-folders-{linkID}-trash_multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/folders/{linkID}": { + '/drive/shares/{shareID}/folders/{linkID}': { parameters: { query?: never; header?: never; @@ -530,7 +530,7 @@ export interface paths { }; get?: never; /** Update folder attributes */ - put: operations["put_drive-shares-{shareID}-folders-{linkID}"]; + put: operations['put_drive-shares-{shareID}-folders-{linkID}']; post?: never; delete?: never; options?: never; @@ -538,7 +538,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/folders": { + '/drive/v2/volumes/{volumeID}/folders': { parameters: { query?: never; header?: never; @@ -551,14 +551,14 @@ export interface paths { * Create a folder (v2) * @description Create a new folder in a given share, under a given folder link. */ - post: operations["post_drive-v2-volumes-{volumeID}-folders"]; + post: operations['post_drive-v2-volumes-{volumeID}-folders']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/folders/{linkID}/children": { + '/drive/v2/volumes/{volumeID}/folders/{linkID}/children': { parameters: { query?: never; header?: never; @@ -569,7 +569,7 @@ export interface paths { * List folder children (v2) * @description List children IDs of a given folder. */ - get: operations["get_drive-v2-volumes-{volumeID}-folders-{linkID}-children"]; + get: operations['get_drive-v2-volumes-{volumeID}-folders-{linkID}-children']; put?: never; post?: never; delete?: never; @@ -578,7 +578,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes": { + '/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes': { parameters: { query?: never; header?: never; @@ -593,14 +593,14 @@ export interface paths { * * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. */ - post: operations["post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes"]; + post: operations['post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/links/{linkID}/checkAvailableHashes": { + '/drive/shares/{shareID}/links/{linkID}/checkAvailableHashes': { parameters: { query?: never; header?: never; @@ -615,14 +615,14 @@ export interface paths { * * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. */ - post: operations["post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes"]; + post: operations['post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/links/{linkID}/copy": { + '/drive/volumes/{volumeID}/links/{linkID}/copy': { parameters: { query?: never; header?: never; @@ -635,14 +635,14 @@ export interface paths { * Copy a node to a volume * @description Copy a single file to a volume, providing the new parent link ID. */ - post: operations["post_drive-volumes-{volumeID}-links-{linkID}-copy"]; + post: operations['post_drive-volumes-{volumeID}-links-{linkID}-copy']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/delete_multiple": { + '/drive/v2/volumes/{volumeID}/delete_multiple': { parameters: { query?: never; header?: never; @@ -655,14 +655,14 @@ export interface paths { * Delete multiple (v2) * @description Permanently delete links, skipping trash. Can only be done for draft links. */ - post: operations["post_drive-v2-volumes-{volumeID}-delete_multiple"]; + post: operations['post_drive-v2-volumes-{volumeID}-delete_multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/links/fetch_metadata": { + '/drive/shares/{shareID}/links/fetch_metadata': { parameters: { query?: never; header?: never; @@ -672,14 +672,14 @@ export interface paths { get?: never; put?: never; /** Fetch links in share */ - post: operations["post_drive-shares-{shareID}-links-fetch_metadata"]; + post: operations['post_drive-shares-{shareID}-links-fetch_metadata']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/links/fetch_metadata": { + '/drive/volumes/{volumeID}/links/fetch_metadata': { parameters: { query?: never; header?: never; @@ -689,14 +689,14 @@ export interface paths { get?: never; put?: never; /** Fetch links in volume */ - post: operations["post_drive-volumes-{volumeID}-links-fetch_metadata"]; + post: operations['post_drive-volumes-{volumeID}-links-fetch_metadata']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/links/{linkID}": { + '/drive/shares/{shareID}/links/{linkID}': { parameters: { query?: never; header?: never; @@ -707,7 +707,7 @@ export interface paths { * Get link data * @description Retrieve individual link information. */ - get: operations["get_drive-shares-{shareID}-links-{linkID}"]; + get: operations['get_drive-shares-{shareID}-links-{linkID}']; put?: never; post?: never; delete?: never; @@ -716,7 +716,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/sanitization/mhk": { + '/drive/sanitization/mhk': { parameters: { query?: never; header?: never; @@ -724,17 +724,17 @@ export interface paths { cookie?: never; }; /** List folders with missing hash keys */ - get: operations["get_drive-sanitization-mhk"]; + get: operations['get_drive-sanitization-mhk']; put?: never; /** List folders with missing hash keys */ - post: operations["post_drive-sanitization-mhk"]; + post: operations['post_drive-sanitization-mhk']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/links": { + '/drive/v2/volumes/{volumeID}/links': { parameters: { query?: never; header?: never; @@ -744,14 +744,14 @@ export interface paths { get?: never; put?: never; /** Load links details */ - post: operations["post_drive-v2-volumes-{volumeID}-links"]; + post: operations['post_drive-v2-volumes-{volumeID}-links']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/links/move-multiple": { + '/drive/volumes/{volumeID}/links/move-multiple': { parameters: { query?: never; header?: never; @@ -760,7 +760,7 @@ export interface paths { }; get?: never; /** Move a batch of files, folders or photos. */ - put: operations["put_drive-volumes-{volumeID}-links-move-multiple"]; + put: operations['put_drive-volumes-{volumeID}-links-move-multiple']; post?: never; delete?: never; options?: never; @@ -768,7 +768,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/links/{linkID}/move": { + '/drive/shares/{shareID}/links/{linkID}/move': { parameters: { query?: never; header?: never; @@ -784,7 +784,7 @@ export interface paths { * for the name and passphrase as these are also used by shares pointing * to the link. The passphrase should NOT be changed, reusing same session key as previously. */ - put: operations["put_drive-shares-{shareID}-links-{linkID}-move"]; + put: operations['put_drive-shares-{shareID}-links-{linkID}-move']; post?: never; delete?: never; options?: never; @@ -792,7 +792,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/links/{linkID}/rename": { + '/drive/v2/volumes/{volumeID}/links/{linkID}/rename': { parameters: { query?: never; header?: never; @@ -807,7 +807,7 @@ export interface paths { * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. */ - put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-rename"]; + put: operations['put_drive-v2-volumes-{volumeID}-links-{linkID}-rename']; post?: never; delete?: never; options?: never; @@ -815,7 +815,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/links/{linkID}/rename": { + '/drive/shares/{shareID}/links/{linkID}/rename': { parameters: { query?: never; header?: never; @@ -830,7 +830,7 @@ export interface paths { * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. */ - put: operations["put_drive-shares-{shareID}-links-{linkID}-rename"]; + put: operations['put_drive-shares-{shareID}-links-{linkID}-rename']; post?: never; delete?: never; options?: never; @@ -838,7 +838,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/links/{linkID}/move": { + '/drive/v2/volumes/{volumeID}/links/{linkID}/move': { parameters: { query?: never; header?: never; @@ -853,7 +853,7 @@ export interface paths { * for the name and passphrase as these are also used by shares pointing * to the link. The passphrase should NOT be changed,reusing same session key as previously */ - put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-move"]; + put: operations['put_drive-v2-volumes-{volumeID}-links-{linkID}-move']; post?: never; delete?: never; options?: never; @@ -861,7 +861,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}": { + '/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}': { parameters: { query?: never; header?: never; @@ -872,7 +872,7 @@ export interface paths { * Get revision * @description Get detailed revision information. */ - get: operations["get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + get: operations['get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}']; /** * Commit a revision * @description The revision becomes the current active one and the updated file content become available for reading. @@ -884,7 +884,7 @@ export interface paths { * 1...BlockNumber will be preserved if they are not overridden by a new block * BlockNumber+1... will be discarded. */ - put: operations["put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + put: operations['put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}']; post?: never; /** * Delete an obsolete/draft revision @@ -892,13 +892,13 @@ export interface paths { * This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active. Create or revert to * another revision first. You cannot delete a draft revision for a draft link. Delete the link instead. */ - delete: operations["delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + delete: operations['delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}": { + '/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}': { parameters: { query?: never; header?: never; @@ -909,7 +909,7 @@ export interface paths { * Get revision * @description Get detailed revision information. */ - get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; + get: operations['get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}']; /** * Commit a revision * @description The revision becomes the current active one and the updated file content become available for reading. @@ -921,7 +921,7 @@ export interface paths { * 1...BlockNumber will be preserved if they are not overridden by a new block * BlockNumber+1... will be discarded. */ - put: operations["put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; + put: operations['put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}']; post?: never; /** * Delete an obsolete/draft revision @@ -929,13 +929,13 @@ export interface paths { * This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active. Create or revert to * another revision first. You cannot delete a draft revision for a draft link. Delete the link instead. */ - delete: operations["delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; + delete: operations['delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/files": { + '/drive/v2/volumes/{volumeID}/files': { parameters: { query?: never; header?: never; @@ -945,14 +945,14 @@ export interface paths { get?: never; put?: never; /** Create a new file */ - post: operations["post_drive-v2-volumes-{volumeID}-files"]; + post: operations['post_drive-v2-volumes-{volumeID}-files']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/files": { + '/drive/shares/{shareID}/files': { parameters: { query?: never; header?: never; @@ -962,14 +962,14 @@ export interface paths { get?: never; put?: never; /** Create a new file */ - post: operations["post_drive-shares-{shareID}-files"]; + post: operations['post_drive-shares-{shareID}-files']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions": { + '/drive/v2/volumes/{volumeID}/files/{linkID}/revisions': { parameters: { query?: never; header?: never; @@ -977,7 +977,7 @@ export interface paths { cookie?: never; }; /** List revisions */ - get: operations["get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions"]; + get: operations['get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions']; put?: never; /** * Create revision @@ -990,14 +990,14 @@ export interface paths { * or it can be specific to the revision. * The client can use it to identify revisions in case it failed to receive the confirmation of the revision creation. */ - post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions"]; + post: operations['post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/files/{linkID}/revisions": { + '/drive/shares/{shareID}/files/{linkID}/revisions': { parameters: { query?: never; header?: never; @@ -1005,7 +1005,7 @@ export interface paths { cookie?: never; }; /** List revisions */ - get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions"]; + get: operations['get_drive-shares-{shareID}-files-{linkID}-revisions']; put?: never; /** * Create revision @@ -1018,14 +1018,14 @@ export interface paths { * or it can be specific to the revision. * The client can use it to identify revisions in case it failed to receive the confirmation of the revision creation. */ - post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions"]; + post: operations['post_drive-shares-{shareID}-files-{linkID}-revisions']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/thumbnail": { + '/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/thumbnail': { parameters: { query?: never; header?: never; @@ -1033,7 +1033,7 @@ export interface paths { cookie?: never; }; /** Get revision thumbnail */ - get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail"]; + get: operations['get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail']; put?: never; post?: never; delete?: never; @@ -1042,7 +1042,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore": { + '/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore': { parameters: { query?: never; header?: never; @@ -1052,14 +1052,14 @@ export interface paths { get?: never; put?: never; /** Restore a revision */ - post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore"]; + post: operations['post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/restore": { + '/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/restore': { parameters: { query?: never; header?: never; @@ -1069,14 +1069,14 @@ export interface paths { get?: never; put?: never; /** Restore a revision */ - post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore"]; + post: operations['post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification": { + '/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification': { parameters: { query?: never; header?: never; @@ -1087,7 +1087,7 @@ export interface paths { * Get verification data. * @description Get data to verify encryption of the revision before committing. */ - get: operations["get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification"]; + get: operations['get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification']; put?: never; post?: never; delete?: never; @@ -1096,7 +1096,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/links/{linkID}/revisions/{revisionID}/verification": { + '/drive/shares/{shareID}/links/{linkID}/revisions/{revisionID}/verification': { parameters: { query?: never; header?: never; @@ -1107,7 +1107,7 @@ export interface paths { * Get verification data. * @description Get data to verify encryption of the revision before committing. */ - get: operations["get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification"]; + get: operations['get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification']; put?: never; post?: never; delete?: never; @@ -1116,7 +1116,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/trash/delete_multiple": { + '/drive/v2/volumes/{volumeID}/trash/delete_multiple': { parameters: { query?: never; header?: never; @@ -1129,14 +1129,14 @@ export interface paths { * Delete items from trash * @description Permanently delete list of links from trash of a given share. */ - post: operations["post_drive-v2-volumes-{volumeID}-trash-delete_multiple"]; + post: operations['post_drive-v2-volumes-{volumeID}-trash-delete_multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/trash/delete_multiple": { + '/drive/shares/{shareID}/trash/delete_multiple': { parameters: { query?: never; header?: never; @@ -1149,14 +1149,14 @@ export interface paths { * Delete items from trash * @description Permanently delete list of links from trash of a given share. */ - post: operations["post_drive-shares-{shareID}-trash-delete_multiple"]; + post: operations['post_drive-shares-{shareID}-trash-delete_multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/trash": { + '/drive/shares/{shareID}/trash': { parameters: { query?: never; header?: never; @@ -1171,7 +1171,7 @@ export interface paths { * * CANNOT be used on Photo-Volume -> use volume-trash */ - get: operations["get_drive-shares-{shareID}-trash"]; + get: operations['get_drive-shares-{shareID}-trash']; put?: never; post?: never; /** @@ -1182,13 +1182,13 @@ export interface paths { * * CANNOT be used on Photo-Volume -> use volume-trash */ - delete: operations["delete_drive-shares-{shareID}-trash"]; + delete: operations['delete_drive-shares-{shareID}-trash']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/trash": { + '/drive/volumes/{volumeID}/trash': { parameters: { query?: never; header?: never; @@ -1196,7 +1196,7 @@ export interface paths { cookie?: never; }; /** List volume trash */ - get: operations["get_drive-volumes-{volumeID}-trash"]; + get: operations['get_drive-volumes-{volumeID}-trash']; put?: never; post?: never; /** @@ -1204,13 +1204,13 @@ export interface paths { * @description When there are fewer items in trash than a certain threshold, trash will be deleted synchronously returning a 200 HTTP code. * Otherwise, it will happen async returning a 202 HTTP code. */ - delete: operations["delete_drive-volumes-{volumeID}-trash"]; + delete: operations['delete_drive-volumes-{volumeID}-trash']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/trash/restore_multiple": { + '/drive/v2/volumes/{volumeID}/trash/restore_multiple': { parameters: { query?: never; header?: never; @@ -1224,7 +1224,7 @@ export interface paths { * * /shares endpoint should NOT be used on Photo-Volume -> use volume-trash */ - put: operations["put_drive-v2-volumes-{volumeID}-trash-restore_multiple"]; + put: operations['put_drive-v2-volumes-{volumeID}-trash-restore_multiple']; post?: never; delete?: never; options?: never; @@ -1232,7 +1232,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/trash/restore_multiple": { + '/drive/shares/{shareID}/trash/restore_multiple': { parameters: { query?: never; header?: never; @@ -1246,7 +1246,7 @@ export interface paths { * * /shares endpoint should NOT be used on Photo-Volume -> use volume-trash */ - put: operations["put_drive-shares-{shareID}-trash-restore_multiple"]; + put: operations['put_drive-shares-{shareID}-trash-restore_multiple']; post?: never; delete?: never; options?: never; @@ -1254,7 +1254,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/trash_multiple": { + '/drive/v2/volumes/{volumeID}/trash_multiple': { parameters: { query?: never; header?: never; @@ -1267,14 +1267,14 @@ export interface paths { * Trash multiple (v2) * @description Send multiple links to the trash */ - post: operations["post_drive-v2-volumes-{volumeID}-trash_multiple"]; + post: operations['post_drive-v2-volumes-{volumeID}-trash_multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/blocks": { + '/drive/blocks': { parameters: { query?: never; header?: never; @@ -1287,14 +1287,14 @@ export interface paths { * Request block upload * @description Request upload information for a set of blocks. */ - post: operations["post_drive-blocks"]; + post: operations['post_drive-blocks']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/files/small": { + '/drive/v2/volumes/{volumeID}/files/small': { parameters: { query?: never; header?: never; @@ -1307,14 +1307,14 @@ export interface paths { * Upload small file * @description This does not support anonymous uploads (yet) */ - post: operations["post_drive-v2-volumes-{volumeID}-files-small"]; + post: operations['post_drive-v2-volumes-{volumeID}-files-small']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small": { + '/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small': { parameters: { query?: never; header?: never; @@ -1327,14 +1327,14 @@ export interface paths { * Upload small revision * @description This does not support anonymous uploads (yet) */ - post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small"]; + post: operations['post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/me/active": { + '/drive/me/active': { parameters: { query?: never; header?: never; @@ -1345,7 +1345,7 @@ export interface paths { * Ping active user * @description Endpoint that can be pinged by clients to mark a user as an active user */ - get: operations["get_drive-me-active"]; + get: operations['get_drive-me-active']; put?: never; post?: never; delete?: never; @@ -1354,7 +1354,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/report/url": { + '/drive/report/url': { parameters: { query?: never; header?: never; @@ -1364,14 +1364,14 @@ export interface paths { get?: never; put?: never; /** Report Share URL */ - post: operations["post_drive-report-url"]; + post: operations['post_drive-report-url']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/checklist/get-started": { + '/drive/v2/checklist/get-started': { parameters: { query?: never; header?: never; @@ -1379,7 +1379,7 @@ export interface paths { cookie?: never; }; /** Get onboarding checklist */ - get: operations["get_drive-v2-checklist-get-started"]; + get: operations['get_drive-v2-checklist-get-started']; put?: never; post?: never; delete?: never; @@ -1388,14 +1388,14 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/onboarding": { + '/drive/v2/onboarding': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_drive-v2-onboarding"]; + get: operations['get_drive-v2-onboarding']; put?: never; post?: never; delete?: never; @@ -1404,7 +1404,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/checklist/get-started/seen-completed-list": { + '/drive/v2/checklist/get-started/seen-completed-list': { parameters: { query?: never; header?: never; @@ -1414,14 +1414,14 @@ export interface paths { get?: never; put?: never; /** Mark completed checklist as seen */ - post: operations["post_drive-v2-checklist-get-started-seen-completed-list"]; + post: operations['post_drive-v2-checklist-get-started-seen-completed-list']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/entitlements": { + '/drive/entitlements': { parameters: { query?: never; header?: never; @@ -1432,7 +1432,7 @@ export interface paths { * Get entitlements * @description Get the current entitlements and their value for the logged-in user. */ - get: operations["get_drive-entitlements"]; + get: operations['get_drive-entitlements']; put?: never; post?: never; delete?: never; @@ -1441,7 +1441,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/links/{linkID}/tags": { + '/drive/photos/volumes/{volumeID}/links/{linkID}/tags': { parameters: { query?: never; header?: never; @@ -1451,15 +1451,15 @@ export interface paths { get?: never; put?: never; /** Add tags to existing photo */ - post: operations["post_drive-photos-volumes-{volumeID}-links-{linkID}-tags"]; + post: operations['post_drive-photos-volumes-{volumeID}-links-{linkID}-tags']; /** Remove tags from existing photo */ - delete: operations["delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags"]; + delete: operations['delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/photos/share": { + '/drive/volumes/{volumeID}/photos/share': { parameters: { query?: never; header?: never; @@ -1472,14 +1472,14 @@ export interface paths { * DEPRECATED: Create photo share * @deprecated */ - post: operations["post_drive-volumes-{volumeID}-photos-share"]; + post: operations['post_drive-volumes-{volumeID}-photos-share']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/photos/share/{shareID}": { + '/drive/volumes/{volumeID}/photos/share/{shareID}': { parameters: { query?: never; header?: never; @@ -1493,13 +1493,13 @@ export interface paths { * Delete empty photo share * @description Can only delete Photo Shares that are empty. */ - delete: operations["delete_drive-volumes-{volumeID}-photos-share-{shareID}"]; + delete: operations['delete_drive-volumes-{volumeID}-photos-share-{shareID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/links/{linkID}/favorite": { + '/drive/photos/volumes/{volumeID}/links/{linkID}/favorite': { parameters: { query?: never; header?: never; @@ -1509,14 +1509,14 @@ export interface paths { get?: never; put?: never; /** Favorite existing photo */ - post: operations["post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite"]; + post: operations['post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/photos/duplicates": { + '/drive/volumes/{volumeID}/photos/duplicates': { parameters: { query?: never; header?: never; @@ -1526,14 +1526,14 @@ export interface paths { get?: never; put?: never; /** Find duplicates */ - post: operations["post_drive-volumes-{volumeID}-photos-duplicates"]; + post: operations['post_drive-volumes-{volumeID}-photos-duplicates']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/photos/migrate-legacy": { + '/drive/photos/migrate-legacy': { parameters: { query?: never; header?: never; @@ -1541,17 +1541,17 @@ export interface paths { cookie?: never; }; /** Get status of migration from legacy photo share on a regular volume into a new Photo Volume */ - get: operations["get_drive-photos-migrate-legacy"]; + get: operations['get_drive-photos-migrate-legacy']; put?: never; /** Start migration from legacy photo share on a regular volume into a new Photo Volume */ - post: operations["post_drive-photos-migrate-legacy"]; + post: operations['post_drive-photos-migrate-legacy']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/photos": { + '/drive/volumes/{volumeID}/photos': { parameters: { query?: never; header?: never; @@ -1562,7 +1562,7 @@ export interface paths { * List photos sorted by capture time * @description When paginating to secondary pages, the PreviousPageLastLinkID must be provided. */ - get: operations["get_drive-volumes-{volumeID}-photos"]; + get: operations['get_drive-volumes-{volumeID}-photos']; put?: never; post?: never; delete?: never; @@ -1571,7 +1571,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/photos/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/xattr": { + '/drive/photos/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/xattr': { parameters: { query?: never; header?: never; @@ -1583,7 +1583,7 @@ export interface paths { * Update xAttr Photo-Link * @description ONLY for use by iOS, due to a bug in the iOS client, xAttr were not populated for photos, the client can use this endpoint to fix this. */ - put: operations["put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr"]; + put: operations['put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr']; post?: never; delete?: never; options?: never; @@ -1591,7 +1591,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/files/{linkID}/checkAvailableHashes": { + '/drive/urls/{token}/files/{linkID}/checkAvailableHashes': { parameters: { query?: never; header?: never; @@ -1606,14 +1606,14 @@ export interface paths { * * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. */ - post: operations["post_drive-urls-{token}-files-{linkID}-checkAvailableHashes"]; + post: operations['post_drive-urls-{token}-files-{linkID}-checkAvailableHashes']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/files/{linkID}/revisions/{revisionID}": { + '/drive/urls/{token}/files/{linkID}/revisions/{revisionID}': { parameters: { query?: never; header?: never; @@ -1632,20 +1632,20 @@ export interface paths { * 1...BlockNumber will be preserved if they are not overridden by a new block * BlockNumber+1... will be discarded. */ - put: operations["put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}"]; + put: operations['put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}']; post?: never; /** * Delete a draft revision. * @description This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active or obsolete. * You cannot delete a draft revision for a draft link. Delete the link instead. */ - delete: operations["delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}"]; + delete: operations['delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/documents": { + '/drive/urls/{token}/documents': { parameters: { query?: never; header?: never; @@ -1658,14 +1658,14 @@ export interface paths { * Create anonymous document. * @description Create a new anonymous proton document. */ - post: operations["post_drive-urls-{token}-documents"]; + post: operations['post_drive-urls-{token}-documents']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/files": { + '/drive/urls/{token}/files': { parameters: { query?: never; header?: never; @@ -1678,14 +1678,14 @@ export interface paths { * Create file. * @description Create a new file. */ - post: operations["post_drive-urls-{token}-files"]; + post: operations['post_drive-urls-{token}-files']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/folders": { + '/drive/urls/{token}/folders': { parameters: { query?: never; header?: never; @@ -1698,14 +1698,14 @@ export interface paths { * Create a folder. * @description Create a new folder in a given share, under a given folder link. */ - post: operations["post_drive-urls-{token}-folders"]; + post: operations['post_drive-urls-{token}-folders']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/folders/{linkID}/delete_multiple": { + '/drive/urls/{token}/folders/{linkID}/delete_multiple': { parameters: { query?: never; header?: never; @@ -1718,14 +1718,14 @@ export interface paths { * Delete children * @description Permanently delete children from folder, skipping trash. */ - post: operations["post_drive-urls-{token}-folders-{linkID}-delete_multiple"]; + post: operations['post_drive-urls-{token}-folders-{linkID}-delete_multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/links/fetch_metadata": { + '/drive/urls/{token}/links/fetch_metadata': { parameters: { query?: never; header?: never; @@ -1739,14 +1739,14 @@ export interface paths { * @description This endpoint is a sibling of /drive/volumes/{volumeID}/links/fetch_metadata, but using token * instead of volumeID. Is meant to be used in public sharing. */ - post: operations["post_drive-urls-{token}-links-fetch_metadata"]; + post: operations['post_drive-urls-{token}-links-fetch_metadata']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/links/{linkID}/path": { + '/drive/urls/{token}/links/{linkID}/path': { parameters: { query?: never; header?: never; @@ -1754,7 +1754,7 @@ export interface paths { cookie?: never; }; /** Fetch link parentIDs by token */ - get: operations["get_drive-urls-{token}-links-{linkID}-path"]; + get: operations['get_drive-urls-{token}-links-{linkID}-path']; put?: never; post?: never; delete?: never; @@ -1763,7 +1763,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/links/{linkID}/rename": { + '/drive/urls/{token}/links/{linkID}/rename': { parameters: { query?: never; header?: never; @@ -1778,7 +1778,7 @@ export interface paths { * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. */ - put: operations["put_drive-urls-{token}-links-{linkID}-rename"]; + put: operations['put_drive-urls-{token}-links-{linkID}-rename']; post?: never; delete?: never; options?: never; @@ -1786,7 +1786,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/blocks": { + '/drive/urls/{token}/blocks': { parameters: { query?: never; header?: never; @@ -1799,14 +1799,14 @@ export interface paths { * Request block upload. * @description Request upload information for a set of blocks. */ - post: operations["post_drive-urls-{token}-blocks"]; + post: operations['post_drive-urls-{token}-blocks']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/links/{linkID}/revisions/{revisionID}/verification": { + '/drive/urls/{token}/links/{linkID}/revisions/{revisionID}/verification': { parameters: { query?: never; header?: never; @@ -1817,7 +1817,7 @@ export interface paths { * Get verification data. * @description Get data to verify encryption of the revision before committing. */ - get: operations["get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification"]; + get: operations['get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification']; put?: never; post?: never; delete?: never; @@ -1826,7 +1826,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/urls": { + '/drive/volumes/{volumeID}/urls': { parameters: { query?: never; header?: never; @@ -1834,7 +1834,7 @@ export interface paths { cookie?: never; }; /** List ShareURLs in a volume */ - get: operations["get_drive-volumes-{volumeID}-urls"]; + get: operations['get_drive-volumes-{volumeID}-urls']; put?: never; post?: never; delete?: never; @@ -1843,7 +1843,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/map": { + '/drive/shares/{shareID}/map': { parameters: { query?: never; header?: never; @@ -1855,7 +1855,7 @@ export interface paths { * @deprecated * @description Used only for search on web that does not scale. Should be replaced by better version in the future. */ - get: operations["get_drive-shares-{shareID}-map"]; + get: operations['get_drive-shares-{shareID}-map']; put?: never; post?: never; delete?: never; @@ -1864,7 +1864,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/my-files": { + '/drive/v2/shares/my-files': { parameters: { query?: never; header?: never; @@ -1872,7 +1872,7 @@ export interface paths { cookie?: never; }; /** Bootstrap my files */ - get: operations["get_drive-v2-shares-my-files"]; + get: operations['get_drive-v2-shares-my-files']; put?: never; post?: never; delete?: never; @@ -1881,7 +1881,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}": { + '/drive/shares/{shareID}': { parameters: { query?: never; header?: never; @@ -1889,7 +1889,7 @@ export interface paths { cookie?: never; }; /** Get share bootstrap */ - get: operations["get_drive-shares-{shareID}"]; + get: operations['get_drive-shares-{shareID}']; put?: never; post?: never; /** @@ -1898,13 +1898,13 @@ export interface paths { * Will throw 422 with body code 2005 if Members, ShareURLs or Invitations are still attached to the share. * Use Force=1 query param to delete the share together with any attached entities. */ - delete: operations["delete_drive-shares-{shareID}"]; + delete: operations['delete_drive-shares-{shareID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/links/{linkID}/context": { + '/drive/volumes/{volumeID}/links/{linkID}/context': { parameters: { query?: never; header?: never; @@ -1915,7 +1915,7 @@ export interface paths { * Get context share * @description Gets the highest share, meaning closest to the root, for a link */ - get: operations["get_drive-volumes-{volumeID}-links-{linkID}-context"]; + get: operations['get_drive-volumes-{volumeID}-links-{linkID}-context']; put?: never; post?: never; delete?: never; @@ -1924,7 +1924,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/sanitization/asv": { + '/drive/sanitization/asv': { parameters: { query?: never; header?: never; @@ -1932,20 +1932,20 @@ export interface paths { cookie?: never; }; /** List top level ShareIDs for the user Volume in AUTO_RESTORE state. */ - get: operations["get_drive-sanitization-asv"]; + get: operations['get_drive-sanitization-asv']; put?: never; /** * Log Missing Keys error for restore process * @description Log a Restore Procedure error when Web detects that Keys are missing. */ - post: operations["post_drive-sanitization-asv"]; + post: operations['post_drive-sanitization-asv']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares": { + '/drive/shares': { parameters: { query?: never; header?: never; @@ -1960,7 +1960,7 @@ export interface paths { * By default, only active shares are shown. * Passing the ShowAll=1 query parameter will show locked and disabled shares also. */ - get: operations["get_drive-shares"]; + get: operations['get_drive-shares']; put?: never; post?: never; delete?: never; @@ -1969,7 +1969,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/owner": { + '/drive/shares/{shareID}/owner': { parameters: { query?: never; header?: never; @@ -1983,14 +1983,14 @@ export interface paths { * @description Replace the signature and related membership of the share. * This allows users to change the associated address & key they use for a share, so that they can get rid of it. */ - post: operations["post_drive-shares-{shareID}-owner"]; + post: operations['post_drive-shares-{shareID}-owner']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/migrations/shareaccesswithnode": { + '/drive/migrations/shareaccesswithnode': { parameters: { query?: never; header?: never; @@ -2000,14 +2000,14 @@ export interface paths { get?: never; put?: never; /** Migrate legacy Shares */ - post: operations["post_drive-migrations-shareaccesswithnode"]; + post: operations['post_drive-migrations-shareaccesswithnode']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/migrations/shareaccesswithnode/unmigrated": { + '/drive/migrations/shareaccesswithnode/unmigrated': { parameters: { query?: never; header?: never; @@ -2019,7 +2019,7 @@ export interface paths { * @description List shares that have not been migrated to the new PassphraseNodeKeyPacket yet. * Will throw a 422 with Code 2000 if the current user doesn't have any active Volume. */ - get: operations["get_drive-migrations-shareaccesswithnode-unmigrated"]; + get: operations['get_drive-migrations-shareaccesswithnode-unmigrated']; put?: never; post?: never; delete?: never; @@ -2028,7 +2028,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/info": { + '/drive/urls/{token}/info': { parameters: { query?: never; header?: never; @@ -2036,7 +2036,7 @@ export interface paths { cookie?: never; }; /** Initiate shared by URL session with SRP. */ - get: operations["get_drive-urls-{token}-info"]; + get: operations['get_drive-urls-{token}-info']; put?: never; post?: never; delete?: never; @@ -2045,7 +2045,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/auth": { + '/drive/urls/{token}/auth': { parameters: { query?: never; header?: never; @@ -2055,14 +2055,14 @@ export interface paths { get?: never; put?: never; /** Perform Handshake, Get session information */ - post: operations["post_drive-urls-{token}-auth"]; + post: operations['post_drive-urls-{token}-auth']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}": { + '/drive/urls/{token}': { parameters: { query?: never; header?: never; @@ -2070,7 +2070,7 @@ export interface paths { cookie?: never; }; /** Get Shared File Information. */ - get: operations["get_drive-urls-{token}"]; + get: operations['get_drive-urls-{token}']; put?: never; post?: never; delete?: never; @@ -2079,7 +2079,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/folders/{linkID}/children": { + '/drive/urls/{token}/folders/{linkID}/children': { parameters: { query?: never; header?: never; @@ -2087,7 +2087,7 @@ export interface paths { cookie?: never; }; /** List shared folder's children. */ - get: operations["get_drive-urls-{token}-folders-{linkID}-children"]; + get: operations['get_drive-urls-{token}-folders-{linkID}-children']; put?: never; post?: never; delete?: never; @@ -2096,7 +2096,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/files/{linkID}": { + '/drive/urls/{token}/files/{linkID}': { parameters: { query?: never; header?: never; @@ -2104,7 +2104,7 @@ export interface paths { cookie?: never; }; /** Get Shared File & Revision Metadata. */ - get: operations["get_drive-urls-{token}-files-{linkID}"]; + get: operations['get_drive-urls-{token}-files-{linkID}']; put?: never; post?: never; delete?: never; @@ -2113,7 +2113,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/file": { + '/drive/urls/{token}/file': { parameters: { query?: never; header?: never; @@ -2123,14 +2123,14 @@ export interface paths { get?: never; put?: never; /** Get Shared File Information. */ - post: operations["post_drive-urls-{token}-file"]; + post: operations['post_drive-urls-{token}-file']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/urls": { + '/drive/shares/{shareID}/urls': { parameters: { query?: never; header?: never; @@ -2138,18 +2138,18 @@ export interface paths { cookie?: never; }; /** List URL links on share. */ - get: operations["get_drive-shares-{shareID}-urls"]; + get: operations['get_drive-shares-{shareID}-urls']; put?: never; /** Share by URL * Create a share by URL link. */ - post: operations["post_drive-shares-{shareID}-urls"]; + post: operations['post_drive-shares-{shareID}-urls']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/urls/{urlID}": { + '/drive/shares/{shareID}/urls/{urlID}': { parameters: { query?: never; header?: never; @@ -2161,16 +2161,16 @@ export interface paths { * Update a share by URL link. * @description Only values which are set in the request are updated. When the password is updated, the password, SharePassphraseKeyPacket and SRPVerifier must be updated together. */ - put: operations["put_drive-shares-{shareID}-urls-{urlID}"]; + put: operations['put_drive-shares-{shareID}-urls-{urlID}']; post?: never; /** Delete a Share URL */ - delete: operations["delete_drive-shares-{shareID}-urls-{urlID}"]; + delete: operations['delete_drive-shares-{shareID}-urls-{urlID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/urls/delete_multiple": { + '/drive/shares/{shareID}/urls/delete_multiple': { parameters: { query?: never; header?: never; @@ -2180,14 +2180,14 @@ export interface paths { get?: never; put?: never; /** Delete multiple ShareURL in a batch. */ - post: operations["post_drive-shares-{shareID}-urls-delete_multiple"]; + post: operations['post_drive-shares-{shareID}-urls-delete_multiple']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/shares": { + '/drive/volumes/{volumeID}/shares': { parameters: { query?: never; header?: never; @@ -2200,14 +2200,14 @@ export interface paths { * Create a standard share * @description Cannot create two shares on the same link. Throws 422 with code 2500 in case a share already exists. */ - post: operations["post_drive-volumes-{volumeID}-shares"]; + post: operations['post_drive-volumes-{volumeID}-shares']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/shares": { + '/drive/v2/volumes/{volumeID}/shares': { parameters: { query?: never; header?: never; @@ -2218,7 +2218,7 @@ export interface paths { * Shared by me * @description List Collaborative Shares in the given volume that are not abandoned, i.e. they either have members, invitations or URLs attached. */ - get: operations["get_drive-v2-volumes-{volumeID}-shares"]; + get: operations['get_drive-v2-volumes-{volumeID}-shares']; put?: never; post?: never; delete?: never; @@ -2227,7 +2227,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/sharedwithme": { + '/drive/v2/sharedwithme': { parameters: { query?: never; header?: never; @@ -2238,7 +2238,7 @@ export interface paths { * Shared with me * @description List Collaborative Shares the user has access to as a non-owner */ - get: operations["get_drive-v2-sharedwithme"]; + get: operations['get_drive-v2-sharedwithme']; put?: never; post?: never; delete?: never; @@ -2247,7 +2247,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/external-invitations/{invitationID}": { + '/drive/v2/shares/{shareID}/external-invitations/{invitationID}': { parameters: { query?: never; header?: never; @@ -2261,19 +2261,19 @@ export interface paths { * After the external invitation has been accepted, the invitation's permissions can be edited. * The current user must have admin permission on the share. */ - put: operations["put_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; + put: operations['put_drive-v2-shares-{shareID}-external-invitations-{invitationID}']; post?: never; /** * Delete an external invitation * @description The current user must have admin permission on the share. */ - delete: operations["delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; + delete: operations['delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/external-invitations": { + '/drive/v2/shares/{shareID}/external-invitations': { parameters: { query?: never; header?: never; @@ -2284,20 +2284,20 @@ export interface paths { * List external invitations in a share * @description The current user must have admin permission on the share. */ - get: operations["get_drive-v2-shares-{shareID}-external-invitations"]; + get: operations['get_drive-v2-shares-{shareID}-external-invitations']; put?: never; /** * Invite an external user to a share * @description The current user must have admin permission on the share. */ - post: operations["post_drive-v2-shares-{shareID}-external-invitations"]; + post: operations['post_drive-v2-shares-{shareID}-external-invitations']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/external-invitations": { + '/drive/v2/shares/external-invitations': { parameters: { query?: never; header?: never; @@ -2308,7 +2308,7 @@ export interface paths { * List external invitations of a user * @description List the UserRegistered external invitations where the current user is the invitee. */ - get: operations["get_drive-v2-shares-external-invitations"]; + get: operations['get_drive-v2-shares-external-invitations']; put?: never; post?: never; delete?: never; @@ -2317,7 +2317,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/external-invitations/{invitationID}/sendemail": { + '/drive/v2/shares/{shareID}/external-invitations/{invitationID}/sendemail': { parameters: { query?: never; header?: never; @@ -2330,14 +2330,14 @@ export interface paths { * Send the external invitation email to the invitee * @description The current user must have admin permission on the share. */ - post: operations["post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail"]; + post: operations['post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/invitations/{invitationID}/accept": { + '/drive/v2/shares/invitations/{invitationID}/accept': { parameters: { query?: never; header?: never; @@ -2347,14 +2347,14 @@ export interface paths { get?: never; put?: never; /** Accept an invitation */ - post: operations["post_drive-v2-shares-invitations-{invitationID}-accept"]; + post: operations['post_drive-v2-shares-invitations-{invitationID}-accept']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/invitations/{invitationID}": { + '/drive/v2/shares/{shareID}/invitations/{invitationID}': { parameters: { query?: never; header?: never; @@ -2368,19 +2368,19 @@ export interface paths { * The owner should not be aware of rejection. After the invitation has been accepted, membership permissions can be edited. * The current user must have admin permission on the share. */ - put: operations["put_drive-v2-shares-{shareID}-invitations-{invitationID}"]; + put: operations['put_drive-v2-shares-{shareID}-invitations-{invitationID}']; post?: never; /** * Delete an invitation * @description The current user must have admin permission on the share. */ - delete: operations["delete_drive-v2-shares-{shareID}-invitations-{invitationID}"]; + delete: operations['delete_drive-v2-shares-{shareID}-invitations-{invitationID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/invitations": { + '/drive/v2/shares/{shareID}/invitations': { parameters: { query?: never; header?: never; @@ -2391,20 +2391,20 @@ export interface paths { * List invitations in a share * @description The current user must have admin permission on the share. */ - get: operations["get_drive-v2-shares-{shareID}-invitations"]; + get: operations['get_drive-v2-shares-{shareID}-invitations']; put?: never; /** * Invite a Proton user to a share * @description The current user must have admin permission on the share. */ - post: operations["post_drive-v2-shares-{shareID}-invitations"]; + post: operations['post_drive-v2-shares-{shareID}-invitations']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/invitations": { + '/drive/v2/shares/invitations': { parameters: { query?: never; header?: never; @@ -2415,7 +2415,7 @@ export interface paths { * List invitations of a user * @description List the pending invitations where the current user is the invitee. */ - get: operations["get_drive-v2-shares-invitations"]; + get: operations['get_drive-v2-shares-invitations']; put?: never; post?: never; delete?: never; @@ -2424,7 +2424,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/invitations/{invitationID}/reject": { + '/drive/v2/shares/invitations/{invitationID}/reject': { parameters: { query?: never; header?: never; @@ -2434,14 +2434,14 @@ export interface paths { get?: never; put?: never; /** Reject an invitation */ - post: operations["post_drive-v2-shares-invitations-{invitationID}-reject"]; + post: operations['post_drive-v2-shares-invitations-{invitationID}-reject']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/invitations/{invitationID}/sendemail": { + '/drive/v2/shares/{shareID}/invitations/{invitationID}/sendemail': { parameters: { query?: never; header?: never; @@ -2454,14 +2454,14 @@ export interface paths { * Send the invitation email to the invitee * @description The current user must have admin permission on the share. */ - post: operations["post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail"]; + post: operations['post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/invitations/{invitationID}": { + '/drive/v2/shares/invitations/{invitationID}': { parameters: { query?: never; header?: never; @@ -2472,7 +2472,7 @@ export interface paths { * Return invitation information * @description Get the information about a pending invitation where the current user is the invitee. */ - get: operations["get_drive-v2-shares-invitations-{invitationID}"]; + get: operations['get_drive-v2-shares-invitations-{invitationID}']; put?: never; post?: never; delete?: never; @@ -2481,7 +2481,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/user-link-access": { + '/drive/v2/user-link-access': { parameters: { query?: never; header?: never; @@ -2492,7 +2492,7 @@ export interface paths { * List link accesses for a share url. * @description RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0031-share-resolution-from-copied-url/ */ - get: operations["get_drive-v2-user-link-access"]; + get: operations['get_drive-v2-user-link-access']; put?: never; post?: never; delete?: never; @@ -2501,7 +2501,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/members": { + '/drive/v2/shares/{shareID}/members': { parameters: { query?: never; header?: never; @@ -2512,7 +2512,7 @@ export interface paths { * List members in a share * @description The current user must have admin permission on the share. */ - get: operations["get_drive-v2-shares-{shareID}-members"]; + get: operations['get_drive-v2-shares-{shareID}-members']; put?: never; post?: never; delete?: never; @@ -2521,7 +2521,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/members/{memberID}": { + '/drive/v2/shares/{shareID}/members/{memberID}': { parameters: { query?: never; header?: never; @@ -2534,20 +2534,20 @@ export interface paths { * @description Only permissions can be changed. They can be changed when the member is active. * The current user must have admin permission on the share. */ - put: operations["put_drive-v2-shares-{shareID}-members-{memberID}"]; + put: operations['put_drive-v2-shares-{shareID}-members-{memberID}']; post?: never; /** * Remove a share member * @description If the current user is an admin of the share they can remove other members. * If the current user is not an admin they can only remove themselves. */ - delete: operations["delete_drive-v2-shares-{shareID}-members-{memberID}"]; + delete: operations['delete_drive-v2-shares-{shareID}-members-{memberID}']; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/security": { + '/drive/v2/shares/{shareID}/security': { parameters: { query?: never; header?: never; @@ -2561,14 +2561,14 @@ export interface paths { * @description Performs virus checks on hashes of files received in the request payload. * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ */ - post: operations["post_drive-v2-shares-{shareID}-security"]; + post: operations['post_drive-v2-shares-{shareID}-security']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/security": { + '/drive/urls/{token}/security': { parameters: { query?: never; header?: never; @@ -2582,14 +2582,14 @@ export interface paths { * @description Performs virus checks on hashes of files received in the request payload. * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ */ - post: operations["post_drive-urls-{token}-security"]; + post: operations['post_drive-urls-{token}-security']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/thumbnails": { + '/drive/volumes/{volumeID}/thumbnails': { parameters: { query?: never; header?: never; @@ -2599,14 +2599,14 @@ export interface paths { get?: never; put?: never; /** Fetch thumbnails by IDs. */ - post: operations["post_drive-volumes-{volumeID}-thumbnails"]; + post: operations['post_drive-volumes-{volumeID}-thumbnails']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/me/settings": { + '/drive/me/settings': { parameters: { query?: never; header?: never; @@ -2614,12 +2614,12 @@ export interface paths { cookie?: never; }; /** Get user settings */ - get: operations["get_drive-me-settings"]; + get: operations['get_drive-me-settings']; /** * Update user settings * @description At least one setting must be provided. */ - put: operations["put_drive-me-settings"]; + put: operations['put_drive-me-settings']; post?: never; delete?: never; options?: never; @@ -2627,7 +2627,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes": { + '/drive/volumes': { parameters: { query?: never; header?: never; @@ -2638,7 +2638,7 @@ export interface paths { * List volumes * @description List all volumes available to current user. */ - get: operations["get_drive-volumes"]; + get: operations['get_drive-volumes']; put?: never; /** * Create volume @@ -2649,14 +2649,14 @@ export interface paths { * * Main share cannot be deleted. */ - post: operations["post_drive-volumes"]; + post: operations['post_drive-volumes']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/delete_locked": { + '/drive/volumes/{volumeID}/delete_locked': { parameters: { query?: never; header?: never; @@ -2668,7 +2668,7 @@ export interface paths { * Delete the whole volume if is locked or the locked root shares in the volume. * @description Web client calls this endpoint when the user decides to delete their locked data and not recover it. */ - put: operations["put_drive-volumes-{volumeID}-delete_locked"]; + put: operations['put_drive-volumes-{volumeID}-delete_locked']; post?: never; delete?: never; options?: never; @@ -2676,7 +2676,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}": { + '/drive/volumes/{volumeID}': { parameters: { query?: never; header?: never; @@ -2687,7 +2687,7 @@ export interface paths { * Get volume * @description Return the attributes of a specific volume. */ - get: operations["get_drive-volumes-{volumeID}"]; + get: operations['get_drive-volumes-{volumeID}']; put?: never; post?: never; delete?: never; @@ -2696,7 +2696,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/restore": { + '/drive/volumes/{volumeID}/restore': { parameters: { query?: never; header?: never; @@ -2711,7 +2711,7 @@ export interface paths { * one of the same type (e.g., locked regular into active regular). This is processed async. * 2. When the user has locked root shares in an active volume, the data is restored within the same volume. This is done synchronous. */ - put: operations["put_drive-volumes-{volumeID}-restore"]; + put: operations['put_drive-volumes-{volumeID}-restore']; post?: never; delete?: never; options?: never; @@ -2729,7 +2729,7 @@ export interface components { */ ResponseCodeSuccess: 1000; ProtonSuccess: { - Code: components["schemas"]["ResponseCodeSuccess"]; + Code: components['schemas']['ResponseCodeSuccess']; }; ProtonError: { /** ErrorCode */ @@ -2754,14 +2754,14 @@ export interface components { DownloadTokenExpirationTimeInSec?: 1800; }; AddPhotosToAlbumRequestDto: { - AlbumData: components["schemas"]["AlbumPhotoLinkDataDto"][]; + AlbumData: components['schemas']['AlbumPhotoLinkDataDto'][]; }; CreateAlbumRequestDto: { Locked: boolean; - Link: components["schemas"]["AlbumLinkDto"]; + Link: components['schemas']['AlbumLinkDto']; }; CreateAlbumResponseDto: { - Album: components["schemas"]["AlbumShortResponseDto"]; + Album: components['schemas']['AlbumShortResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -2770,11 +2770,11 @@ export interface components { Code: 1000; }; CreatePhotoShareRequestDto: { - Share: components["schemas"]["ShareDataDto"]; - Link: components["schemas"]["LinkDataDto"]; + Share: components['schemas']['ShareDataDto']; + Link: components['schemas']['LinkDataDto']; }; GetPhotoVolumeResponseDto: { - Volume: components["schemas"]["PhotoVolumeResponseDto"]; + Volume: components['schemas']['PhotoVolumeResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -2795,7 +2795,7 @@ export interface components { NameHashes: string[]; }; FindDuplicatesOutputCollection: { - DuplicateHashes: components["schemas"]["FoundDuplicate"][]; + DuplicateHashes: components['schemas']['FoundDuplicate'][]; /** * ProtonResponseCode * @example 1000 @@ -2805,7 +2805,7 @@ export interface components { }; PhotoTagMigrationStatusResponseDto: { Finished: boolean; - Anchor?: components["schemas"]["PhotoTagMigrationDataDto"] | null; + Anchor?: components['schemas']['PhotoTagMigrationDataDto'] | null; /** * ProtonResponseCode * @example 1000 @@ -2814,7 +2814,7 @@ export interface components { Code: 1000; }; ListAlbumsResponseDto: { - Albums: components["schemas"]["AlbumResponseDto"][]; + Albums: components['schemas']['AlbumResponseDto'][]; AnchorID?: string | null; More: boolean; /** @@ -2831,11 +2831,11 @@ export interface components { * @default Captured * @enum {string} */ - Sort: "Captured" | "Added"; + Sort: 'Captured' | 'Added'; /** @default true */ Desc: boolean; /** @default null */ - Tag: components["schemas"]["TagType"] | null; + Tag: components['schemas']['TagType'] | null; /** @default false */ OnlyChildren: boolean; /** @default false */ @@ -2847,7 +2847,7 @@ export interface components { */ TagType: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; ListPhotosAlbumResponseDto: { - Photos: components["schemas"]["ListPhotosAlbumItemResponseDto"][]; + Photos: components['schemas']['ListPhotosAlbumItemResponseDto'][]; AnchorID?: string | null; More: boolean; /** @@ -2858,8 +2858,8 @@ export interface components { Code: 1000; }; TransferPhotoLinksRequestDto: { - ParentLinkID: components["schemas"]["Id"]; - Links: components["schemas"]["TransferPhotoLinkInBatchRequestDto"][]; + ParentLinkID: components['schemas']['Id']; + Links: components['schemas']['TransferPhotoLinkInBatchRequestDto'][]; /** * Format: email * @description Signature email address used for signing name @@ -2873,16 +2873,16 @@ export interface components { SignatureEmail: string | null; }; RemovePhotosFromAlbumRequestDto: { - LinkIDs: components["schemas"]["Id"][]; + LinkIDs: components['schemas']['Id'][]; }; UpdatePhotoTagMigrationStatusRequestDto: { Finished: boolean; - Anchor: components["schemas"]["PhotoTagMigrationUpdateDto"]; + Anchor: components['schemas']['PhotoTagMigrationUpdateDto']; }; /** @description An encrypted ID */ Id: string; SharedWithMeResponseDto: { - Albums: components["schemas"]["AlbumResponseDto"][]; + Albums: components['schemas']['AlbumResponseDto'][]; AnchorID?: string | null; More: boolean; /** @@ -2893,14 +2893,14 @@ export interface components { Code: 1000; }; UpdateAlbumRequestDto: { - CoverLinkID?: components["schemas"]["Id"] | null; - Link?: components["schemas"]["AlbumLinkUpdateDto"] | null; + CoverLinkID?: components['schemas']['Id'] | null; + Link?: components['schemas']['AlbumLinkUpdateDto'] | null; }; CreateBookmarkShareURLRequestDto: { - BookmarkShareURL: components["schemas"]["BookmarkShareURLRequestDto"]; + BookmarkShareURL: components['schemas']['BookmarkShareURLRequestDto']; }; CreateBookmarkShareURLResponseDto: { - BookmarkShareURL: components["schemas"]["BookmarkShareURLResponseDto"]; + BookmarkShareURL: components['schemas']['BookmarkShareURLResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -2909,7 +2909,7 @@ export interface components { Code: 1000; }; ListBookmarksOfUserResponseDto: { - Bookmarks: components["schemas"]["BookmarkShareURLInfoResponseDto"][]; + Bookmarks: components['schemas']['BookmarkShareURLInfoResponseDto'][]; /** * ProtonResponseCode * @example 1000 @@ -2918,12 +2918,12 @@ export interface components { Code: 1000; }; CreateDeviceRequestDto: { - Device: components["schemas"]["DeviceDataDto"]; - Share: components["schemas"]["ShareDataDto2"]; - Link: components["schemas"]["LinkDataDto"]; + Device: components['schemas']['DeviceDataDto']; + Share: components['schemas']['ShareDataDto2']; + Link: components['schemas']['LinkDataDto']; }; CreateDeviceResponseDto: { - Device: components["schemas"]["DeviceResponseDto"]; + Device: components['schemas']['DeviceResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -2932,7 +2932,7 @@ export interface components { Code: 1000; }; ListDevicesResponseDto: { - Devices: components["schemas"]["DeviceResponseDto2"][]; + Devices: components['schemas']['DeviceResponseDto2'][]; /** * ProtonResponseCode * @example 1000 @@ -2941,7 +2941,7 @@ export interface components { Code: 1000; }; ListDevicesResponseDto2: { - Devices: components["schemas"]["DeviceResponseDto3"][]; + Devices: components['schemas']['DeviceResponseDto3'][]; /** * ProtonResponseCode * @example 1000 @@ -2951,37 +2951,37 @@ export interface components { }; UpdateDeviceRequestDto: { /** @default null */ - Device: components["schemas"]["DeviceDataDto2"] | null; + Device: components['schemas']['DeviceDataDto2'] | null; /** * @deprecated * @default null */ - Share: components["schemas"]["ShareDataDto3"] | null; + Share: components['schemas']['ShareDataDto3'] | null; }; CreateDocumentDto: { - ContentKeyPacket: components["schemas"]["BinaryString"]; - ManifestSignature: components["schemas"]["PGPSignature"]; + ContentKeyPacket: components['schemas']['BinaryString']; + ManifestSignature: components['schemas']['PGPSignature']; /** * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; - DocumentType?: components["schemas"]["DocumentType"]; - Name: components["schemas"]["PGPMessage"]; + ContentKeyPacketSignature: components['schemas']['PGPSignature'] | null; + DocumentType?: components['schemas']['DocumentType']; + Name: components['schemas']['PGPMessage']; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + ParentLinkID: components['schemas']['Id']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Signature email address used to sign passphrase and name */ SignatureAddress: string; - NodeKey: components["schemas"]["PGPPrivateKey"]; + NodeKey: components['schemas']['PGPPrivateKey']; }; CreateDocumentResponseDto: { - Document: components["schemas"]["DocumentDetailsDto"]; + Document: components['schemas']['DocumentDetailsDto']; /** * ProtonResponseCode * @example 1000 @@ -2990,7 +2990,7 @@ export interface components { Code: 1000; }; LatestEventIDResponseDto: { - EventID: components["schemas"]["Id2"]; + EventID: components['schemas']['Id2']; /** * ProtonResponseCode * @example 1000 @@ -2999,7 +2999,7 @@ export interface components { Code: 1000; }; ListEventsResponseDto: { - Events: components["schemas"]["EventResponseDto"][]; + Events: components['schemas']['EventResponseDto'][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ EventID: string; /** @@ -3020,7 +3020,7 @@ export interface components { Code: 1000; }; ListEventsV2ResponseDto: { - Events: components["schemas"]["EventV2ResponseDto"][]; + Events: components['schemas']['EventV2ResponseDto'][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ EventID: string; /** @description true if there is more to pull, i.e. there are more events than returned in one call */ @@ -3042,21 +3042,21 @@ export interface components { * @default null */ XAttr: string | null; - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + ParentLinkID: components['schemas']['Id']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Signature email address used to sign passphrase and name */ SignatureAddress: string; - NodeKey: components["schemas"]["PGPPrivateKey"]; + NodeKey: components['schemas']['PGPPrivateKey']; }; CreateFolderResponseDto: { - Folder: components["schemas"]["FolderResponseDto"]; + Folder: components['schemas']['FolderResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -3065,7 +3065,7 @@ export interface components { Code: 1000; }; LinkIDsRequestDto: { - LinkIDs: components["schemas"]["EncryptedId"][]; + LinkIDs: components['schemas']['EncryptedId'][]; }; OffsetPagination: { /** The page size */ @@ -3088,21 +3088,21 @@ export interface components { * @default null */ XAttr: string | null; - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + ParentLinkID: components['schemas']['Id']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Signature email address used to sign passphrase and name */ SignatureEmail: string; - NodeKey: components["schemas"]["PGPPrivateKey"]; + NodeKey: components['schemas']['PGPPrivateKey']; }; ListChildrenResponseDto: { - LinkIDs: components["schemas"]["Id2"][]; + LinkIDs: components['schemas']['Id2'][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -3125,7 +3125,7 @@ export interface components { AvailableHashesResponseDto: { AvailableHashes: string[]; /** @description Hashes of existing drafts excluding the ones of provided ClientUIDs */ - PendingHashes: components["schemas"]["PendingHashResponseDto"][]; + PendingHashes: components['schemas']['PendingHashResponseDto'][]; /** * ProtonResponseCode * @example 1000 @@ -3153,7 +3153,7 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature: components['schemas']['PGPSignature'] | null; /** * Format: email * @description Signature email address used for the NodePassphraseSignature. @@ -3164,12 +3164,12 @@ export interface components { * @description Optional, except when moving a Photo-Link. * @default null */ - Photos: components["schemas"]["PhotosDto"] | null; + Photos: components['schemas']['PhotosDto'] | null; /** * @description Only for legacy files (signed by the user). Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + ContentKeyPacketSignature: components['schemas']['PGPSignature'] | null; /** * @description Only for legacy folders (signed by the user). Node hash key should be unchanged, just re-signed with the NodeKey. * @default null @@ -3177,7 +3177,7 @@ export interface components { NodeHashKey: string | null; }; CopyLinkResponseDto: { - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; /** * ProtonResponseCode * @example 1000 @@ -3193,10 +3193,10 @@ export interface components { * @enum {integer} */ Thumbnails: 0 | 1; - LinkIDs: components["schemas"]["EncryptedId"][]; + LinkIDs: components['schemas']['EncryptedId'][]; }; FetchLinksMetadataResponseDto: { - Links: components["schemas"]["ExtendedLinkTransformer"][]; + Links: components['schemas']['ExtendedLinkTransformer'][]; /** * ProtonResponseCode * @example 1000 @@ -3205,7 +3205,7 @@ export interface components { Code: 1000; }; ListMissingHashKeyResponseDto: { - NodesWithMissingNodeHashKey: components["schemas"]["ListMissingHashKeyItemDto"][]; + NodesWithMissingNodeHashKey: components['schemas']['ListMissingHashKeyItemDto'][]; /** * ProtonResponseCode * @example 1000 @@ -3214,7 +3214,11 @@ export interface components { Code: 1000; }; LoadLinkDetailsResponseDto: { - Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"] | components["schemas"]["AlbumDetailsDto"])[]; + Links: ( + | components['schemas']['FileDetailsDto'] + | components['schemas']['FolderDetailsDto'] + | components['schemas']['AlbumDetailsDto'] + )[]; /** * ProtonResponseCode * @example 1000 @@ -3223,8 +3227,8 @@ export interface components { Code: 1000; }; MoveLinkBatchRequestDto: { - ParentLinkID: components["schemas"]["Id"]; - Links: components["schemas"]["MoveLinkInBatchRequestDto"][]; + ParentLinkID: components['schemas']['Id']; + Links: components['schemas']['MoveLinkInBatchRequestDto'][]; /** * Format: email * @description Signature email address used for signing name @@ -3245,7 +3249,7 @@ export interface components { NodePassphrase: string; /** @description Name hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; + ParentLinkID: components['schemas']['Id']; /** * Format: email * @description Signature email address used for signing name; Required when not passing `SignatureAddress` @@ -3269,7 +3273,7 @@ export interface components { * @description Deprecated: Target ShareID (for move between shares/devices). Determined on the backend automatically * @default null */ - NewShareID: components["schemas"]["Id"] | null; + NewShareID: components['schemas']['Id'] | null; /** * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] * @default null @@ -3279,7 +3283,7 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature: components['schemas']['PGPSignature'] | null; /** * Format: email * @description Signature email address used for the NodePassphraseSignature. @@ -3318,7 +3322,7 @@ export interface components { MIMEType: string | null; }; UpdateMissingHashKeyRequestDto: { - NodesWithMissingNodeHashKey: components["schemas"]["UpdateMissingHashKeyItemDto"][]; + NodesWithMissingNodeHashKey: components['schemas']['UpdateMissingHashKeyItemDto'][]; }; MoveLinkRequestDto2: { /** @description Name, reusing same session key as previously. */ @@ -3327,7 +3331,7 @@ export interface components { NodePassphrase: string; /** @description Name hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; + ParentLinkID: components['schemas']['Id']; /** @description Current name hash before move operation. Used to prevent race conditions. */ OriginalHash: string; /** @@ -3344,7 +3348,7 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature: components['schemas']['PGPSignature'] | null; /** * Format: email * @description Signature email address used for the NodePassphraseSignature. @@ -3353,7 +3357,7 @@ export interface components { SignatureEmail: string | null; }; CommitRevisionDto: { - ManifestSignature: components["schemas"]["PGPSignature"]; + ManifestSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. @@ -3371,13 +3375,13 @@ export interface components { */ XAttr: string | null; /** @default null */ - Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; + Photo: components['schemas']['CommitRevisionPhotoDto'] | null; /** * @deprecated * @description Ignored entirely by API. Field can be removed from request by client. * @default null */ - BlockList: components["schemas"]["BlockTokenDto"][] | null; + BlockList: components['schemas']['BlockTokenDto'][] | null; /** * @deprecated * @default null @@ -3393,7 +3397,7 @@ export interface components { CreateFileDto: { /** @example text/plain */ MIMEType: string; - ContentKeyPacket: components["schemas"]["BinaryString"]; + ContentKeyPacket: components['schemas']['BinaryString']; /** * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null @@ -3409,22 +3413,22 @@ export interface components { * @default null */ IntendedUploadSize: number | null; - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + ParentLinkID: components['schemas']['Id']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Signature email address used to sign passphrase and name */ SignatureAddress: string; - NodeKey: components["schemas"]["PGPPrivateKey"]; + NodeKey: components['schemas']['PGPPrivateKey']; }; CreateRevisionRequestDto: { /** @default null */ - CurrentRevisionID: components["schemas"]["Id"] | null; + CurrentRevisionID: components['schemas']['Id'] | null; /** * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. * @default null @@ -3454,7 +3458,7 @@ export interface components { NoBlockUrls: boolean; }; ListRevisionsResponseDto: { - Revisions: components["schemas"]["RevisionResponseDto"][]; + Revisions: components['schemas']['RevisionResponseDto'][]; /** * ProtonResponseCode * @example 1000 @@ -3471,8 +3475,8 @@ export interface components { Code: 1002; }; VerificationData: { - VerificationCode: components["schemas"]["BinaryString2"]; - ContentKeyPacket: components["schemas"]["BinaryString2"]; + VerificationCode: components['schemas']['BinaryString2']; + ContentKeyPacket: components['schemas']['BinaryString2']; /** * ProtonResponseCode * @example 1000 @@ -3490,7 +3494,7 @@ export interface components { }; VolumeTrashList: { /** @description Trash per share */ - Trash: components["schemas"]["ShareTrashList"][]; + Trash: components['schemas']['ShareTrashList'][]; /** * ProtonResponseCode * @example 1000 @@ -3499,17 +3503,17 @@ export interface components { Code: 1000; }; RequestUploadInput: { - AddressID: components["schemas"]["Id"]; - LinkID: components["schemas"]["Id"]; - RevisionID: components["schemas"]["Id"]; + AddressID: components['schemas']['Id']; + LinkID: components['schemas']['Id']; + RevisionID: components['schemas']['Id']; /** @default null */ - VolumeID: components["schemas"]["Id"] | null; + VolumeID: components['schemas']['Id'] | null; /** * @deprecated * @description Deprecated, pass VolumeID instead * @default null */ - ShareID: components["schemas"]["Id"] | null; + ShareID: components['schemas']['Id'] | null; /** * @deprecated * @description Request for thumbnail upload @@ -3529,15 +3533,15 @@ export interface components { */ ThumbnailSize: number | null; /** @default [] */ - BlockList: components["schemas"]["RequestUploadBlockInput"][]; + BlockList: components['schemas']['RequestUploadBlockInput'][]; /** @default [] */ - ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + ThumbnailList: components['schemas']['RequestUploadThumbnailInput'][]; }; RequestUploadResponse: { - UploadLinks: components["schemas"]["BlockURL"][]; + UploadLinks: components['schemas']['BlockURL'][]; /** @deprecated */ - ThumbnailLink?: components["schemas"]["ThumbnailBlockURL"] | null; - ThumbnailLinks?: components["schemas"]["ThumbnailBlockURL"][] | null; + ThumbnailLink?: components['schemas']['ThumbnailBlockURL'] | null; + ThumbnailLinks?: components['schemas']['ThumbnailBlockURL'][] | null; /** * ProtonResponseCode * @example 1000 @@ -3546,8 +3550,8 @@ export interface components { Code: 1000; }; SmallUploadResponseDto: { - LinkID: components["schemas"]["Id2"]; - RevisionID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; + RevisionID: components['schemas']['Id2']; /** * ProtonResponseCode * @example 1000 @@ -3562,7 +3566,7 @@ export interface components { */ ShareURL: string; /** @enum {string} */ - AbuseCategory: "spam" | "copyright" | "child-abuse" | "stolen-data" | "malware" | "other"; + AbuseCategory: 'spam' | 'copyright' | 'child-abuse' | 'stolen-data' | 'malware' | 'other'; /** @description Passphrase for reported Link's Node key, unencrypted, as a string, escaped for JSON. */ ResourcePassphrase: string; /** @@ -3583,11 +3587,11 @@ export interface components { */ ReporterMessage: string | null; /** @default null */ - VolumeID: components["schemas"]["Id"] | null; + VolumeID: components['schemas']['Id'] | null; /** @default null */ - LinkID: components["schemas"]["Id"] | null; + LinkID: components['schemas']['Id'] | null; /** @default null */ - RevisionID: components["schemas"]["Id"] | null; + RevisionID: components['schemas']['Id'] | null; }; ChecklistResponseDto: { /** @description Array of completed checklist items */ @@ -3625,7 +3629,7 @@ export interface components { Code: 1000; }; GetEntitlementResponseDto: { - Entitlements: components["schemas"]["EntitlementsDto"]; + Entitlements: components['schemas']['EntitlementsDto']; /** * ProtonResponseCode * @example 1000 @@ -3634,15 +3638,15 @@ export interface components { Code: 1000; }; AddTagsRequestDto: { - Tags: components["schemas"]["TagType"][]; + Tags: components['schemas']['TagType'][]; }; FavoritePhotoRequestDto: { - PhotoData?: components["schemas"]["FavoritePhotoDataDto"] | null; + PhotoData?: components['schemas']['FavoritePhotoDataDto'] | null; }; FavoritePhotoResponseDto: { - LinkID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - RelatedPhotos: components["schemas"]["FavoriteRelatedPhotoResponseDto"][]; + LinkID: components['schemas']['Id2']; + VolumeID: components['schemas']['Id2']; + RelatedPhotos: components['schemas']['FavoriteRelatedPhotoResponseDto'][]; /** * ProtonResponseCode * @example 1000 @@ -3651,8 +3655,8 @@ export interface components { Code: 1000; }; GetMigrationStatusResponseDto: { - OldVolumeID: components["schemas"]["Id2"]; - NewVolumeID?: components["schemas"]["Id2"] | null; + OldVolumeID: components['schemas']['Id2']; + NewVolumeID?: components['schemas']['Id2'] | null; /** * ProtonResponseCode * @example 1000 @@ -3677,17 +3681,17 @@ export interface components { * @description The link ID of the last photo from the previous page when requesting secondary pages * @default null */ - PreviousPageLastLinkID: components["schemas"]["Id"] | null; + PreviousPageLastLinkID: components['schemas']['Id'] | null; /** * @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) * @default null */ MinimumCaptureTime: number | null; /** @default null */ - Tag: components["schemas"]["TagType"] | null; + Tag: components['schemas']['TagType'] | null; }; PhotoListingResponse: { - Photos: components["schemas"]["PhotoListingItemResponse"][]; + Photos: components['schemas']['PhotoListingItemResponse'][]; /** * ProtonResponseCode * @example 1000 @@ -3697,7 +3701,7 @@ export interface components { }; MigrateFromLegacyRequest: Record; RemoveTagsRequestDto: { - Tags: components["schemas"]["TagType"][]; + Tags: components['schemas']['TagType'][]; }; UpdateXAttrRequest: { /** @@ -3709,7 +3713,7 @@ export interface components { XAttr: string; }; CommitAnonymousRevisionDto: { - ManifestSignature: components["schemas"]["PGPSignature"]; + ManifestSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. @@ -3721,18 +3725,18 @@ export interface components { * @description Photo attributes * @default null */ - Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; + Photo: components['schemas']['CommitRevisionPhotoDto'] | null; }; CreateAnonymousDocumentDto: { - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - NodeKey: components["schemas"]["PGPPrivateKey"]; - ContentKeyPacket: components["schemas"]["BinaryString"]; - ManifestSignature: components["schemas"]["PGPSignature"]; + ParentLinkID: components['schemas']['Id']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; + NodeKey: components['schemas']['PGPPrivateKey']; + ContentKeyPacket: components['schemas']['BinaryString']; + ManifestSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Signature email address used to sign passphrase and name @@ -3742,10 +3746,10 @@ export interface components { * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + ContentKeyPacketSignature: components['schemas']['PGPSignature'] | null; }; CreateAnonymousDocumentResponseDto: { - Document: components["schemas"]["DocumentDetailsDto"]; + Document: components['schemas']['DocumentDetailsDto']; AuthorizationToken: string; /** * ProtonResponseCode @@ -3755,16 +3759,16 @@ export interface components { Code: 1000; }; CreateAnonymousFileRequestDto: { - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - NodeKey: components["schemas"]["PGPPrivateKey"]; + ParentLinkID: components['schemas']['Id']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; + NodeKey: components['schemas']['PGPPrivateKey']; /** @example text/plain */ MIMEType: string; - ContentKeyPacket: components["schemas"]["BinaryString"]; + ContentKeyPacket: components['schemas']['BinaryString']; /** * Format: email * @description Signature email address used to sign passphrase and name @@ -3787,7 +3791,7 @@ export interface components { IntendedUploadSize: number | null; }; CreateAnonymousFileResponseDto: { - File: components["schemas"]["FileResponseDto"]; + File: components['schemas']['FileResponseDto']; AuthorizationToken: string; /** * ProtonResponseCode @@ -3797,18 +3801,18 @@ export interface components { Code: 1000; }; CreateAnonymousFolderRequestDto: { - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + ParentLinkID: components['schemas']['Id']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Signature email address used to sign passphrase and name */ SignatureEmail?: string | null; - NodeKey: components["schemas"]["PGPPrivateKey"]; + NodeKey: components['schemas']['PGPPrivateKey']; /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; /** @@ -3818,7 +3822,7 @@ export interface components { XAttr: string | null; }; CreateAnonymousFolderResponseDto: { - Folder: components["schemas"]["FolderResponseDto"]; + Folder: components['schemas']['FolderResponseDto']; AuthorizationToken: string; /** * ProtonResponseCode @@ -3828,7 +3832,7 @@ export interface components { Code: 1000; }; DeleteChildrenRequestDto: { - Links: components["schemas"]["LinkWithAuthorizationTokenDto"][]; + Links: components['schemas']['LinkWithAuthorizationTokenDto'][]; }; ParentEncryptedLinkIDsResponseDto: { ParentLinkIDs: string[]; @@ -3862,20 +3866,20 @@ export interface components { AuthorizationToken: string | null; }; RequestAnonymousUploadRequestDto: { - LinkID: components["schemas"]["Id"]; - RevisionID: components["schemas"]["Id"]; + LinkID: components['schemas']['Id']; + RevisionID: components['schemas']['Id']; /** * Format: email * @description Signature email address used to sign the blocks content */ SignatureEmail?: string | null; /** @default [] */ - BlockList: components["schemas"]["AnonymousUploadBlockDto"][]; + BlockList: components['schemas']['AnonymousUploadBlockDto'][]; /** @default [] */ - ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + ThumbnailList: components['schemas']['RequestUploadThumbnailInput'][]; }; ShareURLContextsCollection: { - ShareURLContexts: components["schemas"]["ShareURLContext"][]; + ShareURLContexts: components['schemas']['ShareURLContext'][]; /** @description Indicates there may be more ShareURLs */ More: boolean; /** @@ -3889,7 +3893,7 @@ export interface components { SessionName: string; More: number; Total: number; - Links: components["schemas"]["LinkMapItemResponse"][]; + Links: components['schemas']['LinkMapItemResponse'][]; /** * ProtonResponseCode * @example 1000 @@ -3898,9 +3902,9 @@ export interface components { Code: 1000; }; MyFilesResponseDto: { - Volume: components["schemas"]["VolumeDto"]; - Share: components["schemas"]["ShareDto"]; - Link: components["schemas"]["FolderDetailsDto2"]; + Volume: components['schemas']['VolumeDto']; + Share: components['schemas']['ShareDto']; + Link: components['schemas']['FolderDetailsDto2']; /** * ProtonResponseCode * @example 1000 @@ -3909,17 +3913,17 @@ export interface components { Code: 1000; }; BootstrapShareResponseDto: { - ShareID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - Type: components["schemas"]["ShareType"]; - State: components["schemas"]["ShareState"]; - VolumeType: components["schemas"]["VolumeType"]; + ShareID: components['schemas']['Id2']; + VolumeID: components['schemas']['Id2']; + Type: components['schemas']['ShareType']; + State: components['schemas']['ShareState']; + VolumeType: components['schemas']['VolumeType']; /** Format: email */ Creator: string; Locked?: boolean | null; CreateTime: number; ModifyTime: number; - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; /** * @deprecated * @description Deprecated: Use `CreateTime` @@ -3927,16 +3931,16 @@ export interface components { CreationTime: number; /** @deprecated */ PermissionsMask: number; - LinkType: components["schemas"]["NodeType"]; + LinkType: components['schemas']['NodeType']; /** @deprecated */ Flags: number; /** @deprecated */ BlockSize: number; /** @deprecated */ VolumeSoftDeleted: boolean; - Key: components["schemas"]["PGPPrivateKey2"]; - Passphrase: components["schemas"]["PGPMessage2"]; - PassphraseSignature: components["schemas"]["PGPSignature2"]; + Key: components['schemas']['PGPPrivateKey2']; + Passphrase: components['schemas']['PGPMessage2']; + PassphraseSignature: components['schemas']['PGPSignature2']; /** @description Address ID of the current user's address for the membership of this share. Can be missing if the user is not a direct member of the share. */ AddressID?: string | null; /** @@ -3945,13 +3949,13 @@ export interface components { */ AddressKeyID?: string | null; /** @description Your own memberships */ - Memberships: components["schemas"]["MemberResponseDto"][]; + Memberships: components['schemas']['MemberResponseDto'][]; /** * @deprecated * @description Deprecated, use `Memberships` instead */ - PossibleKeyPackets: components["schemas"]["KeyPacketResponseDto"][]; - RootLinkRecoveryPassphrase?: components["schemas"]["PGPMessage2"] | null; + PossibleKeyPackets: components['schemas']['KeyPacketResponseDto'][]; + RootLinkRecoveryPassphrase?: components['schemas']['PGPMessage2'] | null; /** * @deprecated * @description User for AutoRestoreProcedure, see /sanitization/asv endpoint(s) @@ -3966,7 +3970,7 @@ export interface components { Code: 1000; }; GetHighestContextForDocumentResponse: { - ContextShareID: components["schemas"]["Id2"]; + ContextShareID: components['schemas']['Id2']; /** * ProtonResponseCode * @example 1000 @@ -3975,7 +3979,7 @@ export interface components { Code: 1000; }; ListAutoRestoreVolumeRootSharesResponseDto: { - ShareIDs: components["schemas"]["Id2"][]; + ShareIDs: components['schemas']['Id2'][]; /** * ProtonResponseCode * @example 1000 @@ -3984,7 +3988,7 @@ export interface components { Code: 1000; }; ListSharesResponseDto: { - Shares: components["schemas"]["ShareResponseDto"][]; + Shares: components['schemas']['ShareResponseDto'][]; /** * ProtonResponseCode * @example 1000 @@ -3993,7 +3997,7 @@ export interface components { Code: 1000; }; LogFailedRestoreProcedureRequestDto: { - Shares: components["schemas"]["FailedRestoreProcedureShareDataDto"][]; + Shares: components['schemas']['FailedRestoreProcedureShareDataDto'][]; }; TransferInput: { /** @description The ID of the new address */ @@ -4010,18 +4014,18 @@ export interface components { * @description The sum of PassphraseNodeKeyPacket-pairs and UnreadableShareIDs should not exceed 50 * @default [] */ - PassphraseNodeKeyPackets: components["schemas"]["ShareKPMigrationData"][]; + PassphraseNodeKeyPackets: components['schemas']['ShareKPMigrationData'][]; /** * @description ShareIDs of unmigrated Shares that the client could not decrypt and should be locked * @default [] */ - UnreadableShareIDs: components["schemas"]["Id"][]; + UnreadableShareIDs: components['schemas']['Id'][]; }; MigrateSharesResponseDto: { /** @description ShareIDs successfully migrated */ - ShareIDs: components["schemas"]["Id2"][]; + ShareIDs: components['schemas']['Id2'][]; /** @description ShareIDs not migrated with reason and error code */ - Errors: components["schemas"]["ShareKPMigrationError"][]; + Errors: components['schemas']['ShareKPMigrationError'][]; /** * ProtonResponseCode * @example 1000 @@ -4031,7 +4035,7 @@ export interface components { }; UnmigratedSharesResponseDto: { /** @description ShareIDs that can be migrated */ - ShareIDs: components["schemas"]["Id2"][]; + ShareIDs: components['schemas']['Id2'][]; /** * ProtonResponseCode * @example 1000 @@ -4041,14 +4045,14 @@ export interface components { }; InitSRPSessionResponseDto: { Modulus: string; - ServerEphemeral: components["schemas"]["BinaryString2"]; - UrlPasswordSalt: components["schemas"]["BinaryString2"]; - SRPSession: components["schemas"]["BinaryString2"]; + ServerEphemeral: components['schemas']['BinaryString2']; + UrlPasswordSalt: components['schemas']['BinaryString2']; + SRPSession: components['schemas']['BinaryString2']; Version: number; Flags: number; /** @deprecated */ IsDoc: boolean; - VendorType: components["schemas"]["VendorType"]; + VendorType: components['schemas']['VendorType']; /** * ProtonResponseCode * @example 1000 @@ -4057,12 +4061,12 @@ export interface components { Code: 1000; }; AuthShareTokenRequestDto: { - ClientEphemeral: components["schemas"]["BinaryString"]; - ClientProof: components["schemas"]["BinaryString"]; - SRPSession: components["schemas"]["BinaryString"]; + ClientEphemeral: components['schemas']['BinaryString']; + ClientProof: components['schemas']['BinaryString']; + SRPSession: components['schemas']['BinaryString']; }; BootstrapShareTokenResponseDto: { - Token: components["schemas"]["TokenResponseDto"]; + Token: components['schemas']['TokenResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -4071,7 +4075,7 @@ export interface components { Code: 1000; }; GetRevisionResponseDto: { - Revision: components["schemas"]["DetailedRevisionResponseDto"]; + Revision: components['schemas']['DetailedRevisionResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -4084,13 +4088,13 @@ export interface components { FromBlockIndex: number; /** @default null */ PageSize: number | null; - ClientEphemeral: components["schemas"]["BinaryString"]; - ClientProof: components["schemas"]["BinaryString"]; - SRPSession: components["schemas"]["BinaryString"]; + ClientEphemeral: components['schemas']['BinaryString']; + ClientProof: components['schemas']['BinaryString']; + SRPSession: components['schemas']['BinaryString']; }; GetSharedFileInfoResponseDto: { - ServerProof: components["schemas"]["BinaryString2"]; - Payload: components["schemas"]["GetSharedFileInfoPayloadDto"]; + ServerProof: components['schemas']['BinaryString2']; + Payload: components['schemas']['GetSharedFileInfoPayloadDto']; /** * ProtonResponseCode * @example 1000 @@ -4099,10 +4103,10 @@ export interface components { Code: 1000; }; ListShareURLsResponseDto: { - ShareURLs: components["schemas"]["ShareURLResponseDto2"][]; + ShareURLs: components['schemas']['ShareURLResponseDto2'][]; /** @description If the Recursive query parameter is set, also returns the related links and ancestors up to the share as a dictionary by LinkID. */ Links: { - [key: string]: components["schemas"]["ExtendedLinkTransformer2"]; + [key: string]: components['schemas']['ExtendedLinkTransformer2']; }; /** * ProtonResponseCode @@ -4121,13 +4125,13 @@ export interface components { * @enum {integer} */ Permissions: 4 | 6; - UrlPasswordSalt: components["schemas"]["BinaryString"]; - SharePasswordSalt: components["schemas"]["BinaryString"]; - SRPVerifier: components["schemas"]["BinaryString"]; - SRPModulusID: components["schemas"]["Id"]; + UrlPasswordSalt: components['schemas']['BinaryString']; + SharePasswordSalt: components['schemas']['BinaryString']; + SRPVerifier: components['schemas']['BinaryString']; + SRPModulusID: components['schemas']['Id']; /** @description Bitmap: 1 = custom password set, 2 = random password set */ Flags: number; - SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; + SharePassphraseKeyPacket: components['schemas']['BinaryString']; /** @description PGP encrypted password. The password is encrypted with the user's address key. */ Password: string; /** @description Maximum number of times this link can be accessed. 0 for infinite */ @@ -4165,25 +4169,25 @@ export interface components { */ Permissions: 4 | 6 | null; /** @default null */ - UrlPasswordSalt: components["schemas"]["BinaryString"] | null; + UrlPasswordSalt: components['schemas']['BinaryString'] | null; /** @default null */ - SharePasswordSalt: components["schemas"]["BinaryString"] | null; + SharePasswordSalt: components['schemas']['BinaryString'] | null; /** @default null */ - SRPVerifier: components["schemas"]["BinaryString"] | null; + SRPVerifier: components['schemas']['BinaryString'] | null; /** @default null */ - SRPModulusID: components["schemas"]["Id"] | null; + SRPModulusID: components['schemas']['Id'] | null; /** * @description Bitmap: 1 = custom password set, 2 = random password set * @default null */ Flags: number | null; /** @default null */ - SharePassphraseKeyPacket: components["schemas"]["BinaryString"] | null; + SharePassphraseKeyPacket: components['schemas']['BinaryString'] | null; /** * @description PGP encrypted password. The password is encrypted with the user's address key. * @default null */ - Password: components["schemas"]["PGPMessage"] | null; + Password: components['schemas']['PGPMessage'] | null; /** * @description Maximum number of times this link can be accessed. 0 for infinite * @default null @@ -4192,18 +4196,18 @@ export interface components { }; DeleteMultipleShareURLsRequestDto: { /** @description List of ShareURL ids to delete. */ - ShareURLIDs: components["schemas"]["EncryptedId"][]; + ShareURLIDs: components['schemas']['EncryptedId'][]; }; CreateShareRequestDto: { - AddressID: components["schemas"]["Id"]; - RootLinkID: components["schemas"]["Id"]; - ShareKey: components["schemas"]["PGPPrivateKey"]; + AddressID: components['schemas']['Id']; + RootLinkID: components['schemas']['Id']; + ShareKey: components['schemas']['PGPPrivateKey']; /** @description Full PGP message containing (optionally) PassphraseNodeKP and SharePassphrase-KP and data-packet (encrypted SharePassphrase) -> in this exact order */ SharePassphrase: string; - SharePassphraseSignature: components["schemas"]["PGPSignature"]; + SharePassphraseSignature: components['schemas']['PGPSignature']; /** @description Key packet for passphrase of referenced link's node key passphrase */ PassphraseKeyPacket: string; - NameKeyPacket: components["schemas"]["BinaryString"]; + NameKeyPacket: components['schemas']['BinaryString']; /** * @deprecated * @default null @@ -4211,7 +4215,7 @@ export interface components { Name: string | null; }; SharedByMeResponseDto: { - Links: components["schemas"]["LinkSharedByMeResponseDto"][]; + Links: components['schemas']['LinkSharedByMeResponseDto'][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -4224,7 +4228,7 @@ export interface components { Code: 1000; }; SharedWithMeResponseDto2: { - Links: components["schemas"]["LinkSharedWithMeResponseDto"][]; + Links: components['schemas']['LinkSharedWithMeResponseDto'][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -4237,12 +4241,12 @@ export interface components { Code: 1000; }; InviteExternalUserRequestDto: { - ExternalInvitation: components["schemas"]["ExternalInvitationRequestDto"]; + ExternalInvitation: components['schemas']['ExternalInvitationRequestDto']; /** @default null */ - EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; + EmailDetails: components['schemas']['InvitationEmailDetailsRequestDto'] | null; }; InviteExternalUserResponseDto: { - ExternalInvitation: components["schemas"]["ExternalInvitationResponseDto"]; + ExternalInvitation: components['schemas']['ExternalInvitationResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -4251,7 +4255,7 @@ export interface components { Code: 1000; }; ListShareExternalInvitationsResponseDto: { - ExternalInvitations: components["schemas"]["ExternalInvitationResponseDto"][]; + ExternalInvitations: components['schemas']['ExternalInvitationResponseDto'][]; /** * ProtonResponseCode * @example 1000 @@ -4260,7 +4264,7 @@ export interface components { Code: 1000; }; ListUserRegisteredExternalInvitationResponseDto: { - ExternalInvitations: components["schemas"]["UserRegisteredExternalInvitationItemDto"][]; + ExternalInvitations: components['schemas']['UserRegisteredExternalInvitationItemDto'][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -4288,12 +4292,12 @@ export interface components { SessionKeySignature: string; }; InviteUserRequestDto: { - Invitation: components["schemas"]["InvitationRequestDto"]; + Invitation: components['schemas']['InvitationRequestDto']; /** @default null */ - EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; + EmailDetails: components['schemas']['InvitationEmailDetailsRequestDto'] | null; }; InviteUserResponseDto: { - Invitation: components["schemas"]["InvitationResponseDto"]; + Invitation: components['schemas']['InvitationResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -4302,7 +4306,7 @@ export interface components { Code: 1000; }; ListShareInvitationsResponseDto: { - Invitations: components["schemas"]["InvitationResponseDto"][]; + Invitations: components['schemas']['InvitationResponseDto'][]; /** * ProtonResponseCode * @example 1000 @@ -4311,11 +4315,11 @@ export interface components { Code: 1000; }; ListPendingInvitationQueryParameters: { - AnchorID?: components["schemas"]["Id"] | null; + AnchorID?: components['schemas']['Id'] | null; /** @default 150 */ PageSize: number; /** @default null */ - ShareTargetTypes: components["schemas"]["TargetType"][] | null; + ShareTargetTypes: components['schemas']['TargetType'][] | null; }; /** * @description
See values descriptions
See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
@@ -4323,7 +4327,7 @@ export interface components { */ TargetType: 0 | 1 | 2 | 3 | 4 | 5; ListPendingInvitationResponseDto: { - Invitations: components["schemas"]["PendingInvitationItemDto"][]; + Invitations: components['schemas']['PendingInvitationItemDto'][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -4336,9 +4340,9 @@ export interface components { Code: 1000; }; PendingInvitationResponseDto: { - Invitation: components["schemas"]["InvitationResponseDto"]; - Share: components["schemas"]["ShareResponseDto2"]; - Link: components["schemas"]["LinkResponseDto"]; + Invitation: components['schemas']['InvitationResponseDto']; + Share: components['schemas']['ShareResponseDto2']; + Link: components['schemas']['LinkResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -4359,9 +4363,9 @@ export interface components { }; LinkAccessesResponseDto: { /** @default null */ - ContextShare: components["schemas"]["ContextShareDto"] | null; + ContextShare: components['schemas']['ContextShareDto'] | null; /** @default null */ - Invitations: components["schemas"]["PendingInvitationItemDto"][] | null; + Invitations: components['schemas']['PendingInvitationItemDto'][] | null; /** * ProtonResponseCode * @example 1000 @@ -4370,7 +4374,7 @@ export interface components { Code: 1000; }; ListShareMembersResponseDto: { - Members: components["schemas"]["MemberResponseDto2"][]; + Members: components['schemas']['MemberResponseDto2'][]; /** * ProtonResponseCode * @example 1000 @@ -4394,8 +4398,8 @@ export interface components { }; /** @description For each hash from the request, response contains either result or error entry */ SecurityResponseDto: { - Results: components["schemas"]["SecurityResponseResultDto"][]; - Errors: components["schemas"]["SecurityResponseErrorDto"][]; + Results: components['schemas']['SecurityResponseResultDto'][]; + Errors: components['schemas']['SecurityResponseErrorDto'][]; /** * ProtonResponseCode * @example 1000 @@ -4405,11 +4409,11 @@ export interface components { }; ThumbnailIDsListInput: { /** @description List of encrypted ThumbnailIDs. Maximum 30. */ - ThumbnailIDs: components["schemas"]["Id"][]; + ThumbnailIDs: components['schemas']['Id'][]; }; ListThumbnailsResponse: { - Thumbnails: components["schemas"]["ThumbnailResponse"][]; - Errors: components["schemas"]["ThumbnailErrorResponse"][]; + Thumbnails: components['schemas']['ThumbnailResponse'][]; + Errors: components['schemas']['ThumbnailErrorResponse'][]; /** * ProtonResponseCode * @example 1000 @@ -4418,8 +4422,8 @@ export interface components { Code: 1000; }; SettingsResponse: { - UserSettings: components["schemas"]["UserSettings"]; - Defaults: components["schemas"]["Defaults"]; + UserSettings: components['schemas']['UserSettings']; + Defaults: components['schemas']['Defaults']; /** * ProtonResponseCode * @example 1000 @@ -4428,10 +4432,10 @@ export interface components { Code: 1000; }; UserSettingsRequest: { - Layout?: components["schemas"]["LayoutSetting"] | null; - Sort?: components["schemas"]["SortSetting"] | null; + Layout?: components['schemas']['LayoutSetting'] | null; + Sort?: components['schemas']['SortSetting'] | null; /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ - RevisionRetentionDays?: components["schemas"]["RevisionRetentionDays"] | null; + RevisionRetentionDays?: components['schemas']['RevisionRetentionDays'] | null; /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ B2BPhotosEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ @@ -4441,19 +4445,19 @@ export interface components { /** @description Indicates user-preferred font in Proton Docs. */ DocsFontPreference?: string | null; /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ - PhotoTags?: components["schemas"]["TagType"][] | null; + PhotoTags?: components['schemas']['TagType'][] | null; }; CreateVolumeRequestDto: { /** @description User's Address encrypted ID */ AddressID: string; - ShareKey: components["schemas"]["PGPPrivateKey"]; - SharePassphrase: components["schemas"]["PGPMessage"]; - SharePassphraseSignature: components["schemas"]["PGPSignature"]; - FolderName: components["schemas"]["PGPMessage"]; - FolderKey: components["schemas"]["PGPPrivateKey"]; - FolderPassphrase: components["schemas"]["PGPMessage"]; - FolderPassphraseSignature: components["schemas"]["PGPSignature"]; - FolderHashKey: components["schemas"]["PGPMessage"]; + ShareKey: components['schemas']['PGPPrivateKey']; + SharePassphrase: components['schemas']['PGPMessage']; + SharePassphraseSignature: components['schemas']['PGPSignature']; + FolderName: components['schemas']['PGPMessage']; + FolderKey: components['schemas']['PGPPrivateKey']; + FolderPassphrase: components['schemas']['PGPMessage']; + FolderPassphraseSignature: components['schemas']['PGPSignature']; + FolderHashKey: components['schemas']['PGPMessage']; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ AddressKeyID: string; /** @@ -4468,7 +4472,7 @@ export interface components { ShareName: string | null; }; GetVolumeResponseDto: { - Volume: components["schemas"]["VolumeResponseDto"]; + Volume: components['schemas']['VolumeResponseDto']; /** * ProtonResponseCode * @example 1000 @@ -4477,7 +4481,7 @@ export interface components { Code: 1000; }; ListVolumesResponseDto: { - Volumes: components["schemas"]["VolumeResponseDto"][]; + Volumes: components['schemas']['VolumeResponseDto'][]; /** * ProtonResponseCode * @example 1000 @@ -4493,7 +4497,7 @@ export interface components { * @deprecated * @default null */ - TargetVolumeID: components["schemas"]["Id"] | null; + TargetVolumeID: components['schemas']['Id'] | null; /** * @deprecated * @description Folder name as armored PGP message @@ -4509,36 +4513,36 @@ export interface components { * @deprecated * @default null */ - NodePassphrase: components["schemas"]["PGPMessage"] | null; + NodePassphrase: components['schemas']['PGPMessage'] | null; /** * @deprecated * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature: components['schemas']['PGPSignature'] | null; /** @default [] */ - MainShares: components["schemas"]["RestoreMainShareDto"][]; + MainShares: components['schemas']['RestoreMainShareDto'][]; /** @default [] */ - Devices: components["schemas"]["RestoreDeviceDto"][]; + Devices: components['schemas']['RestoreDeviceDto'][]; /** @default [] */ - PhotoShares: components["schemas"]["RestorePhotoShareDto"][]; + PhotoShares: components['schemas']['RestorePhotoShareDto'][]; /** * @deprecated * @default null */ - NodeHashKey: components["schemas"]["PGPMessage"] | null; + NodeHashKey: components['schemas']['PGPMessage'] | null; /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ AddressKeyID: string; }; AddPhotoToAlbumWithLinkIDResponseDto: Record; RemovePhotoFromAlbumWithLinkIDResponseDto: Record; ConflictErrorResponseDto: { - Details: components["schemas"]["ConflictErrorDetailsDto"]; + Details: components['schemas']['ConflictErrorDetailsDto']; Error: string; Code: number; }; MultiDeleteTransformer: { LinkID: string; - Response: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + Response: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; }; /** Link */ ExtendedLinkTransformer: { @@ -4688,8 +4692,8 @@ export interface components { */ Token?: string; }; - Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; - Photo?: components["schemas"]["PhotoTransformer"] | null; + Thumbnails?: components['schemas']['ThumbnailTransformer'][]; + Photo?: components['schemas']['PhotoTransformer'] | null; }; } | null; FolderProperties: { @@ -4730,9 +4734,9 @@ export interface components { /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ Tags?: number[]; } | null; - } & components["schemas"]["LinkTransformer"]; + } & components['schemas']['LinkTransformer']; GetRevisionResponseDto2: { - Revision: components["schemas"]["DetailedRevisionResponseDto2"]; + Revision: components['schemas']['DetailedRevisionResponseDto2']; /** * ProtonResponseCode * @example 1000 @@ -4742,50 +4746,50 @@ export interface components { }; /** @description Conflict, a share already exists for the file or folder. */ ShareConflictErrorResponseDto: { - Details: components["schemas"]["ShareConflictErrorDetailsDto"]; + Details: components['schemas']['ShareConflictErrorDetailsDto']; Error: string; Code: number; }; SmallFileUploadMetadataRequestDto: { - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; NameHash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + ParentLinkID: components['schemas']['Id']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Address used to sign passphrase, name, manifest, block, and xAttr. Is null for anonymous users. */ SignatureEmail?: string | null; - NodeKey: components["schemas"]["PGPPrivateKey"]; + NodeKey: components['schemas']['PGPPrivateKey']; /** @example text/plain */ MIMEType: string; - ContentKeyPacket: components["schemas"]["BinaryString"]; + ContentKeyPacket: components['schemas']['BinaryString']; /** @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. */ ContentKeyPacketSignature?: string | null; - ManifestSignature: components["schemas"]["PGPSignature"]; + ManifestSignature: components['schemas']['PGPSignature']; /** @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. */ ContentBlockEncSignature?: string | null; - ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; + ContentBlockVerificationToken?: components['schemas']['BinaryString'] | null; /** * @description Extended attributes encrypted with link key * @default null */ XAttr: string | null; /** @default null */ - Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; + Photo: components['schemas']['CommitRevisionPhotoDto'] | null; }; SmallRevisionUploadMetadataRequestDto: { - CurrentRevisionID: components["schemas"]["Id"]; + CurrentRevisionID: components['schemas']['Id']; /** * Format: email * @description Address used to sign manifest, block, and xAttr. Is null for anonymous users. */ SignatureEmail?: string | null; - ManifestSignature: components["schemas"]["PGPSignature"]; + ManifestSignature: components['schemas']['PGPSignature']; /** @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. */ - ContentBlockEncSignature?: components["schemas"]["PGPMessage"] | null; - ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; + ContentBlockEncSignature?: components['schemas']['PGPMessage'] | null; + ContentBlockVerificationToken?: components['schemas']['BinaryString'] | null; /** * @description File extended attributes encrypted with link key * @default null @@ -4794,8 +4798,8 @@ export interface components { }; ShareURLResponseDto: { Token: string; - ShareURLID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; + ShareURLID: components['schemas']['Id']; + ShareID: components['schemas']['Id']; /** @description URL to use to access the ShareURL */ PublicUrl: string; ExpirationTime?: number | null; @@ -4803,7 +4807,7 @@ export interface components { CreateTime: number; MaxAccesses: number; NumAccesses: number; - Name?: components["schemas"]["PGPMessage"] | null; + Name?: components['schemas']['PGPMessage'] | null; CreatorEmail: string; /** * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: @@ -4817,15 +4821,15 @@ export interface components { * - `1`: FLAG_CUSTOM_PASSWORD, * - `2`: FLAG_RANDOM_PASSWORD */ Flags: number; - UrlPasswordSalt: components["schemas"]["BinaryString"]; - SharePasswordSalt: components["schemas"]["BinaryString"]; - SRPVerifier: components["schemas"]["BinaryString"]; - SRPModulusID: components["schemas"]["Id"]; - Password: components["schemas"]["PGPMessage"]; - SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; + UrlPasswordSalt: components['schemas']['BinaryString']; + SharePasswordSalt: components['schemas']['BinaryString']; + SRPVerifier: components['schemas']['BinaryString']; + SRPModulusID: components['schemas']['Id']; + Password: components['schemas']['PGPMessage']; + SharePassphraseKeyPacket: components['schemas']['BinaryString']; }; AlbumPhotoLinkDataDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components['schemas']['Id']; /** @description Name Hash */ Hash: string; Name: string; @@ -4834,11 +4838,11 @@ export interface components { * @description Email address used for signing name */ NameSignatureEmail: string; - NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphrase: components['schemas']['PGPMessage']; /** @description Photo content hash */ ContentHash: string; /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ - NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature?: components['schemas']['PGPSignature'] | null; /** * Format: email * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature @@ -4846,52 +4850,52 @@ export interface components { SignatureEmail?: string | null; }; AlbumLinkDto: { - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; /** @description Album name Hash */ Hash: string; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** * Format: email * @description Signature email address used to sign passphrase and name */ SignatureEmail: string; - NodeKey: components["schemas"]["PGPPrivateKey"]; + NodeKey: components['schemas']['PGPPrivateKey']; /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; /** @description Extended attributes encrypted with link key */ XAttr?: string | null; }; AlbumShortResponseDto: { - Link: components["schemas"]["AlbumLinkResponseDto"]; + Link: components['schemas']['AlbumLinkResponseDto']; }; ShareDataDto: { - AddressID: components["schemas"]["Id"]; - Key: components["schemas"]["PGPPrivateKey"]; - Passphrase: components["schemas"]["PGPMessage"]; - PassphraseSignature: components["schemas"]["PGPSignature"]; + AddressID: components['schemas']['Id']; + Key: components['schemas']['PGPPrivateKey']; + Passphrase: components['schemas']['PGPMessage']; + PassphraseSignature: components['schemas']['PGPSignature']; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ AddressKeyID: string; }; LinkDataDto: { /** @description Root folder name */ Name: string; - NodeKey: components["schemas"]["PGPPrivateKey"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - NodeHashKey: components["schemas"]["PGPMessage"]; + NodeKey: components['schemas']['PGPPrivateKey']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; + NodeHashKey: components['schemas']['PGPMessage']; }; PhotoVolumeResponseDto: { - VolumeID: components["schemas"]["Id2"]; + VolumeID: components['schemas']['Id2']; CreateTime: number; ModifyTime: number; /** @description Used space in bytes */ UsedSpace: number; DownloadedBytes: number; UploadedBytes: number; - State: components["schemas"]["VolumeState"]; - Share: components["schemas"]["ShareReferenceResponseDto"]; - Type: components["schemas"]["VolumeType2"]; + State: components['schemas']['VolumeState']; + Share: components['schemas']['ShareReferenceResponseDto']; + Type: components['schemas']['VolumeType2']; }; FoundDuplicate: { /** @description NameHash of the found duplicate */ @@ -4911,7 +4915,7 @@ export interface components { RevisionID: string; }; PhotoTagMigrationDataDto: { - LastProcessedLinkID: components["schemas"]["Id2"]; + LastProcessedLinkID: components['schemas']['Id2']; LastProcessedCaptureTime: number; LastMigrationTimestamp: number; /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. @@ -4922,19 +4926,19 @@ export interface components { Locked: boolean; LastActivityTime: number; PhotoCount: number; - LinkID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; + VolumeID: components['schemas']['Id2']; /** @default null */ - ShareID: components["schemas"]["Id2"] | null; + ShareID: components['schemas']['Id2'] | null; /** @default null */ - CoverLinkID: components["schemas"]["Id2"] | null; + CoverLinkID: components['schemas']['Id2'] | null; }; ListPhotosAlbumItemResponseDto: { - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; CaptureTime: number; Hash: string; ContentHash: string; - RelatedPhotos: components["schemas"]["ListPhotosAlbumRelatedPhotoItemResponseDto"][]; + RelatedPhotos: components['schemas']['ListPhotosAlbumRelatedPhotoItemResponseDto'][]; AddedTime: number; IsChildOfAlbum: boolean; /** @@ -4944,7 +4948,7 @@ export interface components { Tags: number[]; }; TransferPhotoLinkInBatchRequestDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components['schemas']['Id']; /** @description Name, reusing same session key as previously. */ Name: string; /** @description Node passphrase, reusing same session key as previously. */ @@ -4962,17 +4966,17 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature: components['schemas']['PGPSignature'] | null; }; PhotoTagMigrationUpdateDto: { - LastProcessedLinkID: components["schemas"]["Id"]; + LastProcessedLinkID: components['schemas']['Id']; LastProcessedCaptureTime: number; CurrentTimestamp: number; /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. */ ClientUID: string; }; AlbumLinkUpdateDto: { - Name?: components["schemas"]["PGPMessage"] | null; + Name?: components['schemas']['PGPMessage'] | null; Hash?: string | null; /** * Format: email @@ -4984,38 +4988,38 @@ export interface components { XAttr?: string | null; }; BookmarkShareURLRequestDto: { - EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; - AddressID: components["schemas"]["Id"]; - AddressKeyID: components["schemas"]["Id"]; + EncryptedUrlPassword?: components['schemas']['PGPMessage'] | null; + AddressID: components['schemas']['Id']; + AddressKeyID: components['schemas']['Id']; }; BookmarkShareURLResponseDto: { - UserID: components["schemas"]["Id2"]; + UserID: components['schemas']['Id2']; Token: string; - ShareURLID: components["schemas"]["Id2"]; - EncryptedUrlPassword?: components["schemas"]["PGPMessage2"] | null; - State: components["schemas"]["BookmarkShareURLState"]; + ShareURLID: components['schemas']['Id2']; + EncryptedUrlPassword?: components['schemas']['PGPMessage2'] | null; + State: components['schemas']['BookmarkShareURLState']; CreateTime: number; ModifyTime: number; }; BookmarkShareURLInfoResponseDto: { - EncryptedUrlPassword?: components["schemas"]["PGPMessage2"] | null; + EncryptedUrlPassword?: components['schemas']['PGPMessage2'] | null; CreateTime: number; - Token: components["schemas"]["TokenResponseDto"]; + Token: components['schemas']['TokenResponseDto']; }; DeviceDataDto: { - SyncState: components["schemas"]["DeviceSyncState"]; - Type: components["schemas"]["DeviceType"]; + SyncState: components['schemas']['DeviceSyncState']; + Type: components['schemas']['DeviceType']; /** * @deprecated * @default null */ - VolumeID: components["schemas"]["Id"] | null; + VolumeID: components['schemas']['Id'] | null; }; ShareDataDto2: { - AddressID: components["schemas"]["Id"]; - Key: components["schemas"]["PGPPrivateKey"]; - Passphrase: components["schemas"]["PGPMessage"]; - PassphraseSignature: components["schemas"]["PGPSignature"]; + AddressID: components['schemas']['Id']; + Key: components['schemas']['PGPPrivateKey']; + Passphrase: components['schemas']['PGPMessage']; + PassphraseSignature: components['schemas']['PGPSignature']; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ AddressKeyID: string; /** @@ -5025,22 +5029,22 @@ export interface components { Name: string | null; }; DeviceResponseDto: { - DeviceID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; + DeviceID: components['schemas']['Id2']; + ShareID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; }; DeviceResponseDto2: { - Device: components["schemas"]["DeviceDataDto3"]; - Share: components["schemas"]["ShareDataDto4"]; + Device: components['schemas']['DeviceDataDto3']; + Share: components['schemas']['ShareDataDto4']; }; DeviceResponseDto3: { - Device: components["schemas"]["DeviceDto"]; - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; + Device: components['schemas']['DeviceDto']; + ShareID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; }; DeviceDataDto2: { /** @default null */ - SyncState: components["schemas"]["DeviceSyncState"] | null; + SyncState: components['schemas']['DeviceSyncState'] | null; /** * @description UNIX timestamp when the Device got last synced. Optional * @default null @@ -5068,20 +5072,22 @@ export interface components { /** @description An armored PGP Private Key */ PGPPrivateKey: string; DocumentDetailsDto: { - VolumeID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - RevisionID: components["schemas"]["Id2"]; + VolumeID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; + RevisionID: components['schemas']['Id2']; }; /** @description An encrypted ID */ Id2: string; EventResponseDto: { - EventID: components["schemas"]["Id2"]; - EventType: components["schemas"]["EventType"]; + EventID: components['schemas']['Id2']; + EventType: components['schemas']['EventType']; /** @description Event creation timestamp */ CreateTime: number; - Link: { - LinkID: components["schemas"]["Id"]; - } | components["schemas"]["ExtendedLinkTransformer2"]; + Link: + | { + LinkID: components['schemas']['Id']; + } + | components['schemas']['ExtendedLinkTransformer2']; /** * @description The share the user has access to that is closest to the root. Delete events do not have it but other events do. * @default null @@ -5118,70 +5124,70 @@ export interface components { } | null; }; EventV2ResponseDto: { - EventID: components["schemas"]["Id2"]; - EventType: components["schemas"]["EventType"]; - Link: components["schemas"]["EventLinkDataDto"]; + EventID: components['schemas']['Id2']; + EventType: components['schemas']['EventType']; + Link: components['schemas']['EventLinkDataDto']; }; FolderResponseDto: { - ID: components["schemas"]["Id2"]; + ID: components['schemas']['Id2']; }; /** @description An encrypted ID */ EncryptedId: string; PendingHashResponseDto: { Hash: string; - RevisionID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; + RevisionID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; ClientUID?: string | null; }; PhotosDto: { /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ ContentHash: string; /** @default [] */ - RelatedPhotos: components["schemas"]["RelatedPhotoDto"][]; + RelatedPhotos: components['schemas']['RelatedPhotoDto'][]; }; ListMissingHashKeyItemDto: { - LinkID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; + VolumeID: components['schemas']['Id2']; + ShareID: components['schemas']['Id2']; }; FileDetailsDto: { - Link: components["schemas"]["LinkDto"]; - File: components["schemas"]["FileDto"]; + Link: components['schemas']['LinkDto']; + File: components['schemas']['FileDto']; /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; + Sharing: components['schemas']['SharingDto'] | null; /** @default null */ - Membership: components["schemas"]["MembershipDto"] | null; + Membership: components['schemas']['MembershipDto'] | null; /** @default null */ Folder: null | null; /** @default null */ Album: null | null; }; FolderDetailsDto: { - Link: components["schemas"]["LinkDto"]; - Folder: components["schemas"]["FolderDto"]; + Link: components['schemas']['LinkDto']; + Folder: components['schemas']['FolderDto']; /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; + Sharing: components['schemas']['SharingDto'] | null; /** @default null */ - Membership: components["schemas"]["MembershipDto"] | null; + Membership: components['schemas']['MembershipDto'] | null; /** @default null */ File: null | null; /** @default null */ Album: null | null; }; AlbumDetailsDto: { - Link: components["schemas"]["LinkDto"]; - Album: components["schemas"]["AlbumDto"]; + Link: components['schemas']['LinkDto']; + Album: components['schemas']['AlbumDto']; /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; + Sharing: components['schemas']['SharingDto'] | null; /** @default null */ - Membership: components["schemas"]["MembershipDto"] | null; + Membership: components['schemas']['MembershipDto'] | null; /** @default null */ File: null | null; /** @default null */ Folder: null | null; }; MoveLinkInBatchRequestDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components['schemas']['Id']; /** @description Name, reusing same session key as previously. */ Name: string; /** @description Node passphrase, reusing same session key as previously. */ @@ -5202,12 +5208,12 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature: components['schemas']['PGPSignature'] | null; }; UpdateMissingHashKeyItemDto: { - LinkID: components["schemas"]["Id"]; - VolumeID: components["schemas"]["Id"]; - PGPArmoredEncryptedNodeHashKey: components["schemas"]["PGPMessage"]; + LinkID: components['schemas']['Id']; + VolumeID: components['schemas']['Id']; + PGPArmoredEncryptedNodeHashKey: components['schemas']['PGPMessage']; }; CommitRevisionPhotoDto: { /** @description Photo capture timestamp */ @@ -5224,24 +5230,24 @@ export interface components { * @description Deprecated: Clients persist exif information in xAttr instead * @default null */ - Exif: components["schemas"]["BinaryString"] | null; + Exif: components['schemas']['BinaryString'] | null; /** * @description List of tags to be assigned to the photo * @default null */ - Tags: components["schemas"]["TagType"][] | null; + Tags: components['schemas']['TagType'][] | null; }; BlockTokenDto: { Index: number; Token: string; }; RevisionResponseDto: { - ID: components["schemas"]["Id2"]; - ManifestSignature?: components["schemas"]["PGPSignature2"] | null; + ID: components['schemas']['Id2']; + ManifestSignature?: components['schemas']['PGPSignature2'] | null; /** @description Size of revision (in bytes) */ Size: number; - State: components["schemas"]["RevisionState"]; - XAttr?: components["schemas"]["PGPMessage2"] | null; + State: components['schemas']['RevisionState']; + XAttr?: components['schemas']['PGPMessage2'] | null; /** * @deprecated * @description Flag stating if revision has a thumbnail @@ -5249,13 +5255,13 @@ export interface components { */ Thumbnail: 0 | 1; /** @deprecated */ - ThumbnailHash?: components["schemas"]["BinaryString2"] | null; + ThumbnailHash?: components['schemas']['BinaryString2'] | null; /** * @deprecated * @description Size thumbnail in bytes; 0 if no thumbnail present */ ThumbnailSize: number; - Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; + Thumbnails: components['schemas']['ThumbnailResponseDto'][]; ClientUID?: string | null; /** @default null */ CreateTime: number | null; @@ -5276,11 +5282,11 @@ export interface components { /** @description Base64 encoded binary data */ BinaryString2: string; ShareTrashList: { - ShareID: components["schemas"]["Id2"]; + ShareID: components['schemas']['Id2']; /** @description List of trashed link IDs for that share */ - LinkIDs: components["schemas"]["Id2"][]; + LinkIDs: components['schemas']['Id2'][]; /** @description List of trashed link's parentLinkIDs */ - ParentIDs: components["schemas"]["Id2"][]; + ParentIDs: components['schemas']['Id2'][]; }; RequestUploadBlockInput: { /** @description Block size in bytes */ @@ -5292,12 +5298,12 @@ export interface components { /** @description Hash of encrypted block, base64 encoded */ Hash: string; /** @default null */ - Verifier: components["schemas"]["Verifier"] | null; + Verifier: components['schemas']['Verifier'] | null; }; RequestUploadThumbnailInput: { /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ Size: number; - Type: components["schemas"]["ThumbnailType"]; + Type: components['schemas']['ThumbnailType']; /** @description Hash of encrypted block, base64 encoded */ Hash: string; }; @@ -5313,7 +5319,7 @@ export interface components { Token: string; /** @deprecated */ URL: string; - ThumbnailType: components["schemas"]["ThumbnailType2"]; + ThumbnailType: components['schemas']['ThumbnailType2']; }; EntitlementsDto: { /** @description Maximum number of days revision history can be kept */ @@ -5332,24 +5338,24 @@ export interface components { * @description Email address used for signing name */ NameSignatureEmail: string; - NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphrase: components['schemas']['PGPMessage']; /** @description Photo content hash */ ContentHash: string; /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ - NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature?: components['schemas']['PGPSignature'] | null; /** * Format: email * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature */ SignatureEmail?: string | null; /** @default [] */ - RelatedPhotos: components["schemas"]["AlbumPhotoLinkDataDto"][]; + RelatedPhotos: components['schemas']['AlbumPhotoLinkDataDto'][]; }; FavoriteRelatedPhotoResponseDto: { - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; }; PhotoListingItemResponse: { - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; /** @description File name hash */ @@ -5362,15 +5368,15 @@ export interface components { */ Tags: number[]; /** @default [] */ - RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; + RelatedPhotos: components['schemas']['PhotoListingRelatedItemResponse'][]; }; FileResponseDto: { - ID: components["schemas"]["Id2"]; - RevisionID: components["schemas"]["Id2"]; + ID: components['schemas']['Id2']; + RevisionID: components['schemas']['Id2']; ClientUID?: string | null; }; LinkWithAuthorizationTokenDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components['schemas']['Id']; /** @default null */ AuthorizationToken: string | null; }; @@ -5383,56 +5389,56 @@ export interface components { EncSignature: string; /** @description Hash of encrypted block, base64 encoded */ Hash: string; - Verifier: components["schemas"]["Verifier"]; + Verifier: components['schemas']['Verifier']; }; ShareURLContext: { /** @description Share ID of the share highest in the tree with permissions */ ContextShareID: string; - ShareURLs: components["schemas"]["ShareURLResponseDto2"][]; + ShareURLs: components['schemas']['ShareURLResponseDto2'][]; /** @description Related link IDs and ancestors up to the share. */ - LinkIDs: components["schemas"]["Id2"][]; + LinkIDs: components['schemas']['Id2'][]; }; LinkMapItemResponse: { Index: number; - LinkID: components["schemas"]["Id2"]; - ParentLinkID?: components["schemas"]["Id2"] | null; - Type: components["schemas"]["NodeType2"]; - Name: components["schemas"]["PGPMessage2"]; + LinkID: components['schemas']['Id2']; + ParentLinkID?: components['schemas']['Id2'] | null; + Type: components['schemas']['NodeType2']; + Name: components['schemas']['PGPMessage2']; Hash?: string | null; - State: components["schemas"]["LinkState2"]; + State: components['schemas']['LinkState2']; Size: number; MIMEType: string; CreateTime: number; ModifyTime: number; /** @default null */ - NodeKey: components["schemas"]["PGPPrivateKey2"]; + NodeKey: components['schemas']['PGPPrivateKey2']; /** @default null */ - NodePassphrase: components["schemas"]["PGPMessage2"]; + NodePassphrase: components['schemas']['PGPMessage2']; /** @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature2"]; + NodePassphraseSignature: components['schemas']['PGPSignature2']; /** @default null */ NodeSignatureEmail: string; }; VolumeDto: { - VolumeID: components["schemas"]["Id2"]; + VolumeID: components['schemas']['Id2']; UsedSpace: number; }; ShareDto: { - ShareID: components["schemas"]["Id2"]; + ShareID: components['schemas']['Id2']; /** Format: email */ CreatorEmail: string; - Key: components["schemas"]["PGPPrivateKey2"]; - Passphrase: components["schemas"]["PGPMessage2"]; - PassphraseSignature: components["schemas"]["PGPSignature2"]; - AddressID: components["schemas"]["Id2"]; + Key: components['schemas']['PGPPrivateKey2']; + Passphrase: components['schemas']['PGPMessage2']; + PassphraseSignature: components['schemas']['PGPSignature2']; + AddressID: components['schemas']['Id2']; }; FolderDetailsDto2: { - Link: components["schemas"]["LinkDto2"]; - Folder: components["schemas"]["FolderDto2"]; + Link: components['schemas']['LinkDto2']; + Folder: components['schemas']['FolderDto2']; /** @default null */ - Sharing: components["schemas"]["SharingDto2"] | null; + Sharing: components['schemas']['SharingDto2'] | null; /** @default null */ - Membership: components["schemas"]["MembershipDto2"] | null; + Membership: components['schemas']['MembershipDto2'] | null; /** @default null */ File: null | null; /** @default null */ @@ -5465,10 +5471,10 @@ export interface components { /** @description An armored PGP Signature */ PGPSignature2: string; MemberResponseDto: { - MemberID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - AddressID: components["schemas"]["Id2"]; - AddressKeyID: components["schemas"]["Id2"]; + MemberID: components['schemas']['Id2']; + ShareID: components['schemas']['Id2']; + AddressID: components['schemas']['Id2']; + AddressKeyID: components['schemas']['Id2']; /** Format: email */ Inviter: string; /** @@ -5486,7 +5492,7 @@ export interface components { KeyPacketSignature: string; /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ SessionKeySignature: string; - State: components["schemas"]["ShareMemberState"]; + State: components['schemas']['ShareMemberState']; CreateTime: number; ModifyTime: number; /** @deprecated */ @@ -5499,10 +5505,10 @@ export interface components { Unlockable: boolean | null; }; KeyPacketResponseDto: { - AddressID: components["schemas"]["Id2"]; - AddressKeyID: components["schemas"]["Id2"]; - KeyPacket: components["schemas"]["BinaryString2"]; - State: components["schemas"]["ShareMemberState"]; + AddressID: components['schemas']['Id2']; + AddressKeyID: components['schemas']['Id2']; + KeyPacket: components['schemas']['BinaryString2']; + State: components['schemas']['ShareMemberState']; /** * @deprecated * @description Deprecated and always null @@ -5511,17 +5517,17 @@ export interface components { Unlockable: boolean | null; }; ShareResponseDto: { - ShareID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - Type: components["schemas"]["ShareType"]; - State: components["schemas"]["ShareState"]; - VolumeType: components["schemas"]["VolumeType"]; + ShareID: components['schemas']['Id2']; + VolumeID: components['schemas']['Id2']; + Type: components['schemas']['ShareType']; + State: components['schemas']['ShareState']; + VolumeType: components['schemas']['VolumeType']; /** Format: email */ Creator: string; Locked?: boolean | null; CreateTime: number; ModifyTime: number; - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; /** * @deprecated * @description Deprecated: Use `CreateTime` @@ -5539,7 +5545,7 @@ export interface components { VolumeSoftDeleted: boolean; }; FailedRestoreProcedureShareDataDto: { - ShareID: components["schemas"]["Id"]; + ShareID: components['schemas']['Id']; Reason: string; }; ShareKPMigrationData: { @@ -5550,7 +5556,7 @@ export interface components { }; /** @description Share unable to be migrated with reason and code; NOT_EXISTS, INCOMPATIBLE_STATE, PERMISSION_DENIED, ENCRYPTION_VERIFICATION_FAILED */ ShareKPMigrationError: { - ShareID: components["schemas"]["Id2"]; + ShareID: components['schemas']['Id2']; Error: string; Code: number; }; @@ -5565,16 +5571,16 @@ export interface components { * @example YTZZRH7DA8 */ Token: string; - LinkType: components["schemas"]["NodeType3"]; - LinkID: components["schemas"]["Id2"]; - SharePasswordSalt: components["schemas"]["BinaryString2"]; - SharePassphrase: components["schemas"]["PGPMessage2"]; - ShareKey: components["schemas"]["PGPPrivateKey2"]; - NodePassphrase: components["schemas"]["PGPMessage2"]; - NodeKey: components["schemas"]["PGPPrivateKey2"]; - Name: components["schemas"]["PGPMessage2"]; + LinkType: components['schemas']['NodeType3']; + LinkID: components['schemas']['Id2']; + SharePasswordSalt: components['schemas']['BinaryString2']; + SharePassphrase: components['schemas']['PGPMessage2']; + ShareKey: components['schemas']['PGPPrivateKey2']; + NodePassphrase: components['schemas']['PGPMessage2']; + NodeKey: components['schemas']['PGPPrivateKey2']; + Name: components['schemas']['PGPMessage2']; /** @description Base64 encoded content key packet. Null for folders */ - ContentKeyPacket?: components["schemas"]["BinaryString2"] | null; + ContentKeyPacket?: components['schemas']['BinaryString2'] | null; /** @example text/plain */ MIMEType: string; /** @@ -5588,9 +5594,9 @@ export interface components { /** @description File size, null for folders */ Size?: number | null; /** @description File properties */ - ThumbnailURLInfo?: components["schemas"]["ThumbnailURLInfoResponseDto"] | null; + ThumbnailURLInfo?: components['schemas']['ThumbnailURLInfoResponseDto'] | null; /** @default null */ - NodeHashKey: components["schemas"]["PGPMessage2"] | null; + NodeHashKey: components['schemas']['PGPMessage2'] | null; /** * @description Signature email of the share owner. Only set for a ShareURL with read+write permissions. * @default null @@ -5600,17 +5606,17 @@ export interface components { * @description Only set for a ShareURL with read+write permissions. * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature2"] | null; + NodePassphraseSignature: components['schemas']['PGPSignature2'] | null; }; DetailedRevisionResponseDto: { - Blocks: components["schemas"]["BlockResponseDto"][]; - Photo?: components["schemas"]["PhotoResponseDto"] | null; - ID: components["schemas"]["Id2"]; - ManifestSignature?: components["schemas"]["PGPSignature2"] | null; + Blocks: components['schemas']['BlockResponseDto'][]; + Photo?: components['schemas']['PhotoResponseDto'] | null; + ID: components['schemas']['Id2']; + ManifestSignature?: components['schemas']['PGPSignature2'] | null; /** @description Size of revision (in bytes) */ Size: number; - State: components["schemas"]["RevisionState"]; - XAttr?: components["schemas"]["PGPMessage2"] | null; + State: components['schemas']['RevisionState']; + XAttr?: components['schemas']['PGPMessage2'] | null; /** * @deprecated * @description Flag stating if revision has a thumbnail @@ -5618,13 +5624,13 @@ export interface components { */ Thumbnail: 0 | 1; /** @deprecated */ - ThumbnailHash?: components["schemas"]["BinaryString2"] | null; + ThumbnailHash?: components['schemas']['BinaryString2'] | null; /** * @deprecated * @description Size thumbnail in bytes; 0 if no thumbnail present */ ThumbnailSize: number; - Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; + Thumbnails: components['schemas']['ThumbnailResponseDto'][]; ClientUID?: string | null; /** @default null */ CreateTime: number | null; @@ -5643,19 +5649,19 @@ export interface components { SignatureAddress: string | null; }; GetSharedFileInfoPayloadDto: { - SharePasswordSalt: components["schemas"]["BinaryString2"]; - SharePassphrase: components["schemas"]["PGPMessage2"]; - ShareKey: components["schemas"]["PGPPrivateKey2"]; - NodePassphrase: components["schemas"]["PGPMessage2"]; - NodeKey: components["schemas"]["PGPPrivateKey2"]; - Name: components["schemas"]["PGPMessage2"]; + SharePasswordSalt: components['schemas']['BinaryString2']; + SharePassphrase: components['schemas']['PGPMessage2']; + ShareKey: components['schemas']['PGPPrivateKey2']; + NodePassphrase: components['schemas']['PGPMessage2']; + NodeKey: components['schemas']['PGPPrivateKey2']; + Name: components['schemas']['PGPMessage2']; Size: number; MIMEType: string; /** @description UNIX timestamp after which this link is no longer accessible */ ExpirationTime?: number | null; - ContentKeyPacket: components["schemas"]["BinaryString2"]; - BlockURLs: components["schemas"]["ThumbnailURLInfoResponseDto"][]; - ThumbnailURLInfo: components["schemas"]["ThumbnailURLInfoResponseDto"]; + ContentKeyPacket: components['schemas']['BinaryString2']; + BlockURLs: components['schemas']['ThumbnailURLInfoResponseDto'][]; + ThumbnailURLInfo: components['schemas']['ThumbnailURLInfoResponseDto']; /** @deprecated */ Blocks: string[]; /** @deprecated */ @@ -5663,8 +5669,8 @@ export interface components { }; ShareURLResponseDto2: { Token: string; - ShareURLID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; + ShareURLID: components['schemas']['Id2']; + ShareID: components['schemas']['Id2']; /** @description URL to use to access the ShareURL */ PublicUrl: string; ExpirationTime?: number | null; @@ -5672,7 +5678,7 @@ export interface components { CreateTime: number; MaxAccesses: number; NumAccesses: number; - Name?: components["schemas"]["PGPMessage2"] | null; + Name?: components['schemas']['PGPMessage2'] | null; CreatorEmail: string; /** * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: @@ -5686,12 +5692,12 @@ export interface components { * - `1`: FLAG_CUSTOM_PASSWORD, * - `2`: FLAG_RANDOM_PASSWORD */ Flags: number; - UrlPasswordSalt: components["schemas"]["BinaryString2"]; - SharePasswordSalt: components["schemas"]["BinaryString2"]; - SRPVerifier: components["schemas"]["BinaryString2"]; - SRPModulusID: components["schemas"]["Id2"]; - Password: components["schemas"]["PGPMessage2"]; - SharePassphraseKeyPacket: components["schemas"]["BinaryString2"]; + UrlPasswordSalt: components['schemas']['BinaryString2']; + SharePasswordSalt: components['schemas']['BinaryString2']; + SRPVerifier: components['schemas']['BinaryString2']; + SRPModulusID: components['schemas']['Id2']; + Password: components['schemas']['PGPMessage2']; + SharePassphraseKeyPacket: components['schemas']['BinaryString2']; }; /** Link */ ExtendedLinkTransformer2: { @@ -5841,8 +5847,8 @@ export interface components { */ Token?: string; }; - Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; - Photo?: components["schemas"]["PhotoTransformer"] | null; + Thumbnails?: components['schemas']['ThumbnailTransformer'][]; + Photo?: components['schemas']['PhotoTransformer'] | null; }; } | null; FolderProperties: { @@ -5883,20 +5889,20 @@ export interface components { /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ Tags?: number[]; } | null; - } & components["schemas"]["LinkTransformer"]; + } & components['schemas']['LinkTransformer']; LinkSharedByMeResponseDto: { - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - ContextShareID: components["schemas"]["Id2"]; + ShareID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; + ContextShareID: components['schemas']['Id2']; }; LinkSharedWithMeResponseDto: { - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - ShareTargetType: components["schemas"]["TargetType2"]; + VolumeID: components['schemas']['Id2']; + ShareID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; + ShareTargetType: components['schemas']['TargetType2']; }; ExternalInvitationRequestDto: { - InviterAddressID: components["schemas"]["Id"]; + InviterAddressID: components['schemas']['Id']; /** Format: email */ InviteeEmail: string; /** @@ -5916,7 +5922,7 @@ export interface components { ItemName?: string | null; }; ExternalInvitationResponseDto: { - ExternalInvitationID: components["schemas"]["Id2"]; + ExternalInvitationID: components['schemas']['Id2']; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -5932,13 +5938,13 @@ export interface components { Permissions: 4 | 6 | 22; /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ ExternalInvitationSignature: string; - State: components["schemas"]["ExternalInvitationState"]; + State: components['schemas']['ExternalInvitationState']; CreateTime: number; }; UserRegisteredExternalInvitationItemDto: { - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - ExternalInvitationID: components["schemas"]["Id2"]; + VolumeID: components['schemas']['Id2']; + ShareID: components['schemas']['Id2']; + ExternalInvitationID: components['schemas']['Id2']; }; InvitationRequestDto: { /** Format: email */ @@ -5959,10 +5965,10 @@ export interface components { /** @description Signature of the above member key packet with the private key of the user (inviter) and the signature context `drive.share-member.inviter`, base64 encoded */ KeyPacketSignature: string; /** @default null */ - ExternalInvitationID: components["schemas"]["Id"] | null; + ExternalInvitationID: components['schemas']['Id'] | null; }; InvitationResponseDto: { - InvitationID: components["schemas"]["Id2"]; + InvitationID: components['schemas']['Id2']; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -5983,32 +5989,32 @@ export interface components { CreateTime: number; }; PendingInvitationItemDto: { - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - InvitationID: components["schemas"]["Id2"]; - ShareTargetType: components["schemas"]["TargetType2"]; + VolumeID: components['schemas']['Id2']; + ShareID: components['schemas']['Id2']; + InvitationID: components['schemas']['Id2']; + ShareTargetType: components['schemas']['TargetType2']; }; ShareResponseDto2: { - ShareID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - Passphrase: components["schemas"]["PGPMessage2"]; - ShareKey: components["schemas"]["PGPPrivateKey2"]; + ShareID: components['schemas']['Id2']; + VolumeID: components['schemas']['Id2']; + Passphrase: components['schemas']['PGPMessage2']; + ShareKey: components['schemas']['PGPPrivateKey2']; /** Format: email */ CreatorEmail: string; }; LinkResponseDto: { - Type: components["schemas"]["NodeType2"]; - LinkID: components["schemas"]["Id2"]; - Name: components["schemas"]["PGPMessage2"]; + Type: components['schemas']['NodeType2']; + LinkID: components['schemas']['Id2']; + Name: components['schemas']['PGPMessage2']; MIMEType?: string | null; }; ContextShareDto: { - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; + VolumeID: components['schemas']['Id2']; + ShareID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; }; MemberResponseDto2: { - MemberID: components["schemas"]["Id2"]; + MemberID: components['schemas']['Id2']; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -6044,20 +6050,20 @@ export interface components { Error: string; }; ThumbnailResponse: { - ThumbnailID: components["schemas"]["Id2"]; + ThumbnailID: components['schemas']['Id2']; BareURL: string; Token: string; }; ThumbnailErrorResponse: { - ThumbnailID: components["schemas"]["Id2"]; + ThumbnailID: components['schemas']['Id2']; Error: string; Code: number; }; UserSettings: { - Layout?: components["schemas"]["LayoutSetting2"] | null; - Sort?: components["schemas"]["SortSetting2"] | null; + Layout?: components['schemas']['LayoutSetting2'] | null; + Sort?: components['schemas']['SortSetting2'] | null; /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ - RevisionRetentionDays?: components["schemas"]["RevisionRetentionDays2"] | null; + RevisionRetentionDays?: components['schemas']['RevisionRetentionDays2'] | null; /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ B2BPhotosEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ @@ -6070,7 +6076,7 @@ export interface components { PhotoTags?: number[] | null; }; Defaults: { - RevisionRetentionDays: components["schemas"]["RevisionRetentionDays3"]; + RevisionRetentionDays: components['schemas']['RevisionRetentionDays3']; /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ B2BPhotosEnabled: boolean; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ @@ -6096,7 +6102,7 @@ export interface components { */ RevisionRetentionDays: 0 | 7 | 30 | 180 | 365 | 3650; VolumeResponseDto: { - ID: components["schemas"]["Id2"]; + ID: components['schemas']['Id2']; /** * @deprecated * @description Deprecated, use `CreateTime` instead @@ -6107,16 +6113,16 @@ export interface components { * @default null */ MaxSpace: number | null; - VolumeID: components["schemas"]["Id2"]; + VolumeID: components['schemas']['Id2']; CreateTime: number; ModifyTime: number; /** @description Used space in bytes */ UsedSpace: number; DownloadedBytes: number; UploadedBytes: number; - State: components["schemas"]["VolumeState"]; - Share: components["schemas"]["ShareReferenceResponseDto"]; - Type: components["schemas"]["VolumeType2"]; + State: components['schemas']['VolumeState']; + Share: components['schemas']['ShareReferenceResponseDto']; + Type: components['schemas']['VolumeType2']; }; RestoreMainShareDto: { /** @description ShareID of the existing, locked main share */ @@ -6125,8 +6131,8 @@ export interface components { Name: string; /** @description Hash of the name */ Hash: string; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** * @description Node Hash Key should be provided if it needs to be signed because it was unsigned or signed with the address key (legacy). It should be signed with the new parent's node key. If it was properly signed with the parent node key, it should not be updated. Armored PGP message. * @default null @@ -6150,7 +6156,7 @@ export interface components { PassphraseSignature: string; }; ConflictErrorDetailsDto: { - ConflictLinkID: components["schemas"]["Id"]; + ConflictLinkID: components['schemas']['Id']; /** * @description A conflicting Revision in Active state. * @default null @@ -6292,14 +6298,14 @@ export interface components { Trashed: number | null; }; DetailedRevisionResponseDto2: { - Blocks: components["schemas"]["BlockResponseDto2"][]; - Photo?: components["schemas"]["PhotoResponseDto2"] | null; - ID: components["schemas"]["Id"]; - ManifestSignature?: components["schemas"]["PGPSignature"] | null; + Blocks: components['schemas']['BlockResponseDto2'][]; + Photo?: components['schemas']['PhotoResponseDto2'] | null; + ID: components['schemas']['Id']; + ManifestSignature?: components['schemas']['PGPSignature'] | null; /** @description Size of revision (in bytes) */ Size: number; - State: components["schemas"]["RevisionState2"]; - XAttr?: components["schemas"]["PGPMessage"] | null; + State: components['schemas']['RevisionState2']; + XAttr?: components['schemas']['PGPMessage'] | null; /** * @deprecated * @description Flag stating if revision has a thumbnail @@ -6307,13 +6313,13 @@ export interface components { */ Thumbnail: 0 | 1; /** @deprecated */ - ThumbnailHash?: components["schemas"]["BinaryString"] | null; + ThumbnailHash?: components['schemas']['BinaryString'] | null; /** * @deprecated * @description Size thumbnail in bytes; 0 if no thumbnail present */ ThumbnailSize: number; - Thumbnails: components["schemas"]["ThumbnailResponseDto2"][]; + Thumbnails: components['schemas']['ThumbnailResponseDto2'][]; ClientUID?: string | null; /** @default null */ CreateTime: number | null; @@ -6332,12 +6338,12 @@ export interface components { SignatureAddress: string | null; }; ShareConflictErrorDetailsDto: { - ConflictLinkID: components["schemas"]["Id"]; + ConflictLinkID: components['schemas']['Id']; /** @description A conflicting Share on the Link. */ ConflictShareID: string; }; AlbumLinkResponseDto: { - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
1Active
3Locked
@@ -6345,9 +6351,9 @@ export interface components { */ VolumeState: 1 | 3; ShareReferenceResponseDto: { - ShareID: components["schemas"]["Id2"]; - ID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; + ShareID: components['schemas']['Id2']; + ID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
@@ -6360,7 +6366,7 @@ export interface components { */ LinkState: 0 | 1 | 2; ListPhotosAlbumRelatedPhotoItemResponseDto: { - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; CaptureTime: number; Hash: string; ContentHash: string; @@ -6381,10 +6387,10 @@ export interface components { */ DeviceType: 1 | 2 | 3; DeviceDataDto3: { - DeviceID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - SyncState: components["schemas"]["DeviceSyncState2"]; - Type: components["schemas"]["DeviceType2"]; + DeviceID: components['schemas']['Id2']; + VolumeID: components['schemas']['Id2']; + SyncState: components['schemas']['DeviceSyncState2']; + Type: components['schemas']['DeviceType2']; /** @description UNIX timestamp when the Device got last synced */ LastSyncTime?: number | null; CreateTime: number; @@ -6396,16 +6402,16 @@ export interface components { CreationTime: number; }; ShareDataDto4: { - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; + ShareID: components['schemas']['Id2']; + LinkID: components['schemas']['Id2']; /** @deprecated */ Name: string; }; DeviceDto: { - DeviceID: components["schemas"]["Id2"]; + DeviceID: components['schemas']['Id2']; CreateTime: number; ModifyTime: number; - Type: components["schemas"]["DeviceType2"]; + Type: components['schemas']['DeviceType2']; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Delete
1Create
2Update
3UpdateMetadata
@@ -6413,13 +6419,13 @@ export interface components { */ EventType: 0 | 1 | 2 | 3; EventLinkDataDto: { - LinkID: components["schemas"]["Id2"]; - ParentLinkID?: components["schemas"]["Id2"] | null; + LinkID: components['schemas']['Id2']; + ParentLinkID?: components['schemas']['Id2'] | null; IsShared: boolean; IsTrashed: boolean; }; RelatedPhotoDto: { - LinkID: components["schemas"]["Id"]; + LinkID: components['schemas']['Id']; /** @description Name, reusing same session key as previously. */ Name: string; /** @description Node passphrase, reusing same session key as previously. */ @@ -6430,18 +6436,18 @@ export interface components { ContentHash: string; }; LinkDto: { - LinkID: components["schemas"]["Id"]; - Type: components["schemas"]["NodeType4"]; - ParentLinkID?: components["schemas"]["Id"] | null; - State: components["schemas"]["LinkState3"]; + LinkID: components['schemas']['Id']; + Type: components['schemas']['NodeType4']; + ParentLinkID?: components['schemas']['Id'] | null; + State: components['schemas']['LinkState3']; CreateTime: number; ModifyTime: number; TrashTime?: number | null; - Name: components["schemas"]["PGPMessage"]; + Name: components['schemas']['PGPMessage']; NameHash?: string | null; - NodeKey: components["schemas"]["PGPPrivateKey"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components['schemas']['PGPPrivateKey']; + NodePassphrase: components['schemas']['PGPMessage']; + NodePassphraseSignature: components['schemas']['PGPSignature']; /** Format: email */ SignatureEmail?: string | null; /** Format: email */ @@ -6449,18 +6455,18 @@ export interface components { }; FileDto: { TotalEncryptedSize: number; - ContentKeyPacket: components["schemas"]["BinaryString"]; + ContentKeyPacket: components['schemas']['BinaryString']; MediaType?: string | null; - ActiveRevision?: components["schemas"]["ActiveRevisionDto"] | null; - ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + ActiveRevision?: components['schemas']['ActiveRevisionDto'] | null; + ContentKeyPacketSignature?: components['schemas']['PGPSignature'] | null; }; SharingDto: { - ShareID: components["schemas"]["Id"]; - ShareURLID?: components["schemas"]["Id"] | null; + ShareID: components['schemas']['Id']; + ShareURLID?: components['schemas']['Id'] | null; }; MembershipDto: { - ShareID: components["schemas"]["Id"]; - MembershipID: components["schemas"]["Id"]; + ShareID: components['schemas']['Id']; + MembershipID: components['schemas']['Id']; /** * @description Permission bitfield, valid permissions: * - 4: read access @@ -6472,12 +6478,12 @@ export interface components { Permissions: 4 | 6 | 22; }; FolderDto: { - NodeHashKey?: components["schemas"]["PGPMessage"] | null; - XAttr?: components["schemas"]["PGPMessage"] | null; + NodeHashKey?: components['schemas']['PGPMessage'] | null; + XAttr?: components['schemas']['PGPMessage'] | null; }; AlbumDto: { - NodeHashKey?: components["schemas"]["PGPMessage"] | null; - XAttr?: components["schemas"]["PGPMessage"] | null; + NodeHashKey?: components['schemas']['PGPMessage'] | null; + XAttr?: components['schemas']['PGPMessage'] | null; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Obsolete
@@ -6485,13 +6491,13 @@ export interface components { */ RevisionState: 0 | 1 | 2; ThumbnailResponseDto: { - ThumbnailID: components["schemas"]["Id2"]; - Type: components["schemas"]["ThumbnailType2"]; - Hash: components["schemas"]["BinaryString2"]; + ThumbnailID: components['schemas']['Id2']; + Type: components['schemas']['ThumbnailType2']; + Hash: components['schemas']['BinaryString2']; Size: number; }; Verifier: { - Token: components["schemas"]["BinaryString"]; + Token: components['schemas']['BinaryString']; }; /** * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px, max. 65536 bytes in encrypted size
2HDPreview1920 px, max. 1048576 bytes in encrypted size
3MachineLearningmax. 65536 bytes in encrypted size
@@ -6504,7 +6510,7 @@ export interface components { */ ThumbnailType2: 1 | 2 | 3; PhotoListingRelatedItemResponse: { - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; /** @description File name hash */ @@ -6523,34 +6529,34 @@ export interface components { */ LinkState2: 0 | 1 | 2; LinkDto2: { - LinkID: components["schemas"]["Id2"]; - Type: components["schemas"]["NodeType2"]; - ParentLinkID?: components["schemas"]["Id2"] | null; - State: components["schemas"]["LinkState2"]; + LinkID: components['schemas']['Id2']; + Type: components['schemas']['NodeType2']; + ParentLinkID?: components['schemas']['Id2'] | null; + State: components['schemas']['LinkState2']; CreateTime: number; ModifyTime: number; TrashTime?: number | null; - Name: components["schemas"]["PGPMessage2"]; + Name: components['schemas']['PGPMessage2']; NameHash?: string | null; - NodeKey: components["schemas"]["PGPPrivateKey2"]; - NodePassphrase: components["schemas"]["PGPMessage2"]; - NodePassphraseSignature: components["schemas"]["PGPSignature2"]; + NodeKey: components['schemas']['PGPPrivateKey2']; + NodePassphrase: components['schemas']['PGPMessage2']; + NodePassphraseSignature: components['schemas']['PGPSignature2']; /** Format: email */ SignatureEmail?: string | null; /** Format: email */ NameSignatureEmail?: string | null; }; FolderDto2: { - NodeHashKey?: components["schemas"]["PGPMessage2"] | null; - XAttr?: components["schemas"]["PGPMessage2"] | null; + NodeHashKey?: components['schemas']['PGPMessage2'] | null; + XAttr?: components['schemas']['PGPMessage2'] | null; }; SharingDto2: { - ShareID: components["schemas"]["Id2"]; - ShareURLID?: components["schemas"]["Id2"] | null; + ShareID: components['schemas']['Id2']; + ShareURLID?: components['schemas']['Id2'] | null; }; MembershipDto2: { - ShareID: components["schemas"]["Id2"]; - MembershipID: components["schemas"]["Id2"]; + ShareID: components['schemas']['Id2']; + MembershipID: components['schemas']['Id2']; /** * @description Permission bitfield, valid permissions: * - 4: read access @@ -6588,12 +6594,12 @@ export interface components { }; BlockResponseDto: { Index: number; - Hash: components["schemas"]["BinaryString2"]; + Hash: components['schemas']['BinaryString2']; Token?: string | null; /** @deprecated */ URL?: string | null; BareURL?: string | null; - EncSignature?: components["schemas"]["PGPMessage2"] | null; + EncSignature?: components['schemas']['PGPMessage2'] | null; /** * Format: email * @description Email used to sign block @@ -6601,16 +6607,16 @@ export interface components { SignatureEmail?: string | null; }; PhotoResponseDto: { - LinkID: components["schemas"]["Id2"]; + LinkID: components['schemas']['Id2']; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id2"] | null; + MainPhotoLinkID?: components['schemas']['Id2'] | null; /** @description File name hash */ Hash?: string | null; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash?: string | null; /** @description LinkIDs of related Photos if there are any */ - RelatedPhotosLinkIDs: components["schemas"]["Id2"][]; + RelatedPhotosLinkIDs: components['schemas']['Id2'][]; /** * @deprecated * @description Deprecated: Clients persist exif information in xAttr instead @@ -6651,12 +6657,12 @@ export interface components { RevisionRetentionDays3: 0 | 7 | 30 | 180 | 365 | 3650; BlockResponseDto2: { Index: number; - Hash: components["schemas"]["BinaryString"]; + Hash: components['schemas']['BinaryString']; Token?: string | null; /** @deprecated */ URL?: string | null; BareURL?: string | null; - EncSignature?: components["schemas"]["PGPMessage"] | null; + EncSignature?: components['schemas']['PGPMessage'] | null; /** * Format: email * @description Email used to sign block @@ -6664,16 +6670,16 @@ export interface components { SignatureEmail?: string | null; }; PhotoResponseDto2: { - LinkID: components["schemas"]["Id"]; + LinkID: components['schemas']['Id']; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id"] | null; + MainPhotoLinkID?: components['schemas']['Id'] | null; /** @description File name hash */ Hash?: string | null; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash?: string | null; /** @description LinkIDs of related Photos if there are any */ - RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + RelatedPhotosLinkIDs: components['schemas']['Id'][]; /** * @deprecated * @description Deprecated: Clients persist exif information in xAttr instead @@ -6687,9 +6693,9 @@ export interface components { */ RevisionState2: 0 | 1 | 2; ThumbnailResponseDto2: { - ThumbnailID: components["schemas"]["Id"]; - Type: components["schemas"]["ThumbnailType"]; - Hash: components["schemas"]["BinaryString"]; + ThumbnailID: components['schemas']['Id']; + Type: components['schemas']['ThumbnailType']; + Hash: components['schemas']['BinaryString']; Size: number; }; /** @@ -6713,27 +6719,27 @@ export interface components { */ LinkState3: 0 | 1 | 2; ActiveRevisionDto: { - RevisionID: components["schemas"]["Id"]; + RevisionID: components['schemas']['Id']; CreateTime: number; EncryptedSize: number; - ManifestSignature?: components["schemas"]["PGPSignature"] | null; - XAttr?: components["schemas"]["PGPMessage"] | null; - Thumbnails: components["schemas"]["ThumbnailDto"][]; - Photo?: components["schemas"]["PhotoDto"] | null; + ManifestSignature?: components['schemas']['PGPSignature'] | null; + XAttr?: components['schemas']['PGPMessage'] | null; + Thumbnails: components['schemas']['ThumbnailDto'][]; + Photo?: components['schemas']['PhotoDto'] | null; /** Format: email */ SignatureEmail?: string | null; }; ThumbnailDto: { - ThumbnailID: components["schemas"]["Id"]; - Type: components["schemas"]["ThumbnailType"]; - Hash: components["schemas"]["BinaryString"]; + ThumbnailID: components['schemas']['Id']; + Type: components['schemas']['ThumbnailType']; + Hash: components['schemas']['BinaryString']; EncryptedSize: number; }; PhotoDto: { CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id"] | null; + MainPhotoLinkID?: components['schemas']['Id'] | null; ContentHash?: string | null; - RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + RelatedPhotosLinkIDs: components['schemas']['Id'][]; }; }; responses: { @@ -6741,11 +6747,11 @@ export interface components { ProtonSuccessResponse: { headers: { /** @description The same as the body code */ - "X-Pm-Code"?: 1000; + 'X-Pm-Code'?: 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonSuccess"]; + 'application/json': components['schemas']['ProtonSuccess']; }; }; /** @description General Error */ @@ -6754,7 +6760,7 @@ export interface components { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonError"]; + 'application/json': components['schemas']['ProtonError']; }; }; }; @@ -6765,7 +6771,7 @@ export interface components { } export type $defs = Record; export interface operations { - "post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple": { + 'post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple': { parameters: { query?: never; header?: never; @@ -6777,7 +6783,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddPhotosToAlbumRequestDto"]; + 'application/json': components['schemas']['AddPhotosToAlbumRequestDto']; }; }; responses: { @@ -6787,10 +6793,10 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["AddPhotoToAlbumWithLinkIDResponseDto"][]; + Responses?: components['schemas']['AddPhotoToAlbumWithLinkIDResponseDto'][]; }; }; }; @@ -6800,7 +6806,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The album does not exist. * - 200300: Album has reached the limit of photos. @@ -6812,7 +6818,7 @@ export interface operations { }; }; }; - "get_drive-photos-volumes-{volumeID}-albums": { + 'get_drive-photos-volumes-{volumeID}-albums': { parameters: { query?: { AnchorID?: string | null; @@ -6828,11 +6834,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListAlbumsResponseDto"]; + 'application/json': components['schemas']['ListAlbumsResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -6841,7 +6847,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: a photo share does not exist for this volume * - 2011: Insufficient permissions @@ -6852,7 +6858,7 @@ export interface operations { }; }; }; - "post_drive-photos-volumes-{volumeID}-albums": { + 'post_drive-photos-volumes-{volumeID}-albums': { parameters: { query?: never; header?: never; @@ -6863,18 +6869,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateAlbumRequestDto"]; + 'application/json': components['schemas']['CreateAlbumRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateAlbumResponseDto"]; + 'application/json': components['schemas']['CreateAlbumResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -6883,7 +6889,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 200300: Limit of albums per volume reached * - 2501: a photo share does not exist for this volume @@ -6898,7 +6904,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Potential codes: * - 2032 @@ -6911,7 +6917,7 @@ export interface operations { }; }; }; - "post_drive-photos-volumes": { + 'post_drive-photos-volumes': { parameters: { query?: never; header?: never; @@ -6920,18 +6926,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreatePhotoShareRequestDto"]; + 'application/json': components['schemas']['CreatePhotoShareRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetPhotoVolumeResponseDto"]; + 'application/json': components['schemas']['GetPhotoVolumeResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -6940,7 +6946,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 200001: Maximum number of volumes reached for current user * - 2500: A volume is already active @@ -6959,7 +6965,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Potential codes: * - 2032 @@ -6972,30 +6978,30 @@ export interface operations { }; }; }; - "put_drive-photos-volumes-{volumeID}-albums-{linkID}": { + 'put_drive-photos-volumes-{volumeID}-albums-{linkID}': { parameters: { query?: never; header?: never; path: { volumeID: string; - linkID: components["schemas"]["Id"]; + linkID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateAlbumRequestDto"]; + 'application/json': components['schemas']['UpdateAlbumRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -7004,7 +7010,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: a photo share does not exist for this volume * - 2011: Insufficient permissions @@ -7019,7 +7025,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Potential codes: * - 2501: File or folder not found @@ -7035,7 +7041,7 @@ export interface operations { }; }; }; - "delete_drive-photos-volumes-{volumeID}-albums-{linkID}": { + 'delete_drive-photos-volumes-{volumeID}-albums-{linkID}': { parameters: { query?: { /** @description Whether or not to delete the album even with direct children. */ @@ -7053,11 +7059,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -7066,7 +7072,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 200302: Album is not empty. Delete operation would result in data loss. * - 2011: Insufficient permissions @@ -7077,7 +7083,7 @@ export interface operations { }; }; }; - "post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates": { + 'post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates': { parameters: { query?: never; header?: never; @@ -7089,23 +7095,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["FindDuplicatesInput"]; + 'application/json': components['schemas']['FindDuplicatesInput']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["FindDuplicatesOutputCollection"]; + 'application/json': components['schemas']['FindDuplicatesOutputCollection']; }; }; }; }; - "get_drive-photos-volumes-{volumeID}-tags-migration": { + 'get_drive-photos-volumes-{volumeID}-tags-migration': { parameters: { query?: never; header?: never; @@ -7119,11 +7125,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PhotoTagMigrationStatusResponseDto"]; + 'application/json': components['schemas']['PhotoTagMigrationStatusResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -7132,7 +7138,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: volume does not exist, or is not photo volume * - 2011: Insufficient permissions, not volume owner @@ -7143,7 +7149,7 @@ export interface operations { }; }; }; - "post_drive-photos-volumes-{volumeID}-tags-migration": { + 'post_drive-photos-volumes-{volumeID}-tags-migration': { parameters: { query?: never; header?: never; @@ -7154,18 +7160,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdatePhotoTagMigrationStatusRequestDto"]; + 'application/json': components['schemas']['UpdatePhotoTagMigrationStatusRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -7174,7 +7180,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: volume does not exist, or is not photo volume * - 2011: Insufficient permissions, not volume owner @@ -7185,15 +7191,15 @@ export interface operations { }; }; }; - "get_drive-photos-volumes-{volumeID}-albums-{linkID}-children": { + 'get_drive-photos-volumes-{volumeID}-albums-{linkID}-children': { parameters: { query?: { - AnchorID?: components["schemas"]["ListPhotosAlbumQueryParameters"]["AnchorID"]; - Sort?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Sort"]; - Desc?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Desc"]; - Tag?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Tag"]; - OnlyChildren?: components["schemas"]["ListPhotosAlbumQueryParameters"]["OnlyChildren"]; - IncludeTrashed?: components["schemas"]["ListPhotosAlbumQueryParameters"]["IncludeTrashed"]; + AnchorID?: components['schemas']['ListPhotosAlbumQueryParameters']['AnchorID']; + Sort?: components['schemas']['ListPhotosAlbumQueryParameters']['Sort']; + Desc?: components['schemas']['ListPhotosAlbumQueryParameters']['Desc']; + Tag?: components['schemas']['ListPhotosAlbumQueryParameters']['Tag']; + OnlyChildren?: components['schemas']['ListPhotosAlbumQueryParameters']['OnlyChildren']; + IncludeTrashed?: components['schemas']['ListPhotosAlbumQueryParameters']['IncludeTrashed']; }; header?: never; path: { @@ -7207,11 +7213,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListPhotosAlbumResponseDto"]; + 'application/json': components['schemas']['ListPhotosAlbumResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -7220,7 +7226,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: Volume not found * - 2501: File or folder not found @@ -7232,7 +7238,7 @@ export interface operations { }; }; }; - "put_drive-photos-volumes-{volumeID}-recover-multiple": { + 'put_drive-photos-volumes-{volumeID}-recover-multiple': { parameters: { query?: never; header?: never; @@ -7243,7 +7249,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; + 'application/json': components['schemas']['TransferPhotoLinksRequestDto']; }; }; responses: { @@ -7253,13 +7259,13 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; - Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + Response?: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; }[]; }; }; @@ -7270,7 +7276,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. * - 2511: cannot recover photos from a share @@ -7283,7 +7289,7 @@ export interface operations { }; }; }; - "post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple": { + 'post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple': { parameters: { query?: never; header?: never; @@ -7295,7 +7301,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RemovePhotosFromAlbumRequestDto"]; + 'application/json': components['schemas']['RemovePhotosFromAlbumRequestDto']; }; }; responses: { @@ -7305,10 +7311,10 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["RemovePhotoFromAlbumWithLinkIDResponseDto"][]; + Responses?: components['schemas']['RemovePhotoFromAlbumWithLinkIDResponseDto'][]; }; }; }; @@ -7318,7 +7324,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2500: A volume is already active * */ @@ -7328,10 +7334,10 @@ export interface operations { }; }; }; - "get_drive-photos-albums-shared-with-me": { + 'get_drive-photos-albums-shared-with-me': { parameters: { query?: { - AnchorID?: components["schemas"]["Id"] | null; + AnchorID?: components['schemas']['Id'] | null; }; header?: never; path?: never; @@ -7342,11 +7348,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SharedWithMeResponseDto"]; + 'application/json': components['schemas']['SharedWithMeResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -7355,7 +7361,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: Insufficient permissions * */ @@ -7365,7 +7371,7 @@ export interface operations { }; }; }; - "put_drive-volumes-{volumeID}-links-transfer-multiple": { + 'put_drive-volumes-{volumeID}-links-transfer-multiple': { parameters: { query?: never; header?: never; @@ -7376,7 +7382,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; + 'application/json': components['schemas']['TransferPhotoLinksRequestDto']; }; }; responses: { @@ -7386,13 +7392,13 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; - Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + Response?: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; }[]; }; }; @@ -7403,7 +7409,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. * - 2511: cannot move favorite photos from a share @@ -7415,7 +7421,7 @@ export interface operations { }; }; }; - "put_drive-photos-volumes-{volumeID}-links-transfer-multiple": { + 'put_drive-photos-volumes-{volumeID}-links-transfer-multiple': { parameters: { query?: never; header?: never; @@ -7426,7 +7432,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; + 'application/json': components['schemas']['TransferPhotoLinksRequestDto']; }; }; responses: { @@ -7436,13 +7442,13 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; - Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + Response?: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; }[]; }; }; @@ -7453,7 +7459,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. * - 2511: cannot move favorite photos from a share @@ -7465,7 +7471,7 @@ export interface operations { }; }; }; - "post_drive-v2-urls-{token}-bookmark": { + 'post_drive-v2-urls-{token}-bookmark': { parameters: { query?: never; header?: never; @@ -7477,18 +7483,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateBookmarkShareURLRequestDto"]; + 'application/json': components['schemas']['CreateBookmarkShareURLRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateBookmarkShareURLResponseDto"]; + 'application/json': components['schemas']['CreateBookmarkShareURLResponseDto']; }; }; /** @description Bad request */ @@ -7497,7 +7503,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2001: the token format is invalid * */ @@ -7511,7 +7517,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 200001: You have reached the maximum number of items you can save. * - 2501: Item link not found @@ -7524,7 +7530,7 @@ export interface operations { }; }; }; - "delete_drive-v2-urls-{token}-bookmark": { + 'delete_drive-v2-urls-{token}-bookmark': { parameters: { query?: never; header?: never; @@ -7539,11 +7545,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Bad request */ @@ -7552,7 +7558,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2001: the token format is invalid * */ @@ -7566,7 +7572,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: Item link not found * - 2501: Item not found @@ -7577,7 +7583,7 @@ export interface operations { }; }; }; - "get_drive-v2-shared-bookmarks": { + 'get_drive-v2-shared-bookmarks': { parameters: { query?: never; header?: never; @@ -7589,11 +7595,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListBookmarksOfUserResponseDto"]; + 'application/json': components['schemas']['ListBookmarksOfUserResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -7602,7 +7608,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: Item link not found * - 2501: item not found @@ -7614,7 +7620,7 @@ export interface operations { }; }; }; - "get_drive-devices": { + 'get_drive-devices': { parameters: { query?: never; header?: never; @@ -7626,16 +7632,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListDevicesResponseDto"]; + 'application/json': components['schemas']['ListDevicesResponseDto']; }; }; }; }; - "post_drive-devices": { + 'post_drive-devices': { parameters: { query?: never; header?: never; @@ -7644,55 +7650,55 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateDeviceRequestDto"]; + 'application/json': components['schemas']['CreateDeviceRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateDeviceResponseDto"]; + 'application/json': components['schemas']['CreateDeviceResponseDto']; }; }; }; }; - "put_drive-devices-{deviceID}": { + 'put_drive-devices-{deviceID}': { parameters: { query?: never; header?: never; path: { - deviceID: components["schemas"]["Id"]; + deviceID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateDeviceRequestDto"]; + 'application/json': components['schemas']['UpdateDeviceRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "delete_drive-devices-{deviceID}": { + 'delete_drive-devices-{deviceID}': { parameters: { query?: never; header?: never; path: { - deviceID: components["schemas"]["Id"]; + deviceID: components['schemas']['Id']; }; cookie?: never; }; @@ -7701,16 +7707,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "get_drive-v2-devices": { + 'get_drive-v2-devices': { parameters: { query?: never; header?: never; @@ -7722,16 +7728,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListDevicesResponseDto2"]; + 'application/json': components['schemas']['ListDevicesResponseDto2']; }; }; }; }; - "post_drive-shares-{shareID}-documents": { + 'post_drive-shares-{shareID}-documents': { parameters: { query?: never; header?: never; @@ -7742,18 +7748,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateDocumentDto"]; + 'application/json': components['schemas']['CreateDocumentDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateDocumentResponseDto"]; + 'application/json': components['schemas']['CreateDocumentResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -7762,18 +7768,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** - * @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 2011: the user does not have permissions to create a file in this share - * - * @enum {integer} - */ - Code: 200300 | 2500 | 2501 | 2011; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: the user does not have permissions to create a file in this share + * + * @enum {integer} + */ + Code: 200300 | 2500 | 2501 | 2011; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; /** @description Failed dependency */ @@ -7782,7 +7790,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Potential codes and their meaning: * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags @@ -7795,7 +7803,7 @@ export interface operations { }; }; }; - "get_drive-shares-{shareID}-events-latest": { + 'get_drive-shares-{shareID}-events-latest': { parameters: { query?: never; header?: never; @@ -7809,16 +7817,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LatestEventIDResponseDto"]; + 'application/json': components['schemas']['LatestEventIDResponseDto']; }; }; }; }; - "get_drive-volumes-{volumeID}-events-latest": { + 'get_drive-volumes-{volumeID}-events-latest': { parameters: { query?: never; header?: never; @@ -7832,16 +7840,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LatestEventIDResponseDto"]; + 'application/json': components['schemas']['LatestEventIDResponseDto']; }; }; }; }; - "get_drive-shares-{shareID}-events-{eventID}": { + 'get_drive-shares-{shareID}-events-{eventID}': { parameters: { query?: never; header?: never; @@ -7856,16 +7864,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListEventsResponseDto"]; + 'application/json': components['schemas']['ListEventsResponseDto']; }; }; }; }; - "get_drive-volumes-{volumeID}-events-{eventID}": { + 'get_drive-volumes-{volumeID}-events-{eventID}': { parameters: { query?: never; header?: never; @@ -7880,16 +7888,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListEventsResponseDto"]; + 'application/json': components['schemas']['ListEventsResponseDto']; }; }; }; }; - "get_drive-v2-volumes-{volumeID}-events-{eventID}": { + 'get_drive-v2-volumes-{volumeID}-events-{eventID}': { parameters: { query?: never; header?: never; @@ -7904,16 +7912,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListEventsV2ResponseDto"]; + 'application/json': components['schemas']['ListEventsV2ResponseDto']; }; }; }; }; - "post_drive-shares-{shareID}-folders": { + 'post_drive-shares-{shareID}-folders': { parameters: { query?: never; header?: never; @@ -7924,18 +7932,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateFolderRequestDto"]; + 'application/json': components['schemas']['CreateFolderRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateFolderResponseDto"]; + 'application/json': components['schemas']['CreateFolderResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -7944,21 +7952,23 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 2511: the link targeted is a photo link - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * */ - Code?: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "post_drive-shares-{shareID}-folders-{linkID}-delete_multiple": { + 'post_drive-shares-{shareID}-folders-{linkID}-delete_multiple': { parameters: { query?: never; header?: never; @@ -7970,7 +7980,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { @@ -7980,20 +7990,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; + Responses?: components['schemas']['MultiDeleteTransformer'][]; }; }; }; }; }; - "get_drive-shares-{shareID}-folders-{linkID}-children": { + 'get_drive-shares-{shareID}-folders-{linkID}-children': { parameters: { query?: { /** @description Field to sort by */ - Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; + Sort?: 'MIMEType' | 'Size' | 'ModifyTime' | 'CreateTime' | 'Type'; /** @description Sort order */ Desc?: 0 | 1; /** @description Show all files including those in non-active (drafts) state. */ @@ -8005,8 +8015,8 @@ export interface operations { * @description Get thumbnail download URLs */ Thumbnails?: 0 | 1; - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path: { @@ -8023,17 +8033,17 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; /** @description Allow sorting of items in folder */ AllowSorting: boolean; - Links: components["schemas"]["ExtendedLinkTransformer"][]; + Links: components['schemas']['ExtendedLinkTransformer'][]; }; }; }; }; }; - "post_drive-shares-{shareID}-folders-{linkID}-trash_multiple": { + 'post_drive-shares-{shareID}-folders-{linkID}-trash_multiple': { parameters: { query?: never; header?: never; @@ -8045,7 +8055,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { @@ -8055,16 +8065,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; + Responses?: components['schemas']['MultiDeleteTransformer'][]; }; }; }; }; }; - "put_drive-shares-{shareID}-folders-{linkID}": { + 'put_drive-shares-{shareID}-folders-{linkID}': { parameters: { query?: never; header?: never; @@ -8076,7 +8086,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateFolderRequestDto"]; + 'application/json': components['schemas']['UpdateFolderRequestDto']; }; }; responses: { @@ -8086,15 +8096,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Link: components["schemas"]["ExtendedLinkTransformer"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; + Link: components['schemas']['ExtendedLinkTransformer']; }; }; }; }; }; - "post_drive-v2-volumes-{volumeID}-folders": { + 'post_drive-v2-volumes-{volumeID}-folders': { parameters: { query?: never; header?: never; @@ -8105,18 +8115,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateFolderRequestDto2"]; + 'application/json': components['schemas']['CreateFolderRequestDto2']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateFolderResponseDto"]; + 'application/json': components['schemas']['CreateFolderResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -8125,25 +8135,27 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 2511: the link targeted is a photo link - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * */ - Code?: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "get_drive-v2-volumes-{volumeID}-folders-{linkID}-children": { + 'get_drive-v2-volumes-{volumeID}-folders-{linkID}-children': { parameters: { query?: { /** @description Link ID use to indicate where to start the next page */ - AnchorID?: (string & components["schemas"]["Id"]) | null; + AnchorID?: (string & components['schemas']['Id']) | null; /** @description Show folders only */ FoldersOnly?: 0 | 1; }; @@ -8159,11 +8171,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListChildrenResponseDto"]; + 'application/json': components['schemas']['ListChildrenResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -8172,7 +8184,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2032: sharing is temporarily disabled and the user is not the volume owner. * - 2011: The user does not have permission to access this folder. @@ -8183,7 +8195,7 @@ export interface operations { }; }; }; - "post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes": { + 'post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes': { parameters: { query?: never; header?: never; @@ -8195,23 +8207,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; + 'application/json': components['schemas']['CheckAvailableHashesRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AvailableHashesResponseDto"]; + 'application/json': components['schemas']['AvailableHashesResponseDto']; }; }; }; }; - "post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes": { + 'post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes': { parameters: { query?: never; header?: never; @@ -8223,23 +8235,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; + 'application/json': components['schemas']['CheckAvailableHashesRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AvailableHashesResponseDto"]; + 'application/json': components['schemas']['AvailableHashesResponseDto']; }; }; }; }; - "post_drive-volumes-{volumeID}-links-{linkID}-copy": { + 'post_drive-volumes-{volumeID}-links-{linkID}-copy': { parameters: { query?: never; header?: never; @@ -8251,18 +8263,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CopyLinkRequestDto"]; + 'application/json': components['schemas']['CopyLinkRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CopyLinkResponseDto"]; + 'application/json': components['schemas']['CopyLinkResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -8271,7 +8283,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Potential codes and their meaning: * - 2011: Copying Proton Docs to another account is not possible yet. @@ -8292,7 +8304,7 @@ export interface operations { }; }; }; - "post_drive-v2-volumes-{volumeID}-delete_multiple": { + 'post_drive-v2-volumes-{volumeID}-delete_multiple': { parameters: { query?: never; header?: never; @@ -8303,7 +8315,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { @@ -8313,16 +8325,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; + Responses?: components['schemas']['MultiDeleteTransformer'][]; }; }; }; }; }; - "post_drive-shares-{shareID}-links-fetch_metadata": { + 'post_drive-shares-{shareID}-links-fetch_metadata': { parameters: { query?: never; header?: never; @@ -8333,7 +8345,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["FetchLinksMetadataRequestDto"]; + 'application/json': components['schemas']['FetchLinksMetadataRequestDto']; }; }; responses: { @@ -8343,16 +8355,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Links: components["schemas"]["ExtendedLinkTransformer"][]; - Parents: components["schemas"]["ExtendedLinkTransformer"][]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; + Links: components['schemas']['ExtendedLinkTransformer'][]; + Parents: components['schemas']['ExtendedLinkTransformer'][]; }; }; }; }; }; - "post_drive-volumes-{volumeID}-links-fetch_metadata": { + 'post_drive-volumes-{volumeID}-links-fetch_metadata': { parameters: { query?: never; header?: never; @@ -8363,23 +8375,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["FetchLinksMetadataRequestDto"]; + 'application/json': components['schemas']['FetchLinksMetadataRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["FetchLinksMetadataResponseDto"]; + 'application/json': components['schemas']['FetchLinksMetadataResponseDto']; }; }; }; }; - "get_drive-shares-{shareID}-links-{linkID}": { + 'get_drive-shares-{shareID}-links-{linkID}': { parameters: { query?: never; header?: never; @@ -8397,15 +8409,15 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Link: components["schemas"]["ExtendedLinkTransformer"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; + Link: components['schemas']['ExtendedLinkTransformer']; }; }; }; }; }; - "get_drive-sanitization-mhk": { + 'get_drive-sanitization-mhk': { parameters: { query?: never; header?: never; @@ -8417,16 +8429,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListMissingHashKeyResponseDto"]; + 'application/json': components['schemas']['ListMissingHashKeyResponseDto']; }; }; }; }; - "post_drive-sanitization-mhk": { + 'post_drive-sanitization-mhk': { parameters: { query?: never; header?: never; @@ -8435,23 +8447,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateMissingHashKeyRequestDto"]; + 'application/json': components['schemas']['UpdateMissingHashKeyRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "post_drive-v2-volumes-{volumeID}-links": { + 'post_drive-v2-volumes-{volumeID}-links': { parameters: { query?: never; header?: never; @@ -8462,23 +8474,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LoadLinkDetailsResponseDto"]; + 'application/json': components['schemas']['LoadLinkDetailsResponseDto']; }; }; }; }; - "put_drive-volumes-{volumeID}-links-move-multiple": { + 'put_drive-volumes-{volumeID}-links-move-multiple': { parameters: { query?: never; header?: never; @@ -8489,7 +8501,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["MoveLinkBatchRequestDto"]; + 'application/json': components['schemas']['MoveLinkBatchRequestDto']; }; }; responses: { @@ -8499,13 +8511,13 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; - Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + Response?: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; }[]; }; }; @@ -8516,7 +8528,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. * - 2511: cannot move favorite photos from a share @@ -8528,7 +8540,7 @@ export interface operations { }; }; }; - "put_drive-shares-{shareID}-links-{linkID}-move": { + 'put_drive-shares-{shareID}-links-{linkID}-move': { parameters: { query?: never; header?: never; @@ -8540,34 +8552,36 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["MoveLinkRequestDto"]; + 'application/json': components['schemas']['MoveLinkRequestDto']; }; }; responses: { - 200: components["responses"]["ProtonSuccessResponse"]; + 200: components['responses']['ProtonSuccessResponse']; /** @description Unprocessable Entity */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2511: cannot move favorite photos from a share - * - 2501: parent folder was not found - * */ - Code?: number; - /** @description Error message */ - Error?: string; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2511: cannot move favorite photos from a share + * - 2501: parent folder was not found + * */ + Code?: number; + /** @description Error message */ + Error?: string; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "put_drive-v2-volumes-{volumeID}-links-{linkID}-rename": { + 'put_drive-v2-volumes-{volumeID}-links-{linkID}-rename': { parameters: { query?: never; header?: never; @@ -8579,23 +8593,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RenameLinkRequestDto"]; + 'application/json': components['schemas']['RenameLinkRequestDto']; }; }; responses: { - 200: components["responses"]["ProtonSuccessResponse"]; + 200: components['responses']['ProtonSuccessResponse']; /** @description Conflict, a file or folder with the new name already exists in the current folder. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "put_drive-shares-{shareID}-links-{linkID}-rename": { + 'put_drive-shares-{shareID}-links-{linkID}-rename': { parameters: { query?: never; header?: never; @@ -8607,23 +8621,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RenameLinkRequestDto"]; + 'application/json': components['schemas']['RenameLinkRequestDto']; }; }; responses: { - 200: components["responses"]["ProtonSuccessResponse"]; + 200: components['responses']['ProtonSuccessResponse']; /** @description Conflict, a file or folder with the new name already exists in the current folder. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "put_drive-v2-volumes-{volumeID}-links-{linkID}-move": { + 'put_drive-v2-volumes-{volumeID}-links-{linkID}-move': { parameters: { query?: never; header?: never; @@ -8635,48 +8649,50 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["MoveLinkRequestDto2"]; + 'application/json': components['schemas']['MoveLinkRequestDto2']; }; }; responses: { - 200: components["responses"]["ProtonSuccessResponse"]; + 200: components['responses']['ProtonSuccessResponse']; /** @description Unprocessable Entity */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2511: cannot move favorite photos from a share - * - 2501: parent folder was not found - * */ - Code?: number; - /** @description Error message */ - Error?: string; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2511: cannot move favorite photos from a share + * - 2501: parent folder was not found + * */ + Code?: number; + /** @description Error message */ + Error?: string; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + 'get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}': { parameters: { query?: { /** @description Number of blocks */ - PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + PageSize?: components['schemas']['GetRevisionQueryParameters']['PageSize']; /** @description Block index from which to fetch block list */ - FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; + FromBlockIndex?: components['schemas']['GetRevisionQueryParameters']['FromBlockIndex']; /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; + NoBlockUrls?: components['schemas']['GetRevisionQueryParameters']['NoBlockUrls']; }; header?: never; path: { volumeID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -8688,36 +8704,36 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetRevisionResponseDto2"]; + 'application/json': components['schemas']['GetRevisionResponseDto2']; }; }; }; }; - "put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + 'put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}': { parameters: { query?: never; header?: never; path: { volumeID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["CommitRevisionDto"]; + 'application/json': components['schemas']['CommitRevisionDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -8726,24 +8742,26 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * */ - Code: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * */ + Code: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + 'delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}': { parameters: { query?: never; header?: never; path: { volumeID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -8752,11 +8770,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -8765,34 +8783,36 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { - /** @description Potential codes and their meaning: - * - 2501: the link (must be active or trashed) or revision does not exist in the volume - * - 2011: the current user does not have permission to delete the revision - * - 2511: if the revision is active - create or revert to another revision first - * - 200700: if the link is a proton doc (revisions are not used for docs) - * */ - Code?: number; - }; + 'application/json': + | components['schemas']['ShareConflictErrorResponseDto'] + | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2011: the current user does not have permission to delete the revision + * - 2511: if the revision is active - create or revert to another revision first + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; }; }; }; }; - "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + 'get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}': { parameters: { query?: { /** @description Number of blocks */ - PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + PageSize?: components['schemas']['GetRevisionQueryParameters']['PageSize']; /** @description Block index from which to fetch block list */ - FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; + FromBlockIndex?: components['schemas']['GetRevisionQueryParameters']['FromBlockIndex']; /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; + NoBlockUrls?: components['schemas']['GetRevisionQueryParameters']['NoBlockUrls']; }; header?: never; path: { shareID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -8804,36 +8824,36 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetRevisionResponseDto2"]; + 'application/json': components['schemas']['GetRevisionResponseDto2']; }; }; }; }; - "put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + 'put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}': { parameters: { query?: never; header?: never; path: { shareID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["CommitRevisionDto"]; + 'application/json': components['schemas']['CommitRevisionDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -8842,24 +8862,26 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * */ - Code: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * */ + Code: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + 'delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}': { parameters: { query?: never; header?: never; path: { shareID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -8868,11 +8890,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -8881,20 +8903,22 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { - /** @description Potential codes and their meaning: - * - 2501: the link (must be active or trashed) or revision does not exist in the volume - * - 2011: the current user does not have permission to delete the revision - * - 2511: if the revision is active - create or revert to another revision first - * - 200700: if the link is a proton doc (revisions are not used for docs) - * */ - Code?: number; - }; + 'application/json': + | components['schemas']['ShareConflictErrorResponseDto'] + | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2011: the current user does not have permission to delete the revision + * - 2511: if the revision is active - create or revert to another revision first + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; }; }; }; }; - "post_drive-v2-volumes-{volumeID}-files": { + 'post_drive-v2-volumes-{volumeID}-files': { parameters: { query?: never; header?: never; @@ -8905,7 +8929,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateFileDto"]; + 'application/json': components['schemas']['CreateFileDto']; }; }; responses: { @@ -8915,8 +8939,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; File: { /** @description Encrypted link ID */ ID: string; @@ -8933,23 +8957,25 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200701: A document type cannot create a revision - * - 200901: Photos backup is disabled for your account. Please enable it in the settings. - * */ - Code: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * - 200901: Photos backup is disabled for your account. Please enable it in the settings. + * */ + Code: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "post_drive-shares-{shareID}-files": { + 'post_drive-shares-{shareID}-files': { parameters: { query?: never; header?: never; @@ -8960,7 +8986,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateFileDto"]; + 'application/json': components['schemas']['CreateFileDto']; }; }; responses: { @@ -8970,8 +8996,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; File: { /** @description Encrypted link ID */ ID: string; @@ -8988,23 +9014,25 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200701: A document type cannot create a revision - * - 200901: Photos backup is disabled for your account. Please enable it in the settings. - * */ - Code: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * - 200901: Photos backup is disabled for your account. Please enable it in the settings. + * */ + Code: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions": { + 'get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions': { parameters: { query?: never; header?: never; @@ -9019,16 +9047,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListRevisionsResponseDto"]; + 'application/json': components['schemas']['ListRevisionsResponseDto']; }; }; }; }; - "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions": { + 'post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions': { parameters: { query?: never; header?: never; @@ -9040,7 +9068,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateRevisionRequestDto"]; + 'application/json': components['schemas']['CreateRevisionRequestDto']; }; }; responses: { @@ -9050,8 +9078,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; Revision: { /** @description Revision ID */ ID: string; @@ -9065,7 +9093,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConflictErrorResponseDto"] | components["schemas"]["ProtonError"]; + 'application/json': + | components['schemas']['ConflictErrorResponseDto'] + | components['schemas']['ProtonError']; }; }; /** @description Unprocessable Entity */ @@ -9074,18 +9104,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200700: A document type cannot create a revision - * */ - Code: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200700: A document type cannot create a revision + * */ + Code: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "get_drive-shares-{shareID}-files-{linkID}-revisions": { + 'get_drive-shares-{shareID}-files-{linkID}-revisions': { parameters: { query?: never; header?: never; @@ -9100,16 +9132,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListRevisionsResponseDto"]; + 'application/json': components['schemas']['ListRevisionsResponseDto']; }; }; }; }; - "post_drive-shares-{shareID}-files-{linkID}-revisions": { + 'post_drive-shares-{shareID}-files-{linkID}-revisions': { parameters: { query?: never; header?: never; @@ -9121,7 +9153,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateRevisionRequestDto"]; + 'application/json': components['schemas']['CreateRevisionRequestDto']; }; }; responses: { @@ -9131,8 +9163,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; Revision: { /** @description Revision ID */ ID: string; @@ -9146,7 +9178,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConflictErrorResponseDto"] | components["schemas"]["ProtonError"]; + 'application/json': + | components['schemas']['ConflictErrorResponseDto'] + | components['schemas']['ProtonError']; }; }; /** @description Unprocessable Entity */ @@ -9155,18 +9189,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200700: A document type cannot create a revision - * */ - Code: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200700: A document type cannot create a revision + * */ + Code: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail": { + 'get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail': { parameters: { query?: { /** @description Type of Thumbnail to fetch */ @@ -9176,7 +9212,7 @@ export interface operations { path: { shareID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -9188,8 +9224,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; /** @description Thumbnail download link */ ThumbnailLink: string; /** @@ -9204,14 +9240,14 @@ export interface operations { }; }; }; - "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore": { + 'post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore': { parameters: { query?: never; header?: never; path: { volumeID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -9220,24 +9256,24 @@ export interface operations { /** @description Revision restore queued for async processing */ 202: { headers: { - "x-pm-code": 1002; + 'x-pm-code': 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RestoreRevisionAcceptedResponse"]; + 'application/json': components['schemas']['RestoreRevisionAcceptedResponse']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore": { + 'post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore': { parameters: { query?: never; header?: never; path: { shareID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -9246,24 +9282,24 @@ export interface operations { /** @description Revision restore queued for async processing */ 202: { headers: { - "x-pm-code": 1002; + 'x-pm-code': 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RestoreRevisionAcceptedResponse"]; + 'application/json': components['schemas']['RestoreRevisionAcceptedResponse']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification": { + 'get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification': { parameters: { query?: never; header?: never; path: { volumeID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -9272,23 +9308,23 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VerificationData"]; + 'application/json': components['schemas']['VerificationData']; }; }; }; }; - "get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification": { + 'get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification': { parameters: { query?: never; header?: never; path: { shareID: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -9297,16 +9333,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VerificationData"]; + 'application/json': components['schemas']['VerificationData']; }; }; }; }; - "post_drive-v2-volumes-{volumeID}-trash-delete_multiple": { + 'post_drive-v2-volumes-{volumeID}-trash-delete_multiple': { parameters: { query?: never; header?: never; @@ -9317,7 +9353,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { @@ -9327,16 +9363,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; + Responses?: components['schemas']['MultiDeleteTransformer'][]; }; }; }; }; }; - "post_drive-shares-{shareID}-trash-delete_multiple": { + 'post_drive-shares-{shareID}-trash-delete_multiple': { parameters: { query?: never; header?: never; @@ -9347,7 +9383,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { @@ -9357,16 +9393,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; + Responses?: components['schemas']['MultiDeleteTransformer'][]; }; }; }; }; }; - "get_drive-shares-{shareID}-trash": { + 'get_drive-shares-{shareID}-trash': { parameters: { query?: { /** @@ -9374,8 +9410,8 @@ export interface operations { * @description Get thumbnail download URLs */ Thumbnails?: 0 | 1; - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path: { @@ -9391,19 +9427,19 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Links: components["schemas"]["ExtendedLinkTransformer"][]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; + Links: components['schemas']['ExtendedLinkTransformer'][]; /** @description Dictionary of ancestors of trashed links. */ Parents: { - [key: string]: components["schemas"]["ExtendedLinkTransformer"]; + [key: string]: components['schemas']['ExtendedLinkTransformer']; }; }; }; }; }; }; - "delete_drive-shares-{shareID}-trash": { + 'delete_drive-shares-{shareID}-trash': { parameters: { query?: never; header?: never; @@ -9417,30 +9453,30 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Empty trash queued for async processing */ 202: { headers: { - "x-pm-code": 1002; + 'x-pm-code': 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmptyTrashAcceptedResponse"]; + 'application/json': components['schemas']['EmptyTrashAcceptedResponse']; }; }; }; }; - "get_drive-volumes-{volumeID}-trash": { + 'get_drive-volumes-{volumeID}-trash': { parameters: { query?: { - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path: { @@ -9453,16 +9489,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VolumeTrashList"]; + 'application/json': components['schemas']['VolumeTrashList']; }; }; }; }; - "delete_drive-volumes-{volumeID}-trash": { + 'delete_drive-volumes-{volumeID}-trash': { parameters: { query?: never; header?: never; @@ -9476,26 +9512,26 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Empty trash queued for async processing */ 202: { headers: { - "x-pm-code": 1002; + 'x-pm-code': 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmptyTrashAcceptedResponse"]; + 'application/json': components['schemas']['EmptyTrashAcceptedResponse']; }; }; }; }; - "put_drive-v2-volumes-{volumeID}-trash-restore_multiple": { + 'put_drive-v2-volumes-{volumeID}-trash-restore_multiple': { parameters: { query?: never; header?: never; @@ -9506,7 +9542,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { @@ -9516,14 +9552,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; Response?: { - Code?: components["schemas"]["ResponseCodeSuccess"]; + Code?: components['schemas']['ResponseCodeSuccess']; }; }[]; }; @@ -9531,7 +9567,7 @@ export interface operations { }; }; }; - "put_drive-shares-{shareID}-trash-restore_multiple": { + 'put_drive-shares-{shareID}-trash-restore_multiple': { parameters: { query?: never; header?: never; @@ -9542,7 +9578,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { @@ -9552,14 +9588,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; Response?: { - Code?: components["schemas"]["ResponseCodeSuccess"]; + Code?: components['schemas']['ResponseCodeSuccess']; }; }[]; }; @@ -9567,7 +9603,7 @@ export interface operations { }; }; }; - "post_drive-v2-volumes-{volumeID}-trash_multiple": { + 'post_drive-v2-volumes-{volumeID}-trash_multiple': { parameters: { query?: never; header?: never; @@ -9578,7 +9614,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { @@ -9588,16 +9624,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; + Responses?: components['schemas']['MultiDeleteTransformer'][]; }; }; }; }; }; - "post_drive-blocks": { + 'post_drive-blocks': { parameters: { query?: never; header?: never; @@ -9606,23 +9642,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RequestUploadInput"]; + 'application/json': components['schemas']['RequestUploadInput']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RequestUploadResponse"]; + 'application/json': components['schemas']['RequestUploadResponse']; }; }; }; }; - "post_drive-v2-volumes-{volumeID}-files-small": { + 'post_drive-v2-volumes-{volumeID}-files-small': { parameters: { query?: never; header?: never; @@ -9665,8 +9701,8 @@ export interface operations { * * * --[SOME_BOUNDARY]-- */ - "multipart/form-data": { - Metadata: components["schemas"]["SmallFileUploadMetadataRequestDto"]; + 'multipart/form-data': { + Metadata: components['schemas']['SmallFileUploadMetadataRequestDto']; /** * Format: binary * @description The encrypted binary data of the file content. This is optional as 0-byte files do not have a block. @@ -9694,11 +9730,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SmallUploadResponseDto"]; + 'application/json': components['schemas']['SmallUploadResponseDto']; }; }; /** @description Bad request, the metadata does not pass validation. */ @@ -9707,7 +9743,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonError"]; + 'application/json': components['schemas']['ProtonError']; }; }; /** @description Conflict, there is a name hash collision with another link in the same folder. */ @@ -9716,7 +9752,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': components['schemas']['ConflictErrorResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -9725,7 +9761,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The parent link does not exist or is trashed * - 2011: The user does not have write permission on the link @@ -9743,7 +9779,7 @@ export interface operations { }; }; }; - "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small": { + 'post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small': { parameters: { query?: never; header?: never; @@ -9787,8 +9823,8 @@ export interface operations { * * * --[SOME_BOUNDARY]-- */ - "multipart/form-data": { - Metadata: components["schemas"]["SmallRevisionUploadMetadataRequestDto"]; + 'multipart/form-data': { + Metadata: components['schemas']['SmallRevisionUploadMetadataRequestDto']; /** * Format: binary * @description The encrypted binary data of the file content. This is optional as 0-byte files do not have a block. @@ -9816,11 +9852,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SmallUploadResponseDto"]; + 'application/json': components['schemas']['SmallUploadResponseDto']; }; }; /** @description Bad request, the metadata does not pass validation. */ @@ -9829,7 +9865,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonError"]; + 'application/json': components['schemas']['ProtonError']; }; }; /** @description Conflict, the passed CurrentRevisionID is no longer up to date. */ @@ -9838,7 +9874,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonError"]; + 'application/json': components['schemas']['ProtonError']; }; }; /** @description Unprocessable Entity */ @@ -9847,7 +9883,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The link does not exist or is trashed * - 2011: The user does not have write permission on the link @@ -9863,7 +9899,7 @@ export interface operations { }; }; }; - "get_drive-me-active": { + 'get_drive-me-active': { parameters: { query?: never; header?: never; @@ -9878,8 +9914,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code?: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; /** @enum {boolean} */ Active?: true; }; @@ -9887,7 +9923,7 @@ export interface operations { }; }; }; - "post_drive-report-url": { + 'post_drive-report-url': { parameters: { query?: never; header?: never; @@ -9896,23 +9932,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AbuseReportDto"]; + 'application/json': components['schemas']['AbuseReportDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "get_drive-v2-checklist-get-started": { + 'get_drive-v2-checklist-get-started': { parameters: { query?: never; header?: never; @@ -9924,16 +9960,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ChecklistResponseDto"]; + 'application/json': components['schemas']['ChecklistResponseDto']; }; }; }; }; - "get_drive-v2-onboarding": { + 'get_drive-v2-onboarding': { parameters: { query?: never; header?: never; @@ -9945,16 +9981,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["OnboardingResponseDto"]; + 'application/json': components['schemas']['OnboardingResponseDto']; }; }; }; }; - "post_drive-v2-checklist-get-started-seen-completed-list": { + 'post_drive-v2-checklist-get-started-seen-completed-list': { parameters: { query?: never; header?: never; @@ -9966,16 +10002,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "get_drive-entitlements": { + 'get_drive-entitlements': { parameters: { query?: never; header?: never; @@ -9987,16 +10023,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetEntitlementResponseDto"]; + 'application/json': components['schemas']['GetEntitlementResponseDto']; }; }; }; }; - "post_drive-photos-volumes-{volumeID}-links-{linkID}-tags": { + 'post_drive-photos-volumes-{volumeID}-links-{linkID}-tags': { parameters: { query?: never; header?: never; @@ -10008,18 +10044,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AddTagsRequestDto"]; + 'application/json': components['schemas']['AddTagsRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -10028,7 +10064,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The link or volume does not exist. * - 2500: One of the tags is already assigned to the photo. @@ -10041,7 +10077,7 @@ export interface operations { }; }; }; - "delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags": { + 'delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags': { parameters: { query?: never; header?: never; @@ -10053,18 +10089,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RemoveTagsRequestDto"]; + 'application/json': components['schemas']['RemoveTagsRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -10073,7 +10109,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The link or volume does not exist. * - 2011: Only the owner can assign tags to photos. @@ -10084,7 +10120,7 @@ export interface operations { }; }; }; - "post_drive-volumes-{volumeID}-photos-share": { + 'post_drive-volumes-{volumeID}-photos-share': { parameters: { query?: never; header?: never; @@ -10104,7 +10140,7 @@ export interface operations { }; }; }; - "delete_drive-volumes-{volumeID}-photos-share-{shareID}": { + 'delete_drive-volumes-{volumeID}-photos-share-{shareID}': { parameters: { query?: never; header?: never; @@ -10119,17 +10155,17 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite": { + 'post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite': { parameters: { query?: never; header?: never; @@ -10141,18 +10177,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["FavoritePhotoRequestDto"]; + 'application/json': components['schemas']['FavoritePhotoRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["FavoritePhotoResponseDto"]; + 'application/json': components['schemas']['FavoritePhotoResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -10161,7 +10197,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The link or volume does not exist. * */ @@ -10171,7 +10207,7 @@ export interface operations { }; }; }; - "post_drive-volumes-{volumeID}-photos-duplicates": { + 'post_drive-volumes-{volumeID}-photos-duplicates': { parameters: { query?: never; header?: never; @@ -10182,23 +10218,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["FindDuplicatesInput"]; + 'application/json': components['schemas']['FindDuplicatesInput']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["FindDuplicatesOutputCollection"]; + 'application/json': components['schemas']['FindDuplicatesOutputCollection']; }; }; }; }; - "get_drive-photos-migrate-legacy": { + 'get_drive-photos-migrate-legacy': { parameters: { query?: never; header?: never; @@ -10210,21 +10246,21 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetMigrationStatusResponseDto"]; + 'application/json': components['schemas']['GetMigrationStatusResponseDto']; }; }; /** @description Accepted */ 202: { headers: { - "x-pm-code": 1002; + 'x-pm-code': 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AcceptedResponse"]; + 'application/json': components['schemas']['AcceptedResponse']; }; }; /** @description Unprocessable Entity */ @@ -10233,7 +10269,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: The link or volume does not exist. * */ @@ -10243,7 +10279,7 @@ export interface operations { }; }; }; - "post_drive-photos-migrate-legacy": { + 'post_drive-photos-migrate-legacy': { parameters: { query?: never; header?: never; @@ -10252,18 +10288,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["MigrateFromLegacyRequest"]; + 'application/json': components['schemas']['MigrateFromLegacyRequest']; }; }; responses: { /** @description Accepted */ 202: { headers: { - "x-pm-code": 1002; + 'x-pm-code': 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AcceptedResponse"]; + 'application/json': components['schemas']['AcceptedResponse']; }; }; /** @description Unprocessable Entity */ @@ -10272,7 +10308,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2500: Migration in progress * - 2501: Share not found @@ -10289,7 +10325,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Potential codes: * - 2032 @@ -10302,16 +10338,16 @@ export interface operations { }; }; }; - "get_drive-volumes-{volumeID}-photos": { + 'get_drive-volumes-{volumeID}-photos': { parameters: { query?: { - Desc?: components["schemas"]["ListPhotosParameters"]["Desc"]; - PageSize?: components["schemas"]["ListPhotosParameters"]["PageSize"]; + Desc?: components['schemas']['ListPhotosParameters']['Desc']; + PageSize?: components['schemas']['ListPhotosParameters']['PageSize']; /** @description The link ID of the last photo from the previous page when requesting secondary pages */ - PreviousPageLastLinkID?: components["schemas"]["ListPhotosParameters"]["PreviousPageLastLinkID"]; + PreviousPageLastLinkID?: components['schemas']['ListPhotosParameters']['PreviousPageLastLinkID']; /** @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) */ - MinimumCaptureTime?: components["schemas"]["ListPhotosParameters"]["MinimumCaptureTime"]; - Tag?: components["schemas"]["ListPhotosParameters"]["Tag"]; + MinimumCaptureTime?: components['schemas']['ListPhotosParameters']['MinimumCaptureTime']; + Tag?: components['schemas']['ListPhotosParameters']['Tag']; }; header?: never; path: { @@ -10324,16 +10360,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PhotoListingResponse"]; + 'application/json': components['schemas']['PhotoListingResponse']; }; }; }; }; - "put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr": { + 'put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr': { parameters: { query?: never; header?: never; @@ -10346,18 +10382,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateXAttrRequest"]; + 'application/json': components['schemas']['UpdateXAttrRequest']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -10366,7 +10402,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2001: Wrong signature email passed * - 2001: Invalid PGP message @@ -10382,7 +10418,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Potential codes: * - 2032 @@ -10395,7 +10431,7 @@ export interface operations { }; }; }; - "post_drive-urls-{token}-files-{linkID}-checkAvailableHashes": { + 'post_drive-urls-{token}-files-{linkID}-checkAvailableHashes': { parameters: { query?: never; header?: never; @@ -10407,18 +10443,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; + 'application/json': components['schemas']['CheckAvailableHashesRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AvailableHashesResponseDto"]; + 'application/json': components['schemas']['AvailableHashesResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -10427,7 +10463,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions * */ @@ -10437,31 +10473,31 @@ export interface operations { }; }; }; - "put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}": { + 'put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}': { parameters: { query?: never; header?: never; path: { token: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["CommitAnonymousRevisionDto"]; + 'application/json': components['schemas']['CommitAnonymousRevisionDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -10470,25 +10506,27 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 2011: The current ShareURL does not have read+write permissions. - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * */ - Code: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * */ + Code: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}": { + 'delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}': { parameters: { query?: never; header?: never; path: { token: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -10497,11 +10535,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -10510,19 +10548,21 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { - /** @description Potential codes and their meaning: - * - 2501: the link (must be active or trashed) or revision does not exist in the volume - * - 2511: if the revision not in draft - * - 200700: if the link is a proton doc (revisions are not used for docs) - * */ - Code?: number; - }; + 'application/json': + | components['schemas']['ShareConflictErrorResponseDto'] + | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2511: if the revision not in draft + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; }; }; }; }; - "post_drive-urls-{token}-documents": { + 'post_drive-urls-{token}-documents': { parameters: { query?: never; header?: never; @@ -10533,18 +10573,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateAnonymousDocumentDto"]; + 'application/json': components['schemas']['CreateAnonymousDocumentDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateAnonymousDocumentResponseDto"]; + 'application/json': components['schemas']['CreateAnonymousDocumentResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -10553,18 +10593,20 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** - * @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 2011: The current ShareURL does not have read+write permissions - * - * @enum {integer} - */ - Code: 200300 | 2500 | 2501 | 2011; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * + * @enum {integer} + */ + Code: 200300 | 2500 | 2501 | 2011; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; /** @description Failed dependency */ @@ -10573,7 +10615,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description Potential codes and their meaning: * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags @@ -10586,7 +10628,7 @@ export interface operations { }; }; }; - "post_drive-urls-{token}-files": { + 'post_drive-urls-{token}-files': { parameters: { query?: never; header?: never; @@ -10597,18 +10639,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateAnonymousFileRequestDto"]; + 'application/json': components['schemas']['CreateAnonymousFileRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateAnonymousFileResponseDto"]; + 'application/json': components['schemas']['CreateAnonymousFileResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -10617,24 +10659,26 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 2011: The current ShareURL does not have read+write permissions - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200701: A document type cannot create a revision - * - 200901: Photos backup is disabled for your account. Please enable it in the settings. - * */ - Code: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': + | { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * - 200901: Photos backup is disabled for your account. Please enable it in the settings. + * */ + Code: number; + } + | components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "post_drive-urls-{token}-folders": { + 'post_drive-urls-{token}-folders': { parameters: { query?: never; header?: never; @@ -10645,18 +10689,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateAnonymousFolderRequestDto"]; + 'application/json': components['schemas']['CreateAnonymousFolderRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateAnonymousFolderResponseDto"]; + 'application/json': components['schemas']['CreateAnonymousFolderResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -10665,7 +10709,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 200300: max folder size reached * - 200301: max folder depth reached @@ -10679,7 +10723,7 @@ export interface operations { }; }; }; - "post_drive-urls-{token}-folders-{linkID}-delete_multiple": { + 'post_drive-urls-{token}-folders-{linkID}-delete_multiple': { parameters: { query?: never; header?: never; @@ -10691,7 +10735,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["DeleteChildrenRequestDto"]; + 'application/json': components['schemas']['DeleteChildrenRequestDto']; }; }; responses: { @@ -10701,10 +10745,10 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; + Responses?: components['schemas']['MultiDeleteTransformer'][]; }; }; }; @@ -10714,7 +10758,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions * */ @@ -10724,7 +10768,7 @@ export interface operations { }; }; }; - "post_drive-urls-{token}-links-fetch_metadata": { + 'post_drive-urls-{token}-links-fetch_metadata': { parameters: { query?: never; header?: never; @@ -10736,18 +10780,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + 'application/json': components['schemas']['LinkIDsRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["FetchLinksMetadataResponseDto"]; + 'application/json': components['schemas']['FetchLinksMetadataResponseDto']; }; }; /** @description Unprocessable entity */ @@ -10756,7 +10800,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: This file was not found, token invalid. */ Code: number; @@ -10765,7 +10809,7 @@ export interface operations { }; }; }; - "get_drive-urls-{token}-links-{linkID}-path": { + 'get_drive-urls-{token}-links-{linkID}-path': { parameters: { query?: never; header?: never; @@ -10780,11 +10824,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ParentEncryptedLinkIDsResponseDto"]; + 'application/json': components['schemas']['ParentEncryptedLinkIDsResponseDto']; }; }; /** @description Unprocessable entity */ @@ -10793,7 +10837,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2061: Invalid ID. */ Code: number; @@ -10802,7 +10846,7 @@ export interface operations { }; }; }; - "put_drive-urls-{token}-links-{linkID}-rename": { + 'put_drive-urls-{token}-links-{linkID}-rename': { parameters: { query?: never; header?: never; @@ -10814,23 +10858,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RenameAnonymousLinkRequestDto"]; + 'application/json': components['schemas']['RenameAnonymousLinkRequestDto']; }; }; responses: { - 200: components["responses"]["ProtonSuccessResponse"]; + 200: components['responses']['ProtonSuccessResponse']; /** @description Conflict, a file or folder with the new name already exists in the current folder. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConflictErrorResponseDto"]; + 'application/json': components['schemas']['ConflictErrorResponseDto']; }; }; }; }; - "post_drive-urls-{token}-blocks": { + 'post_drive-urls-{token}-blocks': { parameters: { query?: never; header?: never; @@ -10841,18 +10885,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RequestAnonymousUploadRequestDto"]; + 'application/json': components['schemas']['RequestAnonymousUploadRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RequestUploadResponse"]; + 'application/json': components['schemas']['RequestUploadResponse']; }; }; /** @description Unprocessable Entity */ @@ -10861,7 +10905,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions * */ @@ -10871,14 +10915,14 @@ export interface operations { }; }; }; - "get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification": { + 'get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification': { parameters: { query?: never; header?: never; path: { token: string; linkID: string; - revisionID: components["schemas"]["Id"]; + revisionID: components['schemas']['Id']; }; cookie?: never; }; @@ -10887,11 +10931,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VerificationData"]; + 'application/json': components['schemas']['VerificationData']; }; }; /** @description Unprocessable Entity */ @@ -10900,7 +10944,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions * */ @@ -10910,11 +10954,11 @@ export interface operations { }; }; }; - "get_drive-volumes-{volumeID}-urls": { + 'get_drive-volumes-{volumeID}-urls': { parameters: { query?: { - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path: { @@ -10927,16 +10971,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ShareURLContextsCollection"]; + 'application/json': components['schemas']['ShareURLContextsCollection']; }; }; }; }; - "get_drive-shares-{shareID}-map": { + 'get_drive-shares-{shareID}-map': { parameters: { query?: { PageSize?: number; @@ -10956,16 +11000,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LinkMapResponse"]; + 'application/json': components['schemas']['LinkMapResponse']; }; }; }; }; - "get_drive-v2-shares-my-files": { + 'get_drive-v2-shares-my-files': { parameters: { query?: never; header?: never; @@ -10977,16 +11021,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MyFilesResponseDto"]; + 'application/json': components['schemas']['MyFilesResponseDto']; }; }; }; }; - "get_drive-shares-{shareID}": { + 'get_drive-shares-{shareID}': { parameters: { query?: never; header?: never; @@ -11000,16 +11044,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BootstrapShareResponseDto"]; + 'application/json': components['schemas']['BootstrapShareResponseDto']; }; }; }; }; - "delete_drive-shares-{shareID}": { + 'delete_drive-shares-{shareID}': { parameters: { query?: { /** @description Forces the deletion of the share along with attached members and urls */ @@ -11026,11 +11070,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -11039,7 +11083,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: the current user does not have admin permission on this share * - 2005: the share still has members, a public URL or invitations attached and Force=1 has not been used */ @@ -11049,7 +11093,7 @@ export interface operations { }; }; }; - "get_drive-volumes-{volumeID}-links-{linkID}-context": { + 'get_drive-volumes-{volumeID}-links-{linkID}-context': { parameters: { query?: never; header?: never; @@ -11064,11 +11108,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetHighestContextForDocumentResponse"]; + 'application/json': components['schemas']['GetHighestContextForDocumentResponse']; }; }; /** @description Unprocessable Entity */ @@ -11077,7 +11121,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** * @description 2501: Requested data does not exist or you do not have permission to access it * @@ -11089,7 +11133,7 @@ export interface operations { }; }; }; - "get_drive-sanitization-asv": { + 'get_drive-sanitization-asv': { parameters: { query?: never; header?: never; @@ -11101,16 +11145,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListAutoRestoreVolumeRootSharesResponseDto"]; + 'application/json': components['schemas']['ListAutoRestoreVolumeRootSharesResponseDto']; }; }; }; }; - "post_drive-sanitization-asv": { + 'post_drive-sanitization-asv': { parameters: { query?: never; header?: never; @@ -11119,23 +11163,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["LogFailedRestoreProcedureRequestDto"]; + 'application/json': components['schemas']['LogFailedRestoreProcedureRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "get_drive-shares": { + 'get_drive-shares': { parameters: { query?: { /** @description Encrypted AddressID */ @@ -11154,16 +11198,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListSharesResponseDto"]; + 'application/json': components['schemas']['ListSharesResponseDto']; }; }; }; }; - "post_drive-shares-{shareID}-owner": { + 'post_drive-shares-{shareID}-owner': { parameters: { query?: never; header?: never; @@ -11174,23 +11218,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["TransferInput"]; + 'application/json': components['schemas']['TransferInput']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "post_drive-migrations-shareaccesswithnode": { + 'post_drive-migrations-shareaccesswithnode': { parameters: { query?: never; header?: never; @@ -11199,23 +11243,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["MigrateSharesRequestDto"]; + 'application/json': components['schemas']['MigrateSharesRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MigrateSharesResponseDto"]; + 'application/json': components['schemas']['MigrateSharesResponseDto']; }; }; }; }; - "get_drive-migrations-shareaccesswithnode-unmigrated": { + 'get_drive-migrations-shareaccesswithnode-unmigrated': { parameters: { query?: never; header?: never; @@ -11227,16 +11271,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UnmigratedSharesResponseDto"]; + 'application/json': components['schemas']['UnmigratedSharesResponseDto']; }; }; }; }; - "get_drive-urls-{token}-info": { + 'get_drive-urls-{token}-info': { parameters: { query?: never; header?: never; @@ -11251,17 +11295,17 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InitSRPSessionResponseDto"]; + 'application/json': components['schemas']['InitSRPSessionResponseDto']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "post_drive-urls-{token}-auth": { + 'post_drive-urls-{token}-auth': { parameters: { query?: never; header?: never; @@ -11273,7 +11317,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["AuthShareTokenRequestDto"]; + 'application/json': components['schemas']['AuthShareTokenRequestDto']; }; }; responses: { @@ -11283,8 +11327,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; /** @description Session UID */ UID: string; /** @description Session Access token (present if new session) */ @@ -11304,10 +11348,10 @@ export interface operations { }; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "get_drive-urls-{token}": { + 'get_drive-urls-{token}': { parameters: { query?: never; header?: never; @@ -11321,21 +11365,21 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BootstrapShareTokenResponseDto"]; + 'application/json': components['schemas']['BootstrapShareTokenResponseDto']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "get_drive-urls-{token}-folders-{linkID}-children": { + 'get_drive-urls-{token}-folders-{linkID}-children': { parameters: { query?: { /** @description Field to sort by */ - Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; + Sort?: 'MIMEType' | 'Size' | 'ModifyTime' | 'CreateTime' | 'Type'; /** @description Sort order */ Desc?: 0 | 1; /** @description Show all files including those in non-active (drafts) state. */ @@ -11347,8 +11391,8 @@ export interface operations { * @description Get thumbnail download URLs */ Thumbnails?: 0 | 1; - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path: { @@ -11365,24 +11409,24 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Links: components["schemas"]["ExtendedLinkTransformer"][]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; + Links: components['schemas']['ExtendedLinkTransformer'][]; }; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "get_drive-urls-{token}-files-{linkID}": { + 'get_drive-urls-{token}-files-{linkID}': { parameters: { query?: { /** @description Number of blocks */ - PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + PageSize?: components['schemas']['GetRevisionQueryParameters']['PageSize']; /** @description Block index from which to fetch block list */ - FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; + FromBlockIndex?: components['schemas']['GetRevisionQueryParameters']['FromBlockIndex']; /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; + NoBlockUrls?: components['schemas']['GetRevisionQueryParameters']['NoBlockUrls']; }; header?: never; path: { @@ -11396,17 +11440,17 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetRevisionResponseDto"]; + 'application/json': components['schemas']['GetRevisionResponseDto']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "post_drive-urls-{token}-file": { + 'post_drive-urls-{token}-file': { parameters: { query?: never; header?: never; @@ -11418,32 +11462,32 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["GetSharedFileInfoRequestDto"]; + 'application/json': components['schemas']['GetSharedFileInfoRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetSharedFileInfoResponseDto"]; + 'application/json': components['schemas']['GetSharedFileInfoResponseDto']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "get_drive-shares-{shareID}-urls": { + 'get_drive-shares-{shareID}-urls': { parameters: { query?: { /** @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). */ Recursive?: 0 | 1; /** @description Fetch Thumbnail URLs */ Thumbnails?: 0 | 1; - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path: { @@ -11456,16 +11500,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListShareURLsResponseDto"]; + 'application/json': components['schemas']['ListShareURLsResponseDto']; }; }; }; }; - "post_drive-shares-{shareID}-urls": { + 'post_drive-shares-{shareID}-urls': { parameters: { query?: never; header?: never; @@ -11476,7 +11520,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateShareURLRequestDto"]; + 'application/json': components['schemas']['CreateShareURLRequestDto']; }; }; responses: { @@ -11486,27 +11530,27 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - ShareURL: components["schemas"]["ShareURLResponseDto"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; + ShareURL: components['schemas']['ShareURLResponseDto']; }; }; }; }; }; - "put_drive-shares-{shareID}-urls-{urlID}": { + 'put_drive-shares-{shareID}-urls-{urlID}': { parameters: { query?: never; header?: never; path: { shareID: string; - urlID: components["schemas"]["Id"]; + urlID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateShareURLRequestDto"]; + 'application/json': components['schemas']['UpdateShareURLRequestDto']; }; }; responses: { @@ -11516,22 +11560,22 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - ShareURL: components["schemas"]["ShareURLResponseDto"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; + ShareURL: components['schemas']['ShareURLResponseDto']; }; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "delete_drive-shares-{shareID}-urls-{urlID}": { + 'delete_drive-shares-{shareID}-urls-{urlID}': { parameters: { query?: never; header?: never; path: { shareID: string; - urlID: components["schemas"]["Id"]; + urlID: components['schemas']['Id']; }; cookie?: never; }; @@ -11540,16 +11584,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; }; }; - "post_drive-shares-{shareID}-urls-delete_multiple": { + 'post_drive-shares-{shareID}-urls-delete_multiple': { parameters: { query?: never; header?: never; @@ -11560,7 +11604,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["DeleteMultipleShareURLsRequestDto"]; + 'application/json': components['schemas']['DeleteMultipleShareURLsRequestDto']; }; }; responses: { @@ -11570,7 +11614,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @enum {integer} */ Code?: 1001; Responses?: { @@ -11586,7 +11630,7 @@ export interface operations { }; }; }; - "post_drive-volumes-{volumeID}-shares": { + 'post_drive-volumes-{volumeID}-shares': { parameters: { query?: never; header?: never; @@ -11597,7 +11641,7 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateShareRequestDto"]; + 'application/json': components['schemas']['CreateShareRequestDto']; }; }; responses: { @@ -11607,8 +11651,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; + 'application/json': { + Code: components['schemas']['ResponseCodeSuccess']; Share: { /** @description Share ID */ ID: string; @@ -11622,23 +11666,25 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { - /** @description Potential codes and their meaning: - * - 2501: the link does not exist in the volume - * - 2011: the current user does not have admin permission on this share - * - 2001: the PGP message is not correct - * - 200601: The user has too many shares already. - * */ - Code?: number; - }; + 'application/json': + | components['schemas']['ShareConflictErrorResponseDto'] + | { + /** @description Potential codes and their meaning: + * - 2501: the link does not exist in the volume + * - 2011: the current user does not have admin permission on this share + * - 2001: the PGP message is not correct + * - 200601: The user has too many shares already. + * */ + Code?: number; + }; }; }; }; }; - "get_drive-v2-volumes-{volumeID}-shares": { + 'get_drive-v2-volumes-{volumeID}-shares': { parameters: { query?: { - AnchorID?: components["schemas"]["Id"] | null; + AnchorID?: components['schemas']['Id'] | null; }; header?: never; path: { @@ -11651,19 +11697,19 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SharedByMeResponseDto"]; + 'application/json': components['schemas']['SharedByMeResponseDto']; }; }; }; }; - "get_drive-v2-sharedwithme": { + 'get_drive-v2-sharedwithme': { parameters: { query?: { - AnchorID?: components["schemas"]["Id"] | null; + AnchorID?: components['schemas']['Id'] | null; }; header?: never; path?: never; @@ -11674,39 +11720,39 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SharedWithMeResponseDto2"]; + 'application/json': components['schemas']['SharedWithMeResponseDto2']; }; }; }; }; - "put_drive-v2-shares-{shareID}-external-invitations-{invitationID}": { + 'put_drive-v2-shares-{shareID}-external-invitations-{invitationID}': { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateExternalInvitationRequestDto"]; + 'application/json': components['schemas']['UpdateExternalInvitationRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -11715,7 +11761,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist, is not pending or accepted * - 2011: the current user does not have admin permission on this share @@ -11727,13 +11773,13 @@ export interface operations { }; }; }; - "delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}": { + 'delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}': { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; @@ -11742,11 +11788,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -11755,7 +11801,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the external invitation does not exist, is not pending or accepted * - 2011: the current user does not have admin permission on this share @@ -11766,7 +11812,7 @@ export interface operations { }; }; }; - "get_drive-v2-shares-{shareID}-external-invitations": { + 'get_drive-v2-shares-{shareID}-external-invitations': { parameters: { query?: never; header?: never; @@ -11780,11 +11826,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListShareExternalInvitationsResponseDto"]; + 'application/json': components['schemas']['ListShareExternalInvitationsResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -11793,7 +11839,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the share does not exist * - 2011: the current user does not have admin permission on this share */ @@ -11803,7 +11849,7 @@ export interface operations { }; }; }; - "post_drive-v2-shares-{shareID}-external-invitations": { + 'post_drive-v2-shares-{shareID}-external-invitations': { parameters: { query?: never; header?: never; @@ -11814,18 +11860,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["InviteExternalUserRequestDto"]; + 'application/json': components['schemas']['InviteExternalUserRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InviteExternalUserResponseDto"]; + 'application/json': components['schemas']['InviteExternalUserResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -11834,7 +11880,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: the current user does not have admin permission on this share * - 2500: an external invitation for this user on this share already exists @@ -11849,10 +11895,10 @@ export interface operations { }; }; }; - "get_drive-v2-shares-external-invitations": { + 'get_drive-v2-shares-external-invitations': { parameters: { query?: { - AnchorID?: components["schemas"]["Id"] | null; + AnchorID?: components['schemas']['Id'] | null; PageSize?: number; }; header?: never; @@ -11864,22 +11910,22 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListUserRegisteredExternalInvitationResponseDto"]; + 'application/json': components['schemas']['ListUserRegisteredExternalInvitationResponseDto']; }; }; }; }; - "post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail": { + 'post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail': { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; @@ -11888,11 +11934,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -11901,7 +11947,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the external invitation does not exist, is not pending or rejected * - 2011: the current user does not have admin permission on this share @@ -11912,29 +11958,29 @@ export interface operations { }; }; }; - "post_drive-v2-shares-invitations-{invitationID}-accept": { + 'post_drive-v2-shares-invitations-{invitationID}-accept': { parameters: { query?: never; header?: never; path: { - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["AcceptInvitationRequestDto"]; + 'application/json': components['schemas']['AcceptInvitationRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -11943,7 +11989,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the share or the invitation was not found or was not pending * - 2011: the invitee email doesn't belong to the current user @@ -11959,30 +12005,30 @@ export interface operations { }; }; }; - "put_drive-v2-shares-{shareID}-invitations-{invitationID}": { + 'put_drive-v2-shares-{shareID}-invitations-{invitationID}': { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateInvitationRequestDto"]; + 'application/json': components['schemas']['UpdateInvitationRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -11991,7 +12037,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist, is not pending or rejected * - 2011: the current user does not have admin permission on this share @@ -12003,13 +12049,13 @@ export interface operations { }; }; }; - "delete_drive-v2-shares-{shareID}-invitations-{invitationID}": { + 'delete_drive-v2-shares-{shareID}-invitations-{invitationID}': { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; @@ -12018,11 +12064,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -12031,7 +12077,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist, is not pending or rejected * - 2011: the current user does not have admin permission on this share @@ -12042,7 +12088,7 @@ export interface operations { }; }; }; - "get_drive-v2-shares-{shareID}-invitations": { + 'get_drive-v2-shares-{shareID}-invitations': { parameters: { query?: never; header?: never; @@ -12056,11 +12102,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListShareInvitationsResponseDto"]; + 'application/json': components['schemas']['ListShareInvitationsResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -12069,7 +12115,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the share does not exist * - 2011: the current user does not have admin permission on this share */ @@ -12079,7 +12125,7 @@ export interface operations { }; }; }; - "post_drive-v2-shares-{shareID}-invitations": { + 'post_drive-v2-shares-{shareID}-invitations': { parameters: { query?: never; header?: never; @@ -12090,18 +12136,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["InviteUserRequestDto"]; + 'application/json': components['schemas']['InviteUserRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InviteUserResponseDto"]; + 'application/json': components['schemas']['InviteUserResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -12110,7 +12156,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the external invitation does not exists or is still pending * - 2011: the current user does not have admin permission on this share @@ -12130,12 +12176,12 @@ export interface operations { }; }; }; - "get_drive-v2-shares-invitations": { + 'get_drive-v2-shares-invitations': { parameters: { query?: { - AnchorID?: components["schemas"]["ListPendingInvitationQueryParameters"]["AnchorID"]; - PageSize?: components["schemas"]["ListPendingInvitationQueryParameters"]["PageSize"]; - ShareTargetTypes?: components["schemas"]["ListPendingInvitationQueryParameters"]["ShareTargetTypes"]; + AnchorID?: components['schemas']['ListPendingInvitationQueryParameters']['AnchorID']; + PageSize?: components['schemas']['ListPendingInvitationQueryParameters']['PageSize']; + ShareTargetTypes?: components['schemas']['ListPendingInvitationQueryParameters']['ShareTargetTypes']; }; header?: never; path?: never; @@ -12146,21 +12192,21 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListPendingInvitationResponseDto"]; + 'application/json': components['schemas']['ListPendingInvitationResponseDto']; }; }; }; }; - "post_drive-v2-shares-invitations-{invitationID}-reject": { + 'post_drive-v2-shares-invitations-{invitationID}-reject': { parameters: { query?: never; header?: never; path: { - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; @@ -12169,11 +12215,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -12182,7 +12228,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist or is not pending * - 2011: the invitee email doesn't belong to the current user @@ -12193,13 +12239,13 @@ export interface operations { }; }; }; - "post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail": { + 'post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail': { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; @@ -12208,11 +12254,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -12221,7 +12267,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist, is not pending or rejected * - 2011: the current user does not have admin permission on this share @@ -12233,12 +12279,12 @@ export interface operations { }; }; }; - "get_drive-v2-shares-invitations-{invitationID}": { + 'get_drive-v2-shares-invitations-{invitationID}': { parameters: { query?: never; header?: never; path: { - invitationID: components["schemas"]["Id"]; + invitationID: components['schemas']['Id']; }; cookie?: never; }; @@ -12247,11 +12293,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PendingInvitationResponseDto"]; + 'application/json': components['schemas']['PendingInvitationResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -12260,7 +12306,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist or is not pending, or the link/share/volume for it is gone * - 2011: the invitee email doesn't belong to the current user @@ -12271,12 +12317,12 @@ export interface operations { }; }; }; - "get_drive-v2-user-link-access": { + 'get_drive-v2-user-link-access': { parameters: { query?: { - LinkID?: components["schemas"]["Id"]; - VolumeID?: components["schemas"]["Id"] | null; - ShareID?: components["schemas"]["Id"] | null; + LinkID?: components['schemas']['Id']; + VolumeID?: components['schemas']['Id'] | null; + ShareID?: components['schemas']['Id'] | null; }; header?: never; path?: never; @@ -12287,16 +12333,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LinkAccessesResponseDto"]; + 'application/json': components['schemas']['LinkAccessesResponseDto']; }; }; }; }; - "get_drive-v2-shares-{shareID}-members": { + 'get_drive-v2-shares-{shareID}-members': { parameters: { query?: never; header?: never; @@ -12310,11 +12356,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListShareMembersResponseDto"]; + 'application/json': components['schemas']['ListShareMembersResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -12323,7 +12369,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the share does not exist * - 2011: the current user does not have admin permission on this share */ @@ -12333,7 +12379,7 @@ export interface operations { }; }; }; - "put_drive-v2-shares-{shareID}-members-{memberID}": { + 'put_drive-v2-shares-{shareID}-members-{memberID}': { parameters: { query?: never; header?: never; @@ -12345,18 +12391,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateShareMemberRequestDto"]; + 'application/json': components['schemas']['UpdateShareMemberRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -12365,7 +12411,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2501: the member does not exist or is removed. * - 2011: the current user does not have admin permission on this share @@ -12377,7 +12423,7 @@ export interface operations { }; }; }; - "delete_drive-v2-shares-{shareID}-members-{memberID}": { + 'delete_drive-v2-shares-{shareID}-members-{memberID}': { parameters: { query?: never; header?: never; @@ -12392,11 +12438,11 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Unprocessable Entity */ @@ -12405,7 +12451,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: the user does not have enough permission to remove another member * - 2501: the user is not a member of the share @@ -12416,7 +12462,7 @@ export interface operations { }; }; }; - "post_drive-v2-shares-{shareID}-security": { + 'post_drive-v2-shares-{shareID}-security': { parameters: { query?: never; header?: never; @@ -12427,18 +12473,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SecurityRequestDto"]; + 'application/json': components['schemas']['SecurityRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SecurityResponseDto"]; + 'application/json': components['schemas']['SecurityResponseDto']; }; }; /** @description Unprocessable Entity */ @@ -12447,7 +12493,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 2011: the current user does not have read permission on this share */ Code: number; @@ -12456,7 +12502,7 @@ export interface operations { }; }; }; - "post_drive-urls-{token}-security": { + 'post_drive-urls-{token}-security': { parameters: { query?: never; header?: never; @@ -12467,18 +12513,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["SecurityRequestDto"]; + 'application/json': components['schemas']['SecurityRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SecurityResponseDto"]; + 'application/json': components['schemas']['SecurityResponseDto']; }; }; /** @description Code 2028 if feature is disabled, rate-limited or blocked because of abuse. Code 9001 for HV captcha. */ @@ -12487,12 +12533,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProtonError"]; + 'application/json': components['schemas']['ProtonError']; }; }; }; }; - "post_drive-volumes-{volumeID}-thumbnails": { + 'post_drive-volumes-{volumeID}-thumbnails': { parameters: { query?: never; header?: never; @@ -12503,23 +12549,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["ThumbnailIDsListInput"]; + 'application/json': components['schemas']['ThumbnailIDsListInput']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListThumbnailsResponse"]; + 'application/json': components['schemas']['ListThumbnailsResponse']; }; }; }; }; - "get_drive-me-settings": { + 'get_drive-me-settings': { parameters: { query?: never; header?: never; @@ -12531,16 +12577,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SettingsResponse"]; + 'application/json': components['schemas']['SettingsResponse']; }; }; }; }; - "put_drive-me-settings": { + 'put_drive-me-settings': { parameters: { query?: never; header?: never; @@ -12549,18 +12595,18 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["UserSettingsRequest"]; + 'application/json': components['schemas']['UserSettingsRequest']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SettingsResponse"]; + 'application/json': components['schemas']['SettingsResponse']; }; }; /** @description Unprocessable Entity */ @@ -12569,7 +12615,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { + 'application/json': { /** @description Potential codes and their meaning: * - 200900: Photos cannot be disabled. There is data in your Photos section. * */ @@ -12579,11 +12625,11 @@ export interface operations { }; }; }; - "get_drive-volumes": { + 'get_drive-volumes': { parameters: { query?: { - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; + Page?: components['schemas']['OffsetPagination']['Page'] & unknown; }; header?: never; path?: never; @@ -12594,16 +12640,16 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListVolumesResponseDto"]; + 'application/json': components['schemas']['ListVolumesResponseDto']; }; }; }; }; - "post_drive-volumes": { + 'post_drive-volumes': { parameters: { query?: never; header?: never; @@ -12612,23 +12658,23 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["CreateVolumeRequestDto"]; + 'application/json': components['schemas']['CreateVolumeRequestDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetVolumeResponseDto"]; + 'application/json': components['schemas']['GetVolumeResponseDto']; }; }; }; }; - "put_drive-volumes-{volumeID}-delete_locked": { + 'put_drive-volumes-{volumeID}-delete_locked': { parameters: { query?: never; header?: never; @@ -12642,17 +12688,17 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "get_drive-volumes-{volumeID}": { + 'get_drive-volumes-{volumeID}': { parameters: { query?: never; header?: never; @@ -12666,17 +12712,17 @@ export interface operations { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetVolumeResponseDto"]; + 'application/json': components['schemas']['GetVolumeResponseDto']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; - "put_drive-volumes-{volumeID}-restore": { + 'put_drive-volumes-{volumeID}-restore': { parameters: { query?: never; header?: never; @@ -12687,31 +12733,31 @@ export interface operations { }; requestBody?: { content: { - "application/json": components["schemas"]["RestoreVolumeDto"]; + 'application/json': components['schemas']['RestoreVolumeDto']; }; }; responses: { /** @description Success */ 200: { headers: { - "x-pm-code": 1000; + 'x-pm-code': 1000; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + 'application/json': components['schemas']['SuccessfulResponse']; }; }; /** @description Accepted */ 202: { headers: { - "x-pm-code": 1002; + 'x-pm-code': 1002; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AcceptedResponse"]; + 'application/json': components['schemas']['AcceptedResponse']; }; }; - 422: components["responses"]["ProtonErrorResponse"]; + 422: components['responses']['ProtonErrorResponse']; }; }; } diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index a67bb034..05b9d741 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -22,16 +22,16 @@ export const enum ErrorCode { // Following codes takes name from the API documentation. ALREADY_EXISTS = 2500, NOT_EXISTS = 2501, - INSUFFICIENT_QUOTA = 200001, + INSUFFICIENT_QUOTA = 200001, INSUFFICIENT_SPACE = 200002, MAX_FILE_SIZE_FOR_FREE_USER = 200003, MAX_PUBLIC_EDIT_MODE_FOR_FREE_USER = 200004, - INSUFFICIENT_VOLUME_QUOTA= 200100, - INSUFFICIENT_DEVICE_QUOTA= 200101, + INSUFFICIENT_VOLUME_QUOTA = 200100, + INSUFFICIENT_DEVICE_QUOTA = 200101, ALREADY_MEMBER_OF_SHARE_IN_VOLUME_WITH_ANOTHER_ADDRESS = 200201, TOO_MANY_CHILDREN = 200300, NESTING_TOO_DEEP = 200301, - INSUFFICIENT_INVITATION_QUOTA = 200600, + INSUFFICIENT_INVITATION_QUOTA = 200600, INSUFFICIENT_SHARE_QUOTA = 200601, INSUFFICIENT_SHARE_JOINED_QUOTA = 200602, INSUFFICIENT_BOOKMARKS_QUOTA = 200800, diff --git a/js/sdk/src/internal/apiService/errors.test.ts b/js/sdk/src/internal/apiService/errors.test.ts index f4e0c26c..c8c2ea55 100644 --- a/js/sdk/src/internal/apiService/errors.test.ts +++ b/js/sdk/src/internal/apiService/errors.test.ts @@ -1,19 +1,14 @@ -import { apiErrorFactory } from "./errors"; -import * as errors from "./errors"; +import { apiErrorFactory } from './errors'; +import * as errors from './errors'; import { ErrorCode } from './errorCodes'; function mockAPIResponseAndResult(options: { - httpStatusCode?: number, - httpStatusText?: string, - code: number, - message?: string, + httpStatusCode?: number; + httpStatusText?: string; + code: number; + message?: string; }) { - const { - httpStatusCode = 422, - httpStatusText = 'Unprocessable Entity', - code, - message = 'API error', - } = options; + const { httpStatusCode = 422, httpStatusText = 'Unprocessable Entity', code, message = 'API error' } = options; const result = { Code: code, Error: message }; const response = new Response(JSON.stringify(result), { status: httpStatusCode, statusText: httpStatusText }); @@ -21,43 +16,50 @@ function mockAPIResponseAndResult(options: { return { response, result }; } -describe("apiErrorFactory should return", () => { - it("generic APIHTTPError when there is no specifc body", () => { +describe('apiErrorFactory should return', () => { + it('generic APIHTTPError when there is no specifc body', () => { const response = new Response('', { status: 404, statusText: 'Not found' }); const error = apiErrorFactory({ response }); expect(error).toBeInstanceOf(errors.APIHTTPError); - expect(error.message).toBe("Not found"); + expect(error.message).toBe('Not found'); expect((error as errors.APIHTTPError).statusCode).toBe(404); }); - it("generic APIHTTPError with generic message when there is no specifc statusText", () => { + it('generic APIHTTPError with generic message when there is no specifc statusText', () => { const response = new Response('', { status: 404, statusText: '' }); const error = apiErrorFactory({ response }); expect(error).toBeInstanceOf(errors.APIHTTPError); - expect(error.message).toBe("Unknown error"); + expect(error.message).toBe('Unknown error'); expect((error as errors.APIHTTPError).statusCode).toBe(404); }); - it("generic APIHTTPError when there 404 both in status code and body code", () => { - const error = apiErrorFactory(mockAPIResponseAndResult({ httpStatusCode: 404, httpStatusText: 'Path not found', code: 404, message: 'Not found' })); + it('generic APIHTTPError when there 404 both in status code and body code', () => { + const error = apiErrorFactory( + mockAPIResponseAndResult({ + httpStatusCode: 404, + httpStatusText: 'Path not found', + code: 404, + message: 'Not found', + }), + ); expect(error).toBeInstanceOf(errors.APIHTTPError); - expect(error.message).toBe("Path not found"); + expect(error.message).toBe('Path not found'); expect((error as errors.APIHTTPError).statusCode).toBe(404); }); - it("generic APICodeError when there is body even if wrong", () => { + it('generic APICodeError when there is body even if wrong', () => { const result = {}; const response = new Response('', { status: 422 }); const error = apiErrorFactory({ response, result }); expectAPICodeError(error, 0, 'Unknown error'); }); - it("generic APICodeError when there is body but not specific handle", () => { + it('generic APICodeError when there is body but not specific handle', () => { const error = apiErrorFactory(mockAPIResponseAndResult({ code: 42, message: 'General error' })); expectAPICodeError(error, 42, 'General error'); }); - it("NotFoundAPIError when code is ErrorCode.NOT_EXISTS", () => { + it('NotFoundAPIError when code is ErrorCode.NOT_EXISTS', () => { const error = apiErrorFactory(mockAPIResponseAndResult({ code: ErrorCode.NOT_EXISTS, message: 'Not found' })); expect(error).toBeInstanceOf(errors.NotFoundAPIError); expectAPICodeError(error, ErrorCode.NOT_EXISTS, 'Not found'); diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index f5f1b6e5..9d4094b7 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -3,7 +3,7 @@ import { c } from 'ttag'; import { ServerError, ValidationError } from '../../errors'; import { ErrorCode, HTTPErrorCode } from './errorCodes'; -export function apiErrorFactory({ response, result }: { response: Response, result?: unknown }): ServerError { +export function apiErrorFactory({ response, result }: { response: Response; result?: unknown }): ServerError { // Backend responses with 404 both in the response and body code. // In such a case we want to stick to APIHTTPError to be very clear // it is not NotFoundAPIError. @@ -22,15 +22,21 @@ export function apiErrorFactory({ response, result }: { response: Response, resu trace?: object; }; - const [code, message, details] = [typedResult.Code || 0, typedResult.Error || c('Error').t`Unknown error`, typedResult.Details]; + const [code, message, details] = [ + typedResult.Code || 0, + typedResult.Error || c('Error').t`Unknown error`, + typedResult.Details, + ]; - const debug = typedResult.exception ? { - exception: typedResult.exception, - message: typedResult.message, - file: typedResult.file, - line: typedResult.line, - trace: typedResult.trace, - } : undefined; + const debug = typedResult.exception + ? { + exception: typedResult.exception, + message: typedResult.message, + file: typedResult.file, + line: typedResult.line, + trace: typedResult.trace, + } + : undefined; switch (code) { case ErrorCode.NOT_EXISTS: diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 1d00815e..eab4ccb3 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -3,5 +3,5 @@ export type { paths as drivePaths } from './driveTypes'; export type { paths as corePaths } from './coreTypes'; export { HTTPErrorCode, ErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; export { nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from './transformers'; -export { ObserverStream } from './observerStream'; +export { ObserverStream } from './observerStream'; export * from './errors'; diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts index 644bfbb8..6c9d2344 100644 --- a/js/sdk/src/internal/apiService/transformers.ts +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -1,4 +1,4 @@ -import { Logger, NodeType, MemberRole } from "../../interface"; +import { Logger, NodeType, MemberRole } from '../../interface'; export function nodeTypeNumberToNodeType(logger: Logger, nodeTypeNumber: number): NodeType { switch (nodeTypeNumber) { @@ -34,7 +34,7 @@ export function permissionsToDirectMemberRole(logger: Logger, permissionsNumber? export function memberRoleToPermission(memberRole: MemberRole): 4 | 6 | 22 { if (memberRole === MemberRole.Inherited) { // This is developer error. - throw new Error("Cannot convert inherited role to permission"); + throw new Error('Cannot convert inherited role to permission'); } switch (memberRole) { case MemberRole.Viewer: diff --git a/js/sdk/src/internal/asyncIteratorMap.test.ts b/js/sdk/src/internal/asyncIteratorMap.test.ts index b81417df..185e0a57 100644 --- a/js/sdk/src/internal/asyncIteratorMap.test.ts +++ b/js/sdk/src/internal/asyncIteratorMap.test.ts @@ -52,7 +52,7 @@ describe('asyncIteratorMap', () => { const inputGen = createAsyncGenerator(Object.keys(delays).map(Number)); const slowMapper = async (x: number) => { - await new Promise(resolve => setTimeout(resolve, delays[x])); + await new Promise((resolve) => setTimeout(resolve, delays[x])); return x * 2; }; @@ -71,11 +71,11 @@ describe('asyncIteratorMap', () => { }); test('handles errors from input iterator properly', async () => { - const throwingInputGen = async function*() { + const throwingInputGen = async function* () { yield 1; yield 2; throw new Error('Error providing value: 3'); - } + }; const mapper = async (x: number) => x * 2; @@ -134,7 +134,7 @@ describe('asyncIteratorMap', () => { maxConcurrentExecutions = Math.max(maxConcurrentExecutions, concurrentExecutions); // Wait for 100ms to simulate work - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); concurrentExecutions--; return x * 2; diff --git a/js/sdk/src/internal/asyncIteratorMap.ts b/js/sdk/src/internal/asyncIteratorMap.ts index 400b11be..dedb5fde 100644 --- a/js/sdk/src/internal/asyncIteratorMap.ts +++ b/js/sdk/src/internal/asyncIteratorMap.ts @@ -8,7 +8,7 @@ const DEFAULT_CONCURRENCY = 10; * * Any error from the input iterator or the mapper function is propagated * to the output iterator. - * + * * @param inputIterator - The input async iterator. * @param mapper - The mapper function that maps the input values to output values. * @param concurrency - The concurrency limit. How many parallel async mapper calls are allowed. diff --git a/js/sdk/src/internal/batchLoading.test.ts b/js/sdk/src/internal/batchLoading.test.ts index 37124e8e..5f5f3f8d 100644 --- a/js/sdk/src/internal/batchLoading.test.ts +++ b/js/sdk/src/internal/batchLoading.test.ts @@ -1,19 +1,19 @@ import { BatchLoading } from './batchLoading'; -describe("BatchLoading", () => { +describe('BatchLoading', () => { let batchLoading: BatchLoading; beforeEach(() => { jest.clearAllMocks(); }); - it("should load in batches with loadItems", async () => { + it('should load in batches with loadItems', async () => { const loadItems = jest.fn((items: string[]) => Promise.resolve(items.map((item) => `loaded:${item}`))); batchLoading = new BatchLoading({ loadItems, batchSize: 2 }); const result = []; - for (const item of ["a", "b", "c", "d", "e"]) { + for (const item of ['a', 'b', 'c', 'd', 'e']) { for await (const loadedItem of batchLoading.load(item)) { result.push(loadedItem); } @@ -22,15 +22,14 @@ describe("BatchLoading", () => { result.push(loadedItem); } - - expect(result).toEqual(["loaded:a", "loaded:b", "loaded:c", "loaded:d", "loaded:e"]); + expect(result).toEqual(['loaded:a', 'loaded:b', 'loaded:c', 'loaded:d', 'loaded:e']); expect(loadItems).toHaveBeenCalledTimes(3); - expect(loadItems).toHaveBeenNthCalledWith(1, ["a", "b"]); - expect(loadItems).toHaveBeenNthCalledWith(2, ["c", "d"]); - expect(loadItems).toHaveBeenNthCalledWith(3, ["e"]); + expect(loadItems).toHaveBeenNthCalledWith(1, ['a', 'b']); + expect(loadItems).toHaveBeenNthCalledWith(2, ['c', 'd']); + expect(loadItems).toHaveBeenNthCalledWith(3, ['e']); }); - it("should load in batches with iterateItems", async () => { + it('should load in batches with iterateItems', async () => { const iterateItems = jest.fn(async function* (items: string[]) { for (const item of items) { yield `loaded:${item}`; @@ -40,7 +39,7 @@ describe("BatchLoading", () => { batchLoading = new BatchLoading({ iterateItems, batchSize: 2 }); const result = []; - for (const item of ["a", "b", "c", "d", "e"]) { + for (const item of ['a', 'b', 'c', 'd', 'e']) { for await (const loadedItem of batchLoading.load(item)) { result.push(loadedItem); } @@ -49,10 +48,10 @@ describe("BatchLoading", () => { result.push(loadedItem); } - expect(result).toEqual(["loaded:a", "loaded:b", "loaded:c", "loaded:d", "loaded:e"]); + expect(result).toEqual(['loaded:a', 'loaded:b', 'loaded:c', 'loaded:d', 'loaded:e']); expect(iterateItems).toHaveBeenCalledTimes(3); - expect(iterateItems).toHaveBeenNthCalledWith(1, ["a", "b"]); - expect(iterateItems).toHaveBeenNthCalledWith(2, ["c", "d"]); - expect(iterateItems).toHaveBeenNthCalledWith(3, ["e"]); + expect(iterateItems).toHaveBeenNthCalledWith(1, ['a', 'b']); + expect(iterateItems).toHaveBeenNthCalledWith(2, ['c', 'd']); + expect(iterateItems).toHaveBeenNthCalledWith(3, ['e']); }); }); diff --git a/js/sdk/src/internal/batchLoading.ts b/js/sdk/src/internal/batchLoading.ts index 61b86954..fc54d08b 100644 --- a/js/sdk/src/internal/batchLoading.ts +++ b/js/sdk/src/internal/batchLoading.ts @@ -2,14 +2,14 @@ const DEFAULT_BATCH_LOADING = 10; /** * Helper class for batch loading items. - * + * * The class is responsible for fetching items in batches. Any call to * `load` will add the item to the batch (without fetching anything), * and if the batch reaches the limit, it will fetch the items and yield * them transparently to the caller. - * + * * Example: - * + * * ```typescript * const batchLoading = new BatchLoading({ loadItems: loadNodesCallback }); * for (const nodeUid of nodeUids) { @@ -29,19 +29,19 @@ export class BatchLoading { private itemsToFetch: ID[]; constructor(options: { - loadItems?: (ids: ID[]) => Promise, - iterateItems?: (ids: ID[]) => AsyncGenerator, - batchSize?: number, + loadItems?: (ids: ID[]) => Promise; + iterateItems?: (ids: ID[]) => AsyncGenerator; + batchSize?: number; }) { this.itemsToFetch = []; - + if (options.loadItems) { const loadItems = options.loadItems; this.iterateItems = async function* (ids: ID[]) { for (const item of await loadItems(ids)) { yield item; } - } + }; } else if (options.iterateItems) { this.iterateItems = options.iterateItems; } else { diff --git a/js/sdk/src/internal/devices/apiService.ts b/js/sdk/src/internal/devices/apiService.ts index ce770486..f6aa9c57 100644 --- a/js/sdk/src/internal/devices/apiService.ts +++ b/js/sdk/src/internal/devices/apiService.ts @@ -1,19 +1,26 @@ -import { DeviceType } from "../../interface"; -import { DriveAPIService, drivePaths } from "../apiService"; -import { makeDeviceUid, makeNodeUid, splitDeviceUid } from "../uids"; -import { DeviceMetadata } from "./interface"; +import { DeviceType } from '../../interface'; +import { DriveAPIService, drivePaths } from '../apiService'; +import { makeDeviceUid, makeNodeUid, splitDeviceUid } from '../uids'; +import { DeviceMetadata } from './interface'; type GetDevicesResponse = drivePaths['/drive/devices']['get']['responses']['200']['content']['application/json']; -type PostCreateDeviceRequest = Extract['content']['application/json']; +type PostCreateDeviceRequest = Extract< + drivePaths['/drive/devices']['post']['requestBody'], + { content: object } +>['content']['application/json']; type PostCreateDeviceResponse = drivePaths['/drive/devices']['post']['responses']['200']['content']['application/json']; -type PutUpdateDeviceRequest = Extract['content']['application/json']; -type PutUpdateDeviceResponse = drivePaths['/drive/devices/{deviceID}']['put']['responses']['200']['content']['application/json']; +type PutUpdateDeviceRequest = Extract< + drivePaths['/drive/devices/{deviceID}']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PutUpdateDeviceResponse = + drivePaths['/drive/devices/{deviceID}']['put']['responses']['200']['content']['application/json']; /** * Provides API communication for managing devices. - * + * * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ @@ -28,8 +35,8 @@ export class DevicesAPIService { uid: makeDeviceUid(device.Device.VolumeID, device.Device.DeviceID), type: deviceTypeNumberToEnum(device.Device.Type), rootFolderUid: makeNodeUid(device.Device.VolumeID, device.Share.LinkID), - creationTime: new Date(device.Device.CreateTime*1000), - lastSyncTime: device.Device.LastSyncTime ? new Date(device.Device.LastSyncTime*1000) : undefined, + creationTime: new Date(device.Device.CreateTime * 1000), + lastSyncTime: device.Device.LastSyncTime ? new Date(device.Device.LastSyncTime * 1000) : undefined, hasDeprecatedName: !!device.Share.Name, /** @deprecated to be removed once Volume-based navigation is implemented in web */ shareId: device.Share.ShareID, @@ -48,56 +55,56 @@ export class DevicesAPIService { // Web clients do not update Device fields, that is only for desktop clients. Omit, PutUpdateDeviceResponse - >( - `drive/devices/${deviceId}`, - { - Share: { Name: "" }, - }, - ); + >(`drive/devices/${deviceId}`, { + Share: { Name: '' }, + }); } async createDevice( device: { - volumeId: string, - type: DeviceType, + volumeId: string; + type: DeviceType; }, share: { - addressId: string, - addressKeyId: string, - armoredKey: string, - armoredSharePassphrase: string, - armoredSharePassphraseSignature: string, + addressId: string; + addressKeyId: string; + armoredKey: string; + armoredSharePassphrase: string; + armoredSharePassphraseSignature: string; }, node: { - encryptedName: string, - armoredKey: string, - armoredNodePassphrase: string, - armoredNodePassphraseSignature: string, - armoredHashKey: string, - } + encryptedName: string; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + armoredHashKey: string; + }, ): Promise { - const response = await this.apiService.post('drive/devices', { - // @ts-expect-error VolumeID is deprecated. - Device: { - Type: deviceTypeEnumToNumber(device.type), - SyncState: 0, - }, - // @ts-expect-error Name is deprecated. - Share: { - AddressID: share.addressId, - AddressKeyID: share.addressKeyId, - Key: share.armoredKey, - Passphrase: share.armoredSharePassphrase, - PassphraseSignature: share.armoredSharePassphraseSignature, + const response = await this.apiService.post( + 'drive/devices', + { + // @ts-expect-error VolumeID is deprecated. + Device: { + Type: deviceTypeEnumToNumber(device.type), + SyncState: 0, + }, + // @ts-expect-error Name is deprecated. + Share: { + AddressID: share.addressId, + AddressKeyID: share.addressKeyId, + Key: share.armoredKey, + Passphrase: share.armoredSharePassphrase, + PassphraseSignature: share.armoredSharePassphraseSignature, + }, + Link: { + Name: node.encryptedName, + NodeKey: node.armoredKey, + NodePassphrase: node.armoredNodePassphrase, + NodePassphraseSignature: node.armoredNodePassphraseSignature, + NodeHashKey: node.armoredHashKey, + }, }, - Link: { - Name: node.encryptedName, - NodeKey: node.armoredKey, - NodePassphrase: node.armoredNodePassphrase, - NodePassphraseSignature: node.armoredNodePassphraseSignature, - NodeHashKey: node.armoredHashKey, - } - }); + ); return { uid: makeDeviceUid(device.volumeId, response.Device.DeviceID), @@ -106,7 +113,7 @@ export class DevicesAPIService { creationTime: new Date(), hasDeprecatedName: false, shareId: response.Device.ShareID, - } + }; } async deleteDevice(deviceUid: string): Promise { diff --git a/js/sdk/src/internal/devices/cryptoService.ts b/js/sdk/src/internal/devices/cryptoService.ts index 0249dfd2..d6f88808 100644 --- a/js/sdk/src/internal/devices/cryptoService.ts +++ b/js/sdk/src/internal/devices/cryptoService.ts @@ -15,30 +15,35 @@ export class DevicesCryptoService { async createDevice(deviceName: string): Promise<{ address: { - addressId: string, - addressKeyId: string, - }, + addressId: string; + addressKeyId: string; + }; shareKey: { - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, - }, + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; node: { key: { - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, - }, - encryptedName: string, - armoredHashKey: string, - } + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; + encryptedName: string; + armoredHashKey: string; + }; }> { const address = await this.sharesService.getMyFilesShareMemberEmailKey(); const addressKey = address.addressKey; const shareKey = await this.driveCrypto.generateKey([addressKey], addressKey); const rootNodeKey = await this.driveCrypto.generateKey([shareKey.decrypted.key], addressKey); - const { armoredNodeName } = await this.driveCrypto.encryptNodeName(deviceName, undefined, shareKey.decrypted.key, addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName( + deviceName, + undefined, + shareKey.decrypted.key, + addressKey, + ); const { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKey.decrypted.key); return { @@ -59,7 +64,7 @@ export class DevicesCryptoService { }, encryptedName: armoredNodeName, armoredHashKey, - } + }, }; - }; + } } diff --git a/js/sdk/src/internal/devices/index.ts b/js/sdk/src/internal/devices/index.ts index 16a88835..b9d49ecc 100644 --- a/js/sdk/src/internal/devices/index.ts +++ b/js/sdk/src/internal/devices/index.ts @@ -1,17 +1,17 @@ -import { DriveCrypto } from "../../crypto"; -import { ProtonDriveTelemetry } from "../../interface"; -import { DriveAPIService } from "../apiService"; -import { DevicesAPIService } from "./apiService"; -import { DevicesCryptoService } from "./cryptoService"; -import { SharesService, NodesService, NodesManagementService } from "./interface"; -import { DevicesManager } from "./manager"; +import { DriveCrypto } from '../../crypto'; +import { ProtonDriveTelemetry } from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { DevicesAPIService } from './apiService'; +import { DevicesCryptoService } from './cryptoService'; +import { SharesService, NodesService, NodesManagementService } from './interface'; +import { DevicesManager } from './manager'; /** * Provides facade for the whole devices module. - * + * * The devices module is responsible for handling devices metadata, including * API communication, encryption, decryption, caching, and event handling. - * + * * This facade provides internal interface that other modules can use to * interact with the devices. */ @@ -25,7 +25,14 @@ export function initDevicesModule( ) { const api = new DevicesAPIService(apiService); const cryptoService = new DevicesCryptoService(driveCrypto, sharesService); - const manager = new DevicesManager(telemetry.getLogger('devices'), api, cryptoService, sharesService, nodesService, nodesManagementService); + const manager = new DevicesManager( + telemetry.getLogger('devices'), + api, + cryptoService, + sharesService, + nodesService, + nodesManagementService, + ); return manager; } diff --git a/js/sdk/src/internal/devices/interface.ts b/js/sdk/src/internal/devices/interface.ts index 0bad7244..11bc1dc7 100644 --- a/js/sdk/src/internal/devices/interface.ts +++ b/js/sdk/src/internal/devices/interface.ts @@ -1,20 +1,25 @@ -import { PrivateKey } from "../../crypto"; -import { DeviceType, MissingNode } from "../../interface"; -import { DecryptedNode } from "../nodes"; +import { PrivateKey } from '../../crypto'; +import { DeviceType, MissingNode } from '../../interface'; +import { DecryptedNode } from '../nodes'; export type DeviceMetadata = { - uid: string, - type: DeviceType - rootFolderUid: string, - creationTime: Date, + uid: string; + type: DeviceType; + rootFolderUid: string; + creationTime: Date; lastSyncTime?: Date; hasDeprecatedName: boolean; shareId: string; -} +}; export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string }>; - getMyFilesShareMemberEmailKey(): Promise<{ addressId: string, email: string, addressKey: PrivateKey, addressKeyId: string }>, + getMyFilesShareMemberEmailKey(): Promise<{ + addressId: string; + email: string; + addressKey: PrivateKey; + addressKeyId: string; + }>; } export interface NodesService { @@ -22,7 +27,11 @@ export interface NodesService { } export interface NodesManagementService { - renameNode(nodeUid: string, newName: string, options: { - allowRenameRootNode: boolean, - }): Promise; + renameNode( + nodeUid: string, + newName: string, + options: { + allowRenameRootNode: boolean; + }, + ): Promise; } diff --git a/js/sdk/src/internal/devices/manager.test.ts b/js/sdk/src/internal/devices/manager.test.ts index cc42f8e5..8adc217a 100644 --- a/js/sdk/src/internal/devices/manager.test.ts +++ b/js/sdk/src/internal/devices/manager.test.ts @@ -53,9 +53,26 @@ describe('DevicesManager', () => { const name = 'Test Device'; const deviceType = DeviceType.Linux; const address = { addressId: 'address123', addressKeyId: 'key123' }; - const shareKey = { armoredKey: 'armoredKey', armoredPassphrase: 'passphrase', armoredPassphraseSignature: 'signature' }; - const node = { encryptedName: 'encryptedName', key: { armoredKey: 'nodeKey', armoredPassphrase: 'nodePassphrase', armoredPassphraseSignature: 'nodeSignature' }, armoredHashKey: 'hashKey' }; - const createdDevice = { uid: 'device123', rootFolderUid: 'rootFolder123', type: deviceType, shareId: 'shareid' } as DeviceMetadata; + const shareKey = { + armoredKey: 'armoredKey', + armoredPassphrase: 'passphrase', + armoredPassphraseSignature: 'signature', + }; + const node = { + encryptedName: 'encryptedName', + key: { + armoredKey: 'nodeKey', + armoredPassphrase: 'nodePassphrase', + armoredPassphraseSignature: 'nodeSignature', + }, + armoredHashKey: 'hashKey', + }; + const createdDevice = { + uid: 'device123', + rootFolderUid: 'rootFolder123', + type: deviceType, + shareId: 'shareid', + } as DeviceMetadata; sharesService.getMyFilesIDs.mockResolvedValue({ volumeId }); cryptoService.createDevice.mockResolvedValue({ address, shareKey, node }); @@ -80,7 +97,7 @@ describe('DevicesManager', () => { armoredNodePassphrase: node.key.armoredPassphrase, armoredNodePassphraseSignature: node.key.armoredPassphraseSignature, armoredHashKey: node.armoredHashKey, - } + }, ); expect(result).toEqual({ ...createdDevice, name: { ok: true, value: name } }); }); @@ -88,7 +105,12 @@ describe('DevicesManager', () => { it('renames device with deprecated name', async () => { const deviceUid = 'device123'; const name = 'New Device Name'; - const device = { uid: deviceUid, rootFolderUid: 'rootFolder123', hasDeprecatedName: true, shareId: 'shareid' } as DeviceMetadata; + const device = { + uid: deviceUid, + rootFolderUid: 'rootFolder123', + hasDeprecatedName: true, + shareId: 'shareid', + } as DeviceMetadata; apiService.getDevices.mockResolvedValue([device]); @@ -96,14 +118,21 @@ describe('DevicesManager', () => { expect(apiService.getDevices).toHaveBeenCalled(); expect(apiService.removeNameFromDevice).toHaveBeenCalledWith(deviceUid); - expect(nodesManagementService.renameNode).toHaveBeenCalledWith(device.rootFolderUid, name, { allowRenameRootNode: true }); + expect(nodesManagementService.renameNode).toHaveBeenCalledWith(device.rootFolderUid, name, { + allowRenameRootNode: true, + }); expect(result).toEqual({ ...device, name: { ok: true, value: name } }); }); it('renames device without deprecated name', async () => { const deviceUid = 'device123'; const name = 'New Device Name'; - const device = { uid: deviceUid, rootFolderUid: 'rootFolder123', hasDeprecatedName: false, shareId: 'shareid' } as DeviceMetadata; + const device = { + uid: deviceUid, + rootFolderUid: 'rootFolder123', + hasDeprecatedName: false, + shareId: 'shareid', + } as DeviceMetadata; apiService.getDevices.mockResolvedValue([device]); @@ -111,7 +140,9 @@ describe('DevicesManager', () => { expect(apiService.getDevices).toHaveBeenCalled(); expect(apiService.removeNameFromDevice).not.toHaveBeenCalled(); - expect(nodesManagementService.renameNode).toHaveBeenCalledWith(device.rootFolderUid, name, { allowRenameRootNode: true }); + expect(nodesManagementService.renameNode).toHaveBeenCalledWith(device.rootFolderUid, name, { + allowRenameRootNode: true, + }); expect(result).toEqual({ ...device, name: { ok: true, value: name } }); }); @@ -126,4 +157,4 @@ describe('DevicesManager', () => { expect(apiService.removeNameFromDevice).not.toHaveBeenCalled(); expect(nodesManagementService.renameNode).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/js/sdk/src/internal/devices/manager.ts b/js/sdk/src/internal/devices/manager.ts index 3ebb5d61..83561afa 100644 --- a/js/sdk/src/internal/devices/manager.ts +++ b/js/sdk/src/internal/devices/manager.ts @@ -80,7 +80,7 @@ export class DevicesManager { const device = await this.getDeviceMetadata(deviceUid); if (device.hasDeprecatedName) { - this.logger.info("Removing deprecated name from device"); + this.logger.info('Removing deprecated name from device'); try { await this.apiService.removeNameFromDevice(deviceUid); } catch (error: unknown) { @@ -95,12 +95,12 @@ export class DevicesManager { return { ...device, name: resultOk(name), - } + }; } private async getDeviceMetadata(deviceUid: string): Promise { const devices = await this.apiService.getDevices(); - const device = devices.find(device => device.uid === deviceUid); + const device = devices.find((device) => device.uid === deviceUid); if (!device) { throw new ValidationError(c('Error').t`Device not found`); } diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index 1dd44c1f..b49b06fd 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -1,23 +1,32 @@ -import { DriveAPIService, drivePaths, ObserverStream } from "../apiService"; -import { makeNodeThumbnailUid, splitNodeRevisionUid, splitNodeThumbnailUid } from "../uids"; -import { BlockMetadata } from "./interface"; +import { DriveAPIService, drivePaths, ObserverStream } from '../apiService'; +import { makeNodeThumbnailUid, splitNodeRevisionUid, splitNodeThumbnailUid } from '../uids'; +import { BlockMetadata } from './interface'; const BLOCKS_PAGE_SIZE = 20; -type GetRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; +type GetRevisionResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; -type PostGetThumbnailsRequest = Extract['content']['application/json']; -type PostGetThumbnailsResponse = drivePaths['/drive/volumes/{volumeID}/thumbnails']['post']['responses']['200']['content']['application/json']; +type PostGetThumbnailsRequest = Extract< + drivePaths['/drive/volumes/{volumeID}/thumbnails']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostGetThumbnailsResponse = + drivePaths['/drive/volumes/{volumeID}/thumbnails']['post']['responses']['200']['content']['application/json']; export class DownloadAPIService { constructor(private apiService: DriveAPIService) { this.apiService = apiService; } - async* iterateRevisionBlocks(nodeRevisionUid: string, signal?: AbortSignal, fromBlockIndex = 1): AsyncGenerator< - { type: 'manifestSignature', armoredManifestSignature?: string } | - { type: 'thumbnail', base64sha256Hash: string } | - { type: 'block' } & BlockMetadata + async *iterateRevisionBlocks( + nodeRevisionUid: string, + signal?: AbortSignal, + fromBlockIndex = 1, + ): AsyncGenerator< + | { type: 'manifestSignature'; armoredManifestSignature?: string } + | { type: 'thumbnail'; base64sha256Hash: string } + | ({ type: 'block' } & BlockMetadata) > { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); @@ -42,7 +51,7 @@ export class DownloadAPIService { yield { type: 'thumbnail', base64sha256Hash: block.Hash, - } + }; } } } @@ -61,7 +70,11 @@ export class DownloadAPIService { } } - async getRevisionBlockToken(nodeRevisionUid: string, blockIndex: number, signal?: AbortSignal): Promise { + async getRevisionBlockToken( + nodeRevisionUid: string, + blockIndex: number, + signal?: AbortSignal, + ): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); const result = await this.apiService.get( @@ -73,7 +86,12 @@ export class DownloadAPIService { return transformBlock(block); } - async downloadBlock(baseUrl: string, token: string, onProgress?: (downloadedBytes: number) => void, signal?: AbortSignal): Promise { + async downloadBlock( + baseUrl: string, + token: string, + onProgress?: (downloadedBytes: number) => void, + signal?: AbortSignal, + ): Promise { const rawBlockStream = await this.apiService.getBlockStream(baseUrl, token, signal); const progressStream = new ObserverStream((value) => { onProgress?.(value.length); @@ -83,13 +101,15 @@ export class DownloadAPIService { return encryptedBlock; } - async* iterateThumbnails(thumbnailUids: string[], signal?: AbortSignal): AsyncGenerator< - { uid: string, ok: true, bareUrl: string, token: string } | - { uid: string, ok: false, error: string } + async *iterateThumbnails( + thumbnailUids: string[], + signal?: AbortSignal, + ): AsyncGenerator< + { uid: string; ok: true; bareUrl: string; token: string } | { uid: string; ok: false; error: string } > { const splitedThumbnailsIds = thumbnailUids.map(splitNodeThumbnailUid); - const thumbnailIdsByVolumeId = new Map(); + const thumbnailIdsByVolumeId = new Map(); for (const { volumeId, thumbnailId, nodeId } of splitedThumbnailsIds) { if (!thumbnailIdsByVolumeId.has(volumeId)) { thumbnailIdsByVolumeId.set(volumeId, []); @@ -97,42 +117,40 @@ export class DownloadAPIService { thumbnailIdsByVolumeId.get(volumeId)?.push({ volumeId, thumbnailId, nodeId }); } + for (const [volumeId, thumbnailIds] of thumbnailIdsByVolumeId.entries()) { + const result = await this.apiService.post( + `drive/volumes/${volumeId}/thumbnails`, + { + ThumbnailIDs: thumbnailIds.map(({ thumbnailId }) => thumbnailId), + }, + signal, + ); - for (const [volumeId, thumbnailIds] of thumbnailIdsByVolumeId.entries()) { - const result = await this.apiService.post( - `drive/volumes/${volumeId}/thumbnails`, - { - ThumbnailIDs: thumbnailIds.map(({ thumbnailId }) => thumbnailId), - }, - signal, - ); - - for (const thumbnail of result.Thumbnails) { - const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === thumbnail.ThumbnailID); - if (!id) { - continue; + for (const thumbnail of result.Thumbnails) { + const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === thumbnail.ThumbnailID); + if (!id) { + continue; + } + yield { + uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, thumbnail.ThumbnailID), + ok: true, + bareUrl: thumbnail.BareURL, + token: thumbnail.Token, + }; } - yield { - uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, thumbnail.ThumbnailID), - ok: true, - bareUrl: thumbnail.BareURL, - token: thumbnail.Token, - }; - } - for (const error of result.Errors) { - const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === error.ThumbnailID); - if (!id) { - continue; + for (const error of result.Errors) { + const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === error.ThumbnailID); + if (!id) { + continue; + } + yield { + uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, error.ThumbnailID), + ok: false, + error: error.Error, + }; } - yield { - uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, error.ThumbnailID), - ok: false, - error: error.Error, - }; } - - } } } diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index 657d48af..56c5a586 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -1,27 +1,44 @@ import { c } from 'ttag'; -import { DriveCrypto, PrivateKey, PublicKey, SessionKey, uint8ArrayToBase64String, VERIFICATION_STATUS } from "../../crypto"; -import { ProtonDriveAccount, Revision } from "../../interface"; -import { DecryptionError, IntegrityError } from "../../errors"; -import { getErrorMessage } from "../errors"; -import { mergeUint8Arrays } from "../utils"; -import { RevisionKeys } from "./interface"; +import { + DriveCrypto, + PrivateKey, + PublicKey, + SessionKey, + uint8ArrayToBase64String, + VERIFICATION_STATUS, +} from '../../crypto'; +import { ProtonDriveAccount, Revision } from '../../interface'; +import { DecryptionError, IntegrityError } from '../../errors'; +import { getErrorMessage } from '../errors'; +import { mergeUint8Arrays } from '../utils'; +import { RevisionKeys } from './interface'; export class DownloadCryptoService { - constructor(private driveCrypto: DriveCrypto, private account: ProtonDriveAccount) { + constructor( + private driveCrypto: DriveCrypto, + private account: ProtonDriveAccount, + ) { this.account = account; this.driveCrypto = driveCrypto; } - async getRevisionKeys(nodeKey: { key: PrivateKey, contentKeyPacketSessionKey: SessionKey }, revision: Revision): Promise { + async getRevisionKeys( + nodeKey: { key: PrivateKey; contentKeyPacketSessionKey: SessionKey }, + revision: Revision, + ): Promise { const verificationKeys = await this.getRevisionVerificationKeys(revision); return { ...nodeKey, verificationKeys, - } + }; } - async decryptBlock(encryptedBlock: Uint8Array, armoredSignature: string, revisionKeys: RevisionKeys): Promise { + async decryptBlock( + encryptedBlock: Uint8Array, + armoredSignature: string, + revisionKeys: RevisionKeys, + ): Promise { let decryptedBlock; try { // We do not verify signatures on blocks. We only verify @@ -75,8 +92,13 @@ export class DownloadCryptoService { } } - async verifyManifest(revision: Revision, nodeKey: PrivateKey, allBlockHashes: Uint8Array[], armoredManifestSignature?: string): Promise { - const verificationKeys = await this.getRevisionVerificationKeys(revision) || nodeKey; + async verifyManifest( + revision: Revision, + nodeKey: PrivateKey, + allBlockHashes: Uint8Array[], + armoredManifestSignature?: string, + ): Promise { + const verificationKeys = (await this.getRevisionVerificationKeys(revision)) || nodeKey; const hash = mergeUint8Arrays(allBlockHashes); if (!armoredManifestSignature) { @@ -90,7 +112,9 @@ export class DownloadCryptoService { } private async getRevisionVerificationKeys(revision: Revision): Promise { - const signatureEmail = revision.contentAuthor.ok ? revision.contentAuthor.value : revision.contentAuthor.error.claimedAuthor; + const signatureEmail = revision.contentAuthor.ok + ? revision.contentAuthor.value + : revision.contentAuthor.error.claimedAuthor; return signatureEmail ? await this.account.getPublicKeys(signatureEmail) : undefined; } } diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index 8d1d4773..31f802ca 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -99,7 +99,7 @@ describe('FileDownloader', () => { expect(telemetry.downloadFinished).toHaveBeenCalledWith('revisionUid', fileProgress); expect(telemetry.downloadFailed).not.toHaveBeenCalled(); expect(onFinish).toHaveBeenCalledTimes(1); - } + }; const verifyFailure = async (error: string, downloadedBytes: number | undefined) => { const controller = downloader.writeToStream(stream, onProgress); @@ -136,12 +136,20 @@ describe('FileDownloader', () => { write: jest.fn(), close: jest.fn(), abort: jest.fn(), - } + }; // @ts-expect-error Mocking WritableStream stream = { getWriter: () => writer, - } - downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey as any, revision, undefined, onFinish); + }; + downloader = new FileDownloader( + telemetry, + apiService, + cryptoService, + nodeKey as any, + revision, + undefined, + onFinish, + ); }); it('should reject two download starts', async () => { @@ -178,9 +186,9 @@ describe('FileDownloader', () => { yield { type: 'block', index: 9, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; yield { type: 'block', index: 10, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; yield { type: 'block', index: 11, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; - }) + }); apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { - await new Promise(resolve => setTimeout(resolve, timeouts[count++])); + await new Promise((resolve) => setTimeout(resolve, timeouts[count++])); return mockBlockDownload(bareUrl, token, onProgress); }); @@ -353,12 +361,20 @@ describe('FileDownloader', () => { write: jest.fn(), close: jest.fn(), abort: jest.fn(), - } + }; // @ts-expect-error Mocking WritableStream stream = { getWriter: () => writer, - } - downloader = new FileDownloader(telemetry, apiService, cryptoService, nodeKey as any, revision, undefined, onFinish); + }; + downloader = new FileDownloader( + telemetry, + apiService, + cryptoService, + nodeKey as any, + revision, + undefined, + onFinish, + ); }); it('should skip verification steps', async () => { diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 23346a11..9ab457e9 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -1,10 +1,10 @@ -import { PrivateKey, SessionKey, base64StringToUint8Array } from "../../crypto"; -import { Logger, Revision } from "../../interface"; -import { LoggerWithPrefix } from "../../telemetry"; +import { PrivateKey, SessionKey, base64StringToUint8Array } from '../../crypto'; +import { Logger, Revision } from '../../interface'; +import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; -import { DownloadAPIService } from "./apiService"; +import { DownloadAPIService } from './apiService'; import { DownloadController } from './controller'; -import { DownloadCryptoService } from "./cryptoService"; +import { DownloadCryptoService } from './cryptoService'; import { BlockMetadata, RevisionKeys } from './interface'; import { DownloadTelemetry } from './telemetry'; @@ -20,16 +20,19 @@ export class FileDownloader { private controller: DownloadController; private nextBlockIndex = 1; - private ongoingDownloads = new Map, - decryptedBufferedBlock?: Uint8Array, - }>(); + private ongoingDownloads = new Map< + number, + { + downloadPromise: Promise; + decryptedBufferedBlock?: Uint8Array; + } + >(); constructor( private telemetry: DownloadTelemetry, private apiService: DownloadAPIService, private cryptoService: DownloadCryptoService, - private nodeKey: { key: PrivateKey, contentKeyPacketSessionKey: SessionKey }, + private nodeKey: { key: PrivateKey; contentKeyPacketSessionKey: SessionKey }, private revision: Revision, private signal?: AbortSignal, private onFinish?: () => void, @@ -127,7 +130,12 @@ export class FileDownloader { this.logger.warn('Skipping manifest check'); } else { this.logger.debug(`Verifying manifest`); - await this.cryptoService.verifyManifest(this.revision, this.nodeKey.key, allBlockHashes, armoredManifestSignature); + await this.cryptoService.verifyManifest( + this.revision, + this.nodeKey.key, + allBlockHashes, + armoredManifestSignature, + ); } await writer.close(); @@ -161,10 +169,15 @@ export class FileDownloader { logger.debug(`Downloading`); await this.controller.waitWhilePaused(); try { - const encryptedBlock = await this.apiService.downloadBlock(blockMetadata.bareUrl, blockMetadata.token, (downloadedBytes) => { - blockProgress += downloadedBytes; - onProgress?.(downloadedBytes); - }, this.signal); + const encryptedBlock = await this.apiService.downloadBlock( + blockMetadata.bareUrl, + blockMetadata.token, + (downloadedBytes) => { + blockProgress += downloadedBytes; + onProgress?.(downloadedBytes); + }, + this.signal, + ); if (ignoreIntegrityErrors) { logger.warn('Skipping hash check'); @@ -174,7 +187,11 @@ export class FileDownloader { } logger.debug(`Decrypting`); - decryptedBlock = await this.cryptoService.decryptBlock(encryptedBlock, blockMetadata.armoredSignature!, cryptoKeys); + decryptedBlock = await this.cryptoService.decryptBlock( + encryptedBlock, + blockMetadata.armoredSignature!, + cryptoKeys, + ); } catch (error) { if (blockProgress !== 0) { onProgress?.(-blockProgress); @@ -183,7 +200,11 @@ export class FileDownloader { if (error instanceof APIHTTPError && error.statusCode === HTTPErrorCode.NOT_FOUND) { logger.warn(`Token expired, fetching new token and retrying`); - blockMetadata = await this.apiService.getRevisionBlockToken(this.revision.uid, blockMetadata.index, this.signal); + blockMetadata = await this.apiService.getRevisionBlockToken( + this.revision.uid, + blockMetadata.index, + this.signal, + ); continue; } @@ -255,7 +276,8 @@ export class FileDownloader { } private get ongoingDownloadPromises() { - return this.ongoingDownloads.values() + return this.ongoingDownloads + .values() .filter((value) => value.decryptedBufferedBlock === undefined) .map((value) => value.downloadPromise); } diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts index d4dcc810..86375a80 100644 --- a/js/sdk/src/internal/download/index.ts +++ b/js/sdk/src/internal/download/index.ts @@ -1,16 +1,16 @@ import { c } from 'ttag'; -import { DriveCrypto } from "../../crypto"; -import { ValidationError } from "../../errors"; -import { ProtonDriveAccount, ProtonDriveTelemetry, NodeType, ThumbnailType, ThumbnailResult } from "../../interface"; -import { DriveAPIService } from "../apiService"; -import { DownloadAPIService } from "./apiService"; -import { DownloadCryptoService } from "./cryptoService"; -import { NodesService, RevisionsService, SharesService } from "./interface"; -import { FileDownloader } from "./fileDownloader"; -import { DownloadQueue } from "./queue"; -import { DownloadTelemetry } from "./telemetry"; -import { makeNodeUidFromRevisionUid } from "../uids"; +import { DriveCrypto } from '../../crypto'; +import { ValidationError } from '../../errors'; +import { ProtonDriveAccount, ProtonDriveTelemetry, NodeType, ThumbnailType, ThumbnailResult } from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { DownloadAPIService } from './apiService'; +import { DownloadCryptoService } from './cryptoService'; +import { NodesService, RevisionsService, SharesService } from './interface'; +import { FileDownloader } from './fileDownloader'; +import { DownloadQueue } from './queue'; +import { DownloadTelemetry } from './telemetry'; +import { makeNodeUidFromRevisionUid } from '../uids'; import { ThumbnailDownloader } from './thumbnailDownloader'; export function initDownloadModule( @@ -36,13 +36,13 @@ export function initDownloadModule( nodeKey = await nodesService.getNodeKeys(nodeUid); if (node.type === NodeType.Folder) { - throw new ValidationError(c("Error").t`Cannot download a folder`); + throw new ValidationError(c('Error').t`Cannot download a folder`); } if (!nodeKey.contentKeyPacketSessionKey) { - throw new ValidationError(c("Error").t`File has no content key`); + throw new ValidationError(c('Error').t`File has no content key`); } if (!node.activeRevision?.ok || !node.activeRevision.value) { - throw new ValidationError(c("Error").t`File has no active revision`); + throw new ValidationError(c('Error').t`File has no active revision`); } } catch (error: unknown) { queue.releaseCapacity(); @@ -76,12 +76,12 @@ export function initDownloadModule( node = await nodesService.getNode(nodeUid); nodeKey = await nodesService.getNodeKeys(nodeUid); revision = await revisionsService.getRevision(nodeRevisionUid); - + if (node.type === NodeType.Folder) { - throw new ValidationError(c("Error").t`Cannot download a folder`); + throw new ValidationError(c('Error').t`Cannot download a folder`); } if (!nodeKey.contentKeyPacketSessionKey) { - throw new ValidationError(c("Error").t`File has no content key`); + throw new ValidationError(c('Error').t`File has no content key`); } } catch (error: unknown) { queue.releaseCapacity(); @@ -105,7 +105,7 @@ export function initDownloadModule( ); } - async function *iterateThumbnails( + async function* iterateThumbnails( nodeUids: string[], thumbnailType?: ThumbnailType, signal?: AbortSignal, @@ -118,5 +118,5 @@ export function initDownloadModule( getFileDownloader, getFileRevisionDownloader, iterateThumbnails, - } + }; } diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index 1634706c..7ed36060 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,38 +1,38 @@ -import { PrivateKey, PublicKey, SessionKey } from "../../crypto"; -import { NodeType, Result, Revision, MissingNode, MetricVolumeType } from "../../interface"; -import { DecryptedNode } from "../nodes"; +import { PrivateKey, PublicKey, SessionKey } from '../../crypto'; +import { NodeType, Result, Revision, MissingNode, MetricVolumeType } from '../../interface'; +import { DecryptedNode } from '../nodes'; export type BlockMetadata = { - index: number, - bareUrl: string, - token: string, - base64sha256Hash: string, - signatureEmail?: string, - armoredSignature?: string, + index: number; + bareUrl: string; + token: string; + base64sha256Hash: string; + signatureEmail?: string; + armoredSignature?: string; }; export type RevisionKeys = { - key: PrivateKey, - contentKeyPacketSessionKey: SessionKey, - verificationKeys?: PublicKey[], -} + key: PrivateKey; + contentKeyPacketSessionKey: SessionKey; + verificationKeys?: PublicKey[]; +}; export interface SharesService { - getVolumeMetricContext(volumeId: string): Promise, + getVolumeMetricContext(volumeId: string): Promise; } export interface NodesService { - getNode(nodeUid: string): Promise, - getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey, contentKeyPacketSessionKey?: SessionKey; }>, + getNode(nodeUid: string): Promise; + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey; contentKeyPacketSessionKey?: SessionKey }>; iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; } export interface NodesServiceNode { - uid: string, - type: NodeType, - activeRevision?: Result, + uid: string; + type: NodeType; + activeRevision?: Result; } export interface RevisionsService { - getRevision(nodeRevisionUid: string): Promise, + getRevision(nodeRevisionUid: string): Promise; } diff --git a/js/sdk/src/internal/download/queue.ts b/js/sdk/src/internal/download/queue.ts index 8190efcb..2b1bb009 100644 --- a/js/sdk/src/internal/download/queue.ts +++ b/js/sdk/src/internal/download/queue.ts @@ -2,13 +2,13 @@ import { waitForCondition } from '../wait'; /** * A queue that limits the number of concurrent downloads. - * + * * This is used to limit the number of concurrent downloads to avoid * overloading the server, or get rate limited. - * + * * Each file download consumes memory and is limited by the number of * concurrent block downloads for each file. - * + * * This queue is straitforward and does not have any priority mechanism * or other features, such as limiting total number of blocks being * downloaded. That is something we want to add in the future to be diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts index 88dd6304..973edd66 100644 --- a/js/sdk/src/internal/download/telemetry.test.ts +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -25,7 +25,7 @@ describe('DownloadTelemetry', () => { sharesService = { getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), - } + }; downloadTelemetry = new DownloadTelemetry(mockTelemetry, sharesService); }); @@ -35,10 +35,10 @@ describe('DownloadTelemetry', () => { await downloadTelemetry.downloadInitFailed(nodeUid, error); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ - eventName: "download", - volumeType: "own_volume", + eventName: 'download', + volumeType: 'own_volume', downloadedSize: 0, - error: "unknown", + error: 'unknown', originalError: error, }); }); @@ -48,11 +48,11 @@ describe('DownloadTelemetry', () => { await downloadTelemetry.downloadFailed(revisionUid, error, 123, 456); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ - eventName: "download", - volumeType: "own_volume", + eventName: 'download', + volumeType: 'own_volume', downloadedSize: 123, claimedFileSize: 456, - error: "unknown", + error: 'unknown', originalError: error, }); }); @@ -61,8 +61,8 @@ describe('DownloadTelemetry', () => { await downloadTelemetry.downloadFinished(revisionUid, 500); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ - eventName: "download", - volumeType: "own_volume", + eventName: 'download', + volumeType: 'own_volume', downloadedSize: 500, claimedFileSize: 500, }); @@ -73,9 +73,9 @@ describe('DownloadTelemetry', () => { expect(mockTelemetry.logEvent).toHaveBeenCalledWith( expect.objectContaining({ error, - }) + }), ); - } + }; it('should ignore ValidationError', async () => { const error = new ValidationError('Validation error'); diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index ecedabff..322a8baf 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -1,16 +1,19 @@ -import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from "../../errors"; -import { ProtonDriveTelemetry, MetricsDownloadErrorType, Logger } from "../../interface"; -import { LoggerWithPrefix } from "../../telemetry"; +import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from '../../errors'; +import { ProtonDriveTelemetry, MetricsDownloadErrorType, Logger } from '../../interface'; +import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError } from '../apiService'; -import { splitNodeRevisionUid, splitNodeUid } from "../uids"; -import { SharesService } from "./interface"; +import { splitNodeRevisionUid, splitNodeUid } from '../uids'; +import { SharesService } from './interface'; export class DownloadTelemetry { private logger: Logger; - constructor(private telemetry: ProtonDriveTelemetry, private sharesService: SharesService) { + constructor( + private telemetry: ProtonDriveTelemetry, + private sharesService: SharesService, + ) { this.telemetry = telemetry; - this.logger = this.telemetry.getLogger("download"); + this.logger = this.telemetry.getLogger('download'); this.sharesService = sharesService; } @@ -61,12 +64,15 @@ export class DownloadTelemetry { }); } - private async sendTelemetry(volumeId: string, options: { - downloadedSize: number, - claimedFileSize?: number, - error?: MetricsDownloadErrorType, - originalError?: unknown, - }) { + private async sendTelemetry( + volumeId: string, + options: { + downloadedSize: number; + claimedFileSize?: number; + error?: MetricsDownloadErrorType; + originalError?: unknown; + }, + ) { let volumeType; try { volumeType = await this.sharesService.getVolumeMetricContext(volumeId); @@ -107,7 +113,11 @@ function getErrorCategory(error: unknown): MetricsDownloadErrorType | undefined if (error.name === 'TimeoutError') { return 'server_error'; } - if (error.name === 'OfflineError' || error.name === 'NetworkError' || error.message?.toLowerCase() === 'network error') { + if ( + error.name === 'OfflineError' || + error.name === 'NetworkError' || + error.message?.toLowerCase() === 'network error' + ) { return 'network_error'; } if (error.name === 'AbortError') { diff --git a/js/sdk/src/internal/download/thumbnailDownloader.test.ts b/js/sdk/src/internal/download/thumbnailDownloader.test.ts index ec4ce47e..e0f41b29 100644 --- a/js/sdk/src/internal/download/thumbnailDownloader.test.ts +++ b/js/sdk/src/internal/download/thumbnailDownloader.test.ts @@ -1,5 +1,5 @@ import { ProtonDriveTelemetry } from '../../interface'; -import { getMockTelemetry } from "../../tests/telemetry"; +import { getMockTelemetry } from '../../tests/telemetry'; import { ThumbnailDownloader } from './thumbnailDownloader'; import { DownloadAPIService } from './apiService'; import { DownloadCryptoService } from './cryptoService'; @@ -28,7 +28,7 @@ describe('ThumbnailDownloader', () => { thumbnails: [{ type: 1, uid: `thumb-${nodeUid}` }], }, }, - } + }; } }), getNodeKeys: jest.fn().mockReturnValue({ @@ -45,7 +45,7 @@ describe('ThumbnailDownloader', () => { ok: true, bareUrl: `url-${thumbnailUid}`, token: `token-${thumbnailUid}`, - } + }; } }), downloadBlock: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), @@ -68,11 +68,17 @@ describe('ThumbnailDownloader', () => { { nodeUid: 'node3', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }, ]); expect(nodesService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3'], undefined); - expect(apiService.iterateThumbnails).toHaveBeenCalledWith(['thumb-node1', 'thumb-node2', 'thumb-node3'], undefined); + expect(apiService.iterateThumbnails).toHaveBeenCalledWith( + ['thumb-node1', 'thumb-node2', 'thumb-node3'], + undefined, + ); expect(nodesService.getNodeKeys).toHaveBeenCalledTimes(3); expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); expect(cryptoService.decryptThumbnail).toHaveBeenCalledTimes(3); - expect(cryptoService.decryptThumbnail).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), 'contentKeyPacketSessionKey'); + expect(cryptoService.decryptThumbnail).toHaveBeenCalledWith( + new Uint8Array([1, 2, 3]), + 'contentKeyPacketSessionKey', + ); }); it('should handle no requested node', async () => { @@ -115,7 +121,6 @@ describe('ThumbnailDownloader', () => { expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); }); - it('should handle node without requested thumbnail', async () => { nodesService.iterateNodes = jest.fn().mockImplementation(async function* () { yield { uid: 'node1', type: 'file', activeRevision: { ok: true, value: { thumbnails: [] } } }; diff --git a/js/sdk/src/internal/download/thumbnailDownloader.ts b/js/sdk/src/internal/download/thumbnailDownloader.ts index d0523b37..ca6ed60b 100644 --- a/js/sdk/src/internal/download/thumbnailDownloader.ts +++ b/js/sdk/src/internal/download/thumbnailDownloader.ts @@ -1,11 +1,11 @@ import { c } from 'ttag'; -import { NodeType, ThumbnailType, ProtonDriveTelemetry, Logger, ThumbnailResult } from "../../interface"; +import { NodeType, ThumbnailType, ProtonDriveTelemetry, Logger, ThumbnailResult } from '../../interface'; import { ValidationError } from '../../errors'; import { LoggerWithPrefix } from '../../telemetry'; -import { DownloadAPIService } from "./apiService"; -import { DownloadCryptoService } from "./cryptoService"; -import { NodesService } from "./interface"; +import { DownloadAPIService } from './apiService'; +import { DownloadCryptoService } from './cryptoService'; +import { NodesService } from './interface'; import { getErrorMessage } from '../errors'; /** @@ -24,8 +24,8 @@ export class ThumbnailDownloader { private batchThumbnailToNodeUids = new Map(); private ongoingDownloads = new Map>(); private bufferedThumbnails: ( - { nodeUid: string, ok: true, thumbnail: Uint8Array } | - { nodeUid: string, ok: false, error: string } + | { nodeUid: string; ok: true; thumbnail: Uint8Array } + | { nodeUid: string; ok: false; error: string } )[] = []; constructor( @@ -34,7 +34,7 @@ export class ThumbnailDownloader { private apiService: DownloadAPIService, private cryptoService: DownloadCryptoService, ) { - this.logger = telemetry.getLogger("download"); + this.logger = telemetry.getLogger('download'); this.nodesService = nodesService; this.apiService = apiService; this.cryptoService = cryptoService; @@ -79,40 +79,41 @@ export class ThumbnailDownloader { this.bufferedThumbnails = []; } - private async *iterateThumbnailUids(nodeUids: string[], thumbnailType: ThumbnailType, signal?: AbortSignal): AsyncGenerator< - { nodeUid: string, ok: true, thumbnailUid: string } | - { nodeUid: string, ok: false, error: string } + private async *iterateThumbnailUids( + nodeUids: string[], + thumbnailType: ThumbnailType, + signal?: AbortSignal, + ): AsyncGenerator< + { nodeUid: string; ok: true; thumbnailUid: string } | { nodeUid: string; ok: false; error: string } > { for await (const node of this.nodesService.iterateNodes(nodeUids, signal)) { if ('missingUid' in node) { yield { nodeUid: node.missingUid, ok: false, - error: c("Error").t`Node not found`, - } + error: c('Error').t`Node not found`, + }; continue; } if (node.type !== NodeType.File) { yield { nodeUid: node.uid, ok: false, - error: c("Error").t`Node is not a file`, - } + error: c('Error').t`Node is not a file`, + }; continue; } let thumbnail; if (node.activeRevision?.ok) { - thumbnail = node.activeRevision.value.thumbnails?.find( - (t) => t.type === thumbnailType, - ); + thumbnail = node.activeRevision.value.thumbnails?.find((t) => t.type === thumbnailType); } if (!thumbnail) { yield { nodeUid: node.uid, ok: false, - error: c("Error").t`Node has no thumbnail`, - } + error: c('Error').t`Node has no thumbnail`, + }; continue; } @@ -120,7 +121,7 @@ export class ThumbnailDownloader { nodeUid: node.uid, ok: true, thumbnailUid: thumbnail.uid, - } + }; } } @@ -163,13 +164,15 @@ export class ThumbnailDownloader { }), ); } - + this.batchThumbnailToNodeUids.clear(); } - private async *iterateThumbnailDownloads(signal?: AbortSignal): AsyncGenerator< - { nodeUid: string, ok: true, downloadPromise: Promise } | - { nodeUid: string, ok: false, error: string } + private async *iterateThumbnailDownloads( + signal?: AbortSignal, + ): AsyncGenerator< + | { nodeUid: string; ok: true; downloadPromise: Promise } + | { nodeUid: string; ok: false; error: string } > { const missingThumbnailUids = new Set(this.batchThumbnailToNodeUids.keys()); @@ -190,7 +193,7 @@ export class ThumbnailDownloader { nodeUid, ok: false, error: result.error, - } + }; continue; } @@ -198,7 +201,7 @@ export class ThumbnailDownloader { nodeUid, ok: true, downloadPromise: this.downloadThumbnail(nodeUid, result.bareUrl, result.token, signal), - } + }; } for (const uid of missingThumbnailUids) { @@ -207,14 +210,19 @@ export class ThumbnailDownloader { yield { nodeUid, ok: false, - error: c("Error").t`Thumbnail not found`, - } + error: c('Error').t`Thumbnail not found`, + }; } } - private async downloadThumbnail(nodeUid: string, bareUrl: string, token: string, signal?: AbortSignal): Promise { + private async downloadThumbnail( + nodeUid: string, + bareUrl: string, + token: string, + signal?: AbortSignal, + ): Promise { const logger = new LoggerWithPrefix(this.logger, `thumbnail ${token}`); - + let decryptedBlock: Uint8Array | null = null; let attempt = 0; @@ -229,11 +237,14 @@ export class ThumbnailDownloader { ]); if (!nodeKeys.contentKeyPacketSessionKey) { - throw new ValidationError(c("Error").t`File has no content key`); + throw new ValidationError(c('Error').t`File has no content key`); } logger.debug(`Decrypting`); - decryptedBlock = await this.cryptoService.decryptThumbnail(encryptedBlock, nodeKeys.contentKeyPacketSessionKey); + decryptedBlock = await this.cryptoService.decryptThumbnail( + encryptedBlock, + nodeKeys.contentKeyPacketSessionKey, + ); } catch (error: unknown) { if (attempt <= MAX_THUMBNAIL_DOWNLOAD_ATTEMPTS) { logger.warn(`Thumbnail download failed #${attempt}, retrying: ${getErrorMessage(error)}`); diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index 5bb99ec0..cde912f6 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { VERIFICATION_STATUS } from "../crypto"; +import { VERIFICATION_STATUS } from '../crypto'; export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : c('Error').t`Unknown error`; @@ -9,11 +9,13 @@ export function getErrorMessage(error: unknown): string { /** * @param signatureType - Must be translated before calling this function. */ -export function getVerificationMessage(verified: VERIFICATION_STATUS, signatureType?: string, notAvailableVerificationKeys = false): string { +export function getVerificationMessage( + verified: VERIFICATION_STATUS, + signatureType?: string, + notAvailableVerificationKeys = false, +): string { if (verified === VERIFICATION_STATUS.NOT_SIGNED) { - return signatureType - ? c('Error').t`Missing signature for ${signatureType}` - : c('Error').t`Missing signature`; + return signatureType ? c('Error').t`Missing signature for ${signatureType}` : c('Error').t`Missing signature`; } if (notAvailableVerificationKeys) { diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index c76d5ec4..e5c5cb93 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -1,22 +1,26 @@ -import { DriveAPIService, drivePaths, corePaths } from "../apiService"; -import { makeNodeUid } from "../uids"; -import { DriveEventsListWithStatus, DriveEvent, DriveEventType, NodeEvent, NodeEventType } from "./interface"; +import { DriveAPIService, drivePaths, corePaths } from '../apiService'; +import { makeNodeUid } from '../uids'; +import { DriveEventsListWithStatus, DriveEvent, DriveEventType, NodeEvent, NodeEventType } from './interface'; -type GetCoreLatestEventResponse = corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json']; -type GetCoreEventResponse = corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json']; +type GetCoreLatestEventResponse = + corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json']; +type GetCoreEventResponse = + corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json']; -type GetVolumeLatestEventResponse = drivePaths['/drive/volumes/{volumeID}/events/latest']['get']['responses']['200']['content']['application/json']; -type GetVolumeEventResponse = drivePaths['/drive/v2/volumes/{volumeID}/events/{eventID}']['get']['responses']['200']['content']['application/json']; +type GetVolumeLatestEventResponse = + drivePaths['/drive/volumes/{volumeID}/events/latest']['get']['responses']['200']['content']['application/json']; +type GetVolumeEventResponse = + drivePaths['/drive/v2/volumes/{volumeID}/events/{eventID}']['get']['responses']['200']['content']['application/json']; interface VolumeEventTypeMap { - [key: number]: NodeEventType, + [key: number]: NodeEventType; } const VOLUME_EVENT_TYPE_MAP: VolumeEventTypeMap = { 0: DriveEventType.NodeDeleted, 1: DriveEventType.NodeCreated, 2: DriveEventType.NodeUpdated, 3: DriveEventType.NodeUpdated, -} +}; /** * Provides API communication for fetching events. @@ -39,11 +43,16 @@ export class EventsAPIService { const result = await this.apiService.get(`core/v5/events/${eventId}`); // in core/v5/events, refresh is always all apps, value 255 const refresh = result.Refresh > 0; - const events: DriveEvent[] = (refresh || result.DriveShareRefresh?.Action === 2) ? [{ - type: DriveEventType.SharedWithMeUpdated, - eventId: result.EventID, - treeEventScopeId: 'core', - }] : []; + const events: DriveEvent[] = + refresh || result.DriveShareRefresh?.Action === 2 + ? [ + { + type: DriveEventType.SharedWithMeUpdated, + eventId: result.EventID, + treeEventScopeId: 'core', + }, + ] + : []; return { latestEventId: result.EventID, @@ -54,12 +63,16 @@ export class EventsAPIService { } async getVolumeLatestEventId(volumeId: string): Promise { - const result = await this.apiService.get(`drive/volumes/${volumeId}/events/latest`); + const result = await this.apiService.get( + `drive/volumes/${volumeId}/events/latest`, + ); return result.EventID; } async getVolumeEvents(volumeId: string, eventId: string): Promise { - const result = await this.apiService.get(`drive/v2/volumes/${volumeId}/events/${eventId}`); + const result = await this.apiService.get( + `drive/v2/volumes/${volumeId}/events/${eventId}`, + ); return { latestEventId: result.EventID, more: result.More, @@ -69,7 +82,7 @@ export class EventsAPIService { const uids = { nodeUid: makeNodeUid(volumeId, event.Link.LinkID), parentNodeUid: makeNodeUid(volumeId, event.Link.ParentLinkID as string), - } + }; return { type, ...uids, diff --git a/js/sdk/src/internal/events/coreEventManager.test.ts b/js/sdk/src/internal/events/coreEventManager.test.ts index c5b60b70..dbd0640b 100644 --- a/js/sdk/src/internal/events/coreEventManager.test.ts +++ b/js/sdk/src/internal/events/coreEventManager.test.ts @@ -1,9 +1,9 @@ -import { getMockLogger } from "../../tests/logger"; -import { EventsAPIService } from "./apiService"; -import { DriveEvent, DriveEventsListWithStatus, DriveEventType } from "./interface"; -import { CoreEventManager } from "./coreEventManager"; +import { getMockLogger } from '../../tests/logger'; +import { EventsAPIService } from './apiService'; +import { DriveEvent, DriveEventsListWithStatus, DriveEventType } from './interface'; +import { CoreEventManager } from './coreEventManager'; -describe("CoreEventManager", () => { +describe('CoreEventManager', () => { let mockApiService: jest.Mocked; let coreEventManager: CoreEventManager; const mockLogger = getMockLogger(); @@ -22,9 +22,9 @@ describe("CoreEventManager", () => { coreEventManager = new CoreEventManager(mockLogger, mockApiService); }); - describe("getLatestEventId", () => { - it("should return the latest event ID from API service", async () => { - const expectedEventId = "event-123"; + describe('getLatestEventId', () => { + it('should return the latest event ID from API service', async () => { + const expectedEventId = 'event-123'; mockApiService.getCoreLatestEventId.mockResolvedValue(expectedEventId); const result = await coreEventManager.getLatestEventId(); @@ -33,20 +33,20 @@ describe("CoreEventManager", () => { expect(mockApiService.getCoreLatestEventId).toHaveBeenCalledTimes(1); }); - it("should handle API service errors", async () => { - const error = new Error("API error"); + it('should handle API service errors', async () => { + const error = new Error('API error'); mockApiService.getCoreLatestEventId.mockRejectedValue(error); - await expect(coreEventManager.getLatestEventId()).rejects.toThrow("API error"); + await expect(coreEventManager.getLatestEventId()).rejects.toThrow('API error'); expect(mockApiService.getCoreLatestEventId).toHaveBeenCalledTimes(1); }); }); - describe("getEvents", () => { - const eventId = "event1"; - const latestEventId = "event2"; + describe('getEvents', () => { + const eventId = 'event1'; + const latestEventId = 'event2'; - it("should yield ShareWithMeUpdated event when refresh is true", async () => { + it('should yield ShareWithMeUpdated event when refresh is true', async () => { const mockEvents: DriveEventsListWithStatus = { latestEventId, more: false, @@ -69,15 +69,15 @@ describe("CoreEventManager", () => { expect(mockApiService.getCoreEvents).toHaveBeenCalledWith(eventId); }); - it("should yield all events when there are actual events", async () => { + it('should yield all events when there are actual events', async () => { const mockEvent1: DriveEvent = { type: DriveEventType.SharedWithMeUpdated, - eventId: "event-1", + eventId: 'event-1', treeEventScopeId: 'core', }; const mockEvent2: DriveEvent = { type: DriveEventType.SharedWithMeUpdated, - eventId: "event-2", + eventId: 'event-2', treeEventScopeId: 'core', }; const mockEvents: DriveEventsListWithStatus = { diff --git a/js/sdk/src/internal/events/coreEventManager.ts b/js/sdk/src/internal/events/coreEventManager.ts index 8b3f1018..840404fb 100644 --- a/js/sdk/src/internal/events/coreEventManager.ts +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -1,7 +1,7 @@ -import { Logger } from "../../interface"; -import { LoggerWithPrefix } from "../../telemetry"; -import { EventsAPIService } from "./apiService"; -import { DriveEvent, DriveEventType, EventManagerInterface } from "./interface"; +import { Logger } from '../../interface'; +import { LoggerWithPrefix } from '../../telemetry'; +import { EventsAPIService } from './apiService'; +import { DriveEvent, DriveEventType, EventManagerInterface } from './interface'; /** * Combines API and event manager to provide a service for listening to @@ -15,7 +15,10 @@ import { DriveEvent, DriveEventType, EventManagerInterface } from "./interface"; * with own implementation. */ export class CoreEventManager implements EventManagerInterface { - constructor(private logger: Logger, private apiService: EventsAPIService) { + constructor( + private logger: Logger, + private apiService: EventsAPIService, + ) { this.apiService = apiService; this.logger = new LoggerWithPrefix(logger, `core`); @@ -25,7 +28,7 @@ export class CoreEventManager implements EventManagerInterface { return await this.apiService.getCoreLatestEventId(); } - async * getEvents(eventId: string): AsyncIterable { + async *getEvents(eventId: string): AsyncIterable { const events = await this.apiService.getCoreEvents(eventId); if (events.events.length === 0 && events.latestEventId !== eventId) { yield { diff --git a/js/sdk/src/internal/events/eventManager.test.ts b/js/sdk/src/internal/events/eventManager.test.ts index 854da051..dfbc2d7b 100644 --- a/js/sdk/src/internal/events/eventManager.test.ts +++ b/js/sdk/src/internal/events/eventManager.test.ts @@ -1,12 +1,12 @@ -import { getMockLogger } from "../../tests/logger"; -import { EventManager } from "./eventManager"; -import { DriveEvent, DriveEventType, EventSubscription, UnsubscribeFromEventsSourceError } from "./interface"; +import { getMockLogger } from '../../tests/logger'; +import { EventManager } from './eventManager'; +import { DriveEvent, DriveEventType, EventSubscription, UnsubscribeFromEventsSourceError } from './interface'; jest.useFakeTimers(); const POLLING_INTERVAL = 1; -describe("EventManager", () => { +describe('EventManager', () => { let manager: EventManager; const getLatestEventIdMock = jest.fn(); @@ -22,11 +22,7 @@ describe("EventManager", () => { getEvents: getEventsMock, }; - manager = new EventManager( - mockEventManager as any, - POLLING_INTERVAL, - null, - ); + manager = new EventManager(mockEventManager as any, POLLING_INTERVAL, null); const subscription = manager.addListener(listenerMock); subscriptions.push(subscription); }); @@ -40,28 +36,34 @@ describe("EventManager", () => { jest.clearAllMocks(); }); - it("should start polling when started", async () => { + it('should start polling when started', async () => { getLatestEventIdMock.mockResolvedValue('EventId1'); const mockEvents: DriveEvent[][] = [ - [{ - type: DriveEventType.FastForward, - treeEventScopeId: 'volume1', - eventId: 'EventId2', - }], - [{ - type: DriveEventType.FastForward, - treeEventScopeId: 'volume1', - eventId: 'EventId3', - }], + [ + { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'EventId2', + }, + ], + [ + { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'EventId3', + }, + ], ]; - getEventsMock.mockImplementationOnce(async function* () { - yield* mockEvents[0]; - }).mockImplementationOnce(async function* () { - yield* mockEvents[1]; - }).mockImplementationOnce(async function* () { - }); + getEventsMock + .mockImplementationOnce(async function* () { + yield* mockEvents[0]; + }) + .mockImplementationOnce(async function* () { + yield* mockEvents[1]; + }) + .mockImplementationOnce(async function* () {}); expect(getLatestEventIdMock).toHaveBeenCalledTimes(0); expect(getEventsMock).toHaveBeenCalledTimes(0); @@ -76,7 +78,7 @@ describe("EventManager", () => { expect(getEventsMock).toHaveBeenCalledWith('EventId2'); }); - it("should stop polling when stopped", async () => { + it('should stop polling when stopped', async () => { getLatestEventIdMock.mockResolvedValue('eventId1'); getEventsMock.mockImplementation(async function* () { yield { @@ -97,7 +99,7 @@ describe("EventManager", () => { expect(getEventsMock).toHaveBeenCalledTimes(callsBeforeStop); }); - it("should notify all listeners when getting events", async () => { + it('should notify all listeners when getting events', async () => { getLatestEventIdMock.mockResolvedValue('eventId1'); const mockEvents: DriveEvent[] = [ @@ -112,10 +114,11 @@ describe("EventManager", () => { }, ]; - getEventsMock.mockImplementationOnce(async function* () { - yield* mockEvents; - }).mockImplementation(async function* () { - }); + getEventsMock + .mockImplementationOnce(async function* () { + yield* mockEvents; + }) + .mockImplementation(async function* () {}); expect(await manager.start()).toBeUndefined(); await jest.runOnlyPendingTimersAsync(); @@ -123,9 +126,9 @@ describe("EventManager", () => { expect(listenerMock).toHaveBeenNthCalledWith(1, mockEvents[0]); }); - it("should propagate unsubscription errors", async () => { + it('should propagate unsubscription errors', async () => { getLatestEventIdMock.mockImplementation(() => { - throw new UnsubscribeFromEventsSourceError("Not found"); + throw new UnsubscribeFromEventsSourceError('Not found'); }); await expect(manager.start()).rejects.toThrow(UnsubscribeFromEventsSourceError); @@ -135,7 +138,7 @@ describe("EventManager", () => { expect(getEventsMock).toHaveBeenCalledTimes(0); }); - it("should continue processing multiple events", async () => { + it('should continue processing multiple events', async () => { getLatestEventIdMock.mockResolvedValue('eventId1'); const mockEvents: DriveEvent[] = [ @@ -156,14 +159,16 @@ describe("EventManager", () => { isShared: false, treeEventScopeId: 'volume1', eventId: 'eventId3', - } + }, ]; - getEventsMock.mockImplementationOnce(async function* () { - yield* mockEvents; - }).mockImplementation(async function* () { - // Empty generator for subsequent calls - }); + getEventsMock + .mockImplementationOnce(async function* () { + yield* mockEvents; + }) + .mockImplementation(async function* () { + // Empty generator for subsequent calls + }); await manager.start(); await jest.runOnlyPendingTimersAsync(); @@ -174,21 +179,21 @@ describe("EventManager", () => { getEventsMock.mockImplementationOnce(async function* () { yield* mockEvents; - }) + }); await jest.runOnlyPendingTimersAsync(); expect(listenerMock).toHaveBeenCalledTimes(4); expect(listenerMock).toHaveBeenNthCalledWith(1, mockEvents[0]); expect(listenerMock).toHaveBeenNthCalledWith(2, mockEvents[1]); }); - it("should retry on error with exponential backoff", async () => { + it('should retry on error with exponential backoff', async () => { getLatestEventIdMock.mockResolvedValue('eventId1'); let callCount = 0; getEventsMock.mockImplementation(async function* () { callCount++; if (callCount <= 3) { - throw new Error("Network error"); + throw new Error('Network error'); } yield { type: DriveEventType.FastForward, @@ -218,7 +223,7 @@ describe("EventManager", () => { expect(manager['retryIndex']).toEqual(0); }); - it("should stop polling when stopped immediately", async () => { + it('should stop polling when stopped immediately', async () => { getLatestEventIdMock.mockResolvedValue('eventId1'); getEventsMock.mockImplementation(async function* () { yield { @@ -237,7 +242,7 @@ describe("EventManager", () => { expect(getEventsMock).toHaveBeenCalledTimes(1); }); - it("should handle empty event streams", async () => { + it('should handle empty event streams', async () => { getLatestEventIdMock.mockResolvedValue('eventId1'); getEventsMock.mockImplementation(async function* () { diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts index 13d53217..6264466b 100644 --- a/js/sdk/src/internal/events/eventManager.ts +++ b/js/sdk/src/internal/events/eventManager.ts @@ -1,11 +1,10 @@ -import { Logger } from "../../interface"; -import { EventManagerInterface, Event, EventSubscription } from "./interface"; +import { Logger } from '../../interface'; +import { EventManagerInterface, Event, EventSubscription } from './interface'; const FIBONACCI_LIST = [1, 1, 2, 3, 5, 8, 13]; type Listener = (event: T) => Promise; - /** * Event manager general helper that is responsible for fetching events * from the server and notifying listeners about the events. @@ -99,7 +98,9 @@ export class EventManager { this.retryIndex = 0; } catch (error: unknown) { // This could be improved to catch api specific errors and let the listener errors bubble up directly - this.logger.error(`Failed to process events: ${error instanceof Error ? error.message : error} (retry ${this.retryIndex}, last event ID: ${this.latestEventId})`); + this.logger.error( + `Failed to process events: ${error instanceof Error ? error.message : error} (retry ${this.retryIndex}, last event ID: ${this.latestEventId})`, + ); this.retryIndex++; } if (listenerError) { @@ -109,7 +110,7 @@ export class EventManager { this.timeoutHandle = setTimeout(() => { this.processPromise = this.processEvents(); }, this.nextPollTimeout); - }; + } /** * Polling timeout is using exponential backoff with Fibonacci sequence. diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index aa508dfc..cba67f87 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -1,14 +1,14 @@ -import { Logger, ProtonDriveTelemetry } from "../../interface"; -import { DriveAPIService } from "../apiService"; -import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider } from "./interface"; -import { EventsAPIService } from "./apiService"; -import { CoreEventManager } from "./coreEventManager"; -import { VolumeEventManager } from "./volumeEventManager"; -import { EventManager } from "./eventManager"; -import { SharesManager } from "../shares/manager"; +import { Logger, ProtonDriveTelemetry } from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider } from './interface'; +import { EventsAPIService } from './apiService'; +import { CoreEventManager } from './coreEventManager'; +import { VolumeEventManager } from './volumeEventManager'; +import { EventManager } from './eventManager'; +import { SharesManager } from '../shares/manager'; -export type { DriveEvent, DriveListener } from "./interface"; -export { DriveEventType } from "./interface"; +export type { DriveEvent, DriveListener } from './interface'; +export { DriveEventType } from './interface'; const OWN_VOLUME_POLLING_INTERVAL = 30; const OTHER_VOLUME_POLLING_INTERVAL = 60; @@ -25,7 +25,13 @@ export class DriveEventsService { private volumeEventManagers: { [volumeId: string]: EventManager }; private logger: Logger; - constructor(private telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, private shareManagement: SharesManager, private cacheEventListeners: DriveListener[] = [], private latestEventIdProvider?: LatestEventIdProvider) { + constructor( + private telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + private shareManagement: SharesManager, + private cacheEventListeners: DriveListener[] = [], + private latestEventIdProvider?: LatestEventIdProvider, + ) { this.telemetry = telemetry; this.logger = telemetry.getLogger('events'); this.apiService = new EventsAPIService(apiService); @@ -56,7 +62,9 @@ export class DriveEventsService { // FIXME: Allow to pass own core events manager from the public interface. async subscribeToCoreEvents(callback: DriveListener): Promise { if (this.latestEventIdProvider === null || this.latestEventIdProvider === undefined) { - throw new Error('Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization'); + throw new Error( + 'Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization', + ); } if (this.coreEvents === undefined) { const coreEventManager = new CoreEventManager(this.logger, this.apiService); @@ -80,7 +88,9 @@ export class DriveEventsService { private async createVolumeEventManager(volumeId: string): Promise> { if (this.latestEventIdProvider === null || this.latestEventIdProvider === undefined) { - throw new Error('Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization'); + throw new Error( + 'Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization', + ); } const isOwnVolume = await this.shareManagement.isOwnVolume(volumeId); const pollingInterval = this.getDefaultVolumePollingInterval(isOwnVolume); @@ -96,6 +106,6 @@ export class DriveEventsService { } private getDefaultVolumePollingInterval(isOwnVolume: boolean): number { - return isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL + return isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL; } } diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index f32cc01d..09f35a3e 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -1,4 +1,4 @@ -import { Logger } from "../../interface"; +import { Logger } from '../../interface'; /** * Callback that accepts list of Drive events and flag whether no @@ -27,11 +27,11 @@ export interface LatestEventIdProvider { * events to fetch, or whether the listener should refresh its state. */ export type EventsListWithStatus = { - latestEventId: string, - more: boolean, - refresh: boolean, - events: T[], -} + latestEventId: string; + more: boolean; + refresh: boolean; + events: T[]; +}; /** * Internal event interface representing a list of specific Drive events. @@ -41,47 +41,55 @@ export type DriveEventsListWithStatus = EventsListWithStatus; type NodeCruEventType = DriveEventType.NodeCreated | DriveEventType.NodeUpdated; export type NodeEventType = NodeCruEventType | DriveEventType.NodeDeleted; -export type NodeEvent = { - type: NodeCruEventType, - nodeUid: string, - parentNodeUid?: string, - isTrashed: boolean, - isShared: boolean, - treeEventScopeId: string, - eventId: string, -} | { - type: DriveEventType.NodeDeleted, - nodeUid: string, - parentNodeUid?: string, - treeEventScopeId: string, - eventId: string, -} +export type NodeEvent = + | { + type: NodeCruEventType; + nodeUid: string; + parentNodeUid?: string; + isTrashed: boolean; + isShared: boolean; + treeEventScopeId: string; + eventId: string; + } + | { + type: DriveEventType.NodeDeleted; + nodeUid: string; + parentNodeUid?: string; + treeEventScopeId: string; + eventId: string; + }; export type FastForwardEvent = { - type: DriveEventType.FastForward, - treeEventScopeId: string, - eventId: string, -} + type: DriveEventType.FastForward; + treeEventScopeId: string; + eventId: string; +}; export type TreeRefreshEvent = { - type: DriveEventType.TreeRefresh, - treeEventScopeId: string, - eventId: string, -} + type: DriveEventType.TreeRefresh; + treeEventScopeId: string; + eventId: string; +}; export type TreeRemovalEvent = { - type: DriveEventType.TreeRemove, - treeEventScopeId: string, - eventId: 'none', -} + type: DriveEventType.TreeRemove; + treeEventScopeId: string; + eventId: 'none'; +}; export type SharedWithMeUpdated = { - type: DriveEventType.SharedWithMeUpdated, - eventId: string, - treeEventScopeId: 'core', -} + type: DriveEventType.SharedWithMeUpdated; + eventId: string; + treeEventScopeId: 'core'; +}; -export type DriveEvent = NodeEvent | FastForwardEvent | TreeRefreshEvent | TreeRemovalEvent | FastForwardEvent | SharedWithMeUpdated; +export type DriveEvent = + | NodeEvent + | FastForwardEvent + | TreeRefreshEvent + | TreeRemovalEvent + | FastForwardEvent + | SharedWithMeUpdated; export enum DriveEventType { NodeCreated = 'node_created', @@ -97,7 +105,7 @@ export enum DriveEventType { * This can happen if all shared nodes in that volume where unshared or if the * volume was deleted. */ -export class UnsubscribeFromEventsSourceError extends Error {}; +export class UnsubscribeFromEventsSourceError extends Error {} export interface EventManagerInterface { getLatestEventId(): Promise; diff --git a/js/sdk/src/internal/events/volumeEventManager.test.ts b/js/sdk/src/internal/events/volumeEventManager.test.ts index 0ddb221e..519a936a 100644 --- a/js/sdk/src/internal/events/volumeEventManager.test.ts +++ b/js/sdk/src/internal/events/volumeEventManager.test.ts @@ -1,16 +1,16 @@ -import { getMockLogger } from "../../tests/logger"; -import { NotFoundAPIError } from "../apiService"; -import { EventsAPIService } from "./apiService"; -import { VolumeEventManager } from "./volumeEventManager"; -import { DriveEventsListWithStatus, DriveEventType } from "./interface"; +import { getMockLogger } from '../../tests/logger'; +import { NotFoundAPIError } from '../apiService'; +import { EventsAPIService } from './apiService'; +import { VolumeEventManager } from './volumeEventManager'; +import { DriveEventsListWithStatus, DriveEventType } from './interface'; -jest.mock("./apiService"); +jest.mock('./apiService'); -describe("VolumeEventManager", () => { +describe('VolumeEventManager', () => { let manager: VolumeEventManager; let mockEventsAPIService: jest.Mocked; const mockLogger = getMockLogger(); - const volumeId = "volumeId123"; + const volumeId = 'volumeId123'; beforeEach(() => { jest.clearAllMocks(); @@ -22,16 +22,12 @@ describe("VolumeEventManager", () => { getCoreEvents: jest.fn(), } as any; - manager = new VolumeEventManager( - mockLogger, - mockEventsAPIService, - volumeId - ); + manager = new VolumeEventManager(mockLogger, mockEventsAPIService, volumeId); }); - describe("getLatestEventId", () => { - it("should return the latest event ID from API", async () => { - const expectedEventId = "eventId123"; + describe('getLatestEventId', () => { + it('should return the latest event ID from API', async () => { + const expectedEventId = 'eventId123'; mockEventsAPIService.getVolumeLatestEventId.mockResolvedValue(expectedEventId); const result = await manager.getLatestEventId(); @@ -40,83 +36,83 @@ describe("VolumeEventManager", () => { expect(mockEventsAPIService.getVolumeLatestEventId).toHaveBeenCalledWith(volumeId); }); - it("should throw UnsubscribeFromEventsSourceError when API returns NotFoundAPIError", async () => { - const notFoundError = new NotFoundAPIError("Event not found", 2501); + it('should throw UnsubscribeFromEventsSourceError when API returns NotFoundAPIError', async () => { + const notFoundError = new NotFoundAPIError('Event not found', 2501); mockEventsAPIService.getVolumeLatestEventId.mockRejectedValue(notFoundError); - await expect(manager.getLatestEventId()).rejects.toThrow("Event not found"); + await expect(manager.getLatestEventId()).rejects.toThrow('Event not found'); }); - it("should rethrow other errors", async () => { - const networkError = new Error("Network error"); + it('should rethrow other errors', async () => { + const networkError = new Error('Network error'); mockEventsAPIService.getVolumeLatestEventId.mockRejectedValue(networkError); - await expect(manager.getLatestEventId()).rejects.toThrow("Network error"); + await expect(manager.getLatestEventId()).rejects.toThrow('Network error'); }); }); - describe("getEvents", () => { - it("should yield events from API response", async () => { + describe('getEvents', () => { + it('should yield events from API response', async () => { const mockEventsResponse: DriveEventsListWithStatus = { - latestEventId: "eventId456", + latestEventId: 'eventId456', more: false, refresh: false, events: [ { type: DriveEventType.NodeCreated, - nodeUid: "node1", - parentNodeUid: "parent1", + nodeUid: 'node1', + parentNodeUid: 'parent1', isTrashed: false, isShared: false, treeEventScopeId: volumeId, - eventId: "eventId456", - } + eventId: 'eventId456', + }, ], }; mockEventsAPIService.getVolumeEvents.mockResolvedValue(mockEventsResponse); const events = []; - for await (const event of manager.getEvents("startEventId")) { + for await (const event of manager.getEvents('startEventId')) { events.push(event); } expect(events).toEqual(mockEventsResponse.events); - expect(mockEventsAPIService.getVolumeEvents).toHaveBeenCalledWith(volumeId, "startEventId"); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenCalledWith(volumeId, 'startEventId'); }); - it("should continue fetching when more events are available", async () => { + it('should continue fetching when more events are available', async () => { const firstResponse: DriveEventsListWithStatus = { - latestEventId: "eventId2", + latestEventId: 'eventId2', more: true, refresh: false, events: [ { type: DriveEventType.NodeCreated, - nodeUid: "node1", - parentNodeUid: "parent1", + nodeUid: 'node1', + parentNodeUid: 'parent1', isTrashed: false, isShared: false, treeEventScopeId: volumeId, - eventId: "eventId2", - } + eventId: 'eventId2', + }, ], }; const secondResponse: DriveEventsListWithStatus = { - latestEventId: "eventId3", + latestEventId: 'eventId3', more: false, refresh: false, events: [ { type: DriveEventType.NodeUpdated, - nodeUid: "node2", - parentNodeUid: "parent1", + nodeUid: 'node2', + parentNodeUid: 'parent1', isTrashed: false, isShared: false, treeEventScopeId: volumeId, - eventId: "eventId3", - } + eventId: 'eventId3', + }, ], }; @@ -125,7 +121,7 @@ describe("VolumeEventManager", () => { .mockResolvedValueOnce(secondResponse); const events = []; - for await (const event of manager.getEvents("startEventId")) { + for await (const event of manager.getEvents('startEventId')) { events.push(event); } @@ -133,13 +129,13 @@ describe("VolumeEventManager", () => { expect(events[0]).toEqual(firstResponse.events[0]); expect(events[1]).toEqual(secondResponse.events[0]); expect(mockEventsAPIService.getVolumeEvents).toHaveBeenCalledTimes(2); - expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(1, volumeId, "startEventId"); - expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(2, volumeId, "eventId2"); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(1, volumeId, 'startEventId'); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(2, volumeId, 'eventId2'); }); - it("should yield TreeRefresh event when refresh is true", async () => { + it('should yield TreeRefresh event when refresh is true', async () => { const mockEventsResponse: DriveEventsListWithStatus = { - latestEventId: "eventId789", + latestEventId: 'eventId789', more: false, refresh: true, events: [], @@ -148,7 +144,7 @@ describe("VolumeEventManager", () => { mockEventsAPIService.getVolumeEvents.mockResolvedValue(mockEventsResponse); const events = []; - for await (const event of manager.getEvents("startEventId")) { + for await (const event of manager.getEvents('startEventId')) { events.push(event); } @@ -156,13 +152,13 @@ describe("VolumeEventManager", () => { expect(events[0]).toEqual({ type: DriveEventType.TreeRefresh, treeEventScopeId: volumeId, - eventId: "eventId789", + eventId: 'eventId789', }); }); - it("should yield FastForward event when no events but eventId changed", async () => { + it('should yield FastForward event when no events but eventId changed', async () => { const mockEventsResponse: DriveEventsListWithStatus = { - latestEventId: "newEventId", + latestEventId: 'newEventId', more: false, refresh: false, events: [], @@ -171,7 +167,7 @@ describe("VolumeEventManager", () => { mockEventsAPIService.getVolumeEvents.mockResolvedValue(mockEventsResponse); const events = []; - for await (const event of manager.getEvents("oldEventId")) { + for await (const event of manager.getEvents('oldEventId')) { events.push(event); } @@ -179,17 +175,17 @@ describe("VolumeEventManager", () => { expect(events[0]).toEqual({ type: DriveEventType.FastForward, treeEventScopeId: volumeId, - eventId: "newEventId", + eventId: 'newEventId', }); }); - it("should yield TreeRemove event when API returns NotFoundAPIError", async () => { - const notFoundError = new NotFoundAPIError("Volume not found", 2501); + it('should yield TreeRemove event when API returns NotFoundAPIError', async () => { + const notFoundError = new NotFoundAPIError('Volume not found', 2501); mockEventsAPIService.getVolumeEvents.mockRejectedValue(notFoundError); const events = []; try { - for await (const event of manager.getEvents("startEventId")) { + for await (const event of manager.getEvents('startEventId')) { events.push(event); } } catch (error) { @@ -205,18 +201,18 @@ describe("VolumeEventManager", () => { }); }); - it("should rethrow non-NotFoundAPIError errors", async () => { - const networkError = new Error("Network error"); + it('should rethrow non-NotFoundAPIError errors', async () => { + const networkError = new Error('Network error'); mockEventsAPIService.getVolumeEvents.mockRejectedValue(networkError); - const eventGenerator = manager.getEvents("startEventId"); + const eventGenerator = manager.getEvents('startEventId'); const eventIterator = eventGenerator[Symbol.asyncIterator](); - await expect(eventIterator.next()).rejects.toThrow("Network error"); + await expect(eventIterator.next()).rejects.toThrow('Network error'); }); - it("should not yield events when events array is empty and eventId unchanged", async () => { + it('should not yield events when events array is empty and eventId unchanged', async () => { const mockEventsResponse: DriveEventsListWithStatus = { - latestEventId: "sameEventId", + latestEventId: 'sameEventId', more: false, refresh: false, events: [], @@ -225,7 +221,7 @@ describe("VolumeEventManager", () => { mockEventsAPIService.getVolumeEvents.mockResolvedValue(mockEventsResponse); const events = []; - for await (const event of manager.getEvents("sameEventId")) { + for await (const event of manager.getEvents('sameEventId')) { events.push(event); } @@ -233,8 +229,8 @@ describe("VolumeEventManager", () => { }); }); - describe("getLogger", () => { - it("should return logger with prefix", () => { + describe('getLogger', () => { + it('should return logger with prefix', () => { const logger = manager.getLogger(); expect(logger).toBeDefined(); // The logger should be wrapped with LoggerWithPrefix, but we can't easily test the prefix diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index 938dad30..c317f04a 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -1,17 +1,26 @@ -import { Logger } from "../../interface"; -import { LoggerWithPrefix } from "../../telemetry"; -import { EventsAPIService } from "./apiService"; -import { DriveEvent, DriveEventsListWithStatus, DriveEventType, EventManagerInterface, UnsubscribeFromEventsSourceError } from "./interface"; -import { NotFoundAPIError } from "../apiService"; +import { Logger } from '../../interface'; +import { LoggerWithPrefix } from '../../telemetry'; +import { EventsAPIService } from './apiService'; +import { + DriveEvent, + DriveEventsListWithStatus, + DriveEventType, + EventManagerInterface, + UnsubscribeFromEventsSourceError, +} from './interface'; +import { NotFoundAPIError } from '../apiService'; /** * Combines API and event manager to provide a service for listening to * volume events. Volume events are all about nodes updates. Whenever * there is update to the node metadata or content, the event is emitted. */ -export class VolumeEventManager implements EventManagerInterface{ - - constructor(private logger: Logger, private apiService: EventsAPIService, private volumeId: string) { +export class VolumeEventManager implements EventManagerInterface { + constructor( + private logger: Logger, + private apiService: EventsAPIService, + private volumeId: string, + ) { this.apiService = apiService; this.volumeId = volumeId; this.logger = new LoggerWithPrefix(logger, `volume ${volumeId}`); @@ -21,7 +30,7 @@ export class VolumeEventManager implements EventManagerInterface{ return this.logger; } - async * getEvents(eventId: string): AsyncIterable { + async *getEvents(eventId: string): AsyncIterable { try { let events: DriveEventsListWithStatus; let more = true; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index e2d1c1c8..5428341a 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -1,6 +1,6 @@ -import { MemberRole, NodeType } from "../../interface"; -import { getMockLogger } from "../../tests/logger"; -import { DriveAPIService, ErrorCode } from "../apiService"; +import { MemberRole, NodeType } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { DriveAPIService, ErrorCode } from '../apiService'; import { NodeAPIService } from './apiService'; function generateAPIFileNode(linkOverrides = {}, overrides = {}) { @@ -81,26 +81,26 @@ function generateFileNode(overrides = {}) { return { ...node, type: NodeType.File, - mediaType: "text", + mediaType: 'text', totalStorageSize: 42, encryptedCrypto: { ...node.encryptedCrypto, file: { - base64ContentKeyPacket: "contentKeyPacket", - armoredContentKeyPacketSignature: "contentKeyPacketSig", + base64ContentKeyPacket: 'contentKeyPacket', + armoredContentKeyPacketSignature: 'contentKeyPacketSig', }, activeRevision: { - uid: "volumeId~linkId~revisionId", - state: "active", + uid: 'volumeId~linkId~revisionId', + state: 'active', creationTime: new Date(1234567890000), storageSize: 12, - signatureEmail: "revSigEmail", - armoredExtendedAttributes: "{file}", + signatureEmail: 'revSigEmail', + armoredExtendedAttributes: '{file}', thumbnails: [], }, }, - ...overrides - } + ...overrides, + }; } function generateFolderNode(overrides = {}) { @@ -111,12 +111,12 @@ function generateFolderNode(overrides = {}) { encryptedCrypto: { ...node.encryptedCrypto, folder: { - armoredHashKey: "nodeHashKey", - armoredExtendedAttributes: "{folder}", + armoredHashKey: 'nodeHashKey', + armoredExtendedAttributes: '{folder}', }, }, - ...overrides - } + ...overrides, + }; } function generateAlbumNode(overrides = {}) { @@ -124,17 +124,17 @@ function generateAlbumNode(overrides = {}) { return { ...node, type: NodeType.Album, - ...overrides - } + ...overrides, + }; } function generateNode() { return { - hash: "nameHash", - encryptedName: "encName", + hash: 'nameHash', + encryptedName: 'encName', - uid: "volumeId~linkId", - parentUid: "volumeId~parentLinkId", + uid: 'volumeId~linkId', + parentUid: 'volumeId~parentLinkId', creationTime: new Date(123456789000), trashTime: undefined, @@ -143,16 +143,16 @@ function generateNode() { directMemberRole: MemberRole.Admin, encryptedCrypto: { - armoredKey: "nodeKey", - armoredNodePassphrase: "nodePass", - armoredNodePassphraseSignature: "nodePassSig", - nameSignatureEmail: "nameSigEmail", - signatureEmail: "sigEmail", + armoredKey: 'nodeKey', + armoredNodePassphrase: 'nodePass', + armoredNodePassphraseSignature: 'nodePassSig', + nameSignatureEmail: 'nameSigEmail', + signatureEmail: 'sigEmail', }, - } + }; } -describe("nodeAPIService", () => { +describe('nodeAPIService', () => { let apiMock: DriveAPIService; let api: NodeAPIService; @@ -172,19 +172,18 @@ describe("nodeAPIService", () => { describe('iterateNodes', () => { async function testIterateNodes(mockedLink: any, expectedNode: any, ownVolumeId = 'volumeId') { // @ts-expect-error Mocking for testing purposes - apiMock.post = jest.fn(async () => Promise.resolve({ - Links: [mockedLink], - })); + apiMock.post = jest.fn(async () => + Promise.resolve({ + Links: [mockedLink], + }), + ); const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], ownVolumeId)); expect(nodes).toStrictEqual([expectedNode]); } - + it('should get folder node', async () => { - await testIterateNodes( - generateAPIFolderNode(), - generateFolderNode(), - ); + await testIterateNodes(generateAPIFolderNode(), generateFolderNode()); }); it('should get root folder node', async () => { @@ -193,31 +192,28 @@ describe("nodeAPIService", () => { generateFolderNode({ parentUid: undefined }), ); }); - + it('should get file node', async () => { - await testIterateNodes( - generateAPIFileNode(), - generateFileNode(), - ); + await testIterateNodes(generateAPIFileNode(), generateFileNode()); }); it('should get album node', async () => { - await testIterateNodes( - generateAPIAlbumNode(), - generateAlbumNode(), - ); + await testIterateNodes(generateAPIAlbumNode(), generateAlbumNode()); }); it('should get shared node', async () => { await testIterateNodes( - generateAPIFolderNode({}, { - Sharing: { - ShareID: 'shareId', - }, - Membership: { - Permissions: 22, + generateAPIFolderNode( + {}, + { + Sharing: { + ShareID: 'shareId', + }, + Membership: { + Permissions: 22, + }, }, - }), + ), generateFolderNode({ isShared: true, shareId: 'shareId', @@ -228,14 +224,17 @@ describe("nodeAPIService", () => { it('should get shared node with unknown permissions', async () => { await testIterateNodes( - generateAPIFolderNode({}, { - Sharing: { - ShareID: 'shareId', - }, - Membership: { - Permissions: 42, + generateAPIFolderNode( + {}, + { + Sharing: { + ShareID: 'shareId', + }, + Membership: { + Permissions: 42, + }, }, - }), + ), generateFolderNode({ isShared: true, shareId: 'shareId', @@ -251,23 +250,25 @@ describe("nodeAPIService", () => { TrashTime: 123456, }), generateFileNode({ - trashTime: new Date(123456000) + trashTime: new Date(123456000), }), ); }); it('should get all recognised nodes before throwing error', async () => { // @ts-expect-error Mocking for testing purposes - apiMock.post = jest.fn(async () => Promise.resolve({ - Links: [ - generateAPIFolderNode(), - // Type 42 is not recognised - should throw error. - generateAPIFolderNode({ Type: 42 }), - // Type 43 is not recognised - should throw error. - generateAPIFileNode({ Type: 43 }), - generateAPIFileNode(), - ], - })); + apiMock.post = jest.fn(async () => + Promise.resolve({ + Links: [ + generateAPIFolderNode(), + // Type 42 is not recognised - should throw error. + generateAPIFolderNode({ Type: 42 }), + // Type 43 is not recognised - should throw error. + generateAPIFileNode({ Type: 43 }), + generateAPIFileNode(), + ], + }), + ); const generator = api.iterateNodes(['volumeId~nodeId'], 'volumeId'); @@ -283,28 +284,37 @@ describe("nodeAPIService", () => { try { await node3; } catch (error: any) { - expect(error.cause).toEqual([ - new Error('Unknown node type: 42'), - new Error('Unknown node type: 43'), - ]); + expect(error.cause).toEqual([new Error('Unknown node type: 42'), new Error('Unknown node type: 43')]); } }); it('should get nodes across various volumes', async () => { // @ts-expect-error Mocking for testing purposes - apiMock.post = jest.fn(async (url) => Promise.resolve({ - Links: [ - generateAPIFolderNode({ - LinkID: url.includes('volumeId1') ? 'nodeId1' : 'nodeId2', - ParentLinkID: url.includes('volumeId1') ? 'parentNodeId1' : 'parentNodeId2', - }), - ], - })); - - const nodes = await Array.fromAsync(api.iterateNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'], 'volumeId1')); + apiMock.post = jest.fn(async (url) => + Promise.resolve({ + Links: [ + generateAPIFolderNode({ + LinkID: url.includes('volumeId1') ? 'nodeId1' : 'nodeId2', + ParentLinkID: url.includes('volumeId1') ? 'parentNodeId1' : 'parentNodeId2', + }), + ], + }), + ); + + const nodes = await Array.fromAsync( + api.iterateNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'], 'volumeId1'), + ); expect(nodes).toStrictEqual([ - generateFolderNode({ uid: 'volumeId1~nodeId1', parentUid: 'volumeId1~parentNodeId1', directMemberRole: MemberRole.Admin }), - generateFolderNode({ uid: 'volumeId2~nodeId2', parentUid: 'volumeId2~parentNodeId2', directMemberRole: MemberRole.Inherited }), + generateFolderNode({ + uid: 'volumeId1~nodeId1', + parentUid: 'volumeId1~parentNodeId1', + directMemberRole: MemberRole.Admin, + }), + generateFolderNode({ + uid: 'volumeId2~nodeId2', + parentUid: 'volumeId2~parentNodeId2', + directMemberRole: MemberRole.Inherited, + }), ]); }); }); @@ -312,23 +322,25 @@ describe("nodeAPIService", () => { describe('trashNodes', () => { it('should trash nodes', async () => { // @ts-expect-error Mocking for testing purposes - apiMock.post = jest.fn(async () => Promise.resolve({ - Responses: [ - { - LinkID: 'nodeId1', - Response: { - Code: ErrorCode.OK, - } - }, - { - LinkID: 'nodeId2', - Response: { - Code: 2027, - Error: 'INSUFFICIENT_SCOPE' - } - } - ], - })); + apiMock.post = jest.fn(async () => + Promise.resolve({ + Responses: [ + { + LinkID: 'nodeId1', + Response: { + Code: ErrorCode.OK, + }, + }, + { + LinkID: 'nodeId2', + Response: { + Code: 2027, + Error: 'INSUFFICIENT_SCOPE', + }, + }, + ], + }), + ); const result = await Array.fromAsync(api.trashNodes(['volumeId~nodeId1', 'volumeId~nodeId2'])); expect(result).toEqual([ @@ -341,31 +353,35 @@ describe("nodeAPIService", () => { describe('restoreNodes', () => { it('should restore nodes', async () => { // @ts-expect-error Mocking for testing purposes - apiMock.put = jest.fn(async () => Promise.resolve({ - Responses: [ - { - LinkID: 'nodeId1', - Response: { - Code: ErrorCode.OK, - } - }, - { - LinkID: 'nodeId2', - Response: { - Code: 2027, - Error: 'INSUFFICIENT_SCOPE' - } - }, - { - LinkID: 'nodeId3', - Response: { - Code: 2000, - } - }, - ], - })); + apiMock.put = jest.fn(async () => + Promise.resolve({ + Responses: [ + { + LinkID: 'nodeId1', + Response: { + Code: ErrorCode.OK, + }, + }, + { + LinkID: 'nodeId2', + Response: { + Code: 2027, + Error: 'INSUFFICIENT_SCOPE', + }, + }, + { + LinkID: 'nodeId3', + Response: { + Code: 2000, + }, + }, + ], + }), + ); - const result = await Array.fromAsync(api.restoreNodes(['volumeId~nodeId1', 'volumeId~nodeId2', 'volumeId~nodeId3'])); + const result = await Array.fromAsync( + api.restoreNodes(['volumeId~nodeId1', 'volumeId~nodeId2', 'volumeId~nodeId3']), + ); expect(result).toEqual([ { uid: 'volumeId~nodeId1', ok: true }, { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, @@ -373,7 +389,7 @@ describe("nodeAPIService", () => { ]); }); - it('should fail restoring from multiple volumes', async () => { + it('should fail restoring from multiple volumes', async () => { try { await Array.fromAsync(api.restoreNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); throw new Error('Should have thrown'); @@ -386,23 +402,25 @@ describe("nodeAPIService", () => { describe('deleteNOdes', () => { it('should delete nodes', async () => { // @ts-expect-error Mocking for testing purposes - apiMock.post = jest.fn(async () => Promise.resolve({ - Responses: [ - { - LinkID: 'nodeId1', - Response: { - Code: ErrorCode.OK, - } - }, - { - LinkID: 'nodeId2', - Response: { - Code: 2027, - Error: 'INSUFFICIENT_SCOPE' - } - } - ], - })); + apiMock.post = jest.fn(async () => + Promise.resolve({ + Responses: [ + { + LinkID: 'nodeId1', + Response: { + Code: ErrorCode.OK, + }, + }, + { + LinkID: 'nodeId2', + Response: { + Code: 2027, + Error: 'INSUFFICIENT_SCOPE', + }, + }, + ], + }), + ); const result = await Array.fromAsync(api.deleteNodes(['volumeId~nodeId1', 'volumeId~nodeId2'])); expect(result).toEqual([ @@ -411,7 +429,7 @@ describe("nodeAPIService", () => { ]); }); - it('should fail deleting nodes from multiple volumes', async () => { + it('should fail deleting nodes from multiple volumes', async () => { try { await Array.fromAsync(api.deleteNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); throw new Error('Should have thrown'); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 05463141..d0c2f94c 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,57 +1,100 @@ -import { c } from "ttag"; - -import { ProtonDriveError, ValidationError } from "../../errors"; -import { Logger, NodeResult } from "../../interface"; -import { MemberRole, RevisionState } from "../../interface/nodes"; -import { DriveAPIService, drivePaths, isCodeOk, nodeTypeNumberToNodeType, permissionsToDirectMemberRole } from "../apiService"; -import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from "../uids"; -import { EncryptedNode, EncryptedRevision, Thumbnail } from "./interface"; - -type PostLoadLinksMetadataRequest = Extract['content']['application/json']; -type PostLoadLinksMetadataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; - -type GetChildrenResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders/{linkID}/children']['get']['responses']['200']['content']['application/json']; - -type GetTrashedNodesResponse = drivePaths['/drive/volumes/{volumeID}/trash']['get']['responses']['200']['content']['application/json']; - -type PutRenameNodeRequest = Extract['content']['application/json']; -type PutRenameNodeResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/rename']['put']['responses']['200']['content']['application/json']; - -type PutMoveNodeRequest = Extract['content']['application/json']; -type PutMoveNodeResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/move']['put']['responses']['200']['content']['application/json']; - -type PostTrashNodesRequest = Extract['content']['application/json']; -type PostTrashNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['responses']['200']['content']['application/json']; - -type PutRestoreNodesRequest = Extract['content']['application/json']; -type PutRestoreNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/restore_multiple']['put']['responses']['200']['content']['application/json']; - -type PostDeleteNodesRequest = Extract['content']['application/json']; -type PostDeleteNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['responses']['200']['content']['application/json']; - -type PostCreateFolderRequest = Extract['content']['application/json']; -type PostCreateFolderResponse = drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['responses']['200']['content']['application/json']; - -type GetRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; -type GetRevisionsResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions']['get']['responses']['200']['content']['application/json']; +import { c } from 'ttag'; + +import { ProtonDriveError, ValidationError } from '../../errors'; +import { Logger, NodeResult } from '../../interface'; +import { MemberRole, RevisionState } from '../../interface/nodes'; +import { + DriveAPIService, + drivePaths, + isCodeOk, + nodeTypeNumberToNodeType, + permissionsToDirectMemberRole, +} from '../apiService'; +import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from '../uids'; +import { EncryptedNode, EncryptedRevision, Thumbnail } from './interface'; + +type PostLoadLinksMetadataRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostLoadLinksMetadataResponse = + drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; + +type GetChildrenResponse = + drivePaths['/drive/v2/volumes/{volumeID}/folders/{linkID}/children']['get']['responses']['200']['content']['application/json']; + +type GetTrashedNodesResponse = + drivePaths['/drive/volumes/{volumeID}/trash']['get']['responses']['200']['content']['application/json']; + +type PutRenameNodeRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/rename']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PutRenameNodeResponse = + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/rename']['put']['responses']['200']['content']['application/json']; + +type PutMoveNodeRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/move']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PutMoveNodeResponse = + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/move']['put']['responses']['200']['content']['application/json']; + +type PostTrashNodesRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostTrashNodesResponse = + drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['responses']['200']['content']['application/json']; + +type PutRestoreNodesRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/trash/restore_multiple']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PutRestoreNodesResponse = + drivePaths['/drive/v2/volumes/{volumeID}/trash/restore_multiple']['put']['responses']['200']['content']['application/json']; + +type PostDeleteNodesRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostDeleteNodesResponse = + drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['responses']['200']['content']['application/json']; + +type PostCreateFolderRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateFolderResponse = + drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['responses']['200']['content']['application/json']; + +type GetRevisionResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; +type GetRevisionsResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions']['get']['responses']['200']['content']['application/json']; enum APIRevisionState { Draft = 0, Active = 1, Obsolete = 2, } -type PostRestoreRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore']['post']['responses']['202']['content']['application/json']; +type PostRestoreRevisionResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore']['post']['responses']['202']['content']['application/json']; -type DeleteRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['delete']['responses']['200']['content']['application/json']; +type DeleteRevisionResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['delete']['responses']['200']['content']['application/json']; /** * Provides API communication for fetching and manipulating nodes metadata. - * + * * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ export class NodeAPIService { - constructor(private logger: Logger, private apiService: DriveAPIService) { + constructor( + private logger: Logger, + private apiService: DriveAPIService, + ) { this.logger = logger; this.apiService = apiService; } @@ -59,12 +102,12 @@ export class NodeAPIService { async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise { const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, signal); const result = await nodesGenerator.next(); - await nodesGenerator.return("finish"); + await nodesGenerator.return('finish'); return result.value; } // Improvement requested: split into multiple calls for many nodes. - async* iterateNodes(nodeUids: string[], ownVolumeId: string, signal?: AbortSignal): AsyncGenerator { + async *iterateNodes(nodeUids: string[], ownVolumeId: string, signal?: AbortSignal): AsyncGenerator { const allNodeIds = nodeUids.map(splitNodeUid); const nodeIdsByVolumeId = new Map(); @@ -83,9 +126,13 @@ export class NodeAPIService { for (const [volumeId, nodeIds] of nodeIdsByVolumeId.entries()) { const isAdmin = volumeId === ownVolumeId; - const response = await this.apiService.post(`drive/v2/volumes/${volumeId}/links`, { - LinkIDs: nodeIds, - }, signal); + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/links`, + { + LinkIDs: nodeIds, + }, + signal, + ); for (const link of response.Links) { try { @@ -97,7 +144,6 @@ export class NodeAPIService { } } - if (errors.length) { this.logger.warn(`Failed to load ${errors.length} nodes`); throw new ProtonDriveError(c('Error').t`Failed to load some nodes`, { cause: errors }); @@ -108,13 +154,16 @@ export class NodeAPIService { async *iterateChildrenNodeUids(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { const { volumeId, nodeId } = splitNodeUid(parentNodeUid); - let anchor = ""; + let anchor = ''; while (true) { - const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/folders/${nodeId}/children?${anchor ? `AnchorID=${anchor}` : ''}`, signal); + const response = await this.apiService.get( + `drive/v2/volumes/${volumeId}/folders/${nodeId}/children?${anchor ? `AnchorID=${anchor}` : ''}`, + signal, + ); for (const linkID of response.LinkIDs) { yield makeNodeUid(volumeId, linkID); } - + if (!response.More || !response.AnchorID) { break; } @@ -126,8 +175,11 @@ export class NodeAPIService { async *iterateTrashedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { let page = 0; while (true) { - const response = await this.apiService.get(`drive/volumes/${volumeId}/trash?Page=${page}`, signal); - + const response = await this.apiService.get( + `drive/volumes/${volumeId}/trash?Page=${page}`, + signal, + ); + // The API returns items per shares which is not straightforward to // count if there is another page. We had mistakes in the past, thus // we rather end when the page is fully empty. @@ -142,7 +194,7 @@ export class NodeAPIService { hasItems = true; } } - + if (!hasItems) { break; } @@ -153,110 +205,115 @@ export class NodeAPIService { async renameNode( nodeUid: string, originalNode: { - hash?: string, + hash?: string; }, newNode: { - encryptedName: string, - nameSignatureEmail: string, - hash?: string, + encryptedName: string; + nameSignatureEmail: string; + hash?: string; }, signal?: AbortSignal, ): Promise { const { volumeId, nodeId } = splitNodeUid(nodeUid); - await this.apiService.put< - Omit, - PutRenameNodeResponse - >(`drive/v2/volumes/${volumeId}/links/${nodeId}/rename`, { - Name: newNode.encryptedName, - NameSignatureEmail: newNode.nameSignatureEmail, - Hash: newNode.hash, - OriginalHash: originalNode.hash || null, - }, signal); + await this.apiService.put, PutRenameNodeResponse>( + `drive/v2/volumes/${volumeId}/links/${nodeId}/rename`, + { + Name: newNode.encryptedName, + NameSignatureEmail: newNode.nameSignatureEmail, + Hash: newNode.hash, + OriginalHash: originalNode.hash || null, + }, + signal, + ); } async moveNode( nodeUid: string, oldNode: { - hash: string, + hash: string; }, newNode: { - parentUid: string, - armoredNodePassphrase: string, - armoredNodePassphraseSignature?: string, - signatureEmail?: string, - encryptedName: string, - nameSignatureEmail?: string, - hash: string, - contentHash?: string, + parentUid: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature?: string; + signatureEmail?: string; + encryptedName: string; + nameSignatureEmail?: string; + hash: string; + contentHash?: string; }, signal?: AbortSignal, ): Promise { const { volumeId, nodeId } = splitNodeUid(nodeUid); const { nodeId: newParentNodeId } = splitNodeUid(newNode.parentUid); - await this.apiService.put< - Omit, - PutMoveNodeResponse - >(`drive/v2/volumes/${volumeId}/links/${nodeId}/move`, { - ParentLinkID: newParentNodeId, - NodePassphrase: newNode.armoredNodePassphrase, - // @ts-expect-error: API accepts NodePassphraseSignature as optional. - NodePassphraseSignature: newNode.armoredNodePassphraseSignature, - // @ts-expect-error: API accepts SignatureEmail as optional. - SignatureEmail: newNode.signatureEmail, - Name: newNode.encryptedName, - // @ts-expect-error: API accepts NameSignatureEmail as optional. - NameSignatureEmail: newNode.nameSignatureEmail, - Hash: newNode.hash, - OriginalHash: oldNode.hash, - ContentHash: newNode.contentHash || null, - }, signal); + await this.apiService.put, PutMoveNodeResponse>( + `drive/v2/volumes/${volumeId}/links/${nodeId}/move`, + { + ParentLinkID: newParentNodeId, + NodePassphrase: newNode.armoredNodePassphrase, + // @ts-expect-error: API accepts NodePassphraseSignature as optional. + NodePassphraseSignature: newNode.armoredNodePassphraseSignature, + // @ts-expect-error: API accepts SignatureEmail as optional. + SignatureEmail: newNode.signatureEmail, + Name: newNode.encryptedName, + // @ts-expect-error: API accepts NameSignatureEmail as optional. + NameSignatureEmail: newNode.nameSignatureEmail, + Hash: newNode.hash, + OriginalHash: oldNode.hash, + ContentHash: newNode.contentHash || null, + }, + signal, + ); } // Improvement requested: split into multiple calls for many nodes. - async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Trashing items`, nodeIds); - const response = await this.apiService.post< - PostTrashNodesRequest, - PostTrashNodesResponse - >(`drive/v2/volumes/${volumeId}/trash_multiple`, { - LinkIDs: nodeIds.map(({ nodeId }) => nodeId), - }, signal); + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/trash_multiple`, + { + LinkIDs: nodeIds.map(({ nodeId }) => nodeId), + }, + signal, + ); // TODO: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } // Improvement requested: split into multiple calls for many nodes. - async* restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Restoring items`, nodeIds); - const response = await this.apiService.put< - PutRestoreNodesRequest, - PutRestoreNodesResponse - >(`drive/v2/volumes/${volumeId}/trash/restore_multiple`, { - LinkIDs: nodeIds.map(({ nodeId }) => nodeId), - }, signal); + const response = await this.apiService.put( + `drive/v2/volumes/${volumeId}/trash/restore_multiple`, + { + LinkIDs: nodeIds.map(({ nodeId }) => nodeId), + }, + signal, + ); // TODO: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); } // Improvement requested: split into multiple calls for many nodes. - async* deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Deleting items`, nodeIds); - const response = await this.apiService.post< - PostDeleteNodesRequest, - PostDeleteNodesResponse - >(`drive/v2/volumes/${volumeId}/trash/delete_multiple`, { - LinkIDs: nodeIds.map(({ nodeId }) => nodeId), - }, signal); + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/trash/delete_multiple`, + { + LinkIDs: nodeIds.map(({ nodeId }) => nodeId), + }, + signal, + ); // TODO: remove `as` when backend fixes OpenAPI schema. yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); @@ -265,33 +322,33 @@ export class NodeAPIService { async createFolder( parentUid: string, newNode: { - armoredKey: string, - armoredHashKey: string, - armoredNodePassphrase: string, - armoredNodePassphraseSignature: string, - signatureEmail: string, - encryptedName: string, - hash: string, - armoredExtendedAttributes?: string, + armoredKey: string; + armoredHashKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + signatureEmail: string; + encryptedName: string; + hash: string; + armoredExtendedAttributes?: string; }, ): Promise { const { volumeId, nodeId: parentId } = splitNodeUid(parentUid); - const response = await this.apiService.post< - PostCreateFolderRequest, - PostCreateFolderResponse - >(`drive/v2/volumes/${volumeId}/folders`, { - ParentLinkID: parentId, - NodeKey: newNode.armoredKey, - NodeHashKey: newNode.armoredHashKey, - NodePassphrase: newNode.armoredNodePassphrase, - NodePassphraseSignature: newNode.armoredNodePassphraseSignature, - SignatureEmail: newNode.signatureEmail, - Name: newNode.encryptedName, - Hash: newNode.hash, - // @ts-expect-error: XAttr is optional as undefined. - XAttr: newNode.armoredExtendedAttributes, - }); + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/folders`, + { + ParentLinkID: parentId, + NodeKey: newNode.armoredKey, + NodeHashKey: newNode.armoredHashKey, + NodePassphrase: newNode.armoredNodePassphrase, + NodePassphraseSignature: newNode.armoredNodePassphraseSignature, + SignatureEmail: newNode.signatureEmail, + Name: newNode.encryptedName, + Hash: newNode.hash, + // @ts-expect-error: XAttr is optional as undefined. + XAttr: newNode.armoredExtendedAttributes, + }, + ); return makeNodeUid(volumeId, response.Folder.ID); } @@ -299,32 +356,39 @@ export class NodeAPIService { async getRevision(nodeRevisionUid: string, signal?: AbortSignal): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); - const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?NoBlockUrls=true`, signal); + const response = await this.apiService.get( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?NoBlockUrls=true`, + signal, + ); return transformRevisionResponse(volumeId, nodeId, response.Revision); } async getRevisions(nodeUid: string, signal?: AbortSignal): Promise { const { volumeId, nodeId } = splitNodeUid(nodeUid); - const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, signal); - return response.Revisions - .filter((revision) => revision.State === APIRevisionState.Active || revision.State === APIRevisionState.Obsolete) - .map((revision) => transformRevisionResponse(volumeId, nodeId, revision)); + const response = await this.apiService.get( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, + signal, + ); + return response.Revisions.filter( + (revision) => revision.State === APIRevisionState.Active || revision.State === APIRevisionState.Obsolete, + ).map((revision) => transformRevisionResponse(volumeId, nodeId, revision)); } async restoreRevision(nodeRevisionUid: string): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); - await this.apiService.post< - undefined, - PostRestoreRevisionResponse - >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}/restore`); + await this.apiService.post( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}/restore`, + ); } async deleteRevision(nodeRevisionUid: string): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); - await this.apiService.delete(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); + await this.apiService.delete( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, + ); } } @@ -338,14 +402,18 @@ function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { } type LinkResponse = { - LinkID: string, + LinkID: string; Response: { - Code?: number, - Error?: string, - } + Code?: number; + Error?: string; + }; }; -function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: LinkResponse[] = []): Generator { +function* handleResponseErrors( + nodeUids: string[], + volumeId: string, + responses: LinkResponse[] = [], +): Generator { const errors = new Map(); responses.forEach((response) => { @@ -365,7 +433,12 @@ function* handleResponseErrors(nodeUids: string[], volumeId: string, responses: } } -function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLinksMetadataResponse['Links'][0], isAdmin: boolean): EncryptedNode { +function linkToEncryptedNode( + logger: Logger, + volumeId: string, + link: PostLoadLinksMetadataResponse['Links'][0], + isAdmin: boolean, +): EncryptedNode { const baseNodeMetadata = { // Internal metadata hash: link.Link.NameHash || undefined, @@ -375,21 +448,23 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin uid: makeNodeUid(volumeId, link.Link.LinkID), parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, type: nodeTypeNumberToNodeType(logger, link.Link.Type), - creationTime: new Date(link.Link.CreateTime*1000), - trashTime: link.Link.TrashTime ? new Date(link.Link.TrashTime*1000) : undefined, + creationTime: new Date(link.Link.CreateTime * 1000), + trashTime: link.Link.TrashTime ? new Date(link.Link.TrashTime * 1000) : undefined, // Sharing node metadata shareId: link.Sharing?.ShareID || undefined, isShared: !!link.Sharing, - directMemberRole: isAdmin ? MemberRole.Admin : permissionsToDirectMemberRole(logger, link.Membership?.Permissions), - } + directMemberRole: isAdmin + ? MemberRole.Admin + : permissionsToDirectMemberRole(logger, link.Membership?.Permissions), + }; const baseCryptoNodeMetadata = { signatureEmail: link.Link.SignatureEmail || undefined, nameSignatureEmail: link.Link.NameSignatureEmail || undefined, armoredKey: link.Link.NodeKey, armoredNodePassphrase: link.Link.NodePassphrase, armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, - } + }; if (link.Link.Type === 1 && link.Folder) { return { @@ -401,7 +476,7 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin armoredHashKey: link.Folder.NodeHashKey as string, }, }, - } + }; } if (link.Link.Type === 2 && link.File && link.File.ActiveRevision) { @@ -418,14 +493,17 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin activeRevision: { uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.File.ActiveRevision.RevisionID), state: RevisionState.Active, - creationTime: new Date(link.File.ActiveRevision.CreateTime*1000), + creationTime: new Date(link.File.ActiveRevision.CreateTime * 1000), storageSize: link.File.ActiveRevision.EncryptedSize, signatureEmail: link.File.ActiveRevision.SignatureEmail || undefined, armoredExtendedAttributes: link.File.ActiveRevision.XAttr || undefined, - thumbnails: link.File.ActiveRevision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, link.Link.LinkID, thumbnail)) || [], + thumbnails: + link.File.ActiveRevision.Thumbnails?.map((thumbnail) => + transformThumbnail(volumeId, link.Link.LinkID, thumbnail), + ) || [], }, }, - } + }; } if (link.Link.Type === 3) { @@ -434,7 +512,7 @@ function linkToEncryptedNode(logger: Logger, volumeId: string, link: PostLoadLin encryptedCrypto: { ...baseCryptoNodeMetadata, }, - } + }; } throw new Error(`Unknown node type: ${link.Link.Type}`); @@ -449,19 +527,23 @@ function transformRevisionResponse( uid: makeNodeRevisionUid(volumeId, nodeId, revision.ID), state: revision.State === APIRevisionState.Active ? RevisionState.Active : RevisionState.Superseded, // @ts-expect-error: API doc is wrong, CreateTime is not optional. - creationTime: new Date(revision.CreateTime*1000), + creationTime: new Date(revision.CreateTime * 1000), storageSize: revision.Size, signatureEmail: revision.SignatureEmail || undefined, armoredExtendedAttributes: revision.XAttr || undefined, thumbnails: revision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, nodeId, thumbnail)) || [], - } + }; } -function transformThumbnail(volumeId: string, nodeId: string, thumbnail: { ThumbnailID: string | null, Type: 1 | 2 | 3}): Thumbnail { +function transformThumbnail( + volumeId: string, + nodeId: string, + thumbnail: { ThumbnailID: string | null; Type: 1 | 2 | 3 }, +): Thumbnail { return { // TODO: Legacy thumbnails didn't have ID but we don't have them anymore. Remove typing once API doc is updated. uid: makeNodeThumbnailUid(volumeId, nodeId, thumbnail.ThumbnailID as string), // TODO: We don't support any other thumbnail type yet. type: thumbnail.Type as 1 | 2, - } + }; } diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index c6703ed6..85835ef2 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -1,20 +1,24 @@ -import { MemoryCache } from "../../cache"; -import { NodeType, MemberRole, RevisionState, resultOk, Result } from "../../interface"; -import { getMockLogger } from "../../tests/logger"; -import { CACHE_TAG_KEYS, NodesCache } from "./cache"; -import { DecryptedNode, DecryptedRevision } from "./interface"; - -function generateNode(uid: string, parentUid='root', params: Partial & { volumeId?: string } = {}): DecryptedNode { +import { MemoryCache } from '../../cache'; +import { NodeType, MemberRole, RevisionState, resultOk, Result } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { CACHE_TAG_KEYS, NodesCache } from './cache'; +import { DecryptedNode, DecryptedRevision } from './interface'; + +function generateNode( + uid: string, + parentUid = 'root', + params: Partial & { volumeId?: string } = {}, +): DecryptedNode { return { - uid: `${params.volumeId || "volumeId"}~:${uid}`, - parentUid: `${params.volumeId || "volumeId"}~:${parentUid}`, + uid: `${params.volumeId || 'volumeId'}~:${uid}`, + parentUid: `${params.volumeId || 'volumeId'}~:${parentUid}`, directMemberRole: MemberRole.Admin, type: NodeType.File, - mediaType: "text", + mediaType: 'text', isShared: false, creationTime: new Date(), trashTime: undefined, - volumeId: "volumeId", + volumeId: 'volumeId', isStale: false, activeRevision: undefined, folder: undefined, @@ -108,7 +112,7 @@ describe('nodesCache', () => { creationTime: new Date('2021-01-01'), storageSize: 100, contentAuthor: resultOk('test@test.com'), - claimedModificationTime: new Date('2021-02-01') + claimedModificationTime: new Date('2021-02-01'), }); const node = generateNode('node1', '', { activeRevision }); @@ -135,7 +139,9 @@ describe('nodesCache', () => { await cache.getNode('badObject'); fail('Should have thrown an error'); } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialise node: Unexpected token \'a\', \"aaa\" is not valid JSON'); + expect(`${error}`).toBe( + 'Error: Failed to deserialise node: Unexpected token \'a\', \"aaa\" is not valid JSON', + ); } try { @@ -153,7 +159,7 @@ describe('nodesCache', () => { cache, ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b'], ['node3'], - ) + ); }); it('should remove node and its children', async () => { @@ -162,8 +168,8 @@ describe('nodesCache', () => { await verifyNodesCache( cache, ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node3'], - ['node2', 'node2a', 'node2b',], - ) + ['node2', 'node2a', 'node2b'], + ); }); it('should remove node and its children recursively', async () => { @@ -198,9 +204,21 @@ describe('nodesCache', () => { expect(nodeUids).toStrictEqual(['volumeId~:node1', 'volumeId~:node2', 'volumeId~:node3']); await verifyNodesCache( cache, - ['root', 'node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b', 'node3'], + [ + 'root', + 'node1', + 'node1a', + 'node1b', + 'node1c', + 'node1c-alpha', + 'node1c-beta', + 'node2', + 'node2a', + 'node2b', + 'node3', + ], ['badObject'], - ) + ); }); it('should iterate trashed nodes', async () => { @@ -224,8 +242,18 @@ describe('nodesCache', () => { await generateTreeStructure(cache); await cache.setNodesStaleFromVolume('volumeId'); - const staleNodeUids = ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b', 'node3'] - .map((uid) => `volumeId~:${uid}`); + const staleNodeUids = [ + 'node1', + 'node1a', + 'node1b', + 'node1c', + 'node1c-alpha', + 'node1c-beta', + 'node2', + 'node2a', + 'node2b', + 'node3', + ].map((uid) => `volumeId~:${uid}`); const result = await Array.fromAsync(cache.iterateNodes([...staleNodeUids, 'volume2~:root-otherVolume'])); const got = result.map((item) => ({ uid: item.uid, isStale: item.ok ? item.node.isStale : item.error })); const expected = [ diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 54a5a032..9b61066c 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -1,7 +1,7 @@ -import { EntityResult } from "../../cache"; -import { ProtonDriveEntitiesCache, Logger, resultOk, Result } from "../../interface"; -import { splitNodeUid } from "../uids"; -import { DecryptedNode, DecryptedRevision } from "./interface"; +import { EntityResult } from '../../cache'; +import { ProtonDriveEntitiesCache, Logger, resultOk, Result } from '../../interface'; +import { splitNodeUid } from '../uids'; +import { DecryptedNode, DecryptedRevision } from './interface'; export enum CACHE_TAG_KEYS { ParentUid = 'nodeParentUid', @@ -9,10 +9,7 @@ export enum CACHE_TAG_KEYS { Roots = 'nodeRoot', } -type DecryptedNodeResult = ( - {uid: string, ok: true, node: DecryptedNode} | - {uid: string, ok: false, error: string} -); +type DecryptedNodeResult = { uid: string; ok: true; node: DecryptedNode } | { uid: string; ok: false; error: string }; /** * Provides caching for nodes metadata. @@ -23,7 +20,10 @@ type DecryptedNodeResult = ( * The cache of node metadata should not contain any crypto material. */ export class NodesCache { - constructor(private logger: Logger, private driveCache: ProtonDriveEntitiesCache) { + constructor( + private logger: Logger, + private driveCache: ProtonDriveEntitiesCache, + ) { this.logger = logger; this.driveCache = driveCache; } @@ -35,12 +35,12 @@ export class NodesCache { const tags = [`volume:${volumeId}`]; if (node.parentUid) { - tags.push(`${CACHE_TAG_KEYS.ParentUid}:${node.parentUid}`) + tags.push(`${CACHE_TAG_KEYS.ParentUid}:${node.parentUid}`); } else { - tags.push(`${CACHE_TAG_KEYS.Roots}:${volumeId}`) + tags.push(`${CACHE_TAG_KEYS.Roots}:${volumeId}`); } if (node.trashTime) { - tags.push(`${CACHE_TAG_KEYS.Trashed}`) + tags.push(`${CACHE_TAG_KEYS.Trashed}`); } await this.driveCache.setEntity(key, nodeData, tags); @@ -53,7 +53,7 @@ export class NodesCache { return deserialiseNode(nodeData); } catch (error: unknown) { await this.removeCorruptedNode({ nodeUid }, error); - throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`) + throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`); } } @@ -94,7 +94,10 @@ export class NodesCache { * nodes and rather let SDK re-fetch them than to auotmatically * fix issues and do not bother user with it. */ - private async removeCorruptedNode({ nodeUid, cacheUid }: { nodeUid?: string, cacheUid?: string }, corruptionError: unknown): Promise { + private async removeCorruptedNode( + { nodeUid, cacheUid }: { nodeUid?: string; cacheUid?: string }, + corruptionError: unknown, + ): Promise { this.logger.error(`Removing corrupted nodes from the cache`, corruptionError); try { if (nodeUid) { @@ -106,7 +109,9 @@ export class NodesCache { // The node will not be returned, thus SDK will re-fetch // and re-cache it. Setting it again should then fix the // problem. - this.logger.warn(`Failed to remove corrupted node from the cache: ${removingError instanceof Error ? removingError.message : removingError}`); + this.logger.warn( + `Failed to remove corrupted node from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, + ); } } @@ -129,7 +134,9 @@ export class NodesCache { private async getRecursiveChildrenCacheUids(parentNodeUid: string): Promise { const cacheUids = []; - for await (const result of this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`)) { + for await (const result of this.driveCache.iterateEntitiesByTag( + `${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`, + )) { cacheUids.push(result.key); const childrenCacheUids = await this.getRecursiveChildrenCacheUids(getNodeUid(result.key)); cacheUids.push(...childrenCacheUids); @@ -148,7 +155,9 @@ export class NodesCache { } async *iterateChildren(parentNodeUid: string): AsyncGenerator { - for await (const result of this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`)) { + for await (const result of this.driveCache.iterateEntitiesByTag( + `${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`, + )) { const node = await this.convertCacheResult(result); if (node && (!node.ok || !node.node.trashTime)) { yield node; @@ -178,13 +187,13 @@ export class NodesCache { try { nodeUid = getNodeUid(result.key); } catch (error: unknown) { - await this.removeCorruptedNode({ cacheUid: result.key }, error) + await this.removeCorruptedNode({ cacheUid: result.key }, error); return null; } if (result.ok) { let node; try { - node = deserialiseNode(result.value) + node = deserialiseNode(result.value); } catch (error: unknown) { await this.removeCorruptedNode({ nodeUid }, error); return null; @@ -193,7 +202,7 @@ export class NodesCache { uid: nodeUid, ok: true, node, - } + }; } else { return { ...result, @@ -239,31 +248,38 @@ function serialiseNode(node: DecryptedNode) { function deserialiseNode(nodeData: string): DecryptedNode { const node = JSON.parse(nodeData); if ( - !node || typeof node !== 'object' || - !node.uid || typeof node.uid !== 'string' || - !node.directMemberRole || typeof node.directMemberRole !== 'string' || - !node.type || typeof node.type !== 'string' || - (typeof node.mediaType !== 'string' && node.mediaType !== undefined) || - typeof node.isShared !== 'boolean' || - !node.creationTime || typeof node.creationTime !== 'string' || - (typeof node.trashTime !== 'string' && node.trashTime !== undefined) || - (typeof node.folder !== 'object' && node.folder !== undefined) || - (typeof node.folder?.claimedModificationTime !== 'string' && node.folder?.claimedModificationTime !== undefined) - ) { - throw new Error(`Invalid node data: ${nodeData}`); - } - return { - ...node, - creationTime: new Date(node.creationTime), - trashTime: node.trashTime ? new Date(node.trashTime) : undefined, - activeRevision: node.activeRevision ? deserialiseRevision(node.activeRevision) : undefined, - folder: node.folder + !node || + typeof node !== 'object' || + !node.uid || + typeof node.uid !== 'string' || + !node.directMemberRole || + typeof node.directMemberRole !== 'string' || + !node.type || + typeof node.type !== 'string' || + (typeof node.mediaType !== 'string' && node.mediaType !== undefined) || + typeof node.isShared !== 'boolean' || + !node.creationTime || + typeof node.creationTime !== 'string' || + (typeof node.trashTime !== 'string' && node.trashTime !== undefined) || + (typeof node.folder !== 'object' && node.folder !== undefined) || + (typeof node.folder?.claimedModificationTime !== 'string' && node.folder?.claimedModificationTime !== undefined) + ) { + throw new Error(`Invalid node data: ${nodeData}`); + } + return { + ...node, + creationTime: new Date(node.creationTime), + trashTime: node.trashTime ? new Date(node.trashTime) : undefined, + activeRevision: node.activeRevision ? deserialiseRevision(node.activeRevision) : undefined, + folder: node.folder ? { - ...node.folder, - claimedModificationTime: node.folder.claimedModificationTime ? new Date(node.folder.claimedModificationTime) : undefined, - } + ...node.folder, + claimedModificationTime: node.folder.claimedModificationTime + ? new Date(node.folder.claimedModificationTime) + : undefined, + } : undefined, - }; + }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -279,7 +295,7 @@ function deserialiseRevision(revision: any): Result { return resultOk({ ...revision.value, creationTime: new Date(revision.value.creationTime), - claimedModificationTime: new Date(revision.value.claimedModificationTime) + claimedModificationTime: new Date(revision.value.claimedModificationTime), }); } diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index 485870ce..574bd894 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -1,20 +1,20 @@ -import { PrivateKey, SessionKey } from "../../crypto"; -import { MemoryCache } from "../../cache"; -import { CachedCryptoMaterial } from "../../interface"; -import { getMockLogger } from "../../tests/logger"; -import { NodesCryptoCache } from "./cryptoCache"; +import { PrivateKey, SessionKey } from '../../crypto'; +import { MemoryCache } from '../../cache'; +import { CachedCryptoMaterial } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { NodesCryptoCache } from './cryptoCache'; describe('nodesCryptoCache', () => { let memoryCache: MemoryCache; let cache: NodesCryptoCache; const generatePrivateKey = (name: string) => { - return name as unknown as PrivateKey - } + return name as unknown as PrivateKey; + }; const generateSessionKey = (name: string) => { - return name as unknown as SessionKey - } + return name as unknown as SessionKey; + }; beforeEach(async () => { memoryCache = new MemoryCache(); @@ -28,7 +28,12 @@ describe('nodesCryptoCache', () => { it('should store and retrieve keys', async () => { const nodeId = 'newNodeId'; - const keys = { passphrase: 'pass', key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey'), hashKey: undefined }; + const keys = { + passphrase: 'pass', + key: generatePrivateKey('privateKey'), + passphraseSessionKey: generateSessionKey('sessionKey'), + hashKey: undefined, + }; await cache.setNodeKeys(nodeId, keys); const result = await cache.getNodeKeys(nodeId); @@ -38,8 +43,18 @@ describe('nodesCryptoCache', () => { it('should replace and retrieve new keys', async () => { const nodeId = 'newNodeId'; - const keys1 = { passphrase: 'pass', key: generatePrivateKey('privateKey1'), passphraseSessionKey: generateSessionKey('sessionKey1'), hashKey: undefined }; - const keys2 = { passphrase: 'pass', key: generatePrivateKey('privateKey2'), passphraseSessionKey: generateSessionKey('sessionKey2'), hashKey: undefined }; + const keys1 = { + passphrase: 'pass', + key: generatePrivateKey('privateKey1'), + passphraseSessionKey: generateSessionKey('sessionKey1'), + hashKey: undefined, + }; + const keys2 = { + passphrase: 'pass', + key: generatePrivateKey('privateKey2'), + passphraseSessionKey: generateSessionKey('sessionKey2'), + hashKey: undefined, + }; await cache.setNodeKeys(nodeId, keys1); await cache.setNodeKeys(nodeId, keys2); @@ -50,7 +65,12 @@ describe('nodesCryptoCache', () => { it('should remove keys', async () => { const nodeId = 'newNodeId'; - const keys = { passphrase: 'pass', key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey'), hashKey: undefined }; + const keys = { + passphrase: 'pass', + key: generatePrivateKey('privateKey'), + passphraseSessionKey: generateSessionKey('sessionKey'), + hashKey: undefined, + }; await cache.setNodeKeys(nodeId, keys); await cache.removeNodeKeys([nodeId]); @@ -89,4 +109,4 @@ describe('nodesCryptoCache', () => { expect(`${error}`).toBe('Error: Entity not found'); } }); -}); \ No newline at end of file +}); diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts index 47a3f1b6..20ea3020 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -1,14 +1,17 @@ -import { ProtonDriveCryptoCache, Logger } from "../../interface"; -import { DecryptedNodeKeys } from "./interface"; +import { ProtonDriveCryptoCache, Logger } from '../../interface'; +import { DecryptedNodeKeys } from './interface'; /** * Provides caching for node crypto material. - * + * * The cache is responsible for serialising and deserialising node * crypto material. */ export class NodesCryptoCache { - constructor(private logger: Logger, private driveCache: ProtonDriveCryptoCache) { + constructor( + private logger: Logger, + private driveCache: ProtonDriveCryptoCache, + ) { this.logger = logger; this.driveCache = driveCache; } @@ -26,7 +29,9 @@ export class NodesCryptoCache { } catch (removingError: unknown) { // The node keys will not be returned, thus SDK will re-fetch // and re-cache it. Setting it again should then fix the problem. - this.logger.warn(`Failed to remove corrupted node keys from the cache: ${removingError instanceof Error ? removingError.message : removingError}`); + this.logger.warn( + `Failed to remove corrupted node keys from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, + ); } throw new Error(`Failed to deserialize node keys: missing passphrase`); } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index c0eb29f7..783c1182 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,10 +1,10 @@ -import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from "../../interface"; -import { getMockTelemetry } from "../../tests/telemetry"; -import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from "./interface"; -import { NodesCryptoService } from "./cryptoService"; +import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; +import { ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from './interface'; +import { NodesCryptoService } from './cryptoService'; -describe("nodesCryptoService", () => { +describe('nodesCryptoService', () => { let telemetry: ProtonDriveTelemetry; let driveCrypto: DriveCrypto; let account: ProtonDriveAccount; @@ -17,32 +17,44 @@ describe("nodesCryptoService", () => { telemetry = getMockTelemetry(); driveCrypto = { - decryptKey: jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.SIGNED_AND_VALID, - })), - decryptNodeName: jest.fn(async () => Promise.resolve({ - name: "name", - verified: VERIFICATION_STATUS.SIGNED_AND_VALID, - })), - decryptNodeHashKey: jest.fn(async () => Promise.resolve({ - hashKey: new Uint8Array(), - verified: VERIFICATION_STATUS.SIGNED_AND_VALID, - })), - decryptExtendedAttributes: jest.fn(async () => Promise.resolve({ - extendedAttributes: "{}", - verified: VERIFICATION_STATUS.SIGNED_AND_VALID, - })), - encryptNodeName: jest.fn(async () => Promise.resolve({ - armoredNodeName: "armoredName", - })), + decryptKey: jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }), + ), + decryptNodeName: jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }), + ), + decryptNodeHashKey: jest.fn(async () => + Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }), + ), + decryptExtendedAttributes: jest.fn(async () => + Promise.resolve({ + extendedAttributes: '{}', + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }), + ), + encryptNodeName: jest.fn(async () => + Promise.resolve({ + armoredNodeName: 'armoredName', + }), + ), // @ts-expect-error No need to implement all methods for mocking - decryptAndVerifySessionKey: jest.fn(async () => Promise.resolve({ - sessionKey: "contentKeyPacketSessionKey", - verified: VERIFICATION_STATUS.SIGNED_AND_VALID, - })), + decryptAndVerifySessionKey: jest.fn(async () => + Promise.resolve({ + sessionKey: 'contentKeyPacketSessionKey', + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }), + ), }; account = { // @ts-expect-error No need to implement all methods for mocking @@ -51,8 +63,8 @@ describe("nodesCryptoService", () => { // @ts-expect-error No need to implement all methods for mocking sharesService = { getMyFilesShareMemberEmailKey: jest.fn(async () => ({ - email: "email", - addressKey: "key" as unknown as PrivateKey, + email: 'email', + addressKey: 'key' as unknown as PrivateKey, })), getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), }; @@ -60,13 +72,13 @@ describe("nodesCryptoService", () => { cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); }); - const parentKey = "parentKey" as unknown as PrivateKey; + const parentKey = 'parentKey' as unknown as PrivateKey; function verifyLogEventVerificationError(options = {}) { expect(telemetry.logEvent).toHaveBeenCalledTimes(1); expect(telemetry.logEvent).toHaveBeenCalledWith({ - eventName: "verificationError", - volumeType: "own_volume", + eventName: 'verificationError', + volumeType: 'own_volume', fromBefore2024: false, addressMatchingDefaultShare: false, ...options, @@ -76,174 +88,206 @@ describe("nodesCryptoService", () => { function verifyLogEventDecryptionError(options = {}) { expect(telemetry.logEvent).toHaveBeenCalledTimes(1); expect(telemetry.logEvent).toHaveBeenCalledWith({ - eventName: "decryptionError", - volumeType: "own_volume", + eventName: 'decryptionError', + volumeType: 'own_volume', fromBefore2024: false, ...options, }); } - describe("folder node", () => { + describe('folder node', () => { const encryptedNode = { - uid: "volumeId~nodeId", - parentUid: "volumeId~parentId", + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentId', encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'nameSignatureEmail', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', folder: { - armoredHashKey: "armoredHashKey", - armoredExtendedAttributes: "folderArmoredExtendedAttributes", + armoredHashKey: 'armoredHashKey', + armoredExtendedAttributes: 'folderArmoredExtendedAttributes', }, }, } as EncryptedNode; function verifyResult( - result: { node: DecryptedUnparsedNode, keys?: DecryptedNodeKeys }, + result: { node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }, expectedNode: Partial = {}, expectedKeys: Partial | 'noKeys' = {}, ) { expect(result).toMatchObject({ node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, + name: { ok: true, value: 'name' }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'nameSignatureEmail' }, folder: { - extendedAttributes: "{}", + extendedAttributes: '{}', }, activeRevision: undefined, errors: undefined, ...expectedNode, }, - ...expectedKeys === 'noKeys' ? {} : { - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: new Uint8Array(), - ...expectedKeys, - } - }, + ...(expectedKeys === 'noKeys' + ? {} + : { + keys: { + passphrase: 'pass', + key: 'decryptedKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: new Uint8Array(), + ...expectedKeys, + }, + }), }); } - describe("should decrypt successfuly", () => { - it("same author everywhere", async () => { + describe('should decrypt successfuly', () => { + it('same author everywhere', async () => { const encryptedNode = { encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "signatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'signatureEmail', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', folder: { - armoredHashKey: "armoredHashKey", - armoredExtendedAttributes: "folderArmoredExtendedAttributes", + armoredHashKey: 'armoredHashKey', + armoredExtendedAttributes: 'folderArmoredExtendedAttributes', }, }, } as EncryptedNode; const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "signatureEmail" }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'signatureEmail' }, }); expect(account.getPublicKeys).toHaveBeenCalledTimes(1); - expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); expect(telemetry.logEvent).not.toHaveBeenCalled(); }); - it("different authors on key and name", async () => { + it('different authors on key and name', async () => { const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result); expect(account.getPublicKeys).toHaveBeenCalledTimes(2); - expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); - expect(account.getPublicKeys).toHaveBeenCalledWith("nameSignatureEmail"); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('nameSignatureEmail'); expect(telemetry.logEvent).not.toHaveBeenCalled(); }); }); - describe("should decrypt with verification issues", () => { - it("on node key", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.NOT_SIGNED, - })); + describe('should decrypt with verification issues', () => { + it('on node key', async () => { + driveCrypto.decryptKey = jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, + keyAuthor: { + ok: false, + error: { claimedAuthor: 'signatureEmail', error: 'Missing signature for key' }, + }, }); verifyLogEventVerificationError({ field: 'nodeKey', }); }); - it("on node name", async () => { - driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ - name: "name", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + it('on node name', async () => { + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Signature verification for name failed" } }, + nameAuthor: { + ok: false, + error: { claimedAuthor: 'nameSignatureEmail', error: 'Signature verification for name failed' }, + }, }); verifyLogEventVerificationError({ field: 'nodeName', }); }); - it("on hash key", async () => { - driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ - hashKey: new Uint8Array(), - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + it('on hash key', async () => { + driveCrypto.decryptNodeHashKey = jest.fn(async () => + Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for hash key failed" } }, + keyAuthor: { + ok: false, + error: { claimedAuthor: 'signatureEmail', error: 'Signature verification for hash key failed' }, + }, }); verifyLogEventVerificationError({ field: 'nodeHashKey', }); }); - it("on node key and hash key reports error from node key", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.NOT_SIGNED, - })); - driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ - hashKey: new Uint8Array(), - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + it('on node key and hash key reports error from node key', async () => { + driveCrypto.decryptKey = jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + }), + ); + driveCrypto.decryptNodeHashKey = jest.fn(async () => + Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, + keyAuthor: { + ok: false, + error: { claimedAuthor: 'signatureEmail', error: 'Missing signature for key' }, + }, }); verifyLogEventVerificationError({ field: 'nodeKey', }); }); - it("on folder extended attributes", async () => { - driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.resolve({ - extendedAttributes: "{}", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + it('on folder extended attributes', async () => { + driveCrypto.decryptExtendedAttributes = jest.fn(async () => + Promise.resolve({ + extendedAttributes: '{}', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for attributes failed" } }, + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Signature verification for attributes failed', + }, + }, }); verifyLogEventVerificationError({ field: 'nodeExtendedAttributes', @@ -251,61 +295,86 @@ describe("nodesCryptoService", () => { }); }); - describe("should decrypt with decryption issues", () => { - it("on node key", async () => { - const error = new Error("Decryption error"); + describe('should decrypt with decryption issues', () => { + it('on node key', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode(encryptedNode, parentKey); - verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Failed to decrypt node key: Decryption error" } }, - errors: [new Error("Decryption error")], - folder: undefined, - }, 'noKeys'); + verifyResult( + result, + { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Failed to decrypt node key: Decryption error', + }, + }, + errors: [new Error('Decryption error')], + folder: undefined, + }, + 'noKeys', + ); verifyLogEventDecryptionError({ field: 'nodeKey', error, }); }); - it("on node name", async () => { - const error = new Error("Decryption error"); + it('on node name', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode(encryptedNode, parentKey); - verifyResult(result, { - name: { ok: false, error }, - nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, - }, 'noKeys'); + verifyResult( + result, + { + name: { ok: false, error }, + nameAuthor: { + ok: false, + error: { claimedAuthor: 'nameSignatureEmail', error: 'Decryption error' }, + }, + }, + 'noKeys', + ); verifyLogEventDecryptionError({ field: 'nodeName', error, }); }); - it("on hash key", async () => { - const error = new Error("Decryption error"); + it('on hash key', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode(encryptedNode, parentKey); - verifyResult(result, { - errors: [error], - }, 'noKeys'); + verifyResult( + result, + { + errors: [error], + }, + 'noKeys', + ); verifyLogEventDecryptionError({ field: 'nodeHashKey', error, }); }); - it("on folder extended attributes", async () => { - const error = new Error("Decryption error"); + it('on folder extended attributes', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode(encryptedNode, parentKey); - verifyResult(result, { - folder: undefined, - errors: [error], - }, 'noKeys'); + verifyResult( + result, + { + folder: undefined, + errors: [error], + }, + 'noKeys', + ); verifyLogEventDecryptionError({ field: 'nodeExtendedAttributes', error, @@ -313,175 +382,198 @@ describe("nodesCryptoService", () => { }); }); - it("should fail when keys cannot be loaded", async () => { - account.getPublicKeys = jest.fn().mockRejectedValue(new Error("Failed to load keys")); + it('should fail when keys cannot be loaded', async () => { + account.getPublicKeys = jest.fn().mockRejectedValue(new Error('Failed to load keys')); const result = cryptoService.decryptNode(encryptedNode, parentKey); - await expect(result).rejects.toThrow("Failed to load keys"); + await expect(result).rejects.toThrow('Failed to load keys'); }); }); - describe("file node", () => { + describe('file node', () => { const encryptedNode = { - uid: "volumeId~nodeId", - parentUid: "volumeId~parentId", + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentId', encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'nameSignatureEmail', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', file: { - base64ContentKeyPacket: "base64ContentKeyPacket", + base64ContentKeyPacket: 'base64ContentKeyPacket', }, activeRevision: { - uid: "revisionUid", - state: "active", - signatureEmail: "revisionSignatureEmail", - armoredExtendedAttributes: "encryptedExtendedAttributes", + uid: 'revisionUid', + state: 'active', + signatureEmail: 'revisionSignatureEmail', + armoredExtendedAttributes: 'encryptedExtendedAttributes', }, }, } as EncryptedNode; function verifyResult( - result: { node: DecryptedUnparsedNode, keys?: DecryptedNodeKeys }, + result: { node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }, expectedNode: Partial = {}, expectedKeys: Partial | 'noKeys' = {}, ) { expect(result).toMatchObject({ node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, + name: { ok: true, value: 'name' }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'nameSignatureEmail' }, folder: undefined, activeRevision: { - ok: true, value: { - uid: "revisionUid", + ok: true, + value: { + uid: 'revisionUid', state: RevisionState.Active, creationTime: undefined, - extendedAttributes: "{}", - contentAuthor: { ok: true, value: "revisionSignatureEmail" }, - } + extendedAttributes: '{}', + contentAuthor: { ok: true, value: 'revisionSignatureEmail' }, + }, }, errors: undefined, ...expectedNode, }, - ...expectedKeys === 'noKeys' ? {} : { - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: undefined, - contentKeyPacketSessionKey: "contentKeyPacketSessionKey", - ...expectedKeys, - }, - }, + ...(expectedKeys === 'noKeys' + ? {} + : { + keys: { + passphrase: 'pass', + key: 'decryptedKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: undefined, + contentKeyPacketSessionKey: 'contentKeyPacketSessionKey', + ...expectedKeys, + }, + }), }); } - describe("should decrypt successfuly", () => { - it("same author everywhere", async () => { + describe('should decrypt successfuly', () => { + it('same author everywhere', async () => { const encryptedNode = { encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "signatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'signatureEmail', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', file: { - base64ContentKeyPacket: "base64ContentKeyPacket", + base64ContentKeyPacket: 'base64ContentKeyPacket', }, activeRevision: { - uid: "revisionUid", - state: "active", - signatureEmail: "signatureEmail", - armoredExtendedAttributes: "encryptedExtendedAttributes", + uid: 'revisionUid', + state: 'active', + signatureEmail: 'signatureEmail', + armoredExtendedAttributes: 'encryptedExtendedAttributes', }, }, } as EncryptedNode; const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "signatureEmail" }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'signatureEmail' }, activeRevision: { - ok: true, value: { - uid: "revisionUid", + ok: true, + value: { + uid: 'revisionUid', state: RevisionState.Active, // @ts-expect-error Ignore mocked data. creationTime: undefined, - extendedAttributes: "{}", - contentAuthor: { ok: true, value: "signatureEmail" }, - } + extendedAttributes: '{}', + contentAuthor: { ok: true, value: 'signatureEmail' }, + }, }, }); expect(account.getPublicKeys).toHaveBeenCalledTimes(2); // node + revision - expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); expect(telemetry.logEvent).not.toHaveBeenCalled(); }); - it("different authors on key and name", async () => { + it('different authors on key and name', async () => { const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result); expect(account.getPublicKeys).toHaveBeenCalledTimes(3); - expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); - expect(account.getPublicKeys).toHaveBeenCalledWith("nameSignatureEmail"); - expect(account.getPublicKeys).toHaveBeenCalledWith("revisionSignatureEmail"); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('nameSignatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('revisionSignatureEmail'); expect(telemetry.logEvent).not.toHaveBeenCalled(); }); }); - describe("should decrypt with verification issues", () => { - it("on node key", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.NOT_SIGNED, - })); + describe('should decrypt with verification issues', () => { + it('on node key', async () => { + driveCrypto.decryptKey = jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature for key" } }, + keyAuthor: { + ok: false, + error: { claimedAuthor: 'signatureEmail', error: 'Missing signature for key' }, + }, }); verifyLogEventVerificationError({ field: 'nodeKey', }); }); - it("on node name", async () => { - driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ - name: "name", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + it('on node name', async () => { + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Signature verification for name failed" } }, + nameAuthor: { + ok: false, + error: { claimedAuthor: 'nameSignatureEmail', error: 'Signature verification for name failed' }, + }, }); verifyLogEventVerificationError({ field: 'nodeName', }); }); - it("on folder extended attributes", async () => { - driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.resolve({ - extendedAttributes: "{}", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + it('on folder extended attributes', async () => { + driveCrypto.decryptExtendedAttributes = jest.fn(async () => + Promise.resolve({ + extendedAttributes: '{}', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { activeRevision: { - ok: true, value: { - uid: "revisionUid", - extendedAttributes: "{}", + ok: true, + value: { + uid: 'revisionUid', + extendedAttributes: '{}', state: RevisionState.Active, // @ts-expect-error Ignore mocked data. creationTime: undefined, - contentAuthor: { ok: false, error: { claimedAuthor: "revisionSignatureEmail", error: "Signature verification for attributes failed" } }, - } + contentAuthor: { + ok: false, + error: { + claimedAuthor: 'revisionSignatureEmail', + error: 'Signature verification for attributes failed', + }, + }, + }, }, }); verifyLogEventVerificationError({ @@ -489,15 +581,24 @@ describe("nodesCryptoService", () => { }); }); - it("on content key packet", async () => { - driveCrypto.decryptAndVerifySessionKey = jest.fn(async () => Promise.resolve({ - sessionKey: "contentKeyPacketSessionKey", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - }) as any); + it('on content key packet', async () => { + driveCrypto.decryptAndVerifySessionKey = jest.fn( + async () => + Promise.resolve({ + sessionKey: 'contentKeyPacketSessionKey', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }) as any, + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Signature verification for content key failed" } }, + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Signature verification for content key failed', + }, + }, }); verifyLogEventVerificationError({ field: 'nodeContentKey', @@ -505,46 +606,66 @@ describe("nodesCryptoService", () => { }); }); - describe("should decrypt with decryption issues", () => { - it("on node key", async () => { - const error = new Error("Decryption error"); + describe('should decrypt with decryption issues', () => { + it('on node key', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode(encryptedNode, parentKey); - verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Failed to decrypt node key: Decryption error" } }, - activeRevision: { ok: false, error: new Error('Failed to decrypt node key: Decryption error') }, - errors: [new Error("Decryption error")], - folder: undefined, - }, 'noKeys'); + verifyResult( + result, + { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Failed to decrypt node key: Decryption error', + }, + }, + activeRevision: { ok: false, error: new Error('Failed to decrypt node key: Decryption error') }, + errors: [new Error('Decryption error')], + folder: undefined, + }, + 'noKeys', + ); verifyLogEventDecryptionError({ field: 'nodeKey', error, }); }); - it("on node name", async () => { - const error = new Error("Decryption error"); + it('on node name', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode(encryptedNode, parentKey); - verifyResult(result, { - name: { ok: false, error }, - nameAuthor: { ok: false, error: { claimedAuthor: "nameSignatureEmail", error: "Decryption error" } }, - }, 'noKeys'); + verifyResult( + result, + { + name: { ok: false, error }, + nameAuthor: { + ok: false, + error: { claimedAuthor: 'nameSignatureEmail', error: 'Decryption error' }, + }, + }, + 'noKeys', + ); verifyLogEventDecryptionError({ field: 'nodeName', error, }); }); - it("on file extended attributes", async () => { - const error = new Error("Decryption error"); + it('on file extended attributes', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - activeRevision: { ok: false, error: new Error('Failed to decrypt active revision: Decryption error') }, + activeRevision: { + ok: false, + error: new Error('Failed to decrypt active revision: Decryption error'), + }, }); verifyLogEventDecryptionError({ field: 'nodeExtendedAttributes', @@ -552,17 +673,27 @@ describe("nodesCryptoService", () => { }); }); - it("on content key packet", async () => { - const error = new Error("Decryption error"); + it('on content key packet', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptAndVerifySessionKey = jest.fn(async () => Promise.reject(error)); const result = await cryptoService.decryptNode(encryptedNode, parentKey); - verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: "signatureEmail", error: 'Failed to decrypt content key: Decryption error' } }, - errors: [error], - }, { - contentKeyPacketSessionKey: undefined, - }); + verifyResult( + result, + { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Failed to decrypt content key: Decryption error', + }, + }, + errors: [error], + }, + { + contentKeyPacketSessionKey: undefined, + }, + ); verifyLogEventDecryptionError({ field: 'nodeContentKey', error, @@ -570,45 +701,45 @@ describe("nodesCryptoService", () => { }); }); - it("should fail when keys cannot be loaded", async () => { - account.getPublicKeys = jest.fn().mockRejectedValue(new Error("Failed to load keys")); + it('should fail when keys cannot be loaded', async () => { + account.getPublicKeys = jest.fn().mockRejectedValue(new Error('Failed to load keys')); const result = cryptoService.decryptNode(encryptedNode, parentKey); - await expect(result).rejects.toThrow("Failed to load keys"); + await expect(result).rejects.toThrow('Failed to load keys'); }); }); - describe("album node", () => { + describe('album node', () => { const encryptedNode = { - uid: "volumeId~nodeId", - parentUid: "volumeId~parentId", + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentId', encryptedCrypto: { - signatureEmail: "signatureEmail", - nameSignatureEmail: "nameSignatureEmail", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'nameSignatureEmail', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', }, } as EncryptedNode; - it("should decrypt successfuly", async () => { + it('should decrypt successfuly', async () => { const result = await cryptoService.decryptNode(encryptedNode, parentKey); expect(result).toMatchObject({ node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, + name: { ok: true, value: 'name' }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'nameSignatureEmail' }, folder: undefined, activeRevision: undefined, errors: undefined, }, keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", + passphrase: 'pass', + key: 'decryptedKey', + passphraseSessionKey: 'passphraseSessionKey', hashKey: new Uint8Array(), - } + }, }); expect(account.getPublicKeys).toHaveBeenCalledTimes(2); @@ -616,24 +747,24 @@ describe("nodesCryptoService", () => { }); }); - describe("anonymous node", () => { + describe('anonymous node', () => { const encryptedNode = { - uid: "volumeId~nodeId", - parentUid: "volumeId~parentId", + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentId', encryptedCrypto: { signatureEmail: undefined, nameSignatureEmail: undefined, - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", - armoredNodePassphraseSignature: "armoredNodePassphraseSignature", + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', file: { - base64ContentKeyPacket: "base64ContentKeyPacket", + base64ContentKeyPacket: 'base64ContentKeyPacket', }, activeRevision: { - uid: "revisionUid", - state: "active", - signatureEmail: "revisionSignatureEmail", - armoredExtendedAttributes: "encryptedExtendedAttributes", + uid: 'revisionUid', + state: 'active', + signatureEmail: 'revisionSignatureEmail', + armoredExtendedAttributes: 'encryptedExtendedAttributes', }, }, } as EncryptedNode; @@ -641,61 +772,74 @@ describe("nodesCryptoService", () => { const encryptedNodeWithoutParent = { ...encryptedNode, parentUid: undefined, - } + }; function verifyResult( - result: { node: DecryptedUnparsedNode, keys?: DecryptedNodeKeys }, + result: { node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }, expectedNode: Partial = {}, expectedKeys: Partial | 'noKeys' = {}, ) { expect(result).toMatchObject({ node: { - name: { ok: true, value: "name" }, - keyAuthor: { ok: true, value: "signatureEmail" }, - nameAuthor: { ok: true, value: "nameSignatureEmail" }, + name: { ok: true, value: 'name' }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'nameSignatureEmail' }, folder: undefined, activeRevision: { - ok: true, value: { - uid: "revisionUid", + ok: true, + value: { + uid: 'revisionUid', state: RevisionState.Active, creationTime: undefined, - extendedAttributes: "{}", - contentAuthor: { ok: true, value: "revisionSignatureEmail" }, - } + extendedAttributes: '{}', + contentAuthor: { ok: true, value: 'revisionSignatureEmail' }, + }, }, errors: undefined, ...expectedNode, }, - ...expectedKeys === 'noKeys' ? {} : { - keys: { - passphrase: "pass", - key: "decryptedKey", - passphraseSessionKey: "passphraseSessionKey", - hashKey: undefined, - contentKeyPacketSessionKey: "contentKeyPacketSessionKey", - ...expectedKeys, - }, - }, + ...(expectedKeys === 'noKeys' + ? {} + : { + keys: { + passphrase: 'pass', + key: 'decryptedKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: undefined, + contentKeyPacketSessionKey: 'contentKeyPacketSessionKey', + ...expectedKeys, + }, + }), }); } - describe("should decrypt with verification issues", () => { - it("on node key and name with access to parent node", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); - driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ - name: "name", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + describe('should decrypt with verification issues', () => { + it('on node key and name with access to parent node', async () => { + driveCrypto.decryptKey = jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { - keyAuthor: { ok: false, error: { claimedAuthor: undefined, error: "Signature verification for key failed" } }, - nameAuthor: { ok: false, error: { claimedAuthor: undefined, error: "Signature verification for name failed" } }, + keyAuthor: { + ok: false, + error: { claimedAuthor: undefined, error: 'Signature verification for key failed' }, + }, + nameAuthor: { + ok: false, + error: { claimedAuthor: undefined, error: 'Signature verification for name failed' }, + }, }); verifyLogEventVerificationError({ field: 'nodeName', @@ -708,24 +852,26 @@ describe("nodesCryptoService", () => { [parentKey], [parentKey], ); - expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith( - encryptedNode.encryptedName, + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith(encryptedNode.encryptedName, parentKey, [ parentKey, - [parentKey], - ); + ]); }); - it("on anonymous node key and name without access to parent node", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "passphraseSessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); - driveCrypto.decryptNodeName = jest.fn(async () => Promise.resolve({ - name: "name", - verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, - })); + it('on anonymous node key and name without access to parent node', async () => { + driveCrypto.decryptKey = jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); const result = await cryptoService.decryptNode(encryptedNodeWithoutParent, parentKey); verifyResult(result, { @@ -740,11 +886,7 @@ describe("nodesCryptoService", () => { [parentKey], [], ); - expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith( - encryptedNode.encryptedName, - parentKey, - [], - ); + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith(encryptedNode.encryptedName, parentKey, []); }); }); }); @@ -791,17 +933,14 @@ describe("nodesCryptoService", () => { 'testFile.txt', keys.nameSessionKey, parentKeys.key, - address.addressKey - ); - expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith( - 'testFile.txt', - parentKeys.hashKey + address.addressKey, ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('testFile.txt', parentKeys.hashKey); expect(driveCrypto.encryptPassphrase).toHaveBeenCalledWith( keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], - address.addressKey + address.addressKey, ); }); @@ -823,8 +962,9 @@ describe("nodesCryptoService", () => { addressKey: 'addressKey' as any, }; - await expect(cryptoService.moveNode(node, keys as any, parentKeys, address)) - .rejects.toThrow('Moving item to a non-folder is not allowed'); + await expect(cryptoService.moveNode(node, keys as any, parentKeys, address)).rejects.toThrow( + 'Moving item to a non-folder is not allowed', + ); }); it('should throw error when node has invalid name', async () => { @@ -845,8 +985,9 @@ describe("nodesCryptoService", () => { addressKey: 'addressKey' as any, }; - await expect(cryptoService.moveNode(node, keys as any, parentKeys, address)) - .rejects.toThrow('Cannot move item without a valid name, please rename the item first'); + await expect(cryptoService.moveNode(node, keys as any, parentKeys, address)).rejects.toThrow( + 'Cannot move item without a valid name, please rename the item first', + ); }); }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index f87be457..055e148f 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,15 +1,35 @@ import { c } from 'ttag'; -import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { resultOk, resultError, Result, Author, AnonymousUser, ProtonDriveAccount, ProtonDriveTelemetry, Logger, MetricsDecryptionErrorField, MetricVerificationErrorField } from "../../interface"; +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; +import { + resultOk, + resultError, + Result, + Author, + AnonymousUser, + ProtonDriveAccount, + ProtonDriveTelemetry, + Logger, + MetricsDecryptionErrorField, + MetricVerificationErrorField, +} from '../../interface'; import { ValidationError } from '../../errors'; -import { getErrorMessage, getVerificationMessage } from "../errors"; -import { splitNodeUid } from "../uids"; -import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, SharesService, EncryptedRevision, DecryptedUnparsedRevision } from "./interface"; +import { getErrorMessage, getVerificationMessage } from '../errors'; +import { splitNodeUid } from '../uids'; +import { + EncryptedNode, + EncryptedNodeFolderCrypto, + DecryptedUnparsedNode, + DecryptedNode, + DecryptedNodeKeys, + SharesService, + EncryptedRevision, + DecryptedUnparsedRevision, +} from './interface'; /** * Provides crypto operations for nodes metadata. - * + * * The node crypto service is responsible for decrypting and encrypting node * metadata. It should export high-level actions only, such as "decrypt node" * instead of low-level operations like "decrypt node key". Low-level operations @@ -36,11 +56,14 @@ export class NodesCryptoService { this.shareService = shareService; } - async decryptNode(node: EncryptedNode, parentKey: PrivateKey): Promise<{ node: DecryptedUnparsedNode, keys?: DecryptedNodeKeys }> { + async decryptNode( + node: EncryptedNode, + parentKey: PrivateKey, + ): Promise<{ node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }> { const commonNodeMetadata = { ...node, encryptedCrypto: undefined, - } + }; const signatureEmailKeys = node.encryptedCrypto.signatureEmail ? await this.account.getPublicKeys(node.encryptedCrypto.signatureEmail) @@ -53,9 +76,7 @@ export class NodesCryptoService { const nodeParentKeys = node.parentUid ? [parentKey] : []; // Anonymous uploads (without signature email set) use parent key instead. - const keyVerificationKeys = node.encryptedCrypto.signatureEmail - ? signatureEmailKeys - : nodeParentKeys; + const keyVerificationKeys = node.encryptedCrypto.signatureEmail ? signatureEmailKeys : nodeParentKeys; let nameVerificationKeys; const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail; @@ -89,13 +110,11 @@ export class NodesCryptoService { error: errorMessage, }), nameAuthor, - activeRevision: "file" in node.encryptedCrypto - ? resultError(new Error(errorMessage)) - : undefined, + activeRevision: 'file' in node.encryptedCrypto ? resultError(new Error(errorMessage)) : undefined, folder: undefined, errors: [error], }, - } + }; } const errors = []; @@ -104,7 +123,7 @@ export class NodesCryptoService { let hashKeyAuthor; let folder; let folderExtendedAttributesAuthor; - if ("folder" in node.encryptedCrypto) { + if ('folder' in node.encryptedCrypto) { try { const hashKeyResult = await this.decryptHashKey(node, key, signatureEmailKeys); hashKey = hashKeyResult.hashKey; @@ -123,7 +142,7 @@ export class NodesCryptoService { node.encryptedCrypto.folder.armoredExtendedAttributes, key, folderExtendedAttributesVerificationKeys, - node.encryptedCrypto.signatureEmail + node.encryptedCrypto.signatureEmail, ); folder = { extendedAttributes: extendedAttributesResult.extendedAttributes, @@ -138,9 +157,11 @@ export class NodesCryptoService { let activeRevision: Result | undefined; let contentKeyPacketSessionKey; let contentKeyPacketAuthor; - if ("file" in node.encryptedCrypto) { + if ('file' in node.encryptedCrypto) { try { - activeRevision = resultOk(await this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key)); + activeRevision = resultOk( + await this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key), + ); } catch (error: unknown) { void this.reportDecryptionError(node, 'nodeExtendedAttributes', error); const message = getErrorMessage(error); @@ -159,13 +180,15 @@ export class NodesCryptoService { ); contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; - contentKeyPacketAuthor = keySessionKeyResult.verified && await this.handleClaimedAuthor( - node, - 'nodeContentKey', - c('Property').t`content key`, - keySessionKeyResult.verified, - node.encryptedCrypto.signatureEmail, - ); + contentKeyPacketAuthor = + keySessionKeyResult.verified && + (await this.handleClaimedAuthor( + node, + 'nodeContentKey', + c('Property').t`content key`, + keySessionKeyResult.verified, + node.encryptedCrypto.signatureEmail, + )); } catch (error: unknown) { void this.reportDecryptionError(node, 'nodeContentKey', error); const message = getErrorMessage(error); @@ -217,11 +240,17 @@ export class NodesCryptoService { hashKey, }, }; - }; + } - private async decryptKey(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PublicKey[]): Promise { + private async decryptKey( + node: EncryptedNode, + parentKey: PrivateKey, + verificationKeys: PublicKey[], + ): Promise< + DecryptedNodeKeys & { + author: Author; + } + > { const key = await this.driveCrypto.decryptKey( node.encryptedCrypto.armoredKey, node.encryptedCrypto.armoredNodePassphrase, @@ -234,13 +263,24 @@ export class NodesCryptoService { passphrase: key.passphrase, key: key.key, passphraseSessionKey: key.passphraseSessionKey, - author: await this.handleClaimedAuthor(node, 'nodeKey', c('Property').t`key`, key.verified, node.encryptedCrypto.signatureEmail, verificationKeys.length === 0), + author: await this.handleClaimedAuthor( + node, + 'nodeKey', + c('Property').t`key`, + key.verified, + node.encryptedCrypto.signatureEmail, + verificationKeys.length === 0, + ), }; - }; + } - private async decryptName(node: EncryptedNode, parentKey: PrivateKey, verificationKeys: PublicKey[]): Promise<{ - name: Result, - author: Author, + private async decryptName( + node: EncryptedNode, + parentKey: PrivateKey, + verificationKeys: PublicKey[], + ): Promise<{ + name: Result; + author: Author; }> { const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail; @@ -253,8 +293,15 @@ export class NodesCryptoService { return { name: resultOk(name), - author: await this.handleClaimedAuthor(node, 'nodeName', c('Property').t`name`, verified, nameSignatureEmail, verificationKeys.length === 0), - } + author: await this.handleClaimedAuthor( + node, + 'nodeName', + c('Property').t`name`, + verified, + nameSignatureEmail, + verificationKeys.length === 0, + ), + }; } catch (error: unknown) { void this.reportDecryptionError(node, 'nodeName', error); const errorMessage = getErrorMessage(error); @@ -264,19 +311,23 @@ export class NodesCryptoService { claimedAuthor: nameSignatureEmail, error: errorMessage, }), - } + }; } - }; + } async getNameSessionKey(node: { encryptedName: string }, parentKey: PrivateKey): Promise { return this.driveCrypto.decryptSessionKey(node.encryptedName, parentKey); } - private async decryptHashKey(node: EncryptedNode, nodeKey: PrivateKey, addressKeys: PublicKey[]): Promise<{ - hashKey: Uint8Array, - author: Author, + private async decryptHashKey( + node: EncryptedNode, + nodeKey: PrivateKey, + addressKeys: PublicKey[], + ): Promise<{ + hashKey: Uint8Array; + author: Author; }> { - if (!("folder" in node.encryptedCrypto)) { + if (!('folder' in node.encryptedCrypto)) { // This is developer error. throw new Error('Node is not a folder'); } @@ -289,19 +340,26 @@ export class NodesCryptoService { return { hashKey, - author: await this.handleClaimedAuthor(node, 'nodeHashKey', c('Property').t`hash key`, verified, node.encryptedCrypto.signatureEmail), - } + author: await this.handleClaimedAuthor( + node, + 'nodeHashKey', + c('Property').t`hash key`, + verified, + node.encryptedCrypto.signatureEmail, + ), + }; } - async decryptRevision(nodeUid: string, encryptedRevision: EncryptedRevision, nodeKey: PrivateKey): Promise { + async decryptRevision( + nodeUid: string, + encryptedRevision: EncryptedRevision, + nodeKey: PrivateKey, + ): Promise { const verificationKeys = encryptedRevision.signatureEmail ? await this.account.getPublicKeys(encryptedRevision.signatureEmail) : [nodeKey]; - const { - extendedAttributes, - author: contentAuthor, - } = await this.decryptExtendedAttributes( + const { extendedAttributes, author: contentAuthor } = await this.decryptExtendedAttributes( { uid: nodeUid, creationTime: encryptedRevision.creationTime }, encryptedRevision.armoredExtendedAttributes, nodeKey, @@ -317,23 +375,23 @@ export class NodesCryptoService { contentAuthor, extendedAttributes, thumbnails: encryptedRevision.thumbnails, - } + }; } private async decryptExtendedAttributes( - node: { uid: string, creationTime: Date }, + node: { uid: string; creationTime: Date }, encryptedExtendedAttributes: string | undefined, nodeKey: PrivateKey, addressKeys: PublicKey[], signatureEmail?: string, ): Promise<{ - extendedAttributes?: string, - author: Author, + extendedAttributes?: string; + author: Author; }> { if (!encryptedExtendedAttributes) { return { author: resultOk(signatureEmail) as Author, - } + }; } const { extendedAttributes, verified } = await this.driveCrypto.decryptExtendedAttributes( @@ -344,25 +402,27 @@ export class NodesCryptoService { return { extendedAttributes, - author: await this.handleClaimedAuthor(node, "nodeExtendedAttributes", c('Property').t`attributes`, verified, signatureEmail), - } + author: await this.handleClaimedAuthor( + node, + 'nodeExtendedAttributes', + c('Property').t`attributes`, + verified, + signatureEmail, + ), + }; } async createFolder( - parentKeys: { key: PrivateKey, hashKey: Uint8Array }, - address: { email: string, addressKey: PrivateKey }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + address: { email: string; addressKey: PrivateKey }, name: string, extendedAttributes?: string, ): Promise<{ - encryptedCrypto: Required & { encryptedName: string, hash: string }, - keys: DecryptedNodeKeys, + encryptedCrypto: Required & { encryptedName: string; hash: string }; + keys: DecryptedNodeKeys; }> { const { email, addressKey } = address; - const [ - nodeKeys, - { armoredNodeName }, - hash, - ] = await Promise.all([ + const [nodeKeys, { armoredNodeName }, hash] = await Promise.all([ this.driveCrypto.generateKey([parentKeys.key], addressKey), this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, addressKey), this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), @@ -398,18 +458,23 @@ export class NodesCryptoService { } async encryptNewName( - parentKeys: { key: PrivateKey, hashKey?: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey?: Uint8Array }, nodeNameSessionKey: SessionKey, - address: { email: string, addressKey: PrivateKey }, + address: { email: string; addressKey: PrivateKey }, newName: string, ): Promise<{ - signatureEmail: string, - armoredNodeName: string, - hash?: string, + signatureEmail: string; + armoredNodeName: string; + hash?: string; }> { const { email, addressKey } = address; - const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, nodeNameSessionKey, parentKeys.key, addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName( + newName, + nodeNameSessionKey, + parentKeys.key, + addressKey, + ); const hash = parentKeys.hashKey ? await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey) @@ -419,20 +484,20 @@ export class NodesCryptoService { armoredNodeName, hash, }; - }; + } async moveNode( node: Pick, - keys: { passphrase: string, passphraseSessionKey: SessionKey, nameSessionKey: SessionKey }, - parentKeys: { key: PrivateKey, hashKey: Uint8Array }, - address: { email: string, addressKey: PrivateKey }, + keys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + address: { email: string; addressKey: PrivateKey }, ): Promise<{ - encryptedName: string, - hash: string, - armoredNodePassphrase: string, - armoredNodePassphraseSignature: string, - signatureEmail: string, - nameSignatureEmail: string, + encryptedName: string; + hash: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + signatureEmail: string; + nameSignatureEmail: string; }> { if (!parentKeys.hashKey) { throw new ValidationError('Moving item to a non-folder is not allowed'); @@ -442,9 +507,19 @@ export class NodesCryptoService { } const { email, addressKey } = address; - const { armoredNodeName } = await this.driveCrypto.encryptNodeName(node.name.value, keys.nameSessionKey, parentKeys.key, addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName( + node.name.value, + keys.nameSessionKey, + parentKeys.key, + addressKey, + ); const hash = await this.driveCrypto.generateLookupHash(node.name.value, parentKeys.hashKey); - const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase(keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], addressKey); + const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase( + keys.passphrase, + keys.passphraseSessionKey, + [parentKeys.key], + addressKey, + ); return { encryptedName: armoredNodeName, @@ -457,7 +532,7 @@ export class NodesCryptoService { } private async handleClaimedAuthor( - node: { uid: string, creationTime: Date }, + node: { uid: string; creationTime: Date }, field: MetricVerificationErrorField, signatureType: string, verified: VERIFICATION_STATUS, @@ -472,7 +547,7 @@ export class NodesCryptoService { } private async reportVerificationError( - node: { uid: string, creationTime: Date }, + node: { uid: string; creationTime: Date }, field: MetricVerificationErrorField, claimedAuthor?: string, ) { @@ -492,7 +567,9 @@ export class NodesCryptoService { this.logger.error('Failed to check if claimed author matches default share', error); } - this.logger.warn(`Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`); + this.logger.warn( + `Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`, + ); this.telemetry.logEvent({ eventName: 'verificationError', @@ -535,7 +612,12 @@ export class NodesCryptoService { /** * @param signatureType - Must be translated before calling this function. */ -function handleClaimedAuthor(signatureType: string, verified: VERIFICATION_STATUS, claimedAuthor?: string, notAvailableVerificationKeys = false): Author { +function handleClaimedAuthor( + signatureType: string, + verified: VERIFICATION_STATUS, + claimedAuthor?: string, + notAvailableVerificationKeys = false, +): Author { if (!claimedAuthor && notAvailableVerificationKeys) { return resultOk(null as AnonymousUser); } diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index db9ed806..ed8b79c4 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -1,10 +1,10 @@ -import { getMockLogger } from "../../tests/logger"; -import { DriveEvent, DriveEventType } from "../events"; -import { NodesEventsHandler } from "./events"; -import { DecryptedNode } from "./interface"; -import { NodesCache } from "./cache"; +import { getMockLogger } from '../../tests/logger'; +import { DriveEvent, DriveEventType } from '../events'; +import { NodesEventsHandler } from './events'; +import { DecryptedNode } from './interface'; +import { NodesCache } from './cache'; -describe("NodesEventsHandler", () => { +describe('NodesEventsHandler', () => { const logger = getMockLogger(); let cache: NodesCache; let nodesEventsNodesEventsHandler: NodesEventsHandler; @@ -14,11 +14,13 @@ describe("NodesEventsHandler", () => { // @ts-expect-error No need to implement all methods for mocking cache = { - getNode: jest.fn(() => Promise.resolve({ - uid: "nodeUid123", - parentUid: "parentUid", - name: { ok: true, value: "name" }, - } as DecryptedNode)), + getNode: jest.fn(() => + Promise.resolve({ + uid: 'nodeUid123', + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + } as DecryptedNode), + ), setNode: jest.fn(), removeNodes: jest.fn(), resetFolderChildrenLoaded: jest.fn(), @@ -26,47 +28,55 @@ describe("NodesEventsHandler", () => { nodesEventsNodesEventsHandler = new NodesEventsHandler(logger, cache); }); - it("should unset the parent listing complete status when a `NodeCreated` event is received.", async () => { + it('should unset the parent listing complete status when a `NodeCreated` event is received.', async () => { const event: DriveEvent = { - eventId: "event1", + eventId: 'event1', type: DriveEventType.NodeCreated, - nodeUid: "nodeUid", - parentNodeUid: "parentUid", + nodeUid: 'nodeUid', + parentNodeUid: 'parentUid', isTrashed: false, isShared: false, - treeEventScopeId: "volume1", + treeEventScopeId: 'volume1', }; await nodesEventsNodesEventsHandler.updateNodesCacheOnEvent(event); expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledTimes(1); - expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith("parentUid"); + expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); expect(cache.setNode).toHaveBeenCalledTimes(0); }); - it("should update the node metadata when a `NodeUpdated` event is received.", async () => { + it('should update the node metadata when a `NodeUpdated` event is received.', async () => { const event: DriveEvent = { type: DriveEventType.NodeUpdated, - eventId: "event1", - nodeUid: "nodeUid123", - parentNodeUid: "parentUid", + eventId: 'event1', + nodeUid: 'nodeUid123', + parentNodeUid: 'parentUid', isTrashed: false, isShared: false, - treeEventScopeId: "volume1", + treeEventScopeId: 'volume1', }; await nodesEventsNodesEventsHandler.updateNodesCacheOnEvent(event); expect(cache.getNode).toHaveBeenCalledTimes(1); expect(cache.setNode).toHaveBeenCalledTimes(1); - expect(cache.setNode).toHaveBeenCalledWith(expect.objectContaining({ uid: 'nodeUid123', isStale: true, parentUid: "parentUid", trashTime: undefined, isShared: false })); + expect(cache.setNode).toHaveBeenCalledWith( + expect.objectContaining({ + uid: 'nodeUid123', + isStale: true, + parentUid: 'parentUid', + trashTime: undefined, + isShared: false, + }), + ); }); - it("should remove node from cache", async () => { + it('should remove node from cache', async () => { const event: DriveEvent = { type: DriveEventType.NodeDeleted, - eventId: "event1", - nodeUid: "nodeUid123", - parentNodeUid: "parentUid", - treeEventScopeId: "volume1", + eventId: 'event1', + nodeUid: 'nodeUid123', + parentNodeUid: 'parentUid', + treeEventScopeId: 'volume1', }; await nodesEventsNodesEventsHandler.updateNodesCacheOnEvent(event); diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 8e0444f3..21f5c936 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -1,6 +1,6 @@ -import { Logger } from "../../interface"; -import { DriveEvent, DriveEventType } from "../events"; -import { NodesCache } from "./cache"; +import { Logger } from '../../interface'; +import { DriveEvent, DriveEventType } from '../events'; +import { NodesCache } from './cache'; /** * Provides internal event handling. @@ -9,8 +9,10 @@ import { NodesCache } from "./cache"; * from the DriveEventsService. */ export class NodesEventsHandler { - constructor(private logger: Logger, private cache: NodesCache) { - } + constructor( + private logger: Logger, + private cache: NodesCache, + ) {} async updateNodesCacheOnEvent(event: DriveEvent): Promise { try { diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index 073abf21..a71ef9e6 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -1,5 +1,12 @@ -import { getMockLogger } from "../../tests/logger"; -import { FolderExtendedAttributes, FileExtendedAttributesParsed, generateFolderExtendedAttributes, generateFileExtendedAttributes, parseFolderExtendedAttributes, parseFileExtendedAttributes } from './extendedAttributes'; +import { getMockLogger } from '../../tests/logger'; +import { + FolderExtendedAttributes, + FileExtendedAttributesParsed, + generateFolderExtendedAttributes, + generateFileExtendedAttributes, + parseFolderExtendedAttributes, + parseFileExtendedAttributes, +} from './extendedAttributes'; describe('extended attrbiutes', () => { describe('should generate folder attributes', () => { @@ -11,7 +18,7 @@ describe('extended attrbiutes', () => { it(`should generate ${input}`, () => { const output = generateFolderExtendedAttributes(input); expect(output).toBe(expectedAttributes); - }) + }); }); }); @@ -26,46 +33,43 @@ describe('extended attrbiutes', () => { claimedModificationTime: new Date(1234567890000), }, ], - [ - '{"Common": {"ModificationTime": "aa"}}', - {}, - ], + ['{"Common": {"ModificationTime": "aa"}}', {}], [ '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000", "Size": 123}}', { claimedModificationTime: new Date(1234567890000), }, ], - [ - '{"Common": {"Whatever": 123}}', - {}, - ], + ['{"Common": {"Whatever": 123}}', {}], ]; testCases.forEach(([input, expectedAttributes]) => { it(`should parse ${input}`, () => { const output = parseFolderExtendedAttributes(getMockLogger(), input); expect(output).toMatchObject(expectedAttributes); - }) + }); }); }); describe('should generate file attributes', () => { const testCases: [object, string | undefined][] = [ [{}, undefined], - [{modificationTime: new Date(1234567890000)}, '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z"}}'], - [{size: undefined}, undefined], - [{size: 0}, '{"Common":{"Size":0}}'], - [{size: 1234}, '{"Common":{"Size":1234}}'], - [{blockSizes: []}, undefined], - [{blockSizes: [4,4,4,2]}, '{"Common":{"BlockSizes":[4,4,4,2]}}'], - [{digests: {}}, undefined], - [{digests: {sha1: 'abcdef'}}, '{"Common":{"Digests":{"SHA1":"abcdef"}}}'], + [ + { modificationTime: new Date(1234567890000) }, + '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z"}}', + ], + [{ size: undefined }, undefined], + [{ size: 0 }, '{"Common":{"Size":0}}'], + [{ size: 1234 }, '{"Common":{"Size":1234}}'], + [{ blockSizes: [] }, undefined], + [{ blockSizes: [4, 4, 4, 2] }, '{"Common":{"BlockSizes":[4,4,4,2]}}'], + [{ digests: {} }, undefined], + [{ digests: { sha1: 'abcdef' } }, '{"Common":{"Digests":{"SHA1":"abcdef"}}}'], [ { modificationTime: new Date(1234567890000), size: 1234, blockSizes: [4, 4, 4, 2], - digests: {sha1: 'abcdef'}, + digests: { sha1: 'abcdef' }, }, '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z","Size":1234,"BlockSizes":[4,4,4,2],"Digests":{"SHA1":"abcdef"}}}', ], @@ -74,7 +78,7 @@ describe('extended attrbiutes', () => { it(`should generate ${input}`, () => { const output = generateFileExtendedAttributes(input); expect(output).toBe(expectedAttributes); - }) + }); }); }); @@ -151,7 +155,7 @@ describe('extended attrbiutes', () => { { claimedModificationTime: undefined, claimedSize: undefined, - claimedDigests: {sha1: "abcdef"}, + claimedDigests: { sha1: 'abcdef' }, claimedAdditionalMetadata: undefined, }, ], @@ -171,7 +175,7 @@ describe('extended attrbiutes', () => { it(`should parse ${input}`, () => { const output = parseFileExtendedAttributes(getMockLogger(), input); expect(output).toMatchObject(expectedAttributes); - }) + }); }); }); }); diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index 748c20d1..a96e595d 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -1,4 +1,4 @@ -import { Logger } from "../../interface"; +import { Logger } from '../../interface'; interface FolderExtendedAttributesSchema { Common?: { @@ -38,16 +38,16 @@ interface FileExtendedAttributesSchema { } export interface FolderExtendedAttributes { - claimedModificationTime?: Date, + claimedModificationTime?: Date; } export interface FileExtendedAttributesParsed { - claimedSize?: number, - claimedModificationTime?: Date, + claimedSize?: number; + claimedModificationTime?: Date; claimedDigests?: { - sha1?: string, - }, - claimedAdditionalMetadata?: object, + sha1?: string; + }; + claimedAdditionalMetadata?: object; } export function generateFolderExtendedAttributes(claimedModificationTime?: Date): string | undefined { @@ -83,12 +83,12 @@ export function parseFolderExtendedAttributes(logger: Logger, extendedAttributes } export function generateFileExtendedAttributes(options: { - modificationTime?: Date, - size?: number, - blockSizes?: number[], + modificationTime?: Date; + size?: number; + blockSizes?: number[]; digests?: { - sha1?: string, - }, + sha1?: string; + }; }): string | undefined { const commonAttributes: FileExtendedAttributesSchema['Common'] = {}; if (options.modificationTime) { @@ -115,7 +115,7 @@ export function generateFileExtendedAttributes(options: { export function parseFileExtendedAttributes(logger: Logger, extendedAttributes?: string): FileExtendedAttributesParsed { if (!extendedAttributes) { - return {} + return {}; } try { @@ -128,7 +128,9 @@ export function parseFileExtendedAttributes(logger: Logger, extendedAttributes?: claimedSize: parseSize(logger, parsed), claimedModificationTime: parseModificationTime(logger, parsed), claimedDigests: parseDigests(logger, parsed), - claimedAdditionalMetadata: Object.keys(claimedAdditionalMetadata).length ? claimedAdditionalMetadata : undefined, + claimedAdditionalMetadata: Object.keys(claimedAdditionalMetadata).length + ? claimedAdditionalMetadata + : undefined, }; } catch (error: unknown) { logger.error(`Failed to parse extended attributes`, error); @@ -148,7 +150,10 @@ function parseSize(logger: Logger, xattr?: FileExtendedAttributesSchema): number return size; } -function parseModificationTime(logger: Logger, xattr?: FolderExtendedAttributesSchema | FolderExtendedAttributesSchema): Date | undefined { +function parseModificationTime( + logger: Logger, + xattr?: FolderExtendedAttributesSchema | FolderExtendedAttributesSchema, +): Date | undefined { const modificationTime = xattr?.Common?.ModificationTime; if (modificationTime === undefined) { return undefined; diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index a10c32c6..26abe25f 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -1,18 +1,23 @@ -import { DriveAPIService } from "../apiService"; -import { DriveCrypto } from "../../crypto"; -import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; -import { NodeAPIService } from "./apiService"; -import { NodesCache } from "./cache"; -import { NodesCryptoCache } from "./cryptoCache"; -import { NodesCryptoService } from "./cryptoService"; -import { SharesService } from "./interface"; -import { NodesAccess } from "./nodesAccess"; -import { NodesManagement } from "./nodesManagement"; -import { NodesRevisons } from "./nodesRevisions"; -import { NodesEventsHandler } from "./events"; +import { DriveAPIService } from '../apiService'; +import { DriveCrypto } from '../../crypto'; +import { + ProtonDriveEntitiesCache, + ProtonDriveCryptoCache, + ProtonDriveAccount, + ProtonDriveTelemetry, +} from '../../interface'; +import { NodeAPIService } from './apiService'; +import { NodesCache } from './cache'; +import { NodesCryptoCache } from './cryptoCache'; +import { NodesCryptoService } from './cryptoService'; +import { SharesService } from './interface'; +import { NodesAccess } from './nodesAccess'; +import { NodesManagement } from './nodesManagement'; +import { NodesRevisons } from './nodesRevisions'; +import { NodesEventsHandler } from './events'; -export type { DecryptedNode, DecryptedRevision } from "./interface"; -export { generateFileExtendedAttributes } from "./extendedAttributes"; +export type { DecryptedNode, DecryptedRevision } from './interface'; +export { generateFileExtendedAttributes } from './extendedAttributes'; /** * Provides facade for the whole nodes module. @@ -36,7 +41,14 @@ export function initNodesModule( const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); - const nodesAccess = new NodesAccess(telemetry.getLogger('nodes'), api, cache, cryptoCache, cryptoService, sharesService); + const nodesAccess = new NodesAccess( + telemetry.getLogger('nodes'), + api, + cache, + cryptoCache, + cryptoService, + sharesService, + ); const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache); const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess); const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index d9ba9558..5ac62440 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,5 +1,16 @@ -import { PrivateKey, SessionKey } from "../../crypto"; -import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType, MetricVolumeType, Revision, RevisionState } from "../../interface"; +import { PrivateKey, SessionKey } from '../../crypto'; +import { + NodeEntity, + Result, + InvalidNameError, + Author, + MemberRole, + NodeType, + ThumbnailType, + MetricVolumeType, + Revision, + RevisionState, +} from '../../interface'; /** * Internal common node interface for both encrypted or decrypted node. @@ -20,8 +31,8 @@ interface BaseNode { // Share node metadata shareId?: string; - isShared: boolean, - directMemberRole: MemberRole, + isShared: boolean; + directMemberRole: MemberRole; } /** @@ -68,28 +79,30 @@ export interface EncryptedNodeAlbumCrypto extends EncryptedNodeCrypto {} * such as extended attributes. */ export interface DecryptedUnparsedNode extends BaseNode { - keyAuthor: Author, - nameAuthor: Author, - name: Result, - activeRevision?: Result, + keyAuthor: Author; + nameAuthor: Author; + name: Result; + activeRevision?: Result; folder?: { - extendedAttributes?: string, - }, - errors?: unknown[], + extendedAttributes?: string; + }; + errors?: unknown[]; } /** * Interface holding decrypted node metadata. */ -export interface DecryptedNode extends Omit, Omit { +export interface DecryptedNode + extends Omit, + Omit { // Internal metadata isStale: boolean; - name: Result, + name: Result; - activeRevision?: Result, + activeRevision?: Result; folder?: { - claimedModificationTime?: Date, - }, + claimedModificationTime?: Date; + }; } /** @@ -120,7 +133,7 @@ interface BaseRevision { export type Thumbnail = { uid: string; type: ThumbnailType; -} +}; export interface EncryptedRevision extends BaseRevision { signatureEmail?: string; @@ -128,8 +141,8 @@ export interface EncryptedRevision extends BaseRevision { } export interface DecryptedUnparsedRevision extends BaseRevision { - contentAuthor: Author, - extendedAttributes?: string, + contentAuthor: Author; + extendedAttributes?: string; } export interface DecryptedRevision extends Revision { @@ -140,16 +153,16 @@ export interface DecryptedRevision extends Revision { * Interface describing the dependencies to the shares module. */ export interface SharesService { - getMyFilesIDs(): Promise<{ volumeId: string, rootNodeId: string }>, - getSharePrivateKey(shareId: string): Promise, + getMyFilesIDs(): Promise<{ volumeId: string; rootNodeId: string }>; + getSharePrivateKey(shareId: string): Promise; getMyFilesShareMemberEmailKey(): Promise<{ - email: string, - }>, + email: string; + }>; getContextShareMemberEmailKey(shareId: string): Promise<{ - email: string, - addressId: string, - addressKey: PrivateKey, - addressKeyId: string, - }>, - getVolumeMetricContext(volumeId: string): Promise, + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }>; + getVolumeMetricContext(volumeId: string): Promise; } diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index bb332faf..258deaf1 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -1,13 +1,13 @@ -import { getMockLogger } from "../../tests/logger"; -import { PrivateKey } from "../../crypto"; -import { DecryptionError } from "../../errors"; -import { NodeAPIService } from "./apiService"; -import { NodesCache } from "./cache" -import { NodesCryptoCache } from "./cryptoCache"; -import { NodesCryptoService } from "./cryptoService"; +import { getMockLogger } from '../../tests/logger'; +import { PrivateKey } from '../../crypto'; +import { DecryptionError } from '../../errors'; +import { NodeAPIService } from './apiService'; +import { NodesCache } from './cache'; +import { NodesCryptoCache } from './cryptoCache'; +import { NodesCryptoService } from './cryptoService'; import { NodesAccess } from './nodesAccess'; -import { SharesService, DecryptedNode, DecryptedUnparsedNode, EncryptedNode, DecryptedNodeKeys } from "./interface"; -import { NodeType } from "../../interface"; +import { SharesService, DecryptedNode, DecryptedUnparsedNode, EncryptedNode, DecryptedNodeKeys } from './interface'; +import { NodeType } from '../../interface'; describe('nodesAccess', () => { let apiService: NodeAPIService; @@ -22,10 +22,10 @@ describe('nodesAccess', () => { apiService = { getNode: jest.fn(), iterateNodes: jest.fn().mockImplementation(async function* (uids: string[]) { - yield* uids.map((uid => ({ uid, parentUid: 'volumeId~parentNodeId' } as EncryptedNode))); + yield* uids.map((uid) => ({ uid, parentUid: 'volumeId~parentNodeId' }) as EncryptedNode); }), iterateChildrenNodeUids: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cache = { getNode: jest.fn(), @@ -34,16 +34,16 @@ describe('nodesAccess', () => { isFolderChildrenLoaded: jest.fn().mockResolvedValue(false), setFolderChildrenLoaded: jest.fn(), removeNodes: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoCache = { getNodeKeys: jest.fn(), setNodeKeys: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoService = { decryptNode: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking shareService = { getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), @@ -65,21 +65,27 @@ describe('nodesAccess', () => { it('should get node from API when cache is stale', async () => { const encryptedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid' } as EncryptedNode; - const decryptedUnparsedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid', name: { ok: true, value: 'name' } } as DecryptedUnparsedNode; + const decryptedUnparsedNode = { + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentNodeid', + name: { ok: true, value: 'name' }, + } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, name: { ok: true, value: 'name' }, isStale: false, activeRevision: undefined, folder: undefined, - treeEventScopeId: "volumeId", + treeEventScopeId: 'volumeId', } as DecryptedNode; const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; cache.getNode = jest.fn(() => Promise.resolve({ uid: 'volumeId~nodeId', isStale: true } as DecryptedNode)); apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); + cryptoService.decryptNode = jest.fn(() => + Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys }), + ); const result = await access.getNode('volumeId~nodeId'); expect(result).toEqual(decryptedNode); @@ -92,7 +98,11 @@ describe('nodesAccess', () => { it('should get node from API missing cache', async () => { const encryptedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid' } as EncryptedNode; - const decryptedUnparsedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid', name: { ok: true, value: 'name' } } as DecryptedUnparsedNode; + const decryptedUnparsedNode = { + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentNodeid', + name: { ok: true, value: 'name' }, + } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, name: { ok: true, value: 'name' }, @@ -106,7 +116,9 @@ describe('nodesAccess', () => { cache.getNode = jest.fn(() => Promise.reject(new Error('Entity not found'))); apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); + cryptoService.decryptNode = jest.fn(() => + Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys }), + ); const result = await access.getNode('volumeId~nodeId'); expect(result).toEqual(decryptedNode); @@ -119,7 +131,11 @@ describe('nodesAccess', () => { it('should validate node name', async () => { const encryptedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid' } as EncryptedNode; - const decryptedUnparsedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid', name: { ok: true, value: 'foo/bar' } } as DecryptedUnparsedNode; + const decryptedUnparsedNode = { + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentNodeid', + name: { ok: true, value: 'foo/bar' }, + } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, name: { ok: false, error: { name: 'foo/bar', error: "Name must not contain the character '/'" } }, @@ -130,7 +146,9 @@ describe('nodesAccess', () => { cache.getNode = jest.fn(() => Promise.reject(new Error('Entity not found'))); apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - cryptoService.decryptNode = jest.fn(() => Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys })); + cryptoService.decryptNode = jest.fn(() => + Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys }), + ); const result = await access.getNode('volumeId~nodeId'); expect(result).toMatchObject(decryptedNode); @@ -139,11 +157,19 @@ describe('nodesAccess', () => { describe('iterate methods', () => { beforeEach(() => { - cryptoCache.getNodeKeys = jest.fn().mockImplementation((uid: string) => Promise.resolve({ key: 'key' } as any as DecryptedNodeKeys)); - cryptoService.decryptNode = jest.fn().mockImplementation((encryptedNode: EncryptedNode) => Promise.resolve({ - node: { uid: encryptedNode.uid, isStale: false, name: { ok: true, value: 'name' } } as DecryptedNode, - keys: { key: 'key' } as any as DecryptedNodeKeys, - })); + cryptoCache.getNodeKeys = jest + .fn() + .mockImplementation((uid: string) => Promise.resolve({ key: 'key' } as any as DecryptedNodeKeys)); + cryptoService.decryptNode = jest.fn().mockImplementation((encryptedNode: EncryptedNode) => + Promise.resolve({ + node: { + uid: encryptedNode.uid, + isStale: false, + name: { ok: true, value: 'name' }, + } as DecryptedNode, + keys: { key: 'key' } as any as DecryptedNodeKeys, + }), + ); }); describe('iterateChildren', () => { @@ -222,7 +248,11 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('volumeId~parentNodeid', undefined); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], 'volumeId', undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith( + ['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], + 'volumeId', + undefined, + ); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); @@ -243,7 +273,7 @@ describe('nodesAccess', () => { }); apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { // Skip first node - make it missing. - yield* uids.slice(1).map((uid) => ({ uid, parentUid: parentNode.uid } as EncryptedNode)); + yield* uids.slice(1).map((uid) => ({ uid, parentUid: parentNode.uid }) as EncryptedNode); }); const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); @@ -268,7 +298,11 @@ describe('nodesAccess', () => { throw new DecryptionError('Decryption failed'); } return Promise.resolve({ - node: { uid: encryptedNode.uid, isStale: false, name: { ok: true, value: 'name' } } as DecryptedNode, + node: { + uid: encryptedNode.uid, + isStale: false, + name: { ok: true, value: 'name' }, + } as DecryptedNode, keys: { key: 'key' } as any as DecryptedNodeKeys, }); }); @@ -283,11 +317,9 @@ describe('nodesAccess', () => { try { await node3; } catch (error: any) { - expect(error.cause).toEqual([ - new DecryptionError('Decryption failed'), - ]); + expect(error.cause).toEqual([new DecryptionError('Decryption failed')]); } - }) + }); }); describe('iterateTrashedNodes', () => { @@ -324,7 +356,11 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateTrashedNodes()); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], volumeId, undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith( + ['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], + volumeId, + undefined, + ); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); @@ -336,7 +372,7 @@ describe('nodesAccess', () => { }); apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { // Skip first node - make it missing. - yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'volumeId~parentNodeid' } as EncryptedNode)); + yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'volumeId~parentNodeid' }) as EncryptedNode); }); const result = await Array.fromAsync(access.iterateTrashedNodes()); @@ -359,7 +395,9 @@ describe('nodesAccess', () => { yield { ok: true, node: node4 }; }); - const result = await Array.fromAsync(access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'])); + const result = await Array.fromAsync( + access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4']), + ); expect(result).toMatchObject([node1, node2, node3, node4]); expect(apiService.iterateNodes).not.toHaveBeenCalled(); }); @@ -372,9 +410,15 @@ describe('nodesAccess', () => { yield { ok: true, node: node4 }; }); - const result = await Array.fromAsync(access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'])); + const result = await Array.fromAsync( + access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4']), + ); expect(result).toMatchObject([node1, node4, node2, node3]); - expect(apiService.iterateNodes).toHaveBeenCalledWith(['volumeId~node2', 'volumeId~node3'], 'volumeId', undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith( + ['volumeId~node2', 'volumeId~node3'], + 'volumeId', + undefined, + ); }); it('should remove from cache if missing on API and return back to caller', async () => { @@ -385,11 +429,13 @@ describe('nodesAccess', () => { }); apiService.iterateNodes = jest.fn().mockImplementation(async function* (uids: string[]) { // Skip first node - make it missing. - yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'volumeId~parentNodeid' } as EncryptedNode)); + yield* uids.slice(1).map((uid) => ({ uid, parentUid: 'volumeId~parentNodeid' }) as EncryptedNode); }); - const result = await Array.fromAsync(access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3'])); - expect(result).toMatchObject([node2, node3, {missingUid: 'volumeId~node1'}]); + const result = await Array.fromAsync( + access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3']), + ); + expect(result).toMatchObject([node2, node3, { missingUid: 'volumeId~node1' }]); expect(cache.removeNodes).toHaveBeenCalledWith(['volumeId~node1']); }); @@ -407,10 +453,10 @@ describe('nodesAccess', () => { yield* uids.map((uid) => { const parentUid = uid.replace('node', 'parentOfNode'); return { - uid, - parentUid, - encryptedCrypto, - } as EncryptedNode + uid, + parentUid, + encryptedCrypto, + } as EncryptedNode; }); }); const decryptionError = new DecryptionError('Parent cannot be decrypted'); @@ -419,19 +465,27 @@ describe('nodesAccess', () => { throw decryptionError; } return { - key: {_idx: 32132}, + key: { _idx: 32132 }, } as any; - } ); + }); - const result = await Array.fromAsync(access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3'])); + const result = await Array.fromAsync( + access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3']), + ); expect(result).toEqual([ { ...node1, encryptedCrypto, parentUid: 'volumeId~parentOfNode1', name: { ok: false, error: decryptionError }, - keyAuthor: { ok: false, error: { claimedAuthor: 'signatureEmail', error: decryptionError.message } }, - nameAuthor: { ok: false, error: { claimedAuthor: 'nameSignatureEmail', error: decryptionError.message } }, + keyAuthor: { + ok: false, + error: { claimedAuthor: 'signatureEmail', error: decryptionError.message }, + }, + nameAuthor: { + ok: false, + error: { claimedAuthor: 'nameSignatureEmail', error: decryptionError.message }, + }, errors: [decryptionError], }, { @@ -530,30 +584,42 @@ describe('nodesAccess', () => { const nodeUid = 'volumeId~nodeId'; it('should return node URL of document', async () => { - jest.spyOn(access, 'getNode').mockReturnValue(Promise.resolve({ mediaType: 'application/vnd.proton.doc' } as any as DecryptedNode)); + jest.spyOn(access, 'getNode').mockReturnValue( + Promise.resolve({ mediaType: 'application/vnd.proton.doc' } as any as DecryptedNode), + ); const result = await access.getNodeUrl(nodeUid); expect(result).toBe('https://docs.proton.me/doc?type=doc&mode=open&volumeId=volumeId&linkId=nodeId'); }); it('should return node URL of sheet', async () => { - jest.spyOn(access, 'getNode').mockReturnValue(Promise.resolve({ mediaType: 'application/vnd.proton.sheet' } as any as DecryptedNode)); + jest.spyOn(access, 'getNode').mockReturnValue( + Promise.resolve({ mediaType: 'application/vnd.proton.sheet' } as any as DecryptedNode), + ); const result = await access.getNodeUrl(nodeUid); expect(result).toBe('https://docs.proton.me/doc?type=sheet&mode=open&volumeId=volumeId&linkId=nodeId'); }); it('should return node URL of image', async () => { - jest.spyOn(access, 'getNode').mockReturnValue(Promise.resolve({ type: NodeType.File } as any as DecryptedNode)); - jest.spyOn(access as any, 'getRootNode').mockReturnValue(Promise.resolve({ shareId: 'shareId', type: NodeType.Folder } as any as DecryptedNode)); + jest.spyOn(access, 'getNode').mockReturnValue( + Promise.resolve({ type: NodeType.File } as any as DecryptedNode), + ); + jest.spyOn(access as any, 'getRootNode').mockReturnValue( + Promise.resolve({ shareId: 'shareId', type: NodeType.Folder } as any as DecryptedNode), + ); const result = await access.getNodeUrl(nodeUid); expect(result).toBe('https://drive.proton.me/shareId/file/nodeId'); }); it('should return node URL of folder', async () => { - jest.spyOn(access, 'getNode').mockReturnValue(Promise.resolve({ type: NodeType.Folder } as any as DecryptedNode)); - jest.spyOn(access as any, 'getRootNode').mockReturnValue(Promise.resolve({ shareId: 'shareId', type: NodeType.Folder } as any as DecryptedNode)); + jest.spyOn(access, 'getNode').mockReturnValue( + Promise.resolve({ type: NodeType.Folder } as any as DecryptedNode), + ); + jest.spyOn(access as any, 'getRootNode').mockReturnValue( + Promise.resolve({ shareId: 'shareId', type: NodeType.Folder } as any as DecryptedNode), + ); const result = await access.getNodeUrl(nodeUid); expect(result).toBe('https://drive.proton.me/shareId/folder/nodeId'); @@ -567,7 +633,7 @@ describe('nodesAccess', () => { cache.setNode = jest.fn(); await access.notifyNodeChanged(node.uid); expect(cache.getNode).toHaveBeenCalledWith(node.uid); - expect(cache.setNode).toHaveBeenCalledWith({...node, isStale: true}); + expect(cache.setNode).toHaveBeenCalledWith({ ...node, isStale: true }); }); it('should update parent if needed', async () => { const node = { uid: 'volumeId~nodeId', parentUid: 'v1~pn1', isStale: false } as DecryptedNode; @@ -575,7 +641,7 @@ describe('nodesAccess', () => { cache.setNode = jest.fn(); await access.notifyNodeChanged(node.uid, 'v1~pn2'); expect(cache.getNode).toHaveBeenCalledWith(node.uid); - expect(cache.setNode).toHaveBeenCalledWith({...node, parentUid: 'v1~pn2', isStale: true}); + expect(cache.setNode).toHaveBeenCalledWith({ ...node, parentUid: 'v1~pn2', isStale: true }); }); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 77a4d901..709de40b 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,19 +1,19 @@ import { c } from 'ttag'; -import { PrivateKey, SessionKey } from "../../crypto"; -import { InvalidNameError, Logger, MissingNode, NodeType, Result, resultError, resultOk } from "../../interface"; -import { DecryptionError, ProtonDriveError } from "../../errors"; +import { PrivateKey, SessionKey } from '../../crypto'; +import { InvalidNameError, Logger, MissingNode, NodeType, Result, resultError, resultOk } from '../../interface'; +import { DecryptionError, ProtonDriveError } from '../../errors'; import { asyncIteratorMap } from '../asyncIteratorMap'; import { getErrorMessage } from '../errors'; -import { BatchLoading } from "../batchLoading"; -import { makeNodeUid, splitNodeUid } from "../uids"; -import { NodeAPIService } from "./apiService"; -import { NodesCache } from "./cache" -import { NodesCryptoCache } from "./cryptoCache"; -import { NodesCryptoService } from "./cryptoService"; -import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from "./extendedAttributes"; -import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys } from "./interface"; -import { validateNodeName } from "./validations"; +import { BatchLoading } from '../batchLoading'; +import { makeNodeUid, splitNodeUid } from '../uids'; +import { NodeAPIService } from './apiService'; +import { NodesCache } from './cache'; +import { NodesCryptoCache } from './cryptoCache'; +import { NodesCryptoService } from './cryptoService'; +import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from './extendedAttributes'; +import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys } from './interface'; +import { validateNodeName } from './validations'; import { isProtonDocument, isProtonSheet } from './mediaTypes'; // This is the number of nodes that are loaded in parallel. @@ -59,7 +59,7 @@ export class NodesAccess { let cachedNode; try { cachedNode = await this.cache.getNode(nodeUid); - } catch { } + } catch {} if (cachedNode && !cachedNode.isStale) { return cachedNode; @@ -75,7 +75,10 @@ export class NodesAccess { // Ensure the parent is loaded and up-to-date. const parentNode = await this.getNode(parentNodeUid); - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal), batchSize: BATCH_LOADING_SIZE }); + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal), + batchSize: BATCH_LOADING_SIZE, + }); const areChildrenCached = await this.cache.isFolderChildrenLoaded(parentNodeUid); if (areChildrenCached) { @@ -95,7 +98,7 @@ export class NodesAccess { let node; try { node = await this.cache.getNode(nodeUid); - } catch { } + } catch {} if (node && !node.isStale) { yield node; @@ -111,12 +114,15 @@ export class NodesAccess { // Improvement requested: keep status of loaded trash and leverage cache. async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.shareService.getMyFilesIDs(); - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal), batchSize: BATCH_LOADING_SIZE }); + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal), + batchSize: BATCH_LOADING_SIZE, + }); for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { let node; try { node = await this.cache.getNode(nodeUid); - } catch { } + } catch {} if (node && !node.isStale) { yield node; @@ -129,7 +135,10 @@ export class NodesAccess { } async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, signal), batchSize: BATCH_LOADING_SIZE }); + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, signal), + batchSize: BATCH_LOADING_SIZE, + }); for await (const result of this.cache.iterateNodes(nodeUids)) { if (result.ok && !result.node.isStale) { yield result.node; @@ -176,13 +185,13 @@ export class NodesAccess { await this.cache.removeNodes([nodeUid]); } - private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { + private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { const { volumeId: ownVolumeId } = await this.shareService.getMyFilesIDs(); const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); return this.decryptNode(encryptedNode); } - private async* loadNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + private async *loadNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for await (const result of this.loadNodesWithMissingReport(nodeUids, signal)) { if ('missingUid' in result) { continue; @@ -191,7 +200,10 @@ export class NodesAccess { } } - private async* loadNodesWithMissingReport(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + private async *loadNodesWithMissingReport( + nodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { const returnedNodeUids: string[] = []; const errors = []; @@ -207,7 +219,11 @@ export class NodesAccess { return resultError(error); } }; - const decryptedNodesIterator = asyncIteratorMap(encryptedNodesIterator, decryptNodeMapper, DECRYPTION_CONCURRENCY); + const decryptedNodesIterator = asyncIteratorMap( + encryptedNodesIterator, + decryptNodeMapper, + DECRYPTION_CONCURRENCY, + ); for await (const node of decryptedNodesIterator) { if (node.ok) { yield node.value; @@ -232,7 +248,9 @@ export class NodesAccess { } } - private async decryptNode(encryptedNode: EncryptedNode): Promise<{ node: DecryptedNode, keys?: DecryptedNodeKeys }> { + private async decryptNode( + encryptedNode: EncryptedNode, + ): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { let parentKey; try { const parentKeys = await this.getParentKeys(encryptedNode); @@ -299,18 +317,20 @@ export class NodesAccess { return { ...unparsedNode, isStale: false, - activeRevision: !unparsedNode.activeRevision?.ok ? unparsedNode.activeRevision : resultOk({ - uid: unparsedNode.activeRevision.value.uid, - state: unparsedNode.activeRevision.value.state, - creationTime: unparsedNode.activeRevision.value.creationTime, - storageSize: unparsedNode.activeRevision.value.storageSize, - contentAuthor: unparsedNode.activeRevision.value.contentAuthor, - thumbnails: unparsedNode.activeRevision.value.thumbnails, - ...extendedAttributes, - }), + activeRevision: !unparsedNode.activeRevision?.ok + ? unparsedNode.activeRevision + : resultOk({ + uid: unparsedNode.activeRevision.value.uid, + state: unparsedNode.activeRevision.value.state, + creationTime: unparsedNode.activeRevision.value.creationTime, + storageSize: unparsedNode.activeRevision.value.storageSize, + contentAuthor: unparsedNode.activeRevision.value.contentAuthor, + thumbnails: unparsedNode.activeRevision.value.thumbnails, + ...extendedAttributes, + }), folder: undefined, treeEventScopeId: splitNodeUid(unparsedNode.uid).volumeId, - } + }; } const extendedAttributes = unparsedNode.folder?.extendedAttributes @@ -321,14 +341,18 @@ export class NodesAccess { name: nodeName, isStale: false, activeRevision: undefined, - folder: extendedAttributes ? { - ...extendedAttributes, - } : undefined, + folder: extendedAttributes + ? { + ...extendedAttributes, + } + : undefined, treeEventScopeId: splitNodeUid(unparsedNode.uid).volumeId, - } + }; } - async getParentKeys(node: Pick): Promise> { + async getParentKeys( + node: Pick, + ): Promise> { if (node.parentUid) { try { return await this.getNodeKeys(node.parentUid); @@ -345,7 +369,7 @@ export class NodesAccess { if (node.shareId) { return { key: await this.shareService.getSharePrivateKey(node.shareId), - } + }; } // This is bug that should not happen. // API cannot provide node without parent or share. @@ -365,11 +389,11 @@ export class NodesAccess { } async getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ - key: PrivateKey, - passphrase: string, - passphraseSessionKey: SessionKey, - contentKeyPacketSessionKey?: SessionKey, - nameSessionKey: SessionKey, + key: PrivateKey; + passphrase: string; + passphraseSessionKey: SessionKey; + contentKeyPacketSessionKey?: SessionKey; + nameSessionKey: SessionKey; }> { const node = await this.getNode(nodeUid); const { key: parentKey } = await this.getParentKeys(node); @@ -385,10 +409,10 @@ export class NodesAccess { } async getRootNodeEmailKey(nodeUid: string): Promise<{ - email: string, - addressId: string, - addressKey: PrivateKey, - addressKeyId: string, + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; }> { const rootNode = await this.getRootNode(nodeUid); if (!rootNode.shareId) { @@ -418,5 +442,5 @@ export class NodesAccess { private async getRootNode(nodeUid: string): Promise { const node = await this.getNode(nodeUid); return node.parentUid ? this.getRootNode(node.parentUid) : node; - }; + } } diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 94487fe9..76066520 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -1,10 +1,10 @@ -import { NodeAPIService } from "./apiService"; -import { NodesCryptoCache } from "./cryptoCache"; -import { NodesCryptoService } from "./cryptoService"; +import { NodeAPIService } from './apiService'; +import { NodesCryptoCache } from './cryptoCache'; +import { NodesCryptoService } from './cryptoService'; import { NodesAccess } from './nodesAccess'; import { DecryptedNode } from './interface'; import { NodesManagement } from './nodesManagement'; -import { NodeResult } from "../../interface"; +import { NodeResult } from '../../interface'; describe('NodesManagement', () => { let apiService: NodeAPIService; @@ -50,20 +50,20 @@ describe('NodesManagement', () => { renameNode: jest.fn(), moveNode: jest.fn(), trashNodes: jest.fn(async function* (uids) { - yield* uids.map((uid) => ({ok: true, uid} as NodeResult)) + yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult); }), restoreNodes: jest.fn(async function* (uids) { - yield* uids.map((uid) => ({ok: true, uid} as NodeResult)) + yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult); }), deleteNodes: jest.fn(async function* (uids) { - yield* uids.map((uid) => ({ok: true, uid} as NodeResult)) + yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult); }), createFolder: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoCache = { setNodeKeys: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoService = { encryptNewName: jest.fn().mockResolvedValue({ @@ -73,7 +73,7 @@ describe('NodesManagement', () => { }), moveNode: jest.fn(), createFolder: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking nodesAccess = { getNode: jest.fn().mockImplementation((uid: string) => nodes[uid]), @@ -88,17 +88,19 @@ describe('NodesManagement', () => { hashKey: `${nodes[uid].parentUid}-hashKey`, })), iterateNodes: jest.fn(), - getNodePrivateAndSessionKeys: jest.fn().mockImplementation((uid) => Promise.resolve({ - key: `${uid}-key`, - passphrase: `${uid}-passphrase`, - passphraseSessionKey: `${uid}-passphraseSessionKey`, - contentKeyPacketSessionKey: `${uid}-contentKeyPacketSessionKey`, - nameSessionKey: `${uid}-nameSessionKey`, - })), - getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: "root-email", addressKey: "root-key" }), + getNodePrivateAndSessionKeys: jest.fn().mockImplementation((uid) => + Promise.resolve({ + key: `${uid}-key`, + passphrase: `${uid}-passphrase`, + passphraseSessionKey: `${uid}-passphraseSessionKey`, + contentKeyPacketSessionKey: `${uid}-contentKeyPacketSessionKey`, + nameSessionKey: `${uid}-nameSessionKey`, + }), + ), + getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: 'root-email', addressKey: 'root-key' }), notifyNodeChanged: jest.fn(), notifyNodeDeleted: jest.fn(), - } + }; management = new NodesManagement(apiService, cryptoCache, cryptoService, nodesAccess); }); @@ -117,13 +119,13 @@ describe('NodesManagement', () => { expect(cryptoService.encryptNewName).toHaveBeenCalledWith( { key: 'parentUid-key', hashKey: 'parentUid-hashKey' }, 'nodeUid-nameSessionKey', - { email: "root-email", addressKey: "root-key" }, + { email: 'root-email', addressKey: 'root-key' }, 'new name', ); expect(apiService.renameNode).toHaveBeenCalledWith( nodes.nodeUid.uid, { hash: nodes.nodeUid.hash }, - { encryptedName: 'newArmoredNodeName', nameSignatureEmail: 'newSignatureEmail', hash: 'newHash' } + { encryptedName: 'newArmoredNodeName', nameSignatureEmail: 'newSignatureEmail', hash: 'newHash' }, ); expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledWith('nodeUid'); }); @@ -136,7 +138,7 @@ describe('NodesManagement', () => { armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature', signatureEmail: 'movedSignatureEmail', nameSignatureEmail: 'movedNameSignatureEmail', - } + }; cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); const newNode = await management.moveNode('nodeUid', 'newParentNodeUid'); @@ -157,10 +159,10 @@ describe('NodesManagement', () => { passphrase: 'nodeUid-passphrase', passphraseSessionKey: 'nodeUid-passphraseSessionKey', contentKeyPacketSessionKey: 'nodeUid-contentKeyPacketSessionKey', - nameSessionKey: 'nodeUid-nameSessionKey' + nameSessionKey: 'nodeUid-nameSessionKey', }), expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), - { email: "root-email", addressKey: "root-key" }, + { email: 'root-email', addressKey: 'root-key' }, ); expect(apiService.moveNode).toHaveBeenCalledWith( 'nodeUid', @@ -185,7 +187,7 @@ describe('NodesManagement', () => { armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature', signatureEmail: 'movedSignatureEmail', nameSignatureEmail: 'movedNameSignatureEmail', - } + }; cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid'); @@ -197,10 +199,10 @@ describe('NodesManagement', () => { passphrase: 'anonymousNodeUid-passphrase', passphraseSessionKey: 'anonymousNodeUid-passphraseSessionKey', contentKeyPacketSessionKey: 'anonymousNodeUid-contentKeyPacketSessionKey', - nameSessionKey: 'anonymousNodeUid-nameSessionKey' + nameSessionKey: 'anonymousNodeUid-nameSessionKey', }), expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), - { email: "root-email", addressKey: "root-key" }, + { email: 'root-email', addressKey: 'root-key' }, ); expect(newNode).toEqual({ ...nodes.anonymousNodeUid, @@ -217,12 +219,12 @@ describe('NodesManagement', () => { }, { parentUid: 'newParentNodeUid', - ...encryptedCrypto + ...encryptedCrypto, }, ); }); - it("trashes node and updates cache", async () => { + it('trashes node and updates cache', async () => { const uids = ['v1~n1', 'v1~n2']; const trashed = new Set(); for await (const node of management.trashNodes(uids)) { @@ -232,7 +234,7 @@ describe('NodesManagement', () => { expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledTimes(2); }); - it("restores node and updates cache", async () => { + it('restores node and updates cache', async () => { const uids = ['v1~n1', 'v1~n2']; const restored = new Set(); for await (const node of management.restoreNodes(uids)) { @@ -241,5 +243,4 @@ describe('NodesManagement', () => { expect(restored).toEqual(new Set(uids)); expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledTimes(2); }); - }); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index d8d0eed6..e3551413 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -1,15 +1,15 @@ import { c } from 'ttag'; -import { MemberRole, NodeType, NodeResult, resultOk } from "../../interface"; -import { AbortError, ValidationError } from "../../errors"; +import { MemberRole, NodeType, NodeResult, resultOk } from '../../interface'; +import { AbortError, ValidationError } from '../../errors'; import { getErrorMessage } from '../errors'; -import { NodeAPIService } from "./apiService"; -import { NodesCryptoCache } from "./cryptoCache"; -import { NodesCryptoService } from "./cryptoService"; -import { DecryptedNode } from "./interface"; -import { NodesAccess } from "./nodesAccess"; -import { validateNodeName } from "./validations"; -import { generateFolderExtendedAttributes } from "./extendedAttributes"; +import { NodeAPIService } from './apiService'; +import { NodesCryptoCache } from './cryptoCache'; +import { NodesCryptoService } from './cryptoService'; +import { DecryptedNode } from './interface'; +import { NodesAccess } from './nodesAccess'; +import { validateNodeName } from './validations'; +import { generateFolderExtendedAttributes } from './extendedAttributes'; import { splitNodeUid } from '../uids'; /** @@ -34,7 +34,11 @@ export class NodesManagement { this.nodesAccess = nodesAccess; } - async renameNode(nodeUid: string, newName: string, options = { allowRenameRootNode: false }): Promise { + async renameNode( + nodeUid: string, + newName: string, + options = { allowRenameRootNode: false }, + ): Promise { validateNodeName(newName); const node = await this.nodesAccess.getNode(nodeUid); @@ -43,19 +47,20 @@ export class NodesManagement { const address = await this.nodesAccess.getRootNodeEmailKey(nodeUid); if (!options.allowRenameRootNode && (!node.hash || !parentKeys.hashKey)) { - throw new ValidationError(c('Error').t`Renaming root item is not allowed`) + throw new ValidationError(c('Error').t`Renaming root item is not allowed`); } - const { - signatureEmail, - armoredNodeName, - hash, - } = await this.cryptoService.encryptNewName(parentKeys, nodeNameSessionKey, address, newName); + const { signatureEmail, armoredNodeName, hash } = await this.cryptoService.encryptNewName( + parentKeys, + nodeNameSessionKey, + address, + newName, + ); // Because hash is optional, lets ensure we have it unless explicitely // allowed to rename root node. if (!options.allowRenameRootNode && !hash) { - throw new Error("Node hash not generated"); + throw new Error('Node hash not generated'); } await this.apiService.renameNode( @@ -67,7 +72,7 @@ export class NodesManagement { encryptedName: armoredNodeName, nameSignatureEmail: signatureEmail, hash: hash, - } + }, ); await this.nodesAccess.notifyNodeChanged(nodeUid); const newNode: DecryptedNode = { @@ -76,12 +81,12 @@ export class NodesManagement { encryptedName: armoredNodeName, nameAuthor: resultOk(signatureEmail), hash, - } + }; return newNode; } // Improvement requested: move nodes in parallel - async* moveNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + async *moveNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator { for (const nodeUid of nodeUids) { if (signal?.aborted) { throw new AbortError(c('Error').t`Move operation aborted`); @@ -91,13 +96,13 @@ export class NodesManagement { yield { uid: nodeUid, ok: true, - } + }; } catch (error: unknown) { yield { uid: nodeUid, ok: false, error: getErrorMessage(error), - } + }; } } } @@ -132,10 +137,12 @@ export class NodesManagement { // Node passphrase and signature email must be passed if and only if // the the signatures are missing (key author is null). const anonymousKey = node.keyAuthor.ok && node.keyAuthor.value === null; - const keySignatureProperties = !anonymousKey ? {} : { - signatureEmail: encryptedCrypto.signatureEmail, - armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, - } + const keySignatureProperties = !anonymousKey + ? {} + : { + signatureEmail: encryptedCrypto.signatureEmail, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + }; await this.apiService.moveNode( nodeUid, { @@ -149,7 +156,7 @@ export class NodesManagement { nameSignatureEmail: encryptedCrypto.nameSignatureEmail, hash: encryptedCrypto.hash, // TODO: When moving photos, we need to pass content hash. - } + }, ); const newNode: DecryptedNode = { ...node, @@ -163,7 +170,7 @@ export class NodesManagement { return newNode; } - async* trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for await (const result of this.apiService.trashNodes(nodeUids, signal)) { if (result.ok) { await this.nodesAccess.notifyNodeChanged(result.uid); @@ -172,7 +179,7 @@ export class NodesManagement { } } - async* restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for await (const result of this.apiService.restoreNodes(nodeUids, signal)) { if (result.ok) { await this.nodesAccess.notifyNodeChanged(result.uid); @@ -181,7 +188,7 @@ export class NodesManagement { } } - async* deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const deletedNodeUids = []; for await (const result of this.apiService.deleteNodes(nodeUids, signal)) { @@ -233,7 +240,7 @@ export class NodesManagement { uid: nodeUid, parentUid: parentNodeUid, type: NodeType.Folder, - mediaType: "Folder", + mediaType: 'Folder', creationTime: new Date(), // Share node metadata @@ -246,7 +253,7 @@ export class NodesManagement { nameAuthor: resultOk(encryptedCrypto.signatureEmail), name: resultOk(folderName), treeEventScopeId: splitNodeUid(nodeUid).volumeId, - } + }; await this.cryptoCache.setNodeKeys(nodeUid, keys); return node; diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index fb8f86a0..40e5ad23 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -1,9 +1,9 @@ -import { Logger, Revision } from "../../interface"; -import { makeNodeUidFromRevisionUid } from "../uids"; -import { NodeAPIService } from "./apiService"; -import { NodesCryptoService } from "./cryptoService"; -import { NodesAccess } from "./nodesAccess"; -import { parseFileExtendedAttributes } from "./extendedAttributes"; +import { Logger, Revision } from '../../interface'; +import { makeNodeUidFromRevisionUid } from '../uids'; +import { NodeAPIService } from './apiService'; +import { NodesCryptoService } from './cryptoService'; +import { NodesAccess } from './nodesAccess'; +import { parseFileExtendedAttributes } from './extendedAttributes'; /** * Provides access to revisions metadata. @@ -34,7 +34,7 @@ export class NodesRevisons { }; } - async* iterateRevisions(nodeUid: string, signal?: AbortSignal): AsyncGenerator { + async *iterateRevisions(nodeUid: string, signal?: AbortSignal): AsyncGenerator { const { key } = await this.nodesAccess.getNodeKeys(nodeUid); const encryptedRevisions = await this.apiService.getRevisions(nodeUid, signal); diff --git a/js/sdk/src/internal/nodes/validations.ts b/js/sdk/src/internal/nodes/validations.ts index 0133b905..00ef5ac8 100644 --- a/js/sdk/src/internal/nodes/validations.ts +++ b/js/sdk/src/internal/nodes/validations.ts @@ -16,8 +16,8 @@ export function validateNodeName(name: string): void { c('Error').ngettext( msgid`Name must be ${MAX_NODE_NAME_LENGTH} character long at most`, `Name must be ${MAX_NODE_NAME_LENGTH} characters long at most`, - MAX_NODE_NAME_LENGTH - ) + MAX_NODE_NAME_LENGTH, + ), ); } if (name.includes('/')) { diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index 00e668a7..ad2da064 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -1,6 +1,6 @@ -import { PhotosAPIService } from "./apiService"; -import { PhotosCache } from "./cache"; -import { NodesService } from "./interface"; +import { PhotosAPIService } from './apiService'; +import { PhotosCache } from './cache'; +import { NodesService } from './interface'; export class Albums { constructor( @@ -13,12 +13,12 @@ export class Albums { this.nodesService = nodesService; } - async* iterateAlbums() { + async *iterateAlbums() { for await (const album of this.apiService.iterateAlbums()) { const node = await this.nodesService.getNode(album.uid); yield { node, - } + }; } } diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index b40d6019..be2cc1e1 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,16 +1,13 @@ -import { DriveAPIService } from "../apiService"; +import { DriveAPIService } from '../apiService'; export class PhotosAPIService { constructor(private apiService: DriveAPIService) { this.apiService = apiService; } - async* iterateTimeline(): AsyncGenerator { - } + async *iterateTimeline(): AsyncGenerator {} - async* iterateAlbums(): AsyncGenerator { - } + async *iterateAlbums(): AsyncGenerator {} - async createAlbum(object: any): Promise { - } + async createAlbum(object: any): Promise {} } diff --git a/js/sdk/src/internal/photos/cache.ts b/js/sdk/src/internal/photos/cache.ts index 04b50702..f1174a5e 100644 --- a/js/sdk/src/internal/photos/cache.ts +++ b/js/sdk/src/internal/photos/cache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveEntitiesCache } from "../../interface"; +import { ProtonDriveEntitiesCache } from '../../interface'; export class PhotosCache { constructor(private driveCache: ProtonDriveEntitiesCache) { diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index e91af5d4..4e76b5bf 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -1,10 +1,10 @@ -import { DriveAPIService } from "../apiService"; -import { ProtonDriveEntitiesCache } from "../../interface"; -import { PhotosAPIService } from "./apiService"; -import { PhotosCache } from "./cache"; -import { PhotosTimeline } from "./photosTimeline"; -import { Albums } from "./albums"; -import { NodesService } from "./interface"; +import { DriveAPIService } from '../apiService'; +import { ProtonDriveEntitiesCache } from '../../interface'; +import { PhotosAPIService } from './apiService'; +import { PhotosCache } from './cache'; +import { PhotosTimeline } from './photosTimeline'; +import { Albums } from './albums'; +import { NodesService } from './interface'; export function initPhotosModule( apiService: DriveAPIService, @@ -19,5 +19,5 @@ export function initPhotosModule( return { timeline, albums, - } + }; } diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 363d03c4..00b78d74 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -1,5 +1,5 @@ -import { MissingNode } from "../../interface"; -import { DecryptedNode } from "../nodes"; +import { MissingNode } from '../../interface'; +import { DecryptedNode } from '../nodes'; export interface NodesService { getNode(nodeUid: string): Promise; diff --git a/js/sdk/src/internal/photos/photosTimeline.ts b/js/sdk/src/internal/photos/photosTimeline.ts index d7ff50b5..105baaf7 100644 --- a/js/sdk/src/internal/photos/photosTimeline.ts +++ b/js/sdk/src/internal/photos/photosTimeline.ts @@ -1,6 +1,6 @@ -import { PhotosAPIService } from "./apiService"; -import { PhotosCache } from "./cache"; -import { NodesService } from "./interface"; +import { PhotosAPIService } from './apiService'; +import { PhotosCache } from './cache'; +import { NodesService } from './interface'; export class PhotosTimeline { constructor( @@ -13,6 +13,5 @@ export class PhotosTimeline { this.nodesService = nodesService; } - async getTimelineStructure() { - } + async getTimelineStructure() {} } diff --git a/js/sdk/src/internal/sdkEvents.test.ts b/js/sdk/src/internal/sdkEvents.test.ts index 63db5f4a..f7a42f08 100644 --- a/js/sdk/src/internal/sdkEvents.test.ts +++ b/js/sdk/src/internal/sdkEvents.test.ts @@ -1,7 +1,7 @@ -import { SDKEvent } from "../interface"; -import { SDKEvents } from "./sdkEvents"; +import { SDKEvent } from '../interface'; +import { SDKEvents } from './sdkEvents'; -describe("SDKEvents", () => { +describe('SDKEvents', () => { let sdkEvents: SDKEvents; let logger: { debug: jest.Mock }; @@ -10,13 +10,13 @@ describe("SDKEvents", () => { sdkEvents = new SDKEvents({ getLogger: () => logger } as any); }); - it("should log when no listeners are present for an event", () => { + it('should log when no listeners are present for an event', () => { sdkEvents.requestsThrottled(); - expect(logger.debug).toHaveBeenCalledWith("No listeners for event: requestsThrottled"); + expect(logger.debug).toHaveBeenCalledWith('No listeners for event: requestsThrottled'); }); - it("should emit an event to its listeners", () => { + it('should emit an event to its listeners', () => { const requestsThrottledListener = jest.fn(); sdkEvents.addListener(SDKEvent.RequestsThrottled, requestsThrottledListener); const requestsUnthrottledListener = jest.fn(); @@ -26,10 +26,10 @@ describe("SDKEvents", () => { expect(requestsThrottledListener).toHaveBeenCalled(); expect(requestsUnthrottledListener).not.toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalledWith("Emitting event: requestsThrottled"); + expect(logger.debug).toHaveBeenCalledWith('Emitting event: requestsThrottled'); }); - it("should emit an event to multiple listeners", () => { + it('should emit an event to multiple listeners', () => { const requestsThrottledListener1 = jest.fn(); const requestsThrottledListener2 = jest.fn(); sdkEvents.addListener(SDKEvent.RequestsThrottled, requestsThrottledListener1); @@ -39,10 +39,10 @@ describe("SDKEvents", () => { expect(requestsThrottledListener1).toHaveBeenCalled(); expect(requestsThrottledListener2).toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalledWith("Emitting event: requestsThrottled"); + expect(logger.debug).toHaveBeenCalledWith('Emitting event: requestsThrottled'); }); - it("should not emit after unsubsribe", () => { + it('should not emit after unsubsribe', () => { const callback = jest.fn(); const unsubscribe = sdkEvents.addListener(SDKEvent.RequestsThrottled, callback); diff --git a/js/sdk/src/internal/sdkEvents.ts b/js/sdk/src/internal/sdkEvents.ts index 65ede84c..775c722b 100644 --- a/js/sdk/src/internal/sdkEvents.ts +++ b/js/sdk/src/internal/sdkEvents.ts @@ -1,4 +1,4 @@ -import { ProtonDriveTelemetry, Logger, SDKEvent } from "../interface"; +import { ProtonDriveTelemetry, Logger, SDKEvent } from '../interface'; export class SDKEvents { private logger: Logger; @@ -9,17 +9,11 @@ export class SDKEvents { } addListener(eventName: SDKEvent, callback: () => void): () => void { - this.listeners.set(eventName, [ - ...(this.listeners.get(eventName) || []), - callback, - ]); + this.listeners.set(eventName, [...(this.listeners.get(eventName) || []), callback]); return () => { - this.listeners.set( - eventName, - this.listeners.get(eventName)?.filter((cb) => cb !== callback) || [] - ); - } + this.listeners.set(eventName, this.listeners.get(eventName)?.filter((cb) => cb !== callback) || []); + }; } transfersPaused(): void { @@ -45,8 +39,6 @@ export class SDKEvents { } this.logger.debug(`Emitting event: ${eventName}`); - this.listeners - .get(eventName) - ?.forEach((callback) => callback()); + this.listeners.get(eventName)?.forEach((callback) => callback()); } } diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index a48a118f..52a8b2d9 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -1,20 +1,29 @@ -import { DriveAPIService, drivePaths } from "../apiService"; -import { makeMemberUid } from "../uids"; -import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto, ShareType } from "./interface"; - -type PostCreateVolumeRequest = Extract['content']['application/json']; +import { DriveAPIService, drivePaths } from '../apiService'; +import { makeMemberUid } from '../uids'; +import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto, ShareType } from './interface'; + +type PostCreateVolumeRequest = Extract< + drivePaths['/drive/volumes']['post']['requestBody'], + { content: object } +>['content']['application/json']; type PostCreateVolumeResponse = drivePaths['/drive/volumes']['post']['responses']['200']['content']['application/json']; -type PostCreateShareRequest = Extract['content']['application/json']; -type PostCreateShareResponse = drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; - -type GetMyFilesResponse = drivePaths['/drive/v2/shares/my-files']['get']['responses']['200']['content']['application/json']; -type GetVolumeResponse = drivePaths['/drive/volumes/{volumeID}']['get']['responses']['200']['content']['application/json']; +type PostCreateShareRequest = Extract< + drivePaths['/drive/volumes/{volumeID}/shares']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateShareResponse = + drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; + +type GetMyFilesResponse = + drivePaths['/drive/v2/shares/my-files']['get']['responses']['200']['content']['application/json']; +type GetVolumeResponse = + drivePaths['/drive/volumes/{volumeID}']['get']['responses']['200']['content']['application/json']; type GetShareResponse = drivePaths['/drive/shares/{shareID}']['get']['responses']['200']['content']['application/json']; /** * Provides API communication for fetching shares and creating volumes. - * + * * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ @@ -44,7 +53,7 @@ export class SharesAPIService { const response = await this.apiService.get(`drive/volumes/${volumeId}`); return { shareId: response.Volume.Share.ShareID, - } + }; } async getShare(shareId: string): Promise { @@ -54,11 +63,11 @@ export class SharesAPIService { /** * Returns root share with address key. - * + * * This function provides access to root shares that provides access * to node tree via address key. For this reason, caller must use this * only when it is clear the shareId is root share. - * + * * @throws Error when share is not root share. */ async getRootShare(shareId: string): Promise { @@ -76,17 +85,17 @@ export class SharesAPIService { async createVolume( share: { - addressId: string, - addressKeyId: string, + addressId: string; + addressKeyId: string; } & EncryptedShareCrypto, node: { - encryptedName: string, - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, - armoredHashKey: string, + encryptedName: string; + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + armoredHashKey: string; }, - ): Promise<{ volumeId: string, shareId: string, rootNodeId: string }> { + ): Promise<{ volumeId: string; shareId: string; rootNodeId: string }> { const response = await this.apiService.post< // Volume & share names are deprecated. Omit, @@ -108,19 +117,19 @@ export class SharesAPIService { volumeId: response.Volume.ID, shareId: response.Volume.Share.ShareID, rootNodeId: response.Volume.Share.LinkID, - } + }; } async createShare( volumeId: string, share: { - addressId: string, + addressId: string; } & EncryptedShareCrypto, node: { - nodeId: string, - encryptedName: string, - nameKeyPacket: string, - passphraseKeyPacket: string, + nodeId: string; + encryptedName: string; + nameKeyPacket: string; + passphraseKeyPacket: string; }, ): Promise<{ shareId: string }> { const response = await this.apiService.post< @@ -139,7 +148,7 @@ export class SharesAPIService { return { shareId: response.Share.ID, - } + }; } } @@ -149,15 +158,17 @@ function convertSharePayload(response: GetShareResponse): EncryptedShare { shareId: response.ShareID, rootNodeId: response.LinkID, creatorEmail: response.Creator, - creationTime: response.CreateTime ? new Date(response.CreateTime*1000) : undefined, + creationTime: response.CreateTime ? new Date(response.CreateTime * 1000) : undefined, encryptedCrypto: { armoredKey: response.Key, armoredPassphrase: response.Passphrase, armoredPassphraseSignature: response.PassphraseSignature, }, - membership: response.Memberships?.[0] ? { - memberUid: makeMemberUid(response.ShareID, response.Memberships[0].MemberID), - } : undefined, + membership: response.Memberships?.[0] + ? { + memberUid: makeMemberUid(response.ShareID, response.Memberships[0].MemberID), + } + : undefined, type: convertShareTypeNumberToEnum(response.Type), }; } diff --git a/js/sdk/src/internal/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts index acb24cc2..1b1709af 100644 --- a/js/sdk/src/internal/shares/cache.test.ts +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -1,6 +1,6 @@ -import { MemoryCache } from "../../cache"; -import { getMockLogger } from "../../tests/logger"; -import { SharesCache } from "./cache"; +import { MemoryCache } from '../../cache'; +import { getMockLogger } from '../../tests/logger'; +import { SharesCache } from './cache'; describe('sharesCache', () => { let memoryCache: MemoryCache; @@ -45,7 +45,9 @@ describe('sharesCache', () => { await cache.getVolume('badObject'); fail('Should have thrown an error'); } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialize volume: Unexpected token \'a\', \"aaa\" is not valid JSON'); + expect(`${error}`).toBe( + 'Error: Failed to deserialize volume: Unexpected token \'a\', \"aaa\" is not valid JSON', + ); } try { diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index 04ffd734..772c9318 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -1,6 +1,6 @@ -import { ProtonDriveEntitiesCache, Logger } from "../../interface"; -import { getErrorMessage } from "../errors"; -import { Volume } from "./interface"; +import { ProtonDriveEntitiesCache, Logger } from '../../interface'; +import { getErrorMessage } from '../errors'; +import { Volume } from './interface'; /** * Provides caching for shares and volume metadata. @@ -10,7 +10,10 @@ import { Volume } from "./interface"; * This is only intended for the owner's main volume. There is no cache invalidation. */ export class SharesCache { - constructor(private logger: Logger, private driveCache: ProtonDriveEntitiesCache) { + constructor( + private logger: Logger, + private driveCache: ProtonDriveEntitiesCache, + ) { this.logger = logger; this.driveCache = driveCache; } @@ -51,14 +54,20 @@ function serializeVolume(volume: Volume) { } function deserializeVolume(shareData: string): Volume { - const volume = JSON.parse(shareData); - if ( - !volume || typeof volume !== 'object' || - !volume.volumeId || typeof volume.volumeId !== 'string' || - !volume.shareId || typeof volume.shareId !== 'string' || - !volume.rootNodeId || typeof volume.rootNodeId !== 'string' || - !volume.creatorEmail || typeof volume.creatorEmail !== 'string' || - !volume.addressId || typeof volume.addressId !== 'string' + const volume = JSON.parse(shareData); + if ( + !volume || + typeof volume !== 'object' || + !volume.volumeId || + typeof volume.volumeId !== 'string' || + !volume.shareId || + typeof volume.shareId !== 'string' || + !volume.rootNodeId || + typeof volume.rootNodeId !== 'string' || + !volume.creatorEmail || + typeof volume.creatorEmail !== 'string' || + !volume.addressId || + typeof volume.addressId !== 'string' ) { throw new Error('Invalid volume data'); } diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts index 23dcaa61..c5c6f549 100644 --- a/js/sdk/src/internal/shares/cryptoCache.test.ts +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -1,19 +1,19 @@ -import { PrivateKey, SessionKey } from "../../crypto"; -import { MemoryCache } from "../../cache"; -import { CachedCryptoMaterial } from "../../interface"; -import { SharesCryptoCache } from "./cryptoCache"; +import { PrivateKey, SessionKey } from '../../crypto'; +import { MemoryCache } from '../../cache'; +import { CachedCryptoMaterial } from '../../interface'; +import { SharesCryptoCache } from './cryptoCache'; describe('sharesCryptoCache', () => { let memoryCache: MemoryCache; let cache: SharesCryptoCache; const generatePrivateKey = (name: string) => { - return name as unknown as PrivateKey - } + return name as unknown as PrivateKey; + }; const generateSessionKey = (name: string) => { - return name as unknown as SessionKey - } + return name as unknown as SessionKey; + }; beforeEach(() => { memoryCache = new MemoryCache(); @@ -32,8 +32,14 @@ describe('sharesCryptoCache', () => { it('should replace and retrieve new keys', async () => { const shareId = 'newShareId'; - const keys1 = { key: generatePrivateKey('privateKey1'), passphraseSessionKey: generateSessionKey('sessionKey1') }; - const keys2 = { key: generatePrivateKey('privateKey2'), passphraseSessionKey: generateSessionKey('sessionKey2') }; + const keys1 = { + key: generatePrivateKey('privateKey1'), + passphraseSessionKey: generateSessionKey('sessionKey1'), + }; + const keys2 = { + key: generatePrivateKey('privateKey2'), + passphraseSessionKey: generateSessionKey('sessionKey2'), + }; await cache.setShareKey(shareId, keys1); await cache.setShareKey(shareId, keys2); @@ -67,4 +73,4 @@ describe('sharesCryptoCache', () => { expect(`${error}`).toBe('Error: Entity not found'); } }); -}); \ No newline at end of file +}); diff --git a/js/sdk/src/internal/shares/cryptoCache.ts b/js/sdk/src/internal/shares/cryptoCache.ts index b381b136..b2b6bffb 100644 --- a/js/sdk/src/internal/shares/cryptoCache.ts +++ b/js/sdk/src/internal/shares/cryptoCache.ts @@ -1,12 +1,12 @@ -import { ProtonDriveCryptoCache } from "../../interface"; -import { DecryptedShareKey } from "./interface"; +import { ProtonDriveCryptoCache } from '../../interface'; +import { DecryptedShareKey } from './interface'; /** * Provides caching for share crypto material. - * + * * The cache is responsible for serialising and deserialising share * crypto material. - * + * * The share crypto materials are cached so the updates to the root * nodes can be decrypted without the need to fetch the share keys * from the server again. Otherwise the rest of the tree requires diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts index 5e139aa4..9501e2fc 100644 --- a/js/sdk/src/internal/shares/cryptoService.test.ts +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -1,10 +1,10 @@ -import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto"; -import { ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; -import { getMockTelemetry } from "../../tests/telemetry"; -import { EncryptedRootShare, ShareType } from "./interface"; -import { SharesCryptoService } from "./cryptoService"; +import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; +import { ProtonDriveAccount, ProtonDriveTelemetry } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { EncryptedRootShare, ShareType } from './interface'; +import { SharesCryptoService } from './cryptoService'; -describe("SharesCryptoService", () => { +describe('SharesCryptoService', () => { let telemetry: ProtonDriveTelemetry; let driveCrypto: DriveCrypto; let account: ProtonDriveAccount; @@ -14,89 +14,89 @@ describe("SharesCryptoService", () => { telemetry = getMockTelemetry(); // @ts-expect-error No need to implement all methods for mocking driveCrypto = { - decryptKey: jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "sessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.SIGNED_AND_VALID, - })), + decryptKey: jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'sessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }), + ), }; account = { // @ts-expect-error No need to implement full response for mocking getOwnAddress: jest.fn(async () => ({ - keys: [{ key: "addressKey" as unknown as PrivateKey }], + keys: [{ key: 'addressKey' as unknown as PrivateKey }], })), getPublicKeys: jest.fn(async () => []), }; cryptoService = new SharesCryptoService(telemetry, driveCrypto, account); }); - it("should decrypt root share", async () => { - const result = await cryptoService.decryptRootShare( - { - shareId: "shareId", - addressId: "addressId", - creatorEmail: "signatureEmail", - encryptedCrypto: { - armoredKey: "armoredKey", - armoredPassphrase: "armoredPassphrase", - armoredPassphraseSignature: "armoredPassphraseSignature", - }, - type: ShareType.Main, - } as EncryptedRootShare, - ); + it('should decrypt root share', async () => { + const result = await cryptoService.decryptRootShare({ + shareId: 'shareId', + addressId: 'addressId', + creatorEmail: 'signatureEmail', + encryptedCrypto: { + armoredKey: 'armoredKey', + armoredPassphrase: 'armoredPassphrase', + armoredPassphraseSignature: 'armoredPassphraseSignature', + }, + type: ShareType.Main, + } as EncryptedRootShare); expect(result).toMatchObject({ share: { - shareId: "shareId", - author: { ok: true, value: "signatureEmail" }, + shareId: 'shareId', + author: { ok: true, value: 'signatureEmail' }, }, key: { - key: "decryptedKey", - passphraseSessionKey: "sessionKey", + key: 'decryptedKey', + passphraseSessionKey: 'sessionKey', }, }); - expect(account.getOwnAddress).toHaveBeenCalledWith("addressId"); - expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(account.getOwnAddress).toHaveBeenCalledWith('addressId'); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); expect(telemetry.logEvent).not.toHaveBeenCalled(); }); - it("should decrypt root share with signiture verification error", async () => { - driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ - passphrase: "pass", - key: "decryptedKey" as unknown as PrivateKey, - passphraseSessionKey: "sessionKey" as unknown as SessionKey, - verified: VERIFICATION_STATUS.NOT_SIGNED, - })); - - const result = await cryptoService.decryptRootShare( - { - shareId: "shareId", - addressId: "addressId", - creatorEmail: "signatureEmail", - encryptedCrypto: { - armoredKey: "armoredKey", - armoredPassphrase: "armoredPassphrase", - armoredPassphraseSignature: "armoredPassphraseSignature", - }, - type: ShareType.Main, - } as EncryptedRootShare, + it('should decrypt root share with signiture verification error', async () => { + driveCrypto.decryptKey = jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'sessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.NOT_SIGNED, + }), ); + const result = await cryptoService.decryptRootShare({ + shareId: 'shareId', + addressId: 'addressId', + creatorEmail: 'signatureEmail', + encryptedCrypto: { + armoredKey: 'armoredKey', + armoredPassphrase: 'armoredPassphrase', + armoredPassphraseSignature: 'armoredPassphraseSignature', + }, + type: ShareType.Main, + } as EncryptedRootShare); + expect(result).toMatchObject({ share: { - shareId: "shareId", - author: { ok: false, error: { claimedAuthor: "signatureEmail", error: "Missing signature" } }, + shareId: 'shareId', + author: { ok: false, error: { claimedAuthor: 'signatureEmail', error: 'Missing signature' } }, }, key: { - key: "decryptedKey", - passphraseSessionKey: "sessionKey", + key: 'decryptedKey', + passphraseSessionKey: 'sessionKey', }, }); - expect(account.getOwnAddress).toHaveBeenCalledWith("addressId"); - expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail"); + expect(account.getOwnAddress).toHaveBeenCalledWith('addressId'); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: 'verificationError', volumeType: 'own_volume', @@ -106,23 +106,21 @@ describe("SharesCryptoService", () => { }); }); - it("should handle decrypt issue of root share", async () => { - const error = new Error("Decryption error"); + it('should handle decrypt issue of root share', async () => { + const error = new Error('Decryption error'); driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); - const result = cryptoService.decryptRootShare( - { - shareId: "shareId", - addressId: "addressId", - creatorEmail: "signatureEmail", - encryptedCrypto: { - armoredKey: "armoredKey", - armoredPassphrase: "armoredPassphrase", - armoredPassphraseSignature: "armoredPassphraseSignature", - }, - type: ShareType.Main, - } as EncryptedRootShare, - ); + const result = cryptoService.decryptRootShare({ + shareId: 'shareId', + addressId: 'addressId', + creatorEmail: 'signatureEmail', + encryptedCrypto: { + armoredKey: 'armoredKey', + armoredPassphrase: 'armoredPassphrase', + armoredPassphraseSignature: 'armoredPassphraseSignature', + }, + type: ShareType.Main, + } as EncryptedRootShare); await expect(result).rejects.toThrow(error); diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 36f23c60..342468af 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -1,16 +1,31 @@ -import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger, MetricVolumeType } from "../../interface"; -import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto"; -import { getVerificationMessage } from "../errors"; -import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareKey, ShareType } from "./interface"; +import { + ProtonDriveAccount, + resultOk, + resultError, + Result, + UnverifiedAuthorError, + ProtonDriveTelemetry, + Logger, + MetricVolumeType, +} from '../../interface'; +import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from '../../crypto'; +import { getVerificationMessage } from '../errors'; +import { + EncryptedRootShare, + DecryptedRootShare, + EncryptedShareCrypto, + DecryptedShareKey, + ShareType, +} from './interface'; /** * Provides crypto operations for share keys. - * + * * The share crypto service is responsible for encrypting and decrypting share * keys. It should export high-level actions only, such as "decrypt share" * instead of low-level operations like "decrypt share passphrase". Low-level * operations should be kept private to the module. - * + * * The service owns the logic to switch between old and new crypto model. */ export class SharesCryptoService { @@ -19,7 +34,11 @@ export class SharesCryptoService { private reportedDecryptionErrors = new Set(); private reportedVerificationErrors = new Set(); - constructor(private telemetry: ProtonDriveTelemetry, private driveCrypto: DriveCrypto, private account: ProtonDriveAccount) { + constructor( + private telemetry: ProtonDriveTelemetry, + private driveCrypto: DriveCrypto, + private account: ProtonDriveAccount, + ) { this.telemetry = telemetry; this.logger = telemetry.getLogger('shares-crypto'); this.driveCrypto = driveCrypto; @@ -27,16 +46,21 @@ export class SharesCryptoService { } async generateVolumeBootstrap(addressKey: PrivateKey): Promise<{ - shareKey: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareKey }, + shareKey: { encrypted: EncryptedShareCrypto; decrypted: DecryptedShareKey }; rootNode: { - key: { encrypted: EncryptedShareCrypto, decrypted: DecryptedShareKey }, - encryptedName: string, - armoredHashKey: string, - } + key: { encrypted: EncryptedShareCrypto; decrypted: DecryptedShareKey }; + encryptedName: string; + armoredHashKey: string; + }; }> { const shareKey = await this.driveCrypto.generateKey([addressKey], addressKey); const rootNodeKey = await this.driveCrypto.generateKey([shareKey.decrypted.key], addressKey); - const { armoredNodeName } = await this.driveCrypto.encryptNodeName('root', undefined, shareKey.decrypted.key, addressKey); + const { armoredNodeName } = await this.driveCrypto.encryptNodeName( + 'root', + undefined, + shareKey.decrypted.key, + addressKey, + ); const { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKey.decrypted.key); return { shareKey, @@ -45,10 +69,10 @@ export class SharesCryptoService { encryptedName: armoredNodeName, armoredHashKey, }, - } + }; } - async decryptRootShare(share: EncryptedRootShare): Promise<{ share: DecryptedRootShare, key: DecryptedShareKey }> { + async decryptRootShare(share: EncryptedRootShare): Promise<{ share: DecryptedRootShare; key: DecryptedShareKey }> { const { keys: addressKeys } = await this.account.getOwnAddress(share.addressId); const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); @@ -60,7 +84,7 @@ export class SharesCryptoService { share.encryptedCrypto.armoredPassphraseSignature, addressKeys.map(({ key }) => key), addressPublicKeys, - ) + ); key = result.key; passphraseSessionKey = result.passphraseSessionKey; verified = result.verified; @@ -69,12 +93,13 @@ export class SharesCryptoService { throw error; } - const author: Result = verified === VERIFICATION_STATUS.SIGNED_AND_VALID - ? resultOk(share.creatorEmail) - : resultError({ - claimedAuthor: share.creatorEmail, - error: getVerificationMessage(verified), - }); + const author: Result = + verified === VERIFICATION_STATUS.SIGNED_AND_VALID + ? resultOk(share.creatorEmail) + : resultError({ + claimedAuthor: share.creatorEmail, + error: getVerificationMessage(verified), + }); if (!author.ok) { await this.reportVerificationError(share); @@ -89,7 +114,7 @@ export class SharesCryptoService { key, passphraseSessionKey, }, - } + }; } private reportDecryptionError(share: EncryptedRootShare, error?: unknown) { diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts index d78cd151..f76014a4 100644 --- a/js/sdk/src/internal/shares/index.ts +++ b/js/sdk/src/internal/shares/index.ts @@ -1,20 +1,25 @@ -import { ProtonDriveEntitiesCache, ProtonDriveCryptoCache, ProtonDriveAccount, ProtonDriveTelemetry } from "../../interface"; +import { + ProtonDriveEntitiesCache, + ProtonDriveCryptoCache, + ProtonDriveAccount, + ProtonDriveTelemetry, +} from '../../interface'; import { DriveCrypto } from '../../crypto'; -import { DriveAPIService } from "../apiService"; -import { SharesAPIService } from "./apiService"; -import { SharesCryptoCache } from "./cryptoCache"; -import { SharesCache } from "./cache"; -import { SharesCryptoService } from "./cryptoService"; -import { SharesManager } from "./manager"; +import { DriveAPIService } from '../apiService'; +import { SharesAPIService } from './apiService'; +import { SharesCryptoCache } from './cryptoCache'; +import { SharesCache } from './cache'; +import { SharesCryptoService } from './cryptoService'; +import { SharesManager } from './manager'; -export type { EncryptedShare } from "./interface"; +export type { EncryptedShare } from './interface'; /** * Provides facade for the whole shares module. - * + * * The shares module is responsible for handling shares metadata, including * API communication, encryption, decryption, caching, and event handling. - * + * * This facade provides internal interface that other modules can use to * interact with the shares. */ @@ -30,6 +35,13 @@ export function initSharesModule( const cache = new SharesCache(telemetry.getLogger('shares-cache'), driveEntitiesCache); const cryptoCache = new SharesCryptoCache(driveCryptoCache); const cryptoService = new SharesCryptoService(telemetry, crypto, account); - const sharesManager = new SharesManager(telemetry.getLogger('shares'), api, cache, cryptoCache, cryptoService, account); + const sharesManager = new SharesManager( + telemetry.getLogger('shares'), + api, + cache, + cryptoCache, + cryptoService, + account, + ); return sharesManager; } diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index 1a2b8cc5..dde85669 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -1,13 +1,13 @@ -import { PrivateKey, SessionKey } from "../../crypto"; -import { Result, UnverifiedAuthorError } from "../../interface"; +import { PrivateKey, SessionKey } from '../../crypto'; +import { Result, UnverifiedAuthorError } from '../../interface'; /** * Internal interface providing basic identification of volume and its root * share and node. - * + * * No interface should inherit from this, this is only for composition to * create basic volume or share interfaces. - * + * * Volumes do not have necessarily share or node, but we want to always * know what is the root share or node, thus we want to keep this for both * volumes or any type of share. @@ -21,7 +21,7 @@ export interface VolumeShareNodeIDs { export type Volume = { /** * Creator email and address ID come from the default share. - * + * * The idea is to keep this information synced, so whenever we check * cached volume information, we have creator details at hand for any * verification checks or creation needs. @@ -62,7 +62,7 @@ interface BaseRootShare extends BaseShare { /** * Interface used only internaly in the shares module. - * + * * Outside of the module, the decrypted share interface should be used. */ export interface EncryptedShare extends BaseShare { @@ -77,7 +77,7 @@ interface ShareMembership { /** * Interface used only internaly in the shares module. - * + * * Outside of the module, the decrypted share interface should be used. */ export interface EncryptedRootShare extends BaseRootShare { @@ -89,7 +89,7 @@ export interface EncryptedRootShare extends BaseRootShare { * Interface holding decrypted share metadata. */ export interface DecryptedRootShare extends BaseRootShare { - author: Result, + author: Result; } export interface EncryptedShareCrypto { diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts index 64925dd5..af71b0b9 100644 --- a/js/sdk/src/internal/shares/manager.test.ts +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -1,14 +1,14 @@ -import { ProtonDriveAccount } from "../../interface"; -import { getMockLogger } from "../../tests/logger"; -import { NotFoundAPIError } from "../apiService"; -import { SharesAPIService } from "./apiService"; -import { SharesCache } from "./cache"; -import { SharesCryptoCache } from "./cryptoCache"; -import { SharesCryptoService } from "./cryptoService"; -import { VolumeShareNodeIDs } from "./interface"; -import { SharesManager } from "./manager"; - -describe("SharesManager", () => { +import { ProtonDriveAccount } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { NotFoundAPIError } from '../apiService'; +import { SharesAPIService } from './apiService'; +import { SharesCache } from './cache'; +import { SharesCryptoCache } from './cryptoCache'; +import { SharesCryptoService } from './cryptoService'; +import { VolumeShareNodeIDs } from './interface'; +import { SharesManager } from './manager'; + +describe('SharesManager', () => { let apiService: SharesAPIService; let cache: SharesCache; let cryptoCache: SharesCryptoCache; @@ -25,46 +25,46 @@ describe("SharesManager", () => { getShare: jest.fn(), getVolume: jest.fn(), createVolume: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cache = { setVolume: jest.fn(), getVolume: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoCache = { setShareKey: jest.fn(), getShareKey: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoService = { generateVolumeBootstrap: jest.fn(), decryptRootShare: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking account = { getOwnPrimaryAddress: jest.fn(), getOwnAddress: jest.fn(), - } + }; manager = new SharesManager(getMockLogger(), apiService, cache, cryptoCache, cryptoService, account); }); - describe("getMyFilesIDs", () => { + describe('getMyFilesIDs', () => { const myFilesShare = { - shareId: "myFilesShareId", - volumeId: "myFilesVolumeId", - rootNodeId: "myFilesRootNodeId", + shareId: 'myFilesShareId', + volumeId: 'myFilesVolumeId', + rootNodeId: 'myFilesRootNodeId', }; - it("should load My files IDs once", async () => { + it('should load My files IDs once', async () => { const encryptedShare = { share: myFilesShare, - creatorEmail: "email", + creatorEmail: 'email', }; const key = { - key: "privateKey", - sessionKey: "sessionKey", + key: 'privateKey', + sessionKey: 'sessionKey', }; apiService.getMyFiles = jest.fn().mockResolvedValue(encryptedShare); @@ -86,18 +86,20 @@ describe("SharesManager", () => { }); it("should create volume when My files section doesn't exist", async () => { - apiService.getMyFiles = jest.fn().mockRejectedValue(new NotFoundAPIError("no active volume", 0)); - account.getOwnPrimaryAddress = jest.fn().mockResolvedValue({ primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); + apiService.getMyFiles = jest.fn().mockRejectedValue(new NotFoundAPIError('no active volume', 0)); + account.getOwnPrimaryAddress = jest + .fn() + .mockResolvedValue({ primaryKeyIndex: 0, keys: [{ key: 'addressKey' }] }); cryptoService.generateVolumeBootstrap = jest.fn().mockResolvedValue({ shareKey: { - encrypted: "encrypted share key", - decrypted: "decrypted share key", + encrypted: 'encrypted share key', + decrypted: 'decrypted share key', }, rootNode: { key: { - encrypted: "encrypted root key", + encrypted: 'encrypted root key', }, - } + }, }); apiService.createVolume = jest.fn().mockResolvedValue(myFilesShare); @@ -105,102 +107,108 @@ describe("SharesManager", () => { expect(result).toStrictEqual(myFilesShare); expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); - expect(cryptoCache.setShareKey).toHaveBeenCalledWith("myFilesShareId", "decrypted share key"); + expect(cryptoCache.setShareKey).toHaveBeenCalledWith('myFilesShareId', 'decrypted share key'); }); - it("should throw on unknown error", async () => { - apiService.getMyFiles = jest.fn().mockRejectedValue(new Error("Some error")); + it('should throw on unknown error', async () => { + apiService.getMyFiles = jest.fn().mockRejectedValue(new Error('Some error')); - await expect(manager.getMyFilesIDs()).rejects.toThrow("Some error"); + await expect(manager.getMyFilesIDs()).rejects.toThrow('Some error'); expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); expect(apiService.createVolume).not.toHaveBeenCalled(); }); }); - describe("getSharePrivateKey", () => { - it("should return cached private key", async () => { - cryptoCache.getShareKey = jest.fn().mockResolvedValue({ key: "cachedPrivateKey" }); + describe('getSharePrivateKey', () => { + it('should return cached private key', async () => { + cryptoCache.getShareKey = jest.fn().mockResolvedValue({ key: 'cachedPrivateKey' }); - const result = await manager.getSharePrivateKey("shareId"); + const result = await manager.getSharePrivateKey('shareId'); - expect(result).toBe("cachedPrivateKey"); + expect(result).toBe('cachedPrivateKey'); }); - it("should load private key if not in cache", async () => { + it('should load private key if not in cache', async () => { cryptoCache.getShareKey = jest.fn().mockRejectedValue(new Error('not found')); - apiService.getRootShare = jest.fn().mockResolvedValue({ shareId: "shareId" }); - cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ key: { key: "privateKey" } }); + apiService.getRootShare = jest.fn().mockResolvedValue({ shareId: 'shareId' }); + cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ key: { key: 'privateKey' } }); - const result = await manager.getSharePrivateKey("shareId"); + const result = await manager.getSharePrivateKey('shareId'); - expect(result).toBe("privateKey"); - expect(cryptoCache.setShareKey).toHaveBeenCalledWith("shareId", { key: "privateKey" }); + expect(result).toBe('privateKey'); + expect(cryptoCache.setShareKey).toHaveBeenCalledWith('shareId', { key: 'privateKey' }); }); }); - describe("getMyFilesShareMemberEmailKey", () => { - it("should return cached volume email key", async () => { - jest.spyOn(manager, 'getMyFilesIDs').mockResolvedValue({ volumeId: "volumeId" } as VolumeShareNodeIDs); - cache.getVolume = jest.fn().mockResolvedValue({ addressId: "addressId" }); - account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); + describe('getMyFilesShareMemberEmailKey', () => { + it('should return cached volume email key', async () => { + jest.spyOn(manager, 'getMyFilesIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); + cache.getVolume = jest.fn().mockResolvedValue({ addressId: 'addressId' }); + account.getOwnAddress = jest + .fn() + .mockResolvedValue({ email: 'email', primaryKeyIndex: 0, keys: [{ key: 'addressKey' }] }); const result = await manager.getMyFilesShareMemberEmailKey(); expect(result).toEqual({ - addressId: "addressId", - email: "email", - addressKey: "addressKey", + addressId: 'addressId', + email: 'email', + addressKey: 'addressKey', }); }); - it("should load volume email key if not in cache", async () => { - jest.spyOn(manager, 'getMyFilesIDs').mockResolvedValue({ volumeId: "volumeId" } as VolumeShareNodeIDs); + it('should load volume email key if not in cache', async () => { + jest.spyOn(manager, 'getMyFilesIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); const share = { - volumeId: "volumeId", - shareId: "shareId", - rootNodeId: "rootNodeId", - creatorEmail: "email", - addressId: "addressId", - } + volumeId: 'volumeId', + shareId: 'shareId', + rootNodeId: 'rootNodeId', + creatorEmail: 'email', + addressId: 'addressId', + }; cache.getVolume = jest.fn().mockRejectedValue(new Error('not found')); - apiService.getVolume = jest.fn().mockResolvedValue({ shareId: "shareId" }); + apiService.getVolume = jest.fn().mockResolvedValue({ shareId: 'shareId' }); apiService.getRootShare = jest.fn().mockResolvedValue(share); - account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); + account.getOwnAddress = jest + .fn() + .mockResolvedValue({ email: 'email', primaryKeyIndex: 0, keys: [{ key: 'addressKey' }] }); const result = await manager.getMyFilesShareMemberEmailKey(); expect(result).toEqual({ - addressId: "addressId", - email: "email", - addressKey: "addressKey", + addressId: 'addressId', + email: 'email', + addressKey: 'addressKey', }); expect(cache.setVolume).toHaveBeenCalledWith(share); }); }); - describe("getContextShareMemberEmailKey", () => { - it("should load share email key only once", async () => { + describe('getContextShareMemberEmailKey', () => { + it('should load share email key only once', async () => { const share = { - volumeId: "volumeId", - shareId: "shareId", - rootNodeId: "rootNodeId", - creatorEmail: "creatorEmail", - addressId: "addressId", - } + volumeId: 'volumeId', + shareId: 'shareId', + rootNodeId: 'rootNodeId', + creatorEmail: 'creatorEmail', + addressId: 'addressId', + }; apiService.getRootShare = jest.fn().mockResolvedValue(share); - account.getOwnAddress = jest.fn().mockResolvedValue({ email: "email", primaryKeyIndex: 0, keys: [{ key: "addressKey" }] }); + account.getOwnAddress = jest + .fn() + .mockResolvedValue({ email: 'email', primaryKeyIndex: 0, keys: [{ key: 'addressKey' }] }); - const result = await manager.getContextShareMemberEmailKey("shareId"); + const result = await manager.getContextShareMemberEmailKey('shareId'); expect(result).toEqual({ - addressId: "addressId", - email: "email", - addressKey: "addressKey", + addressId: 'addressId', + email: 'email', + addressKey: 'addressKey', }); expect(apiService.getRootShare).toHaveBeenCalledTimes(1); expect(account.getOwnAddress).toHaveBeenCalledTimes(1); - const result2 = await manager.getContextShareMemberEmailKey("shareId"); + const result2 = await manager.getContextShareMemberEmailKey('shareId'); expect(result2).toEqual(result); expect(apiService.getRootShare).toHaveBeenCalledTimes(1); diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 22b2a2c3..a678e3a7 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -1,11 +1,11 @@ -import { Logger, MetricVolumeType, ProtonDriveAccount } from "../../interface"; -import { PrivateKey } from "../../crypto"; -import { NotFoundAPIError } from "../apiService"; -import { SharesAPIService } from "./apiService"; -import { SharesCache } from "./cache"; -import { SharesCryptoCache } from "./cryptoCache"; -import { SharesCryptoService } from "./cryptoService"; -import { VolumeShareNodeIDs, EncryptedShare, EncryptedRootShare } from "./interface"; +import { Logger, MetricVolumeType, ProtonDriveAccount } from '../../interface'; +import { PrivateKey } from '../../crypto'; +import { NotFoundAPIError } from '../apiService'; +import { SharesAPIService } from './apiService'; +import { SharesCache } from './cache'; +import { SharesCryptoCache } from './cryptoCache'; +import { SharesCryptoService } from './cryptoService'; +import { VolumeShareNodeIDs, EncryptedShare, EncryptedRootShare } from './interface'; /** * Provides high-level actions for managing shares. @@ -126,7 +126,7 @@ export class SharesManager { try { const { key } = await this.cryptoCache.getShareKey(shareId); return key; - } catch { } + } catch {} const encryptedShare = await this.apiService.getRootShare(shareId); const { key } = await this.cryptoService.decryptRootShare(encryptedShare); @@ -135,10 +135,10 @@ export class SharesManager { } async getMyFilesShareMemberEmailKey(): Promise<{ - email: string, - addressId: string, - addressKey: PrivateKey, - addressKeyId: string, + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; }> { const { volumeId } = await this.getMyFilesIDs(); @@ -151,7 +151,7 @@ export class SharesManager { addressKey: address.keys[address.primaryKeyIndex].key, addressKeyId: address.keys[address.primaryKeyIndex].id, }; - } catch { } + } catch {} const { shareId } = await this.apiService.getVolume(volumeId); const share = await this.apiService.getRootShare(shareId); @@ -174,10 +174,10 @@ export class SharesManager { } async getContextShareMemberEmailKey(shareId: string): Promise<{ - email: string, - addressId: string, - addressKey: PrivateKey, - addressKeyId: string, + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; }> { let encryptedShare = this.rootShares.get(shareId); if (!encryptedShare) { @@ -195,7 +195,7 @@ export class SharesManager { }; } - async isOwnVolume(volumeId: string): Promise{ + async isOwnVolume(volumeId: string): Promise { return (await this.getMyFilesIDs()).volumeId === volumeId; } diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 566d58b5..d2332c20 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -1,53 +1,123 @@ -import { SRPVerifier } from "../../crypto"; -import { NodeType, MemberRole, NonProtonInvitationState, Logger } from "../../interface"; -import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from "../apiService"; -import { makeNodeUid, splitNodeUid, makeInvitationUid, splitInvitationUid, makeMemberUid, splitMemberUid, makePublicLinkUid, splitPublicLinkUid } from "../uids"; -import { EncryptedInvitationRequest, EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedBookmark, EncryptedExternalInvitationRequest, EncryptedPublicLink, EncryptedPublicLinkCrypto } from "./interface"; - -type GetSharedNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/shares']['get']['responses']['200']['content']['application/json']; - -type GetSharedWithMeNodesResponse = drivePaths['/drive/v2/sharedwithme']['get']['responses']['200']['content']['application/json']; - -type GetInvitationsResponse = drivePaths['/drive/v2/shares/invitations']['get']['responses']['200']['content']['application/json']; - -type GetInvitationDetailsResponse = drivePaths['/drive/v2/shares/invitations/{invitationID}']['get']['responses']['200']['content']['application/json']; - -type PostAcceptInvitationRequest = Extract['content']['application/json']; -type PostAcceptInvitationResponse = drivePaths['/drive/v2/shares/invitations/{invitationID}/accept']['post']['responses']['200']['content']['application/json']; - -type GetSharedBookmarksResponse = drivePaths['/drive/v2/shared-bookmarks']['get']['responses']['200']['content']['application/json']; - -type GetShareInvitations = drivePaths['/drive/v2/shares/{shareID}/invitations']['get']['responses']['200']['content']['application/json']; - -type GetShareExternalInvitations = drivePaths['/drive/v2/shares/{shareID}/external-invitations']['get']['responses']['200']['content']['application/json']; - -type GetShareMembers = drivePaths['/drive/v2/shares/{shareID}/members']['get']['responses']['200']['content']['application/json']; - -type PostCreateShareRequest = Extract['content']['application/json']; -type PostCreateShareResponse = drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; - -type PostInviteProtonUserRequest = Extract['content']['application/json']; -type PostInviteProtonUserResponse = drivePaths['/drive/v2/shares/{shareID}/invitations']['post']['responses']['200']['content']['application/json']; - -type PutUpdateInvitationRequest = Extract['content']['application/json']; -type PutUpdateInvitationResponse = drivePaths['/drive/v2/shares/{shareID}/invitations/{invitationID}']['put']['responses']['200']['content']['application/json']; - -type PostInviteExternalUserRequest = Extract['content']['application/json']; -type PostInviteExternalUserResponse = drivePaths['/drive/v2/shares/{shareID}/external-invitations']['post']['responses']['200']['content']['application/json']; - -type PutUpdateExternalInvitationRequest = Extract['content']['application/json']; -type PutUpdateExternalInvitationResponse = drivePaths['/drive/v2/shares/{shareID}/external-invitations/{invitationID}']['put']['responses']['200']['content']['application/json']; - -type PostUpdateMemberRequest = Extract['content']['application/json']; -type PostUpdateMemberResponse = drivePaths['/drive/v2/shares/{shareID}/members/{memberID}']['put']['responses']['200']['content']['application/json']; - -type GetShareUrlsResponse = drivePaths['/drive/shares/{shareID}/urls']['get']['responses']['200']['content']['application/json']; - -type PostShareUrlRequest = Extract['content']['application/json']; -type PostShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls']['post']['responses']['200']['content']['application/json']; - -type PutShareUrlRequest = Extract['content']['application/json']; -type PutShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls/{urlID}']['put']['responses']['200']['content']['application/json']; +import { SRPVerifier } from '../../crypto'; +import { NodeType, MemberRole, NonProtonInvitationState, Logger } from '../../interface'; +import { + DriveAPIService, + drivePaths, + nodeTypeNumberToNodeType, + permissionsToDirectMemberRole, + memberRoleToPermission, +} from '../apiService'; +import { + makeNodeUid, + splitNodeUid, + makeInvitationUid, + splitInvitationUid, + makeMemberUid, + splitMemberUid, + makePublicLinkUid, + splitPublicLinkUid, +} from '../uids'; +import { + EncryptedInvitationRequest, + EncryptedInvitation, + EncryptedInvitationWithNode, + EncryptedExternalInvitation, + EncryptedMember, + EncryptedBookmark, + EncryptedExternalInvitationRequest, + EncryptedPublicLink, + EncryptedPublicLinkCrypto, +} from './interface'; + +type GetSharedNodesResponse = + drivePaths['/drive/v2/volumes/{volumeID}/shares']['get']['responses']['200']['content']['application/json']; + +type GetSharedWithMeNodesResponse = + drivePaths['/drive/v2/sharedwithme']['get']['responses']['200']['content']['application/json']; + +type GetInvitationsResponse = + drivePaths['/drive/v2/shares/invitations']['get']['responses']['200']['content']['application/json']; + +type GetInvitationDetailsResponse = + drivePaths['/drive/v2/shares/invitations/{invitationID}']['get']['responses']['200']['content']['application/json']; + +type PostAcceptInvitationRequest = Extract< + drivePaths['/drive/v2/shares/invitations/{invitationID}/accept']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostAcceptInvitationResponse = + drivePaths['/drive/v2/shares/invitations/{invitationID}/accept']['post']['responses']['200']['content']['application/json']; + +type GetSharedBookmarksResponse = + drivePaths['/drive/v2/shared-bookmarks']['get']['responses']['200']['content']['application/json']; + +type GetShareInvitations = + drivePaths['/drive/v2/shares/{shareID}/invitations']['get']['responses']['200']['content']['application/json']; + +type GetShareExternalInvitations = + drivePaths['/drive/v2/shares/{shareID}/external-invitations']['get']['responses']['200']['content']['application/json']; + +type GetShareMembers = + drivePaths['/drive/v2/shares/{shareID}/members']['get']['responses']['200']['content']['application/json']; + +type PostCreateShareRequest = Extract< + drivePaths['/drive/volumes/{volumeID}/shares']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateShareResponse = + drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; + +type PostInviteProtonUserRequest = Extract< + drivePaths['/drive/v2/shares/{shareID}/invitations']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostInviteProtonUserResponse = + drivePaths['/drive/v2/shares/{shareID}/invitations']['post']['responses']['200']['content']['application/json']; + +type PutUpdateInvitationRequest = Extract< + drivePaths['/drive/v2/shares/{shareID}/invitations/{invitationID}']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PutUpdateInvitationResponse = + drivePaths['/drive/v2/shares/{shareID}/invitations/{invitationID}']['put']['responses']['200']['content']['application/json']; + +type PostInviteExternalUserRequest = Extract< + drivePaths['/drive/v2/shares/{shareID}/external-invitations']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostInviteExternalUserResponse = + drivePaths['/drive/v2/shares/{shareID}/external-invitations']['post']['responses']['200']['content']['application/json']; + +type PutUpdateExternalInvitationRequest = Extract< + drivePaths['/drive/v2/shares/{shareID}/external-invitations/{invitationID}']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PutUpdateExternalInvitationResponse = + drivePaths['/drive/v2/shares/{shareID}/external-invitations/{invitationID}']['put']['responses']['200']['content']['application/json']; + +type PostUpdateMemberRequest = Extract< + drivePaths['/drive/v2/shares/{shareID}/members/{memberID}']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PostUpdateMemberResponse = + drivePaths['/drive/v2/shares/{shareID}/members/{memberID}']['put']['responses']['200']['content']['application/json']; + +type GetShareUrlsResponse = + drivePaths['/drive/shares/{shareID}/urls']['get']['responses']['200']['content']['application/json']; + +type PostShareUrlRequest = Extract< + drivePaths['/drive/shares/{shareID}/urls']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostShareUrlResponse = + drivePaths['/drive/shares/{shareID}/urls']['post']['responses']['200']['content']['application/json']; + +type PutShareUrlRequest = Extract< + drivePaths['/drive/shares/{shareID}/urls/{urlID}']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PutShareUrlResponse = + drivePaths['/drive/shares/{shareID}/urls/{urlID}']['put']['responses']['200']['content']['application/json']; // We do not support photos and albums yet. const SUPPORTED_SHARE_TARGET_TYPES = [ @@ -64,15 +134,21 @@ const SUPPORTED_SHARE_TARGET_TYPES = [ * and vice versa. It should not contain any business logic. */ export class SharingAPIService { - constructor(private logger: Logger, private apiService: DriveAPIService) { + constructor( + private logger: Logger, + private apiService: DriveAPIService, + ) { this.logger = logger; this.apiService = apiService; } async *iterateSharedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { - let anchor = ""; + let anchor = ''; while (true) { - const response = await this.apiService.get(`drive/v2/volumes/${volumeId}/shares?${anchor ? `AnchorID=${anchor}` : ''}`, signal); + const response = await this.apiService.get( + `drive/v2/volumes/${volumeId}/shares?${anchor ? `AnchorID=${anchor}` : ''}`, + signal, + ); for (const link of response.Links) { yield makeNodeUid(volumeId, link.LinkID); } @@ -85,9 +161,12 @@ export class SharingAPIService { } async *iterateSharedWithMeNodeUids(signal?: AbortSignal): AsyncGenerator { - let anchor = ""; + let anchor = ''; while (true) { - const response = await this.apiService.get(`drive/v2/sharedwithme?${anchor ? `AnchorID=${anchor}` : ''}`, signal); + const response = await this.apiService.get( + `drive/v2/sharedwithme?${anchor ? `AnchorID=${anchor}` : ''}`, + signal, + ); for (const link of response.Links) { const nodeUid = makeNodeUid(link.VolumeID, link.LinkID); @@ -107,14 +186,19 @@ export class SharingAPIService { } async *iterateInvitationUids(signal?: AbortSignal): AsyncGenerator { - let anchor = ""; + let anchor = ''; while (true) { - const response = await this.apiService.get(`drive/v2/shares/invitations?${anchor ? `AnchorID=${anchor}` : ''}`, signal); + const response = await this.apiService.get( + `drive/v2/shares/invitations?${anchor ? `AnchorID=${anchor}` : ''}`, + signal, + ); for (const invitation of response.Invitations) { const invitationUid = makeInvitationUid(invitation.ShareID, invitation.InvitationID); if (!SUPPORTED_SHARE_TARGET_TYPES.includes(invitation.ShareTargetType)) { - this.logger.warn(`Unsupported share target type ${invitation.ShareTargetType} for invitation ${invitationUid}`); + this.logger.warn( + `Unsupported share target type ${invitation.ShareTargetType} for invitation ${invitationUid}`, + ); continue; } @@ -130,7 +214,9 @@ export class SharingAPIService { async getInvitation(invitationUid: string): Promise { const { invitationId } = splitInvitationUid(invitationUid); - const response = await this.apiService.get(`drive/v2/shares/invitations/${invitationId}`); + const response = await this.apiService.get( + `drive/v2/shares/invitations/${invitationId}`, + ); return { uid: invitationUid, addedByEmail: response.Invitation.InviterEmail, @@ -149,17 +235,17 @@ export class SharingAPIService { mediaType: response.Link.MIMEType || undefined, encryptedName: response.Link.Name, }, - } + }; } async acceptInvitation(invitationUid: string, base64SessionKeySignature: string): Promise { const { invitationId } = splitInvitationUid(invitationUid); - await this.apiService.post< - PostAcceptInvitationRequest, - PostAcceptInvitationResponse - >(`drive/v2/shares/invitations/${invitationId}/accept`, { - SessionKeySignature: base64SessionKeySignature, - }); + await this.apiService.post( + `drive/v2/shares/invitations/${invitationId}/accept`, + { + SessionKeySignature: base64SessionKeySignature, + }, + ); } async rejectInvitation(invitationUid: string): Promise { @@ -191,7 +277,7 @@ export class SharingAPIService { base64ContentKeyPacket: bookmark.Token.ContentKeyPacket || undefined, }, }, - } + }; } } @@ -207,7 +293,9 @@ export class SharingAPIService { } async getShareExternalInvitations(shareId: string): Promise { - const response = await this.apiService.get(`drive/v2/shares/${shareId}/external-invitations`); + const response = await this.apiService.get( + `drive/v2/shares/${shareId}/external-invitations`, + ); return response.ExternalInvitations.map((invitation) => { return this.convertExternalInvitaiton(shareId, invitation); }); @@ -224,7 +312,7 @@ export class SharingAPIService { base64KeyPacketSignature: member.KeyPacketSignature, invitationTime: new Date(member.CreateTime * 1000), role: permissionsToDirectMemberRole(this.logger, member.Permissions), - } + }; }); } @@ -232,30 +320,29 @@ export class SharingAPIService { nodeUid: string, addressId: string, shareKey: { - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; }, node: { - base64PassphraseKeyPacket: string, - base64NameKeyPacket: string, + base64PassphraseKeyPacket: string; + base64NameKeyPacket: string; }, ): Promise { const { volumeId, nodeId } = splitNodeUid(nodeUid); - const response = await this.apiService.post< - PostCreateShareRequest, - PostCreateShareResponse - >(`drive/volumes/${volumeId}/shares`, { - RootLinkID: nodeId, - AddressID: addressId, - Name: 'New Share', - ShareKey: shareKey.armoredKey, - SharePassphrase: shareKey.armoredPassphrase, - SharePassphraseSignature: shareKey.armoredPassphraseSignature, - PassphraseKeyPacket: node.base64PassphraseKeyPacket, - NameKeyPacket: node.base64NameKeyPacket, - - }); + const response = await this.apiService.post( + `drive/volumes/${volumeId}/shares`, + { + RootLinkID: nodeId, + AddressID: addressId, + Name: 'New Share', + ShareKey: shareKey.armoredKey, + SharePassphrase: shareKey.armoredPassphrase, + SharePassphraseSignature: shareKey.armoredPassphraseSignature, + PassphraseKeyPacket: node.base64PassphraseKeyPacket, + NameKeyPacket: node.base64NameKeyPacket, + }, + ); return response.Share.ID; } @@ -266,39 +353,36 @@ export class SharingAPIService { async inviteProtonUser( shareId: string, invitation: EncryptedInvitationRequest, - emailDetails: { message?: string, nodeName?: string } = {}, + emailDetails: { message?: string; nodeName?: string } = {}, ): Promise { - const response = await this.apiService.post< - PostInviteProtonUserRequest, - PostInviteProtonUserResponse - >(`drive/v2/shares/${shareId}/invitations`, { - Invitation: { - InviterEmail: invitation.addedByEmail, - InviteeEmail: invitation.inviteeEmail, - Permissions: memberRoleToPermission(invitation.role), - KeyPacket: invitation.base64KeyPacket, - KeyPacketSignature: invitation.base64KeyPacketSignature, - ExternalInvitationID: null, - }, - EmailDetails: { - Message: emailDetails.message, - ItemName: emailDetails.nodeName, + const response = await this.apiService.post( + `drive/v2/shares/${shareId}/invitations`, + { + Invitation: { + InviterEmail: invitation.addedByEmail, + InviteeEmail: invitation.inviteeEmail, + Permissions: memberRoleToPermission(invitation.role), + KeyPacket: invitation.base64KeyPacket, + KeyPacketSignature: invitation.base64KeyPacketSignature, + ExternalInvitationID: null, + }, + EmailDetails: { + Message: emailDetails.message, + ItemName: emailDetails.nodeName, + }, }, - }); + ); return this.convertInternalInvitation(shareId, response.Invitation); } - async updateInvitation( - invitationUid: string, - invitation: { role: MemberRole }, - ): Promise { + async updateInvitation(invitationUid: string, invitation: { role: MemberRole }): Promise { const { shareId, invitationId } = splitInvitationUid(invitationUid); - await this.apiService.put< - PutUpdateInvitationRequest, - PutUpdateInvitationResponse - >(`drive/v2/shares/${shareId}/invitations/${invitationId}`, { - Permissions: memberRoleToPermission(invitation.role), - }); + await this.apiService.put( + `drive/v2/shares/${shareId}/invitations/${invitationId}`, + { + Permissions: memberRoleToPermission(invitation.role), + }, + ); } async resendInvitationEmail(invitationUid: string): Promise { @@ -314,37 +398,34 @@ export class SharingAPIService { async inviteExternalUser( shareId: string, invitation: EncryptedExternalInvitationRequest, - emailDetails: { message?: string, nodeName?: string } = {}, + emailDetails: { message?: string; nodeName?: string } = {}, ): Promise { - const response = await this.apiService.post< - PostInviteExternalUserRequest, - PostInviteExternalUserResponse - >(`drive/v2/shares/${shareId}/external-invitations`, { - ExternalInvitation: { - InviterAddressID: invitation.inviterAddressId, - InviteeEmail: invitation.inviteeEmail, - Permissions: memberRoleToPermission(invitation.role), - ExternalInvitationSignature: invitation.base64Signature, - }, - EmailDetails: { - Message: emailDetails.message, - ItemName: emailDetails.nodeName, + const response = await this.apiService.post( + `drive/v2/shares/${shareId}/external-invitations`, + { + ExternalInvitation: { + InviterAddressID: invitation.inviterAddressId, + InviteeEmail: invitation.inviteeEmail, + Permissions: memberRoleToPermission(invitation.role), + ExternalInvitationSignature: invitation.base64Signature, + }, + EmailDetails: { + Message: emailDetails.message, + ItemName: emailDetails.nodeName, + }, }, - }); + ); return this.convertExternalInvitaiton(shareId, response.ExternalInvitation); } - async updateExternalInvitation( - invitationUid: string, - invitation: { role: MemberRole }, - ): Promise { + async updateExternalInvitation(invitationUid: string, invitation: { role: MemberRole }): Promise { const { shareId, invitationId } = splitInvitationUid(invitationUid); - await this.apiService.put< - PutUpdateExternalInvitationRequest, - PutUpdateExternalInvitationResponse - >(`drive/v2/shares/${shareId}/external-invitations/${invitationId}`, { - Permissions: memberRoleToPermission(invitation.role), - }); + await this.apiService.put( + `drive/v2/shares/${shareId}/external-invitations/${invitationId}`, + { + Permissions: memberRoleToPermission(invitation.role), + }, + ); } async resendExternalInvitationEmail(invitationUid: string): Promise { @@ -359,12 +440,12 @@ export class SharingAPIService { async updateMember(memberUid: string, member: { role: MemberRole }): Promise { const { shareId, memberId } = splitMemberUid(memberUid); - await this.apiService.put< - PostUpdateMemberRequest, - PostUpdateMemberResponse - >(`drive/v2/shares/${shareId}/members/${memberId}`, { - Permissions: memberRoleToPermission(member.role), - }); + await this.apiService.put( + `drive/v2/shares/${shareId}/members/${memberId}`, + { + Permissions: memberRoleToPermission(member.role), + }, + ); } async removeMember(memberUid: string): Promise { @@ -399,16 +480,19 @@ export class SharingAPIService { }; } - async createPublicLink(shareId: string, publicLink: { - creatorEmail: string, - role: MemberRole, - includesCustomPassword: boolean, - expirationTime?: number, - crypto: EncryptedPublicLinkCrypto, - srp: SRPVerifier, - }): Promise<{ - uid: string, - publicUrl: string, + async createPublicLink( + shareId: string, + publicLink: { + creatorEmail: string; + role: MemberRole; + includesCustomPassword: boolean; + expirationTime?: number; + crypto: EncryptedPublicLinkCrypto; + srp: SRPVerifier; + }, + ): Promise<{ + uid: string; + publicUrl: string; }> { if (publicLink.role === MemberRole.Admin) { throw new Error('Cannot set admin role for public link.'); @@ -428,13 +512,16 @@ export class SharingAPIService { }; } - async updatePublicLink(publicLinkUid: string, publicLink: { - role: MemberRole, - includesCustomPassword: boolean, - expirationTime?: number, - crypto: EncryptedPublicLinkCrypto, - srp: SRPVerifier, - }): Promise { + async updatePublicLink( + publicLinkUid: string, + publicLink: { + role: MemberRole; + includesCustomPassword: boolean; + expirationTime?: number; + crypto: EncryptedPublicLinkCrypto; + srp: SRPVerifier; + }, + ): Promise { if (publicLink.role === MemberRole.Admin) { throw new Error('Cannot set admin role for public link.'); } @@ -449,12 +536,24 @@ export class SharingAPIService { } private generatePublicLinkRequestPayload(publicLink: { - role: MemberRole, - includesCustomPassword: boolean, - expirationTime?: number, - crypto: EncryptedPublicLinkCrypto, - srp: SRPVerifier, - }): Pick { + role: MemberRole; + includesCustomPassword: boolean; + expirationTime?: number; + crypto: EncryptedPublicLinkCrypto; + srp: SRPVerifier; + }): Pick< + PostShareUrlRequest, + | 'Permissions' + | 'Flags' + | 'ExpirationTime' + | 'SharePasswordSalt' + | 'SharePassphraseKeyPacket' + | 'Password' + | 'UrlPasswordSalt' + | 'SRPVerifier' + | 'SRPModulusID' + | 'MaxAccesses' + > { return { Permissions: memberRoleToPermission(publicLink.role) as 4 | 6, Flags: publicLink.includesCustomPassword @@ -471,7 +570,7 @@ export class SharingAPIService { SRPModulusID: publicLink.srp.modulusId, MaxAccesses: 0, // We don't support setting limit. - } + }; } async removePublicLink(publicLinkUid: string): Promise { @@ -479,7 +578,10 @@ export class SharingAPIService { await this.apiService.delete(`drive/shares/${shareId}/urls/${publicLinkId}`); } - private convertInternalInvitation(shareId: string, invitation: GetShareInvitations['Invitations'][0]): EncryptedInvitation { + private convertInternalInvitation( + shareId: string, + invitation: GetShareInvitations['Invitations'][0], + ): EncryptedInvitation { return { uid: makeInvitationUid(shareId, invitation.InvitationID), addedByEmail: invitation.InviterEmail, @@ -488,11 +590,15 @@ export class SharingAPIService { role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), base64KeyPacket: invitation.KeyPacket, base64KeyPacketSignature: invitation.KeyPacketSignature, - } + }; } - private convertExternalInvitaiton(shareId: string, invitation: GetShareExternalInvitations['ExternalInvitations'][0]): EncryptedExternalInvitation { - const state = invitation.State === 1 ? NonProtonInvitationState.Pending : NonProtonInvitationState.UserRegistered; + private convertExternalInvitaiton( + shareId: string, + invitation: GetShareExternalInvitations['ExternalInvitations'][0], + ): EncryptedExternalInvitation { + const state = + invitation.State === 1 ? NonProtonInvitationState.Pending : NonProtonInvitationState.UserRegistered; return { uid: makeInvitationUid(shareId, invitation.ExternalInvitationID), addedByEmail: invitation.InviterEmail, @@ -501,6 +607,6 @@ export class SharingAPIService { role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), base64Signature: invitation.ExternalInvitationSignature, state, - } + }; } } diff --git a/js/sdk/src/internal/sharing/cache.test.ts b/js/sdk/src/internal/sharing/cache.test.ts index ebd51e69..12d63e86 100644 --- a/js/sdk/src/internal/sharing/cache.test.ts +++ b/js/sdk/src/internal/sharing/cache.test.ts @@ -1,7 +1,7 @@ -import { MemoryCache } from "../../cache"; -import { SharingCache } from "./cache"; +import { MemoryCache } from '../../cache'; +import { SharingCache } from './cache'; -describe("SharingCache", () => { +describe('SharingCache', () => { let memoryCache: MemoryCache; let cache: SharingCache; @@ -10,76 +10,76 @@ describe("SharingCache", () => { cache = new SharingCache(memoryCache); }); - describe("set and get shared by me nodes", () => { - it("should set node uids", async () => { - await cache.setSharedByMeNodeUids(["nodeUid"]); + describe('set and get shared by me nodes', () => { + it('should set node uids', async () => { + await cache.setSharedByMeNodeUids(['nodeUid']); const result = await cache.getSharedByMeNodeUids(); - expect(result).toEqual(["nodeUid"]); + expect(result).toEqual(['nodeUid']); }); }); - describe("addSharedByMeNodeUid", () => { - it("should throw if adding before setting", async () => { + describe('addSharedByMeNodeUid', () => { + it('should throw if adding before setting', async () => { try { - await cache.addSharedByMeNodeUid("nodeUid"); - fail("Should have thrown an error"); + await cache.addSharedByMeNodeUid('nodeUid'); + fail('Should have thrown an error'); } catch (error) { - expect(`${error}`).toBe("Error: Calling add before setting the loaded items"); + expect(`${error}`).toBe('Error: Calling add before setting the loaded items'); } }); - it("should add node uid", async () => { - await cache.setSharedByMeNodeUids(["nodeUid"]); + it('should add node uid', async () => { + await cache.setSharedByMeNodeUids(['nodeUid']); const spy = jest.spyOn(memoryCache, 'setEntity'); - await cache.addSharedByMeNodeUid("newNodeUid"); + await cache.addSharedByMeNodeUid('newNodeUid'); const result = await cache.getSharedByMeNodeUids(); - expect(result).toEqual(["nodeUid", "newNodeUid"]); + expect(result).toEqual(['nodeUid', 'newNodeUid']); expect(spy).toHaveBeenCalled(); }); - it("should not add duplicate node uid", async () => { - await cache.setSharedByMeNodeUids(["nodeUid"]); + it('should not add duplicate node uid', async () => { + await cache.setSharedByMeNodeUids(['nodeUid']); const spy = jest.spyOn(memoryCache, 'setEntity'); - await cache.addSharedByMeNodeUid("nodeUid"); - await cache.addSharedByMeNodeUid("nodeUid"); + await cache.addSharedByMeNodeUid('nodeUid'); + await cache.addSharedByMeNodeUid('nodeUid'); const result = await cache.getSharedByMeNodeUids(); - expect(result).toEqual(["nodeUid"]); + expect(result).toEqual(['nodeUid']); expect(spy).not.toHaveBeenCalled(); }); }); - describe("removeSharedByMeNodeUid", () => { - it("should throw if removing before setting", async () => { + describe('removeSharedByMeNodeUid', () => { + it('should throw if removing before setting', async () => { try { - await cache.removeSharedByMeNodeUid("nodeUid"); - fail("Should have thrown an error"); + await cache.removeSharedByMeNodeUid('nodeUid'); + fail('Should have thrown an error'); } catch (error) { - expect(`${error}`).toBe("Error: Calling remove before setting the loaded items"); + expect(`${error}`).toBe('Error: Calling remove before setting the loaded items'); } }); - it("should remove node uid", async () => { - await cache.setSharedByMeNodeUids(["nodeUid"]); + it('should remove node uid', async () => { + await cache.setSharedByMeNodeUids(['nodeUid']); const spy = jest.spyOn(memoryCache, 'setEntity'); - await cache.removeSharedByMeNodeUid("nodeUid"); + await cache.removeSharedByMeNodeUid('nodeUid'); const result = await cache.getSharedByMeNodeUids(); expect(result).toEqual([]); expect(spy).toHaveBeenCalled(); }); - it("should handle removing of missing node uid", async () => { + it('should handle removing of missing node uid', async () => { await cache.setSharedByMeNodeUids([]); const spy = jest.spyOn(memoryCache, 'setEntity'); - await cache.removeSharedByMeNodeUid("nodeUid"); + await cache.removeSharedByMeNodeUid('nodeUid'); const result = await cache.getSharedByMeNodeUids(); expect(result).toEqual([]); @@ -87,13 +87,13 @@ describe("SharingCache", () => { }); }); - describe("set and get shared with me nodes", () => { - it("should set node uids", async () => { - await cache.setSharedWithMeNodeUids(["nodeUid"]); + describe('set and get shared with me nodes', () => { + it('should set node uids', async () => { + await cache.setSharedWithMeNodeUids(['nodeUid']); const result = await cache.getSharedWithMeNodeUids(); - expect(result).toEqual(["nodeUid"]); + expect(result).toEqual(['nodeUid']); }); }); }); diff --git a/js/sdk/src/internal/sharing/cache.ts b/js/sdk/src/internal/sharing/cache.ts index 23c8d277..3a861ab8 100644 --- a/js/sdk/src/internal/sharing/cache.ts +++ b/js/sdk/src/internal/sharing/cache.ts @@ -1,5 +1,5 @@ -import { ProtonDriveEntitiesCache } from "../../interface"; -import { SharingType } from "./interface"; +import { ProtonDriveEntitiesCache } from '../../interface'; +import { SharingType } from './interface'; /** * Provides caching for shared by me and with me listings. diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts index 9276725c..0817efd7 100644 --- a/js/sdk/src/internal/sharing/cryptoService.test.ts +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -1,10 +1,17 @@ -import { DriveCrypto, PrivateKey } from "../../crypto"; -import { MetricVolumeType, NodeType, ProtonDriveAccount, ProtonDriveTelemetry, resultError, resultOk } from "../../interface"; -import { getMockTelemetry } from "../../tests/telemetry"; -import { SharesService } from "./interface"; -import { SharingCryptoService } from "./cryptoService"; - -describe("SharingCryptoService", () => { +import { DriveCrypto, PrivateKey } from '../../crypto'; +import { + MetricVolumeType, + NodeType, + ProtonDriveAccount, + ProtonDriveTelemetry, + resultError, + resultOk, +} from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { SharesService } from './interface'; +import { SharingCryptoService } from './cryptoService'; + +describe('SharingCryptoService', () => { let telemetry: ProtonDriveTelemetry; let driveCrypto: DriveCrypto; let account: ProtonDriveAccount; @@ -15,75 +22,80 @@ describe("SharingCryptoService", () => { telemetry = getMockTelemetry(); // @ts-expect-error No need to implement all methods for mocking driveCrypto = { - decryptShareUrlPassword: jest.fn().mockResolvedValue("urlPassword"), + decryptShareUrlPassword: jest.fn().mockResolvedValue('urlPassword'), decryptKeyWithSrpPassword: jest.fn().mockResolvedValue({ - key: "decryptedKey" as unknown as PrivateKey, + key: 'decryptedKey' as unknown as PrivateKey, }), decryptNodeName: jest.fn().mockResolvedValue({ - name: "nodeName", + name: 'nodeName', }), }; account = { // @ts-expect-error No need to implement full response for mocking getOwnAddress: jest.fn(async () => ({ - keys: [{ key: "addressKey" as unknown as PrivateKey }], + keys: [{ key: 'addressKey' as unknown as PrivateKey }], })), }; // @ts-expect-error No need to implement all methods for mocking sharesService = { getMyFilesShareMemberEmailKey: jest.fn().mockResolvedValue({ - addressId: "addressId", + addressId: 'addressId', }), }; cryptoService = new SharingCryptoService(telemetry, driveCrypto, account, sharesService); }); - describe("decryptBookmark", () => { + describe('decryptBookmark', () => { const encryptedBookmark = { - tokenId: "tokenId", + tokenId: 'tokenId', creationTime: new Date(), url: { - encryptedUrlPassword: "encryptedUrlPassword", - base64SharePasswordSalt: "base64SharePasswordSalt", + encryptedUrlPassword: 'encryptedUrlPassword', + base64SharePasswordSalt: 'base64SharePasswordSalt', }, share: { - armoredKey: "armoredKey", - armoredPassphrase: "armoredPassphrase", + armoredKey: 'armoredKey', + armoredPassphrase: 'armoredPassphrase', }, node: { type: NodeType.File, - mediaType: "mediaType", - encryptedName: "encryptedName", - armoredKey: "armoredKey", - armoredNodePassphrase: "armoredNodePassphrase", + mediaType: 'mediaType', + encryptedName: 'encryptedName', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', file: { - base64ContentKeyPacket: "base64ContentKeyPacket", + base64ContentKeyPacket: 'base64ContentKeyPacket', }, }, - } + }; - it("should decrypt bookmark", async () => { + it('should decrypt bookmark', async () => { const result = await cryptoService.decryptBookmark(encryptedBookmark); expect(result).toMatchObject({ - url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"), - nodeName: resultOk("nodeName"), + url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), + nodeName: resultOk('nodeName'), }); - expect(driveCrypto.decryptShareUrlPassword).toHaveBeenCalledWith("encryptedUrlPassword", ["addressKey"]); - expect(driveCrypto.decryptKeyWithSrpPassword).toHaveBeenCalledWith("urlPassword", "base64SharePasswordSalt", "armoredKey", "armoredPassphrase"); - expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith("encryptedName", "decryptedKey", []); + expect(driveCrypto.decryptShareUrlPassword).toHaveBeenCalledWith('encryptedUrlPassword', ['addressKey']); + expect(driveCrypto.decryptKeyWithSrpPassword).toHaveBeenCalledWith( + 'urlPassword', + 'base64SharePasswordSalt', + 'armoredKey', + 'armoredPassphrase', + ); + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith('encryptedName', 'decryptedKey', []); expect(telemetry.logEvent).not.toHaveBeenCalled(); }); - it("should handle undecryptable URL password", async () => { - const error = new Error("Failed to decrypt URL password"); + it('should handle undecryptable URL password', async () => { + const error = new Error('Failed to decrypt URL password'); driveCrypto.decryptShareUrlPassword = jest.fn().mockRejectedValue(error); const result = await cryptoService.decryptBookmark(encryptedBookmark); expect(result).toMatchObject({ - url: resultError(new Error("Failed to decrypt bookmark password: Failed to decrypt URL password")), - nodeName: resultError(new Error("Failed to decrypt bookmark password: Failed to decrypt URL password")), + url: resultError(new Error('Failed to decrypt bookmark password: Failed to decrypt URL password')), + nodeName: resultError(new Error('Failed to decrypt bookmark password: Failed to decrypt URL password')), }); expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: 'decryptionError', @@ -93,15 +105,15 @@ describe("SharingCryptoService", () => { }); }); - it("should handle undecryptable share key", async () => { - const error = new Error("Failed to decrypt share key"); + it('should handle undecryptable share key', async () => { + const error = new Error('Failed to decrypt share key'); driveCrypto.decryptKeyWithSrpPassword = jest.fn().mockRejectedValue(error); const result = await cryptoService.decryptBookmark(encryptedBookmark); expect(result).toMatchObject({ - url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"), - nodeName: resultError(new Error("Failed to decrypt bookmark key: Failed to decrypt share key")), + url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), + nodeName: resultError(new Error('Failed to decrypt bookmark key: Failed to decrypt share key')), }); expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: 'decryptionError', @@ -111,15 +123,15 @@ describe("SharingCryptoService", () => { }); }); - it("should handle undecryptable node name", async () => { - const error = new Error("Failed to decrypt node name"); + it('should handle undecryptable node name', async () => { + const error = new Error('Failed to decrypt node name'); driveCrypto.decryptNodeName = jest.fn().mockRejectedValue(error); const result = await cryptoService.decryptBookmark(encryptedBookmark); expect(result).toMatchObject({ - url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"), - nodeName: resultError(new Error("Failed to decrypt bookmark name: Failed to decrypt node name")), + url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), + nodeName: resultError(new Error('Failed to decrypt bookmark name: Failed to decrypt node name')), }); expect(telemetry.logEvent).toHaveBeenCalledWith({ eventName: 'decryptionError', @@ -129,17 +141,17 @@ describe("SharingCryptoService", () => { }); }); - it("should handle invalid node name", async () => { + it('should handle invalid node name', async () => { driveCrypto.decryptNodeName = jest.fn().mockResolvedValue({ - name: "invalid/name", + name: 'invalid/name', }); const result = await cryptoService.decryptBookmark(encryptedBookmark); expect(result).toMatchObject({ - url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"), + url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), nodeName: resultError({ - name: "invalid/name", + name: 'invalid/name', error: "Name must not contain the character '/'", }), }); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 9fa42211..3141b1f8 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -1,12 +1,42 @@ import bcrypt from 'bcryptjs'; import { c } from 'ttag'; -import { DriveCrypto, PrivateKey, SessionKey, SRPVerifier, uint8ArrayToBase64String, VERIFICATION_STATUS } from '../../crypto'; -import { ProtonDriveAccount, ProtonInvitation, ProtonInvitationWithNode, NonProtonInvitation, Author, Result, Member, UnverifiedAuthorError, resultError, resultOk, InvalidNameError, ProtonDriveTelemetry, MetricVolumeType } from "../../interface"; +import { + DriveCrypto, + PrivateKey, + SessionKey, + SRPVerifier, + uint8ArrayToBase64String, + VERIFICATION_STATUS, +} from '../../crypto'; +import { + ProtonDriveAccount, + ProtonInvitation, + ProtonInvitationWithNode, + NonProtonInvitation, + Author, + Result, + Member, + UnverifiedAuthorError, + resultError, + resultOk, + InvalidNameError, + ProtonDriveTelemetry, + MetricVolumeType, +} from '../../interface'; import { validateNodeName } from '../nodes/validations'; -import { getErrorMessage, getVerificationMessage } from "../errors"; -import { EncryptedShare } from "../shares"; -import { EncryptedInvitation, EncryptedInvitationWithNode, EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink, PublicLinkWithCreatorEmail, EncryptedBookmark, SharesService } from "./interface"; +import { getErrorMessage, getVerificationMessage } from '../errors'; +import { EncryptedShare } from '../shares'; +import { + EncryptedInvitation, + EncryptedInvitationWithNode, + EncryptedExternalInvitation, + EncryptedMember, + EncryptedPublicLink, + PublicLinkWithCreatorEmail, + EncryptedBookmark, + SharesService, +} from './interface'; // Version 2 of bcrypt with 2**10 rounds. // https://en.wikipedia.org/wiki/Bcrypt#Description @@ -26,7 +56,7 @@ enum PublicLinkFlags { /** * Provides crypto operations for sharing. - * + * * The sharing crypto service is responsible for encrypting and decrypting * shares, invitations, etc. */ @@ -45,31 +75,31 @@ export class SharingCryptoService { /** * Generates a share key for a standard share used for sharing with other users. - * + * * Standard share, in contrast to a root share, is encrypted with node key and * can be managed by any admin. */ async generateShareKeys( nodeKeys: { - key: PrivateKey - passphraseSessionKey: SessionKey, - nameSessionKey: SessionKey, + key: PrivateKey; + passphraseSessionKey: SessionKey; + nameSessionKey: SessionKey; }, addressKey: PrivateKey, ): Promise<{ shareKey: { encrypted: { - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, - }, + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; decrypted: { - key: PrivateKey, - passphraseSessionKey: SessionKey, - }, - }, - base64PpassphraseKeyPacket: string, - base64NameKeyPacket: string, + key: PrivateKey; + passphraseSessionKey: SessionKey; + }; + }; + base64PpassphraseKeyPacket: string; + base64NameKeyPacket: string; }> { const shareKey = await this.driveCrypto.generateKey([nodeKeys.key, addressKey], addressKey); @@ -87,21 +117,24 @@ export class SharingCryptoService { base64PpassphraseKeyPacket, base64NameKeyPacket, }; - }; + } /** * Decrypts a share using the node key. - * + * * The share is encrypted with the node key and can be managed by any admin. * * Old shares are encrypted with address key only and thus available only * to owners. `decryptShare` automatically tries to decrypt the share with * address keys as fallback if available. */ - async decryptShare(share: EncryptedShare, nodeKey: PrivateKey): Promise<{ - author: Author, - key: PrivateKey, - passphraseSessionKey: SessionKey, + async decryptShare( + share: EncryptedShare, + nodeKey: PrivateKey, + ): Promise<{ + author: Author; + key: PrivateKey; + passphraseSessionKey: SessionKey; }> { // All standard shares should be encrypted with node key. // Using node key is essential so any admin can manage the share. @@ -121,25 +154,26 @@ export class SharingCryptoService { share.encryptedCrypto.armoredPassphraseSignature, decryptionKeys, addressPublicKeys, - ) + ); - const author: Result = verified === VERIFICATION_STATUS.SIGNED_AND_VALID - ? resultOk(share.creatorEmail) - : resultError({ - claimedAuthor: share.creatorEmail, - error: getVerificationMessage(verified), - }); + const author: Result = + verified === VERIFICATION_STATUS.SIGNED_AND_VALID + ? resultOk(share.creatorEmail) + : resultError({ + claimedAuthor: share.creatorEmail, + error: getVerificationMessage(verified), + }); return { author, key, passphraseSessionKey, - } + }; } /** * Encrypts an invitation for sharing a node with another user. - * + * * `inviteeEmail` is used to load public key of the invitee and used to * encrypt share's session key. `inviterKey` is used to sign the invitation. */ @@ -148,18 +182,20 @@ export class SharingCryptoService { inviterKey: PrivateKey, inviteeEmail: string, ): Promise<{ - base64KeyPacket: string, - base64KeyPacketSignature: string, + base64KeyPacket: string; + base64KeyPacketSignature: string; }> { const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail); - const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKeys[0], inviterKey) + const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKeys[0], inviterKey); return result; - }; + } /** * Decrypts and verifies an invitation and node's name. */ - async decryptInvitationWithNode(encryptedInvitation: EncryptedInvitationWithNode): Promise { + async decryptInvitationWithNode( + encryptedInvitation: EncryptedInvitationWithNode, + ): Promise { const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key; @@ -171,11 +207,7 @@ export class SharingCryptoService { let nodeName: Result; try { - const result = await this.driveCrypto.decryptNodeName( - encryptedInvitation.node.encryptedName, - shareKey, - [], - ); + const result = await this.driveCrypto.decryptNodeName(encryptedInvitation.node.encryptedName, shareKey, []); nodeName = resultOk(result.name); } catch (error: unknown) { const message = getErrorMessage(error); @@ -184,13 +216,13 @@ export class SharingCryptoService { } return { - ...await this.decryptInvitation(encryptedInvitation), + ...(await this.decryptInvitation(encryptedInvitation)), node: { name: nodeName, type: encryptedInvitation.node.type, mediaType: encryptedInvitation.node.mediaType, }, - } + }; } /** @@ -213,22 +245,19 @@ export class SharingCryptoService { * Accepts an invitation by signing the session key by invitee. */ async acceptInvitation(encryptedInvitation: EncryptedInvitationWithNode): Promise<{ - base64SessionKeySignature: string, + base64SessionKeySignature: string; }> { const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key; - const result = await this.driveCrypto.acceptInvitation( - encryptedInvitation.base64KeyPacket, - inviteeKey, - ); + const result = await this.driveCrypto.acceptInvitation(encryptedInvitation.base64KeyPacket, inviteeKey); return result; } /** * Encrypts an external invitation for sharing a node with another user. - * + * * `inviteeEmail` is used to sign the invitation with `inviterKey`. - * + * * External invitations are used to share nodes with users who are not * registered with Proton Drive. The external invitation then requires * the invitee to sign up to create key. Then it can be followed by @@ -239,7 +268,7 @@ export class SharingCryptoService { inviterKey: PrivateKey, inviteeEmail: string, ): Promise<{ - base64ExternalInvitationSignature: string, + base64ExternalInvitationSignature: string; }> { const result = await this.driveCrypto.encryptExternalInvitation(shareSessionKey, inviterKey, inviteeEmail); return result; @@ -278,19 +307,30 @@ export class SharingCryptoService { }; } - async encryptPublicLink(creatorEmail: string, shareSessionKey: SessionKey, password: string): Promise<{ + async encryptPublicLink( + creatorEmail: string, + shareSessionKey: SessionKey, + password: string, + ): Promise<{ crypto: { - base64SharePasswordSalt: string, - base64SharePassphraseKeyPacket: string, - armoredPassword: string, - }, - srp: SRPVerifier, + base64SharePasswordSalt: string; + base64SharePassphraseKeyPacket: string; + armoredPassword: string; + }; + srp: SRPVerifier; }> { const address = await this.account.getOwnAddress(creatorEmail); const addressKey = address.keys[address.primaryKeyIndex].key; - const { base64Salt: base64SharePasswordSalt, bcryptPassphrase } = await this.computeKeySaltAndPassphrase(password); - const { base64SharePassphraseKeyPacket, armoredPassword, srp } = await this.driveCrypto.encryptPublicLinkPasswordAndSessionKey(password, addressKey, bcryptPassphrase, shareSessionKey); + const { base64Salt: base64SharePasswordSalt, bcryptPassphrase } = + await this.computeKeySaltAndPassphrase(password); + const { base64SharePassphraseKeyPacket, armoredPassword, srp } = + await this.driveCrypto.encryptPublicLinkPasswordAndSessionKey( + password, + addressKey, + bcryptPassphrase, + shareSessionKey, + ); return { crypto: { @@ -299,7 +339,7 @@ export class SharingCryptoService { armoredPassword, }, srp, - } + }; } async generatePublicLinkPassword(): Promise { @@ -327,17 +367,14 @@ export class SharingCryptoService { return { base64Salt: uint8ArrayToBase64String(salt), bcryptPassphrase, - } - }; + }; + } async decryptPublicLink(encryptedPublicLink: EncryptedPublicLink): Promise { const address = await this.account.getOwnAddress(encryptedPublicLink.creatorEmail); const addressKeys = address.keys.map(({ key }) => key); - const { password, customPassword } = await this.decryptShareUrlPassword( - encryptedPublicLink, - addressKeys, - ); + const { password, customPassword } = await this.decryptShareUrlPassword(encryptedPublicLink, addressKeys); return { uid: encryptedPublicLink.uid, @@ -347,16 +384,16 @@ export class SharingCryptoService { url: `${encryptedPublicLink.publicUrl}#${password}`, customPassword, creatorEmail: encryptedPublicLink.creatorEmail, - numberOfInitializedDownloads: encryptedPublicLink.numberOfInitializedDownloads - } + numberOfInitializedDownloads: encryptedPublicLink.numberOfInitializedDownloads, + }; } private async decryptShareUrlPassword( encryptedPublicLink: Pick, addressKeys: PrivateKey[], ): Promise<{ - password: string, - customPassword?: string, + password: string; + customPassword?: string; }> { const password = await this.driveCrypto.decryptShareUrlPassword( encryptedPublicLink.armoredUrlPassword, @@ -370,21 +407,21 @@ export class SharingCryptoService { case PublicLinkFlags.CustomPassword: return { password, - } + }; case PublicLinkFlags.GeneratedPasswordIncluded: case PublicLinkFlags.GeneratedPasswordWithCustomPassword: return { password: password.substring(0, PUBLIC_LINK_GENERATED_PASSWORD_LENGTH), customPassword: password.substring(PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) || undefined, - } + }; default: throw new Error(`Unsupported public link with flags: ${encryptedPublicLink.flags}`); } } async decryptBookmark(encryptedBookmark: EncryptedBookmark): Promise<{ - url: Result, - nodeName: Result, + url: Result; + nodeName: Result; }> { // TODO: Signatures are not checked and not specified in the interface. // In the future, we will need to add authorship verification. @@ -478,14 +515,13 @@ export class SharingCryptoService { } } - private async decryptBookmarkName(encryptedBookmark: EncryptedBookmark, shareKey: PrivateKey): Promise> { + private async decryptBookmarkName( + encryptedBookmark: EncryptedBookmark, + shareKey: PrivateKey, + ): Promise> { try { // Use the share key to decrypt the node name of the bookmark. - const { name } = await this.driveCrypto.decryptNodeName( - encryptedBookmark.node.encryptedName, - shareKey, - [], - ); + const { name } = await this.driveCrypto.decryptNodeName(encryptedBookmark.node.encryptedName, shareKey, []); try { validateNodeName(name); diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index 6f0a4124..5f393bf3 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -1,13 +1,13 @@ -import { getMockLogger } from "../../tests/logger"; -import { DriveEvent, DriveEventType } from "../events"; -import { SharingCache } from "./cache"; -import { SharingAccess } from "./sharingAccess"; -import { SharingEventHandler } from "./events"; -import { SharesManager } from "../shares/manager"; +import { getMockLogger } from '../../tests/logger'; +import { DriveEvent, DriveEventType } from '../events'; +import { SharingCache } from './cache'; +import { SharingAccess } from './sharingAccess'; +import { SharingEventHandler } from './events'; +import { SharesManager } from '../shares/manager'; // FIXME: test tree_refresh and tree_remove -describe("handleSharedByMeNodes", () => { +describe('handleSharedByMeNodes', () => { let cache: SharingCache; let sharingEventHandler: SharingEventHandler; let sharesManager: SharesManager; @@ -20,7 +20,7 @@ describe("handleSharedByMeNodes", () => { addSharedByMeNodeUid: jest.fn(), removeSharedByMeNodeUid: jest.fn(), setSharedWithMeNodeUids: jest.fn(), - getSharedByMeNodeUids: jest.fn().mockResolvedValue(["cachedNodeUid"]), + getSharedByMeNodeUids: jest.fn().mockResolvedValue(['cachedNodeUid']), }; sharesManager = { isOwnVolume: jest.fn(async (volumeId: string) => volumeId === 'MyVolume1'), @@ -28,102 +28,99 @@ describe("handleSharedByMeNodes", () => { sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager); }); - describe("node events trigger cache update", () => { - - it("should add if new own shared node is created", async () => { + describe('node events trigger cache update', () => { + it('should add if new own shared node is created', async () => { const event: DriveEvent = { - eventId: "1", + eventId: '1', type: DriveEventType.NodeCreated, - nodeUid: "newNodeUid", - parentNodeUid: "parentUid", + nodeUid: 'newNodeUid', + parentNodeUid: 'parentUid', isTrashed: false, isShared: true, - treeEventScopeId: "MyVolume1", + treeEventScopeId: 'MyVolume1', }; await sharingEventHandler.handleDriveEvent(event); - expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("newNodeUid"); + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith('newNodeUid'); expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); // FIXME enable when volume ownership is handled - test.skip("should not add if new shared node is not own", async () => { + test.skip('should not add if new shared node is not own', async () => { const event: DriveEvent = { - eventId: "1", + eventId: '1', type: DriveEventType.NodeCreated, - nodeUid: "newNodeUid", - parentNodeUid: "parentUid", + nodeUid: 'newNodeUid', + parentNodeUid: 'parentUid', isTrashed: false, isShared: true, - treeEventScopeId: "NotOwnVolume", + treeEventScopeId: 'NotOwnVolume', }; await sharingEventHandler.handleDriveEvent(event); expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); - it("should not add if new own node is not shared", async () => { + it('should not add if new own node is not shared', async () => { const event: DriveEvent = { type: DriveEventType.NodeCreated, - nodeUid: "newNodeUid", - parentNodeUid: "parentUid", + nodeUid: 'newNodeUid', + parentNodeUid: 'parentUid', isTrashed: false, isShared: false, - eventId: "1", - treeEventScopeId: "MyVolume1", + eventId: '1', + treeEventScopeId: 'MyVolume1', }; await sharingEventHandler.handleDriveEvent(event); expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); - it("should add if own node is updated and shared", async () => { + it('should add if own node is updated and shared', async () => { const event: DriveEvent = { type: DriveEventType.NodeUpdated, - nodeUid: "cachedNodeUid", - parentNodeUid: "parentUid", + nodeUid: 'cachedNodeUid', + parentNodeUid: 'parentUid', isTrashed: false, isShared: true, - eventId: "1", - treeEventScopeId: "MyVolume1", + eventId: '1', + treeEventScopeId: 'MyVolume1', }; await sharingEventHandler.handleDriveEvent(event); - expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith("cachedNodeUid"); + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); - it("should remove if shared node is un-shared", async () => { + it('should remove if shared node is un-shared', async () => { const event: DriveEvent = { type: DriveEventType.NodeUpdated, - nodeUid: "cachedNodeUid", - parentNodeUid: "parentUid", + nodeUid: 'cachedNodeUid', + parentNodeUid: 'parentUid', isTrashed: false, isShared: false, - eventId: "1", - treeEventScopeId: "MyVolume1", + eventId: '1', + treeEventScopeId: 'MyVolume1', }; await sharingEventHandler.handleDriveEvent(event); - expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith("cachedNodeUid"); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); - it("should remove if shared node is deleted", async () => { + it('should remove if shared node is deleted', async () => { const event: DriveEvent = { type: DriveEventType.NodeDeleted, - nodeUid: "cachedNodeUid", - parentNodeUid: "parentUid", - eventId: "1", - treeEventScopeId: "MyVolume1", + nodeUid: 'cachedNodeUid', + parentNodeUid: 'parentUid', + eventId: '1', + treeEventScopeId: 'MyVolume1', }; await sharingEventHandler.handleDriveEvent(event); - expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith("cachedNodeUid"); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); }); }); -describe("handleSharedWithMeNodes", () => { +describe('handleSharedWithMeNodes', () => { let cache: SharingCache; let sharingAccess: SharingAccess; let sharesManager: SharesManager; @@ -145,7 +142,7 @@ describe("handleSharedWithMeNodes", () => { } as any; }); - it("should only update cache", async () => { + it('should only update cache', async () => { const event: DriveEvent = { type: DriveEventType.SharedWithMeUpdated, eventId: 'event1', diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index f3ce5495..f62a3401 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -1,11 +1,14 @@ -import { Logger } from "../../interface"; -import { DriveEvent, DriveEventType } from "../events"; -import { SharingCache } from "./cache"; -import { SharesService } from "./interface"; +import { Logger } from '../../interface'; +import { DriveEvent, DriveEventType } from '../events'; +import { SharingCache } from './cache'; +import { SharesService } from './interface'; export class SharingEventHandler { - constructor(private logger: Logger, private cache: SharingCache, private shares: SharesService) { - }; + constructor( + private logger: Logger, + private cache: SharingCache, + private shares: SharesService, + ) {} /** * Update cache and notify listeners accordingly for any updates diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index 1edbeccc..8154b4f6 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -1,13 +1,13 @@ -import { ProtonDriveAccount, ProtonDriveEntitiesCache, ProtonDriveTelemetry } from "../../interface"; +import { ProtonDriveAccount, ProtonDriveEntitiesCache, ProtonDriveTelemetry } from '../../interface'; import { DriveCrypto } from '../../crypto'; -import { DriveAPIService } from "../apiService"; -import { SharingAPIService } from "./apiService"; -import { SharingCache } from "./cache"; -import { SharingCryptoService } from "./cryptoService"; -import { SharingAccess } from "./sharingAccess"; -import { SharingManagement } from "./sharingManagement"; -import { SharesService, NodesService } from "./interface"; -import { SharingEventHandler } from "./events"; +import { DriveAPIService } from '../apiService'; +import { SharingAPIService } from './apiService'; +import { SharingCache } from './cache'; +import { SharingCryptoService } from './cryptoService'; +import { SharingAccess } from './sharingAccess'; +import { SharingManagement } from './sharingManagement'; +import { SharesService, NodesService } from './interface'; +import { SharingEventHandler } from './events'; /** * Provides facade for the whole sharing module. @@ -29,8 +29,19 @@ export function initSharingModule( const cache = new SharingCache(driveEntitiesCache); const cryptoService = new SharingCryptoService(telemetry, crypto, account, sharesService); const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); - const sharingManagement = new SharingManagement(telemetry.getLogger('sharing'), api, cryptoService, account, sharesService, nodesService); - const sharingEventHandler = new SharingEventHandler(telemetry.getLogger('sharing-event-handler'), cache, sharesService); + const sharingManagement = new SharingManagement( + telemetry.getLogger('sharing'), + api, + cryptoService, + account, + sharesService, + nodesService, + ); + const sharingEventHandler = new SharingEventHandler( + telemetry.getLogger('sharing-event-handler'), + cache, + sharesService, + ); return { access: sharingAccess, diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 98a04d77..9638ce83 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -1,7 +1,7 @@ -import { NodeType, MemberRole, NonProtonInvitationState, MissingNode, ShareResult, PublicLink } from "../../interface"; -import { PrivateKey, SessionKey } from "../../crypto"; -import { EncryptedShare } from "../shares"; -import { DecryptedNode } from "../nodes"; +import { NodeType, MemberRole, NonProtonInvitationState, MissingNode, ShareResult, PublicLink } from '../../interface'; +import { PrivateKey, SessionKey } from '../../crypto'; +import { EncryptedShare } from '../shares'; +import { DecryptedNode } from '../nodes'; export enum SharingType { SharedByMe = 'sharedByMe', @@ -46,7 +46,7 @@ export interface EncryptedInvitationWithNode extends EncryptedInvitation { type: NodeType; mediaType?: string; encryptedName: string; - } + }; } /** @@ -109,24 +109,24 @@ export interface EncryptedBookmark { } export interface EncryptedPublicLink { - uid: string, - creationTime: Date, - expirationTime?: Date, - role: MemberRole, - flags: number, - creatorEmail: string, - publicUrl: string, + uid: string; + creationTime: Date; + expirationTime?: Date; + role: MemberRole; + flags: number; + creatorEmail: string; + publicUrl: string; numberOfInitializedDownloads: number; - armoredUrlPassword: string, - urlPasswordSalt: string, - base64SharePassphraseKeyPacket: string, - sharePassphraseSalt: string, + armoredUrlPassword: string; + urlPasswordSalt: string; + base64SharePassphraseKeyPacket: string; + sharePassphraseSalt: string; } export interface EncryptedPublicLinkCrypto { - base64SharePasswordSalt: string, - base64SharePassphraseKeyPacket: string, - armoredPassword: string, + base64SharePasswordSalt: string; + base64SharePassphraseKeyPacket: string; + armoredPassword: string; } export interface ShareResultWithCreatorEmail extends ShareResult { @@ -137,22 +137,21 @@ export interface PublicLinkWithCreatorEmail extends PublicLink { creatorEmail: string; } - /** * Interface describing the dependencies to the shares module. */ export interface SharesService { - getMyFilesIDs(): Promise<{ volumeId: string }>, - loadEncryptedShare(shareId: string): Promise, + getMyFilesIDs(): Promise<{ volumeId: string }>; + loadEncryptedShare(shareId: string): Promise; getMyFilesShareMemberEmailKey(): Promise<{ - addressId: string, - addressKey: PrivateKey, - }>, + addressId: string; + addressKey: PrivateKey; + }>; getContextShareMemberEmailKey(shareId: string): Promise<{ - email: string, - addressId: string, - addressKey: PrivateKey, - }>, + email: string; + addressId: string; + addressKey: PrivateKey; + }>; isOwnVolume(volumeId: string): Promise; } @@ -160,18 +159,18 @@ export interface SharesService { * Interface describing the dependencies to the nodes module. */ export interface NodesService { - getNode(nodeUid: string): Promise, - getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey }>, + getNode(nodeUid: string): Promise; + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey }>; getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ - key: PrivateKey, - passphraseSessionKey: SessionKey, - nameSessionKey: SessionKey, - }>, + key: PrivateKey; + passphraseSessionKey: SessionKey; + nameSessionKey: SessionKey; + }>; getRootNodeEmailKey(nodeUid: string): Promise<{ - email: string, - addressId: string, - addressKey: PrivateKey, - }>, + email: string; + addressId: string; + addressKey: PrivateKey; + }>; iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; notifyNodeChanged(nodeUid: string): Promise; } @@ -181,5 +180,5 @@ export interface NodesService { * Interface describing the dependencies to the nodes module. */ export interface NodesEvents { - nodeUpdated(partialNode: { uid: string, shareId: string | undefined, isShared: boolean }): Promise, + nodeUpdated(partialNode: { uid: string; shareId: string | undefined; isShared: boolean }): Promise; } diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index 5fee572c..c011d1b1 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -1,11 +1,11 @@ -import { NodeType, resultError, resultOk } from "../../interface"; -import { SharingAPIService } from "./apiService"; -import { SharingCache } from "./cache"; -import { SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService } from "./interface"; -import { SharingAccess } from "./sharingAccess"; - -describe("SharingAccess", () => { +import { NodeType, resultError, resultOk } from '../../interface'; +import { SharingAPIService } from './apiService'; +import { SharingCache } from './cache'; +import { SharingCryptoService } from './cryptoService'; +import { SharesService, NodesService } from './interface'; +import { SharingAccess } from './sharingAccess'; + +describe('SharingAccess', () => { let apiService: SharingAPIService; let cache: SharingCache; let cryptoService: SharingCryptoService; @@ -20,7 +20,7 @@ describe("SharingAccess", () => { for (const nodeUid of nodeUids) { yield nodeUid; } - } + }; beforeEach(() => { // @ts-expect-error No need to implement all methods for mocking @@ -29,29 +29,29 @@ describe("SharingAccess", () => { iterateSharedWithMeNodeUids: jest.fn().mockImplementation(() => nodeUidsIterator()), iterateBookmarks: jest.fn().mockImplementation(async function* () { yield { - tokenId: "tokenId", + tokenId: 'tokenId', creationTime: new Date('2025-01-01'), node: { type: NodeType.File, - mediaType: "mediaType", + mediaType: 'mediaType', }, - } + }; }), - } + }; // @ts-expect-error No need to implement all methods for mocking cache = { setSharedByMeNodeUids: jest.fn(), setSharedWithMeNodeUids: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoService = { decryptInvitation: jest.fn(), decryptBookmark: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking sharesService = { - getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: "volumeId" }), - } + getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + }; // @ts-expect-error No need to implement all methods for mocking nodesService = { iterateNodes: jest.fn().mockImplementation(async function* (nodeUids) { @@ -61,13 +61,13 @@ describe("SharingAccess", () => { } } }), - } + }; sharingAccess = new SharingAccess(apiService, cache, cryptoService, sharesService, nodesService); }); - describe("iterateSharedNodes", () => { - it("should iterate from cache", async () => { + describe('iterateSharedNodes', () => { + it('should iterate from cache', async () => { cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(nodeUids); const result = await Array.fromAsync(sharingAccess.iterateSharedNodes()); @@ -77,20 +77,20 @@ describe("SharingAccess", () => { expect(cache.setSharedByMeNodeUids).not.toHaveBeenCalled(); }); - it("should iterate from API", async () => { + it('should iterate from API', async () => { cache.getSharedByMeNodeUids = jest.fn().mockRejectedValue(new Error('Not cached')); const result = await Array.fromAsync(sharingAccess.iterateSharedNodes()); expect(result).toEqual(nodes); - expect(apiService.iterateSharedNodeUids).toHaveBeenCalledWith("volumeId", undefined); + expect(apiService.iterateSharedNodeUids).toHaveBeenCalledWith('volumeId', undefined); expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // 15 / 10 per batch expect(cache.setSharedByMeNodeUids).toHaveBeenCalledWith(nodeUids); }); }); - describe("iterateSharedNodesWithMe", () => { - it("should iterate from cache", async () => { + describe('iterateSharedNodesWithMe', () => { + it('should iterate from cache', async () => { cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(nodeUids); const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe()); @@ -100,7 +100,7 @@ describe("SharingAccess", () => { expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); - it("should iterate from API", async () => { + it('should iterate from API', async () => { cache.getSharedWithMeNodeUids = jest.fn().mockRejectedValue(new Error('Not cached')); const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe()); @@ -112,65 +112,71 @@ describe("SharingAccess", () => { }); }); - describe("iterateBookmarks", () => { - it("should return decrypted bookmark", async () => { + describe('iterateBookmarks', () => { + it('should return decrypted bookmark', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ - url: resultOk("url"), - nodeName: resultOk("nodeName"), + url: resultOk('url'), + nodeName: resultOk('nodeName'), }); const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); - expect(result).toEqual([resultOk({ - uid: "tokenId", - creationTime: new Date('2025-01-01'), - url: "url", - node: { - name: "nodeName", - type: NodeType.File, - mediaType: "mediaType", - }, - })]); + expect(result).toEqual([ + resultOk({ + uid: 'tokenId', + creationTime: new Date('2025-01-01'), + url: 'url', + node: { + name: 'nodeName', + type: NodeType.File, + mediaType: 'mediaType', + }, + }), + ]); }); - it("should return degraded bookmark if URL password cannot be decrypted", async () => { + it('should return degraded bookmark if URL password cannot be decrypted', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ - url: resultError("url cannot be decrypted"), - nodeName: resultError("url cannot be decrypted"), + url: resultError('url cannot be decrypted'), + nodeName: resultError('url cannot be decrypted'), }); const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); - expect(result).toEqual([resultError({ - uid: "tokenId", - creationTime: new Date('2025-01-01'), - url: resultError("url cannot be decrypted"), - node: { - name: resultError("url cannot be decrypted"), - type: NodeType.File, - mediaType: "mediaType", - }, - })]); + expect(result).toEqual([ + resultError({ + uid: 'tokenId', + creationTime: new Date('2025-01-01'), + url: resultError('url cannot be decrypted'), + node: { + name: resultError('url cannot be decrypted'), + type: NodeType.File, + mediaType: 'mediaType', + }, + }), + ]); }); - it("should return degraded bookmark if node name cannot be decrypted", async () => { + it('should return degraded bookmark if node name cannot be decrypted', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ - url: resultOk("url"), - nodeName: resultError("node name cannot be decrypted"), + url: resultOk('url'), + nodeName: resultError('node name cannot be decrypted'), }); const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); - expect(result).toEqual([resultError({ - uid: "tokenId", - creationTime: new Date('2025-01-01'), - url: resultOk("url"), - node: { - name: resultError("node name cannot be decrypted"), - type: NodeType.File, - mediaType: "mediaType", - }, - })]); + expect(result).toEqual([ + resultError({ + uid: 'tokenId', + creationTime: new Date('2025-01-01'), + url: resultOk('url'), + node: { + name: resultError('node name cannot be decrypted'), + type: NodeType.File, + mediaType: 'mediaType', + }, + }), + ]); }); }); }); diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 5af129dd..61a4a4e3 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -1,13 +1,13 @@ import { c } from 'ttag'; -import { MaybeBookmark, ProtonInvitationWithNode, resultError, resultOk } from "../../interface"; -import { ValidationError } from "../../errors"; -import { DecryptedNode } from "../nodes"; -import { BatchLoading } from "../batchLoading"; -import { SharingAPIService } from "./apiService"; -import { SharingCache } from "./cache"; -import { SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService } from "./interface"; +import { MaybeBookmark, ProtonInvitationWithNode, resultError, resultOk } from '../../interface'; +import { ValidationError } from '../../errors'; +import { DecryptedNode } from '../nodes'; +import { BatchLoading } from '../batchLoading'; +import { SharingAPIService } from './apiService'; +import { SharingCache } from './cache'; +import { SharingCryptoService } from './cryptoService'; +import { SharesService, NodesService } from './interface'; /** * Provides high-level actions for access shared nodes. @@ -31,42 +31,57 @@ export class SharingAccess { this.nodesService = nodesService; } - async* iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { try { const nodeUids = await this.cache.getSharedByMeNodeUids(); yield* this.iterateSharedNodesFromCache(nodeUids, signal); } catch { const { volumeId } = await this.sharesService.getMyFilesIDs(); const nodeUidsIterator = this.apiService.iterateSharedNodeUids(volumeId, signal); - yield* this.iterateSharedNodesFromAPI(nodeUidsIterator, (nodeUids) => this.cache.setSharedByMeNodeUids(nodeUids), signal); + yield* this.iterateSharedNodesFromAPI( + nodeUidsIterator, + (nodeUids) => this.cache.setSharedByMeNodeUids(nodeUids), + signal, + ); } } - async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { + async *iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { try { const nodeUids = await this.cache.getSharedWithMeNodeUids(); yield* this.iterateSharedNodesFromCache(nodeUids, signal); } catch { const nodeUidsIterator = this.apiService.iterateSharedWithMeNodeUids(signal); - yield* this.iterateSharedNodesFromAPI(nodeUidsIterator, (nodeUids) => this.cache.setSharedWithMeNodeUids(nodeUids), signal); + yield* this.iterateSharedNodesFromAPI( + nodeUidsIterator, + (nodeUids) => this.cache.setSharedWithMeNodeUids(nodeUids), + signal, + ); } } - private async* iterateSharedNodesFromCache(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal) }); + private async *iterateSharedNodesFromCache( + nodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), + }); for (const nodeUid of nodeUids) { yield* batchLoading.load(nodeUid); } yield* batchLoading.loadRest(); } - private async* iterateSharedNodesFromAPI( + private async *iterateSharedNodesFromAPI( nodeUidsIterator: AsyncGenerator, setCache: (nodeUids: string[]) => Promise, signal?: AbortSignal, ): AsyncGenerator { const loadedNodeUids = []; - const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal) }); + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), + }); for await (const nodeUid of nodeUidsIterator) { loadedNodeUids.push(nodeUid); yield* batchLoading.load(nodeUid); @@ -77,7 +92,10 @@ export class SharingAccess { await setCache(loadedNodeUids); } - private async* iterateNodesAndIgnoreMissingOnes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + private async *iterateNodesAndIgnoreMissingOnes( + nodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { const nodeGenerator = this.nodesService.iterateNodes(nodeUids, signal); for await (const node of nodeGenerator) { if ('missingUid' in node) { @@ -102,7 +120,7 @@ export class SharingAccess { await this.apiService.removeMember(memberUid); } - async* iterateInvitations(signal?: AbortSignal): AsyncGenerator { + async *iterateInvitations(signal?: AbortSignal): AsyncGenerator { for await (const invitationUid of this.apiService.iterateInvitationUids(signal)) { const encryptedInvitation = await this.apiService.getInvitation(invitationUid); const invitation = await this.cryptoService.decryptInvitationWithNode(encryptedInvitation); @@ -120,7 +138,7 @@ export class SharingAccess { await this.apiService.rejectInvitation(invitationUid); } - async* iterateBookmarks(signal?: AbortSignal): AsyncGenerator { + async *iterateBookmarks(signal?: AbortSignal): AsyncGenerator { for await (const bookmark of this.apiService.iterateBookmarks(signal)) { const { url, nodeName } = await this.cryptoService.decryptBookmark(bookmark); @@ -133,7 +151,7 @@ export class SharingAccess { name: nodeName, type: bookmark.node.type, mediaType: bookmark.node.mediaType, - } + }, }); } else { yield resultOk({ @@ -144,7 +162,7 @@ export class SharingAccess { name: nodeName.value, type: bookmark.node.type, mediaType: bookmark.node.mediaType, - } + }, }); } } diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index aba5c945..e54a8d70 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -1,11 +1,20 @@ -import { getMockLogger } from "../../tests/logger"; -import { Member, MemberRole, NonProtonInvitation, NonProtonInvitationState, ProtonDriveAccount, ProtonInvitation, PublicLink, resultOk } from "../../interface"; -import { SharingAPIService } from "./apiService"; -import { SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService } from "./interface"; -import { SharingManagement } from "./sharingManagement"; - -describe("SharingManagement", () => { +import { getMockLogger } from '../../tests/logger'; +import { + Member, + MemberRole, + NonProtonInvitation, + NonProtonInvitationState, + ProtonDriveAccount, + ProtonInvitation, + PublicLink, + resultOk, +} from '../../interface'; +import { SharingAPIService } from './apiService'; +import { SharingCryptoService } from './cryptoService'; +import { SharesService, NodesService } from './interface'; +import { SharingManagement } from './sharingManagement'; + +describe('SharingManagement', () => { let apiService: SharingAPIService; let cryptoService: SharingCryptoService; let accountService: ProtonDriveAccount; @@ -17,19 +26,19 @@ describe("SharingManagement", () => { beforeEach(() => { // @ts-expect-error No need to implement all methods for mocking apiService = { - createStandardShare: jest.fn().mockReturnValue("newShareId"), + createStandardShare: jest.fn().mockReturnValue('newShareId'), getShareInvitations: jest.fn().mockResolvedValue([]), getShareExternalInvitations: jest.fn().mockResolvedValue([]), getShareMembers: jest.fn().mockResolvedValue([]), inviteProtonUser: jest.fn().mockImplementation((_, invitation) => ({ ...invitation, - uid: "created-invitation", + uid: 'created-invitation', })), updateInvitation: jest.fn(), deleteInvitation: jest.fn(), inviteExternalUser: jest.fn().mockImplementation((_, invitation) => ({ ...invitation, - uid: "created-external-invitation", + uid: 'created-external-invitation', state: NonProtonInvitationState.Pending, })), updateExternalInvitation: jest.fn(), @@ -42,55 +51,77 @@ describe("SharingManagement", () => { resendInvitationEmail: jest.fn(), resendExternalInvitationEmail: jest.fn(), createPublicLink: jest.fn().mockResolvedValue({ - uid: "publicLinkUid", - publicUrl: "publicLinkUrl", + uid: 'publicLinkUid', + publicUrl: 'publicLinkUrl', }), updatePublicLink: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoService = { - generateShareKeys: jest.fn().mockResolvedValue({ shareKey: { encrypted: "encrypted-key", decrypted: { passphraseSessionKey: "pass-session-key", } } }), + generateShareKeys: jest + .fn() + .mockResolvedValue({ + shareKey: { encrypted: 'encrypted-key', decrypted: { passphraseSessionKey: 'pass-session-key' } }, + }), decryptShare: jest.fn().mockImplementation((share) => share), decryptInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptExternalInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptMember: jest.fn().mockImplementation((member) => member), - encryptInvitation: jest.fn().mockImplementation(() => { }), + encryptInvitation: jest.fn().mockImplementation(() => {}), encryptExternalInvitation: jest.fn().mockImplementation((invitation) => ({ ...invitation, - base64ExternalInvitationSignature: "extenral-signature", + base64ExternalInvitationSignature: 'extenral-signature', })), decryptPublicLink: jest.fn().mockImplementation((publicLink) => publicLink), - generatePublicLinkPassword: jest.fn().mockResolvedValue("generatedPassword"), + generatePublicLinkPassword: jest.fn().mockResolvedValue('generatedPassword'), encryptPublicLink: jest.fn().mockImplementation(() => ({ - crypto: "publicLinkCrypto", - srp: "publicLinkSrp", + crypto: 'publicLinkCrypto', + srp: 'publicLinkSrp', })), - } + }; // @ts-expect-error No need to implement all methods for mocking accountService = { hasProtonAccount: jest.fn().mockResolvedValue(true), - } + }; // @ts-expect-error No need to implement all methods for mocking sharesService = { - loadEncryptedShare: jest.fn().mockResolvedValue({ id: "shareId", addressId: "addressId", creatorEmail: "address@example.com", passphraseSessionKey: "sharePassphraseSessionKey" }), - getContextShareMemberEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressId: "addressId", addressKey: "volume-key" }), - } + loadEncryptedShare: jest + .fn() + .mockResolvedValue({ + id: 'shareId', + addressId: 'addressId', + creatorEmail: 'address@example.com', + passphraseSessionKey: 'sharePassphraseSessionKey', + }), + getContextShareMemberEmailKey: jest + .fn() + .mockResolvedValue({ email: 'volume-email', addressId: 'addressId', addressKey: 'volume-key' }), + }; // @ts-expect-error No need to implement all methods for mocking nodesService = { - getNode: jest.fn().mockImplementation((nodeUid) => ({ nodeUid, shareId: "shareId", name: { ok: true, value: "name" } })), - getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: "node-key" })), + getNode: jest + .fn() + .mockImplementation((nodeUid) => ({ nodeUid, shareId: 'shareId', name: { ok: true, value: 'name' } })), + getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: 'node-key' })), getNodePrivateAndSessionKeys: jest.fn().mockImplementation((nodeUid) => ({})), - getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: "volume-email", addressKey: "volume-key" }), + getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: 'volume-email', addressKey: 'volume-key' }), notifyNodeChanged: jest.fn(), - } + }; - sharingManagement = new SharingManagement(getMockLogger(), apiService, cryptoService, accountService, sharesService, nodesService); + sharingManagement = new SharingManagement( + getMockLogger(), + apiService, + cryptoService, + accountService, + sharesService, + nodesService, + ); }); - describe("getSharingInfo", () => { - it("should return empty sharing info for unshared node", async () => { - nodesService.getNode = jest.fn().mockResolvedValue({ nodeUid: "nodeUid", shareId: undefined }); - const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + describe('getSharingInfo', () => { + it('should return empty sharing info for unshared node', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ nodeUid: 'nodeUid', shareId: undefined }); + const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); expect(sharingInfo).toEqual(undefined); expect(apiService.getShareInvitations).not.toHaveBeenCalled(); @@ -98,13 +129,11 @@ describe("SharingManagement", () => { expect(apiService.getShareMembers).not.toHaveBeenCalled(); }); - it("should return invitations", async () => { - const invitation = { uid: "invitaiton", addedByEmail: "email" }; - apiService.getShareInvitations = jest.fn().mockResolvedValue([ - invitation, - ]); + it('should return invitations', async () => { + const invitation = { uid: 'invitaiton', addedByEmail: 'email' }; + apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]); - const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -115,13 +144,11 @@ describe("SharingManagement", () => { expect(cryptoService.decryptInvitation).toHaveBeenCalledWith(invitation); }); - it("should return external invitations", async () => { - const externalInvitation = { uid: "external-invitation", addedByEmail: "email" }; - apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([ - externalInvitation, - ]); + it('should return external invitations', async () => { + const externalInvitation = { uid: 'external-invitation', addedByEmail: 'email' }; + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); - const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); expect(sharingInfo).toEqual({ protonInvitations: [], @@ -132,13 +159,11 @@ describe("SharingManagement", () => { expect(cryptoService.decryptExternalInvitation).toHaveBeenCalledWith(externalInvitation); }); - it("should return members", async () => { - const member = { uid: "member", addedByEmail: "email" }; - apiService.getShareMembers = jest.fn().mockResolvedValue([ - member, - ]); + it('should return members', async () => { + const member = { uid: 'member', addedByEmail: 'email' }; + apiService.getShareMembers = jest.fn().mockResolvedValue([member]); - const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); expect(sharingInfo).toEqual({ protonInvitations: [], @@ -149,13 +174,13 @@ describe("SharingManagement", () => { expect(cryptoService.decryptMember).toHaveBeenCalledWith(member); }); - it("should return public link", async () => { + it('should return public link', async () => { const publicLink = { uid: 'shared~publicLink', - } + }; apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); - const sharingInfo = await sharingManagement.getSharingInfo("nodeUid"); + const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); expect(sharingInfo).toEqual({ protonInvitations: [], @@ -167,22 +192,30 @@ describe("SharingManagement", () => { }); }); - describe("shareNode with share creation", () => { - const nodeUid = "volumeId~nodeUid"; + describe('shareNode with share creation', () => { + const nodeUid = 'volumeId~nodeUid'; - it("should create share if no exists", async () => { - nodesService.getNode = jest.fn().mockImplementation((nodeUid) => ({ nodeUid, parentUid: 'parentUid', name: { ok: true, value: "name" } })); + it('should create share if no exists', async () => { + nodesService.getNode = jest + .fn() + .mockImplementation((nodeUid) => ({ + nodeUid, + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + })); nodesService.notifyNodeChanged = jest.fn(); - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email"] }); + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ['email'] }); expect(sharingInfo).toEqual({ - protonInvitations: [{ - uid: "created-invitation", - addedByEmail: { ok: true, value: "volume-email" }, - inviteeEmail: "email", - role: "viewer", - }], + protonInvitations: [ + { + uid: 'created-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'viewer', + }, + ], nonProtonInvitations: [], members: [], publicLink: undefined, @@ -191,10 +224,10 @@ describe("SharingManagement", () => { expect(apiService.inviteProtonUser).toHaveBeenCalled(); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(nodeUid); }); - }) + }); - describe("shareNode with share re-use", () => { - const nodeUid = "volumeId~nodeUid"; + describe('shareNode with share re-use', () => { + const nodeUid = 'volumeId~nodeUid'; let invitation: ProtonInvitation; let externalInvitation: NonProtonInvitation; @@ -202,56 +235,53 @@ describe("SharingManagement", () => { beforeEach(async () => { invitation = { - uid: "invitation", - addedByEmail: resultOk("added-email"), - inviteeEmail: "internal-email", + uid: 'invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'internal-email', role: MemberRole.Viewer, invitationTime: new Date(), }; externalInvitation = { - uid: "external-invitation", - addedByEmail: resultOk("added-email"), - inviteeEmail: "external-email", + uid: 'external-invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'external-email', role: MemberRole.Viewer, invitationTime: new Date(), state: NonProtonInvitationState.Pending, }; member = { - uid: "member", - addedByEmail: resultOk("added-email"), - inviteeEmail: "member-email", + uid: 'member', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'member-email', role: MemberRole.Viewer, invitationTime: new Date(), }; - apiService.getShareInvitations = jest.fn().mockResolvedValue([ - invitation, - ]); + apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]); - apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([ - externalInvitation, - ]); + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); - apiService.getShareMembers = jest.fn().mockResolvedValue([ - member, - ]); + apiService.getShareMembers = jest.fn().mockResolvedValue([member]); }); - describe("invitations", () => { + describe('invitations', () => { beforeEach(() => { accountService.hasProtonAccount = jest.fn().mockResolvedValue(true); }); - it("should share node with proton email with default role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email"] }); + it('should share node with proton email with default role', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ['email'] }); expect(sharingInfo).toEqual({ - protonInvitations: [invitation, { - uid: "created-invitation", - addedByEmail: { ok: true, value: "volume-email" }, - inviteeEmail: "email", - role: "viewer", - }], + protonInvitations: [ + invitation, + { + uid: 'created-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'viewer', + }, + ], nonProtonInvitations: [externalInvitation], members: [member], publicLink: undefined, @@ -260,16 +290,21 @@ describe("SharingManagement", () => { expect(apiService.inviteProtonUser).toHaveBeenCalled(); }); - it("should share node with proton email with specific role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "email", role: MemberRole.Editor }] }); + it('should share node with proton email with specific role', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'email', role: MemberRole.Editor }], + }); expect(sharingInfo).toEqual({ - protonInvitations: [invitation, { - uid: "created-invitation", - addedByEmail: { ok: true, value: "volume-email" }, - inviteeEmail: "email", - role: "editor", - }], + protonInvitations: [ + invitation, + { + uid: 'created-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'editor', + }, + ], nonProtonInvitations: [externalInvitation], members: [member], publicLink: undefined, @@ -278,14 +313,18 @@ describe("SharingManagement", () => { expect(apiService.inviteProtonUser).toHaveBeenCalled(); }); - it("should update existing role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "internal-email", role: MemberRole.Editor }] }); + it('should update existing role', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'internal-email', role: MemberRole.Editor }], + }); expect(sharingInfo).toEqual({ - protonInvitations: [{ - ...invitation, - role: "editor", - }], + protonInvitations: [ + { + ...invitation, + role: 'editor', + }, + ], nonProtonInvitations: [externalInvitation], members: [member], publicLink: undefined, @@ -294,8 +333,10 @@ describe("SharingManagement", () => { expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); }); - it("should be no-op if no change", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "internal-email", role: MemberRole.Viewer }] }); + it('should be no-op if no change', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'internal-email', role: MemberRole.Viewer }], + }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -308,23 +349,26 @@ describe("SharingManagement", () => { }); }); - describe("external invitations", () => { + describe('external invitations', () => { beforeEach(() => { accountService.hasProtonAccount = jest.fn().mockResolvedValue(false); }); - it("should share node with external email with default role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email"] }); + it('should share node with external email with default role', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ['email'] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], - nonProtonInvitations: [externalInvitation, { - uid: "created-external-invitation", - addedByEmail: { ok: true, value: "volume-email" }, - inviteeEmail: "email", - role: "viewer", - state: "pending", - }], + nonProtonInvitations: [ + externalInvitation, + { + uid: 'created-external-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'viewer', + state: 'pending', + }, + ], members: [member], publicLink: undefined, }); @@ -332,18 +376,23 @@ describe("SharingManagement", () => { expect(apiService.inviteExternalUser).toHaveBeenCalled(); }); - it("should share node with external email with specific role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "email", role: MemberRole.Editor }] }); + it('should share node with external email with specific role', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'email', role: MemberRole.Editor }], + }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], - nonProtonInvitations: [externalInvitation, { - uid: "created-external-invitation", - addedByEmail: { ok: true, value: "volume-email" }, - inviteeEmail: "email", - role: "editor", - state: "pending", - }], + nonProtonInvitations: [ + externalInvitation, + { + uid: 'created-external-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'editor', + state: 'pending', + }, + ], members: [member], publicLink: undefined, }); @@ -351,15 +400,19 @@ describe("SharingManagement", () => { expect(apiService.inviteExternalUser).toHaveBeenCalled(); }); - it("should update existing role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "external-email", role: MemberRole.Editor }] }); + it('should update existing role', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'external-email', role: MemberRole.Editor }], + }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], - nonProtonInvitations: [{ - ...externalInvitation, - role: "editor", - }], + nonProtonInvitations: [ + { + ...externalInvitation, + role: 'editor', + }, + ], members: [member], publicLink: undefined, }); @@ -367,8 +420,10 @@ describe("SharingManagement", () => { expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); }); - it("should be no-op if no change", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "external-email", role: MemberRole.Viewer }] }); + it('should be no-op if no change', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'external-email', role: MemberRole.Viewer }], + }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -381,54 +436,70 @@ describe("SharingManagement", () => { }); }); - describe("mix of internal and external invitations", () => { + describe('mix of internal and external invitations', () => { beforeEach(() => { - accountService.hasProtonAccount = jest.fn() - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); + accountService.hasProtonAccount = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); }); - it("should share node with proton and external email with default role", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ["email", "email2"] }); + it('should share node with proton and external email with default role', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ['email', 'email2'] }); expect(sharingInfo).toEqual({ - protonInvitations: [invitation, { - uid: "created-invitation", - addedByEmail: { ok: true, value: "volume-email" }, - inviteeEmail: "email", - role: "viewer", - }], - nonProtonInvitations: [externalInvitation, { - uid: "created-external-invitation", - addedByEmail: { ok: true, value: "volume-email" }, - inviteeEmail: "email2", - role: "viewer", - state: "pending", - }], + protonInvitations: [ + invitation, + { + uid: 'created-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'viewer', + }, + ], + nonProtonInvitations: [ + externalInvitation, + { + uid: 'created-external-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email2', + role: 'viewer', + state: 'pending', + }, + ], members: [member], publicLink: undefined, }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); - expect(apiService.inviteProtonUser).toHaveBeenCalledWith("shareId", expect.objectContaining({ - inviteeEmail: "email", - }), expect.anything()); - expect(apiService.inviteExternalUser).toHaveBeenCalledWith("shareId", expect.objectContaining({ - inviteeEmail: "email2", - }), expect.anything()); + expect(apiService.inviteProtonUser).toHaveBeenCalledWith( + 'shareId', + expect.objectContaining({ + inviteeEmail: 'email', + }), + expect.anything(), + ); + expect(apiService.inviteExternalUser).toHaveBeenCalledWith( + 'shareId', + expect.objectContaining({ + inviteeEmail: 'email2', + }), + expect.anything(), + ); }); }); - describe("members", () => { - it("should update member via proton user", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "member-email", role: MemberRole.Editor }] }); + describe('members', () => { + it('should update member via proton user', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'member-email', role: MemberRole.Editor }], + }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], nonProtonInvitations: [externalInvitation], - members: [{ - ...member, - role: "editor", - }], + members: [ + { + ...member, + role: 'editor', + }, + ], publicLink: undefined, }); expect(apiService.updateMember).toHaveBeenCalled(); @@ -436,8 +507,10 @@ describe("SharingManagement", () => { expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); }); - it("should be no-op if no change via proton user", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "member-email", role: MemberRole.Viewer }] }); + it('should be no-op if no change via proton user', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'member-email', role: MemberRole.Viewer }], + }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -450,16 +523,20 @@ describe("SharingManagement", () => { expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); }); - it("should update member via non-proton user", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "member-email", role: MemberRole.Editor }] }); + it('should update member via non-proton user', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'member-email', role: MemberRole.Editor }], + }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], nonProtonInvitations: [externalInvitation], - members: [{ - ...member, - role: "editor", - }], + members: [ + { + ...member, + role: 'editor', + }, + ], publicLink: undefined, }); expect(apiService.updateMember).toHaveBeenCalled(); @@ -467,8 +544,10 @@ describe("SharingManagement", () => { expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); }); - it("should be no-op if no change via non-proton user", async () => { - const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: "member-email", role: MemberRole.Viewer }] }); + it('should be no-op if no change via non-proton user', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'member-email', role: MemberRole.Viewer }], + }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -482,8 +561,8 @@ describe("SharingManagement", () => { }); }); - describe("public link", () => { - it("should share node with public link", async () => { + describe('public link', () => { + it('should share node with public link', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01')); @@ -500,35 +579,42 @@ describe("SharingManagement", () => { nonProtonInvitations: [externalInvitation], members: [member], publicLink: { - uid: "publicLinkUid", + uid: 'publicLinkUid', role: MemberRole.Viewer, - url: "publicLinkUrl#generatedPassword", + url: 'publicLinkUrl#generatedPassword', creationTime: new Date(), expirationTime: undefined, customPassword: undefined, - creatorEmail: "volume-email", + creatorEmail: 'volume-email', numberOfInitializedDownloads: 0, }, }); expect(cryptoService.generatePublicLinkPassword).toHaveBeenCalled(); - expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith("volume-email", "sharePassphraseSessionKey", "generatedPassword"); - expect(apiService.createPublicLink).toHaveBeenCalledWith("shareId", expect.objectContaining({ - role: MemberRole.Viewer, - includesCustomPassword: false, - expirationTime: undefined, - crypto: "publicLinkCrypto", - srp: "publicLinkSrp", - })); + expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith( + 'volume-email', + 'sharePassphraseSessionKey', + 'generatedPassword', + ); + expect(apiService.createPublicLink).toHaveBeenCalledWith( + 'shareId', + expect.objectContaining({ + role: MemberRole.Viewer, + includesCustomPassword: false, + expirationTime: undefined, + crypto: 'publicLinkCrypto', + srp: 'publicLinkSrp', + }), + ); }); - it("should share node with custom password and expiration", async () => { + it('should share node with custom password and expiration', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01')); const sharingInfo = await sharingManagement.shareNode(nodeUid, { publicLink: { role: MemberRole.Viewer, - customPassword: "customPassword", + customPassword: 'customPassword', expiration: new Date('2025-01-02'), }, }); @@ -538,46 +624,53 @@ describe("SharingManagement", () => { nonProtonInvitations: [externalInvitation], members: [member], publicLink: { - uid: "publicLinkUid", + uid: 'publicLinkUid', role: MemberRole.Viewer, - url: "publicLinkUrl#generatedPassword", + url: 'publicLinkUrl#generatedPassword', creationTime: new Date(), expirationTime: new Date('2025-01-02'), - customPassword: "customPassword", - creatorEmail: "volume-email", + customPassword: 'customPassword', + creatorEmail: 'volume-email', numberOfInitializedDownloads: 0, }, }); expect(cryptoService.generatePublicLinkPassword).toHaveBeenCalled(); - expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith("volume-email", "sharePassphraseSessionKey", "generatedPasswordcustomPassword"); - expect(apiService.createPublicLink).toHaveBeenCalledWith("shareId", expect.objectContaining({ - role: MemberRole.Viewer, - includesCustomPassword: true, - expirationTime: 1735776000, - crypto: "publicLinkCrypto", - srp: "publicLinkSrp", - })); + expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith( + 'volume-email', + 'sharePassphraseSessionKey', + 'generatedPasswordcustomPassword', + ); + expect(apiService.createPublicLink).toHaveBeenCalledWith( + 'shareId', + expect.objectContaining({ + role: MemberRole.Viewer, + includesCustomPassword: true, + expirationTime: 1735776000, + crypto: 'publicLinkCrypto', + srp: 'publicLinkSrp', + }), + ); }); - it("should update public link with custom password and expiration", async () => { + it('should update public link with custom password and expiration', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01')); const publicLink = { uid: 'publicLinkUid', - url: "publicLinkUrl#generatedpas", // Generated password must be 12 chararacters long. + url: 'publicLinkUrl#generatedpas', // Generated password must be 12 chararacters long. creationTime: new Date('2025-01-01'), role: MemberRole.Viewer, customPassword: undefined, expirationTime: undefined, - creatorEmail: "publicLinkCreatorEmail", - } + creatorEmail: 'publicLinkCreatorEmail', + }; apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); const sharingInfo = await sharingManagement.shareNode(nodeUid, { publicLink: { role: MemberRole.Editor, - customPassword: "customPassword", + customPassword: 'customPassword', expiration: new Date('2025-01-02'), }, }); @@ -587,65 +680,78 @@ describe("SharingManagement", () => { nonProtonInvitations: [externalInvitation], members: [member], publicLink: { - uid: "publicLinkUid", + uid: 'publicLinkUid', role: MemberRole.Editor, - url: "publicLinkUrl#generatedpas", + url: 'publicLinkUrl#generatedpas', creationTime: new Date('2025-01-01'), expirationTime: new Date('2025-01-02'), - customPassword: "customPassword", - creatorEmail: "publicLinkCreatorEmail", + customPassword: 'customPassword', + creatorEmail: 'publicLinkCreatorEmail', }, }); - expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith("publicLinkCreatorEmail", "sharePassphraseSessionKey", "generatedpascustomPassword"); - expect(apiService.updatePublicLink).toHaveBeenCalledWith("publicLinkUid", expect.objectContaining({ - role: MemberRole.Editor, - includesCustomPassword: true, - expirationTime: 1735776000, - crypto: "publicLinkCrypto", - srp: "publicLinkSrp", - })); + expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith( + 'publicLinkCreatorEmail', + 'sharePassphraseSessionKey', + 'generatedpascustomPassword', + ); + expect(apiService.updatePublicLink).toHaveBeenCalledWith( + 'publicLinkUid', + expect.objectContaining({ + role: MemberRole.Editor, + includesCustomPassword: true, + expirationTime: 1735776000, + crypto: 'publicLinkCrypto', + srp: 'publicLinkSrp', + }), + ); }); - it("should not allow updating legacy public link", async () => { + it('should not allow updating legacy public link', async () => { apiService.getPublicLink = jest.fn().mockResolvedValue({ uid: 'publicLinkUid', - url: "publicLinkUrl#aaa", // Legacy public links doesn't have 12 chars. + url: 'publicLinkUrl#aaa', // Legacy public links doesn't have 12 chars. }); - await expect(sharingManagement.shareNode(nodeUid, { - publicLink: true, - })).rejects.toThrow("Legacy public link cannot be updated. Please re-create a new public link."); + await expect( + sharingManagement.shareNode(nodeUid, { + publicLink: true, + }), + ).rejects.toThrow('Legacy public link cannot be updated. Please re-create a new public link.'); }); - it("should not allow updating legacy public link without generated password", async () => { + it('should not allow updating legacy public link without generated password', async () => { apiService.getPublicLink = jest.fn().mockResolvedValue({ uid: 'publicLinkUid', - url: "publicLinkUrl", + url: 'publicLinkUrl', }); - await expect(sharingManagement.shareNode(nodeUid, { - publicLink: true, - })).rejects.toThrow("Legacy public link cannot be updated. Please re-create a new public link."); + await expect( + sharingManagement.shareNode(nodeUid, { + publicLink: true, + }), + ).rejects.toThrow('Legacy public link cannot be updated. Please re-create a new public link.'); }); - it("should not allow creating public link with expiration in the past", async () => { + it('should not allow creating public link with expiration in the past', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01')); - await expect(sharingManagement.shareNode(nodeUid, { - publicLink: { - role: MemberRole.Viewer, - expiration: new Date('2024-01-01'), - }, - })).rejects.toThrow("Expiration date cannot be in the past"); + await expect( + sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Viewer, + expiration: new Date('2024-01-01'), + }, + }), + ).rejects.toThrow('Expiration date cannot be in the past'); expect(apiService.createStandardShare).not.toHaveBeenCalled(); expect(apiService.createPublicLink).not.toHaveBeenCalled(); }); }); }); - describe("unshareNode", () => { - const nodeUid = "volumeId~nodeUid"; + describe('unshareNode', () => { + const nodeUid = 'volumeId~nodeUid'; let invitation: ProtonInvitation; let externalInvitation: NonProtonInvitation; @@ -654,49 +760,43 @@ describe("SharingManagement", () => { beforeEach(async () => { invitation = { - uid: "invitation", - addedByEmail: resultOk("added-email"), - inviteeEmail: "internal-email", + uid: 'invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'internal-email', role: MemberRole.Viewer, invitationTime: new Date(), }; externalInvitation = { - uid: "external-invitation", - addedByEmail: resultOk("added-email"), - inviteeEmail: "external-email", + uid: 'external-invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'external-email', role: MemberRole.Viewer, invitationTime: new Date(), state: NonProtonInvitationState.Pending, }; member = { - uid: "member", - addedByEmail: resultOk("added-email"), - inviteeEmail: "member-email", + uid: 'member', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'member-email', role: MemberRole.Viewer, invitationTime: new Date(), }; publicLink = { - uid: "publicLink", + uid: 'publicLink', creationTime: new Date(), role: MemberRole.Viewer, - url: "url", + url: 'url', numberOfInitializedDownloads: 0, - } - - apiService.getShareInvitations = jest.fn().mockResolvedValue([ - invitation, - ]); - apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([ - externalInvitation, - ]); - apiService.getShareMembers = jest.fn().mockResolvedValue([ - member, - ]); + }; + + apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]); + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); + apiService.getShareMembers = jest.fn().mockResolvedValue([member]); apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); }); - it("should delete invitation", async () => { - const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["internal-email"] }); + it('should delete invitation', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ['internal-email'] }); expect(sharingInfo).toEqual({ protonInvitations: [], @@ -711,8 +811,8 @@ describe("SharingManagement", () => { expect(apiService.removePublicLink).not.toHaveBeenCalled(); }); - it("should delete external invitation", async () => { - const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["external-email"] }); + it('should delete external invitation', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ['external-email'] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -727,8 +827,8 @@ describe("SharingManagement", () => { expect(apiService.removePublicLink).not.toHaveBeenCalled(); }); - it("should remove member", async () => { - const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["member-email"] }); + it('should remove member', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ['member-email'] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -743,8 +843,8 @@ describe("SharingManagement", () => { expect(apiService.removePublicLink).not.toHaveBeenCalled(); }); - it("should be no-op if not shared with email", async () => { - const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ["non-existing-email"] }); + it('should be no-op if not shared with email', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ['non-existing-email'] }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -759,8 +859,8 @@ describe("SharingManagement", () => { expect(apiService.removePublicLink).not.toHaveBeenCalled(); }); - it("should remove public link", async () => { - const sharingInfo = await sharingManagement.unshareNode(nodeUid, { publicLink: "remove" }); + it('should remove public link', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { publicLink: 'remove' }); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -775,7 +875,7 @@ describe("SharingManagement", () => { expect(apiService.removePublicLink).toHaveBeenCalled(); }); - it("should remove share if all is removed", async () => { + it('should remove share if all is removed', async () => { const sharingInfo = await sharingManagement.unshareNode(nodeUid); expect(sharingInfo).toEqual(undefined); @@ -787,10 +887,10 @@ describe("SharingManagement", () => { expect(nodesService.notifyNodeChanged).toHaveBeenCalled(); }); - it("should remove share if everything is manually removed", async () => { + it('should remove share if everything is manually removed', async () => { const sharingInfo = await sharingManagement.unshareNode(nodeUid, { - users: ["internal-email", "external-email", "member-email"], - publicLink: "remove", + users: ['internal-email', 'external-email', 'member-email'], + publicLink: 'remove', }); expect(sharingInfo).toEqual(undefined); @@ -802,20 +902,20 @@ describe("SharingManagement", () => { }); }); - describe("resendInvitationEmail", () => { - const nodeUid = "volumeId~nodeUid"; + describe('resendInvitationEmail', () => { + const nodeUid = 'volumeId~nodeUid'; const invitation: ProtonInvitation = { - uid: "invitation", - addedByEmail: resultOk("added-email"), - inviteeEmail: "internal-email", + uid: 'invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'internal-email', role: MemberRole.Viewer, invitationTime: new Date(), }; const externalInvitation: NonProtonInvitation = { - uid: "external-invitation", - addedByEmail: resultOk("added-email"), - inviteeEmail: "external-email", + uid: 'external-invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'external-email', role: MemberRole.Viewer, invitationTime: new Date(), state: NonProtonInvitationState.Pending, @@ -828,35 +928,35 @@ describe("SharingManagement", () => { apiService.getPublicLink = jest.fn().mockResolvedValue(undefined); }); - it("should resend email for proton invitation", async () => { + it('should resend email for proton invitation', async () => { await sharingManagement.resendInvitationEmail(nodeUid, invitation.uid); expect(apiService.resendInvitationEmail).toHaveBeenCalledWith(invitation.uid); expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); }); - it("should resend email for external invitation", async () => { + it('should resend email for external invitation', async () => { await sharingManagement.resendInvitationEmail(nodeUid, externalInvitation.uid); expect(apiService.resendExternalInvitationEmail).toHaveBeenCalledWith(externalInvitation.uid); expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); }); - it("should throw error when no sharing found for node", async () => { + it('should throw error when no sharing found for node', async () => { nodesService.getNode = jest.fn().mockResolvedValue({ nodeUid, shareId: undefined }); - await expect( - sharingManagement.resendInvitationEmail(nodeUid, invitation.uid) - ).rejects.toThrow("Node is not shared"); + await expect(sharingManagement.resendInvitationEmail(nodeUid, invitation.uid)).rejects.toThrow( + 'Node is not shared', + ); expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); }); - it("should log when no invitation found", async () => { - await expect( - sharingManagement.resendInvitationEmail(nodeUid, "non-existent-uid") - ).rejects.toThrow("Invitation not found"); + it('should log when no invitation found', async () => { + await expect(sharingManagement.resendInvitationEmail(nodeUid, 'non-existent-uid')).rejects.toThrow( + 'Invitation not found', + ); expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 70cc267c..b944ef54 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -1,13 +1,25 @@ import { c } from 'ttag'; -import { SessionKey } from "../../crypto"; -import { ValidationError } from "../../errors"; -import { Logger, MemberRole, ShareNodeSettings, UnshareNodeSettings, ShareResult, ProtonInvitation, NonProtonInvitation, Member, resultOk, ProtonDriveAccount, SharePublicLinkSettingsObject } from "../../interface"; -import { splitNodeUid } from "../uids"; +import { SessionKey } from '../../crypto'; +import { ValidationError } from '../../errors'; +import { + Logger, + MemberRole, + ShareNodeSettings, + UnshareNodeSettings, + ShareResult, + ProtonInvitation, + NonProtonInvitation, + Member, + resultOk, + ProtonDriveAccount, + SharePublicLinkSettingsObject, +} from '../../interface'; +import { splitNodeUid } from '../uids'; import { getErrorMessage } from '../errors'; -import { SharingAPIService } from "./apiService"; -import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from "./cryptoService"; -import { SharesService, NodesService, ShareResultWithCreatorEmail, PublicLinkWithCreatorEmail } from "./interface"; +import { SharingAPIService } from './apiService'; +import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService'; +import { SharesService, NodesService, ShareResultWithCreatorEmail, PublicLinkWithCreatorEmail } from './interface'; interface InternalShareResult extends ShareResultWithCreatorEmail { share: Share; @@ -67,24 +79,24 @@ export class SharingManagement { nonProtonInvitations, members, publicLink, - } + }; } - private async* iterateShareInvitations(shareId: string): AsyncGenerator { + private async *iterateShareInvitations(shareId: string): AsyncGenerator { const invitations = await this.apiService.getShareInvitations(shareId); for (const invitation of invitations) { yield this.cryptoService.decryptInvitation(invitation); } } - private async* iterateShareExternalInvitations(shareId: string): AsyncGenerator { + private async *iterateShareExternalInvitations(shareId: string): AsyncGenerator { const invitations = await this.apiService.getShareExternalInvitations(shareId); for (const invitation of invitations) { yield this.cryptoService.decryptExternalInvitation(invitation); } } - private async* iterateShareMembers(shareId: string): AsyncGenerator { + private async *iterateShareMembers(shareId: string): AsyncGenerator { const members = await this.apiService.getShareMembers(shareId); for (const member of members) { yield this.cryptoService.decryptMember(member); @@ -106,9 +118,7 @@ export class SharingManagement { const nonProtonUsers = []; if (settings.users) { for (const user of settings.users) { - const { email, role } = typeof user === "string" - ? { email: user, role: MemberRole.Viewer } - : user; + const { email, role } = typeof user === 'string' ? { email: user, role: MemberRole.Viewer } : user; const isProtonUser = await this.account.hasProtonAccount(email); if (isProtonUser) { protonUsers.push({ email, role }); @@ -120,7 +130,11 @@ export class SharingManagement { // Check if expiration date is in the past before creating share // so if this fails, we don't create empty share. - if (typeof settings.publicLink === 'object' && settings.publicLink.expiration && settings.publicLink.expiration < new Date()) { + if ( + typeof settings.publicLink === 'object' && + settings.publicLink.expiration && + settings.publicLink.expiration < new Date() + ) { throw new ValidationError(c('Error').t`Expiration date cannot be in the past`); } @@ -141,12 +155,14 @@ export class SharingManagement { const emailOptions: EmailOptions = { message: settings.emailOptions?.message, nodeName: settings.emailOptions?.includeNodeName ? currentSharing.nodeName : undefined, - } + }; for (const user of protonUsers) { const { email, role } = user; - const existingInvitation = currentSharing.protonInvitations.find((invitation) => invitation.inviteeEmail === email); + const existingInvitation = currentSharing.protonInvitations.find( + (invitation) => invitation.inviteeEmail === email, + ); if (existingInvitation) { if (existingInvitation.role === role) { this.logger.info(`Invitation for ${email} already exists with role ${role} to node ${nodeUid}`); @@ -178,13 +194,19 @@ export class SharingManagement { for (const user of nonProtonUsers) { const { email, role } = user; - const existingExternalInvitation = currentSharing.nonProtonInvitations.find((invitation) => invitation.inviteeEmail === email); + const existingExternalInvitation = currentSharing.nonProtonInvitations.find( + (invitation) => invitation.inviteeEmail === email, + ); if (existingExternalInvitation) { if (existingExternalInvitation.role === role) { - this.logger.info(`External invitation for ${email} already exists with role ${role} to node ${nodeUid}`); + this.logger.info( + `External invitation for ${email} already exists with role ${role} to node ${nodeUid}`, + ); continue; } - this.logger.info(`External invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`); + this.logger.info( + `External invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`, + ); await this.updateExternalInvitation(existingExternalInvitation.uid, role); existingExternalInvitation.role = role; continue; @@ -208,13 +230,15 @@ export class SharingManagement { } if (settings.publicLink) { - const options = settings.publicLink === true - ? { role: MemberRole.Viewer } - : settings.publicLink; + const options = settings.publicLink === true ? { role: MemberRole.Viewer } : settings.publicLink; if (currentSharing.publicLink) { this.logger.info(`Updating public link with role ${options.role} to node ${nodeUid}`); - currentSharing.publicLink = await this.updateSharedLink(currentSharing.share, currentSharing.publicLink, options); + currentSharing.publicLink = await this.updateSharedLink( + currentSharing.share, + currentSharing.publicLink, + options, + ); } else { this.logger.info(`Sharing via public link with role ${options.role} to node ${nodeUid}`); currentSharing.publicLink = await this.shareViaLink(currentSharing.share, options); @@ -242,19 +266,27 @@ export class SharingManagement { } for (const userEmail of settings.users || []) { - const existingInvitation = currentSharing.protonInvitations.find((invitation) => invitation.inviteeEmail === userEmail); + const existingInvitation = currentSharing.protonInvitations.find( + (invitation) => invitation.inviteeEmail === userEmail, + ); if (existingInvitation) { this.logger.info(`Deleting invitation for ${userEmail} to node ${nodeUid}`); await this.deleteInvitation(existingInvitation.uid); - currentSharing.protonInvitations = currentSharing.protonInvitations.filter((invitation) => invitation.uid !== existingInvitation.uid); + currentSharing.protonInvitations = currentSharing.protonInvitations.filter( + (invitation) => invitation.uid !== existingInvitation.uid, + ); continue; } - const existingExternalInvitation = currentSharing.nonProtonInvitations.find((invitation) => invitation.inviteeEmail === userEmail); + const existingExternalInvitation = currentSharing.nonProtonInvitations.find( + (invitation) => invitation.inviteeEmail === userEmail, + ); if (existingExternalInvitation) { this.logger.info(`Deleting external invitation for ${userEmail} to node ${nodeUid}`); await this.deleteExternalInvitation(existingExternalInvitation.uid); - currentSharing.nonProtonInvitations = currentSharing.nonProtonInvitations.filter((invitation) => invitation.uid !== existingExternalInvitation.uid); + currentSharing.nonProtonInvitations = currentSharing.nonProtonInvitations.filter( + (invitation) => invitation.uid !== existingExternalInvitation.uid, + ); continue; } @@ -297,7 +329,9 @@ export class SharingManagement { // as it might be a race condition that other client updated // the share and it is not empty. // If share is truly empty, backend will delete it eventually. - this.logger.warn(`Failed to delete share ${currentSharing.share.shareId} for node ${nodeUid}: ${getErrorMessage(error)}`); + this.logger.warn( + `Failed to delete share ${currentSharing.share.shareId} for node ${nodeUid}: ${getErrorMessage(error)}`, + ); } return; } @@ -334,7 +368,7 @@ export class SharingManagement { passphraseSessionKey: passphraseSessionKey, }, nodeName: node.name.ok ? node.name.value : node.name.error.name, - } + }; } private async createShare(nodeUid: string): Promise { @@ -348,22 +382,17 @@ export class SharingManagement { const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(nodeUid); const keys = await this.cryptoService.generateShareKeys(nodeKeys, addressKey); - const shareId = await this.apiService.createStandardShare( - nodeUid, - addressId, - keys.shareKey.encrypted, - { - base64PassphraseKeyPacket: keys.base64PpassphraseKeyPacket, - base64NameKeyPacket: keys.base64NameKeyPacket, - }, - ); + const shareId = await this.apiService.createStandardShare(nodeUid, addressId, keys.shareKey.encrypted, { + base64PassphraseKeyPacket: keys.base64PpassphraseKeyPacket, + base64NameKeyPacket: keys.base64NameKeyPacket, + }); await this.nodesService.notifyNodeChanged(nodeUid); return { volumeId, shareId, creatorEmail: email, passphraseSessionKey: keys.shareKey.decrypted.passphraseSessionKey, - } + }; } private async deleteShare(shareId: string, nodeUid: string): Promise { @@ -371,16 +400,29 @@ export class SharingManagement { await this.nodesService.notifyNodeChanged(nodeUid); } - private async inviteProtonUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { + private async inviteProtonUser( + share: Share, + inviteeEmail: string, + role: MemberRole, + emailOptions: EmailOptions, + ): Promise { const inviter = await this.sharesService.getContextShareMemberEmailKey(share.shareId); - const invitationCrypto = await this.cryptoService.encryptInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); + const invitationCrypto = await this.cryptoService.encryptInvitation( + share.passphraseSessionKey, + inviter.addressKey, + inviteeEmail, + ); - const encryptedInvitation = await this.apiService.inviteProtonUser(share.shareId, { - addedByEmail: inviter.email, - inviteeEmail: inviteeEmail, - role, - ...invitationCrypto, - }, emailOptions); + const encryptedInvitation = await this.apiService.inviteProtonUser( + share.shareId, + { + addedByEmail: inviter.email, + inviteeEmail: inviteeEmail, + role, + ...invitationCrypto, + }, + emailOptions, + ); return { ...encryptedInvitation, @@ -404,7 +446,9 @@ export class SharingManagement { return await this.apiService.resendInvitationEmail(protonInvite.uid); } - const nonProtonInvite = currentSharing.nonProtonInvitations.find((invitation) => invitation.uid === invitationUid); + const nonProtonInvite = currentSharing.nonProtonInvitations.find( + (invitation) => invitation.uid === invitationUid, + ); if (nonProtonInvite) { return await this.apiService.resendExternalInvitationEmail(nonProtonInvite.uid); } @@ -416,16 +460,29 @@ export class SharingManagement { await this.apiService.deleteInvitation(invitationUid); } - private async inviteExternalUser(share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions): Promise { + private async inviteExternalUser( + share: Share, + inviteeEmail: string, + role: MemberRole, + emailOptions: EmailOptions, + ): Promise { const inviter = await this.sharesService.getContextShareMemberEmailKey(share.shareId); - const invitationCrypto = await this.cryptoService.encryptExternalInvitation(share.passphraseSessionKey, inviter.addressKey, inviteeEmail); + const invitationCrypto = await this.cryptoService.encryptExternalInvitation( + share.passphraseSessionKey, + inviter.addressKey, + inviteeEmail, + ); - const encryptedInvitation = await this.apiService.inviteExternalUser(share.shareId, { - inviterAddressId: inviter.addressId, - inviteeEmail: inviteeEmail, - role, - base64Signature: invitationCrypto.base64ExternalInvitationSignature, - }, emailOptions); + const encryptedInvitation = await this.apiService.inviteExternalUser( + share.shareId, + { + inviterAddressId: inviter.addressId, + inviteeEmail: inviteeEmail, + role, + base64Signature: invitationCrypto.base64ExternalInvitationSignature, + }, + emailOptions, + ); return { uid: encryptedInvitation.uid, @@ -441,7 +498,6 @@ export class SharingManagement { await this.apiService.updateExternalInvitation(invitationUid, { role }); } - private async deleteExternalInvitation(invitationUid: string): Promise { await this.apiService.deleteExternalInvitation(invitationUid); } @@ -458,13 +514,20 @@ export class SharingManagement { await this.apiService.updateMember(memberUid, { role }); } - private async shareViaLink(share: Share, options: SharePublicLinkSettingsObject): Promise { + private async shareViaLink( + share: Share, + options: SharePublicLinkSettingsObject, + ): Promise { const { email: creatorEmail } = await this.sharesService.getContextShareMemberEmailKey(share.shareId); const generatedPassword = await this.cryptoService.generatePublicLinkPassword(); const password = options.customPassword ? `${generatedPassword}${options.customPassword}` : generatedPassword; - const { crypto, srp } = await this.cryptoService.encryptPublicLink(creatorEmail, share.passphraseSessionKey, password); + const { crypto, srp } = await this.cryptoService.encryptPublicLink( + creatorEmail, + share.passphraseSessionKey, + password, + ); const publicLink = await this.apiService.createPublicLink(share.shareId, { creatorEmail, role: options.role, @@ -483,18 +546,28 @@ export class SharingManagement { expirationTime: options.expiration, numberOfInitializedDownloads: 0, creatorEmail, - } + }; } - private async updateSharedLink(share: Share, publicLink: PublicLinkWithCreatorEmail, options: SharePublicLinkSettingsObject): Promise { + private async updateSharedLink( + share: Share, + publicLink: PublicLinkWithCreatorEmail, + options: SharePublicLinkSettingsObject, + ): Promise { const generatedPassword = publicLink.url.split('#')[1]; // Legacy public links didn't have generated password or had various lengths. if (!generatedPassword || generatedPassword.length !== PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) { - throw new ValidationError(c('Error').t`Legacy public link cannot be updated. Please re-create a new public link.`); + throw new ValidationError( + c('Error').t`Legacy public link cannot be updated. Please re-create a new public link.`, + ); } const password = options.customPassword ? `${generatedPassword}${options.customPassword}` : generatedPassword; - const { crypto, srp } = await this.cryptoService.encryptPublicLink(publicLink.creatorEmail, share.passphraseSessionKey, password); + const { crypto, srp } = await this.cryptoService.encryptPublicLink( + publicLink.creatorEmail, + share.passphraseSessionKey, + password, + ); await this.apiService.updatePublicLink(publicLink.uid, { role: options.role, includesCustomPassword: !!options.customPassword, @@ -508,7 +581,7 @@ export class SharingManagement { role: options.role, customPassword: options.customPassword, expirationTime: options.expiration, - } + }; } private async removeSharedLink(publicLinkUid: string): Promise { diff --git a/js/sdk/src/internal/uids.ts b/js/sdk/src/internal/uids.ts index e7fe6ae1..3b9fe035 100644 --- a/js/sdk/src/internal/uids.ts +++ b/js/sdk/src/internal/uids.ts @@ -7,7 +7,7 @@ export function splitDeviceUid(deviceUid: string) { if (parts.length !== 2) { throw new Error(`"${deviceUid}" is not valid device UID`); } - const [ volumeId, deviceId ] = parts; + const [volumeId, deviceId] = parts; return { volumeId, deviceId }; } diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 494ab9f5..e77ff1e8 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -1,54 +1,85 @@ -import { c } from "ttag"; +import { c } from 'ttag'; -import { base64StringToUint8Array, uint8ArrayToBase64String } from "../../crypto"; -import { APICodeError, DriveAPIService, drivePaths, isCodeOk } from "../apiService"; -import { splitNodeUid, makeNodeUid, splitNodeRevisionUid, makeNodeRevisionUid } from "../uids"; -import { UploadTokens } from "./interface"; -import { ThumbnailType } from "../../interface"; +import { base64StringToUint8Array, uint8ArrayToBase64String } from '../../crypto'; +import { APICodeError, DriveAPIService, drivePaths, isCodeOk } from '../apiService'; +import { splitNodeUid, makeNodeUid, splitNodeRevisionUid, makeNodeRevisionUid } from '../uids'; +import { UploadTokens } from './interface'; +import { ThumbnailType } from '../../interface'; -type PostCheckAvailableHashesRequest = Extract['content']['application/json']; -type PostCheckAvailableHashesResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['responses']['200']['content']['application/json']; +type PostCheckAvailableHashesRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCheckAvailableHashesResponse = + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['responses']['200']['content']['application/json']; -type PostCreateDraftRequest = Extract['content']['application/json']; -type PostCreateDraftResponse = drivePaths['/drive/v2/volumes/{volumeID}/files']['post']['responses']['200']['content']['application/json']; +type PostCreateDraftRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/files']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateDraftResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files']['post']['responses']['200']['content']['application/json']; -type PostCreateDraftRevisionRequest = Extract['content']['application/json']; -type PostCreateDraftRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions']['post']['responses']['200']['content']['application/json']; +type PostCreateDraftRevisionRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateDraftRevisionResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions']['post']['responses']['200']['content']['application/json']; -type GetVerificationDataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification']['get']['responses']['200']['content']['application/json']; +type GetVerificationDataResponse = + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification']['get']['responses']['200']['content']['application/json']; -type PostRequestBlockUploadRequest = Extract['content']['application/json']; -type PostRequestBlockUploadResponse = drivePaths['/drive/blocks']['post']['responses']['200']['content']['application/json']; +type PostRequestBlockUploadRequest = Extract< + drivePaths['/drive/blocks']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostRequestBlockUploadResponse = + drivePaths['/drive/blocks']['post']['responses']['200']['content']['application/json']; -type PostCommitRevisionRequest = Extract['content']['application/json']; -type PostCommitRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['put']['responses']['200']['content']['application/json']; +type PostCommitRevisionRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCommitRevisionResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['put']['responses']['200']['content']['application/json']; -type PostDeleteNodesRequest = Extract['content']['application/json']; -type PostDeleteNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/delete_multiple']['post']['responses']['200']['content']['application/json']; +type PostDeleteNodesRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/delete_multiple']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostDeleteNodesResponse = + drivePaths['/drive/v2/volumes/{volumeID}/delete_multiple']['post']['responses']['200']['content']['application/json']; export class UploadAPIService { - constructor(private apiService: DriveAPIService, private clientUid: string | undefined) { + constructor( + private apiService: DriveAPIService, + private clientUid: string | undefined, + ) { this.apiService = apiService; this.clientUid = clientUid; } - async checkAvailableHashes(parentNodeUid: string, hashes: string[]): Promise<{ - availalbleHashes: string[], + async checkAvailableHashes( + parentNodeUid: string, + hashes: string[], + ): Promise<{ + availalbleHashes: string[]; pendingHashes: { - hash: string, - nodeUid: string, - revisionUid: string, - clientUid?: string, - }[], + hash: string; + nodeUid: string; + revisionUid: string; + clientUid?: string; + }[]; }> { const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); - const result = await this.apiService.post< - PostCheckAvailableHashesRequest, - PostCheckAvailableHashesResponse - >(`drive/v2/volumes/${volumeId}/links/${parentNodeId}/checkAvailableHashes`, { - Hashes: hashes, - ClientUID: this.clientUid ? [this.clientUid] : null, - }); + const result = await this.apiService.post( + `drive/v2/volumes/${volumeId}/links/${parentNodeId}/checkAvailableHashes`, + { + Hashes: hashes, + ClientUID: this.clientUid ? [this.clientUid] : null, + }, + ); return { availalbleHashes: result.AvailableHashes, @@ -58,101 +89,111 @@ export class UploadAPIService { revisionUid: makeNodeRevisionUid(volumeId, hash.LinkID, hash.RevisionID), clientUid: hash.ClientUID || undefined, })), - } + }; } - async createDraft(parentNodeUid: string, node: { - armoredEncryptedName: string, - hash: string, - mediaType: string, - intendedUploadSize?: number, - armoredNodeKey: string, - armoredNodePassphrase: string, - armoredNodePassphraseSignature: string, - base64ContentKeyPacket: string, - armoredContentKeyPacketSignature: string, - signatureEmail: string, - }): Promise<{ - nodeUid: string, - nodeRevisionUid: string, + async createDraft( + parentNodeUid: string, + node: { + armoredEncryptedName: string; + hash: string; + mediaType: string; + intendedUploadSize?: number; + armoredNodeKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature: string; + signatureEmail: string; + }, + ): Promise<{ + nodeUid: string; + nodeRevisionUid: string; }> { const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); - const result = await this.apiService.post< - PostCreateDraftRequest, - PostCreateDraftResponse - >(`drive/v2/volumes/${volumeId}/files`, { - ParentLinkID: parentNodeId, - Name: node.armoredEncryptedName, - Hash: node.hash, - MIMEType: node.mediaType, - ClientUID: this.clientUid || null, - IntendedUploadSize: node.intendedUploadSize || null, - NodeKey: node.armoredNodeKey, - NodePassphrase: node.armoredNodePassphrase, - NodePassphraseSignature: node.armoredNodePassphraseSignature, - ContentKeyPacket: node.base64ContentKeyPacket, - ContentKeyPacketSignature: node.armoredContentKeyPacketSignature, - SignatureAddress: node.signatureEmail, - }); + const result = await this.apiService.post( + `drive/v2/volumes/${volumeId}/files`, + { + ParentLinkID: parentNodeId, + Name: node.armoredEncryptedName, + Hash: node.hash, + MIMEType: node.mediaType, + ClientUID: this.clientUid || null, + IntendedUploadSize: node.intendedUploadSize || null, + NodeKey: node.armoredNodeKey, + NodePassphrase: node.armoredNodePassphrase, + NodePassphraseSignature: node.armoredNodePassphraseSignature, + ContentKeyPacket: node.base64ContentKeyPacket, + ContentKeyPacketSignature: node.armoredContentKeyPacketSignature, + SignatureAddress: node.signatureEmail, + }, + ); return { nodeUid: makeNodeUid(volumeId, result.File.ID), nodeRevisionUid: makeNodeRevisionUid(volumeId, result.File.ID, result.File.RevisionID), - } + }; } - async createDraftRevision(nodeUid: string, revision: { - currentRevisionUid: string, - intendedUploadSize?: number, - }): Promise<{ - nodeRevisionUid: string, + async createDraftRevision( + nodeUid: string, + revision: { + currentRevisionUid: string; + intendedUploadSize?: number; + }, + ): Promise<{ + nodeRevisionUid: string; }> { const { volumeId, nodeId } = splitNodeUid(nodeUid); const { revisionId: currentRevisionId } = splitNodeRevisionUid(revision.currentRevisionUid); - - const result = await this.apiService.post< - PostCreateDraftRevisionRequest, - PostCreateDraftRevisionResponse - >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, { - CurrentRevisionID: currentRevisionId, - ClientUID: this.clientUid || null, - IntendedUploadSize: revision.intendedUploadSize || null, - }); + + const result = await this.apiService.post( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, + { + CurrentRevisionID: currentRevisionId, + ClientUID: this.clientUid || null, + IntendedUploadSize: revision.intendedUploadSize || null, + }, + ); return { nodeRevisionUid: makeNodeRevisionUid(volumeId, nodeId, result.Revision.ID), - } + }; } async getVerificationData(draftNodeRevisionUid: string): Promise<{ - verificationCode: Uint8Array, - base64ContentKeyPacket: string, + verificationCode: Uint8Array; + base64ContentKeyPacket: string; }> { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); - const result = await this.apiService.get< - GetVerificationDataResponse - >(`drive/v2/volumes/${volumeId}/links/${nodeId}/revisions/${revisionId}/verification`); - + const result = await this.apiService.get( + `drive/v2/volumes/${volumeId}/links/${nodeId}/revisions/${revisionId}/verification`, + ); + return { verificationCode: base64StringToUint8Array(result.VerificationCode), base64ContentKeyPacket: result.ContentKeyPacket, - } + }; } - async requestBlockUpload(draftNodeRevisionUid: string, addressId: string, blocks: { - contentBlocks: { - index: number, - hash: Uint8Array, - encryptedSize: number, - armoredSignature: string, - verificationToken: Uint8Array, - }[], - thumbnails?: { - type: ThumbnailType, - hash: Uint8Array, - encryptedSize: number, - }[], - }): Promise { + async requestBlockUpload( + draftNodeRevisionUid: string, + addressId: string, + blocks: { + contentBlocks: { + index: number; + hash: Uint8Array; + encryptedSize: number; + armoredSignature: string; + verificationToken: Uint8Array; + }[]; + thumbnails?: { + type: ThumbnailType; + hash: Uint8Array; + encryptedSize: number; + }[]; + }, + ): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); const result = await this.apiService.post< // TODO: Deprected fields but not properly marked in the types. @@ -194,11 +235,14 @@ export class UploadAPIService { }; } - async commitDraftRevision(draftNodeRevisionUid: string, options: { - armoredManifestSignature: string, - signatureEmail: string, - armoredExtendedAttributes?: string, - }): Promise { + async commitDraftRevision( + draftNodeRevisionUid: string, + options: { + armoredManifestSignature: string; + signatureEmail: string; + armoredExtendedAttributes?: string; + }, + ): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); await this.apiService.put< // TODO: Deprected fields but not properly marked in the types. @@ -215,16 +259,16 @@ export class UploadAPIService { async deleteDraft(draftNodeUid: string): Promise { const { volumeId, nodeId } = splitNodeUid(draftNodeUid); - const response = await this.apiService.post< - PostDeleteNodesRequest, - PostDeleteNodesResponse - >(`drive/v2/volumes/${volumeId}/delete_multiple`, { - LinkIDs: [nodeId], - }); + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/delete_multiple`, + { + LinkIDs: [nodeId], + }, + ); const code = response.Responses?.[0].Response.Code || 0; if (!isCodeOk(code)) { - throw new APICodeError(c('Error').t`Unknown error ${code}`, code) + throw new APICodeError(c('Error').t`Unknown error ${code}`, code); } } @@ -233,9 +277,15 @@ export class UploadAPIService { await this.apiService.delete(`/drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`); } - async uploadBlock(url: string, token: string, block: Uint8Array, onProgress?: (uploadedBytes: number) => void, signal?: AbortSignal): Promise { + async uploadBlock( + url: string, + token: string, + block: Uint8Array, + onProgress?: (uploadedBytes: number) => void, + signal?: AbortSignal, + ): Promise { const formData = new FormData(); - formData.append("Block", new Blob([block]), "blob"); + formData.append('Block', new Blob([block]), 'blob'); await this.apiService.postBlockStream(url, token, formData, onProgress, signal); } diff --git a/js/sdk/src/internal/upload/blockVerifier.ts b/js/sdk/src/internal/upload/blockVerifier.ts index d824b237..672817ad 100644 --- a/js/sdk/src/internal/upload/blockVerifier.ts +++ b/js/sdk/src/internal/upload/blockVerifier.ts @@ -1,6 +1,6 @@ -import { PrivateKey, SessionKey } from "../../crypto"; -import { UploadAPIService } from "./apiService"; -import { UploadCryptoService } from "./cryptoService"; +import { PrivateKey, SessionKey } from '../../crypto'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; export class BlockVerifier { private verificationCode?: Uint8Array; @@ -20,11 +20,14 @@ export class BlockVerifier { async loadVerificationData() { const result = await this.apiService.getVerificationData(this.draftNodeRevisionUid); this.verificationCode = result.verificationCode; - this.contentKeyPacketSessionKey = await this.cryptoService.getContentKeyPacketSessionKey(this.nodeKey, result.base64ContentKeyPacket); + this.contentKeyPacketSessionKey = await this.cryptoService.getContentKeyPacketSessionKey( + this.nodeKey, + result.base64ContentKeyPacket, + ); } async verifyBlock(encryptedBlock: Uint8Array): Promise<{ - verificationToken: Uint8Array, + verificationToken: Uint8Array; }> { if (!this.verificationCode || !this.contentKeyPacketSessionKey) { throw new Error('Verifying block before loading verification data'); diff --git a/js/sdk/src/internal/upload/chunkStreamReader.test.ts b/js/sdk/src/internal/upload/chunkStreamReader.test.ts index 1eec5351..7dae8620 100644 --- a/js/sdk/src/internal/upload/chunkStreamReader.test.ts +++ b/js/sdk/src/internal/upload/chunkStreamReader.test.ts @@ -1,6 +1,6 @@ -import { ChunkStreamReader } from "./chunkStreamReader"; +import { ChunkStreamReader } from './chunkStreamReader'; -describe("ChunkStreamReader", () => { +describe('ChunkStreamReader', () => { let stream: ReadableStream; beforeEach(() => { @@ -15,7 +15,7 @@ describe("ChunkStreamReader", () => { }); }); - it("should yield chunks as enqueued if matching the size", async () => { + it('should yield chunks as enqueued if matching the size', async () => { const reader = new ChunkStreamReader(stream, 3); const chunks: Uint8Array[] = []; @@ -30,7 +30,7 @@ describe("ChunkStreamReader", () => { expect(chunks[3]).toEqual(new Uint8Array([10, 11, 12])); }); - it("should yield smaller chunks than enqueued chunks", async () => { + it('should yield smaller chunks than enqueued chunks', async () => { const reader = new ChunkStreamReader(stream, 2); const chunks: Uint8Array[] = []; @@ -47,7 +47,7 @@ describe("ChunkStreamReader", () => { expect(chunks[5]).toEqual(new Uint8Array([11, 12])); }); - it("should yield bigger chunks than enqueued chunks", async () => { + it('should yield bigger chunks than enqueued chunks', async () => { const reader = new ChunkStreamReader(stream, 4); const chunks: Uint8Array[] = []; @@ -61,7 +61,7 @@ describe("ChunkStreamReader", () => { expect(chunks[2]).toEqual(new Uint8Array([9, 10, 11, 12])); }); - it("should yield last incomplete chunk", async () => { + it('should yield last incomplete chunk', async () => { const reader = new ChunkStreamReader(stream, 5); const chunks: Uint8Array[] = []; @@ -75,7 +75,7 @@ describe("ChunkStreamReader", () => { expect(chunks[2]).toEqual(new Uint8Array([11, 12])); }); - it("should yield as one big chunk", async () => { + it('should yield as one big chunk', async () => { const reader = new ChunkStreamReader(stream, 100); const chunks: Uint8Array[] = []; diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 2eaab290..cecd35e1 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -1,9 +1,9 @@ -import { c } from "ttag"; +import { c } from 'ttag'; -import { DriveCrypto, PrivateKey, SessionKey } from "../../crypto"; -import { IntegrityError } from "../../errors"; -import { Thumbnail } from "../../interface"; -import { EncryptedBlock, EncryptedThumbnail, NodeCrypto, NodeRevisionDraftKeys, NodesService } from "./interface"; +import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto'; +import { IntegrityError } from '../../errors'; +import { Thumbnail } from '../../interface'; +import { EncryptedBlock, EncryptedThumbnail, NodeCrypto, NodeRevisionDraftKeys, NodesService } from './interface'; export class UploadCryptoService { constructor( @@ -16,16 +16,12 @@ export class UploadCryptoService { async generateFileCrypto( parentUid: string, - parentKeys: { key: PrivateKey, hashKey: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, name: string, ): Promise { const signatureAddress = await this.nodesService.getRootNodeEmailKey(parentUid); - const [ - nodeKeys, - { armoredNodeName }, - hash, - ] = await Promise.all([ + const [nodeKeys, { armoredNodeName }, hash] = await Promise.all([ this.driveCrypto.generateKey([parentKeys.key], signatureAddress.addressKey), this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, signatureAddress.addressKey), this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), @@ -44,19 +40,24 @@ export class UploadCryptoService { }; } - async generateNameHashes(parentHashKey: Uint8Array, names: string[]): Promise<{ name: string, hash: string }[]> { - return Promise.all(names.map(async (name) => ({ - name, - hash: await this.driveCrypto.generateLookupHash(name, parentHashKey) - }))); + async generateNameHashes(parentHashKey: Uint8Array, names: string[]): Promise<{ name: string; hash: string }[]> { + return Promise.all( + names.map(async (name) => ({ + name, + hash: await this.driveCrypto.generateLookupHash(name, parentHashKey), + })), + ); } - async encryptThumbnail(nodeRevisionDraftKeys: NodeRevisionDraftKeys, thumbnail: Thumbnail): Promise { + async encryptThumbnail( + nodeRevisionDraftKeys: NodeRevisionDraftKeys, + thumbnail: Thumbnail, + ): Promise { const { encryptedData } = await this.driveCrypto.encryptThumbnailBlock( thumbnail.thumbnail, nodeRevisionDraftKeys.contentKeyPacketSessionKey, nodeRevisionDraftKeys.signatureAddress.addressKey, - ) + ); const digest = await crypto.subtle.digest('SHA-256', encryptedData); @@ -66,7 +67,7 @@ export class UploadCryptoService { originalSize: thumbnail.thumbnail.length, encryptedSize: encryptedData.length, hash: new Uint8Array(digest), - } + }; } async encryptBlock( @@ -93,25 +94,36 @@ export class UploadCryptoService { originalSize: block.length, encryptedSize: encryptedData.length, hash: new Uint8Array(digest), - } + }; } - async commitFile(nodeRevisionDraftKeys: NodeRevisionDraftKeys, manifest: Uint8Array, extendedAttributes?: string): Promise<{ - armoredManifestSignature: string, - signatureEmail: string, - armoredExtendedAttributes?: string, + async commitFile( + nodeRevisionDraftKeys: NodeRevisionDraftKeys, + manifest: Uint8Array, + extendedAttributes?: string, + ): Promise<{ + armoredManifestSignature: string; + signatureEmail: string; + armoredExtendedAttributes?: string; }> { - const { armoredManifestSignature } = await this.driveCrypto.signManifest(manifest, nodeRevisionDraftKeys.signatureAddress.addressKey); + const { armoredManifestSignature } = await this.driveCrypto.signManifest( + manifest, + nodeRevisionDraftKeys.signatureAddress.addressKey, + ); const { armoredExtendedAttributes } = extendedAttributes - ? await this.driveCrypto.encryptExtendedAttributes(extendedAttributes, nodeRevisionDraftKeys.key, nodeRevisionDraftKeys.signatureAddress.addressKey) + ? await this.driveCrypto.encryptExtendedAttributes( + extendedAttributes, + nodeRevisionDraftKeys.key, + nodeRevisionDraftKeys.signatureAddress.addressKey, + ) : { armoredExtendedAttributes: undefined }; return { armoredManifestSignature, signatureEmail: nodeRevisionDraftKeys.signatureAddress.email, armoredExtendedAttributes, - } + }; } async getContentKeyPacketSessionKey(nodeKey: PrivateKey, base64ContentKeyPacket: string): Promise { @@ -130,7 +142,7 @@ export class UploadCryptoService { verificationCode: Uint8Array, encryptedData: Uint8Array, ): Promise<{ - verificationToken: Uint8Array, + verificationToken: Uint8Array; }> { // Attempt to decrypt data block, to try to detect bitflips / bad hardware // @@ -140,12 +152,7 @@ export class UploadCryptoService { // Additionally, we use the key provided by the verification endpoint, to // ensure the correct key was used to encrypt the data try { - await this.driveCrypto.decryptBlock( - encryptedData, - undefined, - nodeKey, - contentKeyPacketSessionKey, - ); + await this.driveCrypto.decryptBlock(encryptedData, undefined, nodeKey, contentKeyPacketSessionKey); } catch (error) { throw new IntegrityError(c('Error').t`Data integrity check of one part failed`, { error, @@ -157,6 +164,6 @@ export class UploadCryptoService { const verificationToken = verificationCode.map((value, index) => value ^ (encryptedData[index] || 0)); return { verificationToken, - } + }; } } diff --git a/js/sdk/src/internal/upload/digests.ts b/js/sdk/src/internal/upload/digests.ts index 9592300a..03710fc9 100644 --- a/js/sdk/src/internal/upload/digests.ts +++ b/js/sdk/src/internal/upload/digests.ts @@ -1,4 +1,4 @@ -import { sha1 } from "@noble/hashes/legacy"; +import { sha1 } from '@noble/hashes/legacy'; import { bytesToHex } from '@noble/hashes/utils'; export class UploadDigests { @@ -13,6 +13,6 @@ export class UploadDigests { digests(): { sha1: string } { return { sha1: bytesToHex(this.digestSha1.digest()), - } + }; } } diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index c48b18f3..75a01b93 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -10,7 +10,12 @@ import { UploadManager } from './manager'; const BLOCK_ENCRYPTION_OVERHEAD = 10000; -async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise, _: any, block: Uint8Array, index: number) { +async function mockEncryptBlock( + verifyBlock: (block: Uint8Array) => Promise, + _: any, + block: Uint8Array, + index: number, +) { await verifyBlock(block); return { index, @@ -23,7 +28,12 @@ async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise void) { +function mockUploadBlock( + _: string, + __: string, + encryptedBlock: Uint8Array, + onProgress: (uploadedBytes: number) => void, +) { onProgress(encryptedBlock.length); } @@ -177,7 +187,9 @@ describe('FileUploader', () => { it('should throw an error if upload already started', async () => { await uploader.writeStream(stream, thumbnails, onProgress); - await expect(uploader.writeStream(stream, thumbnails, onProgress)).rejects.toThrow('Upload already started'); + await expect(uploader.writeStream(stream, thumbnails, onProgress)).rejects.toThrow( + 'Upload already started', + ); }); }); }); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index be09506c..e86219b7 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -1,10 +1,10 @@ -import { Thumbnail, UploadMetadata } from "../../interface"; -import { UploadAPIService } from "./apiService"; -import { BlockVerifier } from "./blockVerifier"; +import { Thumbnail, UploadMetadata } from '../../interface'; +import { UploadAPIService } from './apiService'; +import { BlockVerifier } from './blockVerifier'; import { UploadController } from './controller'; -import { UploadCryptoService } from "./cryptoService"; -import { NodeRevisionDraft } from "./interface"; -import { UploadManager } from "./manager"; +import { UploadCryptoService } from './cryptoService'; +import { NodeRevisionDraft } from './interface'; +import { UploadManager } from './manager'; import { StreamUploader } from './streamUploader'; import { UploadTelemetry } from './telemetry'; @@ -46,7 +46,11 @@ class Uploader { this.controller = new UploadController(); } - async writeFile(fileObject: File, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { + async writeFile( + fileObject: File, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise { if (this.controller.promise) { throw new Error(`Upload already started`); } @@ -63,7 +67,11 @@ class Uploader { return this.controller; } - async writeStream(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { + async writeStream( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise { if (this.controller.promise) { throw new Error(`Upload already started`); } @@ -71,7 +79,11 @@ class Uploader { return this.controller; } - protected async startUpload(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { + protected async startUpload( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise { const uploader = await this.initStreamUploader(); return uploader.start(stream, thumbnails, onProgress); } @@ -84,7 +96,7 @@ class Uploader { if (failure) { await this.manager.deleteDraftNode(revisionDraft.nodeUid); } - } + }; return new StreamUploader( this.telemetry, @@ -99,7 +111,7 @@ class Uploader { ); } - protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft, blockVerifier: BlockVerifier }> { + protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft; blockVerifier: BlockVerifier }> { throw new Error('Not implemented'); } } @@ -125,12 +137,17 @@ export class FileUploader extends Uploader { this.name = name; } - protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft, blockVerifier: BlockVerifier }> { + protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft; blockVerifier: BlockVerifier }> { let revisionDraft, blockVerifier; try { revisionDraft = await this.manager.createDraftNode(this.parentFolderUid, this.name, this.metadata); - blockVerifier = new BlockVerifier(this.apiService, this.cryptoService, revisionDraft.nodeKeys.key, revisionDraft.nodeRevisionUid); + blockVerifier = new BlockVerifier( + this.apiService, + this.cryptoService, + revisionDraft.nodeKeys.key, + revisionDraft.nodeRevisionUid, + ); await blockVerifier.loadVerificationData(); } catch (error: unknown) { this.onFinish(); @@ -144,7 +161,7 @@ export class FileUploader extends Uploader { return { revisionDraft, blockVerifier, - } + }; } async getAvailableName(): Promise { @@ -172,12 +189,17 @@ export class FileRevisionUploader extends Uploader { this.nodeUid = nodeUid; } - protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft, blockVerifier: BlockVerifier }> { + protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft; blockVerifier: BlockVerifier }> { let revisionDraft, blockVerifier; try { revisionDraft = await this.manager.createDraftRevision(this.nodeUid, this.metadata); - blockVerifier = new BlockVerifier(this.apiService, this.cryptoService, revisionDraft.nodeKeys.key, revisionDraft.nodeRevisionUid); + blockVerifier = new BlockVerifier( + this.apiService, + this.cryptoService, + revisionDraft.nodeKeys.key, + revisionDraft.nodeRevisionUid, + ); await blockVerifier.loadVerificationData(); } catch (error: unknown) { this.onFinish(); @@ -191,6 +213,6 @@ export class FileRevisionUploader extends Uploader { return { revisionDraft, blockVerifier, - } + }; } } diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 48b345be..0abfcb2d 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -1,13 +1,13 @@ -import { ProtonDriveTelemetry, UploadMetadata } from "../../interface"; -import { DriveAPIService } from "../apiService"; -import { DriveCrypto } from "../../crypto"; -import { UploadAPIService } from "./apiService"; -import { UploadCryptoService } from "./cryptoService"; -import { FileUploader, FileRevisionUploader } from "./fileUploader"; -import { NodesService, SharesService } from "./interface"; -import { UploadManager } from "./manager"; -import { UploadQueue } from "./queue"; -import { UploadTelemetry } from "./telemetry"; +import { ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { DriveCrypto } from '../../crypto'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { FileUploader, FileRevisionUploader } from './fileUploader'; +import { NodesService, SharesService } from './interface'; +import { UploadManager } from './manager'; +import { UploadQueue } from './queue'; +import { UploadTelemetry } from './telemetry'; /** * Provides facade for the upload module. @@ -49,7 +49,7 @@ export function initUploadModule( const onFinish = () => { queue.releaseCapacity(); - } + }; return new FileUploader( uploadTelemetry, @@ -80,7 +80,7 @@ export function initUploadModule( const onFinish = () => { queue.releaseCapacity(); - } + }; return new FileRevisionUploader( uploadTelemetry, @@ -92,11 +92,10 @@ export function initUploadModule( onFinish, signal, ); - } return { getFileUploader, getFileRevisionUploader, - } + }; } diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 82b21369..65442348 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,110 +1,110 @@ -import { PrivateKey, SessionKey } from "../../crypto"; +import { PrivateKey, SessionKey } from '../../crypto'; -import { MetricVolumeType, ThumbnailType, Result, Revision } from "../../interface"; -import { DecryptedNode } from "../nodes"; +import { MetricVolumeType, ThumbnailType, Result, Revision } from '../../interface'; +import { DecryptedNode } from '../nodes'; export type NodeRevisionDraft = { - nodeUid: string, - nodeRevisionUid: string, - nodeKeys: NodeRevisionDraftKeys, + nodeUid: string; + nodeRevisionUid: string; + nodeKeys: NodeRevisionDraftKeys; // newNodeInfo is set only when revision is created with the new node. newNodeInfo?: { - parentUid: string, - name: string, - encryptedName: string, - hash: string, - } -} + parentUid: string; + name: string; + encryptedName: string; + hash: string; + }; +}; export type NodeRevisionDraftKeys = { - key: PrivateKey, - contentKeyPacketSessionKey: SessionKey, - signatureAddress: NodeCryptoSignatureAddress, -} + key: PrivateKey; + contentKeyPacketSessionKey: SessionKey; + signatureAddress: NodeCryptoSignatureAddress; +}; export type NodeCrypto = { nodeKeys: { encrypted: { - armoredKey: string, - armoredPassphrase: string, - armoredPassphraseSignature: string, - }, + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; decrypted: { - passphrase: string, - key: PrivateKey, - passphraseSessionKey: SessionKey, - }, - }, + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + }; + }; contentKey: { encrypted: { - base64ContentKeyPacket: string, - armoredContentKeyPacketSignature: string, - }, + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature: string; + }; decrypted: { - contentKeyPacketSessionKey: SessionKey, - }, - }, + contentKeyPacketSessionKey: SessionKey; + }; + }; encryptedNode: { - encryptedName: string, - hash: string, - }, - signatureAddress: NodeCryptoSignatureAddress, -} + encryptedName: string; + hash: string; + }; + signatureAddress: NodeCryptoSignatureAddress; +}; export type NodeCryptoSignatureAddress = { - email: string, - addressId: string, - addressKey: PrivateKey, -} + email: string; + addressId: string; + addressKey: PrivateKey; +}; export type EncryptedBlockMetadata = { - encryptedSize: number, - originalSize: number, - hash: Uint8Array, -} + encryptedSize: number; + originalSize: number; + hash: Uint8Array; +}; export type EncryptedBlock = EncryptedBlockMetadata & { - index: number, - encryptedData: Uint8Array, - armoredSignature: string, - verificationToken: Uint8Array, -} + index: number; + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; +}; export type EncryptedThumbnail = EncryptedBlockMetadata & { - type: ThumbnailType, - encryptedData: Uint8Array, -} + type: ThumbnailType; + encryptedData: Uint8Array; +}; export type UploadTokens = { blockTokens: { - index: number, - bareUrl: string, - token: string, - }[], + index: number; + bareUrl: string; + token: string; + }[]; thumbnailTokens: { - type: ThumbnailType, - bareUrl: string, - token: string, - }[], -} + type: ThumbnailType; + bareUrl: string; + token: string; + }[]; +}; /** * Interface describing the dependencies to the nodes module. */ export interface NodesService { - getNode(nodeUid: string): Promise, + getNode(nodeUid: string): Promise; getNodeKeys(nodeUid: string): Promise<{ - key: PrivateKey, - passphraseSessionKey: SessionKey, - contentKeyPacketSessionKey?: SessionKey, - hashKey?: Uint8Array, - }>, + key: PrivateKey; + passphraseSessionKey: SessionKey; + contentKeyPacketSessionKey?: SessionKey; + hashKey?: Uint8Array; + }>; getRootNodeEmailKey(nodeUid: string): Promise<{ - email: string, - addressId: string, - addressKey: PrivateKey, - addressKeyId: string, - }>, + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }>; notifyChildCreated(nodeUid: string): Promise; } @@ -112,19 +112,19 @@ export interface NodesService { * Interface describing the dependencies to the nodes module. */ export interface NodesEvents { - nodeCreated(node: DecryptedNode): Promise, - nodeUpdated(partialNode: { uid: string, activeRevision: Result }): Promise, + nodeCreated(node: DecryptedNode): Promise; + nodeUpdated(partialNode: { uid: string; activeRevision: Result }): Promise; } export interface NodesServiceNode { - uid: string, - parentUid?: string, - activeRevision?: Result, + uid: string; + parentUid?: string; + activeRevision?: Result; } /** * Interface describing the dependencies to the shares module. */ export interface SharesService { - getVolumeMetricContext(volumeId: string): Promise, + getVolumeMetricContext(volumeId: string): Promise; } diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 38301d8d..22199939 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -1,13 +1,13 @@ -import { ValidationError } from "../../errors"; -import { ProtonDriveTelemetry, UploadMetadata } from "../../interface"; -import { getMockTelemetry } from "../../tests/telemetry"; -import { ErrorCode } from "../apiService"; -import { UploadAPIService } from "./apiService"; -import { UploadCryptoService } from "./cryptoService"; -import { NodesService } from "./interface"; +import { ValidationError } from '../../errors'; +import { ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { ErrorCode } from '../apiService'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { NodesService } from './interface'; import { UploadManager } from './manager'; -describe("UploadManager", () => { +describe('UploadManager', () => { let telemetry: ProtonDriveTelemetry; let apiService: UploadAPIService; let cryptoService: UploadCryptoService; @@ -22,16 +22,16 @@ describe("UploadManager", () => { // @ts-expect-error No need to implement all methods for mocking apiService = { createDraft: jest.fn().mockResolvedValue({ - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', }), deleteDraft: jest.fn(), checkAvailableHashes: jest.fn().mockResolvedValue({ - availalbleHashes: ["name1Hash"], + availalbleHashes: ['name1Hash'], pendingHashes: [], }), commitDraftRevision: jest.fn(), - } + }; // @ts-expect-error No need to implement all methods for mocking cryptoService = { generateFileCrypto: jest.fn().mockResolvedValue({ @@ -51,163 +51,168 @@ describe("UploadManager", () => { }, }, encryptedNode: { - encryptedName: "newNode:encryptedName", - hash: "newNode:hash", + encryptedName: 'newNode:encryptedName', + hash: 'newNode:hash', }, signatureAddress: { - email: "signatureEmail", + email: 'signatureEmail', }, }), - generateNameHashes: jest.fn().mockResolvedValue([{ - name: "name1", - hash: "name1Hash", - }, { - name: "name2", - hash: "name2Hash", - }, { - name: "name3", - hash: "name3Hash", - }]), + generateNameHashes: jest.fn().mockResolvedValue([ + { + name: 'name1', + hash: 'name1Hash', + }, + { + name: 'name2', + hash: 'name2Hash', + }, + { + name: 'name3', + hash: 'name3Hash', + }, + ]), commitFile: jest.fn().mockResolvedValue({ - armoredManifestSignature: "newNode:armoredManifestSignature", - signatureEmail: "signatureEmail", - armoredExtendedAttributes: "newNode:armoredExtendedAttributes", + armoredManifestSignature: 'newNode:armoredManifestSignature', + signatureEmail: 'signatureEmail', + armoredExtendedAttributes: 'newNode:armoredExtendedAttributes', }), - } + }; nodesService = { getNode: jest.fn(async (nodeUid: string) => ({ uid: nodeUid, parentUid: 'parentUid', - })), getNodeKeys: jest.fn().mockResolvedValue({ hashKey: 'parentNode:hashKey', key: 'parentNode:nodekey', }), getRootNodeEmailKey: jest.fn().mockResolvedValue({ - email: "signatureEmail", - addressId: "addressId", + email: 'signatureEmail', + addressId: 'addressId', }), - notifyChildCreated: jest.fn(async (nodeUid: string) => { return }), - } + notifyChildCreated: jest.fn(async (nodeUid: string) => { + return; + }), + }; manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, clientUid); }); - describe("createDraftNode", () => { - it("should fail to create node in non-folder parent", async () => { + describe('createDraftNode', () => { + it('should fail to create node in non-folder parent', async () => { nodesService.getNodeKeys = jest.fn().mockResolvedValue({ hashKey: undefined }); - const result = manager.createDraftNode("parentUid", "name", {} as UploadMetadata); - await expect(result).rejects.toThrow("Creating files in non-folders is not allowed"); + const result = manager.createDraftNode('parentUid', 'name', {} as UploadMetadata); + await expect(result).rejects.toThrow('Creating files in non-folders is not allowed'); }); - it("should create draft node", async () => { - const result = await manager.createDraftNode("parentUid", "name", { - mediaType: "myMimeType", + it('should create draft node', async () => { + const result = await manager.createDraftNode('parentUid', 'name', { + mediaType: 'myMimeType', expectedSize: 123456, } as UploadMetadata); expect(result).toEqual({ - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', nodeKeys: { - key: "newNode:key", - contentKeyPacketSessionKey: "newNode:ContentKeyPacketSessionKey", + key: 'newNode:key', + contentKeyPacketSessionKey: 'newNode:ContentKeyPacketSessionKey', signatureAddress: { - email: "signatureEmail", + email: 'signatureEmail', }, }, newNodeInfo: { - parentUid: "parentUid", - name: "name", - encryptedName: "newNode:encryptedName", - hash: "newNode:hash", + parentUid: 'parentUid', + name: 'name', + encryptedName: 'newNode:encryptedName', + hash: 'newNode:hash', }, }); - expect(apiService.createDraft).toHaveBeenCalledWith("parentUid", { - armoredEncryptedName: "newNode:encryptedName", - hash: "newNode:hash", - mediaType: "myMimeType", + expect(apiService.createDraft).toHaveBeenCalledWith('parentUid', { + armoredEncryptedName: 'newNode:encryptedName', + hash: 'newNode:hash', + mediaType: 'myMimeType', intendedUploadSize: 123456, - armoredNodeKey: "newNode:armoredKey", - armoredNodePassphrase: "newNode:armoredPassphrase", - armoredNodePassphraseSignature: "newNode:armoredPassphraseSignature", - base64ContentKeyPacket: "newNode:base64ContentKeyPacket", - armoredContentKeyPacketSignature: "newNode:armoredContentKeyPacketSignature", - signatureEmail: "signatureEmail", + armoredNodeKey: 'newNode:armoredKey', + armoredNodePassphrase: 'newNode:armoredPassphrase', + armoredNodePassphraseSignature: 'newNode:armoredPassphraseSignature', + base64ContentKeyPacket: 'newNode:base64ContentKeyPacket', + armoredContentKeyPacketSignature: 'newNode:armoredContentKeyPacketSignature', + signatureEmail: 'signatureEmail', }); }); - it("should delete existing draft and trying again", async () => { + it('should delete existing draft and trying again', async () => { let firstCall = true; apiService.createDraft = jest.fn().mockImplementation(() => { if (firstCall) { firstCall = false; - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { - ConflictLinkID: "existingLinkId", - ConflictDraftRevisionID: "existingDraftRevisionId", + throw new ValidationError('Draft already exists', ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: 'existingLinkId', + ConflictDraftRevisionID: 'existingDraftRevisionId', ConflictDraftClientUID: clientUid, }); } return { - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', }; }); - const result = await manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); + const result = await manager.createDraftNode('volumeId~parentUid', 'name', {} as UploadMetadata); expect(result).toEqual({ - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', nodeKeys: { - key: "newNode:key", - contentKeyPacketSessionKey: "newNode:ContentKeyPacketSessionKey", + key: 'newNode:key', + contentKeyPacketSessionKey: 'newNode:ContentKeyPacketSessionKey', signatureAddress: { - email: "signatureEmail", + email: 'signatureEmail', }, }, newNodeInfo: { - parentUid: "volumeId~parentUid", - name: "name", - encryptedName: "newNode:encryptedName", - hash: "newNode:hash", + parentUid: 'volumeId~parentUid', + name: 'name', + encryptedName: 'newNode:encryptedName', + hash: 'newNode:hash', }, }); expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); - expect(apiService.deleteDraft).toHaveBeenCalledWith("volumeId~existingLinkId"); + expect(apiService.deleteDraft).toHaveBeenCalledWith('volumeId~existingLinkId'); }); - it("should not delete existing draft if client UID does not match", async () => { + it('should not delete existing draft if client UID does not match', async () => { let firstCall = true; apiService.createDraft = jest.fn().mockImplementation(() => { if (firstCall) { firstCall = false; - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { - ConflictLinkID: "existingLinkId", - ConflictDraftRevisionID: "existingDraftRevisionId", - ConflictDraftClientUID: "anotherClientUid", + throw new ValidationError('Draft already exists', ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: 'existingLinkId', + ConflictDraftRevisionID: 'existingDraftRevisionId', + ConflictDraftClientUID: 'anotherClientUid', }); } return { - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', }; }); - const promise = manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); + const promise = manager.createDraftNode('volumeId~parentUid', 'name', {} as UploadMetadata); try { await promise; } catch (error: any) { - expect(error.message).toBe("Draft already exists"); + expect(error.message).toBe('Draft already exists'); expect(error.ongoingUploadByOtherClient).toBe(true); } expect(apiService.deleteDraft).not.toHaveBeenCalled(); }); - it("should not delete existing draft if client UID is not set", async () => { + it('should not delete existing draft if client UID is not set', async () => { const clientUid = undefined; manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, clientUid); @@ -215,77 +220,81 @@ describe("UploadManager", () => { apiService.createDraft = jest.fn().mockImplementation(() => { if (firstCall) { firstCall = false; - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { - ConflictLinkID: "existingLinkId", - ConflictDraftRevisionID: "existingDraftRevisionId", + throw new ValidationError('Draft already exists', ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: 'existingLinkId', + ConflictDraftRevisionID: 'existingDraftRevisionId', ConflictDraftClientUID: clientUid, }); } return { - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', }; }); - const promise = manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); + const promise = manager.createDraftNode('volumeId~parentUid', 'name', {} as UploadMetadata); try { await promise; } catch (error: any) { - expect(error.message).toBe("Draft already exists"); + expect(error.message).toBe('Draft already exists'); expect(error.ongoingUploadByOtherClient).toBe(true); } expect(apiService.deleteDraft).not.toHaveBeenCalled(); }); - it("should handle error when deleting existing draft", async () => { + it('should handle error when deleting existing draft', async () => { let firstCall = true; apiService.createDraft = jest.fn().mockImplementation(() => { if (firstCall) { firstCall = false; - throw new ValidationError("Draft already exists", ErrorCode.ALREADY_EXISTS, { - ConflictLinkID: "existingLinkId", - ConflictDraftRevisionID: "existingDraftRevisionId", + throw new ValidationError('Draft already exists', ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: 'existingLinkId', + ConflictDraftRevisionID: 'existingDraftRevisionId', ConflictDraftClientUID: clientUid, }); } return { - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', }; }); apiService.deleteDraft = jest.fn().mockImplementation(() => { - throw new Error("Failed to delete draft"); + throw new Error('Failed to delete draft'); }); - const result = manager.createDraftNode("volumeId~parentUid", "name", {} as UploadMetadata); + const result = manager.createDraftNode('volumeId~parentUid', 'name', {} as UploadMetadata); try { await result; } catch (error: any) { - expect(error.message).toBe("Draft already exists"); - expect(error.existingNodeUid).toBe("volumeId~existingLinkId"); + expect(error.message).toBe('Draft already exists'); + expect(error.existingNodeUid).toBe('volumeId~existingLinkId'); } expect(apiService.deleteDraft).toHaveBeenCalledTimes(1); }); }); - describe("findAvailableName", () => { - it("should find available name", async () => { + describe('findAvailableName', () => { + it('should find available name', async () => { apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { return { - availalbleHashes: ["name3Hash"], + availalbleHashes: ['name3Hash'], pendingHashes: [], - } + }; }); - const result = await manager.findAvailableName("parentUid", "name"); - expect(result).toBe("name3"); + const result = await manager.findAvailableName('parentUid', 'name'); + expect(result).toBe('name3'); expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); - expect(apiService.checkAvailableHashes).toHaveBeenCalledWith("parentUid", ["name1Hash", "name2Hash", "name3Hash"]); + expect(apiService.checkAvailableHashes).toHaveBeenCalledWith('parentUid', [ + 'name1Hash', + 'name2Hash', + 'name3Hash', + ]); }); - it("should find available name with multiple pages", async () => { + it('should find available name with multiple pages', async () => { let firstCall = false; apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { if (!firstCall) { @@ -294,80 +303,88 @@ describe("UploadManager", () => { // First page has no available hashes availalbleHashes: [], pendingHashes: [], - } + }; } return { - availalbleHashes: ["name3Hash"], + availalbleHashes: ['name3Hash'], pendingHashes: [], - } + }; }); - const result = await manager.findAvailableName("parentUid", "name"); - expect(result).toBe("name3"); + const result = await manager.findAvailableName('parentUid', 'name'); + expect(result).toBe('name3'); expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(2); - expect(apiService.checkAvailableHashes).toHaveBeenCalledWith("parentUid", ["name1Hash", "name2Hash", "name3Hash"]); + expect(apiService.checkAvailableHashes).toHaveBeenCalledWith('parentUid', [ + 'name1Hash', + 'name2Hash', + 'name3Hash', + ]); }); }); - describe("commit draft", () => { + describe('commit draft', () => { const nodeRevisionDraft = { - nodeUid: "newNode:nodeUid", - nodeRevisionUid: "newNode:nodeRevisionUid", + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', nodeKeys: { key: { _idx: 32321 }, - contentKeyPacketSessionKey: "newNode:contentKeyPacketSessionKey", + contentKeyPacketSessionKey: 'newNode:contentKeyPacketSessionKey', signatureAddress: { - email: "signatureEmail", - addressId: "addressId", - addressKey: "addressKey", + email: 'signatureEmail', + addressId: 'addressId', + addressKey: 'addressKey', } as any, }, }; const manifest = new Uint8Array([1, 2, 3]); const metadata = { - mediaType: "myMimeType", + mediaType: 'myMimeType', expectedSize: 123456, }; const extendedAttributes = { modificationTime: new Date(), digests: { - sha1: "sha1", - } + sha1: 'sha1', + }, }; - it("should commit revision draft", async () => { - await manager.commitDraft( - nodeRevisionDraft as any, + it('should commit revision draft', async () => { + await manager.commitDraft(nodeRevisionDraft as any, manifest, metadata, extendedAttributes); + + expect(cryptoService.commitFile).toHaveBeenCalledWith( + nodeRevisionDraft.nodeKeys, manifest, - metadata, - extendedAttributes, + expect.anything(), ); + expect(apiService.commitDraftRevision).toHaveBeenCalledWith( + nodeRevisionDraft.nodeRevisionUid, + expect.anything(), + ); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid'); + }); - expect(cryptoService.commitFile).toHaveBeenCalledWith(nodeRevisionDraft.nodeKeys, manifest, expect.anything()); - expect(apiService.commitDraftRevision).toHaveBeenCalledWith(nodeRevisionDraft.nodeRevisionUid, expect.anything()); - expect(nodesService.notifyChildCreated).toHaveBeenCalledWith("parentUid"); - }) - - it("should commit node draft", async () => { + it('should commit node draft', async () => { const nodeRevisionDraftWithNewNodeInfo = { ...nodeRevisionDraft, newNodeInfo: { - parentUid: "parentUid", - name: "newNode:name", - encryptedName: "newNode:encryptedName", - hash: "newNode:hash", - } - } - await manager.commitDraft( - nodeRevisionDraftWithNewNodeInfo as any, + parentUid: 'parentUid', + name: 'newNode:name', + encryptedName: 'newNode:encryptedName', + hash: 'newNode:hash', + }, + }; + await manager.commitDraft(nodeRevisionDraftWithNewNodeInfo as any, manifest, metadata, extendedAttributes); + + expect(cryptoService.commitFile).toHaveBeenCalledWith( + nodeRevisionDraft.nodeKeys, manifest, - metadata, - extendedAttributes, + expect.anything(), ); - - expect(cryptoService.commitFile).toHaveBeenCalledWith(nodeRevisionDraft.nodeKeys, manifest, expect.anything()); - expect(apiService.commitDraftRevision).toHaveBeenCalledWith(nodeRevisionDraft.nodeRevisionUid, expect.anything()); - expect(nodesService.notifyChildCreated).toHaveBeenCalledWith("parentUid"); + expect(apiService.commitDraftRevision).toHaveBeenCalledWith( + nodeRevisionDraft.nodeRevisionUid, + expect.anything(), + ); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid'); }); }); }); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 705b17ee..23d1a5ca 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -1,13 +1,13 @@ -import { c } from "ttag"; +import { c } from 'ttag'; -import { Logger, ProtonDriveTelemetry, UploadMetadata } from "../../interface"; -import { ValidationError, NodeAlreadyExistsValidationError } from "../../errors"; -import { ErrorCode } from "../apiService"; -import { generateFileExtendedAttributes } from "../nodes"; -import { UploadAPIService } from "./apiService"; -import { UploadCryptoService } from "./cryptoService"; -import { NodeRevisionDraft, NodesService, NodeCrypto } from "./interface"; -import { makeNodeUid, splitNodeUid } from "../uids"; +import { Logger, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { ValidationError, NodeAlreadyExistsValidationError } from '../../errors'; +import { ErrorCode } from '../apiService'; +import { generateFileExtendedAttributes } from '../nodes'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { NodeRevisionDraft, NodesService, NodeCrypto } from './interface'; +import { makeNodeUid, splitNodeUid } from '../uids'; /** * UploadManager is responsible for creating and deleting draft nodes @@ -75,8 +75,8 @@ export class UploadManager { metadata: UploadMetadata, generatedNodeCrypto: NodeCrypto, ): Promise<{ - nodeUid: string, - nodeRevisionUid: string, + nodeUid: string; + nodeRevisionUid: string; }> { try { const result = await this.apiService.createDraft(parentFolderUid, { @@ -88,7 +88,8 @@ export class UploadManager { armoredNodePassphrase: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphrase, armoredNodePassphraseSignature: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphraseSignature, base64ContentKeyPacket: generatedNodeCrypto.contentKey.encrypted.base64ContentKeyPacket, - armoredContentKeyPacketSignature: generatedNodeCrypto.contentKey.encrypted.armoredContentKeyPacketSignature, + armoredContentKeyPacketSignature: + generatedNodeCrypto.contentKey.encrypted.armoredContentKeyPacketSignature, signatureEmail: generatedNodeCrypto.signatureAddress.email, }); return result; @@ -97,30 +98,39 @@ export class UploadManager { if (error.code === ErrorCode.ALREADY_EXISTS) { this.logger.info(`Node with given name already exists`); - const typedDetails = error.details as { - ConflictLinkID: string, - ConflictRevisionID?: string, - ConflictDraftRevisionID?: string, - ConflictDraftClientUID?: string, - } | undefined; + const typedDetails = error.details as + | { + ConflictLinkID: string; + ConflictRevisionID?: string; + ConflictDraftRevisionID?: string; + ConflictDraftClientUID?: string; + } + | undefined; // If the client doesn't specify the client UID, it should // never be considered own draft. - const isOwnDraftConflict = ( + const isOwnDraftConflict = typedDetails?.ConflictDraftRevisionID && this.clientUid && - typedDetails?.ConflictDraftClientUID === this.clientUid - ); + typedDetails?.ConflictDraftClientUID === this.clientUid; // If there is existing draft created by this client, // automatically delete it and try to create a new one // with the same name again. - if (typedDetails?.ConflictDraftRevisionID && (isOwnDraftConflict || metadata.overrideExistingDraftByOtherClient)) { - const existingDraftNodeUid = makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID); + if ( + typedDetails?.ConflictDraftRevisionID && + (isOwnDraftConflict || metadata.overrideExistingDraftByOtherClient) + ) { + const existingDraftNodeUid = makeNodeUid( + splitNodeUid(parentFolderUid).volumeId, + typedDetails.ConflictLinkID, + ); let deleteFailed = false; try { - this.logger.warn(`Deleting existing draft node ${existingDraftNodeUid} by ${typedDetails.ConflictDraftClientUID}`); + this.logger.warn( + `Deleting existing draft node ${existingDraftNodeUid} by ${typedDetails.ConflictDraftClientUID}`, + ); await this.apiService.deleteDraft(existingDraftNodeUid); } catch (deleteDraftError: unknown) { // Do not throw, let throw the conflict error. @@ -128,15 +138,25 @@ export class UploadManager { this.logger.error('Failed to delete existing draft node', deleteDraftError); } if (!deleteFailed) { - return this.createDraftOnAPI(parentFolderUid, parentHashKey, name, metadata, generatedNodeCrypto); + return this.createDraftOnAPI( + parentFolderUid, + parentHashKey, + name, + metadata, + generatedNodeCrypto, + ); } } if (isOwnDraftConflict) { - this.logger.warn(`Existing draft conflict by another client ${typedDetails.ConflictDraftClientUID}`); + this.logger.warn( + `Existing draft conflict by another client ${typedDetails.ConflictDraftClientUID}`, + ); } - const existingNodeUid = typedDetails ? makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID) : undefined; + const existingNodeUid = typedDetails + ? makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID) + : undefined; // If there is existing node, return special error // that includes the available name the client can use. @@ -223,7 +243,7 @@ export class UploadManager { contentKeyPacketSessionKey: nodeKeys.contentKeyPacketSessionKey, signatureAddress: signatureAddress, }, - } + }; } async deleteDraftRevision(nodeRevisionUid: string): Promise { @@ -242,16 +262,20 @@ export class UploadManager { manifest: Uint8Array, _metadata: UploadMetadata, extendedAttributes: { - modificationTime?: Date, - size?: number, - blockSizes?: number[], + modificationTime?: Date; + size?: number; + blockSizes?: number[]; digests?: { - sha1?: string, - }, + sha1?: string; + }; }, ): Promise { const generatedExtendedAttributes = generateFileExtendedAttributes(extendedAttributes); - const nodeCommitCrypto = await this.cryptoService.commitFile(nodeRevisionDraft.nodeKeys, manifest, generatedExtendedAttributes); + const nodeCommitCrypto = await this.cryptoService.commitFile( + nodeRevisionDraft.nodeKeys, + manifest, + generatedExtendedAttributes, + ); await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto); const node = await this.nodesService.getNode(nodeRevisionDraft.nodeUid); if (node.parentUid) { @@ -269,7 +293,7 @@ function splitExtension(filename = ''): [string, string] { return [filename, '']; } return [filename.slice(0, endIdx), filename.slice(endIdx + 1)]; -}; +} /** * Join a filename into `name (index).extension` diff --git a/js/sdk/src/internal/upload/queue.ts b/js/sdk/src/internal/upload/queue.ts index d93203ce..fedae10a 100644 --- a/js/sdk/src/internal/upload/queue.ts +++ b/js/sdk/src/internal/upload/queue.ts @@ -2,13 +2,13 @@ import { waitForCondition } from '../wait'; /** * A queue that limits the number of concurrent uploads. - * + * * This is used to limit the number of concurrent uploads to avoid * overloading the server, or get rate limited. - * + * * Each file upload consumes memory and is limited by the number of * concurrent block uploads for each file. - * + * * This queue is straitforward and does not have any priority mechanism * or other features, such as limiting total number of blocks being * uploaded. That is something we want to add in the future to be diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 4ae0866c..feaa73b6 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -12,7 +12,12 @@ import { UploadManager } from './manager'; const BLOCK_ENCRYPTION_OVERHEAD = 10000; -async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise, _: any, block: Uint8Array, index: number) { +async function mockEncryptBlock( + verifyBlock: (block: Uint8Array) => Promise, + _: any, + block: Uint8Array, + index: number, +) { await verifyBlock(block); return { index, @@ -25,7 +30,12 @@ async function mockEncryptBlock(verifyBlock: (block: Uint8Array) => Promise void) { +function mockUploadBlock( + _: string, + __: string, + encryptedBlock: Uint8Array, + onProgress: (uploadedBytes: number) => void, +) { onProgress(encryptedBlock.length); } @@ -137,22 +147,19 @@ describe('StreamUploader', () => { const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); expect(uploadManager.commitDraft).toHaveBeenCalledTimes(1); - expect(uploadManager.commitDraft).toHaveBeenCalledWith( - revisionDraft, - expect.anything(), - metadata, - { - size: metadata.expectedSize, - blockSizes: metadata.expectedSize ? [ - ...Array(numberOfExpectedBlocks - 1).fill(FILE_CHUNK_SIZE), - metadata.expectedSize % FILE_CHUNK_SIZE - ] : [], - modificationTime: undefined, - digests: { - sha1: expect.anything(), - } + expect(uploadManager.commitDraft).toHaveBeenCalledWith(revisionDraft, expect.anything(), metadata, { + size: metadata.expectedSize, + blockSizes: metadata.expectedSize + ? [ + ...Array(numberOfExpectedBlocks - 1).fill(FILE_CHUNK_SIZE), + metadata.expectedSize % FILE_CHUNK_SIZE, + ] + : [], + modificationTime: undefined, + digests: { + sha1: expect.anything(), }, - ); + }); expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); expect(telemetry.uploadFailed).not.toHaveBeenCalled(); @@ -160,7 +167,11 @@ describe('StreamUploader', () => { expect(onFinish).toHaveBeenCalledWith(false); }; - const verifyFailure = async (error: string, uploadedBytes: number | undefined, expectedSize = metadata.expectedSize) => { + const verifyFailure = async ( + error: string, + uploadedBytes: number | undefined, + expectedSize = metadata.expectedSize, + ) => { const promise = uploader.start(stream, thumbnails, onProgress); await expect(promise).rejects.toThrow(error); @@ -189,7 +200,7 @@ describe('StreamUploader', () => { { type: ThumbnailType.Type1, thumbnail: new Uint8Array(1024), - } + }, ]; thumbnailSize = thumbnails.reduce((acc, thumbnail) => acc + thumbnail.thumbnail.length, 0); stream = new ReadableStream({ @@ -204,7 +215,7 @@ describe('StreamUploader', () => { }); }); - it("should upload successfully", async () => { + it('should upload successfully', async () => { await verifySuccess(); expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); expect(apiService.uploadBlock).toHaveBeenCalledTimes(4); // 3 blocks + 1 thumbnail @@ -213,7 +224,7 @@ describe('StreamUploader', () => { await verifyOnProgress([thumbnailSize, 4 * 1024 * 1024, 4 * 1024 * 1024, 2 * 1024 * 1024]); }); - it("should upload successfully empty file without thumbnail", async () => { + it('should upload successfully empty file without thumbnail', async () => { metadata = { expectedSize: 0, } as UploadMetadata; @@ -242,7 +253,7 @@ describe('StreamUploader', () => { await verifyOnProgress([]); }); - it("should upload successfully empty file with thumbnail", async () => { + it('should upload successfully empty file with thumbnail', async () => { metadata = { expectedSize: 0, } as UploadMetadata; @@ -395,7 +406,7 @@ describe('StreamUploader', () => { hash: 'blockHash', armoredSignature: 'signature', verificationToken: 'verificationToken', - } + }, ], }, ); @@ -420,9 +431,12 @@ describe('StreamUploader', () => { }); it('should report block verification error when retry helped', async () => { - blockVerifier.verifyBlock = jest.fn().mockRejectedValueOnce(new IntegrityError('Block verification error')).mockResolvedValue({ - verificationToken: new Uint8Array(), - }); + blockVerifier.verifyBlock = jest + .fn() + .mockRejectedValueOnce(new IntegrityError('Block verification error')) + .mockResolvedValue({ + verificationToken: new Uint8Array(), + }); await verifySuccess(); expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(true); }); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 25372d81..b704f8a3 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -1,21 +1,21 @@ -import { c } from "ttag"; - -import { Thumbnail, Logger, ThumbnailType, UploadMetadata } from "../../interface"; -import { IntegrityError } from "../../errors"; -import { LoggerWithPrefix } from "../../telemetry"; -import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from "../apiService"; -import { getErrorMessage } from "../errors"; -import { mergeUint8Arrays } from "../utils"; +import { c } from 'ttag'; + +import { Thumbnail, Logger, ThumbnailType, UploadMetadata } from '../../interface'; +import { IntegrityError } from '../../errors'; +import { LoggerWithPrefix } from '../../telemetry'; +import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from '../apiService'; +import { getErrorMessage } from '../errors'; +import { mergeUint8Arrays } from '../utils'; import { waitForCondition } from '../wait'; -import { UploadAPIService } from "./apiService"; -import { BlockVerifier } from "./blockVerifier"; +import { UploadAPIService } from './apiService'; +import { BlockVerifier } from './blockVerifier'; import { UploadController } from './controller'; -import { UploadCryptoService } from "./cryptoService"; -import { UploadDigests } from "./digests"; -import { NodeRevisionDraft, EncryptedBlock, EncryptedThumbnail, EncryptedBlockMetadata } from "./interface"; +import { UploadCryptoService } from './cryptoService'; +import { UploadDigests } from './digests'; +import { NodeRevisionDraft, EncryptedBlock, EncryptedThumbnail, EncryptedBlockMetadata } from './interface'; import { UploadTelemetry } from './telemetry'; import { ChunkStreamReader } from './chunkStreamReader'; -import { UploadManager } from "./manager"; +import { UploadManager } from './manager'; /** * File chunk size in bytes representing the size of each block. @@ -65,10 +65,13 @@ export class StreamUploader { private encryptedBlocks = new Map(); private encryptionFinished = false; - private ongoingUploads = new Map, - encryptedBlock: EncryptedBlock | EncryptedThumbnail, - }>(); + private ongoingUploads = new Map< + string, + { + uploadPromise: Promise; + encryptedBlock: EncryptedBlock | EncryptedThumbnail; + } + >(); private uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = []; private uploadedBlocks: ({ index: number } & EncryptedBlockMetadata)[] = []; @@ -104,7 +107,11 @@ export class StreamUploader { this.controller = new UploadController(); } - async start(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void): Promise { + async start( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise { let failure = false; // File progress is tracked for telemetry - to track at what @@ -116,7 +123,7 @@ export class StreamUploader { await this.encryptAndUploadBlocks(stream, thumbnails, (uploadedBytes) => { fileProgress += uploadedBytes; onProgress?.(uploadedBytes); - }) + }); this.logger.debug(`All blocks uploaded, committing`); await this.commitFile(thumbnails); @@ -126,7 +133,12 @@ export class StreamUploader { } catch (error: unknown) { failure = true; this.logger.error(`Upload failed`, error); - void this.telemetry.uploadFailed(this.revisionDraft.nodeRevisionUid, error, fileProgress, this.metadata.expectedSize); + void this.telemetry.uploadFailed( + this.revisionDraft.nodeRevisionUid, + error, + fileProgress, + this.metadata.expectedSize, + ); throw error; } finally { this.logger.debug(`Upload cleanup`); @@ -145,7 +157,11 @@ export class StreamUploader { return this.revisionDraft.nodeRevisionUid; } - private async encryptAndUploadBlocks(stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void) { + private async encryptAndUploadBlocks( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ) { // We await for the encryption of thumbnails to finish before // starting the upload. This is because we need to request the // upload tokens for the thumbnails with the first blocks. @@ -198,15 +214,10 @@ export class StreamUploader { const extendedAttributes = { modificationTime: this.metadata.modificationTime, size: this.metadata.expectedSize, - blockSizes: uploadedBlocks.map(block => block.originalSize), + blockSizes: uploadedBlocks.map((block) => block.originalSize), digests: this.digests.digests(), }; - await this.uploadManager.commitDraft( - this.revisionDraft, - this.manifest, - this.metadata, - extendedAttributes, - ); + await this.uploadManager.commitDraft(this.revisionDraft, this.manifest, this.metadata, extendedAttributes); } private async encryptThumbnails(thumbnails: Thumbnail[]) { @@ -216,7 +227,10 @@ export class StreamUploader { for (const thumbnail of thumbnails) { this.logger.debug(`Encrypting thumbnail ${thumbnail.type}`); - const encryptedThumbnail = await this.cryptoService.encryptThumbnail(this.revisionDraft.nodeKeys, thumbnail); + const encryptedThumbnail = await this.cryptoService.encryptThumbnail( + this.revisionDraft.nodeKeys, + thumbnail, + ); this.encryptedThumbnails.set(thumbnail.type, encryptedThumbnail); } } @@ -256,7 +270,9 @@ export class StreamUploader { } if (attempt <= MAX_BLOCK_ENCRYPTION_RETRIES) { - this.logger.warn(`Block encryption failed #${attempt}, retrying: ${getErrorMessage(error)}`); + this.logger.warn( + `Block encryption failed #${attempt}, retrying: ${getErrorMessage(error)}`, + ); continue; } @@ -280,18 +296,22 @@ export class StreamUploader { this.revisionDraft.nodeRevisionUid, this.revisionDraft.nodeKeys.signatureAddress.addressId, { - contentBlocks: Array.from(this.encryptedBlocks.values().map(block => ({ - index: block.index, - encryptedSize: block.encryptedSize, - hash: block.hash, - armoredSignature: block.armoredSignature, - verificationToken: block.verificationToken, - }))), - thumbnails: Array.from(this.encryptedThumbnails.values().map(block => ({ - type: block.type, - encryptedSize: block.encryptedSize, - hash: block.hash, - }))), + contentBlocks: Array.from( + this.encryptedBlocks.values().map((block) => ({ + index: block.index, + encryptedSize: block.encryptedSize, + hash: block.hash, + armoredSignature: block.armoredSignature, + verificationToken: block.verificationToken, + })), + ), + thumbnails: Array.from( + this.encryptedThumbnails.values().map((block) => ({ + type: block.type, + encryptedSize: block.encryptedSize, + hash: block.hash, + })), + ), }, ); @@ -305,11 +325,7 @@ export class StreamUploader { const uploadKey = `thumbnail:${thumbnailToken.type}`; this.ongoingUploads.set(uploadKey, { - uploadPromise: this.uploadThumbnail( - thumbnailToken, - encryptedThumbnail, - onProgress, - ).finally(() => { + uploadPromise: this.uploadThumbnail(thumbnailToken, encryptedThumbnail, onProgress).finally(() => { this.ongoingUploads.delete(uploadKey); // Help the garbage collector to clean up the memory. @@ -329,11 +345,7 @@ export class StreamUploader { const uploadKey = `block:${blockToken.index}`; this.ongoingUploads.set(uploadKey, { - uploadPromise: this.uploadBlock( - blockToken, - encryptedBlock, - onProgress, - ).finally(() => { + uploadPromise: this.uploadBlock(blockToken, encryptedBlock, onProgress).finally(() => { this.ongoingUploads.delete(uploadKey); // Help the garbage collector to clean up the memory. @@ -345,11 +357,14 @@ export class StreamUploader { } private async uploadThumbnail( - uploadToken: { bareUrl: string, token: string }, + uploadToken: { bareUrl: string; token: string }, encryptedThumbnail: EncryptedThumbnail, onProgress?: (uploadedBytes: number) => void, ) { - const logger = new LoggerWithPrefix(this.logger, `thumbnail type ${encryptedThumbnail.type} to ${uploadToken.token}`); + const logger = new LoggerWithPrefix( + this.logger, + `thumbnail type ${encryptedThumbnail.type} to ${uploadToken.token}`, + ); logger.info(`Upload started`); let blockProgress = 0; @@ -368,13 +383,13 @@ export class StreamUploader { onProgress?.(uploadedBytes); }, this.abortController.signal, - ) + ); this.uploadedThumbnails.push({ type: encryptedThumbnail.type, hash: encryptedThumbnail.hash, encryptedSize: encryptedThumbnail.encryptedSize, originalSize: encryptedThumbnail.originalSize, - }) + }); break; } catch (error: unknown) { if (blockProgress !== 0) { @@ -407,7 +422,7 @@ export class StreamUploader { } private async uploadBlock( - uploadToken: { index: number, bareUrl: string, token: string }, + uploadToken: { index: number; bareUrl: string; token: string }, encryptedBlock: EncryptedBlock, onProgress?: (uploadedBytes: number) => void, ) { @@ -430,13 +445,13 @@ export class StreamUploader { onProgress?.(uploadedBytes); }, this.abortController.signal, - ) + ); this.uploadedBlocks.push({ index: encryptedBlock.index, hash: encryptedBlock.hash, encryptedSize: encryptedBlock.encryptedSize, originalSize: encryptedBlock.originalSize, - }) + }); break; } catch (error: unknown) { if (blockProgress !== 0) { @@ -446,20 +461,22 @@ export class StreamUploader { if ( (error instanceof APIHTTPError && error.statusCode === HTTPErrorCode.NOT_FOUND) || - (error instanceof NotFoundAPIError) + error instanceof NotFoundAPIError ) { logger.warn(`Token expired, fetching new token and retrying`); const uploadTokens = await this.apiService.requestBlockUpload( this.revisionDraft.nodeRevisionUid, this.revisionDraft.nodeKeys.signatureAddress.addressId, { - contentBlocks: [{ - index: encryptedBlock.index, - encryptedSize: encryptedBlock.encryptedSize, - hash: encryptedBlock.hash, - armoredSignature: encryptedBlock.armoredSignature, - verificationToken: encryptedBlock.verificationToken, - }], + contentBlocks: [ + { + index: encryptedBlock.index, + encryptedSize: encryptedBlock.encryptedSize, + hash: encryptedBlock.hash, + armoredSignature: encryptedBlock.armoredSignature, + verificationToken: encryptedBlock.verificationToken, + }, + ], }, ); uploadToken = uploadTokens.blockTokens[0]; @@ -498,7 +515,8 @@ export class StreamUploader { } private verifyIntegrity(thumbnails: Thumbnail[]) { - const expectedBlockCount = Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0); + const expectedBlockCount = + Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0); if (this.uploadedBlockCount !== expectedBlockCount) { throw new IntegrityError(c('Error').t`Some file parts failed to upload`, { uploadedBlockCount: this.uploadedBlockCount, diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index 86c30fe8..debac7c3 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -25,7 +25,7 @@ describe('UploadTelemetry', () => { sharesService = { getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), - } + }; uploadTelemetry = new UploadTelemetry(mockTelemetry, sharesService); }); @@ -35,11 +35,11 @@ describe('UploadTelemetry', () => { await uploadTelemetry.uploadInitFailed(parentNodeUid, error, 1000); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ - eventName: "upload", - volumeType: "own_volume", + eventName: 'upload', + volumeType: 'own_volume', uploadedSize: 0, expectedSize: 1000, - error: "unknown", + error: 'unknown', originalError: error, }); }); @@ -49,11 +49,11 @@ describe('UploadTelemetry', () => { await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ - eventName: "upload", - volumeType: "own_volume", + eventName: 'upload', + volumeType: 'own_volume', uploadedSize: 500, expectedSize: 1000, - error: "unknown", + error: 'unknown', originalError: error, }); }); @@ -62,8 +62,8 @@ describe('UploadTelemetry', () => { await uploadTelemetry.uploadFinished(revisionUid, 1000); expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ - eventName: "upload", - volumeType: "own_volume", + eventName: 'upload', + volumeType: 'own_volume', uploadedSize: 1000, expectedSize: 1000, }); @@ -74,7 +74,7 @@ describe('UploadTelemetry', () => { expect(mockTelemetry.logEvent).toHaveBeenCalledWith( expect.objectContaining({ error, - }) + }), ); }; @@ -130,4 +130,4 @@ describe('UploadTelemetry', () => { verifyErrorCategory('network_error'); }); }); -}); \ No newline at end of file +}); diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 39ccc0da..1307ab57 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -1,21 +1,24 @@ -import { RateLimitedError, ValidationError, IntegrityError } from "../../errors"; -import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from "../../interface"; -import { LoggerWithPrefix } from "../../telemetry"; +import { RateLimitedError, ValidationError, IntegrityError } from '../../errors'; +import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from '../../interface'; +import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError } from '../apiService'; -import { splitNodeUid, splitNodeRevisionUid } from "../uids"; -import { SharesService } from "./interface"; +import { splitNodeUid, splitNodeRevisionUid } from '../uids'; +import { SharesService } from './interface'; export class UploadTelemetry { private logger: Logger; - constructor(private telemetry: ProtonDriveTelemetry, private sharesService: SharesService) { + constructor( + private telemetry: ProtonDriveTelemetry, + private sharesService: SharesService, + ) { this.telemetry = telemetry; - this.logger = this.telemetry.getLogger("download"); + this.logger = this.telemetry.getLogger('download'); this.sharesService = sharesService; } getLoggerForRevision(revisionUid: string) { - const logger = this.telemetry.getLogger("upload"); + const logger = this.telemetry.getLogger('upload'); return new LoggerWithPrefix(logger, `revision ${revisionUid}`); } @@ -70,12 +73,15 @@ export class UploadTelemetry { }); } - private async sendTelemetry(volumeId: string, options: { - uploadedSize: number, - expectedSize: number, - error?: MetricsUploadErrorType, - originalError?: unknown, - }) { + private async sendTelemetry( + volumeId: string, + options: { + uploadedSize: number; + expectedSize: number; + error?: MetricsUploadErrorType; + originalError?: unknown; + }, + ) { let volumeType; try { volumeType = await this.sharesService.getVolumeMetricContext(volumeId); @@ -113,7 +119,11 @@ function getErrorCategory(error: unknown): MetricsUploadErrorType | undefined { if (error.name === 'TimeoutError') { return 'server_error'; } - if (error.name === 'OfflineError' || error.name === 'NetworkError' || error.message?.toLowerCase() === 'network error') { + if ( + error.name === 'OfflineError' || + error.name === 'NetworkError' || + error.message?.toLowerCase() === 'network error' + ) { return 'network_error'; } if (error.name === 'AbortError') { diff --git a/js/sdk/src/internal/wait.test.ts b/js/sdk/src/internal/wait.test.ts index c76348dc..c7ff2d3b 100644 --- a/js/sdk/src/internal/wait.test.ts +++ b/js/sdk/src/internal/wait.test.ts @@ -1,4 +1,4 @@ -import { waitForCondition } from "./wait"; +import { waitForCondition } from './wait'; describe('waitForCondition', () => { it('should resolve immediately if condition is met', async () => { diff --git a/js/sdk/src/internal/wait.ts b/js/sdk/src/internal/wait.ts index 6e9f6ab5..e3b3a254 100644 --- a/js/sdk/src/internal/wait.ts +++ b/js/sdk/src/internal/wait.ts @@ -1,4 +1,4 @@ -import { AbortError } from "../errors"; +import { AbortError } from '../errors'; const WAIT_TIME = 50; @@ -17,10 +17,10 @@ export function waitForCondition(callback: () => boolean, signal?: AbortSignal) }); } -export async function waitSeconds(seconds: number){ +export async function waitSeconds(seconds: number) { return wait(seconds * 1000); } -export async function wait(miliseconds: number){ +export async function wait(miliseconds: number) { return new Promise((resolve) => setTimeout(resolve, miliseconds)); } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 784ccdc3..23e82158 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -35,7 +35,14 @@ import { initUploadModule } from './internal/upload'; import { DriveEventsService, DriveListener } from './internal/events'; import { SDKEvents } from './internal/sdkEvents'; import { getConfig } from './config'; -import { getUid, getUids, convertInternalNodePromise, convertInternalNodeIterator, convertInternalMissingNodeIterator, convertInternalNode } from './transformers'; +import { + getUid, + getUids, + convertInternalNodePromise, + convertInternalNodeIterator, + convertInternalMissingNodeIterator, + convertInternalNode, +} from './transformers'; import { Telemetry } from './telemetry'; import { initDevicesModule } from './internal/devices'; import { makeNodeUid } from './internal/uids'; @@ -95,16 +102,69 @@ export class ProtonDriveClient { const fullConfig = getConfig(config); this.sdkEvents = new SDKEvents(telemetry); const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); - const apiService = new DriveAPIService(telemetry, this.sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); + const apiService = new DriveAPIService( + telemetry, + this.sdkEvents, + httpClient, + fullConfig.baseUrl, + fullConfig.language, + ); this.shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); - this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, this.shares); - this.sharing = initSharingModule(telemetry, apiService, entitiesCache, account, cryptoModule, this.shares, this.nodes.access); - this.download = initDownloadModule(telemetry, apiService, cryptoModule, account, this.shares, this.nodes.access, this.nodes.revisions); - this.upload = initUploadModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, fullConfig.clientUid); - this.devices = initDevicesModule(telemetry, apiService, cryptoModule, this.shares, this.nodes.access, this.nodes.management); + this.nodes = initNodesModule( + telemetry, + apiService, + entitiesCache, + cryptoCache, + account, + cryptoModule, + this.shares, + ); + this.sharing = initSharingModule( + telemetry, + apiService, + entitiesCache, + account, + cryptoModule, + this.shares, + this.nodes.access, + ); + this.download = initDownloadModule( + telemetry, + apiService, + cryptoModule, + account, + this.shares, + this.nodes.access, + this.nodes.revisions, + ); + this.upload = initUploadModule( + telemetry, + apiService, + cryptoModule, + this.shares, + this.nodes.access, + fullConfig.clientUid, + ); + this.devices = initDevicesModule( + telemetry, + apiService, + cryptoModule, + this.shares, + this.nodes.access, + this.nodes.management, + ); // These are used to keep the internal cache up to date - const cacheEventListeners: DriveListener[] = [this.nodes.eventHandler.updateNodesCacheOnEvent, this.sharing.eventHandler.handleDriveEvent]; - this.events = new DriveEventsService(telemetry, apiService, this.shares, cacheEventListeners, latestEventIdProvider); + const cacheEventListeners: DriveListener[] = [ + this.nodes.eventHandler.updateNodesCacheOnEvent, + this.sharing.eventHandler.handleDriveEvent, + ]; + this.events = new DriveEventsService( + telemetry, + apiService, + this.shares, + cacheEventListeners, + latestEventIdProvider, + ); this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { @@ -119,7 +179,7 @@ export class ProtonDriveClient { } return keys.contentKeyPacketSessionKey; }, - } + }; } /** @@ -150,7 +210,7 @@ export class ProtonDriveClient { */ async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { this.logger.debug('Subscribing to node updates'); - return this.events.subscribeToTreeEvents(treeEventScopeId, callback) + return this.events.subscribeToTreeEvents(treeEventScopeId, callback); } /** @@ -160,7 +220,7 @@ export class ProtonDriveClient { */ async subscribeToDriveEvents(callback: DriveListener): Promise { this.logger.debug('Subscribing to core updates'); - return this.events.subscribeToCoreEvents(callback) + return this.events.subscribeToCoreEvents(callback); } /** @@ -199,7 +259,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the children of the given parent node. */ - async* iterateFolderChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + async *iterateFolderChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); yield* convertInternalNodeIterator(this.nodes.access.iterateFolderChildren(getUid(parentNodeUid), signal)); } @@ -215,7 +275,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the trashed nodes. */ - async* iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } @@ -229,7 +289,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the nodes. */ - async* iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + async *iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating ${nodeUids.length} nodes`); yield* convertInternalMissingNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } @@ -277,7 +337,11 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the results of the move operation */ - async* moveNodes(nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + async *moveNodes( + nodeUids: NodeOrUid[], + newParentNodeUid: NodeOrUid, + signal?: AbortSignal, + ): AsyncGenerator { this.logger.info(`Moving ${nodeUids.length} nodes to ${newParentNodeUid}`); yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); } @@ -295,7 +359,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the results of the trash operation */ - async* trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + async *trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Trashing ${nodeUids.length} nodes`); yield* this.nodes.management.trashNodes(getUids(nodeUids), signal); } @@ -313,7 +377,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the results of the restore operation */ - async* restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + async *restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Restoring ${nodeUids.length} nodes`); yield* this.nodes.management.restoreNodes(getUids(nodeUids), signal); } @@ -331,7 +395,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the results of the delete operation */ - async* deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Deleting ${nodeUids.length} nodes`); yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal); } @@ -356,7 +420,9 @@ export class ProtonDriveClient { */ async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise { this.logger.info(`Creating folder in ${getUid(parentNodeUid)}`); - return convertInternalNodePromise(this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime)); + return convertInternalNodePromise( + this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime), + ); } /** @@ -372,7 +438,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the node revisions. */ - async* iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + async *iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating revisions of ${getUid(nodeUid)}`); yield* this.nodes.revisions.iterateRevisions(getUid(nodeUid), signal); } @@ -410,7 +476,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. */ - async* iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes by me'); yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); } @@ -427,7 +493,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. */ - async* iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { + async *iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes with me'); for await (const node of this.sharing.access.iterateSharedNodesWithMe(signal)) { @@ -453,7 +519,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the invitations. */ - async* iterateInvitations(signal?: AbortSignal): AsyncGenerator { + async *iterateInvitations(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating invitations'); yield* this.sharing.access.iterateInvitations(signal); } @@ -486,7 +552,7 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the shared bookmarks. */ - async* iterateBookmarks(signal?: AbortSignal): AsyncGenerator { + async *iterateBookmarks(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared bookmarks'); yield* this.sharing.access.iterateBookmarks(signal); } @@ -552,9 +618,12 @@ export class ProtonDriveClient { return this.sharing.management.unshareNode(getUid(nodeUid), settings); } - async resendInvitation(nodeUid: NodeOrUid, invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid): Promise { + async resendInvitation( + nodeUid: NodeOrUid, + invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid, + ): Promise { this.logger.info(`Resending invitation ${getUid(invitationUid)}`); - return this.sharing.management.resendInvitationEmail(getUid(nodeUid), getUid(invitationUid)) + return this.sharing.management.resendInvitationEmail(getUid(nodeUid), getUid(invitationUid)); } /** @@ -612,15 +681,19 @@ export class ProtonDriveClient { } /** - * Iterates the thumbnails of the given nodes. - * - * The output is not sorted and the order of the nodes is not guaranteed. - * - * @param nodeUids - List of node entities or their UIDs. - * @param thumbnailType - Type of the thumbnail to download. - * @returns An async generator of the results of the restore operation - */ - async *iterateThumbnails(nodeUids: NodeOrUid[], thumbnailType?: ThumbnailType, signal?: AbortSignal): AsyncGenerator { + * Iterates the thumbnails of the given nodes. + * + * The output is not sorted and the order of the nodes is not guaranteed. + * + * @param nodeUids - List of node entities or their UIDs. + * @param thumbnailType - Type of the thumbnail to download. + * @returns An async generator of the results of the restore operation + */ + async *iterateThumbnails( + nodeUids: NodeOrUid[], + thumbnailType?: ThumbnailType, + signal?: AbortSignal, + ): AsyncGenerator { this.logger.info(`Iterating ${nodeUids.length} thumbnails`); yield* this.download.iterateThumbnails(getUids(nodeUids), thumbnailType, signal); } @@ -654,7 +727,12 @@ export class ProtonDriveClient { * const nodeUid = await uploadController.completion(); // to await completion * ``` */ - async getFileUploader(parentFolderUid: NodeOrUid, name: string, metadata: UploadMetadata, signal?: AbortSignal): Promise { + async getFileUploader( + parentFolderUid: NodeOrUid, + name: string, + metadata: UploadMetadata, + signal?: AbortSignal, + ): Promise { this.logger.info(`Getting file uploader for parent ${getUid(parentFolderUid)}`); return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } @@ -662,7 +740,11 @@ export class ProtonDriveClient { /** * Same as `getFileUploader`, but for a uploading new revision of the file. */ - async getFileRevisionUploader(nodeUid: NodeOrUid, metadata: UploadMetadata, signal?: AbortSignal): Promise { + async getFileRevisionUploader( + nodeUid: NodeOrUid, + metadata: UploadMetadata, + signal?: AbortSignal, + ): Promise { this.logger.info(`Getting file revision uploader for ${getUid(nodeUid)}`); return this.upload.getFileRevisionUploader(getUid(nodeUid), metadata, signal); } @@ -677,7 +759,7 @@ export class ProtonDriveClient { * * @returns An async generator of devices. */ - async* iterateDevices(signal?: AbortSignal): AsyncGenerator { + async *iterateDevices(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating devices'); yield* this.devices.iterateDevices(signal); } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 0cafebc5..f509ade9 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -31,7 +31,13 @@ export class ProtonDrivePhotosClient { const fullConfig = getConfig(config); const sdkEvents = new SDKEvents(telemetry); const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); - const apiService = new DriveAPIService(telemetry, sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language); + const apiService = new DriveAPIService( + telemetry, + sdkEvents, + httpClient, + fullConfig.baseUrl, + fullConfig.language, + ); const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, shares); const cacheEventListeners: DriveListener[] = [this.nodes.eventHandler.updateNodesCacheOnEvent]; @@ -40,18 +46,18 @@ export class ProtonDrivePhotosClient { } // Timeline or album view - iterateTimelinePhotos() { } // returns only UIDs and dates - used to show grid and scrolling - iterateAlbumPhotos() { } // same as above but for album - iterateThumbnails() { } // returns thumbnails for passed photos that are visible in the UI - getPhoto() { } // returns full photo details + iterateTimelinePhotos() {} // returns only UIDs and dates - used to show grid and scrolling + iterateAlbumPhotos() {} // same as above but for album + iterateThumbnails() {} // returns thumbnails for passed photos that are visible in the UI + getPhoto() {} // returns full photo details // Album management createAlbum(albumName: string) { return this.photos.albums.createAlbum(albumName); } - renameAlbum() { } - shareAlbum() { } - deleteAlbum() { } - iterateAlbums() { } - addPhotosToAlbum() { } + renameAlbum() {} + shareAlbum() {} + deleteAlbum() {} + iterateAlbums() {} + addPhotosToAlbum() {} } diff --git a/js/sdk/src/telemetry.ts b/js/sdk/src/telemetry.ts index 63b022db..e9b23edd 100644 --- a/js/sdk/src/telemetry.ts +++ b/js/sdk/src/telemetry.ts @@ -1,7 +1,7 @@ import { Logger as LoggerInterface } from './interface'; export interface LogRecord { - time: Date, + time: Date; level: LogLevel; loggerName: string; message: string; @@ -24,13 +24,13 @@ export interface LogHandler { } export interface MetricRecord { - time: Date, + time: Date; event: T; } export type MetricEvent = { eventName: string; -} +}; export interface MetricHandler { onEvent(metric: MetricRecord): void; @@ -38,12 +38,12 @@ export interface MetricHandler { /** * Telemetry class that logs messages and metrics. - * + * * Example: - * + * * ```typescript * const memoryLogHandler = new MemoryLogHandler(); - * + * * interface MetricEvents = { * name: string, * value: number, @@ -53,7 +53,7 @@ export interface MetricHandler { * // Process metric event * } * } - * + * * const telemetry = new Telemetry({ * // Enable debug logging * logFilter: new LogFilter({ level: LogLevel.DEBUG }), @@ -62,16 +62,16 @@ export interface MetricHandler { * // Log to console and own handler to process further * metricHandlers: [new ConsoleMetricHandler(), ownMetricHandler], * }); - * + * * const logger = telemetry.getLogger('myLogger'); * logger.debug('Debug message'); - * + * * telemetry.logEvent({ name: 'somethingHappened', value: 42 }); - * + * * const logs = memoryLogHandler.getLogs(); * // Process logs * ``` - * + * * @param logFilter - Log filter to filter logs based on log level, default INFO * @param logHandlers - Log handlers to use for logging, see LogHandler implementations * @param metricHandlers - Metric handlers to use for logging, see MetricHandler implementations @@ -81,13 +81,7 @@ export class Telemetry { private logHandlers: LogHandler[]; private metricHandlers: MetricHandler[]; - constructor( - options?: { - logFilter?: LogFilter, - logHandlers?: LogHandler[], - metricHandlers?: MetricHandler[], - } - ) { + constructor(options?: { logFilter?: LogFilter; logHandlers?: LogHandler[]; metricHandlers?: MetricHandler[] }) { this.logFilter = options?.logFilter || new LogFilter(); this.logHandlers = options?.logHandlers || [new ConsoleLogHandler()]; this.metricHandlers = options?.metricHandlers || [new ConsoleMetricHandler()]; @@ -102,18 +96,22 @@ export class Telemetry { time: new Date(), event, }; - this.metricHandlers.forEach(handler => handler.onEvent(metric)); + this.metricHandlers.forEach((handler) => handler.onEvent(metric)); } } /** * Logger class that logs messages with different levels. - * + * * @param name - Name of the logger * @param handlers - Log handlers to use for logging, see LogHandler implementations */ class Logger { - constructor(private name: string, private filter: LogFilter, private handlers: LogHandler[]) { + constructor( + private name: string, + private filter: LogFilter, + private handlers: LogHandler[], + ) { this.name = name; this.filter = filter; this.handlers = handlers; @@ -160,15 +158,15 @@ class Logger { if (!this.filter.filter(log)) { return; } - this.handlers.forEach(handler => handler.log(log)); + this.handlers.forEach((handler) => handler.log(log)); } } /** * Logger class that logs messages with a prefix. - * + * * Example: - * + * * ```typescript * const logger = new Logger('myLogger', new LogFilter(), [new ConsoleLogHandler()]); * const loggerWithPrefix = new LoggerWithPrefix(logger, 'prefix'); @@ -176,7 +174,10 @@ class Logger { * ``` */ export class LoggerWithPrefix { - constructor(private logger: LoggerInterface, private prefix: string) { + constructor( + private logger: LoggerInterface, + private prefix: string, + ) { this.logger = logger; this.prefix = prefix; } @@ -201,28 +202,29 @@ export class LoggerWithPrefix { /** * Filter logs based on log level. It can be configured by global level or * per logger level. - * + * * @param globalLevel - Global log level, default INFO * @param loggerLevels - Log levels for specific loggers, default empty */ export class LogFilter { private logLevelMap = { - 'DEBUG': 0, - 'INFO': 1, - 'WARNING': 2, - 'ERROR': 3, - } + DEBUG: 0, + INFO: 1, + WARNING: 2, + ERROR: 3, + }; private globalLevel: number; private loggerLevels: { [loggerName: string]: number }; - constructor(options?: { - globalLevel?: LogLevel, - loggerLevels?: { [loggerName: string]: LogLevel }, - }) { + constructor(options?: { globalLevel?: LogLevel; loggerLevels?: { [loggerName: string]: LogLevel } }) { this.globalLevel = this.logLevelMap[options?.globalLevel || LogLevel.INFO]; - this.loggerLevels = Object.fromEntries(Object.entries(options?.loggerLevels || {}) - .map(([loggerName, level]) => [loggerName, this.logLevelMap[level]])); + this.loggerLevels = Object.fromEntries( + Object.entries(options?.loggerLevels || {}).map(([loggerName, level]) => [ + loggerName, + this.logLevelMap[level], + ]), + ); } /** @@ -243,16 +245,16 @@ export class LogFilter { /** * Log handler that logs to console. - * + * * @param formatter - Formatter to use for log messages, default BasicLogFormatter */ export class ConsoleLogHandler implements LogHandler { private logLevelMap = { - 'DEBUG': console.debug, // eslint-disable-line no-console - 'INFO': console.info, // eslint-disable-line no-console - 'WARNING': console.warn, // eslint-disable-line no-console - 'ERROR': console.error, // eslint-disable-line no-console - } + DEBUG: console.debug, // eslint-disable-line no-console + INFO: console.info, // eslint-disable-line no-console + WARNING: console.warn, // eslint-disable-line no-console + ERROR: console.error, // eslint-disable-line no-console + }; private formatter: LogFormatter; @@ -268,10 +270,10 @@ export class ConsoleLogHandler implements LogHandler { /** * Log handler that stores logs in memory with option to retrieve later. - * + * * Useful for keeping logs around and retrieve them on demand when an error * occures. - * + * * @param formatter - Formatter to use for log messages, default JSONLogFormatter * @param maxLogs - Maximum number of logs to store, default 10000 */ @@ -280,7 +282,10 @@ export class MemoryLogHandler implements LogHandler { private formatter: LogFormatter; - constructor(formatter?: LogFormatter, private maxLogs = 10000) { + constructor( + formatter?: LogFormatter, + private maxLogs = 10000, + ) { this.formatter = formatter || new JSONLogFormatter(); this.maxLogs = maxLogs; } @@ -305,7 +310,7 @@ export class MemoryLogHandler implements LogHandler { /** * Formatter that formats logs as JSON. - * + * * Useful for machine processing. */ export class JSONLogFormatter implements LogFormatter { @@ -323,16 +328,17 @@ export class JSONLogFormatter implements LogFormatter { /** * Formatter that formats logs as plain text. - * + * * Useful for human reading. */ export class BasicLogFormatter implements LogFormatter { format(log: LogRecord) { let errorDetails = ''; if (log.error) { - errorDetails = log.error instanceof Error - ? `\nError: ${log.error.message}\nStack:\n${log.error.stack}` - : `\nError: ${log.error}`; + errorDetails = + log.error instanceof Error + ? `\nError: ${log.error.message}\nStack:\n${log.error.stack}` + : `\nError: ${log.error}`; } return `${log.time.toISOString()} ${log.level} [${log.loggerName}] ${log.message}${errorDetails}`; } @@ -341,6 +347,8 @@ export class BasicLogFormatter implements LogFormatter { class ConsoleMetricHandler implements MetricHandler { onEvent(metric: MetricRecord) { // eslint-disable-next-line no-console - console.info(`${metric.time.toISOString()} INFO [metric] ${metric.event.eventName} ${JSON.stringify({ ...metric.event, name: undefined })}`); + console.info( + `${metric.time.toISOString()} INFO [metric] ${metric.event.eventName} ${JSON.stringify({ ...metric.event, name: undefined })}`, + ); } } diff --git a/js/sdk/src/tests/logger.ts b/js/sdk/src/tests/logger.ts index 36a1e51c..a3650fe4 100644 --- a/js/sdk/src/tests/logger.ts +++ b/js/sdk/src/tests/logger.ts @@ -6,5 +6,5 @@ export function getMockLogger(): Logger { info: jest.fn(), warn: jest.fn(), error: jest.fn(), - } + }; } diff --git a/js/sdk/src/tests/telemetry.ts b/js/sdk/src/tests/telemetry.ts index 78a1743c..218d27ff 100644 --- a/js/sdk/src/tests/telemetry.ts +++ b/js/sdk/src/tests/telemetry.ts @@ -1,5 +1,5 @@ -import { ProtonDriveTelemetry } from "../interface"; -import { getMockLogger } from "./logger"; +import { ProtonDriveTelemetry } from '../interface'; +import { getMockLogger } from './logger'; export function getMockTelemetry(): ProtonDriveTelemetry { return { diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 4fc4fb79..70d33f65 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -13,28 +13,28 @@ import { DecryptedNode as InternalNode, DecryptedRevision as InternalRevision } type InternalPartialNode = Pick< InternalNode, - 'uid' | - 'parentUid' | - 'name' | - 'keyAuthor' | - 'nameAuthor' | - 'directMemberRole' | - 'type' | - 'mediaType' | - 'isShared' | - 'creationTime' | - 'trashTime' | - 'activeRevision' | - 'folder' | - 'totalStorageSize' | - 'errors' | - 'shareId' + | 'uid' + | 'parentUid' + | 'name' + | 'keyAuthor' + | 'nameAuthor' + | 'directMemberRole' + | 'type' + | 'mediaType' + | 'isShared' + | 'creationTime' + | 'trashTime' + | 'activeRevision' + | 'folder' + | 'totalStorageSize' + | 'errors' + | 'shareId' >; type NodeUid = string | { uid: string } | Result<{ uid: string }, { uid: string }>; export function getUid(nodeUid: NodeUid): string { - if (typeof nodeUid === "string") { + if (typeof nodeUid === 'string') { return nodeUid; } // Directly passed NodeEntity or DegradedNode that has UID directly. @@ -52,13 +52,17 @@ export function getUids(nodeUids: NodeUid[]): string[] { return nodeUids.map(getUid); } -export async function *convertInternalNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { +export async function* convertInternalNodeIterator( + nodeIterator: AsyncGenerator, +): AsyncGenerator { for await (const node of nodeIterator) { yield convertInternalNode(node); } } -export async function *convertInternalMissingNodeIterator(nodeIterator: AsyncGenerator): AsyncGenerator { +export async function* convertInternalMissingNodeIterator( + nodeIterator: AsyncGenerator, +): AsyncGenerator { for await (const node of nodeIterator) { if ('missingUid' in node) { yield resultError(node); @@ -97,7 +101,9 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode return resultError({ ...baseNodeMetadata, name, - activeRevision: activeRevision?.ok ? resultOk(convertInternalRevision(activeRevision.value)) : activeRevision, + activeRevision: activeRevision?.ok + ? resultOk(convertInternalRevision(activeRevision.value)) + : activeRevision, errors: node.errors, } as PublicDegradedNode); } @@ -120,5 +126,5 @@ function convertInternalRevision(revision: InternalRevision): PublicRevision { claimedModificationTime: revision.claimedModificationTime, claimedDigests: revision.claimedDigests, claimedAdditionalMetadata: revision.claimedAdditionalMetadata, - } + }; } diff --git a/js/sdk/src/version.ts b/js/sdk/src/version.ts index 03115e03..c27c2caf 100644 --- a/js/sdk/src/version.ts +++ b/js/sdk/src/version.ts @@ -1,4 +1,3 @@ import { version } from '../package.json'; export const VERSION = version; - From c99f6a4f49401c6236306f01c5509999a1e368a9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 30 Jul 2025 11:33:02 +0200 Subject: [PATCH 174/791] Add C exports for the Drive client --- cs/Directory.Build.props | 2 + cs/headers/proton_sdk.h | 117 +++++++++-- .../InteropDownloadController.cs | 118 +++++++++++ .../InteropDriveErrorConverter.cs | 45 ++++ .../InteropFileDownloader.cs | 136 +++++++++++++ .../InteropFileUploader.cs | 147 ++++++++++++++ .../InteropProgressCallbackExtensions.cs | 28 +++ .../InteropProtonDriveClient.cs | 64 ++++++ .../InteropReadCallback.cs | 10 + .../InteropStream.cs | 192 ++++++++++++++++++ .../InteropUploadController.cs | 118 +++++++++++ .../InteropWriteCallback.cs | 10 + .../Proton.Drive.Sdk.CExports.csproj | 49 +++++ .../src/Proton.Drive.Sdk/Nodes/RevisionUid.cs | 5 + .../Nodes/Upload/FileUploader.cs | 6 +- .../Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 1 + .../src/Proton.Sdk.CExports/InteropArray.cs | 23 ++- .../InteropAsyncCallbackExtensions.cs | 61 +++++- ...llback.cs => InteropAsyncValueCallback.cs} | 8 +- .../InteropAsyncVoidCallback.cs | 11 + .../InteropProtonApiSession.cs | 110 ++++++---- .../InteropResultExtensions.cs | 75 +++++++ ...InteropTokenRefreshedCallbackExtensions.cs | 8 +- .../InteropTokensRefreshedCallback.cs | 10 - .../InteropValueCallback.cs | 10 + .../InteropVoidCallback.cs | 9 + .../Logging/InteropLogCallback.cs | 2 +- .../Logging/InteropLogger.cs | 2 +- .../Proton.Sdk.CExports.csproj | 7 +- .../Proton.Sdk.CExports/ResultExtensions.cs | 52 ----- .../src/Proton.Sdk.CExports/TaskExtensions.cs | 12 ++ cs/sdk/src/Proton.Sdk/Result.cs | 15 ++ cs/sdk/src/Proton.Sdk/Result{TError}.cs | 5 + cs/sdk/src/protos/drive.proto | 28 ++- 34 files changed, 1346 insertions(+), 150 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropReadCallback.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj rename cs/sdk/src/Proton.Sdk.CExports/{InteropAsyncCallback.cs => InteropAsyncValueCallback.cs} (55%) create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropTokensRefreshedCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/ResultExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/TaskExtensions.cs diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index c779adc8..e3241c21 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -17,6 +17,8 @@ true + lib + false diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h index 78e539a2..a9cbbc48 100644 --- a/cs/headers/proton_sdk.h +++ b/cs/headers/proton_sdk.h @@ -9,22 +9,33 @@ typedef struct { size_t length; } ByteArray; +typedef void ArrayFunction(const void* state, ByteArray array); + typedef struct { - const void* state; - void (*on_success)(const void*, ByteArray); - void (*on_failure)(const void*, ByteArray); + ArrayFunction* success_function; + ArrayFunction* failure_function; intptr_t cancellation_token_source_handle; -} AsyncCallback; +} AsyncArrayCallback; typedef struct { - const void* state; - void (*callback)(const void*, ByteArray); -} Callback; + void (*success_function)(const void* state, int returnValue); + ArrayFunction* failure_function; + intptr_t cancellation_token_source_handle; +} AsyncIntCallback; + +typedef struct { + void (*success_function)(const void* state); + ArrayFunction* failure_function; + intptr_t cancellation_token_source_handle; +} AsyncVoidCallback; typedef struct { - AsyncCallback async_callback; - Callback progress_callback; -} AsyncCallbackWithProgress; + ArrayFunction* function; +} ArrayCallback; + +// These callbacks receive yet another callback to allow asynchronous read/writes +typedef void ReadCallback(const void* state, ByteArray buffer, const void* caller_state, AsyncIntCallback callback); +typedef void WriteCallback(const void* state, ByteArray buffer, const void* caller_state, AsyncVoidCallback callback); intptr_t cancellation_token_source_create(); @@ -38,7 +49,8 @@ void cancellation_token_source_free( int session_begin( ByteArray request, - AsyncCallback callback + const void* caller_state, + AsyncArrayCallback result_callback ); int session_resume( @@ -54,14 +66,91 @@ int session_renew( int session_end( intptr_t session_handle, - AsyncCallback callback + const void* caller_state, + AsyncVoidCallback result_callback +); + +intptr_t session_tokens_refreshed_subscribe( + intptr_t session_handle, + const void* caller_state, + ArrayCallback tokens_refreshed_callback +); + +void session_tokens_refreshed_unsubscribe( + intptr_t subscription_handle ); void session_free(intptr_t session_handle); int logger_provider_create( - Callback log_callback, + ArrayCallback log_callback, intptr_t* logger_provider_handle ); -#endif PROTON_SDK_H +// Drive + +intptr_t drive_client_create( + intptr_t session_handle +); + +void drive_client_free(intptr_t client_handle); + +int get_file_uploader( + intptr_t client_handle, + ByteArray request, // FileUploaderProvisionRequest + const void* caller_state, + AsyncArrayCallback result_callback +); + +intptr_t upload_from_stream( + intptr_t uploader_handle, + ByteArray request, // FileUploadRequest + const void* caller_state, + ReadCallback* read_callback, + AsyncArrayCallback progress_callback, + intptr_t cancellation_token_source_handle +); + +void file_uploader_free(intptr_t file_uploader_handle); + +int upload_controller_set_completion_callback( + intptr_t upload_controller_handle, + const void* caller_state, + AsyncArrayCallback result_callback); + +void upload_controller_pause(intptr_t file_uploader_handle); + +void upload_controller_resume(intptr_t file_uploader_handle); + +void upload_controller_free(intptr_t file_uploader_handle); + +int get_file_downloader( + intptr_t client_handle, + ByteArray request, // FileDownloaderProvisionRequest + const void* caller_state, + AsyncArrayCallback result_callback +); + +intptr_t download_to_stream( + intptr_t downloader_handle, + const void* caller_state, + WriteCallback* write_callback, + AsyncArrayCallback progress_callback, + intptr_t cancellation_token_source_handle +); + +void file_downloader_free(intptr_t file_downloader_handle); + +int download_controller_set_completion_callback( + intptr_t download_controller_handle, + const void* caller_state, + AsyncArrayCallback result_callback +); + +void download_controller_pause(intptr_t file_downloader_handle); + +void download_controller_resume(intptr_t file_downloader_handle); + +void download_controller_free(intptr_t file_downloader_handle); + +#endif // PROTON_SDK_H diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs new file mode 100644 index 00000000..2050ac4c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs @@ -0,0 +1,118 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Sdk; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropDownloadController +{ + private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out DownloadController downloadController) + { + if (handle == 0) + { + downloadController = null; + return false; + } + + var gcHandle = GCHandle.FromIntPtr(handle); + + downloadController = gcHandle.Target as DownloadController; + + return downloadController is not null; + } + + [UnmanagedCallersOnly(EntryPoint = "download_controller_set_completion_callback", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int NativeDownloadToStream(nint downloadControllerHandle, void* callerState, InteropAsyncVoidCallback asyncCallback) + { + try + { + if (!TryGetFromHandle(downloadControllerHandle, out var downloadController)) + { + return -1; + } + + return asyncCallback.InvokeFor(callerState, _ => InteropGetCompletion(downloadController)); + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "download_controller_pause", CallConvs = [typeof(CallConvCdecl)])] + private static int NativePause(nint downloadControllerHandle) + { + try + { + if (!TryGetFromHandle(downloadControllerHandle, out var downloadController)) + { + return -1; + } + + downloadController.Pause(); + + return 0; + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "download_controller_resume", CallConvs = [typeof(CallConvCdecl)])] + private static int NativeResume(nint downloadControllerHandle) + { + try + { + if (!TryGetFromHandle(downloadControllerHandle, out var downloadController)) + { + return -1; + } + + downloadController.Resume(); + + return 0; + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "download_controller_free", CallConvs = [typeof(CallConvCdecl)])] + private static void NativeFree(nint downloadControllerHandle) + { + try + { + var gcHandle = GCHandle.FromIntPtr(downloadControllerHandle); + + if (gcHandle.Target is not DownloadController) + { + return; + } + + gcHandle.Free(); + } + catch + { + // Ignore + } + } + + private static async ValueTask>> InteropGetCompletion(DownloadController downloadController) + { + try + { + await downloadController.Completion.ConfigureAwait(false); + + return Result>.Success; + } + catch (Exception e) + { + return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs new file mode 100644 index 00000000..6ae45ae2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -0,0 +1,45 @@ +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Upload.Verification; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropDriveErrorConverter +{ + private const int UnknownDecryptionErrorPrimaryCode = 0; + private const int ShareMetadataDecryptionErrorPrimaryCode = 1; + private const int NodeMetadataDecryptionErrorPrimaryCode = 2; + private const int FileContentsDecryptionErrorPrimaryCode = 3; + private const int UploadKeyMismatchErrorPrimaryCode = 4; + + public static void SetDomainAndCodes(Error error, Exception exception) + { + switch (exception) + { + case NodeMetadataDecryptionException e: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = NodeMetadataDecryptionErrorPrimaryCode; + error.SecondaryCode = (long)e.Part; + break; + + case FileContentsDecryptionException: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = FileContentsDecryptionErrorPrimaryCode; + break; + + case NodeKeyAndSessionKeyMismatchException: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode; + break; + + case SessionKeyAndDataPacketMismatchException: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode; + break; + + default: + InteropErrorConverter.SetDomainAndCodes(error, exception); + break; + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs new file mode 100644 index 00000000..a3038599 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -0,0 +1,136 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Sdk; +using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; +using RevisionUid = Proton.Drive.Sdk.Nodes.RevisionUid; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropFileDownloader +{ + private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out FileDownloader fileDownloader) + { + if (handle == 0) + { + fileDownloader = null; + return false; + } + + var gcHandle = GCHandle.FromIntPtr(handle); + + fileDownloader = gcHandle.Target as FileDownloader; + + return fileDownloader is not null; + } + + [UnmanagedCallersOnly(EntryPoint = "get_file_downloader", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int NativeGetFileDownloader( + nint clientHandle, + InteropArray requestBytes, + void* callerState, + InteropAsyncValueCallback resultCallback) + { + try + { + if (!InteropProtonDriveClient.TryGetFromHandle(clientHandle, out var client)) + { + return -1; + } + + return resultCallback.InvokeFor(callerState, ct => InteropGetFileDownloaderAsync(client, requestBytes, ct)); + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "download_to_stream", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe nint NativeDownloadToStream( + nint fileDownloaderHandle, + void* callerState, + InteropWriteCallback writeCallback, + InteropValueCallback> progressCallback, + nint cancellationTokenSourceHandle) + { + try + { + if (!TryGetFromHandle(fileDownloaderHandle, out var downloader)) + { + return -1; + } + + if (!InteropCancellationTokenSource.TryGetTokenFromHandle(cancellationTokenSourceHandle, out var cancellationToken)) + { + return -1; + } + + var stream = new InteropStream(callerState, writeCallback); + + var downloadController = downloader.DownloadToStream( + stream, + (completed, total) => progressCallback.UpdateProgress(completed, total), + cancellationToken); + + return GCHandle.ToIntPtr(GCHandle.Alloc(downloadController)); + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "file_downloader_free", CallConvs = [typeof(CallConvCdecl)])] + private static void NativeFree(nint fileDownloaderHandle) + { + try + { + var gcHandle = GCHandle.FromIntPtr(fileDownloaderHandle); + + if (gcHandle.Target is not FileDownloader fileDownloader) + { + return; + } + + try + { + fileDownloader.Dispose(); + } + finally + { + gcHandle.Free(); + } + } + catch + { + // Ignore + } + } + + private static async ValueTask>> InteropGetFileDownloaderAsync( + ProtonDriveClient client, + InteropArray requestBytes, + CancellationToken cancellationToken) + { + try + { + var request = FileDownloaderProvisionRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + if (!RevisionUid.TryParse(request.RevisionUid, out var revisionUid)) + { + throw new ArgumentException($"Invalid revision UID {revisionUid}", nameof(requestBytes)); + } + + var downloader = await client.GetFileDownloaderAsync(revisionUid.Value, cancellationToken).ConfigureAwait(false); + + return GCHandle.ToIntPtr(GCHandle.Alloc(downloader)); + } + catch (Exception e) + { + return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs new file mode 100644 index 00000000..daa51ef9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -0,0 +1,147 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Sdk; +using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropFileUploader +{ + private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out FileUploader uploader) + { + if (handle == 0) + { + uploader = null; + return false; + } + + var gcHandle = GCHandle.FromIntPtr(handle); + + uploader = gcHandle.Target as FileUploader; + + return uploader is not null; + } + + [UnmanagedCallersOnly(EntryPoint = "get_file_uploader", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int NativeCreate( + nint clientHandle, + InteropArray requestBytes, + void* callerState, + InteropAsyncValueCallback resultCallback) + { + try + { + if (!InteropProtonDriveClient.TryGetFromHandle(clientHandle, out var client)) + { + return -1; + } + + return resultCallback.InvokeFor(callerState, ct => InteropGetFileUploaderAsync(client, requestBytes, ct)); + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "upload_from_stream", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe nint NativeUploadFromStream( + nint fileUploaderHandle, + InteropArray requestBytes, + void* callerState, + InteropReadCallback readCallback, + InteropValueCallback> progressCallback, + nint cancellationTokenSourceHandle) + { + try + { + if (!TryGetFromHandle(fileUploaderHandle, out var uploader)) + { + return -1; + } + + if (!InteropCancellationTokenSource.TryGetTokenFromHandle(cancellationTokenSourceHandle, out var cancellationToken)) + { + return -1; + } + + var request = FileUploadRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + if (!NodeUid.TryParse(request.ParentFolderUid, out var parentUid)) + { + return -1; + } + + var stream = new InteropStream(callerState, readCallback); + + var uploadController = uploader.UploadFromStream( + parentUid.Value, + stream, + request.HasThumbnail ? [new FileSample(FileSamplePurpose.Thumbnail, request.Thumbnail.ToByteArray())] : [], + request.CreateNewRevisionIfExists, + (completed, total) => progressCallback.UpdateProgress(completed, total), + cancellationToken); + + return GCHandle.ToIntPtr(GCHandle.Alloc(uploadController)); + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "file_uploader_free", CallConvs = [typeof(CallConvCdecl)])] + private static void NativeFree(nint fileUploaderHandle) + { + try + { + var gcHandle = GCHandle.FromIntPtr(fileUploaderHandle); + + if (gcHandle.Target is not FileUploader fileUploader) + { + return; + } + + try + { + fileUploader.Dispose(); + } + finally + { + gcHandle.Free(); + } + } + catch + { + // Ignore + } + } + + private static async ValueTask>> InteropGetFileUploaderAsync( + ProtonDriveClient client, + InteropArray requestBytes, + CancellationToken cancellationToken) + { + try + { + var request = FileUploaderProvisionRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + var uploader = await client.GetFileUploaderAsync( + request.Name, + request.MediaType, + DateTimeOffset.FromUnixTimeSeconds(request.LastModificationDate).DateTime, + request.FileSize, + cancellationToken).ConfigureAwait(false); + + return GCHandle.ToIntPtr(GCHandle.Alloc(uploader)); + } + catch (Exception e) + { + return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs new file mode 100644 index 00000000..63364a67 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs @@ -0,0 +1,28 @@ +using Google.Protobuf; +using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropProgressCallbackExtensions +{ + internal static unsafe void UpdateProgress(this InteropValueCallback> progressCallback, long completed, long total) + { + var progressUpdate = new ProgressUpdate + { + BytesCompleted = completed, + BytesInTotal = total, + }; + + var messageBytes = InteropArray.FromMemory(progressUpdate.ToByteArray()); + + try + { + progressCallback.Invoke(progressCallback.Invoke, messageBytes); + } + finally + { + messageBytes.Free(); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs new file mode 100644 index 00000000..10edc18c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropProtonDriveClient +{ + internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out ProtonDriveClient client) + { + if (handle == 0) + { + client = null; + return false; + } + + var gcHandle = GCHandle.FromIntPtr(handle); + + client = gcHandle.Target as ProtonDriveClient; + + return client is not null; + } + + [UnmanagedCallersOnly(EntryPoint = "drive_client_create", CallConvs = [typeof(CallConvCdecl)])] + private static nint NativeCreate(nint sessionHandle) + { + try + { + if (!InteropProtonApiSession.TryGetFromHandle(sessionHandle, out var session)) + { + return 0; + } + + var client = new ProtonDriveClient(session); + + return GCHandle.ToIntPtr(GCHandle.Alloc(client)); + } + catch + { + return 0; + } + } + + [UnmanagedCallersOnly(EntryPoint = "drive_client_free", CallConvs = [typeof(CallConvCdecl)])] + private static void NativeFree(nint handle) + { + try + { + var gcHandle = GCHandle.FromIntPtr(handle); + + if (gcHandle.Target is not ProtonDriveClient) + { + return; + } + + gcHandle.Free(); + } + catch + { + // Ignore + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropReadCallback.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropReadCallback.cs new file mode 100644 index 00000000..1f312403 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropReadCallback.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropReadCallback +{ + public readonly delegate* unmanaged[Cdecl], nint, InteropValueCallback, int> Invoke; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs new file mode 100644 index 00000000..2cd44aa4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs @@ -0,0 +1,192 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal sealed unsafe class InteropStream : Stream +{ + private readonly void* _callerState; + private readonly InteropReadCallback _readCallback; + private readonly InteropWriteCallback _writeCallback; + + public InteropStream(void* callerState, InteropReadCallback readCallback) + { + _callerState = callerState; + _readCallback = readCallback; + _writeCallback = default; + } + + public InteropStream(void* callerState, InteropWriteCallback writeCallback) + { + _callerState = callerState; + _readCallback = default; + _writeCallback = writeCallback; + } + + public override bool CanRead => _readCallback.Invoke != null; + public override bool CanSeek => false; + public override bool CanWrite => _writeCallback.Invoke != null; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + return ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_readCallback.Invoke == null) + { + throw new NotSupportedException("Reading not supported"); + } + + var memoryHandle = buffer.Pin(); + + try + { + var operation = new ReadOperation(memoryHandle); + var operationHandle = GCHandle.Alloc(operation); + + try + { + _readCallback.Invoke( + _callerState, + new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), + GCHandle.ToIntPtr(operationHandle), + new InteropValueCallback(&OnReadDone)); + + return new ValueTask(operation.Task); + } + catch + { + operationHandle.Free(); + throw; + } + } + catch + { + memoryHandle.Dispose(); + throw; + } + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (_writeCallback.Invoke == null) + { + throw new NotSupportedException("Writing not supported"); + } + + var memoryHandle = buffer.Pin(); + + try + { + var operation = new WriteOperation(memoryHandle); + var operationHandle = GCHandle.Alloc(operation); + + try + { + _writeCallback.Invoke( + _callerState, + new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), + GCHandle.ToIntPtr(operationHandle), + new InteropVoidCallback(&OnWriteDone)); + + return new ValueTask(operation.Task); + } + catch + { + operationHandle.Free(); + throw; + } + } + catch + { + memoryHandle.Dispose(); + throw; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnReadDone(void* state, int numberOfBytesRead) + { + var operation = (ReadOperation)GCHandle.FromIntPtr(new nint(state)).Target!; + + operation.Complete(numberOfBytesRead); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnWriteDone(void* state) + { + var operation = (WriteOperation)GCHandle.FromIntPtr(new nint(state)).Target!; + + operation.Complete(); + } + + private sealed class ReadOperation(MemoryHandle memoryHandle) + { + private readonly TaskCompletionSource _taskCompletionSource = new(); + + private MemoryHandle _memoryHandle = memoryHandle; + + public Task Task => _taskCompletionSource.Task; + + public void Complete(int bytesRead) + { + _taskCompletionSource.SetResult(bytesRead); + _memoryHandle.Dispose(); + } + } + + private sealed class WriteOperation(MemoryHandle memoryHandle) + { + private readonly TaskCompletionSource _taskCompletionSource = new(); + + private MemoryHandle _memoryHandle = memoryHandle; + + public Task Task => _taskCompletionSource.Task; + + public void Complete() + { + _taskCompletionSource.SetResult(); + _memoryHandle.Dispose(); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs new file mode 100644 index 00000000..c8dc8975 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -0,0 +1,118 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Sdk; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropUploadController +{ + private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out UploadController uploadController) + { + if (handle == 0) + { + uploadController = null; + return false; + } + + var gcHandle = GCHandle.FromIntPtr(handle); + + uploadController = gcHandle.Target as UploadController; + + return uploadController is not null; + } + + [UnmanagedCallersOnly(EntryPoint = "upload_controller_set_completion_callback", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int NativeSetCompletionCallback(nint uploadControllerHandle, void* callerState, InteropAsyncVoidCallback asyncCallback) + { + try + { + if (!TryGetFromHandle(uploadControllerHandle, out var uploadController)) + { + return -1; + } + + return asyncCallback.InvokeFor(callerState, _ => InteropGetCompletion(uploadController)); + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "upload_controller_pause", CallConvs = [typeof(CallConvCdecl)])] + private static int NativePause(nint uploadControllerHandle) + { + try + { + if (!TryGetFromHandle(uploadControllerHandle, out var uploadController)) + { + return -1; + } + + uploadController.Pause(); + + return 0; + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "upload_controller_resume", CallConvs = [typeof(CallConvCdecl)])] + private static int NativeResume(nint uploadControllerHandle) + { + try + { + if (!TryGetFromHandle(uploadControllerHandle, out var uploadController)) + { + return -1; + } + + uploadController.Resume(); + + return 0; + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "upload_controller_free", CallConvs = [typeof(CallConvCdecl)])] + private static void NativeFree(nint uploadControllerHandle) + { + try + { + var gcHandle = GCHandle.FromIntPtr(uploadControllerHandle); + + if (gcHandle.Target is not UploadController) + { + return; + } + + gcHandle.Free(); + } + catch + { + // Ignore + } + } + + private static async ValueTask>> InteropGetCompletion(UploadController uploadController) + { + try + { + await uploadController.Completion.ConfigureAwait(false); + + return Result>.Success; + } + catch (Exception e) + { + return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs new file mode 100644 index 00000000..58da12bd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropWriteCallback +{ + public readonly delegate* unmanaged[Cdecl], nint, InteropVoidCallback, void> Invoke; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj new file mode 100644 index 00000000..baaafc17 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -0,0 +1,49 @@ + + + + $(NativeLibPrefix)proton_drive_sdk + true + true + false + proton_crypto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + true + + + \ No newline at end of file diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs index ded9df87..e5bb3848 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs @@ -22,6 +22,11 @@ public override string ToString() return $"{NodeUid}~{RevisionId}"; } + public static bool TryParse(string s, [NotNullWhen(true)] out RevisionUid? result) + { + return ICompositeUid.TryParse(s, out result); + } + static bool ICompositeUid.TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out RevisionUid? uid) { if (!ICompositeUid.TryParse(baseUidString, out var nodeUid)) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index a918fa31..3e95bf4b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -26,7 +26,7 @@ internal FileUploader( _remainingNumberOfBlocks = expectedNumberOfBlocks; } - public UploadController UploadStream( + public UploadController UploadFromStream( NodeUid parentFolderUid, Stream contentInputStream, IEnumerable samples, @@ -34,7 +34,7 @@ public UploadController UploadStream( Action onProgress, CancellationToken cancellationToken) { - var task = UploadStreamAsync(parentFolderUid, contentInputStream, samples, createNewRevisionIfExists, onProgress, cancellationToken); + var task = UploadFromStreamAsync(parentFolderUid, contentInputStream, samples, createNewRevisionIfExists, onProgress, cancellationToken); return new UploadController(task); } @@ -50,7 +50,7 @@ public void Dispose() _remainingNumberOfBlocks = 0; } - private async Task UploadStreamAsync( + private async Task UploadFromStreamAsync( NodeUid parentFolderUid, Stream contentInputStream, IEnumerable samples, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj index f10e565a..6ef601db 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -16,6 +16,7 @@ + diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs index 6337a285..65122c2c 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs @@ -3,16 +3,17 @@ namespace Proton.Sdk.CExports; [StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropArray(byte* bytes, nint length) +internal readonly unsafe struct InteropArray(T* pointer, nint length) + where T : unmanaged { - private readonly byte* _bytes = bytes; + private readonly void* _pointer = pointer; private readonly nint _length = length; - public static InteropArray Null => default; + public static InteropArray Null => default; - public bool IsNullOrEmpty => _bytes is null || _length == 0; + public bool IsNullOrEmpty => _pointer is null || _length == 0; - public static InteropArray FromMemory(ReadOnlyMemory memory) + public static InteropArray FromMemory(ReadOnlyMemory memory) { if (memory.Length == 0) { @@ -21,28 +22,28 @@ public static InteropArray FromMemory(ReadOnlyMemory memory) var interopBytes = NativeMemory.Alloc((nuint)memory.Length); - memory.Span.CopyTo(new Span(interopBytes, memory.Length)); + memory.Span.CopyTo(new Span(interopBytes, memory.Length)); - return new InteropArray((byte*)interopBytes, memory.Length); + return new InteropArray((T*)interopBytes, memory.Length); } public byte[] ToArray() { - return !IsNullOrEmpty ? new ReadOnlySpan(_bytes, (int)_length).ToArray() : []; + return !IsNullOrEmpty ? new ReadOnlySpan(_pointer, (int)_length).ToArray() : []; } public byte[]? ToArrayOrNull() { - return !IsNullOrEmpty ? new ReadOnlySpan(_bytes, (int)_length).ToArray() : null; + return !IsNullOrEmpty ? new ReadOnlySpan(_pointer, (int)_length).ToArray() : null; } public ReadOnlySpan AsReadOnlySpan() { - return !IsNullOrEmpty ? new ReadOnlySpan(_bytes, (int)_length) : null; + return !IsNullOrEmpty ? new ReadOnlySpan(_pointer, (int)_length) : null; } public void Free() { - NativeMemory.Free(_bytes); + NativeMemory.Free(_pointer); } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs index 91bc0c43..1204ed48 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs @@ -2,7 +2,11 @@ internal static class InteropAsyncCallbackExtensions { - public static unsafe int InvokeFor(this InteropAsyncCallback callback, Func>> asyncFunction) + public static unsafe int InvokeFor( + this InteropAsyncValueCallback callback, + void* callerState, + Func>>> asyncFunction) + where TResult : unmanaged { if (!InteropCancellationTokenSource.TryGetTokenFromHandle(callback.CancellationTokenSourceHandle, out var cancellationToken)) { @@ -10,18 +14,37 @@ public static unsafe int InvokeFor(this InteropAsyncCallback callback, Func callback.OnSuccess(callback.State, value), - error => callback.OnFailure(callback.State, error), + value => callback.OnSuccess(callerState, value), + error => callback.OnFailure(callerState, error), asyncFunction, - cancellationToken); + cancellationToken).RunInBackground(); return 0; } - private static async void Use( + public static unsafe int InvokeFor( + this InteropAsyncVoidCallback callback, + void* callerState, + Func>>> asyncFunction) + { + if (!InteropCancellationTokenSource.TryGetTokenFromHandle(callback.CancellationTokenSourceHandle, out var cancellationToken)) + { + return -1; + } + + Use( + () => callback.OnSuccess(callerState), + error => callback.OnFailure(callerState, error), + asyncFunction, + cancellationToken).RunInBackground(); + + return 0; + } + + private static async ValueTask Use( Action onSuccess, - Action onFailure, - Func>> asyncFunction, + Action> onFailure, + Func>>> asyncFunction, CancellationToken cancellationToken) { try @@ -41,4 +64,28 @@ private static async void Use( // TODO: log } } + + private static async ValueTask Use( + Action onSuccess, + Action> onFailure, + Func>>> asyncFunction, + CancellationToken cancellationToken) + { + try + { + var result = await asyncFunction.Invoke(cancellationToken).ConfigureAwait(false); + + if (result.TryGetError(out var error)) + { + onFailure(error); + return; + } + + onSuccess(); + } + catch + { + // TODO: log + } + } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs similarity index 55% rename from cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallback.cs rename to cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs index a503c96a..d7e9cac0 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallback.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs @@ -3,10 +3,10 @@ namespace Proton.Sdk.CExports; [StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropAsyncCallback +internal readonly unsafe struct InteropAsyncValueCallback + where T : unmanaged { - public readonly void* State; - public readonly delegate* unmanaged[Cdecl] OnSuccess; - public readonly delegate* unmanaged[Cdecl] OnFailure; + public readonly delegate* unmanaged[Cdecl] OnSuccess; + public readonly delegate* unmanaged[Cdecl], void> OnFailure; public readonly nint CancellationTokenSourceHandle; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs new file mode 100644 index 00000000..f1914a3d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs @@ -0,0 +1,11 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropAsyncVoidCallback +{ + public readonly delegate* unmanaged[Cdecl] OnSuccess; + public readonly delegate* unmanaged[Cdecl], void> OnFailure; + public readonly nint CancellationTokenSourceHandle; +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs index 57f4b90b..27c5bf9f 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +using Google.Protobuf; using Microsoft.Extensions.Logging; using Proton.Sdk.Authentication; using Proton.Sdk.Caching; @@ -27,11 +28,11 @@ internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out Pr } [UnmanagedCallersOnly(EntryPoint = "session_begin", CallConvs = [typeof(CallConvCdecl)])] - private static int NativeBegin(InteropArray sessionBeginRequestBytes, InteropAsyncCallback callback) + private static unsafe int NativeBegin(InteropArray requestBytes, void* callerState, InteropAsyncValueCallback> resultCallback) { try { - return callback.InvokeFor(ct => InteropBeginAsync(sessionBeginRequestBytes, ct)); + return resultCallback.InvokeFor(callerState, ct => InteropBeginAsync(requestBytes, ct)); } catch { @@ -40,7 +41,7 @@ private static int NativeBegin(InteropArray sessionBeginRequestBytes, InteropAsy } [UnmanagedCallersOnly(EntryPoint = "session_resume", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeResume(InteropArray requestBytes, nint* sessionHandle) + private static unsafe int NativeResume(InteropArray requestBytes, nint* sessionHandle) { try { @@ -96,11 +97,11 @@ private static unsafe int NativeResume(InteropArray requestBytes, nint* sessionH } [UnmanagedCallersOnly(EntryPoint = "session_renew", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeRenew(nint oldSessionHandle, InteropArray sessionRenewRequestBytes, nint* newSessionHandle) + private static unsafe int NativeRenew(nint oldSessionHandle, InteropArray requestBytes, nint* newSessionHandle) { try { - var request = SessionRenewRequest.Parser.ParseFrom(sessionRenewRequestBytes.AsReadOnlySpan()); + var request = SessionRenewRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); if (!TryGetFromHandle(oldSessionHandle, out var expiredSession)) { @@ -129,7 +130,7 @@ private static unsafe int NativeRenew(nint oldSessionHandle, InteropArray sessio } [UnmanagedCallersOnly(EntryPoint = "session_end", CallConvs = [typeof(CallConvCdecl), typeof(CallConvMemberFunction)])] - private static int NativeEnd(nint sessionHandle, InteropAsyncCallback callback) + private static unsafe int NativeEnd(nint sessionHandle, void* callerState, InteropAsyncVoidCallback resultCallback) { try { @@ -138,7 +139,7 @@ private static int NativeEnd(nint sessionHandle, InteropAsyncCallback callback) return -1; } - callback.InvokeFor(_ => InteropEndAsync(session)); + resultCallback.InvokeFor(callerState, _ => InteropEndAsync(session)); return 0; } @@ -149,7 +150,10 @@ private static int NativeEnd(nint sessionHandle, InteropAsyncCallback callback) } [UnmanagedCallersOnly(EntryPoint = "session_tokens_refreshed_subscribe", CallConvs = [typeof(CallConvCdecl)])] - private static nint NativeSubscribeTokensRefreshed(nint sessionHandle, InteropTokensRefreshedCallback tokensRefreshedCallback) + private static unsafe nint NativeSubscribeTokensRefreshed( + nint sessionHandle, + void* callerState, + InteropValueCallback> tokensRefreshedCallback) { try { @@ -158,11 +162,9 @@ private static nint NativeSubscribeTokensRefreshed(nint sessionHandle, InteropTo return 0; } - Action handler = (accessToken, refreshToken) => tokensRefreshedCallback.Invoke(accessToken, refreshToken); + var subscription = TokensRefreshedSubscription.Create(session, callerState, tokensRefreshedCallback); - session.TokenCredential.TokensRefreshed += handler; - - return GCHandle.ToIntPtr(GCHandle.Alloc(handler)); + return GCHandle.ToIntPtr(GCHandle.Alloc(subscription)); } catch { @@ -171,27 +173,20 @@ private static nint NativeSubscribeTokensRefreshed(nint sessionHandle, InteropTo } [UnmanagedCallersOnly(EntryPoint = "session_tokens_refreshed_unsubscribe", CallConvs = [typeof(CallConvCdecl)])] - private static int NativeUnsubscribeTokensRefreshed(nint sessionHandle, nint subscriptionHandle) + private static void NativeUnsubscribeTokensRefreshed(nint subscriptionHandle) { try { - if (!TryGetFromHandle(sessionHandle, out var session)) + if (!TryGetTokensExpiredSubscriptionFromHandle(subscriptionHandle, out var unregisterAction)) { - return -1; - } - - if (!TryGetTokensExpiredSubscriptionFromHandle(subscriptionHandle, out var handler)) - { - return -1; + return; } - session.TokenCredential.TokensRefreshed -= handler; - - return 0; + unregisterAction.Dispose(); } catch { - return -1; + // Ignore } } @@ -215,7 +210,9 @@ private static void NativeFree(nint handle) } } - private static async ValueTask> InteropBeginAsync(InteropArray requestBytes, CancellationToken cancellationToken) + private static async ValueTask, InteropArray>> InteropBeginAsync( + InteropArray requestBytes, + CancellationToken cancellationToken) { try { @@ -256,40 +253,85 @@ private static async ValueTask> InteropBeginA cancellationToken).ConfigureAwait(false); var handle = GCHandle.ToIntPtr(GCHandle.Alloc(session)); - return ResultExtensions.Success(new IntResponse { Value = handle }); + return InteropResultExtensions.Success(new IntResponse { Value = handle }); } catch (Exception e) { - return ResultExtensions.Failure(e, InteropErrorConverter.SetDomainAndCodes); + return InteropResultExtensions.Failure>(e, InteropErrorConverter.SetDomainAndCodes); } } - private static async ValueTask> InteropEndAsync(ProtonApiSession session) + private static async ValueTask>> InteropEndAsync(ProtonApiSession session) { try { await session.EndAsync().ConfigureAwait(false); - return ResultExtensions.Success(); + return Result>.Success; } catch (Exception e) { - return ResultExtensions.Failure(e, InteropErrorConverter.SetDomainAndCodes); + return InteropResultExtensions.Failure(e, InteropErrorConverter.SetDomainAndCodes); } } - private static bool TryGetTokensExpiredSubscriptionFromHandle(nint handle, [MaybeNullWhen(false)] out Action handler) + private static bool TryGetTokensExpiredSubscriptionFromHandle(nint handle, [MaybeNullWhen(false)] out TokensRefreshedSubscription subscription) { if (handle == 0) { - handler = null; + subscription = null; return false; } var gcHandle = GCHandle.FromIntPtr(handle); - handler = gcHandle.Target as Action; + subscription = gcHandle.Target as TokensRefreshedSubscription; + + return subscription is not null; + } + + private sealed unsafe class TokensRefreshedSubscription : IDisposable + { + private readonly ProtonApiSession _session; + private readonly void* _callerState; + private readonly InteropValueCallback> _tokensRefreshedCallback; + + private TokensRefreshedSubscription(ProtonApiSession session, void* callerState, InteropValueCallback> tokensRefreshedCallback) + { + _session = session; + _callerState = callerState; + _tokensRefreshedCallback = tokensRefreshedCallback; + } + + public static TokensRefreshedSubscription Create( + ProtonApiSession session, + void* callerState, + InteropValueCallback> tokensRefreshedCallback) + { + var subscription = new TokensRefreshedSubscription(session, callerState, tokensRefreshedCallback); + + session.TokenCredential.TokensRefreshed += subscription.Handle; - return handler is not null; + return subscription; + } + + public void Dispose() + { + _session.TokenCredential.TokensRefreshed -= Handle; + } + + private void Handle(string accessToken, string refreshToken) + { + var tokensMessage = InteropArray.FromMemory(new SessionTokens { AccessToken = accessToken, RefreshToken = refreshToken }.ToByteArray()); + + try + { + _tokensRefreshedCallback.Invoke(_callerState, tokensMessage); + } + finally + { + tokensMessage.Free(); + } + } } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs new file mode 100644 index 00000000..1a0bd618 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs @@ -0,0 +1,75 @@ +using System.Runtime.InteropServices; +using System.Text; +using Google.Protobuf; + +namespace Proton.Sdk.CExports; + +internal static class InteropResultExtensions +{ + internal static Result, InteropArray> Success() + { + return new Result, InteropArray>(value: InteropArray.Null); + } + + internal static Result, InteropArray> Success(IMessage data) + { + return new Result, InteropArray>( + value: InteropArray.FromMemory(data.ToByteArray())); + } + + internal static unsafe Result, InteropArray> Success(string value) + { + var maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length); + var ptr = (byte*)NativeMemory.Alloc((nuint)maxByteCount); + + var length = Encoding.UTF8.GetBytes(value, new Span(ptr, maxByteCount)); + + return Result, InteropArray>.Success(new InteropArray(ptr, length)); + } + + internal static Result> Failure(Exception exception, int defaultCode) + { + if (exception is ProtonApiException protonApiException) + { + return Failure((int)protonApiException.Code, protonApiException.Message); + } + + return Failure(defaultCode, exception.Message); + } + + internal static Result> Failure(Exception exception, int defaultCode) + { + if (exception is ProtonApiException protonApiException) + { + return Failure((int)protonApiException.Code, protonApiException.Message); + } + + return Failure(defaultCode, exception.Message); + } + + internal static Result> Failure(Exception exception, Action setDomainAndCodesFunction) + { + var error = exception.ToInteropError(setDomainAndCodesFunction); + + return new Result>(error: InteropArray.FromMemory(error.ToByteArray())); + } + + internal static Result> Failure(Exception exception, Action setDomainAndCodesFunction) + { + var error = exception.ToInteropError(setDomainAndCodesFunction); + + return new Result>(error: InteropArray.FromMemory(error.ToByteArray())); + } + + private static Result> Failure(int code, string message) + { + return new Result>( + error: InteropArray.FromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); + } + + private static Result> Failure(int code, string message) + { + return new Result>( + error: InteropArray.FromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs index 5f4c89ea..558cedb1 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs @@ -4,7 +4,7 @@ namespace Proton.Sdk.CExports; internal static class InteropTokenRefreshedCallbackExtensions { - internal static unsafe void Invoke(this InteropTokensRefreshedCallback callback, string accessToken, string refreshToken) + internal static unsafe void Invoke(this InteropValueCallback> callback, void* callerState, string accessToken, string refreshToken) { var sessionTokens = new SessionTokens { @@ -12,15 +12,15 @@ internal static unsafe void Invoke(this InteropTokensRefreshedCallback callback, RefreshToken = refreshToken, }; - var tokenBytes = InteropArray.FromMemory(sessionTokens.ToByteArray()); + var sessionTokensBytes = InteropArray.FromMemory(sessionTokens.ToByteArray()); try { - callback.OnTokenRefreshed(callback.State, tokenBytes); + callback.Invoke(callerState, sessionTokensBytes); } finally { - tokenBytes.Free(); + sessionTokensBytes.Free(); } } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTokensRefreshedCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTokensRefreshedCallback.cs deleted file mode 100644 index 45729adf..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropTokensRefreshedCallback.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Proton.Sdk.CExports; - -[StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropTokensRefreshedCallback -{ - public readonly void* State; - public readonly delegate* unmanaged[Cdecl] OnTokenRefreshed; -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs new file mode 100644 index 00000000..98a55041 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropValueCallback(delegate* unmanaged[Cdecl] invoke) + where TValue : unmanaged +{ + public readonly delegate* unmanaged[Cdecl] Invoke = invoke; +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs new file mode 100644 index 00000000..bab1542e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs @@ -0,0 +1,9 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropVoidCallback(delegate* unmanaged[Cdecl] invoke) +{ + public readonly delegate* unmanaged[Cdecl] Call; +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs index aa98aac8..d5697de2 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs @@ -6,5 +6,5 @@ namespace Proton.Sdk.CExports.Logging; internal readonly unsafe struct InteropLogCallback { public readonly void* State; - public readonly delegate* unmanaged[Cdecl] Invoke; + public readonly delegate* unmanaged[Cdecl], void> Invoke; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index b00050b0..b399b35c 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -24,7 +24,7 @@ public unsafe void Log(LogLevel logLevel, EventId eventId, TState state, var message = formatter.Invoke(state, exception); var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; - var messageBytes = InteropArray.FromMemory(logEvent.ToByteArray()); + var messageBytes = InteropArray.FromMemory(logEvent.ToByteArray()); try { diff --git a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj index e8ffba26..273ff58e 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj @@ -2,10 +2,9 @@ $(NativeLibPrefix)proton_sdk - net9.0 - enable - enable true + true + false @@ -18,7 +17,7 @@ - + diff --git a/cs/sdk/src/Proton.Sdk.CExports/ResultExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ResultExtensions.cs deleted file mode 100644 index 1d3b815a..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/ResultExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Google.Protobuf; - -namespace Proton.Sdk.CExports; - -public struct ResultExtensions -{ - internal static Result Success() - { - return new Result(value: InteropArray.Null); - } - - internal static Result Success(IMessage data) - { - return new Result( - value: InteropArray.FromMemory(data.ToByteArray())); - } - - internal static Result Success(int value) - { - return new Result( - value: InteropArray.FromMemory(new IntResponse { Value = value }.ToByteArray())); - } - - internal static Result Success(string value) - { - return new Result( - value: InteropArray.FromMemory(new StringResponse { Value = value }.ToByteArray())); - } - - internal static Result Failure(Exception exception, int defaultCode) - { - if (exception is ProtonApiException protonApiException) - { - return Failure((int)protonApiException.Code, protonApiException.Message); - } - - return Failure(defaultCode, exception.Message); - } - - internal static Result Failure(Exception exception, Action setDomainAndCodesFunction) - { - var error = exception.ToInteropError(setDomainAndCodesFunction); - - return new Result(error: InteropArray.FromMemory(error.ToByteArray())); - } - - private static Result Failure(int code, string message) - { - return new Result( - error: InteropArray.FromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); - } -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/TaskExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/TaskExtensions.cs new file mode 100644 index 00000000..cf127dbf --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/TaskExtensions.cs @@ -0,0 +1,12 @@ +namespace Proton.Sdk.CExports; + +internal static class TaskExtensions +{ +#pragma warning disable RCS1175 // Unused 'this' parameter + public static void RunInBackground(this ValueTask task) +#pragma warning restore RCS1175 // Unused 'this' parameter + { + // Do nothing, let the task run in the background + // This method is to avoid warnings of non-awaited async methods + } +} diff --git a/cs/sdk/src/Proton.Sdk/Result.cs b/cs/sdk/src/Proton.Sdk/Result.cs index 6a7e658c..1e6ac5c6 100644 --- a/cs/sdk/src/Proton.Sdk/Result.cs +++ b/cs/sdk/src/Proton.Sdk/Result.cs @@ -27,6 +27,21 @@ public Result(TError error) public static implicit operator Result(T value) => new(value); public static implicit operator Result(TError error) => new(error); + public static implicit operator Result(Result result) => + result.TryGetValueElseError(out _, out var error) + ? Result.Success + : new Result(error); + + public static Result Success(T value) + { + return new Result(value); + } + + public static Result Failure(TError error) + { + return new Result(error); + } + public bool TryGetValueElseError([MaybeNullWhen(false)] out T value, [MaybeNullWhen(true)] out TError error) { value = _value; diff --git a/cs/sdk/src/Proton.Sdk/Result{TError}.cs b/cs/sdk/src/Proton.Sdk/Result{TError}.cs index b7e02515..4d716b1e 100644 --- a/cs/sdk/src/Proton.Sdk/Result{TError}.cs +++ b/cs/sdk/src/Proton.Sdk/Result{TError}.cs @@ -25,6 +25,11 @@ public Result() public static implicit operator Result(TError error) => new(error); + public static Result Failure(TError error) + { + return new Result(error); + } + public bool TryGetError([MaybeNullWhen(true)] out TError error) { error = _error; diff --git a/cs/sdk/src/protos/drive.proto b/cs/sdk/src/protos/drive.proto index 9e107a7d..4d9f7a1d 100644 --- a/cs/sdk/src/protos/drive.proto +++ b/cs/sdk/src/protos/drive.proto @@ -2,12 +2,30 @@ syntax = "proto3"; option csharp_namespace = "Proton.Sdk.Drive.CExports"; -import "account.proto"; +message FileUploaderProvisionRequest { + string name = 1; + string mediaType = 2; + int64 last_modification_date = 3; + int64 file_size = 4; +} + +message FileUploadRequest { + string parent_folder_uid = 1; + bool create_new_revision_if_exists = 2; + optional bytes thumbnail = 3; +} + +message RevisionUploadRequest { + string file_uid = 1; + optional bytes thumbnail = 2; + int64 last_modification_date = 3; +} -message NodeUid { - string value = 1; +message ProgressUpdate { + int64 bytes_completed = 1; + int64 bytes_in_total = 2; } -message RevisionUid { - string value = 1; +message FileDownloaderProvisionRequest { + string revision_uid = 1; } From c6cd45bd272281eb1841cf492d6164f405763263 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 30 Jul 2025 12:56:09 +0000 Subject: [PATCH 175/791] Export event types --- js/sdk/src/interface/events.ts | 18 ++++++++---------- js/sdk/src/interface/index.ts | 11 ++++++++++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index 01b63280..5491fce9 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -20,11 +20,17 @@ export interface LatestEventIdProvider { */ export type DriveListener = (event: DriveEvent) => Promise; -type NodeCruEventType = DriveEventType.NodeCreated | DriveEventType.NodeUpdated; +export type DriveEvent = + | NodeEvent + | FastForwardEvent + | TreeRefreshEvent + | TreeRemovalEvent + | FastForwardEvent + | SharedWithMeUpdated; export type NodeEvent = | { - type: NodeCruEventType; + type: DriveEventType.NodeCreated | DriveEventType.NodeUpdated; nodeUid: string; parentNodeUid?: string; isTrashed: boolean; @@ -64,14 +70,6 @@ export type SharedWithMeUpdated = { treeEventScopeId: 'core'; }; -export type DriveEvent = - | NodeEvent - | FastForwardEvent - | TreeRefreshEvent - | TreeRemovalEvent - | FastForwardEvent - | SharedWithMeUpdated; - export enum DriveEventType { NodeCreated = 'node_created', NodeUpdated = 'node_updated', diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 5a5c85d7..8206cf46 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -14,7 +14,16 @@ export type { ProtonDriveConfig } from './config'; export type { Device, DeviceOrUid } from './devices'; export { DeviceType } from './devices'; export type { FileDownloader, DownloadController } from './download'; -export type { DriveListener, LatestEventIdProvider, DriveEvent } from './events'; +export type { + DriveListener, + LatestEventIdProvider, + DriveEvent, + NodeEvent, + FastForwardEvent, + TreeRefreshEvent, + TreeRemovalEvent, + SharedWithMeUpdated, +} from './events'; export { DriveEventType, SDKEvent } from './events'; export type { ProtonDriveHTTPClient, From 37a2b0c03cbcd58b948bcf271fd2d7f91b8fecb6 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 31 Jul 2025 07:29:04 +0000 Subject: [PATCH 176/791] Add node.uid to proton invitation + fix invitation accept --- js/sdk/src/crypto/driveCrypto.ts | 7 +++++-- js/sdk/src/interface/sharing.ts | 1 + js/sdk/src/internal/sharing/apiService.ts | 1 + js/sdk/src/internal/sharing/cryptoService.ts | 1 + js/sdk/src/internal/sharing/interface.ts | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index c626b221..bfc6c137 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -179,7 +179,7 @@ export class DriveCrypto { passphraseSessionKey: SessionKey; verified: VERIFICATION_STATUS; }> { - const passphraseSessionKey = await this.decryptSessionKey(armoredPassphrase, decryptionKeys); + const passphraseSessionKey = await this.openPGPCrypto.decryptArmoredSessionKey(armoredPassphrase, decryptionKeys); const { data: decryptedPassphrase, verified } = await this.openPGPCrypto.decryptArmoredAndVerifyDetached( armoredPassphrase, @@ -530,7 +530,10 @@ export class DriveCrypto { ): Promise<{ base64SessionKeySignature: string; }> { - const sessionKey = await this.decryptSessionKey(base64KeyPacket, signingKey); + const sessionKey = await this.openPGPCrypto.decryptSessionKey( + base64StringToUint8Array(base64KeyPacket), + signingKey, + ); const { signature } = await this.openPGPCrypto.sign( sessionKey.data, diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index c571373c..2bcbb266 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -14,6 +14,7 @@ export type ProtonInvitation = Member; export type ProtonInvitationWithNode = ProtonInvitation & { node: { + uid: string; name: Result; type: NodeType; mediaType?: string; diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index d2332c20..c2334a82 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -231,6 +231,7 @@ export class SharingAPIService { creatorEmail: response.Share.CreatorEmail, }, node: { + uid: makeNodeUid(response.Share.VolumeID, response.Link.LinkID), type: nodeTypeNumberToNodeType(this.logger, response.Link.Type), mediaType: response.Link.MIMEType || undefined, encryptedName: response.Link.Name, diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 3141b1f8..cc07c080 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -218,6 +218,7 @@ export class SharingCryptoService { return { ...(await this.decryptInvitation(encryptedInvitation)), node: { + uid: encryptedInvitation.node.uid, name: nodeName, type: encryptedInvitation.node.type, mediaType: encryptedInvitation.node.mediaType, diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 9638ce83..3aed082a 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -43,6 +43,7 @@ export interface EncryptedInvitationWithNode extends EncryptedInvitation { creatorEmail: string; }; node: { + uid: string; type: NodeType; mediaType?: string; encryptedName: string; From 051634266c5ece06159083f6f2137a1abf008255 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 31 Jul 2025 11:13:56 +0000 Subject: [PATCH 177/791] Return nodes integration test --- js/sdk/src/internal/nodes/index.test.ts | 133 ++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 js/sdk/src/internal/nodes/index.test.ts diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts new file mode 100644 index 00000000..a40d7993 --- /dev/null +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -0,0 +1,133 @@ +import { + ProtonDriveEntitiesCache, + ProtonDriveCryptoCache, + ProtonDriveAccount, + MemberRole, + NodeType, +} from '../../interface'; +import { DriveCrypto } from '../../crypto'; +import { MemoryCache } from '../../cache'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { DriveAPIService } from '../apiService'; +import { DriveEventType } from '../events'; +import { makeNodeUid } from '../uids'; +import { SharesService, DecryptedNode } from './interface'; +import { initNodesModule } from './index'; +import { NodesCache } from './cache'; +import { getMockLogger } from '../../tests/logger'; + +function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial = {}): DecryptedNode { + return { + uid, + parentUid, + directMemberRole: MemberRole.Admin, + type: NodeType.File, + mediaType: 'text', + isShared: false, + creationTime: new Date(), + trashTime: undefined, + isStale: false, + ...params, + } as DecryptedNode; +} + +describe('nodesModules integration tests', () => { + let apiService: DriveAPIService; + let driveEntitiesCache: ProtonDriveEntitiesCache; + let driveCryptoCache: ProtonDriveCryptoCache; + let account: ProtonDriveAccount; + let driveCrypto: DriveCrypto; + let sharesService: SharesService; + let nodesModule: ReturnType; + let nodesCache: NodesCache; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = {}; + driveEntitiesCache = new MemoryCache(); + driveCryptoCache = new MemoryCache(); + // @ts-expect-error No need to implement all methods for mocking + account = {}; + // @ts-expect-error No need to implement all methods for mocking + driveCrypto = {}; + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + }; + + nodesModule = initNodesModule( + getMockTelemetry(), + apiService, + driveEntitiesCache, + driveCryptoCache, + account, + driveCrypto, + sharesService, + ); + + nodesCache = new NodesCache(getMockLogger(), driveEntitiesCache); + }); + + test('should move node from one folder to another after move event', async () => { + // Prepare two folders (original and target) and a node in the original folder. + const originalFolderUid = makeNodeUid('volumeId', 'originalFolder'); + const targetFolderUid = makeNodeUid('volumeId', 'targetFolder'); + const nodeUid = makeNodeUid('volumeId', 'node1'); + + await nodesCache.setNode(generateNode(originalFolderUid)); + await nodesCache.setFolderChildrenLoaded(originalFolderUid); + await nodesCache.setNode(generateNode(targetFolderUid)); + await nodesCache.setFolderChildrenLoaded(targetFolderUid); + await nodesCache.setNode(generateNode(nodeUid, originalFolderUid)); + + // Mock the API services to return the moved node. + // This is called when listing the children of the target folder after + // move event (when node marked as stale). + apiService.post = jest.fn().mockImplementation(async (url, body) => { + expect(url).toBe(`drive/v2/volumes/volumeId/links`); + return { + Links: [ + { + Link: { + LinkID: 'node1', + ParentLinkID: 'targetFolder', + NameHash: 'hash', + Type: 2, + }, + File: { + ActiveRevision: {}, + }, + }, + ], + }; + }); + jest.spyOn(nodesModule.access, 'getParentKeys').mockResolvedValue({ key: { _idx: 32131 } } as any); + + // Verify the inital state before move event is sent. + const originalBeforeMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(originalFolderUid)); + expect(originalBeforeMove).toMatchObject([{ uid: nodeUid, parentUid: originalFolderUid }]); + + const targetBeforeMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(targetFolderUid)); + expect(targetBeforeMove).toMatchObject([]); + + // Send the move event that updates the cache. + await nodesModule.eventHandler.updateNodesCacheOnEvent({ + type: DriveEventType.NodeUpdated, + nodeUid, + parentNodeUid: targetFolderUid, + isTrashed: false, + isShared: false, + treeEventScopeId: 'volumeId', + eventId: '1', + }); + + // Verify the state after the move event, including when API service is called. + const originalAfterMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(originalFolderUid)); + expect(originalAfterMove).toMatchObject([]); + expect(apiService.post).not.toHaveBeenCalled(); + + const targetAfterMove = await Array.fromAsync(nodesModule.access.iterateFolderChildren(targetFolderUid)); + expect(targetAfterMove).toMatchObject([{ uid: nodeUid, parentUid: targetFolderUid }]); + expect(apiService.post).toHaveBeenCalledTimes(1); + }); +}); From d3cb135687d0ad1f3cbf30aa731372b78232116c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 31 Jul 2025 14:52:25 +0000 Subject: [PATCH 178/791] Remove obsolete signature check on block download --- js/sdk/src/crypto/driveCrypto.ts | 22 ++++--------------- js/sdk/src/internal/download/apiService.ts | 1 - js/sdk/src/internal/download/cryptoService.ts | 12 ++-------- .../src/internal/download/fileDownloader.ts | 6 +---- js/sdk/src/internal/download/interface.ts | 1 - js/sdk/src/internal/upload/blockVerifier.ts | 1 - js/sdk/src/internal/upload/cryptoService.ts | 3 +-- 7 files changed, 8 insertions(+), 38 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index bfc6c137..c3791cad 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -629,29 +629,15 @@ export class DriveCrypto { async decryptBlock( encryptedBlock: Uint8Array, - armoredSignature: string | undefined, - decryptionKey: PrivateKey, sessionKey: SessionKey, - verificationKeys?: PublicKey[], - ): Promise<{ - decryptedBlock: Uint8Array; - verified: VERIFICATION_STATUS; - }> { - const signature = armoredSignature - ? await this.openPGPCrypto.decryptArmored(armoredSignature, [decryptionKey]) - : undefined; - - const { data: decryptedBlock, verified } = await this.openPGPCrypto.decryptAndVerifyDetached( + ): Promise { + const { data: decryptedBlock } = await this.openPGPCrypto.decryptAndVerify( encryptedBlock, - signature, sessionKey, - verificationKeys, + [], ); - return { - decryptedBlock, - verified, - }; + return decryptedBlock; } async signManifest( diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index b49b06fd..4dd13117 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -161,6 +161,5 @@ function transformBlock(block: GetRevisionResponse['Revision']['Blocks'][0]): Bl token: block.Token as string, base64sha256Hash: block.Hash, signatureEmail: block.SignatureEmail || undefined, - armoredSignature: block.EncSignature || undefined, }; } diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index 56c5a586..5aa9cd54 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -34,11 +34,7 @@ export class DownloadCryptoService { }; } - async decryptBlock( - encryptedBlock: Uint8Array, - armoredSignature: string, - revisionKeys: RevisionKeys, - ): Promise { + async decryptBlock(encryptedBlock: Uint8Array, revisionKeys: RevisionKeys): Promise { let decryptedBlock; try { // We do not verify signatures on blocks. We only verify @@ -47,14 +43,10 @@ export class DownloadCryptoService { // We plan to drop signatures of individual blocks // completely in the future. Any issue on the blocks // should be considered serious integrity issue. - const result = await this.driveCrypto.decryptBlock( + decryptedBlock = await this.driveCrypto.decryptBlock( encryptedBlock, - armoredSignature, - revisionKeys.key, revisionKeys.contentKeyPacketSessionKey, - revisionKeys.verificationKeys, ); - decryptedBlock = result.decryptedBlock; } catch (error: unknown) { const message = getErrorMessage(error); throw new DecryptionError(c('Error').t`Failed to decrypt block: ${message}`); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 9ab457e9..26aa5c90 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -187,11 +187,7 @@ export class FileDownloader { } logger.debug(`Decrypting`); - decryptedBlock = await this.cryptoService.decryptBlock( - encryptedBlock, - blockMetadata.armoredSignature!, - cryptoKeys, - ); + decryptedBlock = await this.cryptoService.decryptBlock(encryptedBlock, cryptoKeys); } catch (error) { if (blockProgress !== 0) { onProgress?.(-blockProgress); diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index 7ed36060..9da5925c 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -8,7 +8,6 @@ export type BlockMetadata = { token: string; base64sha256Hash: string; signatureEmail?: string; - armoredSignature?: string; }; export type RevisionKeys = { diff --git a/js/sdk/src/internal/upload/blockVerifier.ts b/js/sdk/src/internal/upload/blockVerifier.ts index 672817ad..25081c20 100644 --- a/js/sdk/src/internal/upload/blockVerifier.ts +++ b/js/sdk/src/internal/upload/blockVerifier.ts @@ -34,7 +34,6 @@ export class BlockVerifier { } return this.cryptoService.verifyBlock( - this.nodeKey, this.contentKeyPacketSessionKey, this.verificationCode, encryptedBlock, diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index cecd35e1..4085c2b0 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -137,7 +137,6 @@ export class UploadCryptoService { } async verifyBlock( - nodeKey: PrivateKey, contentKeyPacketSessionKey: SessionKey, verificationCode: Uint8Array, encryptedData: Uint8Array, @@ -152,7 +151,7 @@ export class UploadCryptoService { // Additionally, we use the key provided by the verification endpoint, to // ensure the correct key was used to encrypt the data try { - await this.driveCrypto.decryptBlock(encryptedData, undefined, nodeKey, contentKeyPacketSessionKey); + await this.driveCrypto.decryptBlock(encryptedData, contentKeyPacketSessionKey); } catch (error) { throw new IntegrityError(c('Error').t`Data integrity check of one part failed`, { error, From eea22742bbe9d8338841d2f1f291b767553fb21e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 1 Aug 2025 04:31:54 +0000 Subject: [PATCH 179/791] Improve loading nodes performance --- js/sdk/src/internal/asyncIteratorRace.test.ts | 149 ++++++++++++++++++ js/sdk/src/internal/asyncIteratorRace.ts | 79 ++++++++++ js/sdk/src/internal/batch.test.ts | 50 ++++++ js/sdk/src/internal/batch.ts | 9 ++ js/sdk/src/internal/nodes/apiService.test.ts | 32 ++++ js/sdk/src/internal/nodes/apiService.ts | 52 ++++-- js/sdk/src/internal/sharing/events.test.ts | 3 +- 7 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 js/sdk/src/internal/asyncIteratorRace.test.ts create mode 100644 js/sdk/src/internal/asyncIteratorRace.ts create mode 100644 js/sdk/src/internal/batch.test.ts create mode 100644 js/sdk/src/internal/batch.ts diff --git a/js/sdk/src/internal/asyncIteratorRace.test.ts b/js/sdk/src/internal/asyncIteratorRace.test.ts new file mode 100644 index 00000000..852e1676 --- /dev/null +++ b/js/sdk/src/internal/asyncIteratorRace.test.ts @@ -0,0 +1,149 @@ +import { asyncIteratorRace } from './asyncIteratorRace'; + +async function* createInputIterator(generators: AsyncGenerator[]): AsyncGenerator> { + for (const generator of generators) { + yield generator; + } +} + +async function* createAsyncGenerator(values: T[], delay: number = 0): AsyncGenerator { + for (const value of values) { + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + yield value; + } +} + +function createTrackingGenerator( + values: T[], + trackingSet: Set, + id: number, + delay: number = 10, +): AsyncGenerator { + return (async function* () { + trackingSet.add(id); + try { + for (const value of values) { + await new Promise((resolve) => setTimeout(resolve, delay)); + yield value; + } + } finally { + trackingSet.delete(id); + } + })(); +} + +describe('asyncIteratorRace', () => { + it('should handle empty input iterator', async () => { + async function* emptyInput(): AsyncGenerator> { + return; + } + + const result = asyncIteratorRace(emptyInput()); + const values = await Array.fromAsync(result); + + expect(values).toEqual([]); + }); + + it('should handle single generator with no values', async () => { + const input = createInputIterator([createAsyncGenerator([])]); + + const result = asyncIteratorRace(input); + const values = await Array.fromAsync(result); + + expect(values).toEqual([]); + }); + + it('should handle single generator with multiple values', async () => { + const input = createInputIterator([createAsyncGenerator([1, 2, 3])]); + + const result = asyncIteratorRace(input); + const values = await Array.fromAsync(result); + + expect(values).toEqual([1, 2, 3]); + }); + + it('should handle generators with mixed empty and non-empty results', async () => { + const input = createInputIterator([ + createAsyncGenerator([]), + createAsyncGenerator([1, 3]), + createAsyncGenerator([]), + createAsyncGenerator([2]), + ]); + + const result = asyncIteratorRace(input); + const values = await Array.fromAsync(result); + + expect(values.sort()).toEqual([1, 2, 3]); + }); + + it('should limit concurrent reading of input iterators', async () => { + const concurrency = 2; + const activeIterators = new Set(); + let maxConcurrentActive = 0; + + const generators = Array.from({ length: 5 }, (_, i) => + createTrackingGenerator([i * 10, i * 10 + 1], activeIterators, i, 50), + ); + const input = createInputIterator(generators); + + const result = asyncIteratorRace(input, concurrency); + + const values: number[] = []; + for await (const value of result) { + maxConcurrentActive = Math.max(maxConcurrentActive, activeIterators.size); + values.push(value); + } + + expect(maxConcurrentActive).toBe(concurrency); + expect(values).toHaveLength(10); + expect(values.sort()).toEqual([0, 1, 10, 11, 20, 21, 30, 31, 40, 41]); + }); + + it('should yield values as soon as any generator yields', async () => { + const slowGenerator = (async function* () { + await new Promise((resolve) => setTimeout(resolve, 100)); + yield 'slow'; + })(); + const fastGenerator = (async function* () { + await new Promise((resolve) => setTimeout(resolve, 50)); + yield 'fast'; + })(); + const input = createInputIterator([slowGenerator, fastGenerator]); + const result = asyncIteratorRace(input, 2); + + const yieldTimes: number[] = []; + const startTime = Date.now(); + const values: string[] = []; + for await (const value of result) { + yieldTimes.push(Date.now() - startTime); + values.push(value); + } + + expect(values).toEqual(['fast', 'slow']); + expect(yieldTimes[0]).toBeGreaterThan(40); + expect(yieldTimes[0]).toBeLessThan(60); + expect(yieldTimes[1]).toBeGreaterThan(90); + expect(yieldTimes[1]).toBeLessThan(110); + }); + + it('should propagate errors from input iterators', async () => { + const errorGenerator = (async function* () { + yield 'before-error'; + throw new Error('Test error'); + })(); + const input = createInputIterator([errorGenerator]); + + const result = asyncIteratorRace(input); + + const values: string[] = []; + await expect(async () => { + for await (const value of result) { + values.push(value); + } + }).rejects.toThrow('Test error'); + + expect(values).toEqual(['before-error']); + }); +}); diff --git a/js/sdk/src/internal/asyncIteratorRace.ts b/js/sdk/src/internal/asyncIteratorRace.ts new file mode 100644 index 00000000..acf9fb44 --- /dev/null +++ b/js/sdk/src/internal/asyncIteratorRace.ts @@ -0,0 +1,79 @@ +const DEFAULT_CONCURRENCY = 10; + +/** + * Races multiple async iterators into a single async iterator. + * + * The input iterators are provided as an async iterator that yields async + * iterators. This allows to create the iterators lazily, e.g., when the + * input iterators are created from a database query. + * + * The number of input iterators being read at the same time is limited by + * the `concurrency` parameter. + * + * Any error from the input iterators is propagated to the output iterator. + */ +export async function* asyncIteratorRace( + inputIterators: AsyncGenerator>, + concurrency: number = DEFAULT_CONCURRENCY, +): AsyncGenerator { + const promises = new Map< + number, + Promise<{ + iteratorIndex: number; + result: IteratorResult; + }> + >(); + + let nextIteratorIndex = 0; + let inputIteratorsExhausted = false; + const activeIterators = new Map>(); + + const startNewIterator = async (): Promise => { + if (inputIteratorsExhausted || activeIterators.size >= concurrency) { + return; + } + + const nextIteratorResult = await inputIterators.next(); + if (nextIteratorResult.done) { + inputIteratorsExhausted = true; + return; + } + + const iterator = nextIteratorResult.value; + const iteratorIndex = nextIteratorIndex++; + activeIterators.set(iteratorIndex, iterator); + + promises.set( + iteratorIndex, + (async () => { + const result = await iterator.next(); + return { iteratorIndex, result }; + })(), + ); + }; + + while (activeIterators.size < concurrency && !inputIteratorsExhausted) { + await startNewIterator(); + } + + while (promises.size > 0) { + const { iteratorIndex, result } = await Promise.race(promises.values()); + promises.delete(iteratorIndex); + + if (result.done) { + activeIterators.delete(iteratorIndex); + await startNewIterator(); + } else { + yield result.value; + + const iterator = activeIterators.get(iteratorIndex)!; + promises.set( + iteratorIndex, + (async () => { + const result = await iterator.next(); + return { iteratorIndex, result }; + })(), + ); + } + } +} diff --git a/js/sdk/src/internal/batch.test.ts b/js/sdk/src/internal/batch.test.ts new file mode 100644 index 00000000..70d1abe2 --- /dev/null +++ b/js/sdk/src/internal/batch.test.ts @@ -0,0 +1,50 @@ +import { batch } from './batch'; + +describe('batch', () => { + it('should batch an array of numbers into chunks of specified size', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const batchSize = 3; + const result = Array.from(batch(items, batchSize)); + + expect(result).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]); + }); + + it('should handle batch size equal to array length', () => { + const items = [1, 2, 3, 4, 5]; + const batchSize = 5; + const result = Array.from(batch(items, batchSize)); + + expect(result).toEqual([[1, 2, 3, 4, 5]]); + }); + + it('should handle batch size larger than array length', () => { + const items = [1, 2, 3]; + const batchSize = 10; + const result = Array.from(batch(items, batchSize)); + + expect(result).toEqual([[1, 2, 3]]); + }); + + it('should handle batch size of 1', () => { + const items = [1, 2, 3]; + const batchSize = 1; + const result = Array.from(batch(items, batchSize)); + + expect(result).toEqual([[1], [2], [3]]); + }); + + it('should handle empty array', () => { + const items: number[] = []; + const batchSize = 3; + const result = Array.from(batch(items, batchSize)); + + expect(result).toEqual([]); + }); + + it('should handle zero batch size gracefully', () => { + const items = [1, 2, 3]; + const batchSize = 0; + + expect(() => Array.from(batch(items, batchSize))).toThrow(); + }); +}); diff --git a/js/sdk/src/internal/batch.ts b/js/sdk/src/internal/batch.ts new file mode 100644 index 00000000..f848583a --- /dev/null +++ b/js/sdk/src/internal/batch.ts @@ -0,0 +1,9 @@ +export function* batch(items: T[], batchSize: number): Generator { + if (batchSize <= 0) { + throw new Error('Batch size must be greater than 0'); + } + + for (let i = 0; i < items.length; i += batchSize) { + yield items.slice(i, i + batchSize); + } +} diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 5428341a..9511ed8b 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -317,6 +317,38 @@ describe('nodeAPIService', () => { }), ]); }); + + it('should get nodes in batches', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async (_, { LinkIDs }) => + Promise.resolve({ + Links: LinkIDs.map((linkId: string) => generateAPIFolderNode({ LinkID: linkId })), + }), + ); + + const nodeUids = Array.from({ length: 250 }, (_, i) => `volumeId1~nodeId${i}`); + const nodeIds = nodeUids.map((uid) => uid.split('~')[1]); + + const nodes = await Array.fromAsync(api.iterateNodes(nodeUids, 'volumeId1')); + expect(nodes).toHaveLength(nodeUids.length); + + expect(apiMock.post).toHaveBeenCalledTimes(3); + expect(apiMock.post).toHaveBeenCalledWith( + 'drive/v2/volumes/volumeId1/links', + { LinkIDs: nodeIds.slice(0, 100) }, + undefined, + ); + expect(apiMock.post).toHaveBeenCalledWith( + 'drive/v2/volumes/volumeId1/links', + { LinkIDs: nodeIds.slice(100, 200) }, + undefined, + ); + expect(apiMock.post).toHaveBeenCalledWith( + 'drive/v2/volumes/volumeId1/links', + { LinkIDs: nodeIds.slice(200, 250) }, + undefined, + ); + }); }); describe('trashNodes', () => { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index d0c2f94c..25795b92 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -10,9 +10,17 @@ import { nodeTypeNumberToNodeType, permissionsToDirectMemberRole, } from '../apiService'; +import { asyncIteratorRace } from '../asyncIteratorRace'; +import { batch } from '../batch'; import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from '../uids'; import { EncryptedNode, EncryptedRevision, Thumbnail } from './interface'; +// This is the number of calls to the API that are made in parallel. +const API_CONCURRENCY = 15; + +// This is the number of nodes that are loaded from the API in one call. +const API_NODES_BATCH_SIZE = 100; + type PostLoadLinksMetadataRequest = Extract< drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['requestBody'], { content: object } @@ -106,7 +114,6 @@ export class NodeAPIService { return result.value; } - // Improvement requested: split into multiple calls for many nodes. async *iterateNodes(nodeUids: string[], ownVolumeId: string, signal?: AbortSignal): AsyncGenerator { const allNodeIds = nodeUids.map(splitNodeUid); @@ -121,22 +128,50 @@ export class NodeAPIService { // If the API returns node that is not recognised, it is returned as // an error, but first all nodes that are recognised are yielded. // Thus we capture all errors and throw them at the end of iteration. - const errors = []; + const errors: unknown[] = []; + + const iterateNodesPerVolume = this.iterateNodesPerVolume.bind(this); + const iterateNodesPerVolumeGenerator = async function* () { + for (const [volumeId, nodeIds] of nodeIdsByVolumeId.entries()) { + const isAdmin = volumeId === ownVolumeId; + + yield (async function* () { + const errorsPerVolume = yield* iterateNodesPerVolume(volumeId, nodeIds, isAdmin, signal); + if (errorsPerVolume.length) { + errors.push(...errorsPerVolume); + } + })(); + } + }; - for (const [volumeId, nodeIds] of nodeIdsByVolumeId.entries()) { - const isAdmin = volumeId === ownVolumeId; + yield* asyncIteratorRace(iterateNodesPerVolumeGenerator(), API_CONCURRENCY); + if (errors.length) { + this.logger.warn(`Failed to load ${errors.length} nodes`); + throw new ProtonDriveError(c('Error').t`Failed to load some nodes`, { cause: errors }); + } + } + + private async *iterateNodesPerVolume( + volumeId: string, + nodeIds: string[], + isOwnVolumeId: boolean, + signal?: AbortSignal, + ): AsyncGenerator { + const errors: unknown[] = []; + + for (const nodeIdsBatch of batch(nodeIds, API_NODES_BATCH_SIZE)) { const response = await this.apiService.post( `drive/v2/volumes/${volumeId}/links`, { - LinkIDs: nodeIds, + LinkIDs: nodeIdsBatch, }, signal, ); for (const link of response.Links) { try { - yield linkToEncryptedNode(this.logger, volumeId, link, isAdmin); + yield linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); } catch (error: unknown) { this.logger.error(`Failed to transform node ${link.Link.LinkID}`, error); errors.push(error); @@ -144,10 +179,7 @@ export class NodeAPIService { } } - if (errors.length) { - this.logger.warn(`Failed to load ${errors.length} nodes`); - throw new ProtonDriveError(c('Error').t`Failed to load some nodes`, { cause: errors }); - } + return errors; } // Improvement requested: load next page sooner before all IDs are yielded. diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index 5f393bf3..577121ad 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -44,8 +44,7 @@ describe('handleSharedByMeNodes', () => { expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); - // FIXME enable when volume ownership is handled - test.skip('should not add if new shared node is not own', async () => { + test('should not add if new shared node is not own', async () => { const event: DriveEvent = { eventId: '1', type: DriveEventType.NodeCreated, From 3754280beff8f6256de5cf7ae3163a18051bf338 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 1 Aug 2025 06:33:06 +0200 Subject: [PATCH 180/791] js/v0.1.1 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index f057b16b..33e55a86 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.1.0", + "version": "0.1.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 9508d2e947a0c42a1596ededd932cfdeb34ce8e6 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 Aug 2025 08:20:54 +0000 Subject: [PATCH 181/791] Fix invalidating cache after upload --- js/sdk/src/internal/upload/interface.ts | 1 + js/sdk/src/internal/upload/manager.test.ts | 17 +++++++---------- js/sdk/src/internal/upload/manager.ts | 11 +++++++---- .../src/internal/upload/streamUploader.test.ts | 2 +- js/sdk/src/internal/upload/streamUploader.ts | 2 +- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 65442348..6318574d 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -106,6 +106,7 @@ export interface NodesService { addressKeyId: string; }>; notifyChildCreated(nodeUid: string): Promise; + notifyNodeChanged(nodeUid: string): Promise; } /** diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 22199939..5f77a629 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -91,9 +91,8 @@ describe('UploadManager', () => { email: 'signatureEmail', addressId: 'addressId', }), - notifyChildCreated: jest.fn(async (nodeUid: string) => { - return; - }), + notifyChildCreated: jest.fn(), + notifyNodeChanged: jest.fn(), }; manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, clientUid); @@ -337,10 +336,6 @@ describe('UploadManager', () => { }, }; const manifest = new Uint8Array([1, 2, 3]); - const metadata = { - mediaType: 'myMimeType', - expectedSize: 123456, - }; const extendedAttributes = { modificationTime: new Date(), digests: { @@ -349,7 +344,7 @@ describe('UploadManager', () => { }; it('should commit revision draft', async () => { - await manager.commitDraft(nodeRevisionDraft as any, manifest, metadata, extendedAttributes); + await manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes); expect(cryptoService.commitFile).toHaveBeenCalledWith( nodeRevisionDraft.nodeKeys, @@ -360,7 +355,8 @@ describe('UploadManager', () => { nodeRevisionDraft.nodeRevisionUid, expect.anything(), ); - expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid'); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('newNode:nodeUid'); + expect(nodesService.notifyChildCreated).not.toHaveBeenCalled(); }); it('should commit node draft', async () => { @@ -373,7 +369,7 @@ describe('UploadManager', () => { hash: 'newNode:hash', }, }; - await manager.commitDraft(nodeRevisionDraftWithNewNodeInfo as any, manifest, metadata, extendedAttributes); + await manager.commitDraft(nodeRevisionDraftWithNewNodeInfo as any, manifest, extendedAttributes); expect(cryptoService.commitFile).toHaveBeenCalledWith( nodeRevisionDraft.nodeKeys, @@ -385,6 +381,7 @@ describe('UploadManager', () => { expect.anything(), ); expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid'); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); }); }); }); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 23d1a5ca..60a8a376 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -260,7 +260,6 @@ export class UploadManager { async commitDraft( nodeRevisionDraft: NodeRevisionDraft, manifest: Uint8Array, - _metadata: UploadMetadata, extendedAttributes: { modificationTime?: Date; size?: number; @@ -277,9 +276,13 @@ export class UploadManager { generatedExtendedAttributes, ); await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto); - const node = await this.nodesService.getNode(nodeRevisionDraft.nodeUid); - if (node.parentUid) { - await this.nodesService.notifyChildCreated(node.parentUid); + + // If new revision to existing node was created, invalidate the node. + // Otherwise notify about the new child in the parent. + if (nodeRevisionDraft.newNodeInfo) { + await this.nodesService.notifyChildCreated(nodeRevisionDraft.newNodeInfo.parentUid); + } else { + await this.nodesService.notifyNodeChanged(nodeRevisionDraft.nodeUid); } } } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index feaa73b6..691c51a4 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -147,7 +147,7 @@ describe('StreamUploader', () => { const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); expect(uploadManager.commitDraft).toHaveBeenCalledTimes(1); - expect(uploadManager.commitDraft).toHaveBeenCalledWith(revisionDraft, expect.anything(), metadata, { + expect(uploadManager.commitDraft).toHaveBeenCalledWith(revisionDraft, expect.anything(), { size: metadata.expectedSize, blockSizes: metadata.expectedSize ? [ diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index b704f8a3..6423407e 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -217,7 +217,7 @@ export class StreamUploader { blockSizes: uploadedBlocks.map((block) => block.originalSize), digests: this.digests.digests(), }; - await this.uploadManager.commitDraft(this.revisionDraft, this.manifest, this.metadata, extendedAttributes); + await this.uploadManager.commitDraft(this.revisionDraft, this.manifest, extendedAttributes); } private async encryptThumbnails(thumbnails: Thumbnail[]) { From 5d9a402adde0c317b482d2979af41ff6bebdd1e5 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 Aug 2025 09:16:28 +0000 Subject: [PATCH 182/791] Fix event subscriptions --- .../src/internal/events/eventManager.test.ts | 101 +++++---- js/sdk/src/internal/events/eventManager.ts | 10 + js/sdk/src/internal/events/index.ts | 88 ++++---- js/sdk/src/internal/nodes/events.ts | 13 +- js/sdk/src/internal/sharing/cache.ts | 9 + js/sdk/src/internal/sharing/events.test.ts | 193 ++++++++++-------- js/sdk/src/internal/sharing/events.ts | 51 +++-- js/sdk/src/protonDriveClient.ts | 4 +- js/sdk/src/transformers.ts | 2 + 9 files changed, 280 insertions(+), 191 deletions(-) diff --git a/js/sdk/src/internal/events/eventManager.test.ts b/js/sdk/src/internal/events/eventManager.test.ts index dfbc2d7b..c333bd23 100644 --- a/js/sdk/src/internal/events/eventManager.test.ts +++ b/js/sdk/src/internal/events/eventManager.test.ts @@ -9,20 +9,16 @@ const POLLING_INTERVAL = 1; describe('EventManager', () => { let manager: EventManager; - const getLatestEventIdMock = jest.fn(); - const getEventsMock = jest.fn(); const listenerMock = jest.fn(); - const mockLogger = getMockLogger(); const subscriptions: EventSubscription[] = []; + const mockEventManager = { + getLogger: () => getMockLogger(), + getLatestEventId: jest.fn(), + getEvents: jest.fn(), + }; beforeEach(() => { - const mockEventManager = { - getLogger: () => mockLogger, - getLatestEventId: getLatestEventIdMock, - getEvents: getEventsMock, - }; - - manager = new EventManager(mockEventManager as any, POLLING_INTERVAL, null); + manager = new EventManager(mockEventManager, POLLING_INTERVAL, null); const subscription = manager.addListener(listenerMock); subscriptions.push(subscription); }); @@ -37,7 +33,7 @@ describe('EventManager', () => { }); it('should start polling when started', async () => { - getLatestEventIdMock.mockResolvedValue('EventId1'); + mockEventManager.getLatestEventId.mockResolvedValue('EventId1'); const mockEvents: DriveEvent[][] = [ [ @@ -56,7 +52,7 @@ describe('EventManager', () => { ], ]; - getEventsMock + mockEventManager.getEvents .mockImplementationOnce(async function* () { yield* mockEvents[0]; }) @@ -65,22 +61,23 @@ describe('EventManager', () => { }) .mockImplementationOnce(async function* () {}); - expect(getLatestEventIdMock).toHaveBeenCalledTimes(0); - expect(getEventsMock).toHaveBeenCalledTimes(0); + expect(mockEventManager.getLatestEventId).toHaveBeenCalledTimes(0); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(0); expect(await manager.start()).toBeUndefined(); + await jest.runOnlyPendingTimersAsync(); - expect(getLatestEventIdMock).toHaveBeenCalledTimes(1); - expect(getEventsMock).toHaveBeenCalledWith('EventId1'); + expect(mockEventManager.getLatestEventId).toHaveBeenCalledTimes(1); + expect(mockEventManager.getEvents).toHaveBeenCalledWith('EventId1'); await jest.runOnlyPendingTimersAsync(); - expect(getEventsMock).toHaveBeenCalledTimes(2); - expect(getEventsMock).toHaveBeenCalledWith('EventId2'); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(2); + expect(mockEventManager.getEvents).toHaveBeenCalledWith('EventId2'); }); it('should stop polling when stopped', async () => { - getLatestEventIdMock.mockResolvedValue('eventId1'); - getEventsMock.mockImplementation(async function* () { + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); + mockEventManager.getEvents.mockImplementation(async function* () { yield { type: DriveEventType.FastForward, treeEventScopeId: 'volume1', @@ -91,16 +88,16 @@ describe('EventManager', () => { await manager.start(); await jest.runOnlyPendingTimersAsync(); - const callsBeforeStop = getEventsMock.mock.calls.length; + const callsBeforeStop = mockEventManager.getEvents.mock.calls.length; await manager.stop(); await jest.runOnlyPendingTimersAsync(); // Should not have made additional calls after stopping - expect(getEventsMock).toHaveBeenCalledTimes(callsBeforeStop); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(callsBeforeStop); }); it('should notify all listeners when getting events', async () => { - getLatestEventIdMock.mockResolvedValue('eventId1'); + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); const mockEvents: DriveEvent[] = [ { @@ -114,7 +111,7 @@ describe('EventManager', () => { }, ]; - getEventsMock + mockEventManager.getEvents .mockImplementationOnce(async function* () { yield* mockEvents; }) @@ -127,19 +124,19 @@ describe('EventManager', () => { }); it('should propagate unsubscription errors', async () => { - getLatestEventIdMock.mockImplementation(() => { + mockEventManager.getLatestEventId.mockImplementation(() => { throw new UnsubscribeFromEventsSourceError('Not found'); }); await expect(manager.start()).rejects.toThrow(UnsubscribeFromEventsSourceError); - expect(getLatestEventIdMock).toHaveBeenCalledTimes(1); + expect(mockEventManager.getLatestEventId).toHaveBeenCalledTimes(1); expect(listenerMock).toHaveBeenCalledTimes(0); - expect(getEventsMock).toHaveBeenCalledTimes(0); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(0); }); it('should continue processing multiple events', async () => { - getLatestEventIdMock.mockResolvedValue('eventId1'); + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); const mockEvents: DriveEvent[] = [ { @@ -162,7 +159,7 @@ describe('EventManager', () => { }, ]; - getEventsMock + mockEventManager.getEvents .mockImplementationOnce(async function* () { yield* mockEvents; }) @@ -177,7 +174,7 @@ describe('EventManager', () => { expect(listenerMock).toHaveBeenNthCalledWith(1, mockEvents[0]); expect(listenerMock).toHaveBeenNthCalledWith(2, mockEvents[1]); - getEventsMock.mockImplementationOnce(async function* () { + mockEventManager.getEvents.mockImplementationOnce(async function* () { yield* mockEvents; }); await jest.runOnlyPendingTimersAsync(); @@ -187,10 +184,10 @@ describe('EventManager', () => { }); it('should retry on error with exponential backoff', async () => { - getLatestEventIdMock.mockResolvedValue('eventId1'); + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); let callCount = 0; - getEventsMock.mockImplementation(async function* () { + mockEventManager.getEvents.mockImplementation(async function* () { callCount++; if (callCount <= 3) { throw new Error('Network error'); @@ -205,11 +202,12 @@ describe('EventManager', () => { expect(manager['retryIndex']).toEqual(0); expect(await manager.start()).toBeUndefined(); - expect(getEventsMock).toHaveBeenCalledTimes(1); + await jest.runOnlyPendingTimersAsync(); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(1); expect(manager['retryIndex']).toEqual(1); await jest.runOnlyPendingTimersAsync(); - expect(getEventsMock).toHaveBeenCalledTimes(2); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(2); expect(manager['retryIndex']).toEqual(2); await jest.runOnlyPendingTimersAsync(); @@ -224,8 +222,8 @@ describe('EventManager', () => { }); it('should stop polling when stopped immediately', async () => { - getLatestEventIdMock.mockResolvedValue('eventId1'); - getEventsMock.mockImplementation(async function* () { + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); + mockEventManager.getEvents.mockImplementation(async function* () { yield { type: DriveEventType.FastForward, treeEventScopeId: 'volume1', @@ -234,18 +232,19 @@ describe('EventManager', () => { }); expect(await manager.start()).toBeUndefined(); - expect(getEventsMock).toHaveBeenCalledTimes(1); + await jest.runOnlyPendingTimersAsync(); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(1); await manager.stop(); await jest.runOnlyPendingTimersAsync(); // getEvents should have been called once during start, but not again after stop - expect(getEventsMock).toHaveBeenCalledTimes(1); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(1); }); it('should handle empty event streams', async () => { - getLatestEventIdMock.mockResolvedValue('eventId1'); + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); - getEventsMock.mockImplementation(async function* () { + mockEventManager.getEvents.mockImplementation(async function* () { // Empty generator - no events }); @@ -254,4 +253,26 @@ describe('EventManager', () => { expect(listenerMock).toHaveBeenCalledTimes(0); }); + + it('should poll right away after start if latestEventId is passed', async () => { + manager = new EventManager(mockEventManager, POLLING_INTERVAL, 'eventId1'); + + await manager.start(); + + // Right after the start it is called. + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(1); + }); + + it('should not poll right away after start if latestEventId is not passed', async () => { + manager = new EventManager(mockEventManager, POLLING_INTERVAL, null); + + await manager.start(); + + // Right after the start it is not called. + expect(mockEventManager.getEvents).not.toHaveBeenCalled(); + + // But it is scheduled to be called after the polling interval. + await jest.runOnlyPendingTimersAsync(); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(1); + }); }); diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts index 6264466b..e1d4720e 100644 --- a/js/sdk/src/internal/events/eventManager.ts +++ b/js/sdk/src/internal/events/eventManager.ts @@ -38,8 +38,11 @@ export class EventManager { } async start(): Promise { + this.logger.info(`Starting event manager with latestEventId: ${this.latestEventId}`); if (this.latestEventId === undefined) { this.latestEventId = await this.specializedEventManager.getLatestEventId(); + this.scheduleNextPoll(); + return; } this.processPromise = this.processEvents(); } @@ -107,6 +110,13 @@ export class EventManager { throw listenerError; } + this.scheduleNextPoll(); + } + + private scheduleNextPoll() { + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle); + } this.timeoutHandle = setTimeout(() => { this.processPromise = this.processEvents(); }, this.nextPollTimeout); diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index cba67f87..62cecaf8 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -21,7 +21,7 @@ const CORE_POLLING_INTERVAL = 30; */ export class DriveEventsService { private apiService: EventsAPIService; - private coreEvents?: EventManager; + private coreEventManager?: EventManager; private volumeEventManagers: { [volumeId: string]: EventManager }; private logger: Logger; @@ -38,20 +38,16 @@ export class DriveEventsService { this.volumeEventManagers = {}; } - /** - * Subscribe to drive events. The treeEventScopeId can be obtained from a node. - */ - async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { - const volumeId = treeEventScopeId; - this.logger.debug(`Creating volume event manager for volume ${volumeId}`); - let manager = this.volumeEventManagers[volumeId]; - let started = true; + // FIXME: Allow to pass own core events manager from the public interface. + async subscribeToCoreEvents(callback: DriveListener): Promise { + let manager = this.coreEventManager; + const started = !!manager; + if (manager === undefined) { - manager = await this.createVolumeEventManager(volumeId); - this.volumeEventManagers[volumeId] = manager; - started = false; - this.sendNumberOfVolumeSubscriptionsToTelemetry(); + manager = await this.createCoreEventManager(); + this.coreEventManager = manager; } + const eventSubscription = manager.addListener(callback); if (!started) { await manager.start(); @@ -59,53 +55,75 @@ export class DriveEventsService { return eventSubscription; } - // FIXME: Allow to pass own core events manager from the public interface. - async subscribeToCoreEvents(callback: DriveListener): Promise { - if (this.latestEventIdProvider === null || this.latestEventIdProvider === undefined) { + private async createCoreEventManager() { + if (!this.latestEventIdProvider) { throw new Error( 'Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization', ); } - if (this.coreEvents === undefined) { - const coreEventManager = new CoreEventManager(this.logger, this.apiService); - const latestEventId = this.latestEventIdProvider.getLatestEventId('core') ?? null; - this.coreEvents = new EventManager(coreEventManager, CORE_POLLING_INTERVAL, latestEventId); - for (const listener of this.cacheEventListeners) { - this.coreEvents.addListener(listener); - } + + const coreEventManager = new CoreEventManager(this.logger, this.apiService); + const latestEventId = this.latestEventIdProvider.getLatestEventId('core') ?? null; + const eventManager = new EventManager(coreEventManager, CORE_POLLING_INTERVAL, latestEventId); + + for (const listener of this.cacheEventListeners) { + eventManager.addListener(listener); } - const eventSubscription = this.coreEvents.addListener(callback); - await this.coreEvents.start(); - return eventSubscription; + + return eventManager; } - private sendNumberOfVolumeSubscriptionsToTelemetry() { - this.telemetry.logEvent({ - eventName: 'volumeEventsSubscriptionsChanged', - numberOfVolumeSubscriptions: Object.keys(this.volumeEventManagers).length, - }); + /** + * Subscribe to drive events. The treeEventScopeId can be obtained from a node. + */ + async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { + const volumeId = treeEventScopeId; + let manager = this.volumeEventManagers[volumeId]; + const started = !!manager; + + if (manager === undefined) { + manager = await this.createVolumeEventManager(volumeId); + this.volumeEventManagers[volumeId] = manager; + } + + const eventSubscription = manager.addListener(callback); + if (!started) { + await manager.start(); + this.sendNumberOfVolumeSubscriptionsToTelemetry(); + } + return eventSubscription; } private async createVolumeEventManager(volumeId: string): Promise> { - if (this.latestEventIdProvider === null || this.latestEventIdProvider === undefined) { + if (!this.latestEventIdProvider) { throw new Error( 'Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization', ); } + + this.logger.debug(`Creating volume event manager for volume ${volumeId}`); + const volumeEventManager = new VolumeEventManager(this.logger, this.apiService, volumeId); + const isOwnVolume = await this.shareManagement.isOwnVolume(volumeId); const pollingInterval = this.getDefaultVolumePollingInterval(isOwnVolume); - const volumeEventManager = new VolumeEventManager(this.logger, this.apiService, volumeId); const latestEventId = this.latestEventIdProvider.getLatestEventId(volumeId); const eventManager = new EventManager(volumeEventManager, pollingInterval, latestEventId); + for (const listener of this.cacheEventListeners) { eventManager.addListener(listener); } - await eventManager.start(); - this.volumeEventManagers[volumeId] = eventManager; + return eventManager; } private getDefaultVolumePollingInterval(isOwnVolume: boolean): number { return isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL; } + + private sendNumberOfVolumeSubscriptionsToTelemetry() { + this.telemetry.logEvent({ + eventName: 'volumeEventsSubscriptionsChanged', + numberOfVolumeSubscriptions: Object.keys(this.volumeEventManagers).length, + }); + } } diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 21f5c936..6b17ed30 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -41,7 +41,12 @@ export class NodesEventsHandler { return; } if (event.type === DriveEventType.NodeUpdated) { - const node = await this.cache.getNode(event.nodeUid); + let node; + try { + node = await this.cache.getNode(event.nodeUid); + } catch { + return; + } node.isStale = true; node.parentUid = event.parentNodeUid; node.isShared = event.isShared; @@ -53,12 +58,6 @@ export class NodesEventsHandler { await this.cache.setNode(node); } } catch (error: unknown) { - if (error instanceof Error) { - // FIXME throw CacheMissException error and catch it - if (error.message === 'Entity not found') { - return; - } - } this.logger.error(`Failed to update node cache for event: ${event.eventId}`, error); } } diff --git a/js/sdk/src/internal/sharing/cache.ts b/js/sdk/src/internal/sharing/cache.ts index 3a861ab8..66d86bfe 100644 --- a/js/sdk/src/internal/sharing/cache.ts +++ b/js/sdk/src/internal/sharing/cache.ts @@ -22,6 +22,15 @@ export class SharingCache { return this.getNodeUids(SharingType.SharedByMe); } + async hasSharedByMeNodeUidsLoaded(): Promise { + try { + await this.getNodeUids(SharingType.SharedByMe); + return true; + } catch { + return false; + } + } + async addSharedByMeNodeUid(nodeUid: string): Promise { return this.addNodeUid(SharingType.SharedByMe, nodeUid); } diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index 577121ad..087ad79a 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -21,6 +21,7 @@ describe('handleSharedByMeNodes', () => { removeSharedByMeNodeUid: jest.fn(), setSharedWithMeNodeUids: jest.fn(), getSharedByMeNodeUids: jest.fn().mockResolvedValue(['cachedNodeUid']), + hasSharedByMeNodeUidsLoaded: jest.fn().mockResolvedValue(true), }; sharesManager = { isOwnVolume: jest.fn(async (volumeId: string) => volumeId === 'MyVolume1'), @@ -28,94 +29,108 @@ describe('handleSharedByMeNodes', () => { sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager); }); - describe('node events trigger cache update', () => { - it('should add if new own shared node is created', async () => { - const event: DriveEvent = { - eventId: '1', - type: DriveEventType.NodeCreated, - nodeUid: 'newNodeUid', - parentNodeUid: 'parentUid', - isTrashed: false, - isShared: true, - treeEventScopeId: 'MyVolume1', - }; - await sharingEventHandler.handleDriveEvent(event); - expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith('newNodeUid'); - expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); - - test('should not add if new shared node is not own', async () => { - const event: DriveEvent = { - eventId: '1', - type: DriveEventType.NodeCreated, - nodeUid: 'newNodeUid', - parentNodeUid: 'parentUid', - isTrashed: false, - isShared: true, - treeEventScopeId: 'NotOwnVolume', - }; - await sharingEventHandler.handleDriveEvent(event); - expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); - expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); - - it('should not add if new own node is not shared', async () => { - const event: DriveEvent = { - type: DriveEventType.NodeCreated, - nodeUid: 'newNodeUid', - parentNodeUid: 'parentUid', - isTrashed: false, - isShared: false, - eventId: '1', - treeEventScopeId: 'MyVolume1', - }; - await sharingEventHandler.handleDriveEvent(event); - expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); - expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); - - it('should add if own node is updated and shared', async () => { - const event: DriveEvent = { - type: DriveEventType.NodeUpdated, - nodeUid: 'cachedNodeUid', - parentNodeUid: 'parentUid', - isTrashed: false, - isShared: true, - eventId: '1', - treeEventScopeId: 'MyVolume1', - }; - await sharingEventHandler.handleDriveEvent(event); - expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); - expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); - - it('should remove if shared node is un-shared', async () => { - const event: DriveEvent = { - type: DriveEventType.NodeUpdated, - nodeUid: 'cachedNodeUid', - parentNodeUid: 'parentUid', - isTrashed: false, - isShared: false, - eventId: '1', - treeEventScopeId: 'MyVolume1', - }; - await sharingEventHandler.handleDriveEvent(event); - expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); - expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); - - it('should remove if shared node is deleted', async () => { - const event: DriveEvent = { - type: DriveEventType.NodeDeleted, - nodeUid: 'cachedNodeUid', - parentNodeUid: 'parentUid', - eventId: '1', - treeEventScopeId: 'MyVolume1', - }; - await sharingEventHandler.handleDriveEvent(event); - expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); - expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); - }); + it('should add if new own shared node is created', async () => { + const event: DriveEvent = { + eventId: '1', + type: DriveEventType.NodeCreated, + nodeUid: 'newNodeUid', + parentNodeUid: 'parentUid', + isTrashed: false, + isShared: true, + treeEventScopeId: 'MyVolume1', + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith('newNodeUid'); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + test('should not add if new shared node is not own', async () => { + const event: DriveEvent = { + eventId: '1', + type: DriveEventType.NodeCreated, + nodeUid: 'newNodeUid', + parentNodeUid: 'parentUid', + isTrashed: false, + isShared: true, + treeEventScopeId: 'NotOwnVolume', + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it('should not add if new own node is not shared', async () => { + const event: DriveEvent = { + type: DriveEventType.NodeCreated, + nodeUid: 'newNodeUid', + parentNodeUid: 'parentUid', + isTrashed: false, + isShared: false, + eventId: '1', + treeEventScopeId: 'MyVolume1', + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it('should add if own node is updated and shared', async () => { + const event: DriveEvent = { + type: DriveEventType.NodeUpdated, + nodeUid: 'cachedNodeUid', + parentNodeUid: 'parentUid', + isTrashed: false, + isShared: true, + eventId: '1', + treeEventScopeId: 'MyVolume1', + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it('should remove if shared node is un-shared', async () => { + const event: DriveEvent = { + type: DriveEventType.NodeUpdated, + nodeUid: 'cachedNodeUid', + parentNodeUid: 'parentUid', + isTrashed: false, + isShared: false, + eventId: '1', + treeEventScopeId: 'MyVolume1', + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it('should remove if shared node is deleted', async () => { + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + nodeUid: 'cachedNodeUid', + parentNodeUid: 'parentUid', + eventId: '1', + treeEventScopeId: 'MyVolume1', + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith('cachedNodeUid'); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it('should not update cache if shared by me is not loaded', async () => { + cache.hasSharedByMeNodeUidsLoaded = jest.fn().mockResolvedValue(false); + const event: DriveEvent = { + eventId: '1', + type: DriveEventType.NodeCreated, + nodeUid: 'newNodeUid', + parentNodeUid: 'parentUid', + isTrashed: false, + isShared: true, + treeEventScopeId: 'MyVolume1', + }; + await sharingEventHandler.handleDriveEvent(event); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); }); @@ -141,7 +156,7 @@ describe('handleSharedWithMeNodes', () => { } as any; }); - it('should only update cache', async () => { + it('should update cache', async () => { const event: DriveEvent = { type: DriveEventType.SharedWithMeUpdated, eventId: 'event1', diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index f62a3401..dc84f502 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -30,26 +30,41 @@ export class SharingEventHandler { await this.cache.setSharedWithMeNodeUids(undefined); return; } - if (!(await this.shares.isOwnVolume(event.treeEventScopeId))) { - return; - } - if (event.type === DriveEventType.NodeCreated || event.type == DriveEventType.NodeUpdated) { - if (event.isShared && !event.isTrashed) { - await this.cache.addSharedByMeNodeUid(event.nodeUid); - } else { - await this.cache.removeSharedByMeNodeUid(event.nodeUid); - } - return; - } - if (event.type === DriveEventType.NodeDeleted) { - await this.cache.removeSharedByMeNodeUid(event.nodeUid); - return; - } - if (event.type === DriveEventType.TreeRefresh || event.type === DriveEventType.TreeRemove) { - await this.cache.setSharedWithMeNodeUids(undefined); - } + await this.handleSharedByMeNodeUidsLoaded(event); } catch (error: unknown) { this.logger.error(`Skipping shared by me node cache update`, error); } } + + private async handleSharedByMeNodeUidsLoaded(event: DriveEvent) { + if (event.type === DriveEventType.TreeRefresh || event.type === DriveEventType.TreeRemove) { + await this.cache.setSharedWithMeNodeUids(undefined); + return; + } + + if (![DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.NodeDeleted].includes(event.type)) { + return; + } + + const hasSharedByMeLoaded = await this.cache.hasSharedByMeNodeUidsLoaded(); + if (!hasSharedByMeLoaded) { + return; + } + + const isOwnVolume = await this.shares.isOwnVolume(event.treeEventScopeId); + if (!isOwnVolume) { + return; + } + + if (event.type === DriveEventType.NodeCreated || event.type == DriveEventType.NodeUpdated) { + if (event.isShared && !event.isTrashed) { + await this.cache.addSharedByMeNodeUid(event.nodeUid); + } else { + await this.cache.removeSharedByMeNodeUid(event.nodeUid); + } + } + if (event.type === DriveEventType.NodeDeleted) { + await this.cache.removeSharedByMeNodeUid(event.nodeUid); + } + } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 23e82158..9cea321f 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -155,8 +155,8 @@ export class ProtonDriveClient { ); // These are used to keep the internal cache up to date const cacheEventListeners: DriveListener[] = [ - this.nodes.eventHandler.updateNodesCacheOnEvent, - this.sharing.eventHandler.handleDriveEvent, + this.nodes.eventHandler.updateNodesCacheOnEvent.bind(this.nodes.eventHandler), + this.sharing.eventHandler.handleDriveEvent.bind(this.sharing.eventHandler), ]; this.events = new DriveEventsService( telemetry, diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 70d33f65..eb39b4a5 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -29,6 +29,7 @@ type InternalPartialNode = Pick< | 'totalStorageSize' | 'errors' | 'shareId' + | 'treeEventScopeId' >; type NodeUid = string | { uid: string } | Result<{ uid: string }, { uid: string }>; @@ -92,6 +93,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode totalStorageSize: node.totalStorageSize, folder: node.folder, deprecatedShareId: node.shareId, + treeEventScopeId: node.treeEventScopeId, }; const name = node.name; From 723a4f4f156712347418f061e81426d2e7ec0030 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 Aug 2025 11:22:52 +0200 Subject: [PATCH 183/791] js/v0.1.2 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 33e55a86..3b1622b8 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.1.1", + "version": "0.1.2", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 1b5a6f458ca24af635fe7c738f85eca586237428 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 Aug 2025 10:06:46 +0200 Subject: [PATCH 184/791] Add upload unit tests --- cs/Directory.Build.props | 5 ++- cs/Directory.Packages.props | 1 + .../InteropFileUploader.cs | 2 +- .../Proton.Drive.Sdk/AccountClientAdapter.cs | 2 +- .../IRevisionVerificationApiClient.cs | 3 +- .../RevisionVerificationApiClient.cs | 23 ++++++++++++ .../Api/Files/FilesApiClient.cs | 11 ------ .../Api/Files/IFilesApiClient.cs | 2 +- .../Cryptography/CryptoGenerator.cs | 4 +-- cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs | 2 +- .../src/Proton.Drive.Sdk/Nodes/FileSample.cs | 7 ---- .../Nodes/FolderOperations.cs | 4 +-- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 2 +- .../Nodes/RevisionOperations.cs | 7 ++-- .../src/Proton.Drive.Sdk/Nodes/Thumbnail.cs | 7 ++++ ...{FileSamplePurpose.cs => ThumbnailType.cs} | 2 +- .../Nodes/Upload/BlockUploader.cs | 8 ++--- .../Nodes/Upload/FileUploader.cs | 20 +++++------ .../Nodes/Upload/RevisionWriter.cs | 28 ++++++++------- .../Nodes/Upload/RevisionWriterExtensions.cs | 10 +++--- .../Upload/Verification/BitwiseOperations.cs | 36 ------------------- .../Upload/Verification/BlockVerifier.cs | 20 ++++++----- .../Verification/BlockVerifierFactory.cs | 19 ++++++++++ .../Upload/Verification/IBlockVerifier.cs | 8 +++++ .../Verification/IBlockVerifierFactory.cs | 9 +++++ .../Upload/Verification/VerificationToken.cs | 10 ++++-- .../Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 1 + .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 29 +++++++++++++-- 28 files changed, 165 insertions(+), 117 deletions(-) rename cs/sdk/src/Proton.Drive.Sdk/Api/{Files => BlockVerification}/IRevisionVerificationApiClient.cs (79%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/RevisionVerificationApiClient.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSample.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Thumbnail.cs rename cs/sdk/src/Proton.Drive.Sdk/Nodes/{FileSamplePurpose.cs => ThumbnailType.cs} (72%) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BitwiseOperations.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifier.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index e3241c21..7fe2d007 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -18,8 +18,11 @@ lib + - false + + true + false diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index e1bd32a8..2b191304 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index daa51ef9..ae74acb8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -81,7 +81,7 @@ private static unsafe nint NativeUploadFromStream( var uploadController = uploader.UploadFromStream( parentUid.Value, stream, - request.HasThumbnail ? [new FileSample(FileSamplePurpose.Thumbnail, request.Thumbnail.ToByteArray())] : [], + request.HasThumbnail ? [new Thumbnail(ThumbnailType.Thumbnail, request.Thumbnail.ToByteArray())] : [], request.CreateNewRevisionIfExists, (completed, total) => progressCallback.UpdateProgress(completed, total), cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs index 1149604f..ff4d4260 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs @@ -8,7 +8,7 @@ internal sealed class AccountClientAdapter(ProtonApiSession session) : IAccountC { private readonly ProtonAccountClient _client = new(session); - public ValueTask
GetAddressAsync(ProtonDriveClient client, AddressId addressId, CancellationToken cancellationToken) + public ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) { return _client.GetAddressAsync(addressId, cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IRevisionVerificationApiClient.cs similarity index 79% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IRevisionVerificationApiClient.cs index 7399e9a2..40aa21c1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IRevisionVerificationApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IRevisionVerificationApiClient.cs @@ -1,7 +1,8 @@ +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Volumes; -namespace Proton.Drive.Sdk.Api.Files; +namespace Proton.Drive.Sdk.Api.BlockVerification; internal interface IRevisionVerificationApiClient { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/RevisionVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/RevisionVerificationApiClient.cs new file mode 100644 index 00000000..3fde14d9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/RevisionVerificationApiClient.cs @@ -0,0 +1,23 @@ +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.BlockVerification; + +internal sealed class RevisionVerificationApiClient(HttpClient httpClient) : IRevisionVerificationApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask GetVerificationInputAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.BlockVerificationInputResponse) + .GetAsync($"v2/volumes/{volumeId}/links/{linkId}/revisions/{revisionId}/verification", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index 22331ab2..b8e3e61a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -42,17 +42,6 @@ public async ValueTask UpdateRevisionAsync( cancellationToken).ConfigureAwait(false); } - public async ValueTask GetVerificationInputAsync( - VolumeId volumeId, - LinkId linkId, - RevisionId revisionId, - CancellationToken cancellationToken) - { - return await _httpClient - .Expecting(DriveApiSerializerContext.Default.BlockVerificationInputResponse) - .GetAsync($"v2/volumes/{volumeId}/links/{linkId}/revisions/{revisionId}/verification", cancellationToken).ConfigureAwait(false); - } - public async ValueTask GetRevisionAsync( VolumeId volumeId, LinkId linkId, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs index 3e39ad6a..c2ea6057 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal interface IFilesApiClient : IRevisionVerificationApiClient +internal interface IFilesApiClient { ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs index 8af35e6d..be896d20 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs @@ -27,9 +27,7 @@ public static PgpPrivateKey GeneratePrivateKey() public static byte[] GenerateFolderHashKey() { - var hashKeyBuffer = new byte[FolderHashKeyLength]; - RandomNumberGenerator.Fill(hashKeyBuffer); - return hashKeyBuffer; + return RandomNumberGenerator.GetBytes(FolderHashKeyLength); } public static PgpSessionKey GenerateSessionKey() diff --git a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs index 9c9f5d7c..58e599d2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk; internal interface IAccountClient { - ValueTask
GetAddressAsync(ProtonDriveClient client, AddressId addressId, CancellationToken cancellationToken); + ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken); ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken); ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken); ValueTask GetAddressPrivateKeyAsync(AddressId addressId, int index, CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSample.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSample.cs deleted file mode 100644 index 87b9049d..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSample.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -public sealed class FileSample(FileSamplePurpose purpose, ArraySegment content) -{ - public FileSamplePurpose Purpose { get; } = purpose; - public ArraySegment Content { get; } = content; -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index b40b9c6f..fe67c081 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -1,8 +1,8 @@ using System.Runtime.CompilerServices; -using System.Security.Cryptography; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Cryptography; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; @@ -63,7 +63,7 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var hashKey = RandomNumberGenerator.GetBytes(32); + var hashKey = CryptoGenerator.GenerateFolderHashKey(); NodeOperations.GetCommonCreationParameters( name, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 58116a79..cfd6480a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -394,7 +394,7 @@ public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClie var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); - return await client.Account.GetAddressAsync(client, share.MembershipAddressId, cancellationToken).ConfigureAwait(false); + return await client.Account.GetAddressAsync(share.MembershipAddressId, cancellationToken).ConfigureAwait(false); } public static bool ValidateName( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index 6a02cf18..f20eb21a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -17,8 +17,6 @@ public static async ValueTask OpenForWritingAsync( await client.BlockUploader.FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - const int targetBlockSize = RevisionWriter.DefaultBlockSize; - return new RevisionWriter( client, revisionUid, @@ -27,8 +25,9 @@ public static async ValueTask OpenForWritingAsync( signingKey, membershipAddress, releaseBlocksAction, - targetBlockSize, - targetBlockSize * 3 / 2); + () => client.BlockUploader.FileSemaphore.Release(), + client.TargetBlockSize, + client.MaxBlockSize); } internal static async ValueTask OpenForReadingAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Thumbnail.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Thumbnail.cs new file mode 100644 index 00000000..4739cfed --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Thumbnail.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed class Thumbnail(ThumbnailType type, ArraySegment content) +{ + public ThumbnailType Type { get; } = type; + public ArraySegment Content { get; } = content; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSamplePurpose.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailType.cs similarity index 72% rename from cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSamplePurpose.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailType.cs index fcf45958..10416278 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSamplePurpose.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailType.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes; -public enum FileSamplePurpose +public enum ThumbnailType { Thumbnail = 1, Preview = 2, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 37e0951b..e97c9168 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -38,7 +38,7 @@ public async Task UploadContentAsync( AddressId membershipAddressId, PgpKey signatureEncryptionKey, Stream plainDataStream, - BlockVerifier verifier, + IBlockVerifier verifier, byte[] plainDataPrefix, int plainDataPrefixLength, Action onBlockProgress, @@ -139,7 +139,7 @@ public async Task UploadThumbnailAsync( PgpSessionKey contentKey, PgpPrivateKey signingKey, AddressId membershipAddressId, - FileSample sample, + Thumbnail thumbnail, Action? onProgress, CancellationToken cancellationToken) { @@ -158,7 +158,7 @@ public async Task UploadThumbnailAsync( await using (encryptingStream.ConfigureAwait(false)) { - await encryptingStream.WriteAsync(sample.Content, cancellationToken).ConfigureAwait(false); + await encryptingStream.WriteAsync(thumbnail.Content, cancellationToken).ConfigureAwait(false); } } @@ -176,7 +176,7 @@ public async Task UploadThumbnailAsync( new ThumbnailCreationRequest { Size = (int)dataPacketStream.Length, - Type = (ThumbnailType)sample.Purpose, + Type = (Api.Files.ThumbnailType)thumbnail.Type, HashDigest = sha256Digest, }, ], diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 3e95bf4b..19785eba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -28,13 +28,13 @@ internal FileUploader( public UploadController UploadFromStream( NodeUid parentFolderUid, - Stream contentInputStream, - IEnumerable samples, + Stream contentStream, + IEnumerable thumbnails, bool createNewRevisionIfExists, Action onProgress, CancellationToken cancellationToken) { - var task = UploadFromStreamAsync(parentFolderUid, contentInputStream, samples, createNewRevisionIfExists, onProgress, cancellationToken); + var task = UploadFromStreamAsync(parentFolderUid, contentStream, thumbnails, createNewRevisionIfExists, onProgress, cancellationToken); return new UploadController(task); } @@ -52,8 +52,8 @@ public void Dispose() private async Task UploadFromStreamAsync( NodeUid parentFolderUid, - Stream contentInputStream, - IEnumerable samples, + Stream contentStream, + IEnumerable thumbnails, bool createNewRevisionIfExists, Action onProgress, CancellationToken cancellationToken) @@ -74,8 +74,8 @@ private async Task UploadFromStreamAsync( await UploadAsync( draftRevisionUid, fileSecrets, - contentInputStream, - samples, + contentStream, + thumbnails, _lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); @@ -84,8 +84,8 @@ await UploadAsync( private async ValueTask UploadAsync( RevisionUid revisionUid, FileSecrets fileSecrets, - Stream contentInputStream, - IEnumerable samples, + Stream contentStream, + IEnumerable thumbnails, DateTimeOffset? lastModificationTime, Action onProgress, CancellationToken cancellationToken) @@ -93,7 +93,7 @@ private async ValueTask UploadAsync( using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionUid, fileSecrets, ReleaseBlocks, cancellationToken) .ConfigureAwait(false); - await revisionWriter.WriteAsync(contentInputStream, samples, lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); + await revisionWriter.WriteAsync(contentStream, thumbnails, lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); } private void ReleaseBlocks(int numberOfBlocks) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 210f8764..7e77f2ce 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -3,13 +3,12 @@ using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; -using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Serialization; using Proton.Sdk.Addresses; namespace Proton.Drive.Sdk.Nodes.Upload; -public sealed class RevisionWriter : IDisposable +internal sealed class RevisionWriter : IDisposable { public const int DefaultBlockSize = 1 << 22; // 4 MiB @@ -21,6 +20,7 @@ public sealed class RevisionWriter : IDisposable private readonly PgpPrivateKey _signingKey; private readonly Address _membershipAddress; private readonly Action _releaseBlocksAction; + private readonly Action _releaseFileAction; private readonly int _targetBlockSize; private readonly int _maxBlockSize; @@ -35,6 +35,7 @@ internal RevisionWriter( PgpPrivateKey signingKey, Address membershipAddress, Action releaseBlocksAction, + Action releaseFileAction, int targetBlockSize = DefaultBlockSize, int maxBlockSize = DefaultBlockSize) { @@ -45,13 +46,14 @@ internal RevisionWriter( _signingKey = signingKey; _membershipAddress = membershipAddress; _releaseBlocksAction = releaseBlocksAction; + _releaseFileAction = releaseFileAction; _targetBlockSize = targetBlockSize; _maxBlockSize = maxBlockSize; } public async ValueTask WriteAsync( - Stream contentInputStream, - IEnumerable samples, + Stream contentStream, + IEnumerable thumbnails, DateTimeOffset? lastModificationTime, Action onProgress, CancellationToken cancellationToken) @@ -71,7 +73,7 @@ public async ValueTask WriteAsync( await using (manifestStream.ConfigureAwait(false)) { - var blockVerifier = await BlockVerifier.CreateAsync(_client.Api.Files, _fileUid, _revisionId, _fileKey, cancellationToken).ConfigureAwait(false); + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_fileUid, _revisionId, _fileKey, cancellationToken).ConfigureAwait(false); using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -79,7 +81,7 @@ public async ValueTask WriteAsync( { try { - foreach (var sample in samples) + foreach (var thumbnail in thumbnails) { await WaitForBlockUploaderAsync(uploadTasks, manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); @@ -89,14 +91,14 @@ public async ValueTask WriteAsync( _contentKey, _signingKey, _membershipAddress.Id, - sample, + thumbnail, onProgress: null, cancellationTokenSource.Token); uploadTasks.Enqueue(uploadTask); } - if (contentInputStream.Length > 0) + if (contentStream.Length > 0) { do { @@ -105,7 +107,7 @@ public async ValueTask WriteAsync( { var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await contentInputStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plainDataPrefix, cancellationTokenSource.Token) + await contentStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plainDataPrefix, cancellationTokenSource.Token) .ConfigureAwait(false); blockSizes.Add((int)plainDataStream.Length); @@ -129,7 +131,7 @@ await contentInputStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, progress => { numberOfBytesUploaded += progress; - onProgress(numberOfBytesUploaded, contentInputStream.Length); + onProgress(numberOfBytesUploaded, contentStream.Length); }, _releaseBlocksAction, cancellationTokenSource.Token); @@ -141,12 +143,12 @@ await contentInputStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, ArrayPool.Shared.Return(plainDataPrefix); throw; } - } while (contentInputStream.Position < contentInputStream.Length); + } while (contentStream.Position < contentStream.Length); } } finally { - _client.BlockUploader.FileSemaphore.Release(); + _releaseFileAction.Invoke(); _semaphoreReleased = true; } @@ -176,7 +178,7 @@ await contentInputStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, manifestSignature = await _signingKey.SignAsync(manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); } - var request = GetRevisionUpdateRequest(contentInputStream, lastModificationTime, blockSizes, manifestSignature, signingEmailAddress); + var request = GetRevisionUpdateRequest(contentStream, lastModificationTime, blockSizes, manifestSignature, signingEmailAddress); await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, request, cancellationToken).ConfigureAwait(false); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs index 030ef2c9..b34b8975 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs @@ -1,8 +1,6 @@ -using Proton.Sdk.Drive; +namespace Proton.Drive.Sdk.Nodes.Upload; -namespace Proton.Drive.Sdk.Nodes.Upload; - -public static class RevisionWriterExtensions +internal static class RevisionWriterExtensions { public static ValueTask WriteAsync( this RevisionWriter revisionWriter, @@ -27,12 +25,12 @@ public static ValueTask WriteAsync( public static ValueTask WriteAsync( this RevisionWriter revisionWriter, Stream contentStream, - IEnumerable samples, + IEnumerable thumbnails, DateTime lastModificationTime, Action onProgress, CancellationToken cancellationToken) { - return revisionWriter.WriteAsync(contentStream, samples, new DateTimeOffset(lastModificationTime), onProgress, cancellationToken); + return revisionWriter.WriteAsync(contentStream, thumbnails, new DateTimeOffset(lastModificationTime), onProgress, cancellationToken); } public static async ValueTask WriteAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BitwiseOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BitwiseOperations.cs deleted file mode 100644 index e81161a3..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BitwiseOperations.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Numerics; -using System.Runtime.InteropServices; - -namespace Proton.Drive.Sdk.Nodes.Upload.Verification; - -internal static class BitwiseOperations -{ - public static byte[] Xor(ReadOnlySpan a, ReadOnlySpan b) - { - if (b.Length != a.Length) - { - throw new ArgumentException("Memory segments must have the same length", nameof(b)); - } - - var result = new byte[a.Length]; - - var vectorChunks = b.Length / Vector.Count; - var vectorChunksBound = vectorChunks * Vector.Count; - - var aVectors = MemoryMarshal.Cast>(a[..vectorChunksBound]); - var bVectors = MemoryMarshal.Cast>(b[..vectorChunksBound]); - var resultVectors = MemoryMarshal.Cast>(result.AsSpan()[..vectorChunksBound]); - - for (var i = 0; i < aVectors.Length; ++i) - { - resultVectors[i] = aVectors[i] ^ bVectors[i]; - } - - for (var i = vectorChunksBound; i < b.Length; ++i) - { - result[i] = (byte)(a[i] ^ b[i]); - } - - return result; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs index 012a754f..34d18359 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs @@ -1,10 +1,11 @@ using CommunityToolkit.HighPerformance; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.BlockVerification; using Proton.Drive.Sdk.Api.Files; namespace Proton.Drive.Sdk.Nodes.Upload.Verification; -internal sealed class BlockVerifier +internal sealed class BlockVerifier : IBlockVerifier { private const int MaxVerificationLength = 16; @@ -19,14 +20,15 @@ private BlockVerifier(PgpSessionKey sessionKey, ReadOnlyMemory verificatio public int DataPacketPrefixMaxLength => _verificationCode.Length; - public static async Task CreateAsync( - IRevisionVerificationApiClient client, + public static async ValueTask CreateAsync( + IRevisionVerificationApiClient apiClient, NodeUid fileUid, RevisionId revisionId, PgpPrivateKey key, CancellationToken cancellationToken) { - var verificationInput = await client.GetVerificationInputAsync(fileUid.VolumeId, fileUid.LinkId, revisionId, cancellationToken).ConfigureAwait(false); + var verificationInput = + await apiClient.GetVerificationInputAsync(fileUid.VolumeId, fileUid.LinkId, revisionId, cancellationToken).ConfigureAwait(false); PgpSessionKey sessionKey; try @@ -43,13 +45,13 @@ public static async Task CreateAsync( public VerificationToken VerifyBlock(ReadOnlyMemory dataPacketPrefix, ReadOnlySpan plainDataPrefix) { - var verificationLength = Math.Min(MaxVerificationLength, plainDataPrefix.Length); - using var decryptingStream = PgpDecryptingStream.Open(dataPacketPrefix.AsStream(), _sessionKey); - - Span buffer = stackalloc byte[verificationLength]; - try { + var verificationLength = Math.Min(MaxVerificationLength, plainDataPrefix.Length); + using var decryptingStream = _sessionKey.OpenDecryptingStream(dataPacketPrefix.AsStream()); + + Span buffer = stackalloc byte[verificationLength]; + var numberOfBytesRead = decryptingStream.Read(buffer); if (!plainDataPrefix.StartsWith(buffer[..numberOfBytesRead])) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs new file mode 100644 index 00000000..9e3acea7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs @@ -0,0 +1,19 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.BlockVerification; +using Proton.Drive.Sdk.Api.Files; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +internal sealed class BlockVerifierFactory(HttpClient httpClient) : IBlockVerifierFactory +{ + private readonly IRevisionVerificationApiClient _apiClient = new RevisionVerificationApiClient(httpClient); + + public async ValueTask CreateAsync( + NodeUid fileUid, + RevisionId revisionId, + PgpPrivateKey key, + CancellationToken cancellationToken) + { + return await BlockVerifier.CreateAsync(_apiClient, fileUid, revisionId, key, cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifier.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifier.cs new file mode 100644 index 00000000..468a6ebf --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifier.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +public interface IBlockVerifier +{ + int DataPacketPrefixMaxLength { get; } + + VerificationToken VerifyBlock(ReadOnlyMemory dataPacketPrefix, ReadOnlySpan plainDataPrefix); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs new file mode 100644 index 00000000..849c66c8 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs @@ -0,0 +1,9 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +internal interface IBlockVerifierFactory +{ + ValueTask CreateAsync(NodeUid fileUid, RevisionId revisionId, PgpPrivateKey key, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs index f1275cf6..ad50450d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs @@ -1,4 +1,6 @@ -namespace Proton.Drive.Sdk.Nodes.Upload.Verification; +using System.Numerics.Tensors; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; public readonly struct VerificationToken { @@ -15,7 +17,11 @@ public static VerificationToken Create(ReadOnlySpan verificationCode, Read // the length of the data packet prefix, we have padding logic to deal with it, as per the agreed verification protocol. var dataPacketPrefixForToken = GetPaddedOrTruncatedBytes(dataPacketPrefix, verificationCode.Length); - return new VerificationToken(BitwiseOperations.Xor(verificationCode, dataPacketPrefixForToken)); + var tokenData = new byte[verificationCode.Length]; + + TensorPrimitives.Xor(verificationCode, dataPacketPrefixForToken, tokenData); + + return new VerificationToken(tokenData); } public ReadOnlyMemory AsReadOnlyMemory() => _data; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj index 6ef601db..daa5dd69 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -11,6 +11,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 8860187f..1716f561 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -5,6 +5,7 @@ using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Volumes; using Proton.Sdk; @@ -22,18 +23,24 @@ public sealed class ProtonDriveClient /// Authenticated API session. public ProtonDriveClient(ProtonApiSession session) : this( + session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds)), new AccountClientAdapter(session), - new DriveApiClients(session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds))), new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), session.ClientConfiguration.LoggerFactory) { } - internal ProtonDriveClient(IAccountClient accountClient, IDriveApiClients apiClients, IDriveClientCache cache, ILoggerFactory loggerFactory) + internal ProtonDriveClient( + IAccountClient accountClient, + IDriveApiClients apiClients, + IDriveClientCache cache, + IBlockVerifierFactory blockVerifierFactory, + ILoggerFactory loggerFactory) { Account = accountClient; Api = apiClients; Cache = cache; + BlockVerifierFactory = blockVerifierFactory; Logger = loggerFactory.CreateLogger(); var maxDegreeOfBlockTransferParallelism = Math.Max( @@ -49,16 +56,34 @@ internal ProtonDriveClient(IAccountClient accountClient, IDriveApiClients apiCli BlockDownloader = new BlockDownloader(this, maxDegreeOfBlockTransferParallelism); } + private ProtonDriveClient( + HttpClient httpClient, + IAccountClient accountClient, + IDriveClientCache cache, + ILoggerFactory loggerFactory) + : this( + accountClient, + new DriveApiClients(httpClient), + cache, + new BlockVerifierFactory(httpClient), + loggerFactory) + { + } + internal static RecyclableMemoryStreamManager MemoryStreamManager { get; } = new(); internal IAccountClient Account { get; } internal IDriveApiClients Api { get; } internal IDriveClientCache Cache { get; } + internal IBlockVerifierFactory BlockVerifierFactory { get; } internal ILogger Logger { get; } internal FifoFlexibleSemaphore RevisionCreationSemaphore { get; } internal FifoFlexibleSemaphore BlockListingSemaphore { get; } + internal int TargetBlockSize { get; set; } = RevisionWriter.DefaultBlockSize; + internal int MaxBlockSize { get; set; } = RevisionWriter.DefaultBlockSize * 3 / 2; + internal BlockUploader BlockUploader { get; } internal BlockDownloader BlockDownloader { get; } From 7a8628e99b2ba228e1a2ba03711897cbddfd8e3a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 8 Aug 2025 05:56:37 +0000 Subject: [PATCH 185/791] Add seeking support for download --- js/sdk/src/interface/download.ts | 46 +++++ js/sdk/src/interface/index.ts | 2 +- .../src/internal/download/blockIndex.test.ts | 158 +++++++++++++++ js/sdk/src/internal/download/blockIndex.ts | 36 ++++ .../internal/download/fileDownloader.test.ts | 101 +++++++++- .../src/internal/download/fileDownloader.ts | 114 ++++++++++- js/sdk/src/internal/download/interface.ts | 8 +- .../internal/download/seekableStream.test.ts | 187 ++++++++++++++++++ .../src/internal/download/seekableStream.ts | 182 +++++++++++++++++ .../internal/nodes/extendedAttributes.test.ts | 67 ++++++- .../src/internal/nodes/extendedAttributes.ts | 38 +++- js/sdk/src/internal/nodes/interface.ts | 1 + js/sdk/src/internal/nodes/nodesAccess.ts | 6 +- js/sdk/src/internal/nodes/nodesRevisions.ts | 19 +- 14 files changed, 933 insertions(+), 32 deletions(-) create mode 100644 js/sdk/src/internal/download/blockIndex.test.ts create mode 100644 js/sdk/src/internal/download/blockIndex.ts create mode 100644 js/sdk/src/internal/download/seekableStream.test.ts create mode 100644 js/sdk/src/internal/download/seekableStream.ts diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index e101f6bd..a34ff2ca 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -26,6 +26,35 @@ export interface FileDownloader { streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void, ): DownloadController; + + /** + * Get a seekable stream that can be used to download specific range of + * data from the file. This is useful for video players to download the + * next several bytes of the video, or skip to the middle without the + * need to download the entire file. + * + * Stream doesn't verify data integrity. For the full integrity of + * the file, use `writeToStream` instead. + * + * The stream is not opportunitistically downloading the data ahead of + * the time. It will only download the data when it is requested. To + * provide smooth experience, pre-buffer the data based on the expected + * playback speed. + * + * The file is chunked into blocks that must be fully downloaded to provide + * given range of data within the block. To avoid downloading the same + * block multiple times, a few blocks can be cached. The size of the cache + * might change in the future to improve performance. + * + * Example: + * + * ```ts + * const seekableStream = fileDownloader.getSeekableStream(); + * await seekableStream.seek(1000); + * const { value, done } = await seekableStream.read(100); + * ``` + */ + getSeekableStream(): SeekableReadableStream; } export interface DownloadController { @@ -33,3 +62,20 @@ export interface DownloadController { resume(): void; completion(): Promise; } + +export interface SeekableReadableStream extends ReadableStream { + /** + * Read a specific number of bytes from the stream at the current position. + * + * @param numBytes - The number of bytes to read. + * @returns A promise that resolves to the read bytes. + */ + read(numBytes: number): Promise<{ value: Uint8Array; done: boolean }>; + + /** + * Seek to the given position in the stream from the beginning of the stream. + * + * @param position - The position to seek to in bytes. + */ + seek(position: number): void | Promise; +} diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 8206cf46..c5cb95ff 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -13,7 +13,7 @@ export type { Author, UnverifiedAuthorError, AnonymousUser } from './author'; export type { ProtonDriveConfig } from './config'; export type { Device, DeviceOrUid } from './devices'; export { DeviceType } from './devices'; -export type { FileDownloader, DownloadController } from './download'; +export type { FileDownloader, DownloadController, SeekableReadableStream } from './download'; export type { DriveListener, LatestEventIdProvider, diff --git a/js/sdk/src/internal/download/blockIndex.test.ts b/js/sdk/src/internal/download/blockIndex.test.ts new file mode 100644 index 00000000..d64ef9fe --- /dev/null +++ b/js/sdk/src/internal/download/blockIndex.test.ts @@ -0,0 +1,158 @@ +import { DEFAULT_FILE_CHUNK_SIZE, getBlockIndex } from './blockIndex'; + +describe('getBlockIndex', () => { + describe('default behavior (no claimedBlockSize)', () => { + it('should handle position 0', () => { + const result = getBlockIndex(undefined, 0); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 1, + blockOffset: 0, + }, + }); + }); + + it('should handle position within first block', () => { + const position = 1024; // 1KB + const result = getBlockIndex(undefined, position); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 1, + blockOffset: 1024, + }, + }); + }); + + it('should handle position at exact block boundary', () => { + const position = DEFAULT_FILE_CHUNK_SIZE; // Exactly 4MB + const result = getBlockIndex(undefined, position); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 2, + blockOffset: 0, + }, + }); + }); + + it('should handle position in second block', () => { + const position = DEFAULT_FILE_CHUNK_SIZE + 1024; // 4MB + 1KB + const result = getBlockIndex(undefined, position); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 2, + blockOffset: 1024, + }, + }); + }); + }); + + describe('default behavior (empty claimedBlockSize)', () => { + it('should handle empty array like undefined', () => { + const position = DEFAULT_FILE_CHUNK_SIZE + 1024; + const result = getBlockIndex([], position); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 2, + blockOffset: 1024, + }, + }); + }); + }); + + describe('variable block sizes', () => { + const claimedBlockSizes = [1024, 2048, 4096]; // 1KB, 2KB, 4KB blocks + + it('should handle position in first block of custom sizes', () => { + const result = getBlockIndex(claimedBlockSizes, 512); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 1, + blockOffset: 512, + }, + }); + }); + + it('should handle position at exact first block boundary', () => { + const result = getBlockIndex(claimedBlockSizes, 1024); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 2, + blockOffset: 0, + }, + }); + }); + + it('should handle position in second block', () => { + const result = getBlockIndex(claimedBlockSizes, 1024 + 512); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 2, + blockOffset: 512, + }, + }); + }); + + it('should handle position at second block boundary', () => { + const result = getBlockIndex(claimedBlockSizes, 1024 + 2048); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 3, + blockOffset: 0, + }, + }); + }); + + it('should handle position in third block', () => { + const result = getBlockIndex(claimedBlockSizes, 1024 + 2048 + 1000); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 3, + blockOffset: 1000, + }, + }); + }); + + it('should handle position at very end of last block', () => { + const result = getBlockIndex(claimedBlockSizes, 1024 + 2048 + 4096 - 1); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 3, + blockOffset: 4095, + }, + }); + }); + + it('should handle zero-sized blocks mixed with normal blocks', () => { + const claimedBlockSizes = [0, 1000, 0, 2000]; + const result = getBlockIndex(claimedBlockSizes, 500); + expect(result).toEqual({ + done: false, + value: { + blockIndex: 2, + blockOffset: 500, + }, + }); + }); + + it('should throw error when position is beyond file with custom block sizes', () => { + const claimedBlockSizes = [1024, 2048, 4096]; + const totalSize = 1024 + 2048 + 4096; + const result = getBlockIndex(claimedBlockSizes, totalSize); + expect(result).toEqual({ + done: true, + value: undefined, + }); + }); + }); +}); diff --git a/js/sdk/src/internal/download/blockIndex.ts b/js/sdk/src/internal/download/blockIndex.ts new file mode 100644 index 00000000..24ab91dd --- /dev/null +++ b/js/sdk/src/internal/download/blockIndex.ts @@ -0,0 +1,36 @@ +export const DEFAULT_FILE_CHUNK_SIZE = 4 * 1024 * 1024; + +export function getBlockIndex( + claimedBlockSizes: number[] | undefined, + position: number, +): { done: false; value: { blockIndex: number; blockOffset: number } } | { done: true; value: undefined } { + if (!claimedBlockSizes || claimedBlockSizes.length === 0) { + return { + value: { + blockIndex: Math.floor(position / DEFAULT_FILE_CHUNK_SIZE) + 1, + blockOffset: position % DEFAULT_FILE_CHUNK_SIZE, + }, + done: false, + }; + } + + let currentPosition = 0; + for (let i = 0; i < claimedBlockSizes.length; i++) { + const blockSize = claimedBlockSizes[i]; + if (position < currentPosition + blockSize) { + return { + value: { + blockIndex: i + 1, + blockOffset: position - currentPosition, + }, + done: false, + }; + } + currentPosition += blockSize; + } + + return { + value: undefined, + done: true, + }; +} diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index 31f802ca..f05f6d82 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -1,9 +1,9 @@ -import { Revision } from '../../interface'; +import { APIHTTPError, HTTPErrorCode } from '../apiService'; +import { DecryptedRevision } from '../nodes'; import { FileDownloader } from './fileDownloader'; import { DownloadTelemetry } from './telemetry'; import { DownloadAPIService } from './apiService'; import { DownloadCryptoService } from './cryptoService'; -import { APIHTTPError, HTTPErrorCode } from '../apiService'; function mockBlockDownload(_: string, token: string, onProgress: (downloadedBytes: number) => void) { const index = parseInt(token.slice(5, 6)); @@ -21,7 +21,7 @@ describe('FileDownloader', () => { let apiService: DownloadAPIService; let cryptoService: DownloadCryptoService; let nodeKey: { key: object; contentKeyPacketSessionKey: string }; - let revision: Revision; + let revision: DecryptedRevision; beforeEach(() => { // @ts-expect-error No need to implement all methods for mocking @@ -74,7 +74,8 @@ describe('FileDownloader', () => { revision = { uid: 'revisionUid', claimedSize: 1024, - } as Revision; + claimedBlockSizes: [16, 16, 16, 16], + } as DecryptedRevision; }); describe('writeToStream', () => { @@ -394,4 +395,96 @@ describe('FileDownloader', () => { expect(onFinish).toHaveBeenCalledTimes(1); }); }); + + describe('getSeekableStream', () => { + let onFinish: () => void; + let downloader: FileDownloader; + + beforeEach(() => { + apiService.downloadBlock = jest.fn().mockImplementation(async function (_, token) { + const index = parseInt(token.slice(5, 6)) - 1; + const data = new Uint8Array(16); + for (let i = 0; i < data.length; i++) { + data[i] = index * 16 + i; + } + return data; + }); + + onFinish = jest.fn(); + + downloader = new FileDownloader( + telemetry, + apiService, + cryptoService, + nodeKey as any, + revision, + undefined, + onFinish, + ); + }); + + it('should read the stream', async () => { + const stream = downloader.getSeekableStream(); + + const data = await stream.read(32); + expect(data.value).toEqual( + new Uint8Array([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + ]), + ); + expect(data.done).toEqual(false); + + const data2 = await stream.read(32); + expect(data2.value).toEqual( + new Uint8Array([ + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 63, + ]), + ); + expect(data2.done).toEqual(false); + + const data3 = await stream.read(32); + expect(data3.value).toEqual(new Uint8Array([])); + expect(data3.done).toEqual(true); + + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.decryptBlock).toHaveBeenCalledWith(expect.anything(), { + key: 'privateKey', + contentKeyPacketSessionKey: 'contentSessionKey', + verificationKeys: 'verificationKeys', + }); + }); + + it('should read the stream with seeking', async () => { + const stream = downloader.getSeekableStream(); + + const data1 = await stream.read(5); + expect(data1.value).toEqual(new Uint8Array([0, 1, 2, 3, 4])); + expect(data1.done).toEqual(false); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(1); + + await stream.seek(10); + + // Seek withing first block, so no new block is downloaded. + const data2 = await stream.read(5); + expect(data2.value).toEqual(new Uint8Array([10, 11, 12, 13, 14])); + expect(data2.done).toEqual(false); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(1); + + // Seek and read from second and third blocks. + await stream.seek(30); + + const data3 = await stream.read(5); + expect(data3.value).toEqual(new Uint8Array([30, 31, 32, 33, 34])); + expect(data3.done).toEqual(false); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + + expect(cryptoService.decryptBlock).toHaveBeenCalledWith(expect.anything(), { + key: 'privateKey', + contentKeyPacketSessionKey: 'contentSessionKey', + verificationKeys: 'verificationKeys', + }); + }); + }); }); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 26aa5c90..3ce36709 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -1,11 +1,14 @@ import { PrivateKey, SessionKey, base64StringToUint8Array } from '../../crypto'; -import { Logger, Revision } from '../../interface'; +import { Logger } from '../../interface'; import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; +import { DecryptedRevision } from '../nodes'; import { DownloadAPIService } from './apiService'; +import { getBlockIndex } from './blockIndex'; import { DownloadController } from './controller'; import { DownloadCryptoService } from './cryptoService'; import { BlockMetadata, RevisionKeys } from './interface'; +import { BufferedSeekableStream } from './seekableStream'; import { DownloadTelemetry } from './telemetry'; /** @@ -33,7 +36,7 @@ export class FileDownloader { private apiService: DownloadAPIService, private cryptoService: DownloadCryptoService, private nodeKey: { key: PrivateKey; contentKeyPacketSessionKey: SessionKey }, - private revision: Revision, + private revision: DecryptedRevision, private signal?: AbortSignal, private onFinish?: () => void, ) { @@ -52,6 +55,89 @@ export class FileDownloader { return this.revision.claimedSize; } + getSeekableStream(): BufferedSeekableStream { + let position = 0; + let cryptoKeys: RevisionKeys; + + const logger = new LoggerWithPrefix(this.logger, `seekable stream`); + + const claimedBlockSizes = this.revision.claimedBlockSizes; + if (!claimedBlockSizes) { + // Old nodes will not have claimed block sizes. One option is to + // use default block size, but old clients didn't use the same + // size (4 MiB vs 4 MB, for example). + // Ideally, we should throw error that client can easily handle, + // at the same time, new nodes shouldn't have this issue. + // For now, we throw general error that client must handle as any + // error from download - do not support seeking and ask user to + // download the whole file instead. + // In the future, we might either change this error, or have some + // clever way to detect block sizes from the first block and work + // around this issue. + throw new Error('Revision does not have defined claimed block sizes'); + } + + const stream = new BufferedSeekableStream({ + start: async () => { + logger.debug(`Starting`); + cryptoKeys = await this.cryptoService.getRevisionKeys(this.nodeKey, this.revision); + }, + pull: async (controller) => { + logger.debug(`Pulling at position ${position}`); + + const result = await this.downloadDataFromPosition(claimedBlockSizes, position, cryptoKeys); + if (result instanceof Error) { + logger.error('Download failed', result); + controller.error(result); + return; + } + if (!result) { + logger.debug(`Download finished at position ${position}`); + controller.close(); + return; + } + controller.enqueue(result); + position += result.length; + }, + cancel: (reason?: unknown) => { + logger.info(`Cancelled: ${reason}`); + this.onFinish?.(); + }, + seek: async (newPosition) => { + logger.info(`Seeking to position ${newPosition}`); + position = newPosition; + }, + }); + return stream; + } + + private async downloadDataFromPosition( + claimedBlockSizes: number[], + position: number, + cryptoKeys: RevisionKeys, + ): Promise { + const { value, done } = getBlockIndex(claimedBlockSizes, position); + if (done) { + return; + } + + this.logger.info(`Downloading data from block ${value.blockIndex} at offset ${value.blockOffset}`); + + try { + const { blockIndex, blockOffset } = value; + const blockMetadata = await this.apiService.getRevisionBlockToken( + this.revision.uid, + blockIndex, + this.signal, + ); + + const blockData = await this.downloadBlockData(blockMetadata, true, cryptoKeys); + return blockData.slice(blockOffset); + } catch (error: unknown) { + return error instanceof Error ? error : new Error(`Unknown error: ${error}`); + } + } + writeToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController { if (this.controller.promise) { throw new Error(`Download already started`); @@ -71,7 +157,7 @@ export class FileDownloader { private async downloadToStream( stream: WritableStream, - onProgress?: (writtenBytes: number) => void, + onProgress?: (downloadedBytes: number) => void, ignoreIntegrityErrors = false, ): Promise { const writer = stream.getWriter(); @@ -113,12 +199,12 @@ export class FileDownloader { this.ongoingDownloads.set(blockMetadata.index, { downloadPromise }); await this.waitForDownloadCapacity(); - await this.flushCompletedBlocks(writer); + await this.flushCompletedBlocks(writer.write); } this.logger.debug(`All blocks downloading, waiting for them to finish`); await Promise.all(this.downloadPromises); - await this.flushCompletedBlocks(writer); + await this.flushCompletedBlocks(writer.write); if (this.ongoingDownloads.size > 0) { this.logger.error(`Some blocks were not downloaded: ${this.ongoingDownloads.keys()}`); @@ -158,6 +244,16 @@ export class FileDownloader { cryptoKeys: RevisionKeys, onProgress: (downloadedBytes: number) => void, ) { + const blockData = await this.downloadBlockData(blockMetadata, ignoreIntegrityErrors, cryptoKeys, onProgress); + this.ongoingDownloads.get(blockMetadata.index)!.decryptedBufferedBlock = blockData; + } + + private async downloadBlockData( + blockMetadata: BlockMetadata, + ignoreIntegrityErrors: boolean, + cryptoKeys: RevisionKeys, + onProgress?: (downloadedBytes: number) => void, + ): Promise { const logger = new LoggerWithPrefix(this.logger, `block ${blockMetadata.index}`); logger.info(`Download started`); @@ -219,8 +315,8 @@ export class FileDownloader { } } - this.ongoingDownloads.get(blockMetadata.index)!.decryptedBufferedBlock = decryptedBlock; logger.info(`Downloaded`); + return decryptedBlock; } private async waitForDownloadCapacity() { @@ -251,16 +347,16 @@ export class FileDownloader { } } - private async flushCompletedBlocks(writer: WritableStreamDefaultWriter) { + private async flushCompletedBlocks(write: (chunk: Uint8Array) => void | Promise) { this.logger.debug(`Flushing completed blocks`); while (this.isNextBlockDownloaded) { const decryptedBlock = this.ongoingDownloads.get(this.nextBlockIndex)!.decryptedBufferedBlock!; this.logger.info(`Flushing completed block ${this.nextBlockIndex}`); try { - await writer.write(decryptedBlock); + await write(decryptedBlock); } catch (error) { this.logger.error(`Failed to write block, retrying once`, error); - await writer.write(decryptedBlock); + await write(decryptedBlock); } this.ongoingDownloads.delete(this.nextBlockIndex); this.nextBlockIndex++; diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index 9da5925c..8641e4ce 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,6 +1,6 @@ import { PrivateKey, PublicKey, SessionKey } from '../../crypto'; -import { NodeType, Result, Revision, MissingNode, MetricVolumeType } from '../../interface'; -import { DecryptedNode } from '../nodes'; +import { NodeType, Result, MissingNode, MetricVolumeType } from '../../interface'; +import { DecryptedNode, DecryptedRevision } from '../nodes'; export type BlockMetadata = { index: number; @@ -29,9 +29,9 @@ export interface NodesService { export interface NodesServiceNode { uid: string; type: NodeType; - activeRevision?: Result; + activeRevision?: Result; } export interface RevisionsService { - getRevision(nodeRevisionUid: string): Promise; + getRevision(nodeRevisionUid: string): Promise; } diff --git a/js/sdk/src/internal/download/seekableStream.test.ts b/js/sdk/src/internal/download/seekableStream.test.ts new file mode 100644 index 00000000..e786258e --- /dev/null +++ b/js/sdk/src/internal/download/seekableStream.test.ts @@ -0,0 +1,187 @@ +import { SeekableReadableStream, BufferedSeekableStream } from './seekableStream'; + +describe('SeekableReadableStream', () => { + it('should call the seek callback when seek is called', async () => { + const mockSeek = jest.fn().mockResolvedValue(undefined); + const mockStart = jest.fn(); + + const stream = new SeekableReadableStream({ + start: mockStart, + seek: mockSeek, + }); + + await stream.seek(100); + + expect(mockSeek).toHaveBeenCalledWith(100); + expect(mockSeek).toHaveBeenCalledTimes(1); + }); + + it('should handle synchronous seek callback', async () => { + const mockSeek = jest.fn().mockReturnValue(undefined); + + const stream = new SeekableReadableStream({ + seek: mockSeek, + }); + + await stream.seek(250); + + expect(mockSeek).toHaveBeenCalledWith(250); + }); +}); + +describe('BufferedSeekableStream', () => { + let startWithCloseMock: jest.Mock; + let pullMock: jest.Mock; + + const data1 = new Uint8Array([1, 2, 3, 4, 5]); + const data2 = new Uint8Array([6, 7, 8, 9, 10]); + + beforeEach(() => { + startWithCloseMock = jest.fn().mockImplementation((controller) => { + controller.enqueue(data1); + controller.close(); + }); + + let readIndex = 0; + pullMock = jest.fn().mockImplementation((controller) => { + if (readIndex === 0) { + controller.enqueue(data1); + } else if (readIndex === 1) { + controller.enqueue(data2); + } else { + controller.close(); + } + readIndex++; + }); + }); + + it('should throw error if highWaterMark is not 0', () => { + expect(() => { + new BufferedSeekableStream({ seek: jest.fn() }, { highWaterMark: 1 }); + }).toThrow('highWaterMark must be 0'); + }); + + it('should throw error when reading invalid number of bytes', async () => { + const stream = new BufferedSeekableStream({ + seek: jest.fn(), + }); + + await expect(stream.read(0)).rejects.toThrow('Invalid number of bytes to read'); + await expect(stream.read(-1)).rejects.toThrow('Invalid number of bytes to read'); + }); + + it('should read exact number of bytes when underlying source provides exact amount', async () => { + const stream = new BufferedSeekableStream({ + start: startWithCloseMock, + seek: jest.fn(), + }); + + const result = await stream.read(5); + + expect(result).toEqual({ value: data1, done: false }); + }); + + it('should buffer extra bytes when underlying source provides more than requested', async () => { + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: jest.fn(), + }); + + const result1 = await stream.read(3); + expect(result1).toEqual({ value: new Uint8Array([1, 2, 3]), done: false }); + expect(pullMock).toHaveBeenCalledTimes(1); + + const result2 = await stream.read(2); + expect(result2).toEqual({ value: new Uint8Array([4, 5]), done: false }); + expect(pullMock).toHaveBeenCalledTimes(1); + }); + + it('should use buffered data and read more when buffer is not enough for next read', async () => { + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: jest.fn(), + }); + + const result1 = await stream.read(3); + expect(result1).toEqual({ value: new Uint8Array([1, 2, 3]), done: false }); + expect(pullMock).toHaveBeenCalledTimes(1); + + const result2 = await stream.read(5); + expect(result2).toEqual({ value: new Uint8Array([4, 5, 6, 7, 8]), done: false }); + expect(pullMock).toHaveBeenCalledTimes(2); + }); + + it('should handle end of file gracefully when not enough data available', async () => { + const stream = new BufferedSeekableStream({ + start: startWithCloseMock, + seek: jest.fn(), + }); + + const result = await stream.read(10); + expect(result).toEqual({ value: data1, done: true }); + }); + + it('should clear buffer when seeking back', async () => { + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: jest.fn(), + }); + + const result1 = await stream.read(2); + expect(result1).toEqual({ value: new Uint8Array([1, 2]), done: false }); + + await stream.seek(0); + + const result2 = await stream.read(3); + expect(result2).toEqual({ value: new Uint8Array([6, 7, 8]), done: false }); + }); + + it('should clear buffer when seeking past buffer end', async () => { + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: jest.fn(), + }); + + const result1 = await stream.read(2); + expect(result1).toEqual({ value: new Uint8Array([1, 2]), done: false }); + + await stream.seek(100); + + const result2 = await stream.read(3); + expect(result2).toEqual({ value: new Uint8Array([6, 7, 8]), done: false }); + }); + + it('should update buffer correctly when seeking within buffer range', async () => { + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: jest.fn(), + }); + + const result1 = await stream.read(1); + expect(result1).toEqual({ value: new Uint8Array([1]), done: false }); + + await stream.seek(3); + + const result2 = await stream.read(3); + expect(result2).toEqual({ value: new Uint8Array([4, 5, 6]), done: false }); + }); + + it('should handle multiple read operations correctly', async () => { + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: jest.fn(), + }); + + const result1 = await stream.read(2); + expect(result1).toEqual({ value: new Uint8Array([1, 2]), done: false }); + + const result2 = await stream.read(4); + expect(result2).toEqual({ value: new Uint8Array([3, 4, 5, 6]), done: false }); + + const result3 = await stream.read(3); + expect(result3).toEqual({ value: new Uint8Array([7, 8, 9]), done: false }); + + const result4 = await stream.read(2); + expect(result4).toEqual({ value: new Uint8Array([10]), done: true }); + }); +}); diff --git a/js/sdk/src/internal/download/seekableStream.ts b/js/sdk/src/internal/download/seekableStream.ts new file mode 100644 index 00000000..27a523d1 --- /dev/null +++ b/js/sdk/src/internal/download/seekableStream.ts @@ -0,0 +1,182 @@ +interface UnderlyingSeekableSource extends UnderlyingDefaultSource { + seek: (position: number) => void | Promise; +} + +/** + * A seekable readable stream that can be used to seek to a specific position + * in the stream. + * + * This is useful for downloading the file in chunks or jumping to a specific + * position in the file when streaming a video. + * + * Example to get next chunk of data from the stream at position 100: + * + * ``` + * const stream = new SeekableReadableStream(underlyingSource); + * const reader = stream.getReader(); + * await stream.seek(100); + * const data = await stream.read(); + * console.log(data); + * ``` + */ +export class SeekableReadableStream extends ReadableStream { + private seekCallback: (position: number) => void | Promise; + + constructor({ seek, ...underlyingSource }: UnderlyingSeekableSource, queuingStrategy?: QueuingStrategy) { + super(underlyingSource, queuingStrategy); + this.seekCallback = seek; + } + + seek(position: number): void | Promise { + return this.seekCallback(position); + } +} + +/** + * A buffered seekable stream that allows to seek and read specific number of + * bytes from the stream. + * + * This is useful for reading specific range of data from the stream. Example + * being video player buffering the next several bytes. + * + * The underlying source can chunk the data into various sizes. To ensure that + * every read operation is for the correct location, the SeekableStream is not + * queueing the data upfront. Instead, it will read the data and buffer it for + * the next read operation. If seek is called, the internal buffer is updated + * accordingly. + * + * Example to read 10 bytes from the stream at position 100: + * + * ``` + * const stream = new BufferedSeekableStream(underlyingSource); + * await stream.seek(100); + * const data = await stream.read(10); + * console.log(data); + * ``` + */ +export class BufferedSeekableStream extends SeekableReadableStream { + private buffer: Uint8Array = new Uint8Array(0); + private bufferPosition: number = 0; + private reader: ReadableStreamDefaultReader | null = null; + private streamClosed: boolean = false; + private currentPosition: number = 0; + + constructor(underlyingSource: UnderlyingSeekableSource, queuingStrategy?: QueuingStrategy) { + // highWaterMark means that the stream will buffer up to this many + // bytes. We do not want to buffer anything + if (queuingStrategy && queuingStrategy.highWaterMark !== 0) { + throw new Error('highWaterMark must be 0'); + } + + super(underlyingSource, { + ...queuingStrategy, + highWaterMark: 0, + }); + + this.reader = super.getReader(); + } + + /** + * Read a specific number of bytes from the stream. + * + * When the underlying source provides more bytes than requested, the + * remaining bytes are buffered and used for the next read operation. + * + * @param numBytes - Number of bytes to read + * @returns Promise The read bytes + */ + async read(numBytes: number): Promise<{ value: Uint8Array; done: boolean }> { + if (numBytes <= 0) { + throw new Error('Invalid number of bytes to read'); + } + + await this.ensureBufferSize(numBytes); + + const result = this.buffer.slice(this.bufferPosition, this.bufferPosition + numBytes); + this.bufferPosition += numBytes; + this.currentPosition += numBytes; + return { + value: result, + done: this.streamClosed, + }; + } + + private async ensureBufferSize(minBytes: number): Promise { + const availableBytes = this.buffer.length - this.bufferPosition; + const neededBytes = minBytes - availableBytes; + + if (neededBytes <= 0 || this.streamClosed) { + return; + } + + const chunks: Uint8Array[] = []; + let totalBytesRead = 0; + + while (totalBytesRead < neededBytes && !this.streamClosed) { + if (!this.reader) { + throw new Error('Stream reader is not available'); + } + + const { done, value } = await this.reader.read(); + + if (done) { + this.streamClosed = true; + break; + } + + if (value) { + chunks.push(value); + totalBytesRead += value.length; + } + } + + if (chunks.length > 0) { + // Create new buffer with existing unused data plus new chunks + const unusedBufferData = this.buffer.slice(this.bufferPosition); + const newTotalLength = unusedBufferData.length + totalBytesRead; + const newBuffer = new Uint8Array(newTotalLength); + + newBuffer.set(unusedBufferData, 0); + let offset = unusedBufferData.length; + for (const chunk of chunks) { + newBuffer.set(chunk, offset); + offset += chunk.length; + } + + this.buffer = newBuffer; + this.bufferPosition = 0; + } + } + + /** + * Seek to the given position in the stream. + * + * If the position is outside of internally buffered data, the buffer is + * cleared. If the position is seeked back, the buffer is read again from + * the underlying source. + * + * @param position - The position to seek to in bytes. + */ + async seek(position: number): Promise { + const endOfBufferPosition = this.currentPosition + (this.buffer.length - this.bufferPosition); + + if (position > endOfBufferPosition) { + this.buffer = new Uint8Array(0); + this.bufferPosition = 0; + } else if (position < this.currentPosition) { + this.buffer = new Uint8Array(0); + this.bufferPosition = 0; + } else { + this.bufferPosition += position - this.currentPosition; + } + + await super.seek(position); + + if (this.reader) { + this.reader.releaseLock(); + } + this.reader = super.getReader(); + this.streamClosed = false; + this.currentPosition = position; + } +} diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index a71ef9e6..05a7b064 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -83,83 +83,100 @@ describe('extended attrbiutes', () => { }); describe('should parses file attributes', () => { - const testCases: [string, FileExtendedAttributesParsed][] = [ - ['', {}], - ['{}', {}], - ['a', {}], + const testCases: [Date, string, FileExtendedAttributesParsed][] = [ + [new Date('2025-01-01'), '', {}], + [new Date('2025-01-01'), '{}', {}], + [new Date('2025-01-01'), 'a', {}], [ + new Date('2025-01-01'), '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000"}}', { claimedModificationTime: new Date(1234567890000), claimedSize: undefined, claimedDigests: undefined, claimedAdditionalMetadata: undefined, + claimedBlockSizes: undefined, }, ], [ + new Date('2025-01-01'), '{"Common": {"Size": 123}}', { claimedModificationTime: undefined, claimedSize: 123, claimedDigests: undefined, claimedAdditionalMetadata: undefined, + claimedBlockSizes: undefined, }, ], [ - '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000", "Size": 123, "BlockSizes": [1, 2, 3]}}', + new Date('2025-01-01'), + '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000", "Size": 123, "BlockSizes": [123]}}', { claimedModificationTime: new Date(1234567890000), claimedSize: 123, claimedDigests: undefined, claimedAdditionalMetadata: undefined, + claimedBlockSizes: [123], }, ], [ + new Date('2025-01-01'), '{"Common": {"ModificationTime": "aa", "Size": 123}}', { claimedModificationTime: undefined, claimedSize: 123, claimedDigests: undefined, claimedAdditionalMetadata: undefined, + claimedBlockSizes: undefined, }, ], [ + new Date('2025-01-01'), '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000", "Size": "aaa"}}', { claimedModificationTime: new Date(1234567890000), claimedSize: undefined, claimedDigests: undefined, claimedAdditionalMetadata: undefined, + claimedBlockSizes: undefined, }, ], [ + new Date('2025-01-01'), '{"Common": {"Digests": {}}}', { claimedModificationTime: undefined, claimedSize: undefined, claimedDigests: undefined, claimedAdditionalMetadata: undefined, + claimedBlockSizes: undefined, }, ], [ + new Date('2025-01-01'), '{"Common": {"Digests": {"SHA1": null}}}', { claimedModificationTime: undefined, claimedSize: undefined, claimedDigests: undefined, claimedAdditionalMetadata: undefined, + claimedBlockSizes: undefined, }, ], [ + new Date('2025-01-01'), '{"Common": {"Digests": {"SHA1": "abcdef"}}}', { claimedModificationTime: undefined, claimedSize: undefined, claimedDigests: { sha1: 'abcdef' }, claimedAdditionalMetadata: undefined, + claimedBlockSizes: undefined, }, ], [ + new Date('2025-01-01'), '{"Common": {}, "Media": {}}', { claimedModificationTime: undefined, @@ -168,12 +185,48 @@ describe('extended attrbiutes', () => { claimedAdditionalMetadata: { Media: {}, }, + claimedBlockSizes: undefined, + }, + ], + [ + new Date('2025-01-01'), + '{"Common": {"BlockSizes": [1024, 1024, 1024, 1024, 123]}}', + { + claimedModificationTime: undefined, + claimedSize: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + claimedBlockSizes: [1024, 1024, 1024, 1024, 123], + }, + ], + [ + // Starting from 2025-01-01, block sizes are passed as is. + new Date('2025-01-01'), + '{"Common": {"BlockSizes": [1024, 1024, 123, 1024, 1024]}}', + { + claimedModificationTime: undefined, + claimedSize: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + claimedBlockSizes: [1024, 1024, 123, 1024, 1024], + }, + ], + [ + // Before 2025-01-01, block sizes are sorted in descending order. + new Date('2024-01-01'), + '{"Common": {"BlockSizes": [123, 1024, 1024, 1024, 1024]}}', + { + claimedModificationTime: undefined, + claimedSize: undefined, + claimedDigests: undefined, + claimedAdditionalMetadata: undefined, + claimedBlockSizes: [1024, 1024, 1024, 1024, 123], }, ], ]; - testCases.forEach(([input, expectedAttributes]) => { + testCases.forEach(([creationTime, input, expectedAttributes]) => { it(`should parse ${input}`, () => { - const output = parseFileExtendedAttributes(getMockLogger(), input); + const output = parseFileExtendedAttributes(getMockLogger(), creationTime, input); expect(output).toMatchObject(expectedAttributes); }); }); diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index a96e595d..6f52bfd8 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -48,6 +48,7 @@ export interface FileExtendedAttributesParsed { sha1?: string; }; claimedAdditionalMetadata?: object; + claimedBlockSizes?: number[]; } export function generateFolderExtendedAttributes(claimedModificationTime?: Date): string | undefined { @@ -113,7 +114,11 @@ export function generateFileExtendedAttributes(options: { }); } -export function parseFileExtendedAttributes(logger: Logger, extendedAttributes?: string): FileExtendedAttributesParsed { +export function parseFileExtendedAttributes( + logger: Logger, + creationTime: Date, + extendedAttributes?: string, +): FileExtendedAttributesParsed { if (!extendedAttributes) { return {}; } @@ -131,6 +136,7 @@ export function parseFileExtendedAttributes(logger: Logger, extendedAttributes?: claimedAdditionalMetadata: Object.keys(claimedAdditionalMetadata).length ? claimedAdditionalMetadata : undefined, + claimedBlockSizes: parseBlockSizes(logger, creationTime, parsed), }; } catch (error: unknown) { logger.error(`Failed to parse extended attributes`, error); @@ -183,3 +189,33 @@ function parseDigests(logger: Logger, xattr?: FileExtendedAttributesSchema): { s sha1, }; } + +function parseBlockSizes( + logger: Logger, + creationTime: Date, + xattr?: FileExtendedAttributesSchema, +): number[] | undefined { + const blockSizes = xattr?.Common?.BlockSizes; + if (blockSizes === undefined) { + return undefined; + } + if (!Array.isArray(blockSizes)) { + logger.warn(`XAttr block sizes "${blockSizes}" is not valid`); + return undefined; + } + if (blockSizes.some((size) => typeof size !== 'number' || size <= 0)) { + logger.warn(`XAttr block sizes "${blockSizes}" is not valid`); + return undefined; + } + if (blockSizes.length === 0) { + return undefined; + } + // Before 2025, there was a bug on the Windows client that didn't sort + // the block sizes in correct order. Because the sizes were all the same + // except the last one, which was always smaller, the block sizes must be + // sorted in descending order. + if (creationTime < new Date('2025-01-01')) { + return blockSizes.sort((a, b) => b - a); + } + return blockSizes; +} diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 5ac62440..14b3a991 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -147,6 +147,7 @@ export interface DecryptedUnparsedRevision extends BaseRevision { export interface DecryptedRevision extends Revision { thumbnails?: Thumbnail[]; + claimedBlockSizes?: number[]; } /** diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 709de40b..f2f09689 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -311,7 +311,11 @@ export class NodesAccess { if (unparsedNode.type === NodeType.File) { const extendedAttributes = unparsedNode.activeRevision?.ok - ? parseFileExtendedAttributes(this.logger, unparsedNode.activeRevision.value.extendedAttributes) + ? parseFileExtendedAttributes( + this.logger, + unparsedNode.activeRevision.value.creationTime, + unparsedNode.activeRevision.value.extendedAttributes, + ) : undefined; return { diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index 40e5ad23..431b803e 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -1,9 +1,10 @@ -import { Logger, Revision } from '../../interface'; +import { Logger } from '../../interface'; import { makeNodeUidFromRevisionUid } from '../uids'; import { NodeAPIService } from './apiService'; import { NodesCryptoService } from './cryptoService'; import { NodesAccess } from './nodesAccess'; import { parseFileExtendedAttributes } from './extendedAttributes'; +import { DecryptedRevision } from './interface'; /** * Provides access to revisions metadata. @@ -21,26 +22,34 @@ export class NodesRevisons { this.nodesAccess = nodesAccess; } - async getRevision(nodeRevisionUid: string): Promise { + async getRevision(nodeRevisionUid: string): Promise { const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid); const { key } = await this.nodesAccess.getNodeKeys(nodeUid); const encryptedRevision = await this.apiService.getRevision(nodeRevisionUid); const revision = await this.cryptoService.decryptRevision(nodeUid, encryptedRevision, key); - const extendedAttributes = parseFileExtendedAttributes(this.logger, revision.extendedAttributes); + const extendedAttributes = parseFileExtendedAttributes( + this.logger, + revision.creationTime, + revision.extendedAttributes, + ); return { ...revision, ...extendedAttributes, }; } - async *iterateRevisions(nodeUid: string, signal?: AbortSignal): AsyncGenerator { + async *iterateRevisions(nodeUid: string, signal?: AbortSignal): AsyncGenerator { const { key } = await this.nodesAccess.getNodeKeys(nodeUid); const encryptedRevisions = await this.apiService.getRevisions(nodeUid, signal); for (const encryptedRevision of encryptedRevisions) { const revision = await this.cryptoService.decryptRevision(nodeUid, encryptedRevision, key); - const extendedAttributes = parseFileExtendedAttributes(this.logger, revision.extendedAttributes); + const extendedAttributes = parseFileExtendedAttributes( + this.logger, + revision.creationTime, + revision.extendedAttributes, + ); yield { ...revision, ...extendedAttributes, From cbfb9972dec3e43b76c7f4ea5730b3afd017b4d3 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 09:48:04 +0200 Subject: [PATCH 186/791] Add download unit tests --- cs/Directory.Packages.props | 24 +++++++++---------- .../Proton.Drive.Sdk/Api/Files/RevisionDto.cs | 2 +- .../Nodes/Download/RevisionReader.cs | 13 ++++++---- .../Nodes/RevisionOperations.cs | 2 +- .../internal/download/fileDownloader.test.ts | 6 ++--- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 2b191304..0a05b372 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -3,30 +3,30 @@ true - - - - - + + + + + - - + + - - - + + + - + - + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs index daa50d9b..257c6161 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs @@ -8,7 +8,7 @@ namespace Proton.Drive.Sdk.Api.Files; internal class RevisionDto { [JsonPropertyName("ID")] - public required string Id { get; init; } + public required RevisionId Id { get; init; } [JsonPropertyName("ClientUID")] public string? ClientId { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 7db4ebd4..71300477 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -7,8 +7,8 @@ namespace Proton.Drive.Sdk.Nodes.Download; internal sealed class RevisionReader : IDisposable { - public const int BlockPageSize = 10; public const int MinBlockIndex = 1; + public const int DefaultBlockPageSize = 10; private readonly ProtonDriveClient _client; private readonly NodeUid _fileUid; @@ -17,6 +17,7 @@ internal sealed class RevisionReader : IDisposable private readonly PgpSessionKey _contentKey; private readonly BlockListingRevisionDto _revisionDto; private readonly Action _releaseBlockListingAction; + private readonly int _blockPageSize; private bool _semaphoreReleased; @@ -28,7 +29,8 @@ internal RevisionReader( PgpPrivateKey fileKey, PgpSessionKey contentKey, BlockListingRevisionDto revisionDto, - Action releaseBlockListingAction) + Action releaseBlockListingAction, + int blockPageSize = DefaultBlockPageSize) { _client = client; _fileUid = revisionUid.NodeUid; @@ -37,6 +39,7 @@ internal RevisionReader( _contentKey = contentKey; _revisionDto = revisionDto; _releaseBlockListingAction = releaseBlockListingAction; + _blockPageSize = blockPageSize; } public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) @@ -217,7 +220,7 @@ private async Task DownloadBlockAsync(Block block, Stream c var mustTryNextPageOfBlocks = true; var nextExpectedIndex = 1; var outstandingBlock = default(Block); - var currentPageBlocks = new List(BlockPageSize); + var currentPageBlocks = new List(_blockPageSize); var revisionDto = _revisionDto; @@ -232,7 +235,7 @@ private async Task DownloadBlockAsync(Block block, Stream c break; } - mustTryNextPageOfBlocks = revisionDto.Blocks.Count >= BlockPageSize; + mustTryNextPageOfBlocks = revisionDto.Blocks.Count >= _blockPageSize; currentPageBlocks.AddRange(revisionDto.Blocks); currentPageBlocks.Sort((a, b) => a.Index.CompareTo(b.Index)); @@ -267,7 +270,7 @@ await _client.Api.Files.GetRevisionAsync( _fileUid.LinkId, _revisionId, lastKnownIndex + 1, - BlockPageSize, + _blockPageSize, false, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index f20eb21a..c44dbed8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -45,7 +45,7 @@ internal static async ValueTask OpenForReadingAsync( fileUid.LinkId, revisionId, RevisionReader.MinBlockIndex, - RevisionReader.BlockPageSize, + RevisionReader.DefaultBlockPageSize, false, cancellationToken).ConfigureAwait(false); diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index f05f6d82..f54227c4 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -263,7 +263,7 @@ describe('FileDownloader', () => { await verifyOnProgress([1, 2, 3]); }); - it('should handle failure when veryfing block', async () => { + it('should handle failure when verifying block', async () => { cryptoService.verifyBlockIntegrity = jest.fn().mockImplementation(async function () { throw new Error('Failed to verify block'); }); @@ -271,7 +271,7 @@ describe('FileDownloader', () => { await verifyFailure('Failed to verify block', undefined); }); - it('should handle one time-off failure when veryfing block', async () => { + it('should handle one time-off failure when verifying block', async () => { let count = 0; cryptoService.verifyBlockIntegrity = jest.fn().mockImplementation(async function () { if (count === 0) { @@ -336,7 +336,7 @@ describe('FileDownloader', () => { await verifyOnProgress([1, 2, 3]); }); - it('should handle failure when veryfing manifest', async () => { + it('should handle failure when verifying manifest', async () => { cryptoService.verifyManifest = jest.fn().mockImplementation(async function () { throw new Error('Failed to verify manifest'); }); From 1437b1d570df68fd21c877ea3f98d401db0c7213 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 12 Aug 2025 05:02:53 +0000 Subject: [PATCH 187/791] Fix download --- js/sdk/src/internal/download/fileDownloader.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 3ce36709..9fc037ef 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -199,12 +199,16 @@ export class FileDownloader { this.ongoingDownloads.set(blockMetadata.index, { downloadPromise }); await this.waitForDownloadCapacity(); - await this.flushCompletedBlocks(writer.write); + await this.flushCompletedBlocks(async (chunk) => { + await writer.write(chunk); + }); } this.logger.debug(`All blocks downloading, waiting for them to finish`); await Promise.all(this.downloadPromises); - await this.flushCompletedBlocks(writer.write); + await this.flushCompletedBlocks(async (chunk) => { + await writer.write(chunk); + }); if (this.ongoingDownloads.size > 0) { this.logger.error(`Some blocks were not downloaded: ${this.ongoingDownloads.keys()}`); From 9ab67267739124f3c4ad5742f30fe825f6043e2d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 Aug 2025 04:58:24 +0000 Subject: [PATCH 188/791] Update telemetry object --- js/sdk/src/diagnostic/telemetry.ts | 2 +- js/sdk/src/interface/telemetry.ts | 4 +++- js/sdk/src/internal/apiService/apiService.ts | 2 +- .../src/internal/download/telemetry.test.ts | 14 ++++++------ js/sdk/src/internal/download/telemetry.ts | 2 +- js/sdk/src/internal/events/index.ts | 2 +- .../src/internal/nodes/cryptoService.test.ts | 22 ++++++++++--------- js/sdk/src/internal/nodes/cryptoService.ts | 6 +++-- .../src/internal/shares/cryptoService.test.ts | 8 ++++--- js/sdk/src/internal/shares/cryptoService.ts | 6 +++-- .../internal/sharing/cryptoService.test.ts | 11 ++++++---- js/sdk/src/internal/sharing/cryptoService.ts | 9 +++++--- js/sdk/src/internal/upload/telemetry.test.ts | 14 ++++++------ js/sdk/src/internal/upload/telemetry.ts | 4 ++-- js/sdk/src/telemetry.ts | 4 ++-- js/sdk/src/tests/telemetry.ts | 2 +- 16 files changed, 64 insertions(+), 48 deletions(-) diff --git a/js/sdk/src/diagnostic/telemetry.ts b/js/sdk/src/diagnostic/telemetry.ts index e46a4935..0c9a8ba6 100644 --- a/js/sdk/src/diagnostic/telemetry.ts +++ b/js/sdk/src/diagnostic/telemetry.ts @@ -20,7 +20,7 @@ export class DiagnosticTelemetry extends EventsGenerator { }); } - logEvent(event: MetricEvent): void { + recordMetric(event: MetricEvent): void { if (event.eventName === 'download' && !event.error) { return; } diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 3b1dbf82..5cca36ab 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -1,6 +1,6 @@ export interface Telemetry { getLogger: (name: string) => Logger; - logEvent: (event: MetricEvent) => void; + recordMetric: (event: MetricEvent) => void; } export interface Logger { @@ -64,6 +64,7 @@ export interface MetricDecryptionErrorEvent { field: MetricsDecryptionErrorField; fromBefore2024?: boolean; error?: unknown; + uid: string; } export type MetricsDecryptionErrorField = | 'shareKey' @@ -81,6 +82,7 @@ export interface MetricVerificationErrorEvent { field: MetricVerificationErrorField; addressMatchingDefaultShare?: boolean; fromBefore2024?: boolean; + uid: string; } export type MetricVerificationErrorField = | 'shareKey' diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 36e31fbf..492b8adb 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -304,7 +304,7 @@ export class DriveAPIService { } } else { if (attempt > 0) { - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'apiRetrySucceeded', failedAttempts: attempt, url: request.url, diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts index 973edd66..4fe3b03b 100644 --- a/js/sdk/src/internal/download/telemetry.test.ts +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -14,7 +14,7 @@ describe('DownloadTelemetry', () => { beforeEach(() => { mockTelemetry = { - logEvent: jest.fn(), + recordMetric: jest.fn(), getLogger: jest.fn().mockReturnValue({ info: jest.fn(), warn: jest.fn(), @@ -34,7 +34,7 @@ describe('DownloadTelemetry', () => { const error = new Error('Failed'); await downloadTelemetry.downloadInitFailed(nodeUid, error); - expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'download', volumeType: 'own_volume', downloadedSize: 0, @@ -47,7 +47,7 @@ describe('DownloadTelemetry', () => { const error = new Error('Failed'); await downloadTelemetry.downloadFailed(revisionUid, error, 123, 456); - expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'download', volumeType: 'own_volume', downloadedSize: 123, @@ -60,7 +60,7 @@ describe('DownloadTelemetry', () => { it('should log successful download (excludes error)', async () => { await downloadTelemetry.downloadFinished(revisionUid, 500); - expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'download', volumeType: 'own_volume', downloadedSize: 500, @@ -70,7 +70,7 @@ describe('DownloadTelemetry', () => { describe('detect error category', () => { const verifyErrorCategory = (error: string) => { - expect(mockTelemetry.logEvent).toHaveBeenCalledWith( + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith( expect.objectContaining({ error, }), @@ -80,7 +80,7 @@ describe('DownloadTelemetry', () => { it('should ignore ValidationError', async () => { const error = new ValidationError('Validation error'); await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); - expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); + expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); }); it('should ignore AbortError', async () => { @@ -88,7 +88,7 @@ describe('DownloadTelemetry', () => { error.name = 'AbortError'; await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); - expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); + expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); }); it('should detect "rate_limited" error for RateLimitedError', async () => { diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index 322a8baf..faa73321 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -80,7 +80,7 @@ export class DownloadTelemetry { this.logger.error('Failed to get metric volume type', error); } - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'download', volumeType, ...options, diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 62cecaf8..270145e8 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -121,7 +121,7 @@ export class DriveEventsService { } private sendNumberOfVolumeSubscriptionsToTelemetry() { - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'volumeEventsSubscriptionsChanged', numberOfVolumeSubscriptions: Object.keys(this.volumeEventManagers).length, }); diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 783c1182..3bce6372 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -75,22 +75,24 @@ describe('nodesCryptoService', () => { const parentKey = 'parentKey' as unknown as PrivateKey; function verifyLogEventVerificationError(options = {}) { - expect(telemetry.logEvent).toHaveBeenCalledTimes(1); - expect(telemetry.logEvent).toHaveBeenCalledWith({ + expect(telemetry.recordMetric).toHaveBeenCalledTimes(1); + expect(telemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'verificationError', volumeType: 'own_volume', fromBefore2024: false, addressMatchingDefaultShare: false, + uid: 'volumeId~nodeId', ...options, }); } function verifyLogEventDecryptionError(options = {}) { - expect(telemetry.logEvent).toHaveBeenCalledTimes(1); - expect(telemetry.logEvent).toHaveBeenCalledWith({ + expect(telemetry.recordMetric).toHaveBeenCalledTimes(1); + expect(telemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'decryptionError', volumeType: 'own_volume', fromBefore2024: false, + uid: 'volumeId~nodeId', ...options, }); } @@ -167,7 +169,7 @@ describe('nodesCryptoService', () => { expect(account.getPublicKeys).toHaveBeenCalledTimes(1); expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); - expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('different authors on key and name', async () => { @@ -176,7 +178,7 @@ describe('nodesCryptoService', () => { expect(account.getPublicKeys).toHaveBeenCalledTimes(2); expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); expect(account.getPublicKeys).toHaveBeenCalledWith('nameSignatureEmail'); - expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); }); @@ -491,7 +493,7 @@ describe('nodesCryptoService', () => { expect(account.getPublicKeys).toHaveBeenCalledTimes(2); // node + revision expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); - expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('different authors on key and name', async () => { @@ -501,7 +503,7 @@ describe('nodesCryptoService', () => { expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); expect(account.getPublicKeys).toHaveBeenCalledWith('nameSignatureEmail'); expect(account.getPublicKeys).toHaveBeenCalledWith('revisionSignatureEmail'); - expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); }); @@ -743,7 +745,7 @@ describe('nodesCryptoService', () => { }); expect(account.getPublicKeys).toHaveBeenCalledTimes(2); - expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); }); @@ -878,7 +880,7 @@ describe('nodesCryptoService', () => { keyAuthor: { ok: true, value: null }, nameAuthor: { ok: true, value: null }, }); - expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); expect(driveCrypto.decryptKey).toHaveBeenCalledWith( encryptedNode.encryptedCrypto.armoredKey, encryptedNode.encryptedCrypto.armoredNodePassphrase, diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 055e148f..88dd61d6 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -571,12 +571,13 @@ export class NodesCryptoService { `Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`, ); - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'verificationError', volumeType, field, addressMatchingDefaultShare, fromBefore2024, + uid: node.uid, }); this.reportedVerificationErrors.add(node.uid); } @@ -598,12 +599,13 @@ export class NodesCryptoService { this.logger.error(`Failed to decrypt node ${node.uid} (from before 2024: ${fromBefore2024})`, error); - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'decryptionError', volumeType, field, fromBefore2024, error, + uid: node.uid, }); this.reportedDecryptionErrors.add(node.uid); } diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts index 9501e2fc..b3db086b 100644 --- a/js/sdk/src/internal/shares/cryptoService.test.ts +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -59,7 +59,7 @@ describe('SharesCryptoService', () => { expect(account.getOwnAddress).toHaveBeenCalledWith('addressId'); expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); - expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('should decrypt root share with signiture verification error', async () => { @@ -97,12 +97,13 @@ describe('SharesCryptoService', () => { expect(account.getOwnAddress).toHaveBeenCalledWith('addressId'); expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); - expect(telemetry.logEvent).toHaveBeenCalledWith({ + expect(telemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'verificationError', volumeType: 'own_volume', field: 'shareKey', addressMatchingDefaultShare: undefined, fromBefore2024: undefined, + uid: 'shareId', }); }); @@ -124,12 +125,13 @@ describe('SharesCryptoService', () => { await expect(result).rejects.toThrow(error); - expect(telemetry.logEvent).toHaveBeenCalledWith({ + expect(telemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'decryptionError', volumeType: 'own_volume', field: 'shareKey', fromBefore2024: undefined, error, + uid: 'shareId', }); }); }); diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 342468af..a1ca6acb 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -125,12 +125,13 @@ export class SharesCryptoService { const fromBefore2024 = share.creationTime ? share.creationTime < new Date('2024-01-01') : undefined; this.logger.error(`Failed to decrypt share ${share.shareId} (from before 2024: ${fromBefore2024})`, error); - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'decryptionError', volumeType: shareTypeToMetricContext(share.type), field: 'shareKey', fromBefore2024, error, + uid: share.shareId, }); this.reportedDecryptionErrors.add(share.shareId); } @@ -143,11 +144,12 @@ export class SharesCryptoService { const fromBefore2024 = share.creationTime ? share.creationTime < new Date('2024-01-01') : undefined; this.logger.error(`Failed to verify share ${share.shareId} (from before 2024: ${fromBefore2024})`); - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'verificationError', volumeType: shareTypeToMetricContext(share.type), field: 'shareKey', fromBefore2024, + uid: share.shareId, }); this.reportedVerificationErrors.add(share.shareId); } diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts index 0817efd7..6f5e27b2 100644 --- a/js/sdk/src/internal/sharing/cryptoService.test.ts +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -84,7 +84,7 @@ describe('SharingCryptoService', () => { 'armoredPassphrase', ); expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith('encryptedName', 'decryptedKey', []); - expect(telemetry.logEvent).not.toHaveBeenCalled(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('should handle undecryptable URL password', async () => { @@ -97,11 +97,12 @@ describe('SharingCryptoService', () => { url: resultError(new Error('Failed to decrypt bookmark password: Failed to decrypt URL password')), nodeName: resultError(new Error('Failed to decrypt bookmark password: Failed to decrypt URL password')), }); - expect(telemetry.logEvent).toHaveBeenCalledWith({ + expect(telemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'decryptionError', volumeType: MetricVolumeType.SharedPublic, field: 'shareUrlPassword', error, + uid: 'tokenId', }); }); @@ -115,11 +116,12 @@ describe('SharingCryptoService', () => { url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), nodeName: resultError(new Error('Failed to decrypt bookmark key: Failed to decrypt share key')), }); - expect(telemetry.logEvent).toHaveBeenCalledWith({ + expect(telemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'decryptionError', volumeType: MetricVolumeType.SharedPublic, field: 'shareKey', error, + uid: 'tokenId', }); }); @@ -133,11 +135,12 @@ describe('SharingCryptoService', () => { url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), nodeName: resultError(new Error('Failed to decrypt bookmark name: Failed to decrypt node name')), }); - expect(telemetry.logEvent).toHaveBeenCalledWith({ + expect(telemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'decryptionError', volumeType: MetricVolumeType.SharedPublic, field: 'nodeName', error, + uid: 'tokenId', }); }); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index cc07c080..d87b23dc 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -478,11 +478,12 @@ export class SharingCryptoService { return urlPassword; } catch (error: unknown) { - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'decryptionError', volumeType: MetricVolumeType.SharedPublic, field: 'shareUrlPassword', error, + uid: encryptedBookmark.tokenId, }); const message = getErrorMessage(error); @@ -503,11 +504,12 @@ export class SharingCryptoService { return shareKey; } catch (error: unknown) { - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'decryptionError', volumeType: MetricVolumeType.SharedPublic, field: 'shareKey', error, + uid: encryptedBookmark.tokenId, }); const message = getErrorMessage(error); @@ -535,11 +537,12 @@ export class SharingCryptoService { return resultOk(name); } catch (error: unknown) { - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'decryptionError', volumeType: MetricVolumeType.SharedPublic, field: 'nodeName', error, + uid: encryptedBookmark.tokenId, }); const message = getErrorMessage(error); diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index debac7c3..c0a84f7c 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -14,7 +14,7 @@ describe('UploadTelemetry', () => { beforeEach(() => { mockTelemetry = { - logEvent: jest.fn(), + recordMetric: jest.fn(), getLogger: jest.fn().mockReturnValue({ info: jest.fn(), warn: jest.fn(), @@ -34,7 +34,7 @@ describe('UploadTelemetry', () => { const error = new Error('Failed'); await uploadTelemetry.uploadInitFailed(parentNodeUid, error, 1000); - expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'upload', volumeType: 'own_volume', uploadedSize: 0, @@ -48,7 +48,7 @@ describe('UploadTelemetry', () => { const error = new Error('Failed'); await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); - expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'upload', volumeType: 'own_volume', uploadedSize: 500, @@ -61,7 +61,7 @@ describe('UploadTelemetry', () => { it('should log successful upload (excludes error)', async () => { await uploadTelemetry.uploadFinished(revisionUid, 1000); - expect(mockTelemetry.logEvent).toHaveBeenCalledWith({ + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ eventName: 'upload', volumeType: 'own_volume', uploadedSize: 1000, @@ -71,7 +71,7 @@ describe('UploadTelemetry', () => { describe('detect error category', () => { const verifyErrorCategory = (error: string) => { - expect(mockTelemetry.logEvent).toHaveBeenCalledWith( + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith( expect.objectContaining({ error, }), @@ -81,7 +81,7 @@ describe('UploadTelemetry', () => { it('should ignore ValidationError', async () => { const error = new ValidationError('Validation error'); await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); - expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); + expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); }); it('should ignore AbortError', async () => { @@ -89,7 +89,7 @@ describe('UploadTelemetry', () => { error.name = 'AbortError'; await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); - expect(mockTelemetry.logEvent).not.toHaveBeenCalled(); + expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); }); it('should detect "rate_limited" error for RateLimitedError', async () => { diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 1307ab57..7a24d4ad 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -23,7 +23,7 @@ export class UploadTelemetry { } logBlockVerificationError(retryHelped: boolean) { - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'blockVerificationError', retryHelped, }); @@ -89,7 +89,7 @@ export class UploadTelemetry { this.logger.error('Failed to get metric volume type', error); } - this.telemetry.logEvent({ + this.telemetry.recordMetric({ eventName: 'upload', volumeType, ...options, diff --git a/js/sdk/src/telemetry.ts b/js/sdk/src/telemetry.ts index e9b23edd..bb300195 100644 --- a/js/sdk/src/telemetry.ts +++ b/js/sdk/src/telemetry.ts @@ -66,7 +66,7 @@ export interface MetricHandler { * const logger = telemetry.getLogger('myLogger'); * logger.debug('Debug message'); * - * telemetry.logEvent({ name: 'somethingHappened', value: 42 }); + * telemetry.recordMetric({ name: 'somethingHappened', value: 42 }); * * const logs = memoryLogHandler.getLogs(); * // Process logs @@ -91,7 +91,7 @@ export class Telemetry { return new Logger(name, this.logFilter, this.logHandlers); } - logEvent(event: T): void { + recordMetric(event: T): void { const metric = { time: new Date(), event, diff --git a/js/sdk/src/tests/telemetry.ts b/js/sdk/src/tests/telemetry.ts index 218d27ff..a124874b 100644 --- a/js/sdk/src/tests/telemetry.ts +++ b/js/sdk/src/tests/telemetry.ts @@ -4,6 +4,6 @@ import { getMockLogger } from './logger'; export function getMockTelemetry(): ProtonDriveTelemetry { return { getLogger: getMockLogger, - logEvent: jest.fn(), + recordMetric: jest.fn(), }; } From 3a72af1150baa426c09072c5bc033609ec200339 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 Aug 2025 11:36:06 +0000 Subject: [PATCH 189/791] Add node membership --- js/sdk/src/crypto/driveCrypto.ts | 95 +++++-- js/sdk/src/crypto/interface.ts | 15 ++ js/sdk/src/crypto/openPGPCrypto.ts | 42 ++- js/sdk/src/interface/index.ts | 1 + js/sdk/src/interface/nodes.ts | 29 ++- js/sdk/src/interface/telemetry.ts | 3 + js/sdk/src/internal/apiService/driveTypes.ts | 243 ++++++------------ js/sdk/src/internal/apiService/index.ts | 2 +- .../src/internal/apiService/transformers.ts | 2 +- js/sdk/src/internal/errors.test.ts | 56 +++- js/sdk/src/internal/errors.ts | 8 + js/sdk/src/internal/nodes/apiService.test.ts | 74 ++++-- js/sdk/src/internal/nodes/apiService.ts | 25 +- js/sdk/src/internal/nodes/cache.test.ts | 7 +- js/sdk/src/internal/nodes/cache.ts | 11 +- .../src/internal/nodes/cryptoService.test.ts | 164 +++++++++--- js/sdk/src/internal/nodes/cryptoService.ts | 97 ++++++- js/sdk/src/internal/nodes/index.test.ts | 2 +- js/sdk/src/internal/nodes/interface.ts | 20 +- js/sdk/src/internal/nodes/nodesAccess.ts | 10 + js/sdk/src/internal/nodes/nodesManagement.ts | 2 +- js/sdk/src/internal/shares/cryptoService.ts | 5 +- js/sdk/src/internal/sharing/apiService.ts | 12 +- js/sdk/src/internal/sharing/cryptoService.ts | 4 +- js/sdk/src/transformers.ts | 6 +- 25 files changed, 644 insertions(+), 291 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index c3791cad..61d6be06 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -178,10 +178,18 @@ export class DriveCrypto { key: PrivateKey; passphraseSessionKey: SessionKey; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }> { - const passphraseSessionKey = await this.openPGPCrypto.decryptArmoredSessionKey(armoredPassphrase, decryptionKeys); + const passphraseSessionKey = await this.openPGPCrypto.decryptArmoredSessionKey( + armoredPassphrase, + decryptionKeys, + ); - const { data: decryptedPassphrase, verified } = await this.openPGPCrypto.decryptArmoredAndVerifyDetached( + const { + data: decryptedPassphrase, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptArmoredAndVerifyDetached( armoredPassphrase, armoredPassphraseSignature, passphraseSessionKey, @@ -196,6 +204,7 @@ export class DriveCrypto { key, passphraseSessionKey, verified, + verificationErrors, }; } @@ -287,20 +296,24 @@ export class DriveCrypto { ): Promise<{ sessionKey: SessionKey; verified?: VERIFICATION_STATUS; + verificationErrors?: Error[]; }> { const data = base64StringToUint8Array(base64data); const sessionKey = await this.openPGPCrypto.decryptSessionKey(data, decryptionKeys); let verified; + let verificationErrors; if (armoredSignature) { - const result = await this.openPGPCrypto.verify(sessionKey.data, armoredSignature, verificationKeys); + const result = await this.openPGPCrypto.verifyArmored(sessionKey.data, armoredSignature, verificationKeys); verified = result.verified; + verificationErrors = result.verificationErrors; } return { sessionKey, verified, + verificationErrors, }; } @@ -423,15 +436,17 @@ export class DriveCrypto { ): Promise<{ name: string; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }> { - const { data: name, verified } = await this.openPGPCrypto.decryptArmoredAndVerify( - armoredNodeName, - [decryptionKey], - verificationKeys, - ); + const { + data: name, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptArmoredAndVerify(armoredNodeName, [decryptionKey], verificationKeys); return { name: uint8ArrayToUtf8(name), verified, + verificationErrors, }; } @@ -448,6 +463,7 @@ export class DriveCrypto { ): Promise<{ hashKey: Uint8Array; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }> { // In the past, we had misunderstanding what key is used to sign hash // key. Originally, it meant to be the node key, which web used for all @@ -455,7 +471,11 @@ export class DriveCrypto { // Similarly, iOS or Android used address key for all nodes. Latest // versions should use node key in all cases, but we accept also // address key. Its still signed with a valid key. - const { data: hashKey, verified } = await this.openPGPCrypto.decryptArmoredAndVerify( + const { + data: hashKey, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptArmoredAndVerify( armoredHashKey, [decryptionAndVerificationKey], [decryptionAndVerificationKey, ...extraVerificationKeys], @@ -463,6 +483,7 @@ export class DriveCrypto { return { hashKey, verified, + verificationErrors, }; } @@ -491,8 +512,13 @@ export class DriveCrypto { ): Promise<{ extendedAttributes: string; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }> { - const { data: decryptedExtendedAttributes, verified } = await this.openPGPCrypto.decryptArmoredAndVerify( + const { + data: decryptedExtendedAttributes, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptArmoredAndVerify( armoreExtendedAttributes, [decryptionKey], verificationKeys, @@ -501,6 +527,7 @@ export class DriveCrypto { return { extendedAttributes: uint8ArrayToUtf8(decryptedExtendedAttributes), verified, + verificationErrors, }; } @@ -524,6 +551,23 @@ export class DriveCrypto { }; } + async verifyInvitation( + base64KeyPacket: string, + armoredKeyPacketSignature: string, + verificationKeys: PublicKey[], + ): Promise<{ + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + const { verified, verificationErrors } = await this.openPGPCrypto.verifyArmored( + base64StringToUint8Array(base64KeyPacket), + armoredKeyPacketSignature, + verificationKeys, + SIGNING_CONTEXTS.SHARING_INVITER, + ); + return { verified, verificationErrors }; + } + async acceptInvitation( base64KeyPacket: string, signingKey: PrivateKey, @@ -591,15 +635,17 @@ export class DriveCrypto { ): Promise<{ decryptedThumbnail: Uint8Array; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }> { - const { data: decryptedThumbnail, verified } = await this.openPGPCrypto.decryptAndVerify( - encryptedThumbnail, - sessionKey, - verificationKeys, - ); + const { + data: decryptedThumbnail, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptAndVerify(encryptedThumbnail, sessionKey, verificationKeys); return { decryptedThumbnail, verified, + verificationErrors, }; } @@ -627,15 +673,8 @@ export class DriveCrypto { }; } - async decryptBlock( - encryptedBlock: Uint8Array, - sessionKey: SessionKey, - ): Promise { - const { data: decryptedBlock } = await this.openPGPCrypto.decryptAndVerify( - encryptedBlock, - sessionKey, - [], - ); + async decryptBlock(encryptedBlock: Uint8Array, sessionKey: SessionKey): Promise { + const { data: decryptedBlock } = await this.openPGPCrypto.decryptAndVerify(encryptedBlock, sessionKey, []); return decryptedBlock; } @@ -658,10 +697,16 @@ export class DriveCrypto { verificationKeys: PublicKey | PublicKey[], ): Promise<{ verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }> { - const { verified } = await this.openPGPCrypto.verify(manifest, armoredSignature, verificationKeys); + const { verified, verificationErrors } = await this.openPGPCrypto.verifyArmored( + manifest, + armoredSignature, + verificationKeys, + ); return { verified, + verificationErrors, }; } diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 1dc5f474..c7e87952 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -170,11 +170,22 @@ export interface OpenPGPCrypto { }>; verify: ( + data: Uint8Array, + signature: Uint8Array, + verificationKeys: PublicKey | PublicKey[], + ) => Promise<{ + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }>; + + verifyArmored: ( data: Uint8Array, armoredSignature: string, verificationKeys: PublicKey | PublicKey[], + signatureContext?: string, ) => Promise<{ verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }>; decryptSessionKey: (data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; @@ -190,6 +201,7 @@ export interface OpenPGPCrypto { ): Promise<{ data: Uint8Array; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }>; decryptAndVerifyDetached( @@ -200,6 +212,7 @@ export interface OpenPGPCrypto { ): Promise<{ data: Uint8Array; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }>; decryptArmored(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]): Promise; @@ -211,6 +224,7 @@ export interface OpenPGPCrypto { ) => Promise<{ data: Uint8Array; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }>; decryptArmoredAndVerifyDetached: ( @@ -221,6 +235,7 @@ export interface OpenPGPCrypto { ) => Promise<{ data: Uint8Array; verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; }>; decryptArmoredWithPassword(armoredData: string, password: string): Promise; diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 458fb9cb..9e695c20 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -45,6 +45,7 @@ export interface OpenPGPCryptoProxy { // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. verified?: VERIFICATION_STATUS; verificationStatus?: VERIFICATION_STATUS; + verificationErrors?: Error[]; }>; signMessage: (options: { format: 'binary' | 'armored'; @@ -55,13 +56,16 @@ export interface OpenPGPCryptoProxy { }) => Promise; verifyMessage: (options: { binaryData: Uint8Array; - armoredSignature: string; + armoredSignature?: string; + binarySignature?: Uint8Array; verificationKeys: PublicKey | PublicKey[]; + signatureContext?: { critical: boolean; value: string }; }) => Promise<{ // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. verified?: VERIFICATION_STATUS; verificationStatus?: VERIFICATION_STATUS; + errors?: Error[]; }>; } @@ -238,16 +242,38 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async verify(data: Uint8Array, armoredSignature: string, verificationKeys: PublicKey | PublicKey[]) { - const { verified, verificationStatus } = await this.cryptoProxy.verifyMessage({ + async verify(data: Uint8Array, signature: Uint8Array, verificationKeys: PublicKey | PublicKey[]) { + const { verified, verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ + binaryData: data, + binarySignature: signature, + verificationKeys, + }); + return { + // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. + // Proper typing is too complex, it will be removed to support only newer pmcrypto. + verified: verified || verificationStatus!, + verificationErrors: errors, + }; + } + + async verifyArmored( + data: Uint8Array, + armoredSignature: string, + verificationKeys: PublicKey | PublicKey[], + signatureContext?: string, + ) { + const { verified, verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ binaryData: data, armoredSignature, verificationKeys, + signatureContext: signatureContext ? { critical: true, value: signatureContext } : undefined, }); + return { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, + verificationErrors: errors, }; } @@ -290,6 +316,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { data: decryptedData, verified, verificationStatus, + verificationErrors, } = await this.cryptoProxy.decryptMessage({ binaryMessage: data, sessionKeys: sessionKey, @@ -302,6 +329,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, + verificationErrors, }; } @@ -315,6 +343,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { data: decryptedData, verified, verificationStatus, + verificationErrors, } = await this.cryptoProxy.decryptMessage({ binaryMessage: data, binarySignature: signature, @@ -328,6 +357,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, + verificationErrors, }; } @@ -345,7 +375,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { decryptionKeys: PrivateKey | PrivateKey[], verificationKeys: PublicKey | PublicKey[], ) { - const { data, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ + const { data, verified, verificationStatus, verificationErrors } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, decryptionKeys, verificationKeys, @@ -357,6 +387,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, + verificationErrors, }; } @@ -366,7 +397,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, verificationKeys: PublicKey | PublicKey[], ) { - const { data, verified, verificationStatus } = await this.cryptoProxy.decryptMessage({ + const { data, verified, verificationStatus, verificationErrors } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, armoredSignature, sessionKeys: sessionKey, @@ -379,6 +410,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, + verificationErrors, }; } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index c5cb95ff..f5e74cd3 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -41,6 +41,7 @@ export type { NodeOrUid, RevisionOrUid, NodeResult, + Membership, } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; export type { diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 1a0e0b0c..e1e34255 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -57,7 +57,17 @@ export type NodeEntity = { * the file, key and content author is user A, while name author is user B. */ nameAuthor: Author; - directMemberRole: MemberRole; + /** + * Role set directly on the node. If not set, the role is inherited from + * the parent node. Client must traverse the tree to get the actual role. + * Actual role should be the highest role available in the tree. + */ + directRole: MemberRole; + /** + * Membership information set directly on the node. If not set, the + * membership is inherited from the parent node. + */ + membership?: Membership; type: NodeType; mediaType?: string; /** @@ -157,6 +167,23 @@ export enum NodeType { Album = 'album', } +export type Membership = { + role: MemberRole; + /** + * Date when the node was shared with the user. + */ + inviteTime: Date; + /** + * Author who shared the node with the user. + * + * If the author cannot be verified, it means that the invitation could + * be forged by bad actor. User should be warned before accepting + * the invitation or opening the shared content. + */ + sharedBy: Author; + // TODO: acceptedBy: Author; +}; + export enum MemberRole { Viewer = 'viewer', Editor = 'editor', diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 5cca36ab..98c52809 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -82,10 +82,13 @@ export interface MetricVerificationErrorEvent { field: MetricVerificationErrorField; addressMatchingDefaultShare?: boolean; fromBefore2024?: boolean; + error?: unknown; uid: string; } export type MetricVerificationErrorField = | 'shareKey' + | 'membershipInviter' + | 'membershipInvitee' | 'nodeKey' | 'nodeName' | 'nodeHashKey' diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 92b11d18..095ce79d 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -1924,27 +1924,6 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/sanitization/asv': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List top level ShareIDs for the user Volume in AUTO_RESTORE state. */ - get: operations['get_drive-sanitization-asv']; - put?: never; - /** - * Log Missing Keys error for restore process - * @description Log a Restore Procedure error when Web detects that Keys are missing. - */ - post: operations['post_drive-sanitization-asv']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/drive/shares': { parameters: { query?: never; @@ -2636,7 +2615,9 @@ export interface paths { }; /** * List volumes - * @description List all volumes available to current user. + * @description List all volumes owned by the current user - can be between zero and two: none, regular and/or photo. + * It can also return volumes in locked state, which are - upon creation of new volumes - re-activated with new root shares. + * The pagination params Page and PageSize are deprecated. */ get: operations['get_drive-volumes']; put?: never; @@ -2647,7 +2628,8 @@ export interface paths { * + Main share for the new Volume * + Adds ShareMember with given Address ID * - * Main share cannot be deleted. + * If the user already has a locked volume, then this locked volume is re-activated + * with a new root share and folder instead of creating a new volume. */ post: operations['post_drive-volumes']; delete?: never; @@ -2706,10 +2688,8 @@ export interface paths { get?: never; /** * Restore locked data in volume. - * @description There are two modes: - * 1. When the user has a locked volume and a different active one, you need to restore the locked volume data into the active - * one of the same type (e.g., locked regular into active regular). This is processed async. - * 2. When the user has locked root shares in an active volume, the data is restored within the same volume. This is done synchronous. + * @description The locked root shares in the volume can be recovered by providing the new encryption material for each share. + * This operation used to be heavy and processed async. But now it's quick and done synchronously. */ put: operations['put_drive-volumes-{volumeID}-restore']; post?: never; @@ -3871,8 +3851,9 @@ export interface components { /** * Format: email * @description Signature email address used to sign the blocks content + * @default null */ - SignatureEmail?: string | null; + SignatureEmail: string | null; /** @default [] */ BlockList: components['schemas']['AnonymousUploadBlockDto'][]; /** @default [] */ @@ -3889,6 +3870,14 @@ export interface components { */ Code: 1000; }; + LinkMapQueryParameters: { + /** @default null */ + SessionName: string | null; + /** @default null */ + LastIndex: number | null; + /** @default 500 */ + PageSize: number; + }; LinkMapResponse: { SessionName: string; More: number; @@ -3956,12 +3945,6 @@ export interface components { */ PossibleKeyPackets: components['schemas']['KeyPacketResponseDto'][]; RootLinkRecoveryPassphrase?: components['schemas']['PGPMessage2'] | null; - /** - * @deprecated - * @description User for AutoRestoreProcedure, see /sanitization/asv endpoint(s) - * @default false - */ - ForASV: boolean; /** * ProtonResponseCode * @example 1000 @@ -3978,15 +3961,6 @@ export interface components { */ Code: 1000; }; - ListAutoRestoreVolumeRootSharesResponseDto: { - ShareIDs: components['schemas']['Id2'][]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; ListSharesResponseDto: { Shares: components['schemas']['ShareResponseDto'][]; /** @@ -3996,9 +3970,6 @@ export interface components { */ Code: 1000; }; - LogFailedRestoreProcedureRequestDto: { - Shares: components['schemas']['FailedRestoreProcedureShareDataDto'][]; - }; TransferInput: { /** @description The ID of the new address */ AddressID: string; @@ -4489,47 +4460,15 @@ export interface components { */ Code: 1000; }; - /** @description Name, Hash, NodePassphrase and NodePassphraseSignature are required when restoring a regular volume but should not be passed when restoring a photo volume. Please pass them as part of MainShares array to support multiple locked main shares. */ RestoreVolumeDto: { /** Format: email */ SignatureAddress: string; - /** - * @deprecated - * @default null - */ - TargetVolumeID: components['schemas']['Id'] | null; - /** - * @deprecated - * @description Folder name as armored PGP message - * @default null - */ - Name: string | null; - /** - * @deprecated - * @description Hash of the name - */ - Hash?: string | null; - /** - * @deprecated - * @default null - */ - NodePassphrase: components['schemas']['PGPMessage'] | null; - /** - * @deprecated - * @default null - */ - NodePassphraseSignature: components['schemas']['PGPSignature'] | null; /** @default [] */ MainShares: components['schemas']['RestoreMainShareDto'][]; /** @default [] */ - Devices: components['schemas']['RestoreDeviceDto'][]; + Devices: components['schemas']['RestoreRootShareDto'][]; /** @default [] */ - PhotoShares: components['schemas']['RestorePhotoShareDto'][]; - /** - * @deprecated - * @default null - */ - NodeHashKey: components['schemas']['PGPMessage'] | null; + PhotoShares: components['schemas']['RestoreRootShareDto'][]; /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ AddressKeyID: string; }; @@ -4768,8 +4707,6 @@ export interface components { /** @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. */ ContentKeyPacketSignature?: string | null; ManifestSignature: components['schemas']['PGPSignature']; - /** @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. */ - ContentBlockEncSignature?: string | null; ContentBlockVerificationToken?: components['schemas']['BinaryString'] | null; /** * @description Extended attributes encrypted with link key @@ -4778,6 +4715,11 @@ export interface components { XAttr: string | null; /** @default null */ Photo: components['schemas']['CommitRevisionPhotoDto'] | null; + /** + * @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. + * @default null + */ + ContentBlockEncSignature: string | null; }; SmallRevisionUploadMetadataRequestDto: { CurrentRevisionID: components['schemas']['Id']; @@ -5293,12 +5235,15 @@ export interface components { Size: number; /** @description Index of block in list (must be consecutive starting at 1) */ Index: number; - /** @description Encrypted PGP Signature of the raw block content */ - EncSignature: string; /** @description Hash of encrypted block, base64 encoded */ Hash: string; /** @default null */ Verifier: components['schemas']['Verifier'] | null; + /** + * @description Encrypted PGP Signature of the raw block content. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. + * @default null + */ + EncSignature: string | null; }; RequestUploadThumbnailInput: { /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ @@ -5385,11 +5330,14 @@ export interface components { Size: number; /** @description Index of block in list (must be consecutive starting at 1) */ Index: number; - /** @description Encrypted PGP Signature of the raw block content */ - EncSignature: string; /** @description Hash of encrypted block, base64 encoded */ Hash: string; Verifier: components['schemas']['Verifier']; + /** + * @description Encrypted PGP Signature of the raw block content. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. + * @default null + */ + EncSignature: string | null; }; ShareURLContext: { /** @description Share ID of the share highest in the tree with permissions */ @@ -5431,6 +5379,8 @@ export interface components { Passphrase: components['schemas']['PGPMessage2']; PassphraseSignature: components['schemas']['PGPSignature2']; AddressID: components['schemas']['Id2']; + InviterSharePassphraseKeyPacketSignature?: components['schemas']['PGPSignature2'] | null; + InviteeSharePassphraseSessionKeySignature?: components['schemas']['PGPSignature2'] | null; }; FolderDetailsDto2: { Link: components['schemas']['LinkDto2']; @@ -5544,10 +5494,6 @@ export interface components { /** @deprecated */ VolumeSoftDeleted: boolean; }; - FailedRestoreProcedureShareDataDto: { - ShareID: components['schemas']['Id']; - Reason: string; - }; ShareKPMigrationData: { /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ ShareID: string; @@ -6139,15 +6085,7 @@ export interface components { */ NodeHashKey: string | null; }; - RestoreDeviceDto: { - /** @description ShareID of the existing share on the old volume */ - LockedShareID: string; - /** @description Key packet for the share passphrase, encrypted with the active key associated with the new volume. Encoded with Base64. */ - ShareKeyPacket: string; - /** @description Signed with new key as armored PGP signature */ - PassphraseSignature: string; - }; - RestorePhotoShareDto: { + RestoreRootShareDto: { /** @description ShareID of the existing share on the old volume */ LockedShareID: string; /** @description Key packet for the share passphrase, encrypted with the active key associated with the new volume. Encoded with Base64. */ @@ -6452,6 +6390,8 @@ export interface components { SignatureEmail?: string | null; /** Format: email */ NameSignatureEmail?: string | null; + /** @default null */ + DirectPermissions: number | null; }; FileDto: { TotalEncryptedSize: number; @@ -6476,6 +6416,15 @@ export interface components { * @enum {integer} */ Permissions: 4 | 6 | 22; + InviteTime: number; + /** Format: email */ + InviterEmail: string; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + MemberSharePassphraseKeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + InviterSharePassphraseKeyPacketSignature: string; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + InviteeSharePassphraseSessionKeySignature: string; }; FolderDto: { NodeHashKey?: components['schemas']['PGPMessage'] | null; @@ -6545,6 +6494,8 @@ export interface components { SignatureEmail?: string | null; /** Format: email */ NameSignatureEmail?: string | null; + /** @default null */ + DirectPermissions: number | null; }; FolderDto2: { NodeHashKey?: components['schemas']['PGPMessage2'] | null; @@ -6566,6 +6517,15 @@ export interface components { * @enum {integer} */ Permissions: 4 | 6 | 22; + InviteTime: number; + /** Format: email */ + InviterEmail: string; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + MemberSharePassphraseKeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + InviterSharePassphraseKeyPacketSignature: string; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + InviteeSharePassphraseSessionKeySignature: string; }; /** * @description

1=active, 3=locked

See values descriptions
See values descriptions
ValueNameDescription
1Active
2Deleted
3Locked* Locked membership can have two reasons: @@ -6599,12 +6559,18 @@ export interface components { /** @deprecated */ URL?: string | null; BareURL?: string | null; - EncSignature?: components['schemas']['PGPMessage2'] | null; + /** + * @deprecated + * @default null + */ + EncSignature: components['schemas']['PGPMessage2'] | null; /** * Format: email + * @deprecated * @description Email used to sign block + * @default null */ - SignatureEmail?: string | null; + SignatureEmail: string | null; }; PhotoResponseDto: { LinkID: components['schemas']['Id2']; @@ -6662,12 +6628,18 @@ export interface components { /** @deprecated */ URL?: string | null; BareURL?: string | null; - EncSignature?: components['schemas']['PGPMessage'] | null; + /** + * @deprecated + * @default null + */ + EncSignature: components['schemas']['PGPMessage'] | null; /** * Format: email + * @deprecated * @description Email used to sign block + * @default null */ - SignatureEmail?: string | null; + SignatureEmail: string | null; }; PhotoResponseDto2: { LinkID: components['schemas']['Id']; @@ -6948,7 +6920,6 @@ export interface operations { content: { 'application/json': { /** @description Potential codes and their meaning: - * - 200001: Maximum number of volumes reached for current user * - 2500: A volume is already active * - 2500: Cannot create the new Photo volume. Should be migrated from current Photo stream * - 2001: Invalid PGP message @@ -10983,11 +10954,9 @@ export interface operations { 'get_drive-shares-{shareID}-map': { parameters: { query?: { - PageSize?: number; - /** @description SessionName provided by previous response */ - SessionName?: string; - /** @description Index value of last element in previous request */ - LastIndex?: number; + SessionName?: components['schemas']['LinkMapQueryParameters']['SessionName']; + LastIndex?: components['schemas']['LinkMapQueryParameters']['LastIndex']; + PageSize?: components['schemas']['LinkMapQueryParameters']['PageSize']; }; header?: never; path: { @@ -11133,52 +11102,6 @@ export interface operations { }; }; }; - 'get_drive-sanitization-asv': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - 'x-pm-code': 1000; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ListAutoRestoreVolumeRootSharesResponseDto']; - }; - }; - }; - }; - 'post_drive-sanitization-asv': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['LogFailedRestoreProcedureRequestDto']; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - 'x-pm-code': 1000; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['SuccessfulResponse']; - }; - }; - }; - }; 'get_drive-shares': { parameters: { query?: { @@ -12747,16 +12670,6 @@ export interface operations { 'application/json': components['schemas']['SuccessfulResponse']; }; }; - /** @description Accepted */ - 202: { - headers: { - 'x-pm-code': 1002; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['AcceptedResponse']; - }; - }; 422: components['responses']['ProtonErrorResponse']; }; }; diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index eab4ccb3..1c5c9b0b 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -2,6 +2,6 @@ export { DriveAPIService } from './apiService'; export type { paths as drivePaths } from './driveTypes'; export type { paths as corePaths } from './coreTypes'; export { HTTPErrorCode, ErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; -export { nodeTypeNumberToNodeType, permissionsToDirectMemberRole, memberRoleToPermission } from './transformers'; +export { nodeTypeNumberToNodeType, permissionsToMemberRole, memberRoleToPermission } from './transformers'; export { ObserverStream } from './observerStream'; export * from './errors'; diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts index 6c9d2344..1c7cc813 100644 --- a/js/sdk/src/internal/apiService/transformers.ts +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -14,7 +14,7 @@ export function nodeTypeNumberToNodeType(logger: Logger, nodeTypeNumber: number) } } -export function permissionsToDirectMemberRole(logger: Logger, permissionsNumber?: number): MemberRole { +export function permissionsToMemberRole(logger: Logger, permissionsNumber?: number): MemberRole { switch (permissionsNumber) { case undefined: return MemberRole.Inherited; diff --git a/js/sdk/src/internal/errors.test.ts b/js/sdk/src/internal/errors.test.ts index 7c2c8d9a..71eb88b7 100644 --- a/js/sdk/src/internal/errors.test.ts +++ b/js/sdk/src/internal/errors.test.ts @@ -2,20 +2,54 @@ import { VERIFICATION_STATUS } from '../crypto'; import { getVerificationMessage } from './errors'; describe('getVerificationMessage', () => { - const testCases: [VERIFICATION_STATUS, string | undefined, boolean, string][] = [ - [VERIFICATION_STATUS.NOT_SIGNED, 'type', false, 'Missing signature for type'], - [VERIFICATION_STATUS.NOT_SIGNED, undefined, false, 'Missing signature'], - [VERIFICATION_STATUS.NOT_SIGNED, 'type', true, 'Missing signature for type'], - [VERIFICATION_STATUS.NOT_SIGNED, undefined, true, 'Missing signature'], - [VERIFICATION_STATUS.SIGNED_AND_INVALID, 'type', false, 'Signature verification for type failed'], - [VERIFICATION_STATUS.SIGNED_AND_INVALID, undefined, false, 'Signature verification failed'], - [VERIFICATION_STATUS.SIGNED_AND_INVALID, 'type', true, 'Verification keys for type are not available'], - [VERIFICATION_STATUS.SIGNED_AND_INVALID, undefined, true, 'Verification keys are not available'], + const testCases: [VERIFICATION_STATUS, Error[] | undefined, string | undefined, boolean, string][] = [ + [VERIFICATION_STATUS.NOT_SIGNED, undefined, 'type', false, 'Missing signature for type'], + [VERIFICATION_STATUS.NOT_SIGNED, undefined, undefined, false, 'Missing signature'], + [VERIFICATION_STATUS.NOT_SIGNED, undefined, 'type', true, 'Missing signature for type'], + [VERIFICATION_STATUS.NOT_SIGNED, undefined, undefined, true, 'Missing signature'], + [VERIFICATION_STATUS.SIGNED_AND_INVALID, undefined, 'type', false, 'Signature verification for type failed'], + [VERIFICATION_STATUS.SIGNED_AND_INVALID, undefined, undefined, false, 'Signature verification failed'], + [ + VERIFICATION_STATUS.SIGNED_AND_INVALID, + undefined, + 'type', + true, + 'Verification keys for type are not available', + ], + [VERIFICATION_STATUS.SIGNED_AND_INVALID, undefined, undefined, true, 'Verification keys are not available'], + [ + VERIFICATION_STATUS.SIGNED_AND_INVALID, + [new Error('error1'), new Error('error2')], + undefined, + false, + 'Signature verification failed: error1, error2', + ], + [ + VERIFICATION_STATUS.SIGNED_AND_INVALID, + [new Error('error1'), new Error('error2')], + 'type', + false, + 'Signature verification for type failed: error1, error2', + ], + [ + VERIFICATION_STATUS.SIGNED_AND_INVALID, + [new Error('error1'), new Error('error2')], + undefined, + true, + 'Verification keys are not available', + ], + [ + VERIFICATION_STATUS.SIGNED_AND_INVALID, + [new Error('error1'), new Error('error2')], + 'type', + true, + 'Verification keys for type are not available', + ], ]; - for (const [status, type, notAvailable, expected] of testCases) { + for (const [status, errors, type, notAvailable, expected] of testCases) { it(`returns correct message for status ${status} with type ${type} and notAvailable ${notAvailable}`, () => { - expect(getVerificationMessage(status, type, notAvailable)).toBe(expected); + expect(getVerificationMessage(status, errors, type, notAvailable)).toBe(expected); }); } }); diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index cde912f6..e07b03ec 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -11,6 +11,7 @@ export function getErrorMessage(error: unknown): string { */ export function getVerificationMessage( verified: VERIFICATION_STATUS, + verificationErrors?: Error[], signatureType?: string, notAvailableVerificationKeys = false, ): string { @@ -24,6 +25,13 @@ export function getVerificationMessage( : c('Error').t`Verification keys are not available`; } + if (verificationErrors) { + const errorMessage = verificationErrors?.map((e) => e.message).join(', '); + return signatureType + ? c('Error').t`Signature verification for ${signatureType} failed: ${errorMessage}` + : c('Error').t`Signature verification failed: ${errorMessage}`; + } + return signatureType ? c('Error').t`Signature verification for ${signatureType} failed` : c('Error').t`Signature verification failed`; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 9511ed8b..08961f53 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -76,7 +76,7 @@ function generateAPINode() { }; } -function generateFileNode(overrides = {}) { +function generateFileNode(overrides = {}, encryptedCryptoOverrides = {}) { const node = generateNode(); return { ...node, @@ -98,12 +98,13 @@ function generateFileNode(overrides = {}) { armoredExtendedAttributes: '{file}', thumbnails: [], }, + ...encryptedCryptoOverrides, }, ...overrides, }; } -function generateFolderNode(overrides = {}) { +function generateFolderNode(overrides = {}, encryptedCryptoOverrides = {}) { const node = generateNode(); return { ...node, @@ -114,6 +115,7 @@ function generateFolderNode(overrides = {}) { armoredHashKey: 'nodeHashKey', armoredExtendedAttributes: '{folder}', }, + ...encryptedCryptoOverrides, }, ...overrides, }; @@ -140,7 +142,8 @@ function generateNode() { shareId: undefined, isShared: false, - directMemberRole: MemberRole.Admin, + directRole: MemberRole.Admin, + membership: undefined, encryptedCrypto: { armoredKey: 'nodeKey', @@ -148,6 +151,7 @@ function generateNode() { armoredNodePassphraseSignature: 'nodePassSig', nameSignatureEmail: 'nameSigEmail', signatureEmail: 'sigEmail', + membership: undefined, }, }; } @@ -211,14 +215,34 @@ describe('nodeAPIService', () => { }, Membership: { Permissions: 22, + InviteTime: 1234567890, + InviterEmail: 'inviterEmail', + MemberSharePassphraseKeyPacket: 'memberSharePassphraseKeyPacket', + InviterSharePassphraseKeyPacketSignature: 'inviterSharePassphraseKeyPacketSignature', + InviteeSharePassphraseSessionKeySignature: 'inviteeSharePassphraseSessionKeySignature', + }, + }, + ), + generateFolderNode( + { + isShared: true, + shareId: 'shareId', + directRole: MemberRole.Admin, + membership: { + role: MemberRole.Admin, + inviteTime: new Date(1234567890000), + }, + }, + { + membership: { + inviterEmail: 'inviterEmail', + base64MemberSharePassphraseKeyPacket: 'memberSharePassphraseKeyPacket', + armoredInviterSharePassphraseKeyPacketSignature: 'inviterSharePassphraseKeyPacketSignature', + armoredInviteeSharePassphraseSessionKeySignature: + 'inviteeSharePassphraseSessionKeySignature', }, }, ), - generateFolderNode({ - isShared: true, - shareId: 'shareId', - directMemberRole: MemberRole.Admin, - }), ); }); @@ -232,14 +256,34 @@ describe('nodeAPIService', () => { }, Membership: { Permissions: 42, + InviteTime: 1234567890, + InviterEmail: 'inviterEmail', + MemberSharePassphraseKeyPacket: 'memberSharePassphraseKeyPacket', + InviterSharePassphraseKeyPacketSignature: 'inviterSharePassphraseKeyPacketSignature', + InviteeSharePassphraseSessionKeySignature: 'inviteeSharePassphraseSessionKeySignature', + }, + }, + ), + generateFolderNode( + { + isShared: true, + shareId: 'shareId', + directRole: MemberRole.Viewer, + membership: { + role: MemberRole.Viewer, + inviteTime: new Date(1234567890000), + }, + }, + { + membership: { + inviterEmail: 'inviterEmail', + base64MemberSharePassphraseKeyPacket: 'memberSharePassphraseKeyPacket', + armoredInviterSharePassphraseKeyPacketSignature: 'inviterSharePassphraseKeyPacketSignature', + armoredInviteeSharePassphraseSessionKeySignature: + 'inviteeSharePassphraseSessionKeySignature', }, }, ), - generateFolderNode({ - isShared: true, - shareId: 'shareId', - directMemberRole: MemberRole.Viewer, - }), 'myVolumeId', ); }); @@ -308,12 +352,12 @@ describe('nodeAPIService', () => { generateFolderNode({ uid: 'volumeId1~nodeId1', parentUid: 'volumeId1~parentNodeId1', - directMemberRole: MemberRole.Admin, + directRole: MemberRole.Admin, }), generateFolderNode({ uid: 'volumeId2~nodeId2', parentUid: 'volumeId2~parentNodeId2', - directMemberRole: MemberRole.Inherited, + directRole: MemberRole.Inherited, }), ]); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 25795b92..c4e9a6c6 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -8,7 +8,7 @@ import { drivePaths, isCodeOk, nodeTypeNumberToNodeType, - permissionsToDirectMemberRole, + permissionsToMemberRole, } from '../apiService'; import { asyncIteratorRace } from '../asyncIteratorRace'; import { batch } from '../batch'; @@ -471,6 +471,8 @@ function linkToEncryptedNode( link: PostLoadLinksMetadataResponse['Links'][0], isAdmin: boolean, ): EncryptedNode { + const membershipRole = permissionsToMemberRole(logger, link.Membership?.Permissions); + const baseNodeMetadata = { // Internal metadata hash: link.Link.NameHash || undefined, @@ -486,16 +488,31 @@ function linkToEncryptedNode( // Sharing node metadata shareId: link.Sharing?.ShareID || undefined, isShared: !!link.Sharing, - directMemberRole: isAdmin - ? MemberRole.Admin - : permissionsToDirectMemberRole(logger, link.Membership?.Permissions), + directRole: isAdmin ? MemberRole.Admin : membershipRole, + membership: link.Membership + ? { + role: membershipRole, + inviteTime: new Date(link.Membership.InviteTime * 1000), + } + : undefined, }; + const baseCryptoNodeMetadata = { signatureEmail: link.Link.SignatureEmail || undefined, nameSignatureEmail: link.Link.NameSignatureEmail || undefined, armoredKey: link.Link.NodeKey, armoredNodePassphrase: link.Link.NodePassphrase, armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, + membership: link.Membership + ? { + inviterEmail: link.Membership.InviterEmail, + base64MemberSharePassphraseKeyPacket: link.Membership.MemberSharePassphraseKeyPacket, + armoredInviterSharePassphraseKeyPacketSignature: + link.Membership.InviterSharePassphraseKeyPacketSignature, + armoredInviteeSharePassphraseSessionKeySignature: + link.Membership.InviteeSharePassphraseSessionKeySignature, + } + : undefined, }; if (link.Link.Type === 1 && link.Folder) { diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 85835ef2..2748d13d 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -12,7 +12,12 @@ function generateNode( return { uid: `${params.volumeId || 'volumeId'}~:${uid}`, parentUid: `${params.volumeId || 'volumeId'}~:${parentUid}`, - directMemberRole: MemberRole.Admin, + directRole: MemberRole.Admin, + membership: { + role: MemberRole.Admin, + inviteTime: new Date(), + sharedBy: resultOk('test@example.com'), + }, type: NodeType.File, mediaType: 'text', isShared: false, diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 9b61066c..dfe1dd05 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -252,8 +252,9 @@ function deserialiseNode(nodeData: string): DecryptedNode { typeof node !== 'object' || !node.uid || typeof node.uid !== 'string' || - !node.directMemberRole || - typeof node.directMemberRole !== 'string' || + !node.directRole || + typeof node.directRole !== 'string' || + (typeof node.membership !== 'object' && node.membership !== undefined) || !node.type || typeof node.type !== 'string' || (typeof node.mediaType !== 'string' && node.mediaType !== undefined) || @@ -271,6 +272,12 @@ function deserialiseNode(nodeData: string): DecryptedNode { creationTime: new Date(node.creationTime), trashTime: node.trashTime ? new Date(node.trashTime) : undefined, activeRevision: node.activeRevision ? deserialiseRevision(node.activeRevision) : undefined, + membership: node.membership + ? { + ...node.membership, + inviteTime: new Date(node.membership.inviteTime), + } + : undefined, folder: node.folder ? { ...node.folder, diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 3bce6372..82eb3515 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,5 +1,5 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; -import { ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from '../../interface'; +import { MemberRole, ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from './interface'; import { NodesCryptoService } from './cryptoService'; @@ -48,13 +48,18 @@ describe('nodesCryptoService', () => { armoredNodeName: 'armoredName', }), ), - // @ts-expect-error No need to implement all methods for mocking + // @ts-expect-error Faking sessionKey as string. decryptAndVerifySessionKey: jest.fn(async () => Promise.resolve({ sessionKey: 'contentKeyPacketSessionKey', verified: VERIFICATION_STATUS.SIGNED_AND_VALID, }), ), + verifyInvitation: jest.fn(async () => + Promise.resolve({ + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }), + ), }; account = { // @ts-expect-error No need to implement all methods for mocking @@ -98,21 +103,37 @@ describe('nodesCryptoService', () => { } describe('folder node', () => { - const encryptedNode = { - uid: 'volumeId~nodeId', - parentUid: 'volumeId~parentId', - encryptedCrypto: { - signatureEmail: 'signatureEmail', - nameSignatureEmail: 'nameSignatureEmail', - armoredKey: 'armoredKey', - armoredNodePassphrase: 'armoredNodePassphrase', - armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', - folder: { - armoredHashKey: 'armoredHashKey', - armoredExtendedAttributes: 'folderArmoredExtendedAttributes', + let encryptedNode: EncryptedNode; + + beforeEach(() => { + encryptedNode = { + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentId', + membership: { + role: MemberRole.Admin, + inviteTime: new Date(1234567890000), }, - }, - } as EncryptedNode; + encryptedCrypto: { + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'nameSignatureEmail', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', + folder: { + armoredHashKey: 'armoredHashKey', + armoredExtendedAttributes: 'folderArmoredExtendedAttributes', + }, + membership: { + inviterEmail: 'inviterEmail', + base64MemberSharePassphraseKeyPacket: 'base64MemberSharePassphraseKeyPacket', + armoredInviterSharePassphraseKeyPacketSignature: + 'armoredInviterSharePassphraseKeyPacketSignature', + armoredInviteeSharePassphraseSessionKeySignature: + 'armoredInviteeSharePassphraseSessionKeySignature', + }, + }, + } as EncryptedNode; + }); function verifyResult( result: { node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }, @@ -127,6 +148,11 @@ describe('nodesCryptoService', () => { folder: { extendedAttributes: '{}', }, + membership: { + role: MemberRole.Admin, + inviteTime: new Date(1234567890000), + sharedBy: { ok: true, value: 'inviterEmail' }, + }, activeRevision: undefined, errors: undefined, ...expectedNode, @@ -147,19 +173,7 @@ describe('nodesCryptoService', () => { describe('should decrypt successfuly', () => { it('same author everywhere', async () => { - const encryptedNode = { - encryptedCrypto: { - signatureEmail: 'signatureEmail', - nameSignatureEmail: 'signatureEmail', - armoredKey: 'armoredKey', - armoredNodePassphrase: 'armoredNodePassphrase', - armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', - folder: { - armoredHashKey: 'armoredHashKey', - armoredExtendedAttributes: 'folderArmoredExtendedAttributes', - }, - }, - } as EncryptedNode; + encryptedNode.encryptedCrypto.nameSignatureEmail = 'signatureEmail'; const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result, { @@ -167,17 +181,19 @@ describe('nodesCryptoService', () => { nameAuthor: { ok: true, value: 'signatureEmail' }, }); - expect(account.getPublicKeys).toHaveBeenCalledTimes(1); + expect(account.getPublicKeys).toHaveBeenCalledTimes(2); // signatureEmail (for both key and name) and inviterEmail expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('inviterEmail'); expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('different authors on key and name', async () => { const result = await cryptoService.decryptNode(encryptedNode, parentKey); verifyResult(result); - expect(account.getPublicKeys).toHaveBeenCalledTimes(2); + expect(account.getPublicKeys).toHaveBeenCalledTimes(3); // signatureEmail, nameSignatureEmail, inviterEmail expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); expect(account.getPublicKeys).toHaveBeenCalledWith('nameSignatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('inviterEmail'); expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); }); @@ -190,6 +206,7 @@ describe('nodesCryptoService', () => { key: 'decryptedKey' as unknown as PrivateKey, passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, verified: VERIFICATION_STATUS.NOT_SIGNED, + verificationErrors: [new Error('verification error')], }), ); @@ -202,6 +219,7 @@ describe('nodesCryptoService', () => { }); verifyLogEventVerificationError({ field: 'nodeKey', + error: 'verification error', }); }); @@ -210,6 +228,7 @@ describe('nodesCryptoService', () => { Promise.resolve({ name: 'name', verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], }), ); @@ -217,11 +236,15 @@ describe('nodesCryptoService', () => { verifyResult(result, { nameAuthor: { ok: false, - error: { claimedAuthor: 'nameSignatureEmail', error: 'Signature verification for name failed' }, + error: { + claimedAuthor: 'nameSignatureEmail', + error: 'Signature verification for name failed: verification error', + }, }, }); verifyLogEventVerificationError({ field: 'nodeName', + error: 'verification error', }); }); @@ -230,6 +253,7 @@ describe('nodesCryptoService', () => { Promise.resolve({ hashKey: new Uint8Array(), verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], }), ); @@ -237,11 +261,15 @@ describe('nodesCryptoService', () => { verifyResult(result, { keyAuthor: { ok: false, - error: { claimedAuthor: 'signatureEmail', error: 'Signature verification for hash key failed' }, + error: { + claimedAuthor: 'signatureEmail', + error: 'Signature verification for hash key failed: verification error', + }, }, }); verifyLogEventVerificationError({ field: 'nodeHashKey', + error: 'verification error', }); }); @@ -252,6 +280,7 @@ describe('nodesCryptoService', () => { key: 'decryptedKey' as unknown as PrivateKey, passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, verified: VERIFICATION_STATUS.NOT_SIGNED, + verificationErrors: [new Error('verification error')], }), ); driveCrypto.decryptNodeHashKey = jest.fn(async () => @@ -270,6 +299,7 @@ describe('nodesCryptoService', () => { }); verifyLogEventVerificationError({ field: 'nodeKey', + error: 'verification error', }); }); @@ -278,6 +308,7 @@ describe('nodesCryptoService', () => { Promise.resolve({ extendedAttributes: '{}', verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], }), ); @@ -287,12 +318,39 @@ describe('nodesCryptoService', () => { ok: false, error: { claimedAuthor: 'signatureEmail', - error: 'Signature verification for attributes failed', + error: 'Signature verification for attributes failed: verification error', }, }, }); verifyLogEventVerificationError({ field: 'nodeExtendedAttributes', + error: 'verification error', + }); + }); + + it('on membership', async () => { + driveCrypto.verifyInvitation = jest.fn().mockResolvedValue({ + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + membership: { + role: MemberRole.Admin, + inviteTime: new Date(1234567890000), + sharedBy: { + ok: false, + error: { + claimedAuthor: 'inviterEmail', + error: 'Signature verification for membership failed: verification error', + }, + }, + }, + }); + verifyLogEventVerificationError({ + field: 'membershipInviter', + error: 'verification error', }); }); }); @@ -382,6 +440,27 @@ describe('nodesCryptoService', () => { error, }); }); + + it('on membership', async () => { + const error = new Error('Decryption error'); + driveCrypto.verifyInvitation = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + membership: { + role: MemberRole.Admin, + inviteTime: new Date(1234567890000), + sharedBy: { + ok: false, + error: { claimedAuthor: 'inviterEmail', error: 'Failed to verify invitation' }, + }, + }, + }); + verifyLogEventVerificationError({ + field: 'membershipInviter', + addressMatchingDefaultShare: undefined, + }); + }); }); it('should fail when keys cannot be loaded', async () => { @@ -515,6 +594,7 @@ describe('nodesCryptoService', () => { key: 'decryptedKey' as unknown as PrivateKey, passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, verified: VERIFICATION_STATUS.NOT_SIGNED, + verificationErrors: [new Error('verification error')], }), ); @@ -527,6 +607,7 @@ describe('nodesCryptoService', () => { }); verifyLogEventVerificationError({ field: 'nodeKey', + error: 'verification error', }); }); @@ -535,6 +616,7 @@ describe('nodesCryptoService', () => { Promise.resolve({ name: 'name', verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], }), ); @@ -542,11 +624,15 @@ describe('nodesCryptoService', () => { verifyResult(result, { nameAuthor: { ok: false, - error: { claimedAuthor: 'nameSignatureEmail', error: 'Signature verification for name failed' }, + error: { + claimedAuthor: 'nameSignatureEmail', + error: 'Signature verification for name failed: verification error', + }, }, }); verifyLogEventVerificationError({ field: 'nodeName', + error: 'verification error', }); }); @@ -555,6 +641,7 @@ describe('nodesCryptoService', () => { Promise.resolve({ extendedAttributes: '{}', verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], }), ); @@ -572,7 +659,7 @@ describe('nodesCryptoService', () => { ok: false, error: { claimedAuthor: 'revisionSignatureEmail', - error: 'Signature verification for attributes failed', + error: 'Signature verification for attributes failed: verification error', }, }, }, @@ -580,6 +667,7 @@ describe('nodesCryptoService', () => { }); verifyLogEventVerificationError({ field: 'nodeExtendedAttributes', + error: 'verification error', }); }); @@ -589,6 +677,7 @@ describe('nodesCryptoService', () => { Promise.resolve({ sessionKey: 'contentKeyPacketSessionKey', verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], }) as any, ); @@ -598,12 +687,13 @@ describe('nodesCryptoService', () => { ok: false, error: { claimedAuthor: 'signatureEmail', - error: 'Signature verification for content key failed', + error: 'Signature verification for content key failed: verification error', }, }, }); verifyLogEventVerificationError({ field: 'nodeContentKey', + error: 'verification error', }); }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 88dd61d6..df291a5f 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -12,6 +12,7 @@ import { Logger, MetricsDecryptionErrorField, MetricVerificationErrorField, + Membership, } from '../../interface'; import { ValidationError } from '../../errors'; import { getErrorMessage, getVerificationMessage } from '../errors'; @@ -90,6 +91,11 @@ export class NodesCryptoService { const { name, author: nameAuthor } = await this.decryptName(node, parentKey, nameVerificationKeys); + let membership; + if (node.membership) { + membership = await this.decryptMembership(node); + } + let passphrase, key, passphraseSessionKey, keyAuthor; try { const keyResult = await this.decryptKey(node, parentKey, keyVerificationKeys); @@ -110,6 +116,7 @@ export class NodesCryptoService { error: errorMessage, }), nameAuthor, + membership, activeRevision: 'file' in node.encryptedCrypto ? resultError(new Error(errorMessage)) : undefined, folder: undefined, errors: [error], @@ -187,6 +194,7 @@ export class NodesCryptoService { 'nodeContentKey', c('Property').t`content key`, keySessionKeyResult.verified, + keySessionKeyResult.verificationErrors, node.encryptedCrypto.signatureEmail, )); } catch (error: unknown) { @@ -228,6 +236,7 @@ export class NodesCryptoService { name, keyAuthor: finalKeyAuthor, nameAuthor, + membership, activeRevision, folder, errors: errors.length ? errors : undefined, @@ -268,6 +277,7 @@ export class NodesCryptoService { 'nodeKey', c('Property').t`key`, key.verified, + key.verificationErrors, node.encryptedCrypto.signatureEmail, verificationKeys.length === 0, ), @@ -285,7 +295,7 @@ export class NodesCryptoService { const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail; try { - const { name, verified } = await this.driveCrypto.decryptNodeName( + const { name, verified, verificationErrors } = await this.driveCrypto.decryptNodeName( node.encryptedName, parentKey, verificationKeys, @@ -298,6 +308,7 @@ export class NodesCryptoService { 'nodeName', c('Property').t`name`, verified, + verificationErrors, nameSignatureEmail, verificationKeys.length === 0, ), @@ -319,6 +330,60 @@ export class NodesCryptoService { return this.driveCrypto.decryptSessionKey(node.encryptedName, parentKey); } + private async decryptMembership(node: EncryptedNode): Promise { + if (!node.membership) { + return undefined; + } + + let sharedBy: Author; + if (node.encryptedCrypto.membership) { + let inviterEmailKeys: PublicKey[] | undefined; + try { + inviterEmailKeys = await this.account.getPublicKeys(node.encryptedCrypto.membership.inviterEmail); + } catch (error: unknown) { + this.logger.error('Failed to get inviter email keys', error); + sharedBy = resultError({ + claimedAuthor: node.encryptedCrypto.membership.inviterEmail, + error: c('Error').t`Failed to get inviter keys`, + }); + } + + try { + const { verified, verificationErrors } = await this.driveCrypto.verifyInvitation( + node.encryptedCrypto.membership.base64MemberSharePassphraseKeyPacket, + node.encryptedCrypto.membership.armoredInviterSharePassphraseKeyPacketSignature, + inviterEmailKeys || [], + ); + + sharedBy = await this.handleClaimedAuthor( + node, + 'membershipInviter', + c('Property').t`membership`, + verified, + verificationErrors, + node.encryptedCrypto.membership.inviterEmail, + ); + } catch (error: unknown) { + void this.reportVerificationError(node, 'membershipInviter'); + this.logger.error('Failed to verify invitation', error); + sharedBy = resultError({ + claimedAuthor: node.encryptedCrypto.membership.inviterEmail, + error: c('Error').t`Failed to verify invitation`, + }); + } + } else { + sharedBy = resultError({ + error: c('Error').t`Missing inviter email`, + }); + } + + return { + role: node.membership.role, + inviteTime: node.membership.inviteTime, + sharedBy, + }; + } + private async decryptHashKey( node: EncryptedNode, nodeKey: PrivateKey, @@ -332,7 +397,7 @@ export class NodesCryptoService { throw new Error('Node is not a folder'); } - const { hashKey, verified } = await this.driveCrypto.decryptNodeHashKey( + const { hashKey, verified, verificationErrors } = await this.driveCrypto.decryptNodeHashKey( node.encryptedCrypto.folder.armoredHashKey, nodeKey, addressKeys, @@ -345,6 +410,7 @@ export class NodesCryptoService { 'nodeHashKey', c('Property').t`hash key`, verified, + verificationErrors, node.encryptedCrypto.signatureEmail, ), }; @@ -394,7 +460,7 @@ export class NodesCryptoService { }; } - const { extendedAttributes, verified } = await this.driveCrypto.decryptExtendedAttributes( + const { extendedAttributes, verified, verificationErrors } = await this.driveCrypto.decryptExtendedAttributes( encryptedExtendedAttributes, nodeKey, addressKeys, @@ -407,6 +473,7 @@ export class NodesCryptoService { 'nodeExtendedAttributes', c('Property').t`attributes`, verified, + verificationErrors, signatureEmail, ), }; @@ -418,7 +485,13 @@ export class NodesCryptoService { name: string, extendedAttributes?: string, ): Promise<{ - encryptedCrypto: Required & { encryptedName: string; hash: string }; + encryptedCrypto: EncryptedNodeFolderCrypto & { + // signatureEmail and nameSignatureEmail are not optional. + signatureEmail: string; + nameSignatureEmail: string; + encryptedName: string; + hash: string; + }; keys: DecryptedNodeKeys; }> { const { email, addressKey } = address; @@ -536,12 +609,19 @@ export class NodesCryptoService { field: MetricVerificationErrorField, signatureType: string, verified: VERIFICATION_STATUS, + verificationErrors?: Error[], claimedAuthor?: string, notAvailableVerificationKeys = false, ): Promise { - const author = handleClaimedAuthor(signatureType, verified, claimedAuthor, notAvailableVerificationKeys); + const author = handleClaimedAuthor( + signatureType, + verified, + verificationErrors, + claimedAuthor, + notAvailableVerificationKeys, + ); if (!author.ok) { - void this.reportVerificationError(node, field, claimedAuthor); + void this.reportVerificationError(node, field, verificationErrors, claimedAuthor); } return author; } @@ -549,6 +629,7 @@ export class NodesCryptoService { private async reportVerificationError( node: { uid: string; creationTime: Date }, field: MetricVerificationErrorField, + verificationErrors?: Error[], claimedAuthor?: string, ) { if (this.reportedVerificationErrors.has(node.uid)) { @@ -577,6 +658,7 @@ export class NodesCryptoService { field, addressMatchingDefaultShare, fromBefore2024, + error: verificationErrors?.map((e) => e.message).join(', '), uid: node.uid, }); this.reportedVerificationErrors.add(node.uid); @@ -617,6 +699,7 @@ export class NodesCryptoService { function handleClaimedAuthor( signatureType: string, verified: VERIFICATION_STATUS, + verificationErrors?: Error[], claimedAuthor?: string, notAvailableVerificationKeys = false, ): Author { @@ -630,6 +713,6 @@ function handleClaimedAuthor( return resultError({ claimedAuthor: claimedAuthor, - error: getVerificationMessage(verified, signatureType, notAvailableVerificationKeys), + error: getVerificationMessage(verified, verificationErrors, signatureType, notAvailableVerificationKeys), }); } diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index a40d7993..03006811 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -20,7 +20,7 @@ function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial< return { uid, parentUid, - directMemberRole: MemberRole.Admin, + directRole: MemberRole.Admin, type: NodeType.File, mediaType: 'text', isShared: false, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 14b3a991..a1ccc2cb 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -32,7 +32,12 @@ interface BaseNode { // Share node metadata shareId?: string; isShared: boolean; - directMemberRole: MemberRole; + directRole: MemberRole; + membership?: { + role: MemberRole; + inviteTime: Date; + // TODO: acceptedBy: Author; + }; } /** @@ -50,6 +55,12 @@ export interface EncryptedNodeCrypto { armoredKey: string; armoredNodePassphrase: string; armoredNodePassphraseSignature: string; + membership?: { + inviterEmail: string; + base64MemberSharePassphraseKeyPacket: string; + armoredInviterSharePassphraseKeyPacketSignature: string; + armoredInviteeSharePassphraseSessionKeySignature: string; + }; } export interface EncryptedNodeFileCrypto extends EncryptedNodeCrypto { @@ -78,9 +89,14 @@ export interface EncryptedNodeAlbumCrypto extends EncryptedNodeCrypto {} * This interface is holding decrypted node metadata that is not yet parsed, * such as extended attributes. */ -export interface DecryptedUnparsedNode extends BaseNode { +export interface DecryptedUnparsedNode extends Omit { keyAuthor: Author; nameAuthor: Author; + membership?: { + role: MemberRole; + inviteTime: Date; + sharedBy: Author; + }; name: Result; activeRevision?: Result; folder?: { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index f2f09689..d787503a 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -270,6 +270,16 @@ export class NodesAccess { claimedAuthor: encryptedNode.encryptedCrypto.nameSignatureEmail, error: getErrorMessage(error), }), + membership: encryptedNode.membership + ? { + role: encryptedNode.membership.role, + inviteTime: encryptedNode.membership.inviteTime, + sharedBy: resultError({ + claimedAuthor: encryptedNode.encryptedCrypto.membership?.inviterEmail, + error: getErrorMessage(error), + }), + } + : undefined, errors: [error], treeEventScopeId: splitNodeUid(encryptedNode.uid).volumeId, }, diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index e3551413..ef393932 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -245,7 +245,7 @@ export class NodesManagement { // Share node metadata isShared: false, - directMemberRole: MemberRole.Inherited, + directRole: MemberRole.Inherited, // Decrypted metadata isStale: false, diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index a1ca6acb..dfda89bd 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -76,7 +76,7 @@ export class SharesCryptoService { const { keys: addressKeys } = await this.account.getOwnAddress(share.addressId); const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); - let key, passphraseSessionKey, verified; + let key, passphraseSessionKey, verified, verificationErrors; try { const result = await this.driveCrypto.decryptKey( share.encryptedCrypto.armoredKey, @@ -88,6 +88,7 @@ export class SharesCryptoService { key = result.key; passphraseSessionKey = result.passphraseSessionKey; verified = result.verified; + verificationErrors = result.verificationErrors; } catch (error: unknown) { this.reportDecryptionError(share, error); throw error; @@ -98,7 +99,7 @@ export class SharesCryptoService { ? resultOk(share.creatorEmail) : resultError({ claimedAuthor: share.creatorEmail, - error: getVerificationMessage(verified), + error: getVerificationMessage(verified, verificationErrors), }); if (!author.ok) { diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index c2334a82..6d41043f 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -4,7 +4,7 @@ import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType, - permissionsToDirectMemberRole, + permissionsToMemberRole, memberRoleToPermission, } from '../apiService'; import { @@ -224,7 +224,7 @@ export class SharingAPIService { base64KeyPacket: response.Invitation.KeyPacket, base64KeyPacketSignature: response.Invitation.KeyPacketSignature, invitationTime: new Date(response.Invitation.CreateTime * 1000), - role: permissionsToDirectMemberRole(this.logger, response.Invitation.Permissions), + role: permissionsToMemberRole(this.logger, response.Invitation.Permissions), share: { armoredKey: response.Share.ShareKey, armoredPassphrase: response.Share.Passphrase, @@ -312,7 +312,7 @@ export class SharingAPIService { base64KeyPacket: member.KeyPacket, base64KeyPacketSignature: member.KeyPacketSignature, invitationTime: new Date(member.CreateTime * 1000), - role: permissionsToDirectMemberRole(this.logger, member.Permissions), + role: permissionsToMemberRole(this.logger, member.Permissions), }; }); } @@ -469,7 +469,7 @@ export class SharingAPIService { uid: makePublicLinkUid(shareUrl.ShareID, shareUrl.ShareURLID), creationTime: new Date(shareUrl.CreateTime * 1000), expirationTime: shareUrl.ExpirationTime ? new Date(shareUrl.ExpirationTime * 1000) : undefined, - role: permissionsToDirectMemberRole(this.logger, shareUrl.Permissions), + role: permissionsToMemberRole(this.logger, shareUrl.Permissions), flags: shareUrl.Flags, creatorEmail: shareUrl.CreatorEmail, publicUrl: shareUrl.PublicUrl, @@ -588,7 +588,7 @@ export class SharingAPIService { addedByEmail: invitation.InviterEmail, inviteeEmail: invitation.InviteeEmail, invitationTime: new Date(invitation.CreateTime * 1000), - role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), + role: permissionsToMemberRole(this.logger, invitation.Permissions), base64KeyPacket: invitation.KeyPacket, base64KeyPacketSignature: invitation.KeyPacketSignature, }; @@ -605,7 +605,7 @@ export class SharingAPIService { addedByEmail: invitation.InviterEmail, inviteeEmail: invitation.InviteeEmail, invitationTime: new Date(invitation.CreateTime * 1000), - role: permissionsToDirectMemberRole(this.logger, invitation.Permissions), + role: permissionsToMemberRole(this.logger, invitation.Permissions), base64Signature: invitation.ExternalInvitationSignature, state, }; diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index d87b23dc..e28bf177 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -148,7 +148,7 @@ export class SharingCryptoService { } const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); - const { key, passphraseSessionKey, verified } = await this.driveCrypto.decryptKey( + const { key, passphraseSessionKey, verified, verificationErrors } = await this.driveCrypto.decryptKey( share.encryptedCrypto.armoredKey, share.encryptedCrypto.armoredPassphrase, share.encryptedCrypto.armoredPassphraseSignature, @@ -161,7 +161,7 @@ export class SharingCryptoService { ? resultOk(share.creatorEmail) : resultError({ claimedAuthor: share.creatorEmail, - error: getVerificationMessage(verified), + error: getVerificationMessage(verified, verificationErrors), }); return { diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index eb39b4a5..54dad026 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -18,7 +18,8 @@ type InternalPartialNode = Pick< | 'name' | 'keyAuthor' | 'nameAuthor' - | 'directMemberRole' + | 'directRole' + | 'membership' | 'type' | 'mediaType' | 'isShared' @@ -84,7 +85,8 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode parentUid: node.parentUid, keyAuthor: node.keyAuthor, nameAuthor: node.nameAuthor, - directMemberRole: node.directMemberRole, + directRole: node.directRole, + membership: node.membership, type: node.type, mediaType: node.mediaType, isShared: node.isShared, From 0f448e3695934470f7b5663034ae5b85d8e4bc4c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 Aug 2025 08:37:13 +0200 Subject: [PATCH 190/791] js/v0.2.0 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 3b1622b8..5e31f68b 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.1.2", + "version": "0.2.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 8c01158843207596c6d648027511a3a643412979 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 19 Aug 2025 14:42:11 +0200 Subject: [PATCH 191/791] Invalid value code is ValidationError --- js/sdk/src/internal/apiService/errorCodes.ts | 1 + js/sdk/src/internal/apiService/errors.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index 05b9d741..fb9d29cc 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -17,6 +17,7 @@ export const enum ErrorCode { OK = 1000, OK_MANY = 1001, OK_ASYNC = 1002, + INVALID_VALUE = 2001, NOT_ENOUGH_PERMISSIONS = 2011, NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS = 2026, // Following codes takes name from the API documentation. diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 9d4094b7..be0d1112 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -46,6 +46,7 @@ export function apiErrorFactory({ response, result }: { response: Response; resu // Here we convert only general enough codes. Specific cases that are // not clear from the code itself must be handled by each module // separately. + case ErrorCode.INVALID_VALUE: case ErrorCode.NOT_ENOUGH_PERMISSIONS: case ErrorCode.NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS: case ErrorCode.ALREADY_EXISTS: From 98301b9b4b21428c1d64cf24e6cfb06a9b1addf4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 15 Aug 2025 07:51:01 +0200 Subject: [PATCH 192/791] Fix parsing claimedModificationTime in NodesCache --- js/sdk/src/internal/nodes/cache.test.ts | 28 +++++++++++++++++++++++++ js/sdk/src/internal/nodes/cache.ts | 4 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 2748d13d..4993a243 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -118,6 +118,34 @@ describe('nodesCache', () => { storageSize: 100, contentAuthor: resultOk('test@test.com'), claimedModificationTime: new Date('2021-02-01'), + claimedSize: 100, + claimedDigests: { + sha1: 'hash', + }, + claimedBlockSizes: [100], + claimedAdditionalMetadata: { + media: { width: 100, height: 100 }, + }, + }); + const node = generateNode('node1', '', { activeRevision }); + + await cache.setNode(node); + const result = await cache.getNode(node.uid); + + expect(result).toStrictEqual({ + ...node, + activeRevision, + }); + }); + + it('should store and retrieve node with active revision with no claimed data', async () => { + const activeRevision: Result = resultOk({ + uid: 'revision1', + state: RevisionState.Active, + creationTime: new Date('2021-01-01'), + storageSize: 100, + contentAuthor: resultOk('test@test.com'), + claimedModificationTime: undefined, }); const node = generateNode('node1', '', { activeRevision }); diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index dfe1dd05..3aa92c5d 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -302,7 +302,9 @@ function deserialiseRevision(revision: any): Result { return resultOk({ ...revision.value, creationTime: new Date(revision.value.creationTime), - claimedModificationTime: new Date(revision.value.claimedModificationTime), + claimedModificationTime: revision.value.claimedModificationTime + ? new Date(revision.value.claimedModificationTime) + : undefined, }); } From d8248c59b38a2bbf64f3a1c402a65295113fafe7 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 20 Aug 2025 09:36:27 +0200 Subject: [PATCH 193/791] Separate custom password from bookmark url --- js/sdk/src/interface/sharing.ts | 4 +++- js/sdk/src/internal/sharing/cryptoService.ts | 24 +++++++++++++++---- .../internal/sharing/sharingAccess.test.ts | 6 +++++ js/sdk/src/internal/sharing/sharingAccess.ts | 6 +++-- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index 2bcbb266..b74e3fa2 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -51,6 +51,7 @@ export type Bookmark = { uid: string; creationTime: Date; url: string; + customPassword?: string; node: { name: string; type: NodeType; @@ -65,8 +66,9 @@ export type Bookmark = { * SDK to represent the bookmark in a way that is easy to work with. Whenever * any field cannot be decrypted, it is returned as `DegradedBookmark` type. */ -export type DegradedBookmark = Omit & { +export type DegradedBookmark = Omit & { url: Result; + customPassword: Result; node: Omit & { name: Result; }; diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index e28bf177..130c819e 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -411,10 +411,7 @@ export class SharingCryptoService { }; case PublicLinkFlags.GeneratedPasswordIncluded: case PublicLinkFlags.GeneratedPasswordWithCustomPassword: - return { - password: password.substring(0, PUBLIC_LINK_GENERATED_PASSWORD_LENGTH), - customPassword: password.substring(PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) || undefined, - }; + return splitGeneratedAndCustomPassword(password); default: throw new Error(`Unsupported public link with flags: ${encryptedPublicLink.flags}`); } @@ -422,18 +419,24 @@ export class SharingCryptoService { async decryptBookmark(encryptedBookmark: EncryptedBookmark): Promise<{ url: Result; + customPassword: Result; nodeName: Result; }> { // TODO: Signatures are not checked and not specified in the interface. // In the future, we will need to add authorship verification. let urlPassword: string; + let customPassword: Result; try { - urlPassword = await this.decryptBookmarkUrlPassword(encryptedBookmark); + const password = await this.decryptBookmarkUrlPassword(encryptedBookmark); + const result = splitGeneratedAndCustomPassword(password); + urlPassword = result.password; + customPassword = resultOk(result.customPassword); } catch (originalError: unknown) { const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`); return { url: resultError(error), + customPassword: resultError(error), nodeName: resultError(error), }; } @@ -448,6 +451,7 @@ export class SharingCryptoService { const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`); return { url, + customPassword, nodeName: resultError(error), }; } @@ -456,6 +460,7 @@ export class SharingCryptoService { return { url, + customPassword, nodeName, }; } @@ -551,3 +556,12 @@ export class SharingCryptoService { } } } + +function splitGeneratedAndCustomPassword(concatenatedPassword: string): { + password: string; + customPassword?: string; +} { + const password = concatenatedPassword.substring(0, PUBLIC_LINK_GENERATED_PASSWORD_LENGTH); + const customPassword = concatenatedPassword.substring(PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) || undefined; + return { password, customPassword }; +} diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index c011d1b1..20137079 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -116,6 +116,7 @@ describe('SharingAccess', () => { it('should return decrypted bookmark', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ url: resultOk('url'), + customPassword: resultOk('customPassword'), nodeName: resultOk('nodeName'), }); @@ -126,6 +127,7 @@ describe('SharingAccess', () => { uid: 'tokenId', creationTime: new Date('2025-01-01'), url: 'url', + customPassword: 'customPassword', node: { name: 'nodeName', type: NodeType.File, @@ -138,6 +140,7 @@ describe('SharingAccess', () => { it('should return degraded bookmark if URL password cannot be decrypted', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ url: resultError('url cannot be decrypted'), + customPassword: resultOk('url cannot be decrypted'), nodeName: resultError('url cannot be decrypted'), }); @@ -148,6 +151,7 @@ describe('SharingAccess', () => { uid: 'tokenId', creationTime: new Date('2025-01-01'), url: resultError('url cannot be decrypted'), + customPassword: resultOk('url cannot be decrypted'), node: { name: resultError('url cannot be decrypted'), type: NodeType.File, @@ -160,6 +164,7 @@ describe('SharingAccess', () => { it('should return degraded bookmark if node name cannot be decrypted', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ url: resultOk('url'), + customPassword: resultOk(undefined), nodeName: resultError('node name cannot be decrypted'), }); @@ -170,6 +175,7 @@ describe('SharingAccess', () => { uid: 'tokenId', creationTime: new Date('2025-01-01'), url: resultOk('url'), + customPassword: resultOk(undefined), node: { name: resultError('node name cannot be decrypted'), type: NodeType.File, diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 61a4a4e3..41adf92c 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -140,13 +140,14 @@ export class SharingAccess { async *iterateBookmarks(signal?: AbortSignal): AsyncGenerator { for await (const bookmark of this.apiService.iterateBookmarks(signal)) { - const { url, nodeName } = await this.cryptoService.decryptBookmark(bookmark); + const { url, customPassword, nodeName } = await this.cryptoService.decryptBookmark(bookmark); - if (!url.ok || !nodeName.ok) { + if (!url.ok || !customPassword.ok || !nodeName.ok) { yield resultError({ uid: bookmark.tokenId, creationTime: bookmark.creationTime, url: url, + customPassword, node: { name: nodeName, type: bookmark.node.type, @@ -158,6 +159,7 @@ export class SharingAccess { uid: bookmark.tokenId, creationTime: bookmark.creationTime, url: url.value, + customPassword: customPassword.value, node: { name: nodeName.value, type: bookmark.node.type, From 9bf587b6b6b6813b10834ff4af8423817dc37665 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 20 Aug 2025 12:12:09 +0200 Subject: [PATCH 194/791] js/v0.2.1 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 5e31f68b..2b45a959 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.2.0", + "version": "0.2.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From aa9ff6cd71687ee7a5b730355bfb8350d5c1cfdd Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 20 Aug 2025 08:17:13 +0200 Subject: [PATCH 195/791] Add node details to diagnostic results --- js/sdk/src/diagnostic/interface.ts | 54 ++++++++-------- js/sdk/src/diagnostic/sdkDiagnostic.ts | 88 +++++++++++++++++--------- 2 files changed, 84 insertions(+), 58 deletions(-) diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index ca3878fd..3e649383 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -1,4 +1,4 @@ -import { DegradedNode, MaybeNode, MetricEvent } from '../interface'; +import { Author, MaybeNode, MetricEvent, NodeType } from '../interface'; import { LogRecord } from '../telemetry'; export interface Diagnostic { @@ -67,77 +67,59 @@ export type HttpErrorResult = { // Event representing that node has some decryption or other (e.g., invalid name) issues. export type DegradedNodeResult = { type: 'degraded_node'; - nodeUid: string; - node: DegradedNode; -}; +} & NodeDetails; // Event representing that signature verification failing. export type UnverifiedAuthorResult = { type: 'unverified_author'; - nodeUid: string; - revisionUid?: string; authorType: string; claimedAuthor?: string; error: string; - node: MaybeNode; -}; +} & NodeDetails; // Event representing that field from the extended attributes is not valid format. // Currently only `sha1` verification is supported. export type ExtendedAttributesErrorResult = { type: 'extended_attributes_error'; - nodeUid: string; - revisionUid?: string; field: 'sha1'; value: string; -}; +} & NodeDetails; // Event representing that field from the extended attributes is missing. // Currently only `sha1` verification is supported. export type ExtendedAttributesMissingFieldResult = { type: 'extended_attributes_missing_field'; - nodeUid: string; - revisionUid?: string; missingField: 'sha1'; -}; +} & NodeDetails; // Event representing that file is missing the active revision. export type ContentFileMissingRevisionResult = { type: 'content_file_missing_revision'; - nodeUid: string; - revisionUid?: string; -}; +} & NodeDetails; // Event representing that file content is not valid - either sha1 or size is not correct. export type ContentIntegrityErrorResult = { type: 'content_integrity_error'; - nodeUid: string; - revisionUid?: string; claimedSha1?: string; computedSha1?: string; claimedSizeInBytes?: number; computedSizeInBytes?: number; -}; +} & NodeDetails; // Event representing that downloading the file content failed. // This can be connection issue or server error. If its integrity issue, // it should be reported as `ContentIntegrityErrorResult`. export type ContentDownloadErrorResult = { type: 'content_download_error'; - nodeUid: string; - revisionUid?: string; error: unknown; -}; +} & NodeDetails; // Event representing that getting the thumbnails failed. // This can be connection issue or server error. export type ThumbnailsErrorResult = { type: 'thumbnails_error'; - nodeUid: string; - revisionUid?: string; - message?: string; - error?: unknown; -}; + error: unknown; +} & NodeDetails; // Event representing errors logged during the diagnostic. export type LogErrorResult = { @@ -156,3 +138,19 @@ export type MetricResult = { type: 'metric'; event: MetricEvent; }; + +export type NodeDetails = { + safeNodeDetails: { + nodeUid: string; + revisionUid?: string; + nodeType: NodeType; + nodeCreationTime: Date; + keyAuthor: Author; + nameAuthor: Author; + errors: { + field: string; + error: unknown; + }[]; + }; + sensitiveNodeDetails: MaybeNode; +}; diff --git a/js/sdk/src/diagnostic/sdkDiagnostic.ts b/js/sdk/src/diagnostic/sdkDiagnostic.ts index b42e9cc0..433048bb 100644 --- a/js/sdk/src/diagnostic/sdkDiagnostic.ts +++ b/js/sdk/src/diagnostic/sdkDiagnostic.ts @@ -1,6 +1,6 @@ import { Author, FileDownloader, MaybeNode, NodeType, Revision, ThumbnailType } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; -import { Diagnostic, DiagnosticOptions, DiagnosticResult } from './interface'; +import { Diagnostic, DiagnosticOptions, DiagnosticResult, NodeDetails } from './interface'; import { IntegrityVerificationStream } from './integrityVerificationStream'; /** @@ -43,32 +43,19 @@ export class SDKDiagnostic implements Diagnostic { } private async *verifyNode(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { - const nodeUid = node.ok ? node.value.uid : node.error.uid; - if (!node.ok) { yield { type: 'degraded_node', - nodeUid, - node: node.error, + ...getNodeDetails(node), }; } + yield* this.verifyAuthor(node.ok ? node.value.keyAuthor : node.error.keyAuthor, 'key', node); + yield* this.verifyAuthor(node.ok ? node.value.nameAuthor : node.error.nameAuthor, 'name', node); + const activeRevision = getActiveRevision(node); - const nodeInfo = { - ...getNodeUids(node), - node, - }; - - yield* this.verifyAuthor(node.ok ? node.value.keyAuthor : node.error.keyAuthor, { - ...nodeInfo, - authorType: 'key', - }); - yield* this.verifyAuthor(node.ok ? node.value.nameAuthor : node.error.nameAuthor, { - ...nodeInfo, - authorType: 'name', - }); if (activeRevision) { - yield* this.verifyAuthor(activeRevision.contentAuthor, { ...nodeInfo, authorType: 'content' }); + yield* this.verifyAuthor(activeRevision.contentAuthor, 'content', node); } yield* this.verifyFileExtendedAttributes(node); @@ -81,16 +68,14 @@ export class SDKDiagnostic implements Diagnostic { } } - private async *verifyAuthor( - author: Author, - info: { nodeUid: string; authorType: string; revisionUid?: string; node: MaybeNode }, - ): AsyncGenerator { + private async *verifyAuthor(author: Author, authorType: string, node: MaybeNode): AsyncGenerator { if (!author.ok) { yield { type: 'unverified_author', + authorType, claimedAuthor: author.error.claimedAuthor, error: author.error.error, - ...info, + ...getNodeDetails(node), }; } } @@ -104,17 +89,17 @@ export class SDKDiagnostic implements Diagnostic { if (claimedSha1 && !/^[0-9a-f]{40}$/i.test(claimedSha1)) { yield { type: 'extended_attributes_error', - ...getNodeUids(node), field: 'sha1', value: claimedSha1, + ...getNodeDetails(node), }; } if (expectedAttributes && !claimedSha1) { yield { type: 'extended_attributes_missing_field', - ...getNodeUids(node), missingField: 'sha1', + ...getNodeDetails(node), }; } } @@ -127,7 +112,7 @@ export class SDKDiagnostic implements Diagnostic { if (!activeRevision) { yield { type: 'content_file_missing_revision', - nodeUid: node.ok ? node.value.uid : node.error.uid, + ...getNodeDetails(node), }; return; } @@ -158,18 +143,18 @@ export class SDKDiagnostic implements Diagnostic { if (claimedSha1 !== computedSha1 || claimedSizeInBytes !== computedSizeInBytes) { yield { type: 'content_integrity_error', - ...getNodeUids(node), claimedSha1, computedSha1, claimedSizeInBytes, computedSizeInBytes, + ...getNodeDetails(node), }; } } catch (error: unknown) { yield { type: 'content_download_error', - ...getNodeUids(node), error, + ...getNodeDetails(node), }; } } @@ -197,8 +182,8 @@ export class SDKDiagnostic implements Diagnostic { if (!result[0].ok && result[0].error !== 'Node has no thumbnail') { yield { type: 'thumbnails_error', - nodeUid, error: result[0].error, + ...getNodeDetails(node), }; } } catch (error: unknown) { @@ -226,6 +211,49 @@ export class SDKDiagnostic implements Diagnostic { } } +function getNodeDetails(node: MaybeNode): NodeDetails { + const errors: { + field: string; + error: unknown; + }[] = []; + + if (!node.ok) { + const degradedNode = node.error; + if (!degradedNode.name.ok) { + errors.push({ + field: 'name', + error: degradedNode.name.error, + }); + } + if (degradedNode.activeRevision?.ok === false) { + errors.push({ + field: 'activeRevision', + error: degradedNode.activeRevision.error, + }); + } + for (const error of degradedNode.errors ?? []) { + if (error instanceof Error) { + errors.push({ + field: 'error', + error, + }); + } + } + } + + return { + safeNodeDetails: { + ...getNodeUids(node), + nodeType: getNodeType(node), + nodeCreationTime: node.ok ? node.value.creationTime : node.error.creationTime, + keyAuthor: node.ok ? node.value.keyAuthor : node.error.keyAuthor, + nameAuthor: node.ok ? node.value.nameAuthor : node.error.nameAuthor, + errors, + }, + sensitiveNodeDetails: node, + }; +} + function getNodeUids(node: MaybeNode): { nodeUid: string; revisionUid?: string } { const activeRevision = getActiveRevision(node); return { From 4494018968a8fd91bba628a3ab6d6396bf385274 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 Aug 2025 09:43:45 +0200 Subject: [PATCH 196/791] Fix multiple missing and wrong C exports in C# --- cs/headers/proton_sdk.h | 44 ++++-- .../InteropFileDownloader.cs | 2 +- .../InteropFileUploader.cs | 4 +- .../InteropProgressCallbackExtensions.cs | 4 +- .../InteropReadCallback.cs | 2 +- .../InteropStream.cs | 147 +++++++++++++++--- .../InteropWriteCallback.cs | 2 +- .../Proton.Drive.Sdk.CExports.csproj | 5 + .../Nodes/Upload/FileUploader.cs | 4 + .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 2 +- .../InteropAsyncValueCallback.cs | 11 +- .../InteropAsyncVoidCallback.cs | 11 +- .../InteropProtonApiSession.cs | 17 +- .../InteropVoidCallback.cs | 2 +- .../Logging/InteropLogCallback.cs | 10 -- .../Logging/InteropLogger.cs | 9 +- .../Logging/InteropLoggerProvider.cs | 11 +- cs/sdk/src/protos/account.proto | 4 - 18 files changed, 204 insertions(+), 87 deletions(-) delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h index a9cbbc48..2f43db6d 100644 --- a/cs/headers/proton_sdk.h +++ b/cs/headers/proton_sdk.h @@ -9,33 +9,48 @@ typedef struct { size_t length; } ByteArray; -typedef void ArrayFunction(const void* state, ByteArray array); +typedef void array_callback_function(const void* state, ByteArray array); typedef struct { - ArrayFunction* success_function; - ArrayFunction* failure_function; + array_callback_function* success_function; + array_callback_function* failure_function; intptr_t cancellation_token_source_handle; } AsyncArrayCallback; typedef struct { void (*success_function)(const void* state, int returnValue); - ArrayFunction* failure_function; + array_callback_function* failure_function; intptr_t cancellation_token_source_handle; } AsyncIntCallback; +typedef struct { + void (*success_function)(const void* state, intptr_t returnValue); + array_callback_function* failure_function; + intptr_t cancellation_token_source_handle; +} AsyncIntPtrCallback; + typedef struct { void (*success_function)(const void* state); - ArrayFunction* failure_function; + array_callback_function* failure_function; intptr_t cancellation_token_source_handle; } AsyncVoidCallback; typedef struct { - ArrayFunction* function; + array_callback_function* function; } ArrayCallback; // These callbacks receive yet another callback to allow asynchronous read/writes -typedef void ReadCallback(const void* state, ByteArray buffer, const void* caller_state, AsyncIntCallback callback); -typedef void WriteCallback(const void* state, ByteArray buffer, const void* caller_state, AsyncVoidCallback callback); +typedef void ReadCallback( + const void* state, + ByteArray buffer, + const void* completion_callback_state, + AsyncIntCallback completion_callback); + +typedef void WriteCallback( + const void* state, + ByteArray buffer, + const void* completion_callback_state, + AsyncVoidCallback completion_callback); intptr_t cancellation_token_source_create(); @@ -50,7 +65,7 @@ void cancellation_token_source_free( int session_begin( ByteArray request, const void* caller_state, - AsyncArrayCallback result_callback + AsyncIntPtrCallback result_callback ); int session_resume( @@ -83,6 +98,7 @@ void session_tokens_refreshed_unsubscribe( void session_free(intptr_t session_handle); int logger_provider_create( + const void* caller_state, ArrayCallback log_callback, intptr_t* logger_provider_handle ); @@ -99,7 +115,7 @@ int get_file_uploader( intptr_t client_handle, ByteArray request, // FileUploaderProvisionRequest const void* caller_state, - AsyncArrayCallback result_callback + AsyncIntPtrCallback result_callback ); intptr_t upload_from_stream( @@ -107,7 +123,7 @@ intptr_t upload_from_stream( ByteArray request, // FileUploadRequest const void* caller_state, ReadCallback* read_callback, - AsyncArrayCallback progress_callback, + ArrayCallback progress_callback, intptr_t cancellation_token_source_handle ); @@ -116,7 +132,7 @@ void file_uploader_free(intptr_t file_uploader_handle); int upload_controller_set_completion_callback( intptr_t upload_controller_handle, const void* caller_state, - AsyncArrayCallback result_callback); + AsyncVoidCallback result_callback); void upload_controller_pause(intptr_t file_uploader_handle); @@ -128,7 +144,7 @@ int get_file_downloader( intptr_t client_handle, ByteArray request, // FileDownloaderProvisionRequest const void* caller_state, - AsyncArrayCallback result_callback + AsyncIntPtrCallback result_callback ); intptr_t download_to_stream( @@ -144,7 +160,7 @@ void file_downloader_free(intptr_t file_downloader_handle); int download_controller_set_completion_callback( intptr_t download_controller_handle, const void* caller_state, - AsyncArrayCallback result_callback + AsyncVoidCallback result_callback ); void download_controller_pause(intptr_t file_downloader_handle); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index a3038599..64b11585 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -72,7 +72,7 @@ private static unsafe nint NativeDownloadToStream( var downloadController = downloader.DownloadToStream( stream, - (completed, total) => progressCallback.UpdateProgress(completed, total), + (completed, total) => progressCallback.UpdateProgress(callerState, completed, total), cancellationToken); return GCHandle.ToIntPtr(GCHandle.Alloc(downloadController)); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index ae74acb8..7c0eed60 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -76,14 +76,14 @@ private static unsafe nint NativeUploadFromStream( return -1; } - var stream = new InteropStream(callerState, readCallback); + var stream = new InteropStream(uploader.FileSize, callerState, readCallback); var uploadController = uploader.UploadFromStream( parentUid.Value, stream, request.HasThumbnail ? [new Thumbnail(ThumbnailType.Thumbnail, request.Thumbnail.ToByteArray())] : [], request.CreateNewRevisionIfExists, - (completed, total) => progressCallback.UpdateProgress(completed, total), + (completed, total) => progressCallback.UpdateProgress(callerState, completed, total), cancellationToken); return GCHandle.ToIntPtr(GCHandle.Alloc(uploadController)); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs index 63364a67..0d196a41 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs @@ -6,7 +6,7 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropProgressCallbackExtensions { - internal static unsafe void UpdateProgress(this InteropValueCallback> progressCallback, long completed, long total) + internal static unsafe void UpdateProgress(this InteropValueCallback> progressCallback, void* callerState, long completed, long total) { var progressUpdate = new ProgressUpdate { @@ -18,7 +18,7 @@ internal static unsafe void UpdateProgress(this InteropValueCallback, nint, InteropValueCallback, int> Invoke; + public readonly delegate* unmanaged[Cdecl], nint, InteropAsyncValueCallback, int> Invoke; } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs index 2cd44aa4..3686826c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs @@ -7,12 +7,16 @@ namespace Proton.Drive.Sdk.CExports; internal sealed unsafe class InteropStream : Stream { + private readonly long? _length; private readonly void* _callerState; private readonly InteropReadCallback _readCallback; private readonly InteropWriteCallback _writeCallback; - public InteropStream(void* callerState, InteropReadCallback readCallback) + private long _position; + + public InteropStream(long length, void* callerState, InteropReadCallback readCallback) { + _length = length; _callerState = callerState; _readCallback = readCallback; _writeCallback = default; @@ -28,12 +32,12 @@ public InteropStream(void* callerState, InteropWriteCallback writeCallback) public override bool CanRead => _readCallback.Invoke != null; public override bool CanSeek => false; public override bool CanWrite => _writeCallback.Invoke != null; - public override long Length => throw new NotSupportedException(); + public override long Length => _length ?? throw new NotSupportedException("Getting length not supported"); public override long Position { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); + get => _position; + set => throw new NotSupportedException("Seeking not supported"); } public override void Flush() @@ -61,7 +65,7 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken try { - var operation = new ReadOperation(memoryHandle); + var operation = new ReadOperation(this, memoryHandle); var operationHandle = GCHandle.Alloc(operation); try @@ -70,7 +74,7 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken _callerState, new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), GCHandle.ToIntPtr(operationHandle), - new InteropValueCallback(&OnReadDone)); + new InteropAsyncValueCallback(&OnReadSucceeded, &OnReadFailed, 0)); return new ValueTask(operation.Task); } @@ -89,17 +93,17 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken public override long Seek(long offset, SeekOrigin origin) { - throw new NotSupportedException(); + throw new NotSupportedException("Seeking not supported"); } public override void SetLength(long value) { - throw new NotSupportedException(); + throw new NotSupportedException("Setting length not supported"); } public override void Write(byte[] buffer, int offset, int count) { - throw new NotSupportedException(); + WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) @@ -118,7 +122,7 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo try { - var operation = new WriteOperation(memoryHandle); + var operation = new WriteOperation(this, memoryHandle, buffer.Length); var operationHandle = GCHandle.Alloc(operation); try @@ -127,7 +131,7 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo _callerState, new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), GCHandle.ToIntPtr(operationHandle), - new InteropVoidCallback(&OnWriteDone)); + new InteropAsyncVoidCallback(&OnWriteSucceeded, &OnWriteFailed, 0)); return new ValueTask(operation.Task); } @@ -145,23 +149,76 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnReadDone(void* state, int numberOfBytesRead) + private static void OnReadSucceeded(void* state, int numberOfBytesRead) { - var operation = (ReadOperation)GCHandle.FromIntPtr(new nint(state)).Target!; + var operationHandle = GCHandle.FromIntPtr(new nint(state)); + + try + { + var operation = (ReadOperation)operationHandle.Target!; - operation.Complete(numberOfBytesRead); + operation.Complete(numberOfBytesRead); + } + finally + { + operationHandle.Free(); + } } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnWriteDone(void* state) + private static void OnReadFailed(void* state, InteropArray errorBytes) { - var operation = (WriteOperation)GCHandle.FromIntPtr(new nint(state)).Target!; + var operationHandle = GCHandle.FromIntPtr(new nint(state)); + + try + { + var operation = (ReadOperation)operationHandle.Target!; - operation.Complete(); + operation.CompleteWithFailure(errorBytes); + } + finally + { + operationHandle.Free(); + } } - private sealed class ReadOperation(MemoryHandle memoryHandle) + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnWriteSucceeded(void* state) { + var operationHandle = GCHandle.FromIntPtr(new nint(state)); + + try + { + var operation = (WriteOperation)operationHandle.Target!; + + operation.CompleteSuccessfully(); + } + finally + { + operationHandle.Free(); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnWriteFailed(void* state, InteropArray errorBytes) + { + var operationHandle = GCHandle.FromIntPtr(new nint(state)); + + try + { + var operation = (WriteOperation)operationHandle.Target!; + + operation.CompleteWithFailure(errorBytes); + } + finally + { + operationHandle.Free(); + } + } + + private sealed class ReadOperation(InteropStream stream, MemoryHandle memoryHandle) + { + private readonly InteropStream _stream = stream; private readonly TaskCompletionSource _taskCompletionSource = new(); private MemoryHandle _memoryHandle = memoryHandle; @@ -170,23 +227,65 @@ private sealed class ReadOperation(MemoryHandle memoryHandle) public void Complete(int bytesRead) { - _taskCompletionSource.SetResult(bytesRead); - _memoryHandle.Dispose(); + try + { + _stream._position += bytesRead; + _taskCompletionSource.SetResult(bytesRead); + } + finally + { + _memoryHandle.Dispose(); + } + } + + public void CompleteWithFailure(InteropArray errorBytes) + { + try + { + var error = Error.Parser.ParseFrom(errorBytes.AsReadOnlySpan()); + _taskCompletionSource.SetException(new IOException(error.Message)); + } + finally + { + _memoryHandle.Dispose(); + } } } - private sealed class WriteOperation(MemoryHandle memoryHandle) + private sealed class WriteOperation(InteropStream stream, MemoryHandle memoryHandle, int bufferLength) { + private readonly InteropStream _stream = stream; + private readonly int _bufferLength = bufferLength; private readonly TaskCompletionSource _taskCompletionSource = new(); private MemoryHandle _memoryHandle = memoryHandle; public Task Task => _taskCompletionSource.Task; - public void Complete() + public void CompleteSuccessfully() { - _taskCompletionSource.SetResult(); - _memoryHandle.Dispose(); + try + { + _stream._position += _bufferLength; + _taskCompletionSource.SetResult(); + } + finally + { + _memoryHandle.Dispose(); + } + } + + public void CompleteWithFailure(InteropArray errorBytes) + { + try + { + var error = Error.Parser.ParseFrom(errorBytes.AsReadOnlySpan()); + _taskCompletionSource.SetException(new IOException(error.Message)); + } + finally + { + _memoryHandle.Dispose(); + } } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs index 58da12bd..9ccc849f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs @@ -6,5 +6,5 @@ namespace Proton.Drive.Sdk.CExports; [StructLayout(LayoutKind.Sequential)] internal readonly unsafe struct InteropWriteCallback { - public readonly delegate* unmanaged[Cdecl], nint, InteropVoidCallback, void> Invoke; + public readonly delegate* unmanaged[Cdecl], nint, InteropAsyncVoidCallback, void> Invoke; } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index baaafc17..fc0a2870 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -27,6 +27,11 @@ + + + + + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 19785eba..0867bad4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -17,15 +17,19 @@ internal FileUploader( string name, string mediaType, DateTimeOffset? lastModificationTime, + long size, int expectedNumberOfBlocks) { _client = client; _name = name; _mediaType = mediaType; _lastModificationTime = lastModificationTime; + FileSize = size; _remainingNumberOfBlocks = expectedNumberOfBlocks; } + internal long FileSize { get; } + public UploadController UploadFromStream( NodeUid parentFolderUid, Stream contentStream, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 1716f561..286eba4c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -113,7 +113,7 @@ public async ValueTask GetFileUploaderAsync( await RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); - return new FileUploader(this, name, mediaType, lastModificationTime, expectedNumberOfBlocks); + return new FileUploader(this, name, mediaType, lastModificationTime, size, expectedNumberOfBlocks); } public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs index d7e9cac0..51ea6d7f 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs @@ -3,10 +3,13 @@ namespace Proton.Sdk.CExports; [StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropAsyncValueCallback +internal readonly unsafe struct InteropAsyncValueCallback( + delegate* unmanaged[Cdecl] onSuccess, + delegate* unmanaged[Cdecl], void> onFailure, + nint cancellationTokenSourceHandle) where T : unmanaged { - public readonly delegate* unmanaged[Cdecl] OnSuccess; - public readonly delegate* unmanaged[Cdecl], void> OnFailure; - public readonly nint CancellationTokenSourceHandle; + public readonly delegate* unmanaged[Cdecl] OnSuccess = onSuccess; + public readonly delegate* unmanaged[Cdecl], void> OnFailure = onFailure; + public readonly nint CancellationTokenSourceHandle = cancellationTokenSourceHandle; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs index f1914a3d..4c441db7 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs @@ -3,9 +3,12 @@ namespace Proton.Sdk.CExports; [StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropAsyncVoidCallback +internal readonly unsafe struct InteropAsyncVoidCallback( + delegate* unmanaged[Cdecl] onSuccess, + delegate* unmanaged[Cdecl], void> onFailure, + nint cancellationTokenSourceHandle) { - public readonly delegate* unmanaged[Cdecl] OnSuccess; - public readonly delegate* unmanaged[Cdecl], void> OnFailure; - public readonly nint CancellationTokenSourceHandle; + public readonly delegate* unmanaged[Cdecl] OnSuccess = onSuccess; + public readonly delegate* unmanaged[Cdecl], void> OnFailure = onFailure; + public readonly nint CancellationTokenSourceHandle = cancellationTokenSourceHandle; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs index 27c5bf9f..e39b768c 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs @@ -28,11 +28,13 @@ internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out Pr } [UnmanagedCallersOnly(EntryPoint = "session_begin", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeBegin(InteropArray requestBytes, void* callerState, InteropAsyncValueCallback> resultCallback) + private static unsafe int NativeBegin(InteropArray requestBytes, void* callerState, InteropAsyncValueCallback resultCallback) { try { - return resultCallback.InvokeFor(callerState, ct => InteropBeginAsync(requestBytes, ct)); + var request = SessionBeginRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + return resultCallback.InvokeFor(callerState, ct => InteropBeginAsync(request, ct)); } catch { @@ -210,14 +212,12 @@ private static void NativeFree(nint handle) } } - private static async ValueTask, InteropArray>> InteropBeginAsync( - InteropArray requestBytes, + private static async ValueTask>> InteropBeginAsync( + SessionBeginRequest request, CancellationToken cancellationToken) { try { - var request = SessionBeginRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - ILoggerFactory? loggerFactory = null; if (request.Options.HasLoggerProviderHandle @@ -252,12 +252,11 @@ private static async ValueTask, InteropArray>> I options, cancellationToken).ConfigureAwait(false); - var handle = GCHandle.ToIntPtr(GCHandle.Alloc(session)); - return InteropResultExtensions.Success(new IntResponse { Value = handle }); + return GCHandle.ToIntPtr(GCHandle.Alloc(session)); } catch (Exception e) { - return InteropResultExtensions.Failure>(e, InteropErrorConverter.SetDomainAndCodes); + return InteropResultExtensions.Failure(e, InteropErrorConverter.SetDomainAndCodes); } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs index bab1542e..80feea48 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs @@ -5,5 +5,5 @@ namespace Proton.Sdk.CExports; [StructLayout(LayoutKind.Sequential)] internal readonly unsafe struct InteropVoidCallback(delegate* unmanaged[Cdecl] invoke) { - public readonly delegate* unmanaged[Cdecl] Call; + public readonly delegate* unmanaged[Cdecl] Invoke = invoke; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs deleted file mode 100644 index d5697de2..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogCallback.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Proton.Sdk.CExports.Logging; - -[StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropLogCallback -{ - public readonly void* State; - public readonly delegate* unmanaged[Cdecl], void> Invoke; -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index b399b35c..444f7eb8 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -7,9 +7,10 @@ namespace Proton.Sdk.CExports.Logging; using EventId = Microsoft.Extensions.Logging.EventId; [StructLayout(LayoutKind.Sequential)] -internal sealed class InteropLogger(InteropLogCallback logCallback, string categoryName) : ILogger +internal sealed unsafe class InteropLogger(void* callerState, InteropValueCallback> logCallback, string categoryName) : ILogger { - private readonly InteropLogCallback _logCallback = logCallback; + private readonly void* _callerState = callerState; + private readonly InteropValueCallback> _logCallback = logCallback; private readonly string _categoryName = categoryName; public IDisposable BeginScope(TState state) @@ -19,7 +20,7 @@ public IDisposable BeginScope(TState state) return new DummyDisposable(); } - public unsafe void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var message = formatter.Invoke(state, exception); var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; @@ -28,7 +29,7 @@ public unsafe void Log(LogLevel logLevel, EventId eventId, TState state, try { - _logCallback.Invoke(_logCallback.State, messageBytes); + _logCallback.Invoke(_callerState, messageBytes); } finally { diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs index 985d9837..0e23725f 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs @@ -5,13 +5,14 @@ namespace Proton.Sdk.CExports.Logging; -internal sealed class InteropLoggerProvider(InteropLogCallback logCallback) : ILoggerProvider +internal sealed unsafe class InteropLoggerProvider(void* callerState, InteropValueCallback> logCallback) : ILoggerProvider { - private readonly InteropLogCallback _logCallback = logCallback; + private readonly void* _callerState = callerState; + private readonly InteropValueCallback> _logCallback = logCallback; public ILogger CreateLogger(string categoryName) { - return new InteropLogger(_logCallback, categoryName); + return new InteropLogger(_callerState, _logCallback, categoryName); } public void Dispose() @@ -35,11 +36,11 @@ internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out In } [UnmanagedCallersOnly(EntryPoint = "logger_provider_create", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int InitializeLoggerProvider(InteropLogCallback logCallback, nint* loggerProviderHandle) + private static int InitializeLoggerProvider(void* callerState, InteropValueCallback> logCallback, nint* loggerProviderHandle) { try { - var provider = new InteropLoggerProvider(logCallback); + var provider = new InteropLoggerProvider(callerState, logCallback); *loggerProviderHandle = GCHandle.ToIntPtr(GCHandle.Alloc(provider)); return 0; } diff --git a/cs/sdk/src/protos/account.proto b/cs/sdk/src/protos/account.proto index b5be0931..5b47615c 100644 --- a/cs/sdk/src/protos/account.proto +++ b/cs/sdk/src/protos/account.proto @@ -109,10 +109,6 @@ message StringResponse { string value = 1; } -message IntResponse { - int64 value = 1; -} - message SessionId { string value = 1; } From 4586f98c17064a8cc078ed228add4454a559f6b8 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 Aug 2025 14:29:30 +0200 Subject: [PATCH 197/791] Remove quark types after merge --- js/sdk/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index baa76e77..d38aab15 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.0.13", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.0.13", + "version": "0.2.1", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", From a4be782d5b88e49a2176717a946481234d1b1d70 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Sep 2025 06:18:44 +0000 Subject: [PATCH 198/791] Revamp documentation --- .gitignore | 2 ++ js/sdk/src/diagnostic/interface.ts | 4 +-- js/sdk/src/interface/author.ts | 2 +- js/sdk/src/interface/events.ts | 8 +----- js/sdk/src/internal/nodes/apiService.test.ts | 28 ++++++++++++++++++++ js/sdk/src/internal/nodes/apiService.ts | 3 +++ js/sdk/src/internal/nodes/cryptoService.ts | 18 +++++++++++-- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- js/sdk/src/internal/nodes/nodesManagement.ts | 3 --- js/sdk/src/protonDriveClient.ts | 2 +- 10 files changed, 55 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index bc898eaa..f8b63254 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .DS_Store # Docs +__pycache__ +docs/build public # JS diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index 3e649383..95aef27f 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -1,4 +1,4 @@ -import { Author, MaybeNode, MetricEvent, NodeType } from '../interface'; +import { Author, MaybeNode, MetricEvent, NodeType, AnonymousUser } from '../interface'; import { LogRecord } from '../telemetry'; export interface Diagnostic { @@ -73,7 +73,7 @@ export type DegradedNodeResult = { export type UnverifiedAuthorResult = { type: 'unverified_author'; authorType: string; - claimedAuthor?: string; + claimedAuthor?: string | AnonymousUser; error: string; } & NodeDetails; diff --git a/js/sdk/src/interface/author.ts b/js/sdk/src/interface/author.ts index 024ee769..b12661b6 100644 --- a/js/sdk/src/interface/author.ts +++ b/js/sdk/src/interface/author.ts @@ -24,6 +24,6 @@ export type AnonymousUser = null; * the claimed author and the verification error. */ export type UnverifiedAuthorError = { - claimedAuthor?: string; + claimedAuthor?: string | AnonymousUser; error: string; }; diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index 5491fce9..2800f67c 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -20,13 +20,7 @@ export interface LatestEventIdProvider { */ export type DriveListener = (event: DriveEvent) => Promise; -export type DriveEvent = - | NodeEvent - | FastForwardEvent - | TreeRefreshEvent - | TreeRemovalEvent - | FastForwardEvent - | SharedWithMeUpdated; +export type DriveEvent = NodeEvent | FastForwardEvent | TreeRefreshEvent | TreeRemovalEvent | SharedWithMeUpdated; export type NodeEvent = | { diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 08961f53..79a090d6 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -173,6 +173,34 @@ describe('nodeAPIService', () => { api = new NodeAPIService(getMockLogger(), apiMock); }); + describe('getNode', () => { + it('should get node', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async () => + Promise.resolve({ + Links: [generateAPIFolderNode()], + }), + ); + + const node = await api.getNode('volumeId~nodeId', 'volumeId'); + + expect(node).toStrictEqual(generateFolderNode()); + }); + + it('should throw error if node is not found', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async () => + Promise.resolve({ + Links: [], + }), + ); + + const promise = api.getNode('volumeId~nodeId', 'volumeId'); + + await expect(promise).rejects.toThrow('Node not found'); + }); + }); + describe('iterateNodes', () => { async function testIterateNodes(mockedLink: any, expectedNode: any, ownVolumeId = 'volumeId') { // @ts-expect-error Mocking for testing purposes diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index c4e9a6c6..38631e38 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -110,6 +110,9 @@ export class NodeAPIService { async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise { const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, signal); const result = await nodesGenerator.next(); + if (!result.value) { + throw new ValidationError(c('Error').t`Node not found`); + } await nodesGenerator.return('finish'); return result.value; } diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index df291a5f..d92fb5b0 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -112,7 +112,10 @@ export class NodesCryptoService { ...commonNodeMetadata, name, keyAuthor: resultError({ - claimedAuthor: node.encryptedCrypto.signatureEmail, + claimedAuthor: getClaimedAuthor( + node.encryptedCrypto.signatureEmail, + keyVerificationKeys.length === 0, + ), error: errorMessage, }), nameAuthor, @@ -319,7 +322,7 @@ export class NodesCryptoService { return { name: resultError(new Error(errorMessage)), author: resultError({ - claimedAuthor: nameSignatureEmail, + claimedAuthor: getClaimedAuthor(nameSignatureEmail, verificationKeys.length === 0), error: errorMessage, }), }; @@ -716,3 +719,14 @@ function handleClaimedAuthor( error: getVerificationMessage(verified, verificationErrors, signatureType, notAvailableVerificationKeys), }); } + +function getClaimedAuthor( + claimedAuthor?: string, + notAvailableVerificationKeys = false, +): string | AnonymousUser | undefined { + if (!claimedAuthor && notAvailableVerificationKeys) { + return null as AnonymousUser; + } + + return claimedAuthor; +} diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index d787503a..ea1ae549 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -234,7 +234,7 @@ export class NodesAccess { if (errors.length > 0) { this.logger.error(`Failed to decrypt ${errors.length} nodes`, errors); - throw new ProtonDriveError(c('Error').t`Failed to decrypt some nodes`, { cause: errors }); + throw new DecryptionError(c('Error').t`Failed to decrypt some nodes`, { cause: errors }); } const missingNodeUids = nodeUids.filter((nodeUid) => !returnedNodeUids.includes(nodeUid)); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index ef393932..e8d70458 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -189,11 +189,8 @@ export class NodesManagement { } async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const deletedNodeUids = []; - for await (const result of this.apiService.deleteNodes(nodeUids, signal)) { if (result.ok) { - deletedNodeUids.push(result.uid); await this.nodesAccess.notifyNodeDeleted(result.uid); } yield result; diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 9cea321f..8705ea98 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -311,7 +311,7 @@ export class ProtonDriveClient { * @param nodeUid - Node entity or its UID string. * @returns The updated node entity. * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. - * @throws {@link Error} If another node with the same name already exists. + * @throws {@link ValidationError} If another node with the same name already exists. */ async renameNode(nodeUid: NodeOrUid, newName: string): Promise { this.logger.info(`Renaming node ${getUid(nodeUid)}`); From 4fd6033035056f6c650335f8eeeb29fe7f118ab9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Sep 2025 09:05:51 +0200 Subject: [PATCH 199/791] Fix accepting entities and UIDs in the interface --- js/sdk/src/protonDriveClient.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 8705ea98..294f5a40 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -6,6 +6,7 @@ import { MaybeMissingNode, NodeResult, Revision, + RevisionOrUid, ShareNodeSettings, UnshareNodeSettings, ProtonInvitationOrUid, @@ -453,9 +454,9 @@ export class ProtonDriveClient { * * @param revisionUid - UID of the revision to restore. */ - async restoreRevision(revisionUid: string): Promise { - this.logger.info(`Restoring revision ${revisionUid}`); - await this.nodes.revisions.restoreRevision(revisionUid); + async restoreRevision(revisionUid: RevisionOrUid): Promise { + this.logger.info(`Restoring revision ${getUid(revisionUid)}`); + await this.nodes.revisions.restoreRevision(getUid(revisionUid)); } /** @@ -463,9 +464,9 @@ export class ProtonDriveClient { * * @param revisionUid - UID of the revision to delete. */ - async deleteRevision(revisionUid: string): Promise { - this.logger.info(`Deleting revision ${revisionUid}`); - await this.nodes.revisions.deleteRevision(revisionUid); + async deleteRevision(revisionUid: RevisionOrUid): Promise { + this.logger.info(`Deleting revision ${getUid(revisionUid)}`); + await this.nodes.revisions.deleteRevision(getUid(revisionUid)); } /** @@ -527,21 +528,21 @@ export class ProtonDriveClient { /** * Accept the invitation to the shared node. * - * @param invitationId - Invitation entity or its UID string. + * @param invitationUid - Invitation entity or its UID string. */ - async acceptInvitation(invitationId: string): Promise { - this.logger.info(`Accepting invitation ${invitationId}`); - await this.sharing.access.acceptInvitation(invitationId); + async acceptInvitation(invitationUid: ProtonInvitationOrUid): Promise { + this.logger.info(`Accepting invitation ${getUid(invitationUid)}`); + await this.sharing.access.acceptInvitation(getUid(invitationUid)); } /** * Reject the invitation to the shared node. * - * @param invitationId - Invitation entity or its UID string. + * @param invitationOrUid - Invitation entity or its UID string. */ - async rejectInvitation(invitationId: string): Promise { - this.logger.info(`Rejecting invitation ${invitationId}`); - await this.sharing.access.rejectInvitation(invitationId); + async rejectInvitation(invitationUid: ProtonInvitationOrUid): Promise { + this.logger.info(`Rejecting invitation ${getUid(invitationUid)}`); + await this.sharing.access.rejectInvitation(getUid(invitationUid)); } /** From 8c92e0c12e6a8a2e12170731ce3e5753f607a190 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Sep 2025 08:21:08 +0200 Subject: [PATCH 200/791] Rename NodeAlreadyExistsValidationError --- js/sdk/src/errors.ts | 10 +++++----- js/sdk/src/internal/upload/manager.test.ts | 4 ++-- js/sdk/src/internal/upload/manager.ts | 6 ++---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index 18e78d07..529effd3 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -70,17 +70,17 @@ export class ValidationError extends ProtonDriveError { * or choose another name. The available name is provided in the `availableName` * property (that will contain original name with the index that can be used). */ -export class NodeAlreadyExistsValidationError extends ValidationError { - name = 'NodeAlreadyExistsValidationError'; +export class NodeWithSameNameExistsValidationError extends ValidationError { + name = 'NodeWithSameNameExistsValidationError'; public readonly existingNodeUid?: string; - public readonly ongoingUploadByOtherClient: boolean; + public readonly isUnfinishedUpload: boolean; - constructor(message: string, code: number, existingNodeUid?: string, ongoingUploadByOtherClient = false) { + constructor(message: string, code: number, existingNodeUid?: string, isUnfinishedUpload = false) { super(message, code); this.existingNodeUid = existingNodeUid; - this.ongoingUploadByOtherClient = ongoingUploadByOtherClient; + this.isUnfinishedUpload = isUnfinishedUpload; } } diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 5f77a629..faed5121 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -206,7 +206,7 @@ describe('UploadManager', () => { await promise; } catch (error: any) { expect(error.message).toBe('Draft already exists'); - expect(error.ongoingUploadByOtherClient).toBe(true); + expect(error.isUnfinishedUpload).toBe(true); } expect(apiService.deleteDraft).not.toHaveBeenCalled(); }); @@ -237,7 +237,7 @@ describe('UploadManager', () => { await promise; } catch (error: any) { expect(error.message).toBe('Draft already exists'); - expect(error.ongoingUploadByOtherClient).toBe(true); + expect(error.isUnfinishedUpload).toBe(true); } expect(apiService.deleteDraft).not.toHaveBeenCalled(); }); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 60a8a376..ada9d99a 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { Logger, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; -import { ValidationError, NodeAlreadyExistsValidationError } from '../../errors'; +import { ValidationError, NodeWithSameNameExistsValidationError } from '../../errors'; import { ErrorCode } from '../apiService'; import { generateFileExtendedAttributes } from '../nodes'; import { UploadAPIService } from './apiService'; @@ -158,9 +158,7 @@ export class UploadManager { ? makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID) : undefined; - // If there is existing node, return special error - // that includes the available name the client can use. - throw new NodeAlreadyExistsValidationError( + throw new NodeWithSameNameExistsValidationError( error.message, error.code, existingNodeUid, From d83c36b2a78990b0dd8e0411cb55904e869d2268 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Sep 2025 09:47:18 +0000 Subject: [PATCH 201/791] Fix what address is used to invite users into the share --- js/sdk/src/internal/sharing/interface.ts | 4 - .../sharing/sharingManagement.test.ts | 78 +++++++++++++------ .../src/internal/sharing/sharingManagement.ts | 63 +++++++++++---- 3 files changed, 101 insertions(+), 44 deletions(-) diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 3aed082a..fa06737c 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -145,10 +145,6 @@ export interface SharesService { getMyFilesIDs(): Promise<{ volumeId: string }>; loadEncryptedShare(shareId: string): Promise; getMyFilesShareMemberEmailKey(): Promise<{ - addressId: string; - addressKey: PrivateKey; - }>; - getContextShareMemberEmailKey(shareId: string): Promise<{ email: string; addressId: string; addressKey: PrivateKey; diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index e54a8d70..d70b11ef 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -58,11 +58,9 @@ describe('SharingManagement', () => { }; // @ts-expect-error No need to implement all methods for mocking cryptoService = { - generateShareKeys: jest - .fn() - .mockResolvedValue({ - shareKey: { encrypted: 'encrypted-key', decrypted: { passphraseSessionKey: 'pass-session-key' } }, - }), + generateShareKeys: jest.fn().mockResolvedValue({ + shareKey: { encrypted: 'encrypted-key', decrypted: { passphraseSessionKey: 'pass-session-key' } }, + }), decryptShare: jest.fn().mockImplementation((share) => share), decryptInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptExternalInvitation: jest.fn().mockImplementation((invitation) => invitation), @@ -70,7 +68,7 @@ describe('SharingManagement', () => { encryptInvitation: jest.fn().mockImplementation(() => {}), encryptExternalInvitation: jest.fn().mockImplementation((invitation) => ({ ...invitation, - base64ExternalInvitationSignature: 'extenral-signature', + base64ExternalInvitationSignature: 'external-signature', })), decryptPublicLink: jest.fn().mockImplementation((publicLink) => publicLink), generatePublicLinkPassword: jest.fn().mockResolvedValue('generatedPassword'), @@ -85,17 +83,12 @@ describe('SharingManagement', () => { }; // @ts-expect-error No need to implement all methods for mocking sharesService = { - loadEncryptedShare: jest - .fn() - .mockResolvedValue({ - id: 'shareId', - addressId: 'addressId', - creatorEmail: 'address@example.com', - passphraseSessionKey: 'sharePassphraseSessionKey', - }), - getContextShareMemberEmailKey: jest - .fn() - .mockResolvedValue({ email: 'volume-email', addressId: 'addressId', addressKey: 'volume-key' }), + loadEncryptedShare: jest.fn().mockResolvedValue({ + id: 'shareId', + addressId: 'addressId', + creatorEmail: 'address@example.com', + passphraseSessionKey: 'sharePassphraseSessionKey', + }), }; // @ts-expect-error No need to implement all methods for mocking nodesService = { @@ -196,13 +189,11 @@ describe('SharingManagement', () => { const nodeUid = 'volumeId~nodeUid'; it('should create share if no exists', async () => { - nodesService.getNode = jest - .fn() - .mockImplementation((nodeUid) => ({ - nodeUid, - parentUid: 'parentUid', - name: { ok: true, value: 'name' }, - })); + nodesService.getNode = jest.fn().mockImplementation((nodeUid) => ({ + nodeUid, + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + })); nodesService.notifyNodeChanged = jest.fn(); const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ['email'] }); @@ -347,6 +338,24 @@ describe('SharingManagement', () => { expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); }); + + it('should use address from the root node context share', async () => { + nodesService.getRootNodeEmailKey = jest + .fn() + .mockResolvedValue({ email: 'my-volume-email', addressKey: 'my-volume-key' }); + + await sharingManagement.shareNode(nodeUid, { users: ['email'] }); + + expect(apiService.inviteProtonUser).toHaveBeenCalledWith( + 'shareId', + { + addedByEmail: 'my-volume-email', + inviteeEmail: 'email', + role: 'viewer', + }, + expect.anything(), + ); + }); }); describe('external invitations', () => { @@ -434,6 +443,27 @@ describe('SharingManagement', () => { expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); }); + + it('should use address from the root node context share', async () => { + nodesService.getRootNodeEmailKey = jest.fn().mockResolvedValue({ + email: 'my-volume-email', + addressId: 'my-volume-addressId', + addressKey: 'my-volume-key', + }); + + await sharingManagement.shareNode(nodeUid, { users: ['email'] }); + + expect(apiService.inviteExternalUser).toHaveBeenCalledWith( + 'shareId', + { + inviterAddressId: 'my-volume-addressId', + inviteeEmail: 'email', + role: 'viewer', + base64Signature: 'external-signature', + }, + expect.anything(), + ); + }); }); describe('mix of internal and external invitations', () => { diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index b944ef54..3ddffc7d 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { SessionKey } from '../../crypto'; +import { PrivateKey, SessionKey } from '../../crypto'; import { ValidationError } from '../../errors'; import { Logger, @@ -33,6 +33,12 @@ interface Share { passphraseSessionKey: SessionKey; } +interface ContextShareAddress { + addressId: string; + addressKey: PrivateKey; + email: string; +} + interface EmailOptions { message?: string; nodeName?: string; @@ -138,18 +144,22 @@ export class SharingManagement { throw new ValidationError(c('Error').t`Expiration date cannot be in the past`); } + let contextShareAddress: ContextShareAddress; let currentSharing = await this.getInternalSharingInfo(nodeUid); - if (!currentSharing) { + if (currentSharing) { + contextShareAddress = await this.nodesService.getRootNodeEmailKey(nodeUid); + } else { const node = await this.nodesService.getNode(nodeUid); - const share = await this.createShare(nodeUid); + const result = await this.createShare(nodeUid); currentSharing = { - share, + share: result.share, nodeName: node.name.ok ? node.name.value : node.name.error.name, protonInvitations: [], nonProtonInvitations: [], members: [], publicLink: undefined, }; + contextShareAddress = result.contextShareAddress; } const emailOptions: EmailOptions = { @@ -187,7 +197,13 @@ export class SharingManagement { } this.logger.info(`Inviting user ${email} with role ${role} to node ${nodeUid}`); - const invitation = await this.inviteProtonUser(currentSharing.share, email, role, emailOptions); + const invitation = await this.inviteProtonUser( + contextShareAddress, + currentSharing.share, + email, + role, + emailOptions, + ); currentSharing.protonInvitations.push(invitation); } @@ -225,7 +241,13 @@ export class SharingManagement { } this.logger.info(`Inviting external user ${email} with role ${role} to node ${nodeUid}`); - const invitation = await this.inviteExternalUser(currentSharing.share, email, role, emailOptions); + const invitation = await this.inviteExternalUser( + contextShareAddress, + currentSharing.share, + email, + role, + emailOptions, + ); currentSharing.nonProtonInvitations.push(invitation); } @@ -241,7 +263,7 @@ export class SharingManagement { ); } else { this.logger.info(`Sharing via public link with role ${options.role} to node ${nodeUid}`); - currentSharing.publicLink = await this.shareViaLink(currentSharing.share, options); + currentSharing.publicLink = await this.shareViaLink(contextShareAddress, currentSharing.share, options); } } @@ -371,7 +393,7 @@ export class SharingManagement { }; } - private async createShare(nodeUid: string): Promise { + private async createShare(nodeUid: string): Promise<{ share: Share; contextShareAddress: ContextShareAddress }> { const node = await this.nodesService.getNode(nodeUid); if (!node.parentUid) { throw new ValidationError(c('Error').t`Cannot share root folder`); @@ -387,12 +409,22 @@ export class SharingManagement { base64NameKeyPacket: keys.base64NameKeyPacket, }); await this.nodesService.notifyNodeChanged(nodeUid); - return { + + const share = { volumeId, shareId, creatorEmail: email, passphraseSessionKey: keys.shareKey.decrypted.passphraseSessionKey, }; + const contextShareAddress = { + email, + addressId, + addressKey, + }; + return { + share, + contextShareAddress, + }; } private async deleteShare(shareId: string, nodeUid: string): Promise { @@ -401,12 +433,12 @@ export class SharingManagement { } private async inviteProtonUser( + inviter: ContextShareAddress, share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions, ): Promise { - const inviter = await this.sharesService.getContextShareMemberEmailKey(share.shareId); const invitationCrypto = await this.cryptoService.encryptInvitation( share.passphraseSessionKey, inviter.addressKey, @@ -461,12 +493,12 @@ export class SharingManagement { } private async inviteExternalUser( + inviter: ContextShareAddress, share: Share, inviteeEmail: string, role: MemberRole, emailOptions: EmailOptions, ): Promise { - const inviter = await this.sharesService.getContextShareMemberEmailKey(share.shareId); const invitationCrypto = await this.cryptoService.encryptExternalInvitation( share.passphraseSessionKey, inviter.addressKey, @@ -515,21 +547,20 @@ export class SharingManagement { } private async shareViaLink( + inviter: ContextShareAddress, share: Share, options: SharePublicLinkSettingsObject, ): Promise { - const { email: creatorEmail } = await this.sharesService.getContextShareMemberEmailKey(share.shareId); - const generatedPassword = await this.cryptoService.generatePublicLinkPassword(); const password = options.customPassword ? `${generatedPassword}${options.customPassword}` : generatedPassword; const { crypto, srp } = await this.cryptoService.encryptPublicLink( - creatorEmail, + inviter.email, share.passphraseSessionKey, password, ); const publicLink = await this.apiService.createPublicLink(share.shareId, { - creatorEmail, + creatorEmail: inviter.email, role: options.role, includesCustomPassword: !!options.customPassword, expirationTime: options.expiration ? Math.floor(options.expiration.getTime() / 1000) : undefined, @@ -545,7 +576,7 @@ export class SharingManagement { customPassword: options.customPassword, expirationTime: options.expiration, numberOfInitializedDownloads: 0, - creatorEmail, + creatorEmail: inviter.email, }; } From 41c9b4ee7e64c88f38cb6ea46132260833e99803 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Sep 2025 07:03:52 +0200 Subject: [PATCH 202/791] Improve performance of loading shared with me --- js/sdk/src/internal/apiService/apiService.ts | 15 +++- js/sdk/src/internal/nodes/cryptoService.ts | 84 ++++++++++++------- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- .../internal/sharing/sharingAccess.test.ts | 8 +- js/sdk/src/internal/sharing/sharingAccess.ts | 6 ++ 5 files changed, 75 insertions(+), 40 deletions(-) diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 492b8adb..34d9813a 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -239,7 +239,11 @@ export class DriveAPIService { throw new AbortError(c('Error').t`Request aborted`); } - this.logger.debug(`${request.method} ${request.url}`); + if (attempt > 0) { + this.logger.debug(`${request.method} ${request.url}: retry ${attempt}`); + } else { + this.logger.debug(`${request.method} ${request.url}`); + } if (this.hasReachedServerErrorLimit) { this.logger.warn('Server errors limit reached'); @@ -250,6 +254,8 @@ export class DriveAPIService { throw new RateLimitedError(c('Error').t`Too many server requests, please try again later`); } + const start = Date.now(); + let response; try { response = await callback(); @@ -276,10 +282,13 @@ export class DriveAPIService { throw error; } + const end = Date.now(); + const duration = end - start; + if (response.ok) { - this.logger.info(`${request.method} ${request.url}: ${response.status}`); + this.logger.info(`${request.method} ${request.url}: ${response.status} (${duration}ms)`); } else { - this.logger.warn(`${request.method} ${request.url}: ${response.status}`); + this.logger.warn(`${request.method} ${request.url}: ${response.status} (${duration}ms)`); } if (response.status === HTTPErrorCode.TOO_MANY_REQUESTS) { diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index d92fb5b0..35ee1cea 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -61,6 +61,8 @@ export class NodesCryptoService { node: EncryptedNode, parentKey: PrivateKey, ): Promise<{ node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }> { + const start = Date.now(); + const commonNodeMetadata = { ...node, encryptedCrypto: undefined, @@ -89,16 +91,17 @@ export class NodesCryptoService { : nodeParentKeys; } - const { name, author: nameAuthor } = await this.decryptName(node, parentKey, nameVerificationKeys); - - let membership; - if (node.membership) { - membership = await this.decryptMembership(node); - } + // Start promises early, but await them only when required to do + // as much work as possible in parallel. + const [membershipPromise, namePromise, keyPromise] = [ + node.membership ? this.decryptMembership(node) : undefined, + this.decryptName(node, parentKey, nameVerificationKeys), + this.decryptKey(node, parentKey, keyVerificationKeys), + ]; let passphrase, key, passphraseSessionKey, keyAuthor; try { - const keyResult = await this.decryptKey(node, parentKey, keyVerificationKeys); + const keyResult = await keyPromise; passphrase = keyResult.passphrase; key = keyResult.key; passphraseSessionKey = keyResult.passphraseSessionKey; @@ -107,6 +110,8 @@ export class NodesCryptoService { void this.reportDecryptionError(node, 'nodeKey', error); const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt node key: ${message}`; + const { name, author: nameAuthor } = await namePromise; + const membership = await membershipPromise; return { node: { ...commonNodeMetadata, @@ -134,8 +139,23 @@ export class NodesCryptoService { let folder; let folderExtendedAttributesAuthor; if ('folder' in node.encryptedCrypto) { + const folderExtendedAttributesVerificationKeys = node.encryptedCrypto.signatureEmail + ? signatureEmailKeys + : [key]; + + const [hashKeyPromise, folderExtendedAttributesPromise] = [ + this.decryptHashKey(node, key, signatureEmailKeys), + this.decryptExtendedAttributes( + node, + node.encryptedCrypto.folder.armoredExtendedAttributes, + key, + folderExtendedAttributesVerificationKeys, + node.encryptedCrypto.signatureEmail, + ), + ]; + try { - const hashKeyResult = await this.decryptHashKey(node, key, signatureEmailKeys); + const hashKeyResult = await hashKeyPromise; hashKey = hashKeyResult.hashKey; hashKeyAuthor = hashKeyResult.author; } catch (error: unknown) { @@ -144,16 +164,7 @@ export class NodesCryptoService { } try { - const folderExtendedAttributesVerificationKeys = node.encryptedCrypto.signatureEmail - ? signatureEmailKeys - : [key]; - const extendedAttributesResult = await this.decryptExtendedAttributes( - node, - node.encryptedCrypto.folder.armoredExtendedAttributes, - key, - folderExtendedAttributesVerificationKeys, - node.encryptedCrypto.signatureEmail, - ); + const extendedAttributesResult = await folderExtendedAttributesPromise; folder = { extendedAttributes: extendedAttributesResult.extendedAttributes, }; @@ -168,10 +179,20 @@ export class NodesCryptoService { let contentKeyPacketSessionKey; let contentKeyPacketAuthor; if ('file' in node.encryptedCrypto) { + const [activeRevisionPromise, contentKeyPacketSessionKeyPromise] = [ + this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key), + this.driveCrypto.decryptAndVerifySessionKey( + node.encryptedCrypto.file.base64ContentKeyPacket, + node.encryptedCrypto.file.armoredContentKeyPacketSignature, + key, + // Content key packet is signed with the node key, but + // in the past some clients signed with the address key. + [key, ...keyVerificationKeys], + ), + ]; + try { - activeRevision = resultOk( - await this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key), - ); + activeRevision = resultOk(await activeRevisionPromise); } catch (error: unknown) { void this.reportDecryptionError(node, 'nodeExtendedAttributes', error); const message = getErrorMessage(error); @@ -180,18 +201,10 @@ export class NodesCryptoService { } try { - const keySessionKeyResult = await this.driveCrypto.decryptAndVerifySessionKey( - node.encryptedCrypto.file.base64ContentKeyPacket, - node.encryptedCrypto.file.armoredContentKeyPacketSignature, - key, - // Content key packet is signed with the node key, but - // in the past some clients signed with the address key. - [key, ...keyVerificationKeys], - ); - + const keySessionKeyResult = await contentKeyPacketSessionKeyPromise; contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; contentKeyPacketAuthor = - keySessionKeyResult.verified && + keySessionKeyResult.verified !== undefined && (await this.handleClaimedAuthor( node, 'nodeContentKey', @@ -233,6 +246,13 @@ export class NodesCryptoService { finalKeyAuthor = keyAuthor; } + const { name, author: nameAuthor } = await namePromise; + const membership = await membershipPromise; + + const end = Date.now(); + const duration = end - start; + this.logger.debug(`Node ${node.uid} decrypted in ${duration}ms`); + return { node: { ...commonNodeMetadata, @@ -638,6 +658,7 @@ export class NodesCryptoService { if (this.reportedVerificationErrors.has(node.uid)) { return; } + this.reportedVerificationErrors.add(node.uid); const fromBefore2024 = node.creationTime < new Date('2024-01-01'); @@ -664,7 +685,6 @@ export class NodesCryptoService { error: verificationErrors?.map((e) => e.message).join(', '), uid: node.uid, }); - this.reportedVerificationErrors.add(node.uid); } private async reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index ea1ae549..fa5cc522 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -24,7 +24,7 @@ const BATCH_LOADING_SIZE = 30; // It is a trade-off between performance and memory usage. // Higher number means more memory usage, but faster decryption. // Lower number means less memory usage, but slower decryption. -const DECRYPTION_CONCURRENCY = 15; +const DECRYPTION_CONCURRENCY = 30; /** * Provides access to node metadata. diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index 20137079..2f5a8ca6 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -3,7 +3,7 @@ import { SharingAPIService } from './apiService'; import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; import { SharesService, NodesService } from './interface'; -import { SharingAccess } from './sharingAccess'; +import { SharingAccess, BATCH_LOADING_SIZE } from './sharingAccess'; describe('SharingAccess', () => { let apiService: SharingAPIService; @@ -14,7 +14,7 @@ describe('SharingAccess', () => { let sharingAccess: SharingAccess; - const nodeUids = Array.from({ length: 15 }, (_, i) => `nodeUid${i}`); + const nodeUids = Array.from({ length: BATCH_LOADING_SIZE + 5 }, (_, i) => `nodeUid${i}`); const nodes = nodeUids.map((nodeUid) => ({ nodeUid })); const nodeUidsIterator = async function* () { for (const nodeUid of nodeUids) { @@ -84,7 +84,7 @@ describe('SharingAccess', () => { expect(result).toEqual(nodes); expect(apiService.iterateSharedNodeUids).toHaveBeenCalledWith('volumeId', undefined); - expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // 15 / 10 per batch + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // Mocked is a bit more over one batch expect(cache.setSharedByMeNodeUids).toHaveBeenCalledWith(nodeUids); }); }); @@ -107,7 +107,7 @@ describe('SharingAccess', () => { expect(result).toEqual(nodes); expect(apiService.iterateSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); - expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // 15 / 10 per batch + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // Mocked is a bit more over one batch expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(nodeUids); }); }); diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 41adf92c..d327d791 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -9,6 +9,10 @@ import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; import { SharesService, NodesService } from './interface'; +// This is the number of nodes that are loaded in parallel. +// It is a trade-off between initial wait time and overhead of API calls. +export const BATCH_LOADING_SIZE = 30; + /** * Provides high-level actions for access shared nodes. * @@ -66,6 +70,7 @@ export class SharingAccess { ): AsyncGenerator { const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), + batchSize: BATCH_LOADING_SIZE, }); for (const nodeUid of nodeUids) { yield* batchLoading.load(nodeUid); @@ -81,6 +86,7 @@ export class SharingAccess { const loadedNodeUids = []; const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), + batchSize: BATCH_LOADING_SIZE, }); for await (const nodeUid of nodeUidsIterator) { loadedNodeUids.push(nodeUid); From 66226c64a233cf6ec11e7797a48013f288528c93 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Sep 2025 07:51:14 +0200 Subject: [PATCH 203/791] Fix cache in CLI --- js/sdk/src/protonDriveClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 294f5a40..7383d3cd 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -343,7 +343,7 @@ export class ProtonDriveClient { newParentNodeUid: NodeOrUid, signal?: AbortSignal, ): AsyncGenerator { - this.logger.info(`Moving ${nodeUids.length} nodes to ${newParentNodeUid}`); + this.logger.info(`Moving ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`); yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); } From be8d9ce2ab3b840a6c66ac1eade44405d0686999 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Sep 2025 08:27:08 +0200 Subject: [PATCH 204/791] js/v0.3.0 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 2b45a959..5b261682 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.2.1", + "version": "0.3.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From a48362f864acaeb9e730f388da17b37b8b277f87 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Sep 2025 12:54:06 +0000 Subject: [PATCH 205/791] Add public access --- js/sdk/src/crypto/interface.ts | 11 ++ js/sdk/src/interface/index.ts | 20 ++- js/sdk/src/internal/apiService/errorCodes.ts | 1 + js/sdk/src/internal/events/index.ts | 2 +- js/sdk/src/internal/nodes/cryptoCache.test.ts | 11 +- js/sdk/src/internal/nodes/cryptoCache.ts | 13 +- js/sdk/src/internal/nodes/interface.ts | 2 + .../src/internal/shares/cryptoCache.test.ts | 5 +- js/sdk/src/internal/shares/cryptoCache.ts | 33 +++- js/sdk/src/internal/shares/index.ts | 2 +- .../src/internal/sharingPublic/apiService.ts | 164 ++++++++++++++++++ .../src/internal/sharingPublic/cryptoCache.ts | 79 +++++++++ .../internal/sharingPublic/cryptoService.ts | 162 +++++++++++++++++ js/sdk/src/internal/sharingPublic/index.ts | 40 +++++ .../src/internal/sharingPublic/interface.ts | 59 +++++++ js/sdk/src/internal/sharingPublic/manager.ts | 85 +++++++++ .../sharingPublic/session/apiService.ts | 74 ++++++++ .../sharingPublic/session/httpClient.ts | 44 +++++ .../internal/sharingPublic/session/index.ts | 1 + .../sharingPublic/session/interface.ts | 20 +++ .../internal/sharingPublic/session/manager.ts | 97 +++++++++++ .../internal/sharingPublic/session/session.ts | 78 +++++++++ .../sharingPublic/session/url.test.ts | 72 ++++++++ .../src/internal/sharingPublic/session/url.ts | 23 +++ js/sdk/src/protonDriveClient.ts | 58 +++++-- js/sdk/src/protonDrivePublicLinkClient.ts | 121 +++++++++++++ 26 files changed, 1237 insertions(+), 40 deletions(-) create mode 100644 js/sdk/src/internal/sharingPublic/apiService.ts create mode 100644 js/sdk/src/internal/sharingPublic/cryptoCache.ts create mode 100644 js/sdk/src/internal/sharingPublic/cryptoService.ts create mode 100644 js/sdk/src/internal/sharingPublic/index.ts create mode 100644 js/sdk/src/internal/sharingPublic/interface.ts create mode 100644 js/sdk/src/internal/sharingPublic/manager.ts create mode 100644 js/sdk/src/internal/sharingPublic/session/apiService.ts create mode 100644 js/sdk/src/internal/sharingPublic/session/httpClient.ts create mode 100644 js/sdk/src/internal/sharingPublic/session/index.ts create mode 100644 js/sdk/src/internal/sharingPublic/session/interface.ts create mode 100644 js/sdk/src/internal/sharingPublic/session/manager.ts create mode 100644 js/sdk/src/internal/sharingPublic/session/session.ts create mode 100644 js/sdk/src/internal/sharingPublic/session/url.test.ts create mode 100644 js/sdk/src/internal/sharingPublic/session/url.ts create mode 100644 js/sdk/src/protonDrivePublicLinkClient.ts diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index c7e87952..37c0c756 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -52,6 +52,17 @@ export enum VERIFICATION_STATUS { } export interface SRPModule { + getSrp: ( + version: number, + modulus: string, + serverEphemeral: string, + salt: string, + password: string, + ) => Promise<{ + expectedServerProof: string; + clientProof: string; + clientEphemeral: string; + }>; getSrpVerifier: (password: string) => Promise; computeKeyPassword: (password: string, salt: string) => Promise; } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index f5e74cd3..64c3a118 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -89,10 +89,22 @@ export type ProtonDriveTelemetry = Telemetry; export type ProtonDriveEntitiesCache = ProtonDriveCache; export type ProtonDriveCryptoCache = ProtonDriveCache; export type CachedCryptoMaterial = { - passphrase?: string; - key: PrivateKey; - passphraseSessionKey: SessionKey; - hashKey?: Uint8Array; + nodeKeys?: { + // Passphrase should not be needed to keep, sessionKey should be enough. + // We will improve this in the future. + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + contentKeyPacketSessionKey?: SessionKey; + hashKey?: Uint8Array; + }; + shareKey?: { + key: PrivateKey; + passphraseSessionKey: SessionKey; + }; + publicShareKey?: { + key: PrivateKey; + }; }; export interface ProtonDriveClientContructorParameters { diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index fb9d29cc..52cfaa80 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -1,5 +1,6 @@ export const enum HTTPErrorCode { OK = 200, + UNAUTHORIZED = 401, NOT_FOUND = 404, TOO_MANY_REQUESTS = 429, INTERNAL_SERVER_ERROR = 500, diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 270145e8..1de7fec2 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -7,7 +7,7 @@ import { VolumeEventManager } from './volumeEventManager'; import { EventManager } from './eventManager'; import { SharesManager } from '../shares/manager'; -export type { DriveEvent, DriveListener } from './interface'; +export type { DriveEvent, DriveListener, EventSubscription } from './interface'; export { DriveEventType } from './interface'; const OWN_VOLUME_POLLING_INTERVAL = 30; diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index 574bd894..8d9ba19a 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -18,10 +18,7 @@ describe('nodesCryptoCache', () => { beforeEach(async () => { memoryCache = new MemoryCache(); - await memoryCache.setEntity('nodeKeys-missingPassphrase', { - key: 'privateKey', - sessionKey: 'sessionKey', - } as any); + await memoryCache.setEntity('nodeKeys-missingProperties', {} as any); cache = new NodesCryptoCache(getMockLogger(), memoryCache); }); @@ -96,14 +93,14 @@ describe('nodesCryptoCache', () => { it('should throw an error when retrieving a bad keys and remove the key', async () => { try { - await cache.getNodeKeys('missingPassphrase'); + await cache.getNodeKeys('missingProperties'); throw new Error('Should have thrown an error'); } catch (error) { - expect(`${error}`).toBe('Error: Failed to deserialize node keys: missing passphrase'); + expect(`${error}`).toBe('Error: Failed to deserialize node keys'); } try { - await memoryCache.getEntity('nodeKeys-missingPassphrase'); + await memoryCache.getEntity('nodeKeys-missingProperties'); throw new Error('Should have thrown an error'); } catch (error) { expect(`${error}`).toBe('Error: Entity not found'); diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts index 20ea3020..a0731b33 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -18,12 +18,14 @@ export class NodesCryptoCache { async setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys): Promise { const cacheUid = getCacheKey(nodeUid); - await this.driveCache.setEntity(cacheUid, keys); + await this.driveCache.setEntity(cacheUid, { + nodeKeys: keys, + }); } async getNodeKeys(nodeUid: string): Promise { const nodeKeysData = await this.driveCache.getEntity(getCacheKey(nodeUid)); - if (!nodeKeysData.passphrase) { + if (!nodeKeysData.nodeKeys) { try { await this.removeNodeKeys([nodeUid]); } catch (removingError: unknown) { @@ -33,12 +35,9 @@ export class NodesCryptoCache { `Failed to remove corrupted node keys from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, ); } - throw new Error(`Failed to deserialize node keys: missing passphrase`); + throw new Error(`Failed to deserialize node keys`); } - return { - ...nodeKeysData, - passphrase: nodeKeysData.passphrase, - }; + return nodeKeysData.nodeKeys; } async removeNodeKeys(nodeUids: string[]): Promise { diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index a1ccc2cb..78aff40e 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -18,6 +18,8 @@ import { interface BaseNode { // Internal metadata hash?: string; // root node doesn't have any hash + // ecnryptedName should not be needed to keep, nameSessionKey should be enough. + // We will improve this in the future. encryptedName: string; // Basic node metadata diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts index c5c6f549..f858057f 100644 --- a/js/sdk/src/internal/shares/cryptoCache.test.ts +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -1,6 +1,7 @@ import { PrivateKey, SessionKey } from '../../crypto'; import { MemoryCache } from '../../cache'; import { CachedCryptoMaterial } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; import { SharesCryptoCache } from './cryptoCache'; describe('sharesCryptoCache', () => { @@ -17,7 +18,7 @@ describe('sharesCryptoCache', () => { beforeEach(() => { memoryCache = new MemoryCache(); - cache = new SharesCryptoCache(memoryCache); + cache = new SharesCryptoCache(getMockLogger(), memoryCache); }); it('should store and retrieve keys', async () => { @@ -53,7 +54,7 @@ describe('sharesCryptoCache', () => { const keys = { key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey') }; await cache.setShareKey(shareId, keys); - await cache.removeShareKey([shareId]); + await cache.removeShareKeys([shareId]); try { await cache.getShareKey(shareId); diff --git a/js/sdk/src/internal/shares/cryptoCache.ts b/js/sdk/src/internal/shares/cryptoCache.ts index b2b6bffb..be796ef4 100644 --- a/js/sdk/src/internal/shares/cryptoCache.ts +++ b/js/sdk/src/internal/shares/cryptoCache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveCryptoCache } from '../../interface'; +import { Logger, ProtonDriveCryptoCache } from '../../interface'; import { DecryptedShareKey } from './interface'; /** @@ -13,23 +13,42 @@ import { DecryptedShareKey } from './interface'; * only the root node, thus share cache is not needed. */ export class SharesCryptoCache { - constructor(private driveCache: ProtonDriveCryptoCache) { + constructor( + private logger: Logger, + private driveCache: ProtonDriveCryptoCache, + ) { + this.logger = logger; this.driveCache = driveCache; } async setShareKey(shareId: string, key: DecryptedShareKey): Promise { - await this.driveCache.setEntity(getCacheUid(shareId), key); + await this.driveCache.setEntity(getCacheKey(shareId), { + shareKey: key, + }); } async getShareKey(shareId: string): Promise { - return this.driveCache.getEntity(getCacheUid(shareId)); + const nodeKeysData = await this.driveCache.getEntity(getCacheKey(shareId)); + if (!nodeKeysData.shareKey) { + try { + await this.removeShareKeys([shareId]); + } catch (removingError: unknown) { + // The node keys will not be returned, thus SDK will re-fetch + // and re-cache it. Setting it again should then fix the problem. + this.logger.warn( + `Failed to remove corrupted node keys from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, + ); + } + throw new Error(`Failed to deserialize node keys`); + } + return nodeKeysData.shareKey; } - async removeShareKey(shareIds: string[]): Promise { - await this.driveCache.removeEntities(shareIds.map(getCacheUid)); + async removeShareKeys(shareIds: string[]): Promise { + await this.driveCache.removeEntities(shareIds.map(getCacheKey)); } } -function getCacheUid(shareId: string) { +function getCacheKey(shareId: string) { return `shareKey-${shareId}`; } diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts index f76014a4..7d87cbf6 100644 --- a/js/sdk/src/internal/shares/index.ts +++ b/js/sdk/src/internal/shares/index.ts @@ -33,7 +33,7 @@ export function initSharesModule( ) { const api = new SharesAPIService(apiService); const cache = new SharesCache(telemetry.getLogger('shares-cache'), driveEntitiesCache); - const cryptoCache = new SharesCryptoCache(driveCryptoCache); + const cryptoCache = new SharesCryptoCache(telemetry.getLogger('shares-cache'), driveCryptoCache); const cryptoService = new SharesCryptoService(telemetry, crypto, account); const sharesManager = new SharesManager( telemetry.getLogger('shares'), diff --git a/js/sdk/src/internal/sharingPublic/apiService.ts b/js/sdk/src/internal/sharingPublic/apiService.ts new file mode 100644 index 00000000..dbf1d675 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/apiService.ts @@ -0,0 +1,164 @@ +import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType } from '../apiService'; +import { Logger } from '../../interface'; +import { makeNodeUid, splitNodeUid } from '../uids'; +import { EncryptedShareCrypto, EncryptedNode } from './interface'; + +const PAGE_SIZE = 50; + +type GetTokenInfoResponse = drivePaths['/drive/urls/{token}']['get']['responses']['200']['content']['application/json']; + +type GetTokenFolderChildrenResponse = + drivePaths['/drive/urls/{token}/folders/{linkID}/children']['get']['responses']['200']['content']['application/json']; + +/** + * Provides API communication for accessing public link data. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ +export class SharingPublicAPIService { + constructor( + private logger: Logger, + private apiService: DriveAPIService, + ) { + this.logger = logger; + this.apiService = apiService; + } + + async getPublicLinkRoot(token: string): Promise<{ + encryptedNode: EncryptedNode; + encryptedShare: EncryptedShareCrypto; + }> { + const response = await this.apiService.get(`drive/urls/${token}`); + const encryptedNode = tokenToEncryptedNode(this.logger, response.Token); + + return { + encryptedNode: encryptedNode, + encryptedShare: { + base64UrlPasswordSalt: response.Token.SharePasswordSalt, + armoredKey: response.Token.ShareKey, + armoredPassphrase: response.Token.SharePassphrase, + }, + }; + } + + async *iterateChildren(parentUid: string): AsyncGenerator { + const { volumeId: token, nodeId } = splitNodeUid(parentUid); + + let page = 0; + while (true) { + const response = await this.apiService.get( + `drive/urls/${token}/folders/${nodeId}/children?Page=${page}&PageSize=${PAGE_SIZE}`, + ); + + for (const link of response.Links) { + yield linkToEncryptedNode(this.logger, token, link); + } + + if (response.Links.length < PAGE_SIZE) { + break; + } + page++; + } + } +} + +function tokenToEncryptedNode(logger: Logger, token: GetTokenInfoResponse['Token']): EncryptedNode { + const baseNodeMetadata = { + // Internal metadata + encryptedName: token.Name, + + // Basic node metadata + uid: makeNodeUid(token.Token, token.LinkID), + parentUid: undefined, + type: nodeTypeNumberToNodeType(logger, token.LinkType), + }; + + const baseCryptoNodeMetadata = { + signatureEmail: token.SignatureEmail || undefined, + armoredKey: token.NodeKey, + armoredNodePassphrase: token.NodePassphrase, + armoredNodePassphraseSignature: token.NodePassphraseSignature || undefined, + }; + + if (token.LinkType === 1 && token.NodeHashKey) { + return { + ...baseNodeMetadata, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + folder: { + armoredHashKey: token.NodeHashKey as string, + }, + }, + }; + } + + if (token.LinkType === 2 && token.ContentKeyPacket) { + return { + ...baseNodeMetadata, + totalStorageSize: token.Size || undefined, + mediaType: token.MIMEType || undefined, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + file: { + base64ContentKeyPacket: token.ContentKeyPacket, + }, + }, + }; + } + + throw new Error(`Unknown node type: ${token.LinkType}`); +} + +function linkToEncryptedNode( + logger: Logger, + token: string, + link: GetTokenFolderChildrenResponse['Links'][0], +): EncryptedNode { + const baseNodeMetadata = { + // Internal metadata + hash: link.Hash || undefined, + encryptedName: link.Name, + + // Basic node metadata + uid: makeNodeUid(token, link.LinkID), + parentUid: link.ParentLinkID ? makeNodeUid(token, link.ParentLinkID) : undefined, + type: nodeTypeNumberToNodeType(logger, link.Type), + totalStorageSize: link.TotalSize, + }; + + const baseCryptoNodeMetadata = { + signatureEmail: link.SignatureEmail || undefined, + armoredKey: link.NodeKey, + armoredNodePassphrase: link.NodePassphrase, + armoredNodePassphraseSignature: link.NodePassphraseSignature || undefined, + }; + + if (link.Type === 1 && link.FolderProperties) { + return { + ...baseNodeMetadata, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + folder: { + armoredHashKey: link.FolderProperties.NodeHashKey as string, + }, + }, + }; + } + + if (link.Type === 2 && link.FileProperties?.ContentKeyPacket) { + return { + ...baseNodeMetadata, + totalStorageSize: link.FileProperties.ActiveRevision?.Size || undefined, + mediaType: link.MIMEType || undefined, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + file: { + base64ContentKeyPacket: link.FileProperties.ContentKeyPacket, + }, + }, + }; + } + + throw new Error(`Unknown node type: ${link.Type}`); +} diff --git a/js/sdk/src/internal/sharingPublic/cryptoCache.ts b/js/sdk/src/internal/sharingPublic/cryptoCache.ts new file mode 100644 index 00000000..058db3c4 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/cryptoCache.ts @@ -0,0 +1,79 @@ +import { PrivateKey } from '../../crypto'; +import { ProtonDriveCryptoCache, Logger } from '../../interface'; +import { DecryptedNodeKeys } from './interface'; + +/** + * Provides caching for public link crypto material. + * + * The cache is responsible for serialising and deserialising public link + * crypto material. + */ +export class SharingPublicCryptoCache { + constructor( + private logger: Logger, + private driveCache: ProtonDriveCryptoCache, + ) { + this.logger = logger; + this.driveCache = driveCache; + } + + async setShareKey(shareKey: PrivateKey): Promise { + await this.driveCache.setEntity(getShareKeyCacheKey(), { + publicShareKey: { + key: shareKey, + }, + }); + } + + async getShareKey(): Promise { + const shareKeyData = await this.driveCache.getEntity(getShareKeyCacheKey()); + if (!shareKeyData.publicShareKey) { + try { + await this.driveCache.removeEntities([getShareKeyCacheKey()]); + } catch (removingError: unknown) { + this.logger.warn( + `Failed to remove corrupted public share key from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, + ); + } + throw new Error('Failed to deserialize public share key'); + } + return shareKeyData.publicShareKey.key; + } + + async setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys): Promise { + const cacheUid = getNodeCacheKey(nodeUid); + await this.driveCache.setEntity(cacheUid, { + nodeKeys: keys, + }); + } + + async getNodeKeys(nodeUid: string): Promise { + const nodeKeysData = await this.driveCache.getEntity(getNodeCacheKey(nodeUid)); + if (!nodeKeysData.nodeKeys) { + try { + await this.removeNodeKeys([nodeUid]); + } catch (removingError: unknown) { + // The node keys will not be returned, thus SDK will re-fetch + // and re-cache it. Setting it again should then fix the problem. + this.logger.warn( + `Failed to remove corrupted public node keys from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, + ); + } + throw new Error(`Failed to deserialize public node keys`); + } + return nodeKeysData.nodeKeys; + } + + async removeNodeKeys(nodeUids: string[]): Promise { + const cacheUids = nodeUids.map(getNodeCacheKey); + await this.driveCache.removeEntities(cacheUids); + } +} + +function getShareKeyCacheKey() { + return 'publicShareKey'; +} + +function getNodeCacheKey(nodeUid: string) { + return `publicNodeKeys-${nodeUid}`; +} diff --git a/js/sdk/src/internal/sharingPublic/cryptoService.ts b/js/sdk/src/internal/sharingPublic/cryptoService.ts new file mode 100644 index 00000000..4832a543 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/cryptoService.ts @@ -0,0 +1,162 @@ +import { DriveCrypto, PrivateKey } from '../../crypto'; +import { resultOk, resultError, Result } from '../../interface'; +import { getErrorMessage } from '../errors'; +import { EncryptedShareCrypto, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from './interface'; + +/** + * Provides crypto operations for public link data. + * + * The public link crypto service is responsible for decrypting and encrypting + * public link data. It should export high-level actions only, such as "decrypt + * share key" instead of low-level operations like "decrypt key". Low-level + * operations should be kept private to the module. + */ +export class SharingPublicCryptoService { + constructor( + private driveCrypto: DriveCrypto, + private password: string, + ) { + this.driveCrypto = driveCrypto; + this.password = password; + } + + async decryptShareKey(encryptedShare: EncryptedShareCrypto): Promise { + const { key: shareKey } = await this.driveCrypto.decryptKeyWithSrpPassword( + this.password, + encryptedShare.base64UrlPasswordSalt, + encryptedShare.armoredKey, + encryptedShare.armoredPassphrase, + ); + return shareKey; + } + + // TODO: verfiy it has all needed + async decryptNode( + node: EncryptedNode, + parentKey: PrivateKey, + ): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { + const commonNodeMetadata = { + ...node, + encryptedCrypto: undefined, + }; + + const { name } = await this.decryptName(node, parentKey); + + let passphrase, key, passphraseSessionKey; + try { + const keyResult = await this.decryptKey(node, parentKey); + passphrase = keyResult.passphrase; + key = keyResult.key; + passphraseSessionKey = keyResult.passphraseSessionKey; + } catch (error: unknown) { + return { + node: { + ...commonNodeMetadata, + name, + errors: [error], + }, + }; + } + + const errors = []; + + let hashKey; + if ('folder' in node.encryptedCrypto) { + try { + const hashKeyResult = await this.decryptHashKey(node, key); + hashKey = hashKeyResult.hashKey; + } catch (error: unknown) { + errors.push(error); + } + } + + let contentKeyPacketSessionKey; + if ('file' in node.encryptedCrypto) { + try { + const keySessionKeyResult = await this.driveCrypto.decryptAndVerifySessionKey( + node.encryptedCrypto.file.base64ContentKeyPacket, + '', + key, + [], + ); + + contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; + } catch (error: unknown) { + errors.push(error); + } + } + + return { + node: { + ...commonNodeMetadata, + name, + errors: errors.length ? errors : undefined, + }, + keys: { + passphrase, + key, + passphraseSessionKey, + contentKeyPacketSessionKey, + hashKey, + }, + }; + } + + private async decryptKey(node: EncryptedNode, parentKey: PrivateKey): Promise { + const key = await this.driveCrypto.decryptKey( + node.encryptedCrypto.armoredKey, + node.encryptedCrypto.armoredNodePassphrase, + '', + [parentKey], + [], + ); + + return { + passphrase: key.passphrase, + key: key.key, + passphraseSessionKey: key.passphraseSessionKey, + }; + } + + private async decryptName( + node: EncryptedNode, + parentKey: PrivateKey, + ): Promise<{ + name: Result; + }> { + try { + const { name } = await this.driveCrypto.decryptNodeName(node.encryptedName, parentKey, []); + + return { + name: resultOk(name), + }; + } catch (error: unknown) { + const errorMessage = getErrorMessage(error); + return { + name: resultError(new Error(errorMessage)), + }; + } + } + + private async decryptHashKey( + node: EncryptedNode, + nodeKey: PrivateKey, + ): Promise<{ + hashKey: Uint8Array; + }> { + if (!('folder' in node.encryptedCrypto)) { + // This is developer error. + throw new Error('Node is not a folder'); + } + + const { hashKey } = await this.driveCrypto.decryptNodeHashKey( + node.encryptedCrypto.folder.armoredHashKey, + nodeKey, + [], + ); + + return { + hashKey, + }; + } +} diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts new file mode 100644 index 00000000..72197aa4 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -0,0 +1,40 @@ +import { DriveCrypto } from '../../crypto'; +import { ProtonDriveCryptoCache, ProtonDriveTelemetry } from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { SharingPublicAPIService } from './apiService'; +import { SharingPublicCryptoCache } from './cryptoCache'; +import { SharingPublicCryptoService } from './cryptoService'; +import { SharingPublicManager } from './manager'; + +export { SharingPublicSessionManager } from './session/manager'; + +/** + * Provides facade for the whole sharing public module. + * + * The sharing public module is responsible for handling public link data, including + * API communication, encryption, decryption, and caching. + * + * This facade provides internal interface that other modules can use to + * interact with the public links. + */ +export function initSharingPublicModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveCryptoCache: ProtonDriveCryptoCache, + driveCrypto: DriveCrypto, + token: string, + password: string, +) { + const api = new SharingPublicAPIService(telemetry.getLogger('sharingPublic-api'), apiService); + const cryptoCache = new SharingPublicCryptoCache(telemetry.getLogger('sharingPublic-crypto'), driveCryptoCache); + const cryptoService = new SharingPublicCryptoService(driveCrypto, password); + const manager = new SharingPublicManager( + telemetry.getLogger('sharingPublic-nodes'), + api, + cryptoCache, + cryptoService, + token, + ); + + return manager; +} diff --git a/js/sdk/src/internal/sharingPublic/interface.ts b/js/sdk/src/internal/sharingPublic/interface.ts new file mode 100644 index 00000000..ca14326c --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/interface.ts @@ -0,0 +1,59 @@ +import { PrivateKey, SessionKey } from "../../crypto"; +import { NodeType, Result, InvalidNameError } from "../../interface"; + +export interface EncryptedShareCrypto { + base64UrlPasswordSalt: string; + armoredKey: string; + armoredPassphrase: string; +} + +// TODO: reuse node entity, or keep custom? +interface BaseNode { + // Internal metadata + hash?: string; // root node doesn't have any hash + encryptedName: string; + + // Basic node metadata + uid: string; + parentUid?: string; + type: NodeType; + mediaType?: string; + totalStorageSize?: number; +} + +export interface EncryptedNode extends BaseNode { + encryptedCrypto: EncryptedNodeFolderCrypto | EncryptedNodeFileCrypto; +} + +export interface EncryptedNodeCrypto { + signatureEmail?: string; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature?: string; +} + +export interface EncryptedNodeFileCrypto extends EncryptedNodeCrypto { + file: { + base64ContentKeyPacket: string; + }; +} + +export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { + folder: { + armoredExtendedAttributes?: string; + armoredHashKey: string; + }; +} + +export interface DecryptedNode extends BaseNode { + name: Result; + errors?: unknown[]; +} + +export interface DecryptedNodeKeys { + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + contentKeyPacketSessionKey?: SessionKey; + hashKey?: Uint8Array; +} diff --git a/js/sdk/src/internal/sharingPublic/manager.ts b/js/sdk/src/internal/sharingPublic/manager.ts new file mode 100644 index 00000000..b89116f9 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/manager.ts @@ -0,0 +1,85 @@ +import { PrivateKey } from '../../crypto'; +import { Logger } from '../../interface'; +import { SharingPublicAPIService } from './apiService'; +import { SharingPublicCryptoCache } from './cryptoCache'; +import { SharingPublicCryptoService } from './cryptoService'; +import { EncryptedShareCrypto, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from './interface'; + +// TODO: comment +export class SharingPublicManager { + constructor( + private logger: Logger, + private api: SharingPublicAPIService, + private cryptoCache: SharingPublicCryptoCache, + private cryptoService: SharingPublicCryptoService, + private token: string, + ) { + this.logger = logger; + this.api = api; + this.cryptoCache = cryptoCache; + this.cryptoService = cryptoService; + this.token = token; + } + + async getRootNode(): Promise { + const { encryptedNode, encryptedShare } = await this.api.getPublicLinkRoot(this.token); + await this.decryptShare(encryptedShare); + return this.decryptNode(encryptedNode); + } + + async *iterateChildren(parentUid: string): AsyncGenerator { + // TODO: optimise this - decrypt in parallel + for await (const node of this.api.iterateChildren(parentUid)) { + const decryptedNode = await this.decryptNode(node); + yield decryptedNode; + } + } + + private async decryptShare(encryptedShare: EncryptedShareCrypto): Promise { + const shareKey = await this.cryptoService.decryptShareKey(encryptedShare); + await this.cryptoCache.setShareKey(shareKey); + } + + private async decryptNode(encryptedNode: EncryptedNode): Promise { + const parentKey = await this.getParentKey(encryptedNode); + + const { node: decryptedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); + + // TODO: cache of metadata? + + if (keys) { + try { + await this.cryptoCache.setNodeKeys(decryptedNode.uid, keys); + } catch (error: unknown) { + this.logger.error(`Failed to cache node keys ${decryptedNode.uid}`, error); + } + } + + return decryptedNode; + } + + private async getParentKey(node: Pick): Promise { + if (node.parentUid) { + // TODO: try-catch + const keys = await this.getNodeKeys(node.parentUid); + return keys.key; + } + + try { + return await this.cryptoCache.getShareKey(); + } catch { + await this.getRootNode(); + return this.cryptoCache.getShareKey(); + } + } + + async getNodeKeys(nodeUid: string): Promise { + try { + const keys = await this.cryptoCache.getNodeKeys(nodeUid); + return keys; + } catch { + // TODO: handle this + throw new Error('Node key not found in cache'); + } + } +} diff --git a/js/sdk/src/internal/sharingPublic/session/apiService.ts b/js/sdk/src/internal/sharingPublic/session/apiService.ts new file mode 100644 index 00000000..fb0edffb --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/apiService.ts @@ -0,0 +1,74 @@ +import { DriveAPIService, drivePaths } from '../../apiService'; +import { PublicLinkInfo, PublicLinkSrpAuth } from './interface'; + +type GetPublicLinkInfoResponse = + drivePaths['/drive/urls/{token}/info']['get']['responses']['200']['content']['application/json']; + +type PostPublicLinkAuthRequest = Extract< + drivePaths['/drive/urls/{token}/auth']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostPublicLinkAuthResponse = + drivePaths['/drive/urls/{token}/auth']['post']['responses']['200']['content']['application/json']; + +/** + * Provides API communication for managing public link session (not data). + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ +export class SharingPublicSessionAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; + } + + /** + * Start a SRP handshake for public link session. + */ + async initPublicLinkSession(token: string): Promise { + const response = await this.apiService.get(`drive/urls/${token}/info`); + return { + srp: { + version: response.Version, + modulus: response.Modulus, + serverEphemeral: response.ServerEphemeral, + salt: response.UrlPasswordSalt, + srpSession: response.SRPSession, + }, + isCustomPasswordProtected: (response.Flags & 1) === 1, + isLegacy: response.Flags === 0 || response.Flags === 1, + vendorType: response.VendorType, + }; + } + + /** + * Authenticate a public link session. + * + * It returns the server proof that must be validated, and the session uid + * with an optional access token. The access token is only returned if + * the session is newly created. + */ + async authPublicLinkSession( + token: string, + srp: PublicLinkSrpAuth, + ): Promise<{ + serverProof: string; + sessionUid: string; + sessionAccessToken?: string; + }> { + const response = await this.apiService.post( + `drive/urls/${token}/auth`, + { + ClientProof: srp.clientProof, + ClientEphemeral: srp.clientEphemeral, + SRPSession: srp.srpSession, + }, + ); + + return { + serverProof: response.ServerProof, + sessionUid: response.UID, + sessionAccessToken: response.AccessToken, + }; + } +} diff --git a/js/sdk/src/internal/sharingPublic/session/httpClient.ts b/js/sdk/src/internal/sharingPublic/session/httpClient.ts new file mode 100644 index 00000000..291304f1 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/httpClient.ts @@ -0,0 +1,44 @@ +import { ProtonDriveHTTPClient, ProtonDriveHTTPClientBlobOptions, ProtonDriveHTTPClientJsonOptions } from "../../../interface"; +import { HTTPErrorCode } from "../../apiService"; +import { SharingPublicLinkSession } from './session'; + +/** + * HTTP client to get access to public link of given session. + * + * It is responsible for adding the session headers to the request if the session + * is authenticated, and re-authenticating the session if the session is expired. + */ +export class SharingPublicSessionHttpClient implements ProtonDriveHTTPClient { + constructor( + private httpClient: ProtonDriveHTTPClient, + private session: SharingPublicLinkSession, + ) { + this.httpClient = httpClient; + this.session = session; + } + + async fetchJson(options: ProtonDriveHTTPClientJsonOptions) { + const response = await this.httpClient.fetchJson(this.getOptionsWithSessionHeaders(options)); + + if (response.status === HTTPErrorCode.UNAUTHORIZED) { + await this.session.reauth(); + return this.httpClient.fetchJson(this.getOptionsWithSessionHeaders(options)); + } + + return response; + } + + async fetchBlob(options: ProtonDriveHTTPClientBlobOptions) { + return this.httpClient.fetchBlob(this.getOptionsWithSessionHeaders(options)); + } + + private getOptionsWithSessionHeaders(options: ProtonDriveHTTPClientJsonOptions) { + // Set headers if the session is newly created. + // This is needed only if the user is not logged in. + if (this.session.session.accessToken) { + options.headers.set('x-pm-uid', this.session.session.uid); + options.headers.set('Authorization', `Bearer ${this.session.session.accessToken}`); + } + return options; + } +} diff --git a/js/sdk/src/internal/sharingPublic/session/index.ts b/js/sdk/src/internal/sharingPublic/session/index.ts new file mode 100644 index 00000000..b5169c65 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/index.ts @@ -0,0 +1 @@ +export { SharingPublicSessionManager } from './manager'; diff --git a/js/sdk/src/internal/sharingPublic/session/interface.ts b/js/sdk/src/internal/sharingPublic/session/interface.ts new file mode 100644 index 00000000..6a914a35 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/interface.ts @@ -0,0 +1,20 @@ +export type PublicLinkInfo = { + srp: PublicLinkSrpInfo; + isCustomPasswordProtected: boolean; + isLegacy: boolean; + vendorType: number; +}; + +export type PublicLinkSrpInfo = { + version: number; + modulus: string; + serverEphemeral: string; + salt: string; + srpSession: string; +}; + +export type PublicLinkSrpAuth = { + clientProof: string; + clientEphemeral: string; + srpSession: string; +}; diff --git a/js/sdk/src/internal/sharingPublic/session/manager.ts b/js/sdk/src/internal/sharingPublic/session/manager.ts new file mode 100644 index 00000000..6b6effc1 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/manager.ts @@ -0,0 +1,97 @@ +import { ProtonDriveHTTPClient } from '../../../interface'; +import { SRPModule } from '../../../crypto'; +import { DriveAPIService } from '../../apiService'; +import { SharingPublicSessionAPIService } from './apiService'; +import { SharingPublicSessionHttpClient } from './httpClient'; +import { PublicLinkInfo } from './interface'; +import { SharingPublicLinkSession } from './session'; +import { getTokenAndPasswordFromUrl } from './url'; + +/** + * Manages sessions for public links. + * + * It can be used to get access to multiple public links. + */ +export class SharingPublicSessionManager { + private api: SharingPublicSessionAPIService; + + private infosPerToken: Map = new Map(); + + constructor( + private httpClient: ProtonDriveHTTPClient, + apiService: DriveAPIService, + private srpModule: SRPModule, + ) { + this.httpClient = httpClient; + this.srpModule = srpModule; + + this.api = new SharingPublicSessionAPIService(apiService); + } + + /** + * Get the info for a public link. + * + * It returns the info for the public link, including if it is custom + * password protected, if it is legacy (not supported anymore), and + * the vendor type (whether it is Proton Docs, for example, and should + * be redirected to the public Docs app). + * + * @param url - The URL of the public link. + */ + async getInfo(url: string): Promise<{ + isCustomPasswordProtected: boolean; + isLegacy: boolean; + vendorType: number; + }> { + const { token } = getTokenAndPasswordFromUrl(url); + + const info = await this.api.initPublicLinkSession(token); + this.infosPerToken.set(token, info); + + return { + isCustomPasswordProtected: info.isCustomPasswordProtected, + isLegacy: info.isLegacy, + vendorType: info.vendorType, + }; + } + + /** + * Authenticate a public link session. + * + * It returns HTTP client that must be used for the endpoints to access the + * public link data. + * + * It returnes parsed token and full password (password from the URL + + * custom password) that can be used for decrypting the share key. + * + * @param url - The URL of the public link. + * @param customPassword - The custom password for the public link, if it is + * custom password protected. + */ + async auth( + url: string, + customPassword?: string, + ): Promise<{ + token: string; + password: string; + httpClient: SharingPublicSessionHttpClient; + }> { + const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url); + + let info = this.infosPerToken.get(token); + if (!info) { + info = await this.api.initPublicLinkSession(token); + } + + const password = `${urlPassword}${customPassword || ''}`; + + const session = new SharingPublicLinkSession(this.api, this.srpModule, token, password); + await session.auth(info.srp); + + return { + token, + password, + httpClient: new SharingPublicSessionHttpClient(this.httpClient, session), + }; + } +} diff --git a/js/sdk/src/internal/sharingPublic/session/session.ts b/js/sdk/src/internal/sharingPublic/session/session.ts new file mode 100644 index 00000000..f16f2bb8 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/session.ts @@ -0,0 +1,78 @@ +import { SRPModule } from "../../../crypto"; +import { SharingPublicSessionAPIService } from "./apiService"; +import { PublicLinkInfo, PublicLinkSrpInfo } from "./interface"; + +/** + * Session for a public link. + * + * It is responsible for initializing and authenticating the public link session + * with the SRP handshake. It also can re-authenticate the session if it is expired. + */ +export class SharingPublicLinkSession { + private sessionUid?: string; + private sessionAccessToken?: string; + + constructor( + private apiService: SharingPublicSessionAPIService, + private srpModule: SRPModule, + private token: string, + private password: string, + ) { + this.apiService = apiService; + this.srpModule = srpModule; + this.token = token; + this.password = password; + } + + async reauth(): Promise { + const info = await this.init(); + await this.auth(info.srp); + } + + async init(): Promise { + return this.apiService.initPublicLinkSession(this.token); + } + + async auth(srp: PublicLinkSrpInfo): Promise { + const { expectedServerProof, clientProof, clientEphemeral } = await this.srpModule.getSrp( + srp.version, + srp.modulus, + srp.serverEphemeral, + srp.salt, + this.password, + ); + + const auth = await this.apiService.authPublicLinkSession(this.token, { + clientProof, + clientEphemeral, + srpSession: srp.srpSession, + }); + + if (auth.serverProof !== expectedServerProof) { + throw new Error('Invalid server proof'); + } + + this.sessionUid = auth.sessionUid; + this.sessionAccessToken = auth.sessionAccessToken; + } + + /** + * Get the session uid and access token. + * + * The access token is only returned if the session is newly created. + * If the access token is not available, it means the existing session + * can be used to access the public link. + * + * @throws If the session is not initialized. + */ + get session() { + if (!this.sessionUid) { + throw new Error('Session not initialized'); + } + + return { + uid: this.sessionUid, + accessToken: this.sessionAccessToken, + }; + } +} diff --git a/js/sdk/src/internal/sharingPublic/session/url.test.ts b/js/sdk/src/internal/sharingPublic/session/url.test.ts new file mode 100644 index 00000000..a8841b36 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/url.test.ts @@ -0,0 +1,72 @@ +import { ValidationError } from '../../../errors'; +import { getTokenAndPasswordFromUrl } from './url'; + +describe('getTokenAndPasswordFromUrl', () => { + describe('valid URLs', () => { + it('should extract token and password from a valid URL', () => { + const url = 'https://drive.proton.me/urls/abc123#def456'; + const result = getTokenAndPasswordFromUrl(url); + + expect(result).toEqual({ + token: 'abc123', + password: 'def456' + }); + }); + + it('should handle URLs with different domains', () => { + const url = 'https://example.com/urls/mytoken#mypassword'; + const result = getTokenAndPasswordFromUrl(url); + + expect(result).toEqual({ + token: 'mytoken', + password: 'mypassword' + }); + }); + + it('should handle URLs with query parameters', () => { + const url = 'https://drive.proton.me/urls/token123?param=value#password456'; + const result = getTokenAndPasswordFromUrl(url); + + expect(result).toEqual({ + token: 'token123', + password: 'password456' + }); + }); + }); + + describe('should throw ValidationError', () => { + it('when token is missing (no path)', () => { + const url = 'https://drive.proton.me/#password123'; + + expect(() => getTokenAndPasswordFromUrl(url)).toThrow(ValidationError); + }); + + it('when token is missing (empty path segment)', () => { + const url = 'https://drive.proton.me/urls/#password123'; + + expect(() => getTokenAndPasswordFromUrl(url)).toThrow(ValidationError); + }); + + it('when password is missing (no hash)', () => { + const url = 'https://drive.proton.me/urls/token123'; + + expect(() => getTokenAndPasswordFromUrl(url)).toThrow(ValidationError); + expect(() => getTokenAndPasswordFromUrl(url)).toThrow('Invalid URL'); + }); + + it('when password is empty (empty hash)', () => { + const url = 'https://drive.proton.me/urls/token123#'; + + expect(() => getTokenAndPasswordFromUrl(url)).toThrow(ValidationError); + expect(() => getTokenAndPasswordFromUrl(url)).toThrow('Invalid URL'); + }); + + it('for empty string', () => { + expect(() => getTokenAndPasswordFromUrl('')).toThrow(); + }); + + it('for invalid URL format', () => { + expect(() => getTokenAndPasswordFromUrl('not-a-url')).toThrow(); + }); + }); +}); diff --git a/js/sdk/src/internal/sharingPublic/session/url.ts b/js/sdk/src/internal/sharingPublic/session/url.ts new file mode 100644 index 00000000..f130f467 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/url.ts @@ -0,0 +1,23 @@ +import { c } from 'ttag'; + +import { ValidationError } from '../../../errors'; + +/** + * Parse the token and password from the URL. + * + * The URL format is: https://drive.proton.me/urls/token#password + * + * @param url - The URL of the public link. + * @returns The token and password. + */ +export function getTokenAndPasswordFromUrl(url: string): { token: string; password: string } { + const urlObj = new URL(url); + const token = urlObj.pathname.split('/').pop(); + const password = urlObj.hash.slice(1); + + if (!token || !password) { + throw new ValidationError(c('Error').t`Invalid URL`); + } + + return { token, password }; +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 7383d3cd..27e2aa49 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,3 +1,5 @@ +import { getConfig } from './config'; +import { DriveCrypto, SessionKey } from './crypto'; import { Logger, ProtonDriveClientContructorParameters, @@ -26,16 +28,6 @@ import { ThumbnailResult, SDKEvent, } from './interface'; -import { DriveCrypto, SessionKey } from './crypto'; -import { DriveAPIService } from './internal/apiService'; -import { initSharesModule } from './internal/shares'; -import { initNodesModule } from './internal/nodes'; -import { initSharingModule } from './internal/sharing'; -import { initDownloadModule } from './internal/download'; -import { initUploadModule } from './internal/upload'; -import { DriveEventsService, DriveListener } from './internal/events'; -import { SDKEvents } from './internal/sdkEvents'; -import { getConfig } from './config'; import { getUid, getUids, @@ -45,9 +37,18 @@ import { convertInternalNode, } from './transformers'; import { Telemetry } from './telemetry'; +import { DriveAPIService } from './internal/apiService'; import { initDevicesModule } from './internal/devices'; +import { initDownloadModule } from './internal/download'; +import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; +import { initNodesModule } from './internal/nodes'; +import { SDKEvents } from './internal/sdkEvents'; +import { initSharesModule } from './internal/shares'; +import { initSharingModule } from './internal/sharing'; +import { SharingPublicSessionManager } from './internal/sharingPublic'; +import { initUploadModule } from './internal/upload'; import { makeNodeUid } from './internal/uids'; -import { EventSubscription } from './internal/events/interface'; +import { ProtonDrivePublicLinkClient } from './protonDrivePublicLinkClient'; /** * ProtonDriveClient is the main interface for the ProtonDrive SDK. @@ -66,6 +67,7 @@ export class ProtonDriveClient { private download: ReturnType; private upload: ReturnType; private devices: ReturnType; + private sessionManager: SharingPublicSessionManager; public experimental: { /** @@ -82,6 +84,20 @@ export class ProtonDriveClient { * This is used by Docs app to encrypt and decrypt document updates. */ getDocsKey: (nodeUid: NodeOrUid) => Promise; + /** + * Experimental feature to get the info for a public link + * required to authenticate the public link. + */ + getPublicLinkInfo: (url: string) => Promise<{ + isCustomPasswordProtected: boolean; + isLegacy: boolean; + vendorType: number; + }>; + /** + * Experimental feature to authenticate a public link and + * return the client for the public link to access it. + */ + authPublicLink: (url: string, customPassword?: string) => Promise; }; constructor({ @@ -167,6 +183,8 @@ export class ProtonDriveClient { latestEventIdProvider, ); + this.sessionManager = new SharingPublicSessionManager(httpClient, apiService, srpModule); + this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); @@ -180,6 +198,24 @@ export class ProtonDriveClient { } return keys.contentKeyPacketSessionKey; }, + getPublicLinkInfo: async (url: string) => { + this.logger.info(`Getting info for public link ${url}`); + return this.sessionManager.getInfo(url); + }, + authPublicLink: async (url: string, customPassword?: string) => { + this.logger.info(`Authenticating public link ${url}`); + const { httpClient, token, password } = await this.sessionManager.auth(url, customPassword); + return new ProtonDrivePublicLinkClient({ + httpClient, + cryptoCache, + openPGPCryptoModule, + srpModule, + config, + telemetry, + token, + password, + }); + }, }; } diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts new file mode 100644 index 00000000..34e7f250 --- /dev/null +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -0,0 +1,121 @@ +import { getConfig } from './config'; +import { DriveCrypto, OpenPGPCrypto, SRPModule, SessionKey } from './crypto'; +import { + ProtonDriveHTTPClient, + ProtonDriveTelemetry, + ProtonDriveConfig, + Logger, + ProtonDriveCryptoCache, + NodeOrUid, +} from './interface'; +import { Telemetry } from './telemetry'; +import { getUid } from './transformers'; +import { DriveAPIService } from './internal/apiService'; +import { SDKEvents } from './internal/sdkEvents'; +import { initSharingPublicModule } from './internal/sharingPublic'; + +/** + * ProtonDrivePublicLinkClient is the interface for the public link client. + * + * The client provides high-level operations for managing nodes, and + * downloading/uploading files. + * + * Do not use this client direclty, use ProtonDriveClient instead. + * The main client handles public link sessions and provides access to + * public links. + * + * See `experimental.getPublicLinkInfo` and `experimental.authPublicLink` + * for more information. + */ +export class ProtonDrivePublicLinkClient { + private logger: Logger; + private sdkEvents: SDKEvents; + private sharingPublic: ReturnType; + + public experimental: { + /** + * Experimental feature to return the URL of the node. + * + * Use it when you want to open the node in the ProtonDrive web app. + * + * It has hardcoded URLs to open in production client only. + */ + getNodeUrl: (nodeUid: NodeOrUid) => Promise; + /** + * Experimental feature to get the docs key for a node. + * + * This is used by Docs app to encrypt and decrypt document updates. + */ + getDocsKey: (nodeUid: NodeOrUid) => Promise; + }; + + constructor({ + httpClient, + cryptoCache, + openPGPCryptoModule, + srpModule, + config, + telemetry, + token, + password, + }: { + httpClient: ProtonDriveHTTPClient; + cryptoCache: ProtonDriveCryptoCache; + openPGPCryptoModule: OpenPGPCrypto; + srpModule: SRPModule; + config?: ProtonDriveConfig; + telemetry?: ProtonDriveTelemetry; + token: string; + password: string; + }) { + if (!telemetry) { + telemetry = new Telemetry(); + } + this.logger = telemetry.getLogger('interface'); + + const fullConfig = getConfig(config); + this.sdkEvents = new SDKEvents(telemetry); + + const apiService = new DriveAPIService( + telemetry, + this.sdkEvents, + httpClient, + fullConfig.baseUrl, + fullConfig.language, + ); + const driveCrypto = new DriveCrypto(openPGPCryptoModule, srpModule); + this.sharingPublic = initSharingPublicModule(telemetry, apiService, cryptoCache, driveCrypto, token, password); + + this.experimental = { + getNodeUrl: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); + // TODO: public node has different URL + return ''; + }, + getDocsKey: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting docs keys for ${getUid(nodeUid)}`); + const keys = await this.sharingPublic.getNodeKeys(getUid(nodeUid)); + if (!keys.contentKeyPacketSessionKey) { + throw new Error('Node does not have a content key packet session key'); + } + return keys.contentKeyPacketSessionKey; + }, + }; + } + + // TODO: comment + // TODO: add public node interface + async getRootNode() { + this.logger.info(`Getting root node`); + // TODO: conversion to public node + return this.sharingPublic.getRootNode(); + } + + // TODO: comment + // TODO: add public node interface + async *iterateChildren(parentUid: NodeOrUid) { + this.logger.info(`Iterating children of ${getUid(parentUid)}`); + // TODO: conversion to public node + yield * this.sharingPublic.iterateChildren(getUid(parentUid)); + } +} From 298ff086b876a96f6612aa57a65708d1f89ca0ee Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Sep 2025 12:59:25 +0000 Subject: [PATCH 206/791] Revamp docs guides --- js/sdk/src/diagnostic/httpClient.ts | 8 ++++---- js/sdk/src/interface/httpClient.ts | 10 +++++----- js/sdk/src/interface/index.ts | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/js/sdk/src/diagnostic/httpClient.ts b/js/sdk/src/diagnostic/httpClient.ts index 7492a134..e9ae23cb 100644 --- a/js/sdk/src/diagnostic/httpClient.ts +++ b/js/sdk/src/diagnostic/httpClient.ts @@ -1,7 +1,7 @@ import { ProtonDriveHTTPClient, - ProtonDriveHTTPClientBlobOptions, - ProtonDriveHTTPClientJsonOptions, + ProtonDriveHTTPClientBlobRequest, + ProtonDriveHTTPClientJsonRequest, } from '../interface'; import { EventsGenerator } from './eventsGenerator'; @@ -19,7 +19,7 @@ export class DiagnosticHTTPClient extends EventsGenerator implements ProtonDrive this.httpClient = httpClient; } - async fetchJson(options: ProtonDriveHTTPClientJsonOptions): Promise { + async fetchJson(options: ProtonDriveHTTPClientJsonRequest): Promise { try { const response = await this.httpClient.fetchJson(options); @@ -78,7 +78,7 @@ export class DiagnosticHTTPClient extends EventsGenerator implements ProtonDrive } } - fetchBlob(options: ProtonDriveHTTPClientBlobOptions): Promise { + fetchBlob(options: ProtonDriveHTTPClientBlobRequest): Promise { return this.httpClient.fetchBlob(options); } } diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts index b973f406..3c9af8dd 100644 --- a/js/sdk/src/interface/httpClient.ts +++ b/js/sdk/src/interface/httpClient.ts @@ -1,18 +1,18 @@ export interface ProtonDriveHTTPClient { - fetchJson(options: ProtonDriveHTTPClientJsonOptions): Promise; - fetchBlob(options: ProtonDriveHTTPClientBlobOptions): Promise; + fetchJson(request: ProtonDriveHTTPClientJsonRequest): Promise; + fetchBlob(request: ProtonDriveHTTPClientBlobRequest): Promise; } -export type ProtonDriveHTTPClientJsonOptions = ProtonDriveHTTPClientBaseOptions & { +export type ProtonDriveHTTPClientJsonRequest = ProtonDriveHTTPClientBaseRequest & { json?: object; }; -export type ProtonDriveHTTPClientBlobOptions = ProtonDriveHTTPClientBaseOptions & { +export type ProtonDriveHTTPClientBlobRequest = ProtonDriveHTTPClientBaseRequest & { body?: XMLHttpRequestBodyInit; onProgress?: (progress: number) => void; }; -type ProtonDriveHTTPClientBaseOptions = { +type ProtonDriveHTTPClientBaseRequest = { url: string; method: string; headers: Headers; diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 64c3a118..19bcb6b0 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -27,8 +27,8 @@ export type { export { DriveEventType, SDKEvent } from './events'; export type { ProtonDriveHTTPClient, - ProtonDriveHTTPClientJsonOptions, - ProtonDriveHTTPClientBlobOptions, + ProtonDriveHTTPClientJsonRequest, + ProtonDriveHTTPClientBlobRequest, } from './httpClient'; export type { MaybeNode, From ca96784c5cba8b6939efbbecb8a5b6f78245d9b4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Sep 2025 10:09:12 +0200 Subject: [PATCH 207/791] Fix cache shared by me --- js/sdk/src/internal/sharing/index.ts | 1 + .../sharing/sharingManagement.test.ts | 33 +++++++++++++++++++ .../src/internal/sharing/sharingManagement.ts | 9 +++++ .../sharingPublic/session/httpClient.ts | 14 +++++--- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index 8154b4f6..7d995915 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -32,6 +32,7 @@ export function initSharingModule( const sharingManagement = new SharingManagement( telemetry.getLogger('sharing'), api, + cache, cryptoService, account, sharesService, diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index d70b11ef..e7b7211b 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -10,12 +10,14 @@ import { resultOk, } from '../../interface'; import { SharingAPIService } from './apiService'; +import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; import { SharesService, NodesService } from './interface'; import { SharingManagement } from './sharingManagement'; describe('SharingManagement', () => { let apiService: SharingAPIService; + let cache: SharingCache; let cryptoService: SharingCryptoService; let accountService: ProtonDriveAccount; let sharesService: SharesService; @@ -57,6 +59,12 @@ describe('SharingManagement', () => { updatePublicLink: jest.fn(), }; // @ts-expect-error No need to implement all methods for mocking + cache = { + hasSharedByMeNodeUidsLoaded: jest.fn().mockResolvedValue(true), + addSharedByMeNodeUid: jest.fn(), + removeSharedByMeNodeUid: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking cryptoService = { generateShareKeys: jest.fn().mockResolvedValue({ shareKey: { encrypted: 'encrypted-key', decrypted: { passphraseSessionKey: 'pass-session-key' } }, @@ -104,6 +112,7 @@ describe('SharingManagement', () => { sharingManagement = new SharingManagement( getMockLogger(), apiService, + cache, cryptoService, accountService, sharesService, @@ -214,6 +223,7 @@ describe('SharingManagement', () => { expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(nodeUid); + expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith(nodeUid); }); }); @@ -279,6 +289,7 @@ describe('SharingManagement', () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should share node with proton email with specific role', async () => { @@ -302,6 +313,7 @@ describe('SharingManagement', () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should update existing role', async () => { @@ -322,6 +334,7 @@ describe('SharingManagement', () => { }); expect(apiService.updateInvitation).toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should be no-op if no change', async () => { @@ -337,6 +350,7 @@ describe('SharingManagement', () => { }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should use address from the root node context share', async () => { @@ -383,6 +397,7 @@ describe('SharingManagement', () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should share node with external email with specific role', async () => { @@ -407,6 +422,7 @@ describe('SharingManagement', () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should update existing role', async () => { @@ -427,6 +443,7 @@ describe('SharingManagement', () => { }); expect(apiService.updateExternalInvitation).toHaveBeenCalled(); expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should be no-op if no change', async () => { @@ -442,6 +459,7 @@ describe('SharingManagement', () => { }); expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should use address from the root node context share', async () => { @@ -512,6 +530,7 @@ describe('SharingManagement', () => { }), expect.anything(), ); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); }); @@ -535,6 +554,7 @@ describe('SharingManagement', () => { expect(apiService.updateMember).toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should be no-op if no change via proton user', async () => { @@ -551,6 +571,7 @@ describe('SharingManagement', () => { expect(apiService.updateMember).not.toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should update member via non-proton user', async () => { @@ -572,6 +593,7 @@ describe('SharingManagement', () => { expect(apiService.updateMember).toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should be no-op if no change via non-proton user', async () => { @@ -588,6 +610,7 @@ describe('SharingManagement', () => { expect(apiService.updateMember).not.toHaveBeenCalled(); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); }); @@ -635,6 +658,7 @@ describe('SharingManagement', () => { srp: 'publicLinkSrp', }), ); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should share node with custom password and expiration', async () => { @@ -680,6 +704,7 @@ describe('SharingManagement', () => { srp: 'publicLinkSrp', }), ); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should update public link with custom password and expiration', async () => { @@ -734,6 +759,7 @@ describe('SharingManagement', () => { srp: 'publicLinkSrp', }), ); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should not allow updating legacy public link', async () => { @@ -839,6 +865,7 @@ describe('SharingManagement', () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should delete external invitation', async () => { @@ -855,6 +882,7 @@ describe('SharingManagement', () => { expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should remove member', async () => { @@ -871,6 +899,7 @@ describe('SharingManagement', () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should be no-op if not shared with email', async () => { @@ -887,6 +916,7 @@ describe('SharingManagement', () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should remove public link', async () => { @@ -903,6 +933,7 @@ describe('SharingManagement', () => { expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); }); it('should remove share if all is removed', async () => { @@ -915,6 +946,7 @@ describe('SharingManagement', () => { expect(apiService.removeMember).not.toHaveBeenCalled(); expect(apiService.removePublicLink).not.toHaveBeenCalled(); expect(nodesService.notifyNodeChanged).toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith(nodeUid); }); it('should remove share if everything is manually removed', async () => { @@ -929,6 +961,7 @@ describe('SharingManagement', () => { expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); expect(apiService.removeMember).toHaveBeenCalled(); expect(apiService.removePublicLink).toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith(nodeUid); }); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 3ddffc7d..f208a3a8 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -20,6 +20,7 @@ import { getErrorMessage } from '../errors'; import { SharingAPIService } from './apiService'; import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService'; import { SharesService, NodesService, ShareResultWithCreatorEmail, PublicLinkWithCreatorEmail } from './interface'; +import { SharingCache } from './cache'; interface InternalShareResult extends ShareResultWithCreatorEmail { share: Share; @@ -54,6 +55,7 @@ export class SharingManagement { constructor( private logger: Logger, private apiService: SharingAPIService, + private cache: SharingCache, private cryptoService: SharingCryptoService, private account: ProtonDriveAccount, private sharesService: SharesService, @@ -61,6 +63,7 @@ export class SharingManagement { ) { this.logger = logger; this.apiService = apiService; + this.cache = cache; this.cryptoService = cryptoService; this.account = account; this.sharesService = sharesService; @@ -409,6 +412,9 @@ export class SharingManagement { base64NameKeyPacket: keys.base64NameKeyPacket, }); await this.nodesService.notifyNodeChanged(nodeUid); + if (await this.cache.hasSharedByMeNodeUidsLoaded()) { + await this.cache.addSharedByMeNodeUid(nodeUid); + } const share = { volumeId, @@ -430,6 +436,9 @@ export class SharingManagement { private async deleteShare(shareId: string, nodeUid: string): Promise { await this.apiService.deleteShare(shareId); await this.nodesService.notifyNodeChanged(nodeUid); + if (await this.cache.hasSharedByMeNodeUidsLoaded()) { + await this.cache.removeSharedByMeNodeUid(nodeUid); + } } private async inviteProtonUser( diff --git a/js/sdk/src/internal/sharingPublic/session/httpClient.ts b/js/sdk/src/internal/sharingPublic/session/httpClient.ts index 291304f1..90d854e3 100644 --- a/js/sdk/src/internal/sharingPublic/session/httpClient.ts +++ b/js/sdk/src/internal/sharingPublic/session/httpClient.ts @@ -1,5 +1,9 @@ -import { ProtonDriveHTTPClient, ProtonDriveHTTPClientBlobOptions, ProtonDriveHTTPClientJsonOptions } from "../../../interface"; -import { HTTPErrorCode } from "../../apiService"; +import { + ProtonDriveHTTPClient, + ProtonDriveHTTPClientBlobRequest, + ProtonDriveHTTPClientJsonRequest, +} from '../../../interface'; +import { HTTPErrorCode } from '../../apiService'; import { SharingPublicLinkSession } from './session'; /** @@ -17,7 +21,7 @@ export class SharingPublicSessionHttpClient implements ProtonDriveHTTPClient { this.session = session; } - async fetchJson(options: ProtonDriveHTTPClientJsonOptions) { + async fetchJson(options: ProtonDriveHTTPClientJsonRequest) { const response = await this.httpClient.fetchJson(this.getOptionsWithSessionHeaders(options)); if (response.status === HTTPErrorCode.UNAUTHORIZED) { @@ -28,11 +32,11 @@ export class SharingPublicSessionHttpClient implements ProtonDriveHTTPClient { return response; } - async fetchBlob(options: ProtonDriveHTTPClientBlobOptions) { + async fetchBlob(options: ProtonDriveHTTPClientBlobRequest) { return this.httpClient.fetchBlob(this.getOptionsWithSessionHeaders(options)); } - private getOptionsWithSessionHeaders(options: ProtonDriveHTTPClientJsonOptions) { + private getOptionsWithSessionHeaders(options: ProtonDriveHTTPClientJsonRequest) { // Set headers if the session is newly created. // This is needed only if the user is not logged in. if (this.session.session.accessToken) { From 6c3140f66882ab8a3e718377d371dc4a553c7e0a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Sep 2025 16:35:56 +0200 Subject: [PATCH 208/791] Fix decrpyting bookmark with custom password --- .../internal/sharing/cryptoService.test.ts | 23 ++++++++++++++++++- js/sdk/src/internal/sharing/cryptoService.ts | 14 ++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts index 6f5e27b2..dbbcdde5 100644 --- a/js/sdk/src/internal/sharing/cryptoService.test.ts +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -9,7 +9,7 @@ import { } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; import { SharesService } from './interface'; -import { SharingCryptoService } from './cryptoService'; +import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService'; describe('SharingCryptoService', () => { let telemetry: ProtonDriveTelemetry; @@ -87,6 +87,27 @@ describe('SharingCryptoService', () => { expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); + it('should decrypt bookmark with custom password', async () => { + // First 12 characters are the generated password. Anything beyond is the custom password. + driveCrypto.decryptShareUrlPassword = jest.fn().mockResolvedValue('urlPassword1WithCustomPassword'); + + const result = await cryptoService.decryptBookmark(encryptedBookmark); + + expect(result).toMatchObject({ + url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword1'), + nodeName: resultOk('nodeName'), + }); + expect(driveCrypto.decryptShareUrlPassword).toHaveBeenCalledWith('encryptedUrlPassword', ['addressKey']); + expect(driveCrypto.decryptKeyWithSrpPassword).toHaveBeenCalledWith( + 'urlPassword1WithCustomPassword', + 'base64SharePasswordSalt', + 'armoredKey', + 'armoredPassphrase', + ); + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith('encryptedName', 'decryptedKey', []); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + it('should handle undecryptable URL password', async () => { const error = new Error('Failed to decrypt URL password'); driveCrypto.decryptShareUrlPassword = jest.fn().mockRejectedValue(error); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 130c819e..0da4a9e8 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -37,6 +37,7 @@ import { EncryptedBookmark, SharesService, } from './interface'; +import { DecryptionError } from '../../errors'; // Version 2 of bcrypt with 2**10 rounds. // https://en.wikipedia.org/wiki/Bcrypt#Description @@ -425,10 +426,11 @@ export class SharingCryptoService { // TODO: Signatures are not checked and not specified in the interface. // In the future, we will need to add authorship verification. + let password: string; let urlPassword: string; let customPassword: Result; try { - const password = await this.decryptBookmarkUrlPassword(encryptedBookmark); + password = await this.decryptBookmarkUrlPassword(encryptedBookmark); const result = splitGeneratedAndCustomPassword(password); urlPassword = result.password; customPassword = resultOk(result.customPassword); @@ -446,7 +448,7 @@ export class SharingCryptoService { let shareKey: PrivateKey; try { - shareKey = await this.decryptBookmarkKey(encryptedBookmark, urlPassword); + shareKey = await this.decryptBookmarkKey(encryptedBookmark, password); } catch (originalError: unknown) { const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`); return { @@ -493,15 +495,15 @@ export class SharingCryptoService { const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt bookmark password: ${message}`; - throw new Error(errorMessage); + throw new DecryptionError(errorMessage, { cause: error }); } } - private async decryptBookmarkKey(encryptedBookmark: EncryptedBookmark, urlPassword: string): Promise { + private async decryptBookmarkKey(encryptedBookmark: EncryptedBookmark, password: string): Promise { try { // Use the password to decrypt the share key. const { key: shareKey } = await this.driveCrypto.decryptKeyWithSrpPassword( - urlPassword, + password, encryptedBookmark.url.base64SharePasswordSalt, encryptedBookmark.share.armoredKey, encryptedBookmark.share.armoredPassphrase, @@ -519,7 +521,7 @@ export class SharingCryptoService { const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt bookmark key: ${message}`; - throw new Error(errorMessage); + throw new DecryptionError(errorMessage, { cause: error }); } } From 6509fc3551c269fca59613859d751e0cd04c6b80 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Sep 2025 07:45:05 +0200 Subject: [PATCH 209/791] NotFoundAPIError is inherited from ValidationError --- js/sdk/src/internal/apiService/apiService.ts | 2 +- js/sdk/src/internal/apiService/errors.test.ts | 3 ++- js/sdk/src/internal/apiService/errors.ts | 19 +++++++++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 34d9813a..30ba990c 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -165,7 +165,7 @@ export class DriveAPIService { if (error instanceof ProtonDriveError) { throw error; } - throw apiErrorFactory({ response }); + throw apiErrorFactory({ response, error }); } } diff --git a/js/sdk/src/internal/apiService/errors.test.ts b/js/sdk/src/internal/apiService/errors.test.ts index c8c2ea55..26bcbf02 100644 --- a/js/sdk/src/internal/apiService/errors.test.ts +++ b/js/sdk/src/internal/apiService/errors.test.ts @@ -62,7 +62,8 @@ describe('apiErrorFactory should return', () => { it('NotFoundAPIError when code is ErrorCode.NOT_EXISTS', () => { const error = apiErrorFactory(mockAPIResponseAndResult({ code: ErrorCode.NOT_EXISTS, message: 'Not found' })); expect(error).toBeInstanceOf(errors.NotFoundAPIError); - expectAPICodeError(error, ErrorCode.NOT_EXISTS, 'Not found'); + expect(error.message).toBe('Not found'); + expect((error as errors.NotFoundAPIError).code).toBe(ErrorCode.NOT_EXISTS); }); }); diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index be0d1112..11258c0e 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -3,12 +3,23 @@ import { c } from 'ttag'; import { ServerError, ValidationError } from '../../errors'; import { ErrorCode, HTTPErrorCode } from './errorCodes'; -export function apiErrorFactory({ response, result }: { response: Response; result?: unknown }): ServerError { +export function apiErrorFactory({ + response, + result, + error, +}: { + response: Response; + result?: unknown; + error?: unknown; +}): ServerError { // Backend responses with 404 both in the response and body code. // In such a case we want to stick to APIHTTPError to be very clear // it is not NotFoundAPIError. if (response.status === HTTPErrorCode.NOT_FOUND || !result) { - return new APIHTTPError(response.statusText || c('Error').t`Unknown error`, response.status); + const fallbackMessage = error instanceof Error ? error.message : c('Error').t`Unknown error`; + const apiHttpError = new APIHTTPError(response.statusText || fallbackMessage, response.status); + apiHttpError.cause = error; + return apiHttpError; } const typedResult = result as { @@ -40,7 +51,7 @@ export function apiErrorFactory({ response, result }: { response: Response; resu switch (code) { case ErrorCode.NOT_EXISTS: - return new NotFoundAPIError(message, code); + return new NotFoundAPIError(message, code, details); // ValidationError should be only when it is clearly user input error, // otherwise it should be ServerError. // Here we convert only general enough codes. Specific cases that are @@ -94,6 +105,6 @@ export class APICodeError extends ServerError { } } -export class NotFoundAPIError extends APICodeError { +export class NotFoundAPIError extends ValidationError { name = 'NotFoundAPIError'; } From da8b023d955df41e8e9ba89a7984f546ca2e422e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Sep 2025 06:36:05 +0200 Subject: [PATCH 210/791] js/v0.3.1 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 5b261682..b571f097 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.3.0", + "version": "0.3.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 230b28b238e8fd3782bf197fce4f395fb08410ca Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Sep 2025 11:32:53 +0000 Subject: [PATCH 211/791] Provide file progress in onProgress callback --- js/sdk/src/internal/download/fileDownloader.test.ts | 4 +++- js/sdk/src/internal/download/fileDownloader.ts | 2 +- js/sdk/src/internal/upload/streamUploader.test.ts | 4 +++- js/sdk/src/internal/upload/streamUploader.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index f54227c4..f6ce1488 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -123,8 +123,10 @@ describe('FileDownloader', () => { const verifyOnProgress = async (downloadedBytes: number[]) => { expect(onProgress).toHaveBeenCalledTimes(downloadedBytes.length); + let fileProgress = 0; for (let i = 0; i < downloadedBytes.length; i++) { - expect(onProgress).toHaveBeenNthCalledWith(i + 1, downloadedBytes[i]); + fileProgress += downloadedBytes[i]; + expect(onProgress).toHaveBeenNthCalledWith(i + 1, fileProgress); } }; diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 9fc037ef..a9b43a38 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -193,7 +193,7 @@ export class FileDownloader { cryptoKeys, (downloadedBytes) => { fileProgress += downloadedBytes; - onProgress?.(downloadedBytes); + onProgress?.(fileProgress); }, ); this.ongoingDownloads.set(blockMetadata.index, { downloadPromise }); diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 691c51a4..0b7ea864 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -189,8 +189,10 @@ describe('StreamUploader', () => { const verifyOnProgress = async (uploadedBytes: number[]) => { expect(onProgress).toHaveBeenCalledTimes(uploadedBytes.length); + let fileProgress = 0; for (let i = 0; i < uploadedBytes.length; i++) { - expect(onProgress).toHaveBeenNthCalledWith(i + 1, uploadedBytes[i]); + fileProgress += uploadedBytes[i]; + expect(onProgress).toHaveBeenNthCalledWith(i + 1, fileProgress); } }; diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 6423407e..c0e1d35d 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -122,7 +122,7 @@ export class StreamUploader { this.logger.info(`Starting upload`); await this.encryptAndUploadBlocks(stream, thumbnails, (uploadedBytes) => { fileProgress += uploadedBytes; - onProgress?.(uploadedBytes); + onProgress?.(fileProgress); }); this.logger.debug(`All blocks uploaded, committing`); From 50651c17d54c64f178919a35801a3271bee8dfba Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Sep 2025 07:35:32 +0200 Subject: [PATCH 212/791] Add cause to wrapped errors --- js/sdk/src/internal/download/cryptoService.ts | 4 ++-- js/sdk/src/internal/download/fileDownloader.ts | 2 +- js/sdk/src/internal/nodes/cache.ts | 4 +++- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index 5aa9cd54..f515efd4 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -49,7 +49,7 @@ export class DownloadCryptoService { ); } catch (error: unknown) { const message = getErrorMessage(error); - throw new DecryptionError(c('Error').t`Failed to decrypt block: ${message}`); + throw new DecryptionError(c('Error').t`Failed to decrypt block: ${message}`, { cause: error }); } return decryptedBlock; @@ -66,7 +66,7 @@ export class DownloadCryptoService { decryptedBlock = result.decryptedThumbnail; } catch (error: unknown) { const message = getErrorMessage(error); - throw new DecryptionError(c('Error').t`Failed to decrypt thumbnail: ${message}`); + throw new DecryptionError(c('Error').t`Failed to decrypt thumbnail: ${message}`, { cause: error }); } return decryptedBlock; diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index a9b43a38..c9f7b333 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -134,7 +134,7 @@ export class FileDownloader { const blockData = await this.downloadBlockData(blockMetadata, true, cryptoKeys); return blockData.slice(blockOffset); } catch (error: unknown) { - return error instanceof Error ? error : new Error(`Unknown error: ${error}`); + return error instanceof Error ? error : new Error(`Unknown error: ${error}`, { cause: error }); } } diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 3aa92c5d..60b40028 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -53,7 +53,9 @@ export class NodesCache { return deserialiseNode(nodeData); } catch (error: unknown) { await this.removeCorruptedNode({ nodeUid }, error); - throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`); + throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`, { + cause: error, + }); } } diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index fa5cc522..e0dc10a2 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -375,7 +375,7 @@ export class NodesAccess { // Change the error message to be more specific. // Original error message is referring to node, while here // it referes to as parent to follow the method context. - throw new DecryptionError(c('Error').t`Parent cannot be decrypted`); + throw new DecryptionError(c('Error').t`Parent cannot be decrypted`, { cause: error }); } throw error; } From eeeda151117a703633f9d0dd29fbb0c551e59c82 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Sep 2025 05:44:39 +0000 Subject: [PATCH 213/791] Reuse Node entity for public link access --- js/sdk/src/crypto/driveCrypto.ts | 2 +- js/sdk/src/crypto/interface.ts | 2 +- js/sdk/src/crypto/openPGPCrypto.ts | 7 +- js/sdk/src/internal/nodes/cryptoReporter.ts | 145 ++++++++++++ .../src/internal/nodes/cryptoService.test.ts | 4 +- js/sdk/src/internal/nodes/cryptoService.ts | 181 ++++----------- js/sdk/src/internal/nodes/index.ts | 4 +- js/sdk/src/internal/nodes/interface.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.ts | 118 +++++----- .../src/internal/sharingPublic/apiService.ts | 13 +- .../internal/sharingPublic/cryptoService.ts | 206 ++++++------------ js/sdk/src/internal/sharingPublic/index.ts | 5 +- .../src/internal/sharingPublic/interface.ts | 61 +----- js/sdk/src/internal/sharingPublic/manager.ts | 17 +- js/sdk/src/protonDriveClient.ts | 1 + js/sdk/src/protonDrivePublicLinkClient.ts | 38 +++- 16 files changed, 390 insertions(+), 416 deletions(-) create mode 100644 js/sdk/src/internal/nodes/cryptoReporter.ts diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 61d6be06..16b8b842 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -170,7 +170,7 @@ export class DriveCrypto { async decryptKey( armoredKey: string, armoredPassphrase: string, - armoredPassphraseSignature: string, + armoredPassphraseSignature: string | undefined, decryptionKeys: PrivateKey[], verificationKeys: PublicKey[], ): Promise<{ diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 37c0c756..3803b7fe 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -240,7 +240,7 @@ export interface OpenPGPCrypto { decryptArmoredAndVerifyDetached: ( armoredData: string, - armoredSignature: string, + armoredSignature: string | undefined, sessionKey: SessionKey, verificationKeys: PublicKey | PublicKey[], ) => Promise<{ diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 9e695c20..df303ac6 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -1,3 +1,4 @@ +import { c } from 'ttag'; import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; import { uint8ArrayToBase64String } from './utils'; @@ -393,7 +394,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async decryptArmoredAndVerifyDetached( armoredData: string, - armoredSignature: string, + armoredSignature: string | undefined, sessionKey: SessionKey, verificationKeys: PublicKey | PublicKey[], ) { @@ -410,7 +411,9 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, - verificationErrors, + verificationErrors: !armoredSignature + ? [new Error(c('Error').t`Signature is missing`)] + : verificationErrors, }; } diff --git a/js/sdk/src/internal/nodes/cryptoReporter.ts b/js/sdk/src/internal/nodes/cryptoReporter.ts new file mode 100644 index 00000000..8164d218 --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoReporter.ts @@ -0,0 +1,145 @@ +import { VERIFICATION_STATUS } from '../../crypto'; +import { + resultOk, + resultError, + Author, + AnonymousUser, + ProtonDriveTelemetry, + Logger, + MetricsDecryptionErrorField, + MetricVerificationErrorField, +} from '../../interface'; +import { getVerificationMessage } from '../errors'; +import { splitNodeUid } from '../uids'; +import { + EncryptedNode, + SharesService, +} from './interface'; + +export class NodesCryptoReporter { + private logger: Logger; + + private reportedDecryptionErrors = new Set(); + private reportedVerificationErrors = new Set(); + + constructor( + private telemetry: ProtonDriveTelemetry, + private shareService: SharesService, + ) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('nodes-crypto'); + this.shareService = shareService; + } + + async handleClaimedAuthor( + node: { uid: string; creationTime: Date }, + field: MetricVerificationErrorField, + signatureType: string, + verified: VERIFICATION_STATUS, + verificationErrors?: Error[], + claimedAuthor?: string, + notAvailableVerificationKeys = false, + ): Promise { + const author = handleClaimedAuthor( + signatureType, + verified, + verificationErrors, + claimedAuthor, + notAvailableVerificationKeys, + ); + if (!author.ok) { + void this.reportVerificationError(node, field, verificationErrors, claimedAuthor); + } + return author; + } + + async reportVerificationError( + node: { uid: string; creationTime: Date }, + field: MetricVerificationErrorField, + verificationErrors?: Error[], + claimedAuthor?: string, + ) { + if (this.reportedVerificationErrors.has(node.uid)) { + return; + } + this.reportedVerificationErrors.add(node.uid); + + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); + + let addressMatchingDefaultShare, volumeType; + try { + const { volumeId } = splitNodeUid(node.uid); + const { email } = await this.shareService.getMyFilesShareMemberEmailKey(); + addressMatchingDefaultShare = claimedAuthor ? claimedAuthor === email : undefined; + volumeType = await this.shareService.getVolumeMetricContext(volumeId); + } catch (error: unknown) { + this.logger.error('Failed to check if claimed author matches default share', error); + } + + this.logger.warn( + `Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`, + ); + + this.telemetry.recordMetric({ + eventName: 'verificationError', + volumeType, + field, + addressMatchingDefaultShare, + fromBefore2024, + error: verificationErrors?.map((e) => e.message).join(', '), + uid: node.uid, + }); + } + + async reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) { + if (this.reportedDecryptionErrors.has(node.uid)) { + return; + } + + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); + + let volumeType; + try { + const { volumeId } = splitNodeUid(node.uid); + volumeType = await this.shareService.getVolumeMetricContext(volumeId); + } catch (error: unknown) { + this.logger.error('Failed to get metric context', error); + } + + this.logger.error(`Failed to decrypt node ${node.uid} (from before 2024: ${fromBefore2024})`, error); + + this.telemetry.recordMetric({ + eventName: 'decryptionError', + volumeType, + field, + fromBefore2024, + error, + uid: node.uid, + }); + this.reportedDecryptionErrors.add(node.uid); + } +} + +/** + * @param signatureType - Must be translated before calling this function. + */ +function handleClaimedAuthor( + signatureType: string, + verified: VERIFICATION_STATUS, + verificationErrors?: Error[], + claimedAuthor?: string, + notAvailableVerificationKeys = false, +): Author { + if (!claimedAuthor && notAvailableVerificationKeys) { + return resultOk(null as AnonymousUser); + } + + if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { + return resultOk(claimedAuthor || (null as AnonymousUser)); + } + + return resultError({ + claimedAuthor, + error: getVerificationMessage(verified, verificationErrors, signatureType, notAvailableVerificationKeys), + }); +} diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 82eb3515..492e64ba 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -3,6 +3,7 @@ import { MemberRole, ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } f import { getMockTelemetry } from '../../tests/telemetry'; import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from './interface'; import { NodesCryptoService } from './cryptoService'; +import { NodesCryptoReporter } from './cryptoReporter'; describe('nodesCryptoService', () => { let telemetry: ProtonDriveTelemetry; @@ -74,7 +75,8 @@ describe('nodesCryptoService', () => { getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), }; - cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); + const nodesCryptoReporter = new NodesCryptoReporter(telemetry, sharesService); + cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, nodesCryptoReporter); }); const parentKey = 'parentKey' as unknown as PrivateKey; diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 35ee1cea..3b8de594 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -7,27 +7,49 @@ import { Result, Author, AnonymousUser, - ProtonDriveAccount, ProtonDriveTelemetry, Logger, MetricsDecryptionErrorField, MetricVerificationErrorField, Membership, + ProtonDriveAccount, } from '../../interface'; import { ValidationError } from '../../errors'; -import { getErrorMessage, getVerificationMessage } from '../errors'; -import { splitNodeUid } from '../uids'; +import { getErrorMessage } from '../errors'; import { EncryptedNode, EncryptedNodeFolderCrypto, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, - SharesService, EncryptedRevision, DecryptedUnparsedRevision, } from './interface'; +export interface NodesCryptoReporter { + handleClaimedAuthor( + node: NodesCryptoReporterNode, + field: MetricVerificationErrorField, + signatureType: string, + verified: VERIFICATION_STATUS, + verificationErrors?: Error[], + claimedAuthor?: string, + notAvailableVerificationKeys?: boolean, + ): Promise; + reportDecryptionError(node: NodesCryptoReporterNode, field: MetricsDecryptionErrorField, error: unknown): void; + reportVerificationError( + node: NodesCryptoReporterNode, + field: MetricVerificationErrorField, + verificationErrors?: Error[], + claimedAuthor?: string, + ): void; +} + +type NodesCryptoReporterNode = { + uid: string; + creationTime: Date; +}; + /** * Provides crypto operations for nodes metadata. * @@ -41,20 +63,16 @@ import { export class NodesCryptoService { private logger: Logger; - private reportedDecryptionErrors = new Set(); - private reportedVerificationErrors = new Set(); - constructor( - private telemetry: ProtonDriveTelemetry, - private driveCrypto: DriveCrypto, + telemetry: ProtonDriveTelemetry, + protected driveCrypto: DriveCrypto, private account: ProtonDriveAccount, - private shareService: SharesService, + private reporter: NodesCryptoReporter, ) { - this.telemetry = telemetry; this.logger = telemetry.getLogger('nodes-crypto'); this.driveCrypto = driveCrypto; this.account = account; - this.shareService = shareService; + this.reporter = reporter; } async decryptNode( @@ -107,7 +125,7 @@ export class NodesCryptoService { passphraseSessionKey = keyResult.passphraseSessionKey; keyAuthor = keyResult.author; } catch (error: unknown) { - void this.reportDecryptionError(node, 'nodeKey', error); + void this.reporter.reportDecryptionError(node, 'nodeKey', error); const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt node key: ${message}`; const { name, author: nameAuthor } = await namePromise; @@ -159,7 +177,7 @@ export class NodesCryptoService { hashKey = hashKeyResult.hashKey; hashKeyAuthor = hashKeyResult.author; } catch (error: unknown) { - void this.reportDecryptionError(node, 'nodeHashKey', error); + void this.reporter.reportDecryptionError(node, 'nodeHashKey', error); errors.push(error); } @@ -170,7 +188,7 @@ export class NodesCryptoService { }; folderExtendedAttributesAuthor = extendedAttributesResult.author; } catch (error: unknown) { - void this.reportDecryptionError(node, 'nodeExtendedAttributes', error); + void this.reporter.reportDecryptionError(node, 'nodeExtendedAttributes', error); errors.push(error); } } @@ -194,7 +212,7 @@ export class NodesCryptoService { try { activeRevision = resultOk(await activeRevisionPromise); } catch (error: unknown) { - void this.reportDecryptionError(node, 'nodeExtendedAttributes', error); + void this.reporter.reportDecryptionError(node, 'nodeExtendedAttributes', error); const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt active revision: ${message}`; activeRevision = resultError(new Error(errorMessage)); @@ -205,7 +223,7 @@ export class NodesCryptoService { contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; contentKeyPacketAuthor = keySessionKeyResult.verified !== undefined && - (await this.handleClaimedAuthor( + (await this.reporter.handleClaimedAuthor( node, 'nodeContentKey', c('Property').t`content key`, @@ -214,7 +232,7 @@ export class NodesCryptoService { node.encryptedCrypto.signatureEmail, )); } catch (error: unknown) { - void this.reportDecryptionError(node, 'nodeContentKey', error); + void this.reporter.reportDecryptionError(node, 'nodeContentKey', error); const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt content key: ${message}`; contentKeyPacketAuthor = resultError({ @@ -295,7 +313,7 @@ export class NodesCryptoService { passphrase: key.passphrase, key: key.key, passphraseSessionKey: key.passphraseSessionKey, - author: await this.handleClaimedAuthor( + author: await this.reporter.handleClaimedAuthor( node, 'nodeKey', c('Property').t`key`, @@ -326,7 +344,7 @@ export class NodesCryptoService { return { name: resultOk(name), - author: await this.handleClaimedAuthor( + author: await this.reporter.handleClaimedAuthor( node, 'nodeName', c('Property').t`name`, @@ -337,7 +355,7 @@ export class NodesCryptoService { ), }; } catch (error: unknown) { - void this.reportDecryptionError(node, 'nodeName', error); + void this.reporter.reportDecryptionError(node, 'nodeName', error); const errorMessage = getErrorMessage(error); return { name: resultError(new Error(errorMessage)), @@ -378,7 +396,7 @@ export class NodesCryptoService { inviterEmailKeys || [], ); - sharedBy = await this.handleClaimedAuthor( + sharedBy = await this.reporter.handleClaimedAuthor( node, 'membershipInviter', c('Property').t`membership`, @@ -387,7 +405,7 @@ export class NodesCryptoService { node.encryptedCrypto.membership.inviterEmail, ); } catch (error: unknown) { - void this.reportVerificationError(node, 'membershipInviter'); + void this.reporter.reportVerificationError(node, 'membershipInviter'); this.logger.error('Failed to verify invitation', error); sharedBy = resultError({ claimedAuthor: node.encryptedCrypto.membership.inviterEmail, @@ -428,7 +446,7 @@ export class NodesCryptoService { return { hashKey, - author: await this.handleClaimedAuthor( + author: await this.reporter.handleClaimedAuthor( node, 'nodeHashKey', c('Property').t`hash key`, @@ -491,7 +509,7 @@ export class NodesCryptoService { return { extendedAttributes, - author: await this.handleClaimedAuthor( + author: await this.reporter.handleClaimedAuthor( node, 'nodeExtendedAttributes', c('Property').t`attributes`, @@ -509,6 +527,7 @@ export class NodesCryptoService { extendedAttributes?: string, ): Promise<{ encryptedCrypto: EncryptedNodeFolderCrypto & { + armoredNodePassphraseSignature: string; // signatureEmail and nameSignatureEmail are not optional. signatureEmail: string; nameSignatureEmail: string; @@ -626,118 +645,6 @@ export class NodesCryptoService { nameSignatureEmail: email, }; } - - private async handleClaimedAuthor( - node: { uid: string; creationTime: Date }, - field: MetricVerificationErrorField, - signatureType: string, - verified: VERIFICATION_STATUS, - verificationErrors?: Error[], - claimedAuthor?: string, - notAvailableVerificationKeys = false, - ): Promise { - const author = handleClaimedAuthor( - signatureType, - verified, - verificationErrors, - claimedAuthor, - notAvailableVerificationKeys, - ); - if (!author.ok) { - void this.reportVerificationError(node, field, verificationErrors, claimedAuthor); - } - return author; - } - - private async reportVerificationError( - node: { uid: string; creationTime: Date }, - field: MetricVerificationErrorField, - verificationErrors?: Error[], - claimedAuthor?: string, - ) { - if (this.reportedVerificationErrors.has(node.uid)) { - return; - } - this.reportedVerificationErrors.add(node.uid); - - const fromBefore2024 = node.creationTime < new Date('2024-01-01'); - - let addressMatchingDefaultShare, volumeType; - try { - const { volumeId } = splitNodeUid(node.uid); - const { email } = await this.shareService.getMyFilesShareMemberEmailKey(); - addressMatchingDefaultShare = claimedAuthor ? claimedAuthor === email : undefined; - volumeType = await this.shareService.getVolumeMetricContext(volumeId); - } catch (error: unknown) { - this.logger.error('Failed to check if claimed author matches default share', error); - } - - this.logger.warn( - `Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`, - ); - - this.telemetry.recordMetric({ - eventName: 'verificationError', - volumeType, - field, - addressMatchingDefaultShare, - fromBefore2024, - error: verificationErrors?.map((e) => e.message).join(', '), - uid: node.uid, - }); - } - - private async reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) { - if (this.reportedDecryptionErrors.has(node.uid)) { - return; - } - - const fromBefore2024 = node.creationTime < new Date('2024-01-01'); - - let volumeType; - try { - const { volumeId } = splitNodeUid(node.uid); - volumeType = await this.shareService.getVolumeMetricContext(volumeId); - } catch (error: unknown) { - this.logger.error('Failed to get metric context', error); - } - - this.logger.error(`Failed to decrypt node ${node.uid} (from before 2024: ${fromBefore2024})`, error); - - this.telemetry.recordMetric({ - eventName: 'decryptionError', - volumeType, - field, - fromBefore2024, - error, - uid: node.uid, - }); - this.reportedDecryptionErrors.add(node.uid); - } -} - -/** - * @param signatureType - Must be translated before calling this function. - */ -function handleClaimedAuthor( - signatureType: string, - verified: VERIFICATION_STATUS, - verificationErrors?: Error[], - claimedAuthor?: string, - notAvailableVerificationKeys = false, -): Author { - if (!claimedAuthor && notAvailableVerificationKeys) { - return resultOk(null as AnonymousUser); - } - - if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { - return resultOk(claimedAuthor || (null as AnonymousUser)); - } - - return resultError({ - claimedAuthor: claimedAuthor, - error: getVerificationMessage(verified, verificationErrors, signatureType, notAvailableVerificationKeys), - }); } function getClaimedAuthor( diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 26abe25f..00dbc214 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -10,6 +10,7 @@ import { NodeAPIService } from './apiService'; import { NodesCache } from './cache'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; +import { NodesCryptoReporter } from './cryptoReporter'; import { SharesService } from './interface'; import { NodesAccess } from './nodesAccess'; import { NodesManagement } from './nodesManagement'; @@ -40,7 +41,8 @@ export function initNodesModule( const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); - const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService); + const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService); + const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); const nodesAccess = new NodesAccess( telemetry.getLogger('nodes'), api, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 78aff40e..ce711f4b 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -56,7 +56,7 @@ export interface EncryptedNodeCrypto { nameSignatureEmail?: string; armoredKey: string; armoredNodePassphrase: string; - armoredNodePassphraseSignature: string; + armoredNodePassphraseSignature?: string; membership?: { inviterEmail: string; base64MemberSharePassphraseKeyPacket: string; diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index e0dc10a2..39ba3f01 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -289,7 +289,7 @@ export class NodesAccess { } const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); - const node = await this.parseNode(unparsedNode); + const node = await parseNode(this.logger, unparsedNode); try { await this.cache.setNode(node); } catch (error: unknown) { @@ -305,65 +305,6 @@ export class NodesAccess { return { node, keys }; } - private async parseNode(unparsedNode: DecryptedUnparsedNode): Promise { - let nodeName: Result = unparsedNode.name; - if (unparsedNode.name.ok) { - try { - validateNodeName(unparsedNode.name.value); - } catch (error: unknown) { - this.logger.warn(`Node name validation failed: ${error instanceof Error ? error.message : error}`); - nodeName = resultError({ - name: unparsedNode.name.value, - error: error instanceof Error ? error.message : c('Error').t`Unknown error`, - }); - } - } - - if (unparsedNode.type === NodeType.File) { - const extendedAttributes = unparsedNode.activeRevision?.ok - ? parseFileExtendedAttributes( - this.logger, - unparsedNode.activeRevision.value.creationTime, - unparsedNode.activeRevision.value.extendedAttributes, - ) - : undefined; - - return { - ...unparsedNode, - isStale: false, - activeRevision: !unparsedNode.activeRevision?.ok - ? unparsedNode.activeRevision - : resultOk({ - uid: unparsedNode.activeRevision.value.uid, - state: unparsedNode.activeRevision.value.state, - creationTime: unparsedNode.activeRevision.value.creationTime, - storageSize: unparsedNode.activeRevision.value.storageSize, - contentAuthor: unparsedNode.activeRevision.value.contentAuthor, - thumbnails: unparsedNode.activeRevision.value.thumbnails, - ...extendedAttributes, - }), - folder: undefined, - treeEventScopeId: splitNodeUid(unparsedNode.uid).volumeId, - }; - } - - const extendedAttributes = unparsedNode.folder?.extendedAttributes - ? parseFolderExtendedAttributes(this.logger, unparsedNode.folder.extendedAttributes) - : undefined; - return { - ...unparsedNode, - name: nodeName, - isStale: false, - activeRevision: undefined, - folder: extendedAttributes - ? { - ...extendedAttributes, - } - : undefined, - treeEventScopeId: splitNodeUid(unparsedNode.uid).volumeId, - }; - } - async getParentKeys( node: Pick, ): Promise> { @@ -458,3 +399,60 @@ export class NodesAccess { return node.parentUid ? this.getRootNode(node.parentUid) : node; } } + +export async function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedNode): Promise { + let nodeName: Result = unparsedNode.name; + if (unparsedNode.name.ok) { + try { + validateNodeName(unparsedNode.name.value); + } catch (error: unknown) { + logger.warn(`Node name validation failed: ${error instanceof Error ? error.message : error}`); + nodeName = resultError({ + name: unparsedNode.name.value, + error: error instanceof Error ? error.message : c('Error').t`Unknown error`, + }); + } + } + + const treeEventScopeId = splitNodeUid(unparsedNode.uid).volumeId; + + if (unparsedNode.type === NodeType.File) { + const extendedAttributes = unparsedNode.activeRevision?.ok + ? parseFileExtendedAttributes( + logger, + unparsedNode.activeRevision.value.creationTime, + unparsedNode.activeRevision.value.extendedAttributes, + ) + : undefined; + + return { + ...unparsedNode, + isStale: false, + activeRevision: !unparsedNode.activeRevision?.ok + ? unparsedNode.activeRevision + : resultOk({ + uid: unparsedNode.activeRevision.value.uid, + state: unparsedNode.activeRevision.value.state, + creationTime: unparsedNode.activeRevision.value.creationTime, + storageSize: unparsedNode.activeRevision.value.storageSize, + contentAuthor: unparsedNode.activeRevision.value.contentAuthor, + thumbnails: unparsedNode.activeRevision.value.thumbnails, + ...extendedAttributes, + }), + folder: undefined, + treeEventScopeId, + }; + } + + const extendedAttributes = unparsedNode.folder?.extendedAttributes + ? parseFolderExtendedAttributes(logger, unparsedNode.folder.extendedAttributes) + : undefined; + return { + ...unparsedNode, + name: nodeName, + isStale: false, + activeRevision: undefined, + folder: extendedAttributes, + treeEventScopeId, + }; +} diff --git a/js/sdk/src/internal/sharingPublic/apiService.ts b/js/sdk/src/internal/sharingPublic/apiService.ts index dbf1d675..30b50c7c 100644 --- a/js/sdk/src/internal/sharingPublic/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/apiService.ts @@ -1,5 +1,5 @@ import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType } from '../apiService'; -import { Logger } from '../../interface'; +import { Logger, MemberRole } from '../../interface'; import { makeNodeUid, splitNodeUid } from '../uids'; import { EncryptedShareCrypto, EncryptedNode } from './interface'; @@ -42,13 +42,14 @@ export class SharingPublicAPIService { }; } - async *iterateChildren(parentUid: string): AsyncGenerator { + async *iterateFolderChildren(parentUid: string, signal?: AbortSignal): AsyncGenerator { const { volumeId: token, nodeId } = splitNodeUid(parentUid); let page = 0; while (true) { const response = await this.apiService.get( `drive/urls/${token}/folders/${nodeId}/children?Page=${page}&PageSize=${PAGE_SIZE}`, + signal, ); for (const link of response.Links) { @@ -72,6 +73,10 @@ function tokenToEncryptedNode(logger: Logger, token: GetTokenInfoResponse['Token uid: makeNodeUid(token.Token, token.LinkID), parentUid: undefined, type: nodeTypeNumberToNodeType(logger, token.LinkType), + creationTime: new Date(), // TODO + + isShared: false, + directRole: MemberRole.Viewer, // TODO }; const baseCryptoNodeMetadata = { @@ -124,7 +129,11 @@ function linkToEncryptedNode( uid: makeNodeUid(token, link.LinkID), parentUid: link.ParentLinkID ? makeNodeUid(token, link.ParentLinkID) : undefined, type: nodeTypeNumberToNodeType(logger, link.Type), + creationTime: new Date(), // TODO totalStorageSize: link.TotalSize, + + isShared: false, + directRole: MemberRole.Viewer, // TODO }; const baseCryptoNodeMetadata = { diff --git a/js/sdk/src/internal/sharingPublic/cryptoService.ts b/js/sdk/src/internal/sharingPublic/cryptoService.ts index 4832a543..0537e7f6 100644 --- a/js/sdk/src/internal/sharingPublic/cryptoService.ts +++ b/js/sdk/src/internal/sharingPublic/cryptoService.ts @@ -1,26 +1,34 @@ -import { DriveCrypto, PrivateKey } from '../../crypto'; -import { resultOk, resultError, Result } from '../../interface'; -import { getErrorMessage } from '../errors'; -import { EncryptedShareCrypto, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from './interface'; - -/** - * Provides crypto operations for public link data. - * - * The public link crypto service is responsible for decrypting and encrypting - * public link data. It should export high-level actions only, such as "decrypt - * share key" instead of low-level operations like "decrypt key". Low-level - * operations should be kept private to the module. - */ -export class SharingPublicCryptoService { +import { c } from 'ttag'; + +import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from '../../crypto'; +import { getVerificationMessage } from '../errors'; +import { + resultOk, + resultError, + Author, + AnonymousUser, + ProtonDriveTelemetry, + MetricVerificationErrorField, + MetricVolumeType, + MetricsDecryptionErrorField, + Logger, + ProtonDriveAccount, +} from '../../interface'; +import { NodesCryptoService } from '../nodes/cryptoService'; +import { EncryptedShareCrypto } from './interface'; + +export class SharingPublicCryptoService extends NodesCryptoService { constructor( - private driveCrypto: DriveCrypto, + telemetry: ProtonDriveTelemetry, + driveCrypto: DriveCrypto, + account: ProtonDriveAccount, private password: string, ) { - this.driveCrypto = driveCrypto; + super(telemetry, driveCrypto, account, new SharingPublicCryptoReporter(telemetry)); this.password = password; } - async decryptShareKey(encryptedShare: EncryptedShareCrypto): Promise { + async decryptPublicLinkShareKey(encryptedShare: EncryptedShareCrypto): Promise { const { key: shareKey } = await this.driveCrypto.decryptKeyWithSrpPassword( this.password, encryptedShare.base64UrlPasswordSalt, @@ -29,134 +37,62 @@ export class SharingPublicCryptoService { ); return shareKey; } +} - // TODO: verfiy it has all needed - async decryptNode( - node: EncryptedNode, - parentKey: PrivateKey, - ): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { - const commonNodeMetadata = { - ...node, - encryptedCrypto: undefined, - }; - - const { name } = await this.decryptName(node, parentKey); - - let passphrase, key, passphraseSessionKey; - try { - const keyResult = await this.decryptKey(node, parentKey); - passphrase = keyResult.passphrase; - key = keyResult.key; - passphraseSessionKey = keyResult.passphraseSessionKey; - } catch (error: unknown) { - return { - node: { - ...commonNodeMetadata, - name, - errors: [error], - }, - }; - } - - const errors = []; - - let hashKey; - if ('folder' in node.encryptedCrypto) { - try { - const hashKeyResult = await this.decryptHashKey(node, key); - hashKey = hashKeyResult.hashKey; - } catch (error: unknown) { - errors.push(error); - } - } +class SharingPublicCryptoReporter { + private logger: Logger; + private telemetry: ProtonDriveTelemetry; - let contentKeyPacketSessionKey; - if ('file' in node.encryptedCrypto) { - try { - const keySessionKeyResult = await this.driveCrypto.decryptAndVerifySessionKey( - node.encryptedCrypto.file.base64ContentKeyPacket, - '', - key, - [], - ); + constructor(telemetry: ProtonDriveTelemetry) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('sharingPublic-crypto'); + } - contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; - } catch (error: unknown) { - errors.push(error); - } + async handleClaimedAuthor( + node: { uid: string; creationTime: Date }, + field: MetricVerificationErrorField, + signatureType: string, + verified: VERIFICATION_STATUS, + verificationErrors?: Error[], + claimedAuthor?: string, + notAvailableVerificationKeys = false, + ): Promise { + if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { + return resultOk(claimedAuthor || (null as AnonymousUser)); } - return { - node: { - ...commonNodeMetadata, - name, - errors: errors.length ? errors : undefined, - }, - keys: { - passphrase, - key, - passphraseSessionKey, - contentKeyPacketSessionKey, - hashKey, - }, - }; + return resultError({ + claimedAuthor, + error: !claimedAuthor + ? c('Info').t`Author is not provided on public link` + : getVerificationMessage(verified, verificationErrors, signatureType, notAvailableVerificationKeys), + }); } - private async decryptKey(node: EncryptedNode, parentKey: PrivateKey): Promise { - const key = await this.driveCrypto.decryptKey( - node.encryptedCrypto.armoredKey, - node.encryptedCrypto.armoredNodePassphrase, - '', - [parentKey], - [], - ); - - return { - passphrase: key.passphrase, - key: key.key, - passphraseSessionKey: key.passphraseSessionKey, - }; - } + reportDecryptionError( + node: { uid: string; creationTime: Date }, + field: MetricsDecryptionErrorField, + error: unknown, + ) { + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); - private async decryptName( - node: EncryptedNode, - parentKey: PrivateKey, - ): Promise<{ - name: Result; - }> { - try { - const { name } = await this.driveCrypto.decryptNodeName(node.encryptedName, parentKey, []); + this.logger.error( + `Failed to decrypt public link node ${node.uid} (from before 2024: ${fromBefore2024})`, + error, + ); - return { - name: resultOk(name), - }; - } catch (error: unknown) { - const errorMessage = getErrorMessage(error); - return { - name: resultError(new Error(errorMessage)), - }; - } + this.telemetry.recordMetric({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field, + fromBefore2024, + error, + uid: node.uid, + }); } - private async decryptHashKey( - node: EncryptedNode, - nodeKey: PrivateKey, - ): Promise<{ - hashKey: Uint8Array; - }> { - if (!('folder' in node.encryptedCrypto)) { - // This is developer error. - throw new Error('Node is not a folder'); - } - - const { hashKey } = await this.driveCrypto.decryptNodeHashKey( - node.encryptedCrypto.folder.armoredHashKey, - nodeKey, - [], - ); - - return { - hashKey, - }; + reportVerificationError() { + // Authors or signatures are not provided on public links. + // We do not report any signature verification errors at this moment. } } diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 72197aa4..a830e369 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -1,5 +1,5 @@ import { DriveCrypto } from '../../crypto'; -import { ProtonDriveCryptoCache, ProtonDriveTelemetry } from '../../interface'; +import { ProtonDriveCryptoCache, ProtonDriveTelemetry, ProtonDriveAccount } from '../../interface'; import { DriveAPIService } from '../apiService'; import { SharingPublicAPIService } from './apiService'; import { SharingPublicCryptoCache } from './cryptoCache'; @@ -22,12 +22,13 @@ export function initSharingPublicModule( apiService: DriveAPIService, driveCryptoCache: ProtonDriveCryptoCache, driveCrypto: DriveCrypto, + account: ProtonDriveAccount, token: string, password: string, ) { const api = new SharingPublicAPIService(telemetry.getLogger('sharingPublic-api'), apiService); const cryptoCache = new SharingPublicCryptoCache(telemetry.getLogger('sharingPublic-crypto'), driveCryptoCache); - const cryptoService = new SharingPublicCryptoService(driveCrypto, password); + const cryptoService = new SharingPublicCryptoService(telemetry, driveCrypto, account, password); const manager = new SharingPublicManager( telemetry.getLogger('sharingPublic-nodes'), api, diff --git a/js/sdk/src/internal/sharingPublic/interface.ts b/js/sdk/src/internal/sharingPublic/interface.ts index ca14326c..563be8af 100644 --- a/js/sdk/src/internal/sharingPublic/interface.ts +++ b/js/sdk/src/internal/sharingPublic/interface.ts @@ -1,59 +1,14 @@ -import { PrivateKey, SessionKey } from "../../crypto"; -import { NodeType, Result, InvalidNameError } from "../../interface"; +// TODO: use them directly, or avoid them completely +export type { + EncryptedNode, + EncryptedNodeFolderCrypto, + EncryptedNodeFileCrypto, + DecryptedNode, + DecryptedNodeKeys, +} from '../nodes/interface'; export interface EncryptedShareCrypto { base64UrlPasswordSalt: string; armoredKey: string; armoredPassphrase: string; } - -// TODO: reuse node entity, or keep custom? -interface BaseNode { - // Internal metadata - hash?: string; // root node doesn't have any hash - encryptedName: string; - - // Basic node metadata - uid: string; - parentUid?: string; - type: NodeType; - mediaType?: string; - totalStorageSize?: number; -} - -export interface EncryptedNode extends BaseNode { - encryptedCrypto: EncryptedNodeFolderCrypto | EncryptedNodeFileCrypto; -} - -export interface EncryptedNodeCrypto { - signatureEmail?: string; - armoredKey: string; - armoredNodePassphrase: string; - armoredNodePassphraseSignature?: string; -} - -export interface EncryptedNodeFileCrypto extends EncryptedNodeCrypto { - file: { - base64ContentKeyPacket: string; - }; -} - -export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { - folder: { - armoredExtendedAttributes?: string; - armoredHashKey: string; - }; -} - -export interface DecryptedNode extends BaseNode { - name: Result; - errors?: unknown[]; -} - -export interface DecryptedNodeKeys { - passphrase: string; - key: PrivateKey; - passphraseSessionKey: SessionKey; - contentKeyPacketSessionKey?: SessionKey; - hashKey?: Uint8Array; -} diff --git a/js/sdk/src/internal/sharingPublic/manager.ts b/js/sdk/src/internal/sharingPublic/manager.ts index b89116f9..503e1ccc 100644 --- a/js/sdk/src/internal/sharingPublic/manager.ts +++ b/js/sdk/src/internal/sharingPublic/manager.ts @@ -1,5 +1,6 @@ import { PrivateKey } from '../../crypto'; import { Logger } from '../../interface'; +import { parseNode } from '../nodes/nodesAccess'; import { SharingPublicAPIService } from './apiService'; import { SharingPublicCryptoCache } from './cryptoCache'; import { SharingPublicCryptoService } from './cryptoService'; @@ -27,35 +28,35 @@ export class SharingPublicManager { return this.decryptNode(encryptedNode); } - async *iterateChildren(parentUid: string): AsyncGenerator { + async *iterateFolderChildren(parentUid: string, signal?: AbortSignal): AsyncGenerator { // TODO: optimise this - decrypt in parallel - for await (const node of this.api.iterateChildren(parentUid)) { + for await (const node of this.api.iterateFolderChildren(parentUid, signal)) { const decryptedNode = await this.decryptNode(node); yield decryptedNode; } } private async decryptShare(encryptedShare: EncryptedShareCrypto): Promise { - const shareKey = await this.cryptoService.decryptShareKey(encryptedShare); + const shareKey = await this.cryptoService.decryptPublicLinkShareKey(encryptedShare); await this.cryptoCache.setShareKey(shareKey); } private async decryptNode(encryptedNode: EncryptedNode): Promise { const parentKey = await this.getParentKey(encryptedNode); - const { node: decryptedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); + const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); + const node = await parseNode(this.logger, unparsedNode); // TODO: cache of metadata? - if (keys) { try { - await this.cryptoCache.setNodeKeys(decryptedNode.uid, keys); + await this.cryptoCache.setNodeKeys(node.uid, keys); } catch (error: unknown) { - this.logger.error(`Failed to cache node keys ${decryptedNode.uid}`, error); + this.logger.error(`Failed to cache node keys ${node.uid}`, error); } } - return decryptedNode; + return node; } private async getParentKey(node: Pick): Promise { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 27e2aa49..cb330f36 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -208,6 +208,7 @@ export class ProtonDriveClient { return new ProtonDrivePublicLinkClient({ httpClient, cryptoCache, + account, openPGPCryptoModule, srpModule, config, diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 34e7f250..3b2ce8ee 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -7,9 +7,11 @@ import { Logger, ProtonDriveCryptoCache, NodeOrUid, + ProtonDriveAccount, + MaybeNode, } from './interface'; import { Telemetry } from './telemetry'; -import { getUid } from './transformers'; +import { getUid, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; import { DriveAPIService } from './internal/apiService'; import { SDKEvents } from './internal/sdkEvents'; import { initSharingPublicModule } from './internal/sharingPublic'; @@ -52,6 +54,7 @@ export class ProtonDrivePublicLinkClient { constructor({ httpClient, cryptoCache, + account, openPGPCryptoModule, srpModule, config, @@ -61,6 +64,7 @@ export class ProtonDrivePublicLinkClient { }: { httpClient: ProtonDriveHTTPClient; cryptoCache: ProtonDriveCryptoCache; + account: ProtonDriveAccount; openPGPCryptoModule: OpenPGPCrypto; srpModule: SRPModule; config?: ProtonDriveConfig; @@ -84,7 +88,15 @@ export class ProtonDrivePublicLinkClient { fullConfig.language, ); const driveCrypto = new DriveCrypto(openPGPCryptoModule, srpModule); - this.sharingPublic = initSharingPublicModule(telemetry, apiService, cryptoCache, driveCrypto, token, password); + this.sharingPublic = initSharingPublicModule( + telemetry, + apiService, + cryptoCache, + driveCrypto, + account, + token, + password, + ); this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { @@ -103,19 +115,21 @@ export class ProtonDrivePublicLinkClient { }; } - // TODO: comment - // TODO: add public node interface - async getRootNode() { + /** + * @returns The root folder to the public link. + */ + async getRootNode(): Promise { this.logger.info(`Getting root node`); - // TODO: conversion to public node - return this.sharingPublic.getRootNode(); + return convertInternalNodePromise(this.sharingPublic.getRootNode()); } - // TODO: comment - // TODO: add public node interface - async *iterateChildren(parentUid: NodeOrUid) { + /** + * Iterates the children of the given parent node. + * + * See `ProtonDriveClient.iterateFolderChildren` for more information. + */ + async *iterateFolderChildren(parentUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentUid)}`); - // TODO: conversion to public node - yield * this.sharingPublic.iterateChildren(getUid(parentUid)); + yield * convertInternalNodeIterator(this.sharingPublic.iterateFolderChildren(getUid(parentUid), signal)); } } From 2daf76bb5bba81528f901bf8d3a2c6d8e6751718 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Sep 2025 09:21:04 +0000 Subject: [PATCH 214/791] Fix SharedWithMe cache --- js/sdk/package-lock.json | 4 +- js/sdk/src/internal/sharing/cache.ts | 21 +- js/sdk/src/internal/sharing/interface.ts | 2 +- .../internal/sharing/sharingAccess.test.ts | 316 ++++++++++++++++-- js/sdk/src/internal/sharing/sharingAccess.ts | 6 + 5 files changed, 310 insertions(+), 39 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index d38aab15..266ea662 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.2.1", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.2.1", + "version": "0.3.1", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/src/internal/sharing/cache.ts b/js/sdk/src/internal/sharing/cache.ts index 66d86bfe..aea3f0c9 100644 --- a/js/sdk/src/internal/sharing/cache.ts +++ b/js/sdk/src/internal/sharing/cache.ts @@ -44,11 +44,28 @@ export class SharingCache { } async getSharedWithMeNodeUids(): Promise { - return this.getNodeUids(SharingType.sharedWithMe); + return this.getNodeUids(SharingType.SharedWithMe); + } + + async hasSharedWithMeNodeUidsLoaded(): Promise { + try { + await this.getNodeUids(SharingType.SharedWithMe); + return true; + } catch { + return false; + } + } + + async addSharedWithMeNodeUid(nodeUid: string): Promise { + return this.addNodeUid(SharingType.SharedWithMe, nodeUid); + } + + async removeSharedWithMeNodeUid(nodeUid: string): Promise { + return this.removeNodeUid(SharingType.SharedWithMe, nodeUid); } async setSharedWithMeNodeUids(nodeUids: string[] | undefined): Promise { - return this.setNodeUids(SharingType.sharedWithMe, nodeUids); + return this.setNodeUids(SharingType.SharedWithMe, nodeUids); } /** diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index fa06737c..bc041a09 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -5,7 +5,7 @@ import { DecryptedNode } from '../nodes'; export enum SharingType { SharedByMe = 'sharedByMe', - sharedWithMe = 'sharedWithMe', + SharedWithMe = 'sharedWithMe', } /** diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index 2f5a8ca6..e611dda9 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -1,4 +1,6 @@ -import { NodeType, resultError, resultOk } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { NodeType, resultError, resultOk, MemberRole } from '../../interface'; +import { ValidationError } from '../../errors'; import { SharingAPIService } from './apiService'; import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; @@ -14,8 +16,12 @@ describe('SharingAccess', () => { let sharingAccess: SharingAccess; - const nodeUids = Array.from({ length: BATCH_LOADING_SIZE + 5 }, (_, i) => `nodeUid${i}`); - const nodes = nodeUids.map((nodeUid) => ({ nodeUid })); + const nodeUids = Array.from({ length: BATCH_LOADING_SIZE + 5 }, (_, i) => `volumeId~nodeUid${i}`); + const nodes = nodeUids.map((nodeUid) => ({ + nodeUid, + shareId: 'shareId', + name: { ok: true, value: `name${nodeUid.split('~')[1]}` } + })); const nodeUidsIterator = async function* () { for (const nodeUid of nodeUids) { yield nodeUid; @@ -33,25 +39,67 @@ describe('SharingAccess', () => { creationTime: new Date('2025-01-01'), node: { type: NodeType.File, - mediaType: 'mediaType', + mediaType: 'image/jpeg', }, }; }), + removeMember: jest.fn(), + iterateInvitationUids: jest.fn().mockImplementation(async function* () { + yield 'invitationUid'; + }), + getInvitation: jest.fn().mockResolvedValue({ + uid: 'invitationUid', + node: { uid: 'volumeId~nodeUid' }, + inviteeEmail: 'invitee-email', + role: MemberRole.Viewer, + }), + acceptInvitation: jest.fn(), + rejectInvitation: jest.fn(), + deleteBookmark: jest.fn(), }; + // @ts-expect-error No need to implement all methods for mocking cache = { setSharedByMeNodeUids: jest.fn(), setSharedWithMeNodeUids: jest.fn(), + getSharedByMeNodeUids: jest.fn(), + getSharedWithMeNodeUids: jest.fn(), + hasSharedByMeNodeUidsLoaded: jest.fn().mockResolvedValue(true), + hasSharedWithMeNodeUidsLoaded: jest.fn().mockResolvedValue(true), + addSharedByMeNodeUid: jest.fn(), + removeSharedByMeNodeUid: jest.fn(), + addSharedWithMeNodeUid: jest.fn(), + removeSharedWithMeNodeUid: jest.fn(), }; + // @ts-expect-error No need to implement all methods for mocking cryptoService = { decryptInvitation: jest.fn(), decryptBookmark: jest.fn(), + decryptInvitationWithNode: jest.fn().mockResolvedValue({ + uid: 'invitationUid', + inviteeEmail: 'invitee-email', + role: MemberRole.Viewer, + node: { + uid: 'volumeId~nodeUid', + name: { ok: true, value: 'SharedFile.txt' }, + type: NodeType.File, + }, + }), + acceptInvitation: jest.fn().mockResolvedValue({ + base64SessionKeySignature: 'mockSignature', + }), }; + // @ts-expect-error No need to implement all methods for mocking sharesService = { getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + loadEncryptedShare: jest.fn().mockResolvedValue({ + id: 'shareId', + membership: { memberUid: 'memberUid' }, + }), }; + // @ts-expect-error No need to implement all methods for mocking nodesService = { iterateNodes: jest.fn().mockImplementation(async function* (nodeUids) { @@ -61,13 +109,18 @@ describe('SharingAccess', () => { } } }), + getNode: jest.fn().mockResolvedValue({ + nodeUid: 'volumeId~nodeUid', + shareId: 'shareId', + name: { ok: true, value: 'TestFile.txt' }, + }), }; sharingAccess = new SharingAccess(apiService, cache, cryptoService, sharesService, nodesService); }); describe('iterateSharedNodes', () => { - it('should iterate from cache', async () => { + it('should iterate from cache when available', async () => { cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(nodeUids); const result = await Array.fromAsync(sharingAccess.iterateSharedNodes()); @@ -77,20 +130,32 @@ describe('SharingAccess', () => { expect(cache.setSharedByMeNodeUids).not.toHaveBeenCalled(); }); - it('should iterate from API', async () => { - cache.getSharedByMeNodeUids = jest.fn().mockRejectedValue(new Error('Not cached')); + it('should iterate from API when cache is empty', async () => { + cache.getSharedByMeNodeUids = jest.fn().mockRejectedValue(new Error('Cache miss')); const result = await Array.fromAsync(sharingAccess.iterateSharedNodes()); expect(result).toEqual(nodes); expect(apiService.iterateSharedNodeUids).toHaveBeenCalledWith('volumeId', undefined); - expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // Mocked is a bit more over one batch + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); expect(cache.setSharedByMeNodeUids).toHaveBeenCalledWith(nodeUids); }); + + it('should ignore missing nodes during iteration', async () => { + cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(['volumeId~nodeUid1', 'volumeId~missingNode']); + nodesService.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { nodeUid: 'volumeId~nodeUid1', name: { ok: true, value: 'file1.txt' } }; + yield { missingUid: 'volumeId~missingNode' }; + }); + + const result = await Array.fromAsync(sharingAccess.iterateSharedNodes()); + + expect(result).toEqual([{ nodeUid: 'volumeId~nodeUid1', name: { ok: true, value: 'file1.txt' } }]); + }); }); describe('iterateSharedNodesWithMe', () => { - it('should iterate from cache', async () => { + it('should iterate from cache when available', async () => { cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(nodeUids); const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe()); @@ -100,24 +165,149 @@ describe('SharingAccess', () => { expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); }); - it('should iterate from API', async () => { - cache.getSharedWithMeNodeUids = jest.fn().mockRejectedValue(new Error('Not cached')); + it('should iterate from API when cache is empty', async () => { + cache.getSharedWithMeNodeUids = jest.fn().mockRejectedValue(new Error('Cache miss')); const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe()); expect(result).toEqual(nodes); expect(apiService.iterateSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); - expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // Mocked is a bit more over one batch + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(nodeUids); }); }); + describe('removeSharedNodeWithMe', () => { + const nodeUid = 'volumeId~nodeUid'; + + it('should remove member and update cache', async () => { + await sharingAccess.removeSharedNodeWithMe(nodeUid); + + expect(nodesService.getNode).toHaveBeenCalledWith(nodeUid); + expect(sharesService.loadEncryptedShare).toHaveBeenCalledWith('shareId'); + expect(apiService.removeMember).toHaveBeenCalledWith('memberUid'); + expect(cache.removeSharedWithMeNodeUid).toHaveBeenCalledWith(nodeUid); + }); + + it('should return early if node is not shared', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + nodeUid, + shareId: undefined, + name: { ok: true, value: 'UnsharedFile.txt' } + }); + + await sharingAccess.removeSharedNodeWithMe(nodeUid); + + expect(sharesService.loadEncryptedShare).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(cache.removeSharedWithMeNodeUid).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError if no membership found', async () => { + sharesService.loadEncryptedShare = jest.fn().mockResolvedValue({ + id: 'shareId', + membership: undefined, + }); + + await expect(sharingAccess.removeSharedNodeWithMe(nodeUid)).rejects.toThrow(ValidationError); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(cache.removeSharedWithMeNodeUid).not.toHaveBeenCalled(); + }); + }); + + describe('iterateInvitations', () => { + it('should iterate and decrypt invitations', async () => { + const result = await Array.fromAsync(sharingAccess.iterateInvitations()); + + expect(result).toEqual([{ + uid: 'invitationUid', + inviteeEmail: 'invitee-email', + role: MemberRole.Viewer, + node: { + uid: 'volumeId~nodeUid', + name: { ok: true, value: 'SharedFile.txt' }, + type: NodeType.File, + }, + }]); + expect(apiService.iterateInvitationUids).toHaveBeenCalledWith(undefined); + expect(apiService.getInvitation).toHaveBeenCalledWith('invitationUid'); + expect(cryptoService.decryptInvitationWithNode).toHaveBeenCalledWith({ + uid: 'invitationUid', + node: { uid: 'volumeId~nodeUid' }, + inviteeEmail: 'invitee-email', + role: MemberRole.Viewer, + }); + }); + }); + + describe('acceptInvitation', () => { + it('should accept invitation and update cache', async () => { + const invitationUid = 'invitationUid'; + + await sharingAccess.acceptInvitation(invitationUid); + + expect(apiService.getInvitation).toHaveBeenCalledWith(invitationUid); + expect(cryptoService.acceptInvitation).toHaveBeenCalledWith({ + uid: 'invitationUid', + node: { uid: 'volumeId~nodeUid' }, + inviteeEmail: 'invitee-email', + role: MemberRole.Viewer, + }); + expect(apiService.acceptInvitation).toHaveBeenCalledWith(invitationUid, 'mockSignature'); + expect(cache.addSharedWithMeNodeUid).toHaveBeenCalledWith('volumeId~nodeUid'); + }); + + it('should not update cache when not loaded', async () => { + const invitationUid = 'invitationUid'; + cache.hasSharedWithMeNodeUidsLoaded = jest.fn().mockResolvedValue(false); + + await sharingAccess.acceptInvitation(invitationUid); + + expect(apiService.acceptInvitation).toHaveBeenCalledWith(invitationUid, 'mockSignature'); + expect(cache.addSharedWithMeNodeUid).not.toHaveBeenCalled(); + }); + }); + + describe('rejectInvitation', () => { + it('should reject invitation', async () => { + const invitationUid = 'invitationUid'; + + await sharingAccess.rejectInvitation(invitationUid); + + expect(apiService.rejectInvitation).toHaveBeenCalledWith(invitationUid); + }); + }); + describe('iterateBookmarks', () => { - it('should return decrypted bookmark', async () => { + it('should return successfully decrypted bookmark', async () => { + cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ + url: resultOk('https://example.com/file.pdf'), + customPassword: resultOk('password123'), + nodeName: resultOk('ImportantDocument.pdf'), + }); + + const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); + + expect(result).toEqual([ + resultOk({ + uid: 'tokenId', + creationTime: new Date('2025-01-01'), + url: 'https://example.com/file.pdf', + customPassword: 'password123', + node: { + name: 'ImportantDocument.pdf', + type: NodeType.File, + mediaType: 'image/jpeg', + }, + }), + ]); + }); + + it('should return successfully decrypted bookmark with undefined password', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ - url: resultOk('url'), - customPassword: resultOk('customPassword'), - nodeName: resultOk('nodeName'), + url: resultOk('https://example.com/file.pdf'), + customPassword: resultOk(undefined), + nodeName: resultOk('PublicDocument.pdf'), }); const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); @@ -126,22 +316,46 @@ describe('SharingAccess', () => { resultOk({ uid: 'tokenId', creationTime: new Date('2025-01-01'), - url: 'url', - customPassword: 'customPassword', + url: 'https://example.com/file.pdf', + customPassword: undefined, + node: { + name: 'PublicDocument.pdf', + type: NodeType.File, + mediaType: 'image/jpeg', + }, + }), + ]); + }); + + it('should return degraded bookmark when URL cannot be decrypted', async () => { + cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ + url: resultError('URL decryption failed'), + customPassword: resultOk('password123'), + nodeName: resultOk('Document.pdf'), + }); + + const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); + + expect(result).toEqual([ + resultError({ + uid: 'tokenId', + creationTime: new Date('2025-01-01'), + url: resultError('URL decryption failed'), + customPassword: resultOk('password123'), node: { - name: 'nodeName', + name: resultOk('Document.pdf'), type: NodeType.File, - mediaType: 'mediaType', + mediaType: 'image/jpeg', }, }), ]); }); - it('should return degraded bookmark if URL password cannot be decrypted', async () => { + it('should return degraded bookmark when custom password cannot be decrypted', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ - url: resultError('url cannot be decrypted'), - customPassword: resultOk('url cannot be decrypted'), - nodeName: resultError('url cannot be decrypted'), + url: resultOk('https://example.com/file.pdf'), + customPassword: resultError('Password decryption failed'), + nodeName: resultOk('Document.pdf'), }); const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); @@ -150,22 +364,22 @@ describe('SharingAccess', () => { resultError({ uid: 'tokenId', creationTime: new Date('2025-01-01'), - url: resultError('url cannot be decrypted'), - customPassword: resultOk('url cannot be decrypted'), + url: resultOk('https://example.com/file.pdf'), + customPassword: resultError('Password decryption failed'), node: { - name: resultError('url cannot be decrypted'), + name: resultOk('Document.pdf'), type: NodeType.File, - mediaType: 'mediaType', + mediaType: 'image/jpeg', }, }), ]); }); - it('should return degraded bookmark if node name cannot be decrypted', async () => { + it('should return degraded bookmark when node name cannot be decrypted', async () => { cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ - url: resultOk('url'), + url: resultOk('https://example.com/file.pdf'), customPassword: resultOk(undefined), - nodeName: resultError('node name cannot be decrypted'), + nodeName: resultError('Node name decryption failed'), }); const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); @@ -174,15 +388,49 @@ describe('SharingAccess', () => { resultError({ uid: 'tokenId', creationTime: new Date('2025-01-01'), - url: resultOk('url'), + url: resultOk('https://example.com/file.pdf'), customPassword: resultOk(undefined), node: { - name: resultError('node name cannot be decrypted'), + name: resultError('Node name decryption failed'), + type: NodeType.File, + mediaType: 'image/jpeg', + }, + }), + ]); + }); + + it('should return degraded bookmark when all decryption fails', async () => { + cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ + url: resultError('URL decryption failed'), + customPassword: resultError('Password decryption failed'), + nodeName: resultError('Node name decryption failed'), + }); + + const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); + + expect(result).toEqual([ + resultError({ + uid: 'tokenId', + creationTime: new Date('2025-01-01'), + url: resultError('URL decryption failed'), + customPassword: resultError('Password decryption failed'), + node: { + name: resultError('Node name decryption failed'), type: NodeType.File, - mediaType: 'mediaType', + mediaType: 'image/jpeg', }, }), ]); }); }); + + describe('deleteBookmark', () => { + it('should delete bookmark using tokenId', async () => { + const bookmarkUid = 'tokenId123'; + + await sharingAccess.deleteBookmark(bookmarkUid); + + expect(apiService.deleteBookmark).toHaveBeenCalledWith(bookmarkUid); + }); + }); }); diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index d327d791..7f78086e 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -124,6 +124,9 @@ export class SharingAccess { } await this.apiService.removeMember(memberUid); + if (await this.cache.hasSharedWithMeNodeUidsLoaded()) { + await this.cache.removeSharedWithMeNodeUid(nodeUid); + } } async *iterateInvitations(signal?: AbortSignal): AsyncGenerator { @@ -138,6 +141,9 @@ export class SharingAccess { const encryptedInvitation = await this.apiService.getInvitation(invitationUid); const { base64SessionKeySignature } = await this.cryptoService.acceptInvitation(encryptedInvitation); await this.apiService.acceptInvitation(invitationUid, base64SessionKeySignature); + if (await this.cache.hasSharedWithMeNodeUidsLoaded()) { + await this.cache.addSharedWithMeNodeUid(encryptedInvitation.node.uid); + } } async rejectInvitation(invitationUid: string): Promise { From ab4daafaff6c60d38356acc2f9686abf6adf8f6d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Sep 2025 09:18:33 +0200 Subject: [PATCH 215/791] js/v0.3.2 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index b571f097..4576e8fc 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.3.1", + "version": "0.3.2", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From de5f1efad421ba0a010253201a780fc75b465b4f Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Sep 2025 08:55:13 +0200 Subject: [PATCH 216/791] Return FastForward event if there is no relevant core event --- js/sdk/src/internal/events/apiService.ts | 2 +- .../internal/events/coreEventManager.test.ts | 48 ++++++++----------- .../src/internal/events/coreEventManager.ts | 2 +- 3 files changed, 23 insertions(+), 29 deletions(-) diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index e5c5cb93..703480cd 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -57,7 +57,7 @@ export class EventsAPIService { return { latestEventId: result.EventID, more: result.More === 1, - refresh: result.Refresh === 1, + refresh, events, }; } diff --git a/js/sdk/src/internal/events/coreEventManager.test.ts b/js/sdk/src/internal/events/coreEventManager.test.ts index dbd0640b..263dc927 100644 --- a/js/sdk/src/internal/events/coreEventManager.test.ts +++ b/js/sdk/src/internal/events/coreEventManager.test.ts @@ -46,29 +46,6 @@ describe('CoreEventManager', () => { const eventId = 'event1'; const latestEventId = 'event2'; - it('should yield ShareWithMeUpdated event when refresh is true', async () => { - const mockEvents: DriveEventsListWithStatus = { - latestEventId, - more: false, - refresh: true, - events: [], - }; - mockApiService.getCoreEvents.mockResolvedValue(mockEvents); - - const events = []; - for await (const event of coreEventManager.getEvents(eventId)) { - events.push(event); - } - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: DriveEventType.SharedWithMeUpdated, - treeEventScopeId: 'core', - eventId: latestEventId, - }); - expect(mockApiService.getCoreEvents).toHaveBeenCalledWith(eventId); - }); - it('should yield all events when there are actual events', async () => { const mockEvent1: DriveEvent = { type: DriveEventType.SharedWithMeUpdated, @@ -88,14 +65,31 @@ describe('CoreEventManager', () => { }; mockApiService.getCoreEvents.mockResolvedValue(mockEvents); - const events = []; - for await (const event of coreEventManager.getEvents(eventId)) { - events.push(event); - } + const events = await Array.fromAsync(coreEventManager.getEvents(eventId)); expect(events).toHaveLength(2); expect(events[0]).toEqual(mockEvent1); expect(events[1]).toEqual(mockEvent2); }); + + it('should yield FastForward event there are no events but lastEventId changed', async () => { + const mockEvents: DriveEventsListWithStatus = { + latestEventId, + more: false, + refresh: false, + events: [], + }; + mockApiService.getCoreEvents.mockResolvedValue(mockEvents); + + const events = await Array.fromAsync(coreEventManager.getEvents(eventId)); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: DriveEventType.FastForward, + treeEventScopeId: 'core', + eventId: latestEventId, + }); + expect(mockApiService.getCoreEvents).toHaveBeenCalledWith(eventId); + }); }); }); diff --git a/js/sdk/src/internal/events/coreEventManager.ts b/js/sdk/src/internal/events/coreEventManager.ts index 840404fb..9afbeffe 100644 --- a/js/sdk/src/internal/events/coreEventManager.ts +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -32,7 +32,7 @@ export class CoreEventManager implements EventManagerInterface { const events = await this.apiService.getCoreEvents(eventId); if (events.events.length === 0 && events.latestEventId !== eventId) { yield { - type: DriveEventType.SharedWithMeUpdated, + type: DriveEventType.FastForward, treeEventScopeId: 'core', eventId: events.latestEventId, }; From 287cfbde43147aa3c831761e621e2446b8ff4f17 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Sep 2025 15:41:10 +0200 Subject: [PATCH 217/791] Handle node out of sync during rename --- js/sdk/src/internal/apiService/errorCodes.ts | 1 + js/sdk/src/internal/apiService/errors.ts | 6 ++++ js/sdk/src/internal/nodes/apiService.test.ts | 36 ++++++++++++++++++- js/sdk/src/internal/nodes/apiService.ts | 34 ++++++++++++------ js/sdk/src/internal/nodes/errors.ts | 5 +++ .../internal/nodes/nodesManagement.test.ts | 10 ++++++ js/sdk/src/internal/nodes/nodesManagement.ts | 36 ++++++++++++------- 7 files changed, 105 insertions(+), 23 deletions(-) create mode 100644 js/sdk/src/internal/nodes/errors.ts diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts index 52cfaa80..1956b260 100644 --- a/js/sdk/src/internal/apiService/errorCodes.ts +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -18,6 +18,7 @@ export const enum ErrorCode { OK = 1000, OK_MANY = 1001, OK_ASYNC = 1002, + INVALID_REQUIREMENTS = 2000, INVALID_VALUE = 2001, NOT_ENOUGH_PERMISSIONS = 2011, NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS = 2026, diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 11258c0e..770d24f6 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -57,6 +57,8 @@ export function apiErrorFactory({ // Here we convert only general enough codes. Specific cases that are // not clear from the code itself must be handled by each module // separately. + case ErrorCode.INVALID_REQUIREMENTS: + return new InvalidRequirementsAPIError(message, code, details); case ErrorCode.INVALID_VALUE: case ErrorCode.NOT_ENOUGH_PERMISSIONS: case ErrorCode.NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS: @@ -108,3 +110,7 @@ export class APICodeError extends ServerError { export class NotFoundAPIError extends ValidationError { name = 'NotFoundAPIError'; } + +export class InvalidRequirementsAPIError extends ValidationError { + name = 'InvalidRequirementsAPIError'; +} diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 79a090d6..07c604d9 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -1,7 +1,8 @@ import { MemberRole, NodeType } from '../../interface'; import { getMockLogger } from '../../tests/logger'; -import { DriveAPIService, ErrorCode } from '../apiService'; +import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService'; import { NodeAPIService } from './apiService'; +import { NodeOutOfSyncError } from './errors'; function generateAPIFileNode(linkOverrides = {}, overrides = {}) { const node = generateAPINode(); @@ -542,4 +543,37 @@ describe('nodeAPIService', () => { } }); }); + + describe('renameNode', () => { + it('should rename node', async () => { + await api.renameNode( + 'volumeId~nodeId1', + { hash: 'originalHash' }, + { encryptedName: 'encryptedName1', nameSignatureEmail: 'nameSignatureEmail1', hash: 'newHash' }, + ); + + expect(apiMock.put).toHaveBeenCalledWith( + 'drive/v2/volumes/volumeId/links/nodeId1/rename', + { + Name: 'encryptedName1', + NameSignatureEmail: 'nameSignatureEmail1', + Hash: 'newHash', + OriginalHash: 'originalHash', + }, + undefined, + ); + }); + + it('should throw error if node is out of sync', async () => { + apiMock.put = jest.fn().mockRejectedValue(new InvalidRequirementsAPIError('Node is out of sync')); + + await expect( + api.renameNode( + 'volumeId~nodeId1', + { hash: 'originalHash' }, + { encryptedName: 'encryptedName1', nameSignatureEmail: 'nameSignatureEmail1', hash: 'newHash' }, + ), + ).rejects.toThrow(new NodeOutOfSyncError('Node is out of sync')); + }); + }); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 38631e38..029282dd 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -6,6 +6,7 @@ import { MemberRole, RevisionState } from '../../interface/nodes'; import { DriveAPIService, drivePaths, + InvalidRequirementsAPIError, isCodeOk, nodeTypeNumberToNodeType, permissionsToMemberRole, @@ -13,6 +14,7 @@ import { import { asyncIteratorRace } from '../asyncIteratorRace'; import { batch } from '../batch'; import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from '../uids'; +import { NodeOutOfSyncError } from './errors'; import { EncryptedNode, EncryptedRevision, Thumbnail } from './interface'; // This is the number of calls to the API that are made in parallel. @@ -251,16 +253,28 @@ export class NodeAPIService { ): Promise { const { volumeId, nodeId } = splitNodeUid(nodeUid); - await this.apiService.put, PutRenameNodeResponse>( - `drive/v2/volumes/${volumeId}/links/${nodeId}/rename`, - { - Name: newNode.encryptedName, - NameSignatureEmail: newNode.nameSignatureEmail, - Hash: newNode.hash, - OriginalHash: originalNode.hash || null, - }, - signal, - ); + try { + await this.apiService.put< + Omit, + PutRenameNodeResponse + >( + `drive/v2/volumes/${volumeId}/links/${nodeId}/rename`, + { + Name: newNode.encryptedName, + NameSignatureEmail: newNode.nameSignatureEmail, + Hash: newNode.hash, + OriginalHash: originalNode.hash || null, + }, + signal, + ); + } catch (error: unknown) { + // API returns generic code 2000 when node is out of sync. + // We map this to specific error for clarity. + if (error instanceof InvalidRequirementsAPIError) { + throw new NodeOutOfSyncError(error.message, error.code, { cause: error }); + } + throw error; + } } async moveNode( diff --git a/js/sdk/src/internal/nodes/errors.ts b/js/sdk/src/internal/nodes/errors.ts new file mode 100644 index 00000000..44426bd0 --- /dev/null +++ b/js/sdk/src/internal/nodes/errors.ts @@ -0,0 +1,5 @@ +import { ValidationError } from "../../errors"; + +export class NodeOutOfSyncError extends ValidationError { + name = 'NodeOutOfSyncError'; +} diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 76066520..75ae9d4c 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -5,6 +5,7 @@ import { NodesAccess } from './nodesAccess'; import { DecryptedNode } from './interface'; import { NodesManagement } from './nodesManagement'; import { NodeResult } from '../../interface'; +import { NodeOutOfSyncError } from './errors'; describe('NodesManagement', () => { let apiService: NodeAPIService; @@ -130,6 +131,15 @@ describe('NodesManagement', () => { expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledWith('nodeUid'); }); + it('renameNode refreshes cache if node is out of sync', async () => { + const error = new NodeOutOfSyncError('Node is out of sync'); + apiService.renameNode = jest.fn().mockRejectedValue(error); + + await expect(management.renameNode('nodeUid', 'new name')).rejects.toThrow(error); + + expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledWith('nodeUid'); + }); + it('moveNode manages move and updates cache', async () => { const encryptedCrypto = { encryptedName: 'movedArmoredNodeName', diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index e8d70458..c7c7edeb 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -3,14 +3,15 @@ import { c } from 'ttag'; import { MemberRole, NodeType, NodeResult, resultOk } from '../../interface'; import { AbortError, ValidationError } from '../../errors'; import { getErrorMessage } from '../errors'; +import { splitNodeUid } from '../uids'; import { NodeAPIService } from './apiService'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; +import { NodeOutOfSyncError } from './errors'; import { DecryptedNode } from './interface'; import { NodesAccess } from './nodesAccess'; import { validateNodeName } from './validations'; import { generateFolderExtendedAttributes } from './extendedAttributes'; -import { splitNodeUid } from '../uids'; /** * Provides high-level actions for managing nodes. @@ -63,17 +64,28 @@ export class NodesManagement { throw new Error('Node hash not generated'); } - await this.apiService.renameNode( - nodeUid, - { - hash: node.hash, - }, - { - encryptedName: armoredNodeName, - nameSignatureEmail: signatureEmail, - hash: hash, - }, - ); + try { + await this.apiService.renameNode( + nodeUid, + { + hash: node.hash, + }, + { + encryptedName: armoredNodeName, + nameSignatureEmail: signatureEmail, + hash: hash, + }, + ); + } catch (error: unknown) { + // If node is out of sync, we notify cache to refresh it before next usage. + // We let the code still throw the error as it must bubble to the user + // so user can re-open the node to ensure they still want to rename it. + if (error instanceof NodeOutOfSyncError) { + await this.nodesAccess.notifyNodeChanged(nodeUid); + } + throw error; + } + await this.nodesAccess.notifyNodeChanged(nodeUid); const newNode: DecryptedNode = { ...node, From 98a6692ee35ecf0de04f71e671d42f59dd4e27f7 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Sep 2025 08:56:47 +0200 Subject: [PATCH 218/791] Add copyNodes --- js/sdk/src/internal/nodes/apiService.ts | 44 +++++++++ .../src/internal/nodes/cryptoService.test.ts | 16 ++-- js/sdk/src/internal/nodes/cryptoService.ts | 2 +- .../internal/nodes/nodesManagement.test.ts | 95 ++++++++++++++++++- js/sdk/src/internal/nodes/nodesManagement.ts | 77 ++++++++++++++- js/sdk/src/protonDriveClient.ts | 23 +++++ 6 files changed, 242 insertions(+), 15 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 029282dd..6f680d2b 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -50,6 +50,13 @@ type PutMoveNodeRequest = Extract< type PutMoveNodeResponse = drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/move']['put']['responses']['200']['content']['application/json']; +type PostCopyNodeRequest = Extract< + drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCopyNodeResponse = + drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['responses']['200']['content']['application/json']; + type PostTrashNodesRequest = Extract< drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['requestBody'], { content: object } @@ -317,6 +324,43 @@ export class NodeAPIService { ); } + async copyNode( + nodeUid: string, + newNode: { + parentUid: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature?: string; + signatureEmail?: string; + encryptedName: string; + nameSignatureEmail?: string; + hash: string; + }, + signal?: AbortSignal, + ): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const { volumeId: parentVolumeId, nodeId: parentNodeId } = splitNodeUid(newNode.parentUid); + + const response = await this.apiService.post( + `drive/volumes/${volumeId}/links/${nodeId}/copy`, + { + TargetVolumeID: parentVolumeId, + TargetParentLinkID: parentNodeId, + NodePassphrase: newNode.armoredNodePassphrase, + // @ts-expect-error: API accepts NodePassphraseSignature as optional. + NodePassphraseSignature: newNode.armoredNodePassphraseSignature, + // @ts-expect-error: API accepts SignatureEmail as optional. + SignatureEmail: newNode.signatureEmail, + Name: newNode.encryptedName, + // @ts-expect-error: API accepts NameSignatureEmail as optional. + NameSignatureEmail: newNode.nameSignatureEmail, + Hash: newNode.hash, + }, + signal, + ); + + return makeNodeUid(volumeId, response.LinkID); + } + // Improvement requested: split into multiple calls for many nodes. async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const nodeIds = nodeUids.map(splitNodeUid); diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 492e64ba..2709a365 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -985,7 +985,7 @@ describe('nodesCryptoService', () => { }); }); - describe('moveNode', () => { + describe('encryptNodeWithNewParent', () => { it('should encrypt node data for move operation', async () => { const node = { name: { ok: true, value: 'testFile.txt' }, @@ -1012,7 +1012,7 @@ describe('nodesCryptoService', () => { armoredPassphraseSignature: 'passphraseSignature', }); - const result = await cryptoService.moveNode(node, keys as any, parentKeys, address); + const result = await cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, address); expect(result).toEqual({ encryptedName: 'encryptedNodeName', @@ -1056,9 +1056,9 @@ describe('nodesCryptoService', () => { addressKey: 'addressKey' as any, }; - await expect(cryptoService.moveNode(node, keys as any, parentKeys, address)).rejects.toThrow( - 'Moving item to a non-folder is not allowed', - ); + await expect( + cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, address), + ).rejects.toThrow('Moving item to a non-folder is not allowed'); }); it('should throw error when node has invalid name', async () => { @@ -1079,9 +1079,9 @@ describe('nodesCryptoService', () => { addressKey: 'addressKey' as any, }; - await expect(cryptoService.moveNode(node, keys as any, parentKeys, address)).rejects.toThrow( - 'Cannot move item without a valid name, please rename the item first', - ); + await expect( + cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, address), + ).rejects.toThrow('Cannot move item without a valid name, please rename the item first'); }); }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 3b8de594..f644d2a8 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -601,7 +601,7 @@ export class NodesCryptoService { }; } - async moveNode( + async encryptNodeWithNewParent( node: Pick, keys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey }, parentKeys: { key: PrivateKey; hashKey: Uint8Array }, diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 75ae9d4c..b8ce5ca3 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -50,6 +50,7 @@ describe('NodesManagement', () => { apiService = { renameNode: jest.fn(), moveNode: jest.fn(), + copyNode: jest.fn(), trashNodes: jest.fn(async function* (uids) { yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult); }), @@ -72,7 +73,7 @@ describe('NodesManagement', () => { armoredNodeName: 'newArmoredNodeName', hash: 'newHash', }), - moveNode: jest.fn(), + encryptNodeWithNewParent: jest.fn(), createFolder: jest.fn(), }; // @ts-expect-error No need to implement all methods for mocking @@ -101,6 +102,7 @@ describe('NodesManagement', () => { getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: 'root-email', addressKey: 'root-key' }), notifyNodeChanged: jest.fn(), notifyNodeDeleted: jest.fn(), + notifyChildCreated: jest.fn(), }; management = new NodesManagement(apiService, cryptoCache, cryptoService, nodesAccess); @@ -149,7 +151,7 @@ describe('NodesManagement', () => { signatureEmail: 'movedSignatureEmail', nameSignatureEmail: 'movedNameSignatureEmail', }; - cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); + cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto); const newNode = await management.moveNode('nodeUid', 'newParentNodeUid'); @@ -162,7 +164,7 @@ describe('NodesManagement', () => { nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, }); expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid'); - expect(cryptoService.moveNode).toHaveBeenCalledWith( + expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( nodes.nodeUid, expect.objectContaining({ key: 'nodeUid-key', @@ -198,11 +200,11 @@ describe('NodesManagement', () => { signatureEmail: 'movedSignatureEmail', nameSignatureEmail: 'movedNameSignatureEmail', }; - cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto); + cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto); const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid'); - expect(cryptoService.moveNode).toHaveBeenCalledWith( + expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( nodes.anonymousNodeUid, expect.objectContaining({ key: 'anonymousNodeUid-key', @@ -234,6 +236,89 @@ describe('NodesManagement', () => { ); }); + it('copyNode manages copy and updates cache', async () => { + const encryptedCrypto = { + encryptedName: 'copiedArmoredNodeName', + hash: 'copiedHash', + armoredNodePassphrase: 'copiedArmoredNodePassphrase', + armoredNodePassphraseSignature: 'copiedArmoredNodePassphraseSignature', + signatureEmail: 'copiedSignatureEmail', + nameSignatureEmail: 'copiedNameSignatureEmail', + }; + cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto); + + const newNode = await management.copyNode('nodeUid', 'newParentNodeUid'); + + expect(newNode).toEqual({ + ...nodes.nodeUid, + parentUid: 'newParentNodeUid', + encryptedName: 'copiedArmoredNodeName', + hash: 'copiedHash', + keyAuthor: { ok: true, value: 'copiedSignatureEmail' }, + nameAuthor: { ok: true, value: 'copiedNameSignatureEmail' }, + }); + expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid'); + expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( + nodes.nodeUid, + expect.objectContaining({ + key: 'nodeUid-key', + passphrase: 'nodeUid-passphrase', + passphraseSessionKey: 'nodeUid-passphraseSessionKey', + contentKeyPacketSessionKey: 'nodeUid-contentKeyPacketSessionKey', + nameSessionKey: 'nodeUid-nameSessionKey', + }), + expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), + { email: 'root-email', addressKey: 'root-key' }, + ); + expect(apiService.copyNode).toHaveBeenCalledWith('nodeUid', { + parentUid: 'newParentNodeUid', + ...encryptedCrypto, + armoredNodePassphraseSignature: undefined, + signatureEmail: undefined, + }); + expect(nodesAccess.notifyNodeChanged).not.toHaveBeenCalledWith(); + expect(nodesAccess.notifyChildCreated).toHaveBeenCalledWith('newParentNodeUid'); + }); + + it('copyNode manages copy of anonymous node', async () => { + const encryptedCrypto = { + encryptedName: 'copiedArmoredNodeName', + hash: 'copiedHash', + armoredNodePassphrase: 'copiedArmoredNodePassphrase', + armoredNodePassphraseSignature: 'copiedArmoredNodePassphraseSignature', + signatureEmail: 'copiedSignatureEmail', + nameSignatureEmail: 'copiedNameSignatureEmail', + }; + cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto); + + const newNode = await management.copyNode('anonymousNodeUid', 'newParentNodeUid'); + + expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( + nodes.anonymousNodeUid, + expect.objectContaining({ + key: 'anonymousNodeUid-key', + passphrase: 'anonymousNodeUid-passphrase', + passphraseSessionKey: 'anonymousNodeUid-passphraseSessionKey', + contentKeyPacketSessionKey: 'anonymousNodeUid-contentKeyPacketSessionKey', + nameSessionKey: 'anonymousNodeUid-nameSessionKey', + }), + expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), + { email: 'root-email', addressKey: 'root-key' }, + ); + expect(newNode).toEqual({ + ...nodes.anonymousNodeUid, + parentUid: 'newParentNodeUid', + encryptedName: 'copiedArmoredNodeName', + hash: 'copiedHash', + keyAuthor: { ok: true, value: 'copiedSignatureEmail' }, + nameAuthor: { ok: true, value: 'copiedNameSignatureEmail' }, + }); + expect(apiService.copyNode).toHaveBeenCalledWith('anonymousNodeUid', { + parentUid: 'newParentNodeUid', + ...encryptedCrypto, + }); + }); + it('trashes node and updates cache', async () => { const uids = ['v1~n1', 'v1~n2']; const trashed = new Set(); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index c7c7edeb..358eb345 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -137,7 +137,7 @@ export class NodesManagement { throw new ValidationError(c('Error').t`Moving item to a non-folder is not allowed`); } - const encryptedCrypto = await this.cryptoService.moveNode( + const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent( node, keys, { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, @@ -182,6 +182,81 @@ export class NodesManagement { return newNode; } + // Improvement requested: copy nodes in parallel + async *copyNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + for (const nodeUid of nodeUids) { + if (signal?.aborted) { + throw new AbortError(c('Error').t`Copy operation aborted`); + } + try { + await this.copyNode(nodeUid, newParentNodeUid); + yield { + uid: nodeUid, + ok: true, + }; + } catch (error: unknown) { + yield { + uid: nodeUid, + ok: false, + error: getErrorMessage(error), + }; + } + } + } + + async copyNode(nodeUid: string, newParentUid: string): Promise { + const [node, address] = await Promise.all([ + this.nodesAccess.getNode(nodeUid), + this.nodesAccess.getRootNodeEmailKey(newParentUid), + ]); + + const [keys, newParentKeys] = await Promise.all([ + this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid), + this.nodesAccess.getNodeKeys(newParentUid), + ]); + + if (!newParentKeys.hashKey) { + throw new ValidationError(c('Error').t`Copying item to a non-folder is not allowed`); + } + + const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent( + node, + keys, + { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, + address, + ); + + // Node could be uploaded or renamed by anonymous user and thus have + // missing signatures that must be added to the copy request. + // Node passphrase and signature email must be passed if and only if + // the the signatures are missing (key author is null). + const anonymousKey = node.keyAuthor.ok && node.keyAuthor.value === null; + const keySignatureProperties = !anonymousKey + ? {} + : { + signatureEmail: encryptedCrypto.signatureEmail, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + }; + await this.apiService.copyNode(nodeUid, { + ...keySignatureProperties, + parentUid: newParentUid, + armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, + encryptedName: encryptedCrypto.encryptedName, + nameSignatureEmail: encryptedCrypto.nameSignatureEmail, + hash: encryptedCrypto.hash, + }); + const newNode: DecryptedNode = { + ...node, + encryptedName: encryptedCrypto.encryptedName, + parentUid: newParentUid, + hash: encryptedCrypto.hash, + keyAuthor: resultOk(encryptedCrypto.signatureEmail), + nameAuthor: resultOk(encryptedCrypto.nameSignatureEmail), + }; + await this.nodesAccess.notifyChildCreated(newParentUid); + return newNode; + } + async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for await (const result of this.apiService.trashNodes(nodeUids, signal)) { if (result.ok) { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index cb330f36..dc74e4e3 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -384,6 +384,29 @@ export class ProtonDriveClient { yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); } + /** + * Copy the nodes to a new parent node. + * + * The operation is performed node by node and the results are yielded + * as they are available. Order of the results is not guaranteed. + * + * If one of the nodes fails to copy, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * @param nodeUids - List of node entities or their UIDs. + * @param newParentNodeUid - Node entity or its UID string. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the copy operation + */ + async *copyNodes( + nodeUids: NodeOrUid[], + newParentNodeUid: NodeOrUid, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Copying ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`); + yield* this.nodes.management.copyNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); + } + /** * Trash the nodes. * From 925496583ed50f7051380bd5557bd092ca78c37f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Sep 2025 15:30:52 +0200 Subject: [PATCH 219/791] Add filter options for listing children --- js/sdk/src/internal/nodes/apiService.ts | 42 +++++++++-- js/sdk/src/internal/nodes/interface.ts | 4 ++ js/sdk/src/internal/nodes/nodesAccess.test.ts | 72 +++++++++++++++++-- js/sdk/src/internal/nodes/nodesAccess.ts | 47 +++++++++--- js/sdk/src/protonDriveClient.ts | 10 ++- 5 files changed, 150 insertions(+), 25 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 6f680d2b..b5ae96ce 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -15,7 +15,7 @@ import { asyncIteratorRace } from '../asyncIteratorRace'; import { batch } from '../batch'; import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from '../uids'; import { NodeOutOfSyncError } from './errors'; -import { EncryptedNode, EncryptedRevision, Thumbnail } from './interface'; +import { EncryptedNode, EncryptedRevision, FilterOptions, Thumbnail } from './interface'; // This is the number of calls to the API that are made in parallel. const API_CONCURRENCY = 15; @@ -117,7 +117,7 @@ export class NodeAPIService { } async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise { - const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, signal); + const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, undefined, signal); const result = await nodesGenerator.next(); if (!result.value) { throw new ValidationError(c('Error').t`Node not found`); @@ -126,7 +126,12 @@ export class NodeAPIService { return result.value; } - async *iterateNodes(nodeUids: string[], ownVolumeId: string, signal?: AbortSignal): AsyncGenerator { + async *iterateNodes( + nodeUids: string[], + ownVolumeId: string, + filterOptions?: FilterOptions, + signal?: AbortSignal, + ): AsyncGenerator { const allNodeIds = nodeUids.map(splitNodeUid); const nodeIdsByVolumeId = new Map(); @@ -148,7 +153,13 @@ export class NodeAPIService { const isAdmin = volumeId === ownVolumeId; yield (async function* () { - const errorsPerVolume = yield* iterateNodesPerVolume(volumeId, nodeIds, isAdmin, signal); + const errorsPerVolume = yield* iterateNodesPerVolume( + volumeId, + nodeIds, + isAdmin, + filterOptions, + signal, + ); if (errorsPerVolume.length) { errors.push(...errorsPerVolume); } @@ -168,6 +179,7 @@ export class NodeAPIService { volumeId: string, nodeIds: string[], isOwnVolumeId: boolean, + filterOptions?: FilterOptions, signal?: AbortSignal, ): AsyncGenerator { const errors: unknown[] = []; @@ -183,7 +195,11 @@ export class NodeAPIService { for (const link of response.Links) { try { - yield linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); + const encryptedNode = linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); + if (filterOptions?.type && encryptedNode.type !== filterOptions.type) { + continue; + } + yield encryptedNode; } catch (error: unknown) { this.logger.error(`Failed to transform node ${link.Link.LinkID}`, error); errors.push(error); @@ -195,13 +211,25 @@ export class NodeAPIService { } // Improvement requested: load next page sooner before all IDs are yielded. - async *iterateChildrenNodeUids(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + async *iterateChildrenNodeUids( + parentNodeUid: string, + onlyFolders: boolean = false, + signal?: AbortSignal, + ): AsyncGenerator { const { volumeId, nodeId } = splitNodeUid(parentNodeUid); let anchor = ''; while (true) { + const queryParams = new URLSearchParams(); + if (onlyFolders) { + queryParams.set('FoldersOnly', '1'); + } + if (anchor) { + queryParams.set('AnchorID', anchor); + } + const response = await this.apiService.get( - `drive/v2/volumes/${volumeId}/folders/${nodeId}/children?${anchor ? `AnchorID=${anchor}` : ''}`, + `drive/v2/volumes/${volumeId}/folders/${nodeId}/children?${queryParams.toString()}`, signal, ); for (const linkID of response.LinkIDs) { diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index ce711f4b..76c4fc30 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -12,6 +12,10 @@ import { RevisionState, } from '../../interface'; +export type FilterOptions = { + type?: NodeType; +}; + /** * Internal common node interface for both encrypted or decrypted node. */ diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 258deaf1..4490185b 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -209,7 +209,12 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node1, node4, node2, node3]); - expect(apiService.iterateNodes).toHaveBeenCalledWith([node2.uid, node3.uid], 'volumeId', undefined); + expect(apiService.iterateNodes).toHaveBeenCalledWith( + [node2.uid, node3.uid], + 'volumeId', + undefined, // filterOptions + undefined, // signal + ); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(2); expect(cache.setNode).toHaveBeenCalledTimes(2); expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(2); @@ -226,7 +231,11 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node1, node2, node3, node4]); - expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('volumeId~parentNodeid', undefined); + expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith( + 'volumeId~parentNodeid', + false, // onlyFolders + undefined, // signal + ); expect(apiService.iterateNodes).not.toHaveBeenCalled(); expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('volumeId~parentNodeid'); }); @@ -247,11 +256,16 @@ describe('nodesAccess', () => { const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); expect(result).toMatchObject([node1, node2, node3, node4]); - expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith('volumeId~parentNodeid', undefined); + expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith( + 'volumeId~parentNodeid', + false, // onlyFolders + undefined, // signal + ); expect(apiService.iterateNodes).toHaveBeenCalledWith( ['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], 'volumeId', - undefined, + undefined, // filterOptions + undefined, // signal ); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); @@ -320,6 +334,50 @@ describe('nodesAccess', () => { expect(error.cause).toEqual([new DecryptionError('Decryption failed')]); } }); + + it('should return only filtered nodes from cache', async () => { + cache.isFolderChildrenLoaded = jest.fn().mockResolvedValue(true); + cache.iterateChildren = jest.fn().mockImplementation(async function* () { + yield { ok: true, node: { ...node1, type: NodeType.Folder } }; + yield { ok: true, node: { ...node2, type: NodeType.Folder } }; + yield { ok: true, node: { ...node3, type: NodeType.File } }; + yield { ok: true, node: { ...node4, type: NodeType.File } }; + }); + + const result = await Array.fromAsync( + access.iterateFolderChildren('volumeId~parentNodeid', { type: NodeType.Folder }), + ); + + expect(result).toMatchObject([node1, node2]); + expect(cache.setFolderChildrenLoaded).not.toHaveBeenCalled(); + }); + + it.only('should return only filtered nodes from API', async () => { + cache.isFolderChildrenLoaded = jest.fn().mockResolvedValue(false); + cache.getNode = jest.fn().mockImplementation((uid: string) => { + if (uid === parentNode.uid) { + return parentNode; + } + throw new Error('Entity not found'); + }); + apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { + yield 'volumeId~node1'; + yield 'volumeId~node2'; + yield 'volumeId~node3'; + yield 'volumeId~node4'; + }); + apiService.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { ...node1, parentUid: 'volumeId~parentNodeId', type: NodeType.Folder }; + yield { ...node2, parentUid: 'volumeId~parentNodeId', type: NodeType.Folder }; + }); + + const result = await Array.fromAsync( + access.iterateFolderChildren('volumeId~parentNodeid', { type: NodeType.Folder }), + ); + + expect(result).toMatchObject([node1, node2]); + expect(cache.setFolderChildrenLoaded).not.toHaveBeenCalled(); + }); }); describe('iterateTrashedNodes', () => { @@ -359,7 +417,8 @@ describe('nodesAccess', () => { expect(apiService.iterateNodes).toHaveBeenCalledWith( ['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], volumeId, - undefined, + undefined, // filterOptions + undefined, // signal ); expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); expect(cache.setNode).toHaveBeenCalledTimes(4); @@ -417,7 +476,8 @@ describe('nodesAccess', () => { expect(apiService.iterateNodes).toHaveBeenCalledWith( ['volumeId~node2', 'volumeId~node3'], 'volumeId', - undefined, + undefined, // filterOptions + undefined, // signal ); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 39ba3f01..0a553ece 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -12,7 +12,14 @@ import { NodesCache } from './cache'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from './extendedAttributes'; -import { SharesService, EncryptedNode, DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys } from './interface'; +import { + SharesService, + EncryptedNode, + DecryptedUnparsedNode, + DecryptedNode, + DecryptedNodeKeys, + FilterOptions, +} from './interface'; import { validateNodeName } from './validations'; import { isProtonDocument, isProtonSheet } from './mediaTypes'; @@ -71,12 +78,16 @@ export class NodesAccess { return node; } - async *iterateFolderChildren(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + async *iterateFolderChildren( + parentNodeUid: string, + filterOptions?: FilterOptions, + signal?: AbortSignal, + ): AsyncGenerator { // Ensure the parent is loaded and up-to-date. const parentNode = await this.getNode(parentNodeUid); const batchLoading = new BatchLoading({ - iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal), + iterateItems: (nodeUids) => this.loadNodes(nodeUids, filterOptions, signal), batchSize: BATCH_LOADING_SIZE, }); @@ -84,6 +95,9 @@ export class NodesAccess { if (areChildrenCached) { for await (const node of this.cache.iterateChildren(parentNodeUid)) { if (node.ok && !node.node.isStale) { + if (filterOptions?.type && node.node.type !== filterOptions.type) { + continue; + } yield node.node; } else { yield* batchLoading.load(node.uid); @@ -94,13 +108,17 @@ export class NodesAccess { } this.logger.debug(`Folder ${parentNodeUid} children are not cached`); - for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, signal)) { + const onlyFolders = filterOptions?.type === NodeType.Folder; + for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, onlyFolders, signal)) { let node; try { node = await this.cache.getNode(nodeUid); } catch {} if (node && !node.isStale) { + if (filterOptions?.type && node.type !== filterOptions.type) { + continue; + } yield node; } else { this.logger.debug(`Node ${nodeUid} from ${parentNodeUid} is ${node?.isStale ? 'stale' : 'not cached'}`); @@ -108,14 +126,18 @@ export class NodesAccess { } } yield* batchLoading.loadRest(); - await this.cache.setFolderChildrenLoaded(parentNodeUid); + + // If some nodes were filtered out, we don't have the folder fully loaded. + if (!filterOptions) { + await this.cache.setFolderChildrenLoaded(parentNodeUid); + } } // Improvement requested: keep status of loaded trash and leverage cache. async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.shareService.getMyFilesIDs(); const batchLoading = new BatchLoading({ - iterateItems: (nodeUids) => this.loadNodes(nodeUids, signal), + iterateItems: (nodeUids) => this.loadNodes(nodeUids, undefined, signal), batchSize: BATCH_LOADING_SIZE, }); for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { @@ -136,7 +158,7 @@ export class NodesAccess { async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const batchLoading = new BatchLoading({ - iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, signal), + iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, undefined, signal), batchSize: BATCH_LOADING_SIZE, }); for await (const result of this.cache.iterateNodes(nodeUids)) { @@ -191,8 +213,12 @@ export class NodesAccess { return this.decryptNode(encryptedNode); } - private async *loadNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - for await (const result of this.loadNodesWithMissingReport(nodeUids, signal)) { + private async *loadNodes( + nodeUids: string[], + filterOptions?: FilterOptions, + signal?: AbortSignal, + ): AsyncGenerator { + for await (const result of this.loadNodesWithMissingReport(nodeUids, filterOptions, signal)) { if ('missingUid' in result) { continue; } @@ -202,6 +228,7 @@ export class NodesAccess { private async *loadNodesWithMissingReport( nodeUids: string[], + filterOptions?: FilterOptions, signal?: AbortSignal, ): AsyncGenerator { const returnedNodeUids: string[] = []; @@ -209,7 +236,7 @@ export class NodesAccess { const { volumeId: ownVolumeId } = await this.shareService.getMyFilesIDs(); - const encryptedNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, signal); + const encryptedNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal); const decryptNodeMapper = async (encryptedNode: EncryptedNode): Promise> => { returnedNodeUids.push(encryptedNode.uid); try { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index dc74e4e3..2505023c 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -27,6 +27,7 @@ import { ThumbnailType, ThumbnailResult, SDKEvent, + NodeType, } from './interface'; import { getUid, @@ -297,9 +298,14 @@ export class ProtonDriveClient { * @param signal - Signal to abort the operation. * @returns An async generator of the children of the given parent node. */ - async *iterateFolderChildren(parentNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + async *iterateFolderChildren( + parentNodeUid: NodeOrUid, + filterOptions?: { type?: NodeType }, + signal?: AbortSignal, + ): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); - yield* convertInternalNodeIterator(this.nodes.access.iterateFolderChildren(getUid(parentNodeUid), signal)); + const iterator = this.nodes.access.iterateFolderChildren(getUid(parentNodeUid), filterOptions, signal); + yield* convertInternalNodeIterator(iterator); } /** From 94ab3f31fcfcff6572f2607333a4ce78e2de184c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Sep 2025 05:50:07 +0000 Subject: [PATCH 220/791] Implement ProtonDrivePhotosClient basics --- js/sdk/.eslintrc.js | 1 - js/sdk/src/internal/devices/interface.ts | 2 +- js/sdk/src/internal/devices/manager.test.ts | 6 +- js/sdk/src/internal/devices/manager.ts | 2 +- js/sdk/src/internal/events/index.ts | 7 +- js/sdk/src/internal/events/interface.ts | 4 + js/sdk/src/internal/nodes/index.test.ts | 2 +- js/sdk/src/internal/nodes/interface.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 4 +- js/sdk/src/internal/nodes/nodesAccess.ts | 10 +- js/sdk/src/internal/photos/albums.ts | 43 +++- js/sdk/src/internal/photos/apiService.ts | 163 +++++++++++- js/sdk/src/internal/photos/cache.ts | 11 - js/sdk/src/internal/photos/index.ts | 63 ++++- js/sdk/src/internal/photos/interface.ts | 24 +- js/sdk/src/internal/photos/photosTimeline.ts | 17 -- js/sdk/src/internal/photos/shares.ts | 134 ++++++++++ js/sdk/src/internal/photos/timeline.ts | 24 ++ js/sdk/src/internal/shares/manager.test.ts | 14 +- js/sdk/src/internal/shares/manager.ts | 8 +- js/sdk/src/internal/sharing/interface.ts | 2 +- .../internal/sharing/sharingAccess.test.ts | 2 +- js/sdk/src/internal/sharing/sharingAccess.ts | 2 +- js/sdk/src/protonDriveClient.ts | 4 +- js/sdk/src/protonDrivePhotosClient.ts | 243 +++++++++++++++--- 25 files changed, 673 insertions(+), 121 deletions(-) delete mode 100644 js/sdk/src/internal/photos/cache.ts delete mode 100644 js/sdk/src/internal/photos/photosTimeline.ts create mode 100644 js/sdk/src/internal/photos/shares.ts create mode 100644 js/sdk/src/internal/photos/timeline.ts diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index 53a647cd..dc19ea3c 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -19,7 +19,6 @@ module.exports = { { files: [ "*.test.ts", - "**/photos/**/*", ], rules: { // Any is used during prototyping - remove once all the types are available to fix all the places. diff --git a/js/sdk/src/internal/devices/interface.ts b/js/sdk/src/internal/devices/interface.ts index 11bc1dc7..d517ef3c 100644 --- a/js/sdk/src/internal/devices/interface.ts +++ b/js/sdk/src/internal/devices/interface.ts @@ -13,7 +13,7 @@ export type DeviceMetadata = { }; export interface SharesService { - getMyFilesIDs(): Promise<{ volumeId: string }>; + getOwnVolumeIDs(): Promise<{ volumeId: string }>; getMyFilesShareMemberEmailKey(): Promise<{ addressId: string; email: string; diff --git a/js/sdk/src/internal/devices/manager.test.ts b/js/sdk/src/internal/devices/manager.test.ts index 8adc217a..557eb8b8 100644 --- a/js/sdk/src/internal/devices/manager.test.ts +++ b/js/sdk/src/internal/devices/manager.test.ts @@ -30,7 +30,7 @@ describe('DevicesManager', () => { }; // @ts-expect-error No need to implement all methods for mocking sharesService = { - getMyFilesIDs: jest.fn(), + getOwnVolumeIDs: jest.fn(), }; // @ts-expect-error No need to implement all methods for mocking nodesService = {}; @@ -74,13 +74,13 @@ describe('DevicesManager', () => { shareId: 'shareid', } as DeviceMetadata; - sharesService.getMyFilesIDs.mockResolvedValue({ volumeId }); + sharesService.getOwnVolumeIDs.mockResolvedValue({ volumeId }); cryptoService.createDevice.mockResolvedValue({ address, shareKey, node }); apiService.createDevice.mockResolvedValue(createdDevice); const result = await manager.createDevice(name, deviceType); - expect(sharesService.getMyFilesIDs).toHaveBeenCalled(); + expect(sharesService.getOwnVolumeIDs).toHaveBeenCalled(); expect(cryptoService.createDevice).toHaveBeenCalledWith(name); expect(apiService.createDevice).toHaveBeenCalledWith( { volumeId, type: deviceType }, diff --git a/js/sdk/src/internal/devices/manager.ts b/js/sdk/src/internal/devices/manager.ts index 83561afa..dce43b28 100644 --- a/js/sdk/src/internal/devices/manager.ts +++ b/js/sdk/src/internal/devices/manager.ts @@ -47,7 +47,7 @@ export class DevicesManager { } async createDevice(name: string, deviceType: DeviceType): Promise { - const { volumeId } = await this.sharesService.getMyFilesIDs(); + const { volumeId } = await this.sharesService.getOwnVolumeIDs(); const { address, shareKey, node } = await this.cryptoService.createDevice(name); const device = await this.apiService.createDevice( diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 1de7fec2..d3e9eac1 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -1,11 +1,10 @@ import { Logger, ProtonDriveTelemetry } from '../../interface'; import { DriveAPIService } from '../apiService'; -import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider } from './interface'; +import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider, SharesService } from './interface'; import { EventsAPIService } from './apiService'; import { CoreEventManager } from './coreEventManager'; import { VolumeEventManager } from './volumeEventManager'; import { EventManager } from './eventManager'; -import { SharesManager } from '../shares/manager'; export type { DriveEvent, DriveListener, EventSubscription } from './interface'; export { DriveEventType } from './interface'; @@ -28,7 +27,7 @@ export class DriveEventsService { constructor( private telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, - private shareManagement: SharesManager, + private sharesService: SharesService, private cacheEventListeners: DriveListener[] = [], private latestEventIdProvider?: LatestEventIdProvider, ) { @@ -104,7 +103,7 @@ export class DriveEventsService { this.logger.debug(`Creating volume event manager for volume ${volumeId}`); const volumeEventManager = new VolumeEventManager(this.logger, this.apiService, volumeId); - const isOwnVolume = await this.shareManagement.isOwnVolume(volumeId); + const isOwnVolume = await this.sharesService.isOwnVolume(volumeId); const pollingInterval = this.getDefaultVolumePollingInterval(isOwnVolume); const latestEventId = this.latestEventIdProvider.getLatestEventId(volumeId); const eventManager = new EventManager(volumeEventManager, pollingInterval, latestEventId); diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index 09f35a3e..9b8b3750 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -112,3 +112,7 @@ export interface EventManagerInterface { getEvents(eventId: string): AsyncIterable; getLogger(): Logger; } + +export interface SharesService { + isOwnVolume(volumeId: string): Promise; +} diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index 03006811..4defcd3b 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -52,7 +52,7 @@ describe('nodesModules integration tests', () => { driveCrypto = {}; // @ts-expect-error No need to implement all methods for mocking sharesService = { - getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + getOwnVolumeIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), }; nodesModule = initNodesModule( diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 76c4fc30..c576d4c9 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -176,7 +176,7 @@ export interface DecryptedRevision extends Revision { * Interface describing the dependencies to the shares module. */ export interface SharesService { - getMyFilesIDs(): Promise<{ volumeId: string; rootNodeId: string }>; + getOwnVolumeIDs(): Promise<{ volumeId: string; rootNodeId: string }>; getSharePrivateKey(shareId: string): Promise; getMyFilesShareMemberEmailKey(): Promise<{ email: string; diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 4490185b..c43e4395 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -46,7 +46,7 @@ describe('nodesAccess', () => { }; // @ts-expect-error No need to implement all methods for mocking shareService = { - getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + getOwnVolumeIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), getSharePrivateKey: jest.fn(), }; @@ -388,7 +388,7 @@ describe('nodesAccess', () => { const node4 = { uid: 'volumeId~node4', isStale: false } as DecryptedNode; beforeEach(() => { - shareService.getMyFilesIDs = jest.fn().mockResolvedValue({ volumeId }); + shareService.getOwnVolumeIDs = jest.fn().mockResolvedValue({ volumeId }); apiService.iterateTrashedNodeUids = jest.fn().mockImplementation(async function* () { yield node1.uid; yield node2.uid; diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 0a553ece..909d83bd 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -56,8 +56,8 @@ export class NodesAccess { this.shareService = shareService; } - async getMyFilesRootFolder() { - const { volumeId, rootNodeId } = await this.shareService.getMyFilesIDs(); + async getVolumeRootFolder() { + const { volumeId, rootNodeId } = await this.shareService.getOwnVolumeIDs(); const nodeUid = makeNodeUid(volumeId, rootNodeId); return this.getNode(nodeUid); } @@ -135,7 +135,7 @@ export class NodesAccess { // Improvement requested: keep status of loaded trash and leverage cache. async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { - const { volumeId } = await this.shareService.getMyFilesIDs(); + const { volumeId } = await this.shareService.getOwnVolumeIDs(); const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, undefined, signal), batchSize: BATCH_LOADING_SIZE, @@ -208,7 +208,7 @@ export class NodesAccess { } private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { - const { volumeId: ownVolumeId } = await this.shareService.getMyFilesIDs(); + const { volumeId: ownVolumeId } = await this.shareService.getOwnVolumeIDs(); const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); return this.decryptNode(encryptedNode); } @@ -234,7 +234,7 @@ export class NodesAccess { const returnedNodeUids: string[] = []; const errors = []; - const { volumeId: ownVolumeId } = await this.shareService.getMyFilesIDs(); + const { volumeId: ownVolumeId } = await this.shareService.getOwnVolumeIDs(); const encryptedNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal); const decryptNodeMapper = async (encryptedNode: EncryptedNode): Promise> => { diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index ad2da064..f4483007 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -1,29 +1,48 @@ +import { BatchLoading } from '../batchLoading'; +import { DecryptedNode } from '../nodes'; import { PhotosAPIService } from './apiService'; -import { PhotosCache } from './cache'; import { NodesService } from './interface'; +import { PhotoSharesManager } from './shares'; +const BATCH_LOADING_SIZE = 10; + +/** + * Provides access and high-level actions for managing albums. + */ export class Albums { constructor( private apiService: PhotosAPIService, - private cache: PhotosCache, + private photoShares: PhotoSharesManager, private nodesService: NodesService, ) { this.apiService = apiService; - this.cache = cache; + this.photoShares = photoShares; this.nodesService = nodesService; } - async *iterateAlbums() { - for await (const album of this.apiService.iterateAlbums()) { - const node = await this.nodesService.getNode(album.uid); - yield { - node, - }; + async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.photoShares.getOwnVolumeIDs(); + + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), + batchSize: BATCH_LOADING_SIZE, + }); + for await (const album of this.apiService.iterateAlbums(volumeId, signal)) { + yield* batchLoading.load(album.albumUid); } + yield* batchLoading.loadRest(); } - async createAlbum(albumName: string) { - const albumdUid = this.apiService.createAlbum(albumName); - await this.cache.setAlbum(albumdUid); + private async *iterateNodesAndIgnoreMissingOnes( + nodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { + const nodeGenerator = this.nodesService.iterateNodes(nodeUids, signal); + for await (const node of nodeGenerator) { + if ('missingUid' in node) { + continue; + } + yield node; + } } } diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index be2cc1e1..2e215e6a 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,13 +1,168 @@ -import { DriveAPIService } from '../apiService'; +import { c } from 'ttag'; +import { DriveAPIService, drivePaths, NotFoundAPIError } from '../apiService'; +import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; +import { makeNodeUid } from '../uids'; + +type GetVolumesResponse = drivePaths['/drive/volumes']['get']['responses']['200']['content']['application/json']; + +type GetShareResponse = drivePaths['/drive/shares/{shareID}']['get']['responses']['200']['content']['application/json']; + +type PostCreateVolumeRequest = Extract< + drivePaths['/drive/photos/volumes']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateVolumeResponse = + drivePaths['/drive/photos/volumes']['post']['responses']['200']['content']['application/json']; + +type GetTimelineResponse = + drivePaths['/drive/volumes/{volumeID}/photos']['get']['responses']['200']['content']['application/json']; + +type GetAlbumsResponse = + drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json']; + +/** + * Provides API communication for fetching and manipulating photos and albums + * metadata. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ export class PhotosAPIService { constructor(private apiService: DriveAPIService) { this.apiService = apiService; } - async *iterateTimeline(): AsyncGenerator {} + async getPhotoShare(): Promise { + // TODO: Switch to drive/v2/shares/photos once available. + + const volumesResponse = await this.apiService.get('drive/volumes'); + + const photoVolume = volumesResponse.Volumes.find((volume) => volume.Type === 2); + + if (!photoVolume) { + throw new NotFoundAPIError(c('Error').t`Photo volume not found`); + } + + const response = await this.apiService.get(`drive/shares/${photoVolume.Share.ShareID}`); - async *iterateAlbums(): AsyncGenerator {} + if (!response.AddressID) { + throw new Error('Photo root share has not address ID set'); + } - async createAlbum(object: any): Promise {} + return { + volumeId: response.VolumeID, + shareId: response.ShareID, + rootNodeId: response.LinkID, + creatorEmail: response.Creator, + encryptedCrypto: { + armoredKey: response.Key, + armoredPassphrase: response.Passphrase, + armoredPassphraseSignature: response.PassphraseSignature, + }, + addressId: response.AddressID, + type: ShareType.Photo, + }; + } + + async createPhotoVolume( + share: { + addressId: string; + addressKeyId: string; + } & EncryptedShareCrypto, + node: { + encryptedName: string; + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + armoredHashKey: string; + }, + ): Promise<{ volumeId: string; shareId: string; rootNodeId: string }> { + const response = await this.apiService.post( + 'drive/photos/volumes', + { + Share: { + AddressID: share.addressId, + AddressKeyID: share.addressKeyId, + Key: share.armoredKey, + Passphrase: share.armoredPassphrase, + PassphraseSignature: share.armoredPassphraseSignature, + }, + Link: { + Name: node.encryptedName, + NodeKey: node.armoredKey, + NodePassphrase: node.armoredPassphrase, + NodePassphraseSignature: node.armoredPassphraseSignature, + NodeHashKey: node.armoredHashKey, + }, + }, + ); + return { + volumeId: response.Volume.VolumeID, + shareId: response.Volume.Share.ShareID, + rootNodeId: response.Volume.Share.LinkID, + }; + } + + async *iterateTimeline( + volumeId: string, + signal?: AbortSignal, + ): AsyncGenerator<{ + nodeUid: string; + captureTime: Date; + tags: number[]; + }> { + let anchor = ''; + while (true) { + const response = await this.apiService.get( + `drive/volumes/${volumeId}/photos?${anchor ? `PreviousPageLastLinkID=${anchor}` : ''}`, + signal, + ); + for (const photo of response.Photos) { + const nodeUid = makeNodeUid(volumeId, photo.LinkID); + yield { + nodeUid, + captureTime: new Date(photo.CaptureTime * 1000), + tags: photo.Tags, + }; + } + + if (!response.Photos.length) { + break; + } + anchor = response.Photos[response.Photos.length - 1].LinkID; + } + } + + async *iterateAlbums( + volumeId: string, + signal?: AbortSignal, + ): AsyncGenerator<{ + albumUid: string; + coverNodeUid?: string; + photoCount: number; + lastActivityTime: Date; + }> { + let anchor = ''; + while (true) { + const response = await this.apiService.get( + `drive/photos/volumes/${volumeId}/albums?${anchor ? `AnchorID=${anchor}` : ''}`, + signal, + ); + for (const album of response.Albums) { + const albumUid = makeNodeUid(volumeId, album.LinkID); + yield { + albumUid, + coverNodeUid: album.CoverLinkID ? makeNodeUid(volumeId, album.CoverLinkID) : undefined, + photoCount: album.PhotoCount, + lastActivityTime: new Date(album.LastActivityTime * 1000), + }; + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } + } } diff --git a/js/sdk/src/internal/photos/cache.ts b/js/sdk/src/internal/photos/cache.ts deleted file mode 100644 index f1174a5e..00000000 --- a/js/sdk/src/internal/photos/cache.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ProtonDriveEntitiesCache } from '../../interface'; - -export class PhotosCache { - constructor(private driveCache: ProtonDriveEntitiesCache) { - this.driveCache = driveCache; - } - - async setAlbum(album: any) { - await this.driveCache.setEntity(album.uid, album); - } -} diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 4e76b5bf..718a64d2 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -1,23 +1,68 @@ import { DriveAPIService } from '../apiService'; -import { ProtonDriveEntitiesCache } from '../../interface'; -import { PhotosAPIService } from './apiService'; -import { PhotosCache } from './cache'; -import { PhotosTimeline } from './photosTimeline'; +import { DriveCrypto } from '../../crypto'; +import { + ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, + ProtonDriveTelemetry, +} from '../../interface'; +import { SharesCache } from '../shares/cache'; +import { SharesCryptoCache } from '../shares/cryptoCache'; +import { SharesCryptoService } from '../shares/cryptoService'; import { Albums } from './albums'; -import { NodesService } from './interface'; +import { PhotosAPIService } from './apiService'; +import { NodesService, SharesService } from './interface'; +import { PhotoSharesManager } from './shares'; +import { PhotosTimeline } from './timeline'; +/** + * Provides facade for the whole photos module. + * + * The photos module is responsible for handling photos and albums metadata, + * including API communication, crypto, caching, and event handling. + */ export function initPhotosModule( apiService: DriveAPIService, - driveEntitiesCache: ProtonDriveEntitiesCache, + photoShares: PhotoSharesManager, nodesService: NodesService, ) { const api = new PhotosAPIService(apiService); - const cache = new PhotosCache(driveEntitiesCache); - const timeline = new PhotosTimeline(api, cache, nodesService); - const albums = new Albums(api, cache, nodesService); + const timeline = new PhotosTimeline(api, photoShares); + const albums = new Albums(api, photoShares, nodesService); return { timeline, albums, }; } + +/** + * Provides facade for the photo share module. + * + * The photo share wraps the core share module, but uses photos volume instead + * of main volume. It provides the same interface so it can be used in the same + * way in various modules that use shares. + */ +export function initPhotoSharesModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, + account: ProtonDriveAccount, + crypto: DriveCrypto, + sharesService: SharesService, +) { + const api = new PhotosAPIService(apiService); + const cache = new SharesCache(telemetry.getLogger('shares-cache'), driveEntitiesCache); + const cryptoCache = new SharesCryptoCache(telemetry.getLogger('shares-cache'), driveCryptoCache); + const cryptoService = new SharesCryptoService(telemetry, crypto, account); + + return new PhotoSharesManager( + telemetry.getLogger('photos-shares'), + api, + cache, + cryptoCache, + cryptoService, + sharesService, + ); +} diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 00b78d74..f6a5fbe4 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -1,5 +1,27 @@ -import { MissingNode } from '../../interface'; +import { PrivateKey } from '../../crypto'; +import { MissingNode, MetricVolumeType } from '../../interface'; import { DecryptedNode } from '../nodes'; +import { EncryptedShare } from '../shares'; + +export interface SharesService { + getOwnVolumeIDs(): Promise<{ volumeId: string; rootNodeId: string }>; + loadEncryptedShare(shareId: string): Promise; + getSharePrivateKey(shareId: string): Promise; + getMyFilesShareMemberEmailKey(): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }>; + getContextShareMemberEmailKey(shareId: string): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }>; + isOwnVolume(volumeId: string): Promise; + getVolumeMetricContext(volumeId: string): Promise; +} export interface NodesService { getNode(nodeUid: string): Promise; diff --git a/js/sdk/src/internal/photos/photosTimeline.ts b/js/sdk/src/internal/photos/photosTimeline.ts deleted file mode 100644 index 105baaf7..00000000 --- a/js/sdk/src/internal/photos/photosTimeline.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PhotosAPIService } from './apiService'; -import { PhotosCache } from './cache'; -import { NodesService } from './interface'; - -export class PhotosTimeline { - constructor( - private apiService: PhotosAPIService, - private cache: PhotosCache, - private nodesService: NodesService, - ) { - this.apiService = apiService; - this.cache = cache; - this.nodesService = nodesService; - } - - async getTimelineStructure() {} -} diff --git a/js/sdk/src/internal/photos/shares.ts b/js/sdk/src/internal/photos/shares.ts new file mode 100644 index 00000000..ac80952f --- /dev/null +++ b/js/sdk/src/internal/photos/shares.ts @@ -0,0 +1,134 @@ +import { PrivateKey } from '../../crypto'; +import { Logger, MetricVolumeType } from '../../interface'; +import { NotFoundAPIError } from '../apiService'; +import { SharesCache } from '../shares/cache'; +import { SharesCryptoCache } from '../shares/cryptoCache'; +import { SharesCryptoService } from '../shares/cryptoService'; +import { EncryptedShare, VolumeShareNodeIDs } from '../shares/interface'; +import { PhotosAPIService } from './apiService'; +import { SharesService } from './interface'; + +/** + * Provides high-level actions for managing photo share. + * + * The photo share manager wraps the core share service, but uses photos volume + * instead of main volume. It provides the same interface so it can be used in + * the same way in various modules that use shares. + */ +export class PhotoSharesManager { + private photoRootIds?: VolumeShareNodeIDs; + + constructor( + private logger: Logger, + private apiService: PhotosAPIService, + private cache: SharesCache, + private cryptoCache: SharesCryptoCache, + private cryptoService: SharesCryptoService, + private sharesService: SharesService, + ) { + this.logger = logger; + this.apiService = apiService; + this.cache = cache; + this.cryptoCache = cryptoCache; + this.cryptoService = cryptoService; + this.sharesService = sharesService; + } + + async getOwnVolumeIDs(): Promise { + if (this.photoRootIds) { + return this.photoRootIds; + } + + try { + const encryptedShare = await this.apiService.getPhotoShare(); + + // Once any place needs IDs for My files, it will most likely + // need also the keys for decrypting the tree. It is better to + // decrypt the share here right away. + const { share: myFilesShare, key } = await this.cryptoService.decryptRootShare(encryptedShare); + await this.cryptoCache.setShareKey(myFilesShare.shareId, key); + await this.cache.setVolume({ + volumeId: myFilesShare.volumeId, + shareId: myFilesShare.shareId, + rootNodeId: myFilesShare.rootNodeId, + creatorEmail: encryptedShare.creatorEmail, + addressId: encryptedShare.addressId, + }); + + this.photoRootIds = { + volumeId: myFilesShare.volumeId, + shareId: myFilesShare.shareId, + rootNodeId: myFilesShare.rootNodeId, + }; + return this.photoRootIds; + } catch (error: unknown) { + if (error instanceof NotFoundAPIError) { + this.logger.warn('Active photo volume not found, creating a new one'); + return this.createVolume(); + } + this.logger.error('Failed to get active photo volume', error); + throw error; + } + } + + private async createVolume(): Promise { + const address = await this.sharesService.getMyFilesShareMemberEmailKey(); + const bootstrap = await this.cryptoService.generateVolumeBootstrap(address.addressKey); + const photoRootIds = await this.apiService.createPhotoVolume( + { + addressId: address.addressId, + addressKeyId: address.addressKeyId, + ...bootstrap.shareKey.encrypted, + }, + { + ...bootstrap.rootNode.key.encrypted, + encryptedName: bootstrap.rootNode.encryptedName, + armoredHashKey: bootstrap.rootNode.armoredHashKey, + }, + ); + await this.cryptoCache.setShareKey(photoRootIds.shareId, bootstrap.shareKey.decrypted); + return photoRootIds; + } + + async getSharePrivateKey(shareId: string): Promise { + return this.sharesService.getSharePrivateKey(shareId); + } + + async getMyFilesShareMemberEmailKey(): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }> { + return this.sharesService.getMyFilesShareMemberEmailKey(); + } + + async getContextShareMemberEmailKey(shareId: string): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }> { + return this.sharesService.getContextShareMemberEmailKey(shareId); + } + + async isOwnVolume(volumeId: string): Promise { + const { volumeId: myVolumeId } = await this.getOwnVolumeIDs(); + if (volumeId === myVolumeId) { + return true; + } + return this.sharesService.isOwnVolume(volumeId); + } + + async getVolumeMetricContext(volumeId: string): Promise { + const { volumeId: myVolumeId } = await this.getOwnVolumeIDs(); + if (volumeId === myVolumeId) { + return MetricVolumeType.OwnVolume; + } + return this.sharesService.getVolumeMetricContext(volumeId); + } + + async loadEncryptedShare(shareId: string): Promise { + return this.sharesService.loadEncryptedShare(shareId); + } +} diff --git a/js/sdk/src/internal/photos/timeline.ts b/js/sdk/src/internal/photos/timeline.ts new file mode 100644 index 00000000..6c5b02d3 --- /dev/null +++ b/js/sdk/src/internal/photos/timeline.ts @@ -0,0 +1,24 @@ +import { PhotosAPIService } from './apiService'; +import { PhotoSharesManager } from './shares'; + +/** + * Provides access to the photo timeline. + */ +export class PhotosTimeline { + constructor( + private apiService: PhotosAPIService, + private photoShares: PhotoSharesManager, + ) { + this.apiService = apiService; + this.photoShares = photoShares; + } + + async* iterateTimeline(signal?: AbortSignal): AsyncGenerator<{ + nodeUid: string; + captureTime: Date; + tags: number[]; + }> { + const { volumeId } = await this.photoShares.getOwnVolumeIDs(); + yield* this.apiService.iterateTimeline(volumeId, signal); + } +} diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts index af71b0b9..55649444 100644 --- a/js/sdk/src/internal/shares/manager.test.ts +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -50,7 +50,7 @@ describe('SharesManager', () => { manager = new SharesManager(getMockLogger(), apiService, cache, cryptoCache, cryptoService, account); }); - describe('getMyFilesIDs', () => { + describe('getOwnVolumeIDs', () => { const myFilesShare = { shareId: 'myFilesShareId', volumeId: 'myFilesVolumeId', @@ -71,8 +71,8 @@ describe('SharesManager', () => { cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ share: myFilesShare, key }); // Calling twice to check if it loads only once. - await manager.getMyFilesIDs(); - const result = await manager.getMyFilesIDs(); + await manager.getOwnVolumeIDs(); + const result = await manager.getOwnVolumeIDs(); expect(result).toStrictEqual(myFilesShare); expect(apiService.getMyFiles).toHaveBeenCalledTimes(1); @@ -103,7 +103,7 @@ describe('SharesManager', () => { }); apiService.createVolume = jest.fn().mockResolvedValue(myFilesShare); - const result = await manager.getMyFilesIDs(); + const result = await manager.getOwnVolumeIDs(); expect(result).toStrictEqual(myFilesShare); expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); @@ -113,7 +113,7 @@ describe('SharesManager', () => { it('should throw on unknown error', async () => { apiService.getMyFiles = jest.fn().mockRejectedValue(new Error('Some error')); - await expect(manager.getMyFilesIDs()).rejects.toThrow('Some error'); + await expect(manager.getOwnVolumeIDs()).rejects.toThrow('Some error'); expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); expect(apiService.createVolume).not.toHaveBeenCalled(); }); @@ -142,7 +142,7 @@ describe('SharesManager', () => { describe('getMyFilesShareMemberEmailKey', () => { it('should return cached volume email key', async () => { - jest.spyOn(manager, 'getMyFilesIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); + jest.spyOn(manager, 'getOwnVolumeIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); cache.getVolume = jest.fn().mockResolvedValue({ addressId: 'addressId' }); account.getOwnAddress = jest .fn() @@ -158,7 +158,7 @@ describe('SharesManager', () => { }); it('should load volume email key if not in cache', async () => { - jest.spyOn(manager, 'getMyFilesIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); + jest.spyOn(manager, 'getOwnVolumeIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); const share = { volumeId: 'volumeId', shareId: 'shareId', diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index a678e3a7..74c0badd 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -46,7 +46,7 @@ export class SharesManager { * * If the default volume or My files section doesn't exist, it creates it. */ - async getMyFilesIDs(): Promise { + async getOwnVolumeIDs(): Promise { if (this.myFilesIds) { return this.myFilesIds; } @@ -140,7 +140,7 @@ export class SharesManager { addressKey: PrivateKey; addressKeyId: string; }> { - const { volumeId } = await this.getMyFilesIDs(); + const { volumeId } = await this.getOwnVolumeIDs(); try { const { addressId } = await this.cache.getVolume(volumeId); @@ -196,11 +196,11 @@ export class SharesManager { } async isOwnVolume(volumeId: string): Promise { - return (await this.getMyFilesIDs()).volumeId === volumeId; + return (await this.getOwnVolumeIDs()).volumeId === volumeId; } async getVolumeMetricContext(volumeId: string): Promise { - const { volumeId: myVolumeId } = await this.getMyFilesIDs(); + const { volumeId: myVolumeId } = await this.getOwnVolumeIDs(); // SDK doesn't support public sharing yet, also public sharing // doesn't use a volume but shareURL, thus we can simplify and diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index bc041a09..eb01f6b5 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -142,7 +142,7 @@ export interface PublicLinkWithCreatorEmail extends PublicLink { * Interface describing the dependencies to the shares module. */ export interface SharesService { - getMyFilesIDs(): Promise<{ volumeId: string }>; + getOwnVolumeIDs(): Promise<{ volumeId: string }>; loadEncryptedShare(shareId: string): Promise; getMyFilesShareMemberEmailKey(): Promise<{ email: string; diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index e611dda9..23cff9c1 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -93,7 +93,7 @@ describe('SharingAccess', () => { // @ts-expect-error No need to implement all methods for mocking sharesService = { - getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + getOwnVolumeIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), loadEncryptedShare: jest.fn().mockResolvedValue({ id: 'shareId', membership: { memberUid: 'memberUid' }, diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 7f78086e..d36f1d51 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -40,7 +40,7 @@ export class SharingAccess { const nodeUids = await this.cache.getSharedByMeNodeUids(); yield* this.iterateSharedNodesFromCache(nodeUids, signal); } catch { - const { volumeId } = await this.sharesService.getMyFilesIDs(); + const { volumeId } = await this.sharesService.getOwnVolumeIDs(); const nodeUidsIterator = this.apiService.iterateSharedNodeUids(volumeId, signal); yield* this.iterateSharedNodesFromAPI( nodeUidsIterator, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 2505023c..08b9fee5 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -253,7 +253,7 @@ export class ProtonDriveClient { } /** - * Subscribes to sharing updates. + * Subscribes to the remote general data updates. * * Only one instance of the SDK should subscribe to updates. */ @@ -286,7 +286,7 @@ export class ProtonDriveClient { */ async getMyFilesRootFolder(): Promise { this.logger.info('Getting my files root folder'); - return convertInternalNodePromise(this.nodes.access.getMyFilesRootFolder()); + return convertInternalNodePromise(this.nodes.access.getVolumeRootFolder()); } /** diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index f509ade9..e6c76e15 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -1,19 +1,56 @@ -import { DriveAPIService } from './internal/apiService'; -import { DriveListener, ProtonDriveClientContructorParameters } from './interface'; +import { + Logger, + ProtonDriveClientContructorParameters, + NodeOrUid, + MaybeMissingNode, + UploadMetadata, + FileDownloader, + FileUploader, + SDKEvent, + MaybeNode, +} from './interface'; +import { getConfig } from './config'; import { DriveCrypto } from './crypto'; -import { initSharesModule } from './internal/shares'; +import { Telemetry } from './telemetry'; +import { convertInternalMissingNodeIterator, convertInternalNodeIterator, getUid, getUids } from './transformers'; +import { DriveAPIService } from './internal/apiService'; +import { initDownloadModule } from './internal/download'; +import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; import { initNodesModule } from './internal/nodes'; -import { initPhotosModule } from './internal/photos'; -import { DriveEventsService } from './internal/events'; +import { initPhotoSharesModule, initPhotosModule } from './internal/photos'; import { SDKEvents } from './internal/sdkEvents'; -import { getConfig } from './config'; -import { Telemetry } from './telemetry'; +import { initSharesModule } from './internal/shares'; +import { initSharingModule } from './internal/sharing'; +import { initUploadModule } from './internal/upload'; -// TODO: this is only example, on background it use drive internals, but it exposes nice interface for photos +/** + * ProtonDrivePhotosClient is the interface to access Photos functionality. + * + * The client provides high-level operations for managing photos, albums, sharing, + * and downloading/uploading photos. + * + * @deprecated This is an experimental feature that might change without a warning. + */ export class ProtonDrivePhotosClient { + private logger: Logger; + private sdkEvents: SDKEvents; + private events: DriveEventsService; + private photoShares: ReturnType; private nodes: ReturnType; + private sharing: ReturnType; + private download: ReturnType; + private upload: ReturnType; private photos: ReturnType; + public experimental: { + /** + * Experimental feature to return the URL of the node. + * + * See `ProtonDriveClient.experimental.getNodeUrl` for more information. + */ + getNodeUrl: (nodeUid: NodeOrUid) => Promise; + }; + constructor({ httpClient, entitiesCache, @@ -23,41 +60,183 @@ export class ProtonDrivePhotosClient { srpModule, config, telemetry, + latestEventIdProvider, }: ProtonDriveClientContructorParameters) { if (!telemetry) { telemetry = new Telemetry(); } + this.logger = telemetry.getLogger('interface'); const fullConfig = getConfig(config); - const sdkEvents = new SDKEvents(telemetry); + this.sdkEvents = new SDKEvents(telemetry); const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); const apiService = new DriveAPIService( telemetry, - sdkEvents, + this.sdkEvents, httpClient, fullConfig.baseUrl, fullConfig.language, ); - const shares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); - this.nodes = initNodesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule, shares); - const cacheEventListeners: DriveListener[] = [this.nodes.eventHandler.updateNodesCacheOnEvent]; - new DriveEventsService(telemetry, apiService, shares, cacheEventListeners); - this.photos = initPhotosModule(apiService, entitiesCache, this.nodes.access); - } - - // Timeline or album view - iterateTimelinePhotos() {} // returns only UIDs and dates - used to show grid and scrolling - iterateAlbumPhotos() {} // same as above but for album - iterateThumbnails() {} // returns thumbnails for passed photos that are visible in the UI - getPhoto() {} // returns full photo details - - // Album management - createAlbum(albumName: string) { - return this.photos.albums.createAlbum(albumName); - } - renameAlbum() {} - shareAlbum() {} - deleteAlbum() {} - iterateAlbums() {} - addPhotosToAlbum() {} + const coreShares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); + this.photoShares = initPhotoSharesModule( + telemetry, + apiService, + entitiesCache, + cryptoCache, + account, + cryptoModule, + coreShares, + ); + this.nodes = initNodesModule( + telemetry, + apiService, + entitiesCache, + cryptoCache, + account, + cryptoModule, + this.photoShares, + ); + this.photos = initPhotosModule(apiService, this.photoShares, this.nodes.access); + this.sharing = initSharingModule( + telemetry, + apiService, + entitiesCache, + account, + cryptoModule, + this.photoShares, + this.nodes.access, + ); + this.download = initDownloadModule( + telemetry, + apiService, + cryptoModule, + account, + this.photoShares, + this.nodes.access, + this.nodes.revisions, + ); + this.upload = initUploadModule( + telemetry, + apiService, + cryptoModule, + this.photoShares, + this.nodes.access, + fullConfig.clientUid, + ); + + // These are used to keep the internal cache up to date + const cacheEventListeners: DriveListener[] = [ + this.nodes.eventHandler.updateNodesCacheOnEvent.bind(this.nodes.eventHandler), + this.sharing.eventHandler.handleDriveEvent.bind(this.sharing.eventHandler), + ]; + this.events = new DriveEventsService( + telemetry, + apiService, + this.photoShares, + cacheEventListeners, + latestEventIdProvider, + ); + + this.experimental = { + getNodeUrl: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); + return this.nodes.access.getNodeUrl(getUid(nodeUid)); + }, + }; + } + + /** + * Subscribes to the general SDK events. + * + * See `ProtonDriveClient.onMessage` for more information. + */ + onMessage(eventName: SDKEvent, callback: () => void): () => void { + this.logger.debug(`Subscribing to event ${eventName}`); + return this.sdkEvents.addListener(eventName, callback); + } + + /** + * Subscribes to the remote data updates for all files in a tree. + * + * See `ProtonDriveClient.subscribeToTreeEvents` for more information. + */ + async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { + this.logger.debug('Subscribing to node updates'); + return this.events.subscribeToTreeEvents(treeEventScopeId, callback); + } + + /** + * Subscribes to the remote general data updates. + * + * See `ProtonDriveClient.subscribeToDriveEvents` for more information. + */ + async subscribeToDriveEvents(callback: DriveListener): Promise { + this.logger.debug('Subscribing to core updates'); + return this.events.subscribeToCoreEvents(callback); + } + + /** + * Iterates all the photos for the timeline view. + * + * The output includes only necessary information to quickly prepare + * the whole timeline view with the break-down per month/year and fast + * scrollbar. + * + * Individual photos details must be loaded separately based on what + * is visible in the UI. + * + * The output is sorted by the capture time, starting from the + * the most recent photos. + */ + async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<{ + nodeUid: string; + captureTime: Date; + tags: number[]; + }> { + // TODO: expose better type + yield* this.photos.timeline.iterateTimeline(signal); + } + + /** + * Iterates the nodes by their UIDs. + * + * See `ProtonDriveClient.iterateNodes` for more information. + */ + async *iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Iterating ${nodeUids.length} nodes`); + // TODO: expose photo type + yield* convertInternalMissingNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); + } + + /** + * Iterates the albums. + * + * The output is not sorted and the order of the nodes is not guaranteed. + */ + async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating albums'); + // TODO: expose album type + yield* convertInternalNodeIterator(this.photos.albums.iterateAlbums(signal)); + } + + /** + * Get the file downloader to download the node content. + * + * See `ProtonDriveClient.getFileDownloader` for more information. + */ + async getFileDownloader(nodeUid: NodeOrUid, signal?: AbortSignal): Promise { + this.logger.info(`Getting file downloader for ${getUid(nodeUid)}`); + return this.download.getFileDownloader(getUid(nodeUid), signal); + } + + /** + * Get the file uploader to upload a new file. + * + * See `ProtonDriveClient.getFileUploader` for more information. + */ + async getFileUploader(name: string, metadata: UploadMetadata, signal?: AbortSignal): Promise { + this.logger.info(`Getting file uploader`); + const parentFolderUid = await this.nodes.access.getVolumeRootFolder(); + return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); + } } From bae25b9988933b0939375e3adfca5316fcd6dc9b Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Sep 2025 10:20:31 +0200 Subject: [PATCH 221/791] Add revision upload logic --- cs/Directory.Build.props | 3 + cs/Directory.Packages.props | 9 +- cs/headers/proton_sdk.h | 27 ++++- .../InteropFileUploader.cs | 74 ++++-------- .../InteropProtonDriveClient.cs | 106 ++++++++++++++++++ .../Proton.Drive.Sdk.CExports.csproj | 1 + .../Api/Files/FilesApiClient.cs | 16 +++ .../Api/Files/IFilesApiClient.cs | 2 + .../Api/Files/RevisionCreationIdentity.cs | 9 ++ .../Api/Files/RevisionCreationRequest.cs | 12 ++ .../Api/Files/RevisionCreationResponse.cs | 10 ++ .../Api/Storage/StorageApiClient.cs | 6 +- .../NodeWithSameNameExistsException.cs | 40 +++++++ .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 59 +++++----- .../Nodes/RevisionOperations.cs | 40 ++++++- .../src/Proton.Drive.Sdk/Nodes/Thumbnail.cs | 4 +- .../Nodes/Upload/FileUploader.cs | 46 +++----- .../Nodes/Upload/IFileDraftProvider.cs | 8 ++ .../Nodes/Upload/NewFileDraftProvider.cs | 34 ++++++ .../Nodes/Upload/NewRevisionDraftProvider.cs | 24 ++++ .../Nodes/Upload/UploadController.cs | 4 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 56 ++++++--- .../RevisionDraftConflictException.cs | 26 +++++ .../DriveApiSerializerContext.cs | 2 + .../Volumes/VolumeTrashBatchLoader.cs | 2 +- .../src/Proton.Sdk.CExports/InteropArray.cs | 25 +++-- cs/sdk/src/protos/drive.proto | 20 ++-- 27 files changed, 496 insertions(+), 169 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationIdentity.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 7fe2d007..c543523c 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -3,6 +3,7 @@ net9.0 true + false Proton Drive Proton AG @@ -56,6 +57,8 @@ + + diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 0a05b372..0d67b19a 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -3,20 +3,21 @@ true + - + - + - + @@ -27,7 +28,7 @@ - + \ No newline at end of file diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h index 2f43db6d..be6d5fc5 100644 --- a/cs/headers/proton_sdk.h +++ b/cs/headers/proton_sdk.h @@ -9,6 +9,11 @@ typedef struct { size_t length; } ByteArray; +typedef struct { + const ByteArray* pointer; + size_t length; +} ByteArrayArray; + typedef void array_callback_function(const void* state, ByteArray array); typedef struct { @@ -114,19 +119,37 @@ void drive_client_free(intptr_t client_handle); int get_file_uploader( intptr_t client_handle, ByteArray request, // FileUploaderProvisionRequest + size_t file_size, + const void* caller_state, + AsyncIntPtrCallback result_callback +); + +int get_revision_uploader( + intptr_t client_handle, + ByteArray request, // RevisionUploaderProvisionRequest + size_t file_size, const void* caller_state, AsyncIntPtrCallback result_callback ); intptr_t upload_from_stream( intptr_t uploader_handle, - ByteArray request, // FileUploadRequest + ByteArrayArray thumbnails, const void* caller_state, ReadCallback* read_callback, ArrayCallback progress_callback, intptr_t cancellation_token_source_handle ); +intptr_t upload_from_path( + intptr_t uploader_handle, + ByteArray path, // UTF-8 + ByteArrayArray thumbnails, + const void* caller_state, + ArrayCallback progress_callback, + intptr_t cancellation_token_source_handle +); + void file_uploader_free(intptr_t file_uploader_handle); int upload_controller_set_completion_callback( @@ -151,7 +174,7 @@ intptr_t download_to_stream( intptr_t downloader_handle, const void* caller_state, WriteCallback* write_callback, - AsyncArrayCallback progress_callback, + ArrayCallback progress_callback, intptr_t cancellation_token_source_handle ); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 7c0eed60..9882a6b9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -1,11 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using DotNext.Buffers; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Upload; -using Proton.Sdk; using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; @@ -26,32 +25,10 @@ private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out Fil return uploader is not null; } - [UnmanagedCallersOnly(EntryPoint = "get_file_uploader", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeCreate( - nint clientHandle, - InteropArray requestBytes, - void* callerState, - InteropAsyncValueCallback resultCallback) - { - try - { - if (!InteropProtonDriveClient.TryGetFromHandle(clientHandle, out var client)) - { - return -1; - } - - return resultCallback.InvokeFor(callerState, ct => InteropGetFileUploaderAsync(client, requestBytes, ct)); - } - catch - { - return -1; - } - } - [UnmanagedCallersOnly(EntryPoint = "upload_from_stream", CallConvs = [typeof(CallConvCdecl)])] private static unsafe nint NativeUploadFromStream( nint fileUploaderHandle, - InteropArray requestBytes, + InteropArray interopThumbnails, void* callerState, InteropReadCallback readCallback, InteropValueCallback> progressCallback, @@ -69,20 +46,13 @@ private static unsafe nint NativeUploadFromStream( return -1; } - var request = FileUploadRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - - if (!NodeUid.TryParse(request.ParentFolderUid, out var parentUid)) - { - return -1; - } - var stream = new InteropStream(uploader.FileSize, callerState, readCallback); + var thumbnails = GetThumbnailsFromInteropArray(interopThumbnails); + var uploadController = uploader.UploadFromStream( - parentUid.Value, stream, - request.HasThumbnail ? [new Thumbnail(ThumbnailType.Thumbnail, request.Thumbnail.ToByteArray())] : [], - request.CreateNewRevisionIfExists, + thumbnails, (completed, total) => progressCallback.UpdateProgress(callerState, completed, total), cancellationToken); @@ -121,27 +91,25 @@ private static void NativeFree(nint fileUploaderHandle) } } - private static async ValueTask>> InteropGetFileUploaderAsync( - ProtonDriveClient client, - InteropArray requestBytes, - CancellationToken cancellationToken) + private static unsafe Thumbnail[] GetThumbnailsFromInteropArray(InteropArray interopThumbnails) { - try - { - var request = FileUploaderProvisionRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - - var uploader = await client.GetFileUploaderAsync( - request.Name, - request.MediaType, - DateTimeOffset.FromUnixTimeSeconds(request.LastModificationDate).DateTime, - request.FileSize, - cancellationToken).ConfigureAwait(false); + var thumbnails = new Thumbnail[interopThumbnails.Length]; + var interopThumbnailsSpan = interopThumbnails.AsReadOnlySpan(); - return GCHandle.ToIntPtr(GCHandle.Alloc(uploader)); - } - catch (Exception e) + for (var i = 0; i < thumbnails.Length; ++i) { - return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); + var interopThumbnail = interopThumbnailsSpan[i]; + var thumbnailContent = UnmanagedMemory.AsMemory(interopThumbnail.Content.Pointer, (int)interopThumbnail.Content.Length); + thumbnails[i] = new Thumbnail(interopThumbnail.Type, thumbnailContent); } + + return thumbnails; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct InteropThumbnail + { + public readonly ThumbnailType Type; + public readonly InteropArray Content; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 10edc18c..d320dc6d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -1,7 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; @@ -42,6 +45,50 @@ private static nint NativeCreate(nint sessionHandle) } } + [UnmanagedCallersOnly(EntryPoint = "get_file_uploader", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int NativeGetFileUploader( + nint clientHandle, + InteropArray requestBytes, + void* callerState, + InteropAsyncValueCallback resultCallback) + { + try + { + if (!TryGetFromHandle(clientHandle, out var client)) + { + return -1; + } + + return resultCallback.InvokeFor(callerState, ct => InteropGetFileUploaderAsync(client, requestBytes, ct)); + } + catch + { + return -1; + } + } + + [UnmanagedCallersOnly(EntryPoint = "get_revision_uploader", CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int NativeGetRevisionUploader( + nint clientHandle, + InteropArray requestBytes, + void* callerState, + InteropAsyncValueCallback resultCallback) + { + try + { + if (!TryGetFromHandle(clientHandle, out var client)) + { + return -1; + } + + return resultCallback.InvokeFor(callerState, ct => InteropGetRevisionUploaderAsync(client, requestBytes, ct)); + } + catch + { + return -1; + } + } + [UnmanagedCallersOnly(EntryPoint = "drive_client_free", CallConvs = [typeof(CallConvCdecl)])] private static void NativeFree(nint handle) { @@ -61,4 +108,63 @@ private static void NativeFree(nint handle) // Ignore } } + + private static async ValueTask>> InteropGetFileUploaderAsync( + ProtonDriveClient client, + InteropArray requestBytes, + CancellationToken cancellationToken) + { + try + { + var request = FileUploaderProvisionRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + if (!NodeUid.TryParse(request.ParentFolderUid, out var parentUid)) + { + return -1; + } + + var uploader = await client.GetFileUploaderAsync( + parentUid.Value, + request.Name, + request.MediaType, + request.FileSize, + DateTimeOffset.FromUnixTimeSeconds(request.LastModificationDate).DateTime, + overrideExistingDraftByOtherClient: request.CreateNewRevisionIfExists, + cancellationToken).ConfigureAwait(false); + + return GCHandle.ToIntPtr(GCHandle.Alloc(uploader)); + } + catch (Exception e) + { + return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); + } + } + + private static async ValueTask>> InteropGetRevisionUploaderAsync( + ProtonDriveClient client, + InteropArray requestBytes, + CancellationToken cancellationToken) + { + try + { + var request = FileRevisionUploaderProvisionRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + if (!RevisionUid.TryParse(request.CurrentActiveRevisionUid, out var currentActiveRevisionUid)) + { + return -1; + } + + var uploader = await client.GetFileRevisionUploaderAsync( + currentActiveRevisionUid.Value, + request.FileSize, + DateTimeOffset.FromUnixTimeSeconds(request.LastModificationDate).DateTime, + cancellationToken).ConfigureAwait(false); + + return GCHandle.ToIntPtr(GCHandle.Alloc(uploader)); + } + catch (Exception e) + { + return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); + } + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index fc0a2870..e88b82d3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -9,6 +9,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index b8e3e61a..847126ed 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -19,6 +19,22 @@ public async ValueTask CreateFileAsync(VolumeId volumeId, .ConfigureAwait(false); } + public async Task CreateRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionCreationRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.RevisionCreationResponse, DriveApiSerializerContext.Default.RevisionConflictResponse) + .PostAsync( + $"volumes/{volumeId}/files/{linkId}/revisions", + request, + DriveApiSerializerContext.Default.RevisionCreationRequest, + cancellationToken) + .ConfigureAwait(false); + } + public async ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken) { return await _httpClient diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs index c2ea6057..65da6d60 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -8,6 +8,8 @@ internal interface IFilesApiClient { ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken); + Task CreateRevisionAsync(VolumeId volumeId, LinkId linkId, RevisionCreationRequest request, CancellationToken cancellationToken); + ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken); ValueTask UpdateRevisionAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationIdentity.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationIdentity.cs new file mode 100644 index 00000000..9825b8e4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationIdentity.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal readonly struct RevisionCreationIdentity +{ + [JsonPropertyName("ID")] + public required RevisionId RevisionId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs new file mode 100644 index 00000000..f9232cd5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal struct RevisionCreationRequest +{ + [JsonPropertyName("CurrentRevisionID")] + public RevisionId? CurrentRevisionId { get; init; } + + [JsonPropertyName("ClientUID")] + public string? ClientId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationResponse.cs new file mode 100644 index 00000000..62604eb5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionCreationResponse : ApiResponse +{ + [JsonPropertyName("Revision")] + public required RevisionCreationIdentity Identity { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index 5e678c19..8af4c03d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -21,8 +21,10 @@ public async ValueTask UploadBlobAsync( blobContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "Block", FileName = "blob" }; blobContent.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet); - using var multipartContent = new MultipartFormDataContent("-----------------------------" + Guid.NewGuid().ToString("N")); - multipartContent.Add(blobContent); + using var multipartContent = new MultipartFormDataContent("-----------------------------" + Guid.NewGuid().ToString("N")) + { + blobContent + }; using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, baseUrl, multipartContent); requestMessage.Headers.Add("pm-storage-token", token); diff --git a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs new file mode 100644 index 00000000..25e05fc4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs @@ -0,0 +1,40 @@ +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; + +namespace Proton.Drive.Sdk; + +public sealed class NodeWithSameNameExistsException : ProtonDriveException +{ + public NodeWithSameNameExistsException() + { + } + + public NodeWithSameNameExistsException(string message) + : base(message) + { + } + + public NodeWithSameNameExistsException(string message, Exception innerException) + : base(message, innerException) + { + } + + internal NodeWithSameNameExistsException(VolumeId volumeId, ProtonApiException innerException) + : base(innerException.Message, innerException) + { + if (innerException.Response is not { } response) + { + return; + } + + ConflictingNodeIsFileDraft = response.Conflict is { RevisionId: null, DraftRevisionId: not null }; + ConflictingNodeUid = response.Conflict.LinkId is not null + ? new NodeUid(volumeId, response.Conflict.LinkId.Value) + : null; + } + + public bool? ConflictingNodeIsFileDraft { get; } + public NodeUid? ConflictingNodeUid { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 9f6a077d..5091c7a8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -6,11 +6,12 @@ namespace Proton.Drive.Sdk.Nodes; internal static class FileOperations { - public static async Task<(RevisionUid RevisionUid, FileSecrets FileSecrets)> CreateOrGetExistingDraftAsync( + public static async Task<(RevisionUid RevisionUid, FileSecrets FileSecrets)> CreateDraftAsync( ProtonDriveClient client, NodeUid parentUid, string name, string mediaType, + bool overrideExistingDraftByOtherClient, CancellationToken cancellationToken) { var parentSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); @@ -36,11 +37,9 @@ internal static class FileOperations var contentKey = PgpSessionKey.Generate(); var (contentKeyToken, _) = contentKey.Export(); - var clientUid = await client.GetClientUidAsync(cancellationToken).ConfigureAwait(false); - var request = new FileCreationRequest { - ClientUid = clientUid, + ClientUid = client.Uid, Name = encryptedName, NameHashDigest = nameHashDigest, ParentLinkId = parentUid.LinkId, @@ -53,38 +52,38 @@ internal static class FileOperations ContentKeyPacketSignature = key.Sign(contentKeyToken), }; - FileSecrets fileSecrets; - RevisionUid draftRevisionUid; - try - { - var response = await client.Api.Files.CreateFileAsync(parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); - - var draftNodeUid = new NodeUid(parentUid.VolumeId, response.Identifiers.LinkId); - draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); - - fileSecrets = new FileSecrets - { - Key = key, - PassphraseSessionKey = passphraseSessionKey, - NameSessionKey = nameSessionKey, - ContentKey = contentKey, - }; + FileCreationResponse? response = null; - await client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); - } - catch (ProtonApiException ex) - when (ex.Response is { Conflict: { LinkId: not null, DraftClientUid: not null, DraftRevisionId: not null } }) + while (response is null) { - if (ex.Response.Conflict.DraftClientUid != clientUid) + try + { + response = await client.Api.Files.CreateFileAsync(parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); + } + catch (ProtonApiException e) + when (e.Response is { Conflict: { LinkId: { } linkId, RevisionId: null, DraftRevisionId: not null } } + && (e.Response.Conflict.DraftClientUid == client.Uid || overrideExistingDraftByOtherClient)) { - throw; + await NodeOperations.DeleteAsync(client, [new NodeUid(parentUid.VolumeId, linkId)], cancellationToken).ConfigureAwait(false); } + catch (ProtonApiException e) + { + throw new NodeWithSameNameExistsException(parentUid.VolumeId, e); + } + } - var draftNodeUid = new NodeUid(parentUid.VolumeId, ex.Response.Conflict.LinkId.Value); - draftRevisionUid = new RevisionUid(draftNodeUid, ex.Response.Conflict.DraftRevisionId.Value); + var draftNodeUid = new NodeUid(parentUid.VolumeId, response.Identifiers.LinkId); + var draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); - fileSecrets = await GetSecretsAsync(client, draftNodeUid, cancellationToken).ConfigureAwait(false); - } + var fileSecrets = new FileSecrets + { + Key = key, + PassphraseSessionKey = passphraseSessionKey, + NameSessionKey = nameSessionKey, + ContentKey = contentKey, + }; + + await client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); return (draftRevisionUid, fileSecrets); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index c44dbed8..ca21ec47 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -1,10 +1,48 @@ -using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; internal static class RevisionOperations { + public static async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> CreateDraftAsync( + ProtonDriveClient client, + NodeUid fileUid, + RevisionId lastKnownRevisionId, + CancellationToken cancellationToken) + { + var parameters = new RevisionCreationRequest + { + CurrentRevisionId = lastKnownRevisionId, + ClientId = client.Uid, + }; + + var fileSecrets = await FileOperations.GetSecretsAsync(client, fileUid, cancellationToken).ConfigureAwait(false); + + RevisionId revisionId; + try + { + var revisionResponse = await client.Api.Files.CreateRevisionAsync(fileUid.VolumeId, fileUid.LinkId, parameters, cancellationToken) + .ConfigureAwait(false); + + revisionId = revisionResponse.Identity.RevisionId; + } + catch (ProtonApiException e) + when (e.Response is { Conflict.DraftRevisionId: { } draftRevisionId } + && (e.Response.Conflict.DraftClientUid == client.Uid)) + { + revisionId = draftRevisionId; + } + catch (ProtonApiException e) + { + throw new RevisionDraftConflictException(e); + } + + return (new RevisionUid(fileUid, revisionId), fileSecrets); + } + public static async ValueTask OpenForWritingAsync( ProtonDriveClient client, RevisionUid revisionUid, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Thumbnail.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Thumbnail.cs index 4739cfed..b7e78621 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Thumbnail.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Thumbnail.cs @@ -1,7 +1,7 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed class Thumbnail(ThumbnailType type, ArraySegment content) +public sealed class Thumbnail(ThumbnailType type, ReadOnlyMemory content) { public ThumbnailType Type { get; } = type; - public ArraySegment Content { get; } = content; + public ReadOnlyMemory Content { get; } = content; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 0867bad4..c111c4e8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -1,44 +1,35 @@ -using Proton.Drive.Sdk.Api.Files; -using Proton.Sdk; - namespace Proton.Drive.Sdk.Nodes.Upload; public sealed class FileUploader : IDisposable { private readonly ProtonDriveClient _client; - private readonly string _name; - private readonly string _mediaType; + private readonly IFileDraftProvider _fileDraftProvider; private readonly DateTimeOffset? _lastModificationTime; - private volatile int _remainingNumberOfBlocks; internal FileUploader( ProtonDriveClient client, - string name, - string mediaType, - DateTimeOffset? lastModificationTime, + IFileDraftProvider fileDraftProvider, long size, + DateTimeOffset? lastModificationTime, int expectedNumberOfBlocks) { _client = client; - _name = name; - _mediaType = mediaType; - _lastModificationTime = lastModificationTime; + _fileDraftProvider = fileDraftProvider; FileSize = size; + _lastModificationTime = lastModificationTime; _remainingNumberOfBlocks = expectedNumberOfBlocks; } internal long FileSize { get; } public UploadController UploadFromStream( - NodeUid parentFolderUid, Stream contentStream, IEnumerable thumbnails, - bool createNewRevisionIfExists, Action onProgress, CancellationToken cancellationToken) { - var task = UploadFromStreamAsync(parentFolderUid, contentStream, thumbnails, createNewRevisionIfExists, onProgress, cancellationToken); + var task = UploadFromStreamAsync(contentStream, thumbnails, onProgress, cancellationToken); return new UploadController(task); } @@ -54,28 +45,15 @@ public void Dispose() _remainingNumberOfBlocks = 0; } - private async Task UploadFromStreamAsync( - NodeUid parentFolderUid, + private async Task UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, - bool createNewRevisionIfExists, Action onProgress, CancellationToken cancellationToken) { - RevisionUid draftRevisionUid; - FileSecrets fileSecrets; - try - { - (draftRevisionUid, fileSecrets) = await FileOperations.CreateOrGetExistingDraftAsync(_client, parentFolderUid, _name, _mediaType, cancellationToken) - .ConfigureAwait(false); - } - catch (ProtonApiException ex) - when (createNewRevisionIfExists && ex.Response is { Conflict: { LinkId: not null, RevisionId: not null, DraftRevisionId: null } }) - { - throw new NotImplementedException("Uploading new revision not yet implemented"); - } + var (draftRevisionUid, fileSecrets) = await _fileDraftProvider.GetDraftAsync(_client, cancellationToken).ConfigureAwait(false); - await UploadAsync( + return await UploadAsync( draftRevisionUid, fileSecrets, contentStream, @@ -85,7 +63,7 @@ await UploadAsync( cancellationToken).ConfigureAwait(false); } - private async ValueTask UploadAsync( + private async ValueTask UploadAsync( RevisionUid revisionUid, FileSecrets fileSecrets, Stream contentStream, @@ -98,6 +76,10 @@ private async ValueTask UploadAsync( .ConfigureAwait(false); await revisionWriter.WriteAsync(contentStream, thumbnails, lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); + + var nodeMetadata = await NodeOperations.GetNodeMetadataAsync(_client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + + return nodeMetadata.Node; } private void ReleaseBlocks(int numberOfBlocks) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs new file mode 100644 index 00000000..78387f62 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal interface IFileDraftProvider +{ + ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync( + ProtonDriveClient client, + CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs new file mode 100644 index 00000000..bd43cc68 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -0,0 +1,34 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal sealed class NewFileDraftProvider : IFileDraftProvider +{ + private readonly NodeUid _parentFolderUid; + private readonly string _name; + private readonly string _mediaType; + private readonly bool _overrideExistingDraftByOtherClient; + + internal NewFileDraftProvider( + NodeUid parentFolderUid, + string name, + string mediaType, + bool overrideExistingDraftByOtherClient) + { + _parentFolderUid = parentFolderUid; + _name = name; + _mediaType = mediaType; + _overrideExistingDraftByOtherClient = overrideExistingDraftByOtherClient; + } + + public async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync( + ProtonDriveClient client, + CancellationToken cancellationToken) + { + return await FileOperations.CreateDraftAsync( + client, + _parentFolderUid, + _name, + _mediaType, + _overrideExistingDraftByOtherClient, + cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs new file mode 100644 index 00000000..3077aa56 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -0,0 +1,24 @@ +using Proton.Drive.Sdk.Api.Files; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal sealed class NewRevisionDraftProvider : IFileDraftProvider +{ + private readonly NodeUid _fileUid; + private readonly RevisionId _lastKnownRevisionId; + + internal NewRevisionDraftProvider( + NodeUid fileUid, + RevisionId lastKnownRevisionId) + { + _fileUid = fileUid; + _lastKnownRevisionId = lastKnownRevisionId; + } + + public async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync( + ProtonDriveClient client, + CancellationToken cancellationToken) + { + return await RevisionOperations.CreateDraftAsync(client, _fileUid, _lastKnownRevisionId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index cb62434b..fbb54a44 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -1,8 +1,8 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -public sealed class UploadController(Task uploadTask) +public sealed class UploadController(Task uploadTask) { - public Task Completion { get; } = uploadTask; + public Task Completion { get; } = uploadTask; public void Pause() { diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 286eba4c..920dbf8f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -21,12 +21,15 @@ public sealed class ProtonDriveClient /// Creates a new instance of . /// /// Authenticated API session. - public ProtonDriveClient(ProtonApiSession session) + /// Unique ID for this client to allow it to resume drafts across instances. + /// If no UID is not provided, one will be generated for the duration of this instance. + public ProtonDriveClient(ProtonApiSession session, string? uid = null) : this( session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds)), new AccountClientAdapter(session), new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), - session.ClientConfiguration.LoggerFactory) + session.ClientConfiguration.LoggerFactory, + uid ?? Guid.NewGuid().ToString()) { } @@ -35,8 +38,11 @@ internal ProtonDriveClient( IDriveApiClients apiClients, IDriveClientCache cache, IBlockVerifierFactory blockVerifierFactory, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + string uid) { + Uid = uid; + Account = accountClient; Api = apiClients; Cache = cache; @@ -60,18 +66,22 @@ private ProtonDriveClient( HttpClient httpClient, IAccountClient accountClient, IDriveClientCache cache, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + string uid) : this( accountClient, new DriveApiClients(httpClient), cache, new BlockVerifierFactory(httpClient), - loggerFactory) + loggerFactory, + uid) { } internal static RecyclableMemoryStreamManager MemoryStreamManager { get; } = new(); + internal string Uid { get; } + internal IAccountClient Account { get; } internal IDriveApiClients Api { get; } internal IDriveClientCache Cache { get; } @@ -103,17 +113,28 @@ public IAsyncEnumerable> EnumerateFolderChildrenAsync } public async ValueTask GetFileUploaderAsync( + NodeUid parentFolderUid, string name, string mediaType, - DateTime? lastModificationTime, long size, + DateTime? lastModificationTime, + bool overrideExistingDraftByOtherClient, CancellationToken cancellationToken) { - var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); + var draftProvider = new NewFileDraftProvider(parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); - await RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetFileRevisionUploaderAsync( + RevisionUid currentActiveRevisionUid, + long size, + DateTime? lastModificationTime, + CancellationToken cancellationToken) + { + var draftProvider = new NewRevisionDraftProvider(currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); - return new FileUploader(this, name, mediaType, lastModificationTime, size, expectedNumberOfBlocks); + return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, cancellationToken).ConfigureAwait(false); } public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) @@ -162,17 +183,16 @@ public ValueTask EmptyTrashAsync(CancellationToken cancellationToken) return VolumeOperations.EmptyTrashAsync(this, cancellationToken); } - internal async ValueTask GetClientUidAsync(CancellationToken cancellationToken) + private async ValueTask GetFileUploaderAsync( + IFileDraftProvider fileDraftProvider, + long size, + DateTime? lastModificationTime, + CancellationToken cancellationToken) { - var clientUid = await Cache.Entities.TryGetClientUidAsync(cancellationToken).ConfigureAwait(false); - - if (clientUid is null) - { - clientUid = Guid.NewGuid().ToString("N"); + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); - await Cache.Entities.SetClientUidAsync(clientUid, cancellationToken).ConfigureAwait(false); - } + await RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); - return clientUid; + return new FileUploader(this, fileDraftProvider, size, lastModificationTime, expectedNumberOfBlocks); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs b/cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs new file mode 100644 index 00000000..2f124706 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs @@ -0,0 +1,26 @@ +using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; + +namespace Proton.Drive.Sdk; + +public sealed class RevisionDraftConflictException : ProtonDriveException +{ + public RevisionDraftConflictException() + { + } + + public RevisionDraftConflictException(string message) + : base(message) + { + } + + public RevisionDraftConflictException(string message, Exception innerException) + : base(message, innerException) + { + } + + internal RevisionDraftConflictException(ProtonApiException innerException) + : base(innerException.Message, innerException) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 15e0369a..3080f7f4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -36,6 +36,8 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(FolderCreationResponse))] [JsonSerializable(typeof(FileCreationRequest))] [JsonSerializable(typeof(FileCreationResponse))] +[JsonSerializable(typeof(RevisionCreationRequest))] +[JsonSerializable(typeof(RevisionCreationResponse))] [JsonSerializable(typeof(RevisionConflictResponse))] [JsonSerializable(typeof(BlockUploadPreparationRequest))] [JsonSerializable(typeof(BlockUploadPreparationResponse))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs index 7838f4f2..a50e4cbc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -13,7 +13,7 @@ internal sealed class VolumeTrashBatchLoader(ProtonDriveClient client, VolumeId private readonly VolumeId _volumeId = volumeId; private readonly PgpPrivateKey _shareKey = shareKey; - private readonly Dictionary _parentKeys = new(); + private readonly Dictionary _parentKeys = []; protected override async ValueTask>> LoadBatchAsync( ReadOnlyMemory ids, diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs index 65122c2c..78e8b292 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs @@ -6,12 +6,12 @@ namespace Proton.Sdk.CExports; internal readonly unsafe struct InteropArray(T* pointer, nint length) where T : unmanaged { - private readonly void* _pointer = pointer; - private readonly nint _length = length; + public readonly T* Pointer = pointer; + public readonly nint Length = length; public static InteropArray Null => default; - public bool IsNullOrEmpty => _pointer is null || _length == 0; + public bool IsNullOrEmpty => Pointer is null || Length == 0; public static InteropArray FromMemory(ReadOnlyMemory memory) { @@ -27,23 +27,28 @@ public static InteropArray FromMemory(ReadOnlyMemory memory) return new InteropArray((T*)interopBytes, memory.Length); } - public byte[] ToArray() + public T[] ToArray() { - return !IsNullOrEmpty ? new ReadOnlySpan(_pointer, (int)_length).ToArray() : []; + return !IsNullOrEmpty ? new ReadOnlySpan(Pointer, (int)Length).ToArray() : []; } - public byte[]? ToArrayOrNull() + public T[]? ToArrayOrNull() { - return !IsNullOrEmpty ? new ReadOnlySpan(_pointer, (int)_length).ToArray() : null; + return !IsNullOrEmpty ? new ReadOnlySpan(Pointer, (int)Length).ToArray() : null; } - public ReadOnlySpan AsReadOnlySpan() + public Span AsSpan() { - return !IsNullOrEmpty ? new ReadOnlySpan(_pointer, (int)_length) : null; + return !IsNullOrEmpty ? new Span(Pointer, (int)Length) : null; + } + + public ReadOnlySpan AsReadOnlySpan() + { + return !IsNullOrEmpty ? new ReadOnlySpan(Pointer, (int)Length) : null; } public void Free() { - NativeMemory.Free(_pointer); + NativeMemory.Free(Pointer); } } diff --git a/cs/sdk/src/protos/drive.proto b/cs/sdk/src/protos/drive.proto index 4d9f7a1d..7fe69e6a 100644 --- a/cs/sdk/src/protos/drive.proto +++ b/cs/sdk/src/protos/drive.proto @@ -3,21 +3,17 @@ syntax = "proto3"; option csharp_namespace = "Proton.Sdk.Drive.CExports"; message FileUploaderProvisionRequest { - string name = 1; - string mediaType = 2; - int64 last_modification_date = 3; - int64 file_size = 4; -} - -message FileUploadRequest { string parent_folder_uid = 1; - bool create_new_revision_if_exists = 2; - optional bytes thumbnail = 3; + string name = 2; + string mediaType = 3; + int64 file_size = 4; + int64 last_modification_date = 5; + bool create_new_revision_if_exists = 6; } -message RevisionUploadRequest { - string file_uid = 1; - optional bytes thumbnail = 2; +message FileRevisionUploaderProvisionRequest { + string current_active_revision_uid = 1; + int64 file_size = 2; int64 last_modification_date = 3; } From 5428204af641b588a99e854a640e2eda48da9dff Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Sep 2025 10:21:45 +0200 Subject: [PATCH 222/791] Refactor interop with request/response pattern --- cs/Directory.Packages.props | 21 +- cs/headers/proton_drive_sdk.h | 20 ++ cs/headers/proton_sdk.h | 180 +--------- .../InteropDownloadController.cs | 109 +----- .../InteropDriveErrorConverter.cs | 4 - .../InteropFileDownloader.cs | 128 +------ .../InteropFileUploader.cs | 112 +----- .../InteropMessageHandler.cs | 106 ++++++ .../InteropProtonDriveClient.cs | 185 +++------- .../InteropReadCallback.cs | 10 - .../InteropStream.cs | 172 ++++----- .../InteropUploadController.cs | 109 +----- .../InteropWriteCallback.cs | 10 - .../NativeLibraryResolver.cs | 35 ++ ...xtensions.cs => ProgressUpdateCallback.cs} | 11 +- .../Proton.Drive.Sdk.CExports.csproj | 12 +- .../Api/Links/LinkDetailsDto.cs | 4 +- .../Api/Links/LinkSharingDto.cs | 10 + .../Api/Volumes/IVolumesApiClient.cs | 2 + .../Api/Volumes/VolumeDetailsDto.cs | 16 + .../Api/Volumes/VolumeResponse.cs | 8 + .../Api/Volumes/VolumeShareDto.cs | 15 + .../Api/Volumes/VolumesApiClient.cs | 7 + .../Nodes/DtoToMetadataConverter.cs | 58 ++- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs | 7 + .../src/Proton.Drive.Sdk/Nodes/RevisionUid.cs | 7 + .../Nodes/Upload/RevisionWriter.cs | 19 +- .../DriveApiSerializerContext.cs | 1 + .../Volumes/VolumeOperations.cs | 8 + .../ExceptionExtensions.cs | 4 +- cs/sdk/src/Proton.Sdk.CExports/Interop.cs | 55 +++ .../InteropAsyncCallbackExtensions.cs | 91 ----- .../InteropAsyncValueCallback.cs | 15 - .../InteropAsyncVoidCallback.cs | 14 - .../InteropCallbackExtensions.cs | 17 + .../InteropCancellationTokenSource.cs | 75 +--- .../InteropMessageHandler.cs | 65 ++++ .../InteropProtonApiSession.cs | 336 ------------------ .../InteropResultExtensions.cs | 4 +- ...InteropTokenRefreshedCallbackExtensions.cs | 2 +- .../InteropValueCallback.cs | 4 +- .../InvalidHandleException.cs | 23 ++ .../Logging/InteropLogger.cs | 13 +- .../Logging/InteropLoggerProvider.cs | 40 +-- .../Proton.Sdk.CExports.csproj | 2 +- .../ProtonApiSessionRequestHandler.cs | 200 +++++++++++ .../Proton.Sdk/Http/HttpBodyLoggingHandler.cs | 76 ++++ .../ProtonClientConfigurationExtensions.cs | 5 + cs/sdk/src/protos/account.proto | 136 ------- cs/sdk/src/protos/drive.proto | 27 -- cs/sdk/src/protos/proton.drive.sdk.proto | 188 ++++++++++ cs/sdk/src/protos/proton.sdk.proto | 209 +++++++++++ 52 files changed, 1382 insertions(+), 1605 deletions(-) create mode 100644 cs/headers/proton_drive_sdk.h create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropReadCallback.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs rename cs/sdk/src/Proton.Drive.Sdk.CExports/{InteropProgressCallbackExtensions.cs => ProgressUpdateCallback.cs} (50%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkSharingDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDetailsDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Interop.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropCallbackExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InvalidHandleException.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpBodyLoggingHandler.cs delete mode 100644 cs/sdk/src/protos/account.proto delete mode 100644 cs/sdk/src/protos/drive.proto create mode 100644 cs/sdk/src/protos/proton.drive.sdk.proto create mode 100644 cs/sdk/src/protos/proton.sdk.proto diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 0d67b19a..605ef6c1 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -3,32 +3,31 @@ true - - - - - - + + + + + - + - + - + - + - + \ No newline at end of file diff --git a/cs/headers/proton_drive_sdk.h b/cs/headers/proton_drive_sdk.h new file mode 100644 index 00000000..0c88e533 --- /dev/null +++ b/cs/headers/proton_drive_sdk.h @@ -0,0 +1,20 @@ +#ifndef PROTON_DRIVE_SDK_H +#define PROTON_DRIVE_SDK_H + +#include +#include + +#include "proton_sdk.h" + +void proton_drive_sdk_handle_request( + ByteArray request, + const void* caller_state, + array_callback_function response_callback +); + +void proton_drive_sdk_handle_response( + const void* state, + ByteArray response +); + +#endif // PROTON_DRIVE_SDK_H diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h index be6d5fc5..1e8a2b85 100644 --- a/cs/headers/proton_sdk.h +++ b/cs/headers/proton_sdk.h @@ -9,187 +9,17 @@ typedef struct { size_t length; } ByteArray; -typedef struct { - const ByteArray* pointer; - size_t length; -} ByteArrayArray; - typedef void array_callback_function(const void* state, ByteArray array); -typedef struct { - array_callback_function* success_function; - array_callback_function* failure_function; - intptr_t cancellation_token_source_handle; -} AsyncArrayCallback; - -typedef struct { - void (*success_function)(const void* state, int returnValue); - array_callback_function* failure_function; - intptr_t cancellation_token_source_handle; -} AsyncIntCallback; - -typedef struct { - void (*success_function)(const void* state, intptr_t returnValue); - array_callback_function* failure_function; - intptr_t cancellation_token_source_handle; -} AsyncIntPtrCallback; - -typedef struct { - void (*success_function)(const void* state); - array_callback_function* failure_function; - intptr_t cancellation_token_source_handle; -} AsyncVoidCallback; - -typedef struct { - array_callback_function* function; -} ArrayCallback; - -// These callbacks receive yet another callback to allow asynchronous read/writes -typedef void ReadCallback( - const void* state, - ByteArray buffer, - const void* completion_callback_state, - AsyncIntCallback completion_callback); - -typedef void WriteCallback( - const void* state, - ByteArray buffer, - const void* completion_callback_state, - AsyncVoidCallback completion_callback); - -intptr_t cancellation_token_source_create(); - -void cancellation_token_source_cancel( - intptr_t cancellation_token_source_handle -); - -void cancellation_token_source_free( - intptr_t cancellation_token_source_handle +void override_native_library_name( + ByteArray library_name, + ByteArray overriding_library_name ); -int session_begin( +void proton_sdk_handle_request( ByteArray request, const void* caller_state, - AsyncIntPtrCallback result_callback -); - -int session_resume( - ByteArray request, - intptr_t* session_handle -); - -int session_renew( - intptr_t old_session_handle, - ByteArray request, - intptr_t* new_session_handle -); - -int session_end( - intptr_t session_handle, - const void* caller_state, - AsyncVoidCallback result_callback -); - -intptr_t session_tokens_refreshed_subscribe( - intptr_t session_handle, - const void* caller_state, - ArrayCallback tokens_refreshed_callback -); - -void session_tokens_refreshed_unsubscribe( - intptr_t subscription_handle -); - -void session_free(intptr_t session_handle); - -int logger_provider_create( - const void* caller_state, - ArrayCallback log_callback, - intptr_t* logger_provider_handle + array_callback_function response_callback ); -// Drive - -intptr_t drive_client_create( - intptr_t session_handle -); - -void drive_client_free(intptr_t client_handle); - -int get_file_uploader( - intptr_t client_handle, - ByteArray request, // FileUploaderProvisionRequest - size_t file_size, - const void* caller_state, - AsyncIntPtrCallback result_callback -); - -int get_revision_uploader( - intptr_t client_handle, - ByteArray request, // RevisionUploaderProvisionRequest - size_t file_size, - const void* caller_state, - AsyncIntPtrCallback result_callback -); - -intptr_t upload_from_stream( - intptr_t uploader_handle, - ByteArrayArray thumbnails, - const void* caller_state, - ReadCallback* read_callback, - ArrayCallback progress_callback, - intptr_t cancellation_token_source_handle -); - -intptr_t upload_from_path( - intptr_t uploader_handle, - ByteArray path, // UTF-8 - ByteArrayArray thumbnails, - const void* caller_state, - ArrayCallback progress_callback, - intptr_t cancellation_token_source_handle -); - -void file_uploader_free(intptr_t file_uploader_handle); - -int upload_controller_set_completion_callback( - intptr_t upload_controller_handle, - const void* caller_state, - AsyncVoidCallback result_callback); - -void upload_controller_pause(intptr_t file_uploader_handle); - -void upload_controller_resume(intptr_t file_uploader_handle); - -void upload_controller_free(intptr_t file_uploader_handle); - -int get_file_downloader( - intptr_t client_handle, - ByteArray request, // FileDownloaderProvisionRequest - const void* caller_state, - AsyncIntPtrCallback result_callback -); - -intptr_t download_to_stream( - intptr_t downloader_handle, - const void* caller_state, - WriteCallback* write_callback, - ArrayCallback progress_callback, - intptr_t cancellation_token_source_handle -); - -void file_downloader_free(intptr_t file_downloader_handle); - -int download_controller_set_completion_callback( - intptr_t download_controller_handle, - const void* caller_state, - AsyncVoidCallback result_callback -); - -void download_controller_pause(intptr_t file_downloader_handle); - -void download_controller_resume(intptr_t file_downloader_handle); - -void download_controller_free(intptr_t file_downloader_handle); - #endif // PROTON_SDK_H diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs index 2050ac4c..733b4702 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs @@ -1,118 +1,43 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using Google.Protobuf; using Proton.Drive.Sdk.Nodes.Download; -using Proton.Sdk; using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; internal static class InteropDownloadController { - private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out DownloadController downloadController) + public static async ValueTask HandleAwaitCompletion(DownloadControllerAwaitCompletionRequest request) { - if (handle == 0) - { - downloadController = null; - return false; - } + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); - var gcHandle = GCHandle.FromIntPtr(handle); + await downloadController.Completion.ConfigureAwait(false); - downloadController = gcHandle.Target as DownloadController; - - return downloadController is not null; - } - - [UnmanagedCallersOnly(EntryPoint = "download_controller_set_completion_callback", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeDownloadToStream(nint downloadControllerHandle, void* callerState, InteropAsyncVoidCallback asyncCallback) - { - try - { - if (!TryGetFromHandle(downloadControllerHandle, out var downloadController)) - { - return -1; - } - - return asyncCallback.InvokeFor(callerState, _ => InteropGetCompletion(downloadController)); - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "download_controller_pause", CallConvs = [typeof(CallConvCdecl)])] - private static int NativePause(nint downloadControllerHandle) - { - try - { - if (!TryGetFromHandle(downloadControllerHandle, out var downloadController)) - { - return -1; - } - - downloadController.Pause(); - - return 0; - } - catch - { - return -1; - } + return null; } - [UnmanagedCallersOnly(EntryPoint = "download_controller_resume", CallConvs = [typeof(CallConvCdecl)])] - private static int NativeResume(nint downloadControllerHandle) + public static IMessage? HandlePause(DownloadControllerPauseRequest request) { - try - { - if (!TryGetFromHandle(downloadControllerHandle, out var downloadController)) - { - return -1; - } + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); - downloadController.Resume(); + downloadController.Pause(); - return 0; - } - catch - { - return -1; - } + return null; } - [UnmanagedCallersOnly(EntryPoint = "download_controller_free", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeFree(nint downloadControllerHandle) + public static IMessage? HandleResume(DownloadControllerResumeRequest request) { - try - { - var gcHandle = GCHandle.FromIntPtr(downloadControllerHandle); + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); - if (gcHandle.Target is not DownloadController) - { - return; - } + downloadController.Resume(); - gcHandle.Free(); - } - catch - { - // Ignore - } + return null; } - private static async ValueTask>> InteropGetCompletion(DownloadController downloadController) + public static IMessage? HandleFree(DownloadControllerFreeRequest request) { - try - { - await downloadController.Completion.ConfigureAwait(false); + Interop.FreeHandle(request.DownloadControllerHandle); - return Result>.Success; - } - catch (Exception e) - { - return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); - } + return null; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs index 6ae45ae2..5e822e02 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -28,10 +28,6 @@ public static void SetDomainAndCodes(Error error, Exception exception) break; case NodeKeyAndSessionKeyMismatchException: - error.Domain = ErrorDomain.DataIntegrity; - error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode; - break; - case SessionKeyAndDataPacketMismatchException: error.Domain = ErrorDomain.DataIntegrity; error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index 64b11585..b68bf77c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -1,136 +1,32 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes.Download; -using Proton.Sdk; using Proton.Sdk.CExports; using Proton.Sdk.Drive.CExports; -using RevisionUid = Proton.Drive.Sdk.Nodes.RevisionUid; namespace Proton.Drive.Sdk.CExports; internal static class InteropFileDownloader { - private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out FileDownloader fileDownloader) + public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, nint callerState) { - if (handle == 0) - { - fileDownloader = null; - return false; - } + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - var gcHandle = GCHandle.FromIntPtr(handle); + var downloader = Interop.GetFromHandle(request.DownloaderHandle); - fileDownloader = gcHandle.Target as FileDownloader; + var stream = new InteropStream(callerState, (nint)request.WriteCallback); - return fileDownloader is not null; - } - - [UnmanagedCallersOnly(EntryPoint = "get_file_downloader", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeGetFileDownloader( - nint clientHandle, - InteropArray requestBytes, - void* callerState, - InteropAsyncValueCallback resultCallback) - { - try - { - if (!InteropProtonDriveClient.TryGetFromHandle(clientHandle, out var client)) - { - return -1; - } - - return resultCallback.InvokeFor(callerState, ct => InteropGetFileDownloaderAsync(client, requestBytes, ct)); - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "download_to_stream", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe nint NativeDownloadToStream( - nint fileDownloaderHandle, - void* callerState, - InteropWriteCallback writeCallback, - InteropValueCallback> progressCallback, - nint cancellationTokenSourceHandle) - { - try - { - if (!TryGetFromHandle(fileDownloaderHandle, out var downloader)) - { - return -1; - } - - if (!InteropCancellationTokenSource.TryGetTokenFromHandle(cancellationTokenSourceHandle, out var cancellationToken)) - { - return -1; - } + var progressUpdateCallback = new ProgressUpdateCallback((nint)request.ProgressCallback, callerState); - var stream = new InteropStream(callerState, writeCallback); + var downloadController = downloader.DownloadToStream(stream, progressUpdateCallback.UpdateProgress, cancellationToken); - var downloadController = downloader.DownloadToStream( - stream, - (completed, total) => progressCallback.UpdateProgress(callerState, completed, total), - cancellationToken); - - return GCHandle.ToIntPtr(GCHandle.Alloc(downloadController)); - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "file_downloader_free", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeFree(nint fileDownloaderHandle) - { - try - { - var gcHandle = GCHandle.FromIntPtr(fileDownloaderHandle); - - if (gcHandle.Target is not FileDownloader fileDownloader) - { - return; - } - - try - { - fileDownloader.Dispose(); - } - finally - { - gcHandle.Free(); - } - } - catch - { - // Ignore - } + return new Int64Value { Value = Interop.AllocHandle(downloadController) }; } - private static async ValueTask>> InteropGetFileDownloaderAsync( - ProtonDriveClient client, - InteropArray requestBytes, - CancellationToken cancellationToken) + public static IMessage? HandleFree(FileDownloaderFreeRequest request) { - try - { - var request = FileDownloaderProvisionRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - - if (!RevisionUid.TryParse(request.RevisionUid, out var revisionUid)) - { - throw new ArgumentException($"Invalid revision UID {revisionUid}", nameof(requestBytes)); - } - - var downloader = await client.GetFileDownloaderAsync(revisionUid.Value, cancellationToken).ConfigureAwait(false); + Interop.FreeHandle(request.FileDownloaderHandle); - return GCHandle.ToIntPtr(GCHandle.Alloc(downloader)); - } - catch (Exception e) - { - return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); - } + return null; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 9882a6b9..81ac9bf3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -1,115 +1,39 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using DotNext.Buffers; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; internal static class InteropFileUploader { - private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out FileUploader uploader) + public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, nint callerState) { - if (handle == 0) - { - uploader = null; - return false; - } + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - var gcHandle = GCHandle.FromIntPtr(handle); + var uploader = Interop.GetFromHandle(request.UploaderHandle); - uploader = gcHandle.Target as FileUploader; + var stream = new InteropStream(uploader.FileSize, callerState, (nint)request.ReadCallback); - return uploader is not null; - } - - [UnmanagedCallersOnly(EntryPoint = "upload_from_stream", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe nint NativeUploadFromStream( - nint fileUploaderHandle, - InteropArray interopThumbnails, - void* callerState, - InteropReadCallback readCallback, - InteropValueCallback> progressCallback, - nint cancellationTokenSourceHandle) - { - try - { - if (!TryGetFromHandle(fileUploaderHandle, out var uploader)) - { - return -1; - } - - if (!InteropCancellationTokenSource.TryGetTokenFromHandle(cancellationTokenSourceHandle, out var cancellationToken)) - { - return -1; - } - - var stream = new InteropStream(uploader.FileSize, callerState, readCallback); - - var thumbnails = GetThumbnailsFromInteropArray(interopThumbnails); - - var uploadController = uploader.UploadFromStream( - stream, - thumbnails, - (completed, total) => progressCallback.UpdateProgress(callerState, completed, total), - cancellationToken); + var thumbnails = request.Thumbnails.Select(t => new Nodes.Thumbnail((ThumbnailType)t.Type, t.ToByteArray())); - return GCHandle.ToIntPtr(GCHandle.Alloc(uploadController)); - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "file_uploader_free", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeFree(nint fileUploaderHandle) - { - try - { - var gcHandle = GCHandle.FromIntPtr(fileUploaderHandle); + var progressUpdateCallback = new ProgressUpdateCallback((nint)request.ProgressCallback, callerState); - if (gcHandle.Target is not FileUploader fileUploader) - { - return; - } + var uploadController = uploader.UploadFromStream( + stream, + thumbnails, + (completed, total) => progressUpdateCallback.UpdateProgress(completed, total), + cancellationToken); - try - { - fileUploader.Dispose(); - } - finally - { - gcHandle.Free(); - } - } - catch - { - // Ignore - } + return new Int64Value { Value = Interop.AllocHandle(uploadController) }; } - private static unsafe Thumbnail[] GetThumbnailsFromInteropArray(InteropArray interopThumbnails) + public static IMessage? HandleFree(FileUploaderFreeRequest request) { - var thumbnails = new Thumbnail[interopThumbnails.Length]; - var interopThumbnailsSpan = interopThumbnails.AsReadOnlySpan(); - - for (var i = 0; i < thumbnails.Length; ++i) - { - var interopThumbnail = interopThumbnailsSpan[i]; - var thumbnailContent = UnmanagedMemory.AsMemory(interopThumbnail.Content.Pointer, (int)interopThumbnail.Content.Length); - thumbnails[i] = new Thumbnail(interopThumbnail.Type, thumbnailContent); - } + Interop.FreeHandle(request.FileUploaderHandle); - return thumbnails; - } - - [StructLayout(LayoutKind.Sequential)] - private readonly struct InteropThumbnail - { - public readonly ThumbnailType Type; - public readonly InteropArray Content; + return null; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs new file mode 100644 index 00000000..5dd24cd1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -0,0 +1,106 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Google.Protobuf.WellKnownTypes; +using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; +using Request = Proton.Sdk.Drive.CExports.Request; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropMessageHandler +{ + [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])] + public static async void OnRequestReceived(InteropArray requestBytes, nint callerState, InteropValueCallback> responseCallback) + { + try + { + var request = Request.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + var response = request.PayloadCase switch + { + Request.PayloadOneofCase.DriveClientCreate + => InteropProtonDriveClient.HandleCreate(request.DriveClientCreate), + + Request.PayloadOneofCase.DriveClientFree + => InteropProtonDriveClient.HandleFree(request.DriveClientFree), + + Request.PayloadOneofCase.DriveClientGetFileUploader + => await InteropProtonDriveClient.HandleGetFileUploaderAsync(request.DriveClientGetFileUploader).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientGetFileRevisionUploader + => await InteropProtonDriveClient.HandleGetFileRevisionUploaderAsync(request.DriveClientGetFileRevisionUploader).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientGetFileDownloader + => await InteropProtonDriveClient.HandleGetFileDownloaderAsync(request.DriveClientGetFileDownloader).ConfigureAwait(false), + + Request.PayloadOneofCase.UploadFromStream + => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, callerState), + + Request.PayloadOneofCase.FileUploaderFree + => InteropFileUploader.HandleFree(request.FileUploaderFree), + + Request.PayloadOneofCase.UploadControllerAwaitCompletion + => await InteropUploadController.HandleAwaitCompletion(request.UploadControllerAwaitCompletion).ConfigureAwait(false), + + Request.PayloadOneofCase.UploadControllerPause + => InteropUploadController.HandlePause(request.UploadControllerPause), + + Request.PayloadOneofCase.UploadControllerResume + => InteropUploadController.HandleResume(request.UploadControllerResume), + + Request.PayloadOneofCase.UploadControllerFree + => InteropUploadController.HandleFree(request.UploadControllerFree), + + Request.PayloadOneofCase.DownloadToStream + => InteropFileDownloader.HandleDownloadToStream(request.DownloadToStream, callerState), + + Request.PayloadOneofCase.FileDownloaderFree + => InteropFileDownloader.HandleFree(request.FileDownloaderFree), + + Request.PayloadOneofCase.DownloadControllerAwaitCompletion + => await InteropDownloadController.HandleAwaitCompletion(request.DownloadControllerAwaitCompletion).ConfigureAwait(false), + + Request.PayloadOneofCase.DownloadControllerPause + => InteropDownloadController.HandlePause(request.DownloadControllerPause), + + Request.PayloadOneofCase.DownloadControllerResume + => InteropDownloadController.HandleResume(request.DownloadControllerResume), + + Request.PayloadOneofCase.DownloadControllerFree + => InteropDownloadController.HandleFree(request.DownloadControllerFree), + + Request.PayloadOneofCase.None or _ + => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), + }; + + responseCallback.InvokeWithResponse(callerState, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); + } + catch (Exception e) + { + var error = e.ToErrorMessage(InteropErrorConverter.SetDomainAndCodes); + + responseCallback.InvokeWithResponse(callerState, new Response { Error = error }); + } + } + + [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_response", CallConvs = [typeof(CallConvCdecl)])] + public static void OnResponseReceived(nint state, InteropArray responseBytes) + { + var response = CallbackResponse.Parser.ParseFrom(responseBytes.AsReadOnlySpan()); + + switch (response.PayloadCase) + { + case CallbackResponse.PayloadOneofCase.StreamRead: + InteropStream.HandleReadResponse(state, response.StreamRead); + break; + + case CallbackResponse.PayloadOneofCase.StreamWrite: + InteropStream.HandleWriteResponse(state, response.StreamWrite); + break; + + case CallbackResponse.PayloadOneofCase.None: + default: + throw new ArgumentException($"Unknown request type: {response.PayloadCase}", nameof(responseBytes)); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index d320dc6d..55b74221 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -1,6 +1,5 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes; using Proton.Sdk; using Proton.Sdk.CExports; @@ -10,161 +9,63 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropProtonDriveClient { - internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out ProtonDriveClient client) + public static IMessage HandleCreate(DriveClientCreateRequest request) { - if (handle == 0) - { - client = null; - return false; - } + var session = Interop.GetFromHandle(request.SessionHandle); - var gcHandle = GCHandle.FromIntPtr(handle); + var client = new ProtonDriveClient(session); - client = gcHandle.Target as ProtonDriveClient; - - return client is not null; + return new Int64Value { Value = Interop.AllocHandle(client) }; } - [UnmanagedCallersOnly(EntryPoint = "drive_client_create", CallConvs = [typeof(CallConvCdecl)])] - private static nint NativeCreate(nint sessionHandle) + public static async ValueTask HandleGetFileUploaderAsync(DriveClientGetFileUploaderRequest request) { - try - { - if (!InteropProtonApiSession.TryGetFromHandle(sessionHandle, out var session)) - { - return 0; - } - - var client = new ProtonDriveClient(session); - - return GCHandle.ToIntPtr(GCHandle.Alloc(client)); - } - catch - { - return 0; - } - } + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - [UnmanagedCallersOnly(EntryPoint = "get_file_uploader", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeGetFileUploader( - nint clientHandle, - InteropArray requestBytes, - void* callerState, - InteropAsyncValueCallback resultCallback) - { - try - { - if (!TryGetFromHandle(clientHandle, out var client)) - { - return -1; - } - - return resultCallback.InvokeFor(callerState, ct => InteropGetFileUploaderAsync(client, requestBytes, ct)); - } - catch - { - return -1; - } - } + var client = Interop.GetFromHandle(request.ClientHandle); - [UnmanagedCallersOnly(EntryPoint = "get_revision_uploader", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeGetRevisionUploader( - nint clientHandle, - InteropArray requestBytes, - void* callerState, - InteropAsyncValueCallback resultCallback) - { - try - { - if (!TryGetFromHandle(clientHandle, out var client)) - { - return -1; - } - - return resultCallback.InvokeFor(callerState, ct => InteropGetRevisionUploaderAsync(client, requestBytes, ct)); - } - catch - { - return -1; - } + var fileUploader = await client.GetFileUploaderAsync( + NodeUid.Parse(request.ParentFolderUid), + request.Name, + request.MediaType, + request.Size, + DateTimeOffset.FromUnixTimeSeconds(request.LastModificationTime).DateTime, + request.OverrideExistingDraftByOtherClient, + cancellationToken).ConfigureAwait(false); + + return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; } - [UnmanagedCallersOnly(EntryPoint = "drive_client_free", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeFree(nint handle) + public static async ValueTask HandleGetFileRevisionUploaderAsync(DriveClientGetFileRevisionUploaderRequest request) { - try - { - var gcHandle = GCHandle.FromIntPtr(handle); - - if (gcHandle.Target is not ProtonDriveClient) - { - return; - } - - gcHandle.Free(); - } - catch - { - // Ignore - } + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var fileUploader = await client.GetFileRevisionUploaderAsync( + RevisionUid.Parse(request.CurrentActiveRevisionUid), + request.FileSize, + DateTimeOffset.FromUnixTimeSeconds(request.LastModificationTime).DateTime, + cancellationToken).ConfigureAwait(false); + + return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; } - private static async ValueTask>> InteropGetFileUploaderAsync( - ProtonDriveClient client, - InteropArray requestBytes, - CancellationToken cancellationToken) + public static async ValueTask HandleGetFileDownloaderAsync(DriveClientGetFileDownloaderRequest request) { - try - { - var request = FileUploaderProvisionRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - - if (!NodeUid.TryParse(request.ParentFolderUid, out var parentUid)) - { - return -1; - } - - var uploader = await client.GetFileUploaderAsync( - parentUid.Value, - request.Name, - request.MediaType, - request.FileSize, - DateTimeOffset.FromUnixTimeSeconds(request.LastModificationDate).DateTime, - overrideExistingDraftByOtherClient: request.CreateNewRevisionIfExists, - cancellationToken).ConfigureAwait(false); - - return GCHandle.ToIntPtr(GCHandle.Alloc(uploader)); - } - catch (Exception e) - { - return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); - } + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var fileUploader = await client.GetFileDownloaderAsync(RevisionUid.Parse(request.RevisionUid), cancellationToken).ConfigureAwait(false); + + return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; } - private static async ValueTask>> InteropGetRevisionUploaderAsync( - ProtonDriveClient client, - InteropArray requestBytes, - CancellationToken cancellationToken) + public static IMessage? HandleFree(DriveClientFreeRequest request) { - try - { - var request = FileRevisionUploaderProvisionRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - - if (!RevisionUid.TryParse(request.CurrentActiveRevisionUid, out var currentActiveRevisionUid)) - { - return -1; - } - - var uploader = await client.GetFileRevisionUploaderAsync( - currentActiveRevisionUid.Value, - request.FileSize, - DateTimeOffset.FromUnixTimeSeconds(request.LastModificationDate).DateTime, - cancellationToken).ConfigureAwait(false); - - return GCHandle.ToIntPtr(GCHandle.Alloc(uploader)); - } - catch (Exception e) - { - return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); - } + Interop.FreeHandle(request.ClientHandle); + + return null; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropReadCallback.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropReadCallback.cs deleted file mode 100644 index f2a58adf..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropReadCallback.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Runtime.InteropServices; -using Proton.Sdk.CExports; - -namespace Proton.Drive.Sdk.CExports; - -[StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropReadCallback -{ - public readonly delegate* unmanaged[Cdecl], nint, InteropAsyncValueCallback, int> Invoke; -} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs index 3686826c..62ecfce1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs @@ -1,38 +1,38 @@ using System.Buffers; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; internal sealed unsafe class InteropStream : Stream { - private readonly long? _length; - private readonly void* _callerState; - private readonly InteropReadCallback _readCallback; - private readonly InteropWriteCallback _writeCallback; + private readonly nint _callerState; + private readonly delegate* unmanaged[Cdecl], nint, void> _readCallback; + private readonly delegate* unmanaged[Cdecl], nint, void> _writeCallback; private long _position; + private long? _length; - public InteropStream(long length, void* callerState, InteropReadCallback readCallback) + public InteropStream(long length, nint callerState, nint readCallbackPointer) { _length = length; _callerState = callerState; - _readCallback = readCallback; + _readCallback = (delegate* unmanaged[Cdecl], nint, void>)readCallbackPointer; _writeCallback = default; } - public InteropStream(void* callerState, InteropWriteCallback writeCallback) + public InteropStream(nint callerState, nint writeCallbackPointer) { _callerState = callerState; _readCallback = default; - _writeCallback = writeCallback; + _writeCallback = (delegate* unmanaged[Cdecl], nint, void>)writeCallbackPointer; } - public override bool CanRead => _readCallback.Invoke != null; + public override bool CanRead => _readCallback != null; public override bool CanSeek => false; - public override bool CanWrite => _writeCallback.Invoke != null; - public override long Length => _length ?? throw new NotSupportedException("Getting length not supported"); + public override bool CanWrite => _writeCallback != null; + public override long Length => _length ?? 0; public override long Position { @@ -40,6 +40,57 @@ public override long Position set => throw new NotSupportedException("Seeking not supported"); } + internal static void HandleReadResponse(nint state, StreamReadResponse response) + { + var operationHandle = GCHandle.FromIntPtr(state); + + try + { + var operation = Interop.GetFromHandle(operationHandle); + + switch (response.ResultCase) + { + case StreamReadResponse.ResultOneofCase.BytesRead: + operation.Complete(response.BytesRead); + break; + + case StreamReadResponse.ResultOneofCase.Error: + operation.Complete(response.Error.Message); + break; + + case StreamReadResponse.ResultOneofCase.None: + default: + break; + } + } + finally + { + operationHandle.Free(); + } + } + + internal static void HandleWriteResponse(nint state, StreamWriteResponse response) + { + var operationHandle = GCHandle.FromIntPtr(new nint(state)); + + try + { + var operation = Interop.GetFromHandle(operationHandle); + + if (response.Error != null) + { + operation.Complete(response.Error.Message); + return; + } + + operation.Complete(); + } + finally + { + operationHandle.Free(); + } + } + public override void Flush() { } @@ -56,7 +107,7 @@ public override Task ReadAsync(byte[] buffer, int offset, int count, Cancel public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - if (_readCallback.Invoke == null) + if (_readCallback == null) { throw new NotSupportedException("Reading not supported"); } @@ -70,11 +121,7 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken try { - _readCallback.Invoke( - _callerState, - new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), - GCHandle.ToIntPtr(operationHandle), - new InteropAsyncValueCallback(&OnReadSucceeded, &OnReadFailed, 0)); + _readCallback(_callerState, new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), GCHandle.ToIntPtr(operationHandle)); return new ValueTask(operation.Task); } @@ -113,7 +160,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - if (_writeCallback.Invoke == null) + if (_writeCallback == null) { throw new NotSupportedException("Writing not supported"); } @@ -127,11 +174,7 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo try { - _writeCallback.Invoke( - _callerState, - new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), - GCHandle.ToIntPtr(operationHandle), - new InteropAsyncVoidCallback(&OnWriteSucceeded, &OnWriteFailed, 0)); + _writeCallback(_callerState, new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), GCHandle.ToIntPtr(operationHandle)); return new ValueTask(operation.Task); } @@ -148,74 +191,6 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo } } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnReadSucceeded(void* state, int numberOfBytesRead) - { - var operationHandle = GCHandle.FromIntPtr(new nint(state)); - - try - { - var operation = (ReadOperation)operationHandle.Target!; - - operation.Complete(numberOfBytesRead); - } - finally - { - operationHandle.Free(); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnReadFailed(void* state, InteropArray errorBytes) - { - var operationHandle = GCHandle.FromIntPtr(new nint(state)); - - try - { - var operation = (ReadOperation)operationHandle.Target!; - - operation.CompleteWithFailure(errorBytes); - } - finally - { - operationHandle.Free(); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnWriteSucceeded(void* state) - { - var operationHandle = GCHandle.FromIntPtr(new nint(state)); - - try - { - var operation = (WriteOperation)operationHandle.Target!; - - operation.CompleteSuccessfully(); - } - finally - { - operationHandle.Free(); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnWriteFailed(void* state, InteropArray errorBytes) - { - var operationHandle = GCHandle.FromIntPtr(new nint(state)); - - try - { - var operation = (WriteOperation)operationHandle.Target!; - - operation.CompleteWithFailure(errorBytes); - } - finally - { - operationHandle.Free(); - } - } - private sealed class ReadOperation(InteropStream stream, MemoryHandle memoryHandle) { private readonly InteropStream _stream = stream; @@ -238,12 +213,11 @@ public void Complete(int bytesRead) } } - public void CompleteWithFailure(InteropArray errorBytes) + public void Complete(string errorMessage) { try { - var error = Error.Parser.ParseFrom(errorBytes.AsReadOnlySpan()); - _taskCompletionSource.SetException(new IOException(error.Message)); + _taskCompletionSource.SetException(new IOException(errorMessage)); } finally { @@ -262,11 +236,12 @@ private sealed class WriteOperation(InteropStream stream, MemoryHandle memoryHan public Task Task => _taskCompletionSource.Task; - public void CompleteSuccessfully() + public void Complete() { try { _stream._position += _bufferLength; + _stream._length = Math.Max(_stream._length ?? 0, _stream._position); _taskCompletionSource.SetResult(); } finally @@ -275,12 +250,11 @@ public void CompleteSuccessfully() } } - public void CompleteWithFailure(InteropArray errorBytes) + public void Complete(string errorMessage) { try { - var error = Error.Parser.ParseFrom(errorBytes.AsReadOnlySpan()); - _taskCompletionSource.SetException(new IOException(error.Message)); + _taskCompletionSource.SetException(new IOException(errorMessage)); } finally { diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs index c8dc8975..c1914173 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -1,118 +1,43 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using Google.Protobuf; using Proton.Drive.Sdk.Nodes.Upload; -using Proton.Sdk; using Proton.Sdk.CExports; +using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; internal static class InteropUploadController { - private static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out UploadController uploadController) + public static async ValueTask HandleAwaitCompletion(UploadControllerAwaitCompletionRequest request) { - if (handle == 0) - { - uploadController = null; - return false; - } + var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); - var gcHandle = GCHandle.FromIntPtr(handle); + await uploadController.Completion.ConfigureAwait(false); - uploadController = gcHandle.Target as UploadController; - - return uploadController is not null; - } - - [UnmanagedCallersOnly(EntryPoint = "upload_controller_set_completion_callback", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeSetCompletionCallback(nint uploadControllerHandle, void* callerState, InteropAsyncVoidCallback asyncCallback) - { - try - { - if (!TryGetFromHandle(uploadControllerHandle, out var uploadController)) - { - return -1; - } - - return asyncCallback.InvokeFor(callerState, _ => InteropGetCompletion(uploadController)); - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "upload_controller_pause", CallConvs = [typeof(CallConvCdecl)])] - private static int NativePause(nint uploadControllerHandle) - { - try - { - if (!TryGetFromHandle(uploadControllerHandle, out var uploadController)) - { - return -1; - } - - uploadController.Pause(); - - return 0; - } - catch - { - return -1; - } + return null; } - [UnmanagedCallersOnly(EntryPoint = "upload_controller_resume", CallConvs = [typeof(CallConvCdecl)])] - private static int NativeResume(nint uploadControllerHandle) + public static IMessage? HandlePause(UploadControllerPauseRequest request) { - try - { - if (!TryGetFromHandle(uploadControllerHandle, out var uploadController)) - { - return -1; - } + var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); - uploadController.Resume(); + uploadController.Pause(); - return 0; - } - catch - { - return -1; - } + return null; } - [UnmanagedCallersOnly(EntryPoint = "upload_controller_free", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeFree(nint uploadControllerHandle) + public static IMessage? HandleResume(UploadControllerResumeRequest request) { - try - { - var gcHandle = GCHandle.FromIntPtr(uploadControllerHandle); + var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); - if (gcHandle.Target is not UploadController) - { - return; - } + uploadController.Resume(); - gcHandle.Free(); - } - catch - { - // Ignore - } + return null; } - private static async ValueTask>> InteropGetCompletion(UploadController uploadController) + public static IMessage? HandleFree(UploadControllerFreeRequest request) { - try - { - await uploadController.Completion.ConfigureAwait(false); + Interop.FreeHandle(request.UploadControllerHandle); - return Result>.Success; - } - catch (Exception e) - { - return InteropResultExtensions.Failure(e, InteropDriveErrorConverter.SetDomainAndCodes); - } + return null; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs deleted file mode 100644 index 9ccc849f..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropWriteCallback.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Runtime.InteropServices; -using Proton.Sdk.CExports; - -namespace Proton.Drive.Sdk.CExports; - -[StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropWriteCallback -{ - public readonly delegate* unmanaged[Cdecl], nint, InteropAsyncVoidCallback, void> Invoke; -} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs new file mode 100644 index 00000000..aebbff33 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs @@ -0,0 +1,35 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using System.Text; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +public static class NativeLibraryResolver +{ + private static readonly ConcurrentDictionary LibraryNameMap = new(); + + [UnmanagedCallersOnly(EntryPoint = "override_native_library_name", CallConvs = [typeof(CallConvCdecl)])] + private static void OverrideNativeLibraryName(InteropArray libraryNameBytes, InteropArray overridingLibraryNameBytes) + { + var libraryName = Encoding.UTF8.GetString(libraryNameBytes.AsReadOnlySpan()); + var overridingLibraryName = Encoding.UTF8.GetString(overridingLibraryNameBytes.AsReadOnlySpan()); + + LibraryNameMap[libraryName] = overridingLibraryName; + + AssemblyLoadContext.Default.ResolvingUnmanagedDll += Resolve; + } + + private static nint Resolve(Assembly assembly, string libraryName) + { + if (LibraryNameMap.TryGetValue(libraryName, out var overridingLibraryName)) + { + libraryName = overridingLibraryName; + } + + return NativeLibrary.Load(libraryName, assembly, null); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/ProgressUpdateCallback.cs similarity index 50% rename from cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs rename to cs/sdk/src/Proton.Drive.Sdk.CExports/ProgressUpdateCallback.cs index 0d196a41..d0a46a94 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProgressCallbackExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/ProgressUpdateCallback.cs @@ -4,9 +4,14 @@ namespace Proton.Drive.Sdk.CExports; -internal static class InteropProgressCallbackExtensions +internal readonly unsafe struct ProgressUpdateCallback(nint progressCallbackPointer, nint callerState) { - internal static unsafe void UpdateProgress(this InteropValueCallback> progressCallback, void* callerState, long completed, long total) + private readonly delegate* unmanaged[Cdecl], void> _progressCallback = + (delegate* unmanaged[Cdecl], void>)progressCallbackPointer; + + private readonly IntPtr _callerState = callerState; + + public void UpdateProgress(long completed, long total) { var progressUpdate = new ProgressUpdate { @@ -18,7 +23,7 @@ internal static unsafe void UpdateProgress(this InteropValueCallback - @@ -20,7 +19,7 @@ - + @@ -33,15 +32,6 @@ - - - - - - - - - diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs index 433d8530..1e24c7bf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs @@ -8,13 +8,15 @@ internal sealed class LinkDetailsDto public required LinkDto Link { get; init; } public FolderDto? Folder { get; init; } public FileDto? File { get; init; } + public LinkSharingDto? Sharing { get; init; } public ShareMembershipSummaryDto? Membership { get; init; } - public void Deconstruct(out LinkDto link, out FolderDto? folder, out FileDto? file, out ShareMembershipSummaryDto? membership) + public void Deconstruct(out LinkDto link, out FolderDto? folder, out FileDto? file, out LinkSharingDto? sharing, out ShareMembershipSummaryDto? membership) { link = Link; folder = Folder; file = File; + sharing = Sharing; membership = Membership; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkSharingDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkSharingDto.cs new file mode 100644 index 00000000..5bb34da7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkSharingDto.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class LinkSharingDto +{ + [JsonPropertyName("ShareID")] + public required ShareId ShareId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs index 171d608b..61ae5fda 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs @@ -7,6 +7,8 @@ internal interface IVolumesApiClient { ValueTask CreateVolumeAsync(VolumeCreationRequest request, CancellationToken cancellationToken); + ValueTask GetVolumeAsync(VolumeId volumeId, CancellationToken cancellationToken); + ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken); ValueTask EmptyTrashAsync(VolumeId volumeId, CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDetailsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDetailsDto.cs new file mode 100644 index 00000000..5302bc48 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDetailsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeDetailsDto +{ + [JsonPropertyName("ID")] + public required VolumeId Id { get; set; } + + public required long UsedSpace { get; init; } + + public required VolumeState State { get; init; } + + public required VolumeShareDto Share { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeResponse.cs new file mode 100644 index 00000000..f9fdd424 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeResponse : ApiResponse +{ + public required VolumeDetailsDto Volume { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs new file mode 100644 index 00000000..9a69966d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeShareDto +{ + [JsonPropertyName("ShareID")] + public required ShareId ShareId { get; init; } + + [JsonPropertyName("LinkID")] + public required LinkId LinkId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs index b4a9f206..8e9f0147 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs @@ -17,6 +17,13 @@ public async ValueTask CreateVolumeAsync(VolumeCreationR .PostAsync("volumes", request, DriveApiSerializerContext.Default.VolumeCreationRequest, cancellationToken).ConfigureAwait(false); } + public async ValueTask GetVolumeAsync(VolumeId volumeId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.VolumeResponse) + .GetAsync($"volumes/{volumeId}", cancellationToken).ConfigureAwait(false); + } + public async ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken) { return await _httpClient diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 14284ba6..2ad7bc4c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -22,7 +22,7 @@ public static async Task> ConvertDtoT volumeId, linkDetailsDto.Link.ParentId, knownShareAndKey, - linkDetailsDto.Membership?.ShareId, + linkDetailsDto.Sharing?.ShareId, cancellationToken).ConfigureAwait(false); return await ConvertDtoToNodeMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false); @@ -59,7 +59,7 @@ public static async ValueTask> Co Result parentKeyResult, CancellationToken cancellationToken) { - var (linkDto, folderDto, _, membershipDto) = linkDetailsDto; + var (linkDto, folderDto, _, _, membershipDto) = linkDetailsDto; if (folderDto is null) { @@ -98,7 +98,13 @@ public static async ValueTask> Co HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), }; - throw new NotImplementedException(); + var nameOrError = decryptionResult.Link.Name.TryGetValueElseError(out var nameValue, out var error) ? nameValue.Data : error; + var name = (NodeOperations.ValidateName(decryptionResult.Link.Name, out _, out _, out _) ? "✅ " : "⌠") + $"(\"{nameOrError}\")"; + var nk = decryptionResult.Link.NodeKey.TryGetValueElseError(out _, out var nkError) ? "✅" : $"⌠(\"{nkError}\")"; + var pp = decryptionResult.Link.Passphrase.TryGetValueElseError(out _, out var ppError) ? "✅" : $"⌠(\"{ppError}\")"; + var hk = decryptionResult.HashKey.TryGetValueElseError(out _, out var hkError) ? "✅" : $"⌠(\"{hkError}\")"; + + throw new TempDebugException($"Name: {name}, Node key: {nk}, Passphrase: {pp}, Hash Key: {hk}"); } var secrets = new FolderSecrets @@ -136,7 +142,7 @@ public static async Task> ConvertDtoT Result parentKeyResult, CancellationToken cancellationToken) { - var (linkDto, _, fileDto, membershipDto) = linkDetailsDto; + var (linkDto, _, fileDto, _, membershipDto) = linkDetailsDto; if (fileDto is null) { @@ -192,7 +198,14 @@ public static async Task> ConvertDtoT ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), }; - throw new NotImplementedException(); + var nameOrError = decryptionResult.Link.Name.TryGetValueElseError(out var nameValue, out var error) ? nameValue.Data : error; + var name = (NodeOperations.ValidateName(decryptionResult.Link.Name, out _, out _, out _) ? "✅ " : "⌠") + $"(\"{nameOrError}\")"; + var nk = decryptionResult.Link.NodeKey.TryGetValueElseError(out _, out var nkError) ? "✅" : $"⌠(\"{nkError}\")"; + var pp = decryptionResult.Link.Passphrase.TryGetValueElseError(out _, out var ppError) ? "✅" : $"⌠(\"{ppError}\")"; + var ea = decryptionResult.ExtendedAttributes.TryGetValueElseError(out _, out var eaError) ? "✅" : $"⌠(\"{eaError}\")"; + var ck = decryptionResult.ContentKey.TryGetValueElseError(out _, out var ckError) ? "✅" : $"⌠(\"{ckError}\")"; + + throw new TempDebugException($"Name: {name}, Node key: {nk}, Passphrase: {pp}, Extended Attributes: {ea}, Content Key: {ck}"); } var secrets = new FileSecrets @@ -244,16 +257,16 @@ private static async ValueTask> GetParen VolumeId volumeId, LinkId? parentId, ShareAndKey? shareAndKeyToUse, - ShareId? childMembershipShareId, + ShareId? childShareId, CancellationToken cancellationToken) { - if (childMembershipShareId is not null && childMembershipShareId == shareAndKeyToUse?.Share.Id) + if (childShareId is not null && childShareId == shareAndKeyToUse?.Share.Id) { return shareAndKeyToUse.Value.Key; } var currentId = parentId; - var currentMembershipShareId = childMembershipShareId; + var currentShareId = childShareId; var linkAncestry = new Stack(8); @@ -286,11 +299,11 @@ private static async ValueTask> GetParen linkAncestry.Push(linkDetails); - var (link, _, _, membership) = linkDetails; + var (link, _, _, sharing, _) = linkDetails; - currentId = link.ParentId; + currentShareId = sharing?.ShareId; - currentMembershipShareId = membership?.ShareId; + currentId = link.ParentId; } } catch (Exception e) @@ -306,12 +319,12 @@ private static async ValueTask> GetParen } else { - if (currentMembershipShareId is null) + if (currentShareId is null) { - return new ProtonDriveError("No membership available to access node"); + return new ProtonDriveError("No share available to access node"); } - (_, currentParentKey) = await ShareOperations.GetShareAsync(client, currentMembershipShareId.Value, cancellationToken).ConfigureAwait(false); + (_, currentParentKey) = await ShareOperations.GetShareAsync(client, currentShareId.Value, cancellationToken).ConfigureAwait(false); } } @@ -336,3 +349,20 @@ private static async ValueTask> GetParen return currentParentKey; } } + +public sealed class TempDebugException : Exception +{ + public TempDebugException(string message) + : base(message) + { + } + + public TempDebugException(string message, Exception innerException) + : base(message, innerException) + { + } + + public TempDebugException() + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs index b8a6ff6b..669ecbd6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs @@ -28,6 +28,13 @@ public static bool TryParse(string s, [NotNullWhen(true)] out NodeUid? result) return ICompositeUid.TryParse(s, out result); } + public static NodeUid Parse(string s) + { + return ICompositeUid.TryParse(s, out var result) + ? result.Value + : throw new FormatException($"Invalid node UID format: \"{s}\""); + } + static bool ICompositeUid.TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out NodeUid? uid) { uid = new NodeUid(new VolumeId(baseUidString), new LinkId(relativeIdString)); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs index e5bb3848..01d32b79 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs @@ -27,6 +27,13 @@ public static bool TryParse(string s, [NotNullWhen(true)] out RevisionUid? resul return ICompositeUid.TryParse(s, out result); } + public static RevisionUid Parse(string s) + { + return ICompositeUid.TryParse(s, out var result) + ? result.Value + : throw new FormatException($"Invalid revision UID format: \"{s}\""); + } + static bool ICompositeUid.TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out RevisionUid? uid) { if (!ICompositeUid.TryParse(baseUidString, out var nodeUid)) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 7e77f2ce..7737c219 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -107,8 +107,23 @@ public async ValueTask WriteAsync( { var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await contentStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plainDataPrefix, cancellationTokenSource.Token) - .ConfigureAwait(false); + var buffer = ArrayPool.Shared.Rent(_targetBlockSize); + + try + { + var bytesRead = await contentStream.ReadAsync(buffer, cancellationTokenSource.Token).ConfigureAwait(false); + + buffer.AsSpan(0, Math.Min(bytesRead, plainDataPrefix.Length)).CopyTo(plainDataPrefix); + + plainDataStream.Write(buffer.AsSpan(0, bytesRead)); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + //await contentStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plainDataPrefix, cancellationTokenSource.Token) + // .ConfigureAwait(false); blockSizes.Add((int)plainDataStream.Length); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 3080f7f4..649029b0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -25,6 +25,7 @@ namespace Proton.Drive.Sdk.Serialization; #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(VolumeCreationRequest))] [JsonSerializable(typeof(VolumeCreationResponse))] +[JsonSerializable(typeof(VolumeResponse))] [JsonSerializable(typeof(LinkDetailsRequest))] [JsonSerializable(typeof(LinkDetailsResponse))] [JsonSerializable(typeof(ExtendedAttributes))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index a9302ee5..89b678b6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes; @@ -53,6 +54,13 @@ internal static class VolumeOperations return (volume, share, rootFolder); } + public static async ValueTask GetVolumeMainShareIdAsync(ProtonDriveClient client, VolumeId volumeId, CancellationToken cancellationToken) + { + var response = await client.Api.Volumes.GetVolumeAsync(volumeId, cancellationToken).ConfigureAwait(false); + + return response.Volume.Share.ShareId; + } + public static async IAsyncEnumerable> EnumerateTrashAsync( ProtonDriveClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) diff --git a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs index 4251736e..64d02461 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs @@ -2,7 +2,7 @@ internal static class ExceptionExtensions { - public static Error ToInteropError(this Exception exception, Action setDomainAndCodesFunction) + public static Error ToErrorMessage(this Exception exception, Action setDomainAndCodesFunction) { var error = new Error { @@ -23,7 +23,7 @@ public static Error ToInteropError(this Exception exception, Action(T obj) + where T : class + { + return GCHandle.ToIntPtr(GCHandle.Alloc(obj)); + } + + internal static T GetFromHandle(long handle) + where T : class + { + GCHandle gcHandle; + try + { + gcHandle = GCHandle.FromIntPtr((nint)handle); + } + catch (Exception e) + { + throw InvalidHandleException.Create((nint)handle, e); + } + + return GetFromHandle(gcHandle); + } + + internal static T GetFromHandle(GCHandle gcHandle) + where T : class + { + return (T)(gcHandle.Target ?? throw InvalidHandleException.Create(GCHandle.ToIntPtr(gcHandle))); + } + + internal static void FreeHandle(long handle) + where T : class + { + var gcHandle = GCHandle.FromIntPtr((nint)handle); + + if (gcHandle.Target is not T) + { + throw InvalidHandleException.Create((nint)handle); + } + + gcHandle.Free(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static CancellationToken GetCancellationToken(long cancellationTokenSourceHandle) + { + return GetFromHandle(cancellationTokenSourceHandle).Token; + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs deleted file mode 100644 index 1204ed48..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncCallbackExtensions.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Proton.Sdk.CExports; - -internal static class InteropAsyncCallbackExtensions -{ - public static unsafe int InvokeFor( - this InteropAsyncValueCallback callback, - void* callerState, - Func>>> asyncFunction) - where TResult : unmanaged - { - if (!InteropCancellationTokenSource.TryGetTokenFromHandle(callback.CancellationTokenSourceHandle, out var cancellationToken)) - { - return -1; - } - - Use( - value => callback.OnSuccess(callerState, value), - error => callback.OnFailure(callerState, error), - asyncFunction, - cancellationToken).RunInBackground(); - - return 0; - } - - public static unsafe int InvokeFor( - this InteropAsyncVoidCallback callback, - void* callerState, - Func>>> asyncFunction) - { - if (!InteropCancellationTokenSource.TryGetTokenFromHandle(callback.CancellationTokenSourceHandle, out var cancellationToken)) - { - return -1; - } - - Use( - () => callback.OnSuccess(callerState), - error => callback.OnFailure(callerState, error), - asyncFunction, - cancellationToken).RunInBackground(); - - return 0; - } - - private static async ValueTask Use( - Action onSuccess, - Action> onFailure, - Func>>> asyncFunction, - CancellationToken cancellationToken) - { - try - { - var result = await asyncFunction.Invoke(cancellationToken).ConfigureAwait(false); - - if (!result.TryGetValueElseError(out var value, out var error)) - { - onFailure(error); - return; - } - - onSuccess(value); - } - catch - { - // TODO: log - } - } - - private static async ValueTask Use( - Action onSuccess, - Action> onFailure, - Func>>> asyncFunction, - CancellationToken cancellationToken) - { - try - { - var result = await asyncFunction.Invoke(cancellationToken).ConfigureAwait(false); - - if (result.TryGetError(out var error)) - { - onFailure(error); - return; - } - - onSuccess(); - } - catch - { - // TODO: log - } - } -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs deleted file mode 100644 index 51ea6d7f..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncValueCallback.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Proton.Sdk.CExports; - -[StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropAsyncValueCallback( - delegate* unmanaged[Cdecl] onSuccess, - delegate* unmanaged[Cdecl], void> onFailure, - nint cancellationTokenSourceHandle) - where T : unmanaged -{ - public readonly delegate* unmanaged[Cdecl] OnSuccess = onSuccess; - public readonly delegate* unmanaged[Cdecl], void> OnFailure = onFailure; - public readonly nint CancellationTokenSourceHandle = cancellationTokenSourceHandle; -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs deleted file mode 100644 index 4c441db7..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAsyncVoidCallback.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Proton.Sdk.CExports; - -[StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropAsyncVoidCallback( - delegate* unmanaged[Cdecl] onSuccess, - delegate* unmanaged[Cdecl], void> onFailure, - nint cancellationTokenSourceHandle) -{ - public readonly delegate* unmanaged[Cdecl] OnSuccess = onSuccess; - public readonly delegate* unmanaged[Cdecl], void> OnFailure = onFailure; - public readonly nint CancellationTokenSourceHandle = cancellationTokenSourceHandle; -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropCallbackExtensions.cs new file mode 100644 index 00000000..01e058b3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropCallbackExtensions.cs @@ -0,0 +1,17 @@ +using Google.Protobuf; + +namespace Proton.Sdk.CExports; + +internal static class InteropCallbackExtensions +{ + public static unsafe void InvokeWithResponse(this InteropValueCallback> callback, nint callerState, T response) + where T : IMessage + { + var responseBytes = response.ToByteArray(); + + fixed (byte* responsePointer = responseBytes) + { + callback.Invoke(callerState, new InteropArray(responsePointer, responseBytes.Length)); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs index 0825ff24..879a4132 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs @@ -1,81 +1,28 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; namespace Proton.Sdk.CExports; internal static class InteropCancellationTokenSource { - internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out CancellationTokenSource cancellationTokenSource) + public static IMessage HandleCreate(CancellationTokenSourceCreateRequest request) { - var gcHandle = GCHandle.FromIntPtr(handle); - - cancellationTokenSource = gcHandle.Target as CancellationTokenSource; - - return cancellationTokenSource is not null; + return new Int64Value { Value = Interop.AllocHandle(new CancellationTokenSource()) }; } - internal static bool TryGetTokenFromHandle(nint handle, out CancellationToken cancellationToken) + public static IMessage? HandleCancel(CancellationTokenSourceCancelRequest request) { - if (handle == 0) - { - cancellationToken = CancellationToken.None; - return true; - } - - if (!TryGetFromHandle(handle, out var cancellationTokenSource)) - { - cancellationToken = CancellationToken.None; - return false; - } + var cancellationTokenSource = Interop.GetFromHandle(request.CancellationTokenSourceHandle); - cancellationToken = cancellationTokenSource.Token; - return true; - } + cancellationTokenSource.Cancel(); - [UnmanagedCallersOnly(EntryPoint = "cancellation_token_source_create", CallConvs = [typeof(CallConvCdecl)])] - private static nint NativeCreate() - { - return GCHandle.ToIntPtr(GCHandle.Alloc(new CancellationTokenSource())); + return null; } - [UnmanagedCallersOnly(EntryPoint = "cancellation_token_source_cancel", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeCancel(nint cancellationTokenSourceHandle) + public static IMessage? HandleFree(CancellationTokenSourceFreeRequest request) { - try - { - if (!TryGetFromHandle(cancellationTokenSourceHandle, out var cancellationTokenSource)) - { - return; - } - - cancellationTokenSource.Cancel(); - } - catch - { - // Ignore - } - } - - [UnmanagedCallersOnly(EntryPoint = "cancellation_token_source_free", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeFree(nint cancellationTokenSourceHandle) - { - try - { - var gcHandle = GCHandle.FromIntPtr(cancellationTokenSourceHandle); - - if (gcHandle.Target is not CancellationTokenSource cancellationTokenSource) - { - return; - } - - gcHandle.Free(); + Interop.FreeHandle(request.CancellationTokenSourceHandle); - cancellationTokenSource.Dispose(); - } - catch - { - // Ignore - } + return null; } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs new file mode 100644 index 00000000..2c907d58 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -0,0 +1,65 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Google.Protobuf.WellKnownTypes; +using Proton.Sdk.CExports.Logging; + +namespace Proton.Sdk.CExports; + +internal static class InteropMessageHandler +{ + [UnmanagedCallersOnly(EntryPoint = "proton_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])] + public static async void OnRequestReceived(InteropArray requestBytes, nint callerState, InteropValueCallback> responseCallback) + { + try + { + var request = Request.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + var response = request.PayloadCase switch + { + Request.PayloadOneofCase.CancellationTokenSourceCreate + => InteropCancellationTokenSource.HandleCreate(request.CancellationTokenSourceCreate), + + Request.PayloadOneofCase.CancellationTokenSourceCancel + => InteropCancellationTokenSource.HandleCancel(request.CancellationTokenSourceCancel), + + Request.PayloadOneofCase.CancellationTokenSourceFree + => InteropCancellationTokenSource.HandleFree(request.CancellationTokenSourceFree), + + Request.PayloadOneofCase.SessionBegin + => await ProtonApiSessionRequestHandler.HandleBeginAsync(request.SessionBegin).ConfigureAwait(false), + + Request.PayloadOneofCase.SessionResume + => ProtonApiSessionRequestHandler.HandleResume(request.SessionResume), + + Request.PayloadOneofCase.SessionRenew + => ProtonApiSessionRequestHandler.HandleRenew(request.SessionRenew), + + Request.PayloadOneofCase.SessionEnd + => await ProtonApiSessionRequestHandler.HandleEndAsync(request.SessionEnd).ConfigureAwait(false), + + Request.PayloadOneofCase.SessionFree + => ProtonApiSessionRequestHandler.HandleFree(request.SessionFree), + + Request.PayloadOneofCase.SessionTokensRefreshedSubscribe + => ProtonApiSessionRequestHandler.HandleSubscribeToTokensRefreshed(request.SessionTokensRefreshedSubscribe, callerState), + + Request.PayloadOneofCase.SessionTokensRefreshedUnsubscribe + => ProtonApiSessionRequestHandler.HandleUnsubscribeFromTokensRefreshed(request.SessionTokensRefreshedUnsubscribe), + + Request.PayloadOneofCase.LoggerProviderCreate + => InteropLoggerProvider.HandleCreate(request.LoggerProviderCreate, callerState), + + Request.PayloadOneofCase.None or _ + => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), + }; + + responseCallback.InvokeWithResponse(callerState, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); + } + catch (Exception e) + { + var error = e.ToErrorMessage(InteropErrorConverter.SetDomainAndCodes); + + responseCallback.InvokeWithResponse(callerState, new Response { Error = error }); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs deleted file mode 100644 index e39b768c..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropProtonApiSession.cs +++ /dev/null @@ -1,336 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Text; -using Google.Protobuf; -using Microsoft.Extensions.Logging; -using Proton.Sdk.Authentication; -using Proton.Sdk.Caching; -using Proton.Sdk.CExports.Logging; - -namespace Proton.Sdk.CExports; - -internal static class InteropProtonApiSession -{ - internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out ProtonApiSession session) - { - if (handle == 0) - { - session = null; - return false; - } - - var gcHandle = GCHandle.FromIntPtr(handle); - - session = gcHandle.Target as ProtonApiSession; - - return session is not null; - } - - [UnmanagedCallersOnly(EntryPoint = "session_begin", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeBegin(InteropArray requestBytes, void* callerState, InteropAsyncValueCallback resultCallback) - { - try - { - var request = SessionBeginRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - - return resultCallback.InvokeFor(callerState, ct => InteropBeginAsync(request, ct)); - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "session_resume", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeResume(InteropArray requestBytes, nint* sessionHandle) - { - try - { - var request = SessionResumeRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - - ILoggerFactory? loggerFactory = null; - - if (request.Options.HasLoggerProviderHandle - && InteropLoggerProvider.TryGetFromHandle((nint)request.Options.LoggerProviderHandle, out var loggerProvider)) - { - loggerFactory = new LoggerFactory([loggerProvider]); - } - - var secretCacheRepository = SqliteCacheRepository.OpenFile(request.SecretCachePath); - - var entityCacheRepository = request.Options.HasEntityCachePath - ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) - : SqliteCacheRepository.OpenInMemory(); - - var options = new Proton.Sdk.ProtonClientOptions - { - BaseUrl = new Uri(request.Options.BaseUrl), - UserAgent = request.Options.UserAgent, - BindingsLanguage = request.Options.BindingsLanguage, - LoggerFactory = loggerFactory, - TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, - EntityCacheRepository = entityCacheRepository, - SecretCacheRepository = secretCacheRepository, - }; - - var passwordMode = request.IsWaitingForDataPassword ? PasswordMode.Dual : PasswordMode.Single; - - var session = ProtonApiSession.Resume( - new Authentication.SessionId(request.SessionId.Value), - request.Username, - new Users.UserId(request.UserId.Value), - request.AccessToken, - request.RefreshToken, - request.Scopes, - request.IsWaitingForSecondFactorCode, - passwordMode, - request.AppVersion, - secretCacheRepository, - options); - - *sessionHandle = GCHandle.ToIntPtr(GCHandle.Alloc(session)); - return 0; - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "session_renew", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int NativeRenew(nint oldSessionHandle, InteropArray requestBytes, nint* newSessionHandle) - { - try - { - var request = SessionRenewRequest.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); - - if (!TryGetFromHandle(oldSessionHandle, out var expiredSession)) - { - return -1; - } - - var passwordMode = request.IsWaitingForDataPassword ? PasswordMode.Dual : PasswordMode.Single; - - var session = ProtonApiSession.Renew( - expiredSession, - new Authentication.SessionId(request.SessionId.Value), - request.AccessToken, - request.RefreshToken, - request.Scopes, - request.IsWaitingForSecondFactorCode, - passwordMode); - - *newSessionHandle = GCHandle.ToIntPtr(GCHandle.Alloc(session)); - - return 0; - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "session_end", CallConvs = [typeof(CallConvCdecl), typeof(CallConvMemberFunction)])] - private static unsafe int NativeEnd(nint sessionHandle, void* callerState, InteropAsyncVoidCallback resultCallback) - { - try - { - if (!TryGetFromHandle(sessionHandle, out var session)) - { - return -1; - } - - resultCallback.InvokeFor(callerState, _ => InteropEndAsync(session)); - - return 0; - } - catch - { - return -1; - } - } - - [UnmanagedCallersOnly(EntryPoint = "session_tokens_refreshed_subscribe", CallConvs = [typeof(CallConvCdecl)])] - private static unsafe nint NativeSubscribeTokensRefreshed( - nint sessionHandle, - void* callerState, - InteropValueCallback> tokensRefreshedCallback) - { - try - { - if (!TryGetFromHandle(sessionHandle, out var session)) - { - return 0; - } - - var subscription = TokensRefreshedSubscription.Create(session, callerState, tokensRefreshedCallback); - - return GCHandle.ToIntPtr(GCHandle.Alloc(subscription)); - } - catch - { - return 0; - } - } - - [UnmanagedCallersOnly(EntryPoint = "session_tokens_refreshed_unsubscribe", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeUnsubscribeTokensRefreshed(nint subscriptionHandle) - { - try - { - if (!TryGetTokensExpiredSubscriptionFromHandle(subscriptionHandle, out var unregisterAction)) - { - return; - } - - unregisterAction.Dispose(); - } - catch - { - // Ignore - } - } - - [UnmanagedCallersOnly(EntryPoint = "session_free", CallConvs = [typeof(CallConvCdecl)])] - private static void NativeFree(nint handle) - { - try - { - var gcHandle = GCHandle.FromIntPtr(handle); - - if (gcHandle.Target is not ProtonApiSession) - { - return; - } - - gcHandle.Free(); - } - catch - { - // Ignore - } - } - - private static async ValueTask>> InteropBeginAsync( - SessionBeginRequest request, - CancellationToken cancellationToken) - { - try - { - ILoggerFactory? loggerFactory = null; - - if (request.Options.HasLoggerProviderHandle - && InteropLoggerProvider.TryGetFromHandle((nint)request.Options.LoggerProviderHandle, out var loggerProvider)) - { - loggerFactory = new LoggerFactory([loggerProvider]); - } - - var secretCacheRepository = request.HasSecretCachePath - ? SqliteCacheRepository.OpenFile(request.SecretCachePath) - : SqliteCacheRepository.OpenInMemory(); - - var entityCacheRepository = request.Options.HasEntityCachePath - ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) - : SqliteCacheRepository.OpenInMemory(); - - var options = new ProtonSessionOptions - { - BaseUrl = new Uri(request.Options.BaseUrl), - UserAgent = request.Options.UserAgent, - BindingsLanguage = request.Options.BindingsLanguage, - LoggerFactory = loggerFactory, - TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, - EntityCacheRepository = entityCacheRepository, - SecretCacheRepository = secretCacheRepository, - }; - - var session = await ProtonApiSession.BeginAsync( - request.Username, - Encoding.UTF8.GetBytes(request.Password), - request.AppVersion, - options, - cancellationToken).ConfigureAwait(false); - - return GCHandle.ToIntPtr(GCHandle.Alloc(session)); - } - catch (Exception e) - { - return InteropResultExtensions.Failure(e, InteropErrorConverter.SetDomainAndCodes); - } - } - - private static async ValueTask>> InteropEndAsync(ProtonApiSession session) - { - try - { - await session.EndAsync().ConfigureAwait(false); - - return Result>.Success; - } - catch (Exception e) - { - return InteropResultExtensions.Failure(e, InteropErrorConverter.SetDomainAndCodes); - } - } - - private static bool TryGetTokensExpiredSubscriptionFromHandle(nint handle, [MaybeNullWhen(false)] out TokensRefreshedSubscription subscription) - { - if (handle == 0) - { - subscription = null; - return false; - } - - var gcHandle = GCHandle.FromIntPtr(handle); - - subscription = gcHandle.Target as TokensRefreshedSubscription; - - return subscription is not null; - } - - private sealed unsafe class TokensRefreshedSubscription : IDisposable - { - private readonly ProtonApiSession _session; - private readonly void* _callerState; - private readonly InteropValueCallback> _tokensRefreshedCallback; - - private TokensRefreshedSubscription(ProtonApiSession session, void* callerState, InteropValueCallback> tokensRefreshedCallback) - { - _session = session; - _callerState = callerState; - _tokensRefreshedCallback = tokensRefreshedCallback; - } - - public static TokensRefreshedSubscription Create( - ProtonApiSession session, - void* callerState, - InteropValueCallback> tokensRefreshedCallback) - { - var subscription = new TokensRefreshedSubscription(session, callerState, tokensRefreshedCallback); - - session.TokenCredential.TokensRefreshed += subscription.Handle; - - return subscription; - } - - public void Dispose() - { - _session.TokenCredential.TokensRefreshed -= Handle; - } - - private void Handle(string accessToken, string refreshToken) - { - var tokensMessage = InteropArray.FromMemory(new SessionTokens { AccessToken = accessToken, RefreshToken = refreshToken }.ToByteArray()); - - try - { - _tokensRefreshedCallback.Invoke(_callerState, tokensMessage); - } - finally - { - tokensMessage.Free(); - } - } - } -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs index 1a0bd618..06af98e9 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs @@ -49,14 +49,14 @@ internal static Result> Failure(Exception exc internal static Result> Failure(Exception exception, Action setDomainAndCodesFunction) { - var error = exception.ToInteropError(setDomainAndCodesFunction); + var error = exception.ToErrorMessage(setDomainAndCodesFunction); return new Result>(error: InteropArray.FromMemory(error.ToByteArray())); } internal static Result> Failure(Exception exception, Action setDomainAndCodesFunction) { - var error = exception.ToInteropError(setDomainAndCodesFunction); + var error = exception.ToErrorMessage(setDomainAndCodesFunction); return new Result>(error: InteropArray.FromMemory(error.ToByteArray())); } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs index 558cedb1..c6f66a1e 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs @@ -4,7 +4,7 @@ namespace Proton.Sdk.CExports; internal static class InteropTokenRefreshedCallbackExtensions { - internal static unsafe void Invoke(this InteropValueCallback> callback, void* callerState, string accessToken, string refreshToken) + internal static unsafe void Invoke(this InteropValueCallback> callback, nint callerState, string accessToken, string refreshToken) { var sessionTokens = new SessionTokens { diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs index 98a55041..268cfc87 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs @@ -3,8 +3,8 @@ namespace Proton.Sdk.CExports; [StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropValueCallback(delegate* unmanaged[Cdecl] invoke) +internal readonly unsafe struct InteropValueCallback(delegate* unmanaged[Cdecl] invoke) where TValue : unmanaged { - public readonly delegate* unmanaged[Cdecl] Invoke = invoke; + public readonly delegate* unmanaged[Cdecl] Invoke = invoke; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InvalidHandleException.cs b/cs/sdk/src/Proton.Sdk.CExports/InvalidHandleException.cs new file mode 100644 index 00000000..5798ef66 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InvalidHandleException.cs @@ -0,0 +1,23 @@ +namespace Proton.Sdk.CExports; + +public class InvalidHandleException : Exception +{ + public InvalidHandleException() + { + } + + public InvalidHandleException(string message) + : base(message) + { + } + + public InvalidHandleException(string message, Exception? innerException) + : base(message, innerException) + { + } + + public static InvalidHandleException Create(nint handle, Exception? innerException = null) + { + return new InvalidHandleException($"Invalid handle {handle:x16} for {typeof(T).Name}", innerException); + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index 444f7eb8..74c82afa 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -4,13 +4,14 @@ namespace Proton.Sdk.CExports.Logging; -using EventId = Microsoft.Extensions.Logging.EventId; - [StructLayout(LayoutKind.Sequential)] -internal sealed unsafe class InteropLogger(void* callerState, InteropValueCallback> logCallback, string categoryName) : ILogger +internal sealed unsafe class InteropLogger( + nint callerState, + delegate* unmanaged[Cdecl], void> logCallback, + string categoryName) : ILogger { - private readonly void* _callerState = callerState; - private readonly InteropValueCallback> _logCallback = logCallback; + private readonly nint _callerState = callerState; + private readonly delegate* unmanaged[Cdecl], void> _logCallback = logCallback; private readonly string _categoryName = categoryName; public IDisposable BeginScope(TState state) @@ -29,7 +30,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except try { - _logCallback.Invoke(_callerState, messageBytes); + _logCallback(_callerState, messageBytes); } finally { diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs index 0e23725f..07d07ceb 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs @@ -1,14 +1,13 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; namespace Proton.Sdk.CExports.Logging; -internal sealed unsafe class InteropLoggerProvider(void* callerState, InteropValueCallback> logCallback) : ILoggerProvider +internal sealed unsafe class InteropLoggerProvider(nint callerState, delegate* unmanaged[Cdecl], void> logCallback) : ILoggerProvider { - private readonly void* _callerState = callerState; - private readonly InteropValueCallback> _logCallback = logCallback; + private readonly nint _callerState = callerState; + private readonly delegate* unmanaged[Cdecl], void> _logCallback = logCallback; public ILogger CreateLogger(string categoryName) { @@ -20,33 +19,10 @@ public void Dispose() // Nothing to do } - internal static bool TryGetFromHandle(nint handle, [MaybeNullWhen(false)] out InteropLoggerProvider session) + public static IMessage HandleCreate(LoggerProviderCreate request, nint callerState) { - if (handle == 0) - { - session = null; - return false; - } + var provider = new InteropLoggerProvider(callerState, (delegate* unmanaged[Cdecl], void>)request.LogCallback); - var gcHandle = GCHandle.FromIntPtr(handle); - - session = gcHandle.Target as InteropLoggerProvider; - - return session is not null; - } - - [UnmanagedCallersOnly(EntryPoint = "logger_provider_create", CallConvs = [typeof(CallConvCdecl)])] - private static int InitializeLoggerProvider(void* callerState, InteropValueCallback> logCallback, nint* loggerProviderHandle) - { - try - { - var provider = new InteropLoggerProvider(callerState, logCallback); - *loggerProviderHandle = GCHandle.ToIntPtr(GCHandle.Alloc(provider)); - return 0; - } - catch - { - return -1; - } + return new Int64Value { Value = Interop.AllocHandle(provider) }; } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj index 273ff58e..7107e650 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj @@ -17,7 +17,7 @@ - + diff --git a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs new file mode 100644 index 00000000..96808d78 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs @@ -0,0 +1,200 @@ +using System.Text; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; +using Proton.Sdk.Authentication; +using Proton.Sdk.Caching; +using Proton.Sdk.CExports.Logging; + +namespace Proton.Sdk.CExports; + +internal static class ProtonApiSessionRequestHandler +{ + public static async ValueTask HandleBeginAsync(SessionBeginRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + ILoggerFactory? loggerFactory = null; + + if (request.Options.HasLoggerProviderHandle) + { + var loggerProvider = Interop.GetFromHandle(request.Options.LoggerProviderHandle); + loggerFactory = new LoggerFactory([loggerProvider]); + } + + var secretCacheRepository = request.HasSecretCachePath + ? SqliteCacheRepository.OpenFile(request.SecretCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var entityCacheRepository = request.Options.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var options = new ProtonSessionOptions + { + BaseUrl = new Uri(request.Options.BaseUrl), + UserAgent = request.Options.UserAgent, + BindingsLanguage = request.Options.BindingsLanguage, + LoggerFactory = loggerFactory, + TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, + EntityCacheRepository = entityCacheRepository, + SecretCacheRepository = secretCacheRepository, + }; + + var session = await ProtonApiSession.BeginAsync( + request.Username, + Encoding.UTF8.GetBytes(request.Password), + request.AppVersion, + options, + cancellationToken).ConfigureAwait(false); + + return new Int64Value { Value = Interop.AllocHandle(session) }; + } + + public static IMessage HandleResume(SessionResumeRequest request) + { + ILoggerFactory? loggerFactory = null; + + if (request.Options.HasLoggerProviderHandle) + { + var loggerProvider = Interop.GetFromHandle(request.Options.LoggerProviderHandle); + loggerFactory = new LoggerFactory([loggerProvider]); + } + + var secretCacheRepository = SqliteCacheRepository.OpenFile(request.SecretCachePath); + + var entityCacheRepository = request.Options.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var options = new Proton.Sdk.ProtonClientOptions + { + BaseUrl = new Uri(request.Options.BaseUrl), + UserAgent = request.Options.UserAgent, + BindingsLanguage = request.Options.BindingsLanguage, + LoggerFactory = loggerFactory, + TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, + EntityCacheRepository = entityCacheRepository, + SecretCacheRepository = secretCacheRepository, + }; + + var passwordMode = request.IsWaitingForDataPassword ? PasswordMode.Dual : PasswordMode.Single; + + var session = ProtonApiSession.Resume( + new Authentication.SessionId(request.SessionId.Value), + request.Username, + new Users.UserId(request.UserId.Value), + request.AccessToken, + request.RefreshToken, + request.Scopes, + request.IsWaitingForSecondFactorCode, + passwordMode, + request.AppVersion, + secretCacheRepository, + options); + + return new Int64Value { Value = Interop.AllocHandle(session) }; + } + + public static IMessage HandleRenew(SessionRenewRequest request) + { + var expiredSession = Interop.GetFromHandle((nint)request.OldSessionHandle); + + var passwordMode = request.IsWaitingForDataPassword ? PasswordMode.Dual : PasswordMode.Single; + + var session = ProtonApiSession.Renew( + expiredSession, + new Authentication.SessionId(request.SessionId.Value), + request.AccessToken, + request.RefreshToken, + request.Scopes, + request.IsWaitingForSecondFactorCode, + passwordMode); + + return new Int64Value { Value = Interop.AllocHandle(session) }; + } + + public static async ValueTask HandleEndAsync(SessionEndRequest request) + { + var session = Interop.GetFromHandle((nint)request.SessionHandle); + + await session.EndAsync().ConfigureAwait(false); + + return null; + } + + public static unsafe IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefreshedSubscribeRequest request, nint callerState) + { + var session = Interop.GetFromHandle((nint)request.SessionHandle); + + var tokenRefreshedCallback = (delegate* unmanaged[Cdecl], void>)request.TokensRefreshedCallback; + + var subscription = TokensRefreshedSubscription.Create(session, callerState, tokenRefreshedCallback); + + return new Int64Value { Value = Interop.AllocHandle(subscription) }; + } + + public static IMessage? HandleUnsubscribeFromTokensRefreshed(SessionTokensRefreshedUnsubscribeRequest request) + { + var subscription = Interop.GetFromHandle((nint)request.SubscriptionHandle); + + subscription.Dispose(); + + return null; + } + + public static IMessage? HandleFree(SessionFreeRequest request) + { + Interop.FreeHandle(request.SessionHandle); + + return null; + } + + private sealed unsafe class TokensRefreshedSubscription : IDisposable + { + private readonly ProtonApiSession _session; + private readonly nint _callerState; + private readonly delegate* unmanaged[Cdecl], void> _tokensRefreshedCallback; + + private TokensRefreshedSubscription( + ProtonApiSession session, + nint callerState, + delegate* unmanaged[Cdecl], void> tokensRefreshedCallback) + { + _session = session; + _callerState = callerState; + _tokensRefreshedCallback = tokensRefreshedCallback; + } + + public static TokensRefreshedSubscription Create( + ProtonApiSession session, + nint callerState, + delegate* unmanaged[Cdecl], void> tokensRefreshedCallback) + { + var subscription = new TokensRefreshedSubscription(session, callerState, tokensRefreshedCallback); + + session.TokenCredential.TokensRefreshed += subscription.Handle; + + return subscription; + } + + public void Dispose() + { + _session.TokenCredential.TokensRefreshed -= Handle; + } + + private void Handle(string accessToken, string refreshToken) + { + var tokensMessage = InteropArray.FromMemory(new SessionTokens { AccessToken = accessToken, RefreshToken = refreshToken }.ToByteArray()); + + try + { + _tokensRefreshedCallback(_callerState, tokensMessage); + } + finally + { + tokensMessage.Free(); + } + } + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpBodyLoggingHandler.cs b/cs/sdk/src/Proton.Sdk/Http/HttpBodyLoggingHandler.cs new file mode 100644 index 00000000..bf3cba89 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpBodyLoggingHandler.cs @@ -0,0 +1,76 @@ +using System.Net.Mime; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Proton.Sdk.Http; + +internal sealed partial class HttpBodyLoggingHandler(ILogger logger) : DelegatingHandler +{ +#if WINDOWS + private const string NewLine = "\r\n"; +#else + private const string NewLine = "\n"; +#endif + + private readonly ILogger _logger = logger; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _logger.IsEnabled(LogLevel.Trace) + ? SendWithBodyLoggingAsync(request, cancellationToken) + : base.SendAsync(request, cancellationToken); + } + + [GeneratedRegex( + """ + ("(AccessToken|RefreshToken)"\s*:\s*")([A-Za-z0-9]+)("\s*) + """, RegexOptions.IgnoreCase)] + private static partial Regex AuthenticationTokensRegex(); + + private static string Indent(string json) + { + var jsonNode = JsonNode.Parse(json); + + return jsonNode?.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) ?? json; + } + + private static async ValueTask TryGetContentAsString(HttpContent? content, CancellationToken cancellationToken) + { + if (content is not { Headers.ContentType.MediaType: { } mediaType } + || (mediaType is not MediaTypeNames.Application.Json + && !mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))) + { + return null; + } + + var contentString = await content.ReadAsStringAsync(cancellationToken); + + return mediaType is MediaTypeNames.Application.Json ? Indent(contentString) : contentString; + } + + private async Task SendWithBodyLoggingAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var requestContentString = await TryGetContentAsString(request.Content, cancellationToken); + if (requestContentString is not null) + { + _logger.LogInformation($"Request body:{NewLine}{{Body}}", requestContentString); + } + + var response = await base.SendAsync(request, cancellationToken); + + var responseContentString = await TryGetContentAsString(response.Content, cancellationToken); + if (responseContentString is not null) + { + if (request.RequestUri?.PathAndQuery.Contains("auth/", StringComparison.OrdinalIgnoreCase) == true) + { + responseContentString = AuthenticationTokensRegex().Replace(responseContentString, "$1*$4"); + } + + _logger.LogInformation($"Response body:{NewLine}{{Body}}", responseContentString); + } + + return response; + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index cb74555f..272cf718 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -2,6 +2,7 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Logging; using Polly; using Proton.Sdk.Authentication; using Proton.Sdk.Http; @@ -58,6 +59,10 @@ public static HttpClient GetHttpClient( builder.AddHttpMessageHandler(() => config.CustomHttpMessageHandlerFactory.Invoke()); } +#if DEBUG + builder.AddHttpMessageHandler(() => new HttpBodyLoggingHandler(config.LoggerFactory.CreateLogger())); +#endif + builder.AddHttpMessageHandler(() => new CryptographyTimeProvisionHandler()); builder.AddStandardResilienceHandler( diff --git a/cs/sdk/src/protos/account.proto b/cs/sdk/src/protos/account.proto deleted file mode 100644 index 5b47615c..00000000 --- a/cs/sdk/src/protos/account.proto +++ /dev/null @@ -1,136 +0,0 @@ -syntax = "proto3"; - -option csharp_namespace = "Proton.Sdk.CExports"; - -message LogEvent { - int32 level = 1; - string message = 2; - string category_name = 3; -} - -enum ProtonClientTlsPolicy { - PROTON_CLIENT_TLS_POLICY_STRICT = 0; - PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_PINNING = 1; - PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_VALIDATION = 2; -} - -enum AddressStatus { - ADDRESS_STATUS_DISABLED = 0; - ADDRESS_STATUS_ENABLED = 1; - ADDRESS_STATUS_DELETING = 2; -} - -enum DelinquentState { - DELINQUENT_STATE_PAID = 0; - DELINQUENT_STATE_AVAILABLE = 1; - DELINQUENT_STATE_OVERDUE = 2; - DELINQUENT_STATE_DELINQUENT = 3; - DELINQUENT_STATE_NOT_RECEIVED = 4; -} - -enum UserType { - USER_TYPE_UNKNOWN = 0; - USER_TYPE_PROTON = 1; - USER_TYPE_MANAGED = 2; - USER_TYPE_EXTERNAL = 3; -} - -message ProtonClientOptions { - optional string base_url = 1; - optional string user_agent = 2; - optional string bindings_language = 3; - optional ProtonClientTlsPolicy tls_policy = 4; - optional int64 logger_provider_handle = 5; - optional string entity_cache_path = 6; -} - -message SessionBeginRequest { - string username = 1; - string password = 2; - string app_version = 3; - optional string secret_cache_path = 5; - optional ProtonClientOptions options = 4; -} - -message SessionResumeRequest { - string username = 1; - string app_version = 2; - SessionId session_id = 3; - UserId user_id = 4; - string access_token = 5; - string refresh_token = 6; - repeated string scopes = 7; - bool is_waiting_for_second_factor_code = 8; - bool is_waiting_for_data_password = 9; - string secret_cache_path = 10; - ProtonClientOptions options = 11; -} - -message SessionRenewRequest { - SessionId session_id = 1; - string access_token = 2; - string refresh_token = 3; - repeated string scopes = 4; - bool is_waiting_for_second_factor_code = 5; - bool is_waiting_for_data_password = 6; -} - -message SessionEndRequest { - int64 session_handle = 1; -} - -message SessionTokens { - string access_token = 1; - string refresh_token = 2; -} - -enum ErrorDomain { - Undefined = 0; - SuccessfulCancellation = 1; - Api = 2; - Network = 3; - Transport = 4; - Serialization = 5; - Cryptography = 6; - DataIntegrity = 7; -} - -message Error { - string type = 1; - string message = 2; - ErrorDomain domain = 3; - optional int64 primary_code = 4; - optional int64 secondary_code = 5; - optional string context = 6; - optional Error inner_error = 7; -} - -message StringResponse { - string value = 1; -} - -message SessionId { - string value = 1; -} - -message UserId { - string value = 1; -} - -message UserKeyId { - string value = 1; -} - -message AddressId { - string value = 1; -} - -message AddressKeyId { - string value = 1; -} - -message AddressKey { - AddressId address_id = 1; - AddressKeyId address_key_id = 2; - bool is_allowed_for_encryption = 3; -} diff --git a/cs/sdk/src/protos/drive.proto b/cs/sdk/src/protos/drive.proto deleted file mode 100644 index 7fe69e6a..00000000 --- a/cs/sdk/src/protos/drive.proto +++ /dev/null @@ -1,27 +0,0 @@ -syntax = "proto3"; - -option csharp_namespace = "Proton.Sdk.Drive.CExports"; - -message FileUploaderProvisionRequest { - string parent_folder_uid = 1; - string name = 2; - string mediaType = 3; - int64 file_size = 4; - int64 last_modification_date = 5; - bool create_new_revision_if_exists = 6; -} - -message FileRevisionUploaderProvisionRequest { - string current_active_revision_uid = 1; - int64 file_size = 2; - int64 last_modification_date = 3; -} - -message ProgressUpdate { - int64 bytes_completed = 1; - int64 bytes_in_total = 2; -} - -message FileDownloaderProvisionRequest { - string revision_uid = 1; -} diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto new file mode 100644 index 00000000..db3c8e5d --- /dev/null +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -0,0 +1,188 @@ +edition = "2023"; +package proton.sdk.drive; + +option features.utf8_validation = NONE; +option csharp_namespace = "Proton.Sdk.Drive.CExports"; + +import "proton.sdk.proto"; + +message Request { + oneof payload { + DriveClientCreateRequest DriveClientCreate = 100; + DriveClientFreeRequest DriveClientFree = 101; + DriveClientGetFileUploaderRequest DriveClientGetFileUploader = 102; + DriveClientGetFileRevisionUploaderRequest DriveClientGetFileRevisionUploader = 103; + DriveClientGetFileDownloaderRequest DriveClientGetFileDownloader = 104; + + UploadFromStreamRequest UploadFromStream = 200; + FileUploaderFreeRequest FileUploaderFree = 201; + UploadControllerAwaitCompletionRequest UploadControllerAwaitCompletion = 202; + UploadControllerPauseRequest UploadControllerPause = 203; + UploadControllerResumeRequest UploadControllerResume = 204; + UploadControllerFreeRequest UploadControllerFree = 205; + + DownloadToStreamRequest DownloadToStream = 300; + FileDownloaderFreeRequest FileDownloaderFree = 301; + DownloadControllerAwaitCompletionRequest DownloadControllerAwaitCompletion = 302; + DownloadControllerPauseRequest DownloadControllerPause = 303; + DownloadControllerResumeRequest DownloadControllerResume = 304; + DownloadControllerFreeRequest DownloadControllerFree = 305; + }; +} + +message CallbackResponse { + oneof payload { + StreamReadResponse StreamRead = 100; + StreamWriteResponse StreamWrite = 101; + }; +} + +message RevisionUploaderProvisionRequest { + string file_uid = 1; + int64 file_size = 2; + int64 last_modification_date = 3; +} + +message ProgressUpdate { + int64 bytes_completed = 1; + int64 bytes_in_total = 2; +} + +message Thumbnail { + int32 type = 1; + int64 content_pointer = 2; +} + +// Drive - client + +// The response value will be an Int64Value message. +message DriveClientCreateRequest { + int64 session_handle = 1; +} + +// No response value. +message DriveClientFreeRequest { + int64 client_handle = 1; +} + +// Drive - uploads + +// The response value will be an Int64Value message. +message DriveClientGetFileUploaderRequest { + int64 client_handle = 1; + string parent_folder_uid = 2; + string name = 3; + string mediaType = 4; + int64 size = 5; + int64 last_modification_time = 6; + bool override_existing_draft_by_other_client = 7; + int64 cancellation_token_source_handle = 8; +} + +// The response value will be an Int64Value message. +message DriveClientGetFileRevisionUploaderRequest { + int64 client_handle = 1; + string current_active_revision_uid = 2; + int64 file_size = 3; + int64 last_modification_time = 4; + int64 cancellation_token_source_handle = 5; +} + +// The response value will be an Int64Value message. +message UploadFromStreamRequest { + int64 uploader_handle = 1; + repeated Thumbnail thumbnails = 2; + int64 read_callback = 3; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); + int64 progress_callback = 4; // See array_callback_function in C header file for signature + int64 cancellation_token_source_handle = 5; +} + +// No response value. +message FileUploaderFreeRequest { + int64 file_uploader_handle = 1; +} + +// The response value will be a Node message. +message UploadControllerAwaitCompletionRequest { + int64 upload_controller_handle = 1; +} + +// No response value. +message UploadControllerPauseRequest { + int64 upload_controller_handle = 1; +} + +// No response value. +message UploadControllerResumeRequest { + int64 upload_controller_handle = 1; +} + +// No response value. +message UploadControllerFreeRequest { + int64 upload_controller_handle = 1; +} + +// Drive - downloads + +// The response value will be an Int64Value message. +message DriveClientGetFileDownloaderRequest { + int64 client_handle = 1; + string revision_uid = 2; + int64 cancellation_token_source_handle = 3; +} + +// The response value will be an Int64Value message. +message DownloadToStreamRequest { + int64 downloader_handle = 1; + int64 write_callback = 2; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); + int64 progress_callback = 3; // See array_callback_function in C header file for signature + int64 cancellation_token_source_handle = 4; +} + +// No response value. +message FileDownloaderFreeRequest { + int64 file_downloader_handle = 1; +} + +// No response value. +message DownloadControllerAwaitCompletionRequest { + int64 download_controller_handle = 1; +} + +// No response value. +message DownloadControllerPauseRequest { + int64 download_controller_handle = 1; +} + +// No response value. +message DownloadControllerResumeRequest { + int64 download_controller_handle = 1; +} + +// No response value. +message DownloadControllerFreeRequest { + int64 download_controller_handle = 1; +} + +// The response must be a StreamReadResponse message. +message StreamReadRequest { + int64 buffer_pointer = 1; + int32 buffer_length = 2; +} + +// The response must be a StreamWriteResponse message. +message StreamWriteRequest { + int64 data_pointer = 1; + int32 data_length = 2; +} + +message StreamReadResponse { + oneof result { + int32 BytesRead = 1; + proton.sdk.Error Error = 2; + } +} + +message StreamWriteResponse { + proton.sdk.Error error = 1; // Optional +} diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto new file mode 100644 index 00000000..87a2654b --- /dev/null +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -0,0 +1,209 @@ +edition = "2023"; +package proton.sdk; + +import "google/protobuf/any.proto"; + +option features.utf8_validation = NONE; +option csharp_namespace = "Proton.Sdk.CExports"; + +message Request { + oneof payload { + CancellationTokenSourceCreateRequest cancellationTokenSourceCreate = 100; + CancellationTokenSourceCancelRequest cancellationTokenSourceCancel = 101; + CancellationTokenSourceFreeRequest cancellationTokenSourceFree = 102; + + SessionBeginRequest sessionBegin = 200; + SessionResumeRequest sessionResume = 201; + SessionRenewRequest sessionRenew = 202; + SessionEndRequest sessionEnd = 203; + SessionTokensRefreshedSubscribeRequest sessionTokensRefreshedSubscribe = 204; + SessionTokensRefreshedUnsubscribeRequest sessionTokensRefreshedUnsubscribe = 205; + SessionFreeRequest sessionFree = 206; + + LoggerProviderCreate loggerProviderCreate = 300; + }; +} + +message Response { + oneof result { // Optional, if no return value and no error + google.protobuf.Any value = 1; + Error error = 2; + } +} + +// Cancellation tokens + +// The response value will be an Int64Value message. +message CancellationTokenSourceCreateRequest {} + +// No response value. +message CancellationTokenSourceCancelRequest { + int64 cancellation_token_source_handle = 1; +} + +// No response value. +message CancellationTokenSourceFreeRequest { + int64 cancellation_token_source_handle = 1; +} + +// Sessions + +// The response value will be an Int64Value message. +message SessionBeginRequest { + string username = 1; + string password = 2; + string app_version = 3; + string secret_cache_path = 4; // Optional + ProtonClientOptions options = 5; // Optional + int64 cancellation_token_source_handle = 6; +} + +// The response value will be an Int64Value message. +message SessionResumeRequest { + string username = 1; + string app_version = 2; + SessionId session_id = 3; + UserId user_id = 4; + string access_token = 5; + string refresh_token = 6; + repeated string scopes = 7; + bool is_waiting_for_second_factor_code = 8; + bool is_waiting_for_data_password = 9; + string secret_cache_path = 10; + ProtonClientOptions options = 11; +} + +// The response value will be an Int64Value message. +message SessionRenewRequest { + int64 old_session_handle = 1; + SessionId session_id = 2; + string access_token = 3; + string refresh_token = 4; + repeated string scopes = 5; + bool is_waiting_for_second_factor_code = 6; + bool is_waiting_for_data_password = 7; +} + +// No response value. +message SessionEndRequest { + int64 session_handle = 1; +} + +// The response value will be an Int64Value message. +message SessionTokensRefreshedSubscribeRequest { + int64 session_handle = 1; + int64 tokens_refreshed_callback = 2; +} + +// No response value. +message SessionTokensRefreshedUnsubscribeRequest { + int64 subscription_handle = 1; +} + +// No response value. +message SessionFreeRequest { + int64 session_handle = 1; +} + +// The response value will be an Int64Value message. +message LoggerProviderCreate { + int64 log_callback = 1; +} + +message LogEvent { + int32 level = 1; + string message = 2; + string category_name = 3; +} + +enum ProtonClientTlsPolicy { + PROTON_CLIENT_TLS_POLICY_STRICT = 0; + PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_PINNING = 1; + PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_VALIDATION = 2; +} + +enum AddressStatus { + ADDRESS_STATUS_DISABLED = 0; + ADDRESS_STATUS_ENABLED = 1; + ADDRESS_STATUS_DELETING = 2; +} + +enum DelinquentState { + DELINQUENT_STATE_PAID = 0; + DELINQUENT_STATE_AVAILABLE = 1; + DELINQUENT_STATE_OVERDUE = 2; + DELINQUENT_STATE_DELINQUENT = 3; + DELINQUENT_STATE_NOT_RECEIVED = 4; +} + +enum UserType { + USER_TYPE_UNKNOWN = 0; + USER_TYPE_PROTON = 1; + USER_TYPE_MANAGED = 2; + USER_TYPE_EXTERNAL = 3; +} + +message ProtonClientOptions { + string base_url = 1; // Optional + string user_agent = 2; // Optional + string bindings_language = 3; // Optional + ProtonClientTlsPolicy tls_policy = 4; // Optional + int64 logger_provider_handle = 5; // Optional + string entity_cache_path = 6; // Optional +} + +message SessionTokens { + string access_token = 1; + string refresh_token = 2; +} + +enum ErrorDomain { + Undefined = 0; + SuccessfulCancellation = 1; + Api = 2; + Network = 3; + Transport = 4; + Serialization = 5; + Cryptography = 6; + DataIntegrity = 7; +} + +message Error { + string type = 1; + string message = 2; + ErrorDomain domain = 3; + int64 primary_code = 4; // Optional + int64 secondary_code = 5; // Optional + string context = 6; // Optional + Error inner_error = 7; // Optional +} + +message StringResponse { + string value = 1; +} + +message SessionId { + string value = 1; +} + +message UserId { + string value = 1; +} + +message UserKeyId { + string value = 1; +} + +message AddressId { + string value = 1; +} + +message AddressKeyId { + string value = 1; +} + +message AddressKey { + AddressId address_id = 1; + AddressKeyId address_key_id = 2; + bool is_allowed_for_encryption = 3; +} From c9b1f1dbc6eb3f6677f9aa04d98f800f9ac39513 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 22 Sep 2025 15:11:18 +0200 Subject: [PATCH 223/791] js/v0.4.0 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 4576e8fc..fac595dd 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.3.2", + "version": "0.4.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 4fca37ed55b00ce5b80fc4ff4d7f7867d6aaedb4 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Sep 2025 12:37:50 +0000 Subject: [PATCH 224/791] Implement photo upload --- .../internal/nodes/extendedAttributes.test.ts | 24 +- .../src/internal/nodes/extendedAttributes.ts | 44 ++-- js/sdk/src/internal/photos/index.ts | 62 ++++++ js/sdk/src/internal/photos/upload.ts | 209 ++++++++++++++++++ js/sdk/src/internal/upload/apiService.ts | 6 +- js/sdk/src/internal/upload/cryptoService.ts | 4 +- js/sdk/src/internal/upload/fileUploader.ts | 12 + js/sdk/src/internal/upload/interface.ts | 3 + js/sdk/src/internal/upload/manager.test.ts | 8 + js/sdk/src/internal/upload/manager.ts | 30 ++- .../internal/upload/streamUploader.test.ts | 29 ++- js/sdk/src/internal/upload/streamUploader.ts | 62 +++--- js/sdk/src/protonDrivePhotosClient.ts | 18 +- 13 files changed, 433 insertions(+), 78 deletions(-) create mode 100644 js/sdk/src/internal/photos/upload.ts diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index 05a7b064..6936ab7c 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -50,7 +50,7 @@ describe('extended attrbiutes', () => { }); }); - describe('should generate file attributes', () => { + describe('should generate file attributes without additional metadata', () => { const testCases: [object, string | undefined][] = [ [{}, undefined], [ @@ -82,6 +82,28 @@ describe('extended attrbiutes', () => { }); }); + describe('should generate file attributes with additional metadata', () => { + const testCases: [object, string | undefined][] = [ + [{}, '{"Media":{"Width":100,"Height":100}}'], + [{ size: undefined }, '{"Media":{"Width":100,"Height":100}}'], + [{ size: 123 }, '{"Common":{"Size":123},"Media":{"Width":100,"Height":100}}'], + ]; + testCases.forEach(([input, expectedAttributes]) => { + it(`should generate ${input}`, () => { + const output = generateFileExtendedAttributes(input, { Media: { Width: 100, Height: 100 } }); + expect(output).toBe(expectedAttributes); + }); + }); + }); + + describe('should throw an error if additional metadata contains common attributes', () => { + it('should throw an error', () => { + expect(() => generateFileExtendedAttributes({ size: 123 }, { Common: { Hello: 'World' } })).toThrow( + 'Common attributes are not allowed in additional metadata', + ); + }); + }); + describe('should parses file attributes', () => { const testCases: [Date, string, FileExtendedAttributesParsed][] = [ [new Date('2025-01-01'), '', {}], diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index 6f52bfd8..c8c9cc92 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -83,34 +83,42 @@ export function parseFolderExtendedAttributes(logger: Logger, extendedAttributes } } -export function generateFileExtendedAttributes(options: { - modificationTime?: Date; - size?: number; - blockSizes?: number[]; - digests?: { - sha1?: string; - }; -}): string | undefined { +export function generateFileExtendedAttributes( + common: { + modificationTime?: Date; + size?: number; + blockSizes?: number[]; + digests?: { + sha1?: string; + }; + }, + additionalMetadata?: object, +): string | undefined { + if (additionalMetadata && 'Common' in additionalMetadata) { + throw new Error('Common attributes are not allowed in additional metadata'); + } + const commonAttributes: FileExtendedAttributesSchema['Common'] = {}; - if (options.modificationTime) { - commonAttributes.ModificationTime = dateToIsoString(options.modificationTime); + if (common.modificationTime) { + commonAttributes.ModificationTime = dateToIsoString(common.modificationTime); } - if (options.size !== undefined) { - commonAttributes.Size = options.size; + if (common.size !== undefined) { + commonAttributes.Size = common.size; } - if (options.blockSizes?.length) { - commonAttributes.BlockSizes = options.blockSizes; + if (common.blockSizes?.length) { + commonAttributes.BlockSizes = common.blockSizes; } - if (options.digests?.sha1) { + if (common.digests?.sha1) { commonAttributes.Digests = { - SHA1: options.digests.sha1, + SHA1: common.digests.sha1, }; } - if (!Object.keys(commonAttributes).length) { + if (!Object.keys(commonAttributes).length && !additionalMetadata) { return undefined; } return JSON.stringify({ - Common: commonAttributes, + ...(Object.keys(commonAttributes).length ? { Common: commonAttributes } : {}), + ...(additionalMetadata ? { ...additionalMetadata } : {}), }); } diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 718a64d2..d64874fa 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -9,11 +9,21 @@ import { import { SharesCache } from '../shares/cache'; import { SharesCryptoCache } from '../shares/cryptoCache'; import { SharesCryptoService } from '../shares/cryptoService'; +import { NodesService as UploadNodesService } from '../upload/interface'; +import { UploadTelemetry } from '../upload/telemetry'; +import { UploadQueue } from '../upload/queue'; import { Albums } from './albums'; import { PhotosAPIService } from './apiService'; import { NodesService, SharesService } from './interface'; import { PhotoSharesManager } from './shares'; import { PhotosTimeline } from './timeline'; +import { + PhotoFileUploader, + PhotoUploadAPIService, + PhotoUploadCryptoService, + PhotoUploadManager, + PhotoUploadMetadata, +} from './upload'; /** * Provides facade for the whole photos module. @@ -66,3 +76,55 @@ export function initPhotoSharesModule( sharesService, ); } + +/** + * Provides facade for the photo upload module. + * + * The photo upload wraps the core upload module and adds photo specific metadata. + * It provides the same interface so it can be used in the same way. + */ +export function initPhotoUploadModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveCrypto: DriveCrypto, + sharesService: SharesService, + nodesService: UploadNodesService, + clientUid?: string, +) { + const api = new PhotoUploadAPIService(apiService, clientUid); + const cryptoService = new PhotoUploadCryptoService(driveCrypto, nodesService); + + const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); + const manager = new PhotoUploadManager(telemetry, api, cryptoService, nodesService, clientUid); + + const queue = new UploadQueue(); + + async function getFileUploader( + parentFolderUid: string, + name: string, + metadata: PhotoUploadMetadata, + signal?: AbortSignal, + ): Promise { + await queue.waitForCapacity(signal); + + const onFinish = () => { + queue.releaseCapacity(); + }; + + return new PhotoFileUploader( + uploadTelemetry, + api, + cryptoService, + manager, + parentFolderUid, + name, + metadata, + onFinish, + signal, + ); + } + + return { + getFileUploader, + }; +} diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts new file mode 100644 index 00000000..f41e580b --- /dev/null +++ b/js/sdk/src/internal/photos/upload.ts @@ -0,0 +1,209 @@ +import { DriveCrypto } from '../../crypto'; +import { ProtonDriveTelemetry, UploadMetadata, Thumbnail } from '../../interface'; +import { DriveAPIService, drivePaths } from '../apiService'; +import { generateFileExtendedAttributes } from '../nodes'; +import { splitNodeRevisionUid } from '../uids'; +import { UploadAPIService } from '../upload/apiService'; +import { BlockVerifier } from '../upload/blockVerifier'; +import { UploadCryptoService } from '../upload/cryptoService'; +import { FileUploader } from '../upload/fileUploader'; +import { NodeRevisionDraft, NodesService } from '../upload/interface'; +import { UploadManager } from '../upload/manager'; +import { StreamUploader } from '../upload/streamUploader'; +import { UploadTelemetry } from '../upload/telemetry'; + +type PostCommitRevisionRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCommitRevisionResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['put']['responses']['200']['content']['application/json']; + +export type PhotoUploadMetadata = UploadMetadata & { + captureTime?: Date; + mainPhotoLinkID?: string; + // TODO: handle tags enum in the SDK + tags?: (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9)[]; +}; + +export class PhotoFileUploader extends FileUploader { + private photoApiService: PhotoUploadAPIService; + private photoManager: PhotoUploadManager; + private photoMetadata: PhotoUploadMetadata; + + constructor( + telemetry: UploadTelemetry, + apiService: PhotoUploadAPIService, + cryptoService: UploadCryptoService, + manager: PhotoUploadManager, + parentFolderUid: string, + name: string, + metadata: PhotoUploadMetadata, + onFinish: () => void, + signal?: AbortSignal, + ) { + super(telemetry, apiService, cryptoService, manager, parentFolderUid, name, metadata, onFinish, signal); + this.photoApiService = apiService; + this.photoManager = manager; + this.photoMetadata = metadata; + } + + protected async newStreamUploader( + blockVerifier: BlockVerifier, + revisionDraft: NodeRevisionDraft, + onFinish: (failure: boolean) => Promise, + ): Promise { + return new PhotoStreamUploader( + this.telemetry, + this.photoApiService, + this.cryptoService, + this.photoManager, + blockVerifier, + revisionDraft, + this.photoMetadata, + onFinish, + this.signal, + ); + } +} + +export class PhotoStreamUploader extends StreamUploader { + private photoUploadManager: PhotoUploadManager; + private photoMetadata: PhotoUploadMetadata; + + constructor( + telemetry: UploadTelemetry, + apiService: PhotoUploadAPIService, + cryptoService: UploadCryptoService, + uploadManager: PhotoUploadManager, + blockVerifier: BlockVerifier, + revisionDraft: NodeRevisionDraft, + metadata: PhotoUploadMetadata, + onFinish: (failure: boolean) => Promise, + signal?: AbortSignal, + ) { + super(telemetry, apiService, cryptoService, uploadManager, blockVerifier, revisionDraft, metadata, onFinish, signal); + this.photoUploadManager = uploadManager; + this.photoMetadata = metadata; + } + + async commitFile(thumbnails: Thumbnail[]) { + this.verifyIntegrity(thumbnails); + + const extendedAttributes = { + modificationTime: this.metadata.modificationTime, + size: this.metadata.expectedSize, + blockSizes: this.uploadedBlockSizes, + digests: this.digests.digests(), + }; + + await this.photoUploadManager.commitDraftPhoto(this.revisionDraft, this.manifest, extendedAttributes, this.photoMetadata); + } +} + +export class PhotoUploadManager extends UploadManager { + private photoApiService: PhotoUploadAPIService; + private photoCryptoService: PhotoUploadCryptoService; + + constructor( + telemetry: ProtonDriveTelemetry, + apiService: PhotoUploadAPIService, + cryptoService: PhotoUploadCryptoService, + nodesService: NodesService, + clientUid: string | undefined, + ) { + super(telemetry, apiService, cryptoService, nodesService, clientUid); + this.photoApiService = apiService; + this.photoCryptoService = cryptoService; + } + + async commitDraftPhoto( + nodeRevisionDraft: NodeRevisionDraft, + manifest: Uint8Array, + extendedAttributes: { + modificationTime?: Date; + size: number; + blockSizes: number[]; + digests: { + sha1: string; + }; + }, + uploadMetadata: PhotoUploadMetadata, + ): Promise { + if (!nodeRevisionDraft.parentNodeKeys) { + throw new Error('Parent node keys are required for photo upload'); + } + + // TODO: handle photo extended attributes in the SDK - now it must be passed from the client + const generatedExtendedAttributes = generateFileExtendedAttributes(extendedAttributes, uploadMetadata.additionalMetadata); + const nodeCommitCrypto = await this.cryptoService.commitFile( + nodeRevisionDraft.nodeKeys, + manifest, + generatedExtendedAttributes, + ); + + const sha1 = extendedAttributes.digests.sha1; + const contentHash = await this.photoCryptoService.generateContentHash(sha1, nodeRevisionDraft.parentNodeKeys?.hashKey); + const photo = { + contentHash, + captureTime: uploadMetadata.captureTime || extendedAttributes.modificationTime, + mainPhotoLinkID: uploadMetadata.mainPhotoLinkID, + tags: uploadMetadata.tags, + } + await this.photoApiService.commitDraftPhoto(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto, photo); + await this.notifyNodeUploaded(nodeRevisionDraft); + } +} + +export class PhotoUploadCryptoService extends UploadCryptoService { + constructor( + driveCrypto: DriveCrypto, + nodesService: NodesService, + ) { + super(driveCrypto, nodesService); + } + + async generateContentHash(sha1: string, parentHashKey: Uint8Array): Promise { + return this.driveCrypto.generateLookupHash(sha1, parentHashKey); + } +} + +export class PhotoUploadAPIService extends UploadAPIService { + constructor(apiService: DriveAPIService, clientUid: string | undefined) { + super(apiService, clientUid); + } + + async commitDraftPhoto( + draftNodeRevisionUid: string, + options: { + armoredManifestSignature: string; + signatureEmail: string; + armoredExtendedAttributes?: string; + }, + photo: { + contentHash: string; + captureTime?: Date; + mainPhotoLinkID?: string; + // TODO: handle tags enum in the SDK + tags?: (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9)[]; + }, + ): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + await this.apiService.put< + // TODO: Deprected fields but not properly marked in the types. + Omit, + PostCommitRevisionResponse + >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, { + ManifestSignature: options.armoredManifestSignature, + SignatureAddress: options.signatureEmail, + XAttr: options.armoredExtendedAttributes || null, + Photo: { + ContentHash: photo.contentHash, + CaptureTime: photo.captureTime?.getTime() || 0, + MainPhotoLinkID: photo.mainPhotoLinkID || null, + Tags: photo.tags || [], + Exif: null, // Deprecated field, not used. + }, + }); + } +} diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index e77ff1e8..32f6a623 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -53,8 +53,8 @@ type PostDeleteNodesResponse = export class UploadAPIService { constructor( - private apiService: DriveAPIService, - private clientUid: string | undefined, + protected apiService: DriveAPIService, + protected clientUid: string | undefined, ) { this.apiService = apiService; this.clientUid = clientUid; @@ -252,7 +252,7 @@ export class UploadAPIService { ManifestSignature: options.armoredManifestSignature, SignatureAddress: options.signatureEmail, XAttr: options.armoredExtendedAttributes || null, - Photo: null, // TODO + Photo: null, // Only used for photos in the Photo volume. }); } diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 4085c2b0..555786c7 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -7,8 +7,8 @@ import { EncryptedBlock, EncryptedThumbnail, NodeCrypto, NodeRevisionDraftKeys, export class UploadCryptoService { constructor( - private driveCrypto: DriveCrypto, - private nodesService: NodesService, + protected driveCrypto: DriveCrypto, + protected nodesService: NodesService, ) { this.driveCrypto = driveCrypto; this.nodesService = nodesService; diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index e86219b7..06a9dce0 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -98,6 +98,18 @@ class Uploader { } }; + return this.newStreamUploader( + blockVerifier, + revisionDraft, + onFinish, + ); + } + + protected async newStreamUploader( + blockVerifier: BlockVerifier, + revisionDraft: NodeRevisionDraft, + onFinish: (failure: boolean) => Promise, + ): Promise { return new StreamUploader( this.telemetry, this.apiService, diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 6318574d..021a2479 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -7,6 +7,9 @@ export type NodeRevisionDraft = { nodeUid: string; nodeRevisionUid: string; nodeKeys: NodeRevisionDraftKeys; + parentNodeKeys?: { + hashKey: Uint8Array; + }; // newNodeInfo is set only when revision is created with the new node. newNodeInfo?: { parentUid: string; diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index faed5121..605ac121 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -122,6 +122,9 @@ describe('UploadManager', () => { email: 'signatureEmail', }, }, + parentNodeKeys: { + hashKey: 'parentNode:hashKey', + }, newNodeInfo: { parentUid: 'parentUid', name: 'name', @@ -172,6 +175,9 @@ describe('UploadManager', () => { email: 'signatureEmail', }, }, + parentNodeKeys: { + hashKey: 'parentNode:hashKey', + }, newNodeInfo: { parentUid: 'volumeId~parentUid', name: 'name', @@ -338,6 +344,8 @@ describe('UploadManager', () => { const manifest = new Uint8Array([1, 2, 3]); const extendedAttributes = { modificationTime: new Date(), + size: 123, + blockSizes: [100, 20, 3], digests: { sha1: 'sha1', }, diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index ada9d99a..4c97cd33 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -15,14 +15,14 @@ import { makeNodeUid, splitNodeUid } from '../uids'; * generating the necessary cryptographic keys and metadata. */ export class UploadManager { - private logger: Logger; + protected logger: Logger; constructor( telemetry: ProtonDriveTelemetry, - private apiService: UploadAPIService, - private cryptoService: UploadCryptoService, - private nodesService: NodesService, - private clientUid: string | undefined, + protected apiService: UploadAPIService, + protected cryptoService: UploadCryptoService, + protected nodesService: NodesService, + protected clientUid: string | undefined, ) { this.logger = telemetry.getLogger('upload'); this.apiService = apiService; @@ -59,6 +59,9 @@ export class UploadManager { contentKeyPacketSessionKey: generatedNodeCrypto.contentKey.decrypted.contentKeyPacketSessionKey, signatureAddress: generatedNodeCrypto.signatureAddress, }, + parentNodeKeys: { + hashKey: parentKeys.hashKey, + }, newNodeInfo: { parentUid: parentFolderUid, name, @@ -260,21 +263,28 @@ export class UploadManager { manifest: Uint8Array, extendedAttributes: { modificationTime?: Date; - size?: number; - blockSizes?: number[]; - digests?: { - sha1?: string; + size: number; + blockSizes: number[]; + digests: { + sha1: string; }; }, + additionalExtendedAttributes?: object, ): Promise { - const generatedExtendedAttributes = generateFileExtendedAttributes(extendedAttributes); + const generatedExtendedAttributes = generateFileExtendedAttributes( + extendedAttributes, + additionalExtendedAttributes, + ); const nodeCommitCrypto = await this.cryptoService.commitFile( nodeRevisionDraft.nodeKeys, manifest, generatedExtendedAttributes, ); await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto); + await this.notifyNodeUploaded(nodeRevisionDraft); + } + protected async notifyNodeUploaded(nodeRevisionDraft: NodeRevisionDraft): Promise { // If new revision to existing node was created, invalidate the node. // Otherwise notify about the new child in the parent. if (nodeRevisionDraft.newNodeInfo) { diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 0b7ea864..5fd84ac8 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -147,19 +147,24 @@ describe('StreamUploader', () => { const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); expect(uploadManager.commitDraft).toHaveBeenCalledTimes(1); - expect(uploadManager.commitDraft).toHaveBeenCalledWith(revisionDraft, expect.anything(), { - size: metadata.expectedSize, - blockSizes: metadata.expectedSize - ? [ - ...Array(numberOfExpectedBlocks - 1).fill(FILE_CHUNK_SIZE), - metadata.expectedSize % FILE_CHUNK_SIZE, - ] - : [], - modificationTime: undefined, - digests: { - sha1: expect.anything(), + expect(uploadManager.commitDraft).toHaveBeenCalledWith( + revisionDraft, + expect.anything(), + { + size: metadata.expectedSize, + blockSizes: metadata.expectedSize + ? [ + ...Array(numberOfExpectedBlocks - 1).fill(FILE_CHUNK_SIZE), + metadata.expectedSize % FILE_CHUNK_SIZE, + ] + : [], + modificationTime: undefined, + digests: { + sha1: expect.anything(), + }, }, - }); + metadata.additionalMetadata, + ); expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); expect(telemetry.uploadFailed).not.toHaveBeenCalled(); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index c0e1d35d..bb09c4e5 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -55,36 +55,36 @@ const MAX_BLOCK_UPLOAD_RETRIES = 3; * that the upload process is efficient and does not overload the server. */ export class StreamUploader { - private logger: Logger; + protected logger: Logger; - private digests: UploadDigests; - private controller: UploadController; - private abortController: AbortController; + protected digests: UploadDigests; + protected controller: UploadController; + protected abortController: AbortController; - private encryptedThumbnails = new Map(); - private encryptedBlocks = new Map(); - private encryptionFinished = false; + protected encryptedThumbnails = new Map(); + protected encryptedBlocks = new Map(); + protected encryptionFinished = false; - private ongoingUploads = new Map< + protected ongoingUploads = new Map< string, { uploadPromise: Promise; encryptedBlock: EncryptedBlock | EncryptedThumbnail; } >(); - private uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = []; - private uploadedBlocks: ({ index: number } & EncryptedBlockMetadata)[] = []; + protected uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = []; + protected uploadedBlocks: ({ index: number } & EncryptedBlockMetadata)[] = []; constructor( - private telemetry: UploadTelemetry, - private apiService: UploadAPIService, - private cryptoService: UploadCryptoService, - private uploadManager: UploadManager, - private blockVerifier: BlockVerifier, - private revisionDraft: NodeRevisionDraft, - private metadata: UploadMetadata, - private onFinish: (failure: boolean) => Promise, - private signal?: AbortSignal, + protected telemetry: UploadTelemetry, + protected apiService: UploadAPIService, + protected cryptoService: UploadCryptoService, + protected uploadManager: UploadManager, + protected blockVerifier: BlockVerifier, + protected revisionDraft: NodeRevisionDraft, + protected metadata: UploadMetadata, + protected onFinish: (failure: boolean) => Promise, + protected signal?: AbortSignal, ) { this.telemetry = telemetry; this.logger = telemetry.getLoggerForRevision(revisionDraft.nodeRevisionUid); @@ -205,19 +205,21 @@ export class StreamUploader { await Promise.all(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); } - private async commitFile(thumbnails: Thumbnail[]) { + protected async commitFile(thumbnails: Thumbnail[]) { this.verifyIntegrity(thumbnails); - const uploadedBlocks = Array.from(this.uploadedBlocks.values()); - uploadedBlocks.sort((a, b) => a.index - b.index); - const extendedAttributes = { modificationTime: this.metadata.modificationTime, size: this.metadata.expectedSize, - blockSizes: uploadedBlocks.map((block) => block.originalSize), + blockSizes: this.uploadedBlockSizes, digests: this.digests.digests(), }; - await this.uploadManager.commitDraft(this.revisionDraft, this.manifest, extendedAttributes); + await this.uploadManager.commitDraft( + this.revisionDraft, + this.manifest, + extendedAttributes, + this.metadata.additionalMetadata, + ); } private async encryptThumbnails(thumbnails: Thumbnail[]) { @@ -514,7 +516,7 @@ export class StreamUploader { await waitForCondition(() => this.encryptedBlocks.size > 0 || this.encryptionFinished); } - private verifyIntegrity(thumbnails: Thumbnail[]) { + protected verifyIntegrity(thumbnails: Thumbnail[]) { const expectedBlockCount = Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0); if (this.uploadedBlockCount !== expectedBlockCount) { @@ -549,7 +551,13 @@ export class StreamUploader { return this.uploadedBlocks.reduce((sum, { originalSize }) => sum + originalSize, 0); } - private get manifest(): Uint8Array { + protected get uploadedBlockSizes(): number[] { + const uploadedBlocks = Array.from(this.uploadedBlocks.values()); + uploadedBlocks.sort((a, b) => a.index - b.index); + return uploadedBlocks.map((block) => block.originalSize); + } + + protected get manifest(): Uint8Array { this.uploadedThumbnails.sort((a, b) => a.type - b.type); this.uploadedBlocks.sort((a, b) => a.index - b.index); const hashes = [ diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index e6c76e15..b60aab20 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -17,11 +17,10 @@ import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; import { initNodesModule } from './internal/nodes'; -import { initPhotoSharesModule, initPhotosModule } from './internal/photos'; +import { initPhotosModule, initPhotoSharesModule, initPhotoUploadModule } from './internal/photos'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; import { initSharingModule } from './internal/sharing'; -import { initUploadModule } from './internal/upload'; /** * ProtonDrivePhotosClient is the interface to access Photos functionality. @@ -39,7 +38,7 @@ export class ProtonDrivePhotosClient { private nodes: ReturnType; private sharing: ReturnType; private download: ReturnType; - private upload: ReturnType; + private upload: ReturnType; private photos: ReturnType; public experimental: { @@ -115,7 +114,7 @@ export class ProtonDrivePhotosClient { this.nodes.access, this.nodes.revisions, ); - this.upload = initUploadModule( + this.upload = initPhotoUploadModule( telemetry, apiService, cryptoModule, @@ -234,7 +233,16 @@ export class ProtonDrivePhotosClient { * * See `ProtonDriveClient.getFileUploader` for more information. */ - async getFileUploader(name: string, metadata: UploadMetadata, signal?: AbortSignal): Promise { + async getFileUploader( + name: string, + metadata: UploadMetadata & { + captureTime?: Date; + mainPhotoLinkID?: string; + // TODO: handle tags enum in the SDK + tags?: (0 | 3 | 1 | 2 | 7 | 4 | 5 | 6 | 8 | 9)[]; + }, + signal?: AbortSignal, + ): Promise { this.logger.info(`Getting file uploader`); const parentFolderUid = await this.nodes.access.getVolumeRootFolder(); return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); From cc2a97a34c3618cda5a08e72a04ed2878c9670cb Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Sep 2025 10:48:42 +0200 Subject: [PATCH 225/791] Add injection of HTTP client factory and account client --- cs/headers/proton_drive_sdk.h | 2 +- cs/headers/proton_sdk.h | 4 +- .../InteropAccountClient.cs | 77 +++++++ .../InteropActionExtensions.cs | 78 +++++++ .../InteropDownloadController.cs | 1 - .../InteropFileDownloader.cs | 10 +- .../InteropFileUploader.cs | 7 +- .../InteropMessageHandler.cs | 90 ++++++-- .../InteropProtonDriveClient.cs | 32 ++- .../InteropStream.cs | 208 ++---------------- .../InteropUploadController.cs | 1 - .../ProgressUpdateCallback.cs | 33 --- .../Proton.Drive.Sdk.CExports.csproj | 1 + .../Proton.Drive.Sdk/AccountClientAdapter.cs | 5 - .../Api/Volumes/VolumeTrashResponse.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs | 3 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 17 ++ cs/sdk/src/Proton.Sdk.CExports/Interop.cs | 47 ++-- .../src/Proton.Sdk.CExports/InteropAction.cs | 57 +++++ .../InteropActionExtensions.cs | 28 +++ .../src/Proton.Sdk.CExports/InteropArray.cs | 2 +- .../InteropCallbackExtensions.cs | 17 -- .../InteropHttpClientFactory.cs | 112 ++++++++++ .../InteropMessageHandler.cs | 6 +- .../InteropResultExtensions.cs | 10 +- ...InteropTokenRefreshedCallbackExtensions.cs | 26 --- .../InteropValueCallback.cs | 10 - .../InteropVoidCallback.cs | 9 - .../Logging/InteropLogger.cs | 11 +- .../Logging/InteropLoggerProvider.cs | 10 +- .../ProtonApiSessionRequestHandler.cs | 26 +-- .../Tasks/IValueTaskCompletionSource.cs | 15 ++ .../Tasks/IValueTaskFaultingSource.cs | 6 + .../{ => Tasks}/TaskExtensions.cs | 2 +- .../Tasks/ValueTaskCompletionSource.cs | 50 +++++ .../Tasks/ValueTaskCompletionSource{T}.cs | 47 ++++ .../Proton.Sdk/Http/HttpBodyLoggingHandler.cs | 8 +- cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs | 1 + cs/sdk/src/protos/proton.drive.sdk.proto | 142 ++++++++---- cs/sdk/src/protos/proton.sdk.proto | 98 +++++---- 40 files changed, 847 insertions(+), 464 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/ProgressUpdateCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropCallbackExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Tasks/IValueTaskCompletionSource.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Tasks/IValueTaskFaultingSource.cs rename cs/sdk/src/Proton.Sdk.CExports/{ => Tasks}/TaskExtensions.cs (90%) create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Tasks/ValueTaskCompletionSource.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/Tasks/ValueTaskCompletionSource{T}.cs diff --git a/cs/headers/proton_drive_sdk.h b/cs/headers/proton_drive_sdk.h index 0c88e533..1ab5ba44 100644 --- a/cs/headers/proton_drive_sdk.h +++ b/cs/headers/proton_drive_sdk.h @@ -9,7 +9,7 @@ void proton_drive_sdk_handle_request( ByteArray request, const void* caller_state, - array_callback_function response_callback + array_action response_callback ); void proton_drive_sdk_handle_response( diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h index 1e8a2b85..5da55a11 100644 --- a/cs/headers/proton_sdk.h +++ b/cs/headers/proton_sdk.h @@ -9,7 +9,7 @@ typedef struct { size_t length; } ByteArray; -typedef void array_callback_function(const void* state, ByteArray array); +typedef void array_action(const void* state, ByteArray array); void override_native_library_name( ByteArray library_name, @@ -19,7 +19,7 @@ void override_native_library_name( void proton_sdk_handle_request( ByteArray request, const void* caller_state, - array_callback_function response_callback + array_action response_action ); #endif // PROTON_SDK_H diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs new file mode 100644 index 00000000..376f08ed --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs @@ -0,0 +1,77 @@ +using Google.Protobuf.WellKnownTypes; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; +using Proton.Sdk.CExports; +using Address = Proton.Sdk.Addresses.Address; +using AddressKey = Proton.Sdk.Addresses.AddressKey; +using AddressStatus = Proton.Sdk.Addresses.AddressStatus; + +namespace Proton.Drive.Sdk.CExports; + +internal sealed class InteropAccountClient(nint state, InteropAction, nint> requestAction) : IAccountClient +{ + private readonly nint _state = state; + private readonly InteropAction, nint> _requestAction = requestAction; + + public async ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + { + var request = new AccountClientRequest { GetAddress = new GetAddressRequest { AddressId = addressId.ToString() } }; + var response = await _requestAction.SendRequestAsync(_state, request).ConfigureAwait(false); + + return ConvertToAddress(response); + } + + public async ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) + { + var response = await _requestAction.SendRequestAsync( + _state, + new AccountClientRequest { GetAddress = new GetAddressRequest() }).ConfigureAwait(false); + + return ConvertToAddress(response); + } + + public async ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken) + { + var request = new AccountClientRequest { GetAddressPrimaryPrivateKey = new GetAddressPrimaryPrivateKeyRequest { AddressId = addressId.ToString() } }; + var response = await _requestAction.SendRequestAsync(_state, request).ConfigureAwait(false); + + return PgpPrivateKey.Import(response.Value.Span); + } + + public async ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken) + { + var request = new AccountClientRequest { GetAddressPrivateKeys = new GetAddressPrivateKeysRequest { AddressId = addressId.ToString() } }; + var response = await _requestAction.SendRequestAsync(_state, request).ConfigureAwait(false); + + return [.. response.Value.Select(keyData => PgpPrivateKey.Import(keyData.Span))]; + } + + public async ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) + { + var request = new AccountClientRequest { GetAddressPublicKeys = new GetAddressPublicKeysRequest { EmailAddress = emailAddress } }; + var response = await _requestAction.SendRequestAsync(_state, request).ConfigureAwait(false); + + return [.. response.Value.Select(keyData => PgpPublicKey.Import(keyData.Span))]; + } + + private static Address ConvertToAddress(Proton.Sdk.CExports.Address addressMessage) + { + var addressId = new AddressId(addressMessage.AddressId); + + var keys = addressMessage.Keys.Select((key, index) => new AddressKey( + addressId, + new AddressKeyId(key.AddressKeyId), + index == addressMessage.PrimaryKeyIndex, + key.IsActive, + key.IsAllowedForEncryption, + key.IsAllowedForVerification)).ToList(); + + return new Address( + addressId, + addressMessage.Order, + addressMessage.EmailAddress, + (AddressStatus)addressMessage.Status, + keys, + addressMessage.PrimaryKeyIndex); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs new file mode 100644 index 00000000..71eecb98 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs @@ -0,0 +1,78 @@ +using Google.Protobuf; +using Proton.Sdk.CExports; +using Proton.Sdk.CExports.Tasks; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropActionExtensions +{ + public static unsafe ValueTask SendRequestAsync( + this InteropAction, nint> interopAction, + nint state, + IMessage request) + where TResponse : IMessage + { + var tcs = new ValueTaskCompletionSource(); + + var tcsHandle = Interop.AllocHandle(tcs); + + var requestBytes = request.ToByteArray(); + + fixed (byte* requestBytesPointer = requestBytes) + { + interopAction.Invoke(state, new InteropArray(requestBytesPointer, requestBytes.Length), (nint)tcsHandle); + } + + return tcs.Task; + } + + public static unsafe ValueTask InvokeWithBufferAsync( + this InteropAction, nint> interopAction, + nint state, + Span buffer) + { + var tcs = new ValueTaskCompletionSource(); + + var tcsHandle = Interop.AllocHandle(tcs); + + fixed (byte* requestBytesPointer = buffer) + { + interopAction.Invoke(state, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); + } + + return tcs.Task; + } + + public static unsafe ValueTask InvokeWithBufferAsync( + this InteropAction, nint> interopAction, + nint state, + ReadOnlySpan buffer) + { + var tcs = new ValueTaskCompletionSource(); + + var tcsHandle = Interop.AllocHandle(tcs); + + fixed (byte* requestBytesPointer = buffer) + { + interopAction.Invoke(state, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); + } + + return tcs.Task; + } + + public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint state, long total, long completed) + { + var progressUpdate = new ProgressUpdate + { + BytesCompleted = completed, + BytesInTotal = total, + }; + + var requestBytes = progressUpdate.ToByteArray(); + + fixed (byte* requestBytesPointer = requestBytes) + { + interopAction.Invoke(state, new InteropArray(requestBytesPointer, requestBytes.Length)); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs index 733b4702..6e347b31 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs @@ -1,7 +1,6 @@ using Google.Protobuf; using Proton.Drive.Sdk.Nodes.Download; using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index b68bf77c..a3802ff2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -2,7 +2,6 @@ using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes.Download; using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; @@ -14,11 +13,14 @@ public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, n var downloader = Interop.GetFromHandle(request.DownloaderHandle); - var stream = new InteropStream(callerState, (nint)request.WriteCallback); + var stream = new InteropStream(callerState, new InteropAction, nint>(request.WriteAction)); - var progressUpdateCallback = new ProgressUpdateCallback((nint)request.ProgressCallback, callerState); + var progressAction = new InteropAction>(request.ProgressAction); - var downloadController = downloader.DownloadToStream(stream, progressUpdateCallback.UpdateProgress, cancellationToken); + var downloadController = downloader.DownloadToStream( + stream, + (completed, total) => progressAction.InvokeProgressUpdate(callerState, total, completed), + cancellationToken); return new Int64Value { Value = Interop.AllocHandle(downloadController) }; } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 81ac9bf3..de64d663 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -3,7 +3,6 @@ using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; @@ -15,16 +14,16 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var uploader = Interop.GetFromHandle(request.UploaderHandle); - var stream = new InteropStream(uploader.FileSize, callerState, (nint)request.ReadCallback); + var stream = new InteropStream(uploader.FileSize, callerState, new InteropAction, nint>(request.ReadAction)); var thumbnails = request.Thumbnails.Select(t => new Nodes.Thumbnail((ThumbnailType)t.Type, t.ToByteArray())); - var progressUpdateCallback = new ProgressUpdateCallback((nint)request.ProgressCallback, callerState); + var progressAction = new InteropAction>(request.ProgressAction); var uploadController = uploader.UploadFromStream( stream, thumbnails, - (completed, total) => progressUpdateCallback.UpdateProgress(completed, total), + (completed, total) => progressAction.InvokeProgressUpdate(callerState, total, completed), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 5dd24cd1..3f252e87 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -1,16 +1,23 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using Google.Protobuf.Reflection; using Google.Protobuf.WellKnownTypes; using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; -using Request = Proton.Sdk.Drive.CExports.Request; +using Proton.Sdk.CExports.Tasks; namespace Proton.Drive.Sdk.CExports; internal static class InteropMessageHandler { + private static readonly TypeRegistry ResponseTypeRegistry = TypeRegistry.FromMessages( + Int32Value.Descriptor, + StringValue.Descriptor, + BytesValue.Descriptor, + RepeatedBytesValue.Descriptor, + Address.Descriptor); + [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])] - public static async void OnRequestReceived(InteropArray requestBytes, nint callerState, InteropValueCallback> responseCallback) + public static async void OnRequestReceived(InteropArray requestBytes, nint callerState, InteropAction> responseAction) { try { @@ -19,7 +26,10 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint var response = request.PayloadCase switch { Request.PayloadOneofCase.DriveClientCreate - => InteropProtonDriveClient.HandleCreate(request.DriveClientCreate), + => InteropProtonDriveClient.HandleCreate(request.DriveClientCreate, callerState), + + Request.PayloadOneofCase.DriveClientCreateFromSession + => InteropProtonDriveClient.HandleCreate(request.DriveClientCreateFromSession), Request.PayloadOneofCase.DriveClientFree => InteropProtonDriveClient.HandleFree(request.DriveClientFree), @@ -73,34 +83,84 @@ Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; - responseCallback.InvokeWithResponse(callerState, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); + responseAction.InvokeWithMessage(callerState, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); } catch (Exception e) { - var error = e.ToErrorMessage(InteropErrorConverter.SetDomainAndCodes); + var error = e.ToErrorMessage(InteropDriveErrorConverter.SetDomainAndCodes); - responseCallback.InvokeWithResponse(callerState, new Response { Error = error }); + responseAction.InvokeWithMessage(callerState, new Response { Error = error }); } } [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_response", CallConvs = [typeof(CallConvCdecl)])] public static void OnResponseReceived(nint state, InteropArray responseBytes) { - var response = CallbackResponse.Parser.ParseFrom(responseBytes.AsReadOnlySpan()); + var response = Response.Parser.ParseFrom(responseBytes.AsReadOnlySpan()); + + if (response.Error is not null) + { + SetException(state, response.Error.Message); + return; + } - switch (response.PayloadCase) + if (response.Value is null) { - case CallbackResponse.PayloadOneofCase.StreamRead: - InteropStream.HandleReadResponse(state, response.StreamRead); + SetResult(state); + return; + } + + var responseValue = response.Value.Unpack(ResponseTypeRegistry); + + switch (responseValue) + { + case Int32Value value: + SetResult(state, value); + break; + + case StringValue value: + SetResult(state, value); + break; + + case BytesValue value: + SetResult(state, value); + break; + + case RepeatedBytesValue value: + SetResult(state, value); break; - case CallbackResponse.PayloadOneofCase.StreamWrite: - InteropStream.HandleWriteResponse(state, response.StreamWrite); + case Address value: + SetResult(state, value); + break; + + case HttpResponse value: + SetResult(state, value); break; - case CallbackResponse.PayloadOneofCase.None: default: - throw new ArgumentException($"Unknown request type: {response.PayloadCase}", nameof(responseBytes)); + throw new ArgumentException($"Unknown response value type: {responseValue.Descriptor.Name}", nameof(responseBytes)); } } + + private static void SetResult(nint tcsHandle, T value) + { + var tcs = Interop.GetFromHandleAndFree>(tcsHandle); + + tcs.SetResult(value); + } + + private static void SetResult(nint tcsHandle) + { + var tcs = Interop.GetFromHandleAndFree(tcsHandle); + + tcs.SetResult(); + } + + private static void SetException(nint tcsHandle, string errorMessage) + { + var tfs = Interop.GetFromHandleAndFree(tcsHandle); + + tfs.SetException(new Exception(errorMessage)); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 55b74221..dd2fee4e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -1,15 +1,43 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; using Proton.Drive.Sdk.Nodes; using Proton.Sdk; +using Proton.Sdk.Caching; using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; +using Proton.Sdk.CExports.Logging; namespace Proton.Drive.Sdk.CExports; internal static class InteropProtonDriveClient { - public static IMessage HandleCreate(DriveClientCreateRequest request) + public static IMessage HandleCreate(DriveClientCreateRequest request, nint state) + { + var httpClientFactory = new InteropHttpClientFactory( + state, + request.BaseUrl, + request.BindingsLanguage, + new InteropAction, nint>(request.SendHttpRequestAction)); + + var accountClient = new InteropAccountClient(state, new InteropAction, nint>(request.AccountClient.RequestAction)); + + var entityCacheRepository = request.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.EntityCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var secretCacheRepository = request.HasSecretCachePath + ? SqliteCacheRepository.OpenFile(request.SecretCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var loggerProvider = Interop.GetFromHandle(request.LoggerProviderHandle); + var loggerFactory = new LoggerFactory([loggerProvider]); + + var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, loggerFactory); + + return new Int64Value { Value = Interop.AllocHandle(client) }; + } + + public static IMessage HandleCreate(DriveClientCreateFromSessionRequest request) { var session = Interop.GetFromHandle(request.SessionHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs index 62ecfce1..5c507bba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs @@ -1,37 +1,35 @@ -using System.Buffers; -using System.Runtime.InteropServices; +using Google.Protobuf.WellKnownTypes; using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; -internal sealed unsafe class InteropStream : Stream +internal sealed class InteropStream : Stream { private readonly nint _callerState; - private readonly delegate* unmanaged[Cdecl], nint, void> _readCallback; - private readonly delegate* unmanaged[Cdecl], nint, void> _writeCallback; + private readonly InteropAction, nint>? _readAction; + private readonly InteropAction, nint>? _writeAction; private long _position; private long? _length; - public InteropStream(long length, nint callerState, nint readCallbackPointer) + public InteropStream(long length, nint callerState, InteropAction, nint>? readAction) { _length = length; _callerState = callerState; - _readCallback = (delegate* unmanaged[Cdecl], nint, void>)readCallbackPointer; - _writeCallback = default; + _readAction = readAction; + _writeAction = null; } - public InteropStream(nint callerState, nint writeCallbackPointer) + public InteropStream(nint callerState, InteropAction, nint>? writeAction) { _callerState = callerState; - _readCallback = default; - _writeCallback = (delegate* unmanaged[Cdecl], nint, void>)writeCallbackPointer; + _readAction = null; + _writeAction = writeAction; } - public override bool CanRead => _readCallback != null; + public override bool CanRead => _readAction != null; public override bool CanSeek => false; - public override bool CanWrite => _writeCallback != null; + public override bool CanWrite => _writeAction != null; public override long Length => _length ?? 0; public override long Position @@ -40,57 +38,6 @@ public override long Position set => throw new NotSupportedException("Seeking not supported"); } - internal static void HandleReadResponse(nint state, StreamReadResponse response) - { - var operationHandle = GCHandle.FromIntPtr(state); - - try - { - var operation = Interop.GetFromHandle(operationHandle); - - switch (response.ResultCase) - { - case StreamReadResponse.ResultOneofCase.BytesRead: - operation.Complete(response.BytesRead); - break; - - case StreamReadResponse.ResultOneofCase.Error: - operation.Complete(response.Error.Message); - break; - - case StreamReadResponse.ResultOneofCase.None: - default: - break; - } - } - finally - { - operationHandle.Free(); - } - } - - internal static void HandleWriteResponse(nint state, StreamWriteResponse response) - { - var operationHandle = GCHandle.FromIntPtr(new nint(state)); - - try - { - var operation = Interop.GetFromHandle(operationHandle); - - if (response.Error != null) - { - operation.Complete(response.Error.Message); - return; - } - - operation.Complete(); - } - finally - { - operationHandle.Free(); - } - } - public override void Flush() { } @@ -105,37 +52,20 @@ public override Task ReadAsync(byte[] buffer, int offset, int count, Cancel return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); } - public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - if (_readCallback == null) + if (_readAction is null) { throw new NotSupportedException("Reading not supported"); } - var memoryHandle = buffer.Pin(); + using var memoryHandle = buffer.Pin(); - try - { - var operation = new ReadOperation(this, memoryHandle); - var operationHandle = GCHandle.Alloc(operation); + var response = await _readAction.Value.InvokeWithBufferAsync(_callerState, buffer.Span).ConfigureAwait(false); - try - { - _readCallback(_callerState, new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), GCHandle.ToIntPtr(operationHandle)); + _position += response.Value; - return new ValueTask(operation.Task); - } - catch - { - operationHandle.Free(); - throw; - } - } - catch - { - memoryHandle.Dispose(); - throw; - } + return response.Value; } public override long Seek(long offset, SeekOrigin origin) @@ -158,108 +88,18 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); } - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - if (_writeCallback == null) + if (_writeAction == null) { throw new NotSupportedException("Writing not supported"); } - var memoryHandle = buffer.Pin(); - - try - { - var operation = new WriteOperation(this, memoryHandle, buffer.Length); - var operationHandle = GCHandle.Alloc(operation); - - try - { - _writeCallback(_callerState, new InteropArray((byte*)memoryHandle.Pointer, buffer.Length), GCHandle.ToIntPtr(operationHandle)); - - return new ValueTask(operation.Task); - } - catch - { - operationHandle.Free(); - throw; - } - } - catch - { - memoryHandle.Dispose(); - throw; - } - } - - private sealed class ReadOperation(InteropStream stream, MemoryHandle memoryHandle) - { - private readonly InteropStream _stream = stream; - private readonly TaskCompletionSource _taskCompletionSource = new(); - - private MemoryHandle _memoryHandle = memoryHandle; - - public Task Task => _taskCompletionSource.Task; - - public void Complete(int bytesRead) - { - try - { - _stream._position += bytesRead; - _taskCompletionSource.SetResult(bytesRead); - } - finally - { - _memoryHandle.Dispose(); - } - } - - public void Complete(string errorMessage) - { - try - { - _taskCompletionSource.SetException(new IOException(errorMessage)); - } - finally - { - _memoryHandle.Dispose(); - } - } - } - - private sealed class WriteOperation(InteropStream stream, MemoryHandle memoryHandle, int bufferLength) - { - private readonly InteropStream _stream = stream; - private readonly int _bufferLength = bufferLength; - private readonly TaskCompletionSource _taskCompletionSource = new(); + using var memoryHandle = buffer.Pin(); - private MemoryHandle _memoryHandle = memoryHandle; + await _writeAction.Value.InvokeWithBufferAsync(_callerState, buffer.Span).ConfigureAwait(false); - public Task Task => _taskCompletionSource.Task; - - public void Complete() - { - try - { - _stream._position += _bufferLength; - _stream._length = Math.Max(_stream._length ?? 0, _stream._position); - _taskCompletionSource.SetResult(); - } - finally - { - _memoryHandle.Dispose(); - } - } - - public void Complete(string errorMessage) - { - try - { - _taskCompletionSource.SetException(new IOException(errorMessage)); - } - finally - { - _memoryHandle.Dispose(); - } - } + _position += buffer.Length; + _length = Math.Max(_length ?? 0, _position); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs index c1914173..eda878b6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -1,7 +1,6 @@ using Google.Protobuf; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; namespace Proton.Drive.Sdk.CExports; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/ProgressUpdateCallback.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/ProgressUpdateCallback.cs deleted file mode 100644 index d0a46a94..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/ProgressUpdateCallback.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Google.Protobuf; -using Proton.Sdk.CExports; -using Proton.Sdk.Drive.CExports; - -namespace Proton.Drive.Sdk.CExports; - -internal readonly unsafe struct ProgressUpdateCallback(nint progressCallbackPointer, nint callerState) -{ - private readonly delegate* unmanaged[Cdecl], void> _progressCallback = - (delegate* unmanaged[Cdecl], void>)progressCallbackPointer; - - private readonly IntPtr _callerState = callerState; - - public void UpdateProgress(long completed, long total) - { - var progressUpdate = new ProgressUpdate - { - BytesCompleted = completed, - BytesInTotal = total, - }; - - var messageBytes = InteropArray.FromMemory(progressUpdate.ToByteArray()); - - try - { - _progressCallback(_callerState, messageBytes); - } - finally - { - messageBytes.Free(); - } - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index 04ee97c6..7302ea7d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -11,6 +11,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs index ff4d4260..f624df44 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs @@ -23,11 +23,6 @@ public ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addre return _client.GetAddressPrimaryPrivateKeyAsync(addressId, cancellationToken); } - public ValueTask GetAddressPrivateKeyAsync(AddressId addressId, int index, CancellationToken cancellationToken) - { - return _client.GetAddressPrivateKeyAsync(addressId, index, cancellationToken); - } - public ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken) { return _client.GetAddressPrivateKeysAsync(addressId, cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs index 34299bce..373d587a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs @@ -6,5 +6,5 @@ namespace Proton.Drive.Sdk.Api.Volumes; internal sealed class VolumeTrashResponse : ApiResponse { [JsonPropertyName("Trash")] - public IReadOnlyList TrashByShare { get; init; } + public required IReadOnlyList TrashByShare { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs index 58e599d2..3ae20b61 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs @@ -3,12 +3,11 @@ namespace Proton.Drive.Sdk; -internal interface IAccountClient +public interface IAccountClient { ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken); ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken); ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken); - ValueTask GetAddressPrivateKeyAsync(AddressId addressId, int index, CancellationToken cancellationToken); ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken); ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 920dbf8f..4b43a13a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -8,6 +8,7 @@ using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Volumes; using Proton.Sdk; +using Proton.Sdk.Caching; namespace Proton.Drive.Sdk; @@ -33,6 +34,22 @@ public ProtonDriveClient(ProtonApiSession session, string? uid = null) { } + public ProtonDriveClient( + IHttpClientFactory httpClientFactory, + IAccountClient accountClient, + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository, + ILoggerFactory loggerFactory, + string? uid = null) + : this( + httpClientFactory.CreateClient(), + accountClient, + new DriveClientCache(entityCacheRepository, secretCacheRepository), + loggerFactory, + uid ?? Guid.NewGuid().ToString()) + { + } + internal ProtonDriveClient( IAccountClient accountClient, IDriveApiClients apiClients, diff --git a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs index d285f7d2..98b272df 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs @@ -6,35 +6,25 @@ namespace Proton.Sdk.CExports; internal static class Interop { [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static long AllocHandle(T obj) + public static long AllocHandle(T obj) where T : class { return GCHandle.ToIntPtr(GCHandle.Alloc(obj)); } - internal static T GetFromHandle(long handle) + public static T GetFromHandle(long handle) where T : class { - GCHandle gcHandle; - try - { - gcHandle = GCHandle.FromIntPtr((nint)handle); - } - catch (Exception e) - { - throw InvalidHandleException.Create((nint)handle, e); - } - - return GetFromHandle(gcHandle); + return GetFromHandle(handle, free: false); } - internal static T GetFromHandle(GCHandle gcHandle) + public static T GetFromHandleAndFree(long handle) where T : class { - return (T)(gcHandle.Target ?? throw InvalidHandleException.Create(GCHandle.ToIntPtr(gcHandle))); + return GetFromHandle(handle, free: true); } - internal static void FreeHandle(long handle) + public static void FreeHandle(long handle) where T : class { var gcHandle = GCHandle.FromIntPtr((nint)handle); @@ -48,8 +38,31 @@ internal static void FreeHandle(long handle) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static CancellationToken GetCancellationToken(long cancellationTokenSourceHandle) + public static CancellationToken GetCancellationToken(long cancellationTokenSourceHandle) { return GetFromHandle(cancellationTokenSourceHandle).Token; } + + private static T GetFromHandle(long handle, bool free) + where T : class + { + GCHandle gcHandle; + try + { + gcHandle = GCHandle.FromIntPtr((nint)handle); + } + catch (Exception e) + { + throw InvalidHandleException.Create((nint)handle, e); + } + + var handleTarget = gcHandle.Target; + + if (free) + { + gcHandle.Free(); + } + + return (T)(handleTarget ?? throw InvalidHandleException.Create(GCHandle.ToIntPtr(gcHandle))); + } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs new file mode 100644 index 00000000..1419c990 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs @@ -0,0 +1,57 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropAction(delegate* unmanaged[Cdecl] pointer) + where T : unmanaged +{ + private readonly delegate* unmanaged[Cdecl] _pointer = pointer; + + public InteropAction(long pointer) + : this((delegate* unmanaged[Cdecl])pointer) + { + } + + public void Invoke(T arg) + { + _pointer(arg); + } +} + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropAction(delegate* unmanaged[Cdecl] pointer) + where T1 : unmanaged + where T2 : unmanaged +{ + private readonly delegate* unmanaged[Cdecl] _pointer = pointer; + + public InteropAction(long pointer) + : this((delegate* unmanaged[Cdecl])pointer) + { + } + + public void Invoke(T1 arg1, T2 arg2) + { + _pointer(arg1, arg2); + } +} + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropAction(delegate* unmanaged[Cdecl] pointer) + where T1 : unmanaged + where T2 : unmanaged + where T3 : unmanaged +{ + private readonly delegate* unmanaged[Cdecl] _pointer = pointer; + + public InteropAction(long pointer) + : this((delegate* unmanaged[Cdecl])pointer) + { + } + + public void Invoke(T1 arg1, T2 arg2, T3 arg3) + { + _pointer(arg1, arg2, arg3); + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs new file mode 100644 index 00000000..e9801c5e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs @@ -0,0 +1,28 @@ +using Google.Protobuf; + +namespace Proton.Sdk.CExports; + +internal static class InteropActionExtensions +{ + public static unsafe void InvokeWithMessage(this InteropAction> action, nint state, T message) + where T : IMessage + { + var responseBytes = message.ToByteArray(); + + fixed (byte* responsePointer = responseBytes) + { + action.Invoke(state, new InteropArray(responsePointer, responseBytes.Length)); + } + } + + public static unsafe void InvokeWithMessage(this InteropAction, nint> action, nint state, T message, nint callerState) + where T : IMessage + { + var responseBytes = message.ToByteArray(); + + fixed (byte* responsePointer = responseBytes) + { + action.Invoke(state, new InteropArray(responsePointer, responseBytes.Length), callerState); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs index 78e8b292..0481f491 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs @@ -13,7 +13,7 @@ internal readonly unsafe struct InteropArray(T* pointer, nint length) public bool IsNullOrEmpty => Pointer is null || Length == 0; - public static InteropArray FromMemory(ReadOnlyMemory memory) + public static InteropArray AllocFromMemory(ReadOnlyMemory memory) { if (memory.Length == 0) { diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropCallbackExtensions.cs deleted file mode 100644 index 01e058b3..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropCallbackExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Google.Protobuf; - -namespace Proton.Sdk.CExports; - -internal static class InteropCallbackExtensions -{ - public static unsafe void InvokeWithResponse(this InteropValueCallback> callback, nint callerState, T response) - where T : IMessage - { - var responseBytes = response.ToByteArray(); - - fixed (byte* responsePointer = responseBytes) - { - callback.Invoke(callerState, new InteropArray(responsePointer, responseBytes.Length)); - } - } -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs new file mode 100644 index 00000000..27a621d2 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Reflection; +using Google.Protobuf; + +namespace Proton.Sdk.CExports; + +internal sealed class InteropHttpClientFactory : IHttpClientFactory +{ + private readonly string _baseUrl; + private readonly string _sdkVersion; + private readonly string _sdkTechnicalStack; + + public InteropHttpClientFactory( + nint state, + string baseUrl, + string? bindingsLanguage, + InteropAction, nint> sendHttpRequestAction) + { + _baseUrl = baseUrl; + State = state; + SendHttpRequestAction = sendHttpRequestAction; + + var executingAssembly = Assembly.GetExecutingAssembly(); + var versionAttribute = executingAssembly.GetCustomAttribute(); + _sdkVersion = versionAttribute?.InformationalVersion + ?? executingAssembly.GetName().Version?.ToString(fieldCount: 3) + ?? "0.0.0"; + + var bindingsSuffix = bindingsLanguage is not null + ? "-" + bindingsLanguage.ToLowerInvariant() + : string.Empty; + + _sdkTechnicalStack = "dotnet" + bindingsSuffix; + } + + private nint State { get; } + private InteropAction, nint> SendHttpRequestAction { get; } + + public HttpClient CreateClient(string name) + { + return new HttpClient(new InteropHttpMessageHandler(this)) + { + BaseAddress = new Uri(_baseUrl), + DefaultRequestHeaders = + { + { "x-pm-drive-sdk-version", $"{_sdkTechnicalStack}@{_sdkVersion}" }, + }, + }; + } + + private sealed class InteropHttpMessageHandler(InteropHttpClientFactory owner) : HttpMessageHandler + { + private readonly InteropHttpClientFactory _owner = owner; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var taskCompletionSource = new TaskCompletionSource(); + var taskCompletionSourceHandle = Interop.AllocHandle(taskCompletionSource); + + var interopHttpRequest = await ConvertHttpRequestToInteropAsync(request, cancellationToken).ConfigureAwait(false); + + _owner.SendHttpRequestAction.InvokeWithMessage(_owner.State, interopHttpRequest, (nint)taskCompletionSourceHandle); + + var interopHttpResponse = await taskCompletionSource.Task.ConfigureAwait(false); + + return ConvertHttpResponseFromInterop(interopHttpResponse); + } + + private static async ValueTask ConvertHttpRequestToInteropAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri?.AbsoluteUri ?? throw new InvalidOperationException($"Missing URL for HTTP request: {request.RequestUri}"); + + var interopHttpRequest = new HttpRequest { Url = url, Method = request.Method.Method }; + + var headers = request.Headers.AsEnumerable(); + + if (request.Content is not null) + { + headers = headers.Concat(request.Content.Headers); + + interopHttpRequest.Content = await ByteString.FromStreamAsync( + await request.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + } + + interopHttpRequest.Headers.AddRange( + headers.Select(h => + { + var header = new HttpHeader { Name = h.Key }; + header.Values.AddRange(h.Value); + return header; + })); + + return interopHttpRequest; + } + + private static HttpResponseMessage ConvertHttpResponseFromInterop(HttpResponse interopHttpResponse) + { + var response = new HttpResponseMessage((HttpStatusCode)interopHttpResponse.StatusCode) + { + Content = new ReadOnlyMemoryContent(interopHttpResponse.Content.Memory), + }; + + foreach (var interopHttpResponseHeader in interopHttpResponse.Headers.Where(x => x.Name.StartsWith("content-", StringComparison.OrdinalIgnoreCase))) + { + response.Content.Headers.Add(interopHttpResponseHeader.Name, interopHttpResponseHeader.Values); + } + + return response; + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs index 2c907d58..fe0eea43 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -8,7 +8,7 @@ namespace Proton.Sdk.CExports; internal static class InteropMessageHandler { [UnmanagedCallersOnly(EntryPoint = "proton_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])] - public static async void OnRequestReceived(InteropArray requestBytes, nint callerState, InteropValueCallback> responseCallback) + public static async void OnRequestReceived(InteropArray requestBytes, nint callerState, InteropAction> responseAction) { try { @@ -53,13 +53,13 @@ Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; - responseCallback.InvokeWithResponse(callerState, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); + responseAction.InvokeWithMessage(callerState, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); } catch (Exception e) { var error = e.ToErrorMessage(InteropErrorConverter.SetDomainAndCodes); - responseCallback.InvokeWithResponse(callerState, new Response { Error = error }); + responseAction.InvokeWithMessage(callerState, new Response { Error = error }); } } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs index 06af98e9..21f02e24 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs @@ -14,7 +14,7 @@ internal static Result, InteropArray> Success() internal static Result, InteropArray> Success(IMessage data) { return new Result, InteropArray>( - value: InteropArray.FromMemory(data.ToByteArray())); + value: InteropArray.AllocFromMemory(data.ToByteArray())); } internal static unsafe Result, InteropArray> Success(string value) @@ -51,25 +51,25 @@ internal static Result> Failure(Exception exception, Action>(error: InteropArray.FromMemory(error.ToByteArray())); + return new Result>(error: InteropArray.AllocFromMemory(error.ToByteArray())); } internal static Result> Failure(Exception exception, Action setDomainAndCodesFunction) { var error = exception.ToErrorMessage(setDomainAndCodesFunction); - return new Result>(error: InteropArray.FromMemory(error.ToByteArray())); + return new Result>(error: InteropArray.AllocFromMemory(error.ToByteArray())); } private static Result> Failure(int code, string message) { return new Result>( - error: InteropArray.FromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); + error: InteropArray.AllocFromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); } private static Result> Failure(int code, string message) { return new Result>( - error: InteropArray.FromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); + error: InteropArray.AllocFromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs deleted file mode 100644 index c6f66a1e..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropTokenRefreshedCallbackExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Google.Protobuf; - -namespace Proton.Sdk.CExports; - -internal static class InteropTokenRefreshedCallbackExtensions -{ - internal static unsafe void Invoke(this InteropValueCallback> callback, nint callerState, string accessToken, string refreshToken) - { - var sessionTokens = new SessionTokens - { - AccessToken = accessToken, - RefreshToken = refreshToken, - }; - - var sessionTokensBytes = InteropArray.FromMemory(sessionTokens.ToByteArray()); - - try - { - callback.Invoke(callerState, sessionTokensBytes); - } - finally - { - sessionTokensBytes.Free(); - } - } -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs deleted file mode 100644 index 268cfc87..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropValueCallback.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Proton.Sdk.CExports; - -[StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropValueCallback(delegate* unmanaged[Cdecl] invoke) - where TValue : unmanaged -{ - public readonly delegate* unmanaged[Cdecl] Invoke = invoke; -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs deleted file mode 100644 index 80feea48..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropVoidCallback.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Proton.Sdk.CExports; - -[StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropVoidCallback(delegate* unmanaged[Cdecl] invoke) -{ - public readonly delegate* unmanaged[Cdecl] Invoke = invoke; -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index 74c82afa..70cc1568 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -5,13 +5,10 @@ namespace Proton.Sdk.CExports.Logging; [StructLayout(LayoutKind.Sequential)] -internal sealed unsafe class InteropLogger( - nint callerState, - delegate* unmanaged[Cdecl], void> logCallback, - string categoryName) : ILogger +internal sealed class InteropLogger(nint callerState, InteropAction> logAction, string categoryName) : ILogger { private readonly nint _callerState = callerState; - private readonly delegate* unmanaged[Cdecl], void> _logCallback = logCallback; + private readonly InteropAction> _logAction = logAction; private readonly string _categoryName = categoryName; public IDisposable BeginScope(TState state) @@ -26,11 +23,11 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except var message = formatter.Invoke(state, exception); var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; - var messageBytes = InteropArray.FromMemory(logEvent.ToByteArray()); + var messageBytes = InteropArray.AllocFromMemory(logEvent.ToByteArray()); try { - _logCallback(_callerState, messageBytes); + _logAction.Invoke(_callerState, messageBytes); } finally { diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs index 07d07ceb..f720dd98 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs @@ -4,14 +4,14 @@ namespace Proton.Sdk.CExports.Logging; -internal sealed unsafe class InteropLoggerProvider(nint callerState, delegate* unmanaged[Cdecl], void> logCallback) : ILoggerProvider +internal sealed class InteropLoggerProvider(nint callerState, InteropAction> logAction) : ILoggerProvider { private readonly nint _callerState = callerState; - private readonly delegate* unmanaged[Cdecl], void> _logCallback = logCallback; + private readonly InteropAction> _logAction = logAction; public ILogger CreateLogger(string categoryName) { - return new InteropLogger(_callerState, _logCallback, categoryName); + return new InteropLogger(_callerState, _logAction, categoryName); } public void Dispose() @@ -21,7 +21,9 @@ public void Dispose() public static IMessage HandleCreate(LoggerProviderCreate request, nint callerState) { - var provider = new InteropLoggerProvider(callerState, (delegate* unmanaged[Cdecl], void>)request.LogCallback); + var logAction = new InteropAction>(request.LogAction); + + var provider = new InteropLoggerProvider(callerState, logAction); return new Int64Value { Value = Interop.AllocHandle(provider) }; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs index 96808d78..7ad28127 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs @@ -81,9 +81,9 @@ public static IMessage HandleResume(SessionResumeRequest request) var passwordMode = request.IsWaitingForDataPassword ? PasswordMode.Dual : PasswordMode.Single; var session = ProtonApiSession.Resume( - new Authentication.SessionId(request.SessionId.Value), + new SessionId(request.SessionId), request.Username, - new Users.UserId(request.UserId.Value), + new Users.UserId(request.UserId), request.AccessToken, request.RefreshToken, request.Scopes, @@ -104,7 +104,7 @@ public static IMessage HandleRenew(SessionRenewRequest request) var session = ProtonApiSession.Renew( expiredSession, - new Authentication.SessionId(request.SessionId.Value), + new SessionId(request.SessionId), request.AccessToken, request.RefreshToken, request.Scopes, @@ -127,9 +127,9 @@ public static unsafe IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefr { var session = Interop.GetFromHandle((nint)request.SessionHandle); - var tokenRefreshedCallback = (delegate* unmanaged[Cdecl], void>)request.TokensRefreshedCallback; + var tokenRefreshedAction = new InteropAction>(request.TokensRefreshedAction); - var subscription = TokensRefreshedSubscription.Create(session, callerState, tokenRefreshedCallback); + var subscription = TokensRefreshedSubscription.Create(session, callerState, tokenRefreshedAction); return new Int64Value { Value = Interop.AllocHandle(subscription) }; } @@ -150,28 +150,28 @@ public static unsafe IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefr return null; } - private sealed unsafe class TokensRefreshedSubscription : IDisposable + private sealed class TokensRefreshedSubscription : IDisposable { private readonly ProtonApiSession _session; private readonly nint _callerState; - private readonly delegate* unmanaged[Cdecl], void> _tokensRefreshedCallback; + private readonly InteropAction> _tokensRefreshedAction; private TokensRefreshedSubscription( ProtonApiSession session, nint callerState, - delegate* unmanaged[Cdecl], void> tokensRefreshedCallback) + InteropAction> tokensRefreshedAction) { _session = session; _callerState = callerState; - _tokensRefreshedCallback = tokensRefreshedCallback; + _tokensRefreshedAction = tokensRefreshedAction; } public static TokensRefreshedSubscription Create( ProtonApiSession session, nint callerState, - delegate* unmanaged[Cdecl], void> tokensRefreshedCallback) + InteropAction> tokensRefreshedAction) { - var subscription = new TokensRefreshedSubscription(session, callerState, tokensRefreshedCallback); + var subscription = new TokensRefreshedSubscription(session, callerState, tokensRefreshedAction); session.TokenCredential.TokensRefreshed += subscription.Handle; @@ -185,11 +185,11 @@ public void Dispose() private void Handle(string accessToken, string refreshToken) { - var tokensMessage = InteropArray.FromMemory(new SessionTokens { AccessToken = accessToken, RefreshToken = refreshToken }.ToByteArray()); + var tokensMessage = InteropArray.AllocFromMemory(new SessionTokens { AccessToken = accessToken, RefreshToken = refreshToken }.ToByteArray()); try { - _tokensRefreshedCallback(_callerState, tokensMessage); + _tokensRefreshedAction.Invoke(_callerState, tokensMessage); } finally { diff --git a/cs/sdk/src/Proton.Sdk.CExports/Tasks/IValueTaskCompletionSource.cs b/cs/sdk/src/Proton.Sdk.CExports/Tasks/IValueTaskCompletionSource.cs new file mode 100644 index 00000000..2a5ebd41 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Tasks/IValueTaskCompletionSource.cs @@ -0,0 +1,15 @@ +namespace Proton.Sdk.CExports.Tasks; + +internal interface IValueTaskCompletionSource : IValueTaskFaultingSource +{ + ValueTask Task { get; } + + void SetResult(T result); +} + +internal interface IValueTaskCompletionSource : IValueTaskFaultingSource +{ + ValueTask Task { get; } + + void SetResult(); +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Tasks/IValueTaskFaultingSource.cs b/cs/sdk/src/Proton.Sdk.CExports/Tasks/IValueTaskFaultingSource.cs new file mode 100644 index 00000000..799bc144 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Tasks/IValueTaskFaultingSource.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.CExports.Tasks; + +internal interface IValueTaskFaultingSource +{ + void SetException(Exception error); +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/TaskExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/Tasks/TaskExtensions.cs similarity index 90% rename from cs/sdk/src/Proton.Sdk.CExports/TaskExtensions.cs rename to cs/sdk/src/Proton.Sdk.CExports/Tasks/TaskExtensions.cs index cf127dbf..a1538687 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/TaskExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Tasks/TaskExtensions.cs @@ -1,4 +1,4 @@ -namespace Proton.Sdk.CExports; +namespace Proton.Sdk.CExports.Tasks; internal static class TaskExtensions { diff --git a/cs/sdk/src/Proton.Sdk.CExports/Tasks/ValueTaskCompletionSource.cs b/cs/sdk/src/Proton.Sdk.CExports/Tasks/ValueTaskCompletionSource.cs new file mode 100644 index 00000000..8ead4588 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Tasks/ValueTaskCompletionSource.cs @@ -0,0 +1,50 @@ +using System.Runtime.CompilerServices; +using System.Threading.Tasks.Sources; + +namespace Proton.Sdk.CExports.Tasks; + +internal sealed class ValueTaskCompletionSource : IValueTaskSource, IValueTaskCompletionSource +{ + private ManualResetValueTaskSourceCore _core; + + public ValueTaskCompletionSource() + { + _core.RunContinuationsAsynchronously = true; + } + + public ValueTask Task + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => new(this, _core.Version); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetResult() + { + _core.SetResult(null); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetException(Exception error) + { + _core.SetException(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void IValueTaskSource.GetResult(short token) + { + _core.GetResult(token); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) + { + return _core.GetStatus(token); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + { + _core.OnCompleted(continuation, state, token, flags); + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Tasks/ValueTaskCompletionSource{T}.cs b/cs/sdk/src/Proton.Sdk.CExports/Tasks/ValueTaskCompletionSource{T}.cs new file mode 100644 index 00000000..6538f9de --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Tasks/ValueTaskCompletionSource{T}.cs @@ -0,0 +1,47 @@ +using System.Runtime.CompilerServices; +using System.Threading.Tasks.Sources; + +namespace Proton.Sdk.CExports.Tasks; + +internal sealed class ValueTaskCompletionSource : IValueTaskSource, IValueTaskCompletionSource +{ + private ManualResetValueTaskSourceCore _core; + + public ValueTaskCompletionSource() + { + _core.RunContinuationsAsynchronously = true; + } + + public ValueTask Task + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => new(this, _core.Version); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetResult(T result) + { + _core.SetResult(result); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetException(Exception error) + { + _core.SetException(error); + } + + T IValueTaskSource.GetResult(short token) + { + return _core.GetResult(token); + } + + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) + { + return _core.GetStatus(token); + } + + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + { + _core.OnCompleted(continuation, state, token, flags); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpBodyLoggingHandler.cs b/cs/sdk/src/Proton.Sdk/Http/HttpBodyLoggingHandler.cs index bf3cba89..3760a36c 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpBodyLoggingHandler.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpBodyLoggingHandler.cs @@ -45,22 +45,22 @@ private static string Indent(string json) return null; } - var contentString = await content.ReadAsStringAsync(cancellationToken); + var contentString = await content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return mediaType is MediaTypeNames.Application.Json ? Indent(contentString) : contentString; } private async Task SendWithBodyLoggingAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - var requestContentString = await TryGetContentAsString(request.Content, cancellationToken); + var requestContentString = await TryGetContentAsString(request.Content, cancellationToken).ConfigureAwait(false); if (requestContentString is not null) { _logger.LogInformation($"Request body:{NewLine}{{Body}}", requestContentString); } - var response = await base.SendAsync(request, cancellationToken); + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - var responseContentString = await TryGetContentAsString(response.Content, cancellationToken); + var responseContentString = await TryGetContentAsString(response.Content, cancellationToken).ConfigureAwait(false); if (responseContentString is not null) { if (request.RequestUri?.PathAndQuery.Contains("auth/", StringComparison.OrdinalIgnoreCase) == true) diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs index 14efd3b3..c7490d8b 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs @@ -10,6 +10,7 @@ public record ProtonClientOptions public string? UserAgent { get; set; } public ProtonClientTlsPolicy? TlsPolicy { get; set; } public Func? CustomHttpMessageHandlerFactory { get; set; } + public IHttpClientFactory? HttpClientFactory { get; set; } public ICacheRepository? EntityCacheRepository { get; set; } public ILoggerFactory? LoggerFactory { get; set; } internal ICacheRepository? SecretCacheRepository { get; set; } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index db3c8e5d..b24cb541 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -1,42 +1,83 @@ edition = "2023"; -package proton.sdk.drive; +package proton.drive.sdk; option features.utf8_validation = NONE; -option csharp_namespace = "Proton.Sdk.Drive.CExports"; +option csharp_namespace = "Proton.Drive.Sdk.CExports"; import "proton.sdk.proto"; message Request { oneof payload { - DriveClientCreateRequest DriveClientCreate = 100; - DriveClientFreeRequest DriveClientFree = 101; - DriveClientGetFileUploaderRequest DriveClientGetFileUploader = 102; - DriveClientGetFileRevisionUploaderRequest DriveClientGetFileRevisionUploader = 103; - DriveClientGetFileDownloaderRequest DriveClientGetFileDownloader = 104; - - UploadFromStreamRequest UploadFromStream = 200; - FileUploaderFreeRequest FileUploaderFree = 201; - UploadControllerAwaitCompletionRequest UploadControllerAwaitCompletion = 202; - UploadControllerPauseRequest UploadControllerPause = 203; - UploadControllerResumeRequest UploadControllerResume = 204; - UploadControllerFreeRequest UploadControllerFree = 205; - - DownloadToStreamRequest DownloadToStream = 300; - FileDownloaderFreeRequest FileDownloaderFree = 301; - DownloadControllerAwaitCompletionRequest DownloadControllerAwaitCompletion = 302; - DownloadControllerPauseRequest DownloadControllerPause = 303; - DownloadControllerResumeRequest DownloadControllerResume = 304; - DownloadControllerFreeRequest DownloadControllerFree = 305; + DriveClientCreateRequest drive_client_create = 1000; + DriveClientCreateFromSessionRequest drive_client_create_from_session = 1001; + DriveClientFreeRequest drive_client_free = 1002; + DriveClientGetFileUploaderRequest drive_client_get_file_uploader = 1003; + DriveClientGetFileRevisionUploaderRequest drive_client_get_file_revision_uploader = 1004; + DriveClientGetFileDownloaderRequest drive_client_get_file_downloader = 1005; + + UploadFromStreamRequest upload_from_stream = 1100; + FileUploaderFreeRequest file_uploader_free = 1101; + UploadControllerAwaitCompletionRequest upload_controller_await_completion = 1102; + UploadControllerPauseRequest upload_controller_pause = 1103; + UploadControllerResumeRequest upload_controller_resume = 1104; + UploadControllerFreeRequest upload_controller_free = 1105; + + DownloadToStreamRequest download_to_stream = 1200; + FileDownloaderFreeRequest file_downloader_free = 1201; + DownloadControllerAwaitCompletionRequest download_controller_await_completion = 1202; + DownloadControllerPauseRequest download_controller_pause = 1203; + DownloadControllerResumeRequest download_controller_resume = 1204; + DownloadControllerFreeRequest download_controller_free = 1205; }; } -message CallbackResponse { +// Account client interface + +message AccountClient { + // Pointer to C function that will be called: + // void account_client_request_action(long state, ByteArray http_request, long caller_state) + // state: value that was passed as caller state when this message was sent, i.e. state of the bindings + // http_request: Protobuf message of type proton.drive.sdk.AccountClientRequest carrying the request data + // caller_state: value to provide as state during the response call + int64 request_action = 1; +} + +message AccountClientRequest { oneof payload { - StreamReadResponse StreamRead = 100; - StreamWriteResponse StreamWrite = 101; + GetAddressRequest get_address = 1; + GetDefaultAddressRequest get_default_address = 2; + GetAddressPrimaryPrivateKeyRequest get_address_primary_private_key = 3; + GetAddressPrivateKeysRequest get_address_private_keys = 4; + GetAddressPublicKeysRequest get_address_public_keys = 5; }; } +// The response value type must be Address. +message GetAddressRequest { + string address_id = 1; +} + +// The response value type must be Address. +message GetDefaultAddressRequest { +} + +// The response value type must be BytesValue. +message GetAddressPrimaryPrivateKeyRequest { + string address_id = 1; +} + +// The response value type must be RepeatedBytesValue. +message GetAddressPrivateKeysRequest { + string address_id = 1; +} + +// The response value type must be RepeatedBytesValue. +message GetAddressPublicKeysRequest { + string email_address = 1; +} + +// Drive - client + message RevisionUploaderProvisionRequest { string file_uid = 1; int64 file_size = 2; @@ -53,10 +94,26 @@ message Thumbnail { int64 content_pointer = 2; } -// Drive - client - -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message DriveClientCreateRequest { + string base_url = 1; + string bindings_language = 2; // Optional + + // Pointer to C function that will be called: + // void send_http_request_action(long state, ByteArray http_request, long caller_state) + // state: value that was passed as caller state when this message was sent, i.e. state of the bindings + // http_request: Protobuf message of type proton.sdk.HttpRequest carrying the HTTP request data + // caller_state: value to provide as state during the response call + int64 send_http_request_action = 3; + + AccountClient account_client = 4; + string entity_cache_path = 5; // Optional + string secret_cache_path = 6; // Optional + int64 logger_provider_handle = 7; +} + +// The response value type must be Int64Value. +message DriveClientCreateFromSessionRequest { int64 session_handle = 1; } @@ -67,7 +124,7 @@ message DriveClientFreeRequest { // Drive - uploads -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message DriveClientGetFileUploaderRequest { int64 client_handle = 1; string parent_folder_uid = 2; @@ -88,12 +145,12 @@ message DriveClientGetFileRevisionUploaderRequest { int64 cancellation_token_source_handle = 5; } -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message UploadFromStreamRequest { int64 uploader_handle = 1; repeated Thumbnail thumbnails = 2; - int64 read_callback = 3; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); - int64 progress_callback = 4; // See array_callback_function in C header file for signature + int64 read_action = 3; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); + int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; } @@ -124,18 +181,18 @@ message UploadControllerFreeRequest { // Drive - downloads -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message DriveClientGetFileDownloaderRequest { int64 client_handle = 1; string revision_uid = 2; int64 cancellation_token_source_handle = 3; } -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message DownloadToStreamRequest { int64 downloader_handle = 1; - int64 write_callback = 2; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); - int64 progress_callback = 3; // See array_callback_function in C header file for signature + int64 write_action = 2; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); + int64 progress_action = 3; // See array_action in C header file for signature int64 cancellation_token_source_handle = 4; } @@ -164,25 +221,14 @@ message DownloadControllerFreeRequest { int64 download_controller_handle = 1; } -// The response must be a StreamReadResponse message. +// The response value type must be Int32Value. message StreamReadRequest { int64 buffer_pointer = 1; int32 buffer_length = 2; } -// The response must be a StreamWriteResponse message. +// No response value. message StreamWriteRequest { int64 data_pointer = 1; int32 data_length = 2; } - -message StreamReadResponse { - oneof result { - int32 BytesRead = 1; - proton.sdk.Error Error = 2; - } -} - -message StreamWriteResponse { - proton.sdk.Error error = 1; // Optional -} diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto index 87a2654b..a830a9c6 100644 --- a/cs/sdk/src/protos/proton.sdk.proto +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -8,19 +8,19 @@ option csharp_namespace = "Proton.Sdk.CExports"; message Request { oneof payload { - CancellationTokenSourceCreateRequest cancellationTokenSourceCreate = 100; - CancellationTokenSourceCancelRequest cancellationTokenSourceCancel = 101; - CancellationTokenSourceFreeRequest cancellationTokenSourceFree = 102; - - SessionBeginRequest sessionBegin = 200; - SessionResumeRequest sessionResume = 201; - SessionRenewRequest sessionRenew = 202; - SessionEndRequest sessionEnd = 203; - SessionTokensRefreshedSubscribeRequest sessionTokensRefreshedSubscribe = 204; - SessionTokensRefreshedUnsubscribeRequest sessionTokensRefreshedUnsubscribe = 205; - SessionFreeRequest sessionFree = 206; - - LoggerProviderCreate loggerProviderCreate = 300; + CancellationTokenSourceCreateRequest cancellation_token_source_create = 100; + CancellationTokenSourceCancelRequest cancellation_token_source_cancel = 101; + CancellationTokenSourceFreeRequest cancellation_token_source_free = 102; + + SessionBeginRequest session_begin = 200; + SessionResumeRequest session_resume = 201; + SessionRenewRequest session_renew = 202; + SessionEndRequest session_end = 203; + SessionTokensRefreshedSubscribeRequest session_tokens_refreshed_subscribe = 204; + SessionTokensRefreshedUnsubscribeRequest session_tokens_refreshed_unsubscribe = 205; + SessionFreeRequest session_free = 206; + + LoggerProviderCreate logger_provider_create = 300; }; } @@ -31,9 +31,13 @@ message Response { } } +message RepeatedBytesValue { + repeated bytes value = 1; +} + // Cancellation tokens -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message CancellationTokenSourceCreateRequest {} // No response value. @@ -48,7 +52,7 @@ message CancellationTokenSourceFreeRequest { // Sessions -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message SessionBeginRequest { string username = 1; string password = 2; @@ -58,12 +62,12 @@ message SessionBeginRequest { int64 cancellation_token_source_handle = 6; } -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message SessionResumeRequest { string username = 1; string app_version = 2; - SessionId session_id = 3; - UserId user_id = 4; + string session_id = 3; + string user_id = 4; string access_token = 5; string refresh_token = 6; repeated string scopes = 7; @@ -73,10 +77,10 @@ message SessionResumeRequest { ProtonClientOptions options = 11; } -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message SessionRenewRequest { int64 old_session_handle = 1; - SessionId session_id = 2; + string session_id = 2; string access_token = 3; string refresh_token = 4; repeated string scopes = 5; @@ -89,10 +93,10 @@ message SessionEndRequest { int64 session_handle = 1; } -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message SessionTokensRefreshedSubscribeRequest { int64 session_handle = 1; - int64 tokens_refreshed_callback = 2; + int64 tokens_refreshed_action = 2; } // No response value. @@ -105,9 +109,9 @@ message SessionFreeRequest { int64 session_handle = 1; } -// The response value will be an Int64Value message. +// The response value type must be Int64Value. message LoggerProviderCreate { - int64 log_callback = 1; + int64 log_action = 1; } message LogEvent { @@ -178,32 +182,38 @@ message Error { Error inner_error = 7; // Optional } -message StringResponse { - string value = 1; -} - -message SessionId { - string value = 1; +message Address { + string address_id = 1; + int32 order = 2; + string email_address = 3; + AddressStatus status = 4; + repeated proton.sdk.AddressKey keys = 5; + int32 primary_key_index = 6; } -message UserId { - string value = 1; -} - -message UserKeyId { - string value = 1; +message AddressKey { + string address_id = 1; + string address_key_id = 2; + bool is_active = 3; + bool is_allowed_for_encryption = 4; + bool is_allowed_for_verification = 5; } -message AddressId { - string value = 1; +message HttpHeader { + string name = 1; + repeated string values = 2; } -message AddressKeyId { - string value = 1; +// The response value type must be HttpResponse. +message HttpRequest { + string url = 1; + string method = 2; + repeated HttpHeader headers = 3; + bytes content = 4; // Optional } -message AddressKey { - AddressId address_id = 1; - AddressKeyId address_key_id = 2; - bool is_allowed_for_encryption = 3; +message HttpResponse { + int32 status_code = 1; + repeated HttpHeader headers = 2; + bytes content = 3; // Optional } From 1356454bffbe9bb993df079ec0052f430ae0f46a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Sep 2025 14:20:09 +0000 Subject: [PATCH 226/791] Implement CLI photo download --- js/sdk/src/protonDrivePhotosClient.ts | 34 ++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index b60aab20..b57ad7c1 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -8,11 +8,19 @@ import { FileUploader, SDKEvent, MaybeNode, + ThumbnailType, + ThumbnailResult, } from './interface'; import { getConfig } from './config'; import { DriveCrypto } from './crypto'; import { Telemetry } from './telemetry'; -import { convertInternalMissingNodeIterator, convertInternalNodeIterator, getUid, getUids } from './transformers'; +import { + convertInternalMissingNodeIterator, + convertInternalNodeIterator, + convertInternalNodePromise, + getUid, + getUids, +} from './transformers'; import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; @@ -207,6 +215,16 @@ export class ProtonDrivePhotosClient { yield* convertInternalMissingNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } + /** + * Get the node by its UID. + * + * See `ProtonDriveClient.getNode` for more information. + */ + async getNode(nodeUid: NodeOrUid): Promise { + this.logger.info(`Getting node ${getUid(nodeUid)}`); + return convertInternalNodePromise(this.nodes.access.getNode(getUid(nodeUid))); + } + /** * Iterates the albums. * @@ -228,6 +246,20 @@ export class ProtonDrivePhotosClient { return this.download.getFileDownloader(getUid(nodeUid), signal); } + /** + * Iterates the thumbnails of the given nodes. + * + * See `ProtonDriveClient.iterateThumbnails` for more information. + */ + async *iterateThumbnails( + nodeUids: NodeOrUid[], + thumbnailType?: ThumbnailType, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating ${nodeUids.length} thumbnails`); + yield* this.download.iterateThumbnails(getUids(nodeUids), thumbnailType, signal); + } + /** * Get the file uploader to upload a new file. * From 489ba1e18048fb16888f2e52fb99452d57d240a3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 24 Sep 2025 12:46:33 +0000 Subject: [PATCH 227/791] Add isSharedPublicly to node based on ShareURLID --- js/sdk/package-lock.json | 4 ++-- js/sdk/src/interface/nodes.ts | 4 ++++ js/sdk/src/internal/nodes/apiService.test.ts | 23 +++++++++++++++++++ js/sdk/src/internal/nodes/apiService.ts | 1 + js/sdk/src/internal/nodes/cache.test.ts | 1 + js/sdk/src/internal/nodes/index.test.ts | 1 + js/sdk/src/internal/nodes/interface.ts | 1 + js/sdk/src/internal/nodes/nodesManagement.ts | 1 + .../src/internal/sharingPublic/apiService.ts | 2 ++ js/sdk/src/transformers.ts | 2 ++ 10 files changed, 38 insertions(+), 2 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 266ea662..e7d66e82 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.3.1", + "version": "0.4.0", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index e1e34255..1917f5b5 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -75,6 +75,10 @@ export type NodeEntity = { * one user, or via public link. */ isShared: boolean; + /** + * Whether the node is publicly shared. If true, the node is shared via public link. + */ + isSharedPublicly: boolean; /** * Provides the ID of the share that the node is shared with. * diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 07c604d9..abce4270 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -143,6 +143,7 @@ function generateNode() { shareId: undefined, isShared: false, + isSharedPublicly: false, directRole: MemberRole.Admin, membership: undefined, @@ -255,6 +256,7 @@ describe('nodeAPIService', () => { generateFolderNode( { isShared: true, + isSharedPublicly: false, shareId: 'shareId', directRole: MemberRole.Admin, membership: { @@ -296,6 +298,7 @@ describe('nodeAPIService', () => { generateFolderNode( { isShared: true, + isSharedPublicly: false, shareId: 'shareId', directRole: MemberRole.Viewer, membership: { @@ -317,6 +320,26 @@ describe('nodeAPIService', () => { ); }); + it('should get publicly shared node', async () => { + await testIterateNodes( + generateAPIFolderNode( + {}, + { + Sharing: { + ShareID: 'shareId', + ShareURLID: 'shareUrlId', + }, + }, + ), + generateFolderNode({ + isShared: true, + isSharedPublicly: true, + shareId: 'shareId', + directRole: MemberRole.Admin, + }), + ); + }); + it('should get trashed file node', async () => { await testIterateNodes( generateAPIFileNode({ diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index b5ae96ce..93c58146 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -577,6 +577,7 @@ function linkToEncryptedNode( // Sharing node metadata shareId: link.Sharing?.ShareID || undefined, isShared: !!link.Sharing, + isSharedPublicly: !!link.Sharing?.ShareURLID, directRole: isAdmin ? MemberRole.Admin : membershipRole, membership: link.Membership ? { diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 4993a243..65e887fb 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -21,6 +21,7 @@ function generateNode( type: NodeType.File, mediaType: 'text', isShared: false, + isSharedPublicly: false, creationTime: new Date(), trashTime: undefined, volumeId: 'volumeId', diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index 4defcd3b..3667e131 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -24,6 +24,7 @@ function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial< type: NodeType.File, mediaType: 'text', isShared: false, + isSharedPublicly: false, creationTime: new Date(), trashTime: undefined, isStale: false, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index c576d4c9..e5e909e6 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -38,6 +38,7 @@ interface BaseNode { // Share node metadata shareId?: string; isShared: boolean; + isSharedPublicly: boolean; directRole: MemberRole; membership?: { role: MemberRole; diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 358eb345..cdaa62f1 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -329,6 +329,7 @@ export class NodesManagement { // Share node metadata isShared: false, + isSharedPublicly: false, directRole: MemberRole.Inherited, // Decrypted metadata diff --git a/js/sdk/src/internal/sharingPublic/apiService.ts b/js/sdk/src/internal/sharingPublic/apiService.ts index 30b50c7c..dd295e1b 100644 --- a/js/sdk/src/internal/sharingPublic/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/apiService.ts @@ -76,6 +76,7 @@ function tokenToEncryptedNode(logger: Logger, token: GetTokenInfoResponse['Token creationTime: new Date(), // TODO isShared: false, + isSharedPublicly: false, directRole: MemberRole.Viewer, // TODO }; @@ -133,6 +134,7 @@ function linkToEncryptedNode( totalStorageSize: link.TotalSize, isShared: false, + isSharedPublicly: false, directRole: MemberRole.Viewer, // TODO }; diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 54dad026..c854afd8 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -23,6 +23,7 @@ type InternalPartialNode = Pick< | 'type' | 'mediaType' | 'isShared' + | 'isSharedPublicly' | 'creationTime' | 'trashTime' | 'activeRevision' @@ -90,6 +91,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode type: node.type, mediaType: node.mediaType, isShared: node.isShared, + isSharedPublicly: node.isSharedPublicly, creationTime: node.creationTime, trashTime: node.trashTime, totalStorageSize: node.totalStorageSize, From 734c62257c1029f689d7efadccc70309ad8ffb76 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 24 Sep 2025 15:35:53 +0200 Subject: [PATCH 228/791] js/v0.4.1 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index fac595dd..0cfa5f5f 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.4.0", + "version": "0.4.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From f72e07779299fa0ccc00356f53121bfe7fff578b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 24 Sep 2025 16:50:58 +0200 Subject: [PATCH 229/791] Return file revision UID on upload completion --- .../InteropProtonDriveClient.cs | 4 +-- .../InteropUploadController.cs | 4 +-- .../Nodes/DegradedFileNode.cs | 2 +- .../Nodes/DtoToMetadataConverter.cs | 4 +-- cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 2 +- .../src/Proton.Drive.Sdk/Nodes/RevisionUid.cs | 2 +- .../Nodes/Upload/FileUploader.cs | 10 +++--- .../Nodes/Upload/UploadController.cs | 4 +-- cs/sdk/src/protos/proton.drive.sdk.proto | 32 ++++++++++++++++--- 10 files changed, 45 insertions(+), 21 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index dd2fee4e..9cef656e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -57,7 +57,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe request.Name, request.MediaType, request.Size, - DateTimeOffset.FromUnixTimeSeconds(request.LastModificationTime).DateTime, + request.LastModificationTime.ToDateTime(), request.OverrideExistingDraftByOtherClient, cancellationToken).ConfigureAwait(false); @@ -73,7 +73,7 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive var fileUploader = await client.GetFileRevisionUploaderAsync( RevisionUid.Parse(request.CurrentActiveRevisionUid), request.FileSize, - DateTimeOffset.FromUnixTimeSeconds(request.LastModificationTime).DateTime, + request.LastModificationTime.ToDateTime(), cancellationToken).ConfigureAwait(false); return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs index eda878b6..84492171 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -10,9 +10,9 @@ internal static class InteropUploadController { var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); - await uploadController.Completion.ConfigureAwait(false); + var (nodeUid, revisionUid) = await uploadController.Completion.ConfigureAwait(false); - return null; + return new UploadResult { NodeUid = nodeUid.ToString(), RevisionUid = revisionUid.ToString() }; } public static IMessage? HandlePause(UploadControllerPauseRequest request) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs index 6a462bf4..d1b06dba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs @@ -23,7 +23,7 @@ public FileNode ToNode(string substituteName, Revision substituteRevision) NameAuthor = NameAuthor, Author = Author, ActiveRevision = ActiveRevision ?? substituteRevision, - TotalStorageQuotaUsage = TotalStorageQuotaUsage, + TotalSizeOnCloudStorage = TotalStorageQuotaUsage, }; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 2ad7bc4c..a810dcf8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -238,13 +238,13 @@ public static async Task> ConvertDtoT { Uid = new RevisionUid(uid, activeRevisionDto.Id), CreationTime = activeRevisionDto.CreationTime, - StorageQuotaConsumption = activeRevisionDto.StorageQuotaConsumption, + SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, ClaimedSize = extendedAttributes?.Common?.Size, ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, Thumbnails = [], // FIXME: thumbnails ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure), }, - TotalStorageQuotaUsage = fileDto.TotalStorageQuotaUsage, + TotalSizeOnCloudStorage = fileDto.TotalStorageQuotaUsage, }; await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs index 65fd349a..27fdef69 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs @@ -4,5 +4,5 @@ public sealed record FileNode : FileOrFileDraftNode { public required Revision ActiveRevision { get; init; } - public required long TotalStorageQuotaUsage { get; init; } + public required long TotalSizeOnCloudStorage { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index bcd4f424..5854cf7b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -6,7 +6,7 @@ public sealed class Revision { public required RevisionUid Uid { get; init; } public required DateTime CreationTime { get; init; } - public required long StorageQuotaConsumption { get; init; } + public required long SizeOnCloudStorage { get; init; } public required long? ClaimedSize { get; init; } public required DateTime? ClaimedModificationTime { get; init; } public required IReadOnlyList> Thumbnails { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs index 01d32b79..36f0d1bb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs @@ -14,7 +14,7 @@ internal RevisionUid(NodeUid nodeUid, RevisionId revisionId) RevisionId = revisionId; } - internal NodeUid NodeUid { get; } + public NodeUid NodeUid { get; } internal RevisionId RevisionId { get; } public override string ToString() diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index c111c4e8..a9893ff1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -45,7 +45,7 @@ public void Dispose() _remainingNumberOfBlocks = 0; } - private async Task UploadFromStreamAsync( + private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, Action onProgress, @@ -53,7 +53,7 @@ private async Task UploadFromStreamAsync( { var (draftRevisionUid, fileSecrets) = await _fileDraftProvider.GetDraftAsync(_client, cancellationToken).ConfigureAwait(false); - return await UploadAsync( + var fileNode = await UploadAsync( draftRevisionUid, fileSecrets, contentStream, @@ -61,9 +61,11 @@ private async Task UploadFromStreamAsync( _lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); + + return (fileNode.Uid, fileNode.ActiveRevision.Uid); } - private async ValueTask UploadAsync( + private async ValueTask UploadAsync( RevisionUid revisionUid, FileSecrets fileSecrets, Stream contentStream, @@ -79,7 +81,7 @@ private async ValueTask UploadAsync( var nodeMetadata = await NodeOperations.GetNodeMetadataAsync(_client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - return nodeMetadata.Node; + return (FileNode)nodeMetadata.Node; } private void ReleaseBlocks(int numberOfBlocks) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index fbb54a44..e0716b18 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -1,8 +1,8 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -public sealed class UploadController(Task uploadTask) +public sealed class UploadController(Task<(NodeUid NodeUid, RevisionUid RevisionUid)> uploadTask) { - public Task Completion { get; } = uploadTask; + public Task<(NodeUid NodeUid, RevisionUid RevisionUid)> Completion { get; } = uploadTask; public void Pause() { diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index b24cb541..be134a82 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -4,7 +4,7 @@ package proton.drive.sdk; option features.utf8_validation = NONE; option csharp_namespace = "Proton.Drive.Sdk.CExports"; -import "proton.sdk.proto"; +import "google/protobuf/timestamp.proto"; message Request { oneof payload { @@ -78,10 +78,27 @@ message GetAddressPublicKeysRequest { // Drive - client +message FileRevision { + string uid = 1; + google.protobuf.Timestamp creation_time = 2; + int64 size_on_cloud_storage = 3; + int64 claimed_size = 4; // Optional + google.protobuf.Timestamp claimed_modification_time = 5; // Optional +} + +message FileNode { + string uid = 1; + string parent_uid = 2; + string name = 3; + string media_type = 4; + int64 total_size_on_cloud_storage = 5; + FileRevision active_revision = 6; +} + message RevisionUploaderProvisionRequest { string file_uid = 1; int64 file_size = 2; - int64 last_modification_date = 3; + google.protobuf.Timestamp last_modification_time = 3; } message ProgressUpdate { @@ -94,6 +111,11 @@ message Thumbnail { int64 content_pointer = 2; } +message UploadResult { + string node_uid = 1; + string revision_uid = 2; +} + // The response value type must be Int64Value. message DriveClientCreateRequest { string base_url = 1; @@ -131,7 +153,7 @@ message DriveClientGetFileUploaderRequest { string name = 3; string mediaType = 4; int64 size = 5; - int64 last_modification_time = 6; + google.protobuf.Timestamp last_modification_time = 6; bool override_existing_draft_by_other_client = 7; int64 cancellation_token_source_handle = 8; } @@ -141,7 +163,7 @@ message DriveClientGetFileRevisionUploaderRequest { int64 client_handle = 1; string current_active_revision_uid = 2; int64 file_size = 3; - int64 last_modification_time = 4; + google.protobuf.Timestamp last_modification_time = 4; int64 cancellation_token_source_handle = 5; } @@ -159,7 +181,7 @@ message FileUploaderFreeRequest { int64 file_uploader_handle = 1; } -// The response value will be a Node message. +// The response value will be a UploadResult message containing the new revision UID. message UploadControllerAwaitCompletionRequest { int64 upload_controller_handle = 1; } From 1c260fa8e955ac78dee75cc80eb04ddd0a75cea4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 24 Sep 2025 17:20:37 +0200 Subject: [PATCH 230/791] Add functions to upload from and download to a file path --- .../InteropFileDownloader.cs | 16 ++++++++ .../InteropFileUploader.cs | 33 ++++++++++++++- .../InteropMessageHandler.cs | 6 +++ .../Nodes/Download/FileDownloader.cs | 17 ++++++++ .../Nodes/Upload/FileUploader.cs | 25 ++++++++++++ .../Nodes/Upload/RevisionWriter.cs | 19 +-------- cs/sdk/src/protos/proton.drive.sdk.proto | 40 ++++++++++++++----- js/sdk/src/diagnostic/sdkDiagnostic.ts | 2 +- js/sdk/src/interface/download.ts | 8 ++-- js/sdk/src/interface/upload.ts | 4 +- .../internal/download/fileDownloader.test.ts | 16 ++++---- .../src/internal/download/fileDownloader.ts | 10 ++--- .../src/internal/upload/fileUploader.test.ts | 20 +++++----- js/sdk/src/internal/upload/fileUploader.ts | 4 +- js/sdk/src/protonDriveClient.ts | 4 +- 15 files changed, 162 insertions(+), 62 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index a3802ff2..06cbc679 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -25,6 +25,22 @@ public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, n return new Int64Value { Value = Interop.AllocHandle(downloadController) }; } + public static IMessage HandleDownloadToFile(DownloadToFileRequest request, nint callerState) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var downloader = Interop.GetFromHandle(request.DownloaderHandle); + + var progressAction = new InteropAction>(request.ProgressAction); + + var downloadController = downloader.DownloadToFile( + request.FilePath, + (completed, total) => progressAction.InvokeProgressUpdate(callerState, total, completed), + cancellationToken); + + return new Int64Value { Value = Interop.AllocHandle(downloadController) }; + } + public static IMessage? HandleFree(FileDownloaderFreeRequest request) { Interop.FreeHandle(request.FileDownloaderHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index de64d663..1f5d1843 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -16,7 +16,13 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var stream = new InteropStream(uploader.FileSize, callerState, new InteropAction, nint>(request.ReadAction)); - var thumbnails = request.Thumbnails.Select(t => new Nodes.Thumbnail((ThumbnailType)t.Type, t.ToByteArray())); + var thumbnails = request.Thumbnails.Select(t => + { + unsafe + { + return new Nodes.Thumbnail((ThumbnailType)t.Type, new InteropArray((byte*)t.ContentPointer, (nint)t.ContentLength).ToArray()); + } + }); var progressAction = new InteropAction>(request.ProgressAction); @@ -29,6 +35,31 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n return new Int64Value { Value = Interop.AllocHandle(uploadController) }; } + public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint callerState) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var uploader = Interop.GetFromHandle(request.UploaderHandle); + + var thumbnails = request.Thumbnails.Select(t => + { + unsafe + { + return new Nodes.Thumbnail((ThumbnailType)t.Type, new InteropArray((byte*)t.ContentPointer, (nint)t.ContentLength).ToArray()); + } + }); + + var progressAction = new InteropAction>(request.ProgressAction); + + var uploadController = uploader.UploadFromFile( + request.FilePath, + thumbnails, + (completed, total) => progressAction.InvokeProgressUpdate(callerState, total, completed), + cancellationToken); + + return new Int64Value { Value = Interop.AllocHandle(uploadController) }; + } + public static IMessage? HandleFree(FileUploaderFreeRequest request) { Interop.FreeHandle(request.FileUploaderHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 3f252e87..5734fa13 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -46,6 +46,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.UploadFromStream => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, callerState), + Request.PayloadOneofCase.UploadFromFile + => InteropFileUploader.HandleUploadFromFile(request.UploadFromFile, callerState), + Request.PayloadOneofCase.FileUploaderFree => InteropFileUploader.HandleFree(request.FileUploaderFree), @@ -64,6 +67,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DownloadToStream => InteropFileDownloader.HandleDownloadToStream(request.DownloadToStream, callerState), + Request.PayloadOneofCase.DownloadToFile + => InteropFileDownloader.HandleDownloadToFile(request.DownloadToFile, callerState), + Request.PayloadOneofCase.FileDownloaderFree => InteropFileDownloader.HandleFree(request.FileDownloaderFree), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 5d007bce..2b6bcff1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -19,6 +19,13 @@ public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + { + var task = DownloadToFileAsync(filePath, onProgress, cancellationToken); + + return new DownloadController(task); + } + public void Dispose() { if (_remainingNumberOfBlocksToList <= 0) @@ -38,6 +45,16 @@ private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + { + var contentOutputStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + + await using (contentOutputStream.ConfigureAwait(false)) + { + await DownloadToStreamAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); + } + } + private void ReleaseBlockListing(int numberOfBlockListings) { var newRemainingNumberOfBlocks = Interlocked.Add(ref _remainingNumberOfBlocksToList, -numberOfBlockListings); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index a9893ff1..3643aae3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -34,6 +34,17 @@ public UploadController UploadFromStream( return new UploadController(task); } + public UploadController UploadFromFile( + string filePath, + IEnumerable thumbnails, + Action onProgress, + CancellationToken cancellationToken) + { + var task = UploadFromFileAsync(filePath, thumbnails, onProgress, cancellationToken); + + return new UploadController(task); + } + public void Dispose() { if (_remainingNumberOfBlocks <= 0) @@ -65,6 +76,20 @@ public void Dispose() return (fileNode.Uid, fileNode.ActiveRevision.Uid); } + private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromFileAsync( + string filePath, + IEnumerable thumbnails, + Action onProgress, + CancellationToken cancellationToken) + { + var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + await using (contentStream.ConfigureAwait(false)) + { + return await UploadFromStreamAsync(contentStream, thumbnails, onProgress, cancellationToken).ConfigureAwait(false); + } + } + private async ValueTask UploadAsync( RevisionUid revisionUid, FileSecrets fileSecrets, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 7737c219..7e77f2ce 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -107,23 +107,8 @@ public async ValueTask WriteAsync( { var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - var buffer = ArrayPool.Shared.Rent(_targetBlockSize); - - try - { - var bytesRead = await contentStream.ReadAsync(buffer, cancellationTokenSource.Token).ConfigureAwait(false); - - buffer.AsSpan(0, Math.Min(bytesRead, plainDataPrefix.Length)).CopyTo(plainDataPrefix); - - plainDataStream.Write(buffer.AsSpan(0, bytesRead)); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - - //await contentStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plainDataPrefix, cancellationTokenSource.Token) - // .ConfigureAwait(false); + await contentStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plainDataPrefix, cancellationTokenSource.Token) + .ConfigureAwait(false); blockSizes.Add((int)plainDataStream.Length); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index be134a82..40b64764 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -16,18 +16,20 @@ message Request { DriveClientGetFileDownloaderRequest drive_client_get_file_downloader = 1005; UploadFromStreamRequest upload_from_stream = 1100; - FileUploaderFreeRequest file_uploader_free = 1101; - UploadControllerAwaitCompletionRequest upload_controller_await_completion = 1102; - UploadControllerPauseRequest upload_controller_pause = 1103; - UploadControllerResumeRequest upload_controller_resume = 1104; - UploadControllerFreeRequest upload_controller_free = 1105; + UploadFromFileRequest upload_from_file = 1101; + FileUploaderFreeRequest file_uploader_free = 1102; + UploadControllerAwaitCompletionRequest upload_controller_await_completion = 1103; + UploadControllerPauseRequest upload_controller_pause = 1104; + UploadControllerResumeRequest upload_controller_resume = 1105; + UploadControllerFreeRequest upload_controller_free = 1106; DownloadToStreamRequest download_to_stream = 1200; - FileDownloaderFreeRequest file_downloader_free = 1201; - DownloadControllerAwaitCompletionRequest download_controller_await_completion = 1202; - DownloadControllerPauseRequest download_controller_pause = 1203; - DownloadControllerResumeRequest download_controller_resume = 1204; - DownloadControllerFreeRequest download_controller_free = 1205; + DownloadToFileRequest download_to_file = 1201; + FileDownloaderFreeRequest file_downloader_free = 1202; + DownloadControllerAwaitCompletionRequest download_controller_await_completion = 1203; + DownloadControllerPauseRequest download_controller_pause = 1204; + DownloadControllerResumeRequest download_controller_resume = 1205; + DownloadControllerFreeRequest download_controller_free = 1206; }; } @@ -109,6 +111,7 @@ message ProgressUpdate { message Thumbnail { int32 type = 1; int64 content_pointer = 2; + int64 content_length = 3; } message UploadResult { @@ -176,6 +179,15 @@ message UploadFromStreamRequest { int64 cancellation_token_source_handle = 5; } +// The response value type must be Int64Value. +message UploadFromFileRequest { + int64 uploader_handle = 1; + repeated Thumbnail thumbnails = 2; + string file_path = 3; + int64 progress_action = 4; // See array_action in C header file for signature + int64 cancellation_token_source_handle = 5; +} + // No response value. message FileUploaderFreeRequest { int64 file_uploader_handle = 1; @@ -218,6 +230,14 @@ message DownloadToStreamRequest { int64 cancellation_token_source_handle = 4; } +// The response value type must be Int64Value. +message DownloadToFileRequest { + int64 downloader_handle = 1; + string file_path = 2; + int64 progress_action = 3; // See array_action in C header file for signature + int64 cancellation_token_source_handle = 4; +} + // No response value. message FileDownloaderFreeRequest { int64 file_downloader_handle = 1; diff --git a/js/sdk/src/diagnostic/sdkDiagnostic.ts b/js/sdk/src/diagnostic/sdkDiagnostic.ts index 433048bb..c4d258c1 100644 --- a/js/sdk/src/diagnostic/sdkDiagnostic.ts +++ b/js/sdk/src/diagnostic/sdkDiagnostic.ts @@ -133,7 +133,7 @@ export class SDKDiagnostic implements Diagnostic { const claimedSizeInBytes = downloader.getClaimedSizeInBytes(); const integrityVerificationStream = new IntegrityVerificationStream(); - const controller = downloader.writeToStream(integrityVerificationStream); + const controller = downloader.downloadToStream(integrityVerificationStream); try { await controller.completion(); diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index a34ff2ca..a49166ff 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -15,14 +15,14 @@ export interface FileDownloader { * * @param onProgress - Callback that is called with the number of downloaded bytes */ - writeToStream(streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController; + downloadToStream(streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController; /** - * Same as `writeToStream` but without verification checks. + * Same as `downloadToStream` but without verification checks. * * Use this only for debugging purposes. */ - unsafeWriteToStream( + unsafeDownloadToStream( streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void, ): DownloadController; @@ -34,7 +34,7 @@ export interface FileDownloader { * need to download the entire file. * * Stream doesn't verify data integrity. For the full integrity of - * the file, use `writeToStream` instead. + * the file, use `downloadToStream` instead. * * The stream is not opportunitistically downloading the data ahead of * the time. It will only download the data when it is requested. To diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index 28ebfd7f..74660e38 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -41,7 +41,7 @@ export interface FileRevisionUploader { * * The function will reject if the node with the given name already exists. */ - writeStream( + uploadFromStream( stream: ReadableStream, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, @@ -57,7 +57,7 @@ export interface FileRevisionUploader { * * The function will reject if the node with the given name already exists. */ - writeFile( + uploadFromFile( fileObject: File, thumnbails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index f6ce1488..2ae98009 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -78,7 +78,7 @@ describe('FileDownloader', () => { } as DecryptedRevision; }); - describe('writeToStream', () => { + describe('downloadToStream', () => { let onProgress: (downloadedBytes: number) => void; let onFinish: () => void; @@ -89,7 +89,7 @@ describe('FileDownloader', () => { const verifySuccess = async ( fileProgress: number = 6, // 3 blocks of length 1, 2, 3 ) => { - const controller = downloader.writeToStream(stream, onProgress); + const controller = downloader.downloadToStream(stream, onProgress); await controller.completion(); expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); @@ -103,7 +103,7 @@ describe('FileDownloader', () => { }; const verifyFailure = async (error: string, downloadedBytes: number | undefined) => { - const controller = downloader.writeToStream(stream, onProgress); + const controller = downloader.downloadToStream(stream, onProgress); await expect(controller.completion()).rejects.toThrow(error); @@ -156,9 +156,9 @@ describe('FileDownloader', () => { }); it('should reject two download starts', async () => { - downloader.writeToStream(stream, onProgress); - expect(() => downloader.writeToStream(stream, onProgress)).toThrow('Download already started'); - expect(() => downloader.unsafeWriteToStream(stream, onProgress)).toThrow('Download already started'); + downloader.downloadToStream(stream, onProgress); + expect(() => downloader.downloadToStream(stream, onProgress)).toThrow('Download already started'); + expect(() => downloader.unsafeDownloadToStream(stream, onProgress)).toThrow('Download already started'); }); it('should start a download and write to the stream', async () => { @@ -347,7 +347,7 @@ describe('FileDownloader', () => { }); }); - describe('unsafeWriteToStream', () => { + describe('unsafeDownloadToStream', () => { let onProgress: (downloadedBytes: number) => void; let onFinish: () => void; @@ -381,7 +381,7 @@ describe('FileDownloader', () => { }); it('should skip verification steps', async () => { - const controller = downloader.unsafeWriteToStream(stream, onProgress); + const controller = downloader.unsafeDownloadToStream(stream, onProgress); await controller.completion(); expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index c9f7b333..a63b47db 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -138,24 +138,24 @@ export class FileDownloader { } } - writeToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController { + downloadToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController { if (this.controller.promise) { throw new Error(`Download already started`); } - this.controller.promise = this.downloadToStream(stream, onProgress); + this.controller.promise = this.internalDownloadToStream(stream, onProgress); return this.controller; } - unsafeWriteToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController { + unsafeDownloadToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController { if (this.controller.promise) { throw new Error(`Download already started`); } const ignoreIntegrityErrors = true; - this.controller.promise = this.downloadToStream(stream, onProgress, ignoreIntegrityErrors); + this.controller.promise = this.internalDownloadToStream(stream, onProgress, ignoreIntegrityErrors); return this.controller; } - private async downloadToStream( + private async internalDownloadToStream( stream: WritableStream, onProgress?: (downloadedBytes: number) => void, ignoreIntegrityErrors = false, diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index 75a01b93..23b0c8f6 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -134,7 +134,7 @@ describe('FileUploader', () => { startUploadSpy = jest.spyOn(uploader as any, 'startUpload').mockReturnValue(Promise.resolve('revisionUid')); }); - describe('writeFile', () => { + describe('uploadFromFile', () => { // @ts-expect-error Ignore mocking File const file = { type: 'image/png', @@ -146,48 +146,48 @@ describe('FileUploader', () => { const onProgress = jest.fn(); it('should set media type if not set', async () => { - await uploader.writeFile(file, thumbnails, onProgress); + await uploader.uploadFromFile(file, thumbnails, onProgress); expect(metadata.mediaType).toEqual('image/png'); expect(startUploadSpy).toHaveBeenCalledWith('stream', thumbnails, onProgress); }); it('should set expected size if not set', async () => { - await uploader.writeFile(file, thumbnails, onProgress); + await uploader.uploadFromFile(file, thumbnails, onProgress); expect(metadata.expectedSize).toEqual(file.size); expect(startUploadSpy).toHaveBeenCalledWith('stream', thumbnails, onProgress); }); it('should set modification time if not set', async () => { - await uploader.writeFile(file, thumbnails, onProgress); + await uploader.uploadFromFile(file, thumbnails, onProgress); expect(metadata.modificationTime).toEqual(new Date(123456789)); expect(startUploadSpy).toHaveBeenCalledWith('stream', thumbnails, onProgress); }); it('should throw an error if upload already started', async () => { - await uploader.writeFile(file, thumbnails, onProgress); + await uploader.uploadFromFile(file, thumbnails, onProgress); - await expect(uploader.writeFile(file, thumbnails, onProgress)).rejects.toThrow('Upload already started'); + await expect(uploader.uploadFromFile(file, thumbnails, onProgress)).rejects.toThrow('Upload already started'); }); }); - describe('writeStream', () => { + describe('uploadFromStream', () => { const stream = new ReadableStream(); const thumbnails: Thumbnail[] = []; const onProgress = jest.fn(); it('should start the upload process', async () => { - await uploader.writeStream(stream, thumbnails, onProgress); + await uploader.uploadFromStream(stream, thumbnails, onProgress); expect(startUploadSpy).toHaveBeenCalledWith(stream, thumbnails, onProgress); }); it('should throw an error if upload already started', async () => { - await uploader.writeStream(stream, thumbnails, onProgress); + await uploader.uploadFromStream(stream, thumbnails, onProgress); - await expect(uploader.writeStream(stream, thumbnails, onProgress)).rejects.toThrow( + await expect(uploader.uploadFromStream(stream, thumbnails, onProgress)).rejects.toThrow( 'Upload already started', ); }); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 06a9dce0..bdbfc236 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -46,7 +46,7 @@ class Uploader { this.controller = new UploadController(); } - async writeFile( + async uploadFromFile( fileObject: File, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, @@ -67,7 +67,7 @@ class Uploader { return this.controller; } - async writeStream( + async uploadFromStream( stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 08b9fee5..707e0f0d 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -726,7 +726,7 @@ export class ProtonDriveClient { * ```typescript * const downloader = await client.getFileDownloader(nodeUid, signal); * const claimedSize = fileDownloader.getClaimedSizeInBytes(); - * const downloadController = fileDownloader.writeToStream(stream, (downloadedBytes) => { ... }); + * const downloadController = fileDownloader.downloadToStream(stream, (downloadedBytes) => { ... }); * * signalController.abort(); // to cancel * downloadController.pause(); // to pause @@ -786,7 +786,7 @@ export class ProtonDriveClient { * * ```typescript * const uploader = await client.getFileUploader(parentFolderUid, name, metadata, signal); - * const uploadController = await uploader.writeStream(stream, thumbnails, (uploadedBytes) => { ... }); + * const uploadController = await uploader.uploadFromStream(stream, thumbnails, (uploadedBytes) => { ... }); * * signalController.abort(); // to cancel * uploadController.pause(); // to pause From 5492d73e021b98ca526d8cfee6ef1c9c8c40bd64 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 07:16:44 +0000 Subject: [PATCH 231/791] Add debouncer to avoid parallel loading of the same node --- js/sdk/src/internal/nodes/debouncer.test.ts | 129 ++++++++++++++++++ js/sdk/src/internal/nodes/debouncer.ts | 93 +++++++++++++ js/sdk/src/internal/nodes/nodesAccess.test.ts | 4 +- js/sdk/src/internal/nodes/nodesAccess.ts | 29 +++- 4 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 js/sdk/src/internal/nodes/debouncer.test.ts create mode 100644 js/sdk/src/internal/nodes/debouncer.ts diff --git a/js/sdk/src/internal/nodes/debouncer.test.ts b/js/sdk/src/internal/nodes/debouncer.test.ts new file mode 100644 index 00000000..7897a3af --- /dev/null +++ b/js/sdk/src/internal/nodes/debouncer.test.ts @@ -0,0 +1,129 @@ +import { NodesDebouncer } from './debouncer'; +import { Logger } from '../../interface'; + +describe('NodesDebouncer', () => { + let debouncer: NodesDebouncer; + let mockLogger: jest.Mocked; + + beforeEach(() => { + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + debouncer = new NodesDebouncer(mockLogger); + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + debouncer.clear(); + }); + + it('should register a node for loading and wait for it to finish', async () => { + const nodeUid = 'test-node-1'; + debouncer.loadingNode(nodeUid); + + // Verify that the node is registered by checking if waitForLoadingNode works + const waitPromise = debouncer.waitForLoadingNode(nodeUid); + expect(waitPromise).toBeInstanceOf(Promise); + + // Finish loading to clean up + debouncer.finishedLoadingNode(nodeUid); + await waitPromise; + }); + + it('should allow multiple nodes to be registered', async () => { + const nodeUid1 = 'test-node-1'; + const nodeUid2 = 'test-node-2'; + + debouncer.loadingNode(nodeUid1); + debouncer.loadingNode(nodeUid2); + + const wait1 = debouncer.waitForLoadingNode(nodeUid1); + const wait2 = debouncer.waitForLoadingNode(nodeUid2); + + expect(wait1).toBeInstanceOf(Promise); + expect(wait2).toBeInstanceOf(Promise); + + debouncer.finishedLoadingNode(nodeUid1); + debouncer.finishedLoadingNode(nodeUid2); + await Promise.all([wait1, wait2]); + }); + + it('should register multiple nodes at once', async () => { + const nodeUid1 = 'test-node-1'; + const nodeUid2 = 'test-node-2'; + + debouncer.loadingNodes([nodeUid1, nodeUid2]); + + const wait1 = debouncer.waitForLoadingNode(nodeUid1); + const wait2 = debouncer.waitForLoadingNode(nodeUid2); + + expect(wait1).toBeInstanceOf(Promise); + expect(wait2).toBeInstanceOf(Promise); + + debouncer.finishedLoadingNode(nodeUid1); + debouncer.finishedLoadingNode(nodeUid2); + await Promise.all([wait1, wait2]); + }); + + it('should warn about registering the same node twice', async () => { + const nodeUid = 'test-node-1'; + + // Register the same node twice + debouncer.loadingNode(nodeUid); + debouncer.loadingNode(nodeUid); + + expect(mockLogger.warn).toHaveBeenCalledWith(`debouncer: Loading twice for: ${nodeUid}`); + }); + + it('should timeout', async () => { + const nodeUid = 'test-node-1'; + debouncer.loadingNode(nodeUid); + + jest.advanceTimersByTime(6000); + expect(mockLogger.warn).toHaveBeenCalledWith(`debouncer: Timeout for: ${nodeUid}`); + await expect(debouncer.waitForLoadingNode(nodeUid)).resolves.toBeUndefined(); + }); + + describe('finishedLoadingNode', () => { + it('should handle non-existent node gracefully', async () => { + const nodeUid = 'non-existent-node'; + + expect(() => debouncer.finishedLoadingNode(nodeUid)).not.toThrow(); + }); + + it('should remove node from internal map after finishing', async () => { + const nodeUid = 'test-node-1'; + debouncer.loadingNode(nodeUid); + debouncer.finishedLoadingNode(nodeUid); + + const waitPromise = debouncer.waitForLoadingNode(nodeUid); + await expect(waitPromise).resolves.toBe(undefined); + }); + }); + + describe('waitForLoadingNode', () => { + it('should return immediately for non-registered node', async () => { + const nodeUid = 'non-existent-node'; + + const result = await debouncer.waitForLoadingNode(nodeUid); + expect(result).toBeUndefined(); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + it('should wait for registered node and log debug message', async () => { + const nodeUid = 'test-node-1'; + debouncer.loadingNode(nodeUid); + + const waitPromise = debouncer.waitForLoadingNode(nodeUid); + + expect(mockLogger.debug).toHaveBeenCalledWith(`debouncer: Wait for: ${nodeUid}`); + debouncer.finishedLoadingNode(nodeUid); + await waitPromise; + }); + }); +}); diff --git a/js/sdk/src/internal/nodes/debouncer.ts b/js/sdk/src/internal/nodes/debouncer.ts new file mode 100644 index 00000000..58ea22fa --- /dev/null +++ b/js/sdk/src/internal/nodes/debouncer.ts @@ -0,0 +1,93 @@ +import { Logger } from "../../interface"; +import { LoggerWithPrefix } from '../../telemetry'; + +/** + * The timeout for which the node is considered to be loading. + * If the node is not loaded after this timeout, it is considered to be + * loaded or failed to be loaded, and allowed other places to proceed. + * + * Decrypting many nodes in parallel can take a lot of time, so we allow + * more time for this. + */ +const DEBOUNCE_TIMEOUT = 5000; + +/** + * Helper to avoid loading the same node twice. + * + * Each place that loads a node should report it is being loaded, + * and when it is finished, it should report it is finished. + * The finish must be called even if the node fails to be loaded + * to clear the promise. + * + * Each place that loads a node from cache should first wait for + * the node to be loaded if that is the case. + */ +export class NodesDebouncer { + private promises: Map< + string, + { + promise: Promise; + resolve: () => void; + timeout: NodeJS.Timeout; + } + > = new Map(); + + constructor(private logger: Logger) { + this.logger = new LoggerWithPrefix(logger, 'debouncer'); + } + + loadingNodes(nodeUids: string[]) { + for (const nodeUid of nodeUids) { + this.loadingNode(nodeUid); + } + } + + loadingNode(nodeUid: string) { + const { promise, resolve } = Promise.withResolvers(); + if (this.promises.has(nodeUid)) { + this.logger.warn(`Loading twice for: ${nodeUid}`); + return; + } + + const timeout = setTimeout(() => { + this.logger.warn(`Timeout for: ${nodeUid}`); + this.finishedLoadingNode(nodeUid); + }, DEBOUNCE_TIMEOUT); + this.promises.set(nodeUid, { promise, resolve, timeout }); + } + + finishedLoadingNodes(nodeUids: string[]) { + for (const nodeUid of nodeUids) { + this.finishedLoadingNode(nodeUid); + } + } + + finishedLoadingNode(nodeUid: string) { + const result = this.promises.get(nodeUid); + if (!result) { + return; + } + + clearTimeout(result.timeout); + result.resolve(); + this.promises.delete(nodeUid); + } + + async waitForLoadingNode(nodeUid: string) { + const result = this.promises.get(nodeUid); + if (!result) { + return; + } + + this.logger.debug(`Wait for: ${nodeUid}`); + await result.promise; + } + + clear() { + for (const result of this.promises.values()) { + clearTimeout(result.timeout); + result.resolve(); + } + this.promises.clear(); + } +} diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index c43e4395..d2a4a08d 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -352,7 +352,7 @@ describe('nodesAccess', () => { expect(cache.setFolderChildrenLoaded).not.toHaveBeenCalled(); }); - it.only('should return only filtered nodes from API', async () => { + it('should return only filtered nodes from API', async () => { cache.isFolderChildrenLoaded = jest.fn().mockResolvedValue(false); cache.getNode = jest.fn().mockImplementation((uid: string) => { if (uid === parentNode.uid) { @@ -444,7 +444,7 @@ describe('nodesAccess', () => { const node1 = { uid: 'volumeId~node1', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; const node2 = { uid: 'volumeId~node2', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; const node3 = { uid: 'volumeId~node3', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; - const node4 = { uid: 'volume~node4', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; + const node4 = { uid: 'volumeId~node4', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; it('should serve fully from cache', async () => { cache.iterateNodes = jest.fn().mockImplementation(async function* () { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 909d83bd..f99a3b1b 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -11,6 +11,7 @@ import { NodeAPIService } from './apiService'; import { NodesCache } from './cache'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; +import { NodesDebouncer } from './debouncer'; import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from './extendedAttributes'; import { SharesService, @@ -40,6 +41,8 @@ const DECRYPTION_CONCURRENCY = 30; * nodes metadata. */ export class NodesAccess { + private debouncer: NodesDebouncer; + constructor( private logger: Logger, private apiService: NodeAPIService, @@ -54,6 +57,7 @@ export class NodesAccess { this.cryptoCache = cryptoCache; this.cryptoService = cryptoService; this.shareService = shareService; + this.debouncer = new NodesDebouncer(this.logger); } async getVolumeRootFolder() { @@ -65,6 +69,7 @@ export class NodesAccess { async getNode(nodeUid: string): Promise { let cachedNode; try { + await this.debouncer.waitForLoadingNode(nodeUid); cachedNode = await this.cache.getNode(nodeUid); } catch {} @@ -112,6 +117,7 @@ export class NodesAccess { for await (const nodeUid of this.apiService.iterateChildrenNodeUids(parentNode.uid, onlyFolders, signal)) { let node; try { + await this.debouncer.waitForLoadingNode(nodeUid); node = await this.cache.getNode(nodeUid); } catch {} @@ -143,6 +149,7 @@ export class NodesAccess { for await (const nodeUid of this.apiService.iterateTrashedNodeUids(volumeId, signal)) { let node; try { + await this.debouncer.waitForLoadingNode(nodeUid); node = await this.cache.getNode(nodeUid); } catch {} @@ -208,9 +215,14 @@ export class NodesAccess { } private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { - const { volumeId: ownVolumeId } = await this.shareService.getOwnVolumeIDs(); - const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); - return this.decryptNode(encryptedNode); + this.debouncer.loadingNode(nodeUid); + try { + const { volumeId: ownVolumeId } = await this.shareService.getOwnVolumeIDs(); + const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); + return this.decryptNode(encryptedNode); + } finally { + this.debouncer.finishedLoadingNode(nodeUid); + } } private async *loadNodes( @@ -236,7 +248,14 @@ export class NodesAccess { const { volumeId: ownVolumeId } = await this.shareService.getOwnVolumeIDs(); - const encryptedNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal); + const apiNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal); + + const debouncedNodeMapper = async (encryptedNode: EncryptedNode): Promise => { + this.debouncer.loadingNode(encryptedNode.uid); + return encryptedNode; + }; + const encryptedNodesIterator = asyncIteratorMap(apiNodesIterator, debouncedNodeMapper, 1); + const decryptNodeMapper = async (encryptedNode: EncryptedNode): Promise> => { returnedNodeUids.push(encryptedNode.uid); try { @@ -329,6 +348,7 @@ export class NodesAccess { this.logger.error(`Failed to cache node keys ${node.uid}`, error); } } + this.debouncer.finishedLoadingNode(node.uid); return { node, keys }; } @@ -360,6 +380,7 @@ export class NodesAccess { async getNodeKeys(nodeUid: string): Promise { try { + await this.debouncer.waitForLoadingNode(nodeUid); return await this.cryptoCache.getNodeKeys(nodeUid); } catch { const { keys } = await this.loadNode(nodeUid); From bbd97ce6863dfe0d1b51eb6185529df0d8479d62 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 09:26:45 +0200 Subject: [PATCH 232/791] Add job to deploy NuGet packages --- cs/Directory.Build.targets | 13 +++++++------ cs/Directory.Packages.props | 2 +- cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 3 ++- cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 3 ++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/cs/Directory.Build.targets b/cs/Directory.Build.targets index 28be5357..c0f69dc5 100644 --- a/cs/Directory.Build.targets +++ b/cs/Directory.Build.targets @@ -1,10 +1,11 @@ - - - $(NETCoreSdkRuntimeIdentifier) - $(DefineConstants);WINDOWS - lib - + + + $(NETCoreSdkRuntimeIdentifier) + $(DefineConstants);WINDOWS + lib + + diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 605ef6c1..a11743f0 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -17,7 +17,7 @@ - + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj index daa5dd69..9a5cb9bf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -1,8 +1,9 @@  + true Cloud Storage Volume Folder File - Package that provides the means to interact with the Proton Drive services. + Provides the means to interact with the Proton Drive services. true true snupkg diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index 33a36ddf..577d0389 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -1,8 +1,9 @@  + true Authentication Session Account - Package that provides the means to authenticate with the Proton API and get user account information. + Provides the means to authenticate with the Proton API and get user account information. true true snupkg From 0ceba6ff0ff2dc4c5ecaba6326fbdb1f7b415753 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 10:11:38 +0200 Subject: [PATCH 233/791] Remove block signature verification on download --- .../src/Proton.Drive.Sdk/Api/Files/Block.cs | 7 ---- .../Nodes/Download/BlockDownloader.cs | 33 ++----------------- .../Nodes/Download/RevisionReader.cs | 32 ++---------------- .../Nodes/Upload/BlockUploader.cs | 16 ++++----- 4 files changed, 14 insertions(+), 74 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs index b967a57c..5c36d80d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Cryptography; namespace Proton.Drive.Sdk.Api.Files; @@ -9,10 +8,4 @@ internal sealed class Block [JsonPropertyName("URL")] public required string Url { get; init; } - - [JsonPropertyName("EncSignature")] - public PgpArmoredMessage? EncryptedSignature { get; init; } - - [JsonPropertyName("SignatureEmail")] - public string? SignatureEmailAddress { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index f901356b..82e015be 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -19,14 +19,7 @@ internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) public SemaphoreSlim FileSemaphore { get; } = new(1, 1); public SemaphoreSlim BlockSemaphore { get; } - public async ValueTask<(ReadOnlyMemory HashDigest, PgpVerificationStatus VerificationStatus)> DownloadAsync( - string url, - PgpSessionKey contentKey, - ReadOnlyMemory? encryptedSignature, - PgpPrivateKey signatureDecryptionKey, - PgpKeyRing verificationKeyRing, - Stream outputStream, - CancellationToken cancellationToken) + public async ValueTask> DownloadAsync(string url, PgpSessionKey contentKey, Stream outputStream, CancellationToken cancellationToken) { using var sha256 = SHA256.Create(); @@ -34,35 +27,15 @@ internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) var hashingStream = new CryptoStream(blobStream, sha256, CryptoStreamMode.Read); - // TODO: use array pool for decrypted signature - ArraySegment? signature; - - try - { - signature = encryptedSignature is not null ? (ArraySegment?)signatureDecryptionKey.Decrypt(encryptedSignature.Value.Span) : null; - } - catch (CryptographicException e) - { - throw new NodeMetadataDecryptionException(NodeMetadataPart.BlockSignature, e); - } - - PgpVerificationStatus verificationStatus; - try { await using (hashingStream.ConfigureAwait(false)) { - var decryptingStream = signature is not null - ? contentKey.OpenDecryptingAndVerifyingStream(hashingStream, signature.Value, verificationKeyRing) - : contentKey.OpenDecryptingStream(hashingStream); + var decryptingStream = contentKey.OpenDecryptingStream(hashingStream); await using (decryptingStream.ConfigureAwait(false)) { await decryptingStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); - - using var verificationResult = decryptingStream.GetVerificationResult(); - - verificationStatus = verificationResult.Status; } } } @@ -73,6 +46,6 @@ internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) sha256.TransformFinalBlock([], 0, 0); - return (sha256.Hash, verificationStatus); + return sha256.Hash; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 71300477..f61a279e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -145,15 +145,6 @@ private async Task WriteNextBlockToOutputAsync( try { - if (downloadResult.VerificationStatus is not PgpVerificationStatus.Ok) - { - _client.Logger.LogWarning( - "Verification failed for block #{Index} of file with UID \"{FileUid}\": {VerificationStatus}", - downloadResult.Index, - _fileUid, - downloadResult.VerificationStatus); - } - manifestStream.Write(downloadResult.Sha256Digest.Span); if (downloadResult.IsIntermediateStream) @@ -197,20 +188,9 @@ private async Task DownloadBlockAsync(Block block, Stream c isIntermediateStream = true; } - var signatureVerificationKeyRing = !string.IsNullOrEmpty(block.SignatureEmailAddress) - ? new PgpKeyRing(await _client.Account.GetAddressPublicKeysAsync(block.SignatureEmailAddress, cancellationToken).ConfigureAwait(false)) - : new PgpKeyRing(_fileKey); - - var (hashDigest, verificationStatus) = await _client.BlockDownloader.DownloadAsync( - block.Url, - _contentKey, - block.EncryptedSignature, - _fileKey, - signatureVerificationKeyRing, - blockOutputStream, - cancellationToken).ConfigureAwait(false); + var hashDigest = await _client.BlockDownloader.DownloadAsync(block.Url, _contentKey, blockOutputStream, cancellationToken).ConfigureAwait(false); - return new BlockDownloadResult(block.Index, blockOutputStream, isIntermediateStream, hashDigest, verificationStatus); + return new BlockDownloadResult(block.Index, blockOutputStream, isIntermediateStream, hashDigest); } private async IAsyncEnumerable<(Block Value, bool IsLast)> GetBlocksAsync([EnumeratorCancellation] CancellationToken cancellationToken) @@ -315,17 +295,11 @@ private async Task VerifyManifestAsync(Stream manifestStr return verificationResult.Status; } - private readonly struct BlockDownloadResult( - int index, - Stream stream, - bool isIntermediateStream, - ReadOnlyMemory sha256Digest, - PgpVerificationStatus verificationStatus) + private readonly struct BlockDownloadResult(int index, Stream stream, bool isIntermediateStream, ReadOnlyMemory sha256Digest) { public int Index { get; } = index; public Stream Stream { get; } = stream; public bool IsIntermediateStream { get; } = isIntermediateStream; public ReadOnlyMemory Sha256Digest { get; } = sha256Digest; - public PgpVerificationStatus VerificationStatus { get; } = verificationStatus; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index e97c9168..38a92340 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -98,14 +98,14 @@ public async Task UploadContentAsync( Blocks = [ new BlockCreationRequest - { - Index = index, - Size = (int)dataPacketStream.Length, - HashDigest = sha256Digest, - EncryptedSignature = signature, - VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, - }, - ], + { + Index = index, + Size = (int)dataPacketStream.Length, + HashDigest = sha256Digest, + EncryptedSignature = signature, + VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, + }, + ], Thumbnails = [], }; From 0dd2795b93b1765b40b19e5de6b75da4b59c2d89 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 16:15:52 +0200 Subject: [PATCH 234/791] Fix regression on build and CI pipeline --- cs/Directory.Build.targets | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cs/Directory.Build.targets b/cs/Directory.Build.targets index c0f69dc5..85006ae3 100644 --- a/cs/Directory.Build.targets +++ b/cs/Directory.Build.targets @@ -1,11 +1,9 @@ - - - $(NETCoreSdkRuntimeIdentifier) - $(DefineConstants);WINDOWS - lib - - + + $(NETCoreSdkRuntimeIdentifier) + $(DefineConstants);WINDOWS + lib + From 0bb5b1050697c778f553bcbd620bbe1a5e11c1eb Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 14:40:45 +0000 Subject: [PATCH 235/791] Reuse endpoints for public link --- js/sdk/src/internal/apiService/driveTypes.ts | 79 +++++++--------- js/sdk/src/internal/nodes/apiService.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.ts | 5 +- .../src/internal/sharingPublic/apiService.ts | 91 +------------------ .../src/internal/sharingPublic/cryptoCache.ts | 34 ------- .../internal/sharingPublic/cryptoReporter.ts | 73 +++++++++++++++ .../internal/sharingPublic/cryptoService.ts | 84 +---------------- js/sdk/src/internal/sharingPublic/index.ts | 66 ++++++++++++-- .../src/internal/sharingPublic/interface.ts | 9 -- js/sdk/src/internal/sharingPublic/manager.ts | 86 ------------------ .../sharingPublic/session/apiService.ts | 2 +- .../internal/sharingPublic/session/session.ts | 6 +- .../sharingPublic/session/url.test.ts | 6 +- js/sdk/src/internal/sharingPublic/shares.ts | 82 +++++++++++++++++ js/sdk/src/protonDriveClient.ts | 1 - js/sdk/src/protonDrivePublicLinkClient.ts | 30 ++++-- 16 files changed, 286 insertions(+), 370 deletions(-) create mode 100644 js/sdk/src/internal/sharingPublic/cryptoReporter.ts delete mode 100644 js/sdk/src/internal/sharingPublic/manager.ts create mode 100644 js/sdk/src/internal/sharingPublic/shares.ts diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 095ce79d..0556f05d 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -1540,10 +1540,16 @@ export interface paths { path?: never; cookie?: never; }; - /** Get status of migration from legacy photo share on a regular volume into a new Photo Volume */ + /** + * Get status of migration from legacy photo share on a regular volume into a new Photo Volume + * @deprecated + */ get: operations['get_drive-photos-migrate-legacy']; put?: never; - /** Start migration from legacy photo share on a regular volume into a new Photo Volume */ + /** + * DEPRECATED: All shares have been migrated, always returns share not found + * @deprecated + */ post: operations['post_drive-photos-migrate-legacy']; delete?: never; options?: never; @@ -3502,7 +3508,7 @@ export interface components { Thumbnail: number | null; /** * @deprecated - * @description Hash of thumbnail contents + * @description sha256 hash of thumbnail contents * @default null */ ThumbnailHash: string | null; @@ -3679,7 +3685,6 @@ export interface components { */ Code: 1000; }; - MigrateFromLegacyRequest: Record; RemoveTagsRequestDto: { Tags: components['schemas']['TagType'][]; }; @@ -5097,7 +5102,10 @@ export interface components { File: components['schemas']['FileDto']; /** @default null */ Sharing: components['schemas']['SharingDto'] | null; - /** @default null */ + /** + * @description Will be null if the user is not a member or is the owner. + * @default null + */ Membership: components['schemas']['MembershipDto'] | null; /** @default null */ Folder: null | null; @@ -5109,7 +5117,10 @@ export interface components { Folder: components['schemas']['FolderDto']; /** @default null */ Sharing: components['schemas']['SharingDto'] | null; - /** @default null */ + /** + * @description Will be null if the user is not a member or is the owner. + * @default null + */ Membership: components['schemas']['MembershipDto'] | null; /** @default null */ File: null | null; @@ -5235,7 +5246,7 @@ export interface components { Size: number; /** @description Index of block in list (must be consecutive starting at 1) */ Index: number; - /** @description Hash of encrypted block, base64 encoded */ + /** @description sha256 hash of encrypted block, base64 encoded */ Hash: string; /** @default null */ Verifier: components['schemas']['Verifier'] | null; @@ -5249,7 +5260,7 @@ export interface components { /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ Size: number; Type: components['schemas']['ThumbnailType']; - /** @description Hash of encrypted block, base64 encoded */ + /** @description sha256 hash of encrypted block, base64 encoded */ Hash: string; }; BlockURL: { @@ -5330,7 +5341,7 @@ export interface components { Size: number; /** @description Index of block in list (must be consecutive starting at 1) */ Index: number; - /** @description Hash of encrypted block, base64 encoded */ + /** @description sha256 hash of encrypted block, base64 encoded */ Hash: string; Verifier: components['schemas']['Verifier']; /** @@ -5387,7 +5398,10 @@ export interface components { Folder: components['schemas']['FolderDto2']; /** @default null */ Sharing: components['schemas']['SharingDto2'] | null; - /** @default null */ + /** + * @description Will be null if the user is not a member or is the owner. + * @default null + */ Membership: components['schemas']['MembershipDto2'] | null; /** @default null */ File: null | null; @@ -5400,10 +5414,10 @@ export interface components { */ ShareType: 1 | 2 | 3 | 4; /** - * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
4Migrating
5Migrated
6Locked
+ * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
5Migrated
6Locked
* @enum {integer} */ - ShareState: 1 | 2 | 3 | 4 | 5 | 6; + ShareState: 1 | 2 | 3 | 5 | 6; /** * @description

1=Regular, 2=Photo

See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
* @enum {integer} @@ -5518,6 +5532,7 @@ export interface components { */ Token: string; LinkType: components['schemas']['NodeType3']; + VolumeID: components['schemas']['Id2']; LinkID: components['schemas']['Id2']; SharePasswordSalt: components['schemas']['BinaryString2']; SharePassphrase: components['schemas']['PGPMessage2']; @@ -6921,7 +6936,6 @@ export interface operations { 'application/json': { /** @description Potential codes and their meaning: * - 2500: A volume is already active - * - 2500: Cannot create the new Photo volume. Should be migrated from current Photo stream * - 2001: Invalid PGP message * - 200501: Operation failed: Please retry * - 200200: Address not found @@ -8717,6 +8731,7 @@ export interface operations { | { /** @description Potential codes and their meaning: * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200303: Cannot commit related photo with main already in album * */ Code: number; } @@ -8837,6 +8852,7 @@ export interface operations { | { /** @description Potential codes and their meaning: * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200303: Cannot commit related photo with main already in album * */ Code: number; } @@ -10257,22 +10273,8 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['MigrateFromLegacyRequest']; - }; - }; + requestBody?: never; responses: { - /** @description Accepted */ - 202: { - headers: { - 'x-pm-code': 1002; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['AcceptedResponse']; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -10281,32 +10283,12 @@ export interface operations { content: { 'application/json': { /** @description Potential codes and their meaning: - * - 2500: Migration in progress * - 2501: Share not found - * - 2501: Volume not found - * - 2501: Address not found * */ Code: number; }; }; }; - /** @description Failed dependency */ - 424: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** - * @description Potential codes: - * - 2032 - * - * @enum {integer} - */ - Code: 2032; - }; - }; - }; }; }; 'get_drive-volumes-{volumeID}-photos': { @@ -10482,6 +10464,7 @@ export interface operations { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions. * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200303: Cannot commit related photo with main already in album * */ Code: number; } diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 93c58146..5ffde274 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -128,7 +128,7 @@ export class NodeAPIService { async *iterateNodes( nodeUids: string[], - ownVolumeId: string, + ownVolumeId: string | undefined, filterOptions?: FilterOptions, signal?: AbortSignal, ): AsyncGenerator { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index f99a3b1b..92c626da 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -49,7 +49,10 @@ export class NodesAccess { private cache: NodesCache, private cryptoCache: NodesCryptoCache, private cryptoService: NodesCryptoService, - private shareService: SharesService, + private shareService: Pick< + SharesService, + 'getOwnVolumeIDs' | 'getSharePrivateKey' | 'getContextShareMemberEmailKey' + >, ) { this.logger = logger; this.apiService = apiService; diff --git a/js/sdk/src/internal/sharingPublic/apiService.ts b/js/sdk/src/internal/sharingPublic/apiService.ts index dd295e1b..6f4b5eae 100644 --- a/js/sdk/src/internal/sharingPublic/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/apiService.ts @@ -1,15 +1,11 @@ import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType } from '../apiService'; import { Logger, MemberRole } from '../../interface'; -import { makeNodeUid, splitNodeUid } from '../uids'; -import { EncryptedShareCrypto, EncryptedNode } from './interface'; - -const PAGE_SIZE = 50; +import { makeNodeUid } from '../uids'; +import { EncryptedNode } from '../nodes/interface'; +import { EncryptedShareCrypto } from './interface'; type GetTokenInfoResponse = drivePaths['/drive/urls/{token}']['get']['responses']['200']['content']['application/json']; -type GetTokenFolderChildrenResponse = - drivePaths['/drive/urls/{token}/folders/{linkID}/children']['get']['responses']['200']['content']['application/json']; - /** * Provides API communication for accessing public link data. * @@ -41,27 +37,6 @@ export class SharingPublicAPIService { }, }; } - - async *iterateFolderChildren(parentUid: string, signal?: AbortSignal): AsyncGenerator { - const { volumeId: token, nodeId } = splitNodeUid(parentUid); - - let page = 0; - while (true) { - const response = await this.apiService.get( - `drive/urls/${token}/folders/${nodeId}/children?Page=${page}&PageSize=${PAGE_SIZE}`, - signal, - ); - - for (const link of response.Links) { - yield linkToEncryptedNode(this.logger, token, link); - } - - if (response.Links.length < PAGE_SIZE) { - break; - } - page++; - } - } } function tokenToEncryptedNode(logger: Logger, token: GetTokenInfoResponse['Token']): EncryptedNode { @@ -70,7 +45,7 @@ function tokenToEncryptedNode(logger: Logger, token: GetTokenInfoResponse['Token encryptedName: token.Name, // Basic node metadata - uid: makeNodeUid(token.Token, token.LinkID), + uid: makeNodeUid(token.VolumeID, token.LinkID), parentUid: undefined, type: nodeTypeNumberToNodeType(logger, token.LinkType), creationTime: new Date(), // TODO @@ -115,61 +90,3 @@ function tokenToEncryptedNode(logger: Logger, token: GetTokenInfoResponse['Token throw new Error(`Unknown node type: ${token.LinkType}`); } - -function linkToEncryptedNode( - logger: Logger, - token: string, - link: GetTokenFolderChildrenResponse['Links'][0], -): EncryptedNode { - const baseNodeMetadata = { - // Internal metadata - hash: link.Hash || undefined, - encryptedName: link.Name, - - // Basic node metadata - uid: makeNodeUid(token, link.LinkID), - parentUid: link.ParentLinkID ? makeNodeUid(token, link.ParentLinkID) : undefined, - type: nodeTypeNumberToNodeType(logger, link.Type), - creationTime: new Date(), // TODO - totalStorageSize: link.TotalSize, - - isShared: false, - isSharedPublicly: false, - directRole: MemberRole.Viewer, // TODO - }; - - const baseCryptoNodeMetadata = { - signatureEmail: link.SignatureEmail || undefined, - armoredKey: link.NodeKey, - armoredNodePassphrase: link.NodePassphrase, - armoredNodePassphraseSignature: link.NodePassphraseSignature || undefined, - }; - - if (link.Type === 1 && link.FolderProperties) { - return { - ...baseNodeMetadata, - encryptedCrypto: { - ...baseCryptoNodeMetadata, - folder: { - armoredHashKey: link.FolderProperties.NodeHashKey as string, - }, - }, - }; - } - - if (link.Type === 2 && link.FileProperties?.ContentKeyPacket) { - return { - ...baseNodeMetadata, - totalStorageSize: link.FileProperties.ActiveRevision?.Size || undefined, - mediaType: link.MIMEType || undefined, - encryptedCrypto: { - ...baseCryptoNodeMetadata, - file: { - base64ContentKeyPacket: link.FileProperties.ContentKeyPacket, - }, - }, - }; - } - - throw new Error(`Unknown node type: ${link.Type}`); -} diff --git a/js/sdk/src/internal/sharingPublic/cryptoCache.ts b/js/sdk/src/internal/sharingPublic/cryptoCache.ts index 058db3c4..e2dce2dc 100644 --- a/js/sdk/src/internal/sharingPublic/cryptoCache.ts +++ b/js/sdk/src/internal/sharingPublic/cryptoCache.ts @@ -1,6 +1,5 @@ import { PrivateKey } from '../../crypto'; import { ProtonDriveCryptoCache, Logger } from '../../interface'; -import { DecryptedNodeKeys } from './interface'; /** * Provides caching for public link crypto material. @@ -39,41 +38,8 @@ export class SharingPublicCryptoCache { } return shareKeyData.publicShareKey.key; } - - async setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys): Promise { - const cacheUid = getNodeCacheKey(nodeUid); - await this.driveCache.setEntity(cacheUid, { - nodeKeys: keys, - }); - } - - async getNodeKeys(nodeUid: string): Promise { - const nodeKeysData = await this.driveCache.getEntity(getNodeCacheKey(nodeUid)); - if (!nodeKeysData.nodeKeys) { - try { - await this.removeNodeKeys([nodeUid]); - } catch (removingError: unknown) { - // The node keys will not be returned, thus SDK will re-fetch - // and re-cache it. Setting it again should then fix the problem. - this.logger.warn( - `Failed to remove corrupted public node keys from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, - ); - } - throw new Error(`Failed to deserialize public node keys`); - } - return nodeKeysData.nodeKeys; - } - - async removeNodeKeys(nodeUids: string[]): Promise { - const cacheUids = nodeUids.map(getNodeCacheKey); - await this.driveCache.removeEntities(cacheUids); - } } function getShareKeyCacheKey() { return 'publicShareKey'; } - -function getNodeCacheKey(nodeUid: string) { - return `publicNodeKeys-${nodeUid}`; -} diff --git a/js/sdk/src/internal/sharingPublic/cryptoReporter.ts b/js/sdk/src/internal/sharingPublic/cryptoReporter.ts new file mode 100644 index 00000000..2ae2e866 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/cryptoReporter.ts @@ -0,0 +1,73 @@ +import { c } from 'ttag'; + +import { VERIFICATION_STATUS } from '../../crypto'; +import { getVerificationMessage } from '../errors'; +import { + resultOk, + resultError, + Author, + AnonymousUser, + ProtonDriveTelemetry, + MetricVerificationErrorField, + MetricVolumeType, + MetricsDecryptionErrorField, + Logger, +} from '../../interface'; + +export class SharingPublicCryptoReporter { + private logger: Logger; + private telemetry: ProtonDriveTelemetry; + + constructor(telemetry: ProtonDriveTelemetry) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('sharingPublic-crypto'); + } + + async handleClaimedAuthor( + node: { uid: string; creationTime: Date }, + field: MetricVerificationErrorField, + signatureType: string, + verified: VERIFICATION_STATUS, + verificationErrors?: Error[], + claimedAuthor?: string, + notAvailableVerificationKeys = false, + ): Promise { + if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { + return resultOk(claimedAuthor || (null as AnonymousUser)); + } + + return resultError({ + claimedAuthor, + error: !claimedAuthor + ? c('Info').t`Author is not provided on public link` + : getVerificationMessage(verified, verificationErrors, signatureType, notAvailableVerificationKeys), + }); + } + + reportDecryptionError( + node: { uid: string; creationTime: Date }, + field: MetricsDecryptionErrorField, + error: unknown, + ) { + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); + + this.logger.error( + `Failed to decrypt public link node ${node.uid} (from before 2024: ${fromBefore2024})`, + error, + ); + + this.telemetry.recordMetric({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field, + fromBefore2024, + error, + uid: node.uid, + }); + } + + reportVerificationError() { + // Authors or signatures are not provided on public links. + // We do not report any signature verification errors at this moment. + } +} diff --git a/js/sdk/src/internal/sharingPublic/cryptoService.ts b/js/sdk/src/internal/sharingPublic/cryptoService.ts index 0537e7f6..ccf42d5c 100644 --- a/js/sdk/src/internal/sharingPublic/cryptoService.ts +++ b/js/sdk/src/internal/sharingPublic/cryptoService.ts @@ -1,30 +1,12 @@ -import { c } from 'ttag'; - -import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from '../../crypto'; -import { getVerificationMessage } from '../errors'; -import { - resultOk, - resultError, - Author, - AnonymousUser, - ProtonDriveTelemetry, - MetricVerificationErrorField, - MetricVolumeType, - MetricsDecryptionErrorField, - Logger, - ProtonDriveAccount, -} from '../../interface'; -import { NodesCryptoService } from '../nodes/cryptoService'; +import { DriveCrypto, PrivateKey } from '../../crypto'; import { EncryptedShareCrypto } from './interface'; -export class SharingPublicCryptoService extends NodesCryptoService { +export class SharingPublicCryptoService { constructor( - telemetry: ProtonDriveTelemetry, - driveCrypto: DriveCrypto, - account: ProtonDriveAccount, + private driveCrypto: DriveCrypto, private password: string, ) { - super(telemetry, driveCrypto, account, new SharingPublicCryptoReporter(telemetry)); + this.driveCrypto = driveCrypto; this.password = password; } @@ -38,61 +20,3 @@ export class SharingPublicCryptoService extends NodesCryptoService { return shareKey; } } - -class SharingPublicCryptoReporter { - private logger: Logger; - private telemetry: ProtonDriveTelemetry; - - constructor(telemetry: ProtonDriveTelemetry) { - this.telemetry = telemetry; - this.logger = telemetry.getLogger('sharingPublic-crypto'); - } - - async handleClaimedAuthor( - node: { uid: string; creationTime: Date }, - field: MetricVerificationErrorField, - signatureType: string, - verified: VERIFICATION_STATUS, - verificationErrors?: Error[], - claimedAuthor?: string, - notAvailableVerificationKeys = false, - ): Promise { - if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { - return resultOk(claimedAuthor || (null as AnonymousUser)); - } - - return resultError({ - claimedAuthor, - error: !claimedAuthor - ? c('Info').t`Author is not provided on public link` - : getVerificationMessage(verified, verificationErrors, signatureType, notAvailableVerificationKeys), - }); - } - - reportDecryptionError( - node: { uid: string; creationTime: Date }, - field: MetricsDecryptionErrorField, - error: unknown, - ) { - const fromBefore2024 = node.creationTime < new Date('2024-01-01'); - - this.logger.error( - `Failed to decrypt public link node ${node.uid} (from before 2024: ${fromBefore2024})`, - error, - ); - - this.telemetry.recordMetric({ - eventName: 'decryptionError', - volumeType: MetricVolumeType.SharedPublic, - field, - fromBefore2024, - error, - uid: node.uid, - }); - } - - reportVerificationError() { - // Authors or signatures are not provided on public links. - // We do not report any signature verification errors at this moment. - } -} diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index a830e369..b7f12b3c 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -1,10 +1,21 @@ import { DriveCrypto } from '../../crypto'; -import { ProtonDriveCryptoCache, ProtonDriveTelemetry, ProtonDriveAccount } from '../../interface'; +import { + ProtonDriveCryptoCache, + ProtonDriveTelemetry, + ProtonDriveAccount, + ProtonDriveEntitiesCache, +} from '../../interface'; import { DriveAPIService } from '../apiService'; +import { NodeAPIService } from '../nodes/apiService'; +import { NodesCache } from '../nodes/cache'; +import { NodesCryptoCache } from '../nodes/cryptoCache'; +import { NodesCryptoService } from '../nodes/cryptoService'; +import { NodesAccess } from '../nodes/nodesAccess'; import { SharingPublicAPIService } from './apiService'; import { SharingPublicCryptoCache } from './cryptoCache'; +import { SharingPublicCryptoReporter } from './cryptoReporter'; import { SharingPublicCryptoService } from './cryptoService'; -import { SharingPublicManager } from './manager'; +import { SharingPublicSharesManager } from './shares'; export { SharingPublicSessionManager } from './session/manager'; @@ -20,6 +31,7 @@ export { SharingPublicSessionManager } from './session/manager'; export function initSharingPublicModule( telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, driveCryptoCache: ProtonDriveCryptoCache, driveCrypto: DriveCrypto, account: ProtonDriveAccount, @@ -28,14 +40,54 @@ export function initSharingPublicModule( ) { const api = new SharingPublicAPIService(telemetry.getLogger('sharingPublic-api'), apiService); const cryptoCache = new SharingPublicCryptoCache(telemetry.getLogger('sharingPublic-crypto'), driveCryptoCache); - const cryptoService = new SharingPublicCryptoService(telemetry, driveCrypto, account, password); - const manager = new SharingPublicManager( - telemetry.getLogger('sharingPublic-nodes'), + const cryptoService = new SharingPublicCryptoService(driveCrypto, password); + const shares = new SharingPublicSharesManager(api, cryptoCache, cryptoService, account, token); + const nodes = initSharingPublicNodesModule( + telemetry, + apiService, + driveEntitiesCache, + driveCryptoCache, + driveCrypto, + account, + shares, + ); + + return { + shares, + nodes, + }; +} + +/** + * Provides facade for the public link nodes module. + * + * The public link nodes initializes the core nodes module, but uses public + * link shares or crypto reporter instead. + */ +export function initSharingPublicNodesModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, + driveCrypto: DriveCrypto, + account: ProtonDriveAccount, + sharesService: SharingPublicSharesManager, +) { + const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); + const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); + const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); + const cryptoReporter = new SharingPublicCryptoReporter(telemetry); + const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); + const nodesAccess = new NodesAccess( + telemetry.getLogger('nodes'), api, + cache, cryptoCache, cryptoService, - token, + sharesService, ); - return manager; + return { + access: nodesAccess, + }; } diff --git a/js/sdk/src/internal/sharingPublic/interface.ts b/js/sdk/src/internal/sharingPublic/interface.ts index 563be8af..03267c9b 100644 --- a/js/sdk/src/internal/sharingPublic/interface.ts +++ b/js/sdk/src/internal/sharingPublic/interface.ts @@ -1,12 +1,3 @@ -// TODO: use them directly, or avoid them completely -export type { - EncryptedNode, - EncryptedNodeFolderCrypto, - EncryptedNodeFileCrypto, - DecryptedNode, - DecryptedNodeKeys, -} from '../nodes/interface'; - export interface EncryptedShareCrypto { base64UrlPasswordSalt: string; armoredKey: string; diff --git a/js/sdk/src/internal/sharingPublic/manager.ts b/js/sdk/src/internal/sharingPublic/manager.ts deleted file mode 100644 index 503e1ccc..00000000 --- a/js/sdk/src/internal/sharingPublic/manager.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { PrivateKey } from '../../crypto'; -import { Logger } from '../../interface'; -import { parseNode } from '../nodes/nodesAccess'; -import { SharingPublicAPIService } from './apiService'; -import { SharingPublicCryptoCache } from './cryptoCache'; -import { SharingPublicCryptoService } from './cryptoService'; -import { EncryptedShareCrypto, EncryptedNode, DecryptedNode, DecryptedNodeKeys } from './interface'; - -// TODO: comment -export class SharingPublicManager { - constructor( - private logger: Logger, - private api: SharingPublicAPIService, - private cryptoCache: SharingPublicCryptoCache, - private cryptoService: SharingPublicCryptoService, - private token: string, - ) { - this.logger = logger; - this.api = api; - this.cryptoCache = cryptoCache; - this.cryptoService = cryptoService; - this.token = token; - } - - async getRootNode(): Promise { - const { encryptedNode, encryptedShare } = await this.api.getPublicLinkRoot(this.token); - await this.decryptShare(encryptedShare); - return this.decryptNode(encryptedNode); - } - - async *iterateFolderChildren(parentUid: string, signal?: AbortSignal): AsyncGenerator { - // TODO: optimise this - decrypt in parallel - for await (const node of this.api.iterateFolderChildren(parentUid, signal)) { - const decryptedNode = await this.decryptNode(node); - yield decryptedNode; - } - } - - private async decryptShare(encryptedShare: EncryptedShareCrypto): Promise { - const shareKey = await this.cryptoService.decryptPublicLinkShareKey(encryptedShare); - await this.cryptoCache.setShareKey(shareKey); - } - - private async decryptNode(encryptedNode: EncryptedNode): Promise { - const parentKey = await this.getParentKey(encryptedNode); - - const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); - const node = await parseNode(this.logger, unparsedNode); - - // TODO: cache of metadata? - if (keys) { - try { - await this.cryptoCache.setNodeKeys(node.uid, keys); - } catch (error: unknown) { - this.logger.error(`Failed to cache node keys ${node.uid}`, error); - } - } - - return node; - } - - private async getParentKey(node: Pick): Promise { - if (node.parentUid) { - // TODO: try-catch - const keys = await this.getNodeKeys(node.parentUid); - return keys.key; - } - - try { - return await this.cryptoCache.getShareKey(); - } catch { - await this.getRootNode(); - return this.cryptoCache.getShareKey(); - } - } - - async getNodeKeys(nodeUid: string): Promise { - try { - const keys = await this.cryptoCache.getNodeKeys(nodeUid); - return keys; - } catch { - // TODO: handle this - throw new Error('Node key not found in cache'); - } - } -} diff --git a/js/sdk/src/internal/sharingPublic/session/apiService.ts b/js/sdk/src/internal/sharingPublic/session/apiService.ts index fb0edffb..12c7f6a2 100644 --- a/js/sdk/src/internal/sharingPublic/session/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/session/apiService.ts @@ -9,7 +9,7 @@ type PostPublicLinkAuthRequest = Extract< { content: object } >['content']['application/json']; type PostPublicLinkAuthResponse = - drivePaths['/drive/urls/{token}/auth']['post']['responses']['200']['content']['application/json']; + drivePaths['/drive/urls/{token}/auth']['post']['responses']['200']['content']['application/json']; /** * Provides API communication for managing public link session (not data). diff --git a/js/sdk/src/internal/sharingPublic/session/session.ts b/js/sdk/src/internal/sharingPublic/session/session.ts index f16f2bb8..d683420b 100644 --- a/js/sdk/src/internal/sharingPublic/session/session.ts +++ b/js/sdk/src/internal/sharingPublic/session/session.ts @@ -1,6 +1,6 @@ -import { SRPModule } from "../../../crypto"; -import { SharingPublicSessionAPIService } from "./apiService"; -import { PublicLinkInfo, PublicLinkSrpInfo } from "./interface"; +import { SRPModule } from '../../../crypto'; +import { SharingPublicSessionAPIService } from './apiService'; +import { PublicLinkInfo, PublicLinkSrpInfo } from './interface'; /** * Session for a public link. diff --git a/js/sdk/src/internal/sharingPublic/session/url.test.ts b/js/sdk/src/internal/sharingPublic/session/url.test.ts index a8841b36..06864318 100644 --- a/js/sdk/src/internal/sharingPublic/session/url.test.ts +++ b/js/sdk/src/internal/sharingPublic/session/url.test.ts @@ -9,7 +9,7 @@ describe('getTokenAndPasswordFromUrl', () => { expect(result).toEqual({ token: 'abc123', - password: 'def456' + password: 'def456', }); }); @@ -19,7 +19,7 @@ describe('getTokenAndPasswordFromUrl', () => { expect(result).toEqual({ token: 'mytoken', - password: 'mypassword' + password: 'mypassword', }); }); @@ -29,7 +29,7 @@ describe('getTokenAndPasswordFromUrl', () => { expect(result).toEqual({ token: 'token123', - password: 'password456' + password: 'password456', }); }); }); diff --git a/js/sdk/src/internal/sharingPublic/shares.ts b/js/sdk/src/internal/sharingPublic/shares.ts new file mode 100644 index 00000000..4398af3b --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/shares.ts @@ -0,0 +1,82 @@ +import { PrivateKey } from '../../crypto'; +import { ProtonDriveAccount } from '../../interface'; +import { splitNodeUid } from '../uids'; +import { SharingPublicAPIService } from './apiService'; +import { SharingPublicCryptoCache } from './cryptoCache'; +import { SharingPublicCryptoService } from './cryptoService'; + +/** + * Provides high-level actions for managing public link share. + * + * The public link share manager provides the same interface as the code share + * service so it can be used in the same way in various modules that use shares. + */ +export class SharingPublicSharesManager { + private promisePublicLinkRoot?: Promise<{ + rootIds: { volumeId: string; rootNodeId: string; rootNodeUid: string }; + shareKey: PrivateKey; + }>; + + constructor( + private apiService: SharingPublicAPIService, + private cryptoCache: SharingPublicCryptoCache, + private cryptoService: SharingPublicCryptoService, + private account: ProtonDriveAccount, + private token: string, + ) { + this.apiService = apiService; + this.cryptoCache = cryptoCache; + this.cryptoService = cryptoService; + this.account = account; + this.token = token; + } + + // TODO: Rename to getRootIDs everywhere. + async getOwnVolumeIDs(): Promise<{ volumeId: string; rootNodeId: string; rootNodeUid: string }> { + const { rootIds } = await this.getPublicLinkRoot(); + return rootIds; + } + + async getSharePrivateKey(): Promise { + const { shareKey } = await this.getPublicLinkRoot(); + return shareKey; + } + + private async getPublicLinkRoot(): Promise<{ + rootIds: { volumeId: string; rootNodeId: string; rootNodeUid: string }; + shareKey: PrivateKey; + }> { + if (!this.promisePublicLinkRoot) { + this.promisePublicLinkRoot = (async () => { + const { encryptedNode, encryptedShare } = await this.apiService.getPublicLinkRoot(this.token); + + const { volumeId, nodeId: rootNodeId } = splitNodeUid(encryptedNode.uid); + + const shareKey = await this.cryptoService.decryptPublicLinkShareKey(encryptedShare); + await this.cryptoCache.setShareKey(shareKey); + + return { + rootIds: { volumeId, rootNodeId, rootNodeUid: encryptedNode.uid }, + shareKey, + }; + })(); + } + + return this.promisePublicLinkRoot; + } + + async getContextShareMemberEmailKey(): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }> { + const address = await this.account.getOwnPrimaryAddress(); + return { + email: address.email, + addressId: address.addressId, + addressKey: address.keys[address.primaryKeyIndex].key, + addressKeyId: address.keys[address.primaryKeyIndex].id, + }; + } +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 707e0f0d..826d458f 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -208,7 +208,6 @@ export class ProtonDriveClient { const { httpClient, token, password } = await this.sessionManager.auth(url, customPassword); return new ProtonDrivePublicLinkClient({ httpClient, - cryptoCache, account, openPGPCryptoModule, srpModule, diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 3b2ce8ee..d8069792 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -1,3 +1,4 @@ +import { MemoryCache } from './cache'; import { getConfig } from './config'; import { DriveCrypto, OpenPGPCrypto, SRPModule, SessionKey } from './crypto'; import { @@ -5,10 +6,11 @@ import { ProtonDriveTelemetry, ProtonDriveConfig, Logger, - ProtonDriveCryptoCache, NodeOrUid, ProtonDriveAccount, MaybeNode, + NodeType, + CachedCryptoMaterial, } from './interface'; import { Telemetry } from './telemetry'; import { getUid, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; @@ -53,7 +55,6 @@ export class ProtonDrivePublicLinkClient { constructor({ httpClient, - cryptoCache, account, openPGPCryptoModule, srpModule, @@ -63,7 +64,6 @@ export class ProtonDrivePublicLinkClient { password, }: { httpClient: ProtonDriveHTTPClient; - cryptoCache: ProtonDriveCryptoCache; account: ProtonDriveAccount; openPGPCryptoModule: OpenPGPCrypto; srpModule: SRPModule; @@ -77,6 +77,10 @@ export class ProtonDrivePublicLinkClient { } this.logger = telemetry.getLogger('interface'); + // Use only in memory cache for public link as there are no events to keep it up to date if persisted. + const entitiesCache = new MemoryCache(); + const cryptoCache = new MemoryCache(); + const fullConfig = getConfig(config); this.sdkEvents = new SDKEvents(telemetry); @@ -87,12 +91,13 @@ export class ProtonDrivePublicLinkClient { fullConfig.baseUrl, fullConfig.language, ); - const driveCrypto = new DriveCrypto(openPGPCryptoModule, srpModule); + const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); this.sharingPublic = initSharingPublicModule( telemetry, apiService, + entitiesCache, cryptoCache, - driveCrypto, + cryptoModule, account, token, password, @@ -106,7 +111,7 @@ export class ProtonDrivePublicLinkClient { }, getDocsKey: async (nodeUid: NodeOrUid) => { this.logger.debug(`Getting docs keys for ${getUid(nodeUid)}`); - const keys = await this.sharingPublic.getNodeKeys(getUid(nodeUid)); + const keys = await this.sharingPublic.nodes.access.getNodeKeys(getUid(nodeUid)); if (!keys.contentKeyPacketSessionKey) { throw new Error('Node does not have a content key packet session key'); } @@ -120,7 +125,8 @@ export class ProtonDrivePublicLinkClient { */ async getRootNode(): Promise { this.logger.info(`Getting root node`); - return convertInternalNodePromise(this.sharingPublic.getRootNode()); + const { rootNodeUid } = await this.sharingPublic.shares.getOwnVolumeIDs(); + return convertInternalNodePromise(this.sharingPublic.nodes.access.getNode(rootNodeUid)); } /** @@ -128,8 +134,14 @@ export class ProtonDrivePublicLinkClient { * * See `ProtonDriveClient.iterateFolderChildren` for more information. */ - async *iterateFolderChildren(parentUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + async *iterateFolderChildren( + parentUid: NodeOrUid, + filterOptions?: { type?: NodeType }, + signal?: AbortSignal, + ): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentUid)}`); - yield * convertInternalNodeIterator(this.sharingPublic.iterateFolderChildren(getUid(parentUid), signal)); + yield* convertInternalNodeIterator( + this.sharingPublic.nodes.access.iterateFolderChildren(getUid(parentUid), filterOptions, signal), + ); } } From 9e3f109eb1e6f0a990ca2612d4710196b257d578 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 26 Sep 2025 07:41:37 +0000 Subject: [PATCH 236/791] Add CLI commands for public access --- js/sdk/src/internal/sharingPublic/index.ts | 14 +++- js/sdk/src/internal/sharingPublic/nodes.ts | 37 ++++++++++ js/sdk/src/internal/sharingPublic/shares.ts | 6 +- js/sdk/src/protonDriveClient.ts | 1 + js/sdk/src/protonDrivePublicLinkClient.ts | 75 ++++++++++++++++++++- 5 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 js/sdk/src/internal/sharingPublic/nodes.ts diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index b7f12b3c..53bc860b 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -10,11 +10,12 @@ import { NodeAPIService } from '../nodes/apiService'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; -import { NodesAccess } from '../nodes/nodesAccess'; +import { NodesRevisons } from '../nodes/nodesRevisions'; import { SharingPublicAPIService } from './apiService'; import { SharingPublicCryptoCache } from './cryptoCache'; import { SharingPublicCryptoReporter } from './cryptoReporter'; import { SharingPublicCryptoService } from './cryptoService'; +import { SharingPublicNodesAccess } from './nodes'; import { SharingPublicSharesManager } from './shares'; export { SharingPublicSessionManager } from './session/manager'; @@ -35,6 +36,7 @@ export function initSharingPublicModule( driveCryptoCache: ProtonDriveCryptoCache, driveCrypto: DriveCrypto, account: ProtonDriveAccount, + url: string, token: string, password: string, ) { @@ -50,6 +52,8 @@ export function initSharingPublicModule( driveCrypto, account, shares, + url, + token, ); return { @@ -72,22 +76,28 @@ export function initSharingPublicNodesModule( driveCrypto: DriveCrypto, account: ProtonDriveAccount, sharesService: SharingPublicSharesManager, + url: string, + token: string, ) { const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new SharingPublicCryptoReporter(telemetry); const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); - const nodesAccess = new NodesAccess( + const nodesAccess = new SharingPublicNodesAccess( telemetry.getLogger('nodes'), api, cache, cryptoCache, cryptoService, sharesService, + url, + token, ); + const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); return { access: nodesAccess, + revisions: nodesRevisions, }; } diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts new file mode 100644 index 00000000..aaeb6084 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -0,0 +1,37 @@ +import { Logger } from "../../interface"; +import { NodeAPIService } from "../nodes/apiService"; +import { NodesCache } from "../nodes/cache"; +import { NodesCryptoCache } from "../nodes/cryptoCache"; +import { NodesCryptoService } from "../nodes/cryptoService"; +import { NodesAccess } from "../nodes/nodesAccess"; +import { isProtonDocument, isProtonSheet } from "../nodes/mediaTypes"; +import { splitNodeUid } from "../uids"; +import { SharingPublicSharesManager } from "./shares"; + +export class SharingPublicNodesAccess extends NodesAccess { + constructor( + logger: Logger, + apiService: NodeAPIService, + cache: NodesCache, + cryptoCache: NodesCryptoCache, + cryptoService: NodesCryptoService, + sharesService: SharingPublicSharesManager, + private url: string, + private token: string, + ) { + super(logger, apiService, cache, cryptoCache, cryptoService, sharesService); + this.token = token; + } + + async getNodeUrl(nodeUid: string): Promise { + const node = await this.getNode(nodeUid); + if (isProtonDocument(node.mediaType) || isProtonSheet(node.mediaType)) { + const { nodeId } = splitNodeUid(nodeUid); + const type = isProtonDocument(node.mediaType) ? 'doc' : 'sheet'; + return `https://docs.proton.me/doc?type=${type}&mode=open-url&token=${this.token}&linkId=${nodeId}`; + } + + // Public link doesn't support specific node URLs. + return this.url; + } +} diff --git a/js/sdk/src/internal/sharingPublic/shares.ts b/js/sdk/src/internal/sharingPublic/shares.ts index 4398af3b..f2d13e8b 100644 --- a/js/sdk/src/internal/sharingPublic/shares.ts +++ b/js/sdk/src/internal/sharingPublic/shares.ts @@ -1,5 +1,5 @@ import { PrivateKey } from '../../crypto'; -import { ProtonDriveAccount } from '../../interface'; +import { MetricVolumeType, ProtonDriveAccount } from '../../interface'; import { splitNodeUid } from '../uids'; import { SharingPublicAPIService } from './apiService'; import { SharingPublicCryptoCache } from './cryptoCache'; @@ -79,4 +79,8 @@ export class SharingPublicSharesManager { addressKeyId: address.keys[address.primaryKeyIndex].id, }; } + + async getVolumeMetricContext(): Promise { + return MetricVolumeType.SharedPublic; + } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 826d458f..41bbcb4b 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -213,6 +213,7 @@ export class ProtonDriveClient { srpModule, config, telemetry, + url, token, password, }); diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index d8069792..73af5d74 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -11,10 +11,21 @@ import { MaybeNode, NodeType, CachedCryptoMaterial, + MaybeMissingNode, + FileDownloader, + ThumbnailType, + ThumbnailResult, } from './interface'; import { Telemetry } from './telemetry'; -import { getUid, convertInternalNodePromise, convertInternalNodeIterator } from './transformers'; +import { + getUid, + convertInternalNodePromise, + convertInternalNodeIterator, + convertInternalMissingNodeIterator, + getUids, +} from './transformers'; import { DriveAPIService } from './internal/apiService'; +import { initDownloadModule } from './internal/download'; import { SDKEvents } from './internal/sdkEvents'; import { initSharingPublicModule } from './internal/sharingPublic'; @@ -35,6 +46,7 @@ export class ProtonDrivePublicLinkClient { private logger: Logger; private sdkEvents: SDKEvents; private sharingPublic: ReturnType; + private download: ReturnType; public experimental: { /** @@ -60,6 +72,7 @@ export class ProtonDrivePublicLinkClient { srpModule, config, telemetry, + url, token, password, }: { @@ -69,6 +82,7 @@ export class ProtonDrivePublicLinkClient { srpModule: SRPModule; config?: ProtonDriveConfig; telemetry?: ProtonDriveTelemetry; + url: string; token: string; password: string; }) { @@ -99,15 +113,24 @@ export class ProtonDrivePublicLinkClient { cryptoCache, cryptoModule, account, + url, token, password, ); + this.download = initDownloadModule( + telemetry, + apiService, + cryptoModule, + account, + this.sharingPublic.shares, + this.sharingPublic.nodes.access, + this.sharingPublic.nodes.revisions, + ); this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); - // TODO: public node has different URL - return ''; + return this.sharingPublic.nodes.access.getNodeUrl(getUid(nodeUid)); }, getDocsKey: async (nodeUid: NodeOrUid) => { this.logger.debug(`Getting docs keys for ${getUid(nodeUid)}`); @@ -144,4 +167,50 @@ export class ProtonDrivePublicLinkClient { this.sharingPublic.nodes.access.iterateFolderChildren(getUid(parentUid), filterOptions, signal), ); } + + /** + * Iterates the nodes by their UIDs. + * + * See `ProtonDriveClient.iterateNodes` for more information. + */ + async *iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Iterating ${nodeUids.length} nodes`); + yield* convertInternalMissingNodeIterator( + this.sharingPublic.nodes.access.iterateNodes(getUids(nodeUids), signal), + ); + } + + /** + * Get the node by its UID. + * + * See `ProtonDriveClient.getNode` for more information. + */ + async getNode(nodeUid: NodeOrUid): Promise { + this.logger.info(`Getting node ${getUid(nodeUid)}`); + return convertInternalNodePromise(this.sharingPublic.nodes.access.getNode(getUid(nodeUid))); + } + + /** + * Get the file downloader to download the node content. + * + * See `ProtonDriveClient.getFileDownloader` for more information. + */ + async getFileDownloader(nodeUid: NodeOrUid, signal?: AbortSignal): Promise { + this.logger.info(`Getting file downloader for ${getUid(nodeUid)}`); + return this.download.getFileDownloader(getUid(nodeUid), signal); + } + + /** + * Iterates the thumbnails of the given nodes. + * + * See `ProtonDriveClient.iterateThumbnails` for more information. + */ + async *iterateThumbnails( + nodeUids: NodeOrUid[], + thumbnailType?: ThumbnailType, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating ${nodeUids.length} thumbnails`); + yield* this.download.iterateThumbnails(getUids(nodeUids), thumbnailType, signal); + } } From 9fd46320239e54baa92a9ccfbe235932efbf4642 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 26 Sep 2025 14:52:28 +0200 Subject: [PATCH 237/791] Guard against invalid response when reading a stream through interop --- .../InteropProtonDriveClient.cs | 2 +- .../InteropStream.cs | 5 ++ .../Nodes/Upload/RevisionWriter.cs | 22 ++++--- cs/sdk/src/protos/proton.drive.sdk.proto | 62 +++++++++---------- cs/sdk/src/protos/proton.sdk.proto | 18 +++--- 5 files changed, 56 insertions(+), 53 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 9cef656e..4f0c3131 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -72,7 +72,7 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive var fileUploader = await client.GetFileRevisionUploaderAsync( RevisionUid.Parse(request.CurrentActiveRevisionUid), - request.FileSize, + request.Size, request.LastModificationTime.ToDateTime(), cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs index 5c507bba..e1411dbe 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs @@ -63,6 +63,11 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation var response = await _readAction.Value.InvokeWithBufferAsync(_callerState, buffer.Span).ConfigureAwait(false); + if (response.Value < 0) + { + throw new IOException($"Invalid number of bytes read: {response.Value}"); + } + _position += response.Value; return response.Value; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 7e77f2ce..0adc6935 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -76,6 +76,7 @@ public async ValueTask WriteAsync( var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_fileUid, _revisionId, _fileKey, cancellationToken).ConfigureAwait(false); using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var linkedCancellationToken = cancellationTokenSource.Token; try { @@ -83,7 +84,7 @@ public async ValueTask WriteAsync( { foreach (var thumbnail in thumbnails) { - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); var uploadTask = _client.BlockUploader.UploadThumbnailAsync( _fileUid, @@ -102,17 +103,20 @@ public async ValueTask WriteAsync( { do { - var plainDataPrefix = ArrayPool.Shared.Rent(blockVerifier.DataPacketPrefixMaxLength); + var plainDataPrefixBuffer = ArrayPool.Shared.Rent(blockVerifier.DataPacketPrefixMaxLength); try { var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await contentStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plainDataPrefix, cancellationTokenSource.Token) - .ConfigureAwait(false); + var bytesCopied = await contentStream.PartiallyCopyToAsync( + plainDataStream, + _targetBlockSize, + plainDataPrefixBuffer, + linkedCancellationToken).ConfigureAwait(false); blockSizes.Add((int)plainDataStream.Length); - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); plainDataStream.Seek(0, SeekOrigin.Begin); @@ -126,21 +130,21 @@ await contentStream.PartiallyCopyToAsync(plainDataStream, _targetBlockSize, plai _fileKey, plainDataStream, blockVerifier, - plainDataPrefix, - (int)Math.Min(blockVerifier.DataPacketPrefixMaxLength, plainDataStream.Length), + plainDataPrefixBuffer, + Math.Min(blockVerifier.DataPacketPrefixMaxLength, bytesCopied), progress => { numberOfBytesUploaded += progress; onProgress(numberOfBytesUploaded, contentStream.Length); }, _releaseBlocksAction, - cancellationTokenSource.Token); + linkedCancellationToken); uploadTasks.Enqueue(uploadTask); } catch { - ArrayPool.Shared.Return(plainDataPrefix); + ArrayPool.Shared.Return(plainDataPrefixBuffer); throw; } } while (contentStream.Position < contentStream.Length); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 40b64764..90d45bad 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -54,26 +54,26 @@ message AccountClientRequest { }; } -// The response value type must be Address. +// The response value must be an Address. message GetAddressRequest { string address_id = 1; } -// The response value type must be Address. +// The response value must be an Address. message GetDefaultAddressRequest { } -// The response value type must be BytesValue. +// The response value must be a BytesValue. message GetAddressPrimaryPrivateKeyRequest { string address_id = 1; } -// The response value type must be RepeatedBytesValue. +// The response value must be a RepeatedBytesValue. message GetAddressPrivateKeysRequest { string address_id = 1; } -// The response value type must be RepeatedBytesValue. +// The response value must be a RepeatedBytesValue. message GetAddressPublicKeysRequest { string email_address = 1; } @@ -97,12 +97,6 @@ message FileNode { FileRevision active_revision = 6; } -message RevisionUploaderProvisionRequest { - string file_uid = 1; - int64 file_size = 2; - google.protobuf.Timestamp last_modification_time = 3; -} - message ProgressUpdate { int64 bytes_completed = 1; int64 bytes_in_total = 2; @@ -119,7 +113,7 @@ message UploadResult { string revision_uid = 2; } -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. message DriveClientCreateRequest { string base_url = 1; string bindings_language = 2; // Optional @@ -137,19 +131,19 @@ message DriveClientCreateRequest { int64 logger_provider_handle = 7; } -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. message DriveClientCreateFromSessionRequest { int64 session_handle = 1; } -// No response value. +// The reponse must not have a value. message DriveClientFreeRequest { int64 client_handle = 1; } // Drive - uploads -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of FileUploader. message DriveClientGetFileUploaderRequest { int64 client_handle = 1; string parent_folder_uid = 2; @@ -161,16 +155,16 @@ message DriveClientGetFileUploaderRequest { int64 cancellation_token_source_handle = 8; } -// The response value will be an Int64Value message. +// The response value must be an Int64Value carrying a handle to an instance of FileUploader. message DriveClientGetFileRevisionUploaderRequest { int64 client_handle = 1; string current_active_revision_uid = 2; - int64 file_size = 3; + int64 size = 3; google.protobuf.Timestamp last_modification_time = 4; int64 cancellation_token_source_handle = 5; } -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of UploadController. message UploadFromStreamRequest { int64 uploader_handle = 1; repeated Thumbnail thumbnails = 2; @@ -179,7 +173,7 @@ message UploadFromStreamRequest { int64 cancellation_token_source_handle = 5; } -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of UploadController. message UploadFromFileRequest { int64 uploader_handle = 1; repeated Thumbnail thumbnails = 2; @@ -188,41 +182,41 @@ message UploadFromFileRequest { int64 cancellation_token_source_handle = 5; } -// No response value. +// The reponse must not have a value. message FileUploaderFreeRequest { int64 file_uploader_handle = 1; } -// The response value will be a UploadResult message containing the new revision UID. +// The response message must be of type UploadResult. message UploadControllerAwaitCompletionRequest { int64 upload_controller_handle = 1; } -// No response value. +// The reponse must not have a value. message UploadControllerPauseRequest { int64 upload_controller_handle = 1; } -// No response value. +// The reponse must not have a value. message UploadControllerResumeRequest { int64 upload_controller_handle = 1; } -// No response value. +// The reponse must not have a value. message UploadControllerFreeRequest { int64 upload_controller_handle = 1; } // Drive - downloads -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of FileDownloader. message DriveClientGetFileDownloaderRequest { int64 client_handle = 1; string revision_uid = 2; int64 cancellation_token_source_handle = 3; } -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of DownloadController. message DownloadToStreamRequest { int64 downloader_handle = 1; int64 write_action = 2; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); @@ -230,7 +224,7 @@ message DownloadToStreamRequest { int64 cancellation_token_source_handle = 4; } -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of DownloadController. message DownloadToFileRequest { int64 downloader_handle = 1; string file_path = 2; @@ -238,38 +232,38 @@ message DownloadToFileRequest { int64 cancellation_token_source_handle = 4; } -// No response value. +// The reponse must not have a value. message FileDownloaderFreeRequest { int64 file_downloader_handle = 1; } -// No response value. +// The reponse must not have a value. message DownloadControllerAwaitCompletionRequest { int64 download_controller_handle = 1; } -// No response value. +// The reponse must not have a value. message DownloadControllerPauseRequest { int64 download_controller_handle = 1; } -// No response value. +// The reponse must not have a value. message DownloadControllerResumeRequest { int64 download_controller_handle = 1; } -// No response value. +// The reponse must not have a value. message DownloadControllerFreeRequest { int64 download_controller_handle = 1; } -// The response value type must be Int32Value. +// The response value must be an Int32Value carrying the number of bytes read into the buffer. That number must not be negative. message StreamReadRequest { int64 buffer_pointer = 1; int32 buffer_length = 2; } -// No response value. +// The reponse must not have a value. message StreamWriteRequest { int64 data_pointer = 1; int32 data_length = 2; diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto index a830a9c6..06f6f552 100644 --- a/cs/sdk/src/protos/proton.sdk.proto +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -37,15 +37,15 @@ message RepeatedBytesValue { // Cancellation tokens -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of CancellationTokenSource. message CancellationTokenSourceCreateRequest {} -// No response value. +// The reponse must not have a value. message CancellationTokenSourceCancelRequest { int64 cancellation_token_source_handle = 1; } -// No response value. +// The reponse must not have a value. message CancellationTokenSourceFreeRequest { int64 cancellation_token_source_handle = 1; } @@ -88,28 +88,28 @@ message SessionRenewRequest { bool is_waiting_for_data_password = 7; } -// No response value. +// The reponse must not have a value. message SessionEndRequest { int64 session_handle = 1; } -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of TokensRefreshedSubscription. message SessionTokensRefreshedSubscribeRequest { int64 session_handle = 1; int64 tokens_refreshed_action = 2; } -// No response value. +// The reponse must not have a value. message SessionTokensRefreshedUnsubscribeRequest { int64 subscription_handle = 1; } -// No response value. +// The reponse must not have a value. message SessionFreeRequest { int64 session_handle = 1; } -// The response value type must be Int64Value. +// The response value must be an Int64Value carrying a handle to an instance of ILoggerProvider. message LoggerProviderCreate { int64 log_action = 1; } @@ -204,7 +204,7 @@ message HttpHeader { repeated string values = 2; } -// The response value type must be HttpResponse. +// The response value must be an HttpResponse. message HttpRequest { string url = 1; string method = 2; From 220be3c386c49eef584a96e37545d937e464e4bb Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 29 Sep 2025 11:38:48 +0000 Subject: [PATCH 238/791] [JS] Use the same instance of uploadController in stream upload --- js/sdk/src/internal/photos/upload.ts | 5 ++++- js/sdk/src/internal/upload/fileUploader.ts | 1 + js/sdk/src/internal/upload/streamUploader.test.ts | 10 ++++++++-- js/sdk/src/internal/upload/streamUploader.ts | 3 ++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index f41e580b..803c7809 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -5,6 +5,7 @@ import { generateFileExtendedAttributes } from '../nodes'; import { splitNodeRevisionUid } from '../uids'; import { UploadAPIService } from '../upload/apiService'; import { BlockVerifier } from '../upload/blockVerifier'; +import { UploadController } from '../upload/controller'; import { UploadCryptoService } from '../upload/cryptoService'; import { FileUploader } from '../upload/fileUploader'; import { NodeRevisionDraft, NodesService } from '../upload/interface'; @@ -62,6 +63,7 @@ export class PhotoFileUploader extends FileUploader { revisionDraft, this.photoMetadata, onFinish, + this.controller, this.signal, ); } @@ -80,9 +82,10 @@ export class PhotoStreamUploader extends StreamUploader { revisionDraft: NodeRevisionDraft, metadata: PhotoUploadMetadata, onFinish: (failure: boolean) => Promise, + controller: UploadController, signal?: AbortSignal, ) { - super(telemetry, apiService, cryptoService, uploadManager, blockVerifier, revisionDraft, metadata, onFinish, signal); + super(telemetry, apiService, cryptoService, uploadManager, blockVerifier, revisionDraft, metadata, onFinish, controller, signal); this.photoUploadManager = uploadManager; this.photoMetadata = metadata; } diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index bdbfc236..d2080d2f 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -119,6 +119,7 @@ class Uploader { revisionDraft, this.metadata, onFinish, + this.controller, this.signal, ); } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 5fd84ac8..c71a0e60 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -131,6 +131,7 @@ describe('StreamUploader', () => { revisionDraft, metadata, onFinish, + controller, abortController.signal, ); }); @@ -251,6 +252,8 @@ describe('StreamUploader', () => { revisionDraft, metadata, onFinish, + controller, + abortController.signal, ); await verifySuccess(); @@ -278,6 +281,8 @@ describe('StreamUploader', () => { revisionDraft, metadata, onFinish, + controller, + abortController.signal, ); await verifySuccess(); @@ -459,9 +464,10 @@ describe('StreamUploader', () => { { // Fake expected size to break verification expectedSize: 1 * 1024 * 1024 + 1024, - mediaType: '', - }, + } as UploadMetadata, onFinish, + controller, + abortController.signal, ); await verifyFailure( diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index bb09c4e5..34517d99 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -84,6 +84,7 @@ export class StreamUploader { protected revisionDraft: NodeRevisionDraft, protected metadata: UploadMetadata, protected onFinish: (failure: boolean) => Promise, + protected uploadController: UploadController, protected signal?: AbortSignal, ) { this.telemetry = telemetry; @@ -104,7 +105,7 @@ export class StreamUploader { } this.digests = new UploadDigests(); - this.controller = new UploadController(); + this.controller = uploadController; } async start( From 007090f5a443d591e542d31286aad4b5a07494ab Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 30 Sep 2025 08:36:51 +0200 Subject: [PATCH 239/791] Handle abort errors --- js/sdk/src/internal/apiService/apiService.test.ts | 11 +++++++++++ js/sdk/src/internal/apiService/apiService.ts | 7 ++++++- js/sdk/src/internal/apiService/errors.test.ts | 10 ++++++++++ js/sdk/src/internal/apiService/errors.ts | 6 +++++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 695c4fbe..8bdd91df 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -1,3 +1,4 @@ +import { AbortError } from '../../errors'; import { ProtonDriveHTTPClient, SDKEvent } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; import { SDKEvents } from '../sdkEvents'; @@ -112,6 +113,16 @@ describe('DriveAPIService', () => { }); describe('should throw', () => { + it('AbortError on aborted error from the provided HTTP client', async () => { + const abortError = new Error('AbortError'); + abortError.name = 'AbortError'; + + httpClient.fetchJson = jest.fn(() => Promise.reject(abortError)); + + await expect(api.get('test')).rejects.toThrow(new AbortError('Request aborted')); + expectSDKEvents(); + }); + it('APIHTTPError on 4xx response without JSON body', async () => { httpClient.fetchJson = jest.fn(() => Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not found' })), diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 30ba990c..34e2e984 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -219,7 +219,7 @@ export class DriveAPIService { if (error instanceof ProtonDriveError) { throw error; } - throw apiErrorFactory({ response }); + throw apiErrorFactory({ response, error }); } } return response; @@ -261,6 +261,11 @@ export class DriveAPIService { response = await callback(); } catch (error: unknown) { if (error instanceof Error) { + if (error.name === 'AbortError') { + this.logger.debug(`${request.method} ${request.url}: Aborted`); + throw new AbortError(c('Error').t`Request aborted`); + } + if (error.name === 'OfflineError') { this.logger.info(`${request.method} ${request.url}: Offline error, retrying`); await waitSeconds(OFFLINE_RETRY_DELAY_SECONDS); diff --git a/js/sdk/src/internal/apiService/errors.test.ts b/js/sdk/src/internal/apiService/errors.test.ts index 26bcbf02..c799a046 100644 --- a/js/sdk/src/internal/apiService/errors.test.ts +++ b/js/sdk/src/internal/apiService/errors.test.ts @@ -1,3 +1,4 @@ +import { AbortError } from '../../errors'; import { apiErrorFactory } from './errors'; import * as errors from './errors'; import { ErrorCode } from './errorCodes'; @@ -17,6 +18,15 @@ function mockAPIResponseAndResult(options: { } describe('apiErrorFactory should return', () => { + it('AbortError on aborted error', () => { + const abortError = new Error('AbortError'); + abortError.name = 'AbortError'; + + const error = apiErrorFactory({ response: new Response(), error: abortError }); + expect(error).toBeInstanceOf(AbortError); + expect(error.message).toBe('Request aborted'); + }); + it('generic APIHTTPError when there is no specifc body', () => { const response = new Response('', { status: 404, statusText: 'Not found' }); const error = apiErrorFactory({ response }); diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index 770d24f6..d5d18a3e 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { ServerError, ValidationError } from '../../errors'; +import { AbortError, ServerError, ValidationError } from '../../errors'; import { ErrorCode, HTTPErrorCode } from './errorCodes'; export function apiErrorFactory({ @@ -12,6 +12,10 @@ export function apiErrorFactory({ result?: unknown; error?: unknown; }): ServerError { + if (error && error instanceof Error && error.name === 'AbortError') { + return new AbortError(c('Error').t`Request aborted`); + } + // Backend responses with 404 both in the response and body code. // In such a case we want to stick to APIHTTPError to be very clear // it is not NotFoundAPIError. From d42d855d13aa580825f4f10e3e1685db651c0e88 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 30 Sep 2025 08:48:27 +0200 Subject: [PATCH 240/791] Abort decrypting nodes --- js/sdk/src/internal/asyncIteratorMap.test.ts | 12 ++++++++++++ js/sdk/src/internal/asyncIteratorMap.ts | 8 ++++++++ js/sdk/src/internal/nodes/nodesAccess.ts | 1 + 3 files changed, 21 insertions(+) diff --git a/js/sdk/src/internal/asyncIteratorMap.test.ts b/js/sdk/src/internal/asyncIteratorMap.test.ts index 185e0a57..d8885ead 100644 --- a/js/sdk/src/internal/asyncIteratorMap.test.ts +++ b/js/sdk/src/internal/asyncIteratorMap.test.ts @@ -1,3 +1,4 @@ +import { AbortError } from '../errors'; import { asyncIteratorMap } from './asyncIteratorMap'; // Helper function to create an async generator from array @@ -147,4 +148,15 @@ describe('asyncIteratorMap', () => { expect(maxConcurrentExecutions).toBe(concurrencyLimit); expect(results).toEqual([2, 4, 6, 8, 10, 12, 14, 16]); }); + + test('throws AbortError if signal is aborted', async () => { + const inputGen = createAsyncGenerator([1, 2, 3, 4, 5]); + const mapper = async (x: number) => x * 2; + + const ac = new AbortController(); + ac.abort(); + + const mappedGen = asyncIteratorMap(inputGen, mapper, 1, ac.signal); + await expect(collectResults(mappedGen)).rejects.toThrow(AbortError); + }); }); diff --git a/js/sdk/src/internal/asyncIteratorMap.ts b/js/sdk/src/internal/asyncIteratorMap.ts index dedb5fde..0a866d42 100644 --- a/js/sdk/src/internal/asyncIteratorMap.ts +++ b/js/sdk/src/internal/asyncIteratorMap.ts @@ -1,3 +1,7 @@ +import { c } from 'ttag'; + +import { AbortError } from '../errors'; + const DEFAULT_CONCURRENCY = 10; /** @@ -18,6 +22,7 @@ export async function* asyncIteratorMap( inputIterator: AsyncGenerator, mapper: (item: I) => Promise, concurrency: number = DEFAULT_CONCURRENCY, + signal?: AbortSignal, ): AsyncGenerator { let done = false; @@ -50,6 +55,9 @@ export async function* asyncIteratorMap( }; while (!done || executing.size > 0 || results.length > 0) { + if (signal?.aborted) { + throw new AbortError(c('Error').t`Operation aborted`); + } while (!done && executing.size < concurrency) { await pump(); } diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 92c626da..4e313979 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -272,6 +272,7 @@ export class NodesAccess { encryptedNodesIterator, decryptNodeMapper, DECRYPTION_CONCURRENCY, + signal, ); for await (const node of decryptedNodesIterator) { if (node.ok) { From e18e9120bb06d7929ea89f62f93d84fb03152d94 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 12:00:21 +0000 Subject: [PATCH 241/791] Batch and split per volume trash/restore/delete nodes --- js/sdk/src/internal/nodes/apiService.test.ts | 215 +++++++++++++++++-- js/sdk/src/internal/nodes/apiService.ts | 109 +++++----- 2 files changed, 260 insertions(+), 64 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index abce4270..8bcd6e0f 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -1,7 +1,7 @@ import { MemberRole, NodeType } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService'; -import { NodeAPIService } from './apiService'; +import { NodeAPIService, groupNodeUidsByVolumeAndIteratePerBatch } from './apiService'; import { NodeOutOfSyncError } from './errors'; function generateAPIFileNode(linkOverrides = {}, overrides = {}) { @@ -476,6 +476,44 @@ describe('nodeAPIService', () => { { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, ]); }); + + it('should trash nodes in batches', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async (_, { LinkIDs }) => + Promise.resolve({ + Responses: LinkIDs.map((linkId: string) => ({ + LinkID: linkId, + Response: { + Code: ErrorCode.OK, + }, + })), + }), + ); + + const nodeUids = Array.from({ length: 250 }, (_, i) => `volumeId1~nodeId${i}`); + const nodeIds = nodeUids.map((uid) => uid.split('~')[1]); + + const results = await Array.fromAsync(api.trashNodes(nodeUids)); + expect(results).toHaveLength(nodeUids.length); + expect(results.every((result) => result.ok)).toBe(true); + + expect(apiMock.post).toHaveBeenCalledTimes(3); + expect(apiMock.post).toHaveBeenCalledWith( + 'drive/v2/volumes/volumeId1/trash_multiple', + { LinkIDs: nodeIds.slice(0, 100) }, + undefined, + ); + expect(apiMock.post).toHaveBeenCalledWith( + 'drive/v2/volumes/volumeId1/trash_multiple', + { LinkIDs: nodeIds.slice(100, 200) }, + undefined, + ); + expect(apiMock.post).toHaveBeenCalledWith( + 'drive/v2/volumes/volumeId1/trash_multiple', + { LinkIDs: nodeIds.slice(200, 250) }, + undefined, + ); + }); }); describe('restoreNodes', () => { @@ -517,17 +555,28 @@ describe('nodeAPIService', () => { ]); }); - it('should fail restoring from multiple volumes', async () => { - try { - await Array.fromAsync(api.restoreNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); - throw new Error('Should have thrown'); - } catch (error: any) { - expect(error.message).toEqual('Restoring items from multiple sections is not allowed'); - } + it('should restore nodes from multiple volumes', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.put = jest.fn(async (_, { LinkIDs }) => + Promise.resolve({ + Responses: LinkIDs.map((linkId: string) => ({ + LinkID: linkId, + Response: { + Code: ErrorCode.OK, + }, + })), + }), + ); + + const result = await Array.fromAsync(api.restoreNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); + expect(result).toEqual([ + { uid: 'volumeId1~nodeId1', ok: true }, + { uid: 'volumeId2~nodeId2', ok: true }, + ]); }); }); - describe('deleteNOdes', () => { + describe('deleteNodes', () => { it('should delete nodes', async () => { // @ts-expect-error Mocking for testing purposes apiMock.post = jest.fn(async () => @@ -557,13 +606,24 @@ describe('nodeAPIService', () => { ]); }); - it('should fail deleting nodes from multiple volumes', async () => { - try { - await Array.fromAsync(api.deleteNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); - throw new Error('Should have thrown'); - } catch (error: any) { - expect(error.message).toEqual('Deleting items from multiple sections is not allowed'); - } + it('should delete nodes from multiple volumes', async () => { + // @ts-expect-error Mocking for testing purposes + apiMock.post = jest.fn(async (_, { LinkIDs }) => + Promise.resolve({ + Responses: LinkIDs.map((linkId: string) => ({ + LinkID: linkId, + Response: { + Code: ErrorCode.OK, + }, + })), + }), + ); + + const result = await Array.fromAsync(api.deleteNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); + expect(result).toEqual([ + { uid: 'volumeId1~nodeId1', ok: true }, + { uid: 'volumeId2~nodeId2', ok: true }, + ]); }); }); @@ -600,3 +660,126 @@ describe('nodeAPIService', () => { }); }); }); + +describe('groupNodeUidsByVolumeAndIteratePerBatch', () => { + it('should handle empty array', () => { + const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch([])); + expect(result).toEqual([]); + }); + + it('should handle single volume with nodes that fit in one batch', () => { + const nodeUids = ['volumeId1~nodeId1', 'volumeId1~nodeId2', 'volumeId1~nodeId3']; + + const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)); + + expect(result).toEqual([ + { + volumeId: 'volumeId1', + batchNodeIds: ['nodeId1', 'nodeId2', 'nodeId3'], + batchNodeUids: ['volumeId1~nodeId1', 'volumeId1~nodeId2', 'volumeId1~nodeId3'], + }, + ]); + }); + + it('should handle single volume with nodes that require multiple batches', () => { + // Create 250 node UIDs to test batching (API_NODES_BATCH_SIZE = 100) + const nodeUids = Array.from({ length: 250 }, (_, i) => `volumeId1~nodeId${i}`); + + const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)); + + expect(result).toHaveLength(3); // 100 + 100 + 50 + + // First batch + expect(result[0]).toEqual({ + volumeId: 'volumeId1', + batchNodeIds: Array.from({ length: 100 }, (_, i) => `nodeId${i}`), + batchNodeUids: Array.from({ length: 100 }, (_, i) => `volumeId1~nodeId${i}`), + }); + + // Second batch + expect(result[1]).toEqual({ + volumeId: 'volumeId1', + batchNodeIds: Array.from({ length: 100 }, (_, i) => `nodeId${i + 100}`), + batchNodeUids: Array.from({ length: 100 }, (_, i) => `volumeId1~nodeId${i + 100}`), + }); + + // Third batch + expect(result[2]).toEqual({ + volumeId: 'volumeId1', + batchNodeIds: Array.from({ length: 50 }, (_, i) => `nodeId${i + 200}`), + batchNodeUids: Array.from({ length: 50 }, (_, i) => `volumeId1~nodeId${i + 200}`), + }); + }); + + it('should handle multiple volumes with nodes distributed across them', () => { + const nodeUids = [ + 'volumeId1~nodeId1', + 'volumeId2~nodeId2', + 'volumeId1~nodeId3', + 'volumeId3~nodeId4', + 'volumeId2~nodeId5', + ]; + + const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)); + + expect(result).toHaveLength(3); // One batch per volume + + // Results should be grouped by volume + const volumeId1Batch = result.find((batch) => batch.volumeId === 'volumeId1'); + const volumeId2Batch = result.find((batch) => batch.volumeId === 'volumeId2'); + const volumeId3Batch = result.find((batch) => batch.volumeId === 'volumeId3'); + + expect(volumeId1Batch).toEqual({ + volumeId: 'volumeId1', + batchNodeIds: ['nodeId1', 'nodeId3'], + batchNodeUids: ['volumeId1~nodeId1', 'volumeId1~nodeId3'], + }); + + expect(volumeId2Batch).toEqual({ + volumeId: 'volumeId2', + batchNodeIds: ['nodeId2', 'nodeId5'], + batchNodeUids: ['volumeId2~nodeId2', 'volumeId2~nodeId5'], + }); + + expect(volumeId3Batch).toEqual({ + volumeId: 'volumeId3', + batchNodeIds: ['nodeId4'], + batchNodeUids: ['volumeId3~nodeId4'], + }); + }); + + it('should handle multiple volumes where some require multiple batches', () => { + // Volume 1: 150 nodes (2 batches) + // Volume 2: 50 nodes (1 batch) + // Volume 3: 200 nodes (2 batches) + const volume1Nodes = Array.from({ length: 150 }, (_, i) => `volumeId1~nodeId${i}`); + const volume2Nodes = Array.from({ length: 50 }, (_, i) => `volumeId2~nodeId${i}`); + const volume3Nodes = Array.from({ length: 200 }, (_, i) => `volumeId3~nodeId${i}`); + + const nodeUids = [...volume1Nodes, ...volume2Nodes, ...volume3Nodes]; + + const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)); + + expect(result).toHaveLength(5); // 2 + 1 + 2 batches + + // Group results by volume + const volume1Batches = result.filter((batch) => batch.volumeId === 'volumeId1'); + const volume2Batches = result.filter((batch) => batch.volumeId === 'volumeId2'); + const volume3Batches = result.filter((batch) => batch.volumeId === 'volumeId3'); + + expect(volume1Batches).toHaveLength(2); + expect(volume2Batches).toHaveLength(1); + expect(volume3Batches).toHaveLength(2); + + // Verify volume 1 batches + expect(volume1Batches[0].batchNodeIds).toHaveLength(100); + expect(volume1Batches[1].batchNodeIds).toHaveLength(50); + + // Verify volume 2 batch + expect(volume2Batches[0].batchNodeIds).toHaveLength(50); + + // Verify volume 3 batches + expect(volume3Batches[0].batchNodeIds).toHaveLength(100); + expect(volume3Batches[1].batchNodeIds).toHaveLength(100); + }); +}); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 5ffde274..12ed0da0 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -389,55 +389,49 @@ export class NodeAPIService { return makeNodeUid(volumeId, response.LinkID); } - // Improvement requested: split into multiple calls for many nodes. async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Trashing items`, nodeIds); - - const response = await this.apiService.post( - `drive/v2/volumes/${volumeId}/trash_multiple`, - { - LinkIDs: nodeIds.map(({ nodeId }) => nodeId), - }, - signal, - ); + for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) { + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/trash_multiple`, + { + LinkIDs: batchNodeIds, + }, + signal, + ); - // TODO: remove `as` when backend fixes OpenAPI schema. - yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); + // TODO: remove `as` when backend fixes OpenAPI schema. + yield * handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + } } - // Improvement requested: split into multiple calls for many nodes. async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Restoring items`, nodeIds); - - const response = await this.apiService.put( - `drive/v2/volumes/${volumeId}/trash/restore_multiple`, - { - LinkIDs: nodeIds.map(({ nodeId }) => nodeId), - }, - signal, - ); + for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) { + const response = await this.apiService.put( + `drive/v2/volumes/${volumeId}/trash/restore_multiple`, + { + LinkIDs: batchNodeIds, + }, + signal, + ); - // TODO: remove `as` when backend fixes OpenAPI schema. - yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); + // TODO: remove `as` when backend fixes OpenAPI schema. + yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + } } - // Improvement requested: split into multiple calls for many nodes. async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const nodeIds = nodeUids.map(splitNodeUid); - const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Deleting items`, nodeIds); - - const response = await this.apiService.post( - `drive/v2/volumes/${volumeId}/trash/delete_multiple`, - { - LinkIDs: nodeIds.map(({ nodeId }) => nodeId), - }, - signal, - ); + for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) { + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/trash/delete_multiple`, + { + LinkIDs: batchNodeIds, + }, + signal, + ); - // TODO: remove `as` when backend fixes OpenAPI schema. - yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]); + // TODO: remove `as` when backend fixes OpenAPI schema. + yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + } } async createFolder( @@ -513,15 +507,6 @@ export class NodeAPIService { } } -function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { volumeId: string }[]): string { - const uniqueVolumeIds = new Set(nodeIds.map(({ volumeId }) => volumeId)); - if (uniqueVolumeIds.size !== 1) { - throw new ValidationError(c('Error').t`${operationForErrorMessage} from multiple sections is not allowed`); - } - const volumeId = nodeIds[0].volumeId; - return volumeId; -} - type LinkResponse = { LinkID: string; Response: { @@ -657,6 +642,34 @@ function linkToEncryptedNode( throw new Error(`Unknown node type: ${link.Link.Type}`); } +export function* groupNodeUidsByVolumeAndIteratePerBatch( + nodeUids: string[], +): Generator<{ volumeId: string; batchNodeIds: string[]; batchNodeUids: string[] }> { + const allNodeIds = nodeUids.map((nodeUid: string) => { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + return { volumeId, nodeIds: { nodeId, nodeUid } }; + }); + + const nodeIdsByVolumeId = new Map(); + for (const { volumeId, nodeIds } of allNodeIds) { + if (!nodeIdsByVolumeId.has(volumeId)) { + nodeIdsByVolumeId.set(volumeId, []); + } + nodeIdsByVolumeId.get(volumeId)?.push(nodeIds); + } + + for (const [volumeId, nodeIds] of nodeIdsByVolumeId.entries()) { + for (const nodeIdsBatch of batch(nodeIds, API_NODES_BATCH_SIZE)) { + yield { + volumeId, + batchNodeIds: nodeIdsBatch.map(({ nodeId }) => nodeId), + batchNodeUids: nodeIdsBatch.map(({ nodeUid }) => nodeUid), + }; + } + } +} + + function transformRevisionResponse( volumeId: string, nodeId: string, From cb62c6df839a9e411a47aae881d0db8f3fff4455 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 09:38:52 +0200 Subject: [PATCH 242/791] Use npm ci instead install --- js/sdk/package-lock.json | 78 ++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index e7d66e82..78908f59 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.4.0", + "version": "0.4.1", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", @@ -370,14 +370,14 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" @@ -2001,14 +2001,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", - "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "devOptional": true, "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -2048,9 +2045,9 @@ } }, "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2547,9 +2544,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2626,9 +2623,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -4972,9 +4969,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5987,9 +5984,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -8041,9 +8038,9 @@ } }, "node_modules/koa": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", - "integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.2.tgz", + "integrity": "sha512-+CCssgnrWKx9aI3OeZwroa/ckG4JICxvIFnSiOUyl2Uv+UTI+xIw0FfFrWS7cQFpoePpr9o8csss7KzsTzNL8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -9618,13 +9615,6 @@ "node": ">=4" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "devOptional": true, - "license": "MIT" - }, "node_modules/regenerator-transform": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", @@ -10468,9 +10458,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -10615,9 +10605,9 @@ } }, "node_modules/ttag-cli": { - "version": "1.10.18", - "resolved": "https://registry.npmjs.org/ttag-cli/-/ttag-cli-1.10.18.tgz", - "integrity": "sha512-VSS59YPUP5jGTHVzqOQC1Hxt/02y8hc/O0lsBnLoPSACcyGg7e/wDT7DEFvoSEfgQ8JCov4ZjcGVXTPxC8d2Hg==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/ttag-cli/-/ttag-cli-1.11.2.tgz", + "integrity": "sha512-unLFfw4ZxRcEjO7rqwESwiuchtd8BdzyvnGVDtqGLUvdZFvm2zLu311aBcTNS31pwKt5JMQB03G3Or3LlEe4/g==", "dev": true, "hasInstallScript": true, "license": "MIT", From 52c70e0f06e83676c30492a5b0afa821855a0eb7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 17:47:51 +0200 Subject: [PATCH 243/791] fileUpload completion should return nodeUid and nodeRevisionUid --- js/sdk/src/interface/upload.ts | 2 +- js/sdk/src/internal/upload/controller.ts | 4 ++-- js/sdk/src/internal/upload/fileUploader.test.ts | 16 +++++++++++++++- js/sdk/src/internal/upload/fileUploader.ts | 2 +- .../src/internal/upload/streamUploader.test.ts | 8 +++++++- js/sdk/src/internal/upload/streamUploader.ts | 8 ++++++-- js/sdk/src/protonDriveClient.ts | 2 +- 7 files changed, 33 insertions(+), 9 deletions(-) diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index 74660e38..b3d5b73d 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -79,5 +79,5 @@ export interface FileUploader extends FileRevisionUploader { export interface UploadController { pause(): void; resume(): void; - completion(): Promise; + completion(): Promise<{ nodeRevisionUid: string, nodeUid: string }>; } diff --git a/js/sdk/src/internal/upload/controller.ts b/js/sdk/src/internal/upload/controller.ts index a3ca8f8b..85772403 100644 --- a/js/sdk/src/internal/upload/controller.ts +++ b/js/sdk/src/internal/upload/controller.ts @@ -2,7 +2,7 @@ import { waitForCondition } from '../wait'; export class UploadController { private paused = false; - public promise?: Promise; + public promise?: Promise<{ nodeRevisionUid: string, nodeUid: string }>; async waitIfPaused(): Promise { await waitForCondition(() => !this.paused); @@ -16,7 +16,7 @@ export class UploadController { this.paused = false; } - async completion(): Promise { + async completion(): Promise<{ nodeRevisionUid: string, nodeUid: string }> { if (!this.promise) { throw new Error('UploadController.completion() called before upload started'); } diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index 23b0c8f6..95c3cccc 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -108,6 +108,7 @@ describe('FileUploader', () => { revisionDraft = { nodeRevisionUid: 'revisionUid', + nodeUid: 'nodeUid', nodeKeys: { signatureAddress: { addressId: 'addressId' }, }, @@ -131,7 +132,10 @@ describe('FileUploader', () => { abortController.signal, ); - startUploadSpy = jest.spyOn(uploader as any, 'startUpload').mockReturnValue(Promise.resolve('revisionUid')); + startUploadSpy = jest.spyOn(uploader as any, 'startUpload').mockReturnValue(Promise.resolve({ + nodeRevisionUid: 'revisionUid', + nodeUid: 'nodeUid' + })); }); describe('uploadFromFile', () => { @@ -191,5 +195,15 @@ describe('FileUploader', () => { 'Upload already started', ); }); + + it('should return correct nodeUid and nodeRevisionUid via controller completion', async () => { + const controller = await uploader.uploadFromStream(stream, thumbnails, onProgress); + const result = await controller.completion(); + + expect(result).toEqual({ + nodeRevisionUid: 'revisionUid', + nodeUid: 'nodeUid' + }); + }); }); }); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index d2080d2f..68301d39 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -83,7 +83,7 @@ class Uploader { stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, - ): Promise { + ): Promise<{ nodeRevisionUid: string, nodeUid: string }> { const uploader = await this.initStreamUploader(); return uploader.start(stream, thumbnails, onProgress); } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index c71a0e60..ff06750e 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -108,6 +108,7 @@ describe('StreamUploader', () => { revisionDraft = { nodeRevisionUid: 'revisionUid', + nodeUid: 'nodeUid', nodeKeys: { signatureAddress: { addressId: 'addressId' }, }, @@ -144,7 +145,12 @@ describe('StreamUploader', () => { let stream: ReadableStream; const verifySuccess = async () => { - await uploader.start(stream, thumbnails, onProgress); + const result = await uploader.start(stream, thumbnails, onProgress); + + expect(result).toEqual({ + nodeRevisionUid: 'revisionUid', + nodeUid: 'nodeUid' + }); const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); expect(uploadManager.commitDraft).toHaveBeenCalledTimes(1); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 34517d99..8df62c26 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -112,7 +112,7 @@ export class StreamUploader { stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, - ): Promise { + ): Promise<{ nodeRevisionUid: string, nodeUid: string }> { let failure = false; // File progress is tracked for telemetry - to track at what @@ -155,7 +155,11 @@ export class StreamUploader { await this.onFinish(failure); } - return this.revisionDraft.nodeRevisionUid; + return { + nodeRevisionUid: this.revisionDraft.nodeRevisionUid, + nodeUid: this.revisionDraft.nodeUid + } + } private async encryptAndUploadBlocks( diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 41bbcb4b..7c96b1a0 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -791,7 +791,7 @@ export class ProtonDriveClient { * signalController.abort(); // to cancel * uploadController.pause(); // to pause * uploadController.resume(); // to resume - * const nodeUid = await uploadController.completion(); // to await completion + * const { nodeUid, nodeRevisionUid } = await uploadController.completion(); // to await completion * ``` */ async getFileUploader( From 90f3415dae7599a8a4ae67df9df1ae14014dc07b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 07:47:06 +0200 Subject: [PATCH 244/791] Add propagating offline error to SDK events --- .../internal/apiService/apiService.test.ts | 39 +++++++++++++++++++ js/sdk/src/internal/apiService/apiService.ts | 28 ++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 8bdd91df..24cd0590 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -325,5 +325,44 @@ describe('DriveAPIService', () => { expect(httpClient.fetchJson).toHaveBeenCalledTimes(35); expectSDKEvents(); }); + + it('notify about offline error', async () => { + jest.useFakeTimers(); + const offlineError = new Error('OfflineError'); + offlineError.name = 'OfflineError'; + + let attempt = 0; + httpClient.fetchJson = jest.fn().mockImplementation(() => { + if (attempt++ >= 15) { + return generateOkResponse(); + } + throw offlineError; + }); + + const promise = api.get('test'); + + // First 9 calls (first is immediate, then 8 with 5 second delay), no events are sent yet + await jest.advanceTimersByTimeAsync(5 * 8 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(9); + expectSDKEvents(); + + // 10th call, service sends TransfersPaused event + await jest.advanceTimersByTimeAsync(5 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(10); + expectSDKEvents(SDKEvent.TransfersPaused); + + // Next 5 calls, still offline, no more events are sent + await jest.advanceTimersByTimeAsync(5 * 5 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(15); + expectSDKEvents(SDKEvent.TransfersPaused); + + // 16th call, mock returns OK response, service sends TransfersResumed event + await jest.advanceTimersByTimeAsync(5 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(16); + expectSDKEvents(SDKEvent.TransfersPaused, SDKEvent.TransfersResumed); + + await promise; + }); + }); }); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 34e2e984..b05a0bdf 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -40,6 +40,11 @@ const TOO_MANY_SUBSEQUENT_SERVER_ERRORS = 10; */ const TOO_MANY_SUBSEQUENT_SERVER_ERRORS_TIMEOUT_IN_SECONDS = 60; +/** + * How many subsequent offline errors are allowed before we consider the client offline. + */ +const TOO_MANY_SUBSEQUENT_OFFLINE_ERRORS = 10; + /** * After how long to re-try after 5xx or timeout error. */ @@ -88,6 +93,8 @@ export class DriveAPIService { private subsequentServerErrorsCounter = 0; private lastServerErrorAt?: number; + private subsequentOfflineErrorsCounter = 0; + private logger: Logger; constructor( @@ -267,6 +274,7 @@ export class DriveAPIService { } if (error.name === 'OfflineError') { + this.offlineErrorHappened(); this.logger.info(`${request.method} ${request.url}: Offline error, retrying`); await waitSeconds(OFFLINE_RETRY_DELAY_SECONDS); return this.fetch(request, callback, attempt + 1); @@ -287,6 +295,8 @@ export class DriveAPIService { throw error; } + this.clearSubsequentOfflineErrors(); + const end = Date.now(); const duration = end - start; @@ -347,7 +357,7 @@ export class DriveAPIService { // the client is very limited. This is generic event and it doesn't // take into account that various endpoints can be rate limited // independently. - if (this.subsequentTooManyRequestsCounter >= TOO_MANY_SUBSEQUENT_429_ERRORS) { + if (this.subsequentTooManyRequestsCounter === TOO_MANY_SUBSEQUENT_429_ERRORS) { this.sdkEvents.requestsThrottled(); } } @@ -378,4 +388,20 @@ export class DriveAPIService { this.subsequentServerErrorsCounter = 0; this.lastServerErrorAt = undefined; } + + private offlineErrorHappened() { + this.subsequentOfflineErrorsCounter++; + + if (this.subsequentOfflineErrorsCounter === TOO_MANY_SUBSEQUENT_OFFLINE_ERRORS) { + this.sdkEvents.transfersPaused(); + } + } + + private clearSubsequentOfflineErrors() { + if (this.subsequentOfflineErrorsCounter >= TOO_MANY_SUBSEQUENT_OFFLINE_ERRORS) { + this.sdkEvents.transfersResumed(); + } + + this.subsequentOfflineErrorsCounter = 0; + } } From aab5066773da79b39d1c78de00678263e1bf4582 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 14:49:21 +0200 Subject: [PATCH 245/791] Do not send cleartext file size --- js/sdk/src/internal/upload/apiService.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 32f6a623..f44ab1e8 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -110,6 +110,17 @@ export class UploadAPIService { nodeUid: string; nodeRevisionUid: string; }> { + // The client shouldn't send the clear text size of the file. + // The intented upload size is needed only for early validation that + // the file can fit in the remaining quota to avoid data transfer when + // the upload would be rejected. The backend will still validate + // the quota during block upload and revision commit. + const precision = 100_000; // bytes + const intendedUploadSize = + node.intendedUploadSize && node.intendedUploadSize > precision + ? Math.floor(node.intendedUploadSize / precision) * precision + : null; + const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); const result = await this.apiService.post( `drive/v2/volumes/${volumeId}/files`, @@ -119,7 +130,7 @@ export class UploadAPIService { Hash: node.hash, MIMEType: node.mediaType, ClientUID: this.clientUid || null, - IntendedUploadSize: node.intendedUploadSize || null, + IntendedUploadSize: intendedUploadSize, NodeKey: node.armoredNodeKey, NodePassphrase: node.armoredNodePassphrase, NodePassphraseSignature: node.armoredNodePassphraseSignature, From a8ea4eda74974c222147dcc618123ae0f8a07e6d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 3 Oct 2025 08:14:10 +0200 Subject: [PATCH 246/791] js/v0.5.0 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 0cfa5f5f..b679acb5 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.4.1", + "version": "0.5.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 5a855d2c9890a7bf3050b4b975a63232e14e9067 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Oct 2025 08:20:50 +0200 Subject: [PATCH 247/791] Make deleting share with force explicit --- js/sdk/src/internal/sharing/apiService.ts | 4 ++-- js/sdk/src/internal/sharing/sharingManagement.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 6d41043f..8d5a5aa7 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -347,8 +347,8 @@ export class SharingAPIService { return response.Share.ID; } - async deleteShare(shareId: string): Promise { - await this.apiService.delete(`drive/shares/${shareId}?Force=1`); + async deleteShare(shareId: string, force: boolean = false): Promise { + await this.apiService.delete(`drive/shares/${shareId}?Force=${force ? 1 : 0}`); } async inviteProtonUser( diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index f208a3a8..140b6993 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -286,7 +286,7 @@ export class SharingManagement { if (!settings) { this.logger.info(`Unsharing node ${nodeUid}`); - await this.deleteShare(currentSharing.share.shareId, nodeUid); + await this.deleteShareWithForce(currentSharing.share.shareId, nodeUid); return; } @@ -348,7 +348,7 @@ export class SharingManagement { // update local state immediately. this.logger.info(`Deleting share ${currentSharing.share.shareId} for node ${nodeUid}`); try { - await this.deleteShare(currentSharing.share.shareId, nodeUid); + await this.deleteShareWithForce(currentSharing.share.shareId, nodeUid); } catch (error: unknown) { // If deleting the share fails, we don't want to throw an error // as it might be a race condition that other client updated @@ -433,8 +433,11 @@ export class SharingManagement { }; } - private async deleteShare(shareId: string, nodeUid: string): Promise { - await this.apiService.deleteShare(shareId); + /** + * Deletes the share even if it is not empty. + */ + private async deleteShareWithForce(shareId: string, nodeUid: string): Promise { + await this.apiService.deleteShare(shareId, true); await this.nodesService.notifyNodeChanged(nodeUid); if (await this.cache.hasSharedByMeNodeUidsLoaded()) { await this.cache.removeSharedByMeNodeUid(nodeUid); From 0f3c1a5d3ea6bfbb63aa1f33100617d681f0158f Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 14 Oct 2025 14:04:22 +0200 Subject: [PATCH 248/791] Fix various interop issues found after enabling HTTP client injection --- cs/headers/proton_drive_sdk.h | 6 +-- cs/headers/proton_sdk.h | 4 +- .../InteropAccountClient.cs | 24 +++++----- .../InteropActionExtensions.cs | 16 +++---- .../InteropFileDownloader.cs | 10 ++--- .../InteropFileUploader.cs | 17 +++---- .../InteropMessageHandler.cs | 34 +++++++------- .../InteropProtonDriveClient.cs | 17 ++++--- .../InteropStream.cs | 14 +++--- .../Api/Files/FilesApiClient.cs | 2 +- .../Nodes/Upload/BlockUploader.cs | 17 +++++++ .../Nodes/Upload/RevisionWriter.cs | 6 +++ cs/sdk/src/Proton.Sdk.CExports/Interop.cs | 14 +++++- .../InteropActionExtensions.cs | 8 ++-- .../InteropHttpClientFactory.cs | 11 ++--- .../InteropMessageHandler.cs | 10 ++--- .../Logging/InteropLogger.cs | 6 +-- .../Logging/InteropLoggerProvider.cs | 10 ++--- .../ProtonApiSessionRequestHandler.cs | 16 +++---- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 5 +++ cs/sdk/src/protos/proton.drive.sdk.proto | 45 +++++++++++-------- 21 files changed, 174 insertions(+), 118 deletions(-) diff --git a/cs/headers/proton_drive_sdk.h b/cs/headers/proton_drive_sdk.h index 1ab5ba44..dae27334 100644 --- a/cs/headers/proton_drive_sdk.h +++ b/cs/headers/proton_drive_sdk.h @@ -8,12 +8,12 @@ void proton_drive_sdk_handle_request( ByteArray request, - const void* caller_state, - array_action response_callback + intptr_t bindings_handle, + array_action response_action ); void proton_drive_sdk_handle_response( - const void* state, + intptr_t sdk_handle, ByteArray response ); diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h index 5da55a11..dfe99df8 100644 --- a/cs/headers/proton_sdk.h +++ b/cs/headers/proton_sdk.h @@ -9,7 +9,7 @@ typedef struct { size_t length; } ByteArray; -typedef void array_action(const void* state, ByteArray array); +typedef void array_action(intptr_t handle, ByteArray array); void override_native_library_name( ByteArray library_name, @@ -18,7 +18,7 @@ void override_native_library_name( void proton_sdk_handle_request( ByteArray request, - const void* caller_state, + intptr_t bindings_handle, array_action response_action ); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs index 376f08ed..226731a2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs @@ -8,15 +8,15 @@ namespace Proton.Drive.Sdk.CExports; -internal sealed class InteropAccountClient(nint state, InteropAction, nint> requestAction) : IAccountClient +internal sealed class InteropAccountClient(nint bindingsHandle, InteropAction, nint> requestAction) : IAccountClient { - private readonly nint _state = state; + private readonly nint _bindingsHandle = bindingsHandle; private readonly InteropAction, nint> _requestAction = requestAction; public async ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) { - var request = new AccountClientRequest { GetAddress = new GetAddressRequest { AddressId = addressId.ToString() } }; - var response = await _requestAction.SendRequestAsync(_state, request).ConfigureAwait(false); + var request = new AccountRequest { GetAddress = new GetAddressRequest { AddressId = addressId.ToString() } }; + var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false); return ConvertToAddress(response); } @@ -24,32 +24,32 @@ public async ValueTask
GetAddressAsync(AddressId addressId, Cancellatio public async ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) { var response = await _requestAction.SendRequestAsync( - _state, - new AccountClientRequest { GetAddress = new GetAddressRequest() }).ConfigureAwait(false); + _bindingsHandle, + new AccountRequest { GetAddress = new GetAddressRequest() }).ConfigureAwait(false); return ConvertToAddress(response); } public async ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken) { - var request = new AccountClientRequest { GetAddressPrimaryPrivateKey = new GetAddressPrimaryPrivateKeyRequest { AddressId = addressId.ToString() } }; - var response = await _requestAction.SendRequestAsync(_state, request).ConfigureAwait(false); + var request = new AccountRequest { GetAddressPrimaryPrivateKey = new GetAddressPrimaryPrivateKeyRequest { AddressId = addressId.ToString() } }; + var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false); return PgpPrivateKey.Import(response.Value.Span); } public async ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken) { - var request = new AccountClientRequest { GetAddressPrivateKeys = new GetAddressPrivateKeysRequest { AddressId = addressId.ToString() } }; - var response = await _requestAction.SendRequestAsync(_state, request).ConfigureAwait(false); + var request = new AccountRequest { GetAddressPrivateKeys = new GetAddressPrivateKeysRequest { AddressId = addressId.ToString() } }; + var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false); return [.. response.Value.Select(keyData => PgpPrivateKey.Import(keyData.Span))]; } public async ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) { - var request = new AccountClientRequest { GetAddressPublicKeys = new GetAddressPublicKeysRequest { EmailAddress = emailAddress } }; - var response = await _requestAction.SendRequestAsync(_state, request).ConfigureAwait(false); + var request = new AccountRequest { GetAddressPublicKeys = new GetAddressPublicKeysRequest { EmailAddress = emailAddress } }; + var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false); return [.. response.Value.Select(keyData => PgpPublicKey.Import(keyData.Span))]; } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs index 71eecb98..65a42c08 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs @@ -8,7 +8,7 @@ internal static class InteropActionExtensions { public static unsafe ValueTask SendRequestAsync( this InteropAction, nint> interopAction, - nint state, + nint bindingsHandle, IMessage request) where TResponse : IMessage { @@ -20,7 +20,7 @@ public static unsafe ValueTask SendRequestAsync( fixed (byte* requestBytesPointer = requestBytes) { - interopAction.Invoke(state, new InteropArray(requestBytesPointer, requestBytes.Length), (nint)tcsHandle); + interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, requestBytes.Length), (nint)tcsHandle); } return tcs.Task; @@ -28,7 +28,7 @@ public static unsafe ValueTask SendRequestAsync( public static unsafe ValueTask InvokeWithBufferAsync( this InteropAction, nint> interopAction, - nint state, + nint bindingsHandle, Span buffer) { var tcs = new ValueTaskCompletionSource(); @@ -37,7 +37,7 @@ public static unsafe ValueTask InvokeWithBufferAsync( fixed (byte* requestBytesPointer = buffer) { - interopAction.Invoke(state, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); + interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); } return tcs.Task; @@ -45,7 +45,7 @@ public static unsafe ValueTask InvokeWithBufferAsync( public static unsafe ValueTask InvokeWithBufferAsync( this InteropAction, nint> interopAction, - nint state, + nint bindingsHandle, ReadOnlySpan buffer) { var tcs = new ValueTaskCompletionSource(); @@ -54,13 +54,13 @@ public static unsafe ValueTask InvokeWithBufferAsync( fixed (byte* requestBytesPointer = buffer) { - interopAction.Invoke(state, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); + interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); } return tcs.Task; } - public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint state, long total, long completed) + public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint bindingsHandle, long total, long completed) { var progressUpdate = new ProgressUpdate { @@ -72,7 +72,7 @@ public static unsafe void InvokeProgressUpdate(this InteropAction(requestBytesPointer, requestBytes.Length)); + interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, requestBytes.Length)); } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index 06cbc679..5fa99417 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -7,25 +7,25 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropFileDownloader { - public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, nint callerState) + public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, nint bindingsHandle) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var downloader = Interop.GetFromHandle(request.DownloaderHandle); - var stream = new InteropStream(callerState, new InteropAction, nint>(request.WriteAction)); + var stream = new InteropStream(bindingsHandle, new InteropAction, nint>(request.WriteAction)); var progressAction = new InteropAction>(request.ProgressAction); var downloadController = downloader.DownloadToStream( stream, - (completed, total) => progressAction.InvokeProgressUpdate(callerState, total, completed), + (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(downloadController) }; } - public static IMessage HandleDownloadToFile(DownloadToFileRequest request, nint callerState) + public static IMessage HandleDownloadToFile(DownloadToFileRequest request, nint bindingsHandle) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); @@ -35,7 +35,7 @@ public static IMessage HandleDownloadToFile(DownloadToFileRequest request, nint var downloadController = downloader.DownloadToFile( request.FilePath, - (completed, total) => progressAction.InvokeProgressUpdate(callerState, total, completed), + (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(downloadController) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 1f5d1843..88054501 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -1,6 +1,5 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; -using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk.CExports; @@ -8,19 +7,20 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropFileUploader { - public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, nint callerState) + public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, nint bindingsHandle) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var uploader = Interop.GetFromHandle(request.UploaderHandle); - var stream = new InteropStream(uploader.FileSize, callerState, new InteropAction, nint>(request.ReadAction)); + var stream = new InteropStream(uploader.FileSize, bindingsHandle, new InteropAction, nint>(request.ReadAction)); var thumbnails = request.Thumbnails.Select(t => { unsafe { - return new Nodes.Thumbnail((ThumbnailType)t.Type, new InteropArray((byte*)t.ContentPointer, (nint)t.ContentLength).ToArray()); + var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.ContentPointer, (nint)t.ContentLength).ToArray()); } }); @@ -29,13 +29,13 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var uploadController = uploader.UploadFromStream( stream, thumbnails, - (completed, total) => progressAction.InvokeProgressUpdate(callerState, total, completed), + (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; } - public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint callerState) + public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint bindingsHandle) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); @@ -45,7 +45,8 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint { unsafe { - return new Nodes.Thumbnail((ThumbnailType)t.Type, new InteropArray((byte*)t.ContentPointer, (nint)t.ContentLength).ToArray()); + var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.ContentPointer, (nint)t.ContentLength).ToArray()); } }); @@ -54,7 +55,7 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint var uploadController = uploader.UploadFromFile( request.FilePath, thumbnails, - (completed, total) => progressAction.InvokeProgressUpdate(callerState, total, completed), + (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 5734fa13..f11cb00d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -17,7 +17,7 @@ internal static class InteropMessageHandler Address.Descriptor); [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])] - public static async void OnRequestReceived(InteropArray requestBytes, nint callerState, InteropAction> responseAction) + public static async void OnRequestReceived(InteropArray requestBytes, nint bindingsHandle, InteropAction> responseAction) { try { @@ -26,7 +26,7 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint var response = request.PayloadCase switch { Request.PayloadOneofCase.DriveClientCreate - => InteropProtonDriveClient.HandleCreate(request.DriveClientCreate, callerState), + => InteropProtonDriveClient.HandleCreate(request.DriveClientCreate, bindingsHandle), Request.PayloadOneofCase.DriveClientCreateFromSession => InteropProtonDriveClient.HandleCreate(request.DriveClientCreateFromSession), @@ -44,10 +44,10 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint => await InteropProtonDriveClient.HandleGetFileDownloaderAsync(request.DriveClientGetFileDownloader).ConfigureAwait(false), Request.PayloadOneofCase.UploadFromStream - => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, callerState), + => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, bindingsHandle), Request.PayloadOneofCase.UploadFromFile - => InteropFileUploader.HandleUploadFromFile(request.UploadFromFile, callerState), + => InteropFileUploader.HandleUploadFromFile(request.UploadFromFile, bindingsHandle), Request.PayloadOneofCase.FileUploaderFree => InteropFileUploader.HandleFree(request.FileUploaderFree), @@ -65,10 +65,10 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint => InteropUploadController.HandleFree(request.UploadControllerFree), Request.PayloadOneofCase.DownloadToStream - => InteropFileDownloader.HandleDownloadToStream(request.DownloadToStream, callerState), + => InteropFileDownloader.HandleDownloadToStream(request.DownloadToStream, bindingsHandle), Request.PayloadOneofCase.DownloadToFile - => InteropFileDownloader.HandleDownloadToFile(request.DownloadToFile, callerState), + => InteropFileDownloader.HandleDownloadToFile(request.DownloadToFile, bindingsHandle), Request.PayloadOneofCase.FileDownloaderFree => InteropFileDownloader.HandleFree(request.FileDownloaderFree), @@ -89,30 +89,30 @@ Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; - responseAction.InvokeWithMessage(callerState, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); + responseAction.InvokeWithMessage(bindingsHandle, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); } catch (Exception e) { var error = e.ToErrorMessage(InteropDriveErrorConverter.SetDomainAndCodes); - responseAction.InvokeWithMessage(callerState, new Response { Error = error }); + responseAction.InvokeWithMessage(bindingsHandle, new Response { Error = error }); } } [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_response", CallConvs = [typeof(CallConvCdecl)])] - public static void OnResponseReceived(nint state, InteropArray responseBytes) + public static void OnResponseReceived(nint sdkHandle, InteropArray responseBytes) { var response = Response.Parser.ParseFrom(responseBytes.AsReadOnlySpan()); if (response.Error is not null) { - SetException(state, response.Error.Message); + SetException(sdkHandle, response.Error.Message); return; } if (response.Value is null) { - SetResult(state); + SetResult(sdkHandle); return; } @@ -121,27 +121,27 @@ public static void OnResponseReceived(nint state, InteropArray responseByt switch (responseValue) { case Int32Value value: - SetResult(state, value); + SetResult(sdkHandle, value); break; case StringValue value: - SetResult(state, value); + SetResult(sdkHandle, value); break; case BytesValue value: - SetResult(state, value); + SetResult(sdkHandle, value); break; case RepeatedBytesValue value: - SetResult(state, value); + SetResult(sdkHandle, value); break; case Address value: - SetResult(state, value); + SetResult(sdkHandle, value); break; case HttpResponse value: - SetResult(state, value); + SetResult(sdkHandle, value); break; default: diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 4f0c3131..a16672e8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -1,6 +1,7 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Proton.Drive.Sdk.Nodes; using Proton.Sdk; using Proton.Sdk.Caching; @@ -11,15 +12,15 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropProtonDriveClient { - public static IMessage HandleCreate(DriveClientCreateRequest request, nint state) + public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindingsHandle) { var httpClientFactory = new InteropHttpClientFactory( - state, + bindingsHandle, request.BaseUrl, request.BindingsLanguage, - new InteropAction, nint>(request.SendHttpRequestAction)); + new InteropAction, nint>(request.HttpClientRequestAction)); - var accountClient = new InteropAccountClient(state, new InteropAction, nint>(request.AccountClient.RequestAction)); + var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountClientRequestAction)); var entityCacheRepository = request.HasEntityCachePath ? SqliteCacheRepository.OpenFile(request.EntityCachePath) @@ -29,7 +30,13 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint state ? SqliteCacheRepository.OpenFile(request.SecretCachePath) : SqliteCacheRepository.OpenInMemory(); - var loggerProvider = Interop.GetFromHandle(request.LoggerProviderHandle); + var loggerProvider = request.LoggerCase switch + { + DriveClientCreateRequest.LoggerOneofCase.LogAction => new InteropLoggerProvider(bindingsHandle, new InteropAction>(request.LogAction)), + DriveClientCreateRequest.LoggerOneofCase.LoggerProviderHandle => Interop.GetFromHandle(request.LoggerProviderHandle), + DriveClientCreateRequest.LoggerOneofCase.None or _ => NullLoggerProvider.Instance, + }; + var loggerFactory = new LoggerFactory([loggerProvider]); var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, loggerFactory); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs index e1411dbe..1a47d488 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs @@ -5,24 +5,24 @@ namespace Proton.Drive.Sdk.CExports; internal sealed class InteropStream : Stream { - private readonly nint _callerState; + private readonly nint _bindingsHandle; private readonly InteropAction, nint>? _readAction; private readonly InteropAction, nint>? _writeAction; private long _position; private long? _length; - public InteropStream(long length, nint callerState, InteropAction, nint>? readAction) + public InteropStream(long length, nint bindingsHandle, InteropAction, nint>? readAction) { _length = length; - _callerState = callerState; + _bindingsHandle = bindingsHandle; _readAction = readAction; _writeAction = null; } - public InteropStream(nint callerState, InteropAction, nint>? writeAction) + public InteropStream(nint bindingsHandle, InteropAction, nint>? writeAction) { - _callerState = callerState; + _bindingsHandle = bindingsHandle; _readAction = null; _writeAction = writeAction; } @@ -61,7 +61,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation using var memoryHandle = buffer.Pin(); - var response = await _readAction.Value.InvokeWithBufferAsync(_callerState, buffer.Span).ConfigureAwait(false); + var response = await _readAction.Value.InvokeWithBufferAsync(_bindingsHandle, buffer.Span).ConfigureAwait(false); if (response.Value < 0) { @@ -102,7 +102,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella using var memoryHandle = buffer.Pin(); - await _writeAction.Value.InvokeWithBufferAsync(_callerState, buffer.Span).ConfigureAwait(false); + await _writeAction.Value.InvokeWithBufferAsync(_bindingsHandle, buffer.Span).ConfigureAwait(false); _position += buffer.Length; _length = Math.Max(_length ?? 0, _position); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index 847126ed..4dea9a27 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -28,7 +28,7 @@ public async Task CreateRevisionAsync( return await _httpClient .Expecting(DriveApiSerializerContext.Default.RevisionCreationResponse, DriveApiSerializerContext.Default.RevisionConflictResponse) .PostAsync( - $"volumes/{volumeId}/files/{linkId}/revisions", + $"v2/volumes/{volumeId}/files/{linkId}/revisions", request, DriveApiSerializerContext.Default.RevisionCreationRequest, cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 38a92340..1a991553 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Net; using System.Security.Cryptography; +using Microsoft.Extensions.Logging; using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; @@ -111,6 +112,12 @@ public async Task UploadContentAsync( await UploadBlobAsync(request, dataPacketStream, onBlockProgress, cancellationToken).ConfigureAwait(false); + _client.Logger.LogDebug( + "Uploaded blob for block #{BlockIndex} for revision {RevisionId} of file {FileUid}", + index, + revisionId, + fileUid); + return sha256Digest; } } @@ -184,6 +191,8 @@ public async Task UploadThumbnailAsync( await UploadBlobAsync(request, dataPacketStream, onProgress, cancellationToken).ConfigureAwait(false); + _client.Logger.LogDebug("Uploaded thumbnail blob for revision {RevisionId} of node {FileUid}", revisionId, fileUid); + return sha256Digest; } } @@ -223,6 +232,14 @@ await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Tok } catch (Exception e) when ((UrlExpired(e) || BlobAlreadyUploaded(e)) && remainingNumberOfAttempts >= 2) { + _client.Logger.LogWarning( + e, + "Blob upload failed for block #{BlockIndex} for revision {RevisionId} of file {FileUid} (remaining attempts: {RemainingAttempts}", + request.Blocks[0].Index, + request.RevisionId, + new NodeUid(request.VolumeId, request.LinkId), + remainingNumberOfAttempts); + --remainingNumberOfAttempts; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 0adc6935..5d7d6bb5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Text.Json; +using Microsoft.Extensions.Logging; using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; @@ -184,7 +185,12 @@ public async ValueTask WriteAsync( var request = GetRevisionUpdateRequest(contentStream, lastModificationTime, blockSizes, manifestSignature, signingEmailAddress); + + _client.Logger.LogDebug("Sealing revision {RevisionId} of file {FileUid}", _revisionId, _fileUid); + await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, request, cancellationToken).ConfigureAwait(false); + + _client.Logger.LogDebug("Revision {RevisionId} of file {FileUid} sealed", _revisionId, _fileUid); } public void Dispose() diff --git a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs index 98b272df..3c39cd67 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs @@ -63,6 +63,18 @@ private static T GetFromHandle(long handle, bool free) gcHandle.Free(); } - return (T)(handleTarget ?? throw InvalidHandleException.Create(GCHandle.ToIntPtr(gcHandle))); + if (handleTarget is null) + { + throw InvalidHandleException.Create(GCHandle.ToIntPtr(gcHandle)); + } + + try + { + return (T)handleTarget; + } + catch (InvalidCastException e) + { + throw new InvalidHandleException($"Expected handle for object of type {typeof(T)} but object was of type {handleTarget.GetType()}", e); + } } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs index e9801c5e..00694bd5 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs @@ -4,25 +4,25 @@ namespace Proton.Sdk.CExports; internal static class InteropActionExtensions { - public static unsafe void InvokeWithMessage(this InteropAction> action, nint state, T message) + public static unsafe void InvokeWithMessage(this InteropAction> action, nint bindingsHandle, T message) where T : IMessage { var responseBytes = message.ToByteArray(); fixed (byte* responsePointer = responseBytes) { - action.Invoke(state, new InteropArray(responsePointer, responseBytes.Length)); + action.Invoke(bindingsHandle, new InteropArray(responsePointer, responseBytes.Length)); } } - public static unsafe void InvokeWithMessage(this InteropAction, nint> action, nint state, T message, nint callerState) + public static unsafe void InvokeWithMessage(this InteropAction, nint> action, nint bindingsHandle, T message, nint sdkHandle) where T : IMessage { var responseBytes = message.ToByteArray(); fixed (byte* responsePointer = responseBytes) { - action.Invoke(state, new InteropArray(responsePointer, responseBytes.Length), callerState); + action.Invoke(bindingsHandle, new InteropArray(responsePointer, responseBytes.Length), sdkHandle); } } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index 27a621d2..38d18c31 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -1,6 +1,7 @@ using System.Net; using System.Reflection; using Google.Protobuf; +using Proton.Sdk.CExports.Tasks; namespace Proton.Sdk.CExports; @@ -11,13 +12,13 @@ internal sealed class InteropHttpClientFactory : IHttpClientFactory private readonly string _sdkTechnicalStack; public InteropHttpClientFactory( - nint state, + nint bindingsHandle, string baseUrl, string? bindingsLanguage, InteropAction, nint> sendHttpRequestAction) { _baseUrl = baseUrl; - State = state; + BindingsHandle = bindingsHandle; SendHttpRequestAction = sendHttpRequestAction; var executingAssembly = Assembly.GetExecutingAssembly(); @@ -33,7 +34,7 @@ public InteropHttpClientFactory( _sdkTechnicalStack = "dotnet" + bindingsSuffix; } - private nint State { get; } + private nint BindingsHandle { get; } private InteropAction, nint> SendHttpRequestAction { get; } public HttpClient CreateClient(string name) @@ -54,12 +55,12 @@ private sealed class InteropHttpMessageHandler(InteropHttpClientFactory owner) : protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - var taskCompletionSource = new TaskCompletionSource(); + var taskCompletionSource = new ValueTaskCompletionSource(); var taskCompletionSourceHandle = Interop.AllocHandle(taskCompletionSource); var interopHttpRequest = await ConvertHttpRequestToInteropAsync(request, cancellationToken).ConfigureAwait(false); - _owner.SendHttpRequestAction.InvokeWithMessage(_owner.State, interopHttpRequest, (nint)taskCompletionSourceHandle); + _owner.SendHttpRequestAction.InvokeWithMessage(_owner.BindingsHandle, interopHttpRequest, (nint)taskCompletionSourceHandle); var interopHttpResponse = await taskCompletionSource.Task.ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs index fe0eea43..c76c91ab 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -8,7 +8,7 @@ namespace Proton.Sdk.CExports; internal static class InteropMessageHandler { [UnmanagedCallersOnly(EntryPoint = "proton_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])] - public static async void OnRequestReceived(InteropArray requestBytes, nint callerState, InteropAction> responseAction) + public static async void OnRequestReceived(InteropArray requestBytes, nint bindingsHandle, InteropAction> responseAction) { try { @@ -41,25 +41,25 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint => ProtonApiSessionRequestHandler.HandleFree(request.SessionFree), Request.PayloadOneofCase.SessionTokensRefreshedSubscribe - => ProtonApiSessionRequestHandler.HandleSubscribeToTokensRefreshed(request.SessionTokensRefreshedSubscribe, callerState), + => ProtonApiSessionRequestHandler.HandleSubscribeToTokensRefreshed(request.SessionTokensRefreshedSubscribe, bindingsHandle), Request.PayloadOneofCase.SessionTokensRefreshedUnsubscribe => ProtonApiSessionRequestHandler.HandleUnsubscribeFromTokensRefreshed(request.SessionTokensRefreshedUnsubscribe), Request.PayloadOneofCase.LoggerProviderCreate - => InteropLoggerProvider.HandleCreate(request.LoggerProviderCreate, callerState), + => InteropLoggerProvider.HandleCreate(request.LoggerProviderCreate, bindingsHandle), Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; - responseAction.InvokeWithMessage(callerState, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); + responseAction.InvokeWithMessage(bindingsHandle, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); } catch (Exception e) { var error = e.ToErrorMessage(InteropErrorConverter.SetDomainAndCodes); - responseAction.InvokeWithMessage(callerState, new Response { Error = error }); + responseAction.InvokeWithMessage(bindingsHandle, new Response { Error = error }); } } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index 70cc1568..e87d1028 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -5,9 +5,9 @@ namespace Proton.Sdk.CExports.Logging; [StructLayout(LayoutKind.Sequential)] -internal sealed class InteropLogger(nint callerState, InteropAction> logAction, string categoryName) : ILogger +internal sealed class InteropLogger(nint bindingsHandle, InteropAction> logAction, string categoryName) : ILogger { - private readonly nint _callerState = callerState; + private readonly nint _bindingsHandle = bindingsHandle; private readonly InteropAction> _logAction = logAction; private readonly string _categoryName = categoryName; @@ -27,7 +27,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except try { - _logAction.Invoke(_callerState, messageBytes); + _logAction.Invoke(_bindingsHandle, messageBytes); } finally { diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs index f720dd98..b001309b 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs @@ -4,14 +4,14 @@ namespace Proton.Sdk.CExports.Logging; -internal sealed class InteropLoggerProvider(nint callerState, InteropAction> logAction) : ILoggerProvider +internal sealed class InteropLoggerProvider(nint bindingsHandle, InteropAction> logAction) : ILoggerProvider { - private readonly nint _callerState = callerState; + private readonly nint _bindingsHandle = bindingsHandle; private readonly InteropAction> _logAction = logAction; public ILogger CreateLogger(string categoryName) { - return new InteropLogger(_callerState, _logAction, categoryName); + return new InteropLogger(_bindingsHandle, _logAction, categoryName); } public void Dispose() @@ -19,11 +19,11 @@ public void Dispose() // Nothing to do } - public static IMessage HandleCreate(LoggerProviderCreate request, nint callerState) + public static IMessage HandleCreate(LoggerProviderCreate request, nint bindingsHandle) { var logAction = new InteropAction>(request.LogAction); - var provider = new InteropLoggerProvider(callerState, logAction); + var provider = new InteropLoggerProvider(bindingsHandle, logAction); return new Int64Value { Value = Interop.AllocHandle(provider) }; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs index 7ad28127..2653abf7 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs @@ -123,13 +123,13 @@ public static IMessage HandleRenew(SessionRenewRequest request) return null; } - public static unsafe IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefreshedSubscribeRequest request, nint callerState) + public static unsafe IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefreshedSubscribeRequest request, nint bindingsHandle) { var session = Interop.GetFromHandle((nint)request.SessionHandle); var tokenRefreshedAction = new InteropAction>(request.TokensRefreshedAction); - var subscription = TokensRefreshedSubscription.Create(session, callerState, tokenRefreshedAction); + var subscription = TokensRefreshedSubscription.Create(session, bindingsHandle, tokenRefreshedAction); return new Int64Value { Value = Interop.AllocHandle(subscription) }; } @@ -153,25 +153,25 @@ public static unsafe IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefr private sealed class TokensRefreshedSubscription : IDisposable { private readonly ProtonApiSession _session; - private readonly nint _callerState; + private readonly nint _bindingsHandle; private readonly InteropAction> _tokensRefreshedAction; private TokensRefreshedSubscription( ProtonApiSession session, - nint callerState, + nint bindingsHandle, InteropAction> tokensRefreshedAction) { _session = session; - _callerState = callerState; + _bindingsHandle = bindingsHandle; _tokensRefreshedAction = tokensRefreshedAction; } public static TokensRefreshedSubscription Create( ProtonApiSession session, - nint callerState, + nint bindingsHandle, InteropAction> tokensRefreshedAction) { - var subscription = new TokensRefreshedSubscription(session, callerState, tokensRefreshedAction); + var subscription = new TokensRefreshedSubscription(session, bindingsHandle, tokensRefreshedAction); session.TokenCredential.TokensRefreshed += subscription.Handle; @@ -189,7 +189,7 @@ private void Handle(string accessToken, string refreshToken) try { - _tokensRefreshedAction.Invoke(_callerState, tokensMessage); + _tokensRefreshedAction.Invoke(_bindingsHandle, tokensMessage); } finally { diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index 6e526b3b..2a5e8fce 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -92,11 +92,14 @@ public static async ValueTask BeginAsync( CancellationToken cancellationToken) { var configuration = new ProtonClientConfiguration(appVersion, options); + var logger = configuration.LoggerFactory.CreateLogger(); var authApiClient = ApiClientFactory.Instance.CreateAuthenticationApiClient(configuration.GetHttpClient(), configuration.RefreshRedirectUri); var sessionInitResponse = await authApiClient.InitiateSessionAsync(username, cancellationToken).ConfigureAwait(false); + logger.LogDebug("SRP session {SessionId} initiated", sessionInitResponse.SrpSessionId); + var srpClient = SrpClient.Create( username, password.Span, @@ -109,6 +112,8 @@ public static async ValueTask BeginAsync( var authResponse = await authApiClient.AuthenticateAsync(sessionInitResponse, srpClientHandshake, username, cancellationToken) .ConfigureAwait(false); + logger.LogDebug("API session {SessionId} authenticated with password", authResponse.SessionId); + var tokenCredential = new TokenCredential( authApiClient, authResponse.SessionId, diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 90d45bad..8528bb12 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -35,16 +35,7 @@ message Request { // Account client interface -message AccountClient { - // Pointer to C function that will be called: - // void account_client_request_action(long state, ByteArray http_request, long caller_state) - // state: value that was passed as caller state when this message was sent, i.e. state of the bindings - // http_request: Protobuf message of type proton.drive.sdk.AccountClientRequest carrying the request data - // caller_state: value to provide as state during the response call - int64 request_action = 1; -} - -message AccountClientRequest { +message AccountRequest { oneof payload { GetAddressRequest get_address = 1; GetDefaultAddressRequest get_default_address = 2; @@ -80,6 +71,12 @@ message GetAddressPublicKeysRequest { // Drive - client +enum ThumbnailType { + THUMBNAIL_TYPE_UNSPECIFIED = 0; // Invalid value + THUMBNAIL_TYPE_THUMBNAIL = 1; + THUMBNAIL_TYPE_PREVIEW = 2; +} + message FileRevision { string uid = 1; google.protobuf.Timestamp creation_time = 2; @@ -103,7 +100,7 @@ message ProgressUpdate { } message Thumbnail { - int32 type = 1; + ThumbnailType type = 1; int64 content_pointer = 2; int64 content_length = 3; } @@ -119,16 +116,26 @@ message DriveClientCreateRequest { string bindings_language = 2; // Optional // Pointer to C function that will be called: - // void send_http_request_action(long state, ByteArray http_request, long caller_state) - // state: value that was passed as caller state when this message was sent, i.e. state of the bindings + // void http_client_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) + // bindings_handle: handle for the bindings // http_request: Protobuf message of type proton.sdk.HttpRequest carrying the HTTP request data - // caller_state: value to provide as state during the response call - int64 send_http_request_action = 3; + // sdk_handle: handle for the SDK + int64 http_client_request_action = 3; + + // Pointer to C function that will be called: + // void account_client_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) + // bindings_handle: handle for the bindings + // account_request: Protobuf message of type proton.drive.sdk.AccountRequest carrying the request data + // sdk_handle: handle for the SDK + int64 account_client_request_action = 4; - AccountClient account_client = 4; string entity_cache_path = 5; // Optional string secret_cache_path = 6; // Optional - int64 logger_provider_handle = 7; + + oneof logger { // Optional + int64 log_action = 7; // See array_action in C header file for signature + int64 logger_provider_handle = 8; + } } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. @@ -168,7 +175,7 @@ message DriveClientGetFileRevisionUploaderRequest { message UploadFromStreamRequest { int64 uploader_handle = 1; repeated Thumbnail thumbnails = 2; - int64 read_action = 3; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); + int64 read_action = 3; // C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; } @@ -219,7 +226,7 @@ message DriveClientGetFileDownloaderRequest { // The response value must be an Int64Value carrying a handle to an instance of DownloadController. message DownloadToStreamRequest { int64 downloader_handle = 1; - int64 write_action = 2; // C signature: void on_stream_operation(const void* state, ByteArray buffer, const void* caller_state); + int64 write_action = 2; // C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 3; // See array_action in C header file for signature int64 cancellation_token_source_handle = 4; } From b3898b8a360e14b8c71a098d3ec9b01d67a75057 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 11:42:32 +0200 Subject: [PATCH 249/791] Fix old revision UID being returned instead of new one after revision upload --- .../Caching/DriveEntityCache.cs | 5 +++ .../Caching/IDriveEntityCache.cs | 2 + cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 2 +- .../Nodes/Upload/FileUploader.cs | 39 +++++++++++++++---- .../Nodes/Upload/RevisionWriter.cs | 1 - .../Nodes/Upload/UploadController.cs | 1 + 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 741d53da..697d7af0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -90,6 +90,11 @@ public ValueTask SetNodeAsync( : null; } + public async ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + { + await _repository.RemoveAsync(GetNodeCacheKey(nodeUid), cancellationToken).ConfigureAwait(false); + } + private static string GetShareCacheKey(ShareId shareId) { return $"share:{shareId}"; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index 685cb9b0..35c49ffb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -28,4 +28,6 @@ ValueTask SetNodeAsync( CancellationToken cancellationToken); ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); + + ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index 5854cf7b..4cc3ac31 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed class Revision +public sealed record Revision { public required RevisionUid Uid { get; init; } public required DateTime CreationTime { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 3643aae3..1c6af9d8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -1,3 +1,5 @@ +using Proton.Sdk; + namespace Proton.Drive.Sdk.Nodes.Upload; public sealed class FileUploader : IDisposable @@ -64,7 +66,7 @@ public void Dispose() { var (draftRevisionUid, fileSecrets) = await _fileDraftProvider.GetDraftAsync(_client, cancellationToken).ConfigureAwait(false); - var fileNode = await UploadAsync( + await UploadAsync( draftRevisionUid, fileSecrets, contentStream, @@ -73,7 +75,34 @@ public void Dispose() onProgress, cancellationToken).ConfigureAwait(false); - return (fileNode.Uid, fileNode.ActiveRevision.Uid); + await UpdateActiveRevisionInCacheAsync(draftRevisionUid, contentStream.Length, cancellationToken).ConfigureAwait(false); + + return (draftRevisionUid.NodeUid, draftRevisionUid); + } + + private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid, long size, CancellationToken cancellationToken) + { + var cachedNodeInfo = await _client.Cache.Entities.TryGetNodeAsync(revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + + if (cachedNodeInfo is not var (nodeProvisionResult, membershipShareId, nameHashDigest) || !nodeProvisionResult.TryGetValue(out var node) || + node is not FileNode fileNode) + { + await _client.Cache.Entities.RemoveNodeAsync(revisionUid.NodeUid, cancellationToken); + return; + } + + fileNode = fileNode with + { + ActiveRevision = fileNode.ActiveRevision with + { + Uid = revisionUid, + ClaimedSize = size, + ClaimedModificationTime = _lastModificationTime?.UtcDateTime, + // FIXME: update remaining metadata in cache, but this is not critical because this metadata will soon be invalidated by the event anyway + }, + }; + + await _client.Cache.Entities.SetNodeAsync(fileNode.Uid, fileNode, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); } private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromFileAsync( @@ -90,7 +119,7 @@ public void Dispose() } } - private async ValueTask UploadAsync( + private async ValueTask UploadAsync( RevisionUid revisionUid, FileSecrets fileSecrets, Stream contentStream, @@ -103,10 +132,6 @@ private async ValueTask UploadAsync( .ConfigureAwait(false); await revisionWriter.WriteAsync(contentStream, thumbnails, lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); - - var nodeMetadata = await NodeOperations.GetNodeMetadataAsync(_client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - - return (FileNode)nodeMetadata.Node; } private void ReleaseBlocks(int numberOfBlocks) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 5d7d6bb5..19420eb1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -185,7 +185,6 @@ public async ValueTask WriteAsync( var request = GetRevisionUpdateRequest(contentStream, lastModificationTime, blockSizes, manifestSignature, signingEmailAddress); - _client.Logger.LogDebug("Sealing revision {RevisionId} of file {FileUid}", _revisionId, _fileUid); await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, request, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index e0716b18..2c560741 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -2,6 +2,7 @@ namespace Proton.Drive.Sdk.Nodes.Upload; public sealed class UploadController(Task<(NodeUid NodeUid, RevisionUid RevisionUid)> uploadTask) { + // FIXME: Add unit test to ensure that the revision UID is of the new active revision public Task<(NodeUid NodeUid, RevisionUid RevisionUid)> Completion { get; } = uploadTask; public void Pause() From 893ca8c85646c66fc862dc154bdf41a7cf740ebf Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 15 Oct 2025 06:01:22 +0000 Subject: [PATCH 250/791] =?UTF-8?q?Fix=20aborting=20uploads=20&=C2=A0downl?= =?UTF-8?q?oads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/sdk/src/internal/download/controller.ts | 14 ++- .../src/internal/download/fileDownloader.ts | 9 +- js/sdk/src/internal/photos/upload.ts | 20 +++- js/sdk/src/internal/upload/controller.ts | 20 +++- js/sdk/src/internal/upload/fileUploader.ts | 4 +- .../internal/upload/streamUploader.test.ts | 60 +++++++++--- js/sdk/src/internal/upload/streamUploader.ts | 95 +++++++++++++++---- 7 files changed, 178 insertions(+), 44 deletions(-) diff --git a/js/sdk/src/internal/download/controller.ts b/js/sdk/src/internal/download/controller.ts index bd6af3e6..0e374b04 100644 --- a/js/sdk/src/internal/download/controller.ts +++ b/js/sdk/src/internal/download/controller.ts @@ -1,11 +1,23 @@ +import { AbortError } from '../../errors'; import { waitForCondition } from '../wait'; export class DownloadController { private paused = false; public promise?: Promise; + constructor(private signal?: AbortSignal) { + this.signal = signal; + } + async waitWhilePaused(): Promise { - await waitForCondition(() => !this.paused); + try { + await waitForCondition(() => !this.paused, this.signal); + } catch (error) { + if (error instanceof AbortError) { + return; + } + throw error; + } } pause(): void { diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index a63b47db..60f64b20 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -1,4 +1,7 @@ +import { c } from 'ttag'; + import { PrivateKey, SessionKey, base64StringToUint8Array } from '../../crypto'; +import { AbortError } from '../../errors'; import { Logger } from '../../interface'; import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; @@ -48,7 +51,7 @@ export class FileDownloader { this.revision = revision; this.signal = signal; this.onFinish = onFinish; - this.controller = new DownloadController(); + this.controller = new DownloadController(this.signal); } getClaimedSizeInBytes(): number | undefined { @@ -289,6 +292,10 @@ export class FileDownloader { logger.debug(`Decrypting`); decryptedBlock = await this.cryptoService.decryptBlock(encryptedBlock, cryptoKeys); } catch (error) { + if (this.signal?.aborted) { + throw new AbortError(c('Error').t`Operation aborted`); + } + if (blockProgress !== 0) { onProgress?.(-blockProgress); blockProgress = 0; diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index 803c7809..72ee7116 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -85,7 +85,25 @@ export class PhotoStreamUploader extends StreamUploader { controller: UploadController, signal?: AbortSignal, ) { - super(telemetry, apiService, cryptoService, uploadManager, blockVerifier, revisionDraft, metadata, onFinish, controller, signal); + const abortController = new AbortController(); + if (signal) { + signal.addEventListener('abort', () => { + abortController.abort(); + }); + } + + super( + telemetry, + apiService, + cryptoService, + uploadManager, + blockVerifier, + revisionDraft, + metadata, + onFinish, + controller, + abortController, + ); this.photoUploadManager = uploadManager; this.photoMetadata = metadata; } diff --git a/js/sdk/src/internal/upload/controller.ts b/js/sdk/src/internal/upload/controller.ts index 85772403..dbddf173 100644 --- a/js/sdk/src/internal/upload/controller.ts +++ b/js/sdk/src/internal/upload/controller.ts @@ -1,11 +1,23 @@ +import { AbortError } from '../../errors'; import { waitForCondition } from '../wait'; export class UploadController { private paused = false; - public promise?: Promise<{ nodeRevisionUid: string, nodeUid: string }>; + public promise?: Promise<{ nodeRevisionUid: string; nodeUid: string }>; - async waitIfPaused(): Promise { - await waitForCondition(() => !this.paused); + constructor(private signal?: AbortSignal) { + this.signal = signal; + } + + async waitWhilePaused(): Promise { + try { + await waitForCondition(() => !this.paused, this.signal); + } catch (error) { + if (error instanceof AbortError) { + return; + } + throw error; + } } pause(): void { @@ -16,7 +28,7 @@ export class UploadController { this.paused = false; } - async completion(): Promise<{ nodeRevisionUid: string, nodeUid: string }> { + async completion(): Promise<{ nodeRevisionUid: string; nodeUid: string }> { if (!this.promise) { throw new Error('UploadController.completion() called before upload started'); } diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 68301d39..e555ac0b 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -43,7 +43,7 @@ class Uploader { }); } - this.controller = new UploadController(); + this.controller = new UploadController(this.abortController.signal); } async uploadFromFile( @@ -120,7 +120,7 @@ class Uploader { this.metadata, onFinish, this.controller, - this.signal, + this.abortController, ); } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index ff06750e..80ac164e 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -133,7 +133,7 @@ describe('StreamUploader', () => { metadata, onFinish, controller, - abortController.signal, + abortController, ); }); @@ -146,10 +146,10 @@ describe('StreamUploader', () => { const verifySuccess = async () => { const result = await uploader.start(stream, thumbnails, onProgress); - + expect(result).toEqual({ nodeRevisionUid: 'revisionUid', - nodeUid: 'nodeUid' + nodeUid: 'nodeUid', }); const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); @@ -259,7 +259,7 @@ describe('StreamUploader', () => { metadata, onFinish, controller, - abortController.signal, + abortController, ); await verifySuccess(); @@ -288,7 +288,7 @@ describe('StreamUploader', () => { metadata, onFinish, controller, - abortController.signal, + abortController, ); await verifySuccess(); @@ -433,14 +433,6 @@ describe('StreamUploader', () => { await verifyOnProgress([1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024]); }); - it('should handle abortion', async () => { - const error = new Error('Aborted'); - const promise = uploader.start(stream, thumbnails, onProgress); - abortController.abort(error); - await promise; - expect(apiService.uploadBlock.mock.calls[0][4]?.aborted).toBe(true); - }); - describe('verifyIntegrity', () => { it('should report block verification error', async () => { blockVerifier.verifyBlock = jest.fn().mockRejectedValue(new IntegrityError('Block verification error')); @@ -473,7 +465,7 @@ describe('StreamUploader', () => { } as UploadMetadata, onFinish, controller, - abortController.signal, + abortController, ); await verifyFailure( @@ -498,4 +490,44 @@ describe('StreamUploader', () => { }); }); }); + + describe('abort', () => { + const thumbnails: Thumbnail[] = []; + let stream: ReadableStream; + let streamController: ReadableStreamDefaultController; + + beforeEach(() => { + stream = new ReadableStream({ + start(controller) { + streamController = controller; + }, + }); + }); + + it('should abort at the start', async () => { + const promise = uploader.start(stream, thumbnails); + abortController.abort(); + await expect(promise).rejects.toThrow('Operation aborted'); + }); + + it('should abort when encrypting blocks', async () => { + const promise = uploader.start(stream, thumbnails); + streamController.enqueue(new Uint8Array(FILE_CHUNK_SIZE)); + streamController.enqueue(new Uint8Array(FILE_CHUNK_SIZE)); + streamController.enqueue(new Uint8Array(FILE_CHUNK_SIZE)); + abortController.abort(); + await expect(promise).rejects.toThrow('Operation aborted'); + }); + + it('should abort when uploading block', async () => { + apiService.uploadBlock = jest.fn().mockImplementation(async function () { + abortController.abort(); + }); + + const promise = uploader.start(stream, thumbnails); + streamController.enqueue(new Uint8Array(FILE_CHUNK_SIZE)); + + await expect(promise).rejects.toThrow('Operation aborted'); + }); + }); }); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 8df62c26..a8a388e2 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { Thumbnail, Logger, ThumbnailType, UploadMetadata } from '../../interface'; -import { IntegrityError } from '../../errors'; +import { AbortError, IntegrityError } from '../../errors'; import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from '../apiService'; import { getErrorMessage } from '../errors'; @@ -59,7 +59,6 @@ export class StreamUploader { protected digests: UploadDigests; protected controller: UploadController; - protected abortController: AbortController; protected encryptedThumbnails = new Map(); protected encryptedBlocks = new Map(); @@ -75,6 +74,9 @@ export class StreamUploader { protected uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = []; protected uploadedBlocks: ({ index: number } & EncryptedBlockMetadata)[] = []; + // Error of the whole upload - either encryption or upload error. + protected error: unknown | undefined; + constructor( protected telemetry: UploadTelemetry, protected apiService: UploadAPIService, @@ -85,7 +87,7 @@ export class StreamUploader { protected metadata: UploadMetadata, protected onFinish: (failure: boolean) => Promise, protected uploadController: UploadController, - protected signal?: AbortSignal, + protected abortController: AbortController, ) { this.telemetry = telemetry; this.logger = telemetry.getLoggerForRevision(revisionDraft.nodeRevisionUid); @@ -96,23 +98,16 @@ export class StreamUploader { this.metadata = metadata; this.onFinish = onFinish; - this.signal = signal; - this.abortController = new AbortController(); - if (signal) { - signal.addEventListener('abort', () => { - this.abortController.abort(); - }); - } - this.digests = new UploadDigests(); this.controller = uploadController; + this.abortController = abortController; } async start( stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, - ): Promise<{ nodeRevisionUid: string, nodeUid: string }> { + ): Promise<{ nodeRevisionUid: string; nodeUid: string }> { let failure = false; // File progress is tracked for telemetry - to track at what @@ -157,9 +152,8 @@ export class StreamUploader { return { nodeRevisionUid: this.revisionDraft.nodeRevisionUid, - nodeUid: this.revisionDraft.nodeUid - } - + nodeUid: this.revisionDraft.nodeUid, + }; } private async encryptAndUploadBlocks( @@ -183,8 +177,8 @@ export class StreamUploader { void this.abortUpload(error); }); - while (!encryptionError) { - await this.controller.waitIfPaused(); + while (!this.isUploadAborted) { + await this.controller.waitWhilePaused(); await this.waitForUploadCapacityAndBufferedBlocks(); if (this.isEncryptionFullyFinished) { @@ -198,6 +192,17 @@ export class StreamUploader { } } + // If the upload was aborted due to encryption or upload error, throw + // the original error (it is failing upload). + // If the upload was aborted due to abort signal, throw AbortError + // (it is aborted by the user). + if (this.error) { + throw this.error; + } + if (this.abortController.signal.aborted) { + throw new AbortError(); + } + this.logger.debug(`All blocks uploading, waiting for them to finish`); // Technically this is finished as while-block above will break // when encryption is finished. But in case of error there could @@ -233,6 +238,10 @@ export class StreamUploader { } for (const thumbnail of thumbnails) { + if (this.isUploadAborted) { + break; + } + this.logger.debug(`Encrypting thumbnail ${thumbnail.type}`); const encryptedThumbnail = await this.cryptoService.encryptThumbnail( this.revisionDraft.nodeKeys, @@ -251,9 +260,13 @@ export class StreamUploader { this.digests.update(block); - await this.controller.waitIfPaused(); + await this.controller.waitWhilePaused(); await this.waitForBufferCapacity(); + if (this.isUploadAborted) { + break; + } + this.logger.debug(`Encrypting block ${index}`); let attempt = 0; let integrityError = false; @@ -272,6 +285,11 @@ export class StreamUploader { void this.telemetry.logBlockVerificationError(true); } } catch (error: unknown) { + // Do not retry or report anything if the upload was aborted. + if (error instanceof AbortError) { + throw error; + } + if (error instanceof IntegrityError) { integrityError = true; } @@ -399,6 +417,11 @@ export class StreamUploader { }); break; } catch (error: unknown) { + // Do not retry or report anything if the upload was aborted. + if (error instanceof AbortError) { + throw error; + } + if (blockProgress !== 0) { onProgress?.(-blockProgress); blockProgress = 0; @@ -461,6 +484,11 @@ export class StreamUploader { }); break; } catch (error: unknown) { + // Do not retry or report anything if the upload was aborted. + if (error instanceof AbortError) { + throw error; + } + if (blockProgress !== 0) { onProgress?.(-blockProgress); blockProgress = 0; @@ -510,7 +538,17 @@ export class StreamUploader { private async waitForBufferCapacity() { if (this.encryptedBlocks.size >= MAX_BUFFERED_BLOCKS) { - await waitForCondition(() => this.encryptedBlocks.size < MAX_BUFFERED_BLOCKS); + try { + await waitForCondition( + () => this.encryptedBlocks.size < MAX_BUFFERED_BLOCKS, + this.abortController.signal, + ); + } catch (error: unknown) { + if (error instanceof AbortError) { + return; + } + throw error; + } } } @@ -518,7 +556,17 @@ export class StreamUploader { while (this.ongoingUploads.size >= MAX_UPLOADING_BLOCKS) { await Promise.race(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); } - await waitForCondition(() => this.encryptedBlocks.size > 0 || this.encryptionFinished); + try { + await waitForCondition( + () => this.encryptedBlocks.size > 0 || this.encryptionFinished, + this.abortController.signal, + ); + } catch (error: unknown) { + if (error instanceof AbortError) { + return; + } + throw error; + } } protected verifyIntegrity(thumbnails: Thumbnail[]) { @@ -573,9 +621,14 @@ export class StreamUploader { } private async abortUpload(error: unknown) { - if (this.abortController.signal.aborted || this.signal?.aborted) { + if (this.isUploadAborted) { return; } + this.error = error; this.abortController.abort(error); } + + private get isUploadAborted(): boolean { + return !!this.error || this.abortController.signal.aborted; + } } From 43506b55d7bfa9f21d50c08543bd13b01645f90a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 26 Sep 2025 14:45:50 +0200 Subject: [PATCH 251/791] Add telemetry for debouncer --- js/sdk/src/interface/telemetry.ts | 5 +++ js/sdk/src/internal/nodes/debouncer.test.ts | 38 ++++++++++++------- js/sdk/src/internal/nodes/debouncer.ts | 24 ++++++++++-- js/sdk/src/internal/nodes/index.ts | 9 +---- js/sdk/src/internal/nodes/nodesAccess.test.ts | 4 +- js/sdk/src/internal/nodes/nodesAccess.ts | 18 +++++++-- js/sdk/src/internal/sharingPublic/index.ts | 2 +- js/sdk/src/internal/sharingPublic/nodes.ts | 22 +++++------ js/sdk/src/tests/telemetry.ts | 9 +++-- 9 files changed, 85 insertions(+), 46 deletions(-) diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 98c52809..07a15e8f 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -12,6 +12,7 @@ export interface Logger { export type MetricEvent = | MetricAPIRetrySucceededEvent + | MetricDebounceLongWaitEvent | MetricUploadEvent | MetricDownloadEvent | MetricDecryptionErrorEvent @@ -25,6 +26,10 @@ export interface MetricAPIRetrySucceededEvent { failedAttempts: number; } +export interface MetricDebounceLongWaitEvent { + eventName: 'debounceLongWait'; +} + export interface MetricUploadEvent { eventName: 'upload'; volumeType?: MetricVolumeType; diff --git a/js/sdk/src/internal/nodes/debouncer.test.ts b/js/sdk/src/internal/nodes/debouncer.test.ts index 7897a3af..a68671b1 100644 --- a/js/sdk/src/internal/nodes/debouncer.test.ts +++ b/js/sdk/src/internal/nodes/debouncer.test.ts @@ -1,18 +1,14 @@ +import { ProtonDriveTelemetry } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; import { NodesDebouncer } from './debouncer'; -import { Logger } from '../../interface'; describe('NodesDebouncer', () => { let debouncer: NodesDebouncer; - let mockLogger: jest.Mocked; + let mockTelemetry: ReturnType; beforeEach(() => { - mockLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; - debouncer = new NodesDebouncer(mockLogger); + mockTelemetry = getMockTelemetry(); + debouncer = new NodesDebouncer(mockTelemetry); jest.useFakeTimers(); }); @@ -77,7 +73,22 @@ describe('NodesDebouncer', () => { debouncer.loadingNode(nodeUid); debouncer.loadingNode(nodeUid); - expect(mockLogger.warn).toHaveBeenCalledWith(`debouncer: Loading twice for: ${nodeUid}`); + expect(mockTelemetry.mockLogger.warn).toHaveBeenCalledWith(`Loading twice for: ${nodeUid}`); + }); + + it('should send metric when waiting for a long time', async () => { + const nodeUid = 'test-node-1'; + debouncer.loadingNode(nodeUid); + + const waitPromise = debouncer.waitForLoadingNode(nodeUid); + expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1500); + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'debounceLongWait', + }); + + debouncer.finishedLoadingNode(nodeUid); + await waitPromise; }); it('should timeout', async () => { @@ -85,7 +96,7 @@ describe('NodesDebouncer', () => { debouncer.loadingNode(nodeUid); jest.advanceTimersByTime(6000); - expect(mockLogger.warn).toHaveBeenCalledWith(`debouncer: Timeout for: ${nodeUid}`); + expect(mockTelemetry.mockLogger.warn).toHaveBeenCalledWith(`Timeout for: ${nodeUid}`); await expect(debouncer.waitForLoadingNode(nodeUid)).resolves.toBeUndefined(); }); @@ -112,7 +123,7 @@ describe('NodesDebouncer', () => { const result = await debouncer.waitForLoadingNode(nodeUid); expect(result).toBeUndefined(); - expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockTelemetry.mockLogger.debug).not.toHaveBeenCalled(); }); it('should wait for registered node and log debug message', async () => { @@ -121,7 +132,8 @@ describe('NodesDebouncer', () => { const waitPromise = debouncer.waitForLoadingNode(nodeUid); - expect(mockLogger.debug).toHaveBeenCalledWith(`debouncer: Wait for: ${nodeUid}`); + expect(mockTelemetry.mockLogger.debug).toHaveBeenCalledWith(`Wait for: ${nodeUid}`); + debouncer.finishedLoadingNode(nodeUid); await waitPromise; }); diff --git a/js/sdk/src/internal/nodes/debouncer.ts b/js/sdk/src/internal/nodes/debouncer.ts index 58ea22fa..75cf6287 100644 --- a/js/sdk/src/internal/nodes/debouncer.ts +++ b/js/sdk/src/internal/nodes/debouncer.ts @@ -1,5 +1,4 @@ -import { Logger } from "../../interface"; -import { LoggerWithPrefix } from '../../telemetry'; +import { Logger, ProtonDriveTelemetry } from '../../interface'; /** * The timeout for which the node is considered to be loading. @@ -11,6 +10,12 @@ import { LoggerWithPrefix } from '../../telemetry'; */ const DEBOUNCE_TIMEOUT = 5000; +/** + * The timeout for which the node is considered to be waiting for a long time. + * After this timeout the metric is sent. + */ +const DEBOUNCE_LONG_WAIT_TIMEOUT = 1000; + /** * Helper to avoid loading the same node twice. * @@ -23,6 +28,8 @@ const DEBOUNCE_TIMEOUT = 5000; * the node to be loaded if that is the case. */ export class NodesDebouncer { + private logger: Logger; + private promises: Map< string, { @@ -32,8 +39,9 @@ export class NodesDebouncer { } > = new Map(); - constructor(private logger: Logger) { - this.logger = new LoggerWithPrefix(logger, 'debouncer'); + constructor(private telemetry: ProtonDriveTelemetry) { + this.logger = telemetry.getLogger('nodes-debouncer'); + this.telemetry = telemetry; } loadingNodes(nodeUids: string[]) { @@ -79,8 +87,16 @@ export class NodesDebouncer { return; } + const metricTimeout = setTimeout(() => { + this.telemetry.recordMetric({ + eventName: 'debounceLongWait', + }); + }, DEBOUNCE_LONG_WAIT_TIMEOUT); + this.logger.debug(`Wait for: ${nodeUid}`); await result.promise; + + clearTimeout(metricTimeout); } clear() { diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 00dbc214..837e8ed8 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -43,14 +43,7 @@ export function initNodesModule( const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService); const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); - const nodesAccess = new NodesAccess( - telemetry.getLogger('nodes'), - api, - cache, - cryptoCache, - cryptoService, - sharesService, - ); + const nodesAccess = new NodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService); const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache); const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess); const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index d2a4a08d..29b15c0d 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -1,4 +1,4 @@ -import { getMockLogger } from '../../tests/logger'; +import { getMockTelemetry } from '../../tests/telemetry'; import { PrivateKey } from '../../crypto'; import { DecryptionError } from '../../errors'; import { NodeAPIService } from './apiService'; @@ -50,7 +50,7 @@ describe('nodesAccess', () => { getSharePrivateKey: jest.fn(), }; - access = new NodesAccess(getMockLogger(), apiService, cache, cryptoCache, cryptoService, shareService); + access = new NodesAccess(getMockTelemetry(), apiService, cache, cryptoCache, cryptoService, shareService); }); describe('getNode', () => { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 4e313979..a480b954 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,7 +1,16 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey } from '../../crypto'; -import { InvalidNameError, Logger, MissingNode, NodeType, Result, resultError, resultOk } from '../../interface'; +import { + InvalidNameError, + Logger, + MissingNode, + NodeType, + ProtonDriveTelemetry, + Result, + resultError, + resultOk, +} from '../../interface'; import { DecryptionError, ProtonDriveError } from '../../errors'; import { asyncIteratorMap } from '../asyncIteratorMap'; import { getErrorMessage } from '../errors'; @@ -41,10 +50,11 @@ const DECRYPTION_CONCURRENCY = 30; * nodes metadata. */ export class NodesAccess { + private logger: Logger; private debouncer: NodesDebouncer; constructor( - private logger: Logger, + private telemetry: ProtonDriveTelemetry, private apiService: NodeAPIService, private cache: NodesCache, private cryptoCache: NodesCryptoCache, @@ -54,13 +64,13 @@ export class NodesAccess { 'getOwnVolumeIDs' | 'getSharePrivateKey' | 'getContextShareMemberEmailKey' >, ) { - this.logger = logger; + this.logger = telemetry.getLogger('nodes'); this.apiService = apiService; this.cache = cache; this.cryptoCache = cryptoCache; this.cryptoService = cryptoService; this.shareService = shareService; - this.debouncer = new NodesDebouncer(this.logger); + this.debouncer = new NodesDebouncer(this.telemetry); } async getVolumeRootFolder() { diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 53bc860b..52c47931 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -85,7 +85,7 @@ export function initSharingPublicNodesModule( const cryptoReporter = new SharingPublicCryptoReporter(telemetry); const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); const nodesAccess = new SharingPublicNodesAccess( - telemetry.getLogger('nodes'), + telemetry, api, cache, cryptoCache, diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index aaeb6084..6a737cdd 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -1,16 +1,16 @@ -import { Logger } from "../../interface"; -import { NodeAPIService } from "../nodes/apiService"; -import { NodesCache } from "../nodes/cache"; -import { NodesCryptoCache } from "../nodes/cryptoCache"; -import { NodesCryptoService } from "../nodes/cryptoService"; -import { NodesAccess } from "../nodes/nodesAccess"; -import { isProtonDocument, isProtonSheet } from "../nodes/mediaTypes"; -import { splitNodeUid } from "../uids"; -import { SharingPublicSharesManager } from "./shares"; +import { ProtonDriveTelemetry } from '../../interface'; +import { NodeAPIService } from '../nodes/apiService'; +import { NodesCache } from '../nodes/cache'; +import { NodesCryptoCache } from '../nodes/cryptoCache'; +import { NodesCryptoService } from '../nodes/cryptoService'; +import { NodesAccess } from '../nodes/nodesAccess'; +import { isProtonDocument, isProtonSheet } from '../nodes/mediaTypes'; +import { splitNodeUid } from '../uids'; +import { SharingPublicSharesManager } from './shares'; export class SharingPublicNodesAccess extends NodesAccess { constructor( - logger: Logger, + telemetry: ProtonDriveTelemetry, apiService: NodeAPIService, cache: NodesCache, cryptoCache: NodesCryptoCache, @@ -19,7 +19,7 @@ export class SharingPublicNodesAccess extends NodesAccess { private url: string, private token: string, ) { - super(logger, apiService, cache, cryptoCache, cryptoService, sharesService); + super(telemetry, apiService, cache, cryptoCache, cryptoService, sharesService); this.token = token; } diff --git a/js/sdk/src/tests/telemetry.ts b/js/sdk/src/tests/telemetry.ts index a124874b..6efc94fe 100644 --- a/js/sdk/src/tests/telemetry.ts +++ b/js/sdk/src/tests/telemetry.ts @@ -1,9 +1,12 @@ -import { ProtonDriveTelemetry } from '../interface'; +import { Logger, ProtonDriveTelemetry } from '../interface'; import { getMockLogger } from './logger'; -export function getMockTelemetry(): ProtonDriveTelemetry { +export function getMockTelemetry(): ProtonDriveTelemetry & { mockLogger: Logger } { + const mockLogger = getMockLogger(); + return { - getLogger: getMockLogger, + mockLogger, + getLogger: () => mockLogger, recordMetric: jest.fn(), }; } From 3d2040d5182cdfc5b7d4065684b57a65e0e1ea47 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Oct 2025 14:29:23 +0200 Subject: [PATCH 252/791] Use shares/photos endpoint to bootstrap photos --- js/sdk/src/internal/apiService/driveTypes.ts | 5227 +++++++++--------- js/sdk/src/internal/photos/apiService.ts | 41 +- 2 files changed, 2710 insertions(+), 2558 deletions(-) diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 0556f05d..225e65ed 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -4,7 +4,7 @@ */ export interface paths { - '/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple': { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple": { parameters: { query?: never; header?: never; @@ -14,14 +14,14 @@ export interface paths { get?: never; put?: never; /** Add photos to an album */ - post: operations['post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple']; + post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/albums': { + "/drive/photos/volumes/{volumeID}/albums": { parameters: { query?: never; header?: never; @@ -29,17 +29,17 @@ export interface paths { cookie?: never; }; /** List current user albums */ - get: operations['get_drive-photos-volumes-{volumeID}-albums']; + get: operations["get_drive-photos-volumes-{volumeID}-albums"]; put?: never; /** Create an album */ - post: operations['post_drive-photos-volumes-{volumeID}-albums']; + post: operations["post_drive-photos-volumes-{volumeID}-albums"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/volumes': { + "/drive/photos/volumes": { parameters: { query?: never; header?: never; @@ -55,14 +55,14 @@ export interface paths { * + Photo share for the new Photo Volume * + Adds ShareMember with given Address ID */ - post: operations['post_drive-photos-volumes']; + post: operations["post_drive-photos-volumes"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/albums/{linkID}': { + "/drive/photos/volumes/{volumeID}/albums/{linkID}": { parameters: { query?: never; header?: never; @@ -71,16 +71,16 @@ export interface paths { }; get?: never; /** Update an album */ - put: operations['put_drive-photos-volumes-{volumeID}-albums-{linkID}']; + put: operations["put_drive-photos-volumes-{volumeID}-albums-{linkID}"]; post?: never; /** Delete an album */ - delete: operations['delete_drive-photos-volumes-{volumeID}-albums-{linkID}']; + delete: operations["delete_drive-photos-volumes-{volumeID}-albums-{linkID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/albums/{linkID}/duplicates': { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/duplicates": { parameters: { query?: never; header?: never; @@ -90,14 +90,14 @@ export interface paths { get?: never; put?: never; /** Find duplicates in album */ - post: operations['post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates']; + post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/tags-migration': { + "/drive/photos/volumes/{volumeID}/tags-migration": { parameters: { query?: never; header?: never; @@ -105,17 +105,17 @@ export interface paths { cookie?: never; }; /** Get photo tag migration status */ - get: operations['get_drive-photos-volumes-{volumeID}-tags-migration']; + get: operations["get_drive-photos-volumes-{volumeID}-tags-migration"]; put?: never; /** Update tag migration status */ - post: operations['post_drive-photos-volumes-{volumeID}-tags-migration']; + post: operations["post_drive-photos-volumes-{volumeID}-tags-migration"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/albums/{linkID}/children': { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/children": { parameters: { query?: never; header?: never; @@ -123,7 +123,7 @@ export interface paths { cookie?: never; }; /** List photos in album */ - get: operations['get_drive-photos-volumes-{volumeID}-albums-{linkID}-children']; + get: operations["get_drive-photos-volumes-{volumeID}-albums-{linkID}-children"]; put?: never; post?: never; delete?: never; @@ -132,7 +132,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/recover-multiple': { + "/drive/photos/volumes/{volumeID}/recover-multiple": { parameters: { query?: never; header?: never; @@ -141,7 +141,7 @@ export interface paths { }; get?: never; /** Recover photos from your photo volume */ - put: operations['put_drive-photos-volumes-{volumeID}-recover-multiple']; + put: operations["put_drive-photos-volumes-{volumeID}-recover-multiple"]; post?: never; delete?: never; options?: never; @@ -149,7 +149,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple': { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple": { parameters: { query?: never; header?: never; @@ -158,21 +158,21 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple']; + post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/albums/shared-with-me': { + "/drive/photos/albums/shared-with-me": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_drive-photos-albums-shared-with-me']; + get: operations["get_drive-photos-albums-shared-with-me"]; put?: never; post?: never; delete?: never; @@ -181,7 +181,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/links/transfer-multiple': { + "/drive/volumes/{volumeID}/links/transfer-multiple": { parameters: { query?: never; header?: never; @@ -193,7 +193,7 @@ export interface paths { * Transfer photos from and to albums * @deprecated */ - put: operations['put_drive-volumes-{volumeID}-links-transfer-multiple']; + put: operations["put_drive-volumes-{volumeID}-links-transfer-multiple"]; post?: never; delete?: never; options?: never; @@ -201,7 +201,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/links/transfer-multiple': { + "/drive/photos/volumes/{volumeID}/links/transfer-multiple": { parameters: { query?: never; header?: never; @@ -210,7 +210,7 @@ export interface paths { }; get?: never; /** Transfer photos from and to albums */ - put: operations['put_drive-photos-volumes-{volumeID}-links-transfer-multiple']; + put: operations["put_drive-photos-volumes-{volumeID}-links-transfer-multiple"]; post?: never; delete?: never; options?: never; @@ -218,7 +218,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/urls/{token}/bookmark': { + "/drive/v2/urls/{token}/bookmark": { parameters: { query?: never; header?: never; @@ -231,18 +231,18 @@ export interface paths { * Create ShareURL Bookmark * @description It creates a bookmark for the user in an already existing ShareURL. The bookmark would be stored for the current user if the password is encrypted with his/her addressKey */ - post: operations['post_drive-v2-urls-{token}-bookmark']; + post: operations["post_drive-v2-urls-{token}-bookmark"]; /** * Delete ShareURL Bookmark * @description It soft deletes the bookmark share url, that would be GC later. The user should be the owner of the bookmark. */ - delete: operations['delete_drive-v2-urls-{token}-bookmark']; + delete: operations["delete_drive-v2-urls-{token}-bookmark"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shared-bookmarks': { + "/drive/v2/shared-bookmarks": { parameters: { query?: never; header?: never; @@ -253,7 +253,7 @@ export interface paths { * List all Bookmarks * @description This endpoint would only show active bookmarks from the user doing the request */ - get: operations['get_drive-v2-shared-bookmarks']; + get: operations["get_drive-v2-shared-bookmarks"]; put?: never; post?: never; delete?: never; @@ -262,7 +262,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/devices': { + "/drive/devices": { parameters: { query?: never; header?: never; @@ -273,17 +273,17 @@ export interface paths { * List devices * @description Gives a list of devices for current user, ordered by creationTime DESC */ - get: operations['get_drive-devices']; + get: operations["get_drive-devices"]; put?: never; /** Create a Device */ - post: operations['post_drive-devices']; + post: operations["post_drive-devices"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/devices/{deviceID}': { + "/drive/devices/{deviceID}": { parameters: { query?: never; header?: never; @@ -292,16 +292,16 @@ export interface paths { }; get?: never; /** Update device */ - put: operations['put_drive-devices-{deviceID}']; + put: operations["put_drive-devices-{deviceID}"]; post?: never; /** Delete a device */ - delete: operations['delete_drive-devices-{deviceID}']; + delete: operations["delete_drive-devices-{deviceID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/devices': { + "/drive/v2/devices": { parameters: { query?: never; header?: never; @@ -309,7 +309,7 @@ export interface paths { cookie?: never; }; /** List devices (v2) */ - get: operations['get_drive-v2-devices']; + get: operations["get_drive-v2-devices"]; put?: never; post?: never; delete?: never; @@ -318,7 +318,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/documents': { + "/drive/v2/volumes/{volumeID}/documents": { parameters: { query?: never; header?: never; @@ -331,14 +331,34 @@ export interface paths { * Create document * @description Create a new proton document. */ - post: operations['post_drive-shares-{shareID}-documents']; + post: operations["post_drive-v2-volumes-{volumeID}-documents"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/events/latest': { + "/drive/shares/{shareID}/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create document + * @description Create a new proton document. + */ + post: operations["post_drive-shares-{shareID}-documents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/events/latest": { parameters: { query?: never; header?: never; @@ -350,7 +370,7 @@ export interface paths { * @deprecated * @description Get latest EventID for a given share. Deprecated: Use events per volume instead. */ - get: operations['get_drive-shares-{shareID}-events-latest']; + get: operations["get_drive-shares-{shareID}-events-latest"]; put?: never; post?: never; delete?: never; @@ -359,7 +379,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/events/latest': { + "/drive/volumes/{volumeID}/events/latest": { parameters: { query?: never; header?: never; @@ -370,7 +390,7 @@ export interface paths { * Get latest volume event * @description Get latest EventID for a given volume. */ - get: operations['get_drive-volumes-{volumeID}-events-latest']; + get: operations["get_drive-volumes-{volumeID}-events-latest"]; put?: never; post?: never; delete?: never; @@ -379,7 +399,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/events/{eventID}': { + "/drive/shares/{shareID}/events/{eventID}": { parameters: { query?: never; header?: never; @@ -391,7 +411,7 @@ export interface paths { * @deprecated * @description Get new events for given share since eventID. Deprecated: Use events per volume instead. */ - get: operations['get_drive-shares-{shareID}-events-{eventID}']; + get: operations["get_drive-shares-{shareID}-events-{eventID}"]; put?: never; post?: never; delete?: never; @@ -400,7 +420,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/events/{eventID}': { + "/drive/volumes/{volumeID}/events/{eventID}": { parameters: { query?: never; header?: never; @@ -411,7 +431,7 @@ export interface paths { * List volume events * @description Get new events for given volume since eventID. */ - get: operations['get_drive-volumes-{volumeID}-events-{eventID}']; + get: operations["get_drive-volumes-{volumeID}-events-{eventID}"]; put?: never; post?: never; delete?: never; @@ -420,7 +440,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/events/{eventID}': { + "/drive/v2/volumes/{volumeID}/events/{eventID}": { parameters: { query?: never; header?: never; @@ -432,7 +452,7 @@ export interface paths { * @description Get new events for given volume since eventID. * RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0054-light-events/ */ - get: operations['get_drive-v2-volumes-{volumeID}-events-{eventID}']; + get: operations["get_drive-v2-volumes-{volumeID}-events-{eventID}"]; put?: never; post?: never; delete?: never; @@ -441,7 +461,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/folders': { + "/drive/shares/{shareID}/folders": { parameters: { query?: never; header?: never; @@ -454,14 +474,14 @@ export interface paths { * Create a folder * @description Create a new folder in a given share, under a given folder link. */ - post: operations['post_drive-shares-{shareID}-folders']; + post: operations["post_drive-shares-{shareID}-folders"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/folders/{linkID}/delete_multiple': { + "/drive/shares/{shareID}/folders/{linkID}/delete_multiple": { parameters: { query?: never; header?: never; @@ -474,14 +494,14 @@ export interface paths { * Delete children * @description Permanently delete children from folder, skipping trash. Can only be done for draft links. */ - post: operations['post_drive-shares-{shareID}-folders-{linkID}-delete_multiple']; + post: operations["post_drive-shares-{shareID}-folders-{linkID}-delete_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/folders/{linkID}/children': { + "/drive/shares/{shareID}/folders/{linkID}/children": { parameters: { query?: never; header?: never; @@ -492,7 +512,7 @@ export interface paths { * List folder children * @description List children of a given folder. */ - get: operations['get_drive-shares-{shareID}-folders-{linkID}-children']; + get: operations["get_drive-shares-{shareID}-folders-{linkID}-children"]; put?: never; post?: never; delete?: never; @@ -501,7 +521,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/folders/{linkID}/trash_multiple': { + "/drive/shares/{shareID}/folders/{linkID}/trash_multiple": { parameters: { query?: never; header?: never; @@ -514,14 +534,14 @@ export interface paths { * Trash children * @description Send children to trash */ - post: operations['post_drive-shares-{shareID}-folders-{linkID}-trash_multiple']; + post: operations["post_drive-shares-{shareID}-folders-{linkID}-trash_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/folders/{linkID}': { + "/drive/shares/{shareID}/folders/{linkID}": { parameters: { query?: never; header?: never; @@ -530,7 +550,7 @@ export interface paths { }; get?: never; /** Update folder attributes */ - put: operations['put_drive-shares-{shareID}-folders-{linkID}']; + put: operations["put_drive-shares-{shareID}-folders-{linkID}"]; post?: never; delete?: never; options?: never; @@ -538,7 +558,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/folders': { + "/drive/v2/volumes/{volumeID}/folders": { parameters: { query?: never; header?: never; @@ -551,14 +571,14 @@ export interface paths { * Create a folder (v2) * @description Create a new folder in a given share, under a given folder link. */ - post: operations['post_drive-v2-volumes-{volumeID}-folders']; + post: operations["post_drive-v2-volumes-{volumeID}-folders"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/folders/{linkID}/children': { + "/drive/v2/volumes/{volumeID}/folders/{linkID}/children": { parameters: { query?: never; header?: never; @@ -569,7 +589,7 @@ export interface paths { * List folder children (v2) * @description List children IDs of a given folder. */ - get: operations['get_drive-v2-volumes-{volumeID}-folders-{linkID}-children']; + get: operations["get_drive-v2-volumes-{volumeID}-folders-{linkID}-children"]; put?: never; post?: never; delete?: never; @@ -578,7 +598,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes': { + "/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes": { parameters: { query?: never; header?: never; @@ -593,14 +613,14 @@ export interface paths { * * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. */ - post: operations['post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes']; + post: operations["post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/links/{linkID}/checkAvailableHashes': { + "/drive/shares/{shareID}/links/{linkID}/checkAvailableHashes": { parameters: { query?: never; header?: never; @@ -615,14 +635,14 @@ export interface paths { * * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. */ - post: operations['post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes']; + post: operations["post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/links/{linkID}/copy': { + "/drive/volumes/{volumeID}/links/{linkID}/copy": { parameters: { query?: never; header?: never; @@ -635,14 +655,14 @@ export interface paths { * Copy a node to a volume * @description Copy a single file to a volume, providing the new parent link ID. */ - post: operations['post_drive-volumes-{volumeID}-links-{linkID}-copy']; + post: operations["post_drive-volumes-{volumeID}-links-{linkID}-copy"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/delete_multiple': { + "/drive/v2/volumes/{volumeID}/delete_multiple": { parameters: { query?: never; header?: never; @@ -655,14 +675,14 @@ export interface paths { * Delete multiple (v2) * @description Permanently delete links, skipping trash. Can only be done for draft links. */ - post: operations['post_drive-v2-volumes-{volumeID}-delete_multiple']; + post: operations["post_drive-v2-volumes-{volumeID}-delete_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/links/fetch_metadata': { + "/drive/shares/{shareID}/links/fetch_metadata": { parameters: { query?: never; header?: never; @@ -672,14 +692,14 @@ export interface paths { get?: never; put?: never; /** Fetch links in share */ - post: operations['post_drive-shares-{shareID}-links-fetch_metadata']; + post: operations["post_drive-shares-{shareID}-links-fetch_metadata"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/links/fetch_metadata': { + "/drive/volumes/{volumeID}/links/fetch_metadata": { parameters: { query?: never; header?: never; @@ -689,14 +709,14 @@ export interface paths { get?: never; put?: never; /** Fetch links in volume */ - post: operations['post_drive-volumes-{volumeID}-links-fetch_metadata']; + post: operations["post_drive-volumes-{volumeID}-links-fetch_metadata"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/links/{linkID}': { + "/drive/shares/{shareID}/links/{linkID}": { parameters: { query?: never; header?: never; @@ -707,7 +727,7 @@ export interface paths { * Get link data * @description Retrieve individual link information. */ - get: operations['get_drive-shares-{shareID}-links-{linkID}']; + get: operations["get_drive-shares-{shareID}-links-{linkID}"]; put?: never; post?: never; delete?: never; @@ -716,7 +736,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/sanitization/mhk': { + "/drive/sanitization/mhk": { parameters: { query?: never; header?: never; @@ -724,17 +744,17 @@ export interface paths { cookie?: never; }; /** List folders with missing hash keys */ - get: operations['get_drive-sanitization-mhk']; + get: operations["get_drive-sanitization-mhk"]; put?: never; /** List folders with missing hash keys */ - post: operations['post_drive-sanitization-mhk']; + post: operations["post_drive-sanitization-mhk"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/links': { + "/drive/v2/volumes/{volumeID}/links": { parameters: { query?: never; header?: never; @@ -744,14 +764,14 @@ export interface paths { get?: never; put?: never; /** Load links details */ - post: operations['post_drive-v2-volumes-{volumeID}-links']; + post: operations["post_drive-v2-volumes-{volumeID}-links"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/links/move-multiple': { + "/drive/volumes/{volumeID}/links/move-multiple": { parameters: { query?: never; header?: never; @@ -760,7 +780,7 @@ export interface paths { }; get?: never; /** Move a batch of files, folders or photos. */ - put: operations['put_drive-volumes-{volumeID}-links-move-multiple']; + put: operations["put_drive-volumes-{volumeID}-links-move-multiple"]; post?: never; delete?: never; options?: never; @@ -768,7 +788,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/links/{linkID}/move': { + "/drive/shares/{shareID}/links/{linkID}/move": { parameters: { query?: never; header?: never; @@ -784,7 +804,7 @@ export interface paths { * for the name and passphrase as these are also used by shares pointing * to the link. The passphrase should NOT be changed, reusing same session key as previously. */ - put: operations['put_drive-shares-{shareID}-links-{linkID}-move']; + put: operations["put_drive-shares-{shareID}-links-{linkID}-move"]; post?: never; delete?: never; options?: never; @@ -792,7 +812,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/links/{linkID}/rename': { + "/drive/v2/volumes/{volumeID}/links/{linkID}/rename": { parameters: { query?: never; header?: never; @@ -807,7 +827,7 @@ export interface paths { * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. */ - put: operations['put_drive-v2-volumes-{volumeID}-links-{linkID}-rename']; + put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-rename"]; post?: never; delete?: never; options?: never; @@ -815,7 +835,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/links/{linkID}/rename': { + "/drive/shares/{shareID}/links/{linkID}/rename": { parameters: { query?: never; header?: never; @@ -830,7 +850,7 @@ export interface paths { * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. */ - put: operations['put_drive-shares-{shareID}-links-{linkID}-rename']; + put: operations["put_drive-shares-{shareID}-links-{linkID}-rename"]; post?: never; delete?: never; options?: never; @@ -838,7 +858,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/links/{linkID}/move': { + "/drive/v2/volumes/{volumeID}/links/{linkID}/move": { parameters: { query?: never; header?: never; @@ -853,7 +873,7 @@ export interface paths { * for the name and passphrase as these are also used by shares pointing * to the link. The passphrase should NOT be changed,reusing same session key as previously */ - put: operations['put_drive-v2-volumes-{volumeID}-links-{linkID}-move']; + put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-move"]; post?: never; delete?: never; options?: never; @@ -861,7 +881,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}': { + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}": { parameters: { query?: never; header?: never; @@ -872,7 +892,7 @@ export interface paths { * Get revision * @description Get detailed revision information. */ - get: operations['get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}']; + get: operations["get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; /** * Commit a revision * @description The revision becomes the current active one and the updated file content become available for reading. @@ -884,7 +904,7 @@ export interface paths { * 1...BlockNumber will be preserved if they are not overridden by a new block * BlockNumber+1... will be discarded. */ - put: operations['put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}']; + put: operations["put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; post?: never; /** * Delete an obsolete/draft revision @@ -892,13 +912,13 @@ export interface paths { * This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active. Create or revert to * another revision first. You cannot delete a draft revision for a draft link. Delete the link instead. */ - delete: operations['delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}']; + delete: operations["delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}': { + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}": { parameters: { query?: never; header?: never; @@ -909,7 +929,7 @@ export interface paths { * Get revision * @description Get detailed revision information. */ - get: operations['get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}']; + get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; /** * Commit a revision * @description The revision becomes the current active one and the updated file content become available for reading. @@ -921,7 +941,7 @@ export interface paths { * 1...BlockNumber will be preserved if they are not overridden by a new block * BlockNumber+1... will be discarded. */ - put: operations['put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}']; + put: operations["put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; post?: never; /** * Delete an obsolete/draft revision @@ -929,13 +949,13 @@ export interface paths { * This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active. Create or revert to * another revision first. You cannot delete a draft revision for a draft link. Delete the link instead. */ - delete: operations['delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}']; + delete: operations["delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/files': { + "/drive/v2/volumes/{volumeID}/files": { parameters: { query?: never; header?: never; @@ -944,15 +964,15 @@ export interface paths { }; get?: never; put?: never; - /** Create a new file */ - post: operations['post_drive-v2-volumes-{volumeID}-files']; + /** Create a new draft file */ + post: operations["post_drive-v2-volumes-{volumeID}-files"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/files': { + "/drive/shares/{shareID}/files": { parameters: { query?: never; header?: never; @@ -961,15 +981,15 @@ export interface paths { }; get?: never; put?: never; - /** Create a new file */ - post: operations['post_drive-shares-{shareID}-files']; + /** Create a new draft file */ + post: operations["post_drive-shares-{shareID}-files"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/files/{linkID}/revisions': { + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions": { parameters: { query?: never; header?: never; @@ -977,7 +997,7 @@ export interface paths { cookie?: never; }; /** List revisions */ - get: operations['get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions']; + get: operations["get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions"]; put?: never; /** * Create revision @@ -990,14 +1010,14 @@ export interface paths { * or it can be specific to the revision. * The client can use it to identify revisions in case it failed to receive the confirmation of the revision creation. */ - post: operations['post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions']; + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/files/{linkID}/revisions': { + "/drive/shares/{shareID}/files/{linkID}/revisions": { parameters: { query?: never; header?: never; @@ -1005,7 +1025,7 @@ export interface paths { cookie?: never; }; /** List revisions */ - get: operations['get_drive-shares-{shareID}-files-{linkID}-revisions']; + get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions"]; put?: never; /** * Create revision @@ -1018,14 +1038,14 @@ export interface paths { * or it can be specific to the revision. * The client can use it to identify revisions in case it failed to receive the confirmation of the revision creation. */ - post: operations['post_drive-shares-{shareID}-files-{linkID}-revisions']; + post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/thumbnail': { + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/thumbnail": { parameters: { query?: never; header?: never; @@ -1033,7 +1053,7 @@ export interface paths { cookie?: never; }; /** Get revision thumbnail */ - get: operations['get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail']; + get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail"]; put?: never; post?: never; delete?: never; @@ -1042,7 +1062,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore': { + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore": { parameters: { query?: never; header?: never; @@ -1052,14 +1072,14 @@ export interface paths { get?: never; put?: never; /** Restore a revision */ - post: operations['post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore']; + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/restore': { + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/restore": { parameters: { query?: never; header?: never; @@ -1069,14 +1089,14 @@ export interface paths { get?: never; put?: never; /** Restore a revision */ - post: operations['post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore']; + post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification': { + "/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification": { parameters: { query?: never; header?: never; @@ -1087,7 +1107,7 @@ export interface paths { * Get verification data. * @description Get data to verify encryption of the revision before committing. */ - get: operations['get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification']; + get: operations["get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification"]; put?: never; post?: never; delete?: never; @@ -1096,7 +1116,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/links/{linkID}/revisions/{revisionID}/verification': { + "/drive/shares/{shareID}/links/{linkID}/revisions/{revisionID}/verification": { parameters: { query?: never; header?: never; @@ -1107,7 +1127,7 @@ export interface paths { * Get verification data. * @description Get data to verify encryption of the revision before committing. */ - get: operations['get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification']; + get: operations["get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification"]; put?: never; post?: never; delete?: never; @@ -1116,7 +1136,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/trash/delete_multiple': { + "/drive/v2/volumes/{volumeID}/trash/delete_multiple": { parameters: { query?: never; header?: never; @@ -1129,14 +1149,14 @@ export interface paths { * Delete items from trash * @description Permanently delete list of links from trash of a given share. */ - post: operations['post_drive-v2-volumes-{volumeID}-trash-delete_multiple']; + post: operations["post_drive-v2-volumes-{volumeID}-trash-delete_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/trash/delete_multiple': { + "/drive/shares/{shareID}/trash/delete_multiple": { parameters: { query?: never; header?: never; @@ -1149,14 +1169,14 @@ export interface paths { * Delete items from trash * @description Permanently delete list of links from trash of a given share. */ - post: operations['post_drive-shares-{shareID}-trash-delete_multiple']; + post: operations["post_drive-shares-{shareID}-trash-delete_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/trash': { + "/drive/shares/{shareID}/trash": { parameters: { query?: never; header?: never; @@ -1171,7 +1191,7 @@ export interface paths { * * CANNOT be used on Photo-Volume -> use volume-trash */ - get: operations['get_drive-shares-{shareID}-trash']; + get: operations["get_drive-shares-{shareID}-trash"]; put?: never; post?: never; /** @@ -1182,13 +1202,13 @@ export interface paths { * * CANNOT be used on Photo-Volume -> use volume-trash */ - delete: operations['delete_drive-shares-{shareID}-trash']; + delete: operations["delete_drive-shares-{shareID}-trash"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/trash': { + "/drive/volumes/{volumeID}/trash": { parameters: { query?: never; header?: never; @@ -1196,7 +1216,7 @@ export interface paths { cookie?: never; }; /** List volume trash */ - get: operations['get_drive-volumes-{volumeID}-trash']; + get: operations["get_drive-volumes-{volumeID}-trash"]; put?: never; post?: never; /** @@ -1204,13 +1224,13 @@ export interface paths { * @description When there are fewer items in trash than a certain threshold, trash will be deleted synchronously returning a 200 HTTP code. * Otherwise, it will happen async returning a 202 HTTP code. */ - delete: operations['delete_drive-volumes-{volumeID}-trash']; + delete: operations["delete_drive-volumes-{volumeID}-trash"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/trash/restore_multiple': { + "/drive/v2/volumes/{volumeID}/trash/restore_multiple": { parameters: { query?: never; header?: never; @@ -1224,7 +1244,7 @@ export interface paths { * * /shares endpoint should NOT be used on Photo-Volume -> use volume-trash */ - put: operations['put_drive-v2-volumes-{volumeID}-trash-restore_multiple']; + put: operations["put_drive-v2-volumes-{volumeID}-trash-restore_multiple"]; post?: never; delete?: never; options?: never; @@ -1232,7 +1252,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/trash/restore_multiple': { + "/drive/shares/{shareID}/trash/restore_multiple": { parameters: { query?: never; header?: never; @@ -1246,7 +1266,7 @@ export interface paths { * * /shares endpoint should NOT be used on Photo-Volume -> use volume-trash */ - put: operations['put_drive-shares-{shareID}-trash-restore_multiple']; + put: operations["put_drive-shares-{shareID}-trash-restore_multiple"]; post?: never; delete?: never; options?: never; @@ -1254,7 +1274,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/trash_multiple': { + "/drive/v2/volumes/{volumeID}/trash_multiple": { parameters: { query?: never; header?: never; @@ -1267,14 +1287,14 @@ export interface paths { * Trash multiple (v2) * @description Send multiple links to the trash */ - post: operations['post_drive-v2-volumes-{volumeID}-trash_multiple']; + post: operations["post_drive-v2-volumes-{volumeID}-trash_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/blocks': { + "/drive/blocks": { parameters: { query?: never; header?: never; @@ -1287,14 +1307,14 @@ export interface paths { * Request block upload * @description Request upload information for a set of blocks. */ - post: operations['post_drive-blocks']; + post: operations["post_drive-blocks"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/files/small': { + "/drive/v2/volumes/{volumeID}/files/small": { parameters: { query?: never; header?: never; @@ -1307,14 +1327,14 @@ export interface paths { * Upload small file * @description This does not support anonymous uploads (yet) */ - post: operations['post_drive-v2-volumes-{volumeID}-files-small']; + post: operations["post_drive-v2-volumes-{volumeID}-files-small"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small': { + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small": { parameters: { query?: never; header?: never; @@ -1327,14 +1347,14 @@ export interface paths { * Upload small revision * @description This does not support anonymous uploads (yet) */ - post: operations['post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small']; + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/me/active': { + "/drive/me/active": { parameters: { query?: never; header?: never; @@ -1345,7 +1365,7 @@ export interface paths { * Ping active user * @description Endpoint that can be pinged by clients to mark a user as an active user */ - get: operations['get_drive-me-active']; + get: operations["get_drive-me-active"]; put?: never; post?: never; delete?: never; @@ -1354,7 +1374,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/report/url': { + "/drive/report/url": { parameters: { query?: never; header?: never; @@ -1364,14 +1384,30 @@ export interface paths { get?: never; put?: never; /** Report Share URL */ - post: operations['post_drive-report-url']; + post: operations["post_drive-report-url"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/onboarding/fresh-account": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_drive-v2-onboarding-fresh-account"]; + put?: never; + post: operations["post_drive-v2-onboarding-fresh-account"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/checklist/get-started': { + "/drive/v2/checklist/get-started": { parameters: { query?: never; header?: never; @@ -1379,7 +1415,7 @@ export interface paths { cookie?: never; }; /** Get onboarding checklist */ - get: operations['get_drive-v2-checklist-get-started']; + get: operations["get_drive-v2-checklist-get-started"]; put?: never; post?: never; delete?: never; @@ -1388,14 +1424,14 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/onboarding': { + "/drive/v2/onboarding": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_drive-v2-onboarding']; + get: operations["get_drive-v2-onboarding"]; put?: never; post?: never; delete?: never; @@ -1404,7 +1440,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/checklist/get-started/seen-completed-list': { + "/drive/v2/checklist/get-started/seen-completed-list": { parameters: { query?: never; header?: never; @@ -1414,14 +1450,14 @@ export interface paths { get?: never; put?: never; /** Mark completed checklist as seen */ - post: operations['post_drive-v2-checklist-get-started-seen-completed-list']; + post: operations["post_drive-v2-checklist-get-started-seen-completed-list"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/entitlements': { + "/drive/entitlements": { parameters: { query?: never; header?: never; @@ -1432,7 +1468,7 @@ export interface paths { * Get entitlements * @description Get the current entitlements and their value for the logged-in user. */ - get: operations['get_drive-entitlements']; + get: operations["get_drive-entitlements"]; put?: never; post?: never; delete?: never; @@ -1441,7 +1477,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/links/{linkID}/tags': { + "/drive/photos/volumes/{volumeID}/links/{linkID}/tags": { parameters: { query?: never; header?: never; @@ -1451,15 +1487,15 @@ export interface paths { get?: never; put?: never; /** Add tags to existing photo */ - post: operations['post_drive-photos-volumes-{volumeID}-links-{linkID}-tags']; + post: operations["post_drive-photos-volumes-{volumeID}-links-{linkID}-tags"]; /** Remove tags from existing photo */ - delete: operations['delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags']; + delete: operations["delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/photos/share': { + "/drive/volumes/{volumeID}/photos/share": { parameters: { query?: never; header?: never; @@ -1472,14 +1508,14 @@ export interface paths { * DEPRECATED: Create photo share * @deprecated */ - post: operations['post_drive-volumes-{volumeID}-photos-share']; + post: operations["post_drive-volumes-{volumeID}-photos-share"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/photos/share/{shareID}': { + "/drive/volumes/{volumeID}/photos/share/{shareID}": { parameters: { query?: never; header?: never; @@ -1493,13 +1529,13 @@ export interface paths { * Delete empty photo share * @description Can only delete Photo Shares that are empty. */ - delete: operations['delete_drive-volumes-{volumeID}-photos-share-{shareID}']; + delete: operations["delete_drive-volumes-{volumeID}-photos-share-{shareID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/links/{linkID}/favorite': { + "/drive/photos/volumes/{volumeID}/links/{linkID}/favorite": { parameters: { query?: never; header?: never; @@ -1509,14 +1545,14 @@ export interface paths { get?: never; put?: never; /** Favorite existing photo */ - post: operations['post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite']; + post: operations["post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/photos/duplicates': { + "/drive/volumes/{volumeID}/photos/duplicates": { parameters: { query?: never; header?: never; @@ -1526,14 +1562,14 @@ export interface paths { get?: never; put?: never; /** Find duplicates */ - post: operations['post_drive-volumes-{volumeID}-photos-duplicates']; + post: operations["post_drive-volumes-{volumeID}-photos-duplicates"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/photos/migrate-legacy': { + "/drive/photos/migrate-legacy": { parameters: { query?: never; header?: never; @@ -1544,20 +1580,20 @@ export interface paths { * Get status of migration from legacy photo share on a regular volume into a new Photo Volume * @deprecated */ - get: operations['get_drive-photos-migrate-legacy']; + get: operations["get_drive-photos-migrate-legacy"]; put?: never; /** * DEPRECATED: All shares have been migrated, always returns share not found * @deprecated */ - post: operations['post_drive-photos-migrate-legacy']; + post: operations["post_drive-photos-migrate-legacy"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/photos': { + "/drive/volumes/{volumeID}/photos": { parameters: { query?: never; header?: never; @@ -1568,7 +1604,7 @@ export interface paths { * List photos sorted by capture time * @description When paginating to secondary pages, the PreviousPageLastLinkID must be provided. */ - get: operations['get_drive-volumes-{volumeID}-photos']; + get: operations["get_drive-volumes-{volumeID}-photos"]; put?: never; post?: never; delete?: never; @@ -1577,7 +1613,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/photos/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/xattr': { + "/drive/photos/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/xattr": { parameters: { query?: never; header?: never; @@ -1589,7 +1625,44 @@ export interface paths { * Update xAttr Photo-Link * @description ONLY for use by iOS, due to a bug in the iOS client, xAttr were not populated for photos, the client can use this endpoint to fix this. */ - put: operations['put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr']; + put: operations["put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/auth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Authenticate on a public Share URL + * @description Client proves to know the URL password and receives session information + */ + post: operations["post_drive-urls-{token}-auth"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Initiate shared by URL session with SRP */ + get: operations["get_drive-urls-{token}-info"]; + put?: never; post?: never; delete?: never; options?: never; @@ -1597,7 +1670,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/urls/{token}/files/{linkID}/checkAvailableHashes': { + "/drive/urls/{token}/files/{linkID}/checkAvailableHashes": { parameters: { query?: never; header?: never; @@ -1608,18 +1681,19 @@ export interface paths { put?: never; /** * Check available hashes + * @deprecated * @description Filter unavailable hashes out of a list of hashes under a given parent folder. * * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. */ - post: operations['post_drive-urls-{token}-files-{linkID}-checkAvailableHashes']; + post: operations["post_drive-urls-{token}-files-{linkID}-checkAvailableHashes"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/files/{linkID}/revisions/{revisionID}': { + "/drive/urls/{token}/files/{linkID}/revisions/{revisionID}": { parameters: { query?: never; header?: never; @@ -1629,6 +1703,7 @@ export interface paths { get?: never; /** * Commit a revision + * @deprecated * @description The revision becomes the current active one and the updated file content become available for reading. * * If NO `BlockNumber` parameter is passed when creating a new revision, @@ -1638,20 +1713,21 @@ export interface paths { * 1...BlockNumber will be preserved if they are not overridden by a new block * BlockNumber+1... will be discarded. */ - put: operations['put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}']; + put: operations["put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}"]; post?: never; /** * Delete a draft revision. + * @deprecated * @description This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active or obsolete. * You cannot delete a draft revision for a draft link. Delete the link instead. */ - delete: operations['delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}']; + delete: operations["delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/documents': { + "/drive/urls/{token}/documents": { parameters: { query?: never; header?: never; @@ -1662,16 +1738,17 @@ export interface paths { put?: never; /** * Create anonymous document. + * @deprecated * @description Create a new anonymous proton document. */ - post: operations['post_drive-urls-{token}-documents']; + post: operations["post_drive-urls-{token}-documents"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/files': { + "/drive/urls/{token}/files": { parameters: { query?: never; header?: never; @@ -1682,16 +1759,17 @@ export interface paths { put?: never; /** * Create file. + * @deprecated * @description Create a new file. */ - post: operations['post_drive-urls-{token}-files']; + post: operations["post_drive-urls-{token}-files"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/folders': { + "/drive/urls/{token}/folders": { parameters: { query?: never; header?: never; @@ -1702,16 +1780,17 @@ export interface paths { put?: never; /** * Create a folder. + * @deprecated * @description Create a new folder in a given share, under a given folder link. */ - post: operations['post_drive-urls-{token}-folders']; + post: operations["post_drive-urls-{token}-folders"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/folders/{linkID}/delete_multiple': { + "/drive/urls/{token}/folders/{linkID}/delete_multiple": { parameters: { query?: never; header?: never; @@ -1722,16 +1801,17 @@ export interface paths { put?: never; /** * Delete children + * @deprecated * @description Permanently delete children from folder, skipping trash. */ - post: operations['post_drive-urls-{token}-folders-{linkID}-delete_multiple']; + post: operations["post_drive-urls-{token}-folders-{linkID}-delete_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/links/fetch_metadata': { + "/drive/urls/{token}/links/fetch_metadata": { parameters: { query?: never; header?: never; @@ -1742,17 +1822,18 @@ export interface paths { put?: never; /** * Fetch links metadata using token + * @deprecated * @description This endpoint is a sibling of /drive/volumes/{volumeID}/links/fetch_metadata, but using token * instead of volumeID. Is meant to be used in public sharing. */ - post: operations['post_drive-urls-{token}-links-fetch_metadata']; + post: operations["post_drive-urls-{token}-links-fetch_metadata"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/links/{linkID}/path': { + "/drive/urls/{token}/links/{linkID}/path": { parameters: { query?: never; header?: never; @@ -1760,7 +1841,7 @@ export interface paths { cookie?: never; }; /** Fetch link parentIDs by token */ - get: operations['get_drive-urls-{token}-links-{linkID}-path']; + get: operations["get_drive-urls-{token}-links-{linkID}-path"]; put?: never; post?: never; delete?: never; @@ -1769,7 +1850,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/urls/{token}/links/{linkID}/rename': { + "/drive/urls/{token}/links/{linkID}/rename": { parameters: { query?: never; header?: never; @@ -1779,12 +1860,13 @@ export interface paths { get?: never; /** * Rename entry + * @deprecated * @description Rename a file or folder. Client must provide new values for fields linked to name. * * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. */ - put: operations['put_drive-urls-{token}-links-{linkID}-rename']; + put: operations["put_drive-urls-{token}-links-{linkID}-rename"]; post?: never; delete?: never; options?: never; @@ -1792,7 +1874,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/urls/{token}/blocks': { + "/drive/urls/{token}/blocks": { parameters: { query?: never; header?: never; @@ -1803,16 +1885,17 @@ export interface paths { put?: never; /** * Request block upload. + * @deprecated * @description Request upload information for a set of blocks. */ - post: operations['post_drive-urls-{token}-blocks']; + post: operations["post_drive-urls-{token}-blocks"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/links/{linkID}/revisions/{revisionID}/verification': { + "/drive/urls/{token}/links/{linkID}/revisions/{revisionID}/verification": { parameters: { query?: never; header?: never; @@ -1821,9 +1904,10 @@ export interface paths { }; /** * Get verification data. + * @deprecated * @description Get data to verify encryption of the revision before committing. */ - get: operations['get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification']; + get: operations["get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification"]; put?: never; post?: never; delete?: never; @@ -1832,15 +1916,18 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/urls': { + "/drive/urls/{token}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List ShareURLs in a volume */ - get: operations['get_drive-volumes-{volumeID}-urls']; + /** + * Get Shared File Information. + * @deprecated + */ + get: operations["get_drive-urls-{token}"]; put?: never; post?: never; delete?: never; @@ -1849,7 +1936,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}/map': { + "/drive/urls/{token}/folders/{linkID}/children": { parameters: { query?: never; header?: never; @@ -1857,11 +1944,10 @@ export interface paths { cookie?: never; }; /** - * Search map + * List shared folder's children. * @deprecated - * @description Used only for search on web that does not scale. Should be replaced by better version in the future. */ - get: operations['get_drive-shares-{shareID}-map']; + get: operations["get_drive-urls-{token}-folders-{linkID}-children"]; put?: never; post?: never; delete?: never; @@ -1870,15 +1956,18 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/shares/my-files': { + "/drive/urls/{token}/files/{linkID}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Bootstrap my files */ - get: operations['get_drive-v2-shares-my-files']; + /** + * Get Shared File & Revision Metadata. + * @deprecated + */ + get: operations["get_drive-urls-{token}-files-{linkID}"]; put?: never; post?: never; delete?: never; @@ -1887,41 +1976,35 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares/{shareID}': { + "/drive/urls/{token}/file": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get share bootstrap */ - get: operations['get_drive-shares-{shareID}']; + get?: never; put?: never; - post?: never; /** - * Delete a standard share by ID - * @description Only standard shares (type 2) can be deleted this way. - * Will throw 422 with body code 2005 if Members, ShareURLs or Invitations are still attached to the share. - * Use Force=1 query param to delete the share together with any attached entities. + * Get Shared File Information. + * @deprecated */ - delete: operations['delete_drive-shares-{shareID}']; + post: operations["post_drive-urls-{token}-file"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/links/{linkID}/context': { + "/drive/volumes/{volumeID}/urls": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get context share - * @description Gets the highest share, meaning closest to the root, for a link - */ - get: operations['get_drive-volumes-{volumeID}-links-{linkID}-context']; + /** List ShareURLs in a volume */ + get: operations["get_drive-volumes-{volumeID}-urls"]; put?: never; post?: never; delete?: never; @@ -1930,31 +2013,26 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/shares': { + "/drive/shares/{shareID}/urls": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List shares - * @description List shares available to current user. - * - * The results can be restricted to a single address by providing the AddressID query parameter. - * By default, only active shares are shown. - * Passing the ShowAll=1 query parameter will show locked and disabled shares also. - */ - get: operations['get_drive-shares']; + /** List URL links on share. */ + get: operations["get_drive-shares-{shareID}-urls"]; put?: never; - post?: never; + /** Share by URL + * Create a share by URL link. */ + post: operations["post_drive-shares-{shareID}-urls"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/owner': { + "/drive/shares/{shareID}/urls/{urlID}": { parameters: { query?: never; header?: never; @@ -1962,20 +2040,20 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; /** - * Update ownership of a share - * @description Replace the signature and related membership of the share. - * This allows users to change the associated address & key they use for a share, so that they can get rid of it. + * Update a share by URL link. + * @description Only values which are set in the request are updated. When the password is updated, the password, SharePassphraseKeyPacket and SRPVerifier must be updated together. */ - post: operations['post_drive-shares-{shareID}-owner']; - delete?: never; + put: operations["put_drive-shares-{shareID}-urls-{urlID}"]; + post?: never; + /** Delete a Share URL */ + delete: operations["delete_drive-shares-{shareID}-urls-{urlID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/migrations/shareaccesswithnode': { + "/drive/shares/{shareID}/urls/delete_multiple": { parameters: { query?: never; header?: never; @@ -1984,15 +2062,15 @@ export interface paths { }; get?: never; put?: never; - /** Migrate legacy Shares */ - post: operations['post_drive-migrations-shareaccesswithnode']; + /** Delete multiple ShareURL in a batch. */ + post: operations["post_drive-shares-{shareID}-urls-delete_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/migrations/shareaccesswithnode/unmigrated': { + "/drive/shares/{shareID}/map": { parameters: { query?: never; header?: never; @@ -2000,11 +2078,11 @@ export interface paths { cookie?: never; }; /** - * List unmigrated shares - * @description List shares that have not been migrated to the new PassphraseNodeKeyPacket yet. - * Will throw a 422 with Code 2000 if the current user doesn't have any active Volume. + * Search map + * @deprecated + * @description Used only for search on web that does not scale. Should be replaced by better version in the future. */ - get: operations['get_drive-migrations-shareaccesswithnode-unmigrated']; + get: operations["get_drive-shares-{shareID}-map"]; put?: never; post?: never; delete?: never; @@ -2013,15 +2091,15 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/urls/{token}/info': { + "/drive/v2/shares/my-files": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Initiate shared by URL session with SRP. */ - get: operations['get_drive-urls-{token}-info']; + /** Bootstrap my files */ + get: operations["get_drive-v2-shares-my-files"]; put?: never; post?: never; delete?: never; @@ -2030,49 +2108,58 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/urls/{token}/auth': { + "/drive/v2/shares/photos": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Bootstrap photos section */ + get: operations["get_drive-v2-shares-photos"]; put?: never; - /** Perform Handshake, Get session information */ - post: operations['post_drive-urls-{token}-auth']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}': { + "/drive/shares/{shareID}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Shared File Information. */ - get: operations['get_drive-urls-{token}']; + /** Get share bootstrap */ + get: operations["get_drive-shares-{shareID}"]; put?: never; post?: never; - delete?: never; + /** + * Delete a standard share by ID + * @description Only standard shares (type 2) can be deleted this way. + * Will throw 422 with body code 2005 if Members, ShareURLs or Invitations are still attached to the share. + * Use Force=1 query param to delete the share together with any attached entities. + */ + delete: operations["delete_drive-shares-{shareID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/folders/{linkID}/children': { + "/drive/volumes/{volumeID}/links/{linkID}/context": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List shared folder's children. */ - get: operations['get_drive-urls-{token}-folders-{linkID}-children']; + /** + * Get context share + * @description Gets the highest share, meaning closest to the root, for a link + */ + get: operations["get_drive-volumes-{volumeID}-links-{linkID}-context"]; put?: never; post?: never; delete?: never; @@ -2081,15 +2168,22 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/urls/{token}/files/{linkID}': { + "/drive/shares": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Shared File & Revision Metadata. */ - get: operations['get_drive-urls-{token}-files-{linkID}']; + /** + * List shares + * @description List shares available to current user. + * + * The results can be restricted to a single address by providing the AddressID query parameter. + * By default, only active shares are shown. + * Passing the ShowAll=1 query parameter will show locked and disabled shares also. + */ + get: operations["get_drive-shares"]; put?: never; post?: never; delete?: never; @@ -2098,7 +2192,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/urls/{token}/file': { + "/drive/shares/{shareID}/owner": { parameters: { query?: never; header?: never; @@ -2107,72 +2201,57 @@ export interface paths { }; get?: never; put?: never; - /** Get Shared File Information. */ - post: operations['post_drive-urls-{token}-file']; + /** + * Update ownership of a share + * @description Replace the signature and related membership of the share. + * This allows users to change the associated address & key they use for a share, so that they can get rid of it. + */ + post: operations["post_drive-shares-{shareID}-owner"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/urls': { + "/drive/migrations/shareaccesswithnode": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List URL links on share. */ - get: operations['get_drive-shares-{shareID}-urls']; + get?: never; put?: never; - /** Share by URL - * Create a share by URL link. */ - post: operations['post_drive-shares-{shareID}-urls']; + /** Migrate legacy Shares */ + post: operations["post_drive-migrations-shareaccesswithnode"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/shares/{shareID}/urls/{urlID}': { + "/drive/migrations/shareaccesswithnode/unmigrated": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; /** - * Update a share by URL link. - * @description Only values which are set in the request are updated. When the password is updated, the password, SharePassphraseKeyPacket and SRPVerifier must be updated together. + * List unmigrated shares + * @description List shares that have not been migrated to the new PassphraseNodeKeyPacket yet. + * Will throw a 422 with Code 2000 if the current user doesn't have any active Volume. */ - put: operations['put_drive-shares-{shareID}-urls-{urlID}']; - post?: never; - /** Delete a Share URL */ - delete: operations['delete_drive-shares-{shareID}-urls-{urlID}']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/drive/shares/{shareID}/urls/delete_multiple': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; + get: operations["get_drive-migrations-shareaccesswithnode-unmigrated"]; put?: never; - /** Delete multiple ShareURL in a batch. */ - post: operations['post_drive-shares-{shareID}-urls-delete_multiple']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/shares': { + "/drive/volumes/{volumeID}/shares": { parameters: { query?: never; header?: never; @@ -2185,14 +2264,14 @@ export interface paths { * Create a standard share * @description Cannot create two shares on the same link. Throws 422 with code 2500 in case a share already exists. */ - post: operations['post_drive-volumes-{volumeID}-shares']; + post: operations["post_drive-volumes-{volumeID}-shares"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/volumes/{volumeID}/shares': { + "/drive/v2/volumes/{volumeID}/shares": { parameters: { query?: never; header?: never; @@ -2203,7 +2282,7 @@ export interface paths { * Shared by me * @description List Collaborative Shares in the given volume that are not abandoned, i.e. they either have members, invitations or URLs attached. */ - get: operations['get_drive-v2-volumes-{volumeID}-shares']; + get: operations["get_drive-v2-volumes-{volumeID}-shares"]; put?: never; post?: never; delete?: never; @@ -2212,7 +2291,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/sharedwithme': { + "/drive/v2/sharedwithme": { parameters: { query?: never; header?: never; @@ -2223,7 +2302,7 @@ export interface paths { * Shared with me * @description List Collaborative Shares the user has access to as a non-owner */ - get: operations['get_drive-v2-sharedwithme']; + get: operations["get_drive-v2-sharedwithme"]; put?: never; post?: never; delete?: never; @@ -2232,7 +2311,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/external-invitations/{invitationID}': { + "/drive/v2/shares/{shareID}/external-invitations/{invitationID}": { parameters: { query?: never; header?: never; @@ -2246,19 +2325,19 @@ export interface paths { * After the external invitation has been accepted, the invitation's permissions can be edited. * The current user must have admin permission on the share. */ - put: operations['put_drive-v2-shares-{shareID}-external-invitations-{invitationID}']; + put: operations["put_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; post?: never; /** * Delete an external invitation * @description The current user must have admin permission on the share. */ - delete: operations['delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}']; + delete: operations["delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/external-invitations': { + "/drive/v2/shares/{shareID}/external-invitations": { parameters: { query?: never; header?: never; @@ -2269,20 +2348,20 @@ export interface paths { * List external invitations in a share * @description The current user must have admin permission on the share. */ - get: operations['get_drive-v2-shares-{shareID}-external-invitations']; + get: operations["get_drive-v2-shares-{shareID}-external-invitations"]; put?: never; /** * Invite an external user to a share * @description The current user must have admin permission on the share. */ - post: operations['post_drive-v2-shares-{shareID}-external-invitations']; + post: operations["post_drive-v2-shares-{shareID}-external-invitations"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/external-invitations': { + "/drive/v2/shares/external-invitations": { parameters: { query?: never; header?: never; @@ -2293,7 +2372,7 @@ export interface paths { * List external invitations of a user * @description List the UserRegistered external invitations where the current user is the invitee. */ - get: operations['get_drive-v2-shares-external-invitations']; + get: operations["get_drive-v2-shares-external-invitations"]; put?: never; post?: never; delete?: never; @@ -2302,7 +2381,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/external-invitations/{invitationID}/sendemail': { + "/drive/v2/shares/{shareID}/external-invitations/{invitationID}/sendemail": { parameters: { query?: never; header?: never; @@ -2315,14 +2394,14 @@ export interface paths { * Send the external invitation email to the invitee * @description The current user must have admin permission on the share. */ - post: operations['post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail']; + post: operations["post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/invitations/{invitationID}/accept': { + "/drive/v2/shares/invitations/{invitationID}/accept": { parameters: { query?: never; header?: never; @@ -2332,14 +2411,14 @@ export interface paths { get?: never; put?: never; /** Accept an invitation */ - post: operations['post_drive-v2-shares-invitations-{invitationID}-accept']; + post: operations["post_drive-v2-shares-invitations-{invitationID}-accept"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/invitations/{invitationID}': { + "/drive/v2/shares/{shareID}/invitations/{invitationID}": { parameters: { query?: never; header?: never; @@ -2353,19 +2432,19 @@ export interface paths { * The owner should not be aware of rejection. After the invitation has been accepted, membership permissions can be edited. * The current user must have admin permission on the share. */ - put: operations['put_drive-v2-shares-{shareID}-invitations-{invitationID}']; + put: operations["put_drive-v2-shares-{shareID}-invitations-{invitationID}"]; post?: never; /** * Delete an invitation * @description The current user must have admin permission on the share. */ - delete: operations['delete_drive-v2-shares-{shareID}-invitations-{invitationID}']; + delete: operations["delete_drive-v2-shares-{shareID}-invitations-{invitationID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/invitations': { + "/drive/v2/shares/{shareID}/invitations": { parameters: { query?: never; header?: never; @@ -2376,20 +2455,20 @@ export interface paths { * List invitations in a share * @description The current user must have admin permission on the share. */ - get: operations['get_drive-v2-shares-{shareID}-invitations']; + get: operations["get_drive-v2-shares-{shareID}-invitations"]; put?: never; /** * Invite a Proton user to a share * @description The current user must have admin permission on the share. */ - post: operations['post_drive-v2-shares-{shareID}-invitations']; + post: operations["post_drive-v2-shares-{shareID}-invitations"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/invitations': { + "/drive/v2/shares/invitations": { parameters: { query?: never; header?: never; @@ -2400,7 +2479,7 @@ export interface paths { * List invitations of a user * @description List the pending invitations where the current user is the invitee. */ - get: operations['get_drive-v2-shares-invitations']; + get: operations["get_drive-v2-shares-invitations"]; put?: never; post?: never; delete?: never; @@ -2409,7 +2488,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/shares/invitations/{invitationID}/reject': { + "/drive/v2/shares/invitations/{invitationID}/reject": { parameters: { query?: never; header?: never; @@ -2419,14 +2498,14 @@ export interface paths { get?: never; put?: never; /** Reject an invitation */ - post: operations['post_drive-v2-shares-invitations-{invitationID}-reject']; + post: operations["post_drive-v2-shares-invitations-{invitationID}-reject"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/invitations/{invitationID}/sendemail': { + "/drive/v2/shares/{shareID}/invitations/{invitationID}/sendemail": { parameters: { query?: never; header?: never; @@ -2439,14 +2518,14 @@ export interface paths { * Send the invitation email to the invitee * @description The current user must have admin permission on the share. */ - post: operations['post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail']; + post: operations["post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/invitations/{invitationID}': { + "/drive/v2/shares/invitations/{invitationID}": { parameters: { query?: never; header?: never; @@ -2457,7 +2536,7 @@ export interface paths { * Return invitation information * @description Get the information about a pending invitation where the current user is the invitee. */ - get: operations['get_drive-v2-shares-invitations-{invitationID}']; + get: operations["get_drive-v2-shares-invitations-{invitationID}"]; put?: never; post?: never; delete?: never; @@ -2466,7 +2545,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/user-link-access': { + "/drive/v2/user-link-access": { parameters: { query?: never; header?: never; @@ -2477,7 +2556,7 @@ export interface paths { * List link accesses for a share url. * @description RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0031-share-resolution-from-copied-url/ */ - get: operations['get_drive-v2-user-link-access']; + get: operations["get_drive-v2-user-link-access"]; put?: never; post?: never; delete?: never; @@ -2486,7 +2565,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/members': { + "/drive/v2/shares/{shareID}/members": { parameters: { query?: never; header?: never; @@ -2497,7 +2576,7 @@ export interface paths { * List members in a share * @description The current user must have admin permission on the share. */ - get: operations['get_drive-v2-shares-{shareID}-members']; + get: operations["get_drive-v2-shares-{shareID}-members"]; put?: never; post?: never; delete?: never; @@ -2506,7 +2585,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/members/{memberID}': { + "/drive/v2/shares/{shareID}/members/{memberID}": { parameters: { query?: never; header?: never; @@ -2519,20 +2598,20 @@ export interface paths { * @description Only permissions can be changed. They can be changed when the member is active. * The current user must have admin permission on the share. */ - put: operations['put_drive-v2-shares-{shareID}-members-{memberID}']; + put: operations["put_drive-v2-shares-{shareID}-members-{memberID}"]; post?: never; /** * Remove a share member * @description If the current user is an admin of the share they can remove other members. * If the current user is not an admin they can only remove themselves. */ - delete: operations['delete_drive-v2-shares-{shareID}-members-{memberID}']; + delete: operations["delete_drive-v2-shares-{shareID}-members-{memberID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/v2/shares/{shareID}/security': { + "/drive/v2/shares/{shareID}/security": { parameters: { query?: never; header?: never; @@ -2546,14 +2625,14 @@ export interface paths { * @description Performs virus checks on hashes of files received in the request payload. * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ */ - post: operations['post_drive-v2-shares-{shareID}-security']; + post: operations["post_drive-v2-shares-{shareID}-security"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/urls/{token}/security': { + "/drive/urls/{token}/security": { parameters: { query?: never; header?: never; @@ -2567,14 +2646,14 @@ export interface paths { * @description Performs virus checks on hashes of files received in the request payload. * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ */ - post: operations['post_drive-urls-{token}-security']; + post: operations["post_drive-urls-{token}-security"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/thumbnails': { + "/drive/volumes/{volumeID}/thumbnails": { parameters: { query?: never; header?: never; @@ -2584,14 +2663,14 @@ export interface paths { get?: never; put?: never; /** Fetch thumbnails by IDs. */ - post: operations['post_drive-volumes-{volumeID}-thumbnails']; + post: operations["post_drive-volumes-{volumeID}-thumbnails"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/me/settings': { + "/drive/me/settings": { parameters: { query?: never; header?: never; @@ -2599,12 +2678,12 @@ export interface paths { cookie?: never; }; /** Get user settings */ - get: operations['get_drive-me-settings']; + get: operations["get_drive-me-settings"]; /** * Update user settings * @description At least one setting must be provided. */ - put: operations['put_drive-me-settings']; + put: operations["put_drive-me-settings"]; post?: never; delete?: never; options?: never; @@ -2612,7 +2691,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/volumes': { + "/drive/volumes": { parameters: { query?: never; header?: never; @@ -2625,7 +2704,7 @@ export interface paths { * It can also return volumes in locked state, which are - upon creation of new volumes - re-activated with new root shares. * The pagination params Page and PageSize are deprecated. */ - get: operations['get_drive-volumes']; + get: operations["get_drive-volumes"]; put?: never; /** * Create volume @@ -2637,14 +2716,14 @@ export interface paths { * If the user already has a locked volume, then this locked volume is re-activated * with a new root share and folder instead of creating a new volume. */ - post: operations['post_drive-volumes']; + post: operations["post_drive-volumes"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/delete_locked': { + "/drive/volumes/{volumeID}/delete_locked": { parameters: { query?: never; header?: never; @@ -2656,7 +2735,7 @@ export interface paths { * Delete the whole volume if is locked or the locked root shares in the volume. * @description Web client calls this endpoint when the user decides to delete their locked data and not recover it. */ - put: operations['put_drive-volumes-{volumeID}-delete_locked']; + put: operations["put_drive-volumes-{volumeID}-delete_locked"]; post?: never; delete?: never; options?: never; @@ -2664,7 +2743,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}': { + "/drive/volumes/{volumeID}": { parameters: { query?: never; header?: never; @@ -2675,7 +2754,7 @@ export interface paths { * Get volume * @description Return the attributes of a specific volume. */ - get: operations['get_drive-volumes-{volumeID}']; + get: operations["get_drive-volumes-{volumeID}"]; put?: never; post?: never; delete?: never; @@ -2684,7 +2763,7 @@ export interface paths { patch?: never; trace?: never; }; - '/drive/volumes/{volumeID}/restore': { + "/drive/volumes/{volumeID}/restore": { parameters: { query?: never; header?: never; @@ -2697,7 +2776,7 @@ export interface paths { * @description The locked root shares in the volume can be recovered by providing the new encryption material for each share. * This operation used to be heavy and processed async. But now it's quick and done synchronously. */ - put: operations['put_drive-volumes-{volumeID}-restore']; + put: operations["put_drive-volumes-{volumeID}-restore"]; post?: never; delete?: never; options?: never; @@ -2715,7 +2794,7 @@ export interface components { */ ResponseCodeSuccess: 1000; ProtonSuccess: { - Code: components['schemas']['ResponseCodeSuccess']; + Code: components["schemas"]["ResponseCodeSuccess"]; }; ProtonError: { /** ErrorCode */ @@ -2740,14 +2819,14 @@ export interface components { DownloadTokenExpirationTimeInSec?: 1800; }; AddPhotosToAlbumRequestDto: { - AlbumData: components['schemas']['AlbumPhotoLinkDataDto'][]; + AlbumData: components["schemas"]["AlbumPhotoLinkDataDto"][]; }; CreateAlbumRequestDto: { Locked: boolean; - Link: components['schemas']['AlbumLinkDto']; + Link: components["schemas"]["AlbumLinkDto"]; }; CreateAlbumResponseDto: { - Album: components['schemas']['AlbumShortResponseDto']; + Album: components["schemas"]["AlbumShortResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -2756,11 +2835,11 @@ export interface components { Code: 1000; }; CreatePhotoShareRequestDto: { - Share: components['schemas']['ShareDataDto']; - Link: components['schemas']['LinkDataDto']; + Share: components["schemas"]["ShareDataDto"]; + Link: components["schemas"]["LinkDataDto"]; }; GetPhotoVolumeResponseDto: { - Volume: components['schemas']['PhotoVolumeResponseDto']; + Volume: components["schemas"]["PhotoVolumeResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -2781,7 +2860,7 @@ export interface components { NameHashes: string[]; }; FindDuplicatesOutputCollection: { - DuplicateHashes: components['schemas']['FoundDuplicate'][]; + DuplicateHashes: components["schemas"]["FoundDuplicate"][]; /** * ProtonResponseCode * @example 1000 @@ -2791,7 +2870,7 @@ export interface components { }; PhotoTagMigrationStatusResponseDto: { Finished: boolean; - Anchor?: components['schemas']['PhotoTagMigrationDataDto'] | null; + Anchor?: components["schemas"]["PhotoTagMigrationDataDto"] | null; /** * ProtonResponseCode * @example 1000 @@ -2800,7 +2879,7 @@ export interface components { Code: 1000; }; ListAlbumsResponseDto: { - Albums: components['schemas']['AlbumResponseDto'][]; + Albums: components["schemas"]["AlbumResponseDto"][]; AnchorID?: string | null; More: boolean; /** @@ -2817,11 +2896,11 @@ export interface components { * @default Captured * @enum {string} */ - Sort: 'Captured' | 'Added'; + Sort: "Captured" | "Added"; /** @default true */ Desc: boolean; /** @default null */ - Tag: components['schemas']['TagType'] | null; + Tag: components["schemas"]["TagType"] | null; /** @default false */ OnlyChildren: boolean; /** @default false */ @@ -2833,7 +2912,7 @@ export interface components { */ TagType: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; ListPhotosAlbumResponseDto: { - Photos: components['schemas']['ListPhotosAlbumItemResponseDto'][]; + Photos: components["schemas"]["ListPhotosAlbumItemResponseDto"][]; AnchorID?: string | null; More: boolean; /** @@ -2844,8 +2923,8 @@ export interface components { Code: 1000; }; TransferPhotoLinksRequestDto: { - ParentLinkID: components['schemas']['Id']; - Links: components['schemas']['TransferPhotoLinkInBatchRequestDto'][]; + ParentLinkID: components["schemas"]["Id"]; + Links: components["schemas"]["TransferPhotoLinkInBatchRequestDto"][]; /** * Format: email * @description Signature email address used for signing name @@ -2859,16 +2938,16 @@ export interface components { SignatureEmail: string | null; }; RemovePhotosFromAlbumRequestDto: { - LinkIDs: components['schemas']['Id'][]; + LinkIDs: components["schemas"]["Id"][]; }; UpdatePhotoTagMigrationStatusRequestDto: { Finished: boolean; - Anchor: components['schemas']['PhotoTagMigrationUpdateDto']; + Anchor: components["schemas"]["PhotoTagMigrationUpdateDto"]; }; /** @description An encrypted ID */ Id: string; SharedWithMeResponseDto: { - Albums: components['schemas']['AlbumResponseDto'][]; + Albums: components["schemas"]["AlbumResponseDto"][]; AnchorID?: string | null; More: boolean; /** @@ -2879,14 +2958,14 @@ export interface components { Code: 1000; }; UpdateAlbumRequestDto: { - CoverLinkID?: components['schemas']['Id'] | null; - Link?: components['schemas']['AlbumLinkUpdateDto'] | null; + CoverLinkID?: components["schemas"]["Id"] | null; + Link?: components["schemas"]["AlbumLinkUpdateDto"] | null; }; CreateBookmarkShareURLRequestDto: { - BookmarkShareURL: components['schemas']['BookmarkShareURLRequestDto']; + BookmarkShareURL: components["schemas"]["BookmarkShareURLRequestDto"]; }; CreateBookmarkShareURLResponseDto: { - BookmarkShareURL: components['schemas']['BookmarkShareURLResponseDto']; + BookmarkShareURL: components["schemas"]["BookmarkShareURLResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -2895,7 +2974,7 @@ export interface components { Code: 1000; }; ListBookmarksOfUserResponseDto: { - Bookmarks: components['schemas']['BookmarkShareURLInfoResponseDto'][]; + Bookmarks: components["schemas"]["BookmarkShareURLInfoResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -2904,12 +2983,12 @@ export interface components { Code: 1000; }; CreateDeviceRequestDto: { - Device: components['schemas']['DeviceDataDto']; - Share: components['schemas']['ShareDataDto2']; - Link: components['schemas']['LinkDataDto']; + Device: components["schemas"]["DeviceDataDto"]; + Share: components["schemas"]["ShareDataDto2"]; + Link: components["schemas"]["LinkDataDto"]; }; CreateDeviceResponseDto: { - Device: components['schemas']['DeviceResponseDto']; + Device: components["schemas"]["DeviceResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -2918,7 +2997,7 @@ export interface components { Code: 1000; }; ListDevicesResponseDto: { - Devices: components['schemas']['DeviceResponseDto2'][]; + Devices: components["schemas"]["DeviceResponseDto2"][]; /** * ProtonResponseCode * @example 1000 @@ -2927,7 +3006,7 @@ export interface components { Code: 1000; }; ListDevicesResponseDto2: { - Devices: components['schemas']['DeviceResponseDto3'][]; + Devices: components["schemas"]["DeviceResponseDto3"][]; /** * ProtonResponseCode * @example 1000 @@ -2937,37 +3016,38 @@ export interface components { }; UpdateDeviceRequestDto: { /** @default null */ - Device: components['schemas']['DeviceDataDto2'] | null; + Device: components["schemas"]["DeviceDataDto2"] | null; /** * @deprecated * @default null */ - Share: components['schemas']['ShareDataDto3'] | null; + Share: components["schemas"]["ShareDataDto3"] | null; }; CreateDocumentDto: { - ContentKeyPacket: components['schemas']['BinaryString']; - ManifestSignature: components['schemas']['PGPSignature']; + ContentKeyPacket: components["schemas"]["BinaryString"]; + ManifestSignature: components["schemas"]["PGPSignature"]; /** * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ContentKeyPacketSignature: components['schemas']['PGPSignature'] | null; - DocumentType?: components['schemas']['DocumentType']; - Name: components['schemas']['PGPMessage']; + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + DocumentType?: components["schemas"]["DocumentType"]; + Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; /** * Format: email * @description Signature email address used to sign passphrase and name + * @default null */ - SignatureAddress: string; - NodeKey: components['schemas']['PGPPrivateKey']; + SignatureAddress: components["schemas"]["AddressEmail"] | null; }; CreateDocumentResponseDto: { - Document: components['schemas']['DocumentDetailsDto']; + Document: components["schemas"]["DocumentDetailsDto"]; /** * ProtonResponseCode * @example 1000 @@ -2976,7 +3056,7 @@ export interface components { Code: 1000; }; LatestEventIDResponseDto: { - EventID: components['schemas']['Id2']; + EventID: components["schemas"]["Id2"]; /** * ProtonResponseCode * @example 1000 @@ -2985,7 +3065,7 @@ export interface components { Code: 1000; }; ListEventsResponseDto: { - Events: components['schemas']['EventResponseDto'][]; + Events: components["schemas"]["EventResponseDto"][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ EventID: string; /** @@ -3006,7 +3086,7 @@ export interface components { Code: 1000; }; ListEventsV2ResponseDto: { - Events: components['schemas']['EventV2ResponseDto'][]; + Events: components["schemas"]["EventV2ResponseDto"][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ EventID: string; /** @description true if there is more to pull, i.e. there are more events than returned in one call */ @@ -3028,21 +3108,22 @@ export interface components { * @default null */ XAttr: string | null; - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; /** * Format: email * @description Signature email address used to sign passphrase and name + * @default null */ - SignatureAddress: string; - NodeKey: components['schemas']['PGPPrivateKey']; + SignatureAddress: components["schemas"]["AddressEmail"] | null; }; CreateFolderResponseDto: { - Folder: components['schemas']['FolderResponseDto']; + Folder: components["schemas"]["FolderResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -3051,7 +3132,7 @@ export interface components { Code: 1000; }; LinkIDsRequestDto: { - LinkIDs: components['schemas']['EncryptedId'][]; + LinkIDs: components["schemas"]["EncryptedId"][]; }; OffsetPagination: { /** The page size */ @@ -3074,21 +3155,22 @@ export interface components { * @default null */ XAttr: string | null; - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; /** * Format: email * @description Signature email address used to sign passphrase and name + * @default null */ - SignatureEmail: string; - NodeKey: components['schemas']['PGPPrivateKey']; + SignatureEmail: components["schemas"]["AddressEmail"] | null; }; ListChildrenResponseDto: { - LinkIDs: components['schemas']['Id2'][]; + LinkIDs: components["schemas"]["Id2"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -3111,7 +3193,7 @@ export interface components { AvailableHashesResponseDto: { AvailableHashes: string[]; /** @description Hashes of existing drafts excluding the ones of provided ClientUIDs */ - PendingHashes: components['schemas']['PendingHashResponseDto'][]; + PendingHashes: components["schemas"]["PendingHashResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -3139,7 +3221,7 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components['schemas']['PGPSignature'] | null; + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; /** * Format: email * @description Signature email address used for the NodePassphraseSignature. @@ -3150,12 +3232,12 @@ export interface components { * @description Optional, except when moving a Photo-Link. * @default null */ - Photos: components['schemas']['PhotosDto'] | null; + Photos: components["schemas"]["PhotosDto"] | null; /** * @description Only for legacy files (signed by the user). Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ContentKeyPacketSignature: components['schemas']['PGPSignature'] | null; + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; /** * @description Only for legacy folders (signed by the user). Node hash key should be unchanged, just re-signed with the NodeKey. * @default null @@ -3163,7 +3245,7 @@ export interface components { NodeHashKey: string | null; }; CopyLinkResponseDto: { - LinkID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; /** * ProtonResponseCode * @example 1000 @@ -3179,10 +3261,10 @@ export interface components { * @enum {integer} */ Thumbnails: 0 | 1; - LinkIDs: components['schemas']['EncryptedId'][]; + LinkIDs: components["schemas"]["EncryptedId"][]; }; FetchLinksMetadataResponseDto: { - Links: components['schemas']['ExtendedLinkTransformer'][]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; /** * ProtonResponseCode * @example 1000 @@ -3191,7 +3273,7 @@ export interface components { Code: 1000; }; ListMissingHashKeyResponseDto: { - NodesWithMissingNodeHashKey: components['schemas']['ListMissingHashKeyItemDto'][]; + NodesWithMissingNodeHashKey: components["schemas"]["ListMissingHashKeyItemDto"][]; /** * ProtonResponseCode * @example 1000 @@ -3200,11 +3282,7 @@ export interface components { Code: 1000; }; LoadLinkDetailsResponseDto: { - Links: ( - | components['schemas']['FileDetailsDto'] - | components['schemas']['FolderDetailsDto'] - | components['schemas']['AlbumDetailsDto'] - )[]; + Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"] | components["schemas"]["AlbumDetailsDto"])[]; /** * ProtonResponseCode * @example 1000 @@ -3213,8 +3291,8 @@ export interface components { Code: 1000; }; MoveLinkBatchRequestDto: { - ParentLinkID: components['schemas']['Id']; - Links: components['schemas']['MoveLinkInBatchRequestDto'][]; + ParentLinkID: components["schemas"]["Id"]; + Links: components["schemas"]["MoveLinkInBatchRequestDto"][]; /** * Format: email * @description Signature email address used for signing name @@ -3235,7 +3313,7 @@ export interface components { NodePassphrase: string; /** @description Name hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; + ParentLinkID: components["schemas"]["Id"]; /** * Format: email * @description Signature email address used for signing name; Required when not passing `SignatureAddress` @@ -3259,7 +3337,7 @@ export interface components { * @description Deprecated: Target ShareID (for move between shares/devices). Determined on the backend automatically * @default null */ - NewShareID: components['schemas']['Id'] | null; + NewShareID: components["schemas"]["Id"] | null; /** * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] * @default null @@ -3269,7 +3347,7 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components['schemas']['PGPSignature'] | null; + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; /** * Format: email * @description Signature email address used for the NodePassphraseSignature. @@ -3308,7 +3386,7 @@ export interface components { MIMEType: string | null; }; UpdateMissingHashKeyRequestDto: { - NodesWithMissingNodeHashKey: components['schemas']['UpdateMissingHashKeyItemDto'][]; + NodesWithMissingNodeHashKey: components["schemas"]["UpdateMissingHashKeyItemDto"][]; }; MoveLinkRequestDto2: { /** @description Name, reusing same session key as previously. */ @@ -3317,7 +3395,7 @@ export interface components { NodePassphrase: string; /** @description Name hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; + ParentLinkID: components["schemas"]["Id"]; /** @description Current name hash before move operation. Used to prevent race conditions. */ OriginalHash: string; /** @@ -3334,7 +3412,7 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components['schemas']['PGPSignature'] | null; + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; /** * Format: email * @description Signature email address used for the NodePassphraseSignature. @@ -3343,12 +3421,13 @@ export interface components { SignatureEmail: string | null; }; CommitRevisionDto: { - ManifestSignature: components['schemas']['PGPSignature']; + ManifestSignature: components["schemas"]["PGPSignature"]; /** * Format: email * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. + * @default null */ - SignatureAddress: string; + SignatureAddress: components["schemas"]["AddressEmail"] | null; /** * @deprecated * @description Unused. Was meant for shorter partial revisions. @@ -3361,13 +3440,13 @@ export interface components { */ XAttr: string | null; /** @default null */ - Photo: components['schemas']['CommitRevisionPhotoDto'] | null; + Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; /** * @deprecated * @description Ignored entirely by API. Field can be removed from request by client. * @default null */ - BlockList: components['schemas']['BlockTokenDto'][] | null; + BlockList: components["schemas"]["BlockTokenDto"][] | null; /** * @deprecated * @default null @@ -3383,7 +3462,7 @@ export interface components { CreateFileDto: { /** @example text/plain */ MIMEType: string; - ContentKeyPacket: components['schemas']['BinaryString']; + ContentKeyPacket: components["schemas"]["BinaryString"]; /** * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null @@ -3399,22 +3478,32 @@ export interface components { * @default null */ IntendedUploadSize: number | null; - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; /** * Format: email * @description Signature email address used to sign passphrase and name + * @default null */ - SignatureAddress: string; - NodeKey: components['schemas']['PGPPrivateKey']; + SignatureAddress: components["schemas"]["AddressEmail"] | null; + }; + CreateDraftFileResponseDto: { + File: components["schemas"]["FileResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; CreateRevisionRequestDto: { /** @default null */ - CurrentRevisionID: components['schemas']['Id'] | null; + CurrentRevisionID: components["schemas"]["Id"] | null; /** * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. * @default null @@ -3444,7 +3533,7 @@ export interface components { NoBlockUrls: boolean; }; ListRevisionsResponseDto: { - Revisions: components['schemas']['RevisionResponseDto'][]; + Revisions: components["schemas"]["RevisionResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -3461,8 +3550,8 @@ export interface components { Code: 1002; }; VerificationData: { - VerificationCode: components['schemas']['BinaryString2']; - ContentKeyPacket: components['schemas']['BinaryString2']; + VerificationCode: components["schemas"]["BinaryString2"]; + ContentKeyPacket: components["schemas"]["BinaryString2"]; /** * ProtonResponseCode * @example 1000 @@ -3480,7 +3569,7 @@ export interface components { }; VolumeTrashList: { /** @description Trash per share */ - Trash: components['schemas']['ShareTrashList'][]; + Trash: components["schemas"]["ShareTrashList"][]; /** * ProtonResponseCode * @example 1000 @@ -3489,17 +3578,17 @@ export interface components { Code: 1000; }; RequestUploadInput: { - AddressID: components['schemas']['Id']; - LinkID: components['schemas']['Id']; - RevisionID: components['schemas']['Id']; + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + AddressID?: components["schemas"]["AddressID"] | null; /** @default null */ - VolumeID: components['schemas']['Id'] | null; + VolumeID: components["schemas"]["Id"] | null; /** * @deprecated * @description Deprecated, pass VolumeID instead * @default null */ - ShareID: components['schemas']['Id'] | null; + ShareID: components["schemas"]["Id"] | null; /** * @deprecated * @description Request for thumbnail upload @@ -3519,15 +3608,15 @@ export interface components { */ ThumbnailSize: number | null; /** @default [] */ - BlockList: components['schemas']['RequestUploadBlockInput'][]; + BlockList: components["schemas"]["RequestUploadBlockInput"][]; /** @default [] */ - ThumbnailList: components['schemas']['RequestUploadThumbnailInput'][]; + ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; }; RequestUploadResponse: { - UploadLinks: components['schemas']['BlockURL'][]; + UploadLinks: components["schemas"]["BlockURL"][]; /** @deprecated */ - ThumbnailLink?: components['schemas']['ThumbnailBlockURL'] | null; - ThumbnailLinks?: components['schemas']['ThumbnailBlockURL'][] | null; + ThumbnailLink?: components["schemas"]["ThumbnailBlockURL"] | null; + ThumbnailLinks: components["schemas"]["ThumbnailBlockURL"][]; /** * ProtonResponseCode * @example 1000 @@ -3536,8 +3625,8 @@ export interface components { Code: 1000; }; SmallUploadResponseDto: { - LinkID: components['schemas']['Id2']; - RevisionID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; + RevisionID: components["schemas"]["Id2"]; /** * ProtonResponseCode * @example 1000 @@ -3552,7 +3641,7 @@ export interface components { */ ShareURL: string; /** @enum {string} */ - AbuseCategory: 'spam' | 'copyright' | 'child-abuse' | 'stolen-data' | 'malware' | 'other'; + AbuseCategory: "spam" | "copyright" | "child-abuse" | "stolen-data" | "malware" | "other"; /** @description Passphrase for reported Link's Node key, unencrypted, as a string, escaped for JSON. */ ResourcePassphrase: string; /** @@ -3573,11 +3662,20 @@ export interface components { */ ReporterMessage: string | null; /** @default null */ - VolumeID: components['schemas']['Id'] | null; + VolumeID: components["schemas"]["Id"] | null; /** @default null */ - LinkID: components['schemas']['Id'] | null; + LinkID: components["schemas"]["Id"] | null; /** @default null */ - RevisionID: components['schemas']['Id'] | null; + RevisionID: components["schemas"]["Id"] | null; + }; + FreshAccountResponseDto: { + EndTime?: number | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; ChecklistResponseDto: { /** @description Array of completed checklist items */ @@ -3607,6 +3705,7 @@ export interface components { OnboardingResponseDto: { /** @description `true` if the user has pending/rejected invitations or user_registered external invitation */ HasPendingInvitations: boolean; + IsFreshAccount: boolean; /** * ProtonResponseCode * @example 1000 @@ -3615,7 +3714,7 @@ export interface components { Code: 1000; }; GetEntitlementResponseDto: { - Entitlements: components['schemas']['EntitlementsDto']; + Entitlements: components["schemas"]["EntitlementsDto"]; /** * ProtonResponseCode * @example 1000 @@ -3624,15 +3723,15 @@ export interface components { Code: 1000; }; AddTagsRequestDto: { - Tags: components['schemas']['TagType'][]; + Tags: components["schemas"]["TagType"][]; }; FavoritePhotoRequestDto: { - PhotoData?: components['schemas']['FavoritePhotoDataDto'] | null; + PhotoData?: components["schemas"]["FavoritePhotoDataDto"] | null; }; FavoritePhotoResponseDto: { - LinkID: components['schemas']['Id2']; - VolumeID: components['schemas']['Id2']; - RelatedPhotos: components['schemas']['FavoriteRelatedPhotoResponseDto'][]; + LinkID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + RelatedPhotos: components["schemas"]["FavoriteRelatedPhotoResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -3641,8 +3740,8 @@ export interface components { Code: 1000; }; GetMigrationStatusResponseDto: { - OldVolumeID: components['schemas']['Id2']; - NewVolumeID?: components['schemas']['Id2'] | null; + OldVolumeID: components["schemas"]["Id2"]; + NewVolumeID?: components["schemas"]["Id2"] | null; /** * ProtonResponseCode * @example 1000 @@ -3667,17 +3766,17 @@ export interface components { * @description The link ID of the last photo from the previous page when requesting secondary pages * @default null */ - PreviousPageLastLinkID: components['schemas']['Id'] | null; + PreviousPageLastLinkID: components["schemas"]["Id"] | null; /** * @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) * @default null */ MinimumCaptureTime: number | null; /** @default null */ - Tag: components['schemas']['TagType'] | null; + Tag: components["schemas"]["TagType"] | null; }; PhotoListingResponse: { - Photos: components['schemas']['PhotoListingItemResponse'][]; + Photos: components["schemas"]["PhotoListingItemResponse"][]; /** * ProtonResponseCode * @example 1000 @@ -3686,7 +3785,7 @@ export interface components { Code: 1000; }; RemoveTagsRequestDto: { - Tags: components['schemas']['TagType'][]; + Tags: components["schemas"]["TagType"][]; }; UpdateXAttrRequest: { /** @@ -3697,44 +3796,100 @@ export interface components { /** @description Extended attributes encrypted with link key */ XAttr: string; }; + AuthShareTokenRequestDto: { + ClientEphemeral: components["schemas"]["BinaryString"]; + ClientProof: components["schemas"]["BinaryString"]; + SRPSession: components["schemas"]["BinaryString"]; + }; + AuthShareTokenResponseDto: { + /** @description Session UID */ + UID: string; + ServerProof: components["schemas"]["BinaryString2"]; + Share: components["schemas"]["AuthShareDataResponseDto"]; + /** + * @description Session Access token (present if new session) + * @default null + */ + AccessToken: string; + /** + * @description Duration of the session in seconds (present if new session) + * @default null + */ + ExpiresIn: number; + /** + * @description Type of token (present if new session) + * @default null + * @example Bearer + */ + TokenType: string; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + InitSRPSessionResponseDto: { + Modulus: string; + ServerEphemeral: components["schemas"]["BinaryString2"]; + UrlPasswordSalt: components["schemas"]["BinaryString2"]; + SRPSession: components["schemas"]["BinaryString2"]; + Version: number; + Flags: number; + /** @deprecated */ + IsDoc: boolean; + VendorType: components["schemas"]["VendorType"]; + /** + * @description Only set if the user is authenticated AND has direct access to the share already + * @default null + */ + DirectAccess: components["schemas"]["DirectAccessResponseDto"] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; CommitAnonymousRevisionDto: { - ManifestSignature: components['schemas']['PGPSignature']; + ManifestSignature: components["schemas"]["PGPSignature"]; /** * Format: email * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. */ - SignatureEmail?: string | null; + SignatureEmail?: components["schemas"]["AddressEmail"] | null; /** @description Extended attributes encrypted with link key */ XAttr: string; /** * @description Photo attributes * @default null */ - Photo: components['schemas']['CommitRevisionPhotoDto'] | null; + Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; }; CreateAnonymousDocumentDto: { - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; - NodeKey: components['schemas']['PGPPrivateKey']; - ContentKeyPacket: components['schemas']['BinaryString']; - ManifestSignature: components['schemas']['PGPSignature']; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + ContentKeyPacket: components["schemas"]["BinaryString"]; + ManifestSignature: components["schemas"]["PGPSignature"]; /** * Format: email * @description Signature email address used to sign passphrase and name + * @default null */ - SignatureEmail?: string | null; + SignatureEmail: components["schemas"]["AddressEmail"] | null; /** * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ContentKeyPacketSignature: components['schemas']['PGPSignature'] | null; + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; }; CreateAnonymousDocumentResponseDto: { - Document: components['schemas']['DocumentDetailsDto']; + Document: components["schemas"]["DocumentDetailsDto"]; AuthorizationToken: string; /** * ProtonResponseCode @@ -3744,21 +3899,22 @@ export interface components { Code: 1000; }; CreateAnonymousFileRequestDto: { - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; - NodeKey: components['schemas']['PGPPrivateKey']; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; /** @example text/plain */ MIMEType: string; - ContentKeyPacket: components['schemas']['BinaryString']; + ContentKeyPacket: components["schemas"]["BinaryString"]; /** * Format: email * @description Signature email address used to sign passphrase and name + * @default null */ - SignatureEmail?: string | null; + SignatureEmail: components["schemas"]["AddressEmail"] | null; /** * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null @@ -3776,7 +3932,7 @@ export interface components { IntendedUploadSize: number | null; }; CreateAnonymousFileResponseDto: { - File: components['schemas']['FileResponseDto']; + File: components["schemas"]["FileResponseDto"]; AuthorizationToken: string; /** * ProtonResponseCode @@ -3786,20 +3942,21 @@ export interface components { Code: 1000; }; CreateAnonymousFolderRequestDto: { - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; - ParentLinkID: components['schemas']['Id']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: string; /** * Format: email * @description Signature email address used to sign passphrase and name + * @default null */ - SignatureEmail?: string | null; - NodeKey: components['schemas']['PGPPrivateKey']; - /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; + SignatureEmail: components["schemas"]["AddressEmail"] | null; /** * @description Extended attributes encrypted with link key * @default null @@ -3807,7 +3964,7 @@ export interface components { XAttr: string | null; }; CreateAnonymousFolderResponseDto: { - Folder: components['schemas']['FolderResponseDto']; + Folder: components["schemas"]["FolderResponseDto"]; AuthorizationToken: string; /** * ProtonResponseCode @@ -3817,7 +3974,7 @@ export interface components { Code: 1000; }; DeleteChildrenRequestDto: { - Links: components['schemas']['LinkWithAuthorizationTokenDto'][]; + Links: components["schemas"]["LinkWithAuthorizationTokenDto"][]; }; ParentEncryptedLinkIDsResponseDto: { ParentLinkIDs: string[]; @@ -3851,23 +4008,21 @@ export interface components { AuthorizationToken: string | null; }; RequestAnonymousUploadRequestDto: { - LinkID: components['schemas']['Id']; - RevisionID: components['schemas']['Id']; + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; /** * Format: email * @description Signature email address used to sign the blocks content * @default null */ - SignatureEmail: string | null; + SignatureEmail: components["schemas"]["AddressEmail"] | null; /** @default [] */ - BlockList: components['schemas']['AnonymousUploadBlockDto'][]; + BlockList: components["schemas"]["RequestUploadBlockInput"][]; /** @default [] */ - ThumbnailList: components['schemas']['RequestUploadThumbnailInput'][]; + ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; }; - ShareURLContextsCollection: { - ShareURLContexts: components['schemas']['ShareURLContext'][]; - /** @description Indicates there may be more ShareURLs */ - More: boolean; + BootstrapShareTokenResponseDto: { + Token: components["schemas"]["TokenResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -3875,8 +4030,143 @@ export interface components { */ Code: 1000; }; - LinkMapQueryParameters: { - /** @default null */ + GetRevisionResponseDto: { + Revision: components["schemas"]["DetailedRevisionResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetSharedFileInfoRequestDto: { + /** @default 1 */ + FromBlockIndex: number; + /** @default null */ + PageSize: number | null; + ClientEphemeral: components["schemas"]["BinaryString"]; + ClientProof: components["schemas"]["BinaryString"]; + SRPSession: components["schemas"]["BinaryString"]; + }; + GetSharedFileInfoResponseDto: { + ServerProof: components["schemas"]["BinaryString2"]; + Payload: components["schemas"]["GetSharedFileInfoPayloadDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ShareURLContextsCollection: { + ShareURLContexts: components["schemas"]["ShareURLContext"][]; + /** @description Indicates there may be more ShareURLs */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListShareURLsResponseDto: { + ShareURLs: components["schemas"]["ShareURLResponseDto2"][]; + /** @description If the Recursive query parameter is set, also returns the related links and ancestors up to the share as a dictionary by LinkID. */ + Links: { + [key: string]: components["schemas"]["ExtendedLinkTransformer2"]; + }; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateShareURLRequestDto: { + CreatorEmail: components["schemas"]["AddressEmail"]; + /** + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @enum {integer} + */ + Permissions: 4 | 6; + UrlPasswordSalt: components["schemas"]["BinaryString"]; + SharePasswordSalt: components["schemas"]["BinaryString"]; + SRPVerifier: components["schemas"]["BinaryString"]; + SRPModulusID: components["schemas"]["Id"]; + /** @description Bitmap: 1 = custom password set, 2 = random password set */ + Flags: number; + SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; + /** @description PGP encrypted password. The password is encrypted with the user's address key. */ + Password: string; + /** @description Maximum number of times this link can be accessed. 0 for infinite */ + MaxAccesses: number; + /** + * @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. Optional + * @default null + */ + ExpirationTime: number | null; + /** + * @description Number of seconds after which this link is no longer accessible. Maximum 90 days. Optional + * @default null + */ + ExpirationDuration: number | null; + /** + * @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. + * @default null + */ + Name: string | null; + }; + UpdateShareURLRequestDto: { + /** @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. */ + ExpirationTime: number; + /** @description Number of seconds after which this link is no longer accessible. Maximum 90 days. */ + ExpirationDuration?: number | null; + /** @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. */ + Name: number; + /** + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @default null + * @enum {integer|null} + */ + Permissions: 4 | 6 | null; + /** @default null */ + UrlPasswordSalt: components["schemas"]["BinaryString"] | null; + /** @default null */ + SharePasswordSalt: components["schemas"]["BinaryString"] | null; + /** @default null */ + SRPVerifier: components["schemas"]["BinaryString"] | null; + /** @default null */ + SRPModulusID: components["schemas"]["Id"] | null; + /** + * @description Bitmap: 1 = custom password set, 2 = random password set + * @default null + */ + Flags: number | null; + /** @default null */ + SharePassphraseKeyPacket: components["schemas"]["BinaryString"] | null; + /** + * @description PGP encrypted password. The password is encrypted with the user's address key. + * @default null + */ + Password: components["schemas"]["PGPMessage"] | null; + /** + * @description Maximum number of times this link can be accessed. 0 for infinite + * @default null + */ + MaxAccesses: number | null; + }; + DeleteMultipleShareURLsRequestDto: { + /** @description List of ShareURL ids to delete. */ + ShareURLIDs: components["schemas"]["EncryptedId"][]; + }; + LinkMapQueryParameters: { + /** @default null */ SessionName: string | null; /** @default null */ LastIndex: number | null; @@ -3887,7 +4177,7 @@ export interface components { SessionName: string; More: number; Total: number; - Links: components['schemas']['LinkMapItemResponse'][]; + Links: components["schemas"]["LinkMapItemResponse"][]; /** * ProtonResponseCode * @example 1000 @@ -3895,10 +4185,10 @@ export interface components { */ Code: 1000; }; - MyFilesResponseDto: { - Volume: components['schemas']['VolumeDto']; - Share: components['schemas']['ShareDto']; - Link: components['schemas']['FolderDetailsDto2']; + PrimaryRootShareResponseDto: { + Volume: components["schemas"]["VolumeDto"]; + Share: components["schemas"]["ShareDto"]; + Link: components["schemas"]["FolderDetailsDto2"]; /** * ProtonResponseCode * @example 1000 @@ -3907,17 +4197,17 @@ export interface components { Code: 1000; }; BootstrapShareResponseDto: { - ShareID: components['schemas']['Id2']; - VolumeID: components['schemas']['Id2']; - Type: components['schemas']['ShareType']; - State: components['schemas']['ShareState']; - VolumeType: components['schemas']['VolumeType']; + ShareID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + Type: components["schemas"]["ShareType"]; + State: components["schemas"]["ShareState"]; + VolumeType: components["schemas"]["VolumeType"]; /** Format: email */ Creator: string; Locked?: boolean | null; CreateTime: number; ModifyTime: number; - LinkID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; /** * @deprecated * @description Deprecated: Use `CreateTime` @@ -3925,16 +4215,16 @@ export interface components { CreationTime: number; /** @deprecated */ PermissionsMask: number; - LinkType: components['schemas']['NodeType']; + LinkType: components["schemas"]["NodeType"]; /** @deprecated */ Flags: number; /** @deprecated */ BlockSize: number; /** @deprecated */ VolumeSoftDeleted: boolean; - Key: components['schemas']['PGPPrivateKey2']; - Passphrase: components['schemas']['PGPMessage2']; - PassphraseSignature: components['schemas']['PGPSignature2']; + Key: components["schemas"]["PGPPrivateKey2"]; + Passphrase: components["schemas"]["PGPMessage2"]; + PassphraseSignature: components["schemas"]["PGPSignature2"]; /** @description Address ID of the current user's address for the membership of this share. Can be missing if the user is not a direct member of the share. */ AddressID?: string | null; /** @@ -3943,13 +4233,13 @@ export interface components { */ AddressKeyID?: string | null; /** @description Your own memberships */ - Memberships: components['schemas']['MemberResponseDto'][]; + Memberships: components["schemas"]["MemberResponseDto"][]; /** * @deprecated * @description Deprecated, use `Memberships` instead */ - PossibleKeyPackets: components['schemas']['KeyPacketResponseDto'][]; - RootLinkRecoveryPassphrase?: components['schemas']['PGPMessage2'] | null; + PossibleKeyPackets: components["schemas"]["KeyPacketResponseDto"][]; + RootLinkRecoveryPassphrase?: components["schemas"]["PGPMessage2"] | null; /** * ProtonResponseCode * @example 1000 @@ -3958,7 +4248,7 @@ export interface components { Code: 1000; }; GetHighestContextForDocumentResponse: { - ContextShareID: components['schemas']['Id2']; + ContextShareID: components["schemas"]["Id2"]; /** * ProtonResponseCode * @example 1000 @@ -3967,7 +4257,7 @@ export interface components { Code: 1000; }; ListSharesResponseDto: { - Shares: components['schemas']['ShareResponseDto'][]; + Shares: components["schemas"]["ShareResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -3990,18 +4280,18 @@ export interface components { * @description The sum of PassphraseNodeKeyPacket-pairs and UnreadableShareIDs should not exceed 50 * @default [] */ - PassphraseNodeKeyPackets: components['schemas']['ShareKPMigrationData'][]; + PassphraseNodeKeyPackets: components["schemas"]["ShareKPMigrationData"][]; /** * @description ShareIDs of unmigrated Shares that the client could not decrypt and should be locked * @default [] */ - UnreadableShareIDs: components['schemas']['Id'][]; + UnreadableShareIDs: components["schemas"]["Id"][]; }; MigrateSharesResponseDto: { /** @description ShareIDs successfully migrated */ - ShareIDs: components['schemas']['Id2'][]; + ShareIDs: components["schemas"]["Id2"][]; /** @description ShareIDs not migrated with reason and error code */ - Errors: components['schemas']['ShareKPMigrationError'][]; + Errors: components["schemas"]["ShareKPMigrationError"][]; /** * ProtonResponseCode * @example 1000 @@ -4011,7 +4301,7 @@ export interface components { }; UnmigratedSharesResponseDto: { /** @description ShareIDs that can be migrated */ - ShareIDs: components['schemas']['Id2'][]; + ShareIDs: components["schemas"]["Id2"][]; /** * ProtonResponseCode * @example 1000 @@ -4019,171 +4309,16 @@ export interface components { */ Code: 1000; }; - InitSRPSessionResponseDto: { - Modulus: string; - ServerEphemeral: components['schemas']['BinaryString2']; - UrlPasswordSalt: components['schemas']['BinaryString2']; - SRPSession: components['schemas']['BinaryString2']; - Version: number; - Flags: number; - /** @deprecated */ - IsDoc: boolean; - VendorType: components['schemas']['VendorType']; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - AuthShareTokenRequestDto: { - ClientEphemeral: components['schemas']['BinaryString']; - ClientProof: components['schemas']['BinaryString']; - SRPSession: components['schemas']['BinaryString']; - }; - BootstrapShareTokenResponseDto: { - Token: components['schemas']['TokenResponseDto']; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - GetRevisionResponseDto: { - Revision: components['schemas']['DetailedRevisionResponseDto']; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - GetSharedFileInfoRequestDto: { - /** @default 1 */ - FromBlockIndex: number; - /** @default null */ - PageSize: number | null; - ClientEphemeral: components['schemas']['BinaryString']; - ClientProof: components['schemas']['BinaryString']; - SRPSession: components['schemas']['BinaryString']; - }; - GetSharedFileInfoResponseDto: { - ServerProof: components['schemas']['BinaryString2']; - Payload: components['schemas']['GetSharedFileInfoPayloadDto']; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - ListShareURLsResponseDto: { - ShareURLs: components['schemas']['ShareURLResponseDto2'][]; - /** @description If the Recursive query parameter is set, also returns the related links and ancestors up to the share as a dictionary by LinkID. */ - Links: { - [key: string]: components['schemas']['ExtendedLinkTransformer2']; - }; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - CreateShareURLRequestDto: { - CreatorEmail: string; - /** - * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * - * @enum {integer} - */ - Permissions: 4 | 6; - UrlPasswordSalt: components['schemas']['BinaryString']; - SharePasswordSalt: components['schemas']['BinaryString']; - SRPVerifier: components['schemas']['BinaryString']; - SRPModulusID: components['schemas']['Id']; - /** @description Bitmap: 1 = custom password set, 2 = random password set */ - Flags: number; - SharePassphraseKeyPacket: components['schemas']['BinaryString']; - /** @description PGP encrypted password. The password is encrypted with the user's address key. */ - Password: string; - /** @description Maximum number of times this link can be accessed. 0 for infinite */ - MaxAccesses: number; - /** - * @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. Optional - * @default null - */ - ExpirationTime: number | null; - /** - * @description Number of seconds after which this link is no longer accessible. Maximum 90 days. Optional - * @default null - */ - ExpirationDuration: number | null; - /** - * @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. - * @default null - */ - Name: string | null; - }; - UpdateShareURLRequestDto: { - /** @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. */ - ExpirationTime: number; - /** @description Number of seconds after which this link is no longer accessible. Maximum 90 days. */ - ExpirationDuration?: number | null; - /** @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. */ - Name: number; - /** - * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * - * @default null - * @enum {integer|null} - */ - Permissions: 4 | 6 | null; - /** @default null */ - UrlPasswordSalt: components['schemas']['BinaryString'] | null; - /** @default null */ - SharePasswordSalt: components['schemas']['BinaryString'] | null; - /** @default null */ - SRPVerifier: components['schemas']['BinaryString'] | null; - /** @default null */ - SRPModulusID: components['schemas']['Id'] | null; - /** - * @description Bitmap: 1 = custom password set, 2 = random password set - * @default null - */ - Flags: number | null; - /** @default null */ - SharePassphraseKeyPacket: components['schemas']['BinaryString'] | null; - /** - * @description PGP encrypted password. The password is encrypted with the user's address key. - * @default null - */ - Password: components['schemas']['PGPMessage'] | null; - /** - * @description Maximum number of times this link can be accessed. 0 for infinite - * @default null - */ - MaxAccesses: number | null; - }; - DeleteMultipleShareURLsRequestDto: { - /** @description List of ShareURL ids to delete. */ - ShareURLIDs: components['schemas']['EncryptedId'][]; - }; CreateShareRequestDto: { - AddressID: components['schemas']['Id']; - RootLinkID: components['schemas']['Id']; - ShareKey: components['schemas']['PGPPrivateKey']; + AddressID: components["schemas"]["AddressID"]; + RootLinkID: components["schemas"]["Id"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; /** @description Full PGP message containing (optionally) PassphraseNodeKP and SharePassphrase-KP and data-packet (encrypted SharePassphrase) -> in this exact order */ SharePassphrase: string; - SharePassphraseSignature: components['schemas']['PGPSignature']; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; /** @description Key packet for passphrase of referenced link's node key passphrase */ PassphraseKeyPacket: string; - NameKeyPacket: components['schemas']['BinaryString']; + NameKeyPacket: components["schemas"]["BinaryString"]; /** * @deprecated * @default null @@ -4191,7 +4326,7 @@ export interface components { Name: string | null; }; SharedByMeResponseDto: { - Links: components['schemas']['LinkSharedByMeResponseDto'][]; + Links: components["schemas"]["LinkSharedByMeResponseDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -4204,7 +4339,7 @@ export interface components { Code: 1000; }; SharedWithMeResponseDto2: { - Links: components['schemas']['LinkSharedWithMeResponseDto'][]; + Links: components["schemas"]["LinkSharedWithMeResponseDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -4217,12 +4352,12 @@ export interface components { Code: 1000; }; InviteExternalUserRequestDto: { - ExternalInvitation: components['schemas']['ExternalInvitationRequestDto']; + ExternalInvitation: components["schemas"]["ExternalInvitationRequestDto"]; /** @default null */ - EmailDetails: components['schemas']['InvitationEmailDetailsRequestDto'] | null; + EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; }; InviteExternalUserResponseDto: { - ExternalInvitation: components['schemas']['ExternalInvitationResponseDto']; + ExternalInvitation: components["schemas"]["ExternalInvitationResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -4231,7 +4366,7 @@ export interface components { Code: 1000; }; ListShareExternalInvitationsResponseDto: { - ExternalInvitations: components['schemas']['ExternalInvitationResponseDto'][]; + ExternalInvitations: components["schemas"]["ExternalInvitationResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -4240,7 +4375,7 @@ export interface components { Code: 1000; }; ListUserRegisteredExternalInvitationResponseDto: { - ExternalInvitations: components['schemas']['UserRegisteredExternalInvitationItemDto'][]; + ExternalInvitations: components["schemas"]["UserRegisteredExternalInvitationItemDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -4268,12 +4403,12 @@ export interface components { SessionKeySignature: string; }; InviteUserRequestDto: { - Invitation: components['schemas']['InvitationRequestDto']; + Invitation: components["schemas"]["InvitationRequestDto"]; /** @default null */ - EmailDetails: components['schemas']['InvitationEmailDetailsRequestDto'] | null; + EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; }; InviteUserResponseDto: { - Invitation: components['schemas']['InvitationResponseDto']; + Invitation: components["schemas"]["InvitationResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -4282,7 +4417,7 @@ export interface components { Code: 1000; }; ListShareInvitationsResponseDto: { - Invitations: components['schemas']['InvitationResponseDto'][]; + Invitations: components["schemas"]["InvitationResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -4291,11 +4426,11 @@ export interface components { Code: 1000; }; ListPendingInvitationQueryParameters: { - AnchorID?: components['schemas']['Id'] | null; + AnchorID?: components["schemas"]["Id"] | null; /** @default 150 */ PageSize: number; /** @default null */ - ShareTargetTypes: components['schemas']['TargetType'][] | null; + ShareTargetTypes: components["schemas"]["TargetType"][] | null; }; /** * @description
See values descriptions
See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
@@ -4303,7 +4438,7 @@ export interface components { */ TargetType: 0 | 1 | 2 | 3 | 4 | 5; ListPendingInvitationResponseDto: { - Invitations: components['schemas']['PendingInvitationItemDto'][]; + Invitations: components["schemas"]["PendingInvitationItemDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ AnchorID?: string | null; /** @description Indicates if there is a next page of results */ @@ -4316,9 +4451,9 @@ export interface components { Code: 1000; }; PendingInvitationResponseDto: { - Invitation: components['schemas']['InvitationResponseDto']; - Share: components['schemas']['ShareResponseDto2']; - Link: components['schemas']['LinkResponseDto']; + Invitation: components["schemas"]["InvitationResponseDto"]; + Share: components["schemas"]["ShareResponseDto2"]; + Link: components["schemas"]["LinkResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -4339,9 +4474,9 @@ export interface components { }; LinkAccessesResponseDto: { /** @default null */ - ContextShare: components['schemas']['ContextShareDto'] | null; + ContextShare: components["schemas"]["ContextShareDto"] | null; /** @default null */ - Invitations: components['schemas']['PendingInvitationItemDto'][] | null; + Invitations: components["schemas"]["PendingInvitationItemDto"][] | null; /** * ProtonResponseCode * @example 1000 @@ -4350,7 +4485,7 @@ export interface components { Code: 1000; }; ListShareMembersResponseDto: { - Members: components['schemas']['MemberResponseDto2'][]; + Members: components["schemas"]["MemberResponseDto2"][]; /** * ProtonResponseCode * @example 1000 @@ -4374,8 +4509,8 @@ export interface components { }; /** @description For each hash from the request, response contains either result or error entry */ SecurityResponseDto: { - Results: components['schemas']['SecurityResponseResultDto'][]; - Errors: components['schemas']['SecurityResponseErrorDto'][]; + Results: components["schemas"]["SecurityResponseResultDto"][]; + Errors: components["schemas"]["SecurityResponseErrorDto"][]; /** * ProtonResponseCode * @example 1000 @@ -4385,11 +4520,11 @@ export interface components { }; ThumbnailIDsListInput: { /** @description List of encrypted ThumbnailIDs. Maximum 30. */ - ThumbnailIDs: components['schemas']['Id'][]; + ThumbnailIDs: components["schemas"]["Id"][]; }; ListThumbnailsResponse: { - Thumbnails: components['schemas']['ThumbnailResponse'][]; - Errors: components['schemas']['ThumbnailErrorResponse'][]; + Thumbnails: components["schemas"]["ThumbnailResponse"][]; + Errors: components["schemas"]["ThumbnailErrorResponse"][]; /** * ProtonResponseCode * @example 1000 @@ -4398,8 +4533,8 @@ export interface components { Code: 1000; }; SettingsResponse: { - UserSettings: components['schemas']['UserSettings']; - Defaults: components['schemas']['Defaults']; + UserSettings: components["schemas"]["UserSettings"]; + Defaults: components["schemas"]["Defaults"]; /** * ProtonResponseCode * @example 1000 @@ -4408,12 +4543,10 @@ export interface components { Code: 1000; }; UserSettingsRequest: { - Layout?: components['schemas']['LayoutSetting'] | null; - Sort?: components['schemas']['SortSetting'] | null; + Layout?: components["schemas"]["LayoutSetting"] | null; + Sort?: components["schemas"]["SortSetting"] | null; /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ - RevisionRetentionDays?: components['schemas']['RevisionRetentionDays'] | null; - /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ - B2BPhotosEnabled?: boolean | null; + RevisionRetentionDays?: components["schemas"]["RevisionRetentionDays"] | null; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ @@ -4421,19 +4554,18 @@ export interface components { /** @description Indicates user-preferred font in Proton Docs. */ DocsFontPreference?: string | null; /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ - PhotoTags?: components['schemas']['TagType'][] | null; + PhotoTags?: components["schemas"]["TagType"][] | null; }; CreateVolumeRequestDto: { - /** @description User's Address encrypted ID */ - AddressID: string; - ShareKey: components['schemas']['PGPPrivateKey']; - SharePassphrase: components['schemas']['PGPMessage']; - SharePassphraseSignature: components['schemas']['PGPSignature']; - FolderName: components['schemas']['PGPMessage']; - FolderKey: components['schemas']['PGPPrivateKey']; - FolderPassphrase: components['schemas']['PGPMessage']; - FolderPassphraseSignature: components['schemas']['PGPSignature']; - FolderHashKey: components['schemas']['PGPMessage']; + AddressID: components["schemas"]["AddressID"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + SharePassphrase: components["schemas"]["PGPMessage"]; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + FolderName: components["schemas"]["PGPMessage"]; + FolderKey: components["schemas"]["PGPPrivateKey"]; + FolderPassphrase: components["schemas"]["PGPMessage"]; + FolderPassphraseSignature: components["schemas"]["PGPSignature"]; + FolderHashKey: components["schemas"]["PGPMessage"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ AddressKeyID: string; /** @@ -4448,7 +4580,7 @@ export interface components { ShareName: string | null; }; GetVolumeResponseDto: { - Volume: components['schemas']['VolumeResponseDto']; + Volume: components["schemas"]["VolumeResponseDto"]; /** * ProtonResponseCode * @example 1000 @@ -4457,7 +4589,7 @@ export interface components { Code: 1000; }; ListVolumesResponseDto: { - Volumes: components['schemas']['VolumeResponseDto'][]; + Volumes: components["schemas"]["VolumeResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -4466,27 +4598,26 @@ export interface components { Code: 1000; }; RestoreVolumeDto: { - /** Format: email */ - SignatureAddress: string; + SignatureAddress: components["schemas"]["AddressEmail"]; /** @default [] */ - MainShares: components['schemas']['RestoreMainShareDto'][]; + MainShares: components["schemas"]["RestoreMainShareDto"][]; /** @default [] */ - Devices: components['schemas']['RestoreRootShareDto'][]; + Devices: components["schemas"]["RestoreRootShareDto"][]; /** @default [] */ - PhotoShares: components['schemas']['RestoreRootShareDto'][]; + PhotoShares: components["schemas"]["RestoreRootShareDto"][]; /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ AddressKeyID: string; }; AddPhotoToAlbumWithLinkIDResponseDto: Record; RemovePhotoFromAlbumWithLinkIDResponseDto: Record; ConflictErrorResponseDto: { - Details: components['schemas']['ConflictErrorDetailsDto']; + Details: components["schemas"]["ConflictErrorDetailsDto"]; Error: string; Code: number; }; MultiDeleteTransformer: { LinkID: string; - Response: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; + Response: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; }; /** Link */ ExtendedLinkTransformer: { @@ -4498,17 +4629,10 @@ export interface components { Shared: 0 | 1; /** @deprecated */ ShareUrls: { - /** - * @deprecated - * @description Share URL ID Deprecated - */ + /** @deprecated */ ShareUrlId?: string; - /** @description ShareURL ID */ ShareURLID?: string; - /** - * @deprecated - * @description ShareID - */ + /** @deprecated */ ShareID?: string; /** @description URL Token (not always provided) */ Token?: string; @@ -4532,13 +4656,11 @@ export interface components { ShareID?: string; /** @description Share URL linking to this file or folder */ ShareUrl?: { - /** - * @deprecated - * @description Share URL ID Deprecated - */ + /** @deprecated */ ShareUrlId?: string; - /** @description ShareURL ID */ ShareURLID?: string; + /** @deprecated */ + ShareID?: string; /** @description URL Token (not always provided) */ Token?: string; /** @@ -4636,8 +4758,8 @@ export interface components { */ Token?: string; }; - Thumbnails?: components['schemas']['ThumbnailTransformer'][]; - Photo?: components['schemas']['PhotoTransformer'] | null; + Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; + Photo?: components["schemas"]["PhotoTransformer"] | null; }; } | null; FolderProperties: { @@ -4678,9 +4800,9 @@ export interface components { /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ Tags?: number[]; } | null; - } & components['schemas']['LinkTransformer']; + } & components["schemas"]["LinkTransformer"]; GetRevisionResponseDto2: { - Revision: components['schemas']['DetailedRevisionResponseDto2']; + Revision: components["schemas"]["DetailedRevisionResponseDto2"]; /** * ProtonResponseCode * @example 1000 @@ -4690,36 +4812,36 @@ export interface components { }; /** @description Conflict, a share already exists for the file or folder. */ ShareConflictErrorResponseDto: { - Details: components['schemas']['ShareConflictErrorDetailsDto']; + Details: components["schemas"]["ShareConflictErrorDetailsDto"]; Error: string; Code: number; }; SmallFileUploadMetadataRequestDto: { - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; NameHash: string; - ParentLinkID: components['schemas']['Id']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; /** * Format: email * @description Address used to sign passphrase, name, manifest, block, and xAttr. Is null for anonymous users. */ - SignatureEmail?: string | null; - NodeKey: components['schemas']['PGPPrivateKey']; + SignatureEmail?: components["schemas"]["AddressEmail"] | null; + NodeKey: components["schemas"]["PGPPrivateKey"]; /** @example text/plain */ MIMEType: string; - ContentKeyPacket: components['schemas']['BinaryString']; + ContentKeyPacket: components["schemas"]["BinaryString"]; /** @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. */ ContentKeyPacketSignature?: string | null; - ManifestSignature: components['schemas']['PGPSignature']; - ContentBlockVerificationToken?: components['schemas']['BinaryString'] | null; + ManifestSignature: components["schemas"]["PGPSignature"]; + ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; /** * @description Extended attributes encrypted with link key * @default null */ XAttr: string | null; /** @default null */ - Photo: components['schemas']['CommitRevisionPhotoDto'] | null; + Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; /** * @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. * @default null @@ -4727,16 +4849,16 @@ export interface components { ContentBlockEncSignature: string | null; }; SmallRevisionUploadMetadataRequestDto: { - CurrentRevisionID: components['schemas']['Id']; + CurrentRevisionID: components["schemas"]["Id"]; /** * Format: email * @description Address used to sign manifest, block, and xAttr. Is null for anonymous users. */ - SignatureEmail?: string | null; - ManifestSignature: components['schemas']['PGPSignature']; + SignatureEmail?: components["schemas"]["AddressEmail"] | null; + ManifestSignature: components["schemas"]["PGPSignature"]; /** @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. */ - ContentBlockEncSignature?: components['schemas']['PGPMessage'] | null; - ContentBlockVerificationToken?: components['schemas']['BinaryString'] | null; + ContentBlockEncSignature?: components["schemas"]["PGPMessage"] | null; + ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; /** * @description File extended attributes encrypted with link key * @default null @@ -4745,8 +4867,8 @@ export interface components { }; ShareURLResponseDto: { Token: string; - ShareURLID: components['schemas']['Id']; - ShareID: components['schemas']['Id']; + ShareURLID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; /** @description URL to use to access the ShareURL */ PublicUrl: string; ExpirationTime?: number | null; @@ -4754,7 +4876,7 @@ export interface components { CreateTime: number; MaxAccesses: number; NumAccesses: number; - Name?: components['schemas']['PGPMessage'] | null; + Name?: components["schemas"]["PGPMessage"] | null; CreatorEmail: string; /** * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: @@ -4768,15 +4890,15 @@ export interface components { * - `1`: FLAG_CUSTOM_PASSWORD, * - `2`: FLAG_RANDOM_PASSWORD */ Flags: number; - UrlPasswordSalt: components['schemas']['BinaryString']; - SharePasswordSalt: components['schemas']['BinaryString']; - SRPVerifier: components['schemas']['BinaryString']; - SRPModulusID: components['schemas']['Id']; - Password: components['schemas']['PGPMessage']; - SharePassphraseKeyPacket: components['schemas']['BinaryString']; + UrlPasswordSalt: components["schemas"]["BinaryString"]; + SharePasswordSalt: components["schemas"]["BinaryString"]; + SRPVerifier: components["schemas"]["BinaryString"]; + SRPModulusID: components["schemas"]["Id"]; + Password: components["schemas"]["PGPMessage"]; + SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; }; AlbumPhotoLinkDataDto: { - LinkID: components['schemas']['Id']; + LinkID: components["schemas"]["Id"]; /** @description Name Hash */ Hash: string; Name: string; @@ -4785,64 +4907,63 @@ export interface components { * @description Email address used for signing name */ NameSignatureEmail: string; - NodePassphrase: components['schemas']['PGPMessage']; + NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Photo content hash */ ContentHash: string; /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ - NodePassphraseSignature?: components['schemas']['PGPSignature'] | null; + NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; /** * Format: email * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature */ - SignatureEmail?: string | null; + SignatureEmail?: components["schemas"]["AddressEmail"] | null; }; AlbumLinkDto: { - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; /** @description Album name Hash */ Hash: string; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; - /** - * Format: email - * @description Signature email address used to sign passphrase and name - */ - SignatureEmail: string; - NodeKey: components['schemas']['PGPPrivateKey']; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + SignatureEmail: components["schemas"]["AddressEmail"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; - /** @description Extended attributes encrypted with link key */ - XAttr?: string | null; + /** + * @description Extended attributes encrypted with link key + * @default null + */ + XAttr: string | null; }; AlbumShortResponseDto: { - Link: components['schemas']['AlbumLinkResponseDto']; + Link: components["schemas"]["AlbumLinkResponseDto"]; }; ShareDataDto: { - AddressID: components['schemas']['Id']; - Key: components['schemas']['PGPPrivateKey']; - Passphrase: components['schemas']['PGPMessage']; - PassphraseSignature: components['schemas']['PGPSignature']; + AddressID: components["schemas"]["AddressID"]; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ AddressKeyID: string; }; LinkDataDto: { /** @description Root folder name */ Name: string; - NodeKey: components['schemas']['PGPPrivateKey']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; - NodeHashKey: components['schemas']['PGPMessage']; + NodeKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeHashKey: components["schemas"]["PGPMessage"]; }; PhotoVolumeResponseDto: { - VolumeID: components['schemas']['Id2']; + VolumeID: components["schemas"]["Id2"]; CreateTime: number; ModifyTime: number; /** @description Used space in bytes */ UsedSpace: number; DownloadedBytes: number; UploadedBytes: number; - State: components['schemas']['VolumeState']; - Share: components['schemas']['ShareReferenceResponseDto']; - Type: components['schemas']['VolumeType2']; + State: components["schemas"]["VolumeState"]; + Share: components["schemas"]["ShareReferenceResponseDto"]; + Type: components["schemas"]["VolumeType2"]; }; FoundDuplicate: { /** @description NameHash of the found duplicate */ @@ -4862,7 +4983,7 @@ export interface components { RevisionID: string; }; PhotoTagMigrationDataDto: { - LastProcessedLinkID: components['schemas']['Id2']; + LastProcessedLinkID: components["schemas"]["Id2"]; LastProcessedCaptureTime: number; LastMigrationTimestamp: number; /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. @@ -4873,19 +4994,19 @@ export interface components { Locked: boolean; LastActivityTime: number; PhotoCount: number; - LinkID: components['schemas']['Id2']; - VolumeID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; /** @default null */ - ShareID: components['schemas']['Id2'] | null; + ShareID: components["schemas"]["Id2"] | null; /** @default null */ - CoverLinkID: components['schemas']['Id2'] | null; + CoverLinkID: components["schemas"]["Id2"] | null; }; ListPhotosAlbumItemResponseDto: { - LinkID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; CaptureTime: number; Hash: string; ContentHash: string; - RelatedPhotos: components['schemas']['ListPhotosAlbumRelatedPhotoItemResponseDto'][]; + RelatedPhotos: components["schemas"]["ListPhotosAlbumRelatedPhotoItemResponseDto"][]; AddedTime: number; IsChildOfAlbum: boolean; /** @@ -4895,7 +5016,7 @@ export interface components { Tags: number[]; }; TransferPhotoLinkInBatchRequestDto: { - LinkID: components['schemas']['Id']; + LinkID: components["schemas"]["Id"]; /** @description Name, reusing same session key as previously. */ Name: string; /** @description Node passphrase, reusing same session key as previously. */ @@ -4913,17 +5034,17 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components['schemas']['PGPSignature'] | null; + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; }; PhotoTagMigrationUpdateDto: { - LastProcessedLinkID: components['schemas']['Id']; + LastProcessedLinkID: components["schemas"]["Id"]; LastProcessedCaptureTime: number; CurrentTimestamp: number; /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. */ ClientUID: string; }; AlbumLinkUpdateDto: { - Name?: components['schemas']['PGPMessage'] | null; + Name?: components["schemas"]["PGPMessage"] | null; Hash?: string | null; /** * Format: email @@ -4935,38 +5056,38 @@ export interface components { XAttr?: string | null; }; BookmarkShareURLRequestDto: { - EncryptedUrlPassword?: components['schemas']['PGPMessage'] | null; - AddressID: components['schemas']['Id']; - AddressKeyID: components['schemas']['Id']; + EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + AddressID: components["schemas"]["AddressID"]; + AddressKeyID: components["schemas"]["Id"]; }; BookmarkShareURLResponseDto: { - UserID: components['schemas']['Id2']; + UserID: components["schemas"]["Id2"]; Token: string; - ShareURLID: components['schemas']['Id2']; - EncryptedUrlPassword?: components['schemas']['PGPMessage2'] | null; - State: components['schemas']['BookmarkShareURLState']; + ShareURLID: components["schemas"]["Id2"]; + EncryptedUrlPassword?: components["schemas"]["PGPMessage2"] | null; + State: components["schemas"]["BookmarkShareURLState"]; CreateTime: number; ModifyTime: number; }; BookmarkShareURLInfoResponseDto: { - EncryptedUrlPassword?: components['schemas']['PGPMessage2'] | null; + EncryptedUrlPassword?: components["schemas"]["PGPMessage2"] | null; CreateTime: number; - Token: components['schemas']['TokenResponseDto']; + Token: components["schemas"]["TokenResponseDto"]; }; DeviceDataDto: { - SyncState: components['schemas']['DeviceSyncState']; - Type: components['schemas']['DeviceType']; + SyncState: components["schemas"]["DeviceSyncState"]; + Type: components["schemas"]["DeviceType"]; /** * @deprecated * @default null */ - VolumeID: components['schemas']['Id'] | null; + VolumeID: components["schemas"]["Id"] | null; }; ShareDataDto2: { - AddressID: components['schemas']['Id']; - Key: components['schemas']['PGPPrivateKey']; - Passphrase: components['schemas']['PGPMessage']; - PassphraseSignature: components['schemas']['PGPSignature']; + AddressID: components["schemas"]["AddressID"]; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ AddressKeyID: string; /** @@ -4976,22 +5097,22 @@ export interface components { Name: string | null; }; DeviceResponseDto: { - DeviceID: components['schemas']['Id2']; - ShareID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; + DeviceID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; DeviceResponseDto2: { - Device: components['schemas']['DeviceDataDto3']; - Share: components['schemas']['ShareDataDto4']; + Device: components["schemas"]["DeviceDataDto3"]; + Share: components["schemas"]["ShareDataDto4"]; }; DeviceResponseDto3: { - Device: components['schemas']['DeviceDto']; - ShareID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; + Device: components["schemas"]["DeviceDto"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; DeviceDataDto2: { /** @default null */ - SyncState: components['schemas']['DeviceSyncState'] | null; + SyncState: components["schemas"]["DeviceSyncState"] | null; /** * @description UNIX timestamp when the Device got last synced. Optional * @default null @@ -5018,23 +5139,26 @@ export interface components { PGPMessage: string; /** @description An armored PGP Private Key */ PGPPrivateKey: string; + /** + * Format: email + * @description Address Email + */ + AddressEmail: string; DocumentDetailsDto: { - VolumeID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; - RevisionID: components['schemas']['Id2']; + VolumeID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; + RevisionID: components["schemas"]["Id2"]; }; /** @description An encrypted ID */ Id2: string; EventResponseDto: { - EventID: components['schemas']['Id2']; - EventType: components['schemas']['EventType']; + EventID: components["schemas"]["Id2"]; + EventType: components["schemas"]["EventType"]; /** @description Event creation timestamp */ CreateTime: number; - Link: - | { - LinkID: components['schemas']['Id']; - } - | components['schemas']['ExtendedLinkTransformer2']; + Link: { + LinkID: components["schemas"]["Id"]; + } | components["schemas"]["ExtendedLinkTransformer2"]; /** * @description The share the user has access to that is closest to the root. Delete events do not have it but other events do. * @default null @@ -5071,76 +5195,76 @@ export interface components { } | null; }; EventV2ResponseDto: { - EventID: components['schemas']['Id2']; - EventType: components['schemas']['EventType']; - Link: components['schemas']['EventLinkDataDto']; + EventID: components["schemas"]["Id2"]; + EventType: components["schemas"]["EventType"]; + Link: components["schemas"]["EventLinkDataDto"]; }; FolderResponseDto: { - ID: components['schemas']['Id2']; + ID: components["schemas"]["Id2"]; }; /** @description An encrypted ID */ EncryptedId: string; PendingHashResponseDto: { Hash: string; - RevisionID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; + RevisionID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; ClientUID?: string | null; }; PhotosDto: { /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ ContentHash: string; /** @default [] */ - RelatedPhotos: components['schemas']['RelatedPhotoDto'][]; + RelatedPhotos: components["schemas"]["RelatedPhotoDto"][]; }; ListMissingHashKeyItemDto: { - LinkID: components['schemas']['Id2']; - VolumeID: components['schemas']['Id2']; - ShareID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; }; FileDetailsDto: { - Link: components['schemas']['LinkDto']; - File: components['schemas']['FileDto']; + Link: components["schemas"]["LinkDto"]; + File: components["schemas"]["FileDto"]; /** @default null */ - Sharing: components['schemas']['SharingDto'] | null; + Sharing: components["schemas"]["SharingDto"] | null; /** * @description Will be null if the user is not a member or is the owner. * @default null */ - Membership: components['schemas']['MembershipDto'] | null; + Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ Folder: null | null; /** @default null */ Album: null | null; }; FolderDetailsDto: { - Link: components['schemas']['LinkDto']; - Folder: components['schemas']['FolderDto']; + Link: components["schemas"]["LinkDto"]; + Folder: components["schemas"]["FolderDto"]; /** @default null */ - Sharing: components['schemas']['SharingDto'] | null; + Sharing: components["schemas"]["SharingDto"] | null; /** * @description Will be null if the user is not a member or is the owner. * @default null */ - Membership: components['schemas']['MembershipDto'] | null; + Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ File: null | null; /** @default null */ Album: null | null; }; AlbumDetailsDto: { - Link: components['schemas']['LinkDto']; - Album: components['schemas']['AlbumDto']; + Link: components["schemas"]["LinkDto"]; + Album: components["schemas"]["AlbumDto"]; /** @default null */ - Sharing: components['schemas']['SharingDto'] | null; + Sharing: components["schemas"]["SharingDto"] | null; /** @default null */ - Membership: components['schemas']['MembershipDto'] | null; + Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ File: null | null; /** @default null */ Folder: null | null; }; MoveLinkInBatchRequestDto: { - LinkID: components['schemas']['Id']; + LinkID: components["schemas"]["Id"]; /** @description Name, reusing same session key as previously. */ Name: string; /** @description Node passphrase, reusing same session key as previously. */ @@ -5161,12 +5285,12 @@ export interface components { * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. * @default null */ - NodePassphraseSignature: components['schemas']['PGPSignature'] | null; + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; }; UpdateMissingHashKeyItemDto: { - LinkID: components['schemas']['Id']; - VolumeID: components['schemas']['Id']; - PGPArmoredEncryptedNodeHashKey: components['schemas']['PGPMessage']; + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + PGPArmoredEncryptedNodeHashKey: components["schemas"]["PGPMessage"]; }; CommitRevisionPhotoDto: { /** @description Photo capture timestamp */ @@ -5183,24 +5307,29 @@ export interface components { * @description Deprecated: Clients persist exif information in xAttr instead * @default null */ - Exif: components['schemas']['BinaryString'] | null; + Exif: components["schemas"]["BinaryString"] | null; /** * @description List of tags to be assigned to the photo * @default null */ - Tags: components['schemas']['TagType'][] | null; + Tags: components["schemas"]["TagType"][] | null; }; BlockTokenDto: { Index: number; Token: string; }; + FileResponseDto: { + ID: components["schemas"]["Id2"]; + RevisionID: components["schemas"]["Id2"]; + ClientUID?: string | null; + }; RevisionResponseDto: { - ID: components['schemas']['Id2']; - ManifestSignature?: components['schemas']['PGPSignature2'] | null; + ID: components["schemas"]["Id2"]; + ManifestSignature?: components["schemas"]["PGPSignature2"] | null; /** @description Size of revision (in bytes) */ Size: number; - State: components['schemas']['RevisionState']; - XAttr?: components['schemas']['PGPMessage2'] | null; + State: components["schemas"]["RevisionState"]; + XAttr?: components["schemas"]["PGPMessage2"] | null; /** * @deprecated * @description Flag stating if revision has a thumbnail @@ -5208,13 +5337,13 @@ export interface components { */ Thumbnail: 0 | 1; /** @deprecated */ - ThumbnailHash?: components['schemas']['BinaryString2'] | null; + ThumbnailHash?: components["schemas"]["BinaryString2"] | null; /** * @deprecated * @description Size thumbnail in bytes; 0 if no thumbnail present */ ThumbnailSize: number; - Thumbnails: components['schemas']['ThumbnailResponseDto'][]; + Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; ClientUID?: string | null; /** @default null */ CreateTime: number | null; @@ -5235,12 +5364,14 @@ export interface components { /** @description Base64 encoded binary data */ BinaryString2: string; ShareTrashList: { - ShareID: components['schemas']['Id2']; + ShareID: components["schemas"]["Id2"]; /** @description List of trashed link IDs for that share */ - LinkIDs: components['schemas']['Id2'][]; + LinkIDs: components["schemas"]["Id2"][]; /** @description List of trashed link's parentLinkIDs */ - ParentIDs: components['schemas']['Id2'][]; + ParentIDs: components["schemas"]["Id2"][]; }; + /** @description Address ID */ + AddressID: string; RequestUploadBlockInput: { /** @description Block size in bytes */ Size: number; @@ -5249,7 +5380,7 @@ export interface components { /** @description sha256 hash of encrypted block, base64 encoded */ Hash: string; /** @default null */ - Verifier: components['schemas']['Verifier'] | null; + Verifier: components["schemas"]["Verifier"] | null; /** * @description Encrypted PGP Signature of the raw block content. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. * @default null @@ -5259,7 +5390,7 @@ export interface components { RequestUploadThumbnailInput: { /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ Size: number; - Type: components['schemas']['ThumbnailType']; + Type: components["schemas"]["ThumbnailType"]; /** @description sha256 hash of encrypted block, base64 encoded */ Hash: string; }; @@ -5275,7 +5406,7 @@ export interface components { Token: string; /** @deprecated */ URL: string; - ThumbnailType: components['schemas']['ThumbnailType2']; + ThumbnailType: components["schemas"]["ThumbnailType2"]; }; EntitlementsDto: { /** @description Maximum number of days revision history can be kept */ @@ -5294,24 +5425,24 @@ export interface components { * @description Email address used for signing name */ NameSignatureEmail: string; - NodePassphrase: components['schemas']['PGPMessage']; + NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Photo content hash */ ContentHash: string; /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ - NodePassphraseSignature?: components['schemas']['PGPSignature'] | null; + NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; /** * Format: email * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature */ SignatureEmail?: string | null; /** @default [] */ - RelatedPhotos: components['schemas']['AlbumPhotoLinkDataDto'][]; + RelatedPhotos: components["schemas"]["AlbumPhotoLinkDataDto"][]; }; FavoriteRelatedPhotoResponseDto: { - LinkID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; }; PhotoListingItemResponse: { - LinkID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; /** @description File name hash */ @@ -5324,224 +5455,61 @@ export interface components { */ Tags: number[]; /** @default [] */ - RelatedPhotos: components['schemas']['PhotoListingRelatedItemResponse'][]; - }; - FileResponseDto: { - ID: components['schemas']['Id2']; - RevisionID: components['schemas']['Id2']; - ClientUID?: string | null; - }; - LinkWithAuthorizationTokenDto: { - LinkID: components['schemas']['Id']; - /** @default null */ - AuthorizationToken: string | null; - }; - AnonymousUploadBlockDto: { - /** @description Block size in bytes */ - Size: number; - /** @description Index of block in list (must be consecutive starting at 1) */ - Index: number; - /** @description sha256 hash of encrypted block, base64 encoded */ - Hash: string; - Verifier: components['schemas']['Verifier']; - /** - * @description Encrypted PGP Signature of the raw block content. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. - * @default null - */ - EncSignature: string | null; - }; - ShareURLContext: { - /** @description Share ID of the share highest in the tree with permissions */ - ContextShareID: string; - ShareURLs: components['schemas']['ShareURLResponseDto2'][]; - /** @description Related link IDs and ancestors up to the share. */ - LinkIDs: components['schemas']['Id2'][]; - }; - LinkMapItemResponse: { - Index: number; - LinkID: components['schemas']['Id2']; - ParentLinkID?: components['schemas']['Id2'] | null; - Type: components['schemas']['NodeType2']; - Name: components['schemas']['PGPMessage2']; - Hash?: string | null; - State: components['schemas']['LinkState2']; - Size: number; - MIMEType: string; - CreateTime: number; - ModifyTime: number; - /** @default null */ - NodeKey: components['schemas']['PGPPrivateKey2']; - /** @default null */ - NodePassphrase: components['schemas']['PGPMessage2']; - /** @default null */ - NodePassphraseSignature: components['schemas']['PGPSignature2']; - /** @default null */ - NodeSignatureEmail: string; - }; - VolumeDto: { - VolumeID: components['schemas']['Id2']; - UsedSpace: number; - }; - ShareDto: { - ShareID: components['schemas']['Id2']; - /** Format: email */ - CreatorEmail: string; - Key: components['schemas']['PGPPrivateKey2']; - Passphrase: components['schemas']['PGPMessage2']; - PassphraseSignature: components['schemas']['PGPSignature2']; - AddressID: components['schemas']['Id2']; - InviterSharePassphraseKeyPacketSignature?: components['schemas']['PGPSignature2'] | null; - InviteeSharePassphraseSessionKeySignature?: components['schemas']['PGPSignature2'] | null; + RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; }; - FolderDetailsDto2: { - Link: components['schemas']['LinkDto2']; - Folder: components['schemas']['FolderDto2']; - /** @default null */ - Sharing: components['schemas']['SharingDto2'] | null; + AuthShareDataResponseDto: { + VolumeID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; + SharePasswordSalt: components["schemas"]["BinaryString2"]; + SharePassphrase: components["schemas"]["PGPMessage2"]; + ShareKey: components["schemas"]["PGPPrivateKey2"]; /** - * @description Will be null if the user is not a member or is the owner. - * @default null + * @description Permission bitfield of the share URL + * @enum {integer} */ - Membership: components['schemas']['MembershipDto2'] | null; - /** @default null */ - File: null | null; - /** @default null */ - Album: null | null; + PublicPermissions: 4 | 6; }; /** - * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
- * @enum {integer} - */ - ShareType: 1 | 2 | 3 | 4; - /** - * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
5Migrated
6Locked
- * @enum {integer} - */ - ShareState: 1 | 2 | 3 | 5 | 6; - /** - * @description

1=Regular, 2=Photo

See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
- * @enum {integer} - */ - VolumeType: 1 | 2; - /** - * @description

1=folder, 2=file

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @description
See values descriptions
See values descriptions
ValueDescription
0ProtonDrive
1ProtonDoc
2ProtonSheet
* @enum {integer} */ - NodeType: 1 | 2 | 3; - /** @description An armored PGP Private Key */ - PGPPrivateKey2: string; - /** @description An armored PGP Message */ - PGPMessage2: string; - /** @description An armored PGP Signature */ - PGPSignature2: string; - MemberResponseDto: { - MemberID: components['schemas']['Id2']; - ShareID: components['schemas']['Id2']; - AddressID: components['schemas']['Id2']; - AddressKeyID: components['schemas']['Id2']; - /** Format: email */ - Inviter: string; + VendorType: 0 | 1 | 2; + DirectAccessResponseDto: { + VolumeID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; /** - * @description Permission bitfield, valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * + * @description Permission bitfield the user has on the node the share URL points to * @enum {integer} */ - Permissions: 4 | 6 | 22; - /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - KeyPacket: string; - /** @description PGP signature of the member key packet (encrypted) by inviter */ - KeyPacketSignature: string; - /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ - SessionKeySignature: string; - State: components['schemas']['ShareMemberState']; - CreateTime: number; - ModifyTime: number; - /** @deprecated */ - CreationTime: number; + DirectPermissions: 4 | 6 | 22; /** - * @deprecated - * @description Deprecated and always null - * @default null - */ - Unlockable: boolean | null; - }; - KeyPacketResponseDto: { - AddressID: components['schemas']['Id2']; - AddressKeyID: components['schemas']['Id2']; - KeyPacket: components['schemas']['BinaryString2']; - State: components['schemas']['ShareMemberState']; - /** - * @deprecated - * @description Deprecated and always null - * @default null - */ - Unlockable: boolean | null; - }; - ShareResponseDto: { - ShareID: components['schemas']['Id2']; - VolumeID: components['schemas']['Id2']; - Type: components['schemas']['ShareType']; - State: components['schemas']['ShareState']; - VolumeType: components['schemas']['VolumeType']; - /** Format: email */ - Creator: string; - Locked?: boolean | null; - CreateTime: number; - ModifyTime: number; - LinkID: components['schemas']['Id2']; - /** - * @deprecated - * @description Deprecated: Use `CreateTime` + * @description Permission bitfield of the share URL + * @enum {integer} */ - CreationTime: number; - /** @deprecated */ - PermissionsMask: number; - /** @deprecated */ - LinkType: number; - /** @deprecated */ - Flags: number; - /** @deprecated */ - BlockSize: number; - /** @deprecated */ - VolumeSoftDeleted: boolean; - }; - ShareKPMigrationData: { - /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ - ShareID: string; - /** @description Key packet to decrypt the share passphrase, encrypted with the node key, base64 encoded */ - PassphraseNodeKeyPacket: string; + PublicPermissions: 4 | 6; }; - /** @description Share unable to be migrated with reason and code; NOT_EXISTS, INCOMPATIBLE_STATE, PERMISSION_DENIED, ENCRYPTION_VERIFICATION_FAILED */ - ShareKPMigrationError: { - ShareID: components['schemas']['Id2']; - Error: string; - Code: number; + LinkWithAuthorizationTokenDto: { + LinkID: components["schemas"]["Id"]; + /** @default null */ + AuthorizationToken: string | null; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0ProtonDrive
1ProtonDoc
2ProtonSheet
- * @enum {integer} - */ - VendorType: 0 | 1 | 2; TokenResponseDto: { /** * @description Url Token * @example YTZZRH7DA8 */ Token: string; - LinkType: components['schemas']['NodeType3']; - VolumeID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; - SharePasswordSalt: components['schemas']['BinaryString2']; - SharePassphrase: components['schemas']['PGPMessage2']; - ShareKey: components['schemas']['PGPPrivateKey2']; - NodePassphrase: components['schemas']['PGPMessage2']; - NodeKey: components['schemas']['PGPPrivateKey2']; - Name: components['schemas']['PGPMessage2']; + LinkType: components["schemas"]["NodeType2"]; + VolumeID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; + SharePasswordSalt: components["schemas"]["BinaryString2"]; + SharePassphrase: components["schemas"]["PGPMessage2"]; + ShareKey: components["schemas"]["PGPPrivateKey2"]; + NodePassphrase: components["schemas"]["PGPMessage2"]; + NodeKey: components["schemas"]["PGPPrivateKey2"]; + Name: components["schemas"]["PGPMessage2"]; /** @description Base64 encoded content key packet. Null for folders */ - ContentKeyPacket?: components['schemas']['BinaryString2'] | null; + ContentKeyPacket?: components["schemas"]["BinaryString2"] | null; /** @example text/plain */ MIMEType: string; /** @@ -5555,9 +5523,9 @@ export interface components { /** @description File size, null for folders */ Size?: number | null; /** @description File properties */ - ThumbnailURLInfo?: components['schemas']['ThumbnailURLInfoResponseDto'] | null; + ThumbnailURLInfo?: components["schemas"]["ThumbnailURLInfoResponseDto"] | null; /** @default null */ - NodeHashKey: components['schemas']['PGPMessage2'] | null; + NodeHashKey: components["schemas"]["PGPMessage2"] | null; /** * @description Signature email of the share owner. Only set for a ShareURL with read+write permissions. * @default null @@ -5567,17 +5535,17 @@ export interface components { * @description Only set for a ShareURL with read+write permissions. * @default null */ - NodePassphraseSignature: components['schemas']['PGPSignature2'] | null; + NodePassphraseSignature: components["schemas"]["PGPSignature2"] | null; }; DetailedRevisionResponseDto: { - Blocks: components['schemas']['BlockResponseDto'][]; - Photo?: components['schemas']['PhotoResponseDto'] | null; - ID: components['schemas']['Id2']; - ManifestSignature?: components['schemas']['PGPSignature2'] | null; + Blocks: components["schemas"]["BlockResponseDto"][]; + Photo?: components["schemas"]["PhotoResponseDto"] | null; + ID: components["schemas"]["Id2"]; + ManifestSignature?: components["schemas"]["PGPSignature2"] | null; /** @description Size of revision (in bytes) */ Size: number; - State: components['schemas']['RevisionState']; - XAttr?: components['schemas']['PGPMessage2'] | null; + State: components["schemas"]["RevisionState"]; + XAttr?: components["schemas"]["PGPMessage2"] | null; /** * @deprecated * @description Flag stating if revision has a thumbnail @@ -5585,13 +5553,13 @@ export interface components { */ Thumbnail: 0 | 1; /** @deprecated */ - ThumbnailHash?: components['schemas']['BinaryString2'] | null; + ThumbnailHash?: components["schemas"]["BinaryString2"] | null; /** * @deprecated * @description Size thumbnail in bytes; 0 if no thumbnail present */ ThumbnailSize: number; - Thumbnails: components['schemas']['ThumbnailResponseDto'][]; + Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; ClientUID?: string | null; /** @default null */ CreateTime: number | null; @@ -5610,28 +5578,35 @@ export interface components { SignatureAddress: string | null; }; GetSharedFileInfoPayloadDto: { - SharePasswordSalt: components['schemas']['BinaryString2']; - SharePassphrase: components['schemas']['PGPMessage2']; - ShareKey: components['schemas']['PGPPrivateKey2']; - NodePassphrase: components['schemas']['PGPMessage2']; - NodeKey: components['schemas']['PGPPrivateKey2']; - Name: components['schemas']['PGPMessage2']; + SharePasswordSalt: components["schemas"]["BinaryString2"]; + SharePassphrase: components["schemas"]["PGPMessage2"]; + ShareKey: components["schemas"]["PGPPrivateKey2"]; + NodePassphrase: components["schemas"]["PGPMessage2"]; + NodeKey: components["schemas"]["PGPPrivateKey2"]; + Name: components["schemas"]["PGPMessage2"]; Size: number; MIMEType: string; /** @description UNIX timestamp after which this link is no longer accessible */ ExpirationTime?: number | null; - ContentKeyPacket: components['schemas']['BinaryString2']; - BlockURLs: components['schemas']['ThumbnailURLInfoResponseDto'][]; - ThumbnailURLInfo: components['schemas']['ThumbnailURLInfoResponseDto']; + ContentKeyPacket: components["schemas"]["BinaryString2"]; + BlockURLs: components["schemas"]["ThumbnailURLInfoResponseDto"][]; + ThumbnailURLInfo: components["schemas"]["ThumbnailURLInfoResponseDto"]; /** @deprecated */ Blocks: string[]; /** @deprecated */ ThumbnailURL?: string | null; }; + ShareURLContext: { + /** @description Share ID of the share highest in the tree with permissions */ + ContextShareID: string; + ShareURLs: components["schemas"]["ShareURLResponseDto2"][]; + /** @description Related link IDs and ancestors up to the share. */ + LinkIDs: components["schemas"]["Id2"][]; + }; ShareURLResponseDto2: { Token: string; - ShareURLID: components['schemas']['Id2']; - ShareID: components['schemas']['Id2']; + ShareURLID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; /** @description URL to use to access the ShareURL */ PublicUrl: string; ExpirationTime?: number | null; @@ -5639,7 +5614,7 @@ export interface components { CreateTime: number; MaxAccesses: number; NumAccesses: number; - Name?: components['schemas']['PGPMessage2'] | null; + Name?: components["schemas"]["PGPMessage2"] | null; CreatorEmail: string; /** * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: @@ -5653,12 +5628,12 @@ export interface components { * - `1`: FLAG_CUSTOM_PASSWORD, * - `2`: FLAG_RANDOM_PASSWORD */ Flags: number; - UrlPasswordSalt: components['schemas']['BinaryString2']; - SharePasswordSalt: components['schemas']['BinaryString2']; - SRPVerifier: components['schemas']['BinaryString2']; - SRPModulusID: components['schemas']['Id2']; - Password: components['schemas']['PGPMessage2']; - SharePassphraseKeyPacket: components['schemas']['BinaryString2']; + UrlPasswordSalt: components["schemas"]["BinaryString2"]; + SharePasswordSalt: components["schemas"]["BinaryString2"]; + SRPVerifier: components["schemas"]["BinaryString2"]; + SRPModulusID: components["schemas"]["Id2"]; + Password: components["schemas"]["PGPMessage2"]; + SharePassphraseKeyPacket: components["schemas"]["BinaryString2"]; }; /** Link */ ExtendedLinkTransformer2: { @@ -5670,17 +5645,10 @@ export interface components { Shared: 0 | 1; /** @deprecated */ ShareUrls: { - /** - * @deprecated - * @description Share URL ID Deprecated - */ + /** @deprecated */ ShareUrlId?: string; - /** @description ShareURL ID */ ShareURLID?: string; - /** - * @deprecated - * @description ShareID - */ + /** @deprecated */ ShareID?: string; /** @description URL Token (not always provided) */ Token?: string; @@ -5704,13 +5672,11 @@ export interface components { ShareID?: string; /** @description Share URL linking to this file or folder */ ShareUrl?: { - /** - * @deprecated - * @description Share URL ID Deprecated - */ + /** @deprecated */ ShareUrlId?: string; - /** @description ShareURL ID */ ShareURLID?: string; + /** @deprecated */ + ShareID?: string; /** @description URL Token (not always provided) */ Token?: string; /** @@ -5808,8 +5774,8 @@ export interface components { */ Token?: string; }; - Thumbnails?: components['schemas']['ThumbnailTransformer'][]; - Photo?: components['schemas']['PhotoTransformer'] | null; + Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; + Photo?: components["schemas"]["PhotoTransformer"] | null; }; } | null; FolderProperties: { @@ -5850,20 +5816,183 @@ export interface components { /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ Tags?: number[]; } | null; - } & components['schemas']['LinkTransformer']; + } & components["schemas"]["LinkTransformer"]; + LinkMapItemResponse: { + Index: number; + LinkID: components["schemas"]["Id2"]; + ParentLinkID?: components["schemas"]["Id2"] | null; + Type: components["schemas"]["NodeType3"]; + Name: components["schemas"]["PGPMessage2"]; + Hash?: string | null; + State: components["schemas"]["LinkState2"]; + Size: number; + MIMEType: string; + CreateTime: number; + ModifyTime: number; + /** @default null */ + NodeKey: components["schemas"]["PGPPrivateKey2"]; + /** @default null */ + NodePassphrase: components["schemas"]["PGPMessage2"]; + /** @default null */ + NodePassphraseSignature: components["schemas"]["PGPSignature2"]; + /** @default null */ + NodeSignatureEmail: string; + }; + VolumeDto: { + VolumeID: components["schemas"]["Id2"]; + UsedSpace: number; + }; + ShareDto: { + ShareID: components["schemas"]["Id2"]; + /** Format: email */ + CreatorEmail: string; + Key: components["schemas"]["PGPPrivateKey2"]; + Passphrase: components["schemas"]["PGPMessage2"]; + PassphraseSignature: components["schemas"]["PGPSignature2"]; + AddressID: components["schemas"]["Id2"]; + InviterSharePassphraseKeyPacketSignature?: components["schemas"]["PGPSignature2"] | null; + InviteeSharePassphraseSessionKeySignature?: components["schemas"]["PGPSignature2"] | null; + }; + FolderDetailsDto2: { + Link: components["schemas"]["LinkDto2"]; + Folder: components["schemas"]["FolderDto2"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto2"] | null; + /** + * @description Will be null if the user is not a member or is the owner. + * @default null + */ + Membership: components["schemas"]["MembershipDto2"] | null; + /** @default null */ + File: null | null; + /** @default null */ + Album: null | null; + }; + /** + * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
+ * @enum {integer} + */ + ShareType: 1 | 2 | 3 | 4; + /** + * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
5Migrated
6Locked
+ * @enum {integer} + */ + ShareState: 1 | 2 | 3 | 5 | 6; + /** + * @description

1=Regular, 2=Photo

See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
+ * @enum {integer} + */ + VolumeType: 1 | 2; + /** + * @description

1=folder, 2=file

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType: 1 | 2 | 3; + /** @description An armored PGP Private Key */ + PGPPrivateKey2: string; + /** @description An armored PGP Message */ + PGPMessage2: string; + /** @description An armored PGP Signature */ + PGPSignature2: string; + MemberResponseDto: { + MemberID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + AddressID: components["schemas"]["Id2"]; + AddressKeyID: components["schemas"]["Id2"]; + /** Format: email */ + Inviter: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + KeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: string; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + SessionKeySignature: string; + State: components["schemas"]["ShareMemberState"]; + CreateTime: number; + ModifyTime: number; + /** @deprecated */ + CreationTime: number; + /** + * @deprecated + * @description Deprecated and always null + * @default null + */ + Unlockable: boolean | null; + }; + KeyPacketResponseDto: { + AddressID: components["schemas"]["Id2"]; + AddressKeyID: components["schemas"]["Id2"]; + KeyPacket: components["schemas"]["BinaryString2"]; + State: components["schemas"]["ShareMemberState"]; + /** + * @deprecated + * @description Deprecated and always null + * @default null + */ + Unlockable: boolean | null; + }; + ShareResponseDto: { + ShareID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + Type: components["schemas"]["ShareType"]; + State: components["schemas"]["ShareState"]; + VolumeType: components["schemas"]["VolumeType"]; + /** Format: email */ + Creator: string; + Locked?: boolean | null; + CreateTime: number; + ModifyTime: number; + LinkID: components["schemas"]["Id2"]; + /** + * @deprecated + * @description Deprecated: Use `CreateTime` + */ + CreationTime: number; + /** @deprecated */ + PermissionsMask: number; + /** @deprecated */ + LinkType: number; + /** @deprecated */ + Flags: number; + /** @deprecated */ + BlockSize: number; + /** @deprecated */ + VolumeSoftDeleted: boolean; + }; + ShareKPMigrationData: { + /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ + ShareID: string; + /** @description Key packet to decrypt the share passphrase, encrypted with the node key, base64 encoded */ + PassphraseNodeKeyPacket: string; + }; + /** @description Share unable to be migrated with reason and code; NOT_EXISTS, INCOMPATIBLE_STATE, PERMISSION_DENIED, ENCRYPTION_VERIFICATION_FAILED */ + ShareKPMigrationError: { + ShareID: components["schemas"]["Id2"]; + Error: string; + Code: number; + }; LinkSharedByMeResponseDto: { - ShareID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; - ContextShareID: components['schemas']['Id2']; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; + ContextShareID: components["schemas"]["Id2"]; }; LinkSharedWithMeResponseDto: { - VolumeID: components['schemas']['Id2']; - ShareID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; - ShareTargetType: components['schemas']['TargetType2']; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; + ShareTargetType: components["schemas"]["TargetType2"]; }; ExternalInvitationRequestDto: { - InviterAddressID: components['schemas']['Id']; + InviterAddressID: components["schemas"]["Id"]; /** Format: email */ InviteeEmail: string; /** @@ -5883,7 +6012,7 @@ export interface components { ItemName?: string | null; }; ExternalInvitationResponseDto: { - ExternalInvitationID: components['schemas']['Id2']; + ExternalInvitationID: components["schemas"]["Id2"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -5899,19 +6028,17 @@ export interface components { Permissions: 4 | 6 | 22; /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ ExternalInvitationSignature: string; - State: components['schemas']['ExternalInvitationState']; + State: components["schemas"]["ExternalInvitationState"]; CreateTime: number; }; UserRegisteredExternalInvitationItemDto: { - VolumeID: components['schemas']['Id2']; - ShareID: components['schemas']['Id2']; - ExternalInvitationID: components['schemas']['Id2']; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + ExternalInvitationID: components["schemas"]["Id2"]; }; InvitationRequestDto: { - /** Format: email */ - InviterEmail: string; - /** Format: email */ - InviteeEmail: string; + InviterEmail: components["schemas"]["AddressEmail"]; + InviteeEmail: components["schemas"]["AddressEmail"]; /** * @description Permission bitfield, valid permissions: * - 4: read access @@ -5926,10 +6053,10 @@ export interface components { /** @description Signature of the above member key packet with the private key of the user (inviter) and the signature context `drive.share-member.inviter`, base64 encoded */ KeyPacketSignature: string; /** @default null */ - ExternalInvitationID: components['schemas']['Id'] | null; + ExternalInvitationID: components["schemas"]["Id"] | null; }; InvitationResponseDto: { - InvitationID: components['schemas']['Id2']; + InvitationID: components["schemas"]["Id2"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -5950,32 +6077,32 @@ export interface components { CreateTime: number; }; PendingInvitationItemDto: { - VolumeID: components['schemas']['Id2']; - ShareID: components['schemas']['Id2']; - InvitationID: components['schemas']['Id2']; - ShareTargetType: components['schemas']['TargetType2']; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + InvitationID: components["schemas"]["Id2"]; + ShareTargetType: components["schemas"]["TargetType2"]; }; ShareResponseDto2: { - ShareID: components['schemas']['Id2']; - VolumeID: components['schemas']['Id2']; - Passphrase: components['schemas']['PGPMessage2']; - ShareKey: components['schemas']['PGPPrivateKey2']; + ShareID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + Passphrase: components["schemas"]["PGPMessage2"]; + ShareKey: components["schemas"]["PGPPrivateKey2"]; /** Format: email */ CreatorEmail: string; }; LinkResponseDto: { - Type: components['schemas']['NodeType2']; - LinkID: components['schemas']['Id2']; - Name: components['schemas']['PGPMessage2']; + Type: components["schemas"]["NodeType3"]; + LinkID: components["schemas"]["Id2"]; + Name: components["schemas"]["PGPMessage2"]; MIMEType?: string | null; }; ContextShareDto: { - VolumeID: components['schemas']['Id2']; - ShareID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; + VolumeID: components["schemas"]["Id2"]; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; MemberResponseDto2: { - MemberID: components['schemas']['Id2']; + MemberID: components["schemas"]["Id2"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -6011,22 +6138,25 @@ export interface components { Error: string; }; ThumbnailResponse: { - ThumbnailID: components['schemas']['Id2']; + ThumbnailID: components["schemas"]["Id2"]; BareURL: string; Token: string; }; ThumbnailErrorResponse: { - ThumbnailID: components['schemas']['Id2']; + ThumbnailID: components["schemas"]["Id2"]; Error: string; Code: number; }; UserSettings: { - Layout?: components['schemas']['LayoutSetting2'] | null; - Sort?: components['schemas']['SortSetting2'] | null; + /** + * @deprecated + * @description [DEPRECATED] Always NULL + */ + B2BPhotosEnabled: null; + Layout?: components["schemas"]["LayoutSetting2"] | null; + Sort?: components["schemas"]["SortSetting2"] | null; /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ - RevisionRetentionDays?: components['schemas']['RevisionRetentionDays2'] | null; - /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ - B2BPhotosEnabled?: boolean | null; + RevisionRetentionDays?: components["schemas"]["RevisionRetentionDays2"] | null; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsEnabled?: boolean | null; /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ @@ -6037,9 +6167,12 @@ export interface components { PhotoTags?: number[] | null; }; Defaults: { - RevisionRetentionDays: components['schemas']['RevisionRetentionDays3']; - /** @description Indicates if B2BPhotos (possibility to the user to use Photos) is enabled. If null, the default value to 0 = false will be used by backend. Changing the setting is only available to B2B users */ + /** + * @deprecated + * @description [DEPRECATED] Always true + */ B2BPhotosEnabled: boolean; + RevisionRetentionDays: components["schemas"]["RevisionRetentionDays3"]; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsEnabled: boolean; /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. */ @@ -6063,7 +6196,7 @@ export interface components { */ RevisionRetentionDays: 0 | 7 | 30 | 180 | 365 | 3650; VolumeResponseDto: { - ID: components['schemas']['Id2']; + ID: components["schemas"]["Id2"]; /** * @deprecated * @description Deprecated, use `CreateTime` instead @@ -6074,16 +6207,16 @@ export interface components { * @default null */ MaxSpace: number | null; - VolumeID: components['schemas']['Id2']; + VolumeID: components["schemas"]["Id2"]; CreateTime: number; ModifyTime: number; /** @description Used space in bytes */ UsedSpace: number; DownloadedBytes: number; UploadedBytes: number; - State: components['schemas']['VolumeState']; - Share: components['schemas']['ShareReferenceResponseDto']; - Type: components['schemas']['VolumeType2']; + State: components["schemas"]["VolumeState"]; + Share: components["schemas"]["ShareReferenceResponseDto"]; + Type: components["schemas"]["VolumeType2"]; }; RestoreMainShareDto: { /** @description ShareID of the existing, locked main share */ @@ -6092,8 +6225,8 @@ export interface components { Name: string; /** @description Hash of the name */ Hash: string; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; /** * @description Node Hash Key should be provided if it needs to be signed because it was unsigned or signed with the address key (legacy). It should be signed with the new parent's node key. If it was properly signed with the parent node key, it should not be updated. Armored PGP message. * @default null @@ -6109,7 +6242,7 @@ export interface components { PassphraseSignature: string; }; ConflictErrorDetailsDto: { - ConflictLinkID: components['schemas']['Id']; + ConflictLinkID: components["schemas"]["Id"]; /** * @description A conflicting Revision in Active state. * @default null @@ -6251,14 +6384,14 @@ export interface components { Trashed: number | null; }; DetailedRevisionResponseDto2: { - Blocks: components['schemas']['BlockResponseDto2'][]; - Photo?: components['schemas']['PhotoResponseDto2'] | null; - ID: components['schemas']['Id']; - ManifestSignature?: components['schemas']['PGPSignature'] | null; + Blocks: components["schemas"]["BlockResponseDto2"][]; + Photo?: components["schemas"]["PhotoResponseDto2"] | null; + ID: components["schemas"]["Id"]; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; /** @description Size of revision (in bytes) */ Size: number; - State: components['schemas']['RevisionState2']; - XAttr?: components['schemas']['PGPMessage'] | null; + State: components["schemas"]["RevisionState2"]; + XAttr?: components["schemas"]["PGPMessage"] | null; /** * @deprecated * @description Flag stating if revision has a thumbnail @@ -6266,13 +6399,13 @@ export interface components { */ Thumbnail: 0 | 1; /** @deprecated */ - ThumbnailHash?: components['schemas']['BinaryString'] | null; + ThumbnailHash?: components["schemas"]["BinaryString"] | null; /** * @deprecated * @description Size thumbnail in bytes; 0 if no thumbnail present */ ThumbnailSize: number; - Thumbnails: components['schemas']['ThumbnailResponseDto2'][]; + Thumbnails: components["schemas"]["ThumbnailResponseDto2"][]; ClientUID?: string | null; /** @default null */ CreateTime: number | null; @@ -6291,12 +6424,12 @@ export interface components { SignatureAddress: string | null; }; ShareConflictErrorDetailsDto: { - ConflictLinkID: components['schemas']['Id']; + ConflictLinkID: components["schemas"]["Id"]; /** @description A conflicting Share on the Link. */ ConflictShareID: string; }; AlbumLinkResponseDto: { - LinkID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
1Active
3Locked
@@ -6304,9 +6437,9 @@ export interface components { */ VolumeState: 1 | 3; ShareReferenceResponseDto: { - ShareID: components['schemas']['Id2']; - ID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; + ShareID: components["schemas"]["Id2"]; + ID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
@@ -6319,7 +6452,7 @@ export interface components { */ LinkState: 0 | 1 | 2; ListPhotosAlbumRelatedPhotoItemResponseDto: { - LinkID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; CaptureTime: number; Hash: string; ContentHash: string; @@ -6340,10 +6473,10 @@ export interface components { */ DeviceType: 1 | 2 | 3; DeviceDataDto3: { - DeviceID: components['schemas']['Id2']; - VolumeID: components['schemas']['Id2']; - SyncState: components['schemas']['DeviceSyncState2']; - Type: components['schemas']['DeviceType2']; + DeviceID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + SyncState: components["schemas"]["DeviceSyncState2"]; + Type: components["schemas"]["DeviceType2"]; /** @description UNIX timestamp when the Device got last synced */ LastSyncTime?: number | null; CreateTime: number; @@ -6355,16 +6488,16 @@ export interface components { CreationTime: number; }; ShareDataDto4: { - ShareID: components['schemas']['Id2']; - LinkID: components['schemas']['Id2']; + ShareID: components["schemas"]["Id2"]; + LinkID: components["schemas"]["Id2"]; /** @deprecated */ Name: string; }; DeviceDto: { - DeviceID: components['schemas']['Id2']; + DeviceID: components["schemas"]["Id2"]; CreateTime: number; ModifyTime: number; - Type: components['schemas']['DeviceType2']; + Type: components["schemas"]["DeviceType2"]; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Delete
1Create
2Update
3UpdateMetadata
@@ -6372,13 +6505,13 @@ export interface components { */ EventType: 0 | 1 | 2 | 3; EventLinkDataDto: { - LinkID: components['schemas']['Id2']; - ParentLinkID?: components['schemas']['Id2'] | null; + LinkID: components["schemas"]["Id2"]; + ParentLinkID?: components["schemas"]["Id2"] | null; IsShared: boolean; IsTrashed: boolean; }; RelatedPhotoDto: { - LinkID: components['schemas']['Id']; + LinkID: components["schemas"]["Id"]; /** @description Name, reusing same session key as previously. */ Name: string; /** @description Node passphrase, reusing same session key as previously. */ @@ -6389,18 +6522,18 @@ export interface components { ContentHash: string; }; LinkDto: { - LinkID: components['schemas']['Id']; - Type: components['schemas']['NodeType4']; - ParentLinkID?: components['schemas']['Id'] | null; - State: components['schemas']['LinkState3']; + LinkID: components["schemas"]["Id"]; + Type: components["schemas"]["NodeType4"]; + ParentLinkID?: components["schemas"]["Id"] | null; + State: components["schemas"]["LinkState3"]; CreateTime: number; ModifyTime: number; TrashTime?: number | null; - Name: components['schemas']['PGPMessage']; + Name: components["schemas"]["PGPMessage"]; NameHash?: string | null; - NodeKey: components['schemas']['PGPPrivateKey']; - NodePassphrase: components['schemas']['PGPMessage']; - NodePassphraseSignature: components['schemas']['PGPSignature']; + NodeKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; /** Format: email */ SignatureEmail?: string | null; /** Format: email */ @@ -6410,18 +6543,18 @@ export interface components { }; FileDto: { TotalEncryptedSize: number; - ContentKeyPacket: components['schemas']['BinaryString']; + ContentKeyPacket: components["schemas"]["BinaryString"]; MediaType?: string | null; - ActiveRevision?: components['schemas']['ActiveRevisionDto'] | null; - ContentKeyPacketSignature?: components['schemas']['PGPSignature'] | null; + ActiveRevision?: components["schemas"]["ActiveRevisionDto"] | null; + ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; }; SharingDto: { - ShareID: components['schemas']['Id']; - ShareURLID?: components['schemas']['Id'] | null; + ShareID: components["schemas"]["Id"]; + ShareURLID?: components["schemas"]["Id"] | null; }; MembershipDto: { - ShareID: components['schemas']['Id']; - MembershipID: components['schemas']['Id']; + ShareID: components["schemas"]["Id"]; + MembershipID: components["schemas"]["Id"]; /** * @description Permission bitfield, valid permissions: * - 4: read access @@ -6442,12 +6575,12 @@ export interface components { InviteeSharePassphraseSessionKeySignature: string; }; FolderDto: { - NodeHashKey?: components['schemas']['PGPMessage'] | null; - XAttr?: components['schemas']['PGPMessage'] | null; + NodeHashKey?: components["schemas"]["PGPMessage"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; }; AlbumDto: { - NodeHashKey?: components['schemas']['PGPMessage'] | null; - XAttr?: components['schemas']['PGPMessage'] | null; + NodeHashKey?: components["schemas"]["PGPMessage"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; }; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Obsolete
@@ -6455,13 +6588,13 @@ export interface components { */ RevisionState: 0 | 1 | 2; ThumbnailResponseDto: { - ThumbnailID: components['schemas']['Id2']; - Type: components['schemas']['ThumbnailType2']; - Hash: components['schemas']['BinaryString2']; + ThumbnailID: components["schemas"]["Id2"]; + Type: components["schemas"]["ThumbnailType2"]; + Hash: components["schemas"]["BinaryString2"]; Size: number; }; Verifier: { - Token: components['schemas']['BinaryString']; + Token: components["schemas"]["BinaryString"]; }; /** * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px, max. 65536 bytes in encrypted size
2HDPreview1920 px, max. 1048576 bytes in encrypted size
3MachineLearningmax. 65536 bytes in encrypted size
@@ -6474,7 +6607,7 @@ export interface components { */ ThumbnailType2: 1 | 2 | 3; PhotoListingRelatedItemResponse: { - LinkID: components['schemas']['Id2']; + LinkID: components["schemas"]["Id2"]; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; /** @description File name hash */ @@ -6483,28 +6616,82 @@ export interface components { ContentHash?: string | null; }; /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @description

Types: Folder - 1, File - 2}

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
* @enum {integer} */ NodeType2: 1 | 2 | 3; + ThumbnailURLInfoResponseDto: { + /** + * @deprecated + * @description Download URL for the thumbnail + */ + URL?: string | null; + /** @description Bare Download URL for the thumbnail */ + BareURL?: string | null; + /** @description Token for the thumbnail URL */ + Token?: string | null; + }; + BlockResponseDto: { + Index: number; + Hash: components["schemas"]["BinaryString2"]; + Token?: string | null; + /** @deprecated */ + URL?: string | null; + BareURL?: string | null; + /** + * @deprecated + * @default null + */ + EncSignature: components["schemas"]["PGPMessage2"] | null; + /** + * Format: email + * @deprecated + * @description Email used to sign block + * @default null + */ + SignatureEmail: string | null; + }; + PhotoResponseDto: { + LinkID: components["schemas"]["Id2"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + MainPhotoLinkID?: components["schemas"]["Id2"] | null; + /** @description File name hash */ + Hash?: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + /** @description LinkIDs of related Photos if there are any */ + RelatedPhotosLinkIDs: components["schemas"]["Id2"][]; + /** + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead + * @default null + */ + Exif: string | null; + }; + /** + * @description
See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType3: 1 | 2 | 3; /** * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
* @enum {integer} */ LinkState2: 0 | 1 | 2; LinkDto2: { - LinkID: components['schemas']['Id2']; - Type: components['schemas']['NodeType2']; - ParentLinkID?: components['schemas']['Id2'] | null; - State: components['schemas']['LinkState2']; + LinkID: components["schemas"]["Id2"]; + Type: components["schemas"]["NodeType3"]; + ParentLinkID?: components["schemas"]["Id2"] | null; + State: components["schemas"]["LinkState2"]; CreateTime: number; ModifyTime: number; TrashTime?: number | null; - Name: components['schemas']['PGPMessage2']; + Name: components["schemas"]["PGPMessage2"]; NameHash?: string | null; - NodeKey: components['schemas']['PGPPrivateKey2']; - NodePassphrase: components['schemas']['PGPMessage2']; - NodePassphraseSignature: components['schemas']['PGPSignature2']; + NodeKey: components["schemas"]["PGPPrivateKey2"]; + NodePassphrase: components["schemas"]["PGPMessage2"]; + NodePassphraseSignature: components["schemas"]["PGPSignature2"]; /** Format: email */ SignatureEmail?: string | null; /** Format: email */ @@ -6513,16 +6700,16 @@ export interface components { DirectPermissions: number | null; }; FolderDto2: { - NodeHashKey?: components['schemas']['PGPMessage2'] | null; - XAttr?: components['schemas']['PGPMessage2'] | null; + NodeHashKey?: components["schemas"]["PGPMessage2"] | null; + XAttr?: components["schemas"]["PGPMessage2"] | null; }; SharingDto2: { - ShareID: components['schemas']['Id2']; - ShareURLID?: components['schemas']['Id2'] | null; + ShareID: components["schemas"]["Id2"]; + ShareURLID?: components["schemas"]["Id2"] | null; }; MembershipDto2: { - ShareID: components['schemas']['Id2']; - MembershipID: components['schemas']['Id2']; + ShareID: components["schemas"]["Id2"]; + MembershipID: components["schemas"]["Id2"]; /** * @description Permission bitfield, valid permissions: * - 4: read access @@ -6551,60 +6738,6 @@ export interface components { * @enum {integer} */ ShareMemberState: 1 | 2 | 3; - /** - * @description

Types: Folder - 1, File - 2}

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
- * @enum {integer} - */ - NodeType3: 1 | 2 | 3; - ThumbnailURLInfoResponseDto: { - /** - * @deprecated - * @description Download URL for the thumbnail - */ - URL?: string | null; - /** @description Bare Download URL for the thumbnail */ - BareURL?: string | null; - /** @description Token for the thumbnail URL */ - Token?: string | null; - }; - BlockResponseDto: { - Index: number; - Hash: components['schemas']['BinaryString2']; - Token?: string | null; - /** @deprecated */ - URL?: string | null; - BareURL?: string | null; - /** - * @deprecated - * @default null - */ - EncSignature: components['schemas']['PGPMessage2'] | null; - /** - * Format: email - * @deprecated - * @description Email used to sign block - * @default null - */ - SignatureEmail: string | null; - }; - PhotoResponseDto: { - LinkID: components['schemas']['Id2']; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ - CaptureTime: number; - MainPhotoLinkID?: components['schemas']['Id2'] | null; - /** @description File name hash */ - Hash?: string | null; - /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash?: string | null; - /** @description LinkIDs of related Photos if there are any */ - RelatedPhotosLinkIDs: components['schemas']['Id2'][]; - /** - * @deprecated - * @description Deprecated: Clients persist exif information in xAttr instead - * @default null - */ - Exif: string | null; - }; /** * @description

The target type of the Share that is corresponding to this invitation.
* This should not be used as source of information to know what NodeType or MIMEType the targeted Share is.

See values descriptions
See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
@@ -6638,7 +6771,7 @@ export interface components { RevisionRetentionDays3: 0 | 7 | 30 | 180 | 365 | 3650; BlockResponseDto2: { Index: number; - Hash: components['schemas']['BinaryString']; + Hash: components["schemas"]["BinaryString"]; Token?: string | null; /** @deprecated */ URL?: string | null; @@ -6647,7 +6780,7 @@ export interface components { * @deprecated * @default null */ - EncSignature: components['schemas']['PGPMessage'] | null; + EncSignature: components["schemas"]["PGPMessage"] | null; /** * Format: email * @deprecated @@ -6657,16 +6790,16 @@ export interface components { SignatureEmail: string | null; }; PhotoResponseDto2: { - LinkID: components['schemas']['Id']; + LinkID: components["schemas"]["Id"]; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; - MainPhotoLinkID?: components['schemas']['Id'] | null; + MainPhotoLinkID?: components["schemas"]["Id"] | null; /** @description File name hash */ Hash?: string | null; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash?: string | null; /** @description LinkIDs of related Photos if there are any */ - RelatedPhotosLinkIDs: components['schemas']['Id'][]; + RelatedPhotosLinkIDs: components["schemas"]["Id"][]; /** * @deprecated * @description Deprecated: Clients persist exif information in xAttr instead @@ -6680,9 +6813,9 @@ export interface components { */ RevisionState2: 0 | 1 | 2; ThumbnailResponseDto2: { - ThumbnailID: components['schemas']['Id']; - Type: components['schemas']['ThumbnailType']; - Hash: components['schemas']['BinaryString']; + ThumbnailID: components["schemas"]["Id"]; + Type: components["schemas"]["ThumbnailType"]; + Hash: components["schemas"]["BinaryString"]; Size: number; }; /** @@ -6706,27 +6839,27 @@ export interface components { */ LinkState3: 0 | 1 | 2; ActiveRevisionDto: { - RevisionID: components['schemas']['Id']; + RevisionID: components["schemas"]["Id"]; CreateTime: number; EncryptedSize: number; - ManifestSignature?: components['schemas']['PGPSignature'] | null; - XAttr?: components['schemas']['PGPMessage'] | null; - Thumbnails: components['schemas']['ThumbnailDto'][]; - Photo?: components['schemas']['PhotoDto'] | null; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + Thumbnails: components["schemas"]["ThumbnailDto"][]; + Photo?: components["schemas"]["PhotoDto"] | null; /** Format: email */ SignatureEmail?: string | null; }; ThumbnailDto: { - ThumbnailID: components['schemas']['Id']; - Type: components['schemas']['ThumbnailType']; - Hash: components['schemas']['BinaryString']; + ThumbnailID: components["schemas"]["Id"]; + Type: components["schemas"]["ThumbnailType"]; + Hash: components["schemas"]["BinaryString"]; EncryptedSize: number; }; PhotoDto: { CaptureTime: number; - MainPhotoLinkID?: components['schemas']['Id'] | null; + MainPhotoLinkID?: components["schemas"]["Id"] | null; ContentHash?: string | null; - RelatedPhotosLinkIDs: components['schemas']['Id'][]; + RelatedPhotosLinkIDs: components["schemas"]["Id"][]; }; }; responses: { @@ -6734,11 +6867,11 @@ export interface components { ProtonSuccessResponse: { headers: { /** @description The same as the body code */ - 'X-Pm-Code'?: 1000; + "X-Pm-Code"?: 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonSuccess']; + "application/json": components["schemas"]["ProtonSuccess"]; }; }; /** @description General Error */ @@ -6747,7 +6880,7 @@ export interface components { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError']; + "application/json": components["schemas"]["ProtonError"]; }; }; }; @@ -6758,7 +6891,7 @@ export interface components { } export type $defs = Record; export interface operations { - 'post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple': { + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple": { parameters: { query?: never; header?: never; @@ -6770,7 +6903,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AddPhotosToAlbumRequestDto']; + "application/json": components["schemas"]["AddPhotosToAlbumRequestDto"]; }; }; responses: { @@ -6780,10 +6913,10 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['AddPhotoToAlbumWithLinkIDResponseDto'][]; + Responses?: components["schemas"]["AddPhotoToAlbumWithLinkIDResponseDto"][]; }; }; }; @@ -6793,7 +6926,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The album does not exist. * - 200300: Album has reached the limit of photos. @@ -6805,7 +6938,7 @@ export interface operations { }; }; }; - 'get_drive-photos-volumes-{volumeID}-albums': { + "get_drive-photos-volumes-{volumeID}-albums": { parameters: { query?: { AnchorID?: string | null; @@ -6821,11 +6954,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListAlbumsResponseDto']; + "application/json": components["schemas"]["ListAlbumsResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -6834,7 +6967,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: a photo share does not exist for this volume * - 2011: Insufficient permissions @@ -6845,7 +6978,7 @@ export interface operations { }; }; }; - 'post_drive-photos-volumes-{volumeID}-albums': { + "post_drive-photos-volumes-{volumeID}-albums": { parameters: { query?: never; header?: never; @@ -6856,18 +6989,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateAlbumRequestDto']; + "application/json": components["schemas"]["CreateAlbumRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateAlbumResponseDto']; + "application/json": components["schemas"]["CreateAlbumResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -6876,7 +7009,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 200300: Limit of albums per volume reached * - 2501: a photo share does not exist for this volume @@ -6891,7 +7024,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Potential codes: * - 2032 @@ -6904,7 +7037,7 @@ export interface operations { }; }; }; - 'post_drive-photos-volumes': { + "post_drive-photos-volumes": { parameters: { query?: never; header?: never; @@ -6913,18 +7046,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreatePhotoShareRequestDto']; + "application/json": components["schemas"]["CreatePhotoShareRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetPhotoVolumeResponseDto']; + "application/json": components["schemas"]["GetPhotoVolumeResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -6933,12 +7066,11 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2500: A volume is already active * - 2001: Invalid PGP message * - 200501: Operation failed: Please retry - * - 200200: Address not found * */ Code: number; }; @@ -6950,7 +7082,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Potential codes: * - 2032 @@ -6963,30 +7095,30 @@ export interface operations { }; }; }; - 'put_drive-photos-volumes-{volumeID}-albums-{linkID}': { + "put_drive-photos-volumes-{volumeID}-albums-{linkID}": { parameters: { query?: never; header?: never; path: { volumeID: string; - linkID: components['schemas']['Id']; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateAlbumRequestDto']; + "application/json": components["schemas"]["UpdateAlbumRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -6995,7 +7127,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: a photo share does not exist for this volume * - 2011: Insufficient permissions @@ -7010,7 +7142,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Potential codes: * - 2501: File or folder not found @@ -7026,7 +7158,7 @@ export interface operations { }; }; }; - 'delete_drive-photos-volumes-{volumeID}-albums-{linkID}': { + "delete_drive-photos-volumes-{volumeID}-albums-{linkID}": { parameters: { query?: { /** @description Whether or not to delete the album even with direct children. */ @@ -7044,11 +7176,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -7057,7 +7189,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 200302: Album is not empty. Delete operation would result in data loss. * - 2011: Insufficient permissions @@ -7068,7 +7200,7 @@ export interface operations { }; }; }; - 'post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates': { + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates": { parameters: { query?: never; header?: never; @@ -7080,23 +7212,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['FindDuplicatesInput']; + "application/json": components["schemas"]["FindDuplicatesInput"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['FindDuplicatesOutputCollection']; + "application/json": components["schemas"]["FindDuplicatesOutputCollection"]; }; }; }; }; - 'get_drive-photos-volumes-{volumeID}-tags-migration': { + "get_drive-photos-volumes-{volumeID}-tags-migration": { parameters: { query?: never; header?: never; @@ -7110,11 +7242,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PhotoTagMigrationStatusResponseDto']; + "application/json": components["schemas"]["PhotoTagMigrationStatusResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -7123,7 +7255,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: volume does not exist, or is not photo volume * - 2011: Insufficient permissions, not volume owner @@ -7134,7 +7266,7 @@ export interface operations { }; }; }; - 'post_drive-photos-volumes-{volumeID}-tags-migration': { + "post_drive-photos-volumes-{volumeID}-tags-migration": { parameters: { query?: never; header?: never; @@ -7145,18 +7277,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdatePhotoTagMigrationStatusRequestDto']; + "application/json": components["schemas"]["UpdatePhotoTagMigrationStatusRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -7165,7 +7297,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: volume does not exist, or is not photo volume * - 2011: Insufficient permissions, not volume owner @@ -7176,15 +7308,15 @@ export interface operations { }; }; }; - 'get_drive-photos-volumes-{volumeID}-albums-{linkID}-children': { + "get_drive-photos-volumes-{volumeID}-albums-{linkID}-children": { parameters: { query?: { - AnchorID?: components['schemas']['ListPhotosAlbumQueryParameters']['AnchorID']; - Sort?: components['schemas']['ListPhotosAlbumQueryParameters']['Sort']; - Desc?: components['schemas']['ListPhotosAlbumQueryParameters']['Desc']; - Tag?: components['schemas']['ListPhotosAlbumQueryParameters']['Tag']; - OnlyChildren?: components['schemas']['ListPhotosAlbumQueryParameters']['OnlyChildren']; - IncludeTrashed?: components['schemas']['ListPhotosAlbumQueryParameters']['IncludeTrashed']; + AnchorID?: components["schemas"]["ListPhotosAlbumQueryParameters"]["AnchorID"]; + Sort?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Sort"]; + Desc?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Desc"]; + Tag?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Tag"]; + OnlyChildren?: components["schemas"]["ListPhotosAlbumQueryParameters"]["OnlyChildren"]; + IncludeTrashed?: components["schemas"]["ListPhotosAlbumQueryParameters"]["IncludeTrashed"]; }; header?: never; path: { @@ -7198,11 +7330,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListPhotosAlbumResponseDto']; + "application/json": components["schemas"]["ListPhotosAlbumResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -7211,7 +7343,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: Volume not found * - 2501: File or folder not found @@ -7223,7 +7355,7 @@ export interface operations { }; }; }; - 'put_drive-photos-volumes-{volumeID}-recover-multiple': { + "put_drive-photos-volumes-{volumeID}-recover-multiple": { parameters: { query?: never; header?: never; @@ -7234,7 +7366,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['TransferPhotoLinksRequestDto']; + "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; }; }; responses: { @@ -7244,13 +7376,13 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; - Response?: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; + Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; }[]; }; }; @@ -7261,7 +7393,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. * - 2511: cannot recover photos from a share @@ -7274,7 +7406,7 @@ export interface operations { }; }; }; - 'post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple': { + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-remove-multiple": { parameters: { query?: never; header?: never; @@ -7286,7 +7418,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RemovePhotosFromAlbumRequestDto']; + "application/json": components["schemas"]["RemovePhotosFromAlbumRequestDto"]; }; }; responses: { @@ -7296,10 +7428,10 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['RemovePhotoFromAlbumWithLinkIDResponseDto'][]; + Responses?: components["schemas"]["RemovePhotoFromAlbumWithLinkIDResponseDto"][]; }; }; }; @@ -7309,7 +7441,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2500: A volume is already active * */ @@ -7319,10 +7451,10 @@ export interface operations { }; }; }; - 'get_drive-photos-albums-shared-with-me': { + "get_drive-photos-albums-shared-with-me": { parameters: { query?: { - AnchorID?: components['schemas']['Id'] | null; + AnchorID?: components["schemas"]["Id"] | null; }; header?: never; path?: never; @@ -7333,11 +7465,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SharedWithMeResponseDto']; + "application/json": components["schemas"]["SharedWithMeResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -7346,7 +7478,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2011: Insufficient permissions * */ @@ -7356,7 +7488,7 @@ export interface operations { }; }; }; - 'put_drive-volumes-{volumeID}-links-transfer-multiple': { + "put_drive-volumes-{volumeID}-links-transfer-multiple": { parameters: { query?: never; header?: never; @@ -7367,7 +7499,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['TransferPhotoLinksRequestDto']; + "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; }; }; responses: { @@ -7377,13 +7509,13 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; - Response?: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; + Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; }[]; }; }; @@ -7394,7 +7526,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. * - 2511: cannot move favorite photos from a share @@ -7406,7 +7538,7 @@ export interface operations { }; }; }; - 'put_drive-photos-volumes-{volumeID}-links-transfer-multiple': { + "put_drive-photos-volumes-{volumeID}-links-transfer-multiple": { parameters: { query?: never; header?: never; @@ -7417,7 +7549,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['TransferPhotoLinksRequestDto']; + "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; }; }; responses: { @@ -7427,13 +7559,13 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; - Response?: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; + Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; }[]; }; }; @@ -7444,7 +7576,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. * - 2511: cannot move favorite photos from a share @@ -7456,7 +7588,7 @@ export interface operations { }; }; }; - 'post_drive-v2-urls-{token}-bookmark': { + "post_drive-v2-urls-{token}-bookmark": { parameters: { query?: never; header?: never; @@ -7468,18 +7600,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateBookmarkShareURLRequestDto']; + "application/json": components["schemas"]["CreateBookmarkShareURLRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateBookmarkShareURLResponseDto']; + "application/json": components["schemas"]["CreateBookmarkShareURLResponseDto"]; }; }; /** @description Bad request */ @@ -7488,7 +7620,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2001: the token format is invalid * */ @@ -7502,7 +7634,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 200001: You have reached the maximum number of items you can save. * - 2501: Item link not found @@ -7515,7 +7647,7 @@ export interface operations { }; }; }; - 'delete_drive-v2-urls-{token}-bookmark': { + "delete_drive-v2-urls-{token}-bookmark": { parameters: { query?: never; header?: never; @@ -7530,11 +7662,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Bad request */ @@ -7543,7 +7675,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2001: the token format is invalid * */ @@ -7557,7 +7689,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: Item link not found * - 2501: Item not found @@ -7568,7 +7700,7 @@ export interface operations { }; }; }; - 'get_drive-v2-shared-bookmarks': { + "get_drive-v2-shared-bookmarks": { parameters: { query?: never; header?: never; @@ -7580,11 +7712,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListBookmarksOfUserResponseDto']; + "application/json": components["schemas"]["ListBookmarksOfUserResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -7593,7 +7725,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: Item link not found * - 2501: item not found @@ -7605,7 +7737,7 @@ export interface operations { }; }; }; - 'get_drive-devices': { + "get_drive-devices": { parameters: { query?: never; header?: never; @@ -7617,16 +7749,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListDevicesResponseDto']; + "application/json": components["schemas"]["ListDevicesResponseDto"]; }; }; }; }; - 'post_drive-devices': { + "post_drive-devices": { parameters: { query?: never; header?: never; @@ -7635,55 +7767,55 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateDeviceRequestDto']; + "application/json": components["schemas"]["CreateDeviceRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateDeviceResponseDto']; + "application/json": components["schemas"]["CreateDeviceResponseDto"]; }; }; }; }; - 'put_drive-devices-{deviceID}': { + "put_drive-devices-{deviceID}": { parameters: { query?: never; header?: never; path: { - deviceID: components['schemas']['Id']; + deviceID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateDeviceRequestDto']; + "application/json": components["schemas"]["UpdateDeviceRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'delete_drive-devices-{deviceID}': { + "delete_drive-devices-{deviceID}": { parameters: { query?: never; header?: never; path: { - deviceID: components['schemas']['Id']; + deviceID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -7692,16 +7824,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'get_drive-v2-devices': { + "get_drive-v2-devices": { parameters: { query?: never; header?: never; @@ -7713,16 +7845,80 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListDevicesResponseDto2"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-documents": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateDocumentDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDocumentResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: the user does not have permissions to create a file in this share + * + * @enum {integer} + */ + Code: 200300 | 2500 | 2501 | 2011; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListDevicesResponseDto2']; + "application/json": { + /** + * @description Potential codes and their meaning: + * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags + * + * @enum {integer} + */ + Code: 2032; + }; }; }; }; }; - 'post_drive-shares-{shareID}-documents': { + "post_drive-shares-{shareID}-documents": { parameters: { query?: never; header?: never; @@ -7733,18 +7929,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateDocumentDto']; + "application/json": components["schemas"]["CreateDocumentDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateDocumentResponseDto']; + "application/json": components["schemas"]["CreateDocumentResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -7753,20 +7949,18 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** - * @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 2011: the user does not have permissions to create a file in this share - * - * @enum {integer} - */ - Code: 200300 | 2500 | 2501 | 2011; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: the user does not have permissions to create a file in this share + * + * @enum {integer} + */ + Code: 200300 | 2500 | 2501 | 2011; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; /** @description Failed dependency */ @@ -7775,7 +7969,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Potential codes and their meaning: * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags @@ -7788,7 +7982,7 @@ export interface operations { }; }; }; - 'get_drive-shares-{shareID}-events-latest': { + "get_drive-shares-{shareID}-events-latest": { parameters: { query?: never; header?: never; @@ -7802,16 +7996,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LatestEventIDResponseDto']; + "application/json": components["schemas"]["LatestEventIDResponseDto"]; }; }; }; }; - 'get_drive-volumes-{volumeID}-events-latest': { + "get_drive-volumes-{volumeID}-events-latest": { parameters: { query?: never; header?: never; @@ -7825,16 +8019,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LatestEventIDResponseDto']; + "application/json": components["schemas"]["LatestEventIDResponseDto"]; }; }; }; }; - 'get_drive-shares-{shareID}-events-{eventID}': { + "get_drive-shares-{shareID}-events-{eventID}": { parameters: { query?: never; header?: never; @@ -7849,16 +8043,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListEventsResponseDto']; + "application/json": components["schemas"]["ListEventsResponseDto"]; }; }; }; }; - 'get_drive-volumes-{volumeID}-events-{eventID}': { + "get_drive-volumes-{volumeID}-events-{eventID}": { parameters: { query?: never; header?: never; @@ -7873,16 +8067,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListEventsResponseDto']; + "application/json": components["schemas"]["ListEventsResponseDto"]; }; }; }; }; - 'get_drive-v2-volumes-{volumeID}-events-{eventID}': { + "get_drive-v2-volumes-{volumeID}-events-{eventID}": { parameters: { query?: never; header?: never; @@ -7897,16 +8091,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListEventsV2ResponseDto']; + "application/json": components["schemas"]["ListEventsV2ResponseDto"]; }; }; }; }; - 'post_drive-shares-{shareID}-folders': { + "post_drive-shares-{shareID}-folders": { parameters: { query?: never; header?: never; @@ -7917,18 +8111,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateFolderRequestDto']; + "application/json": components["schemas"]["CreateFolderRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateFolderResponseDto']; + "application/json": components["schemas"]["CreateFolderResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -7937,23 +8131,21 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 2511: the link targeted is a photo link - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * */ - Code?: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'post_drive-shares-{shareID}-folders-{linkID}-delete_multiple': { + "post_drive-shares-{shareID}-folders-{linkID}-delete_multiple": { parameters: { query?: never; header?: never; @@ -7965,7 +8157,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { @@ -7975,20 +8167,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['MultiDeleteTransformer'][]; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; }; }; }; }; }; - 'get_drive-shares-{shareID}-folders-{linkID}-children': { + "get_drive-shares-{shareID}-folders-{linkID}-children": { parameters: { query?: { /** @description Field to sort by */ - Sort?: 'MIMEType' | 'Size' | 'ModifyTime' | 'CreateTime' | 'Type'; + Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; /** @description Sort order */ Desc?: 0 | 1; /** @description Show all files including those in non-active (drafts) state. */ @@ -8000,8 +8192,8 @@ export interface operations { * @description Get thumbnail download URLs */ Thumbnails?: 0 | 1; - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; }; header?: never; path: { @@ -8018,17 +8210,17 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; /** @description Allow sorting of items in folder */ AllowSorting: boolean; - Links: components['schemas']['ExtendedLinkTransformer'][]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; }; }; }; }; }; - 'post_drive-shares-{shareID}-folders-{linkID}-trash_multiple': { + "post_drive-shares-{shareID}-folders-{linkID}-trash_multiple": { parameters: { query?: never; header?: never; @@ -8040,7 +8232,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { @@ -8050,16 +8242,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['MultiDeleteTransformer'][]; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; }; }; }; }; }; - 'put_drive-shares-{shareID}-folders-{linkID}': { + "put_drive-shares-{shareID}-folders-{linkID}": { parameters: { query?: never; header?: never; @@ -8071,7 +8263,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateFolderRequestDto']; + "application/json": components["schemas"]["UpdateFolderRequestDto"]; }; }; responses: { @@ -8081,15 +8273,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - Link: components['schemas']['ExtendedLinkTransformer']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Link: components["schemas"]["ExtendedLinkTransformer"]; }; }; }; }; }; - 'post_drive-v2-volumes-{volumeID}-folders': { + "post_drive-v2-volumes-{volumeID}-folders": { parameters: { query?: never; header?: never; @@ -8100,18 +8292,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateFolderRequestDto2']; + "application/json": components["schemas"]["CreateFolderRequestDto2"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateFolderResponseDto']; + "application/json": components["schemas"]["CreateFolderResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -8120,27 +8312,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 2511: the link targeted is a photo link - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * */ - Code?: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'get_drive-v2-volumes-{volumeID}-folders-{linkID}-children': { + "get_drive-v2-volumes-{volumeID}-folders-{linkID}-children": { parameters: { query?: { /** @description Link ID use to indicate where to start the next page */ - AnchorID?: (string & components['schemas']['Id']) | null; + AnchorID?: (string & components["schemas"]["Id"]) | null; /** @description Show folders only */ FoldersOnly?: 0 | 1; }; @@ -8156,11 +8346,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListChildrenResponseDto']; + "application/json": components["schemas"]["ListChildrenResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -8169,7 +8359,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2032: sharing is temporarily disabled and the user is not the volume owner. * - 2011: The user does not have permission to access this folder. @@ -8180,7 +8370,7 @@ export interface operations { }; }; }; - 'post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes': { + "post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes": { parameters: { query?: never; header?: never; @@ -8192,23 +8382,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CheckAvailableHashesRequestDto']; + "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AvailableHashesResponseDto']; + "application/json": components["schemas"]["AvailableHashesResponseDto"]; }; }; }; }; - 'post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes': { + "post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes": { parameters: { query?: never; header?: never; @@ -8220,23 +8410,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CheckAvailableHashesRequestDto']; + "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AvailableHashesResponseDto']; + "application/json": components["schemas"]["AvailableHashesResponseDto"]; }; }; }; }; - 'post_drive-volumes-{volumeID}-links-{linkID}-copy': { + "post_drive-volumes-{volumeID}-links-{linkID}-copy": { parameters: { query?: never; header?: never; @@ -8248,18 +8438,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CopyLinkRequestDto']; + "application/json": components["schemas"]["CopyLinkRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CopyLinkResponseDto']; + "application/json": components["schemas"]["CopyLinkResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -8268,7 +8458,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Potential codes and their meaning: * - 2011: Copying Proton Docs to another account is not possible yet. @@ -8289,7 +8479,7 @@ export interface operations { }; }; }; - 'post_drive-v2-volumes-{volumeID}-delete_multiple': { + "post_drive-v2-volumes-{volumeID}-delete_multiple": { parameters: { query?: never; header?: never; @@ -8300,7 +8490,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { @@ -8310,16 +8500,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['MultiDeleteTransformer'][]; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; }; }; }; }; }; - 'post_drive-shares-{shareID}-links-fetch_metadata': { + "post_drive-shares-{shareID}-links-fetch_metadata": { parameters: { query?: never; header?: never; @@ -8330,7 +8520,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['FetchLinksMetadataRequestDto']; + "application/json": components["schemas"]["FetchLinksMetadataRequestDto"]; }; }; responses: { @@ -8340,16 +8530,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - Links: components['schemas']['ExtendedLinkTransformer'][]; - Parents: components['schemas']['ExtendedLinkTransformer'][]; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + Parents: components["schemas"]["ExtendedLinkTransformer"][]; }; }; }; }; }; - 'post_drive-volumes-{volumeID}-links-fetch_metadata': { + "post_drive-volumes-{volumeID}-links-fetch_metadata": { parameters: { query?: never; header?: never; @@ -8360,23 +8550,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['FetchLinksMetadataRequestDto']; + "application/json": components["schemas"]["FetchLinksMetadataRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['FetchLinksMetadataResponseDto']; + "application/json": components["schemas"]["FetchLinksMetadataResponseDto"]; }; }; }; }; - 'get_drive-shares-{shareID}-links-{linkID}': { + "get_drive-shares-{shareID}-links-{linkID}": { parameters: { query?: never; header?: never; @@ -8394,15 +8584,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - Link: components['schemas']['ExtendedLinkTransformer']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Link: components["schemas"]["ExtendedLinkTransformer"]; }; }; }; }; }; - 'get_drive-sanitization-mhk': { + "get_drive-sanitization-mhk": { parameters: { query?: never; header?: never; @@ -8414,16 +8604,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListMissingHashKeyResponseDto']; + "application/json": components["schemas"]["ListMissingHashKeyResponseDto"]; }; }; }; }; - 'post_drive-sanitization-mhk': { + "post_drive-sanitization-mhk": { parameters: { query?: never; header?: never; @@ -8432,23 +8622,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMissingHashKeyRequestDto']; + "application/json": components["schemas"]["UpdateMissingHashKeyRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_drive-v2-volumes-{volumeID}-links': { + "post_drive-v2-volumes-{volumeID}-links": { parameters: { query?: never; header?: never; @@ -8459,23 +8649,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LoadLinkDetailsResponseDto']; + "application/json": components["schemas"]["LoadLinkDetailsResponseDto"]; }; }; }; }; - 'put_drive-volumes-{volumeID}-links-move-multiple': { + "put_drive-volumes-{volumeID}-links-move-multiple": { parameters: { query?: never; header?: never; @@ -8486,7 +8676,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['MoveLinkBatchRequestDto']; + "application/json": components["schemas"]["MoveLinkBatchRequestDto"]; }; }; responses: { @@ -8496,13 +8686,13 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; - Response?: components['schemas']['ProtonSuccess'] | components['schemas']['ProtonError']; + Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; }[]; }; }; @@ -8513,7 +8703,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The volume does not exist. * - 2511: cannot move favorite photos from a share @@ -8525,7 +8715,7 @@ export interface operations { }; }; }; - 'put_drive-shares-{shareID}-links-{linkID}-move': { + "put_drive-shares-{shareID}-links-{linkID}-move": { parameters: { query?: never; header?: never; @@ -8537,36 +8727,34 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['MoveLinkRequestDto']; + "application/json": components["schemas"]["MoveLinkRequestDto"]; }; }; responses: { - 200: components['responses']['ProtonSuccessResponse']; + 200: components["responses"]["ProtonSuccessResponse"]; /** @description Unprocessable Entity */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2511: cannot move favorite photos from a share - * - 2501: parent folder was not found - * */ - Code?: number; - /** @description Error message */ - Error?: string; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2511: cannot move favorite photos from a share + * - 2501: parent folder was not found + * */ + Code?: number; + /** @description Error message */ + Error?: string; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'put_drive-v2-volumes-{volumeID}-links-{linkID}-rename': { + "put_drive-v2-volumes-{volumeID}-links-{linkID}-rename": { parameters: { query?: never; header?: never; @@ -8578,23 +8766,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RenameLinkRequestDto']; + "application/json": components["schemas"]["RenameLinkRequestDto"]; }; }; responses: { - 200: components['responses']['ProtonSuccessResponse']; + 200: components["responses"]["ProtonSuccessResponse"]; /** @description Conflict, a file or folder with the new name already exists in the current folder. */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ConflictErrorResponseDto']; + "application/json": components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'put_drive-shares-{shareID}-links-{linkID}-rename': { + "put_drive-shares-{shareID}-links-{linkID}-rename": { parameters: { query?: never; header?: never; @@ -8606,23 +8794,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RenameLinkRequestDto']; + "application/json": components["schemas"]["RenameLinkRequestDto"]; }; }; responses: { - 200: components['responses']['ProtonSuccessResponse']; + 200: components["responses"]["ProtonSuccessResponse"]; /** @description Conflict, a file or folder with the new name already exists in the current folder. */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ConflictErrorResponseDto']; + "application/json": components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'put_drive-v2-volumes-{volumeID}-links-{linkID}-move': { + "put_drive-v2-volumes-{volumeID}-links-{linkID}-move": { parameters: { query?: never; header?: never; @@ -8634,50 +8822,48 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['MoveLinkRequestDto2']; + "application/json": components["schemas"]["MoveLinkRequestDto2"]; }; }; responses: { - 200: components['responses']['ProtonSuccessResponse']; + 200: components["responses"]["ProtonSuccessResponse"]; /** @description Unprocessable Entity */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2511: cannot move favorite photos from a share - * - 2501: parent folder was not found - * */ - Code?: number; - /** @description Error message */ - Error?: string; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2511: cannot move favorite photos from a share + * - 2501: parent folder was not found + * */ + Code?: number; + /** @description Error message */ + Error?: string; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}': { + "get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: { /** @description Number of blocks */ - PageSize?: components['schemas']['GetRevisionQueryParameters']['PageSize']; + PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; /** @description Block index from which to fetch block list */ - FromBlockIndex?: components['schemas']['GetRevisionQueryParameters']['FromBlockIndex']; + FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: components['schemas']['GetRevisionQueryParameters']['NoBlockUrls']; + NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; }; header?: never; path: { volumeID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8689,36 +8875,36 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetRevisionResponseDto2']; + "application/json": components["schemas"]["GetRevisionResponseDto2"]; }; }; }; }; - 'put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}': { + "put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: never; header?: never; path: { volumeID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['CommitRevisionDto']; + "application/json": components["schemas"]["CommitRevisionDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -8727,27 +8913,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200303: Cannot commit related photo with main already in album - * */ - Code: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200303: Cannot commit related photo with main already in album + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}': { + "delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: never; header?: never; path: { volumeID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8756,11 +8940,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -8769,36 +8953,34 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | components['schemas']['ShareConflictErrorResponseDto'] - | { - /** @description Potential codes and their meaning: - * - 2501: the link (must be active or trashed) or revision does not exist in the volume - * - 2011: the current user does not have permission to delete the revision - * - 2511: if the revision is active - create or revert to another revision first - * - 200700: if the link is a proton doc (revisions are not used for docs) - * */ - Code?: number; - }; + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2011: the current user does not have permission to delete the revision + * - 2511: if the revision is active - create or revert to another revision first + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; }; }; }; }; - 'get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}': { + "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: { /** @description Number of blocks */ - PageSize?: components['schemas']['GetRevisionQueryParameters']['PageSize']; + PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; /** @description Block index from which to fetch block list */ - FromBlockIndex?: components['schemas']['GetRevisionQueryParameters']['FromBlockIndex']; + FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: components['schemas']['GetRevisionQueryParameters']['NoBlockUrls']; + NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; }; header?: never; path: { shareID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8810,36 +8992,36 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetRevisionResponseDto2']; + "application/json": components["schemas"]["GetRevisionResponseDto2"]; }; }; }; }; - 'put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}': { + "put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: never; header?: never; path: { shareID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['CommitRevisionDto']; + "application/json": components["schemas"]["CommitRevisionDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -8848,27 +9030,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200303: Cannot commit related photo with main already in album - * */ - Code: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200303: Cannot commit related photo with main already in album + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}': { + "delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: never; header?: never; path: { shareID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8877,11 +9057,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -8890,22 +9070,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | components['schemas']['ShareConflictErrorResponseDto'] - | { - /** @description Potential codes and their meaning: - * - 2501: the link (must be active or trashed) or revision does not exist in the volume - * - 2011: the current user does not have permission to delete the revision - * - 2511: if the revision is active - create or revert to another revision first - * - 200700: if the link is a proton doc (revisions are not used for docs) - * */ - Code?: number; - }; + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2011: the current user does not have permission to delete the revision + * - 2511: if the revision is active - create or revert to another revision first + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; }; }; }; }; - 'post_drive-v2-volumes-{volumeID}-files': { + "post_drive-v2-volumes-{volumeID}-files": { parameters: { query?: never; header?: never; @@ -8916,26 +9094,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateFileDto']; + "application/json": components["schemas"]["CreateFileDto"]; }; }; responses: { - /** @description Ok */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - File: { - /** @description Encrypted link ID */ - ID: string; - /** @description Encrypted revision ID. */ - RevisionID: string; - ClientUID: string | null; - }; - }; + "application/json": components["schemas"]["CreateDraftFileResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -8944,25 +9114,22 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200701: A document type cannot create a revision - * - 200901: Photos backup is disabled for your account. Please enable it in the settings. - * */ - Code: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'post_drive-shares-{shareID}-files': { + "post_drive-shares-{shareID}-files": { parameters: { query?: never; header?: never; @@ -8973,26 +9140,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateFileDto']; + "application/json": components["schemas"]["CreateFileDto"]; }; }; responses: { - /** @description Ok */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - File: { - /** @description Encrypted link ID */ - ID: string; - /** @description Encrypted revision ID. */ - RevisionID: string; - ClientUID: string | null; - }; - }; + "application/json": components["schemas"]["CreateDraftFileResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -9001,25 +9160,22 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200701: A document type cannot create a revision - * - 200901: Photos backup is disabled for your account. Please enable it in the settings. - * */ - Code: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions': { + "get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions": { parameters: { query?: never; header?: never; @@ -9034,16 +9190,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListRevisionsResponseDto']; + "application/json": components["schemas"]["ListRevisionsResponseDto"]; }; }; }; }; - 'post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions': { + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions": { parameters: { query?: never; header?: never; @@ -9055,7 +9211,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateRevisionRequestDto']; + "application/json": components["schemas"]["CreateRevisionRequestDto"]; }; }; responses: { @@ -9065,8 +9221,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; Revision: { /** @description Revision ID */ ID: string; @@ -9080,9 +9236,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | components['schemas']['ConflictErrorResponseDto'] - | components['schemas']['ProtonError']; + "application/json": components["schemas"]["ConflictErrorResponseDto"] | components["schemas"]["ProtonError"]; }; }; /** @description Unprocessable Entity */ @@ -9091,20 +9245,18 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200700: A document type cannot create a revision - * */ - Code: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200700: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'get_drive-shares-{shareID}-files-{linkID}-revisions': { + "get_drive-shares-{shareID}-files-{linkID}-revisions": { parameters: { query?: never; header?: never; @@ -9119,16 +9271,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListRevisionsResponseDto']; + "application/json": components["schemas"]["ListRevisionsResponseDto"]; }; }; }; }; - 'post_drive-shares-{shareID}-files-{linkID}-revisions': { + "post_drive-shares-{shareID}-files-{linkID}-revisions": { parameters: { query?: never; header?: never; @@ -9140,7 +9292,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateRevisionRequestDto']; + "application/json": components["schemas"]["CreateRevisionRequestDto"]; }; }; responses: { @@ -9150,8 +9302,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; Revision: { /** @description Revision ID */ ID: string; @@ -9165,9 +9317,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | components['schemas']['ConflictErrorResponseDto'] - | components['schemas']['ProtonError']; + "application/json": components["schemas"]["ConflictErrorResponseDto"] | components["schemas"]["ProtonError"]; }; }; /** @description Unprocessable Entity */ @@ -9176,20 +9326,18 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200700: A document type cannot create a revision - * */ - Code: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200700: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail': { + "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail": { parameters: { query?: { /** @description Type of Thumbnail to fetch */ @@ -9199,7 +9347,7 @@ export interface operations { path: { shareID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9211,8 +9359,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; /** @description Thumbnail download link */ ThumbnailLink: string; /** @@ -9227,14 +9375,14 @@ export interface operations { }; }; }; - 'post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore': { + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore": { parameters: { query?: never; header?: never; path: { volumeID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9243,24 +9391,24 @@ export interface operations { /** @description Revision restore queued for async processing */ 202: { headers: { - 'x-pm-code': 1002; + "x-pm-code": 1002; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['RestoreRevisionAcceptedResponse']; + "application/json": components["schemas"]["RestoreRevisionAcceptedResponse"]; }; }; - 422: components['responses']['ProtonErrorResponse']; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore': { + "post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore": { parameters: { query?: never; header?: never; path: { shareID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9269,24 +9417,24 @@ export interface operations { /** @description Revision restore queued for async processing */ 202: { headers: { - 'x-pm-code': 1002; + "x-pm-code": 1002; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['RestoreRevisionAcceptedResponse']; + "application/json": components["schemas"]["RestoreRevisionAcceptedResponse"]; }; }; - 422: components['responses']['ProtonErrorResponse']; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification': { + "get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification": { parameters: { query?: never; header?: never; path: { volumeID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9295,23 +9443,23 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['VerificationData']; + "application/json": components["schemas"]["VerificationData"]; }; }; }; }; - 'get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification': { + "get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification": { parameters: { query?: never; header?: never; path: { shareID: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9320,16 +9468,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['VerificationData']; + "application/json": components["schemas"]["VerificationData"]; }; }; }; }; - 'post_drive-v2-volumes-{volumeID}-trash-delete_multiple': { + "post_drive-v2-volumes-{volumeID}-trash-delete_multiple": { parameters: { query?: never; header?: never; @@ -9340,7 +9488,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { @@ -9350,16 +9498,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['MultiDeleteTransformer'][]; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; }; }; }; }; }; - 'post_drive-shares-{shareID}-trash-delete_multiple': { + "post_drive-shares-{shareID}-trash-delete_multiple": { parameters: { query?: never; header?: never; @@ -9370,7 +9518,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { @@ -9380,16 +9528,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['MultiDeleteTransformer'][]; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; }; }; }; }; }; - 'get_drive-shares-{shareID}-trash': { + "get_drive-shares-{shareID}-trash": { parameters: { query?: { /** @@ -9397,8 +9545,8 @@ export interface operations { * @description Get thumbnail download URLs */ Thumbnails?: 0 | 1; - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; }; header?: never; path: { @@ -9414,19 +9562,19 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - Links: components['schemas']['ExtendedLinkTransformer'][]; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; /** @description Dictionary of ancestors of trashed links. */ Parents: { - [key: string]: components['schemas']['ExtendedLinkTransformer']; + [key: string]: components["schemas"]["ExtendedLinkTransformer"]; }; }; }; }; }; }; - 'delete_drive-shares-{shareID}-trash': { + "delete_drive-shares-{shareID}-trash": { parameters: { query?: never; header?: never; @@ -9440,30 +9588,30 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Empty trash queued for async processing */ 202: { headers: { - 'x-pm-code': 1002; + "x-pm-code": 1002; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['EmptyTrashAcceptedResponse']; + "application/json": components["schemas"]["EmptyTrashAcceptedResponse"]; }; }; }; }; - 'get_drive-volumes-{volumeID}-trash': { + "get_drive-volumes-{volumeID}-trash": { parameters: { query?: { - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; }; header?: never; path: { @@ -9476,16 +9624,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['VolumeTrashList']; + "application/json": components["schemas"]["VolumeTrashList"]; }; }; }; }; - 'delete_drive-volumes-{volumeID}-trash': { + "delete_drive-volumes-{volumeID}-trash": { parameters: { query?: never; header?: never; @@ -9499,26 +9647,26 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Empty trash queued for async processing */ 202: { headers: { - 'x-pm-code': 1002; + "x-pm-code": 1002; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['EmptyTrashAcceptedResponse']; + "application/json": components["schemas"]["EmptyTrashAcceptedResponse"]; }; }; }; }; - 'put_drive-v2-volumes-{volumeID}-trash-restore_multiple': { + "put_drive-v2-volumes-{volumeID}-trash-restore_multiple": { parameters: { query?: never; header?: never; @@ -9529,7 +9677,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { @@ -9539,14 +9687,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; Response?: { - Code?: components['schemas']['ResponseCodeSuccess']; + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }[]; }; @@ -9554,7 +9702,7 @@ export interface operations { }; }; }; - 'put_drive-shares-{shareID}-trash-restore_multiple': { + "put_drive-shares-{shareID}-trash-restore_multiple": { parameters: { query?: never; header?: never; @@ -9565,7 +9713,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { @@ -9575,14 +9723,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; Responses?: { /** @description Encrypted link ID */ LinkID?: string; Response?: { - Code?: components['schemas']['ResponseCodeSuccess']; + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }[]; }; @@ -9590,7 +9738,7 @@ export interface operations { }; }; }; - 'post_drive-v2-volumes-{volumeID}-trash_multiple': { + "post_drive-v2-volumes-{volumeID}-trash_multiple": { parameters: { query?: never; header?: never; @@ -9601,7 +9749,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { @@ -9611,16 +9759,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['MultiDeleteTransformer'][]; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; }; }; }; }; }; - 'post_drive-blocks': { + "post_drive-blocks": { parameters: { query?: never; header?: never; @@ -9629,23 +9777,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RequestUploadInput']; + "application/json": components["schemas"]["RequestUploadInput"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['RequestUploadResponse']; + "application/json": components["schemas"]["RequestUploadResponse"]; }; }; }; }; - 'post_drive-v2-volumes-{volumeID}-files-small': { + "post_drive-v2-volumes-{volumeID}-files-small": { parameters: { query?: never; header?: never; @@ -9688,8 +9836,8 @@ export interface operations { * * * --[SOME_BOUNDARY]-- */ - 'multipart/form-data': { - Metadata: components['schemas']['SmallFileUploadMetadataRequestDto']; + "multipart/form-data": { + Metadata: components["schemas"]["SmallFileUploadMetadataRequestDto"]; /** * Format: binary * @description The encrypted binary data of the file content. This is optional as 0-byte files do not have a block. @@ -9717,11 +9865,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SmallUploadResponseDto']; + "application/json": components["schemas"]["SmallUploadResponseDto"]; }; }; /** @description Bad request, the metadata does not pass validation. */ @@ -9730,7 +9878,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError']; + "application/json": components["schemas"]["ProtonError"]; }; }; /** @description Conflict, there is a name hash collision with another link in the same folder. */ @@ -9739,7 +9887,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ConflictErrorResponseDto']; + "application/json": components["schemas"]["ConflictErrorResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -9748,7 +9896,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The parent link does not exist or is trashed * - 2011: The user does not have write permission on the link @@ -9758,7 +9906,6 @@ export interface operations { * - 200701: A document type cannot create a revision * - 2511: A photo link is missing photo metadata * - 200300: max folder size reached - * - 200901: Photos backup is disabled for your account. Please enable it in the settings. * */ Code: number; }; @@ -9766,7 +9913,7 @@ export interface operations { }; }; }; - 'post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small': { + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small": { parameters: { query?: never; header?: never; @@ -9810,8 +9957,8 @@ export interface operations { * * * --[SOME_BOUNDARY]-- */ - 'multipart/form-data': { - Metadata: components['schemas']['SmallRevisionUploadMetadataRequestDto']; + "multipart/form-data": { + Metadata: components["schemas"]["SmallRevisionUploadMetadataRequestDto"]; /** * Format: binary * @description The encrypted binary data of the file content. This is optional as 0-byte files do not have a block. @@ -9839,11 +9986,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SmallUploadResponseDto']; + "application/json": components["schemas"]["SmallUploadResponseDto"]; }; }; /** @description Bad request, the metadata does not pass validation. */ @@ -9852,7 +9999,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError']; + "application/json": components["schemas"]["ProtonError"]; }; }; /** @description Conflict, the passed CurrentRevisionID is no longer up to date. */ @@ -9861,7 +10008,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError']; + "application/json": components["schemas"]["ProtonError"]; }; }; /** @description Unprocessable Entity */ @@ -9870,7 +10017,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The link does not exist or is trashed * - 2011: The user does not have write permission on the link @@ -9886,7 +10033,7 @@ export interface operations { }; }; }; - 'get_drive-me-active': { + "get_drive-me-active": { parameters: { query?: never; header?: never; @@ -9901,8 +10048,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @enum {boolean} */ Active?: true; }; @@ -9910,7 +10057,7 @@ export interface operations { }; }; }; - 'post_drive-report-url': { + "post_drive-report-url": { parameters: { query?: never; header?: never; @@ -9919,23 +10066,65 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AbuseReportDto']; + "application/json": components["schemas"]["AbuseReportDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-v2-onboarding-fresh-account": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FreshAccountResponseDto"]; + }; }; }; + }; + "post_drive-v2-onboarding-fresh-account": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["FreshAccountResponseDto"]; }; }; }; }; - 'get_drive-v2-checklist-get-started': { + "get_drive-v2-checklist-get-started": { parameters: { query?: never; header?: never; @@ -9947,16 +10136,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ChecklistResponseDto']; + "application/json": components["schemas"]["ChecklistResponseDto"]; }; }; }; }; - 'get_drive-v2-onboarding': { + "get_drive-v2-onboarding": { parameters: { query?: never; header?: never; @@ -9968,16 +10157,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['OnboardingResponseDto']; + "application/json": components["schemas"]["OnboardingResponseDto"]; }; }; }; }; - 'post_drive-v2-checklist-get-started-seen-completed-list': { + "post_drive-v2-checklist-get-started-seen-completed-list": { parameters: { query?: never; header?: never; @@ -9989,16 +10178,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'get_drive-entitlements': { + "get_drive-entitlements": { parameters: { query?: never; header?: never; @@ -10010,16 +10199,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetEntitlementResponseDto']; + "application/json": components["schemas"]["GetEntitlementResponseDto"]; }; }; }; }; - 'post_drive-photos-volumes-{volumeID}-links-{linkID}-tags': { + "post_drive-photos-volumes-{volumeID}-links-{linkID}-tags": { parameters: { query?: never; header?: never; @@ -10031,18 +10220,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AddTagsRequestDto']; + "application/json": components["schemas"]["AddTagsRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -10051,7 +10240,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The link or volume does not exist. * - 2500: One of the tags is already assigned to the photo. @@ -10064,7 +10253,7 @@ export interface operations { }; }; }; - 'delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags': { + "delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags": { parameters: { query?: never; header?: never; @@ -10076,18 +10265,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RemoveTagsRequestDto']; + "application/json": components["schemas"]["RemoveTagsRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -10096,7 +10285,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The link or volume does not exist. * - 2011: Only the owner can assign tags to photos. @@ -10107,7 +10296,7 @@ export interface operations { }; }; }; - 'post_drive-volumes-{volumeID}-photos-share': { + "post_drive-volumes-{volumeID}-photos-share": { parameters: { query?: never; header?: never; @@ -10127,7 +10316,7 @@ export interface operations { }; }; }; - 'delete_drive-volumes-{volumeID}-photos-share-{shareID}': { + "delete_drive-volumes-{volumeID}-photos-share-{shareID}": { parameters: { query?: never; header?: never; @@ -10142,17 +10331,17 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; - 422: components['responses']['ProtonErrorResponse']; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite': { + "post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite": { parameters: { query?: never; header?: never; @@ -10164,18 +10353,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['FavoritePhotoRequestDto']; + "application/json": components["schemas"]["FavoritePhotoRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['FavoritePhotoResponseDto']; + "application/json": components["schemas"]["FavoritePhotoResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -10184,7 +10373,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The link or volume does not exist. * */ @@ -10194,7 +10383,7 @@ export interface operations { }; }; }; - 'post_drive-volumes-{volumeID}-photos-duplicates': { + "post_drive-volumes-{volumeID}-photos-duplicates": { parameters: { query?: never; header?: never; @@ -10205,23 +10394,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['FindDuplicatesInput']; + "application/json": components["schemas"]["FindDuplicatesInput"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['FindDuplicatesOutputCollection']; + "application/json": components["schemas"]["FindDuplicatesOutputCollection"]; }; }; }; }; - 'get_drive-photos-migrate-legacy': { + "get_drive-photos-migrate-legacy": { parameters: { query?: never; header?: never; @@ -10233,21 +10422,21 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetMigrationStatusResponseDto']; + "application/json": components["schemas"]["GetMigrationStatusResponseDto"]; }; }; /** @description Accepted */ 202: { headers: { - 'x-pm-code': 1002; + "x-pm-code": 1002; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AcceptedResponse']; + "application/json": components["schemas"]["AcceptedResponse"]; }; }; /** @description Unprocessable Entity */ @@ -10256,7 +10445,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: The link or volume does not exist. * */ @@ -10266,7 +10455,7 @@ export interface operations { }; }; }; - 'post_drive-photos-migrate-legacy': { + "post_drive-photos-migrate-legacy": { parameters: { query?: never; header?: never; @@ -10281,7 +10470,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: Share not found * */ @@ -10291,16 +10480,16 @@ export interface operations { }; }; }; - 'get_drive-volumes-{volumeID}-photos': { + "get_drive-volumes-{volumeID}-photos": { parameters: { query?: { - Desc?: components['schemas']['ListPhotosParameters']['Desc']; - PageSize?: components['schemas']['ListPhotosParameters']['PageSize']; + Desc?: components["schemas"]["ListPhotosParameters"]["Desc"]; + PageSize?: components["schemas"]["ListPhotosParameters"]["PageSize"]; /** @description The link ID of the last photo from the previous page when requesting secondary pages */ - PreviousPageLastLinkID?: components['schemas']['ListPhotosParameters']['PreviousPageLastLinkID']; + PreviousPageLastLinkID?: components["schemas"]["ListPhotosParameters"]["PreviousPageLastLinkID"]; /** @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) */ - MinimumCaptureTime?: components['schemas']['ListPhotosParameters']['MinimumCaptureTime']; - Tag?: components['schemas']['ListPhotosParameters']['Tag']; + MinimumCaptureTime?: components["schemas"]["ListPhotosParameters"]["MinimumCaptureTime"]; + Tag?: components["schemas"]["ListPhotosParameters"]["Tag"]; }; header?: never; path: { @@ -10313,16 +10502,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PhotoListingResponse']; + "application/json": components["schemas"]["PhotoListingResponse"]; }; }; }; }; - 'put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr': { + "put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr": { parameters: { query?: never; header?: never; @@ -10335,18 +10524,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateXAttrRequest']; + "application/json": components["schemas"]["UpdateXAttrRequest"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -10355,7 +10544,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2001: Wrong signature email passed * - 2001: Invalid PGP message @@ -10371,7 +10560,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Potential codes: * - 2032 @@ -10384,7 +10573,61 @@ export interface operations { }; }; }; - 'post_drive-urls-{token}-files-{linkID}-checkAvailableHashes': { + "post_drive-urls-{token}-auth": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AuthShareTokenRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthShareTokenResponseDto"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-urls-{token}-info": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InitSRPSessionResponseDto"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-urls-{token}-files-{linkID}-checkAvailableHashes": { parameters: { query?: never; header?: never; @@ -10396,18 +10639,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CheckAvailableHashesRequestDto']; + "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AvailableHashesResponseDto']; + "application/json": components["schemas"]["AvailableHashesResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -10416,7 +10659,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions * */ @@ -10426,31 +10669,31 @@ export interface operations { }; }; }; - 'put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}': { + "put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: never; header?: never; path: { token: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['CommitAnonymousRevisionDto']; + "application/json": components["schemas"]["CommitAnonymousRevisionDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -10459,28 +10702,26 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 2011: The current ShareURL does not have read+write permissions. - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200303: Cannot commit related photo with main already in album - * */ - Code: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions. + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200303: Cannot commit related photo with main already in album + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}': { + "delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: never; header?: never; path: { token: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -10489,11 +10730,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -10502,21 +10743,19 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | components['schemas']['ShareConflictErrorResponseDto'] - | { - /** @description Potential codes and their meaning: - * - 2501: the link (must be active or trashed) or revision does not exist in the volume - * - 2511: if the revision not in draft - * - 200700: if the link is a proton doc (revisions are not used for docs) - * */ - Code?: number; - }; + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2511: if the revision not in draft + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; }; }; }; }; - 'post_drive-urls-{token}-documents': { + "post_drive-urls-{token}-documents": { parameters: { query?: never; header?: never; @@ -10527,18 +10766,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateAnonymousDocumentDto']; + "application/json": components["schemas"]["CreateAnonymousDocumentDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateAnonymousDocumentResponseDto']; + "application/json": components["schemas"]["CreateAnonymousDocumentResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -10547,20 +10786,18 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** - * @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 2011: The current ShareURL does not have read+write permissions - * - * @enum {integer} - */ - Code: 200300 | 2500 | 2501 | 2011; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * + * @enum {integer} + */ + Code: 200300 | 2500 | 2501 | 2011; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; /** @description Failed dependency */ @@ -10569,7 +10806,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Potential codes and their meaning: * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags @@ -10582,7 +10819,7 @@ export interface operations { }; }; }; - 'post_drive-urls-{token}-files': { + "post_drive-urls-{token}-files": { parameters: { query?: never; header?: never; @@ -10593,18 +10830,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateAnonymousFileRequestDto']; + "application/json": components["schemas"]["CreateAnonymousFileRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateAnonymousFileResponseDto']; + "application/json": components["schemas"]["CreateAnonymousFileResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -10613,26 +10850,23 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * - 2011: The current ShareURL does not have read+write permissions - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. - * - 200701: A document type cannot create a revision - * - 200901: Photos backup is disabled for your account. Please enable it in the settings. - * */ - Code: number; - } - | components['schemas']['ConflictErrorResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200701: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'post_drive-urls-{token}-folders': { + "post_drive-urls-{token}-folders": { parameters: { query?: never; header?: never; @@ -10643,18 +10877,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateAnonymousFolderRequestDto']; + "application/json": components["schemas"]["CreateAnonymousFolderRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateAnonymousFolderResponseDto']; + "application/json": components["schemas"]["CreateAnonymousFolderResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -10663,7 +10897,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 200300: max folder size reached * - 200301: max folder depth reached @@ -10677,7 +10911,7 @@ export interface operations { }; }; }; - 'post_drive-urls-{token}-folders-{linkID}-delete_multiple': { + "post_drive-urls-{token}-folders-{linkID}-delete_multiple": { parameters: { query?: never; header?: never; @@ -10689,7 +10923,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['DeleteChildrenRequestDto']; + "application/json": components["schemas"]["DeleteChildrenRequestDto"]; }; }; responses: { @@ -10699,10 +10933,10 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @enum {integer} */ Code?: 1001; - Responses?: components['schemas']['MultiDeleteTransformer'][]; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; }; }; }; @@ -10712,7 +10946,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions * */ @@ -10722,7 +10956,7 @@ export interface operations { }; }; }; - 'post_drive-urls-{token}-links-fetch_metadata': { + "post_drive-urls-{token}-links-fetch_metadata": { parameters: { query?: never; header?: never; @@ -10734,18 +10968,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LinkIDsRequestDto']; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['FetchLinksMetadataResponseDto']; + "application/json": components["schemas"]["FetchLinksMetadataResponseDto"]; }; }; /** @description Unprocessable entity */ @@ -10754,7 +10988,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: This file was not found, token invalid. */ Code: number; @@ -10763,7 +10997,7 @@ export interface operations { }; }; }; - 'get_drive-urls-{token}-links-{linkID}-path': { + "get_drive-urls-{token}-links-{linkID}-path": { parameters: { query?: never; header?: never; @@ -10778,11 +11012,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ParentEncryptedLinkIDsResponseDto']; + "application/json": components["schemas"]["ParentEncryptedLinkIDsResponseDto"]; }; }; /** @description Unprocessable entity */ @@ -10791,7 +11025,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2061: Invalid ID. */ Code: number; @@ -10800,7 +11034,7 @@ export interface operations { }; }; }; - 'put_drive-urls-{token}-links-{linkID}-rename': { + "put_drive-urls-{token}-links-{linkID}-rename": { parameters: { query?: never; header?: never; @@ -10812,23 +11046,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RenameAnonymousLinkRequestDto']; + "application/json": components["schemas"]["RenameAnonymousLinkRequestDto"]; }; }; responses: { - 200: components['responses']['ProtonSuccessResponse']; + 200: components["responses"]["ProtonSuccessResponse"]; /** @description Conflict, a file or folder with the new name already exists in the current folder. */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ConflictErrorResponseDto']; + "application/json": components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - 'post_drive-urls-{token}-blocks': { + "post_drive-urls-{token}-blocks": { parameters: { query?: never; header?: never; @@ -10839,18 +11073,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RequestAnonymousUploadRequestDto']; + "application/json": components["schemas"]["RequestAnonymousUploadRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['RequestUploadResponse']; + "application/json": components["schemas"]["RequestUploadResponse"]; }; }; /** @description Unprocessable Entity */ @@ -10859,7 +11093,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions * */ @@ -10869,14 +11103,14 @@ export interface operations { }; }; }; - 'get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification': { + "get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification": { parameters: { query?: never; header?: never; path: { token: string; linkID: string; - revisionID: components['schemas']['Id']; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -10885,11 +11119,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['VerificationData']; + "application/json": components["schemas"]["VerificationData"]; }; }; /** @description Unprocessable Entity */ @@ -10898,7 +11132,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions * */ @@ -10908,15 +11142,12 @@ export interface operations { }; }; }; - 'get_drive-volumes-{volumeID}-urls': { + "get_drive-urls-{token}": { parameters: { - query?: { - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; - }; + query?: never; header?: never; path: { - volumeID: string; + token: string; }; cookie?: never; }; @@ -10925,178 +11156,130 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ShareURLContextsCollection']; + "application/json": components["schemas"]["BootstrapShareTokenResponseDto"]; }; }; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'get_drive-shares-{shareID}-map': { + "get_drive-urls-{token}-folders-{linkID}-children": { parameters: { query?: { - SessionName?: components['schemas']['LinkMapQueryParameters']['SessionName']; - LastIndex?: components['schemas']['LinkMapQueryParameters']['LastIndex']; - PageSize?: components['schemas']['LinkMapQueryParameters']['PageSize']; - }; - header?: never; - path: { - shareID: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - 'x-pm-code': 1000; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['LinkMapResponse']; - }; - }; - }; - }; - 'get_drive-v2-shares-my-files': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - 'x-pm-code': 1000; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MyFilesResponseDto']; - }; + /** @description Field to sort by */ + Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; + /** @description Sort order */ + Desc?: 0 | 1; + /** @description Show all files including those in non-active (drafts) state. */ + ShowAll?: 0 | 1; + /** @description Show folders only */ + FoldersOnly?: 0 | 1; + /** + * @deprecated + * @description Get thumbnail download URLs + */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; }; - }; - }; - 'get_drive-shares-{shareID}': { - parameters: { - query?: never; header?: never; path: { - shareID: string; + token: string; + linkID: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Success */ + /** @description Links */ 200: { headers: { - 'x-pm-code': 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['BootstrapShareResponseDto']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + }; }; }; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'delete_drive-shares-{shareID}': { + "get_drive-urls-{token}-files-{linkID}": { parameters: { query?: { - /** @description Forces the deletion of the share along with attached members and urls */ - Force?: 0 | 1; + /** @description Number of blocks */ + PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + /** @description Block index from which to fetch block list */ + FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; + /** @description Do not generate download URLs for blocks */ + NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; }; header?: never; path: { - shareID: string; + token: string; + linkID: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Success */ - 200: { - headers: { - 'x-pm-code': 1000; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['SuccessfulResponse']; - }; - }; - /** @description Unprocessable Entity */ - 422: { + /** @description Success */ + 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - /** @description Potential codes and their meaning: - * - 2011: the current user does not have admin permission on this share - * - 2005: the share still has members, a public URL or invitations attached and Force=1 has not been used */ - Code: number; - }; + "application/json": components["schemas"]["GetRevisionResponseDto"]; }; }; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'get_drive-volumes-{volumeID}-links-{linkID}-context': { + "post_drive-urls-{token}-file": { parameters: { query?: never; header?: never; path: { - volumeID: string; - linkID: string; + /** @description ShareURL Token */ + token: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["GetSharedFileInfoRequestDto"]; + }; + }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetHighestContextForDocumentResponse']; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** - * @description 2501: Requested data does not exist or you do not have permission to access it - * - * @enum {integer} - */ - Code: 2501; - }; + "application/json": components["schemas"]["GetSharedFileInfoResponseDto"]; }; }; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'get_drive-shares': { + "get_drive-volumes-{volumeID}-urls": { parameters: { query?: { - /** @description Encrypted AddressID */ - AddressID?: string; - /** @description Show disabled shares as well, i.e. Shares where the ShareMemberShip for the user is non-active (locked), otherwise only return with active Membership */ - ShowAll?: 0 | 1; - /** @description Filter on Share Type */ - ShareType?: 1 | 2 | 3 | 4; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; }; header?: never; - path?: never; + path: { + volumeID: string; + }; cookie?: never; }; requestBody?: never; @@ -11104,95 +11287,112 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListSharesResponseDto']; + "application/json": components["schemas"]["ShareURLContextsCollection"]; }; }; }; }; - 'post_drive-shares-{shareID}-owner': { + "get_drive-shares-{shareID}-urls": { parameters: { - query?: never; + query?: { + /** @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). */ + Recursive?: 0 | 1; + /** @description Fetch Thumbnail URLs */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; header?: never; path: { shareID: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['TransferInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["ListShareURLsResponseDto"]; }; }; }; }; - 'post_drive-migrations-shareaccesswithnode': { + "post_drive-shares-{shareID}-urls": { parameters: { query?: never; header?: never; - path?: never; + path: { + shareID: string; + }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['MigrateSharesRequestDto']; + "application/json": components["schemas"]["CreateShareURLRequestDto"]; }; }; responses: { - /** @description Success */ + /** @description Share URL created */ 200: { headers: { - 'x-pm-code': 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MigrateSharesResponseDto']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + ShareURL: components["schemas"]["ShareURLResponseDto"]; + }; }; }; }; }; - 'get_drive-migrations-shareaccesswithnode-unmigrated': { + "put_drive-shares-{shareID}-urls-{urlID}": { parameters: { query?: never; header?: never; - path?: never; + path: { + shareID: string; + urlID: components["schemas"]["Id"]; + }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateShareURLRequestDto"]; + }; + }; responses: { - /** @description Success */ + /** @description Share URL updated */ 200: { headers: { - 'x-pm-code': 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['UnmigratedSharesResponseDto']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + ShareURL: components["schemas"]["ShareURLResponseDto"]; + }; }; }; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'get_drive-urls-{token}-info': { + "delete_drive-shares-{shareID}-urls-{urlID}": { parameters: { query?: never; header?: never; path: { - /** @description ShareURL Token */ - token: string; + shareID: string; + urlID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -11201,68 +11401,62 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['InitSRPSessionResponseDto']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; - 422: components['responses']['ProtonErrorResponse']; }; }; - 'post_drive-urls-{token}-auth': { + "post_drive-shares-{shareID}-urls-delete_multiple": { parameters: { query?: never; header?: never; path: { - /** @description ShareURL Token */ - token: string; + shareID: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['AuthShareTokenRequestDto']; + "application/json": components["schemas"]["DeleteMultipleShareURLsRequestDto"]; }; }; responses: { - /** @description Share URL */ + /** @description Responses */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - /** @description Session UID */ - UID: string; - /** @description Session Access token (present if new session) */ - AccessToken?: string; - /** @description Duration of the session in seconds (present if new session) */ - ExpiresIn?: number; - /** - * @description Type of token (present if new session) - * @example bearer - */ - TokenType?: string; - /** - * @description SRP server proof, base64 encoded. - * @example 00o4YSsW/Z7a0+ak - */ - ServerProof: string; + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: { + ShareURLID: string; + Response: { + /** @enum {integer} */ + Code: 1000 | 2501; + Error?: string; + }; + }[]; }; }; }; - 422: components['responses']['ProtonErrorResponse']; }; }; - 'get_drive-urls-{token}': { + "get_drive-shares-{shareID}-map": { parameters: { - query?: never; + query?: { + SessionName?: components["schemas"]["LinkMapQueryParameters"]["SessionName"]; + LastIndex?: components["schemas"]["LinkMapQueryParameters"]["LastIndex"]; + PageSize?: components["schemas"]["LinkMapQueryParameters"]["PageSize"]; + }; header?: never; path: { - token: string; + shareID: string; }; cookie?: never; }; @@ -11271,74 +11465,41 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['BootstrapShareTokenResponseDto']; + "application/json": components["schemas"]["LinkMapResponse"]; }; }; - 422: components['responses']['ProtonErrorResponse']; }; }; - 'get_drive-urls-{token}-folders-{linkID}-children': { + "get_drive-v2-shares-my-files": { parameters: { - query?: { - /** @description Field to sort by */ - Sort?: 'MIMEType' | 'Size' | 'ModifyTime' | 'CreateTime' | 'Type'; - /** @description Sort order */ - Desc?: 0 | 1; - /** @description Show all files including those in non-active (drafts) state. */ - ShowAll?: 0 | 1; - /** @description Show folders only */ - FoldersOnly?: 0 | 1; - /** - * @deprecated - * @description Get thumbnail download URLs - */ - Thumbnails?: 0 | 1; - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; - }; + query?: never; header?: never; - path: { - token: string; - linkID: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Links */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - Links: components['schemas']['ExtendedLinkTransformer'][]; - }; + "application/json": components["schemas"]["PrimaryRootShareResponseDto"]; }; }; - 422: components['responses']['ProtonErrorResponse']; }; }; - 'get_drive-urls-{token}-files-{linkID}': { + "get_drive-v2-shares-photos": { parameters: { - query?: { - /** @description Number of blocks */ - PageSize?: components['schemas']['GetRevisionQueryParameters']['PageSize']; - /** @description Block index from which to fetch block list */ - FromBlockIndex?: components['schemas']['GetRevisionQueryParameters']['FromBlockIndex']; - /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: components['schemas']['GetRevisionQueryParameters']['NoBlockUrls']; - }; + query?: never; header?: never; - path: { - token: string; - linkID: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -11346,54 +11507,43 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetRevisionResponseDto']; + "application/json": components["schemas"]["PrimaryRootShareResponseDto"]; }; }; - 422: components['responses']['ProtonErrorResponse']; }; }; - 'post_drive-urls-{token}-file': { + "get_drive-shares-{shareID}": { parameters: { query?: never; header?: never; path: { - /** @description ShareURL Token */ - token: string; + shareID: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['GetSharedFileInfoRequestDto']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetSharedFileInfoResponseDto']; + "application/json": components["schemas"]["BootstrapShareResponseDto"]; }; }; - 422: components['responses']['ProtonErrorResponse']; }; }; - 'get_drive-shares-{shareID}-urls': { + "delete_drive-shares-{shareID}": { parameters: { query?: { - /** @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). */ - Recursive?: 0 | 1; - /** @description Fetch Thumbnail URLs */ - Thumbnails?: 0 | 1; - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; + /** @description Forces the deletion of the share along with attached members and urls */ + Force?: 0 | 1; }; header?: never; path: { @@ -11406,137 +11556,171 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListShareURLsResponseDto']; + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the current user does not have admin permission on this share + * - 2005: the share still has members, a public URL or invitations attached and Force=1 has not been used */ + Code: number; + }; }; }; }; }; - 'post_drive-shares-{shareID}-urls': { + "get_drive-volumes-{volumeID}-links-{linkID}-context": { parameters: { query?: never; header?: never; path: { - shareID: string; + volumeID: string; + linkID: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateShareURLRequestDto']; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetHighestContextForDocumentResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description 2501: Requested data does not exist or you do not have permission to access it + * + * @enum {integer} + */ + Code: 2501; + }; + }; + }; + }; + }; + "get_drive-shares": { + parameters: { + query?: { + /** @description Encrypted AddressID */ + AddressID?: string; + /** @description Show disabled shares as well, i.e. Shares where the ShareMemberShip for the user is non-active (locked), otherwise only return with active Membership */ + ShowAll?: 0 | 1; + /** @description Filter on Share Type */ + ShareType?: 1 | 2 | 3 | 4; }; + header?: never; + path?: never; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Share URL created */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - ShareURL: components['schemas']['ShareURLResponseDto']; - }; + "application/json": components["schemas"]["ListSharesResponseDto"]; }; }; }; }; - 'put_drive-shares-{shareID}-urls-{urlID}': { + "post_drive-shares-{shareID}-owner": { parameters: { query?: never; header?: never; path: { shareID: string; - urlID: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateShareURLRequestDto']; + "application/json": components["schemas"]["TransferInput"]; }; }; responses: { - /** @description Share URL updated */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; - ShareURL: components['schemas']['ShareURLResponseDto']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; - 422: components['responses']['ProtonErrorResponse']; }; }; - 'delete_drive-shares-{shareID}-urls-{urlID}': { + "post_drive-migrations-shareaccesswithnode": { parameters: { query?: never; header?: never; - path: { - shareID: string; - urlID: components['schemas']['Id']; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateSharesRequestDto"]; + }; + }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["MigrateSharesResponseDto"]; }; }; }; }; - 'post_drive-shares-{shareID}-urls-delete_multiple': { + "get_drive-migrations-shareaccesswithnode-unmigrated": { parameters: { query?: never; header?: never; - path: { - shareID: string; - }; + path?: never; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['DeleteMultipleShareURLsRequestDto']; - }; - }; + requestBody?: never; responses: { - /** @description Responses */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - /** @enum {integer} */ - Code?: 1001; - Responses?: { - ShareURLID: string; - Response: { - /** @enum {integer} */ - Code: 1000 | 2501; - Error?: string; - }; - }[]; - }; + "application/json": components["schemas"]["UnmigratedSharesResponseDto"]; }; }; }; }; - 'post_drive-volumes-{volumeID}-shares': { + "post_drive-volumes-{volumeID}-shares": { parameters: { query?: never; header?: never; @@ -11547,7 +11731,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateShareRequestDto']; + "application/json": components["schemas"]["CreateShareRequestDto"]; }; }; responses: { @@ -11557,8 +11741,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; Share: { /** @description Share ID */ ID: string; @@ -11572,25 +11756,23 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | components['schemas']['ShareConflictErrorResponseDto'] - | { - /** @description Potential codes and their meaning: - * - 2501: the link does not exist in the volume - * - 2011: the current user does not have admin permission on this share - * - 2001: the PGP message is not correct - * - 200601: The user has too many shares already. - * */ - Code?: number; - }; + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link does not exist in the volume + * - 2011: the current user does not have admin permission on this share + * - 2001: the PGP message is not correct + * - 200601: The user has too many shares already. + * */ + Code?: number; + }; }; }; }; }; - 'get_drive-v2-volumes-{volumeID}-shares': { + "get_drive-v2-volumes-{volumeID}-shares": { parameters: { query?: { - AnchorID?: components['schemas']['Id'] | null; + AnchorID?: components["schemas"]["Id"] | null; }; header?: never; path: { @@ -11603,19 +11785,19 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SharedByMeResponseDto']; + "application/json": components["schemas"]["SharedByMeResponseDto"]; }; }; }; }; - 'get_drive-v2-sharedwithme': { + "get_drive-v2-sharedwithme": { parameters: { query?: { - AnchorID?: components['schemas']['Id'] | null; + AnchorID?: components["schemas"]["Id"] | null; }; header?: never; path?: never; @@ -11626,39 +11808,39 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SharedWithMeResponseDto2']; + "application/json": components["schemas"]["SharedWithMeResponseDto2"]; }; }; }; }; - 'put_drive-v2-shares-{shareID}-external-invitations-{invitationID}': { + "put_drive-v2-shares-{shareID}-external-invitations-{invitationID}": { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateExternalInvitationRequestDto']; + "application/json": components["schemas"]["UpdateExternalInvitationRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -11667,7 +11849,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist, is not pending or accepted * - 2011: the current user does not have admin permission on this share @@ -11679,13 +11861,13 @@ export interface operations { }; }; }; - 'delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}': { + "delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}": { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -11694,11 +11876,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -11707,7 +11889,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the external invitation does not exist, is not pending or accepted * - 2011: the current user does not have admin permission on this share @@ -11718,7 +11900,7 @@ export interface operations { }; }; }; - 'get_drive-v2-shares-{shareID}-external-invitations': { + "get_drive-v2-shares-{shareID}-external-invitations": { parameters: { query?: never; header?: never; @@ -11732,11 +11914,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListShareExternalInvitationsResponseDto']; + "application/json": components["schemas"]["ListShareExternalInvitationsResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -11745,7 +11927,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the share does not exist * - 2011: the current user does not have admin permission on this share */ @@ -11755,7 +11937,7 @@ export interface operations { }; }; }; - 'post_drive-v2-shares-{shareID}-external-invitations': { + "post_drive-v2-shares-{shareID}-external-invitations": { parameters: { query?: never; header?: never; @@ -11766,18 +11948,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['InviteExternalUserRequestDto']; + "application/json": components["schemas"]["InviteExternalUserRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['InviteExternalUserResponseDto']; + "application/json": components["schemas"]["InviteExternalUserResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -11786,7 +11968,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2011: the current user does not have admin permission on this share * - 2500: an external invitation for this user on this share already exists @@ -11801,10 +11983,10 @@ export interface operations { }; }; }; - 'get_drive-v2-shares-external-invitations': { + "get_drive-v2-shares-external-invitations": { parameters: { query?: { - AnchorID?: components['schemas']['Id'] | null; + AnchorID?: components["schemas"]["Id"] | null; PageSize?: number; }; header?: never; @@ -11816,22 +11998,22 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListUserRegisteredExternalInvitationResponseDto']; + "application/json": components["schemas"]["ListUserRegisteredExternalInvitationResponseDto"]; }; }; }; }; - 'post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail': { + "post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail": { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -11840,11 +12022,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -11853,7 +12035,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the external invitation does not exist, is not pending or rejected * - 2011: the current user does not have admin permission on this share @@ -11864,29 +12046,29 @@ export interface operations { }; }; }; - 'post_drive-v2-shares-invitations-{invitationID}-accept': { + "post_drive-v2-shares-invitations-{invitationID}-accept": { parameters: { query?: never; header?: never; path: { - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['AcceptInvitationRequestDto']; + "application/json": components["schemas"]["AcceptInvitationRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -11895,7 +12077,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the share or the invitation was not found or was not pending * - 2011: the invitee email doesn't belong to the current user @@ -11911,30 +12093,30 @@ export interface operations { }; }; }; - 'put_drive-v2-shares-{shareID}-invitations-{invitationID}': { + "put_drive-v2-shares-{shareID}-invitations-{invitationID}": { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateInvitationRequestDto']; + "application/json": components["schemas"]["UpdateInvitationRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -11943,7 +12125,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist, is not pending or rejected * - 2011: the current user does not have admin permission on this share @@ -11955,13 +12137,13 @@ export interface operations { }; }; }; - 'delete_drive-v2-shares-{shareID}-invitations-{invitationID}': { + "delete_drive-v2-shares-{shareID}-invitations-{invitationID}": { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -11970,11 +12152,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -11983,7 +12165,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist, is not pending or rejected * - 2011: the current user does not have admin permission on this share @@ -11994,7 +12176,7 @@ export interface operations { }; }; }; - 'get_drive-v2-shares-{shareID}-invitations': { + "get_drive-v2-shares-{shareID}-invitations": { parameters: { query?: never; header?: never; @@ -12008,11 +12190,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListShareInvitationsResponseDto']; + "application/json": components["schemas"]["ListShareInvitationsResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -12021,7 +12203,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the share does not exist * - 2011: the current user does not have admin permission on this share */ @@ -12031,7 +12213,7 @@ export interface operations { }; }; }; - 'post_drive-v2-shares-{shareID}-invitations': { + "post_drive-v2-shares-{shareID}-invitations": { parameters: { query?: never; header?: never; @@ -12042,18 +12224,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['InviteUserRequestDto']; + "application/json": components["schemas"]["InviteUserRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['InviteUserResponseDto']; + "application/json": components["schemas"]["InviteUserResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -12062,7 +12244,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the external invitation does not exists or is still pending * - 2011: the current user does not have admin permission on this share @@ -12082,12 +12264,12 @@ export interface operations { }; }; }; - 'get_drive-v2-shares-invitations': { + "get_drive-v2-shares-invitations": { parameters: { query?: { - AnchorID?: components['schemas']['ListPendingInvitationQueryParameters']['AnchorID']; - PageSize?: components['schemas']['ListPendingInvitationQueryParameters']['PageSize']; - ShareTargetTypes?: components['schemas']['ListPendingInvitationQueryParameters']['ShareTargetTypes']; + AnchorID?: components["schemas"]["ListPendingInvitationQueryParameters"]["AnchorID"]; + PageSize?: components["schemas"]["ListPendingInvitationQueryParameters"]["PageSize"]; + ShareTargetTypes?: components["schemas"]["ListPendingInvitationQueryParameters"]["ShareTargetTypes"]; }; header?: never; path?: never; @@ -12098,21 +12280,21 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListPendingInvitationResponseDto']; + "application/json": components["schemas"]["ListPendingInvitationResponseDto"]; }; }; }; }; - 'post_drive-v2-shares-invitations-{invitationID}-reject': { + "post_drive-v2-shares-invitations-{invitationID}-reject": { parameters: { query?: never; header?: never; path: { - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -12121,11 +12303,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -12134,7 +12316,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist or is not pending * - 2011: the invitee email doesn't belong to the current user @@ -12145,13 +12327,13 @@ export interface operations { }; }; }; - 'post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail': { + "post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail": { parameters: { query?: never; header?: never; path: { shareID: string; - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -12160,11 +12342,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -12173,7 +12355,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist, is not pending or rejected * - 2011: the current user does not have admin permission on this share @@ -12185,12 +12367,12 @@ export interface operations { }; }; }; - 'get_drive-v2-shares-invitations-{invitationID}': { + "get_drive-v2-shares-invitations-{invitationID}": { parameters: { query?: never; header?: never; path: { - invitationID: components['schemas']['Id']; + invitationID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -12199,11 +12381,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PendingInvitationResponseDto']; + "application/json": components["schemas"]["PendingInvitationResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -12212,7 +12394,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the invitation does not exist or is not pending, or the link/share/volume for it is gone * - 2011: the invitee email doesn't belong to the current user @@ -12223,12 +12405,12 @@ export interface operations { }; }; }; - 'get_drive-v2-user-link-access': { + "get_drive-v2-user-link-access": { parameters: { query?: { - LinkID?: components['schemas']['Id']; - VolumeID?: components['schemas']['Id'] | null; - ShareID?: components['schemas']['Id'] | null; + LinkID?: components["schemas"]["Id"]; + VolumeID?: components["schemas"]["Id"] | null; + ShareID?: components["schemas"]["Id"] | null; }; header?: never; path?: never; @@ -12239,16 +12421,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LinkAccessesResponseDto']; + "application/json": components["schemas"]["LinkAccessesResponseDto"]; }; }; }; }; - 'get_drive-v2-shares-{shareID}-members': { + "get_drive-v2-shares-{shareID}-members": { parameters: { query?: never; header?: never; @@ -12262,11 +12444,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListShareMembersResponseDto']; + "application/json": components["schemas"]["ListShareMembersResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -12275,7 +12457,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the share does not exist * - 2011: the current user does not have admin permission on this share */ @@ -12285,7 +12467,7 @@ export interface operations { }; }; }; - 'put_drive-v2-shares-{shareID}-members-{memberID}': { + "put_drive-v2-shares-{shareID}-members-{memberID}": { parameters: { query?: never; header?: never; @@ -12297,18 +12479,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateShareMemberRequestDto']; + "application/json": components["schemas"]["UpdateShareMemberRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -12317,11 +12499,11 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2501: the member does not exist or is removed. * - 2011: the current user does not have admin permission on this share - * - 2026: trying to grant permissions you do not have to a member + * - 2026: trying to grant permissions you do not have to a member or only the owner can modify their own permissions * */ Code: number; }; @@ -12329,7 +12511,7 @@ export interface operations { }; }; }; - 'delete_drive-v2-shares-{shareID}-members-{memberID}': { + "delete_drive-v2-shares-{shareID}-members-{memberID}": { parameters: { query?: never; header?: never; @@ -12344,11 +12526,11 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; /** @description Unprocessable Entity */ @@ -12357,10 +12539,11 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2011: the user does not have enough permission to remove another member * - 2501: the user is not a member of the share + * - 2026: Cannot remove the owner of the file or folder * */ Code: number; }; @@ -12368,7 +12551,7 @@ export interface operations { }; }; }; - 'post_drive-v2-shares-{shareID}-security': { + "post_drive-v2-shares-{shareID}-security": { parameters: { query?: never; header?: never; @@ -12379,18 +12562,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SecurityRequestDto']; + "application/json": components["schemas"]["SecurityRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SecurityResponseDto']; + "application/json": components["schemas"]["SecurityResponseDto"]; }; }; /** @description Unprocessable Entity */ @@ -12399,7 +12582,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Potential codes and their meaning: * - 2011: the current user does not have read permission on this share */ Code: number; @@ -12408,7 +12591,7 @@ export interface operations { }; }; }; - 'post_drive-urls-{token}-security': { + "post_drive-urls-{token}-security": { parameters: { query?: never; header?: never; @@ -12419,18 +12602,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SecurityRequestDto']; + "application/json": components["schemas"]["SecurityRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SecurityResponseDto']; + "application/json": components["schemas"]["SecurityResponseDto"]; }; }; /** @description Code 2028 if feature is disabled, rate-limited or blocked because of abuse. Code 9001 for HV captcha. */ @@ -12439,12 +12622,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError']; + "application/json": components["schemas"]["ProtonError"]; }; }; }; }; - 'post_drive-volumes-{volumeID}-thumbnails': { + "post_drive-volumes-{volumeID}-thumbnails": { parameters: { query?: never; header?: never; @@ -12455,23 +12638,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ThumbnailIDsListInput']; + "application/json": components["schemas"]["ThumbnailIDsListInput"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListThumbnailsResponse']; + "application/json": components["schemas"]["ListThumbnailsResponse"]; }; }; }; }; - 'get_drive-me-settings': { + "get_drive-me-settings": { parameters: { query?: never; header?: never; @@ -12483,16 +12666,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SettingsResponse']; + "application/json": components["schemas"]["SettingsResponse"]; }; }; }; }; - 'put_drive-me-settings': { + "put_drive-me-settings": { parameters: { query?: never; header?: never; @@ -12501,41 +12684,27 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UserSettingsRequest']; + "application/json": components["schemas"]["UserSettingsRequest"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['SettingsResponse']; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - /** @description Potential codes and their meaning: - * - 200900: Photos cannot be disabled. There is data in your Photos section. - * */ - Code: number; - }; + "application/json": components["schemas"]["SettingsResponse"]; }; }; }; }; - 'get_drive-volumes': { + "get_drive-volumes": { parameters: { query?: { - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; }; header?: never; path?: never; @@ -12546,16 +12715,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListVolumesResponseDto']; + "application/json": components["schemas"]["ListVolumesResponseDto"]; }; }; }; }; - 'post_drive-volumes': { + "post_drive-volumes": { parameters: { query?: never; header?: never; @@ -12564,23 +12733,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateVolumeRequestDto']; + "application/json": components["schemas"]["CreateVolumeRequestDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetVolumeResponseDto']; + "application/json": components["schemas"]["GetVolumeResponseDto"]; }; }; }; }; - 'put_drive-volumes-{volumeID}-delete_locked': { + "put_drive-volumes-{volumeID}-delete_locked": { parameters: { query?: never; header?: never; @@ -12594,17 +12763,17 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; - 422: components['responses']['ProtonErrorResponse']; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'get_drive-volumes-{volumeID}': { + "get_drive-volumes-{volumeID}": { parameters: { query?: never; header?: never; @@ -12618,17 +12787,17 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetVolumeResponseDto']; + "application/json": components["schemas"]["GetVolumeResponseDto"]; }; }; - 422: components['responses']['ProtonErrorResponse']; + 422: components["responses"]["ProtonErrorResponse"]; }; }; - 'put_drive-volumes-{volumeID}-restore': { + "put_drive-volumes-{volumeID}-restore": { parameters: { query?: never; header?: never; @@ -12639,21 +12808,21 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RestoreVolumeDto']; + "application/json": components["schemas"]["RestoreVolumeDto"]; }; }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; - 422: components['responses']['ProtonErrorResponse']; + 422: components["responses"]["ProtonErrorResponse"]; }; }; } diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index 2e215e6a..d9f1550f 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,12 +1,9 @@ -import { c } from 'ttag'; - -import { DriveAPIService, drivePaths, NotFoundAPIError } from '../apiService'; +import { DriveAPIService, drivePaths } from '../apiService'; import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; import { makeNodeUid } from '../uids'; -type GetVolumesResponse = drivePaths['/drive/volumes']['get']['responses']['200']['content']['application/json']; - -type GetShareResponse = drivePaths['/drive/shares/{shareID}']['get']['responses']['200']['content']['application/json']; +type GetPhotoShareResponse = + drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json']; type PostCreateVolumeRequest = Extract< drivePaths['/drive/photos/volumes']['post']['requestBody'], @@ -34,33 +31,19 @@ export class PhotosAPIService { } async getPhotoShare(): Promise { - // TODO: Switch to drive/v2/shares/photos once available. - - const volumesResponse = await this.apiService.get('drive/volumes'); - - const photoVolume = volumesResponse.Volumes.find((volume) => volume.Type === 2); - - if (!photoVolume) { - throw new NotFoundAPIError(c('Error').t`Photo volume not found`); - } - - const response = await this.apiService.get(`drive/shares/${photoVolume.Share.ShareID}`); - - if (!response.AddressID) { - throw new Error('Photo root share has not address ID set'); - } + const response = await this.apiService.get('drive/v2/shares/photos'); return { - volumeId: response.VolumeID, - shareId: response.ShareID, - rootNodeId: response.LinkID, - creatorEmail: response.Creator, + volumeId: response.Volume.VolumeID, + shareId: response.Share.ShareID, + rootNodeId: response.Link.Link.LinkID, + creatorEmail: response.Share.CreatorEmail, encryptedCrypto: { - armoredKey: response.Key, - armoredPassphrase: response.Passphrase, - armoredPassphraseSignature: response.PassphraseSignature, + armoredKey: response.Share.Key, + armoredPassphrase: response.Share.Passphrase, + armoredPassphraseSignature: response.Share.PassphraseSignature, }, - addressId: response.AddressID, + addressId: response.Share.AddressID, type: ShareType.Photo, }; } From b45257c86968c7245c8996606582ca3f73a0c0f1 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Oct 2025 15:37:12 +0200 Subject: [PATCH 253/791] Fix conflicting draft deletion failure --- .../Proton.Drive.Sdk/Api/DriveApiClients.cs | 1 + .../Api/Files/FilesApiClient.cs | 10 ++- .../Api/Files/IFilesApiClient.cs | 8 ++- .../Api/Files/TrashApiClient.cs | 61 +++++++++++++++++++ .../Proton.Drive.Sdk/Api/IDriveApiClients.cs | 1 + .../Api/Links/ILinksApiClient.cs | 10 --- .../Api/Links/ITrashApiClient.cs | 24 ++++++++ .../Api/Links/LinksApiClient.cs | 28 +-------- .../Api/Volumes/IVolumesApiClient.cs | 3 - .../Api/Volumes/VolumesApiClient.cs | 9 --- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 23 ++++++- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 10 +-- .../Nodes/Upload/FileUploader.cs | 2 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 2 +- .../Volumes/VolumeOperations.cs | 2 +- .../InteropErrorConverter.cs | 6 +- cs/sdk/src/Proton.Sdk/ProtonApiException.cs | 8 ++- cs/sdk/src/Proton.Sdk/Result{TError}.cs | 2 +- 18 files changed, 147 insertions(+), 63 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs index 8ea53ebb..4fc33513 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -15,4 +15,5 @@ internal sealed class DriveApiClients(HttpClient httpClient) : IDriveApiClients public IFoldersApiClient Folders { get; } = new FoldersApiClient(httpClient); public IFilesApiClient Files { get; } = new FilesApiClient(httpClient); public IStorageApiClient Storage { get; } = new StorageApiClient(httpClient); + public ITrashApiClient Trash { get; } = new TrashApiClient(httpClient); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index 4dea9a27..3f2d1ffa 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -19,7 +19,7 @@ public async ValueTask CreateFileAsync(VolumeId volumeId, .ConfigureAwait(false); } - public async Task CreateRevisionAsync( + public async ValueTask CreateRevisionAsync( VolumeId volumeId, LinkId linkId, RevisionCreationRequest request, @@ -73,4 +73,12 @@ public async ValueTask GetRevisionAsync( $"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}?FromBlockIndex={fromBlockIndex}&PageSize={pageSize}&NoBlockUrls={(withoutBlockUrls ? 1 : 0)}", cancellationToken).ConfigureAwait(false); } + + public async ValueTask DeleteRevisionAsync(VolumeId volumeId, LinkId linkId, RevisionId revisionId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ApiResponse) + .DeleteAsync($"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}", cancellationToken) + .ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs index 65da6d60..0b4e978e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -8,7 +8,11 @@ internal interface IFilesApiClient { ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken); - Task CreateRevisionAsync(VolumeId volumeId, LinkId linkId, RevisionCreationRequest request, CancellationToken cancellationToken); + ValueTask CreateRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionCreationRequest request, + CancellationToken cancellationToken); ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken); @@ -27,4 +31,6 @@ public ValueTask GetRevisionAsync( int pageSize, bool withoutBlockUrls, CancellationToken cancellationToken); + + ValueTask DeleteRevisionAsync(VolumeId volumeId, LinkId linkId, RevisionId revisionId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs new file mode 100644 index 00000000..631c66bd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs @@ -0,0 +1,61 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class TrashApiClient(HttpClient httpClient) : ITrashApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask> TrashMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) + .PostAsync($"v2/volumes/{volumeId}/trash_multiple", request, DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask> DeleteMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) + .PostAsync( + $"v2/volumes/{volumeId}/trash/delete_multiple", + request, + DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, + cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask> RestoreMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) + .PutAsync( + $"v2/volumes/{volumeId}/trash/restore_multiple", + request, + DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, + cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask EmptyAsync(VolumeId volumeId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .DeleteAsync("volumes/trash", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs index ea810c85..18af01e3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs @@ -15,4 +15,5 @@ internal interface IDriveApiClients IFoldersApiClient Folders { get; } IFilesApiClient Files { get; } IStorageApiClient Storage { get; } + ITrashApiClient Trash { get; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs index 933174b0..8592782d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs @@ -15,18 +15,8 @@ internal interface ILinksApiClient ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken); - ValueTask> TrashMultipleAsync( - VolumeId volumeId, - MultipleLinksNullaryRequest request, - CancellationToken cancellationToken); - ValueTask> DeleteMultipleAsync( VolumeId volumeId, MultipleLinksNullaryRequest request, CancellationToken cancellationToken); - - ValueTask> RestoreMultipleAsync( - VolumeId volumeId, - MultipleLinksNullaryRequest request, - CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs new file mode 100644 index 00000000..f1a2948d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs @@ -0,0 +1,24 @@ +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal interface ITrashApiClient +{ + ValueTask> TrashMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken); + + ValueTask> RestoreMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken); + + ValueTask> DeleteMultipleAsync( + VolumeId volumeId, + MultipleLinksNullaryRequest request, + CancellationToken cancellationToken); + + ValueTask EmptyAsync(VolumeId volumeId, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs index aaa2c2e0..108eb771 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -49,17 +49,6 @@ public async ValueTask RenameAsync(VolumeId volumeId, LinkId linkId .ConfigureAwait(false); } - public async ValueTask> TrashMultipleAsync( - VolumeId volumeId, - MultipleLinksNullaryRequest request, - CancellationToken cancellationToken) - { - return await _httpClient - .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) - .PostAsync($"v2/volumes/{volumeId}/trash_multiple", request, DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, cancellationToken) - .ConfigureAwait(false); - } - public async ValueTask> DeleteMultipleAsync( VolumeId volumeId, MultipleLinksNullaryRequest request, @@ -68,22 +57,7 @@ public async ValueTask> DeleteMultipleA return await _httpClient .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) .PostAsync( - $"v2/volumes/{volumeId}/trash/delete_multiple", - request, - DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, - cancellationToken) - .ConfigureAwait(false); - } - - public async ValueTask> RestoreMultipleAsync( - VolumeId volumeId, - MultipleLinksNullaryRequest request, - CancellationToken cancellationToken) - { - return await _httpClient - .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) - .PutAsync( - $"v2/volumes/{volumeId}/trash/restore_multiple", + $"v2/volumes/{volumeId}/delete_multiple", request, DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs index 61ae5fda..71d68632 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs @@ -1,5 +1,4 @@ using Proton.Drive.Sdk.Volumes; -using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Api.Volumes; @@ -10,6 +9,4 @@ internal interface IVolumesApiClient ValueTask GetVolumeAsync(VolumeId volumeId, CancellationToken cancellationToken); ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken); - - ValueTask EmptyTrashAsync(VolumeId volumeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs index 8e9f0147..d188ae8f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs @@ -1,8 +1,6 @@ using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Volumes; -using Proton.Sdk.Api; using Proton.Sdk.Http; -using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Api.Volumes; @@ -30,11 +28,4 @@ public async ValueTask GetTrashAsync(VolumeId volumeId, int .Expecting(DriveApiSerializerContext.Default.VolumeTrashResponse) .GetAsync($"volumes/{volumeId}/trash?pageSize={pageSize}&page={page}", cancellationToken).ConfigureAwait(false); } - - public async ValueTask EmptyTrashAsync(VolumeId volumeId, CancellationToken cancellationToken) - { - return await _httpClient - .Expecting(ProtonApiSerializerContext.Default.ApiResponse) - .DeleteAsync("volumes/trash", cancellationToken).ConfigureAwait(false); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 5091c7a8..1070cbb0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -1,11 +1,14 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Sdk; +using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Nodes; internal static class FileOperations { + private const int MaxNumberOfDraftCreationAttempts = 3; + public static async Task<(RevisionUid RevisionUid, FileSecrets FileSecrets)> CreateDraftAsync( ProtonDriveClient client, NodeUid parentUid, @@ -53,6 +56,7 @@ internal static class FileOperations }; FileCreationResponse? response = null; + var remainingNumberOfAttempts = MaxNumberOfDraftCreationAttempts; while (response is null) { @@ -64,7 +68,24 @@ internal static class FileOperations when (e.Response is { Conflict: { LinkId: { } linkId, RevisionId: null, DraftRevisionId: not null } } && (e.Response.Conflict.DraftClientUid == client.Uid || overrideExistingDraftByOtherClient)) { - await NodeOperations.DeleteAsync(client, [new NodeUid(parentUid.VolumeId, linkId)], cancellationToken).ConfigureAwait(false); + var uidOfNodeToDelete = new NodeUid(parentUid.VolumeId, linkId); + + var deletionResults = await NodeOperations.DeleteAsync(client, [uidOfNodeToDelete], cancellationToken).ConfigureAwait(false); + + if (!deletionResults.TryGetValue(uidOfNodeToDelete, out var deletionResult)) + { + throw new ProtonApiException("Missing deletion result in response"); + } + + if (deletionResult.TryGetError(out var deletionException) && deletionException is not ProtonApiException { Code: ResponseCode.DoesNotExist }) + { + throw deletionException; + } + + if (--remainingNumberOfAttempts <= 0) + { + throw; + } } catch (ProtonApiException e) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index cfd6480a..7aa67311 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -288,7 +288,7 @@ public static async ValueTask RenameAsync( { var request = new MultipleLinksNullaryRequest { LinkIds = batch }; - var aggregateResponse = await client.Api.Links.TrashMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + var aggregateResponse = await client.Api.Trash.TrashMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); foreach (var (linkId, response) in aggregateResponse.Responses) { @@ -319,14 +319,14 @@ await client.Cache.Entities.SetNodeAsync(uid, newNodeProvisionResult, membership return results; } - public static async ValueTask>> DeleteAsync( + public static async ValueTask>> DeleteAsync( ProtonDriveClient client, IEnumerable uids, CancellationToken cancellationToken) { var uidsByVolumeId = uids.GroupBy(x => x.VolumeId); - var results = new ConcurrentDictionary>(); + var results = new ConcurrentDictionary>(); var tasks = uidsByVolumeId.Select(async uidGroup => { @@ -340,7 +340,7 @@ await client.Cache.Entities.SetNodeAsync(uid, newNodeProvisionResult, membership { var uid = new NodeUid(uidGroup.Key, linkId); - var result = response.IsSuccess ? Result.Success : response.ErrorMessage; + var result = response.IsSuccess ? Result.Success : new ProtonApiException(response); results.TryAdd(uid, result); } @@ -367,7 +367,7 @@ await client.Cache.Entities.SetNodeAsync(uid, newNodeProvisionResult, membership { var request = new MultipleLinksNullaryRequest { LinkIds = batch }; - var aggregateResponse = await client.Api.Links.RestoreMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + var aggregateResponse = await client.Api.Trash.RestoreMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); foreach (var (linkId, response) in aggregateResponse.Responses) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 1c6af9d8..51be78f6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -87,7 +87,7 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid if (cachedNodeInfo is not var (nodeProvisionResult, membershipShareId, nameHashDigest) || !nodeProvisionResult.TryGetValue(out var node) || node is not FileNode fileNode) { - await _client.Cache.Entities.RemoveNodeAsync(revisionUid.NodeUid, cancellationToken); + await _client.Cache.Entities.RemoveNodeAsync(revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); return; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 4b43a13a..b349b5f4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -180,7 +180,7 @@ public ValueTask RenameNodeAsync(NodeUid uid, string newName, string? newMediaTy return NodeOperations.TrashAsync(this, uids, cancellationToken); } - public ValueTask>> DeleteNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + public ValueTask>> DeleteNodesAsync(IEnumerable uids, CancellationToken cancellationToken) { return NodeOperations.DeleteAsync(this, uids, cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 89b678b6..70a580a2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -115,7 +115,7 @@ public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, Cancella { var volumeId = await GetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); - await client.Api.Volumes.EmptyTrashAsync(volumeId, cancellationToken).ConfigureAwait(false); + await client.Api.Trash.EmptyAsync(volumeId, cancellationToken).ConfigureAwait(false); } private static VolumeCreationRequest GetCreationRequest( diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs index 19d9432c..457af629 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs @@ -18,7 +18,11 @@ public static void SetDomainAndCodes(Error error, Exception exception) case ProtonApiException ex: error.Domain = ErrorDomain.Api; error.PrimaryCode = (long)ex.Code; - error.SecondaryCode = ex.TransportCode; + if (ex.TransportCode is not null) + { + error.SecondaryCode = ex.TransportCode.Value; + } + break; case SocketException ex: diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiException.cs b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs index ac2370dc..65f1108a 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiException.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs @@ -26,6 +26,12 @@ internal ProtonApiException(HttpStatusCode statusCode, ApiResponse response) TransportCode = (int)statusCode; } + internal ProtonApiException(ApiResponse response) + : this(response.ErrorMessage) + { + Code = response.Code; + } + public ResponseCode Code { get; } - public int TransportCode { get; } + public int? TransportCode { get; } } diff --git a/cs/sdk/src/Proton.Sdk/Result{TError}.cs b/cs/sdk/src/Proton.Sdk/Result{TError}.cs index 4d716b1e..cae74e16 100644 --- a/cs/sdk/src/Proton.Sdk/Result{TError}.cs +++ b/cs/sdk/src/Proton.Sdk/Result{TError}.cs @@ -30,7 +30,7 @@ public static Result Failure(TError error) return new Result(error); } - public bool TryGetError([MaybeNullWhen(true)] out TError error) + public bool TryGetError([MaybeNullWhen(false)] out TError error) { error = _error; return IsFailure; From f5a5a16355908ae96af750adcafbbf6f1526a96f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 15 Oct 2025 18:47:01 +0200 Subject: [PATCH 254/791] Add support for 16KB pages and ARMv7 platform on Android --- cs/Directory.Build.props | 3 +++ cs/Directory.Packages.props | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index c543523c..245edf4e 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -5,6 +5,9 @@ true false + + true + Proton Drive Proton AG Proton AG diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index a11743f0..02e2f7a8 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -12,7 +12,7 @@ - + From 59b756d8b4200e1928f16407b876f5360f3ba4ba Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Oct 2025 08:08:34 +0200 Subject: [PATCH 255/791] Throw NodeWithSameNameExists from createFolder --- js/sdk/src/internal/nodes/apiService.test.ts | 64 ++++++++++++++++++++ js/sdk/src/internal/nodes/apiService.ts | 55 +++++++++++------ 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 8bcd6e0f..7dce09fe 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -1,3 +1,4 @@ +import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; import { MemberRole, NodeType } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService'; @@ -627,6 +628,69 @@ describe('nodeAPIService', () => { }); }); + describe('createFolder', () => { + it('should create folder', async () => { + apiMock.post = jest.fn().mockResolvedValue({ + Code: ErrorCode.OK, + Folder: { + ID: 'newNodeId', + }, + }); + + const result = await api.createFolder('volumeId~parentNodeId', { + armoredKey: 'armoredKey', + armoredHashKey: 'armoredHashKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', + signatureEmail: 'signatureEmail', + encryptedName: 'encryptedName', + hash: 'hash', + armoredExtendedAttributes: 'armoredExtendedAttributes', + }); + + expect(result).toEqual('volumeId~newNodeId'); + expect(apiMock.post).toHaveBeenCalledWith('drive/v2/volumes/volumeId/folders', { + ParentLinkID: 'parentNodeId', + NodeKey: 'armoredKey', + NodeHashKey: 'armoredHashKey', + NodePassphrase: 'armoredNodePassphrase', + NodePassphraseSignature: 'armoredNodePassphraseSignature', + SignatureEmail: 'signatureEmail', + Name: 'encryptedName', + Hash: 'hash', + XAttr: 'armoredExtendedAttributes', + }); + }); + + it('should throw NodeWithSameNameExistsValidationError if node already exists', async () => { + apiMock.post = jest.fn().mockRejectedValue( + new ValidationError('Node already exists', ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: 'existingNodeId', + }), + ); + + try { + await api.createFolder('volumeId~parentNodeId', { + armoredKey: 'armoredKey', + armoredHashKey: 'armoredHashKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', + signatureEmail: 'signatureEmail', + encryptedName: 'encryptedName', + hash: 'hash', + armoredExtendedAttributes: 'armoredExtendedAttributes', + }); + expect(false).toBeTruthy(); + } catch (error: unknown) { + expect(error).toBeInstanceOf(NodeWithSameNameExistsValidationError); + if (error instanceof NodeWithSameNameExistsValidationError) { + expect(error.code).toEqual(ErrorCode.ALREADY_EXISTS); + expect(error.existingNodeUid).toEqual('volumeId~existingNodeId'); + } + } + }); + }); + describe('renameNode', () => { it('should rename node', async () => { await api.renameNode( diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 12ed0da0..c11bdf57 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,11 +1,12 @@ import { c } from 'ttag'; -import { ProtonDriveError, ValidationError } from '../../errors'; +import { NodeWithSameNameExistsValidationError, ProtonDriveError, ValidationError } from '../../errors'; import { Logger, NodeResult } from '../../interface'; import { MemberRole, RevisionState } from '../../interface/nodes'; import { DriveAPIService, drivePaths, + ErrorCode, InvalidRequirementsAPIError, isCodeOk, nodeTypeNumberToNodeType, @@ -400,7 +401,7 @@ export class NodeAPIService { ); // TODO: remove `as` when backend fixes OpenAPI schema. - yield * handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); } } @@ -449,21 +450,41 @@ export class NodeAPIService { ): Promise { const { volumeId, nodeId: parentId } = splitNodeUid(parentUid); - const response = await this.apiService.post( - `drive/v2/volumes/${volumeId}/folders`, - { - ParentLinkID: parentId, - NodeKey: newNode.armoredKey, - NodeHashKey: newNode.armoredHashKey, - NodePassphrase: newNode.armoredNodePassphrase, - NodePassphraseSignature: newNode.armoredNodePassphraseSignature, - SignatureEmail: newNode.signatureEmail, - Name: newNode.encryptedName, - Hash: newNode.hash, - // @ts-expect-error: XAttr is optional as undefined. - XAttr: newNode.armoredExtendedAttributes, - }, - ); + let response: PostCreateFolderResponse; + try { + response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/folders`, + { + ParentLinkID: parentId, + NodeKey: newNode.armoredKey, + NodeHashKey: newNode.armoredHashKey, + NodePassphrase: newNode.armoredNodePassphrase, + NodePassphraseSignature: newNode.armoredNodePassphraseSignature, + SignatureEmail: newNode.signatureEmail, + Name: newNode.encryptedName, + Hash: newNode.hash, + // @ts-expect-error: XAttr is optional as undefined. + XAttr: newNode.armoredExtendedAttributes, + }, + ); + } catch (error: unknown) { + if (error instanceof ValidationError) { + if (error.code === ErrorCode.ALREADY_EXISTS) { + const typedDetails = error.details as + | { + ConflictLinkID: string; + } + | undefined; + + const existingNodeUid = typedDetails?.ConflictLinkID + ? makeNodeUid(volumeId, typedDetails.ConflictLinkID) + : undefined; + + throw new NodeWithSameNameExistsValidationError(error.message, error.code, existingNodeUid); + } + } + throw error; + } return makeNodeUid(volumeId, response.Folder.ID); } From d82509060281cd3368953225b057b08e79508def Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 15 Oct 2025 07:44:06 +0200 Subject: [PATCH 256/791] Return new UID of copied node --- js/sdk/src/interface/index.ts | 1 + js/sdk/src/interface/nodes.ts | 3 +++ .../src/internal/nodes/nodesManagement.test.ts | 4 +++- js/sdk/src/internal/nodes/nodesManagement.ts | 16 +++++++++++----- js/sdk/src/protonDriveClient.ts | 3 ++- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 19bcb6b0..cfb99d87 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -41,6 +41,7 @@ export type { NodeOrUid, RevisionOrUid, NodeResult, + NodeResultWithNewUid, Membership, } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 1917f5b5..4ddee408 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -224,3 +224,6 @@ export type NodeOrUid = MaybeNode | NodeEntity | DegradedNode | string; export type RevisionOrUid = Revision | string; export type NodeResult = { uid: string; ok: true } | { uid: string; ok: false; error: string }; +export type NodeResultWithNewUid = + | { uid: string; newUid: string; ok: true } + | { uid: string; ok: false; error: string }; diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index b8ce5ca3..4868bd65 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -50,7 +50,7 @@ describe('NodesManagement', () => { apiService = { renameNode: jest.fn(), moveNode: jest.fn(), - copyNode: jest.fn(), + copyNode: jest.fn().mockResolvedValue('newCopiedNodeUid'), trashNodes: jest.fn(async function* (uids) { yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult); }), @@ -251,6 +251,7 @@ describe('NodesManagement', () => { expect(newNode).toEqual({ ...nodes.nodeUid, + uid: 'newCopiedNodeUid', parentUid: 'newParentNodeUid', encryptedName: 'copiedArmoredNodeName', hash: 'copiedHash', @@ -307,6 +308,7 @@ describe('NodesManagement', () => { ); expect(newNode).toEqual({ ...nodes.anonymousNodeUid, + uid: 'newCopiedNodeUid', parentUid: 'newParentNodeUid', encryptedName: 'copiedArmoredNodeName', hash: 'copiedHash', diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index cdaa62f1..4d01ce89 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { MemberRole, NodeType, NodeResult, resultOk } from '../../interface'; +import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk } from '../../interface'; import { AbortError, ValidationError } from '../../errors'; import { getErrorMessage } from '../errors'; import { splitNodeUid } from '../uids'; @@ -182,16 +182,21 @@ export class NodesManagement { return newNode; } - // Improvement requested: copy nodes in parallel - async *copyNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + // Improvement requested: copy nodes in parallel using copy_multiple endpoint + async *copyNodes( + nodeUids: string[], + newParentNodeUid: string, + signal?: AbortSignal, + ): AsyncGenerator { for (const nodeUid of nodeUids) { if (signal?.aborted) { throw new AbortError(c('Error').t`Copy operation aborted`); } try { - await this.copyNode(nodeUid, newParentNodeUid); + const { uid: newNodeUid } = await this.copyNode(nodeUid, newParentNodeUid); yield { uid: nodeUid, + newUid: newNodeUid, ok: true, }; } catch (error: unknown) { @@ -237,7 +242,7 @@ export class NodesManagement { signatureEmail: encryptedCrypto.signatureEmail, armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, }; - await this.apiService.copyNode(nodeUid, { + const newNodeUid = await this.apiService.copyNode(nodeUid, { ...keySignatureProperties, parentUid: newParentUid, armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, @@ -247,6 +252,7 @@ export class NodesManagement { }); const newNode: DecryptedNode = { ...node, + uid: newNodeUid, encryptedName: encryptedCrypto.encryptedName, parentUid: newParentUid, hash: encryptedCrypto.hash, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 7c96b1a0..e7bd3655 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -7,6 +7,7 @@ import { MaybeNode, MaybeMissingNode, NodeResult, + NodeResultWithNewUid, Revision, RevisionOrUid, ShareNodeSettings, @@ -408,7 +409,7 @@ export class ProtonDriveClient { nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { this.logger.info(`Copying ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`); yield* this.nodes.management.copyNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); } From 036251fedcee4ba44f2b9398aa71c21cdba4c40f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 15 Oct 2025 16:07:10 +0200 Subject: [PATCH 257/791] Align JSON output of the C# CLI with the JavaScript one --- cs/Directory.Packages.props | 28 +-- .../AlternateFileNameGenerator.cs | 12 + .../Files}/CommonExtendedAttributes.cs | 4 +- .../Files}/ExtendedAttributes.cs | 2 +- .../Api/Files/FileContentDigestsDto.cs | 11 + .../Api/Files/FileCreationRequest.cs | 3 + .../Api/Files/TrashApiClient.cs | 8 + .../Api/Folders/FolderCreationRequest.cs | 3 + .../Api/Links/ILinksApiClient.cs | 6 + .../Api/Links/ITrashApiClient.cs | 5 +- .../Api/Links/LinksApiClient.cs | 16 ++ .../Api/Links/NodeCreationRequest.cs | 3 - .../Api/Links/NodeNameAvailabilityRequest.cs | 12 + .../Api/Links/NodeNameAvailabilityResponse.cs | 13 ++ .../Api/Volumes/IVolumesApiClient.cs | 2 - .../Api/Volumes/VolumesApiClient.cs | 7 - .../NodeWithSameNameExistsException.cs | 21 +- .../Cryptography/FileDecryptionResult.cs | 1 + .../Nodes/DegradedFileNode.cs | 1 + .../Nodes/DegradedFolderNode.cs | 1 + .../Proton.Drive.Sdk/Nodes/DegradedNode.cs | 4 + .../Nodes/Download/RevisionReader.cs | 15 +- .../Nodes/DtoToMetadataConverter.cs | 28 ++- .../Nodes/FileContentDigests.cs | 6 + .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 107 +-------- .../Nodes/FolderOperations.cs | 1 + cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 4 + .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 44 +++- cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 7 +- .../src/Proton.Drive.Sdk/Nodes/RevisionUid.cs | 2 +- .../Nodes/Upload/BlockUploader.cs | 2 +- .../Nodes/Upload/FileUploader.cs | 11 +- .../Nodes/Upload/NewFileDraftProvider.cs | 153 ++++++++++++- .../Nodes/Upload/RevisionWriter.cs | 214 ++++++++++-------- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 7 + .../DriveApiSerializerContext.cs | 3 +- .../DriveEntitiesSerializerContext.cs | 1 + .../Volumes/VolumeOperations.cs | 3 +- .../Authentication/TokenCredential.cs | 2 +- .../Caching/SqliteCacheRepository.cs | 2 + cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 2 +- .../ForgivingBytesToHexJsonConverter.cs | 1 + 42 files changed, 496 insertions(+), 282 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/AlternateFileNameGenerator.cs rename cs/sdk/src/Proton.Drive.Sdk/{Nodes => Api/Files}/CommonExtendedAttributes.cs (67%) rename cs/sdk/src/Proton.Drive.Sdk/{Nodes => Api/Files}/ExtendedAttributes.cs (72%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileContentDigestsDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityRequest.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 02e2f7a8..1a82a75b 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -3,31 +3,31 @@ true - - - - - + + + + + - + - - - + + + - + - + - - - + + + \ No newline at end of file diff --git a/cs/sdk/src/Proton.Drive.Sdk/AlternateFileNameGenerator.cs b/cs/sdk/src/Proton.Drive.Sdk/AlternateFileNameGenerator.cs new file mode 100644 index 00000000..7af31c0a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/AlternateFileNameGenerator.cs @@ -0,0 +1,12 @@ +namespace Proton.Drive.Sdk; + +internal static class AlternateFileNameGenerator +{ + public static IEnumerable GetNames(string originalName) + { + var nameWithoutExtension = Path.GetFileNameWithoutExtension(originalName); + var extension = originalName[nameWithoutExtension.Length..]; + + return Enumerable.Range(1, int.MaxValue).Select(i => $"{nameWithoutExtension} ({i}){extension}"); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CommonExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs similarity index 67% rename from cs/sdk/src/Proton.Drive.Sdk/Nodes/CommonExtendedAttributes.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs index 7e9f0bd4..f2cd1514 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CommonExtendedAttributes.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs @@ -1,4 +1,4 @@ -namespace Proton.Drive.Sdk.Nodes; +namespace Proton.Drive.Sdk.Api.Files; internal sealed class CommonExtendedAttributes { @@ -7,4 +7,6 @@ internal sealed class CommonExtendedAttributes public DateTime? ModificationTime { get; init; } public IReadOnlyList? BlockSizes { get; init; } + + public FileContentDigestsDto? Digests { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs similarity index 72% rename from cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributes.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs index 56793154..e1006d39 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributes.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs @@ -1,4 +1,4 @@ -namespace Proton.Drive.Sdk.Nodes; +namespace Proton.Drive.Sdk.Api.Files; internal readonly struct ExtendedAttributes { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileContentDigestsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileContentDigestsDto.cs new file mode 100644 index 00000000..a26523d4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileContentDigestsDto.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal readonly struct FileContentDigestsDto +{ + [JsonPropertyName("SHA1")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public ReadOnlyMemory? Sha1 { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs index 81014866..1aed5436 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs @@ -17,4 +17,7 @@ internal sealed class FileCreationRequest : NodeCreationRequest public string? ClientUid { get; init; } public long? IntendedUploadSize { get; init; } + + [JsonPropertyName("SignatureAddress")] + public required string SignatureEmailAddress { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs index 631c66bd..8261765d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs @@ -1,4 +1,5 @@ using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Api; @@ -11,6 +12,13 @@ internal sealed class TrashApiClient(HttpClient httpClient) : ITrashApiClient { private readonly HttpClient _httpClient = httpClient; + public async ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.VolumeTrashResponse) + .GetAsync($"volumes/{volumeId}/trash?pageSize={pageSize}&page={page}", cancellationToken).ConfigureAwait(false); + } + public async ValueTask> TrashMultipleAsync( VolumeId volumeId, MultipleLinksNullaryRequest request, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs index 49b1bb5d..c5b8f33e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs @@ -8,4 +8,7 @@ internal sealed class FolderCreationRequest : NodeCreationRequest { [JsonPropertyName("NodeHashKey")] public required PgpArmoredMessage HashKey { get; init; } + + [JsonPropertyName("SignatureEmail")] + public required string SignatureEmailAddress { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs index 8592782d..1d539186 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs @@ -19,4 +19,10 @@ ValueTask> DeleteMultipleAsync( VolumeId volumeId, MultipleLinksNullaryRequest request, CancellationToken cancellationToken); + + ValueTask GetAvailableNames( + VolumeId volumeId, + LinkId folderId, + NodeNameAvailabilityRequest request, + CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs index f1a2948d..1071b617 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs @@ -1,10 +1,13 @@ -using Proton.Drive.Sdk.Volumes; +using Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Api.Links; internal interface ITrashApiClient { + ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken); + ValueTask> TrashMultipleAsync( VolumeId volumeId, MultipleLinksNullaryRequest request, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs index 108eb771..03e6b76b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -63,4 +63,20 @@ public async ValueTask> DeleteMultipleA cancellationToken) .ConfigureAwait(false); } + + public async ValueTask GetAvailableNames( + VolumeId volumeId, + LinkId folderId, + NodeNameAvailabilityRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.NodeNameAvailabilityResponse) + .PostAsync( + $"v2/volumes/{volumeId}/links/{folderId}/checkAvailableHashes", + request, + DriveApiSerializerContext.Default.NodeNameAvailabilityRequest, + cancellationToken) + .ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs index 2d7eb3ac..1b271203 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs @@ -21,9 +21,6 @@ internal abstract class NodeCreationRequest [JsonPropertyName("NodePassphraseSignature")] public required PgpArmoredSignature PassphraseSignature { get; init; } - [JsonPropertyName("SignatureAddress")] - public required string SignatureEmailAddress { get; init; } - [JsonPropertyName("NodeKey")] public required PgpArmoredPrivateKey Key { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityRequest.cs new file mode 100644 index 00000000..e170a8ce --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class NodeNameAvailabilityRequest +{ + [JsonPropertyName("Hashes")] + public required IReadOnlyCollection NameHashDigests { get; init; } + + [JsonPropertyName("ClientUID")] + public required IEnumerable ClientUid { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityResponse.cs new file mode 100644 index 00000000..16957e7c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class NodeNameAvailabilityResponse : ApiResponse +{ + [JsonPropertyName("AvailableHashes")] + public required IReadOnlyList AvailableNameHashDigests { get; init; } + + [JsonPropertyName("PendingHashes")] + public required IReadOnlyList UnavailableNameHashDigests { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs index 71d68632..3b9a4057 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs @@ -7,6 +7,4 @@ internal interface IVolumesApiClient ValueTask CreateVolumeAsync(VolumeCreationRequest request, CancellationToken cancellationToken); ValueTask GetVolumeAsync(VolumeId volumeId, CancellationToken cancellationToken); - - ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs index d188ae8f..c4c157f4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs @@ -21,11 +21,4 @@ public async ValueTask GetVolumeAsync(VolumeId volumeId, Cancell .Expecting(DriveApiSerializerContext.Default.VolumeResponse) .GetAsync($"volumes/{volumeId}", cancellationToken).ConfigureAwait(false); } - - public async ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken) - { - return await _httpClient - .Expecting(DriveApiSerializerContext.Default.VolumeTrashResponse) - .GetAsync($"volumes/{volumeId}/trash?pageSize={pageSize}&page={page}", cancellationToken).ConfigureAwait(false); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs index 25e05fc4..98cb65eb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs @@ -30,11 +30,26 @@ internal NodeWithSameNameExistsException(VolumeId volumeId, ProtonApiException Uid.VolumeId.ToString(); + public required Result Name { get; init; } public required Result NameAuthor { get; init; } + public required DateTime CreationTime { get; init; } + public DateTime? TrashTime { get; init; } public required Result Author { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index f61a279e..6239e196 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -11,9 +11,9 @@ internal sealed class RevisionReader : IDisposable public const int DefaultBlockPageSize = 10; private readonly ProtonDriveClient _client; + private readonly PgpPrivateKey _nodeKey; private readonly NodeUid _fileUid; private readonly RevisionId _revisionId; - private readonly PgpPrivateKey _fileKey; private readonly PgpSessionKey _contentKey; private readonly BlockListingRevisionDto _revisionDto; private readonly Action _releaseBlockListingAction; @@ -26,16 +26,16 @@ internal sealed class RevisionReader : IDisposable internal RevisionReader( ProtonDriveClient client, RevisionUid revisionUid, - PgpPrivateKey fileKey, + PgpPrivateKey nodeKey, PgpSessionKey contentKey, BlockListingRevisionDto revisionDto, Action releaseBlockListingAction, int blockPageSize = DefaultBlockPageSize) { _client = client; + _nodeKey = nodeKey; _fileUid = revisionUid.NodeUid; _revisionId = revisionUid.RevisionId; - _fileKey = fileKey; _contentKey = contentKey; _revisionDto = revisionDto; _releaseBlockListingAction = releaseBlockListingAction; @@ -278,12 +278,9 @@ private async Task VerifyManifestAsync(Stream manifestStr return PgpVerificationStatus.NotSigned; } - if (string.IsNullOrEmpty(_revisionDto.SignatureEmailAddress)) - { - return PgpVerificationStatus.NoVerifier; - } - - var verificationKeys = await _client.Account.GetAddressPublicKeysAsync(_revisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + var verificationKeys = string.IsNullOrEmpty(_revisionDto.SignatureEmailAddress) + ? [_nodeKey.ToPublic()] + : await _client.Account.GetAddressPublicKeysAsync(_revisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); if (verificationKeys.Count == 0) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index a810dcf8..d8cbc24c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -84,6 +84,7 @@ public static async ValueTask> Co ParentUid = parentUid, Name = nameResult, NameAuthor = default, + CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, Author = default, Errors = null!, // FIXME @@ -127,6 +128,7 @@ public static async ValueTask> Co // FIXME: combine with verification failure from name hash key Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure), + CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, }; @@ -181,6 +183,7 @@ public static async Task> ConvertDtoT ParentUid = parentUid, Name = nameResult, NameAuthor = default, + CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, Author = default, MediaType = fileDto.MediaType, @@ -223,6 +226,18 @@ public static async Task> ConvertDtoT var extendedAttributes = extendedAttributesOutput.Data; + var activeRevision = new Revision + { + Uid = new RevisionUid(uid, activeRevisionDto.Id), + CreationTime = activeRevisionDto.CreationTime, + SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, + ClaimedSize = extendedAttributes?.Common?.Size, + ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, + ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, + Thumbnails = [], // FIXME: thumbnails + ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure), + }; + var node = new FileNode { Uid = uid, @@ -232,18 +247,10 @@ public static async Task> ConvertDtoT // FIXME: combine with verification failure from name hash key Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure), + CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, MediaType = fileDto.MediaType, - ActiveRevision = new Revision - { - Uid = new RevisionUid(uid, activeRevisionDto.Id), - CreationTime = activeRevisionDto.CreationTime, - SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, - ClaimedSize = extendedAttributes?.Common?.Size, - ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, - Thumbnails = [], // FIXME: thumbnails - ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure), - }, + ActiveRevision = activeRevision, TotalSizeOnCloudStorage = fileDto.TotalStorageQuotaUsage, }; @@ -350,6 +357,7 @@ private static async ValueTask> GetParen } } +// FIXME: remove this public sealed class TempDebugException : Exception { public TempDebugException(string message) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs new file mode 100644 index 00000000..63fb217c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +public readonly struct FileContentDigests +{ + public ReadOnlyMemory? Sha1 { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 1070cbb0..3244d384 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -1,114 +1,9 @@ -using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk.Api.Files; -using Proton.Sdk; -using Proton.Sdk.Api; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; internal static class FileOperations { - private const int MaxNumberOfDraftCreationAttempts = 3; - - public static async Task<(RevisionUid RevisionUid, FileSecrets FileSecrets)> CreateDraftAsync( - ProtonDriveClient client, - NodeUid parentUid, - string name, - string mediaType, - bool overrideExistingDraftByOtherClient, - CancellationToken cancellationToken) - { - var parentSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); - - var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, parentUid, cancellationToken).ConfigureAwait(false); - - var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - - NodeOperations.GetCommonCreationParameters( - name, - parentSecrets.Key, - parentSecrets.HashKey.Span, - signingKey, - out var key, - out var nameSessionKey, - out var passphraseSessionKey, - out var encryptedName, - out var nameHashDigest, - out var encryptedKeyPassphrase, - out var passphraseSignature, - out var lockedKeyBytes); - - var contentKey = PgpSessionKey.Generate(); - var (contentKeyToken, _) = contentKey.Export(); - - var request = new FileCreationRequest - { - ClientUid = client.Uid, - Name = encryptedName, - NameHashDigest = nameHashDigest, - ParentLinkId = parentUid.LinkId, - Passphrase = encryptedKeyPassphrase, - PassphraseSignature = passphraseSignature, - SignatureEmailAddress = membershipAddress.EmailAddress, - Key = lockedKeyBytes, - MediaType = mediaType, - ContentKeyPacket = key.EncryptSessionKey(contentKey), - ContentKeyPacketSignature = key.Sign(contentKeyToken), - }; - - FileCreationResponse? response = null; - var remainingNumberOfAttempts = MaxNumberOfDraftCreationAttempts; - - while (response is null) - { - try - { - response = await client.Api.Files.CreateFileAsync(parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); - } - catch (ProtonApiException e) - when (e.Response is { Conflict: { LinkId: { } linkId, RevisionId: null, DraftRevisionId: not null } } - && (e.Response.Conflict.DraftClientUid == client.Uid || overrideExistingDraftByOtherClient)) - { - var uidOfNodeToDelete = new NodeUid(parentUid.VolumeId, linkId); - - var deletionResults = await NodeOperations.DeleteAsync(client, [uidOfNodeToDelete], cancellationToken).ConfigureAwait(false); - - if (!deletionResults.TryGetValue(uidOfNodeToDelete, out var deletionResult)) - { - throw new ProtonApiException("Missing deletion result in response"); - } - - if (deletionResult.TryGetError(out var deletionException) && deletionException is not ProtonApiException { Code: ResponseCode.DoesNotExist }) - { - throw deletionException; - } - - if (--remainingNumberOfAttempts <= 0) - { - throw; - } - } - catch (ProtonApiException e) - { - throw new NodeWithSameNameExistsException(parentUid.VolumeId, e); - } - } - - var draftNodeUid = new NodeUid(parentUid.VolumeId, response.Identifiers.LinkId); - var draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); - - var fileSecrets = new FileSecrets - { - Key = key, - PassphraseSessionKey = passphraseSessionKey, - NameSessionKey = nameSessionKey, - ContentKey = contentKey, - }; - - await client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); - - return (draftRevisionUid, fileSecrets); - } - public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) { var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index fe67c081..1841b136 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -114,6 +114,7 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, Name = name, NameAuthor = author, Author = author, + CreationTime = DateTime.UtcNow, }; await client.Cache.Entities.SetNodeAsync(folderUid, folderNode, membershipShareId: null, nameHashDigest, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index 543a1661..080ae6e0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -13,8 +13,12 @@ public abstract record Node public required NodeUid? ParentUid { get; init; } + public string TreeEventScopeId => Uid.VolumeId.ToString(); + public required string Name { get; init; } + public required DateTime CreationTime { get; init; } + public DateTime? TrashTime { get; init; } public required Result NameAuthor { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 7aa67311..71748fed 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -383,10 +383,52 @@ public static async ValueTask>> D await Task.WhenAll(tasks).ConfigureAwait(false); // FIXME: remove trash time from cache - return results; } + public static async ValueTask GetAvailableNameAsync(ProtonDriveClient client, NodeUid parentUid, string name, CancellationToken cancellationToken) + { + const int batchSize = 10; + + var folderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + + var digestsToNamesMap = new Dictionary(batchSize); + + using var batchEnumerator = client.GetAlternateFileNames.Invoke(name).Prepend(name).Chunk(10).GetEnumerator(); + + string? availableName = null; + + while (availableName is null) + { + digestsToNamesMap.Clear(); + + batchEnumerator.MoveNext(); + + foreach (var candidateName in batchEnumerator.Current) + { + var digest = Convert.ToHexStringLower(NodeCrypto.HashNodeName(candidateName, folderSecrets.HashKey.Span)); + digestsToNamesMap[digest] = candidateName; + } + + var nameAvailabilityRequest = new NodeNameAvailabilityRequest { ClientUid = [client.Uid], NameHashDigests = digestsToNamesMap.Keys }; + + var response = await client.Api.Links.GetAvailableNames(parentUid.VolumeId, parentUid.LinkId, nameAvailabilityRequest, cancellationToken) + .ConfigureAwait(false); + + if (response.AvailableNameHashDigests.Count == 0) + { + continue; + } + + if (!digestsToNamesMap.TryGetValue(response.AvailableNameHashDigests[0], out availableName)) + { + throw new KeyNotFoundException("Unknown name hash digest received"); + } + } + + return availableName; + } + public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) { // FIXME: try to get the information from cache first diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index 4cc3ac31..b533330b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -7,8 +7,9 @@ public sealed record Revision public required RevisionUid Uid { get; init; } public required DateTime CreationTime { get; init; } public required long SizeOnCloudStorage { get; init; } - public required long? ClaimedSize { get; init; } - public required DateTime? ClaimedModificationTime { get; init; } + public long? ClaimedSize { get; init; } + public required FileContentDigests ClaimedDigests { get; init; } + public DateTime? ClaimedModificationTime { get; init; } public required IReadOnlyList> Thumbnails { get; init; } - public required Result? ContentAuthor { get; init; } + public Result? ContentAuthor { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs index 36f0d1bb..01d32b79 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs @@ -14,7 +14,7 @@ internal RevisionUid(NodeUid nodeUid, RevisionId revisionId) RevisionId = revisionId; } - public NodeUid NodeUid { get; } + internal NodeUid NodeUid { get; } internal RevisionId RevisionId { get; } public override string ToString() diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 1a991553..d8fffbb7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -42,7 +42,7 @@ public async Task UploadContentAsync( IBlockVerifier verifier, byte[] plainDataPrefix, int plainDataPrefixLength, - Action onBlockProgress, + Action? onBlockProgress, Action releaseBlocksAction, CancellationToken cancellationToken) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 51be78f6..bbc908df 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -28,7 +28,7 @@ internal FileUploader( public UploadController UploadFromStream( Stream contentStream, IEnumerable thumbnails, - Action onProgress, + Action? onProgress, CancellationToken cancellationToken) { var task = UploadFromStreamAsync(contentStream, thumbnails, onProgress, cancellationToken); @@ -39,7 +39,7 @@ public UploadController UploadFromStream( public UploadController UploadFromFile( string filePath, IEnumerable thumbnails, - Action onProgress, + Action? onProgress, CancellationToken cancellationToken) { var task = UploadFromFileAsync(filePath, thumbnails, onProgress, cancellationToken); @@ -61,7 +61,7 @@ public void Dispose() private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, - Action onProgress, + Action? onProgress, CancellationToken cancellationToken) { var (draftRevisionUid, fileSecrets) = await _fileDraftProvider.GetDraftAsync(_client, cancellationToken).ConfigureAwait(false); @@ -98,6 +98,7 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid Uid = revisionUid, ClaimedSize = size, ClaimedModificationTime = _lastModificationTime?.UtcDateTime, + // FIXME: update remaining metadata in cache, but this is not critical because this metadata will soon be invalidated by the event anyway }, }; @@ -108,7 +109,7 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromFileAsync( string filePath, IEnumerable thumbnails, - Action onProgress, + Action? onProgress, CancellationToken cancellationToken) { var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); @@ -125,7 +126,7 @@ private async ValueTask UploadAsync( Stream contentStream, IEnumerable thumbnails, DateTimeOffset? lastModificationTime, - Action onProgress, + Action? onProgress, CancellationToken cancellationToken) { using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionUid, fileSecrets, ReleaseBlocks, cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index bd43cc68..b2e33849 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -1,34 +1,165 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; +using Proton.Sdk.Api; + namespace Proton.Drive.Sdk.Nodes.Upload; internal sealed class NewFileDraftProvider : IFileDraftProvider { - private readonly NodeUid _parentFolderUid; + private const int MaxNumberOfDraftCreationAttempts = 3; + + private readonly NodeUid _parentUid; private readonly string _name; private readonly string _mediaType; private readonly bool _overrideExistingDraftByOtherClient; internal NewFileDraftProvider( - NodeUid parentFolderUid, + NodeUid parentUid, string name, string mediaType, bool overrideExistingDraftByOtherClient) { - _parentFolderUid = parentFolderUid; + _parentUid = parentUid; _name = name; _mediaType = mediaType; _overrideExistingDraftByOtherClient = overrideExistingDraftByOtherClient; } - public async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync( + public async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var parentSecrets = await FolderOperations.GetSecretsAsync(client, _parentUid, cancellationToken).ConfigureAwait(false); + + var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, _parentUid, cancellationToken).ConfigureAwait(false); + + var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + + var (response, fileSecrets) = await CreateDraftAsync(client, parentSecrets, signingKey, membershipAddress.EmailAddress, cancellationToken) + .ConfigureAwait(false); + + var draftNodeUid = new NodeUid(_parentUid.VolumeId, response.Identifiers.LinkId); + var draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); + + await client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); + + return (draftRevisionUid, fileSecrets); + } + + private static FileCreationRequest GetFileCreationRequest( + string clientUid, + NodeUid parentUid, + string name, + string mediaType, + FolderSecrets parentSecrets, + PgpPrivateKey signingKey, + string membershipEmailAddress, + out PgpPrivateKey nodeKey, + out PgpSessionKey passphraseSessionKey, + out PgpSessionKey nameSessionKey, + out PgpSessionKey contentKey) + { + NodeOperations.GetCommonCreationParameters( + name, + parentSecrets.Key, + parentSecrets.HashKey.Span, + signingKey, + out nodeKey, + out nameSessionKey, + out passphraseSessionKey, + out var encryptedName, + out var nameHashDigest, + out var encryptedKeyPassphrase, + out var passphraseSignature, + out var lockedKeyBytes); + + contentKey = PgpSessionKey.Generate(); + var (contentKeyToken, _) = contentKey.Export(); + + return new FileCreationRequest + { + ClientUid = clientUid, + Name = encryptedName, + NameHashDigest = nameHashDigest, + ParentLinkId = parentUid.LinkId, + Passphrase = encryptedKeyPassphrase, + PassphraseSignature = passphraseSignature, + SignatureEmailAddress = membershipEmailAddress, + Key = lockedKeyBytes, + MediaType = mediaType, + ContentKeyPacket = nodeKey.EncryptSessionKey(contentKey), + ContentKeyPacketSignature = nodeKey.Sign(contentKeyToken), + }; + } + + private async ValueTask<(FileCreationResponse Response, FileSecrets FileSecrets)> CreateDraftAsync( ProtonDriveClient client, + FolderSecrets parentSecrets, + PgpPrivateKey signingKey, + string membershipEmailAddress, CancellationToken cancellationToken) { - return await FileOperations.CreateDraftAsync( - client, - _parentFolderUid, - _name, - _mediaType, - _overrideExistingDraftByOtherClient, - cancellationToken).ConfigureAwait(false); + var remainingNumberOfAttempts = MaxNumberOfDraftCreationAttempts; + + (FileCreationResponse Response, FileSecrets FileSecrets)? result = null; + + while (result is null) + { + var request = GetFileCreationRequest( + client.Uid, + _parentUid, + _name, + _mediaType, + parentSecrets, + signingKey, + membershipEmailAddress, + out var nodeKey, + out var passphraseSessionKey, + out var nameSessionKey, + out var contentKey); + + try + { + var response = await client.Api.Files.CreateFileAsync(_parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); + + var fileSecrets = new FileSecrets + { + Key = nodeKey, + PassphraseSessionKey = passphraseSessionKey, + NameSessionKey = nameSessionKey, + ContentKey = contentKey, + }; + + result = (response, fileSecrets); + } + catch (ProtonApiException e) + when (e.Response is { Conflict: { LinkId: { } conflictingLinkId, RevisionId: null, DraftRevisionId: not null } } + && (e.Response.Conflict.DraftClientUid == client.Uid || _overrideExistingDraftByOtherClient)) + { + var conflictingNodeUid = new NodeUid(_parentUid.VolumeId, conflictingLinkId); + + var deletionResults = await NodeOperations.DeleteAsync(client, [conflictingNodeUid], cancellationToken).ConfigureAwait(false); + + if (!deletionResults.TryGetValue(conflictingNodeUid, out var deletionResult)) + { + throw new ProtonApiException("Missing deletion result in response"); + } + + if (deletionResult.TryGetError(out var deletionException) && deletionException is not ProtonApiException { Code: ResponseCode.DoesNotExist }) + { + throw deletionException; + } + + if (--remainingNumberOfAttempts <= 0) + { + throw; + } + } + catch (ProtonApiException e) + { + throw new NodeWithSameNameExistsException(_parentUid.VolumeId, e); + } + } + + return result.Value; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 19420eb1..207a49c8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Security.Cryptography; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.IO; @@ -56,7 +57,7 @@ public async ValueTask WriteAsync( Stream contentStream, IEnumerable thumbnails, DateTimeOffset? lastModificationTime, - Action onProgress, + Action? onProgress, CancellationToken cancellationToken) { long numberOfBytesUploaded = 0; @@ -66,124 +67,137 @@ public async ValueTask WriteAsync( var uploadTasks = new Queue>(_client.BlockUploader.MaxDegreeOfParallelism); var blockIndex = 0; - // TODO: provide capacity - var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - ArraySegment manifestSignature; var blockSizes = new List(8); - await using (manifestStream.ConfigureAwait(false)) - { - var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_fileUid, _revisionId, _fileKey, cancellationToken).ConfigureAwait(false); + using var sha1 = SHA1.Create(); - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var linkedCancellationToken = cancellationTokenSource.Token; + var hashingContentStream = new CryptoStream(contentStream, sha1, CryptoStreamMode.Read, leaveOpen: true); - try + await using (hashingContentStream.ConfigureAwait(false)) + { + // TODO: provide capacity + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + await using (manifestStream.ConfigureAwait(false)) { + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_fileUid, _revisionId, _fileKey, cancellationToken).ConfigureAwait(false); + + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var linkedCancellationToken = cancellationTokenSource.Token; + try { - foreach (var thumbnail in thumbnails) + try { - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); - - var uploadTask = _client.BlockUploader.UploadThumbnailAsync( - _fileUid, - _revisionId, - _contentKey, - _signingKey, - _membershipAddress.Id, - thumbnail, - onProgress: null, - cancellationTokenSource.Token); - - uploadTasks.Enqueue(uploadTask); + foreach (var thumbnail in thumbnails) + { + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); + + var uploadTask = _client.BlockUploader.UploadThumbnailAsync( + _fileUid, + _revisionId, + _contentKey, + _signingKey, + _membershipAddress.Id, + thumbnail, + onProgress: null, + cancellationTokenSource.Token); + + uploadTasks.Enqueue(uploadTask); + } + + if (contentStream.Length > 0) + { + do + { + var plainDataPrefixBuffer = ArrayPool.Shared.Rent(blockVerifier.DataPacketPrefixMaxLength); + try + { + var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + var bytesCopied = await hashingContentStream.PartiallyCopyToAsync( + plainDataStream, + _targetBlockSize, + plainDataPrefixBuffer, + linkedCancellationToken).ConfigureAwait(false); + + blockSizes.Add((int)plainDataStream.Length); + + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); + + plainDataStream.Seek(0, SeekOrigin.Begin); + + var onBlockProgress = onProgress is not null + ? progress => + { + numberOfBytesUploaded += progress; + onProgress(numberOfBytesUploaded, contentStream.Length); + } + : default(Action?); + + var uploadTask = _client.BlockUploader.UploadContentAsync( + _fileUid, + _revisionId, + ++blockIndex, + _contentKey, + _signingKey, + _membershipAddress.Id, + _fileKey, + plainDataStream, + blockVerifier, + plainDataPrefixBuffer, + Math.Min(blockVerifier.DataPacketPrefixMaxLength, bytesCopied), + onBlockProgress, + _releaseBlocksAction, + linkedCancellationToken); + + uploadTasks.Enqueue(uploadTask); + } + catch + { + ArrayPool.Shared.Return(plainDataPrefixBuffer); + throw; + } + } while (contentStream.Position < contentStream.Length); + } + } + finally + { + _releaseFileAction.Invoke(); + _semaphoreReleased = true; } - if (contentStream.Length > 0) + while (uploadTasks.Count > 0) { - do - { - var plainDataPrefixBuffer = ArrayPool.Shared.Rent(blockVerifier.DataPacketPrefixMaxLength); - try - { - var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - - var bytesCopied = await contentStream.PartiallyCopyToAsync( - plainDataStream, - _targetBlockSize, - plainDataPrefixBuffer, - linkedCancellationToken).ConfigureAwait(false); - - blockSizes.Add((int)plainDataStream.Length); - - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); - - plainDataStream.Seek(0, SeekOrigin.Begin); - - var uploadTask = _client.BlockUploader.UploadContentAsync( - _fileUid, - _revisionId, - ++blockIndex, - _contentKey, - _signingKey, - _membershipAddress.Id, - _fileKey, - plainDataStream, - blockVerifier, - plainDataPrefixBuffer, - Math.Min(blockVerifier.DataPacketPrefixMaxLength, bytesCopied), - progress => - { - numberOfBytesUploaded += progress; - onProgress(numberOfBytesUploaded, contentStream.Length); - }, - _releaseBlocksAction, - linkedCancellationToken); - - uploadTasks.Enqueue(uploadTask); - } - catch - { - ArrayPool.Shared.Return(plainDataPrefixBuffer); - throw; - } - } while (contentStream.Position < contentStream.Length); + await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); } } - finally + catch { - _releaseFileAction.Invoke(); - _semaphoreReleased = true; - } + await cancellationTokenSource.CancelAsync().ConfigureAwait(false); - while (uploadTasks.Count > 0) - { - await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); - } - } - catch - { - await cancellationTokenSource.CancelAsync().ConfigureAwait(false); + try + { + await Task.WhenAll(uploadTasks).ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + } - try - { - await Task.WhenAll(uploadTasks).ConfigureAwait(false); - } - catch - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + throw; } - throw; - } - - manifestStream.Seek(0, SeekOrigin.Begin); + manifestStream.Seek(0, SeekOrigin.Begin); - manifestSignature = await _signingKey.SignAsync(manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + manifestSignature = await _signingKey.SignAsync(manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + } } - var request = GetRevisionUpdateRequest(contentStream, lastModificationTime, blockSizes, manifestSignature, signingEmailAddress); + sha1.TransformFinalBlock([], 0, 0); + + var request = GetRevisionUpdateRequest(contentStream, lastModificationTime, blockSizes, sha1.Hash, manifestSignature, signingEmailAddress); _client.Logger.LogDebug("Sealing revision {RevisionId} of file {FileUid}", _revisionId, _fileUid); @@ -224,8 +238,9 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( Stream contentInputStream, DateTimeOffset? lastModificationTime, IReadOnlyList blockSizes, + byte[]? sha1Digest, ArraySegment manifestSignature, - string signinEmailAddress) + string signingEmailAddress) { var extendedAttributes = new ExtendedAttributes { @@ -234,6 +249,7 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( Size = contentInputStream.Length, ModificationTime = lastModificationTime?.UtcDateTime, BlockSizes = blockSizes, + Digests = new FileContentDigestsDto { Sha1 = sha1Digest }, }, }; @@ -244,7 +260,7 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( return new RevisionUpdateRequest { ManifestSignature = manifestSignature, - SignatureEmailAddress = signinEmailAddress, + SignatureEmailAddress = signingEmailAddress, ExtendedAttributes = encryptedExtendedAttributes, }; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index b349b5f4..886cd315 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -114,6 +114,8 @@ private ProtonDriveClient( internal BlockUploader BlockUploader { get; } internal BlockDownloader BlockDownloader { get; } + internal Func> GetAlternateFileNames { get; } = AlternateFileNameGenerator.GetNames; + public ValueTask GetMyFilesFolderAsync(CancellationToken cancellationToken) { return NodeOperations.GetMyFilesFolderAsync(this, cancellationToken); @@ -161,6 +163,11 @@ public async ValueTask GetFileDownloaderAsync(RevisionUid revisi return new FileDownloader(this, revisionUid); } + public ValueTask GetAvailableNameAsync(NodeUid parentUid, string name, CancellationToken cancellationToken) + { + return NodeOperations.GetAvailableNameAsync(this, parentUid, name, cancellationToken); + } + public async ValueTask MoveNodesAsync(IEnumerable uids, NodeUid newParentFolderUid, CancellationToken cancellationToken) { // FIXME: finalize the implementation that uses the batch move endpoint, and use it instead of this naïve code diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 649029b0..3264ef61 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -4,7 +4,6 @@ using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; -using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Api; using Proton.Sdk.Serialization; @@ -37,6 +36,8 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(FolderCreationResponse))] [JsonSerializable(typeof(FileCreationRequest))] [JsonSerializable(typeof(FileCreationResponse))] +[JsonSerializable(typeof(NodeNameAvailabilityRequest))] +[JsonSerializable(typeof(NodeNameAvailabilityResponse))] [JsonSerializable(typeof(RevisionCreationRequest))] [JsonSerializable(typeof(RevisionCreationResponse))] [JsonSerializable(typeof(RevisionConflictResponse))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs index e2820da3..e6753a40 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -8,6 +8,7 @@ namespace Proton.Drive.Sdk.Serialization; #pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, Converters = [ typeof(RefResultJsonConverter), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 70a580a2..4e8d98b4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -38,6 +38,7 @@ internal static class VolumeOperations Name = RootFolderName, NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, Author = new Author { EmailAddress = defaultAddress.EmailAddress }, + CreationTime = DateTime.UtcNow, }; // The volume root folder never has siblings and does not need a name hash digest @@ -72,7 +73,7 @@ public static async IAsyncEnumerable> EnumerateTrashA while (mustTryMoreResults) { - var response = await client.Api.Volumes.GetTrashAsync(volumeId, TrashPageSize, page, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Trash.GetTrashAsync(volumeId, TrashPageSize, page, cancellationToken).ConfigureAwait(false); mustTryMoreResults = response.TrashByShare.Sum(x => x.LinkIds.Count) == TrashPageSize; diff --git a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs index ed686d0b..1af884d3 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs +++ b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs @@ -51,7 +51,7 @@ public async Task GetRefreshedAccessTokenAsync(string rejectedAccessToke { try { - _logger.Log(LogLevel.Debug, "Refreshing token for {SessionId}", _sessionId); + _logger.LogDebug("Refreshing token for {SessionId}", _sessionId); var response = await _client.RefreshSessionAsync(_sessionId, currentAccessToken, currentRefreshToken, cancellationToken) .ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index e1d60f6f..c6d4242a 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -252,6 +252,8 @@ HAVING COUNT(DISTINCT t.Tag) = @tagCount public void Dispose() { + SqliteConnection.ClearPool(_connection); + _connection.Close(); _connection.Dispose(); } diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index 2a5e8fce..17edec69 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -209,7 +209,7 @@ public static ProtonApiSession Resume( passwordMode, configuration); - logger.Log(LogLevel.Information, "Session {SessionId} was resumed", session.SessionId); + logger.LogDebug("Session {SessionId} was resumed", session.SessionId); return session; } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs index 44e52dbc..2061b23d 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs @@ -38,6 +38,7 @@ public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, Js { if (value.Length == 0) { + writer.WriteNullValue(); return; } From 557c0056123dc0bd6336665b805b1ed69140fa4d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Oct 2025 06:18:21 +0000 Subject: [PATCH 258/791] Update public access to new APIs --- .../src/internal/nodes/extendedAttributes.ts | 4 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 18 +++- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- .../src/internal/sharingPublic/apiService.ts | 100 ++++-------------- .../src/internal/sharingPublic/cryptoCache.ts | 45 -------- .../internal/sharingPublic/cryptoService.ts | 22 ---- js/sdk/src/internal/sharingPublic/index.ts | 19 ++-- .../src/internal/sharingPublic/interface.ts | 5 - js/sdk/src/internal/sharingPublic/nodes.ts | 22 ++++ .../sharingPublic/session/apiService.ts | 40 +++++-- .../sharingPublic/session/interface.ts | 20 ++++ .../internal/sharingPublic/session/manager.ts | 39 +++++-- .../internal/sharingPublic/session/session.ts | 15 ++- js/sdk/src/internal/sharingPublic/shares.ts | 50 ++------- js/sdk/src/protonDriveClient.ts | 26 ++++- js/sdk/src/protonDrivePublicLinkClient.ts | 11 +- 16 files changed, 200 insertions(+), 238 deletions(-) delete mode 100644 js/sdk/src/internal/sharingPublic/cryptoCache.ts delete mode 100644 js/sdk/src/internal/sharingPublic/cryptoService.ts delete mode 100644 js/sdk/src/internal/sharingPublic/interface.ts diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index c8c9cc92..af4cde34 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -208,11 +208,11 @@ function parseBlockSizes( return undefined; } if (!Array.isArray(blockSizes)) { - logger.warn(`XAttr block sizes "${blockSizes}" is not valid`); + logger.warn(`XAttr block sizes "${JSON.stringify(blockSizes)}" is not valid`); return undefined; } if (blockSizes.some((size) => typeof size !== 'number' || size <= 0)) { - logger.warn(`XAttr block sizes "${blockSizes}" is not valid`); + logger.warn(`XAttr block sizes "${JSON.stringify(blockSizes)}" is not valid`); return undefined; } if (blockSizes.length === 0) { diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 29b15c0d..232be788 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -569,7 +569,11 @@ describe('nodesAccess', () => { it('should get share parent keys', async () => { shareService.getSharePrivateKey = jest.fn(() => Promise.resolve('shareKey' as any as PrivateKey)); - const result = await access.getParentKeys({ shareId: 'shareId', parentUid: undefined }); + const result = await access.getParentKeys({ + uid: 'volumeId~nodeId', + shareId: 'shareId', + parentUid: undefined, + }); expect(result).toEqual({ key: 'shareKey' }); expect(cryptoCache.getNodeKeys).not.toHaveBeenCalled(); }); @@ -577,7 +581,11 @@ describe('nodesAccess', () => { it('should get node parent keys', async () => { cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - const result = await access.getParentKeys({ shareId: undefined, parentUid: 'volumeId~parentNodeid' }); + const result = await access.getParentKeys({ + uid: 'volumeId~nodeId', + shareId: undefined, + parentUid: 'volumeId~parentNodeid', + }); expect(result).toEqual({ key: 'parentKey' }); expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); }); @@ -585,7 +593,11 @@ describe('nodesAccess', () => { it('should get node parent keys even if share is set', async () => { cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); - const result = await access.getParentKeys({ shareId: 'shareId', parentUid: 'volume1~parentNodeid' }); + const result = await access.getParentKeys({ + uid: 'volume1~nodeId', + shareId: 'shareId', + parentUid: 'volume1~parentNodeid', + }); expect(result).toEqual({ key: 'parentKey' }); expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index a480b954..53270365 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -367,7 +367,7 @@ export class NodesAccess { } async getParentKeys( - node: Pick, + node: Pick, ): Promise> { if (node.parentUid) { try { diff --git a/js/sdk/src/internal/sharingPublic/apiService.ts b/js/sdk/src/internal/sharingPublic/apiService.ts index 6f4b5eae..d936e8bf 100644 --- a/js/sdk/src/internal/sharingPublic/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/apiService.ts @@ -1,92 +1,38 @@ -import { DriveAPIService, drivePaths, nodeTypeNumberToNodeType } from '../apiService'; -import { Logger, MemberRole } from '../../interface'; -import { makeNodeUid } from '../uids'; -import { EncryptedNode } from '../nodes/interface'; -import { EncryptedShareCrypto } from './interface'; +import { DriveAPIService, drivePaths } from '../apiService'; -type GetTokenInfoResponse = drivePaths['/drive/urls/{token}']['get']['responses']['200']['content']['application/json']; +type PostTokenInfoRequest = Extract< + drivePaths['/drive/v2/urls/{token}/bookmark']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostTokenInfoResponse = + drivePaths['/drive/v2/urls/{token}/bookmark']['post']['responses']['200']['content']['application/json']; /** - * Provides API communication for accessing public link data. + * Provides API communication for actions on the public link. * * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ export class SharingPublicAPIService { - constructor( - private logger: Logger, - private apiService: DriveAPIService, - ) { - this.logger = logger; + constructor(private apiService: DriveAPIService) { this.apiService = apiService; } - async getPublicLinkRoot(token: string): Promise<{ - encryptedNode: EncryptedNode; - encryptedShare: EncryptedShareCrypto; - }> { - const response = await this.apiService.get(`drive/urls/${token}`); - const encryptedNode = tokenToEncryptedNode(this.logger, response.Token); - - return { - encryptedNode: encryptedNode, - encryptedShare: { - base64UrlPasswordSalt: response.Token.SharePasswordSalt, - armoredKey: response.Token.ShareKey, - armoredPassphrase: response.Token.SharePassphrase, - }, - }; - } -} - -function tokenToEncryptedNode(logger: Logger, token: GetTokenInfoResponse['Token']): EncryptedNode { - const baseNodeMetadata = { - // Internal metadata - encryptedName: token.Name, - - // Basic node metadata - uid: makeNodeUid(token.VolumeID, token.LinkID), - parentUid: undefined, - type: nodeTypeNumberToNodeType(logger, token.LinkType), - creationTime: new Date(), // TODO - - isShared: false, - isSharedPublicly: false, - directRole: MemberRole.Viewer, // TODO - }; - - const baseCryptoNodeMetadata = { - signatureEmail: token.SignatureEmail || undefined, - armoredKey: token.NodeKey, - armoredNodePassphrase: token.NodePassphrase, - armoredNodePassphraseSignature: token.NodePassphraseSignature || undefined, - }; - - if (token.LinkType === 1 && token.NodeHashKey) { - return { - ...baseNodeMetadata, - encryptedCrypto: { - ...baseCryptoNodeMetadata, - folder: { - armoredHashKey: token.NodeHashKey as string, + async bookmarkPublicLink(bookmark: { + token: string; + encryptedUrlPassword: string; + addressId: string; + addressKeyId: string; + }): Promise { + await this.apiService.post( + `drive/v2/urls/${bookmark.token}/bookmark`, + { + BookmarkShareURL: { + EncryptedUrlPassword: bookmark.encryptedUrlPassword, + AddressID: bookmark.addressId, + AddressKeyID: bookmark.addressKeyId, }, }, - }; + ); } - - if (token.LinkType === 2 && token.ContentKeyPacket) { - return { - ...baseNodeMetadata, - totalStorageSize: token.Size || undefined, - mediaType: token.MIMEType || undefined, - encryptedCrypto: { - ...baseCryptoNodeMetadata, - file: { - base64ContentKeyPacket: token.ContentKeyPacket, - }, - }, - }; - } - - throw new Error(`Unknown node type: ${token.LinkType}`); } diff --git a/js/sdk/src/internal/sharingPublic/cryptoCache.ts b/js/sdk/src/internal/sharingPublic/cryptoCache.ts deleted file mode 100644 index e2dce2dc..00000000 --- a/js/sdk/src/internal/sharingPublic/cryptoCache.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { PrivateKey } from '../../crypto'; -import { ProtonDriveCryptoCache, Logger } from '../../interface'; - -/** - * Provides caching for public link crypto material. - * - * The cache is responsible for serialising and deserialising public link - * crypto material. - */ -export class SharingPublicCryptoCache { - constructor( - private logger: Logger, - private driveCache: ProtonDriveCryptoCache, - ) { - this.logger = logger; - this.driveCache = driveCache; - } - - async setShareKey(shareKey: PrivateKey): Promise { - await this.driveCache.setEntity(getShareKeyCacheKey(), { - publicShareKey: { - key: shareKey, - }, - }); - } - - async getShareKey(): Promise { - const shareKeyData = await this.driveCache.getEntity(getShareKeyCacheKey()); - if (!shareKeyData.publicShareKey) { - try { - await this.driveCache.removeEntities([getShareKeyCacheKey()]); - } catch (removingError: unknown) { - this.logger.warn( - `Failed to remove corrupted public share key from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, - ); - } - throw new Error('Failed to deserialize public share key'); - } - return shareKeyData.publicShareKey.key; - } -} - -function getShareKeyCacheKey() { - return 'publicShareKey'; -} diff --git a/js/sdk/src/internal/sharingPublic/cryptoService.ts b/js/sdk/src/internal/sharingPublic/cryptoService.ts deleted file mode 100644 index ccf42d5c..00000000 --- a/js/sdk/src/internal/sharingPublic/cryptoService.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DriveCrypto, PrivateKey } from '../../crypto'; -import { EncryptedShareCrypto } from './interface'; - -export class SharingPublicCryptoService { - constructor( - private driveCrypto: DriveCrypto, - private password: string, - ) { - this.driveCrypto = driveCrypto; - this.password = password; - } - - async decryptPublicLinkShareKey(encryptedShare: EncryptedShareCrypto): Promise { - const { key: shareKey } = await this.driveCrypto.decryptKeyWithSrpPassword( - this.password, - encryptedShare.base64UrlPasswordSalt, - encryptedShare.armoredKey, - encryptedShare.armoredPassphrase, - ); - return shareKey; - } -} diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 52c47931..1e04ac7a 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -1,4 +1,4 @@ -import { DriveCrypto } from '../../crypto'; +import { DriveCrypto, PrivateKey } from '../../crypto'; import { ProtonDriveCryptoCache, ProtonDriveTelemetry, @@ -11,10 +11,7 @@ import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; import { NodesRevisons } from '../nodes/nodesRevisions'; -import { SharingPublicAPIService } from './apiService'; -import { SharingPublicCryptoCache } from './cryptoCache'; import { SharingPublicCryptoReporter } from './cryptoReporter'; -import { SharingPublicCryptoService } from './cryptoService'; import { SharingPublicNodesAccess } from './nodes'; import { SharingPublicSharesManager } from './shares'; @@ -38,12 +35,10 @@ export function initSharingPublicModule( account: ProtonDriveAccount, url: string, token: string, - password: string, + publicShareKey: PrivateKey, + publicRootNodeUid: string, ) { - const api = new SharingPublicAPIService(telemetry.getLogger('sharingPublic-api'), apiService); - const cryptoCache = new SharingPublicCryptoCache(telemetry.getLogger('sharingPublic-crypto'), driveCryptoCache); - const cryptoService = new SharingPublicCryptoService(driveCrypto, password); - const shares = new SharingPublicSharesManager(api, cryptoCache, cryptoService, account, token); + const shares = new SharingPublicSharesManager(account, publicShareKey, publicRootNodeUid); const nodes = initSharingPublicNodesModule( telemetry, apiService, @@ -54,6 +49,8 @@ export function initSharingPublicModule( shares, url, token, + publicShareKey, + publicRootNodeUid, ); return { @@ -78,6 +75,8 @@ export function initSharingPublicNodesModule( sharesService: SharingPublicSharesManager, url: string, token: string, + publicShareKey: PrivateKey, + publicRootNodeUid: string, ) { const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); @@ -93,6 +92,8 @@ export function initSharingPublicNodesModule( sharesService, url, token, + publicShareKey, + publicRootNodeUid, ); const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); diff --git a/js/sdk/src/internal/sharingPublic/interface.ts b/js/sdk/src/internal/sharingPublic/interface.ts deleted file mode 100644 index 03267c9b..00000000 --- a/js/sdk/src/internal/sharingPublic/interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface EncryptedShareCrypto { - base64UrlPasswordSalt: string; - armoredKey: string; - armoredPassphrase: string; -} diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index 6a737cdd..78673566 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -7,6 +7,8 @@ import { NodesAccess } from '../nodes/nodesAccess'; import { isProtonDocument, isProtonSheet } from '../nodes/mediaTypes'; import { splitNodeUid } from '../uids'; import { SharingPublicSharesManager } from './shares'; +import { DecryptedNode, DecryptedNodeKeys } from '../nodes/interface'; +import { PrivateKey } from '../../crypto'; export class SharingPublicNodesAccess extends NodesAccess { constructor( @@ -18,9 +20,29 @@ export class SharingPublicNodesAccess extends NodesAccess { sharesService: SharingPublicSharesManager, private url: string, private token: string, + private publicShareKey: PrivateKey, + private publicRootNodeUid: string, ) { super(telemetry, apiService, cache, cryptoCache, cryptoService, sharesService); this.token = token; + this.publicShareKey = publicShareKey; + this.publicRootNodeUid = publicRootNodeUid; + } + + async getParentKeys( + node: Pick, + ): Promise> { + // If we reached the root node of the public link, return the public + // share key even if user has access to the parent node. We do not + // support access to nodes outside of the public link context. + // For other nodes, the client must use the main SDK. + if (node.uid === this.publicRootNodeUid) { + return { + key: this.publicShareKey, + }; + } + + return super.getParentKeys(node); } async getNodeUrl(nodeUid: string): Promise { diff --git a/js/sdk/src/internal/sharingPublic/session/apiService.ts b/js/sdk/src/internal/sharingPublic/session/apiService.ts index 12c7f6a2..2eb42b29 100644 --- a/js/sdk/src/internal/sharingPublic/session/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/session/apiService.ts @@ -1,5 +1,7 @@ -import { DriveAPIService, drivePaths } from '../../apiService'; -import { PublicLinkInfo, PublicLinkSrpAuth } from './interface'; +import { Logger } from '../../../interface'; +import { DriveAPIService, drivePaths, permissionsToMemberRole } from '../../apiService'; +import { makeNodeUid } from '../../uids'; +import { PublicLinkInfo, PublicLinkSrpAuth, PublicLinkSession, EncryptedShareCrypto } from './interface'; type GetPublicLinkInfoResponse = drivePaths['/drive/urls/{token}/info']['get']['responses']['200']['content']['application/json']; @@ -18,7 +20,11 @@ type PostPublicLinkAuthResponse = * and vice versa. It should not contain any business logic. */ export class SharingPublicSessionAPIService { - constructor(private apiService: DriveAPIService) { + constructor( + private logger: Logger, + private apiService: DriveAPIService, + ) { + this.logger = logger; this.apiService = apiService; } @@ -38,6 +44,13 @@ export class SharingPublicSessionAPIService { isCustomPasswordProtected: (response.Flags & 1) === 1, isLegacy: response.Flags === 0 || response.Flags === 1, vendorType: response.VendorType, + directAccess: response.DirectAccess + ? { + nodeUid: makeNodeUid(response.DirectAccess.VolumeID, response.DirectAccess.LinkID), + directRole: permissionsToMemberRole(this.logger, response.DirectAccess.DirectPermissions), + publicRole: permissionsToMemberRole(this.logger, response.DirectAccess.PublicPermissions), + } + : undefined, }; } @@ -52,9 +65,9 @@ export class SharingPublicSessionAPIService { token: string, srp: PublicLinkSrpAuth, ): Promise<{ - serverProof: string; - sessionUid: string; - sessionAccessToken?: string; + session: PublicLinkSession; + encryptedShare: EncryptedShareCrypto; + rootUid: string; }> { const response = await this.apiService.post( `drive/urls/${token}/auth`, @@ -66,9 +79,18 @@ export class SharingPublicSessionAPIService { ); return { - serverProof: response.ServerProof, - sessionUid: response.UID, - sessionAccessToken: response.AccessToken, + session: { + serverProof: response.ServerProof, + sessionUid: response.UID, + sessionAccessToken: response.AccessToken, + }, + encryptedShare: { + base64UrlPasswordSalt: response.Share.SharePasswordSalt, + armoredKey: response.Share.ShareKey, + armoredPassphrase: response.Share.SharePassphrase, + publicRole: permissionsToMemberRole(this.logger, response.Share.PublicPermissions), + }, + rootUid: makeNodeUid(response.Share.VolumeID, response.Share.LinkID), }; } } diff --git a/js/sdk/src/internal/sharingPublic/session/interface.ts b/js/sdk/src/internal/sharingPublic/session/interface.ts index 6a914a35..d717b909 100644 --- a/js/sdk/src/internal/sharingPublic/session/interface.ts +++ b/js/sdk/src/internal/sharingPublic/session/interface.ts @@ -1,8 +1,15 @@ +import { MemberRole } from '../../../interface'; + export type PublicLinkInfo = { srp: PublicLinkSrpInfo; isCustomPasswordProtected: boolean; isLegacy: boolean; vendorType: number; + directAccess?: { + nodeUid: string; + directRole: MemberRole; + publicRole: MemberRole; + }; }; export type PublicLinkSrpInfo = { @@ -18,3 +25,16 @@ export type PublicLinkSrpAuth = { clientEphemeral: string; srpSession: string; }; + +export type PublicLinkSession = { + serverProof: string; + sessionUid: string; + sessionAccessToken?: string; +}; + +export type EncryptedShareCrypto = { + base64UrlPasswordSalt: string; + armoredKey: string; + armoredPassphrase: string; + publicRole: MemberRole; +}; diff --git a/js/sdk/src/internal/sharingPublic/session/manager.ts b/js/sdk/src/internal/sharingPublic/session/manager.ts index 6b6effc1..c17f23f9 100644 --- a/js/sdk/src/internal/sharingPublic/session/manager.ts +++ b/js/sdk/src/internal/sharingPublic/session/manager.ts @@ -1,9 +1,9 @@ -import { ProtonDriveHTTPClient } from '../../../interface'; -import { SRPModule } from '../../../crypto'; +import { MemberRole, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../../interface'; +import { DriveCrypto, PrivateKey, SRPModule } from '../../../crypto'; import { DriveAPIService } from '../../apiService'; import { SharingPublicSessionAPIService } from './apiService'; import { SharingPublicSessionHttpClient } from './httpClient'; -import { PublicLinkInfo } from './interface'; +import { EncryptedShareCrypto, PublicLinkInfo } from './interface'; import { SharingPublicLinkSession } from './session'; import { getTokenAndPasswordFromUrl } from './url'; @@ -18,14 +18,17 @@ export class SharingPublicSessionManager { private infosPerToken: Map = new Map(); constructor( + telemetry: ProtonDriveTelemetry, private httpClient: ProtonDriveHTTPClient, - apiService: DriveAPIService, + private driveCrypto: DriveCrypto, private srpModule: SRPModule, + apiService: DriveAPIService, ) { this.httpClient = httpClient; + this.driveCrypto = driveCrypto; this.srpModule = srpModule; - this.api = new SharingPublicSessionAPIService(apiService); + this.api = new SharingPublicSessionAPIService(telemetry.getLogger('sharingPublicSession'), apiService); } /** @@ -42,6 +45,11 @@ export class SharingPublicSessionManager { isCustomPasswordProtected: boolean; isLegacy: boolean; vendorType: number; + directAccess?: { + nodeUid: string; + directRole: MemberRole; + publicRole: MemberRole; + }; }> { const { token } = getTokenAndPasswordFromUrl(url); @@ -52,6 +60,7 @@ export class SharingPublicSessionManager { isCustomPasswordProtected: info.isCustomPasswordProtected, isLegacy: info.isLegacy, vendorType: info.vendorType, + directAccess: info.directAccess, }; } @@ -73,8 +82,9 @@ export class SharingPublicSessionManager { customPassword?: string, ): Promise<{ token: string; - password: string; httpClient: SharingPublicSessionHttpClient; + shareKey: PrivateKey; + rootUid: string; }> { const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url); @@ -86,12 +96,25 @@ export class SharingPublicSessionManager { const password = `${urlPassword}${customPassword || ''}`; const session = new SharingPublicLinkSession(this.api, this.srpModule, token, password); - await session.auth(info.srp); + const { encryptedShare, rootUid } = await session.auth(info.srp); + + const shareKey = await this.decryptShareKey(encryptedShare, password); return { token, - password, httpClient: new SharingPublicSessionHttpClient(this.httpClient, session), + shareKey, + rootUid, }; } + + private async decryptShareKey(encryptedShare: EncryptedShareCrypto, password: string): Promise { + const { key: shareKey } = await this.driveCrypto.decryptKeyWithSrpPassword( + password, + encryptedShare.base64UrlPasswordSalt, + encryptedShare.armoredKey, + encryptedShare.armoredPassphrase, + ); + return shareKey; + } } diff --git a/js/sdk/src/internal/sharingPublic/session/session.ts b/js/sdk/src/internal/sharingPublic/session/session.ts index d683420b..b45a6947 100644 --- a/js/sdk/src/internal/sharingPublic/session/session.ts +++ b/js/sdk/src/internal/sharingPublic/session/session.ts @@ -1,6 +1,6 @@ import { SRPModule } from '../../../crypto'; import { SharingPublicSessionAPIService } from './apiService'; -import { PublicLinkInfo, PublicLinkSrpInfo } from './interface'; +import { EncryptedShareCrypto, PublicLinkInfo, PublicLinkSrpInfo } from './interface'; /** * Session for a public link. @@ -33,7 +33,7 @@ export class SharingPublicLinkSession { return this.apiService.initPublicLinkSession(this.token); } - async auth(srp: PublicLinkSrpInfo): Promise { + async auth(srp: PublicLinkSrpInfo): Promise<{ encryptedShare: EncryptedShareCrypto; rootUid: string }> { const { expectedServerProof, clientProof, clientEphemeral } = await this.srpModule.getSrp( srp.version, srp.modulus, @@ -48,12 +48,17 @@ export class SharingPublicLinkSession { srpSession: srp.srpSession, }); - if (auth.serverProof !== expectedServerProof) { + if (auth.session.serverProof !== expectedServerProof) { throw new Error('Invalid server proof'); } - this.sessionUid = auth.sessionUid; - this.sessionAccessToken = auth.sessionAccessToken; + this.sessionUid = auth.session.sessionUid; + this.sessionAccessToken = auth.session.sessionAccessToken; + + return { + encryptedShare: auth.encryptedShare, + rootUid: auth.rootUid, + }; } /** diff --git a/js/sdk/src/internal/sharingPublic/shares.ts b/js/sdk/src/internal/sharingPublic/shares.ts index f2d13e8b..59e1973c 100644 --- a/js/sdk/src/internal/sharingPublic/shares.ts +++ b/js/sdk/src/internal/sharingPublic/shares.ts @@ -1,9 +1,6 @@ import { PrivateKey } from '../../crypto'; import { MetricVolumeType, ProtonDriveAccount } from '../../interface'; import { splitNodeUid } from '../uids'; -import { SharingPublicAPIService } from './apiService'; -import { SharingPublicCryptoCache } from './cryptoCache'; -import { SharingPublicCryptoService } from './cryptoService'; /** * Provides high-level actions for managing public link share. @@ -12,57 +9,24 @@ import { SharingPublicCryptoService } from './cryptoService'; * service so it can be used in the same way in various modules that use shares. */ export class SharingPublicSharesManager { - private promisePublicLinkRoot?: Promise<{ - rootIds: { volumeId: string; rootNodeId: string; rootNodeUid: string }; - shareKey: PrivateKey; - }>; - constructor( - private apiService: SharingPublicAPIService, - private cryptoCache: SharingPublicCryptoCache, - private cryptoService: SharingPublicCryptoService, private account: ProtonDriveAccount, - private token: string, + private publicShareKey: PrivateKey, + private publicRootNodeUid: string, ) { - this.apiService = apiService; - this.cryptoCache = cryptoCache; - this.cryptoService = cryptoService; this.account = account; - this.token = token; + this.publicShareKey = publicShareKey; + this.publicRootNodeUid = publicRootNodeUid; } // TODO: Rename to getRootIDs everywhere. async getOwnVolumeIDs(): Promise<{ volumeId: string; rootNodeId: string; rootNodeUid: string }> { - const { rootIds } = await this.getPublicLinkRoot(); - return rootIds; + const { volumeId, nodeId: rootNodeId } = splitNodeUid(this.publicRootNodeUid); + return { volumeId, rootNodeId, rootNodeUid: this.publicRootNodeUid }; } async getSharePrivateKey(): Promise { - const { shareKey } = await this.getPublicLinkRoot(); - return shareKey; - } - - private async getPublicLinkRoot(): Promise<{ - rootIds: { volumeId: string; rootNodeId: string; rootNodeUid: string }; - shareKey: PrivateKey; - }> { - if (!this.promisePublicLinkRoot) { - this.promisePublicLinkRoot = (async () => { - const { encryptedNode, encryptedShare } = await this.apiService.getPublicLinkRoot(this.token); - - const { volumeId, nodeId: rootNodeId } = splitNodeUid(encryptedNode.uid); - - const shareKey = await this.cryptoService.decryptPublicLinkShareKey(encryptedShare); - await this.cryptoCache.setShareKey(shareKey); - - return { - rootIds: { volumeId, rootNodeId, rootNodeUid: encryptedNode.uid }, - shareKey, - }; - })(); - } - - return this.promisePublicLinkRoot; + return this.publicShareKey; } async getContextShareMemberEmailKey(): Promise<{ diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index e7bd3655..3ee00fb9 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -29,6 +29,7 @@ import { ThumbnailResult, SDKEvent, NodeType, + MemberRole, } from './interface'; import { getUid, @@ -69,7 +70,7 @@ export class ProtonDriveClient { private download: ReturnType; private upload: ReturnType; private devices: ReturnType; - private sessionManager: SharingPublicSessionManager; + private publicSessionManager: SharingPublicSessionManager; public experimental: { /** @@ -94,6 +95,11 @@ export class ProtonDriveClient { isCustomPasswordProtected: boolean; isLegacy: boolean; vendorType: number; + directAccess?: { + nodeUid: string; + directRole: MemberRole; + publicRole: MemberRole; + }; }>; /** * Experimental feature to authenticate a public link and @@ -185,7 +191,13 @@ export class ProtonDriveClient { latestEventIdProvider, ); - this.sessionManager = new SharingPublicSessionManager(httpClient, apiService, srpModule); + this.publicSessionManager = new SharingPublicSessionManager( + telemetry, + httpClient, + cryptoModule, + srpModule, + apiService, + ); this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { @@ -202,11 +214,14 @@ export class ProtonDriveClient { }, getPublicLinkInfo: async (url: string) => { this.logger.info(`Getting info for public link ${url}`); - return this.sessionManager.getInfo(url); + return this.publicSessionManager.getInfo(url); }, authPublicLink: async (url: string, customPassword?: string) => { this.logger.info(`Authenticating public link ${url}`); - const { httpClient, token, password } = await this.sessionManager.auth(url, customPassword); + const { httpClient, token, shareKey, rootUid } = await this.publicSessionManager.auth( + url, + customPassword, + ); return new ProtonDrivePublicLinkClient({ httpClient, account, @@ -216,7 +231,8 @@ export class ProtonDriveClient { telemetry, url, token, - password, + publicShareKey: shareKey, + publicRootNodeUid: rootUid, }); }, }; diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 73af5d74..829142b3 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -1,6 +1,6 @@ import { MemoryCache } from './cache'; import { getConfig } from './config'; -import { DriveCrypto, OpenPGPCrypto, SRPModule, SessionKey } from './crypto'; +import { DriveCrypto, OpenPGPCrypto, PrivateKey, SRPModule, SessionKey } from './crypto'; import { ProtonDriveHTTPClient, ProtonDriveTelemetry, @@ -74,7 +74,8 @@ export class ProtonDrivePublicLinkClient { telemetry, url, token, - password, + publicShareKey, + publicRootNodeUid, }: { httpClient: ProtonDriveHTTPClient; account: ProtonDriveAccount; @@ -84,7 +85,8 @@ export class ProtonDrivePublicLinkClient { telemetry?: ProtonDriveTelemetry; url: string; token: string; - password: string; + publicShareKey: PrivateKey; + publicRootNodeUid: string; }) { if (!telemetry) { telemetry = new Telemetry(); @@ -115,7 +117,8 @@ export class ProtonDrivePublicLinkClient { account, url, token, - password, + publicShareKey, + publicRootNodeUid, ); this.download = initDownloadModule( telemetry, From d879c0cfe9fea631c9b863dc05ace656dd148725 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Oct 2025 15:31:36 +0200 Subject: [PATCH 259/791] Convert revisions to public interface --- js/sdk/src/protonDriveClient.ts | 3 ++- js/sdk/src/transformers.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 3ee00fb9..ec98c27c 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -38,6 +38,7 @@ import { convertInternalNodeIterator, convertInternalMissingNodeIterator, convertInternalNode, + convertInternalRevisionIterator, } from './transformers'; import { Telemetry } from './telemetry'; import { DriveAPIService } from './internal/apiService'; @@ -524,7 +525,7 @@ export class ProtonDriveClient { */ async *iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating revisions of ${getUid(nodeUid)}`); - yield* this.nodes.revisions.iterateRevisions(getUid(nodeUid), signal); + yield* convertInternalRevisionIterator(this.nodes.revisions.iterateRevisions(getUid(nodeUid), signal)); } /** diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index c854afd8..02ac226d 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -121,6 +121,14 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode } as PublicNodeEntity); } +export async function* convertInternalRevisionIterator( + revisionIterator: AsyncGenerator, +): AsyncGenerator { + for await (const revision of revisionIterator) { + yield convertInternalRevision(revision); + } +} + function convertInternalRevision(revision: InternalRevision): PublicRevision { return { uid: revision.uid, From 3e93a97b6c5d289c004cdc2771be63b8a44065c9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Oct 2025 15:17:20 +0200 Subject: [PATCH 260/791] Add expectedStrcuture options for diagnostics --- .../{sdkDiagnosticFull.ts => diagnostic.ts} | 16 ++- js/sdk/src/diagnostic/index.ts | 8 +- js/sdk/src/diagnostic/interface.ts | 39 ++++++ js/sdk/src/diagnostic/sdkDiagnostic.ts | 119 ++++++++++++++++-- 4 files changed, 162 insertions(+), 20 deletions(-) rename js/sdk/src/diagnostic/{sdkDiagnosticFull.ts => diagnostic.ts} (66%) diff --git a/js/sdk/src/diagnostic/sdkDiagnosticFull.ts b/js/sdk/src/diagnostic/diagnostic.ts similarity index 66% rename from js/sdk/src/diagnostic/sdkDiagnosticFull.ts rename to js/sdk/src/diagnostic/diagnostic.ts index 488de8e0..1ea58697 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticFull.ts +++ b/js/sdk/src/diagnostic/diagnostic.ts @@ -1,6 +1,8 @@ import { MaybeNode } from '../interface'; +import { ProtonDriveClient } from '../protonDriveClient'; import { DiagnosticHTTPClient } from './httpClient'; -import { Diagnostic, DiagnosticOptions, DiagnosticResult } from './interface'; +import { DiagnosticOptions, DiagnosticResult } from './interface'; +import { SDKDiagnostic } from './sdkDiagnostic'; import { DiagnosticTelemetry } from './telemetry'; import { zipGenerators } from './zipGenerators'; @@ -8,23 +10,25 @@ import { zipGenerators } from './zipGenerators'; * Diagnostic tool that produces full diagnostic, including logs and metrics * by reading the events from the telemetry and HTTP client. */ -export class FullSDKDiagnostic implements Diagnostic { +export class Diagnostic { constructor( - private diagnostic: Diagnostic, private telemetry: DiagnosticTelemetry, private httpClient: DiagnosticHTTPClient, + private protonDriveClient: ProtonDriveClient, ) { - this.diagnostic = diagnostic; this.telemetry = telemetry; this.httpClient = httpClient; + this.protonDriveClient = protonDriveClient; } async *verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { - yield* this.yieldEvents(this.diagnostic.verifyMyFiles(options)); + const diagnostic = new SDKDiagnostic(this.protonDriveClient); + yield* this.yieldEvents(diagnostic.verifyMyFiles(options)); } async *verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { - yield* this.yieldEvents(this.diagnostic.verifyNodeTree(node, options)); + const diagnostic = new SDKDiagnostic(this.protonDriveClient); + yield* this.yieldEvents(diagnostic.verifyNodeTree(node, options)); } private async *yieldEvents(generator: AsyncGenerator): AsyncGenerator { diff --git a/js/sdk/src/diagnostic/index.ts b/js/sdk/src/diagnostic/index.ts index de86f834..51455242 100644 --- a/js/sdk/src/diagnostic/index.ts +++ b/js/sdk/src/diagnostic/index.ts @@ -1,10 +1,9 @@ import { MemoryCache, NullCache } from '../cache'; import { ProtonDriveClientContructorParameters } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; -import { DiagnosticHTTPClient } from './httpClient'; +import { Diagnostic as DiagnosticClass } from './diagnostic'; import { Diagnostic } from './interface'; -import { SDKDiagnostic } from './sdkDiagnostic'; -import { FullSDKDiagnostic } from './sdkDiagnosticFull'; +import { DiagnosticHTTPClient } from './httpClient'; import { DiagnosticTelemetry } from './telemetry'; export type { Diagnostic, DiagnosticResult } from './interface'; @@ -35,6 +34,5 @@ export function initDiagnostic( telemetry, }); - const diagnostic = new SDKDiagnostic(protonDriveClient); - return new FullSDKDiagnostic(diagnostic, telemetry, httpClient); + return new DiagnosticClass(telemetry, httpClient, protonDriveClient); } diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index 95aef27f..deff65d1 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -9,6 +9,15 @@ export interface Diagnostic { export type DiagnosticOptions = { verifyContent?: boolean; verifyThumbnails?: boolean; + expectedStructure?: ExcpectedTreeNode; +}; + +// Tree structure of the expected node tree. +export type ExcpectedTreeNode = { + name: string; + expectedSha1?: string; + expectedSizeInBytes?: number; + children?: ExcpectedTreeNode[]; }; export type DiagnosticResult = @@ -23,6 +32,9 @@ export type DiagnosticResult = | ContentIntegrityErrorResult | ContentDownloadErrorResult | ThumbnailsErrorResult + | ExpectedStructureMissingNode + | ExpectedStructureUnexpectedNode + | ExpectedStructureIntegrityError | LogErrorResult | LogWarningResult | MetricResult; @@ -121,6 +133,33 @@ export type ThumbnailsErrorResult = { error: unknown; } & NodeDetails; +// Event representing that expected node is missing. +// This will be reported for any node that is not found compared to +// the expected structure. +export type ExpectedStructureMissingNode = { + type: 'expected_structure_missing_node'; + expectedNode: ExcpectedTreeNode; + parentNodeUid: string; +}; + +// Event representing that unexpected node is present. +// This will be reported for any node that is found in the actual structure +// but is not defined in the expected structure. +export type ExpectedStructureUnexpectedNode = { + type: 'expected_structure_unexpected_node'; +} & NodeDetails; + +// Event representing that expected node is not matching the actual node. +// This will be reported when claimed and expected values are different. +// It doesn't check the real content - use content verification to verify +// the claimed values with the real content. +export type ExpectedStructureIntegrityError = { + type: 'expected_structure_integrity_error'; + expectedNode: ExcpectedTreeNode; + claimedSha1?: string; + claimedSizeInBytes?: number; +} & NodeDetails; + // Event representing errors logged during the diagnostic. export type LogErrorResult = { type: 'log_error'; diff --git a/js/sdk/src/diagnostic/sdkDiagnostic.ts b/js/sdk/src/diagnostic/sdkDiagnostic.ts index c4d258c1..010e8ed4 100644 --- a/js/sdk/src/diagnostic/sdkDiagnostic.ts +++ b/js/sdk/src/diagnostic/sdkDiagnostic.ts @@ -1,6 +1,6 @@ import { Author, FileDownloader, MaybeNode, NodeType, Revision, ThumbnailType } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; -import { Diagnostic, DiagnosticOptions, DiagnosticResult, NodeDetails } from './interface'; +import { DiagnosticOptions, DiagnosticResult, NodeDetails, ExcpectedTreeNode } from './interface'; import { IntegrityVerificationStream } from './integrityVerificationStream'; /** @@ -10,7 +10,7 @@ import { IntegrityVerificationStream } from './integrityVerificationStream'; * It produces only events that can be read by direct SDK invocation. * To get the full diagnostic, use {@link FullSDKDiagnostic}. */ -export class SDKDiagnostic implements Diagnostic { +export class SDKDiagnostic { constructor(private protonDriveClient: ProtonDriveClient) { this.protonDriveClient = protonDriveClient; } @@ -58,7 +58,7 @@ export class SDKDiagnostic implements Diagnostic { yield* this.verifyAuthor(activeRevision.contentAuthor, 'content', node); } - yield* this.verifyFileExtendedAttributes(node); + yield* this.verifyFileExtendedAttributes(node, options); if (options?.verifyContent) { yield* this.verifyContent(node); @@ -80,12 +80,17 @@ export class SDKDiagnostic implements Diagnostic { } } - private async *verifyFileExtendedAttributes(node: MaybeNode): AsyncGenerator { + private async *verifyFileExtendedAttributes( + node: MaybeNode, + options?: DiagnosticOptions, + ): AsyncGenerator { const activeRevision = getActiveRevision(node); const expectedAttributes = getNodeType(node) === NodeType.File; const claimedSha1 = activeRevision?.claimedDigests?.sha1; + const claimedSizeInBytes = activeRevision?.claimedSize; + if (claimedSha1 && !/^[0-9a-f]{40}$/i.test(claimedSha1)) { yield { type: 'extended_attributes_error', @@ -102,6 +107,24 @@ export class SDKDiagnostic implements Diagnostic { ...getNodeDetails(node), }; } + + if (options?.expectedStructure) { + const expectedSha1 = options.expectedStructure.expectedSha1; + const expectedSizeInBytes = options.expectedStructure.expectedSizeInBytes; + + const wrongSha1 = expectedSha1 !== undefined && claimedSha1 !== expectedSha1; + const wrongSizeInBytes = expectedSizeInBytes !== undefined && claimedSizeInBytes !== expectedSizeInBytes; + + if (wrongSha1 || wrongSizeInBytes) { + yield { + type: 'expected_structure_integrity_error', + claimedSha1, + claimedSizeInBytes, + expectedNode: getExpectedTreeNodeDetails(options.expectedStructure), + ...getNodeDetails(node), + }; + } + } } private async *verifyContent(node: MaybeNode): AsyncGenerator { @@ -195,19 +218,73 @@ export class SDKDiagnostic implements Diagnostic { } } - private async *verifyNodeChildren(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { - const nodeUid = node.ok ? node.value.uid : node.error.uid; + private async *verifyNodeChildren( + parentNode: MaybeNode, + options?: DiagnosticOptions, + ): AsyncGenerator { + const parentNodeUid = parentNode.ok ? parentNode.value.uid : parentNode.error.uid; + const children: MaybeNode[] = []; + try { - for await (const child of this.protonDriveClient.iterateFolderChildren(node)) { - yield* this.verifyNodeTree(child, options); + for await (const child of this.protonDriveClient.iterateFolderChildren(parentNode)) { + if (options?.expectedStructure) { + children.push(child); + } + + yield * + this.verifyNodeTree(child, { + ...options, + expectedStructure: options?.expectedStructure + ? getTreeNodeChildByNodeName(options.expectedStructure, getNodeName(child)) + : undefined, + }); } } catch (error: unknown) { yield { type: 'sdk_error', - call: `iterateFolderChildren(${nodeUid})`, + call: `iterateFolderChildren(${parentNodeUid})`, error, }; } + + if (options?.expectedStructure) { + yield* this.verifyExpectedNodeChildren(parentNodeUid, children, options); + } + } + + private async *verifyExpectedNodeChildren( + parentNodeUid: string, + children: MaybeNode[], + options: DiagnosticOptions, + ): AsyncGenerator { + if (!options.expectedStructure) { + return; + } + + const expectedNodes = options.expectedStructure.children ?? []; + const actualNodeNames = children.map((child) => getNodeName(child)); + + for (const expectedNode of expectedNodes) { + if (!actualNodeNames.includes(expectedNode.name)) { + yield { + type: 'expected_structure_missing_node', + expectedNode: getExpectedTreeNodeDetails(expectedNode), + parentNodeUid, + }; + } + } + + for (const child of children) { + const childName = getNodeName(child); + const isExpected = expectedNodes.some((expectedNode) => expectedNode.name === childName); + + if (!isExpected) { + yield { + type: 'expected_structure_unexpected_node', + ...getNodeDetails(child), + }; + } + } } } @@ -275,3 +352,27 @@ function getActiveRevision(node: MaybeNode): Revision | undefined { } return undefined; } + +function getNodeName(node: MaybeNode): string { + if (node.ok) { + return node.value.name; + } + if (node.error.name.ok) { + return node.error.name.value; + } + return 'N/A'; +} + +function getExpectedTreeNodeDetails(expectedNode: ExcpectedTreeNode): ExcpectedTreeNode { + return { + ...expectedNode, + children: undefined, + }; +} + +function getTreeNodeChildByNodeName( + expectedSubtree: ExcpectedTreeNode, + nodeName: string, +): ExcpectedTreeNode | undefined { + return expectedSubtree.children?.find((expectedNode) => expectedNode.name === nodeName); +} From 12c5e24fee35c1ffbf47105c09adf947f8d01fa3 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Oct 2025 07:35:54 +0200 Subject: [PATCH 261/791] js/v0.5.1 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index b679acb5..03cf75ff 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.5.0", + "version": "0.5.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 3942aaba8df36cebd1280db4088265f6acd92f79 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Oct 2025 05:29:10 +0000 Subject: [PATCH 262/791] Add getAvailableName method --- js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/upload.ts | 14 +--- js/sdk/src/internal/nodes/apiService.test.ts | 2 +- js/sdk/src/internal/nodes/apiService.ts | 42 ++++++++++++ js/sdk/src/internal/nodes/cryptoService.ts | 9 +++ js/sdk/src/internal/nodes/index.test.ts | 1 + js/sdk/src/internal/nodes/index.ts | 3 +- js/sdk/src/internal/nodes/nodeName.test.ts | 57 ++++++++++++++++ js/sdk/src/internal/nodes/nodeName.ts | 26 ++++++++ .../internal/nodes/nodesManagement.test.ts | 65 +++++++++++++++++++ js/sdk/src/internal/nodes/nodesManagement.ts | 44 ++++++++++++- js/sdk/src/internal/sharingPublic/index.ts | 3 +- js/sdk/src/internal/upload/apiService.ts | 39 ----------- js/sdk/src/internal/upload/cryptoService.ts | 9 --- js/sdk/src/internal/upload/fileUploader.ts | 5 -- js/sdk/src/internal/upload/manager.test.ts | 65 ------------------- js/sdk/src/internal/upload/manager.ts | 64 ------------------ js/sdk/src/protonDriveClient.ts | 17 ++++- js/sdk/src/protonDrivePhotosClient.ts | 1 + 19 files changed, 266 insertions(+), 202 deletions(-) create mode 100644 js/sdk/src/internal/nodes/nodeName.test.ts create mode 100644 js/sdk/src/internal/nodes/nodeName.ts diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index cfb99d87..9c018022 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -82,7 +82,7 @@ export type { MetricEvent, } from './telemetry'; export { MetricVolumeType } from './telemetry'; -export type { FileUploader, FileRevisionUploader, UploadController, UploadMetadata } from './upload'; +export type { FileUploader, UploadController, UploadMetadata } from './upload'; export type { Thumbnail, ThumbnailResult } from './thumbnail'; export { ThumbnailType } from './thumbnail'; diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index b3d5b73d..f38c8809 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -32,7 +32,7 @@ export type UploadMetadata = { overrideExistingDraftByOtherClient?: boolean; }; -export interface FileRevisionUploader { +export interface FileUploader { /** * Uploads a file from a stream. * @@ -64,18 +64,6 @@ export interface FileRevisionUploader { ): Promise; } -export interface FileUploader extends FileRevisionUploader { - /** - * Returns the available name for the file. - * - * The function will return a name that includes the original name with the - * available index. The name is guaranteed to be unique in the parent folder. - * - * Example new name: `file (2).txt`. - */ - getAvailableName(): Promise; -} - export interface UploadController { pause(): void; resume(): void; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 7dce09fe..603a7f1c 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -173,7 +173,7 @@ describe('nodeAPIService', () => { put: jest.fn(), }; - api = new NodeAPIService(getMockLogger(), apiMock); + api = new NodeAPIService(getMockLogger(), apiMock, 'clientUid'); }); describe('getNode', () => { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index c11bdf57..430f2a9f 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -102,6 +102,14 @@ type PostRestoreRevisionResponse = type DeleteRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['delete']['responses']['200']['content']['application/json']; + +type PostCheckAvailableHashesRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCheckAvailableHashesResponse = + drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['responses']['200']['content']['application/json']; + /** * Provides API communication for fetching and manipulating nodes metadata. * @@ -112,9 +120,11 @@ export class NodeAPIService { constructor( private logger: Logger, private apiService: DriveAPIService, + private clientUid: string | undefined, ) { this.logger = logger; this.apiService = apiService; + this.clientUid = clientUid; } async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise { @@ -526,6 +536,38 @@ export class NodeAPIService { `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, ); } + + async checkAvailableHashes( + parentNodeUid: string, + hashes: string[], + ): Promise<{ + availableHashes: string[]; + pendingHashes: { + hash: string; + nodeUid: string; + revisionUid: string; + clientUid?: string; + }[]; + }> { + const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); + const result = await this.apiService.post( + `drive/v2/volumes/${volumeId}/links/${parentNodeId}/checkAvailableHashes`, + { + Hashes: hashes, + ClientUID: this.clientUid ? [this.clientUid] : null, + }, + ); + + return { + availableHashes: result.AvailableHashes, + pendingHashes: result.PendingHashes.map((hash) => ({ + hash: hash.Hash, + nodeUid: makeNodeUid(volumeId, hash.LinkID), + revisionUid: makeNodeRevisionUid(volumeId, hash.LinkID, hash.RevisionID), + clientUid: hash.ClientUID || undefined, + })), + }; + } } type LinkResponse = { diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index f644d2a8..28d59e76 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -645,6 +645,15 @@ export class NodesCryptoService { nameSignatureEmail: email, }; } + + async generateNameHashes(parentHashKey: Uint8Array, names: string[]): Promise<{ name: string; hash: string }[]> { + return Promise.all( + names.map(async (name) => ({ + name, + hash: await this.driveCrypto.generateLookupHash(name, parentHashKey), + })), + ); + } } function getClaimedAuthor( diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index 3667e131..aacc4ffe 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -64,6 +64,7 @@ describe('nodesModules integration tests', () => { account, driveCrypto, sharesService, + 'clientUid', ); nodesCache = new NodesCache(getMockLogger(), driveEntitiesCache); diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 837e8ed8..471e7cf6 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -37,8 +37,9 @@ export function initNodesModule( account: ProtonDriveAccount, driveCrypto: DriveCrypto, sharesService: SharesService, + clientUid: string | undefined, ) { - const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); + const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService, clientUid); const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService); diff --git a/js/sdk/src/internal/nodes/nodeName.test.ts b/js/sdk/src/internal/nodes/nodeName.test.ts new file mode 100644 index 00000000..5ace64f2 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodeName.test.ts @@ -0,0 +1,57 @@ +import { splitExtension, joinNameAndExtension } from './nodeName'; + +describe('nodeName', () => { + describe('splitExtension', () => { + it('should handle empty string', () => { + const result = splitExtension(''); + expect(result).toEqual(['', '']); + }); + + it('should split filename with extension correctly', () => { + const result = splitExtension('document.pdf'); + expect(result).toEqual(['document', 'pdf']); + }); + + it('should handle filename without extension', () => { + const result = splitExtension('folder'); + expect(result).toEqual(['folder', '']); + }); + + it('should split filename with multiple dots correctly', () => { + const result = splitExtension('my.file.name.txt'); + expect(result).toEqual(['my.file.name', 'txt']); + }); + + it('should handle filename ending with dot', () => { + const result = splitExtension('dot.'); + expect(result).toEqual(['dot.', '']); + }); + + it('should handle filename with only extension', () => { + const result = splitExtension('.gitignore'); + expect(result).toEqual(['.gitignore', '']); + }); + }); + + describe('joinNameAndExtension', () => { + it('should join name, index, and extension correctly', () => { + const result = joinNameAndExtension('document', 1, 'pdf'); + expect(result).toBe('document (1).pdf'); + }); + + it('should handle empty name with extension', () => { + const result = joinNameAndExtension('', 2, 'txt'); + expect(result).toBe('(2).txt'); + }); + + it('should handle name with empty extension', () => { + const result = joinNameAndExtension('document', 3, ''); + expect(result).toBe('document (3)'); + }); + + it('should handle both name and extension empty', () => { + const result = joinNameAndExtension('', 4, ''); + expect(result).toBe('(4)'); + }); + }); +}); diff --git a/js/sdk/src/internal/nodes/nodeName.ts b/js/sdk/src/internal/nodes/nodeName.ts new file mode 100644 index 00000000..56aaa32f --- /dev/null +++ b/js/sdk/src/internal/nodes/nodeName.ts @@ -0,0 +1,26 @@ +/** + * Split a filename into `[name, extension]` + */ +export function splitExtension(filename = ''): [string, string] { + const endIdx = filename.lastIndexOf('.'); + if (endIdx === -1 || endIdx === 0 || endIdx === filename.length - 1) { + return [filename, '']; + } + return [filename.slice(0, endIdx), filename.slice(endIdx + 1)]; +} + +/** + * Join a filename into `name (index).extension` + */ +export function joinNameAndExtension(name: string, index: number, extension: string): string { + if (!name && !extension) { + return `(${index})`; + } + if (!name) { + return `(${index}).${extension}`; + } + if (!extension) { + return `${name} (${index})`; + } + return `${name} (${index}).${extension}`; +} diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 4868bd65..92637e42 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -61,6 +61,10 @@ describe('NodesManagement', () => { yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult); }), createFolder: jest.fn(), + checkAvailableHashes: jest.fn().mockResolvedValue({ + availableHashes: ['name1Hash'], + pendingHashes: [], + }), }; // @ts-expect-error No need to implement all methods for mocking cryptoCache = { @@ -75,6 +79,20 @@ describe('NodesManagement', () => { }), encryptNodeWithNewParent: jest.fn(), createFolder: jest.fn(), + generateNameHashes: jest.fn().mockResolvedValue([ + { + name: 'name1', + hash: 'name1Hash', + }, + { + name: 'name2', + hash: 'name2Hash', + }, + { + name: 'name3', + hash: 'name3Hash', + }, + ]), }; // @ts-expect-error No need to implement all methods for mocking nodesAccess = { @@ -340,4 +358,51 @@ describe('NodesManagement', () => { expect(restored).toEqual(new Set(uids)); expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledTimes(2); }); + + describe('findAvailableName', () => { + it('should find available name', async () => { + apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { + return { + availableHashes: ['name3Hash'], + pendingHashes: [], + }; + }); + + const result = await management.findAvailableName('parentUid', 'name'); + expect(result).toBe('name3'); + expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); + expect(apiService.checkAvailableHashes).toHaveBeenCalledWith('parentUid', [ + 'name1Hash', + 'name2Hash', + 'name3Hash', + ]); + }); + + it('should find available name with multiple pages', async () => { + let firstCall = false; + apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { + if (!firstCall) { + firstCall = true; + return { + // First page has no available hashes + availableHashes: [], + pendingHashes: [], + }; + } + return { + availableHashes: ['name3Hash'], + pendingHashes: [], + }; + }); + + const result = await management.findAvailableName('parentUid', 'name'); + expect(result).toBe('name3'); + expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(2); + expect(apiService.checkAvailableHashes).toHaveBeenCalledWith('parentUid', [ + 'name1Hash', + 'name2Hash', + 'name3Hash', + ]); + }); + }); }); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 4d01ce89..2859e7bb 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -8,10 +8,14 @@ import { NodeAPIService } from './apiService'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; import { NodeOutOfSyncError } from './errors'; +import { generateFolderExtendedAttributes } from './extendedAttributes'; import { DecryptedNode } from './interface'; +import { splitExtension, joinNameAndExtension } from './nodeName'; import { NodesAccess } from './nodesAccess'; import { validateNodeName } from './validations'; -import { generateFolderExtendedAttributes } from './extendedAttributes'; + +const AVAILABLE_NAME_BATCH_SIZE = 10; +const AVAILABLE_NAME_LIMIT = 1000; /** * Provides high-level actions for managing nodes. @@ -349,4 +353,42 @@ export class NodesManagement { await this.cryptoCache.setNodeKeys(nodeUid, keys); return node; } + + async findAvailableName(parentFolderUid: string, name: string): Promise { + const { hashKey: parentHashKey } = await this.nodesAccess.getNodeKeys(parentFolderUid); + if (!parentHashKey) { + throw new ValidationError(c('Error').t`Creating files in non-folders is not allowed`); + } + + const [namePart, extension] = splitExtension(name); + + let startIndex = 1; + while (startIndex < AVAILABLE_NAME_LIMIT) { + const namesToCheck = startIndex === 1 ? [name] : []; + for (let i = startIndex; i < startIndex + AVAILABLE_NAME_BATCH_SIZE; i++) { + namesToCheck.push(joinNameAndExtension(namePart, i, extension)); + } + + const hashesToCheck = await this.cryptoService.generateNameHashes(parentHashKey, namesToCheck); + + const { availableHashes } = await this.apiService.checkAvailableHashes( + parentFolderUid, + hashesToCheck.map(({ hash }) => hash), + ); + + if (!availableHashes.length) { + startIndex += AVAILABLE_NAME_BATCH_SIZE; + continue; + } + + const availableHash = hashesToCheck.find(({ hash }) => hash === availableHashes[0]); + if (!availableHash) { + throw Error('Backend returned unexpected hash'); + } + + return availableHash.name; + } + + throw new ValidationError(c('Error').t`No available name found`); + } } diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 1e04ac7a..c0de973b 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -78,7 +78,8 @@ export function initSharingPublicNodesModule( publicShareKey: PrivateKey, publicRootNodeUid: string, ) { - const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService); + const clientUid = undefined; // No client UID for public context yet. + const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService, clientUid); const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new SharingPublicCryptoReporter(telemetry); diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index f44ab1e8..a6df2f4b 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -6,13 +6,6 @@ import { splitNodeUid, makeNodeUid, splitNodeRevisionUid, makeNodeRevisionUid } import { UploadTokens } from './interface'; import { ThumbnailType } from '../../interface'; -type PostCheckAvailableHashesRequest = Extract< - drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['requestBody'], - { content: object } ->['content']['application/json']; -type PostCheckAvailableHashesResponse = - drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['responses']['200']['content']['application/json']; - type PostCreateDraftRequest = Extract< drivePaths['/drive/v2/volumes/{volumeID}/files']['post']['requestBody'], { content: object } @@ -60,38 +53,6 @@ export class UploadAPIService { this.clientUid = clientUid; } - async checkAvailableHashes( - parentNodeUid: string, - hashes: string[], - ): Promise<{ - availalbleHashes: string[]; - pendingHashes: { - hash: string; - nodeUid: string; - revisionUid: string; - clientUid?: string; - }[]; - }> { - const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentNodeUid); - const result = await this.apiService.post( - `drive/v2/volumes/${volumeId}/links/${parentNodeId}/checkAvailableHashes`, - { - Hashes: hashes, - ClientUID: this.clientUid ? [this.clientUid] : null, - }, - ); - - return { - availalbleHashes: result.AvailableHashes, - pendingHashes: result.PendingHashes.map((hash) => ({ - hash: hash.Hash, - nodeUid: makeNodeUid(volumeId, hash.LinkID), - revisionUid: makeNodeRevisionUid(volumeId, hash.LinkID, hash.RevisionID), - clientUid: hash.ClientUID || undefined, - })), - }; - } - async createDraft( parentNodeUid: string, node: { diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 555786c7..57643a76 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -40,15 +40,6 @@ export class UploadCryptoService { }; } - async generateNameHashes(parentHashKey: Uint8Array, names: string[]): Promise<{ name: string; hash: string }[]> { - return Promise.all( - names.map(async (name) => ({ - name, - hash: await this.driveCrypto.generateLookupHash(name, parentHashKey), - })), - ); - } - async encryptThumbnail( nodeRevisionDraftKeys: NodeRevisionDraftKeys, thumbnail: Thumbnail, diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index e555ac0b..34370222 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -176,11 +176,6 @@ export class FileUploader extends Uploader { blockVerifier, }; } - - async getAvailableName(): Promise { - const availableName = await this.manager.findAvailableName(this.parentFolderUid, this.name); - return availableName; - } } /** diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 605ac121..8ed2128f 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -26,10 +26,6 @@ describe('UploadManager', () => { nodeRevisionUid: 'newNode:nodeRevisionUid', }), deleteDraft: jest.fn(), - checkAvailableHashes: jest.fn().mockResolvedValue({ - availalbleHashes: ['name1Hash'], - pendingHashes: [], - }), commitDraftRevision: jest.fn(), }; // @ts-expect-error No need to implement all methods for mocking @@ -58,20 +54,6 @@ describe('UploadManager', () => { email: 'signatureEmail', }, }), - generateNameHashes: jest.fn().mockResolvedValue([ - { - name: 'name1', - hash: 'name1Hash', - }, - { - name: 'name2', - hash: 'name2Hash', - }, - { - name: 'name3', - hash: 'name3Hash', - }, - ]), commitFile: jest.fn().mockResolvedValue({ armoredManifestSignature: 'newNode:armoredManifestSignature', signatureEmail: 'signatureEmail', @@ -280,53 +262,6 @@ describe('UploadManager', () => { }); }); - describe('findAvailableName', () => { - it('should find available name', async () => { - apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { - return { - availalbleHashes: ['name3Hash'], - pendingHashes: [], - }; - }); - - const result = await manager.findAvailableName('parentUid', 'name'); - expect(result).toBe('name3'); - expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(1); - expect(apiService.checkAvailableHashes).toHaveBeenCalledWith('parentUid', [ - 'name1Hash', - 'name2Hash', - 'name3Hash', - ]); - }); - - it('should find available name with multiple pages', async () => { - let firstCall = false; - apiService.checkAvailableHashes = jest.fn().mockImplementation(() => { - if (!firstCall) { - firstCall = true; - return { - // First page has no available hashes - availalbleHashes: [], - pendingHashes: [], - }; - } - return { - availalbleHashes: ['name3Hash'], - pendingHashes: [], - }; - }); - - const result = await manager.findAvailableName('parentUid', 'name'); - expect(result).toBe('name3'); - expect(apiService.checkAvailableHashes).toHaveBeenCalledTimes(2); - expect(apiService.checkAvailableHashes).toHaveBeenCalledWith('parentUid', [ - 'name1Hash', - 'name2Hash', - 'name3Hash', - ]); - }); - }); - describe('commit draft', () => { const nodeRevisionDraft = { nodeUid: 'newNode:nodeUid', diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 4c97cd33..31bcd2f5 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -173,43 +173,6 @@ export class UploadManager { } } - async findAvailableName(parentFolderUid: string, name: string): Promise { - const { hashKey: parentHashKey } = await this.nodesService.getNodeKeys(parentFolderUid); - if (!parentHashKey) { - throw new ValidationError(c('Error').t`Creating files in non-folders is not allowed`); - } - - const [namePart, extension] = splitExtension(name); - - const batchSize = 10; - let startIndex = 1; - while (true) { - const namesToCheck = []; - for (let i = startIndex; i < startIndex + batchSize; i++) { - namesToCheck.push(joinNameAndExtension(namePart, i, extension)); - } - - const hashesToCheck = await this.cryptoService.generateNameHashes(parentHashKey, namesToCheck); - - const { availalbleHashes } = await this.apiService.checkAvailableHashes( - parentFolderUid, - hashesToCheck.map(({ hash }) => hash), - ); - - if (!availalbleHashes.length) { - startIndex += batchSize; - continue; - } - - const availableHash = hashesToCheck.find(({ hash }) => hash === availalbleHashes[0]); - if (!availableHash) { - throw Error('Backend returned unexpected hash'); - } - - return availableHash.name; - } - } - async deleteDraftNode(nodeUid: string): Promise { try { await this.apiService.deleteDraft(nodeUid); @@ -294,30 +257,3 @@ export class UploadManager { } } } - -/** - * Split a filename into `[name, extension]` - */ -function splitExtension(filename = ''): [string, string] { - const endIdx = filename.lastIndexOf('.'); - if (endIdx === -1 || endIdx === filename.length - 1) { - return [filename, '']; - } - return [filename.slice(0, endIdx), filename.slice(endIdx + 1)]; -} - -/** - * Join a filename into `name (index).extension` - */ -function joinNameAndExtension(name: string, index: number, extension: string): string { - if (!name && !extension) { - return `(${index})`; - } - if (!name) { - return `(${index}).${extension}`; - } - if (!extension) { - return `${name} (${index})`; - } - return `${name} (${index}).${extension}`; -} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index ec98c27c..8d98a730 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -24,7 +24,6 @@ import { UploadMetadata, FileDownloader, FileUploader, - FileRevisionUploader, ThumbnailType, ThumbnailResult, SDKEvent, @@ -144,6 +143,7 @@ export class ProtonDriveClient { account, cryptoModule, this.shares, + fullConfig.clientUid, ); this.sharing = initSharingModule( telemetry, @@ -829,11 +829,24 @@ export class ProtonDriveClient { nodeUid: NodeOrUid, metadata: UploadMetadata, signal?: AbortSignal, - ): Promise { + ): Promise { this.logger.info(`Getting file revision uploader for ${getUid(nodeUid)}`); return this.upload.getFileRevisionUploader(getUid(nodeUid), metadata, signal); } + /** + * Returns the available name for the file in the given parent folder. + * + * The function will return a name that includes the original name with the + * available index. The name is guaranteed to be unique in the parent folder. + * + * Example new name: `file (2).txt`. + */ + async getAvailableName(parentFolderUid: NodeOrUid, name: string): Promise { + this.logger.info(`Getting available name in folder ${getUid(parentFolderUid)}`); + return this.nodes.management.findAvailableName(getUid(parentFolderUid), name); + } + /** * Iterates the devices of the user. * diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index b57ad7c1..311cbca0 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -102,6 +102,7 @@ export class ProtonDrivePhotosClient { account, cryptoModule, this.photoShares, + fullConfig.clientUid, ); this.photos = initPhotosModule(apiService, this.photoShares, this.nodes.access); this.sharing = initSharingModule( From c462a149e3339d26f00be70c46e257c1379b9272 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Oct 2025 13:58:32 +0200 Subject: [PATCH 263/791] Expose sharing for Photos SDK --- js/sdk/src/protonDriveClient.ts | 6 + js/sdk/src/protonDrivePhotosClient.ts | 192 +++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 7 deletions(-) diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 8d98a730..7f7d513e 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -703,6 +703,12 @@ export class ProtonDriveClient { return this.sharing.management.unshareNode(getUid(nodeUid), settings); } + /** + * Resend the invitation email to shared node. + * + * @param nodeUid - Node entity or its UID string. + * @param invitationUid - Invitation entity or its UID string. + */ async resendInvitation( nodeUid: NodeOrUid, invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid, diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 311cbca0..0b8ff421 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -10,12 +10,20 @@ import { MaybeNode, ThumbnailType, ThumbnailResult, + ShareNodeSettings, + ShareResult, + UnshareNodeSettings, + ProtonInvitationOrUid, + NonProtonInvitationOrUid, + ProtonInvitationWithNode, + NodeResult, } from './interface'; import { getConfig } from './config'; import { DriveCrypto } from './crypto'; import { Telemetry } from './telemetry'; import { convertInternalMissingNodeIterator, + convertInternalNode, convertInternalNodeIterator, convertInternalNodePromise, getUid, @@ -72,7 +80,7 @@ export class ProtonDrivePhotosClient { if (!telemetry) { telemetry = new Telemetry(); } - this.logger = telemetry.getLogger('interface'); + this.logger = telemetry.getLogger('photos-interface'); const fullConfig = getConfig(config); this.sdkEvents = new SDKEvents(telemetry); @@ -205,6 +213,16 @@ export class ProtonDrivePhotosClient { yield* this.photos.timeline.iterateTimeline(signal); } + /** + * Iterates the trashed nodes. + * + * See `ProtonDriveClient.iterateTrashedNodes` for more information. + */ + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating trashed nodes'); + yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); + } + /** * Iterates the nodes by their UIDs. * @@ -227,14 +245,163 @@ export class ProtonDrivePhotosClient { } /** - * Iterates the albums. + * Rename the node. * - * The output is not sorted and the order of the nodes is not guaranteed. + * See `ProtonDriveClient.renameNode` for more information. */ - async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { - this.logger.info('Iterating albums'); - // TODO: expose album type - yield* convertInternalNodeIterator(this.photos.albums.iterateAlbums(signal)); + async renameNode(nodeUid: NodeOrUid, newName: string): Promise { + this.logger.info(`Renaming node ${getUid(nodeUid)}`); + return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); + } + + /** + * Trash the nodes. + * + * See `ProtonDriveClient.trashNodes` for more information. + */ + async *trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Trashing ${nodeUids.length} nodes`); + yield* this.nodes.management.trashNodes(getUids(nodeUids), signal); + } + + /** + * Restore the nodes from the trash to their original place. + * + * See `ProtonDriveClient.restoreNodes` for more information. + */ + async *restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Restoring ${nodeUids.length} nodes`); + yield* this.nodes.management.restoreNodes(getUids(nodeUids), signal); + } + + /** + * Delete the nodes permanently. + * + * See `ProtonDriveClient.deleteNodes` for more information. + */ + async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Deleting ${nodeUids.length} nodes`); + yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal); + } + + /** + * Empty the trash. + * + * See `ProtonDriveClient.emptyTrash` for more information. + */ + async emptyTrash(): Promise { + this.logger.info('Emptying trash'); + throw new Error('Method not implemented'); + } + + /** + * Iterates the nodes shared by the user. + * + * See `ProtonDriveClient.iterateSharedNodes` for more information. + */ + async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating shared nodes by me'); + yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); + } + + /** + * Iterates the nodes shared with the user. + * + * See `ProtonDriveClient.iterateSharedNodesWithMe` for more information. + */ + async *iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating shared nodes with me'); + + for await (const node of this.sharing.access.iterateSharedNodesWithMe(signal)) { + yield convertInternalNode(node); + } + } + + /** + * Leave shared node that was previously shared with the user. + * + * See `ProtonDriveClient.leaveSharedNode` for more information. + */ + async leaveSharedNode(nodeUid: NodeOrUid): Promise { + this.logger.info(`Leaving shared node with me ${getUid(nodeUid)}`); + await this.sharing.access.removeSharedNodeWithMe(getUid(nodeUid)); + } + + /** + * Iterates the invitations to shared nodes. + * + * See `ProtonDriveClient.iterateInvitations` for more information. + */ + async *iterateInvitations(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating invitations'); + yield* this.sharing.access.iterateInvitations(signal); + } + + /** + * Accept the invitation to the shared node. + * + * See `ProtonDriveClient.acceptInvitation` for more information. + */ + async acceptInvitation(invitationUid: ProtonInvitationOrUid): Promise { + this.logger.info(`Accepting invitation ${getUid(invitationUid)}`); + await this.sharing.access.acceptInvitation(getUid(invitationUid)); + } + + /** + * Reject the invitation to the shared node. + * + * See `ProtonDriveClient.rejectInvitation` for more information. + */ + async rejectInvitation(invitationUid: ProtonInvitationOrUid): Promise { + this.logger.info(`Rejecting invitation ${getUid(invitationUid)}`); + await this.sharing.access.rejectInvitation(getUid(invitationUid)); + } + + /** + * Get sharing info of the node. + * + * See `ProtonDriveClient.getSharingInfo` for more information. + */ + async getSharingInfo(nodeUid: NodeOrUid): Promise { + this.logger.info(`Getting sharing info for ${getUid(nodeUid)}`); + return this.sharing.management.getSharingInfo(getUid(nodeUid)); + } + + /** + * Share or update sharing of the node. + * + * See `ProtonDriveClient.shareNode` for more information. + */ + async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings): Promise { + this.logger.info(`Sharing node ${getUid(nodeUid)}`); + return this.sharing.management.shareNode(getUid(nodeUid), settings); + } + + /** + * Unshare the node, completely or partially. + * + * See `ProtonDriveClient.unshareNode` for more information. + */ + async unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings): Promise { + if (!settings) { + this.logger.info(`Unsharing node ${getUid(nodeUid)}`); + } else { + this.logger.info(`Partially unsharing ${getUid(nodeUid)}`); + } + return this.sharing.management.unshareNode(getUid(nodeUid), settings); + } + + /** + * Resend the invitation email to shared node. + * + * See `ProtonDriveClient.resendInvitation` for more information. + */ + async resendInvitation( + nodeUid: NodeOrUid, + invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid, + ): Promise { + this.logger.info(`Resending invitation ${getUid(invitationUid)}`); + return this.sharing.management.resendInvitationEmail(getUid(nodeUid), getUid(invitationUid)); } /** @@ -280,4 +447,15 @@ export class ProtonDrivePhotosClient { const parentFolderUid = await this.nodes.access.getVolumeRootFolder(); return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } + + /** + * Iterates the albums. + * + * The output is not sorted and the order of the nodes is not guaranteed. + */ + async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating albums'); + // TODO: expose album type + yield* convertInternalNodeIterator(this.photos.albums.iterateAlbums(signal)); + } } From d72330fdf5ed48e1e5910dfd18a527780bd9a7e7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Oct 2025 10:01:46 +0200 Subject: [PATCH 264/791] Parametrize shared with me and invitations for Photos SDK --- js/sdk/src/internal/photos/index.ts | 4 ++++ js/sdk/src/internal/shares/index.ts | 1 + js/sdk/src/internal/shares/interface.ts | 9 ++++++++ js/sdk/src/internal/sharing/apiService.ts | 27 +++++++++++++---------- js/sdk/src/internal/sharing/index.ts | 8 ++++++- js/sdk/src/protonDrivePhotosClient.ts | 8 ++++++- 6 files changed, 43 insertions(+), 14 deletions(-) diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index d64874fa..8eb8955b 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -24,6 +24,10 @@ import { PhotoUploadManager, PhotoUploadMetadata, } from './upload'; +import { ShareTargetType } from '../shares'; + +// Only photos and albums can be shared in photos volume. +export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album]; /** * Provides facade for the whole photos module. diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts index 7d87cbf6..ddfe6c53 100644 --- a/js/sdk/src/internal/shares/index.ts +++ b/js/sdk/src/internal/shares/index.ts @@ -12,6 +12,7 @@ import { SharesCache } from './cache'; import { SharesCryptoService } from './cryptoService'; import { SharesManager } from './manager'; +export { ShareTargetType } from './interface'; export type { EncryptedShare } from './interface'; /** diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index dde85669..dba5b48d 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -1,6 +1,15 @@ import { PrivateKey, SessionKey } from '../../crypto'; import { Result, UnverifiedAuthorError } from '../../interface'; +export enum ShareTargetType { + Root = 0, + Folder = 1, + File = 2, + Album = 3, + Photo = 4, + ProtonVendor = 5, +} + /** * Internal interface providing basic identification of volume and its root * share and node. diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 8d5a5aa7..49ca02e1 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -7,6 +7,7 @@ import { permissionsToMemberRole, memberRoleToPermission, } from '../apiService'; +import { ShareTargetType } from '../shares'; import { makeNodeUid, splitNodeUid, @@ -119,14 +120,6 @@ type PutShareUrlRequest = Extract< type PutShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls/{urlID}']['put']['responses']['200']['content']['application/json']; -// We do not support photos and albums yet. -const SUPPORTED_SHARE_TARGET_TYPES = [ - 0, // Root - 1, // Folder - 2, // File - 5, // Proton vendor (documents and sheets) -]; - /** * Provides API communication for fetching and managing sharing. * @@ -137,9 +130,11 @@ export class SharingAPIService { constructor( private logger: Logger, private apiService: DriveAPIService, + private shareTargetTypes: ShareTargetType[], ) { this.logger = logger; this.apiService = apiService; + this.shareTargetTypes = shareTargetTypes; } async *iterateSharedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { @@ -163,6 +158,7 @@ export class SharingAPIService { async *iterateSharedWithMeNodeUids(signal?: AbortSignal): AsyncGenerator { let anchor = ''; while (true) { + // TODO: Use ShareTargetTypes filter when it is supported by the API. const response = await this.apiService.get( `drive/v2/sharedwithme?${anchor ? `AnchorID=${anchor}` : ''}`, signal, @@ -170,8 +166,8 @@ export class SharingAPIService { for (const link of response.Links) { const nodeUid = makeNodeUid(link.VolumeID, link.LinkID); - if (!SUPPORTED_SHARE_TARGET_TYPES.includes(link.ShareTargetType)) { - this.logger.warn(`Unsupported share target type ${link.ShareTargetType} for node ${nodeUid}`); + if (!this.shareTargetTypes.includes(link.ShareTargetType)) { + this.logger.debug(`Unsupported share target type ${link.ShareTargetType} for node ${nodeUid}`); continue; } @@ -188,14 +184,21 @@ export class SharingAPIService { async *iterateInvitationUids(signal?: AbortSignal): AsyncGenerator { let anchor = ''; while (true) { + const params = new URLSearchParams(); + this.shareTargetTypes.forEach((type) => { + params.append('ShareTargetTypes[]', type.toString()); + }); + if (anchor) { + params.append('AnchorID', anchor); + } const response = await this.apiService.get( - `drive/v2/shares/invitations?${anchor ? `AnchorID=${anchor}` : ''}`, + `drive/v2/shares/invitations?${params.toString()}`, signal, ); for (const invitation of response.Invitations) { const invitationUid = makeInvitationUid(invitation.ShareID, invitation.InvitationID); - if (!SUPPORTED_SHARE_TARGET_TYPES.includes(invitation.ShareTargetType)) { + if (!this.shareTargetTypes.includes(invitation.ShareTargetType)) { this.logger.warn( `Unsupported share target type ${invitation.ShareTargetType} for invitation ${invitationUid}`, ); diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index 7d995915..2951e288 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -1,6 +1,7 @@ import { ProtonDriveAccount, ProtonDriveEntitiesCache, ProtonDriveTelemetry } from '../../interface'; import { DriveCrypto } from '../../crypto'; import { DriveAPIService } from '../apiService'; +import { ShareTargetType } from '../shares'; import { SharingAPIService } from './apiService'; import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; @@ -9,6 +10,10 @@ import { SharingManagement } from './sharingManagement'; import { SharesService, NodesService } from './interface'; import { SharingEventHandler } from './events'; +// Root shares are not allowed to be shared. +// Photos and Albums are not supported in main volume (core Drive). +const DEFAULT_SHARE_TARGET_TYPES = [ShareTargetType.Folder, ShareTargetType.File, ShareTargetType.ProtonVendor]; + /** * Provides facade for the whole sharing module. * @@ -24,8 +29,9 @@ export function initSharingModule( crypto: DriveCrypto, sharesService: SharesService, nodesService: NodesService, + shareTargetTypes: ShareTargetType[] = DEFAULT_SHARE_TARGET_TYPES, ) { - const api = new SharingAPIService(telemetry.getLogger('sharing-api'), apiService); + const api = new SharingAPIService(telemetry.getLogger('sharing-api'), apiService, shareTargetTypes); const cache = new SharingCache(driveEntitiesCache); const cryptoService = new SharingCryptoService(telemetry, crypto, account, sharesService); const sharingAccess = new SharingAccess(api, cache, cryptoService, sharesService, nodesService); diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 0b8ff421..e7034241 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -33,7 +33,12 @@ import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; import { initNodesModule } from './internal/nodes'; -import { initPhotosModule, initPhotoSharesModule, initPhotoUploadModule } from './internal/photos'; +import { + PHOTOS_SHARE_TARGET_TYPES, + initPhotosModule, + initPhotoSharesModule, + initPhotoUploadModule, +} from './internal/photos'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; import { initSharingModule } from './internal/sharing'; @@ -121,6 +126,7 @@ export class ProtonDrivePhotosClient { cryptoModule, this.photoShares, this.nodes.access, + PHOTOS_SHARE_TARGET_TYPES, ); this.download = initDownloadModule( telemetry, From 9edcc340cd93c5b21997553f5c8105565e999d0d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Oct 2025 14:50:24 +0200 Subject: [PATCH 265/791] js/v0.6.0 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 03cf75ff..b2ba59b3 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.5.1", + "version": "0.6.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 3eb6787c327799885513dbfb19985fdcf5bf50b8 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Oct 2025 05:20:24 +0000 Subject: [PATCH 266/791] Ignore TimeoutError and similar from decryption issues --- js/sdk/src/internal/errors.test.ts | 63 ++++++++++++++++++- js/sdk/src/internal/errors.ts | 27 ++++++++ js/sdk/src/internal/nodes/cryptoReporter.ts | 11 ++-- js/sdk/src/internal/shares/cryptoService.ts | 6 +- .../internal/sharingPublic/cryptoReporter.ts | 6 +- 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/js/sdk/src/internal/errors.test.ts b/js/sdk/src/internal/errors.test.ts index 71eb88b7..e0b164e8 100644 --- a/js/sdk/src/internal/errors.test.ts +++ b/js/sdk/src/internal/errors.test.ts @@ -1,5 +1,6 @@ import { VERIFICATION_STATUS } from '../crypto'; -import { getVerificationMessage } from './errors'; +import { AbortError, ConnectionError, RateLimitedError, ValidationError } from '../errors'; +import { getVerificationMessage, isNotApplicationError } from './errors'; describe('getVerificationMessage', () => { const testCases: [VERIFICATION_STATUS, Error[] | undefined, string | undefined, boolean, string][] = [ @@ -53,3 +54,63 @@ describe('getVerificationMessage', () => { }); } }); + +describe('isNotApplicationError', () => { + describe('SDK errors that should be ignored', () => { + it('returns true for AbortError', () => { + const error = new AbortError('Operation aborted'); + expect(isNotApplicationError(error)).toBe(true); + }); + + it('returns true for ValidationError', () => { + const error = new ValidationError('Validation failed'); + expect(isNotApplicationError(error)).toBe(true); + }); + + it('returns true for RateLimitedError', () => { + const error = new RateLimitedError('Rate limited'); + expect(isNotApplicationError(error)).toBe(true); + }); + + it('returns true for ConnectionError', () => { + const error = new ConnectionError('Connection failed'); + expect(isNotApplicationError(error)).toBe(true); + }); + }); + + describe('General errors with specific names that should be ignored', () => { + it('returns true for Error with name AbortError', () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + expect(isNotApplicationError(error)).toBe(true); + }); + + it('returns true for Error with name OfflineError', () => { + const error = new Error('Offline'); + error.name = 'OfflineError'; + expect(isNotApplicationError(error)).toBe(true); + }); + + it('returns true for Error with name TimeoutError', () => { + const error = new Error('Timeout'); + error.name = 'TimeoutError'; + expect(isNotApplicationError(error)).toBe(true); + }); + }); + + describe('Errors that should not be ignored', () => { + it('returns false for regular Error', () => { + const error = new Error('Regular error'); + expect(isNotApplicationError(error)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isNotApplicationError(undefined)).toBe(false); + }); + + it('returns false for non-Error object', () => { + const error = { message: 'Not an error' }; + expect(isNotApplicationError(error)).toBe(false); + }); + }); +}); diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index e07b03ec..0ff7742c 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -1,6 +1,7 @@ import { c } from 'ttag'; import { VERIFICATION_STATUS } from '../crypto'; +import { AbortError, ConnectionError, RateLimitedError, ValidationError } from '../errors'; export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : c('Error').t`Unknown error`; @@ -36,3 +37,29 @@ export function getVerificationMessage( ? c('Error').t`Signature verification for ${signatureType} failed` : c('Error').t`Signature verification failed`; } + +/** + * Returns true if the error is not an application error (it is for example + * a network error failing to fetch keys) and can be ignored for telemetry. + */ +export function isNotApplicationError(error?: unknown): boolean { + // SDK errors. + if ( + error instanceof AbortError || + error instanceof ValidationError || + error instanceof RateLimitedError || + error instanceof ConnectionError + ) { + return true; + } + + // General errors that can come from the SDK dependencies (notably Account + // dependency which loads the keys for the crypto services). + if (error instanceof Error) { + if (error.name === 'AbortError' || error.name === 'OfflineError' || error.name === 'TimeoutError') { + return true; + } + } + + return false; +} diff --git a/js/sdk/src/internal/nodes/cryptoReporter.ts b/js/sdk/src/internal/nodes/cryptoReporter.ts index 8164d218..76f51330 100644 --- a/js/sdk/src/internal/nodes/cryptoReporter.ts +++ b/js/sdk/src/internal/nodes/cryptoReporter.ts @@ -9,12 +9,9 @@ import { MetricsDecryptionErrorField, MetricVerificationErrorField, } from '../../interface'; -import { getVerificationMessage } from '../errors'; +import { getVerificationMessage, isNotApplicationError } from '../errors'; import { splitNodeUid } from '../uids'; -import { - EncryptedNode, - SharesService, -} from './interface'; +import { EncryptedNode, SharesService } from './interface'; export class NodesCryptoReporter { private logger: Logger; @@ -92,6 +89,10 @@ export class NodesCryptoReporter { } async reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) { + if (isNotApplicationError(error)) { + return; + } + if (this.reportedDecryptionErrors.has(node.uid)) { return; } diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index dfda89bd..73768e41 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -9,7 +9,7 @@ import { MetricVolumeType, } from '../../interface'; import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from '../../crypto'; -import { getVerificationMessage } from '../errors'; +import { getVerificationMessage, isNotApplicationError } from '../errors'; import { EncryptedRootShare, DecryptedRootShare, @@ -119,6 +119,10 @@ export class SharesCryptoService { } private reportDecryptionError(share: EncryptedRootShare, error?: unknown) { + if (isNotApplicationError(error)) { + return; + } + if (this.reportedDecryptionErrors.has(share.shareId)) { return; } diff --git a/js/sdk/src/internal/sharingPublic/cryptoReporter.ts b/js/sdk/src/internal/sharingPublic/cryptoReporter.ts index 2ae2e866..e3fd7be3 100644 --- a/js/sdk/src/internal/sharingPublic/cryptoReporter.ts +++ b/js/sdk/src/internal/sharingPublic/cryptoReporter.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { VERIFICATION_STATUS } from '../../crypto'; -import { getVerificationMessage } from '../errors'; +import { getVerificationMessage, isNotApplicationError } from '../errors'; import { resultOk, resultError, @@ -49,6 +49,10 @@ export class SharingPublicCryptoReporter { field: MetricsDecryptionErrorField, error: unknown, ) { + if (isNotApplicationError(error)) { + return; + } + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); this.logger.error( From 3acc288af7cea6851ef436c68af6f5e56755c33d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Oct 2025 10:50:16 +0200 Subject: [PATCH 267/791] Add diagnostic progress --- js/sdk/src/diagnostic/diagnostic.ts | 21 +- js/sdk/src/diagnostic/index.ts | 8 +- js/sdk/src/diagnostic/interface.ts | 17 +- js/sdk/src/diagnostic/sdkDiagnostic.ts | 260 ++++++++++++++++--------- 4 files changed, 207 insertions(+), 99 deletions(-) diff --git a/js/sdk/src/diagnostic/diagnostic.ts b/js/sdk/src/diagnostic/diagnostic.ts index 1ea58697..53da2209 100644 --- a/js/sdk/src/diagnostic/diagnostic.ts +++ b/js/sdk/src/diagnostic/diagnostic.ts @@ -1,7 +1,7 @@ import { MaybeNode } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; import { DiagnosticHTTPClient } from './httpClient'; -import { DiagnosticOptions, DiagnosticResult } from './interface'; +import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult } from './interface'; import { SDKDiagnostic } from './sdkDiagnostic'; import { DiagnosticTelemetry } from './telemetry'; import { zipGenerators } from './zipGenerators'; @@ -21,14 +21,21 @@ export class Diagnostic { this.protonDriveClient = protonDriveClient; } - async *verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { - const diagnostic = new SDKDiagnostic(this.protonDriveClient); - yield* this.yieldEvents(diagnostic.verifyMyFiles(options)); + async *verifyMyFiles( + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator { + const diagnostic = new SDKDiagnostic(this.protonDriveClient, options, onProgress); + yield* this.yieldEvents(diagnostic.verifyMyFiles(options?.expectedStructure)); } - async *verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { - const diagnostic = new SDKDiagnostic(this.protonDriveClient); - yield* this.yieldEvents(diagnostic.verifyNodeTree(node, options)); + async *verifyNodeTree( + node: MaybeNode, + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator { + const diagnostic = new SDKDiagnostic(this.protonDriveClient, options, onProgress); + yield* this.yieldEvents(diagnostic.verifyNodeTree(node, options?.expectedStructure)); } private async *yieldEvents(generator: AsyncGenerator): AsyncGenerator { diff --git a/js/sdk/src/diagnostic/index.ts b/js/sdk/src/diagnostic/index.ts index 51455242..15447390 100644 --- a/js/sdk/src/diagnostic/index.ts +++ b/js/sdk/src/diagnostic/index.ts @@ -6,7 +6,13 @@ import { Diagnostic } from './interface'; import { DiagnosticHTTPClient } from './httpClient'; import { DiagnosticTelemetry } from './telemetry'; -export type { Diagnostic, DiagnosticResult } from './interface'; +export type { + Diagnostic, + DiagnosticOptions, + ExcpectedTreeNode, + DiagnosticProgressCallback, + DiagnosticResult, +} from './interface'; /** * Initializes the diagnostic tool. It creates the instance of diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index deff65d1..b36dc96c 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -2,8 +2,15 @@ import { Author, MaybeNode, MetricEvent, NodeType, AnonymousUser } from '../inte import { LogRecord } from '../telemetry'; export interface Diagnostic { - verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator; - verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator; + verifyMyFiles( + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator; + verifyNodeTree( + node: MaybeNode, + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator; } export type DiagnosticOptions = { @@ -20,6 +27,12 @@ export type ExcpectedTreeNode = { children?: ExcpectedTreeNode[]; }; +export type DiagnosticProgressCallback = (progress: { + allNodesLoaded: boolean; + loadedNodes: number; + checkedNodes: number; +}) => void; + export type DiagnosticResult = | FatalErrorResult | SdkErrorResult diff --git a/js/sdk/src/diagnostic/sdkDiagnostic.ts b/js/sdk/src/diagnostic/sdkDiagnostic.ts index 010e8ed4..9e33953a 100644 --- a/js/sdk/src/diagnostic/sdkDiagnostic.ts +++ b/js/sdk/src/diagnostic/sdkDiagnostic.ts @@ -1,7 +1,16 @@ import { Author, FileDownloader, MaybeNode, NodeType, Revision, ThumbnailType } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; -import { DiagnosticOptions, DiagnosticResult, NodeDetails, ExcpectedTreeNode } from './interface'; +import { + DiagnosticOptions, + DiagnosticResult, + NodeDetails, + ExcpectedTreeNode, + DiagnosticProgressCallback, +} from './interface'; import { IntegrityVerificationStream } from './integrityVerificationStream'; +import { zipGenerators } from './zipGenerators'; + +const PROGRESS_REPORT_INTERVAL = 500; /** * Diagnostic tool that uses SDK to traverse the node tree and verify @@ -11,11 +20,55 @@ import { IntegrityVerificationStream } from './integrityVerificationStream'; * To get the full diagnostic, use {@link FullSDKDiagnostic}. */ export class SDKDiagnostic { - constructor(private protonDriveClient: ProtonDriveClient) { + private options: Pick; + + private onProgress?: DiagnosticProgressCallback; + private progressReportInterval: NodeJS.Timeout | undefined; + + private nodesQueue: { node: MaybeNode; expected?: ExcpectedTreeNode }[] = []; + private allNodesLoaded: boolean = false; + private loadedNodes: number = 0; + private checkedNodes: number = 0; + + constructor( + private protonDriveClient: ProtonDriveClient, + options?: Pick, + onProgress?: DiagnosticProgressCallback, + ) { this.protonDriveClient = protonDriveClient; + this.options = options || { verifyContent: false, verifyThumbnails: false }; + this.onProgress = onProgress; } - async *verifyMyFiles(options?: DiagnosticOptions): AsyncGenerator { + private startProgress(): void { + this.allNodesLoaded = false; + this.loadedNodes = 0; + this.checkedNodes = 0; + + this.reportProgress(); + this.progressReportInterval = setInterval(() => { + this.reportProgress(); + }, PROGRESS_REPORT_INTERVAL); + } + + private finishProgress(): void { + if (this.progressReportInterval) { + clearInterval(this.progressReportInterval); + this.progressReportInterval = undefined; + } + + this.reportProgress(); + } + + private reportProgress(): void { + this.onProgress?.({ + allNodesLoaded: this.allNodesLoaded, + loadedNodes: this.loadedNodes, + checkedNodes: this.checkedNodes, + }); + } + + async *verifyMyFiles(expectedStructure?: ExcpectedTreeNode): AsyncGenerator { let myFilesRootFolder: MaybeNode; try { @@ -29,20 +82,118 @@ export class SDKDiagnostic { return; } - yield* this.verifyNodeTree(myFilesRootFolder, options); + yield* this.verifyNodeTree(myFilesRootFolder, expectedStructure); } - async *verifyNodeTree(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { - const isFolder = getNodeType(node) === NodeType.Folder; - - yield* this.verifyNode(node, options); + async *verifyNodeTree(node: MaybeNode, expectedStructure?: ExcpectedTreeNode): AsyncGenerator { + this.startProgress(); + this.nodesQueue.push({ node, expected: expectedStructure }); + this.loadedNodes++; + yield* zipGenerators(this.loadNodeTree(node, expectedStructure), this.verifyNodesQueue()); + this.finishProgress(); + } + private async *loadNodeTree( + parentNode: MaybeNode, + expectedStructure?: ExcpectedTreeNode, + ): AsyncGenerator { + const isFolder = getNodeType(parentNode) === NodeType.Folder; if (isFolder) { - yield* this.verifyNodeChildren(node, options); + yield* this.loadNodeTreeRecursively(parentNode, expectedStructure); + } + this.allNodesLoaded = true; + } + + private async *loadNodeTreeRecursively( + parentNode: MaybeNode, + expectedStructure?: ExcpectedTreeNode, + ): AsyncGenerator { + const parentNodeUid = parentNode.ok ? parentNode.value.uid : parentNode.error.uid; + const children: MaybeNode[] = []; + + try { + for await (const child of this.protonDriveClient.iterateFolderChildren(parentNode)) { + children.push(child); + this.nodesQueue.push({ + node: child, + expected: getTreeNodeChildByNodeName(expectedStructure, getNodeName(child)), + }); + this.loadedNodes++; + } + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `iterateFolderChildren(${parentNodeUid})`, + error, + }; + } + + if (expectedStructure) { + yield* this.verifyExpectedNodeChildren(parentNodeUid, children, expectedStructure); + } + + for (const child of children) { + if (getNodeType(child) === NodeType.Folder) { + yield* this.loadNodeTreeRecursively( + child, + getTreeNodeChildByNodeName(expectedStructure, getNodeName(child)), + ); + } + } + } + + private async *verifyExpectedNodeChildren( + parentNodeUid: string, + children: MaybeNode[], + expectedStructure?: ExcpectedTreeNode, + ): AsyncGenerator { + if (!expectedStructure) { + return; + } + + const expectedNodes = expectedStructure.children ?? []; + const actualNodeNames = children.map((child) => getNodeName(child)); + + for (const expectedNode of expectedNodes) { + if (!actualNodeNames.includes(expectedNode.name)) { + yield { + type: 'expected_structure_missing_node', + expectedNode: getExpectedTreeNodeDetails(expectedNode), + parentNodeUid, + }; + } + } + + for (const child of children) { + const childName = getNodeName(child); + const isExpected = expectedNodes.some((expectedNode) => expectedNode.name === childName); + + if (!isExpected) { + yield { + type: 'expected_structure_unexpected_node', + ...getNodeDetails(child), + }; + } } } - private async *verifyNode(node: MaybeNode, options?: DiagnosticOptions): AsyncGenerator { + private async *verifyNodesQueue(): AsyncGenerator { + while (this.nodesQueue.length > 0 || !this.allNodesLoaded) { + const result = this.nodesQueue.shift(); + if (result) { + yield* this.verifyNode(result.node, result.expected); + this.checkedNodes++; + } else { + // Wait for 100ms before checking again. + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + } + + private async *verifyNode( + node: MaybeNode, + expectedStructure?: ExcpectedTreeNode, + ): AsyncGenerator { if (!node.ok) { yield { type: 'degraded_node', @@ -58,12 +209,12 @@ export class SDKDiagnostic { yield* this.verifyAuthor(activeRevision.contentAuthor, 'content', node); } - yield* this.verifyFileExtendedAttributes(node, options); + yield* this.verifyFileExtendedAttributes(node, expectedStructure); - if (options?.verifyContent) { + if (this.options.verifyContent) { yield* this.verifyContent(node); } - if (options?.verifyThumbnails) { + if (this.options.verifyThumbnails) { yield* this.verifyThumbnails(node); } } @@ -82,7 +233,7 @@ export class SDKDiagnostic { private async *verifyFileExtendedAttributes( node: MaybeNode, - options?: DiagnosticOptions, + expectedStructure?: ExcpectedTreeNode, ): AsyncGenerator { const activeRevision = getActiveRevision(node); @@ -108,9 +259,9 @@ export class SDKDiagnostic { }; } - if (options?.expectedStructure) { - const expectedSha1 = options.expectedStructure.expectedSha1; - const expectedSizeInBytes = options.expectedStructure.expectedSizeInBytes; + if (expectedStructure) { + const expectedSha1 = expectedStructure.expectedSha1; + const expectedSizeInBytes = expectedStructure.expectedSizeInBytes; const wrongSha1 = expectedSha1 !== undefined && claimedSha1 !== expectedSha1; const wrongSizeInBytes = expectedSizeInBytes !== undefined && claimedSizeInBytes !== expectedSizeInBytes; @@ -120,7 +271,7 @@ export class SDKDiagnostic { type: 'expected_structure_integrity_error', claimedSha1, claimedSizeInBytes, - expectedNode: getExpectedTreeNodeDetails(options.expectedStructure), + expectedNode: getExpectedTreeNodeDetails(expectedStructure), ...getNodeDetails(node), }; } @@ -217,75 +368,6 @@ export class SDKDiagnostic { }; } } - - private async *verifyNodeChildren( - parentNode: MaybeNode, - options?: DiagnosticOptions, - ): AsyncGenerator { - const parentNodeUid = parentNode.ok ? parentNode.value.uid : parentNode.error.uid; - const children: MaybeNode[] = []; - - try { - for await (const child of this.protonDriveClient.iterateFolderChildren(parentNode)) { - if (options?.expectedStructure) { - children.push(child); - } - - yield * - this.verifyNodeTree(child, { - ...options, - expectedStructure: options?.expectedStructure - ? getTreeNodeChildByNodeName(options.expectedStructure, getNodeName(child)) - : undefined, - }); - } - } catch (error: unknown) { - yield { - type: 'sdk_error', - call: `iterateFolderChildren(${parentNodeUid})`, - error, - }; - } - - if (options?.expectedStructure) { - yield* this.verifyExpectedNodeChildren(parentNodeUid, children, options); - } - } - - private async *verifyExpectedNodeChildren( - parentNodeUid: string, - children: MaybeNode[], - options: DiagnosticOptions, - ): AsyncGenerator { - if (!options.expectedStructure) { - return; - } - - const expectedNodes = options.expectedStructure.children ?? []; - const actualNodeNames = children.map((child) => getNodeName(child)); - - for (const expectedNode of expectedNodes) { - if (!actualNodeNames.includes(expectedNode.name)) { - yield { - type: 'expected_structure_missing_node', - expectedNode: getExpectedTreeNodeDetails(expectedNode), - parentNodeUid, - }; - } - } - - for (const child of children) { - const childName = getNodeName(child); - const isExpected = expectedNodes.some((expectedNode) => expectedNode.name === childName); - - if (!isExpected) { - yield { - type: 'expected_structure_unexpected_node', - ...getNodeDetails(child), - }; - } - } - } } function getNodeDetails(node: MaybeNode): NodeDetails { @@ -371,8 +453,8 @@ function getExpectedTreeNodeDetails(expectedNode: ExcpectedTreeNode): ExcpectedT } function getTreeNodeChildByNodeName( - expectedSubtree: ExcpectedTreeNode, + expectedSubtree: ExcpectedTreeNode | undefined, nodeName: string, ): ExcpectedTreeNode | undefined { - return expectedSubtree.children?.find((expectedNode) => expectedNode.name === nodeName); + return expectedSubtree?.children?.find((expectedNode) => expectedNode.name === nodeName); } From 92e2124fa253b1d5265341802c4866ee16cb9214 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Oct 2025 14:44:39 +0200 Subject: [PATCH 268/791] Add create folder & upload for public link SDK --- .gitignore | 3 +- js/sdk/src/internal/sharingPublic/index.ts | 3 ++ js/sdk/src/protonDrivePublicLinkClient.ts | 56 +++++++++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index f8b63254..906b4165 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ # Docs __pycache__ docs/build -public +/public +js/public # JS node_modules diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index c0de973b..23f08544 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -10,6 +10,7 @@ import { NodeAPIService } from '../nodes/apiService'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; +import { NodesManagement } from '../nodes/nodesManagement'; import { NodesRevisons } from '../nodes/nodesRevisions'; import { SharingPublicCryptoReporter } from './cryptoReporter'; import { SharingPublicNodesAccess } from './nodes'; @@ -96,10 +97,12 @@ export function initSharingPublicNodesModule( publicShareKey, publicRootNodeUid, ); + const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess); const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); return { access: nodesAccess, + management: nodesManagement, revisions: nodesRevisions, }; } diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 829142b3..a064511b 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -15,6 +15,8 @@ import { FileDownloader, ThumbnailType, ThumbnailResult, + UploadMetadata, + FileUploader, } from './interface'; import { Telemetry } from './telemetry'; import { @@ -28,6 +30,7 @@ import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; import { SDKEvents } from './internal/sdkEvents'; import { initSharingPublicModule } from './internal/sharingPublic'; +import { initUploadModule } from './internal/upload'; /** * ProtonDrivePublicLinkClient is the interface for the public link client. @@ -47,6 +50,7 @@ export class ProtonDrivePublicLinkClient { private sdkEvents: SDKEvents; private sharingPublic: ReturnType; private download: ReturnType; + private upload: ReturnType; public experimental: { /** @@ -91,7 +95,7 @@ export class ProtonDrivePublicLinkClient { if (!telemetry) { telemetry = new Telemetry(); } - this.logger = telemetry.getLogger('interface'); + this.logger = telemetry.getLogger('publicLink-interface'); // Use only in memory cache for public link as there are no events to keep it up to date if persisted. const entitiesCache = new MemoryCache(); @@ -129,6 +133,14 @@ export class ProtonDrivePublicLinkClient { this.sharingPublic.nodes.access, this.sharingPublic.nodes.revisions, ); + this.upload = initUploadModule( + telemetry, + apiService, + cryptoModule, + this.sharingPublic.shares, + this.sharingPublic.nodes.access, + fullConfig.clientUid, + ); this.experimental = { getNodeUrl: async (nodeUid: NodeOrUid) => { @@ -193,6 +205,18 @@ export class ProtonDrivePublicLinkClient { return convertInternalNodePromise(this.sharingPublic.nodes.access.getNode(getUid(nodeUid))); } + /** + * Create a new folder. + * + * See `ProtonDriveClient.createFolder` for more information. + */ + async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise { + this.logger.info(`Creating folder in ${getUid(parentNodeUid)}`); + return convertInternalNodePromise( + this.sharingPublic.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime), + ); + } + /** * Get the file downloader to download the node content. * @@ -216,4 +240,34 @@ export class ProtonDrivePublicLinkClient { this.logger.info(`Iterating ${nodeUids.length} thumbnails`); yield* this.download.iterateThumbnails(getUids(nodeUids), thumbnailType, signal); } + + /** + * Get the file uploader to upload a new file. For uploading a new + * revision, use `getFileRevisionUploader` instead. + * + * See `ProtonDriveClient.getFileUploader` for more information. + */ + async getFileUploader( + parentFolderUid: NodeOrUid, + name: string, + metadata: UploadMetadata, + signal?: AbortSignal, + ): Promise { + this.logger.info(`Getting file uploader for parent ${getUid(parentFolderUid)}`); + return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); + } + + /** + * Same as `getFileUploader`, but for a uploading new revision of the file. + * + * See `ProtonDriveClient.getFileRevisionUploader` for more information. + */ + async getFileRevisionUploader( + nodeUid: NodeOrUid, + metadata: UploadMetadata, + signal?: AbortSignal, + ): Promise { + this.logger.info(`Getting file revision uploader for ${getUid(nodeUid)}`); + return this.upload.getFileRevisionUploader(getUid(nodeUid), metadata, signal); + } } From 715caae8d0b4d9c6bcaf38c24ce53da080d5e948 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Oct 2025 09:35:13 +0000 Subject: [PATCH 269/791] Fix typo in class name --- js/sdk/src/diagnostic/index.ts | 2 +- js/sdk/src/diagnostic/interface.ts | 10 +++++----- js/sdk/src/diagnostic/sdkDiagnostic.ts | 24 ++++++++++++------------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/js/sdk/src/diagnostic/index.ts b/js/sdk/src/diagnostic/index.ts index 15447390..2615b115 100644 --- a/js/sdk/src/diagnostic/index.ts +++ b/js/sdk/src/diagnostic/index.ts @@ -9,7 +9,7 @@ import { DiagnosticTelemetry } from './telemetry'; export type { Diagnostic, DiagnosticOptions, - ExcpectedTreeNode, + ExpectedTreeNode, DiagnosticProgressCallback, DiagnosticResult, } from './interface'; diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index b36dc96c..dfcd1d9f 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -16,15 +16,15 @@ export interface Diagnostic { export type DiagnosticOptions = { verifyContent?: boolean; verifyThumbnails?: boolean; - expectedStructure?: ExcpectedTreeNode; + expectedStructure?: ExpectedTreeNode; }; // Tree structure of the expected node tree. -export type ExcpectedTreeNode = { +export type ExpectedTreeNode = { name: string; expectedSha1?: string; expectedSizeInBytes?: number; - children?: ExcpectedTreeNode[]; + children?: ExpectedTreeNode[]; }; export type DiagnosticProgressCallback = (progress: { @@ -151,7 +151,7 @@ export type ThumbnailsErrorResult = { // the expected structure. export type ExpectedStructureMissingNode = { type: 'expected_structure_missing_node'; - expectedNode: ExcpectedTreeNode; + expectedNode: ExpectedTreeNode; parentNodeUid: string; }; @@ -168,7 +168,7 @@ export type ExpectedStructureUnexpectedNode = { // the claimed values with the real content. export type ExpectedStructureIntegrityError = { type: 'expected_structure_integrity_error'; - expectedNode: ExcpectedTreeNode; + expectedNode: ExpectedTreeNode; claimedSha1?: string; claimedSizeInBytes?: number; } & NodeDetails; diff --git a/js/sdk/src/diagnostic/sdkDiagnostic.ts b/js/sdk/src/diagnostic/sdkDiagnostic.ts index 9e33953a..c68c4918 100644 --- a/js/sdk/src/diagnostic/sdkDiagnostic.ts +++ b/js/sdk/src/diagnostic/sdkDiagnostic.ts @@ -4,7 +4,7 @@ import { DiagnosticOptions, DiagnosticResult, NodeDetails, - ExcpectedTreeNode, + ExpectedTreeNode, DiagnosticProgressCallback, } from './interface'; import { IntegrityVerificationStream } from './integrityVerificationStream'; @@ -25,7 +25,7 @@ export class SDKDiagnostic { private onProgress?: DiagnosticProgressCallback; private progressReportInterval: NodeJS.Timeout | undefined; - private nodesQueue: { node: MaybeNode; expected?: ExcpectedTreeNode }[] = []; + private nodesQueue: { node: MaybeNode; expected?: ExpectedTreeNode }[] = []; private allNodesLoaded: boolean = false; private loadedNodes: number = 0; private checkedNodes: number = 0; @@ -68,7 +68,7 @@ export class SDKDiagnostic { }); } - async *verifyMyFiles(expectedStructure?: ExcpectedTreeNode): AsyncGenerator { + async *verifyMyFiles(expectedStructure?: ExpectedTreeNode): AsyncGenerator { let myFilesRootFolder: MaybeNode; try { @@ -85,7 +85,7 @@ export class SDKDiagnostic { yield* this.verifyNodeTree(myFilesRootFolder, expectedStructure); } - async *verifyNodeTree(node: MaybeNode, expectedStructure?: ExcpectedTreeNode): AsyncGenerator { + async *verifyNodeTree(node: MaybeNode, expectedStructure?: ExpectedTreeNode): AsyncGenerator { this.startProgress(); this.nodesQueue.push({ node, expected: expectedStructure }); this.loadedNodes++; @@ -95,7 +95,7 @@ export class SDKDiagnostic { private async *loadNodeTree( parentNode: MaybeNode, - expectedStructure?: ExcpectedTreeNode, + expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { const isFolder = getNodeType(parentNode) === NodeType.Folder; if (isFolder) { @@ -106,7 +106,7 @@ export class SDKDiagnostic { private async *loadNodeTreeRecursively( parentNode: MaybeNode, - expectedStructure?: ExcpectedTreeNode, + expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { const parentNodeUid = parentNode.ok ? parentNode.value.uid : parentNode.error.uid; const children: MaybeNode[] = []; @@ -145,7 +145,7 @@ export class SDKDiagnostic { private async *verifyExpectedNodeChildren( parentNodeUid: string, children: MaybeNode[], - expectedStructure?: ExcpectedTreeNode, + expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { if (!expectedStructure) { return; @@ -192,7 +192,7 @@ export class SDKDiagnostic { private async *verifyNode( node: MaybeNode, - expectedStructure?: ExcpectedTreeNode, + expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { if (!node.ok) { yield { @@ -233,7 +233,7 @@ export class SDKDiagnostic { private async *verifyFileExtendedAttributes( node: MaybeNode, - expectedStructure?: ExcpectedTreeNode, + expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { const activeRevision = getActiveRevision(node); @@ -445,7 +445,7 @@ function getNodeName(node: MaybeNode): string { return 'N/A'; } -function getExpectedTreeNodeDetails(expectedNode: ExcpectedTreeNode): ExcpectedTreeNode { +function getExpectedTreeNodeDetails(expectedNode: ExpectedTreeNode): ExpectedTreeNode { return { ...expectedNode, children: undefined, @@ -453,8 +453,8 @@ function getExpectedTreeNodeDetails(expectedNode: ExcpectedTreeNode): ExcpectedT } function getTreeNodeChildByNodeName( - expectedSubtree: ExcpectedTreeNode | undefined, + expectedSubtree: ExpectedTreeNode | undefined, nodeName: string, -): ExcpectedTreeNode | undefined { +): ExpectedTreeNode | undefined { return expectedSubtree?.children?.find((expectedNode) => expectedNode.name === nodeName); } From a65bf6f02465996778d8107c4fe30950524bcdc6 Mon Sep 17 00:00:00 2001 From: drive Date: Sun, 26 Oct 2025 11:37:44 +0100 Subject: [PATCH 270/791] Fix SHA1 extended attribute --- .../Cryptography/HashingReadStream.cs | 82 +++++++++++++++++++ .../Nodes/Upload/RevisionWriter.cs | 9 +- 2 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs new file mode 100644 index 00000000..749cc9f0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs @@ -0,0 +1,82 @@ +using System.Security.Cryptography; + +namespace Proton.Drive.Sdk.Cryptography; + +public class HashingReadStream(Stream underlyingStream, IncrementalHash hash, bool leaveOpen = false) : Stream +{ + private readonly Stream _underlyingStream = underlyingStream ?? throw new ArgumentNullException(nameof(underlyingStream)); + private readonly IncrementalHash _hash = hash; + + public override bool CanRead => _underlyingStream.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _underlyingStream.Length; + public override long Position { get => _underlyingStream.Position; set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) + { + var readCount = _underlyingStream.Read(buffer); + _hash.AppendData(buffer.AsSpan(0, readCount)); + return readCount; + } + + public override int Read(Span buffer) + { + var readCount = _underlyingStream.Read(buffer); + _hash.AppendData(buffer[..readCount]); + return readCount; + } + + public override int ReadByte() + { + var result = (byte)_underlyingStream.ReadByte(); + _hash.AppendData(new ReadOnlySpan(ref result)); + return result; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var readCount = await _underlyingStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + _hash.AppendData(buffer.AsSpan(0, readCount)); + return readCount; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var readCount = await _underlyingStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + _hash.AppendData(buffer.Span[..readCount]); + return readCount; + } + + public override void Flush() => _underlyingStream.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _underlyingStream.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + +#pragma warning disable CA2215 // Dispose methods should call base class dispose + public override ValueTask DisposeAsync() +#pragma warning restore CA2215 // Dispose methods should call base class dispose + { + GC.SuppressFinalize(this); + + if (leaveOpen) + { + return ValueTask.CompletedTask; + } + + return _underlyingStream.DisposeAsync(); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (leaveOpen || !disposing) + { + return; + } + + _underlyingStream.Dispose(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 207a49c8..a55e6cdd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -5,6 +5,7 @@ using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Serialization; using Proton.Sdk.Addresses; @@ -70,9 +71,9 @@ public async ValueTask WriteAsync( ArraySegment manifestSignature; var blockSizes = new List(8); - using var sha1 = SHA1.Create(); + using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); - var hashingContentStream = new CryptoStream(contentStream, sha1, CryptoStreamMode.Read, leaveOpen: true); + var hashingContentStream = new HashingReadStream(contentStream, sha1, leaveOpen: true); await using (hashingContentStream.ConfigureAwait(false)) { @@ -195,9 +196,7 @@ public async ValueTask WriteAsync( } } - sha1.TransformFinalBlock([], 0, 0); - - var request = GetRevisionUpdateRequest(contentStream, lastModificationTime, blockSizes, sha1.Hash, manifestSignature, signingEmailAddress); + var request = GetRevisionUpdateRequest(contentStream, lastModificationTime, blockSizes, sha1.GetCurrentHash(), manifestSignature, signingEmailAddress); _client.Logger.LogDebug("Sealing revision {RevisionId} of file {FileUid}", _revisionId, _fileUid); From b3c0cb08184afeb7e23c688e3dcfa2d0716a6a8b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Oct 2025 09:46:24 +0100 Subject: [PATCH 271/791] Improve logging and clean up some code --- cs/Directory.Packages.props | 1 + ...iClient.cs => BlockVerificationApiClient.cs} | 2 +- ...Client.cs => IBlockVerificationApiClient.cs} | 2 +- cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs | 11 ----------- .../src/Proton.Drive.Sdk/Api/Files/BlockDto.cs | 17 +++++++++++++++++ .../Api/Files/BlockListingRevisionDto.cs | 2 +- .../src/Proton.Drive.Sdk/Api/Files/FileDto.cs | 2 +- .../Api/Shares/ShareMembershipDto.cs | 11 ++++++----- .../{MemberState.cs => ShareMembershipState.cs} | 2 +- .../Api/Shares/ShareResponse.cs | 2 +- .../Api/Storage/StorageApiClient.cs | 2 +- .../Proton.Drive.Sdk/FifoFlexibleSemaphore.cs | 14 ++++++++++---- .../Nodes/Download/RevisionReader.cs | 11 ++++++----- .../Nodes/DtoToMetadataConverter.cs | 4 ++-- .../Nodes/Upload/Verification/BlockVerifier.cs | 2 +- .../Upload/Verification/BlockVerifierFactory.cs | 2 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 4 ++-- cs/sdk/src/Proton.Sdk/MemoryProvider.cs | 1 + 18 files changed, 54 insertions(+), 38 deletions(-) rename cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/{RevisionVerificationApiClient.cs => BlockVerificationApiClient.cs} (87%) rename cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/{IRevisionVerificationApiClient.cs => IBlockVerificationApiClient.cs} (87%) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs rename cs/sdk/src/Proton.Drive.Sdk/Api/Shares/{MemberState.cs => ShareMembershipState.cs} (70%) diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 1a82a75b..e24e8c01 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/RevisionVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/BlockVerificationApiClient.cs similarity index 87% rename from cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/RevisionVerificationApiClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/BlockVerificationApiClient.cs index 3fde14d9..372b2385 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/RevisionVerificationApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/BlockVerificationApiClient.cs @@ -6,7 +6,7 @@ namespace Proton.Drive.Sdk.Api.BlockVerification; -internal sealed class RevisionVerificationApiClient(HttpClient httpClient) : IRevisionVerificationApiClient +internal sealed class BlockVerificationApiClient(HttpClient httpClient) : IBlockVerificationApiClient { private readonly HttpClient _httpClient = httpClient; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IRevisionVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IBlockVerificationApiClient.cs similarity index 87% rename from cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IRevisionVerificationApiClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IBlockVerificationApiClient.cs index 40aa21c1..93939ecd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IRevisionVerificationApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IBlockVerificationApiClient.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Api.BlockVerification; -internal interface IRevisionVerificationApiClient +internal interface IBlockVerificationApiClient { public ValueTask GetVerificationInputAsync( VolumeId volumeId, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs deleted file mode 100644 index 5c36d80d..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Block.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Proton.Drive.Sdk.Api.Files; - -internal sealed class Block -{ - public required int Index { get; init; } - - [JsonPropertyName("URL")] - public required string Url { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs new file mode 100644 index 00000000..1d038554 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockDto +{ + public required int Index { get; init; } + + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory HashDigest { get; init; } + + [JsonPropertyName("BareURL")] + public required string BareUrl { get; init; } + + public required string Token { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs index 2c674b4e..e76fb66d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs @@ -2,5 +2,5 @@ internal sealed class BlockListingRevisionDto : RevisionDto { - public required IReadOnlyList Blocks { get; init; } + public required IReadOnlyList Blocks { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs index ab9142f9..6c3b2228 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs @@ -8,7 +8,7 @@ internal sealed class FileDto public required string MediaType { get; init; } [JsonPropertyName("TotalEncryptedSize")] - public required long TotalStorageQuotaUsage { get; init; } + public required long TotalSizeOnStorage { get; init; } public required ReadOnlyMemory ContentKeyPacket { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs index 5247fc0b..c66571da 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; using Proton.Sdk.Cryptography; using Proton.Sdk.Serialization; @@ -7,16 +8,16 @@ namespace Proton.Drive.Sdk.Api.Shares; internal sealed class ShareMembershipDto { [JsonPropertyName("MemberID")] - public required string MemberId { get; init; } + public required ShareMembershipId Id { get; init; } [JsonPropertyName("ShareID")] - public required string ShareId { get; init; } + public required ShareId ShareId { get; init; } [JsonPropertyName("AddressID")] - public required string AddressId { get; init; } + public required AddressId AddressId { get; init; } [JsonPropertyName("AddressKeyID")] - public required string AddressKeyId { get; init; } + public required AddressKeyId AddressKeyId { get; init; } [JsonPropertyName("Inviter")] public required string InviterEmailAddress { get; init; } @@ -29,7 +30,7 @@ internal sealed class ShareMembershipDto public PgpArmoredSignature? SessionKeySignature { get; init; } - public required MemberState State { get; init; } + public required ShareMembershipState State { get; init; } [JsonPropertyName("Unlockable")] public bool? CanBeUnlocked { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberState.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipState.cs similarity index 70% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberState.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipState.cs index 3a4ad509..0fb516a1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/MemberState.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipState.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Api.Shares; -public enum MemberState +public enum ShareMembershipState { Active = 1, Locked = 3, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs index 796a0ba7..20512a95 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs @@ -46,7 +46,7 @@ internal sealed class ShareResponse : ApiResponse public required PgpArmoredMessage Passphrase { get; init; } [JsonPropertyName("PassphraseSignature")] - public PgpArmoredSignature? PassphraseSignature { get; init; } + public required PgpArmoredSignature PassphraseSignature { get; init; } [JsonPropertyName("AddressID")] public required AddressId AddressId { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index 8af4c03d..492aa91c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -23,7 +23,7 @@ public async ValueTask UploadBlobAsync( using var multipartContent = new MultipartFormDataContent("-----------------------------" + Guid.NewGuid().ToString("N")) { - blobContent + blobContent, }; using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, baseUrl, multipartContent); diff --git a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs index 783afe62..28b6090b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs @@ -3,9 +3,9 @@ namespace Proton.Drive.Sdk; /// -/// Acts as a semaphore that acts in a first in / first out manner, can increment and decrement its count by more than 1, and can be entered as long as the count before the increment is less than the maximum. +/// Acts as a semaphore that operates in a first in / first out manner, can increment and decrement its count by more than 1, and can be entered as long as the count before the increment is less than the maximum. /// -internal sealed class FifoFlexibleSemaphore +internal sealed partial class FifoFlexibleSemaphore { private readonly ILogger _logger; private readonly int _maximumCount; @@ -26,7 +26,7 @@ public ValueTask EnterAsync(int increment, CancellationToken cancellationToken = { ArgumentOutOfRangeException.ThrowIfNegative(increment); - _logger.LogTrace($"FifoFlexibleSemaphore.EnterAsync called with {nameof(increment)} {{Increment}}", increment); + LogEnter(increment); TaskCompletionSource tcs; lock (_waitingQueue) @@ -64,7 +64,7 @@ public void Release(int decrement) { ArgumentOutOfRangeException.ThrowIfNegative(decrement); - _logger.LogTrace($"FifoFlexibleSemaphore.Release called with {nameof(decrement)} {{Decrement}}", decrement); + LogRelease(decrement); lock (_waitingQueue) { @@ -86,4 +86,10 @@ public void Release(int decrement) } } } + + [LoggerMessage(Level = LogLevel.Trace, Message = "Enter with increment of {Increment}")] + private partial void LogEnter(int increment); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Release with decrement of {Decrement}")] + private partial void LogRelease(int decrement); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 6239e196..b6523b23 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -172,7 +172,7 @@ private async Task WriteNextBlockToOutputAsync( } } - private async Task DownloadBlockAsync(Block block, Stream contentOutputStream, CancellationToken cancellationToken) + private async Task DownloadBlockAsync(BlockDto block, Stream contentOutputStream, CancellationToken cancellationToken) { Stream blockOutputStream; bool isIntermediateStream; @@ -188,19 +188,20 @@ private async Task DownloadBlockAsync(Block block, Stream c isIntermediateStream = true; } - var hashDigest = await _client.BlockDownloader.DownloadAsync(block.Url, _contentKey, blockOutputStream, cancellationToken).ConfigureAwait(false); + var url = block.BareUrl + block.Token; + var hashDigest = await _client.BlockDownloader.DownloadAsync(url, _contentKey, blockOutputStream, cancellationToken).ConfigureAwait(false); return new BlockDownloadResult(block.Index, blockOutputStream, isIntermediateStream, hashDigest); } - private async IAsyncEnumerable<(Block Value, bool IsLast)> GetBlocksAsync([EnumeratorCancellation] CancellationToken cancellationToken) + private async IAsyncEnumerable<(BlockDto Value, bool IsLast)> GetBlocksAsync([EnumeratorCancellation] CancellationToken cancellationToken) { try { var mustTryNextPageOfBlocks = true; var nextExpectedIndex = 1; - var outstandingBlock = default(Block); - var currentPageBlocks = new List(_blockPageSize); + var outstandingBlock = default(BlockDto); + var currentPageBlocks = new List(_blockPageSize); var revisionDto = _revisionDto; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index d8cbc24c..27bb39f1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -188,7 +188,7 @@ public static async Task> ConvertDtoT Author = default, MediaType = fileDto.MediaType, ActiveRevision = null, - TotalStorageQuotaUsage = fileDto.TotalStorageQuotaUsage, + TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, Errors = null!, }; @@ -251,7 +251,7 @@ public static async Task> ConvertDtoT TrashTime = linkDto.TrashTime, MediaType = fileDto.MediaType, ActiveRevision = activeRevision, - TotalSizeOnCloudStorage = fileDto.TotalStorageQuotaUsage, + TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, }; await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs index 34d18359..e2667a5e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs @@ -21,7 +21,7 @@ private BlockVerifier(PgpSessionKey sessionKey, ReadOnlyMemory verificatio public int DataPacketPrefixMaxLength => _verificationCode.Length; public static async ValueTask CreateAsync( - IRevisionVerificationApiClient apiClient, + IBlockVerificationApiClient apiClient, NodeUid fileUid, RevisionId revisionId, PgpPrivateKey key, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs index 9e3acea7..32d4d9e7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs @@ -6,7 +6,7 @@ namespace Proton.Drive.Sdk.Nodes.Upload.Verification; internal sealed class BlockVerifierFactory(HttpClient httpClient) : IBlockVerifierFactory { - private readonly IRevisionVerificationApiClient _apiClient = new RevisionVerificationApiClient(httpClient); + private readonly IBlockVerificationApiClient _apiClient = new BlockVerificationApiClient(httpClient); public async ValueTask CreateAsync( NodeUid fileUid, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 886cd315..e7047dd0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -72,8 +72,8 @@ internal ProtonDriveClient( var maxDegreeOfBlockProcessingParallelism = maxDegreeOfBlockTransferParallelism + Math.Min(Math.Max(maxDegreeOfBlockTransferParallelism / 2, 2), 4); - RevisionCreationSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger()); - BlockListingSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger()); + RevisionCreationSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger("Revision creation semaphore")); + BlockListingSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger("Block listing semaphore")); BlockUploader = new BlockUploader(this, maxDegreeOfBlockTransferParallelism); BlockDownloader = new BlockDownloader(this, maxDegreeOfBlockTransferParallelism); diff --git a/cs/sdk/src/Proton.Sdk/MemoryProvider.cs b/cs/sdk/src/Proton.Sdk/MemoryProvider.cs index 9bebdfcf..74639d9b 100644 --- a/cs/sdk/src/Proton.Sdk/MemoryProvider.cs +++ b/cs/sdk/src/Proton.Sdk/MemoryProvider.cs @@ -4,6 +4,7 @@ using CommunityToolkit.HighPerformance.Buffers; namespace Proton.Sdk; + internal static class MemoryProvider { private const int MaxStackBufferSize = 256; From 1c59ad66f7ffa5f84af6217fa192fe0b6969b271 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Oct 2025 09:35:51 +0100 Subject: [PATCH 272/791] Apply server time to PGP when injecting the HTTP client through interop --- cs/Directory.Build.props | 13 ++++++++++++- .../Proton.Drive.Sdk.CExports.csproj | 5 ----- .../Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 2 -- .../InteropHttpClientFactory.cs | 19 ++++++++++++++++--- .../Proton.Sdk.CExports.csproj | 2 -- cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 2 -- 6 files changed, 28 insertions(+), 15 deletions(-) diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 245edf4e..4cef6f88 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -25,10 +25,16 @@ - true false + + Exe + true + true + true + + all @@ -46,6 +52,11 @@ + + + + + all diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index 7302ea7d..8581ac1d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -23,11 +23,6 @@ - - - - - diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj index 9a5cb9bf..245a92b2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -16,8 +16,6 @@ - - diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index 38d18c31..907b1d9e 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -2,6 +2,7 @@ using System.Reflection; using Google.Protobuf; using Proton.Sdk.CExports.Tasks; +using Proton.Sdk.Http; namespace Proton.Sdk.CExports; @@ -39,7 +40,12 @@ public InteropHttpClientFactory( public HttpClient CreateClient(string name) { - return new HttpClient(new InteropHttpMessageHandler(this)) + var httpMessageHandler = new CryptographyTimeProvisionHandler + { + InnerHandler = new InteropHttpMessageHandler(this), + }; + + return new HttpClient(httpMessageHandler) { BaseAddress = new Uri(_baseUrl), DefaultRequestHeaders = @@ -102,9 +108,16 @@ private static HttpResponseMessage ConvertHttpResponseFromInterop(HttpResponse i Content = new ReadOnlyMemoryContent(interopHttpResponse.Content.Memory), }; - foreach (var interopHttpResponseHeader in interopHttpResponse.Headers.Where(x => x.Name.StartsWith("content-", StringComparison.OrdinalIgnoreCase))) + foreach (var interopHttpResponseHeader in interopHttpResponse.Headers) { - response.Content.Headers.Add(interopHttpResponseHeader.Name, interopHttpResponseHeader.Values); + if (interopHttpResponseHeader.Name.StartsWith("content-", StringComparison.OrdinalIgnoreCase)) + { + response.Content.Headers.Add(interopHttpResponseHeader.Name, interopHttpResponseHeader.Values); + } + else + { + response.Headers.Add(interopHttpResponseHeader.Name, interopHttpResponseHeader.Values); + } } return response; diff --git a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj index 7107e650..9c4151f0 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj @@ -21,8 +21,6 @@ - - diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index 577d0389..3d0871e1 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -20,8 +20,6 @@ - - From b9cea18045bc6834a18479b3828a23e8c44e1591 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 08:10:43 +0100 Subject: [PATCH 273/791] Fix deserialization error on download --- cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs index 1d038554..e7667853 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs @@ -7,6 +7,7 @@ internal sealed class BlockDto { public required int Index { get; init; } + [JsonPropertyName("Hash")] [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] public required ReadOnlyMemory HashDigest { get; init; } From 0614bb1f13cc35727abd1d03dfb03b22dda74190 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 10:40:50 +0100 Subject: [PATCH 274/791] Fix error on HTTP response with Expires header when using interop --- .../InteropHttpClientFactory.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index 907b1d9e..b9cdea3d 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -110,14 +110,16 @@ private static HttpResponseMessage ConvertHttpResponseFromInterop(HttpResponse i foreach (var interopHttpResponseHeader in interopHttpResponse.Headers) { - if (interopHttpResponseHeader.Name.StartsWith("content-", StringComparison.OrdinalIgnoreCase)) + if ((interopHttpResponseHeader.Name.StartsWith("content-", StringComparison.OrdinalIgnoreCase) + || interopHttpResponseHeader.Name.Equals("expires", StringComparison.OrdinalIgnoreCase) + || interopHttpResponseHeader.Name.Equals("allow", StringComparison.OrdinalIgnoreCase) + || interopHttpResponseHeader.Name.Equals("last-modified", StringComparison.OrdinalIgnoreCase)) + && response.Content.Headers.TryAddWithoutValidation(interopHttpResponseHeader.Name, interopHttpResponseHeader.Values)) { - response.Content.Headers.Add(interopHttpResponseHeader.Name, interopHttpResponseHeader.Values); - } - else - { - response.Headers.Add(interopHttpResponseHeader.Name, interopHttpResponseHeader.Values); + continue; } + + response.Headers.TryAddWithoutValidation(interopHttpResponseHeader.Name, interopHttpResponseHeader.Values); } return response; From 29ce47373ae6c4b25b0bd9a99487eb60916225e4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 11:40:30 +0100 Subject: [PATCH 275/791] Fix download error due to misuse of new URL block fields --- .../Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs | 2 +- .../src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs | 7 +++++-- .../Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs | 9 +++++++-- .../Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs index 9d316b95..23da2fc8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs @@ -5,5 +5,5 @@ namespace Proton.Drive.Sdk.Api.Storage; internal interface IStorageApiClient { ValueTask UploadBlobAsync(string baseUrl, string token, Stream stream, Action? onProgress, CancellationToken cancellationToken); - ValueTask GetBlobStreamAsync(string url, CancellationToken cancellationToken); + ValueTask GetBlobStreamAsync(string baseUrl, string token, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index 492aa91c..eda15e57 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -39,9 +39,12 @@ public async ValueTask UploadBlobAsync( return response; } - public async ValueTask GetBlobStreamAsync(string url, CancellationToken cancellationToken) + public async ValueTask GetBlobStreamAsync(string baseUrl, string token, CancellationToken cancellationToken) { - var blobResponse = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Get, baseUrl); + requestMessage.Headers.Add("pm-storage-token", token); + + var blobResponse = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); await blobResponse.EnsureApiSuccessAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index 82e015be..86314b85 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -19,11 +19,16 @@ internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) public SemaphoreSlim FileSemaphore { get; } = new(1, 1); public SemaphoreSlim BlockSemaphore { get; } - public async ValueTask> DownloadAsync(string url, PgpSessionKey contentKey, Stream outputStream, CancellationToken cancellationToken) + public async ValueTask> DownloadAsync( + string bareUrl, + string token, + PgpSessionKey contentKey, + Stream outputStream, + CancellationToken cancellationToken) { using var sha256 = SHA256.Create(); - var blobStream = await _client.Api.Storage.GetBlobStreamAsync(url, cancellationToken).ConfigureAwait(false); + var blobStream = await _client.Api.Storage.GetBlobStreamAsync(bareUrl, token, cancellationToken).ConfigureAwait(false); var hashingStream = new CryptoStream(blobStream, sha256, CryptoStreamMode.Read); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index b6523b23..01392204 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -188,8 +188,8 @@ private async Task DownloadBlockAsync(BlockDto block, Strea isIntermediateStream = true; } - var url = block.BareUrl + block.Token; - var hashDigest = await _client.BlockDownloader.DownloadAsync(url, _contentKey, blockOutputStream, cancellationToken).ConfigureAwait(false); + var hashDigest = await _client.BlockDownloader.DownloadAsync(block.BareUrl, block.Token, _contentKey, blockOutputStream, cancellationToken) + .ConfigureAwait(false); return new BlockDownloadResult(block.Index, blockOutputStream, isIntermediateStream, hashDigest); } From 88391fac993c79df90fab0ee27564adb649ddf2d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 14:15:22 +0100 Subject: [PATCH 276/791] Add rename and delete for public link SDK --- js/sdk/src/protonDrivePublicLinkClient.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index a064511b..bb4a63cd 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -17,6 +17,7 @@ import { ThumbnailResult, UploadMetadata, FileUploader, + NodeResult, } from './interface'; import { Telemetry } from './telemetry'; import { @@ -205,6 +206,26 @@ export class ProtonDrivePublicLinkClient { return convertInternalNodePromise(this.sharingPublic.nodes.access.getNode(getUid(nodeUid))); } + /** + * Rename the node. + * + * See `ProtonDriveClient.renameNode` for more information. + */ + async renameNode(nodeUid: NodeOrUid, newName: string): Promise { + this.logger.info(`Renaming node ${getUid(nodeUid)}`); + return convertInternalNodePromise(this.sharingPublic.nodes.management.renameNode(getUid(nodeUid), newName)); + } + + /** + * Delete the nodes permanently. + * + * See `ProtonDriveClient.deleteNodes` for more information. + */ + async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Deleting ${nodeUids.length} nodes`); + yield* this.sharingPublic.nodes.management.deleteNodes(getUids(nodeUids), signal); + } + /** * Create a new folder. * From 78be1675e7873bf539d986602ce6667b0bd3da75 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 14:57:25 +0000 Subject: [PATCH 277/791] Rename getOwnVolumeIDs to getRootIDs --- js/sdk/src/internal/devices/interface.ts | 2 +- js/sdk/src/internal/devices/manager.test.ts | 6 +++--- js/sdk/src/internal/devices/manager.ts | 2 +- js/sdk/src/internal/nodes/index.test.ts | 2 +- js/sdk/src/internal/nodes/interface.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 4 ++-- js/sdk/src/internal/nodes/nodesAccess.ts | 10 +++++----- js/sdk/src/internal/photos/albums.ts | 2 +- js/sdk/src/internal/photos/interface.ts | 2 +- js/sdk/src/internal/photos/shares.ts | 6 +++--- js/sdk/src/internal/photos/timeline.ts | 2 +- js/sdk/src/internal/shares/manager.test.ts | 14 +++++++------- js/sdk/src/internal/shares/manager.ts | 8 ++++---- js/sdk/src/internal/sharing/interface.ts | 2 +- js/sdk/src/internal/sharing/sharingAccess.test.ts | 2 +- js/sdk/src/internal/sharing/sharingAccess.ts | 2 +- js/sdk/src/internal/sharingPublic/shares.ts | 3 +-- js/sdk/src/protonDrivePublicLinkClient.ts | 2 +- 18 files changed, 36 insertions(+), 37 deletions(-) diff --git a/js/sdk/src/internal/devices/interface.ts b/js/sdk/src/internal/devices/interface.ts index d517ef3c..c1ede277 100644 --- a/js/sdk/src/internal/devices/interface.ts +++ b/js/sdk/src/internal/devices/interface.ts @@ -13,7 +13,7 @@ export type DeviceMetadata = { }; export interface SharesService { - getOwnVolumeIDs(): Promise<{ volumeId: string }>; + getRootIDs(): Promise<{ volumeId: string }>; getMyFilesShareMemberEmailKey(): Promise<{ addressId: string; email: string; diff --git a/js/sdk/src/internal/devices/manager.test.ts b/js/sdk/src/internal/devices/manager.test.ts index 557eb8b8..b44dc2d2 100644 --- a/js/sdk/src/internal/devices/manager.test.ts +++ b/js/sdk/src/internal/devices/manager.test.ts @@ -30,7 +30,7 @@ describe('DevicesManager', () => { }; // @ts-expect-error No need to implement all methods for mocking sharesService = { - getOwnVolumeIDs: jest.fn(), + getRootIDs: jest.fn(), }; // @ts-expect-error No need to implement all methods for mocking nodesService = {}; @@ -74,13 +74,13 @@ describe('DevicesManager', () => { shareId: 'shareid', } as DeviceMetadata; - sharesService.getOwnVolumeIDs.mockResolvedValue({ volumeId }); + sharesService.getRootIDs.mockResolvedValue({ volumeId }); cryptoService.createDevice.mockResolvedValue({ address, shareKey, node }); apiService.createDevice.mockResolvedValue(createdDevice); const result = await manager.createDevice(name, deviceType); - expect(sharesService.getOwnVolumeIDs).toHaveBeenCalled(); + expect(sharesService.getRootIDs).toHaveBeenCalled(); expect(cryptoService.createDevice).toHaveBeenCalledWith(name); expect(apiService.createDevice).toHaveBeenCalledWith( { volumeId, type: deviceType }, diff --git a/js/sdk/src/internal/devices/manager.ts b/js/sdk/src/internal/devices/manager.ts index dce43b28..b531904a 100644 --- a/js/sdk/src/internal/devices/manager.ts +++ b/js/sdk/src/internal/devices/manager.ts @@ -47,7 +47,7 @@ export class DevicesManager { } async createDevice(name: string, deviceType: DeviceType): Promise { - const { volumeId } = await this.sharesService.getOwnVolumeIDs(); + const { volumeId } = await this.sharesService.getRootIDs(); const { address, shareKey, node } = await this.cryptoService.createDevice(name); const device = await this.apiService.createDevice( diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index aacc4ffe..2332fa70 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -53,7 +53,7 @@ describe('nodesModules integration tests', () => { driveCrypto = {}; // @ts-expect-error No need to implement all methods for mocking sharesService = { - getOwnVolumeIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), }; nodesModule = initNodesModule( diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index e5e909e6..57c6cfa4 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -177,7 +177,7 @@ export interface DecryptedRevision extends Revision { * Interface describing the dependencies to the shares module. */ export interface SharesService { - getOwnVolumeIDs(): Promise<{ volumeId: string; rootNodeId: string }>; + getRootIDs(): Promise<{ volumeId: string; rootNodeId: string }>; getSharePrivateKey(shareId: string): Promise; getMyFilesShareMemberEmailKey(): Promise<{ email: string; diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 232be788..31b7a896 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -46,7 +46,7 @@ describe('nodesAccess', () => { }; // @ts-expect-error No need to implement all methods for mocking shareService = { - getOwnVolumeIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), getSharePrivateKey: jest.fn(), }; @@ -388,7 +388,7 @@ describe('nodesAccess', () => { const node4 = { uid: 'volumeId~node4', isStale: false } as DecryptedNode; beforeEach(() => { - shareService.getOwnVolumeIDs = jest.fn().mockResolvedValue({ volumeId }); + shareService.getRootIDs = jest.fn().mockResolvedValue({ volumeId }); apiService.iterateTrashedNodeUids = jest.fn().mockImplementation(async function* () { yield node1.uid; yield node2.uid; diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 53270365..fe0290a0 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -61,7 +61,7 @@ export class NodesAccess { private cryptoService: NodesCryptoService, private shareService: Pick< SharesService, - 'getOwnVolumeIDs' | 'getSharePrivateKey' | 'getContextShareMemberEmailKey' + 'getRootIDs' | 'getSharePrivateKey' | 'getContextShareMemberEmailKey' >, ) { this.logger = telemetry.getLogger('nodes'); @@ -74,7 +74,7 @@ export class NodesAccess { } async getVolumeRootFolder() { - const { volumeId, rootNodeId } = await this.shareService.getOwnVolumeIDs(); + const { volumeId, rootNodeId } = await this.shareService.getRootIDs(); const nodeUid = makeNodeUid(volumeId, rootNodeId); return this.getNode(nodeUid); } @@ -154,7 +154,7 @@ export class NodesAccess { // Improvement requested: keep status of loaded trash and leverage cache. async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { - const { volumeId } = await this.shareService.getOwnVolumeIDs(); + const { volumeId } = await this.shareService.getRootIDs(); const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, undefined, signal), batchSize: BATCH_LOADING_SIZE, @@ -230,7 +230,7 @@ export class NodesAccess { private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { this.debouncer.loadingNode(nodeUid); try { - const { volumeId: ownVolumeId } = await this.shareService.getOwnVolumeIDs(); + const { volumeId: ownVolumeId } = await this.shareService.getRootIDs(); const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); return this.decryptNode(encryptedNode); } finally { @@ -259,7 +259,7 @@ export class NodesAccess { const returnedNodeUids: string[] = []; const errors = []; - const { volumeId: ownVolumeId } = await this.shareService.getOwnVolumeIDs(); + const { volumeId: ownVolumeId } = await this.shareService.getRootIDs(); const apiNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal); diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index f4483007..617fb361 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -21,7 +21,7 @@ export class Albums { } async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { - const { volumeId } = await this.photoShares.getOwnVolumeIDs(); + const { volumeId } = await this.photoShares.getRootIDs(); const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index f6a5fbe4..d0e6ea3a 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -4,7 +4,7 @@ import { DecryptedNode } from '../nodes'; import { EncryptedShare } from '../shares'; export interface SharesService { - getOwnVolumeIDs(): Promise<{ volumeId: string; rootNodeId: string }>; + getRootIDs(): Promise<{ volumeId: string; rootNodeId: string }>; loadEncryptedShare(shareId: string): Promise; getSharePrivateKey(shareId: string): Promise; getMyFilesShareMemberEmailKey(): Promise<{ diff --git a/js/sdk/src/internal/photos/shares.ts b/js/sdk/src/internal/photos/shares.ts index ac80952f..98bb385d 100644 --- a/js/sdk/src/internal/photos/shares.ts +++ b/js/sdk/src/internal/photos/shares.ts @@ -34,7 +34,7 @@ export class PhotoSharesManager { this.sharesService = sharesService; } - async getOwnVolumeIDs(): Promise { + async getRootIDs(): Promise { if (this.photoRootIds) { return this.photoRootIds; } @@ -113,7 +113,7 @@ export class PhotoSharesManager { } async isOwnVolume(volumeId: string): Promise { - const { volumeId: myVolumeId } = await this.getOwnVolumeIDs(); + const { volumeId: myVolumeId } = await this.getRootIDs(); if (volumeId === myVolumeId) { return true; } @@ -121,7 +121,7 @@ export class PhotoSharesManager { } async getVolumeMetricContext(volumeId: string): Promise { - const { volumeId: myVolumeId } = await this.getOwnVolumeIDs(); + const { volumeId: myVolumeId } = await this.getRootIDs(); if (volumeId === myVolumeId) { return MetricVolumeType.OwnVolume; } diff --git a/js/sdk/src/internal/photos/timeline.ts b/js/sdk/src/internal/photos/timeline.ts index 6c5b02d3..94e6ef3d 100644 --- a/js/sdk/src/internal/photos/timeline.ts +++ b/js/sdk/src/internal/photos/timeline.ts @@ -18,7 +18,7 @@ export class PhotosTimeline { captureTime: Date; tags: number[]; }> { - const { volumeId } = await this.photoShares.getOwnVolumeIDs(); + const { volumeId } = await this.photoShares.getRootIDs(); yield* this.apiService.iterateTimeline(volumeId, signal); } } diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts index 55649444..1c58b8b2 100644 --- a/js/sdk/src/internal/shares/manager.test.ts +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -50,7 +50,7 @@ describe('SharesManager', () => { manager = new SharesManager(getMockLogger(), apiService, cache, cryptoCache, cryptoService, account); }); - describe('getOwnVolumeIDs', () => { + describe('getRootIDs', () => { const myFilesShare = { shareId: 'myFilesShareId', volumeId: 'myFilesVolumeId', @@ -71,8 +71,8 @@ describe('SharesManager', () => { cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ share: myFilesShare, key }); // Calling twice to check if it loads only once. - await manager.getOwnVolumeIDs(); - const result = await manager.getOwnVolumeIDs(); + await manager.getRootIDs(); + const result = await manager.getRootIDs(); expect(result).toStrictEqual(myFilesShare); expect(apiService.getMyFiles).toHaveBeenCalledTimes(1); @@ -103,7 +103,7 @@ describe('SharesManager', () => { }); apiService.createVolume = jest.fn().mockResolvedValue(myFilesShare); - const result = await manager.getOwnVolumeIDs(); + const result = await manager.getRootIDs(); expect(result).toStrictEqual(myFilesShare); expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); @@ -113,7 +113,7 @@ describe('SharesManager', () => { it('should throw on unknown error', async () => { apiService.getMyFiles = jest.fn().mockRejectedValue(new Error('Some error')); - await expect(manager.getOwnVolumeIDs()).rejects.toThrow('Some error'); + await expect(manager.getRootIDs()).rejects.toThrow('Some error'); expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); expect(apiService.createVolume).not.toHaveBeenCalled(); }); @@ -142,7 +142,7 @@ describe('SharesManager', () => { describe('getMyFilesShareMemberEmailKey', () => { it('should return cached volume email key', async () => { - jest.spyOn(manager, 'getOwnVolumeIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); + jest.spyOn(manager, 'getRootIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); cache.getVolume = jest.fn().mockResolvedValue({ addressId: 'addressId' }); account.getOwnAddress = jest .fn() @@ -158,7 +158,7 @@ describe('SharesManager', () => { }); it('should load volume email key if not in cache', async () => { - jest.spyOn(manager, 'getOwnVolumeIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); + jest.spyOn(manager, 'getRootIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); const share = { volumeId: 'volumeId', shareId: 'shareId', diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index 74c0badd..d2bf2e2d 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -46,7 +46,7 @@ export class SharesManager { * * If the default volume or My files section doesn't exist, it creates it. */ - async getOwnVolumeIDs(): Promise { + async getRootIDs(): Promise { if (this.myFilesIds) { return this.myFilesIds; } @@ -140,7 +140,7 @@ export class SharesManager { addressKey: PrivateKey; addressKeyId: string; }> { - const { volumeId } = await this.getOwnVolumeIDs(); + const { volumeId } = await this.getRootIDs(); try { const { addressId } = await this.cache.getVolume(volumeId); @@ -196,11 +196,11 @@ export class SharesManager { } async isOwnVolume(volumeId: string): Promise { - return (await this.getOwnVolumeIDs()).volumeId === volumeId; + return (await this.getRootIDs()).volumeId === volumeId; } async getVolumeMetricContext(volumeId: string): Promise { - const { volumeId: myVolumeId } = await this.getOwnVolumeIDs(); + const { volumeId: myVolumeId } = await this.getRootIDs(); // SDK doesn't support public sharing yet, also public sharing // doesn't use a volume but shareURL, thus we can simplify and diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index eb01f6b5..25a29261 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -142,7 +142,7 @@ export interface PublicLinkWithCreatorEmail extends PublicLink { * Interface describing the dependencies to the shares module. */ export interface SharesService { - getOwnVolumeIDs(): Promise<{ volumeId: string }>; + getRootIDs(): Promise<{ volumeId: string }>; loadEncryptedShare(shareId: string): Promise; getMyFilesShareMemberEmailKey(): Promise<{ email: string; diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index 23cff9c1..3b35bdb3 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -93,7 +93,7 @@ describe('SharingAccess', () => { // @ts-expect-error No need to implement all methods for mocking sharesService = { - getOwnVolumeIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), loadEncryptedShare: jest.fn().mockResolvedValue({ id: 'shareId', membership: { memberUid: 'memberUid' }, diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index d36f1d51..67c29706 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -40,7 +40,7 @@ export class SharingAccess { const nodeUids = await this.cache.getSharedByMeNodeUids(); yield* this.iterateSharedNodesFromCache(nodeUids, signal); } catch { - const { volumeId } = await this.sharesService.getOwnVolumeIDs(); + const { volumeId } = await this.sharesService.getRootIDs(); const nodeUidsIterator = this.apiService.iterateSharedNodeUids(volumeId, signal); yield* this.iterateSharedNodesFromAPI( nodeUidsIterator, diff --git a/js/sdk/src/internal/sharingPublic/shares.ts b/js/sdk/src/internal/sharingPublic/shares.ts index 59e1973c..cf230cad 100644 --- a/js/sdk/src/internal/sharingPublic/shares.ts +++ b/js/sdk/src/internal/sharingPublic/shares.ts @@ -19,8 +19,7 @@ export class SharingPublicSharesManager { this.publicRootNodeUid = publicRootNodeUid; } - // TODO: Rename to getRootIDs everywhere. - async getOwnVolumeIDs(): Promise<{ volumeId: string; rootNodeId: string; rootNodeUid: string }> { + async getRootIDs(): Promise<{ volumeId: string; rootNodeId: string; rootNodeUid: string }> { const { volumeId, nodeId: rootNodeId } = splitNodeUid(this.publicRootNodeUid); return { volumeId, rootNodeId, rootNodeUid: this.publicRootNodeUid }; } diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index bb4a63cd..964dc50c 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -164,7 +164,7 @@ export class ProtonDrivePublicLinkClient { */ async getRootNode(): Promise { this.logger.info(`Getting root node`); - const { rootNodeUid } = await this.sharingPublic.shares.getOwnVolumeIDs(); + const { rootNodeUid } = await this.sharingPublic.shares.getRootIDs(); return convertInternalNodePromise(this.sharingPublic.nodes.access.getNode(rootNodeUid)); } From a3e322e42dd23714ce81c338576930f90669666b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 3 Nov 2025 09:44:05 +0000 Subject: [PATCH 278/791] Add Swift SDK package for iOS & macOS --- cs/Directory.Packages.props | 2 + cs/headers/module.modulemap | 4 +- .../InteropProtonDriveClient.cs | 4 +- .../Proton.Drive.Sdk.CExports.csproj | 14 ++ .../src/Proton.Sdk.CExports/InteropAction.cs | 45 ++++- .../Proton.Sdk.CExports.csproj | 5 + .../ProtonApiSessionRequestHandler.cs | 2 +- .../Caching/SqliteCacheRepository.cs | 2 +- cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 14 +- swift/ProtonDriveSDK/.swiftlint.yml | 23 +++ .../Sources/CancellationTokenSource.swift | 48 +++++ .../FileOperations/DownloadManager.swift | 134 +++++++++++++ .../FileOperations/UploadManager.swift | 176 +++++++++++++++++ .../Sources/Logging/Logger.swift | 80 ++++++++ .../Sources/Logging/LoggerTypes.swift | 71 +++++++ .../Sources/Plumbing/BoxedContinuation.swift | 83 ++++++++ .../Sources/Plumbing/InternalTypes.swift | 170 +++++++++++++++++ .../Sources/Plumbing/InteropRequest.swift | 28 +++ .../Sources/Plumbing/Message+Packaging.swift | 147 +++++++++++++++ .../Sources/Plumbing/PublicTypes.swift | 178 ++++++++++++++++++ .../Sources/Plumbing/SDKRequestHandler.swift | 116 ++++++++++++ .../Sources/Plumbing/SDKResponseHandler.swift | 15 ++ .../ProtonDriveClient/AccountClient.swift | 96 ++++++++++ .../HttpClientProtocol.swift | 68 +++++++ .../ProtonDriveClient/ProtonDriveClient.swift | 114 +++++++++++ .../Sources/ProtonDriveSDKError.swift | 7 + 26 files changed, 1634 insertions(+), 12 deletions(-) create mode 100644 swift/ProtonDriveSDK/.swiftlint.yml create mode 100644 swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift create mode 100644 swift/ProtonDriveSDK/Sources/Logging/Logger.swift create mode 100644 swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index e24e8c01..6f19b08c 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -4,6 +4,8 @@ + + diff --git a/cs/headers/module.modulemap b/cs/headers/module.modulemap index 245ecb3d..345cd13c 100644 --- a/cs/headers/module.modulemap +++ b/cs/headers/module.modulemap @@ -1,4 +1,4 @@ -module ProtonDriveSdk { - umbrella header "proton_sdk.h" +module CProtonDriveSDK { + umbrella header "proton_drive_sdk.h" export * } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index a16672e8..f34f3917 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -32,7 +32,9 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi var loggerProvider = request.LoggerCase switch { - DriveClientCreateRequest.LoggerOneofCase.LogAction => new InteropLoggerProvider(bindingsHandle, new InteropAction>(request.LogAction)), + DriveClientCreateRequest.LoggerOneofCase.LogAction => new InteropLoggerProvider( + bindingsHandle, + new InteropAction>(request.LogAction)), DriveClientCreateRequest.LoggerOneofCase.LoggerProviderHandle => Interop.GetFromHandle(request.LoggerProviderHandle), DriveClientCreateRequest.LoggerOneofCase.None or _ => NullLoggerProvider.Instance, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index 8581ac1d..7005408a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -38,4 +38,18 @@ true + + Static + + + + + + + + + + + + \ No newline at end of file diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs index 1419c990..3de9eb31 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs @@ -3,10 +3,16 @@ namespace Proton.Sdk.CExports; [StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropAction(delegate* unmanaged[Cdecl] pointer) +internal readonly unsafe struct InteropAction where T : unmanaged { - private readonly delegate* unmanaged[Cdecl] _pointer = pointer; + private readonly delegate* unmanaged[Cdecl] _pointer; + + public InteropAction(delegate* unmanaged[Cdecl] pointer) + { + ArgumentNullException.ThrowIfNull(pointer); + _pointer = pointer; + } public InteropAction(long pointer) : this((delegate* unmanaged[Cdecl])pointer) @@ -17,14 +23,25 @@ public void Invoke(T arg) { _pointer(arg); } + + public override string ToString() + { + return $"0x{new nint(_pointer):x16}"; + } } [StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropAction(delegate* unmanaged[Cdecl] pointer) +internal readonly unsafe struct InteropAction where T1 : unmanaged where T2 : unmanaged { - private readonly delegate* unmanaged[Cdecl] _pointer = pointer; + private readonly delegate* unmanaged[Cdecl] _pointer; + + public InteropAction(delegate* unmanaged[Cdecl] pointer) + { + ArgumentNullException.ThrowIfNull(pointer); + _pointer = pointer; + } public InteropAction(long pointer) : this((delegate* unmanaged[Cdecl])pointer) @@ -35,15 +52,26 @@ public void Invoke(T1 arg1, T2 arg2) { _pointer(arg1, arg2); } + + public override string ToString() + { + return $"0x{new nint(_pointer):x16}"; + } } [StructLayout(LayoutKind.Sequential)] -internal readonly unsafe struct InteropAction(delegate* unmanaged[Cdecl] pointer) +internal readonly unsafe struct InteropAction where T1 : unmanaged where T2 : unmanaged where T3 : unmanaged { - private readonly delegate* unmanaged[Cdecl] _pointer = pointer; + private readonly delegate* unmanaged[Cdecl] _pointer; + + public InteropAction(delegate* unmanaged[Cdecl] pointer) + { + ArgumentNullException.ThrowIfNull(pointer); + _pointer = pointer; + } public InteropAction(long pointer) : this((delegate* unmanaged[Cdecl])pointer) @@ -54,4 +82,9 @@ public void Invoke(T1 arg1, T2 arg2, T3 arg3) { _pointer(arg1, arg2, arg3); } + + public override string ToString() + { + return $"0x{new nint(_pointer):x16}"; + } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj index 9c4151f0..4a219d66 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj @@ -24,4 +24,9 @@ + + + + + diff --git a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs index 2653abf7..9f303aef 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs @@ -123,7 +123,7 @@ public static IMessage HandleRenew(SessionRenewRequest request) return null; } - public static unsafe IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefreshedSubscribeRequest request, nint bindingsHandle) + public static IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefreshedSubscribeRequest request, nint bindingsHandle) { var session = Interop.GetFromHandle((nint)request.SessionHandle); diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index c6d4242a..e7a3f892 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -16,7 +16,7 @@ public static SqliteCacheRepository OpenInMemory() { var connectionStringBuilder = new SqliteConnectionStringBuilder { - DataSource = Guid.NewGuid().ToString(), + DataSource = ":memory:", Mode = SqliteOpenMode.Memory, Cache = SqliteCacheMode.Shared, }; diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index 3d0871e1..67fabd4d 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -10,7 +10,6 @@ - @@ -19,6 +18,19 @@ + + + + + + + + + + diff --git a/swift/ProtonDriveSDK/.swiftlint.yml b/swift/ProtonDriveSDK/.swiftlint.yml new file mode 100644 index 00000000..4a33b026 --- /dev/null +++ b/swift/ProtonDriveSDK/.swiftlint.yml @@ -0,0 +1,23 @@ +# .swiftlint.yml +included: + - Sources + - Tests + +opt_in_rules: + - empty_count + - explicit_init + - force_unwrapping + - implicit_return + - prohibited_super_call + - implicit_optional_initialization + +# disabled_rules: + +identifier_name: + min_length: 2 + +line_length: + warning: 160 + error: 160 + +# strict: true diff --git a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift new file mode 100644 index 00000000..a88895d8 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift @@ -0,0 +1,48 @@ +actor CancellationTokenSource { + let handle: ObjectHandle + private let logger: ProtonDriveSDK.Logger? + + init(logger: ProtonDriveSDK.Logger?) async throws { + self.logger = logger + + let request = Proton_Sdk_CancellationTokenSourceCreateRequest() + self.handle = try await SDKRequestHandler.sendInteropRequest(request, logger: logger) + + logger?.trace("CancellationTokenSource.init, handle: \(String(describing: handle))", category: .cancellation) + } + + func cancel() async throws { + logger?.trace("CancellationTokenSource.cancel, handle: \(String(describing: handle))", category: .cancellation) + + try await SDKRequestHandler.sendInteropRequest( + Proton_Sdk_CancellationTokenSourceCancelRequest.with { + $0.cancellationTokenSourceHandle = Int64(handle) + }, + logger: logger + ) as Void + } + + nonisolated func free() { + logger?.trace("CancellationTokenSource.free, handle: \(String(describing: handle))", category: .cancellation) + let cancellationHandle = self.handle + + // CAUTION: Intentionally capturing `self` strongly here, because otherwise + // this instance might get released before the async response from the SDK is received. + var strongSelf: CancellationTokenSource? = self + Task { + let request = Proton_Sdk_CancellationTokenSourceFreeRequest.with { + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + try await SDKRequestHandler.sendInteropRequest(request, logger: logger) as Void + logger?.trace("CancellationTokenSource.free succeeded, handle: \(cancellationHandle) -> nil", category: .cancellation) + strongSelf = nil + } + } + + deinit { + logger?.trace("CancellationTokenSource.deinit, handle: \(String(describing: handle))", category: .cancellation) +// // TODO(SDK): free handle in deinit +// free() +// logger?.trace("CancellationTokenSource.deinit, after handle: \(String(describing: cancellationHandle))", category: .cancellation) + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift new file mode 100644 index 00000000..953b6a7d --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift @@ -0,0 +1,134 @@ +import Foundation + +/// Handles file download operations for ProtonDrive +public actor DownloadManager { + + private let clientHandle: ObjectHandle + private let logger: Logger? + private var downloads: [ObjectHandle: DownloadManager] = [:] + + init(clientHandle: ObjectHandle, logger: Logger?) { + self.clientHandle = clientHandle + self.logger = logger + } + + /// Download file from file URL with complete download flow + public func downloadFile( + revisionUid: SDKRevisionUid, + destinationUrl: URL, + progressCallback: @escaping ProgressCallback + ) async throws { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + defer { + // TODO: Should be done in deinit! + cancellationTokenSource.free() + } + + let downloaderHandle = try await startFileDownload( + revisionUid: revisionUid.sdkCompatibleIdentifier, + fileURL: destinationUrl, + cancellationHandle: cancellationTokenSource.handle + ) + + defer { + freeDownloader(downloaderHandle) + } + + let downloaderRequest = Proton_Drive_Sdk_DownloadToFileRequest.with { + $0.downloaderHandle = Int64(downloaderHandle) + $0.filePath = destinationUrl.path(percentEncoded: false) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + } + + let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send(downloaderRequest, logger: logger) + assert(downloadControllerHandle != 0) + defer { + freeDownloadController(downloadControllerHandle) + } + + try await awaitDownloadCompletion(downloadControllerHandle) + } + + /// Get a file downloader for downloading files from Drive + private func startFileDownload( + revisionUid: String, + fileURL: URL, + cancellationHandle: ObjectHandle + ) async throws -> ObjectHandle { + let downloaderRequest = Proton_Drive_Sdk_DriveClientGetFileDownloaderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.revisionUid = revisionUid + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let downloaderHandle: ObjectHandle = try await SDKRequestHandler.send(downloaderRequest, logger: logger) + assert(downloaderHandle != 0) + return downloaderHandle + } + + /// Wait for download completion + private func awaitDownloadCompletion(_ downloadControllerHandle: ObjectHandle) async throws { + assert(downloadControllerHandle != 0) + let awaitDownloadCompletionRequest = Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest.with { + $0.downloadControllerHandle = Int64(downloadControllerHandle) + } + + try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void + } + + /// Pause download controllers + public func pauseDownloads() async throws { + downloads.keys.forEach { downloadControllerHandle in + Task { + let pauseRequest = Proton_Drive_Sdk_DownloadControllerPauseRequest.with { + $0.downloadControllerHandle = Int64(downloadControllerHandle) + } + + try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void + } + } + } + + /// Resume download controllers + public func resumeDownloads() async throws { + downloads.keys.forEach { downloadControllerHandle in + Task { + let pauseRequest = Proton_Drive_Sdk_DownloadControllerResumeRequest.with { + $0.downloadControllerHandle = Int64(downloadControllerHandle) + } + + try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void + } + } + } + + /// Free download controller when no longer needed + private func freeDownloadControllers() { + downloads.keys.forEach { downloadControllerHandle in + freeDownloadController(downloadControllerHandle) + } + } + + /// Free a file downloader when no longer needed + private func freeDownloader(_ fileDownloaderHandle: ObjectHandle) { + Task { + let freeRequest = Proton_Drive_Sdk_FileDownloaderFreeRequest.with { + $0.fileDownloaderHandle = Int64(fileDownloaderHandle) + } + + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } + } + + /// Free a file download controller when no longer needed + private func freeDownloadController(_ downloadControllerHandle: ObjectHandle) { + Task { + let freeRequest = Proton_Drive_Sdk_DownloadControllerFreeRequest.with { + $0.downloadControllerHandle = Int64(downloadControllerHandle) + } + + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift new file mode 100644 index 00000000..fba0cc55 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift @@ -0,0 +1,176 @@ +import Foundation +import SwiftProtobuf + +/// Handles file upload operations for ProtonDrive +public actor UploadManager { + + private let clientHandle: ObjectHandle + private let logger: Logger? + private var uploads: [ObjectHandle: UploadManager] = [:] + + init(clientHandle: ObjectHandle, logger: Logger?) { + self.clientHandle = clientHandle + self.logger = logger + } + + private func startFileUpload( + parentFolderUid: String, + name: String, + mediaType: String, + fileSize: Int64, + modificationDate: Date, + overrideExistingDraft: Bool = false, + cancellationHandle: ObjectHandle? = nil, + logger: Logger? + ) async throws -> ObjectHandle { + let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileUploaderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.parentFolderUid = parentFolderUid + $0.name = name + $0.mediaType = mediaType + $0.size = fileSize + $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + $0.overrideExistingDraftByOtherClient = overrideExistingDraft + + if let cancellationHandle = cancellationHandle { + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + } + + let uploaderHandle: ObjectHandle = try await SDKRequestHandler.send(uploaderRequest, logger: logger) + assert(uploaderHandle != 0) + return uploaderHandle + } + + /// Upload file from file URL with complete upload flow + public func uploadFile( + parentFolderUid: SDKNodeUid, + name: String, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + mediaType: String, + thumbnails: [ThumbnailData] = [], + overrideExistingDraft: Bool = false, + progressCallback: @escaping ProgressCallback + ) async throws -> FileNodeUploadResult { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + defer { + // TODO: Should be done in deinit! + cancellationTokenSource.free() + } + + let cancellationHandle = cancellationTokenSource.handle + + let uploaderHandle = try await startFileUpload( + parentFolderUid: parentFolderUid.sdkCompatibleIdentifier, + name: name, + mediaType: mediaType, + fileSize: fileSize, + modificationDate: modificationDate, + overrideExistingDraft: overrideExistingDraft, + cancellationHandle: cancellationTokenSource.handle, + logger: logger + ) + + defer { + freeFileUploader(uploaderHandle) + } + + let progressAction: Int64 = 0 + + let uploaderRequest = Proton_Drive_Sdk_UploadFromFileRequest.with { + $0.uploaderHandle = Int64(uploaderHandle) + $0.filePath = fileURL.path(percentEncoded: false) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + $0.thumbnails = thumbnails.map { thumbnail in + Proton_Drive_Sdk_Thumbnail.with { + $0.type = thumbnail.type == .thumbnail ? .thumbnail : .preview + $0.contentLength = Int64(thumbnail.data.count) + let dataHandle = thumbnail.data.withUnsafeBytes { (u8Ptr: UnsafePointer) in + return ObjectHandle(bitPattern: UnsafeRawPointer(u8Ptr)) + } + $0.contentPointer = Int64(dataHandle) + } + } + } + + let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send(uploaderRequest, logger: logger) + assert(uploadControllerHandle != 0) + + let uploadedNode = try await awaitUploadCompletion(uploadControllerHandle) + return uploadedNode + } + + /// Wait for upload completion + private func awaitUploadCompletion(_ uploadControllerHandle: ObjectHandle) async throws -> FileNodeUploadResult { + assert(uploadControllerHandle != 0) + let awaitUploadCompletionRequest = Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest.with { + $0.uploadControllerHandle = Int64(uploadControllerHandle) + } + + let uploadResult: Proton_Drive_Sdk_UploadResult = try await SDKRequestHandler.send(awaitUploadCompletionRequest, logger: logger) + guard let result = FileNodeUploadResult(interopUploadResult: uploadResult) else { + throw "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)" + } + return result + } + + /// Free a file uploader when no longer needed + private func freeFileUploader(_ fileUploaderHandle: ObjectHandle) { + let freeRequest = Proton_Drive_Sdk_FileUploaderFreeRequest.with { + $0.fileUploaderHandle = Int64(fileUploaderHandle) + } + + Task { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } + } + + /// Pause upload controllers + public func pauseUploads() async throws { + uploads.keys.forEach { uploadControllerHandle in + Task { + let pauseRequest = Proton_Drive_Sdk_UploadControllerPauseRequest.with { + $0.uploadControllerHandle = Int64(uploadControllerHandle) + } + + try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void + } + } + } + + /// Resume upload controllers + public func resumeUploads() async throws { + uploads.keys.forEach { uploadControllerHandle in + Task { + let pauseRequest = Proton_Drive_Sdk_UploadControllerResumeRequest.with { + $0.uploadControllerHandle = Int64(uploadControllerHandle) + } + + try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void + } + } + } + + /// Free upload controller when no longer needed + private func freeUploadControllers() { + uploads.keys.forEach { uploadControllerHandle in + Task { + let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { + $0.uploadControllerHandle = Int64(uploadControllerHandle) + } + + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } + } + } +} + +let cProgressCallback: CCallback = { state, byteArray in + guard let state else { + return + } +} + diff --git a/swift/ProtonDriveSDK/Sources/Logging/Logger.swift b/swift/ProtonDriveSDK/Sources/Logging/Logger.swift new file mode 100644 index 00000000..3dc1891f --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Logging/Logger.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Callback for log events +public typealias LogCallback = @Sendable (LogEvent) -> Void + + +func logCallbackForTests(logEvent: LogEvent) { + let timestamp = logEvent.timestamp.formatted(date: .abbreviated, time: .shortened) + + let prefix = "\(logEvent.level.symbol)[\(String(describing: logEvent.level).prefix(1).capitalized)][\(logEvent.thread ?? 0)]" + let logLine = "\(prefix)\(timestamp) \(logEvent.category): \(logEvent.message)" + print(logLine) +} + +extension LogLevel { + var symbol: String { + switch self { + case .trace: "🟣" + case .debug: "🔵" + case .info: "🟢" + case .warning: "âš ï¸" + case .error: "âŒ" + case .critical: "💣" + case .none: "" + } + } +} + +let cCompatibleLogCallback: CCallback = { state, byteArray in + guard let state else { + return + } + +// let logEvent = LogEvent(sdkLogEvent: Proton_Sdk_LogEvent(byteArray: byteArray)) +// +// let continuationBox = Unmanaged>.fromOpaque(state).takeUnretainedValue() +// let driveClient: ProtonDriveClient = continuationBox.state +// +// driveClient.log(logEvent) +} + +final class Logger: Sendable { + /// Callback provided by the SDK consumer + let logCallback: LogCallback + + init(logCallback: @escaping LogCallback) async throws { + self.logCallback = logCallback + } + + func trace(_ message: String, category: LogCategory, file: String = #file, function: String = #function, line: UInt = #line) { + self.log(level: .trace, message, category: category) + } + + func debug(_ message: String, category: LogCategory) { + self.log(level: .debug, message, category: category) + } + + func error(_ message: String, category: LogCategory) { + self.log(level: .error, message, category: category) + } + + func info(_ message: String, category: LogCategory) { + self.log(level: .info, message, category: category) + } + + func log(level: LogLevel, _ message: String, category: LogCategory, file: String = #file, function: String = #function, line: UInt = #line) { + self.logCallback( + LogEvent(level: level, message: message, category: category, thread: Thread.current.number, file: file, function: function, line: line) + ) + } +} + +extension Thread { + var number: UInt { + guard let match = Thread.current.description.firstMatch(of: #/number = (\d+)/#), let number = UInt(match.output.1) else { + return 0 + } + return number + } +} diff --git a/swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift b/swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift new file mode 100644 index 00000000..e8107548 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift @@ -0,0 +1,71 @@ +import Foundation + +public struct LogEvent: Sendable { + public let level: LogLevel + public let message: String + public let category: LogCategory + public let timestamp: Date + + public let thread: UInt? + public let file: String + public let function: String + public let line: UInt + + public init(level: LogLevel, message: String, category: LogCategory, timestamp: Date = Date(), thread: UInt?, file: String, function: String, line: UInt) { + self.level = level + self.message = message + self.category = category + self.timestamp = timestamp + + self.thread = thread + self.file = file + self.function = function + self.line = line + } + + init(sdkLogEvent: Proton_Sdk_LogEvent) { + self.init( + level: LogLevel(sdkLogEvent.level), + message: sdkLogEvent.message, + category: LogCategory(sdkLogEvent.categoryName), + thread: 0, + // TODO: extract this from SDK error + file: "n/a", + function: "n/a", + line: 0 + ) + } +} + +/// https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel +public enum LogLevel: Int32, Sendable { + case trace = 0 + case debug = 1 + case info = 2 + case warning = 3 + case error = 4 + case critical = 5 + case none = 6 + + public init(_ rawValue: Int32) { + self = LogLevel(rawValue: rawValue) ?? .debug + } +} + +public enum LogCategory: Sendable { + case other(String) + case upload + case download + case cancellation + case logging + + init(_ categoryName: String) { + switch categoryName { + case "upload": self = .upload + case "download": self = .download + case "cancellation": self = .cancellation + case "logging": self = .logging + default: self = .other(categoryName) + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift new file mode 100644 index 00000000..e23a8eec --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift @@ -0,0 +1,83 @@ +protocol Resumable: AnyObject { + associatedtype ReturnType + typealias Continuation = CheckedContinuation + + func resume(returning value: sending ReturnType) + func resume(throwing error: Error) + + var context: Any { get } +} + +extension Resumable where ReturnType == Void { + func resume() { + self.resume(returning: ()) + } +} + +/// Class containing a continuation - for when a continuation needs to be accessible by memory address +final class BoxedContinuation: Resumable { + private var continuation: Continuation? + + let context: Any = Void() + + init(_ continuation: Continuation) { + self.continuation = continuation + } + + func resume(returning value: sending ResultType) { + guard let continuation else { + assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") + return + } + continuation.resume(returning: value) + self.continuation = nil + } + + func resume(throwing error: any Error) { + guard let continuation else { + assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") + return + } + continuation.resume(throwing: error) + self.continuation = nil + } +} + +final class BoxedContinuationWithState: Resumable { + typealias Continuation = CheckedContinuation + + private var continuation: Continuation? + let state: StateType + let context: Any + + init(_ continuation: Continuation, state: StateType, context: Any) { + self.continuation = continuation + self.state = state + self.context = context + } + + init(_ continuation: Continuation, weakState state: WeakStateType, context: Any) + where StateType == WeakReference { + self.continuation = continuation + self.state = WeakReference(value: state) + self.context = context + } + + func resume(returning value: sending ResultType) { + guard let continuation else { + assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") + return + } + continuation.resume(returning: value) + self.continuation = nil + } + + func resume(throwing error: any Error) { + guard let continuation else { + assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") + return + } + continuation.resume(throwing: error) + self.continuation = nil + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift new file mode 100644 index 00000000..1a93915c --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -0,0 +1,170 @@ +import Foundation +import CProtonDriveSDK +import SwiftProtobuf + +/// Used internally to pass around numbers representing memory addresses +typealias ObjectHandle = Int + +extension ObjectHandle { + /// Returns the address of a callback as a number + init(callback: CCallback) { + let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) + self = ObjectHandle(bitPattern: callbackAddress) + } + + /// Returns the address of a callback as a number + init(callback: CCallbackWithReturnValue) { + let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) + self = ObjectHandle(bitPattern: callbackAddress) + } + + init(callback: CResponseCallback) { + let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) + self = ObjectHandle(bitPattern: callbackAddress) + } +} + +extension ObjectHandle { + init(rawPointer: UnsafeRawPointer) { + self.init(UInt(bitPattern: rawPointer)) + } +} + +func address(of object: T) -> ObjectHandle { + let rawPointer = Unmanaged.passUnretained(object).toOpaque() + return ObjectHandle(bitPattern: rawPointer) +} + +/// C-compatible callback used to get response from the SDK +typealias CResponseCallback = @convention(c) (Int, ByteArray) -> Void + +/// C-compatible callback used by SDK to pass data to the app +typealias CCallback = @convention(c) (UnsafeMutableRawPointer?, ByteArray) -> Void +typealias CCallbackWithReturnValue = @convention(c) (UnsafeMutableRawPointer, ByteArray, UnsafeMutableRawPointer) -> Void + +extension Data { + var dumptoString: String { + String(data: self, encoding: .isoLatin2).map { String($0) } ?? "n/a" + } +} + +// MARK: - Error extensions + +extension String: @retroactive Error {} + +extension Proton_Sdk_Error: Error { + + var localizedDescription: String { + return "\(self.message)" + } + + var nsError: NSError { + return NSError(domain: "ProtonDriveSDK", code: Int(primaryCode), userInfo: [ + NSLocalizedDescriptionKey: message + ]) + } +} + +// MARK: - ByteArray extensions + +extension ByteArray: @unchecked @retroactive Sendable {} + +extension ByteArray { + init(data: Data) { + if !data.isEmpty { + let buffer = UnsafeMutablePointer.allocate(capacity: data.count) + data.copyBytes(to: buffer, count: data.count) + self.init(pointer: UnsafePointer(buffer), length: data.count) + } else { + self.init(pointer: nil, length: 0) + } + } + + /// Deallocate memory - call when done with the array + func deallocate() { + if let pointer = pointer { + UnsafeMutablePointer(mutating: pointer).deallocate() + } + } +} + +extension Data { + init(byteArray: ByteArray) { + if let pointer = byteArray.pointer { + self.init(bytes: pointer, count: byteArray.length) + } else { + self.init() + } + } +} + +// MARK: - Protobuf extensions + +extension SwiftProtobuf.Message { + var isDriveRequest: Bool { + String(describing: self).starts(with: "ProtonDriveSDK.Proton_Drive_Sdk_") + } +} + +extension SwiftProtobuf.Message { + init(byteArray: ByteArray) { + guard let pointer = byteArray.pointer else { self.init(); return } + + let data = Data(bytes: pointer, count: byteArray.length) + do { + try self.init(serializedBytes: data) + } catch { + self.init() + } + } +} + +extension Proton_Sdk_ProtonClientTlsPolicy { + init(tlsPolicy: TlsPolicy) { + switch tlsPolicy { + case .strict: + self = .strict + + case .noCertificatePinning: + self = .noCertificatePinning + + case .noCertificateValidation: + self = .noCertificateValidation + } + } +} + +extension Proton_Sdk_ProtonClientOptions { + init(clientOptions: ClientOptions) { + self = Proton_Sdk_ProtonClientOptions.with { + if let baseUrl = clientOptions.baseUrl { + $0.baseURL = baseUrl + } + + if let userAgent = clientOptions.userAgent { + $0.userAgent = userAgent + } + + if let bindingsLanguage = clientOptions.bindingsLanguage { + $0.bindingsLanguage = bindingsLanguage + } + + if let tlsPolicy = clientOptions.tlsPolicy { + $0.tlsPolicy = Proton_Sdk_ProtonClientTlsPolicy(tlsPolicy: tlsPolicy) + } + + if let loggerProviderHandle = clientOptions.loggerProviderHandle { + $0.loggerProviderHandle = Int64(loggerProviderHandle) + } + + if let entityCachePath = clientOptions.entityCachePath { + $0.entityCachePath = entityCachePath + } + } + } +} + +final class WeakReference where T: AnyObject { + private(set) weak var value: T? + init(value: T) { self.value = value } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift new file mode 100644 index 00000000..c7488abd --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift @@ -0,0 +1,28 @@ +protocol InteropRequest { + associatedtype CallResultType + associatedtype StateType +} + +extension InteropRequest { + typealias BoxedStateType = BoxedContinuationWithState +} + +extension Proton_Drive_Sdk_DriveClientCreateRequest: InteropRequest { + typealias CallResultType = ObjectHandle + typealias StateType = WeakReference +} + +extension Proton_Sdk_CancellationTokenSourceCreateRequest: InteropRequest { + typealias CallResultType = ObjectHandle + typealias StateType = Void +} + +extension Proton_Sdk_CancellationTokenSourceCancelRequest: InteropRequest { + typealias CallResultType = Void + typealias StateType = Void +} + +extension Proton_Sdk_CancellationTokenSourceFreeRequest: InteropRequest { + typealias CallResultType = Void + typealias StateType = Void +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift new file mode 100644 index 00000000..ae5ec4fa --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -0,0 +1,147 @@ +import SwiftProtobuf +import CProtonDriveSDK + +extension Message { + func serializedIntoRequest() throws -> ByteArray { + try packIntoRequest().serialisedByteArray() + } + + func serializedIntoResponse() throws -> ByteArray { + try packIntoResponse().serialisedByteArray() + } + + /// Packs any request into a Proton_Sdk_Request or Proton_Drive_Sdk_Request. + func packIntoRequest() throws -> Message { + switch self { + case let request as Proton_Sdk_SessionBeginRequest: + Proton_Sdk_Request.with { + $0.payload = .sessionBegin(request) + } + + case let request as Proton_Sdk_CancellationTokenSourceCreateRequest: + Proton_Sdk_Request.with { + $0.payload = .cancellationTokenSourceCreate(request) + } + + case let request as Proton_Sdk_CancellationTokenSourceCancelRequest: + Proton_Sdk_Request.with { + $0.payload = .cancellationTokenSourceCancel(request) + } + + case let request as Proton_Sdk_CancellationTokenSourceFreeRequest: + Proton_Sdk_Request.with { + $0.payload = .cancellationTokenSourceFree(request) + } + + case let request as Proton_Sdk_LoggerProviderCreate: + Proton_Sdk_Request.with { + $0.payload = .loggerProviderCreate(request) + } + + case let request as Proton_Drive_Sdk_DriveClientCreateFromSessionRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientCreateFromSession(request) + } + + case let request as Proton_Drive_Sdk_DriveClientCreateRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientCreate(request) + } + + case let request as Proton_Drive_Sdk_DriveClientGetFileUploaderRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientGetFileUploader(request) + } + + case let request as Proton_Drive_Sdk_DriveClientGetFileDownloaderRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientGetFileDownloader(request) + } + + case let request as Proton_Drive_Sdk_UploadFromFileRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .uploadFromFile(request) + } + + case let request as Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .uploadControllerAwaitCompletion(request) + } + + case let request as Proton_Drive_Sdk_DownloadToFileRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadToFile(request) + } + + case let request as Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadControllerAwaitCompletion(request) + } + + case let request as Proton_Drive_Sdk_FileUploaderFreeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .fileUploaderFree(request) + } + + case let request as Proton_Drive_Sdk_UploadControllerFreeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .uploadControllerFree(request) + } + + case let request as Proton_Drive_Sdk_FileDownloaderFreeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .fileDownloaderFree(request) + } + + case let request as Proton_Drive_Sdk_DownloadControllerFreeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadControllerFree(request) + } + + default: + assertionFailure("Unknown request") + throw "Unknown request type: \(self)" + } + } + + private func packIntoResponse() throws -> Message { + if let error = self as? Proton_Sdk_Error { + return Proton_Sdk_Response.with { + $0.error = error + } + } + switch self { + case let httpResponse as Proton_Sdk_HttpResponse: + let value = try Google_Protobuf_Any.init(message: httpResponse) + return Proton_Sdk_Response.with { + $0.value = value + } + case let repeatedBytes as Proton_Sdk_RepeatedBytesValue: + let value = try Google_Protobuf_Any.init(message: repeatedBytes) + return Proton_Sdk_Response.with { + $0.value = value + } + case let bytesValue as Google_Protobuf_BytesValue: + let value = try Google_Protobuf_Any.init(message: bytesValue) + return Proton_Sdk_Response.with { + $0.value = value + } + case let address as Proton_Sdk_Address: + let value = try Google_Protobuf_Any.init(message: address) + return Proton_Sdk_Response.with { + $0.value = value + } + case let error as Proton_Sdk_Error: + return Proton_Sdk_Response.with { + $0.error = error + } + default: + assertionFailure("Unknown response") + throw "Unknown response type: \(self)" + } + } + + private func serialisedByteArray() throws -> ByteArray { + ByteArray(data: try serializedData()) + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift new file mode 100644 index 00000000..7f340f89 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -0,0 +1,178 @@ +import Foundation + +// MARK: - Swift Types (hiding protobuf implementation) + +public struct SDKNodeUid: Sendable { + public let volumeID: String + public let nodeID: String + public let sdkCompatibleIdentifier: String + + public init(volumeID: String, nodeID: String) { + self.volumeID = volumeID + self.nodeID = nodeID + self.sdkCompatibleIdentifier = "\(volumeID)~\(nodeID)" + } + + public init?(sdkCompatibleIdentifier: String) { + guard let match = sdkCompatibleIdentifier.firstMatch(of: #/(.+)~(.+)/#) else { return nil } + self.volumeID = String(match.output.1) + self.nodeID = String(match.output.2) + self.sdkCompatibleIdentifier = sdkCompatibleIdentifier + } +} + +public struct SDKRevisionUid: Sendable { + public let volumeID: String + public let nodeID: String + public let revisionID: String + public let sdkCompatibleIdentifier: String + + public init(sdkNodeUid: SDKNodeUid, revisionID: String) { + self.init(volumeID: sdkNodeUid.volumeID, nodeID: sdkNodeUid.nodeID, revisionID: revisionID) + } + + public init(volumeID: String, nodeID: String, revisionID: String) { + self.volumeID = volumeID + self.nodeID = nodeID + self.revisionID = revisionID + self.sdkCompatibleIdentifier = "\(volumeID)~\(nodeID)~\(revisionID)" + } + + public init?(sdkCompatibleIdentifier: String) { + guard let match = sdkCompatibleIdentifier.firstMatch(of: #/(.+)~(.+)~(.+)/#) else { return nil } + self.volumeID = String(match.output.1) + self.nodeID = String(match.output.2) + self.revisionID = String(match.output.3) + self.sdkCompatibleIdentifier = sdkCompatibleIdentifier + } +} + +/// TLS policy for Proton client connections +public enum TlsPolicy: Sendable { + case strict + case noCertificatePinning + case noCertificateValidation +} + +/// Session tokens for authentication +public struct SessionTokens { + public let accessToken: String + public let refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} + +/// Proton client configuration options +public struct ClientOptions: Sendable { + public let baseUrl: String? + public let userAgent: String? + public let bindingsLanguage: String? + public let tlsPolicy: TlsPolicy? + public let loggerProviderHandle: Int? + public let entityCachePath: String? + public let secretCachePath: String? + + public init(baseUrl: String? = nil, + userAgent: String? = nil, + bindingsLanguage: String? = nil, + tlsPolicy: TlsPolicy? = nil, + loggerProviderHandle: Int? = nil, + entityCachePath: String? = nil, + secretCachePath: String? = nil + ) { + self.baseUrl = baseUrl + self.userAgent = userAgent + self.bindingsLanguage = bindingsLanguage + self.tlsPolicy = tlsPolicy + self.loggerProviderHandle = loggerProviderHandle + self.entityCachePath = entityCachePath + self.secretCachePath = secretCachePath + } +} + +/// Thumbnail data for file uploads +public struct ThumbnailData: Sendable { + public enum ThumbnailType: Sendable { + case thumbnail + case preview + } + + public let type: ThumbnailType + public let data: Data + + public init(type: ThumbnailType, data: Data) { + self.type = type + self.data = data + } +} + +public struct FileNode: Sendable { + let uid: String + let parentUid: String + let name: String + let mediaType: String + let totalSizeOnCloudStorage: Int64 + let activeRevision: FileRevision + + init(sdkFileNode: Proton_Drive_Sdk_FileNode) { + self.uid = sdkFileNode.uid + self.parentUid = sdkFileNode.parentUid + self.name = sdkFileNode.name + self.mediaType = sdkFileNode.mediaType + self.totalSizeOnCloudStorage = sdkFileNode.totalSizeOnCloudStorage + self.activeRevision = FileRevision(sdkFileRevision: sdkFileNode.activeRevision) + } +} + +public struct FileRevision: Sendable { + let uid: String + let creationTime: Double + let sizeOnCloudStorage: Int64 + let claimedSize: Int64? + let claimedModificationTime: Double? + + init(sdkFileRevision: Proton_Drive_Sdk_FileRevision) { + self.uid = sdkFileRevision.uid + self.creationTime = sdkFileRevision.creationTime.timeIntervalSince1970 + self.sizeOnCloudStorage = sdkFileRevision.sizeOnCloudStorage + self.claimedSize = sdkFileRevision.claimedSize + self.claimedModificationTime = sdkFileRevision.claimedModificationTime.timeIntervalSince1970 + } +} + +public struct FileNodeUploadResult: Sendable { + public let nodeUid: SDKNodeUid + public let revisionUid: SDKRevisionUid + + init?(interopUploadResult: Proton_Drive_Sdk_UploadResult) { + interopUploadResult.nodeUid + guard let nodeUid = SDKNodeUid(sdkCompatibleIdentifier: interopUploadResult.nodeUid), + let revisionUid = SDKRevisionUid(sdkCompatibleIdentifier: interopUploadResult.revisionUid) + else { return nil } + self.nodeUid = nodeUid + self.revisionUid = revisionUid + } +} + +/// Callback for progress updates +public typealias ProgressCallback = @Sendable (Progress) -> Void + +/////// Progress information for upload/download operations +//// public struct FileOperationProgress { +//// public let bytesCompleted: Int64 +//// public let bytesTotal: Int64 +//// +//// /// Progress percentage (0.0 to 1.0) +//// public var percentage: Double { +//// guard bytesTotal > 0 else { return 0.0 } +//// return Double(bytesCompleted) / Double(bytesTotal) +//// } +//// +//// public init(bytesCompleted: Int64, bytesTotal: Int64) { +//// self.bytesCompleted = bytesCompleted +//// self.bytesTotal = bytesTotal +//// } +//// } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift new file mode 100644 index 00000000..12a483e9 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -0,0 +1,116 @@ +import Foundation +import CProtonDriveSDK +import SwiftProtobuf + +/// Sends requests to SDK and handles responses +enum SDKRequestHandler { + + // MARK: - Simple requests (without state) + + static func sendInteropRequest(_ request: T, logger: Logger?) async throws -> T.CallResultType + where T.StateType == Void { + try await send(request, logger: logger) + } + + static func send(_ request: T, logger: Logger?) async throws -> U { + try await send(request, state: (), logger: logger) + } + + // MARK: - Requests with additional state + // `includesLongLivedCallback` property is used to know whether we need keep the box for state alive longer + // than just until this method finished + + static func sendInteropRequest( + _ request: T, state: T.StateType, includesLongLivedCallback: Bool = false, logger: Logger? + ) async throws -> T.CallResultType { + try await self.send(request, state: state, includesLongLivedCallback: includesLongLivedCallback, logger: logger) + } + + static func send( + _ request: T, state: V, includesLongLivedCallback: Bool = false, logger: Logger? + ) async throws -> U { + // Put the request in an envelope + let envelopedRequestData = try request.packIntoRequest().serializedData() + let isDriveRequest = request.isDriveRequest + logger?.trace("Sending SDK message with state: \(T.protoMessageName) - \(request)", category: .other("SDKRequestHandler")) + + let response: U = try await withCheckedThrowingContinuation { continuation in + let requestArray = ByteArray(data: envelopedRequestData) + defer { + logger?.trace("deferred deallocate of requestData", category: .other("SDKRequestHandler")) + requestArray.deallocate() + } + + logger?.trace("Sending (\(isDriveRequest ? "Drive" : "non-Drive")) SDK request ", category: .other("SDKRequestHandler")) + + // Switch to InteropTypes.BoxedStateType once we use it for all requests + let boxedState = BoxedContinuationWithState(continuation, state: state, context: envelopedRequestData) + let pointer = Unmanaged.passRetained(boxedState) + if includesLongLivedCallback { + // We double-retain to keep the box alive after the method finishes. + // Currently, the reference to the box will not be kept anywhere, + // so the deallocation must be done in the long-lived callback. Improve if necessary. + pointer.retain() + } + let bindingsHandle = Int(rawPointer: pointer.toOpaque()) + if isDriveRequest { + logger?.trace(" -> proton_drive_sdk_handle_request", category: .other("SDKRequestHandler")) + proton_drive_sdk_handle_request(requestArray, bindingsHandle, sdkResponseCallbackWithState) + } else { + logger?.trace(" -> proton_sdk_handle_request", category: .other("SDKRequestHandler")) + proton_sdk_handle_request(requestArray, bindingsHandle, sdkResponseCallbackWithState) + } + } + return response + } +} + +/// C-compatible callback function for SDK responses. +let sdkResponseCallbackWithState: CResponseCallback = { (sdkHandle: ObjectHandle, responseArray: ByteArray) in + + guard let sdkPointer = UnsafeRawPointer(bitPattern: UInt(sdkHandle)), + let box = Unmanaged.fromOpaque(sdkPointer).takeRetainedValue() as? any Resumable + else { + assertionFailure("If the pointer is not Resumable, we cannot get the continuation") + return + } + + let response = Proton_Sdk_Response(byteArray: responseArray) + + do { + switch response.result { + case nil: // empty response. Might be expected, might be not expected + guard let voidBox = box as? Resumable else { + throw "Unexpected empty response received" + } + voidBox.resume() + + case .value(let value) where value.isA(Google_Protobuf_Int64Value.self): + let unpackedValue = try Google_Protobuf_Int64Value(unpackingAny: value).value + switch box { + case let int64Box as Resumable: + int64Box.resume(returning: unpackedValue) + case let intBox as Resumable: + intBox.resume(returning: Int(unpackedValue)) + default: + throw "Unexpected SDK call response type: Google_Protobuf_Int64Value" + } + + case .value(let value) where value.isA(Proton_Drive_Sdk_UploadResult.self): + let unpackedValue = try Proton_Drive_Sdk_UploadResult(unpackingAny: value) + guard let uploadResultBox = box as? Resumable else { + throw "Unexpected SDK call response type: Proton_Drive_Sdk_UploadResult" + } + uploadResultBox.resume(returning: unpackedValue) + + case .value: // unknown value type + throw "Unknown SDK call response value type" + + case .error(let error): + throw error + } + + } catch { + box.resume(throwing: error) + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift new file mode 100644 index 00000000..74623e3b --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift @@ -0,0 +1,15 @@ +import CProtonDriveSDK +import SwiftProtobuf + +enum SDKResponseHandler { + static func send(callbackPointer: Int, message: Message) { + do { + let byteArray = try message.serializedIntoResponse() + proton_drive_sdk_handle_response(callbackPointer, byteArray) + byteArray.deallocate() + } catch { + // TODO: this breaks SDK. We should definitely log this to Sentry. We might choose not to crash though. + fatalError("SDKResponseHandler.send failed with \(error)") + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift new file mode 100644 index 00000000..335ece7d --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift @@ -0,0 +1,96 @@ +import Foundation +import ProtonCoreDataModel +import SwiftProtobuf + +public protocol AccountClientProtocol: Sendable { + func getAddress(addressId: String) -> Address + func getDefaultAddress() -> Address + func getAddressPrimaryPrivateKey(addressId: String) -> Data + func getAddressPrivateKeys(addressId: String) -> [Data] + func getAddressPublicKeysRequest(emailAddress: String) -> [Data] +} + +let cCompatibleAccountClientRequest: CCallbackWithReturnValue = { state, byteArray, callback in + let callbackPointer = Int(rawPointer: callback) + let statePointer = Unmanaged>>.fromOpaque(state) + let weakDriveClient: WeakReference = statePointer.takeUnretainedValue().state + + let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { statePointer.release() }, weakDriveClient: weakDriveClient) + guard let driveClient else { return } + + Task { [driveClient] in + let accountClient = await driveClient.accountClient + + let request = Proton_Drive_Sdk_AccountRequest(byteArray: byteArray) + + switch request.payload { + case .getAddress(let request): + let address = accountClient.getAddress(addressId: request.addressID) + let protoAddress = address.makeProtoAddress() + SDKResponseHandler.send(callbackPointer: callbackPointer, message: protoAddress) + case .getDefaultAddress(let request): + let address = accountClient.getDefaultAddress() + let protoAddress = address.makeProtoAddress() + SDKResponseHandler.send(callbackPointer: callbackPointer, message: protoAddress) + case .getAddressPrimaryPrivateKey(let request): + let key = accountClient.getAddressPrimaryPrivateKey(addressId: request.addressID) + let bytesValue = Google_Protobuf_BytesValue.with { + $0.value = key + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: bytesValue) + case .getAddressPrivateKeys(let request): + let privateKeys = accountClient.getAddressPrivateKeys(addressId: request.addressID) + let repeatedBytes = Proton_Sdk_RepeatedBytesValue.with { + $0.value = privateKeys + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: repeatedBytes) + case .getAddressPublicKeys(let request): + let publicKeys = accountClient.getAddressPublicKeysRequest(emailAddress: request.emailAddress) + let repeatedBytes = Proton_Sdk_RepeatedBytesValue.with { + $0.value = publicKeys + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: repeatedBytes) + case nil: + fatalError() + } + } +} + +extension ProtonCoreDataModel.Address { + func makeProtoAddress() -> Proton_Sdk_Address { + return Proton_Sdk_Address.with { + $0.addressID = addressID + $0.order = Int32(order) + $0.emailAddress = email + let addressStatus: Proton_Sdk_AddressStatus = { + switch status { + case .disabled: + return .disabled + case .enabled: + return .enabled + } + }() + $0.status = addressStatus + $0.primaryKeyIndex = Int32(keys.firstIndex(where: { $0.primary == 1 }) ?? 0) + $0.keys = keys.map { key in + Proton_Sdk_AddressKey.with { + $0.addressID = addressID + $0.addressKeyID = key.keyID + $0.isActive = key.active == 1 + $0.isAllowedForEncryption = key.isAllowedForEncryption //TODO double check + $0.isAllowedForVerification = key.isAllowedForVerification + } + } + } + } +} + +fileprivate extension Key { + var isAllowedForEncryption: Bool { + KeyFlags(rawValue: UInt8(truncating: keyFlags as NSNumber)).contains(.encryptNewData) + } + + var isAllowedForVerification: Bool { + KeyFlags(rawValue: UInt8(truncating: keyFlags as NSNumber)).contains(.verifySignatures) + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift new file mode 100644 index 00000000..e2c1d3ec --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftProtobuf + +public struct HttpClientResponse { + let data: Data? + let headers: [(String, [String])] + let statusCode: Int + + public init(data: Data?, headers: [(String, [String])], statusCode: Int) { + self.data = data + self.headers = headers + self.statusCode = statusCode + } +} + +/// Protocol to be implemented by object making http requests. +public protocol HttpClientProtocol: AnyObject, Sendable { + func request(method: String, url: String, content: Data, headers: [(String, [String])]) async -> Result +} + +let cCompatibleHttpRequest: CCallbackWithReturnValue = { state, byteArray, callback in + + let callbackPointer = Int(rawPointer: callback) + let statePointer = Unmanaged>>.fromOpaque(state) + let weakDriveClient: WeakReference = statePointer.takeUnretainedValue().state + + let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { statePointer.release() }, weakDriveClient: weakDriveClient) + guard let driveClient else { return } + + Task { [driveClient] in + let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) + let headers: [(String, [String])] = httpRequestData.headers.map { header in + (header.name, header.values) + } + + let result = await driveClient.httpClient.request( + method: httpRequestData.method, + url: httpRequestData.url, + content: httpRequestData.content, + headers: headers + ) + + switch result { + case .success(let response): + let httpResponse = Proton_Sdk_HttpResponse.with { + $0.headers = response.headers.map { header in + Proton_Sdk_HttpHeader.with { + $0.name = header.0 + $0.values = header.1 + } + } + if let data = response.data { + $0.content = data + } + $0.statusCode = Int32(response.statusCode) + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) + case .failure(let error): + //TODO below we're just returning some rubbish + let error = Proton_Sdk_Error.with { + $0.type = "sdk error" + $0.domain = Proton_Sdk_ErrorDomain.api + $0.context = error.localizedDescription + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift new file mode 100644 index 00000000..6cc97730 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Main entry point for all SDK functionality. +/// +/// Create a single object of this class and use it to perform downloads, uploads and all other supported operations. +public actor ProtonDriveClient: Sendable { + + private var clientHandle: ObjectHandle? + + private let logger: ProtonDriveSDK.Logger + private var uploadManager: UploadManager! + private var downloadManager: DownloadManager! + + let httpClient: HttpClientProtocol + let accountClient: AccountClientProtocol + + public init( + baseURL: String, + entityCachePath: String? = nil, + secretCachePath: String? = nil, + httpClient: HttpClientProtocol, + accountClient: AccountClientProtocol, + logCallback: @escaping LogCallback + ) async throws { + self.logger = try await Logger(logCallback: logCallback) + + self.httpClient = httpClient + self.accountClient = accountClient + + let clientCreateRequest = Proton_Drive_Sdk_DriveClientCreateRequest.with { + $0.baseURL = baseURL + + $0.httpClientRequestAction = Int64(ObjectHandle(callback: cCompatibleHttpRequest)) + $0.accountClientRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) + + $0.logAction = Int64(ObjectHandle(callback: cCompatibleLogCallback)) + + if let entityCachePath { + $0.entityCachePath = entityCachePath + } + if let secretCachePath { + $0.secretCachePath = secretCachePath + } + } + + // we pass the weak reference as the state because we don't want the interop layer + // to prolong the client object existence + let weakSelf = WeakReference(value: self) + let handle: Proton_Drive_Sdk_DriveClientCreateRequest.CallResultType = try await SDKRequestHandler.sendInteropRequest( + clientCreateRequest, state: weakSelf, includesLongLivedCallback: true, logger: logger + ) + self.clientHandle = ObjectHandle(handle) + + guard let clientHandle else { + throw ProtonDriveSDKError.noHandle + } + logger.trace("client handle: \(clientHandle)", category: .other("ProtonDriveClient")) + + self.uploadManager = UploadManager(clientHandle: clientHandle, logger: logger) + self.downloadManager = DownloadManager(clientHandle: clientHandle, logger: logger) + } + + nonisolated func log(_ logEvent: LogEvent) { + logger.logCallback(logEvent) + } + + public func downloadFile( + revisionUid: SDKRevisionUid, + destinationUrl: URL, + progressCallback: @escaping ProgressCallback + ) async throws { + try await downloadManager.downloadFile( + revisionUid: revisionUid, + destinationUrl: destinationUrl, + progressCallback: progressCallback + ) + } + + public func uploadFile( + parentFolderUid: SDKNodeUid, + name: String, + url: URL, + fileSize: Int64, + modificationDate: Date, + mediaType: String, + thumbnails: [ThumbnailData], + progressCallback: @escaping ProgressCallback + ) async throws -> FileNodeUploadResult { + try await uploadManager.uploadFile( + parentFolderUid: parentFolderUid, + name: name, + fileURL: url, + fileSize: fileSize, + modificationDate: modificationDate, + mediaType: mediaType, + thumbnails: thumbnails, + progressCallback: progressCallback + ) + } + + static func unbox(callbackPointer: Int, releaseBox: () -> Void, weakDriveClient: WeakReference) -> ProtonDriveClient? { + guard let driveClient = weakDriveClient.value else { + releaseBox() + let error = Proton_Sdk_Error.with { + $0.type = "sdk_error" + $0.domain = Proton_Sdk_ErrorDomain.api + $0.context = "account client callback called after the proton client object was deallocated" + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) + return nil + } + return driveClient + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift new file mode 100644 index 00000000..2d02f335 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum ProtonDriveSDKError: String, LocalizedError { + case noHandle + + public var errorDescription: String? { rawValue } +} From f9a533d6d489b0b0f8140de902f21080c7d549e7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 3 Nov 2025 15:38:41 +0100 Subject: [PATCH 279/791] Fix deserialization error on getting available names --- .../Links/NameHashDigestUnavailabilityDto.cs | 19 +++++++++++++++++++ .../Api/Links/NodeNameAvailabilityResponse.cs | 2 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 1 + 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/NameHashDigestUnavailabilityDto.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NameHashDigestUnavailabilityDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NameHashDigestUnavailabilityDto.cs new file mode 100644 index 00000000..d29f3c28 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NameHashDigestUnavailabilityDto.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Files; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class NameHashDigestUnavailabilityDto +{ + [JsonPropertyName("Hash")] + public required string NameHashDigest { get; init; } + + [JsonPropertyName("RevisionID")] + public required RevisionId RevisionId { get; init; } + + [JsonPropertyName("LinkID")] + public required LinkId LinkId { get; set; } + + [JsonPropertyName("ClientUID")] + public required string ClientUid { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityResponse.cs index 16957e7c..a4fa4b3c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeNameAvailabilityResponse.cs @@ -9,5 +9,5 @@ internal sealed class NodeNameAvailabilityResponse : ApiResponse public required IReadOnlyList AvailableNameHashDigests { get; init; } [JsonPropertyName("PendingHashes")] - public required IReadOnlyList UnavailableNameHashDigests { get; init; } + public required IReadOnlyList UnavailableNameHashDigests { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index e7047dd0..638bd9c2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -163,6 +163,7 @@ public async ValueTask GetFileDownloaderAsync(RevisionUid revisi return new FileDownloader(this, revisionUid); } + // FIXME: unit tests, including name collision cases public ValueTask GetAvailableNameAsync(NodeUid parentUid, string name, CancellationToken cancellationToken) { return NodeOperations.GetAvailableNameAsync(this, parentUid, name, cancellationToken); From 33fbff812023352ba50d5a0282fdc34d42a4839a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 4 Nov 2025 06:34:27 +0100 Subject: [PATCH 280/791] Fix thumbnails causing upload to hang --- .../Cryptography/HashingReadStream.cs | 4 +- .../Cryptography/HashingWriteStream.cs | 78 +++++++++++++++++++ .../Proton.Drive.Sdk/FifoFlexibleSemaphore.cs | 40 ++++------ .../Nodes/Download/FileDownloader.cs | 46 ++++++++--- .../Nodes/Download/RevisionReader.cs | 13 ++-- .../Nodes/RevisionOperations.cs | 48 ++++++++++-- .../Nodes/Upload/BlockUploader.cs | 16 +++- .../Nodes/Upload/FileUploader.cs | 50 ++++++++++-- .../Nodes/Upload/RevisionWriter.cs | 16 ++-- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 16 ++-- 10 files changed, 247 insertions(+), 80 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingWriteStream.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs index 749cc9f0..7bbea784 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Cryptography; -public class HashingReadStream(Stream underlyingStream, IncrementalHash hash, bool leaveOpen = false) : Stream +internal sealed class HashingReadStream(Stream underlyingStream, IncrementalHash hash, bool leaveOpen = false) : Stream { private readonly Stream _underlyingStream = underlyingStream ?? throw new ArgumentNullException(nameof(underlyingStream)); private readonly IncrementalHash _hash = hash; @@ -58,8 +58,6 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation public override ValueTask DisposeAsync() #pragma warning restore CA2215 // Dispose methods should call base class dispose { - GC.SuppressFinalize(this); - if (leaveOpen) { return ValueTask.CompletedTask; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingWriteStream.cs b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingWriteStream.cs new file mode 100644 index 00000000..633e4cf0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingWriteStream.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using CommunityToolkit.HighPerformance; + +namespace Proton.Drive.Sdk.Cryptography; + +internal sealed class HashingWriteStream(Stream underlyingStream, IncrementalHash hash, bool leaveOpen = false) : Stream +{ + private readonly Stream _underlyingStream = underlyingStream ?? throw new ArgumentNullException(nameof(underlyingStream)); + private readonly IncrementalHash _hash = hash; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => _underlyingStream.CanWrite; + public override long Length => _underlyingStream.Length; + public override long Position { get => _underlyingStream.Position; set => throw new NotSupportedException(); } + + public override void Write(ReadOnlySpan buffer) + { + _underlyingStream.Write(buffer); + _hash.AppendData(buffer); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _underlyingStream.Write(buffer, offset, count); + _hash.AppendData(buffer); + } + + public override void WriteByte(byte value) + { + _underlyingStream.Write(value); + _hash.AppendData(new ReadOnlySpan(ref value)); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { +#pragma warning disable CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' + await _underlyingStream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); +#pragma warning restore CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' + _hash.AppendData(buffer); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await _underlyingStream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + _hash.AppendData(buffer.Span); + } + + public override void Flush() => _underlyingStream.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _underlyingStream.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + +#pragma warning disable CA2215 // Dispose methods should call base class dispose + public override ValueTask DisposeAsync() +#pragma warning restore CA2215 // Dispose methods should call base class dispose + { + if (leaveOpen) + { + return ValueTask.CompletedTask; + } + + return _underlyingStream.DisposeAsync(); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (leaveOpen || !disposing) + { + return; + } + + _underlyingStream.Dispose(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs index 28b6090b..6c4d44f5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs @@ -1,39 +1,33 @@ -using Microsoft.Extensions.Logging; - -namespace Proton.Drive.Sdk; +namespace Proton.Drive.Sdk; /// /// Acts as a semaphore that operates in a first in / first out manner, can increment and decrement its count by more than 1, and can be entered as long as the count before the increment is less than the maximum. /// -internal sealed partial class FifoFlexibleSemaphore +internal sealed class FifoFlexibleSemaphore { - private readonly ILogger _logger; private readonly int _maximumCount; private readonly Queue<(int Increment, TaskCompletionSource TaskCompletionSource)> _waitingQueue = new(); - private int _currentCount; - - public FifoFlexibleSemaphore(int maximumCount, ILogger logger) + public FifoFlexibleSemaphore(int maximumCount) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCount); _maximumCount = maximumCount; - _currentCount = 0; - _logger = logger; + CurrentCount = 0; } + public int CurrentCount { get; private set; } + public ValueTask EnterAsync(int increment, CancellationToken cancellationToken = default) { ArgumentOutOfRangeException.ThrowIfNegative(increment); - LogEnter(increment); - TaskCompletionSource tcs; lock (_waitingQueue) { - if (_currentCount < _maximumCount) + if (CurrentCount < _maximumCount) { - _currentCount += increment; + CurrentCount += increment; return ValueTask.CompletedTask; } @@ -64,32 +58,24 @@ public void Release(int decrement) { ArgumentOutOfRangeException.ThrowIfNegative(decrement); - LogRelease(decrement); - lock (_waitingQueue) { - _currentCount -= decrement; + CurrentCount -= decrement; - if (_currentCount < 0) + if (CurrentCount < 0) { - _currentCount = 0; + CurrentCount = 0; } - while (_currentCount < _maximumCount && _waitingQueue.TryDequeue(out var queuedEntry)) + while (CurrentCount < _maximumCount && _waitingQueue.TryDequeue(out var queuedEntry)) { var (increment, taskCompletionSource) = queuedEntry; if (taskCompletionSource.TrySetResult()) { - _currentCount += increment; + CurrentCount += increment; } } } } - - [LoggerMessage(Level = LogLevel.Trace, Message = "Enter with increment of {Increment}")] - private partial void LogEnter(int increment); - - [LoggerMessage(Level = LogLevel.Trace, Message = "Release with decrement of {Decrement}")] - private partial void LogRelease(int decrement); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 2b6bcff1..b0757eab 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -1,12 +1,14 @@ -namespace Proton.Drive.Sdk.Nodes.Download; +using Microsoft.Extensions.Logging; -public sealed class FileDownloader : IDisposable +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed partial class FileDownloader : IDisposable { private readonly ProtonDriveClient _client; private readonly RevisionUid _revisionUid; private volatile int _remainingNumberOfBlocksToList; - internal FileDownloader(ProtonDriveClient client, RevisionUid revisionUid) + private FileDownloader(ProtonDriveClient client, RevisionUid revisionUid) { _client = client; _revisionUid = revisionUid; @@ -28,15 +30,27 @@ public DownloadController DownloadToFile(string filePath, Action onP public void Dispose() { - if (_remainingNumberOfBlocksToList <= 0) - { - return; - } + ReleaseRemainingBlockListing(); + } - _client.BlockListingSemaphore.Release(_remainingNumberOfBlocksToList); - _remainingNumberOfBlocksToList = 0; + internal static async ValueTask CreateAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) + { + LogEnteringBlockListingSemaphore(client.Logger, revisionUid, 1); + await client.BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); + LogEnteredBlockListingSemaphore(client.Logger, revisionUid, 1); + + return new FileDownloader(client, revisionUid); } + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter block listing semaphore for revision {RevisionUid} with {Increment}")] + private static partial void LogEnteringBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int increment); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Entered block listing semaphore for revision {RevisionUid} with {Increment}")] + private static partial void LogEnteredBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int increment); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Decrement} from block listing semaphore for revision {RevisionUid}")] + private static partial void LogReleasedBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int decrement); + private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { using var revisionReader = await RevisionOperations.OpenForReadingAsync(_client, _revisionUid, ReleaseBlockListing, cancellationToken) @@ -62,5 +76,19 @@ private void ReleaseBlockListing(int numberOfBlockListings) var amountToRelease = Math.Max(newRemainingNumberOfBlocks >= 0 ? numberOfBlockListings : newRemainingNumberOfBlocks + numberOfBlockListings, 0); _client.BlockListingSemaphore.Release(amountToRelease); + LogReleasedBlockListingSemaphore(_client.Logger, _revisionUid, amountToRelease); + } + + private void ReleaseRemainingBlockListing() + { + if (_remainingNumberOfBlocksToList <= 0) + { + return; + } + + _client.BlockListingSemaphore.Release(_remainingNumberOfBlocksToList); + LogReleasedBlockListingSemaphore(_client.Logger, _revisionUid, _remainingNumberOfBlocksToList); + + _remainingNumberOfBlocksToList = 0; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 01392204..4297e266 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -17,9 +17,10 @@ internal sealed class RevisionReader : IDisposable private readonly PgpSessionKey _contentKey; private readonly BlockListingRevisionDto _revisionDto; private readonly Action _releaseBlockListingAction; + private readonly Action _releaseFileSemaphoreAction; private readonly int _blockPageSize; - private bool _semaphoreReleased; + private bool _fileSemaphoreReleased; private long _totalProgress; @@ -30,6 +31,7 @@ internal RevisionReader( PgpSessionKey contentKey, BlockListingRevisionDto revisionDto, Action releaseBlockListingAction, + Action releaseFileSemaphoreAction, int blockPageSize = DefaultBlockPageSize) { _client = client; @@ -39,6 +41,7 @@ internal RevisionReader( _contentKey = contentKey; _revisionDto = revisionDto; _releaseBlockListingAction = releaseBlockListingAction; + _releaseFileSemaphoreAction = releaseFileSemaphoreAction; _blockPageSize = blockPageSize; } @@ -81,8 +84,8 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt } finally { - _client.BlockDownloader.FileSemaphore.Release(); - _semaphoreReleased = true; + _releaseFileSemaphoreAction.Invoke(); + _fileSemaphoreReleased = true; } while (downloadTasks.Count > 0) @@ -122,9 +125,9 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt public void Dispose() { - if (!_semaphoreReleased) + if (!_fileSemaphoreReleased) { - _client.BlockDownloader.FileSemaphore.Release(); + _releaseFileSemaphoreAction.Invoke(); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index ca21ec47..99a6060b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -1,11 +1,12 @@ -using Proton.Drive.Sdk.Api.Files; +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; -internal static class RevisionOperations +internal static partial class RevisionOperations { public static async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> CreateDraftAsync( ProtonDriveClient client, @@ -53,7 +54,9 @@ public static async ValueTask OpenForWritingAsync( var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + LogEnteringFileUploadSemaphore(client.Logger, revisionUid); await client.BlockUploader.FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + LogEnteredFileUploadSemaphore(client.Logger, revisionUid); return new RevisionWriter( client, @@ -63,7 +66,11 @@ public static async ValueTask OpenForWritingAsync( signingKey, membershipAddress, releaseBlocksAction, - () => client.BlockUploader.FileSemaphore.Release(), + () => + { + var previousCount = client.BlockUploader.FileSemaphore.Release(); + LogReleasedFileUploadSemaphore(client.Logger, revisionUid, previousCount); + }, client.TargetBlockSize, client.MaxBlockSize); } @@ -84,11 +91,42 @@ internal static async ValueTask OpenForReadingAsync( revisionId, RevisionReader.MinBlockIndex, RevisionReader.DefaultBlockPageSize, - false, + withoutBlockUrls: false, cancellationToken).ConfigureAwait(false); + LogEnteringFileDownloadSemaphore(client.Logger, revisionUid); await client.BlockDownloader.FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + LogEnteredFileDownloadSemaphore(client.Logger, revisionUid); - return new RevisionReader(client, revisionUid, fileSecrets.Key, fileSecrets.ContentKey, revisionResponse.Revision, releaseBlockListingAction); + return new RevisionReader( + client, + revisionUid, + fileSecrets.Key, + fileSecrets.ContentKey, + revisionResponse.Revision, + releaseBlockListingAction, + () => + { + var previousCount = client.BlockDownloader.FileSemaphore.Release(); + LogReleasedFileDownloadSemaphore(client.Logger, revisionUid, previousCount); + }); } + + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter file upload semaphore for revision {RevisionUid}")] + private static partial void LogEnteringFileUploadSemaphore(ILogger logger, RevisionUid revisionUid); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Entered file upload semaphore for revision {RevisionUid}")] + private static partial void LogEnteredFileUploadSemaphore(ILogger logger, RevisionUid revisionUid); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Releasing file upload semaphore for revision {RevisionUid}, previous count = {PreviousCount}")] + private static partial void LogReleasedFileUploadSemaphore(ILogger logger, RevisionUid revisionUid, int previousCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter file download semaphore for revision {RevisionUid}")] + private static partial void LogEnteringFileDownloadSemaphore(ILogger logger, RevisionUid revisionUid); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Entered file download semaphore for revision {RevisionUid}")] + private static partial void LogEnteredFileDownloadSemaphore(ILogger logger, RevisionUid revisionUid); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Releasing file download semaphore for revision {RevisionUid}, previous count = {PreviousCount}")] + private static partial void LogReleasedFileDownloadSemaphore(ILogger logger, RevisionUid revisionUid, int previousCount); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index d8fffbb7..37375b12 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -6,6 +6,7 @@ using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk; using Proton.Sdk.Addresses; @@ -61,9 +62,9 @@ public async Task UploadContentAsync( await using (plainDataStream.ConfigureAwait(false)) { - using var sha256 = SHA256.Create(); + using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - var hashingStream = new CryptoStream(dataPacketStream, sha256, CryptoStreamMode.Write, leaveOpen: true); + var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); await using (hashingStream.ConfigureAwait(false)) { @@ -80,7 +81,7 @@ public async Task UploadContentAsync( } } - sha256Digest = sha256.Hash ?? []; + sha256Digest = sha256.GetCurrentHash(); } // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, @@ -198,7 +199,14 @@ public async Task UploadThumbnailAsync( } finally { - _client.RevisionCreationSemaphore.Release(1); + try + { + _client.RevisionCreationSemaphore.Release(1); + } + finally + { + BlockSemaphore.Release(1); + } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index bbc908df..0ba40425 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -1,15 +1,16 @@ +using Microsoft.Extensions.Logging; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Upload; -public sealed class FileUploader : IDisposable +public sealed partial class FileUploader : IDisposable { private readonly ProtonDriveClient _client; private readonly IFileDraftProvider _fileDraftProvider; private readonly DateTimeOffset? _lastModificationTime; private volatile int _remainingNumberOfBlocks; - internal FileUploader( + private FileUploader( ProtonDriveClient client, IFileDraftProvider fileDraftProvider, long size, @@ -49,15 +50,34 @@ public UploadController UploadFromFile( public void Dispose() { - if (_remainingNumberOfBlocks <= 0) - { - return; - } + ReleaseRemainingBlocks(); + } - _client.RevisionCreationSemaphore.Release(_remainingNumberOfBlocks); - _remainingNumberOfBlocks = 0; + internal static async ValueTask CreateAsync( + ProtonDriveClient client, + IFileDraftProvider fileDraftProvider, + long size, + DateTime? lastModificationTime, + CancellationToken cancellationToken) + { + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); + + LogEnteredRevisionCreationSemaphore(client.Logger, expectedNumberOfBlocks); + await client.RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); + LogEnteredRevisionCreationSemaphore(client.Logger, expectedNumberOfBlocks); + + return new FileUploader(client, fileDraftProvider, size, lastModificationTime, expectedNumberOfBlocks); } + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter revision creation semaphore with {Increment}")] + private static partial void LogEnteringRevisionCreationSemaphore(ILogger logger, int increment); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Entered revision creation semaphore with {Increment}")] + private static partial void LogEnteredRevisionCreationSemaphore(ILogger logger, int increment); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Decrement} from revision creation semaphore")] + private static partial void LogReleasedRevisionCreationSemaphore(ILogger logger, int decrement); + private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, @@ -142,5 +162,19 @@ private void ReleaseBlocks(int numberOfBlocks) var amountToRelease = Math.Max(newRemainingNumberOfBlocks >= 0 ? numberOfBlocks : newRemainingNumberOfBlocks + numberOfBlocks, 0); _client.RevisionCreationSemaphore.Release(amountToRelease); + LogReleasedRevisionCreationSemaphore(_client.Logger, amountToRelease); + } + + private void ReleaseRemainingBlocks() + { + if (_remainingNumberOfBlocks <= 0) + { + return; + } + + _client.RevisionCreationSemaphore.Release(_remainingNumberOfBlocks); + LogReleasedRevisionCreationSemaphore(_client.Logger, _remainingNumberOfBlocks); + + _remainingNumberOfBlocks = 0; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index a55e6cdd..b1efbc3d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -23,12 +23,12 @@ internal sealed class RevisionWriter : IDisposable private readonly PgpPrivateKey _signingKey; private readonly Address _membershipAddress; private readonly Action _releaseBlocksAction; - private readonly Action _releaseFileAction; + private readonly Action _releaseFileSemaphoreAction; private readonly int _targetBlockSize; private readonly int _maxBlockSize; - private bool _semaphoreReleased; + private bool _fileReleased; internal RevisionWriter( ProtonDriveClient client, @@ -38,7 +38,7 @@ internal RevisionWriter( PgpPrivateKey signingKey, Address membershipAddress, Action releaseBlocksAction, - Action releaseFileAction, + Action releaseFileSemaphoreAction, int targetBlockSize = DefaultBlockSize, int maxBlockSize = DefaultBlockSize) { @@ -49,7 +49,7 @@ internal RevisionWriter( _signingKey = signingKey; _membershipAddress = membershipAddress; _releaseBlocksAction = releaseBlocksAction; - _releaseFileAction = releaseFileAction; + _releaseFileSemaphoreAction = releaseFileSemaphoreAction; _targetBlockSize = targetBlockSize; _maxBlockSize = maxBlockSize; } @@ -165,8 +165,8 @@ public async ValueTask WriteAsync( } finally { - _releaseFileAction.Invoke(); - _semaphoreReleased = true; + _releaseFileSemaphoreAction.Invoke(); + _fileReleased = true; } while (uploadTasks.Count > 0) @@ -207,9 +207,9 @@ public async ValueTask WriteAsync( public void Dispose() { - if (!_semaphoreReleased) + if (!_fileReleased) { - _client.BlockUploader.FileSemaphore.Release(); + _releaseFileSemaphoreAction.Invoke(); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 638bd9c2..d0c4ca32 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -72,8 +72,8 @@ internal ProtonDriveClient( var maxDegreeOfBlockProcessingParallelism = maxDegreeOfBlockTransferParallelism + Math.Min(Math.Max(maxDegreeOfBlockTransferParallelism / 2, 2), 4); - RevisionCreationSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger("Revision creation semaphore")); - BlockListingSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism, loggerFactory.CreateLogger("Block listing semaphore")); + RevisionCreationSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism); + BlockListingSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism); BlockUploader = new BlockUploader(this, maxDegreeOfBlockTransferParallelism); BlockDownloader = new BlockDownloader(this, maxDegreeOfBlockTransferParallelism); @@ -158,9 +158,7 @@ public async ValueTask GetFileRevisionUploaderAsync( public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) { - await BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); - - return new FileDownloader(this, revisionUid); + return await FileDownloader.CreateAsync(this, revisionUid, cancellationToken).ConfigureAwait(false); } // FIXME: unit tests, including name collision cases @@ -171,7 +169,7 @@ public ValueTask GetAvailableNameAsync(NodeUid parentUid, string name, C public async ValueTask MoveNodesAsync(IEnumerable uids, NodeUid newParentFolderUid, CancellationToken cancellationToken) { - // FIXME: finalize the implementation that uses the batch move endpoint, and use it instead of this naïve code + // FIXME: finalize the implementation that uses the batch move endpoint, and use it instead of this naïve code foreach (var uid in uids) { await NodeOperations.MoveSingleAsync(this, uid, newParentFolderUid, newName: null, cancellationToken).ConfigureAwait(false); @@ -214,10 +212,6 @@ private async ValueTask GetFileUploaderAsync( DateTime? lastModificationTime, CancellationToken cancellationToken) { - var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); - - await RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); - - return new FileUploader(this, fileDraftProvider, size, lastModificationTime, expectedNumberOfBlocks); + return await FileUploader.CreateAsync(this, fileDraftProvider, size, lastModificationTime, cancellationToken).ConfigureAwait(false); } } From 9b88068c72690b534e782a414b717aa20f2fc68c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 5 Nov 2025 10:04:48 +0000 Subject: [PATCH 281/791] Fix progress callback doesn't report issue --- .../FileOperations/DownloadManager.swift | 8 +++- .../FileOperations/UploadManager.swift | 17 +++---- .../Plumbing/ProgressCallbackWrapper.swift | 46 +++++++++++++++++++ .../Sources/Plumbing/PublicTypes.swift | 39 ++++++++-------- 4 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift index 953b6a7d..a24751ab 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift @@ -41,7 +41,13 @@ public actor DownloadManager { $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) } - let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send(downloaderRequest, logger: logger) + let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + downloaderRequest, + state: WeakReference(value: callbackState), + includesLongLivedCallback: true, + logger: logger + ) assert(downloadControllerHandle != 0) defer { freeDownloadController(downloadControllerHandle) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift index fba0cc55..88a4141b 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift @@ -77,8 +77,6 @@ public actor UploadManager { freeFileUploader(uploaderHandle) } - let progressAction: Int64 = 0 - let uploaderRequest = Proton_Drive_Sdk_UploadFromFileRequest.with { $0.uploaderHandle = Int64(uploaderHandle) $0.filePath = fileURL.path(percentEncoded: false) @@ -96,7 +94,13 @@ public actor UploadManager { } } - let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send(uploaderRequest, logger: logger) + let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + uploaderRequest, + state: WeakReference(value: callbackState), + includesLongLivedCallback: true, + logger: logger + ) assert(uploadControllerHandle != 0) let uploadedNode = try await awaitUploadCompletion(uploadControllerHandle) @@ -167,10 +171,3 @@ public actor UploadManager { } } } - -let cProgressCallback: CCallback = { state, byteArray in - guard let state else { - return - } -} - diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift new file mode 100644 index 00000000..fd9ae626 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -0,0 +1,46 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Drive. +// +// Proton Drive is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Drive is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Drive. If not, see https://www.gnu.org/licenses/. + +import Foundation +import CProtonDriveSDK + +final class ProgressCallbackWrapper { + let callback: ProgressCallback + + init(callback: @escaping ProgressCallback) { + self.callback = callback + } +} + +let cProgressCallback: CResponseCallback = { (sdkHandle: ObjectHandle, byteArray: ByteArray) in + typealias BoxType = BoxedContinuationWithState> + let progressUpdate = Proton_Drive_Sdk_ProgressUpdate(byteArray: byteArray) + let progress = FileOperationProgress( + bytesCompleted: progressUpdate.bytesCompleted, + bytesTotal: progressUpdate.bytesInTotal + ) + + guard let sdkPointer = UnsafeRawPointer(bitPattern: UInt(sdkHandle)) else { return } + let statePointer = Unmanaged.fromOpaque(sdkPointer) + let weakWrapper: WeakReference = statePointer.takeUnretainedValue().state + weakWrapper.value?.callback(progress) + + // TODO: also release pointer when task is cancelled + if progress.isCompleted { + statePointer.release() + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 7f340f89..a145236b 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -158,21 +158,24 @@ public struct FileNodeUploadResult: Sendable { } /// Callback for progress updates -public typealias ProgressCallback = @Sendable (Progress) -> Void - -/////// Progress information for upload/download operations -//// public struct FileOperationProgress { -//// public let bytesCompleted: Int64 -//// public let bytesTotal: Int64 -//// -//// /// Progress percentage (0.0 to 1.0) -//// public var percentage: Double { -//// guard bytesTotal > 0 else { return 0.0 } -//// return Double(bytesCompleted) / Double(bytesTotal) -//// } -//// -//// public init(bytesCompleted: Int64, bytesTotal: Int64) { -//// self.bytesCompleted = bytesCompleted -//// self.bytesTotal = bytesTotal -//// } -//// } +public typealias ProgressCallback = @Sendable (FileOperationProgress) -> Void + +/// Progress information for upload/download operations +public struct FileOperationProgress { + public let bytesCompleted: Int64 + public let bytesTotal: Int64 + + /// Progress percentage (0.0 to 1.0) + public var fractionCompleted: Double { + guard bytesTotal > 0 else { return 0.0 } + let value = Double(bytesCompleted) / Double(bytesTotal) + return min(1.0, value) + } + + public var isCompleted: Bool { fractionCompleted == 1.0 } + + public init(bytesCompleted: Int64, bytesTotal: Int64) { + self.bytesCompleted = bytesCompleted + self.bytesTotal = bytesTotal + } +} From 70bf39a9474f511bf20039086f36c2e6d0ee1914 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 5 Nov 2025 14:05:00 +0100 Subject: [PATCH 282/791] Fix missing SDK version header when injecting HTTP client without interop --- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 3 +- .../InteropHttpClientFactory.cs | 24 +---------- .../Http/SdkHttpClientFactoryDecorator.cs | 40 +++++++++++++++++++ 3 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Http/SdkHttpClientFactoryDecorator.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index d0c4ca32..a208e76f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -9,6 +9,7 @@ using Proton.Drive.Sdk.Volumes; using Proton.Sdk; using Proton.Sdk.Caching; +using Proton.Sdk.Http; namespace Proton.Drive.Sdk; @@ -42,7 +43,7 @@ public ProtonDriveClient( ILoggerFactory loggerFactory, string? uid = null) : this( - httpClientFactory.CreateClient(), + new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClient(), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), loggerFactory, diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index b9cdea3d..9b364822 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Reflection; using Google.Protobuf; using Proton.Sdk.CExports.Tasks; using Proton.Sdk.Http; @@ -9,8 +8,6 @@ namespace Proton.Sdk.CExports; internal sealed class InteropHttpClientFactory : IHttpClientFactory { private readonly string _baseUrl; - private readonly string _sdkVersion; - private readonly string _sdkTechnicalStack; public InteropHttpClientFactory( nint bindingsHandle, @@ -21,18 +18,6 @@ public InteropHttpClientFactory( _baseUrl = baseUrl; BindingsHandle = bindingsHandle; SendHttpRequestAction = sendHttpRequestAction; - - var executingAssembly = Assembly.GetExecutingAssembly(); - var versionAttribute = executingAssembly.GetCustomAttribute(); - _sdkVersion = versionAttribute?.InformationalVersion - ?? executingAssembly.GetName().Version?.ToString(fieldCount: 3) - ?? "0.0.0"; - - var bindingsSuffix = bindingsLanguage is not null - ? "-" + bindingsLanguage.ToLowerInvariant() - : string.Empty; - - _sdkTechnicalStack = "dotnet" + bindingsSuffix; } private nint BindingsHandle { get; } @@ -45,14 +30,7 @@ public HttpClient CreateClient(string name) InnerHandler = new InteropHttpMessageHandler(this), }; - return new HttpClient(httpMessageHandler) - { - BaseAddress = new Uri(_baseUrl), - DefaultRequestHeaders = - { - { "x-pm-drive-sdk-version", $"{_sdkTechnicalStack}@{_sdkVersion}" }, - }, - }; + return new HttpClient(httpMessageHandler) { BaseAddress = new Uri(_baseUrl) }; } private sealed class InteropHttpMessageHandler(InteropHttpClientFactory owner) : HttpMessageHandler diff --git a/cs/sdk/src/Proton.Sdk/Http/SdkHttpClientFactoryDecorator.cs b/cs/sdk/src/Proton.Sdk/Http/SdkHttpClientFactoryDecorator.cs new file mode 100644 index 00000000..fb975061 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/SdkHttpClientFactoryDecorator.cs @@ -0,0 +1,40 @@ +using System.Reflection; + +namespace Proton.Sdk.Http; + +internal sealed class SdkHttpClientFactoryDecorator : IHttpClientFactory +{ + private static readonly string SdkVersion = GetSdkVersion(); + + private readonly IHttpClientFactory _instanceToDecorate; + private readonly string _sdkTechnicalStack; + + public SdkHttpClientFactoryDecorator(IHttpClientFactory instanceToDecorate, string? bindingsLanguage = null) + { + _instanceToDecorate = instanceToDecorate; + + var bindingsSuffix = bindingsLanguage is not null + ? "-" + bindingsLanguage.ToLowerInvariant() + : string.Empty; + + _sdkTechnicalStack = "dotnet" + bindingsSuffix; + } + + public HttpClient CreateClient(string name) + { + var client = _instanceToDecorate.CreateClient(name); + + client.DefaultRequestHeaders.Add("x-pm-drive-sdk-version", $"{_sdkTechnicalStack}@{SdkVersion}"); + + return client; + } + + private static string GetSdkVersion() + { + var executingAssembly = Assembly.GetExecutingAssembly(); + var versionAttribute = executingAssembly.GetCustomAttribute(); + return versionAttribute?.InformationalVersion + ?? executingAssembly.GetName().Version?.ToString(fieldCount: 3) + ?? "0.0.0"; + } +} From 43d0ffb3c4a3b7a4ef1dac7d997e52d269f697ec Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 5 Nov 2025 14:40:09 +0100 Subject: [PATCH 283/791] Fix possibility of missing domain and type on interop errors --- cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs | 7 +------ cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs | 4 ++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs index 64d02461..5543d5c1 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs @@ -6,15 +6,10 @@ public static Error ToErrorMessage(this Exception exception, Action Date: Thu, 6 Nov 2025 10:29:47 +0100 Subject: [PATCH 284/791] Feat/parse error swift interop --- .../FileOperations/UploadManager.swift | 2 +- .../Sources/Plumbing/InternalTypes.swift | 17 - .../Sources/Plumbing/Message+Packaging.swift | 4 +- .../Sources/Plumbing/SDKRequestHandler.swift | 10 +- .../ProtonDriveClient/ProtonDriveClient.swift | 11 +- .../Sources/ProtonDriveSDKError.swift | 357 +++++++++++++++++- 6 files changed, 366 insertions(+), 35 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift index 88a4141b..a616d172 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift @@ -116,7 +116,7 @@ public actor UploadManager { let uploadResult: Proton_Drive_Sdk_UploadResult = try await SDKRequestHandler.send(awaitUploadCompletionRequest, logger: logger) guard let result = FileNodeUploadResult(interopUploadResult: uploadResult) else { - throw "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)" + throw ProtonDriveSDKError(interopError: .wrongResult(message: "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)")) } return result } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index 1a93915c..832791af 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -48,23 +48,6 @@ extension Data { } } -// MARK: - Error extensions - -extension String: @retroactive Error {} - -extension Proton_Sdk_Error: Error { - - var localizedDescription: String { - return "\(self.message)" - } - - var nsError: NSError { - return NSError(domain: "ProtonDriveSDK", code: Int(primaryCode), userInfo: [ - NSLocalizedDescriptionKey: message - ]) - } -} - // MARK: - ByteArray extensions extension ByteArray: @unchecked @retroactive Sendable {} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index ae5ec4fa..7ea81e85 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -100,7 +100,7 @@ extension Message { default: assertionFailure("Unknown request") - throw "Unknown request type: \(self)" + throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown request type: \(self)")) } } @@ -137,7 +137,7 @@ extension Message { } default: assertionFailure("Unknown response") - throw "Unknown response type: \(self)" + throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown response type: \(self)")) } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 12a483e9..01bfc0b3 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -81,7 +81,7 @@ let sdkResponseCallbackWithState: CResponseCallback = { (sdkHandle: ObjectHandle switch response.result { case nil: // empty response. Might be expected, might be not expected guard let voidBox = box as? Resumable else { - throw "Unexpected empty response received" + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected empty response received")) } voidBox.resume() @@ -93,21 +93,21 @@ let sdkResponseCallbackWithState: CResponseCallback = { (sdkHandle: ObjectHandle case let intBox as Resumable: intBox.resume(returning: Int(unpackedValue)) default: - throw "Unexpected SDK call response type: Google_Protobuf_Int64Value" + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Google_Protobuf_Int64Value")) } case .value(let value) where value.isA(Proton_Drive_Sdk_UploadResult.self): let unpackedValue = try Proton_Drive_Sdk_UploadResult(unpackingAny: value) guard let uploadResultBox = box as? Resumable else { - throw "Unexpected SDK call response type: Proton_Drive_Sdk_UploadResult" + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Proton_Drive_Sdk_UploadResult")) } uploadResultBox.resume(returning: unpackedValue) case .value: // unknown value type - throw "Unknown SDK call response value type" + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unknown SDK call response value type")) case .error(let error): - throw error + throw ProtonDriveSDKError(protoError: error) } } catch { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 6cc97730..27621b56 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -5,11 +5,12 @@ import Foundation /// Create a single object of this class and use it to perform downloads, uploads and all other supported operations. public actor ProtonDriveClient: Sendable { - private var clientHandle: ObjectHandle? - - private let logger: ProtonDriveSDK.Logger + private var clientHandle: ObjectHandle! + private var uploadManager: UploadManager! private var downloadManager: DownloadManager! + + private let logger: ProtonDriveSDK.Logger let httpClient: HttpClientProtocol let accountClient: AccountClientProtocol @@ -50,10 +51,6 @@ public actor ProtonDriveClient: Sendable { clientCreateRequest, state: weakSelf, includesLongLivedCallback: true, logger: logger ) self.clientHandle = ObjectHandle(handle) - - guard let clientHandle else { - throw ProtonDriveSDKError.noHandle - } logger.trace("client handle: \(clientHandle)", category: .other("ProtonDriveClient")) self.uploadManager = UploadManager(clientHandle: clientHandle, logger: logger) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift index 2d02f335..23b379ab 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift @@ -1,7 +1,358 @@ import Foundation -public enum ProtonDriveSDKError: String, LocalizedError { - case noHandle +// MARK: - Swift Types (hiding protobuf implementation) + +public struct ProtonDriveSDKError: LocalizedError, Sendable { + + public enum Domain: Sendable { + // SDK domains + case undefined + case successfulCancellation + case api + case network + case transport + case serialization + case cryptography + case dataIntegrity + + // Interop domains + case interop + + init(interopErrorDomain: Proton_Sdk_ErrorDomain) { + switch interopErrorDomain { + case .undefined: self = .undefined + case .successfulCancellation: self = .successfulCancellation + case .api: self = .api + case .network: self = .network + case .transport: self = .transport + case .serialization: self = .serialization + case .cryptography: self = .cryptography + case .dataIntegrity: self = .dataIntegrity + case .UNRECOGNIZED(let int): + assertionFailure("Received unexpected error domain value \(int)") + self = .undefined + } + } + } + + public enum InteropErrorTypes: Sendable { + case wrongProto(message: String) + case wrongSDKResponse(message: String) + case wrongResult(message: String) + + var typeName: String { + switch self { + case .wrongProto: return "WrongProtoMessageType" + case .wrongSDKResponse: return "WrongSDKResponseType" + case .wrongResult: return "WrongSDKRequestResult" + } + } + + var message: String { + switch self { + case .wrongProto(let message): return message + case .wrongSDKResponse(let message): return message + case .wrongResult(let message): return message + } + } + } + + // Helper to break type recursion + private final class InnerErrorBox: Sendable { + let innerError: ProtonDriveSDKError + init(protoError: Proton_Sdk_Error) { + self.innerError = ProtonDriveSDKError(protoError: protoError) + } + } + + public var errorDescription: String? { message } + + public let type: String + public let message: String + public let domain: Domain + public let primaryCode: Int? + public let secondaryCode: Int? + public let context: String? + public var innerError: ProtonDriveSDKError? { innerErrorBox?.innerError } + + private let innerErrorBox: InnerErrorBox? + + init(protoError: Proton_Sdk_Error) { + if !(protoError.hasMessage && protoError.hasType && protoError.hasDomain) { + assertionFailure("Type, message, and domain are non-optional in Proton_Sdk_Error proto") + } + self.type = protoError.hasType ? protoError.type : "" + self.message = protoError.hasMessage ? protoError.message : "" + self.domain = protoError.hasDomain ? Domain(interopErrorDomain: protoError.domain) : .undefined + self.primaryCode = protoError.hasPrimaryCode ? Int(protoError.primaryCode) : nil + self.secondaryCode = protoError.hasSecondaryCode ? Int(protoError.secondaryCode) : nil + self.context = protoError.hasContext ? protoError.context : nil + self.innerErrorBox = protoError.hasInnerError ? InnerErrorBox(protoError: protoError.innerError) : nil + } + + init(interopError: InteropErrorTypes) { + self.type = interopError.typeName + self.message = interopError.message + self.domain = .interop + self.primaryCode = nil + self.secondaryCode = nil + self.context = nil + self.innerErrorBox = nil + } +} + +// MARK: — Helpers for data integrity errors + +public extension ProtonDriveSDKError { + + var asDataIntegrityError: ProtonDriveSDKDataIntegrityError? { + guard domain == .dataIntegrity, let primaryCode else { return nil } + // taken from dotNET code + let unknownDecryptionErrorPrimaryCode = 0 + let shareMetadataDecryptionErrorPrimaryCode = 1 + let nodeMetadataDecryptionErrorPrimaryCode = 2 + let fileContentsDecryptionErrorPrimaryCode = 3 + let uploadKeyMismatchErrorPrimaryCode = 4 + switch primaryCode { + case shareMetadataDecryptionErrorPrimaryCode: + return .shareMetadata(message: message, context: context) + case nodeMetadataDecryptionErrorPrimaryCode: + return .nodeMetadata(message: message, + part: secondaryCode.flatMap(ProtonDriveSDKDataIntegrityError.NodeMetadataPart.init), + context: context) + case fileContentsDecryptionErrorPrimaryCode: + return .fileContents(message: message, context: context) + case uploadKeyMismatchErrorPrimaryCode: + return .uploadKeyMismatch(message: message, context: context) + case unknownDecryptionErrorPrimaryCode: + return .unknown(message: message, context: context) + default: + return .unknown(message: message, context: context) + } + } + var underlyingDataIntegrityError: ProtonDriveSDKDataIntegrityError? { + guard let dataIntegrityError = asDataIntegrityError else { return innerError?.underlyingDataIntegrityError } + return dataIntegrityError + } +} + +public enum ProtonDriveSDKDataIntegrityError: LocalizedError { + case unknown(message: String, context: String?) + case shareMetadata(message: String, context: String?) + case nodeMetadata(message: String, part: NodeMetadataPart?, context: String?) + case fileContents(message: String, context: String?) + case uploadKeyMismatch(message: String, context: String?) + + public enum NodeMetadataPart: Int { + case key = 0 + case passphrase = 1 + case name = 2 + case extendedAttributes = 3 + case contentKey = 4 + case hashKey = 5 + case blockSignature = 6 + case thumbnail = 7 + } +} + +// MARK: - Helpers for handling the network errors + +public extension ProtonDriveSDKError { + + var asAPINetworkError: ProtonDriveSDKAPINetworkError? { + guard domain == .api, let primaryCode else { return nil } + return ProtonDriveSDKAPINetworkError( + message: message, domainCode: primaryCode, httpCode: secondaryCode, context: context + ) + } + + var asHTTPNetworkError: ProtonDriveSDKHTTPNetworkError? { + guard domain == .transport, + let primaryCode, + let errorType = ProtonDriveSDKHTTPNetworkError.HttpErrorType(rawValue: primaryCode) + else { return nil } + return ProtonDriveSDKHTTPNetworkError( + message: message, errorType: errorType, httpCode: secondaryCode, context: context + ) + } + + var asSocketNetworkError: ProtonDriveSDKSocketNetworkError? { + guard domain == .network, + let primaryCode, + let secondaryCode, + let errorType = ProtonDriveSDKSocketNetworkError.SocketErrorType(rawValue: secondaryCode) + else { return nil } + return ProtonDriveSDKSocketNetworkError( + message: message, errorCode: primaryCode, errorType: errorType, context: context + ) + } + + var underlyingAPINetworkError: ProtonDriveSDKAPINetworkError? { + guard let apiNetworkError = asAPINetworkError else { return innerError?.underlyingAPINetworkError } + return apiNetworkError + } + + var underlyingHTTPNetworkError: ProtonDriveSDKHTTPNetworkError? { + guard let httpNetworkError = asHTTPNetworkError else { return innerError?.underlyingHTTPNetworkError } + return httpNetworkError + } + + var underlyingSocketNetworkError: ProtonDriveSDKSocketNetworkError? { + guard let socketNetworkError = asSocketNetworkError else { return innerError?.underlyingSocketNetworkError } + return socketNetworkError + } +} + + +public struct ProtonDriveSDKAPINetworkError: LocalizedError, Sendable { + public let message: String + public let domainCode: Int + public let httpCode: Int? + public let context: String? + + public var errorDescription: String? { message } +} + +public struct ProtonDriveSDKHTTPNetworkError: LocalizedError, Sendable { + // the comments and values for the cases were taken from dotNET + public enum HttpErrorType: Int, Sendable { + /// A generic or unknown error occurred. + case unknown = 0 + /// The DNS name resolution failed. + case nameResolutionError = 1 + /// A transport-level failure occurred while connecting to the remote endpoint. + case connectionError = 2 + /// An error occurred during the TLS handshake. + case secureConnectionError = 3 + /// An HTTP/2 or HTTP/3 protocol error occurred. + case httpProtocolError = 4 + /// Extended CONNECT for WebSockets over HTTP/2 is not supported by the peer. + case extendedConnectNotSupported = 5 + /// Cannot negotiate the HTTP version requested. + case versionNegotiationError = 6 + /// The authentication failed. + case userAuthenticationError = 7 + /// An error occurred while establishing a connection to the proxy tunnel. + case proxyTunnelError = 8 + /// An invalid or malformed response has been received. + case invalidResponse = 9 + /// The response ended prematurely. + case responseEnded = 10 + /// The response exceeded a pre-configured limit such as "System.Net.Http.HttpClient.MaxResponseContentBufferSize" or "System.Net.Http.HttpClientHandler.MaxResponseHeadersLength". + case configurationLimitExceeded = 11 + } + + public let message: String + public let errorType: HttpErrorType + public let httpCode: Int? + public let context: String? + + public var errorDescription: String? { message } +} + +public struct ProtonDriveSDKSocketNetworkError: LocalizedError, Sendable { + // the comments and values for the cases were taken from dotNET + public enum SocketErrorType: Int, Sendable { + /// An unspecified Socket error has occurred. + case socketError = -1 + /// The Socket operation succeeded. + case success = 0 + /// The overlapped operation was aborted due to the closure of the Socket. + case operationAborted = 995 + /// The application has initiated an overlapped operation that cannot be completed immediately. + case ioPending = 997 + /// A blocking Socket call was canceled. + case interrupted = 10004 + /// An attempt was made to access a Socket in a way that is forbidden by its access permissions. + case accessDenied = 10013 + /// An invalid pointer address was detected by the underlying socket provider. + case fault = 10014 + /// An invalid argument was supplied to a Socket member. + case invalidArgument = 10022 + /// There are too many open sockets in the underlying socket provider. + case tooManyOpenSockets = 10024 + /// An operation on a nonblocking socket cannot be completed immediately. + case wouldBlock = 10035 + /// A blocking operation is in progress. + case inProgress = 10036 + /// The nonblocking Socket already has an operation in progress. + case alreadyInProgress = 10037 + /// A Socket operation was attempted on a non-socket. + case notSocket = 10038 + /// A required address was omitted from an operation on a Socket. + case destinationAddressRequired = 10039 + /// The datagram is too long. + case messageSize = 10040 + /// The protocol type is incorrect for this Socket. + case protocolType = 10041 + /// An unknown, invalid, or unsupported option or level was used with a Socket. + case protocolOption = 10042 + /// The protocol is not implemented or has not been configured. + case protocolNotSupported = 10043 + /// The support for the specified socket type does not exist in this address family. + case socketNotSupported = 10044 + /// The address family is not supported by the protocol family. + case operationNotSupported = 10045 + /// The protocol family is not implemented or has not been configured. + case protocolFamilyNotSupported = 10046 + /// The address family specified is not supported. This error is returned if the IPv6 address family was specified and the IPv6 stack is not installed on the local machine. This error is returned if the IPv4 address family was specified and the IPv4 stack is not installed on the local machine. + case addressFamilyNotSupported = 10047 + /// Only one use of an address is normally permitted. + case addressAlreadyInUse = 10048 + /// The selected IP address is not valid in this context. + case addressNotAvailable = 10049 + /// The network is not available. + case networkDown = 10050 + /// No route to the remote host exists. + case networkUnreachable = 10051 + /// The application tried to set KeepAlive on a connection that has already timed out. + case networkReset = 10052 + /// The connection was aborted by .NET or the underlying socket provider. + case connectionAborted = 10053 + /// The connection was reset by the remote peer. + case connectionReset = 10054 + /// No free buffer space is available for a Socket operation. + case noBufferSpaceAvailable = 10055 + /// The Socket is already connected. + case isConnected = 10056 + /// The application tried to send or receive data, and the Socket is not connected. + case notConnected = 10057 + /// A request to send or receive data was disallowed because the Socket has already been closed. + case shutdown = 10058 + /// The connection attempt timed out, or the connected host has failed to respond. + case timedOut = 10060 + /// The remote host is actively refusing a connection. + case connectionRefused = 10061 + /// The operation failed because the remote host is down. + case hostDown = 10064 + /// There is no network route to the specified host. + case hostUnreachable = 10065 + /// Too many processes are using the underlying socket provider. + case processLimit = 10067 + /// The network subsystem is unavailable. + case systemNotReady = 10091 + /// The version of the underlying socket provider is out of range. + case versionNotSupported = 10092 + /// The underlying socket provider has not been initialized. + case notInitialized = 10093 + /// A graceful shutdown is in progress. + case disconnecting = 10101 + /// The specified class was not found. + case typeNotFound = 10109 + /// No such host is known. The name is not an official host name or alias. + case hostNotFound = 11001 + /// The name of the host could not be resolved. Try again later. + case tryAgain = 11002 + /// The error is unrecoverable or the requested database cannot be located. + case noRecovery = 11003 + /// The requested name or IP address was not found on the name server. + case noData = 11004 + } + + public let message: String + public let errorCode: Int + public let errorType: SocketErrorType + public let context: String? - public var errorDescription: String? { rawValue } + public var errorDescription: String? { message } } From b6b9232ced01ebe0a92d9772f15aee70113e7682 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 6 Nov 2025 10:42:57 +0100 Subject: [PATCH 285/791] Fix logger --- .../Sources/CancellationTokenSource.swift | 10 +++--- .../Sources/Logging/Logger.swift | 35 ++++++++++--------- .../Sources/Logging/LoggerTypes.swift | 34 +++++------------- .../Sources/Plumbing/InternalTypes.swift | 12 ++----- .../Plumbing/ProgressCallbackWrapper.swift | 10 +++--- .../Sources/Plumbing/SDKRequestHandler.swift | 15 ++++---- .../ProtonDriveClient/AccountClient.swift | 14 +++++--- .../HttpClientProtocol.swift | 13 +++---- .../ProtonDriveClient/ProtonDriveClient.swift | 3 +- 9 files changed, 63 insertions(+), 83 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift index a88895d8..52187ac8 100644 --- a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift +++ b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift @@ -8,11 +8,11 @@ actor CancellationTokenSource { let request = Proton_Sdk_CancellationTokenSourceCreateRequest() self.handle = try await SDKRequestHandler.sendInteropRequest(request, logger: logger) - logger?.trace("CancellationTokenSource.init, handle: \(String(describing: handle))", category: .cancellation) + logger?.trace("CancellationTokenSource.init, handle: \(String(describing: handle))", category: "Cancellation") } func cancel() async throws { - logger?.trace("CancellationTokenSource.cancel, handle: \(String(describing: handle))", category: .cancellation) + logger?.trace("CancellationTokenSource.cancel, handle: \(String(describing: handle))", category: "Cancellation") try await SDKRequestHandler.sendInteropRequest( Proton_Sdk_CancellationTokenSourceCancelRequest.with { @@ -23,7 +23,7 @@ actor CancellationTokenSource { } nonisolated func free() { - logger?.trace("CancellationTokenSource.free, handle: \(String(describing: handle))", category: .cancellation) + logger?.trace("CancellationTokenSource.free, handle: \(String(describing: handle))", category: "Cancellation") let cancellationHandle = self.handle // CAUTION: Intentionally capturing `self` strongly here, because otherwise @@ -34,13 +34,13 @@ actor CancellationTokenSource { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } try await SDKRequestHandler.sendInteropRequest(request, logger: logger) as Void - logger?.trace("CancellationTokenSource.free succeeded, handle: \(cancellationHandle) -> nil", category: .cancellation) + logger?.trace("CancellationTokenSource.free succeeded, handle: \(cancellationHandle) -> nil", category: "Cancellation") strongSelf = nil } } deinit { - logger?.trace("CancellationTokenSource.deinit, handle: \(String(describing: handle))", category: .cancellation) + logger?.trace("CancellationTokenSource.deinit, handle: \(String(describing: handle))", category: "Cancellation") // // TODO(SDK): free handle in deinit // free() // logger?.trace("CancellationTokenSource.deinit, after handle: \(String(describing: cancellationHandle))", category: .cancellation) diff --git a/swift/ProtonDriveSDK/Sources/Logging/Logger.swift b/swift/ProtonDriveSDK/Sources/Logging/Logger.swift index 3dc1891f..74f8b135 100644 --- a/swift/ProtonDriveSDK/Sources/Logging/Logger.swift +++ b/swift/ProtonDriveSDK/Sources/Logging/Logger.swift @@ -3,7 +3,6 @@ import Foundation /// Callback for log events public typealias LogCallback = @Sendable (LogEvent) -> Void - func logCallbackForTests(logEvent: LogEvent) { let timestamp = logEvent.timestamp.formatted(date: .abbreviated, time: .shortened) @@ -26,17 +25,21 @@ extension LogLevel { } } -let cCompatibleLogCallback: CCallback = { state, byteArray in - guard let state else { +let cCompatibleLogCallback: CCallback = { statePointer, byteArray in + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + return + } + + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let weakDriveClient = stateTypedPointer.takeUnretainedValue().state + + guard let driveClient = weakDriveClient.value else { + stateTypedPointer.release() return } -// let logEvent = LogEvent(sdkLogEvent: Proton_Sdk_LogEvent(byteArray: byteArray)) -// -// let continuationBox = Unmanaged>.fromOpaque(state).takeUnretainedValue() -// let driveClient: ProtonDriveClient = continuationBox.state -// -// driveClient.log(logEvent) + let logEvent = LogEvent(sdkLogEvent: Proton_Sdk_LogEvent(byteArray: byteArray)) + driveClient.log(logEvent) } final class Logger: Sendable { @@ -47,23 +50,23 @@ final class Logger: Sendable { self.logCallback = logCallback } - func trace(_ message: String, category: LogCategory, file: String = #file, function: String = #function, line: UInt = #line) { - self.log(level: .trace, message, category: category) + func trace(_ message: String, category: String, file: String = #file, function: String = #function, line: UInt = #line) { + self.log(level: .trace, message, category: category, file: file, function: function, line: line) } - func debug(_ message: String, category: LogCategory) { - self.log(level: .debug, message, category: category) + func debug(_ message: String, category: String, file: String = #file, function: String = #function, line: UInt = #line) { + self.log(level: .debug, message, category: category, file: file, function: function, line: line) } - func error(_ message: String, category: LogCategory) { + func error(_ message: String, category: String) { self.log(level: .error, message, category: category) } - func info(_ message: String, category: LogCategory) { + func info(_ message: String, category: String) { self.log(level: .info, message, category: category) } - func log(level: LogLevel, _ message: String, category: LogCategory, file: String = #file, function: String = #function, line: UInt = #line) { + func log(level: LogLevel, _ message: String, category: String, file: String = #file, function: String = #function, line: UInt = #line) { self.logCallback( LogEvent(level: level, message: message, category: category, thread: Thread.current.number, file: file, function: function, line: line) ) diff --git a/swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift b/swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift index e8107548..7f5ee8b6 100644 --- a/swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift @@ -3,15 +3,15 @@ import Foundation public struct LogEvent: Sendable { public let level: LogLevel public let message: String - public let category: LogCategory + public let category: String public let timestamp: Date - public let thread: UInt? + public let thread: UInt public let file: String public let function: String public let line: UInt - public init(level: LogLevel, message: String, category: LogCategory, timestamp: Date = Date(), thread: UInt?, file: String, function: String, line: UInt) { + public init(level: LogLevel, message: String, category: String, timestamp: Date = .now, thread: UInt, file: String, function: String, line: UInt) { self.level = level self.message = message self.category = category @@ -27,11 +27,11 @@ public struct LogEvent: Sendable { self.init( level: LogLevel(sdkLogEvent.level), message: sdkLogEvent.message, - category: LogCategory(sdkLogEvent.categoryName), - thread: 0, - // TODO: extract this from SDK error - file: "n/a", - function: "n/a", + category: sdkLogEvent.categoryName, + thread: Thread.current.number, + // this is not implemented on SDK side + file: "", + function: "", line: 0 ) } @@ -51,21 +51,3 @@ public enum LogLevel: Int32, Sendable { self = LogLevel(rawValue: rawValue) ?? .debug } } - -public enum LogCategory: Sendable { - case other(String) - case upload - case download - case cancellation - case logging - - init(_ categoryName: String) { - switch categoryName { - case "upload": self = .upload - case "download": self = .download - case "cancellation": self = .cancellation - case "logging": self = .logging - default: self = .other(categoryName) - } - } -} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index 832791af..3ab82417 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -17,11 +17,6 @@ extension ObjectHandle { let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) self = ObjectHandle(bitPattern: callbackAddress) } - - init(callback: CResponseCallback) { - let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) - self = ObjectHandle(bitPattern: callbackAddress) - } } extension ObjectHandle { @@ -35,12 +30,9 @@ func address(of object: T) -> ObjectHandle { return ObjectHandle(bitPattern: rawPointer) } -/// C-compatible callback used to get response from the SDK -typealias CResponseCallback = @convention(c) (Int, ByteArray) -> Void - /// C-compatible callback used by SDK to pass data to the app -typealias CCallback = @convention(c) (UnsafeMutableRawPointer?, ByteArray) -> Void -typealias CCallbackWithReturnValue = @convention(c) (UnsafeMutableRawPointer, ByteArray, UnsafeMutableRawPointer) -> Void +typealias CCallback = @convention(c) (Int, ByteArray) -> Void +typealias CCallbackWithReturnValue = @convention(c) (Int, ByteArray, Int) -> Void extension Data { var dumptoString: String { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift index fd9ae626..81746ca6 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -26,7 +26,7 @@ final class ProgressCallbackWrapper { } } -let cProgressCallback: CResponseCallback = { (sdkHandle: ObjectHandle, byteArray: ByteArray) in +let cProgressCallback: CCallback = { statePointer, byteArray in typealias BoxType = BoxedContinuationWithState> let progressUpdate = Proton_Drive_Sdk_ProgressUpdate(byteArray: byteArray) let progress = FileOperationProgress( @@ -34,13 +34,13 @@ let cProgressCallback: CResponseCallback = { (sdkHandle: ObjectHandle, byteArray bytesTotal: progressUpdate.bytesInTotal ) - guard let sdkPointer = UnsafeRawPointer(bitPattern: UInt(sdkHandle)) else { return } - let statePointer = Unmanaged.fromOpaque(sdkPointer) - let weakWrapper: WeakReference = statePointer.takeUnretainedValue().state + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { return } + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state weakWrapper.value?.callback(progress) // TODO: also release pointer when task is cancelled if progress.isCompleted { - statePointer.release() + stateTypedPointer.release() } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 01bfc0b3..0ff2ef14 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -32,16 +32,16 @@ enum SDKRequestHandler { // Put the request in an envelope let envelopedRequestData = try request.packIntoRequest().serializedData() let isDriveRequest = request.isDriveRequest - logger?.trace("Sending SDK message with state: \(T.protoMessageName) - \(request)", category: .other("SDKRequestHandler")) + logger?.trace("Sending SDK message with state: \(T.protoMessageName) - \(request)", category: "SDKRequestHandler") let response: U = try await withCheckedThrowingContinuation { continuation in let requestArray = ByteArray(data: envelopedRequestData) defer { - logger?.trace("deferred deallocate of requestData", category: .other("SDKRequestHandler")) + logger?.trace("deferred deallocate of requestData", category: "SDKRequestHandler") requestArray.deallocate() } - logger?.trace("Sending (\(isDriveRequest ? "Drive" : "non-Drive")) SDK request ", category: .other("SDKRequestHandler")) + logger?.trace("Sending (\(isDriveRequest ? "Drive" : "non-Drive")) SDK request ", category: "SDKRequestHandler") // Switch to InteropTypes.BoxedStateType once we use it for all requests let boxedState = BoxedContinuationWithState(continuation, state: state, context: envelopedRequestData) @@ -54,10 +54,10 @@ enum SDKRequestHandler { } let bindingsHandle = Int(rawPointer: pointer.toOpaque()) if isDriveRequest { - logger?.trace(" -> proton_drive_sdk_handle_request", category: .other("SDKRequestHandler")) + logger?.trace(" -> proton_drive_sdk_handle_request", category: "SDKRequestHandler") proton_drive_sdk_handle_request(requestArray, bindingsHandle, sdkResponseCallbackWithState) } else { - logger?.trace(" -> proton_sdk_handle_request", category: .other("SDKRequestHandler")) + logger?.trace(" -> proton_sdk_handle_request", category: "SDKRequestHandler") proton_sdk_handle_request(requestArray, bindingsHandle, sdkResponseCallbackWithState) } } @@ -66,9 +66,8 @@ enum SDKRequestHandler { } /// C-compatible callback function for SDK responses. -let sdkResponseCallbackWithState: CResponseCallback = { (sdkHandle: ObjectHandle, responseArray: ByteArray) in - - guard let sdkPointer = UnsafeRawPointer(bitPattern: UInt(sdkHandle)), +let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in + guard let sdkPointer = UnsafeRawPointer(bitPattern: statePointer), let box = Unmanaged.fromOpaque(sdkPointer).takeRetainedValue() as? any Resumable else { assertionFailure("If the pointer is not Resumable, we cannot get the continuation") diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift index 335ece7d..bda56abf 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift @@ -10,12 +10,16 @@ public protocol AccountClientProtocol: Sendable { func getAddressPublicKeysRequest(emailAddress: String) -> [Data] } -let cCompatibleAccountClientRequest: CCallbackWithReturnValue = { state, byteArray, callback in - let callbackPointer = Int(rawPointer: callback) - let statePointer = Unmanaged>>.fromOpaque(state) - let weakDriveClient: WeakReference = statePointer.takeUnretainedValue().state +let cCompatibleAccountClientRequest: CCallbackWithReturnValue = { statePointer, byteArray, callbackPointer in + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + return + } + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state - let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { statePointer.release() }, weakDriveClient: weakDriveClient) + let driveClient = ProtonDriveClient.unbox( + callbackPointer: callbackPointer, releaseBox: { stateTypedPointer.release() }, weakDriveClient: weakDriveClient + ) guard let driveClient else { return } Task { [driveClient] in diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift index e2c1d3ec..e29e2bcd 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift @@ -18,13 +18,14 @@ public protocol HttpClientProtocol: AnyObject, Sendable { func request(method: String, url: String, content: Data, headers: [(String, [String])]) async -> Result } -let cCompatibleHttpRequest: CCallbackWithReturnValue = { state, byteArray, callback in - - let callbackPointer = Int(rawPointer: callback) - let statePointer = Unmanaged>>.fromOpaque(state) - let weakDriveClient: WeakReference = statePointer.takeUnretainedValue().state +let cCompatibleHttpRequest: CCallbackWithReturnValue = { statePointer, byteArray, callbackPointer in + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + return + } + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state - let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { statePointer.release() }, weakDriveClient: weakDriveClient) + let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { stateTypedPointer.release() }, weakDriveClient: weakDriveClient) guard let driveClient else { return } Task { [driveClient] in diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 27621b56..337513bd 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -33,7 +33,6 @@ public actor ProtonDriveClient: Sendable { $0.httpClientRequestAction = Int64(ObjectHandle(callback: cCompatibleHttpRequest)) $0.accountClientRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) - $0.logAction = Int64(ObjectHandle(callback: cCompatibleLogCallback)) if let entityCachePath { @@ -51,7 +50,7 @@ public actor ProtonDriveClient: Sendable { clientCreateRequest, state: weakSelf, includesLongLivedCallback: true, logger: logger ) self.clientHandle = ObjectHandle(handle) - logger.trace("client handle: \(clientHandle)", category: .other("ProtonDriveClient")) + logger.trace("client handle: \(clientHandle)", category: "ProtonDriveClient") self.uploadManager = UploadManager(clientHandle: clientHandle, logger: logger) self.downloadManager = DownloadManager(clientHandle: clientHandle, logger: logger) From e7f9c6c68a7b5c9701bdc5e4d66f8169dadec09d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Nov 2025 11:53:01 +0000 Subject: [PATCH 286/791] Expose function to get available node name through Swift package --- .../InteropMessageHandler.cs | 3 +++ .../InteropProtonDriveClient.cs | 14 ++++++++++ cs/sdk/src/protos/proton.drive.sdk.proto | 9 +++++++ .../Sources/Plumbing/Message+Packaging.swift | 5 ++++ .../Sources/Plumbing/SDKRequestHandler.swift | 9 ++++++- .../ProtonDriveClient/ProtonDriveClient.swift | 27 +++++++++++++++++-- 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index f11cb00d..2a5ab1b2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -43,6 +43,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientGetFileDownloader => await InteropProtonDriveClient.HandleGetFileDownloaderAsync(request.DriveClientGetFileDownloader).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientGetAvailableName + => await InteropProtonDriveClient.HandleGetAvailableNameAsync(request.DriveClientGetAvailableName).ConfigureAwait(false), + Request.PayloadOneofCase.UploadFromStream => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, bindingsHandle), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index f34f3917..17fe4059 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -88,6 +88,20 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; } + public static async ValueTask HandleGetAvailableNameAsync(DriveClientGetAvailableNameRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var availableName = await client.GetAvailableNameAsync( + NodeUid.Parse(request.ParentFolderUid), + request.Name, + cancellationToken).ConfigureAwait(false); + + return new StringValue { Value = availableName };; + } + public static async ValueTask HandleGetFileDownloaderAsync(DriveClientGetFileDownloaderRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 8528bb12..05fb93a2 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -14,6 +14,7 @@ message Request { DriveClientGetFileUploaderRequest drive_client_get_file_uploader = 1003; DriveClientGetFileRevisionUploaderRequest drive_client_get_file_revision_uploader = 1004; DriveClientGetFileDownloaderRequest drive_client_get_file_downloader = 1005; + DriveClientGetAvailableNameRequest drive_client_get_available_name = 1006; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -214,6 +215,14 @@ message UploadControllerFreeRequest { int64 upload_controller_handle = 1; } +// The response message must be of type String. +message DriveClientGetAvailableNameRequest { + int64 client_handle = 1; + string parent_folder_uid = 2; + string name = 3; + int64 cancellation_token_source_handle = 4; +} + // Drive - downloads // The response value must be an Int64Value carrying a handle to an instance of FileDownloader. diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 7ea81e85..9e0dfba5 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -42,6 +42,11 @@ extension Message { Proton_Drive_Sdk_Request.with { $0.payload = .driveClientCreateFromSession(request) } + + case let request as Proton_Drive_Sdk_DriveClientGetAvailableNameRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientGetAvailableName(request) + } case let request as Proton_Drive_Sdk_DriveClientCreateRequest: Proton_Drive_Sdk_Request.with { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 0ff2ef14..d3d7e26e 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -101,7 +101,14 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Proton_Drive_Sdk_UploadResult")) } uploadResultBox.resume(returning: unpackedValue) - + + case .value(let value) where value.isA(Google_Protobuf_StringValue.self): + let unpackedValue = try Google_Protobuf_StringValue(unpackingAny: value) + guard let stringResultBox = box as? Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: String")) + } + stringResultBox.resume(returning: unpackedValue.value) + case .value: // unknown value type throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unknown SDK call response value type")) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 337513bd..f4e41131 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -9,7 +9,7 @@ public actor ProtonDriveClient: Sendable { private var uploadManager: UploadManager! private var downloadManager: DownloadManager! - + private let logger: ProtonDriveSDK.Logger let httpClient: HttpClientProtocol @@ -93,7 +93,30 @@ public actor ProtonDriveClient: Sendable { progressCallback: progressCallback ) } - + + public func getAvailableName( + parentFolderUid: SDKNodeUid, + name: String + ) async throws -> String { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + defer { + // TODO: Should be done in deinit! + cancellationTokenSource.free() + } + + let cancellationHandle = cancellationTokenSource.handle + + let getAvailableNameRequest = Proton_Drive_Sdk_DriveClientGetAvailableNameRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.parentFolderUid = parentFolderUid.sdkCompatibleIdentifier + $0.name = name + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let nameResult: String = try await SDKRequestHandler.send(getAvailableNameRequest, logger: logger) + return nameResult + } + static func unbox(callbackPointer: Int, releaseBox: () -> Void, weakDriveClient: WeakReference) -> ProtonDriveClient? { guard let driveClient = weakDriveClient.value else { releaseBox() From c1857695fc34de02d153183ce76d49448288338c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Nov 2025 14:19:42 +0100 Subject: [PATCH 287/791] Add telemetry for uploads --- .../DriveInteropTelemetryDecorator.cs | 55 ++++ .../InteropProtonDriveClient.cs | 24 +- .../Nodes/Upload/RevisionWriter.cs | 259 ++++++++++-------- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 17 +- .../Telemetry/BlockVerificationErrorEvent.cs | 10 + .../Telemetry/DecryptionErrorEvent.cs | 18 ++ .../Telemetry/DownloadError.cs | 12 + .../Telemetry/DownloadEvent.cs | 18 ++ .../Telemetry/EncryptedField.cs | 12 + .../Telemetry/TelemetryErrorResolver.cs | 21 ++ .../Proton.Drive.Sdk/Telemetry/UploadError.cs | 11 + .../Proton.Drive.Sdk/Telemetry/UploadEvent.cs | 18 ++ .../Telemetry/VerificationErrorEvent.cs | 20 ++ .../Proton.Drive.Sdk/Telemetry/VolumeType.cs | 9 + .../InteropMessageHandler.cs | 4 +- .../Proton.Sdk.CExports/InteropMetricEvent.cs | 10 + .../Proton.Sdk.CExports/InteropTelemetry.cs | 46 ++++ .../InteropTelemetryExtensions.cs | 40 +++ .../Logging/InteropLogger.cs | 12 +- .../Logging/InteropLoggerProvider.cs | 18 +- .../ProtonApiSessionRequestHandler.cs | 38 +-- .../Authentication/TokenCredential.cs | 2 +- cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 5 +- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 11 +- .../Proton.Sdk/ProtonClientConfiguration.cs | 5 +- .../ProtonClientConfigurationExtensions.cs | 6 +- cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs | 6 +- .../src/Proton.Sdk/Telemetry/IMetricEvent.cs | 6 + cs/sdk/src/Proton.Sdk/Telemetry/ITelemetry.cs | 10 + .../src/Proton.Sdk/Telemetry/NullTelemetry.cs | 16 ++ .../Telemetry/TelemetryExtensions.cs | 33 +++ cs/sdk/src/protos/proton.drive.sdk.proto | 79 +++++- cs/sdk/src/protos/proton.sdk.proto | 20 +- .../Sources/Plumbing/InternalTypes.swift | 4 +- .../ProtonDriveClient/ProtonDriveClient.swift | 14 +- .../Logger.swift | 0 .../LoggerTypes.swift | 0 .../TelemetryAndLogging/Telemetry.swift | 27 ++ .../TelemetryAndLogging/TelemetryTypes.swift | 255 +++++++++++++++++ 39 files changed, 964 insertions(+), 207 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/EncryptedField.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropMetricEvent.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropTelemetry.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropTelemetryExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Telemetry/IMetricEvent.cs create mode 100644 cs/sdk/src/Proton.Sdk/Telemetry/ITelemetry.cs create mode 100644 cs/sdk/src/Proton.Sdk/Telemetry/NullTelemetry.cs create mode 100644 cs/sdk/src/Proton.Sdk/Telemetry/TelemetryExtensions.cs rename swift/ProtonDriveSDK/Sources/{Logging => TelemetryAndLogging}/Logger.swift (100%) rename swift/ProtonDriveSDK/Sources/{Logging => TelemetryAndLogging}/LoggerTypes.swift (100%) create mode 100644 swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift create mode 100644 swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs new file mode 100644 index 00000000..ce631ba1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Telemetry; +using Proton.Sdk.CExports; +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.CExports; + +internal sealed class DriveInteropTelemetryDecorator(InteropTelemetry instanceToDecorate) : ITelemetry +{ + private readonly InteropTelemetry _instanceToDecorate = instanceToDecorate; + + public ILogger GetLogger(string name) + { + return _instanceToDecorate.GetLogger(name); + } + + public void RecordMetric(IMetricEvent metricEvent) + { + var payload = metricEvent switch + { + UploadEvent me => GetUploadEventPayload(me), + _ => null, + }; + + if (payload is null) + { + _instanceToDecorate.RecordMetric(metricEvent); + return; + } + + _instanceToDecorate.RecordMetric(metricEvent.Name, payload); + } + + private static UploadEventPayload GetUploadEventPayload(UploadEvent me) + { + var payload = new UploadEventPayload + { + VolumeType = (VolumeType)me.VolumeType, + UploadedSize = me.UploadedSize, + ExpectedSize = me.ExpectedSize, + }; + + if (me.Error is not null) + { + payload.Error = (UploadError)me.Error; + } + + if (me.OriginalError is not null) + { + payload.OriginalError = me.OriginalError; + } + + return payload; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 17fe4059..ae6d6474 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -1,12 +1,10 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Proton.Drive.Sdk.Nodes; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.CExports; -using Proton.Sdk.CExports.Logging; +using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk.CExports; @@ -30,20 +28,16 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi ? SqliteCacheRepository.OpenFile(request.SecretCachePath) : SqliteCacheRepository.OpenInMemory(); - var loggerProvider = request.LoggerCase switch - { - DriveClientCreateRequest.LoggerOneofCase.LogAction => new InteropLoggerProvider( - bindingsHandle, - new InteropAction>(request.LogAction)), - DriveClientCreateRequest.LoggerOneofCase.LoggerProviderHandle => Interop.GetFromHandle(request.LoggerProviderHandle), - DriveClientCreateRequest.LoggerOneofCase.None or _ => NullLoggerProvider.Instance, - }; - - var loggerFactory = new LoggerFactory([loggerProvider]); + ITelemetry telemetry = request.Telemetry.ToTelemetry(bindingsHandle) is { } interopTelemetry + ? new DriveInteropTelemetryDecorator(interopTelemetry) + : NullTelemetry.Instance; - var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, loggerFactory); + var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, telemetry); - return new Int64Value { Value = Interop.AllocHandle(client) }; + return new Int64Value + { + Value = Interop.AllocHandle(client), + }; } public static IMessage HandleCreate(DriveClientCreateFromSessionRequest request) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index b1efbc3d..88804438 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -7,6 +7,7 @@ using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Telemetry; using Proton.Sdk.Addresses; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -61,148 +62,180 @@ public async ValueTask WriteAsync( Action? onProgress, CancellationToken cancellationToken) { - long numberOfBytesUploaded = 0; + var uploadEvent = new UploadEvent + { + ExpectedSize = contentStream.Length, + UploadedSize = 0, + VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type + }; - var signingEmailAddress = _membershipAddress.EmailAddress; + try + { + long numberOfBytesUploaded = 0; - var uploadTasks = new Queue>(_client.BlockUploader.MaxDegreeOfParallelism); - var blockIndex = 0; + var signingEmailAddress = _membershipAddress.EmailAddress; - ArraySegment manifestSignature; - var blockSizes = new List(8); + var uploadTasks = new Queue>(_client.BlockUploader.MaxDegreeOfParallelism); + var blockIndex = 0; - using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + ArraySegment manifestSignature; + var blockSizes = new List(8); - var hashingContentStream = new HashingReadStream(contentStream, sha1, leaveOpen: true); + using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); - await using (hashingContentStream.ConfigureAwait(false)) - { - // TODO: provide capacity - var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + var hashingContentStream = new HashingReadStream(contentStream, sha1, leaveOpen: true); - await using (manifestStream.ConfigureAwait(false)) + await using (hashingContentStream.ConfigureAwait(false)) { - var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_fileUid, _revisionId, _fileKey, cancellationToken).ConfigureAwait(false); - - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var linkedCancellationToken = cancellationTokenSource.Token; + // TODO: provide capacity + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - try + await using (manifestStream.ConfigureAwait(false)) { + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_fileUid, _revisionId, _fileKey, cancellationToken) + .ConfigureAwait(false); + + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var linkedCancellationToken = cancellationTokenSource.Token; + try { - foreach (var thumbnail in thumbnails) + try + { + foreach (var thumbnail in thumbnails) + { + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); + + var uploadTask = _client.BlockUploader.UploadThumbnailAsync( + _fileUid, + _revisionId, + _contentKey, + _signingKey, + _membershipAddress.Id, + thumbnail, + onProgress: null, + cancellationTokenSource.Token); + + uploadTasks.Enqueue(uploadTask); + } + + if (contentStream.Length > 0) + { + do + { + var plainDataPrefixBuffer = ArrayPool.Shared.Rent(blockVerifier.DataPacketPrefixMaxLength); + try + { + var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + var bytesCopied = await hashingContentStream.PartiallyCopyToAsync( + plainDataStream, + _targetBlockSize, + plainDataPrefixBuffer, + linkedCancellationToken).ConfigureAwait(false); + + blockSizes.Add((int)plainDataStream.Length); + + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); + + plainDataStream.Seek(0, SeekOrigin.Begin); + + var onBlockProgress = onProgress is not null + ? progress => + { + numberOfBytesUploaded += progress; + + // TODO: move this to a decorator, wrap the progress action + uploadEvent.UploadedSize = numberOfBytesUploaded; + + onProgress(numberOfBytesUploaded, contentStream.Length); + } + : default(Action?); + + var uploadTask = _client.BlockUploader.UploadContentAsync( + _fileUid, + _revisionId, + ++blockIndex, + _contentKey, + _signingKey, + _membershipAddress.Id, + _fileKey, + plainDataStream, + blockVerifier, + plainDataPrefixBuffer, + Math.Min(blockVerifier.DataPacketPrefixMaxLength, bytesCopied), + onBlockProgress, + _releaseBlocksAction, + linkedCancellationToken); + + uploadTasks.Enqueue(uploadTask); + } + catch + { + ArrayPool.Shared.Return(plainDataPrefixBuffer); + throw; + } + } while (contentStream.Position < contentStream.Length); + } + } + finally { - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); - - var uploadTask = _client.BlockUploader.UploadThumbnailAsync( - _fileUid, - _revisionId, - _contentKey, - _signingKey, - _membershipAddress.Id, - thumbnail, - onProgress: null, - cancellationTokenSource.Token); - - uploadTasks.Enqueue(uploadTask); + _releaseFileSemaphoreAction.Invoke(); + _fileReleased = true; } - if (contentStream.Length > 0) + while (uploadTasks.Count > 0) { - do - { - var plainDataPrefixBuffer = ArrayPool.Shared.Rent(blockVerifier.DataPacketPrefixMaxLength); - try - { - var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - - var bytesCopied = await hashingContentStream.PartiallyCopyToAsync( - plainDataStream, - _targetBlockSize, - plainDataPrefixBuffer, - linkedCancellationToken).ConfigureAwait(false); - - blockSizes.Add((int)plainDataStream.Length); - - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); - - plainDataStream.Seek(0, SeekOrigin.Begin); - - var onBlockProgress = onProgress is not null - ? progress => - { - numberOfBytesUploaded += progress; - onProgress(numberOfBytesUploaded, contentStream.Length); - } - : default(Action?); - - var uploadTask = _client.BlockUploader.UploadContentAsync( - _fileUid, - _revisionId, - ++blockIndex, - _contentKey, - _signingKey, - _membershipAddress.Id, - _fileKey, - plainDataStream, - blockVerifier, - plainDataPrefixBuffer, - Math.Min(blockVerifier.DataPacketPrefixMaxLength, bytesCopied), - onBlockProgress, - _releaseBlocksAction, - linkedCancellationToken); - - uploadTasks.Enqueue(uploadTask); - } - catch - { - ArrayPool.Shared.Return(plainDataPrefixBuffer); - throw; - } - } while (contentStream.Position < contentStream.Length); + await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); } } - finally + catch { - _releaseFileSemaphoreAction.Invoke(); - _fileReleased = true; - } + await cancellationTokenSource.CancelAsync().ConfigureAwait(false); - while (uploadTasks.Count > 0) - { - await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); - } - } - catch - { - await cancellationTokenSource.CancelAsync().ConfigureAwait(false); + try + { + await Task.WhenAll(uploadTasks).ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + } - try - { - await Task.WhenAll(uploadTasks).ConfigureAwait(false); - } - catch - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + throw; } - throw; - } - - manifestStream.Seek(0, SeekOrigin.Begin); + manifestStream.Seek(0, SeekOrigin.Begin); - manifestSignature = await _signingKey.SignAsync(manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + manifestSignature = await _signingKey.SignAsync(manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + } } - } - var request = GetRevisionUpdateRequest(contentStream, lastModificationTime, blockSizes, sha1.GetCurrentHash(), manifestSignature, signingEmailAddress); + var request = GetRevisionUpdateRequest( + contentStream, + lastModificationTime, + blockSizes, + sha1.GetCurrentHash(), + manifestSignature, + signingEmailAddress); - _client.Logger.LogDebug("Sealing revision {RevisionId} of file {FileUid}", _revisionId, _fileUid); + _client.Logger.LogDebug("Sealing revision {RevisionId} of file {FileUid}", _revisionId, _fileUid); - await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, request, cancellationToken).ConfigureAwait(false); + await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, request, cancellationToken).ConfigureAwait(false); - _client.Logger.LogDebug("Revision {RevisionId} of file {FileUid} sealed", _revisionId, _fileUid); + _client.Logger.LogDebug("Revision {RevisionId} of file {FileUid} sealed", _revisionId, _fileUid); + } + catch (Exception ex) + { + uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); + uploadEvent.OriginalError = ex.GetBaseException().ToString(); + throw; + } + finally + { + // TODO: put this in a decorator + _client.Telemetry.RecordMetric(uploadEvent); + } } public void Dispose() diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index a208e76f..695fcf8e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -10,6 +10,7 @@ using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk; @@ -30,7 +31,7 @@ public ProtonDriveClient(ProtonApiSession session, string? uid = null) session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds)), new AccountClientAdapter(session), new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), - session.ClientConfiguration.LoggerFactory, + session.ClientConfiguration.Telemetry, uid ?? Guid.NewGuid().ToString()) { } @@ -40,13 +41,13 @@ public ProtonDriveClient( IAccountClient accountClient, ICacheRepository entityCacheRepository, ICacheRepository secretCacheRepository, - ILoggerFactory loggerFactory, + ITelemetry telemetry, string? uid = null) : this( new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClient(), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), - loggerFactory, + telemetry, uid ?? Guid.NewGuid().ToString()) { } @@ -56,7 +57,7 @@ internal ProtonDriveClient( IDriveApiClients apiClients, IDriveClientCache cache, IBlockVerifierFactory blockVerifierFactory, - ILoggerFactory loggerFactory, + ITelemetry telemetry, string uid) { Uid = uid; @@ -65,7 +66,8 @@ internal ProtonDriveClient( Api = apiClients; Cache = cache; BlockVerifierFactory = blockVerifierFactory; - Logger = loggerFactory.CreateLogger(); + Telemetry = telemetry; + Logger = telemetry.GetLogger(); var maxDegreeOfBlockTransferParallelism = Math.Max( Math.Min(Environment.ProcessorCount / 2, MaxDegreeOfBlockTransferParallelism), @@ -84,14 +86,14 @@ private ProtonDriveClient( HttpClient httpClient, IAccountClient accountClient, IDriveClientCache cache, - ILoggerFactory loggerFactory, + ITelemetry telemetry, string uid) : this( accountClient, new DriveApiClients(httpClient), cache, new BlockVerifierFactory(httpClient), - loggerFactory, + telemetry, uid) { } @@ -104,6 +106,7 @@ private ProtonDriveClient( internal IDriveApiClients Api { get; } internal IDriveClientCache Cache { get; } internal IBlockVerifierFactory BlockVerifierFactory { get; } + internal ITelemetry Telemetry { get; } internal ILogger Logger { get; } internal FifoFlexibleSemaphore RevisionCreationSemaphore { get; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs new file mode 100644 index 00000000..51ef60f6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs @@ -0,0 +1,10 @@ +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.Telemetry; + +public sealed class BlockVerificationErrorEvent : IMetricEvent +{ + public string Name => "blockVerificationError"; + + public bool RetryHelped { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs new file mode 100644 index 00000000..ab9dcc0c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs @@ -0,0 +1,18 @@ +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.Telemetry; + +public sealed class DecryptionErrorEvent : IMetricEvent +{ + public string Name => "decryptionError"; + + public required VolumeType VolumeType { get; init; } + + public required EncryptedField Field { get; init; } + + public bool? FromBefore2024 { get; init; } + + public string? Error { get; init; } + + public required string Uid { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs new file mode 100644 index 00000000..4f0c324c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs @@ -0,0 +1,12 @@ +namespace Proton.Drive.Sdk.Telemetry; + +public enum DownloadError +{ + ServerError, + NetworkError, + DecryptionError, + IntegrityError, + RateLimited, + HttpClientSideError, + Unknown, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs new file mode 100644 index 00000000..67f2db74 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs @@ -0,0 +1,18 @@ +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.Telemetry; + +public sealed class DownloadEvent : IMetricEvent +{ + public string Name => "download"; + + public required VolumeType VolumeType { get; init; } + + public required long DownloadedSize { get; init; } + + public long? ClaimedFileSize { get; init; } + + public DownloadError? Error { get; init; } + + public string? OriginalError { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/EncryptedField.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/EncryptedField.cs new file mode 100644 index 00000000..cec5e0d9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/EncryptedField.cs @@ -0,0 +1,12 @@ +namespace Proton.Drive.Sdk.Telemetry; + +public enum EncryptedField +{ + ShareKey, + NodeKey, + NodeName, + NodeHashKey, + NodeExtendedAttributes, + NodeContentKey, + Content, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs new file mode 100644 index 00000000..8a5b1a12 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -0,0 +1,21 @@ +using System.Net; +using Proton.Drive.Sdk.Nodes.Upload.Verification; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Telemetry; + +internal static class TelemetryErrorResolver +{ + public static UploadError? GetUploadErrorFromException(Exception exception) + { + return exception switch + { + NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => UploadError.IntegrityError, + HttpRequestException { HttpRequestError: HttpRequestError.ConnectionError } => UploadError.NetworkError, + ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => UploadError.RateLimited, + ProtonApiException { TransportCode: >= 400 and < 500 } => UploadError.HttpClientSideError, + ProtonApiException { TransportCode: >= 500 and < 600 } => UploadError.ServerError, + _ => UploadError.Unknown, + }; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs new file mode 100644 index 00000000..c2f8758a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs @@ -0,0 +1,11 @@ +namespace Proton.Drive.Sdk.Telemetry; + +public enum UploadError +{ + ServerError, + NetworkError, + IntegrityError, + RateLimited, + HttpClientSideError, + Unknown, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs new file mode 100644 index 00000000..aab9632a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs @@ -0,0 +1,18 @@ +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.Telemetry; + +public sealed class UploadEvent : IMetricEvent +{ + public string Name => "upload"; + + public required VolumeType VolumeType { get; set; } + + public required long UploadedSize { get; set; } + + public required long ExpectedSize { get; set; } + + public UploadError? Error { get; set; } + + public string? OriginalError { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs new file mode 100644 index 00000000..a830ee9c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs @@ -0,0 +1,20 @@ +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.Telemetry; + +public sealed class VerificationErrorEvent : IMetricEvent +{ + public string Name => "verificationError"; + + public required VolumeType VolumeType { get; set; } + + public required EncryptedField Field { get; set; } + + public bool? FromBefore2024 { get; set; } + + public bool? AddressMatchingDefaultShare { get; set; } + + public string? Error { get; set; } + + public required string Uid { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs new file mode 100644 index 00000000..a3b04067 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk.Telemetry; + +public enum VolumeType +{ + OwnVolume, + Shared, + SharedPublic, + Photo, +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs index c76c91ab..69417620 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -26,10 +26,10 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint => InteropCancellationTokenSource.HandleFree(request.CancellationTokenSourceFree), Request.PayloadOneofCase.SessionBegin - => await ProtonApiSessionRequestHandler.HandleBeginAsync(request.SessionBegin).ConfigureAwait(false), + => await ProtonApiSessionRequestHandler.HandleBeginAsync(request.SessionBegin, bindingsHandle).ConfigureAwait(false), Request.PayloadOneofCase.SessionResume - => ProtonApiSessionRequestHandler.HandleResume(request.SessionResume), + => ProtonApiSessionRequestHandler.HandleResume(request.SessionResume, bindingsHandle), Request.PayloadOneofCase.SessionRenew => ProtonApiSessionRequestHandler.HandleRenew(request.SessionRenew), diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMetricEvent.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMetricEvent.cs new file mode 100644 index 00000000..cb9b9362 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMetricEvent.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal struct InteropMetricEvent +{ + public nint EventName; + public nint PropertiesJson; +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetry.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetry.cs new file mode 100644 index 00000000..5fafa2d0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetry.cs @@ -0,0 +1,46 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Proton.Sdk.Telemetry; + +namespace Proton.Sdk.CExports; + +internal sealed class InteropTelemetry(nint bindingsHandle, InteropAction>? recordMetricAction, ILoggerFactory? loggerFactory) + : ITelemetry +{ + private readonly InteropAction>? _recordMetricAction = recordMetricAction; + private readonly ILoggerFactory _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + private readonly nint _bindingsHandle = bindingsHandle; + + public ILogger GetLogger(string name) + { + return _loggerFactory.CreateLogger(name); + } + + public void RecordMetric(IMetricEvent metricEvent) + { + IMessage payload = metricEvent.Name switch + { + _ => throw new NotSupportedException($"Unknown metric event \"{metricEvent.Name}\""), + }; + + RecordMetric(metricEvent.Name, payload); + } + + public unsafe void RecordMetric(string eventName, IMessage payload) + { + var message = new MetricEvent + { + Name = eventName, + Payload = Any.Pack(payload), + }; + + var messageBytes = message.ToByteArray(); + + fixed (byte* messagePointer = messageBytes) + { + _recordMetricAction?.Invoke(_bindingsHandle, new InteropArray(messagePointer, messageBytes.Length)); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetryExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetryExtensions.cs new file mode 100644 index 00000000..e8e39c5a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetryExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; +using Proton.Sdk.CExports.Logging; + +namespace Proton.Sdk.CExports; + +internal static class InteropTelemetryExtensions +{ + public static InteropTelemetry? ToTelemetry(this Telemetry telemetry, nint bindingsHandle) + { + var loggerFactory = GetLoggerFactory(telemetry, bindingsHandle); + + var recordMetricAction = telemetry.HasRecordMetricAction + ? new InteropAction>(telemetry.RecordMetricAction) + : default(InteropAction>?); + + if (loggerFactory is null && recordMetricAction is null) + { + return null; + } + + return new InteropTelemetry(bindingsHandle, recordMetricAction, loggerFactory); + } + + private static LoggerFactory? GetLoggerFactory(Telemetry telemetry, nint bindingsHandle) + { + if (telemetry.HasLoggerProviderHandle) + { + var loggerProvider = Interop.GetFromHandle(telemetry.LoggerProviderHandle); + return new LoggerFactory([loggerProvider]); + } + + if (telemetry.HasLogAction) + { + var logAction = new InteropAction>(telemetry.LogAction); + return new LoggerFactory([new InteropLoggerProvider(bindingsHandle, logAction)]); + } + + return null; + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index e87d1028..d8b8d8d8 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -18,20 +18,16 @@ public IDisposable BeginScope(TState state) return new DummyDisposable(); } - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + public unsafe void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var message = formatter.Invoke(state, exception); var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; - var messageBytes = InteropArray.AllocFromMemory(logEvent.ToByteArray()); + var messageBytes = logEvent.ToByteArray(); - try + fixed (byte* messagePointer = messageBytes) { - _logAction.Invoke(_bindingsHandle, messageBytes); - } - finally - { - messageBytes.Free(); + _logAction.Invoke(_bindingsHandle, new InteropArray(messagePointer, messageBytes.Length)); } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs index b001309b..1411f7a0 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs @@ -9,6 +9,15 @@ internal sealed class InteropLoggerProvider(nint bindingsHandle, InteropAction> _logAction = logAction; + public static IMessage HandleCreate(LoggerProviderCreate request, nint bindingsHandle) + { + var logAction = new InteropAction>(request.LogAction); + + var provider = new InteropLoggerProvider(bindingsHandle, logAction); + + return new Int64Value { Value = Interop.AllocHandle(provider) }; + } + public ILogger CreateLogger(string categoryName) { return new InteropLogger(_bindingsHandle, _logAction, categoryName); @@ -18,13 +27,4 @@ public void Dispose() { // Nothing to do } - - public static IMessage HandleCreate(LoggerProviderCreate request, nint bindingsHandle) - { - var logAction = new InteropAction>(request.LogAction); - - var provider = new InteropLoggerProvider(bindingsHandle, logAction); - - return new Int64Value { Value = Interop.AllocHandle(provider) }; - } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs index 9f303aef..6ac1bd2f 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs @@ -1,26 +1,18 @@ using System.Text; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging; using Proton.Sdk.Authentication; using Proton.Sdk.Caching; -using Proton.Sdk.CExports.Logging; namespace Proton.Sdk.CExports; internal static class ProtonApiSessionRequestHandler { - public static async ValueTask HandleBeginAsync(SessionBeginRequest request) + public static async ValueTask HandleBeginAsync(SessionBeginRequest request, nint bindingsHandle) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - ILoggerFactory? loggerFactory = null; - - if (request.Options.HasLoggerProviderHandle) - { - var loggerProvider = Interop.GetFromHandle(request.Options.LoggerProviderHandle); - loggerFactory = new LoggerFactory([loggerProvider]); - } + var telemetry = request.Options.Telemetry.ToTelemetry(bindingsHandle); var secretCacheRepository = request.HasSecretCachePath ? SqliteCacheRepository.OpenFile(request.SecretCachePath) @@ -35,7 +27,7 @@ internal static class ProtonApiSessionRequestHandler BaseUrl = new Uri(request.Options.BaseUrl), UserAgent = request.Options.UserAgent, BindingsLanguage = request.Options.BindingsLanguage, - LoggerFactory = loggerFactory, + Telemetry = telemetry, TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, EntityCacheRepository = entityCacheRepository, SecretCacheRepository = secretCacheRepository, @@ -51,15 +43,9 @@ internal static class ProtonApiSessionRequestHandler return new Int64Value { Value = Interop.AllocHandle(session) }; } - public static IMessage HandleResume(SessionResumeRequest request) + public static IMessage HandleResume(SessionResumeRequest request, nint bindingsHandle) { - ILoggerFactory? loggerFactory = null; - - if (request.Options.HasLoggerProviderHandle) - { - var loggerProvider = Interop.GetFromHandle(request.Options.LoggerProviderHandle); - loggerFactory = new LoggerFactory([loggerProvider]); - } + var telemetry = request.Options.Telemetry.ToTelemetry(bindingsHandle); var secretCacheRepository = SqliteCacheRepository.OpenFile(request.SecretCachePath); @@ -72,7 +58,7 @@ public static IMessage HandleResume(SessionResumeRequest request) BaseUrl = new Uri(request.Options.BaseUrl), UserAgent = request.Options.UserAgent, BindingsLanguage = request.Options.BindingsLanguage, - LoggerFactory = loggerFactory, + Telemetry = telemetry, TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, EntityCacheRepository = entityCacheRepository, SecretCacheRepository = secretCacheRepository, @@ -183,17 +169,13 @@ public void Dispose() _session.TokenCredential.TokensRefreshed -= Handle; } - private void Handle(string accessToken, string refreshToken) + private unsafe void Handle(string accessToken, string refreshToken) { - var tokensMessage = InteropArray.AllocFromMemory(new SessionTokens { AccessToken = accessToken, RefreshToken = refreshToken }.ToByteArray()); + var tokensMessageBytes = new SessionTokens { AccessToken = accessToken, RefreshToken = refreshToken }.ToByteArray(); - try - { - _tokensRefreshedAction.Invoke(_bindingsHandle, tokensMessage); - } - finally + fixed (byte* tokensMessagePointer = tokensMessageBytes) { - tokensMessage.Free(); + _tokensRefreshedAction.Invoke(_bindingsHandle, new InteropArray(tokensMessagePointer, tokensMessageBytes.Length)); } } } diff --git a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs index 1af884d3..7ede0a85 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs +++ b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs @@ -8,7 +8,7 @@ public sealed class TokenCredential { private readonly IAuthenticationApiClient _client; private readonly SessionId _sessionId; - private readonly ILogger _logger; + private readonly ILogger _logger; private Lazy> _tokensTask; diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs index 0c4b46db..ed236486 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -3,6 +3,7 @@ using Proton.Sdk.Addresses; using Proton.Sdk.Api; using Proton.Sdk.Caching; +using Proton.Sdk.Telemetry; namespace Proton.Sdk; @@ -12,7 +13,7 @@ public ProtonAccountClient(ProtonApiSession session) : this( new AccountApiClients(session.GetHttpClient()), new AccountClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository, session.SecretCache), - session.ClientConfiguration.LoggerFactory.CreateLogger()) + session.ClientConfiguration.Telemetry.GetLogger()) { } @@ -27,7 +28,7 @@ internal ProtonAccountClient(IAccountApiClients apiClients, IAccountClientCache internal IAccountClientCache Cache { get; } - internal ILogger Logger { get; } + internal ILogger Logger { get; } public ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) { diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index 17edec69..4d7ac11b 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -5,6 +5,7 @@ using Proton.Sdk.Api.Keys; using Proton.Sdk.Authentication; using Proton.Sdk.Caching; +using Proton.Sdk.Telemetry; using Proton.Sdk.Users; namespace Proton.Sdk; @@ -92,7 +93,7 @@ public static async ValueTask BeginAsync( CancellationToken cancellationToken) { var configuration = new ProtonClientConfiguration(appVersion, options); - var logger = configuration.LoggerFactory.CreateLogger(); + var logger = configuration.Telemetry.GetLogger(); var authApiClient = ApiClientFactory.Instance.CreateAuthenticationApiClient(configuration.GetHttpClient(), configuration.RefreshRedirectUri); @@ -119,7 +120,7 @@ public static async ValueTask BeginAsync( authResponse.SessionId, authResponse.AccessToken, authResponse.RefreshToken, - configuration.LoggerFactory.CreateLogger()); + configuration.Telemetry.GetLogger()); var session = new ProtonApiSession( authResponse.SessionId, @@ -190,14 +191,14 @@ public static ProtonApiSession Resume( var configuration = new ProtonClientConfiguration(appVersion, options); - var logger = configuration.LoggerFactory.CreateLogger(); + var logger = configuration.Telemetry.GetLogger(); var tokenCredential = new TokenCredential( ApiClientFactory.Instance.CreateAuthenticationApiClient(configuration.GetHttpClient(), configuration.RefreshRedirectUri), sessionId, accessToken, refreshToken, - configuration.LoggerFactory.CreateLogger()); + configuration.Telemetry.GetLogger()); var session = new ProtonApiSession( sessionId, @@ -228,7 +229,7 @@ public static ProtonApiSession Renew( sessionId, accessToken, refreshToken, - expiredSession.ClientConfiguration.LoggerFactory.CreateLogger()); + expiredSession.ClientConfiguration.Telemetry.GetLogger()); return new ProtonApiSession( sessionId, diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs index a47eaf8e..255ff70f 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs @@ -1,7 +1,6 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Proton.Sdk.Caching; using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; namespace Proton.Sdk; @@ -19,7 +18,7 @@ internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientO public Func? CustomHttpMessageHandlerFactory { get; } = options?.CustomHttpMessageHandlerFactory; public ICacheRepository SecretCacheRepository { get; } = options?.SecretCacheRepository ?? SqliteCacheRepository.OpenInMemory(); public ICacheRepository EntityCacheRepository { get; } = options?.EntityCacheRepository ?? SqliteCacheRepository.OpenInMemory(); - public ILoggerFactory LoggerFactory { get; } = options?.LoggerFactory ?? NullLoggerFactory.Instance; + public ITelemetry Telemetry { get; } = options?.Telemetry ?? NullTelemetry.Instance; public Uri RefreshRedirectUri { get; } = options?.RefreshRedirectUri ?? ProtonApiDefaults.RefreshRedirectUri; public string? BindingsLanguage { get; } = options?.BindingsLanguage; } diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index 272cf718..cf405cd8 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -2,10 +2,10 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; -using Microsoft.Extensions.Logging; using Polly; using Proton.Sdk.Authentication; using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; namespace Proton.Sdk; @@ -23,7 +23,7 @@ public static HttpClient GetHttpClient( var services = new ServiceCollection(); - services.AddSingleton(config.LoggerFactory); + services.AddSingleton(config.Telemetry.ToLoggerFactory()); services.ConfigureHttpClientDefaults( builder => @@ -60,7 +60,7 @@ public static HttpClient GetHttpClient( } #if DEBUG - builder.AddHttpMessageHandler(() => new HttpBodyLoggingHandler(config.LoggerFactory.CreateLogger())); + builder.AddHttpMessageHandler(() => new HttpBodyLoggingHandler(config.Telemetry.GetLogger())); #endif builder.AddHttpMessageHandler(() => new CryptographyTimeProvisionHandler()); diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs index c7490d8b..4a354c51 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs @@ -1,6 +1,6 @@ -using Microsoft.Extensions.Logging; -using Proton.Sdk.Caching; +using Proton.Sdk.Caching; using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; namespace Proton.Sdk; @@ -12,7 +12,7 @@ public record ProtonClientOptions public Func? CustomHttpMessageHandlerFactory { get; set; } public IHttpClientFactory? HttpClientFactory { get; set; } public ICacheRepository? EntityCacheRepository { get; set; } - public ILoggerFactory? LoggerFactory { get; set; } + public ITelemetry? Telemetry { get; set; } internal ICacheRepository? SecretCacheRepository { get; set; } internal Uri? RefreshRedirectUri { get; set; } internal string? BindingsLanguage { get; set; } diff --git a/cs/sdk/src/Proton.Sdk/Telemetry/IMetricEvent.cs b/cs/sdk/src/Proton.Sdk/Telemetry/IMetricEvent.cs new file mode 100644 index 00000000..33115b98 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Telemetry/IMetricEvent.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Telemetry; + +public interface IMetricEvent +{ + string Name { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/Telemetry/ITelemetry.cs b/cs/sdk/src/Proton.Sdk/Telemetry/ITelemetry.cs new file mode 100644 index 00000000..19605f13 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Telemetry/ITelemetry.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Logging; + +namespace Proton.Sdk.Telemetry; + +public interface ITelemetry +{ + ILogger GetLogger(string name); + + void RecordMetric(IMetricEvent metricEvent); +} diff --git a/cs/sdk/src/Proton.Sdk/Telemetry/NullTelemetry.cs b/cs/sdk/src/Proton.Sdk/Telemetry/NullTelemetry.cs new file mode 100644 index 00000000..1a38718d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Telemetry/NullTelemetry.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Proton.Sdk.Telemetry; + +internal sealed class NullTelemetry : ITelemetry +{ + public static NullTelemetry Instance { get; } = new(); + + public ILogger GetLogger(string name) => NullLogger.Instance; + + public void RecordMetric(IMetricEvent metricEvent) + { + // Do nothing + } +} diff --git a/cs/sdk/src/Proton.Sdk/Telemetry/TelemetryExtensions.cs b/cs/sdk/src/Proton.Sdk/Telemetry/TelemetryExtensions.cs new file mode 100644 index 00000000..f8959778 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Telemetry/TelemetryExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; + +namespace Proton.Sdk.Telemetry; + +public static class TelemetryExtensions +{ + public static ILogger GetLogger(this ITelemetry telemetry) + { + return new Logger(new TelemetryLoggerFactory(telemetry)); + } + + public static ILoggerFactory ToLoggerFactory(this ITelemetry telemetry) + { + return new TelemetryLoggerFactory(telemetry); + } + + private sealed class TelemetryLoggerFactory(ITelemetry telemetry) : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) + { + return telemetry.GetLogger(categoryName); + } + + public void AddProvider(ILoggerProvider provider) + { + throw new NotSupportedException(); + } + + public void Dispose() + { + } + } +} diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 05fb93a2..e08cbbc7 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -4,6 +4,7 @@ package proton.drive.sdk; option features.utf8_validation = NONE; option csharp_namespace = "Proton.Drive.Sdk.CExports"; +import "proton.sdk.proto"; import "google/protobuf/timestamp.proto"; message Request { @@ -133,10 +134,7 @@ message DriveClientCreateRequest { string entity_cache_path = 5; // Optional string secret_cache_path = 6; // Optional - oneof logger { // Optional - int64 log_action = 7; // See array_action in C header file for signature - int64 logger_provider_handle = 8; - } + proton.sdk.Telemetry telemetry = 7; // Optional } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. @@ -284,3 +282,76 @@ message StreamWriteRequest { int64 data_pointer = 1; int32 data_length = 2; } + +enum VolumeType { + VOLUME_TYPE_OWN_VOLUME = 0; + VOLUME_TYPE_SHARED = 1; + VOLUME_TYPE_SHARED_PUBLIC = 2; + VOLUME_TYPE_PHOTO = 3; +} + +enum DownloadError { + DOWNLOAD_ERROR_SERVER_ERROR = 0; + DOWNLOAD_ERROR_NETWORK_ERROR = 1; + DOWNLOAD_ERROR_DECRYPTION_ERROR = 2; + DOWNLOAD_ERROR_INTEGRITY_ERROR = 3; + DOWNLOAD_ERROR_RATE_LIMITED = 4; + DOWNLOAD_ERROR_HTTP_CLIENT_SIDE_ERROR = 5; + DOWNLOAD_ERROR_UNKNOWN = 6; +} + +enum UploadError { + UPLOAD_ERROR_SERVER_ERROR = 0; + UPLOAD_ERROR_NETWORK_ERROR = 1; + UPLOAD_ERROR_INTEGRITY_ERROR = 2; + UPLOAD_ERROR_RATE_LIMITED = 3; + UPLOAD_ERROR_HTTP_CLIENT_SIDE_ERROR = 4; + UPLOAD_ERROR_UNKNOWN = 5; +} + +enum EncryptedField { + ENCRYPTED_FIELD_SHARE_KEY = 0; + ENCRYPTED_FIELD_NODE_KEY = 1; + ENCRYPTED_FIELD_NODE_NAME = 2; + ENCRYPTED_FIELD_NODE_HASH_KEY = 3; + ENCRYPTED_FIELD_NODE_EXTENDED_ATTRIBUTES = 4; + ENCRYPTED_FIELD_NODE_CONTENT_KEY = 5; + ENCRYPTED_FIELD_CONTENT = 6; +} + +message UploadEventPayload { + VolumeType volume_type = 1; + int64 expected_size = 2; + int64 uploaded_size = 3; + UploadError error = 4; // Optional + string original_error = 5; // Optional +} + +message DownloadEventPayload { + VolumeType volume_type = 1; + int64 expected_size = 2; + int64 uploaded_size = 3; + DownloadError error = 4; // Optional + string original_error = 5; // Optional +} + +message DecryptionErrorEventPayload { + VolumeType volume_type = 1; + EncryptedField field = 2; + bool from_before_2024 = 3; + string error = 4; // Optional + string uid = 5; +} + +message VerificationErrorEventPayload { + VolumeType volume_type = 1; + EncryptedField field = 2; + bool from_before_2024 = 3; + bool address_matching_default_share = 4; + string error = 5; // Optional + string uid = 6; +} + +message BlockVerificationErrorEventPayload { + bool retry_helped = 1; +} diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto index 06f6f552..1325fec0 100644 --- a/cs/sdk/src/protos/proton.sdk.proto +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -120,6 +120,11 @@ message LogEvent { string category_name = 3; } +message MetricEvent { + string name = 1; + google.protobuf.Any payload = 2; +} + enum ProtonClientTlsPolicy { PROTON_CLIENT_TLS_POLICY_STRICT = 0; PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_PINNING = 1; @@ -147,12 +152,20 @@ enum UserType { USER_TYPE_EXTERNAL = 3; } +message Telemetry { + oneof logger { // Optional + int64 log_action = 1; // See array_action in C header file for signature + int64 logger_provider_handle = 2; + } + int64 record_metric_action = 3; // Optional, see array_action in C header file for signature +} + message ProtonClientOptions { string base_url = 1; // Optional string user_agent = 2; // Optional string bindings_language = 3; // Optional ProtonClientTlsPolicy tls_policy = 4; // Optional - int64 logger_provider_handle = 5; // Optional + Telemetry telemetry = 5; // Optional string entity_cache_path = 6; // Optional } @@ -217,3 +230,8 @@ message HttpResponse { repeated HttpHeader headers = 2; bytes content = 3; // Optional } + +message ApiRetrySucceededEventPayload { + string url = 1; + int32 failed_attempts = 2; +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index 3ab82417..3fdb90d9 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -129,7 +129,9 @@ extension Proton_Sdk_ProtonClientOptions { } if let loggerProviderHandle = clientOptions.loggerProviderHandle { - $0.loggerProviderHandle = Int64(loggerProviderHandle) + $0.telemetry = Proton_Sdk_Telemetry.with { + $0.loggerProviderHandle = Int64(loggerProviderHandle) + } } if let entityCachePath = clientOptions.entityCachePath { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index f4e41131..463ce26f 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -11,6 +11,7 @@ public actor ProtonDriveClient: Sendable { private var downloadManager: DownloadManager! private let logger: ProtonDriveSDK.Logger + private let recordMetricEventCallback: RecordMetricEventCallback let httpClient: HttpClientProtocol let accountClient: AccountClientProtocol @@ -21,9 +22,11 @@ public actor ProtonDriveClient: Sendable { secretCachePath: String? = nil, httpClient: HttpClientProtocol, accountClient: AccountClientProtocol, - logCallback: @escaping LogCallback + logCallback: @escaping LogCallback, + recordMetricEventCallback: @escaping RecordMetricEventCallback ) async throws { self.logger = try await Logger(logCallback: logCallback) + self.recordMetricEventCallback = recordMetricEventCallback self.httpClient = httpClient self.accountClient = accountClient @@ -33,7 +36,10 @@ public actor ProtonDriveClient: Sendable { $0.httpClientRequestAction = Int64(ObjectHandle(callback: cCompatibleHttpRequest)) $0.accountClientRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) - $0.logAction = Int64(ObjectHandle(callback: cCompatibleLogCallback)) + $0.telemetry = Proton_Sdk_Telemetry.with { + $0.logAction = Int64(ObjectHandle(callback: cCompatibleLogCallback)) + $0.recordMetricAction = Int64(ObjectHandle(callback: cCompatibleTelemetryRecordMetricCallback)) + } if let entityCachePath { $0.entityCachePath = entityCachePath @@ -59,6 +65,10 @@ public actor ProtonDriveClient: Sendable { nonisolated func log(_ logEvent: LogEvent) { logger.logCallback(logEvent) } + + nonisolated func record(_ metricEvent: MetricEvent) { + recordMetricEventCallback(metricEvent) + } public func downloadFile( revisionUid: SDKRevisionUid, diff --git a/swift/ProtonDriveSDK/Sources/Logging/Logger.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/Logging/Logger.swift rename to swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift diff --git a/swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/LoggerTypes.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/Logging/LoggerTypes.swift rename to swift/ProtonDriveSDK/Sources/TelemetryAndLogging/LoggerTypes.swift diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift new file mode 100644 index 00000000..5e8204f8 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift @@ -0,0 +1,27 @@ +import Foundation + +let cCompatibleTelemetryRecordMetricCallback: CCallback = { statePointer, byteArray in + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + return + } + + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let weakDriveClient = stateTypedPointer.takeUnretainedValue().state + + guard let driveClient = weakDriveClient.value else { + stateTypedPointer.release() + return + } + + let sdkMetricEvent = Proton_Sdk_MetricEvent(byteArray: byteArray) + do { + let metricEvent = try MetricEvent(sdkMetricEvent: sdkMetricEvent) + driveClient.record(metricEvent) + } catch { + let logEvent: LogEvent = .init( + level: .error, message: "Failed to parse Telemetry Record: \(error)", category: "Telemetry", + thread: Thread.current.number, file: #file, function: #function, line: #line + ) + driveClient.log(logEvent) + } +} diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift new file mode 100644 index 00000000..d8ba4044 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -0,0 +1,255 @@ +import Foundation + +public typealias RecordMetricEventCallback = @Sendable (MetricEvent) -> Void + +public enum MetricEvent: Sendable { + + case apiRetrySucceeded(ApiRetrySucceededEventPayload) + case blockVerificationError(BlockVerificationErrorEventPayload) + case decryptionError(DecryptionErrorEventPayload) + case download(DownloadEventPayload) + case upload(UploadEventPayload) + case verificationError(VerificationErrorEventPayload) + + case other(name: String) + + init(sdkMetricEvent: Proton_Sdk_MetricEvent) throws { + switch sdkMetricEvent.payload { + case let proto where proto.isA(Proton_Sdk_ApiRetrySucceededEventPayload.self): + let sdkPayload = try Proton_Sdk_ApiRetrySucceededEventPayload(unpackingAny: proto) + self = .apiRetrySucceeded(ApiRetrySucceededEventPayload(sdkEventPayload: sdkPayload)) + + case let proto where proto.isA(Proton_Drive_Sdk_BlockVerificationErrorEventPayload.self): + let sdkPayload = try Proton_Drive_Sdk_BlockVerificationErrorEventPayload(unpackingAny: proto) + self = .blockVerificationError(BlockVerificationErrorEventPayload(sdkEventPayload: sdkPayload)) + + case let proto where proto.isA(Proton_Drive_Sdk_DecryptionErrorEventPayload.self): + let sdkPayload = try Proton_Drive_Sdk_DecryptionErrorEventPayload(unpackingAny: proto) + self = .decryptionError(DecryptionErrorEventPayload(sdkEventPayload: sdkPayload)) + + case let proto where proto.isA(Proton_Drive_Sdk_DownloadEventPayload.self): + let sdkPayload = try Proton_Drive_Sdk_DownloadEventPayload(unpackingAny: proto) + self = .download(DownloadEventPayload(sdkDownloadEventPayload: sdkPayload)) + + case let proto where proto.isA(Proton_Drive_Sdk_UploadEventPayload.self): + let sdkPayload = try Proton_Drive_Sdk_UploadEventPayload(unpackingAny: proto) + self = .upload(UploadEventPayload(sdkUploadEventPayload: sdkPayload)) + + case let proto where proto.isA(Proton_Drive_Sdk_VerificationErrorEventPayload.self): + let sdkPayload = try Proton_Drive_Sdk_VerificationErrorEventPayload(unpackingAny: proto) + self = .verificationError(VerificationErrorEventPayload(sdkEventPayload: sdkPayload)) + + default: + self = .other(name: sdkMetricEvent.name) + } + } +} + +public struct ApiRetrySucceededEventPayload: Sendable { + + public let url: String + public let failedAttempts: Int + + init(sdkEventPayload: Proton_Sdk_ApiRetrySucceededEventPayload) { + self.url = sdkEventPayload.url + self.failedAttempts = Int(sdkEventPayload.failedAttempts) + } +} + +public struct BlockVerificationErrorEventPayload: Sendable { + + public let retryHelped: Bool + + init(sdkEventPayload: Proton_Drive_Sdk_BlockVerificationErrorEventPayload) { + self.retryHelped = sdkEventPayload.retryHelped + } +} + +public struct DecryptionErrorEventPayload: Sendable { + + public let volumeType: VolumeType + public let field: EncryptedField + public let fromBefore2024: Bool + public let error: String? + public let uid: String + + init(sdkEventPayload: Proton_Drive_Sdk_DecryptionErrorEventPayload) { + self.volumeType = .init(sdkVolumeType: sdkEventPayload.volumeType) + self.field = .init(sdkEncryptedField: sdkEventPayload.field) + self.fromBefore2024 = sdkEventPayload.fromBefore2024 + self.error = sdkEventPayload.hasError ? sdkEventPayload.error : nil + self.uid = sdkEventPayload.uid + } +} + +public struct DownloadEventPayload: Sendable { + + public let volumeType: VolumeType + public let expectedSize: Int64 + public let downloadedSize: Int64 + public let error: DownloadError? + public let originalError: String? + + init(sdkDownloadEventPayload: Proton_Drive_Sdk_DownloadEventPayload) { + self.volumeType = .init(sdkVolumeType: sdkDownloadEventPayload.volumeType) + self.expectedSize = sdkDownloadEventPayload.expectedSize + self.downloadedSize = sdkDownloadEventPayload.uploadedSize + self.error = sdkDownloadEventPayload.hasError ? .init(sdkDownloadError: sdkDownloadEventPayload.error) : nil + self.originalError = sdkDownloadEventPayload.hasOriginalError ? sdkDownloadEventPayload.originalError : nil + } +} + +public struct UploadEventPayload: Sendable { + + public let volumeType: VolumeType + public let expectedSize: Int64 + public let uploadedSize: Int64 + public let error: UploadError? + public let originalError: String? + + init(sdkUploadEventPayload: Proton_Drive_Sdk_UploadEventPayload) { + self.volumeType = .init(sdkVolumeType: sdkUploadEventPayload.volumeType) + self.expectedSize = sdkUploadEventPayload.expectedSize + self.uploadedSize = sdkUploadEventPayload.uploadedSize + self.error = sdkUploadEventPayload.hasError ? .init(sdkUploadError: sdkUploadEventPayload.error) : nil + self.originalError = sdkUploadEventPayload.hasOriginalError ? sdkUploadEventPayload.originalError : nil + } +} + +public struct VerificationErrorEventPayload: Sendable { + + public let volumeType: VolumeType + public let field: EncryptedField + public let fromBefore2024: Bool + public let addressMatchingDefaultShare: Bool + public let error: String? + public let uid: String + + init(sdkEventPayload: Proton_Drive_Sdk_VerificationErrorEventPayload) { + self.volumeType = .init(sdkVolumeType: sdkEventPayload.volumeType) + self.field = .init(sdkEncryptedField: sdkEventPayload.field) + self.fromBefore2024 = sdkEventPayload.fromBefore2024 + self.addressMatchingDefaultShare = sdkEventPayload.addressMatchingDefaultShare + self.error = sdkEventPayload.hasError ? sdkEventPayload.error : nil + self.uid = sdkEventPayload.uid + } +} + + +public enum VolumeType: Int, Sendable { + case unknown = -1 + case ownVolume = 0 + case shared = 1 + case sharedPublic = 2 + case photo = 3 + + init(sdkVolumeType: Proton_Drive_Sdk_VolumeType) { + switch sdkVolumeType { + case .ownVolume: + self = .ownVolume + case .shared: + self = .shared + case .sharedPublic: + self = .sharedPublic + case .photo: + self = .photo + case .UNRECOGNIZED(let value): + assertionFailure("Received unrecognized VolumeType from the SDK \(value)") + self = .unknown + } + } +} + +public enum EncryptedField: Int, Sendable { + case unknown = -1 + case shareKey = 0 + case nodeKey = 1 + case nodeName = 2 + case nodeHashKey = 3 + case nodeExtendedAttributes = 4 + case nodeContentKey = 5 + case content = 6 + + init(sdkEncryptedField: Proton_Drive_Sdk_EncryptedField) { + switch sdkEncryptedField { + case .shareKey: + self = .shareKey + case .nodeKey: + self = .nodeKey + case .nodeName: + self = .nodeName + case .nodeHashKey: + self = .nodeHashKey + case .nodeExtendedAttributes: + self = .nodeExtendedAttributes + case .nodeContentKey: + self = .nodeContentKey + case .content: + self = .content + case .UNRECOGNIZED(let value): + assertionFailure("Received unrecognized EncryptedField from the SDK \(value)") + self = .unknown + } + } +} + +public enum DownloadError: Int, Sendable { + case serverError = 0 + case networkError = 1 + case decryptionError = 2 + case integrityError = 3 + case rateLimited = 4 + case httpClientSideError = 5 + case unknown = 6 + + init(sdkDownloadError: Proton_Drive_Sdk_DownloadError) { + switch sdkDownloadError { + case .serverError: + self = .serverError + case .networkError: + self = .networkError + case .decryptionError: + self = .decryptionError + case .integrityError: + self = .integrityError + case .rateLimited: + self = .rateLimited + case .httpClientSideError: + self = .httpClientSideError + case .unknown: + self = .unknown + case .UNRECOGNIZED(let value): + assertionFailure("Received unrecognized DownloadError from the SDK \(value)") + self = .unknown + } + } +} + +public enum UploadError: Int, Sendable { + case serverError = 0 + case networkError = 1 + case integrityError = 2 + case rateLimited = 3 + case httpClientSideError = 4 + case unknown = 5 + + init(sdkUploadError: Proton_Drive_Sdk_UploadError) { + switch sdkUploadError { + case .serverError: + self = .serverError + case .networkError: + self = .networkError + case .integrityError: + self = .integrityError + case .rateLimited: + self = .rateLimited + case .httpClientSideError: + self = .httpClientSideError + case .unknown: + self = .unknown + case .UNRECOGNIZED(let value): + assertionFailure("Received unrecognized UploadError from the SDK \(value)") + self = .unknown + } + } +} From d12aefc99772ef1884e150aac0bdf860207a57c2 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Nov 2025 09:04:19 +0000 Subject: [PATCH 288/791] Update client creation through interop to be able to set client UID --- .../src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs | 2 +- cs/sdk/src/protos/proton.drive.sdk.proto | 4 ++++ .../Sources/ProtonDriveClient/ProtonDriveClient.swift | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index ae6d6474..106b8105 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -32,7 +32,7 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi ? new DriveInteropTelemetryDecorator(interopTelemetry) : NullTelemetry.Instance; - var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, telemetry); + var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, telemetry, request.Uid); return new Int64Value { diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index e08cbbc7..e8230ebc 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -135,6 +135,10 @@ message DriveClientCreateRequest { string secret_cache_path = 6; // Optional proton.sdk.Telemetry telemetry = 7; // Optional + + // Client UID, optional + // If a null value is provided, the SDK automatically generates a UUID during initialization + string uid = 8; } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 463ce26f..7f720677 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -22,6 +22,7 @@ public actor ProtonDriveClient: Sendable { secretCachePath: String? = nil, httpClient: HttpClientProtocol, accountClient: AccountClientProtocol, + clientUID: String?, logCallback: @escaping LogCallback, recordMetricEventCallback: @escaping RecordMetricEventCallback ) async throws { @@ -47,6 +48,9 @@ public actor ProtonDriveClient: Sendable { if let secretCachePath { $0.secretCachePath = secretCachePath } + if let clientUID { + $0.uid = clientUID + } } // we pass the weak reference as the state because we don't want the interop layer From e74e3700a79e2a878332c2ccbeedce6797c67a6f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Nov 2025 11:14:42 +0100 Subject: [PATCH 289/791] Add CI job to build and deploy Swift package --- .../Sources/Plumbing/SDKRequestHandler.swift | 8 ++++---- swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index d3d7e26e..e5dfd7d9 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -79,7 +79,7 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in do { switch response.result { case nil: // empty response. Might be expected, might be not expected - guard let voidBox = box as? Resumable else { + guard let voidBox = box as? any Resumable else { throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected empty response received")) } voidBox.resume() @@ -87,9 +87,9 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in case .value(let value) where value.isA(Google_Protobuf_Int64Value.self): let unpackedValue = try Google_Protobuf_Int64Value(unpackingAny: value).value switch box { - case let int64Box as Resumable: + case let int64Box as any Resumable: int64Box.resume(returning: unpackedValue) - case let intBox as Resumable: + case let intBox as any Resumable: intBox.resume(returning: Int(unpackedValue)) default: throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Google_Protobuf_Int64Value")) @@ -97,7 +97,7 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in case .value(let value) where value.isA(Proton_Drive_Sdk_UploadResult.self): let unpackedValue = try Proton_Drive_Sdk_UploadResult(unpackingAny: value) - guard let uploadResultBox = box as? Resumable else { + guard let uploadResultBox = box as? any Resumable else { throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Proton_Drive_Sdk_UploadResult")) } uploadResultBox.resume(returning: unpackedValue) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift index 23b379ab..1afa2cbf 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift @@ -142,8 +142,8 @@ public enum ProtonDriveSDKDataIntegrityError: LocalizedError { case nodeMetadata(message: String, part: NodeMetadataPart?, context: String?) case fileContents(message: String, context: String?) case uploadKeyMismatch(message: String, context: String?) - - public enum NodeMetadataPart: Int { + + public enum NodeMetadataPart: Int, Sendable { case key = 0 case passphrase = 1 case name = 2 From 77cb3c9a9f4bdedbcc4d0fd674d8856c4e1020ac Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Nov 2025 11:01:38 +0000 Subject: [PATCH 290/791] Add diagnostics for Photos timeline --- js/sdk/src/diagnostic/diagnostic.ts | 18 +- js/sdk/src/diagnostic/index.ts | 11 +- js/sdk/src/diagnostic/interface.ts | 17 +- js/sdk/src/diagnostic/nodeUtils.ts | 100 ++++++ ...{sdkDiagnostic.ts => sdkDiagnosticBase.ts} | 312 +++++++----------- js/sdk/src/diagnostic/sdkDiagnosticMain.ts | 95 ++++++ js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts | 70 ++++ 7 files changed, 420 insertions(+), 203 deletions(-) create mode 100644 js/sdk/src/diagnostic/nodeUtils.ts rename js/sdk/src/diagnostic/{sdkDiagnostic.ts => sdkDiagnosticBase.ts} (58%) create mode 100644 js/sdk/src/diagnostic/sdkDiagnosticMain.ts create mode 100644 js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts diff --git a/js/sdk/src/diagnostic/diagnostic.ts b/js/sdk/src/diagnostic/diagnostic.ts index 53da2209..93fd9cd8 100644 --- a/js/sdk/src/diagnostic/diagnostic.ts +++ b/js/sdk/src/diagnostic/diagnostic.ts @@ -1,8 +1,10 @@ import { MaybeNode } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; +import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; import { DiagnosticHTTPClient } from './httpClient'; import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult } from './interface'; -import { SDKDiagnostic } from './sdkDiagnostic'; +import { SDKDiagnosticMain } from './sdkDiagnosticMain'; +import { SDKDiagnosticPhotos } from './sdkDiagnosticPhotos'; import { DiagnosticTelemetry } from './telemetry'; import { zipGenerators } from './zipGenerators'; @@ -15,17 +17,19 @@ export class Diagnostic { private telemetry: DiagnosticTelemetry, private httpClient: DiagnosticHTTPClient, private protonDriveClient: ProtonDriveClient, + private protonDrivePhotosClient: ProtonDrivePhotosClient, ) { this.telemetry = telemetry; this.httpClient = httpClient; this.protonDriveClient = protonDriveClient; + this.protonDrivePhotosClient = protonDrivePhotosClient; } async *verifyMyFiles( options?: DiagnosticOptions, onProgress?: DiagnosticProgressCallback, ): AsyncGenerator { - const diagnostic = new SDKDiagnostic(this.protonDriveClient, options, onProgress); + const diagnostic = new SDKDiagnosticMain(this.protonDriveClient, options, onProgress); yield* this.yieldEvents(diagnostic.verifyMyFiles(options?.expectedStructure)); } @@ -34,10 +38,18 @@ export class Diagnostic { options?: DiagnosticOptions, onProgress?: DiagnosticProgressCallback, ): AsyncGenerator { - const diagnostic = new SDKDiagnostic(this.protonDriveClient, options, onProgress); + const diagnostic = new SDKDiagnosticMain(this.protonDriveClient, options, onProgress); yield* this.yieldEvents(diagnostic.verifyNodeTree(node, options?.expectedStructure)); } + async *verifyPhotosTimeline( + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator { + const diagnostic = new SDKDiagnosticPhotos(this.protonDrivePhotosClient, options, onProgress); + yield* this.yieldEvents(diagnostic.verifyTimeline(options?.expectedStructure)); + } + private async *yieldEvents(generator: AsyncGenerator): AsyncGenerator { yield* zipGenerators(generator, this.internalGenerator(), { stopOnFirstDone: true }); } diff --git a/js/sdk/src/diagnostic/index.ts b/js/sdk/src/diagnostic/index.ts index 2615b115..64a0bda8 100644 --- a/js/sdk/src/diagnostic/index.ts +++ b/js/sdk/src/diagnostic/index.ts @@ -5,6 +5,7 @@ import { Diagnostic as DiagnosticClass } from './diagnostic'; import { Diagnostic } from './interface'; import { DiagnosticHTTPClient } from './httpClient'; import { DiagnosticTelemetry } from './telemetry'; +import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; export type { Diagnostic, @@ -40,5 +41,13 @@ export function initDiagnostic( telemetry, }); - return new DiagnosticClass(telemetry, httpClient, protonDriveClient); + const protonDrivePhotosClient = new ProtonDrivePhotosClient({ + ...options, + httpClient, + entitiesCache: new NullCache(), + cryptoCache: new MemoryCache(), + telemetry, + }); + + return new DiagnosticClass(telemetry, httpClient, protonDriveClient, protonDrivePhotosClient); } diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index dfcd1d9f..8551ba02 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -11,10 +11,14 @@ export interface Diagnostic { options?: DiagnosticOptions, onProgress?: DiagnosticProgressCallback, ): AsyncGenerator; + verifyPhotosTimeline( + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator; } export type DiagnosticOptions = { - verifyContent?: boolean; + verifyContent?: boolean | 'peakOnly'; verifyThumbnails?: boolean; expectedStructure?: ExpectedTreeNode; }; @@ -22,11 +26,18 @@ export type DiagnosticOptions = { // Tree structure of the expected node tree. export type ExpectedTreeNode = { name: string; + expectedMediaType?: string; expectedSha1?: string; expectedSizeInBytes?: number; + // If expectedAuthors is provided, it will be used to verify authors. + // If it's a string, it will be used to verify all authors match the same email. + // If it's an object, it will be used to verify specific authors by type. + expectedAuthors?: ExpectedAuthor | { key?: ExpectedAuthor; name?: ExpectedAuthor; content?: ExpectedAuthor }; children?: ExpectedTreeNode[]; }; +export type ExpectedAuthor = string | 'anonymous'; + export type DiagnosticProgressCallback = (progress: { allNodesLoaded: boolean; loadedNodes: number; @@ -194,11 +205,13 @@ export type MetricResult = { export type NodeDetails = { safeNodeDetails: { nodeUid: string; - revisionUid?: string; + revisionUid: string | undefined; nodeType: NodeType; + mediaType: string | undefined; nodeCreationTime: Date; keyAuthor: Author; nameAuthor: Author; + contentAuthor: Author | undefined; errors: { field: string; error: unknown; diff --git a/js/sdk/src/diagnostic/nodeUtils.ts b/js/sdk/src/diagnostic/nodeUtils.ts new file mode 100644 index 00000000..2cdc0a80 --- /dev/null +++ b/js/sdk/src/diagnostic/nodeUtils.ts @@ -0,0 +1,100 @@ +import { MaybeNode, NodeType, Revision } from '../interface'; +import { + NodeDetails, + ExpectedTreeNode, +} from './interface'; + +export function getNodeDetails(node: MaybeNode): NodeDetails { + const errors: { + field: string; + error: unknown; + }[] = []; + + if (!node.ok) { + const degradedNode = node.error; + if (!degradedNode.name.ok) { + errors.push({ + field: 'name', + error: degradedNode.name.error, + }); + } + if (degradedNode.activeRevision?.ok === false) { + errors.push({ + field: 'activeRevision', + error: degradedNode.activeRevision.error, + }); + } + for (const error of degradedNode.errors ?? []) { + if (error instanceof Error) { + errors.push({ + field: 'error', + error, + }); + } + } + } + + return { + safeNodeDetails: { + ...getNodeUids(node), + nodeType: getNodeType(node), + mediaType: getMediaType(node), + nodeCreationTime: node.ok ? node.value.creationTime : node.error.creationTime, + keyAuthor: node.ok ? node.value.keyAuthor : node.error.keyAuthor, + nameAuthor: node.ok ? node.value.nameAuthor : node.error.nameAuthor, + contentAuthor: getActiveRevision(node)?.contentAuthor, + errors, + }, + sensitiveNodeDetails: node, + }; +} + +export function getNodeUids(node: MaybeNode): { nodeUid: string; revisionUid: string | undefined } { + const activeRevision = getActiveRevision(node); + return { + nodeUid: node.ok ? node.value.uid : node.error.uid, + revisionUid: activeRevision?.uid, + }; +} + +export function getNodeType(node: MaybeNode): NodeType { + return node.ok ? node.value.type : node.error.type; +} + +export function getMediaType(node: MaybeNode): string | undefined { + return node.ok ? node.value.mediaType : node.error.mediaType; +} + +export function getActiveRevision(node: MaybeNode): Revision | undefined { + if (node.ok) { + return node.value.activeRevision; + } + if (node.error.activeRevision?.ok) { + return node.error.activeRevision.value; + } + return undefined; +} + +export function getNodeName(node: MaybeNode): string { + if (node.ok) { + return node.value.name; + } + if (node.error.name.ok) { + return node.error.name.value; + } + return 'N/A'; +} + +export function getExpectedTreeNodeDetails(expectedNode: ExpectedTreeNode): ExpectedTreeNode { + return { + ...expectedNode, + children: undefined, + }; +} + +export function getTreeNodeChildByNodeName( + expectedSubtree: ExpectedTreeNode | undefined, + nodeName: string, +): ExpectedTreeNode | undefined { + return expectedSubtree?.children?.find((expectedNode) => expectedNode.name === nodeName); +} diff --git a/js/sdk/src/diagnostic/sdkDiagnostic.ts b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts similarity index 58% rename from js/sdk/src/diagnostic/sdkDiagnostic.ts rename to js/sdk/src/diagnostic/sdkDiagnosticBase.ts index c68c4918..5e0e5e46 100644 --- a/js/sdk/src/diagnostic/sdkDiagnostic.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts @@ -1,46 +1,53 @@ -import { Author, FileDownloader, MaybeNode, NodeType, Revision, ThumbnailType } from '../interface'; -import { ProtonDriveClient } from '../protonDriveClient'; +import { Author, FileDownloader, MaybeNode, NodeOrUid, NodeType, ThumbnailType, ThumbnailResult } from '../interface'; import { DiagnosticOptions, DiagnosticResult, - NodeDetails, ExpectedTreeNode, DiagnosticProgressCallback, } from './interface'; import { IntegrityVerificationStream } from './integrityVerificationStream'; -import { zipGenerators } from './zipGenerators'; +import { + getNodeType, + getNodeDetails, + getActiveRevision, + getMediaType, + getExpectedTreeNodeDetails, + getNodeName, +} from './nodeUtils'; const PROGRESS_REPORT_INTERVAL = 500; +interface SDKClient { + getFileDownloader(nodeOrUid: NodeOrUid): Promise; + iterateThumbnails(nodeUids: string[], thumbnailType: ThumbnailType): AsyncGenerator; +} + /** - * Diagnostic tool that uses SDK to traverse the node tree and verify - * the integrity of the node tree. - * - * It produces only events that can be read by direct SDK invocation. - * To get the full diagnostic, use {@link FullSDKDiagnostic}. + * Base class for all SDK diagnostic tools that verifies the integrity of + * the individual nodes. */ -export class SDKDiagnostic { +export class SDKDiagnosticBase { private options: Pick; private onProgress?: DiagnosticProgressCallback; private progressReportInterval: NodeJS.Timeout | undefined; - private nodesQueue: { node: MaybeNode; expected?: ExpectedTreeNode }[] = []; - private allNodesLoaded: boolean = false; - private loadedNodes: number = 0; - private checkedNodes: number = 0; + protected nodesQueue: { node: MaybeNode; expected?: ExpectedTreeNode }[] = []; + protected allNodesLoaded: boolean = false; + protected loadedNodes: number = 0; + protected checkedNodes: number = 0; constructor( - private protonDriveClient: ProtonDriveClient, + private sdkClient: SDKClient, options?: Pick, onProgress?: DiagnosticProgressCallback, ) { - this.protonDriveClient = protonDriveClient; + this.sdkClient = sdkClient; this.options = options || { verifyContent: false, verifyThumbnails: false }; this.onProgress = onProgress; } - private startProgress(): void { + protected startProgress(): void { this.allNodesLoaded = false; this.loadedNodes = 0; this.checkedNodes = 0; @@ -51,7 +58,7 @@ export class SDKDiagnostic { }, PROGRESS_REPORT_INTERVAL); } - private finishProgress(): void { + protected finishProgress(): void { if (this.progressReportInterval) { clearInterval(this.progressReportInterval); this.progressReportInterval = undefined; @@ -68,81 +75,7 @@ export class SDKDiagnostic { }); } - async *verifyMyFiles(expectedStructure?: ExpectedTreeNode): AsyncGenerator { - let myFilesRootFolder: MaybeNode; - - try { - myFilesRootFolder = await this.protonDriveClient.getMyFilesRootFolder(); - } catch (error: unknown) { - yield { - type: 'fatal_error', - message: `Error getting my files root folder`, - error, - }; - return; - } - - yield* this.verifyNodeTree(myFilesRootFolder, expectedStructure); - } - - async *verifyNodeTree(node: MaybeNode, expectedStructure?: ExpectedTreeNode): AsyncGenerator { - this.startProgress(); - this.nodesQueue.push({ node, expected: expectedStructure }); - this.loadedNodes++; - yield* zipGenerators(this.loadNodeTree(node, expectedStructure), this.verifyNodesQueue()); - this.finishProgress(); - } - - private async *loadNodeTree( - parentNode: MaybeNode, - expectedStructure?: ExpectedTreeNode, - ): AsyncGenerator { - const isFolder = getNodeType(parentNode) === NodeType.Folder; - if (isFolder) { - yield* this.loadNodeTreeRecursively(parentNode, expectedStructure); - } - this.allNodesLoaded = true; - } - - private async *loadNodeTreeRecursively( - parentNode: MaybeNode, - expectedStructure?: ExpectedTreeNode, - ): AsyncGenerator { - const parentNodeUid = parentNode.ok ? parentNode.value.uid : parentNode.error.uid; - const children: MaybeNode[] = []; - - try { - for await (const child of this.protonDriveClient.iterateFolderChildren(parentNode)) { - children.push(child); - this.nodesQueue.push({ - node: child, - expected: getTreeNodeChildByNodeName(expectedStructure, getNodeName(child)), - }); - this.loadedNodes++; - } - } catch (error: unknown) { - yield { - type: 'sdk_error', - call: `iterateFolderChildren(${parentNodeUid})`, - error, - }; - } - - if (expectedStructure) { - yield* this.verifyExpectedNodeChildren(parentNodeUid, children, expectedStructure); - } - - for (const child of children) { - if (getNodeType(child) === NodeType.Folder) { - yield* this.loadNodeTreeRecursively( - child, - getTreeNodeChildByNodeName(expectedStructure, getNodeName(child)), - ); - } - } - } - - private async *verifyExpectedNodeChildren( + protected async *verifyExpectedNodeChildren( parentNodeUid: string, children: MaybeNode[], expectedStructure?: ExpectedTreeNode, @@ -177,7 +110,7 @@ export class SDKDiagnostic { } } - private async *verifyNodesQueue(): AsyncGenerator { + protected async *verifyNodesQueue(): AsyncGenerator { while (this.nodesQueue.length > 0 || !this.allNodesLoaded) { const result = this.nodesQueue.shift(); if (result) { @@ -190,10 +123,7 @@ export class SDKDiagnostic { } } - private async *verifyNode( - node: MaybeNode, - expectedStructure?: ExpectedTreeNode, - ): AsyncGenerator { + private async *verifyNode(node: MaybeNode, expectedStructure?: ExpectedTreeNode): AsyncGenerator { if (!node.ok) { yield { type: 'degraded_node', @@ -201,25 +131,48 @@ export class SDKDiagnostic { }; } - yield* this.verifyAuthor(node.ok ? node.value.keyAuthor : node.error.keyAuthor, 'key', node); - yield* this.verifyAuthor(node.ok ? node.value.nameAuthor : node.error.nameAuthor, 'name', node); + yield* this.verifyAuthor(node.ok ? node.value.keyAuthor : node.error.keyAuthor, 'key', node, expectedStructure); + yield* this.verifyAuthor( + node.ok ? node.value.nameAuthor : node.error.nameAuthor, + 'name', + node, + expectedStructure, + ); const activeRevision = getActiveRevision(node); if (activeRevision) { - yield* this.verifyAuthor(activeRevision.contentAuthor, 'content', node); + yield* this.verifyAuthor(activeRevision.contentAuthor, 'content', node, expectedStructure); } yield* this.verifyFileExtendedAttributes(node, expectedStructure); - if (this.options.verifyContent) { + if (this.options.verifyContent === 'peakOnly') { + yield* this.verifyContentPeak(node); + } else if (this.options.verifyContent) { yield* this.verifyContent(node); } if (this.options.verifyThumbnails) { yield* this.verifyThumbnails(node); } + + if (expectedStructure?.expectedMediaType) { + const mediaType = getMediaType(node); + if (mediaType !== expectedStructure.expectedMediaType) { + yield { + type: 'expected_structure_integrity_error', + expectedNode: getExpectedTreeNodeDetails(expectedStructure), + ...getNodeDetails(node), + }; + } + } } - private async *verifyAuthor(author: Author, authorType: string, node: MaybeNode): AsyncGenerator { + private async *verifyAuthor( + author: Author, + authorType: 'key' | 'name' | 'content', + node: MaybeNode, + expectedStructure?: ExpectedTreeNode, + ): AsyncGenerator { if (!author.ok) { yield { type: 'unverified_author', @@ -229,6 +182,26 @@ export class SDKDiagnostic { ...getNodeDetails(node), }; } + + if (expectedStructure?.expectedAuthors) { + let expectedEmail: string | null | undefined = + typeof expectedStructure.expectedAuthors === 'string' + ? expectedStructure.expectedAuthors + : expectedStructure.expectedAuthors[authorType]; + + if (expectedEmail === 'anonymous') { + expectedEmail = null; + } + + const email = author.ok ? author.value : author.error.claimedAuthor; + if (expectedEmail !== undefined && email !== expectedEmail) { + yield { + type: 'expected_structure_integrity_error', + expectedNode: getExpectedTreeNodeDetails(expectedStructure), + ...getNodeDetails(node), + }; + } + } } private async *verifyFileExtendedAttributes( @@ -278,6 +251,42 @@ export class SDKDiagnostic { } } + private async *verifyContentPeak(node: MaybeNode): AsyncGenerator { + if (getNodeType(node) !== NodeType.File) { + return; + } + + let downloader: FileDownloader; + try { + downloader = await this.sdkClient.getFileDownloader(node); + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `getFileDownloader(${node.ok ? node.value.uid : node.error.uid})`, + error, + }; + return; + } + + try { + const stream = downloader.getSeekableStream(); + const peak = await stream.read(1024); + if (peak.value.length === 0) { + yield { + type: 'content_download_error', + error: new Error('No data read'), + ...getNodeDetails(node), + }; + } + } catch (error: unknown) { + yield { + type: 'content_download_error', + error, + ...getNodeDetails(node), + }; + } + } + private async *verifyContent(node: MaybeNode): AsyncGenerator { if (getNodeType(node) !== NodeType.File) { return; @@ -293,11 +302,11 @@ export class SDKDiagnostic { let downloader: FileDownloader; try { - downloader = await this.protonDriveClient.getFileRevisionDownloader(activeRevision.uid); + downloader = await this.sdkClient.getFileDownloader(node); } catch (error: unknown) { yield { type: 'sdk_error', - call: `getFileRevisionDownloader(${activeRevision.uid})`, + call: `getFileDownloader(${node.ok ? node.value.uid : node.error.uid})`, error, }; return; @@ -341,9 +350,7 @@ export class SDKDiagnostic { const nodeUid = node.ok ? node.value.uid : node.error.uid; try { - const result = await Array.fromAsync( - this.protonDriveClient.iterateThumbnails([nodeUid], ThumbnailType.Type1), - ); + const result = await Array.fromAsync(this.sdkClient.iterateThumbnails([nodeUid], ThumbnailType.Type1)); if (result.length === 0) { yield { @@ -369,92 +376,3 @@ export class SDKDiagnostic { } } } - -function getNodeDetails(node: MaybeNode): NodeDetails { - const errors: { - field: string; - error: unknown; - }[] = []; - - if (!node.ok) { - const degradedNode = node.error; - if (!degradedNode.name.ok) { - errors.push({ - field: 'name', - error: degradedNode.name.error, - }); - } - if (degradedNode.activeRevision?.ok === false) { - errors.push({ - field: 'activeRevision', - error: degradedNode.activeRevision.error, - }); - } - for (const error of degradedNode.errors ?? []) { - if (error instanceof Error) { - errors.push({ - field: 'error', - error, - }); - } - } - } - - return { - safeNodeDetails: { - ...getNodeUids(node), - nodeType: getNodeType(node), - nodeCreationTime: node.ok ? node.value.creationTime : node.error.creationTime, - keyAuthor: node.ok ? node.value.keyAuthor : node.error.keyAuthor, - nameAuthor: node.ok ? node.value.nameAuthor : node.error.nameAuthor, - errors, - }, - sensitiveNodeDetails: node, - }; -} - -function getNodeUids(node: MaybeNode): { nodeUid: string; revisionUid?: string } { - const activeRevision = getActiveRevision(node); - return { - nodeUid: node.ok ? node.value.uid : node.error.uid, - revisionUid: activeRevision?.uid, - }; -} - -function getNodeType(node: MaybeNode): NodeType { - return node.ok ? node.value.type : node.error.type; -} - -function getActiveRevision(node: MaybeNode): Revision | undefined { - if (node.ok) { - return node.value.activeRevision; - } - if (node.error.activeRevision?.ok) { - return node.error.activeRevision.value; - } - return undefined; -} - -function getNodeName(node: MaybeNode): string { - if (node.ok) { - return node.value.name; - } - if (node.error.name.ok) { - return node.error.name.value; - } - return 'N/A'; -} - -function getExpectedTreeNodeDetails(expectedNode: ExpectedTreeNode): ExpectedTreeNode { - return { - ...expectedNode, - children: undefined, - }; -} - -function getTreeNodeChildByNodeName( - expectedSubtree: ExpectedTreeNode | undefined, - nodeName: string, -): ExpectedTreeNode | undefined { - return expectedSubtree?.children?.find((expectedNode) => expectedNode.name === nodeName); -} diff --git a/js/sdk/src/diagnostic/sdkDiagnosticMain.ts b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts new file mode 100644 index 00000000..409cd8d8 --- /dev/null +++ b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts @@ -0,0 +1,95 @@ +import { MaybeNode, NodeType } from '../interface'; +import { ProtonDriveClient } from '../protonDriveClient'; +import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult, ExpectedTreeNode } from './interface'; +import { zipGenerators } from './zipGenerators'; +import { getNodeType, getNodeName, getTreeNodeChildByNodeName } from './nodeUtils'; +import { SDKDiagnosticBase } from './sdkDiagnosticBase'; + +/** + * Diagnostic tool that uses the main Drive SDK to traverse and verify + * the integrity of the node tree. + */ +export class SDKDiagnosticMain extends SDKDiagnosticBase { + constructor( + private protonDriveClient: ProtonDriveClient, + options?: Pick, + onProgress?: DiagnosticProgressCallback, + ) { + super(protonDriveClient, options, onProgress); + this.protonDriveClient = protonDriveClient; + } + + async *verifyMyFiles(expectedStructure?: ExpectedTreeNode): AsyncGenerator { + let myFilesRootFolder: MaybeNode; + + try { + myFilesRootFolder = await this.protonDriveClient.getMyFilesRootFolder(); + } catch (error: unknown) { + yield { + type: 'fatal_error', + message: `Error getting my files root folder`, + error, + }; + return; + } + + yield* this.verifyNodeTree(myFilesRootFolder, expectedStructure); + } + + async *verifyNodeTree(node: MaybeNode, expectedStructure?: ExpectedTreeNode): AsyncGenerator { + this.startProgress(); + this.nodesQueue.push({ node, expected: expectedStructure }); + this.loadedNodes++; + yield* zipGenerators(this.loadNodeTree(node, expectedStructure), this.verifyNodesQueue()); + this.finishProgress(); + } + + private async *loadNodeTree( + parentNode: MaybeNode, + expectedStructure?: ExpectedTreeNode, + ): AsyncGenerator { + const isFolder = getNodeType(parentNode) === NodeType.Folder; + if (isFolder) { + yield* this.loadNodeTreeRecursively(parentNode, expectedStructure); + } + this.allNodesLoaded = true; + } + + private async *loadNodeTreeRecursively( + parentNode: MaybeNode, + expectedStructure?: ExpectedTreeNode, + ): AsyncGenerator { + const parentNodeUid = parentNode.ok ? parentNode.value.uid : parentNode.error.uid; + const children: MaybeNode[] = []; + + try { + for await (const child of this.protonDriveClient.iterateFolderChildren(parentNode)) { + children.push(child); + this.nodesQueue.push({ + node: child, + expected: getTreeNodeChildByNodeName(expectedStructure, getNodeName(child)), + }); + this.loadedNodes++; + } + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `iterateFolderChildren(${parentNodeUid})`, + error, + }; + } + + if (expectedStructure) { + yield* this.verifyExpectedNodeChildren(parentNodeUid, children, expectedStructure); + } + + for (const child of children) { + if (getNodeType(child) === NodeType.Folder) { + yield* this.loadNodeTreeRecursively( + child, + getTreeNodeChildByNodeName(expectedStructure, getNodeName(child)), + ); + } + } + } +} diff --git a/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts new file mode 100644 index 00000000..10458fbb --- /dev/null +++ b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts @@ -0,0 +1,70 @@ +import { MaybeNode } from '../interface'; +import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; +import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult, ExpectedTreeNode } from './interface'; +import { zipGenerators } from './zipGenerators'; +import { getNodeName, getTreeNodeChildByNodeName } from './nodeUtils'; +import { SDKDiagnosticBase } from './sdkDiagnosticBase'; + +/** + * Diagnostic tool that uses the Photos SDK to traverse and verify + * the integrity of the Photos in the timeline. + */ +export class SDKDiagnosticPhotos extends SDKDiagnosticBase { + constructor( + private protonDrivePhotosClient: ProtonDrivePhotosClient, + options?: Pick, + onProgress?: DiagnosticProgressCallback, + ) { + super(protonDrivePhotosClient, options, onProgress); + this.protonDrivePhotosClient = protonDrivePhotosClient; + } + + async *verifyTimeline(expectedStructure?: ExpectedTreeNode): AsyncGenerator { + this.startProgress(); + yield* zipGenerators(this.loadTimeline(expectedStructure), this.verifyNodesQueue()); + this.finishProgress(); + } + + private async *loadTimeline(expectedStructure?: ExpectedTreeNode): AsyncGenerator { + let nodeUids: string[] = []; + try { + const results = await Array.fromAsync(this.protonDrivePhotosClient.iterateTimeline()); + nodeUids = results.map((result) => result.nodeUid); + this.loadedNodes = nodeUids.length; + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `iterateTimeline()`, + error, + }; + } + + const photos: MaybeNode[] = []; + try { + for await (const maybeMissingNode of this.protonDrivePhotosClient.iterateNodes(nodeUids)) { + if (!maybeMissingNode.ok && 'missingUid' in maybeMissingNode.error) { + continue; + } + const maybeNode = maybeMissingNode as MaybeNode; + + photos.push(maybeNode); + this.nodesQueue.push({ + node: maybeNode, + expected: getTreeNodeChildByNodeName(expectedStructure, getNodeName(maybeNode)), + }); + } + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `iterateNodes(...)`, + error, + }; + } + + if (expectedStructure) { + yield* this.verifyExpectedNodeChildren('photo-timeline', photos, expectedStructure); + } + + this.allNodesLoaded = true; + } +} From f312e27a9e126675e8d5e66cd5d5465074de2bc2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 11 Nov 2025 14:21:55 +0100 Subject: [PATCH 291/791] Expose cancellation support in SDK bindings --- .../FileOperations/DownloadManager.swift | 65 ++++----- .../FileOperations/UploadManager.swift | 123 ++++++++---------- .../Sources/Plumbing/SDKRequestHandler.swift | 16 +-- .../ProtonDriveClient/ProtonDriveClient.swift | 18 ++- 4 files changed, 104 insertions(+), 118 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift index a24751ab..a07dc3d3 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift @@ -2,29 +2,41 @@ import Foundation /// Handles file download operations for ProtonDrive public actor DownloadManager { + enum Error: Swift.Error { + case noCancellationTokenForIdentifier + } private let clientHandle: ObjectHandle private let logger: Logger? - private var downloads: [ObjectHandle: DownloadManager] = [:] + private var activeDownloads: [UUID: CancellationTokenSource] = [:] init(clientHandle: ObjectHandle, logger: Logger?) { self.clientHandle = clientHandle self.logger = logger } + deinit { + activeDownloads.values.forEach { + $0.free() + } + } + /// Download file from file URL with complete download flow public func downloadFile( revisionUid: SDKRevisionUid, destinationUrl: URL, + cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeDownloads[cancellationToken] = cancellationTokenSource + defer { - // TODO: Should be done in deinit! + activeDownloads[cancellationToken] = nil cancellationTokenSource.free() } - let downloaderHandle = try await startFileDownload( + let downloaderHandle = try await buildFileDownloader( revisionUid: revisionUid.sdkCompatibleIdentifier, fileURL: destinationUrl, cancellationHandle: cancellationTokenSource.handle @@ -49,6 +61,7 @@ public actor DownloadManager { logger: logger ) assert(downloadControllerHandle != 0) + defer { freeDownloadController(downloadControllerHandle) } @@ -56,8 +69,19 @@ public actor DownloadManager { try await awaitDownloadCompletion(downloadControllerHandle) } + func cancelDownload(with cancellationToken: UUID) async throws { + guard let downloadCancellationToken = activeDownloads[cancellationToken] else { + throw Error.noCancellationTokenForIdentifier + } + + try await downloadCancellationToken.cancel() + try await downloadCancellationToken.free() + + activeDownloads[cancellationToken] = nil + } + /// Get a file downloader for downloading files from Drive - private func startFileDownload( + private func buildFileDownloader( revisionUid: String, fileURL: URL, cancellationHandle: ObjectHandle @@ -83,39 +107,6 @@ public actor DownloadManager { try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void } - /// Pause download controllers - public func pauseDownloads() async throws { - downloads.keys.forEach { downloadControllerHandle in - Task { - let pauseRequest = Proton_Drive_Sdk_DownloadControllerPauseRequest.with { - $0.downloadControllerHandle = Int64(downloadControllerHandle) - } - - try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void - } - } - } - - /// Resume download controllers - public func resumeDownloads() async throws { - downloads.keys.forEach { downloadControllerHandle in - Task { - let pauseRequest = Proton_Drive_Sdk_DownloadControllerResumeRequest.with { - $0.downloadControllerHandle = Int64(downloadControllerHandle) - } - - try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void - } - } - } - - /// Free download controller when no longer needed - private func freeDownloadControllers() { - downloads.keys.forEach { downloadControllerHandle in - freeDownloadController(downloadControllerHandle) - } - } - /// Free a file downloader when no longer needed private func freeDownloader(_ fileDownloaderHandle: ObjectHandle) { Task { diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift index a616d172..7f744f83 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift @@ -3,43 +3,23 @@ import SwiftProtobuf /// Handles file upload operations for ProtonDrive public actor UploadManager { + enum Error: Swift.Error { + case noCancellationTokenForIdentifier + } private let clientHandle: ObjectHandle private let logger: Logger? - private var uploads: [ObjectHandle: UploadManager] = [:] + private var activeUploads: [UUID: CancellationTokenSource] = [:] init(clientHandle: ObjectHandle, logger: Logger?) { self.clientHandle = clientHandle self.logger = logger } - private func startFileUpload( - parentFolderUid: String, - name: String, - mediaType: String, - fileSize: Int64, - modificationDate: Date, - overrideExistingDraft: Bool = false, - cancellationHandle: ObjectHandle? = nil, - logger: Logger? - ) async throws -> ObjectHandle { - let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileUploaderRequest.with { - $0.clientHandle = Int64(clientHandle) - $0.parentFolderUid = parentFolderUid - $0.name = name - $0.mediaType = mediaType - $0.size = fileSize - $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) - $0.overrideExistingDraftByOtherClient = overrideExistingDraft - - if let cancellationHandle = cancellationHandle { - $0.cancellationTokenSourceHandle = Int64(cancellationHandle) - } + deinit { + activeUploads.values.forEach { + $0.free() } - - let uploaderHandle: ObjectHandle = try await SDKRequestHandler.send(uploaderRequest, logger: logger) - assert(uploaderHandle != 0) - return uploaderHandle } /// Upload file from file URL with complete upload flow @@ -52,17 +32,20 @@ public actor UploadManager { mediaType: String, thumbnails: [ThumbnailData] = [], overrideExistingDraft: Bool = false, + cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> FileNodeUploadResult { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeUploads[cancellationToken] = cancellationTokenSource + defer { - // TODO: Should be done in deinit! + activeUploads[cancellationToken] = cancellationTokenSource cancellationTokenSource.free() } let cancellationHandle = cancellationTokenSource.handle - let uploaderHandle = try await startFileUpload( + let uploaderHandle = try await buildFileUploader( parentFolderUid: parentFolderUid.sdkCompatibleIdentifier, name: name, mediaType: mediaType, @@ -103,8 +86,47 @@ public actor UploadManager { ) assert(uploadControllerHandle != 0) - let uploadedNode = try await awaitUploadCompletion(uploadControllerHandle) - return uploadedNode + return try await awaitUploadCompletion(uploadControllerHandle) + } + + func cancelUpload(with cancellationToken: UUID) async throws { + guard let uploadCancellationToken = activeUploads[cancellationToken] else { + throw Error.noCancellationTokenForIdentifier + } + + try await uploadCancellationToken.cancel() + try await uploadCancellationToken.free() + + activeUploads[cancellationToken] = nil + } + + private func buildFileUploader( + parentFolderUid: String, + name: String, + mediaType: String, + fileSize: Int64, + modificationDate: Date, + overrideExistingDraft: Bool = false, + cancellationHandle: ObjectHandle? = nil, + logger: Logger? + ) async throws -> ObjectHandle { + let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileUploaderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.parentFolderUid = parentFolderUid + $0.name = name + $0.mediaType = mediaType + $0.size = fileSize + $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + $0.overrideExistingDraftByOtherClient = overrideExistingDraft + + if let cancellationHandle = cancellationHandle { + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + } + + let uploaderHandle: ObjectHandle = try await SDKRequestHandler.send(uploaderRequest, logger: logger) + assert(uploaderHandle != 0) + return uploaderHandle } /// Wait for upload completion @@ -131,43 +153,4 @@ public actor UploadManager { try await SDKRequestHandler.send(freeRequest, logger: logger) as Void } } - - /// Pause upload controllers - public func pauseUploads() async throws { - uploads.keys.forEach { uploadControllerHandle in - Task { - let pauseRequest = Proton_Drive_Sdk_UploadControllerPauseRequest.with { - $0.uploadControllerHandle = Int64(uploadControllerHandle) - } - - try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void - } - } - } - - /// Resume upload controllers - public func resumeUploads() async throws { - uploads.keys.forEach { uploadControllerHandle in - Task { - let pauseRequest = Proton_Drive_Sdk_UploadControllerResumeRequest.with { - $0.uploadControllerHandle = Int64(uploadControllerHandle) - } - - try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void - } - } - } - - /// Free upload controller when no longer needed - private func freeUploadControllers() { - uploads.keys.forEach { uploadControllerHandle in - Task { - let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { - $0.uploadControllerHandle = Int64(uploadControllerHandle) - } - - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void - } - } - } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index e5dfd7d9..59bec8b7 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -6,12 +6,12 @@ import SwiftProtobuf enum SDKRequestHandler { // MARK: - Simple requests (without state) - + static func sendInteropRequest(_ request: T, logger: Logger?) async throws -> T.CallResultType where T.StateType == Void { try await send(request, logger: logger) } - + static func send(_ request: T, logger: Logger?) async throws -> U { try await send(request, state: (), logger: logger) } @@ -25,7 +25,7 @@ enum SDKRequestHandler { ) async throws -> T.CallResultType { try await self.send(request, state: state, includesLongLivedCallback: includesLongLivedCallback, logger: logger) } - + static func send( _ request: T, state: V, includesLongLivedCallback: Bool = false, logger: Logger? ) async throws -> U { @@ -33,7 +33,7 @@ enum SDKRequestHandler { let envelopedRequestData = try request.packIntoRequest().serializedData() let isDriveRequest = request.isDriveRequest logger?.trace("Sending SDK message with state: \(T.protoMessageName) - \(request)", category: "SDKRequestHandler") - + let response: U = try await withCheckedThrowingContinuation { continuation in let requestArray = ByteArray(data: envelopedRequestData) defer { @@ -83,7 +83,7 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected empty response received")) } voidBox.resume() - + case .value(let value) where value.isA(Google_Protobuf_Int64Value.self): let unpackedValue = try Google_Protobuf_Int64Value(unpackingAny: value).value switch box { @@ -94,7 +94,7 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in default: throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Google_Protobuf_Int64Value")) } - + case .value(let value) where value.isA(Proton_Drive_Sdk_UploadResult.self): let unpackedValue = try Proton_Drive_Sdk_UploadResult(unpackingAny: value) guard let uploadResultBox = box as? any Resumable else { @@ -104,14 +104,14 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in case .value(let value) where value.isA(Google_Protobuf_StringValue.self): let unpackedValue = try Google_Protobuf_StringValue(unpackingAny: value) - guard let stringResultBox = box as? Resumable else { + guard let stringResultBox = box as? any Resumable else { throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: String")) } stringResultBox.resume(returning: unpackedValue.value) case .value: // unknown value type throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unknown SDK call response value type")) - + case .error(let error): throw ProtonDriveSDKError(protoError: error) } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 7f720677..9a82d2f6 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -6,7 +6,7 @@ import Foundation public actor ProtonDriveClient: Sendable { private var clientHandle: ObjectHandle! - + private var uploadManager: UploadManager! private var downloadManager: DownloadManager! @@ -52,7 +52,7 @@ public actor ProtonDriveClient: Sendable { $0.uid = clientUID } } - + // we pass the weak reference as the state because we don't want the interop layer // to prolong the client object existence let weakSelf = WeakReference(value: self) @@ -69,7 +69,7 @@ public actor ProtonDriveClient: Sendable { nonisolated func log(_ logEvent: LogEvent) { logger.logCallback(logEvent) } - + nonisolated func record(_ metricEvent: MetricEvent) { recordMetricEventCallback(metricEvent) } @@ -77,15 +77,21 @@ public actor ProtonDriveClient: Sendable { public func downloadFile( revisionUid: SDKRevisionUid, destinationUrl: URL, + cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws { try await downloadManager.downloadFile( revisionUid: revisionUid, destinationUrl: destinationUrl, + cancellationToken: cancellationToken, progressCallback: progressCallback ) } + public func cancelDownload(cancellationToken: UUID) async throws { + try await downloadManager.cancelDownload(with: cancellationToken) + } + public func uploadFile( parentFolderUid: SDKNodeUid, name: String, @@ -94,6 +100,7 @@ public actor ProtonDriveClient: Sendable { modificationDate: Date, mediaType: String, thumbnails: [ThumbnailData], + cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> FileNodeUploadResult { try await uploadManager.uploadFile( @@ -104,6 +111,7 @@ public actor ProtonDriveClient: Sendable { modificationDate: modificationDate, mediaType: mediaType, thumbnails: thumbnails, + cancellationToken: cancellationToken, progressCallback: progressCallback ) } @@ -131,6 +139,10 @@ public actor ProtonDriveClient: Sendable { return nameResult } + public func cancelUpload(cancellationToken: UUID) async throws { + try await uploadManager.cancelUpload(with: cancellationToken) + } + static func unbox(callbackPointer: Int, releaseBox: () -> Void, weakDriveClient: WeakReference) -> ProtonDriveClient? { guard let driveClient = weakDriveClient.value else { releaseBox() From e7c17f99945043c071b7ffa3f0cb624f1106efec Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 12 Nov 2025 11:56:54 +0100 Subject: [PATCH 292/791] Refresh node when share already exists --- .../sharing/sharingManagement.test.ts | 60 ++++++++++++++++++- .../src/internal/sharing/sharingManagement.ts | 47 ++++++++++----- 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index e7b7211b..e941183c 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -1,5 +1,6 @@ import { getMockLogger } from '../../tests/logger'; import { + Logger, Member, MemberRole, NonProtonInvitation, @@ -14,8 +15,11 @@ import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; import { SharesService, NodesService } from './interface'; import { SharingManagement } from './sharingManagement'; +import { ValidationError } from '../../errors'; +import { ErrorCode } from '../apiService'; describe('SharingManagement', () => { + let logger: Logger; let apiService: SharingAPIService; let cache: SharingCache; let cryptoService: SharingCryptoService; @@ -26,6 +30,8 @@ describe('SharingManagement', () => { let sharingManagement: SharingManagement; beforeEach(() => { + logger = getMockLogger(); + // @ts-expect-error No need to implement all methods for mocking apiService = { createStandardShare: jest.fn().mockReturnValue('newShareId'), @@ -110,7 +116,7 @@ describe('SharingManagement', () => { }; sharingManagement = new SharingManagement( - getMockLogger(), + logger, apiService, cache, cryptoService, @@ -225,6 +231,58 @@ describe('SharingManagement', () => { expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(nodeUid); expect(cache.addSharedByMeNodeUid).toHaveBeenCalledWith(nodeUid); }); + + it('should refresh node info if share already exists', async () => { + nodesService.getNode = jest + .fn() + .mockImplementationOnce((nodeUid) => ({ + nodeUid, + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + })) + .mockImplementation((nodeUid) => ({ + nodeUid, + shareId: 'shareId', + parentUid: 'parentUid', + name: { ok: true, value: 'name' }, + })); + apiService.createStandardShare = jest + .fn() + .mockRejectedValue(new ValidationError('Share already exists', ErrorCode.ALREADY_EXISTS)); + + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ['email'] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [ + { + uid: 'created-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'viewer', + }, + ], + nonProtonInvitations: [], + members: [], + publicLink: undefined, + }); + + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(nodeUid); + expect(logger.debug).toHaveBeenCalledWith( + 'Share already exists for node volumeId~nodeUid, refreshing node', + ); + expect(apiService.inviteProtonUser).toHaveBeenCalledWith( + 'shareId', + { + addedByEmail: 'volume-email', + inviteeEmail: 'email', + role: 'viewer', + }, + { + message: undefined, + nodeName: undefined, + }, + ); + }); }); describe('shareNode with share re-use', () => { diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 140b6993..d5421e03 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -15,6 +15,7 @@ import { ProtonDriveAccount, SharePublicLinkSettingsObject, } from '../../interface'; +import { ErrorCode } from '../apiService'; import { splitNodeUid } from '../uids'; import { getErrorMessage } from '../errors'; import { SharingAPIService } from './apiService'; @@ -147,22 +148,40 @@ export class SharingManagement { throw new ValidationError(c('Error').t`Expiration date cannot be in the past`); } - let contextShareAddress: ContextShareAddress; + let contextShareAddress: ContextShareAddress | undefined; let currentSharing = await this.getInternalSharingInfo(nodeUid); - if (currentSharing) { - contextShareAddress = await this.nodesService.getRootNodeEmailKey(nodeUid); - } else { + if (!currentSharing) { const node = await this.nodesService.getNode(nodeUid); - const result = await this.createShare(nodeUid); - currentSharing = { - share: result.share, - nodeName: node.name.ok ? node.name.value : node.name.error.name, - protonInvitations: [], - nonProtonInvitations: [], - members: [], - publicLink: undefined, - }; - contextShareAddress = result.contextShareAddress; + try { + const result = await this.createShare(nodeUid); + currentSharing = { + share: result.share, + nodeName: node.name.ok ? node.name.value : node.name.error.name, + protonInvitations: [], + nonProtonInvitations: [], + members: [], + publicLink: undefined, + }; + contextShareAddress = result.contextShareAddress; + } catch (error: unknown) { + // If the share already exists, notify that the node has + // changed to force refresh and get the latest sharing info + // again. + if (error instanceof ValidationError && error.code === ErrorCode.ALREADY_EXISTS) { + this.logger.debug(`Share already exists for node ${nodeUid}, refreshing node`); + await this.nodesService.notifyNodeChanged(nodeUid); + currentSharing = await this.getInternalSharingInfo(nodeUid); + } else { + throw error; + } + } + } + + if (!currentSharing) { + throw new ValidationError(c('Error').t`Failed to get sharing info for node ${nodeUid}`); + } + if (!contextShareAddress) { + contextShareAddress = await this.nodesService.getRootNodeEmailKey(nodeUid); } const emailOptions: EmailOptions = { From f479a3f882f6549689a655b841909066743617ea Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 12 Nov 2025 11:41:45 +0100 Subject: [PATCH 293/791] Fix blocks not being released during download --- .../src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index b0757eab..7f4facd4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -12,6 +12,7 @@ private FileDownloader(ProtonDriveClient client, RevisionUid revisionUid) { _client = client; _revisionUid = revisionUid; + _remainingNumberOfBlocksToList = 1; } public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) @@ -73,7 +74,11 @@ private void ReleaseBlockListing(int numberOfBlockListings) { var newRemainingNumberOfBlocks = Interlocked.Add(ref _remainingNumberOfBlocksToList, -numberOfBlockListings); - var amountToRelease = Math.Max(newRemainingNumberOfBlocks >= 0 ? numberOfBlockListings : newRemainingNumberOfBlocks + numberOfBlockListings, 0); + var amountToRelease = Math.Max( + newRemainingNumberOfBlocks >= 0 + ? numberOfBlockListings + : newRemainingNumberOfBlocks + numberOfBlockListings, + 0); _client.BlockListingSemaphore.Release(amountToRelease); LogReleasedBlockListingSemaphore(_client.Logger, _revisionUid, amountToRelease); From e516db4ff34559c24344df346db5e09373dc6c64 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 13 Nov 2025 16:54:06 +0100 Subject: [PATCH 294/791] Pass node name conflict error data through interop --- .../InteropDriveErrorConverter.cs | 25 +++- .../InteropProtonDriveClient.cs | 5 + cs/sdk/src/protos/proton.drive.sdk.proto | 6 + cs/sdk/src/protos/proton.sdk.proto | 2 + .../FileOperations/UploadManager.swift | 140 +++++++++++++----- .../Sources/Plumbing/Message+Packaging.swift | 5 + .../ProtonDriveClient/ProtonDriveClient.swift | 18 +++ .../AdditionalErrorData.swift | 32 ++++ .../ProtonDriveSDKError.swift | 14 +- 9 files changed, 211 insertions(+), 36 deletions(-) create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift rename swift/ProtonDriveSDK/Sources/{ => ProtonDriveSDKError}/ProtonDriveSDKError.swift (97%) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs index 5e822e02..6e25cd68 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -1,4 +1,5 @@ -using Proton.Drive.Sdk.Nodes.Download; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk.CExports; @@ -33,6 +34,28 @@ public static void SetDomainAndCodes(Error error, Exception exception) error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode; break; + case NodeWithSameNameExistsException e: + error.Domain = ErrorDomain.BusinessLogic; + + var additionalData = new NodeNameConflictErrorData(); + if (e.ConflictingNodeIsFileDraft is { } conflictingNodeIsFileDraft) + { + additionalData.ConflictingNodeIsFileDraft = conflictingNodeIsFileDraft; + } + + if (e.ConflictingNodeUid is { } conflictingNodeUid) + { + additionalData.ConflictingNodeUid = conflictingNodeUid.ToString(); + } + + if (e.ConflictingRevisionUid is { } conflictingRevisionUid) + { + additionalData.ConflictingRevisionUid = conflictingRevisionUid.ToString(); + } + + error.AdditionalData = Any.Pack(additionalData); + break; + default: InteropErrorConverter.SetDomainAndCodes(error, exception); break; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 106b8105..cdf2261c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -12,6 +12,11 @@ internal static class InteropProtonDriveClient { public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindingsHandle) { + if (!request.BaseUrl.EndsWith('/')) + { + throw new UriFormatException("Base URL must end with a '/'"); + } + var httpClientFactory = new InteropHttpClientFactory( bindingsHandle, request.BaseUrl, diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index e8230ebc..0dd94e20 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -359,3 +359,9 @@ message VerificationErrorEventPayload { message BlockVerificationErrorEventPayload { bool retry_helped = 1; } + +message NodeNameConflictErrorData { + bool conflicting_node_is_file_draft = 1; + string conflicting_node_uid = 2; + string conflicting_revision_uid = 3; +} diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto index 1325fec0..8db4a4e3 100644 --- a/cs/sdk/src/protos/proton.sdk.proto +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -183,6 +183,7 @@ enum ErrorDomain { Serialization = 5; Cryptography = 6; DataIntegrity = 7; + BusinessLogic = 8; } message Error { @@ -193,6 +194,7 @@ message Error { int64 secondary_code = 5; // Optional string context = 6; // Optional Error inner_error = 7; // Optional + google.protobuf.Any additional_data = 8; // Optional } message Address { diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift index 7f744f83..f30ee89d 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift @@ -60,33 +60,46 @@ public actor UploadManager { freeFileUploader(uploaderHandle) } - let uploaderRequest = Proton_Drive_Sdk_UploadFromFileRequest.with { - $0.uploaderHandle = Int64(uploaderHandle) - $0.filePath = fileURL.path(percentEncoded: false) - $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) - $0.cancellationTokenSourceHandle = Int64(cancellationHandle) - $0.thumbnails = thumbnails.map { thumbnail in - Proton_Drive_Sdk_Thumbnail.with { - $0.type = thumbnail.type == .thumbnail ? .thumbnail : .preview - $0.contentLength = Int64(thumbnail.data.count) - let dataHandle = thumbnail.data.withUnsafeBytes { (u8Ptr: UnsafePointer) in - return ObjectHandle(bitPattern: UnsafeRawPointer(u8Ptr)) - } - $0.contentPointer = Int64(dataHandle) - } - } + let uploadedNode = try await uploadFromFile( + uploaderHandle: uploaderHandle, + fileURL: fileURL, + progressCallback: progressCallback, + cancellationHandle: cancellationHandle, + thumbnails: thumbnails + ) + return uploadedNode + } + + public func uploadNewRevision( + currentActiveRevisionUid: SDKRevisionUid, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + thumbnails: [ThumbnailData], + progressCallback: @escaping ProgressCallback + ) async throws -> FileNodeUploadResult { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + defer { + // TODO: Should be done in deinit! + cancellationTokenSource.free() } - let callbackState = ProgressCallbackWrapper(callback: progressCallback) - let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( - uploaderRequest, - state: WeakReference(value: callbackState), - includesLongLivedCallback: true, - logger: logger - ) - assert(uploadControllerHandle != 0) + let cancellationHandle = cancellationTokenSource.handle - return try await awaitUploadCompletion(uploadControllerHandle) + let uploaderHandle = try await getRevisionUploader( + currentActiveRevisionUid: currentActiveRevisionUid, + fileSize: fileSize, + modificationDate: modificationDate, + cancellationHandle: cancellationHandle + ) + let uploadedNode = try await uploadFromFile( + uploaderHandle: uploaderHandle, + fileURL: fileURL, + progressCallback: progressCallback, + cancellationHandle: cancellationHandle, + thumbnails: thumbnails + ) + return uploadedNode } func cancelUpload(with cancellationToken: UUID) async throws { @@ -99,7 +112,10 @@ public actor UploadManager { activeUploads[cancellationToken] = nil } +} +// MARK: - Revision uploader +extension UploadManager { private func buildFileUploader( parentFolderUid: String, name: String, @@ -129,18 +145,62 @@ public actor UploadManager { return uploaderHandle } - /// Wait for upload completion - private func awaitUploadCompletion(_ uploadControllerHandle: ObjectHandle) async throws -> FileNodeUploadResult { - assert(uploadControllerHandle != 0) - let awaitUploadCompletionRequest = Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest.with { - $0.uploadControllerHandle = Int64(uploadControllerHandle) + private func getRevisionUploader( + currentActiveRevisionUid: SDKRevisionUid, + fileSize: Int64, + modificationDate: Date, + cancellationHandle: ObjectHandle? = nil + ) async throws -> ObjectHandle { + let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileRevisionUploaderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.currentActiveRevisionUid = currentActiveRevisionUid.sdkCompatibleIdentifier + $0.size = fileSize + $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + if let cancellationHandle = cancellationHandle { + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } } - let uploadResult: Proton_Drive_Sdk_UploadResult = try await SDKRequestHandler.send(awaitUploadCompletionRequest, logger: logger) - guard let result = FileNodeUploadResult(interopUploadResult: uploadResult) else { - throw ProtonDriveSDKError(interopError: .wrongResult(message: "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)")) + let uploaderHandle: ObjectHandle = try await SDKRequestHandler.send(uploaderRequest, logger: logger) + assert(uploaderHandle != 0) + return uploaderHandle + } + + private func uploadFromFile( + uploaderHandle: ObjectHandle, + fileURL: URL, + progressCallback: @escaping ProgressCallback, + cancellationHandle: ObjectHandle, + thumbnails: [ThumbnailData] + ) async throws -> FileNodeUploadResult { + let uploaderRequest = Proton_Drive_Sdk_UploadFromFileRequest.with { + $0.uploaderHandle = Int64(uploaderHandle) + $0.filePath = fileURL.path(percentEncoded: false) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + $0.thumbnails = thumbnails.map { thumbnail in + Proton_Drive_Sdk_Thumbnail.with { + $0.type = thumbnail.type == .thumbnail ? .thumbnail : .preview + $0.contentLength = Int64(thumbnail.data.count) + let dataHandle = thumbnail.data.withUnsafeBytes { (u8Ptr: UnsafePointer) in + return ObjectHandle(bitPattern: UnsafeRawPointer(u8Ptr)) + } + $0.contentPointer = Int64(dataHandle) + } + } } - return result + + let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + uploaderRequest, + state: WeakReference(value: callbackState), + includesLongLivedCallback: true, + logger: logger + ) + assert(uploadControllerHandle != 0) + + let uploadedNode = try await awaitUploadCompletion(uploadControllerHandle) + return uploadedNode } /// Free a file uploader when no longer needed @@ -153,4 +213,18 @@ public actor UploadManager { try await SDKRequestHandler.send(freeRequest, logger: logger) as Void } } + + /// Wait for upload completion + private func awaitUploadCompletion(_ uploadControllerHandle: ObjectHandle) async throws -> FileNodeUploadResult { + assert(uploadControllerHandle != 0) + let awaitUploadCompletionRequest = Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest.with { + $0.uploadControllerHandle = Int64(uploadControllerHandle) + } + + let uploadResult: Proton_Drive_Sdk_UploadResult = try await SDKRequestHandler.send(awaitUploadCompletionRequest, logger: logger) + guard let result = FileNodeUploadResult(interopUploadResult: uploadResult) else { + throw ProtonDriveSDKError(interopError: .wrongResult(message: "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)")) + } + return result + } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 9e0dfba5..662ef655 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -103,6 +103,11 @@ extension Message { $0.payload = .downloadControllerFree(request) } + case let request as Proton_Drive_Sdk_DriveClientGetFileRevisionUploaderRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientGetFileRevisionUploader(request) + } + default: assertionFailure("Unknown request") throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown request type: \(self)")) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 9a82d2f6..f358af2b 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -116,6 +116,24 @@ public actor ProtonDriveClient: Sendable { ) } + public func uploadNewRevision( + currentActiveRevisionUid: SDKRevisionUid, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + thumbnails: [ThumbnailData], + progressCallback: @escaping ProgressCallback + ) async throws -> FileNodeUploadResult { + try await uploadManager.uploadNewRevision( + currentActiveRevisionUid: currentActiveRevisionUid, + fileURL: fileURL, + fileSize: fileSize, + modificationDate: modificationDate, + thumbnails: thumbnails, + progressCallback: progressCallback + ) + } + public func getAvailableName( parentFolderUid: SDKNodeUid, name: String diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift new file mode 100644 index 00000000..9e2c4bf7 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift @@ -0,0 +1,32 @@ +import Foundation +import SwiftProtobuf + +struct AdditionalErrorDataFactory { + func make(data: Google_Protobuf_Any) -> AdditionalErrorData? { + return NodeNameConflictErrorData(data: data) +// ?? SomeOtherErrorData(data: data) + } +} + +public protocol AdditionalErrorData: Sendable { } + +public struct NodeNameConflictErrorData: AdditionalErrorData { + public let isFileDraft: Bool + /// Conflicting node UID + public let nodeUID: SDKNodeUid? + /// Conflicting revision UID + public let revisionUID: SDKRevisionUid? + + init?(data: Google_Protobuf_Any) { + do { + let errorData = try Proton_Drive_Sdk_NodeNameConflictErrorData(unpackingAny: data) + self.isFileDraft = errorData.hasConflictingNodeIsFileDraft ? errorData.conflictingNodeIsFileDraft : false + let nodeUIDStr = errorData.hasConflictingNodeUid ? errorData.conflictingNodeUid : "" + self.nodeUID = SDKNodeUid(sdkCompatibleIdentifier: nodeUIDStr) + let revisionUIDStr = errorData.hasConflictingRevisionUid ? errorData.conflictingRevisionUid : "" + self.revisionUID = SDKRevisionUid(sdkCompatibleIdentifier: revisionUIDStr) + } catch { + return nil + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift similarity index 97% rename from swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift rename to swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift index 1afa2cbf..cd61cf56 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift @@ -14,7 +14,8 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { case serialization case cryptography case dataIntegrity - + case businessLogic + // Interop domains case interop @@ -31,6 +32,8 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { case .UNRECOGNIZED(let int): assertionFailure("Received unexpected error domain value \(int)") self = .undefined + case .businessLogic: + self = .businessLogic } } } @@ -74,7 +77,8 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { public let secondaryCode: Int? public let context: String? public var innerError: ProtonDriveSDKError? { innerErrorBox?.innerError } - + public let additionalErrorData: AdditionalErrorData? + private let innerErrorBox: InnerErrorBox? init(protoError: Proton_Sdk_Error) { @@ -88,6 +92,11 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { self.secondaryCode = protoError.hasSecondaryCode ? Int(protoError.secondaryCode) : nil self.context = protoError.hasContext ? protoError.context : nil self.innerErrorBox = protoError.hasInnerError ? InnerErrorBox(protoError: protoError.innerError) : nil + if protoError.hasAdditionalData { + self.additionalErrorData = AdditionalErrorDataFactory().make(data: protoError.additionalData) + } else { + self.additionalErrorData = nil + } } init(interopError: InteropErrorTypes) { @@ -98,6 +107,7 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { self.secondaryCode = nil self.context = nil self.innerErrorBox = nil + self.additionalErrorData = nil } } From d6d109adad21de49fc3a093ca536af369156bfbe Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 18 Nov 2025 08:46:20 +0000 Subject: [PATCH 295/791] Add empty and thumbnail file uploads for cross client --- cs/Directory.Packages.props | 1 + 1 file changed, 1 insertion(+) diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 6f19b08c..aa470a65 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -27,6 +27,7 @@ + From 851c43c203fb20a481bd168e3d49041b09f284ff Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 18 Nov 2025 15:26:35 +0100 Subject: [PATCH 296/791] Add method to download thumbnails --- .../InteropFileUploader.cs | 4 +- .../InteropMessageHandler.cs | 3 + .../InteropProtonDriveClient.cs | 20 +++- .../Api/Files/ActiveRevisionDto.cs | 2 +- .../Api/Files/FilesApiClient.cs | 15 +++ .../Api/Files/IFilesApiClient.cs | 4 +- .../Proton.Drive.Sdk/Api/Files/Thumbnail.cs | 16 --- .../Api/Files/ThumbnailBlock.cs | 2 +- .../Api/Files/ThumbnailBlockListRequest.cs | 9 ++ .../Api/Files/ThumbnailBlockListResponse.cs | 10 ++ .../Api/Files/ThumbnailDto.cs | 4 +- .../Api/Files/ThumbnailDtoV2.cs | 2 +- .../Nodes/Download/BlockDownloader.cs | 9 +- .../Nodes/DtoToMetadataConverter.cs | 10 +- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 100 +++++++++++++++++- .../Proton.Drive.Sdk/Nodes/FileThumbnail.cs | 3 + .../Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs | 36 +++++++ .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 32 ++++++ cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 2 +- .../Proton.Drive.Sdk/Nodes/ThumbnailHeader.cs | 3 + .../Nodes/Upload/RevisionWriter.cs | 12 ++- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 7 ++ .../DriveApiSerializerContext.cs | 2 + .../Shares/ShareOperations.cs | 7 ++ cs/sdk/src/protos/proton.drive.sdk.proto | 22 +++- .../DownloadThumbnailsManager.swift | 64 +++++++++++ .../FileOperations/UploadManager.swift | 4 +- .../Sources/Plumbing/Message+Packaging.swift | 4 + .../Sources/Plumbing/PublicTypes.swift | 29 +++++ .../Sources/Plumbing/SDKRequestHandler.swift | 7 ++ .../ProtonDriveClient/ProtonDriveClient.swift | 14 +++ 31 files changed, 415 insertions(+), 43 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/Thumbnail.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListRequest.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailHeader.cs create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 88054501..7ded46b8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -20,7 +20,7 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n unsafe { var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; - return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.ContentPointer, (nint)t.ContentLength).ToArray()); + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); } }); @@ -46,7 +46,7 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint unsafe { var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; - return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.ContentPointer, (nint)t.ContentLength).ToArray()); + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); } }); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 2a5ab1b2..d4c739eb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -46,6 +46,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientGetAvailableName => await InteropProtonDriveClient.HandleGetAvailableNameAsync(request.DriveClientGetAvailableName).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientGetThumbnails + => await InteropProtonDriveClient.HandleGetThumbnailsAsync(request.DriveClientGetThumbnails).ConfigureAwait(false), + Request.PayloadOneofCase.UploadFromStream => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, bindingsHandle), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index cdf2261c..13aecd4b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -98,7 +98,25 @@ public static async ValueTask HandleGetAvailableNameAsync(DriveClientG request.Name, cancellationToken).ConfigureAwait(false); - return new StringValue { Value = availableName };; + return new StringValue { Value = availableName }; + } + + public static async ValueTask HandleGetThumbnailsAsync(DriveClientGetThumbnailsRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var thumbnails = await client.EnumerateThumbnailsAsync(request.FileUids.Select(NodeUid.Parse), cancellationToken) + .Select(x => new FileThumbnail + { + FileUid = x.FileUid.ToString(), + Type = (ThumbnailType)x.Type, + Data = ByteString.CopyFrom(x.Data.Span), + }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return new FileThumbnailList { Thumbnails = { thumbnails } }; } public static async ValueTask HandleGetFileDownloaderAsync(DriveClientGetFileDownloaderRequest request) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs index 484e31e0..9f48eef4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs @@ -21,7 +21,7 @@ internal sealed class ActiveRevisionDto [JsonPropertyName("XAttr")] public PgpArmoredMessage? ExtendedAttributes { get; init; } - public IReadOnlyList? Thumbnails { get; init; } + public required IReadOnlyList Thumbnails { get; init; } [JsonPropertyName("SignatureEmail")] public string? SignatureEmailAddress { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index 3f2d1ffa..b58d4d76 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -81,4 +81,19 @@ public async ValueTask DeleteRevisionAsync(VolumeId volumeId, LinkI .DeleteAsync($"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}", cancellationToken) .ConfigureAwait(false); } + + public async ValueTask GetThumbnailBlocksAsync( + VolumeId volumeId, + IEnumerable thumbnailIds, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ThumbnailBlockListResponse) + .PostAsync( + $"volumes/{volumeId}/thumbnails", + new ThumbnailBlockListRequest { ThumbnailIds = thumbnailIds }, + DriveApiSerializerContext.Default.ThumbnailBlockListRequest, + cancellationToken) + .ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs index 0b4e978e..6682d29d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -23,7 +23,7 @@ ValueTask UpdateRevisionAsync( RevisionUpdateRequest request, CancellationToken cancellationToken); - public ValueTask GetRevisionAsync( + ValueTask GetRevisionAsync( VolumeId volumeId, LinkId linkId, RevisionId revisionId, @@ -32,5 +32,7 @@ public ValueTask GetRevisionAsync( bool withoutBlockUrls, CancellationToken cancellationToken); + ValueTask GetThumbnailBlocksAsync(VolumeId volumeId, IEnumerable thumbnailIds, CancellationToken cancellationToken); + ValueTask DeleteRevisionAsync(VolumeId volumeId, LinkId linkId, RevisionId revisionId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Thumbnail.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Thumbnail.cs deleted file mode 100644 index e53fe61e..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/Thumbnail.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Proton.Drive.Sdk.Api.Files; - -internal sealed class Thumbnail -{ - [JsonPropertyName("ThumbnailID")] - public required string Id { get; init; } - - public required int Type { get; init; } - - [JsonPropertyName("BaseURL")] - public required ReadOnlyMemory HashDigest { get; init; } - - public required int Size { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs index fa04393d..7216959e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk.Api.Files; internal sealed class ThumbnailBlock { [JsonPropertyName("ThumbnailID")] - public required string Id { get; init; } + public required string ThumbnailId { get; init; } [JsonPropertyName("BareURL")] public required string BareUrl { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListRequest.cs new file mode 100644 index 00000000..ad5ef850 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal struct ThumbnailBlockListRequest +{ + [JsonPropertyName("ThumbnailIDs")] + public required IEnumerable ThumbnailIds { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs new file mode 100644 index 00000000..ed7cbffd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailBlockListResponse : ApiResponse +{ + [JsonPropertyName("Thumbnails")] + public required IReadOnlyList Blocks { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs index 7d68a4d1..f3740959 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk.Api.Files; internal sealed class ThumbnailDto { [JsonPropertyName("ThumbnailID")] - public string? Id { get; init; } + public required string Id { get; init; } public required ThumbnailType Type { get; init; } @@ -13,5 +13,5 @@ internal sealed class ThumbnailDto public required ReadOnlyMemory HashDigest { get; init; } [JsonPropertyName("Size")] - public required int StorageQuotaUsage { get; init; } + public required int SizeOnCloudStorage { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs index eee9cc3a..dc54aae1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk.Api.Files; internal sealed class ThumbnailDtoV2 { [JsonPropertyName("ThumbnailID")] - public string? Id { get; init; } + public required string Id { get; init; } public required ThumbnailType Type { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index 86314b85..e16905c1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Cryptography; namespace Proton.Drive.Sdk.Nodes.Download; @@ -26,11 +27,11 @@ public async ValueTask> DownloadAsync( Stream outputStream, CancellationToken cancellationToken) { - using var sha256 = SHA256.Create(); + using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); var blobStream = await _client.Api.Storage.GetBlobStreamAsync(bareUrl, token, cancellationToken).ConfigureAwait(false); - var hashingStream = new CryptoStream(blobStream, sha256, CryptoStreamMode.Read); + var hashingStream = new HashingReadStream(blobStream, sha256); try { @@ -49,8 +50,6 @@ public async ValueTask> DownloadAsync( throw new FileContentsDecryptionException(e); } - sha256.TransformFinalBlock([], 0, 0); - - return sha256.Hash; + return sha256.GetCurrentHash(); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 27bb39f1..448258dd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -226,6 +226,14 @@ public static async Task> ConvertDtoT var extendedAttributes = extendedAttributesOutput.Data; + var thumbnails = activeRevisionDto.Thumbnails.Count > 0 ? new ThumbnailHeader[activeRevisionDto.Thumbnails.Count] : []; + + for (var i = 0; i < activeRevisionDto.Thumbnails.Count; ++i) + { + var thumbnailDto = activeRevisionDto.Thumbnails[i]; + thumbnails[i] = new ThumbnailHeader(thumbnailDto.Id, (ThumbnailType)thumbnailDto.Type); + } + var activeRevision = new Revision { Uid = new RevisionUid(uid, activeRevisionDto.Id), @@ -234,7 +242,7 @@ public static async Task> ConvertDtoT ClaimedSize = extendedAttributes?.Common?.Size, ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, - Thumbnails = [], // FIXME: thumbnails + Thumbnails = thumbnails.AsReadOnly(), ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure), }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 3244d384..db149048 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -1,8 +1,11 @@ -using Proton.Sdk; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; -internal static class FileOperations +internal static partial class FileOperations { public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) { @@ -20,4 +23,97 @@ public static async ValueTask GetSecretsAsync(ProtonDriveClient cli return fileSecrets; } + + public static async IAsyncEnumerable EnumerateThumbnailsAsync( + ProtonDriveClient client, + IEnumerable fileUids, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + // TODO: optimize parallelization for when UIDs are scattered over many volumes + foreach (var volumeLinkIdGroup in fileUids.GroupBy(uid => uid.VolumeId, uid => uid.LinkId)) + { + var volumeId = volumeLinkIdGroup.Key; + + var nodeResults = NodeOperations.EnumerateNodesAsync(client, volumeId, volumeLinkIdGroup, cancellationToken); + + var thumbnailIds = await nodeResults + .Select(nodeResult => nodeResult.TryGetValue(out var node) ? node as FileNode : null) + .Where(fileNode => fileNode is not null) + .SelectMany(fileNode => + { + var thumbnails = fileNode!.ActiveRevision.Thumbnails; + if (thumbnails.Count == 0) + { + LogNoThumbnailOnNode(client.Logger, fileNode.Uid); + } + + return thumbnails.Select(thumbnail => (thumbnail.Id, thumbnail.Type, Node: fileNode)).ToAsyncEnumerable(); + }) + .ToDictionaryAsync(thumbnail => thumbnail.Id, thumbnail => (thumbnail.Type, NodeUid: thumbnail.Node), cancellationToken) + .ConfigureAwait(false); + + if (thumbnailIds.Count == 0) + { + continue; + } + + var response = await client.Api.Files.GetThumbnailBlocksAsync(volumeId, thumbnailIds.Keys, cancellationToken).ConfigureAwait(false); + + var tasks = new Queue>(); + foreach (var block in response.Blocks) + { + var (type, fileNode) = thumbnailIds[block.ThumbnailId]; + + if (!await client.ThumbnailBlockDownloader.BlockSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + if (tasks.Count > 0) + { + yield return await tasks.Dequeue().ConfigureAwait(false); + } + + await client.ThumbnailBlockDownloader.BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + tasks.Enqueue(DownloadThumbnailAsync(client, fileNode.Uid, type, block, cancellationToken)); + } + + while (tasks.TryDequeue(out var task)) + { + yield return await task.ConfigureAwait(false); + } + } + } + + private static async Task DownloadThumbnailAsync( + ProtonDriveClient client, + NodeUid fileUid, + ThumbnailType thumbnailType, + ThumbnailBlock block, + CancellationToken cancellationToken) + { + const int initialBufferLength = 64 * 1024; + + try + { + var outputStream = new MemoryStream(initialBufferLength); + await using (outputStream.ConfigureAwait(false)) + { + var fileSecrets = await GetSecretsAsync(client, fileUid, cancellationToken).ConfigureAwait(false); + + await client.ThumbnailBlockDownloader.DownloadAsync(block.BareUrl, block.Token, fileSecrets.ContentKey, outputStream, cancellationToken) + .ConfigureAwait(false); + + var thumbnailData = outputStream.TryGetBuffer(out var outputBuffer) ? outputBuffer : outputStream.ToArray(); + + return new FileThumbnail(fileUid, thumbnailType, thumbnailData); + } + } + finally + { + client.ThumbnailBlockDownloader.BlockSemaphore.Release(); + } + } + + [LoggerMessage(Level = LogLevel.Trace, Message = "No thumbnail on node {NodeUid}")] + private static partial void LogNoThumbnailOnNode(ILogger logger, NodeUid nodeUid); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs new file mode 100644 index 00000000..0cd6bbb0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed record FileThumbnail(NodeUid FileUid, ThumbnailType Type, ReadOnlyMemory Data); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs new file mode 100644 index 00000000..75c47158 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs @@ -0,0 +1,36 @@ +using System.Runtime.InteropServices; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class NodeBatchLoader(ProtonDriveClient client, VolumeId volumeId) : BatchLoaderBase> +{ + private readonly ProtonDriveClient _client = client; + + protected override async ValueTask>> LoadBatchAsync( + ReadOnlyMemory ids, + CancellationToken cancellationToken) + { + var nodeResults = new List>(ids.Length); + + var response = await _client.Api.Links.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); + + foreach (var linkDetails in response.Links) + { + var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + _client, + volumeId, + linkDetails, + knownShareAndKey: null, + cancellationToken).ConfigureAwait(false); + + var nodeResult = nodeMetadataResult.ToNodeResult(); + + nodeResults.Add(nodeResult); + } + + return nodeResults; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 71748fed..66c09f82 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using Proton.Cryptography.Pgp; @@ -56,6 +57,37 @@ public static async ValueTask GetNodeMetadataAsync( return metadataResult.Value.GetValueOrThrow(); } + public static async IAsyncEnumerable> EnumerateNodesAsync( + ProtonDriveClient client, + VolumeId volumeId, + IEnumerable linkIds, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var batchLoader = new NodeBatchLoader(client, volumeId); + + foreach (var linkId in linkIds) + { + var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(new NodeUid(volumeId, linkId), cancellationToken).ConfigureAwait(false); + + if (cachedChildNodeInfo is null) + { + foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + else + { + yield return cachedChildNodeInfo.Value.NodeProvisionResult; + } + } + + foreach (var nodeResult in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + public static void GetCommonCreationParameters( string name, PgpPrivateKey parentFolderKey, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index b533330b..a450c49f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -10,6 +10,6 @@ public sealed record Revision public long? ClaimedSize { get; init; } public required FileContentDigests ClaimedDigests { get; init; } public DateTime? ClaimedModificationTime { get; init; } - public required IReadOnlyList> Thumbnails { get; init; } + public required IReadOnlyList Thumbnails { get; init; } public Result? ContentAuthor { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailHeader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailHeader.cs new file mode 100644 index 00000000..84c84767 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailHeader.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes; + +public record struct ThumbnailHeader(string Id, ThumbnailType Type); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 88804438..b0b4a3d1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -81,6 +81,8 @@ public async ValueTask WriteAsync( ArraySegment manifestSignature; var blockSizes = new List(8); + var contentLength = contentStream.Length - contentStream.Position; + using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); var hashingContentStream = new HashingReadStream(contentStream, sha1, leaveOpen: true); @@ -119,7 +121,7 @@ public async ValueTask WriteAsync( uploadTasks.Enqueue(uploadTask); } - if (contentStream.Length > 0) + if (contentLength > 0) { do { @@ -148,7 +150,7 @@ public async ValueTask WriteAsync( // TODO: move this to a decorator, wrap the progress action uploadEvent.UploadedSize = numberOfBytesUploaded; - onProgress(numberOfBytesUploaded, contentStream.Length); + onProgress(numberOfBytesUploaded, contentLength); } : default(Action?); @@ -212,7 +214,7 @@ public async ValueTask WriteAsync( } var request = GetRevisionUpdateRequest( - contentStream, + contentLength, lastModificationTime, blockSizes, sha1.GetCurrentHash(), @@ -267,7 +269,7 @@ private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTask } private RevisionUpdateRequest GetRevisionUpdateRequest( - Stream contentInputStream, + long contentLength, DateTimeOffset? lastModificationTime, IReadOnlyList blockSizes, byte[]? sha1Digest, @@ -278,7 +280,7 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( { Common = new CommonExtendedAttributes { - Size = contentInputStream.Length, + Size = contentLength, ModificationTime = lastModificationTime?.UtcDateTime, BlockSizes = blockSizes, Digests = new FileContentDigestsDto { Sha1 = sha1Digest }, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 695fcf8e..b523d61d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -80,6 +80,7 @@ internal ProtonDriveClient( BlockUploader = new BlockUploader(this, maxDegreeOfBlockTransferParallelism); BlockDownloader = new BlockDownloader(this, maxDegreeOfBlockTransferParallelism); + ThumbnailBlockDownloader = new BlockDownloader(this, 8); } private ProtonDriveClient( @@ -117,6 +118,7 @@ private ProtonDriveClient( internal BlockUploader BlockUploader { get; } internal BlockDownloader BlockDownloader { get; } + internal BlockDownloader ThumbnailBlockDownloader { get; } internal Func> GetAlternateFileNames { get; } = AlternateFileNameGenerator.GetNames; @@ -135,6 +137,11 @@ public IAsyncEnumerable> EnumerateFolderChildrenAsync return FolderOperations.EnumerateChildrenAsync(this, folderId, cancellationToken); } + public IAsyncEnumerable EnumerateThumbnailsAsync(IEnumerable fileUids, CancellationToken cancellationToken = default) + { + return FileOperations.EnumerateThumbnailsAsync(this, fileUids, cancellationToken); + } + public async ValueTask GetFileUploaderAsync( NodeUid parentFolderUid, string name, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 3264ef61..70d1d7d3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -46,6 +46,8 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(RevisionUpdateRequest))] [JsonSerializable(typeof(BlockVerificationInputResponse))] [JsonSerializable(typeof(RevisionResponse))] +[JsonSerializable(typeof(ThumbnailBlockListRequest))] +[JsonSerializable(typeof(ThumbnailBlockListResponse))] [JsonSerializable(typeof(MoveSingleLinkRequest))] [JsonSerializable(typeof(MoveMultipleLinksRequest))] [JsonSerializable(typeof(RenameLinkRequest))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index a31a5d39..c21c72ca 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -33,4 +33,11 @@ public static async ValueTask GetShareAsync( return new ShareAndKey(share, shareKey.Value); } + + public static async ValueTask GetContextShareAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) + { + var response = await client.Api.Links.GetContextShareAsync(nodeUid.VolumeId, nodeUid.LinkId, cancellationToken).ConfigureAwait(false); + + return await GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 0dd94e20..988236e9 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -16,6 +16,7 @@ message Request { DriveClientGetFileRevisionUploaderRequest drive_client_get_file_revision_uploader = 1004; DriveClientGetFileDownloaderRequest drive_client_get_file_downloader = 1005; DriveClientGetAvailableNameRequest drive_client_get_available_name = 1006; + DriveClientGetThumbnailsRequest drive_client_get_thumbnails = 1007; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -103,8 +104,18 @@ message ProgressUpdate { message Thumbnail { ThumbnailType type = 1; - int64 content_pointer = 2; - int64 content_length = 3; + int64 data_pointer = 2; + int64 data_length = 3; +} + +message FileThumbnail { + string file_uid = 1; + ThumbnailType type = 2; + bytes data = 3; +} + +message FileThumbnailList { + repeated FileThumbnail thumbnails = 1; } message UploadResult { @@ -225,6 +236,13 @@ message DriveClientGetAvailableNameRequest { int64 cancellation_token_source_handle = 4; } +// The response message must be of type FileThumbnailList. +message DriveClientGetThumbnailsRequest { + int64 client_handle = 1; + repeated string file_uids = 2; + int64 cancellation_token_source_handle = 3; +} + // Drive - downloads // The response value must be an Int64Value carrying a handle to an instance of FileDownloader. diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift new file mode 100644 index 00000000..e5c43e2a --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Handles file download operations for ProtonDrive +public actor DownloadThumbnailsManager { + enum Error: Swift.Error { + case noCancellationTokenForIdentifier + } + + private let clientHandle: ObjectHandle + private let logger: Logger? + private var activeDownloads: [UUID: CancellationTokenSource] = [:] + + init(clientHandle: ObjectHandle, logger: Logger?) { + self.clientHandle = clientHandle + self.logger = logger + } + + deinit { + activeDownloads.values.forEach { + $0.free() + } + } + + /// Download thumbnails for file UIDs + public func downloadThumbnails( + fileUids: [SDKNodeUid], + type: ThumbnailData.ThumbnailType, + cancellationToken: UUID + ) async throws -> [ThumbnailDataWithId] { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeDownloads[cancellationToken] = cancellationTokenSource + + defer { + activeDownloads[cancellationToken] = nil + cancellationTokenSource.free() + } + + // TODO(SDK): pass thumbnail type once SDK accepts it + let thumbnailsRequest = Proton_Drive_Sdk_DriveClientGetThumbnailsRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.fileUids = fileUids.map(\.sdkCompatibleIdentifier) + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + } + + let thumbnailsList: Proton_Drive_Sdk_FileThumbnailList = try await SDKRequestHandler.send( + thumbnailsRequest, + logger: logger + ) + return thumbnailsList.thumbnails.compactMap { + ThumbnailDataWithId(fileThumbnail: $0) + } + } + + func cancelDownload(with cancellationToken: UUID) async throws { + guard let downloadCancellationToken = activeDownloads[cancellationToken] else { + throw Error.noCancellationTokenForIdentifier + } + + try await downloadCancellationToken.cancel() + try await downloadCancellationToken.free() + + activeDownloads[cancellationToken] = nil + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift index f30ee89d..13b51e96 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift @@ -181,11 +181,11 @@ extension UploadManager { $0.thumbnails = thumbnails.map { thumbnail in Proton_Drive_Sdk_Thumbnail.with { $0.type = thumbnail.type == .thumbnail ? .thumbnail : .preview - $0.contentLength = Int64(thumbnail.data.count) + $0.dataLength = Int64(thumbnail.data.count) let dataHandle = thumbnail.data.withUnsafeBytes { (u8Ptr: UnsafePointer) in return ObjectHandle(bitPattern: UnsafeRawPointer(u8Ptr)) } - $0.contentPointer = Int64(dataHandle) + $0.dataPointer = Int64(dataHandle) } } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 662ef655..e0efd9e8 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -107,6 +107,10 @@ extension Message { Proton_Drive_Sdk_Request.with { $0.payload = .driveClientGetFileRevisionUploader(request) } + case let request as Proton_Drive_Sdk_DriveClientGetThumbnailsRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientGetThumbnails(request) + } default: assertionFailure("Unknown request") diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index a145236b..d50d83e4 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -179,3 +179,32 @@ public struct FileOperationProgress { self.bytesTotal = bytesTotal } } + +/// Thumbnail with file id +public struct ThumbnailDataWithId: Sendable { + public let fileUid: SDKNodeUid + public let thumbnail: ThumbnailData + + init?(fileThumbnail: Proton_Drive_Sdk_FileThumbnail) { + guard let fileUid = SDKNodeUid(sdkCompatibleIdentifier: fileThumbnail.fileUid) else { + return nil + } + let type: ThumbnailData.ThumbnailType? = { + switch fileThumbnail.type { + case .unspecified: + return nil + case .thumbnail: + return .thumbnail + case .preview: + return .preview + case .UNRECOGNIZED(let int): + return nil + } + }() + guard let type else { + return nil + } + self.fileUid = fileUid + self.thumbnail = ThumbnailData(type: type, data: fileThumbnail.data) + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 59bec8b7..35d82f9c 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -109,6 +109,13 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in } stringResultBox.resume(returning: unpackedValue.value) + case .value(let value) where value.isA(Proton_Drive_Sdk_FileThumbnailList.self): + let unpackedValue = try Proton_Drive_Sdk_FileThumbnailList(unpackingAny: value) + guard let uploadResultBox = box as? any Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Proton_Drive_Sdk_FileThumbnailList")) + } + uploadResultBox.resume(returning: unpackedValue) + case .value: // unknown value type throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unknown SDK call response value type")) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index f358af2b..fc181213 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -9,6 +9,7 @@ public actor ProtonDriveClient: Sendable { private var uploadManager: UploadManager! private var downloadManager: DownloadManager! + private var thumbnailsManager: DownloadThumbnailsManager! private let logger: ProtonDriveSDK.Logger private let recordMetricEventCallback: RecordMetricEventCallback @@ -64,6 +65,7 @@ public actor ProtonDriveClient: Sendable { self.uploadManager = UploadManager(clientHandle: clientHandle, logger: logger) self.downloadManager = DownloadManager(clientHandle: clientHandle, logger: logger) + self.thumbnailsManager = DownloadThumbnailsManager(clientHandle: clientHandle, logger: logger) } nonisolated func log(_ logEvent: LogEvent) { @@ -174,4 +176,16 @@ public actor ProtonDriveClient: Sendable { } return driveClient } + + public func downloadThumbnails( + fileUids: [SDKNodeUid], + type: ThumbnailData.ThumbnailType, + cancellationToken: UUID + ) async throws -> [ThumbnailDataWithId] { + try await thumbnailsManager.downloadThumbnails( + fileUids: fileUids, + type: type, + cancellationToken: cancellationToken + ) + } } From 97bb2eec37d86479834a47a6289d0e69d95e051d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 17 Nov 2025 09:41:39 +0100 Subject: [PATCH 297/791] Add possibility to provide additional metadata on file upload --- cs/.editorconfig | 1 - .../InteropFileUploader.cs | 2 ++ .../InteropProtonDriveClient.cs | 13 +++++++++++ .../Api/Files/ExtendedAttributes.cs | 10 +++++++-- .../Nodes/AdditionalMetadataProperty.cs | 5 +++++ .../Nodes/DtoToMetadataConverter.cs | 3 +++ cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 1 + .../Nodes/Upload/FileUploader.cs | 22 ++++++++++++++----- .../Nodes/Upload/RevisionWriter.cs | 10 ++++++--- .../Nodes/Upload/RevisionWriterExtensions.cs | 12 ++++++---- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 9 +++++--- cs/sdk/src/protos/proton.drive.sdk.proto | 13 ++++++++--- 12 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/AdditionalMetadataProperty.cs diff --git a/cs/.editorconfig b/cs/.editorconfig index a9508694..00528acb 100644 --- a/cs/.editorconfig +++ b/cs/.editorconfig @@ -15,7 +15,6 @@ indent_style = space tab_width = 4 # New line preferences -end_of_line = crlf insert_final_newline = true #### .NET Coding Conventions #### diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 7ded46b8..fcd026de 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -29,6 +29,7 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var uploadController = uploader.UploadFromStream( stream, thumbnails, + additionalMetadata: null, (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), cancellationToken); @@ -55,6 +56,7 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint var uploadController = uploader.UploadFromFile( request.FilePath, thumbnails, + additionalMetadata: null, (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 13aecd4b..e242a910 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes; @@ -60,12 +61,18 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe var client = Interop.GetFromHandle(request.ClientHandle); + var additionalMetadata = request.AdditionalMetadata.Count > 0 + ? request.AdditionalMetadata.Select(x => + new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) + : null; + var fileUploader = await client.GetFileUploaderAsync( NodeUid.Parse(request.ParentFolderUid), request.Name, request.MediaType, request.Size, request.LastModificationTime.ToDateTime(), + additionalMetadata, request.OverrideExistingDraftByOtherClient, cancellationToken).ConfigureAwait(false); @@ -78,10 +85,16 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive var client = Interop.GetFromHandle(request.ClientHandle); + var additionalMetadata = request.AdditionalMetadata.Count > 0 + ? request.AdditionalMetadata.Select(x => + new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) + : null; + var fileUploader = await client.GetFileRevisionUploaderAsync( RevisionUid.Parse(request.CurrentActiveRevisionUid), request.Size, request.LastModificationTime.ToDateTime(), + additionalMetadata, cancellationToken).ConfigureAwait(false); return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs index e1006d39..08036fb4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs @@ -1,6 +1,12 @@ -namespace Proton.Drive.Sdk.Api.Files; +using System.Text.Json; +using System.Text.Json.Serialization; -internal readonly struct ExtendedAttributes +namespace Proton.Drive.Sdk.Api.Files; + +internal struct ExtendedAttributes { public CommonExtendedAttributes? Common { get; init; } + + [JsonExtensionData] + public Dictionary? AdditionalMetadata { get; set; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AdditionalMetadataProperty.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AdditionalMetadataProperty.cs new file mode 100644 index 00000000..a38bc029 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AdditionalMetadataProperty.cs @@ -0,0 +1,5 @@ +using System.Text.Json; + +namespace Proton.Drive.Sdk.Nodes; + +public record struct AdditionalMetadataProperty(string Name, JsonElement Value); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 448258dd..de1e1c98 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -228,6 +228,8 @@ public static async Task> ConvertDtoT var thumbnails = activeRevisionDto.Thumbnails.Count > 0 ? new ThumbnailHeader[activeRevisionDto.Thumbnails.Count] : []; + var additionalMetadata = extendedAttributes?.AdditionalMetadata?.Select(x => new AdditionalMetadataProperty(x.Key, x.Value)).ToList().AsReadOnly(); + for (var i = 0; i < activeRevisionDto.Thumbnails.Count; ++i) { var thumbnailDto = activeRevisionDto.Thumbnails[i]; @@ -243,6 +245,7 @@ public static async Task> ConvertDtoT ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, Thumbnails = thumbnails.AsReadOnly(), + AdditionalClaimedMetadata = additionalMetadata, ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure), }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index a450c49f..c449e0b7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -11,5 +11,6 @@ public sealed record Revision public required FileContentDigests ClaimedDigests { get; init; } public DateTime? ClaimedModificationTime { get; init; } public required IReadOnlyList Thumbnails { get; init; } + public required IReadOnlyList? AdditionalClaimedMetadata { get; init; } public Result? ContentAuthor { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 0ba40425..7bf14d7f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -8,6 +8,7 @@ public sealed partial class FileUploader : IDisposable private readonly ProtonDriveClient _client; private readonly IFileDraftProvider _fileDraftProvider; private readonly DateTimeOffset? _lastModificationTime; + private readonly IEnumerable? _additionalMetadata; private volatile int _remainingNumberOfBlocks; private FileUploader( @@ -15,12 +16,14 @@ private FileUploader( IFileDraftProvider fileDraftProvider, long size, DateTimeOffset? lastModificationTime, + IEnumerable? additionalMetadata, int expectedNumberOfBlocks) { _client = client; _fileDraftProvider = fileDraftProvider; FileSize = size; _lastModificationTime = lastModificationTime; + _additionalMetadata = additionalMetadata; _remainingNumberOfBlocks = expectedNumberOfBlocks; } @@ -29,10 +32,11 @@ private FileUploader( public UploadController UploadFromStream( Stream contentStream, IEnumerable thumbnails, + IEnumerable? additionalMetadata, Action? onProgress, CancellationToken cancellationToken) { - var task = UploadFromStreamAsync(contentStream, thumbnails, onProgress, cancellationToken); + var task = UploadFromStreamAsync(contentStream, thumbnails, additionalMetadata, onProgress, cancellationToken); return new UploadController(task); } @@ -40,10 +44,11 @@ public UploadController UploadFromStream( public UploadController UploadFromFile( string filePath, IEnumerable thumbnails, + IEnumerable? additionalMetadata, Action? onProgress, CancellationToken cancellationToken) { - var task = UploadFromFileAsync(filePath, thumbnails, onProgress, cancellationToken); + var task = UploadFromFileAsync(filePath, thumbnails, additionalMetadata, onProgress, cancellationToken); return new UploadController(task); } @@ -58,15 +63,16 @@ internal static async ValueTask CreateAsync( IFileDraftProvider fileDraftProvider, long size, DateTime? lastModificationTime, + IEnumerable? additionalExtendedAttributes, CancellationToken cancellationToken) { var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); - LogEnteredRevisionCreationSemaphore(client.Logger, expectedNumberOfBlocks); + LogEnteringRevisionCreationSemaphore(client.Logger, expectedNumberOfBlocks); await client.RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); LogEnteredRevisionCreationSemaphore(client.Logger, expectedNumberOfBlocks); - return new FileUploader(client, fileDraftProvider, size, lastModificationTime, expectedNumberOfBlocks); + return new FileUploader(client, fileDraftProvider, size, lastModificationTime, additionalExtendedAttributes, expectedNumberOfBlocks); } [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter revision creation semaphore with {Increment}")] @@ -81,6 +87,7 @@ internal static async ValueTask CreateAsync( private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, + IEnumerable? additionalExtendedAttributes, Action? onProgress, CancellationToken cancellationToken) { @@ -92,6 +99,7 @@ await UploadAsync( contentStream, thumbnails, _lastModificationTime, + additionalExtendedAttributes, onProgress, cancellationToken).ConfigureAwait(false); @@ -129,6 +137,7 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromFileAsync( string filePath, IEnumerable thumbnails, + IEnumerable? additionalMetadata, Action? onProgress, CancellationToken cancellationToken) { @@ -136,7 +145,7 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid await using (contentStream.ConfigureAwait(false)) { - return await UploadFromStreamAsync(contentStream, thumbnails, onProgress, cancellationToken).ConfigureAwait(false); + return await UploadFromStreamAsync(contentStream, thumbnails, additionalMetadata, onProgress, cancellationToken).ConfigureAwait(false); } } @@ -146,13 +155,14 @@ private async ValueTask UploadAsync( Stream contentStream, IEnumerable thumbnails, DateTimeOffset? lastModificationTime, + IEnumerable? additionalMetadata, Action? onProgress, CancellationToken cancellationToken) { using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionUid, fileSecrets, ReleaseBlocks, cancellationToken) .ConfigureAwait(false); - await revisionWriter.WriteAsync(contentStream, thumbnails, lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); + await revisionWriter.WriteAsync(contentStream, thumbnails, lastModificationTime, additionalMetadata, onProgress, cancellationToken).ConfigureAwait(false); } private void ReleaseBlocks(int numberOfBlocks) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index b0b4a3d1..b3013f5c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -59,6 +59,7 @@ public async ValueTask WriteAsync( Stream contentStream, IEnumerable thumbnails, DateTimeOffset? lastModificationTime, + IEnumerable? additionalMetadata, Action? onProgress, CancellationToken cancellationToken) { @@ -81,7 +82,7 @@ public async ValueTask WriteAsync( ArraySegment manifestSignature; var blockSizes = new List(8); - var contentLength = contentStream.Length - contentStream.Position; + var contentLength = contentStream.Length - contentStream.Position; using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); @@ -219,7 +220,8 @@ public async ValueTask WriteAsync( blockSizes, sha1.GetCurrentHash(), manifestSignature, - signingEmailAddress); + signingEmailAddress, + additionalMetadata); _client.Logger.LogDebug("Sealing revision {RevisionId} of file {FileUid}", _revisionId, _fileUid); @@ -274,7 +276,8 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( IReadOnlyList blockSizes, byte[]? sha1Digest, ArraySegment manifestSignature, - string signingEmailAddress) + string signingEmailAddress, + IEnumerable? additionalMetadata) { var extendedAttributes = new ExtendedAttributes { @@ -285,6 +288,7 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( BlockSizes = blockSizes, Digests = new FileContentDigestsDto { Sha1 = sha1Digest }, }, + AdditionalMetadata = additionalMetadata?.ToDictionary(x => x.Name, x => x.Value), }; var extendedAttributesUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(extendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs index b34b8975..5fea04e9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs @@ -6,20 +6,22 @@ public static ValueTask WriteAsync( this RevisionWriter revisionWriter, Stream contentStream, DateTimeOffset? lastModificationTime, + IEnumerable? additionalMetadata, Action onProgress, CancellationToken cancellationToken) { - return revisionWriter.WriteAsync(contentStream, [], lastModificationTime, onProgress, cancellationToken); + return revisionWriter.WriteAsync(contentStream, [], lastModificationTime, additionalMetadata, onProgress, cancellationToken); } public static ValueTask WriteAsync( this RevisionWriter revisionWriter, Stream contentStream, DateTime lastModificationTime, + IEnumerable? additionalMetadata, Action onProgress, CancellationToken cancellationToken) { - return revisionWriter.WriteAsync(contentStream, [], new DateTimeOffset(lastModificationTime), onProgress, cancellationToken); + return revisionWriter.WriteAsync(contentStream, [], new DateTimeOffset(lastModificationTime), additionalMetadata, onProgress, cancellationToken); } public static ValueTask WriteAsync( @@ -27,16 +29,18 @@ public static ValueTask WriteAsync( Stream contentStream, IEnumerable thumbnails, DateTime lastModificationTime, + IEnumerable? additionalMetadata, Action onProgress, CancellationToken cancellationToken) { - return revisionWriter.WriteAsync(contentStream, thumbnails, new DateTimeOffset(lastModificationTime), onProgress, cancellationToken); + return revisionWriter.WriteAsync(contentStream, thumbnails, new DateTimeOffset(lastModificationTime), additionalMetadata, onProgress, cancellationToken); } public static async ValueTask WriteAsync( this RevisionWriter writer, string targetFilePath, DateTime lastModificationTime, + IEnumerable? additionalMetadata, Action onProgress, CancellationToken cancellationToken) { @@ -44,7 +48,7 @@ public static async ValueTask WriteAsync( await using (fileStream) { - await WriteAsync(writer, fileStream, lastModificationTime, onProgress, cancellationToken).ConfigureAwait(false); + await WriteAsync(writer, fileStream, lastModificationTime, additionalMetadata, onProgress, cancellationToken).ConfigureAwait(false); } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index b523d61d..18bc55ed 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -148,23 +148,25 @@ public async ValueTask GetFileUploaderAsync( string mediaType, long size, DateTime? lastModificationTime, + IEnumerable? additionalMetadata, bool overrideExistingDraftByOtherClient, CancellationToken cancellationToken) { var draftProvider = new NewFileDraftProvider(parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); - return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); } public async ValueTask GetFileRevisionUploaderAsync( RevisionUid currentActiveRevisionUid, long size, DateTime? lastModificationTime, + IEnumerable? additionalMetadata, CancellationToken cancellationToken) { var draftProvider = new NewRevisionDraftProvider(currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); - return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); } public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) @@ -221,8 +223,9 @@ private async ValueTask GetFileUploaderAsync( IFileDraftProvider fileDraftProvider, long size, DateTime? lastModificationTime, + IEnumerable? additionalMetadata, CancellationToken cancellationToken) { - return await FileUploader.CreateAsync(this, fileDraftProvider, size, lastModificationTime, cancellationToken).ConfigureAwait(false); + return await FileUploader.CreateAsync(this, fileDraftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 988236e9..e3015bf0 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -164,6 +164,11 @@ message DriveClientFreeRequest { // Drive - uploads +message AdditionalMetadataProperty { + string name = 1; + bytes utf8_json_value = 2; +} + // The response value must be an Int64Value carrying a handle to an instance of FileUploader. message DriveClientGetFileUploaderRequest { int64 client_handle = 1; @@ -172,8 +177,9 @@ message DriveClientGetFileUploaderRequest { string mediaType = 4; int64 size = 5; google.protobuf.Timestamp last_modification_time = 6; - bool override_existing_draft_by_other_client = 7; - int64 cancellation_token_source_handle = 8; + repeated AdditionalMetadataProperty additional_metadata = 7; // Optional + bool override_existing_draft_by_other_client = 8; + int64 cancellation_token_source_handle = 9; } // The response value must be an Int64Value carrying a handle to an instance of FileUploader. @@ -182,7 +188,8 @@ message DriveClientGetFileRevisionUploaderRequest { string current_active_revision_uid = 2; int64 size = 3; google.protobuf.Timestamp last_modification_time = 4; - int64 cancellation_token_source_handle = 5; + repeated AdditionalMetadataProperty additional_metadata = 5; // Optional + int64 cancellation_token_source_handle = 6; } // The response value must be an Int64Value carrying a handle to an instance of UploadController. From ba89e66e6527ee0644d393f95f6773533255efa6 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 19 Nov 2025 11:31:44 +0100 Subject: [PATCH 298/791] Fix wrong additional metadata parameters in upload --- cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs | 2 -- cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index fcd026de..7ded46b8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -29,7 +29,6 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var uploadController = uploader.UploadFromStream( stream, thumbnails, - additionalMetadata: null, (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), cancellationToken); @@ -56,7 +55,6 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint var uploadController = uploader.UploadFromFile( request.FilePath, thumbnails, - additionalMetadata: null, (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 7bf14d7f..a483718e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -32,11 +32,10 @@ private FileUploader( public UploadController UploadFromStream( Stream contentStream, IEnumerable thumbnails, - IEnumerable? additionalMetadata, Action? onProgress, CancellationToken cancellationToken) { - var task = UploadFromStreamAsync(contentStream, thumbnails, additionalMetadata, onProgress, cancellationToken); + var task = UploadFromStreamAsync(contentStream, thumbnails, _additionalMetadata, onProgress, cancellationToken); return new UploadController(task); } @@ -44,11 +43,10 @@ public UploadController UploadFromStream( public UploadController UploadFromFile( string filePath, IEnumerable thumbnails, - IEnumerable? additionalMetadata, Action? onProgress, CancellationToken cancellationToken) { - var task = UploadFromFileAsync(filePath, thumbnails, additionalMetadata, onProgress, cancellationToken); + var task = UploadFromFileAsync(filePath, thumbnails, _additionalMetadata, onProgress, cancellationToken); return new UploadController(task); } From 9b3d11defe6aa51f4c847b8866d4e4876e0ccbd9 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 14 Nov 2025 09:14:23 +0100 Subject: [PATCH 299/791] Add isDuplicatePhoto method --- js/sdk/src/internal/photos/apiService.ts | 40 +++++++ js/sdk/src/internal/photos/index.ts | 10 +- js/sdk/src/internal/photos/interface.ts | 3 + js/sdk/src/internal/photos/timeline.test.ts | 116 ++++++++++++++++++++ js/sdk/src/internal/photos/timeline.ts | 47 +++++++- js/sdk/src/protonDrivePhotosClient.ts | 25 ++++- 6 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 js/sdk/src/internal/photos/timeline.test.ts diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index d9f1550f..fbe74e3c 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -18,6 +18,13 @@ type GetTimelineResponse = type GetAlbumsResponse = drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json']; +type PostPhotoDuplicateRequest = Extract< + drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostPhotoDuplicateResponse = + drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json']; + /** * Provides API communication for fetching and manipulating photos and albums * metadata. @@ -148,4 +155,37 @@ export class PhotosAPIService { anchor = response.AnchorID; } } + + async checkPhotoDuplicates( + volumeId: string, + nameHashes: string[], + signal?: AbortSignal, + ): Promise< + { + nameHash: string; + contentHash: string; + nodeUid: string; + clientUid?: string; + }[] + > { + const response = await this.apiService.post( + `drive/volumes/${volumeId}/photos/duplicates`, + { + NameHashes: nameHashes, + }, + signal, + ); + + return response.DuplicateHashes.map((duplicate) => { + if (!duplicate.Hash || !duplicate.ContentHash || duplicate.LinkState !== 1 /* Active */) { + return undefined; + } + return { + nameHash: duplicate.Hash, + contentHash: duplicate.ContentHash, + nodeUid: makeNodeUid(volumeId, duplicate.LinkID), + clientUid: duplicate.ClientUID || undefined, + }; + }).filter((duplicate) => duplicate !== undefined); + } } diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 8eb8955b..ae1d3afc 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -36,12 +36,20 @@ export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType * including API communication, crypto, caching, and event handling. */ export function initPhotosModule( + telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, + driveCrypto: DriveCrypto, photoShares: PhotoSharesManager, nodesService: NodesService, ) { const api = new PhotosAPIService(apiService); - const timeline = new PhotosTimeline(api, photoShares); + const timeline = new PhotosTimeline( + telemetry.getLogger('photos-timeline'), + api, + driveCrypto, + photoShares, + nodesService, + ); const albums = new Albums(api, photoShares, nodesService); return { diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index d0e6ea3a..54754530 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -26,4 +26,7 @@ export interface SharesService { export interface NodesService { getNode(nodeUid: string): Promise; iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; + getNodeKeys(nodeUid: string): Promise<{ + hashKey?: Uint8Array; + }>; } diff --git a/js/sdk/src/internal/photos/timeline.test.ts b/js/sdk/src/internal/photos/timeline.test.ts new file mode 100644 index 00000000..7b0d4dd6 --- /dev/null +++ b/js/sdk/src/internal/photos/timeline.test.ts @@ -0,0 +1,116 @@ +import { getMockLogger } from '../../tests/logger'; +import { DriveCrypto } from '../../crypto'; +import { makeNodeUid } from '../uids'; +import { PhotosAPIService } from './apiService'; +import { NodesService } from './interface'; +import { PhotoSharesManager } from './shares'; +import { PhotosTimeline } from './timeline'; + +describe('PhotosTimeline', () => { + let logger: ReturnType; + let apiService: PhotosAPIService; + let driveCrypto: DriveCrypto; + let photoShares: PhotoSharesManager; + let nodesService: NodesService; + let timeline: PhotosTimeline; + + const volumeId = 'volumeId'; + const rootNodeId = 'rootNodeId'; + const rootNodeUid = makeNodeUid(volumeId, rootNodeId); + const hashKey = new Uint8Array([1, 2, 3]); + const name = 'photo.jpg'; + const nameHash = 'nameHash123'; + const sha1 = 'sha1Hash123'; + const contentHash = 'contentHash123'; + + beforeEach(() => { + logger = getMockLogger(); + // @ts-expect-error No need to implement all methods for mocking + apiService = { + checkPhotoDuplicates: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + driveCrypto = { + generateLookupHash: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + photoShares = { + getRootIDs: jest.fn().mockResolvedValue({ volumeId, rootNodeId }), + }; + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + getNodeKeys: jest.fn().mockResolvedValue({ hashKey }), + }; + + timeline = new PhotosTimeline(logger, apiService, driveCrypto, photoShares, nodesService); + }); + + describe('isDuplicatePhoto', () => { + it('should not call sha1 callback when there is no name hash match', async () => { + const generateSha1 = jest.fn(); + apiService.checkPhotoDuplicates = jest.fn().mockResolvedValue([]); + driveCrypto.generateLookupHash = jest.fn().mockResolvedValue(nameHash); + + const result = await timeline.isDuplicatePhoto(name, generateSha1); + + expect(result).toBe(false); + expect(generateSha1).not.toHaveBeenCalled(); + expect(photoShares.getRootIDs).toHaveBeenCalled(); + expect(nodesService.getNodeKeys).toHaveBeenCalledWith(rootNodeUid); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith(name, hashKey); + expect(apiService.checkPhotoDuplicates).toHaveBeenCalledWith(volumeId, [nameHash], undefined); + }); + + it('should call sha1 callback and not logger when name hash match but content hash does not', async () => { + const generateSha1 = jest.fn().mockResolvedValue(sha1); + const duplicates = [ + { + nameHash: nameHash, + contentHash: 'differentContentHash', + nodeUid: 'volumeId~node1', + }, + ]; + apiService.checkPhotoDuplicates = jest.fn().mockResolvedValue(duplicates); + driveCrypto.generateLookupHash = jest + .fn() + .mockResolvedValueOnce(nameHash) + .mockResolvedValueOnce(contentHash); + + const result = await timeline.isDuplicatePhoto(name, generateSha1); + + expect(result).toBe(false); + expect(generateSha1).toHaveBeenCalledTimes(1); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledTimes(2); + expect(driveCrypto.generateLookupHash).toHaveBeenNthCalledWith(1, name, hashKey); + expect(driveCrypto.generateLookupHash).toHaveBeenNthCalledWith(2, sha1, hashKey); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it('should call sha1 and logger when name and content hashes match', async () => { + const generateSha1 = jest.fn().mockResolvedValue(sha1); + const nodeUid1 = 'volumeId~node1'; + const duplicates = [ + { + nameHash: nameHash, + contentHash: contentHash, + nodeUid: nodeUid1, + }, + ]; + apiService.checkPhotoDuplicates = jest.fn().mockResolvedValue(duplicates); + driveCrypto.generateLookupHash = jest + .fn() + .mockResolvedValueOnce(nameHash) + .mockResolvedValueOnce(contentHash); + + const result = await timeline.isDuplicatePhoto(name, generateSha1); + + expect(result).toBe(true); + expect(generateSha1).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + `Duplicate photo found: name hash: ${nameHash}, content hash: ${contentHash}, node uids: ${nodeUid1}`, + ); + }); + }); +}); + diff --git a/js/sdk/src/internal/photos/timeline.ts b/js/sdk/src/internal/photos/timeline.ts index 94e6ef3d..8192c6a6 100644 --- a/js/sdk/src/internal/photos/timeline.ts +++ b/js/sdk/src/internal/photos/timeline.ts @@ -1,4 +1,8 @@ +import { DriveCrypto } from '../../crypto'; +import { Logger } from '../../interface'; +import { makeNodeUid } from '../uids'; import { PhotosAPIService } from './apiService'; +import { NodesService } from './interface'; import { PhotoSharesManager } from './shares'; /** @@ -6,14 +10,20 @@ import { PhotoSharesManager } from './shares'; */ export class PhotosTimeline { constructor( + private logger: Logger, private apiService: PhotosAPIService, + private driveCrypto: DriveCrypto, private photoShares: PhotoSharesManager, + private nodesService: NodesService, ) { + this.logger = logger; this.apiService = apiService; + this.driveCrypto = driveCrypto; this.photoShares = photoShares; + this.nodesService = nodesService; } - async* iterateTimeline(signal?: AbortSignal): AsyncGenerator<{ + async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<{ nodeUid: string; captureTime: Date; tags: number[]; @@ -21,4 +31,39 @@ export class PhotosTimeline { const { volumeId } = await this.photoShares.getRootIDs(); yield* this.apiService.iterateTimeline(volumeId, signal); } + + async isDuplicatePhoto(name: string, generateSha1: () => Promise, signal?: AbortSignal): Promise { + const { volumeId, rootNodeId } = await this.photoShares.getRootIDs(); + const rootNodeUid = makeNodeUid(volumeId, rootNodeId); + const { hashKey } = await this.nodesService.getNodeKeys(rootNodeUid); + if (!hashKey) { + throw new Error('Hash key of photo root node not found'); + } + + const nameHash = await this.driveCrypto.generateLookupHash(name, hashKey); + const duplicates = await this.apiService.checkPhotoDuplicates(volumeId, [nameHash], signal); + + if (duplicates.length === 0) { + return false; + } + + // Generate the SHA1 only when there is any matching node hash to avoid + // computing it for every node as in most cases there is no match. + const sha1 = await generateSha1(); + const contentHash = await this.driveCrypto.generateLookupHash(sha1, hashKey); + + const matchingDuplicates = duplicates.filter( + (duplicate) => duplicate.nameHash === nameHash && duplicate.contentHash === contentHash, + ); + + if (matchingDuplicates.length === 0) { + return false; + } + + const nodeUids = matchingDuplicates.map((duplicate) => duplicate.nodeUid); + this.logger.debug( + `Duplicate photo found: name hash: ${nameHash}, content hash: ${contentHash}, node uids: ${nodeUids}`, + ); + return true; + } } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index e7034241..37e0ea20 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -117,7 +117,7 @@ export class ProtonDrivePhotosClient { this.photoShares, fullConfig.clientUid, ); - this.photos = initPhotosModule(apiService, this.photoShares, this.nodes.access); + this.photos = initPhotosModule(telemetry, apiService, cryptoModule, this.photoShares, this.nodes.access); this.sharing = initSharingModule( telemetry, apiService, @@ -454,6 +454,29 @@ export class ProtonDrivePhotosClient { return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } + /** + * Check if the photo is a duplicate. + * + * For given photo name, find existing photos with the same name + * in the timeline and check if the photo content is also the same. + * Only the same name is not considered as duplicate photo because + * it is expected that there are photos with the same name (e.g., + * date as a name from multiple cameras, or rolling number). + * + * The function accepts a callback to generate the SHA1 and it is + * called only when there is any matching node name hash to avoid + * computation for every node if its not necessary. + * + * @param name - The name of the photo to check for duplicates. + * @param generateSha1 - A callback to generate the hex string representation of the SHA1 of the photo content. + * @param signal - An optional abort signal to cancel the operation. + * @returns True if the photo already exists in the timeline, false otherwise. + */ + async isDuplicatePhoto(name: string, generateSha1: () => Promise, signal?: AbortSignal): Promise { + this.logger.info(`Checking if photo is a duplicate`); + return this.photos.timeline.isDuplicatePhoto(name, generateSha1, signal); + } + /** * Iterates the albums. * From 094f4599c27b3d885f25c704e7ebd43fc6a188ce Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 08:06:25 +0100 Subject: [PATCH 300/791] js/v0.6.1 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index b2ba59b3..6aab0c62 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.6.0", + "version": "0.6.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From fb6c6f966cf1858c8537b73b38c897a376d4fd62 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 10:12:00 +0100 Subject: [PATCH 301/791] Fix cancellation token source being double-freed in the Swift interop --- .../Sources/CancellationTokenSource.swift | 7 ---- .../FileOperations/DownloadManager.swift | 11 +++---- .../DownloadThumbnailsManager.swift | 11 +++---- .../FileOperations/UploadManager.swift | 32 +++++++++++-------- .../ProtonDriveClient/ProtonDriveClient.swift | 4 +++ .../ProtonDriveSDKError.swift | 3 ++ 6 files changed, 35 insertions(+), 33 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift index 52187ac8..ba522b91 100644 --- a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift +++ b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift @@ -38,11 +38,4 @@ actor CancellationTokenSource { strongSelf = nil } } - - deinit { - logger?.trace("CancellationTokenSource.deinit, handle: \(String(describing: handle))", category: "Cancellation") -// // TODO(SDK): free handle in deinit -// free() -// logger?.trace("CancellationTokenSource.deinit, after handle: \(String(describing: cancellationHandle))", category: .cancellation) - } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift index a07dc3d3..d1211431 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift @@ -2,9 +2,6 @@ import Foundation /// Handles file download operations for ProtonDrive public actor DownloadManager { - enum Error: Swift.Error { - case noCancellationTokenForIdentifier - } private let clientHandle: ObjectHandle private let logger: Logger? @@ -32,8 +29,10 @@ public actor DownloadManager { activeDownloads[cancellationToken] = cancellationTokenSource defer { - activeDownloads[cancellationToken] = nil - cancellationTokenSource.free() + if let cancellationTokenSource = activeDownloads[cancellationToken] { + activeDownloads[cancellationToken] = nil + cancellationTokenSource.free() + } } let downloaderHandle = try await buildFileDownloader( @@ -71,7 +70,7 @@ public actor DownloadManager { func cancelDownload(with cancellationToken: UUID) async throws { guard let downloadCancellationToken = activeDownloads[cancellationToken] else { - throw Error.noCancellationTokenForIdentifier + throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "download")) } try await downloadCancellationToken.cancel() diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift index e5c43e2a..f609d6f6 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift @@ -2,9 +2,6 @@ import Foundation /// Handles file download operations for ProtonDrive public actor DownloadThumbnailsManager { - enum Error: Swift.Error { - case noCancellationTokenForIdentifier - } private let clientHandle: ObjectHandle private let logger: Logger? @@ -31,8 +28,10 @@ public actor DownloadThumbnailsManager { activeDownloads[cancellationToken] = cancellationTokenSource defer { - activeDownloads[cancellationToken] = nil - cancellationTokenSource.free() + if let cancellationTokenSource = activeDownloads[cancellationToken] { + activeDownloads[cancellationToken] = nil + cancellationTokenSource.free() + } } // TODO(SDK): pass thumbnail type once SDK accepts it @@ -53,7 +52,7 @@ public actor DownloadThumbnailsManager { func cancelDownload(with cancellationToken: UUID) async throws { guard let downloadCancellationToken = activeDownloads[cancellationToken] else { - throw Error.noCancellationTokenForIdentifier + throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "thumbnails download")) } try await downloadCancellationToken.cancel() diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift index 13b51e96..781bd47b 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift @@ -3,9 +3,6 @@ import SwiftProtobuf /// Handles file upload operations for ProtonDrive public actor UploadManager { - enum Error: Swift.Error { - case noCancellationTokenForIdentifier - } private let clientHandle: ObjectHandle private let logger: Logger? @@ -30,8 +27,8 @@ public actor UploadManager { fileSize: Int64, modificationDate: Date, mediaType: String, - thumbnails: [ThumbnailData] = [], - overrideExistingDraft: Bool = false, + thumbnails: [ThumbnailData], + overrideExistingDraft: Bool, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> FileNodeUploadResult { @@ -39,8 +36,10 @@ public actor UploadManager { activeUploads[cancellationToken] = cancellationTokenSource defer { - activeUploads[cancellationToken] = cancellationTokenSource - cancellationTokenSource.free() + if let cancellationTokenSource = activeUploads[cancellationToken] { + activeUploads[cancellationToken] = nil + cancellationTokenSource.free() + } } let cancellationHandle = cancellationTokenSource.handle @@ -52,7 +51,7 @@ public actor UploadManager { fileSize: fileSize, modificationDate: modificationDate, overrideExistingDraft: overrideExistingDraft, - cancellationHandle: cancellationTokenSource.handle, + cancellationHandle: cancellationHandle, logger: logger ) @@ -76,12 +75,17 @@ public actor UploadManager { fileSize: Int64, modificationDate: Date, thumbnails: [ThumbnailData], + cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> FileNodeUploadResult { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeUploads[cancellationToken] = cancellationTokenSource + defer { - // TODO: Should be done in deinit! - cancellationTokenSource.free() + if let cancellationTokenSource = activeUploads[cancellationToken] { + activeUploads[cancellationToken] = nil + cancellationTokenSource.free() + } } let cancellationHandle = cancellationTokenSource.handle @@ -104,7 +108,7 @@ public actor UploadManager { func cancelUpload(with cancellationToken: UUID) async throws { guard let uploadCancellationToken = activeUploads[cancellationToken] else { - throw Error.noCancellationTokenForIdentifier + throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "upload")) } try await uploadCancellationToken.cancel() @@ -122,8 +126,8 @@ extension UploadManager { mediaType: String, fileSize: Int64, modificationDate: Date, - overrideExistingDraft: Bool = false, - cancellationHandle: ObjectHandle? = nil, + overrideExistingDraft: Bool, + cancellationHandle: ObjectHandle?, logger: Logger? ) async throws -> ObjectHandle { let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileUploaderRequest.with { @@ -149,7 +153,7 @@ extension UploadManager { currentActiveRevisionUid: SDKRevisionUid, fileSize: Int64, modificationDate: Date, - cancellationHandle: ObjectHandle? = nil + cancellationHandle: ObjectHandle? ) async throws -> ObjectHandle { let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileRevisionUploaderRequest.with { $0.clientHandle = Int64(clientHandle) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index fc181213..ede7f21a 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -102,6 +102,7 @@ public actor ProtonDriveClient: Sendable { modificationDate: Date, mediaType: String, thumbnails: [ThumbnailData], + overrideExistingDraft: Bool, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> FileNodeUploadResult { @@ -113,6 +114,7 @@ public actor ProtonDriveClient: Sendable { modificationDate: modificationDate, mediaType: mediaType, thumbnails: thumbnails, + overrideExistingDraft: overrideExistingDraft, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -124,6 +126,7 @@ public actor ProtonDriveClient: Sendable { fileSize: Int64, modificationDate: Date, thumbnails: [ThumbnailData], + cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> FileNodeUploadResult { try await uploadManager.uploadNewRevision( @@ -132,6 +135,7 @@ public actor ProtonDriveClient: Sendable { fileSize: fileSize, modificationDate: modificationDate, thumbnails: thumbnails, + cancellationToken: cancellationToken, progressCallback: progressCallback ) } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift index cd61cf56..81eca85a 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift @@ -39,12 +39,14 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { } public enum InteropErrorTypes: Sendable { + case noCancellationTokenForIdentifier(operation: String) case wrongProto(message: String) case wrongSDKResponse(message: String) case wrongResult(message: String) var typeName: String { switch self { + case .noCancellationTokenForIdentifier(let operation): return "NoCancellationTokenFor\(operation.capitalized.replacingOccurrences(of: " ", with: ""))" case .wrongProto: return "WrongProtoMessageType" case .wrongSDKResponse: return "WrongSDKResponseType" case .wrongResult: return "WrongSDKRequestResult" @@ -53,6 +55,7 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { var message: String { switch self { + case .noCancellationTokenForIdentifier(let operation): return "No cancellation token found for \(operation)" case .wrongProto(let message): return message case .wrongSDKResponse(let message): return message case .wrongResult(let message): return message From 8607a8e79bbbb906667bfd4d43555cb34968e9d1 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 12:56:45 +0000 Subject: [PATCH 302/791] Add feature flag support --- .../InteropProtonDriveClient.cs | 3 ++- .../Proton.Drive.Sdk/Nodes/FolderOperations.cs | 2 +- .../src/Proton.Drive.Sdk/Nodes/NodeOperations.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs | 8 ++++++++ .../AlwaysDisabledFeatureFlagProvider.cs | 16 ++++++++++++++++ cs/sdk/src/Proton.Sdk/FeatureFlags.cs | 6 ++++++ cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs | 6 ++++++ .../src/Proton.Sdk/ProtonClientConfiguration.cs | 1 + cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs | 3 ++- js/sdk/src/featureFlags.ts | 11 +++++++++++ js/sdk/src/index.ts | 1 + js/sdk/src/interface/featureFlags.ts | 7 +++++++ js/sdk/src/interface/index.ts | 3 +++ js/sdk/src/protonDriveClient.ts | 5 +++++ 14 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs create mode 100644 cs/sdk/src/Proton.Sdk/FeatureFlags.cs create mode 100644 cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs create mode 100644 js/sdk/src/featureFlags.ts create mode 100644 js/sdk/src/interface/featureFlags.ts diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index e242a910..3067e70a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -38,7 +38,8 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi ? new DriveInteropTelemetryDecorator(interopTelemetry) : NullTelemetry.Instance; - var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, telemetry, request.Uid); + // FIXME add support for client to inject FF provider + var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, AlwaysDisabledFeatureFlagProvider.Instance, telemetry, request.Uid); return new Int64Value { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 1841b136..5ec82eae 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 66c09f82..6076130d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Security.Cryptography; diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 18bc55ed..daa2436c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -31,6 +31,7 @@ public ProtonDriveClient(ProtonApiSession session, string? uid = null) session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds)), new AccountClientAdapter(session), new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), + session.ClientConfiguration.FeatureFlagProvider, session.ClientConfiguration.Telemetry, uid ?? Guid.NewGuid().ToString()) { @@ -41,12 +42,14 @@ public ProtonDriveClient( IAccountClient accountClient, ICacheRepository entityCacheRepository, ICacheRepository secretCacheRepository, + IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, string? uid = null) : this( new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClient(), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), + featureFlagProvider, telemetry, uid ?? Guid.NewGuid().ToString()) { @@ -57,6 +60,7 @@ internal ProtonDriveClient( IDriveApiClients apiClients, IDriveClientCache cache, IBlockVerifierFactory blockVerifierFactory, + IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, string uid) { @@ -68,6 +72,7 @@ internal ProtonDriveClient( BlockVerifierFactory = blockVerifierFactory; Telemetry = telemetry; Logger = telemetry.GetLogger(); + FeatureFlagProvider = featureFlagProvider; var maxDegreeOfBlockTransferParallelism = Math.Max( Math.Min(Environment.ProcessorCount / 2, MaxDegreeOfBlockTransferParallelism), @@ -87,6 +92,7 @@ private ProtonDriveClient( HttpClient httpClient, IAccountClient accountClient, IDriveClientCache cache, + IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, string uid) : this( @@ -94,6 +100,7 @@ private ProtonDriveClient( new DriveApiClients(httpClient), cache, new BlockVerifierFactory(httpClient), + featureFlagProvider, telemetry, uid) { @@ -109,6 +116,7 @@ private ProtonDriveClient( internal IBlockVerifierFactory BlockVerifierFactory { get; } internal ITelemetry Telemetry { get; } internal ILogger Logger { get; } + internal IFeatureFlagProvider FeatureFlagProvider { get; } internal FifoFlexibleSemaphore RevisionCreationSemaphore { get; } internal FifoFlexibleSemaphore BlockListingSemaphore { get; } diff --git a/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs new file mode 100644 index 00000000..e7fff7a0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs @@ -0,0 +1,16 @@ +namespace Proton.Sdk; + +/// +/// Default feature flag provider which always returns false. +/// By default, don't use unstable features that are behind feature flags. +/// +internal sealed class AlwaysDisabledFeatureFlagProvider : IFeatureFlagProvider +{ + public static readonly IFeatureFlagProvider Instance = new AlwaysDisabledFeatureFlagProvider(); + + private AlwaysDisabledFeatureFlagProvider() + { + } + + public bool IsEnabled(string flagName) => false; +} diff --git a/cs/sdk/src/Proton.Sdk/FeatureFlags.cs b/cs/sdk/src/Proton.Sdk/FeatureFlags.cs new file mode 100644 index 00000000..fbe0c438 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/FeatureFlags.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk; + +internal static class FeatureFlags +{ + public const string DriveCryptoEncryptBlocksWithPgpAead = "DriveCryptoEncryptBlocksWithPgpAead"; +} diff --git a/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs new file mode 100644 index 00000000..75b28932 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk; + +public interface IFeatureFlagProvider +{ + bool IsEnabled(string flagName); +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs index 255ff70f..fffcbef3 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs @@ -19,6 +19,7 @@ internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientO public ICacheRepository SecretCacheRepository { get; } = options?.SecretCacheRepository ?? SqliteCacheRepository.OpenInMemory(); public ICacheRepository EntityCacheRepository { get; } = options?.EntityCacheRepository ?? SqliteCacheRepository.OpenInMemory(); public ITelemetry Telemetry { get; } = options?.Telemetry ?? NullTelemetry.Instance; + public IFeatureFlagProvider FeatureFlagProvider { get; } = options?.FeatureFlagProvider ?? AlwaysDisabledFeatureFlagProvider.Instance; public Uri RefreshRedirectUri { get; } = options?.RefreshRedirectUri ?? ProtonApiDefaults.RefreshRedirectUri; public string? BindingsLanguage { get; } = options?.BindingsLanguage; } diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs index 4a354c51..4d975f78 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs @@ -1,4 +1,4 @@ -using Proton.Sdk.Caching; +using Proton.Sdk.Caching; using Proton.Sdk.Http; using Proton.Sdk.Telemetry; @@ -13,6 +13,7 @@ public record ProtonClientOptions public IHttpClientFactory? HttpClientFactory { get; set; } public ICacheRepository? EntityCacheRepository { get; set; } public ITelemetry? Telemetry { get; set; } + public IFeatureFlagProvider? FeatureFlagProvider { get; set; } internal ICacheRepository? SecretCacheRepository { get; set; } internal Uri? RefreshRedirectUri { get; set; } internal string? BindingsLanguage { get; set; } diff --git a/js/sdk/src/featureFlags.ts b/js/sdk/src/featureFlags.ts new file mode 100644 index 00000000..d19d87a6 --- /dev/null +++ b/js/sdk/src/featureFlags.ts @@ -0,0 +1,11 @@ +import { FeatureFlagProvider } from './interface/featureFlags'; + +/** + * Default feature flag provider that returns false for all flags. + */ +export class NullFeatureFlagProvider implements FeatureFlagProvider { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isEnabled(flagName: string): boolean { + return false; + } +} diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index b3eb4119..9ea8e552 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -9,6 +9,7 @@ export * from './cache'; export * from './errors'; export type { OpenPGPCrypto, OpenPGPCryptoProxy } from './crypto'; export { OpenPGPCryptoWithCryptoProxy } from './crypto'; +export { NullFeatureFlagProvider } from './featureFlags'; export { ProtonDriveClient } from './protonDriveClient'; export { VERSION } from './version'; diff --git a/js/sdk/src/interface/featureFlags.ts b/js/sdk/src/interface/featureFlags.ts new file mode 100644 index 00000000..c11f9b46 --- /dev/null +++ b/js/sdk/src/interface/featureFlags.ts @@ -0,0 +1,7 @@ +/** + * Provides feature flag evaluation for controlling SDK behavior. + * Applications must supply their own implementation. + */ +export interface FeatureFlagProvider { + isEnabled(flagName: string): boolean; +} diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 9c018022..1d959219 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -3,6 +3,7 @@ import { OpenPGPCrypto, PrivateKey, SessionKey, SRPModule } from '../crypto'; import { LatestEventIdProvider } from '../internal/events/interface'; import { ProtonDriveAccount } from './account'; import { ProtonDriveConfig } from './config'; +import { FeatureFlagProvider } from './featureFlags'; import { ProtonDriveHTTPClient } from './httpClient'; import { Telemetry, MetricEvent } from './telemetry'; @@ -12,6 +13,7 @@ export type { ProtonDriveAccount, ProtonDriveAccountAddress } from './account'; export type { Author, UnverifiedAuthorError, AnonymousUser } from './author'; export type { ProtonDriveConfig } from './config'; export type { Device, DeviceOrUid } from './devices'; +export type { FeatureFlagProvider } from './featureFlags'; export { DeviceType } from './devices'; export type { FileDownloader, DownloadController, SeekableReadableStream } from './download'; export type { @@ -117,5 +119,6 @@ export interface ProtonDriveClientContructorParameters { srpModule: SRPModule; config?: ProtonDriveConfig; telemetry?: ProtonDriveTelemetry; + featureFlagProvider?: FeatureFlagProvider; latestEventIdProvider?: LatestEventIdProvider; } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 7f7d513e..3814d033 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -1,5 +1,6 @@ import { getConfig } from './config'; import { DriveCrypto, SessionKey } from './crypto'; +import { NullFeatureFlagProvider } from './featureFlags'; import { Logger, ProtonDriveClientContructorParameters, @@ -117,11 +118,15 @@ export class ProtonDriveClient { srpModule, config, telemetry, + featureFlagProvider, latestEventIdProvider, }: ProtonDriveClientContructorParameters) { if (!telemetry) { telemetry = new Telemetry(); } + if (!featureFlagProvider) { + featureFlagProvider = new NullFeatureFlagProvider(); + } this.logger = telemetry.getLogger('interface'); const fullConfig = getConfig(config); From 8173ac416c1da6b6ffaf8d5c3ce31a9707d6ca04 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 15:38:49 +0100 Subject: [PATCH 303/791] Make feature flag provision asynchronous --- cs/Directory.Build.props | 5 +++++ cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs | 6 ++++-- cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs | 2 +- js/sdk/src/featureFlags.ts | 4 ++-- js/sdk/src/interface/featureFlags.ts | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 4cef6f88..35717993 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -24,6 +24,11 @@ lib + + embedded + true + + false diff --git a/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs index e7fff7a0..d045f86f 100644 --- a/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs +++ b/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs @@ -9,8 +9,10 @@ internal sealed class AlwaysDisabledFeatureFlagProvider : IFeatureFlagProvider public static readonly IFeatureFlagProvider Instance = new AlwaysDisabledFeatureFlagProvider(); private AlwaysDisabledFeatureFlagProvider() + { } + + public Task IsEnabledAsync(string flagName, CancellationToken cancellationToken) { + return Task.FromResult(false); } - - public bool IsEnabled(string flagName) => false; } diff --git a/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs index 75b28932..81a082b1 100644 --- a/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs +++ b/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs @@ -2,5 +2,5 @@ namespace Proton.Sdk; public interface IFeatureFlagProvider { - bool IsEnabled(string flagName); + Task IsEnabledAsync(string flagName, CancellationToken cancellationToken); } diff --git a/js/sdk/src/featureFlags.ts b/js/sdk/src/featureFlags.ts index d19d87a6..8188ebe5 100644 --- a/js/sdk/src/featureFlags.ts +++ b/js/sdk/src/featureFlags.ts @@ -5,7 +5,7 @@ import { FeatureFlagProvider } from './interface/featureFlags'; */ export class NullFeatureFlagProvider implements FeatureFlagProvider { // eslint-disable-next-line @typescript-eslint/no-unused-vars - isEnabled(flagName: string): boolean { - return false; + isEnabled(flagName: string, signal?: AbortSignal): Promise { + return Promise.resolve(false); } } diff --git a/js/sdk/src/interface/featureFlags.ts b/js/sdk/src/interface/featureFlags.ts index c11f9b46..d821f922 100644 --- a/js/sdk/src/interface/featureFlags.ts +++ b/js/sdk/src/interface/featureFlags.ts @@ -3,5 +3,5 @@ * Applications must supply their own implementation. */ export interface FeatureFlagProvider { - isEnabled(flagName: string): boolean; + isEnabled(flagName: string, signal?: AbortSignal): Promise; } From 17f293ae2751965fdaa2543ee6c0bf2728249f56 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 14:18:16 +0000 Subject: [PATCH 304/791] Add Kotlin bindings package for Android --- .gitignore | 3 + .../InteropProtonDriveClient.cs | 2 +- kt/build.gradle.kts | 138 ++++++++++ kt/gradle.properties | 46 ++++ kt/gradle/wrapper/gradle-wrapper.properties | 7 + kt/gradlew | 251 ++++++++++++++++++ kt/gradlew.bat | 94 +++++++ kt/libs.versions.toml | 112 ++++++++ kt/sdk/build.gradle.kts | 181 +++++++++++++ kt/sdk/src/main/AndroidManifest.xml | 23 ++ kt/sdk/src/main/jni/Android.mk | 16 ++ kt/sdk/src/main/jni/Application.mk | 2 + kt/sdk/src/main/jni/global.c | 93 +++++++ kt/sdk/src/main/jni/global.h | 22 ++ kt/sdk/src/main/jni/native_library.c | 29 ++ kt/sdk/src/main/jni/proton_drive_sdk.c | 170 ++++++++++++ kt/sdk/src/main/jni/proton_sdk.c | 40 +++ kt/sdk/src/main/jniLibs | 1 + .../kotlin/me/proton/drive/sdk/Cancellable.kt | 27 ++ .../drive/sdk/CancellationTokenSource.kt | 31 +++ .../drive/sdk/CorePublicAddressResolver.kt | 38 +++ .../drive/sdk/CoreUserAddressResolver.kt | 81 ++++++ .../me/proton/drive/sdk/DownloadController.kt | 33 +++ .../kotlin/me/proton/drive/sdk/Downloader.kt | 80 ++++++ .../kotlin/me/proton/drive/sdk/DriveClient.kt | 81 ++++++ .../kotlin/me/proton/drive/sdk/HttpSdkApi.kt | 57 ++++ .../me/proton/drive/sdk/LoggerProvider.kt | 33 +++ .../me/proton/drive/sdk/MetricCallback.kt | 35 +++ .../me/proton/drive/sdk/ProtonDriveSdk.kt | 91 +++++++ .../drive/sdk/ProtonDriveSdkException.kt | 47 ++++ .../me/proton/drive/sdk/ProtonSdkError.kt | 52 ++++ .../proton/drive/sdk/PublicAddressResolver.kt | 24 ++ .../kotlin/me/proton/drive/sdk/SdkNode.kt | 32 +++ .../kotlin/me/proton/drive/sdk/Session.kt | 42 +++ .../me/proton/drive/sdk/TelemetryBridge.kt | 59 ++++ .../main/kotlin/me/proton/drive/sdk/Uid.kt | 28 ++ .../me/proton/drive/sdk/UploadController.kt | 36 +++ .../kotlin/me/proton/drive/sdk/Uploader.kt | 96 +++++++ .../proton/drive/sdk/UserAddressResolver.kt | 29 ++ .../drive/sdk/converter/AnyConverter.kt | 26 ++ .../converter/FileThumbnailListConverter.kt | 28 ++ .../drive/sdk/converter/LongConverter.kt | 28 ++ .../drive/sdk/converter/StringConverter.kt | 28 ++ .../sdk/converter/UploadResultConverter.kt | 29 ++ .../me/proton/drive/sdk/entity/Address.kt | 40 +++ .../drive/sdk/entity/ClientCreateRequest.kt | 30 +++ .../sdk/entity/FileRevisionUploaderRequest.kt | 25 ++ .../drive/sdk/entity/FileUploaderRequest.kt | 29 ++ .../drive/sdk/entity/ProtonClientOptions.kt | 30 +++ .../drive/sdk/entity/ProtonClientTlsPolicy.kt | 25 ++ .../drive/sdk/entity/SessionBeginRequest.kt | 29 ++ .../drive/sdk/entity/SessionRenewRequest.kt | 28 ++ .../drive/sdk/entity/SessionResumeRequest.kt | 33 +++ .../proton/drive/sdk/entity/ThumbnailType.kt | 24 ++ .../proton/drive/sdk/entity/UploadResult.kt | 24 ++ .../me/proton/drive/sdk/extension/Address.kt | 46 ++++ .../ApiRetrySucceededEventPayload.kt | 27 ++ .../BlockVerificationErrorEventPayload.kt | 26 ++ .../proton/drive/sdk/extension/ByteBuffer.kt | 27 ++ .../sdk/extension/CancellableContinuation.kt | 60 +++++ .../drive/sdk/extension/CoroutineScope.kt | 56 ++++ .../extension/DecryptionErrorEventPayload.kt | 30 +++ .../drive/sdk/extension/DownloadError.kt | 33 +++ .../sdk/extension/DownloadEventPayload.kt | 30 +++ .../drive/sdk/extension/EncryptedField.kt | 33 +++ .../me/proton/drive/sdk/extension/Error.kt | 61 +++++ .../extension/FileUploaderCreationRequest.kt | 45 ++++ .../GetFileRevisionUploaderRequest.kt | 34 +++ .../proton/drive/sdk/extension/MessageLite.kt | 28 ++ .../extension/NodeNameConflictErrorData.kt | 28 ++ .../sdk/extension/ProtonClientOptions.kt | 37 +++ .../sdk/extension/ProtonClientTlsPolicy.kt | 33 +++ .../ProtonSdk.IntegerOrErrorResponse.kt | 38 +++ .../sdk/extension/SessionBeginRequest.kt | 31 +++ .../sdk/extension/SessionRenewRequest.kt | 32 +++ .../sdk/extension/SessionResumeRequest.kt | 36 +++ .../proton/drive/sdk/extension/Throwable.kt | 28 ++ .../drive/sdk/extension/ThumbnailType.kt | 29 ++ .../proton/drive/sdk/extension/UploadError.kt | 32 +++ .../drive/sdk/extension/UploadEventPayload.kt | 30 +++ .../drive/sdk/extension/UploadResult.kt | 27 ++ .../VerificationErrorEventPayload.kt | 31 +++ .../proton/drive/sdk/extension/VolumeType.kt | 30 +++ .../drive/sdk/internal/AccountClientBridge.kt | 75 ++++++ .../drive/sdk/internal/ApiProviderBridge.kt | 103 +++++++ .../ContinuationUnitOrErrorResponse.kt | 58 ++++ .../ContinuationValueOrErrorResponse.kt | 63 +++++ .../internal/IgnoredIntegerOrErrorResponse.kt | 25 ++ .../me/proton/drive/sdk/internal/JniBase.kt | 36 +++ .../sdk/internal/JniBaseProtonDriveSdk.kt | 76 ++++++ .../drive/sdk/internal/JniBaseProtonSdk.kt | 69 +++++ .../internal/JniCancellationTokenSource.kt | 49 ++++ .../sdk/internal/JniDownloadController.kt | 56 ++++ .../drive/sdk/internal/JniDownloader.kt | 80 ++++++ .../drive/sdk/internal/JniDriveClient.kt | 102 +++++++ .../drive/sdk/internal/JniLoggerProvider.kt | 78 ++++++ .../drive/sdk/internal/JniNativeLibrary.kt | 27 ++ .../proton/drive/sdk/internal/JniSession.kt | 64 +++++ .../drive/sdk/internal/JniUploadController.kt | 59 ++++ .../proton/drive/sdk/internal/JniUploader.kt | 103 +++++++ .../internal/ProtonDriveSdkNativeClient.kt | 199 ++++++++++++++ .../sdk/internal/ProtonSdkNativeClient.kt | 58 ++++ .../sdk/telemetry/ApiRetrySucceededEvent.kt | 24 ++ .../telemetry/BlockVerificationErrorEvent.kt | 23 ++ .../sdk/telemetry/DecryptionErrorEvent.kt | 27 ++ .../drive/sdk/telemetry/DownloadError.kt | 30 +++ .../drive/sdk/telemetry/DownloadEvent.kt | 27 ++ .../drive/sdk/telemetry/EncryptedField.kt | 30 +++ .../proton/drive/sdk/telemetry/UploadError.kt | 29 ++ .../proton/drive/sdk/telemetry/UploadEvent.kt | 27 ++ .../sdk/telemetry/VerificationErrorEvent.kt | 28 ++ .../proton/drive/sdk/telemetry/VolumeType.kt | 27 ++ kt/settings.gradle.kts | 67 +++++ 113 files changed, 5414 insertions(+), 1 deletion(-) create mode 100644 kt/build.gradle.kts create mode 100644 kt/gradle.properties create mode 100644 kt/gradle/wrapper/gradle-wrapper.properties create mode 100755 kt/gradlew create mode 100644 kt/gradlew.bat create mode 100644 kt/libs.versions.toml create mode 100644 kt/sdk/build.gradle.kts create mode 100644 kt/sdk/src/main/AndroidManifest.xml create mode 100644 kt/sdk/src/main/jni/Android.mk create mode 100644 kt/sdk/src/main/jni/Application.mk create mode 100644 kt/sdk/src/main/jni/global.c create mode 100644 kt/sdk/src/main/jni/global.h create mode 100644 kt/sdk/src/main/jni/native_library.c create mode 100644 kt/sdk/src/main/jni/proton_drive_sdk.c create mode 100644 kt/sdk/src/main/jni/proton_sdk.c create mode 160000 kt/sdk/src/main/jniLibs create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt create mode 100644 kt/settings.gradle.kts diff --git a/.gitignore b/.gitignore index 906b4165..df5975e0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ cache*.sqlite # VS Code .vs +# Intellij +.idea + # Tests tests/storage diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 3067e70a..785a25b1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -62,7 +62,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe var client = Interop.GetFromHandle(request.ClientHandle); - var additionalMetadata = request.AdditionalMetadata.Count > 0 + var additionalMetadata = request.AdditionalMetadata != null && request.AdditionalMetadata.Count > 0 ? request.AdditionalMetadata.Select(x => new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; diff --git a/kt/build.gradle.kts b/kt/build.gradle.kts new file mode 100644 index 00000000..4d2fa95b --- /dev/null +++ b/kt/build.gradle.kts @@ -0,0 +1,138 @@ +import com.android.build.gradle.LibraryExtension +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SonatypeHost +import java.util.Properties + +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +val privateProperties = Properties().apply { + try { + load(rootDir.resolve("private.properties").inputStream()) + } catch (exception: java.io.FileNotFoundException) { + // Provide empty properties to allow the app to be built without secrets + logger.warn("private.properties file not found", exception) + Properties() + } +} + +plugins { + alias(libs.plugins.proton.detekt) + alias(libs.plugins.maven.publish) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.protobuf) apply false +} +allprojects { + repositories { + providers.environmentVariable("INTERNAL_REPOSITORY").orNull?.let { path -> + maven { url = uri(path) } + } + google() + mavenCentral() + maven("https://plugins.gradle.org/m2/") + maven { + url = uri("https://jitpack.io") + content { + includeGroupByRegex("com.github.bastienpaulfr.*") + } + } + } + group = "me.proton.drive" + version = "0.3.1" + + afterEvaluate { + configurations.all { + resolutionStrategy.dependencySubstitution { + substitute(module("com.google.protobuf:protobuf-lite")) + .using(module("com.google.protobuf:protobuf-javalite:${libs.versions.protobufJavaLite.get()}")) + } + } + } +} + +subprojects { + plugins.withId("com.android.library") { + extensions.configure { + compileSdk = 35 + defaultConfig { + minSdk = 26 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + val proxyToken = privateProperties.getProperty("PROXY_TOKEN", "") + val testEnvironment = System.getenv("TEST_ENV_DOMAIN") + val dynamicEnvironment = privateProperties.getProperty("HOST", "proton.black") + val environment = testEnvironment ?: dynamicEnvironment + testInstrumentationRunner = "me.proton.drive.sdk.HiltTestRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + testInstrumentationRunnerArguments["proxyToken"] = proxyToken + testInstrumentationRunnerArguments["host"] = environment + } + } + } + plugins.withId("org.jetbrains.kotlin.android") { + extensions.configure { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + } + plugins.withId("com.vanniktech.maven.publish") { + extensions.configure { + val artifactId = name + + if (!version.toString().endsWith("SNAPSHOT")) { + // Only sign non snapshot release + signAllPublications() + } + pom { + name.set(artifactId) + description.set("Proton Drive sdk for Android") + url.set("https://github.com/ProtonDriveApps/sdk") + licenses { + license { + name.set("GNU GENERAL PUBLIC LICENSE, Version 3.0") + url.set("https://www.gnu.org/licenses/gpl-3.0.en.html") + } + } + developers { + developer { + name.set("Open Source Proton") + email.set("opensource@proton.me") + id.set(email) + } + } + scm { + url.set("https://gitlab.protontech.ch/drive/sdk") + connection.set("git@gitlab.protontech.ch:drive/sdk.git") + developerConnection.set("https://gitlab.protontech.ch/drive/sdk.git") + } + } + } + } +} + +tasks.register("clean", Delete::class) { + delete(rootProject.layout.buildDirectory) +} diff --git a/kt/gradle.properties b/kt/gradle.properties new file mode 100644 index 00000000..848801cb --- /dev/null +++ b/kt/gradle.properties @@ -0,0 +1,46 @@ +# +# Copyright (c) 2023 Proton AG. +# This file is part of Proton Drive. +# +# Proton Drive is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Proton Drive is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Proton Drive. If not, see . +# + +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx7g -XX:+UseParallelGC +org.gradle.parallel=true +org.gradle.caching=true +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +android.useAndroidX=true +android.enableJetifier=false +android.nonTransitiveRClass=true +android.nonFinalResIds=false +# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#r8-full-mode +android.enableR8.fullMode=false +# IncludeGit Gradle Plugin: override include with local. +#auto.include.git.dirs=../ +#local.git.proton-libs=../proton-libs +mavenCentralPublishing=true +mavenCentralAutomaticPublishing=true diff --git a/kt/gradle/wrapper/gradle-wrapper.properties b/kt/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..37f853b1 --- /dev/null +++ b/kt/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/kt/gradlew b/kt/gradlew new file mode 100755 index 00000000..faf93008 --- /dev/null +++ b/kt/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/kt/gradlew.bat b/kt/gradlew.bat new file mode 100644 index 00000000..9d21a218 --- /dev/null +++ b/kt/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kt/libs.versions.toml b/kt/libs.versions.toml new file mode 100644 index 00000000..6e03d6c7 --- /dev/null +++ b/kt/libs.versions.toml @@ -0,0 +1,112 @@ +[versions] +# AndroidX +androidx-compose = "1.7.5" +androidx-room = "2.7.2" +androidx-test = "1.5.0" +# Android tools +android-tools = "1.1.5" +core = "35.0.0" +# Dagger +dagger = "2.53.1" +# Desugar +desugar = "2.0.4" +# Gradle +android-gradle-plugin = "8.9.1" +maven-publish-gradle-plugin = "0.33.0" +proton-detekt-plugin = "1.3.0" +protobuf-plugin = "0.9.4" +# Kotlin +kotlin = "2.0.21" +coroutines = "1.8.0" +okhttp = "4.10.0" +retrofit = "2.9.0" +# Test +junit = "4.13.2" +robolectric = "4.15.1" +fusion = "0.9.97" +testParameterInjector = "1.10" +protobufKotlinLite = "4.29.2" +protobufJavaLite = "4.29.2" + +[libraries] + +# Android tools +desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-tools" } + +# AndroidX +## Room +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } + +# Core +core-account-dagger = { module = "me.proton.core:account-dagger", version.ref = "core" } +core-accountManager-dagger = { module = "me.proton.core:account-manager-dagger", version.ref = "core" } +core-accountRecovery-dagger = { module = "me.proton.core:account-recovery-dagger", version.ref = "core" } +core-auth-domain = { module = "me.proton.core:auth-domain", version.ref = "core" } +core-crypto-dagger = { module = "me.proton.core:crypto-dagger", version.ref = "core" } +core-crypto-android = { module = "me.proton.core:crypto-android", version.ref = "core" } +core-dataRoom = { module = "me.proton.core:data-room", version.ref = "core" } +core-domain = { module = "me.proton.core:domain", version.ref = "core" } +core-featureFlag-dagger = { module = "me.proton.core:feature-flag-dagger", version.ref = "core" } +core-key-dagger = { module = "me.proton.core:key-dagger", version.ref = "core" } +core-network-data = { module = "me.proton.core:network-data", version.ref = "core" } +core-observability-dagger = { module = "me.proton.core:observability-dagger", version.ref = "core" } +core-plan-dagger = { module = "me.proton.core:plan-dagger", version.ref = "core" } +core-test-kotlin = { module = "me.proton.core:test-kotlin", version.ref = "core" } +core-test-quark = { module = "me.proton.core:test-quark", version.ref = "core" } +core-test-rule = { module = "me.proton.core:test-rule", version.ref = "core" } +core-user-dagger = { module = "me.proton.core:user-dagger", version.ref = "core" } +core-user-domain = { module = "me.proton.core:user-domain", version.ref = "core" } +core-userSettings-dagger = { module = "me.proton.core:user-settings-dagger", version.ref = "core" } +core-utilAndroidDatetime = { module = "me.proton.core:util-android-datetime", version.ref = "core" } +core-utilKotlin = { module = "me.proton.core:util-kotlin", version.ref = "core" } + +# Dagger +dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger" } +dagger-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "dagger" } +dagger-hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "dagger" } +dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" } + +# Desugar +tools-desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" } + +# Kotlin +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } + +# Kotlinx +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } + +# Squareup +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttpLoggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } + +# Test +androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidx-test" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +junit = { module = "junit:junit", version.ref = "junit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric"} +fusion = { module = "me.proton.test:fusion", version.ref = "fusion"} +testParameterInjector = { module = "com.google.testparameterinjector:test-parameter-injector", version.ref = "testParameterInjector" } +protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobufKotlinLite" } +protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobufJavaLite" } + +[bundles] +test-android = [ + "junit", + "coroutines-test", + "androidx-test-core-ktx", + "androidx-test-runner", + "androidx-test-rules", +] + +[plugins] +android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish-gradle-plugin" } +proton-detekt = { id = "me.proton.core.gradle-plugins.detekt", version.ref = "proton-detekt-plugin" } +protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" } diff --git a/kt/sdk/build.gradle.kts b/kt/sdk/build.gradle.kts new file mode 100644 index 00000000..2e412c45 --- /dev/null +++ b/kt/sdk/build.gradle.kts @@ -0,0 +1,181 @@ +import com.google.protobuf.gradle.proto + +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.protobuf) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + kotlin("kapt") + alias(libs.plugins.hilt.android) + alias(libs.plugins.maven.publish) + id("signing") +} + +android { + namespace = "me.proton.drive.sdk" + ndkVersion = "28.1.13356709" + externalNativeBuild { + ndkBuild { + path("src/main/jni/Android.mk") + } + } + defaultConfig { + ndk { + abiFilters += listOf( + // x86 will never be supported + "x86_64", + "armeabi-v7a", + "arm64-v8a", + ) + } + externalNativeBuild { + ndkBuild { + arguments("BUILD_DIR=${layout.buildDirectory.asFile.get().path}") + } + } + } + sourceSets { + getByName("main") { + jniLibs.srcDirs(layout.buildDirectory.dir("cs/jni")) + jniLibs.srcDirs("src/main/jniLibs") + proto { + srcDir(layout.buildDirectory.dir("cs/proto")) + } + } + } + packaging { + resources.excludes.add("META-INF/licenses/**") + resources.excludes.add("META-INF/LICENSE*") + resources.excludes.add("META-INF/AL2.0") + resources.excludes.add("META-INF/LGPL2.1") + resources.excludes.add("licenses/*.txt") + resources.excludes.add("licenses/*.xml") + } +} + +dependencies { + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.core.utilKotlin) + implementation(libs.protobuf.javalite) + implementation(libs.protobuf.kotlin.lite) + implementation(libs.retrofit) + implementation(libs.core.user.domain) + implementation(libs.core.network.data) + androidTestImplementation(libs.coroutines.test) + androidTestImplementation(libs.androidx.test.core.ktx) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(files("$rootDir/gopenpgp-v2-v3/gopenpgp.aar")) + androidTestImplementation(libs.core.auth.domain) + androidTestImplementation(libs.core.network.data) + androidTestImplementation(libs.core.crypto.android) + androidTestImplementation(libs.core.domain) + androidTestImplementation(libs.core.account.dagger) + androidTestImplementation(libs.core.accountManager.dagger) { + exclude("me.proton.core", "notification-dagger") + exclude("me.proton.core", "notification-presentation") + exclude("me.proton.core", "account-recovery-presentation-compose") + exclude("me.proton.core", "auth-presentation") + } + androidTestImplementation(libs.core.accountRecovery.dagger) + androidTestImplementation(libs.core.crypto.dagger) + androidTestImplementation(libs.core.featureFlag.dagger) + androidTestImplementation(libs.core.key.dagger) + androidTestImplementation(libs.core.plan.dagger) + androidTestImplementation(libs.core.user.dagger) + androidTestImplementation(libs.core.userSettings.dagger) { + exclude("me.proton.core", "account-manager-presentation") + exclude("me.proton.core", "user-settings-presentation") + } + androidTestImplementation(libs.core.utilAndroidDatetime) { + exclude("me.proton.core", "presentation") + } + androidTestImplementation(libs.core.observability.dagger) + androidTestImplementation(libs.dagger.hilt.android.testing) + androidTestImplementation(libs.dagger.hilt.android) + kaptAndroidTest(libs.dagger.hilt.android.compiler) + kaptAndroidTest(libs.androidx.room.compiler) + androidTestImplementation(libs.core.dataRoom) + androidTestImplementation(libs.core.test.kotlin) + androidTestImplementation(libs.core.test.quark) + androidTestImplementation(libs.core.test.rule) { + exclude("me.proton.core", "auth-presentation") + } + androidTestImplementation(libs.kotlin.reflect) + androidTestImplementation(libs.okhttpLoggingInterceptor) + androidTestImplementation(libs.androidx.room.ktx) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.29.2" + } + generateProtoTasks { + all().forEach { task -> + task.builtins { + create("java") { + option("lite") + } + create("kotlin") { + option("lite") + } + } + } + } +} + +tasks.register("copyHeader") { + from(layout.projectDirectory.dir("../../cs/headers")) { + include { file -> file.name.endsWith(".h") } + } + into(layout.buildDirectory.dir("cs/includes")) +} + +tasks.register("copySharedLibrary") { + from(layout.projectDirectory.dir("../../cs/sdk/bin")) { + include("**/libproton_drive_sdk.so") + } + into(layout.buildDirectory.dir("cs/jni")) +} + +tasks.named { name -> + name.startsWith("configureNdkBuild") +}.configureEach { + dependsOn("copyHeader") + dependsOn("copySharedLibrary") +} + +tasks.named { name -> + name.matches("merge.*JniLibFolders".toRegex()) +}.configureEach { + dependsOn("copySharedLibrary") +} + +tasks.register("copyProto") { + from(layout.projectDirectory.dir("../../cs/sdk/src/protos")) { + include { file -> file.name.endsWith(".proto") } + } + into(layout.buildDirectory.dir("cs/proto")) +} + +tasks.named { name -> + name.matches("generate.*Proto".toRegex()) +}.configureEach { dependsOn("copyProto") } diff --git a/kt/sdk/src/main/AndroidManifest.xml b/kt/sdk/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b7f1ea45 --- /dev/null +++ b/kt/sdk/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/kt/sdk/src/main/jni/Android.mk b/kt/sdk/src/main/jni/Android.mk new file mode 100644 index 00000000..032ed030 --- /dev/null +++ b/kt/sdk/src/main/jni/Android.mk @@ -0,0 +1,16 @@ +LOCAL_PATH := $(call my-dir) +BUILD_DIR := $(BUILD_DIR) + +include $(CLEAR_VARS) +LOCAL_MODULE := proton_drive_sdk +LOCAL_SRC_FILES := $(BUILD_DIR)/cs/jni/$(TARGET_ARCH_ABI)/libproton_drive_sdk.so +LOCAL_EXPORT_C_INCLUDES := $(BUILD_DIR)/cs/includes +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) +LOCAL_MODULE := proton_drive_sdk_jni +LOCAL_SRC_FILES := global.c native_library.c proton_drive_sdk.c proton_sdk.c +LOCAL_SHARED_LIBRARIES := proton_drive_sdk +LOCAL_C_INCLUDES += $(BUILD_DIR)/cs/includes +LOCAL_LDLIBS := -llog +include $(BUILD_SHARED_LIBRARY) diff --git a/kt/sdk/src/main/jni/Application.mk b/kt/sdk/src/main/jni/Application.mk new file mode 100644 index 00000000..79e4c495 --- /dev/null +++ b/kt/sdk/src/main/jni/Application.mk @@ -0,0 +1,2 @@ +# x86 will never be supported +APP_ABI := arm64-v8a x86_64 armeabi-v7a diff --git a/kt/sdk/src/main/jni/global.c b/kt/sdk/src/main/jni/global.c new file mode 100644 index 00000000..fac98763 --- /dev/null +++ b/kt/sdk/src/main/jni/global.c @@ -0,0 +1,93 @@ +#include +#include +#include +#include "proton_sdk.h" + +JavaVM *g_vm; + +jint JNI_OnLoad(JavaVM *vm, void *reserved) { + g_vm = vm; + JNIEnv *env; + if ((*vm)->GetEnv(vm, (void **) &env, JNI_VERSION_1_6) != JNI_OK) { + return -1; + } + return JNI_VERSION_1_6; +} + +JNIEnv *getJNIEnv() { + JNIEnv *env; + (*g_vm)->GetEnv(g_vm, (void **) &env, JNI_VERSION_1_6 /*version*/); + if (env == NULL) { + (*g_vm)->AttachCurrentThread(g_vm, &env, NULL); + } + return env; +} + +void pushDataToVoidMethod( + intptr_t bindings_handle, + ByteArray value, + const char *name +) { + JNIEnv *env = getJNIEnv(); + jobject obj = (*env)->NewLocalRef(env, (jweak) bindings_handle); + if ((*env)->IsSameObject(env, obj, NULL)) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Object was recycled for: %s %ld", name, bindings_handle + ); + return; + } else { + jclass cls = (*env)->GetObjectClass(env, obj); + jmethodID mid = (*env)->GetMethodID(env, cls, name, "(Ljava/nio/ByteBuffer;)V"); + if (mid == 0) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Cannot found method: %s", name + ); + return; + } + jobject buffer = (*env)->NewDirectByteBuffer( + env, + (void *) value.pointer, + (jlong) value.length + ); + (*env)->CallVoidMethod(env, obj, mid, buffer); + } +} + +void pushDataAndLongToVoidMethod( + intptr_t bindings_handle, + ByteArray value, + intptr_t caller_state, + const char *name +) { + JNIEnv *env = getJNIEnv(); + jobject obj = (*env)->NewLocalRef(env, (jweak) bindings_handle); + if ((*env)->IsSameObject(env, obj, NULL)) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Object was recycled for: %s %ld", name, bindings_handle + ); + return; + } else { + jclass cls = (*env)->GetObjectClass(env, obj); + jmethodID mid = (*env)->GetMethodID(env, cls, name, "(Ljava/nio/ByteBuffer;J)V"); + if (mid == 0) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Cannot found method: %s", name + ); + return; + } + jobject buffer = (*env)->NewDirectByteBuffer( + env, + (void *) value.pointer, + (jlong) value.length + ); + (*env)->CallVoidMethod(env, obj, mid, buffer, caller_state); + } +} diff --git a/kt/sdk/src/main/jni/global.h b/kt/sdk/src/main/jni/global.h new file mode 100644 index 00000000..124cc974 --- /dev/null +++ b/kt/sdk/src/main/jni/global.h @@ -0,0 +1,22 @@ +#include +#include "proton_drive_sdk.h" + +#ifndef PROTONDRIVE_GLOBAL_H +#define PROTONDRIVE_GLOBAL_H + +JNIEnv *getJNIEnv(); + +void pushDataToVoidMethod( + intptr_t bindings_handle, + ByteArray value, + const char *name +); + +void pushDataAndLongToVoidMethod( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle, + const char *name +); + +#endif //PROTONDRIVE_GLOBAL_H diff --git a/kt/sdk/src/main/jni/native_library.c b/kt/sdk/src/main/jni/native_library.c new file mode 100644 index 00000000..a95ed836 --- /dev/null +++ b/kt/sdk/src/main/jni/native_library.c @@ -0,0 +1,29 @@ +#include +#include +#include +#include "proton_drive_sdk.h" +#include "global.h" + +void Java_me_proton_drive_sdk_internal_JniNativeLibrary_overrideName( + JNIEnv *env, + jobject obj, + jbyteArray name, + jbyteArray overridingName +) { + ByteArray nameByteArray; + jbyte *nameBufferElems = (*env)->GetByteArrayElements(env, name, 0); + nameByteArray.pointer = (const uint8_t *) nameBufferElems; + nameByteArray.length = (*env)->GetArrayLength(env, name); + + ByteArray overridingNameByteArray; + jbyte *overridingNameBufferElems = (*env)->GetByteArrayElements(env, overridingName, 0); + overridingNameByteArray.pointer = (const uint8_t *) overridingNameBufferElems; + overridingNameByteArray.length = (*env)->GetArrayLength(env, overridingName); + + override_native_library_name( + nameByteArray, + overridingNameByteArray + ); + (*env)->ReleaseByteArrayElements(env, name, nameBufferElems, 0); + (*env)->ReleaseByteArrayElements(env, overridingName, overridingNameBufferElems, 0); +} diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c new file mode 100644 index 00000000..ccb6126d --- /dev/null +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -0,0 +1,170 @@ +#include +#include +#include +#include +#include "proton_drive_sdk.h" +#include "global.h" + +void onDriveSdkResponse(intptr_t bindings_handle, ByteArray value) { + pushDataToVoidMethod(bindings_handle, value, "onResponse"); +} + +void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleRequest( + JNIEnv *env, + jobject obj, + jbyteArray request +) { + jbyte *bufferElems = (*env)->GetByteArrayElements(env, request, 0); + ByteArray byteArray; + byteArray.pointer = (const uint8_t *) bufferElems; + byteArray.length = (*env)->GetArrayLength(env, request); + intptr_t weakObjRef = (intptr_t) (*env)->NewWeakGlobalRef(env, obj); + + proton_drive_sdk_handle_request( + byteArray, + weakObjRef, + onDriveSdkResponse + ); + + (*env)->ReleaseByteArrayElements(env, request, bufferElems, 0); +} + +void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleResponse( + JNIEnv *env, + jobject obj, + jlong sdk_handle, + jbyteArray response +) { + jbyte *bufferElems = (*env)->GetByteArrayElements(env, response, 0); + ByteArray byteArray; + byteArray.pointer = (const uint8_t *) bufferElems; + byteArray.length = (*env)->GetArrayLength(env, response); + + proton_drive_sdk_handle_response( + (intptr_t) sdk_handle, + byteArray + ); + + (*env)->ReleaseByteArrayElements(env, response, bufferElems, 0); +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getByteArray( + JNIEnv *env, + jobject obj, + jbyteArray array +) { + jsize length = (*env)->GetArrayLength(env, array); + jbyte *data = (*env)->GetByteArrayElements(env, array, NULL); + + // Allocate native memory + jbyte *buffer = (jbyte *) malloc(length); + if (buffer == NULL) { + (*env)->ReleaseByteArrayElements(env, array, data, JNI_ABORT); + return 0; // OOM + } + + // Copy into native memory + memcpy(buffer, data, length); + + (*env)->ReleaseByteArrayElements(env, array, data, JNI_ABORT); + + // Return as jlong handle + return (jlong) buffer; +} + +void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_releaseByteArray( + JNIEnv *env, + jobject obj, + jlong ptr +) { + if (ptr != 0) { + free((void *) ptr); + } +} + + +void onRead( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onRead"); +} + +void onWrite( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onWrite"); +} + +void onProgress(intptr_t bindings_handle, ByteArray value) { + pushDataToVoidMethod(bindings_handle, value, "onProgress"); +} + +void onSendHttpRequest( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onSendHttpRequest"); +} + +void onRequest( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onRequest"); +} + +void onRecordMetric( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + pushDataToVoidMethod(bindings_handle, value, "onRecordMetric"); +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getReadPointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onRead; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getWritePointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onWrite; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getProgressPointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onProgress; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSendHttpRequestPointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onSendHttpRequest; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getRequestPointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onRequest; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getRecordMetricPointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onRecordMetric; +} diff --git a/kt/sdk/src/main/jni/proton_sdk.c b/kt/sdk/src/main/jni/proton_sdk.c new file mode 100644 index 00000000..58278838 --- /dev/null +++ b/kt/sdk/src/main/jni/proton_sdk.c @@ -0,0 +1,40 @@ +#include +#include +#include +#include "proton_drive_sdk.h" +#include "global.h" + +void onSdkResponse(intptr_t bindings_handle, ByteArray value) { + pushDataToVoidMethod(bindings_handle, value, "onResponse"); +} + +void Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_handleRequest( + JNIEnv *env, + jobject obj, + jbyteArray request +) { + jbyte *bufferElems = (*env)->GetByteArrayElements(env, request, 0); + ByteArray byteArray; + byteArray.pointer = (const uint8_t *) bufferElems; + byteArray.length = (*env)->GetArrayLength(env, request); + intptr_t weakObjRef = (intptr_t) (*env)->NewWeakGlobalRef(env, obj); + + proton_sdk_handle_request( + byteArray, + weakObjRef, + onSdkResponse + ); + + (*env)->ReleaseByteArrayElements(env, request, bufferElems, 0); +} + +void onCallback(intptr_t bindings_handle, ByteArray value) { + pushDataToVoidMethod(bindings_handle, value, "onCallback"); +} + +jlong Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_getCallbackPointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onCallback; +} diff --git a/kt/sdk/src/main/jniLibs b/kt/sdk/src/main/jniLibs new file mode 160000 index 00000000..d67f3231 --- /dev/null +++ b/kt/sdk/src/main/jniLibs @@ -0,0 +1 @@ +Subproject commit d67f323161090e66a6e278c0dd995ac3cf442621 diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt new file mode 100644 index 00000000..edc66851 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +interface Cancellable { + val cancellationTokenSource: CancellationTokenSource + + suspend fun cancel() { + cancellationTokenSource.cancel() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt new file mode 100644 index 00000000..3f012024 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.internal.JniCancellationTokenSource + +class CancellationTokenSource internal constructor( + internal val handle: Long, + private val bridge: JniCancellationTokenSource +) : AutoCloseable { + + suspend fun cancel() = bridge.cancel(handle) + + override fun close() = bridge.free(handle) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt new file mode 100644 index 00000000..5529ef28 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.extension.publicKeyRing +import me.proton.core.key.domain.repository.PublicAddressRepository + +class CorePublicAddressResolver( + private val userId: UserId, + private val publicAddressRepository: PublicAddressRepository, +) : PublicAddressResolver { + + override suspend fun getAddressPublicKeys(emailAddress: String): List = + publicAddressRepository.getPublicAddressInfo( + sessionUserId = userId, + email = emailAddress + ).address.keys.publicKeyRing().keys.map { publicKey -> + publicKey.key.toByteArray() + } + +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt new file mode 100644 index 00000000..05ca4ac5 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.extension.primary +import me.proton.core.key.domain.useKeys +import me.proton.core.user.domain.entity.AddressId +import me.proton.core.user.domain.entity.UserAddress +import me.proton.core.user.domain.extension.canEncrypt +import me.proton.core.user.domain.extension.canVerify +import me.proton.core.user.domain.extension.primary +import me.proton.core.user.domain.repository.UserAddressRepository +import me.proton.drive.sdk.entity.Address + +class CoreUserAddressResolver( + private val userId: UserId, + private val cryptoContext: CryptoContext, + private val userAddressRepository: UserAddressRepository, +) : UserAddressResolver { + override suspend fun getAddress(id: String): Address = + checkNotNull(userAddressRepository.getAddress(userId, AddressId(id))) { + "Cannot found address: $id" + }.toSdkAddress() + + override suspend fun getDefaultAddress(): Address = + checkNotNull(userAddressRepository.getAddresses(userId).primary()) { + "Cannot found default address" + }.toSdkAddress() + + override suspend fun getAddressPrimaryPrivateKey(id: String, block: (ByteArray) -> T): T = + checkNotNull(userAddressRepository.getAddress(userId, AddressId(id))) { + "Cannot found address: $id" + }.useKeys(cryptoContext) { + block(privateKeyRing.unlockedPrimaryKey.unlockedKey.value) + } + + override suspend fun getAddressPrivateKeys(id: String, block: (List) -> T): T = + checkNotNull(userAddressRepository.getAddress(userId, AddressId(id))) { + "Cannot found address: $id" + }.useKeys(cryptoContext) { + block(privateKeyRing.unlockedKeys.map { key -> key.unlockedKey.value }) + } + + private fun UserAddress.toSdkAddress() = Address( + addressId = addressId.id, + order = order, + emailAddress = email, + status = when { + enabled -> Address.Status.ENABLED + else -> Address.Status.DISABLED + }, + keys = keys.map { key -> + Address.Key( + addressId = key.addressId.id, + keyId = key.keyId.id, + active = key.active, + allowedForEncryption = key.canEncrypt(), + allowedForVerification = key.canVerify(), + ) + }, + primaryKeyIndex = keys.indexOf(keys.primary()) + ) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt new file mode 100644 index 00000000..d03d6c1e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.internal.JniDownloadController + +class DownloadController internal constructor( + downloader: Downloader, + internal val handle: Long, + private val bridge: JniDownloadController, + override val cancellationTokenSource: CancellationTokenSource, +) : SdkNode(downloader), AutoCloseable, Cancellable { + + suspend fun awaitCompletion() = bridge.awaitCompletion(handle) + + override fun close() = bridge.free(handle) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt new file mode 100644 index 00000000..d1a8a13d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.internal.JniDownloadController +import me.proton.drive.sdk.internal.JniDownloader +import java.io.OutputStream +import java.nio.ByteBuffer +import java.nio.channels.Channels + +class Downloader internal constructor( + client: DriveClient, + internal val handle: Long, + private val bridge: JniDownloader, + override val cancellationTokenSource: CancellationTokenSource +) : SdkNode(client), AutoCloseable, Cancellable { + + suspend fun downloadToStream( + coroutineScope: CoroutineScope, + outputStream: OutputStream, + progress: suspend (Long, Long) -> Unit = { _, _ -> }, + ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> + val handle = bridge.downloadToStream( + handle = handle, + cancellationTokenSourceHandle = cancellationTokenSource.handle, + onWrite = { byteBuffer: ByteBuffer -> + Channels.newChannel(outputStream).write(byteBuffer) + }, + onProgress = { progressUpdate -> + with(progressUpdate) { + progress(bytesCompleted, bytesInTotal) + } + }, + coroutineScope = coroutineScope, + ) + DownloadController( + downloader = this@Downloader, + handle = handle, + bridge = JniDownloadController(), + cancellationTokenSource = cancellationTokenSource, + ) + } + + override fun close() { + bridge.free(handle) + super.close() + } +} + +suspend fun DriveClient.downloader( + revisionUid: String +): Downloader = cancellationTokenSource().let { source -> + val client = this@downloader + JniDownloader().run { + Downloader( + client = client, + handle = create(handle, source.handle, revisionUid), + bridge = this, + cancellationTokenSource = source, + ) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt new file mode 100644 index 00000000..0a43da6c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.toEntity +import me.proton.drive.sdk.internal.JniDriveClient +import proton.drive.sdk.driveClientGetAvailableNameRequest +import proton.drive.sdk.driveClientGetThumbnailsRequest +import java.io.OutputStream + +class DriveClient internal constructor( + internal val handle: Long, + private val bridge: JniDriveClient, + session: Session? = null, +) : SdkNode(session), AutoCloseable { + + suspend fun getAvailableName( + parentFolderUid: String, + name: String, + ): String = cancellationTokenSource().let { source -> + bridge.getAvailableName( + driveClientGetAvailableNameRequest { + this.parentFolderUid = parentFolderUid + this.name = name + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ) + } + + suspend fun getThumbnails( + fileUids: List, + block: (String, ThumbnailType) -> OutputStream?, + ): Unit = cancellationTokenSource().let { source -> + bridge.getThumbnails( + driveClientGetThumbnailsRequest { + this.fileUids += fileUids + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).thumbnailsList.forEach { fileThumbnail -> + fileThumbnail.type.toEntity()?.let { thumbnailType -> + block(fileThumbnail.fileUid, thumbnailType) + }?.use { outputStream -> + outputStream.write(fileThumbnail.data.toByteArray()) + } + } + } + + override fun close() { + bridge.free(handle) + super.close() + } +} + +suspend fun Session.driveClientCreate(): DriveClient = JniDriveClient().run { + val session = this@driveClientCreate + DriveClient( + session = session, + handle = create(handle), + bridge = this, + ) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt new file mode 100644 index 00000000..92915b82 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.core.network.data.protonApi.BaseRetrofitApi +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.HTTP +import retrofit2.http.HeaderMap +import retrofit2.http.Url + +interface HttpSdkApi : BaseRetrofitApi { + @HTTP(method = "GET", path = "", hasBody = false) + suspend fun get( + @Url url: String, + @HeaderMap headers: Map = emptyMap() + ): Response + + @HTTP(method = "POST", path = "", hasBody = true) + suspend fun post( + @Url url: String, + @HeaderMap headers: Map = emptyMap(), + @Body body: RequestBody? = null + ): Response + + @HTTP(method = "PUT", path = "", hasBody = true) + suspend fun put( + @Url url: String, + @HeaderMap headers: Map = emptyMap(), + @Body body: RequestBody? = null + ): Response + + @HTTP(method = "DELETE", path = "", hasBody = true) + suspend fun delete( + @Url url: String, + @HeaderMap headers: Map = emptyMap(), + @Body body: RequestBody? = null + ): Response +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt new file mode 100644 index 00000000..3f7b34eb --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.internal.JniLoggerProvider + +typealias SdkLogger = (level: LoggerProvider.Level, category: String, message: String) -> Unit + +class LoggerProvider internal constructor( + internal val handle: Long, + private val bridge: JniLoggerProvider +) { + + enum class Level { + VERBOSE, DEBUG, INFO, WARN, ERROR, + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt new file mode 100644 index 00000000..159fdc79 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.telemetry.ApiRetrySucceededEvent +import me.proton.drive.sdk.telemetry.BlockVerificationErrorEvent +import me.proton.drive.sdk.telemetry.DecryptionErrorEvent +import me.proton.drive.sdk.telemetry.DownloadEvent +import me.proton.drive.sdk.telemetry.UploadEvent +import me.proton.drive.sdk.telemetry.VerificationErrorEvent + +interface MetricCallback { + fun onApiRetrySucceededEvent(event: ApiRetrySucceededEvent) + fun onBlockVerificationErrorEvent(event: BlockVerificationErrorEvent) + fun onDecryptionErrorEvent(event: DecryptionErrorEvent) + fun onDownloadEvent(event: DownloadEvent) + fun onUploadEvent(event: UploadEvent) + fun onVerificationErrorEvent(event: VerificationErrorEvent) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt new file mode 100644 index 00000000..f09b75aa --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024-2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import me.proton.core.domain.entity.UserId +import me.proton.core.network.data.ApiProvider +import me.proton.drive.sdk.entity.ClientCreateRequest +import me.proton.drive.sdk.entity.SessionBeginRequest +import me.proton.drive.sdk.entity.SessionResumeRequest +import me.proton.drive.sdk.internal.AccountClientBridge +import me.proton.drive.sdk.internal.ApiProviderBridge +import me.proton.drive.sdk.internal.JniCancellationTokenSource +import me.proton.drive.sdk.internal.JniDriveClient +import me.proton.drive.sdk.internal.JniLoggerProvider +import me.proton.drive.sdk.internal.JniNativeLibrary +import me.proton.drive.sdk.internal.JniSession + +object ProtonDriveSdk { + init { + System.loadLibrary("proton_drive_sdk_jni") + overrideName() + } + + suspend fun loggerProvider(logger: SdkLogger): LoggerProvider = JniLoggerProvider(logger).run { + LoggerProvider(create(), this) + } + + suspend fun sessionBegin( + request: SessionBeginRequest, + ): Session = cancellationTokenSource().let { source -> + JniSession().run { + Session(begin(source.handle, request), this, source) + } + } + + suspend fun sessionResume( + request: SessionResumeRequest, + ): Session = cancellationTokenSource().let { source -> + JniSession().run { + Session(resume(request), this, source) + } + } + + suspend fun driveClientCreate( + coroutineScope: CoroutineScope, + userId: UserId, + apiProvider: ApiProvider, + request: ClientCreateRequest, + userAddressResolver: UserAddressResolver, + publicAddressResolver: PublicAddressResolver, + metricCallback: MetricCallback? = null, + ): DriveClient = JniDriveClient().run { + DriveClient( + create( + coroutineScope = coroutineScope, + request = request, + onSendHttpRequest = ApiProviderBridge(userId, apiProvider), + onRequest = AccountClientBridge(userAddressResolver, publicAddressResolver), + onRecordMetric = metricCallback?.let(::TelemetryBridge) ?: {}, + ), this + ) + } + + internal suspend fun cancellationTokenSource(): CancellationTokenSource = + JniCancellationTokenSource().run { + CancellationTokenSource(create(), this) + } + + private fun overrideName() { + JniNativeLibrary().overrideName( + libraryName = "proton_crypto".toByteArray(), + overridingLibraryName = "gojni".toByteArray() + ) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt new file mode 100644 index 00000000..7c5f30c4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +class ProtonDriveSdkException( + override val message: String? = null, + override val cause: Throwable? = null, + val error: ProtonSdkError? = null +) : Throwable(message, cause) { + override fun toString(): String { + return buildString { + appendLine(super.toString()) + appendError(error) + } + } +} + +private fun StringBuilder.appendError(error: ProtonSdkError?) { + error?.run { + appendLine("type: $type") + appendLine("domain: $domain") + appendLine("primaryCode: $primaryCode") + appendLine("secondaryCode: $secondaryCode") + appendLine("additionalData: $additionalData") + appendLine(context) + if (innerError != null) { + appendLine("Caused by: ${innerError.message}") + appendError(innerError) + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt new file mode 100644 index 00000000..8344d11a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +data class ProtonSdkError( + val message: String, + val type: String, + val domain: ErrorDomain = ErrorDomain.Undefined, + val primaryCode: Long? = null, + val secondaryCode: Long? = null, + val context: String? = null, + val innerError: ProtonSdkError? = null, + val additionalData: Data? = null, +) { + + enum class ErrorDomain { + Undefined, + SuccessfulCancellation, + Api, + Network, + Transport, + Serialization, + Cryptography, + DataIntegrity, + BusinessLogic, + UNRECOGNIZED, + } + + sealed interface Data { + data class NodeNameConflict ( + val conflictingNodeIsFileDraft: Boolean, + val conflictingNodeUid: String, + val conflictingRevisionUid: String, + ): Data + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt new file mode 100644 index 00000000..271bf3fb --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +interface PublicAddressResolver { + + suspend fun getAddressPublicKeys(emailAddress: String): List +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt new file mode 100644 index 00000000..4103e2f3 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +abstract class SdkNode(val parent: SdkNode?) : AutoCloseable { + + private var children: List = emptyList() + + init { + parent?.run { children += this } + } + + override fun close() { + parent?.run { children -= this } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt new file mode 100644 index 00000000..b4251940 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.entity.SessionRenewRequest +import me.proton.drive.sdk.internal.JniSession + +class Session internal constructor( + internal val handle: Long, + private val bridge: JniSession, + override val cancellationTokenSource: CancellationTokenSource +) : SdkNode(null), AutoCloseable, Cancellable { + + suspend fun renew( + request: SessionRenewRequest, + ): Session = bridge.renew(handle, request).run { + Session(this, bridge, cancellationTokenSource) + } + + suspend fun end() = bridge.end(handle) + + override fun close() { + bridge.free(handle) + super.close() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt new file mode 100644 index 00000000..f9fa6db1 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.extension.toEvent +import proton.drive.sdk.ProtonDriveSdk +import proton.sdk.ProtonSdk + +class TelemetryBridge( + private val callback: MetricCallback, +) : suspend (ProtonSdk.MetricEvent) -> Unit { + override suspend fun invoke(event: ProtonSdk.MetricEvent) { + val data = event.payload.value + when (event.payload.typeUrl) { + "type.googleapis.com/proton.sdk.ApiRetrySucceededEventPayload" -> callback.onApiRetrySucceededEvent( + ProtonSdk.ApiRetrySucceededEventPayload.parseFrom(data).toEvent() + ) + + "type.googleapis.com/proton.drive.sdk.BlockVerificationErrorEventPayload" -> + callback.onBlockVerificationErrorEvent( + ProtonDriveSdk.BlockVerificationErrorEventPayload.parseFrom(data).toEvent() + ) + + "type.googleapis.com/proton.drive.sdk.DecryptionErrorEventPayload" -> callback.onDecryptionErrorEvent( + ProtonDriveSdk.DecryptionErrorEventPayload.parseFrom(data).toEvent() + ) + + "type.googleapis.com/proton.drive.sdk.DownloadEventPayload" -> callback.onDownloadEvent( + ProtonDriveSdk.DownloadEventPayload.parseFrom(data).toEvent() + ) + + "type.googleapis.com/proton.drive.sdk.UploadEventPayload" -> callback.onUploadEvent( + ProtonDriveSdk.UploadEventPayload.parseFrom(data).toEvent() + ) + + "type.googleapis.com/proton.drive.sdk.VerificationErrorEventPayload" -> callback.onVerificationErrorEvent( + ProtonDriveSdk.VerificationErrorEventPayload.parseFrom(data).toEvent() + ) + + else -> error("Cannot parse ${event.name} (${event.payload.typeUrl})") + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt new file mode 100644 index 00000000..07e73fcd --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk + +object Uid { + + fun makeNodeUid(volumeId: String, nodeId: String) = makeUid(volumeId, nodeId) + fun makeNodeRevisionUid(volumeId: String, nodeId: String, revisionId: String) = + makeUid(volumeId, nodeId, revisionId) + + private fun makeUid(vararg ids: String) = ids.joinToString("~") +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt new file mode 100644 index 00000000..337e8484 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.internal.JniUploadController + +class UploadController internal constructor( + uploader: Uploader, + internal val handle: Long, + private val bridge: JniUploadController, + override val cancellationTokenSource: CancellationTokenSource, +) : SdkNode(uploader), AutoCloseable, Cancellable { + + suspend fun awaitCompletion() = bridge.awaitCompletion(handle) + + override fun close() { + bridge.free(handle) + super.close() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt new file mode 100644 index 00000000..3c3917ab --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.entity.FileRevisionUploaderRequest +import me.proton.drive.sdk.entity.FileUploaderRequest +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.internal.JniUploadController +import me.proton.drive.sdk.internal.JniUploader +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.channels.Channels + +class Uploader internal constructor( + client: DriveClient, + internal val handle: Long, + private val bridge: JniUploader, + override val cancellationTokenSource: CancellationTokenSource, +) : SdkNode(client), AutoCloseable, Cancellable { + + suspend fun uploadFromStream( + coroutineScope: CoroutineScope, + inputStream: InputStream, + thumbnails: Map = emptyMap(), + progress: suspend (Long, Long) -> Unit = { _, _ -> }, + ): UploadController = cancellationTokenSource().let { source -> + val handle = bridge.uploadFromStream( + uploaderHandle = handle, + cancellationTokenSourceHandle = source.handle, + thumbnails = thumbnails, + onRead = { buffer: ByteBuffer -> + Channels.newChannel(inputStream).read(buffer) + }, + onProgress = { progressUpdate -> + with(progressUpdate) { + progress(bytesCompleted, bytesInTotal) + } + }, + coroutineScope = coroutineScope + ) + UploadController( + uploader = this@Uploader, + handle = handle, + bridge = JniUploadController(), + cancellationTokenSource = source, + ) + } + + override fun close() = bridge.free(handle) +} + +suspend fun DriveClient.uploader( + request: FileUploaderRequest +): Uploader = cancellationTokenSource().let { source -> + val client = this + JniUploader().run { + Uploader( + client = client, + handle = getFile(client.handle, source.handle, request), + bridge = this, + cancellationTokenSource = source, + ) + } +} + +suspend fun DriveClient.uploader( + request: FileRevisionUploaderRequest +): Uploader = cancellationTokenSource().let { source -> + val client = this@uploader + JniUploader().run { + Uploader( + client = client, + handle = getFileRevision(handle, source.handle, request), + bridge = this, + cancellationTokenSource = source, + ) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt new file mode 100644 index 00000000..75f3c25e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk + +import me.proton.drive.sdk.entity.Address + +interface UserAddressResolver { + + suspend fun getAddress(id: String): Address + suspend fun getDefaultAddress(): Address + suspend fun getAddressPrimaryPrivateKey(id: String, block: (ByteArray) -> T): T + suspend fun getAddressPrivateKeys(id: String, block: (List) -> T): T +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt new file mode 100644 index 00000000..e343e1c0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any + +interface AnyConverter { + val typeUrl: String + fun convert(any: Any): T +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt new file mode 100644 index 00000000..f19b8b91 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class FileThumbnailListConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.FileThumbnailList" + + override fun convert(any: Any): ProtonDriveSdk.FileThumbnailList = ProtonDriveSdk.FileThumbnailList.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt new file mode 100644 index 00000000..3a8b5cba --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import com.google.protobuf.Int64Value + +class LongConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/google.protobuf.Int64Value" + + override fun convert(any: Any): Long = Int64Value.parseFrom(any.value).value +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt new file mode 100644 index 00000000..97a200db --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import com.google.protobuf.StringValue + +class StringConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/google.protobuf.StringValue" + + override fun convert(any: Any): String = StringValue.parseFrom(any.value).value +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt new file mode 100644 index 00000000..1b3eea27 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class UploadResultConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.UploadResult" + + override fun convert(any: Any): ProtonDriveSdk.UploadResult = + ProtonDriveSdk.UploadResult.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt new file mode 100644 index 00000000..e08f3a40 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +data class Address( + val addressId: String, + val order: Int, + val emailAddress: String, + val status: Status, + val keys: List, + val primaryKeyIndex: Int, +) { + enum class Status { + DISABLED, ENABLED, DELETING, + } + + data class Key( + val addressId: String, + val keyId: String, + val active: Boolean, + val allowedForEncryption: Boolean, + val allowedForVerification: Boolean, + ) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt new file mode 100644 index 00000000..a5b67b80 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +import me.proton.drive.sdk.LoggerProvider + +data class ClientCreateRequest( + val baseUrl: String, + val entityCachePath: String, + val secretCachePath: String, + val loggerProvider: LoggerProvider, + val bindingsLanguage: String? = null, + val uid: String? = null, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt new file mode 100644 index 00000000..8b217452 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +data class FileRevisionUploaderRequest( + val currentActiveRevisionUid: String, + val lastModificationTime: Long, + val size: Long, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt new file mode 100644 index 00000000..897ed1c8 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +data class FileUploaderRequest( + val parentFolderUid: String, + val name: String, + val mediaType: String, + val fileSize: Long, + val lastModificationTime: Long, + val overrideExistingDraftByOtherClient: Boolean, + val additionalMetadata: Map = emptyMap() +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt new file mode 100644 index 00000000..0cf8d22a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +import me.proton.drive.sdk.LoggerProvider + +data class ProtonClientOptions( + val userAgent: String? = null, + val baseUrl: String? = null, + val bindingsLanguage: String? = null, + val tlsPolicy: ProtonClientTlsPolicy? = null, + val loggerProvider: LoggerProvider? = null, + val entityCachePath: String? = null, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt new file mode 100644 index 00000000..ad8fd557 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +enum class ProtonClientTlsPolicy { + STRICT, + NO_CERTIFICATE_PINNING, + NO_CERTIFICATE_VALIDATION, +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt new file mode 100644 index 00000000..e70d3221 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +import java.io.File + +data class SessionBeginRequest( + val username: String, + val password: String, + val appVersion: String, + val secretCache: File, + val options: ProtonClientOptions, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt new file mode 100644 index 00000000..6ee2cd48 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +data class SessionRenewRequest( + val sessionId: String, + val accessToken: String, + val refreshToken: String, + val scopes: List, + val isWaitingForSecondFactorCode: Boolean, + val isWaitingForDataPassword: Boolean, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt new file mode 100644 index 00000000..332ef6f8 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +data class SessionResumeRequest( + val username: String, + val appVersion: String, + val sessionId: String, + val userId: String, + val accessToken: String, + val refreshToken: String, + val scopes: List, + val isWaitingForSecondFactorCode: Boolean, + val isWaitingForDataPassword: Boolean, + val secretCachePath: String, + val options: ProtonClientOptions, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt new file mode 100644 index 00000000..b8fd1ba7 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +enum class ThumbnailType { + THUMBNAIL, + PREVIEW, +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt new file mode 100644 index 00000000..68036bc5 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.entity + +data class UploadResult( + val nodeUid: String, + val revisionUid: String, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt new file mode 100644 index 00000000..53bb94bc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.Address +import me.proton.drive.sdk.entity.Address.Status +import proton.sdk.ProtonSdk +import proton.sdk.address +import proton.sdk.addressKey + +fun Address.toProtobuf() = address { + addressId = this@toProtobuf.addressId + order = this@toProtobuf.order + emailAddress = this@toProtobuf.emailAddress + status = when (this@toProtobuf.status) { + Status.DISABLED -> ProtonSdk.AddressStatus.ADDRESS_STATUS_DISABLED + Status.ENABLED -> ProtonSdk.AddressStatus.ADDRESS_STATUS_ENABLED + Status.DELETING -> ProtonSdk.AddressStatus.ADDRESS_STATUS_DELETING + } + keys.addAll(this@toProtobuf.keys.map { it.toProtobuf() }) + primaryKeyIndex = this@toProtobuf.primaryKeyIndex +} + +fun Address.Key.toProtobuf() = addressKey { + addressId = this@toProtobuf.addressId + addressKeyId = this@toProtobuf.keyId + isActive = active + isAllowedForEncryption = allowedForEncryption + isAllowedForVerification = allowedForVerification +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt new file mode 100644 index 00000000..0ef0ee0f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.ApiRetrySucceededEvent +import proton.sdk.ProtonSdk + +fun ProtonSdk.ApiRetrySucceededEventPayload.toEvent() = ApiRetrySucceededEvent( + url = url, + failedAttempts = failedAttempts, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt new file mode 100644 index 00000000..1916bd65 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.BlockVerificationErrorEvent +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.BlockVerificationErrorEventPayload.toEvent() = BlockVerificationErrorEvent( + retryHelped = retryHelped, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt new file mode 100644 index 00000000..f8dd83cb --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import java.nio.ByteBuffer + +internal fun ByteBuffer.decodeToString(): String { + val bytes = ByteArray(remaining()) + get(bytes) + return String(bytes, Charsets.UTF_8) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt new file mode 100644 index 00000000..b153a2b4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.converter.FileThumbnailListConverter +import me.proton.drive.sdk.converter.LongConverter +import me.proton.drive.sdk.converter.StringConverter +import me.proton.drive.sdk.converter.UploadResultConverter +import me.proton.drive.sdk.internal.ContinuationUnitOrErrorResponse +import me.proton.drive.sdk.internal.ContinuationValueOrErrorResponse +import me.proton.drive.sdk.internal.ResponseCallback +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.ProtonDriveSdk.UploadResult + +fun CancellableContinuation.toUnitResponse(): ResponseCallback = + ContinuationUnitOrErrorResponse(this) + +val UnitResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toUnitResponse + +fun CancellableContinuation.toLongResponse(): ResponseCallback = + ContinuationValueOrErrorResponse(this, LongConverter()) + +val LongResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toLongResponse + +fun CancellableContinuation.toStringResponse(): ResponseCallback = + ContinuationValueOrErrorResponse(this, StringConverter()) + +val StringResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toStringResponse + +fun CancellableContinuation.toUploadResultResponse(): ResponseCallback = + ContinuationValueOrErrorResponse(this, UploadResultConverter()) + +val UploadResultResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toUploadResultResponse + +fun CancellableContinuation.toFileThumbnailListResponse(): ResponseCallback = + ContinuationValueOrErrorResponse(this, FileThumbnailListConverter()) + +val FileThumbnailListResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toFileThumbnailListResponse diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt new file mode 100644 index 00000000..bce44631 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import me.proton.drive.sdk.Cancellable + +suspend fun T.use( + scope: CoroutineScope, + block: suspend (T) -> R, +): R where T : Cancellable, T : AutoCloseable = use { + try { + block(this) + } finally { + if (!scope.isActive) { + withContext(NonCancellable) { + cancel() + } + } + } +} + + +suspend fun CoroutineScope.withCancellable( + cancellable: T, + block: suspend (T) -> R, +): R where T : Cancellable, T : AutoCloseable = cancellable.use { + try { + block(cancellable) + } finally { + if (!isActive) { + withContext(NonCancellable) { + cancellable.cancel() + } + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt new file mode 100644 index 00000000..62a3eee1 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.DecryptionErrorEvent +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.DecryptionErrorEventPayload.toEvent() = DecryptionErrorEvent( + volumeType = volumeType.toEnum(), + field = field.toEnum(), + fromBefore2024 = fromBefore2024, + error = error, + uid = uid, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt new file mode 100644 index 00000000..0fee52c6 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.DownloadError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.DownloadError.toEnum() = when (this) { + ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_SERVER_ERROR -> DownloadError.SERVER_ERROR + ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_NETWORK_ERROR -> DownloadError.NETWORK_ERROR + ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_DECRYPTION_ERROR -> DownloadError.DECRYPTION_ERROR + ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_INTEGRITY_ERROR -> DownloadError.INTEGRITY_ERROR + ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_RATE_LIMITED -> DownloadError.RATE_LIMITED + ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_HTTP_CLIENT_SIDE_ERROR -> DownloadError.HTTP_CLIENT_SIDE_ERROR + ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_UNKNOWN -> DownloadError.UNKNOWN + ProtonDriveSdk.DownloadError.UNRECOGNIZED -> DownloadError.UNRECOGNIZED +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt new file mode 100644 index 00000000..6134a23f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.DownloadEvent +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.DownloadEventPayload.toEvent() = DownloadEvent( + volumeType = volumeType.toEnum(), + expectedSize = expectedSize, + uploadedSize = uploadedSize, + error = error.toEnum(), + originalError = originalError, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt new file mode 100644 index 00000000..6a6f3a80 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.EncryptedField +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.EncryptedField.toEnum() = when(this) { + ProtonDriveSdk.EncryptedField.ENCRYPTED_FIELD_SHARE_KEY -> EncryptedField.SHARE_KEY + ProtonDriveSdk.EncryptedField.ENCRYPTED_FIELD_NODE_KEY -> EncryptedField.NODE_KEY + ProtonDriveSdk.EncryptedField.ENCRYPTED_FIELD_NODE_NAME -> EncryptedField.NODE_NAME + ProtonDriveSdk.EncryptedField.ENCRYPTED_FIELD_NODE_HASH_KEY -> EncryptedField.NODE_HASH_KEY + ProtonDriveSdk.EncryptedField.ENCRYPTED_FIELD_NODE_EXTENDED_ATTRIBUTES -> EncryptedField.NODE_EXTENDED_ATTRIBUTES + ProtonDriveSdk.EncryptedField.ENCRYPTED_FIELD_NODE_CONTENT_KEY -> EncryptedField.NODE_CONTENT_KEY + ProtonDriveSdk.EncryptedField.ENCRYPTED_FIELD_CONTENT -> EncryptedField.CONTENT + ProtonDriveSdk.EncryptedField.UNRECOGNIZED -> EncryptedField.UNRECOGNIZED +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt new file mode 100644 index 00000000..82ddee75 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import com.google.protobuf.Any +import me.proton.drive.sdk.ProtonDriveSdkException +import me.proton.drive.sdk.ProtonSdkError +import proton.drive.sdk.ProtonDriveSdk +import proton.sdk.ProtonSdk +import proton.sdk.additionalDataOrNull +import proton.sdk.innerErrorOrNull + +fun ProtonSdk.Error.toException() = + ProtonDriveSdkException(message, error = toError()) + +fun ProtonSdk.Error.toError(): ProtonSdkError = ProtonSdkError( + message = message, + type = type, + domain = toErrorDomain(), + primaryCode = primaryCode, + secondaryCode = secondaryCode, + context = context, + innerError = innerErrorOrNull?.toError(), + additionalData = additionalDataOrNull?.toData() +) + +private fun ProtonSdk.Error.toErrorDomain() = when (domain) { + ProtonSdk.ErrorDomain.Undefined -> ProtonSdkError.ErrorDomain.Undefined + ProtonSdk.ErrorDomain.SuccessfulCancellation -> ProtonSdkError.ErrorDomain.SuccessfulCancellation + ProtonSdk.ErrorDomain.Api -> ProtonSdkError.ErrorDomain.Api + ProtonSdk.ErrorDomain.Network -> ProtonSdkError.ErrorDomain.Network + ProtonSdk.ErrorDomain.Transport -> ProtonSdkError.ErrorDomain.Transport + ProtonSdk.ErrorDomain.Serialization -> ProtonSdkError.ErrorDomain.Serialization + ProtonSdk.ErrorDomain.Cryptography -> ProtonSdkError.ErrorDomain.Cryptography + ProtonSdk.ErrorDomain.DataIntegrity -> ProtonSdkError.ErrorDomain.DataIntegrity + ProtonSdk.ErrorDomain.BusinessLogic -> ProtonSdkError.ErrorDomain.BusinessLogic + ProtonSdk.ErrorDomain.UNRECOGNIZED, null -> ProtonSdkError.ErrorDomain.UNRECOGNIZED +} + +private fun Any.toData() = when (typeUrl) { + "type.googleapis.com/proton.drive.sdk.NodeNameConflictErrorData" -> + ProtonDriveSdk.NodeNameConflictErrorData.parseFrom(value).toEntity() + + else -> null +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt new file mode 100644 index 00000000..90febbc9 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import com.google.protobuf.kotlin.toByteString +import com.google.protobuf.timestamp +import me.proton.drive.sdk.entity.FileUploaderRequest +import proton.drive.sdk.additionalMetadataProperty +import proton.drive.sdk.driveClientGetFileUploaderRequest + +internal fun FileUploaderRequest.toProtobuf( + clientHandle: Long, + cancellationTokenSourceHandle: Long, +) = driveClientGetFileUploaderRequest { + name = this@toProtobuf.name + mediaType = this@toProtobuf.mediaType + size = this@toProtobuf.fileSize + parentFolderUid = this@toProtobuf.parentFolderUid + lastModificationTime = timestamp { seconds = this@toProtobuf.lastModificationTime } + overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient + additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> + additionalMetadataProperty { + this.name = name + this.utf8JsonValue = data.toByteString() + } + } + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt new file mode 100644 index 00000000..917b0c46 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import com.google.protobuf.timestamp +import me.proton.drive.sdk.entity.FileRevisionUploaderRequest +import proton.drive.sdk.driveClientGetFileRevisionUploaderRequest + +internal fun FileRevisionUploaderRequest.toProtobuf( + clientHandle: Long, + cancellationTokenSourceHandle: Long, +) = driveClientGetFileRevisionUploaderRequest { + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + this.currentActiveRevisionUid = this@toProtobuf.currentActiveRevisionUid + this.size = this@toProtobuf.size + this.lastModificationTime = timestamp { seconds = this@toProtobuf.lastModificationTime } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt new file mode 100644 index 00000000..96de58e7 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import com.google.protobuf.MessageLite +import com.google.protobuf.any + + +fun MessageLite.asAny(name: String) = any { + typeUrl = "type.googleapis.com/$name" + value = toByteString() +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt new file mode 100644 index 00000000..97422116 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonSdkError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.NodeNameConflictErrorData.toEntity() = ProtonSdkError.Data.NodeNameConflict( + conflictingNodeIsFileDraft = conflictingNodeIsFileDraft, + conflictingNodeUid = conflictingNodeUid, + conflictingRevisionUid = conflictingRevisionUid, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt new file mode 100644 index 00000000..a27ef1a4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.ProtonClientOptions +import proton.sdk.protonClientOptions +import proton.sdk.telemetry + +internal fun ProtonClientOptions.toProtobuf( + recordMetricAction: Long? = null, +) = protonClientOptions { + this@toProtobuf.userAgent?.let { userAgent = it } + this@toProtobuf.baseUrl?.let { baseUrl = it } + this@toProtobuf.bindingsLanguage?.let { bindingsLanguage = it } + this@toProtobuf.tlsPolicy?.let { tlsPolicy = it.toProtobuf() } + telemetry = telemetry { + this@toProtobuf.loggerProvider?.let { loggerProviderHandle = it.handle } + recordMetricAction?.let { this@telemetry.recordMetricAction = recordMetricAction } + } + this@toProtobuf.entityCachePath?.let { entityCachePath = it } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt new file mode 100644 index 00000000..397d7de8 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.ProtonClientTlsPolicy +import me.proton.drive.sdk.entity.ProtonClientTlsPolicy.NO_CERTIFICATE_PINNING +import me.proton.drive.sdk.entity.ProtonClientTlsPolicy.NO_CERTIFICATE_VALIDATION +import me.proton.drive.sdk.entity.ProtonClientTlsPolicy.STRICT +import proton.sdk.ProtonSdk.ProtonClientTlsPolicy.PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_PINNING +import proton.sdk.ProtonSdk.ProtonClientTlsPolicy.PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_VALIDATION +import proton.sdk.ProtonSdk.ProtonClientTlsPolicy.PROTON_CLIENT_TLS_POLICY_STRICT + +fun ProtonClientTlsPolicy.toProtobuf(): proton.sdk.ProtonSdk.ProtonClientTlsPolicy = when (this) { + STRICT -> PROTON_CLIENT_TLS_POLICY_STRICT + NO_CERTIFICATE_PINNING -> PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_PINNING + NO_CERTIFICATE_VALIDATION -> PROTON_CLIENT_TLS_POLICY_NO_CERTIFICATE_VALIDATION +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt new file mode 100644 index 00000000..598741df --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import com.google.protobuf.Any +import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.ProtonDriveSdkException +import proton.sdk.ProtonSdk +import proton.sdk.ProtonSdk.Response.ResultCase.ERROR +import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET +import proton.sdk.ProtonSdk.Response.ResultCase.VALUE +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +fun ProtonSdk.Response.completeOrFail(deferred: CancellableContinuation, block: (Any) -> T) { + when (resultCase) { + VALUE -> deferred.resume(block(value)) + RESULT_NOT_SET -> deferred.resumeWithException(ProtonDriveSdkException("No response (not set)")) + ERROR -> deferred.resumeWithException(error.toException()) + null -> deferred.resumeWithException(ProtonDriveSdkException("No response (null)")) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt new file mode 100644 index 00000000..b1c905ef --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.SessionBeginRequest +import proton.sdk.sessionBeginRequest + +fun SessionBeginRequest.toProtobuf(cancellationTokenSourceHandle: Long) = sessionBeginRequest { + this@sessionBeginRequest.username = this@toProtobuf.username + this@sessionBeginRequest.password = this@toProtobuf.password + appVersion = this@toProtobuf.appVersion + options = this@toProtobuf.options.toProtobuf() + secretCachePath = secretCache.path + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt new file mode 100644 index 00000000..d30bcc91 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.SessionRenewRequest +import proton.sdk.sessionRenewRequest + +internal fun SessionRenewRequest.toProtobuf(handle: Long) = sessionRenewRequest { + oldSessionHandle = handle + sessionId = this@toProtobuf.sessionId + accessToken = this@toProtobuf.accessToken + refreshToken = this@toProtobuf.refreshToken + scopes.addAll(this@toProtobuf.scopes) + isWaitingForSecondFactorCode = this@toProtobuf.isWaitingForSecondFactorCode + isWaitingForDataPassword = this@toProtobuf.isWaitingForDataPassword +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt new file mode 100644 index 00000000..f64f5768 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.SessionResumeRequest +import proton.sdk.sessionResumeRequest + +internal fun SessionResumeRequest.toProtobuf() = sessionResumeRequest { + sessionId = this@toProtobuf.sessionId + username = this@toProtobuf.username + appVersion = this@toProtobuf.appVersion + userId = this@toProtobuf.userId + accessToken = this@toProtobuf.accessToken + refreshToken = this@toProtobuf.refreshToken + scopes.addAll(this@toProtobuf.scopes) + isWaitingForSecondFactorCode = this@toProtobuf.isWaitingForSecondFactorCode + isWaitingForDataPassword = this@toProtobuf.isWaitingForDataPassword + secretCachePath = this@toProtobuf.secretCachePath + options = this@toProtobuf.options.toProtobuf() +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt new file mode 100644 index 00000000..013de255 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import proton.sdk.ProtonSdk + +fun Throwable.toProtonSdkError(defaultMessage: String) = proton.sdk.error { + type = javaClass.name + this.message = this@toProtonSdkError.message ?: defaultMessage + domain = ProtonSdk.ErrorDomain.Serialization + context = stackTraceToString() +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt new file mode 100644 index 00000000..add697e5 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.ThumbnailType +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.ThumbnailType.toEntity() = when(this) { + ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_THUMBNAIL -> ThumbnailType.THUMBNAIL + ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_PREVIEW -> ThumbnailType.PREVIEW + ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_UNSPECIFIED, + ProtonDriveSdk.ThumbnailType.UNRECOGNIZED -> null +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt new file mode 100644 index 00000000..2653cf54 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.UploadError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.UploadError.toEnum() = when(this) { + ProtonDriveSdk.UploadError.UPLOAD_ERROR_SERVER_ERROR -> UploadError.SERVER_ERROR + ProtonDriveSdk.UploadError.UPLOAD_ERROR_NETWORK_ERROR -> UploadError.NETWORK_ERROR + ProtonDriveSdk.UploadError.UPLOAD_ERROR_INTEGRITY_ERROR -> UploadError.INTEGRITY_ERROR + ProtonDriveSdk.UploadError.UPLOAD_ERROR_RATE_LIMITED -> UploadError.RATE_LIMITED + ProtonDriveSdk.UploadError.UPLOAD_ERROR_HTTP_CLIENT_SIDE_ERROR -> UploadError.HTTP_CLIENT_SIDE_ERROR + ProtonDriveSdk.UploadError.UPLOAD_ERROR_UNKNOWN -> UploadError.UNKNOWN + ProtonDriveSdk.UploadError.UNRECOGNIZED -> UploadError.UNRECOGNIZED +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt new file mode 100644 index 00000000..d77b5728 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.UploadEvent +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.UploadEventPayload.toEvent() = UploadEvent( + volumeType = volumeType.toEnum(), + expectedSize = expectedSize, + uploadedSize = uploadedSize, + error = error.toEnum(), + originalError = originalError, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt new file mode 100644 index 00000000..9b0c2bb4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.UploadResult +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.UploadResult.toEntity() = UploadResult( + nodeUid = nodeUid, + revisionUid = revisionUid +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt new file mode 100644 index 00000000..e12caa83 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.VerificationErrorEvent +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.VerificationErrorEventPayload.toEvent() = VerificationErrorEvent( + volumeType = volumeType.toEnum(), + field = field.toEnum(), + fromBefore2024 = fromBefore2024, + addressMatchingDefaultShare = addressMatchingDefaultShare, + error = error, + uid = uid, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt new file mode 100644 index 00000000..51131ec0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.telemetry.VolumeType +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.VolumeType.toEnum() = when (this) { + ProtonDriveSdk.VolumeType.VOLUME_TYPE_OWN_VOLUME -> VolumeType.OWN_VOLUME + ProtonDriveSdk.VolumeType.VOLUME_TYPE_SHARED -> VolumeType.SHARED + ProtonDriveSdk.VolumeType.VOLUME_TYPE_SHARED_PUBLIC -> VolumeType.SHARED_PUBLIC + ProtonDriveSdk.VolumeType.VOLUME_TYPE_PHOTO -> VolumeType.PHOTO + ProtonDriveSdk.VolumeType.UNRECOGNIZED -> VolumeType.UNRECOGNIZED +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt new file mode 100644 index 00000000..dc282808 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.internal + +import com.google.protobuf.Any +import com.google.protobuf.bytesValue +import com.google.protobuf.kotlin.toByteString +import me.proton.drive.sdk.PublicAddressResolver +import me.proton.drive.sdk.UserAddressResolver +import me.proton.drive.sdk.extension.asAny +import me.proton.drive.sdk.extension.toProtobuf +import proton.drive.sdk.ProtonDriveSdk +import proton.sdk.repeatedBytesValue + +internal class AccountClientBridge( + private val userAddressResolver: UserAddressResolver, + private val publicAddressResolver: PublicAddressResolver, +) : suspend (ProtonDriveSdk.AccountRequest) -> Any { + override suspend fun invoke( + request: ProtonDriveSdk.AccountRequest, + ): Any = when (request.payloadCase) { + ProtonDriveSdk.AccountRequest.PayloadCase.GET_ADDRESS -> userAddressResolver + .getAddress(request.getAddress.addressId) + .toProtobuf() + .asAny("proton.sdk.Address") + + ProtonDriveSdk.AccountRequest.PayloadCase.GET_DEFAULT_ADDRESS -> userAddressResolver + .getDefaultAddress() + .toProtobuf() + .asAny("proton.sdk.Address") + + ProtonDriveSdk.AccountRequest.PayloadCase.GET_ADDRESS_PRIMARY_PRIVATE_KEY -> userAddressResolver + .getAddressPrimaryPrivateKey(request.getAddressPrimaryPrivateKey.addressId) { key -> + bytesValue { value = key.toByteString() } + }.asAny("google.protobuf.BytesValue") + + + ProtonDriveSdk.AccountRequest.PayloadCase.GET_ADDRESS_PRIVATE_KEYS -> userAddressResolver + .getAddressPrivateKeys(request.getAddressPrivateKeys.addressId) { keys -> + repeatedBytesValue { + value.addAll(keys.map { key -> key.toByteString() }) + }.asAny("proton.sdk.RepeatedBytesValue") + } + + ProtonDriveSdk.AccountRequest.PayloadCase.GET_ADDRESS_PUBLIC_KEYS -> repeatedBytesValue { + value.addAll( + publicAddressResolver + .getAddressPublicKeys(request.getAddressPublicKeys.emailAddress) + .map { key -> key.toByteString() } + ) + }.asAny("proton.sdk.RepeatedBytesValue") + + ProtonDriveSdk.AccountRequest.PayloadCase.PAYLOAD_NOT_SET -> + error("request not set (payload)") + + null -> + error("request not set (null)") + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt new file mode 100644 index 00000000..98efe6c7 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.internal + +import com.google.protobuf.kotlin.toByteString +import me.proton.core.domain.entity.UserId +import me.proton.core.network.data.ApiProvider +import me.proton.core.network.data.ProtonErrorException +import me.proton.core.network.domain.ApiResult +import me.proton.drive.sdk.HttpSdkApi +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody +import proton.sdk.ProtonSdk.HttpRequest +import proton.sdk.ProtonSdk.HttpResponse +import proton.sdk.httpHeader +import proton.sdk.httpResponse +import retrofit2.Response + +internal class ApiProviderBridge( + private val userId: UserId, + private val apiProvider: ApiProvider +) : suspend (HttpRequest) -> HttpResponse { + override suspend fun invoke(request: HttpRequest): HttpResponse { + val apiResult = apiProvider.get(userId).invoke { + execute( + method = request.method, + url = request.url, + headers = request.headersList.associate { header -> + header.name to header.valuesList.joinToString(",") + }, + body = request.content.toByteArray().toRequestBody() + ) + } + + if (apiResult is ApiResult.Error) { + val error = apiResult.cause + if (error is ProtonErrorException) { + val response = error.response + return httpResponse { + statusCode = response.code + val responseHeaders = response.headers + responseHeaders.names().forEach { name -> + headers += httpHeader { + this@httpHeader.name = name + values.addAll(responseHeaders.values(name)) + } + } + response.body?.byteString()?.toByteArray()?.toByteString()?.let { body -> + content = body + } + } + } + } + + val response = apiResult.valueOrThrow + + return httpResponse { + statusCode = response.code() + val responseHeaders = response.headers() + responseHeaders.names().forEach { name -> + headers += httpHeader { + this@httpHeader.name = name + values.addAll(responseHeaders.values(name)) + } + } + response.body()?.byteString()?.toByteArray()?.toByteString()?.let { body -> + content = body + } + } + } + + private suspend fun HttpSdkApi.execute( + method: String, + url: String, + headers: Map = emptyMap(), + body: RequestBody? = null + ): Response { + return when (method.uppercase()) { + "GET" -> get(url, headers) + "POST" -> post(url, headers, body) + "PUT" -> put(url, headers, body) + "DELETE" -> delete(url, headers, body) + else -> throw IllegalArgumentException("Unsupported method: $method") + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt new file mode 100644 index 00000000..d23f339d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import com.google.protobuf.kotlin.toByteString +import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.ProtonDriveSdkException +import me.proton.drive.sdk.extension.toException +import proton.sdk.ProtonSdk +import proton.sdk.ProtonSdk.Response.ResultCase.ERROR +import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET +import proton.sdk.ProtonSdk.Response.ResultCase.VALUE +import java.nio.ByteBuffer +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Suppress("TooGenericExceptionCaught") +class ContinuationUnitOrErrorResponse( + private val deferred: CancellableContinuation, +) : ResponseCallback { + override fun invoke(data: ByteBuffer) { + try { + val parseFrom = ProtonSdk.Response.parseFrom(data) + when (parseFrom.resultCase) { + VALUE -> deferred.resumeWithException( + ProtonDriveSdkException("No response was expected but: ${parseFrom.value.typeUrl}") + ) + + RESULT_NOT_SET -> deferred.resume(Unit) + ERROR -> deferred.resumeWithException(parseFrom.error.toException()) + null -> deferred.resumeWithException(ProtonDriveSdkException("No response (null)")) + } + } catch (error: Throwable) { + deferred.resumeWithException( + ProtonDriveSdkException( + message = "Cannot parse message: ${data.toByteString().toStringUtf8()}", + cause = error, + ) + ) + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt new file mode 100644 index 00000000..bc4ce6c1 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import com.google.protobuf.kotlin.toByteString +import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.ProtonDriveSdkException +import me.proton.drive.sdk.converter.AnyConverter +import me.proton.drive.sdk.extension.toException +import proton.sdk.ProtonSdk +import proton.sdk.ProtonSdk.Response.ResultCase.ERROR +import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET +import proton.sdk.ProtonSdk.Response.ResultCase.VALUE +import java.nio.ByteBuffer +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Suppress("TooGenericExceptionCaught") +class ContinuationValueOrErrorResponse( + private val deferred: CancellableContinuation, + private val anyConverter: AnyConverter, +) : ResponseCallback { + override fun invoke(data: ByteBuffer) { + try { + val parseFrom = ProtonSdk.Response.parseFrom(data) + when (parseFrom.resultCase) { + VALUE -> { + check(parseFrom.value.typeUrl == anyConverter.typeUrl) { + "Wrong converter for ${parseFrom.value.typeUrl} (${anyConverter.typeUrl})" + } + deferred.resume(anyConverter.convert(parseFrom.value)) + } + + RESULT_NOT_SET -> deferred.resumeWithException(ProtonDriveSdkException("No response (not set)")) + ERROR -> deferred.resumeWithException(parseFrom.error.toException()) + null -> deferred.resumeWithException(ProtonDriveSdkException("No response (null)")) + } + } catch (error: Throwable) { + deferred.resumeWithException( + ProtonDriveSdkException( + message = "Cannot parse message: ${data.toByteString().toStringUtf8()}", + cause = error, + ) + ) + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt new file mode 100644 index 00000000..ca1e51a7 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import java.nio.ByteBuffer + +class IgnoredIntegerOrErrorResponse : ResponseCallback { + override fun invoke(data: ByteBuffer) = Unit +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt new file mode 100644 index 00000000..49533316 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.SdkLogger +import java.nio.ByteBuffer + +typealias ResponseCallback = (ByteBuffer) -> Unit + +abstract class JniBase { + + open val logger: (String) -> Unit = { message -> globalSdkLogger(DEBUG, "internal", message) } + + internal fun method(name: String) = "${this.javaClass.simpleName}::$name" + + companion object { + var globalSdkLogger: SdkLogger = { _, _, _ -> } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt new file mode 100644 index 00000000..499f19ce --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import proton.drive.sdk.ProtonDriveSdk.Request +import proton.drive.sdk.RequestKt +import proton.drive.sdk.request + +abstract class JniBaseProtonDriveSdk : JniBase() { + + private var released = false + private var clients = emptyList() + + fun dispatch( + name: String, + block: RequestKt.Dsl.() -> Unit, + ) { + check(released.not()) { "Cannot dispatch ${method(name)} after release" } + val nativeClient = ProtonDriveSdkNativeClient( + method(name), + IgnoredIntegerOrErrorResponse(), + logger = logger, + ) + nativeClient.handleRequest(request(block)) + } + + suspend fun executeOnce( + name: String, + callback: (CancellableContinuation) -> ResponseCallback, + block: RequestKt.Dsl.() -> Unit, + ): T = suspendCancellableCoroutine { continuation -> + check(released.not()) { "Cannot executeOnce ${method(name)} after release" } + val nativeClient = ProtonDriveSdkNativeClient( + name = method(name), + response = callback(continuation), + logger = logger, + ) + continuation.invokeOnCancellation { nativeClient.release() } + nativeClient.handleRequest(request(block)) + } + + suspend fun executePersistent( + clientBuilder: (CancellableContinuation) -> ProtonDriveSdkNativeClient, + requestBuilder: (ProtonDriveSdkNativeClient) -> Request, + ): T = suspendCancellableCoroutine { continuation -> + val nativeClient = clientBuilder(continuation) + check(released.not()) { "Cannot executePersistent ${method(nativeClient.name)} after release" } + clients += nativeClient + nativeClient.handleRequest(requestBuilder(nativeClient)) + } + + fun releaseAll() { + logger("Releasing all for ${javaClass.simpleName}") + released = true + clients.forEach { client -> client.release() } + clients = emptyList() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt new file mode 100644 index 00000000..8b28624d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import proton.sdk.ProtonSdk.Request +import proton.sdk.RequestKt +import proton.sdk.request + +abstract class JniBaseProtonSdk : JniBase() { + + private var clients = emptyList() + + fun dispatch( + name: String, + block: RequestKt.Dsl.() -> Unit, + ) { + val nativeClient = ProtonSdkNativeClient( + method(name), + IgnoredIntegerOrErrorResponse(), + ) + nativeClient.handleRequest(request(block)) + } + + suspend fun executeOnce( + name: String, + callback: (CancellableContinuation) -> ResponseCallback, + block: RequestKt.Dsl.() -> Unit, + ): T = suspendCancellableCoroutine { continuation -> + val nativeClient = ProtonSdkNativeClient( + name = method(name), + response = callback(continuation), + logger = logger, + ) + continuation.invokeOnCancellation { nativeClient.release() } + nativeClient.handleRequest(request(block)) + } + + suspend fun executePersistent( + clientBuilder: (CancellableContinuation) -> ProtonSdkNativeClient, + requestBuilder: (ProtonSdkNativeClient) -> Request, + ): T = suspendCancellableCoroutine { continuation -> + val nativeClient = clientBuilder(continuation) + clients += nativeClient + nativeClient.handleRequest(requestBuilder(nativeClient)) + } + + fun releaseAll() { + clients.forEach { client -> client.release() } + clients = emptyList() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt new file mode 100644 index 00000000..a29d3d62 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.UnitResponseCallback +import proton.sdk.cancellationTokenSourceCancelRequest +import proton.sdk.cancellationTokenSourceCreateRequest +import proton.sdk.cancellationTokenSourceFreeRequest + +class JniCancellationTokenSource internal constructor() : JniBaseProtonSdk() { + + suspend fun create(): Long = executeOnce("create", LongResponseCallback) { + cancellationTokenSourceCreate = cancellationTokenSourceCreateRequest { } + } + + suspend fun cancel(handle: Long) { + executeOnce("cancel", UnitResponseCallback) { + cancellationTokenSourceCancel = cancellationTokenSourceCancelRequest { + cancellationTokenSourceHandle = handle + } + } + } + + fun free(handle: Long) { + dispatch("free") { + cancellationTokenSourceFree = cancellationTokenSourceFreeRequest { + cancellationTokenSourceHandle = handle + } + } + releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt new file mode 100644 index 00000000..6d62bb88 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.extension.UnitResponseCallback +import proton.drive.sdk.downloadControllerAwaitCompletionRequest +import proton.drive.sdk.downloadControllerFreeRequest +import proton.drive.sdk.downloadControllerPauseRequest +import proton.drive.sdk.downloadControllerResumeRequest + +class JniDownloadController internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun awaitCompletion(handle: Long) = + executeOnce("awaitCompletion", UnitResponseCallback) { + downloadControllerAwaitCompletion = downloadControllerAwaitCompletionRequest { + downloadControllerHandle = handle + } + } + + suspend fun pause(handle: Long) = executeOnce("pause", UnitResponseCallback) { + downloadControllerPause = downloadControllerPauseRequest { + downloadControllerHandle = handle + } + } + + suspend fun resume(handle: Long) = executeOnce("resume", UnitResponseCallback) { + downloadControllerResume = downloadControllerResumeRequest { + downloadControllerHandle = handle + } + } + + fun free(handle: Long) { + dispatch("free") { + downloadControllerFree = downloadControllerFreeRequest { + downloadControllerHandle = handle + } + } + releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt new file mode 100644 index 00000000..a71cbe4a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.toLongResponse +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.downloadToStreamRequest +import proton.drive.sdk.driveClientGetFileDownloaderRequest +import proton.drive.sdk.fileDownloaderFreeRequest +import proton.drive.sdk.request +import java.nio.ByteBuffer + +class JniDownloader internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun create( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + revisionUid: String, + ): Long = executeOnce("create", LongResponseCallback) { + driveClientGetFileDownloader = driveClientGetFileDownloaderRequest { + this.revisionUid = revisionUid + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + } + } + + suspend fun downloadToStream( + handle: Long, + cancellationTokenSourceHandle: Long, + onWrite: suspend (ByteBuffer) -> Unit, + onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, + coroutineScope: CoroutineScope + ): Long = executePersistent( + clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + method("downloadToStream"), + continuation.toLongResponse(), + write = onWrite, + progress = onProgress, + logger = logger, + coroutineScope = coroutineScope, + ) + }, + requestBuilder = { client -> + request { + downloadToStream = downloadToStreamRequest { + this.downloaderHandle = handle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + writeAction = client.getWritePointer() + progressAction = client.getProgressPointer() + } + } + } + ) + + fun free(handle: Long) { + dispatch("free") { + fileDownloaderFree = fileDownloaderFreeRequest { fileDownloaderHandle = handle } + } + releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt new file mode 100644 index 00000000..194876b9 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import com.google.protobuf.Any +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.entity.ClientCreateRequest +import me.proton.drive.sdk.extension.FileThumbnailListResponseCallback +import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.StringResponseCallback +import me.proton.drive.sdk.extension.toLongResponse +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.driveClientCreateFromSessionRequest +import proton.drive.sdk.driveClientCreateRequest +import proton.drive.sdk.driveClientFreeRequest +import proton.drive.sdk.request +import proton.sdk.ProtonSdk +import proton.sdk.ProtonSdk.HttpResponse +import proton.sdk.telemetry + + +class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun create(sessionHandle: Long) = + executeOnce("createFromSession", LongResponseCallback) { + driveClientCreateFromSession = driveClientCreateFromSessionRequest { + this.sessionHandle = sessionHandle + } + } + + suspend fun create( + coroutineScope: CoroutineScope, + request: ClientCreateRequest, + onSendHttpRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse, + onRequest: suspend (ProtonDriveSdk.AccountRequest) -> Any, + onRecordMetric: suspend (ProtonSdk.MetricEvent) -> Unit, + ) = executePersistent(clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + method("create"), + continuation.toLongResponse(), + sendHttpRequest = onSendHttpRequest, + request = onRequest, + logger = logger, + recordMetric = onRecordMetric, + coroutineScope = coroutineScope, + ) + }, requestBuilder = { client -> + request { + driveClientCreate = driveClientCreateRequest { + baseUrl = request.baseUrl + httpClientRequestAction = client.getSendHttpRequestPointer() + accountClientRequestAction = client.getRequestPointer() + entityCachePath = request.entityCachePath + secretCachePath = request.secretCachePath + telemetry = telemetry { + loggerProviderHandle = request.loggerProvider.handle + recordMetricAction = client.getRecordMetricPointer() + } + request.bindingsLanguage?.let { bindingsLanguage = it } + request.uid?.let { uid = it } + } + } + }) + + suspend fun getAvailableName( + request: ProtonDriveSdk.DriveClientGetAvailableNameRequest, + ): String = executeOnce("getAvailableName", StringResponseCallback) { + driveClientGetAvailableName = request + } + + suspend fun getThumbnails( + request: ProtonDriveSdk.DriveClientGetThumbnailsRequest, + ): ProtonDriveSdk.FileThumbnailList = + executeOnce("getThumbnails", FileThumbnailListResponseCallback) { + driveClientGetThumbnails = request + } + + fun free(handle: Long) { + dispatch("free") { + driveClientFree = driveClientFreeRequest { + clientHandle = handle + } + } + releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt new file mode 100644 index 00000000..3a12eb1b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import com.google.protobuf.InvalidProtocolBufferException +import me.proton.drive.sdk.LoggerProvider +import me.proton.drive.sdk.SdkLogger +import me.proton.drive.sdk.extension.decodeToString +import me.proton.drive.sdk.extension.toLongResponse +import proton.sdk.ProtonSdk +import proton.sdk.loggerProviderCreate +import proton.sdk.request +import java.nio.ByteBuffer + +class JniLoggerProvider internal constructor( + private val sdkLogger: SdkLogger, +) : JniBaseProtonSdk() { + + init { + globalSdkLogger = sdkLogger + } + + suspend fun create(): Long = executePersistent( + clientBuilder = { continuation -> + ProtonSdkNativeClient( + method("create"), + continuation.toLongResponse(), + callback = ::onLog, + ) + }, + requestBuilder = { client -> + request { + loggerProviderCreate = loggerProviderCreate { + logAction = client.getCallbackPointer() + } + } + } + ) + + fun onLog(logEventMessage: ByteBuffer) { + try { + val logEvent = ProtonSdk.LogEvent.parseFrom(logEventMessage) + + val priority = when (logEvent.level) { + 0 -> LoggerProvider.Level.VERBOSE + 1 -> LoggerProvider.Level.DEBUG + 2 -> LoggerProvider.Level.INFO + 3 -> LoggerProvider.Level.WARN + 4, 5 -> LoggerProvider.Level.ERROR + else -> return + } + + sdkLogger(priority, logEvent.categoryName, logEvent.message) + } catch (error: InvalidProtocolBufferException) { + sdkLogger( + LoggerProvider.Level.ERROR, + "parsing", + error.message + "\n" + logEventMessage.decodeToString() + ) + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt new file mode 100644 index 00000000..10254f05 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +class JniNativeLibrary internal constructor() { + + external fun overrideName( + libraryName: ByteArray, + overridingLibraryName: ByteArray, + ) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt new file mode 100644 index 00000000..597657ed --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.entity.SessionBeginRequest +import me.proton.drive.sdk.entity.SessionRenewRequest +import me.proton.drive.sdk.entity.SessionResumeRequest +import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.UnitResponseCallback +import me.proton.drive.sdk.extension.toProtobuf +import proton.sdk.sessionEndRequest +import proton.sdk.sessionFreeRequest + +class JniSession internal constructor() : JniBaseProtonSdk() { + + suspend fun begin( + cancellationTokenSourceHandle: Long, + request: SessionBeginRequest, + ): Long = executeOnce("begin", LongResponseCallback) { + sessionBegin = request.toProtobuf(cancellationTokenSourceHandle) + } + + suspend fun resume( + request: SessionResumeRequest, + ): Long = executeOnce("resume", LongResponseCallback) { + sessionResume = request.toProtobuf() + } + + suspend fun renew( + handle: Long, + request: SessionRenewRequest, + ): Long = executeOnce("renew", LongResponseCallback) { + sessionRenew = request.toProtobuf(handle) + } + + suspend fun end( + handle: Long, + ) = executeOnce("end", UnitResponseCallback) { + sessionEnd = sessionEndRequest { sessionHandle = handle } + } + + fun free(handle: Long) { + dispatch("free") { + sessionFree = sessionFreeRequest { sessionHandle = handle } + } + releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt new file mode 100644 index 00000000..9a397020 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.entity.UploadResult +import me.proton.drive.sdk.extension.UnitResponseCallback +import me.proton.drive.sdk.extension.UploadResultResponseCallback +import me.proton.drive.sdk.extension.toEntity +import proton.drive.sdk.uploadControllerAwaitCompletionRequest +import proton.drive.sdk.uploadControllerFreeRequest +import proton.drive.sdk.uploadControllerPauseRequest +import proton.drive.sdk.uploadControllerResumeRequest + +class JniUploadController internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun awaitCompletion(handle: Long): UploadResult = + executeOnce("awaitCompletion", UploadResultResponseCallback) { + uploadControllerAwaitCompletion = uploadControllerAwaitCompletionRequest { + uploadControllerHandle = handle + } + }.toEntity() + + suspend fun pause(handle: Long) = executeOnce("pause", UnitResponseCallback) { + uploadControllerPause = uploadControllerPauseRequest { + uploadControllerHandle = handle + } + } + + suspend fun resume(handle: Long) = executeOnce("resume", UnitResponseCallback) { + uploadControllerResume = uploadControllerResumeRequest { + uploadControllerHandle = handle + } + } + + fun free(handle: Long) { + dispatch("free") { + uploadControllerFree = uploadControllerFreeRequest { + uploadControllerHandle = handle + } + } + releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt new file mode 100644 index 00000000..d29bb633 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.entity.FileRevisionUploaderRequest +import me.proton.drive.sdk.entity.FileUploaderRequest +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.toLongResponse +import me.proton.drive.sdk.extension.toProtobuf +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_PREVIEW +import proton.drive.sdk.ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_THUMBNAIL +import proton.drive.sdk.fileUploaderFreeRequest +import proton.drive.sdk.request +import proton.drive.sdk.thumbnail +import proton.drive.sdk.uploadFromStreamRequest +import java.nio.ByteBuffer + +class JniUploader internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun getFile( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + request: FileUploaderRequest, + ): Long = executeOnce("getFile", LongResponseCallback) { + driveClientGetFileUploader = + request.toProtobuf(clientHandle, cancellationTokenSourceHandle) + } + + suspend fun getFileRevision( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + request: FileRevisionUploaderRequest, + ): Long = executeOnce("getFileRevision", LongResponseCallback) { + driveClientGetFileRevisionUploader = + request.toProtobuf(clientHandle, cancellationTokenSourceHandle) + } + + suspend fun uploadFromStream( + uploaderHandle: Long, + cancellationTokenSourceHandle: Long, + thumbnails: Map, + onRead: (ByteBuffer) -> Int, + onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, + coroutineScope: CoroutineScope + ): Long = executePersistent( + clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + method("uploadFromStream"), + continuation.toLongResponse(), + read = onRead, + progress = onProgress, + logger = logger, + coroutineScope = coroutineScope, + ) + }, + requestBuilder = { nativeClient -> + request { + uploadFromStream = uploadFromStreamRequest { + this.uploaderHandle = uploaderHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + readAction = nativeClient.getReadPointer() + progressAction = nativeClient.getProgressPointer() + thumbnails.forEach { (type, data) -> + this.thumbnails.add(thumbnail { + this.type = when (type) { + ThumbnailType.THUMBNAIL -> THUMBNAIL_TYPE_THUMBNAIL + ThumbnailType.PREVIEW -> THUMBNAIL_TYPE_PREVIEW + } + dataPointer = nativeClient.getByteArrayPointer(data) + dataLength = data.size.toLong() + }) + } + } + } + } + ) + + fun free(handle: Long) { + dispatch("free") { + fileUploaderFree = fileUploaderFreeRequest { fileUploaderHandle = handle } + } + releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt new file mode 100644 index 00000000..859d4fa0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import com.google.protobuf.Any +import com.google.protobuf.Int32Value +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import me.proton.drive.sdk.extension.asAny +import me.proton.drive.sdk.extension.toProtonSdkError +import proton.drive.sdk.ProtonDriveSdk +import proton.sdk.ProtonSdk +import proton.sdk.ProtonSdk.HttpResponse +import proton.sdk.ProtonSdk.Response +import proton.sdk.response +import java.nio.ByteBuffer + +class ProtonDriveSdkNativeClient internal constructor( + val name: String, + val response: ResponseCallback = { error("response not configured for $name") }, + val read: suspend (ByteBuffer) -> Int = { error("read not configured for $name") }, + val write: suspend (ByteBuffer) -> Unit = { error("write not configured for $name") }, + val sendHttpRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse = { error("sendHttpRequest not configured for $name") }, + val request: suspend (ProtonDriveSdk.AccountRequest) -> Any = { error("request not configured for $name") }, + val progress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit = { error("progress not configured for $name") }, + val recordMetric: suspend (ProtonSdk.MetricEvent) -> Unit = { error("recordMetric not configured for $name") }, + val logger: (String) -> Unit = {}, + private val coroutineScope: CoroutineScope? = null, +) { + + private var pointers = emptyList() + + fun release() { + pointers.forEach { pointer -> + releaseByteArray(pointer) + } + } + + fun handleRequest( + request: ProtonDriveSdk.Request, + ) { + logger("handle request ${request.payloadCase.name} for $name") + handleRequest(request.toByteArray()) + } + + external fun handleRequest( + request: ByteArray, + ) + + fun handleResponse( + sdkHandle: Long, + response: Response, + ) { + if (response.hasValue()) { + logger("handle response value: ${response.value.typeUrl} for $name") + } else { + logger("handle response ${response.resultCase.name} for $name") + if (response.resultCase == Response.ResultCase.ERROR) { + logger(response.error.context) + } + } + handleResponse(sdkHandle, response.toByteArray()) + } + + external fun handleResponse( + sdkHandle: Long, + response: ByteArray, + ) + + fun getByteArrayPointer(data: ByteArray): Long = getByteArray(data).also { pointer -> + pointers += pointer + } + + external fun getByteArray(data: ByteArray): Long + external fun releaseByteArray(pointer: Long) + + external fun getReadPointer(): Long + external fun getWritePointer(): Long + external fun getProgressPointer(): Long + external fun getSendHttpRequestPointer(): Long + external fun getRequestPointer(): Long + external fun getRecordMetricPointer(): Long + + @Suppress("unused") // Called by JNI + fun onResponse(data: ByteBuffer) { + logger("response for $name of size: ${data.capacity()}") + response(data) + } + + @Suppress("unused") // Called by JNI + fun onProgress(data: ByteBuffer) = onCallback("progress") { + logger("progress for $name of size: ${data.capacity()}") + progress(ProtonDriveSdk.ProgressUpdate.parseFrom(data)) + } + + @Suppress("unused") // Called by JNI + fun onRead(buffer: ByteBuffer, sdkHandle: Long) = onOperation("read", sdkHandle) { + logger("read for $name of size: ${buffer.capacity()}") + val bytesRead = read(buffer).takeUnless { it < 0 } ?: 0 + logger("$bytesRead bytes read for $name") + response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } + } + + @Suppress("unused") // Called by JNI + fun onWrite(data: ByteBuffer, sdkHandle: Long) = onOperation("write", sdkHandle) { + logger("write for $name of size: ${data.capacity()}") + write(data) + response {} + } + + @Suppress("unused") // Called by JNI + fun onSendHttpRequest(data: ByteBuffer, sdkHandle: Long) = onOperation("http", sdkHandle) { + val httpRequest = ProtonSdk.HttpRequest.parseFrom(data) + logger("send http request for ${httpRequest.method} ${httpRequest.url} of size: ${data.capacity()}") + val httpResponse = sendHttpRequest(httpRequest) + logger("receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}") + response { value = httpResponse.asAny("proton.sdk.HttpResponse") } + } + + @Suppress("unused") // Called by JNI + fun onRequest(data: ByteBuffer, sdkHandle: Long) = onOperation("request", sdkHandle) { + val clientRequest = ProtonDriveSdk.AccountRequest.parseFrom(data) + logger("request for ${clientRequest.payloadCase.name} of size: ${data.capacity()}") + val response = request(clientRequest) + response { value = response } + } + + @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI + fun onRecordMetric(data: ByteBuffer) = onCallback("recordMetric") { + logger("Record metric for $name of size: ${data.capacity()}") + recordMetric(ProtonSdk.MetricEvent.parseFrom(data)) + } + + @Suppress("TooGenericExceptionCaught") + private fun onOperation(operation: String, sdkHandle: Long, block: suspend () -> Response) { + coroutineScope(operation).launch { + try { + handleResponse(sdkHandle, block()) + } catch (error: CancellationException) { + logger("Operation $operation was cancelled") + handleResponse(sdkHandle, response { + this@response.error = + error.toProtonSdkError("Operation $operation was cancelled") + }) + throw error + } catch (error: Throwable) { + // loggers here could be removed + logger("Error while $operation") + logger(error.stackTraceToString()) + handleResponse(sdkHandle, response { + this@response.error = error.toProtonSdkError("Error while $operation") + }) + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun onCallback(callback: String, block: suspend () -> Unit) { + coroutineScope(callback).launch { + try { + block() + } catch (error: CancellationException) { + logger("Callback $callback was cancelled") + throw error + } catch (error: Throwable) { + logger("Error while $callback") + logger(error.stackTraceToString()) + } + } + } + + private fun coroutineScope(operation: String): CoroutineScope { + checkNotNull(coroutineScope) { + "No coroutineScope was provided to ${javaClass.simpleName}, cannot execute $operation" + } + if (!coroutineScope.isActive) { + logger("CoroutineScope not active for $operation") + } + return coroutineScope + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt new file mode 100644 index 00000000..670d98f2 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.drive.sdk.internal + +import proton.sdk.ProtonSdk.Request +import java.nio.ByteBuffer + +class ProtonSdkNativeClient internal constructor( + val name: String, + val response: ResponseCallback = { error("response not configured for $name") }, + val callback: (ByteBuffer) -> Unit = { error("callback not configured for $name") }, + val logger: (String) -> Unit = {} +) { + + fun release() { + // do nothing as C code use weak reference + // keep this method to force user to keep a strong reference to the native client until they are done + } + + fun handleRequest( + request: Request, + ) { + logger("handle request ${request.payloadCase.name} for $name") + handleRequest(request.toByteArray()) + } + + external fun handleRequest( + request: ByteArray, + ) + + external fun getCallbackPointer(): Long + + fun onResponse(data: ByteBuffer) { + logger("response for $name of size: ${data.capacity()}") + response(data) + } + + fun onCallback(data: ByteBuffer) { + logger("callback for $name of size: ${data.capacity()}") + callback(data) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt new file mode 100644 index 00000000..e6fb51eb --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +data class ApiRetrySucceededEvent( + val url: String, + val failedAttempts: Int, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt new file mode 100644 index 00000000..03e84cb0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +data class BlockVerificationErrorEvent( + val retryHelped: Boolean, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt new file mode 100644 index 00000000..7ded017e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +data class DecryptionErrorEvent( + val volumeType: VolumeType, + val field: EncryptedField, + val fromBefore2024: Boolean, + val error: String?, + val uid: String, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt new file mode 100644 index 00000000..0900c5d0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +enum class DownloadError { + UNRECOGNIZED, + SERVER_ERROR, + NETWORK_ERROR, + DECRYPTION_ERROR, + INTEGRITY_ERROR, + RATE_LIMITED, + HTTP_CLIENT_SIDE_ERROR, + UNKNOWN, +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt new file mode 100644 index 00000000..f69b126b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +data class DownloadEvent( + val volumeType: VolumeType, + val expectedSize: Long, + val uploadedSize: Long, + val error: DownloadError?, + val originalError: String?, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt new file mode 100644 index 00000000..82519bdf --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +enum class EncryptedField { + UNRECOGNIZED, + SHARE_KEY, + NODE_KEY, + NODE_NAME, + NODE_HASH_KEY, + NODE_EXTENDED_ATTRIBUTES, + NODE_CONTENT_KEY, + CONTENT, +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt new file mode 100644 index 00000000..923b5f02 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +enum class UploadError { + UNRECOGNIZED, + SERVER_ERROR, + NETWORK_ERROR, + INTEGRITY_ERROR, + RATE_LIMITED, + HTTP_CLIENT_SIDE_ERROR, + UNKNOWN, +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt new file mode 100644 index 00000000..7dd2bf74 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +data class UploadEvent( + val volumeType: VolumeType, + val expectedSize: Long, + val uploadedSize: Long, + val error: UploadError?, + val originalError: String?, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt new file mode 100644 index 00000000..f9db77cb --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +data class VerificationErrorEvent( + val volumeType: VolumeType, + val field: EncryptedField, + val fromBefore2024: Boolean, + val addressMatchingDefaultShare: Boolean, + val error: String?, + val uid: String, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt new file mode 100644 index 00000000..a20e8074 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.telemetry + +enum class VolumeType { + UNRECOGNIZED, + OWN_VOLUME, + SHARED, + SHARED_PUBLIC, + PHOTO, +} diff --git a/kt/settings.gradle.kts b/kt/settings.gradle.kts new file mode 100644 index 00000000..a66bd78c --- /dev/null +++ b/kt/settings.gradle.kts @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +rootProject.name = "ProtonDriveSdk" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("./libs.versions.toml")) + } + } +} + +pluginManagement { + repositories { + providers.environmentVariable("INTERNAL_REPOSITORY").orNull?.let { path -> + maven { url = uri(path) } + } + gradlePluginPortal() + google() + mavenCentral() + } +} + +plugins { + id("me.proton.core.gradle-plugins.include-core-build") version "1.3.0" + id("com.gradle.enterprise") version "3.12.6" +} + +gradleEnterprise { + buildScan { + publishAlwaysIf(!System.getenv("BUILD_SCAN_PUBLISH").isNullOrEmpty()) + termsOfServiceUrl = "https://gradle.com/terms-of-service" + termsOfServiceAgree = "yes" + } +} + +buildCache { + local { + isEnabled = !providers.environmentVariable("CI_SERVER").isPresent + } + providers.environmentVariable("BUILD_CACHE_URL").orNull?.let { buildCacheUrl -> + remote { + isPush = providers.environmentVariable("CI_SERVER").isPresent + url = uri(buildCacheUrl) + } + } +} + +include(":sdk") +include(":testapp") + From b8604cd143ae5c580069723e247a0a7a09b0508e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 14:36:38 +0000 Subject: [PATCH 305/791] Add pause and resume API --- .../main/kotlin/me/proton/drive/sdk/DownloadController.kt | 4 ++++ .../main/kotlin/me/proton/drive/sdk/UploadController.kt | 7 +++++++ kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt | 1 + 3 files changed, 12 insertions(+) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index d03d6c1e..857a901e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -29,5 +29,9 @@ class DownloadController internal constructor( suspend fun awaitCompletion() = bridge.awaitCompletion(handle) + suspend fun resume() = bridge.resume(handle) + + suspend fun pause() = bridge.pause(handle) + override fun close() = bridge.free(handle) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index 337e8484..01cebece 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -19,17 +19,24 @@ package me.proton.drive.sdk import me.proton.drive.sdk.internal.JniUploadController +import java.io.InputStream class UploadController internal constructor( uploader: Uploader, internal val handle: Long, private val bridge: JniUploadController, + private val inputStream: InputStream, override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(uploader), AutoCloseable, Cancellable { suspend fun awaitCompletion() = bridge.awaitCompletion(handle) + suspend fun resume() = bridge.resume(handle) + + suspend fun pause() = bridge.pause(handle) + override fun close() { + inputStream.close() bridge.free(handle) super.close() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 3c3917ab..57705b43 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -61,6 +61,7 @@ class Uploader internal constructor( handle = handle, bridge = JniUploadController(), cancellationTokenSource = source, + inputStream = inputStream, ) } From 9820bb0b4907be5bc18905033676756dd997840b Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 15:44:02 +0100 Subject: [PATCH 306/791] CaptureTime unix time was in milliseconds instead of seconds --- js/sdk/src/internal/photos/upload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index 72ee7116..1bd0d8f0 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -220,7 +220,7 @@ export class PhotoUploadAPIService extends UploadAPIService { XAttr: options.armoredExtendedAttributes || null, Photo: { ContentHash: photo.contentHash, - CaptureTime: photo.captureTime?.getTime() || 0, + CaptureTime: photo.captureTime ? Math.floor(photo.captureTime?.getTime() /1000) : 0, MainPhotoLinkID: photo.mainPhotoLinkID || null, Tags: photo.tags || [], Exif: null, // Deprecated field, not used. From 9a86f96b21d8078955e01223da3b47659a39fdeb Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 15:42:59 +0100 Subject: [PATCH 307/791] Fix deleting draft --- js/sdk/src/internal/upload/fileUploader.ts | 29 ++++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 34370222..71fd25d9 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -15,7 +15,7 @@ import { UploadTelemetry } from './telemetry'; * This class is not meant to be used directly, but rather to be extended * by `FileUploader` and `FileRevisionUploader`. */ -class Uploader { +abstract class Uploader { protected controller: UploadController; protected abortController: AbortController; @@ -83,7 +83,7 @@ class Uploader { stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, - ): Promise<{ nodeRevisionUid: string, nodeUid: string }> { + ): Promise<{ nodeRevisionUid: string; nodeUid: string }> { const uploader = await this.initStreamUploader(); return uploader.start(stream, thumbnails, onProgress); } @@ -94,15 +94,11 @@ class Uploader { const onFinish = async (failure: boolean) => { this.onFinish(); if (failure) { - await this.manager.deleteDraftNode(revisionDraft.nodeUid); + await this.deleteRevisionDraft(revisionDraft); } }; - return this.newStreamUploader( - blockVerifier, - revisionDraft, - onFinish, - ); + return this.newStreamUploader(blockVerifier, revisionDraft, onFinish); } protected async newStreamUploader( @@ -124,9 +120,12 @@ class Uploader { ); } - protected async createRevisionDraft(): Promise<{ revisionDraft: NodeRevisionDraft; blockVerifier: BlockVerifier }> { - throw new Error('Not implemented'); - } + protected abstract createRevisionDraft(): Promise<{ + revisionDraft: NodeRevisionDraft; + blockVerifier: BlockVerifier; + }>; + + protected abstract deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise; } /** @@ -176,6 +175,10 @@ export class FileUploader extends Uploader { blockVerifier, }; } + + protected async deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise { + await this.manager.deleteDraftNode(revisionDraft.nodeUid); + } } /** @@ -223,4 +226,8 @@ export class FileRevisionUploader extends Uploader { blockVerifier, }; } + + protected async deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise { + await this.manager.deleteDraftRevision(revisionDraft.nodeRevisionUid); + } } From 061e0b8d07bc17cf42e3158cc80fdaef59d34190 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 16:04:12 +0100 Subject: [PATCH 308/791] js/v0.6.2 --- js/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/package.json b/js/sdk/package.json index 6aab0c62..c5db043e 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.6.1", + "version": "0.6.2", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 11904ec93ede62e89d6e589648881826047cfbf8 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 15:38:22 +0100 Subject: [PATCH 309/791] Fix missing disposal of file uploader and file downloader through interop --- .../src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs | 4 +++- cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs | 4 +++- cs/sdk/src/Proton.Sdk.CExports/Interop.cs | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index 5fa99417..91d91b18 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -43,7 +43,9 @@ public static IMessage HandleDownloadToFile(DownloadToFileRequest request, nint public static IMessage? HandleFree(FileDownloaderFreeRequest request) { - Interop.FreeHandle(request.FileDownloaderHandle); + var fileDownloader = Interop.FreeHandle(request.FileDownloaderHandle); + + fileDownloader.Dispose(); return null; } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 7ded46b8..47a04b4f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -63,7 +63,9 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint public static IMessage? HandleFree(FileUploaderFreeRequest request) { - Interop.FreeHandle(request.FileUploaderHandle); + var fileUploader = Interop.FreeHandle(request.FileUploaderHandle); + + fileUploader.Dispose(); return null; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs index 3c39cd67..fc13f044 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs @@ -24,17 +24,19 @@ public static T GetFromHandleAndFree(long handle) return GetFromHandle(handle, free: true); } - public static void FreeHandle(long handle) + public static T FreeHandle(long handle) where T : class { var gcHandle = GCHandle.FromIntPtr((nint)handle); - if (gcHandle.Target is not T) + if (gcHandle.Target is not T target) { throw InvalidHandleException.Create((nint)handle); } gcHandle.Free(); + + return target; } [MethodImpl(MethodImplOptions.AggressiveInlining)] From d1248f3e5e3842c2e195281c72e81e72fd01080f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 12:08:15 +0100 Subject: [PATCH 310/791] Add filtering by type to thumbnail enumeration --- .../InteropProtonDriveClient.cs | 8 ++++++-- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 15 +++++++++------ .../Proton.Drive.Sdk/Nodes/FileThumbnail.cs | 2 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 7 +++++-- cs/sdk/src/protos/proton.drive.sdk.proto | 6 +++--- .../kotlin/me/proton/drive/sdk/DriveClient.kt | 10 +++++----- .../drive/sdk/extension/ThumbnailType.kt | 8 +++----- .../DownloadThumbnailsManager.swift | 13 ++++++++++++- .../Sources/Plumbing/PublicTypes.swift | 19 ++----------------- 9 files changed, 46 insertions(+), 42 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 785a25b1..6eede45c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -121,11 +121,15 @@ public static async ValueTask HandleGetThumbnailsAsync(DriveClientGetT var client = Interop.GetFromHandle(request.ClientHandle); - var thumbnails = await client.EnumerateThumbnailsAsync(request.FileUids.Select(NodeUid.Parse), cancellationToken) + var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( + request.FileUids.Select(NodeUid.Parse), + (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, + cancellationToken); + + var thumbnails = await thumbnailsEnumerable .Select(x => new FileThumbnail { FileUid = x.FileUid.ToString(), - Type = (ThumbnailType)x.Type, Data = ByteString.CopyFrom(x.Data.Span), }) .ToListAsync(cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index db149048..96baae66 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -27,6 +27,7 @@ public static async ValueTask GetSecretsAsync(ProtonDriveClient cli public static async IAsyncEnumerable EnumerateThumbnailsAsync( ProtonDriveClient client, IEnumerable fileUids, + ThumbnailType thumbnailType, [EnumeratorCancellation] CancellationToken cancellationToken) { // TODO: optimize parallelization for when UIDs are scattered over many volumes @@ -47,9 +48,12 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( LogNoThumbnailOnNode(client.Logger, fileNode.Uid); } - return thumbnails.Select(thumbnail => (thumbnail.Id, thumbnail.Type, Node: fileNode)).ToAsyncEnumerable(); + return thumbnails + .Where(thumbnail => thumbnail.Type == thumbnailType) + .Select(thumbnail => (thumbnail.Id, Node: fileNode)) + .ToAsyncEnumerable(); }) - .ToDictionaryAsync(thumbnail => thumbnail.Id, thumbnail => (thumbnail.Type, NodeUid: thumbnail.Node), cancellationToken) + .ToDictionaryAsync(thumbnail => thumbnail.Id, thumbnail => thumbnail.Node, cancellationToken) .ConfigureAwait(false); if (thumbnailIds.Count == 0) @@ -62,7 +66,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( var tasks = new Queue>(); foreach (var block in response.Blocks) { - var (type, fileNode) = thumbnailIds[block.ThumbnailId]; + var fileNode = thumbnailIds[block.ThumbnailId]; if (!await client.ThumbnailBlockDownloader.BlockSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) { @@ -74,7 +78,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( await client.ThumbnailBlockDownloader.BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); } - tasks.Enqueue(DownloadThumbnailAsync(client, fileNode.Uid, type, block, cancellationToken)); + tasks.Enqueue(DownloadThumbnailAsync(client, fileNode.Uid, block, cancellationToken)); } while (tasks.TryDequeue(out var task)) @@ -87,7 +91,6 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( private static async Task DownloadThumbnailAsync( ProtonDriveClient client, NodeUid fileUid, - ThumbnailType thumbnailType, ThumbnailBlock block, CancellationToken cancellationToken) { @@ -105,7 +108,7 @@ await client.ThumbnailBlockDownloader.DownloadAsync(block.BareUrl, block.Token, var thumbnailData = outputStream.TryGetBuffer(out var outputBuffer) ? outputBuffer : outputStream.ToArray(); - return new FileThumbnail(fileUid, thumbnailType, thumbnailData); + return new FileThumbnail(fileUid, thumbnailData); } } finally diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs index 0cd6bbb0..5840154f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs @@ -1,3 +1,3 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed record FileThumbnail(NodeUid FileUid, ThumbnailType Type, ReadOnlyMemory Data); +public sealed record FileThumbnail(NodeUid FileUid, ReadOnlyMemory Data); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index daa2436c..fe8d9e8f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -145,9 +145,12 @@ public IAsyncEnumerable> EnumerateFolderChildrenAsync return FolderOperations.EnumerateChildrenAsync(this, folderId, cancellationToken); } - public IAsyncEnumerable EnumerateThumbnailsAsync(IEnumerable fileUids, CancellationToken cancellationToken = default) + public IAsyncEnumerable EnumerateThumbnailsAsync( + IEnumerable fileUids, + ThumbnailType type, + CancellationToken cancellationToken = default) { - return FileOperations.EnumerateThumbnailsAsync(this, fileUids, cancellationToken); + return FileOperations.EnumerateThumbnailsAsync(this, fileUids, type, cancellationToken); } public async ValueTask GetFileUploaderAsync( diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index e3015bf0..3ef1da54 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -110,8 +110,7 @@ message Thumbnail { message FileThumbnail { string file_uid = 1; - ThumbnailType type = 2; - bytes data = 3; + bytes data = 2; } message FileThumbnailList { @@ -247,7 +246,8 @@ message DriveClientGetAvailableNameRequest { message DriveClientGetThumbnailsRequest { int64 client_handle = 1; repeated string file_uids = 2; - int64 cancellation_token_source_handle = 3; + ThumbnailType type = 3; + int64 cancellation_token_source_handle = 4; } // Drive - downloads diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt index 0a43da6c..d0c4f317 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt @@ -20,7 +20,7 @@ package me.proton.drive.sdk import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.entity.ThumbnailType -import me.proton.drive.sdk.extension.toEntity +import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniDriveClient import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetThumbnailsRequest @@ -48,18 +48,18 @@ class DriveClient internal constructor( suspend fun getThumbnails( fileUids: List, - block: (String, ThumbnailType) -> OutputStream?, + type: ThumbnailType, + block: (String) -> OutputStream, ): Unit = cancellationTokenSource().let { source -> bridge.getThumbnails( driveClientGetThumbnailsRequest { this.fileUids += fileUids + this.type = type.toProto() clientHandle = handle cancellationTokenSourceHandle = source.handle } ).thumbnailsList.forEach { fileThumbnail -> - fileThumbnail.type.toEntity()?.let { thumbnailType -> - block(fileThumbnail.fileUid, thumbnailType) - }?.use { outputStream -> + block(fileThumbnail.fileUid).use { outputStream -> outputStream.write(fileThumbnail.data.toByteArray()) } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt index add697e5..33df12eb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt @@ -21,9 +21,7 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.ThumbnailType import proton.drive.sdk.ProtonDriveSdk -fun ProtonDriveSdk.ThumbnailType.toEntity() = when(this) { - ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_THUMBNAIL -> ThumbnailType.THUMBNAIL - ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_PREVIEW -> ThumbnailType.PREVIEW - ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_UNSPECIFIED, - ProtonDriveSdk.ThumbnailType.UNRECOGNIZED -> null +fun ThumbnailType.toProto() = when (this) { + ThumbnailType.THUMBNAIL -> ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_THUMBNAIL + ThumbnailType.PREVIEW -> ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_PREVIEW } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift index f609d6f6..f5c9d42e 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift @@ -34,9 +34,9 @@ public actor DownloadThumbnailsManager { } } - // TODO(SDK): pass thumbnail type once SDK accepts it let thumbnailsRequest = Proton_Drive_Sdk_DriveClientGetThumbnailsRequest.with { $0.clientHandle = Int64(clientHandle) + $0.type = type.sdkType $0.fileUids = fileUids.map(\.sdkCompatibleIdentifier) $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) } @@ -61,3 +61,14 @@ public actor DownloadThumbnailsManager { activeDownloads[cancellationToken] = nil } } + +private extension ThumbnailData.ThumbnailType { + var sdkType: Proton_Drive_Sdk_ThumbnailType { + switch self { + case .preview: + return .preview + case .thumbnail: + return .thumbnail + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index d50d83e4..a5d2ae13 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -183,28 +183,13 @@ public struct FileOperationProgress { /// Thumbnail with file id public struct ThumbnailDataWithId: Sendable { public let fileUid: SDKNodeUid - public let thumbnail: ThumbnailData + public let data: Data init?(fileThumbnail: Proton_Drive_Sdk_FileThumbnail) { guard let fileUid = SDKNodeUid(sdkCompatibleIdentifier: fileThumbnail.fileUid) else { return nil } - let type: ThumbnailData.ThumbnailType? = { - switch fileThumbnail.type { - case .unspecified: - return nil - case .thumbnail: - return .thumbnail - case .preview: - return .preview - case .UNRECOGNIZED(let int): - return nil - } - }() - guard let type else { - return nil - } self.fileUid = fileUid - self.thumbnail = ThumbnailData(type: type, data: fileThumbnail.data) + self.data = fileThumbnail.data } } From a4257b447be446d9381ebadf2a5143ba49830267 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 11:47:32 +0000 Subject: [PATCH 311/791] Remove copyrights and optimize imports --- kt/build.gradle.kts | 18 ------------------ kt/gradle.properties | 18 ------------------ kt/sdk/build.gradle.kts | 17 ----------------- kt/sdk/src/main/AndroidManifest.xml | 18 ------------------ .../kotlin/me/proton/drive/sdk/Cancellable.kt | 18 ------------------ .../drive/sdk/CancellationTokenSource.kt | 18 ------------------ .../drive/sdk/CorePublicAddressResolver.kt | 18 ------------------ .../drive/sdk/CoreUserAddressResolver.kt | 18 ------------------ .../me/proton/drive/sdk/DownloadController.kt | 18 ------------------ .../kotlin/me/proton/drive/sdk/Downloader.kt | 18 ------------------ .../kotlin/me/proton/drive/sdk/DriveClient.kt | 18 ------------------ .../kotlin/me/proton/drive/sdk/HttpSdkApi.kt | 18 ------------------ .../me/proton/drive/sdk/LoggerProvider.kt | 18 ------------------ .../me/proton/drive/sdk/MetricCallback.kt | 18 ------------------ .../me/proton/drive/sdk/ProtonDriveSdk.kt | 17 ----------------- .../drive/sdk/ProtonDriveSdkException.kt | 18 ------------------ .../me/proton/drive/sdk/ProtonSdkError.kt | 18 ------------------ .../proton/drive/sdk/PublicAddressResolver.kt | 18 ------------------ .../main/kotlin/me/proton/drive/sdk/SdkNode.kt | 18 ------------------ .../main/kotlin/me/proton/drive/sdk/Session.kt | 18 ------------------ .../me/proton/drive/sdk/TelemetryBridge.kt | 18 ------------------ .../src/main/kotlin/me/proton/drive/sdk/Uid.kt | 18 ------------------ .../me/proton/drive/sdk/UploadController.kt | 18 ------------------ .../kotlin/me/proton/drive/sdk/Uploader.kt | 18 ------------------ .../me/proton/drive/sdk/UserAddressResolver.kt | 18 ------------------ .../proton/drive/sdk/converter/AnyConverter.kt | 18 ------------------ .../converter/FileThumbnailListConverter.kt | 18 ------------------ .../drive/sdk/converter/LongConverter.kt | 18 ------------------ .../drive/sdk/converter/StringConverter.kt | 18 ------------------ .../sdk/converter/UploadResultConverter.kt | 18 ------------------ .../me/proton/drive/sdk/entity/Address.kt | 18 ------------------ .../drive/sdk/entity/ClientCreateRequest.kt | 18 ------------------ .../sdk/entity/FileRevisionUploaderRequest.kt | 18 ------------------ .../drive/sdk/entity/FileUploaderRequest.kt | 18 ------------------ .../drive/sdk/entity/ProtonClientOptions.kt | 18 ------------------ .../drive/sdk/entity/ProtonClientTlsPolicy.kt | 18 ------------------ .../drive/sdk/entity/SessionBeginRequest.kt | 18 ------------------ .../drive/sdk/entity/SessionRenewRequest.kt | 18 ------------------ .../drive/sdk/entity/SessionResumeRequest.kt | 18 ------------------ .../proton/drive/sdk/entity/ThumbnailType.kt | 18 ------------------ .../me/proton/drive/sdk/entity/UploadResult.kt | 18 ------------------ .../me/proton/drive/sdk/extension/Address.kt | 18 ------------------ .../extension/ApiRetrySucceededEventPayload.kt | 18 ------------------ .../BlockVerificationErrorEventPayload.kt | 18 ------------------ .../proton/drive/sdk/extension/ByteBuffer.kt | 18 ------------------ .../sdk/extension/CancellableContinuation.kt | 18 ------------------ .../drive/sdk/extension/CoroutineScope.kt | 18 ------------------ .../extension/DecryptionErrorEventPayload.kt | 18 ------------------ .../drive/sdk/extension/DownloadError.kt | 18 ------------------ .../sdk/extension/DownloadEventPayload.kt | 18 ------------------ .../drive/sdk/extension/EncryptedField.kt | 18 ------------------ .../me/proton/drive/sdk/extension/Error.kt | 18 ------------------ .../extension/FileUploaderCreationRequest.kt | 18 ------------------ .../GetFileRevisionUploaderRequest.kt | 18 ------------------ .../proton/drive/sdk/extension/MessageLite.kt | 18 ------------------ .../sdk/extension/NodeNameConflictErrorData.kt | 18 ------------------ .../drive/sdk/extension/ProtonClientOptions.kt | 18 ------------------ .../sdk/extension/ProtonClientTlsPolicy.kt | 18 ------------------ .../ProtonSdk.IntegerOrErrorResponse.kt | 18 ------------------ .../drive/sdk/extension/SessionBeginRequest.kt | 18 ------------------ .../drive/sdk/extension/SessionRenewRequest.kt | 18 ------------------ .../sdk/extension/SessionResumeRequest.kt | 18 ------------------ .../me/proton/drive/sdk/extension/Throwable.kt | 18 ------------------ .../drive/sdk/extension/ThumbnailType.kt | 18 ------------------ .../proton/drive/sdk/extension/UploadError.kt | 18 ------------------ .../drive/sdk/extension/UploadEventPayload.kt | 18 ------------------ .../proton/drive/sdk/extension/UploadResult.kt | 18 ------------------ .../extension/VerificationErrorEventPayload.kt | 18 ------------------ .../proton/drive/sdk/extension/VolumeType.kt | 18 ------------------ .../drive/sdk/internal/AccountClientBridge.kt | 18 ------------------ .../drive/sdk/internal/ApiProviderBridge.kt | 18 ------------------ .../ContinuationUnitOrErrorResponse.kt | 18 ------------------ .../ContinuationValueOrErrorResponse.kt | 18 ------------------ .../internal/IgnoredIntegerOrErrorResponse.kt | 18 ------------------ .../me/proton/drive/sdk/internal/JniBase.kt | 18 ------------------ .../sdk/internal/JniBaseProtonDriveSdk.kt | 18 ------------------ .../drive/sdk/internal/JniBaseProtonSdk.kt | 18 ------------------ .../sdk/internal/JniCancellationTokenSource.kt | 18 ------------------ .../sdk/internal/JniDownloadController.kt | 18 ------------------ .../proton/drive/sdk/internal/JniDownloader.kt | 18 ------------------ .../drive/sdk/internal/JniDriveClient.kt | 18 ------------------ .../drive/sdk/internal/JniLoggerProvider.kt | 18 ------------------ .../drive/sdk/internal/JniNativeLibrary.kt | 18 ------------------ .../me/proton/drive/sdk/internal/JniSession.kt | 18 ------------------ .../drive/sdk/internal/JniUploadController.kt | 18 ------------------ .../proton/drive/sdk/internal/JniUploader.kt | 18 ------------------ .../sdk/internal/ProtonDriveSdkNativeClient.kt | 18 ------------------ .../sdk/internal/ProtonSdkNativeClient.kt | 18 ------------------ .../sdk/telemetry/ApiRetrySucceededEvent.kt | 18 ------------------ .../telemetry/BlockVerificationErrorEvent.kt | 18 ------------------ .../sdk/telemetry/DecryptionErrorEvent.kt | 18 ------------------ .../drive/sdk/telemetry/DownloadError.kt | 18 ------------------ .../drive/sdk/telemetry/DownloadEvent.kt | 18 ------------------ .../drive/sdk/telemetry/EncryptedField.kt | 18 ------------------ .../proton/drive/sdk/telemetry/UploadError.kt | 18 ------------------ .../proton/drive/sdk/telemetry/UploadEvent.kt | 18 ------------------ .../sdk/telemetry/VerificationErrorEvent.kt | 18 ------------------ .../proton/drive/sdk/telemetry/VolumeType.kt | 18 ------------------ kt/settings.gradle.kts | 18 ------------------ 99 files changed, 1780 deletions(-) diff --git a/kt/build.gradle.kts b/kt/build.gradle.kts index 4d2fa95b..5c441192 100644 --- a/kt/build.gradle.kts +++ b/kt/build.gradle.kts @@ -3,24 +3,6 @@ import com.vanniktech.maven.publish.MavenPublishBaseExtension import com.vanniktech.maven.publish.SonatypeHost import java.util.Properties -/* - * Copyright (c) 2023 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - // Top-level build file where you can add configuration options common to all sub-projects/modules. val privateProperties = Properties().apply { diff --git a/kt/gradle.properties b/kt/gradle.properties index 848801cb..5f43bbaa 100644 --- a/kt/gradle.properties +++ b/kt/gradle.properties @@ -1,21 +1,3 @@ -# -# Copyright (c) 2023 Proton AG. -# This file is part of Proton Drive. -# -# Proton Drive is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Proton Drive is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Proton Drive. If not, see . -# - # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* diff --git a/kt/sdk/build.gradle.kts b/kt/sdk/build.gradle.kts index 2e412c45..111dae02 100644 --- a/kt/sdk/build.gradle.kts +++ b/kt/sdk/build.gradle.kts @@ -1,22 +1,5 @@ import com.google.protobuf.gradle.proto -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ plugins { alias(libs.plugins.android.library) alias(libs.plugins.protobuf) diff --git a/kt/sdk/src/main/AndroidManifest.xml b/kt/sdk/src/main/AndroidManifest.xml index b7f1ea45..036712d1 100644 --- a/kt/sdk/src/main/AndroidManifest.xml +++ b/kt/sdk/src/main/AndroidManifest.xml @@ -1,22 +1,4 @@ - - diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt index edc66851..e334b41e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk interface Cancellable { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt index 3f012024..54d155ec 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.internal.JniCancellationTokenSource diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt index 5529ef28..12a0d56c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.core.domain.entity.UserId diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt index 05ca4ac5..a5b9c0aa 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.core.crypto.common.context.CryptoContext diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index 857a901e..94d8335e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.internal.JniDownloadController diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt index d1a8a13d..036a23f2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt index d0c4f317..3a4a4472 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt index 92915b82..657701ee 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.core.network.data.protonApi.BaseRetrofitApi diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt index 3f7b34eb..f9de4bfe 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.internal.JniLoggerProvider diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt index 159fdc79..fe2184b3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.telemetry.ApiRetrySucceededEvent diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt index f09b75aa..9f7dd528 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -1,20 +1,3 @@ -/* - * Copyright (c) 2024-2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt index 7c5f30c4..5555c26c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk class ProtonDriveSdkException( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt index 8344d11a..e4e03bc0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk data class ProtonSdkError( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt index 271bf3fb..147eb266 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk interface PublicAddressResolver { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt index 4103e2f3..13e4ffdc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk abstract class SdkNode(val parent: SdkNode?) : AutoCloseable { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt index b4251940..63b86c2e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.entity.SessionRenewRequest diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt index f9fa6db1..9ad68291 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.extension.toEvent diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt index 07e73fcd..533a9ebf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk object Uid { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index 01cebece..c417578e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.internal.JniUploadController diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 57705b43..a88e3863 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt index 75f3c25e..193b29ed 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk import me.proton.drive.sdk.entity.Address diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt index e343e1c0..faf073d1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.converter import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt index f19b8b91..12a52f98 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.converter import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt index 3a8b5cba..7151eaff 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.converter import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt index 97a200db..ef710a87 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.converter import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt index 1b3eea27..09b182dc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.converter import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt index e08f3a40..6d414801 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity data class Address( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt index a5b67b80..80703531 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity import me.proton.drive.sdk.LoggerProvider diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt index 8b217452..742b5cd4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity data class FileRevisionUploaderRequest( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt index 897ed1c8..d1012d80 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity data class FileUploaderRequest( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt index 0cf8d22a..10c349fb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity import me.proton.drive.sdk.LoggerProvider diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt index ad8fd557..d5bfca21 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity enum class ProtonClientTlsPolicy { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt index e70d3221..5b416725 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity import java.io.File diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt index 6ee2cd48..157db8ac 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity data class SessionRenewRequest( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt index 332ef6f8..52588c10 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity data class SessionResumeRequest( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt index b8fd1ba7..850d2bba 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity enum class ThumbnailType { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt index 68036bc5..64bbee1a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.entity data class UploadResult( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt index 53bb94bc..2d8e459c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.Address diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt index 0ef0ee0f..1820437f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.ApiRetrySucceededEvent diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt index 1916bd65..3bb366a4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.BlockVerificationErrorEvent diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt index f8dd83cb..4d28719a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import java.nio.ByteBuffer diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt index b153a2b4..71ebbde1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import kotlinx.coroutines.CancellableContinuation diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt index bce44631..333bcdaf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import kotlinx.coroutines.CoroutineScope diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt index 62a3eee1..caeabf5e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.DecryptionErrorEvent diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt index 0fee52c6..406ca8db 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.DownloadError diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt index 6134a23f..1b76d65f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.DownloadEvent diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt index 6a6f3a80..a9c74708 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.EncryptedField diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt index 82ddee75..897ba8e1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt index 90febbc9..28a64d22 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import com.google.protobuf.kotlin.toByteString diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt index 917b0c46..39db093c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import com.google.protobuf.timestamp diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt index 96de58e7..6d40fb09 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import com.google.protobuf.MessageLite diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt index 97422116..cbbccce3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.ProtonSdkError diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt index a27ef1a4..f5b2bae5 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.ProtonClientOptions diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt index 397d7de8..0712fb33 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.ProtonClientTlsPolicy diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt index 598741df..761f0602 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt index b1c905ef..4bc8ae05 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.SessionBeginRequest diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt index d30bcc91..0cec43fa 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.SessionRenewRequest diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt index f64f5768..5e6ef146 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.SessionResumeRequest diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt index 013de255..4c42d518 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import proton.sdk.ProtonSdk diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt index 33df12eb..e2ce8883 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.ThumbnailType diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt index 2653cf54..08945ffb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.UploadError diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt index d77b5728..e9361851 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.UploadEvent diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt index 9b0c2bb4..9d837039 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.UploadResult diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt index e12caa83..144a1bac 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.VerificationErrorEvent diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt index 51131ec0..63e4632f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.telemetry.VolumeType diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt index dc282808..cead75d7 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.internal import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt index 98efe6c7..79c87f69 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.internal import com.google.protobuf.kotlin.toByteString diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt index d23f339d..6e8d9b77 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import com.google.protobuf.kotlin.toByteString diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt index bc4ce6c1..d3e8d497 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import com.google.protobuf.kotlin.toByteString diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt index ca1e51a7..07159389 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import java.nio.ByteBuffer diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt index 49533316..415a6ed1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import me.proton.drive.sdk.LoggerProvider.Level.DEBUG diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt index 499f19ce..4b4ad327 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import kotlinx.coroutines.CancellableContinuation diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt index 8b28624d..c312d123 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import kotlinx.coroutines.CancellableContinuation diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt index a29d3d62..d2d1926d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import me.proton.drive.sdk.extension.LongResponseCallback diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt index 6d62bb88..db0020ff 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import me.proton.drive.sdk.extension.UnitResponseCallback diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt index a71cbe4a..2b983025 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import kotlinx.coroutines.CoroutineScope diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index 194876b9..acba9d42 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt index 3a12eb1b..4190d686 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import com.google.protobuf.InvalidProtocolBufferException diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt index 10254f05..adfae91c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal class JniNativeLibrary internal constructor() { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt index 597657ed..e1ca47ea 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import me.proton.drive.sdk.entity.SessionBeginRequest diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt index 9a397020..b1caaa14 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import me.proton.drive.sdk.entity.UploadResult diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt index d29bb633..704e38d0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import kotlinx.coroutines.CoroutineScope diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 859d4fa0..7d63d1eb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import com.google.protobuf.Any diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt index 670d98f2..0803ac82 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ - package me.proton.drive.sdk.internal import proton.sdk.ProtonSdk.Request diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt index e6fb51eb..c8a65ea6 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry data class ApiRetrySucceededEvent( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt index 03e84cb0..3e46231b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry data class BlockVerificationErrorEvent( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt index 7ded017e..0aceaeb6 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry data class DecryptionErrorEvent( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt index 0900c5d0..a36a3a18 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry enum class DownloadError { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt index f69b126b..2762ff35 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry data class DownloadEvent( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt index 82519bdf..da161b31 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry enum class EncryptedField { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt index 923b5f02..f2594177 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry enum class UploadError { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt index 7dd2bf74..def51871 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry data class UploadEvent( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt index f9db77cb..30768c24 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry data class VerificationErrorEvent( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt index a20e8074..0f1b049e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.telemetry enum class VolumeType { diff --git a/kt/settings.gradle.kts b/kt/settings.gradle.kts index a66bd78c..477b39d0 100644 --- a/kt/settings.gradle.kts +++ b/kt/settings.gradle.kts @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2023 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - rootProject.name = "ProtonDriveSdk" dependencyResolutionManagement { From e82587ef4c7c2e7082d3ab8bc09679d3029990bf Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 13:09:52 +0100 Subject: [PATCH 312/791] Support client-injected feature flags in Swift --- .../InteropProtonDriveClient.cs | 7 ++- .../InteropFeatureFlagProvider.cs | 31 +++++++++++++ .../Proton.Sdk.CExports/InteropFunction.cs | 37 ++++++++++++++++ cs/sdk/src/protos/proton.drive.sdk.proto | 7 +++ .../Sources/Plumbing/FeatureFlags.swift | 38 ++++++++++++++++ .../Sources/Plumbing/InternalTypes.swift | 43 ++++--------------- .../Plumbing/ProgressCallbackWrapper.swift | 17 -------- .../ProtonDriveClient/AccountClient.swift | 2 +- .../HttpClientProtocol.swift | 2 +- .../ProtonDriveClient/ProtonDriveClient.swift | 12 +++++- 10 files changed, 140 insertions(+), 56 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 6eede45c..54065b5c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -38,8 +38,11 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi ? new DriveInteropTelemetryDecorator(interopTelemetry) : NullTelemetry.Instance; - // FIXME add support for client to inject FF provider - var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, AlwaysDisabledFeatureFlagProvider.Instance, telemetry, request.Uid); + IFeatureFlagProvider featureFlagProvider = request.HasFeatureEnabledFunction + ? new InteropFeatureFlagProvider(bindingsHandle, new InteropFunction, int>(request.FeatureEnabledFunction)) + : AlwaysDisabledFeatureFlagProvider.Instance; + + var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, featureFlagProvider, telemetry, request.Uid); return new Int64Value { diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs new file mode 100644 index 00000000..f7e2bad6 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs @@ -0,0 +1,31 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace Proton.Sdk.CExports; + +/// +/// Feature flag provider that calls back to the bindings layer (e.g., Swift) to get feature flag values. +/// +internal sealed class InteropFeatureFlagProvider : IFeatureFlagProvider +{ + private readonly nint _bindingsHandle; + private readonly InteropFunction, int> _isEnabledFunc; + + public InteropFeatureFlagProvider(nint bindingsHandle, InteropFunction, int> isEnabledFunc) + { + _bindingsHandle = bindingsHandle; + _isEnabledFunc = isEnabledFunc; + } + + public unsafe Task IsEnabledAsync(string flagName, CancellationToken cancellationToken) + { + // Convert the flag name to UTF-8 bytes + var flagNameBytes = Encoding.UTF8.GetBytes(flagName); + + fixed (byte* flagNamePointer = flagNameBytes) + { + var result = _isEnabledFunc.Invoke(_bindingsHandle, new InteropArray(flagNamePointer, flagNameBytes.Length)); + return Task.FromResult(result != 0); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs new file mode 100644 index 00000000..087ccda7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs @@ -0,0 +1,37 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +/// +/// Represents a function pointer that can be called from C# to Swift/other languages. +/// Similar to InteropAction but with a return value. +/// +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropFunction + where TArg1 : unmanaged + where TArg2 : unmanaged + where TResult : unmanaged +{ + private readonly delegate* unmanaged[Cdecl] _pointer; + + public InteropFunction(delegate* unmanaged[Cdecl] pointer) + { + ArgumentNullException.ThrowIfNull(pointer); + _pointer = pointer; + } + + public InteropFunction(long pointer) + : this((delegate* unmanaged[Cdecl])pointer) + { + } + + public TResult Invoke(TArg1 arg1, TArg2 arg2) + { + return _pointer(arg1, arg2); + } + + public override string ToString() + { + return $"0x{new nint(_pointer):x16}"; + } +} diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 3ef1da54..8dfb9bcd 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -149,6 +149,13 @@ message DriveClientCreateRequest { // Client UID, optional // If a null value is provided, the SDK automatically generates a UUID during initialization string uid = 8; + + // Pointer to C function that will be called to check feature flags: + // int is_feature_flag_enabled(intptr_t bindings_handle, ByteArray flag_name) + // bindings_handle: handle for the bindings + // flag_name: UTF-8 encoded feature flag name + // returns: 0 for disabled, non-zero for enabled + int64 feature_enabled_function = 9; // Optional } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift new file mode 100644 index 00000000..87ccffb2 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift @@ -0,0 +1,38 @@ +import Foundation + +public typealias FeatureFlagProviderCallback = @Sendable (String) async -> Bool + +let cCompatibleFeatureFlagProviderCallback: CCallbackWithIntReturn = { statePointer, byteArray in + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + return 0 + } + + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let weakDriveClient = stateTypedPointer.takeUnretainedValue().state + + guard let driveClient = weakDriveClient.value else { + stateTypedPointer.release() + return 0 + } + + // Convert ByteArray to String + guard let pointer = byteArray.pointer, + let data = Data(bytes: pointer, count: byteArray.length), + let flagName = String(data: data, encoding: .utf8) else { + return 0 + } + + // Since the C# callback expects a synchronous return but our Swift callback is async, + // we need to block and wait for the async result using a semaphore + let semaphore = DispatchSemaphore(value: 0) + var result = false + + Task { + result = await driveClient.isFlagEnabled(flagName) + semaphore.signal() + } + + semaphore.wait() + + return result ? 1 : 0 +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index 3fdb90d9..70c9fa7a 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -13,7 +13,13 @@ extension ObjectHandle { } /// Returns the address of a callback as a number - init(callback: CCallbackWithReturnValue) { + init(callback: CCallbackWithCallbackPointer) { + let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) + self = ObjectHandle(bitPattern: callbackAddress) + } + + /// Returns the address of a callback with int return as a number + init(callback: CCallbackWithIntReturn) { let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) self = ObjectHandle(bitPattern: callbackAddress) } @@ -32,7 +38,8 @@ func address(of object: T) -> ObjectHandle { /// C-compatible callback used by SDK to pass data to the app typealias CCallback = @convention(c) (Int, ByteArray) -> Void -typealias CCallbackWithReturnValue = @convention(c) (Int, ByteArray, Int) -> Void +typealias CCallbackWithCallbackPointer = @convention(c) (Int, ByteArray, Int) -> Void +typealias CCallbackWithIntReturn = @convention(c) (Int, ByteArray) -> Int32 extension Data { var dumptoString: String { @@ -109,38 +116,6 @@ extension Proton_Sdk_ProtonClientTlsPolicy { } } -extension Proton_Sdk_ProtonClientOptions { - init(clientOptions: ClientOptions) { - self = Proton_Sdk_ProtonClientOptions.with { - if let baseUrl = clientOptions.baseUrl { - $0.baseURL = baseUrl - } - - if let userAgent = clientOptions.userAgent { - $0.userAgent = userAgent - } - - if let bindingsLanguage = clientOptions.bindingsLanguage { - $0.bindingsLanguage = bindingsLanguage - } - - if let tlsPolicy = clientOptions.tlsPolicy { - $0.tlsPolicy = Proton_Sdk_ProtonClientTlsPolicy(tlsPolicy: tlsPolicy) - } - - if let loggerProviderHandle = clientOptions.loggerProviderHandle { - $0.telemetry = Proton_Sdk_Telemetry.with { - $0.loggerProviderHandle = Int64(loggerProviderHandle) - } - } - - if let entityCachePath = clientOptions.entityCachePath { - $0.entityCachePath = entityCachePath - } - } - } -} - final class WeakReference where T: AnyObject { private(set) weak var value: T? init(value: T) { self.value = value } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift index 81746ca6..c9178840 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -1,20 +1,3 @@ -// Copyright (c) 2025 Proton AG -// -// This file is part of Proton Drive. -// -// Proton Drive is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Proton Drive is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Proton Drive. If not, see https://www.gnu.org/licenses/. - import Foundation import CProtonDriveSDK diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift index bda56abf..eaa3ecad 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift @@ -10,7 +10,7 @@ public protocol AccountClientProtocol: Sendable { func getAddressPublicKeysRequest(emailAddress: String) -> [Data] } -let cCompatibleAccountClientRequest: CCallbackWithReturnValue = { statePointer, byteArray, callbackPointer in +let cCompatibleAccountClientRequest: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { return } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift index e29e2bcd..765b38e7 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift @@ -18,7 +18,7 @@ public protocol HttpClientProtocol: AnyObject, Sendable { func request(method: String, url: String, content: Data, headers: [(String, [String])]) async -> Result } -let cCompatibleHttpRequest: CCallbackWithReturnValue = { statePointer, byteArray, callbackPointer in +let cCompatibleHttpRequest: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { return } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index ede7f21a..1f0b6583 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -13,6 +13,7 @@ public actor ProtonDriveClient: Sendable { private let logger: ProtonDriveSDK.Logger private let recordMetricEventCallback: RecordMetricEventCallback + private let featureFlagProviderCallback: FeatureFlagProviderCallback let httpClient: HttpClientProtocol let accountClient: AccountClientProtocol @@ -25,10 +26,12 @@ public actor ProtonDriveClient: Sendable { accountClient: AccountClientProtocol, clientUID: String?, logCallback: @escaping LogCallback, - recordMetricEventCallback: @escaping RecordMetricEventCallback + recordMetricEventCallback: @escaping RecordMetricEventCallback, + featureFlagProviderCallback: @escaping FeatureFlagProviderCallback ) async throws { self.logger = try await Logger(logCallback: logCallback) self.recordMetricEventCallback = recordMetricEventCallback + self.featureFlagProviderCallback = featureFlagProviderCallback self.httpClient = httpClient self.accountClient = accountClient @@ -43,6 +46,8 @@ public actor ProtonDriveClient: Sendable { $0.recordMetricAction = Int64(ObjectHandle(callback: cCompatibleTelemetryRecordMetricCallback)) } + $0.featureFlags = Int64(ObjectHandle(callback: cCompatibleFeatureFlagProviderCallback)) + if let entityCachePath { $0.entityCachePath = entityCachePath } @@ -76,6 +81,11 @@ public actor ProtonDriveClient: Sendable { recordMetricEventCallback(metricEvent) } + func isFlagEnabled(_ flagName: String) async -> Bool { + await featureFlagProviderCallback(flagName) + nonisolated func isFlagEnabled(_ flagName: String) async -> Bool { + } + public func downloadFile( revisionUid: SDKRevisionUid, destinationUrl: URL, From 3bedf727355230a7f6de1b53f4c75db349c5bd31 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 13:06:16 +0000 Subject: [PATCH 313/791] Parse Protobuf request within the same JNI call --- kt/sdk/src/main/jni/proton_drive_sdk.c | 8 +- .../drive/sdk/internal/JniDriveClient.kt | 2 +- .../internal/ProtonDriveSdkNativeClient.kt | 103 +++++++++++++----- 3 files changed, 83 insertions(+), 30 deletions(-) diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index ccb6126d..91e7b88f 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -111,12 +111,12 @@ void onSendHttpRequest( pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onSendHttpRequest"); } -void onRequest( +void onAccountRequest( intptr_t bindings_handle, ByteArray value, intptr_t sdk_handle ) { - pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onRequest"); + pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onAccountRequest"); } void onRecordMetric( @@ -155,11 +155,11 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSendHttpRe return (jlong) (intptr_t) &onSendHttpRequest; } -jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getRequestPointer( +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getAccountRequestPointer( JNIEnv *env, jobject obj ) { - return (jlong) (intptr_t) &onRequest; + return (jlong) (intptr_t) &onAccountRequest; } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getRecordMetricPointer( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index acba9d42..7b9fb30e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -47,7 +47,7 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { driveClientCreate = driveClientCreateRequest { baseUrl = request.baseUrl httpClientRequestAction = client.getSendHttpRequestPointer() - accountClientRequestAction = client.getRequestPointer() + accountClientRequestAction = client.getAccountRequestPointer() entityCachePath = request.entityCachePath secretCachePath = request.secretCachePath telemetry = telemetry { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 7d63d1eb..c9d25c86 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -78,7 +78,7 @@ class ProtonDriveSdkNativeClient internal constructor( external fun getWritePointer(): Long external fun getProgressPointer(): Long external fun getSendHttpRequestPointer(): Long - external fun getRequestPointer(): Long + external fun getAccountRequestPointer(): Long external fun getRecordMetricPointer(): Long @Suppress("unused") // Called by JNI @@ -88,10 +88,12 @@ class ProtonDriveSdkNativeClient internal constructor( } @Suppress("unused") // Called by JNI - fun onProgress(data: ByteBuffer) = onCallback("progress") { - logger("progress for $name of size: ${data.capacity()}") - progress(ProtonDriveSdk.ProgressUpdate.parseFrom(data)) - } + fun onProgress(data: ByteBuffer) = onCallback( + callback = "progress", + data = data, + parser = ProtonDriveSdk.ProgressUpdate::parseFrom, + block = progress, + ) @Suppress("unused") // Called by JNI fun onRead(buffer: ByteBuffer, sdkHandle: Long) = onOperation("read", sdkHandle) { @@ -109,8 +111,15 @@ class ProtonDriveSdkNativeClient internal constructor( } @Suppress("unused") // Called by JNI - fun onSendHttpRequest(data: ByteBuffer, sdkHandle: Long) = onOperation("http", sdkHandle) { - val httpRequest = ProtonSdk.HttpRequest.parseFrom(data) + fun onSendHttpRequest( + data: ByteBuffer, + sdkHandle: Long, + ) = onRequest( + operation = "http", + data = data, + sdkHandle = sdkHandle, + parser = ProtonSdk.HttpRequest::parseFrom, + ) { httpRequest -> logger("send http request for ${httpRequest.method} ${httpRequest.url} of size: ${data.capacity()}") val httpResponse = sendHttpRequest(httpRequest) logger("receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}") @@ -118,18 +127,27 @@ class ProtonDriveSdkNativeClient internal constructor( } @Suppress("unused") // Called by JNI - fun onRequest(data: ByteBuffer, sdkHandle: Long) = onOperation("request", sdkHandle) { - val clientRequest = ProtonDriveSdk.AccountRequest.parseFrom(data) - logger("request for ${clientRequest.payloadCase.name} of size: ${data.capacity()}") - val response = request(clientRequest) + fun onAccountRequest( + data: ByteBuffer, + sdkHandle: Long, + ) = onRequest( + operation = "request", + data = data, + sdkHandle = sdkHandle, + parser = ProtonDriveSdk.AccountRequest::parseFrom, + ) { accountRequest -> + logger("request for ${accountRequest.payloadCase.name} of size: ${data.capacity()}") + val response = request(accountRequest) response { value = response } } @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI - fun onRecordMetric(data: ByteBuffer) = onCallback("recordMetric") { - logger("Record metric for $name of size: ${data.capacity()}") - recordMetric(ProtonSdk.MetricEvent.parseFrom(data)) - } + fun onRecordMetric(data: ByteBuffer) = onCallback( + callback = "recordMetric", + data = data, + parser = ProtonSdk.MetricEvent::parseFrom, + block = recordMetric, + ) @Suppress("TooGenericExceptionCaught") private fun onOperation(operation: String, sdkHandle: Long, block: suspend () -> Response) { @@ -155,18 +173,53 @@ class ProtonDriveSdkNativeClient internal constructor( } @Suppress("TooGenericExceptionCaught") - private fun onCallback(callback: String, block: suspend () -> Unit) { - coroutineScope(callback).launch { - try { - block() - } catch (error: CancellationException) { - logger("Callback $callback was cancelled") - throw error - } catch (error: Throwable) { - logger("Error while $callback") - logger(error.stackTraceToString()) + private fun onRequest( + operation: String, + data: ByteBuffer, + sdkHandle: Long, + parser: (ByteBuffer) -> T, + block: suspend (T) -> Response + ) { + try { + // parsing of protobuf needs to be done serially + val request = parser(data) + onOperation(operation, sdkHandle) { block(request) } + } catch (error: Throwable) { + handleResponse(sdkHandle, response { + this@response.error = error.toProtonSdkError( + "Error while parsing request for $operation" + ) + }) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun onCallback( + callback: String, + data: ByteBuffer, + parser: (ByteBuffer) -> T, + block: suspend (T) -> Unit + ) { + try { + logger("callback for $name of size: ${data.capacity()}") + // parsing of protobuf needs to be done serially + val value = parser(data) + coroutineScope(callback).launch { + try { + block(value) + } catch (error: CancellationException) { + logger("Callback $callback was cancelled") + throw error + } catch (error: Throwable) { + logger("Error while $callback") + logger(error.stackTraceToString()) + } } + } catch (error: Throwable) { + logger("Error while parsing value for $callback") + logger(error.stackTraceToString()) } + } private fun coroutineScope(operation: String): CoroutineScope { From 82ecd06e27fe6f7d1f0c0b87c53eb97c1ac79782 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 15:41:16 +0100 Subject: [PATCH 314/791] Upgrade version from 0.3.1 to 0.4.0 --- kt/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kt/build.gradle.kts b/kt/build.gradle.kts index 5c441192..a234e2c4 100644 --- a/kt/build.gradle.kts +++ b/kt/build.gradle.kts @@ -40,7 +40,7 @@ allprojects { } } group = "me.proton.drive" - version = "0.3.1" + version = "0.4.0" afterEvaluate { configurations.all { From 5202aed454b42487e1f1b60e50dd5364bcb653c0 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 14:55:44 +0100 Subject: [PATCH 315/791] Add approximate upload size to upload metric event --- .../DriveInteropTelemetryDecorator.cs | 1 + .../Nodes/Upload/RevisionWriter.cs | 26 +++++++++++++++++++ .../Proton.Drive.Sdk/Telemetry/UploadEvent.cs | 2 ++ cs/sdk/src/protos/proton.drive.sdk.proto | 5 ++-- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index ce631ba1..771f5fa3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -37,6 +37,7 @@ private static UploadEventPayload GetUploadEventPayload(UploadEvent me) { VolumeType = (VolumeType)me.VolumeType, UploadedSize = me.UploadedSize, + ApproximateUploadedSize = me.ApproximateUploadedSize, ExpectedSize = me.ExpectedSize, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index b3013f5c..f0a9797f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -67,6 +67,7 @@ public async ValueTask WriteAsync( { ExpectedSize = contentStream.Length, UploadedSize = 0, + ApproximateUploadedSize = 0, VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type }; @@ -150,6 +151,7 @@ public async ValueTask WriteAsync( // TODO: move this to a decorator, wrap the progress action uploadEvent.UploadedSize = numberOfBytesUploaded; + uploadEvent.ApproximateUploadedSize = ReduceSizePrecision(numberOfBytesUploaded); onProgress(numberOfBytesUploaded, contentLength); } @@ -250,6 +252,30 @@ public void Dispose() } } + private static long ReduceSizePrecision(long size) + { + const long precision = 100_000; + + if (size == 0) + { + return 0; + } + + // We care about very small files in metrics, thus we handle explicitely + // the very small files so they appear correctly in metrics. + if (size < 4096) + { + return 4095; + } + + if (size < precision) + { + return precision; + } + + return (size / precision) * precision; + } + private static async ValueTask AddNextBlockToManifestAsync(Queue> uploadTasks, RecyclableMemoryStream manifestStream) { var sha256Digest = await uploadTasks.Dequeue().ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs index aab9632a..cf6a9ac7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs @@ -10,6 +10,8 @@ public sealed class UploadEvent : IMetricEvent public required long UploadedSize { get; set; } + public required long ApproximateUploadedSize { get; set; } + public required long ExpectedSize { get; set; } public UploadError? Error { get; set; } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 8dfb9bcd..b3165f97 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -359,8 +359,9 @@ message UploadEventPayload { VolumeType volume_type = 1; int64 expected_size = 2; int64 uploaded_size = 3; - UploadError error = 4; // Optional - string original_error = 5; // Optional + int64 approximate_uploaded_size = 4; // To be used when exact size must not be communicated in order to prevent fingerprinting + UploadError error = 5; // Optional + string original_error = 6; // Optional } message DownloadEventPayload { From 8f4783bf5bbd32a90444ff3bdbb8952ef8b717a7 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Nov 2025 11:44:17 +0000 Subject: [PATCH 316/791] Improve mapping of SDK exceptions to Kotlin errors --- .../proton/drive/sdk/extension/Throwable.kt | 20 +++++++++++++++++-- .../internal/ProtonDriveSdkNativeClient.kt | 3 --- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt index 4c42d518..b6786083 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -1,10 +1,26 @@ package me.proton.drive.sdk.extension +import kotlinx.coroutines.CancellationException +import me.proton.core.network.domain.ApiException +import me.proton.core.network.domain.ApiResult import proton.sdk.ProtonSdk fun Throwable.toProtonSdkError(defaultMessage: String) = proton.sdk.error { + val exception = this@toProtonSdkError type = javaClass.name - this.message = this@toProtonSdkError.message ?: defaultMessage - domain = ProtonSdk.ErrorDomain.Serialization + this.message = exception.message ?: defaultMessage + domain = when (exception) { + is CancellationException -> ProtonSdk.ErrorDomain.SuccessfulCancellation + is ApiException -> { + when (exception.error) { + is ApiResult.Error.Http -> ProtonSdk.ErrorDomain.Api + is ApiResult.Error.Timeout -> ProtonSdk.ErrorDomain.Transport + is ApiResult.Error.Connection -> ProtonSdk.ErrorDomain.Network + is ApiResult.Error.Parse -> ProtonSdk.ErrorDomain.Serialization + } + } + + else -> ProtonSdk.ErrorDomain.Undefined + } context = stackTraceToString() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index c9d25c86..6d377ab8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -162,9 +162,6 @@ class ProtonDriveSdkNativeClient internal constructor( }) throw error } catch (error: Throwable) { - // loggers here could be removed - logger("Error while $operation") - logger(error.stackTraceToString()) handleResponse(sdkHandle, response { this@response.error = error.toProtonSdkError("Error while $operation") }) From c2a7ba32001be2873fea81b768bacb9cecdc6099 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Nov 2025 14:56:58 +0100 Subject: [PATCH 317/791] Add approximate upload size to upload metric event in kt binding --- .../kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt | 1 + .../src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt index e9361851..c4d216a3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt @@ -7,6 +7,7 @@ fun ProtonDriveSdk.UploadEventPayload.toEvent() = UploadEvent( volumeType = volumeType.toEnum(), expectedSize = expectedSize, uploadedSize = uploadedSize, + approximateUploadedSize = approximateUploadedSize, error = error.toEnum(), originalError = originalError, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt index def51871..4a23683b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt @@ -4,6 +4,7 @@ data class UploadEvent( val volumeType: VolumeType, val expectedSize: Long, val uploadedSize: Long, + val approximateUploadedSize: Long, val error: UploadError?, val originalError: String?, ) From 9349caa787505fd7f11b86f6d0d3801ed5053ce2 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Nov 2025 16:28:03 +0000 Subject: [PATCH 318/791] Add AEAD support --- cs/Directory.Packages.props | 2 +- .../PgpAeadStreamingChunkLength.cs | 10 ++++++++++ .../Nodes/FolderOperations.cs | 3 +++ .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 4 +++- .../Nodes/Upload/BlockUploader.cs | 19 ++++++++++++++++--- .../Nodes/Upload/NewFileDraftProvider.cs | 7 ++++++- .../Upload/Verification/BlockVerifier.cs | 8 ++++---- ...essionKeyAndDataPacketMismatchException.cs | 5 +++++ .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 6 +++++- .../PgpSessionKeyJsonConverter.cs | 18 ++++++++++++++++-- 10 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Cryptography/PgpAeadStreamingChunkLength.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index aa470a65..74b9b461 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -15,7 +15,7 @@ - + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Cryptography/PgpAeadStreamingChunkLength.cs b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/PgpAeadStreamingChunkLength.cs new file mode 100644 index 00000000..da61b889 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/PgpAeadStreamingChunkLength.cs @@ -0,0 +1,10 @@ +namespace Proton.Drive.Sdk.Cryptography; + +internal static class PgpAeadStreamingChunkLength +{ + // This parameter will set the streaming block size for AEAD encryption. Increasing this + // reduces the number of tags and slightly improves performance, at the cost of more memory + // consumption during decryption, and encryption due to the verifier which must decrypt the + // first chunk of the encrypted payload. + public const long ChunkLength = 1 << 17; // bytes -> 128KiB block size for streaming +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 5ec82eae..df821889 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -65,11 +65,14 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, var hashKey = CryptoGenerator.GenerateFolderHashKey(); + var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken); + NodeOperations.GetCommonCreationParameters( name, parentSecrets.Key, parentSecrets.HashKey.Span, signingKey, + useAeadFeatureFlag, out var key, out var nameSessionKey, out var passphraseSessionKey, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 6076130d..fd4016f0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -93,6 +93,7 @@ public static void GetCommonCreationParameters( PgpPrivateKey parentFolderKey, ReadOnlySpan parentFolderHashKey, PgpPrivateKey signingKey, + bool useAeadFeatureFlag, out PgpPrivateKey key, out PgpSessionKey nameSessionKey, out PgpSessionKey passphraseSessionKey, @@ -102,7 +103,8 @@ public static void GetCommonCreationParameters( out ArraySegment passphraseSignature, out ArraySegment lockedKeyBytes) { - key = PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default); + var pgpProfile = useAeadFeatureFlag ? PgpProfile.ProtonAead : PgpProfile.Proton; + key = PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default, profile: pgpProfile); nameSessionKey = PgpSessionKey.Generate(); Span passphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 37375b12..2b1ca8a6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -72,7 +72,8 @@ public async Task UploadContentAsync( await using (signatureEncryptingStream.ConfigureAwait(false)) { - var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signatureEncryptingStream, signingKey); + var pgpProfile = contentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signatureEncryptingStream, signingKey, profile: pgpProfile, aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); await using (encryptingStream.ConfigureAwait(false)) { @@ -89,7 +90,18 @@ public async Task UploadContentAsync( var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; // FIXME: retry upon verification failure - var verificationToken = verifier.VerifyBlock(dataPacketStream.GetFirstBytes(128), plainDataPrefix.AsSpan()[..plainDataPrefixLength]); + + const long AeadChunkSize = + 1 + // packet header: packet type + 1 + // packet header: partial length + 4 + // SEIPDv2 header: packet version, cipher ID, algo Id, chunk size + 32 + // SEIPDv2 header: salt + PgpAeadStreamingChunkLength.ChunkLength + + 1 + // chunk size header + 36 + // end of chunk + 16; // Aead Tag + + var verificationToken = verifier.VerifyBlock(dataPacketStream.GetFirstBytes(AeadChunkSize), plainDataPrefix.AsSpan()[..plainDataPrefixLength]); var request = new BlockUploadPreparationRequest { @@ -162,7 +174,8 @@ public async Task UploadThumbnailAsync( await using (hashingStream.ConfigureAwait(false)) { - var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signingKey); + var pgpProfile = contentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signingKey, profile: pgpProfile, aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); await using (encryptingStream.ConfigureAwait(false)) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index b2e33849..5ec21af7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -53,6 +53,7 @@ private static FileCreationRequest GetFileCreationRequest( FolderSecrets parentSecrets, PgpPrivateKey signingKey, string membershipEmailAddress, + bool useAeadFeatureFlag, out PgpPrivateKey nodeKey, out PgpSessionKey passphraseSessionKey, out PgpSessionKey nameSessionKey, @@ -63,6 +64,7 @@ private static FileCreationRequest GetFileCreationRequest( parentSecrets.Key, parentSecrets.HashKey.Span, signingKey, + useAeadFeatureFlag, out nodeKey, out nameSessionKey, out passphraseSessionKey, @@ -72,7 +74,7 @@ private static FileCreationRequest GetFileCreationRequest( out var passphraseSignature, out var lockedKeyBytes); - contentKey = PgpSessionKey.Generate(); + contentKey = useAeadFeatureFlag ? PgpSessionKey.GenerateForAead() : PgpSessionKey.Generate(); var (contentKeyToken, _) = contentKey.Export(); return new FileCreationRequest @@ -102,6 +104,8 @@ private static FileCreationRequest GetFileCreationRequest( (FileCreationResponse Response, FileSecrets FileSecrets)? result = null; + var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken); + while (result is null) { var request = GetFileCreationRequest( @@ -112,6 +116,7 @@ private static FileCreationRequest GetFileCreationRequest( parentSecrets, signingKey, membershipEmailAddress, + useAeadFeatureFlag, out var nodeKey, out var passphraseSessionKey, out var nameSessionKey, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs index e2667a5e..f2d39699 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.HighPerformance; +using CommunityToolkit.HighPerformance; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.BlockVerification; using Proton.Drive.Sdk.Api.Files; @@ -55,12 +55,12 @@ public VerificationToken VerifyBlock(ReadOnlyMemory dataPacketPrefix, Read var numberOfBytesRead = decryptingStream.Read(buffer); if (!plainDataPrefix.StartsWith(buffer[..numberOfBytesRead])) { - throw new SessionKeyAndDataPacketMismatchException(); + throw new SessionKeyAndDataPacketMismatchException("Mismatched plaintext verification"); } } - catch + catch (Exception e) { - throw new SessionKeyAndDataPacketMismatchException(); + throw new SessionKeyAndDataPacketMismatchException(e); } return VerificationToken.Create(_verificationCode.Span, dataPacketPrefix.Span); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs index 960de69d..9506f826 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs @@ -15,4 +15,9 @@ public SessionKeyAndDataPacketMismatchException(string message, Exception innerE public SessionKeyAndDataPacketMismatchException() { } + + public SessionKeyAndDataPacketMismatchException(Exception innerException) + : base(string.Empty, innerException) + { + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index fe8d9e8f..2c1639a4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.Logging; using Microsoft.IO; +using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Caching; +using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; @@ -86,6 +88,7 @@ internal ProtonDriveClient( BlockUploader = new BlockUploader(this, maxDegreeOfBlockTransferParallelism); BlockDownloader = new BlockDownloader(this, maxDegreeOfBlockTransferParallelism); ThumbnailBlockDownloader = new BlockDownloader(this, 8); + PgpEnvironment.DefaultAeadStreamingChunkLength = PgpAeadStreamingChunkLength.ChunkLength; } private ProtonDriveClient( @@ -106,7 +109,8 @@ private ProtonDriveClient( { } - internal static RecyclableMemoryStreamManager MemoryStreamManager { get; } = new(); + // use 132KiB to align and provide some padding for AEAD chunk size (128KiB + PGP headers) + internal static RecyclableMemoryStreamManager MemoryStreamManager { get; } = new(new RecyclableMemoryStreamManager.Options { BlockSize = 135168 }); internal string Uid { get; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs index 80fd22c1..f0f3e995 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs @@ -6,15 +6,29 @@ namespace Proton.Drive.Sdk.Serialization; internal sealed class PgpSessionKeyJsonConverter : JsonConverter { + private const byte NonAeadVersion = 3; + private const byte AeadVersion = 6; + public override PgpSessionKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var bytes = reader.GetBytesFromBase64(); + var pkeskVersion = bytes[0]; + if (pkeskVersion == AeadVersion) + { + return PgpSessionKey.ImportForAead(bytes.AsSpan()[1..], SymmetricCipher.Aes256); + } - return PgpSessionKey.Import(bytes, SymmetricCipher.Aes256); + return PgpSessionKey.Import(bytes.AsSpan()[1..], SymmetricCipher.Aes256); } public override void Write(Utf8JsonWriter writer, PgpSessionKey value, JsonSerializerOptions options) { - writer.WriteBase64StringValue(value.Export().Token); + var pkeskVersion = value.IsAead() ? AeadVersion : NonAeadVersion; + var (token, _) = value.Export(); + Span versionedValue = stackalloc byte[token.Length + 1]; + versionedValue[0] = pkeskVersion; + token.CopyTo(versionedValue[1..]); + + writer.WriteBase64StringValue(versionedValue); } } From 3e1b146c281d3a2a6bf2e97725d2cd6d3af6ca15 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 10:38:58 +0000 Subject: [PATCH 319/791] Use streaming in HTTP client --- cs/Directory.Build.props | 1 + cs/headers/proton_drive_sdk.h | 5 - cs/headers/proton_sdk.h | 5 + .../InteropActionExtensions.cs | 59 +--- .../InteropFileDownloader.cs | 4 +- .../InteropFileUploader.cs | 4 +- .../InteropMessageHandler.cs | 80 ------ .../InteropProtonDriveClient.cs | 7 +- .../InteropStream.cs | 110 -------- .../Api/Storage/IStorageApiClient.cs | 2 +- .../Api/Storage/StorageApiClient.cs | 3 - .../Nodes/Upload/BlockUploader.cs | 12 +- .../Nodes/Upload/FileUploader.cs | 10 +- .../Nodes/Upload/RevisionWriter.cs | 151 ++++++----- .../Nodes/Upload/RevisionWriterExtensions.cs | 8 +- .../InteropActionExtensions.cs | 55 ++++ .../InteropHttpClientFactory.cs | 42 ++- .../InteropMessageHandler.cs | 87 ++++++ .../src/Proton.Sdk.CExports/InteropStream.cs | 175 ++++++++++++ .../Caching/SqliteCacheRepository.cs | 2 +- cs/sdk/src/protos/proton.drive.sdk.proto | 32 +-- cs/sdk/src/protos/proton.sdk.proto | 31 ++- kt/sdk/src/main/jni/proton_drive_sdk.c | 24 +- kt/sdk/src/main/jni/proton_sdk.c | 21 ++ .../kotlin/me/proton/drive/sdk/HttpSdkApi.kt | 8 + .../me/proton/drive/sdk/ProtonDriveSdk.kt | 10 +- .../drive/sdk/converter/IntConverter.kt | 10 + .../sdk/extension/CancellableContinuation.kt | 7 + .../proton/drive/sdk/extension/HttpStream.kt | 95 +++++++ .../drive/sdk/internal/ApiProviderBridge.kt | 80 ++++-- .../proton/drive/sdk/internal/HttpStream.kt | 40 +++ .../drive/sdk/internal/JniBaseProtonSdk.kt | 9 + .../drive/sdk/internal/JniDriveClient.kt | 14 +- .../drive/sdk/internal/JniHttpStream.kt | 59 ++++ .../internal/ProtonDriveSdkNativeClient.kt | 27 +- .../sdk/internal/ProtonSdkNativeClient.kt | 4 + .../Sources/Plumbing/BoxedContinuation.swift | 62 ++--- .../Sources/Plumbing/FeatureFlags.swift | 25 +- .../Sources/Plumbing/InteropRequest.swift | 4 +- .../Sources/Plumbing/Message+Packaging.swift | 16 +- .../Plumbing/ProgressCallbackWrapper.swift | 14 +- .../Sources/Plumbing/PublicTypes.swift | 7 +- .../Sources/Plumbing/SDKRequestHandler.swift | 97 +++++-- .../Sources/Plumbing/SDKResponseHandler.swift | 27 +- .../ProtonDriveClient/AccountClient.swift | 2 +- .../HttpClientProtocol.swift | 93 +++---- .../HttpClientRequestProcessor.swift | 251 ++++++++++++++++++ .../HttpClientResponseProcessor.swift | 102 +++++++ .../Model/BoxedDownloadStream.swift | 24 ++ .../Networking/Model/BoxedRawBuffer.swift | 60 +++++ .../Networking/Model/BytesOrStream.swift | 24 ++ .../Networking/Model/StreamForUpload.swift | 163 ++++++++++++ .../ProtonDriveClient/ProtonDriveClient.swift | 32 ++- .../ProtonDriveClientConfiguration.swift | 13 + .../Sources/TelemetryAndLogging/Logger.swift | 2 +- .../TelemetryAndLogging/Telemetry.swift | 2 +- 56 files changed, 1732 insertions(+), 581 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/IntConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 35717993..1f605603 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -38,6 +38,7 @@ true true true + true diff --git a/cs/headers/proton_drive_sdk.h b/cs/headers/proton_drive_sdk.h index dae27334..3af0c726 100644 --- a/cs/headers/proton_drive_sdk.h +++ b/cs/headers/proton_drive_sdk.h @@ -12,9 +12,4 @@ void proton_drive_sdk_handle_request( array_action response_action ); -void proton_drive_sdk_handle_response( - intptr_t sdk_handle, - ByteArray response -); - #endif // PROTON_DRIVE_SDK_H diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h index dfe99df8..b2f55e7a 100644 --- a/cs/headers/proton_sdk.h +++ b/cs/headers/proton_sdk.h @@ -22,4 +22,9 @@ void proton_sdk_handle_request( array_action response_action ); +void proton_sdk_handle_response( + intptr_t sdk_handle, + ByteArray response +); + #endif // PROTON_SDK_H diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs index 65a42c08..aeb6e5f1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs @@ -1,70 +1,15 @@ using Google.Protobuf; using Proton.Sdk.CExports; -using Proton.Sdk.CExports.Tasks; namespace Proton.Drive.Sdk.CExports; internal static class InteropActionExtensions { - public static unsafe ValueTask SendRequestAsync( - this InteropAction, nint> interopAction, - nint bindingsHandle, - IMessage request) - where TResponse : IMessage - { - var tcs = new ValueTaskCompletionSource(); - - var tcsHandle = Interop.AllocHandle(tcs); - - var requestBytes = request.ToByteArray(); - - fixed (byte* requestBytesPointer = requestBytes) - { - interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, requestBytes.Length), (nint)tcsHandle); - } - - return tcs.Task; - } - - public static unsafe ValueTask InvokeWithBufferAsync( - this InteropAction, nint> interopAction, - nint bindingsHandle, - Span buffer) - { - var tcs = new ValueTaskCompletionSource(); - - var tcsHandle = Interop.AllocHandle(tcs); - - fixed (byte* requestBytesPointer = buffer) - { - interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); - } - - return tcs.Task; - } - - public static unsafe ValueTask InvokeWithBufferAsync( - this InteropAction, nint> interopAction, - nint bindingsHandle, - ReadOnlySpan buffer) - { - var tcs = new ValueTaskCompletionSource(); - - var tcsHandle = Interop.AllocHandle(tcs); - - fixed (byte* requestBytesPointer = buffer) - { - interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); - } - - return tcs.Task; - } - - public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint bindingsHandle, long total, long completed) + public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint bindingsHandle, long progress, long total) { var progressUpdate = new ProgressUpdate { - BytesCompleted = completed, + BytesCompleted = progress, BytesInTotal = total, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index 91d91b18..26aaadae 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -19,7 +19,7 @@ public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, n var downloadController = downloader.DownloadToStream( stream, - (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), + (bytesCompleted, bytesInTotal) => progressAction.InvokeProgressUpdate(bindingsHandle, bytesCompleted, bytesInTotal), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(downloadController) }; @@ -35,7 +35,7 @@ public static IMessage HandleDownloadToFile(DownloadToFileRequest request, nint var downloadController = downloader.DownloadToFile( request.FilePath, - (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), + (bytesCompleted, bytesInTotal) => progressAction.InvokeProgressUpdate(bindingsHandle, bytesCompleted, bytesInTotal), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(downloadController) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 47a04b4f..a17496e6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -29,7 +29,7 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var uploadController = uploader.UploadFromStream( stream, thumbnails, - (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), + (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; @@ -55,7 +55,7 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint var uploadController = uploader.UploadFromFile( request.FilePath, thumbnails, - (completed, total) => progressAction.InvokeProgressUpdate(bindingsHandle, total, completed), + (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index d4c739eb..78890adf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -1,21 +1,12 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using Google.Protobuf.Reflection; using Google.Protobuf.WellKnownTypes; using Proton.Sdk.CExports; -using Proton.Sdk.CExports.Tasks; namespace Proton.Drive.Sdk.CExports; internal static class InteropMessageHandler { - private static readonly TypeRegistry ResponseTypeRegistry = TypeRegistry.FromMessages( - Int32Value.Descriptor, - StringValue.Descriptor, - BytesValue.Descriptor, - RepeatedBytesValue.Descriptor, - Address.Descriptor); - [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])] public static async void OnRequestReceived(InteropArray requestBytes, nint bindingsHandle, InteropAction> responseAction) { @@ -104,75 +95,4 @@ Request.PayloadOneofCase.None or _ responseAction.InvokeWithMessage(bindingsHandle, new Response { Error = error }); } } - - [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_response", CallConvs = [typeof(CallConvCdecl)])] - public static void OnResponseReceived(nint sdkHandle, InteropArray responseBytes) - { - var response = Response.Parser.ParseFrom(responseBytes.AsReadOnlySpan()); - - if (response.Error is not null) - { - SetException(sdkHandle, response.Error.Message); - return; - } - - if (response.Value is null) - { - SetResult(sdkHandle); - return; - } - - var responseValue = response.Value.Unpack(ResponseTypeRegistry); - - switch (responseValue) - { - case Int32Value value: - SetResult(sdkHandle, value); - break; - - case StringValue value: - SetResult(sdkHandle, value); - break; - - case BytesValue value: - SetResult(sdkHandle, value); - break; - - case RepeatedBytesValue value: - SetResult(sdkHandle, value); - break; - - case Address value: - SetResult(sdkHandle, value); - break; - - case HttpResponse value: - SetResult(sdkHandle, value); - break; - - default: - throw new ArgumentException($"Unknown response value type: {responseValue.Descriptor.Name}", nameof(responseBytes)); - } - } - - private static void SetResult(nint tcsHandle, T value) - { - var tcs = Interop.GetFromHandleAndFree>(tcsHandle); - - tcs.SetResult(value); - } - - private static void SetResult(nint tcsHandle) - { - var tcs = Interop.GetFromHandleAndFree(tcsHandle); - - tcs.SetResult(); - } - - private static void SetException(nint tcsHandle, string errorMessage) - { - var tfs = Interop.GetFromHandleAndFree(tcsHandle); - - tfs.SetException(new Exception(errorMessage)); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 54065b5c..f86c8644 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -22,9 +22,10 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi bindingsHandle, request.BaseUrl, request.BindingsLanguage, - new InteropAction, nint>(request.HttpClientRequestAction)); + new InteropAction, nint>(request.HttpClientRequestAction), + new InteropAction, nint>(request.HttpResponseReadAction)); - var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountClientRequestAction)); + var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction)); var entityCacheRepository = request.HasEntityCachePath ? SqliteCacheRepository.OpenFile(request.EntityCachePath) @@ -65,7 +66,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe var client = Interop.GetFromHandle(request.ClientHandle); - var additionalMetadata = request.AdditionalMetadata != null && request.AdditionalMetadata.Count > 0 + var additionalMetadata = request.AdditionalMetadata is { Count: > 0 } ? request.AdditionalMetadata.Select(x => new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs deleted file mode 100644 index 1a47d488..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropStream.cs +++ /dev/null @@ -1,110 +0,0 @@ -using Google.Protobuf.WellKnownTypes; -using Proton.Sdk.CExports; - -namespace Proton.Drive.Sdk.CExports; - -internal sealed class InteropStream : Stream -{ - private readonly nint _bindingsHandle; - private readonly InteropAction, nint>? _readAction; - private readonly InteropAction, nint>? _writeAction; - - private long _position; - private long? _length; - - public InteropStream(long length, nint bindingsHandle, InteropAction, nint>? readAction) - { - _length = length; - _bindingsHandle = bindingsHandle; - _readAction = readAction; - _writeAction = null; - } - - public InteropStream(nint bindingsHandle, InteropAction, nint>? writeAction) - { - _bindingsHandle = bindingsHandle; - _readAction = null; - _writeAction = writeAction; - } - - public override bool CanRead => _readAction != null; - public override bool CanSeek => false; - public override bool CanWrite => _writeAction != null; - public override long Length => _length ?? 0; - - public override long Position - { - get => _position; - set => throw new NotSupportedException("Seeking not supported"); - } - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) - { - return ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); - } - - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - { - if (_readAction is null) - { - throw new NotSupportedException("Reading not supported"); - } - - using var memoryHandle = buffer.Pin(); - - var response = await _readAction.Value.InvokeWithBufferAsync(_bindingsHandle, buffer.Span).ConfigureAwait(false); - - if (response.Value < 0) - { - throw new IOException($"Invalid number of bytes read: {response.Value}"); - } - - _position += response.Value; - - return response.Value; - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException("Seeking not supported"); - } - - public override void SetLength(long value) - { - throw new NotSupportedException("Setting length not supported"); - } - - public override void Write(byte[] buffer, int offset, int count) - { - WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); - } - - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - if (_writeAction == null) - { - throw new NotSupportedException("Writing not supported"); - } - - using var memoryHandle = buffer.Pin(); - - await _writeAction.Value.InvokeWithBufferAsync(_bindingsHandle, buffer.Span).ConfigureAwait(false); - - _position += buffer.Length; - _length = Math.Max(_length ?? 0, _position); - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs index 23da2fc8..0c4d3a28 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs @@ -4,6 +4,6 @@ namespace Proton.Drive.Sdk.Api.Storage; internal interface IStorageApiClient { - ValueTask UploadBlobAsync(string baseUrl, string token, Stream stream, Action? onProgress, CancellationToken cancellationToken); + ValueTask UploadBlobAsync(string baseUrl, string token, Stream stream, CancellationToken cancellationToken); ValueTask GetBlobStreamAsync(string baseUrl, string token, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index eda15e57..f56b3f92 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -14,7 +14,6 @@ public async ValueTask UploadBlobAsync( string baseUrl, string token, Stream stream, - Action? onProgress, CancellationToken cancellationToken) { using var blobContent = new StreamContent(stream); @@ -34,8 +33,6 @@ public async ValueTask UploadBlobAsync( .Expecting(ProtonApiSerializerContext.Default.ApiResponse) .SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); - onProgress?.Invoke(stream.Position); - return response; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 2b1ca8a6..7cd55ca0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -51,6 +51,8 @@ public async Task UploadContentAsync( { try { + var plainDataLength = plainDataStream.Length; + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); await using (dataPacketStream.ConfigureAwait(false)) { @@ -123,7 +125,9 @@ public async Task UploadContentAsync( Thumbnails = [], }; - await UploadBlobAsync(request, dataPacketStream, onBlockProgress, cancellationToken).ConfigureAwait(false); + await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); + + onBlockProgress?.Invoke(plainDataLength); _client.Logger.LogDebug( "Uploaded blob for block #{BlockIndex} for revision {RevisionId} of file {FileUid}", @@ -160,7 +164,6 @@ public async Task UploadThumbnailAsync( PgpPrivateKey signingKey, AddressId membershipAddressId, Thumbnail thumbnail, - Action? onProgress, CancellationToken cancellationToken) { try @@ -203,7 +206,7 @@ public async Task UploadThumbnailAsync( ], }; - await UploadBlobAsync(request, dataPacketStream, onProgress, cancellationToken).ConfigureAwait(false); + await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); _client.Logger.LogDebug("Uploaded thumbnail blob for revision {RevisionId} of node {FileUid}", revisionId, fileUid); @@ -226,7 +229,6 @@ public async Task UploadThumbnailAsync( private async ValueTask UploadBlobAsync( BlockUploadPreparationRequest request, RecyclableMemoryStream dataPacketStream, - Action? onProgress, CancellationToken cancellationToken) { #pragma warning disable S3236 // FP: https://community.sonarsource.com/t/false-positive-on-s3236-when-calling-debug-assert-with-message/138761/6 @@ -246,7 +248,7 @@ private async ValueTask UploadBlobAsync( dataPacketStream.Seek(0, SeekOrigin.Begin); - await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Token, dataPacketStream, onProgress, cancellationToken) + await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Token, dataPacketStream, cancellationToken) .ConfigureAwait(false); remainingNumberOfAttempts = 0; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index a483718e..8aeed8e2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -35,7 +35,7 @@ public UploadController UploadFromStream( Action? onProgress, CancellationToken cancellationToken) { - var task = UploadFromStreamAsync(contentStream, thumbnails, _additionalMetadata, onProgress, cancellationToken); + var task = UploadFromStreamAsync(contentStream, thumbnails, _additionalMetadata, progress => onProgress?.Invoke(progress, FileSize), cancellationToken); return new UploadController(task); } @@ -46,7 +46,7 @@ public UploadController UploadFromFile( Action? onProgress, CancellationToken cancellationToken) { - var task = UploadFromFileAsync(filePath, thumbnails, _additionalMetadata, onProgress, cancellationToken); + var task = UploadFromFileAsync(filePath, thumbnails, _additionalMetadata, progress => onProgress?.Invoke(progress, FileSize), cancellationToken); return new UploadController(task); } @@ -86,7 +86,7 @@ internal static async ValueTask CreateAsync( Stream contentStream, IEnumerable thumbnails, IEnumerable? additionalExtendedAttributes, - Action? onProgress, + Action? onProgress, CancellationToken cancellationToken) { var (draftRevisionUid, fileSecrets) = await _fileDraftProvider.GetDraftAsync(_client, cancellationToken).ConfigureAwait(false); @@ -136,7 +136,7 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid string filePath, IEnumerable thumbnails, IEnumerable? additionalMetadata, - Action? onProgress, + Action? onProgress, CancellationToken cancellationToken) { var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); @@ -154,7 +154,7 @@ private async ValueTask UploadAsync( IEnumerable thumbnails, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, - Action? onProgress, + Action? onProgress, CancellationToken cancellationToken) { using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionUid, fileSecrets, ReleaseBlocks, cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index f0a9797f..d62c9168 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -60,7 +60,7 @@ public async ValueTask WriteAsync( IEnumerable thumbnails, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, - Action? onProgress, + Action? onProgress, CancellationToken cancellationToken) { var uploadEvent = new UploadEvent @@ -83,8 +83,6 @@ public async ValueTask WriteAsync( ArraySegment manifestSignature; var blockSizes = new List(8); - var contentLength = contentStream.Length - contentStream.Position; - using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); var hashingContentStream = new HashingReadStream(contentStream, sha1, leaveOpen: true); @@ -117,70 +115,61 @@ public async ValueTask WriteAsync( _signingKey, _membershipAddress.Id, thumbnail, - onProgress: null, cancellationTokenSource.Token); uploadTasks.Enqueue(uploadTask); } - if (contentLength > 0) + while ( + await TryGetBlockPlainDataStreamAsync( + hashingContentStream, + blockVerifier.DataPacketPrefixMaxLength, + linkedCancellationToken).ConfigureAwait(false) is var (plainDataStream, plainDataPrefixBuffer)) { - do + try + { + blockSizes.Add((int)plainDataStream.Length); + + await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); + + plainDataStream.Seek(0, SeekOrigin.Begin); + + var onBlockProgress = onProgress is not null + ? progress => + { + numberOfBytesUploaded += progress; + + // TODO: move this to a decorator, wrap the progress action + uploadEvent.UploadedSize = numberOfBytesUploaded; + uploadEvent.ApproximateUploadedSize = ReduceSizePrecision(numberOfBytesUploaded); + + onProgress(numberOfBytesUploaded); + } + : default(Action?); + + var uploadTask = _client.BlockUploader.UploadContentAsync( + _fileUid, + _revisionId, + ++blockIndex, + _contentKey, + _signingKey, + _membershipAddress.Id, + _fileKey, + plainDataStream, + blockVerifier, + plainDataPrefixBuffer, + (int)Math.Min(blockVerifier.DataPacketPrefixMaxLength, plainDataStream.Length), + onBlockProgress, + _releaseBlocksAction, + linkedCancellationToken); + + uploadTasks.Enqueue(uploadTask); + } + catch { - var plainDataPrefixBuffer = ArrayPool.Shared.Rent(blockVerifier.DataPacketPrefixMaxLength); - try - { - var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - - var bytesCopied = await hashingContentStream.PartiallyCopyToAsync( - plainDataStream, - _targetBlockSize, - plainDataPrefixBuffer, - linkedCancellationToken).ConfigureAwait(false); - - blockSizes.Add((int)plainDataStream.Length); - - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); - - plainDataStream.Seek(0, SeekOrigin.Begin); - - var onBlockProgress = onProgress is not null - ? progress => - { - numberOfBytesUploaded += progress; - - // TODO: move this to a decorator, wrap the progress action - uploadEvent.UploadedSize = numberOfBytesUploaded; - uploadEvent.ApproximateUploadedSize = ReduceSizePrecision(numberOfBytesUploaded); - - onProgress(numberOfBytesUploaded, contentLength); - } - : default(Action?); - - var uploadTask = _client.BlockUploader.UploadContentAsync( - _fileUid, - _revisionId, - ++blockIndex, - _contentKey, - _signingKey, - _membershipAddress.Id, - _fileKey, - plainDataStream, - blockVerifier, - plainDataPrefixBuffer, - Math.Min(blockVerifier.DataPacketPrefixMaxLength, bytesCopied), - onBlockProgress, - _releaseBlocksAction, - linkedCancellationToken); - - uploadTasks.Enqueue(uploadTask); - } - catch - { - ArrayPool.Shared.Return(plainDataPrefixBuffer); - throw; - } - } while (contentStream.Position < contentStream.Length); + ArrayPool.Shared.Return(plainDataPrefixBuffer); + throw; + } } } finally @@ -217,7 +206,6 @@ public async ValueTask WriteAsync( } var request = GetRevisionUpdateRequest( - contentLength, lastModificationTime, blockSizes, sha1.GetCurrentHash(), @@ -283,6 +271,44 @@ private static async ValueTask AddNextBlockToManifestAsync(Queue> u await manifestStream.WriteAsync(sha256Digest).ConfigureAwait(false); } + private async ValueTask<(Stream Stream, byte[] Prefix)?> TryGetBlockPlainDataStreamAsync( + Stream contentStream, + int prefixLength, + CancellationToken cancellationToken) + { + var plainDataPrefixBuffer = ArrayPool.Shared.Rent(prefixLength); + try + { + var plainDataStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + try + { + var bytesCopied = await contentStream.PartiallyCopyToAsync( + plainDataStream, + _targetBlockSize, + plainDataPrefixBuffer, + cancellationToken).ConfigureAwait(false); + + if (bytesCopied == 0) + { + return null; + } + + return (plainDataStream, plainDataPrefixBuffer); + } + catch + { + await plainDataStream.DisposeAsync().ConfigureAwait(false); + throw; + } + } + catch + { + ArrayPool.Shared.Return(plainDataPrefixBuffer); + throw; + } + } + private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTasks, RecyclableMemoryStream manifestStream, CancellationToken cancellationToken) { if (!await _client.BlockUploader.BlockSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) @@ -297,7 +323,6 @@ private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTask } private RevisionUpdateRequest GetRevisionUpdateRequest( - long contentLength, DateTimeOffset? lastModificationTime, IReadOnlyList blockSizes, byte[]? sha1Digest, @@ -309,7 +334,7 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( { Common = new CommonExtendedAttributes { - Size = contentLength, + Size = blockSizes.Sum(x => (long)x), ModificationTime = lastModificationTime?.UtcDateTime, BlockSizes = blockSizes, Digests = new FileContentDigestsDto { Sha1 = sha1Digest }, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs index 5fea04e9..b411eff6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs @@ -7,7 +7,7 @@ public static ValueTask WriteAsync( Stream contentStream, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, - Action onProgress, + Action onProgress, CancellationToken cancellationToken) { return revisionWriter.WriteAsync(contentStream, [], lastModificationTime, additionalMetadata, onProgress, cancellationToken); @@ -18,7 +18,7 @@ public static ValueTask WriteAsync( Stream contentStream, DateTime lastModificationTime, IEnumerable? additionalMetadata, - Action onProgress, + Action onProgress, CancellationToken cancellationToken) { return revisionWriter.WriteAsync(contentStream, [], new DateTimeOffset(lastModificationTime), additionalMetadata, onProgress, cancellationToken); @@ -30,7 +30,7 @@ public static ValueTask WriteAsync( IEnumerable thumbnails, DateTime lastModificationTime, IEnumerable? additionalMetadata, - Action onProgress, + Action onProgress, CancellationToken cancellationToken) { return revisionWriter.WriteAsync(contentStream, thumbnails, new DateTimeOffset(lastModificationTime), additionalMetadata, onProgress, cancellationToken); @@ -41,7 +41,7 @@ public static async ValueTask WriteAsync( string targetFilePath, DateTime lastModificationTime, IEnumerable? additionalMetadata, - Action onProgress, + Action onProgress, CancellationToken cancellationToken) { var fileStream = File.Open(targetFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs index 00694bd5..b05a3a37 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs @@ -1,4 +1,5 @@ using Google.Protobuf; +using Proton.Sdk.CExports.Tasks; namespace Proton.Sdk.CExports; @@ -25,4 +26,58 @@ public static unsafe void InvokeWithMessage(this InteropAction(responsePointer, responseBytes.Length), sdkHandle); } } + + public static unsafe ValueTask SendRequestAsync( + this InteropAction, nint> interopAction, + nint bindingsHandle, + IMessage request) + where TResponse : IMessage + { + var tcs = new ValueTaskCompletionSource(); + + var tcsHandle = Interop.AllocHandle(tcs); + + var requestBytes = request.ToByteArray(); + + fixed (byte* requestBytesPointer = requestBytes) + { + interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, requestBytes.Length), (nint)tcsHandle); + } + + return tcs.Task; + } + + public static unsafe ValueTask InvokeWithBufferAsync( + this InteropAction, nint> interopAction, + nint bindingsHandle, + Span buffer) + { + var tcs = new ValueTaskCompletionSource(); + + var tcsHandle = Interop.AllocHandle(tcs); + + fixed (byte* requestBytesPointer = buffer) + { + interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); + } + + return tcs.Task; + } + + public static unsafe ValueTask InvokeWithBufferAsync( + this InteropAction, nint> interopAction, + nint bindingsHandle, + ReadOnlySpan buffer) + { + var tcs = new ValueTaskCompletionSource(); + + var tcsHandle = Interop.AllocHandle(tcs); + + fixed (byte* requestBytesPointer = buffer) + { + interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); + } + + return tcs.Task; + } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index 9b364822..a2bbfc7e 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -1,5 +1,4 @@ using System.Net; -using Google.Protobuf; using Proton.Sdk.CExports.Tasks; using Proton.Sdk.Http; @@ -13,15 +12,18 @@ public InteropHttpClientFactory( nint bindingsHandle, string baseUrl, string? bindingsLanguage, - InteropAction, nint> sendHttpRequestAction) + InteropAction, nint> httpRequestAction, + InteropAction, nint> httpResponseReadAction) { _baseUrl = baseUrl; BindingsHandle = bindingsHandle; - SendHttpRequestAction = sendHttpRequestAction; + HttpRequestAction = httpRequestAction; + HttpResponseReadAction = httpResponseReadAction; } private nint BindingsHandle { get; } - private InteropAction, nint> SendHttpRequestAction { get; } + private InteropAction, nint> HttpRequestAction { get; } + private InteropAction, IntPtr> HttpResponseReadAction { get; } public HttpClient CreateClient(string name) { @@ -44,11 +46,21 @@ protected override async Task SendAsync(HttpRequestMessage var interopHttpRequest = await ConvertHttpRequestToInteropAsync(request, cancellationToken).ConfigureAwait(false); - _owner.SendHttpRequestAction.InvokeWithMessage(_owner.BindingsHandle, interopHttpRequest, (nint)taskCompletionSourceHandle); + try + { + _owner.HttpRequestAction.InvokeWithMessage(_owner.BindingsHandle, interopHttpRequest, (nint)taskCompletionSourceHandle); - var interopHttpResponse = await taskCompletionSource.Task.ConfigureAwait(false); + var interopHttpResponse = await taskCompletionSource.Task.ConfigureAwait(false); - return ConvertHttpResponseFromInterop(interopHttpResponse); + return ConvertHttpResponseFromInterop(interopHttpResponse); + } + finally + { + if (interopHttpRequest.HasSdkContentHandle) + { + Interop.FreeHandle(interopHttpRequest.SdkContentHandle); + } + } } private static async ValueTask ConvertHttpRequestToInteropAsync(HttpRequestMessage request, CancellationToken cancellationToken) @@ -63,9 +75,9 @@ private static async ValueTask ConvertHttpRequestToInteropAsync(Htt { headers = headers.Concat(request.Content.Headers); - interopHttpRequest.Content = await ByteString.FromStreamAsync( - await request.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), - cancellationToken).ConfigureAwait(false); + var contentStream = await request.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + interopHttpRequest.SdkContentHandle = Interop.AllocHandle(contentStream); } interopHttpRequest.Headers.AddRange( @@ -79,12 +91,14 @@ await request.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false) return interopHttpRequest; } - private static HttpResponseMessage ConvertHttpResponseFromInterop(HttpResponse interopHttpResponse) + private HttpResponseMessage ConvertHttpResponseFromInterop(HttpResponse interopHttpResponse) { - var response = new HttpResponseMessage((HttpStatusCode)interopHttpResponse.StatusCode) + var response = new HttpResponseMessage((HttpStatusCode)interopHttpResponse.StatusCode); + + if (interopHttpResponse.HasBindingsContentHandle) { - Content = new ReadOnlyMemoryContent(interopHttpResponse.Content.Memory), - }; + response.Content = new StreamContent(new InteropStream(null, (nint)interopHttpResponse.BindingsContentHandle, _owner.HttpResponseReadAction)); + } foreach (var interopHttpResponseHeader in interopHttpResponse.Headers) { diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs index 69417620..7e9fb3bf 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -1,12 +1,21 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using Google.Protobuf.Reflection; using Google.Protobuf.WellKnownTypes; using Proton.Sdk.CExports.Logging; +using Proton.Sdk.CExports.Tasks; namespace Proton.Sdk.CExports; internal static class InteropMessageHandler { + private static readonly TypeRegistry ResponseTypeRegistry = TypeRegistry.FromMessages( + Int32Value.Descriptor, + StringValue.Descriptor, + BytesValue.Descriptor, + RepeatedBytesValue.Descriptor, + Address.Descriptor); + [UnmanagedCallersOnly(EntryPoint = "proton_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])] public static async void OnRequestReceived(InteropArray requestBytes, nint bindingsHandle, InteropAction> responseAction) { @@ -25,6 +34,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.CancellationTokenSourceFree => InteropCancellationTokenSource.HandleFree(request.CancellationTokenSourceFree), + Request.PayloadOneofCase.StreamRead + => await InteropStream.HandleReadAsync(request.StreamRead).ConfigureAwait(false), + Request.PayloadOneofCase.SessionBegin => await ProtonApiSessionRequestHandler.HandleBeginAsync(request.SessionBegin, bindingsHandle).ConfigureAwait(false), @@ -62,4 +74,79 @@ Request.PayloadOneofCase.None or _ responseAction.InvokeWithMessage(bindingsHandle, new Response { Error = error }); } } + + [UnmanagedCallersOnly(EntryPoint = "proton_sdk_handle_response", CallConvs = [typeof(CallConvCdecl)])] + public static void OnResponseReceived(nint sdkHandle, InteropArray responseBytes) + { + var response = Response.Parser.ParseFrom(responseBytes.AsReadOnlySpan()); + + if (response.Error is not null) + { + SetException(sdkHandle, response.Error.Message); + return; + } + + if (response.Value is null) + { + SetResult(sdkHandle); + return; + } + + var responseValue = response.Value.Unpack(ResponseTypeRegistry); + + switch (responseValue) + { + case Int32Value value: + SetResult(sdkHandle, value); + break; + + case Int64Value value: + SetResult(sdkHandle, value); + break; + + case StringValue value: + SetResult(sdkHandle, value); + break; + + case BytesValue value: + SetResult(sdkHandle, value); + break; + + case RepeatedBytesValue value: + SetResult(sdkHandle, value); + break; + + case Address value: + SetResult(sdkHandle, value); + break; + + case HttpResponse value: + SetResult(sdkHandle, value); + break; + + default: + throw new ArgumentException($"Unknown response value type: {responseValue.Descriptor.Name}", nameof(responseBytes)); + } + } + + private static void SetResult(nint tcsHandle, T value) + { + var tcs = Interop.GetFromHandleAndFree>(tcsHandle); + + tcs.SetResult(value); + } + + private static void SetResult(nint tcsHandle) + { + var tcs = Interop.GetFromHandleAndFree(tcsHandle); + + tcs.SetResult(); + } + + private static void SetException(nint tcsHandle, string errorMessage) + { + var tfs = Interop.GetFromHandleAndFree(tcsHandle); + + tfs.SetException(new Exception(errorMessage)); + } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs new file mode 100644 index 00000000..46ab95b5 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -0,0 +1,175 @@ +using System.Buffers; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Proton.Sdk.CExports; + +internal sealed class InteropStream : Stream +{ + private readonly nint _bindingsHandle; + private readonly InteropAction, nint>? _readAction; + private readonly InteropAction, nint>? _writeAction; + + private long _position; + private long? _length; + + public InteropStream(long? length, nint bindingsHandle, InteropAction, nint>? readAction) + { + _length = length; + _bindingsHandle = bindingsHandle; + _readAction = readAction; + _writeAction = null; + } + + public InteropStream(nint bindingsHandle, InteropAction, nint>? writeAction) + { + _bindingsHandle = bindingsHandle; + _readAction = null; + _writeAction = writeAction; + } + + public override bool CanRead => _readAction != null; + + public override bool CanSeek => _length is not null; + public override bool CanWrite => _writeAction != null; + public override long Length => _length ?? throw new NotSupportedException("Getting length is not supported"); + + public override long Position + { + get => CanSeek ? _position : throw new NotSupportedException("Getting position is not supported"); + set => throw new NotSupportedException("Setting position is not supported"); + } + + public static async ValueTask HandleReadAsync(StreamReadRequest requestStreamRead) + { + var stream = Interop.GetFromHandle(requestStreamRead.StreamHandle); + + using var bufferMemoryManager = new UnmanagedMemoryManager((nint)requestStreamRead.BufferPointer, requestStreamRead.BufferLength); + + var bytesRead = await stream.ReadAsync(bufferMemoryManager.Memory, CancellationToken.None).ConfigureAwait(false); + + return new Int32Value { Value = bytesRead }; + } + + public override void Flush() + { + } + + //add more overrides + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + Console.WriteLine("IteropStream.CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)"); + return base.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + { + Console.WriteLine("IteropStream.BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state)"); + return base.BeginRead(buffer, offset, count, callback, state); + } + + public override int Read(Span buffer) + { + Console.WriteLine("IteropStream.Read(Span buffer)"); + return base.Read(buffer); + } + + public override int ReadByte() + { + Console.WriteLine("IteropStream.ReadByte()"); + return base.ReadByte(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + Console.WriteLine("IteropStream.Read(byte[] buffer, int offset, int count)"); + return ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + Console.WriteLine("IteropStream.ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)"); + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + Console.WriteLine("IteropStream.ReadAsync(Memory buffer, CancellationToken cancellationToken = default)"); + if (_readAction is null) + { + throw new NotSupportedException("Reading not supported"); + } + + using var memoryHandle = buffer.Pin(); + + var response = await _readAction.Value.InvokeWithBufferAsync(_bindingsHandle, buffer.Span).ConfigureAwait(false); + + if (response.Value < 0) + { + throw new IOException($"Invalid number of bytes read: {response.Value}"); + } + + _position += response.Value; + + return response.Value; + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException("Seeking not supported"); + } + + public override void SetLength(long value) + { + throw new NotSupportedException("Setting length not supported"); + } + + public override void Write(byte[] buffer, int offset, int count) + { + WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (_writeAction == null) + { + throw new NotSupportedException("Writing not supported"); + } + + using var memoryHandle = buffer.Pin(); + + await _writeAction.Value.InvokeWithBufferAsync(_bindingsHandle, buffer.Span).ConfigureAwait(false); + + _position += buffer.Length; + _length = Math.Max(_length ?? 0, _position); + } + + private sealed unsafe class UnmanagedMemoryManager(nint pointer, int length) : MemoryManager + where T : unmanaged + { + private readonly T* _pointer = (T*)pointer; + private readonly int _length = length; + + public override Span GetSpan() => new(_pointer, _length); + + public override MemoryHandle Pin(int elementIndex = 0) + { + if (elementIndex < 0 || elementIndex >= _length) + { + throw new ArgumentOutOfRangeException(nameof(elementIndex)); + } + + return new MemoryHandle(_pointer + elementIndex); + } + + public override void Unpin() { } + + protected override void Dispose(bool disposing) { } + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index e7a3f892..c6d4242a 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -16,7 +16,7 @@ public static SqliteCacheRepository OpenInMemory() { var connectionStringBuilder = new SqliteConnectionStringBuilder { - DataSource = ":memory:", + DataSource = Guid.NewGuid().ToString(), Mode = SqliteOpenMode.Memory, Cache = SqliteCacheMode.Shared, }; diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index b3165f97..ac134353 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -128,34 +128,36 @@ message DriveClientCreateRequest { string bindings_language = 2; // Optional // Pointer to C function that will be called: - // void http_client_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) + // void handle_http_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) // bindings_handle: handle for the bindings // http_request: Protobuf message of type proton.sdk.HttpRequest carrying the HTTP request data // sdk_handle: handle for the SDK int64 http_client_request_action = 3; + + int64 http_response_read_action = 4; // C signature: void handle_http_response_read(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); // Pointer to C function that will be called: - // void account_client_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) + // void handle_account_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) // bindings_handle: handle for the bindings // account_request: Protobuf message of type proton.drive.sdk.AccountRequest carrying the request data // sdk_handle: handle for the SDK - int64 account_client_request_action = 4; + int64 account_request_action = 5; - string entity_cache_path = 5; // Optional - string secret_cache_path = 6; // Optional + string entity_cache_path = 6; // Optional + string secret_cache_path = 7; // Optional - proton.sdk.Telemetry telemetry = 7; // Optional + proton.sdk.Telemetry telemetry = 8; // Optional // Client UID, optional // If a null value is provided, the SDK automatically generates a UUID during initialization - string uid = 8; + string uid = 9; // Pointer to C function that will be called to check feature flags: // int is_feature_flag_enabled(intptr_t bindings_handle, ByteArray flag_name) // bindings_handle: handle for the bindings // flag_name: UTF-8 encoded feature flag name // returns: 0 for disabled, non-zero for enabled - int64 feature_enabled_function = 9; // Optional + int64 feature_enabled_function = 10; // Optional } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. @@ -202,7 +204,7 @@ message DriveClientGetFileRevisionUploaderRequest { message UploadFromStreamRequest { int64 uploader_handle = 1; repeated Thumbnail thumbnails = 2; - int64 read_action = 3; // C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); + int64 read_action = 3; // C signature: void handle_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; } @@ -307,18 +309,6 @@ message DownloadControllerFreeRequest { int64 download_controller_handle = 1; } -// The response value must be an Int32Value carrying the number of bytes read into the buffer. That number must not be negative. -message StreamReadRequest { - int64 buffer_pointer = 1; - int32 buffer_length = 2; -} - -// The reponse must not have a value. -message StreamWriteRequest { - int64 data_pointer = 1; - int32 data_length = 2; -} - enum VolumeType { VOLUME_TYPE_OWN_VOLUME = 0; VOLUME_TYPE_SHARED = 1; diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto index 8db4a4e3..13f1a246 100644 --- a/cs/sdk/src/protos/proton.sdk.proto +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -12,15 +12,17 @@ message Request { CancellationTokenSourceCancelRequest cancellation_token_source_cancel = 101; CancellationTokenSourceFreeRequest cancellation_token_source_free = 102; - SessionBeginRequest session_begin = 200; - SessionResumeRequest session_resume = 201; - SessionRenewRequest session_renew = 202; - SessionEndRequest session_end = 203; - SessionTokensRefreshedSubscribeRequest session_tokens_refreshed_subscribe = 204; - SessionTokensRefreshedUnsubscribeRequest session_tokens_refreshed_unsubscribe = 205; - SessionFreeRequest session_free = 206; - - LoggerProviderCreate logger_provider_create = 300; + StreamReadRequest stream_read = 200; + + SessionBeginRequest session_begin = 300; + SessionResumeRequest session_resume = 301; + SessionRenewRequest session_renew = 302; + SessionEndRequest session_end = 303; + SessionTokensRefreshedSubscribeRequest session_tokens_refreshed_subscribe = 304; + SessionTokensRefreshedUnsubscribeRequest session_tokens_refreshed_unsubscribe = 305; + SessionFreeRequest session_free = 306; + + LoggerProviderCreate logger_provider_create = 400; }; } @@ -214,6 +216,13 @@ message AddressKey { bool is_allowed_for_verification = 5; } +// The response value must be an Int32Value carrying the number of bytes read into the buffer. That number must not be negative. +message StreamReadRequest { + int64 stream_handle = 1; + int64 buffer_pointer = 2; + int32 buffer_length = 3; +} + message HttpHeader { string name = 1; repeated string values = 2; @@ -224,13 +233,13 @@ message HttpRequest { string url = 1; string method = 2; repeated HttpHeader headers = 3; - bytes content = 4; // Optional + int64 sdk_content_handle = 4; // Optional, to be used with StreamReadRequest } message HttpResponse { int32 status_code = 1; repeated HttpHeader headers = 2; - bytes content = 3; // Optional + int64 bindings_content_handle = 3; // Optional } message ApiRetrySucceededEventPayload { diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 91e7b88f..4f8c59af 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -40,7 +40,7 @@ void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleResponse byteArray.pointer = (const uint8_t *) bufferElems; byteArray.length = (*env)->GetArrayLength(env, response); - proton_drive_sdk_handle_response( + proton_sdk_handle_response( (intptr_t) sdk_handle, byteArray ); @@ -111,6 +111,14 @@ void onSendHttpRequest( pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onSendHttpRequest"); } +void onHttpResponseRead( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onHttpResponseRead"); +} + void onAccountRequest( intptr_t bindings_handle, ByteArray value, @@ -148,13 +156,20 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getProgressPo return (jlong) (intptr_t) &onProgress; } -jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSendHttpRequestPointer( +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpClientRequestPointer( JNIEnv *env, jobject obj ) { return (jlong) (intptr_t) &onSendHttpRequest; } +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpResponseReadPointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onHttpResponseRead; +} + jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getAccountRequestPointer( JNIEnv *env, jobject obj @@ -168,3 +183,8 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getRecordMetr ) { return (jlong) (intptr_t) &onRecordMetric; } + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createWeakRef(JNIEnv* env, jobject obj) { + jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); + return (jlong)(intptr_t) weakRef; +} diff --git a/kt/sdk/src/main/jni/proton_sdk.c b/kt/sdk/src/main/jni/proton_sdk.c index 58278838..15540d43 100644 --- a/kt/sdk/src/main/jni/proton_sdk.c +++ b/kt/sdk/src/main/jni/proton_sdk.c @@ -38,3 +38,24 @@ jlong Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_getCallbackPointer ) { return (jlong) (intptr_t) &onCallback; } + +jlong Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_getBufferPointer( + JNIEnv *env, + jobject obj, + jobject buffer +) { + void *ptr = (*env)->GetDirectBufferAddress(env, buffer); + if (ptr == NULL) { + return 0; + } + return (jlong) (intptr_t) ptr; +} + + +jlong Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_getBufferSize( + JNIEnv *env, + jobject obj, + jobject buffer +) { + return (*env)->GetDirectBufferCapacity(env, buffer); +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt index 657701ee..797e5bac 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt @@ -7,6 +7,7 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.HTTP import retrofit2.http.HeaderMap +import retrofit2.http.Streaming import retrofit2.http.Url interface HttpSdkApi : BaseRetrofitApi { @@ -16,6 +17,13 @@ interface HttpSdkApi : BaseRetrofitApi { @HeaderMap headers: Map = emptyMap() ): Response + @HTTP(method = "GET", path = "", hasBody = false) + @Streaming + suspend fun getStreaming( + @Url url: String, + @HeaderMap headers: Map = emptyMap() + ): Response + @HTTP(method = "POST", path = "", hasBody = true) suspend fun post( @Url url: String, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt index 9f7dd528..74cbe0a9 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -13,6 +13,7 @@ import me.proton.drive.sdk.internal.JniDriveClient import me.proton.drive.sdk.internal.JniLoggerProvider import me.proton.drive.sdk.internal.JniNativeLibrary import me.proton.drive.sdk.internal.JniSession +import me.proton.drive.sdk.internal.ProtonDriveSdkNativeClient object ProtonDriveSdk { init { @@ -53,8 +54,13 @@ object ProtonDriveSdk { create( coroutineScope = coroutineScope, request = request, - onSendHttpRequest = ApiProviderBridge(userId, apiProvider), - onRequest = AccountClientBridge(userAddressResolver, publicAddressResolver), + httpResponseReadPointer = ProtonDriveSdkNativeClient.getHttpResponseReadPointer(), + onHttpClientRequest = ApiProviderBridge( + userId = userId, + apiProvider = apiProvider, + coroutineScope = coroutineScope, + ), + onAccountRequest = AccountClientBridge(userAddressResolver, publicAddressResolver), onRecordMetric = metricCallback?.let(::TelemetryBridge) ?: {}, ), this ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/IntConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/IntConverter.kt new file mode 100644 index 00000000..d373b6b4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/IntConverter.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import com.google.protobuf.Int32Value + +class IntConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/google.protobuf.Int32Value" + + override fun convert(any: Any): Int = Int32Value.parseFrom(any.value).value +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt index 71ebbde1..c2338cf9 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt @@ -2,6 +2,7 @@ package me.proton.drive.sdk.extension import kotlinx.coroutines.CancellableContinuation import me.proton.drive.sdk.converter.FileThumbnailListConverter +import me.proton.drive.sdk.converter.IntConverter import me.proton.drive.sdk.converter.LongConverter import me.proton.drive.sdk.converter.StringConverter import me.proton.drive.sdk.converter.UploadResultConverter @@ -17,6 +18,12 @@ fun CancellableContinuation.toUnitResponse(): ResponseCallback = val UnitResponseCallback: (CancellableContinuation) -> ResponseCallback = CancellableContinuation::toUnitResponse +fun CancellableContinuation.toIntResponse(): ResponseCallback = + ContinuationValueOrErrorResponse(this, IntConverter()) + +val IntResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toIntResponse + fun CancellableContinuation.toLongResponse(): ResponseCallback = ContinuationValueOrErrorResponse(this, LongConverter()) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt new file mode 100644 index 00000000..38ae59aa --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import kotlinx.coroutines.runBlocking +import me.proton.drive.sdk.internal.HttpStream +import okhttp3.MediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okio.BufferedSink +import proton.sdk.ProtonSdk.HttpRequest +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.channels.Channels + + +internal suspend fun HttpStream.read( + request: HttpRequest +): RequestBody { + val outputStream = ByteArrayOutputStream() + if (request.hasSdkContentHandle()) { + val buffer = ByteBuffer.allocateDirect(64 * 1024) + + while (true) { + buffer.clear() + val bytesRead = read(request.sdkContentHandle, buffer) + if (bytesRead <= 0) break + buffer.position(bytesRead) + + // Flip so we can read bytes from ByteBuffer + buffer.flip() + + // Write directly from ByteBuffer to okio + Channels.newChannel(outputStream).write(buffer) + } + } + + val body = outputStream.toByteArray().toRequestBody() + return body +} + + +internal fun HttpStream.readAsStream( + request: HttpRequest, +): RequestBody = StreamRequestBody( + httpStream = this, + request = request, +) + +private class StreamRequestBody( + private val httpStream: HttpStream, + private val request: HttpRequest, +) : RequestBody() { + override fun isOneShot(): Boolean = true + + override fun contentType(): MediaType? = null + + override fun contentLength(): Long = -1 // enables chunked mode + + override fun writeTo(sink: BufferedSink) { + if (request.hasSdkContentHandle()) { + val buffer = ByteBuffer.allocateDirect(64 * 1024) + runBlocking { + while (true) { + buffer.clear() + val bytesRead = httpStream.read(request.sdkContentHandle, buffer) + if (bytesRead <= 0) break + buffer.position(bytesRead) + + // Flip so we can read bytes from ByteBuffer + buffer.flip() + + // Write directly from ByteBuffer to okio + sink.write(buffer) + } + } + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt index 79c87f69..1ead48a5 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -1,13 +1,14 @@ package me.proton.drive.sdk.internal -import com.google.protobuf.kotlin.toByteString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import me.proton.core.domain.entity.UserId import me.proton.core.network.data.ApiProvider import me.proton.core.network.data.ProtonErrorException import me.proton.core.network.domain.ApiResult import me.proton.drive.sdk.HttpSdkApi -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody +import me.proton.drive.sdk.extension.read import okhttp3.ResponseBody import proton.sdk.ProtonSdk.HttpRequest import proton.sdk.ProtonSdk.HttpResponse @@ -17,18 +18,17 @@ import retrofit2.Response internal class ApiProviderBridge( private val userId: UserId, - private val apiProvider: ApiProvider + private val apiProvider: ApiProvider, + private val coroutineScope: CoroutineScope, ) : suspend (HttpRequest) -> HttpResponse { + + private var httpStreams = emptyList() + private val mutex = Mutex() + override suspend fun invoke(request: HttpRequest): HttpResponse { + val httpStream = createHttpStream() val apiResult = apiProvider.get(userId).invoke { - execute( - method = request.method, - url = request.url, - headers = request.headersList.associate { header -> - header.name to header.valuesList.joinToString(",") - }, - body = request.content.toByteArray().toRequestBody() - ) + execute(request, httpStream) } if (apiResult is ApiResult.Error) { @@ -44,8 +44,8 @@ internal class ApiProviderBridge( values.addAll(responseHeaders.values(name)) } } - response.body?.byteString()?.toByteArray()?.toByteString()?.let { body -> - content = body + response.body?.byteStream()?.let { inputStream -> + bindingsContentHandle = httpStream.write(coroutineScope, inputStream) } } } @@ -62,20 +62,58 @@ internal class ApiProviderBridge( values.addAll(responseHeaders.values(name)) } } - response.body()?.byteString()?.toByteArray()?.toByteString()?.let { body -> - content = body + response.body()?.byteStream()?.let { inputStream -> + bindingsContentHandle = httpStream.write(coroutineScope, inputStream) } } } + private fun HttpRequest.isUploadBlock(): Boolean = + method == "POST" && url.contains("/storage/blocks") + + private fun HttpRequest.isDownloadBlock(): Boolean = + method == "GET" && url.contains("/storage/blocks") + + private suspend fun createHttpStream(): HttpStream { + val jniHttpStream = JniHttpStream() + val httpStream = HttpStream( + bridge = jniHttpStream + ) + jniHttpStream.onBodyRead = { + mutex.withLock { + httpStreams -= httpStream + httpStream.close() + } + } + mutex.withLock { + httpStreams += httpStream + } + return httpStream + } + private suspend fun HttpSdkApi.execute( - method: String, - url: String, - headers: Map = emptyMap(), - body: RequestBody? = null + request: HttpRequest, + httpStream: HttpStream, ): Response { + val method = request.method + val url = request.url + val headers = request.headersList.associate { header -> + header.name to header.valuesList.joinToString(",") + } + val body = if (request.isUploadBlock()) { + httpStream.read(request) + // TODO: no working yet request is seen in the log but not send + //httpStream.readAsStream(request) + } else { + httpStream.read(request) + } return when (method.uppercase()) { - "GET" -> get(url, headers) + "GET" -> if (request.isDownloadBlock()) { + getStreaming(url, headers) + } else { + get(url, headers) + } + "POST" -> post(url, headers, body) "PUT" -> put(url, headers, body) "DELETE" -> delete(url, headers, body) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt new file mode 100644 index 00000000..3c1effe3 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.CoroutineScope +import java.io.InputStream +import java.nio.ByteBuffer + + +class HttpStream internal constructor( + private val bridge: JniHttpStream, +) : AutoCloseable { + + suspend fun read(sdkContentHandle: Long, buffer: ByteBuffer) = + bridge.read(sdkContentHandle, buffer) + + fun write(coroutineScope: CoroutineScope, inputStream: InputStream): Long = + bridge.write(coroutineScope, inputStream) + + override fun close() { + bridge.release() + bridge.releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt index c312d123..126ebd83 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt @@ -35,6 +35,15 @@ abstract class JniBaseProtonSdk : JniBase() { nativeClient.handleRequest(request(block)) } + suspend fun executeOnce( + clientBuilder: (CancellableContinuation) -> ProtonSdkNativeClient, + requestBuilder: (ProtonSdkNativeClient) -> Request, + ): T = suspendCancellableCoroutine { continuation -> + val nativeClient = clientBuilder(continuation) + continuation.invokeOnCancellation { nativeClient.release() } + nativeClient.handleRequest(requestBuilder(nativeClient)) + } + suspend fun executePersistent( clientBuilder: (CancellableContinuation) -> ProtonSdkNativeClient, requestBuilder: (ProtonSdkNativeClient) -> Request, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index 7b9fb30e..da4713dd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -29,15 +29,16 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun create( coroutineScope: CoroutineScope, request: ClientCreateRequest, - onSendHttpRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse, - onRequest: suspend (ProtonDriveSdk.AccountRequest) -> Any, + httpResponseReadPointer: Long, + onHttpClientRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse, + onAccountRequest: suspend (ProtonDriveSdk.AccountRequest) -> Any, onRecordMetric: suspend (ProtonSdk.MetricEvent) -> Unit, ) = executePersistent(clientBuilder = { continuation -> ProtonDriveSdkNativeClient( method("create"), continuation.toLongResponse(), - sendHttpRequest = onSendHttpRequest, - request = onRequest, + httpClientRequest = onHttpClientRequest, + accountRequest = onAccountRequest, logger = logger, recordMetric = onRecordMetric, coroutineScope = coroutineScope, @@ -46,8 +47,9 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { request { driveClientCreate = driveClientCreateRequest { baseUrl = request.baseUrl - httpClientRequestAction = client.getSendHttpRequestPointer() - accountClientRequestAction = client.getAccountRequestPointer() + httpClientRequestAction = client.getHttpClientRequestPointer() + httpResponseReadAction = httpResponseReadPointer + accountRequestAction = client.getAccountRequestPointer() entityCachePath = request.entityCachePath secretCachePath = request.secretCachePath telemetry = telemetry { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt new file mode 100644 index 00000000..ffe8bf8d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -0,0 +1,59 @@ +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.extension.toIntResponse +import proton.sdk.request +import proton.sdk.streamReadRequest +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.channels.Channels + +class JniHttpStream internal constructor( +) : JniBaseProtonSdk() { + + private var client: ProtonDriveSdkNativeClient? = null + + internal var onBodyRead: (suspend () -> Unit)? = null + + fun write( + coroutineScope: CoroutineScope, + inputStream: InputStream, + ): Long = ProtonDriveSdkNativeClient( + name = method("write"), + readHttpBody = { buffer -> + Channels.newChannel(inputStream).read(buffer).also { numberOfByteRead -> + if (numberOfByteRead == 0) { + onBodyRead?.invoke() + } + } + }, + coroutineScope = coroutineScope, + logger = logger + ).also { + client = it + }.createWeakRef() + + suspend fun read( + handle: Long, + buffer: ByteBuffer, + ): Int = executeOnce(clientBuilder = { continuation -> + ProtonSdkNativeClient( + name = method("read"), + response = continuation.toIntResponse(), + logger = logger, + ) + }, requestBuilder = { client -> + request { + streamRead = streamReadRequest { + streamHandle = handle + bufferPointer = client.getBufferPointer(buffer) + bufferLength = client.getBufferSize(buffer).toInt() + } + } + }) + + fun release() { + client = null + } + +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 6d377ab8..baa38e76 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -20,8 +20,9 @@ class ProtonDriveSdkNativeClient internal constructor( val response: ResponseCallback = { error("response not configured for $name") }, val read: suspend (ByteBuffer) -> Int = { error("read not configured for $name") }, val write: suspend (ByteBuffer) -> Unit = { error("write not configured for $name") }, - val sendHttpRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse = { error("sendHttpRequest not configured for $name") }, - val request: suspend (ProtonDriveSdk.AccountRequest) -> Any = { error("request not configured for $name") }, + val httpClientRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse = { error("httpClientRequest not configured for $name") }, + val readHttpBody: suspend (ByteBuffer) -> Int = { error("readHttpBody not configured for $name") }, + val accountRequest: suspend (ProtonDriveSdk.AccountRequest) -> Any = { error("accountRequest not configured for $name") }, val progress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit = { error("progress not configured for $name") }, val recordMetric: suspend (ProtonSdk.MetricEvent) -> Unit = { error("recordMetric not configured for $name") }, val logger: (String) -> Unit = {}, @@ -77,9 +78,10 @@ class ProtonDriveSdkNativeClient internal constructor( external fun getReadPointer(): Long external fun getWritePointer(): Long external fun getProgressPointer(): Long - external fun getSendHttpRequestPointer(): Long + external fun getHttpClientRequestPointer(): Long external fun getAccountRequestPointer(): Long external fun getRecordMetricPointer(): Long + external fun createWeakRef(): Long @Suppress("unused") // Called by JNI fun onResponse(data: ByteBuffer) { @@ -121,11 +123,21 @@ class ProtonDriveSdkNativeClient internal constructor( parser = ProtonSdk.HttpRequest::parseFrom, ) { httpRequest -> logger("send http request for ${httpRequest.method} ${httpRequest.url} of size: ${data.capacity()}") - val httpResponse = sendHttpRequest(httpRequest) + val httpResponse = httpClientRequest(httpRequest) logger("receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}") response { value = httpResponse.asAny("proton.sdk.HttpResponse") } } + @Suppress("unused") // Called by JNI + fun onHttpResponseRead(buffer: ByteBuffer, sdkHandle: Long) { + onOperation("read", sdkHandle) { + logger("http response read for $name of size: ${buffer.capacity()}") + val bytesRead = readHttpBody(buffer).takeUnless { it < 0 } ?: 0 + logger("$bytesRead bytes read for http response $name") + response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } + } + } + @Suppress("unused") // Called by JNI fun onAccountRequest( data: ByteBuffer, @@ -137,7 +149,7 @@ class ProtonDriveSdkNativeClient internal constructor( parser = ProtonDriveSdk.AccountRequest::parseFrom, ) { accountRequest -> logger("request for ${accountRequest.payloadCase.name} of size: ${data.capacity()}") - val response = request(accountRequest) + val response = accountRequest(accountRequest) response { value = response } } @@ -228,4 +240,9 @@ class ProtonDriveSdkNativeClient internal constructor( } return coroutineScope } + + companion object{ + @JvmStatic + external fun getHttpResponseReadPointer(): Long + } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt index 0803ac82..aeb4db6e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -28,6 +28,10 @@ class ProtonSdkNativeClient internal constructor( external fun getCallbackPointer(): Long + external fun getBufferPointer(buffer: ByteBuffer): Long + + external fun getBufferSize(buffer: ByteBuffer): Long + fun onResponse(data: ByteBuffer) { logger("response for $name of size: ${data.capacity()}") response(data) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift index e23a8eec..faaa5e86 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift @@ -14,70 +14,42 @@ extension Resumable where ReturnType == Void { } } -/// Class containing a continuation - for when a continuation needs to be accessible by memory address -final class BoxedContinuation: Resumable { - private var continuation: Continuation? - - let context: Any = Void() - - init(_ continuation: Continuation) { - self.continuation = continuation - } - - func resume(returning value: sending ResultType) { - guard let continuation else { - assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") - return - } - continuation.resume(returning: value) - self.continuation = nil - } - - func resume(throwing error: any Error) { - guard let continuation else { - assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") - return - } - continuation.resume(throwing: error) - self.continuation = nil - } -} +// Boxed completion +final class BoxedCompletionBlock: Resumable { + typealias CompletionBlock = (Result) -> Void -final class BoxedContinuationWithState: Resumable { - typealias Continuation = CheckedContinuation - - private var continuation: Continuation? + private var completionBlock: CompletionBlock? let state: StateType let context: Any - init(_ continuation: Continuation, state: StateType, context: Any) { - self.continuation = continuation + init(_ completionBlock: CompletionBlock?, state: StateType, context: Any) { + self.completionBlock = completionBlock self.state = state self.context = context } - - init(_ continuation: Continuation, weakState state: WeakStateType, context: Any) + + init(_ completionBlock: CompletionBlock?, weakState state: WeakStateType, context: Any) where StateType == WeakReference { - self.continuation = continuation + self.completionBlock = completionBlock self.state = WeakReference(value: state) self.context = context } - - func resume(returning value: sending ResultType) { - guard let continuation else { + + func resume(returning value: ResultType) { + guard let completionBlock else { assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") return } - continuation.resume(returning: value) - self.continuation = nil + completionBlock(.success(value)) + self.completionBlock = nil } func resume(throwing error: any Error) { - guard let continuation else { + guard let completionBlock else { assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") return } - continuation.resume(throwing: error) - self.continuation = nil + completionBlock(.failure(error)) + self.completionBlock = nil } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift index 87ccffb2..fa08defb 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift @@ -1,13 +1,13 @@ import Foundation -public typealias FeatureFlagProviderCallback = @Sendable (String) async -> Bool +public typealias FeatureFlagProviderCallback = @Sendable (String, (Bool) -> Void) -> Void let cCompatibleFeatureFlagProviderCallback: CCallbackWithIntReturn = { statePointer, byteArray in guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { return 0 } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) let weakDriveClient = stateTypedPointer.takeUnretainedValue().state guard let driveClient = weakDriveClient.value else { @@ -16,23 +16,10 @@ let cCompatibleFeatureFlagProviderCallback: CCallbackWithIntReturn = { statePoin } // Convert ByteArray to String - guard let pointer = byteArray.pointer, - let data = Data(bytes: pointer, count: byteArray.length), - let flagName = String(data: data, encoding: .utf8) else { - return 0 - } - - // Since the C# callback expects a synchronous return but our Swift callback is async, - // we need to block and wait for the async result using a semaphore - let semaphore = DispatchSemaphore(value: 0) - var result = false - - Task { - result = await driveClient.isFlagEnabled(flagName) - semaphore.signal() - } - - semaphore.wait() + guard let pointer = byteArray.pointer else { return 0 } + let data = Data(bytes: pointer, count: byteArray.length) + guard let flagName = String(data: data, encoding: .utf8) else { return 0 } + let result = driveClient.isFlagEnabled(flagName) return result ? 1 : 0 } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift index c7488abd..64c676a1 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift @@ -1,10 +1,10 @@ protocol InteropRequest { - associatedtype CallResultType + associatedtype CallResultType: Sendable associatedtype StateType } extension InteropRequest { - typealias BoxedStateType = BoxedContinuationWithState + typealias BoxedStateType = BoxedCompletionBlock } extension Proton_Drive_Sdk_DriveClientCreateRequest: InteropRequest { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index e0efd9e8..6f608aa7 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -111,6 +111,10 @@ extension Message { Proton_Drive_Sdk_Request.with { $0.payload = .driveClientGetThumbnails(request) } + case let request as Proton_Sdk_StreamReadRequest: + Proton_Sdk_Request.with { + $0.payload = .streamRead(request) + } default: assertionFailure("Unknown request") @@ -149,8 +153,18 @@ extension Message { return Proton_Sdk_Response.with { $0.error = error } + case let intValue as Google_Protobuf_Int64Value: + let value = try Google_Protobuf_Any.init(message: intValue) + return Proton_Sdk_Response.with { + $0.value = value + } + case let intValue as Google_Protobuf_Int32Value: + let value = try Google_Protobuf_Any.init(message: intValue) + return Proton_Sdk_Response.with { + $0.value = value + } default: - assertionFailure("Unknown response") + assertionFailure("Unknown response type: \(self)") throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown response type: \(self)")) } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift index c9178840..e7834c8e 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -10,11 +10,11 @@ final class ProgressCallbackWrapper { } let cProgressCallback: CCallback = { statePointer, byteArray in - typealias BoxType = BoxedContinuationWithState> + typealias BoxType = BoxedCompletionBlock> let progressUpdate = Proton_Drive_Sdk_ProgressUpdate(byteArray: byteArray) let progress = FileOperationProgress( - bytesCompleted: progressUpdate.bytesCompleted, - bytesTotal: progressUpdate.bytesInTotal + bytesCompleted: progressUpdate.hasBytesCompleted ? progressUpdate.bytesCompleted : nil, + bytesTotal: progressUpdate.hasBytesInTotal ? progressUpdate.bytesInTotal : nil ) guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { return } @@ -22,8 +22,8 @@ let cProgressCallback: CCallback = { statePointer, byteArray in let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state weakWrapper.value?.callback(progress) - // TODO: also release pointer when task is cancelled - if progress.isCompleted { - stateTypedPointer.release() - } + // TODO: release pointer when task is cancelled or completed + // if progress.isCompleted { + // stateTypedPointer.release() + // } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index a5d2ae13..3936061b 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -162,11 +162,12 @@ public typealias ProgressCallback = @Sendable (FileOperationProgress) -> Void /// Progress information for upload/download operations public struct FileOperationProgress { - public let bytesCompleted: Int64 - public let bytesTotal: Int64 + public let bytesCompleted: Int64? + public let bytesTotal: Int64? /// Progress percentage (0.0 to 1.0) public var fractionCompleted: Double { + guard let bytesTotal, let bytesCompleted else { return 0.0 } guard bytesTotal > 0 else { return 0.0 } let value = Double(bytesCompleted) / Double(bytesTotal) return min(1.0, value) @@ -174,7 +175,7 @@ public struct FileOperationProgress { public var isCompleted: Bool { fractionCompleted == 1.0 } - public init(bytesCompleted: Int64, bytesTotal: Int64) { + public init(bytesCompleted: Int64?, bytesTotal: Int64?) { self.bytesCompleted = bytesCompleted self.bytesTotal = bytesTotal } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 35d82f9c..f986a830 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -7,34 +7,81 @@ enum SDKRequestHandler { // MARK: - Simple requests (without state) - static func sendInteropRequest(_ request: T, logger: Logger?) async throws -> T.CallResultType + /// Async/await API for request without state for types with the generics documented via InteropRequest protocol. + // TODO(SDK): document generics (message and return types) via InteropRequest for all calls. + static func sendInteropRequest( + _ request: T, + logger: Logger? + ) async throws -> T.CallResultType where T.StateType == Void { try await send(request, logger: logger) } - static func send(_ request: T, logger: Logger?) async throws -> U { + /// Async/await API for requests without state + static func send( + _ request: T, + logger: Logger? + ) async throws -> U { try await send(request, state: (), logger: logger) } + /// Completion block API for requests without state + static func send( + _ request: T, + logger: Logger?, + includesLongLivedCallback: Bool = false, + completionBlock: @escaping (Result) -> Void + ) { + send(request, state: (), logger: logger, includesLongLivedCallback: includesLongLivedCallback, completionBlock: completionBlock) + } + // MARK: - Requests with additional state // `includesLongLivedCallback` property is used to know whether we need keep the box for state alive longer // than just until this method finished - static func sendInteropRequest( - _ request: T, state: T.StateType, includesLongLivedCallback: Bool = false, logger: Logger? + /// Async/await API for request with state for types with the generics documented via InteropRequest protocol. + static func sendInteropRequest( + _ request: T, + state: T.StateType, + includesLongLivedCallback: Bool = false, + logger: Logger? ) async throws -> T.CallResultType { - try await self.send(request, state: state, includesLongLivedCallback: includesLongLivedCallback, logger: logger) + try await send(request, state: state, includesLongLivedCallback: includesLongLivedCallback, logger: logger) } - static func send( - _ request: T, state: V, includesLongLivedCallback: Bool = false, logger: Logger? + /// Async/await API for requests with state + static func send( + _ request: T, + state: V, + includesLongLivedCallback: Bool = false, + logger: Logger? ) async throws -> U { - // Put the request in an envelope - let envelopedRequestData = try request.packIntoRequest().serializedData() - let isDriveRequest = request.isDriveRequest - logger?.trace("Sending SDK message with state: \(T.protoMessageName) - \(request)", category: "SDKRequestHandler") + try await withCheckedThrowingContinuation { continuation in + send(request, state: state, logger: logger, includesLongLivedCallback: includesLongLivedCallback) { (result: Result) in + switch result { + case .success(let response): + continuation.resume(returning: response) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Completion block API for requests with state + static func send( + _ request: T, + state: V, + logger: Logger?, + includesLongLivedCallback: Bool = false, + completionBlock: @escaping (Result) -> Void + ) { + do { + // Put the request in an envelope + let envelopedRequestData = try request.packIntoRequest().serializedData() + let isDriveRequest = request.isDriveRequest + logger?.trace("Sending SDK message with state: \(T.protoMessageName) - \(request)", category: "SDKRequestHandler") - let response: U = try await withCheckedThrowingContinuation { continuation in let requestArray = ByteArray(data: envelopedRequestData) defer { logger?.trace("deferred deallocate of requestData", category: "SDKRequestHandler") @@ -44,7 +91,7 @@ enum SDKRequestHandler { logger?.trace("Sending (\(isDriveRequest ? "Drive" : "non-Drive")) SDK request ", category: "SDKRequestHandler") // Switch to InteropTypes.BoxedStateType once we use it for all requests - let boxedState = BoxedContinuationWithState(continuation, state: state, context: envelopedRequestData) + let boxedState = BoxedCompletionBlock(completionBlock, state: state, context: envelopedRequestData) let pointer = Unmanaged.passRetained(boxedState) if includesLongLivedCallback { // We double-retain to keep the box alive after the method finishes. @@ -60,8 +107,9 @@ enum SDKRequestHandler { logger?.trace(" -> proton_sdk_handle_request", category: "SDKRequestHandler") proton_sdk_handle_request(requestArray, bindingsHandle, sdkResponseCallbackWithState) } + } catch { + completionBlock(.failure(error)) } - return response } } @@ -80,7 +128,7 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in switch response.result { case nil: // empty response. Might be expected, might be not expected guard let voidBox = box as? any Resumable else { - throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected empty response received")) + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } voidBox.resume() @@ -92,27 +140,38 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in case let intBox as any Resumable: intBox.resume(returning: Int(unpackedValue)) default: - throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Google_Protobuf_Int64Value")) + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) + } + + case .value(let value) where value.isA(Google_Protobuf_Int32Value.self): + let unpackedValue = try Google_Protobuf_Int32Value(unpackingAny: value).value + switch box { + case let int32Box as any Resumable: + int32Box.resume(returning: unpackedValue) + case let intBox as any Resumable: + intBox.resume(returning: Int(unpackedValue)) + default: + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } case .value(let value) where value.isA(Proton_Drive_Sdk_UploadResult.self): let unpackedValue = try Proton_Drive_Sdk_UploadResult(unpackingAny: value) guard let uploadResultBox = box as? any Resumable else { - throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Proton_Drive_Sdk_UploadResult")) + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } uploadResultBox.resume(returning: unpackedValue) case .value(let value) where value.isA(Google_Protobuf_StringValue.self): let unpackedValue = try Google_Protobuf_StringValue(unpackingAny: value) guard let stringResultBox = box as? any Resumable else { - throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: String")) + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } stringResultBox.resume(returning: unpackedValue.value) case .value(let value) where value.isA(Proton_Drive_Sdk_FileThumbnailList.self): let unpackedValue = try Proton_Drive_Sdk_FileThumbnailList(unpackingAny: value) guard let uploadResultBox = box as? any Resumable else { - throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unexpected SDK call response type: Proton_Drive_Sdk_FileThumbnailList")) + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } uploadResultBox.resume(returning: unpackedValue) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift index 74623e3b..d0695626 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift @@ -1,3 +1,4 @@ +import Foundation import CProtonDriveSDK import SwiftProtobuf @@ -5,11 +6,35 @@ enum SDKResponseHandler { static func send(callbackPointer: Int, message: Message) { do { let byteArray = try message.serializedIntoResponse() - proton_drive_sdk_handle_response(callbackPointer, byteArray) + proton_sdk_handle_response(callbackPointer, byteArray) byteArray.deallocate() } catch { // TODO: this breaks SDK. We should definitely log this to Sentry. We might choose not to crash though. fatalError("SDKResponseHandler.send failed with \(error)") } } + + static func sendErrorToSDK(_ error: Error, callbackPointer: Int) { + sendErrorToSDK(error as NSError, callbackPointer: callbackPointer) + } + + static func sendInteropErrorToSDK(message: String, callbackPointer: Int) { + let error = Proton_Sdk_Error.with { + $0.type = "interop" + $0.domain = Proton_Sdk_ErrorDomain.businessLogic + $0.context = message + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) + } + + static func sendErrorToSDK(_ error: NSError, callbackPointer: Int) { + // TODO(SDK): below we're just returning some rubbish + let error = Proton_Sdk_Error.with { + $0.type = "sdk error" + $0.domain = Proton_Sdk_ErrorDomain.api + $0.context = error.localizedDescription + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) + } + } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift index eaa3ecad..277a7b85 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift @@ -14,7 +14,7 @@ let cCompatibleAccountClientRequest: CCallbackWithCallbackPointer = { statePoint guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { return } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state let driveClient = ProtonDriveClient.unbox( diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift index 765b38e7..e6a93814 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift @@ -2,9 +2,9 @@ import Foundation import SwiftProtobuf public struct HttpClientResponse { - let data: Data? - let headers: [(String, [String])] - let statusCode: Int + public let data: Data? + public let headers: [(String, [String])] + public let statusCode: Int public init(data: Data?, headers: [(String, [String])], statusCode: Int) { self.data = data @@ -13,57 +13,48 @@ public struct HttpClientResponse { } } -/// Protocol to be implemented by object making http requests. -public protocol HttpClientProtocol: AnyObject, Sendable { - func request(method: String, url: String, content: Data, headers: [(String, [String])]) async -> Result -} +public struct HttpClientStream { + public let stream: URLSession.AsyncBytes + public let headers: [(String, [String])] + public let statusCode: Int -let cCompatibleHttpRequest: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in - guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { - return + public init(stream: URLSession.AsyncBytes, headers: [(String, [String])], statusCode: Int) { + self.stream = stream + self.headers = headers + self.statusCode = statusCode } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) - let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state - - let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { stateTypedPointer.release() }, weakDriveClient: weakDriveClient) - guard let driveClient else { return } +} - Task { [driveClient] in - let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) - let headers: [(String, [String])] = httpRequestData.headers.map { header in - (header.name, header.values) - } +public enum RequestType { + case driveAPI(relativePath: String) + case uploadToStorage + case downloadFromStorage +} + +/// Protocol to be implemented by object making http requests. +public protocol HttpClientProtocol: AnyObject, Sendable { + func getRelativeDrivePath(url: String, method: String) -> RequestType - let result = await driveClient.httpClient.request( - method: httpRequestData.method, - url: httpRequestData.url, - content: httpRequestData.content, - headers: headers - ) + /// Drive api calls (takes `/drive/...` path) + func requestDriveApi( + method: String, + relativePath: String, + content: Data, + headers: [(String, [String])] + ) async -> Result - switch result { - case .success(let response): - let httpResponse = Proton_Sdk_HttpResponse.with { - $0.headers = response.headers.map { header in - Proton_Sdk_HttpHeader.with { - $0.name = header.0 - $0.values = header.1 - } - } - if let data = response.data { - $0.content = data - } - $0.statusCode = Int32(response.statusCode) - } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) - case .failure(let error): - //TODO below we're just returning some rubbish - let error = Proton_Sdk_Error.with { - $0.type = "sdk error" - $0.domain = Proton_Sdk_ErrorDomain.api - $0.context = error.localizedDescription - } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) - } - } + /// Raw request (takes whole url) - should be storage request + func requestUploadToStorage( + method: String, + url: String, + content: StreamForUpload, + headers: [(String, [String])] + ) async -> Result + + func requestDownloadFromStorage( + method: String, + url: String, + content: Data, + headers: [(String, [String])] + ) async -> Result } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift new file mode 100644 index 00000000..cdcfcf4b --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -0,0 +1,251 @@ +import Foundation + +enum HttpClientRequestProcessor { + + static let cCompatibleHttpRequest: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in + Task { + do { + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + return + } + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state + + let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { stateTypedPointer.release() }, weakDriveClient: weakDriveClient) + guard let driveClient else { return } + + + let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) + try await HttpClientRequestProcessor.perform(client: driveClient, httpRequestData: httpRequestData, callbackPointer: callbackPointer) + } catch { + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } + } + } + + + fileprivate static func perform( + client: ProtonDriveClient, + httpRequestData: Proton_Sdk_HttpRequest, + callbackPointer: Int + ) async throws { + let requestType = client.httpClient.getRelativeDrivePath(url: httpRequestData.url, method: httpRequestData.method) + + switch requestType { + case .driveAPI(let driveRelativePath): + try await performDriveApi( + driveRelativePath: driveRelativePath, + client: client, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer + ) + case .uploadToStorage: + try await uploadToStorage( + client: client, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer + ) + case .downloadFromStorage: + try await downloadFromStorage( + client: client, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer + ) + } + } + + /// the API calls are performed in a non-streaming way. both request body and response data are buffered in memory + fileprivate static func performDriveApi( + driveRelativePath: String, + client: ProtonDriveClient, + httpRequestData: Proton_Sdk_HttpRequest, + callbackPointer: Int + ) async throws { + let headers: [(String, [String])] = httpRequestData.headers.map { header in + (header.name, header.values) + } + var contentData = Data() + if httpRequestData.hasSdkContentHandle { + // the API calls are performed in a non-streaming way, + // so we buffer all request data in-memory before making a call + let bufferLength = client.configuration.httpTransferBufferSize + var buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) + let baseAddress = buffer.baseAddress! + + while true { + let streamReadRequest = Proton_Sdk_StreamReadRequest.with { + $0.bufferLength = Int32(buffer.count) + $0.bufferPointer = Int64(ObjectHandle(rawPointer: UnsafeRawPointer(baseAddress))) + $0.streamHandle = httpRequestData.sdkContentHandle + } + let read: Int32 = try await SDKRequestHandler.send(streamReadRequest, logger: client.logger) + let dataFromThisRead = Data(bytes: baseAddress, count: Int(read)) + contentData.append(dataFromThisRead) + if read == 0 { + break + } + } + buffer.deallocate() + } + + let result = await client.httpClient.requestDriveApi( + method: httpRequestData.method, + relativePath: driveRelativePath, + content: contentData, + headers: headers + ) + + switch result { + case .success(let response): + // the API calls are performed in a non-streaming way, we have whole data cached in-memory, + // so we prepare a buffer that holds everything and wrap it into offset-keeping box + let bindingsHandle: Int? + if let data = response.data, !data.isEmpty { + let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) + await uploadBuffer.copyBytes(from: data) + let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) + let pointer = Unmanaged.passRetained(bytesOrStream) + bindingsHandle = Int(rawPointer: pointer.toOpaque()) + } else { + bindingsHandle = nil + } + let httpResponse = Proton_Sdk_HttpResponse.with { + $0.headers = response.headers.map { header in + Proton_Sdk_HttpHeader.with { + $0.name = header.0 + $0.values = header.1 + } + } + if let bindingsHandle { + $0.bindingsContentHandle = Int64(bindingsHandle) + } + $0.statusCode = Int32(response.statusCode) + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) + case .failure(let error): + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } + } + + /// the storage upload calls are using stream to upload request body, but cache the whole response in memory + fileprivate static func uploadToStorage( + client: ProtonDriveClient, + httpRequestData: Proton_Sdk_HttpRequest, + callbackPointer: Int + ) async throws { + let headers: [(String, [String])] = httpRequestData.headers.map { header in + (header.name, header.values) + } + + guard httpRequestData.hasSdkContentHandle else { + assertionFailure("Should never happen for uploads, we must have data for uploading") + SDKResponseHandler.sendInteropErrorToSDK( + message: "Proton_Sdk_HttpRequest.sdk_content_handle is missing", + callbackPointer: callbackPointer + ) + return + } + + let bufferLength = client.configuration.httpTransferBufferSize + let stream = try StreamForUpload( + bufferLength: bufferLength, sdkContentHandle: httpRequestData.sdkContentHandle, logger: client.logger + ) + + let result = await client.httpClient.requestUploadToStorage( + method: httpRequestData.method, + url: httpRequestData.url, + content: stream, + headers: headers + ) + + switch result { + case .success(let response): + let bindingsHandle: Int? + if let data = response.data, !data.isEmpty { + let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) + await uploadBuffer.copyBytes(from: data) + let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) + let pointer = Unmanaged.passRetained(bytesOrStream) + bindingsHandle = Int(rawPointer: pointer.toOpaque()) + } else { + bindingsHandle = nil + } + let httpResponse = Proton_Sdk_HttpResponse.with { + $0.headers = response.headers.map { header in + Proton_Sdk_HttpHeader.with { + $0.name = header.0 + $0.values = header.1 + } + } + if let bindingsHandle { + $0.bindingsContentHandle = Int64(bindingsHandle) + } + $0.statusCode = Int32(response.statusCode) + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) + case .failure(let error): + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } + } + + /// the download upload calls are caching the whole request body in-memory, but stream the response data + fileprivate static func downloadFromStorage( + client: ProtonDriveClient, + httpRequestData: Proton_Sdk_HttpRequest, + callbackPointer: Int + ) async throws { + let headers: [(String, [String])] = httpRequestData.headers.map { header in + (header.name, header.values) + } + + var contentData = Data() + if httpRequestData.hasSdkContentHandle { + // We expect that request data to be small, we need to fetch them whole + let bufferLength = client.configuration.httpTransferBufferSize + var buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) + let baseAddress = buffer.baseAddress! + + while true { + let streamReadRequest = Proton_Sdk_StreamReadRequest.with { + $0.bufferLength = Int32(buffer.count) + $0.bufferPointer = Int64(ObjectHandle(rawPointer: UnsafeRawPointer(baseAddress))) + $0.streamHandle = httpRequestData.sdkContentHandle + } + let read: Int32 = try await SDKRequestHandler.send(streamReadRequest, logger: client.logger) + let dataFromThisRead = Data(bytes: baseAddress, count: Int(read)) + contentData.append(dataFromThisRead) + if read == 0 { + break + } + } + buffer.deallocate() + } + + let result = await client.httpClient.requestDownloadFromStorage( + method: httpRequestData.method, + url: httpRequestData.url, + content: contentData, + headers: headers + ) + + switch result { + case .success(let response): + let httpResponse = Proton_Sdk_HttpResponse.with { + $0.headers = response.headers.map { header in + Proton_Sdk_HttpHeader.with { + $0.name = header.0 + $0.values = header.1 + } + } + let bytesOrStream = BoxedStreamingData(downloadStream: response.stream, logger: client.logger) + let pointer = Unmanaged.passRetained(bytesOrStream) + let bindingsHandle = Int(rawPointer: pointer.toOpaque()) + $0.bindingsContentHandle = Int64(bindingsHandle) + $0.statusCode = Int32(response.statusCode) + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) + case .failure(let error): + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift new file mode 100644 index 00000000..13782200 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftProtobuf + +enum HttpClientResponseProcessor { + + // statePointer is bindings content handle, + // byteArray is buffer, + // callbackPointer is used for calling sdk back to let it know we've filled the buffer + static let cCompatibleHttpResponseRead: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in + Task { + do { + guard let bindingsContentHandle = UnsafeRawPointer(bitPattern: statePointer) + else { + assertionFailure("We must have a state pointer to perform this operation") + SDKResponseHandler.sendInteropErrorToSDK( + message: "Invalid state pointer", + callbackPointer: callbackPointer + ) + return + } + + let buffer = UnsafeMutablePointer(mutating: byteArray.pointer)! + let bufferSize = byteArray.length + + let boxedStreamingData = Unmanaged.fromOpaque(bindingsContentHandle).takeUnretainedValue() + + if let boxedRawBuffer = boxedStreamingData.uploadBuffer { + try await HttpClientResponseProcessor.passResponseBytes( + boxedRawBuffer: boxedRawBuffer, + buffer: buffer, + bufferSize: bufferSize, + callbackPointer: callbackPointer, + releaseBox: { + _ = Unmanaged.fromOpaque(bindingsContentHandle).takeRetainedValue() + } + ) + } else if let boxedDownloadStream = boxedStreamingData.downloadStream { + try await HttpClientResponseProcessor.passStream( + boxedDownloadStream: boxedDownloadStream, + buffer: buffer, + bufferSize: bufferSize, + callbackPointer: callbackPointer, + releaseBox: { + _ = Unmanaged.fromOpaque(bindingsContentHandle).takeRetainedValue() + } + ) + } else { + assertionFailure("Failed to pass valid BytesOrStream") + } + } catch { + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } + } + } + + + fileprivate static func passStream( + boxedDownloadStream: BoxedDownloadStream, + buffer: sending UnsafeMutablePointer, + bufferSize: Int, + callbackPointer: Int, + releaseBox: () -> Void + ) async throws { + var data = Data(capacity: bufferSize) + var receivedBytes = 0 + do { + while let byte = try await boxedDownloadStream.next() { + data.append(byte) + receivedBytes += 1 + if receivedBytes == bufferSize { + break + } + } + } catch {} + data.copyBytes(to: buffer, count: receivedBytes) + let message = Google_Protobuf_Int32Value.with { + $0.value = Int32(receivedBytes) + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: message) + if bufferSize > receivedBytes { + releaseBox() + } + } + + fileprivate static func passResponseBytes( + boxedRawBuffer: BoxedRawBuffer, + buffer: sending UnsafeMutablePointer, + bufferSize: Int, + callbackPointer: Int, + releaseBox: () -> Void + ) async throws { + let copiedBytesCount = await boxedRawBuffer.copyBytes(to: buffer, count: bufferSize) + + let message = Google_Protobuf_Int32Value.with { + $0.value = Int32(copiedBytesCount) + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: message) + if copiedBytesCount == 0 { + releaseBox() + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift new file mode 100644 index 00000000..22e90a4b --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift @@ -0,0 +1,24 @@ +import Foundation + +actor BoxedDownloadStream: Sendable { + private let stream: URLSession.AsyncBytes + private var iterator: URLSession.AsyncBytes.AsyncIterator + + private let logger: Logger + + init(stream: URLSession.AsyncBytes, logger: Logger) { + self.stream = stream + self.iterator = stream.makeAsyncIterator() + self.logger = logger + } + + func next() async throws -> UInt8? { + var localIterator = self.iterator + defer { self.iterator = localIterator } + return try await localIterator.next() + } + + deinit { + logger.trace("BoxedDownloadStream.deinit", category: "memory management") + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift new file mode 100644 index 00000000..34b80c57 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift @@ -0,0 +1,60 @@ +import Foundation + +actor BoxedRawBuffer: Sendable { + private let buffer: UnsafeMutableRawBufferPointer + private let address: UnsafeMutableRawPointer + private let count: Int + private var offset: Int = 0 + + private let logger: Logger + + // Store these for deinit since deinit is nonisolated + nonisolated private let deinitAddress: Int + nonisolated private let deinitCount: Int + + init(bufferSize: Int, logger: Logger) { + let rawBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferSize, + alignment: MemoryLayout.alignment) + let bufferAddress = rawBuffer.baseAddress! + let bufferCount = rawBuffer.count + self.buffer = rawBuffer + self.address = bufferAddress + self.count = bufferCount + self.deinitAddress = Int(bitPattern: bufferAddress) + self.deinitCount = bufferCount + self.logger = logger + } + + func copyBytes(from data: Data) { + data.copyBytes(to: buffer) + } + + func copyBytes(to buffer: UnsafeMutablePointer, count bufferSize: Int) -> Int { + let copiedBytesCount: Int + let remainingData = count - offset + if remainingData >= bufferSize { + copiedBytesCount = bufferSize + performCopying(to: buffer, count: copiedBytesCount) + } else if remainingData > 0 { + copiedBytesCount = remainingData + performCopying(to: buffer, count: copiedBytesCount) + } else { + // we are done, nothing more to send to SDK + copiedBytesCount = 0 + } + return copiedBytesCount + } + + private func performCopying(to destination: UnsafeMutablePointer, count: Int) { + let currentAddress = address.advanced(by: offset) + let source = currentAddress.assumingMemoryBound(to: UInt8.self) + destination.update(from: source, count: count) + self.offset += count + } + + deinit { + logger.trace("BoxedRawBuffer.deinit", category: "memory management") + let pointer = UnsafeMutableRawPointer(bitPattern: deinitAddress)! + UnsafeMutableRawBufferPointer(start: pointer, count: deinitCount).deallocate() + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift new file mode 100644 index 00000000..9f0b4558 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift @@ -0,0 +1,24 @@ +import Foundation + +final class BoxedStreamingData: Sendable { + let uploadBuffer: BoxedRawBuffer? + let downloadStream: BoxedDownloadStream? + + private let logger: Logger + + init(uploadBuffer: BoxedRawBuffer, logger: Logger) { + self.uploadBuffer = uploadBuffer + self.downloadStream = nil + self.logger = logger + } + + init(downloadStream stream: URLSession.AsyncBytes, logger: Logger) { + self.uploadBuffer = nil + self.downloadStream = BoxedDownloadStream(stream: stream, logger: logger) + self.logger = logger + } + + deinit { + logger.trace("BoxedStreamingData.deinit", category: "memory management") + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift new file mode 100644 index 00000000..4128b15a --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift @@ -0,0 +1,163 @@ +import Foundation + +public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendable { + + public let input: InputStream + public let output: OutputStream + + let sdkContentHandle: Int64 + let logger: Logger + public var onStreamError: (Error) -> Void = { _ in } + let buffer: UnsafeMutableRawBufferPointer + let bufferLength: Int + + enum State { + case initialized + case isReadyForNextWrite + case writingInProgress + case writingDone + case isClosed + } + + private var state: State = .initialized + private let stateQueue = DispatchQueue(label: "StreamForUpload.StateQueue", qos: .userInitiated) + public var hasStartedWriting: Bool { + stateQueue.sync { state != .initialized } + } + + private var remainingBytes: [UInt8] = [] + private let writingQueue = DispatchQueue(label: "StreamForUpload.WritingQueue", qos: .userInitiated) + + init(bufferLength: Int, sdkContentHandle: Int64, logger: Logger) throws { + var inputOrNil: InputStream? = nil + var outputOrNil: OutputStream? = nil + Stream.getBoundStreams(withBufferSize: bufferLength, + inputStream: &inputOrNil, + outputStream: &outputOrNil) + guard let input = inputOrNil, let output = outputOrNil else { + throw ProtonDriveSDKError(interopError: .wrongResult(message: "Cannot make stream")) + } + self.bufferLength = bufferLength + self.sdkContentHandle = sdkContentHandle + self.logger = logger + self.input = input + self.output = output + self.buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) + super.init() + output.delegate = self + output.schedule(in: RunLoop.main, forMode: .default) + output.open() + } + + public func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + guard aStream == output else { return } + + if eventCode.contains(.hasSpaceAvailable) { + receivedHasSpaceAvailableEvent() + } + + if eventCode.contains(.errorOccurred) { + onStreamError(aStream.streamError ?? ProtonDriveSDKError(interopError: .wrongResult(message: "Stream error"))) + closeAndCleanUp() + } + } + + private func receivedHasSpaceAvailableEvent() { + stateQueue.sync { + switch state { + case .initialized, .writingDone: + state = .isReadyForNextWrite + case .isReadyForNextWrite: + break /* no-op, we already know */ + case .writingInProgress, .isClosed: + break /* ignore, we're not ready to send any more data */ + } + + if state == .isReadyForNextWrite { + state = .writingInProgress + writeToOutputStream() + } + } + } + + private func hasFinishedWriting() { + stateQueue.sync { + switch state { + case .writingInProgress: + state = .writingDone + case .isClosed: + return /* no-op, our stream is not usable for writing anymore */ + case .initialized, .isReadyForNextWrite, .writingDone: + assertionFailure("We should never be in \(state) state when we finish writing") + } + } + } + + private func writeToOutputStream() { + writingQueue.async { [weak self] in + guard let self else { return } + do { + guard self.remainingBytes.isEmpty else { + self.remainingBytes.withUnsafeBufferPointer { buffer in + let bytesWritten = self.output.write(buffer.baseAddress!, maxLength: remainingBytes.count) + if bytesWritten < remainingBytes.count { + // We have bytes in the memory from the last time + // we were writing to the stream. We use them instead of asking the SDK. + // Once all the remaining bytes are written, ask the SDK for more + self.remainingBytes = Array(remainingBytes[bytesWritten...]) + } else { + self.remainingBytes = [] + } + } + hasFinishedWriting() + return + } + + let baseAddress = buffer.baseAddress! + let streamReadRequest = Proton_Sdk_StreamReadRequest.with { + $0.bufferLength = Int32(buffer.count) + $0.bufferPointer = Int64(ObjectHandle(rawPointer: UnsafeRawPointer(baseAddress))) + $0.streamHandle = sdkContentHandle + } + SDKRequestHandler.send(streamReadRequest, logger: logger) { (result: Result) in + switch result { + case .success(let read): + if read == 0 { + self.output.close() + } else { + let bytesWritten = self.output.write(baseAddress, maxLength: Int(read)) + if bytesWritten < Int(read) { + // Keep the remaining, unwritten bytes in the memory. + // On the next .hasSpaceAvailable event, we will write + // these bytes from the memory instead of asking the SDK. + self.remainingBytes = Array(self.buffer[bytesWritten...]) + } + } + case .failure(let error): + self.onStreamError(error) + } + self.hasFinishedWriting() + } + } catch { + onStreamError(error) + hasFinishedWriting() + } + } + } + + private func closeAndCleanUp() { + let shouldClose = stateQueue.sync { + let isAlreadyClosed = self.state == .isClosed + self.state = .isClosed + return !isAlreadyClosed + } + guard shouldClose else { return } + output.close() + input.close() + } + + deinit { + closeAndCleanUp() + buffer.deallocate() + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 1f0b6583..895d54ed 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -11,12 +11,13 @@ public actor ProtonDriveClient: Sendable { private var downloadManager: DownloadManager! private var thumbnailsManager: DownloadThumbnailsManager! - private let logger: ProtonDriveSDK.Logger + let logger: ProtonDriveSDK.Logger private let recordMetricEventCallback: RecordMetricEventCallback private let featureFlagProviderCallback: FeatureFlagProviderCallback let httpClient: HttpClientProtocol let accountClient: AccountClientProtocol + let configuration: ProtonDriveClientConfiguration public init( baseURL: String, @@ -25,6 +26,7 @@ public actor ProtonDriveClient: Sendable { httpClient: HttpClientProtocol, accountClient: AccountClientProtocol, clientUID: String?, + configuration: ProtonDriveClientConfiguration = .default, logCallback: @escaping LogCallback, recordMetricEventCallback: @escaping RecordMetricEventCallback, featureFlagProviderCallback: @escaping FeatureFlagProviderCallback @@ -35,18 +37,22 @@ public actor ProtonDriveClient: Sendable { self.httpClient = httpClient self.accountClient = accountClient + self.configuration = configuration let clientCreateRequest = Proton_Drive_Sdk_DriveClientCreateRequest.with { $0.baseURL = baseURL - $0.httpClientRequestAction = Int64(ObjectHandle(callback: cCompatibleHttpRequest)) - $0.accountClientRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) + $0.accountRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) + + $0.httpClientRequestAction = Int64(ObjectHandle(callback: HttpClientRequestProcessor.cCompatibleHttpRequest)) + $0.httpResponseReadAction = Int64(ObjectHandle(callback: HttpClientResponseProcessor.cCompatibleHttpResponseRead)) + $0.telemetry = Proton_Sdk_Telemetry.with { $0.logAction = Int64(ObjectHandle(callback: cCompatibleLogCallback)) $0.recordMetricAction = Int64(ObjectHandle(callback: cCompatibleTelemetryRecordMetricCallback)) } - $0.featureFlags = Int64(ObjectHandle(callback: cCompatibleFeatureFlagProviderCallback)) + $0.featureEnabledFunction = Int64(ObjectHandle(callback: cCompatibleFeatureFlagProviderCallback)) if let entityCachePath { $0.entityCachePath = entityCachePath @@ -81,9 +87,21 @@ public actor ProtonDriveClient: Sendable { recordMetricEventCallback(metricEvent) } - func isFlagEnabled(_ flagName: String) async -> Bool { - await featureFlagProviderCallback(flagName) - nonisolated func isFlagEnabled(_ flagName: String) async -> Bool { +// nonisolated func isFlagEnabled(_ flagName: String) async -> Bool { +// await featureFlagProviderCallback(flagName) +// } + + nonisolated func isFlagEnabled(_ flagName: String) -> Bool { + // Since the C# callback expects a synchronous return but our Swift callback has completion block, + // we need to block and wait for the async result using a semaphore + let semaphore = DispatchSemaphore(value: 0) + var result = false + featureFlagProviderCallback(flagName) { resultValue in + result = resultValue + semaphore.signal() + } + semaphore.wait() + return result } public func downloadFile( diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift new file mode 100644 index 00000000..3517e31a --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift @@ -0,0 +1,13 @@ +public struct ProtonDriveClientConfiguration: Sendable { + #if os(iOS) + public static let `default` = ProtonDriveClientConfiguration(httpTransferBufferSize: 64 * 1024) + #else + public static let `default` = ProtonDriveClientConfiguration(httpTransferBufferSize: 4 * 1024 * 1024) + #endif + + let httpTransferBufferSize: Int // Used for establishing buffer for http streams + + public init(httpTransferBufferSize: Int) { + self.httpTransferBufferSize = httpTransferBufferSize + } +} diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift index 74f8b135..1f71cc0a 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift @@ -30,7 +30,7 @@ let cCompatibleLogCallback: CCallback = { statePointer, byteArray in return } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) let weakDriveClient = stateTypedPointer.takeUnretainedValue().state guard let driveClient = weakDriveClient.value else { diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift index 5e8204f8..e36200d6 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift @@ -5,7 +5,7 @@ let cCompatibleTelemetryRecordMetricCallback: CCallback = { statePointer, byteAr return } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) let weakDriveClient = stateTypedPointer.takeUnretainedValue().state guard let driveClient = weakDriveClient.value else { From f4b8856f946fd183b642075163f2aafed72a6f5a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 17:11:37 +0100 Subject: [PATCH 320/791] Add more logging to transfer queues --- .../Proton.Drive.Sdk/FifoFlexibleSemaphore.cs | 32 ++++---- .../Nodes/Download/BlockDownloader.cs | 9 +-- .../Nodes/Download/FileDownloader.cs | 30 +++++--- .../Nodes/Download/RevisionReader.cs | 27 ++++--- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 10 ++- .../Nodes/RevisionOperations.cs | 41 ++-------- .../src/Proton.Drive.Sdk/Nodes/RevisionUid.cs | 8 ++ .../Proton.Drive.Sdk/Nodes/TransferQueue.cs | 77 +++++++++++++++++++ .../Nodes/Upload/BlockUploader.cs | 66 ++++++++-------- .../Nodes/Upload/FileUploader.cs | 34 +++++--- .../Nodes/Upload/RevisionWriter.cs | 40 ++++++---- .../Upload/Verification/BlockVerifier.cs | 7 +- .../Verification/BlockVerifierFactory.cs | 6 +- .../Verification/IBlockVerifierFactory.cs | 3 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 3 - 15 files changed, 235 insertions(+), 158 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs index 6c4d44f5..0057321c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs @@ -5,34 +5,34 @@ /// internal sealed class FifoFlexibleSemaphore { - private readonly int _maximumCount; private readonly Queue<(int Increment, TaskCompletionSource TaskCompletionSource)> _waitingQueue = new(); public FifoFlexibleSemaphore(int maximumCount) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCount); - _maximumCount = maximumCount; - CurrentCount = 0; + MaximumCount = maximumCount; + CurrentCount = maximumCount; } + public int MaximumCount { get; } public int CurrentCount { get; private set; } - public ValueTask EnterAsync(int increment, CancellationToken cancellationToken = default) + public ValueTask EnterAsync(int count, CancellationToken cancellationToken = default) { - ArgumentOutOfRangeException.ThrowIfNegative(increment); + ArgumentOutOfRangeException.ThrowIfNegative(count); TaskCompletionSource tcs; lock (_waitingQueue) { - if (CurrentCount < _maximumCount) + if (CurrentCount > 0) { - CurrentCount += increment; + CurrentCount -= count; return ValueTask.CompletedTask; } tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _waitingQueue.Enqueue((increment, tcs)); + _waitingQueue.Enqueue((count, tcs)); } var cancellationTokenRegistration = cancellationToken.Register(() => tcs.TrySetCanceled()); @@ -54,26 +54,26 @@ async ValueTask WaitAsync() } } - public void Release(int decrement) + public void Release(int count) { - ArgumentOutOfRangeException.ThrowIfNegative(decrement); + ArgumentOutOfRangeException.ThrowIfNegative(count); lock (_waitingQueue) { - CurrentCount -= decrement; + CurrentCount += count; - if (CurrentCount < 0) + if (CurrentCount > MaximumCount) { - CurrentCount = 0; + CurrentCount = MaximumCount; } - while (CurrentCount < _maximumCount && _waitingQueue.TryDequeue(out var queuedEntry)) + while (CurrentCount > 0 && _waitingQueue.TryDequeue(out var queuedEntry)) { - var (increment, taskCompletionSource) = queuedEntry; + var (countToDecrement, taskCompletionSource) = queuedEntry; if (taskCompletionSource.TrySetResult()) { - CurrentCount += increment; + CurrentCount -= countToDecrement; } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index e16905c1..f066285b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -11,14 +11,11 @@ internal sealed class BlockDownloader internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) { _client = client; - MaxDegreeOfParallelism = maxDegreeOfParallelism; - BlockSemaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism); - } - public int MaxDegreeOfParallelism { get; } + Queue = new TransferQueue(maxDegreeOfParallelism, client.Telemetry.GetLogger("Block downloader queue")); + } - public SemaphoreSlim FileSemaphore { get; } = new(1, 1); - public SemaphoreSlim BlockSemaphore { get; } + public TransferQueue Queue { get; } public async ValueTask> DownloadAsync( string bareUrl, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 7f4facd4..8af26673 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -6,12 +6,14 @@ public sealed partial class FileDownloader : IDisposable { private readonly ProtonDriveClient _client; private readonly RevisionUid _revisionUid; + private readonly ILogger _logger; private volatile int _remainingNumberOfBlocksToList; - private FileDownloader(ProtonDriveClient client, RevisionUid revisionUid) + private FileDownloader(ProtonDriveClient client, RevisionUid revisionUid, ILogger logger) { _client = client; _revisionUid = revisionUid; + _logger = logger; _remainingNumberOfBlocksToList = 1; } @@ -36,21 +38,25 @@ public void Dispose() internal static async ValueTask CreateAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) { - LogEnteringBlockListingSemaphore(client.Logger, revisionUid, 1); + var logger = client.Telemetry.GetLogger("File downloader"); + + LogAcquiringBlockListingSemaphore(logger, revisionUid, 1); + await client.BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); - LogEnteredBlockListingSemaphore(client.Logger, revisionUid, 1); - return new FileDownloader(client, revisionUid); + LogAcquiredBlockListingSemaphore(logger, revisionUid, 1); + + return new FileDownloader(client, revisionUid, logger); } - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter block listing semaphore for revision {RevisionUid} with {Increment}")] - private static partial void LogEnteringBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int increment); + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire {Count} from block listing semaphore for revision \"{RevisionUid}\"")] + private static partial void LogAcquiringBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int count); - [LoggerMessage(Level = LogLevel.Trace, Message = "Entered block listing semaphore for revision {RevisionUid} with {Increment}")] - private static partial void LogEnteredBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int increment); + [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired {Count} from block listing semaphore for revision \"{RevisionUid}\"")] + private static partial void LogAcquiredBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int count); - [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Decrement} from block listing semaphore for revision {RevisionUid}")] - private static partial void LogReleasedBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int decrement); + [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from block listing semaphore for revision \"{RevisionUid}\"")] + private static partial void LogReleasedBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int count); private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { @@ -81,7 +87,7 @@ private void ReleaseBlockListing(int numberOfBlockListings) 0); _client.BlockListingSemaphore.Release(amountToRelease); - LogReleasedBlockListingSemaphore(_client.Logger, _revisionUid, amountToRelease); + LogReleasedBlockListingSemaphore(_logger, _revisionUid, amountToRelease); } private void ReleaseRemainingBlockListing() @@ -92,7 +98,7 @@ private void ReleaseRemainingBlockListing() } _client.BlockListingSemaphore.Release(_remainingNumberOfBlocksToList); - LogReleasedBlockListingSemaphore(_client.Logger, _revisionUid, _remainingNumberOfBlocksToList); + LogReleasedBlockListingSemaphore(_logger, _revisionUid, _remainingNumberOfBlocksToList); _remainingNumberOfBlocksToList = 0; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 4297e266..3d8f9cff 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk.Nodes.Download; -internal sealed class RevisionReader : IDisposable +internal sealed partial class RevisionReader : IDisposable { public const int MinBlockIndex = 1; public const int DefaultBlockPageSize = 10; @@ -19,6 +19,7 @@ internal sealed class RevisionReader : IDisposable private readonly Action _releaseBlockListingAction; private readonly Action _releaseFileSemaphoreAction; private readonly int _blockPageSize; + private readonly ILogger _logger; private bool _fileSemaphoreReleased; @@ -43,11 +44,12 @@ internal RevisionReader( _releaseBlockListingAction = releaseBlockListingAction; _releaseFileSemaphoreAction = releaseFileSemaphoreAction; _blockPageSize = blockPageSize; + _logger = client.Telemetry.GetLogger("Revision reader"); } public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { - var downloadTasks = new Queue>(_client.BlockDownloader.MaxDegreeOfParallelism); + var downloadTasks = new Queue>(_client.BlockDownloader.Queue.Depth); var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); await using (manifestStream) @@ -66,7 +68,7 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action { await foreach (var (block, _) in GetBlocksAsync(cancellationToken).ConfigureAwait(false)) { - if (!await _client.BlockDownloader.BlockSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + if (!_client.BlockDownloader.Queue.TryStartBlock()) { if (downloadTasks.Count > 0) { @@ -74,7 +76,7 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt .ConfigureAwait(false); } - await _client.BlockDownloader.BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } var downloadTask = DownloadBlockAsync(block, contentOutputStream, cancellationToken); @@ -101,7 +103,7 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt } finally { - _client.BlockDownloader.BlockSemaphore.Release(downloadTasks.Count); + _client.BlockDownloader.Queue.FinishBlocks(downloadTasks.Count); } throw; @@ -113,10 +115,7 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt if (manifestVerificationStatus is not PgpVerificationStatus.Ok) { - _client.Logger.LogError( - "Manifest verification failed for file with UID \"{FileUid}\": {VerificationStatus}", - _fileUid, - manifestVerificationStatus); + LogFailedManifestVerification(_fileUid, manifestVerificationStatus); throw new ProtonDriveException("File authenticity check failed"); } @@ -171,7 +170,7 @@ private async Task WriteNextBlockToOutputAsync( } finally { - _client.BlockDownloader.BlockSemaphore.Release(); + _client.BlockDownloader.Queue.FinishBlocks(1); } } @@ -236,7 +235,7 @@ private async Task DownloadBlockAsync(BlockDto block, Strea if (block.Index != nextExpectedIndex) { - _client.Logger.LogError("Missing block #{BlockIndex} on file with UID \"{FileUid}\"", block.Index, _fileUid); + LogMissingBlock(block.Index, _fileUid); throw new ProtonDriveException("File contents are incomplete"); } @@ -296,6 +295,12 @@ private async Task VerifyManifestAsync(Stream manifestStr return verificationResult.Status; } + [LoggerMessage(Level = LogLevel.Trace, Message = "Missing block #{BlockIndex} on file with UID \"{FileUid}\"")] + private partial void LogMissingBlock(int blockIndex, NodeUid fileUid); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Manifest verification failed for file with UID \"{FileUid}\": {VerificationStatus}")] + private partial void LogFailedManifestVerification(NodeUid fileUid, PgpVerificationStatus verificationStatus); + private readonly struct BlockDownloadResult(int index, Stream stream, bool isIntermediateStream, ReadOnlyMemory sha256Digest) { public int Index { get; } = index; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 96baae66..e48d32db 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -30,6 +30,8 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( ThumbnailType thumbnailType, [EnumeratorCancellation] CancellationToken cancellationToken) { + var logger = client.Telemetry.GetLogger("Thumbnail enumeration"); + // TODO: optimize parallelization for when UIDs are scattered over many volumes foreach (var volumeLinkIdGroup in fileUids.GroupBy(uid => uid.VolumeId, uid => uid.LinkId)) { @@ -45,7 +47,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( var thumbnails = fileNode!.ActiveRevision.Thumbnails; if (thumbnails.Count == 0) { - LogNoThumbnailOnNode(client.Logger, fileNode.Uid); + LogNoThumbnailOnNode(logger, fileNode.Uid); } return thumbnails @@ -68,14 +70,14 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( { var fileNode = thumbnailIds[block.ThumbnailId]; - if (!await client.ThumbnailBlockDownloader.BlockSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + if (!client.ThumbnailBlockDownloader.Queue.TryStartBlock()) { if (tasks.Count > 0) { yield return await tasks.Dequeue().ConfigureAwait(false); } - await client.ThumbnailBlockDownloader.BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + await client.ThumbnailBlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } tasks.Enqueue(DownloadThumbnailAsync(client, fileNode.Uid, block, cancellationToken)); @@ -113,7 +115,7 @@ await client.ThumbnailBlockDownloader.DownloadAsync(block.BareUrl, block.Token, } finally { - client.ThumbnailBlockDownloader.BlockSemaphore.Release(); + client.ThumbnailBlockDownloader.Queue.FinishBlocks(1); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index 99a6060b..11f5b018 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -1,5 +1,4 @@ -using Microsoft.Extensions.Logging; -using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk; @@ -54,9 +53,7 @@ public static async ValueTask OpenForWritingAsync( var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - LogEnteringFileUploadSemaphore(client.Logger, revisionUid); - await client.BlockUploader.FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - LogEnteredFileUploadSemaphore(client.Logger, revisionUid); + await client.BlockUploader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); return new RevisionWriter( client, @@ -66,11 +63,7 @@ public static async ValueTask OpenForWritingAsync( signingKey, membershipAddress, releaseBlocksAction, - () => - { - var previousCount = client.BlockUploader.FileSemaphore.Release(); - LogReleasedFileUploadSemaphore(client.Logger, revisionUid, previousCount); - }, + () => client.BlockUploader.Queue.FinishFile(), client.TargetBlockSize, client.MaxBlockSize); } @@ -94,9 +87,7 @@ internal static async ValueTask OpenForReadingAsync( withoutBlockUrls: false, cancellationToken).ConfigureAwait(false); - LogEnteringFileDownloadSemaphore(client.Logger, revisionUid); - await client.BlockDownloader.FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - LogEnteredFileDownloadSemaphore(client.Logger, revisionUid); + await client.BlockDownloader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); return new RevisionReader( client, @@ -105,28 +96,6 @@ internal static async ValueTask OpenForReadingAsync( fileSecrets.ContentKey, revisionResponse.Revision, releaseBlockListingAction, - () => - { - var previousCount = client.BlockDownloader.FileSemaphore.Release(); - LogReleasedFileDownloadSemaphore(client.Logger, revisionUid, previousCount); - }); + () => client.BlockDownloader.Queue.FinishFile()); } - - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter file upload semaphore for revision {RevisionUid}")] - private static partial void LogEnteringFileUploadSemaphore(ILogger logger, RevisionUid revisionUid); - - [LoggerMessage(Level = LogLevel.Trace, Message = "Entered file upload semaphore for revision {RevisionUid}")] - private static partial void LogEnteredFileUploadSemaphore(ILogger logger, RevisionUid revisionUid); - - [LoggerMessage(Level = LogLevel.Trace, Message = "Releasing file upload semaphore for revision {RevisionUid}, previous count = {PreviousCount}")] - private static partial void LogReleasedFileUploadSemaphore(ILogger logger, RevisionUid revisionUid, int previousCount); - - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter file download semaphore for revision {RevisionUid}")] - private static partial void LogEnteringFileDownloadSemaphore(ILogger logger, RevisionUid revisionUid); - - [LoggerMessage(Level = LogLevel.Trace, Message = "Entered file download semaphore for revision {RevisionUid}")] - private static partial void LogEnteredFileDownloadSemaphore(ILogger logger, RevisionUid revisionUid); - - [LoggerMessage(Level = LogLevel.Trace, Message = "Releasing file download semaphore for revision {RevisionUid}, previous count = {PreviousCount}")] - private static partial void LogReleasedFileDownloadSemaphore(ILogger logger, RevisionUid revisionUid, int previousCount); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs index 01d32b79..d779bc50 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs @@ -1,7 +1,9 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; namespace Proton.Drive.Sdk.Nodes; @@ -14,6 +16,12 @@ internal RevisionUid(NodeUid nodeUid, RevisionId revisionId) RevisionId = revisionId; } + internal RevisionUid(VolumeId volumeId, LinkId linkId, RevisionId revisionId) + { + NodeUid = new NodeUid(volumeId, linkId); + RevisionId = revisionId; + } + internal NodeUid NodeUid { get; } internal RevisionId RevisionId { get; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs new file mode 100644 index 00000000..7a0f3ebd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Logging; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed partial class TransferQueue(int maxDegreeOfParallelism, ILogger logger) +{ + private readonly ILogger _logger = logger; + + public SemaphoreSlim FileSemaphore { get; } = new(1, 1); + public SemaphoreSlim BlockSemaphore { get; } = new(maxDegreeOfParallelism, maxDegreeOfParallelism); + + public int Depth { get; } = maxDegreeOfParallelism; + + public async ValueTask StartFileAsync(CancellationToken cancellationToken) + { + LogAcquiringFileSemaphore(FileSemaphore.CurrentCount); + + await FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + LogAcquiredFileSemaphore(FileSemaphore.CurrentCount); + } + + public void FinishFile() + { + FileSemaphore.Release(); + + LogReleasedFileSemaphore(FileSemaphore.CurrentCount); + } + + public bool TryStartBlock() + { + LogAcquiringBlockSemaphore(BlockSemaphore.CurrentCount); + + var result = BlockSemaphore.Wait(0); + + if (result) + { + LogAcquiredBlockSemaphore(BlockSemaphore.CurrentCount); + } + + return result; + } + + public async ValueTask StartBlockAsync(CancellationToken cancellationToken) + { + LogAcquiringBlockSemaphore(BlockSemaphore.CurrentCount); + + await BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + LogAcquiredBlockSemaphore(BlockSemaphore.CurrentCount); + } + + public void FinishBlocks(int count) + { + BlockSemaphore.Release(count); + + LogReleasedBlockSemaphore(count, BlockSemaphore.CurrentCount); + } + + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire file semaphore, current count is {CurrentCount}")] + private partial void LogAcquiringFileSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired file semaphore, current count is {CurrentCount}")] + private partial void LogAcquiredFileSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Released file semaphore, current count is {CurrentCount}")] + private partial void LogReleasedFileSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire block semaphore, current count is {CurrentCount}")] + private partial void LogAcquiringBlockSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired block semaphore, current count is {CurrentCount}")] + private partial void LogAcquiredBlockSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from block semaphore, current count is {CurrentCount}")] + private partial void LogReleasedBlockSemaphore(int count, int currentCount); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 7cd55ca0..93216102 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -15,25 +15,23 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -internal sealed class BlockUploader +internal sealed partial class BlockUploader { private readonly ProtonDriveClient _client; + private readonly ILogger _logger; internal BlockUploader(ProtonDriveClient client, int maxDegreeOfParallelism) { _client = client; - MaxDegreeOfParallelism = maxDegreeOfParallelism; - BlockSemaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism); - } + _logger = client.Telemetry.GetLogger("Block uploader"); - public int MaxDegreeOfParallelism { get; } + Queue = new TransferQueue(maxDegreeOfParallelism, client.Telemetry.GetLogger("Block uploader queue")); + } - public SemaphoreSlim FileSemaphore { get; } = new(1, 1); - public SemaphoreSlim BlockSemaphore { get; } + public TransferQueue Queue { get; } public async Task UploadContentAsync( - NodeUid fileUid, - RevisionId revisionId, + RevisionUid revisionUid, int index, PgpSessionKey contentKey, PgpPrivateKey signingKey, @@ -107,9 +105,9 @@ public async Task UploadContentAsync( var request = new BlockUploadPreparationRequest { - VolumeId = fileUid.VolumeId, - LinkId = fileUid.LinkId, - RevisionId = revisionId, + VolumeId = revisionUid.NodeUid.VolumeId, + LinkId = revisionUid.NodeUid.LinkId, + RevisionId = revisionUid.RevisionId, AddressId = membershipAddressId, Blocks = [ @@ -129,11 +127,7 @@ public async Task UploadContentAsync( onBlockProgress?.Invoke(plainDataLength); - _client.Logger.LogDebug( - "Uploaded blob for block #{BlockIndex} for revision {RevisionId} of file {FileUid}", - index, - revisionId, - fileUid); + LogContentBlobUploaded(index, revisionUid); return sha256Digest; } @@ -143,7 +137,7 @@ public async Task UploadContentAsync( { try { - BlockSemaphore.Release(); + Queue.FinishBlocks(1); } finally { @@ -158,8 +152,7 @@ public async Task UploadContentAsync( } public async Task UploadThumbnailAsync( - NodeUid fileUid, - RevisionId revisionId, + RevisionUid revisionUid, PgpSessionKey contentKey, PgpPrivateKey signingKey, AddressId membershipAddressId, @@ -190,9 +183,9 @@ public async Task UploadThumbnailAsync( var request = new BlockUploadPreparationRequest { - VolumeId = fileUid.VolumeId, - LinkId = fileUid.LinkId, - RevisionId = revisionId, + VolumeId = revisionUid.NodeUid.VolumeId, + LinkId = revisionUid.NodeUid.LinkId, + RevisionId = revisionUid.RevisionId, AddressId = membershipAddressId, Blocks = [], Thumbnails = @@ -208,7 +201,7 @@ public async Task UploadThumbnailAsync( await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); - _client.Logger.LogDebug("Uploaded thumbnail blob for revision {RevisionId} of node {FileUid}", revisionId, fileUid); + LogThumbnailBlobUploaded(revisionUid); return sha256Digest; } @@ -217,11 +210,11 @@ public async Task UploadThumbnailAsync( { try { - _client.RevisionCreationSemaphore.Release(1); + Queue.FinishBlocks(1); } finally { - BlockSemaphore.Release(1); + _client.RevisionCreationSemaphore.Release(1); } } } @@ -255,13 +248,9 @@ await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Tok } catch (Exception e) when ((UrlExpired(e) || BlobAlreadyUploaded(e)) && remainingNumberOfAttempts >= 2) { - _client.Logger.LogWarning( - e, - "Blob upload failed for block #{BlockIndex} for revision {RevisionId} of file {FileUid} (remaining attempts: {RemainingAttempts}", - request.Blocks[0].Index, - request.RevisionId, - new NodeUid(request.VolumeId, request.LinkId), - remainingNumberOfAttempts); + var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); + + LogBlobUploadFailure(e, request.Blocks[0].Index, revisionUid, remainingNumberOfAttempts); --remainingNumberOfAttempts; } @@ -278,4 +267,15 @@ await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Tok // causing the back-end to reject the upload with this error. static bool BlobAlreadyUploaded(Exception e) => e is ProtonApiException { Code: ResponseCode.AlreadyExists }; } + + [LoggerMessage(Level = LogLevel.Trace, Message = "Uploaded blob for content block #{BlockIndex} for revision \"{RevisionUid}\"")] + private partial void LogContentBlobUploaded(int blockIndex, RevisionUid revisionUid); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Uploaded blob for thumbnail block of revision \"{RevisionUid}\"")] + private partial void LogThumbnailBlobUploaded(RevisionUid revisionUid); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Blob upload failed for block #{BlockIndex} for revision \"{RevisionUid}\" (remaining attempts: {RemainingAttempts}")] + private partial void LogBlobUploadFailure(Exception exception, int blockIndex, RevisionUid revisionUid, int remainingAttempts); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 8aeed8e2..97e2a144 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -9,6 +9,8 @@ public sealed partial class FileUploader : IDisposable private readonly IFileDraftProvider _fileDraftProvider; private readonly DateTimeOffset? _lastModificationTime; private readonly IEnumerable? _additionalMetadata; + private readonly ILogger _logger; + private volatile int _remainingNumberOfBlocks; private FileUploader( @@ -17,7 +19,8 @@ private FileUploader( long size, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, - int expectedNumberOfBlocks) + int expectedNumberOfBlocks, + ILogger logger) { _client = client; _fileDraftProvider = fileDraftProvider; @@ -25,6 +28,7 @@ private FileUploader( _lastModificationTime = lastModificationTime; _additionalMetadata = additionalMetadata; _remainingNumberOfBlocks = expectedNumberOfBlocks; + _logger = logger; } internal long FileSize { get; } @@ -64,23 +68,27 @@ internal static async ValueTask CreateAsync( IEnumerable? additionalExtendedAttributes, CancellationToken cancellationToken) { + var logger = client.Telemetry.GetLogger("File uploader"); + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); - LogEnteringRevisionCreationSemaphore(client.Logger, expectedNumberOfBlocks); + LogAcquiringRevisionCreationSemaphore(logger, expectedNumberOfBlocks); + await client.RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); - LogEnteredRevisionCreationSemaphore(client.Logger, expectedNumberOfBlocks); - return new FileUploader(client, fileDraftProvider, size, lastModificationTime, additionalExtendedAttributes, expectedNumberOfBlocks); + LogAcquiredRevisionCreationSemaphore(logger, expectedNumberOfBlocks); + + return new FileUploader(client, fileDraftProvider, size, lastModificationTime, additionalExtendedAttributes, expectedNumberOfBlocks, logger); } - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter revision creation semaphore with {Increment}")] - private static partial void LogEnteringRevisionCreationSemaphore(ILogger logger, int increment); + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire {Count} from revision creation semaphore")] + private static partial void LogAcquiringRevisionCreationSemaphore(ILogger logger, int count); - [LoggerMessage(Level = LogLevel.Trace, Message = "Entered revision creation semaphore with {Increment}")] - private static partial void LogEnteredRevisionCreationSemaphore(ILogger logger, int increment); + [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired {Count} from revision creation semaphore")] + private static partial void LogAcquiredRevisionCreationSemaphore(ILogger logger, int count); - [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Decrement} from revision creation semaphore")] - private static partial void LogReleasedRevisionCreationSemaphore(ILogger logger, int decrement); + [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from revision creation semaphore")] + private static partial void LogReleasedRevisionCreationSemaphore(ILogger logger, int count); private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromStreamAsync( Stream contentStream, @@ -170,7 +178,8 @@ private void ReleaseBlocks(int numberOfBlocks) var amountToRelease = Math.Max(newRemainingNumberOfBlocks >= 0 ? numberOfBlocks : newRemainingNumberOfBlocks + numberOfBlocks, 0); _client.RevisionCreationSemaphore.Release(amountToRelease); - LogReleasedRevisionCreationSemaphore(_client.Logger, amountToRelease); + + LogReleasedRevisionCreationSemaphore(_logger, amountToRelease); } private void ReleaseRemainingBlocks() @@ -181,7 +190,8 @@ private void ReleaseRemainingBlocks() } _client.RevisionCreationSemaphore.Release(_remainingNumberOfBlocks); - LogReleasedRevisionCreationSemaphore(_client.Logger, _remainingNumberOfBlocks); + + LogReleasedRevisionCreationSemaphore(_logger, _remainingNumberOfBlocks); _remainingNumberOfBlocks = 0; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index d62c9168..e756c044 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -12,19 +12,19 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -internal sealed class RevisionWriter : IDisposable +internal sealed partial class RevisionWriter : IDisposable { public const int DefaultBlockSize = 1 << 22; // 4 MiB private readonly ProtonDriveClient _client; - private readonly NodeUid _fileUid; - private readonly RevisionId _revisionId; + private readonly RevisionUid _revisionUid; private readonly PgpPrivateKey _fileKey; private readonly PgpSessionKey _contentKey; private readonly PgpPrivateKey _signingKey; private readonly Address _membershipAddress; private readonly Action _releaseBlocksAction; private readonly Action _releaseFileSemaphoreAction; + private readonly ILogger _logger; private readonly int _targetBlockSize; private readonly int _maxBlockSize; @@ -44,7 +44,7 @@ internal RevisionWriter( int maxBlockSize = DefaultBlockSize) { _client = client; - (_fileUid, _revisionId) = revisionUid; + _revisionUid = revisionUid; _fileKey = fileKey; _contentKey = contentKey; _signingKey = signingKey; @@ -53,6 +53,7 @@ internal RevisionWriter( _releaseFileSemaphoreAction = releaseFileSemaphoreAction; _targetBlockSize = targetBlockSize; _maxBlockSize = maxBlockSize; + _logger = client.Telemetry.GetLogger("Revision writer"); } public async ValueTask WriteAsync( @@ -77,7 +78,7 @@ public async ValueTask WriteAsync( var signingEmailAddress = _membershipAddress.EmailAddress; - var uploadTasks = new Queue>(_client.BlockUploader.MaxDegreeOfParallelism); + var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); var blockIndex = 0; ArraySegment manifestSignature; @@ -94,7 +95,7 @@ public async ValueTask WriteAsync( await using (manifestStream.ConfigureAwait(false)) { - var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_fileUid, _revisionId, _fileKey, cancellationToken) + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_revisionUid, _fileKey, cancellationToken) .ConfigureAwait(false); using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -109,8 +110,7 @@ public async ValueTask WriteAsync( await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); var uploadTask = _client.BlockUploader.UploadThumbnailAsync( - _fileUid, - _revisionId, + _revisionUid, _contentKey, _signingKey, _membershipAddress.Id, @@ -148,8 +148,7 @@ await TryGetBlockPlainDataStreamAsync( : default(Action?); var uploadTask = _client.BlockUploader.UploadContentAsync( - _fileUid, - _revisionId, + _revisionUid, ++blockIndex, _contentKey, _signingKey, @@ -213,11 +212,16 @@ await TryGetBlockPlainDataStreamAsync( signingEmailAddress, additionalMetadata); - _client.Logger.LogDebug("Sealing revision {RevisionId} of file {FileUid}", _revisionId, _fileUid); + LogSealingRevision(_revisionUid); - await _client.Api.Files.UpdateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, _revisionId, request, cancellationToken).ConfigureAwait(false); + await _client.Api.Files.UpdateRevisionAsync( + _revisionUid.NodeUid.VolumeId, + _revisionUid.NodeUid.LinkId, + _revisionUid.RevisionId, + request, + cancellationToken).ConfigureAwait(false); - _client.Logger.LogDebug("Revision {RevisionId} of file {FileUid} sealed", _revisionId, _fileUid); + LogRevisionSealed(_revisionUid); } catch (Exception ex) { @@ -311,17 +315,23 @@ private static async ValueTask AddNextBlockToManifestAsync(Queue> u private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTasks, RecyclableMemoryStream manifestStream, CancellationToken cancellationToken) { - if (!await _client.BlockUploader.BlockSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + if (!_client.BlockUploader.Queue.TryStartBlock()) { if (uploadTasks.Count > 0) { await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); } - await _client.BlockUploader.BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + await _client.BlockUploader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } } + [LoggerMessage(Level = LogLevel.Debug, Message = "Sealing revision \"{RevisionUid}\"")] + private partial void LogSealingRevision(RevisionUid revisionUid); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Revision \"{RevisionUid}\" sealed")] + private partial void LogRevisionSealed(RevisionUid revisionUid); + private RevisionUpdateRequest GetRevisionUpdateRequest( DateTimeOffset? lastModificationTime, IReadOnlyList blockSizes, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs index f2d39699..0947d385 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs @@ -1,7 +1,6 @@ using CommunityToolkit.HighPerformance; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.BlockVerification; -using Proton.Drive.Sdk.Api.Files; namespace Proton.Drive.Sdk.Nodes.Upload.Verification; @@ -22,13 +21,13 @@ private BlockVerifier(PgpSessionKey sessionKey, ReadOnlyMemory verificatio public static async ValueTask CreateAsync( IBlockVerificationApiClient apiClient, - NodeUid fileUid, - RevisionId revisionId, + RevisionUid revisionUid, PgpPrivateKey key, CancellationToken cancellationToken) { var verificationInput = - await apiClient.GetVerificationInputAsync(fileUid.VolumeId, fileUid.LinkId, revisionId, cancellationToken).ConfigureAwait(false); + await apiClient.GetVerificationInputAsync(revisionUid.NodeUid.VolumeId, revisionUid.NodeUid.LinkId, revisionUid.RevisionId, cancellationToken) + .ConfigureAwait(false); PgpSessionKey sessionKey; try diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs index 32d4d9e7..2a0baefb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs @@ -1,6 +1,5 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.BlockVerification; -using Proton.Drive.Sdk.Api.Files; namespace Proton.Drive.Sdk.Nodes.Upload.Verification; @@ -9,11 +8,10 @@ internal sealed class BlockVerifierFactory(HttpClient httpClient) : IBlockVerifi private readonly IBlockVerificationApiClient _apiClient = new BlockVerificationApiClient(httpClient); public async ValueTask CreateAsync( - NodeUid fileUid, - RevisionId revisionId, + RevisionUid revisionUid, PgpPrivateKey key, CancellationToken cancellationToken) { - return await BlockVerifier.CreateAsync(_apiClient, fileUid, revisionId, key, cancellationToken).ConfigureAwait(false); + return await BlockVerifier.CreateAsync(_apiClient, revisionUid, key, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs index 849c66c8..c7c2ef5a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs @@ -1,9 +1,8 @@ using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk.Api.Files; namespace Proton.Drive.Sdk.Nodes.Upload.Verification; internal interface IBlockVerifierFactory { - ValueTask CreateAsync(NodeUid fileUid, RevisionId revisionId, PgpPrivateKey key, CancellationToken cancellationToken); + ValueTask CreateAsync(RevisionUid revisionUid, PgpPrivateKey key, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 2c1639a4..869989fb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.Logging; using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api; @@ -73,7 +72,6 @@ internal ProtonDriveClient( Cache = cache; BlockVerifierFactory = blockVerifierFactory; Telemetry = telemetry; - Logger = telemetry.GetLogger(); FeatureFlagProvider = featureFlagProvider; var maxDegreeOfBlockTransferParallelism = Math.Max( @@ -119,7 +117,6 @@ private ProtonDriveClient( internal IDriveClientCache Cache { get; } internal IBlockVerifierFactory BlockVerifierFactory { get; } internal ITelemetry Telemetry { get; } - internal ILogger Logger { get; } internal IFeatureFlagProvider FeatureFlagProvider { get; } internal FifoFlexibleSemaphore RevisionCreationSemaphore { get; } From 865e28e28cb81e929d35089db5655dd0a6d071f1 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 11:07:08 +0100 Subject: [PATCH 321/791] Add proguard rules to keep protobuf classes to be optimized --- kt/sdk/build.gradle.kts | 3 +++ kt/sdk/proguard-rules.pro | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 kt/sdk/proguard-rules.pro diff --git a/kt/sdk/build.gradle.kts b/kt/sdk/build.gradle.kts index 111dae02..d44e53a5 100644 --- a/kt/sdk/build.gradle.kts +++ b/kt/sdk/build.gradle.kts @@ -33,6 +33,9 @@ android { arguments("BUILD_DIR=${layout.buildDirectory.asFile.get().path}") } } + defaultConfig { + consumerProguardFiles("proguard-rules.pro") + } } sourceSets { getByName("main") { diff --git a/kt/sdk/proguard-rules.pro b/kt/sdk/proguard-rules.pro new file mode 100644 index 00000000..2d86e78d --- /dev/null +++ b/kt/sdk/proguard-rules.pro @@ -0,0 +1,4 @@ +-keep class com.google.protobuf.** { *; } +-dontwarn com.google.protobuf.** +-keep class proton.sdk.** { *; } +-keep class proton.drive.sdk.** { *; } From b3de3a5cd014a1e30e3e1b0135970888e9cd4f04 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 12:43:57 +0100 Subject: [PATCH 322/791] Close properly response body when read --- .../main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index ffe8bf8d..cfab385f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -22,7 +22,8 @@ class JniHttpStream internal constructor( name = method("write"), readHttpBody = { buffer -> Channels.newChannel(inputStream).read(buffer).also { numberOfByteRead -> - if (numberOfByteRead == 0) { + if (numberOfByteRead == -1) { + inputStream.close() onBodyRead?.invoke() } } From 1965e57d6f3d992529feaeb9e68fb6e2f56b3313 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 10:34:29 +0100 Subject: [PATCH 323/791] Add hint to disable retries on HTTP requests --- .../InteropProtonDriveClient.cs | 12 ++++++++++-- .../Api/Storage/StorageApiClient.cs | 6 +++--- cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs | 3 ++- .../InteropHttpClientFactory.cs | 2 +- .../Http/HttpRequestMessageExtensions.cs | 14 ++++++++++++++ cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs | 6 ++++++ .../ProtonClientConfigurationExtensions.cs | 5 +---- cs/sdk/src/protos/proton.sdk.proto | 1 + 8 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index f86c8644..94fea142 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -39,11 +39,19 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi ? new DriveInteropTelemetryDecorator(interopTelemetry) : NullTelemetry.Instance; - IFeatureFlagProvider featureFlagProvider = request.HasFeatureEnabledFunction + var featureFlagProvider = request.HasFeatureEnabledFunction ? new InteropFeatureFlagProvider(bindingsHandle, new InteropFunction, int>(request.FeatureEnabledFunction)) : AlwaysDisabledFeatureFlagProvider.Instance; - var client = new ProtonDriveClient(httpClientFactory, accountClient, entityCacheRepository, secretCacheRepository, featureFlagProvider, telemetry, request.Uid); + var client = new ProtonDriveClient( + httpClientFactory, + accountClient, + entityCacheRepository, + secretCacheRepository, + featureFlagProvider, + telemetry, + request.BindingsLanguage, + request.Uid); return new Int64Value { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index f56b3f92..2d852a32 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -27,19 +27,19 @@ public async ValueTask UploadBlobAsync( using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, baseUrl, multipartContent); requestMessage.Headers.Add("pm-storage-token", token); + requestMessage.DisableRetry(); // TODO: investigate what happens with the stream in case of a retry after a failure, is there a seek back to its beginning? - var response = await _httpClient + return await _httpClient .Expecting(ProtonApiSerializerContext.Default.ApiResponse) .SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); - - return response; } public async ValueTask GetBlobStreamAsync(string baseUrl, string token, CancellationToken cancellationToken) { using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Get, baseUrl); requestMessage.Headers.Add("pm-storage-token", token); + requestMessage.DisableRetry(); var blobResponse = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 869989fb..aa2ee7b4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -45,9 +45,10 @@ public ProtonDriveClient( ICacheRepository secretCacheRepository, IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, + string? bindingsLanguage = null, string? uid = null) : this( - new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClient(), + new SdkHttpClientFactoryDecorator(httpClientFactory, bindingsLanguage).CreateClient(), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), featureFlagProvider, diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index a2bbfc7e..be259bcc 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -67,7 +67,7 @@ private static async ValueTask ConvertHttpRequestToInteropAsync(Htt { var url = request.RequestUri?.AbsoluteUri ?? throw new InvalidOperationException($"Missing URL for HTTP request: {request.RequestUri}"); - var interopHttpRequest = new HttpRequest { Url = url, Method = request.Method.Method }; + var interopHttpRequest = new HttpRequest { Url = url, Method = request.Method.Method, DisableRetry = request.GetRetryIsDisabled() }; var headers = request.Headers.AsEnumerable(); diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageExtensions.cs new file mode 100644 index 00000000..7e357dd3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageExtensions.cs @@ -0,0 +1,14 @@ +namespace Proton.Sdk.Http; + +internal static class HttpRequestMessageExtensions +{ + public static void DisableRetry(this HttpRequestMessage requestMessage) + { + requestMessage.Options.Set(HttpRequestOptions.DisableRetryKey, true); + } + + public static bool GetRetryIsDisabled(this HttpRequestMessage requestMessage) + { + return requestMessage.Options.TryGetValue(HttpRequestOptions.DisableRetryKey, out var isDisabled) && isDisabled; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs new file mode 100644 index 00000000..4c4c53fc --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Http; + +public static class HttpRequestOptions +{ + public static readonly HttpRequestOptionsKey DisableRetryKey = new("DisableRetry"); +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index cf405cd8..a20d3b6a 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -1,7 +1,6 @@ using System.Net; using System.Reflection; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Resilience; using Polly; using Proton.Sdk.Authentication; using Proton.Sdk.Http; @@ -74,15 +73,13 @@ public static HttpClient GetHttpClient( options.CircuitBreaker.SamplingDuration = options.AttemptTimeout.Timeout * 2; } + options.Retry.ShouldHandle += arguments => ValueTask.FromResult(arguments.Context.GetRequestMessage()?.GetRetryIsDisabled() != true); options.Retry.ShouldRetryAfterHeader = true; options.Retry.Delay = TimeSpan.FromSeconds(2); options.Retry.BackoffType = DelayBackoffType.Exponential; options.Retry.UseJitter = true; options.Retry.MaxRetryAttempts = 1; - var totalTimeout = (options.AttemptTimeout.Timeout + options.Retry.Delay) * options.Retry.MaxRetryAttempts * 1.5; - options.TotalRequestTimeout = new HttpTimeoutStrategyOptions { Timeout = totalTimeout }; - options.CircuitBreaker.FailureRatio = 0.5; }); diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto index 13f1a246..7ddacb30 100644 --- a/cs/sdk/src/protos/proton.sdk.proto +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -234,6 +234,7 @@ message HttpRequest { string method = 2; repeated HttpHeader headers = 3; int64 sdk_content_handle = 4; // Optional, to be used with StreamReadRequest + bool disable_retry = 5; } message HttpResponse { From 7cd8c6b701379282c9f7483bf06139d7ee398e1d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 16:39:31 +0100 Subject: [PATCH 324/791] Upgrade version from 0.4.0 to 0.5.0 --- kt/build.gradle.kts | 2 +- .../proton/drive/sdk/extension/Throwable.kt | 25 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/kt/build.gradle.kts b/kt/build.gradle.kts index a234e2c4..29c92590 100644 --- a/kt/build.gradle.kts +++ b/kt/build.gradle.kts @@ -40,7 +40,7 @@ allprojects { } } group = "me.proton.drive" - version = "0.4.0" + version = "0.5.0" afterEvaluate { configurations.all { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt index b6786083..e695b13e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -9,18 +9,19 @@ fun Throwable.toProtonSdkError(defaultMessage: String) = proton.sdk.error { val exception = this@toProtonSdkError type = javaClass.name this.message = exception.message ?: defaultMessage - domain = when (exception) { - is CancellationException -> ProtonSdk.ErrorDomain.SuccessfulCancellation - is ApiException -> { - when (exception.error) { - is ApiResult.Error.Http -> ProtonSdk.ErrorDomain.Api - is ApiResult.Error.Timeout -> ProtonSdk.ErrorDomain.Transport - is ApiResult.Error.Connection -> ProtonSdk.ErrorDomain.Network - is ApiResult.Error.Parse -> ProtonSdk.ErrorDomain.Serialization - } - } + domain = exception.domain() + context = stackTraceToString() +} + +private fun Throwable.domain(): ProtonSdk.ErrorDomain = when (this) { + is CancellationException -> ProtonSdk.ErrorDomain.SuccessfulCancellation - else -> ProtonSdk.ErrorDomain.Undefined + is ApiException -> when (error) { + is ApiResult.Error.Http -> ProtonSdk.ErrorDomain.Api + is ApiResult.Error.Timeout -> ProtonSdk.ErrorDomain.Transport + is ApiResult.Error.Connection -> ProtonSdk.ErrorDomain.Network + is ApiResult.Error.Parse -> ProtonSdk.ErrorDomain.Serialization } - context = stackTraceToString() + + else -> ProtonSdk.ErrorDomain.Undefined } From 01a001a9202541812f7dc1a492ba95a94df6c907 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 09:33:25 +0100 Subject: [PATCH 325/791] Delay opening upload stream until necessery --- .../ProtonDriveClient/Networking/Model/StreamForUpload.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift index 4128b15a..34d5e9cd 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift @@ -44,6 +44,9 @@ public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendabl self.output = output self.buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) super.init() + } + + public func openOutputStream() { output.delegate = self output.schedule(in: RunLoop.main, forMode: .default) output.open() From 66e671e7336b57b5519983ac8a6ff2fcf00988a5 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 10:59:04 +0100 Subject: [PATCH 326/791] Replace option to disable HTTP retries with a request type --- .../Api/Storage/StorageApiClient.cs | 4 ++-- .../InteropHttpClientFactory.cs | 2 +- cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs | 14 ++++++-------- .../Http/HttpRequestMessageExtensions.cs | 8 ++++---- .../src/Proton.Sdk/Http/HttpRequestOptionKeys.cs | 6 ++++++ cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs | 6 ------ cs/sdk/src/Proton.Sdk/Http/HttpRequestType.cs | 8 ++++++++ .../ProtonClientConfigurationExtensions.cs | 3 ++- cs/sdk/src/protos/proton.sdk.proto | 16 +++++++++++----- 9 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpRequestOptionKeys.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Http/HttpRequestType.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index 2d852a32..5af20b9e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -27,7 +27,7 @@ public async ValueTask UploadBlobAsync( using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, baseUrl, multipartContent); requestMessage.Headers.Add("pm-storage-token", token); - requestMessage.DisableRetry(); + requestMessage.SetRequestType(HttpRequestType.StorageUpload); // TODO: investigate what happens with the stream in case of a retry after a failure, is there a seek back to its beginning? return await _httpClient @@ -39,7 +39,7 @@ public async ValueTask GetBlobStreamAsync(string baseUrl, string token, { using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Get, baseUrl); requestMessage.Headers.Add("pm-storage-token", token); - requestMessage.DisableRetry(); + requestMessage.SetRequestType(HttpRequestType.StorageDownload); var blobResponse = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index be259bcc..ef64b3fe 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -67,7 +67,7 @@ private static async ValueTask ConvertHttpRequestToInteropAsync(Htt { var url = request.RequestUri?.AbsoluteUri ?? throw new InvalidOperationException($"Missing URL for HTTP request: {request.RequestUri}"); - var interopHttpRequest = new HttpRequest { Url = url, Method = request.Method.Method, DisableRetry = request.GetRetryIsDisabled() }; + var interopHttpRequest = new HttpRequest { Url = url, Method = request.Method.Method, Type = (HttpRequestType)request.GetRequestType() }; var headers = request.Headers.AsEnumerable(); diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs index 46ab95b5..52b3ba96 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -55,8 +55,6 @@ public override void Flush() { } - //add more overrides - public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) { Console.WriteLine("IteropStream.CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)"); @@ -69,12 +67,6 @@ public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, Asy return base.BeginRead(buffer, offset, count, callback, state); } - public override int Read(Span buffer) - { - Console.WriteLine("IteropStream.Read(Span buffer)"); - return base.Read(buffer); - } - public override int ReadByte() { Console.WriteLine("IteropStream.ReadByte()"); @@ -87,6 +79,12 @@ public override int Read(byte[] buffer, int offset, int count) return ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); } + public override int Read(Span buffer) + { + Console.WriteLine("IteropStream.Read(Span buffer)"); + return base.Read(buffer); + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { Console.WriteLine("IteropStream.ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)"); diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageExtensions.cs index 7e357dd3..1b09bea4 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageExtensions.cs @@ -2,13 +2,13 @@ internal static class HttpRequestMessageExtensions { - public static void DisableRetry(this HttpRequestMessage requestMessage) + public static void SetRequestType(this HttpRequestMessage requestMessage, HttpRequestType requestType) { - requestMessage.Options.Set(HttpRequestOptions.DisableRetryKey, true); + requestMessage.Options.Set(HttpRequestOptionKeys.RequestType, requestType); } - public static bool GetRetryIsDisabled(this HttpRequestMessage requestMessage) + public static HttpRequestType GetRequestType(this HttpRequestMessage requestMessage) { - return requestMessage.Options.TryGetValue(HttpRequestOptions.DisableRetryKey, out var isDisabled) && isDisabled; + return requestMessage.Options.TryGetValue(HttpRequestOptionKeys.RequestType, out var requestType) ? requestType : HttpRequestType.RegularApi; } } diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptionKeys.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptionKeys.cs new file mode 100644 index 00000000..ac03489d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptionKeys.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Http; + +public static class HttpRequestOptionKeys +{ + public static readonly HttpRequestOptionsKey RequestType = new("RequestType"); +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs deleted file mode 100644 index 4c4c53fc..00000000 --- a/cs/sdk/src/Proton.Sdk/Http/HttpRequestOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Proton.Sdk.Http; - -public static class HttpRequestOptions -{ - public static readonly HttpRequestOptionsKey DisableRetryKey = new("DisableRetry"); -} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestType.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestType.cs new file mode 100644 index 00000000..32ea8987 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestType.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Http; + +public enum HttpRequestType +{ + RegularApi = 0, + StorageDownload = 1, + StorageUpload = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index a20d3b6a..132d19d4 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -73,7 +73,8 @@ public static HttpClient GetHttpClient( options.CircuitBreaker.SamplingDuration = options.AttemptTimeout.Timeout * 2; } - options.Retry.ShouldHandle += arguments => ValueTask.FromResult(arguments.Context.GetRequestMessage()?.GetRetryIsDisabled() != true); + options.Retry.ShouldHandle += arguments => + ValueTask.FromResult(arguments.Context.GetRequestMessage()?.GetRequestType() != HttpRequestType.RegularApi); options.Retry.ShouldRetryAfterHeader = true; options.Retry.Delay = TimeSpan.FromSeconds(2); options.Retry.BackoffType = DelayBackoffType.Exponential; diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto index 7ddacb30..f668da85 100644 --- a/cs/sdk/src/protos/proton.sdk.proto +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -223,6 +223,12 @@ message StreamReadRequest { int32 buffer_length = 3; } +enum HttpRequestType { + HTTP_REQUEST_TYPE_REGULAR_API = 0; + HTTP_REQUEST_TYPE_STORAGE_DOWNLOAD = 1; + HTTP_REQUEST_TYPE_STORAGE_UPLOAD = 2; +} + message HttpHeader { string name = 1; repeated string values = 2; @@ -230,11 +236,11 @@ message HttpHeader { // The response value must be an HttpResponse. message HttpRequest { - string url = 1; - string method = 2; - repeated HttpHeader headers = 3; - int64 sdk_content_handle = 4; // Optional, to be used with StreamReadRequest - bool disable_retry = 5; + HttpRequestType type = 1; + string url = 2; + string method = 3; + repeated HttpHeader headers = 4; + int64 sdk_content_handle = 5; // Optional, to be used with StreamReadRequest } message HttpResponse { From a8efea4b3522e741522d1245056cf6179a74e991 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 08:23:01 +0100 Subject: [PATCH 327/791] Abort uploads properly --- js/sdk/src/internal/upload/streamUploader.test.ts | 7 ++++--- js/sdk/src/internal/upload/streamUploader.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 80ac164e..a6dfd69d 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -312,8 +312,9 @@ describe('StreamUploader', () => { throw new Error('Failed to encrypt block'); }); - // Encrypting thumbnails is before blocks, thus it can be uploaded before failure. - await verifyFailure('Failed to encrypt block', 1024); + // Thumbnail are uploaded with the first content block. If the + // content block fails to encrypt, nothing is uploaded. + await verifyFailure('Failed to encrypt block', 0); // 1 block + 1 retry, others are skipped expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(2); }); @@ -436,7 +437,7 @@ describe('StreamUploader', () => { describe('verifyIntegrity', () => { it('should report block verification error', async () => { blockVerifier.verifyBlock = jest.fn().mockRejectedValue(new IntegrityError('Block verification error')); - await verifyFailure('Block verification error', 1024); + await verifyFailure('Block verification error', 0); expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(false); }); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index a8a388e2..55eda073 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -340,6 +340,12 @@ export class StreamUploader { }, ); + // If the upload was aborted while requesting next upload tokens, + // do not schedule any next upload. + if (this.isUploadAborted) { + throw this.error || new AbortError(); + } + for (const thumbnailToken of uploadTokens.thumbnailTokens) { let encryptedThumbnail = this.encryptedThumbnails.get(thumbnailToken.type); if (!encryptedThumbnail) { @@ -418,7 +424,7 @@ export class StreamUploader { break; } catch (error: unknown) { // Do not retry or report anything if the upload was aborted. - if (error instanceof AbortError) { + if (error instanceof AbortError || this.isUploadAborted) { throw error; } @@ -485,7 +491,7 @@ export class StreamUploader { break; } catch (error: unknown) { // Do not retry or report anything if the upload was aborted. - if (error instanceof AbortError) { + if (error instanceof AbortError || this.isUploadAborted) { throw error; } From cea6eb64c89e25786aedba032d7413b263ff9eb1 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 11:44:18 +0100 Subject: [PATCH 328/791] Allow multiple calls to override native library name --- cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs index aebbff33..1503485b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs @@ -16,10 +16,10 @@ public static class NativeLibraryResolver private static void OverrideNativeLibraryName(InteropArray libraryNameBytes, InteropArray overridingLibraryNameBytes) { var libraryName = Encoding.UTF8.GetString(libraryNameBytes.AsReadOnlySpan()); - var overridingLibraryName = Encoding.UTF8.GetString(overridingLibraryNameBytes.AsReadOnlySpan()); - LibraryNameMap[libraryName] = overridingLibraryName; + LibraryNameMap[libraryName] = Encoding.UTF8.GetString(overridingLibraryNameBytes.AsReadOnlySpan()); + AssemblyLoadContext.Default.ResolvingUnmanagedDll -= Resolve; AssemblyLoadContext.Default.ResolvingUnmanagedDll += Resolve; } From 86ef974c7e24d3b44c5a4ba343014684335e401b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Nov 2025 15:05:43 +0100 Subject: [PATCH 329/791] Preserve interop errors passing through SDK --- .../ExceptionExtensions.cs | 5 ++++ .../InteropErrorException.cs | 26 +++++++++++++++++++ .../InteropMessageHandler.cs | 6 ++--- 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropErrorException.cs diff --git a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs index 5543d5c1..d6012fd9 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs @@ -4,6 +4,11 @@ internal static class ExceptionExtensions { public static Error ToErrorMessage(this Exception exception, Action setDomainAndCodesFunction) { + if (exception is InteropErrorException { Error: not null } interopErrorException) + { + return interopErrorException.Error; + } + var error = new Error { Type = exception.GetType().FullName ?? $"{nameof(System)}.{nameof(Exception)}", diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropErrorException.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorException.cs new file mode 100644 index 00000000..04b67b6c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorException.cs @@ -0,0 +1,26 @@ +namespace Proton.Sdk.CExports; + +public sealed class InteropErrorException : Exception +{ + public InteropErrorException() + { + } + + public InteropErrorException(string message) + : base(message) + { + } + + public InteropErrorException(string message, Exception innerException) + : base(message, innerException) + { + } + + internal InteropErrorException(Error error) + : base(error.Message) + { + Error = error; + } + + internal Error? Error { get; } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs index 7e9fb3bf..11df1c0d 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -82,7 +82,7 @@ public static void OnResponseReceived(nint sdkHandle, InteropArray respons if (response.Error is not null) { - SetException(sdkHandle, response.Error.Message); + SetException(sdkHandle, response.Error); return; } @@ -143,10 +143,10 @@ private static void SetResult(nint tcsHandle) tcs.SetResult(); } - private static void SetException(nint tcsHandle, string errorMessage) + private static void SetException(nint tcsHandle, Error error) { var tfs = Interop.GetFromHandleAndFree(tcsHandle); - tfs.SetException(new Exception(errorMessage)); + tfs.SetException(new InteropErrorException(error)); } } From e90233e3b4503c0f0bd97de894b1af3108d5742e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 14:00:22 +0000 Subject: [PATCH 330/791] Address security review of C# crypto --- .../Proton.Sdk/Addresses/AddressOperations.cs | 5 +++++ .../src/Proton.Sdk/ProtonAccountException.cs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 cs/sdk/src/Proton.Sdk/ProtonAccountException.cs diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 40da4b36..4a91c250 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -239,6 +239,11 @@ private static ReadOnlyMemory GetAddressKeyTokenPassphrase( var userKeyRing = new PgpPrivateKeyRing(userKeys); using var decryptingStream = PgpDecryptingStream.Open(token, userKeyRing, signature, userKeyRing); + if (decryptingStream.GetVerificationResult().Status is not PgpVerificationStatus.Ok) + { + throw new ProtonAccountException("Invalid account address key passphrase signature"); + } + using var passphraseStream = new MemoryStream(); decryptingStream.CopyTo(passphraseStream); diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountException.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountException.cs new file mode 100644 index 00000000..58287195 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountException.cs @@ -0,0 +1,18 @@ +namespace Proton.Sdk; + +public class ProtonAccountException : Exception +{ + public ProtonAccountException() + { + } + + public ProtonAccountException(string? message) + : base(message) + { + } + + public ProtonAccountException(string message, Exception innerException) + : base(message, innerException) + { + } +} From ba759cf1c4d93c5e306d045b6e42a775b205c561 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 14:03:26 +0000 Subject: [PATCH 331/791] Ignore missing signatures on legacy nodes --- .../src/internal/nodes/cryptoService.test.ts | 84 +++++++++++++++++++ js/sdk/src/internal/nodes/cryptoService.ts | 36 +++++--- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 2709a365..1fb8de87 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -250,6 +250,48 @@ describe('nodesCryptoService', () => { }); }); + it('on older node name ignores NOT_SIGNED', async () => { + encryptedNode.creationTime = new Date('2020-12-31'); + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.NOT_SIGNED, + verificationErrors: [new Error('missing signature')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + nameAuthor: { + ok: true, + value: 'nameSignatureEmail', + }, + }); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + it('on newer node name does not ignore NOT_SIGNED', async () => { + encryptedNode.creationTime = new Date('2021-01-01'); + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.NOT_SIGNED, + verificationErrors: [new Error('missing signature')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + nameAuthor: { + ok: false, + error: { + claimedAuthor: 'nameSignatureEmail', + error: 'Missing signature for name', + }, + }, + }); + }); + it('on hash key', async () => { driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.resolve({ @@ -275,6 +317,48 @@ describe('nodesCryptoService', () => { }); }); + it('on older node hash key ignores NOT_SIGNED', async () => { + encryptedNode.creationTime = new Date('2021-07-31'); + driveCrypto.decryptNodeHashKey = jest.fn(async () => + Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.NOT_SIGNED, + verificationErrors: [new Error('missing signature')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { + ok: true, + value: 'signatureEmail', + }, + }); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + it('on newer node hash key does not ignore NOT_SIGNED', async () => { + encryptedNode.creationTime = new Date('2021-08-01'); + driveCrypto.decryptNodeHashKey = jest.fn(async () => + Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.NOT_SIGNED, + verificationErrors: [new Error('missing signature')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Missing signature for hash key', + }, + }, + }); + }); + it('on node key and hash key reports error from node key', async () => { driveCrypto.decryptKey = jest.fn(async () => Promise.resolve({ diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 28d59e76..7c52a17c 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -336,11 +336,19 @@ export class NodesCryptoService { const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail; try { - const { name, verified, verificationErrors } = await this.driveCrypto.decryptNodeName( - node.encryptedName, - parentKey, - verificationKeys, - ); + const { + name, + verified: verificationStatus, + verificationErrors, + } = await this.driveCrypto.decryptNodeName(node.encryptedName, parentKey, verificationKeys); + + let verified = verificationStatus; + // The name was not signed until Drive web Beta 3. + // It is decided to ignore this and consider it signed. + // The problem will be gone with migration to new crypto model. + if (verificationStatus === VERIFICATION_STATUS.NOT_SIGNED && node.creationTime < new Date(2021, 0, 1)) { + verified = VERIFICATION_STATUS.SIGNED_AND_VALID; + } return { name: resultOk(name), @@ -438,11 +446,19 @@ export class NodesCryptoService { throw new Error('Node is not a folder'); } - const { hashKey, verified, verificationErrors } = await this.driveCrypto.decryptNodeHashKey( - node.encryptedCrypto.folder.armoredHashKey, - nodeKey, - addressKeys, - ); + const { + hashKey, + verified: verificationStatus, + verificationErrors, + } = await this.driveCrypto.decryptNodeHashKey(node.encryptedCrypto.folder.armoredHashKey, nodeKey, addressKeys); + + let verified = verificationStatus; + // The hash was not signed until Drive web Beta 17. + // It is decided to ignore this and consider it signed. + // The problem will be gone with migration to new crypto model. + if (verificationStatus === VERIFICATION_STATUS.NOT_SIGNED && node.creationTime < new Date(2021, 7, 1)) { + verified = VERIFICATION_STATUS.SIGNED_AND_VALID; + } return { hashKey, From fab5246a9b0570e8a659a3e9d446962bbd426283 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 15:14:32 +0100 Subject: [PATCH 332/791] Handle diverging size on upload --- .../InteropDriveErrorConverter.cs | 5 + .../Api/Links/ILinksApiClient.cs | 2 +- .../Api/Links/LinksApiClient.cs | 4 +- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 2 +- .../Nodes/Upload/BlockUploadResult.cs | 3 + .../Nodes/Upload/BlockUploader.cs | 16 +- .../Nodes/Upload/FileUploader.cs | 21 +- .../Nodes/Upload/IFileDraftProvider.cs | 6 +- .../Nodes/Upload/IntegrityException.cs | 18 ++ .../Nodes/Upload/NewFileDraftProvider.cs | 5 + .../Nodes/Upload/NewRevisionDraftProvider.cs | 6 + .../Nodes/Upload/RevisionWriter.cs | 229 ++++++++++-------- .../Nodes/Upload/RevisionWriterExtensions.cs | 26 +- 13 files changed, 220 insertions(+), 123 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadResult.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs index 6e25cd68..9d92a9a7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -1,5 +1,6 @@ using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Upload; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk.CExports; @@ -34,6 +35,10 @@ public static void SetDomainAndCodes(Error error, Exception exception) error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode; break; + case IntegrityException: + error.Domain = ErrorDomain.DataIntegrity; + break; + case NodeWithSameNameExistsException e: error.Domain = ErrorDomain.BusinessLogic; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs index 1d539186..1f96c569 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs @@ -17,7 +17,7 @@ internal interface ILinksApiClient ValueTask> DeleteMultipleAsync( VolumeId volumeId, - MultipleLinksNullaryRequest request, + IEnumerable linkIds, CancellationToken cancellationToken); ValueTask GetAvailableNames( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs index 03e6b76b..04acad2e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -51,14 +51,14 @@ public async ValueTask RenameAsync(VolumeId volumeId, LinkId linkId public async ValueTask> DeleteMultipleAsync( VolumeId volumeId, - MultipleLinksNullaryRequest request, + IEnumerable linkIds, CancellationToken cancellationToken) { return await _httpClient .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) .PostAsync( $"v2/volumes/{volumeId}/delete_multiple", - request, + new MultipleLinksNullaryRequest { LinkIds = linkIds }, DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, cancellationToken) .ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index fd4016f0..56a4b767 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -368,7 +368,7 @@ public static async ValueTask>> D { var request = new MultipleLinksNullaryRequest { LinkIds = batch }; - var aggregateResponse = await client.Api.Links.DeleteMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + var aggregateResponse = await client.Api.Links.DeleteMultipleAsync(uidGroup.Key, request.LinkIds, cancellationToken).ConfigureAwait(false); foreach (var (linkId, response) in aggregateResponse.Responses) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadResult.cs new file mode 100644 index 00000000..c53cce63 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadResult.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal readonly record struct BlockUploadResult(int PlaintextSize, byte[] Sha256Digest, bool IsFileContent); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 93216102..976fe273 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -30,7 +30,7 @@ internal BlockUploader(ProtonDriveClient client, int maxDegreeOfParallelism) public TransferQueue Queue { get; } - public async Task UploadContentAsync( + public async Task UploadContentAsync( RevisionUid revisionUid, int index, PgpSessionKey contentKey, @@ -58,7 +58,7 @@ public async Task UploadContentAsync( await using (signatureStream.ConfigureAwait(false)) { - byte[] sha256Digest; + BlockUploadResult result; await using (plainDataStream.ConfigureAwait(false)) { @@ -82,7 +82,9 @@ public async Task UploadContentAsync( } } - sha256Digest = sha256.GetCurrentHash(); + var sha256Digest = sha256.GetCurrentHash(); + + result = new BlockUploadResult((int)plainDataStream.Length, sha256Digest, IsFileContent: true); } // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, @@ -115,7 +117,7 @@ public async Task UploadContentAsync( { Index = index, Size = (int)dataPacketStream.Length, - HashDigest = sha256Digest, + HashDigest = result.Sha256Digest, EncryptedSignature = signature, VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, }, @@ -129,7 +131,7 @@ public async Task UploadContentAsync( LogContentBlobUploaded(index, revisionUid); - return sha256Digest; + return result; } } } @@ -151,7 +153,7 @@ public async Task UploadContentAsync( } } - public async Task UploadThumbnailAsync( + public async Task UploadThumbnailAsync( RevisionUid revisionUid, PgpSessionKey contentKey, PgpPrivateKey signingKey, @@ -203,7 +205,7 @@ public async Task UploadThumbnailAsync( LogThumbnailBlobUploaded(revisionUid); - return sha256Digest; + return new BlockUploadResult(0, sha256Digest, IsFileContent: false); } } finally diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 97e2a144..5e93a335 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -90,6 +90,9 @@ internal static async ValueTask CreateAsync( [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from revision creation semaphore")] private static partial void LogReleasedRevisionCreationSemaphore(ILogger logger, int count); + [LoggerMessage(Level = LogLevel.Error, Message = "Draft deletion failed for revision {RevisionUid}")] + private static partial void LogDraftDeletionFailure(ILogger logger, Exception exception, RevisionUid revisionUid); + private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, @@ -168,7 +171,23 @@ private async ValueTask UploadAsync( using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionUid, fileSecrets, ReleaseBlocks, cancellationToken) .ConfigureAwait(false); - await revisionWriter.WriteAsync(contentStream, thumbnails, lastModificationTime, additionalMetadata, onProgress, cancellationToken).ConfigureAwait(false); + try + { + await revisionWriter.WriteAsync(contentStream, FileSize, thumbnails, lastModificationTime, additionalMetadata, onProgress, cancellationToken).ConfigureAwait(false); + } + catch + { + try + { + await _fileDraftProvider.DeleteDraftAsync(_client, revisionUid, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogDraftDeletionFailure(_client.Telemetry.GetLogger("Upload"), ex, revisionUid); + } + + throw; + } } private void ReleaseBlocks(int numberOfBlocks) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs index 78387f62..89d862f7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Nodes.Upload; internal interface IFileDraftProvider { - ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync( - ProtonDriveClient client, - CancellationToken cancellationToken); + ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync(ProtonDriveClient client, CancellationToken cancellationToken); + + ValueTask DeleteDraftAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs new file mode 100644 index 00000000..721edb71 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal class IntegrityException : Exception +{ + public IntegrityException(string message) + : base(message) + { + } + + public IntegrityException(string message, Exception innerException) + : base(message, innerException) + { + } + + public IntegrityException() + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 5ec21af7..3855c9ea 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -45,6 +45,11 @@ internal NewFileDraftProvider( return (draftRevisionUid, fileSecrets); } + public async ValueTask DeleteDraftAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) + { + await client.Api.Links.DeleteMultipleAsync(revisionUid.NodeUid.VolumeId, [revisionUid.NodeUid.LinkId], cancellationToken).ConfigureAwait(false); + } + private static FileCreationRequest GetFileCreationRequest( string clientUid, NodeUid parentUid, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index 3077aa56..051e95b9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -21,4 +21,10 @@ internal NewRevisionDraftProvider( { return await RevisionOperations.CreateDraftAsync(client, _fileUid, _lastKnownRevisionId, cancellationToken).ConfigureAwait(false); } + + public async ValueTask DeleteDraftAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) + { + await client.Api.Files.DeleteRevisionAsync(revisionUid.NodeUid.VolumeId, revisionUid.NodeUid.LinkId, revisionUid.RevisionId, cancellationToken) + .ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index e756c044..6cc8d5de 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -2,7 +2,6 @@ using System.Security.Cryptography; using System.Text.Json; using Microsoft.Extensions.Logging; -using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Cryptography; @@ -58,6 +57,7 @@ internal RevisionWriter( public async ValueTask WriteAsync( Stream contentStream, + long expectedContentLength, IEnumerable thumbnails, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, @@ -78,11 +78,11 @@ public async ValueTask WriteAsync( var signingEmailAddress = _membershipAddress.EmailAddress; - var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); + var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); var blockIndex = 0; - ArraySegment manifestSignature; - var blockSizes = new List(8); + var blockUploadResults = new List(8); + var expectedThumbnailBlockCount = 0; using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); @@ -90,125 +90,115 @@ public async ValueTask WriteAsync( await using (hashingContentStream.ConfigureAwait(false)) { - // TODO: provide capacity - var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_revisionUid, _fileKey, cancellationToken).ConfigureAwait(false); - await using (manifestStream.ConfigureAwait(false)) - { - var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_revisionUid, _fileKey, cancellationToken) - .ConfigureAwait(false); - - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var linkedCancellationToken = cancellationTokenSource.Token; + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var linkedCancellationToken = cancellationTokenSource.Token; + try + { try { - try + foreach (var thumbnail in thumbnails) { - foreach (var thumbnail in thumbnails) + ++expectedThumbnailBlockCount; + + await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, linkedCancellationToken).ConfigureAwait(false); + + var uploadTask = _client.BlockUploader.UploadThumbnailAsync( + _revisionUid, + _contentKey, + _signingKey, + _membershipAddress.Id, + thumbnail, + cancellationTokenSource.Token); + + uploadTasks.Enqueue(uploadTask); + } + + while ( + await TryGetBlockPlainDataStreamAsync( + hashingContentStream, + blockVerifier.DataPacketPrefixMaxLength, + linkedCancellationToken).ConfigureAwait(false) is var (plainDataStream, plainDataPrefixBuffer)) + { + try { - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); + await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, linkedCancellationToken).ConfigureAwait(false); + + plainDataStream.Seek(0, SeekOrigin.Begin); + + var onBlockProgress = onProgress is not null + ? progress => + { + numberOfBytesUploaded += progress; + + // TODO: move this to a decorator, wrap the progress action + uploadEvent.UploadedSize = numberOfBytesUploaded; + uploadEvent.ApproximateUploadedSize = ReduceSizePrecision(numberOfBytesUploaded); + + onProgress(numberOfBytesUploaded); + } + : default(Action?); - var uploadTask = _client.BlockUploader.UploadThumbnailAsync( + var uploadTask = _client.BlockUploader.UploadContentAsync( _revisionUid, + ++blockIndex, _contentKey, _signingKey, _membershipAddress.Id, - thumbnail, - cancellationTokenSource.Token); + _fileKey, + plainDataStream, + blockVerifier, + plainDataPrefixBuffer, + (int)Math.Min(blockVerifier.DataPacketPrefixMaxLength, plainDataStream.Length), + onBlockProgress, + _releaseBlocksAction, + linkedCancellationToken); uploadTasks.Enqueue(uploadTask); } - - while ( - await TryGetBlockPlainDataStreamAsync( - hashingContentStream, - blockVerifier.DataPacketPrefixMaxLength, - linkedCancellationToken).ConfigureAwait(false) is var (plainDataStream, plainDataPrefixBuffer)) + catch { - try - { - blockSizes.Add((int)plainDataStream.Length); - - await WaitForBlockUploaderAsync(uploadTasks, manifestStream, linkedCancellationToken).ConfigureAwait(false); - - plainDataStream.Seek(0, SeekOrigin.Begin); - - var onBlockProgress = onProgress is not null - ? progress => - { - numberOfBytesUploaded += progress; - - // TODO: move this to a decorator, wrap the progress action - uploadEvent.UploadedSize = numberOfBytesUploaded; - uploadEvent.ApproximateUploadedSize = ReduceSizePrecision(numberOfBytesUploaded); - - onProgress(numberOfBytesUploaded); - } - : default(Action?); - - var uploadTask = _client.BlockUploader.UploadContentAsync( - _revisionUid, - ++blockIndex, - _contentKey, - _signingKey, - _membershipAddress.Id, - _fileKey, - plainDataStream, - blockVerifier, - plainDataPrefixBuffer, - (int)Math.Min(blockVerifier.DataPacketPrefixMaxLength, plainDataStream.Length), - onBlockProgress, - _releaseBlocksAction, - linkedCancellationToken); - - uploadTasks.Enqueue(uploadTask); - } - catch - { - ArrayPool.Shared.Return(plainDataPrefixBuffer); - throw; - } + ArrayPool.Shared.Return(plainDataPrefixBuffer); + throw; } } - finally - { - _releaseFileSemaphoreAction.Invoke(); - _fileReleased = true; - } - - while (uploadTasks.Count > 0) - { - await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); - } } - catch + finally { - await cancellationTokenSource.CancelAsync().ConfigureAwait(false); - - try - { - await Task.WhenAll(uploadTasks).ConfigureAwait(false); - } - catch - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw - } + _releaseFileSemaphoreAction.Invoke(); + _fileReleased = true; + } - throw; + while (uploadTasks.Count > 0) + { + await RegisterNextCompletedBlockAsync(uploadTasks, blockUploadResults).ConfigureAwait(false); } + } + catch + { + await cancellationTokenSource.CancelAsync().ConfigureAwait(false); - manifestStream.Seek(0, SeekOrigin.Begin); + try + { + await Task.WhenAll(uploadTasks).ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + } - manifestSignature = await _signingKey.SignAsync(manifestStream, cancellationTokenSource.Token).ConfigureAwait(false); + throw; } } var request = GetRevisionUpdateRequest( lastModificationTime, - blockSizes, + blockUploadResults, + expectedContentLength, + expectedThumbnailBlockCount, sha1.GetCurrentHash(), - manifestSignature, signingEmailAddress, additionalMetadata); @@ -268,11 +258,11 @@ private static long ReduceSizePrecision(long size) return (size / precision) * precision; } - private static async ValueTask AddNextBlockToManifestAsync(Queue> uploadTasks, RecyclableMemoryStream manifestStream) + private static async ValueTask RegisterNextCompletedBlockAsync(Queue> uploadTasks, List blockUploadResults) { - var sha256Digest = await uploadTasks.Dequeue().ConfigureAwait(false); + var blockUploadResult = await uploadTasks.Dequeue().ConfigureAwait(false); - await manifestStream.WriteAsync(sha256Digest).ConfigureAwait(false); + blockUploadResults.Add(blockUploadResult); } private async ValueTask<(Stream Stream, byte[] Prefix)?> TryGetBlockPlainDataStreamAsync( @@ -313,13 +303,16 @@ private static async ValueTask AddNextBlockToManifestAsync(Queue> u } } - private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTasks, RecyclableMemoryStream manifestStream, CancellationToken cancellationToken) + private async ValueTask WaitForBlockUploaderAsync( + Queue> uploadTasks, + List blockUploadResults, + CancellationToken cancellationToken) { if (!_client.BlockUploader.Queue.TryStartBlock()) { if (uploadTasks.Count > 0) { - await AddNextBlockToManifestAsync(uploadTasks, manifestStream).ConfigureAwait(false); + await RegisterNextCompletedBlockAsync(uploadTasks, blockUploadResults).ConfigureAwait(false); } await _client.BlockUploader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); @@ -334,19 +327,47 @@ private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTask private RevisionUpdateRequest GetRevisionUpdateRequest( DateTimeOffset? lastModificationTime, - IReadOnlyList blockSizes, + List blockUploadResults, + long expectedContentLength, + int expectedThumbnailBlockCount, byte[]? sha1Digest, - ArraySegment manifestSignature, string signingEmailAddress, IEnumerable? additionalMetadata) { + var manifest = new byte[blockUploadResults.Count * SHA256.HashSizeInBytes]; + using var manifestStream = new MemoryStream(manifest); + + var contentBlockSizes = new List(blockUploadResults.Count); + var uploadedContentSize = 0L; + + foreach (var (plaintextSize, sha256Digest, isFileContent) in blockUploadResults) + { + manifestStream.Write(sha256Digest); + + if (isFileContent) + { + contentBlockSizes.Add(plaintextSize); + uploadedContentSize += plaintextSize; + } + } + + if (uploadedContentSize != expectedContentLength) + { + throw new IntegrityException("Mismatch between uploaded size and expected size"); + } + + if (expectedThumbnailBlockCount != blockUploadResults.Count - contentBlockSizes.Count) + { + throw new IntegrityException("Unexpected number of thumbnail blocks"); + } + var extendedAttributes = new ExtendedAttributes { Common = new CommonExtendedAttributes { - Size = blockSizes.Sum(x => (long)x), + Size = uploadedContentSize, ModificationTime = lastModificationTime?.UtcDateTime, - BlockSizes = blockSizes, + BlockSizes = contentBlockSizes, Digests = new FileContentDigestsDto { Sha1 = sha1Digest }, }, AdditionalMetadata = additionalMetadata?.ToDictionary(x => x.Name, x => x.Value), @@ -358,7 +379,7 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( return new RevisionUpdateRequest { - ManifestSignature = manifestSignature, + ManifestSignature = _signingKey.Sign(manifest), SignatureEmailAddress = signingEmailAddress, ExtendedAttributes = encryptedExtendedAttributes, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs index b411eff6..9158bb1c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs @@ -5,35 +5,52 @@ internal static class RevisionWriterExtensions public static ValueTask WriteAsync( this RevisionWriter revisionWriter, Stream contentStream, + long expectedContentLength, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, Action onProgress, CancellationToken cancellationToken) { - return revisionWriter.WriteAsync(contentStream, [], lastModificationTime, additionalMetadata, onProgress, cancellationToken); + return revisionWriter.WriteAsync(contentStream, expectedContentLength, [], lastModificationTime, additionalMetadata, onProgress, cancellationToken); } public static ValueTask WriteAsync( this RevisionWriter revisionWriter, Stream contentStream, + long expectedContentLength, DateTime lastModificationTime, IEnumerable? additionalMetadata, Action onProgress, CancellationToken cancellationToken) { - return revisionWriter.WriteAsync(contentStream, [], new DateTimeOffset(lastModificationTime), additionalMetadata, onProgress, cancellationToken); + return revisionWriter.WriteAsync( + contentStream, + expectedContentLength, + [], + new DateTimeOffset(lastModificationTime), + additionalMetadata, + onProgress, + cancellationToken); } public static ValueTask WriteAsync( this RevisionWriter revisionWriter, Stream contentStream, + long expectedContentLength, IEnumerable thumbnails, DateTime lastModificationTime, IEnumerable? additionalMetadata, Action onProgress, CancellationToken cancellationToken) { - return revisionWriter.WriteAsync(contentStream, thumbnails, new DateTimeOffset(lastModificationTime), additionalMetadata, onProgress, cancellationToken); + return revisionWriter.WriteAsync( + contentStream, + expectedContentLength, + thumbnails, + new DateTimeOffset(lastModificationTime), + additionalMetadata, + onProgress, + cancellationToken); } public static async ValueTask WriteAsync( @@ -48,7 +65,8 @@ public static async ValueTask WriteAsync( await using (fileStream) { - await WriteAsync(writer, fileStream, lastModificationTime, additionalMetadata, onProgress, cancellationToken).ConfigureAwait(false); + await WriteAsync(writer, fileStream, fileStream.Length, lastModificationTime, additionalMetadata, onProgress, cancellationToken) + .ConfigureAwait(false); } } } From 235f54c79f45daecf7a58fb9e04d4f6538047e08 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 14:30:12 +0000 Subject: [PATCH 333/791] Add unauth prefix for all API calls from public link context --- js/sdk/src/internal/apiService/apiService.ts | 4 +- js/sdk/src/internal/apiService/errors.ts | 9 +- js/sdk/src/internal/download/cryptoService.ts | 19 +- .../src/internal/download/fileDownloader.ts | 6 +- js/sdk/src/internal/download/index.ts | 3 + js/sdk/src/internal/nodes/apiService.test.ts | 10 +- js/sdk/src/internal/nodes/apiService.ts | 33 +- js/sdk/src/internal/nodes/cryptoReporter.ts | 6 +- .../src/internal/nodes/cryptoService.test.ts | 304 ++++++++++++++++-- js/sdk/src/internal/nodes/cryptoService.ts | 69 ++-- js/sdk/src/internal/nodes/interface.ts | 18 +- js/sdk/src/internal/nodes/nodesAccess.ts | 17 + .../internal/nodes/nodesManagement.test.ts | 31 +- js/sdk/src/internal/nodes/nodesManagement.ts | 44 ++- js/sdk/src/internal/photos/upload.ts | 6 +- js/sdk/src/internal/sharingPublic/index.ts | 10 +- js/sdk/src/internal/sharingPublic/nodes.ts | 45 ++- .../sharingPublic/unauthApiService.test.ts | 29 ++ .../sharingPublic/unauthApiService.ts | 32 ++ js/sdk/src/internal/upload/apiService.ts | 7 +- js/sdk/src/internal/upload/cryptoService.ts | 85 ++++- .../src/internal/upload/fileUploader.test.ts | 2 +- js/sdk/src/internal/upload/interface.ts | 37 ++- js/sdk/src/internal/upload/manager.test.ts | 9 +- js/sdk/src/internal/upload/manager.ts | 11 +- .../internal/upload/streamUploader.test.ts | 6 +- js/sdk/src/internal/upload/streamUploader.ts | 4 +- js/sdk/src/protonDriveClient.ts | 9 +- js/sdk/src/protonDrivePublicLinkClient.ts | 11 +- 29 files changed, 714 insertions(+), 162 deletions(-) create mode 100644 js/sdk/src/internal/sharingPublic/unauthApiService.test.ts create mode 100644 js/sdk/src/internal/sharingPublic/unauthApiService.ts diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index b05a0bdf..58179404 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -136,7 +136,7 @@ export class DriveAPIService { return this.makeRequest(url, 'DELETE', undefined, signal); } - private async makeRequest( + protected async makeRequest( url: string, method = 'GET', data?: RequestPayload, @@ -194,7 +194,7 @@ export class DriveAPIService { await this.makeStorageRequest('POST', baseUrl, token, data, onProgress, signal); } - private async makeStorageRequest( + protected async makeStorageRequest( method: 'GET' | 'POST', url: string, token: string, diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index d5d18a3e..aa2a0658 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -30,7 +30,7 @@ export function apiErrorFactory({ Code?: number; Error?: string; Details?: object; - exception?: string; + Exception?: string; message?: string; file?: string; line?: number; @@ -43,9 +43,10 @@ export function apiErrorFactory({ typedResult.Details, ]; - const debug = typedResult.exception + const debug = typedResult.Exception ? { - exception: typedResult.exception, + details: typedResult.Details, + exception: typedResult.Exception, message: typedResult.message, file: typedResult.file, line: typedResult.line, @@ -82,7 +83,7 @@ export function apiErrorFactory({ case ErrorCode.INSUFFICIENT_BOOKMARKS_QUOTA: return new ValidationError(message, code, details); default: - return new APICodeError(message, code, debug); + return new APICodeError(message, code, debug || details); } } diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index f515efd4..fa7f5403 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -27,7 +27,7 @@ export class DownloadCryptoService { nodeKey: { key: PrivateKey; contentKeyPacketSessionKey: SessionKey }, revision: Revision, ): Promise { - const verificationKeys = await this.getRevisionVerificationKeys(revision); + const verificationKeys = await this.getRevisionVerificationKeys(revision, nodeKey.key); return { ...nodeKey, verificationKeys, @@ -90,23 +90,30 @@ export class DownloadCryptoService { allBlockHashes: Uint8Array[], armoredManifestSignature?: string, ): Promise { - const verificationKeys = (await this.getRevisionVerificationKeys(revision)) || nodeKey; + const verificationKeys = await this.getRevisionVerificationKeys(revision, nodeKey); const hash = mergeUint8Arrays(allBlockHashes); if (!armoredManifestSignature) { throw new IntegrityError(c('Error').t`Missing integrity signature`); } - const { verified } = await this.driveCrypto.verifyManifest(hash, armoredManifestSignature, verificationKeys); + const { verified, verificationErrors } = await this.driveCrypto.verifyManifest( + hash, + armoredManifestSignature, + verificationKeys, + ); + if (verified !== VERIFICATION_STATUS.SIGNED_AND_VALID) { - throw new IntegrityError(c('Error').t`Data integrity check failed`); + throw new IntegrityError(c('Error').t`Data integrity check failed`, { + verificationErrors, + }); } } - private async getRevisionVerificationKeys(revision: Revision): Promise { + private async getRevisionVerificationKeys(revision: Revision, nodeKey: PrivateKey): Promise { const signatureEmail = revision.contentAuthor.ok ? revision.contentAuthor.value : revision.contentAuthor.error.claimedAuthor; - return signatureEmail ? await this.account.getPublicKeys(signatureEmail) : undefined; + return signatureEmail ? await this.account.getPublicKeys(signatureEmail) : [nodeKey]; } } diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 60f64b20..1cdb0532 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -42,6 +42,7 @@ export class FileDownloader { private revision: DecryptedRevision, private signal?: AbortSignal, private onFinish?: () => void, + private ignoreManifestVerification = false, ) { this.telemetry = telemetry; this.logger = telemetry.getLoggerForRevision(revision.uid); @@ -51,6 +52,7 @@ export class FileDownloader { this.revision = revision; this.signal = signal; this.onFinish = onFinish; + this.ignoreManifestVerification = ignoreManifestVerification; this.controller = new DownloadController(this.signal); } @@ -219,7 +221,7 @@ export class FileDownloader { throw new Error(`Some blocks were not downloaded`); } - if (ignoreIntegrityErrors) { + if (ignoreIntegrityErrors || this.ignoreManifestVerification) { this.logger.warn('Skipping manifest check'); } else { this.logger.debug(`Verifying manifest`); @@ -237,7 +239,7 @@ export class FileDownloader { } catch (error: unknown) { this.logger.error(`Download failed`, error); void this.telemetry.downloadFailed(this.revision.uid, error, fileProgress, this.getClaimedSizeInBytes()); - await writer.abort(); + await writer.abort?.(); throw error; } finally { this.logger.debug(`Download cleanup`); diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts index 86375a80..4bf8f7be 100644 --- a/js/sdk/src/internal/download/index.ts +++ b/js/sdk/src/internal/download/index.ts @@ -21,6 +21,7 @@ export function initDownloadModule( sharesService: SharesService, nodesService: NodesService, revisionsService: RevisionsService, + ignoreManifestVerification = false, ) { const queue = new DownloadQueue(); const api = new DownloadAPIService(apiService); @@ -63,6 +64,7 @@ export function initDownloadModule( node.activeRevision.value, signal, onFinish, + ignoreManifestVerification, ); } @@ -102,6 +104,7 @@ export function initDownloadModule( revision, signal, onFinish, + ignoreManifestVerification, ); } diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 603a7f1c..118cb8cd 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -577,8 +577,8 @@ describe('nodeAPIService', () => { }); }); - describe('deleteNodes', () => { - it('should delete nodes', async () => { + describe('deleteTrashedNodes', () => { + it('should delete trashed nodes', async () => { // @ts-expect-error Mocking for testing purposes apiMock.post = jest.fn(async () => Promise.resolve({ @@ -600,14 +600,14 @@ describe('nodeAPIService', () => { }), ); - const result = await Array.fromAsync(api.deleteNodes(['volumeId~nodeId1', 'volumeId~nodeId2'])); + const result = await Array.fromAsync(api.deleteTrashedNodes(['volumeId~nodeId1', 'volumeId~nodeId2'])); expect(result).toEqual([ { uid: 'volumeId~nodeId1', ok: true }, { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, ]); }); - it('should delete nodes from multiple volumes', async () => { + it('should delete trashed nodes from multiple volumes', async () => { // @ts-expect-error Mocking for testing purposes apiMock.post = jest.fn(async (_, { LinkIDs }) => Promise.resolve({ @@ -620,7 +620,7 @@ describe('nodeAPIService', () => { }), ); - const result = await Array.fromAsync(api.deleteNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); + const result = await Array.fromAsync(api.deleteTrashedNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); expect(result).toEqual([ { uid: 'volumeId1~nodeId1', ok: true }, { uid: 'volumeId2~nodeId2', ok: true }, diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 430f2a9f..be969b1c 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,8 +1,7 @@ import { c } from 'ttag'; import { NodeWithSameNameExistsValidationError, ProtonDriveError, ValidationError } from '../../errors'; -import { Logger, NodeResult } from '../../interface'; -import { MemberRole, RevisionState } from '../../interface/nodes'; +import { Logger, NodeResult, MemberRole, RevisionState, AnonymousUser } from '../../interface'; import { DriveAPIService, drivePaths, @@ -102,7 +101,6 @@ type PostRestoreRevisionResponse = type DeleteRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['delete']['responses']['200']['content']['application/json']; - type PostCheckAvailableHashesRequest = Extract< drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes']['post']['requestBody'], { content: object } @@ -292,7 +290,7 @@ export class NodeAPIService { }, newNode: { encryptedName: string; - nameSignatureEmail: string; + nameSignatureEmail: string | AnonymousUser; hash?: string; }, signal?: AbortSignal, @@ -332,9 +330,9 @@ export class NodeAPIService { parentUid: string; armoredNodePassphrase: string; armoredNodePassphraseSignature?: string; - signatureEmail?: string; + signatureEmail?: string | AnonymousUser; encryptedName: string; - nameSignatureEmail?: string; + nameSignatureEmail?: string | AnonymousUser; hash: string; contentHash?: string; }, @@ -369,9 +367,9 @@ export class NodeAPIService { parentUid: string; armoredNodePassphrase: string; armoredNodePassphraseSignature?: string; - signatureEmail?: string; + signatureEmail?: string | AnonymousUser; encryptedName: string; - nameSignatureEmail?: string; + nameSignatureEmail?: string | AnonymousUser; hash: string; }, signal?: AbortSignal, @@ -430,7 +428,7 @@ export class NodeAPIService { } } - async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *deleteTrashedNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) { const response = await this.apiService.post( `drive/v2/volumes/${volumeId}/trash/delete_multiple`, @@ -445,6 +443,21 @@ export class NodeAPIService { } } + async *deleteExistingNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) { + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/delete_multiple`, + { + LinkIDs: batchNodeIds, + }, + signal, + ); + + // TODO: remove `as` when backend fixes OpenAPI schema. + yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + } + } + async createFolder( parentUid: string, newNode: { @@ -452,7 +465,7 @@ export class NodeAPIService { armoredHashKey: string; armoredNodePassphrase: string; armoredNodePassphraseSignature: string; - signatureEmail: string; + signatureEmail: string | AnonymousUser; encryptedName: string; hash: string; armoredExtendedAttributes?: string; diff --git a/js/sdk/src/internal/nodes/cryptoReporter.ts b/js/sdk/src/internal/nodes/cryptoReporter.ts index 76f51330..430cd2ef 100644 --- a/js/sdk/src/internal/nodes/cryptoReporter.ts +++ b/js/sdk/src/internal/nodes/cryptoReporter.ts @@ -34,7 +34,7 @@ export class NodesCryptoReporter { signatureType: string, verified: VERIFICATION_STATUS, verificationErrors?: Error[], - claimedAuthor?: string, + claimedAuthor?: string | AnonymousUser, notAvailableVerificationKeys = false, ): Promise { const author = handleClaimedAuthor( @@ -54,7 +54,7 @@ export class NodesCryptoReporter { node: { uid: string; creationTime: Date }, field: MetricVerificationErrorField, verificationErrors?: Error[], - claimedAuthor?: string, + claimedAuthor?: string | AnonymousUser, ) { if (this.reportedVerificationErrors.has(node.uid)) { return; @@ -128,7 +128,7 @@ function handleClaimedAuthor( signatureType: string, verified: VERIFICATION_STATUS, verificationErrors?: Error[], - claimedAuthor?: string, + claimedAuthor?: string | AnonymousUser, notAvailableVerificationKeys = false, ): Author { if (!claimedAuthor && notAvailableVerificationKeys) { diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 1fb8de87..e148f47e 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,7 +1,14 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; import { MemberRole, ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; -import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from './interface'; +import { + DecryptedNode, + DecryptedNodeKeys, + DecryptedUnparsedNode, + EncryptedNode, + NodeSigningKeys, + SharesService, +} from './interface'; import { NodesCryptoService } from './cryptoService'; import { NodesCryptoReporter } from './cryptoReporter'; @@ -1069,24 +1076,239 @@ describe('nodesCryptoService', () => { }); }); + describe('createFolder', () => { + let parentKeys: any; + + beforeEach(() => { + parentKeys = { + key: 'parentKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + driveCrypto.generateKey = jest.fn().mockResolvedValue({ + encrypted: { + armoredKey: 'encryptedNodeKey', + armoredPassphrase: 'encryptedPassphrase', + armoredPassphraseSignature: 'passphraseSignature', + }, + decrypted: { + key: 'nodeKey' as any, + passphrase: 'nodePassphrase', + passphraseSessionKey: 'passphraseSessionKey' as any, + }, + }); + driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({ + armoredNodeName: 'encryptedNodeName', + }); + driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('lookupHash'); + driveCrypto.generateHashKey = jest.fn().mockResolvedValue({ + armoredHashKey: 'encryptedHashKey', + hashKey: new Uint8Array([4, 5, 6]), + }); + driveCrypto.encryptExtendedAttributes = jest.fn().mockResolvedValue({ + armoredExtendedAttributes: 'encryptedAttributes', + }); + }); + + it('should encrypt new folder with account key', async () => { + const signingKeys: NodeSigningKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'addressKey' as any, + }; + + const result = await cryptoService.createFolder( + parentKeys, + signingKeys, + 'New Folder', + '{"modificationTime": 1234567890}', + ); + + expect(result).toEqual({ + encryptedCrypto: { + encryptedName: 'encryptedNodeName', + hash: 'lookupHash', + armoredKey: 'encryptedNodeKey', + armoredNodePassphrase: 'encryptedPassphrase', + armoredNodePassphraseSignature: 'passphraseSignature', + folder: { + armoredExtendedAttributes: 'encryptedAttributes', + armoredHashKey: 'encryptedHashKey', + }, + signatureEmail: 'test@example.com', + nameSignatureEmail: 'test@example.com', + }, + keys: { + passphrase: 'nodePassphrase', + key: 'nodeKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: new Uint8Array([4, 5, 6]), + }, + }); + + expect(driveCrypto.generateKey).toHaveBeenCalledWith([parentKeys.key], signingKeys.key); + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'New Folder', + undefined, + parentKeys.key, + signingKeys.key, + ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('New Folder', parentKeys.hashKey); + expect(driveCrypto.generateHashKey).toHaveBeenCalledWith('nodeKey'); + expect(driveCrypto.encryptExtendedAttributes).toHaveBeenCalledWith( + '{"modificationTime": 1234567890}', + 'nodeKey', + signingKeys.key, + ); + }); + + it('should encrypt new folder with node key', async () => { + const signingKeys: NodeSigningKeys = { + type: 'nodeKey', + nodeKey: 'nodeSigningKey' as any, + parentNodeKey: 'parentNodeKey' as any, + }; + + const result = await cryptoService.createFolder( + parentKeys, + signingKeys, + 'New Folder', + '{"modificationTime": 1234567890}', + ); + + expect(result).toEqual({ + encryptedCrypto: { + encryptedName: 'encryptedNodeName', + hash: 'lookupHash', + armoredKey: 'encryptedNodeKey', + armoredNodePassphrase: 'encryptedPassphrase', + armoredNodePassphraseSignature: 'passphraseSignature', + folder: { + armoredExtendedAttributes: 'encryptedAttributes', + armoredHashKey: 'encryptedHashKey', + }, + signatureEmail: null, + nameSignatureEmail: null, + }, + keys: { + passphrase: 'nodePassphrase', + key: 'nodeKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: new Uint8Array([4, 5, 6]), + }, + }); + + expect(driveCrypto.generateKey).toHaveBeenCalledWith([parentKeys.key], signingKeys.parentNodeKey); + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'New Folder', + undefined, + parentKeys.key, + signingKeys.parentNodeKey, + ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('New Folder', parentKeys.hashKey); + expect(driveCrypto.generateHashKey).toHaveBeenCalledWith('nodeKey'); + expect(driveCrypto.encryptExtendedAttributes).toHaveBeenCalledWith( + '{"modificationTime": 1234567890}', + 'nodeKey', + signingKeys.nodeKey, + ); + }); + }); + + describe('encryptNewName', () => { + let parentKeys: any; + let nodeNameSessionKey: SessionKey; + + beforeEach(() => { + parentKeys = { + key: 'parentKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + nodeNameSessionKey = 'nameSessionKey' as any; + driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({ + armoredNodeName: 'encryptedNewNodeName', + }); + driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('newHash'); + }); + + it('should encrypt new name with account key', async () => { + const signingKeys: NodeSigningKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'addressKey' as any, + }; + + const result = await cryptoService.encryptNewName( + parentKeys, + nodeNameSessionKey, + signingKeys, + 'Renamed File.txt', + ); + + expect(result).toEqual({ + signatureEmail: 'test@example.com', + armoredNodeName: 'encryptedNewNodeName', + hash: 'newHash', + }); + + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'Renamed File.txt', + nodeNameSessionKey, + parentKeys.key, + signingKeys.key, + ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('Renamed File.txt', parentKeys.hashKey); + }); + + it('should encrypt new name with node key', async () => { + const signingKeys: NodeSigningKeys = { + type: 'nodeKey', + nodeKey: 'nodeSigningKey' as any, + parentNodeKey: 'parentNodeKey' as any, + }; + + const result = await cryptoService.encryptNewName( + parentKeys, + nodeNameSessionKey, + signingKeys, + 'Renamed File.txt', + ); + + expect(result).toEqual({ + signatureEmail: null, + armoredNodeName: 'encryptedNewNodeName', + hash: 'newHash', + }); + + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'Renamed File.txt', + nodeNameSessionKey, + parentKeys.key, + signingKeys.parentNodeKey, + ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('Renamed File.txt', parentKeys.hashKey); + }); + }); + describe('encryptNodeWithNewParent', () => { - it('should encrypt node data for move operation', async () => { - const node = { + let node: DecryptedNode; + let keys: any; + let parentKeys: any; + + beforeEach(() => { + node = { name: { ok: true, value: 'testFile.txt' }, } as DecryptedNode; - const keys = { + keys = { passphrase: 'nodePassphrase', passphraseSessionKey: 'nodePassphraseSessionKey', nameSessionKey: 'nameSessionKey' as any, }; - const parentKeys = { + parentKeys = { key: 'newParentKey' as any, hashKey: new Uint8Array([1, 2, 3]), }; - const address = { - email: 'test@example.com', - addressKey: 'addressKey' as any, - }; driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({ armoredNodeName: 'encryptedNodeName', }); @@ -1095,8 +1317,17 @@ describe('nodesCryptoService', () => { armoredPassphrase: 'encryptedPassphrase', armoredPassphraseSignature: 'passphraseSignature', }); + }); + + it('should encrypt node data for move operation with account key (logged in context)', async () => { + const signingKeys: NodeSigningKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'addressKey' as any, + }; - const result = await cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, address); + const result = await cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, signingKeys); expect(result).toEqual({ encryptedName: 'encryptedNodeName', @@ -1111,14 +1342,47 @@ describe('nodesCryptoService', () => { 'testFile.txt', keys.nameSessionKey, parentKeys.key, - address.addressKey, + signingKeys.key, + ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('testFile.txt', parentKeys.hashKey); + expect(driveCrypto.encryptPassphrase).toHaveBeenCalledWith( + keys.passphrase, + keys.passphraseSessionKey, + [parentKeys.key], + signingKeys.key, + ); + }); + + it('should encrypt node data for move operation with node key (anonymous context)', async () => { + const signingKeys: NodeSigningKeys = { + type: 'nodeKey', + nodeKey: 'addressKey' as any, + parentNodeKey: 'parentNodeKey' as any, + }; + + const result = await cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, signingKeys); + + expect(result).toEqual({ + encryptedName: 'encryptedNodeName', + hash: 'newHash', + armoredNodePassphrase: 'encryptedPassphrase', + armoredNodePassphraseSignature: 'passphraseSignature', + signatureEmail: null, + nameSignatureEmail: null, + }); + + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'testFile.txt', + keys.nameSessionKey, + parentKeys.key, + signingKeys.nodeKey, ); expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('testFile.txt', parentKeys.hashKey); expect(driveCrypto.encryptPassphrase).toHaveBeenCalledWith( keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], - address.addressKey, + signingKeys.nodeKey, ); }); @@ -1135,13 +1399,15 @@ describe('nodesCryptoService', () => { key: 'newParentKey' as any, hashKey: undefined, } as any; - const address = { + const signingKeys: NodeSigningKeys = { + type: 'userAddress', email: 'test@example.com', - addressKey: 'addressKey' as any, + addressId: 'addressId', + key: 'addressKey' as any, }; await expect( - cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, address), + cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, signingKeys), ).rejects.toThrow('Moving item to a non-folder is not allowed'); }); @@ -1158,13 +1424,15 @@ describe('nodesCryptoService', () => { key: 'newParentKey' as any, hashKey: new Uint8Array([1, 2, 3]), }; - const address = { + const signingKeys: NodeSigningKeys = { + type: 'userAddress', email: 'test@example.com', - addressKey: 'addressKey' as any, + addressId: 'addressId', + key: 'addressKey' as any, }; await expect( - cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, address), + cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, signingKeys), ).rejects.toThrow('Cannot move item without a valid name, please rename the item first'); }); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 7c52a17c..35c5ecef 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -24,6 +24,7 @@ import { DecryptedNodeKeys, EncryptedRevision, DecryptedUnparsedRevision, + NodeSigningKeys, } from './interface'; export interface NodesCryptoReporter { @@ -33,7 +34,7 @@ export interface NodesCryptoReporter { signatureType: string, verified: VERIFICATION_STATUS, verificationErrors?: Error[], - claimedAuthor?: string, + claimedAuthor?: string | AnonymousUser, notAvailableVerificationKeys?: boolean, ): Promise; reportDecryptionError(node: NodesCryptoReporterNode, field: MetricsDecryptionErrorField, error: unknown): void; @@ -506,7 +507,7 @@ export class NodesCryptoService { encryptedExtendedAttributes: string | undefined, nodeKey: PrivateKey, addressKeys: PublicKey[], - signatureEmail?: string, + signatureEmail?: string | AnonymousUser, ): Promise<{ extendedAttributes?: string; author: Author; @@ -538,31 +539,44 @@ export class NodesCryptoService { async createFolder( parentKeys: { key: PrivateKey; hashKey: Uint8Array }, - address: { email: string; addressKey: PrivateKey }, + signingKeys: NodeSigningKeys, name: string, extendedAttributes?: string, ): Promise<{ - encryptedCrypto: EncryptedNodeFolderCrypto & { + encryptedCrypto: Omit & { + signatureEmail: string | AnonymousUser; + nameSignatureEmail: string | AnonymousUser; armoredNodePassphraseSignature: string; - // signatureEmail and nameSignatureEmail are not optional. - signatureEmail: string; - nameSignatureEmail: string; encryptedName: string; hash: string; }; keys: DecryptedNodeKeys; }> { - const { email, addressKey } = address; + const email = signingKeys.type === 'userAddress' ? signingKeys.email : null; + const nameAndPassprhaseSigningKey = + signingKeys.type === 'userAddress' ? signingKeys.key : signingKeys.parentNodeKey; + if (!nameAndPassprhaseSigningKey) { + // This is a bug within the SDK. + throw new Error('Cannot create new node without a name and passphrase signing key'); + } + const [nodeKeys, { armoredNodeName }, hash] = await Promise.all([ - this.driveCrypto.generateKey([parentKeys.key], addressKey), - this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, addressKey), + this.driveCrypto.generateKey([parentKeys.key], nameAndPassprhaseSigningKey), + this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, nameAndPassprhaseSigningKey), this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), ]); const { armoredHashKey, hashKey } = await this.driveCrypto.generateHashKey(nodeKeys.decrypted.key); + const extendedAttributesSigningKey = + signingKeys.type === 'userAddress' ? signingKeys.key : signingKeys.nodeKey || nodeKeys.decrypted.key; + const { armoredExtendedAttributes } = extendedAttributes - ? await this.driveCrypto.encryptExtendedAttributes(extendedAttributes, nodeKeys.decrypted.key, addressKey) + ? await this.driveCrypto.encryptExtendedAttributes( + extendedAttributes, + nodeKeys.decrypted.key, + extendedAttributesSigningKey, + ) : { armoredExtendedAttributes: undefined }; return { @@ -591,20 +605,25 @@ export class NodesCryptoService { async encryptNewName( parentKeys: { key: PrivateKey; hashKey?: Uint8Array }, nodeNameSessionKey: SessionKey, - address: { email: string; addressKey: PrivateKey }, + signingKeys: NodeSigningKeys, newName: string, ): Promise<{ - signatureEmail: string; + signatureEmail: string | AnonymousUser; armoredNodeName: string; hash?: string; }> { - const { email, addressKey } = address; + const email = signingKeys.type === 'userAddress' ? signingKeys.email : null; + const nameSigningKey = signingKeys.type === 'userAddress' ? signingKeys.key : signingKeys.parentNodeKey; + if (!nameSigningKey) { + // This is a bug within the SDK. + throw new Error('Cannot encrypt new node name without a name signing key'); + } const { armoredNodeName } = await this.driveCrypto.encryptNodeName( newName, nodeNameSessionKey, parentKeys.key, - addressKey, + nameSigningKey, ); const hash = parentKeys.hashKey @@ -621,14 +640,14 @@ export class NodesCryptoService { node: Pick, keys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey }, parentKeys: { key: PrivateKey; hashKey: Uint8Array }, - address: { email: string; addressKey: PrivateKey }, + signingKeys: NodeSigningKeys, ): Promise<{ encryptedName: string; hash: string; armoredNodePassphrase: string; armoredNodePassphraseSignature: string; - signatureEmail: string; - nameSignatureEmail: string; + signatureEmail: string | AnonymousUser; + nameSignatureEmail: string | AnonymousUser; }> { if (!parentKeys.hashKey) { throw new ValidationError('Moving item to a non-folder is not allowed'); @@ -637,19 +656,25 @@ export class NodesCryptoService { throw new ValidationError('Cannot move item without a valid name, please rename the item first'); } - const { email, addressKey } = address; + const email = signingKeys.type === 'userAddress' ? signingKeys.email : null; + const nameAndPassprhaseSigningKey = signingKeys.type === 'userAddress' ? signingKeys.key : signingKeys.nodeKey; + if (!nameAndPassprhaseSigningKey) { + // This is a bug within the SDK. + throw new Error('Cannot re-encrypt node without a name and passphrase signing key'); + } + const { armoredNodeName } = await this.driveCrypto.encryptNodeName( node.name.value, keys.nameSessionKey, parentKeys.key, - addressKey, + nameAndPassprhaseSigningKey, ); const hash = await this.driveCrypto.generateLookupHash(node.name.value, parentKeys.hashKey); const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase( keys.passphrase, keys.passphraseSessionKey, [parentKeys.key], - addressKey, + nameAndPassprhaseSigningKey, ); return { @@ -673,7 +698,7 @@ export class NodesCryptoService { } function getClaimedAuthor( - claimedAuthor?: string, + claimedAuthor?: string | AnonymousUser, notAvailableVerificationKeys = false, ): string | AnonymousUser | undefined { if (!claimedAuthor && notAvailableVerificationKeys) { diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 57c6cfa4..d9326f94 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -10,6 +10,7 @@ import { MetricVolumeType, Revision, RevisionState, + AnonymousUser, } from '../../interface'; export type FilterOptions = { @@ -57,8 +58,8 @@ export interface EncryptedNode extends BaseNode { } export interface EncryptedNodeCrypto { - signatureEmail?: string; - nameSignatureEmail?: string; + signatureEmail?: string | AnonymousUser; + nameSignatureEmail?: string | AnonymousUser; armoredKey: string; armoredNodePassphrase: string; armoredNodePassphraseSignature?: string; @@ -88,6 +89,19 @@ export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface EncryptedNodeAlbumCrypto extends EncryptedNodeCrypto {} +export type NodeSigningKeys = + | { + type: 'userAddress'; + email: string; + addressId: string; + key: PrivateKey; + } + | { + type: 'nodeKey'; + nodeKey?: PrivateKey; + parentNodeKey?: PrivateKey; + }; + /** * Interface used only internally in the nodes module. * diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index fe0290a0..7f96941d 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -29,6 +29,7 @@ import { DecryptedNode, DecryptedNodeKeys, FilterOptions, + NodeSigningKeys, } from './interface'; import { validateNodeName } from './validations'; import { isProtonDocument, isProtonSheet } from './mediaTypes'; @@ -425,6 +426,22 @@ export class NodesAccess { }; } + async getNodeSigningKeys( + uids: { nodeUid: string; parentNodeUid?: string } | { nodeUid?: string; parentNodeUid: string }, + ): Promise { + const contextNodeUid = uids.nodeUid || uids.parentNodeUid; + if (!contextNodeUid) { + throw new Error('Context node UID is required for signing keys'); + } + const address = await this.getRootNodeEmailKey(contextNodeUid); + return { + type: 'userAddress', + email: address.email, + addressId: address.addressId, + key: address.addressKey, + }; + } + async getRootNodeEmailKey(nodeUid: string): Promise<{ email: string; addressId: string; diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 92637e42..12c7ea09 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -57,7 +57,7 @@ describe('NodesManagement', () => { restoreNodes: jest.fn(async function* (uids) { yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult); }), - deleteNodes: jest.fn(async function* (uids) { + deleteTrashedNodes: jest.fn(async function* (uids) { yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult); }), createFolder: jest.fn(), @@ -117,7 +117,12 @@ describe('NodesManagement', () => { nameSessionKey: `${uid}-nameSessionKey`, }), ), - getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: 'root-email', addressKey: 'root-key' }), + getNodeSigningKeys: jest.fn().mockResolvedValue({ + type: 'userAddress', + email: 'root-email', + addressId: 'root-addressId', + key: 'root-key', + }), notifyNodeChanged: jest.fn(), notifyNodeDeleted: jest.fn(), notifyChildCreated: jest.fn(), @@ -136,11 +141,11 @@ describe('NodesManagement', () => { nameAuthor: { ok: true, value: 'newSignatureEmail' }, hash: 'newHash', }); - expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('nodeUid'); + expect(nodesAccess.getNodeSigningKeys).toHaveBeenCalledWith({ nodeUid: 'nodeUid', parentNodeUid: 'parentUid' }); expect(cryptoService.encryptNewName).toHaveBeenCalledWith( { key: 'parentUid-key', hashKey: 'parentUid-hashKey' }, 'nodeUid-nameSessionKey', - { email: 'root-email', addressKey: 'root-key' }, + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, 'new name', ); expect(apiService.renameNode).toHaveBeenCalledWith( @@ -181,7 +186,10 @@ describe('NodesManagement', () => { keyAuthor: { ok: true, value: 'movedSignatureEmail' }, nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, }); - expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid'); + expect(nodesAccess.getNodeSigningKeys).toHaveBeenCalledWith({ + nodeUid: 'nodeUid', + parentNodeUid: 'newParentNodeUid', + }); expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( nodes.nodeUid, expect.objectContaining({ @@ -192,7 +200,7 @@ describe('NodesManagement', () => { nameSessionKey: 'nodeUid-nameSessionKey', }), expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), - { email: 'root-email', addressKey: 'root-key' }, + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, ); expect(apiService.moveNode).toHaveBeenCalledWith( 'nodeUid', @@ -232,7 +240,7 @@ describe('NodesManagement', () => { nameSessionKey: 'anonymousNodeUid-nameSessionKey', }), expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), - { email: 'root-email', addressKey: 'root-key' }, + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, ); expect(newNode).toEqual({ ...nodes.anonymousNodeUid, @@ -276,7 +284,10 @@ describe('NodesManagement', () => { keyAuthor: { ok: true, value: 'copiedSignatureEmail' }, nameAuthor: { ok: true, value: 'copiedNameSignatureEmail' }, }); - expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid'); + expect(nodesAccess.getNodeSigningKeys).toHaveBeenCalledWith({ + nodeUid: 'nodeUid', + parentNodeUid: 'newParentNodeUid', + }); expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( nodes.nodeUid, expect.objectContaining({ @@ -287,7 +298,7 @@ describe('NodesManagement', () => { nameSessionKey: 'nodeUid-nameSessionKey', }), expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), - { email: 'root-email', addressKey: 'root-key' }, + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, ); expect(apiService.copyNode).toHaveBeenCalledWith('nodeUid', { parentUid: 'newParentNodeUid', @@ -322,7 +333,7 @@ describe('NodesManagement', () => { nameSessionKey: 'anonymousNodeUid-nameSessionKey', }), expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), - { email: 'root-email', addressKey: 'root-key' }, + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, ); expect(newNode).toEqual({ ...nodes.anonymousNodeUid, diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 2859e7bb..153eada4 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -28,10 +28,10 @@ const AVAILABLE_NAME_LIMIT = 1000; */ export class NodesManagement { constructor( - private apiService: NodeAPIService, - private cryptoCache: NodesCryptoCache, - private cryptoService: NodesCryptoService, - private nodesAccess: NodesAccess, + protected apiService: NodeAPIService, + protected cryptoCache: NodesCryptoCache, + protected cryptoService: NodesCryptoService, + protected nodesAccess: NodesAccess, ) { this.apiService = apiService; this.cryptoCache = cryptoCache; @@ -49,7 +49,7 @@ export class NodesManagement { const node = await this.nodesAccess.getNode(nodeUid); const { nameSessionKey: nodeNameSessionKey } = await this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid); const parentKeys = await this.nodesAccess.getParentKeys(node); - const address = await this.nodesAccess.getRootNodeEmailKey(nodeUid); + const signingKeys = await this.nodesAccess.getNodeSigningKeys({ nodeUid, parentNodeUid: node.parentUid }); if (!options.allowRenameRootNode && (!node.hash || !parentKeys.hashKey)) { throw new ValidationError(c('Error').t`Renaming root item is not allowed`); @@ -58,7 +58,7 @@ export class NodesManagement { const { signatureEmail, armoredNodeName, hash } = await this.cryptoService.encryptNewName( parentKeys, nodeNameSessionKey, - address, + signingKeys, newName, ); @@ -95,7 +95,7 @@ export class NodesManagement { ...node, name: resultOk(newName), encryptedName: armoredNodeName, - nameAuthor: resultOk(signatureEmail), + nameAuthor: resultOk(signatureEmail || null), hash, }; return newNode; @@ -124,14 +124,12 @@ export class NodesManagement { } async moveNode(nodeUid: string, newParentUid: string): Promise { - const [node, address] = await Promise.all([ - this.nodesAccess.getNode(nodeUid), - this.nodesAccess.getRootNodeEmailKey(newParentUid), - ]); + const node = await this.nodesAccess.getNode(nodeUid); - const [keys, newParentKeys] = await Promise.all([ + const [keys, newParentKeys, signingKeys] = await Promise.all([ this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid), this.nodesAccess.getNodeKeys(newParentUid), + this.nodesAccess.getNodeSigningKeys({ nodeUid, parentNodeUid: newParentUid }), ]); if (!node.hash) { @@ -145,7 +143,7 @@ export class NodesManagement { node, keys, { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, - address, + signingKeys, ); // Node could be uploaded or renamed by anonymous user and thus have @@ -214,14 +212,12 @@ export class NodesManagement { } async copyNode(nodeUid: string, newParentUid: string): Promise { - const [node, address] = await Promise.all([ - this.nodesAccess.getNode(nodeUid), - this.nodesAccess.getRootNodeEmailKey(newParentUid), - ]); + const node = await this.nodesAccess.getNode(nodeUid); - const [keys, newParentKeys] = await Promise.all([ + const [keys, newParentKeys, signingKeys] = await Promise.all([ this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid), this.nodesAccess.getNodeKeys(newParentUid), + this.nodesAccess.getNodeSigningKeys({ nodeUid, parentNodeUid: newParentUid }), ]); if (!newParentKeys.hashKey) { @@ -232,7 +228,7 @@ export class NodesManagement { node, keys, { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, - address, + signingKeys, ); // Node could be uploaded or renamed by anonymous user and thus have @@ -286,7 +282,7 @@ export class NodesManagement { } async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - for await (const result of this.apiService.deleteNodes(nodeUids, signal)) { + for await (const result of this.apiService.deleteTrashedNodes(nodeUids, signal)) { if (result.ok) { await this.nodesAccess.notifyNodeDeleted(result.uid); } @@ -303,12 +299,12 @@ export class NodesManagement { throw new ValidationError(c('Error').t`Creating folders in non-folders is not allowed`); } - const address = await this.nodesAccess.getRootNodeEmailKey(parentNodeUid); + const signingKeys = await this.nodesAccess.getNodeSigningKeys({ parentNodeUid }); const extendedAttributes = generateFolderExtendedAttributes(modificationTime); const { encryptedCrypto, keys } = await this.cryptoService.createFolder( { key: parentKeys.key, hashKey: parentKeys.hashKey }, - address, + signingKeys, folderName, extendedAttributes, ); @@ -344,8 +340,8 @@ export class NodesManagement { // Decrypted metadata isStale: false, - keyAuthor: resultOk(encryptedCrypto.signatureEmail), - nameAuthor: resultOk(encryptedCrypto.signatureEmail), + keyAuthor: resultOk(encryptedCrypto.signatureEmail || null), + nameAuthor: resultOk(encryptedCrypto.signatureEmail || null), name: resultOk(folderName), treeEventScopeId: splitNodeUid(nodeUid).volumeId, }; diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index 1bd0d8f0..dd656aa0 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -1,5 +1,5 @@ import { DriveCrypto } from '../../crypto'; -import { ProtonDriveTelemetry, UploadMetadata, Thumbnail } from '../../interface'; +import { ProtonDriveTelemetry, UploadMetadata, Thumbnail, AnonymousUser } from '../../interface'; import { DriveAPIService, drivePaths } from '../apiService'; import { generateFileExtendedAttributes } from '../nodes'; import { splitNodeRevisionUid } from '../uids'; @@ -198,7 +198,7 @@ export class PhotoUploadAPIService extends UploadAPIService { draftNodeRevisionUid: string, options: { armoredManifestSignature: string; - signatureEmail: string; + signatureEmail: string | AnonymousUser; armoredExtendedAttributes?: string; }, photo: { @@ -220,7 +220,7 @@ export class PhotoUploadAPIService extends UploadAPIService { XAttr: options.armoredExtendedAttributes || null, Photo: { ContentHash: photo.contentHash, - CaptureTime: photo.captureTime ? Math.floor(photo.captureTime?.getTime() /1000) : 0, + CaptureTime: photo.captureTime ? Math.floor(photo.captureTime?.getTime() / 1000) : 0, MainPhotoLinkID: photo.mainPhotoLinkID || null, Tags: photo.tags || [], Exif: null, // Deprecated field, not used. diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 23f08544..09155db8 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -10,13 +10,13 @@ import { NodeAPIService } from '../nodes/apiService'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; -import { NodesManagement } from '../nodes/nodesManagement'; import { NodesRevisons } from '../nodes/nodesRevisions'; import { SharingPublicCryptoReporter } from './cryptoReporter'; -import { SharingPublicNodesAccess } from './nodes'; +import { SharingPublicNodesAccess, SharingPublicNodesManagement } from './nodes'; import { SharingPublicSharesManager } from './shares'; export { SharingPublicSessionManager } from './session/manager'; +export { UnauthDriveAPIService } from './unauthApiService'; /** * Provides facade for the whole sharing public module. @@ -38,6 +38,7 @@ export function initSharingPublicModule( token: string, publicShareKey: PrivateKey, publicRootNodeUid: string, + isAnonymousContext: boolean, ) { const shares = new SharingPublicSharesManager(account, publicShareKey, publicRootNodeUid); const nodes = initSharingPublicNodesModule( @@ -52,6 +53,7 @@ export function initSharingPublicModule( token, publicShareKey, publicRootNodeUid, + isAnonymousContext, ); return { @@ -78,6 +80,7 @@ export function initSharingPublicNodesModule( token: string, publicShareKey: PrivateKey, publicRootNodeUid: string, + isAnonymousContext: boolean, ) { const clientUid = undefined; // No client UID for public context yet. const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService, clientUid); @@ -96,8 +99,9 @@ export function initSharingPublicNodesModule( token, publicShareKey, publicRootNodeUid, + isAnonymousContext, ); - const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess); + const nodesManagement = new SharingPublicNodesManagement(api, cryptoCache, cryptoService, nodesAccess); const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); return { diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index 78673566..f9d3b5ba 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -1,13 +1,14 @@ -import { ProtonDriveTelemetry } from '../../interface'; +import { NodeResult, ProtonDriveTelemetry } from '../../interface'; import { NodeAPIService } from '../nodes/apiService'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; import { NodesAccess } from '../nodes/nodesAccess'; +import { NodesManagement } from '../nodes/nodesManagement'; import { isProtonDocument, isProtonSheet } from '../nodes/mediaTypes'; import { splitNodeUid } from '../uids'; import { SharingPublicSharesManager } from './shares'; -import { DecryptedNode, DecryptedNodeKeys } from '../nodes/interface'; +import { DecryptedNode, DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface'; import { PrivateKey } from '../../crypto'; export class SharingPublicNodesAccess extends NodesAccess { @@ -22,11 +23,13 @@ export class SharingPublicNodesAccess extends NodesAccess { private token: string, private publicShareKey: PrivateKey, private publicRootNodeUid: string, + private isAnonymousContext: boolean, ) { super(telemetry, apiService, cache, cryptoCache, cryptoService, sharesService); this.token = token; this.publicShareKey = publicShareKey; this.publicRootNodeUid = publicRootNodeUid; + this.isAnonymousContext = isAnonymousContext; } async getParentKeys( @@ -56,4 +59,42 @@ export class SharingPublicNodesAccess extends NodesAccess { // Public link doesn't support specific node URLs. return this.url; } + + async getNodeSigningKeys( + uids: { nodeUid: string; parentNodeUid?: string } | { nodeUid?: string; parentNodeUid: string }, + ): Promise { + if (this.isAnonymousContext) { + const nodeKeys = uids.nodeUid ? await this.getNodeKeys(uids.nodeUid) : { key: undefined }; + const parentNodeKeys = uids.parentNodeUid ? await this.getNodeKeys(uids.parentNodeUid) : { key: undefined }; + return { + type: 'nodeKey', + nodeKey: nodeKeys.key, + parentNodeKey: parentNodeKeys.key, + }; + } + + return super.getNodeSigningKeys(uids); + } +} + +export class SharingPublicNodesManagement extends NodesManagement { + constructor( + apiService: NodeAPIService, + cryptoCache: NodesCryptoCache, + cryptoService: NodesCryptoService, + nodesAccess: SharingPublicNodesAccess, + ) { + super(apiService, cryptoCache, cryptoService, nodesAccess); + } + + async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + // Public link does not support trashing and deleting trashed nodes. + // Instead, if user is owner, API allows directly deleting existing nodes. + for await (const result of this.apiService.deleteExistingNodes(nodeUids, signal)) { + if (result.ok) { + await this.nodesAccess.notifyNodeDeleted(result.uid); + } + yield result; + } + } } diff --git a/js/sdk/src/internal/sharingPublic/unauthApiService.test.ts b/js/sdk/src/internal/sharingPublic/unauthApiService.test.ts new file mode 100644 index 00000000..36a79e4b --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/unauthApiService.test.ts @@ -0,0 +1,29 @@ +import { getUnauthEndpoint } from './unauthApiService'; + +describe('getUnauthEndpoint', () => { + it('should not change urls endpoints', () => { + expect(getUnauthEndpoint('drive/urls/anything')).toBe('drive/urls/anything'); + expect(getUnauthEndpoint('drive/urls/drive/anything')).toBe('drive/urls/drive/anything'); + expect(getUnauthEndpoint('drive/urls/drive/v2/anything')).toBe('drive/urls/drive/v2/anything'); + }); + + it('should not change v2/urls endpoints', () => { + expect(getUnauthEndpoint('drive/v2/urls/anything')).toBe('drive/v2/urls/anything'); + expect(getUnauthEndpoint('drive/v2/urls/drive/anything')).toBe('drive/v2/urls/drive/anything'); + expect(getUnauthEndpoint('drive/v2/urls/drive/v2/anything')).toBe('drive/v2/urls/drive/v2/anything'); + }); + + it('should put unauth prefix for v2 endpoints', () => { + expect(getUnauthEndpoint('drive/v2/anything')).toBe('drive/unauth/v2/anything'); + expect(getUnauthEndpoint('drive/v2/drive/anything')).toBe('drive/unauth/v2/drive/anything'); + expect(getUnauthEndpoint('drive/v2/drive/v2/anything')).toBe('drive/unauth/v2/drive/v2/anything'); + }); + + it('should put unauth prefix for non-v2 endpoints', () => { + expect(getUnauthEndpoint('drive/anything')).toBe('drive/unauth/anything'); + expect(getUnauthEndpoint('drive/anything/v2/anything')).toBe('drive/unauth/anything/v2/anything'); + expect(getUnauthEndpoint('drive/anything/drive/anything')).toBe('drive/unauth/anything/drive/anything'); + expect(getUnauthEndpoint('drive/anything/drive/v2/anything')).toBe('drive/unauth/anything/drive/v2/anything'); + }); +}); + diff --git a/js/sdk/src/internal/sharingPublic/unauthApiService.ts b/js/sdk/src/internal/sharingPublic/unauthApiService.ts new file mode 100644 index 00000000..64f82866 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/unauthApiService.ts @@ -0,0 +1,32 @@ +import { DriveAPIService } from '../apiService'; + +/** + * Drive API Service for public links. + * + * This service is used to make requests to the Drive API without + * authentication. The unauth context uses the same endpoint, but + * with an `unauth` prefix. The goal is to avoid the need to use + * different path and use the exact endpoint for both contexts. + * However, API has global logic for handling expired sessions that + * is not compatible with the unauth context. For this reason, this + * service is used to make requests to the Drive API for public + * link context in the mean time. + */ +export class UnauthDriveAPIService extends DriveAPIService { + protected async makeRequest( + url: string, + method = 'GET', + data?: RequestPayload, + signal?: AbortSignal, + ): Promise { + const unauthUrl = getUnauthEndpoint(url); + return super.makeRequest(unauthUrl, method, data, signal); + } +} + +export function getUnauthEndpoint(url: string): string { + if (url.startsWith('drive/urls/') || url.startsWith('drive/v2/urls/')) { + return url; + } + return url.replace(/^drive\//, 'drive/unauth/'); +} diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index a6df2f4b..4c742599 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -1,6 +1,7 @@ import { c } from 'ttag'; import { base64StringToUint8Array, uint8ArrayToBase64String } from '../../crypto'; +import { AnonymousUser } from '../../interface'; import { APICodeError, DriveAPIService, drivePaths, isCodeOk } from '../apiService'; import { splitNodeUid, makeNodeUid, splitNodeRevisionUid, makeNodeRevisionUid } from '../uids'; import { UploadTokens } from './interface'; @@ -65,7 +66,7 @@ export class UploadAPIService { armoredNodePassphraseSignature: string; base64ContentKeyPacket: string; armoredContentKeyPacketSignature: string; - signatureEmail: string; + signatureEmail: string | AnonymousUser; }, ): Promise<{ nodeUid: string; @@ -150,7 +151,7 @@ export class UploadAPIService { async requestBlockUpload( draftNodeRevisionUid: string, - addressId: string, + addressId: string | AnonymousUser, blocks: { contentBlocks: { index: number; @@ -211,7 +212,7 @@ export class UploadAPIService { draftNodeRevisionUid: string, options: { armoredManifestSignature: string; - signatureEmail: string; + signatureEmail: string | AnonymousUser; armoredExtendedAttributes?: string; }, ): Promise { diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 57643a76..1406dcaa 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -2,8 +2,15 @@ import { c } from 'ttag'; import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto'; import { IntegrityError } from '../../errors'; -import { Thumbnail } from '../../interface'; -import { EncryptedBlock, EncryptedThumbnail, NodeCrypto, NodeRevisionDraftKeys, NodesService } from './interface'; +import { Thumbnail, AnonymousUser } from '../../interface'; +import { + EncryptedBlock, + EncryptedThumbnail, + NodeCrypto, + NodeCryptoSigningKeys, + NodeRevisionDraftKeys, + NodesService, +} from './interface'; export class UploadCryptoService { constructor( @@ -19,11 +26,15 @@ export class UploadCryptoService { parentKeys: { key: PrivateKey; hashKey: Uint8Array }, name: string, ): Promise { - const signatureAddress = await this.nodesService.getRootNodeEmailKey(parentUid); + const signingKeys = await this.getSigningKeys({ parentNodeUid: parentUid }); + + if (!signingKeys.nameAndPassphraseSigningKey) { + throw new Error('Cannot create new node without a name and passphrase signing key'); + } const [nodeKeys, { armoredNodeName }, hash] = await Promise.all([ - this.driveCrypto.generateKey([parentKeys.key], signatureAddress.addressKey), - this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, signatureAddress.addressKey), + this.driveCrypto.generateKey([parentKeys.key], signingKeys.nameAndPassphraseSigningKey), + this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, signingKeys.nameAndPassphraseSigningKey), this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), ]); @@ -36,7 +47,57 @@ export class UploadCryptoService { encryptedName: armoredNodeName, hash, }, - signatureAddress, + signingKeys: { + email: signingKeys.email, + addressId: signingKeys.addressId, + nameAndPassphraseSigningKey: signingKeys.nameAndPassphraseSigningKey, + contentSigningKey: signingKeys.contentSigningKey || nodeKeys.decrypted.key, + }, + }; + } + + async getSigningKeysForExistingNode(uids: { + nodeUid: string; + parentNodeUid?: string; + }): Promise { + const signingKeys = await this.getSigningKeys(uids); + + if (!signingKeys.nameAndPassphraseSigningKey) { + throw new Error('Cannot get name and passphrase signing key for existing node'); + } + if (!signingKeys.contentSigningKey) { + throw new Error('Cannot get content signing key for existing node'); + } + + return { + email: signingKeys.email, + addressId: signingKeys.addressId, + nameAndPassphraseSigningKey: signingKeys.nameAndPassphraseSigningKey, + contentSigningKey: signingKeys.contentSigningKey, + }; + } + + private async getSigningKeys( + uids: { nodeUid: string; parentNodeUid?: string } | { nodeUid?: string; parentNodeUid: string }, + ): Promise< + Omit & { + nameAndPassphraseSigningKey?: PrivateKey; + contentSigningKey?: PrivateKey; + } + > { + const signingKeys = await this.nodesService.getNodeSigningKeys(uids); + + const email = signingKeys.type === 'userAddress' ? signingKeys.email : null; + const addressId = signingKeys.type === 'userAddress' ? signingKeys.addressId : null; + const nameAndPassphraseSigningKey = + signingKeys.type === 'userAddress' ? signingKeys.key : signingKeys.parentNodeKey; + const contentSigningKey = signingKeys.type === 'userAddress' ? signingKeys.key : signingKeys.nodeKey; + + return { + email, + addressId, + nameAndPassphraseSigningKey, + contentSigningKey, }; } @@ -47,7 +108,7 @@ export class UploadCryptoService { const { encryptedData } = await this.driveCrypto.encryptThumbnailBlock( thumbnail.thumbnail, nodeRevisionDraftKeys.contentKeyPacketSessionKey, - nodeRevisionDraftKeys.signatureAddress.addressKey, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, ); const digest = await crypto.subtle.digest('SHA-256', encryptedData); @@ -71,7 +132,7 @@ export class UploadCryptoService { block, nodeRevisionDraftKeys.key, nodeRevisionDraftKeys.contentKeyPacketSessionKey, - nodeRevisionDraftKeys.signatureAddress.addressKey, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, ); const digest = await crypto.subtle.digest('SHA-256', encryptedData); @@ -94,25 +155,25 @@ export class UploadCryptoService { extendedAttributes?: string, ): Promise<{ armoredManifestSignature: string; - signatureEmail: string; + signatureEmail: string | AnonymousUser; armoredExtendedAttributes?: string; }> { const { armoredManifestSignature } = await this.driveCrypto.signManifest( manifest, - nodeRevisionDraftKeys.signatureAddress.addressKey, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, ); const { armoredExtendedAttributes } = extendedAttributes ? await this.driveCrypto.encryptExtendedAttributes( extendedAttributes, nodeRevisionDraftKeys.key, - nodeRevisionDraftKeys.signatureAddress.addressKey, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, ) : { armoredExtendedAttributes: undefined }; return { armoredManifestSignature, - signatureEmail: nodeRevisionDraftKeys.signatureAddress.email, + signatureEmail: nodeRevisionDraftKeys.signingKeys.email, armoredExtendedAttributes, }; } diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index 95c3cccc..6bbc2b84 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -110,7 +110,7 @@ describe('FileUploader', () => { nodeRevisionUid: 'revisionUid', nodeUid: 'nodeUid', nodeKeys: { - signatureAddress: { addressId: 'addressId' }, + signingKeys: { addressId: 'addressId' }, }, } as NodeRevisionDraft; diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 021a2479..9701f7f6 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,6 +1,6 @@ import { PrivateKey, SessionKey } from '../../crypto'; -import { MetricVolumeType, ThumbnailType, Result, Revision } from '../../interface'; +import { MetricVolumeType, ThumbnailType, Result, Revision, AnonymousUser } from '../../interface'; import { DecryptedNode } from '../nodes'; export type NodeRevisionDraft = { @@ -22,7 +22,7 @@ export type NodeRevisionDraft = { export type NodeRevisionDraftKeys = { key: PrivateKey; contentKeyPacketSessionKey: SessionKey; - signatureAddress: NodeCryptoSignatureAddress; + signingKeys: NodeCryptoSigningKeys; }; export type NodeCrypto = { @@ -51,13 +51,14 @@ export type NodeCrypto = { encryptedName: string; hash: string; }; - signatureAddress: NodeCryptoSignatureAddress; + signingKeys: NodeCryptoSigningKeys; }; -export type NodeCryptoSignatureAddress = { - email: string; - addressId: string; - addressKey: PrivateKey; +export type NodeCryptoSigningKeys = { + email: string | AnonymousUser; + addressId: string | AnonymousUser; + nameAndPassphraseSigningKey: PrivateKey; + contentSigningKey: PrivateKey; }; export type EncryptedBlockMetadata = { @@ -102,12 +103,9 @@ export interface NodesService { contentKeyPacketSessionKey?: SessionKey; hashKey?: Uint8Array; }>; - getRootNodeEmailKey(nodeUid: string): Promise<{ - email: string; - addressId: string; - addressKey: PrivateKey; - addressKeyId: string; - }>; + getNodeSigningKeys( + uids: { nodeUid: string; parentNodeUid?: string } | { nodeUid?: string; parentNodeUid: string }, + ): Promise; notifyChildCreated(nodeUid: string): Promise; notifyNodeChanged(nodeUid: string): Promise; } @@ -126,6 +124,19 @@ export interface NodesServiceNode { activeRevision?: Result; } +export type NodeSigningKeys = + | { + type: 'userAddress'; + email: string; + addressId: string; + key: PrivateKey; + } + | { + type: 'nodeKey'; + nodeKey?: PrivateKey; + parentNodeKey?: PrivateKey; + }; + /** * Interface describing the dependencies to the shares module. */ diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 8ed2128f..69297c5b 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -50,7 +50,7 @@ describe('UploadManager', () => { encryptedName: 'newNode:encryptedName', hash: 'newNode:hash', }, - signatureAddress: { + signingKeys: { email: 'signatureEmail', }, }), @@ -69,7 +69,8 @@ describe('UploadManager', () => { hashKey: 'parentNode:hashKey', key: 'parentNode:nodekey', }), - getRootNodeEmailKey: jest.fn().mockResolvedValue({ + getNodeSigningKeys: jest.fn().mockResolvedValue({ + type: 'userAddress', email: 'signatureEmail', addressId: 'addressId', }), @@ -100,7 +101,7 @@ describe('UploadManager', () => { nodeKeys: { key: 'newNode:key', contentKeyPacketSessionKey: 'newNode:ContentKeyPacketSessionKey', - signatureAddress: { + signingKeys: { email: 'signatureEmail', }, }, @@ -153,7 +154,7 @@ describe('UploadManager', () => { nodeKeys: { key: 'newNode:key', contentKeyPacketSessionKey: 'newNode:ContentKeyPacketSessionKey', - signatureAddress: { + signingKeys: { email: 'signatureEmail', }, }, diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 31bcd2f5..2678788d 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -57,7 +57,7 @@ export class UploadManager { nodeKeys: { key: generatedNodeCrypto.nodeKeys.decrypted.key, contentKeyPacketSessionKey: generatedNodeCrypto.contentKey.decrypted.contentKeyPacketSessionKey, - signatureAddress: generatedNodeCrypto.signatureAddress, + signingKeys: generatedNodeCrypto.signingKeys, }, parentNodeKeys: { hashKey: parentKeys.hashKey, @@ -93,7 +93,7 @@ export class UploadManager { base64ContentKeyPacket: generatedNodeCrypto.contentKey.encrypted.base64ContentKeyPacket, armoredContentKeyPacketSignature: generatedNodeCrypto.contentKey.encrypted.armoredContentKeyPacketSignature, - signatureEmail: generatedNodeCrypto.signatureAddress.email, + signatureEmail: generatedNodeCrypto.signingKeys.email, }); return result; } catch (error: unknown) { @@ -192,7 +192,10 @@ export class UploadManager { throw new ValidationError(c('Error').t`Creating revisions in non-files is not allowed`); } - const signatureAddress = await this.nodesService.getRootNodeEmailKey(nodeUid); + const signingKeys = await this.cryptoService.getSigningKeysForExistingNode({ + nodeUid, + parentNodeUid: node.parentUid, + }); const { nodeRevisionUid } = await this.apiService.createDraftRevision(nodeUid, { currentRevisionUid: node.activeRevision.value.uid, @@ -205,7 +208,7 @@ export class UploadManager { nodeKeys: { key: nodeKeys.key, contentKeyPacketSessionKey: nodeKeys.contentKeyPacketSessionKey, - signatureAddress: signatureAddress, + signingKeys, }, }; } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index a6dfd69d..387d068d 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -110,7 +110,9 @@ describe('StreamUploader', () => { nodeRevisionUid: 'revisionUid', nodeUid: 'nodeUid', nodeKeys: { - signatureAddress: { addressId: 'addressId' }, + signingKeys: { + addressId: 'addressId', + }, }, } as NodeRevisionDraft; @@ -416,7 +418,7 @@ describe('StreamUploader', () => { expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(2); expect(apiService.requestBlockUpload).toHaveBeenCalledWith( revisionDraft.nodeRevisionUid, - revisionDraft.nodeKeys.signatureAddress.addressId, + revisionDraft.nodeKeys.signingKeys.addressId, { contentBlocks: [ { diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 55eda073..43fd5434 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -319,7 +319,7 @@ export class StreamUploader { this.logger.info(`Requesting upload tokens for ${this.encryptedBlocks.size} blocks`); const uploadTokens = await this.apiService.requestBlockUpload( this.revisionDraft.nodeRevisionUid, - this.revisionDraft.nodeKeys.signatureAddress.addressId, + this.revisionDraft.nodeKeys.signingKeys.addressId, { contentBlocks: Array.from( this.encryptedBlocks.values().map((block) => ({ @@ -507,7 +507,7 @@ export class StreamUploader { logger.warn(`Token expired, fetching new token and retrying`); const uploadTokens = await this.apiService.requestBlockUpload( this.revisionDraft.nodeRevisionUid, - this.revisionDraft.nodeKeys.signatureAddress.addressId, + this.revisionDraft.nodeKeys.signingKeys.addressId, { contentBlocks: [ { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 3814d033..f5ebd8d9 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -106,7 +106,11 @@ export class ProtonDriveClient { * Experimental feature to authenticate a public link and * return the client for the public link to access it. */ - authPublicLink: (url: string, customPassword?: string) => Promise; + authPublicLink: ( + url: string, + customPassword?: string, + isAnonymousContext?: boolean, + ) => Promise; }; constructor({ @@ -222,7 +226,7 @@ export class ProtonDriveClient { this.logger.info(`Getting info for public link ${url}`); return this.publicSessionManager.getInfo(url); }, - authPublicLink: async (url: string, customPassword?: string) => { + authPublicLink: async (url: string, customPassword?: string, isAnonymousContext: boolean = false) => { this.logger.info(`Authenticating public link ${url}`); const { httpClient, token, shareKey, rootUid } = await this.publicSessionManager.auth( url, @@ -239,6 +243,7 @@ export class ProtonDriveClient { token, publicShareKey: shareKey, publicRootNodeUid: rootUid, + isAnonymousContext, }); }, }; diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 964dc50c..85861daf 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -27,10 +27,9 @@ import { convertInternalMissingNodeIterator, getUids, } from './transformers'; -import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; import { SDKEvents } from './internal/sdkEvents'; -import { initSharingPublicModule } from './internal/sharingPublic'; +import { initSharingPublicModule, UnauthDriveAPIService } from './internal/sharingPublic'; import { initUploadModule } from './internal/upload'; /** @@ -81,6 +80,7 @@ export class ProtonDrivePublicLinkClient { token, publicShareKey, publicRootNodeUid, + isAnonymousContext, }: { httpClient: ProtonDriveHTTPClient; account: ProtonDriveAccount; @@ -92,6 +92,7 @@ export class ProtonDrivePublicLinkClient { token: string; publicShareKey: PrivateKey; publicRootNodeUid: string; + isAnonymousContext: boolean; }) { if (!telemetry) { telemetry = new Telemetry(); @@ -105,7 +106,7 @@ export class ProtonDrivePublicLinkClient { const fullConfig = getConfig(config); this.sdkEvents = new SDKEvents(telemetry); - const apiService = new DriveAPIService( + const apiService = new UnauthDriveAPIService( telemetry, this.sdkEvents, httpClient, @@ -124,6 +125,7 @@ export class ProtonDrivePublicLinkClient { token, publicShareKey, publicRootNodeUid, + isAnonymousContext, ); this.download = initDownloadModule( telemetry, @@ -133,6 +135,9 @@ export class ProtonDrivePublicLinkClient { this.sharingPublic.shares, this.sharingPublic.nodes.access, this.sharingPublic.nodes.revisions, + // Ignore manifest integrity verifications for public links. + // Anonymous user on public page cannot load public keys of other users (yet). + true, ); this.upload = initUploadModule( telemetry, From 14e8eb856e7f513100cde6059f01ebac3d544f75 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 14:34:10 +0000 Subject: [PATCH 334/791] js/v0.7.0 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 78908f59..0e1d2977 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.4.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.4.1", + "version": "0.7.0", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index c5db043e..17f67b77 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.6.2", + "version": "0.7.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 049e14abc1d8d4ed25c5ec5ceb89c5f1c6b30165 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 16:49:18 +0100 Subject: [PATCH 335/791] Add HTTP timeouts and ability to cancel requests through interop --- .../InteropProtonDriveClient.cs | 5 +- .../Proton.Drive.Sdk/Api/DriveApiClients.cs | 16 ++--- .../Http/HttpClientFactoryExtensions.cs | 13 ++++ .../Nodes/Upload/RevisionWriter.cs | 4 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 18 +++-- .../src/Proton.Sdk.CExports/InteropAction.cs | 31 +++++++++ .../InteropActionExtensions.cs | 15 +++++ .../InteropHttpClientFactory.cs | 30 ++++++--- .../src/Proton.Sdk.CExports/InteropStream.cs | 9 +-- .../InteropTelemetryExtensions.cs | 6 +- cs/sdk/src/protos/proton.drive.sdk.proto | 34 ++++++---- .../Sources/Plumbing/InternalTypes.swift | 7 ++ .../HttpClientRequestProcessor.swift | 67 +++++++++++++------ .../Model/BoxedCancellableTask.swift | 37 ++++++++++ .../ProtonDriveClient/ProtonDriveClient.swift | 11 +-- 15 files changed, 225 insertions(+), 78 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Http/HttpClientFactoryExtensions.cs create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 94fea142..cbefdf9d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -22,8 +22,9 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi bindingsHandle, request.BaseUrl, request.BindingsLanguage, - new InteropAction, nint>(request.HttpClientRequestAction), - new InteropAction, nint>(request.HttpResponseReadAction)); + new InteropFunction, nint, nint>(request.HttpClient.RequestFunction), + new InteropAction, nint>(request.HttpClient.ResponseContentReadAction), + new InteropAction(request.HttpClient.CancellationAction)); var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction)); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs index 4fc33513..afda5889 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -7,13 +7,13 @@ namespace Proton.Drive.Sdk.Api; -internal sealed class DriveApiClients(HttpClient httpClient) : IDriveApiClients +internal sealed class DriveApiClients(HttpClient defaultHttpClient, HttpClient storageHttpClient) : IDriveApiClients { - public IVolumesApiClient Volumes { get; } = new VolumesApiClient(httpClient); - public ISharesApiClient Shares { get; } = new SharesApiClient(httpClient); - public ILinksApiClient Links { get; } = new LinksApiClient(httpClient); - public IFoldersApiClient Folders { get; } = new FoldersApiClient(httpClient); - public IFilesApiClient Files { get; } = new FilesApiClient(httpClient); - public IStorageApiClient Storage { get; } = new StorageApiClient(httpClient); - public ITrashApiClient Trash { get; } = new TrashApiClient(httpClient); + public IVolumesApiClient Volumes { get; } = new VolumesApiClient(defaultHttpClient); + public ISharesApiClient Shares { get; } = new SharesApiClient(defaultHttpClient); + public ILinksApiClient Links { get; } = new LinksApiClient(defaultHttpClient); + public IFoldersApiClient Folders { get; } = new FoldersApiClient(defaultHttpClient); + public IFilesApiClient Files { get; } = new FilesApiClient(defaultHttpClient); + public IStorageApiClient Storage { get; } = new StorageApiClient(storageHttpClient); + public ITrashApiClient Trash { get; } = new TrashApiClient(defaultHttpClient); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Http/HttpClientFactoryExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Http/HttpClientFactoryExtensions.cs new file mode 100644 index 00000000..bc3f98a6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Http/HttpClientFactoryExtensions.cs @@ -0,0 +1,13 @@ +namespace Proton.Drive.Sdk.Http; + +internal static class HttpClientFactoryExtensions +{ + public static HttpClient CreateClientWithTimeout(this IHttpClientFactory httpClientFactory, int timeoutSeconds) + { + var client = httpClientFactory.CreateClient(); + + client.Timeout = TimeSpan.FromSeconds(timeoutSeconds); + + return client; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 6cc8d5de..190cfd23 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -126,8 +126,6 @@ await TryGetBlockPlainDataStreamAsync( { await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, linkedCancellationToken).ConfigureAwait(false); - plainDataStream.Seek(0, SeekOrigin.Begin); - var onBlockProgress = onProgress is not null ? progress => { @@ -288,6 +286,8 @@ private static async ValueTask RegisterNextCompletedBlockAsync(Queue /// Creates a new instance of . @@ -29,7 +32,8 @@ public sealed class ProtonDriveClient /// If no UID is not provided, one will be generated for the duration of this instance. public ProtonDriveClient(ProtonApiSession session, string? uid = null) : this( - session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds)), + session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(DefaultApiTimeoutSeconds)), + session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(StorageApiTimeoutSeconds)), new AccountClientAdapter(session), new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), session.ClientConfiguration.FeatureFlagProvider, @@ -48,7 +52,8 @@ public ProtonDriveClient( string? bindingsLanguage = null, string? uid = null) : this( - new SdkHttpClientFactoryDecorator(httpClientFactory, bindingsLanguage).CreateClient(), + new SdkHttpClientFactoryDecorator(httpClientFactory, bindingsLanguage).CreateClientWithTimeout(DefaultApiTimeoutSeconds), + new SdkHttpClientFactoryDecorator(httpClientFactory, bindingsLanguage).CreateClientWithTimeout(StorageApiTimeoutSeconds), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), featureFlagProvider, @@ -91,7 +96,8 @@ internal ProtonDriveClient( } private ProtonDriveClient( - HttpClient httpClient, + HttpClient defaultApiHttpClient, + HttpClient storageApiHttpClient, IAccountClient accountClient, IDriveClientCache cache, IFeatureFlagProvider featureFlagProvider, @@ -99,9 +105,9 @@ private ProtonDriveClient( string uid) : this( accountClient, - new DriveApiClients(httpClient), + new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), cache, - new BlockVerifierFactory(httpClient), + new BlockVerifierFactory(defaultApiHttpClient), featureFlagProvider, telemetry, uid) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs index 3de9eb31..dd8e2736 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs @@ -88,3 +88,34 @@ public override string ToString() return $"0x{new nint(_pointer):x16}"; } } + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropFunction + where T1 : unmanaged + where T2 : unmanaged + where T3 : unmanaged + where T4 : unmanaged +{ + private readonly delegate* unmanaged[Cdecl] _pointer; + + public InteropFunction(delegate* unmanaged[Cdecl] pointer) + { + ArgumentNullException.ThrowIfNull(pointer); + _pointer = pointer; + } + + public InteropFunction(long pointer) + : this((delegate* unmanaged[Cdecl])pointer) + { + } + + public T4 Invoke(T1 arg1, T2 arg2, T3 arg3) + { + return _pointer(arg1, arg2, arg3); + } + + public override string ToString() + { + return $"0x{new nint(_pointer):x16}"; + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs index b05a3a37..08bf9803 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs @@ -27,6 +27,21 @@ public static unsafe void InvokeWithMessage(this InteropAction( + this InteropFunction, nint, nint> function, + nint bindingsHandle, + T message, + nint sdkHandle) + where T : IMessage + { + var responseBytes = message.ToByteArray(); + + fixed (byte* responsePointer = responseBytes) + { + return function.Invoke(bindingsHandle, new InteropArray(responsePointer, responseBytes.Length), sdkHandle); + } + } + public static unsafe ValueTask SendRequestAsync( this InteropAction, nint> interopAction, nint bindingsHandle, diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index ef64b3fe..58dd23ac 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -12,18 +12,21 @@ public InteropHttpClientFactory( nint bindingsHandle, string baseUrl, string? bindingsLanguage, - InteropAction, nint> httpRequestAction, - InteropAction, nint> httpResponseReadAction) + InteropFunction, nint, nint> requestFunction, + InteropAction, nint> responseContentReadAction, + InteropAction cancellationAction) { _baseUrl = baseUrl; BindingsHandle = bindingsHandle; - HttpRequestAction = httpRequestAction; - HttpResponseReadAction = httpResponseReadAction; + RequestFunction = requestFunction; + ResponseContentReadAction = responseContentReadAction; + CancellationAction = cancellationAction; } private nint BindingsHandle { get; } - private InteropAction, nint> HttpRequestAction { get; } - private InteropAction, IntPtr> HttpResponseReadAction { get; } + private InteropFunction, nint, nint> RequestFunction { get; } + private InteropAction, nint> ResponseContentReadAction { get; } + private InteropAction CancellationAction { get; } public HttpClient CreateClient(string name) { @@ -48,11 +51,17 @@ protected override async Task SendAsync(HttpRequestMessage try { - _owner.HttpRequestAction.InvokeWithMessage(_owner.BindingsHandle, interopHttpRequest, (nint)taskCompletionSourceHandle); + var foreignCancellationHandle = _owner.RequestFunction.InvokeWithMessage( + _owner.BindingsHandle, + interopHttpRequest, + (nint)taskCompletionSourceHandle); - var interopHttpResponse = await taskCompletionSource.Task.ConfigureAwait(false); + await using (cancellationToken.Register(x => ((InteropHttpClientFactory)x!).CancellationAction.Invoke(foreignCancellationHandle), _owner)) + { + var interopHttpResponse = await taskCompletionSource.Task.ConfigureAwait(false); - return ConvertHttpResponseFromInterop(interopHttpResponse); + return ConvertHttpResponseFromInterop(interopHttpResponse); + } } finally { @@ -97,7 +106,8 @@ private HttpResponseMessage ConvertHttpResponseFromInterop(HttpResponse interopH if (interopHttpResponse.HasBindingsContentHandle) { - response.Content = new StreamContent(new InteropStream(null, (nint)interopHttpResponse.BindingsContentHandle, _owner.HttpResponseReadAction)); + response.Content = new StreamContent( + new InteropStream(null, (nint)interopHttpResponse.BindingsContentHandle, _owner.ResponseContentReadAction)); } foreach (var interopHttpResponseHeader in interopHttpResponse.Headers) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs index 52b3ba96..1e62c72c 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -1,4 +1,4 @@ -using System.Buffers; +using System.Buffers; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; @@ -57,43 +57,36 @@ public override void Flush() public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) { - Console.WriteLine("IteropStream.CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)"); return base.CopyToAsync(destination, bufferSize, cancellationToken); } public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) { - Console.WriteLine("IteropStream.BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state)"); return base.BeginRead(buffer, offset, count, callback, state); } public override int ReadByte() { - Console.WriteLine("IteropStream.ReadByte()"); return base.ReadByte(); } public override int Read(byte[] buffer, int offset, int count) { - Console.WriteLine("IteropStream.Read(byte[] buffer, int offset, int count)"); return ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); } public override int Read(Span buffer) { - Console.WriteLine("IteropStream.Read(Span buffer)"); return base.Read(buffer); } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - Console.WriteLine("IteropStream.ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)"); return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - Console.WriteLine("IteropStream.ReadAsync(Memory buffer, CancellationToken cancellationToken = default)"); if (_readAction is null) { throw new NotSupportedException("Reading not supported"); diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetryExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetryExtensions.cs index e8e39c5a..81901a67 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetryExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropTelemetryExtensions.cs @@ -10,8 +10,8 @@ internal static class InteropTelemetryExtensions var loggerFactory = GetLoggerFactory(telemetry, bindingsHandle); var recordMetricAction = telemetry.HasRecordMetricAction - ? new InteropAction>(telemetry.RecordMetricAction) - : default(InteropAction>?); + ? new InteropAction>(telemetry.RecordMetricAction) + : default(InteropAction>?); if (loggerFactory is null && recordMetricAction is null) { @@ -31,7 +31,7 @@ internal static class InteropTelemetryExtensions if (telemetry.HasLogAction) { - var logAction = new InteropAction>(telemetry.LogAction); + var logAction = new InteropAction>(telemetry.LogAction); return new LoggerFactory([new InteropLoggerProvider(bindingsHandle, logAction)]); } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index ac134353..afb49256 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -122,42 +122,48 @@ message UploadResult { string revision_uid = 2; } -// The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. -message DriveClientCreateRequest { - string base_url = 1; - string bindings_language = 2; // Optional - +message HttpClient { // Pointer to C function that will be called: - // void handle_http_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) + // intptr_t handle_http_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) // bindings_handle: handle for the bindings // http_request: Protobuf message of type proton.sdk.HttpRequest carrying the HTTP request data // sdk_handle: handle for the SDK - int64 http_client_request_action = 3; + // Returns a cancellation handle to be passed to the cancellation action. That handle can be freed after the request is completed, but there could be + // a race condition where the cancellation is still called after the request is completed, so this must be accounted for. + int64 request_function = 1; + int64 response_content_read_action = 2; // C signature: void handle_http_response_read(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); + int64 cancellation_action = 3; // C signature: void handle_http_cancellation(intptr_t bindings_operation_handle); +} + +// The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. +message DriveClientCreateRequest { + string base_url = 1; + string bindings_language = 2; // Optional - int64 http_response_read_action = 4; // C signature: void handle_http_response_read(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); + HttpClient http_client = 3; // Pointer to C function that will be called: // void handle_account_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) // bindings_handle: handle for the bindings // account_request: Protobuf message of type proton.drive.sdk.AccountRequest carrying the request data // sdk_handle: handle for the SDK - int64 account_request_action = 5; + int64 account_request_action = 6; - string entity_cache_path = 6; // Optional - string secret_cache_path = 7; // Optional + string entity_cache_path = 7; // Optional + string secret_cache_path = 8; // Optional - proton.sdk.Telemetry telemetry = 8; // Optional + proton.sdk.Telemetry telemetry = 9; // Optional // Client UID, optional // If a null value is provided, the SDK automatically generates a UUID during initialization - string uid = 9; + string uid = 10; // Pointer to C function that will be called to check feature flags: // int is_feature_flag_enabled(intptr_t bindings_handle, ByteArray flag_name) // bindings_handle: handle for the bindings // flag_name: UTF-8 encoded feature flag name // returns: 0 for disabled, non-zero for enabled - int64 feature_enabled_function = 10; // Optional + int64 feature_enabled_function = 11; // Optional } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index 70c9fa7a..e2bbb992 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -18,6 +18,12 @@ extension ObjectHandle { self = ObjectHandle(bitPattern: callbackAddress) } + /// Returns the address of a callback as a number + init(callback: CCallbackWithCallbackPointerAndIntReturn) { + let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) + self = ObjectHandle(bitPattern: callbackAddress) + } + /// Returns the address of a callback with int return as a number init(callback: CCallbackWithIntReturn) { let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) @@ -39,6 +45,7 @@ func address(of object: T) -> ObjectHandle { /// C-compatible callback used by SDK to pass data to the app typealias CCallback = @convention(c) (Int, ByteArray) -> Void typealias CCallbackWithCallbackPointer = @convention(c) (Int, ByteArray, Int) -> Void +typealias CCallbackWithCallbackPointerAndIntReturn = @convention(c) (Int, ByteArray, Int) -> Int typealias CCallbackWithIntReturn = @convention(c) (Int, ByteArray) -> Int32 extension Data { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift index cdcfcf4b..d8251bfa 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -1,29 +1,54 @@ import Foundation enum HttpClientRequestProcessor { + static let cCompatibleHttpRequest: CCallbackWithCallbackPointerAndIntReturn = { statePointer, byteArray, callbackPointer in + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + return 0 + } + let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) + let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state + + let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { stateTypedPointer.release() }, weakDriveClient: weakDriveClient) + guard let driveClient else { return 0 } + + // Create a boxed task with the HTTP work + let taskBox = BoxedCancellableTask { [driveClient] in + let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) + try await HttpClientRequestProcessor.perform( + client: driveClient, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer + ) + } + + // Retain the task box and return its address as the cancellation handle + let unmanaged = Unmanaged.passRetained(taskBox) + let handle = Int(bitPattern: unmanaged.toOpaque()) + + // Set completion handler to release the Unmanaged reference when done + taskBox.setCompletionHandler { + unmanaged.release() + } + + return handle + } - static let cCompatibleHttpRequest: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in - Task { - do { - guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { - return - } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) - let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state - - let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { stateTypedPointer.release() }, weakDriveClient: weakDriveClient) - guard let driveClient else { return } - - - let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) - try await HttpClientRequestProcessor.perform(client: driveClient, httpRequestData: httpRequestData, callbackPointer: callbackPointer) - } catch { - SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) - } + static let cCompatibleHttpCancellationAction: CCallback = { handle, _ in + // Convert the address back to the task box + guard let pointer = UnsafeRawPointer(bitPattern: Int(handle)) else { + print("Invalid cancellation handle: \(handle)") + return } + + // Get the task box and cancel it + let unmanaged = Unmanaged.fromOpaque(pointer) + let taskBox = unmanaged.takeUnretainedValue() + taskBox.cancel() + + // Release our reference (matching the passRetained in cCompatibleHttpRequest) + unmanaged.release() } - fileprivate static func perform( client: ProtonDriveClient, httpRequestData: Proton_Sdk_HttpRequest, @@ -33,7 +58,7 @@ enum HttpClientRequestProcessor { switch requestType { case .driveAPI(let driveRelativePath): - try await performDriveApi( + try await callDriveApi( driveRelativePath: driveRelativePath, client: client, httpRequestData: httpRequestData, @@ -55,7 +80,7 @@ enum HttpClientRequestProcessor { } /// the API calls are performed in a non-streaming way. both request body and response data are buffered in memory - fileprivate static func performDriveApi( + fileprivate static func callDriveApi( driveRelativePath: String, client: ProtonDriveClient, httpRequestData: Proton_Sdk_HttpRequest, diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift new file mode 100644 index 00000000..054672c6 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Boxed task that can be cancelled via its memory address. +/// Retained via Unmanaged until completion or cancellation. +final class BoxedCancellableTask: @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + private var onComplete: (() -> Void)? + + init(work: @escaping @Sendable () async throws -> Void) { + task = Task { [weak self] in + defer { + self?.onComplete?() + } + try? await work() + } + } + + func setCompletionHandler(_ handler: @escaping () -> Void) { + lock.lock() + defer { lock.unlock() } + onComplete = handler + } + + func cancel() { + lock.lock() + let taskToCancel = task + let completionHandler = onComplete + task = nil + onComplete = nil + lock.unlock() + + taskToCancel?.cancel() + // Call completion handler since we're done with this task box (to release it) + completionHandler?() + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 895d54ed..08888dda 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -43,10 +43,13 @@ public actor ProtonDriveClient: Sendable { $0.baseURL = baseURL $0.accountRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) - - $0.httpClientRequestAction = Int64(ObjectHandle(callback: HttpClientRequestProcessor.cCompatibleHttpRequest)) - $0.httpResponseReadAction = Int64(ObjectHandle(callback: HttpClientResponseProcessor.cCompatibleHttpResponseRead)) - + + $0.httpClient = Proton_Drive_Sdk_HttpClient.with { httpClient in + httpClient.requestFunction = Int64(ObjectHandle(callback: HttpClientRequestProcessor.cCompatibleHttpRequest)) + httpClient.responseContentReadAction = Int64(ObjectHandle(callback: HttpClientResponseProcessor.cCompatibleHttpResponseRead)) + httpClient.cancellationAction = Int64(ObjectHandle(callback: HttpClientRequestProcessor.cCompatibleHttpCancellationAction)) + } + $0.telemetry = Proton_Sdk_Telemetry.with { $0.logAction = Int64(ObjectHandle(callback: cCompatibleLogCallback)) $0.recordMetricAction = Int64(ObjectHandle(callback: cCompatibleTelemetryRecordMetricCallback)) From c10b11cab96b2a94ebaa39779e87ffcd8169ea85 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 17:12:32 +0000 Subject: [PATCH 336/791] Add auto-retries into HTTP client bridge for certain HTTP errors: 401, 429, 5xx --- kt/libs.versions.toml | 8 +++ kt/sdk/build.gradle.kts | 1 + .../me/proton/drive/sdk/extension/Duration.kt | 16 ++++++ .../drive/sdk/internal/ApiProviderBridge.kt | 26 ++++++--- .../drive/sdk/internal/RetryAfterDelay.kt | 56 +++++++++++++++++++ 5 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Duration.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/RetryAfterDelay.kt diff --git a/kt/libs.versions.toml b/kt/libs.versions.toml index 6e03d6c7..649ee525 100644 --- a/kt/libs.versions.toml +++ b/kt/libs.versions.toml @@ -27,6 +27,7 @@ fusion = "0.9.97" testParameterInjector = "1.10" protobufKotlinLite = "4.29.2" protobufJavaLite = "4.29.2" +mockk = "1.13.9" [libraries] @@ -92,6 +93,7 @@ fusion = { module = "me.proton.test:fusion", version.ref = "fusion"} testParameterInjector = { module = "com.google.testparameterinjector:test-parameter-injector", version.ref = "testParameterInjector" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobufKotlinLite" } protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobufJavaLite" } +mockk-jvm = { module = "io.mockk:mockk", version.ref = "mockk" } [bundles] test-android = [ @@ -101,6 +103,12 @@ test-android = [ "androidx-test-runner", "androidx-test-rules", ] +test-jvm = [ + "junit", + "mockk-jvm", + "coroutines-test", + "androidx-test-core-ktx", +] [plugins] android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } diff --git a/kt/sdk/build.gradle.kts b/kt/sdk/build.gradle.kts index d44e53a5..66443dc3 100644 --- a/kt/sdk/build.gradle.kts +++ b/kt/sdk/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { implementation(libs.retrofit) implementation(libs.core.user.domain) implementation(libs.core.network.data) + testImplementation(libs.bundles.test.jvm) androidTestImplementation(libs.coroutines.test) androidTestImplementation(libs.androidx.test.core.ktx) androidTestImplementation(libs.androidx.test.runner) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Duration.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Duration.kt new file mode 100644 index 00000000..ba2d410a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Duration.kt @@ -0,0 +1,16 @@ +package me.proton.drive.sdk.extension + +import kotlin.ranges.coerceIn +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +fun Duration?.coerceInOrElse( + minValue: Duration, + maxValue: Duration, + defaultValue: Duration = 10.seconds, +) = this?.inWholeNanoseconds?.coerceIn( + minValue.inWholeNanoseconds, + maxValue.inWholeNanoseconds, +)?.toDuration(DurationUnit.NANOSECONDS) ?: defaultValue diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt index 1ead48a5..e7e37a88 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -10,6 +10,7 @@ import me.proton.core.network.domain.ApiResult import me.proton.drive.sdk.HttpSdkApi import me.proton.drive.sdk.extension.read import okhttp3.ResponseBody +import proton.sdk.ProtonSdk import proton.sdk.ProtonSdk.HttpRequest import proton.sdk.ProtonSdk.HttpResponse import proton.sdk.httpHeader @@ -27,10 +28,13 @@ internal class ApiProviderBridge( override suspend fun invoke(request: HttpRequest): HttpResponse { val httpStream = createHttpStream() - val apiResult = apiProvider.get(userId).invoke { - execute(request, httpStream) + val apiResult = RetryAfterDelay(isEnabled = request.isRetryEnabled) { + apiProvider.get(userId).invoke( + forceNoRetryOnConnectionErrors = true + ) { + execute(request, httpStream) + } } - if (apiResult is ApiResult.Error) { val error = apiResult.cause if (error is ProtonErrorException) { @@ -68,11 +72,14 @@ internal class ApiProviderBridge( } } - private fun HttpRequest.isUploadBlock(): Boolean = - method == "POST" && url.contains("/storage/blocks") + private val HttpRequest.isUploadBlock: Boolean get() = + type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_STORAGE_UPLOAD - private fun HttpRequest.isDownloadBlock(): Boolean = - method == "GET" && url.contains("/storage/blocks") + private val HttpRequest.isDownloadBlock: Boolean get() = + type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_STORAGE_DOWNLOAD + + private val HttpRequest.isRetryEnabled get() = + type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_REGULAR_API private suspend fun createHttpStream(): HttpStream { val jniHttpStream = JniHttpStream() @@ -100,7 +107,7 @@ internal class ApiProviderBridge( val headers = request.headersList.associate { header -> header.name to header.valuesList.joinToString(",") } - val body = if (request.isUploadBlock()) { + val body = if (request.isUploadBlock) { httpStream.read(request) // TODO: no working yet request is seen in the log but not send //httpStream.readAsStream(request) @@ -108,7 +115,7 @@ internal class ApiProviderBridge( httpStream.read(request) } return when (method.uppercase()) { - "GET" -> if (request.isDownloadBlock()) { + "GET" -> if (request.isDownloadBlock) { getStreaming(url, headers) } else { get(url, headers) @@ -121,3 +128,4 @@ internal class ApiProviderBridge( } } } + diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/RetryAfterDelay.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/RetryAfterDelay.kt new file mode 100644 index 00000000..c073029d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/RetryAfterDelay.kt @@ -0,0 +1,56 @@ +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.delay +import me.proton.core.network.domain.ApiResult +import me.proton.drive.sdk.extension.coerceInOrElse +import okhttp3.ResponseBody +import retrofit2.Response +import kotlin.math.pow +import kotlin.random.Random +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +object RetryAfterDelay { + private const val MAX_FAILURES = 10 + private val MAX_DELAY_DURATION = 60.seconds + private val MAX_RETRY_AFTER_DURATION = 60.seconds + private val DEFAULT_SERVER_ERROR_DURATION = 1.seconds + + suspend operator fun invoke( + isEnabled: Boolean, + strategy: Duration.(Int, Double) -> Duration = Duration::exponentialDelay, + block: suspend () -> ApiResult>, + ): ApiResult> { + var attempts = 0 + var remaining = MAX_DELAY_DURATION + var result: ApiResult> + do { + result = block() + if (!isEnabled) break + attempts++ + val duration = when (result) { + is ApiResult.Error.Http -> { + when (result.httpCode) { + 429 -> result.retryAfter.coerceInOrElse( + minValue = Duration.ZERO, + maxValue = MAX_RETRY_AFTER_DURATION, + ) + in 500..599 -> DEFAULT_SERVER_ERROR_DURATION + .strategy(attempts, 2.0) + .coerceAtMost(remaining) + else -> break + } + } + else -> break + } + remaining -= duration + delay(duration) + } while (remaining.isPositive() && attempts < MAX_FAILURES) + return result + } +} + +fun Duration.exponentialDelay(retryCount: Int, base: Double = 2.0): Duration { + fun jitter(duration: Double, fraction: Double = 0.2) = duration * (1 + fraction * Random.nextDouble()) + return this * jitter(base.pow(retryCount)) +} From dcdde889d942a1077cfc29f273bb4443819edb0d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 17:36:30 +0100 Subject: [PATCH 337/791] Include the Swift's error message in the SDK interop error --- .../ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift | 4 ++-- .../Sources/ProtonDriveClient/ProtonDriveClient.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift index d0695626..5e8a7a83 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift @@ -22,7 +22,7 @@ enum SDKResponseHandler { let error = Proton_Sdk_Error.with { $0.type = "interop" $0.domain = Proton_Sdk_ErrorDomain.businessLogic - $0.context = message + $0.message = message } SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) } @@ -32,7 +32,7 @@ enum SDKResponseHandler { let error = Proton_Sdk_Error.with { $0.type = "sdk error" $0.domain = Proton_Sdk_ErrorDomain.api - $0.context = error.localizedDescription + $0.message = error.localizedDescription } SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 08888dda..25376683 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -204,7 +204,7 @@ public actor ProtonDriveClient: Sendable { let error = Proton_Sdk_Error.with { $0.type = "sdk_error" $0.domain = Proton_Sdk_ErrorDomain.api - $0.context = "account client callback called after the proton client object was deallocated" + $0.message = "account client callback called after the proton client object was deallocated" } SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) return nil From 14968390799234c70a302613c1b5929fb523854f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Dec 2025 20:44:00 +0000 Subject: [PATCH 338/791] Fix address verification happening too early --- cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 4a91c250..4742c6ff 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -239,14 +239,14 @@ private static ReadOnlyMemory GetAddressKeyTokenPassphrase( var userKeyRing = new PgpPrivateKeyRing(userKeys); using var decryptingStream = PgpDecryptingStream.Open(token, userKeyRing, signature, userKeyRing); + using var passphraseStream = new MemoryStream(); + decryptingStream.CopyTo(passphraseStream); + if (decryptingStream.GetVerificationResult().Status is not PgpVerificationStatus.Ok) { throw new ProtonAccountException("Invalid account address key passphrase signature"); } - using var passphraseStream = new MemoryStream(); - decryptingStream.CopyTo(passphraseStream); - // TODO: avoid another allocation return passphraseStream.ToArray(); } From 4b0ecdb25411aff4c4f99f086d4a191934e3c2c9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Dec 2025 21:50:47 +0100 Subject: [PATCH 339/791] Fix CLI resilience retrying even on successful round trips --- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 6 +++--- .../ProtonClientConfigurationExtensions.cs | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index 4d7ac11b..2c4f8661 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -302,11 +302,11 @@ public async Task EndAsync() return _isEnded; } - internal HttpClient GetHttpClient(string? baseRoutePath = null, TimeSpan? attemptTimeout = null) + internal HttpClient GetHttpClient(string? baseRoutePath = null, TimeSpan? timeout = null) { - return baseRoutePath is null && attemptTimeout is null + return baseRoutePath is null && timeout is null ? _httpClient - : ClientConfiguration.GetHttpClient(this, baseRoutePath, attemptTimeout); + : ClientConfiguration.GetHttpClient(this, baseRoutePath, timeout); } private static ReadOnlyMemory DeriveSecretFromPassword(ReadOnlySpan password, ReadOnlySpan salt) diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index 132d19d4..b7984ed4 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -16,7 +16,7 @@ public static HttpClient GetHttpClient( this ProtonClientConfiguration config, ProtonApiSession? session = null, string? baseRoutePath = null, - TimeSpan? attemptTimeout = null) + TimeSpan? timeout = null) { var baseAddress = config.BaseUrl + (baseRoutePath ?? string.Empty); @@ -67,19 +67,22 @@ public static HttpClient GetHttpClient( builder.AddStandardResilienceHandler( options => { - if (attemptTimeout is not null) + if (timeout is not null) { - options.AttemptTimeout.Timeout = attemptTimeout.Value; + options.TotalRequestTimeout.Timeout = timeout.Value; options.CircuitBreaker.SamplingDuration = options.AttemptTimeout.Timeout * 2; } - options.Retry.ShouldHandle += arguments => - ValueTask.FromResult(arguments.Context.GetRequestMessage()?.GetRequestType() != HttpRequestType.RegularApi); + var defaultShouldHandleRetry = options.Retry.ShouldHandle; + + options.Retry.ShouldHandle = async args => await defaultShouldHandleRetry(args).ConfigureAwait(false) + && args.Context.GetRequestMessage()?.GetRequestType() is HttpRequestType.RegularApi; + options.Retry.ShouldRetryAfterHeader = true; - options.Retry.Delay = TimeSpan.FromSeconds(2); + options.Retry.Delay = TimeSpan.FromSeconds(1.75); options.Retry.BackoffType = DelayBackoffType.Exponential; - options.Retry.UseJitter = true; - options.Retry.MaxRetryAttempts = 1; + options.Retry.UseJitter = false; + options.Retry.MaxRetryAttempts = 4; options.CircuitBreaker.FailureRatio = 0.5; }); From 8ad1b502853940ad3d849830524845ff9058db23 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 15:45:30 +0100 Subject: [PATCH 340/791] Remove unused parameter --- kt/sdk/src/main/jni/proton_drive_sdk.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 4f8c59af..2fe081aa 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -129,8 +129,7 @@ void onAccountRequest( void onRecordMetric( intptr_t bindings_handle, - ByteArray value, - intptr_t sdk_handle + ByteArray value ) { pushDataToVoidMethod(bindings_handle, value, "onRecordMetric"); } From 9ac0e93363d3d86254b83079c0927331239feded Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 07:15:43 +0000 Subject: [PATCH 341/791] Add Kotlin bindings for feature flags --- kt/sdk/src/main/jni/global.c | 34 +++++++++++++++++++ kt/sdk/src/main/jni/global.h | 6 ++++ kt/sdk/src/main/jni/proton_drive_sdk.c | 14 ++++++++ .../me/proton/drive/sdk/ProtonDriveSdk.kt | 2 ++ .../drive/sdk/internal/JniDriveClient.kt | 3 ++ .../internal/ProtonDriveSdkNativeClient.kt | 33 +++++++++++++++++- 6 files changed, 91 insertions(+), 1 deletion(-) diff --git a/kt/sdk/src/main/jni/global.c b/kt/sdk/src/main/jni/global.c index fac98763..86ee9b8b 100644 --- a/kt/sdk/src/main/jni/global.c +++ b/kt/sdk/src/main/jni/global.c @@ -57,6 +57,40 @@ void pushDataToVoidMethod( } } +long pushDataToLongMethod( + intptr_t bindings_handle, + ByteArray value, + const char *name +) { + JNIEnv *env = getJNIEnv(); + jobject obj = (*env)->NewLocalRef(env, (jweak) bindings_handle); + if ((*env)->IsSameObject(env, obj, NULL)) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Object was recycled for: %s %ld", name, bindings_handle + ); + return 0; + } else { + jclass cls = (*env)->GetObjectClass(env, obj); + jmethodID mid = (*env)->GetMethodID(env, cls, name, "(Ljava/nio/ByteBuffer;)L"); + if (mid == 0) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Cannot found method: %s", name + ); + return 0; + } + jobject buffer = (*env)->NewDirectByteBuffer( + env, + (void *) value.pointer, + (jlong) value.length + ); + return (*env)->CallLongMethod(env, obj, mid, buffer); + } +} + void pushDataAndLongToVoidMethod( intptr_t bindings_handle, ByteArray value, diff --git a/kt/sdk/src/main/jni/global.h b/kt/sdk/src/main/jni/global.h index 124cc974..2f2249f9 100644 --- a/kt/sdk/src/main/jni/global.h +++ b/kt/sdk/src/main/jni/global.h @@ -12,6 +12,12 @@ void pushDataToVoidMethod( const char *name ); +long pushDataToLongMethod( + intptr_t bindings_handle, + ByteArray value, + const char *name +); + void pushDataAndLongToVoidMethod( intptr_t bindings_handle, ByteArray value, diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 2fe081aa..b1e9a97d 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -134,6 +134,13 @@ void onRecordMetric( pushDataToVoidMethod(bindings_handle, value, "onRecordMetric"); } +long onFeatureEnabled( + intptr_t bindings_handle, + ByteArray value +) { + return pushDataToLongMethod(bindings_handle, value, "onFeatureEnabled"); +} + jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getReadPointer( JNIEnv *env, jobject obj @@ -183,6 +190,13 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getRecordMetr return (jlong) (intptr_t) &onRecordMetric; } +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getFeatureEnabledPointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onFeatureEnabled; +} + jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createWeakRef(JNIEnv* env, jobject obj) { jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); return (jlong)(intptr_t) weakRef; diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt index 74cbe0a9..533de553 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -49,6 +49,7 @@ object ProtonDriveSdk { userAddressResolver: UserAddressResolver, publicAddressResolver: PublicAddressResolver, metricCallback: MetricCallback? = null, + featureEnabled: suspend (String) -> Boolean = { false }, ): DriveClient = JniDriveClient().run { DriveClient( create( @@ -62,6 +63,7 @@ object ProtonDriveSdk { ), onAccountRequest = AccountClientBridge(userAddressResolver, publicAddressResolver), onRecordMetric = metricCallback?.let(::TelemetryBridge) ?: {}, + onFeatureEnabled = featureEnabled ), this ) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index da4713dd..7993ba55 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -33,6 +33,7 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { onHttpClientRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse, onAccountRequest: suspend (ProtonDriveSdk.AccountRequest) -> Any, onRecordMetric: suspend (ProtonSdk.MetricEvent) -> Unit, + onFeatureEnabled: suspend (String) -> Boolean, ) = executePersistent(clientBuilder = { continuation -> ProtonDriveSdkNativeClient( method("create"), @@ -41,6 +42,7 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { accountRequest = onAccountRequest, logger = logger, recordMetric = onRecordMetric, + featureEnabled = onFeatureEnabled, coroutineScope = coroutineScope, ) }, requestBuilder = { client -> @@ -56,6 +58,7 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { loggerProviderHandle = request.loggerProvider.handle recordMetricAction = client.getRecordMetricPointer() } + featureEnabledFunction = client.getFeatureEnabledPointer() request.bindingsLanguage?.let { bindingsLanguage = it } request.uid?.let { uid = it } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index baa38e76..f6021a0d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -2,10 +2,13 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import com.google.protobuf.Int32Value +import com.google.protobuf.StringValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import me.proton.drive.sdk.extension.asAny import me.proton.drive.sdk.extension.toProtonSdkError import proton.drive.sdk.ProtonDriveSdk @@ -25,6 +28,7 @@ class ProtonDriveSdkNativeClient internal constructor( val accountRequest: suspend (ProtonDriveSdk.AccountRequest) -> Any = { error("accountRequest not configured for $name") }, val progress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit = { error("progress not configured for $name") }, val recordMetric: suspend (ProtonSdk.MetricEvent) -> Unit = { error("recordMetric not configured for $name") }, + val featureEnabled: suspend (String) -> Boolean = { error("featureEnabled not configured for $name") }, val logger: (String) -> Unit = {}, private val coroutineScope: CoroutineScope? = null, ) { @@ -81,6 +85,7 @@ class ProtonDriveSdkNativeClient internal constructor( external fun getHttpClientRequestPointer(): Long external fun getAccountRequestPointer(): Long external fun getRecordMetricPointer(): Long + external fun getFeatureEnabledPointer(): Long external fun createWeakRef(): Long @Suppress("unused") // Called by JNI @@ -161,6 +166,32 @@ class ProtonDriveSdkNativeClient internal constructor( block = recordMetric, ) + @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI + fun onFeatureEnabled(data: ByteBuffer): Long = onFunction( + operation = "featureEnabled", + data = data, + parser = StringValue::parseFrom, + ) { value -> + val name = value.value + runCatching { + if (featureEnabled(name)) 1L else 0L + }.getOrElse { error -> + logger("Cannot get feature flag $name") + logger(error.stackTraceToString()) + 0L + } + } + + private fun onFunction( + operation: String, + data: ByteBuffer, + parser: (ByteBuffer) -> T, + block: suspend (T) -> R + ): R = runBlocking { + val value = parser(data) + coroutineScope(operation).async { block(value) }.await() + } + @Suppress("TooGenericExceptionCaught") private fun onOperation(operation: String, sdkHandle: Long, block: suspend () -> Response) { coroutineScope(operation).launch { @@ -241,7 +272,7 @@ class ProtonDriveSdkNativeClient internal constructor( return coroutineScope } - companion object{ + companion object { @JvmStatic external fun getHttpResponseReadPointer(): Long } From 9d02cc6c3aa13f4da00448dffeb0852b6989d1be Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 08:32:15 +0000 Subject: [PATCH 342/791] Fix crashes when download is interrupted --- .../Networking/HttpClientRequestProcessor.swift | 4 +--- .../Networking/HttpClientResponseProcessor.swift | 14 ++++++-------- .../Networking/Model/BoxedCancellableTask.swift | 12 +++++++++++- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift index d8251bfa..bfa69285 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -43,10 +43,8 @@ enum HttpClientRequestProcessor { // Get the task box and cancel it let unmanaged = Unmanaged.fromOpaque(pointer) let taskBox = unmanaged.takeUnretainedValue() + // Release of the task box is wrapped in completionBlock (see `cCompatibleHttpRequest`), which is called in `cancel` taskBox.cancel() - - // Release our reference (matching the passRetained in cCompatibleHttpRequest) - unmanaged.release() } fileprivate static func perform( diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift index 13782200..3d553824 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift @@ -63,15 +63,13 @@ enum HttpClientResponseProcessor { ) async throws { var data = Data(capacity: bufferSize) var receivedBytes = 0 - do { - while let byte = try await boxedDownloadStream.next() { - data.append(byte) - receivedBytes += 1 - if receivedBytes == bufferSize { - break - } + while let byte = try await boxedDownloadStream.next() { + data.append(byte) + receivedBytes += 1 + if receivedBytes == bufferSize { + break } - } catch {} + } data.copyBytes(to: buffer, count: receivedBytes) let message = Google_Protobuf_Int32Value.with { $0.value = Int32(receivedBytes) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift index 054672c6..50020c60 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift @@ -10,12 +10,22 @@ final class BoxedCancellableTask: @unchecked Sendable { init(work: @escaping @Sendable () async throws -> Void) { task = Task { [weak self] in defer { - self?.onComplete?() + self?.complete() } try? await work() } } + private func complete() { + lock.lock() + let completionHandler = onComplete + task = nil + onComplete = nil + lock.unlock() + // Call completion handler since we're done with this task box (to release it) + completionHandler?() + } + func setCompletionHandler(_ handler: @escaping () -> Void) { lock.lock() defer { lock.unlock() } From d6624fadb3ff0019cc0e9fe666e9cd9cc418de26 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 11:30:00 +0100 Subject: [PATCH 343/791] Implement telemetry for download --- .../DriveInteropTelemetryDecorator.cs | 26 +++- .../Nodes/Download/RevisionReader.cs | 113 +++++++++++------- .../Nodes/Upload/RevisionWriter.cs | 24 ++-- .../Telemetry/DownloadEvent.cs | 8 +- .../Telemetry/TelemetryErrorResolver.cs | 15 +++ cs/sdk/src/protos/proton.drive.sdk.proto | 4 +- 6 files changed, 136 insertions(+), 54 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index 771f5fa3..4c12a364 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -1,3 +1,4 @@ +using Google.Protobuf; using Microsoft.Extensions.Logging; using Proton.Drive.Sdk.Telemetry; using Proton.Sdk.CExports; @@ -16,9 +17,10 @@ public ILogger GetLogger(string name) public void RecordMetric(IMetricEvent metricEvent) { - var payload = metricEvent switch + IMessage? payload = metricEvent switch { UploadEvent me => GetUploadEventPayload(me), + DownloadEvent me => GetDownloadEventPayload(me), _ => null, }; @@ -53,4 +55,26 @@ private static UploadEventPayload GetUploadEventPayload(UploadEvent me) return payload; } + + private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me) + { + var payload = new DownloadEventPayload + { + VolumeType = (VolumeType)me.VolumeType, + DownloadedSize = me.DownloadedSize, + ClaimedFileSize = me.ClaimedFileSize, + }; + + if (me.Error is not null) + { + payload.Error = (DownloadError)me.Error; + } + + if (me.OriginalError is not null) + { + payload.OriginalError = me.OriginalError; + } + + return payload; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 3d8f9cff..1b8d6544 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Telemetry; namespace Proton.Drive.Sdk.Nodes.Download; @@ -49,75 +50,107 @@ internal RevisionReader( public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { - var downloadTasks = new Queue>(_client.BlockDownloader.Queue.Depth); - var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + var downloadEvent = new DownloadEvent + { + ClaimedFileSize = -1, + VolumeType = VolumeType.OwnVolume, + }; - await using (manifestStream) + try { - if (_revisionDto.Thumbnails is { } thumbnails) + var downloadTasks = new Queue>(_client.BlockDownloader.Queue.Depth); + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + await using (manifestStream) { - foreach (var sha256Digest in thumbnails.Select(x => x.HashDigest)) + if (_revisionDto.Thumbnails is { } thumbnails) { - manifestStream.Write(sha256Digest.Span); + foreach (var sha256Digest in thumbnails.Select(x => x.HashDigest)) + { + manifestStream.Write(sha256Digest.Span); + } } - } - try - { try { - await foreach (var (block, _) in GetBlocksAsync(cancellationToken).ConfigureAwait(false)) + try { - if (!_client.BlockDownloader.Queue.TryStartBlock()) + await foreach (var (block, _) in GetBlocksAsync(cancellationToken).ConfigureAwait(false)) { - if (downloadTasks.Count > 0) + if (!_client.BlockDownloader.Queue.TryStartBlock()) { - await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) - .ConfigureAwait(false); + if (downloadTasks.Count > 0) + { + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); + } + + await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } - await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); - } + var downloadTask = DownloadBlockAsync(block, contentOutputStream, cancellationToken); - var downloadTask = DownloadBlockAsync(block, contentOutputStream, cancellationToken); + downloadTasks.Enqueue(downloadTask); + } + } + finally + { + _releaseFileSemaphoreAction.Invoke(); + _fileSemaphoreReleased = true; + } - downloadTasks.Enqueue(downloadTask); + while (downloadTasks.Count > 0) + { + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); } } - finally + catch when (downloadTasks.Count > 0) { - _releaseFileSemaphoreAction.Invoke(); - _fileSemaphoreReleased = true; + try + { + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + finally + { + _client.BlockDownloader.Queue.FinishBlocks(downloadTasks.Count); + } + + throw; } - while (downloadTasks.Count > 0) + manifestStream.Seek(0, SeekOrigin.Begin); + + var manifestVerificationStatus = await VerifyManifestAsync(manifestStream, cancellationToken).ConfigureAwait(false); + + if (manifestVerificationStatus is not PgpVerificationStatus.Ok) { - await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken).ConfigureAwait(false); + LogFailedManifestVerification(_fileUid, manifestVerificationStatus); + + throw new ProtonDriveException("File authenticity check failed"); } } - catch when (downloadTasks.Count > 0) + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); + downloadEvent.OriginalError = ex.GetBaseException().ToString(); + throw; + } + finally + { + if (!cancellationToken.IsCancellationRequested) { try { - await Task.WhenAll(downloadTasks).ConfigureAwait(false); + downloadEvent.ClaimedFileSize = contentOutputStream.Length; // FIXME: try to report actual claimed size from metadata + downloadEvent.DownloadedSize = contentOutputStream.Length; + _client.Telemetry.RecordMetric(downloadEvent); } - finally + catch { - _client.BlockDownloader.Queue.FinishBlocks(downloadTasks.Count); + // Ignore telemetry errors } - - throw; - } - - manifestStream.Seek(0, SeekOrigin.Begin); - - var manifestVerificationStatus = await VerifyManifestAsync(manifestStream, cancellationToken).ConfigureAwait(false); - - if (manifestVerificationStatus is not PgpVerificationStatus.Ok) - { - LogFailedManifestVerification(_fileUid, manifestVerificationStatus); - - throw new ProtonDriveException("File authenticity check failed"); } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 190cfd23..77ee7845 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -64,6 +64,9 @@ public async ValueTask WriteAsync( Action? onProgress, CancellationToken cancellationToken) { + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var linkedCancellationToken = cancellationTokenSource.Token; + var uploadEvent = new UploadEvent { ExpectedSize = contentStream.Length, @@ -92,9 +95,6 @@ public async ValueTask WriteAsync( { var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_revisionUid, _fileKey, cancellationToken).ConfigureAwait(false); - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var linkedCancellationToken = cancellationTokenSource.Token; - try { try @@ -207,11 +207,11 @@ await _client.Api.Files.UpdateRevisionAsync( _revisionUid.NodeUid.LinkId, _revisionUid.RevisionId, request, - cancellationToken).ConfigureAwait(false); + linkedCancellationToken).ConfigureAwait(false); LogRevisionSealed(_revisionUid); } - catch (Exception ex) + catch (Exception ex) when (!linkedCancellationToken.IsCancellationRequested) { uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); uploadEvent.OriginalError = ex.GetBaseException().ToString(); @@ -219,8 +219,18 @@ await _client.Api.Files.UpdateRevisionAsync( } finally { - // TODO: put this in a decorator - _client.Telemetry.RecordMetric(uploadEvent); + if (!linkedCancellationToken.IsCancellationRequested) + { + try + { + // TODO: put this in a decorator + _client.Telemetry.RecordMetric(uploadEvent); + } + catch + { + // Ignore telemetry errors + } + } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs index 67f2db74..84309b5f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs @@ -8,11 +8,11 @@ public sealed class DownloadEvent : IMetricEvent public required VolumeType VolumeType { get; init; } - public required long DownloadedSize { get; init; } + public long DownloadedSize { get; set; } - public long? ClaimedFileSize { get; init; } + public long ClaimedFileSize { get; set; } - public DownloadError? Error { get; init; } + public DownloadError? Error { get; set; } - public string? OriginalError { get; init; } + public string? OriginalError { get; set; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index 8a5b1a12..cea1c0dc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Security.Cryptography; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk; @@ -6,6 +7,20 @@ namespace Proton.Drive.Sdk.Telemetry; internal static class TelemetryErrorResolver { + public static DownloadError? GetDownloadErrorFromException(Exception exception) + { + return exception switch + { + NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => DownloadError.IntegrityError, + CryptographicException => DownloadError.DecryptionError, + HttpRequestException { HttpRequestError: HttpRequestError.ConnectionError } => DownloadError.NetworkError, + ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => DownloadError.RateLimited, + ProtonApiException { TransportCode: >= 400 and < 500 } => DownloadError.HttpClientSideError, + ProtonApiException { TransportCode: >= 500 and < 600 } => DownloadError.ServerError, + _ => DownloadError.Unknown, + }; + } + public static UploadError? GetUploadErrorFromException(Exception exception) { return exception switch diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index afb49256..07477528 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -362,8 +362,8 @@ message UploadEventPayload { message DownloadEventPayload { VolumeType volume_type = 1; - int64 expected_size = 2; - int64 uploaded_size = 3; + int64 claimed_file_size = 2; // -1 if unknown + int64 downloaded_size = 3; DownloadError error = 4; // Optional string original_error = 5; // Optional } From a2e1a19cb600da854c0a00b89542971d1d90cce7 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 15:54:13 +0100 Subject: [PATCH 344/791] Fix Kotlin build failure due to Protobuf changes --- kt/sdk/src/main/jni/global.c | 43 ++++++- kt/sdk/src/main/jni/global.h | 7 ++ kt/sdk/src/main/jni/proton_drive_sdk.c | 51 +++++++- .../sdk/extension/DownloadEventPayload.kt | 4 +- .../drive/sdk/internal/JniDriveClient.kt | 8 +- .../internal/ProtonDriveSdkNativeClient.kt | 110 ++++++++++-------- .../drive/sdk/telemetry/DownloadEvent.kt | 4 +- 7 files changed, 167 insertions(+), 60 deletions(-) diff --git a/kt/sdk/src/main/jni/global.c b/kt/sdk/src/main/jni/global.c index 86ee9b8b..00a122dc 100644 --- a/kt/sdk/src/main/jni/global.c +++ b/kt/sdk/src/main/jni/global.c @@ -34,7 +34,7 @@ void pushDataToVoidMethod( __android_log_print( ANDROID_LOG_FATAL, "drive.sdk.internal", - "Object was recycled for: %s %ld", name, bindings_handle + "Object was recycled for: %s %ld", name, (long) bindings_handle ); return; } else { @@ -68,12 +68,12 @@ long pushDataToLongMethod( __android_log_print( ANDROID_LOG_FATAL, "drive.sdk.internal", - "Object was recycled for: %s %ld", name, bindings_handle + "Object was recycled for: %s %ld", name, (long) bindings_handle ); return 0; } else { jclass cls = (*env)->GetObjectClass(env, obj); - jmethodID mid = (*env)->GetMethodID(env, cls, name, "(Ljava/nio/ByteBuffer;)L"); + jmethodID mid = (*env)->GetMethodID(env, cls, name, "(Ljava/nio/ByteBuffer;)J"); if (mid == 0) { __android_log_print( ANDROID_LOG_FATAL, @@ -103,7 +103,7 @@ void pushDataAndLongToVoidMethod( __android_log_print( ANDROID_LOG_FATAL, "drive.sdk.internal", - "Object was recycled for: %s %ld", name, bindings_handle + "Object was recycled for: %s %ld", name, (long) bindings_handle ); return; } else { @@ -125,3 +125,38 @@ void pushDataAndLongToVoidMethod( (*env)->CallVoidMethod(env, obj, mid, buffer, caller_state); } } + +long pushDataAndLongToLongMethod( + intptr_t bindings_handle, + ByteArray value, + intptr_t caller_state, + const char *name +) { + JNIEnv *env = getJNIEnv(); + jobject obj = (*env)->NewLocalRef(env, (jweak) bindings_handle); + if ((*env)->IsSameObject(env, obj, NULL)) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Object was recycled for: %s %ld", name, (long) bindings_handle + ); + return 0; + } else { + jclass cls = (*env)->GetObjectClass(env, obj); + jmethodID mid = (*env)->GetMethodID(env, cls, name, "(Ljava/nio/ByteBuffer;J)J"); + if (mid == 0) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Cannot found method: %s", name + ); + return 0; + } + jobject buffer = (*env)->NewDirectByteBuffer( + env, + (void *) value.pointer, + (jlong) value.length + ); + return (*env)->CallLongMethod(env, obj, mid, buffer, caller_state); + } +} diff --git a/kt/sdk/src/main/jni/global.h b/kt/sdk/src/main/jni/global.h index 2f2249f9..5396ae60 100644 --- a/kt/sdk/src/main/jni/global.h +++ b/kt/sdk/src/main/jni/global.h @@ -25,4 +25,11 @@ void pushDataAndLongToVoidMethod( const char *name ); +long pushDataAndLongToLongMethod( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle, + const char *name +); + #endif //PROTONDRIVE_GLOBAL_H diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index b1e9a97d..903fa7e9 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -103,12 +103,47 @@ void onProgress(intptr_t bindings_handle, ByteArray value) { pushDataToVoidMethod(bindings_handle, value, "onProgress"); } -void onSendHttpRequest( +long onSendHttpRequest( intptr_t bindings_handle, ByteArray value, intptr_t sdk_handle ) { - pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onSendHttpRequest"); + return pushDataAndLongToLongMethod(bindings_handle, value, sdk_handle, "onSendHttpRequest"); +} + +void onHttpCancellation( + intptr_t bindings_operation_handle +) { + if (bindings_operation_handle == 0) { + return; + } + JNIEnv *env = getJNIEnv(); + jobject obj = (*env)->NewLocalRef(env, (jweak) bindings_operation_handle); + if ((*env)->IsSameObject(env, obj, NULL)) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Object was recycled for: %s %ld", "cancel", bindings_operation_handle + ); + return; + } else { + jclass jobClass = (*env)->GetObjectClass(env, obj); + jmethodID mid = (*env)->GetMethodID(env, jobClass, "cancel", "()V"); + if (mid == 0) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Cannot found method: %s", "cancel" + ); + return; + } + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Calling found method: %s", "cancel" + ); + (*env)->CallVoidMethod(env, obj, mid); + } } void onHttpResponseRead( @@ -169,6 +204,13 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpClient return (jlong) (intptr_t) &onSendHttpRequest; } +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpClientCancellationPointer( + JNIEnv *env, + jobject obj +) { + return (jlong) (intptr_t) &onHttpCancellation; +} + jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpResponseReadPointer( JNIEnv *env, jclass clazz @@ -201,3 +243,8 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createWeakRef jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); return (jlong)(intptr_t) weakRef; } + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createJobWeakRef(JNIEnv* env, jclass clazz, jobject obj) { + jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); + return (jlong)(intptr_t) weakRef; +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt index 1b76d65f..7b6a1a36 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt @@ -5,8 +5,8 @@ import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.DownloadEventPayload.toEvent() = DownloadEvent( volumeType = volumeType.toEnum(), - expectedSize = expectedSize, - uploadedSize = uploadedSize, + claimedFileSize = claimedFileSize, + downloadedSize = downloadedSize, error = error.toEnum(), originalError = originalError, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index 7993ba55..b2df002e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -11,6 +11,7 @@ import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.driveClientCreateFromSessionRequest import proton.drive.sdk.driveClientCreateRequest import proton.drive.sdk.driveClientFreeRequest +import proton.drive.sdk.httpClient import proton.drive.sdk.request import proton.sdk.ProtonSdk import proton.sdk.ProtonSdk.HttpResponse @@ -49,8 +50,11 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { request { driveClientCreate = driveClientCreateRequest { baseUrl = request.baseUrl - httpClientRequestAction = client.getHttpClientRequestPointer() - httpResponseReadAction = httpResponseReadPointer + httpClient = httpClient { + requestFunction = client.getHttpClientRequestPointer() + responseContentReadAction = httpResponseReadPointer + cancellationAction = client.getHttpClientCancellationPointer() + } accountRequestAction = client.getAccountRequestPointer() entityCachePath = request.entityCachePath secretCachePath = request.secretCachePath diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index f6021a0d..39dbab91 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -5,6 +5,7 @@ import com.google.protobuf.Int32Value import com.google.protobuf.StringValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -83,6 +84,7 @@ class ProtonDriveSdkNativeClient internal constructor( external fun getWritePointer(): Long external fun getProgressPointer(): Long external fun getHttpClientRequestPointer(): Long + external fun getHttpClientCancellationPointer(): Long external fun getAccountRequestPointer(): Long external fun getRecordMetricPointer(): Long external fun getFeatureEnabledPointer(): Long @@ -103,25 +105,29 @@ class ProtonDriveSdkNativeClient internal constructor( ) @Suppress("unused") // Called by JNI - fun onRead(buffer: ByteBuffer, sdkHandle: Long) = onOperation("read", sdkHandle) { - logger("read for $name of size: ${buffer.capacity()}") - val bytesRead = read(buffer).takeUnless { it < 0 } ?: 0 - logger("$bytesRead bytes read for $name") - response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } + fun onRead(buffer: ByteBuffer, sdkHandle: Long) { + onOperation("read", sdkHandle) { + logger("read for $name of size: ${buffer.capacity()}") + val bytesRead = read(buffer).takeUnless { it < 0 } ?: 0 + logger("$bytesRead bytes read for $name") + response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } + } } @Suppress("unused") // Called by JNI - fun onWrite(data: ByteBuffer, sdkHandle: Long) = onOperation("write", sdkHandle) { - logger("write for $name of size: ${data.capacity()}") - write(data) - response {} + fun onWrite(data: ByteBuffer, sdkHandle: Long) { + onOperation("write", sdkHandle) { + logger("write for $name of size: ${data.capacity()}") + write(data) + response {} + } } @Suppress("unused") // Called by JNI fun onSendHttpRequest( data: ByteBuffer, sdkHandle: Long, - ) = onRequest( + ): Long = onRequest( operation = "http", data = data, sdkHandle = sdkHandle, @@ -131,7 +137,9 @@ class ProtonDriveSdkNativeClient internal constructor( val httpResponse = httpClientRequest(httpRequest) logger("receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}") response { value = httpResponse.asAny("proton.sdk.HttpResponse") } - } + }?.let { job -> + createJobWeakRef(job) + } ?: 0 @Suppress("unused") // Called by JNI fun onHttpResponseRead(buffer: ByteBuffer, sdkHandle: Long) { @@ -147,15 +155,17 @@ class ProtonDriveSdkNativeClient internal constructor( fun onAccountRequest( data: ByteBuffer, sdkHandle: Long, - ) = onRequest( - operation = "request", - data = data, - sdkHandle = sdkHandle, - parser = ProtonDriveSdk.AccountRequest::parseFrom, - ) { accountRequest -> - logger("request for ${accountRequest.payloadCase.name} of size: ${data.capacity()}") - val response = accountRequest(accountRequest) - response { value = response } + ) { + onRequest( + operation = "request", + data = data, + sdkHandle = sdkHandle, + parser = ProtonDriveSdk.AccountRequest::parseFrom, + ) { accountRequest -> + logger("request for ${accountRequest.payloadCase.name} of size: ${data.capacity()}") + val response = accountRequest(accountRequest) + response { value = response } + } } @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI @@ -193,22 +203,24 @@ class ProtonDriveSdkNativeClient internal constructor( } @Suppress("TooGenericExceptionCaught") - private fun onOperation(operation: String, sdkHandle: Long, block: suspend () -> Response) { - coroutineScope(operation).launch { - try { - handleResponse(sdkHandle, block()) - } catch (error: CancellationException) { - logger("Operation $operation was cancelled") - handleResponse(sdkHandle, response { - this@response.error = - error.toProtonSdkError("Operation $operation was cancelled") - }) - throw error - } catch (error: Throwable) { - handleResponse(sdkHandle, response { - this@response.error = error.toProtonSdkError("Error while $operation") - }) - } + private fun onOperation( + operation: String, + sdkHandle: Long, + block: suspend () -> Response, + ): Job = coroutineScope(operation).launch { + try { + handleResponse(sdkHandle, block()) + } catch (error: CancellationException) { + logger("Operation $operation was cancelled") + handleResponse(sdkHandle, response { + this@response.error = + error.toProtonSdkError("Operation $operation was cancelled") + }) + throw error + } catch (error: Throwable) { + handleResponse(sdkHandle, response { + this@response.error = error.toProtonSdkError("Error while $operation") + }) } } @@ -219,18 +231,17 @@ class ProtonDriveSdkNativeClient internal constructor( sdkHandle: Long, parser: (ByteBuffer) -> T, block: suspend (T) -> Response - ) { - try { - // parsing of protobuf needs to be done serially - val request = parser(data) - onOperation(operation, sdkHandle) { block(request) } - } catch (error: Throwable) { - handleResponse(sdkHandle, response { - this@response.error = error.toProtonSdkError( - "Error while parsing request for $operation" - ) - }) - } + ): Job? = try { + // parsing of protobuf needs to be done serially + val request = parser(data) + onOperation(operation, sdkHandle) { block(request) } + } catch (error: Throwable) { + handleResponse(sdkHandle, response { + this@response.error = error.toProtonSdkError( + "Error while parsing request for $operation" + ) + }) + null } @Suppress("TooGenericExceptionCaught") @@ -275,5 +286,8 @@ class ProtonDriveSdkNativeClient internal constructor( companion object { @JvmStatic external fun getHttpResponseReadPointer(): Long + @JvmStatic + external fun createJobWeakRef(job: Job): Long + } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt index 2762ff35..a994e918 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt @@ -2,8 +2,8 @@ package me.proton.drive.sdk.telemetry data class DownloadEvent( val volumeType: VolumeType, - val expectedSize: Long, - val uploadedSize: Long, + val claimedFileSize: Long, + val downloadedSize: Long, val error: DownloadError?, val originalError: String?, ) From d3e8b57a4fa225b8e43cba416341c474d4f47023 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 15:42:30 +0000 Subject: [PATCH 345/791] Bump Kotlin package version --- kt/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kt/build.gradle.kts b/kt/build.gradle.kts index 29c92590..9beecbcd 100644 --- a/kt/build.gradle.kts +++ b/kt/build.gradle.kts @@ -40,7 +40,7 @@ allprojects { } } group = "me.proton.drive" - version = "0.5.0" + version = "0.6.0-alpha.1" afterEvaluate { configurations.all { From 32647d9c1b2a2685c9866b09eb546495d7894f7b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Dec 2025 08:50:43 +0000 Subject: [PATCH 346/791] Handle degraded node --- .../Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs | 4 +- .../Nodes/Cryptography/NodeCrypto.cs | 28 ++- .../Nodes/DegradedFileNode.cs | 20 +- .../Nodes/DegradedRevision.cs | 18 ++ .../Nodes/DtoToMetadataConverter.cs | 191 +++++++++++------- .../Nodes/FolderOperations.cs | 2 +- .../Nodes/Upload/IntegrityException.cs | 2 +- .../Nodes/Upload/NewFileDraftProvider.cs | 2 +- 8 files changed, 160 insertions(+), 107 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedRevision.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs index ea1fe65f..ee2be2ea 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Nodes; internal readonly struct AuthorshipClaim(Author author, IReadOnlyList keys, string? keyRetrievalErrorMessage = null) { - private readonly IReadOnlyList _keys = keys; + public readonly IReadOnlyList Keys { get; } = keys; public Author Author { get; } = author; @@ -34,6 +34,6 @@ public static async ValueTask CreateAsync( public PgpKeyRing GetKeyRing(PgpPrivateKey anonymousFallbackKey) { - return Author != Author.Anonymous ? new PgpKeyRing(_keys) : anonymousFallbackKey; + return Author != Author.Anonymous ? new PgpKeyRing(Keys) : anonymousFallbackKey; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index 8478f966..9d85aa20 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -126,7 +126,7 @@ private static Result>, string> Decr { try { - var passphrase = DecryptMessage(encryptedPassphrase, signature, parentNodeKey, authorshipClaim, out var sessionKey, out var author); + var passphrase = DecryptMessage(encryptedPassphrase, signature, parentNodeKey, authorshipClaim.GetKeyRing(parentNodeKey), out var sessionKey, out var author); return new PhasedDecryptionOutput>(sessionKey, passphrase, author); } @@ -160,7 +160,7 @@ private static Result, string> DecryptName( { try { - var nameUtf8Bytes = DecryptMessage(encryptedName, detachedSignature: null, parentNodeKey, authorshipClaim, out var sessionKey, out var author); + var nameUtf8Bytes = DecryptMessage(encryptedName, detachedSignature: null, parentNodeKey, authorshipClaim.GetKeyRing(parentNodeKey), out var sessionKey, out var author); var name = Encoding.UTF8.GetString(nameUtf8Bytes); @@ -189,8 +189,8 @@ private static Result, string> DecryptName( try { - var hashKey = DecryptMessage(encryptedHashKey.Value, detachedSignature: null, nodeKey.Value, authorshipClaim, out _, out var author); - + var verificationKeyRing = GetContentKeyAndHashKeyVerificationKeyRing(nodeKey.Value, authorshipClaim); + var hashKey = DecryptMessage(encryptedHashKey.Value, detachedSignature: null, nodeKey.Value, verificationKeyRing, out _, out var author); return new DecryptionOutput>(hashKey, author); } catch (Exception e) @@ -199,6 +199,18 @@ private static Result, string> DecryptName( } } + private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateKey nodeKey, AuthorshipClaim authorshipClaim) + { + var keys = new List([nodeKey]); + if (authorshipClaim.Author != Author.Anonymous) + { + keys.AddRange(authorshipClaim.Keys.AsEnumerable().Select(k => new PgpKey(k))); + } + + var keyRing = new PgpKeyRing(keys); + return keyRing; + } + private static Result, string?> DecryptContentKey( PgpPrivateKey? nodeKey, ReadOnlyMemory contentKeyPacket, @@ -220,7 +232,7 @@ private static Result, string> DecryptName( return e.Message; } - var verificationKeyRing = nodeAuthorshipClaim.GetKeyRing(nodeKey.Value); + var verificationKeyRing = GetContentKeyAndHashKeyVerificationKeyRing(nodeKey.Value, nodeAuthorshipClaim); AuthorshipVerificationFailure? verificationFailure; try @@ -260,7 +272,7 @@ private static Result, string> DecryptName( encryptedExtendedAttributes.Value, detachedSignature: null, nodeKey.Value, - authorshipClaim, + authorshipClaim.GetKeyRing(nodeKey.Value), out _, out var author); @@ -285,14 +297,12 @@ private static ArraySegment DecryptMessage( PgpArmoredMessage encryptedMessage, PgpArmoredSignature? detachedSignature, PgpPrivateKey decryptionKey, - AuthorshipClaim authorshipClaim, + PgpKeyRing verificationKeyRing, out PgpSessionKey sessionKey, out AuthorshipVerificationFailure? authorshipVerificationFailure) { sessionKey = decryptionKey.DecryptSessionKey(encryptedMessage); - var verificationKeyRing = authorshipClaim.GetKeyRing(anonymousFallbackKey: decryptionKey); - var plaintext = detachedSignature is not null ? sessionKey.DecryptAndVerify(encryptedMessage.Bytes.Span, detachedSignature.Value.Bytes.Span, verificationKeyRing, out var verificationResult) : sessionKey.DecryptAndVerify(encryptedMessage, verificationKeyRing, out verificationResult); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs index 469c485d..c9e42f12 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs @@ -6,25 +6,7 @@ public sealed record DegradedFileNode : DegradedNode { public required string MediaType { get; init; } - public required Revision? ActiveRevision { get; init; } + public required DegradedRevision? ActiveRevision { get; init; } public required long TotalStorageQuotaUsage { get; init; } - - public FileNode ToNode(string substituteName, Revision substituteRevision) - { - return new FileNode - { - Uid = Uid, - ParentUid = ParentUid, - MediaType = MediaType, - Name = Name.TryGetValue(out var name) - ? name - : substituteName, - NameAuthor = NameAuthor, - Author = Author, - CreationTime = CreationTime, - ActiveRevision = ActiveRevision ?? substituteRevision, - TotalSizeOnCloudStorage = TotalStorageQuotaUsage, - }; - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedRevision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedRevision.cs new file mode 100644 index 00000000..8cfe9009 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedRevision.cs @@ -0,0 +1,18 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed record DegradedRevision +{ + public required RevisionUid Uid { get; init; } + public required DateTime CreationTime { get; init; } + public required long SizeOnCloudStorage { get; init; } + public long? ClaimedSize { get; init; } + public FileContentDigests? ClaimedDigests { get; init; } + public DateTime? ClaimedModificationTime { get; init; } + public required IReadOnlyList Thumbnails { get; init; } + public required IReadOnlyList? AdditionalClaimedMetadata { get; init; } + public Result? ContentAuthor { get; init; } + public bool CanDecrypt { get; init; } + public required IReadOnlyList Errors { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index de1e1c98..188cd050 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -72,25 +72,49 @@ public static async ValueTask> Co var decryptionResult = await NodeCrypto.DecryptFolderAsync(client, linkDto, folderDto, parentKeyResult, cancellationToken).ConfigureAwait(false); - if (!NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey) - || !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey) - || !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput) - || !decryptionResult.HashKey.TryGetValue(out var hashKeyOutput)) + var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); + var nodeKeyIsInvalid = !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); + var passphraseIsInvalid = !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput); + var hashKeyIsInvalid = !decryptionResult.HashKey.TryGetValue(out var hashKeyOutput); + + var nameAuthor = !nameIsInvalid && nameOutput.HasValue + ? decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure) + : default; + var nodeAuthor = !passphraseIsInvalid && !hashKeyIsInvalid + ? decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure ?? hashKeyOutput.AuthorshipVerificationFailure) + : default; + + if ( + nameIsInvalid || nameSessionKey is null || nameOutput is null + || passphraseIsInvalid || nodeKeyIsInvalid || hashKeyIsInvalid) { - // FIXME: complete degraded node and cache it + var errors = new List(); + + if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) + { + errors.Add(new DecryptionError(passphraseError ?? "Failed to decrypt passphrase")); + } + else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) + { + errors.Add(new DecryptionError(nodeKeyError ?? "Failed to decrypt node key")); + } + else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) + { + errors.Add(new DecryptionError(hashKeyError ?? "Failed to decrypt hash key")); + } + var degradedNode = new DegradedFolderNode { Uid = uid, ParentUid = parentUid, Name = nameResult, - NameAuthor = default, + NameAuthor = nameAuthor, CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, - Author = default, - Errors = null!, // FIXME + Author = nodeAuthor, + Errors = errors, }; - // FIXME: cache secrets var degradedSecrets = new DegradedFolderSecrets { Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), @@ -99,13 +123,11 @@ public static async ValueTask> Co HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), }; - var nameOrError = decryptionResult.Link.Name.TryGetValueElseError(out var nameValue, out var error) ? nameValue.Data : error; - var name = (NodeOperations.ValidateName(decryptionResult.Link.Name, out _, out _, out _) ? "✅ " : "⌠") + $"(\"{nameOrError}\")"; - var nk = decryptionResult.Link.NodeKey.TryGetValueElseError(out _, out var nkError) ? "✅" : $"⌠(\"{nkError}\")"; - var pp = decryptionResult.Link.Passphrase.TryGetValueElseError(out _, out var ppError) ? "✅" : $"⌠(\"{ppError}\")"; - var hk = decryptionResult.HashKey.TryGetValueElseError(out _, out var hkError) ? "✅" : $"⌠(\"{hkError}\")"; + await client.Cache.Secrets.SetFolderSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); + + // FIXME: remove entity cache or cache degraded node - throw new TempDebugException($"Name: {name}, Node key: {nk}, Passphrase: {pp}, Hash Key: {hk}"); + return new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); } var secrets = new FolderSecrets @@ -124,10 +146,8 @@ public static async ValueTask> Co Uid = uid, ParentUid = parentUid, Name = nameOutput.Value.Data, - NameAuthor = decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure), - - // FIXME: combine with verification failure from name hash key - Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure), + NameAuthor = nameAuthor, + Author = nodeAuthor, CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, }; @@ -170,29 +190,88 @@ public static async Task> ConvertDtoT var decryptionResult = await NodeCrypto.DecryptFileAsync(client, linkDto, fileDto, activeRevisionDto, parentKeyResult, cancellationToken) .ConfigureAwait(false); - if (!NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey) - || !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey) - || !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput) - || !decryptionResult.ExtendedAttributes.TryGetValue(out var extendedAttributesOutput) - || !decryptionResult.ContentKey.TryGetValue(out var contentKeyOutput)) + var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); + var nodeKeyIsInvalid = !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); + var passphraseIsInvalid = !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput); + var extendedAttributesIsInvalid = !decryptionResult.ExtendedAttributes.TryGetValue(out var extendedAttributesOutput); + var contentKeyIsInvalid = !decryptionResult.ContentKey.TryGetValue(out var contentKeyOutput); + + var nameAuthor = !nameIsInvalid && nameOutput.HasValue + ? decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure) + : default; + + var nodeAuthor = !passphraseIsInvalid + ? decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure ?? contentKeyOutput.AuthorshipVerificationFailure) + : default; + + var contentAuthor = !extendedAttributesIsInvalid + ? decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure) + : default; + + var extendedAttributes = extendedAttributesOutput.Data; + + var thumbnails = activeRevisionDto.Thumbnails.Count > 0 ? new ThumbnailHeader[activeRevisionDto.Thumbnails.Count] : []; + + var additionalMetadata = extendedAttributes?.AdditionalMetadata?.Select(x => new AdditionalMetadataProperty(x.Key, x.Value)).ToList().AsReadOnly(); + + for (var i = 0; i < activeRevisionDto.Thumbnails.Count; ++i) + { + var thumbnailDto = activeRevisionDto.Thumbnails[i]; + thumbnails[i] = new ThumbnailHeader(thumbnailDto.Id, (ThumbnailType)thumbnailDto.Type); + } + + if ( + nameIsInvalid || (nameSessionKey is null) || nameOutput is null + || passphraseIsInvalid + || nodeKeyIsInvalid + || extendedAttributesIsInvalid + || contentKeyIsInvalid) { - // FIXME: complete degraded node and cache it + var errors = new List(); + if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) + { + errors.Add(new DecryptionError(passphraseError ?? "Failed to decrypt passphrase")); + } + else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) + { + errors.Add(new DecryptionError(nodeKeyError ?? "Failed to decrypt node key")); + } + + var revisionErrors = new List(); + if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) + { + revisionErrors.Add(new DecryptionError(extendedAttributesError ?? "Failed to decrypt extended attributes key")); + } + + var degradedRevision = new DegradedRevision + { + Uid = new RevisionUid(uid, activeRevisionDto.Id), + CreationTime = activeRevisionDto.CreationTime, + SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, + ClaimedSize = extendedAttributes?.Common?.Size, + ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, + ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, + Thumbnails = thumbnails.AsReadOnly(), + AdditionalClaimedMetadata = additionalMetadata, + ContentAuthor = contentAuthor, + Errors = (IReadOnlyList)revisionErrors, + }; + var degradedNode = new DegradedFileNode { Uid = uid, ParentUid = parentUid, Name = nameResult, - NameAuthor = default, + NameAuthor = nameAuthor, CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, - Author = default, + Author = nodeAuthor, MediaType = fileDto.MediaType, - ActiveRevision = null, + ActiveRevision = degradedRevision, TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, - Errors = null!, + Errors = errors, }; - // FIXME: cache secrets var degradedSecrets = new DegradedFileSecrets { Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), @@ -201,14 +280,10 @@ public static async Task> ConvertDtoT ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), }; - var nameOrError = decryptionResult.Link.Name.TryGetValueElseError(out var nameValue, out var error) ? nameValue.Data : error; - var name = (NodeOperations.ValidateName(decryptionResult.Link.Name, out _, out _, out _) ? "✅ " : "⌠") + $"(\"{nameOrError}\")"; - var nk = decryptionResult.Link.NodeKey.TryGetValueElseError(out _, out var nkError) ? "✅" : $"⌠(\"{nkError}\")"; - var pp = decryptionResult.Link.Passphrase.TryGetValueElseError(out _, out var ppError) ? "✅" : $"⌠(\"{ppError}\")"; - var ea = decryptionResult.ExtendedAttributes.TryGetValueElseError(out _, out var eaError) ? "✅" : $"⌠(\"{eaError}\")"; - var ck = decryptionResult.ContentKey.TryGetValueElseError(out _, out var ckError) ? "✅" : $"⌠(\"{ckError}\")"; + await client.Cache.Secrets.SetFileSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); + // FIXME: remove entity cache or cache degraded node - throw new TempDebugException($"Name: {name}, Node key: {nk}, Passphrase: {pp}, Extended Attributes: {ea}, Content Key: {ck}"); + return new DegradedFileMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); } var secrets = new FileSecrets @@ -222,20 +297,6 @@ public static async Task> ConvertDtoT : (ReadOnlyMemory?)null, }; - await client.Cache.Secrets.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); - - var extendedAttributes = extendedAttributesOutput.Data; - - var thumbnails = activeRevisionDto.Thumbnails.Count > 0 ? new ThumbnailHeader[activeRevisionDto.Thumbnails.Count] : []; - - var additionalMetadata = extendedAttributes?.AdditionalMetadata?.Select(x => new AdditionalMetadataProperty(x.Key, x.Value)).ToList().AsReadOnly(); - - for (var i = 0; i < activeRevisionDto.Thumbnails.Count; ++i) - { - var thumbnailDto = activeRevisionDto.Thumbnails[i]; - thumbnails[i] = new ThumbnailHeader(thumbnailDto.Id, (ThumbnailType)thumbnailDto.Type); - } - var activeRevision = new Revision { Uid = new RevisionUid(uid, activeRevisionDto.Id), @@ -246,7 +307,7 @@ public static async Task> ConvertDtoT ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, Thumbnails = thumbnails.AsReadOnly(), AdditionalClaimedMetadata = additionalMetadata, - ContentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure), + ContentAuthor = contentAuthor, }; var node = new FileNode @@ -254,10 +315,8 @@ public static async Task> ConvertDtoT Uid = uid, ParentUid = parentUid, Name = nameOutput.Value.Data, - NameAuthor = decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure), - - // FIXME: combine with verification failure from name hash key - Author = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure), + NameAuthor = nameAuthor, + Author = nodeAuthor, CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, MediaType = fileDto.MediaType, @@ -265,6 +324,8 @@ public static async Task> ConvertDtoT TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, }; + await client.Cache.Secrets.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); @@ -367,21 +428,3 @@ private static async ValueTask> GetParen return currentParentKey; } } - -// FIXME: remove this -public sealed class TempDebugException : Exception -{ - public TempDebugException(string message) - : base(message) - { - } - - public TempDebugException(string message, Exception innerException) - : base(message, innerException) - { - } - - public TempDebugException() - { - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index df821889..e9251ace 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -65,7 +65,7 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, var hashKey = CryptoGenerator.GenerateFolderHashKey(); - var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken); + var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken).ConfigureAwait(false); NodeOperations.GetCommonCreationParameters( name, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs index 721edb71..87df9999 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -internal class IntegrityException : Exception +public class IntegrityException : Exception { public IntegrityException(string message) : base(message) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 3855c9ea..435efad9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -109,7 +109,7 @@ private static FileCreationRequest GetFileCreationRequest( (FileCreationResponse Response, FileSecrets FileSecrets)? result = null; - var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken); + var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken).ConfigureAwait(false); while (result is null) { From 264e7784416a4f0b6224f0d868a148f82e9ee1d3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Dec 2025 11:31:14 +0100 Subject: [PATCH 347/791] Improve performance of iterating over URLSession.AsyncBytes during download --- .../HttpClientResponseProcessor.swift | 10 +--------- .../Model/BoxedDownloadStream.swift | 20 ++++++++++++++----- .../Networking/Model/BoxedRawBuffer.swift | 2 +- .../Networking/Model/BytesOrStream.swift | 2 +- .../TelemetryAndLogging/TelemetryTypes.swift | 6 +++--- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift index 3d553824..dec4bc06 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift @@ -61,15 +61,7 @@ enum HttpClientResponseProcessor { callbackPointer: Int, releaseBox: () -> Void ) async throws { - var data = Data(capacity: bufferSize) - var receivedBytes = 0 - while let byte = try await boxedDownloadStream.next() { - data.append(byte) - receivedBytes += 1 - if receivedBytes == bufferSize { - break - } - } + let (data, receivedBytes) = try await boxedDownloadStream.read(upTo: bufferSize) data.copyBytes(to: buffer, count: receivedBytes) let message = Google_Protobuf_Int32Value.with { $0.value = Int32(receivedBytes) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift index 22e90a4b..b127d562 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift @@ -1,6 +1,6 @@ import Foundation -actor BoxedDownloadStream: Sendable { +final class BoxedDownloadStream { private let stream: URLSession.AsyncBytes private var iterator: URLSession.AsyncBytes.AsyncIterator @@ -12,10 +12,20 @@ actor BoxedDownloadStream: Sendable { self.logger = logger } - func next() async throws -> UInt8? { - var localIterator = self.iterator - defer { self.iterator = localIterator } - return try await localIterator.next() + func read(upTo bufferSize: Int) async throws -> (Data, Int) { + let pointer = UnsafeMutablePointer.allocate(capacity: bufferSize) + var receivedBytes = 0 + while let byte = try await self.iterator.next() { + pointer[receivedBytes] = byte + receivedBytes += 1 + if receivedBytes == bufferSize { + break + } + } + + let data = Data(bytesNoCopy: pointer, count: receivedBytes, + deallocator: .custom { _, _ in pointer.deallocate() }) + return (data, receivedBytes) } deinit { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift index 34b80c57..ac05f3e3 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift @@ -1,6 +1,6 @@ import Foundation -actor BoxedRawBuffer: Sendable { +final class BoxedRawBuffer { private let buffer: UnsafeMutableRawBufferPointer private let address: UnsafeMutableRawPointer private let count: Int diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift index 9f0b4558..8732efcc 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift @@ -1,6 +1,6 @@ import Foundation -final class BoxedStreamingData: Sendable { +final class BoxedStreamingData { let uploadBuffer: BoxedRawBuffer? let downloadStream: BoxedDownloadStream? diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift index d8ba4044..73333a8b 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -85,15 +85,15 @@ public struct DecryptionErrorEventPayload: Sendable { public struct DownloadEventPayload: Sendable { public let volumeType: VolumeType - public let expectedSize: Int64 + public let claimedFileSize: Int64 public let downloadedSize: Int64 public let error: DownloadError? public let originalError: String? init(sdkDownloadEventPayload: Proton_Drive_Sdk_DownloadEventPayload) { self.volumeType = .init(sdkVolumeType: sdkDownloadEventPayload.volumeType) - self.expectedSize = sdkDownloadEventPayload.expectedSize - self.downloadedSize = sdkDownloadEventPayload.uploadedSize + self.claimedFileSize = sdkDownloadEventPayload.claimedFileSize + self.downloadedSize = sdkDownloadEventPayload.downloadedSize self.error = sdkDownloadEventPayload.hasError ? .init(sdkDownloadError: sdkDownloadEventPayload.error) : nil self.originalError = sdkDownloadEventPayload.hasOriginalError ? sdkDownloadEventPayload.originalError : nil } From a5301b8046a7ca3c9f3de861bd4ad5d0b087ed6e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Dec 2025 10:45:58 +0000 Subject: [PATCH 348/791] Fix missing artifact requirements for publishing Kotlin package --- kt/sdk/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kt/sdk/build.gradle.kts b/kt/sdk/build.gradle.kts index 66443dc3..27118276 100644 --- a/kt/sdk/build.gradle.kts +++ b/kt/sdk/build.gradle.kts @@ -166,3 +166,7 @@ tasks.register("copyProto") { tasks.named { name -> name.matches("generate.*Proto".toRegex()) }.configureEach { dependsOn("copyProto") } + +tasks.named { name -> name == "javaDocReleaseGeneration" }.configureEach { + enabled = false +} From d80a728b0012ed21a46e9d8e5fb84489781d6449 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Dec 2025 07:40:33 +0000 Subject: [PATCH 349/791] Bump crypto lib to handle decrypted AEAD session key exports --- cs/Directory.Packages.props | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs | 2 +- .../src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs | 2 +- .../Serialization/PgpSessionKeyJsonConverter.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 74b9b461..d461e75f 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -15,7 +15,7 @@ - + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index 9d85aa20..950c4e15 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -237,7 +237,7 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK AuthorshipVerificationFailure? verificationFailure; try { - var verificationResult = verificationKeyRing.Verify(contentKey.Export().Token, contentKeySignature); + var verificationResult = verificationKeyRing.Verify(contentKey.Export(), contentKeySignature); verificationFailure = verificationResult.Status is not PgpVerificationStatus.Ok ? new AuthorshipVerificationFailure(verificationResult.Status) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 435efad9..404a660e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -80,7 +80,7 @@ private static FileCreationRequest GetFileCreationRequest( out var lockedKeyBytes); contentKey = useAeadFeatureFlag ? PgpSessionKey.GenerateForAead() : PgpSessionKey.Generate(); - var (contentKeyToken, _) = contentKey.Export(); + var contentKeyToken = contentKey.Export(); return new FileCreationRequest { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs index f0f3e995..67531dec 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs @@ -24,7 +24,7 @@ public override PgpSessionKey Read(ref Utf8JsonReader reader, Type typeToConvert public override void Write(Utf8JsonWriter writer, PgpSessionKey value, JsonSerializerOptions options) { var pkeskVersion = value.IsAead() ? AeadVersion : NonAeadVersion; - var (token, _) = value.Export(); + var token = value.Export(); Span versionedValue = stackalloc byte[token.Length + 1]; versionedValue[0] = pkeskVersion; token.CopyTo(versionedValue[1..]); From d7b4cc356a553aa71c7e0351ce3b3dd9a047208e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Dec 2025 09:21:43 +0000 Subject: [PATCH 350/791] Upgrade version from 0.6.0-alpha.1 to 0.6.0-alpha.3 --- kt/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kt/build.gradle.kts b/kt/build.gradle.kts index 9beecbcd..c2ad0353 100644 --- a/kt/build.gradle.kts +++ b/kt/build.gradle.kts @@ -40,7 +40,7 @@ allprojects { } } group = "me.proton.drive" - version = "0.6.0-alpha.1" + version = "0.6.0-alpha.3" afterEvaluate { configurations.all { From 1a4033724827886e5f31dc2105bcd6b939061e1e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Dec 2025 12:00:42 +0100 Subject: [PATCH 351/791] Remove debug log with fatal level --- kt/sdk/src/main/jni/proton_drive_sdk.c | 5 ----- 1 file changed, 5 deletions(-) diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 903fa7e9..a5bfa20b 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -137,11 +137,6 @@ void onHttpCancellation( ); return; } - __android_log_print( - ANDROID_LOG_FATAL, - "drive.sdk.internal", - "Calling found method: %s", "cancel" - ); (*env)->CallVoidMethod(env, obj, mid); } } From 7164f00e89772e919b557c105c500cace381b691 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Dec 2025 13:49:45 +0100 Subject: [PATCH 352/791] Revamp CI pipelines --- kt/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kt/build.gradle.kts b/kt/build.gradle.kts index c2ad0353..66ff4a00 100644 --- a/kt/build.gradle.kts +++ b/kt/build.gradle.kts @@ -40,7 +40,7 @@ allprojects { } } group = "me.proton.drive" - version = "0.6.0-alpha.3" + version = providers.environmentVariable("VERSION").getOrElse("0.0.0-SNAPSHOT") afterEvaluate { configurations.all { From f283038e20bb998ad365da94ba3ac2367dda2e5e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 06:38:35 +0000 Subject: [PATCH 353/791] Add new name param to copy --- js/sdk/src/interface/nodes.ts | 5 +- js/sdk/src/internal/errors.ts | 4 + js/sdk/src/internal/nodes/apiService.ts | 115 ++++++++++-------- .../src/internal/nodes/cryptoService.test.ts | 16 ++- js/sdk/src/internal/nodes/cryptoService.ts | 8 +- .../internal/nodes/nodesManagement.test.ts | 52 +++++++- js/sdk/src/internal/nodes/nodesManagement.ts | 26 ++-- js/sdk/src/protonDriveClient.ts | 23 +++- 8 files changed, 173 insertions(+), 76 deletions(-) diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 4ddee408..b0d228dd 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -223,7 +223,6 @@ export enum RevisionState { export type NodeOrUid = MaybeNode | NodeEntity | DegradedNode | string; export type RevisionOrUid = Revision | string; +// TODO: Remove string from the result and use Error instead to be compatible with the NodeResultWithNewUid. export type NodeResult = { uid: string; ok: true } | { uid: string; ok: false; error: string }; -export type NodeResultWithNewUid = - | { uid: string; newUid: string; ok: true } - | { uid: string; ok: false; error: string }; +export type NodeResultWithNewUid = { uid: string; newUid: string; ok: true } | { uid: string; ok: false; error: Error }; diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index 0ff7742c..7a439d20 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -3,6 +3,10 @@ import { c } from 'ttag'; import { VERIFICATION_STATUS } from '../crypto'; import { AbortError, ConnectionError, RateLimitedError, ValidationError } from '../errors'; +export function createErrorFromUnknown(error: unknown): Error { + return error instanceof Error ? error : new Error(getErrorMessage(error), { cause: error }); +} + export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : c('Error').t`Unknown error`; } diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index be969b1c..64b80cb1 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -341,24 +341,29 @@ export class NodeAPIService { const { volumeId, nodeId } = splitNodeUid(nodeUid); const { nodeId: newParentNodeId } = splitNodeUid(newNode.parentUid); - await this.apiService.put, PutMoveNodeResponse>( - `drive/v2/volumes/${volumeId}/links/${nodeId}/move`, - { - ParentLinkID: newParentNodeId, - NodePassphrase: newNode.armoredNodePassphrase, - // @ts-expect-error: API accepts NodePassphraseSignature as optional. - NodePassphraseSignature: newNode.armoredNodePassphraseSignature, - // @ts-expect-error: API accepts SignatureEmail as optional. - SignatureEmail: newNode.signatureEmail, - Name: newNode.encryptedName, - // @ts-expect-error: API accepts NameSignatureEmail as optional. - NameSignatureEmail: newNode.nameSignatureEmail, - Hash: newNode.hash, - OriginalHash: oldNode.hash, - ContentHash: newNode.contentHash || null, - }, - signal, - ); + try { + await this.apiService.put, PutMoveNodeResponse>( + `drive/v2/volumes/${volumeId}/links/${nodeId}/move`, + { + ParentLinkID: newParentNodeId, + NodePassphrase: newNode.armoredNodePassphrase, + // @ts-expect-error: API accepts NodePassphraseSignature as optional. + NodePassphraseSignature: newNode.armoredNodePassphraseSignature, + // @ts-expect-error: API accepts SignatureEmail as optional. + SignatureEmail: newNode.signatureEmail, + Name: newNode.encryptedName, + // @ts-expect-error: API accepts NameSignatureEmail as optional. + NameSignatureEmail: newNode.nameSignatureEmail, + Hash: newNode.hash, + OriginalHash: oldNode.hash, + ContentHash: newNode.contentHash || null, + }, + signal, + ); + } catch (error: unknown) { + handleNodeWithSameNameExistsValidationError(volumeId, error); + throw error; + } } async copyNode( @@ -377,23 +382,29 @@ export class NodeAPIService { const { volumeId, nodeId } = splitNodeUid(nodeUid); const { volumeId: parentVolumeId, nodeId: parentNodeId } = splitNodeUid(newNode.parentUid); - const response = await this.apiService.post( - `drive/volumes/${volumeId}/links/${nodeId}/copy`, - { - TargetVolumeID: parentVolumeId, - TargetParentLinkID: parentNodeId, - NodePassphrase: newNode.armoredNodePassphrase, - // @ts-expect-error: API accepts NodePassphraseSignature as optional. - NodePassphraseSignature: newNode.armoredNodePassphraseSignature, - // @ts-expect-error: API accepts SignatureEmail as optional. - SignatureEmail: newNode.signatureEmail, - Name: newNode.encryptedName, - // @ts-expect-error: API accepts NameSignatureEmail as optional. - NameSignatureEmail: newNode.nameSignatureEmail, - Hash: newNode.hash, - }, - signal, - ); + let response: PostCopyNodeResponse; + try { + response = await this.apiService.post( + `drive/volumes/${volumeId}/links/${nodeId}/copy`, + { + TargetVolumeID: parentVolumeId, + TargetParentLinkID: parentNodeId, + NodePassphrase: newNode.armoredNodePassphrase, + // @ts-expect-error: API accepts NodePassphraseSignature as optional. + NodePassphraseSignature: newNode.armoredNodePassphraseSignature, + // @ts-expect-error: API accepts SignatureEmail as optional. + SignatureEmail: newNode.signatureEmail, + Name: newNode.encryptedName, + // @ts-expect-error: API accepts NameSignatureEmail as optional. + NameSignatureEmail: newNode.nameSignatureEmail, + Hash: newNode.hash, + }, + signal, + ); + } catch (error: unknown) { + handleNodeWithSameNameExistsValidationError(volumeId, error); + throw error; + } return makeNodeUid(volumeId, response.LinkID); } @@ -491,21 +502,7 @@ export class NodeAPIService { }, ); } catch (error: unknown) { - if (error instanceof ValidationError) { - if (error.code === ErrorCode.ALREADY_EXISTS) { - const typedDetails = error.details as - | { - ConflictLinkID: string; - } - | undefined; - - const existingNodeUid = typedDetails?.ConflictLinkID - ? makeNodeUid(volumeId, typedDetails.ConflictLinkID) - : undefined; - - throw new NodeWithSameNameExistsValidationError(error.message, error.code, existingNodeUid); - } - } + handleNodeWithSameNameExistsValidationError(volumeId, error); throw error; } @@ -615,6 +612,24 @@ function* handleResponseErrors( } } +function handleNodeWithSameNameExistsValidationError(volumeId: string, error: unknown): void { + if (error instanceof ValidationError) { + if (error.code === ErrorCode.ALREADY_EXISTS) { + const typedDetails = error.details as + | { + ConflictLinkID: string; + } + | undefined; + + const existingNodeUid = typedDetails?.ConflictLinkID + ? makeNodeUid(volumeId, typedDetails.ConflictLinkID) + : undefined; + + throw new NodeWithSameNameExistsValidationError(error.message, error.code, existingNodeUid); + } + } +} + function linkToEncryptedNode( logger: Logger, volumeId: string, diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index e148f47e..a2295abf 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1327,7 +1327,12 @@ describe('nodesCryptoService', () => { key: 'addressKey' as any, }; - const result = await cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, signingKeys); + const result = await cryptoService.encryptNodeWithNewParent( + node.name, + keys as any, + parentKeys, + signingKeys, + ); expect(result).toEqual({ encryptedName: 'encryptedNodeName', @@ -1360,7 +1365,12 @@ describe('nodesCryptoService', () => { parentNodeKey: 'parentNodeKey' as any, }; - const result = await cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, signingKeys); + const result = await cryptoService.encryptNodeWithNewParent( + node.name, + keys as any, + parentKeys, + signingKeys, + ); expect(result).toEqual({ encryptedName: 'encryptedNodeName', @@ -1407,7 +1417,7 @@ describe('nodesCryptoService', () => { }; await expect( - cryptoService.encryptNodeWithNewParent(node, keys as any, parentKeys, signingKeys), + cryptoService.encryptNodeWithNewParent(node.name, keys as any, parentKeys, signingKeys), ).rejects.toThrow('Moving item to a non-folder is not allowed'); }); diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 35c5ecef..b7b0d146 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -637,7 +637,7 @@ export class NodesCryptoService { } async encryptNodeWithNewParent( - node: Pick, + nodeName: DecryptedNode['name'], keys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey }, parentKeys: { key: PrivateKey; hashKey: Uint8Array }, signingKeys: NodeSigningKeys, @@ -652,7 +652,7 @@ export class NodesCryptoService { if (!parentKeys.hashKey) { throw new ValidationError('Moving item to a non-folder is not allowed'); } - if (!node.name.ok) { + if (!nodeName.ok) { throw new ValidationError('Cannot move item without a valid name, please rename the item first'); } @@ -664,12 +664,12 @@ export class NodesCryptoService { } const { armoredNodeName } = await this.driveCrypto.encryptNodeName( - node.name.value, + nodeName.value, keys.nameSessionKey, parentKeys.key, nameAndPassprhaseSigningKey, ); - const hash = await this.driveCrypto.generateLookupHash(node.name.value, parentKeys.hashKey); + const hash = await this.driveCrypto.generateLookupHash(nodeName.value, parentKeys.hashKey); const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase( keys.passphrase, keys.passphraseSessionKey, diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 12c7ea09..f997ee51 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -6,6 +6,7 @@ import { DecryptedNode } from './interface'; import { NodesManagement } from './nodesManagement'; import { NodeResult } from '../../interface'; import { NodeOutOfSyncError } from './errors'; +import { ValidationError } from '../../errors'; describe('NodesManagement', () => { let apiService: NodeAPIService; @@ -191,7 +192,7 @@ describe('NodesManagement', () => { parentNodeUid: 'newParentNodeUid', }); expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( - nodes.nodeUid, + nodes.nodeUid.name, expect.objectContaining({ key: 'nodeUid-key', passphrase: 'nodeUid-passphrase', @@ -231,7 +232,7 @@ describe('NodesManagement', () => { const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid'); expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( - nodes.anonymousNodeUid, + nodes.anonymousNodeUid.name, expect.objectContaining({ key: 'anonymousNodeUid-key', passphrase: 'anonymousNodeUid-passphrase', @@ -289,7 +290,7 @@ describe('NodesManagement', () => { parentNodeUid: 'newParentNodeUid', }); expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( - nodes.nodeUid, + nodes.nodeUid.name, expect.objectContaining({ key: 'nodeUid-key', passphrase: 'nodeUid-passphrase', @@ -324,7 +325,7 @@ describe('NodesManagement', () => { const newNode = await management.copyNode('anonymousNodeUid', 'newParentNodeUid'); expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( - nodes.anonymousNodeUid, + nodes.anonymousNodeUid.name, expect.objectContaining({ key: 'anonymousNodeUid-key', passphrase: 'anonymousNodeUid-passphrase', @@ -350,6 +351,49 @@ describe('NodesManagement', () => { }); }); + it('copyNode manages copy of node with new name', async () => { + const encryptedCrypto = { + encryptedName: 'copiedArmoredNodeName', + hash: 'copiedHash', + armoredNodePassphrase: 'copiedArmoredNodePassphrase', + armoredNodePassphraseSignature: 'copiedArmoredNodePassphraseSignature', + signatureEmail: 'copiedSignatureEmail', + nameSignatureEmail: 'copiedNameSignatureEmail', + }; + cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto); + + const newName = 'new name'; + const newNode = await management.copyNode('nodeUid', 'newParentNodeUid', newName); + + expect(newNode).toEqual({ + ...nodes.nodeUid, + name: { ok: true, value: newName }, + uid: 'newCopiedNodeUid', + parentUid: 'newParentNodeUid', + encryptedName: 'copiedArmoredNodeName', + hash: 'copiedHash', + keyAuthor: { ok: true, value: 'copiedSignatureEmail' }, + nameAuthor: { ok: true, value: 'copiedNameSignatureEmail' }, + }); + expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( + { ok: true, value: newName }, + expect.objectContaining({ + key: 'nodeUid-key', + passphrase: 'nodeUid-passphrase', + passphraseSessionKey: 'nodeUid-passphraseSessionKey', + contentKeyPacketSessionKey: 'nodeUid-contentKeyPacketSessionKey', + nameSessionKey: 'nodeUid-nameSessionKey', + }), + expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }), + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, + ); + }); + + it('copyNode throws error if name is invalid', async () => { + const promise = management.copyNode('nodeUid', 'newParentNodeUid', 'invalid/name'); + await expect(promise).rejects.toThrow(ValidationError); + }); + it('trashes node and updates cache', async () => { const uids = ['v1~n1', 'v1~n2']; const trashed = new Set(); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 153eada4..3b3b8883 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -1,8 +1,8 @@ import { c } from 'ttag'; -import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk } from '../../interface'; +import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk, InvalidNameError } from '../../interface'; import { AbortError, ValidationError } from '../../errors'; -import { getErrorMessage } from '../errors'; +import { createErrorFromUnknown, getErrorMessage } from '../errors'; import { splitNodeUid } from '../uids'; import { NodeAPIService } from './apiService'; import { NodesCryptoCache } from './cryptoCache'; @@ -140,7 +140,7 @@ export class NodesManagement { } const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent( - node, + node.name, keys, { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, signingKeys, @@ -186,16 +186,18 @@ export class NodesManagement { // Improvement requested: copy nodes in parallel using copy_multiple endpoint async *copyNodes( - nodeUids: string[], + nodeUidsOrWithNames: (string | { uid: string; name: string })[], newParentNodeUid: string, signal?: AbortSignal, ): AsyncGenerator { - for (const nodeUid of nodeUids) { + for (const nodeUidOrWithName of nodeUidsOrWithNames) { if (signal?.aborted) { throw new AbortError(c('Error').t`Copy operation aborted`); } + const nodeUid = typeof nodeUidOrWithName === 'string' ? nodeUidOrWithName : nodeUidOrWithName.uid; + const name = typeof nodeUidOrWithName === 'string' ? undefined : nodeUidOrWithName.name; try { - const { uid: newNodeUid } = await this.copyNode(nodeUid, newParentNodeUid); + const { uid: newNodeUid } = await this.copyNode(nodeUid, newParentNodeUid, name); yield { uid: nodeUid, newUid: newNodeUid, @@ -205,14 +207,19 @@ export class NodesManagement { yield { uid: nodeUid, ok: false, - error: getErrorMessage(error), + error: createErrorFromUnknown(error), }; } } } - async copyNode(nodeUid: string, newParentUid: string): Promise { + async copyNode(nodeUid: string, newParentUid: string, name?: string): Promise { + if (name) { + validateNodeName(name); + } + const node = await this.nodesAccess.getNode(nodeUid); + const nodeName = name ? resultOk(name) : node.name; const [keys, newParentKeys, signingKeys] = await Promise.all([ this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid), @@ -225,7 +232,7 @@ export class NodesManagement { } const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent( - node, + nodeName, keys, { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, signingKeys, @@ -252,6 +259,7 @@ export class NodesManagement { }); const newNode: DecryptedNode = { ...node, + name: nodeName, uid: newNodeUid, encryptedName: encryptedCrypto.encryptedName, parentUid: newParentUid, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index f5ebd8d9..e1797bb3 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -424,6 +424,12 @@ export class ProtonDriveClient { * The operation is performed node by node and the results are yielded * as they are available. Order of the results is not guaranteed. * + * The `nodeUids` can be a list of node entities or their UIDs, or a list + * of objects with `uid` and `name` properties where the name is the new + * name of the copied node. By default, the name is the same as the + * original node. Use `getAvailableName` to get the available name for the + * new node in the target parent node in case of a name conflict. + * * If one of the nodes fails to copy, the operation continues with the * rest of the nodes. Use `NodeResult` to check the status of the action. * @@ -433,12 +439,23 @@ export class ProtonDriveClient { * @returns An async generator of the results of the copy operation */ async *copyNodes( - nodeUids: NodeOrUid[], + nodesOrNodeUidsOrWithNames: (NodeOrUid | { uid: string; name: string })[], newParentNodeUid: NodeOrUid, signal?: AbortSignal, ): AsyncGenerator { - this.logger.info(`Copying ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`); - yield* this.nodes.management.copyNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); + this.logger.info(`Copying ${nodesOrNodeUidsOrWithNames.length} nodes to ${getUid(newParentNodeUid)}`); + + const nodeUidsOrWithNames = nodesOrNodeUidsOrWithNames.map((param) => { + if (typeof param === 'string') { + return param; + } + if ('uid' in param && 'name' in param && typeof param.uid === 'string' && typeof param.name === 'string') { + return { uid: param.uid, name: param.name }; + } + return getUid(param); + }); + + yield* this.nodes.management.copyNodes(nodeUidsOrWithNames, getUid(newParentNodeUid), signal); } /** From 3336ebe4386f298d8fe30bccd6a0ef08a53ee12a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 08:23:29 +0100 Subject: [PATCH 354/791] Add modification time to the node entity --- js/sdk/src/interface/nodes.ts | 7 +++++++ js/sdk/src/internal/nodes/apiService.test.ts | 2 ++ js/sdk/src/internal/nodes/apiService.ts | 1 + js/sdk/src/internal/nodes/cache.test.ts | 1 + js/sdk/src/internal/nodes/cache.ts | 2 ++ js/sdk/src/internal/nodes/index.test.ts | 1 + js/sdk/src/internal/nodes/interface.ts | 1 + js/sdk/src/internal/nodes/nodesManagement.ts | 1 + js/sdk/src/transformers.ts | 5 +++-- 9 files changed, 19 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index b0d228dd..d03347cd 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -92,6 +92,10 @@ export type NodeEntity = { * Created on server date. */ creationTime: Date; + /** + * Modified on server (renamed, moved, etc.). + */ + modificationTime: Date; trashTime?: Date; /** * Total size of all revisions, encrypted size on the server. @@ -208,6 +212,9 @@ export type Revision = { * Raw size of the revision, as stored in extended attributes. */ claimedSize?: number; + /** + * Modification time on the file system. + */ claimedModificationTime?: Date; claimedDigests?: { sha1?: string; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 118cb8cd..e19c6c14 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -65,6 +65,7 @@ function generateAPINode() { ParentLinkID: 'parentLinkId', NameHash: 'nameHash', CreateTime: 123456789, + ModifyTime: 1234567890, TrashTime: 0, Name: 'encName', @@ -140,6 +141,7 @@ function generateNode() { uid: 'volumeId~linkId', parentUid: 'volumeId~parentLinkId', creationTime: new Date(123456789000), + modificationTime: new Date(1234567890000), trashTime: undefined, shareId: undefined, diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 64b80cb1..a6e13687 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -648,6 +648,7 @@ function linkToEncryptedNode( parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, type: nodeTypeNumberToNodeType(logger, link.Link.Type), creationTime: new Date(link.Link.CreateTime * 1000), + modificationTime: new Date(link.Link.ModifyTime * 1000), trashTime: link.Link.TrashTime ? new Date(link.Link.TrashTime * 1000) : undefined, // Sharing node metadata diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 65e887fb..05e1cb58 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -23,6 +23,7 @@ function generateNode( isShared: false, isSharedPublicly: false, creationTime: new Date(), + modificationTime: new Date(), trashTime: undefined, volumeId: 'volumeId', isStale: false, diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 60b40028..1006469e 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -263,6 +263,7 @@ function deserialiseNode(nodeData: string): DecryptedNode { typeof node.isShared !== 'boolean' || !node.creationTime || typeof node.creationTime !== 'string' || + typeof node.modificationTime !== 'string' || (typeof node.trashTime !== 'string' && node.trashTime !== undefined) || (typeof node.folder !== 'object' && node.folder !== undefined) || (typeof node.folder?.claimedModificationTime !== 'string' && node.folder?.claimedModificationTime !== undefined) @@ -272,6 +273,7 @@ function deserialiseNode(nodeData: string): DecryptedNode { return { ...node, creationTime: new Date(node.creationTime), + modificationTime: new Date(node.modificationTime), trashTime: node.trashTime ? new Date(node.trashTime) : undefined, activeRevision: node.activeRevision ? deserialiseRevision(node.activeRevision) : undefined, membership: node.membership diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index 2332fa70..221c21db 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -26,6 +26,7 @@ function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial< isShared: false, isSharedPublicly: false, creationTime: new Date(), + modificationTime: new Date(), trashTime: undefined, isStale: false, ...params, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index d9326f94..c9104ea8 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -33,6 +33,7 @@ interface BaseNode { type: NodeType; mediaType?: string; creationTime: Date; // created on the server + modificationTime: Date; // modified on server trashTime?: Date; totalStorageSize?: number; diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 3b3b8883..a23b5b09 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -340,6 +340,7 @@ export class NodesManagement { type: NodeType.Folder, mediaType: 'Folder', creationTime: new Date(), + modificationTime: new Date(), // Share node metadata isShared: false, diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index 02ac226d..c5188861 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,7 +1,6 @@ import { MaybeNode as PublicMaybeNode, MaybeMissingNode as PublicMaybeMissingNode, - NodeEntity as PublicNodeEntity, DegradedNode as PublicDegradedNode, Revision as PublicRevision, Result, @@ -25,6 +24,7 @@ type InternalPartialNode = Pick< | 'isShared' | 'isSharedPublicly' | 'creationTime' + | 'modificationTime' | 'trashTime' | 'activeRevision' | 'folder' @@ -93,6 +93,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode isShared: node.isShared, isSharedPublicly: node.isSharedPublicly, creationTime: node.creationTime, + modificationTime: node.modificationTime, trashTime: node.trashTime, totalStorageSize: node.totalStorageSize, folder: node.folder, @@ -118,7 +119,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode ...baseNodeMetadata, name: name.value, activeRevision: activeRevision?.ok ? convertInternalRevision(activeRevision.value) : undefined, - } as PublicNodeEntity); + }); } export async function* convertInternalRevisionIterator( From 363f41949cead660e390c229eac5e5d2b3047daa Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 07:33:22 +0100 Subject: [PATCH 355/791] Add onMessage to ProtonDrivePublicLinkClient --- js/sdk/src/protonDrivePublicLinkClient.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 85861daf..e867f16b 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -18,6 +18,7 @@ import { UploadMetadata, FileUploader, NodeResult, + SDKEvent, } from './interface'; import { Telemetry } from './telemetry'; import { @@ -164,6 +165,16 @@ export class ProtonDrivePublicLinkClient { }; } + /** + * Subscribes to the general SDK events. + * + * See `ProtonDriveClient.onMessage` for more information. + */ + onMessage(eventName: SDKEvent, callback: () => void): () => void { + this.logger.debug(`Subscribing to event ${eventName}`); + return this.sdkEvents.addListener(eventName, callback); + } + /** * @returns The root folder to the public link. */ From 4e554bcd15eea667b5028c798150c793cf98c021 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 15:21:02 +0100 Subject: [PATCH 356/791] Increase number of attempts for block transfers --- cs/Directory.Packages.props | 1 + .../Http/NonDisposingStreamWrapper.cs | 140 ++++++++++++++++++ .../Nodes/Download/BlockDownloader.cs | 61 +++++--- .../Nodes/Download/RevisionReader.cs | 34 +++-- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 18 ++- .../Nodes/Upload/BlockUploader.cs | 67 ++++----- .../Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 1 + .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 2 +- .../Resilience/RetryPolicy.cs | 13 ++ cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 6 +- .../ProtonClientConfigurationExtensions.cs | 12 +- 11 files changed, 273 insertions(+), 82 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Http/NonDisposingStreamWrapper.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Resilience/RetryPolicy.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index d461e75f..f03aad12 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Http/NonDisposingStreamWrapper.cs b/cs/sdk/src/Proton.Drive.Sdk/Http/NonDisposingStreamWrapper.cs new file mode 100644 index 00000000..0a3ad5bf --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Http/NonDisposingStreamWrapper.cs @@ -0,0 +1,140 @@ +namespace Proton.Drive.Sdk.Http; + +/// +/// Wrapper that cancels disposal of a object>. +/// This wrapper forwards all operations to an inner stream but suppresses disposal of that inner stream. +/// +/// +/// This is useful in case a stream consumer such as +/// always disposes/closes a stream with no option to leave it open for re-use or for getting its position or length. +/// Note: The underlying stream is not disposed when this wrapper is disposed. +/// You remain responsible for explicitly disposing the original stream when it is no longer needed. +/// +internal sealed class NonDisposingStreamWrapper(Stream innerStream) : Stream +{ + private readonly Stream _innerStream = innerStream; + + public override bool CanRead => _innerStream.CanRead; + public override bool CanSeek => _innerStream.CanSeek; + public override bool CanTimeout => _innerStream.CanTimeout; + public override bool CanWrite => _innerStream.CanWrite; + public override long Length => _innerStream.Length; + public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; } + public override int ReadTimeout { get => _innerStream.ReadTimeout; set => _innerStream.ReadTimeout = value; } + public override int WriteTimeout { get => _innerStream.WriteTimeout; set => _innerStream.WriteTimeout = value; } + + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + { + return _innerStream.BeginRead(buffer, offset, count, callback, state); + } + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + { + return _innerStream.BeginWrite(buffer, offset, count, callback, state); + } + + public override void CopyTo(Stream destination, int bufferSize) + { + _innerStream.CopyTo(destination, bufferSize); + } + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override int EndRead(IAsyncResult asyncResult) + { + return _innerStream.EndRead(asyncResult); + } + + public override void EndWrite(IAsyncResult asyncResult) + { + _innerStream.EndWrite(asyncResult); + } + + public override void Flush() + { + _innerStream.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _innerStream.FlushAsync(cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _innerStream.Read(buffer, offset, count); + } + + public override int Read(Span buffer) + { + return _innerStream.Read(buffer); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _innerStream.ReadAsync(buffer, cancellationToken); + } + + public override int ReadByte() + { + return _innerStream.ReadByte(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _innerStream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _innerStream.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + _innerStream.Write(buffer); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return _innerStream.WriteAsync(buffer, cancellationToken); + } + + public override void WriteByte(byte value) + { + _innerStream.WriteByte(value); + } + + public override bool Equals(object? obj) + { + return _innerStream.Equals(obj); + } + + public override int GetHashCode() + { + return _innerStream.GetHashCode(); + } + + public override string? ToString() + { + return _innerStream.ToString(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index f066285b..977dc076 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -1,16 +1,21 @@ using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Polly; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Resilience; namespace Proton.Drive.Sdk.Nodes.Download; -internal sealed class BlockDownloader +internal sealed partial class BlockDownloader { private readonly ProtonDriveClient _client; + private readonly ILogger _logger; internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) { _client = client; + _logger = client.Telemetry.GetLogger("Block downloader"); Queue = new TransferQueue(maxDegreeOfParallelism, client.Telemetry.GetLogger("Block downloader queue")); } @@ -18,35 +23,57 @@ internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) public TransferQueue Queue { get; } public async ValueTask> DownloadAsync( + RevisionUid revisionUid, + int index, string bareUrl, string token, PgpSessionKey contentKey, Stream outputStream, CancellationToken cancellationToken) { - using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - - var blobStream = await _client.Api.Storage.GetBlobStreamAsync(bareUrl, token, cancellationToken).ConfigureAwait(false); - - var hashingStream = new HashingReadStream(blobStream, sha256); + return await Policy + .Handle(ex => ex is not FileContentsDecryptionException) + .WaitAndRetryAsync( + retryCount: 4, + sleepDurationProvider: RetryPolicy.GetAttemptDelay, + onRetry: (exception, _, retryNumber, _) => + { + LogBlobDownloadFailure(exception, index, revisionUid, retryNumber); + outputStream.Seek(0, SeekOrigin.Begin); + }) + .ExecuteAsync(ExecuteDownloadAsync).ConfigureAwait(false); - try + async Task ExecuteDownloadAsync() { - await using (hashingStream.ConfigureAwait(false)) + try { - var decryptingStream = contentKey.OpenDecryptingStream(hashingStream); + using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + var blobStream = await _client.Api.Storage.GetBlobStreamAsync(bareUrl, token, cancellationToken).ConfigureAwait(false); - await using (decryptingStream.ConfigureAwait(false)) + var hashingStream = new HashingReadStream(blobStream, sha256); + + await using (hashingStream.ConfigureAwait(false)) { - await decryptingStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + var decryptingStream = contentKey.OpenDecryptingStream(hashingStream); + + await using (decryptingStream.ConfigureAwait(false)) + { + await decryptingStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + } } + + return sha256.GetCurrentHash(); + } + catch (CryptographicException e) + { + throw new FileContentsDecryptionException(e); } } - catch (CryptographicException e) - { - throw new FileContentsDecryptionException(e); - } - - return sha256.GetCurrentHash(); } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Blob download failed for block #{BlockIndex} for revision \"{RevisionUid}\" (retry number: {RetryNumber})")] + private partial void LogBlobDownloadFailure(Exception exception, int blockIndex, RevisionUid revisionUid, int retryNumber); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 1b8d6544..e3217eba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -13,8 +13,7 @@ internal sealed partial class RevisionReader : IDisposable private readonly ProtonDriveClient _client; private readonly PgpPrivateKey _nodeKey; - private readonly NodeUid _fileUid; - private readonly RevisionId _revisionId; + private readonly RevisionUid _revisionUid; private readonly PgpSessionKey _contentKey; private readonly BlockListingRevisionDto _revisionDto; private readonly Action _releaseBlockListingAction; @@ -38,8 +37,7 @@ internal RevisionReader( { _client = client; _nodeKey = nodeKey; - _fileUid = revisionUid.NodeUid; - _revisionId = revisionUid.RevisionId; + _revisionUid = revisionUid; _contentKey = contentKey; _revisionDto = revisionDto; _releaseBlockListingAction = releaseBlockListingAction; @@ -125,7 +123,7 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt if (manifestVerificationStatus is not PgpVerificationStatus.Ok) { - LogFailedManifestVerification(_fileUid, manifestVerificationStatus); + LogFailedManifestVerification(_revisionUid, manifestVerificationStatus); throw new ProtonDriveException("File authenticity check failed"); } @@ -223,8 +221,14 @@ private async Task DownloadBlockAsync(BlockDto block, Strea isIntermediateStream = true; } - var hashDigest = await _client.BlockDownloader.DownloadAsync(block.BareUrl, block.Token, _contentKey, blockOutputStream, cancellationToken) - .ConfigureAwait(false); + var hashDigest = await _client.BlockDownloader.DownloadAsync( + _revisionUid, + block.Index, + block.BareUrl, + block.Token, + _contentKey, + blockOutputStream, + cancellationToken).ConfigureAwait(false); return new BlockDownloadResult(block.Index, blockOutputStream, isIntermediateStream, hashDigest); } @@ -268,7 +272,7 @@ private async Task DownloadBlockAsync(BlockDto block, Strea if (block.Index != nextExpectedIndex) { - LogMissingBlock(block.Index, _fileUid); + LogMissingBlock(block.Index, _revisionUid); throw new ProtonDriveException("File contents are incomplete"); } @@ -282,9 +286,9 @@ private async Task DownloadBlockAsync(BlockDto block, Strea { var revisionResponse = await _client.Api.Files.GetRevisionAsync( - _fileUid.VolumeId, - _fileUid.LinkId, - _revisionId, + _revisionUid.NodeUid.VolumeId, + _revisionUid.NodeUid.LinkId, + _revisionUid.RevisionId, lastKnownIndex + 1, _blockPageSize, false, @@ -328,11 +332,11 @@ private async Task VerifyManifestAsync(Stream manifestStr return verificationResult.Status; } - [LoggerMessage(Level = LogLevel.Trace, Message = "Missing block #{BlockIndex} on file with UID \"{FileUid}\"")] - private partial void LogMissingBlock(int blockIndex, NodeUid fileUid); + [LoggerMessage(Level = LogLevel.Trace, Message = "Missing block #{BlockIndex} on revision \"{RevisionUid}\"")] + private partial void LogMissingBlock(int blockIndex, RevisionUid revisionUid); - [LoggerMessage(Level = LogLevel.Trace, Message = "Manifest verification failed for file with UID \"{FileUid}\": {VerificationStatus}")] - private partial void LogFailedManifestVerification(NodeUid fileUid, PgpVerificationStatus verificationStatus); + [LoggerMessage(Level = LogLevel.Trace, Message = "Manifest verification failed for revision \"{RevisionUid}\": {VerificationStatus}")] + private partial void LogFailedManifestVerification(RevisionUid revisionUid, PgpVerificationStatus verificationStatus); private readonly struct BlockDownloadResult(int index, Stream stream, bool isIntermediateStream, ReadOnlyMemory sha256Digest) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index e48d32db..74297aaa 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -80,7 +80,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( await client.ThumbnailBlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } - tasks.Enqueue(DownloadThumbnailAsync(client, fileNode.Uid, block, cancellationToken)); + tasks.Enqueue(DownloadThumbnailAsync(client, fileNode.ActiveRevision.Uid, block, cancellationToken)); } while (tasks.TryDequeue(out var task)) @@ -92,7 +92,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( private static async Task DownloadThumbnailAsync( ProtonDriveClient client, - NodeUid fileUid, + RevisionUid revisionUid, ThumbnailBlock block, CancellationToken cancellationToken) { @@ -103,14 +103,20 @@ private static async Task DownloadThumbnailAsync( var outputStream = new MemoryStream(initialBufferLength); await using (outputStream.ConfigureAwait(false)) { - var fileSecrets = await GetSecretsAsync(client, fileUid, cancellationToken).ConfigureAwait(false); + var fileSecrets = await GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - await client.ThumbnailBlockDownloader.DownloadAsync(block.BareUrl, block.Token, fileSecrets.ContentKey, outputStream, cancellationToken) - .ConfigureAwait(false); + await client.ThumbnailBlockDownloader.DownloadAsync( + revisionUid, + index: 0, + block.BareUrl, + block.Token, + fileSecrets.ContentKey, + outputStream, + cancellationToken).ConfigureAwait(false); var thumbnailData = outputStream.TryGetBuffer(out var outputBuffer) ? outputBuffer : outputStream.ToArray(); - return new FileThumbnail(fileUid, thumbnailData); + return new FileThumbnail(revisionUid.NodeUid, thumbnailData); } } finally diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 976fe273..12a05e70 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -1,16 +1,17 @@ using System.Buffers; using System.Diagnostics; -using System.Net; using System.Security.Cryptography; using Microsoft.Extensions.Logging; using Microsoft.IO; +using Polly; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Http; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload.Verification; -using Proton.Sdk; +using Proton.Drive.Sdk.Resilience; using Proton.Sdk.Addresses; -using Proton.Sdk.Api; using Proton.Sdk.Drive; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -230,44 +231,36 @@ private async ValueTask UploadBlobAsync( Debug.Assert(request.Thumbnails.Count + request.Blocks.Count == 1, "Block upload request should be for only one block, content or thumbnail"); #pragma warning restore S3236 // Caller information arguments should not be provided explicitly - var remainingNumberOfAttempts = 2; - - while (remainingNumberOfAttempts >= 1) + var nonDisposableDataPacketStream = new NonDisposingStreamWrapper(dataPacketStream); + await using (nonDisposableDataPacketStream.ConfigureAwait(false)) { - try - { - // FIXME: request multiple blocks at once - var uploadRequestResponse = await _client.Api.Files.PrepareBlockUploadAsync(request, cancellationToken).ConfigureAwait(false); + await Policy + .Handle(ex => ex is not FileContentsDecryptionException) + .WaitAndRetryAsync( + retryCount: 4, + sleepDurationProvider: RetryPolicy.GetAttemptDelay, + onRetry: (exception, _, retryNumber, _) => + { + var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); + LogBlobUploadFailure(exception, request.Blocks[0].Index, revisionUid, retryNumber); + }) + .ExecuteAsync(ExecuteUploadAsync).ConfigureAwait(false); + } - var uploadTarget = request.Thumbnails.Count == 0 ? uploadRequestResponse.UploadTargets[0] : uploadRequestResponse.ThumbnailUploadTargets[0]; + return; - dataPacketStream.Seek(0, SeekOrigin.Begin); + async Task ExecuteUploadAsync() + { + // FIXME: request multiple blocks at once + var uploadRequestResponse = await _client.Api.Files.PrepareBlockUploadAsync(request, cancellationToken).ConfigureAwait(false); - await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Token, dataPacketStream, cancellationToken) - .ConfigureAwait(false); + var uploadTarget = request.Thumbnails.Count == 0 ? uploadRequestResponse.UploadTargets[0] : uploadRequestResponse.ThumbnailUploadTargets[0]; - remainingNumberOfAttempts = 0; - } - catch (Exception e) when ((UrlExpired(e) || BlobAlreadyUploaded(e)) && remainingNumberOfAttempts >= 2) - { - var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); - - LogBlobUploadFailure(e, request.Blocks[0].Index, revisionUid, remainingNumberOfAttempts); + nonDisposableDataPacketStream.Seek(0, SeekOrigin.Begin); - --remainingNumberOfAttempts; - } + await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Token, nonDisposableDataPacketStream, cancellationToken) + .ConfigureAwait(false); } - - return; - - static bool UrlExpired(Exception e) => e is HttpRequestException { StatusCode: HttpStatusCode.NotFound }; - - // This can happen if the previous successful upload response was not received/processed, - // which could happen for instance if the connection was interrupted just as the success was being sent back. - // The HTTP client's resilience logic will kick in and retry the blob upload at the same URL - // without handing control back to register a new block at the same index with its own new URL, - // causing the back-end to reject the upload with this error. - static bool BlobAlreadyUploaded(Exception e) => e is ProtonApiException { Code: ResponseCode.AlreadyExists }; } [LoggerMessage(Level = LogLevel.Trace, Message = "Uploaded blob for content block #{BlockIndex} for revision \"{RevisionUid}\"")] @@ -277,7 +270,7 @@ await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Tok private partial void LogThumbnailBlobUploaded(RevisionUid revisionUid); [LoggerMessage( - Level = LogLevel.Warning, - Message = "Blob upload failed for block #{BlockIndex} for revision \"{RevisionUid}\" (remaining attempts: {RemainingAttempts}")] - private partial void LogBlobUploadFailure(Exception exception, int blockIndex, RevisionUid revisionUid, int remainingAttempts); + Level = LogLevel.Information, + Message = "Blob upload failed for block #{BlockIndex} for revision \"{RevisionUid}\" (retry number: {RetryNumber})")] + private partial void LogBlobUploadFailure(Exception exception, int blockIndex, RevisionUid revisionUid, int retryNumber); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj index 245a92b2..6bc30714 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -11,6 +11,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index c7ce1c1f..a28a2220 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -33,7 +33,7 @@ public sealed class ProtonDriveClient public ProtonDriveClient(ProtonApiSession session, string? uid = null) : this( session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(DefaultApiTimeoutSeconds)), - session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(StorageApiTimeoutSeconds)), + session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(StorageApiTimeoutSeconds), TimeSpan.FromSeconds(StorageApiTimeoutSeconds)), new AccountClientAdapter(session), new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), session.ClientConfiguration.FeatureFlagProvider, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Resilience/RetryPolicy.cs b/cs/sdk/src/Proton.Drive.Sdk/Resilience/RetryPolicy.cs new file mode 100644 index 00000000..f449f98b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Resilience/RetryPolicy.cs @@ -0,0 +1,13 @@ +namespace Proton.Drive.Sdk.Resilience; + +internal readonly struct RetryPolicy +{ + public static TimeSpan GetAttemptDelay(int retryNumber) + { + var baseSeconds = Math.Pow(2.0, retryNumber - 2); + + var jitteredSeconds = baseSeconds + (Random.Shared.NextDouble() * baseSeconds); + + return TimeSpan.FromSeconds(jitteredSeconds); + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index 2c4f8661..7c5e285f 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -302,11 +302,11 @@ public async Task EndAsync() return _isEnded; } - internal HttpClient GetHttpClient(string? baseRoutePath = null, TimeSpan? timeout = null) + internal HttpClient GetHttpClient(string? baseRoutePath = null, TimeSpan? attemptTimeout = null, TimeSpan? totalTimeout = null) { - return baseRoutePath is null && timeout is null + return baseRoutePath is null && attemptTimeout is null && totalTimeout is null ? _httpClient - : ClientConfiguration.GetHttpClient(this, baseRoutePath, timeout); + : ClientConfiguration.GetHttpClient(this, baseRoutePath, attemptTimeout, totalTimeout); } private static ReadOnlyMemory DeriveSecretFromPassword(ReadOnlySpan password, ReadOnlySpan salt) diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index b7984ed4..16d240f5 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -16,7 +16,8 @@ public static HttpClient GetHttpClient( this ProtonClientConfiguration config, ProtonApiSession? session = null, string? baseRoutePath = null, - TimeSpan? timeout = null) + TimeSpan? attemptTimeout = null, + TimeSpan? totalTimeout = null) { var baseAddress = config.BaseUrl + (baseRoutePath ?? string.Empty); @@ -67,12 +68,17 @@ public static HttpClient GetHttpClient( builder.AddStandardResilienceHandler( options => { - if (timeout is not null) + if (attemptTimeout is not null) { - options.TotalRequestTimeout.Timeout = timeout.Value; + options.AttemptTimeout.Timeout = attemptTimeout.Value; options.CircuitBreaker.SamplingDuration = options.AttemptTimeout.Timeout * 2; } + if (totalTimeout is not null) + { + options.TotalRequestTimeout.Timeout = totalTimeout.Value; + } + var defaultShouldHandleRetry = options.Retry.ShouldHandle; options.Retry.ShouldHandle = async args => await defaultShouldHandleRetry(args).ConfigureAwait(false) From 108ec688f7777eec7993da137fca4e57d75c9cd2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 16:59:14 +0000 Subject: [PATCH 357/791] Add support to C# CLI for downloading by node UID --- .../Nodes/DtoToMetadataConverter.cs | 1 + .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 20 +++++++++-------- .../Nodes/NodeMetadataResultExtensions.cs | 22 ++++++++++++++----- .../Nodes/RevisionOperations.cs | 18 +++++++++++---- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 5 +++++ 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 188cd050..1e270b69 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -254,6 +254,7 @@ public static async Task> ConvertDtoT Thumbnails = thumbnails.AsReadOnly(), AdditionalClaimedMetadata = additionalMetadata, ContentAuthor = contentAuthor, + CanDecrypt = !contentKeyIsInvalid, Errors = (IReadOnlyList)revisionErrors, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 74297aaa..ee510090 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -7,21 +7,20 @@ namespace Proton.Drive.Sdk.Nodes; internal static partial class FileOperations { - public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) + public static async ValueTask> GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) { var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); - var fileSecrets = fileSecretsResult?.GetValueOrDefault(); - - if (fileSecrets is null) + if (fileSecretsResult is null) { var metadataResult = await NodeOperations.GetFreshNodeMetadataAsync(client, fileUid, knownShareAndKey: null, cancellationToken) .ConfigureAwait(false); - fileSecrets = metadataResult.GetFileSecretsOrThrow(); + fileSecretsResult = metadataResult.GetFileSecretsOrThrow(); + } - return fileSecrets; + return (Result)fileSecretsResult; } public static async IAsyncEnumerable EnumerateThumbnailsAsync( @@ -103,17 +102,20 @@ private static async Task DownloadThumbnailAsync( var outputStream = new MemoryStream(initialBufferLength); await using (outputStream.ConfigureAwait(false)) { - var fileSecrets = await GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + var fileSecretsResult = await GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + + var contentKey = fileSecretsResult.TryGetValueElseError(out var fileSecrets, out var degradedFileSecrets) + ? fileSecrets.ContentKey + : degradedFileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}"); await client.ThumbnailBlockDownloader.DownloadAsync( revisionUid, index: 0, block.BareUrl, block.Token, - fileSecrets.ContentKey, + contentKey, outputStream, cancellationToken).ConfigureAwait(false); - var thumbnailData = outputStream.TryGetBuffer(out var outputBuffer) ? outputBuffer : outputStream.ToArray(); return new FileThumbnail(revisionUid.NodeUid, thumbnailData); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs index 77abf024..6849bae8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs @@ -38,16 +38,26 @@ public static FolderSecrets GetFolderSecretsOrThrow(this Result metadataResult) + public static Result GetFileSecretsOrThrow(this Result metadataResult) { - var metadata = metadataResult.GetValueOrThrow(); - - if (!metadata.TryGetFileElseFolder(out _, out var fileSecrets, out var folderNode, out _)) + if (metadataResult.TryGetValueElseError(out var metadata, out var degradedMetadata)) { - throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); + if (!metadata.TryGetFileElseFolder(out _, out var fileSecrets, out var folderNode, out _)) + { + throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); + } + + return fileSecrets; } + else + { + if (!degradedMetadata.TryGetFileElseFolder(out _, out var degradedFileSecrets, out var folderNode, out _)) + { + throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); + } - return fileSecrets; + return degradedFileSecrets; + } } public static bool TryGetFolderKeyElseError( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index 11f5b018..c2c69727 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -19,7 +19,12 @@ internal static partial class RevisionOperations ClientId = client.Uid, }; - var fileSecrets = await FileOperations.GetSecretsAsync(client, fileUid, cancellationToken).ConfigureAwait(false); + var fileSecretsResult = await FileOperations.GetSecretsAsync(client, fileUid, cancellationToken).ConfigureAwait(false); + + if (!fileSecretsResult.TryGetValueElseError(out var fileSecrets, out _)) + { + throw new InvalidOperationException($"Cannot create draft for file {fileUid} with degraded secrets"); + } RevisionId revisionId; try @@ -74,7 +79,12 @@ internal static async ValueTask OpenForReadingAsync( Action releaseBlockListingAction, CancellationToken cancellationToken) { - var fileSecrets = await FileOperations.GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + var fileSecretsResult = await FileOperations.GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + + var (key, contentKey) = fileSecretsResult.TryGetValueElseError(out var fileSecrets, out var degradedFileSecrets) + ? (fileSecrets.Key, fileSecrets.ContentKey) + : (degradedFileSecrets.Key ?? throw new InvalidOperationException($"Node key not available for file {revisionUid.NodeUid}"), + degradedFileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}")); var (fileUid, revisionId) = revisionUid; @@ -92,8 +102,8 @@ internal static async ValueTask OpenForReadingAsync( return new RevisionReader( client, revisionUid, - fileSecrets.Key, - fileSecrets.ContentKey, + key, + contentKey, revisionResponse.Revision, releaseBlockListingAction, () => client.BlockDownloader.Queue.FinishFile()); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index a28a2220..1bbdd4bc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -143,6 +143,11 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio return NodeOperations.GetMyFilesFolderAsync(this, cancellationToken); } + public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + { + return NodeOperations.EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken).Select(x => (Result?)x).FirstOrDefaultAsync(); + } + public ValueTask CreateFolderAsync(NodeUid parentId, string name, CancellationToken cancellationToken) { return FolderOperations.CreateAsync(this, parentId, name, cancellationToken); From 71b8b3a648df1f7fcc7df570ee4678df538f0c55 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Dec 2025 14:09:49 +0000 Subject: [PATCH 358/791] Photos entity to support full decryption and access to photo attributes --- js/sdk/src/interface/index.ts | 1 + js/sdk/src/interface/nodes.ts | 13 +- js/sdk/src/interface/photos.ts | 67 + js/sdk/src/internal/apiService/driveTypes.ts | 1811 +++++++++++++----- js/sdk/src/internal/nodes/apiService.ts | 187 +- js/sdk/src/internal/nodes/cache.ts | 43 +- js/sdk/src/internal/nodes/events.ts | 4 +- js/sdk/src/internal/nodes/nodesAccess.ts | 136 +- js/sdk/src/internal/nodes/nodesManagement.ts | 77 +- js/sdk/src/internal/nodes/nodesRevisions.ts | 6 +- js/sdk/src/internal/photos/albums.ts | 4 +- js/sdk/src/internal/photos/index.ts | 48 +- js/sdk/src/internal/photos/interface.ts | 30 +- js/sdk/src/internal/photos/nodes.ts | 233 +++ js/sdk/src/internal/photos/timeline.test.ts | 4 +- js/sdk/src/internal/photos/timeline.ts | 4 +- js/sdk/src/protonDrivePhotosClient.ts | 46 +- js/sdk/src/transformers.ts | 46 + 18 files changed, 2039 insertions(+), 721 deletions(-) create mode 100644 js/sdk/src/interface/photos.ts create mode 100644 js/sdk/src/internal/photos/nodes.ts diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 1d959219..a6a801fb 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -47,6 +47,7 @@ export type { Membership, } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; +export type { MaybePhotoNode, MaybeMissingPhotoNode, PhotoNode, DegradedPhotoNode, PhotoAttributes } from './photos'; export type { ProtonInvitation, ProtonInvitationWithNode, diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index d03347cd..c68e57ce 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -163,16 +163,13 @@ export enum NodeType { File = 'file', Folder = 'folder', /** - * Album is a special type available only in Photos section. - * - * The SDK does not support any album-specific actions, but it can load - * the node and do general operations on it, such as sharing. However, - * you should not rely that anything can work. It is not guaranteed that - * and in the future specific Photos SDK will support albums. - * - * @deprecated This type is not part of the public API. + * Album is returned only by `ProtonDrivePhotosClient`. */ Album = 'album', + /** + * Photo is returned only by `ProtonDrivePhotosClient`. + */ + Photo = 'photo', } export type Membership = { diff --git a/js/sdk/src/interface/photos.ts b/js/sdk/src/interface/photos.ts new file mode 100644 index 00000000..8ef6bfee --- /dev/null +++ b/js/sdk/src/interface/photos.ts @@ -0,0 +1,67 @@ +import { Result } from "./result"; +import { DegradedNode, NodeEntity, NodeType, MissingNode } from "./nodes"; + +/** + * Node representing a photo or album for Photos SDK. + * + * See `MaybeNode` for more information. + */ +export type MaybePhotoNode = Result; + +/** + * Node representing a photo or album, or missing node for Photos SDK. + * + * See `MaybeMissingNode` for more information. + */ +export type MaybeMissingPhotoNode = Result; + +/** + * Node representing a photo or album for Photos SDK. + * + * See `NodeEntity` for more information. + */ +export type PhotoNode = NodeEntity & { + type: NodeType.Photo; + photo?: PhotoAttributes; +}; + +/** + * Degraded node representing a photo or album for Photos SDK. + * + * See `DegradedNode` for more information. + */ +export type DegradedPhotoNode = DegradedNode & { + photo?: PhotoAttributes; +}; + +/** + * Attributes of a photo. + * + * Only nodes of type `NodeType.Photo` have property of this type. + */ +export type PhotoAttributes = { + /** + * Date used for sorting in the photo timeline. + */ + captureTime: Date; + /** + * Photo can consist of multiple photos or vidoes (e.g., live photo). + * Only the main photos are iterated and each main photo will have + * set the list of related photo UIDs that client can use to load + * the related photos. All the related photos will have set the + * main photo UID. + */ + mainPhotoNodeUid?: string; + relatedPhotoNodeUids: string[]; + /** + * List of albums in which the photo is included. + */ + albums: { + nodeUid: string; + additionTime: Date; + }[]; + /** + * List of tags assigned to the photo. + */ + tags: number[]; // TODO: enum +} diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 225e65ed..8013b3fe 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -763,7 +763,10 @@ export interface paths { }; get?: never; put?: never; - /** Load links details */ + /** + * Load links details + * @description Usage on Photo Volumes of this endpoint is DEPRECATED + */ post: operations["post_drive-v2-volumes-{volumeID}-links"]; delete?: never; options?: never; @@ -1613,6 +1616,23 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/photos/volumes/{volumeID}/links": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Load links details */ + post: operations["post_drive-photos-volumes-{volumeID}-links"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/photos/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/xattr": { parameters: { query?: never; @@ -1653,6 +1673,23 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/urls/{token}/links/{linkID}/path": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fetch link parentIDs by token */ + get: operations["get_drive-urls-{token}-links-{linkID}-path"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/urls/{token}/info": { parameters: { query?: never; @@ -1833,23 +1870,6 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/urls/{token}/links/{linkID}/path": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Fetch link parentIDs by token */ - get: operations["get_drive-urls-{token}-links-{linkID}-path"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/drive/urls/{token}/links/{linkID}/rename": { parameters: { query?: never; @@ -2020,11 +2040,10 @@ export interface paths { path?: never; cookie?: never; }; - /** List URL links on share. */ + /** List URLs on share */ get: operations["get_drive-shares-{shareID}-urls"]; put?: never; - /** Share by URL - * Create a share by URL link. */ + /** Share by URL */ post: operations["post_drive-shares-{shareID}-urls"]; delete?: never; options?: never; @@ -2041,7 +2060,7 @@ export interface paths { }; get?: never; /** - * Update a share by URL link. + * Update a Share URL * @description Only values which are set in the request are updated. When the password is updated, the password, SharePassphraseKeyPacket and SRPVerifier must be updated together. */ put: operations["put_drive-shares-{shareID}-urls-{urlID}"]; @@ -2062,7 +2081,7 @@ export interface paths { }; get?: never; put?: never; - /** Delete multiple ShareURL in a batch. */ + /** Delete multiple Share URLs */ post: operations["post_drive-shares-{shareID}-urls-delete_multiple"]; delete?: never; options?: never; @@ -2070,129 +2089,135 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/map": { + "/drive/unauth/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Search map - * @deprecated - * @description Used only for search on web that does not scale. Should be replaced by better version in the future. + * Check available hashes + * @description See /drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes for full documentation */ - get: operations["get_drive-shares-{shareID}-map"]; - put?: never; - post?: never; + post: operations["post_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/my-files": { + "/drive/unauth/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Bootstrap my files */ - get: operations["get_drive-v2-shares-my-files"]; - put?: never; + /** + * Get revision + * @description See /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID} for full documentation + */ + get: operations["get_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + /** + * Commit a revision + * @description See /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID} for full documentation + */ + put: operations["put_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; post?: never; - delete?: never; + /** + * Delete an obsolete/draft revision + * @description See /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID} for full documentation + */ + delete: operations["delete_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/photos": { + "/drive/unauth/v2/volumes/{volumeID}/documents": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Bootstrap photos section */ - get: operations["get_drive-v2-shares-photos"]; + get?: never; put?: never; - post?: never; + /** + * Create document + * @description See /drive/v2/volumes/{volumeID}/documents for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-documents"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}": { + "/drive/unauth/v2/volumes/{volumeID}/files": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get share bootstrap */ - get: operations["get_drive-shares-{shareID}"]; + get?: never; put?: never; - post?: never; /** - * Delete a standard share by ID - * @description Only standard shares (type 2) can be deleted this way. - * Will throw 422 with body code 2005 if Members, ShareURLs or Invitations are still attached to the share. - * Use Force=1 query param to delete the share together with any attached entities. + * Create a new draft file + * @description See /drive/v2/volumes/{volumeID}/files for full documentation */ - delete: operations["delete_drive-shares-{shareID}"]; + post: operations["post_drive-unauth-v2-volumes-{volumeID}-files"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/links/{linkID}/context": { + "/drive/unauth/v2/volumes/{volumeID}/folders": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Get context share - * @description Gets the highest share, meaning closest to the root, for a link + * Create a folder (v2) + * @description See /drive/v2/volumes/{volumeID}/folders for full documentation */ - get: operations["get_drive-volumes-{volumeID}-links-{linkID}-context"]; - put?: never; - post?: never; + post: operations["post_drive-unauth-v2-volumes-{volumeID}-folders"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares": { + "/drive/unauth/v2/volumes/{volumeID}/files/{linkID}/revisions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List shares - * @description List shares available to current user. - * - * The results can be restricted to a single address by providing the AddressID query parameter. - * By default, only active shares are shown. - * Passing the ShowAll=1 query parameter will show locked and disabled shares also. + * Create revision + * @description See /drive/v2/volumes/{volumeID}/files/{linkID}/revisions for full documentation */ - get: operations["get_drive-shares"]; - put?: never; - post?: never; + post: operations["post_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/owner": { + "/drive/unauth/v2/volumes/{volumeID}/delete_multiple": { parameters: { query?: never; header?: never; @@ -2202,18 +2227,17 @@ export interface paths { get?: never; put?: never; /** - * Update ownership of a share - * @description Replace the signature and related membership of the share. - * This allows users to change the associated address & key they use for a share, so that they can get rid of it. + * Delete multiple (v2) + * @description See /drive/v2/volumes/{volumeID}/delete_multiple for full documentation */ - post: operations["post_drive-shares-{shareID}-owner"]; + post: operations["post_drive-unauth-v2-volumes-{volumeID}-delete_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/migrations/shareaccesswithnode": { + "/drive/unauth/volumes/{volumeID}/thumbnails": { parameters: { query?: never; header?: never; @@ -2222,15 +2246,18 @@ export interface paths { }; get?: never; put?: never; - /** Migrate legacy Shares */ - post: operations["post_drive-migrations-shareaccesswithnode"]; + /** + * Fetch thumbnails by IDs. + * @description See /drive/volumes/{volumeID}/thumbnails for full documentation + */ + post: operations["post_drive-unauth-volumes-{volumeID}-thumbnails"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/migrations/shareaccesswithnode/unmigrated": { + "/drive/unauth/v2/volumes/{volumeID}/folders/{linkID}/children": { parameters: { query?: never; header?: never; @@ -2238,11 +2265,10 @@ export interface paths { cookie?: never; }; /** - * List unmigrated shares - * @description List shares that have not been migrated to the new PassphraseNodeKeyPacket yet. - * Will throw a 422 with Code 2000 if the current user doesn't have any active Volume. + * List folder children (v2) + * @description See /drive/v2/volumes/{volumeID}/folders/{linkID}/children for full documentation */ - get: operations["get_drive-migrations-shareaccesswithnode-unmigrated"]; + get: operations["get_drive-unauth-v2-volumes-{volumeID}-folders-{linkID}-children"]; put?: never; post?: never; delete?: never; @@ -2251,7 +2277,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/shares": { + "/drive/unauth/v2/volumes/{volumeID}/links": { parameters: { query?: never; header?: never; @@ -2261,29 +2287,29 @@ export interface paths { get?: never; put?: never; /** - * Create a standard share - * @description Cannot create two shares on the same link. Throws 422 with code 2500 in case a share already exists. + * Load links details + * @description See /drive/v2/volumes/{volumeID}/links for full documentation */ - post: operations["post_drive-volumes-{volumeID}-shares"]; + post: operations["post_drive-unauth-v2-volumes-{volumeID}-links"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/shares": { + "/drive/unauth/v2/volumes/{volumeID}/links/{linkID}/rename": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; /** - * Shared by me - * @description List Collaborative Shares in the given volume that are not abandoned, i.e. they either have members, invitations or URLs attached. + * Rename link + * @description See /drive/v2/volumes/{volumeID}/links/{linkID}/rename for full documentation */ - get: operations["get_drive-v2-volumes-{volumeID}-shares"]; - put?: never; + put: operations["put_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-rename"]; post?: never; delete?: never; options?: never; @@ -2291,53 +2317,47 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/sharedwithme": { + "/drive/unauth/blocks": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Shared with me - * @description List Collaborative Shares the user has access to as a non-owner + * Request block upload + * @description See /drive/blocks for full documentation */ - get: operations["get_drive-v2-sharedwithme"]; - put?: never; - post?: never; + post: operations["post_drive-unauth-blocks"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/external-invitations/{invitationID}": { + "/drive/unauth/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; /** - * Update an external invitation - * @description Only permissions can be changed. They can be changed when the external invitation is pending or accepted. - * After the external invitation has been accepted, the invitation's permissions can be edited. - * The current user must have admin permission on the share. + * Get verification data. + * @description See /drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification for full documentation */ - put: operations["put_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; + get: operations["get_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification"]; + put?: never; post?: never; - /** - * Delete an external invitation - * @description The current user must have admin permission on the share. - */ - delete: operations["delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/external-invitations": { + "/drive/shares/{shareID}/map": { parameters: { query?: never; header?: never; @@ -2345,34 +2365,28 @@ export interface paths { cookie?: never; }; /** - * List external invitations in a share - * @description The current user must have admin permission on the share. + * Search map + * @deprecated + * @description Used only for search on web that does not scale. Should be replaced by better version in the future. */ - get: operations["get_drive-v2-shares-{shareID}-external-invitations"]; + get: operations["get_drive-shares-{shareID}-map"]; put?: never; - /** - * Invite an external user to a share - * @description The current user must have admin permission on the share. - */ - post: operations["post_drive-v2-shares-{shareID}-external-invitations"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/external-invitations": { + "/drive/v2/shares/my-files": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List external invitations of a user - * @description List the UserRegistered external invitations where the current user is the invitee. - */ - get: operations["get_drive-v2-shares-external-invitations"]; + /** Bootstrap my files */ + get: operations["get_drive-v2-shares-my-files"]; put?: never; post?: never; delete?: never; @@ -2381,70 +2395,67 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/external-invitations/{invitationID}/sendemail": { + "/drive/v2/shares/photos": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Bootstrap photos section */ + get: operations["get_drive-v2-shares-photos"]; put?: never; - /** - * Send the external invitation email to the invitee - * @description The current user must have admin permission on the share. - */ - post: operations["post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/invitations/{invitationID}/accept": { + "/drive/shares/{shareID}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get share bootstrap */ + get: operations["get_drive-shares-{shareID}"]; put?: never; - /** Accept an invitation */ - post: operations["post_drive-v2-shares-invitations-{invitationID}-accept"]; - delete?: never; + post?: never; + /** + * Delete a standard share by ID + * @description Only standard shares (type 2) can be deleted this way. + * Will throw 422 with body code 2005 if Members, ShareURLs or Invitations are still attached to the share. + * Use Force=1 query param to delete the share together with any attached entities. + */ + delete: operations["delete_drive-shares-{shareID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/invitations/{invitationID}": { + "/drive/volumes/{volumeID}/links/{linkID}/context": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; /** - * Update an invitation - * @description Only permissions can be changed. They can be changed when the invitation is pending and when it has been rejected. - * The owner should not be aware of rejection. After the invitation has been accepted, membership permissions can be edited. - * The current user must have admin permission on the share. + * Get context share + * @description Gets the highest share, meaning closest to the root, for a link */ - put: operations["put_drive-v2-shares-{shareID}-invitations-{invitationID}"]; + get: operations["get_drive-volumes-{volumeID}-links-{linkID}-context"]; + put?: never; post?: never; - /** - * Delete an invitation - * @description The current user must have admin permission on the share. - */ - delete: operations["delete_drive-v2-shares-{shareID}-invitations-{invitationID}"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/invitations": { + "/drive/shares": { parameters: { query?: never; header?: never; @@ -2452,43 +2463,44 @@ export interface paths { cookie?: never; }; /** - * List invitations in a share - * @description The current user must have admin permission on the share. + * List shares + * @description List shares available to current user. + * + * The results can be restricted to a single address by providing the AddressID query parameter. + * By default, only active shares are shown. + * Passing the ShowAll=1 query parameter will show locked and disabled shares also. */ - get: operations["get_drive-v2-shares-{shareID}-invitations"]; + get: operations["get_drive-shares"]; put?: never; - /** - * Invite a Proton user to a share - * @description The current user must have admin permission on the share. - */ - post: operations["post_drive-v2-shares-{shareID}-invitations"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/invitations": { + "/drive/shares/{shareID}/owner": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List invitations of a user - * @description List the pending invitations where the current user is the invitee. + * Update ownership of a share + * @description Replace the signature and related membership of the share. + * This allows users to change the associated address & key they use for a share, so that they can get rid of it. */ - get: operations["get_drive-v2-shares-invitations"]; - put?: never; - post?: never; + post: operations["post_drive-shares-{shareID}-owner"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/invitations/{invitationID}/reject": { + "/drive/migrations/shareaccesswithnode": { parameters: { query?: never; header?: never; @@ -2497,55 +2509,56 @@ export interface paths { }; get?: never; put?: never; - /** Reject an invitation */ - post: operations["post_drive-v2-shares-invitations-{invitationID}-reject"]; + /** Migrate legacy Shares */ + post: operations["post_drive-migrations-shareaccesswithnode"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/invitations/{invitationID}/sendemail": { + "/drive/migrations/shareaccesswithnode/unmigrated": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send the invitation email to the invitee - * @description The current user must have admin permission on the share. + * List unmigrated shares + * @description List shares that have not been migrated to the new PassphraseNodeKeyPacket yet. + * Will throw a 422 with Code 2000 if the current user doesn't have any active Volume. */ - post: operations["post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail"]; + get: operations["get_drive-migrations-shareaccesswithnode-unmigrated"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/invitations/{invitationID}": { + "/drive/volumes/{volumeID}/shares": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Return invitation information - * @description Get the information about a pending invitation where the current user is the invitee. + * Create a standard share + * @description Cannot create two shares on the same link. Throws 422 with code 2500 in case a share already exists. */ - get: operations["get_drive-v2-shares-invitations-{invitationID}"]; - put?: never; - post?: never; + post: operations["post_drive-volumes-{volumeID}-shares"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/user-link-access": { + "/drive/v2/volumes/{volumeID}/shares": { parameters: { query?: never; header?: never; @@ -2553,10 +2566,10 @@ export interface paths { cookie?: never; }; /** - * List link accesses for a share url. - * @description RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0031-share-resolution-from-copied-url/ + * Shared by me + * @description List Collaborative Shares in the given volume that are not abandoned, i.e. they either have members, invitations or URLs attached. */ - get: operations["get_drive-v2-user-link-access"]; + get: operations["get_drive-v2-volumes-{volumeID}-shares"]; put?: never; post?: never; delete?: never; @@ -2565,7 +2578,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/members": { + "/drive/v2/sharedwithme": { parameters: { query?: never; header?: never; @@ -2573,10 +2586,10 @@ export interface paths { cookie?: never; }; /** - * List members in a share - * @description The current user must have admin permission on the share. + * Shared with me + * @description List Collaborative Shares the user has access to as a non-owner */ - get: operations["get_drive-v2-shares-{shareID}-members"]; + get: operations["get_drive-v2-sharedwithme"]; put?: never; post?: never; delete?: never; @@ -2585,7 +2598,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/members/{memberID}": { + "/drive/v2/shares/{shareID}/external-invitations/{invitationID}": { parameters: { query?: never; header?: never; @@ -2594,45 +2607,68 @@ export interface paths { }; get?: never; /** - * Update a member - * @description Only permissions can be changed. They can be changed when the member is active. + * Update an external invitation + * @description Only permissions can be changed. They can be changed when the external invitation is pending or accepted. + * After the external invitation has been accepted, the invitation's permissions can be edited. * The current user must have admin permission on the share. */ - put: operations["put_drive-v2-shares-{shareID}-members-{memberID}"]; + put: operations["put_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; post?: never; /** - * Remove a share member - * @description If the current user is an admin of the share they can remove other members. - * If the current user is not an admin they can only remove themselves. + * Delete an external invitation + * @description The current user must have admin permission on the share. */ - delete: operations["delete_drive-v2-shares-{shareID}-members-{memberID}"]; + delete: operations["delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/shares/{shareID}/security": { + "/drive/v2/shares/{shareID}/external-invitations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List external invitations in a share + * @description The current user must have admin permission on the share. + */ + get: operations["get_drive-v2-shares-{shareID}-external-invitations"]; put?: never; /** - * Scan for malware (direct sharing) - * @description Performs virus checks on hashes of files received in the request payload. - * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ + * Invite an external user to a share + * @description The current user must have admin permission on the share. */ - post: operations["post_drive-v2-shares-{shareID}-security"]; + post: operations["post_drive-v2-shares-{shareID}-external-invitations"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/urls/{token}/security": { + "/drive/v2/shares/external-invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List external invitations of a user + * @description List the UserRegistered external invitations where the current user is the invitee. + */ + get: operations["get_drive-v2-shares-external-invitations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/external-invitations/{invitationID}/sendemail": { parameters: { query?: never; header?: never; @@ -2642,18 +2678,17 @@ export interface paths { get?: never; put?: never; /** - * Scan for malware (public share URL) - * @description Performs virus checks on hashes of files received in the request payload. - * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ + * Send the external invitation email to the invitee + * @description The current user must have admin permission on the share. */ - post: operations["post_drive-urls-{token}-security"]; + post: operations["post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/thumbnails": { + "/drive/v2/shares/invitations/{invitationID}/accept": { parameters: { query?: never; header?: never; @@ -2662,36 +2697,41 @@ export interface paths { }; get?: never; put?: never; - /** Fetch thumbnails by IDs. */ - post: operations["post_drive-volumes-{volumeID}-thumbnails"]; + /** Accept an invitation */ + post: operations["post_drive-v2-shares-invitations-{invitationID}-accept"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/me/settings": { + "/drive/v2/shares/{shareID}/invitations/{invitationID}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get user settings */ - get: operations["get_drive-me-settings"]; + get?: never; /** - * Update user settings - * @description At least one setting must be provided. + * Update an invitation + * @description Only permissions can be changed. They can be changed when the invitation is pending and when it has been rejected. + * The owner should not be aware of rejection. After the invitation has been accepted, membership permissions can be edited. + * The current user must have admin permission on the share. */ - put: operations["put_drive-me-settings"]; + put: operations["put_drive-v2-shares-{shareID}-invitations-{invitationID}"]; post?: never; - delete?: never; + /** + * Delete an invitation + * @description The current user must have admin permission on the share. + */ + delete: operations["delete_drive-v2-shares-{shareID}-invitations-{invitationID}"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes": { + "/drive/v2/shares/{shareID}/invitations": { parameters: { query?: never; header?: never; @@ -2699,43 +2739,35 @@ export interface paths { cookie?: never; }; /** - * List volumes - * @description List all volumes owned by the current user - can be between zero and two: none, regular and/or photo. - * It can also return volumes in locked state, which are - upon creation of new volumes - re-activated with new root shares. - * The pagination params Page and PageSize are deprecated. + * List invitations in a share + * @description The current user must have admin permission on the share. */ - get: operations["get_drive-volumes"]; + get: operations["get_drive-v2-shares-{shareID}-invitations"]; put?: never; /** - * Create volume - * @description Creating a new volume also creates : - * + root folder for the new Volume - * + Main share for the new Volume - * + Adds ShareMember with given Address ID - * - * If the user already has a locked volume, then this locked volume is re-activated - * with a new root share and folder instead of creating a new volume. + * Invite a Proton user to a share + * @description The current user must have admin permission on the share. */ - post: operations["post_drive-volumes"]; + post: operations["post_drive-v2-shares-{shareID}-invitations"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/delete_locked": { + "/drive/v2/shares/invitations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; /** - * Delete the whole volume if is locked or the locked root shares in the volume. - * @description Web client calls this endpoint when the user decides to delete their locked data and not recover it. + * List invitations of a user + * @description List the pending invitations where the current user is the invitee. */ - put: operations["put_drive-volumes-{volumeID}-delete_locked"]; + get: operations["get_drive-v2-shares-invitations"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2743,18 +2775,55 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}": { + "/drive/v2/shares/invitations/{invitationID}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reject an invitation */ + post: operations["post_drive-v2-shares-invitations-{invitationID}-reject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/invitations/{invitationID}/sendemail": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Get volume - * @description Return the attributes of a specific volume. + * Send the invitation email to the invitee + * @description The current user must have admin permission on the share. */ - get: operations["get_drive-volumes-{volumeID}"]; + post: operations["post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations/{invitationID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return invitation information + * @description Get the information about a pending invitation where the current user is the invitee. + */ + get: operations["get_drive-v2-shares-invitations-{invitationID}"]; put?: never; post?: never; delete?: never; @@ -2763,20 +2832,19 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/volumes/{volumeID}/restore": { + "/drive/v2/user-link-access": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; /** - * Restore locked data in volume. - * @description The locked root shares in the volume can be recovered by providing the new encryption material for each share. - * This operation used to be heavy and processed async. But now it's quick and done synchronously. + * List link accesses for a share url. + * @description RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0031-share-resolution-from-copied-url/ */ - put: operations["put_drive-volumes-{volumeID}-restore"]; + get: operations["get_drive-v2-user-link-access"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2784,22 +2852,241 @@ export interface paths { patch?: never; trace?: never; }; -} -export type webhooks = Record; -export interface components { - schemas: { + "/drive/v2/shares/{shareID}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; /** - * ProtonResponseCode - * @enum {integer} + * List members in a share + * @description The current user must have admin permission on the share. */ - ResponseCodeSuccess: 1000; - ProtonSuccess: { - Code: components["schemas"]["ResponseCodeSuccess"]; - }; - ProtonError: { - /** ErrorCode */ - Code: number; - /** @description Error message */ + get: operations["get_drive-v2-shares-{shareID}-members"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/members/{memberID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a member + * @description Only permissions can be changed. They can be changed when the member is active. + * The current user must have admin permission on the share. + */ + put: operations["put_drive-v2-shares-{shareID}-members-{memberID}"]; + post?: never; + /** + * Remove a share member + * @description If the current user is an admin of the share they can remove other members. + * If the current user is not an admin they can only remove themselves. + */ + delete: operations["delete_drive-v2-shares-{shareID}-members-{memberID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/security": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Scan for malware (direct sharing) + * @description Performs virus checks on hashes of files received in the request payload. + * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ + */ + post: operations["post_drive-v2-shares-{shareID}-security"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/security": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Scan for malware (public share URL) + * @description Performs virus checks on hashes of files received in the request payload. + * See https://drive.gitlab-pages.protontech.ch/documentation/specifications/data/virus-scanning/ + */ + post: operations["post_drive-urls-{token}-security"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/thumbnails": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Fetch thumbnails by IDs. */ + post: operations["post_drive-volumes-{volumeID}-thumbnails"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/me/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get user settings */ + get: operations["get_drive-me-settings"]; + /** + * Update user settings + * @description At least one setting must be provided. + */ + put: operations["put_drive-me-settings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List volumes + * @description List all volumes owned by the current user - can be between zero and two: none, regular and/or photo. + * It can also return volumes in locked state, which are - upon creation of new volumes - re-activated with new root shares. + * The pagination params Page and PageSize are deprecated. + */ + get: operations["get_drive-volumes"]; + put?: never; + /** + * Create volume + * @description Creating a new volume also creates : + * + root folder for the new Volume + * + Main share for the new Volume + * + Adds ShareMember with given Address ID + * + * If the user already has a locked volume, then this locked volume is re-activated + * with a new root share and folder instead of creating a new volume. + */ + post: operations["post_drive-volumes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/delete_locked": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Delete the whole volume if is locked or the locked root shares in the volume. + * @description Web client calls this endpoint when the user decides to delete their locked data and not recover it. + */ + put: operations["put_drive-volumes-{volumeID}-delete_locked"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get volume + * @description Return the attributes of a specific volume. + */ + get: operations["get_drive-volumes-{volumeID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Restore locked data in volume. + * @description The locked root shares in the volume can be recovered by providing the new encryption material for each share. + * This operation used to be heavy and processed async. But now it's quick and done synchronously. + */ + put: operations["put_drive-volumes-{volumeID}-restore"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * ProtonResponseCode + * @enum {integer} + */ + ResponseCodeSuccess: 1000; + ProtonSuccess: { + Code: components["schemas"]["ResponseCodeSuccess"]; + }; + ProtonError: { + /** ErrorCode */ + Code: number; + /** @description Error message */ Error: string; /** @description Error description (can be an empty object) */ Details: Record; @@ -3474,7 +3761,7 @@ export interface components { */ ClientUID: string | null; /** - * @description Intended upload file size, to check if the user is trying to upload a bigger filesize than allowed. + * @description Intended upload file size, future BE size validation * @default null */ IntendedUploadSize: number | null; @@ -3510,7 +3797,7 @@ export interface components { */ ClientUID: string | null; /** - * @description Intended upload file size, to check if the user is trying to upload a bigger filesize than allowed. + * @description Intended upload file size, future BE size validation * @default null */ IntendedUploadSize: number | null; @@ -3784,6 +4071,15 @@ export interface components { */ Code: 1000; }; + LoadPhotoVolumeLinkDetailsResponseDto: { + Links: (components["schemas"]["PhotoDetailsDto"] | components["schemas"]["PhotoAlbumDetailsDto"] | components["schemas"]["PhotoRootFolderDetailsDto"])[]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; RemoveTagsRequestDto: { Tags: components["schemas"]["TagType"][]; }; @@ -3829,6 +4125,15 @@ export interface components { */ Code: 1000; }; + ParentEncryptedLinkIDsResponseDto: { + ParentLinkIDs: components["schemas"]["Id2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; InitSRPSessionResponseDto: { Modulus: string; ServerEphemeral: components["schemas"]["BinaryString2"]; @@ -3926,7 +4231,7 @@ export interface components { */ ClientUID: string | null; /** - * @description Intended upload file size, to check if the user is trying to upload a bigger filesize than allowed. + * @description Intended upload file size, future BE size validation * @default null */ IntendedUploadSize: number | null; @@ -3976,15 +4281,6 @@ export interface components { DeleteChildrenRequestDto: { Links: components["schemas"]["LinkWithAuthorizationTokenDto"][]; }; - ParentEncryptedLinkIDsResponseDto: { - ParentLinkIDs: string[]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; RenameAnonymousLinkRequestDto: { /** @description Name, reusing same session key as previously. */ Name: string; @@ -4165,15 +4461,29 @@ export interface components { /** @description List of ShareURL ids to delete. */ ShareURLIDs: components["schemas"]["EncryptedId"][]; }; - LinkMapQueryParameters: { - /** @default null */ - SessionName: string | null; - /** @default null */ - LastIndex: number | null; - /** @default 500 */ - PageSize: number; - }; - LinkMapResponse: { + ThumbnailIDsListInput: { + /** @description List of encrypted ThumbnailIDs. Maximum 30. */ + ThumbnailIDs: components["schemas"]["Id"][]; + }; + ListThumbnailsResponse: { + Thumbnails: components["schemas"]["ThumbnailResponse"][]; + Errors: components["schemas"]["ThumbnailErrorResponse"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + LinkMapQueryParameters: { + /** @default null */ + SessionName: string | null; + /** @default null */ + LastIndex: number | null; + /** @default 500 */ + PageSize: number; + }; + LinkMapResponse: { SessionName: string; More: number; Total: number; @@ -4518,20 +4828,6 @@ export interface components { */ Code: 1000; }; - ThumbnailIDsListInput: { - /** @description List of encrypted ThumbnailIDs. Maximum 30. */ - ThumbnailIDs: components["schemas"]["Id"][]; - }; - ListThumbnailsResponse: { - Thumbnails: components["schemas"]["ThumbnailResponse"][]; - Errors: components["schemas"]["ThumbnailErrorResponse"][]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; SettingsResponse: { UserSettings: components["schemas"]["UserSettings"]; Defaults: components["schemas"]["Defaults"]; @@ -5457,6 +5753,44 @@ export interface components { /** @default [] */ RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; }; + PhotoDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Photo: components["schemas"]["PhotoFileDto"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto"] | null; + /** + * @description Will be null if the user is not a member or is the owner. + * @default null + */ + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + Album: null | null; + }; + PhotoAlbumDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Album: components["schemas"]["AlbumDto"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto"] | null; + /** @default null */ + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + Photo: null | null; + }; + PhotoRootFolderDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Folder: components["schemas"]["FolderDto"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto"] | null; + /** + * @description Will be null if the user is not a member or is the owner. + * @default null + */ + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + Photo: null | null; + /** @default null */ + Album: null | null; + }; AuthShareDataResponseDto: { VolumeID: components["schemas"]["Id2"]; LinkID: components["schemas"]["Id2"]; @@ -5817,6 +6151,16 @@ export interface components { Tags?: number[]; } | null; } & components["schemas"]["LinkTransformer"]; + ThumbnailResponse: { + ThumbnailID: components["schemas"]["Id2"]; + BareURL: string; + Token: string; + }; + ThumbnailErrorResponse: { + ThumbnailID: components["schemas"]["Id2"]; + Error: string; + Code: number; + }; LinkMapItemResponse: { Index: number; LinkID: components["schemas"]["Id2"]; @@ -5874,10 +6218,10 @@ export interface components { */ ShareType: 1 | 2 | 3 | 4; /** - * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
5Migrated
6Locked
+ * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
6Locked
* @enum {integer} */ - ShareState: 1 | 2 | 3 | 5 | 6; + ShareState: 1 | 2 | 3 | 6; /** * @description

1=Regular, 2=Photo

See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
* @enum {integer} @@ -6137,16 +6481,6 @@ export interface components { */ Error: string; }; - ThumbnailResponse: { - ThumbnailID: components["schemas"]["Id2"]; - BareURL: string; - Token: string; - }; - ThumbnailErrorResponse: { - ThumbnailID: components["schemas"]["Id2"]; - Error: string; - Code: number; - }; UserSettings: { /** * @deprecated @@ -6542,10 +6876,10 @@ export interface components { DirectPermissions: number | null; }; FileDto: { + ActiveRevision?: components["schemas"]["ActiveRevisionDto"] | null; TotalEncryptedSize: number; ContentKeyPacket: components["schemas"]["BinaryString"]; MediaType?: string | null; - ActiveRevision?: components["schemas"]["ActiveRevisionDto"] | null; ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; }; SharingDto: { @@ -6615,6 +6949,20 @@ export interface components { /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash?: string | null; }; + PhotoFileDto: { + ActiveRevision?: components["schemas"]["ActivePhotoRevisionDto"] | null; + CaptureTime: number; + MainPhotoLinkID?: components["schemas"]["Id"] | null; + ContentHash?: string | null; + RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + Albums: components["schemas"]["PhotoAlbumDto"][]; + /** @description Will be empty if the user is not the owner. */ + Tags: components["schemas"]["TagType"][]; + TotalEncryptedSize: number; + ContentKeyPacket: components["schemas"]["BinaryString"]; + MediaType?: string | null; + ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + }; /** * @description

Types: Folder - 1, File - 2}

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
* @enum {integer} @@ -6839,21 +7187,32 @@ export interface components { */ LinkState3: 0 | 1 | 2; ActiveRevisionDto: { + /** @deprecated */ + Photo?: components["schemas"]["PhotoDto"] | null; RevisionID: components["schemas"]["Id"]; CreateTime: number; EncryptedSize: number; ManifestSignature?: components["schemas"]["PGPSignature"] | null; XAttr?: components["schemas"]["PGPMessage"] | null; Thumbnails: components["schemas"]["ThumbnailDto"][]; - Photo?: components["schemas"]["PhotoDto"] | null; /** Format: email */ SignatureEmail?: string | null; }; - ThumbnailDto: { - ThumbnailID: components["schemas"]["Id"]; - Type: components["schemas"]["ThumbnailType"]; - Hash: components["schemas"]["BinaryString"]; + ActivePhotoRevisionDto: { + RevisionID: components["schemas"]["Id"]; + CreateTime: number; EncryptedSize: number; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + Thumbnails: components["schemas"]["ThumbnailDto"][]; + /** Format: email */ + SignatureEmail?: string | null; + }; + PhotoAlbumDto: { + AlbumLinkID: components["schemas"]["Id"]; + Hash: string; + ContentHash: string; + AddedTime: number; }; PhotoDto: { CaptureTime: number; @@ -6861,6 +7220,12 @@ export interface components { ContentHash?: string | null; RelatedPhotosLinkIDs: components["schemas"]["Id"][]; }; + ThumbnailDto: { + ThumbnailID: components["schemas"]["Id"]; + Type: components["schemas"]["ThumbnailType"]; + Hash: components["schemas"]["BinaryString"]; + EncryptedSize: number; + }; }; responses: { /** @description Plain success response without additional information */ @@ -8376,7 +8741,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8404,7 +8769,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8572,7 +8937,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8721,7 +9086,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8816,7 +9181,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -8862,7 +9227,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; revisionID: components["schemas"]["Id"]; }; cookie?: never; @@ -8886,7 +9251,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; revisionID: components["schemas"]["Id"]; }; cookie?: never; @@ -8915,7 +9280,6 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200303: Cannot commit related photo with main already in album * */ Code: number; @@ -8979,7 +9343,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; revisionID: components["schemas"]["Id"]; }; cookie?: never; @@ -9003,7 +9367,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; revisionID: components["schemas"]["Id"]; }; cookie?: never; @@ -9032,7 +9396,6 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200303: Cannot commit related photo with main already in album * */ Code: number; @@ -9120,7 +9483,6 @@ export interface operations { * - 200301: max folder depth reached * - 2500: file or folder with same name already exists * - 2501: parent folder was not found - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200701: A document type cannot create a revision * */ Code: number; @@ -9166,7 +9528,6 @@ export interface operations { * - 200301: max folder depth reached * - 2500: file or folder with same name already exists * - 2501: parent folder was not found - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200701: A document type cannot create a revision * */ Code: number; @@ -9181,7 +9542,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9205,7 +9566,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9247,7 +9608,6 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200700: A document type cannot create a revision * */ Code: number; @@ -9262,7 +9622,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9286,7 +9646,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9328,7 +9688,6 @@ export interface operations { content: { "application/json": { /** @description Potential codes and their meaning: - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200700: A document type cannot create a revision * */ Code: number; @@ -9346,7 +9705,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; revisionID: components["schemas"]["Id"]; }; cookie?: never; @@ -9433,7 +9792,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; revisionID: components["schemas"]["Id"]; }; cookie?: never; @@ -9458,7 +9817,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; revisionID: components["schemas"]["Id"]; }; cookie?: never; @@ -9900,7 +10259,7 @@ export interface operations { /** @description Potential codes and their meaning: * - 2501: The parent link does not exist or is trashed * - 2011: The user does not have write permission on the link - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200003: Small file upload can only be used for revisions up to 128 KiB in size (encrypted content + thumbnails) * - 200002: Storage quota exceeded * - 2001: PGP data is not correct * - 200701: A document type cannot create a revision @@ -10021,7 +10380,7 @@ export interface operations { /** @description Potential codes and their meaning: * - 2501: The link does not exist or is trashed * - 2011: The user does not have write permission on the link - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. + * - 200003: Small file upload can only be used for revisions up to 128 KiB in size (encrypted content + thumbnails) * - 200002: Storage quota exceeded * - 2001: PGP data is not correct * - 200700: A document type cannot create a revision @@ -10511,6 +10870,33 @@ export interface operations { }; }; }; + "post_drive-photos-volumes-{volumeID}-links": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoadPhotoVolumeLinkDetailsResponseDto"]; + }; + }; + }; + }; "put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr": { parameters: { query?: never; @@ -10602,6 +10988,43 @@ export interface operations { 422: components["responses"]["ProtonErrorResponse"]; }; }; + "get_drive-urls-{token}-links-{linkID}-path": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ParentEncryptedLinkIDsResponseDto"]; + }; + }; + /** @description Unprocessable entity */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2061: Invalid ID. */ + Code: number; + }; + }; + }; + }; + }; "get_drive-urls-{token}-info": { parameters: { query?: never; @@ -10675,7 +11098,7 @@ export interface operations { header?: never; path: { token: string; - linkID: string; + linkID: components["schemas"]["Id"]; revisionID: components["schemas"]["Id"]; }; cookie?: never; @@ -10705,7 +11128,6 @@ export interface operations { "application/json": { /** @description Potential codes and their meaning: * - 2011: The current ShareURL does not have read+write permissions. - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200303: Cannot commit related photo with main already in album * */ Code: number; @@ -10857,7 +11279,6 @@ export interface operations { * - 2500: file or folder with same name already exists * - 2501: parent folder was not found * - 2011: The current ShareURL does not have read+write permissions - * - 200003: Max file size limited to 100 MB on your plan. Please upgrade. * - 200701: A document type cannot create a revision * */ Code: number; @@ -10997,7 +11418,7 @@ export interface operations { }; }; }; - "get_drive-urls-{token}-links-{linkID}-path": { + "put_drive-urls-{token}-links-{linkID}-rename": { parameters: { query?: never; header?: never; @@ -11007,73 +11428,479 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ParentEncryptedLinkIDsResponseDto"]; - }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameAnonymousLinkRequestDto"]; }; - /** @description Unprocessable entity */ - 400: { + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Conflict, a file or folder with the new name already exists in the current folder. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 2061: Invalid ID. */ - Code: number; - }; + "application/json": components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - "put_drive-urls-{token}-links-{linkID}-rename": { + "post_drive-urls-{token}-blocks": { parameters: { query?: never; header?: never; path: { token: string; - linkID: string; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["RenameAnonymousLinkRequestDto"]; + "application/json": components["schemas"]["RequestAnonymousUploadRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestUploadResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerificationData"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-urls-{token}": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapShareTokenResponseDto"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-urls-{token}-folders-{linkID}-children": { + parameters: { + query?: { + /** @description Field to sort by */ + Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; + /** @description Sort order */ + Desc?: 0 | 1; + /** @description Show all files including those in non-active (drafts) state. */ + ShowAll?: 0 | 1; + /** @description Show folders only */ + FoldersOnly?: 0 | 1; + /** + * @deprecated + * @description Get thumbnail download URLs + */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Links */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + }; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-urls-{token}-files-{linkID}": { + parameters: { + query?: { + /** @description Number of blocks */ + PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + /** @description Block index from which to fetch block list */ + FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; + /** @description Do not generate download URLs for blocks */ + NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; + }; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetRevisionResponseDto"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-urls-{token}-file": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["GetSharedFileInfoRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetSharedFileInfoResponseDto"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-volumes-{volumeID}-urls": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareURLContextsCollection"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-urls": { + parameters: { + query?: { + /** @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). */ + Recursive?: 0 | 1; + /** @description Fetch Thumbnail URLs */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListShareURLsResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-urls": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateShareURLRequestDto"]; + }; + }; + responses: { + /** @description Share URL created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + ShareURL: components["schemas"]["ShareURLResponseDto"]; + }; + }; + }; + }; + }; + "put_drive-shares-{shareID}-urls-{urlID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + urlID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateShareURLRequestDto"]; + }; + }; + responses: { + /** @description Share URL updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + ShareURL: components["schemas"]["ShareURLResponseDto"]; + }; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "delete_drive-shares-{shareID}-urls-{urlID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + urlID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-urls-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["DeleteMultipleShareURLsRequestDto"]; + }; + }; + responses: { + /** @description Responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: { + ShareURLID: string; + Response: { + /** @enum {integer} */ + Code: 1000 | 2501; + Error?: string; + }; + }[]; + }; + }; + }; + }; + }; + "post_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailableHashesResponseDto"]; + }; + }; + }; + }; + "get_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: { + /** @description Number of blocks */ + PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; + /** @description Block index from which to fetch block list */ + FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; + /** @description Do not generate download URLs for blocks */ + NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; + }; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; }; + cookie?: never; }; + requestBody?: never; responses: { - 200: components["responses"]["ProtonSuccessResponse"]; - /** @description Conflict, a file or folder with the new name already exists in the current folder. */ - 422: { + default: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["ConflictErrorResponseDto"]; - }; + content?: never; }; }; }; - "post_drive-urls-{token}-blocks": { + "put_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: never; header?: never; path: { - token: string; + volumeID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["RequestAnonymousUploadRequestDto"]; + "application/json": components["schemas"]["CommitRevisionDto"]; }; }; responses: { @@ -11084,31 +11911,17 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RequestUploadResponse"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 2011: The current ShareURL does not have read+write permissions - * */ - Code: number; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - "get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification": { + "delete_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { parameters: { query?: never; header?: never; path: { - token: string; + volumeID: string; linkID: string; revisionID: components["schemas"]["Id"]; }; @@ -11123,35 +11936,25 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VerificationData"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 2011: The current ShareURL does not have read+write permissions - * */ - Code: number; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - "get_drive-urls-{token}": { + "post_drive-unauth-v2-volumes-{volumeID}-documents": { parameters: { query?: never; header?: never; path: { - token: string; + volumeID: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateDocumentDto"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11160,73 +11963,52 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BootstrapShareTokenResponseDto"]; + "application/json": components["schemas"]["CreateDocumentResponseDto"]; }; }; - 422: components["responses"]["ProtonErrorResponse"]; }; }; - "get_drive-urls-{token}-folders-{linkID}-children": { + "post_drive-unauth-v2-volumes-{volumeID}-files": { parameters: { - query?: { - /** @description Field to sort by */ - Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; - /** @description Sort order */ - Desc?: 0 | 1; - /** @description Show all files including those in non-active (drafts) state. */ - ShowAll?: 0 | 1; - /** @description Show folders only */ - FoldersOnly?: 0 | 1; - /** - * @deprecated - * @description Get thumbnail download URLs - */ - Thumbnails?: 0 | 1; - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; - }; + query?: never; header?: never; path: { - token: string; - linkID: string; + volumeID: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFileDto"]; + }; + }; responses: { - /** @description Links */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Links: components["schemas"]["ExtendedLinkTransformer"][]; - }; + "application/json": components["schemas"]["CreateDraftFileResponseDto"]; }; }; - 422: components["responses"]["ProtonErrorResponse"]; }; }; - "get_drive-urls-{token}-files-{linkID}": { + "post_drive-unauth-v2-volumes-{volumeID}-folders": { parameters: { - query?: { - /** @description Number of blocks */ - PageSize?: components["schemas"]["GetRevisionQueryParameters"]["PageSize"]; - /** @description Block index from which to fetch block list */ - FromBlockIndex?: components["schemas"]["GetRevisionQueryParameters"]["FromBlockIndex"]; - /** @description Do not generate download URLs for blocks */ - NoBlockUrls?: components["schemas"]["GetRevisionQueryParameters"]["NoBlockUrls"]; - }; + query?: never; header?: never; path: { - token: string; - linkID: string; + volumeID: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFolderRequestDto2"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11235,54 +12017,72 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetRevisionResponseDto"]; + "application/json": components["schemas"]["CreateFolderResponseDto"]; }; }; - 422: components["responses"]["ProtonErrorResponse"]; }; }; - "post_drive-urls-{token}-file": { + "post_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions": { parameters: { query?: never; header?: never; path: { - /** @description ShareURL Token */ - token: string; + volumeID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["GetSharedFileInfoRequestDto"]; + "application/json": components["schemas"]["CreateRevisionRequestDto"]; }; }; responses: { - /** @description Success */ - 200: { + default: { headers: { - "x-pm-code": 1000; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["GetSharedFileInfoResponseDto"]; - }; + content?: never; }; - 422: components["responses"]["ProtonErrorResponse"]; }; }; - "get_drive-volumes-{volumeID}-urls": { + "post_drive-unauth-v2-volumes-{volumeID}-delete_multiple": { parameters: { - query?: { - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; }; + }; + }; + "post_drive-unauth-volumes-{volumeID}-thumbnails": { + parameters: { + query?: never; header?: never; path: { volumeID: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["ThumbnailIDsListInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11291,24 +12091,21 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ShareURLContextsCollection"]; + "application/json": components["schemas"]["ListThumbnailsResponse"]; }; }; }; }; - "get_drive-shares-{shareID}-urls": { + "get_drive-unauth-v2-volumes-{volumeID}-folders-{linkID}-children": { parameters: { query?: { - /** @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). */ - Recursive?: 0 | 1; - /** @description Fetch Thumbnail URLs */ - Thumbnails?: 0 | 1; - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + AnchorID?: components["schemas"]["Id"] | null; + FoldersOnly?: number; }; header?: never; path: { - shareID: string; + volumeID: string; + linkID: string; }; cookie?: never; }; @@ -11321,82 +12118,74 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListShareURLsResponseDto"]; + "application/json": components["schemas"]["ListChildrenResponseDto"]; }; }; }; }; - "post_drive-shares-{shareID}-urls": { + "post_drive-unauth-v2-volumes-{volumeID}-links": { parameters: { query?: never; header?: never; path: { - shareID: string; + volumeID: string; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["CreateShareURLRequestDto"]; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { - /** @description Share URL created */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - ShareURL: components["schemas"]["ShareURLResponseDto"]; - }; + "application/json": components["schemas"]["LoadLinkDetailsResponseDto"]; }; }; }; }; - "put_drive-shares-{shareID}-urls-{urlID}": { + "put_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-rename": { parameters: { query?: never; header?: never; path: { - shareID: string; - urlID: components["schemas"]["Id"]; + volumeID: string; + linkID: string; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["UpdateShareURLRequestDto"]; + "application/json": components["schemas"]["RenameLinkRequestDto"]; }; }; responses: { - /** @description Share URL updated */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - ShareURL: components["schemas"]["ShareURLResponseDto"]; - }; - }; + content?: never; }; - 422: components["responses"]["ProtonErrorResponse"]; }; }; - "delete_drive-shares-{shareID}-urls-{urlID}": { + "post_drive-unauth-blocks": { parameters: { query?: never; header?: never; - path: { - shareID: string; - urlID: components["schemas"]["Id"]; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["RequestUploadInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11405,44 +12194,32 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulResponse"]; + "application/json": components["schemas"]["RequestUploadResponse"]; }; }; }; }; - "post_drive-shares-{shareID}-urls-delete_multiple": { + "get_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification": { parameters: { query?: never; header?: never; path: { - shareID: string; + volumeID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["DeleteMultipleShareURLsRequestDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Responses */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: { - ShareURLID: string; - Response: { - /** @enum {integer} */ - Code: 1000 | 2501; - Error?: string; - }; - }[]; - }; + "application/json": components["schemas"]["VerificationData"]; }; }; }; diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index a6e13687..3e330a8e 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -114,18 +114,21 @@ type PostCheckAvailableHashesResponse = * The service is responsible for transforming local objects to API payloads * and vice versa. It should not contain any business logic. */ -export class NodeAPIService { +export abstract class NodeAPIServiceBase< + T extends EncryptedNode = EncryptedNode, + TMetadataResponseLink extends { Link: { LinkID: string } } = { Link: { LinkID: string } }, +> { constructor( - private logger: Logger, - private apiService: DriveAPIService, - private clientUid: string | undefined, + protected logger: Logger, + protected apiService: DriveAPIService, + protected clientUid: string | undefined, ) { this.logger = logger; this.apiService = apiService; this.clientUid = clientUid; } - async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise { + async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise { const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, undefined, signal); const result = await nodesGenerator.next(); if (!result.value) { @@ -140,7 +143,7 @@ export class NodeAPIService { ownVolumeId: string | undefined, filterOptions?: FilterOptions, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { const allNodeIds = nodeUids.map(splitNodeUid); const nodeIdsByVolumeId = new Map(); @@ -184,27 +187,21 @@ export class NodeAPIService { } } - private async *iterateNodesPerVolume( + protected async *iterateNodesPerVolume( volumeId: string, nodeIds: string[], isOwnVolumeId: boolean, filterOptions?: FilterOptions, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { const errors: unknown[] = []; for (const nodeIdsBatch of batch(nodeIds, API_NODES_BATCH_SIZE)) { - const response = await this.apiService.post( - `drive/v2/volumes/${volumeId}/links`, - { - LinkIDs: nodeIdsBatch, - }, - signal, - ); + const responseLinks = await this.fetchNodeMetadata(volumeId, nodeIdsBatch, signal); - for (const link of response.Links) { + for (const link of responseLinks) { try { - const encryptedNode = linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); + const encryptedNode = this.linkToEncryptedNode(volumeId, link, isOwnVolumeId); if (filterOptions?.type && encryptedNode.type !== filterOptions.type) { continue; } @@ -219,6 +216,14 @@ export class NodeAPIService { return errors; } + protected abstract fetchNodeMetadata( + volumeId: string, + linkIds: string[], + signal?: AbortSignal, + ): Promise; + + protected abstract linkToEncryptedNode(volumeId: string, link: TMetadataResponseLink, isOwnVolumeId: boolean): T; + // Improvement requested: load next page sooner before all IDs are yielded. async *iterateChildrenNodeUids( parentNodeUid: string, @@ -580,6 +585,35 @@ export class NodeAPIService { } } +export class NodeAPIService extends NodeAPIServiceBase { + constructor(logger: Logger, apiService: DriveAPIService, clientUid: string | undefined) { + super(logger, apiService, clientUid); + } + + protected async fetchNodeMetadata( + volumeId: string, + linkIds: string[], + signal?: AbortSignal, + ): Promise { + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/links`, + { + LinkIDs: linkIds, + }, + signal, + ); + return response.Links; + } + + protected linkToEncryptedNode( + volumeId: string, + link: PostLoadLinksMetadataResponse['Links'][0], + isOwnVolumeId: boolean, + ): EncryptedNode { + return linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); + } +} + type LinkResponse = { LinkID: string; Response: { @@ -630,57 +664,18 @@ function handleNodeWithSameNameExistsValidationError(volumeId: string, error: un } } -function linkToEncryptedNode( +export function linkToEncryptedNode( logger: Logger, volumeId: string, - link: PostLoadLinksMetadataResponse['Links'][0], + link: Pick, isAdmin: boolean, ): EncryptedNode { - const membershipRole = permissionsToMemberRole(logger, link.Membership?.Permissions); - - const baseNodeMetadata = { - // Internal metadata - hash: link.Link.NameHash || undefined, - encryptedName: link.Link.Name, - - // Basic node metadata - uid: makeNodeUid(volumeId, link.Link.LinkID), - parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, - type: nodeTypeNumberToNodeType(logger, link.Link.Type), - creationTime: new Date(link.Link.CreateTime * 1000), - modificationTime: new Date(link.Link.ModifyTime * 1000), - trashTime: link.Link.TrashTime ? new Date(link.Link.TrashTime * 1000) : undefined, - - // Sharing node metadata - shareId: link.Sharing?.ShareID || undefined, - isShared: !!link.Sharing, - isSharedPublicly: !!link.Sharing?.ShareURLID, - directRole: isAdmin ? MemberRole.Admin : membershipRole, - membership: link.Membership - ? { - role: membershipRole, - inviteTime: new Date(link.Membership.InviteTime * 1000), - } - : undefined, - }; - - const baseCryptoNodeMetadata = { - signatureEmail: link.Link.SignatureEmail || undefined, - nameSignatureEmail: link.Link.NameSignatureEmail || undefined, - armoredKey: link.Link.NodeKey, - armoredNodePassphrase: link.Link.NodePassphrase, - armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, - membership: link.Membership - ? { - inviterEmail: link.Membership.InviterEmail, - base64MemberSharePassphraseKeyPacket: link.Membership.MemberSharePassphraseKeyPacket, - armoredInviterSharePassphraseKeyPacketSignature: - link.Membership.InviterSharePassphraseKeyPacketSignature, - armoredInviteeSharePassphraseSessionKeySignature: - link.Membership.InviteeSharePassphraseSessionKeySignature, - } - : undefined, - }; + const { baseNodeMetadata, baseCryptoNodeMetadata } = linkToEncryptedNodeBaseMetadata( + logger, + volumeId, + link, + isAdmin, + ); if (link.Link.Type === 1 && link.Folder) { return { @@ -722,6 +717,11 @@ function linkToEncryptedNode( }; } + // TODO: Remove this once client do not use main SDK for photos. + // At the beginning, the client used main SDK for some photo actions. + // This was a temporary solution before the Photos SDK was implemented. + // Now the client must use Photos SDK for all photo-related actions. + // Knowledge of albums in main SDK is deprecated and will be removed. if (link.Link.Type === 3) { return { ...baseNodeMetadata, @@ -734,6 +734,64 @@ function linkToEncryptedNode( throw new Error(`Unknown node type: ${link.Link.Type}`); } +export function linkToEncryptedNodeBaseMetadata( + logger: Logger, + volumeId: string, + link: Pick, + isAdmin: boolean, +) { + const membershipRole = permissionsToMemberRole(logger, link.Membership?.Permissions); + + const baseNodeMetadata = { + // Internal metadata + hash: link.Link.NameHash || undefined, + encryptedName: link.Link.Name, + + // Basic node metadata + uid: makeNodeUid(volumeId, link.Link.LinkID), + parentUid: link.Link.ParentLinkID ? makeNodeUid(volumeId, link.Link.ParentLinkID) : undefined, + type: nodeTypeNumberToNodeType(logger, link.Link.Type), + creationTime: new Date(link.Link.CreateTime * 1000), + modificationTime: new Date(link.Link.ModifyTime * 1000), + trashTime: link.Link.TrashTime ? new Date(link.Link.TrashTime * 1000) : undefined, + + // Sharing node metadata + shareId: link.Sharing?.ShareID || undefined, + isShared: !!link.Sharing, + isSharedPublicly: !!link.Sharing?.ShareURLID, + directRole: isAdmin ? MemberRole.Admin : membershipRole, + membership: link.Membership + ? { + role: membershipRole, + inviteTime: new Date(link.Membership.InviteTime * 1000), + } + : undefined, + }; + + const baseCryptoNodeMetadata = { + signatureEmail: link.Link.SignatureEmail || undefined, + nameSignatureEmail: link.Link.NameSignatureEmail || undefined, + armoredKey: link.Link.NodeKey, + armoredNodePassphrase: link.Link.NodePassphrase, + armoredNodePassphraseSignature: link.Link.NodePassphraseSignature, + membership: link.Membership + ? { + inviterEmail: link.Membership.InviterEmail, + base64MemberSharePassphraseKeyPacket: link.Membership.MemberSharePassphraseKeyPacket, + armoredInviterSharePassphraseKeyPacketSignature: + link.Membership.InviterSharePassphraseKeyPacketSignature, + armoredInviteeSharePassphraseSessionKeySignature: + link.Membership.InviteeSharePassphraseSessionKeySignature, + } + : undefined, + }; + + return { + baseNodeMetadata, + baseCryptoNodeMetadata, + }; +} + export function* groupNodeUidsByVolumeAndIteratePerBatch( nodeUids: string[], ): Generator<{ volumeId: string; batchNodeIds: string[]; batchNodeUids: string[] }> { @@ -761,7 +819,6 @@ export function* groupNodeUidsByVolumeAndIteratePerBatch( } } - function transformRevisionResponse( volumeId: string, nodeId: string, diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 1006469e..2643b7cf 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -9,7 +9,9 @@ export enum CACHE_TAG_KEYS { Roots = 'nodeRoot', } -type DecryptedNodeResult = { uid: string; ok: true; node: DecryptedNode } | { uid: string; ok: false; error: string }; +type DecryptedNodeResult = + | { uid: string; ok: true; node: T } + | { uid: string; ok: false; error: string }; /** * Provides caching for nodes metadata. @@ -19,7 +21,7 @@ type DecryptedNodeResult = { uid: string; ok: true; node: DecryptedNode } | { ui * * The cache of node metadata should not contain any crypto material. */ -export class NodesCache { +export abstract class NodesCacheBase { constructor( private logger: Logger, private driveCache: ProtonDriveEntitiesCache, @@ -28,9 +30,9 @@ export class NodesCache { this.driveCache = driveCache; } - async setNode(node: DecryptedNode): Promise { + async setNode(node: T): Promise { const key = getCacheUid(node.uid); - const nodeData = serialiseNode(node); + const nodeData = this.serialiseNode(node); const { volumeId } = splitNodeUid(node.uid); const tags = [`volume:${volumeId}`]; @@ -46,11 +48,11 @@ export class NodesCache { await this.driveCache.setEntity(key, nodeData, tags); } - async getNode(nodeUid: string): Promise { + async getNode(nodeUid: string): Promise { const key = getCacheUid(nodeUid); const nodeData = await this.driveCache.getEntity(key); try { - return deserialiseNode(nodeData); + return this.deserialiseNode(nodeData); } catch (error: unknown) { await this.removeCorruptedNode({ nodeUid }, error); throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`, { @@ -59,6 +61,10 @@ export class NodesCache { } } + protected abstract serialiseNode(node: T): string; + + protected abstract deserialiseNode(nodeData: string): T; + /** * Set all nodes on given node as stale. This is useful when we * get refresh event from the server and we thus don't know @@ -146,7 +152,7 @@ export class NodesCache { return cacheUids; } - async *iterateNodes(nodeUids: string[]): AsyncGenerator { + async *iterateNodes(nodeUids: string[]): AsyncGenerator> { const cacheUids = nodeUids.map(getCacheUid); for await (const result of this.driveCache.iterateEntities(cacheUids)) { const node = await this.convertCacheResult(result); @@ -156,7 +162,7 @@ export class NodesCache { } } - async *iterateChildren(parentNodeUid: string): AsyncGenerator { + async *iterateChildren(parentNodeUid: string): AsyncGenerator> { for await (const result of this.driveCache.iterateEntitiesByTag( `${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`, )) { @@ -171,7 +177,7 @@ export class NodesCache { yield* this.driveCache.iterateEntitiesByTag(`${CACHE_TAG_KEYS.Roots}:${volumeId}`); } - async *iterateTrashedNodes(): AsyncGenerator { + async *iterateTrashedNodes(): AsyncGenerator> { for await (const result of this.driveCache.iterateEntitiesByTag(CACHE_TAG_KEYS.Trashed)) { const node = await this.convertCacheResult(result); if (node) { @@ -184,7 +190,7 @@ export class NodesCache { * Converts result from the cache with cache UID and data to result of node * with node UID and DecryptedNode. */ - private async convertCacheResult(result: EntityResult): Promise { + private async convertCacheResult(result: EntityResult): Promise | null> { let nodeUid; try { nodeUid = getNodeUid(result.key); @@ -195,7 +201,7 @@ export class NodesCache { if (result.ok) { let node; try { - node = deserialiseNode(result.value); + node = this.deserialiseNode(result.value); } catch (error: unknown) { await this.removeCorruptedNode({ nodeUid }, error); return null; @@ -232,6 +238,16 @@ export class NodesCache { } } +export class NodesCache extends NodesCacheBase { + protected serialiseNode(node: DecryptedNode): string { + return serialiseNode(node); + } + + protected deserialiseNode(nodeData: string): DecryptedNode { + return deserialiseNode(nodeData); + } +} + function getCacheUid(nodeUid: string) { return `node-${nodeUid}`; } @@ -243,11 +259,12 @@ function getNodeUid(cacheUid: string) { return cacheUid.substring(5); } -function serialiseNode(node: DecryptedNode) { +export function serialiseNode(node: DecryptedNode) { return JSON.stringify(node); } -function deserialiseNode(nodeData: string): DecryptedNode { +// TODO: use better deserialisation with validation +export function deserialiseNode(nodeData: string): DecryptedNode { const node = JSON.parse(nodeData); if ( !node || diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index 6b17ed30..ef36d7dc 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -1,6 +1,6 @@ import { Logger } from '../../interface'; import { DriveEvent, DriveEventType } from '../events'; -import { NodesCache } from './cache'; +import { NodesCacheBase } from './cache'; /** * Provides internal event handling. @@ -11,7 +11,7 @@ import { NodesCache } from './cache'; export class NodesEventsHandler { constructor( private logger: Logger, - private cache: NodesCache, + private cache: NodesCacheBase, ) {} async updateNodesCacheOnEvent(event: DriveEvent): Promise { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 7f96941d..e76ea36e 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -16,8 +16,8 @@ import { asyncIteratorMap } from '../asyncIteratorMap'; import { getErrorMessage } from '../errors'; import { BatchLoading } from '../batchLoading'; import { makeNodeUid, splitNodeUid } from '../uids'; -import { NodeAPIService } from './apiService'; -import { NodesCache } from './cache'; +import { NodeAPIServiceBase } from './apiService'; +import { NodesCacheBase } from './cache'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; import { NodesDebouncer } from './debouncer'; @@ -50,17 +50,21 @@ const DECRYPTION_CONCURRENCY = 30; * The node access module is responsible for fetching, decrypting and caching * nodes metadata. */ -export class NodesAccess { - private logger: Logger; - private debouncer: NodesDebouncer; +export abstract class NodesAccessBase< + TEncryptedNode extends EncryptedNode = EncryptedNode, + TDecryptedNode extends DecryptedNode = DecryptedNode, + TCryptoService extends NodesCryptoService = NodesCryptoService, +> { + protected logger: Logger; + protected debouncer: NodesDebouncer; constructor( - private telemetry: ProtonDriveTelemetry, - private apiService: NodeAPIService, - private cache: NodesCache, - private cryptoCache: NodesCryptoCache, - private cryptoService: NodesCryptoService, - private shareService: Pick< + protected telemetry: ProtonDriveTelemetry, + protected apiService: NodeAPIServiceBase, + protected cache: NodesCacheBase, + protected cryptoCache: NodesCryptoCache, + protected cryptoService: TCryptoService, + protected shareService: Pick< SharesService, 'getRootIDs' | 'getSharePrivateKey' | 'getContextShareMemberEmailKey' >, @@ -80,7 +84,7 @@ export class NodesAccess { return this.getNode(nodeUid); } - async getNode(nodeUid: string): Promise { + async getNode(nodeUid: string): Promise { let cachedNode; try { await this.debouncer.waitForLoadingNode(nodeUid); @@ -101,11 +105,11 @@ export class NodesAccess { parentNodeUid: string, filterOptions?: FilterOptions, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { // Ensure the parent is loaded and up-to-date. const parentNode = await this.getNode(parentNodeUid); - const batchLoading = new BatchLoading({ + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, filterOptions, signal), batchSize: BATCH_LOADING_SIZE, }); @@ -154,9 +158,9 @@ export class NodesAccess { } // Improvement requested: keep status of loaded trash and leverage cache. - async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.shareService.getRootIDs(); - const batchLoading = new BatchLoading({ + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodes(nodeUids, undefined, signal), batchSize: BATCH_LOADING_SIZE, }); @@ -177,8 +181,8 @@ export class NodesAccess { yield* batchLoading.loadRest(); } - async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { - const batchLoading = new BatchLoading({ + async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, undefined, signal), batchSize: BATCH_LOADING_SIZE, }); @@ -228,7 +232,7 @@ export class NodesAccess { await this.cache.removeNodes([nodeUid]); } - private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { + private async loadNode(nodeUid: string): Promise<{ node: TDecryptedNode; keys?: DecryptedNodeKeys }> { this.debouncer.loadingNode(nodeUid); try { const { volumeId: ownVolumeId } = await this.shareService.getRootIDs(); @@ -243,7 +247,7 @@ export class NodesAccess { nodeUids: string[], filterOptions?: FilterOptions, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { for await (const result of this.loadNodesWithMissingReport(nodeUids, filterOptions, signal)) { if ('missingUid' in result) { continue; @@ -256,7 +260,7 @@ export class NodesAccess { nodeUids: string[], filterOptions?: FilterOptions, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { const returnedNodeUids: string[] = []; const errors = []; @@ -264,13 +268,13 @@ export class NodesAccess { const apiNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal); - const debouncedNodeMapper = async (encryptedNode: EncryptedNode): Promise => { + const debouncedNodeMapper = async (encryptedNode: TEncryptedNode): Promise => { this.debouncer.loadingNode(encryptedNode.uid); return encryptedNode; }; const encryptedNodesIterator = asyncIteratorMap(apiNodesIterator, debouncedNodeMapper, 1); - const decryptNodeMapper = async (encryptedNode: EncryptedNode): Promise> => { + const decryptNodeMapper = async (encryptedNode: TEncryptedNode): Promise> => { returnedNodeUids.push(encryptedNode.uid); try { const { node } = await this.decryptNode(encryptedNode); @@ -310,8 +314,8 @@ export class NodesAccess { } private async decryptNode( - encryptedNode: EncryptedNode, - ): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> { + encryptedNode: TEncryptedNode, + ): Promise<{ node: TDecryptedNode; keys?: DecryptedNodeKeys }> { let parentKey; try { const parentKeys = await this.getParentKeys(encryptedNode); @@ -319,38 +323,14 @@ export class NodesAccess { } catch (error: unknown) { if (error instanceof DecryptionError) { return { - node: { - ...encryptedNode, - isStale: false, - name: resultError(error), - keyAuthor: resultError({ - claimedAuthor: encryptedNode.encryptedCrypto.signatureEmail, - error: getErrorMessage(error), - }), - nameAuthor: resultError({ - claimedAuthor: encryptedNode.encryptedCrypto.nameSignatureEmail, - error: getErrorMessage(error), - }), - membership: encryptedNode.membership - ? { - role: encryptedNode.membership.role, - inviteTime: encryptedNode.membership.inviteTime, - sharedBy: resultError({ - claimedAuthor: encryptedNode.encryptedCrypto.membership?.inviterEmail, - error: getErrorMessage(error), - }), - } - : undefined, - errors: [error], - treeEventScopeId: splitNodeUid(encryptedNode.uid).volumeId, - }, + node: this.getDegradedUndecryptableNode(encryptedNode, error), }; } throw error; } const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); - const node = await parseNode(this.logger, unparsedNode); + const node = this.parseNode(unparsedNode); try { await this.cache.setNode(node); } catch (error: unknown) { @@ -367,8 +347,45 @@ export class NodesAccess { return { node, keys }; } + protected abstract getDegradedUndecryptableNode( + encryptedNode: TEncryptedNode, + error: DecryptionError, + ): TDecryptedNode; + + protected getDegradedUndecryptableNodeBase(encryptedNode: EncryptedNode, error: DecryptionError): DecryptedNode { + return { + ...encryptedNode, + isStale: false, + name: resultError(error), + keyAuthor: resultError({ + claimedAuthor: encryptedNode.encryptedCrypto.signatureEmail, + error: getErrorMessage(error), + }), + nameAuthor: resultError({ + claimedAuthor: encryptedNode.encryptedCrypto.nameSignatureEmail, + error: getErrorMessage(error), + }), + membership: encryptedNode.membership + ? { + role: encryptedNode.membership.role, + inviteTime: encryptedNode.membership.inviteTime, + sharedBy: resultError({ + claimedAuthor: encryptedNode.encryptedCrypto.membership?.inviterEmail, + error: getErrorMessage(error), + }), + } + : undefined, + errors: [error], + treeEventScopeId: splitNodeUid(encryptedNode.uid).volumeId, + }; + } + + protected abstract parseNode( + unparsedNode: Awaited>['node'], + ): TDecryptedNode; + async getParentKeys( - node: Pick, + node: Pick, ): Promise> { if (node.parentUid) { try { @@ -473,13 +490,23 @@ export class NodesAccess { return `https://drive.proton.me/${rootNode.shareId}/${type}/${nodeId}`; } - private async getRootNode(nodeUid: string): Promise { + private async getRootNode(nodeUid: string): Promise { const node = await this.getNode(nodeUid); return node.parentUid ? this.getRootNode(node.parentUid) : node; } } -export async function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedNode): Promise { +export class NodesAccess extends NodesAccessBase { + protected getDegradedUndecryptableNode(encryptedNode: EncryptedNode, error: DecryptionError): DecryptedNode { + return this.getDegradedUndecryptableNodeBase(encryptedNode, error); + } + + protected parseNode(unparsedNode: DecryptedUnparsedNode): DecryptedNode { + return parseNode(this.logger, unparsedNode); + } +} + +export function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedNode): DecryptedNode { let nodeName: Result = unparsedNode.name; if (unparsedNode.name.ok) { try { @@ -526,6 +553,7 @@ export async function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedN const extendedAttributes = unparsedNode.folder?.extendedAttributes ? parseFolderExtendedAttributes(logger, unparsedNode.folder.extendedAttributes) : undefined; + return { ...unparsedNode, name: nodeName, diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index a23b5b09..0a2da775 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -4,14 +4,14 @@ import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk, Inval import { AbortError, ValidationError } from '../../errors'; import { createErrorFromUnknown, getErrorMessage } from '../errors'; import { splitNodeUid } from '../uids'; -import { NodeAPIService } from './apiService'; +import { NodeAPIServiceBase } from './apiService'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; import { NodeOutOfSyncError } from './errors'; import { generateFolderExtendedAttributes } from './extendedAttributes'; -import { DecryptedNode } from './interface'; +import { DecryptedNode, EncryptedNode } from './interface'; import { splitExtension, joinNameAndExtension } from './nodeName'; -import { NodesAccess } from './nodesAccess'; +import { NodesAccessBase } from './nodesAccess'; import { validateNodeName } from './validations'; const AVAILABLE_NAME_BATCH_SIZE = 10; @@ -26,12 +26,16 @@ const AVAILABLE_NAME_LIMIT = 1000; * This module uses other modules providing low-level operations, such * as API service, cache, crypto service, etc. */ -export class NodesManagement { +export abstract class NodesManagementBase< + TEncryptedNode extends EncryptedNode = EncryptedNode, + TDecryptedNode extends DecryptedNode = DecryptedNode, + TNodesCryptoService extends NodesCryptoService = NodesCryptoService, +> { constructor( - protected apiService: NodeAPIService, + protected apiService: NodeAPIServiceBase, protected cryptoCache: NodesCryptoCache, protected cryptoService: NodesCryptoService, - protected nodesAccess: NodesAccess, + protected nodesAccess: NodesAccessBase, ) { this.apiService = apiService; this.cryptoCache = cryptoCache; @@ -43,7 +47,7 @@ export class NodesManagement { nodeUid: string, newName: string, options = { allowRenameRootNode: false }, - ): Promise { + ): Promise { validateNodeName(newName); const node = await this.nodesAccess.getNode(nodeUid); @@ -91,7 +95,7 @@ export class NodesManagement { } await this.nodesAccess.notifyNodeChanged(nodeUid); - const newNode: DecryptedNode = { + const newNode: TDecryptedNode = { ...node, name: resultOk(newName), encryptedName: armoredNodeName, @@ -123,7 +127,7 @@ export class NodesManagement { } } - async moveNode(nodeUid: string, newParentUid: string): Promise { + async moveNode(nodeUid: string, newParentUid: string): Promise { const node = await this.nodesAccess.getNode(nodeUid); const [keys, newParentKeys, signingKeys] = await Promise.all([ @@ -172,7 +176,7 @@ export class NodesManagement { // TODO: When moving photos, we need to pass content hash. }, ); - const newNode: DecryptedNode = { + const newNode: TDecryptedNode = { ...node, encryptedName: encryptedCrypto.encryptedName, parentUid: newParentUid, @@ -213,7 +217,7 @@ export class NodesManagement { } } - async copyNode(nodeUid: string, newParentUid: string, name?: string): Promise { + async copyNode(nodeUid: string, newParentUid: string, name?: string): Promise { if (name) { validateNodeName(name); } @@ -257,7 +261,7 @@ export class NodesManagement { nameSignatureEmail: encryptedCrypto.nameSignatureEmail, hash: encryptedCrypto.hash, }); - const newNode: DecryptedNode = { + const newNode: TDecryptedNode = { ...node, name: nodeName, uid: newNodeUid, @@ -299,7 +303,7 @@ export class NodesManagement { } // FIXME create test for create folder - async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { + async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { validateNodeName(folderName); const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); @@ -328,8 +332,33 @@ export class NodesManagement { }); await this.nodesAccess.notifyChildCreated(parentNodeUid); + const node = this.generateNodeFolder(nodeUid, parentNodeUid, folderName, encryptedCrypto); + await this.cryptoCache.setNodeKeys(nodeUid, keys); + return node; + } - const node: DecryptedNode = { + protected abstract generateNodeFolder( + nodeUid: string, + parentUid: string, + name: string, + encryptedCrypto: { + hash: string; + encryptedName: string; + signatureEmail: string | null; + }, + ): TDecryptedNode; + + protected generateNodeFolderBase( + nodeUid: string, + parentNodeUid: string, + name: string, + encryptedCrypto: { + hash: string; + encryptedName: string; + signatureEmail: string | null; + }, + ): DecryptedNode { + return { // Internal metadata hash: encryptedCrypto.hash, encryptedName: encryptedCrypto.encryptedName, @@ -351,12 +380,9 @@ export class NodesManagement { isStale: false, keyAuthor: resultOk(encryptedCrypto.signatureEmail || null), nameAuthor: resultOk(encryptedCrypto.signatureEmail || null), - name: resultOk(folderName), + name: resultOk(name), treeEventScopeId: splitNodeUid(nodeUid).volumeId, }; - - await this.cryptoCache.setNodeKeys(nodeUid, keys); - return node; } async findAvailableName(parentFolderUid: string, name: string): Promise { @@ -397,3 +423,18 @@ export class NodesManagement { throw new ValidationError(c('Error').t`No available name found`); } } + +export class NodesManagement extends NodesManagementBase { + protected generateNodeFolder( + nodeUid: string, + parentNodeUid: string, + name: string, + encryptedCrypto: { + hash: string; + encryptedName: string; + signatureEmail: string | null; + }, + ): DecryptedNode { + return this.generateNodeFolderBase(nodeUid, parentNodeUid, name, encryptedCrypto); + } +} diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index 431b803e..6a2b840f 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -1,6 +1,6 @@ import { Logger } from '../../interface'; import { makeNodeUidFromRevisionUid } from '../uids'; -import { NodeAPIService } from './apiService'; +import { NodeAPIServiceBase } from './apiService'; import { NodesCryptoService } from './cryptoService'; import { NodesAccess } from './nodesAccess'; import { parseFileExtendedAttributes } from './extendedAttributes'; @@ -12,9 +12,9 @@ import { DecryptedRevision } from './interface'; export class NodesRevisons { constructor( private logger: Logger, - private apiService: NodeAPIService, + private apiService: NodeAPIServiceBase, private cryptoService: NodesCryptoService, - private nodesAccess: NodesAccess, + private nodesAccess: Pick, ) { this.logger = logger; this.apiService = apiService; diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index 617fb361..51a72b24 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -1,7 +1,7 @@ import { BatchLoading } from '../batchLoading'; import { DecryptedNode } from '../nodes'; import { PhotosAPIService } from './apiService'; -import { NodesService } from './interface'; +import { PhotosNodesAccess } from './nodes'; import { PhotoSharesManager } from './shares'; const BATCH_LOADING_SIZE = 10; @@ -13,7 +13,7 @@ export class Albums { constructor( private apiService: PhotosAPIService, private photoShares: PhotoSharesManager, - private nodesService: NodesService, + private nodesService: PhotosNodesAccess, ) { this.apiService = apiService; this.photoShares = photoShares; diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index ae1d3afc..4690de8a 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -6,6 +6,10 @@ import { ProtonDriveEntitiesCache, ProtonDriveTelemetry, } from '../../interface'; +import { NodesCryptoService } from '../nodes/cryptoService'; +import { NodesCryptoReporter } from '../nodes/cryptoReporter'; +import { NodesCryptoCache } from '../nodes/cryptoCache'; +import { ShareTargetType } from '../shares'; import { SharesCache } from '../shares/cache'; import { SharesCryptoCache } from '../shares/cryptoCache'; import { SharesCryptoService } from '../shares/cryptoService'; @@ -14,7 +18,8 @@ import { UploadTelemetry } from '../upload/telemetry'; import { UploadQueue } from '../upload/queue'; import { Albums } from './albums'; import { PhotosAPIService } from './apiService'; -import { NodesService, SharesService } from './interface'; +import { SharesService } from './interface'; +import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes'; import { PhotoSharesManager } from './shares'; import { PhotosTimeline } from './timeline'; import { @@ -24,7 +29,10 @@ import { PhotoUploadManager, PhotoUploadMetadata, } from './upload'; -import { ShareTargetType } from '../shares'; +import { NodesRevisons } from '../nodes/nodesRevisions'; +import { NodesEventsHandler } from '../nodes/events'; + +export type { DecryptedPhotoNode } from './interface'; // Only photos and albums can be shared in photos volume. export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album]; @@ -40,7 +48,7 @@ export function initPhotosModule( apiService: DriveAPIService, driveCrypto: DriveCrypto, photoShares: PhotoSharesManager, - nodesService: NodesService, + nodesService: PhotosNodesAccess, ) { const api = new PhotosAPIService(apiService); const timeline = new PhotosTimeline( @@ -89,6 +97,40 @@ export function initPhotoSharesModule( ); } +/** + * Provides facade for the photo nodes module. + * + * The photo nodes module wraps the core nodes module and adds photo specific + * metadata. It provides the same interface so it can be used in the same way. + */ +export function initPhotosNodesModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, + account: ProtonDriveAccount, + driveCrypto: DriveCrypto, + sharesService: PhotoSharesManager, + clientUid: string | undefined, +) { + const api = new PhotosNodesAPIService(telemetry.getLogger('nodes-api'), apiService, clientUid); + const cache = new PhotosNodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); + const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); + const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService); + const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); + const nodesAccess = new PhotosNodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService); + const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache); + const nodesManagement = new PhotosNodesManagement(api, cryptoCache, cryptoService, nodesAccess); + const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); + + return { + access: nodesAccess, + management: nodesManagement, + revisions: nodesRevisions, + eventHandler: nodesEventHandler, + }; +} + /** * Provides facade for the photo upload module. * diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 54754530..616be0a9 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -1,6 +1,6 @@ import { PrivateKey } from '../../crypto'; -import { MissingNode, MetricVolumeType } from '../../interface'; -import { DecryptedNode } from '../nodes'; +import { MetricVolumeType, PhotoAttributes } from '../../interface'; +import { DecryptedNode, EncryptedNode, DecryptedUnparsedNode } from '../nodes/interface'; import { EncryptedShare } from '../shares'; export interface SharesService { @@ -23,10 +23,22 @@ export interface SharesService { getVolumeMetricContext(volumeId: string): Promise; } -export interface NodesService { - getNode(nodeUid: string): Promise; - iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; - getNodeKeys(nodeUid: string): Promise<{ - hashKey?: Uint8Array; - }>; -} +export type EncryptedPhotoNode = EncryptedNode & { + photo?: EcnryptedPhotoAttributes; +}; + +export type DecryptedUnparsedPhotoNode = DecryptedUnparsedNode & { + photo?: PhotoAttributes; +}; + +export type DecryptedPhotoNode = DecryptedNode & { + photo?: PhotoAttributes; +}; + +export type EcnryptedPhotoAttributes = Omit & { + contentHash?: string; + albums: (PhotoAttributes['albums'][0] & { + nameHash?: string; + contentHash?: string; + })[]; +}; diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts new file mode 100644 index 00000000..5e5a4f12 --- /dev/null +++ b/js/sdk/src/internal/photos/nodes.ts @@ -0,0 +1,233 @@ +import { PrivateKey } from '../../crypto'; +import { DecryptionError } from '../../errors'; +import { NodeType } from '../../interface'; +import { drivePaths } from '../apiService'; +import { NodeAPIServiceBase, linkToEncryptedNode, linkToEncryptedNodeBaseMetadata } from '../nodes/apiService'; +import { NodesCacheBase, serialiseNode, deserialiseNode } from '../nodes/cache'; +import { NodesCryptoService } from '../nodes/cryptoService'; +import { DecryptedNodeKeys } from '../nodes/interface'; +import { NodesAccessBase, parseNode as parseNodeBase } from '../nodes/nodesAccess'; +import { NodesManagementBase } from '../nodes/nodesManagement'; +import { makeNodeUid } from '../uids'; +import { EncryptedPhotoNode, DecryptedPhotoNode, DecryptedUnparsedPhotoNode } from './interface'; + +type PostLoadLinksMetadataRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/links']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostLoadLinksMetadataResponse = + drivePaths['/drive/photos/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; + +export class PhotosNodesAPIService extends NodeAPIServiceBase< + EncryptedPhotoNode, + PostLoadLinksMetadataResponse['Links'][0] +> { + protected async fetchNodeMetadata(volumeId: string, linkIds: string[], signal?: AbortSignal) { + const response = await this.apiService.post( + `drive/photos/volumes/${volumeId}/links`, + { + LinkIDs: linkIds, + }, + signal, + ); + return response.Links; + } + + protected linkToEncryptedNode( + volumeId: string, + link: PostLoadLinksMetadataResponse['Links'][0], + isOwnVolumeId: boolean, + ): EncryptedPhotoNode { + const { baseNodeMetadata, baseCryptoNodeMetadata } = linkToEncryptedNodeBaseMetadata( + this.logger, + volumeId, + link, + isOwnVolumeId, + ); + + if (link.Link.Type === 2 && link.Photo && link.Photo.ActiveRevision) { + const node = linkToEncryptedNode( + this.logger, + volumeId, + { ...link, File: link.Photo, Folder: null }, + isOwnVolumeId, + ); + return { + ...node, + type: NodeType.Photo, + photo: { + captureTime: new Date(link.Photo.CaptureTime * 1000), + mainPhotoNodeUid: link.Photo.MainPhotoLinkID + ? makeNodeUid(volumeId, link.Photo.MainPhotoLinkID) + : undefined, + relatedPhotoNodeUids: link.Photo.RelatedPhotosLinkIDs.map((relatedLinkId) => + makeNodeUid(volumeId, relatedLinkId), + ), + contentHash: link.Photo.ContentHash || undefined, + tags: link.Photo.Tags, + albums: link.Photo.Albums.map((album) => ({ + nodeUid: makeNodeUid(volumeId, album.AlbumLinkID), + additionTime: new Date(album.AddedTime * 1000), + nameHash: album.Hash, + contentHash: album.ContentHash, + })), + }, + }; + } + + if (link.Link.Type === 3) { + return { + ...baseNodeMetadata, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + }, + }; + } + + const baseLink = { + Link: link.Link, + Membership: link.Membership, + Sharing: link.Sharing, + // @ts-expect-error The photo link can have a folder type, but not always. If not set, it will use other paths. + Folder: link.Folder, + File: null, // The photo link metadata never returns a file type. + }; + return linkToEncryptedNode(this.logger, volumeId, baseLink, isOwnVolumeId); + } +} + +export class PhotosNodesCache extends NodesCacheBase { + serialiseNode(node: DecryptedPhotoNode): string { + return serialiseNode(node); + } + + // TODO: use better deserialisation with validation + deserialiseNode(nodeData: string): DecryptedPhotoNode { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const node = deserialiseNode(nodeData) as any; + + if ( + !node || + typeof node !== 'object' || + (typeof node.photo !== 'object' && node.photo !== undefined) || + (typeof node.photo?.captureTime !== 'string' && node.folder?.captureTime !== undefined) || + (typeof node.photo?.albums !== 'object' && node.photo?.albums !== undefined) + ) { + throw new Error(`Invalid node data: ${nodeData}`); + } + + return { + ...node, + photo: !node.photo + ? undefined + : { + captureTime: new Date(node.photo.captureTime), + mainPhotoNodeUid: node.photo.mainPhotoNodeUid, + relatedPhotoNodeUids: node.photo.relatedPhotoNodeUids, + contentHash: node.photo.contentHash, + tags: node.photo.tags, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + albums: node.photo.albums?.map((album: any) => ({ + nodeUid: album.nodeUid, + additionTime: new Date(album.additionTime), + })), + }, + } as DecryptedPhotoNode; + } +} + +export class PhotosNodesAccess extends NodesAccessBase { + async getParentKeys( + node: Pick, + ): Promise> { + if (node.parentUid || node.shareId) { + return super.getParentKeys(node); + } + + if (node.photo?.albums.length) { + // If photo is in multiple albums, we just need to get keys for one of them. + // Prefer to find a cached key first. + for (const album of node.photo.albums) { + try { + const keys = await this.cryptoCache.getNodeKeys(album.nodeUid); + return { + key: keys.key, + hashKey: keys.hashKey, + }; + } catch { + // We ignore missing or invalid keys here, its just optimization. + // If it cannot be fixed, it will bubble up later when requesting + // the node keys for one of the albums. + } + } + + const albumNodeUid = node.photo.albums[0].nodeUid; + return this.getNodeKeys(albumNodeUid); + } + + // This is bug that should not happen. + // API cannot provide node without parent or share or album. + throw new Error('Node has neither parent node nor share nor album'); + } + + protected getDegradedUndecryptableNode( + encryptedNode: EncryptedPhotoNode, + error: DecryptionError, + ): DecryptedPhotoNode { + return this.getDegradedUndecryptableNodeBase(encryptedNode, error); + } + + protected parseNode(unparsedNode: DecryptedUnparsedPhotoNode): DecryptedPhotoNode { + if (unparsedNode.type === NodeType.Photo) { + const node = parseNodeBase(this.logger, { + ...unparsedNode, + type: NodeType.File, + }); + return { + ...node, + photo: unparsedNode.photo, + }; + } + + return parseNodeBase(this.logger, unparsedNode); + } +} + +export class PhotosNodesCryptoService extends NodesCryptoService { + async decryptNode( + encryptedNode: EncryptedPhotoNode, + parentKey: PrivateKey, + ): Promise<{ node: DecryptedUnparsedPhotoNode; keys?: DecryptedNodeKeys }> { + const decryptedNode = await super.decryptNode(encryptedNode, parentKey); + + if (decryptedNode.node.type === NodeType.Photo) { + return { + node: { + ...decryptedNode.node, + photo: encryptedNode.photo, + }, + }; + } + + return decryptedNode; + } +} + +export class PhotosNodesManagement extends NodesManagementBase< + EncryptedPhotoNode, + DecryptedPhotoNode, + PhotosNodesCryptoService +> { + protected generateNodeFolder( + nodeUid: string, + parentNodeUid: string, + name: string, + encryptedCrypto: { + hash: string; + encryptedName: string; + signatureEmail: string | null; + }, + ): DecryptedPhotoNode { + return this.generateNodeFolderBase(nodeUid, parentNodeUid, name, encryptedCrypto); + } +} diff --git a/js/sdk/src/internal/photos/timeline.test.ts b/js/sdk/src/internal/photos/timeline.test.ts index 7b0d4dd6..649bf3bf 100644 --- a/js/sdk/src/internal/photos/timeline.test.ts +++ b/js/sdk/src/internal/photos/timeline.test.ts @@ -2,7 +2,7 @@ import { getMockLogger } from '../../tests/logger'; import { DriveCrypto } from '../../crypto'; import { makeNodeUid } from '../uids'; import { PhotosAPIService } from './apiService'; -import { NodesService } from './interface'; +import { PhotosNodesAccess } from './nodes'; import { PhotoSharesManager } from './shares'; import { PhotosTimeline } from './timeline'; @@ -11,7 +11,7 @@ describe('PhotosTimeline', () => { let apiService: PhotosAPIService; let driveCrypto: DriveCrypto; let photoShares: PhotoSharesManager; - let nodesService: NodesService; + let nodesService: PhotosNodesAccess; let timeline: PhotosTimeline; const volumeId = 'volumeId'; diff --git a/js/sdk/src/internal/photos/timeline.ts b/js/sdk/src/internal/photos/timeline.ts index 8192c6a6..04ac14f9 100644 --- a/js/sdk/src/internal/photos/timeline.ts +++ b/js/sdk/src/internal/photos/timeline.ts @@ -2,7 +2,7 @@ import { DriveCrypto } from '../../crypto'; import { Logger } from '../../interface'; import { makeNodeUid } from '../uids'; import { PhotosAPIService } from './apiService'; -import { NodesService } from './interface'; +import { PhotosNodesAccess } from './nodes'; import { PhotoSharesManager } from './shares'; /** @@ -14,7 +14,7 @@ export class PhotosTimeline { private apiService: PhotosAPIService, private driveCrypto: DriveCrypto, private photoShares: PhotoSharesManager, - private nodesService: NodesService, + private nodesService: PhotosNodesAccess, ) { this.logger = logger; this.apiService = apiService; diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 37e0ea20..7efe0ec6 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -2,12 +2,12 @@ import { Logger, ProtonDriveClientContructorParameters, NodeOrUid, - MaybeMissingNode, + MaybeMissingPhotoNode, UploadMetadata, FileDownloader, FileUploader, SDKEvent, - MaybeNode, + MaybePhotoNode, ThumbnailType, ThumbnailResult, ShareNodeSettings, @@ -22,22 +22,22 @@ import { getConfig } from './config'; import { DriveCrypto } from './crypto'; import { Telemetry } from './telemetry'; import { - convertInternalMissingNodeIterator, - convertInternalNode, - convertInternalNodeIterator, - convertInternalNodePromise, + convertInternalMissingPhotoNodeIterator, + convertInternalPhotoNode, + convertInternalPhotoNodeIterator, + convertInternalPhotoNodePromise, getUid, getUids, } from './transformers'; import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; -import { initNodesModule } from './internal/nodes'; import { PHOTOS_SHARE_TARGET_TYPES, initPhotosModule, initPhotoSharesModule, initPhotoUploadModule, + initPhotosNodesModule, } from './internal/photos'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; @@ -56,7 +56,7 @@ export class ProtonDrivePhotosClient { private sdkEvents: SDKEvents; private events: DriveEventsService; private photoShares: ReturnType; - private nodes: ReturnType; + private nodes: ReturnType; private sharing: ReturnType; private download: ReturnType; private upload: ReturnType; @@ -107,7 +107,7 @@ export class ProtonDrivePhotosClient { cryptoModule, coreShares, ); - this.nodes = initNodesModule( + this.nodes = initPhotosNodesModule( telemetry, apiService, entitiesCache, @@ -224,9 +224,9 @@ export class ProtonDrivePhotosClient { * * See `ProtonDriveClient.iterateTrashedNodes` for more information. */ - async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); - yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); + yield * convertInternalPhotoNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } /** @@ -234,10 +234,10 @@ export class ProtonDrivePhotosClient { * * See `ProtonDriveClient.iterateNodes` for more information. */ - async *iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + async *iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating ${nodeUids.length} nodes`); // TODO: expose photo type - yield* convertInternalMissingNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); + yield * convertInternalMissingPhotoNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } /** @@ -245,9 +245,9 @@ export class ProtonDrivePhotosClient { * * See `ProtonDriveClient.getNode` for more information. */ - async getNode(nodeUid: NodeOrUid): Promise { + async getNode(nodeUid: NodeOrUid): Promise { this.logger.info(`Getting node ${getUid(nodeUid)}`); - return convertInternalNodePromise(this.nodes.access.getNode(getUid(nodeUid))); + return convertInternalPhotoNodePromise(this.nodes.access.getNode(getUid(nodeUid))); } /** @@ -255,9 +255,9 @@ export class ProtonDrivePhotosClient { * * See `ProtonDriveClient.renameNode` for more information. */ - async renameNode(nodeUid: NodeOrUid, newName: string): Promise { + async renameNode(nodeUid: NodeOrUid, newName: string): Promise { this.logger.info(`Renaming node ${getUid(nodeUid)}`); - return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); + return convertInternalPhotoNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); } /** @@ -305,9 +305,9 @@ export class ProtonDrivePhotosClient { * * See `ProtonDriveClient.iterateSharedNodes` for more information. */ - async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes by me'); - yield* convertInternalNodeIterator(this.sharing.access.iterateSharedNodes(signal)); + yield * convertInternalPhotoNodeIterator(this.sharing.access.iterateSharedNodes(signal)); } /** @@ -315,11 +315,11 @@ export class ProtonDrivePhotosClient { * * See `ProtonDriveClient.iterateSharedNodesWithMe` for more information. */ - async *iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { + async *iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes with me'); for await (const node of this.sharing.access.iterateSharedNodesWithMe(signal)) { - yield convertInternalNode(node); + yield convertInternalPhotoNode(node); } } @@ -482,9 +482,9 @@ export class ProtonDrivePhotosClient { * * The output is not sorted and the order of the nodes is not guaranteed. */ - async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { + async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating albums'); // TODO: expose album type - yield* convertInternalNodeIterator(this.photos.albums.iterateAlbums(signal)); + yield * convertInternalPhotoNodeIterator(this.photos.albums.iterateAlbums(signal)); } } diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index c5188861..28bcfedc 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -7,8 +7,13 @@ import { resultOk, resultError, MissingNode, + MaybePhotoNode as PublicMaybePhotoNode, + MaybeMissingPhotoNode as PublicMaybeMissingPhotoNode, + PhotoNode as PublicPhotoNode, + DegradedPhotoNode as PublicDegradedPhotoNode, } from './interface'; import { DecryptedNode as InternalNode, DecryptedRevision as InternalRevision } from './internal/nodes'; +import { DecryptedPhotoNode as InternalPartialPhotoNode } from './internal/photos'; type InternalPartialNode = Pick< InternalNode, @@ -122,6 +127,47 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode }); } +export async function* convertInternalPhotoNodeIterator( + photoNodeIterator: AsyncGenerator, +): AsyncGenerator { + for await (const photoNode of photoNodeIterator) { + yield convertInternalPhotoNode(photoNode); + } +} + +export async function* convertInternalMissingPhotoNodeIterator( + photoNodeIterator: AsyncGenerator, +): AsyncGenerator { + for await (const photoNode of photoNodeIterator) { + if ('missingUid' in photoNode) { + yield resultError(photoNode); + } else { + yield convertInternalPhotoNode(photoNode); + } + } +} + +export async function convertInternalPhotoNodePromise( + photoNodePromise: Promise, +): Promise { + const photoNode = await photoNodePromise; + return convertInternalPhotoNode(photoNode); +} + +export function convertInternalPhotoNode(photoNode: InternalPartialPhotoNode): PublicMaybePhotoNode { + const node = convertInternalNode(photoNode); + if (node.ok) { + return resultOk({ + ...node.value, + photo: photoNode.photo, + } as PublicPhotoNode); + } + return resultError({ + ...node.error, + photo: photoNode.photo, + } as PublicDegradedPhotoNode); +} + export async function* convertInternalRevisionIterator( revisionIterator: AsyncGenerator, ): AsyncGenerator { From 7f689be81401d722c962e442997cce8dfbd03a2a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 08:36:11 +0100 Subject: [PATCH 359/791] js/v0.7.1 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 0e1d2977..14dd13de 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.7.0", + "version": "0.7.1", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index 17f67b77..e6311963 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.7.0", + "version": "0.7.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 6ad362e9acaf23eab362afdaf37294942b10210b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Dec 2025 17:30:19 +0000 Subject: [PATCH 360/791] Add error handling for writing to output stream --- .../Networking/Model/StreamForUpload.swift | 118 +++++++++++------- 1 file changed, 73 insertions(+), 45 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift index 34d5e9cd..e1001f40 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift @@ -60,11 +60,24 @@ public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendabl } if eventCode.contains(.errorOccurred) { - onStreamError(aStream.streamError ?? ProtonDriveSDKError(interopError: .wrongResult(message: "Stream error"))) - closeAndCleanUp() + invokeStreamError(aStream.streamError, fallbackMessage: "Stream error") } } - + + private func makeError(_ error: Error?, fallbackMessage: String) -> Error { + return error ?? ProtonDriveSDKError(interopError: .wrongResult(message: fallbackMessage)) + } + + private func invokeStreamError(_ error: Error?, fallbackMessage: String) { + let error = makeError(error, fallbackMessage: fallbackMessage) + invokeStreamError(error) + } + + private func invokeStreamError(_ error: Error) { + onStreamError(error) + closeAndCleanUp() + } + private func receivedHasSpaceAvailableEvent() { stateQueue.sync { switch state { @@ -99,52 +112,67 @@ public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendabl private func writeToOutputStream() { writingQueue.async { [weak self] in guard let self else { return } - do { - guard self.remainingBytes.isEmpty else { - self.remainingBytes.withUnsafeBufferPointer { buffer in - let bytesWritten = self.output.write(buffer.baseAddress!, maxLength: remainingBytes.count) - if bytesWritten < remainingBytes.count { - // We have bytes in the memory from the last time - // we were writing to the stream. We use them instead of asking the SDK. - // Once all the remaining bytes are written, ask the SDK for more - self.remainingBytes = Array(remainingBytes[bytesWritten...]) - } else { - self.remainingBytes = [] - } - } - hasFinishedWriting() - return - } - - let baseAddress = buffer.baseAddress! - let streamReadRequest = Proton_Sdk_StreamReadRequest.with { - $0.bufferLength = Int32(buffer.count) - $0.bufferPointer = Int64(ObjectHandle(rawPointer: UnsafeRawPointer(baseAddress))) - $0.streamHandle = sdkContentHandle + guard self.remainingBytes.isEmpty else { + processRemainingBytes() + return + } + + let baseAddress = buffer.baseAddress! + let streamReadRequest = Proton_Sdk_StreamReadRequest.with { + $0.bufferLength = Int32(buffer.count) + $0.bufferPointer = Int64(ObjectHandle(rawPointer: UnsafeRawPointer(baseAddress))) + $0.streamHandle = sdkContentHandle + } + SDKRequestHandler.send(streamReadRequest, logger: logger) { (result: Result) in + self.handleReadResult(result, baseAddress: baseAddress) + } + } + } + + private func processRemainingBytes() { + do { + try remainingBytes.withUnsafeBufferPointer { buffer in + let bytesWritten = output.write(buffer.baseAddress!, maxLength: remainingBytes.count) + if bytesWritten < 0 { + throw makeError(output.streamError, fallbackMessage: "Failed to append stream data") + } else if bytesWritten < remainingBytes.count { + // We have bytes in the memory from the last time + // we were writing to the stream. We use them instead of asking the SDK. + // Once all the remaining bytes are written, ask the SDK for more + remainingBytes = Array(remainingBytes[bytesWritten...]) + } else { + remainingBytes = [] } - SDKRequestHandler.send(streamReadRequest, logger: logger) { (result: Result) in - switch result { - case .success(let read): - if read == 0 { - self.output.close() - } else { - let bytesWritten = self.output.write(baseAddress, maxLength: Int(read)) - if bytesWritten < Int(read) { - // Keep the remaining, unwritten bytes in the memory. - // On the next .hasSpaceAvailable event, we will write - // these bytes from the memory instead of asking the SDK. - self.remainingBytes = Array(self.buffer[bytesWritten...]) - } - } - case .failure(let error): - self.onStreamError(error) + } + hasFinishedWriting() + } catch { + invokeStreamError(error) + } + } + + private func handleReadResult(_ result: Result, baseAddress: UnsafeMutableRawPointer) { + do { + switch result { + case .success(let read): + if read == 0 { + output.close() + } else { + let bytesWritten = output.write(baseAddress, maxLength: Int(read)) + if bytesWritten < 0 { + throw makeError(output.streamError, fallbackMessage: "Failed to write stream data") + } else if bytesWritten < Int(read) { + // Keep the remaining, unwritten bytes in the memory. + // On the next .hasSpaceAvailable event, we will write + // these bytes from the memory instead of asking the SDK. + remainingBytes = Array(self.buffer[bytesWritten...]) } - self.hasFinishedWriting() } - } catch { - onStreamError(error) - hasFinishedWriting() + case .failure(let error): + throw error } + hasFinishedWriting() + } catch { + invokeStreamError(error) } } From 3fe97620c23b49d7f247f46c57bbac19723a4cec Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Dec 2025 16:12:30 +0100 Subject: [PATCH 361/791] Prevent download from seeking back in output stream --- .../Nodes/Download/RevisionReader.cs | 35 +++++-------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index e3217eba..5039f125 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -86,7 +86,7 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } - var downloadTask = DownloadBlockAsync(block, contentOutputStream, cancellationToken); + var downloadTask = DownloadBlockAsync(block, cancellationToken); downloadTasks.Enqueue(downloadTask); } @@ -180,12 +180,9 @@ private async Task WriteNextBlockToOutputAsync( { manifestStream.Write(downloadResult.Sha256Digest.Span); - if (downloadResult.IsIntermediateStream) - { - downloadedStream.Seek(0, SeekOrigin.Begin); + downloadedStream.Seek(0, SeekOrigin.Begin); - await downloadedStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); - } + await downloadedStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); _totalProgress += downloadedStream.Length; @@ -193,10 +190,7 @@ private async Task WriteNextBlockToOutputAsync( } finally { - if (downloadResult.IsIntermediateStream) - { - await downloadedStream.DisposeAsync().ConfigureAwait(false); - } + await downloadedStream.DisposeAsync().ConfigureAwait(false); } } finally @@ -205,21 +199,9 @@ private async Task WriteNextBlockToOutputAsync( } } - private async Task DownloadBlockAsync(BlockDto block, Stream contentOutputStream, CancellationToken cancellationToken) + private async Task DownloadBlockAsync(BlockDto block, CancellationToken cancellationToken) { - Stream blockOutputStream; - bool isIntermediateStream; - - if (block.Index == 1) - { - blockOutputStream = contentOutputStream; - isIntermediateStream = false; - } - else - { - blockOutputStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - isIntermediateStream = true; - } + var blockOutputStream = ProtonDriveClient.MemoryStreamManager.GetStream(); var hashDigest = await _client.BlockDownloader.DownloadAsync( _revisionUid, @@ -230,7 +212,7 @@ private async Task DownloadBlockAsync(BlockDto block, Strea blockOutputStream, cancellationToken).ConfigureAwait(false); - return new BlockDownloadResult(block.Index, blockOutputStream, isIntermediateStream, hashDigest); + return new BlockDownloadResult(block.Index, blockOutputStream, hashDigest); } private async IAsyncEnumerable<(BlockDto Value, bool IsLast)> GetBlocksAsync([EnumeratorCancellation] CancellationToken cancellationToken) @@ -338,11 +320,10 @@ private async Task VerifyManifestAsync(Stream manifestStr [LoggerMessage(Level = LogLevel.Trace, Message = "Manifest verification failed for revision \"{RevisionUid}\": {VerificationStatus}")] private partial void LogFailedManifestVerification(RevisionUid revisionUid, PgpVerificationStatus verificationStatus); - private readonly struct BlockDownloadResult(int index, Stream stream, bool isIntermediateStream, ReadOnlyMemory sha256Digest) + private readonly struct BlockDownloadResult(int index, Stream stream, ReadOnlyMemory sha256Digest) { public int Index { get; } = index; public Stream Stream { get; } = stream; - public bool IsIntermediateStream { get; } = isIntermediateStream; public ReadOnlyMemory Sha256Digest { get; } = sha256Digest; } } From 42564fc85aab3e3384f78f8b243c5ea9b03452cd Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Dec 2025 08:58:05 +0100 Subject: [PATCH 362/791] Add properties to query paused state of upload and download --- .../InteropDownloadController.cs | 8 ++ .../InteropMessageHandler.cs | 6 ++ .../InteropUploadController.cs | 8 ++ .../Nodes/Download/DownloadController.cs | 3 + .../Nodes/Upload/UploadController.cs | 3 + .../src/Proton.Sdk.CExports/InteropArray.cs | 14 ---- .../InteropResultExtensions.cs | 75 ------------------- cs/sdk/src/protos/proton.drive.sdk.proto | 28 +++++-- 8 files changed, 48 insertions(+), 97 deletions(-) delete mode 100644 cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs index 6e347b31..d0aecc53 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs @@ -1,4 +1,5 @@ using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes.Download; using Proton.Sdk.CExports; @@ -6,6 +7,13 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropDownloadController { + public static IMessage HandleIsPaused(DownloadControllerIsPausedRequest request) + { + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); + + return new BoolValue { Value = downloadController.IsPaused }; + } + public static async ValueTask HandleAwaitCompletion(DownloadControllerAwaitCompletionRequest request) { var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 78890adf..fe33bf69 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -49,6 +49,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.FileUploaderFree => InteropFileUploader.HandleFree(request.FileUploaderFree), + Request.PayloadOneofCase.UploadControllerIsPaused + => InteropUploadController.HandleIsPaused(request.UploadControllerIsPaused), + Request.PayloadOneofCase.UploadControllerAwaitCompletion => await InteropUploadController.HandleAwaitCompletion(request.UploadControllerAwaitCompletion).ConfigureAwait(false), @@ -70,6 +73,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.FileDownloaderFree => InteropFileDownloader.HandleFree(request.FileDownloaderFree), + Request.PayloadOneofCase.DownloadControllerIsPaused + => InteropDownloadController.HandleIsPaused(request.DownloadControllerIsPaused), + Request.PayloadOneofCase.DownloadControllerAwaitCompletion => await InteropDownloadController.HandleAwaitCompletion(request.DownloadControllerAwaitCompletion).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs index 84492171..685a997d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -1,4 +1,5 @@ using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk.CExports; @@ -6,6 +7,13 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropUploadController { + public static IMessage HandleIsPaused(UploadControllerIsPausedRequest request) + { + var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); + + return new BoolValue { Value = uploadController.IsPaused }; + } + public static async ValueTask HandleAwaitCompletion(UploadControllerAwaitCompletionRequest request) { var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 9675dd10..ea6c0235 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -2,6 +2,9 @@ public sealed class DownloadController(Task downloadTask) { + // FIXME + public bool IsPaused { get; } + public Task Completion { get; } = downloadTask; public void Pause() diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 2c560741..312dd989 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -2,6 +2,9 @@ namespace Proton.Drive.Sdk.Nodes.Upload; public sealed class UploadController(Task<(NodeUid NodeUid, RevisionUid RevisionUid)> uploadTask) { + // FIXME + public bool IsPaused { get; } + // FIXME: Add unit test to ensure that the revision UID is of the new active revision public Task<(NodeUid NodeUid, RevisionUid RevisionUid)> Completion { get; } = uploadTask; diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs index 0481f491..f325d24e 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs @@ -13,20 +13,6 @@ internal readonly unsafe struct InteropArray(T* pointer, nint length) public bool IsNullOrEmpty => Pointer is null || Length == 0; - public static InteropArray AllocFromMemory(ReadOnlyMemory memory) - { - if (memory.Length == 0) - { - return Null; - } - - var interopBytes = NativeMemory.Alloc((nuint)memory.Length); - - memory.Span.CopyTo(new Span(interopBytes, memory.Length)); - - return new InteropArray((T*)interopBytes, memory.Length); - } - public T[] ToArray() { return !IsNullOrEmpty ? new ReadOnlySpan(Pointer, (int)Length).ToArray() : []; diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs deleted file mode 100644 index 21f02e24..00000000 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropResultExtensions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Runtime.InteropServices; -using System.Text; -using Google.Protobuf; - -namespace Proton.Sdk.CExports; - -internal static class InteropResultExtensions -{ - internal static Result, InteropArray> Success() - { - return new Result, InteropArray>(value: InteropArray.Null); - } - - internal static Result, InteropArray> Success(IMessage data) - { - return new Result, InteropArray>( - value: InteropArray.AllocFromMemory(data.ToByteArray())); - } - - internal static unsafe Result, InteropArray> Success(string value) - { - var maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length); - var ptr = (byte*)NativeMemory.Alloc((nuint)maxByteCount); - - var length = Encoding.UTF8.GetBytes(value, new Span(ptr, maxByteCount)); - - return Result, InteropArray>.Success(new InteropArray(ptr, length)); - } - - internal static Result> Failure(Exception exception, int defaultCode) - { - if (exception is ProtonApiException protonApiException) - { - return Failure((int)protonApiException.Code, protonApiException.Message); - } - - return Failure(defaultCode, exception.Message); - } - - internal static Result> Failure(Exception exception, int defaultCode) - { - if (exception is ProtonApiException protonApiException) - { - return Failure((int)protonApiException.Code, protonApiException.Message); - } - - return Failure(defaultCode, exception.Message); - } - - internal static Result> Failure(Exception exception, Action setDomainAndCodesFunction) - { - var error = exception.ToErrorMessage(setDomainAndCodesFunction); - - return new Result>(error: InteropArray.AllocFromMemory(error.ToByteArray())); - } - - internal static Result> Failure(Exception exception, Action setDomainAndCodesFunction) - { - var error = exception.ToErrorMessage(setDomainAndCodesFunction); - - return new Result>(error: InteropArray.AllocFromMemory(error.ToByteArray())); - } - - private static Result> Failure(int code, string message) - { - return new Result>( - error: InteropArray.AllocFromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); - } - - private static Result> Failure(int code, string message) - { - return new Result>( - error: InteropArray.AllocFromMemory(new Error { PrimaryCode = code, Message = message }.ToByteArray())); - } -} diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 07477528..f059a712 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -21,18 +21,20 @@ message Request { UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; FileUploaderFreeRequest file_uploader_free = 1102; - UploadControllerAwaitCompletionRequest upload_controller_await_completion = 1103; - UploadControllerPauseRequest upload_controller_pause = 1104; - UploadControllerResumeRequest upload_controller_resume = 1105; - UploadControllerFreeRequest upload_controller_free = 1106; + UploadControllerIsPausedRequest upload_controller_is_paused = 1103; + UploadControllerAwaitCompletionRequest upload_controller_await_completion = 1104; + UploadControllerPauseRequest upload_controller_pause = 1105; + UploadControllerResumeRequest upload_controller_resume = 1106; + UploadControllerFreeRequest upload_controller_free = 1107; DownloadToStreamRequest download_to_stream = 1200; DownloadToFileRequest download_to_file = 1201; FileDownloaderFreeRequest file_downloader_free = 1202; - DownloadControllerAwaitCompletionRequest download_controller_await_completion = 1203; - DownloadControllerPauseRequest download_controller_pause = 1204; - DownloadControllerResumeRequest download_controller_resume = 1205; - DownloadControllerFreeRequest download_controller_free = 1206; + DownloadControllerIsPausedRequest download_controller_is_paused = 1203; + DownloadControllerAwaitCompletionRequest download_controller_await_completion = 1204; + DownloadControllerPauseRequest download_controller_pause = 1205; + DownloadControllerResumeRequest download_controller_resume = 1206; + DownloadControllerFreeRequest download_controller_free = 1207; }; } @@ -229,6 +231,11 @@ message FileUploaderFreeRequest { int64 file_uploader_handle = 1; } +// The reponse message must be of type BoolValue. +message UploadControllerIsPausedRequest { + int64 upload_controller_handle = 1; +} + // The response message must be of type UploadResult. message UploadControllerAwaitCompletionRequest { int64 upload_controller_handle = 1; @@ -295,6 +302,11 @@ message FileDownloaderFreeRequest { int64 file_downloader_handle = 1; } +// The reponse message must be of type BoolValue. +message DownloadControllerIsPausedRequest { + int64 download_controller_handle = 1; +} + // The reponse must not have a value. message DownloadControllerAwaitCompletionRequest { int64 download_controller_handle = 1; From 4256beb5021b0eef1cee66e2597aeddf47d74f88 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Dec 2025 12:27:47 +0100 Subject: [PATCH 363/791] Check optional proto fields --- .../proton/drive/sdk/extension/DecryptionErrorEventPayload.kt | 2 +- .../me/proton/drive/sdk/extension/DownloadEventPayload.kt | 4 ++-- .../me/proton/drive/sdk/extension/UploadEventPayload.kt | 4 ++-- .../drive/sdk/extension/VerificationErrorEventPayload.kt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt index caeabf5e..3d720282 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt @@ -7,6 +7,6 @@ fun ProtonDriveSdk.DecryptionErrorEventPayload.toEvent() = DecryptionErrorEvent( volumeType = volumeType.toEnum(), field = field.toEnum(), fromBefore2024 = fromBefore2024, - error = error, + error = takeIf { hasError() }?.error, uid = uid, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt index 7b6a1a36..dbba21d1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt @@ -7,6 +7,6 @@ fun ProtonDriveSdk.DownloadEventPayload.toEvent() = DownloadEvent( volumeType = volumeType.toEnum(), claimedFileSize = claimedFileSize, downloadedSize = downloadedSize, - error = error.toEnum(), - originalError = originalError, + error = takeIf { hasError() }?.error?.toEnum(), + originalError = takeIf { hasOriginalError() }?.originalError, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt index c4d216a3..5947e175 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt @@ -8,6 +8,6 @@ fun ProtonDriveSdk.UploadEventPayload.toEvent() = UploadEvent( expectedSize = expectedSize, uploadedSize = uploadedSize, approximateUploadedSize = approximateUploadedSize, - error = error.toEnum(), - originalError = originalError, + error = takeIf { hasError() }?.error?.toEnum(), + originalError = takeIf { hasOriginalError() }?.originalError, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt index 144a1bac..0d005581 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt @@ -8,6 +8,6 @@ fun ProtonDriveSdk.VerificationErrorEventPayload.toEvent() = VerificationErrorEv field = field.toEnum(), fromBefore2024 = fromBefore2024, addressMatchingDefaultShare = addressMatchingDefaultShare, - error = error, + error = takeIf { hasError() }?.error, uid = uid, ) From 2422f58b115b7dcade378f55dee9944742f8b21d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 07:36:32 +0100 Subject: [PATCH 364/791] Add getMyPhotosRootFolder --- js/sdk/src/protonDrivePhotosClient.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 7efe0ec6..8e504460 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -197,6 +197,14 @@ export class ProtonDrivePhotosClient { return this.events.subscribeToCoreEvents(callback); } + /** + * @returns The root folder to Photos section of the user. + */ + async getMyPhotosRootFolder(): Promise { + this.logger.info('Getting my photos root folder'); + return convertInternalPhotoNodePromise(this.nodes.access.getVolumeRootFolder()); + } + /** * Iterates all the photos for the timeline view. * From 1ee60ba35cc790004fa10df10671e58cb71b5cb8 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 12:05:51 +0100 Subject: [PATCH 365/791] Improve error generation and parsing in Swift bindings --- .../Sources/CancellationTokenSource.swift | 8 +- .../FileOperations/DownloadManager.swift | 16 +- .../FileOperations/UploadManager.swift | 37 +++- .../Sources/Plumbing/FeatureFlags.swift | 3 + .../Sources/Plumbing/InternalTypes.swift | 47 ++--- .../Plumbing/ProgressCallbackWrapper.swift | 7 +- .../Sources/Plumbing/SDKResponseHandler.swift | 86 ++++++-- .../ProtonDriveClient/AccountClient.swift | 5 +- .../HttpClientProtocol.swift | 119 ++++++++--- .../HttpClientRequestProcessor.swift | 185 +++++++++--------- .../HttpClientResponseProcessor.swift | 104 +++++----- .../Model/BoxedCancellableTask.swift | 6 +- .../Model/BoxedDownloadStream.swift | 6 +- .../Networking/Model/BoxedRawBuffer.swift | 8 +- .../Networking/Model/BytesOrStream.swift | 2 +- .../Networking/Model/StreamForUpload.swift | 28 ++- .../ProtonDriveClient/ProtonDriveClient.swift | 30 +-- .../ProtonDriveClientConfiguration.swift | 47 ++++- .../ProtonDriveSDKError.swift | 55 +++++- .../Sources/TelemetryAndLogging/Logger.swift | 3 + .../TelemetryAndLogging/Telemetry.swift | 3 + 21 files changed, 525 insertions(+), 280 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift index ba522b91..412561f7 100644 --- a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift +++ b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift @@ -33,8 +33,12 @@ actor CancellationTokenSource { let request = Proton_Sdk_CancellationTokenSourceFreeRequest.with { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } - try await SDKRequestHandler.sendInteropRequest(request, logger: logger) as Void - logger?.trace("CancellationTokenSource.free succeeded, handle: \(cancellationHandle) -> nil", category: "Cancellation") + do { + try await SDKRequestHandler.sendInteropRequest(request, logger: logger) as Void + logger?.trace("CancellationTokenSource.free succeeded, handle: \(cancellationHandle) -> nil", category: "Cancellation") + } catch { + logger?.error("CancellationTokenSource.free failed, error: \(error)", category: "Cancellation") + } strongSelf = nil } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift index d1211431..45a9dbf0 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift @@ -113,7 +113,13 @@ public actor DownloadManager { $0.fileDownloaderHandle = Int64(fileDownloaderHandle) } - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the downloader failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_FileDownloaderFreeRequest failed: \(error)", category: "DownloadManager.freeDownloader") + } } } @@ -124,7 +130,13 @@ public actor DownloadManager { $0.downloadControllerHandle = Int64(downloadControllerHandle) } - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the download controller failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_DownloadControllerFreeRequest failed: \(error)", category: "DownloadManager.freeDownloadController") + } } } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift index 781bd47b..853c4544 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift @@ -96,6 +96,11 @@ public actor UploadManager { modificationDate: modificationDate, cancellationHandle: cancellationHandle ) + + defer { + freeFileUploader(uploaderHandle) + } + let uploadedNode = try await uploadFromFile( uploaderHandle: uploaderHandle, fileURL: fileURL, @@ -202,6 +207,10 @@ extension UploadManager { logger: logger ) assert(uploadControllerHandle != 0) + + defer { + freeFileUploadController(uploadControllerHandle) + } let uploadedNode = try await awaitUploadCompletion(uploadControllerHandle) return uploadedNode @@ -209,12 +218,32 @@ extension UploadManager { /// Free a file uploader when no longer needed private func freeFileUploader(_ fileUploaderHandle: ObjectHandle) { - let freeRequest = Proton_Drive_Sdk_FileUploaderFreeRequest.with { - $0.fileUploaderHandle = Int64(fileUploaderHandle) + Task { + let freeRequest = Proton_Drive_Sdk_FileUploaderFreeRequest.with { + $0.fileUploaderHandle = Int64(fileUploaderHandle) + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the file uploader failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_FileUploaderFreeRequest failed: \(error)", category: "UploadManager.freeFileUploader") + } } - + } + + private func freeFileUploadController(_ fileUploadControllerHandle: ObjectHandle) { Task { - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { + $0.uploadControllerHandle = Int64(fileUploadControllerHandle) + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the file upload controller failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_UploadControllerFreeRequest failed: \(error)", category: "UploadManager.freeFileUploadController") + } } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift index fa08defb..2cb8828e 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift @@ -4,6 +4,9 @@ public typealias FeatureFlagProviderCallback = @Sendable (String, (Bool) -> Void let cCompatibleFeatureFlagProviderCallback: CCallbackWithIntReturn = { statePointer, byteArray in guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + let message = "cCompatibleFeatureFlagProviderCallback.statePointer is nil" + assertionFailure(message) + // there is no way we can inform the SDK back about the issue return 0 } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index e2bbb992..51387ed0 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -7,25 +7,7 @@ typealias ObjectHandle = Int extension ObjectHandle { /// Returns the address of a callback as a number - init(callback: CCallback) { - let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) - self = ObjectHandle(bitPattern: callbackAddress) - } - - /// Returns the address of a callback as a number - init(callback: CCallbackWithCallbackPointer) { - let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) - self = ObjectHandle(bitPattern: callbackAddress) - } - - /// Returns the address of a callback as a number - init(callback: CCallbackWithCallbackPointerAndIntReturn) { - let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) - self = ObjectHandle(bitPattern: callbackAddress) - } - - /// Returns the address of a callback with int return as a number - init(callback: CCallbackWithIntReturn) { + init(callback: T) { let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) self = ObjectHandle(bitPattern: callbackAddress) } @@ -42,17 +24,16 @@ func address(of object: T) -> ObjectHandle { return ObjectHandle(bitPattern: rawPointer) } -/// C-compatible callback used by SDK to pass data to the app -typealias CCallback = @convention(c) (Int, ByteArray) -> Void -typealias CCallbackWithCallbackPointer = @convention(c) (Int, ByteArray, Int) -> Void -typealias CCallbackWithCallbackPointerAndIntReturn = @convention(c) (Int, ByteArray, Int) -> Int -typealias CCallbackWithIntReturn = @convention(c) (Int, ByteArray) -> Int32 +/// C-compatible callback used by SDK to pass data to the app and back +/// `statePointer` is pointer to the state we create on the app side and pass to the SDK in the request that is causing the callback to be called. SDK does not interact with the state at all, it just passes it back to the app. It's app's responsibility to maintain the lifecycle of the state (deallocate when appropriate). It's always passed, in every callback variant. +/// `byteArray` is a pointer and the count struct describing the memory allocated by the SDK, and passed to the callback to enable it to perform its operation. It is either the protobuf message created by the SDK that contains all the necessary information, or it's a memory buffer from which/into which the callback is supposed to read/write. The app does not maintain the lifecycle of the byteArray, it's SDK's responsibility. It's passed on the callback variants that require it for their work. +/// `callbackPointer` is a pointer to the callback created on the SDK side that keeps the SDK's async operation waiting. It's app's responsibility to make a response call (using `proton_sdk_handle_response`) and pass the operation result (be it success or error). If the app fails to do it, the operation might hang indefinitely. The lifecycle of the object under `callbackPointer` is SDK's responsibility. It's passed in the callbacks that are represented as async operations on the SDK side. -extension Data { - var dumptoString: String { - String(data: self, encoding: .isoLatin2).map { String($0) } ?? "n/a" - } -} +typealias CCallback = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray) -> Void +typealias CCallbackWithoutByteArray = @convention(c) (_ statePointer: Int) -> Void +typealias CCallbackWithIntReturn = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray) -> Int32 +typealias CCallbackWithCallbackPointer = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray, _ callbackPointer: Int) -> Void +typealias CCallbackWithCallbackPointerAndObjectPointerReturn = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray, _ callbackPointer: Int) -> Int // MARK: - ByteArray extensions @@ -87,6 +68,13 @@ extension Data { } } +// helper for debugging — makes inspecting data in the debugger easier +extension Data { + var dumpToString: String { + String(data: self, encoding: .isoLatin2).map { String($0) } ?? "n/a" + } +} + // MARK: - Protobuf extensions extension SwiftProtobuf.Message { @@ -103,6 +91,7 @@ extension SwiftProtobuf.Message { do { try self.init(serializedBytes: data) } catch { + assertionFailure("The protobuf message could not be created") self.init() } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift index e7834c8e..09a0f603 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -17,7 +17,12 @@ let cProgressCallback: CCallback = { statePointer, byteArray in bytesTotal: progressUpdate.hasBytesInTotal ? progressUpdate.bytesInTotal : nil ) - guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { return } + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + let message = "cProgressCallback.statePointer is nil" + assertionFailure(message) + // there is no way we can inform the SDK back about the issue + return + } let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state weakWrapper.value?.callback(progress) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift index 5e8a7a83..bcc22954 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift @@ -15,26 +15,88 @@ enum SDKResponseHandler { } static func sendErrorToSDK(_ error: Error, callbackPointer: Int) { - sendErrorToSDK(error as NSError, callbackPointer: callbackPointer) + let sdkError = Proton_Sdk_Error.from(nsError: error as NSError) + SDKResponseHandler.send(callbackPointer: callbackPointer, message: sdkError) } + /// A helper method to send an interop error from Swift bindings by providing just the message. + /// The examples of interop errors are: unable to serialize/deserialize protobuf, unable to use a provide pointer etc. static func sendInteropErrorToSDK(message: String, callbackPointer: Int) { - let error = Proton_Sdk_Error.with { - $0.type = "interop" + assertionFailure(message) + let sdkError = Proton_Sdk_Error.with { + $0.type = "Swift bindings" $0.domain = Proton_Sdk_ErrorDomain.businessLogic $0.message = message } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) + SDKResponseHandler.send(callbackPointer: callbackPointer, message: sdkError) } +} + +extension Proton_Sdk_Error { + + private static let encoder = JSONEncoder() + + static func from(nsError: NSError) -> Proton_Sdk_Error { + let type: String + let domain: Proton_Sdk_ErrorDomain + let message: String + var primaryCode: Int? = nil + var secondaryCode: Int? = nil + var context: String? = nil + var innerError: Proton_Sdk_Error? = nil + var additionalData: Codable? = nil - static func sendErrorToSDK(_ error: NSError, callbackPointer: Int) { - // TODO(SDK): below we're just returning some rubbish - let error = Proton_Sdk_Error.with { - $0.type = "sdk error" - $0.domain = Proton_Sdk_ErrorDomain.api - $0.message = error.localizedDescription + switch nsError { + case let sdkError as Proton_Sdk_Error: + return sdkError + + case let protonDriveSDKError as ProtonDriveSDKError: + return protonDriveSDKError.asProton_Sdk_Error + + case let cocoaError as CocoaError where cocoaError.code == .userCancelled: + type = NSURLErrorDomain + domain = .successfulCancellation + message = cocoaError.localizedDescription + + case let urlError as URLError where urlError.code == .cancelled: + type = NSURLErrorDomain + domain = .successfulCancellation + message = urlError.localizedDescription + + case let urlError as URLError: + type = NSURLErrorDomain + domain = .network + message = urlError.localizedDescription + primaryCode = urlError.code.rawValue + + default: + type = nsError.domain + domain = .undefined + message = nsError.localizedDescription + primaryCode = nsError.code + } + + return Proton_Sdk_Error.with { + $0.type = type + $0.domain = domain + $0.message = message + if let primaryCode { + $0.primaryCode = Int64(primaryCode) + } + if let secondaryCode { + $0.secondaryCode = Int64(secondaryCode) + } + if let context { + $0.context = context + } + if let innerError { + $0.innerError = innerError + } + if let additionalData, + let jsonData = try? encoder.encode(additionalData), + let protobufData = try? Google_Protobuf_Any.init(jsonUTF8Data: jsonData) { + $0.additionalData = protobufData + } } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) } - } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift index 277a7b85..9a40a112 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift @@ -12,6 +12,8 @@ public protocol AccountClientProtocol: Sendable { let cCompatibleAccountClientRequest: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + SDKResponseHandler.sendInteropErrorToSDK(message: "cCompatibleAccountClientRequest.statePointer is null", + callbackPointer: callbackPointer) return } let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) @@ -55,7 +57,8 @@ let cCompatibleAccountClientRequest: CCallbackWithCallbackPointer = { statePoint } SDKResponseHandler.send(callbackPointer: callbackPointer, message: repeatedBytes) case nil: - fatalError() + let message = "cCompatibleAccountClientRequest.Proton_Drive_Sdk_AccountRequest.payload is null" + SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer) } } } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift index e6a93814..9cc4fed5 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift @@ -1,39 +1,9 @@ import Foundation import SwiftProtobuf -public struct HttpClientResponse { - public let data: Data? - public let headers: [(String, [String])] - public let statusCode: Int - - public init(data: Data?, headers: [(String, [String])], statusCode: Int) { - self.data = data - self.headers = headers - self.statusCode = statusCode - } -} - -public struct HttpClientStream { - public let stream: URLSession.AsyncBytes - public let headers: [(String, [String])] - public let statusCode: Int - - public init(stream: URLSession.AsyncBytes, headers: [(String, [String])], statusCode: Int) { - self.stream = stream - self.headers = headers - self.statusCode = statusCode - } -} - -public enum RequestType { - case driveAPI(relativePath: String) - case uploadToStorage - case downloadFromStorage -} - /// Protocol to be implemented by object making http requests. public protocol HttpClientProtocol: AnyObject, Sendable { - func getRelativeDrivePath(url: String, method: String) -> RequestType + func identifyRequestType(url: String, method: String) -> RequestType /// Drive api calls (takes `/drive/...` path) func requestDriveApi( @@ -55,6 +25,91 @@ public protocol HttpClientProtocol: AnyObject, Sendable { method: String, url: String, content: Data, - headers: [(String, [String])] + headers: [(String, [String])], + downloadStreamCreator: @Sendable @escaping (URLSession.AsyncBytes) -> AnyAsyncSequence ) async -> Result } + +public enum RequestType { + case driveAPI(relativePath: String) + case uploadToStorage + case downloadFromStorage +} + +public struct HttpClientResponse { + public let data: Data? + public let headers: [(String, [String])] + public let statusCode: Int + + public init(data: Data?, headers: [(String, [String])], statusCode: Int) { + self.data = data + self.headers = headers + self.statusCode = statusCode + } +} + +public struct HttpClientStream { + public let stream: AnyAsyncSequence + public let headers: [(String, [String])] + public let statusCode: Int + + public init( + stream: AnyAsyncSequence, + headers: [(String, [String])], + statusCode: Int + ) { + self.stream = stream + self.headers = headers + self.statusCode = statusCode + } +} + +public struct AnyAsyncSequence: AsyncSequence { + public typealias AsyncIterator = AnyAsyncIterator + public typealias Element = Element + + private let internalMakeAsyncIterator: () -> AnyAsyncIterator + + public init(_ sequence: S) where S.Element == Element { + internalMakeAsyncIterator = { + AnyAsyncIterator(iterator: sequence.makeAsyncIterator()) + } + } + + public func makeAsyncIterator() -> AnyAsyncIterator { + internalMakeAsyncIterator() + } +} + +public struct AnyAsyncIterator: AsyncIteratorProtocol { + public typealias Element = Element + + private final class IteratorBox: @unchecked Sendable { + var iterator: I + init(_ iterator: I) { self.iterator = iterator } + } + + private var internalNext: () async throws -> Element? + private var internalNextIsolated: (isolated (any Actor)?) async throws -> Element? + + public init(iterator: Iterator) where Iterator.Element == Element { + let box = IteratorBox(iterator) + internalNext = { try await box.iterator.next() } + internalNextIsolated = { + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + try await box.iterator.next(isolation: $0) + } else { + try await box.iterator.next() + } + } + } + + public mutating func next() async throws -> Element? { + try await internalNext() + } + + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + public func next(isolation actor: isolated (any Actor)?) async throws -> Element? { + try await internalNextIsolated(actor) + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift index bfa69285..7391ae89 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -1,8 +1,10 @@ import Foundation enum HttpClientRequestProcessor { - static let cCompatibleHttpRequest: CCallbackWithCallbackPointerAndIntReturn = { statePointer, byteArray, callbackPointer in + static let cCompatibleHttpRequest: CCallbackWithCallbackPointerAndObjectPointerReturn = { statePointer, byteArray, callbackPointer in guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + SDKResponseHandler.sendInteropErrorToSDK(message: "cCompatibleHttpRequest.statePointer was nil", + callbackPointer: callbackPointer) return 0 } let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) @@ -13,12 +15,16 @@ enum HttpClientRequestProcessor { // Create a boxed task with the HTTP work let taskBox = BoxedCancellableTask { [driveClient] in - let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) - try await HttpClientRequestProcessor.perform( - client: driveClient, - httpRequestData: httpRequestData, - callbackPointer: callbackPointer - ) + do { + let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) + try await HttpClientRequestProcessor.perform( + client: driveClient, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer + ) + } catch { + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } } // Retain the task box and return its address as the cancellation handle @@ -33,10 +39,12 @@ enum HttpClientRequestProcessor { return handle } - static let cCompatibleHttpCancellationAction: CCallback = { handle, _ in + static let cCompatibleHttpCancellationAction: CCallbackWithoutByteArray = { statePointer in // Convert the address back to the task box - guard let pointer = UnsafeRawPointer(bitPattern: Int(handle)) else { - print("Invalid cancellation handle: \(handle)") + guard let pointer = UnsafeRawPointer(bitPattern: statePointer) else { + let message = "cCompatibleHttpCancellationAction.statePointer is nil" + assertionFailure(message) + // there is no way we can inform the SDK back about the issue return } @@ -52,7 +60,7 @@ enum HttpClientRequestProcessor { httpRequestData: Proton_Sdk_HttpRequest, callbackPointer: Int ) async throws { - let requestType = client.httpClient.getRelativeDrivePath(url: httpRequestData.url, method: httpRequestData.method) + let requestType = client.httpClient.identifyRequestType(url: httpRequestData.url, method: httpRequestData.method) switch requestType { case .driveAPI(let driveRelativePath): @@ -111,43 +119,38 @@ enum HttpClientRequestProcessor { buffer.deallocate() } - let result = await client.httpClient.requestDriveApi( + let response = try await client.httpClient.requestDriveApi( method: httpRequestData.method, relativePath: driveRelativePath, content: contentData, headers: headers - ) + ).get() - switch result { - case .success(let response): - // the API calls are performed in a non-streaming way, we have whole data cached in-memory, - // so we prepare a buffer that holds everything and wrap it into offset-keeping box - let bindingsHandle: Int? - if let data = response.data, !data.isEmpty { - let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) - await uploadBuffer.copyBytes(from: data) - let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) - let pointer = Unmanaged.passRetained(bytesOrStream) - bindingsHandle = Int(rawPointer: pointer.toOpaque()) - } else { - bindingsHandle = nil - } - let httpResponse = Proton_Sdk_HttpResponse.with { - $0.headers = response.headers.map { header in - Proton_Sdk_HttpHeader.with { - $0.name = header.0 - $0.values = header.1 - } - } - if let bindingsHandle { - $0.bindingsContentHandle = Int64(bindingsHandle) + // the API calls are performed in a non-streaming way, we have whole data cached in-memory, + // so we prepare a buffer that holds everything and wrap it into offset-keeping box + let bindingsHandle: Int? + if let data = response.data, !data.isEmpty { + let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) + await uploadBuffer.copyBytes(from: data) + let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) + let pointer = Unmanaged.passRetained(bytesOrStream) + bindingsHandle = Int(rawPointer: pointer.toOpaque()) + } else { + bindingsHandle = nil + } + let httpResponse = Proton_Sdk_HttpResponse.with { + $0.headers = response.headers.map { header in + Proton_Sdk_HttpHeader.with { + $0.name = header.0 + $0.values = header.1 } - $0.statusCode = Int32(response.statusCode) } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) - case .failure(let error): - SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + if let bindingsHandle { + $0.bindingsContentHandle = Int64(bindingsHandle) + } + $0.statusCode = Int32(response.statusCode) } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) } /// the storage upload calls are using stream to upload request body, but cache the whole response in memory @@ -161,54 +164,52 @@ enum HttpClientRequestProcessor { } guard httpRequestData.hasSdkContentHandle else { - assertionFailure("Should never happen for uploads, we must have data for uploading") SDKResponseHandler.sendInteropErrorToSDK( message: "Proton_Sdk_HttpRequest.sdk_content_handle is missing", callbackPointer: callbackPointer ) return } - - let bufferLength = client.configuration.httpTransferBufferSize + + let (inputStream, outputStream, bufferLength) = try client.configuration.boundStreamsCreator() let stream = try StreamForUpload( - bufferLength: bufferLength, sdkContentHandle: httpRequestData.sdkContentHandle, logger: client.logger + inputStream: inputStream, + outputStream: outputStream, + bufferLength: bufferLength, + sdkContentHandle: httpRequestData.sdkContentHandle, + logger: client.logger ) - let result = await client.httpClient.requestUploadToStorage( + let response = try await client.httpClient.requestUploadToStorage( method: httpRequestData.method, url: httpRequestData.url, content: stream, headers: headers - ) + ).get() - switch result { - case .success(let response): - let bindingsHandle: Int? - if let data = response.data, !data.isEmpty { - let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) - await uploadBuffer.copyBytes(from: data) - let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) - let pointer = Unmanaged.passRetained(bytesOrStream) - bindingsHandle = Int(rawPointer: pointer.toOpaque()) - } else { - bindingsHandle = nil - } - let httpResponse = Proton_Sdk_HttpResponse.with { - $0.headers = response.headers.map { header in - Proton_Sdk_HttpHeader.with { - $0.name = header.0 - $0.values = header.1 - } - } - if let bindingsHandle { - $0.bindingsContentHandle = Int64(bindingsHandle) + let bindingsHandle: Int? + if let data = response.data, !data.isEmpty { + let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) + await uploadBuffer.copyBytes(from: data) + let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) + let pointer = Unmanaged.passRetained(bytesOrStream) + bindingsHandle = Int(rawPointer: pointer.toOpaque()) + } else { + bindingsHandle = nil + } + let httpResponse = Proton_Sdk_HttpResponse.with { + $0.headers = response.headers.map { header in + Proton_Sdk_HttpHeader.with { + $0.name = header.0 + $0.values = header.1 } - $0.statusCode = Int32(response.statusCode) } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) - case .failure(let error): - SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + if let bindingsHandle { + $0.bindingsContentHandle = Int64(bindingsHandle) + } + $0.statusCode = Int32(response.statusCode) } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) } /// the download upload calls are caching the whole request body in-memory, but stream the response data @@ -220,14 +221,14 @@ enum HttpClientRequestProcessor { let headers: [(String, [String])] = httpRequestData.headers.map { header in (header.name, header.values) } - + var contentData = Data() if httpRequestData.hasSdkContentHandle { // We expect that request data to be small, we need to fetch them whole let bufferLength = client.configuration.httpTransferBufferSize var buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) let baseAddress = buffer.baseAddress! - + while true { let streamReadRequest = Proton_Sdk_StreamReadRequest.with { $0.bufferLength = Int32(buffer.count) @@ -243,32 +244,28 @@ enum HttpClientRequestProcessor { } buffer.deallocate() } - - let result = await client.httpClient.requestDownloadFromStorage( + + let response = try await client.httpClient.requestDownloadFromStorage( method: httpRequestData.method, url: httpRequestData.url, content: contentData, - headers: headers - ) - - switch result { - case .success(let response): - let httpResponse = Proton_Sdk_HttpResponse.with { - $0.headers = response.headers.map { header in - Proton_Sdk_HttpHeader.with { - $0.name = header.0 - $0.values = header.1 - } + headers: headers, + downloadStreamCreator: client.configuration.downloadStreamCreator + ).get() + + let httpResponse = Proton_Sdk_HttpResponse.with { + $0.headers = response.headers.map { header in + Proton_Sdk_HttpHeader.with { + $0.name = header.0 + $0.values = header.1 } - let bytesOrStream = BoxedStreamingData(downloadStream: response.stream, logger: client.logger) - let pointer = Unmanaged.passRetained(bytesOrStream) - let bindingsHandle = Int(rawPointer: pointer.toOpaque()) - $0.bindingsContentHandle = Int64(bindingsHandle) - $0.statusCode = Int32(response.statusCode) } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) - case .failure(let error): - SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + let bytesOrStream = BoxedStreamingData(downloadStream: response.stream, logger: client.logger) + let pointer = Unmanaged.passRetained(bytesOrStream) + let bindingsHandle = Int(rawPointer: pointer.toOpaque()) + $0.bindingsContentHandle = Int64(bindingsHandle) + $0.statusCode = Int32(response.statusCode) } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) } } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift index dec4bc06..dd5b7485 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift @@ -7,48 +7,46 @@ enum HttpClientResponseProcessor { // byteArray is buffer, // callbackPointer is used for calling sdk back to let it know we've filled the buffer static let cCompatibleHttpResponseRead: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in + guard let bindingsContentHandle = UnsafeRawPointer(bitPattern: statePointer) + else { + let message = "cCompatibleHttpResponseRead.statePointer is null" + SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer) + return + } + Task { - do { - guard let bindingsContentHandle = UnsafeRawPointer(bitPattern: statePointer) - else { - assertionFailure("We must have a state pointer to perform this operation") - SDKResponseHandler.sendInteropErrorToSDK( - message: "Invalid state pointer", - callbackPointer: callbackPointer - ) - return - } - - let buffer = UnsafeMutablePointer(mutating: byteArray.pointer)! - let bufferSize = byteArray.length - - let boxedStreamingData = Unmanaged.fromOpaque(bindingsContentHandle).takeUnretainedValue() - - if let boxedRawBuffer = boxedStreamingData.uploadBuffer { - try await HttpClientResponseProcessor.passResponseBytes( - boxedRawBuffer: boxedRawBuffer, - buffer: buffer, - bufferSize: bufferSize, - callbackPointer: callbackPointer, - releaseBox: { - _ = Unmanaged.fromOpaque(bindingsContentHandle).takeRetainedValue() - } - ) - } else if let boxedDownloadStream = boxedStreamingData.downloadStream { - try await HttpClientResponseProcessor.passStream( - boxedDownloadStream: boxedDownloadStream, - buffer: buffer, - bufferSize: bufferSize, - callbackPointer: callbackPointer, - releaseBox: { - _ = Unmanaged.fromOpaque(bindingsContentHandle).takeRetainedValue() - } - ) - } else { - assertionFailure("Failed to pass valid BytesOrStream") - } - } catch { - SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + guard let buffer = UnsafeMutablePointer(mutating: byteArray.pointer) else { + let message = "cCompatibleHttpResponseRead.byteArray.pointer is null" + SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer) + return + } + let bufferSize = byteArray.length + + let boxedStreamingData = Unmanaged.fromOpaque(bindingsContentHandle).takeUnretainedValue() + + if let boxedRawBuffer = boxedStreamingData.uploadBuffer { + await HttpClientResponseProcessor.passResponseBytes( + boxedRawBuffer: boxedRawBuffer, + buffer: buffer, + bufferSize: bufferSize, + callbackPointer: callbackPointer, + releaseBox: { + _ = Unmanaged.fromOpaque(bindingsContentHandle).takeRetainedValue() + } + ) + } else if let boxedDownloadStream = boxedStreamingData.downloadStream { + await HttpClientResponseProcessor.passStream( + boxedDownloadStream: boxedDownloadStream, + buffer: buffer, + bufferSize: bufferSize, + callbackPointer: callbackPointer, + releaseBox: { + _ = Unmanaged.fromOpaque(bindingsContentHandle).takeRetainedValue() + } + ) + } else { + SDKResponseHandler.sendInteropErrorToSDK(message: "Failed to pass valid BytesOrStream", + callbackPointer: callbackPointer) } } } @@ -60,15 +58,19 @@ enum HttpClientResponseProcessor { bufferSize: Int, callbackPointer: Int, releaseBox: () -> Void - ) async throws { - let (data, receivedBytes) = try await boxedDownloadStream.read(upTo: bufferSize) - data.copyBytes(to: buffer, count: receivedBytes) - let message = Google_Protobuf_Int32Value.with { - $0.value = Int32(receivedBytes) - } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: message) - if bufferSize > receivedBytes { - releaseBox() + ) async { + do { + let (data, receivedBytes) = try await boxedDownloadStream.read(upTo: bufferSize) + data.copyBytes(to: buffer, count: receivedBytes) + let message = Google_Protobuf_Int32Value.with { + $0.value = Int32(receivedBytes) + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: message) + if bufferSize > receivedBytes { + releaseBox() + } + } catch { + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) } } @@ -78,7 +80,7 @@ enum HttpClientResponseProcessor { bufferSize: Int, callbackPointer: Int, releaseBox: () -> Void - ) async throws { + ) async { let copiedBytesCount = await boxedRawBuffer.copyBytes(to: buffer, count: bufferSize) let message = Google_Protobuf_Int32Value.with { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift index 50020c60..28027296 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift @@ -7,12 +7,12 @@ final class BoxedCancellableTask: @unchecked Sendable { private var task: Task? private var onComplete: (() -> Void)? - init(work: @escaping @Sendable () async throws -> Void) { - task = Task { [weak self] in + init(work: @escaping @Sendable () async -> Void) { + self.task = Task { [weak self] in defer { self?.complete() } - try? await work() + await work() } } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift index b127d562..862651a9 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift @@ -1,12 +1,12 @@ import Foundation final class BoxedDownloadStream { - private let stream: URLSession.AsyncBytes - private var iterator: URLSession.AsyncBytes.AsyncIterator + private let stream: AnyAsyncSequence + private var iterator: AnyAsyncIterator private let logger: Logger - init(stream: URLSession.AsyncBytes, logger: Logger) { + init(stream: AnyAsyncSequence, logger: Logger) { self.stream = stream self.iterator = stream.makeAsyncIterator() self.logger = logger diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift index ac05f3e3..dbbb1007 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift @@ -26,7 +26,13 @@ final class BoxedRawBuffer { } func copyBytes(from data: Data) { - data.copyBytes(to: buffer) + let copiedBytes = data.copyBytes(to: buffer) + guard copiedBytes == data.count else { + assertionFailure("We should copy all the bytes") + logger.error("[BoxedRawBuffer.copyBytes] Failed to copy all the bytes", + category: "BoxedRawBuffer.copyBytes") + return + } } func copyBytes(to buffer: UnsafeMutablePointer, count bufferSize: Int) -> Int { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift index 8732efcc..382b7381 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift @@ -12,7 +12,7 @@ final class BoxedStreamingData { self.logger = logger } - init(downloadStream stream: URLSession.AsyncBytes, logger: Logger) { + init(downloadStream stream: AnyAsyncSequence, logger: Logger) { self.uploadBuffer = nil self.downloadStream = BoxedDownloadStream(stream: stream, logger: logger) self.logger = logger diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift index e1001f40..bf00df78 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift @@ -3,11 +3,12 @@ import Foundation public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendable { public let input: InputStream - public let output: OutputStream + let output: OutputStream + + public var onStreamError: (Error) -> Void = { _ in } let sdkContentHandle: Int64 let logger: Logger - public var onStreamError: (Error) -> Void = { _ in } let buffer: UnsafeMutableRawBufferPointer let bufferLength: Int @@ -21,27 +22,16 @@ public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendabl private var state: State = .initialized private let stateQueue = DispatchQueue(label: "StreamForUpload.StateQueue", qos: .userInitiated) - public var hasStartedWriting: Bool { - stateQueue.sync { state != .initialized } - } private var remainingBytes: [UInt8] = [] private let writingQueue = DispatchQueue(label: "StreamForUpload.WritingQueue", qos: .userInitiated) - init(bufferLength: Int, sdkContentHandle: Int64, logger: Logger) throws { - var inputOrNil: InputStream? = nil - var outputOrNil: OutputStream? = nil - Stream.getBoundStreams(withBufferSize: bufferLength, - inputStream: &inputOrNil, - outputStream: &outputOrNil) - guard let input = inputOrNil, let output = outputOrNil else { - throw ProtonDriveSDKError(interopError: .wrongResult(message: "Cannot make stream")) - } + init(inputStream: InputStream, outputStream: OutputStream, bufferLength: Int, sdkContentHandle: Int64, logger: Logger) throws { self.bufferLength = bufferLength self.sdkContentHandle = sdkContentHandle self.logger = logger - self.input = input - self.output = output + self.input = inputStream + self.output = outputStream self.buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) super.init() } @@ -53,7 +43,7 @@ public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendabl } public func stream(_ aStream: Stream, handle eventCode: Stream.Event) { - guard aStream == output else { return } + guard aStream == output.outputStream else { return } if eventCode.contains(.hasSpaceAvailable) { receivedHasSpaceAvailableEvent() @@ -192,3 +182,7 @@ public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendabl buffer.deallocate() } } + +extension OutputStream { + @objc open var outputStream: OutputStream { self } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 25376683..fb309631 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -20,13 +20,9 @@ public actor ProtonDriveClient: Sendable { let configuration: ProtonDriveClientConfiguration public init( - baseURL: String, - entityCachePath: String? = nil, - secretCachePath: String? = nil, + configuration: ProtonDriveClientConfiguration, httpClient: HttpClientProtocol, accountClient: AccountClientProtocol, - clientUID: String?, - configuration: ProtonDriveClientConfiguration = .default, logCallback: @escaping LogCallback, recordMetricEventCallback: @escaping RecordMetricEventCallback, featureFlagProviderCallback: @escaping FeatureFlagProviderCallback @@ -40,7 +36,9 @@ public actor ProtonDriveClient: Sendable { self.configuration = configuration let clientCreateRequest = Proton_Drive_Sdk_DriveClientCreateRequest.with { - $0.baseURL = baseURL + $0.baseURL = configuration.baseURL + + $0.uid = configuration.clientUID $0.accountRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) @@ -57,15 +55,12 @@ public actor ProtonDriveClient: Sendable { $0.featureEnabledFunction = Int64(ObjectHandle(callback: cCompatibleFeatureFlagProviderCallback)) - if let entityCachePath { + if let entityCachePath = configuration.entityCachePath { $0.entityCachePath = entityCachePath } - if let secretCachePath { + if let secretCachePath = configuration.secretCachePath { $0.secretCachePath = secretCachePath } - if let clientUID { - $0.uid = clientUID - } } // we pass the weak reference as the state because we don't want the interop layer @@ -90,10 +85,6 @@ public actor ProtonDriveClient: Sendable { recordMetricEventCallback(metricEvent) } -// nonisolated func isFlagEnabled(_ flagName: String) async -> Bool { -// await featureFlagProviderCallback(flagName) -// } - nonisolated func isFlagEnabled(_ flagName: String) -> Bool { // Since the C# callback expects a synchronous return but our Swift callback has completion block, // we need to block and wait for the async result using a semaphore @@ -177,7 +168,6 @@ public actor ProtonDriveClient: Sendable { ) async throws -> String { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) defer { - // TODO: Should be done in deinit! cancellationTokenSource.free() } @@ -201,12 +191,8 @@ public actor ProtonDriveClient: Sendable { static func unbox(callbackPointer: Int, releaseBox: () -> Void, weakDriveClient: WeakReference) -> ProtonDriveClient? { guard let driveClient = weakDriveClient.value else { releaseBox() - let error = Proton_Sdk_Error.with { - $0.type = "sdk_error" - $0.domain = Proton_Sdk_ErrorDomain.api - $0.message = "account client callback called after the proton client object was deallocated" - } - SDKResponseHandler.send(callbackPointer: callbackPointer, message: error) + let message = "account client callback called after the proton client object was deallocated" + SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer) return nil } return driveClient diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift index 3517e31a..f2674b3c 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift @@ -1,13 +1,52 @@ +import Foundation + public struct ProtonDriveClientConfiguration: Sendable { #if os(iOS) - public static let `default` = ProtonDriveClientConfiguration(httpTransferBufferSize: 64 * 1024) + @usableFromInline static let defaultHttpTransportBufferSize = 64 * 1024 #else - public static let `default` = ProtonDriveClientConfiguration(httpTransferBufferSize: 4 * 1024 * 1024) + @usableFromInline static let defaultHttpTransportBufferSize = 4 * 1024 * 1024 #endif - + + public static let defaultBoundStreamsCreator: @Sendable () throws -> (InputStream, OutputStream, Int) = { + let bufferSize = defaultHttpTransportBufferSize + var inputOrNil: InputStream? = nil + var outputOrNil: OutputStream? = nil + Stream.getBoundStreams(withBufferSize: bufferSize, + inputStream: &inputOrNil, + outputStream: &outputOrNil) + guard let input = inputOrNil, let output = outputOrNil else { + throw ProtonDriveSDKError(interopError: .wrongResult(message: "Cannot make stream")) + } + return (input, output, bufferSize) + } + + @usableFromInline static let defaultDownloadStreamCreator: @Sendable (URLSession.AsyncBytes) -> AnyAsyncSequence = AnyAsyncSequence.init + + let baseURL: String + let clientUID: String let httpTransferBufferSize: Int // Used for establishing buffer for http streams + + let entityCachePath: String? + let secretCachePath: String? + + let boundStreamsCreator: @Sendable () throws -> (InputStream, OutputStream, Int) + let downloadStreamCreator: @Sendable (URLSession.AsyncBytes) -> AnyAsyncSequence - public init(httpTransferBufferSize: Int) { + public init( + baseURL: String, + clientUID: String, + httpTransferBufferSize: Int = defaultHttpTransportBufferSize, + boundStreamsCreator: @Sendable @escaping () throws -> (InputStream, OutputStream, Int) = defaultBoundStreamsCreator, + downloadStreamCreator: @Sendable @escaping (URLSession.AsyncBytes) -> AnyAsyncSequence = defaultDownloadStreamCreator, + entityCachePath: String? = nil, + secretCachePath: String? = nil + ) { + self.baseURL = baseURL + self.clientUID = clientUID self.httpTransferBufferSize = httpTransferBufferSize + self.boundStreamsCreator = boundStreamsCreator + self.downloadStreamCreator = downloadStreamCreator + self.entityCachePath = entityCachePath + self.secretCachePath = secretCachePath } } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift index 81eca85a..eea05dea 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift @@ -1,10 +1,11 @@ import Foundation +import SwiftProtobuf // MARK: - Swift Types (hiding protobuf implementation) public struct ProtonDriveSDKError: LocalizedError, Sendable { - public enum Domain: Sendable { + public enum Domain: Sendable, Equatable { // SDK domains case undefined case successfulCancellation @@ -19,6 +20,21 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { // Interop domains case interop + var toProton_Sdk_ErrorDomain: Proton_Sdk_ErrorDomain { + switch self { + case .undefined: return .undefined + case .successfulCancellation: return .successfulCancellation + case .api: return .api + case .network: return .network + case .transport: return .transport + case .serialization: return .serialization + case .cryptography: return .cryptography + case .dataIntegrity: return .dataIntegrity + case .businessLogic: return .businessLogic + case .interop: return .undefined + } + } + init(interopErrorDomain: Proton_Sdk_ErrorDomain) { switch interopErrorDomain { case .undefined: self = .undefined @@ -84,6 +100,43 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { private let innerErrorBox: InnerErrorBox? + var asProton_Sdk_Error: Proton_Sdk_Error { + Proton_Sdk_Error.with { + $0.type = type + $0.domain = domain.toProton_Sdk_ErrorDomain + $0.message = message + if let primaryCode { + $0.primaryCode = Int64(primaryCode) + } + if let secondaryCode { + $0.secondaryCode = Int64(secondaryCode) + } + if let context { + $0.context = context + } + if let innerError = innerErrorBox?.innerError.asProton_Sdk_Error { + $0.innerError = innerError + } + switch additionalErrorData { + case .some(let data as NodeNameConflictErrorData): + let errorData = Proton_Drive_Sdk_NodeNameConflictErrorData.with { + $0.conflictingNodeIsFileDraft = data.isFileDraft + if let conflictingNodeId = data.nodeUID { + $0.conflictingNodeUid = conflictingNodeId.sdkCompatibleIdentifier + } + if let conflictingRevisionUid = data.revisionUID { + $0.conflictingRevisionUid = conflictingRevisionUid.sdkCompatibleIdentifier + } + } + if let additionalData = try? Google_Protobuf_Any(message: errorData) { + $0.additionalData = additionalData + } + + case .some, nil: break + } + } + } + init(protoError: Proton_Sdk_Error) { if !(protoError.hasMessage && protoError.hasType && protoError.hasDomain) { assertionFailure("Type, message, and domain are non-optional in Proton_Sdk_Error proto") diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift index 1f71cc0a..d3c8c495 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift @@ -27,6 +27,9 @@ extension LogLevel { let cCompatibleLogCallback: CCallback = { statePointer, byteArray in guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + let message = "cCompatibleLogCallback.statePointer is nil" + assertionFailure(message) + // there is no way we can inform the SDK back about the issue return } diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift index e36200d6..bf8168a6 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift @@ -2,6 +2,9 @@ import Foundation let cCompatibleTelemetryRecordMetricCallback: CCallback = { statePointer, byteArray in guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + let message = "cCompatibleTelemetryRecordMetricCallback.statePointer is nil" + assertionFailure(message) + // there is no way we can inform the SDK back about the issue return } From 7e9cb4d4e74b4b41c6c116ebcfdb9ef60f1c4c32 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 13:23:01 +0000 Subject: [PATCH 366/791] Set error type to the name of the Kotlin exception --- .../main/kotlin/me/proton/drive/sdk/extension/Throwable.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt index e695b13e..7eaea108 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -7,8 +7,8 @@ import proton.sdk.ProtonSdk fun Throwable.toProtonSdkError(defaultMessage: String) = proton.sdk.error { val exception = this@toProtonSdkError - type = javaClass.name - this.message = exception.message ?: defaultMessage + type = exception.javaClass.name + message = exception.message ?: defaultMessage domain = exception.domain() context = stackTraceToString() } From 1d57c5e1e1d15b6d259f58c257c8e41bbf6187dc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 16:39:22 +0100 Subject: [PATCH 367/791] Reduce log level and normalize logs --- .../src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt | 5 +++-- .../proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt index 415a6ed1..e1ec16f4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk.internal -import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider +import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE import me.proton.drive.sdk.SdkLogger import java.nio.ByteBuffer @@ -8,7 +9,7 @@ typealias ResponseCallback = (ByteBuffer) -> Unit abstract class JniBase { - open val logger: (String) -> Unit = { message -> globalSdkLogger(DEBUG, "internal", message) } + open val logger: (String) -> Unit = { message -> globalSdkLogger(VERBOSE, "internal", message) } internal fun method(name: String) = "${this.javaClass.simpleName}::$name" diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 39dbab91..3c433b46 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -252,7 +252,7 @@ class ProtonDriveSdkNativeClient internal constructor( block: suspend (T) -> Unit ) { try { - logger("callback for $name of size: ${data.capacity()}") + logger("$callback for $name of size: ${data.capacity()}") // parsing of protobuf needs to be done serially val value = parser(data) coroutineScope(callback).launch { From 917490329b67a4030fed74c4f42fb1baaf8cc4ab Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 17:32:55 +0000 Subject: [PATCH 368/791] Attach current thread only when detached --- kt/sdk/src/main/jni/global.c | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/kt/sdk/src/main/jni/global.c b/kt/sdk/src/main/jni/global.c index 00a122dc..61496915 100644 --- a/kt/sdk/src/main/jni/global.c +++ b/kt/sdk/src/main/jni/global.c @@ -15,11 +15,17 @@ jint JNI_OnLoad(JavaVM *vm, void *reserved) { } JNIEnv *getJNIEnv() { - JNIEnv *env; - (*g_vm)->GetEnv(g_vm, (void **) &env, JNI_VERSION_1_6 /*version*/); - if (env == NULL) { - (*g_vm)->AttachCurrentThread(g_vm, &env, NULL); + JNIEnv* env = NULL; + jint status = (*g_vm)->GetEnv(g_vm, (void**)&env, JNI_VERSION_1_6); + + if (status == JNI_EDETACHED) { + if ((*g_vm)->AttachCurrentThread(g_vm, &env, NULL) != 0) { + return NULL; + } + } else if (status == JNI_EVERSION) { + return NULL; } + return env; } From 6d3b1d5ca71d94aaf32daa93c58c3ffafe9de760 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 07:59:55 +0100 Subject: [PATCH 369/791] Fix photo node type --- js/sdk/src/internal/photos/nodes.test.ts | 258 +++++++++++++++++++++++ js/sdk/src/internal/photos/nodes.ts | 1 + 2 files changed, 259 insertions(+) create mode 100644 js/sdk/src/internal/photos/nodes.test.ts diff --git a/js/sdk/src/internal/photos/nodes.test.ts b/js/sdk/src/internal/photos/nodes.test.ts new file mode 100644 index 00000000..ea6eadfc --- /dev/null +++ b/js/sdk/src/internal/photos/nodes.test.ts @@ -0,0 +1,258 @@ +import { MemoryCache } from '../../cache'; +import { NodeType, MemberRole } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { DriveAPIService } from '../apiService'; +import { PhotosNodesAPIService, PhotosNodesCache, PhotosNodesAccess, PhotosNodesCryptoService } from './nodes'; + +function generateAPINode() { + return { + Link: { + LinkID: 'linkId', + ParentLinkID: 'parentLinkId', + NameHash: 'nameHash', + CreateTime: 123456789, + ModifyTime: 1234567890, + TrashTime: 0, + Name: 'encName', + SignatureEmail: 'sigEmail', + NameSignatureEmail: 'nameSigEmail', + NodeKey: 'nodeKey', + NodePassphrase: 'nodePass', + NodePassphraseSignature: 'nodePassSig', + }, + SharingSummary: null, + Sharing: null, + Membership: null, + }; +} + +function generateAPIFolderNode(linkOverrides = {}, overrides = {}) { + const node = generateAPINode(); + return { + ...node, + Link: { ...node.Link, Type: 1, ...linkOverrides }, + Folder: { XAttr: '{folder}', NodeHashKey: 'nodeHashKey' }, + Photo: null, + ...overrides, + }; +} + +function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) { + const node = generateAPINode(); + return { + ...node, + Link: { ...node.Link, Type: 3, ...linkOverrides }, + Photo: null, + Folder: null, + ...overrides, + }; +} + +function generateAPIPhotoNode(linkOverrides = {}, overrides = {}) { + const node = generateAPINode(); + return { + ...node, + Link: { ...node.Link, Type: 2, ...linkOverrides }, + Photo: { + CaptureTime: 1700000000, + MainPhotoLinkID: null, + RelatedPhotosLinkIDs: [], + ContentHash: 'contentHash123', + Tags: [1, 2], + Albums: [ + { + AlbumLinkID: 'albumLinkId1', + AddedTime: 1700001000, + Hash: 'albumHash', + ContentHash: 'albumContentHash', + }, + ], + ActiveRevision: { + RevisionID: 'revisionId', + CreateTime: 1234567890, + SignatureEmail: 'revSigEmail', + XAttr: '{photo}', + EncryptedSize: 12, + }, + MediaType: 'image/jpeg', + ContentKeyPacket: 'contentKeyPacket', + ContentKeyPacketSignature: 'contentKeyPacketSig', + }, + Folder: null, + ...overrides, + }; +} + +describe('PhotosNodesAPIService', () => { + let apiMock: DriveAPIService; + let api: PhotosNodesAPIService; + + beforeEach(() => { + // @ts-expect-error Mocking for testing purposes + apiMock = { + post: jest.fn(), + }; + api = new PhotosNodesAPIService(getMockLogger(), apiMock, 'clientUid'); + }); + + describe('linkToEncryptedNode', () => { + async function testIterateNodes(mockedLink: object, expectedType: NodeType) { + apiMock.post = jest.fn().mockResolvedValue({ Links: [mockedLink] }); + + const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId')); + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe(expectedType); + } + + it('should convert folder (type 1) to folder node', async () => { + await testIterateNodes(generateAPIFolderNode(), NodeType.Folder); + }); + + it('should convert album (type 3) to album node', async () => { + await testIterateNodes(generateAPIAlbumNode(), NodeType.Album); + }); + + it('should convert photo (type 2) to photo node with photo attributes', async () => { + apiMock.post = jest.fn().mockResolvedValue({ Links: [generateAPIPhotoNode()] }); + + const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId')); + + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe(NodeType.Photo); + expect(nodes[0].photo).toBeDefined(); + expect(nodes[0].photo?.captureTime).toEqual(new Date(1700000000 * 1000)); + expect(nodes[0].photo?.tags).toEqual([1, 2]); + expect(nodes[0].photo?.albums).toHaveLength(1); + expect(nodes[0].photo?.albums[0].nodeUid).toBe('volumeId~albumLinkId1'); + expect(nodes[0].photo?.albums[0].additionTime).toEqual(new Date(1700001000 * 1000)); + }); + }); +}); + +describe('PhotosNodesCache', () => { + let cache: PhotosNodesCache; + + beforeEach(() => { + const memoryCache = new MemoryCache(); + cache = new PhotosNodesCache(getMockLogger(), memoryCache); + }); + + describe('deserialiseNode', () => { + it('should convert photo attributes dates from strings to Date objects', () => { + const serialisedNode = JSON.stringify({ + uid: 'volumeId~linkId', + parentUid: 'volumeId~parentLinkId', + type: NodeType.Photo, + directRole: MemberRole.Admin, + isShared: false, + isSharedPublicly: false, + creationTime: '2023-11-14T22:13:20.000Z', + modificationTime: '2023-11-14T22:13:20.000Z', + photo: { + captureTime: '2023-11-14T22:13:20.000Z', + mainPhotoNodeUid: undefined, + relatedPhotoNodeUids: [], + tags: [1], + albums: [ + { + nodeUid: 'volumeId~albumId', + additionTime: '2023-11-15T10:00:00.000Z', + }, + ], + }, + }); + + const node = cache.deserialiseNode(serialisedNode); + + expect(node.photo).toBeDefined(); + expect(node.photo?.captureTime).toBeInstanceOf(Date); + expect(node.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z')); + expect(node.photo?.albums[0].additionTime).toBeInstanceOf(Date); + expect(node.photo?.albums[0].additionTime).toEqual(new Date('2023-11-15T10:00:00.000Z')); + }); + + it('should handle node without photo attributes', () => { + const serialisedNode = JSON.stringify({ + uid: 'volumeId~linkId', + parentUid: 'volumeId~parentLinkId', + type: NodeType.Folder, + directRole: MemberRole.Admin, + isShared: false, + isSharedPublicly: false, + creationTime: '2023-11-14T22:13:20.000Z', + modificationTime: '2023-11-14T22:13:20.000Z', + }); + + const node = cache.deserialiseNode(serialisedNode); + + expect(node.photo).toBeUndefined(); + }); + }); +}); + +describe('PhotosNodesAccess', () => { + describe('parseNode', () => { + it('should keep photo type and add photo object', async () => { + const telemetry = getMockTelemetry(); + + // @ts-expect-error Mocking for testing purposes + const cryptoService: PhotosNodesCryptoService = {}; + // @ts-expect-error Mocking for testing purposes + const apiService: PhotosNodesAPIService = {}; + // @ts-expect-error Mocking for testing purposes + const cacheService: PhotosNodesCache = {}; + // @ts-expect-error Mocking for testing purposes + const cryptoCache: NodesCryptoCache = {}; + // @ts-expect-error Mocking for testing purposes + const sharesService: SharesService = {}; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nodesAccess = new PhotosNodesAccess(telemetry, apiService, cacheService, cryptoCache, cryptoService, sharesService); + + const unparsedNode = { + uid: 'volumeId~linkId', + parentUid: 'volumeId~parentLinkId', + type: NodeType.Photo, + name: 'photo.jpg', + hash: 'hash123', + directRole: MemberRole.Admin, + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: new Date(), + trashTime: undefined, + mediaType: 'image/jpeg', + folder: undefined, + file: { + activeRevision: { + uid: 'revisionId', + state: 'active' as const, + creationTime: new Date(), + storageSize: 100, + signatureEmail: 'test@example.com', + claimedModificationTime: new Date(), + claimedSize: 100, + claimedDigests: { sha1: 'sha1hash' }, + claimedBlockSizes: [100], + }, + }, + photo: { + captureTime: new Date('2023-11-14T22:13:20.000Z'), + mainPhotoNodeUid: undefined, + relatedPhotoNodeUids: [], + tags: [1, 2], + albums: [], + }, + }; + + // @ts-expect-error Accessing protected method for testing + const parsedNode = nodesAccess.parseNode(unparsedNode); + + expect(parsedNode.type).toBe(NodeType.Photo); + expect(parsedNode.photo).toBeDefined(); + expect(parsedNode.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z')); + expect(parsedNode.photo?.tags).toEqual([1, 2]); + }); + }); +}); diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 5e5a4f12..90db6965 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -186,6 +186,7 @@ export class PhotosNodesAccess extends NodesAccessBase Date: Thu, 11 Dec 2025 06:30:35 +0000 Subject: [PATCH 370/791] i18n: Upgrade translations from crowdin (5f7f1f9c). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 80 +++++++++++----- js/sdk/locales/ca_ES.json | 78 +++++++++++----- js/sdk/locales/de_DE.json | 64 ++++++++++--- js/sdk/locales/el_GR.json | 64 ++++++++++--- js/sdk/locales/es_ES.json | 64 ++++++++++--- js/sdk/locales/es_LA.json | 64 ++++++++++--- js/sdk/locales/fr_FR.json | 64 ++++++++++--- js/sdk/locales/it_IT.json | 64 ++++++++++--- js/sdk/locales/ko_KR.json | 67 +++++++++++--- js/sdk/locales/nl_NL.json | 54 +++++++---- js/sdk/locales/pl_PL.json | 55 ++++++++--- js/sdk/locales/pt_BR.json | 61 +++++++++--- js/sdk/locales/pt_PT.json | 27 +++--- js/sdk/locales/ro_RO.json | 64 ++++++++++--- js/sdk/locales/ru_RU.json | 45 +++++---- js/sdk/locales/sk_SK.json | 64 ++++++++++--- js/sdk/locales/tr_TR.json | 128 +++++++++++++++++--------- 18 files changed, 817 insertions(+), 292 deletions(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index 0193cb6d..34aadc06 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "ab71f3ec8d4f22b3ab271f9d56fe8e5e5b66b67b" + "locale": "cb6edd8bdaffd29c51f6ebbb49e99cd385bd9d0e" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index e5646c34..4c7ae314 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } з некалькіх раздзелаў забаронена" + "Bookmark password is not available": [ + "Пароль закладкі недаÑтупны" ], "Cannot download a folder": [ "Ðемагчыма Ñпампаваць папку" @@ -14,11 +14,17 @@ "Cannot share root folder": [ "Ðемагчыма абагуліць каранёвую папку" ], + "Copy operation aborted": [ + "ÐÐ¿ÐµÑ€Ð°Ñ†Ñ‹Ñ ÐºÐ°Ð¿Ñ–ÑÐ²Ð°Ð½Ð½Ñ Ð¿ÐµÑ€Ð°Ñ€Ð²Ð°Ð½Ð°" + ], + "Copying item to a non-folder is not allowed": [ + "КапіÑванне Ñлементаў у аÑÑроддзі, Ñкое не належыць папкам забаронена" + ], "Creating files in non-folders is not allowed": [ - "Забаронена Ñтвараць файлы Ñž папках, ÑÐºÑ–Ñ Ð½Ðµ з'ÑўлÑюцца папкамі" + "СтварÑнне файлаў у аÑÑроддзі, Ñкое не належыць папкам забаронена" ], "Creating folders in non-folders is not allowed": [ - "Забаронена Ñтвараць папкі у Ñлементах, ÑÐºÑ–Ñ Ð½Ðµ з'ÑўлÑюцца папкамі" + "СтварÑнне папак у аÑÑроддзі, Ñкое не належыць папкам забаронена" ], "Creating revisions in non-files is not allowed": [ "Забаронена Ñтвараць Ñ€Ñдакцыі Ñž файлах, ÑÐºÑ–Ñ Ð½Ðµ з'ÑўлÑюцца файламі" @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць блок: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць ключ закладкі: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць назву закладкі: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць пароль закладкі: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць ключ змеÑціва: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць мініÑцюру: ${ message }" ], + "Failed to get inviter keys": [ + "Ðе ўдалоÑÑ Ð°Ñ‚Ñ€Ñ‹Ð¼Ð°Ñ†ÑŒ ключы запрашальніка" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Ðе ўдалоÑÑ Ð°Ñ‚Ñ€Ñ‹Ð¼Ð°Ñ†ÑŒ звеÑткі аб абагульванні Ð´Ð»Ñ Ð²ÑƒÐ·Ð»Ð° ${ nodeUid }" + ], "Failed to load some nodes": [ "Ðе ўдалоÑÑ Ð·Ð°Ð³Ñ€ÑƒÐ·Ñ–Ñ†ÑŒ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ Ð²ÑƒÐ·Ð»Ñ‹" ], + "Failed to verify invitation": [ + "Ðе ўдалоÑÑ Ñпраўдзіць запрашÑнне" + ], "File download failed due to empty response": [ "Спампоўка файла не атрымалаÑÑ Ð¿Ð° прычыне пуÑтога адказу" ], @@ -68,6 +92,9 @@ "File has no content key": [ "Файл не мае ключа змеÑціва" ], + "Invalid URL": [ + "Памылковы URL-адраÑ" + ], "Invitation not found": [ "ЗапрашÑнне не знойдзена" ], @@ -75,14 +102,14 @@ "Ðемагчыма раÑшыфраваць Ñлемент" ], "Legacy public link cannot be updated. Please re-create a new public link.": [ - "Ðемагчыма абнавіць Ñтарую публічную ÑпаÑылку. Калі лаÑка, Ñтварыце новую публічную ÑпаÑылку." - ], - "Loading thumbnails from multiple sections is not allowed": [ - "Загрузка мініÑцюр з некалькіх раздзелаў забаронена" + "Ðемагчыма абнавіць Ñтарую публічную ÑпаÑылку. Стварыце новую публічную ÑпаÑылку." ], "Missing integrity signature": [ "ÐдÑутнічае Ð¿Ð¾Ð´Ð¿Ñ–Ñ Ñ†ÑлаÑнаÑці" ], + "Missing inviter email": [ + "ÐдÑутнічае Ð°Ð´Ñ€Ð°Ñ Ñлектроннай пошты запрашальніка" + ], "Missing signature": [ "ÐдÑутнічае подпіÑ" ], @@ -93,7 +120,7 @@ "ÐÐ¿ÐµÑ€Ð°Ñ†Ñ‹Ñ Ð¿ÐµÑ€Ð°Ð¼ÑшчÑÐ½Ð½Ñ Ð¿ÐµÑ€Ð°Ñ€Ð²Ð°Ð½Ð°" ], "Moving item to a non-folder is not allowed": [ - "Забаронена перамÑшчаць Ñлементы Ñž папкі, ÑÐºÑ–Ñ Ð½Ðµ з'ÑўлÑюцца папкамі" + "ПерамÑшчÑнне Ñлементаў у аÑÑроддзі, Ñкое не належыць папка забаронена" ], "Moving root item is not allowed": [ "ПерамÑшчаць каранёвы Ñлемент забаронена" @@ -110,6 +137,9 @@ "Name must not contain the character '/'": [ "Ðазва не павінна змÑшчаць Ñімвал '/'" ], + "No available name found": [ + "ДаÑÑ‚ÑƒÐ¿Ð½Ð°Ñ Ð½Ð°Ð·Ð²Ð° не знойдзена" + ], "Node has no thumbnail": [ "У вузла адÑутнічае мініÑцюра" ], @@ -137,11 +167,20 @@ "Request aborted": [ "Запыт перарваны" ], + "Signature is missing": [ + "ÐŸÐ¾Ð´Ð¿Ñ–Ñ Ð°Ð´Ñутнічае" + ], "Signature verification failed": [ - "Ðе ўдалоÑÑ Ð¿Ñ€Ð°Ð²ÐµÑ€Ñ‹Ñ†ÑŒ подпіÑ" + "Ðе ўдалоÑÑ Ñпраўдзіць подпіÑ" + ], + "Signature verification failed: ${ errorMessage }": [ + "Ðе ўдалоÑÑ Ñпраўдзіць подпіÑ: ${ errorMessage }" ], "Signature verification for ${ signatureType } failed": [ - "Збой праверкі подпіÑу Ð´Ð»Ñ ${ signatureType }" + "Збой ÑÐ¿Ñ€Ð°ÑžÐ´Ð¶Ð°Ð½Ð½Ñ Ð¿Ð¾Ð´Ð¿Ñ–Ñу Ð´Ð»Ñ ${ signatureType }" + ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Збой ÑÐ¿Ñ€Ð°ÑžÐ´Ð¶Ð°Ð½Ð½Ñ Ð¿Ð¾Ð´Ð¿Ñ–Ñу Ð´Ð»Ñ ${ signatureType }: ${ errorMessage }" ], "Some file bytes failed to upload": [ "Збой Ð·Ð°Ð¿Ð°Ð¼Ð¿Ð¾ÑžÐ²Ð°Ð½Ð½Ñ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ… байтаў файла" @@ -168,24 +207,18 @@ "ÐевÑÐ´Ð¾Ð¼Ð°Ñ Ð¿Ð°Ð¼Ñ‹Ð»ÐºÐ° ${ response.Response.Code }" ], "Verification keys are not available": [ - "Ключы праверкі недаÑтупны" + "Ключы ÑÐ¿Ñ€Ð°ÑžÐ´Ð¶Ð°Ð½Ð½Ñ Ð½ÐµÐ´Ð°Ñтупны" ], "Verification keys for ${ signatureType } are not available": [ - "Ключы праверкі Ð´Ð»Ñ ${ signatureType } недаÑтупны" + "Ключы ÑÐ¿Ñ€Ð°ÑžÐ´Ð¶Ð°Ð½Ð½Ñ Ð´Ð»Ñ ${ signatureType } недаÑтупны" ], "You can leave only item that is shared with you": [ "Ð’Ñ‹ можаце пакінуць толькі Ñлемент, Ñкі абагулены з вамі" ] }, - "Operation": { - "Deleting items": [ - "Выдаленне Ñлементаў" - ], - "Restoring items": [ - "Ðднаўленне Ñлементаў" - ], - "Trashing items": [ - "Ð’Ñ‹Ð´Ð°Ð»ÐµÐ½Ñ‹Ñ Ñлементы" + "Info": { + "Author is not provided on public link": [ + "Ðўтар не пазначаны Ñž публічнай ÑпаÑылцы" ] }, "Property": { @@ -201,6 +234,9 @@ "key": [ "ключ" ], + "membership": [ + "членÑтва" + ], "name": [ "назва" ] diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index b80b9b4a..2cc812c7 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "No es permet ${ operationForErrorMessage } des de diverses seccions" + "Bookmark password is not available": [ + "La contrasenya d'adreces d'interès no està disponible." ], "Cannot download a folder": [ "No es pot descarregar una carpeta" @@ -14,6 +14,12 @@ "Cannot share root folder": [ "No es pot compartir la carpeta arrel" ], + "Copy operation aborted": [ + "S'ha cancel·lat l'operació de còpia" + ], + "Copying item to a non-folder is not allowed": [ + "No està permès copiar un element a un lloc que no és una carpeta" + ], "Creating files in non-folders is not allowed": [ "No es permet crear carpetes en llocs que no són carpetes" ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "No s'ha pogut desxifrar el bloc: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "No s'ha pogut desxifrar la clau d'adreces d'interès: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "No s'ha pogut desxifrar el nom de l'adreça d'interès: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "No s'ha pogut desxifrar la contrasenya d'adreces d'interès: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "No s'ha pogut desxifrar la clau de contingut: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "No s'ha pogut desxifrar la miniatura: ${ message }" ], + "Failed to get inviter keys": [ + "No s'han pogut obtenir les claus de qui us convida" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "No s'ha pogut obtenir la informació de compartició per al node ${ nodeUid }" + ], "Failed to load some nodes": [ "No s'han pogut carregar alguns nodes" ], + "Failed to verify invitation": [ + "No s'ha pogut verificar la invitació" + ], "File download failed due to empty response": [ "La descàrrega del fitxer ha fallat a causa d'una resposta buida." ], @@ -68,21 +92,24 @@ "File has no content key": [ "El fitxer no té clau de contingut" ], + "Invalid URL": [ + "L'URL no és vàlid" + ], "Invitation not found": [ - "No s'ha pogut trobar la invitació" + "No s'ha trobat la invitació" ], "Item cannot be decrypted": [ "No s'ha pogut desxifrar l'element" ], "Legacy public link cannot be updated. Please re-create a new public link.": [ - "L'enllaç públic heretat no es pot actualitzar. Torneu a crear-ne un de nou." - ], - "Loading thumbnails from multiple sections is not allowed": [ - "No està permès carregar miniatures de diverses seccions" + "L'enllaç públic antic no es pot actualitzar. Torneu a crear-ne un de nou." ], "Missing integrity signature": [ "No s'ha trobat la signatura d'integritat" ], + "Missing inviter email": [ + "Falta el correu electrònic de qui us convida" + ], "Missing signature": [ "No s'ha pogut trobar la signatura" ], @@ -108,23 +135,26 @@ "Name must not contain the character '/'": [ "El nom no pot contenir el caràcter '/'" ], + "No available name found": [ + "No s'ha trobat cap nom disponible" + ], "Node has no thumbnail": [ "Aquest node no té miniatura" ], "Node is not a file": [ - "Aquest node no és un fitxer" + "El node no és un fitxer" ], "Node is not accessible": [ "Aquest node no és accessible" ], "Node is not shared": [ - "Aquest node no ha estat compartit" + "Node no compartit" ], "Node not found": [ "No s'ha pogut trobar el node" ], "Operation aborted": [ - "L'operació s'ha cancel·lat" + "S'ha interromput l'operació" ], "Parent cannot be decrypted": [ "L'element principal no es pot desxifrar" @@ -133,14 +163,23 @@ "No està permès canviar el nom d'un element arrel" ], "Request aborted": [ - "S'ha cancel·lat la sol·licitud" + "Sol·licitud cancel·lada" + ], + "Signature is missing": [ + "Hi manca la signatura" ], "Signature verification failed": [ "No s'ha pogut verificar la signatura." ], + "Signature verification failed: ${ errorMessage }": [ + "Hi ha hagut un error en la verificació de la signatura: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "No s'ha pogut verificar la signatura per a ${ signatureType }" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "No s'ha pogut verificar la signatura per a ${ signatureType }: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Alguns bytes del fitxer no s'han pogut carregar" ], @@ -151,7 +190,7 @@ "No s'ha trobat la miniatura" ], "Too many server errors, please try again later": [ - "Hi ha hagut massa errors del servidor. Torneu-ho a provar més tard." + "Massa errors del servidor. Torneu-ho a provar més tard" ], "Too many server requests, please try again later": [ "Hi ha hagut massa peticions del servidor. Torneu-ho a provar més tard." @@ -175,15 +214,9 @@ "Només podeu abandonar un element que us hagi estat compartit." ] }, - "Operation": { - "Deleting items": [ - "S'estan eliminant els elements" - ], - "Restoring items": [ - "S'estan restaurant els elements" - ], - "Trashing items": [ - "S'estan movent els elements a la paperera" + "Info": { + "Author is not provided on public link": [ + "L'autor no es proporciona a l'enllaç públic" ] }, "Property": { @@ -199,6 +232,9 @@ "key": [ "clau" ], + "membership": [ + "subscripció" + ], "name": [ "nom" ] diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 47ba38db..59c248c4 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } aus mehreren Abschnitten ist nicht erlaubt." + "Bookmark password is not available": [ + "Lesezeichen-Passwort ist nicht verfügbar" ], "Cannot download a folder": [ "Ordner kann nicht heruntergeladen werden" @@ -14,6 +14,12 @@ "Cannot share root folder": [ "Stammordner kann nicht geteilt werden" ], + "Copy operation aborted": [ + "Kopiervorgang abgebrochen" + ], + "Copying item to a non-folder is not allowed": [ + "Kopieren des Eintrags in einen Nicht-Ordner ist nicht erlaubt" + ], "Creating files in non-folders is not allowed": [ "Erstellen von Dateien in Nicht-Ordnern ist nicht erlaubt" ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Konnte Block nicht entschlüsseln: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Konnte Lesezeichen-Schlüssel nicht entschlüsseln: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Konnte Lesezeichen-Name nicht entschlüsseln: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Konnte Lesezeichen-Passwort nicht entschlüsseln: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Konnte Inhaltsschlüssel nicht entschlüsseln: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Konnte Vorschaubild nicht entschlüsseln: ${ message }" ], + "Failed to get inviter keys": [ + "Fehler beim Abrufen der Schlüssel des Einladenden" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Fehler beim Abrufen der Informationen fürs Teilen beim Knoten ${ nodeUid }" + ], "Failed to load some nodes": [ "Konnte einige Nodes nicht laden" ], + "Failed to verify invitation": [ + "Fehler beim Überprüfen der Einladung" + ], "File download failed due to empty response": [ "Download der Datei ist fehlgeschlagen, weil die Antwort leer war." ], @@ -68,6 +92,9 @@ "File has no content key": [ "Datei hat keinen Inhaltsschlüssel" ], + "Invalid URL": [ + "Ungültige URL" + ], "Invitation not found": [ "Einladung nicht gefunden" ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "Alter öffentlicher Link kann nicht aktualisiert werden. Bitte erstelle einen neuen öffentlichen Link." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Laden von Vorschaubildern aus mehreren Abschnitten ist nicht erlaubt" - ], "Missing integrity signature": [ "Fehlende Integritätssignatur" ], + "Missing inviter email": [ + "E-Mail-Adresse des Einladenden fehlt" + ], "Missing signature": [ "Fehlende Signatur" ], @@ -108,6 +135,9 @@ "Name must not contain the character '/'": [ "Name darf das Zeichen \"/\" nicht enthalten." ], + "No available name found": [ + "Kein verfügbarer Name gefunden" + ], "Node has no thumbnail": [ "Node hat kein Vorschaubild" ], @@ -135,12 +165,21 @@ "Request aborted": [ "Anfrage abgebrochen" ], + "Signature is missing": [ + "Signatur fehlt" + ], "Signature verification failed": [ "Signaturprüfung fehlgeschlagen" ], + "Signature verification failed: ${ errorMessage }": [ + "Signaturverifizierung fehlgeschlagen: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Signaturverifizierung für ${ signatureType } fehlgeschlagen" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Signaturverifizierung für ${ signatureType } fehlgeschlagen: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Einige Dateibytes konnten nicht hochgeladen werden" ], @@ -175,15 +214,9 @@ "Du kannst nur den Eintrag verlassen, der mit dir geteilt wurde." ] }, - "Operation": { - "Deleting items": [ - "Einträge löschen" - ], - "Restoring items": [ - "Einträge wiederherstellen" - ], - "Trashing items": [ - "Einträge in den Papierkorb verschieben" + "Info": { + "Author is not provided on public link": [ + "Der Autor wird unter dem öffentlichen Link nicht angegeben." ] }, "Property": { @@ -199,6 +232,9 @@ "key": [ "Schlüssel" ], + "membership": [ + "Mitgliedschaft" + ], "name": [ "Name" ] diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index 78efa57c..15ec2c5b 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } από πολλαπλές ενότητες δεν επιτÏέπεται" + "Bookmark password is not available": [ + "Η σελιδοδείκτηση ÎºÏ‰Î´Î¹ÎºÎ¿Ï Î´ÎµÎ½ είναι διαθέσιμη" ], "Cannot download a folder": [ "Δεν είναι δυνατή η λήψη ενός φακέλου" @@ -14,6 +14,12 @@ "Cannot share root folder": [ "Δεν είναι δυνατή η κοινοποίηση του κεντÏÎ¹ÎºÎ¿Ï Ï†Î±ÎºÎ­Î»Î¿Ï…" ], + "Copy operation aborted": [ + "Η αντιγÏαφή ακυÏώθηκε" + ], + "Copying item to a non-folder is not allowed": [ + "Δεν επιτÏέπεται η αντιγÏαφή στοιχείου σε μη-φάκελο" + ], "Creating files in non-folders is not allowed": [ "Δεν επιτÏέπεται η δημιουÏγία αÏχείων σε μη-φακέλους." ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Αποτυχία αποκÏυπτογÏάφησης μπλοκ: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης ÎºÎ»ÎµÎ¹Î´Î¹Î¿Ï ÏƒÎµÎ»Î¹Î´Î¿Î´ÎµÎ¯ÎºÏ„Î·: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης ονόματος σελιδοδείκτη: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Αποτυχία αποκÏυπτογÏάφησης ÎºÏ‰Î´Î¹ÎºÎ¿Ï ÏƒÎµÎ»Î¹Î´Î¿Î´ÎµÎ¯ÎºÏ„Î·: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Αποτυχία αποκÏυπτογÏάφησης ÎºÎ»ÎµÎ¹Î´Î¹Î¿Ï Ï€ÎµÏιεχομένου: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Αποτυχία αποκÏυπτογÏάφησης μικÏογÏαφίας: ${ message }" ], + "Failed to get inviter keys": [ + "Αποτυχία λήψης κλειδιών Ï€ÏοσκαλοÏντος" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Δεν ήταν δυνατή η λήψη πληÏοφοÏιών κοινοποίησης για τον κόμβο ${ nodeUid }" + ], "Failed to load some nodes": [ "Αποτυχία φόÏτωσης κάποιων κόμβων" ], + "Failed to verify invitation": [ + "Αποτυχία επαλήθευσης Ï€Ïόσκλησης" + ], "File download failed due to empty response": [ "Η λήψη αÏχείου απέτυχε λόγω κενής απόκÏισης" ], @@ -68,6 +92,9 @@ "File has no content key": [ "Το αÏχείο δεν έχει κλειδί πεÏιεχομένου" ], + "Invalid URL": [ + "Μη έγκυÏη διεÏθυνση URL" + ], "Invitation not found": [ "Η Ï€Ïόσκληση δεν βÏέθηκε" ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "Ο Ï€Î±Î»Î±Î¹Î¿Ï Ï„Ïπου δημόσιος σÏνδεσμος δεν μποÏεί να ενημεÏωθεί. ΠαÏακαλοÏμε δημιουÏγήστε εκ νέου έναν νέο δημόσιο σÏνδεσμο." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Δεν επιτÏέπεται η φόÏτωση μικÏογÏαφιών από πολλαπλές ενότητες." - ], "Missing integrity signature": [ "Λείπει η υπογÏαφή ακεÏαιότητας" ], + "Missing inviter email": [ + "Λείπει η ηλεκτÏονική διεÏθυνση του Ï€ÏοσκαλοÏντος" + ], "Missing signature": [ "Λείπει η υπογÏαφή" ], @@ -108,6 +135,9 @@ "Name must not contain the character '/'": [ "Το όνομα δεν Ï€Ïέπει να πεÏιέχει τον χαÏακτήÏα '/'" ], + "No available name found": [ + "Δεν βÏέθηκε διαθέσιμο όνομα" + ], "Node has no thumbnail": [ "Ο κόμβος δεν έχει μικÏογÏαφία" ], @@ -135,12 +165,21 @@ "Request aborted": [ "Το αίτημα ακυÏώθηκε" ], + "Signature is missing": [ + "Η υπογÏαφή λείπει" + ], "Signature verification failed": [ "Η επαλήθευση υπογÏαφής απέτυχε" ], + "Signature verification failed: ${ errorMessage }": [ + "Αποτυχία επαλήθευσης υπογÏαφής: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Αποτυχία επαλήθευσης υπογÏαφής για ${ signatureType }" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Αποτυχία επαλήθευσης υπογÏαφής για ${ signatureType }: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Κάποια byte αÏχείου απέτυχαν να μεταφοÏτωθοÏν" ], @@ -175,15 +214,9 @@ "ΜποÏείτε να αποχωÏήσετε μόνο από το στοιχείο που έχει κοινοποιηθεί σε εσάς." ] }, - "Operation": { - "Deleting items": [ - "ΔιαγÏαφή στοιχείων" - ], - "Restoring items": [ - "ΕπαναφοÏά στοιχείων" - ], - "Trashing items": [ - "Μετακίνηση στοιχείων στα ΔιαγÏαμμένα" + "Info": { + "Author is not provided on public link": [ + "Ο συγγÏαφέας δεν παÏέχεται στον δημόσιο σÏνδεσμο." ] }, "Property": { @@ -199,6 +232,9 @@ "key": [ "κλειδί" ], + "membership": [ + "συνδÏομή" + ], "name": [ "ονομα" ] diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 6f21fcbc..7926db9c 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "No se permite ${ operationForErrorMessage } de varias secciones." + "Bookmark password is not available": [ + "La contraseña del marcador no está disponible" ], "Cannot download a folder": [ "No se puede descargar una carpeta." @@ -14,6 +14,12 @@ "Cannot share root folder": [ "No se puede compartir la carpeta principal." ], + "Copy operation aborted": [ + "Se ha cancelado la copia." + ], + "Copying item to a non-folder is not allowed": [ + "No está permitido copiar un elemento a una ubicación que no sea una carpeta." + ], "Creating files in non-folders is not allowed": [ "No está permitido crear archivos en ubicaciones que no sean carpetas." ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Error al descifrar el bloque: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Error al descifrar la clave del marcador: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Error al descifrar el nombre del marcador: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Error al descifrar la contraseña del marcador: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Error al descifrar la clave del contenido: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Error al descifrar la miniatura: ${ message }" ], + "Failed to get inviter keys": [ + "Fallo al obtener las claves de quien invita" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "No se pudo obtener la información para compartir del nodo ${ nodeUid }" + ], "Failed to load some nodes": [ "Error al cargar algunos nodos" ], + "Failed to verify invitation": [ + "Fallo al verificar la invitación" + ], "File download failed due to empty response": [ "Error con la descarga del archivo debido a una respuesta vacía" ], @@ -68,6 +92,9 @@ "File has no content key": [ "El archivo no tiene clave de contenido." ], + "Invalid URL": [ + "La URL no es válida." + ], "Invitation not found": [ "No se ha encontrado la invitación." ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "El enlace público de origen no se puede actualizar. Vuelve a crear un nuevo enlace público." ], - "Loading thumbnails from multiple sections is not allowed": [ - "No se permite cargar miniaturas de varias secciones." - ], "Missing integrity signature": [ "Falta la firma de integridad." ], + "Missing inviter email": [ + "Falta el correo electrónico de quien invita" + ], "Missing signature": [ "Falta la firma." ], @@ -108,6 +135,9 @@ "Name must not contain the character '/'": [ "El nombre no debe contener el carácter «/»." ], + "No available name found": [ + "No se encontró ningún nombre disponible" + ], "Node has no thumbnail": [ "El nodo no tiene miniatura." ], @@ -135,12 +165,21 @@ "Request aborted": [ "Solicitud cancelada" ], + "Signature is missing": [ + "Falta la firma" + ], "Signature verification failed": [ "Error al verificar la firma" ], + "Signature verification failed: ${ errorMessage }": [ + "Fallo en la verificación de firma: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Error en la verificación de la firma para ${ signatureType }" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Fallo en la verificación de firma para ${ signatureType }: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Algunos bytes del archivo no se han podido cargar." ], @@ -175,15 +214,9 @@ "Solo puedes abandonar el elemento que se comparte contigo." ] }, - "Operation": { - "Deleting items": [ - "Eliminando elementos" - ], - "Restoring items": [ - "Restaurando elementos" - ], - "Trashing items": [ - "Eliminando elementos" + "Info": { + "Author is not provided on public link": [ + "El autor no se proporciona en el enlace público" ] }, "Property": { @@ -199,6 +232,9 @@ "key": [ "clave" ], + "membership": [ + "membresía" + ], "name": [ "nombre" ] diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index aff5b53d..32402885 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "No se permite ${ operationForErrorMessage } de varias secciones" + "Bookmark password is not available": [ + "La contraseña del marcador no está disponible." ], "Cannot download a folder": [ "No se puede descargar una carpeta." @@ -14,6 +14,12 @@ "Cannot share root folder": [ "No se puede compartir la carpeta raíz" ], + "Copy operation aborted": [ + "Se canceló la operación de copia" + ], + "Copying item to a non-folder is not allowed": [ + "No está permitido copiar un elemento a una ubicación que no es una carpeta" + ], "Creating files in non-folders is not allowed": [ "No está permitido crear archivos en ubicaciones que no son carpetas" ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "No se pudo descifrar el bloque: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Error al descifrar la clave del marcador: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Error al descifrar el nombre del marcador: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Error al descifrar la contraseña del marcador: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "No se pudo descifrar la clave de contenido: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Error al descifrar la miniatura: ${ message }" ], + "Failed to get inviter keys": [ + "No se pudieron obtener las claves de quien invita" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Error al obtener la información de compartición para el nodo ${ nodeUid }" + ], "Failed to load some nodes": [ "Error al cargar algunos nodos" ], + "Failed to verify invitation": [ + "No se pudo verificar la invitación" + ], "File download failed due to empty response": [ "Error con la descarga del archivo debido a una respuesta vacía" ], @@ -68,6 +92,9 @@ "File has no content key": [ "El archivo no tiene clave de contenido" ], + "Invalid URL": [ + "URL inválida" + ], "Invitation not found": [ "Invitación no encontrada" ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "El enlace público de origen no se puede actualizar. Vuelva a crear un nuevo enlace público." ], - "Loading thumbnails from multiple sections is not allowed": [ - "No se permite cargar miniaturas de varias secciones" - ], "Missing integrity signature": [ "Falta la firma de integridad" ], + "Missing inviter email": [ + "Falta el correo electrónico de quien invita" + ], "Missing signature": [ "Falta la firma" ], @@ -108,6 +135,9 @@ "Name must not contain the character '/'": [ "El nombre no debe contener el caracter «/»." ], + "No available name found": [ + "No se encontró ningún nombre disponible" + ], "Node has no thumbnail": [ "El nodo no tiene miniatura" ], @@ -135,12 +165,21 @@ "Request aborted": [ "Solicitud cancelada" ], + "Signature is missing": [ + "Falta la firma" + ], "Signature verification failed": [ "Error al verificar la firma" ], + "Signature verification failed: ${ errorMessage }": [ + "Falló la verificación de la firma: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Error en la verificación de la firma para ${ signatureType }" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Falló la verificación de la firma para ${ signatureType }: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Algunos bytes del archivo no se pudieron cargar" ], @@ -175,15 +214,9 @@ "Solo puede abandonar el elemento que se comparte con usted." ] }, - "Operation": { - "Deleting items": [ - "Eliminando elementos" - ], - "Restoring items": [ - "Restaurando elementos" - ], - "Trashing items": [ - "Eliminando elementos" + "Info": { + "Author is not provided on public link": [ + "El autor no se proporciona en el enlace público" ] }, "Property": { @@ -199,6 +232,9 @@ "key": [ "clave" ], + "membership": [ + "membresía" + ], "name": [ "nombre" ] diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index 9d50596e..5fdd465b 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } à partir de plusieurs sections n'est pas autorisé." + "Bookmark password is not available": [ + "Le mot de passe du favori n'est pas disponible." ], "Cannot download a folder": [ "Le téléchargement d'un dossier n'a pas abouti." @@ -14,6 +14,12 @@ "Cannot share root folder": [ "Le partager du dossier principal n'a pas abouti." ], + "Copy operation aborted": [ + "L'opération de copie a été annulée." + ], + "Copying item to a non-folder is not allowed": [ + "La copie d'un élément vers un élément qui n'est pas un dossier n'est pas autorisée." + ], "Creating files in non-folders is not allowed": [ "La création de fichiers dans des éléments qui ne sont pas des dossiers n'est pas autorisée." ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Le déchiffrement du bloc n'a pas abouti : ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Le déchiffrement de la clé du favori n'a pas abouti : ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Le déchiffrement du nom du favori n'a pas abouti : ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Le déchiffrement du mot de passe du favori n'a pas abouti : ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Le déchiffrement de la clé de contenu n'a pas abouti : ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Le déchiffrement de la vignette n'a pas abouti : ${ message }" ], + "Failed to get inviter keys": [ + "La récupération des clés de l'invitant n'a pas abouti" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "La récupération des informations de partage pour le nÅ“ud ${ nodeUid } n'a pas abouti" + ], "Failed to load some nodes": [ "Le chargement de certains nÅ“uds n'a pas abouti." ], + "Failed to verify invitation": [ + "La vérification de l'invitation n'a pas abouti." + ], "File download failed due to empty response": [ "Le téléchargement du fichier n'a pas abouti en raison d'une réponse vide." ], @@ -68,6 +92,9 @@ "File has no content key": [ "Le fichier n'a pas de clé de contenu." ], + "Invalid URL": [ + "L'URL n'est pas valide." + ], "Invitation not found": [ "L'invitation est introuvable." ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "L'ancien lien public ne peut pas être mis à jour. Veuillez recréer un nouveau lien public." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Le chargement de vignettes depuis plusieurs sections n'est pas autorisé" - ], "Missing integrity signature": [ "Il manque la signature d'intégrité." ], + "Missing inviter email": [ + "L'adresse de l'expéditeur est manquante" + ], "Missing signature": [ "Il manque la signature." ], @@ -108,6 +135,9 @@ "Name must not contain the character '/'": [ "Le nom ne doit pas contenir le caractère « / »." ], + "No available name found": [ + "Aucun nom disponible trouvé" + ], "Node has no thumbnail": [ "Le nÅ“ud n'a pas de vignette." ], @@ -135,12 +165,21 @@ "Request aborted": [ "La requête a été annulée." ], + "Signature is missing": [ + "La signature est manquante" + ], "Signature verification failed": [ "La signature n'a pas pu être vérifiée." ], + "Signature verification failed: ${ errorMessage }": [ + "La signature de vérification n'a pas abouti : ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "La vérification de la signature pour ${ signatureType } n'a pas abouti." ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "La vérification de la signature destinée à ${ signatureType } n'a pas été aboutie :${ errorMessage }" + ], "Some file bytes failed to upload": [ "Certains octets de fichier n'ont pas pu être importés." ], @@ -175,15 +214,9 @@ "Vous pouvez seulement quitter l'élément partagé avec vous." ] }, - "Operation": { - "Deleting items": [ - "Suppression des éléments" - ], - "Restoring items": [ - "Restauration des éléments" - ], - "Trashing items": [ - "Suppression des éléments" + "Info": { + "Author is not provided on public link": [ + "L'auteur n'est pas indiqué sur le lien public" ] }, "Property": { @@ -199,6 +232,9 @@ "key": [ "clé" ], + "membership": [ + "abonnement" + ], "name": [ "nom" ] diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index ce22a5ca..74f3a33a 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "Non è consentito ${ operationForErrorMessage } da più sezioni" + "Bookmark password is not available": [ + "La password del segnalibro non è disponibile." ], "Cannot download a folder": [ "Impossibile scaricare una cartella" @@ -14,6 +14,12 @@ "Cannot share root folder": [ "Impossibile condividere la cartella principale" ], + "Copy operation aborted": [ + "Copia interrotta" + ], + "Copying item to a non-folder is not allowed": [ + "Non è consentito copiare l'elemento in un elemento che non è una cartella." + ], "Creating files in non-folders is not allowed": [ "Non è consentito creare file in elementi che non sono cartelle." ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Impossibile decriptare il blocco: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Impossibile decriptare la chiave del segnalibro: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Impossibile decriptare il nome segnalibro: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Impossibile decriptare la password segnalibro: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Impossibile decriptare la chiave di contenuto: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Impossibile decriptare la miniatura: ${ message }" ], + "Failed to get inviter keys": [ + "Recupero delle chiavi dell'invitante non riuscito" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Impossibile ottenere le informazioni di condivisione per il nodo ${ nodeUid }" + ], "Failed to load some nodes": [ "Impossibile caricare alcuni nodi" ], + "Failed to verify invitation": [ + "Impossibile verificare l'invito" + ], "File download failed due to empty response": [ "Scaricamento del file fallito a causa di una risposta vuota." ], @@ -68,6 +92,9 @@ "File has no content key": [ "Il file non ha una chiave di contenuto" ], + "Invalid URL": [ + "URL non valido" + ], "Invitation not found": [ "Invito non trovato" ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "Il link pubblico obsoleto non può essere aggiornato. Per favore, ricrea un nuovo link pubblico." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Non è consentito caricare miniature da più sezioni" - ], "Missing integrity signature": [ "Firma di integrità mancante" ], + "Missing inviter email": [ + "Non è stata inserita l’email dell’invitante" + ], "Missing signature": [ "Firma mancante" ], @@ -108,6 +135,9 @@ "Name must not contain the character '/'": [ "Il nome non deve contenere il carattere '/'" ], + "No available name found": [ + "Non è stato trovato nessun nome disponibile" + ], "Node has no thumbnail": [ "Il nodo non ha alcuna miniatura" ], @@ -135,12 +165,21 @@ "Request aborted": [ "Richiesta interrotta" ], + "Signature is missing": [ + "Firma non presente" + ], "Signature verification failed": [ "Verifica della firma non riuscita" ], + "Signature verification failed: ${ errorMessage }": [ + "Verifica della firma non riuscita: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Verifica della firma per ${ signatureType } non riuscita" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Verifica della firma per ${ signatureType } non riuscita: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Alcuni byte del file non sono stati caricati" ], @@ -175,15 +214,9 @@ "Puoi lasciare solo l'elemento che è condiviso con te" ] }, - "Operation": { - "Deleting items": [ - "Eliminazione elementi" - ], - "Restoring items": [ - "Ripristino elementi" - ], - "Trashing items": [ - "Cancellazione elementi" + "Info": { + "Author is not provided on public link": [ + "L'autore non è fornito sul link pubblico." ] }, "Property": { @@ -199,6 +232,9 @@ "key": [ "chiave" ], + "membership": [ + "abbonamento" + ], "name": [ "nome" ] diff --git a/js/sdk/locales/ko_KR.json b/js/sdk/locales/ko_KR.json index d8830552..e66f8106 100644 --- a/js/sdk/locales/ko_KR.json +++ b/js/sdk/locales/ko_KR.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "다중 섹션으로 ë¶€í„°ì˜ ${ operationForErrorMessage }ì€(는) í—ˆë½ë˜ì§€ 않습니다." + "Bookmark password is not available": [ + "ë¶ë§ˆí¬ 비밀번호를 사용할 수 없습니다" ], "Cannot download a folder": [ "í´ë”를 다운로드할 수 ì—†ìŒ" @@ -14,6 +14,12 @@ "Cannot share root folder": [ "ìƒìœ„ í´ë”를 공유할 수 없습니다" ], + "Copy operation aborted": [ + "복사 작업 중단ë¨" + ], + "Copying item to a non-folder is not allowed": [ + "í´ë”ê°€ 아닌 곳으로 í•­ëª©ì„ ë³µì‚¬í•  수 없습니다" + ], "Creating files in non-folders is not allowed": [ "í´ë”ê°€ 아닌 ê³³ì— í´ë”를 ìƒì„±í•  수 없습니다" ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "블ë¡ì„ 복호화할 수 ì—†ìŒ: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "ë¶ë§ˆí¬ 키를 복호화할 수 ì—†ìŒ: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "ë¶ë§ˆí¬ ì´ë¦„ì„ ë³µí˜¸í™”í•  수 ì—†ìŒ: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "ë¶ë§ˆí¬ 비밀번호를 복호화할 수 ì—†ìŒ: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "콘í…츠 키 복호화 실패: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "ì„¬ë‚´ì¼ ë³µí˜¸í™” 실패: ${ message }" ], + "Failed to get inviter keys": [ + "ì´ˆëŒ€ìž í‚¤ë¥¼ 가져오는 ë° ì‹¤íŒ¨í–ˆìŠµë‹ˆë‹¤" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "노드 ${ nodeUid }ì— ëŒ€í•œ 공유 정보를 가져오지 못했습니다" + ], "Failed to load some nodes": [ "ì¼ë¶€ 로드 불러오기 실패" ], + "Failed to verify invitation": [ + "초대 í™•ì¸ ì‹¤íŒ¨" + ], "File download failed due to empty response": [ "빈 ì‘답으로 ì¸í•˜ì—¬ ë‹¤ìš´ë¡œë“œì— ì‹¤íŒ¨í–ˆìŠµë‹ˆë‹¤" ], @@ -68,6 +92,9 @@ "File has no content key": [ "파ì¼ì— 콘í…츠 키가 없습니다" ], + "Invalid URL": [ + "ìž˜ëª»ëœ URL" + ], "Invitation not found": [ "초대를 ì°¾ì„ ìˆ˜ ì—†ìŒ" ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "레거시 공개 ë§í¬ëŠ” ì—…ë°ì´íŠ¸ê°€ 불가능합니다. 새로운 공개 ë§í¬ë¥¼ 다시 ìƒì„±í•´ 주세요." ], - "Loading thumbnails from multiple sections is not allowed": [ - "여러 섹션ì—서 ì„¬ë„¤ì¼ ë¡œë“œëŠ” 허용ë˜ì§€ 않습니다." - ], "Missing integrity signature": [ "무결성 서명 ì—†ìŒ" ], + "Missing inviter email": [ + "ì´ˆëŒ€ìž ì´ë©”ì¼ ëˆ„ë½" + ], "Missing signature": [ "서명 누ë½" ], @@ -107,6 +134,9 @@ "Name must not contain the character '/'": [ "ì´ë¦„ì— ë¬¸ìž '/'ì„ í¬í•¨í•  수 없습니다" ], + "No available name found": [ + "사용 가능한 ì´ë¦„ì„ ì°¾ì„ ìˆ˜ ì—†ìŒ" + ], "Node has no thumbnail": [ "ë…¸ë“œì— ì„¬ë‚´ì¼ì´ 없습니다" ], @@ -134,12 +164,21 @@ "Request aborted": [ "요청 중단ë¨" ], + "Signature is missing": [ + "ì„œëª…ì´ ëˆ„ë½ë˜ì—ˆìŠµë‹ˆë‹¤" + ], "Signature verification failed": [ "서명 ì¸ì¦ 실패" ], + "Signature verification failed: ${ errorMessage }": [ + "서명 ê²€ì¦ ì‹¤íŒ¨: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "${ signatureType }ì˜ ì„œëª… 확ì¸ì— 실패했습니다" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "${ signatureType }ì˜ ì„œëª… 확ì¸ì— 실패했습니다: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "ì¼ë¶€ íŒŒì¼ ë°”ì´íЏ ì—…ë¡œë“œì— ì‹¤íŒ¨í–ˆìŠµë‹ˆë‹¤" ], @@ -174,18 +213,15 @@ "나와 ê³µìœ ëœ í•­ëª©ì—서만 나갈 수 있습니다." ] }, - "Operation": { - "Deleting items": [ - "항목 ì‚­ì œ 중" - ], - "Restoring items": [ - "항목 ë³µì› ì¤‘" - ], - "Trashing items": [ - "í•­ëª©ì„ íœ´ì§€í†µì— ì´ë™ 중" + "Info": { + "Author is not provided on public link": [ + "공개 ë§í¬ì—는 작성ìžê°€ 제공ë˜ì§€ 않습니다" ] }, "Property": { + "attributes": [ + "ì†ì„±" + ], "content key": [ "콘í…츠 키" ], @@ -195,6 +231,9 @@ "key": [ "키" ], + "membership": [ + "멤버십" + ], "name": [ "ì´ë¦„" ] diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index e2892da5..bd25f0b0 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } uit meerdere secties is niet toegestaan" + "Bookmark password is not available": [ + "Bladwijzerwachtwoord is niet beschikbaar" ], "Cannot download a folder": [ "Kan geen map downloaden" @@ -41,6 +41,15 @@ "Failed to decrypt block: ${ message }": [ "Fout bij het ontsleutelen van blok: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Fout bij het ontsleutelen van de bladwijzersleutel: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Fout bij het ontsleutelen van bladwijzernaam: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Fout bij het ontsleutelen van bladwijzerwachtwoord: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Fout bij het ontsleutelen van de inhoudssleutel: ${ message }" ], @@ -56,9 +65,15 @@ "Failed to decrypt thumbnail: ${ message }": [ "Fout bij het ontsleutelen van de miniatuur: ${ message }" ], + "Failed to get inviter keys": [ + "Ophalen van de sleutels van de uitnodiger is niet gelukt." + ], "Failed to load some nodes": [ "Fout bij het laden van sommige nodes" ], + "Failed to verify invitation": [ + "Verificatie van de uitnodiging is mislukt" + ], "File download failed due to empty response": [ "Het downloaden van het bestand is mislukt door een leeg antwoord" ], @@ -68,6 +83,9 @@ "File has no content key": [ "Bestand heeft geen inhoudssleutel" ], + "Invalid URL": [ + "Ongeldige URL" + ], "Invitation not found": [ "Uitnodiging niet gevonden" ], @@ -77,12 +95,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "De verouderde openbare koppeling kan niet worden bijgewerkt. Maak opnieuw een openbare koppeling aan." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Laden van miniaturen uit meerdere secties is niet toegestaan" - ], "Missing integrity signature": [ "Integriteitshandtekening ontbreekt" ], + "Missing inviter email": [ + "E-mailadres van uitnodiger ontbreekt" + ], "Missing signature": [ "Handtekening ontbreekt" ], @@ -108,6 +126,9 @@ "Name must not contain the character '/'": [ "Naam mag het teken '/' niet bevatten" ], + "No available name found": [ + "Geen beschikbare naam gevonden" + ], "Node has no thumbnail": [ "Node heeft geen miniatuur" ], @@ -135,12 +156,21 @@ "Request aborted": [ "Verzoek afgebroken" ], + "Signature is missing": [ + "Handtekening ontbreekt" + ], "Signature verification failed": [ "Controle van handtekening mislukt" ], + "Signature verification failed: ${ errorMessage }": [ + "Verificatie van handtekening is mislukt: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Verificatie van de handtekening voor ${ signatureType } is mislukt" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Verificatie van de handtekening voor ${ signatureType } is mislukt: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Sommige bestandsbytes konden niet worden geüpload" ], @@ -175,17 +205,6 @@ "U kunt alleen een item verlaten dat met u wordt gedeeld" ] }, - "Operation": { - "Deleting items": [ - "Items verwijderen" - ], - "Restoring items": [ - "Herstellen van items" - ], - "Trashing items": [ - "Items verwijderen" - ] - }, "Property": { "attributes": [ "attributen" @@ -199,6 +218,9 @@ "key": [ "sleutel" ], + "membership": [ + "lidmaatschap" + ], "name": [ "naam" ] diff --git a/js/sdk/locales/pl_PL.json b/js/sdk/locales/pl_PL.json index da1e9c6e..44f89d40 100644 --- a/js/sdk/locales/pl_PL.json +++ b/js/sdk/locales/pl_PL.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } z wielu sekcji nie jest dozwolone" + "Bookmark password is not available": [ + "HasÅ‚o zakÅ‚adki nie jest dostÄ™pne" ], "Cannot download a folder": [ "Nie można pobrać folderu" @@ -14,6 +14,9 @@ "Cannot share root folder": [ "Nie można udostÄ™pnić folderu głównego" ], + "Copy operation aborted": [ + "Operacja kopiowania zostaÅ‚a przerwana" + ], "Creating files in non-folders is not allowed": [ "Tworzenie plików poza folderami nie jest dozwolone" ], @@ -41,6 +44,15 @@ "Failed to decrypt block: ${ message }": [ "Nie udaÅ‚o siÄ™ odszyfrować bloku: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować klucza zakÅ‚adki: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować nazwy zakÅ‚adki: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Nie udaÅ‚o siÄ™ odszyfrować hasÅ‚a zakÅ‚adki: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Nie udaÅ‚o siÄ™ odszyfrować klucza zawartoÅ›ci: ${ message }" ], @@ -56,9 +68,15 @@ "Failed to decrypt thumbnail: ${ message }": [ "Nie udaÅ‚o siÄ™ odszyfrować podglÄ…du: ${ message }" ], + "Failed to get inviter keys": [ + "Nie udaÅ‚o siÄ™ pobrać kluczy zapraszajÄ…cego" + ], "Failed to load some nodes": [ "Nie udaÅ‚o siÄ™ zaÅ‚adować niektórych wÄ™złów" ], + "Failed to verify invitation": [ + "Nie udaÅ‚o siÄ™ zweryfikować zaproszenia" + ], "File download failed due to empty response": [ "Pobieranie pliku nie powiodÅ‚o siÄ™ z powodu pustej odpowiedzi" ], @@ -68,6 +86,9 @@ "File has no content key": [ "Plik nie ma klucza zawartoÅ›ci" ], + "Invalid URL": [ + "Adres URL jest nieprawidÅ‚owy" + ], "Invitation not found": [ "Zaproszenie nie zostaÅ‚o znalezione" ], @@ -77,12 +98,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "Starszy typ linku nie może zostać zaktualizowany. Utwórz ponownie nowy link." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Åadowanie podglÄ…dów z wielu sekcji nie jest dozwolone" - ], "Missing integrity signature": [ "Brak podpisu integralnoÅ›ci" ], + "Missing inviter email": [ + "Brak adresu e-mail nadawcy zaproszenia" + ], "Missing signature": [ "Brak podpisu" ], @@ -137,12 +158,21 @@ "Request aborted": [ "Żądanie zostaÅ‚o anulowane" ], + "Signature is missing": [ + "Brak podpisu" + ], "Signature verification failed": [ "Weryfikacja podpisu nie powiodÅ‚a siÄ™" ], + "Signature verification failed: ${ errorMessage }": [ + "Weryfikacja podpisu nie powiodÅ‚a siÄ™: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Weryfikacja podpisu dla ${ signatureType } nie powiodÅ‚a siÄ™" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Weryfikacja podpisu dla ${ signatureType } nie powiodÅ‚a siÄ™: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Nie udaÅ‚o siÄ™ przesÅ‚ać niektórych bajtów pliku" ], @@ -177,15 +207,9 @@ "Możesz opuÅ›cić tylko element, który zostaÅ‚ Ci udostÄ™pniony" ] }, - "Operation": { - "Deleting items": [ - "Usuwanie elementów" - ], - "Restoring items": [ - "Przywracanie elementów" - ], - "Trashing items": [ - "Przenoszenie elementów do kosza" + "Info": { + "Author is not provided on public link": [ + "Autor nie jest podany w publicznym linku" ] }, "Property": { @@ -201,6 +225,9 @@ "key": [ "klucz" ], + "membership": [ + "czÅ‚onkostwo" + ], "name": [ "nazwa" ] diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index 79a621a7..c9fa0525 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } de várias seções não é permitido." + "Bookmark password is not available": [ + "A senha do marcador não está disponível." ], "Cannot download a folder": [ "Não é possível baixar uma pasta." @@ -14,6 +14,12 @@ "Cannot share root folder": [ "Não é possível compartilhar a pasta raiz" ], + "Copy operation aborted": [ + "A cópia foi cancelado." + ], + "Copying item to a non-folder is not allowed": [ + "Não é permitido copiar um item para um item que não seja uma pasta." + ], "Creating files in non-folders is not allowed": [ "Não é permitido criar arquivos em itens que não sejam pastas." ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Falha ao descriptografar bloco: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Erro ao descriptografar a chave do favorito: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Erro ao descriptografar o nome do favorito: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Erro ao descriptografar a senha do favorito: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Falha ao descriptografar a chave de conteúdo: ${ message }" ], @@ -56,9 +71,15 @@ "Failed to decrypt thumbnail: ${ message }": [ "Erro ao descriptografar a miniatura: ${ message }" ], + "Failed to get inviter keys": [ + "Falha ao obter chaves do convidante" + ], "Failed to load some nodes": [ "Erro ao carregar alguns nós" ], + "Failed to verify invitation": [ + "Erro ao verificar o convite" + ], "File download failed due to empty response": [ "Erro ao baixar o arquivo devido a uma resposta vazia" ], @@ -68,6 +89,9 @@ "File has no content key": [ "O arquivo não tem chave de conteúdo." ], + "Invalid URL": [ + "O URL não é válido." + ], "Invitation not found": [ "Convite não encontrado" ], @@ -77,12 +101,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "O link público antigo não pode ser atualizado. Crie um novo link público." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Não é permitido carregar miniaturas de várias secções." - ], "Missing integrity signature": [ "Assinatura de integridade ausente" ], + "Missing inviter email": [ + "E-mail do convidante ausente" + ], "Missing signature": [ "Assinatura ausente" ], @@ -108,6 +132,9 @@ "Name must not contain the character '/'": [ "O nome não pode conter o caractere \"/\"" ], + "No available name found": [ + "Nenhum nome disponível encontrado" + ], "Node has no thumbnail": [ "Nó não tem miniatura" ], @@ -135,12 +162,21 @@ "Request aborted": [ "Solicitação cancelada" ], + "Signature is missing": [ + "Assinatura ausente" + ], "Signature verification failed": [ "Erro na verificação da assinatura" ], + "Signature verification failed: ${ errorMessage }": [ + "Erro na verificação da verificação: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Verificação de assinatura para ${ signatureType } falhou" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Verificação de assinatura para ${ signatureType } falhou: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Alguns bytes do arquivo falharam ao enviar" ], @@ -175,15 +211,9 @@ "Você pode sair apenas do item que é compartilhado com você" ] }, - "Operation": { - "Deleting items": [ - "Excluindo itens" - ], - "Restoring items": [ - "Restaurando itens" - ], - "Trashing items": [ - "Movendo itens para a lixeira" + "Info": { + "Author is not provided on public link": [ + "Autor não fornecido no link público" ] }, "Property": { @@ -199,6 +229,9 @@ "key": [ "chave" ], + "membership": [ + "subscrição" + ], "name": [ "nome" ] diff --git a/js/sdk/locales/pt_PT.json b/js/sdk/locales/pt_PT.json index 2024470a..0847590c 100644 --- a/js/sdk/locales/pt_PT.json +++ b/js/sdk/locales/pt_PT.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } de várias seções não é permitido." + "Bookmark password is not available": [ + "A palavra-passe do marcador não se encontra disponível." ], "Cannot download a folder": [ "Não é possível transferir uma pasta." @@ -41,6 +41,15 @@ "Failed to decrypt block: ${ message }": [ "Erro ao desencriptar o bloco: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Erro ao desencriptar a chave do marcador: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Erro ao desencriptar o nome do marcador: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Erro ao desencriptar a palavra-passe do marcador: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Erro ao desencriptar a chave de conteúdo: ${ message }" ], @@ -77,9 +86,6 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "Não é possível atualizar a ligação pública antiga. Cria uma nova ligação pública." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Não é permitido carregar miniaturas de várias secções." - ], "Missing integrity signature": [ "Assinatura de integridade ausente" ], @@ -175,17 +181,6 @@ "Só pode deixar o item partilhado consigo." ] }, - "Operation": { - "Deleting items": [ - "A eliminar itens" - ], - "Restoring items": [ - "A restaurae itens" - ], - "Trashing items": [ - "A eliminar itens" - ] - }, "Property": { "attributes": [ "atributos" diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index d5b90c10..4ee80cdd 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } din secÈ›iuni multiple nu este permisă." + "Bookmark password is not available": [ + "Parola semnului de carte nu este disponibilă." ], "Cannot download a folder": [ "Nu se poate descărca un folder." @@ -14,6 +14,12 @@ "Cannot share root folder": [ "Nu se poate partaja folderul rădăcină." ], + "Copy operation aborted": [ + "Copierea a fost anulată." + ], + "Copying item to a non-folder is not allowed": [ + "Copierea articolului în alt loc decât un folder nu este permisă." + ], "Creating files in non-folders is not allowed": [ "Crearea fiÈ™ierelor în non-foldere nu este permisă." ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Nu s-a reuÈ™it decriptarea blocului: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Nu s-a reuÈ™it decriptarea cheii semnului de carte: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Nu s-a reuÈ™it decriptarea numelui semnului de carte: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Nu s-a reuÈ™it decriptarea parolei semnului de carte: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Nu s-a reuÈ™it decriptarea cheii de conÈ›inut: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Nu s-a reuÈ™it decriptarea miniaturii: ${ message }" ], + "Failed to get inviter keys": [ + "Preluarea cheilor de invitare a eÈ™uat." + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Nu s-au putut obÈ›ine informaÈ›ii de partajare pentru nodul ${ nodeUid }." + ], "Failed to load some nodes": [ "Nu s-a reuÈ™it încărcarea unor noduri." ], + "Failed to verify invitation": [ + "EÈ™uare verificare invitaÈ›ie." + ], "File download failed due to empty response": [ "Descărcarea fiÈ™ierului a eÈ™uat din cauza unui răspuns gol." ], @@ -68,6 +92,9 @@ "File has no content key": [ "FiÈ™ierul nu are nicio cheie de conÈ›inut." ], + "Invalid URL": [ + "Adresă URL nevalidă." + ], "Invitation not found": [ "InvitaÈ›ia nu a fost găsită." ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "Vechea legătură web publică nu poate fi actualizată. RecreaÈ›i o nouă legătură web publică." ], - "Loading thumbnails from multiple sections is not allowed": [ - "ÃŽncărcarea miniaturilor din secÈ›iuni multiple nu este permisă." - ], "Missing integrity signature": [ "LipseÈ™te semnătura de integritate" ], + "Missing inviter email": [ + "Adresa de e-mail al invitantului lipseÈ™te." + ], "Missing signature": [ "Semnătură lipsă." ], @@ -109,6 +136,9 @@ "Name must not contain the character '/'": [ "Numele nu trebuie să conÈ›ină caracterul „/â€." ], + "No available name found": [ + "Nu a fost găsit niciun nume disponibil." + ], "Node has no thumbnail": [ "Nodul nu are miniatură." ], @@ -136,12 +166,21 @@ "Request aborted": [ "Solicitare anulată." ], + "Signature is missing": [ + "Semnătura lipseÈ™te" + ], "Signature verification failed": [ "EÈ™uare verificare semnătură." ], + "Signature verification failed: ${ errorMessage }": [ + "Verificarea semnăturii a eÈ™uat: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Verificarea semnăturii pentru ${ signatureType } a eÈ™uat." ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Verificarea semnăturii pentru ${ signatureType } a eÈ™uat: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Unii octeÈ›i ai fiÈ™ierului nu s-au încărcat." ], @@ -176,15 +215,9 @@ "PuteÈ›i părăsi doar articolul care vi s-a partajat." ] }, - "Operation": { - "Deleting items": [ - "Ștergerea articolelor" - ], - "Restoring items": [ - "Restaurare elemente" - ], - "Trashing items": [ - "Se pun în gunoi articolele" + "Info": { + "Author is not provided on public link": [ + "Autorul nu este furnizat pentru legătura publică." ] }, "Property": { @@ -200,6 +233,9 @@ "key": [ "cheie" ], + "membership": [ + "apartenență" + ], "name": [ "nume" ] diff --git a/js/sdk/locales/ru_RU.json b/js/sdk/locales/ru_RU.json index 94272086..70ba54bb 100644 --- a/js/sdk/locales/ru_RU.json +++ b/js/sdk/locales/ru_RU.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } из неÑкольких разделов не допуÑкаетÑÑ" + "Bookmark password is not available": [ + "Пароль закладки недоÑтупен" ], "Cannot download a folder": [ "Ðевозможно Ñкачать папку" @@ -41,6 +41,15 @@ "Failed to decrypt block: ${ message }": [ "Ðе удалоÑÑŒ раÑшифровать блок: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать ключ закладки: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать Ð¸Ð¼Ñ Ð·Ð°ÐºÐ»Ð°Ð´ÐºÐ¸: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Ðе удалоÑÑŒ раÑшифровать пароль закладки: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Ðе удалоÑÑŒ раÑшифровать ключ Ñодержимого: ${ message }" ], @@ -56,9 +65,15 @@ "Failed to decrypt thumbnail: ${ message }": [ "Ðе удалоÑÑŒ раÑшифровать значок: ${ message }" ], + "Failed to get inviter keys": [ + "Ðе удалоÑÑŒ получить ключи приглаÑившего" + ], "Failed to load some nodes": [ "Ðе удалоÑÑŒ загрузить некоторые узлы" ], + "Failed to verify invitation": [ + "Ðе удалоÑÑŒ проверить приглашение" + ], "File download failed due to empty response": [ "Ðе удалоÑÑŒ Ñкачать файл из-за пуÑтого ответа" ], @@ -77,12 +92,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "УÑтаревшую публичную ÑÑылку невозможно обновить. Создайте новую публичную ÑÑылку." ], - "Loading thumbnails from multiple sections is not allowed": [ - "Загрузка значков из неÑкольких разделов не допуÑкаетÑÑ" - ], "Missing integrity signature": [ "ОтÑутÑтвует подпиÑÑŒ целоÑтноÑти" ], + "Missing inviter email": [ + "ОтÑутÑтвует Ð°Ð´Ñ€ÐµÑ Ñлектронной почты приглаÑившего" + ], "Missing signature": [ "ПодпиÑÑŒ отÑутÑтвует" ], @@ -140,9 +155,15 @@ "Signature verification failed": [ "Проверка подпиÑи не удалаÑÑŒ" ], + "Signature verification failed: ${ errorMessage }": [ + "Проверка подпиÑи не удалаÑÑŒ: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Проверка подпиÑи Ð´Ð»Ñ ${ signatureType } не удалаÑÑŒ" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Проверка подпиÑи Ð´Ð»Ñ ${ signatureType } не удалаÑÑŒ: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Ðе удалоÑÑŒ загрузить некоторые байты файла" ], @@ -177,17 +198,6 @@ "Ð’Ñ‹ можете покинуть только тот Ñлемент, к которому вам предоÑтавили доÑтуп" ] }, - "Operation": { - "Deleting items": [ - "Удаление Ñлементов" - ], - "Restoring items": [ - "ВоÑÑтановление Ñлементов" - ], - "Trashing items": [ - "Перемещение Ñлементов в корзину" - ] - }, "Property": { "attributes": [ "атрибуты" @@ -201,6 +211,9 @@ "key": [ "ключ" ], + "membership": [ + "членÑтво" + ], "name": [ "название" ] diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index e67758fa..82a5ca76 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -5,8 +5,8 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "${ operationForErrorMessage } z viacerých sekcií nie je povolené" + "Bookmark password is not available": [ + "Heslo záložky nie je dostupné" ], "Cannot download a folder": [ "Nie je možné stiahnuÅ¥ prieÄinok" @@ -14,6 +14,12 @@ "Cannot share root folder": [ "Nie je možné zdieľaÅ¥ koreňový prieÄinok" ], + "Copy operation aborted": [ + "Operácia kopírovania bola preruÅ¡ená" + ], + "Copying item to a non-folder is not allowed": [ + "Kopírovanie položky mimo prieÄinka nie je povolené." + ], "Creating files in non-folders is not allowed": [ "Vytváranie súborov mimo prieÄinkov nie je povolené." ], @@ -41,6 +47,15 @@ "Failed to decrypt block: ${ message }": [ "Nepodarilo sa deÅ¡ifrovaÅ¥ blok: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ kÄ¾ÃºÄ záložky: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ názov záložky: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Nepodarilo sa deÅ¡ifrovaÅ¥ heslo záložky: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "Nepodarilo sa deÅ¡ifrovaÅ¥ kÄ¾ÃºÄ obsahu: ${ message }" ], @@ -56,9 +71,18 @@ "Failed to decrypt thumbnail: ${ message }": [ "Nepodarilo sa deÅ¡ifrovaÅ¥ miniatúru: ${ message }" ], + "Failed to get inviter keys": [ + "Nepodarilo sa získaÅ¥ pozývacie kľúÄe" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Nepodarilo sa získaÅ¥ informácie o zdieľaní pre uzol ${ nodeUid }" + ], "Failed to load some nodes": [ "Nepodarilo sa naÄítaÅ¥ niektoré uzly" ], + "Failed to verify invitation": [ + "Nepodarilo sa overiÅ¥ pozvánku" + ], "File download failed due to empty response": [ "Stiahnutie súboru zlyhalo z dôvodu prázdnej odpovede." ], @@ -68,6 +92,9 @@ "File has no content key": [ "Súbor nemá kÄ¾ÃºÄ obsahu" ], + "Invalid URL": [ + "Neplatná URL" + ], "Invitation not found": [ "Pozvánka nenájdená" ], @@ -77,12 +104,12 @@ "Legacy public link cannot be updated. Please re-create a new public link.": [ "Starý verejný odkaz sa nedá aktualizovaÅ¥. Vytvorte nový verejný odkaz, prosím." ], - "Loading thumbnails from multiple sections is not allowed": [ - "NaÄítavanie miniatúr z viacerých sekcií nie je povolené" - ], "Missing integrity signature": [ "Chýbajúci podpis integrity" ], + "Missing inviter email": [ + "Chýba email odosielateľa pozvánky" + ], "Missing signature": [ "Chýbajúci podpis" ], @@ -110,6 +137,9 @@ "Name must not contain the character '/'": [ "Názov nesmie obsahovaÅ¥ znak '/'" ], + "No available name found": [ + "NenaÅ¡iel sa žiadny dostupný názov" + ], "Node has no thumbnail": [ "Uzol nemá miniatúru" ], @@ -137,12 +167,21 @@ "Request aborted": [ "Požiadavka preruÅ¡ená" ], + "Signature is missing": [ + "Chýbajúci podpis" + ], "Signature verification failed": [ "Overenie podpisu zlyhalo" ], + "Signature verification failed: ${ errorMessage }": [ + "Overenie podpisu zlyhalo: ${ errorMessage }" + ], "Signature verification for ${ signatureType } failed": [ "Overenie podpisu pre ${ signatureType } zlyhalo" ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Overenie podpisu pre ${ signatureType } zlyhalo: ${ errorMessage }" + ], "Some file bytes failed to upload": [ "Niektoré bajty súboru sa nepodarilo nahraÅ¥" ], @@ -177,15 +216,9 @@ "Môžete opustiÅ¥ iba položku, ktorá je s vami zdieľaná." ] }, - "Operation": { - "Deleting items": [ - "Vymazávanie položiek" - ], - "Restoring items": [ - "Obnovovanie položiek" - ], - "Trashing items": [ - "Presúvanie položiek do koÅ¡a" + "Info": { + "Author is not provided on public link": [ + "Autor nie je uvedený na verejnom odkaze." ] }, "Property": { @@ -201,6 +234,9 @@ "key": [ "kľúÄ" ], + "membership": [ + "Älenstvo" + ], "name": [ "názov" ] diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index 40f066f6..32246d3d 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -5,84 +5,111 @@ }, "contexts": { "Error": { - "${ operationForErrorMessage } from multiple sections is not allowed": [ - "Birden fazla bölümden ${ operationForErrorMessage } iÅŸlemine izin verilmiyor" + "Bookmark password is not available": [ + "Yer imi parolası kullanılamıyor" ], "Cannot download a folder": [ - "Klasör indirilemez" + "Bir klasör indirilemedi" ], "Cannot share root folder": [ - "Kök klasör paylaşılamıyor" + "Kök klasör paylaşılamaz" + ], + "Copy operation aborted": [ + "Kopyalama iÅŸlemi iptal edildi" + ], + "Copying item to a non-folder is not allowed": [ + "Öge klasör olmayan bir yere kopyalanamaz" ], "Creating files in non-folders is not allowed": [ - "Klasör olmayan yerlerde dosya oluÅŸturulmasına izin verilmemektedir." + "Klasör olmayanlar içinde dosya oluÅŸturulamaz" ], "Creating folders in non-folders is not allowed": [ - "Klasör olmayan yerlerde klasör oluÅŸturulmasına izin verilmemektedir." + "Klasör olmayanlar içinde klasör oluÅŸturulamaz" ], "Creating revisions in non-files is not allowed": [ - "Dosya olmayanlarda revizyon oluÅŸturulmasına izin verilmemektedir." + "Dosya olmayanlar içinde sürüm oluÅŸturulamaz" ], "Data integrity check failed": [ - "Veri bütünlüğü kontrolü baÅŸarısız oldu" + "Veri bütünlüğü denetlenemedi" ], "Data integrity check of one part failed": [ - "Bir parçanın veri bütünlüğü kontrolü baÅŸarısız oldu" + "Bir parçanın veri bütünlüğü denetlenemedi" ], "Device not found": [ "Aygıt bulunamadı" ], "Expiration date cannot be in the past": [ - "Geçerlilik süresi geçmiÅŸ bir tarih olamaz" + "Geçerlilik süresi geçmiÅŸte bir tarih olamaz" ], "Failed to decrypt active revision: ${ message }": [ - "Etkin düzeltmenin ÅŸifresi çözülemedi: ${ message }" + "Etkin sürümün ÅŸifresi çözülemedi: ${ message }" ], "Failed to decrypt block: ${ message }": [ "Blok ÅŸifresi çözülemedi: ${ message }" ], + "Failed to decrypt bookmark key: ${ message }": [ + "Yer imi anahtarının ÅŸifresi çözülemedi: ${ message }" + ], + "Failed to decrypt bookmark name: ${ message }": [ + "Yer imi adının ÅŸifresi çözülemedi: ${ message }" + ], + "Failed to decrypt bookmark password: ${ message }": [ + "Yer imi parolasının ÅŸifresi çözülemedi: ${ message }" + ], "Failed to decrypt content key: ${ message }": [ "İçerik anahtarının ÅŸifresi çözülemedi: ${ message }" ], "Failed to decrypt item name: ${ message }": [ - "Öğe adının ÅŸifresi çözülemedi: ${ message }" + "Öge adının ÅŸifresi çözülemedi: ${ message }" ], "Failed to decrypt node key: ${ message }": [ "Düğüm anahtarının ÅŸifresi çözülemedi: ${ message }" ], "Failed to decrypt some nodes": [ - "Bazı düğümlerin ÅŸifresi çözülemedi." + "Bazı düğümlerin ÅŸifresi çözülemedi" ], "Failed to decrypt thumbnail: ${ message }": [ "Küçük görselin ÅŸifresi çözülemedi: ${ message }" ], + "Failed to get inviter keys": [ + "Davet edenin anahtarları alınamadı" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "${ nodeUid } düğümünün paylaşım bilgileri alınamadı" + ], "Failed to load some nodes": [ "Bazı düğümler yüklenemedi" ], + "Failed to verify invitation": [ + "Davet doÄŸrulanamadı" + ], "File download failed due to empty response": [ - "BoÅŸ yanıt nedeniyle dosya indirme baÅŸarısız oldu." + "BoÅŸ yanıt nedeniyle dosya indirilemedi" ], "File has no active revision": [ - "Dosyanın aktif revizyonu bulunmamaktadır." + "Dosyanın etkin bir sürümü yok" ], "File has no content key": [ - "Dosyanın içerik anahtarı yok." + "Dosyanın içerik anahtarı yok" + ], + "Invalid URL": [ + "Adres geçersiz" ], "Invitation not found": [ "Davet bulunamadı" ], "Item cannot be decrypted": [ - "Öğenin ÅŸifresi çözülemez" + "Ögenin ÅŸifresi çözülemedi" ], "Legacy public link cannot be updated. Please re-create a new public link.": [ - "Eski herkese açık baÄŸlantı güncellenemez. Lütfen yeni bir herkese açık baÄŸlantı oluÅŸturun." - ], - "Loading thumbnails from multiple sections is not allowed": [ - "Birden fazla bölümden küçük görsellerin yüklenmesine izin verilmemektedir." + "Eski herkese açık baÄŸlantı güncellenemedi. Lütfen yeni bir herkese açık baÄŸlantı oluÅŸturun." ], "Missing integrity signature": [ "Bütünlük imzası eksik" ], + "Missing inviter email": [ + "Davet edenin e-posta adresi eksik" + ], "Missing signature": [ "İmza eksik" ], @@ -93,10 +120,10 @@ "Taşıma iÅŸlemi iptal edildi" ], "Moving item to a non-folder is not allowed": [ - "Öğenin klasör olmayan bir yere taşınmasına izin verilmemektedir." + "Öge klasör olmayan bir yere taşınamaz" ], "Moving root item is not allowed": [ - "Kök öğenin taşınmasına izin verilmiyor" + "Kök öge taşınamaz" ], "Name must be ${ MAX_NODE_NAME_LENGTH } character long at most": [ "Ad en fazla ${ MAX_NODE_NAME_LENGTH } karakter uzunluÄŸunda olmalıdır.", @@ -106,10 +133,13 @@ "Ad boÅŸ olamaz" ], "Name must not contain the character '/'": [ - "Ad \"/\" karakterini içermemelidir." + "Ad içinde \"/\" karakteri bulunamaz." + ], + "No available name found": [ + "Kullanılabilecek bir ad bulunamadı" ], "Node has no thumbnail": [ - "Düğümün küçük resmi yok" + "Düğümün küçük görseli yok" ], "Node is not a file": [ "Düğüm bir dosya deÄŸil" @@ -127,31 +157,40 @@ "İşlem iptal edildi" ], "Parent cannot be decrypted": [ - "Üst öğenin ÅŸifresi çözülemez" + "Üst ögenin ÅŸifresi çözülemedi" ], "Renaming root item is not allowed": [ - "Kök öğenin yeniden adlandırılmasına izin verilmiyor." + "Kök öge yeniden adlandırılamaz" ], "Request aborted": [ "İstek iptal edildi" ], + "Signature is missing": [ + "İmza eksik" + ], "Signature verification failed": [ - "İmza doÄŸrulaması baÅŸarısız oldu" + "İmza doÄŸrulanamadı" + ], + "Signature verification failed: ${ errorMessage }": [ + "İmza doÄŸrulanamadı: ${ errorMessage }" ], "Signature verification for ${ signatureType } failed": [ - "${ signatureType } için imza doÄŸrulama baÅŸarısız oldu." + "${ signatureType } için imza doÄŸrulanamadı" + ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "${ signatureType } için imza doÄŸrulanamadı: ${ errorMessage }" ], "Some file bytes failed to upload": [ - "Bazı dosya baytları yüklenemedi." + "Bazı dosya baytları yüklenemedi" ], "Some file parts failed to upload": [ - "Bazı dosya parçaları yüklenemedi." + "Bazı dosya parçaları yüklenemedi" ], "Thumbnail not found": [ - "Küçük resim bulunamadı" + "Küçük görsel bulunamadı" ], "Too many server errors, please try again later": [ - "Çok fazla sunucu hatası oluÅŸtu, lütfen daha sonra yeniden deneyin" + "Çok fazla sayıda sunucu sorunu çıktı. Lütfen bir süre sonra yeniden deneyin." ], "Too many server requests, please try again later": [ "Çok fazla sayıda sunucu isteÄŸi yapıldı. Lütfen bir süre sonra yeniden deneyin." @@ -166,29 +205,23 @@ "Bilinmeyen hata ${ response.Response.Code }" ], "Verification keys are not available": [ - "DoÄŸrulama anahtarları mevcut deÄŸil" + "DoÄŸrulama anahtarları kullanılamıyor" ], "Verification keys for ${ signatureType } are not available": [ - "${ signatureType } için doÄŸrulama anahtarları mevcut deÄŸil" + "${ signatureType } için doÄŸrulama anahtarları kullanılamıyor" ], "You can leave only item that is shared with you": [ - "Sadece sizinle paylaşılan öğeyi bırakabilirsiniz" + "Yalnızca sizinle paylaşılan bir ögeden ayrılabilirsiniz" ] }, - "Operation": { - "Deleting items": [ - "Öğeler siliniyor" - ], - "Restoring items": [ - "Öğeler geri yükleniyor" - ], - "Trashing items": [ - "Öğeler çöpe atılıyor" + "Info": { + "Author is not provided on public link": [ + "Yazar herkese açık baÄŸlantıda belirtilmemiÅŸ" ] }, "Property": { "attributes": [ - "özellikler" + "öznitelikler" ], "content key": [ "içerik anahtarı" @@ -199,6 +232,9 @@ "key": [ "anahtar" ], + "membership": [ + "üyelik" + ], "name": [ "ad" ] From 130b19f33f74e524cd9f117ee9a8ce50702b9b05 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Dec 2025 07:59:41 +0100 Subject: [PATCH 371/791] js/v0.7.2 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 14dd13de..49230eea 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index e6311963..4f3f5250 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.7.1", + "version": "0.7.2", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 9c0cce6ed2f800738697cf1d851d2d40d893ab88 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 22:17:12 +0100 Subject: [PATCH 372/791] Fix build of Swift bindings on CI --- .../Sources/ProtonDriveClient/HttpClientProtocol.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift index 9cc4fed5..d75555ca 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift @@ -99,7 +99,7 @@ public struct AnyAsyncIterator: AsyncIteratorProtocol { if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { try await box.iterator.next(isolation: $0) } else { - try await box.iterator.next() + fatalError("This method is not available on older OS versions.") } } } From afd17f5e70d239bbac8061d79f0cd0d745cc3fcf Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Dec 2025 08:02:04 +0000 Subject: [PATCH 373/791] Create findPhotoDuplicates to get uids of duplicates --- js/sdk/src/internal/photos/timeline.test.ts | 46 +++++++++++++++++---- js/sdk/src/internal/photos/timeline.ts | 8 ++-- js/sdk/src/protonDrivePhotosClient.ts | 26 +++++++++++- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/js/sdk/src/internal/photos/timeline.test.ts b/js/sdk/src/internal/photos/timeline.test.ts index 649bf3bf..cce42417 100644 --- a/js/sdk/src/internal/photos/timeline.test.ts +++ b/js/sdk/src/internal/photos/timeline.test.ts @@ -45,15 +45,15 @@ describe('PhotosTimeline', () => { timeline = new PhotosTimeline(logger, apiService, driveCrypto, photoShares, nodesService); }); - describe('isDuplicatePhoto', () => { + describe('findPhotoDuplicates', () => { it('should not call sha1 callback when there is no name hash match', async () => { const generateSha1 = jest.fn(); apiService.checkPhotoDuplicates = jest.fn().mockResolvedValue([]); driveCrypto.generateLookupHash = jest.fn().mockResolvedValue(nameHash); - const result = await timeline.isDuplicatePhoto(name, generateSha1); + const result = await timeline.findPhotoDuplicates(name, generateSha1); - expect(result).toBe(false); + expect(result).toEqual([]); expect(generateSha1).not.toHaveBeenCalled(); expect(photoShares.getRootIDs).toHaveBeenCalled(); expect(nodesService.getNodeKeys).toHaveBeenCalledWith(rootNodeUid); @@ -76,9 +76,9 @@ describe('PhotosTimeline', () => { .mockResolvedValueOnce(nameHash) .mockResolvedValueOnce(contentHash); - const result = await timeline.isDuplicatePhoto(name, generateSha1); + const result = await timeline.findPhotoDuplicates(name, generateSha1); - expect(result).toBe(false); + expect(result).toEqual([]); expect(generateSha1).toHaveBeenCalledTimes(1); expect(driveCrypto.generateLookupHash).toHaveBeenCalledTimes(2); expect(driveCrypto.generateLookupHash).toHaveBeenNthCalledWith(1, name, hashKey); @@ -102,15 +102,47 @@ describe('PhotosTimeline', () => { .mockResolvedValueOnce(nameHash) .mockResolvedValueOnce(contentHash); - const result = await timeline.isDuplicatePhoto(name, generateSha1); + const result = await timeline.findPhotoDuplicates(name, generateSha1); - expect(result).toBe(true); + expect(result).toEqual([nodeUid1]); expect(generateSha1).toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledWith( `Duplicate photo found: name hash: ${nameHash}, content hash: ${contentHash}, node uids: ${nodeUid1}`, ); }); + + it('should return multiple node UIDs when multiple duplicates match', async () => { + const generateSha1 = jest.fn().mockResolvedValue(sha1); + const nodeUid1 = 'volumeId~node1'; + const nodeUid2 = 'volumeId~node2'; + const duplicates = [ + { + nameHash: nameHash, + contentHash: contentHash, + nodeUid: nodeUid1, + }, + { + nameHash: nameHash, + contentHash: contentHash, + nodeUid: nodeUid2, + }, + ]; + apiService.checkPhotoDuplicates = jest.fn().mockResolvedValue(duplicates); + driveCrypto.generateLookupHash = jest + .fn() + .mockResolvedValueOnce(nameHash) + .mockResolvedValueOnce(contentHash); + + const result = await timeline.findPhotoDuplicates(name, generateSha1); + + expect(result).toEqual([nodeUid1, nodeUid2]); + expect(generateSha1).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + `Duplicate photo found: name hash: ${nameHash}, content hash: ${contentHash}, node uids: ${nodeUid1},${nodeUid2}`, + ); + }); }); }); diff --git a/js/sdk/src/internal/photos/timeline.ts b/js/sdk/src/internal/photos/timeline.ts index 04ac14f9..39df9acc 100644 --- a/js/sdk/src/internal/photos/timeline.ts +++ b/js/sdk/src/internal/photos/timeline.ts @@ -32,7 +32,7 @@ export class PhotosTimeline { yield* this.apiService.iterateTimeline(volumeId, signal); } - async isDuplicatePhoto(name: string, generateSha1: () => Promise, signal?: AbortSignal): Promise { + async findPhotoDuplicates(name: string, generateSha1: () => Promise, signal?: AbortSignal): Promise { const { volumeId, rootNodeId } = await this.photoShares.getRootIDs(); const rootNodeUid = makeNodeUid(volumeId, rootNodeId); const { hashKey } = await this.nodesService.getNodeKeys(rootNodeUid); @@ -44,7 +44,7 @@ export class PhotosTimeline { const duplicates = await this.apiService.checkPhotoDuplicates(volumeId, [nameHash], signal); if (duplicates.length === 0) { - return false; + return []; } // Generate the SHA1 only when there is any matching node hash to avoid @@ -57,13 +57,13 @@ export class PhotosTimeline { ); if (matchingDuplicates.length === 0) { - return false; + return []; } const nodeUids = matchingDuplicates.map((duplicate) => duplicate.nodeUid); this.logger.debug( `Duplicate photo found: name hash: ${nameHash}, content hash: ${contentHash}, node uids: ${nodeUids}`, ); - return true; + return nodeUids; } } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 8e504460..3449059a 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -479,10 +479,34 @@ export class ProtonDrivePhotosClient { * @param generateSha1 - A callback to generate the hex string representation of the SHA1 of the photo content. * @param signal - An optional abort signal to cancel the operation. * @returns True if the photo already exists in the timeline, false otherwise. + * @deprecated Use `findPhotoDuplicates` instead to get the node UIDs of duplicate photos. */ async isDuplicatePhoto(name: string, generateSha1: () => Promise, signal?: AbortSignal): Promise { this.logger.info(`Checking if photo is a duplicate`); - return this.photos.timeline.isDuplicatePhoto(name, generateSha1, signal); + return this.photos.timeline.findPhotoDuplicates(name, generateSha1, signal).then(nodeUids => nodeUids.length !== 0); + } + + /** + * Find duplicate photos by name and content. + * + * For given photo name, find existing photos with the same name + * in the timeline and check if the photo content is also the same. + * Only the same name is not considered as duplicate photo because + * it is expected that there are photos with the same name (e.g., + * date as a name from multiple cameras, or rolling number). + * + * The function accepts a callback to generate the SHA1 and it is + * called only when there is any matching node name hash to avoid + * computation for every node if its not necessary. + * + * @param name - The name of the photo to check for duplicates. + * @param generateSha1 - A callback to generate the hex string representation of the SHA1 of the photo content. + * @param signal - An optional abort signal to cancel the operation. + * @returns An array of node UIDs of duplicate photos. Empty array if no duplicates found. + */ + async findPhotoDuplicates(name: string, generateSha1: () => Promise, signal?: AbortSignal): Promise { + this.logger.info(`Checking if photo have duplicates`); + return this.photos.timeline.findPhotoDuplicates(name, generateSha1, signal); } /** From 39c9b14d797611f288c944d2eeddff253503abeb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 19:26:37 +0100 Subject: [PATCH 374/791] Adds the pause, resume and isPaused calls to Swift bindings for upload and download --- .../FileOperations/DownloadManager.swift | 142 ---------------- .../FileOperations/DownloadOperation.swift | 126 ++++++++++++++ .../DownloadThumbnailsManager.swift | 4 +- .../FileOperations/DownloadsManager.swift | 101 ++++++++++++ .../OperationalResilience.swift | 46 ++++++ .../FileOperations/UploadOperation.swift | 125 ++++++++++++++ ...loadManager.swift => UploadsManager.swift} | 135 +++++---------- .../Sources/Plumbing/Message+Packaging.swift | 89 ++++++---- .../Plumbing/ProgressCallbackWrapper.swift | 2 +- .../Sources/Plumbing/PublicTypes.swift | 2 +- .../Sources/Plumbing/SDKRequestHandler.swift | 7 + .../HttpClientProtocol.swift | 8 - .../HttpClientRequestProcessor.swift | 16 +- .../ProtonDriveClient/ProtonDriveClient.swift | 156 ++++++++++++++++-- .../ProtonDriveClientConfiguration.swift | 7 + 15 files changed, 670 insertions(+), 296 deletions(-) delete mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/OperationalResilience.swift create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift rename swift/ProtonDriveSDK/Sources/FileOperations/{UploadManager.swift => UploadsManager.swift} (61%) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift deleted file mode 100644 index 45a9dbf0..00000000 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadManager.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation - -/// Handles file download operations for ProtonDrive -public actor DownloadManager { - - private let clientHandle: ObjectHandle - private let logger: Logger? - private var activeDownloads: [UUID: CancellationTokenSource] = [:] - - init(clientHandle: ObjectHandle, logger: Logger?) { - self.clientHandle = clientHandle - self.logger = logger - } - - deinit { - activeDownloads.values.forEach { - $0.free() - } - } - - /// Download file from file URL with complete download flow - public func downloadFile( - revisionUid: SDKRevisionUid, - destinationUrl: URL, - cancellationToken: UUID, - progressCallback: @escaping ProgressCallback - ) async throws { - let cancellationTokenSource = try await CancellationTokenSource(logger: logger) - activeDownloads[cancellationToken] = cancellationTokenSource - - defer { - if let cancellationTokenSource = activeDownloads[cancellationToken] { - activeDownloads[cancellationToken] = nil - cancellationTokenSource.free() - } - } - - let downloaderHandle = try await buildFileDownloader( - revisionUid: revisionUid.sdkCompatibleIdentifier, - fileURL: destinationUrl, - cancellationHandle: cancellationTokenSource.handle - ) - - defer { - freeDownloader(downloaderHandle) - } - - let downloaderRequest = Proton_Drive_Sdk_DownloadToFileRequest.with { - $0.downloaderHandle = Int64(downloaderHandle) - $0.filePath = destinationUrl.path(percentEncoded: false) - $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) - $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) - } - - let callbackState = ProgressCallbackWrapper(callback: progressCallback) - let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( - downloaderRequest, - state: WeakReference(value: callbackState), - includesLongLivedCallback: true, - logger: logger - ) - assert(downloadControllerHandle != 0) - - defer { - freeDownloadController(downloadControllerHandle) - } - - try await awaitDownloadCompletion(downloadControllerHandle) - } - - func cancelDownload(with cancellationToken: UUID) async throws { - guard let downloadCancellationToken = activeDownloads[cancellationToken] else { - throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "download")) - } - - try await downloadCancellationToken.cancel() - try await downloadCancellationToken.free() - - activeDownloads[cancellationToken] = nil - } - - /// Get a file downloader for downloading files from Drive - private func buildFileDownloader( - revisionUid: String, - fileURL: URL, - cancellationHandle: ObjectHandle - ) async throws -> ObjectHandle { - let downloaderRequest = Proton_Drive_Sdk_DriveClientGetFileDownloaderRequest.with { - $0.clientHandle = Int64(clientHandle) - $0.revisionUid = revisionUid - $0.cancellationTokenSourceHandle = Int64(cancellationHandle) - } - - let downloaderHandle: ObjectHandle = try await SDKRequestHandler.send(downloaderRequest, logger: logger) - assert(downloaderHandle != 0) - return downloaderHandle - } - - /// Wait for download completion - private func awaitDownloadCompletion(_ downloadControllerHandle: ObjectHandle) async throws { - assert(downloadControllerHandle != 0) - let awaitDownloadCompletionRequest = Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest.with { - $0.downloadControllerHandle = Int64(downloadControllerHandle) - } - - try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void - } - - /// Free a file downloader when no longer needed - private func freeDownloader(_ fileDownloaderHandle: ObjectHandle) { - Task { - let freeRequest = Proton_Drive_Sdk_FileDownloaderFreeRequest.with { - $0.fileDownloaderHandle = Int64(fileDownloaderHandle) - } - - do { - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void - } catch { - // If the request to free the downloader failed, we have a memory leak, but not much else can be done. - // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_FileDownloaderFreeRequest failed: \(error)", category: "DownloadManager.freeDownloader") - } - } - } - - /// Free a file download controller when no longer needed - private func freeDownloadController(_ downloadControllerHandle: ObjectHandle) { - Task { - let freeRequest = Proton_Drive_Sdk_DownloadControllerFreeRequest.with { - $0.downloadControllerHandle = Int64(downloadControllerHandle) - } - - do { - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void - } catch { - // If the request to free the download controller failed, we have a memory leak, but not much else can be done. - // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_DownloadControllerFreeRequest failed: \(error)", category: "DownloadManager.freeDownloadController") - } - } - } -} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift new file mode 100644 index 00000000..6e74350f --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift @@ -0,0 +1,126 @@ +import Foundation + +public enum DownloadOperationResult { + case succeeded + case pausedOnError(Error) + case failed(Error) +} + +public final class DownloadOperation: Sendable { + + private let fileDownloaderHandle: ObjectHandle + private let downloadControllerHandle: ObjectHandle + private let logger: Logger? + private let progressCallbackWrapper: ProgressCallbackWrapper + private let onOperationCancel: @Sendable () async throws -> Void + private let onOperationDispose: @Sendable () async -> Void + + private var downloadControllerHandleForProtos: Int64 { Int64(downloadControllerHandle) } + + init(fileDownloaderHandle: ObjectHandle, + downloadControllerHandle: ObjectHandle, + progressCallbackWrapper: ProgressCallbackWrapper, + logger: Logger?, + onOperationCancel: @Sendable @escaping () async throws -> Void, + onOperationDispose: @Sendable @escaping () async -> Void) { + assert(fileDownloaderHandle != 0) + assert(downloadControllerHandle != 0) + self.fileDownloaderHandle = fileDownloaderHandle + self.downloadControllerHandle = downloadControllerHandle + self.progressCallbackWrapper = progressCallbackWrapper + self.logger = logger + self.onOperationCancel = onOperationCancel + self.onOperationDispose = onOperationDispose + } + + /// Wait for download completion + public func awaitDownloadCompletion() async -> DownloadOperationResult { + do { + let awaitDownloadCompletionRequest = Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest.with { + $0.downloadControllerHandle = downloadControllerHandleForProtos + } + + try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void + return .succeeded + } catch { + if let isPaused = try? await isPaused(), isPaused { + // if the operation is paused, we can try recovering from the error + return .pausedOnError(error) + } else { + return .failed(error) + } + } + } + + public func pause() async throws { + let pauseRequest = Proton_Drive_Sdk_DownloadControllerPauseRequest.with { + $0.downloadControllerHandle = downloadControllerHandleForProtos + } + try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void + } + + public func resume() async throws { + let resumeRequest = Proton_Drive_Sdk_DownloadControllerResumeRequest.with { + $0.downloadControllerHandle = downloadControllerHandleForProtos + } + try await SDKRequestHandler.send(resumeRequest, logger: logger) as Void + } + + public func isPaused() async throws -> Bool { + let isPausedRequest = Proton_Drive_Sdk_DownloadControllerIsPausedRequest.with { + $0.downloadControllerHandle = downloadControllerHandleForProtos + } + return try await SDKRequestHandler.send(isPausedRequest, logger: logger) + } + + // a convenience API allowing for cancelling the operation through DownloadOperation instance + public func cancel() async throws { + try await onOperationCancel() + } + + deinit { + Self.freeSDKObjects(downloadControllerHandle, fileDownloaderHandle, logger, onOperationDispose) + } + + private static func freeSDKObjects( + _ downloadControllerHandle: ObjectHandle, + _ fileDownloaderHandle: ObjectHandle, + _ logger: Logger?, + _ onOperationDispose: @Sendable @escaping () async -> Void + ) { + Task { + await onOperationDispose() + await freeDownloadController(Int64(downloadControllerHandle), logger) + await freeFileDownloader(Int64(fileDownloaderHandle), logger) + } + } + + /// Free a file downloader when no longer needed + private static func freeFileDownloader(_ fileDownloaderHandle: Int64, _ logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_FileDownloaderFreeRequest.with { + $0.fileDownloaderHandle = fileDownloaderHandle + } + + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the downloader failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_FileDownloaderFreeRequest failed: \(error)", category: "DownloadManager.freeDownloader") + } + } + + /// Free a file download controller when no longer needed + private static func freeDownloadController(_ downloadControllerHandle: Int64, _ logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_DownloadControllerFreeRequest.with { + $0.downloadControllerHandle = downloadControllerHandle + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the download controller failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_DownloadControllerFreeRequest failed: \(error)", category: "DownloadController.freeDownloadController") + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift index f5c9d42e..69fe8de5 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift @@ -1,7 +1,7 @@ import Foundation /// Handles file download operations for ProtonDrive -public actor DownloadThumbnailsManager { +actor DownloadThumbnailsManager { private let clientHandle: ObjectHandle private let logger: Logger? @@ -19,7 +19,7 @@ public actor DownloadThumbnailsManager { } /// Download thumbnails for file UIDs - public func downloadThumbnails( + func downloadThumbnails( fileUids: [SDKNodeUid], type: ThumbnailData.ThumbnailType, cancellationToken: UUID diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift new file mode 100644 index 00000000..f7f2717f --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Handles file download operations for ProtonDrive +actor DownloadsManager { + + private let clientHandle: ObjectHandle + private let logger: Logger? + private var activeDownloads: [UUID: CancellationTokenSource] = [:] + + init(clientHandle: ObjectHandle, logger: Logger?) { + self.clientHandle = clientHandle + self.logger = logger + } + + deinit { + activeDownloads.values.forEach { + $0.free() + } + } + + func downloadFileOperation( + revisionUid: SDKRevisionUid, + destinationUrl: URL, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> DownloadOperation { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeDownloads[cancellationToken] = cancellationTokenSource + + let downloaderHandle = try await buildFileDownloader( + revisionUid: revisionUid.sdkCompatibleIdentifier, + fileURL: destinationUrl, + cancellationHandle: cancellationTokenSource.handle + ) + + let downloaderRequest = Proton_Drive_Sdk_DownloadToFileRequest.with { + $0.downloaderHandle = Int64(downloaderHandle) + $0.filePath = destinationUrl.path(percentEncoded: false) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + } + + let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + downloaderRequest, + state: WeakReference(value: callbackState), + includesLongLivedCallback: true, + logger: logger + ) + + return DownloadOperation( + fileDownloaderHandle: downloaderHandle, + downloadControllerHandle: downloadControllerHandle, + progressCallbackWrapper: callbackState, + logger: logger, + onOperationCancel: { [weak self] in + guard let self else { return } + try await self.cancelDownload(with: cancellationToken) + }, + onOperationDispose: { [weak self] in + guard let self else { return } + await self.freeCancellationTokenSourceIfNeeded(cancellationToken: cancellationToken) + } + ) + } + + // API to cancel operation when the client does not use the DownloadOperation + func cancelDownload(with cancellationToken: UUID) async throws { + guard let downloadCancellationToken = activeDownloads[cancellationToken] else { + throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "download")) + } + + try await downloadCancellationToken.cancel() + + activeDownloads[cancellationToken] = nil + downloadCancellationToken.free() + } + + private func freeCancellationTokenSourceIfNeeded(cancellationToken: UUID) { + guard let cancellationTokenSource = activeDownloads[cancellationToken] else { return } + activeDownloads[cancellationToken] = nil + cancellationTokenSource.free() + } + + /// Get a file downloader for downloading files from Drive + private func buildFileDownloader( + revisionUid: String, + fileURL: URL, + cancellationHandle: ObjectHandle + ) async throws -> ObjectHandle { + let downloaderRequest = Proton_Drive_Sdk_DriveClientGetFileDownloaderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.revisionUid = revisionUid + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let downloaderHandle: ObjectHandle = try await SDKRequestHandler.send(downloaderRequest, logger: logger) + assert(downloaderHandle != 0) + return downloaderHandle + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/OperationalResilience.swift b/swift/ProtonDriveSDK/Sources/FileOperations/OperationalResilience.swift new file mode 100644 index 00000000..707bc914 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/OperationalResilience.swift @@ -0,0 +1,46 @@ +import Foundation + +public protocol OperationalResilience: Sendable { + func performRetry( + _ retryCounter: UInt, _ error: Error, _ work: @Sendable (UInt) async throws -> T + ) async throws -> T +} + +public final class BasicOperationalResilience: OperationalResilience, Sendable { + + public static let `default` = BasicOperationalResilience() + + public static let noop = BasicOperationalResilience(maxRetries: 0) + + private let maxRetries: UInt + private let baseRetryDurationInMilliseconds: Double + private let jitterFactor: Double + + public init(maxRetries: UInt = 5, + baseRetryDurationInMilliseconds: Double = 30_000.0, /* 30 s */ + jitterFactor: Double = 0.1 /* max 3s jitter */) { + self.maxRetries = maxRetries + self.baseRetryDurationInMilliseconds = baseRetryDurationInMilliseconds + self.jitterFactor = jitterFactor + } + + public func performRetry( + _ retryCounter: UInt, _ previousError: Error, _ work: @Sendable (UInt) async throws -> T + ) async throws -> T { + + guard retryCounter < maxRetries else { + throw previousError + } + + let maxJitterInMilliseconds: Double = jitterFactor * baseRetryDurationInMilliseconds + let jitterInMilliseconds = Double.random(in: 0.0...maxJitterInMilliseconds) + let retryDurationInMilliseconds = Int(baseRetryDurationInMilliseconds + jitterInMilliseconds) + do { + try await Task.sleep(for: .milliseconds(retryDurationInMilliseconds)) + } catch { + // we don't care about the task sleep error + throw previousError + } + return try await work(retryCounter + 1) + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift new file mode 100644 index 00000000..4a73f261 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift @@ -0,0 +1,125 @@ +import Foundation + +public enum UploadOperationResult { + case succeeded(UploadedFileIdentifiers) + case pausedOnError(Error) + case failed(Error) +} + +public final class UploadOperation: Sendable { + private let fileUploaderHandle: ObjectHandle + private let uploadControllerHandle: ObjectHandle + private let progressCallbackWrapper: ProgressCallbackWrapper + private let logger: Logger? + private let onOperationCancel: @Sendable () async throws -> Void + private let onOperationDispose: @Sendable () async -> Void + + private var uploadControllerHandleForProto: Int64 { Int64(uploadControllerHandle) } + + init(fileUploaderHandle: ObjectHandle, + uploadControllerHandle: ObjectHandle, + progressCallbackWrapper: ProgressCallbackWrapper, + logger: Logger?, + onOperationCancel: @Sendable @escaping () async throws -> Void, + onOperationDispose: @Sendable @escaping () async -> Void) { + assert(fileUploaderHandle != 0) + assert(uploadControllerHandle != 0) + self.fileUploaderHandle = fileUploaderHandle + self.uploadControllerHandle = uploadControllerHandle + self.progressCallbackWrapper = progressCallbackWrapper + self.logger = logger + self.onOperationCancel = onOperationCancel + self.onOperationDispose = onOperationDispose + } + + /// Wait for upload completion + public func awaitUploadCompletion() async -> UploadOperationResult { + let awaitUploadCompletionRequest = Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest.with { + $0.uploadControllerHandle = uploadControllerHandleForProto + } + + do { + let uploadResult: Proton_Drive_Sdk_UploadResult = try await SDKRequestHandler.send(awaitUploadCompletionRequest, logger: logger) + guard let result = UploadedFileIdentifiers(interopUploadResult: uploadResult) else { + throw ProtonDriveSDKError(interopError: .wrongResult(message: "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)")) + } + return .succeeded(result) + } catch { + if let isPaused = try? await isPaused(), isPaused { + return .pausedOnError(error) + } else { + return .failed(error) + } + } + } + + public func pause() async throws { + let pauseRequest = Proton_Drive_Sdk_UploadControllerPauseRequest.with { + $0.uploadControllerHandle = uploadControllerHandleForProto + } + try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void + } + + public func resume() async throws { + let resumeRequest = Proton_Drive_Sdk_UploadControllerResumeRequest.with { + $0.uploadControllerHandle = uploadControllerHandleForProto + } + try await SDKRequestHandler.send(resumeRequest, logger: logger) as Void + } + + public func isPaused() async throws -> Bool { + let isPausedRequest = Proton_Drive_Sdk_UploadControllerIsPausedRequest.with { + $0.uploadControllerHandle = uploadControllerHandleForProto + } + return try await SDKRequestHandler.send(isPausedRequest, logger: logger) + } + + // a convenience API allowing for cancelling the operation through UploadOperation instance + public func cancel() async throws { + try await onOperationCancel() + } + + deinit { + Self.freeSDKObjects(uploadControllerHandle, fileUploaderHandle, logger, onOperationDispose) + } + + private static func freeSDKObjects( + _ uploadControllerHandle: ObjectHandle, + _ fileUploaderHandle: ObjectHandle, + _ logger: Logger?, + _ onOperationDispose: @Sendable @escaping () async -> Void + ) { + Task { + await onOperationDispose() + await freeFileUploadController(Int64(uploadControllerHandle), logger: logger) + await freeFileUploader(Int64(fileUploaderHandle), logger) + } + } + + private static func freeFileUploadController(_ uploadControllerHandle: Int64, logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { + $0.uploadControllerHandle = uploadControllerHandle + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the file upload controller failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_UploadControllerFreeRequest failed: \(error)", category: "UploadController.freeFileUploadController") + } + } + + /// Free a file uploader when no longer needed + private static func freeFileUploader(_ fileUploaderHandle: Int64, _ logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_FileUploaderFreeRequest.with { + $0.fileUploaderHandle = fileUploaderHandle + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the file uploader failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_FileUploaderFreeRequest failed: \(error)", category: "UploadManager.freeFileUploader") + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift similarity index 61% rename from swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift rename to swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift index 853c4544..8632f00f 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift @@ -2,7 +2,7 @@ import Foundation import SwiftProtobuf /// Handles file upload operations for ProtonDrive -public actor UploadManager { +actor UploadsManager { private let clientHandle: ObjectHandle private let logger: Logger? @@ -18,9 +18,8 @@ public actor UploadManager { $0.free() } } - - /// Upload file from file URL with complete upload flow - public func uploadFile( + + func uploadFileOperation( parentFolderUid: SDKNodeUid, name: String, fileURL: URL, @@ -31,17 +30,10 @@ public actor UploadManager { overrideExistingDraft: Bool, cancellationToken: UUID, progressCallback: @escaping ProgressCallback - ) async throws -> FileNodeUploadResult { + ) async throws -> UploadOperation { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) activeUploads[cancellationToken] = cancellationTokenSource - defer { - if let cancellationTokenSource = activeUploads[cancellationToken] { - activeUploads[cancellationToken] = nil - cancellationTokenSource.free() - } - } - let cancellationHandle = cancellationTokenSource.handle let uploaderHandle = try await buildFileUploader( @@ -55,21 +47,18 @@ public actor UploadManager { logger: logger ) - defer { - freeFileUploader(uploaderHandle) - } - - let uploadedNode = try await uploadFromFile( - uploaderHandle: uploaderHandle, + let uploadController = try await uploadFromFile( + fileUploaderHandle: uploaderHandle, fileURL: fileURL, progressCallback: progressCallback, + cancellationToken: cancellationToken, cancellationHandle: cancellationHandle, thumbnails: thumbnails ) - return uploadedNode + return uploadController } - - public func uploadNewRevision( + + func uploadNewRevisionOperation( currentActiveRevisionUid: SDKRevisionUid, fileURL: URL, fileSize: Int64, @@ -77,17 +66,10 @@ public actor UploadManager { thumbnails: [ThumbnailData], cancellationToken: UUID, progressCallback: @escaping ProgressCallback - ) async throws -> FileNodeUploadResult { + ) async throws -> UploadOperation { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) activeUploads[cancellationToken] = cancellationTokenSource - defer { - if let cancellationTokenSource = activeUploads[cancellationToken] { - activeUploads[cancellationToken] = nil - cancellationTokenSource.free() - } - } - let cancellationHandle = cancellationTokenSource.handle let uploaderHandle = try await getRevisionUploader( @@ -97,34 +79,38 @@ public actor UploadManager { cancellationHandle: cancellationHandle ) - defer { - freeFileUploader(uploaderHandle) - } - - let uploadedNode = try await uploadFromFile( - uploaderHandle: uploaderHandle, + let uploadController = try await uploadFromFile( + fileUploaderHandle: uploaderHandle, fileURL: fileURL, progressCallback: progressCallback, + cancellationToken: cancellationToken, cancellationHandle: cancellationHandle, thumbnails: thumbnails ) - return uploadedNode + return uploadController } + // API to cancel operation when the client does not use the UploadOperation func cancelUpload(with cancellationToken: UUID) async throws { guard let uploadCancellationToken = activeUploads[cancellationToken] else { throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "upload")) } try await uploadCancellationToken.cancel() - try await uploadCancellationToken.free() - + + activeUploads[cancellationToken] = nil + uploadCancellationToken.free() + } + + private func freeCancellationTokenSourceIfNeeded(cancellationToken: UUID) { + guard let cancellationTokenSource = activeUploads[cancellationToken] else { return } activeUploads[cancellationToken] = nil + cancellationTokenSource.free() } } // MARK: - Revision uploader -extension UploadManager { +extension UploadsManager { private func buildFileUploader( parentFolderUid: String, name: String, @@ -176,14 +162,15 @@ extension UploadManager { } private func uploadFromFile( - uploaderHandle: ObjectHandle, + fileUploaderHandle: ObjectHandle, fileURL: URL, progressCallback: @escaping ProgressCallback, + cancellationToken: UUID, cancellationHandle: ObjectHandle, thumbnails: [ThumbnailData] - ) async throws -> FileNodeUploadResult { + ) async throws -> UploadOperation { let uploaderRequest = Proton_Drive_Sdk_UploadFromFileRequest.with { - $0.uploaderHandle = Int64(uploaderHandle) + $0.uploaderHandle = Int64(fileUploaderHandle) $0.filePath = fileURL.path(percentEncoded: false) $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) $0.cancellationTokenSourceHandle = Int64(cancellationHandle) @@ -206,58 +193,20 @@ extension UploadManager { includesLongLivedCallback: true, logger: logger ) - assert(uploadControllerHandle != 0) - defer { - freeFileUploadController(uploadControllerHandle) - } - - let uploadedNode = try await awaitUploadCompletion(uploadControllerHandle) - return uploadedNode - } - - /// Free a file uploader when no longer needed - private func freeFileUploader(_ fileUploaderHandle: ObjectHandle) { - Task { - let freeRequest = Proton_Drive_Sdk_FileUploaderFreeRequest.with { - $0.fileUploaderHandle = Int64(fileUploaderHandle) - } - do { - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void - } catch { - // If the request to free the file uploader failed, we have a memory leak, but not much else can be done. - // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_FileUploaderFreeRequest failed: \(error)", category: "UploadManager.freeFileUploader") - } - } - } - - private func freeFileUploadController(_ fileUploadControllerHandle: ObjectHandle) { - Task { - let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { - $0.uploadControllerHandle = Int64(fileUploadControllerHandle) - } - do { - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void - } catch { - // If the request to free the file upload controller failed, we have a memory leak, but not much else can be done. - // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_UploadControllerFreeRequest failed: \(error)", category: "UploadManager.freeFileUploadController") + return UploadOperation( + fileUploaderHandle: fileUploaderHandle, + uploadControllerHandle: uploadControllerHandle, + progressCallbackWrapper: callbackState, + logger: logger, + onOperationCancel: { [weak self] in + guard let self else { return } + try await self.cancelUpload(with: cancellationToken) + }, + onOperationDispose: { [weak self] in + guard let self else { return } + await self.freeCancellationTokenSourceIfNeeded(cancellationToken: cancellationToken) } - } - } - - /// Wait for upload completion - private func awaitUploadCompletion(_ uploadControllerHandle: ObjectHandle) async throws -> FileNodeUploadResult { - assert(uploadControllerHandle != 0) - let awaitUploadCompletionRequest = Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest.with { - $0.uploadControllerHandle = Int64(uploadControllerHandle) - } - - let uploadResult: Proton_Drive_Sdk_UploadResult = try await SDKRequestHandler.send(awaitUploadCompletionRequest, logger: logger) - guard let result = FileNodeUploadResult(interopUploadResult: uploadResult) else { - throw ProtonDriveSDKError(interopError: .wrongResult(message: "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)")) - } - return result + ) } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 6f608aa7..693b9d3e 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -13,10 +13,6 @@ extension Message { /// Packs any request into a Proton_Sdk_Request or Proton_Drive_Sdk_Request. func packIntoRequest() throws -> Message { switch self { - case let request as Proton_Sdk_SessionBeginRequest: - Proton_Sdk_Request.with { - $0.payload = .sessionBegin(request) - } case let request as Proton_Sdk_CancellationTokenSourceCreateRequest: Proton_Sdk_Request.with { @@ -33,24 +29,29 @@ extension Message { $0.payload = .cancellationTokenSourceFree(request) } + case let request as Proton_Sdk_StreamReadRequest: + Proton_Sdk_Request.with { + $0.payload = .streamRead(request) + } + case let request as Proton_Sdk_LoggerProviderCreate: Proton_Sdk_Request.with { $0.payload = .loggerProviderCreate(request) } - case let request as Proton_Drive_Sdk_DriveClientCreateFromSessionRequest: + case let request as Proton_Drive_Sdk_DriveClientCreateRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .driveClientCreateFromSession(request) + $0.payload = .driveClientCreate(request) } - - case let request as Proton_Drive_Sdk_DriveClientGetAvailableNameRequest: + + case let request as Proton_Drive_Sdk_DriveClientCreateFromSessionRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .driveClientGetAvailableName(request) + $0.payload = .driveClientCreateFromSession(request) } - case let request as Proton_Drive_Sdk_DriveClientCreateRequest: + case let request as Proton_Drive_Sdk_DriveClientFreeRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .driveClientCreate(request) + $0.payload = .driveClientFree(request) } case let request as Proton_Drive_Sdk_DriveClientGetFileUploaderRequest: @@ -58,34 +59,54 @@ extension Message { $0.payload = .driveClientGetFileUploader(request) } + case let request as Proton_Drive_Sdk_DriveClientGetFileRevisionUploaderRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientGetFileRevisionUploader(request) + } + case let request as Proton_Drive_Sdk_DriveClientGetFileDownloaderRequest: Proton_Drive_Sdk_Request.with { $0.payload = .driveClientGetFileDownloader(request) } + case let request as Proton_Drive_Sdk_DriveClientGetAvailableNameRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientGetAvailableName(request) + } + + case let request as Proton_Drive_Sdk_DriveClientGetThumbnailsRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientGetThumbnails(request) + } + case let request as Proton_Drive_Sdk_UploadFromFileRequest: Proton_Drive_Sdk_Request.with { $0.payload = .uploadFromFile(request) } - case let request as Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest: + case let request as Proton_Drive_Sdk_FileUploaderFreeRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .uploadControllerAwaitCompletion(request) + $0.payload = .fileUploaderFree(request) } - case let request as Proton_Drive_Sdk_DownloadToFileRequest: + case let request as Proton_Drive_Sdk_UploadControllerIsPausedRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .downloadToFile(request) + $0.payload = .uploadControllerIsPaused(request) } - case let request as Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest: + case let request as Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .downloadControllerAwaitCompletion(request) + $0.payload = .uploadControllerAwaitCompletion(request) } - case let request as Proton_Drive_Sdk_FileUploaderFreeRequest: + case let request as Proton_Drive_Sdk_UploadControllerPauseRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .fileUploaderFree(request) + $0.payload = .uploadControllerPause(request) + } + + case let request as Proton_Drive_Sdk_UploadControllerResumeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .uploadControllerResume(request) } case let request as Proton_Drive_Sdk_UploadControllerFreeRequest: @@ -93,27 +114,39 @@ extension Message { $0.payload = .uploadControllerFree(request) } + case let request as Proton_Drive_Sdk_DownloadToFileRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadToFile(request) + } + case let request as Proton_Drive_Sdk_FileDownloaderFreeRequest: Proton_Drive_Sdk_Request.with { $0.payload = .fileDownloaderFree(request) } - case let request as Proton_Drive_Sdk_DownloadControllerFreeRequest: + case let request as Proton_Drive_Sdk_DownloadControllerIsPausedRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .downloadControllerFree(request) + $0.payload = .downloadControllerIsPaused(request) } - case let request as Proton_Drive_Sdk_DriveClientGetFileRevisionUploaderRequest: + case let request as Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .driveClientGetFileRevisionUploader(request) + $0.payload = .downloadControllerAwaitCompletion(request) } - case let request as Proton_Drive_Sdk_DriveClientGetThumbnailsRequest: + + case let request as Proton_Drive_Sdk_DownloadControllerPauseRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .driveClientGetThumbnails(request) + $0.payload = .downloadControllerPause(request) } - case let request as Proton_Sdk_StreamReadRequest: - Proton_Sdk_Request.with { - $0.payload = .streamRead(request) + + case let request as Proton_Drive_Sdk_DownloadControllerResumeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadControllerResume(request) + } + + case let request as Proton_Drive_Sdk_DownloadControllerFreeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadControllerFree(request) } default: diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift index 09a0f603..a42ee8f8 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -1,7 +1,7 @@ import Foundation import CProtonDriveSDK -final class ProgressCallbackWrapper { +final class ProgressCallbackWrapper: Sendable { let callback: ProgressCallback init(callback: @escaping ProgressCallback) { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 3936061b..6c864cab 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -143,7 +143,7 @@ public struct FileRevision: Sendable { } } -public struct FileNodeUploadResult: Sendable { +public struct UploadedFileIdentifiers: Sendable { public let nodeUid: SDKNodeUid public let revisionUid: SDKRevisionUid diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index f986a830..f41ed77d 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -131,6 +131,13 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } voidBox.resume() + + case .value(let value) where value.isA(Google_Protobuf_BoolValue.self): + let unpackedValue = try Google_Protobuf_BoolValue(unpackingAny: value) + guard let boolResultBox = box as? any Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) + } + boolResultBox.resume(returning: unpackedValue.value) case .value(let value) where value.isA(Google_Protobuf_Int64Value.self): let unpackedValue = try Google_Protobuf_Int64Value(unpackingAny: value).value diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift index d75555ca..cae0be19 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift @@ -3,8 +3,6 @@ import SwiftProtobuf /// Protocol to be implemented by object making http requests. public protocol HttpClientProtocol: AnyObject, Sendable { - func identifyRequestType(url: String, method: String) -> RequestType - /// Drive api calls (takes `/drive/...` path) func requestDriveApi( method: String, @@ -30,12 +28,6 @@ public protocol HttpClientProtocol: AnyObject, Sendable { ) async -> Result } -public enum RequestType { - case driveAPI(relativePath: String) - case uploadToStorage - case downloadFromStorage -} - public struct HttpClientResponse { public let data: Data? public let headers: [(String, [String])] diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift index 7391ae89..797e98a0 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -60,28 +60,32 @@ enum HttpClientRequestProcessor { httpRequestData: Proton_Sdk_HttpRequest, callbackPointer: Int ) async throws { - let requestType = client.httpClient.identifyRequestType(url: httpRequestData.url, method: httpRequestData.method) - switch requestType { - case .driveAPI(let driveRelativePath): + switch httpRequestData.type { + case .regularApi: + guard let relativeApiPath = httpRequestData.url.split(separator: "/drive/").last else { + fatalError("The regular API calls must always have the '/drive/' prefix in the path") + } try await callDriveApi( - driveRelativePath: driveRelativePath, + driveRelativePath: "/drive/" + relativeApiPath, client: client, httpRequestData: httpRequestData, callbackPointer: callbackPointer ) - case .uploadToStorage: + case .storageUpload: try await uploadToStorage( client: client, httpRequestData: httpRequestData, callbackPointer: callbackPointer ) - case .downloadFromStorage: + case .storageDownload: try await downloadFromStorage( client: client, httpRequestData: httpRequestData, callbackPointer: callbackPointer ) + case .UNRECOGNIZED(let int): + fatalError("Unknown HttpRequestType: \(int)") } } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index fb309631..69e86b1c 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -5,10 +5,10 @@ import Foundation /// Create a single object of this class and use it to perform downloads, uploads and all other supported operations. public actor ProtonDriveClient: Sendable { - private var clientHandle: ObjectHandle! + private var clientHandle: ObjectHandle = 0 - private var uploadManager: UploadManager! - private var downloadManager: DownloadManager! + private var uploadsManager: UploadsManager! + private var downloadsManager: DownloadsManager! private var thumbnailsManager: DownloadThumbnailsManager! let logger: ProtonDriveSDK.Logger @@ -69,11 +69,12 @@ public actor ProtonDriveClient: Sendable { let handle: Proton_Drive_Sdk_DriveClientCreateRequest.CallResultType = try await SDKRequestHandler.sendInteropRequest( clientCreateRequest, state: weakSelf, includesLongLivedCallback: true, logger: logger ) + assert(handle != 0) self.clientHandle = ObjectHandle(handle) logger.trace("client handle: \(clientHandle)", category: "ProtonDriveClient") - self.uploadManager = UploadManager(clientHandle: clientHandle, logger: logger) - self.downloadManager = DownloadManager(clientHandle: clientHandle, logger: logger) + self.uploadsManager = UploadsManager(clientHandle: clientHandle, logger: logger) + self.downloadsManager = DownloadsManager(clientHandle: clientHandle, logger: logger) self.thumbnailsManager = DownloadThumbnailsManager(clientHandle: clientHandle, logger: logger) } @@ -98,13 +99,48 @@ public actor ProtonDriveClient: Sendable { return result } + /// Convenience API for when you don't need a more granular control over the download (pause, resume etc.) public func downloadFile( revisionUid: SDKRevisionUid, destinationUrl: URL, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws { - try await downloadManager.downloadFile( + let operation = try await downloadFileOperation( + revisionUid: revisionUid, + destinationUrl: destinationUrl, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + return try await awaitDownloadCompletion(operation: operation, retryCounter: 0) + } + + private func awaitDownloadCompletion( + operation: DownloadOperation, retryCounter: UInt + ) async throws { + let result = try await operation.awaitDownloadCompletion() + switch result { + case .succeeded: + return + + case .failed(let error): + throw error + + case .pausedOnError(let error): + return try await configuration.downloadOperationalResilience.performRetry(retryCounter, error) { + try await operation.resume() + return try await awaitDownloadCompletion(operation: operation, retryCounter: $0) + } + } + } + + public func downloadFileOperation( + revisionUid: SDKRevisionUid, + destinationUrl: URL, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> DownloadOperation { + try await downloadsManager.downloadFileOperation( revisionUid: revisionUid, destinationUrl: destinationUrl, cancellationToken: cancellationToken, @@ -113,9 +149,10 @@ public actor ProtonDriveClient: Sendable { } public func cancelDownload(cancellationToken: UUID) async throws { - try await downloadManager.cancelDownload(with: cancellationToken) + try await downloadsManager.cancelDownload(with: cancellationToken) } + /// Convenience API for when you don't need a more granular control over the upload (pause, resume etc.) public func uploadFile( parentFolderUid: SDKNodeUid, name: String, @@ -127,8 +164,55 @@ public actor ProtonDriveClient: Sendable { overrideExistingDraft: Bool, cancellationToken: UUID, progressCallback: @escaping ProgressCallback - ) async throws -> FileNodeUploadResult { - try await uploadManager.uploadFile( + ) async throws -> UploadedFileIdentifiers { + let operation = try await uploadFileOperation( + parentFolderUid: parentFolderUid, + name: name, + url: url, + fileSize: fileSize, + modificationDate: modificationDate, + mediaType: mediaType, + thumbnails: thumbnails, + overrideExistingDraft: overrideExistingDraft, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + + return try await awaitUploadCompletion(operation: operation, retryCounter: 0) + } + + private func awaitUploadCompletion( + operation: UploadOperation, retryCounter: UInt + ) async throws -> UploadedFileIdentifiers { + let result = try await operation.awaitUploadCompletion() + switch result { + case .succeeded(let uploadResult): + return uploadResult + + case .failed(let error): + throw error + + case .pausedOnError(let error): + return try await configuration.uploadOperationalResilience.performRetry(retryCounter, error) { + try await operation.resume() + return try await awaitUploadCompletion(operation: operation, retryCounter: $0) + } + } + } + + public func uploadFileOperation( + parentFolderUid: SDKNodeUid, + name: String, + url: URL, + fileSize: Int64, + modificationDate: Date, + mediaType: String, + thumbnails: [ThumbnailData], + overrideExistingDraft: Bool, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> UploadOperation { + try await uploadsManager.uploadFileOperation( parentFolderUid: parentFolderUid, name: name, fileURL: url, @@ -142,6 +226,7 @@ public actor ProtonDriveClient: Sendable { ) } + /// Convenience API for when you don't need a more granular control over the upload (pause, resume etc.) public func uploadNewRevision( currentActiveRevisionUid: SDKRevisionUid, fileURL: URL, @@ -150,8 +235,30 @@ public actor ProtonDriveClient: Sendable { thumbnails: [ThumbnailData], cancellationToken: UUID, progressCallback: @escaping ProgressCallback - ) async throws -> FileNodeUploadResult { - try await uploadManager.uploadNewRevision( + ) async throws -> UploadedFileIdentifiers { + let operation = try await uploadNewRevisionOperation( + currentActiveRevisionUid: currentActiveRevisionUid, + fileURL: fileURL, + fileSize: fileSize, + modificationDate: modificationDate, + thumbnails: thumbnails, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + + return try await awaitUploadCompletion(operation: operation, retryCounter: 0) + } + + public func uploadNewRevisionOperation( + currentActiveRevisionUid: SDKRevisionUid, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + thumbnails: [ThumbnailData], + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> UploadOperation { + return try await uploadsManager.uploadNewRevisionOperation( currentActiveRevisionUid: currentActiveRevisionUid, fileURL: fileURL, fileSize: fileSize, @@ -161,6 +268,10 @@ public actor ProtonDriveClient: Sendable { progressCallback: progressCallback ) } + + public func cancelUpload(cancellationToken: UUID) async throws { + try await uploadsManager.cancelUpload(with: cancellationToken) + } public func getAvailableName( parentFolderUid: SDKNodeUid, @@ -184,10 +295,6 @@ public actor ProtonDriveClient: Sendable { return nameResult } - public func cancelUpload(cancellationToken: UUID) async throws { - try await uploadManager.cancelUpload(with: cancellationToken) - } - static func unbox(callbackPointer: Int, releaseBox: () -> Void, weakDriveClient: WeakReference) -> ProtonDriveClient? { guard let driveClient = weakDriveClient.value else { releaseBox() @@ -209,4 +316,23 @@ public actor ProtonDriveClient: Sendable { cancellationToken: cancellationToken ) } + + deinit { + guard clientHandle != 0 else { return } + Self.freeProtonDriveClient(Int64(clientHandle), logger) + } + + private static func freeProtonDriveClient(_ clientHandle: Int64, _ logger: Logger?) { + Task { + let freeRequest = Proton_Drive_Sdk_DriveClientFreeRequest.with { + $0.clientHandle = clientHandle + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the client failed, we have a memory leak, but not much else can be done. + logger?.error("Proton_Drive_Sdk_DriveClientFreeRequest failed: \(error)", category: "ProtonDriveClient.freeProtonDriveClient") + } + } + } } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift index f2674b3c..2ea9efcc 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift @@ -26,6 +26,9 @@ public struct ProtonDriveClientConfiguration: Sendable { let clientUID: String let httpTransferBufferSize: Int // Used for establishing buffer for http streams + let downloadOperationalResilience: OperationalResilience + let uploadOperationalResilience: OperationalResilience + let entityCachePath: String? let secretCachePath: String? @@ -36,6 +39,8 @@ public struct ProtonDriveClientConfiguration: Sendable { baseURL: String, clientUID: String, httpTransferBufferSize: Int = defaultHttpTransportBufferSize, + downloadOperationalResilience: OperationalResilience = BasicOperationalResilience.default, + uploadOperationalResilience: OperationalResilience = BasicOperationalResilience.default, boundStreamsCreator: @Sendable @escaping () throws -> (InputStream, OutputStream, Int) = defaultBoundStreamsCreator, downloadStreamCreator: @Sendable @escaping (URLSession.AsyncBytes) -> AnyAsyncSequence = defaultDownloadStreamCreator, entityCachePath: String? = nil, @@ -44,6 +49,8 @@ public struct ProtonDriveClientConfiguration: Sendable { self.baseURL = baseURL self.clientUID = clientUID self.httpTransferBufferSize = httpTransferBufferSize + self.downloadOperationalResilience = downloadOperationalResilience + self.uploadOperationalResilience = uploadOperationalResilience self.boundStreamsCreator = boundStreamsCreator self.downloadStreamCreator = downloadStreamCreator self.entityCachePath = entityCachePath From 6bf9cd9de86e19c8b1bbe7e057aaa7ace8e9d288 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Dec 2025 14:08:14 +0100 Subject: [PATCH 375/791] js/v0.7.3 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 49230eea..73864677 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.7.2", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.7.2", + "version": "0.7.3", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index 4f3f5250..b01f9dca 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.7.2", + "version": "0.7.3", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 8583154250b55ddac94bc370674181a8b427fb38 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Dec 2025 16:10:27 +0100 Subject: [PATCH 376/791] Compress extended attributes --- js/sdk/src/crypto/driveCrypto.ts | 1 + js/sdk/src/crypto/interface.ts | 1 + js/sdk/src/crypto/openPGPCrypto.ts | 3 +++ 3 files changed, 5 insertions(+) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 16b8b842..83656c17 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -499,6 +499,7 @@ export class DriveCrypto { undefined, [encryptionKey], signingKey, + { compress: true }, ); return { armoredExtendedAttributes, diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 3803b7fe..749bb88c 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -141,6 +141,7 @@ export interface OpenPGPCrypto { sessionKey: SessionKey | undefined, encryptionKeys: PrivateKey[], signingKey: PrivateKey, + options?: { compress?: boolean }, ) => Promise<{ armoredData: string; }>; diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index df303ac6..f22a8993 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -26,6 +26,7 @@ export interface OpenPGPCryptoProxy { encryptionKeys: PrivateKey[]; signingKeys?: PrivateKey; detached?: boolean; + compress?: boolean; }) => Promise<{ message: string | Uint8Array; signature?: string | Uint8Array; @@ -166,6 +167,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey | undefined, encryptionKeys: PrivateKey[], signingKey: PrivateKey, + options: { compress?: boolean } = {}, ) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, @@ -173,6 +175,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey, signingKeys: signingKey, detached: false, + compress: options.compress || false, }); return { armoredData: armoredData as string, From 12accb44e131f871571cd228410f51d9a878015f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Dec 2025 10:20:38 +0100 Subject: [PATCH 377/791] Fix old content key packet verification --- js/sdk/src/interface/account.ts | 6 + .../src/internal/nodes/cryptoService.test.ts | 115 +++++++++++++++++- js/sdk/src/internal/nodes/cryptoService.ts | 61 ++++++++-- 3 files changed, 172 insertions(+), 10 deletions(-) diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index 7596e484..cfc3cb89 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -7,6 +7,12 @@ export interface ProtonDriveAccount { * @throws Error If there is no primary address. */ getOwnPrimaryAddress(): Promise; + /** + * Get all own addresses. + * + * @throws Error If there are no addresses. + */ + getOwnAddresses(): Promise; /** * Get own address by email or addressId. * diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index a2295abf..958a27bc 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -20,6 +20,9 @@ describe('nodesCryptoService', () => { let cryptoService: NodesCryptoService; + const publicAddressKey = { _idx: 21312 }; + const ownPrivateAddressKey = { id: 'id', key: 'key' as unknown as PrivateKey }; + beforeEach(() => { jest.clearAllMocks(); @@ -71,7 +74,15 @@ describe('nodesCryptoService', () => { }; account = { // @ts-expect-error No need to implement all methods for mocking - getPublicKeys: jest.fn(async () => [{ _idx: 21312 }]), + getPublicKeys: jest.fn(async () => [publicAddressKey]), + getOwnAddresses: jest.fn(async () => [ + { + email: 'email', + addressId: 'addressId', + primaryKeyIndex: 0, + keys: [ownPrivateAddressKey], + }, + ]), }; // @ts-expect-error No need to implement all methods for mocking sharesService = { @@ -576,6 +587,7 @@ describe('nodesCryptoService', () => { armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', file: { base64ContentKeyPacket: 'base64ContentKeyPacket', + armoredContentKeyPacketSignature: 'armoredContentKeyPacketSignature', }, activeRevision: { uid: 'revisionUid', @@ -764,7 +776,7 @@ describe('nodesCryptoService', () => { }); }); - it('on content key packet', async () => { + it('on content key packet without fallback verification', async () => { driveCrypto.decryptAndVerifySessionKey = jest.fn( async () => Promise.resolve({ @@ -789,6 +801,105 @@ describe('nodesCryptoService', () => { error: 'verification error', }); }); + + it('on content key packet with successful fallback verification', async () => { + driveCrypto.decryptAndVerifySessionKey = jest + .fn() + .mockImplementationOnce( + async () => + Promise.resolve({ + sessionKey: 'contentKeyPacketSessionKey', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }) as any, + ) + .mockImplementationOnce( + async () => + Promise.resolve({ + sessionKey: 'contentKeyPacketSessionKey', + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }) as any, + ); + + const result = await cryptoService.decryptNode( + { + ...encryptedNode, + creationTime: new Date('2022-01-01'), + }, + parentKey, + ); + verifyResult(result); + expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledTimes(2); + expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith( + 'base64ContentKeyPacket', + 'armoredContentKeyPacketSignature', + 'decryptedKey', + ['decryptedKey', publicAddressKey], + ); + expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith( + 'base64ContentKeyPacket', + 'armoredContentKeyPacketSignature', + 'decryptedKey', + [ownPrivateAddressKey.key], + ); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + it('on content key packet with failed fallback verification', async () => { + driveCrypto.decryptAndVerifySessionKey = jest + .fn() + .mockImplementationOnce( + async () => + Promise.resolve({ + sessionKey: 'contentKeyPacketSessionKey', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }) as any, + ) + .mockImplementationOnce( + async () => + Promise.resolve({ + sessionKey: 'contentKeyPacketSessionKey', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('fallback verification error')], + }) as any, + ); + + const result = await cryptoService.decryptNode( + { + ...encryptedNode, + creationTime: new Date('2022-01-01'), + }, + parentKey, + ); + verifyResult(result, { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Signature verification for content key failed: verification error', + }, + }, + }); + expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledTimes(2); + expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith( + 'base64ContentKeyPacket', + 'armoredContentKeyPacketSignature', + 'decryptedKey', + ['decryptedKey', publicAddressKey], + ); + expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith( + 'base64ContentKeyPacket', + 'armoredContentKeyPacketSignature', + 'decryptedKey', + [ownPrivateAddressKey.key], + ); + verifyLogEventVerificationError({ + field: 'nodeContentKey', + error: 'verification error', + fromBefore2024: true, + }); + }); }); describe('should decrypt with decryption issues', () => { diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index b7b0d146..f1c8bb77 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -25,6 +25,7 @@ import { EncryptedRevision, DecryptedUnparsedRevision, NodeSigningKeys, + EncryptedNodeFileCrypto, } from './interface'; export interface NodesCryptoReporter { @@ -200,14 +201,7 @@ export class NodesCryptoService { if ('file' in node.encryptedCrypto) { const [activeRevisionPromise, contentKeyPacketSessionKeyPromise] = [ this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key), - this.driveCrypto.decryptAndVerifySessionKey( - node.encryptedCrypto.file.base64ContentKeyPacket, - node.encryptedCrypto.file.armoredContentKeyPacketSignature, - key, - // Content key packet is signed with the node key, but - // in the past some clients signed with the address key. - [key, ...keyVerificationKeys], - ), + this.decryptContentKeyPacket(node, node.encryptedCrypto, key, keyVerificationKeys), ]; try { @@ -502,6 +496,57 @@ export class NodesCryptoService { }; } + private async decryptContentKeyPacket( + node: EncryptedNode, + encryptedCrypto: EncryptedNodeFileCrypto, + key: PrivateKey, + keyVerificationKeys: PublicKey[], + ): Promise<{ + sessionKey: SessionKey; + verified?: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + const result = await this.driveCrypto.decryptAndVerifySessionKey( + encryptedCrypto.file.base64ContentKeyPacket, + encryptedCrypto.file.armoredContentKeyPacketSignature, + key, + // Content key packet is signed with the node key, but + // in the past some clients signed with the address key. + [key, ...keyVerificationKeys], + ); + + // Return right away if the verification is signed or not signed. + // If the verification is failing and the file is before 2023, try + // to decrypt with all owners keys. Because of the old nodes signed + // with address key instead of node key, when the node was renamed + // or moved, it could change the address but without updating the + // content key packet, which is now failing. + if (result.verified !== VERIFICATION_STATUS.SIGNED_AND_INVALID || node.creationTime > new Date(2023, 0, 1)) { + return result; + } + + const allAddresses = await this.account.getOwnAddresses(); + const allKeys = allAddresses.flatMap((address) => address.keys.map(({ key }) => key)); + + const resultWithAllKeys = await this.driveCrypto.decryptAndVerifySessionKey( + encryptedCrypto.file.base64ContentKeyPacket, + encryptedCrypto.file.armoredContentKeyPacketSignature, + key, + // Content key packet is signed with the node key, but + // in the past some clients signed with the address key. + allKeys, + ); + + // Return original result with original error if the fallback verification also fails. + if (resultWithAllKeys.verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { + this.logger.warn( + 'Content key packet signature verification failed, but fallback to all addresses succeeded', + ); + return resultWithAllKeys; + } + return result; + } + private async decryptExtendedAttributes( node: { uid: string; creationTime: Date }, encryptedExtendedAttributes: string | undefined, From 4b0b22ab2b045f669856dc37ae9b142bae268a32 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 07:51:15 +0000 Subject: [PATCH 378/791] Use remove-mine for deleting nodes on public page --- js/sdk/src/internal/apiService/driveTypes.ts | 219 +++++++++++++++++-- js/sdk/src/internal/nodes/apiService.ts | 19 +- js/sdk/src/internal/nodes/nodesManagement.ts | 2 +- js/sdk/src/internal/shares/apiService.ts | 4 +- js/sdk/src/internal/sharingPublic/nodes.ts | 4 +- js/sdk/src/protonDriveClient.ts | 4 +- js/sdk/src/protonDrivePhotosClient.ts | 2 +- js/sdk/src/protonDrivePublicLinkClient.ts | 6 +- 8 files changed, 223 insertions(+), 37 deletions(-) diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 8013b3fe..fb6ce834 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -491,7 +491,8 @@ export interface paths { get?: never; put?: never; /** - * Delete children + * Delete drafts from folder + * @deprecated * @description Permanently delete children from folder, skipping trash. Can only be done for draft links. */ post: operations["post_drive-shares-{shareID}-folders-{linkID}-delete_multiple"]; @@ -531,8 +532,8 @@ export interface paths { get?: never; put?: never; /** - * Trash children - * @description Send children to trash + * Trash children from folder + * @deprecated */ post: operations["post_drive-shares-{shareID}-folders-{linkID}-trash_multiple"]; delete?: never; @@ -672,8 +673,8 @@ export interface paths { get?: never; put?: never; /** - * Delete multiple (v2) - * @description Permanently delete links, skipping trash. Can only be done for draft links. + * Delete drafts + * @description Permanently delete files, skipping trash. Can only be done for draft links. */ post: operations["post_drive-v2-volumes-{volumeID}-delete_multiple"]; delete?: never; @@ -815,6 +816,32 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/v2/volumes/{volumeID}/remove-mine": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Remove my nodes skipping trash + * @description This is called by Web SDK on public sharing to remove active nodes created by the same user + * as a way to delete wrongly uploaded files without going to trash. It's supported on the following conditions: + * - anonymous users must have created the node in their own session + * - for authenticated users the signature email must match + * - file/folder must have been created within the last 1 hour + * - folders must be empty + * - files must have all revisions created by this user + */ + post: operations["post_drive-v2-volumes-{volumeID}-remove-mine"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/v2/volumes/{volumeID}/links/{linkID}/rename": { parameters: { query?: never; @@ -829,6 +856,11 @@ export interface paths { * * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. + * + * Users with access only through a public sharing URL (no editor membership) are limited to renaming + * their own files and folders: + * - Unauthenticated users must have created them in their session + * - Authenticated users' email must match the signature email on the node for folders or active revision for files */ put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-rename"]; post?: never; @@ -852,6 +884,11 @@ export interface paths { * * Clients renaming a file or folder MUST reuse the existing session key * for the name as it is also used by shares pointing to the link. + * + * Users with access only through a public sharing URL (no editor membership) are limited to renaming + * their own files and folders: + * - Unauthenticated users must have created them in their session + * - Authenticated users' email must match the signature email on the node for folders or active revision for files */ put: operations["put_drive-shares-{shareID}-links-{linkID}-rename"]; post?: never; @@ -2227,7 +2264,7 @@ export interface paths { get?: never; put?: never; /** - * Delete multiple (v2) + * Delete drafts * @description See /drive/v2/volumes/{volumeID}/delete_multiple for full documentation */ post: operations["post_drive-unauth-v2-volumes-{volumeID}-delete_multiple"]; @@ -2297,6 +2334,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/unauth/v2/volumes/{volumeID}/remove-mine": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Remove my nodes skipping trash + * @description See /drive/v2/volumes/{volumeID}/remove-mine for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-remove-mine"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/unauth/v2/volumes/{volumeID}/links/{linkID}/rename": { parameters: { query?: never; @@ -2978,6 +3035,30 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/organization/volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Organization volume + * @description Only allowed to Org administrators + * + * This new volume would have: + * + OwnerOrgID filled with the orgID of the request + * + specific membership for the owner (OrgAdmin to true) + */ + post: operations["post_drive-organization-volumes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/volumes": { parameters: { query?: never; @@ -4852,6 +4933,31 @@ export interface components { /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ PhotoTags?: components["schemas"]["TagType"][] | null; }; + CreateOrgVolumeRequestDto: { + AddressID: components["schemas"]["AddressID"]; + /** @description XX's encrypted AddressKeyID. Must be the primary key from the AddressID */ + AddressKeyID: string; + ShareKey: components["schemas"]["PGPPrivateKey"]; + SharePassphrase: components["schemas"]["PGPMessage"]; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + FolderName: components["schemas"]["PGPMessage"]; + FolderKey: components["schemas"]["PGPPrivateKey"]; + FolderPassphrase: components["schemas"]["PGPMessage"]; + FolderPassphraseSignature: components["schemas"]["PGPSignature"]; + FolderHashKey: components["schemas"]["PGPMessage"]; + OrganizationID: components["schemas"]["Id"]; + /** @description Name of the org. volume. It's plain text so that name can be displayed in UI menu */ + VolumeName: string; + }; + GetVolumeResponseDto: { + Volume: components["schemas"]["VolumeResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; CreateVolumeRequestDto: { AddressID: components["schemas"]["AddressID"]; ShareKey: components["schemas"]["PGPPrivateKey"]; @@ -4875,15 +4981,6 @@ export interface components { */ ShareName: string | null; }; - GetVolumeResponseDto: { - Volume: components["schemas"]["VolumeResponseDto"]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; ListVolumesResponseDto: { Volumes: components["schemas"]["VolumeResponseDto"][]; /** @@ -6213,20 +6310,20 @@ export interface components { Album: null | null; }; /** - * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
+ * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
5Organization* Root share for organization
* @enum {integer} */ - ShareType: 1 | 2 | 3 | 4; + ShareType: 1 | 2 | 3 | 4 | 5; /** * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
6Locked
* @enum {integer} */ ShareState: 1 | 2 | 3 | 6; /** - * @description

1=Regular, 2=Photo

See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
+ * @description

1=Regular, 2=Photo

See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
3Organization
* @enum {integer} */ - VolumeType: 1 | 2; + VolumeType: 1 | 2 | 3; /** * @description

1=folder, 2=file

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
* @enum {integer} @@ -6776,10 +6873,10 @@ export interface components { LinkID: components["schemas"]["Id2"]; }; /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
+ * @description
See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
3Organization
* @enum {integer} */ - VolumeType2: 1 | 2; + VolumeType2: 1 | 2 | 3; /** * @description

Can be null if the Link was deleted

See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
* @enum {integer} @@ -9119,6 +9216,36 @@ export interface operations { }; }; }; + "post_drive-v2-volumes-{volumeID}-remove-mine": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["MultiDeleteTransformer"][]; + }; + }; + }; + }; + }; "put_drive-v2-volumes-{volumeID}-links-{linkID}-rename": { parameters: { query?: never; @@ -12150,6 +12277,29 @@ export interface operations { }; }; }; + "post_drive-unauth-v2-volumes-{volumeID}-remove-mine": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; "put_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-rename": { parameters: { query?: never; @@ -12404,7 +12554,7 @@ export interface operations { /** @description Show disabled shares as well, i.e. Shares where the ShareMemberShip for the user is non-active (locked), otherwise only return with active Membership */ ShowAll?: 0 | 1; /** @description Filter on Share Type */ - ShareType?: 1 | 2 | 3 | 4; + ShareType?: 1 | 2 | 3 | 4 | 5; }; header?: never; path?: never; @@ -13477,6 +13627,31 @@ export interface operations { }; }; }; + "post_drive-organization-volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateOrgVolumeRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetVolumeResponseDto"]; + }; + }; + }; + }; "get_drive-volumes": { parameters: { query?: { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 3e330a8e..73fbb74d 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -71,13 +71,20 @@ type PutRestoreNodesRequest = Extract< type PutRestoreNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/restore_multiple']['put']['responses']['200']['content']['application/json']; -type PostDeleteNodesRequest = Extract< +type PostDeleteTrashedNodesRequest = Extract< drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['requestBody'], { content: object } >['content']['application/json']; -type PostDeleteNodesResponse = +type PostDeleteTrashedNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['responses']['200']['content']['application/json']; +type PostDeleteMyNodesRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/remove-mine']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostDeleteMyNodesResponse = + drivePaths['/drive/v2/volumes/{volumeID}/remove-mine']['post']['responses']['200']['content']['application/json']; + type PostCreateFolderRequest = Extract< drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['requestBody'], { content: object } @@ -446,7 +453,7 @@ export abstract class NodeAPIServiceBase< async *deleteTrashedNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) { - const response = await this.apiService.post( + const response = await this.apiService.post( `drive/v2/volumes/${volumeId}/trash/delete_multiple`, { LinkIDs: batchNodeIds, @@ -459,10 +466,10 @@ export abstract class NodeAPIServiceBase< } } - async *deleteExistingNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *deleteMyNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) { - const response = await this.apiService.post( - `drive/v2/volumes/${volumeId}/delete_multiple`, + const response = await this.apiService.post( + `drive/v2/volumes/${volumeId}/remove-mine`, { LinkIDs: batchNodeIds, }, diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 0a2da775..319897af 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -293,7 +293,7 @@ export abstract class NodesManagementBase< } } - async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *deleteTrashedNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for await (const result of this.apiService.deleteTrashedNodes(nodeUids, signal)) { if (result.ok) { await this.nodesAccess.notifyNodeDeleted(result.uid); diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index 52a8b2d9..e7f97f7d 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -173,7 +173,7 @@ function convertSharePayload(response: GetShareResponse): EncryptedShare { }; } -function convertShareTypeNumberToEnum(type: 1 | 2 | 3 | 4): ShareType { +function convertShareTypeNumberToEnum(type: 1 | 2 | 3 | 4 | 5): ShareType { switch (type) { case 1: return ShareType.Main; @@ -183,5 +183,7 @@ function convertShareTypeNumberToEnum(type: 1 | 2 | 3 | 4): ShareType { return ShareType.Device; case 4: return ShareType.Photo; + case 5: + throw new Error('Organization shares are not supported yet'); } } diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index f9d3b5ba..9be4edfc 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -87,10 +87,10 @@ export class SharingPublicNodesManagement extends NodesManagement { super(apiService, cryptoCache, cryptoService, nodesAccess); } - async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + async *deleteMyNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { // Public link does not support trashing and deleting trashed nodes. // Instead, if user is owner, API allows directly deleting existing nodes. - for await (const result of this.apiService.deleteExistingNodes(nodeUids, signal)) { + for await (const result of this.apiService.deleteMyNodes(nodeUids, signal)) { if (result.ok) { await this.nodesAccess.notifyNodeDeleted(result.uid); } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index e1797bb3..e21a30e2 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -495,7 +495,7 @@ export class ProtonDriveClient { } /** - * Delete the nodes permanently. + * Delete the trashed nodes permanently. Only the owner can do that. * * The operation is performed in batches and the results are yielded * as they are available. Order of the results is not guaranteed. @@ -509,7 +509,7 @@ export class ProtonDriveClient { */ async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Deleting ${nodeUids.length} nodes`); - yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal); + yield* this.nodes.management.deleteTrashedNodes(getUids(nodeUids), signal); } async emptyTrash(): Promise { diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 3449059a..8a74f128 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -295,7 +295,7 @@ export class ProtonDrivePhotosClient { */ async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Deleting ${nodeUids.length} nodes`); - yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal); + yield * this.nodes.management.deleteTrashedNodes(getUids(nodeUids), signal); } /** diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index e867f16b..31a6a1aa 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -233,13 +233,15 @@ export class ProtonDrivePublicLinkClient { } /** - * Delete the nodes permanently. + * Delete own nodes permanently. It skips the trash and allows to delete + * only nodes that are owned by the user. For anonymous files, this method + * allows to delete them only in the same session. * * See `ProtonDriveClient.deleteNodes` for more information. */ async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Deleting ${nodeUids.length} nodes`); - yield* this.sharingPublic.nodes.management.deleteNodes(getUids(nodeUids), signal); + yield* this.sharingPublic.nodes.management.deleteMyNodes(getUids(nodeUids), signal); } /** From de8c02b69dfe3497c174aebd4bae637f4aa38938 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 12:01:36 +0100 Subject: [PATCH 379/791] js/v0.8.0 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 73864677..d04da184 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.7.3", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.7.3", + "version": "0.8.0", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index b01f9dca..ad759fe2 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.7.3", + "version": "0.8.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From fdd5cf8c6dad1964eda47d7347226cee48814bb4 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 15:00:19 +0100 Subject: [PATCH 380/791] Prefix the SDK static lib name for Swift with `lib`. Use non-macOS runner for SPM release. --- cs/Directory.Build.props | 2 +- cs/Directory.Build.targets | 2 +- .../Sources/CancellationTokenSource.swift | 1 + .../Sources/FileOperations/DownloadOperation.swift | 2 +- .../FileOperations/DownloadThumbnailsManager.swift | 2 +- .../Sources/FileOperations/UploadOperation.swift | 2 +- .../ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift | 1 - .../Sources/Plumbing/SDKRequestHandler.swift | 2 +- .../Sources/Plumbing/SDKResponseHandler.swift | 10 ++++------ .../Sources/ProtonDriveClient/AccountClient.swift | 2 +- .../Networking/HttpClientRequestProcessor.swift | 8 ++++---- .../Networking/HttpClientResponseProcessor.swift | 2 +- .../Sources/ProtonDriveClient/ProtonDriveClient.swift | 4 ++-- .../Sources/TelemetryAndLogging/Logger.swift | 2 +- 14 files changed, 20 insertions(+), 22 deletions(-) diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 1f605603..9fa559c6 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -21,7 +21,7 @@ true - lib + lib diff --git a/cs/Directory.Build.targets b/cs/Directory.Build.targets index 85006ae3..875a2046 100644 --- a/cs/Directory.Build.targets +++ b/cs/Directory.Build.targets @@ -3,7 +3,7 @@ $(NETCoreSdkRuntimeIdentifier) $(DefineConstants);WINDOWS - lib + lib diff --git a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift index 412561f7..d3ad777c 100644 --- a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift +++ b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift @@ -39,6 +39,7 @@ actor CancellationTokenSource { } catch { logger?.error("CancellationTokenSource.free failed, error: \(error)", category: "Cancellation") } + _ = strongSelf // fixes "variable 'strongSelf' was written to, but never read" warning strongSelf = nil } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift index 6e74350f..42072f30 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift @@ -1,6 +1,6 @@ import Foundation -public enum DownloadOperationResult { +public enum DownloadOperationResult: Sendable { case succeeded case pausedOnError(Error) case failed(Error) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift index 69fe8de5..fd716205 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift @@ -56,7 +56,7 @@ actor DownloadThumbnailsManager { } try await downloadCancellationToken.cancel() - try await downloadCancellationToken.free() + downloadCancellationToken.free() activeDownloads[cancellationToken] = nil } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift index 4a73f261..59779cd5 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift @@ -1,6 +1,6 @@ import Foundation -public enum UploadOperationResult { +public enum UploadOperationResult: Sendable { case succeeded(UploadedFileIdentifiers) case pausedOnError(Error) case failed(Error) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 6c864cab..2591215c 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -148,7 +148,6 @@ public struct UploadedFileIdentifiers: Sendable { public let revisionUid: SDKRevisionUid init?(interopUploadResult: Proton_Drive_Sdk_UploadResult) { - interopUploadResult.nodeUid guard let nodeUid = SDKNodeUid(sdkCompatibleIdentifier: interopUploadResult.nodeUid), let revisionUid = SDKRevisionUid(sdkCompatibleIdentifier: interopUploadResult.revisionUid) else { return nil } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index f41ed77d..940c2d21 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -97,7 +97,7 @@ enum SDKRequestHandler { // We double-retain to keep the box alive after the method finishes. // Currently, the reference to the box will not be kept anywhere, // so the deallocation must be done in the long-lived callback. Improve if necessary. - pointer.retain() + _ = pointer.retain() // fixes "result of call to 'retain()' is unused" warning } let bindingsHandle = Int(rawPointer: pointer.toOpaque()) if isDriveRequest { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift index bcc22954..5a785036 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift @@ -41,14 +41,12 @@ extension Proton_Sdk_Error { let domain: Proton_Sdk_ErrorDomain let message: String var primaryCode: Int? = nil - var secondaryCode: Int? = nil - var context: String? = nil - var innerError: Proton_Sdk_Error? = nil - var additionalData: Codable? = nil + let secondaryCode: Int? = nil + let context: String? = nil + let innerError: Proton_Sdk_Error? = nil + let additionalData: Codable? = nil switch nsError { - case let sdkError as Proton_Sdk_Error: - return sdkError case let protonDriveSDKError as ProtonDriveSDKError: return protonDriveSDKError.asProton_Sdk_Error diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift index 9a40a112..60a0ead2 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift @@ -25,7 +25,7 @@ let cCompatibleAccountClientRequest: CCallbackWithCallbackPointer = { statePoint guard let driveClient else { return } Task { [driveClient] in - let accountClient = await driveClient.accountClient + let accountClient = driveClient.accountClient let request = Proton_Drive_Sdk_AccountRequest(byteArray: byteArray) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift index 797e98a0..93c65614 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -104,7 +104,7 @@ enum HttpClientRequestProcessor { // the API calls are performed in a non-streaming way, // so we buffer all request data in-memory before making a call let bufferLength = client.configuration.httpTransferBufferSize - var buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) let baseAddress = buffer.baseAddress! while true { @@ -135,7 +135,7 @@ enum HttpClientRequestProcessor { let bindingsHandle: Int? if let data = response.data, !data.isEmpty { let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) - await uploadBuffer.copyBytes(from: data) + uploadBuffer.copyBytes(from: data) let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) let pointer = Unmanaged.passRetained(bytesOrStream) bindingsHandle = Int(rawPointer: pointer.toOpaque()) @@ -194,7 +194,7 @@ enum HttpClientRequestProcessor { let bindingsHandle: Int? if let data = response.data, !data.isEmpty { let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) - await uploadBuffer.copyBytes(from: data) + uploadBuffer.copyBytes(from: data) let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) let pointer = Unmanaged.passRetained(bytesOrStream) bindingsHandle = Int(rawPointer: pointer.toOpaque()) @@ -230,7 +230,7 @@ enum HttpClientRequestProcessor { if httpRequestData.hasSdkContentHandle { // We expect that request data to be small, we need to fetch them whole let bufferLength = client.configuration.httpTransferBufferSize - var buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bufferLength, alignment: MemoryLayout.alignment) let baseAddress = buffer.baseAddress! while true { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift index dd5b7485..75c5a1ca 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift @@ -81,7 +81,7 @@ enum HttpClientResponseProcessor { callbackPointer: Int, releaseBox: () -> Void ) async { - let copiedBytesCount = await boxedRawBuffer.copyBytes(to: buffer, count: bufferSize) + let copiedBytesCount = boxedRawBuffer.copyBytes(to: buffer, count: bufferSize) let message = Google_Protobuf_Int32Value.with { $0.value = Int32(copiedBytesCount) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 69e86b1c..d37841fe 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -118,7 +118,7 @@ public actor ProtonDriveClient: Sendable { private func awaitDownloadCompletion( operation: DownloadOperation, retryCounter: UInt ) async throws { - let result = try await operation.awaitDownloadCompletion() + let result = await operation.awaitDownloadCompletion() switch result { case .succeeded: return @@ -184,7 +184,7 @@ public actor ProtonDriveClient: Sendable { private func awaitUploadCompletion( operation: UploadOperation, retryCounter: UInt ) async throws -> UploadedFileIdentifiers { - let result = try await operation.awaitUploadCompletion() + let result = await operation.awaitUploadCompletion() switch result { case .succeeded(let uploadResult): return uploadResult diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift index d3c8c495..91d23cb3 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift @@ -6,7 +6,7 @@ public typealias LogCallback = @Sendable (LogEvent) -> Void func logCallbackForTests(logEvent: LogEvent) { let timestamp = logEvent.timestamp.formatted(date: .abbreviated, time: .shortened) - let prefix = "\(logEvent.level.symbol)[\(String(describing: logEvent.level).prefix(1).capitalized)][\(logEvent.thread ?? 0)]" + let prefix = "\(logEvent.level.symbol)[\(String(describing: logEvent.level).prefix(1).capitalized)][\(logEvent.thread)]" let logLine = "\(prefix)\(timestamp) \(logEvent.category): \(logEvent.message)" print(logLine) } From f65c8cd85f89617ea4198b9e934d229d6bd55b98 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 14:20:13 +0100 Subject: [PATCH 381/791] Propagate exception to interop logger --- cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index d8b8d8d8..a87bb59b 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -21,6 +21,10 @@ public IDisposable BeginScope(TState state) public unsafe void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var message = formatter.Invoke(state, exception); + if (exception != null) + { + message = message + Environment.NewLine + exception.ToString(); + } var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; var messageBytes = logEvent.ToByteArray(); From 0d0313430c65c3097f567f331e55736fd4e5c4bc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 14:42:11 +0100 Subject: [PATCH 382/791] Log swallowed exceptions --- cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs | 4 ++-- cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs | 4 ++-- cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs | 4 ++-- cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs | 3 ++- cs/sdk/src/Proton.Sdk/ProtonApiSession.cs | 5 ++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 5039f125..35932945 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -145,9 +145,9 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt downloadEvent.DownloadedSize = contentOutputStream.Length; _client.Telemetry.RecordMetric(downloadEvent); } - catch + catch (Exception ex) { - // Ignore telemetry errors + _logger.LogWarning(ex, "Failed to record metric for download event"); } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 77ee7845..c75619ef 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -226,9 +226,9 @@ await _client.Api.Files.UpdateRevisionAsync( // TODO: put this in a decorator _client.Telemetry.RecordMetric(uploadEvent); } - catch + catch (Exception ex) { - // Ignore telemetry errors + _logger.LogWarning(ex, "Failed to record metric for upload event"); } } } diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 4742c6ff..03a6e9bd 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -197,9 +197,9 @@ private static async ValueTask
ConvertFromDtoAsync( unlockedKeys.Add(unlockedKey); } - catch + catch (Exception ex) { - // FIXME: log that + client.Logger.LogWarning(ex, "Failed to import and unlock address key {UserKeyId}", keyDto.Id); continue; } diff --git a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs index 7ede0a85..b31e9f95 100644 --- a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs +++ b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs @@ -61,9 +61,10 @@ public async Task GetRefreshedAccessTokenAsync(string rejectedAccessToke { throw; } - catch + catch (Exception ex) { // Return expired access token to allow refreshing again later + _logger.LogDebug(ex, "Failed to refresh token for {SessionId}", _sessionId); return (currentAccessToken, currentRefreshToken); } }); diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs index 7c5e285f..1a7b2307 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -138,10 +138,9 @@ public static async ValueTask BeginAsync( { await session.ApplyDataPasswordAsync(password, cancellationToken).ConfigureAwait(false); } - catch + catch (Exception ex) { - // Ignore - // FIXME: log that + logger.LogWarning(ex, "Failed to apply data password"); } } From dff30bacaa5d66e99158834da7c1c12963520f14 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 15:07:04 +0000 Subject: [PATCH 383/791] Convert stateless JNI methods to static --- kt/sdk/src/main/jni/Android.mk | 2 +- kt/sdk/src/main/jni/buffer.c | 21 +++++++ kt/sdk/src/main/jni/byte_array.c | 37 +++++++++++++ kt/sdk/src/main/jni/native_library.c | 2 +- kt/sdk/src/main/jni/proton_drive_sdk.c | 54 +++--------------- kt/sdk/src/main/jni/proton_sdk.c | 23 +------- .../me/proton/drive/sdk/ProtonDriveSdk.kt | 2 +- .../drive/sdk/internal/ByteArrayPointers.kt | 29 ++++++++++ .../me/proton/drive/sdk/internal/JniBuffer.kt | 26 +++++++++ .../proton/drive/sdk/internal/JniByteArray.kt | 23 ++++++++ .../drive/sdk/internal/JniDownloader.kt | 4 +- .../drive/sdk/internal/JniDriveClient.kt | 10 ++-- .../drive/sdk/internal/JniHttpStream.kt | 4 +- .../drive/sdk/internal/JniLoggerProvider.kt | 2 +- .../drive/sdk/internal/JniNativeLibrary.kt | 3 +- .../proton/drive/sdk/internal/JniUploader.kt | 4 +- .../internal/ProtonDriveSdkNativeClient.kt | 55 +++++++++++-------- .../sdk/internal/ProtonSdkNativeClient.kt | 11 ++-- 18 files changed, 199 insertions(+), 113 deletions(-) create mode 100644 kt/sdk/src/main/jni/buffer.c create mode 100644 kt/sdk/src/main/jni/byte_array.c create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ByteArrayPointers.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBuffer.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniByteArray.kt diff --git a/kt/sdk/src/main/jni/Android.mk b/kt/sdk/src/main/jni/Android.mk index 032ed030..8d678962 100644 --- a/kt/sdk/src/main/jni/Android.mk +++ b/kt/sdk/src/main/jni/Android.mk @@ -9,7 +9,7 @@ include $(PREBUILT_SHARED_LIBRARY) include $(CLEAR_VARS) LOCAL_MODULE := proton_drive_sdk_jni -LOCAL_SRC_FILES := global.c native_library.c proton_drive_sdk.c proton_sdk.c +LOCAL_SRC_FILES := global.c byte_array.c buffer.c native_library.c proton_drive_sdk.c proton_sdk.c LOCAL_SHARED_LIBRARIES := proton_drive_sdk LOCAL_C_INCLUDES += $(BUILD_DIR)/cs/includes LOCAL_LDLIBS := -llog diff --git a/kt/sdk/src/main/jni/buffer.c b/kt/sdk/src/main/jni/buffer.c new file mode 100644 index 00000000..c5b11929 --- /dev/null +++ b/kt/sdk/src/main/jni/buffer.c @@ -0,0 +1,21 @@ +#include + +jlong Java_me_proton_drive_sdk_internal_JniBuffer_getBufferPointer( + JNIEnv *env, + jclass clazz, + jobject buffer +) { + void *ptr = (*env)->GetDirectBufferAddress(env, buffer); + if (ptr == NULL) { + return 0; + } + return (jlong) (intptr_t) ptr; +} + +jlong Java_me_proton_drive_sdk_internal_JniBuffer_getBufferSize( + JNIEnv *env, + jclass clazz, + jobject buffer +) { + return (*env)->GetDirectBufferCapacity(env, buffer); +} \ No newline at end of file diff --git a/kt/sdk/src/main/jni/byte_array.c b/kt/sdk/src/main/jni/byte_array.c new file mode 100644 index 00000000..ed4cefc3 --- /dev/null +++ b/kt/sdk/src/main/jni/byte_array.c @@ -0,0 +1,37 @@ +#include +#include +#include + +jlong Java_me_proton_drive_sdk_internal_JniByteArray_getByteArray( + JNIEnv *env, + jclass clazz, + jbyteArray array +) { + jsize length = (*env)->GetArrayLength(env, array); + jbyte *data = (*env)->GetByteArrayElements(env, array, NULL); + + // Allocate native memory + jbyte *buffer = (jbyte *) malloc(length); + if (buffer == NULL) { + (*env)->ReleaseByteArrayElements(env, array, data, JNI_ABORT); + return 0; // OOM + } + + // Copy into native memory + memcpy(buffer, data, length); + + (*env)->ReleaseByteArrayElements(env, array, data, JNI_ABORT); + + // Return as jlong handle + return (jlong) buffer; +} + +void Java_me_proton_drive_sdk_internal_JniByteArray_releaseByteArray( + JNIEnv *env, + jclass clazz, + jlong ptr +) { + if (ptr != 0) { + free((void *) ptr); + } +} diff --git a/kt/sdk/src/main/jni/native_library.c b/kt/sdk/src/main/jni/native_library.c index a95ed836..cb5dfc1d 100644 --- a/kt/sdk/src/main/jni/native_library.c +++ b/kt/sdk/src/main/jni/native_library.c @@ -6,7 +6,7 @@ void Java_me_proton_drive_sdk_internal_JniNativeLibrary_overrideName( JNIEnv *env, - jobject obj, + jclass clazz, jbyteArray name, jbyteArray overridingName ) { diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index a5bfa20b..be9a4120 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -1,7 +1,6 @@ #include #include #include -#include #include "proton_drive_sdk.h" #include "global.h" @@ -31,7 +30,7 @@ void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleRequest( void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleResponse( JNIEnv *env, - jobject obj, + jclass clazz, jlong sdk_handle, jbyteArray response ) { @@ -48,41 +47,6 @@ void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleResponse (*env)->ReleaseByteArrayElements(env, response, bufferElems, 0); } -jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getByteArray( - JNIEnv *env, - jobject obj, - jbyteArray array -) { - jsize length = (*env)->GetArrayLength(env, array); - jbyte *data = (*env)->GetByteArrayElements(env, array, NULL); - - // Allocate native memory - jbyte *buffer = (jbyte *) malloc(length); - if (buffer == NULL) { - (*env)->ReleaseByteArrayElements(env, array, data, JNI_ABORT); - return 0; // OOM - } - - // Copy into native memory - memcpy(buffer, data, length); - - (*env)->ReleaseByteArrayElements(env, array, data, JNI_ABORT); - - // Return as jlong handle - return (jlong) buffer; -} - -void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_releaseByteArray( - JNIEnv *env, - jobject obj, - jlong ptr -) { - if (ptr != 0) { - free((void *) ptr); - } -} - - void onRead( intptr_t bindings_handle, ByteArray value, @@ -173,35 +137,35 @@ long onFeatureEnabled( jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getReadPointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onRead; } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getWritePointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onWrite; } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getProgressPointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onProgress; } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpClientRequestPointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onSendHttpRequest; } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpClientCancellationPointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onHttpCancellation; } @@ -215,21 +179,21 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpRespon jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getAccountRequestPointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onAccountRequest; } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getRecordMetricPointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onRecordMetric; } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getFeatureEnabledPointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onFeatureEnabled; } diff --git a/kt/sdk/src/main/jni/proton_sdk.c b/kt/sdk/src/main/jni/proton_sdk.c index 15540d43..ff36d834 100644 --- a/kt/sdk/src/main/jni/proton_sdk.c +++ b/kt/sdk/src/main/jni/proton_sdk.c @@ -34,28 +34,7 @@ void onCallback(intptr_t bindings_handle, ByteArray value) { jlong Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_getCallbackPointer( JNIEnv *env, - jobject obj + jclass clazz ) { return (jlong) (intptr_t) &onCallback; } - -jlong Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_getBufferPointer( - JNIEnv *env, - jobject obj, - jobject buffer -) { - void *ptr = (*env)->GetDirectBufferAddress(env, buffer); - if (ptr == NULL) { - return 0; - } - return (jlong) (intptr_t) ptr; -} - - -jlong Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_getBufferSize( - JNIEnv *env, - jobject obj, - jobject buffer -) { - return (*env)->GetDirectBufferCapacity(env, buffer); -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt index 533de553..b67cde99 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -74,7 +74,7 @@ object ProtonDriveSdk { } private fun overrideName() { - JniNativeLibrary().overrideName( + JniNativeLibrary.overrideName( libraryName = "proton_crypto".toByteArray(), overridingLibraryName = "gojni".toByteArray() ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ByteArrayPointers.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ByteArrayPointers.kt new file mode 100644 index 00000000..abd90dea --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ByteArrayPointers.kt @@ -0,0 +1,29 @@ +package me.proton.drive.sdk.internal + +/** + * Manages native memory pointers for byte arrays allocated via JNI. + * Tracks allocated pointers and ensures they are properly released. + */ +internal class ByteArrayPointers { + + private var pointers = emptyList() + + /** + * Allocates native memory for a byte array and tracks the pointer. + * @param data The byte array to copy to native memory + * @return A pointer to the native memory + */ + fun allocate(data: ByteArray): Long = JniByteArray.getByteArray(data).also { pointer -> + pointers += pointer + } + + /** + * Releases all tracked native memory pointers. + */ + fun releaseAll() { + pointers.forEach { pointer -> + JniByteArray.releaseByteArray(pointer) + } + pointers = emptyList() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBuffer.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBuffer.kt new file mode 100644 index 00000000..085918dd --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBuffer.kt @@ -0,0 +1,26 @@ +package me.proton.drive.sdk.internal + +import java.nio.ByteBuffer + +/** + * JNI utility object for ByteBuffer operations. + * Provides direct buffer pointer and size access for JNI. + */ +object JniBuffer { + + /** + * Gets the native memory pointer from a direct ByteBuffer. + * @param buffer The ByteBuffer to get the pointer from + * @return A pointer to the buffer's native memory, or 0 if not a direct buffer + */ + @JvmStatic + external fun getBufferPointer(buffer: ByteBuffer): Long + + /** + * Gets the capacity of a ByteBuffer. + * @param buffer The ByteBuffer to get the size from + * @return The capacity of the buffer in bytes + */ + @JvmStatic + external fun getBufferSize(buffer: ByteBuffer): Long +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniByteArray.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniByteArray.kt new file mode 100644 index 00000000..97d388f9 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniByteArray.kt @@ -0,0 +1,23 @@ +package me.proton.drive.sdk.internal + +/** + * JNI utility object for byte array operations. + * Provides native memory management for byte arrays. + */ +object JniByteArray { + + /** + * Allocates native memory and copies the byte array data into it. + * @param data The byte array to copy + * @return A pointer to the native memory, or 0 if allocation failed + */ + @JvmStatic + external fun getByteArray(data: ByteArray): Long + + /** + * Releases native memory allocated by getByteArray. + * @param pointer The pointer to native memory to release + */ + @JvmStatic + external fun releaseByteArray(pointer: Long) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt index 2b983025..62dcf360 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt @@ -46,8 +46,8 @@ class JniDownloader internal constructor() : JniBaseProtonDriveSdk() { downloadToStream = downloadToStreamRequest { this.downloaderHandle = handle this.cancellationTokenSourceHandle = cancellationTokenSourceHandle - writeAction = client.getWritePointer() - progressAction = client.getProgressPointer() + writeAction = ProtonDriveSdkNativeClient.getWritePointer() + progressAction = ProtonDriveSdkNativeClient.getProgressPointer() } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index b2df002e..7ee725ad 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -51,18 +51,18 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { driveClientCreate = driveClientCreateRequest { baseUrl = request.baseUrl httpClient = httpClient { - requestFunction = client.getHttpClientRequestPointer() + requestFunction = ProtonDriveSdkNativeClient.getHttpClientRequestPointer() responseContentReadAction = httpResponseReadPointer - cancellationAction = client.getHttpClientCancellationPointer() + cancellationAction = ProtonDriveSdkNativeClient.getHttpClientCancellationPointer() } - accountRequestAction = client.getAccountRequestPointer() + accountRequestAction = ProtonDriveSdkNativeClient.getAccountRequestPointer() entityCachePath = request.entityCachePath secretCachePath = request.secretCachePath telemetry = telemetry { loggerProviderHandle = request.loggerProvider.handle - recordMetricAction = client.getRecordMetricPointer() + recordMetricAction = ProtonDriveSdkNativeClient.getRecordMetricPointer() } - featureEnabledFunction = client.getFeatureEnabledPointer() + featureEnabledFunction = ProtonDriveSdkNativeClient.getFeatureEnabledPointer() request.bindingsLanguage?.let { bindingsLanguage = it } request.uid?.let { uid = it } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index cfab385f..4757f927 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -47,8 +47,8 @@ class JniHttpStream internal constructor( request { streamRead = streamReadRequest { streamHandle = handle - bufferPointer = client.getBufferPointer(buffer) - bufferLength = client.getBufferSize(buffer).toInt() + bufferPointer = JniBuffer.getBufferPointer(buffer) + bufferLength = JniBuffer.getBufferSize(buffer).toInt() } } }) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt index 4190d686..59bff0e6 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt @@ -29,7 +29,7 @@ class JniLoggerProvider internal constructor( requestBuilder = { client -> request { loggerProviderCreate = loggerProviderCreate { - logAction = client.getCallbackPointer() + logAction = ProtonSdkNativeClient.getCallbackPointer() } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt index adfae91c..600883f3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt @@ -1,7 +1,8 @@ package me.proton.drive.sdk.internal -class JniNativeLibrary internal constructor() { +object JniNativeLibrary { + @JvmStatic external fun overrideName( libraryName: ByteArray, overridingLibraryName: ByteArray, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt index 704e38d0..06f8369c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt @@ -59,8 +59,8 @@ class JniUploader internal constructor() : JniBaseProtonDriveSdk() { uploadFromStream = uploadFromStreamRequest { this.uploaderHandle = uploaderHandle this.cancellationTokenSourceHandle = cancellationTokenSourceHandle - readAction = nativeClient.getReadPointer() - progressAction = nativeClient.getProgressPointer() + readAction = ProtonDriveSdkNativeClient.getReadPointer() + progressAction = ProtonDriveSdkNativeClient.getProgressPointer() thumbnails.forEach { (type, data) -> this.thumbnails.add(thumbnail { this.type = when (type) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 3c433b46..5a1dfb79 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -34,12 +34,10 @@ class ProtonDriveSdkNativeClient internal constructor( private val coroutineScope: CoroutineScope? = null, ) { - private var pointers = emptyList() + private val byteArrayPointers = ByteArrayPointers() fun release() { - pointers.forEach { pointer -> - releaseByteArray(pointer) - } + byteArrayPointers.releaseAll() } fun handleRequest( @@ -68,26 +66,8 @@ class ProtonDriveSdkNativeClient internal constructor( handleResponse(sdkHandle, response.toByteArray()) } - external fun handleResponse( - sdkHandle: Long, - response: ByteArray, - ) + fun getByteArrayPointer(data: ByteArray): Long = byteArrayPointers.allocate(data) - fun getByteArrayPointer(data: ByteArray): Long = getByteArray(data).also { pointer -> - pointers += pointer - } - - external fun getByteArray(data: ByteArray): Long - external fun releaseByteArray(pointer: Long) - - external fun getReadPointer(): Long - external fun getWritePointer(): Long - external fun getProgressPointer(): Long - external fun getHttpClientRequestPointer(): Long - external fun getHttpClientCancellationPointer(): Long - external fun getAccountRequestPointer(): Long - external fun getRecordMetricPointer(): Long - external fun getFeatureEnabledPointer(): Long external fun createWeakRef(): Long @Suppress("unused") // Called by JNI @@ -284,10 +264,37 @@ class ProtonDriveSdkNativeClient internal constructor( } companion object { + @JvmStatic + external fun handleResponse(sdkHandle: Long, response: ByteArray) + + @JvmStatic + external fun getReadPointer(): Long + + @JvmStatic + external fun getWritePointer(): Long + + @JvmStatic + external fun getProgressPointer(): Long + + @JvmStatic + external fun getHttpClientRequestPointer(): Long + + @JvmStatic + external fun getHttpClientCancellationPointer(): Long + @JvmStatic external fun getHttpResponseReadPointer(): Long + @JvmStatic - external fun createJobWeakRef(job: Job): Long + external fun getAccountRequestPointer(): Long + @JvmStatic + external fun getRecordMetricPointer(): Long + + @JvmStatic + external fun getFeatureEnabledPointer(): Long + + @JvmStatic + external fun createJobWeakRef(job: Job): Long } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt index aeb4db6e..ae472a7c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -26,12 +26,6 @@ class ProtonSdkNativeClient internal constructor( request: ByteArray, ) - external fun getCallbackPointer(): Long - - external fun getBufferPointer(buffer: ByteBuffer): Long - - external fun getBufferSize(buffer: ByteBuffer): Long - fun onResponse(data: ByteBuffer) { logger("response for $name of size: ${data.capacity()}") response(data) @@ -41,4 +35,9 @@ class ProtonSdkNativeClient internal constructor( logger("callback for $name of size: ${data.capacity()}") callback(data) } + + companion object { + @JvmStatic + external fun getCallbackPointer(): Long + } } From 02034b38b6a6cf490f8a8def1a47b8c0d0754022 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Dec 2025 18:11:08 +0100 Subject: [PATCH 384/791] Test upload and download events --- .../kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt | 4 ++-- .../main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt index a994e918..6f0a9dcb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt @@ -4,6 +4,6 @@ data class DownloadEvent( val volumeType: VolumeType, val claimedFileSize: Long, val downloadedSize: Long, - val error: DownloadError?, - val originalError: String?, + val error: DownloadError? = null, + val originalError: String? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt index 4a23683b..108ba07f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt @@ -5,6 +5,6 @@ data class UploadEvent( val expectedSize: Long, val uploadedSize: Long, val approximateUploadedSize: Long, - val error: UploadError?, - val originalError: String?, + val error: UploadError? = null, + val originalError: String? = null, ) From abe163de4985778313c49cfc571a028c05f11b5d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 16:34:43 +0000 Subject: [PATCH 385/791] Extract Job code from JniDriveClient --- kt/sdk/src/main/jni/Android.mk | 2 +- kt/sdk/src/main/jni/job.c | 49 +++++++++++++++++++ kt/sdk/src/main/jni/proton_drive_sdk.c | 42 ---------------- .../drive/sdk/internal/JniDriveClient.kt | 2 +- .../me/proton/drive/sdk/internal/JniJob.kt | 14 ++++++ .../internal/ProtonDriveSdkNativeClient.kt | 10 +--- 6 files changed, 66 insertions(+), 53 deletions(-) create mode 100644 kt/sdk/src/main/jni/job.c create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt diff --git a/kt/sdk/src/main/jni/Android.mk b/kt/sdk/src/main/jni/Android.mk index 8d678962..4ce6235e 100644 --- a/kt/sdk/src/main/jni/Android.mk +++ b/kt/sdk/src/main/jni/Android.mk @@ -9,7 +9,7 @@ include $(PREBUILT_SHARED_LIBRARY) include $(CLEAR_VARS) LOCAL_MODULE := proton_drive_sdk_jni -LOCAL_SRC_FILES := global.c byte_array.c buffer.c native_library.c proton_drive_sdk.c proton_sdk.c +LOCAL_SRC_FILES := global.c byte_array.c buffer.c job.c native_library.c proton_drive_sdk.c proton_sdk.c LOCAL_SHARED_LIBRARIES := proton_drive_sdk LOCAL_C_INCLUDES += $(BUILD_DIR)/cs/includes LOCAL_LDLIBS := -llog diff --git a/kt/sdk/src/main/jni/job.c b/kt/sdk/src/main/jni/job.c new file mode 100644 index 00000000..aa38b328 --- /dev/null +++ b/kt/sdk/src/main/jni/job.c @@ -0,0 +1,49 @@ +#include +#include +#include "global.h" + +void onCancel( + intptr_t bindings_operation_handle +) { + if (bindings_operation_handle == 0) { + return; + } + JNIEnv *env = getJNIEnv(); + jobject obj = (*env)->NewLocalRef(env, (jweak) bindings_operation_handle); + if ((*env)->IsSameObject(env, obj, NULL)) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Object was recycled for: %s %ld", "cancel", (long) bindings_operation_handle + ); + return; + } else { + jclass jobClass = (*env)->GetObjectClass(env, obj); + jmethodID mid = (*env)->GetMethodID(env, jobClass, "cancel", "()V"); + if (mid == 0) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Cannot found method: %s", "cancel" + ); + return; + } + (*env)->CallVoidMethod(env, obj, mid); + } +} + +jlong Java_me_proton_drive_sdk_internal_JniJob_getCancelPointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onCancel; +} + +jlong Java_me_proton_drive_sdk_internal_JniJob_createWeakRef( + JNIEnv *env, + jclass clazz, + jobject obj +) { + jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); + return (jlong)(intptr_t) weakRef; +} \ No newline at end of file diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index be9a4120..d32bedfa 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -75,36 +75,6 @@ long onSendHttpRequest( return pushDataAndLongToLongMethod(bindings_handle, value, sdk_handle, "onSendHttpRequest"); } -void onHttpCancellation( - intptr_t bindings_operation_handle -) { - if (bindings_operation_handle == 0) { - return; - } - JNIEnv *env = getJNIEnv(); - jobject obj = (*env)->NewLocalRef(env, (jweak) bindings_operation_handle); - if ((*env)->IsSameObject(env, obj, NULL)) { - __android_log_print( - ANDROID_LOG_FATAL, - "drive.sdk.internal", - "Object was recycled for: %s %ld", "cancel", bindings_operation_handle - ); - return; - } else { - jclass jobClass = (*env)->GetObjectClass(env, obj); - jmethodID mid = (*env)->GetMethodID(env, jobClass, "cancel", "()V"); - if (mid == 0) { - __android_log_print( - ANDROID_LOG_FATAL, - "drive.sdk.internal", - "Cannot found method: %s", "cancel" - ); - return; - } - (*env)->CallVoidMethod(env, obj, mid); - } -} - void onHttpResponseRead( intptr_t bindings_handle, ByteArray value, @@ -163,13 +133,6 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpClient return (jlong) (intptr_t) &onSendHttpRequest; } -jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpClientCancellationPointer( - JNIEnv *env, - jclass clazz -) { - return (jlong) (intptr_t) &onHttpCancellation; -} - jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpResponseReadPointer( JNIEnv *env, jclass clazz @@ -202,8 +165,3 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createWeakRef jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); return (jlong)(intptr_t) weakRef; } - -jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createJobWeakRef(JNIEnv* env, jclass clazz, jobject obj) { - jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); - return (jlong)(intptr_t) weakRef; -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index 7ee725ad..5228086f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -53,7 +53,7 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { httpClient = httpClient { requestFunction = ProtonDriveSdkNativeClient.getHttpClientRequestPointer() responseContentReadAction = httpResponseReadPointer - cancellationAction = ProtonDriveSdkNativeClient.getHttpClientCancellationPointer() + cancellationAction = JniJob.getCancelPointer() } accountRequestAction = ProtonDriveSdkNativeClient.getAccountRequestPointer() entityCachePath = request.entityCachePath diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt new file mode 100644 index 00000000..8977546f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.Job + +object JniJob { + + @JvmStatic + external fun getCancelPointer(): Long + + @JvmStatic + external fun createWeakRef(job: Job): Long +} + +fun Job.createWeakRef() = JniJob.createWeakRef(this) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 5a1dfb79..ab536ed2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -117,9 +117,7 @@ class ProtonDriveSdkNativeClient internal constructor( val httpResponse = httpClientRequest(httpRequest) logger("receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}") response { value = httpResponse.asAny("proton.sdk.HttpResponse") } - }?.let { job -> - createJobWeakRef(job) - } ?: 0 + }?.createWeakRef() ?: 0 @Suppress("unused") // Called by JNI fun onHttpResponseRead(buffer: ByteBuffer, sdkHandle: Long) { @@ -279,9 +277,6 @@ class ProtonDriveSdkNativeClient internal constructor( @JvmStatic external fun getHttpClientRequestPointer(): Long - @JvmStatic - external fun getHttpClientCancellationPointer(): Long - @JvmStatic external fun getHttpResponseReadPointer(): Long @@ -293,8 +288,5 @@ class ProtonDriveSdkNativeClient internal constructor( @JvmStatic external fun getFeatureEnabledPointer(): Long - - @JvmStatic - external fun createJobWeakRef(job: Job): Long } } From 2ab7cae72266a0b000038f4b16a298720610e99a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 09:36:13 +0100 Subject: [PATCH 386/791] Add Photos client and Photos volume creation --- .../Api/Volumes/VolumeCreationRequest.cs | 3 + .../Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs | 2 + .../Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 1 + .../Volumes/VolumeOperations.cs | 6 +- .../Proton.Drive.Sdk/Volumes/VolumeType.cs | 7 ++ .../Proton.Photos.Sdk/Api/IPhotosApiClient.cs | 12 ++ .../Api/Photos/PhotosVolumeCreationRequest.cs | 7 ++ .../PhotosVolumeLinkCreationParameters.cs | 16 +++ .../PhotosVolumeShareCreationParameters.cs | 20 +++ .../Proton.Photos.Sdk/Api/PhotosApiClient.cs | 27 ++++ .../Caching/IPhotosClientCache.cs | 7 ++ .../Caching/IPhotosEntityCache.cs | 25 ++++ .../Caching/IPhotosSecretCache.cs | 16 +++ .../Caching/PhotosClientCache.cs | 11 ++ .../Caching/PhotosEntityCache.cs | 73 +++++++++++ .../Caching/PhotosSecretCache.cs | 42 +++++++ .../Nodes/PhotosNodeOperations.cs | 76 ++++++++++++ .../Proton.Photos.Sdk.csproj | 20 +++ .../Proton.Photos.Sdk/ProtonPhotosClient.cs | 76 ++++++++++++ .../PhotosApiSerializerContext.cs | 23 ++++ .../Volumes/VolumeOperations.cs | 117 ++++++++++++++++++ cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 1 + 22 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeType.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs index d23c1a95..331db7f1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs @@ -9,6 +9,9 @@ internal sealed class VolumeCreationRequest [JsonPropertyName("AddressID")] public required AddressId AddressId { get; init; } + [JsonPropertyName("AddressKeyID")] + public required AddressKeyId AddressKeyId { get; init; } + public required PgpArmoredPrivateKey ShareKey { get; init; } public required PgpArmoredMessage SharePassphrase { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs index 61fdeec9..d0da9dfa 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs @@ -14,6 +14,8 @@ internal sealed class VolumeDto public required VolumeState State { get; init; } + public required VolumeType Type { get; init; } + [JsonPropertyName("Share")] public required VolumeRootDto Root { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj index 6bc30714..960bfe7a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -18,6 +18,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 4e8d98b4..1a1b68d9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -23,7 +23,9 @@ internal static class VolumeOperations var addressKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); - var request = GetCreationRequest(defaultAddress.Id, addressKey, out var rootShareKey, out var rootFolderSecrets); + var addressKeyId = defaultAddress.GetPrimaryKey().AddressKeyId; + + var request = GetCreationRequest(defaultAddress.Id, addressKeyId, addressKey, out var rootShareKey, out var rootFolderSecrets); var response = await client.Api.Volumes.CreateVolumeAsync(request, cancellationToken).ConfigureAwait(false); @@ -121,6 +123,7 @@ public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, Cancella private static VolumeCreationRequest GetCreationRequest( AddressId addressId, + AddressKeyId addressKeyId, PgpPrivateKey addressKey, out PgpPrivateKey rootShareKey, out FolderSecrets rootFolderSecrets) @@ -160,6 +163,7 @@ private static VolumeCreationRequest GetCreationRequest( return new VolumeCreationRequest { AddressId = addressId, + AddressKeyId = addressKeyId, ShareKey = lockedShareKey.ToBytes(), SharePassphrase = encryptedSharePassphrase, SharePassphraseSignature = sharePassphraseSignature, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeType.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeType.cs new file mode 100644 index 00000000..cc22fcc1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeType.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Volumes; + +public enum VolumeType +{ + Main = 1, + Photos = 2, +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs new file mode 100644 index 00000000..29eb656c --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs @@ -0,0 +1,12 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Volumes; +using Proton.Photos.Sdk.Api.Photos; + +namespace Proton.Photos.Sdk.Api; + +internal interface IPhotosApiClient +{ + ValueTask CreateVolumeAsync(PhotosVolumeCreationRequest request, CancellationToken cancellationToken); + + ValueTask GetRootShareAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs new file mode 100644 index 00000000..8af11aab --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs @@ -0,0 +1,7 @@ +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class PhotosVolumeCreationRequest +{ + public required PhotosVolumeShareCreationParameters Share { get; init; } + public required PhotosVolumeLinkCreationParameters Link { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs new file mode 100644 index 00000000..cff78dbe --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs @@ -0,0 +1,16 @@ +using Proton.Sdk.Cryptography; + +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class PhotosVolumeLinkCreationParameters +{ + public required PgpArmoredMessage Name { get; init; } + + public required PgpArmoredPrivateKey NodeKey { get; init; } + + public required PgpArmoredMessage NodePassphrase { get; init; } + + public required PgpArmoredSignature NodePassphraseSignature { get; init; } + + public required PgpArmoredMessage NodeHashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs new file mode 100644 index 00000000..241fc6e7 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class PhotosVolumeShareCreationParameters +{ + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } + + [JsonPropertyName("AddressKeyID")] + public required AddressKeyId AddressKeyId { get; init; } + + public required PgpArmoredPrivateKey Key { get; init; } + + public required PgpArmoredMessage Passphrase { get; init; } + + public required PgpArmoredSignature PassphraseSignature { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs new file mode 100644 index 00000000..b21f12a4 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs @@ -0,0 +1,27 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Serialization; +using Proton.Photos.Sdk.Api.Photos; +using Proton.Photos.Sdk.Serialization; +using Proton.Sdk.Http; + +namespace Proton.Photos.Sdk.Api; + +internal sealed class PhotosApiClient(HttpClient httpClient) : IPhotosApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask CreateVolumeAsync(PhotosVolumeCreationRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.VolumeCreationResponse) + .PostAsync("photos/volumes", request, PhotosApiSerializerContext.Default.PhotosVolumeCreationRequest, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetRootShareAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ShareResponseV2) + .GetAsync("v2/shares/photos", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs new file mode 100644 index 00000000..e02a139b --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs @@ -0,0 +1,7 @@ +namespace Proton.Photos.Sdk.Caching; + +internal interface IPhotosClientCache +{ + IPhotosEntityCache Entities { get; } + IPhotosSecretCache Secrets { get; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs new file mode 100644 index 00000000..49a67bcc --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs @@ -0,0 +1,25 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; + +namespace Proton.Photos.Sdk.Caching; + +internal interface IPhotosEntityCache +{ + ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); + + ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken); + + ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken); + + ValueTask SetShareAsync(Share share, CancellationToken cancellationToken); + + ValueTask SetNodeAsync( + NodeUid nodeId, + Result nodeProvisionResult, + ShareId? membershipShareId, + ReadOnlyMemory nameHashDigest, + CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs new file mode 100644 index 00000000..cd6d5fcf --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs @@ -0,0 +1,16 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; + +namespace Proton.Photos.Sdk.Caching; + +internal interface IPhotosSecretCache +{ + ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken); + + ValueTask SetFolderSecretsAsync( + NodeUid nodeId, + Result secretsProvisionResult, + CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs new file mode 100644 index 00000000..9d93455a --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Caching; + +namespace Proton.Photos.Sdk.Caching; + +internal class PhotosClientCache( + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository) : IPhotosClientCache +{ + public IPhotosEntityCache Entities { get; } = new PhotosEntityCache(entityCacheRepository); + public IPhotosSecretCache Secrets { get; } = new PhotosSecretCache(secretCacheRepository); +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs new file mode 100644 index 00000000..b1a6ba58 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; +using Proton.Sdk.Caching; + +namespace Proton.Photos.Sdk.Caching; + +internal sealed class PhotosEntityCache(ICacheRepository repository) : IPhotosEntityCache +{ + private const string PhotoVolumeIdCacheKey = "volume:photos:id"; + private const string PhotosShareIdCacheKey = "share:photos:id"; + + private readonly ICacheRepository _repository = repository; + + public ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken) + { + return _repository.SetAsync(PhotoVolumeIdCacheKey, volumeId.ToString(), cancellationToken); + } + + public async ValueTask TryGetPhotosVolumeIdAsync(CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(PhotoVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); + + return value is not null ? (VolumeId?)value : null; + } + + public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken) + { + return _repository.SetAsync(PhotosShareIdCacheKey, shareId.ToString(), cancellationToken); + } + + public async ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(PhotosShareIdCacheKey, cancellationToken).ConfigureAwait(false); + + return value is not null ? (ShareId)value : null; + } + + public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(share, DriveEntitiesSerializerContext.Default.Share); + + return _repository.SetAsync(GetShareCacheKey(share.Id), serializedValue, cancellationToken); + } + + public ValueTask SetNodeAsync( + NodeUid nodeId, + Result nodeProvisionResult, + ShareId? membershipShareId, + ReadOnlyMemory nameHashDigest, + CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize( + new CachedNodeInfo(nodeProvisionResult, membershipShareId, nameHashDigest), + DriveEntitiesSerializerContext.Default.CachedNodeInfo); + + return _repository.SetAsync(GetNodeCacheKey(nodeId), serializedValue, cancellationToken); + } + + private static string GetShareCacheKey(ShareId shareId) + { + return $"share:{shareId}"; + } + + private static string GetNodeCacheKey(NodeUid nodeId) + { + return $"node:{nodeId}"; + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs new file mode 100644 index 00000000..8fb3a8e6 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk; +using Proton.Sdk.Caching; +using Proton.Sdk.Serialization; + +namespace Proton.Photos.Sdk.Caching; + +internal sealed class PhotosSecretCache(ICacheRepository repository) : IPhotosSecretCache +{ + private readonly ICacheRepository _repository = repository; + + public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(shareKey, SecretsSerializerContext.Default.PgpPrivateKey); + + return _repository.SetAsync(GetShareKeyCacheKey(shareId), serializedValue, cancellationToken); + } + + public ValueTask SetFolderSecretsAsync( + NodeUid nodeId, + Result secretsProvisionResult, + CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(secretsProvisionResult, DriveSecretsSerializerContext.Default.ResultFolderSecretsDegradedFolderSecrets); + + return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); + } + + private static string GetShareKeyCacheKey(ShareId shareId) + { + return $"share:{shareId}:key"; + } + + private static string GetFolderSecretsCacheKey(NodeUid nodeId) + { + return $"folder:{nodeId}:secrets"; + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs new file mode 100644 index 00000000..afe8c8c9 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs @@ -0,0 +1,76 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; +using Proton.Photos.Sdk.Volumes; +using Proton.Sdk; +using Proton.Sdk.Api; + +namespace Proton.Photos.Sdk.Nodes; + +internal static class PhotosNodeOperations +{ + public static async ValueTask GetPhotosFolderAsync(ProtonPhotosClient client, CancellationToken cancellationToken) + { + var shareId = await client.Cache.Entities.TryGetPhotosShareIdAsync(cancellationToken).ConfigureAwait(false); + if (shareId is null) + { + return await GetFreshPhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); + } + + var shareAndKey = await ShareOperations.GetShareAsync(client.DriveClient, shareId.Value, cancellationToken).ConfigureAwait(false); + + var metadata = await NodeOperations.GetNodeMetadataAsync(client.DriveClient, shareAndKey.Share.RootFolderId, shareAndKey, cancellationToken).ConfigureAwait(false); + + return (FolderNode)metadata.Node; + } + + private static async ValueTask GetFreshPhotosFolderAsync(ProtonPhotosClient photosClient, CancellationToken cancellationToken) + { + ShareVolumeDto volumeDto; + ShareDto shareDto; + LinkDetailsDto linkDetailsDto; + + try + { + (volumeDto, shareDto, linkDetailsDto) = await photosClient.PhotosApi.GetRootShareAsync(cancellationToken).ConfigureAwait(false); + } + catch (ProtonApiException e) when (e.Code == ResponseCode.DoesNotExist) + { + return await CreatePhotosFolderAsync(photosClient, cancellationToken).ConfigureAwait(false); + } + + await photosClient.Cache.Entities.SetPhotosShareIdAsync(shareDto.Id, cancellationToken).ConfigureAwait(false); + + var nodeUid = new NodeUid(volumeDto.Id, linkDetailsDto.Link.Id); + + var (share, shareKey) = await ShareCrypto.DecryptShareAsync( + photosClient.DriveClient, + shareDto.Id, + shareDto.Key, + shareDto.Passphrase, + shareDto.AddressId, + nodeUid, + cancellationToken).ConfigureAwait(false); + + await photosClient.DriveClient.Cache.Secrets.SetShareKeyAsync(share.Id, shareKey, cancellationToken).ConfigureAwait(false); + await photosClient.DriveClient.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + + var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( + photosClient.DriveClient, + volumeDto.Id, + linkDetailsDto, + shareKey, + cancellationToken) + .ConfigureAwait(false); + + return metadataResult.GetValueOrThrow().Node; + } + + private static async ValueTask CreatePhotosFolderAsync(ProtonPhotosClient photosClient, CancellationToken cancellationToken) + { + var (_, _, folderNode) = await VolumeOperations.CreatePhotosVolumeAsync(photosClient, cancellationToken).ConfigureAwait(false); + + return folderNode; + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj new file mode 100644 index 00000000..0b65e562 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj @@ -0,0 +1,20 @@ + + + + true + Cloud Storage Volume Photo Album + Provides the means to interact with the Proton Photos services. + true + true + snupkg + + + + + + + + + + + diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs new file mode 100644 index 00000000..a1243fda --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs @@ -0,0 +1,76 @@ +using Proton.Drive.Sdk; +using Proton.Drive.Sdk.Nodes; +using Proton.Photos.Sdk.Api; +using Proton.Photos.Sdk.Caching; +using Proton.Photos.Sdk.Nodes; +using Proton.Sdk; +using Proton.Sdk.Caching; +using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; + +namespace Proton.Photos.Sdk; + +public sealed class ProtonPhotosClient : IDisposable +{ + private const int ApiTimeoutSeconds = 20; + + private readonly HttpClient _httpClient; + + public ProtonPhotosClient(ProtonApiSession session, string? uid = null) + { + DriveClient = new ProtonDriveClient(session, uid); + + _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds)); + + Cache = new PhotosClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository); + PhotosApi = new PhotosApiClient(_httpClient); + } + + public ProtonPhotosClient( + IHttpClientFactory httpClientFactory, + IAccountClient accountClient, + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository, + IFeatureFlagProvider featureFlagProvider, + ITelemetry telemetry, + string? uid = null) + { + DriveClient = new ProtonDriveClient( + httpClientFactory, + accountClient, + entityCacheRepository, + secretCacheRepository, + featureFlagProvider, + telemetry, + uid); + + _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClient(); + + Cache = new PhotosClientCache(entityCacheRepository, secretCacheRepository); + PhotosApi = new PhotosApiClient(_httpClient); + } + + internal IPhotosApiClient PhotosApi { get; } + + internal IPhotosClientCache Cache { get; } + + internal ProtonDriveClient DriveClient { get; } + + [Obsolete("Used internally for testing purposes")] + public ValueTask GetPhotosRootAsync(CancellationToken cancellationToken) + { + return PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken); + } + +#pragma warning disable S2325 + public IAsyncEnumerable> EnumeratePhotosTimelineAsync(NodeUid uid, CancellationToken cancellationToken) +#pragma warning restore S2325 + { + throw new NotSupportedException(); + } + + public void Dispose() + { + _httpClient.Dispose(); + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs new file mode 100644 index 00000000..95349b88 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Proton.Photos.Sdk.Api.Photos; +using Proton.Sdk.Serialization; + +namespace Proton.Photos.Sdk.Serialization; + +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( +#if DEBUG + WriteIndented = true, +#endif + Converters = + [ + typeof(PgpArmoredMessageJsonConverter), + typeof(PgpArmoredSignatureJsonConverter), + typeof(PgpArmoredPrivateKeyJsonConverter), + typeof(PgpArmoredPublicKeyJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 +[JsonSerializable(typeof(PhotosVolumeCreationRequest))] +[JsonSerializable(typeof(PhotosVolumeShareCreationParameters))] +[JsonSerializable(typeof(PhotosVolumeLinkCreationParameters))] +internal sealed partial class PhotosApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs new file mode 100644 index 00000000..e7c54c78 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs @@ -0,0 +1,117 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk; +using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Photos.Sdk.Api.Photos; +using Proton.Sdk.Addresses; + +namespace Proton.Photos.Sdk.Volumes; + +internal static class VolumeOperations +{ + private const string RootFolderName = "root"; + + public static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreatePhotosVolumeAsync( + ProtonPhotosClient photosClient, + CancellationToken cancellationToken) + { + var defaultAddress = await photosClient.DriveClient.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); + + var addressKey = await photosClient.DriveClient.Account.GetAddressPrimaryPrivateKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); + + var addressKeyId = defaultAddress.GetPrimaryKey().AddressKeyId; + + var request = GetCreationRequest(defaultAddress.Id, addressKeyId, addressKey, out var rootShareKey, out var rootFolderSecrets); + + var response = await photosClient.PhotosApi.CreateVolumeAsync(request, cancellationToken).ConfigureAwait(false); + + var volume = new Volume(response.Volume); + + var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id); + + var rootFolder = new FolderNode + { + Uid = volume.RootFolderId, + ParentUid = null, + Name = RootFolderName, + NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, + Author = new Author { EmailAddress = defaultAddress.EmailAddress }, + CreationTime = DateTime.UtcNow, + }; + + // The volume root folder never has siblings and does not need a name hash digest + var nameHashDigest = ReadOnlyMemory.Empty; + + await photosClient.Cache.Entities.SetPhotosVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); + await photosClient.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share.Id, nameHashDigest, cancellationToken).ConfigureAwait(false); + await photosClient.Cache.Entities.SetPhotosShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); + await photosClient.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + + await photosClient.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); + await photosClient.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); + + return (volume, share, rootFolder); + } + + private static PhotosVolumeCreationRequest GetCreationRequest( + AddressId addressId, + AddressKeyId addressKeyId, + PgpPrivateKey addressKey, + out PgpPrivateKey rootShareKey, + out FolderSecrets rootFolderSecrets) + { + rootShareKey = CryptoGenerator.GeneratePrivateKey(); + + rootFolderSecrets = new FolderSecrets + { + Key = CryptoGenerator.GeneratePrivateKey(), + PassphraseSessionKey = CryptoGenerator.GenerateSessionKey(), + NameSessionKey = CryptoGenerator.GenerateSessionKey(), + HashKey = CryptoGenerator.GenerateFolderHashKey(), + }; + + Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var sharePassphrase = CryptoGenerator.GeneratePassphrase(sharePassphraseBuffer); + using var lockedShareKey = rootShareKey.Lock(sharePassphrase); + + var encryptedSharePassphrase = addressKey.EncryptAndSign(sharePassphrase, addressKey, out var sharePassphraseSignature); + + Span folderPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var folderPassphrase = CryptoGenerator.GeneratePassphrase(folderPassphraseBuffer); + using var lockedFolderKey = rootFolderSecrets.Key.Lock(folderPassphrase); + + var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.PassphraseSessionKey); + var encryptedFolderPassphrase = PgpEncrypter.EncryptAndSign( + folderPassphrase, + folderPassphraseEncryptionSecrets, + addressKey, + out var folderPassphraseSignature); + + var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.NameSessionKey); + var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); + + var encryptedHashKey = rootFolderSecrets.Key.EncryptAndSign(rootFolderSecrets.HashKey.Span, addressKey); + + return new PhotosVolumeCreationRequest + { + Share = new PhotosVolumeShareCreationParameters + { + AddressId = addressId, + AddressKeyId = addressKeyId, + Key = lockedShareKey.ToBytes(), + Passphrase = encryptedSharePassphrase, + PassphraseSignature = sharePassphraseSignature, + }, + Link = new PhotosVolumeLinkCreationParameters + { + Name = encryptedName, + NodeKey = lockedFolderKey.ToBytes(), + NodePassphrase = encryptedFolderPassphrase, + NodePassphraseSignature = folderPassphraseSignature, + NodeHashKey = encryptedHashKey, + }, + }; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index 67fabd4d..7d2c1393 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -33,6 +33,7 @@ + From 98e6774b9b8064049316f275d46c12a9d4b7f26b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 09:50:46 +0100 Subject: [PATCH 387/791] Add photo downloader --- .../Nodes/Download/FileDownloader.cs | 2 +- .../Nodes/Download/IFileDownloader.cs | 8 ++ cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs | 2 +- .../Proton.Photos.Sdk/Api/IPhotosApiClient.cs | 2 + .../Proton.Photos.Sdk/Api/Photos/AlbumDto.cs | 20 ++++ .../Proton.Photos.Sdk/Api/Photos/PhotoDto.cs | 29 +++++ .../Api/Photos/PhotoListResponse.cs | 6 + .../Proton.Photos.Sdk/Api/Photos/PhotoTag.cs | 15 +++ .../Api/Photos/PhotoTimelineRequest.cs | 13 ++ .../Proton.Photos.Sdk/Api/PhotosApiClient.cs | 9 ++ .../Caching/IPhotosEntityCache.cs | 2 + .../Caching/IPhotosSecretCache.cs | 2 + .../Caching/PhotosEntityCache.cs | 9 ++ .../Caching/PhotosSecretCache.cs | 9 ++ .../Nodes/PhotoDownloader.cs | 113 ++++++++++++++++++ .../Proton.Photos.Sdk/Nodes/PhotoNode.cs | 8 ++ .../Nodes/PhotosNodeOperations.cs | 30 ++++- .../Nodes/PhotosTimelineItem.cs | 5 + .../Proton.Photos.Sdk/ProtonPhotosClient.cs | 15 ++- .../PhotosApiSerializerContext.cs | 2 + 20 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/IFileDownloader.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoListResponse.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTimelineRequest.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDownloader.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNode.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 8af26673..d9c1c2f8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -2,7 +2,7 @@ namespace Proton.Drive.Sdk.Nodes.Download; -public sealed partial class FileDownloader : IDisposable +public sealed partial class FileDownloader : IFileDownloader { private readonly ProtonDriveClient _client; private readonly RevisionUid _revisionUid; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/IFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/IFileDownloader.cs new file mode 100644 index 00000000..c0457892 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/IFileDownloader.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +public interface IFileDownloader : IDisposable +{ + DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken); + + DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs index 27fdef69..15193866 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes; -public sealed record FileNode : FileOrFileDraftNode +public record FileNode : FileOrFileDraftNode { public required Revision ActiveRevision { get; init; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs index 29eb656c..db25d4b0 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs @@ -9,4 +9,6 @@ internal interface IPhotosApiClient ValueTask CreateVolumeAsync(PhotosVolumeCreationRequest request, CancellationToken cancellationToken); ValueTask GetRootShareAsync(CancellationToken cancellationToken); + + ValueTask GetPhotosTimelineAsync(PhotoTimelineRequest request, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs new file mode 100644 index 00000000..fd77e375 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Serialization; + +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class AlbumDto +{ + [JsonPropertyName("AlbumLinkID")] + public required LinkId Id { get; init; } + + [JsonPropertyName("Hash")] + public required string NameHash { get; init; } + + public required string ContentHash { get; init; } + + [JsonConverter(typeof(EpochSecondsJsonConverter))] + [JsonPropertyName("AddedTime")] + public required DateTime CreationTime { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs new file mode 100644 index 00000000..51ae5a51 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Serialization; + +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class PhotoDto +{ + [JsonPropertyName("LinkID")] + public required LinkId Id { get; init; } + + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CaptureTime { get; init; } + + [JsonPropertyName("Hash")] + public required string NameHash { get; init; } + + public required string ContentHash { get; init; } + + [JsonPropertyName("MainPhotoLinkID")] + public string? MainPhotoLinkId { get; init; } + + [JsonPropertyName("RelatedPhotosLinkIDs")] + public IReadOnlyList RelatedPhotosLinkIds { get; init; } = []; + + public IReadOnlyList Tags { get; init; } = []; + + public IReadOnlyList Albums { get; init; } = []; +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoListResponse.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoListResponse.cs new file mode 100644 index 00000000..96779587 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoListResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class PhotoListResponse +{ + public required IReadOnlyList Photos { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs new file mode 100644 index 00000000..461c0bcf --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs @@ -0,0 +1,15 @@ +namespace Proton.Photos.Sdk.Api.Photos; + +public enum PhotoTag +{ + Favorites = 0, + Screenshots = 1, + Videos = 2, + LivePhotos = 3, + MotionPhotos = 4, + Selfies = 5, + Portraits = 6, + Bursts = 7, + Panoramas = 8, + Raw = 9, +}; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTimelineRequest.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTimelineRequest.cs new file mode 100644 index 00000000..8e36d78c --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTimelineRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class PhotoTimelineRequest +{ + public required VolumeId VolumeId { get; init; } + + [JsonPropertyName("PreviousPageLastLinkID")] + public LinkId? PreviousPageLastLinkId { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs index b21f12a4..e7064a32 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs @@ -24,4 +24,13 @@ public async ValueTask GetRootShareAsync(CancellationToken canc .Expecting(DriveApiSerializerContext.Default.ShareResponseV2) .GetAsync("v2/shares/photos", cancellationToken).ConfigureAwait(false); } + + public async ValueTask GetPhotosTimelineAsync(PhotoTimelineRequest request, CancellationToken cancellationToken) + { + var query = request.PreviousPageLastLinkId is not null ? $"?PreviousPageLastLinkID={request.PreviousPageLastLinkId}" : string.Empty; + + return await _httpClient + .Expecting(PhotosApiSerializerContext.Default.PhotoListResponse) + .GetAsync($"volumes/{request.VolumeId}/photos{query}", cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs index 49a67bcc..8b51fb86 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs @@ -22,4 +22,6 @@ ValueTask SetNodeAsync( ShareId? membershipShareId, ReadOnlyMemory nameHashDigest, CancellationToken cancellationToken); + + ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs index cd6d5fcf..d9a392d9 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs @@ -13,4 +13,6 @@ ValueTask SetFolderSecretsAsync( NodeUid nodeId, Result secretsProvisionResult, CancellationToken cancellationToken); + + ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs index b1a6ba58..af70c2e8 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs @@ -61,6 +61,15 @@ public ValueTask SetNodeAsync( return _repository.SetAsync(GetNodeCacheKey(nodeId), serializedValue, cancellationToken); } + public async ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetNodeCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.CachedNodeInfo) + : null; + } + private static string GetShareCacheKey(ShareId shareId) { return $"share:{shareId}"; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs index 8fb3a8e6..0ad36937 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs @@ -30,6 +30,15 @@ public ValueTask SetFolderSecretsAsync( return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); } + public async ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetFolderSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableResultFolderSecretsDegradedFolderSecrets) + : null; + } + private static string GetShareKeyCacheKey(ShareId shareId) { return $"share:{shareId}:key"; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDownloader.cs new file mode 100644 index 00000000..6a79527b --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDownloader.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Nodes.Download; + +namespace Proton.Photos.Sdk.Nodes; + +public sealed partial class PhotoDownloader : IFileDownloader +{ + private readonly ProtonPhotosClient _client; + private readonly NodeUid _photoUid; + private readonly ILogger _logger; + + private volatile int _remainingNumberOfBlocksToList; + + private PhotoDownloader(ProtonPhotosClient client, NodeUid photoUid, ILogger logger) + { + _client = client; + _photoUid = photoUid; + _logger = logger; + _remainingNumberOfBlocksToList = 1; + } + + public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + { + var task = DownloadToStreamAsync(contentOutputStream, onProgress, cancellationToken); + + return new DownloadController(task); + } + + public DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken) + { + var task = DownloadToFileAsync(filePath, onProgress, cancellationToken); + + return new DownloadController(task); + } + + public void Dispose() + { + ReleaseRemainingBlockListing(); + } + + internal static async ValueTask CreateAsync(ProtonPhotosClient client, NodeUid photoUid, CancellationToken cancellationToken) + { + var logger = client.DriveClient.Telemetry.GetLogger("Photo downloader"); + LogEnteringBlockListingSemaphore(logger, photoUid, 1); + await client.DriveClient.BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); + LogEnteredBlockListingSemaphore(logger, photoUid, 1); + + return new PhotoDownloader(client, photoUid, logger); + } + + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter block listing semaphore for photo {PhotoUid} with {Increment}")] + private static partial void LogEnteringBlockListingSemaphore(ILogger logger, NodeUid photoUid, int increment); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Entered block listing semaphore for photo {PhotoUid} with {Increment}")] + private static partial void LogEnteredBlockListingSemaphore(ILogger logger, NodeUid photoUid, int increment); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Decrement} from block listing semaphore for photo {PhotoUid}")] + private static partial void LogReleasedBlockListingSemaphore(ILogger logger, NodeUid photoUid, int decrement); + + private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + { + var result = await _client.DriveClient.GetNodeAsync(_photoUid, cancellationToken).ConfigureAwait(false); + + if (result is null || !result.Value.TryGetValueElseError(out var node, out _) || node is not FileNode fileNode) + { + throw new ProtonDriveException($"Revision not found for photo with ID {_photoUid}"); + } + + using var revisionReader = await RevisionOperations.OpenForReadingAsync(_client.DriveClient, fileNode.ActiveRevision.Uid, ReleaseBlockListing, cancellationToken) + .ConfigureAwait(false); + + await revisionReader.ReadAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); + } + + private async Task DownloadToFileAsync(string filePath, Action onProgress, CancellationToken cancellationToken) + { + var contentOutputStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + + await using (contentOutputStream.ConfigureAwait(false)) + { + await DownloadToStreamAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); + } + } + + private void ReleaseBlockListing(int numberOfBlockListings) + { + var newRemainingNumberOfBlocks = Interlocked.Add(ref _remainingNumberOfBlocksToList, -numberOfBlockListings); + + var amountToRelease = Math.Max( + newRemainingNumberOfBlocks >= 0 + ? numberOfBlockListings + : newRemainingNumberOfBlocks + numberOfBlockListings, + 0); + + _client.DriveClient.BlockListingSemaphore.Release(amountToRelease); + LogReleasedBlockListingSemaphore(_logger, _photoUid, amountToRelease); + } + + private void ReleaseRemainingBlockListing() + { + if (_remainingNumberOfBlocksToList <= 0) + { + return; + } + + _client.DriveClient.BlockListingSemaphore.Release(_remainingNumberOfBlocksToList); + LogReleasedBlockListingSemaphore(_logger, _photoUid, _remainingNumberOfBlocksToList); + + _remainingNumberOfBlocksToList = 0; + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNode.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNode.cs new file mode 100644 index 00000000..f4c463df --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNode.cs @@ -0,0 +1,8 @@ +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Photos.Sdk.Nodes; + +public sealed record PhotoNode : FileNode +{ + public required DateTime CaptureTime { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs index afe8c8c9..bbf409fd 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs @@ -1,7 +1,9 @@ +using System.Runtime.CompilerServices; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; +using Proton.Photos.Sdk.Api.Photos; using Proton.Photos.Sdk.Volumes; using Proton.Sdk; using Proton.Sdk.Api; @@ -10,6 +12,8 @@ namespace Proton.Photos.Sdk.Nodes; internal static class PhotosNodeOperations { + private const int PhotosPageSize = 500; + public static async ValueTask GetPhotosFolderAsync(ProtonPhotosClient client, CancellationToken cancellationToken) { var shareId = await client.Cache.Entities.TryGetPhotosShareIdAsync(cancellationToken).ConfigureAwait(false); @@ -20,11 +24,35 @@ public static async ValueTask GetPhotosFolderAsync(ProtonPhotosClien var shareAndKey = await ShareOperations.GetShareAsync(client.DriveClient, shareId.Value, cancellationToken).ConfigureAwait(false); - var metadata = await NodeOperations.GetNodeMetadataAsync(client.DriveClient, shareAndKey.Share.RootFolderId, shareAndKey, cancellationToken).ConfigureAwait(false); + var metadata = await NodeOperations.GetNodeMetadataAsync(client.DriveClient, shareAndKey.Share.RootFolderId, shareAndKey, cancellationToken) + .ConfigureAwait(false); return (FolderNode)metadata.Node; } + public static async IAsyncEnumerable EnumeratePhotosAsync( + ProtonPhotosClient client, + NodeUid folderUid, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var anchorLinkId = default(LinkId?); + + do + { + var request = new PhotoTimelineRequest { VolumeId = folderUid.VolumeId, PreviousPageLastLinkId = anchorLinkId }; + var response = await client.PhotosApi.GetPhotosTimelineAsync(request, cancellationToken).ConfigureAwait(false); + + anchorLinkId = response.Photos.Count == PhotosPageSize ? response.Photos[^1].Id : null; + + foreach (var photo in response.Photos) + { + var photoUid = new NodeUid(folderUid.VolumeId, photo.Id); + + yield return new PhotosTimelineItem(photoUid, photo.CaptureTime); + } + } while (anchorLinkId is not null); + } + private static async ValueTask GetFreshPhotosFolderAsync(ProtonPhotosClient photosClient, CancellationToken cancellationToken) { ShareVolumeDto volumeDto; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs new file mode 100644 index 00000000..74dd0d5e --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs @@ -0,0 +1,5 @@ +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Photos.Sdk.Nodes; + +public sealed record PhotosTimelineItem(NodeUid Uid, DateTime CaptureTime); diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs index a1243fda..12b1bfd5 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Proton.Drive.Sdk; using Proton.Drive.Sdk.Nodes; using Proton.Photos.Sdk.Api; @@ -56,17 +57,21 @@ public ProtonPhotosClient( internal ProtonDriveClient DriveClient { get; } - [Obsolete("Used internally for testing purposes")] + [Experimental("Photos")] public ValueTask GetPhotosRootAsync(CancellationToken cancellationToken) { return PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken); } -#pragma warning disable S2325 - public IAsyncEnumerable> EnumeratePhotosTimelineAsync(NodeUid uid, CancellationToken cancellationToken) -#pragma warning restore S2325 + [Experimental("Photos")] + public IAsyncEnumerable EnumeratePhotosTimelineAsync(NodeUid uid, CancellationToken cancellationToken) { - throw new NotSupportedException(); + return PhotosNodeOperations.EnumeratePhotosAsync(this, uid, cancellationToken); + } + + public async ValueTask GetPhotoDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) + { + return await PhotoDownloader.CreateAsync(this, photoUid, cancellationToken).ConfigureAwait(false); } public void Dispose() diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs index 95349b88..6265366a 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -20,4 +20,6 @@ namespace Proton.Photos.Sdk.Serialization; [JsonSerializable(typeof(PhotosVolumeCreationRequest))] [JsonSerializable(typeof(PhotosVolumeShareCreationParameters))] [JsonSerializable(typeof(PhotosVolumeLinkCreationParameters))] +[JsonSerializable(typeof(PhotoTimelineRequest))] +[JsonSerializable(typeof(PhotoListResponse))] internal sealed partial class PhotosApiSerializerContext : JsonSerializerContext; From 4b817e83d8c75704b73a698f9dc48e1a0a4b9c01 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 09:26:12 +0100 Subject: [PATCH 388/791] Handle failed upload due to double-commit attempt --- js/sdk/src/internal/upload/apiService.ts | 25 +++++++++++++ js/sdk/src/internal/upload/manager.test.ts | 37 ++++++++++++++++++++ js/sdk/src/internal/upload/manager.ts | 18 +++++++++- js/sdk/src/internal/upload/streamUploader.ts | 2 +- 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 4c742599..7a11f84d 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -45,6 +45,13 @@ type PostDeleteNodesRequest = Extract< type PostDeleteNodesResponse = drivePaths['/drive/v2/volumes/{volumeID}/delete_multiple']['post']['responses']['200']['content']['application/json']; +type PostLoadLinksMetadataRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostLoadLinksMetadataResponse = + drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; + export class UploadAPIService { constructor( protected apiService: DriveAPIService, @@ -262,4 +269,22 @@ export class UploadAPIService { await this.apiService.postBlockStream(url, token, formData, onProgress, signal); } + + async isRevisionUploaded(nodeRevisionUid: string): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + const result = await this.apiService.post( + `drive/v2/volumes/${volumeId}/links`, + { + LinkIDs: [nodeId], + }, + ); + if (result.Links.length === 0) { + return false; + } + const link = result.Links[0]; + return ( + link.Link.State === 1 && // ACTIVE state + link.File?.ActiveRevision?.RevisionID === revisionId + ); + } } diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 69297c5b..e480ab18 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -327,5 +327,42 @@ describe('UploadManager', () => { expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid'); expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); }); + + it('should ignore error if revision was committed successfully', async () => { + apiService.commitDraftRevision = jest + .fn() + .mockRejectedValue(new Error('Revision to commit must be a draft')); + apiService.isRevisionUploaded = jest.fn().mockResolvedValue(true); + + await manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes); + + expect(apiService.commitDraftRevision).toHaveBeenCalledWith( + nodeRevisionDraft.nodeRevisionUid, + expect.anything(), + ); + expect(nodesService.notifyNodeChanged).toHaveBeenCalled(); + }); + + it('should throw error if revision was not committed successfully', async () => { + apiService.commitDraftRevision = jest + .fn() + .mockRejectedValue(new Error('Revision to commit must be a draft')); + apiService.isRevisionUploaded = jest.fn().mockResolvedValue(false); + + await expect(manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes)).rejects.toThrow( + 'Revision to commit must be a draft', + ); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); + }); + + it('should throw original error if revision cannot be verified', async () => { + apiService.commitDraftRevision = jest.fn().mockRejectedValue(new Error('Failed to commit revision')); + apiService.isRevisionUploaded = jest.fn().mockRejectedValue(new Error('Failed to verify revision')); + + await expect(manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes)).rejects.toThrow( + 'Failed to commit revision', + ); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); + }); }); }); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 2678788d..d21ebc7a 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -246,7 +246,23 @@ export class UploadManager { manifest, generatedExtendedAttributes, ); - await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto); + try { + await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto); + } catch (error: unknown) { + // Commit might be sent but due to network error no response is + // received. In this case, API service automatically retries the + // request. If the first attempt passed, it will fail on the second + // attempt. We need to check if the revision was actually committed. + try { + const isRevisionUploaded = await this.apiService.isRevisionUploaded(nodeRevisionDraft.nodeRevisionUid); + if (!isRevisionUploaded) { + throw error; + } + } catch { + throw error; // Throw original error, not the checking one. + } + this.logger.warn(`Node commit failed but node was committed successfully ${nodeRevisionDraft.nodeUid}`); + } await this.notifyNodeUploaded(nodeRevisionDraft); } diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 43fd5434..44c92b38 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -181,7 +181,7 @@ export class StreamUploader { await this.controller.waitWhilePaused(); await this.waitForUploadCapacityAndBufferedBlocks(); - if (this.isEncryptionFullyFinished) { + if (this.isEncryptionFullyFinished || this.isUploadAborted) { break; } From 5d009b492abdd52e6bf7c9979eb1f92d22116a65 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 13:10:20 +0100 Subject: [PATCH 389/791] Fix exception on retrying thumbnail block upload --- cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 12a05e70..32d50a2e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -242,7 +242,8 @@ await Policy onRetry: (exception, _, retryNumber, _) => { var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); - LogBlobUploadFailure(exception, request.Blocks[0].Index, revisionUid, retryNumber); + var blockIndex = request.Blocks.Count > 0 ? request.Blocks[0].Index : 0; + LogBlobUploadFailure(exception, blockIndex, revisionUid, retryNumber); }) .ExecuteAsync(ExecuteUploadAsync).ConfigureAwait(false); } From 23f7357548476b4d9d618025fd599cf74805b010 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 15:17:26 +0100 Subject: [PATCH 390/791] Add empty-trash Implementation --- js/sdk/src/internal/nodes/apiService.ts | 7 +++++++ js/sdk/src/internal/nodes/nodesManagement.ts | 6 ++++++ js/sdk/src/protonDriveClient.ts | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 73fbb74d..44e4334f 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -57,6 +57,9 @@ type PostCopyNodeRequest = Extract< type PostCopyNodeResponse = drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['responses']['200']['content']['application/json']; +type EmptyTrashResponse = + drivePaths['/drive/volumes/{volumeID}/trash']['delete']['responses']['200']['content']['application/json']; + type PostTrashNodesRequest = Extract< drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['requestBody'], { content: object } @@ -436,6 +439,10 @@ export abstract class NodeAPIServiceBase< } } + async emptyTrash(volumeId: string): Promise { + await this.apiService.delete(`drive/volumes/${volumeId}/trash`); + } + async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) { const response = await this.apiService.put( diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 319897af..2e46d698 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -127,6 +127,12 @@ export abstract class NodesManagementBase< } } + async emptyTrash(): Promise { + const node = await this.nodesAccess.getVolumeRootFolder(); + const { volumeId } = splitNodeUid(node.uid); + await this.apiService.emptyTrash(volumeId); + } + async moveNode(nodeUid: string, newParentUid: string): Promise { const node = await this.nodesAccess.getNode(nodeUid); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index e21a30e2..539d62d7 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -514,7 +514,7 @@ export class ProtonDriveClient { async emptyTrash(): Promise { this.logger.info('Emptying trash'); - throw new Error('Method not implemented'); + return this.nodes.management.emptyTrash(); } /** From c9c4ed6073778d55419e3b4f41ed3c9880c1848c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 15:45:07 +0100 Subject: [PATCH 391/791] Implement pausing and resuming of uploads --- .../InteropUploadController.cs | 4 +- .../Proton.Drive.Sdk/Nodes/ITaskControl.cs | 9 + .../Nodes/RevisionOperations.cs | 13 +- .../src/Proton.Drive.Sdk/Nodes/TaskControl.cs | 122 +++++++++++ .../Nodes/Upload/BlockUploader.cs | 206 +++++++----------- .../Nodes/Upload/FileUploader.cs | 60 +++-- .../Nodes/Upload/RevisionWriter.cs | 145 ++++++++---- .../Nodes/Upload/RevisionWriterExtensions.cs | 72 ------ .../Nodes/Upload/UploadController.cs | 36 ++- .../Nodes/Upload/UploadResult.cs | 3 + .../Logging/InteropLogger.cs | 4 +- .../ProtonClientConfigurationExtensions.cs | 9 +- 12 files changed, 413 insertions(+), 270 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadResult.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs index 685a997d..87d5e72f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -43,7 +43,9 @@ public static IMessage HandleIsPaused(UploadControllerIsPausedRequest request) public static IMessage? HandleFree(UploadControllerFreeRequest request) { - Interop.FreeHandle(request.UploadControllerHandle); + var uploadController = Interop.FreeHandle(request.UploadControllerHandle); + + uploadController.Dispose(); return null; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs new file mode 100644 index 00000000..776bd428 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal interface ITaskControl : IDisposable +{ + bool IsPaused { get; } + Task PauseExceptionSignal { get; } + void Pause(); + void Resume(); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index c2c69727..089e1593 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -53,12 +53,17 @@ public static async ValueTask OpenForWritingAsync( RevisionUid revisionUid, FileSecrets fileSecrets, Action releaseBlocksAction, - CancellationToken cancellationToken) + TaskControl taskControl) { - var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + var (membershipAddress, signingKey) = await taskControl.HandlePauseAsync(async ct => + { + var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, revisionUid.NodeUid, ct).ConfigureAwait(false); + var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, ct).ConfigureAwait(false); + + return (membershipAddress, signingKey); + }).ConfigureAwait(false); - await client.BlockUploader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); + await client.BlockUploader.Queue.StartFileAsync(taskControl.CancellationToken).ConfigureAwait(false); return new RevisionWriter( client, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs new file mode 100644 index 00000000..9951d12b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs @@ -0,0 +1,122 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class TaskControl(CancellationToken cancellationToken) : ITaskControl +{ + private readonly Lock _pauseLock = new(); + + private TaskCompletionSource? _resumeSignalSource; + private TaskCompletionSource _pauseExceptionSignalSource = new(); + private CancellationTokenSource _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + public bool IsPaused => _resumeSignalSource is { Task.IsCompleted: false } && !IsCanceled; + public bool IsCanceled => CancellationToken.IsCancellationRequested; + + public CancellationToken CancellationToken { get; } = cancellationToken; + public CancellationToken PauseOrCancellationToken => _pauseCancellationTokenSource.Token; + + public Task PauseExceptionSignal => _pauseExceptionSignalSource.Task; + + public void Pause() + { + if (IsPaused) + { + return; + } + + lock (_pauseLock) + { + if (IsPaused) + { + return; + } + + _resumeSignalSource = new TaskCompletionSource(); + + // TODO: write unit test to verify that we reset the pause exception signal if and only if the previous one is faulted + if (PauseExceptionSignal.IsFaulted) + { + _pauseExceptionSignalSource = new TaskCompletionSource(); + } + + _pauseCancellationTokenSource.Cancel(); + } + } + + public void PauseOnError(Exception ex) + { + // TODO: write unit test to check that we don't use the new signal source set by the Pause() call + var pauseExceptionSignalSource = _pauseExceptionSignalSource; + + Pause(); + + pauseExceptionSignalSource.TrySetException(ex); + } + + public void Resume() + { + if (!IsPaused) + { + return; + } + + lock (_pauseLock) + { + if (!IsPaused) + { + return; + } + + // TODO: write unit test to justify that the fields must be set to the new state before signaling resume + _pauseCancellationTokenSource.Dispose(); + _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); + + _pauseExceptionSignalSource = new TaskCompletionSource(); + + var resumeSignalSource = _resumeSignalSource; + _resumeSignalSource = null; + + resumeSignalSource?.SetResult(); + } + } + + public async ValueTask WaitWhilePausedAsync() + { + var resumeTask = _resumeSignalSource?.Task; + + if (resumeTask is not null) + { + await resumeTask.WaitAsync(CancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask HandlePauseAsync(Func> function, Func? exceptionTriggersPause = null) + { + await WaitWhilePausedAsync().ConfigureAwait(false); + + while (true) + { + try + { + return await function.Invoke(PauseOrCancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (IsPaused) + { + await WaitWhilePausedAsync().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (exceptionTriggersPause?.Invoke(ex) == true) + { + PauseOnError(ex); + await WaitWhilePausedAsync().ConfigureAwait(false); + } + } + } + + public void Dispose() + { + _pauseCancellationTokenSource.Dispose(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 32d50a2e..44a8957e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -1,4 +1,3 @@ -using System.Buffers; using System.Diagnostics; using System.Security.Cryptography; using Microsoft.Extensions.Logging; @@ -31,7 +30,7 @@ internal BlockUploader(ProtonDriveClient client, int maxDegreeOfParallelism) public TransferQueue Queue { get; } - public async Task UploadContentAsync( + public async ValueTask UploadContentAsync( RevisionUid revisionUid, int index, PgpSessionKey contentKey, @@ -43,78 +42,68 @@ public async Task UploadContentAsync( byte[] plainDataPrefix, int plainDataPrefixLength, Action? onBlockProgress, - Action releaseBlocksAction, CancellationToken cancellationToken) { - try + var plainDataLength = plainDataStream.Length; + + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) { - try + var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + await using (signatureStream.ConfigureAwait(false)) { - var plainDataLength = plainDataStream.Length; + using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (dataPacketStream.ConfigureAwait(false)) + var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); + + await using (hashingStream.ConfigureAwait(false)) { - var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + var signatureEncryptingStream = signatureEncryptionKey.OpenEncryptingStream(signatureStream); - await using (signatureStream.ConfigureAwait(false)) + await using (signatureEncryptingStream.ConfigureAwait(false)) { - BlockUploadResult result; + var pgpProfile = contentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signatureEncryptingStream, signingKey, profile: pgpProfile, aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); - await using (plainDataStream.ConfigureAwait(false)) + await using (encryptingStream.ConfigureAwait(false)) { - using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - - var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); - - await using (hashingStream.ConfigureAwait(false)) - { - var signatureEncryptingStream = signatureEncryptionKey.OpenEncryptingStream(signatureStream); - - await using (signatureEncryptingStream.ConfigureAwait(false)) - { - var pgpProfile = contentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; - var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signatureEncryptingStream, signingKey, profile: pgpProfile, aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); + await plainDataStream.CopyToAsync(encryptingStream, cancellationToken).ConfigureAwait(false); + } + } + } - await using (encryptingStream.ConfigureAwait(false)) - { - await plainDataStream.CopyToAsync(encryptingStream, cancellationToken).ConfigureAwait(false); - } - } - } + var sha256Digest = sha256.GetCurrentHash(); - var sha256Digest = sha256.GetCurrentHash(); + var result = new BlockUploadResult((int)plainDataStream.Length, sha256Digest, IsFileContent: true); - result = new BlockUploadResult((int)plainDataStream.Length, sha256Digest, IsFileContent: true); - } + // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, + // leading to a garbage signature. + var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; - // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, - // leading to a garbage signature. - var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; + // FIXME: retry upon verification failure - // FIXME: retry upon verification failure + const long AeadChunkSize = + 1 + // packet header: packet type + 1 + // packet header: partial length + 4 + // SEIPDv2 header: packet version, cipher ID, algo Id, chunk size + 32 + // SEIPDv2 header: salt + PgpAeadStreamingChunkLength.ChunkLength + + 1 + // chunk size header + 36 + // end of chunk + 16; // Aead Tag - const long AeadChunkSize = - 1 + // packet header: packet type - 1 + // packet header: partial length - 4 + // SEIPDv2 header: packet version, cipher ID, algo Id, chunk size - 32 + // SEIPDv2 header: salt - PgpAeadStreamingChunkLength.ChunkLength + - 1 + // chunk size header - 36 + // end of chunk - 16; // Aead Tag + var verificationToken = verifier.VerifyBlock(dataPacketStream.GetFirstBytes(AeadChunkSize), plainDataPrefix.AsSpan()[..plainDataPrefixLength]); - var verificationToken = verifier.VerifyBlock(dataPacketStream.GetFirstBytes(AeadChunkSize), plainDataPrefix.AsSpan()[..plainDataPrefixLength]); - - var request = new BlockUploadPreparationRequest - { - VolumeId = revisionUid.NodeUid.VolumeId, - LinkId = revisionUid.NodeUid.LinkId, - RevisionId = revisionUid.RevisionId, - AddressId = membershipAddressId, - Blocks = - [ - new BlockCreationRequest + var request = new BlockUploadPreparationRequest + { + VolumeId = revisionUid.NodeUid.VolumeId, + LinkId = revisionUid.NodeUid.LinkId, + RevisionId = revisionUid.RevisionId, + AddressId = membershipAddressId, + Blocks = + [ + new BlockCreationRequest { Index = index, Size = (int)dataPacketStream.Length, @@ -123,38 +112,21 @@ public async Task UploadContentAsync( VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, }, ], - Thumbnails = [], - }; + Thumbnails = [], + }; - await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); + await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); - onBlockProgress?.Invoke(plainDataLength); + onBlockProgress?.Invoke(plainDataLength); - LogContentBlobUploaded(index, revisionUid); + LogContentBlobUploaded(index, revisionUid); - return result; - } - } - } - finally - { - try - { - Queue.FinishBlocks(1); - } - finally - { - releaseBlocksAction.Invoke(1); - } + return result; } } - finally - { - ArrayPool.Shared.Return(plainDataPrefix); - } } - public async Task UploadThumbnailAsync( + public async ValueTask UploadThumbnailAsync( RevisionUid revisionUid, PgpSessionKey contentKey, PgpPrivateKey signingKey, @@ -162,63 +134,49 @@ public async Task UploadThumbnailAsync( Thumbnail thumbnail, CancellationToken cancellationToken) { - try + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) { - var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (dataPacketStream.ConfigureAwait(false)) - { - using var sha256 = SHA256.Create(); + using var sha256 = SHA256.Create(); - var hashingStream = new CryptoStream(dataPacketStream, sha256, CryptoStreamMode.Write, leaveOpen: true); + var hashingStream = new CryptoStream(dataPacketStream, sha256, CryptoStreamMode.Write, leaveOpen: true); - await using (hashingStream.ConfigureAwait(false)) - { - var pgpProfile = contentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; - var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signingKey, profile: pgpProfile, aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); + await using (hashingStream.ConfigureAwait(false)) + { + var pgpProfile = contentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signingKey, profile: pgpProfile, aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); - await using (encryptingStream.ConfigureAwait(false)) - { - await encryptingStream.WriteAsync(thumbnail.Content, cancellationToken).ConfigureAwait(false); - } + await using (encryptingStream.ConfigureAwait(false)) + { + await encryptingStream.WriteAsync(thumbnail.Content, cancellationToken).ConfigureAwait(false); } + } - var sha256Digest = sha256.Hash ?? []; + var sha256Digest = sha256.Hash ?? []; - var request = new BlockUploadPreparationRequest - { - VolumeId = revisionUid.NodeUid.VolumeId, - LinkId = revisionUid.NodeUid.LinkId, - RevisionId = revisionUid.RevisionId, - AddressId = membershipAddressId, - Blocks = [], - Thumbnails = - [ - new ThumbnailCreationRequest + var request = new BlockUploadPreparationRequest + { + VolumeId = revisionUid.NodeUid.VolumeId, + LinkId = revisionUid.NodeUid.LinkId, + RevisionId = revisionUid.RevisionId, + AddressId = membershipAddressId, + Blocks = [], + Thumbnails = + [ + new ThumbnailCreationRequest { Size = (int)dataPacketStream.Length, Type = (Api.Files.ThumbnailType)thumbnail.Type, HashDigest = sha256Digest, }, ], - }; + }; - await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); + await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); - LogThumbnailBlobUploaded(revisionUid); + LogThumbnailBlobUploaded(revisionUid); - return new BlockUploadResult(0, sha256Digest, IsFileContent: false); - } - } - finally - { - try - { - Queue.FinishBlocks(1); - } - finally - { - _client.RevisionCreationSemaphore.Release(1); - } + return new BlockUploadResult(0, sha256Digest, IsFileContent: false); } } @@ -235,9 +193,9 @@ private async ValueTask UploadBlobAsync( await using (nonDisposableDataPacketStream.ConfigureAwait(false)) { await Policy - .Handle(ex => ex is not FileContentsDecryptionException) + .Handle(ex => !cancellationToken.IsCancellationRequested && ex is not FileContentsDecryptionException) .WaitAndRetryAsync( - retryCount: 4, + retryCount: 1, sleepDurationProvider: RetryPolicy.GetAttemptDelay, onRetry: (exception, _, retryNumber, _) => { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 5e93a335..79d5838e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -39,9 +39,16 @@ public UploadController UploadFromStream( Action? onProgress, CancellationToken cancellationToken) { - var task = UploadFromStreamAsync(contentStream, thumbnails, _additionalMetadata, progress => onProgress?.Invoke(progress, FileSize), cancellationToken); + var taskControl = new TaskControl(cancellationToken); - return new UploadController(task); + var uploadTask = UploadFromStreamAsync( + contentStream, + thumbnails, + _additionalMetadata, + progress => onProgress?.Invoke(progress, FileSize), + taskControl); + + return new UploadController(uploadTask, taskControl); } public UploadController UploadFromFile( @@ -50,9 +57,16 @@ public UploadController UploadFromFile( Action? onProgress, CancellationToken cancellationToken) { - var task = UploadFromFileAsync(filePath, thumbnails, _additionalMetadata, progress => onProgress?.Invoke(progress, FileSize), cancellationToken); + var taskControl = new TaskControl(cancellationToken); + + var task = UploadFromFileAsync( + filePath, + thumbnails, + _additionalMetadata, + progress => onProgress?.Invoke(progress, FileSize), + taskControl); - return new UploadController(task); + return new UploadController(task, taskControl); } public void Dispose() @@ -93,14 +107,14 @@ internal static async ValueTask CreateAsync( [LoggerMessage(Level = LogLevel.Error, Message = "Draft deletion failed for revision {RevisionUid}")] private static partial void LogDraftDeletionFailure(ILogger logger, Exception exception, RevisionUid revisionUid); - private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromStreamAsync( + private async Task UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, IEnumerable? additionalExtendedAttributes, Action? onProgress, - CancellationToken cancellationToken) + TaskControl taskControl) { - var (draftRevisionUid, fileSecrets) = await _fileDraftProvider.GetDraftAsync(_client, cancellationToken).ConfigureAwait(false); + var (draftRevisionUid, fileSecrets) = await taskControl.HandlePauseAsync(ct => _fileDraftProvider.GetDraftAsync(_client, ct)).ConfigureAwait(false); await UploadAsync( draftRevisionUid, @@ -110,11 +124,11 @@ await UploadAsync( _lastModificationTime, additionalExtendedAttributes, onProgress, - cancellationToken).ConfigureAwait(false); + taskControl).ConfigureAwait(false); - await UpdateActiveRevisionInCacheAsync(draftRevisionUid, contentStream.Length, cancellationToken).ConfigureAwait(false); + await UpdateActiveRevisionInCacheAsync(draftRevisionUid, contentStream.Length, taskControl.CancellationToken).ConfigureAwait(false); - return (draftRevisionUid.NodeUid, draftRevisionUid); + return new UploadResult(draftRevisionUid.NodeUid, draftRevisionUid); } private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid, long size, CancellationToken cancellationToken) @@ -143,18 +157,23 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid await _client.Cache.Entities.SetNodeAsync(fileNode.Uid, fileNode, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); } - private async Task<(NodeUid NodeUid, RevisionUid RevisionUid)> UploadFromFileAsync( + private async Task UploadFromFileAsync( string filePath, IEnumerable thumbnails, IEnumerable? additionalMetadata, Action? onProgress, - CancellationToken cancellationToken) + TaskControl taskControl) { var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); await using (contentStream.ConfigureAwait(false)) { - return await UploadFromStreamAsync(contentStream, thumbnails, additionalMetadata, onProgress, cancellationToken).ConfigureAwait(false); + return await UploadFromStreamAsync( + contentStream, + thumbnails, + additionalMetadata, + onProgress, + taskControl).ConfigureAwait(false); } } @@ -166,20 +185,25 @@ private async ValueTask UploadAsync( DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, Action? onProgress, - CancellationToken cancellationToken) + TaskControl taskControl) { - using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionUid, fileSecrets, ReleaseBlocks, cancellationToken) - .ConfigureAwait(false); + using var revisionWriter = await RevisionOperations.OpenForWritingAsync( + _client, + revisionUid, + fileSecrets, + ReleaseBlocks, + taskControl).ConfigureAwait(false); try { - await revisionWriter.WriteAsync(contentStream, FileSize, thumbnails, lastModificationTime, additionalMetadata, onProgress, cancellationToken).ConfigureAwait(false); + await revisionWriter.WriteAsync(contentStream, FileSize, thumbnails, lastModificationTime, additionalMetadata, onProgress, taskControl) + .ConfigureAwait(false); } catch { try { - await _fileDraftProvider.DeleteDraftAsync(_client, revisionUid, cancellationToken).ConfigureAwait(false); + await _fileDraftProvider.DeleteDraftAsync(_client, revisionUid, CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index c75619ef..53e54e73 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -5,8 +5,10 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Telemetry; +using Proton.Sdk; using Proton.Sdk.Addresses; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -62,11 +64,8 @@ public async ValueTask WriteAsync( DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, Action? onProgress, - CancellationToken cancellationToken) + TaskControl taskControl) { - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var linkedCancellationToken = cancellationTokenSource.Token; - var uploadEvent = new UploadEvent { ExpectedSize = contentStream.Length, @@ -77,14 +76,13 @@ public async ValueTask WriteAsync( try { - long numberOfBytesUploaded = 0; + var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); + var blockUploadResults = new List(8); var signingEmailAddress = _membershipAddress.EmailAddress; - var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); var blockIndex = 0; - - var blockUploadResults = new List(8); + long numberOfBytesUploaded = 0; var expectedThumbnailBlockCount = 0; using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); @@ -93,7 +91,8 @@ public async ValueTask WriteAsync( await using (hashingContentStream.ConfigureAwait(false)) { - var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(_revisionUid, _fileKey, cancellationToken).ConfigureAwait(false); + var blockVerifier = await taskControl.HandlePauseAsync(ct => _client.BlockVerifierFactory.CreateAsync(_revisionUid, _fileKey, ct)) + .ConfigureAwait(false); try { @@ -103,15 +102,9 @@ public async ValueTask WriteAsync( { ++expectedThumbnailBlockCount; - await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, linkedCancellationToken).ConfigureAwait(false); + await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, taskControl).ConfigureAwait(false); - var uploadTask = _client.BlockUploader.UploadThumbnailAsync( - _revisionUid, - _contentKey, - _signingKey, - _membershipAddress.Id, - thumbnail, - cancellationTokenSource.Token); + var uploadTask = UploadThumbnailBlockAsync(thumbnail, taskControl).AsTask(); uploadTasks.Enqueue(uploadTask); } @@ -120,11 +113,11 @@ public async ValueTask WriteAsync( await TryGetBlockPlainDataStreamAsync( hashingContentStream, blockVerifier.DataPacketPrefixMaxLength, - linkedCancellationToken).ConfigureAwait(false) is var (plainDataStream, plainDataPrefixBuffer)) + taskControl).ConfigureAwait(false) is var (plainDataStream, plainDataPrefixBuffer)) { try { - await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, linkedCancellationToken).ConfigureAwait(false); + await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, taskControl).ConfigureAwait(false); var onBlockProgress = onProgress is not null ? progress => @@ -139,20 +132,13 @@ await TryGetBlockPlainDataStreamAsync( } : default(Action?); - var uploadTask = _client.BlockUploader.UploadContentAsync( - _revisionUid, + var uploadTask = UploadContentBlockAsync( ++blockIndex, - _contentKey, - _signingKey, - _membershipAddress.Id, - _fileKey, plainDataStream, blockVerifier, plainDataPrefixBuffer, - (int)Math.Min(blockVerifier.DataPacketPrefixMaxLength, plainDataStream.Length), onBlockProgress, - _releaseBlocksAction, - linkedCancellationToken); + taskControl).AsTask(); uploadTasks.Enqueue(uploadTask); } @@ -176,8 +162,6 @@ await TryGetBlockPlainDataStreamAsync( } catch { - await cancellationTokenSource.CancelAsync().ConfigureAwait(false); - try { await Task.WhenAll(uploadTasks).ConfigureAwait(false); @@ -191,6 +175,8 @@ await TryGetBlockPlainDataStreamAsync( } } + await taskControl.WaitWhilePausedAsync().ConfigureAwait(false); + var request = GetRevisionUpdateRequest( lastModificationTime, blockUploadResults, @@ -207,11 +193,11 @@ await _client.Api.Files.UpdateRevisionAsync( _revisionUid.NodeUid.LinkId, _revisionUid.RevisionId, request, - linkedCancellationToken).ConfigureAwait(false); + taskControl.CancellationToken).ConfigureAwait(false); LogRevisionSealed(_revisionUid); } - catch (Exception ex) when (!linkedCancellationToken.IsCancellationRequested) + catch (Exception ex) when (!taskControl.IsCanceled) { uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); uploadEvent.OriginalError = ex.GetBaseException().ToString(); @@ -219,7 +205,7 @@ await _client.Api.Files.UpdateRevisionAsync( } finally { - if (!linkedCancellationToken.IsCancellationRequested) + if (!taskControl.IsCanceled) { try { @@ -273,10 +259,93 @@ private static async ValueTask RegisterNextCompletedBlockAsync(Queue UploadContentBlockAsync( + int index, + Stream plainDataStream, + IBlockVerifier blockVerifier, + byte[] plainDataPrefix, + Action? onBlockProgress, + TaskControl taskControl) + { + try + { + await using (plainDataStream.ConfigureAwait(false)) + { + return await taskControl.HandlePauseAsync( + ct => _client.BlockUploader.UploadContentAsync( + _revisionUid, + index, + _contentKey, + _signingKey, + _membershipAddress.Id, + _fileKey, + plainDataStream, + blockVerifier, + plainDataPrefix, + (int)Math.Min(blockVerifier.DataPacketPrefixMaxLength, plainDataStream.Length), + onBlockProgress, + ct), + exceptionTriggersPause: IsResumableError).ConfigureAwait(false); + } + } + finally + { + try + { + _client.BlockUploader.Queue.FinishBlocks(1); + } + finally + { + try + { + ArrayPool.Shared.Return(plainDataPrefix); + } + finally + { + _releaseBlocksAction.Invoke(1); + } + } + } + + static bool IsResumableError(Exception ex) + { + return ex is not ProtonApiException { TransportCode: > 400 and < 500 } + and not NodeKeyAndSessionKeyMismatchException + and not SessionKeyAndDataPacketMismatchException; + } + } + + private async ValueTask UploadThumbnailBlockAsync(Thumbnail thumbnail, TaskControl taskControl) + { + try + { + return await taskControl.HandlePauseAsync( + ct => _client.BlockUploader.UploadThumbnailAsync( + _revisionUid, + _contentKey, + _signingKey, + _membershipAddress.Id, + thumbnail, + ct), + exceptionTriggersPause: _ => true).ConfigureAwait(false); + } + finally + { + try + { + _client.BlockUploader.Queue.FinishBlocks(1); + } + finally + { + _client.RevisionCreationSemaphore.Release(1); + } + } + } + private async ValueTask<(Stream Stream, byte[] Prefix)?> TryGetBlockPlainDataStreamAsync( Stream contentStream, int prefixLength, - CancellationToken cancellationToken) + TaskControl taskControl) { var plainDataPrefixBuffer = ArrayPool.Shared.Rent(prefixLength); try @@ -289,7 +358,7 @@ private static async ValueTask RegisterNextCompletedBlockAsync(Queue> uploadTasks, List blockUploadResults, - CancellationToken cancellationToken) + TaskControl taskControl) { + await taskControl.WaitWhilePausedAsync().ConfigureAwait(false); + if (!_client.BlockUploader.Queue.TryStartBlock()) { if (uploadTasks.Count > 0) @@ -325,7 +396,7 @@ private async ValueTask WaitForBlockUploaderAsync( await RegisterNextCompletedBlockAsync(uploadTasks, blockUploadResults).ConfigureAwait(false); } - await _client.BlockUploader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); + await _client.BlockUploader.Queue.StartBlockAsync(taskControl.CancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs deleted file mode 100644 index 9158bb1c..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriterExtensions.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes.Upload; - -internal static class RevisionWriterExtensions -{ - public static ValueTask WriteAsync( - this RevisionWriter revisionWriter, - Stream contentStream, - long expectedContentLength, - DateTimeOffset? lastModificationTime, - IEnumerable? additionalMetadata, - Action onProgress, - CancellationToken cancellationToken) - { - return revisionWriter.WriteAsync(contentStream, expectedContentLength, [], lastModificationTime, additionalMetadata, onProgress, cancellationToken); - } - - public static ValueTask WriteAsync( - this RevisionWriter revisionWriter, - Stream contentStream, - long expectedContentLength, - DateTime lastModificationTime, - IEnumerable? additionalMetadata, - Action onProgress, - CancellationToken cancellationToken) - { - return revisionWriter.WriteAsync( - contentStream, - expectedContentLength, - [], - new DateTimeOffset(lastModificationTime), - additionalMetadata, - onProgress, - cancellationToken); - } - - public static ValueTask WriteAsync( - this RevisionWriter revisionWriter, - Stream contentStream, - long expectedContentLength, - IEnumerable thumbnails, - DateTime lastModificationTime, - IEnumerable? additionalMetadata, - Action onProgress, - CancellationToken cancellationToken) - { - return revisionWriter.WriteAsync( - contentStream, - expectedContentLength, - thumbnails, - new DateTimeOffset(lastModificationTime), - additionalMetadata, - onProgress, - cancellationToken); - } - - public static async ValueTask WriteAsync( - this RevisionWriter writer, - string targetFilePath, - DateTime lastModificationTime, - IEnumerable? additionalMetadata, - Action onProgress, - CancellationToken cancellationToken) - { - var fileStream = File.Open(targetFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - - await using (fileStream) - { - await WriteAsync(writer, fileStream, fileStream.Length, lastModificationTime, additionalMetadata, onProgress, cancellationToken) - .ConfigureAwait(false); - } - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 312dd989..eb42d43c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -1,22 +1,42 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -public sealed class UploadController(Task<(NodeUid NodeUid, RevisionUid RevisionUid)> uploadTask) +public sealed class UploadController : IDisposable { - // FIXME - public bool IsPaused { get; } + private readonly Task _uploadTask; + private readonly ITaskControl _taskControl; + + internal UploadController(Task uploadTask, ITaskControl taskControl) + { + _uploadTask = uploadTask; + _taskControl = taskControl; + + Completion = Task.WhenAny(_taskControl.PauseExceptionSignal, _uploadTask).Unwrap(); + } + + public bool IsPaused => _taskControl.IsPaused; // FIXME: Add unit test to ensure that the revision UID is of the new active revision - public Task<(NodeUid NodeUid, RevisionUid RevisionUid)> Completion { get; } = uploadTask; + public Task Completion { get; private set; } public void Pause() { - // FIXME - throw new NotImplementedException(); + _taskControl.Pause(); + + Completion = Task.WhenAny(_taskControl.PauseExceptionSignal, _uploadTask).Unwrap(); } public void Resume() { - // FIXME - throw new NotImplementedException(); + _taskControl.Resume(); + + if (Completion.IsFaulted) + { + Completion = Task.WhenAny(_taskControl.PauseExceptionSignal, _uploadTask).Unwrap(); + } + } + + public void Dispose() + { + _taskControl.Dispose(); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadResult.cs new file mode 100644 index 00000000..ca180231 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadResult.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +public readonly record struct UploadResult(NodeUid NodeUid, RevisionUid RevisionUid); diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index a87bb59b..74be3484 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -1,10 +1,8 @@ -using System.Runtime.InteropServices; using Google.Protobuf; using Microsoft.Extensions.Logging; namespace Proton.Sdk.CExports.Logging; -[StructLayout(LayoutKind.Sequential)] internal sealed class InteropLogger(nint bindingsHandle, InteropAction> logAction, string categoryName) : ILogger { private readonly nint _bindingsHandle = bindingsHandle; @@ -44,7 +42,7 @@ private sealed class DummyDisposable : IDisposable { public void Dispose() { - // do nothing intentionally + // Nothing to do } } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs index 16d240f5..2ff3779c 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -79,10 +79,13 @@ public static HttpClient GetHttpClient( options.TotalRequestTimeout.Timeout = totalTimeout.Value; } - var defaultShouldHandleRetry = options.Retry.ShouldHandle; + var defaultShouldHandleRetryPredicate = options.Retry.ShouldHandle; - options.Retry.ShouldHandle = async args => await defaultShouldHandleRetry(args).ConfigureAwait(false) - && args.Context.GetRequestMessage()?.GetRequestType() is HttpRequestType.RegularApi; + options.Retry.ShouldHandle = async args => + { + var defaultShouldHandleRetry = await defaultShouldHandleRetryPredicate(args).ConfigureAwait(false); + return defaultShouldHandleRetry && args.Context.GetRequestMessage()?.GetRequestType() is HttpRequestType.RegularApi; + }; options.Retry.ShouldRetryAfterHeader = true; options.Retry.Delay = TimeSpan.FromSeconds(1.75); From 6571ec10b3e5b7dda9b66fba51b79add431cc3cb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Dec 2025 10:07:30 +0000 Subject: [PATCH 392/791] Update telemetry error mapping --- .../Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index cea1c0dc..5ebae8ab 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using System.Security.Cryptography; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk; @@ -13,7 +14,9 @@ internal static class TelemetryErrorResolver { NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => DownloadError.IntegrityError, CryptographicException => DownloadError.DecryptionError, - HttpRequestException { HttpRequestError: HttpRequestError.ConnectionError } => DownloadError.NetworkError, + HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => DownloadError.NetworkError, + HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => DownloadError.ServerError, + ProtonApiException { TransportCode: (int)HttpStatusCode.RequestTimeout } => DownloadError.ServerError, ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => DownloadError.RateLimited, ProtonApiException { TransportCode: >= 400 and < 500 } => DownloadError.HttpClientSideError, ProtonApiException { TransportCode: >= 500 and < 600 } => DownloadError.ServerError, @@ -26,7 +29,9 @@ internal static class TelemetryErrorResolver return exception switch { NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => UploadError.IntegrityError, - HttpRequestException { HttpRequestError: HttpRequestError.ConnectionError } => UploadError.NetworkError, + HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => UploadError.NetworkError, + HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => UploadError.ServerError, + ProtonApiException { TransportCode: (int)HttpStatusCode.RequestTimeout } => UploadError.ServerError, ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => UploadError.RateLimited, ProtonApiException { TransportCode: >= 400 and < 500 } => UploadError.HttpClientSideError, ProtonApiException { TransportCode: >= 500 and < 600 } => UploadError.ServerError, From f5f037acfea6ad73ff3f408934d72b76c953d98f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Dec 2025 09:56:55 +0100 Subject: [PATCH 393/791] Allow download with signature issues --- js/sdk/src/errors.ts | 4 +-- js/sdk/src/interface/download.ts | 16 +++++++++++ js/sdk/src/internal/download/controller.ts | 9 ++++++ js/sdk/src/internal/download/cryptoService.ts | 16 +++++++++-- .../internal/download/fileDownloader.test.ts | 28 +++++++++++-------- .../src/internal/download/fileDownloader.ts | 14 ++++++---- js/sdk/src/internal/download/interface.ts | 15 ++++++++++ 7 files changed, 81 insertions(+), 21 deletions(-) diff --git a/js/sdk/src/errors.ts b/js/sdk/src/errors.ts index 529effd3..1fea891d 100644 --- a/js/sdk/src/errors.ts +++ b/js/sdk/src/errors.ts @@ -170,8 +170,8 @@ export class IntegrityError extends ProtonDriveError { public readonly debug?: object; - constructor(message: string, debug?: object) { - super(message); + constructor(message: string, debug?: object, options?: ErrorOptions) { + super(message, options); this.debug = debug; } } diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts index a49166ff..4df72edd 100644 --- a/js/sdk/src/interface/download.ts +++ b/js/sdk/src/interface/download.ts @@ -60,7 +60,23 @@ export interface FileDownloader { export interface DownloadController { pause(): void; resume(): void; + + /** + * Wait for the download to complete. + * + * Throws if the download fails. In some cases, the download can complete + * anyway, such as when the signature verification fails at the end of the + * download. See `isDownloadCompleteWithSignatureIssues` for more details. + */ completion(): Promise; + + /** + * The download can throw at completion() call, but the download is still + * completed. The client is responsible for showing warning to the user + * and asking for confirmation to save the file anyway or abort and clean + * up the file. + */ + isDownloadCompleteWithSignatureIssues(): boolean; } export interface SeekableReadableStream extends ReadableStream { diff --git a/js/sdk/src/internal/download/controller.ts b/js/sdk/src/internal/download/controller.ts index 0e374b04..1a9f65f8 100644 --- a/js/sdk/src/internal/download/controller.ts +++ b/js/sdk/src/internal/download/controller.ts @@ -4,6 +4,7 @@ import { waitForCondition } from '../wait'; export class DownloadController { private paused = false; public promise?: Promise; + private _isDownloadCompleteWithSignatureIssues = false; constructor(private signal?: AbortSignal) { this.signal = signal; @@ -31,4 +32,12 @@ export class DownloadController { async completion(): Promise { await this.promise; } + + isDownloadCompleteWithSignatureIssues(): boolean { + return this._isDownloadCompleteWithSignatureIssues; + } + + setIsDownloadCompleteWithSignatureIssues(value: boolean): void { + this._isDownloadCompleteWithSignatureIssues = value; + } } diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index fa7f5403..709c12f1 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -12,7 +12,7 @@ import { ProtonDriveAccount, Revision } from '../../interface'; import { DecryptionError, IntegrityError } from '../../errors'; import { getErrorMessage } from '../errors'; import { mergeUint8Arrays } from '../utils'; -import { RevisionKeys } from './interface'; +import { RevisionKeys, SignatureVerificationError } from './interface'; export class DownloadCryptoService { constructor( @@ -90,13 +90,23 @@ export class DownloadCryptoService { allBlockHashes: Uint8Array[], armoredManifestSignature?: string, ): Promise { - const verificationKeys = await this.getRevisionVerificationKeys(revision, nodeKey); const hash = mergeUint8Arrays(allBlockHashes); if (!armoredManifestSignature) { throw new IntegrityError(c('Error').t`Missing integrity signature`); } + let verificationKeys; + try { + verificationKeys = await this.getRevisionVerificationKeys(revision, nodeKey); + } catch (error: unknown) { + throw new SignatureVerificationError( + c('Error').t`Failed to get verification keys`, + { revisionUid: revision.uid, contentAuthor: revision.contentAuthor }, + { cause: error }, + ); + } + const { verified, verificationErrors } = await this.driveCrypto.verifyManifest( hash, armoredManifestSignature, @@ -104,7 +114,7 @@ export class DownloadCryptoService { ); if (verified !== VERIFICATION_STATUS.SIGNED_AND_VALID) { - throw new IntegrityError(c('Error').t`Data integrity check failed`, { + throw new SignatureVerificationError(c('Error').t`Data integrity check failed`, { verificationErrors, }); } diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index 2ae98009..a62f39b8 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -4,6 +4,8 @@ import { FileDownloader } from './fileDownloader'; import { DownloadTelemetry } from './telemetry'; import { DownloadAPIService } from './apiService'; import { DownloadCryptoService } from './cryptoService'; +import { SignatureVerificationError } from './interface'; +import { IntegrityError } from '../..'; function mockBlockDownload(_: string, token: string, onProgress: (downloadedBytes: number) => void) { const index = parseInt(token.slice(5, 6)); @@ -94,8 +96,6 @@ describe('FileDownloader', () => { expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); expect(cryptoService.verifyManifest).toHaveBeenCalledTimes(1); - expect(writer.close).toHaveBeenCalledTimes(1); - expect(writer.abort).not.toHaveBeenCalled(); expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); expect(telemetry.downloadFinished).toHaveBeenCalledWith('revisionUid', fileProgress); expect(telemetry.downloadFailed).not.toHaveBeenCalled(); @@ -108,8 +108,6 @@ describe('FileDownloader', () => { await expect(controller.completion()).rejects.toThrow(error); expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); - expect(writer.close).not.toHaveBeenCalled(); - expect(writer.abort).toHaveBeenCalledTimes(1); expect(telemetry.downloadFinished).not.toHaveBeenCalled(); expect(telemetry.downloadFailed).toHaveBeenCalledTimes(1); expect(telemetry.downloadFailed).toHaveBeenCalledWith( @@ -119,6 +117,8 @@ describe('FileDownloader', () => { revision.claimedSize, ); expect(onFinish).toHaveBeenCalledTimes(1); + + return controller; }; const verifyOnProgress = async (downloadedBytes: number[]) => { @@ -137,8 +137,6 @@ describe('FileDownloader', () => { // @ts-expect-error Mocking WritableStreamDefaultWriter writer = { write: jest.fn(), - close: jest.fn(), - abort: jest.fn(), }; // @ts-expect-error Mocking WritableStream stream = { @@ -338,12 +336,22 @@ describe('FileDownloader', () => { await verifyOnProgress([1, 2, 3]); }); - it('should handle failure when verifying manifest', async () => { + it('should handle failure when verifying manifest with non-recoverable integrity error', async () => { + cryptoService.verifyManifest = jest.fn().mockImplementation(async function () { + throw new IntegrityError('Failed to verify manifest'); + }); + + const controller = await verifyFailure('Failed to verify manifest', 6); // All blocks of length 1, 2, 3. + expect(controller.isDownloadCompleteWithSignatureIssues()).toBe(false); + }); + + it('should handle failure when verifying manifest with recoverable signature verification error', async () => { cryptoService.verifyManifest = jest.fn().mockImplementation(async function () { - throw new Error('Failed to verify manifest'); + throw new SignatureVerificationError('Failed to verify manifest'); }); - await verifyFailure('Failed to verify manifest', 6); // All blocks of length 1, 2, 3. + const controller = await verifyFailure('Failed to verify manifest', 6); // All blocks of length 1, 2, 3. + expect(controller.isDownloadCompleteWithSignatureIssues()).toBe(true); }); }); @@ -389,8 +397,6 @@ describe('FileDownloader', () => { expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); expect(cryptoService.verifyBlockIntegrity).not.toHaveBeenCalled(); expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); - expect(writer.close).toHaveBeenCalledTimes(1); - expect(writer.abort).not.toHaveBeenCalled(); expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); expect(telemetry.downloadFinished).toHaveBeenCalledWith('revisionUid', 6); // 3 blocks of length 1, 2, 3. expect(telemetry.downloadFailed).not.toHaveBeenCalled(); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 1cdb0532..c4321354 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey, base64StringToUint8Array } from '../../crypto'; -import { AbortError } from '../../errors'; +import { AbortError, IntegrityError } from '../../errors'; import { Logger } from '../../interface'; import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; @@ -10,7 +10,7 @@ import { DownloadAPIService } from './apiService'; import { getBlockIndex } from './blockIndex'; import { DownloadController } from './controller'; import { DownloadCryptoService } from './cryptoService'; -import { BlockMetadata, RevisionKeys } from './interface'; +import { BlockMetadata, RevisionKeys, SignatureVerificationError } from './interface'; import { BufferedSeekableStream } from './seekableStream'; import { DownloadTelemetry } from './telemetry'; @@ -233,13 +233,17 @@ export class FileDownloader { ); } - await writer.close(); void this.telemetry.downloadFinished(this.revision.uid, fileProgress); this.logger.info(`Download succeeded`); } catch (error: unknown) { - this.logger.error(`Download failed`, error); + if (error instanceof SignatureVerificationError) { + this.logger.warn(`Download finished with signature verification issues`); + this.controller.setIsDownloadCompleteWithSignatureIssues(true); + error = new IntegrityError(error.message, error.debug, { cause: error }); + } else { + this.logger.error(`Download failed`, error); + } void this.telemetry.downloadFailed(this.revision.uid, error, fileProgress, this.getClaimedSizeInBytes()); - await writer.abort?.(); throw error; } finally { this.logger.debug(`Download cleanup`); diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index 8641e4ce..61470885 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,4 +1,5 @@ import { PrivateKey, PublicKey, SessionKey } from '../../crypto'; +import { IntegrityError } from '../../errors'; import { NodeType, Result, MissingNode, MetricVolumeType } from '../../interface'; import { DecryptedNode, DecryptedRevision } from '../nodes'; @@ -35,3 +36,17 @@ export interface NodesServiceNode { export interface RevisionsService { getRevision(nodeRevisionUid: string): Promise; } + +/** + * Error thrown when the manifest signature verification fails. + * This is a special case that is reported as download complete with signature + * issues. The client must then ask the user to agree to save the file anyway + * or abort and clean up the file. + * + * This error is not exposed to the client. It is only used internally to track + * the signature verification issues. For the client it must be reported as + * the IntegrityError. + */ +export class SignatureVerificationError extends IntegrityError { + name = 'SignatureVerificationError'; +} From 6303deec2fe300a11bf43ac8a03a71fda2726d09 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Dec 2025 11:25:02 +0100 Subject: [PATCH 394/791] js/v0.9.0 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index d04da184..dff45ec8 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.8.0", + "version": "0.9.0", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index ad759fe2..cdd43e4a 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.8.0", + "version": "0.9.0", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From adeb6653fc4df7963ba3699b0af30498747dad74 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 10:17:05 +0100 Subject: [PATCH 395/791] Add photos thumbnail downloader --- .../{PhotoDownloader.cs => PhotosDownloader.cs} | 8 ++++---- .../Proton.Photos.Sdk/ProtonPhotosClient.cs | 12 ++++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) rename cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/{PhotoDownloader.cs => PhotosDownloader.cs} (92%) diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs similarity index 92% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDownloader.cs rename to cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs index 6a79527b..0a7cf7d2 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDownloader.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs @@ -5,7 +5,7 @@ namespace Proton.Photos.Sdk.Nodes; -public sealed partial class PhotoDownloader : IFileDownloader +public sealed partial class PhotosDownloader : IFileDownloader { private readonly ProtonPhotosClient _client; private readonly NodeUid _photoUid; @@ -13,7 +13,7 @@ public sealed partial class PhotoDownloader : IFileDownloader private volatile int _remainingNumberOfBlocksToList; - private PhotoDownloader(ProtonPhotosClient client, NodeUid photoUid, ILogger logger) + private PhotosDownloader(ProtonPhotosClient client, NodeUid photoUid, ILogger logger) { _client = client; _photoUid = photoUid; @@ -40,14 +40,14 @@ public void Dispose() ReleaseRemainingBlockListing(); } - internal static async ValueTask CreateAsync(ProtonPhotosClient client, NodeUid photoUid, CancellationToken cancellationToken) + internal static async ValueTask CreateAsync(ProtonPhotosClient client, NodeUid photoUid, CancellationToken cancellationToken) { var logger = client.DriveClient.Telemetry.GetLogger("Photo downloader"); LogEnteringBlockListingSemaphore(logger, photoUid, 1); await client.DriveClient.BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); LogEnteredBlockListingSemaphore(logger, photoUid, 1); - return new PhotoDownloader(client, photoUid, logger); + return new PhotosDownloader(client, photoUid, logger); } [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter block listing semaphore for photo {PhotoUid} with {Increment}")] diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs index 12b1bfd5..9f09235d 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs @@ -69,9 +69,17 @@ public IAsyncEnumerable EnumeratePhotosTimelineAsync(NodeUid return PhotosNodeOperations.EnumeratePhotosAsync(this, uid, cancellationToken); } - public async ValueTask GetPhotoDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) + public async ValueTask GetPhotosDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) { - return await PhotoDownloader.CreateAsync(this, photoUid, cancellationToken).ConfigureAwait(false); + return await PhotosDownloader.CreateAsync(this, photoUid, cancellationToken).ConfigureAwait(false); + } + + public IAsyncEnumerable EnumeratePhotosThumbnailsAsync( + IEnumerable photoUids, + ThumbnailType thumbnailType = ThumbnailType.Thumbnail, + CancellationToken cancellationToken = default) + { + return FileOperations.EnumerateThumbnailsAsync(DriveClient, photoUids, thumbnailType, cancellationToken); } public void Dispose() From 22e8c8b12f2ee3c6f38168ebbd7b115203849d5c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Dec 2025 10:51:05 +0100 Subject: [PATCH 396/791] Cancel CancellationTokenSource when coroutine scope is cancelled executing blocking function --- .../kotlin/me/proton/drive/sdk/DriveClient.kt | 6 ++--- .../proton/drive/sdk/extension/HttpStream.kt | 18 --------------- .../internal/CancellationCoroutineScope.kt | 23 +++++++++++++++++++ .../proton/drive/sdk/internal/HttpStream.kt | 18 --------------- 4 files changed, 26 insertions(+), 39 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CancellationCoroutineScope.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt index 3a4a4472..5f36af97 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt @@ -1,9 +1,9 @@ package me.proton.drive.sdk -import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniDriveClient +import me.proton.drive.sdk.internal.cancellationCoroutineScope import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetThumbnailsRequest import java.io.OutputStream @@ -17,7 +17,7 @@ class DriveClient internal constructor( suspend fun getAvailableName( parentFolderUid: String, name: String, - ): String = cancellationTokenSource().let { source -> + ): String = cancellationCoroutineScope { source -> bridge.getAvailableName( driveClientGetAvailableNameRequest { this.parentFolderUid = parentFolderUid @@ -32,7 +32,7 @@ class DriveClient internal constructor( fileUids: List, type: ThumbnailType, block: (String) -> OutputStream, - ): Unit = cancellationTokenSource().let { source -> + ): Unit = cancellationCoroutineScope { source -> bridge.getThumbnails( driveClientGetThumbnailsRequest { this.fileUids += fileUids diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt index 38ae59aa..518eadb6 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import kotlinx.coroutines.runBlocking diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CancellationCoroutineScope.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CancellationCoroutineScope.kt new file mode 100644 index 00000000..178da434 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CancellationCoroutineScope.kt @@ -0,0 +1,23 @@ +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import me.proton.drive.sdk.CancellationTokenSource +import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource + +suspend fun cancellationCoroutineScope( + block: suspend (CancellationTokenSource) -> T, +): T = coroutineScope { + val source = cancellationTokenSource() + try { + block(source) + } finally { + if (!isActive) { + withContext(NonCancellable) { + source.cancel() + } + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt index 3c1effe3..b29bce8e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2025 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.internal import kotlinx.coroutines.CoroutineScope From 2b0555bf41f2780f291271bbb0c1fe84ca307b79 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 18 Dec 2025 17:02:22 +0000 Subject: [PATCH 397/791] Fix download failures due to missing keys for manifest check --- .../me/proton/drive/sdk/CorePublicAddressResolver.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt index 12a0d56c..858fdd1a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt @@ -9,12 +9,15 @@ class CorePublicAddressResolver( private val publicAddressRepository: PublicAddressRepository, ) : PublicAddressResolver { - override suspend fun getAddressPublicKeys(emailAddress: String): List = - publicAddressRepository.getPublicAddressInfo( + override suspend fun getAddressPublicKeys(emailAddress: String): List { + val publicAddressInfo = publicAddressRepository.getPublicAddressInfo( sessionUserId = userId, email = emailAddress - ).address.keys.publicKeyRing().keys.map { publicKey -> + ) + val publicAddressKeys = publicAddressInfo.address.keys + publicAddressInfo.unverified?.keys.orEmpty() + return publicAddressKeys.publicKeyRing().keys.map { publicKey -> publicKey.key.toByteArray() } + } } From b15e0bf97a520e931351ef4e9f71f940f5cb8701 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 18 Dec 2025 16:45:21 +0100 Subject: [PATCH 398/791] Catch TypeError when calling releaseLock --- .../internal/download/seekableStream.test.ts | 35 +++++++++++++++++++ .../src/internal/download/seekableStream.ts | 21 ++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/download/seekableStream.test.ts b/js/sdk/src/internal/download/seekableStream.test.ts index e786258e..672793af 100644 --- a/js/sdk/src/internal/download/seekableStream.test.ts +++ b/js/sdk/src/internal/download/seekableStream.test.ts @@ -184,4 +184,39 @@ describe('BufferedSeekableStream', () => { const result4 = await stream.read(2); expect(result4).toEqual({ value: new Uint8Array([10]), done: true }); }); + + it('should catch and ignore TypeError from releaseLock during seek', async () => { + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: jest.fn(), + }); + + await stream.read(2); + + const reader = (stream as any).reader; + const originalReleaseLock = reader.releaseLock.bind(reader); + jest.spyOn(reader, 'releaseLock').mockImplementation(() => { + originalReleaseLock(); + throw new TypeError('Reader has pending read requests'); + }); + + await expect(stream.seek(0)).resolves.not.toThrow(); + }); + + it('should re-throw non-TypeError errors from releaseLock during seek', async () => { + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: jest.fn(), + }); + + await stream.read(2); + + const reader = (stream as any).reader; + const customError = new Error('Custom error'); + jest.spyOn(reader, 'releaseLock').mockImplementation(() => { + throw customError; + }); + + await expect(stream.seek(0)).rejects.toThrow(customError); + }); }); diff --git a/js/sdk/src/internal/download/seekableStream.ts b/js/sdk/src/internal/download/seekableStream.ts index 27a523d1..8bd5d684 100644 --- a/js/sdk/src/internal/download/seekableStream.ts +++ b/js/sdk/src/internal/download/seekableStream.ts @@ -173,7 +173,26 @@ export class BufferedSeekableStream extends SeekableReadableStream { await super.seek(position); if (this.reader) { - this.reader.releaseLock(); + try { + this.reader.releaseLock(); + } catch (error) { + // Streams API spec-compliant behavior: releaseLock() only throws TypeError when + // there are pending read requests. This can occur due to timing differences between + // when read() promises resolve on the client side vs when the browser's internal + // stream mechanism fully completes. + // + // This manifests more frequently in Firefox than Chrome due to implementation + // timing differences, but both are following the spec correctly. + // + // References: + // - https://github.com/whatwg/streams/issues/1000 + // - https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/releaseLock + // + // Safe to ignore since we're acquiring a new reader immediately after. + if (!(error instanceof TypeError)) { + throw error; + } + } } this.reader = super.getReader(); this.streamClosed = false; From 6ab330d49108e1826ee8721aab1272a7f1881b37 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 06:52:32 +0000 Subject: [PATCH 399/791] Add cancellation message when CS cancels a job --- kt/sdk/src/main/jni/job.c | 57 +++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/kt/sdk/src/main/jni/job.c b/kt/sdk/src/main/jni/job.c index aa38b328..09436396 100644 --- a/kt/sdk/src/main/jni/job.c +++ b/kt/sdk/src/main/jni/job.c @@ -17,19 +17,48 @@ void onCancel( "Object was recycled for: %s %ld", "cancel", (long) bindings_operation_handle ); return; - } else { - jclass jobClass = (*env)->GetObjectClass(env, obj); - jmethodID mid = (*env)->GetMethodID(env, jobClass, "cancel", "()V"); - if (mid == 0) { - __android_log_print( - ANDROID_LOG_FATAL, - "drive.sdk.internal", - "Cannot found method: %s", "cancel" - ); - return; - } - (*env)->CallVoidMethod(env, obj, mid); } + + /* --- Build CancellationException(String) --- */ + + jclass ceClass = (*env)->FindClass(env, "java/util/concurrent/CancellationException"); + if (ceClass == NULL) { + return; // exception pending + } + + jmethodID ceCtor = (*env)->GetMethodID(env, ceClass, "", "(Ljava/lang/String;)V"); + if (ceCtor == NULL) { + return; + } + + jstring message = (*env)->NewStringUTF(env, "Operation cancelled by sdk"); + jobject cancellationException = (*env)->NewObject(env, ceClass, ceCtor, message); + + /* --- Call cancel(CancellationException) --- */ + + jclass jobClass = (*env)->GetObjectClass(env, obj); + + char *signature = "(Ljava/util/concurrent/CancellationException;)V"; + jmethodID mid = (*env)->GetMethodID(env, jobClass, "cancel", signature); + + if (mid == 0) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Cannot find method: cancel(CancellationException)" + ); + return; + } + + (*env)->CallVoidMethod(env, obj, mid, cancellationException); + + /* --- Cleanup local references --- */ + + (*env)->DeleteLocalRef(env, message); + (*env)->DeleteLocalRef(env, cancellationException); + (*env)->DeleteLocalRef(env, ceClass); + (*env)->DeleteLocalRef(env, jobClass); + (*env)->DeleteLocalRef(env, obj); } jlong Java_me_proton_drive_sdk_internal_JniJob_getCancelPointer( @@ -45,5 +74,5 @@ jlong Java_me_proton_drive_sdk_internal_JniJob_createWeakRef( jobject obj ) { jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); - return (jlong)(intptr_t) weakRef; -} \ No newline at end of file + return (jlong) (intptr_t) weakRef; +} From 25c26925eac68a82a2d2fa5a55236e5b40f34f9b Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 09:49:50 +0100 Subject: [PATCH 400/791] Pass error when operation is paused to the client. Prevent crashes for calls after operation throws. --- .../FileOperations/DownloadOperation.swift | 54 +++++++++++++-- .../FileOperations/UploadOperation.swift | 44 ++++++++++++- .../FileOperations/UploadsManager.swift | 24 +++++-- .../Sources/Plumbing/BoxedContinuation.swift | 13 +--- .../Sources/Plumbing/FeatureFlags.swift | 3 +- .../Sources/Plumbing/InternalTypes.swift | 9 +-- .../Sources/Plumbing/SDKRequestHandler.swift | 2 +- .../Sources/Plumbing/SDKResponseHandler.swift | 6 +- .../ProtonDriveClient/AccountClient.swift | 5 +- .../HttpClientRequestProcessor.swift | 20 ++++-- .../ProtonDriveClient/ProtonDriveClient.swift | 66 ++++++------------- .../Sources/TelemetryAndLogging/Logger.swift | 3 +- .../TelemetryAndLogging/Telemetry.swift | 3 +- 13 files changed, 157 insertions(+), 95 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift index 42072f30..26da0320 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift @@ -33,7 +33,43 @@ public final class DownloadOperation: Sendable { self.onOperationDispose = onOperationDispose } - /// Wait for download completion + // Wait for download completion and uses operational resilience to retry if needed + public func awaitDownloadWithResilience( + operationalResilience: OperationalResilience, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws { + try await awaitDownloadWithResilience( + retryCounter: 0, operationalResilience: operationalResilience, onPauseErrorReceived: onRetriableErrorReceived + ) + } + + private func awaitDownloadWithResilience( + retryCounter: UInt, + operationalResilience: OperationalResilience, + onPauseErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws { + let result = await awaitDownloadCompletion() + switch result { + case .succeeded: + return + + case .failed(let error): + throw error + + case .pausedOnError(let error): + onPauseErrorReceived(error) + return try await operationalResilience.performRetry(retryCounter, error) { + try await resume() + return try await awaitDownloadWithResilience( + retryCounter: $0, + operationalResilience: operationalResilience, + onPauseErrorReceived: onPauseErrorReceived + ) + } + } + } + + /// Wait for download completion, no retries public func awaitDownloadCompletion() async -> DownloadOperationResult { do { let awaitDownloadCompletionRequest = Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest.with { @@ -42,11 +78,17 @@ public final class DownloadOperation: Sendable { try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void return .succeeded - } catch { - if let isPaused = try? await isPaused(), isPaused { - // if the operation is paused, we can try recovering from the error - return .pausedOnError(error) - } else { + } catch let error { + do { + let isPaused = try await isPaused() + if isPaused { + // if the operation is paused, we can try recovering from the error + return .pausedOnError(error) + } else { + return .failed(error) + } + } catch let isPausedError { + logger?.info("Checking isPaused status failed with: \(isPausedError.localizedDescription)", category: "DownloadOperation") return .failed(error) } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift index 59779cd5..c57b58e8 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift @@ -32,6 +32,37 @@ public final class UploadOperation: Sendable { self.onOperationDispose = onOperationDispose } + public func awaitUploadWithResilience( + operationalResilience: OperationalResilience, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> UploadedFileIdentifiers { + try await awaitUploadWithResilience( + retryCounter: 0, operationalResilience: operationalResilience, onPauseErrorReceived: onRetriableErrorReceived + ) + } + + private func awaitUploadWithResilience( + retryCounter: UInt, operationalResilience: OperationalResilience, onPauseErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> UploadedFileIdentifiers { + let result = await awaitUploadCompletion() + switch result { + case .succeeded(let uploadResult): + return uploadResult + + case .failed(let error): + throw error + + case .pausedOnError(let error): + onPauseErrorReceived(error) + return try await operationalResilience.performRetry(retryCounter, error) { + try await resume() + return try await awaitUploadWithResilience( + retryCounter: $0, operationalResilience: operationalResilience, onPauseErrorReceived: onPauseErrorReceived + ) + } + } + } + /// Wait for upload completion public func awaitUploadCompletion() async -> UploadOperationResult { let awaitUploadCompletionRequest = Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest.with { @@ -45,9 +76,16 @@ public final class UploadOperation: Sendable { } return .succeeded(result) } catch { - if let isPaused = try? await isPaused(), isPaused { - return .pausedOnError(error) - } else { + do { + let isPaused = try await isPaused() + if isPaused { + // if the operation is paused, we can try recovering from the error + return .pausedOnError(error) + } else { + return .failed(error) + } + } catch let isPausedError { + logger?.info("Checking isPaused status failed with: \(isPausedError.localizedDescription)", category: "DownloadOperation") return .failed(error) } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift index 8632f00f..c07d4bf0 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift @@ -169,19 +169,28 @@ extension UploadsManager { cancellationHandle: ObjectHandle, thumbnails: [ThumbnailData] ) async throws -> UploadOperation { + let thumbnails = thumbnails.map { + let count = $0.data.count + let buffer = UnsafeMutablePointer.allocate(capacity: count) + $0.data.copyBytes(to: buffer, count: count) + return ($0.type, ObjectHandle(bitPattern: buffer), count) + } + let deallocateBuffers: @Sendable () -> Void = { + thumbnails.forEach { _, handle, count in + let pointer = UnsafeMutableRawPointer(bitPattern: handle) + UnsafeMutableRawBufferPointer(start: pointer, count: count).deallocate() + } + } let uploaderRequest = Proton_Drive_Sdk_UploadFromFileRequest.with { $0.uploaderHandle = Int64(fileUploaderHandle) $0.filePath = fileURL.path(percentEncoded: false) $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) $0.cancellationTokenSourceHandle = Int64(cancellationHandle) - $0.thumbnails = thumbnails.map { thumbnail in + $0.thumbnails = thumbnails.map { type, handle, count in Proton_Drive_Sdk_Thumbnail.with { - $0.type = thumbnail.type == .thumbnail ? .thumbnail : .preview - $0.dataLength = Int64(thumbnail.data.count) - let dataHandle = thumbnail.data.withUnsafeBytes { (u8Ptr: UnsafePointer) in - return ObjectHandle(bitPattern: UnsafeRawPointer(u8Ptr)) - } - $0.dataPointer = Int64(dataHandle) + $0.type = type == .thumbnail ? .thumbnail : .preview + $0.dataPointer = Int64(handle) + $0.dataLength = Int64(count) } } } @@ -205,6 +214,7 @@ extension UploadsManager { }, onOperationDispose: { [weak self] in guard let self else { return } + deallocateBuffers() await self.freeCancellationTokenSourceIfNeeded(cancellationToken: cancellationToken) } ) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift index faaa5e86..64c4e24d 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift @@ -4,8 +4,6 @@ protocol Resumable: AnyObject { func resume(returning value: sending ReturnType) func resume(throwing error: Error) - - var context: Any { get } } extension Resumable where ReturnType == Void { @@ -20,19 +18,10 @@ final class BoxedCompletionBlock: Resumable { private var completionBlock: CompletionBlock? let state: StateType - let context: Any - init(_ completionBlock: CompletionBlock?, state: StateType, context: Any) { + init(_ completionBlock: CompletionBlock?, state: StateType) { self.completionBlock = completionBlock self.state = state - self.context = context - } - - init(_ completionBlock: CompletionBlock?, weakState state: WeakStateType, context: Any) - where StateType == WeakReference { - self.completionBlock = completionBlock - self.state = WeakReference(value: state) - self.context = context } func resume(returning value: ResultType) { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift index 2cb8828e..6f016011 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift @@ -14,7 +14,8 @@ let cCompatibleFeatureFlagProviderCallback: CCallbackWithIntReturn = { statePoin let weakDriveClient = stateTypedPointer.takeUnretainedValue().state guard let driveClient = weakDriveClient.value else { - stateTypedPointer.release() + // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation +// stateTypedPointer.release() return 0 } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index 51387ed0..6f5e19a3 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -19,11 +19,6 @@ extension ObjectHandle { } } -func address(of object: T) -> ObjectHandle { - let rawPointer = Unmanaged.passUnretained(object).toOpaque() - return ObjectHandle(bitPattern: rawPointer) -} - /// C-compatible callback used by SDK to pass data to the app and back /// `statePointer` is pointer to the state we create on the app side and pass to the SDK in the request that is causing the callback to be called. SDK does not interact with the state at all, it just passes it back to the app. It's app's responsibility to maintain the lifecycle of the state (deallocate when appropriate). It's always passed, in every callback variant. /// `byteArray` is a pointer and the count struct describing the memory allocated by the SDK, and passed to the callback to enable it to perform its operation. It is either the protobuf message created by the SDK that contains all the necessary information, or it's a memory buffer from which/into which the callback is supposed to read/write. The app does not maintain the lifecycle of the byteArray, it's SDK's responsibility. It's passed on the callback variants that require it for their work. @@ -114,5 +109,7 @@ extension Proton_Sdk_ProtonClientTlsPolicy { final class WeakReference where T: AnyObject { private(set) weak var value: T? - init(value: T) { self.value = value } + init(value: T) { + self.value = value + } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 940c2d21..dd960b40 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -91,7 +91,7 @@ enum SDKRequestHandler { logger?.trace("Sending (\(isDriveRequest ? "Drive" : "non-Drive")) SDK request ", category: "SDKRequestHandler") // Switch to InteropTypes.BoxedStateType once we use it for all requests - let boxedState = BoxedCompletionBlock(completionBlock, state: state, context: envelopedRequestData) + let boxedState = BoxedCompletionBlock(completionBlock, state: state) let pointer = Unmanaged.passRetained(boxedState) if includesLongLivedCallback { // We double-retain to keep the box alive after the method finishes. diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift index 5a785036..f97dcc23 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift @@ -21,8 +21,10 @@ enum SDKResponseHandler { /// A helper method to send an interop error from Swift bindings by providing just the message. /// The examples of interop errors are: unable to serialize/deserialize protobuf, unable to use a provide pointer etc. - static func sendInteropErrorToSDK(message: String, callbackPointer: Int) { - assertionFailure(message) + static func sendInteropErrorToSDK(message: String, callbackPointer: Int, assert: Bool = true) { + if assert { + assertionFailure(message) + } let sdkError = Proton_Sdk_Error.with { $0.type = "Swift bindings" $0.domain = Proton_Sdk_ErrorDomain.businessLogic diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift index 60a0ead2..dc237b25 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift @@ -20,7 +20,10 @@ let cCompatibleAccountClientRequest: CCallbackWithCallbackPointer = { statePoint let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state let driveClient = ProtonDriveClient.unbox( - callbackPointer: callbackPointer, releaseBox: { stateTypedPointer.release() }, weakDriveClient: weakDriveClient + callbackPointer: callbackPointer, releaseBox: { + // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation +// stateTypedPointer.release() + }, weakDriveClient: weakDriveClient ) guard let driveClient else { return } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift index 93c65614..68bf3418 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -5,18 +5,22 @@ enum HttpClientRequestProcessor { guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { SDKResponseHandler.sendInteropErrorToSDK(message: "cCompatibleHttpRequest.statePointer was nil", callbackPointer: callbackPointer) - return 0 + return -1 } let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state - let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { stateTypedPointer.release() }, weakDriveClient: weakDriveClient) - guard let driveClient else { return 0 } - + let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { + // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation +// stateTypedPointer.release() + }, weakDriveClient: weakDriveClient) + guard let driveClient else { return -1 } + + let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) + // Create a boxed task with the HTTP work - let taskBox = BoxedCancellableTask { [driveClient] in + let taskBox = BoxedCancellableTask { do { - let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) try await HttpClientRequestProcessor.perform( client: driveClient, httpRequestData: httpRequestData, @@ -40,6 +44,8 @@ enum HttpClientRequestProcessor { } static let cCompatibleHttpCancellationAction: CCallbackWithoutByteArray = { statePointer in + // if statePointer is -1, it means we've early returned from cCompatibleHttpRequest + guard statePointer != -1 else { return } // Convert the address back to the task box guard let pointer = UnsafeRawPointer(bitPattern: statePointer) else { let message = "cCompatibleHttpCancellationAction.statePointer is nil" @@ -88,7 +94,7 @@ enum HttpClientRequestProcessor { fatalError("Unknown HttpRequestType: \(int)") } } - + /// the API calls are performed in a non-streaming way. both request body and response data are buffered in memory fileprivate static func callDriveApi( driveRelativePath: String, diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index d37841fe..8a4ac6d1 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -104,7 +104,8 @@ public actor ProtonDriveClient: Sendable { revisionUid: SDKRevisionUid, destinationUrl: URL, cancellationToken: UUID, - progressCallback: @escaping ProgressCallback + progressCallback: @escaping ProgressCallback, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void ) async throws { let operation = try await downloadFileOperation( revisionUid: revisionUid, @@ -112,26 +113,10 @@ public actor ProtonDriveClient: Sendable { cancellationToken: cancellationToken, progressCallback: progressCallback ) - return try await awaitDownloadCompletion(operation: operation, retryCounter: 0) - } - - private func awaitDownloadCompletion( - operation: DownloadOperation, retryCounter: UInt - ) async throws { - let result = await operation.awaitDownloadCompletion() - switch result { - case .succeeded: - return - - case .failed(let error): - throw error - - case .pausedOnError(let error): - return try await configuration.downloadOperationalResilience.performRetry(retryCounter, error) { - try await operation.resume() - return try await awaitDownloadCompletion(operation: operation, retryCounter: $0) - } - } + return try await operation.awaitDownloadWithResilience( + operationalResilience: configuration.downloadOperationalResilience, + onRetriableErrorReceived: onRetriableErrorReceived + ) } public func downloadFileOperation( @@ -163,7 +148,8 @@ public actor ProtonDriveClient: Sendable { thumbnails: [ThumbnailData], overrideExistingDraft: Bool, cancellationToken: UUID, - progressCallback: @escaping ProgressCallback + progressCallback: @escaping ProgressCallback, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void ) async throws -> UploadedFileIdentifiers { let operation = try await uploadFileOperation( parentFolderUid: parentFolderUid, @@ -178,28 +164,11 @@ public actor ProtonDriveClient: Sendable { progressCallback: progressCallback ) - return try await awaitUploadCompletion(operation: operation, retryCounter: 0) - } - - private func awaitUploadCompletion( - operation: UploadOperation, retryCounter: UInt - ) async throws -> UploadedFileIdentifiers { - let result = await operation.awaitUploadCompletion() - switch result { - case .succeeded(let uploadResult): - return uploadResult - - case .failed(let error): - throw error - - case .pausedOnError(let error): - return try await configuration.uploadOperationalResilience.performRetry(retryCounter, error) { - try await operation.resume() - return try await awaitUploadCompletion(operation: operation, retryCounter: $0) - } - } + return try await operation.awaitUploadWithResilience( + operationalResilience: configuration.uploadOperationalResilience, onRetriableErrorReceived: onRetriableErrorReceived + ) } - + public func uploadFileOperation( parentFolderUid: SDKNodeUid, name: String, @@ -234,7 +203,8 @@ public actor ProtonDriveClient: Sendable { modificationDate: Date, thumbnails: [ThumbnailData], cancellationToken: UUID, - progressCallback: @escaping ProgressCallback + progressCallback: @escaping ProgressCallback, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void ) async throws -> UploadedFileIdentifiers { let operation = try await uploadNewRevisionOperation( currentActiveRevisionUid: currentActiveRevisionUid, @@ -246,7 +216,9 @@ public actor ProtonDriveClient: Sendable { progressCallback: progressCallback ) - return try await awaitUploadCompletion(operation: operation, retryCounter: 0) + return try await operation.awaitUploadWithResilience( + operationalResilience: configuration.uploadOperationalResilience, onRetriableErrorReceived: onRetriableErrorReceived + ) } public func uploadNewRevisionOperation( @@ -298,8 +270,8 @@ public actor ProtonDriveClient: Sendable { static func unbox(callbackPointer: Int, releaseBox: () -> Void, weakDriveClient: WeakReference) -> ProtonDriveClient? { guard let driveClient = weakDriveClient.value else { releaseBox() - let message = "account client callback called after the proton client object was deallocated" - SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer) + let message = "callback called after the proton client object was deallocated" + SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer, assert: false) return nil } return driveClient diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift index 91d23cb3..bf0c5144 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift @@ -37,7 +37,8 @@ let cCompatibleLogCallback: CCallback = { statePointer, byteArray in let weakDriveClient = stateTypedPointer.takeUnretainedValue().state guard let driveClient = weakDriveClient.value else { - stateTypedPointer.release() + // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation +// stateTypedPointer.release() return } diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift index bf8168a6..22309e66 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift @@ -12,7 +12,8 @@ let cCompatibleTelemetryRecordMetricCallback: CCallback = { statePointer, byteAr let weakDriveClient = stateTypedPointer.takeUnretainedValue().state guard let driveClient = weakDriveClient.value else { - stateTypedPointer.release() + // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation +// stateTypedPointer.release() return } From b7f727abecb6fc1df1b125190470dce977fb75db Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 13:24:46 +0100 Subject: [PATCH 401/791] Fix download retrying on cancellation --- .../Nodes/Download/BlockDownloader.cs | 10 +++--- .../Nodes/Upload/BlockUploader.cs | 8 +++-- cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs | 32 +++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index 977dc076..06ebf59e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -4,6 +4,7 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Resilience; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Download; @@ -32,13 +33,14 @@ public async ValueTask> DownloadAsync( CancellationToken cancellationToken) { return await Policy - .Handle(ex => ex is not FileContentsDecryptionException) + // TODO: add unit tests to verify the retry conditions + .Handle(ex => !cancellationToken.IsCancellationRequested && ex is not FileContentsDecryptionException) .WaitAndRetryAsync( retryCount: 4, sleepDurationProvider: RetryPolicy.GetAttemptDelay, onRetry: (exception, _, retryNumber, _) => { - LogBlobDownloadFailure(exception, index, revisionUid, retryNumber); + LogBlobDownloadRetry(index, revisionUid, retryNumber, exception.FlattenMessage()); outputStream.Seek(0, SeekOrigin.Begin); }) .ExecuteAsync(ExecuteDownloadAsync).ConfigureAwait(false); @@ -74,6 +76,6 @@ async Task ExecuteDownloadAsync() [LoggerMessage( Level = LogLevel.Information, - Message = "Blob download failed for block #{BlockIndex} for revision \"{RevisionUid}\" (retry number: {RetryNumber})")] - private partial void LogBlobDownloadFailure(Exception exception, int blockIndex, RevisionUid revisionUid, int retryNumber); + Message = "Retrying blob download for block #{BlockIndex} for revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt failed: {ErrorMessage}")] + private partial void LogBlobDownloadRetry(int blockIndex, RevisionUid revisionUid, int retryNumber, string errorMessage); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 44a8957e..395ffd25 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -10,6 +10,7 @@ using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Resilience; +using Proton.Sdk; using Proton.Sdk.Addresses; using Proton.Sdk.Drive; @@ -193,6 +194,7 @@ private async ValueTask UploadBlobAsync( await using (nonDisposableDataPacketStream.ConfigureAwait(false)) { await Policy + // TODO: add unit tests to verify the retry conditions .Handle(ex => !cancellationToken.IsCancellationRequested && ex is not FileContentsDecryptionException) .WaitAndRetryAsync( retryCount: 1, @@ -201,7 +203,7 @@ await Policy { var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); var blockIndex = request.Blocks.Count > 0 ? request.Blocks[0].Index : 0; - LogBlobUploadFailure(exception, blockIndex, revisionUid, retryNumber); + LogBlobUploadFailure(blockIndex, revisionUid, retryNumber, exception.FlattenMessage()); }) .ExecuteAsync(ExecuteUploadAsync).ConfigureAwait(false); } @@ -230,6 +232,6 @@ await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Tok [LoggerMessage( Level = LogLevel.Information, - Message = "Blob upload failed for block #{BlockIndex} for revision \"{RevisionUid}\" (retry number: {RetryNumber})")] - private partial void LogBlobUploadFailure(Exception exception, int blockIndex, RevisionUid revisionUid, int retryNumber); + Message = "Retrying blob upload for block #{BlockIndex} for revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt failed: {ErrorMessage}")] + private partial void LogBlobUploadFailure(int blockIndex, RevisionUid revisionUid, int retryNumber, string errorMessage); } diff --git a/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs new file mode 100644 index 00000000..9f8700bf --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs @@ -0,0 +1,32 @@ +namespace Proton.Sdk; + +public static class ExceptionExtensions +{ + public static string FlattenMessage(this Exception exception) + { + var previousMessage = string.Empty; + + return string.Join( + " ---> ", + ThisAndInnerExceptions(exception) + .Select(ex => ex.Message) + .Where(m => + { + if (m == previousMessage) + { + return false; + } + + previousMessage = m; + return true; + })); + } + + private static IEnumerable ThisAndInnerExceptions(Exception? e) + { + for (; e != null; e = e.InnerException) + { + yield return e; + } + } +} From 89fc6e37a88ba2a48399377e42fb408bf760a6ff Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 13:14:12 +0000 Subject: [PATCH 402/791] Expose download integrity errors and download status --- ...edDownloadManifestVerificationException.cs | 9 ++++++ .../DataIntegrityException.cs | 9 ++++++ .../Nodes/Download/DownloadController.cs | 31 +++++++++++++++++-- .../Nodes/Download/RevisionReader.cs | 4 +-- 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/CompletedDownloadManifestVerificationException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/DataIntegrityException.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/CompletedDownloadManifestVerificationException.cs b/cs/sdk/src/Proton.Drive.Sdk/CompletedDownloadManifestVerificationException.cs new file mode 100644 index 00000000..439c48ae --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/CompletedDownloadManifestVerificationException.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk; + +internal sealed class CompletedDownloadManifestVerificationException : Exception +{ + public CompletedDownloadManifestVerificationException(string message) + : base(message) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/DataIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/DataIntegrityException.cs new file mode 100644 index 00000000..77a61285 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/DataIntegrityException.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk; + +public sealed class DataIntegrityException : ProtonDriveException +{ + public DataIntegrityException(string message) + : base(message) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index ea6c0235..322d007c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -1,11 +1,38 @@ namespace Proton.Drive.Sdk.Nodes.Download; -public sealed class DownloadController(Task downloadTask) +public sealed class DownloadController { + private readonly Task _downloadTask; + private bool _isDownloadCompleteWithVerificationIssue; + + internal DownloadController(Task downloadTask) + { + _downloadTask = downloadTask; + Completion = WrapDownloadTaskAsync(); + } + // FIXME public bool IsPaused { get; } - public Task Completion { get; } = downloadTask; + public bool GetIsDownloadCompleteWithVerificationIssue() + { + return _isDownloadCompleteWithVerificationIssue; + } + + public Task Completion { get; private set; } + + private async Task WrapDownloadTaskAsync() + { + try + { + await _downloadTask.ConfigureAwait(false); + } + catch (CompletedDownloadManifestVerificationException error) + { + _isDownloadCompleteWithVerificationIssue = true; + throw new DataIntegrityException(error.Message); + } + } public void Pause() { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 35932945..4c78efef 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -63,7 +63,7 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action { if (_revisionDto.Thumbnails is { } thumbnails) { - foreach (var sha256Digest in thumbnails.Select(x => x.HashDigest)) + foreach (var sha256Digest in thumbnails.OrderBy(t => t.Type).Select(x => x.HashDigest)) { manifestStream.Write(sha256Digest.Span); } @@ -125,7 +125,7 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt { LogFailedManifestVerification(_revisionUid, manifestVerificationStatus); - throw new ProtonDriveException("File authenticity check failed"); + throw new CompletedDownloadManifestVerificationException("File authenticity check failed"); } } } From e61e40fef0b497738c7ddc131b83ac1f6259925c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 15:37:16 +0100 Subject: [PATCH 403/791] Fix shares and share secrets not being cached --- cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index c21c72ca..b0a6d811 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -19,7 +19,7 @@ public static async ValueTask GetShareAsync( var rootFolderId = new NodeUid(response.VolumeId, response.RootLinkId); - (_, shareKey) = await ShareCrypto.DecryptShareAsync( + (share, shareKey) = await ShareCrypto.DecryptShareAsync( client, shareId, response.Key, @@ -28,7 +28,8 @@ public static async ValueTask GetShareAsync( rootFolderId, cancellationToken).ConfigureAwait(false); - share = new Share(shareId, new NodeUid(response.VolumeId, response.RootLinkId), response.AddressId); + await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetShareKeyAsync(shareId, shareKey.Value, cancellationToken).ConfigureAwait(false); } return new ShareAndKey(share, shareKey.Value); From cd1f570e61f8adf0ea34b74271d1d66ca9b48425 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 16:56:19 +0100 Subject: [PATCH 404/791] Move incomplete draft deletion to upload controller disposal --- .../InteropMessageHandler.cs | 3 + .../InteropUploadController.cs | 13 +++- .../Nodes/Download/BlockDownloader.cs | 2 +- .../Nodes/RevisionOperations.cs | 47 +----------- .../Nodes/Upload/BlockUploader.cs | 6 +- .../Nodes/Upload/FileUploader.cs | 73 ++++++++++--------- .../Nodes/Upload/IFileDraftProvider.cs | 4 +- .../Nodes/Upload/NewFileDraftProvider.cs | 13 ++-- .../Nodes/Upload/NewRevisionDraftProvider.cs | 48 +++++++++++- .../Nodes/Upload/UploadController.cs | 50 ++++++++++++- cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs | 8 +- cs/sdk/src/protos/proton.drive.sdk.proto | 8 +- .../me/proton/drive/sdk/UploadController.kt | 2 + .../drive/sdk/internal/JniUploadController.kt | 7 ++ .../FileOperations/UploadOperation.swift | 11 +++ .../Sources/Plumbing/Message+Packaging.swift | 5 ++ 16 files changed, 191 insertions(+), 109 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index fe33bf69..2c6e3454 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -61,6 +61,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.UploadControllerResume => InteropUploadController.HandleResume(request.UploadControllerResume), + Request.PayloadOneofCase.UploadControllerDispose + => await InteropUploadController.HandleDisposeAsync(request.UploadControllerDispose).ConfigureAwait(false), + Request.PayloadOneofCase.UploadControllerFree => InteropUploadController.HandleFree(request.UploadControllerFree), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs index 87d5e72f..d12c5028 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -41,11 +41,18 @@ public static IMessage HandleIsPaused(UploadControllerIsPausedRequest request) return null; } - public static IMessage? HandleFree(UploadControllerFreeRequest request) + public static async ValueTask HandleDisposeAsync(UploadControllerDisposeRequest request) { - var uploadController = Interop.FreeHandle(request.UploadControllerHandle); + var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); + + await uploadController.DisposeAsync().ConfigureAwait(false); + + return null; + } - uploadController.Dispose(); + public static IMessage? HandleFree(UploadControllerFreeRequest request) + { + Interop.FreeHandle(request.UploadControllerHandle); return null; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index 06ebf59e..62c38408 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -76,6 +76,6 @@ async Task ExecuteDownloadAsync() [LoggerMessage( Level = LogLevel.Information, - Message = "Retrying blob download for block #{BlockIndex} for revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt failed: {ErrorMessage}")] + Message = "Retrying blob download for block #{BlockIndex} of revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt error: {ErrorMessage}")] private partial void LogBlobDownloadRetry(int blockIndex, RevisionUid revisionUid, int retryNumber, string errorMessage); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index 089e1593..ba370cb9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -1,53 +1,10 @@ -using Proton.Drive.Sdk.Api.Files; -using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; -using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; -internal static partial class RevisionOperations +internal static class RevisionOperations { - public static async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> CreateDraftAsync( - ProtonDriveClient client, - NodeUid fileUid, - RevisionId lastKnownRevisionId, - CancellationToken cancellationToken) - { - var parameters = new RevisionCreationRequest - { - CurrentRevisionId = lastKnownRevisionId, - ClientId = client.Uid, - }; - - var fileSecretsResult = await FileOperations.GetSecretsAsync(client, fileUid, cancellationToken).ConfigureAwait(false); - - if (!fileSecretsResult.TryGetValueElseError(out var fileSecrets, out _)) - { - throw new InvalidOperationException($"Cannot create draft for file {fileUid} with degraded secrets"); - } - - RevisionId revisionId; - try - { - var revisionResponse = await client.Api.Files.CreateRevisionAsync(fileUid.VolumeId, fileUid.LinkId, parameters, cancellationToken) - .ConfigureAwait(false); - - revisionId = revisionResponse.Identity.RevisionId; - } - catch (ProtonApiException e) - when (e.Response is { Conflict.DraftRevisionId: { } draftRevisionId } - && (e.Response.Conflict.DraftClientUid == client.Uid)) - { - revisionId = draftRevisionId; - } - catch (ProtonApiException e) - { - throw new RevisionDraftConflictException(e); - } - - return (new RevisionUid(fileUid, revisionId), fileSecrets); - } - public static async ValueTask OpenForWritingAsync( ProtonDriveClient client, RevisionUid revisionUid, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 395ffd25..f03d84cd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -203,7 +203,7 @@ await Policy { var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); var blockIndex = request.Blocks.Count > 0 ? request.Blocks[0].Index : 0; - LogBlobUploadFailure(blockIndex, revisionUid, retryNumber, exception.FlattenMessage()); + LogBlobUploadRetry(blockIndex, revisionUid, retryNumber, exception.FlattenMessage()); }) .ExecuteAsync(ExecuteUploadAsync).ConfigureAwait(false); } @@ -232,6 +232,6 @@ await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Tok [LoggerMessage( Level = LogLevel.Information, - Message = "Retrying blob upload for block #{BlockIndex} for revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt failed: {ErrorMessage}")] - private partial void LogBlobUploadFailure(int blockIndex, RevisionUid revisionUid, int retryNumber, string errorMessage); + Message = "Retrying blob upload for block #{BlockIndex} of revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt error: {ErrorMessage}")] + private partial void LogBlobUploadRetry(int blockIndex, RevisionUid revisionUid, int retryNumber, string errorMessage); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 79d5838e..91be5a95 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -41,14 +41,17 @@ public UploadController UploadFromStream( { var taskControl = new TaskControl(cancellationToken); + var revisionUidTaskCompletionSource = new TaskCompletionSource(); + var uploadTask = UploadFromStreamAsync( contentStream, thumbnails, _additionalMetadata, progress => onProgress?.Invoke(progress, FileSize), + revisionUidTaskCompletionSource, taskControl); - return new UploadController(uploadTask, taskControl); + return new UploadController(_client.Api, _fileDraftProvider, revisionUidTaskCompletionSource.Task, uploadTask, taskControl, _logger); } public UploadController UploadFromFile( @@ -59,14 +62,17 @@ public UploadController UploadFromFile( { var taskControl = new TaskControl(cancellationToken); - var task = UploadFromFileAsync( + var revisionUidTaskCompletionSource = new TaskCompletionSource(); + + var uploadTask = UploadFromFileAsync( filePath, thumbnails, _additionalMetadata, progress => onProgress?.Invoke(progress, FileSize), + revisionUidTaskCompletionSource, taskControl); - return new UploadController(task, taskControl); + return new UploadController(_client.Api, _fileDraftProvider, revisionUidTaskCompletionSource.Task, uploadTask, taskControl, _logger); } public void Dispose() @@ -104,31 +110,40 @@ internal static async ValueTask CreateAsync( [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from revision creation semaphore")] private static partial void LogReleasedRevisionCreationSemaphore(ILogger logger, int count); - [LoggerMessage(Level = LogLevel.Error, Message = "Draft deletion failed for revision {RevisionUid}")] - private static partial void LogDraftDeletionFailure(ILogger logger, Exception exception, RevisionUid revisionUid); - private async Task UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, IEnumerable? additionalExtendedAttributes, Action? onProgress, + TaskCompletionSource revisionUidTaskCompletionSource, TaskControl taskControl) { - var (draftRevisionUid, fileSecrets) = await taskControl.HandlePauseAsync(ct => _fileDraftProvider.GetDraftAsync(_client, ct)).ConfigureAwait(false); + try + { + var (draftRevisionUid, fileSecrets) = await taskControl.HandlePauseAsync(ct => _fileDraftProvider.GetDraftAsync(_client, ct)).ConfigureAwait(false); - await UploadAsync( - draftRevisionUid, - fileSecrets, - contentStream, - thumbnails, - _lastModificationTime, - additionalExtendedAttributes, - onProgress, - taskControl).ConfigureAwait(false); + revisionUidTaskCompletionSource.SetResult(draftRevisionUid); - await UpdateActiveRevisionInCacheAsync(draftRevisionUid, contentStream.Length, taskControl.CancellationToken).ConfigureAwait(false); + await UploadAsync( + draftRevisionUid, + fileSecrets, + contentStream, + thumbnails, + _lastModificationTime, + additionalExtendedAttributes, + onProgress, + taskControl).ConfigureAwait(false); + + await UpdateActiveRevisionInCacheAsync(draftRevisionUid, contentStream.Length, taskControl.CancellationToken).ConfigureAwait(false); - return new UploadResult(draftRevisionUid.NodeUid, draftRevisionUid); + return new UploadResult(draftRevisionUid.NodeUid, draftRevisionUid); + } + catch + { + // This will set it to canceled only if the result was not already set above + revisionUidTaskCompletionSource.TrySetCanceled(); + throw; + } } private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid, long size, CancellationToken cancellationToken) @@ -162,6 +177,7 @@ private async Task UploadFromFileAsync( IEnumerable thumbnails, IEnumerable? additionalMetadata, Action? onProgress, + TaskCompletionSource revisionUidTaskCompletionSource, TaskControl taskControl) { var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); @@ -173,6 +189,7 @@ private async Task UploadFromFileAsync( thumbnails, additionalMetadata, onProgress, + revisionUidTaskCompletionSource, taskControl).ConfigureAwait(false); } } @@ -194,24 +211,8 @@ private async ValueTask UploadAsync( ReleaseBlocks, taskControl).ConfigureAwait(false); - try - { - await revisionWriter.WriteAsync(contentStream, FileSize, thumbnails, lastModificationTime, additionalMetadata, onProgress, taskControl) - .ConfigureAwait(false); - } - catch - { - try - { - await _fileDraftProvider.DeleteDraftAsync(_client, revisionUid, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - LogDraftDeletionFailure(_client.Telemetry.GetLogger("Upload"), ex, revisionUid); - } - - throw; - } + await revisionWriter.WriteAsync(contentStream, FileSize, thumbnails, lastModificationTime, additionalMetadata, onProgress, taskControl) + .ConfigureAwait(false); } private void ReleaseBlocks(int numberOfBlocks) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs index 89d862f7..68d4e579 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs @@ -1,8 +1,10 @@ +using Proton.Drive.Sdk.Api; + namespace Proton.Drive.Sdk.Nodes.Upload; internal interface IFileDraftProvider { ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync(ProtonDriveClient client, CancellationToken cancellationToken); - ValueTask DeleteDraftAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken); + ValueTask DeleteDraftAsync(IDriveApiClients apiClients, RevisionUid revisionUid, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 404a660e..49eefd24 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -1,4 +1,5 @@ using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Files; using Proton.Sdk; using Proton.Sdk.Api; @@ -45,9 +46,9 @@ internal NewFileDraftProvider( return (draftRevisionUid, fileSecrets); } - public async ValueTask DeleteDraftAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) + public async ValueTask DeleteDraftAsync(IDriveApiClients apiClients, RevisionUid revisionUid, CancellationToken cancellationToken) { - await client.Api.Links.DeleteMultipleAsync(revisionUid.NodeUid.VolumeId, [revisionUid.NodeUid.LinkId], cancellationToken).ConfigureAwait(false); + await apiClients.Links.DeleteMultipleAsync(revisionUid.NodeUid.VolumeId, [revisionUid.NodeUid.LinkId], cancellationToken).ConfigureAwait(false); } private static FileCreationRequest GetFileCreationRequest( @@ -143,7 +144,8 @@ private static FileCreationRequest GetFileCreationRequest( } catch (ProtonApiException e) when (e.Response is { Conflict: { LinkId: { } conflictingLinkId, RevisionId: null, DraftRevisionId: not null } } - && (e.Response.Conflict.DraftClientUid == client.Uid || _overrideExistingDraftByOtherClient)) + && (e.Response.Conflict.DraftClientUid == client.Uid || _overrideExistingDraftByOtherClient) + && remainingNumberOfAttempts-- > 0) { var conflictingNodeUid = new NodeUid(_parentUid.VolumeId, conflictingLinkId); @@ -158,11 +160,6 @@ private static FileCreationRequest GetFileCreationRequest( { throw deletionException; } - - if (--remainingNumberOfAttempts <= 0) - { - throw; - } } catch (ProtonApiException e) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index 051e95b9..e277a87c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -1,9 +1,13 @@ +using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Upload; internal sealed class NewRevisionDraftProvider : IFileDraftProvider { + private const int MaxNumberOfDraftCreationAttempts = 3; + private readonly NodeUid _fileUid; private readonly RevisionId _lastKnownRevisionId; @@ -19,12 +23,50 @@ internal NewRevisionDraftProvider( ProtonDriveClient client, CancellationToken cancellationToken) { - return await RevisionOperations.CreateDraftAsync(client, _fileUid, _lastKnownRevisionId, cancellationToken).ConfigureAwait(false); + var parameters = new RevisionCreationRequest + { + CurrentRevisionId = _lastKnownRevisionId, + ClientId = client.Uid, + }; + + var fileSecretsResult = await FileOperations.GetSecretsAsync(client, _fileUid, cancellationToken).ConfigureAwait(false); + + if (!fileSecretsResult.TryGetValueElseError(out var fileSecrets, out _)) + { + throw new InvalidOperationException($"Cannot create draft for file {_fileUid} with degraded secrets"); + } + + var remainingNumberOfAttempts = MaxNumberOfDraftCreationAttempts; + RevisionId? revisionId = null; + + while (revisionId is null) + { + try + { + var revisionResponse = await client.Api.Files.CreateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, parameters, cancellationToken) + .ConfigureAwait(false); + + revisionId = revisionResponse.Identity.RevisionId; + } + catch (ProtonApiException e) + when (e.Response is { Conflict.DraftRevisionId: { } draftRevisionId } + && (e.Response.Conflict.DraftClientUid == client.Uid) + && remainingNumberOfAttempts-- > 0) + { + await client.Api.Files.DeleteRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, draftRevisionId, cancellationToken).ConfigureAwait(false); + } + catch (ProtonApiException e) + { + throw new RevisionDraftConflictException(e); + } + } + + return (new RevisionUid(_fileUid, revisionId.Value), fileSecrets); } - public async ValueTask DeleteDraftAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) + public async ValueTask DeleteDraftAsync(IDriveApiClients apiClients, RevisionUid revisionUid, CancellationToken cancellationToken) { - await client.Api.Files.DeleteRevisionAsync(revisionUid.NodeUid.VolumeId, revisionUid.NodeUid.LinkId, revisionUid.RevisionId, cancellationToken) + await apiClients.Files.DeleteRevisionAsync(revisionUid.NodeUid.VolumeId, revisionUid.NodeUid.LinkId, revisionUid.RevisionId, cancellationToken) .ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index eb42d43c..fc9f2113 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -1,14 +1,31 @@ +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Api; + namespace Proton.Drive.Sdk.Nodes.Upload; -public sealed class UploadController : IDisposable +public sealed partial class UploadController : IAsyncDisposable { + private readonly IDriveApiClients _apiClients; + private readonly IFileDraftProvider _fileDraftProvider; + private readonly Task _revisionUidTask; private readonly Task _uploadTask; private readonly ITaskControl _taskControl; + private readonly ILogger _logger; - internal UploadController(Task uploadTask, ITaskControl taskControl) + internal UploadController( + IDriveApiClients apiClients, + IFileDraftProvider fileDraftProvider, + Task revisionUidTask, + Task uploadTask, + ITaskControl taskControl, + ILogger logger) { + _apiClients = apiClients; + _fileDraftProvider = fileDraftProvider; + _revisionUidTask = revisionUidTask; _uploadTask = uploadTask; _taskControl = taskControl; + _logger = logger; Completion = Task.WhenAny(_taskControl.PauseExceptionSignal, _uploadTask).Unwrap(); } @@ -35,8 +52,33 @@ public void Resume() } } - public void Dispose() + public async ValueTask DisposeAsync() { - _taskControl.Dispose(); + try + { + var draftExists = _revisionUidTask.IsCompletedSuccessfully; + if (!draftExists) + { + return; + } + + var revisionUid = _revisionUidTask.Result; + + try + { + await _fileDraftProvider.DeleteDraftAsync(_apiClients, revisionUid, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + LogDraftDeletionFailure(ex, revisionUid); + } + } + finally + { + _taskControl.Dispose(); + } } + + [LoggerMessage(Level = LogLevel.Error, Message = "Draft deletion failed for revision {RevisionUid}")] + private partial void LogDraftDeletionFailure(Exception exception, RevisionUid revisionUid); } diff --git a/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs index 9f8700bf..4d2be89f 100644 --- a/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs @@ -7,8 +7,8 @@ public static string FlattenMessage(this Exception exception) var previousMessage = string.Empty; return string.Join( - " ---> ", - ThisAndInnerExceptions(exception) + " → ", + EnumerateExceptionHierarchy(exception) .Select(ex => ex.Message) .Where(m => { @@ -22,9 +22,9 @@ public static string FlattenMessage(this Exception exception) })); } - private static IEnumerable ThisAndInnerExceptions(Exception? e) + private static IEnumerable EnumerateExceptionHierarchy(Exception outermostException) { - for (; e != null; e = e.InnerException) + for (var e = outermostException; e != null; e = e.InnerException) { yield return e; } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index f059a712..d9457cb8 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -25,7 +25,8 @@ message Request { UploadControllerAwaitCompletionRequest upload_controller_await_completion = 1104; UploadControllerPauseRequest upload_controller_pause = 1105; UploadControllerResumeRequest upload_controller_resume = 1106; - UploadControllerFreeRequest upload_controller_free = 1107; + UploadControllerDisposeRequest upload_controller_dispose = 1107; + UploadControllerFreeRequest upload_controller_free = 1108; DownloadToStreamRequest download_to_stream = 1200; DownloadToFileRequest download_to_file = 1201; @@ -251,6 +252,11 @@ message UploadControllerResumeRequest { int64 upload_controller_handle = 1; } +// The reponse must not have a value. +message UploadControllerDisposeRequest { + int64 upload_controller_handle = 1; +} + // The reponse must not have a value. message UploadControllerFreeRequest { int64 upload_controller_handle = 1; diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index c417578e..cf08982d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -17,6 +17,8 @@ class UploadController internal constructor( suspend fun pause() = bridge.pause(handle) + suspend fun dispose() = bridge.dispose(handle) + override fun close() { inputStream.close() bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt index b1caaa14..edea9949 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt @@ -5,6 +5,7 @@ import me.proton.drive.sdk.extension.UnitResponseCallback import me.proton.drive.sdk.extension.UploadResultResponseCallback import me.proton.drive.sdk.extension.toEntity import proton.drive.sdk.uploadControllerAwaitCompletionRequest +import proton.drive.sdk.uploadControllerDisposeRequest import proton.drive.sdk.uploadControllerFreeRequest import proton.drive.sdk.uploadControllerPauseRequest import proton.drive.sdk.uploadControllerResumeRequest @@ -30,6 +31,12 @@ class JniUploadController internal constructor() : JniBaseProtonDriveSdk() { } } + suspend fun dispose(handle: Long) = executeOnce("dispose", UnitResponseCallback) { + uploadControllerDispose = uploadControllerDisposeRequest { + uploadControllerHandle = handle + } + } + fun free(handle: Long) { dispatch("free") { uploadControllerFree = uploadControllerFreeRequest { diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift index c57b58e8..d50d2710 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift @@ -134,6 +134,17 @@ public final class UploadOperation: Sendable { } } + private static func disposeFileUploadController(_ uploadControllerHandle: Int64, logger: Logger?) async { + let disposeRequest = Proton_Drive_Sdk_UploadControllerDisposeRequest.with { + $0.uploadControllerHandle = uploadControllerHandle + } + do { + try await SDKRequestHandler.send(disposeRequest, logger: logger) as Void + } catch { + logger?.error("Proton_Drive_Sdk_UploadControllerDisposeRequest failed: \(error)", category: "UploadController.disposeFileUploadController") + } + } + private static func freeFileUploadController(_ uploadControllerHandle: Int64, logger: Logger?) async { let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { $0.uploadControllerHandle = uploadControllerHandle diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 693b9d3e..2f885299 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -109,6 +109,11 @@ extension Message { $0.payload = .uploadControllerResume(request) } + case let request as Proton_Drive_Sdk_UploadControllerDisposeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .uploadControllerDispose(request) + } + case let request as Proton_Drive_Sdk_UploadControllerFreeRequest: Proton_Drive_Sdk_Request.with { $0.payload = .uploadControllerFree(request) From 342ea359d5746333bdec87e1366e4896118c5016 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 22 Dec 2025 11:18:46 +0100 Subject: [PATCH 405/791] Reapply removed upload controller dispose calls --- .../FileOperations/UploadOperation.swift | 99 ++++++++++++------- .../Sources/Plumbing/Message+Packaging.swift | 8 +- .../ProtonDriveClient/ProtonDriveClient.swift | 37 ++++--- 3 files changed, 91 insertions(+), 53 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift index d50d2710..57c82255 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift @@ -13,9 +13,9 @@ public final class UploadOperation: Sendable { private let logger: Logger? private let onOperationCancel: @Sendable () async throws -> Void private let onOperationDispose: @Sendable () async -> Void - + private var uploadControllerHandleForProto: Int64 { Int64(uploadControllerHandle) } - + init(fileUploaderHandle: ObjectHandle, uploadControllerHandle: ObjectHandle, progressCallbackWrapper: ProgressCallbackWrapper, @@ -31,7 +31,7 @@ public final class UploadOperation: Sendable { self.onOperationCancel = onOperationCancel self.onOperationDispose = onOperationDispose } - + public func awaitUploadWithResilience( operationalResilience: OperationalResilience, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void @@ -40,11 +40,11 @@ public final class UploadOperation: Sendable { retryCounter: 0, operationalResilience: operationalResilience, onPauseErrorReceived: onRetriableErrorReceived ) } - + private func awaitUploadWithResilience( retryCounter: UInt, operationalResilience: OperationalResilience, onPauseErrorReceived: @Sendable @escaping (Error) -> Void ) async throws -> UploadedFileIdentifiers { - let result = await awaitUploadCompletion() + let result = await awaitUploadCompletion(cleanUpTemporaryState: true) switch result { case .succeeded(let uploadResult): return uploadResult @@ -53,26 +53,39 @@ public final class UploadOperation: Sendable { throw error case .pausedOnError(let error): - onPauseErrorReceived(error) - return try await operationalResilience.performRetry(retryCounter, error) { - try await resume() - return try await awaitUploadWithResilience( - retryCounter: $0, operationalResilience: operationalResilience, onPauseErrorReceived: onPauseErrorReceived - ) + do { + onPauseErrorReceived(error) + return try await operationalResilience.performRetry(retryCounter, error) { + try await resume() + return try await awaitUploadWithResilience( + retryCounter: $0, operationalResilience: operationalResilience, onPauseErrorReceived: onPauseErrorReceived + ) + } + } catch { + // if the retry throws, it means the operation cannot be recovered from anymore + // in this case, we clean up the temporary state + try? await cleanUpTemporaryState() + throw error } } } - + /// Wait for upload completion - public func awaitUploadCompletion() async -> UploadOperationResult { + public func awaitUploadCompletion(cleanUpTemporaryState: Bool = true) async -> UploadOperationResult { let awaitUploadCompletionRequest = Proton_Drive_Sdk_UploadControllerAwaitCompletionRequest.with { $0.uploadControllerHandle = uploadControllerHandleForProto } do { - let uploadResult: Proton_Drive_Sdk_UploadResult = try await SDKRequestHandler.send(awaitUploadCompletionRequest, logger: logger) + let uploadResult: Proton_Drive_Sdk_UploadResult = try await SDKRequestHandler.send(awaitUploadCompletionRequest, + logger: logger) guard let result = UploadedFileIdentifiers(interopUploadResult: uploadResult) else { - throw ProtonDriveSDKError(interopError: .wrongResult(message: "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)")) + throw ProtonDriveSDKError( + interopError: .wrongResult(message: "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)") + ) + } + if cleanUpTemporaryState { + try? await self.cleanUpTemporaryState() } return .succeeded(result) } catch { @@ -80,47 +93,72 @@ public final class UploadOperation: Sendable { let isPaused = try await isPaused() if isPaused { // if the operation is paused, we can try recovering from the error + // this is why this is the only scenario in which we do NOT clean up the temporary state + // the resume relies on that temporary state to be there return .pausedOnError(error) } else { + if cleanUpTemporaryState { + try? await self.cleanUpTemporaryState() + } return .failed(error) } } catch let isPausedError { - logger?.info("Checking isPaused status failed with: \(isPausedError.localizedDescription)", category: "DownloadOperation") + logger?.info("Checking isPaused status failed with: \(isPausedError.localizedDescription)", + category: "DownloadOperation") + if cleanUpTemporaryState { + try? await self.cleanUpTemporaryState() + } return .failed(error) } } } - + public func pause() async throws { let pauseRequest = Proton_Drive_Sdk_UploadControllerPauseRequest.with { $0.uploadControllerHandle = uploadControllerHandleForProto } try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void } - + public func resume() async throws { let resumeRequest = Proton_Drive_Sdk_UploadControllerResumeRequest.with { $0.uploadControllerHandle = uploadControllerHandleForProto } try await SDKRequestHandler.send(resumeRequest, logger: logger) as Void } - + public func isPaused() async throws -> Bool { let isPausedRequest = Proton_Drive_Sdk_UploadControllerIsPausedRequest.with { $0.uploadControllerHandle = uploadControllerHandleForProto } return try await SDKRequestHandler.send(isPausedRequest, logger: logger) } - + // a convenience API allowing for cancelling the operation through UploadOperation instance public func cancel() async throws { try await onOperationCancel() } - + + // allows the manual cleanup of temporary state on BE, like the draft being created there + public func cleanUpTemporaryState() async throws { + do { + let disposeRequest = Proton_Drive_Sdk_UploadControllerDisposeRequest.with { + $0.uploadControllerHandle = uploadControllerHandleForProto + } + try await SDKRequestHandler.send(disposeRequest, logger: logger) as Void + } catch { + // If the request to dispose the file upload controller failed, we have some BE state not cleaned up properly. + // This might manifest in some user-visible errors on retry, but there is no clear way of handling the error, so we propagate it up. + logger?.error("Proton_Drive_Sdk_UploadControllerDisposeRequest failed: \(error)", + category: "UploadController.disposeFileUploadController") + throw error + } + } + deinit { Self.freeSDKObjects(uploadControllerHandle, fileUploaderHandle, logger, onOperationDispose) } - + private static func freeSDKObjects( _ uploadControllerHandle: ObjectHandle, _ fileUploaderHandle: ObjectHandle, @@ -133,17 +171,6 @@ public final class UploadOperation: Sendable { await freeFileUploader(Int64(fileUploaderHandle), logger) } } - - private static func disposeFileUploadController(_ uploadControllerHandle: Int64, logger: Logger?) async { - let disposeRequest = Proton_Drive_Sdk_UploadControllerDisposeRequest.with { - $0.uploadControllerHandle = uploadControllerHandle - } - do { - try await SDKRequestHandler.send(disposeRequest, logger: logger) as Void - } catch { - logger?.error("Proton_Drive_Sdk_UploadControllerDisposeRequest failed: \(error)", category: "UploadController.disposeFileUploadController") - } - } private static func freeFileUploadController(_ uploadControllerHandle: Int64, logger: Logger?) async { let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { @@ -154,7 +181,8 @@ public final class UploadOperation: Sendable { } catch { // If the request to free the file upload controller failed, we have a memory leak, but not much else can be done. // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_UploadControllerFreeRequest failed: \(error)", category: "UploadController.freeFileUploadController") + logger?.error("Proton_Drive_Sdk_UploadControllerFreeRequest failed: \(error)", + category: "UploadController.freeFileUploadController") } } @@ -168,7 +196,8 @@ public final class UploadOperation: Sendable { } catch { // If the request to free the file uploader failed, we have a memory leak, but not much else can be done. // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_FileUploaderFreeRequest failed: \(error)", category: "UploadManager.freeFileUploader") + logger?.error("Proton_Drive_Sdk_FileUploaderFreeRequest failed: \(error)", + category: "UploadManager.freeFileUploader") } } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 2f885299..1eb1ad44 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -5,11 +5,11 @@ extension Message { func serializedIntoRequest() throws -> ByteArray { try packIntoRequest().serialisedByteArray() } - + func serializedIntoResponse() throws -> ByteArray { try packIntoResponse().serialisedByteArray() } - + /// Packs any request into a Proton_Sdk_Request or Proton_Drive_Sdk_Request. func packIntoRequest() throws -> Message { switch self { @@ -159,7 +159,7 @@ extension Message { throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown request type: \(self)")) } } - + private func packIntoResponse() throws -> Message { if let error = self as? Proton_Sdk_Error { return Proton_Sdk_Response.with { @@ -206,7 +206,7 @@ extension Message { throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown response type: \(self)")) } } - + private func serialisedByteArray() throws -> ByteArray { ByteArray(data: try serializedData()) } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 8a4ac6d1..dcade97f 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -37,7 +37,7 @@ public actor ProtonDriveClient: Sendable { let clientCreateRequest = Proton_Drive_Sdk_DriveClientCreateRequest.with { $0.baseURL = configuration.baseURL - + $0.uid = configuration.clientUID $0.accountRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) @@ -118,7 +118,7 @@ public actor ProtonDriveClient: Sendable { onRetriableErrorReceived: onRetriableErrorReceived ) } - + public func downloadFileOperation( revisionUid: SDKRevisionUid, destinationUrl: URL, @@ -163,9 +163,10 @@ public actor ProtonDriveClient: Sendable { cancellationToken: cancellationToken, progressCallback: progressCallback ) - + return try await operation.awaitUploadWithResilience( - operationalResilience: configuration.uploadOperationalResilience, onRetriableErrorReceived: onRetriableErrorReceived + operationalResilience: configuration.uploadOperationalResilience, + onRetriableErrorReceived: onRetriableErrorReceived ) } @@ -215,12 +216,13 @@ public actor ProtonDriveClient: Sendable { cancellationToken: cancellationToken, progressCallback: progressCallback ) - + return try await operation.awaitUploadWithResilience( - operationalResilience: configuration.uploadOperationalResilience, onRetriableErrorReceived: onRetriableErrorReceived + operationalResilience: configuration.uploadOperationalResilience, + onRetriableErrorReceived: onRetriableErrorReceived ) } - + public func uploadNewRevisionOperation( currentActiveRevisionUid: SDKRevisionUid, fileURL: URL, @@ -240,7 +242,7 @@ public actor ProtonDriveClient: Sendable { progressCallback: progressCallback ) } - + public func cancelUpload(cancellationToken: UUID) async throws { try await uploadsManager.cancelUpload(with: cancellationToken) } @@ -263,15 +265,21 @@ public actor ProtonDriveClient: Sendable { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } - let nameResult: String = try await SDKRequestHandler.send(getAvailableNameRequest, logger: logger) + let nameResult: String = try await SDKRequestHandler.send(getAvailableNameRequest, + logger: logger) return nameResult } - static func unbox(callbackPointer: Int, releaseBox: () -> Void, weakDriveClient: WeakReference) -> ProtonDriveClient? { + static func unbox( + callbackPointer: Int, releaseBox: () -> Void, + weakDriveClient: WeakReference + ) -> ProtonDriveClient? { guard let driveClient = weakDriveClient.value else { releaseBox() let message = "callback called after the proton client object was deallocated" - SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer, assert: false) + SDKResponseHandler.sendInteropErrorToSDK(message: message, + callbackPointer: callbackPointer, + assert: false) return nil } return driveClient @@ -288,12 +296,12 @@ public actor ProtonDriveClient: Sendable { cancellationToken: cancellationToken ) } - + deinit { guard clientHandle != 0 else { return } Self.freeProtonDriveClient(Int64(clientHandle), logger) } - + private static func freeProtonDriveClient(_ clientHandle: Int64, _ logger: Logger?) { Task { let freeRequest = Proton_Drive_Sdk_DriveClientFreeRequest.with { @@ -303,7 +311,8 @@ public actor ProtonDriveClient: Sendable { try await SDKRequestHandler.send(freeRequest, logger: logger) as Void } catch { // If the request to free the client failed, we have a memory leak, but not much else can be done. - logger?.error("Proton_Drive_Sdk_DriveClientFreeRequest failed: \(error)", category: "ProtonDriveClient.freeProtonDriveClient") + logger?.error("Proton_Drive_Sdk_DriveClientFreeRequest failed: \(error)", + category: "ProtonDriveClient.freeProtonDriveClient") } } } From c2a733b9fae3a148b2b89805e7e5ae311e8458e6 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Dec 2025 09:09:43 +0000 Subject: [PATCH 406/791] Add levels to logs --- .../me/proton/drive/sdk/DownloadController.kt | 37 ++++++++++-- .../kotlin/me/proton/drive/sdk/Downloader.kt | 15 +++++ .../kotlin/me/proton/drive/sdk/DriveClient.kt | 10 ++++ .../me/proton/drive/sdk/ProtonDriveSdk.kt | 4 ++ .../kotlin/me/proton/drive/sdk/Session.kt | 20 ++++++- .../me/proton/drive/sdk/UploadController.kt | 34 ++++++++++- .../kotlin/me/proton/drive/sdk/Uploader.kt | 14 +++++ .../proton/drive/sdk/extension/Throwable.kt | 6 +- .../me/proton/drive/sdk/internal/JniBase.kt | 11 +++- .../sdk/internal/JniBaseProtonDriveSdk.kt | 8 ++- .../drive/sdk/internal/JniBaseProtonSdk.kt | 2 +- .../drive/sdk/internal/JniDownloader.kt | 2 +- .../drive/sdk/internal/JniDriveClient.kt | 2 +- .../drive/sdk/internal/JniHttpStream.kt | 4 +- .../proton/drive/sdk/internal/JniUploader.kt | 2 +- .../me/proton/drive/sdk/internal/Long.kt | 3 + .../internal/ProtonDriveSdkNativeClient.kt | 60 +++++++++++-------- .../sdk/internal/ProtonSdkNativeClient.kt | 11 ++-- 18 files changed, 193 insertions(+), 52 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/Long.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index 94d8335e..64d76b5f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -1,6 +1,10 @@ package me.proton.drive.sdk +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.LoggerProvider.Level.WARN import me.proton.drive.sdk.internal.JniDownloadController +import me.proton.drive.sdk.internal.toLogId class DownloadController internal constructor( downloader: Downloader, @@ -9,11 +13,36 @@ class DownloadController internal constructor( override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(downloader), AutoCloseable, Cancellable { - suspend fun awaitCompletion() = bridge.awaitCompletion(handle) + suspend fun awaitCompletion() { + log(DEBUG, "await completion") + runCatching { + bridge.awaitCompletion(handle) + }.onSuccess { log(INFO, "completed") } + .onFailure { log(INFO, "cancelled or failed") } + .getOrThrow() + } - suspend fun resume() = bridge.resume(handle) + suspend fun resume() { + log(INFO, "resume") + bridge.resume(handle) + } - suspend fun pause() = bridge.pause(handle) + suspend fun pause() { + log(INFO, "pause") + bridge.pause(handle) + } - override fun close() = bridge.free(handle) + override fun close() { + log(DEBUG, "close") + bridge.free(handle) + } + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "DownloadController(${handle.toLogId()}) $message") + } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt index 036a23f2..4b38f8bc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -1,9 +1,12 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniDownloader +import me.proton.drive.sdk.internal.toLogId import java.io.OutputStream import java.nio.ByteBuffer import java.nio.channels.Channels @@ -20,6 +23,7 @@ class Downloader internal constructor( outputStream: OutputStream, progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> + log(INFO, "downloadToStream") val handle = bridge.downloadToStream( handle = handle, cancellationTokenSourceHandle = cancellationTokenSource.handle, @@ -28,6 +32,7 @@ class Downloader internal constructor( }, onProgress = { progressUpdate -> with(progressUpdate) { + bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") progress(bytesCompleted, bytesInTotal) } }, @@ -42,9 +47,19 @@ class Downloader internal constructor( } override fun close() { + log(DEBUG, "close") bridge.free(handle) super.close() } + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "FileDownloader(${handle.toLogId()}) $message") + } } suspend fun DriveClient.downloader( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt index 5f36af97..b81d36b5 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt @@ -1,9 +1,12 @@ package me.proton.drive.sdk +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniDriveClient import me.proton.drive.sdk.internal.cancellationCoroutineScope +import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetThumbnailsRequest import java.io.OutputStream @@ -18,6 +21,7 @@ class DriveClient internal constructor( parentFolderUid: String, name: String, ): String = cancellationCoroutineScope { source -> + log(DEBUG, "getAvailableName") bridge.getAvailableName( driveClientGetAvailableNameRequest { this.parentFolderUid = parentFolderUid @@ -33,6 +37,7 @@ class DriveClient internal constructor( type: ThumbnailType, block: (String) -> OutputStream, ): Unit = cancellationCoroutineScope { source -> + log(INFO, "getThumbnails($type)") bridge.getThumbnails( driveClientGetThumbnailsRequest { this.fileUids += fileUids @@ -48,9 +53,14 @@ class DriveClient internal constructor( } override fun close() { + log(DEBUG, "close") bridge.free(handle) super.close() } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "DriveClient(${handle.toLogId()}) $message") + } } suspend fun Session.driveClientCreate(): DriveClient = JniDriveClient().run { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt index b67cde99..a190e35e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -3,6 +3,7 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope import me.proton.core.domain.entity.UserId import me.proton.core.network.data.ApiProvider +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.entity.SessionBeginRequest import me.proton.drive.sdk.entity.SessionResumeRequest @@ -29,6 +30,7 @@ object ProtonDriveSdk { request: SessionBeginRequest, ): Session = cancellationTokenSource().let { source -> JniSession().run { + clientLogger(DEBUG, "ProtonDriveSdk sessionBegin") Session(begin(source.handle, request), this, source) } } @@ -37,6 +39,7 @@ object ProtonDriveSdk { request: SessionResumeRequest, ): Session = cancellationTokenSource().let { source -> JniSession().run { + clientLogger(DEBUG, "ProtonDriveSdk sessionResume") Session(resume(request), this, source) } } @@ -51,6 +54,7 @@ object ProtonDriveSdk { metricCallback: MetricCallback? = null, featureEnabled: suspend (String) -> Boolean = { false }, ): DriveClient = JniDriveClient().run { + clientLogger(DEBUG, "ProtonDriveSdk driveClientCreate(${userId.id.take(8)})") DriveClient( create( coroutineScope = coroutineScope, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt index 63b86c2e..5783ab33 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt @@ -1,7 +1,10 @@ package me.proton.drive.sdk +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.SessionRenewRequest import me.proton.drive.sdk.internal.JniSession +import me.proton.drive.sdk.internal.toLogId class Session internal constructor( internal val handle: Long, @@ -11,14 +14,25 @@ class Session internal constructor( suspend fun renew( request: SessionRenewRequest, - ): Session = bridge.renew(handle, request).run { - Session(this, bridge, cancellationTokenSource) + ): Session { + log(DEBUG, "end") + return bridge.renew(handle, request).run { + Session(this, bridge, cancellationTokenSource) + } } - suspend fun end() = bridge.end(handle) + suspend fun end() { + log(INFO, "end") + bridge.end(handle) + } override fun close() { + log(DEBUG, "close") bridge.free(handle) super.close() } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "Session(${handle.toLogId()}) $message") + } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index cf08982d..03ca059b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -1,6 +1,11 @@ package me.proton.drive.sdk +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.LoggerProvider.Level.WARN +import me.proton.drive.sdk.entity.UploadResult import me.proton.drive.sdk.internal.JniUploadController +import me.proton.drive.sdk.internal.toLogId import java.io.InputStream class UploadController internal constructor( @@ -11,17 +16,40 @@ class UploadController internal constructor( override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(uploader), AutoCloseable, Cancellable { - suspend fun awaitCompletion() = bridge.awaitCompletion(handle) + suspend fun awaitCompletion(): UploadResult { + log(DEBUG, "await completion") + return runCatching { + bridge.awaitCompletion(handle) + }.onSuccess { log(INFO, "completed") } + .onFailure { log(INFO, "cancelled or failed") } + .getOrThrow() + } - suspend fun resume() = bridge.resume(handle) + suspend fun resume() { + log(INFO, "resume") + bridge.resume(handle) + } - suspend fun pause() = bridge.pause(handle) + suspend fun pause() { + log(INFO, "pause") + bridge.pause(handle) + } suspend fun dispose() = bridge.dispose(handle) override fun close() { + log(DEBUG, "close") inputStream.close() bridge.free(handle) super.close() } + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "UploadController(${handle.toLogId()}) $message") + } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index a88e3863..8a7ae7e2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -1,12 +1,15 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.JniUploader +import me.proton.drive.sdk.internal.toLogId import java.io.InputStream import java.nio.ByteBuffer import java.nio.channels.Channels @@ -24,6 +27,7 @@ class Uploader internal constructor( thumbnails: Map = emptyMap(), progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): UploadController = cancellationTokenSource().let { source -> + log(INFO, "uploadFromStream") val handle = bridge.uploadFromStream( uploaderHandle = handle, cancellationTokenSourceHandle = source.handle, @@ -33,6 +37,7 @@ class Uploader internal constructor( }, onProgress = { progressUpdate -> with(progressUpdate) { + log(DEBUG, "progress: bytesCompleted/bytesInTotal") progress(bytesCompleted, bytesInTotal) } }, @@ -48,6 +53,15 @@ class Uploader internal constructor( } override fun close() = bridge.free(handle) + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "FileUploader(${handle.toLogId()}) $message") + } } suspend fun DriveClient.uploader( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt index 7eaea108..f57eafaa 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -5,10 +5,12 @@ import me.proton.core.network.domain.ApiException import me.proton.core.network.domain.ApiResult import proton.sdk.ProtonSdk -fun Throwable.toProtonSdkError(defaultMessage: String) = proton.sdk.error { +fun Throwable.toProtonSdkError(message: String) = proton.sdk.error { val exception = this@toProtonSdkError type = exception.javaClass.name - message = exception.message ?: defaultMessage + this.message = exception.message?.let { + "$message, caused by ${exception.message}" + } ?: message domain = exception.domain() context = stackTraceToString() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt index e1ec16f4..9c9cd55d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt @@ -1,7 +1,6 @@ package me.proton.drive.sdk.internal -import me.proton.drive.sdk.LoggerProvider -import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE +import me.proton.drive.sdk.LoggerProvider.Level import me.proton.drive.sdk.SdkLogger import java.nio.ByteBuffer @@ -9,7 +8,13 @@ typealias ResponseCallback = (ByteBuffer) -> Unit abstract class JniBase { - open val logger: (String) -> Unit = { message -> globalSdkLogger(VERBOSE, "internal", message) } + open val internalLogger: (Level, String) -> Unit = { level, message -> + globalSdkLogger(level, "internal", message) + } + + open val clientLogger: (Level, String) -> Unit = { level, message -> + globalSdkLogger(level, "client", message) + } internal fun method(name: String) = "${this.javaClass.simpleName}::$name" diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt index 4b4ad327..add264ea 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -2,6 +2,8 @@ package me.proton.drive.sdk.internal import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine +import me.proton.drive.sdk.LoggerProvider.Level +import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE import proton.drive.sdk.ProtonDriveSdk.Request import proton.drive.sdk.RequestKt import proton.drive.sdk.request @@ -19,7 +21,7 @@ abstract class JniBaseProtonDriveSdk : JniBase() { val nativeClient = ProtonDriveSdkNativeClient( method(name), IgnoredIntegerOrErrorResponse(), - logger = logger, + logger = internalLogger, ) nativeClient.handleRequest(request(block)) } @@ -33,7 +35,7 @@ abstract class JniBaseProtonDriveSdk : JniBase() { val nativeClient = ProtonDriveSdkNativeClient( name = method(name), response = callback(continuation), - logger = logger, + logger = internalLogger, ) continuation.invokeOnCancellation { nativeClient.release() } nativeClient.handleRequest(request(block)) @@ -50,7 +52,7 @@ abstract class JniBaseProtonDriveSdk : JniBase() { } fun releaseAll() { - logger("Releasing all for ${javaClass.simpleName}") + internalLogger(VERBOSE, "Releasing all for ${javaClass.simpleName}") released = true clients.forEach { client -> client.release() } clients = emptyList() diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt index 126ebd83..0e2b6451 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt @@ -29,7 +29,7 @@ abstract class JniBaseProtonSdk : JniBase() { val nativeClient = ProtonSdkNativeClient( name = method(name), response = callback(continuation), - logger = logger, + logger = internalLogger, ) continuation.invokeOnCancellation { nativeClient.release() } nativeClient.handleRequest(request(block)) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt index 62dcf360..e8efd75f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt @@ -37,7 +37,7 @@ class JniDownloader internal constructor() : JniBaseProtonDriveSdk() { continuation.toLongResponse(), write = onWrite, progress = onProgress, - logger = logger, + logger = internalLogger, coroutineScope = coroutineScope, ) }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index 5228086f..20db5608 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -41,7 +41,7 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { continuation.toLongResponse(), httpClientRequest = onHttpClientRequest, accountRequest = onAccountRequest, - logger = logger, + logger = internalLogger, recordMetric = onRecordMetric, featureEnabled = onFeatureEnabled, coroutineScope = coroutineScope, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index 4757f927..d5bfdef4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -29,7 +29,7 @@ class JniHttpStream internal constructor( } }, coroutineScope = coroutineScope, - logger = logger + logger = internalLogger ).also { client = it }.createWeakRef() @@ -41,7 +41,7 @@ class JniHttpStream internal constructor( ProtonSdkNativeClient( name = method("read"), response = continuation.toIntResponse(), - logger = logger, + logger = internalLogger, ) }, requestBuilder = { client -> request { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt index 06f8369c..d45e38be 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt @@ -50,7 +50,7 @@ class JniUploader internal constructor() : JniBaseProtonDriveSdk() { continuation.toLongResponse(), read = onRead, progress = onProgress, - logger = logger, + logger = internalLogger, coroutineScope = coroutineScope, ) }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/Long.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/Long.kt new file mode 100644 index 00000000..33d11d44 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/Long.kt @@ -0,0 +1,3 @@ +package me.proton.drive.sdk.internal + +fun Long.toLogId() = toString(Character.MAX_RADIX) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index ab536ed2..faefbedf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -10,6 +10,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import me.proton.drive.sdk.LoggerProvider.Level +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.ERROR +import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE +import me.proton.drive.sdk.LoggerProvider.Level.WARN import me.proton.drive.sdk.extension.asAny import me.proton.drive.sdk.extension.toProtonSdkError import proton.drive.sdk.ProtonDriveSdk @@ -30,7 +35,7 @@ class ProtonDriveSdkNativeClient internal constructor( val progress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit = { error("progress not configured for $name") }, val recordMetric: suspend (ProtonSdk.MetricEvent) -> Unit = { error("recordMetric not configured for $name") }, val featureEnabled: suspend (String) -> Boolean = { error("featureEnabled not configured for $name") }, - val logger: (String) -> Unit = {}, + val logger: (Level, String) -> Unit = { _, _ -> }, private val coroutineScope: CoroutineScope? = null, ) { @@ -43,7 +48,7 @@ class ProtonDriveSdkNativeClient internal constructor( fun handleRequest( request: ProtonDriveSdk.Request, ) { - logger("handle request ${request.payloadCase.name} for $name") + logger(DEBUG, "handle request ${request.payloadCase.name} for $name") handleRequest(request.toByteArray()) } @@ -56,11 +61,12 @@ class ProtonDriveSdkNativeClient internal constructor( response: Response, ) { if (response.hasValue()) { - logger("handle response value: ${response.value.typeUrl} for $name") + logger(VERBOSE, "handle response value: ${response.value.typeUrl} for $name") } else { - logger("handle response ${response.resultCase.name} for $name") if (response.resultCase == Response.ResultCase.ERROR) { - logger(response.error.context) + logger(VERBOSE, "handle response ${response.resultCase.name} for $name (${response.error.message})") + } else { + logger(VERBOSE, "handle response ${response.resultCase.name} for $name") } } handleResponse(sdkHandle, response.toByteArray()) @@ -72,7 +78,7 @@ class ProtonDriveSdkNativeClient internal constructor( @Suppress("unused") // Called by JNI fun onResponse(data: ByteBuffer) { - logger("response for $name of size: ${data.capacity()}") + logger(DEBUG, "response for $name of size: ${data.capacity()}") response(data) } @@ -87,9 +93,9 @@ class ProtonDriveSdkNativeClient internal constructor( @Suppress("unused") // Called by JNI fun onRead(buffer: ByteBuffer, sdkHandle: Long) { onOperation("read", sdkHandle) { - logger("read for $name of size: ${buffer.capacity()}") + logger(VERBOSE, "read for $name of size: ${buffer.capacity()}") val bytesRead = read(buffer).takeUnless { it < 0 } ?: 0 - logger("$bytesRead bytes read for $name") + logger(VERBOSE, "$bytesRead bytes read for $name") response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } } } @@ -97,7 +103,7 @@ class ProtonDriveSdkNativeClient internal constructor( @Suppress("unused") // Called by JNI fun onWrite(data: ByteBuffer, sdkHandle: Long) { onOperation("write", sdkHandle) { - logger("write for $name of size: ${data.capacity()}") + logger(VERBOSE, "write for $name of size: ${data.capacity()}") write(data) response {} } @@ -113,18 +119,24 @@ class ProtonDriveSdkNativeClient internal constructor( sdkHandle = sdkHandle, parser = ProtonSdk.HttpRequest::parseFrom, ) { httpRequest -> - logger("send http request for ${httpRequest.method} ${httpRequest.url} of size: ${data.capacity()}") + logger( + DEBUG, + "send http request for ${httpRequest.method} ${httpRequest.url} of size: ${data.capacity()}" + ) val httpResponse = httpClientRequest(httpRequest) - logger("receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}") + logger( + DEBUG, + "receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}" + ) response { value = httpResponse.asAny("proton.sdk.HttpResponse") } }?.createWeakRef() ?: 0 @Suppress("unused") // Called by JNI fun onHttpResponseRead(buffer: ByteBuffer, sdkHandle: Long) { onOperation("read", sdkHandle) { - logger("http response read for $name of size: ${buffer.capacity()}") + logger(VERBOSE, "http response read for $name of size: ${buffer.capacity()}") val bytesRead = readHttpBody(buffer).takeUnless { it < 0 } ?: 0 - logger("$bytesRead bytes read for http response $name") + logger(VERBOSE, "$bytesRead bytes read for http response $name") response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } } } @@ -140,7 +152,7 @@ class ProtonDriveSdkNativeClient internal constructor( sdkHandle = sdkHandle, parser = ProtonDriveSdk.AccountRequest::parseFrom, ) { accountRequest -> - logger("request for ${accountRequest.payloadCase.name} of size: ${data.capacity()}") + logger(VERBOSE, "request for ${accountRequest.payloadCase.name} of size: ${data.capacity()}") val response = accountRequest(accountRequest) response { value = response } } @@ -164,8 +176,8 @@ class ProtonDriveSdkNativeClient internal constructor( runCatching { if (featureEnabled(name)) 1L else 0L }.getOrElse { error -> - logger("Cannot get feature flag $name") - logger(error.stackTraceToString()) + logger(WARN, "Cannot get feature flag $name") + logger(WARN, error.stackTraceToString()) 0L } } @@ -189,7 +201,7 @@ class ProtonDriveSdkNativeClient internal constructor( try { handleResponse(sdkHandle, block()) } catch (error: CancellationException) { - logger("Operation $operation was cancelled") + logger(DEBUG, "Operation $operation was cancelled") handleResponse(sdkHandle, response { this@response.error = error.toProtonSdkError("Operation $operation was cancelled") @@ -230,23 +242,23 @@ class ProtonDriveSdkNativeClient internal constructor( block: suspend (T) -> Unit ) { try { - logger("$callback for $name of size: ${data.capacity()}") + logger(VERBOSE, "$callback for $name of size: ${data.capacity()}") // parsing of protobuf needs to be done serially val value = parser(data) coroutineScope(callback).launch { try { block(value) } catch (error: CancellationException) { - logger("Callback $callback was cancelled") + logger(DEBUG, "Callback $callback was cancelled") throw error } catch (error: Throwable) { - logger("Error while $callback") - logger(error.stackTraceToString()) + logger(WARN, "Error while $callback") + logger(WARN, error.stackTraceToString()) } } } catch (error: Throwable) { - logger("Error while parsing value for $callback") - logger(error.stackTraceToString()) + logger(ERROR, "Error while parsing value for $callback") + logger(ERROR, error.stackTraceToString()) } } @@ -256,7 +268,7 @@ class ProtonDriveSdkNativeClient internal constructor( "No coroutineScope was provided to ${javaClass.simpleName}, cannot execute $operation" } if (!coroutineScope.isActive) { - logger("CoroutineScope not active for $operation") + logger(DEBUG, "CoroutineScope not active for $operation") } return coroutineScope } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt index ae472a7c..af6c9c55 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -1,5 +1,8 @@ package me.proton.drive.sdk.internal +import me.proton.drive.sdk.LoggerProvider.Level +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE import proton.sdk.ProtonSdk.Request import java.nio.ByteBuffer @@ -7,7 +10,7 @@ class ProtonSdkNativeClient internal constructor( val name: String, val response: ResponseCallback = { error("response not configured for $name") }, val callback: (ByteBuffer) -> Unit = { error("callback not configured for $name") }, - val logger: (String) -> Unit = {} + val logger: (Level, String) -> Unit = { _, _ -> } ) { fun release() { @@ -18,7 +21,7 @@ class ProtonSdkNativeClient internal constructor( fun handleRequest( request: Request, ) { - logger("handle request ${request.payloadCase.name} for $name") + logger(DEBUG, "handle request ${request.payloadCase.name} for $name") handleRequest(request.toByteArray()) } @@ -27,12 +30,12 @@ class ProtonSdkNativeClient internal constructor( ) fun onResponse(data: ByteBuffer) { - logger("response for $name of size: ${data.capacity()}") + logger(DEBUG, "response for $name of size: ${data.capacity()}") response(data) } fun onCallback(data: ByteBuffer) { - logger("callback for $name of size: ${data.capacity()}") + logger(VERBOSE, "callback for $name of size: ${data.capacity()}") callback(data) } From f8982212ab46e6e7ac24dadcb7e948a8100738fb Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Dec 2025 09:55:59 +0000 Subject: [PATCH 407/791] Limit GC pressure by creating less Channel instances --- .../kotlin/me/proton/drive/sdk/Downloader.kt | 5 ++- .../kotlin/me/proton/drive/sdk/Uploader.kt | 5 ++- .../proton/drive/sdk/extension/HttpStream.kt | 3 +- .../drive/sdk/internal/JniHttpStream.kt | 31 ++++++++++--------- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt index 4b38f8bc..ba4ae7f0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -24,12 +24,11 @@ class Downloader internal constructor( progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> log(INFO, "downloadToStream") + val channel = Channels.newChannel(outputStream) val handle = bridge.downloadToStream( handle = handle, cancellationTokenSourceHandle = cancellationTokenSource.handle, - onWrite = { byteBuffer: ByteBuffer -> - Channels.newChannel(outputStream).write(byteBuffer) - }, + onWrite = channel::write, onProgress = { progressUpdate -> with(progressUpdate) { bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 8a7ae7e2..543c7c6c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -28,13 +28,12 @@ class Uploader internal constructor( progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): UploadController = cancellationTokenSource().let { source -> log(INFO, "uploadFromStream") + val channel = Channels.newChannel(inputStream) val handle = bridge.uploadFromStream( uploaderHandle = handle, cancellationTokenSourceHandle = source.handle, thumbnails = thumbnails, - onRead = { buffer: ByteBuffer -> - Channels.newChannel(inputStream).read(buffer) - }, + onRead = channel::read, onProgress = { progressUpdate -> with(progressUpdate) { log(DEBUG, "progress: bytesCompleted/bytesInTotal") diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt index 518eadb6..4292d679 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt @@ -18,6 +18,7 @@ internal suspend fun HttpStream.read( val outputStream = ByteArrayOutputStream() if (request.hasSdkContentHandle()) { val buffer = ByteBuffer.allocateDirect(64 * 1024) + val channel = Channels.newChannel(outputStream) while (true) { buffer.clear() @@ -29,7 +30,7 @@ internal suspend fun HttpStream.read( buffer.flip() // Write directly from ByteBuffer to okio - Channels.newChannel(outputStream).write(buffer) + channel.write(buffer) } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index d5bfdef4..8cab67b2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -18,21 +18,24 @@ class JniHttpStream internal constructor( fun write( coroutineScope: CoroutineScope, inputStream: InputStream, - ): Long = ProtonDriveSdkNativeClient( - name = method("write"), - readHttpBody = { buffer -> - Channels.newChannel(inputStream).read(buffer).also { numberOfByteRead -> - if (numberOfByteRead == -1) { - inputStream.close() - onBodyRead?.invoke() + ): Long { + val channel = Channels.newChannel(inputStream) + return ProtonDriveSdkNativeClient( + name = method("write"), + readHttpBody = { buffer -> + channel.read(buffer).also { numberOfByteRead -> + if (numberOfByteRead == -1) { + inputStream.close() + onBodyRead?.invoke() + } } - } - }, - coroutineScope = coroutineScope, - logger = internalLogger - ).also { - client = it - }.createWeakRef() + }, + coroutineScope = coroutineScope, + logger = internalLogger + ).also { + client = it + }.createWeakRef() + } suspend fun read( handle: Long, From c6ee48eac1b004980205e3f81579bb913345d770 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Dec 2025 16:16:27 +0000 Subject: [PATCH 408/791] Update download error handling --- .../Proton.Drive.Sdk/Api/DriveApiClients.cs | 2 +- .../Api/Storage/StorageApiClient.cs | 21 ++- .../Proton.Drive.Sdk/ExceptionExtensions.cs | 141 ++++++++++++++++++ .../Nodes/Download/BlockDownloader.cs | 1 - .../Nodes/Download/RevisionReader.cs | 12 +- .../Nodes/Upload/BlockUploader.cs | 1 - .../Telemetry/TelemetryErrorResolver.cs | 9 +- cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs | 32 ---- .../src/Proton.Sdk/Http/HttpApiCallBuilder.cs | 19 ++- 9 files changed, 184 insertions(+), 54 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs delete mode 100644 cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs index afda5889..13fa3380 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -14,6 +14,6 @@ internal sealed class DriveApiClients(HttpClient defaultHttpClient, HttpClient s public ILinksApiClient Links { get; } = new LinksApiClient(defaultHttpClient); public IFoldersApiClient Folders { get; } = new FoldersApiClient(defaultHttpClient); public IFilesApiClient Files { get; } = new FilesApiClient(defaultHttpClient); - public IStorageApiClient Storage { get; } = new StorageApiClient(storageHttpClient); + public IStorageApiClient Storage { get; } = new StorageApiClient(defaultHttpClient, storageHttpClient); public ITrashApiClient Trash { get; } = new TrashApiClient(defaultHttpClient); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index 5af20b9e..d1358495 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -6,9 +6,10 @@ namespace Proton.Drive.Sdk.Api.Storage; -internal sealed class StorageApiClient(HttpClient httpClient) : IStorageApiClient +internal sealed class StorageApiClient(HttpClient defaultHttpClient, HttpClient storageHttpClient) : IStorageApiClient { - private readonly HttpClient _httpClient = httpClient; + private readonly HttpClient _defaultHttpClient = defaultHttpClient; + private readonly HttpClient _storageHttpClient = storageHttpClient; public async ValueTask UploadBlobAsync( string baseUrl, @@ -30,7 +31,7 @@ public async ValueTask UploadBlobAsync( requestMessage.SetRequestType(HttpRequestType.StorageUpload); // TODO: investigate what happens with the stream in case of a retry after a failure, is there a seek back to its beginning? - return await _httpClient + return await _storageHttpClient .Expecting(ProtonApiSerializerContext.Default.ApiResponse) .SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); } @@ -41,10 +42,18 @@ public async ValueTask GetBlobStreamAsync(string baseUrl, string token, requestMessage.Headers.Add("pm-storage-token", token); requestMessage.SetRequestType(HttpRequestType.StorageDownload); - var blobResponse = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + try + { + // Because of HttpCompletionOption.ResponseHeadersRead option, the long timeout is not needed, so we don't use the storage HTTP client + var blobResponse = await _defaultHttpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - await blobResponse.EnsureApiSuccessAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken).ConfigureAwait(false); + await blobResponse.EnsureApiSuccessAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken).ConfigureAwait(false); - return await blobResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return await blobResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException e) when (e.InnerException is TimeoutException) + { + throw new TimeoutException("HTTP request timed out", e); + } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs new file mode 100644 index 00000000..ffafc4c0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs @@ -0,0 +1,141 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace Proton.Drive.Sdk; + +internal static class ExceptionExtensions +{ +#pragma warning disable SA1310 + // ReSharper disable InconsistentNaming + private const int E_FAIL = unchecked((int)0x80004005); + private const int COR_E_IO = unchecked((int)0x80131620); + private const int COR_E_SYSTEM = unchecked((int)0x80131501); + private const int COR_E_EXCEPTION = unchecked((int)0x80131500); + + // ReSharper restore InconsistentNaming +#pragma warning restore SA1310 + + private enum ErrorCodeFormat + { + Decimal, + Hexadecimal, + Adaptive, + } + + public static string FlattenMessage(this Exception exception) + { + var previousMessage = string.Empty; + + return string.Join( + " → ", + EnumerateExceptionHierarchy(exception) + .Select(ex => ex.Message) + .Where(m => + { + if (m == previousMessage) + { + return false; + } + + previousMessage = m; + return true; + })); + } + + public static string FlattenMessageWithExceptionType(this Exception exception) + { + return string.Join( + " → ", + EnumerateExceptionHierarchy(exception) + .Select(GetExceptionTypeAndMessage)); + } + + private static IEnumerable EnumerateExceptionHierarchy(Exception outermostException) + { + for (var e = outermostException; e != null; e = e.InnerException) + { + yield return e; + } + } + + private static string GetExceptionTypeAndMessage(Exception exception) + { + return $"{GetExceptionType(exception)}: {exception.Message}"; + } + + private static string GetExceptionType(Exception exception) + { + var type = exception.GetType(); + var index = type.Name.IndexOf('`'); + var typeName = index <= 0 ? type.Name : type.Name[..index]; + + return exception.TryGetRelevantFormattedErrorCode(out var formattedErrorCode) + ? $"{typeName}({formattedErrorCode})" + : typeName; + } + + public static bool TryGetRelevantFormattedErrorCode(this Exception ex, [MaybeNullWhen(false)] out string formattedErrorCode) + { + return ex switch + { + HttpRequestException httpException + => httpException.StatusCode != null + ? TryFormatEnumValue(httpException.StatusCode.Value, out formattedErrorCode) + : TryFormatEnumValue(httpException.HttpRequestError, out formattedErrorCode), + + HttpIOException httpIoException + => TryFormatEnumValue(httpIoException.HttpRequestError, out formattedErrorCode), + + SocketException socketException + => TryFormatEnumValue(socketException.SocketErrorCode, out formattedErrorCode), + + Win32Exception win32Exception + => TryFormatErrorCode(win32Exception.NativeErrorCode, 0, ErrorCodeFormat.Decimal, out formattedErrorCode), + + IOException + => TryFormatErrorCode(ex.HResult, COR_E_IO, ErrorCodeFormat.Hexadecimal, out formattedErrorCode), + + ExternalException externalException + => TryFormatErrorCode(externalException.ErrorCode, E_FAIL, ErrorCodeFormat.Adaptive, out formattedErrorCode), + + SystemException + => TryFormatErrorCode(ex.HResult, COR_E_SYSTEM, ErrorCodeFormat.Hexadecimal, out formattedErrorCode), + + _ => TryFormatErrorCode(ex.HResult, COR_E_EXCEPTION, ErrorCodeFormat.Hexadecimal, out formattedErrorCode), + }; + + static bool TryFormatErrorCode(int errorCode, int errorCodeToIgnore, ErrorCodeFormat format, [MaybeNullWhen(false)] out string formattedErrorCode) + { + if (errorCode == errorCodeToIgnore) + { + formattedErrorCode = null; + return false; + } + + formattedErrorCode = format switch + { + ErrorCodeFormat.Decimal => errorCode.ToString(), + ErrorCodeFormat.Hexadecimal => $"0x{errorCode:X8}", + _ => IsBetterFormattedAsHex(errorCode) ? $"0x{errorCode:X8}" : errorCode.ToString(), + }; + + return true; + } + + static bool IsBetterFormattedAsHex(int errorCode) + { + // If the first bit is set to 1, it is likely to be the severity bit of an HRESULT which is usually displayed in hex format. + return (errorCode & 0x80000000) != 0; + } + + static bool TryFormatEnumValue(T value, [MaybeNullWhen(false)] out string formattedCode) + where T : struct + { + formattedCode = value.ToString(); + + return formattedCode is not null; + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index 62c38408..a66f73f7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -4,7 +4,6 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Resilience; -using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Download; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 4c78efef..34069e66 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -51,7 +51,7 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action var downloadEvent = new DownloadEvent { ClaimedFileSize = -1, - VolumeType = VolumeType.OwnVolume, + VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type }; try @@ -129,10 +129,10 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt } } } - catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + catch (Exception ex) when (TelemetryErrorResolver.GetDownloadErrorFromException(ex) is { } error) { - downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); - downloadEvent.OriginalError = ex.GetBaseException().ToString(); + downloadEvent.Error = error; + downloadEvent.OriginalError = ex.FlattenMessageWithExceptionType(); throw; } finally @@ -314,10 +314,10 @@ private async Task VerifyManifestAsync(Stream manifestStr return verificationResult.Status; } - [LoggerMessage(Level = LogLevel.Trace, Message = "Missing block #{BlockIndex} on revision \"{RevisionUid}\"")] + [LoggerMessage(Level = LogLevel.Warning, Message = "Missing block #{BlockIndex} on revision \"{RevisionUid}\"")] private partial void LogMissingBlock(int blockIndex, RevisionUid revisionUid); - [LoggerMessage(Level = LogLevel.Trace, Message = "Manifest verification failed for revision \"{RevisionUid}\": {VerificationStatus}")] + [LoggerMessage(Level = LogLevel.Warning, Message = "Manifest verification failed for revision \"{RevisionUid}\": {VerificationStatus}")] private partial void LogFailedManifestVerification(RevisionUid revisionUid, PgpVerificationStatus verificationStatus); private readonly struct BlockDownloadResult(int index, Stream stream, ReadOnlyMemory sha256Digest) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index f03d84cd..f26039a8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -10,7 +10,6 @@ using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Resilience; -using Proton.Sdk; using Proton.Sdk.Addresses; using Proton.Sdk.Drive; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index 5ebae8ab..42fe6632 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -1,6 +1,6 @@ using System.Net; -using System.Net.Sockets; using System.Security.Cryptography; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk; @@ -12,7 +12,13 @@ internal static class TelemetryErrorResolver { return exception switch { + // Not reported as download error + OperationCanceledException => null, + CompletedDownloadManifestVerificationException => null, + + // Download errors NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => DownloadError.IntegrityError, + FileContentsDecryptionException => DownloadError.DecryptionError, CryptographicException => DownloadError.DecryptionError, HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => DownloadError.NetworkError, HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => DownloadError.ServerError, @@ -20,6 +26,7 @@ internal static class TelemetryErrorResolver ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => DownloadError.RateLimited, ProtonApiException { TransportCode: >= 400 and < 500 } => DownloadError.HttpClientSideError, ProtonApiException { TransportCode: >= 500 and < 600 } => DownloadError.ServerError, + TimeoutException => DownloadError.ServerError, _ => DownloadError.Unknown, }; } diff --git a/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs deleted file mode 100644 index 4d2be89f..00000000 --- a/cs/sdk/src/Proton.Sdk/ExceptionExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Proton.Sdk; - -public static class ExceptionExtensions -{ - public static string FlattenMessage(this Exception exception) - { - var previousMessage = string.Empty; - - return string.Join( - " → ", - EnumerateExceptionHierarchy(exception) - .Select(ex => ex.Message) - .Where(m => - { - if (m == previousMessage) - { - return false; - } - - previousMessage = m; - return true; - })); - } - - private static IEnumerable EnumerateExceptionHierarchy(Exception outermostException) - { - for (var e = outermostException; e != null; e = e.InnerException) - { - yield return e; - } - } -} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs index a74a12d8..b5efeeef 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -82,11 +82,18 @@ public async ValueTask DeleteAsync(string requestUri, string sessionId public async ValueTask SendAsync(HttpRequestMessage requestMessage, CancellationToken cancellationToken) { - var responseMessage = await _httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); - - await responseMessage.EnsureApiSuccessAsync(_failureTypeInfo, cancellationToken).ConfigureAwait(false); - - return await responseMessage.Content.ReadFromJsonAsync(_successTypeInfo, cancellationToken) - .ConfigureAwait(false) ?? throw new JsonException(); + try + { + var responseMessage = await _httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + + await responseMessage.EnsureApiSuccessAsync(_failureTypeInfo, cancellationToken).ConfigureAwait(false); + + return await responseMessage.Content.ReadFromJsonAsync(_successTypeInfo, cancellationToken) + .ConfigureAwait(false) ?? throw new JsonException(); + } + catch (OperationCanceledException e) when (e.InnerException is TimeoutException) + { + throw new TimeoutException("HTTP request timed out", e); + } } } From 75f51d1c5746b5994e5bfc4f78015ff075b3edef Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 31 Dec 2025 02:04:07 +0000 Subject: [PATCH 409/791] Expose function to rename node through Swift package --- .../InteropMessageHandler.cs | 3 +++ .../InteropProtonDriveClient.cs | 14 ++++++++++++ cs/sdk/src/protos/proton.drive.sdk.proto | 9 ++++++++ .../Sources/Plumbing/Message+Packaging.swift | 5 +++++ .../Sources/Plumbing/SDKRequestHandler.swift | 8 ++++++- .../ProtonDriveClient/ProtonDriveClient.swift | 22 +++++++++++++++++++ 6 files changed, 60 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 2c6e3454..b4953f35 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -37,6 +37,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientGetAvailableName => await InteropProtonDriveClient.HandleGetAvailableNameAsync(request.DriveClientGetAvailableName).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientRename + => await InteropProtonDriveClient.HandleRenameAsync(request.DriveClientRename).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientGetThumbnails => await InteropProtonDriveClient.HandleGetThumbnailsAsync(request.DriveClientGetThumbnails).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index cbefdf9d..f1895b69 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -161,6 +161,20 @@ public static async ValueTask HandleGetFileDownloaderAsync(DriveClient return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; } + public static async ValueTask HandleRenameAsync(DriveClientRenameRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + await client.RenameNodeAsync( + NodeUid.Parse(request.NodeUid), + request.NewName, + request.NewMediaType, + cancellationToken).ConfigureAwait(false); + return new Empty(); + } + public static IMessage? HandleFree(DriveClientFreeRequest request) { Interop.FreeHandle(request.ClientHandle); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index d9457cb8..699cd66c 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -17,6 +17,7 @@ message Request { DriveClientGetFileDownloaderRequest drive_client_get_file_downloader = 1005; DriveClientGetAvailableNameRequest drive_client_get_available_name = 1006; DriveClientGetThumbnailsRequest drive_client_get_thumbnails = 1007; + DriveClientRenameRequest drive_client_rename = 1008; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -270,6 +271,14 @@ message DriveClientGetAvailableNameRequest { int64 cancellation_token_source_handle = 4; } +message DriveClientRenameRequest { + int64 client_handle = 1; + string node_uid = 2; + string new_name = 3; + string new_media_type = 4; + int64 cancellation_token_source_handle = 5; +} + // The response message must be of type FileThumbnailList. message DriveClientGetThumbnailsRequest { int64 client_handle = 1; diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 1eb1ad44..44b2de7c 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -73,6 +73,11 @@ extension Message { Proton_Drive_Sdk_Request.with { $0.payload = .driveClientGetAvailableName(request) } + + case let request as Proton_Drive_Sdk_DriveClientRenameRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientRename(request) + } case let request as Proton_Drive_Sdk_DriveClientGetThumbnailsRequest: Proton_Drive_Sdk_Request.with { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index dd960b40..264c421c 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -131,7 +131,13 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } voidBox.resume() - + + case .value(let value) where value.isA(Google_Protobuf_Empty.self): + guard let voidResultBox = box as? any Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) + } + voidResultBox.resume(returning: ()) + case .value(let value) where value.isA(Google_Protobuf_BoolValue.self): let unpackedValue = try Google_Protobuf_BoolValue(unpackingAny: value) guard let boolResultBox = box as? any Resumable else { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index dcade97f..9dc9a9bd 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -317,3 +317,25 @@ public actor ProtonDriveClient: Sendable { } } } + +// MARK: - Node action +extension ProtonDriveClient { + public func rename(nodeUid: SDKNodeUid, newName: String, newMediaType: String?) async throws { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + defer { + cancellationTokenSource.free() + } + + let cancellationHandle = cancellationTokenSource.handle + let renameRequest = Proton_Drive_Sdk_DriveClientRenameRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.nodeUid = nodeUid.sdkCompatibleIdentifier + $0.newName = newName + if let newMediaType { + $0.newMediaType = newMediaType + } + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + let result: Void = try await SDKRequestHandler.send(renameRequest, logger: logger) + } +} From 367e49de734760abc8567b9f6453a81b140ea487 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 29 Dec 2025 06:03:20 +0000 Subject: [PATCH 410/791] i18n: Upgrade translations from crowdin (2cb75ecb). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 3 +++ js/sdk/locales/ca_ES.json | 15 +++++++++------ js/sdk/locales/de_DE.json | 3 +++ js/sdk/locales/el_GR.json | 3 +++ js/sdk/locales/es_ES.json | 3 +++ js/sdk/locales/es_LA.json | 3 +++ js/sdk/locales/fr_FR.json | 3 +++ js/sdk/locales/it_IT.json | 3 +++ js/sdk/locales/nl_NL.json | 17 +++++++++++++++++ js/sdk/locales/pt_BR.json | 3 +++ js/sdk/locales/ro_RO.json | 3 +++ js/sdk/locales/sk_SK.json | 3 +++ js/sdk/locales/tr_TR.json | 3 +++ 14 files changed, 60 insertions(+), 7 deletions(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index 34aadc06..fe3de90a 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "cb6edd8bdaffd29c51f6ebbb49e99cd385bd9d0e" + "locale": "563eb3a0ba3b5a70fcbb7d7bcd141992ba2f901c" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 4c7ae314..7290474a 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "Ðе ўдалоÑÑ Ð°Ñ‚Ñ€Ñ‹Ð¼Ð°Ñ†ÑŒ звеÑткі аб абагульванні Ð´Ð»Ñ Ð²ÑƒÐ·Ð»Ð° ${ nodeUid }" ], + "Failed to get verification keys": [ + "Ðе ўдалоÑÑ Ð°Ñ‚Ñ€Ñ‹Ð¼Ð°Ñ†ÑŒ ключы праверкі" + ], "Failed to load some nodes": [ "Ðе ўдалоÑÑ Ð·Ð°Ð³Ñ€ÑƒÐ·Ñ–Ñ†ÑŒ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ Ð²ÑƒÐ·Ð»Ñ‹" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 2cc812c7..c6764d6f 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "No s'ha pogut obtenir la informació de compartició per al node ${ nodeUid }" ], + "Failed to get verification keys": [ + "No s'han pogut obtenir les claus de verificació" + ], "Failed to load some nodes": [ "No s'han pogut carregar alguns nodes" ], @@ -87,7 +90,7 @@ "La descàrrega del fitxer ha fallat a causa d'una resposta buida." ], "File has no active revision": [ - "Aquest fitxer no té cap revisió activa." + "El fitxer no té revisió activa." ], "File has no content key": [ "El fitxer no té clau de contingut" @@ -111,7 +114,7 @@ "Falta el correu electrònic de qui us convida" ], "Missing signature": [ - "No s'ha pogut trobar la signatura" + "No s'ha trobat la signatura" ], "Missing signature for ${ signatureType }": [ "Falta la signatura per a ${ signatureType }" @@ -139,7 +142,7 @@ "No s'ha trobat cap nom disponible" ], "Node has no thumbnail": [ - "Aquest node no té miniatura" + "El node no té miniatura" ], "Node is not a file": [ "El node no és un fitxer" @@ -151,7 +154,7 @@ "Node no compartit" ], "Node not found": [ - "No s'ha pogut trobar el node" + "No s'ha trobat el node" ], "Operation aborted": [ "S'ha interromput l'operació" @@ -166,10 +169,10 @@ "Sol·licitud cancel·lada" ], "Signature is missing": [ - "Hi manca la signatura" + "Falta la signatura" ], "Signature verification failed": [ - "No s'ha pogut verificar la signatura." + "Ha fallat la verificació de la signatura" ], "Signature verification failed: ${ errorMessage }": [ "Hi ha hagut un error en la verificació de la signatura: ${ errorMessage }" diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 59c248c4..b3e31dcf 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "Fehler beim Abrufen der Informationen fürs Teilen beim Knoten ${ nodeUid }" ], + "Failed to get verification keys": [ + "Fehler beim Abrufen der Verifizierungsschlüssel." + ], "Failed to load some nodes": [ "Konnte einige Nodes nicht laden" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index 15ec2c5b..55bbd89f 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "Δεν ήταν δυνατή η λήψη πληÏοφοÏιών κοινοποίησης για τον κόμβο ${ nodeUid }" ], + "Failed to get verification keys": [ + "Αποτυχία λήψης κλειδιών επαλήθευσης" + ], "Failed to load some nodes": [ "Αποτυχία φόÏτωσης κάποιων κόμβων" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 7926db9c..79bcec00 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "No se pudo obtener la información para compartir del nodo ${ nodeUid }" ], + "Failed to get verification keys": [ + "Error al obtener las claves de verificación" + ], "Failed to load some nodes": [ "Error al cargar algunos nodos" ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 32402885..f70ad4b5 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "Error al obtener la información de compartición para el nodo ${ nodeUid }" ], + "Failed to get verification keys": [ + "Error al obtener las claves de verificación" + ], "Failed to load some nodes": [ "Error al cargar algunos nodos" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index 5fdd465b..476f3d22 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "La récupération des informations de partage pour le nÅ“ud ${ nodeUid } n'a pas abouti" ], + "Failed to get verification keys": [ + "Impossible d'obtenir les clés de vérification" + ], "Failed to load some nodes": [ "Le chargement de certains nÅ“uds n'a pas abouti." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index 74f3a33a..2c20fa9b 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "Impossibile ottenere le informazioni di condivisione per il nodo ${ nodeUid }" ], + "Failed to get verification keys": [ + "Impossibile ottenere le chiavi di verifica" + ], "Failed to load some nodes": [ "Impossibile caricare alcuni nodi" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index bd25f0b0..2b86a2a1 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -14,6 +14,12 @@ "Cannot share root folder": [ "Kan hoofdmap niet delen" ], + "Copy operation aborted": [ + "Kopieeractie afgebroken" + ], + "Copying item to a non-folder is not allowed": [ + "Het kopiëren van een item naar een niet-map is niet toegestaan" + ], "Creating files in non-folders is not allowed": [ "Het aanmaken van bestanden in niet-mappen is niet toegestaan" ], @@ -68,6 +74,12 @@ "Failed to get inviter keys": [ "Ophalen van de sleutels van de uitnodiger is niet gelukt." ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Kon de deel informatie voor node ${ nodeUid } niet ophalen." + ], + "Failed to get verification keys": [ + "Fout bij het ophalen van verificatiesleutels" + ], "Failed to load some nodes": [ "Fout bij het laden van sommige nodes" ], @@ -205,6 +217,11 @@ "U kunt alleen een item verlaten dat met u wordt gedeeld" ] }, + "Info": { + "Author is not provided on public link": [ + "Auteur is niet opgegeven op openbare link" + ] + }, "Property": { "attributes": [ "attributen" diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index c9fa0525..e25f8f93 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -74,6 +74,9 @@ "Failed to get inviter keys": [ "Falha ao obter chaves do convidante" ], + "Failed to get verification keys": [ + "Não foi possível obter a chave de verificação" + ], "Failed to load some nodes": [ "Erro ao carregar alguns nós" ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index 4ee80cdd..186cd872 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "Nu s-au putut obÈ›ine informaÈ›ii de partajare pentru nodul ${ nodeUid }." ], + "Failed to get verification keys": [ + "ObÈ›inerea cheilor de verificare a eÈ™uat." + ], "Failed to load some nodes": [ "Nu s-a reuÈ™it încărcarea unor noduri." ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index 82a5ca76..39ed7bc0 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "Nepodarilo sa získaÅ¥ informácie o zdieľaní pre uzol ${ nodeUid }" ], + "Failed to get verification keys": [ + "Získanie overovacích kľúÄov zlyhalo" + ], "Failed to load some nodes": [ "Nepodarilo sa naÄítaÅ¥ niektoré uzly" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index 32246d3d..db1954cd 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -77,6 +77,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "${ nodeUid } düğümünün paylaşım bilgileri alınamadı" ], + "Failed to get verification keys": [ + "DoÄŸrulama anahtarları alınamadı" + ], "Failed to load some nodes": [ "Bazı düğümler yüklenemedi" ], From d86a46b7fb9c7b48bd367fedbe32409ea21cb601 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Oct 2025 14:41:39 +0200 Subject: [PATCH 411/791] Switch to SQLite-free implementation for in-memory caching --- .../InteropProtonDriveClient.cs | 8 +- .../ProtonApiSessionRequestHandler.cs | 12 +- .../Proton.Sdk/Caching/ICacheRepository.cs | 2 +- .../Caching/InMemoryCacheRepository.cs | 222 ++++++++++++++++++ .../Proton.Sdk/ProtonClientConfiguration.cs | 4 +- 5 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index f1895b69..f6ba2b5c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -28,13 +28,13 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction)); - var entityCacheRepository = request.HasEntityCachePath + ICacheRepository entityCacheRepository = request.HasEntityCachePath ? SqliteCacheRepository.OpenFile(request.EntityCachePath) - : SqliteCacheRepository.OpenInMemory(); + : new InMemoryCacheRepository(); - var secretCacheRepository = request.HasSecretCachePath + ICacheRepository secretCacheRepository = request.HasSecretCachePath ? SqliteCacheRepository.OpenFile(request.SecretCachePath) - : SqliteCacheRepository.OpenInMemory(); + : new InMemoryCacheRepository(); ITelemetry telemetry = request.Telemetry.ToTelemetry(bindingsHandle) is { } interopTelemetry ? new DriveInteropTelemetryDecorator(interopTelemetry) diff --git a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs index 6ac1bd2f..7b6e100b 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs @@ -14,13 +14,13 @@ internal static class ProtonApiSessionRequestHandler var telemetry = request.Options.Telemetry.ToTelemetry(bindingsHandle); - var secretCacheRepository = request.HasSecretCachePath + ICacheRepository secretCacheRepository = request.HasSecretCachePath ? SqliteCacheRepository.OpenFile(request.SecretCachePath) - : SqliteCacheRepository.OpenInMemory(); + : new InMemoryCacheRepository(); - var entityCacheRepository = request.Options.HasEntityCachePath + ICacheRepository entityCacheRepository = request.Options.HasEntityCachePath ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) - : SqliteCacheRepository.OpenInMemory(); + : new InMemoryCacheRepository(); var options = new ProtonSessionOptions { @@ -49,9 +49,9 @@ public static IMessage HandleResume(SessionResumeRequest request, nint bindingsH var secretCacheRepository = SqliteCacheRepository.OpenFile(request.SecretCachePath); - var entityCacheRepository = request.Options.HasEntityCachePath + ICacheRepository entityCacheRepository = request.Options.HasEntityCachePath ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) - : SqliteCacheRepository.OpenInMemory(); + : new InMemoryCacheRepository(); var options = new Proton.Sdk.ProtonClientOptions { diff --git a/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs index 924b798a..bd31f444 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs @@ -12,5 +12,5 @@ public interface ICacheRepository : IAsyncDisposable ValueTask TryGetAsync(string key, CancellationToken cancellationToken); - IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken); + IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken = default); } diff --git a/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs new file mode 100644 index 00000000..1959288a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs @@ -0,0 +1,222 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk.Caching; + +internal sealed class InMemoryCacheRepository : ICacheRepository, IDisposable +{ + private readonly ConcurrentDictionary _entries = new(); + private readonly ConcurrentDictionary> _keyToTags = new(); + private readonly ConcurrentDictionary> _tagToKeys = new(); + private readonly ReaderWriterLockSlim _lock = new(); + + IAsyncEnumerable<(string Key, string Value)> ICacheRepository.GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) + { + return GetByTags(tags).ToAsyncEnumerable(); + } + + ValueTask ICacheRepository.SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) + { + Set(key, value, tags, cancellationToken); + + return ValueTask.CompletedTask; + } + + ValueTask ICacheRepository.TryGetAsync(string key, CancellationToken cancellationToken) + { + return ValueTask.FromResult(TryGet(key, out var value) ? value : null); + } + + ValueTask ICacheRepository.RemoveAsync(string key, CancellationToken cancellationToken) + { + Remove(key); + + return ValueTask.CompletedTask; + } + + ValueTask ICacheRepository.RemoveByTagAsync(string tag, CancellationToken cancellationToken) + { + RemoveByTag(tag); + + return ValueTask.CompletedTask; + } + + ValueTask ICacheRepository.ClearAsync() + { + Clear(); + + return ValueTask.CompletedTask; + } + + ValueTask IAsyncDisposable.DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + + public void Set(string key, string value, IEnumerable tags, CancellationToken cancellationToken) + { + _lock.EnterWriteLock(); + try + { + ClearTagsForKey(key); + + _entries[key] = value; + + var newTags = new HashSet(tags); + _keyToTags[key] = newTags; + + foreach (var tag in newTags) + { + _tagToKeys.GetOrAdd(tag, _ => []).Add(key); + } + } + finally + { + _lock.ExitWriteLock(); + } + } + + public bool TryGet(string key, [MaybeNullWhen(false)] out string value) + { + return _entries.TryGetValue(key, out value); + } + + public void Remove(string key) + { + _lock.EnterWriteLock(); + try + { + _entries.TryRemove(key, out _); + + ClearTagsForKey(key); + } + finally + { + _lock.ExitWriteLock(); + } + } + + public void RemoveByTag(string tag) + { + _lock.EnterWriteLock(); + try + { + if (!_tagToKeys.TryGetValue(tag, out var keys)) + { + return; + } + + foreach (var key in keys.Where(key => _entries.TryRemove(key, out _))) + { + ClearTagsForKey(key); + } + } + finally + { + _lock.ExitWriteLock(); + } + } + + public void Clear() + { + _lock.EnterWriteLock(); + try + { + _entries.Clear(); + _keyToTags.Clear(); + _tagToKeys.Clear(); + } + finally + { + _lock.ExitWriteLock(); + } + } + + public IEnumerable<(string Key, string Value)> GetByTags(IEnumerable tags) + { + var tagsList = tags.ToList(); + if (tagsList.Count == 0) + { + yield break; + } + + List<(string Key, string Value)> results; + + _lock.EnterReadLock(); + try + { + HashSet? candidateKeys = null; + + foreach (var tag in tagsList) + { + if (_tagToKeys.TryGetValue(tag, out var keysWithTag)) + { + if (candidateKeys is not null) + { + candidateKeys.IntersectWith(keysWithTag); + } + else + { + candidateKeys = new HashSet(keysWithTag); + } + + if (candidateKeys.Count == 0) + { + yield break; + } + } + else + { + yield break; + } + } + + if (candidateKeys is null) + { + yield break; + } + + results = []; + foreach (var key in candidateKeys) + { + if (_entries.TryGetValue(key, out var value)) + { + results.Add((key, value)); + } + } + } + finally + { + _lock.ExitReadLock(); + } + + foreach (var result in results) + { + yield return result; + } + } + + public void Dispose() + { + _lock.Dispose(); + } + + private void ClearTagsForKey(string key) + { + if (!_keyToTags.TryRemove(key, out var tags)) + { + return; + } + + foreach (var tag in tags) + { + if (_tagToKeys.TryGetValue(tag, out var keys) + && keys.Remove(key) + && keys.Count == 0) + { + _tagToKeys.TryRemove(tag, out _); + } + } + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs index fffcbef3..7e8dfb5b 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs @@ -16,8 +16,8 @@ internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientO : ProtonClientTlsPolicy.Strict; public Func? CustomHttpMessageHandlerFactory { get; } = options?.CustomHttpMessageHandlerFactory; - public ICacheRepository SecretCacheRepository { get; } = options?.SecretCacheRepository ?? SqliteCacheRepository.OpenInMemory(); - public ICacheRepository EntityCacheRepository { get; } = options?.EntityCacheRepository ?? SqliteCacheRepository.OpenInMemory(); + public ICacheRepository SecretCacheRepository { get; } = options?.SecretCacheRepository ?? new InMemoryCacheRepository(); + public ICacheRepository EntityCacheRepository { get; } = options?.EntityCacheRepository ?? new InMemoryCacheRepository(); public ITelemetry Telemetry { get; } = options?.Telemetry ?? NullTelemetry.Instance; public IFeatureFlagProvider FeatureFlagProvider { get; } = options?.FeatureFlagProvider ?? AlwaysDisabledFeatureFlagProvider.Instance; public Uri RefreshRedirectUri { get; } = options?.RefreshRedirectUri ?? ProtonApiDefaults.RefreshRedirectUri; From 7a1139e2886eb5e9aef846b982d5e693b254bd77 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 5 Jan 2026 14:22:30 +0000 Subject: [PATCH 412/791] Fix progress logs in kotlin --- kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 543c7c6c..4732f2fd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -36,7 +36,7 @@ class Uploader internal constructor( onRead = channel::read, onProgress = { progressUpdate -> with(progressUpdate) { - log(DEBUG, "progress: bytesCompleted/bytesInTotal") + log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") progress(bytesCompleted, bytesInTotal) } }, From 53f76fb90612f5098e96ad3189fa77bdaee2a5bc Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 6 Jan 2026 10:24:28 +0100 Subject: [PATCH 413/791] Pause upload on timeout --- .../Api/Storage/StorageApiClient.cs | 6 ++- .../Nodes/Download/RevisionReader.cs | 5 +- .../src/Proton.Drive.Sdk/Nodes/TaskControl.cs | 46 ++++++++++++++++++- .../Nodes/Upload/RevisionWriter.cs | 45 +++++++++--------- .../Nodes/Upload/UploadController.cs | 19 +++++++- .../Telemetry/TelemetryErrorResolver.cs | 23 +++++++--- .../InteropErrorConverter.cs | 2 +- .../src/Proton.Sdk/Http/HttpApiCallBuilder.cs | 9 ++-- 8 files changed, 117 insertions(+), 38 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index d1358495..e335c728 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Net.Mime; +using System.Runtime.ExceptionServices; using Proton.Sdk.Api; using Proton.Sdk.Http; using Proton.Sdk.Serialization; @@ -51,9 +52,10 @@ public async ValueTask GetBlobStreamAsync(string baseUrl, string token, return await blobResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException e) when (e.InnerException is TimeoutException) + catch (OperationCanceledException e) when (e.InnerException is TimeoutException timeoutException) { - throw new TimeoutException("HTTP request timed out", e); + ExceptionDispatchInfo.Capture(timeoutException).Throw(); + throw; // This line is never reached } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 34069e66..dcab654a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -107,7 +107,8 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt { try { - await Task.WhenAll(downloadTasks).ConfigureAwait(false); + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + await Task.WhenAll(downloadTasks).ContinueWith(task => task.Exception?.Handle(_ => true), TaskContinuationOptions.NotOnRanToCompletion).ConfigureAwait(false); } finally { @@ -129,7 +130,7 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt } } } - catch (Exception ex) when (TelemetryErrorResolver.GetDownloadErrorFromException(ex) is { } error) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested && TelemetryErrorResolver.GetDownloadErrorFromException(ex) is { } error) { downloadEvent.Error = error; downloadEvent.OriginalError = ex.FlattenMessageWithExceptionType(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs index 9951d12b..9f12a427 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs @@ -7,6 +7,7 @@ internal sealed class TaskControl(CancellationToken cancellationToken) : ITas private TaskCompletionSource? _resumeSignalSource; private TaskCompletionSource _pauseExceptionSignalSource = new(); private CancellationTokenSource _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + private bool _isDisposed; public bool IsPaused => _resumeSignalSource is { Task.IsCompleted: false } && !IsCanceled; public bool IsCanceled => CancellationToken.IsCancellationRequested; @@ -18,6 +19,11 @@ internal sealed class TaskControl(CancellationToken cancellationToken) : ITas public void Pause() { + if (_isDisposed) + { + return; + } + if (IsPaused) { return; @@ -25,6 +31,11 @@ public void Pause() lock (_pauseLock) { + if (_isDisposed) + { + return; + } + if (IsPaused) { return; @@ -54,6 +65,11 @@ public void PauseOnError(Exception ex) public void Resume() { + if (_isDisposed) + { + return; + } + if (!IsPaused) { return; @@ -61,6 +77,11 @@ public void Resume() lock (_pauseLock) { + if (_isDisposed) + { + return; + } + if (!IsPaused) { return; @@ -103,7 +124,7 @@ public async ValueTask HandlePauseAsync(Func HandlePauseAsync(Func 0) { - try - { - await Task.WhenAll(uploadTasks).ConfigureAwait(false); - } - catch - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw - } - + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + await Task.WhenAll(uploadTasks).ContinueWith(task => task.Exception?.Handle(_ => true), TaskContinuationOptions.NotOnRanToCompletion).ConfigureAwait(false); throw; } } @@ -199,8 +192,14 @@ await _client.Api.Files.UpdateRevisionAsync( } catch (Exception ex) when (!taskControl.IsCanceled) { - uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); - uploadEvent.OriginalError = ex.GetBaseException().ToString(); + var exception = taskControl.PauseExceptionSignal.Exception?.InnerException ?? ex; + + if (TelemetryErrorResolver.GetUploadErrorFromException(exception) is { } uploadError) + { + uploadEvent.Error = uploadError; + uploadEvent.OriginalError = ex.FlattenMessageWithExceptionType(); + } + throw; } finally @@ -237,7 +236,7 @@ private static long ReduceSizePrecision(long size) return 0; } - // We care about very small files in metrics, thus we handle explicitely + // We care about very small files in metrics, thus we handle explicitly // the very small files so they appear correctly in metrics. if (size < 4096) { @@ -259,6 +258,13 @@ private static async ValueTask RegisterNextCompletedBlockAsync(Queue= 400 and < 500 } + and not NodeKeyAndSessionKeyMismatchException + and not SessionKeyAndDataPacketMismatchException; + } + private async ValueTask UploadContentBlockAsync( int index, Stream plainDataStream, @@ -306,13 +312,6 @@ private async ValueTask UploadContentBlockAsync( } } } - - static bool IsResumableError(Exception ex) - { - return ex is not ProtonApiException { TransportCode: > 400 and < 500 } - and not NodeKeyAndSessionKeyMismatchException - and not SessionKeyAndDataPacketMismatchException; - } } private async ValueTask UploadThumbnailBlockAsync(Thumbnail thumbnail, TaskControl taskControl) @@ -327,7 +326,7 @@ private async ValueTask UploadThumbnailBlockAsync(Thumbnail t _membershipAddress.Id, thumbnail, ct), - exceptionTriggersPause: _ => true).ConfigureAwait(false); + exceptionTriggersPause: IsResumableError).ConfigureAwait(false); } finally { @@ -347,6 +346,10 @@ private async ValueTask UploadThumbnailBlockAsync(Thumbnail t int prefixLength, TaskControl taskControl) { + // Prevent reading source content stream while paused. If pausing happens when execution has already passed further, + // content stream might be read after pausing is signaled to the external observer. + await taskControl.WaitWhilePausedAsync().ConfigureAwait(false); + var plainDataPrefixBuffer = ArrayPool.Shared.Rent(prefixLength); try { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index fc9f2113..9a03e1eb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -56,7 +56,7 @@ public async ValueTask DisposeAsync() { try { - var draftExists = _revisionUidTask.IsCompletedSuccessfully; + var draftExists = _revisionUidTask.IsCompletedSuccessfully && !_uploadTask.IsCompletedSuccessfully; if (!draftExists) { return; @@ -75,7 +75,24 @@ public async ValueTask DisposeAsync() } finally { + var uploadTaskIsCompleted = _uploadTask.IsCompleted; + _taskControl.Dispose(); + + // If the upload task is not yet completed, disposal of task control unblocks it from being paused. + // The unblocked upload task will complete unsuccessfully (either in faulted or cancelled state). + if (!uploadTaskIsCompleted) + { + try + { + await _uploadTask.ConfigureAwait(false); + } + catch + { + // Upon upload controller disposal, the upload task is not expected to be observed, + // so we catch here to prevent escalation of unhandled exception. + } + } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index 42fe6632..f53de33b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -12,20 +12,24 @@ internal static class TelemetryErrorResolver { return exception switch { - // Not reported as download error - OperationCanceledException => null, + // Reported as download success CompletedDownloadManifestVerificationException => null, // Download errors NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => DownloadError.IntegrityError, FileContentsDecryptionException => DownloadError.DecryptionError, CryptographicException => DownloadError.DecryptionError, + HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => DownloadError.NetworkError, HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => DownloadError.ServerError, - ProtonApiException { TransportCode: (int)HttpStatusCode.RequestTimeout } => DownloadError.ServerError, + HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => DownloadError.ServerError, + HttpRequestException { StatusCode: >= (HttpStatusCode)400 and < (HttpStatusCode)500 } => DownloadError.HttpClientSideError, + HttpRequestException { StatusCode: >= (HttpStatusCode)500 and < (HttpStatusCode)600 } => DownloadError.ServerError, + ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => DownloadError.RateLimited, ProtonApiException { TransportCode: >= 400 and < 500 } => DownloadError.HttpClientSideError, - ProtonApiException { TransportCode: >= 500 and < 600 } => DownloadError.ServerError, + + // TODO: How to better distinguish network errors, that were subject to retry in the HTTP request handler, but resulted in TimeoutException? TimeoutException => DownloadError.ServerError, _ => DownloadError.Unknown, }; @@ -35,13 +39,20 @@ internal static class TelemetryErrorResolver { return exception switch { + // Upload errors NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => UploadError.IntegrityError, + HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => UploadError.NetworkError, HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => UploadError.ServerError, - ProtonApiException { TransportCode: (int)HttpStatusCode.RequestTimeout } => UploadError.ServerError, + HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => UploadError.ServerError, + HttpRequestException { StatusCode: >= (HttpStatusCode)400 and < (HttpStatusCode)500 } => UploadError.HttpClientSideError, + HttpRequestException { StatusCode: >= (HttpStatusCode)500 and < (HttpStatusCode)600 } => UploadError.ServerError, + ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => UploadError.RateLimited, ProtonApiException { TransportCode: >= 400 and < 500 } => UploadError.HttpClientSideError, - ProtonApiException { TransportCode: >= 500 and < 600 } => UploadError.ServerError, + + // TODO: How to better distinguish network errors, that were subject to retry in the HTTP request handler, but resulted in TimeoutException? + TimeoutException => UploadError.ServerError, _ => UploadError.Unknown, }; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs index 32d3a381..527b715c 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs @@ -37,7 +37,7 @@ public static void SetDomainAndCodes(Error error, Exception exception) error.SecondaryCode = ex.StatusCode is not null ? (long)ex.StatusCode : 0; break; - case TimeoutRejectedException: + case TimeoutException or TimeoutRejectedException: error.Domain = ErrorDomain.Transport; error.PrimaryCode = (long)HttpRequestError.ConnectionError; break; diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs index b5efeeef..d956a215 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -1,4 +1,6 @@ -using System.Net.Http.Json; +using System; +using System.Net.Http.Json; +using System.Runtime.ExceptionServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Proton.Sdk.Api; @@ -91,9 +93,10 @@ public async ValueTask SendAsync(HttpRequestMessage requestMessage, Ca return await responseMessage.Content.ReadFromJsonAsync(_successTypeInfo, cancellationToken) .ConfigureAwait(false) ?? throw new JsonException(); } - catch (OperationCanceledException e) when (e.InnerException is TimeoutException) + catch (OperationCanceledException e) when (e.InnerException is TimeoutException timeoutException) { - throw new TimeoutException("HTTP request timed out", e); + ExceptionDispatchInfo.Capture(timeoutException).Throw(); + throw; // This line is never reached } } } From 5a42ac3fc3fe43886fecf1cf4f88d71c2254250d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 6 Jan 2026 11:00:59 +0100 Subject: [PATCH 414/791] Fix buffered seekable stream --- .../internal/download/seekableStream.test.ts | 92 ++++++++++++++++++- .../src/internal/download/seekableStream.ts | 66 ++++++------- 2 files changed, 125 insertions(+), 33 deletions(-) diff --git a/js/sdk/src/internal/download/seekableStream.test.ts b/js/sdk/src/internal/download/seekableStream.test.ts index 672793af..d045474b 100644 --- a/js/sdk/src/internal/download/seekableStream.test.ts +++ b/js/sdk/src/internal/download/seekableStream.test.ts @@ -1,4 +1,4 @@ -import { SeekableReadableStream, BufferedSeekableStream } from './seekableStream'; +import { SeekableReadableStream, BufferedSeekableStream, UnderlyingSeekableSource } from './seekableStream'; describe('SeekableReadableStream', () => { it('should call the seek callback when seek is called', async () => { @@ -32,6 +32,7 @@ describe('SeekableReadableStream', () => { describe('BufferedSeekableStream', () => { let startWithCloseMock: jest.Mock; let pullMock: jest.Mock; + let seekableSource: UnderlyingSeekableSource; const data1 = new Uint8Array([1, 2, 3, 4, 5]); const data2 = new Uint8Array([6, 7, 8, 9, 10]); @@ -53,6 +54,26 @@ describe('BufferedSeekableStream', () => { } readIndex++; }); + + // Simulates a real seekable source where seek repositions the read pointer + const fileData = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); + let readPosition = 0; + const chunkSize = 5; + + seekableSource = { + pull: jest.fn().mockImplementation((controller) => { + if (readPosition >= fileData.length) { + controller.close(); + return; + } + const chunk = fileData.slice(readPosition, readPosition + chunkSize); + readPosition += chunk.length; + controller.enqueue(chunk); + }), + seek: jest.fn().mockImplementation((position: number) => { + readPosition = position; + }), + }; }); it('should throw error if highWaterMark is not 0', () => { @@ -219,4 +240,73 @@ describe('BufferedSeekableStream', () => { await expect(stream.seek(0)).rejects.toThrow(customError); }); + + it('should not call underlying seek when seeking within buffer range', async () => { + const seekMock = jest.fn(); + const stream = new BufferedSeekableStream({ + pull: pullMock, + seek: seekMock, + }); + + await stream.read(2); + expect(seekMock).not.toHaveBeenCalled(); + + // Seek within buffer range (buffer has bytes for positions 2-4) + await stream.seek(3); + expect(seekMock).not.toHaveBeenCalled(); + + // Seek to current position (still within buffer) + await stream.seek(3); + expect(seekMock).not.toHaveBeenCalled(); + }); + + it('should not corrupt data when seeking to current position with seekable underlying source', async () => { + const stream = new BufferedSeekableStream(seekableSource); + + // Read first 3 bytes [0, 1, 2], buffer will have [0, 1, 2, 3, 4] + const result1 = await stream.read(3); + expect(result1.value).toEqual(new Uint8Array([0, 1, 2])); + expect(seekableSource.seek).not.toHaveBeenCalled(); + + // Seek to position 3 (current position), should use buffer without seeking underlying source + await stream.seek(3); + expect(seekableSource.seek).not.toHaveBeenCalled(); + + // Buffer has [3, 4], needs 2 more from underlying source + // Underlying source stays at position 5, giving [5, 6, 7, 8, 9] + // Buffer becomes [3, 4, 5, 6, 7, 8, 9] + const result2 = await stream.read(4); + expect(result2.value).toEqual(new Uint8Array([3, 4, 5, 6])); + expect(seekableSource.seek).not.toHaveBeenCalled(); + + // Continue reading to verify stream integrity + const result3 = await stream.read(3); + expect(result3.value).toEqual(new Uint8Array([7, 8, 9])); + }); + + it('should call underlying seek only when seeking outside buffer range', async () => { + const stream = new BufferedSeekableStream(seekableSource); + + // Read first 3 bytes [0, 1, 2], buffer will have [0, 1, 2, 3, 4] + await stream.read(3); + expect(seekableSource.seek).not.toHaveBeenCalled(); + + // Seek backward (outside buffer range) - should call underlying seek + await stream.seek(0); + expect(seekableSource.seek).toHaveBeenCalledWith(0); + expect(seekableSource.seek).toHaveBeenCalledTimes(1); + + // Read and verify data is correct after backward seek + const result1 = await stream.read(3); + expect(result1.value).toEqual(new Uint8Array([0, 1, 2])); + + // Seek forward past buffer end - should call underlying seek + await stream.seek(10); + expect(seekableSource.seek).toHaveBeenCalledWith(10); + expect(seekableSource.seek).toHaveBeenCalledTimes(2); + + // Read and verify data is correct after forward seek + const result2 = await stream.read(3); + expect(result2.value).toEqual(new Uint8Array([10, 11, 12])); + }); }); diff --git a/js/sdk/src/internal/download/seekableStream.ts b/js/sdk/src/internal/download/seekableStream.ts index 8bd5d684..5e0bc63b 100644 --- a/js/sdk/src/internal/download/seekableStream.ts +++ b/js/sdk/src/internal/download/seekableStream.ts @@ -1,4 +1,4 @@ -interface UnderlyingSeekableSource extends UnderlyingDefaultSource { +export interface UnderlyingSeekableSource extends UnderlyingDefaultSource { seek: (position: number) => void | Promise; } @@ -22,7 +22,10 @@ interface UnderlyingSeekableSource extends UnderlyingDefaultSource { export class SeekableReadableStream extends ReadableStream { private seekCallback: (position: number) => void | Promise; - constructor({ seek, ...underlyingSource }: UnderlyingSeekableSource, queuingStrategy?: QueuingStrategy) { + constructor( + { seek, ...underlyingSource }: UnderlyingSeekableSource, + queuingStrategy?: QueuingStrategy, + ) { super(underlyingSource, queuingStrategy); this.seekCallback = seek; } @@ -160,42 +163,41 @@ export class BufferedSeekableStream extends SeekableReadableStream { async seek(position: number): Promise { const endOfBufferPosition = this.currentPosition + (this.buffer.length - this.bufferPosition); - if (position > endOfBufferPosition) { + if (position > endOfBufferPosition || position < this.currentPosition) { this.buffer = new Uint8Array(0); this.bufferPosition = 0; - } else if (position < this.currentPosition) { - this.buffer = new Uint8Array(0); - this.bufferPosition = 0; - } else { - this.bufferPosition += position - this.currentPosition; - } - await super.seek(position); - - if (this.reader) { - try { - this.reader.releaseLock(); - } catch (error) { - // Streams API spec-compliant behavior: releaseLock() only throws TypeError when - // there are pending read requests. This can occur due to timing differences between - // when read() promises resolve on the client side vs when the browser's internal - // stream mechanism fully completes. - // - // This manifests more frequently in Firefox than Chrome due to implementation - // timing differences, but both are following the spec correctly. - // - // References: - // - https://github.com/whatwg/streams/issues/1000 - // - https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/releaseLock - // - // Safe to ignore since we're acquiring a new reader immediately after. - if (!(error instanceof TypeError)) { - throw error; + await super.seek(position); + + if (this.reader) { + try { + this.reader.releaseLock(); + } catch (error) { + // Streams API spec-compliant behavior: releaseLock() only throws TypeError when + // there are pending read requests. This can occur due to timing differences between + // when read() promises resolve on the client side vs when the browser's internal + // stream mechanism fully completes. + // + // This manifests more frequently in Firefox than Chrome due to implementation + // timing differences, but both are following the spec correctly. + // + // References: + // - https://github.com/whatwg/streams/issues/1000 + // - https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/releaseLock + // + // Safe to ignore since we're acquiring a new reader immediately after. + if (!(error instanceof TypeError)) { + throw error; + } } } + this.reader = super.getReader(); + this.streamClosed = false; + } else { + // Position is within buffer range, just update buffer position. + this.bufferPosition += position - this.currentPosition; } - this.reader = super.getReader(); - this.streamClosed = false; + this.currentPosition = position; } } From 6a043c1f68bb7f48989c15cda06d0e3f8f19fa2e Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 6 Jan 2026 12:32:31 +0000 Subject: [PATCH 415/791] Map download integrity exception to integrity domain for interop --- .../InteropDriveErrorConverter.cs | 8 ++++++++ .../CompletedDownloadManifestVerificationException.cs | 2 +- .../{ => Nodes/Download}/DataIntegrityException.cs | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) rename cs/sdk/src/Proton.Drive.Sdk/{ => Nodes/Download}/CompletedDownloadManifestVerificationException.cs (82%) rename cs/sdk/src/Proton.Drive.Sdk/{ => Nodes/Download}/DataIntegrityException.cs (78%) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs index 9d92a9a7..c84eced6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -13,6 +13,8 @@ internal static class InteropDriveErrorConverter private const int NodeMetadataDecryptionErrorPrimaryCode = 2; private const int FileContentsDecryptionErrorPrimaryCode = 3; private const int UploadKeyMismatchErrorPrimaryCode = 4; + private const int ManifestSignatureVerificationErrorPrimaryCode = 5; + private const int ContentUploadIntegrityErrorPrimaryCode = 6; public static void SetDomainAndCodes(Error error, Exception exception) { @@ -35,8 +37,14 @@ public static void SetDomainAndCodes(Error error, Exception exception) error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode; break; + case DataIntegrityException: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ManifestSignatureVerificationErrorPrimaryCode; + break; + case IntegrityException: error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; break; case NodeWithSameNameExistsException e: diff --git a/cs/sdk/src/Proton.Drive.Sdk/CompletedDownloadManifestVerificationException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs similarity index 82% rename from cs/sdk/src/Proton.Drive.Sdk/CompletedDownloadManifestVerificationException.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs index 439c48ae..33ecfeb3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/CompletedDownloadManifestVerificationException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs @@ -1,4 +1,4 @@ -namespace Proton.Drive.Sdk; +namespace Proton.Drive.Sdk.Nodes.Download; internal sealed class CompletedDownloadManifestVerificationException : Exception { diff --git a/cs/sdk/src/Proton.Drive.Sdk/DataIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DataIntegrityException.cs similarity index 78% rename from cs/sdk/src/Proton.Drive.Sdk/DataIntegrityException.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DataIntegrityException.cs index 77a61285..6bee8a82 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/DataIntegrityException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DataIntegrityException.cs @@ -1,4 +1,4 @@ -namespace Proton.Drive.Sdk; +namespace Proton.Drive.Sdk.Nodes.Download; public sealed class DataIntegrityException : ProtonDriveException { From bfe039ff7e7520e2f1eb8adca46a15db6eea33fc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 06:18:12 +0000 Subject: [PATCH 416/791] Handle timeouts during uploads --- .../internal/apiService/apiService.test.ts | 14 +- js/sdk/src/internal/apiService/apiService.ts | 9 +- js/sdk/src/internal/photos/index.ts | 4 +- js/sdk/src/internal/upload/index.ts | 8 +- js/sdk/src/internal/upload/queue.test.ts | 130 ++++++++++++++++++ js/sdk/src/internal/upload/queue.ts | 43 ++++-- .../internal/upload/streamUploader.test.ts | 87 +++++++++++- js/sdk/src/internal/upload/streamUploader.ts | 34 ++++- js/sdk/src/internal/upload/telemetry.ts | 5 +- 9 files changed, 304 insertions(+), 30 deletions(-) create mode 100644 js/sdk/src/internal/upload/queue.test.ts diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 24cd0590..ad89148d 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -97,7 +97,7 @@ describe('DriveAPIService', () => { // @ts-expect-error: Fetch is mock. const request = httpClient.fetchBlob.mock.calls[0][0]; expect(request.method).toEqual(method); - expect(request.timeoutMs).toEqual(90000); + expect(request.timeoutMs).toEqual(600_000); expect(Array.from(request.headers.entries())).toEqual( Array.from( new Headers({ @@ -250,6 +250,17 @@ describe('DriveAPIService', () => { }); describe('should handle subsequent errors', () => { + it('limit timeout errors', async () => { + const error = new Error('TimeoutError'); + error.name = 'TimeoutError'; + + httpClient.fetchJson = jest.fn().mockRejectedValue(error); + + await expect(api.get('test')).rejects.toThrow(error); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); + expectSDKEvents(); + }); + it('limit 429 errors', async () => { httpClient.fetchJson = jest .fn() @@ -363,6 +374,5 @@ describe('DriveAPIService', () => { await promise; }); - }); }); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 58179404..14b9c55f 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -16,7 +16,12 @@ const DEFAULT_TIMEOUT_MS = 30000; /** * The default timeout in milliseconds for all storage requests (file content). */ -const DEFAULT_STORAGE_TIMEOUT_MS = 90000; +const DEFAULT_STORAGE_TIMEOUT_MS = 600_000; + +/** + * Maximum number of retry attempts for a timeout error. + */ +const MAX_TIMEOUT_ERROR_RETRY_ATTEMPTS = 3; /** * How many subsequent 429 errors are allowed before we stop further requests. @@ -280,7 +285,7 @@ export class DriveAPIService { return this.fetch(request, callback, attempt + 1); } - if (error.name === 'TimeoutError') { + if (error.name === 'TimeoutError' && attempt + 1 < MAX_TIMEOUT_ERROR_RETRY_ATTEMPTS) { this.logger.warn(`${request.method} ${request.url}: Timeout error, retrying`); await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); return this.fetch(request, callback, attempt + 1); diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 4690de8a..7f081b31 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -159,10 +159,10 @@ export function initPhotoUploadModule( metadata: PhotoUploadMetadata, signal?: AbortSignal, ): Promise { - await queue.waitForCapacity(signal); + await queue.waitForCapacity(metadata.expectedSize, signal); const onFinish = () => { - queue.releaseCapacity(); + queue.releaseCapacity(metadata.expectedSize); }; return new PhotoFileUploader( diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 0abfcb2d..dbfdd90b 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -45,10 +45,10 @@ export function initUploadModule( metadata: UploadMetadata, signal?: AbortSignal, ): Promise { - await queue.waitForCapacity(signal); + await queue.waitForCapacity(metadata.expectedSize, signal); const onFinish = () => { - queue.releaseCapacity(); + queue.releaseCapacity(metadata.expectedSize); }; return new FileUploader( @@ -76,10 +76,10 @@ export function initUploadModule( metadata: UploadMetadata, signal?: AbortSignal, ): Promise { - await queue.waitForCapacity(signal); + await queue.waitForCapacity(metadata.expectedSize, signal); const onFinish = () => { - queue.releaseCapacity(); + queue.releaseCapacity(metadata.expectedSize); }; return new FileRevisionUploader( diff --git a/js/sdk/src/internal/upload/queue.test.ts b/js/sdk/src/internal/upload/queue.test.ts new file mode 100644 index 00000000..6f7b4293 --- /dev/null +++ b/js/sdk/src/internal/upload/queue.test.ts @@ -0,0 +1,130 @@ +import { AbortError } from '../../errors'; +import { UploadQueue } from './queue'; +import { FILE_CHUNK_SIZE } from './streamUploader'; + +describe('UploadQueue', () => { + let queue: UploadQueue; + + beforeEach(() => { + queue = new UploadQueue(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should resolve immediately when queue is empty', async () => { + const promise = queue.waitForCapacity(0); + await promise; + }); + + it('should resolve immediately when under file upload limit', async () => { + // Fill queue with 4 uploads (limit is 5) + for (let i = 0; i < 4; i++) { + await queue.waitForCapacity(0); + } + + const promise = queue.waitForCapacity(0); + await promise; + }); + + it('should wait when max concurrent file uploads is reached', async () => { + // Fill queue to max (5 uploads) + for (let i = 0; i < 5; i++) { + await queue.waitForCapacity(0); + } + + let resolved = false; + const promise = queue.waitForCapacity(0).then(() => { + resolved = true; + }); + + await jest.advanceTimersByTimeAsync(100); + expect(resolved).toBe(false); + + queue.releaseCapacity(0); + + await jest.advanceTimersByTimeAsync(100); + await promise; + expect(resolved).toBe(true); + }); + + it('should wait when max concurrent upload size is reached', async () => { + // Fill queue with one large file that exceeds size limit + const largeSize = 10 * FILE_CHUNK_SIZE; + await queue.waitForCapacity(largeSize); + + let resolved = false; + const promise = queue.waitForCapacity(0).then(() => { + resolved = true; + }); + + await jest.advanceTimersByTimeAsync(100); + expect(resolved).toBe(false); + + queue.releaseCapacity(largeSize); + + await jest.advanceTimersByTimeAsync(100); + await promise; + expect(resolved).toBe(true); + }); + + it('should track expected size correctly', async () => { + const size1 = 5 * FILE_CHUNK_SIZE; + const size2 = 4 * FILE_CHUNK_SIZE; + + await queue.waitForCapacity(size1); + await queue.waitForCapacity(size2); + + // Total is 9 * FILE_CHUNK_SIZE, limit is 10 * FILE_CHUNK_SIZE + // So next upload should still be allowed immediately + const promise = queue.waitForCapacity(3 * FILE_CHUNK_SIZE); + await promise; + + // But now we're at limit, next one should wait + let resolved = false; + const waitingPromise = queue.waitForCapacity(0).then(() => { + resolved = true; + }); + + await jest.advanceTimersByTimeAsync(100); + expect(resolved).toBe(false); + + queue.releaseCapacity(size1); + await jest.advanceTimersByTimeAsync(100); + await waitingPromise; + expect(resolved).toBe(true); + }); + + it('should reject when signal is aborted', async () => { + // Fill queue to max + for (let i = 0; i < 5; i++) { + await queue.waitForCapacity(0); + } + + const controller = new AbortController(); + const promise = queue.waitForCapacity(0, controller.signal); + + controller.abort(); + + // Attach rejection handler BEFORE advancing timers to avoid unhandled rejection + const expectation = expect(promise).rejects.toThrow(AbortError); + await jest.advanceTimersByTimeAsync(50); + await expectation; + }); + + it('should reject immediately if signal is already aborted', async () => { + // Fill queue to max + for (let i = 0; i < 5; i++) { + await queue.waitForCapacity(0); + } + + const controller = new AbortController(); + controller.abort(); + + const promise = queue.waitForCapacity(0, controller.signal); + await expect(promise).rejects.toThrow(AbortError); + }); +}); + diff --git a/js/sdk/src/internal/upload/queue.ts b/js/sdk/src/internal/upload/queue.ts index fedae10a..32143586 100644 --- a/js/sdk/src/internal/upload/queue.ts +++ b/js/sdk/src/internal/upload/queue.ts @@ -1,4 +1,23 @@ import { waitForCondition } from '../wait'; +import { FILE_CHUNK_SIZE } from './streamUploader'; + +/** + * Maximum number of concurrent file uploads. + * + * It avoids uploading too many files at the same time. The total file size + * below also limits that, but if the file is empty, we still need to make + * a reasonable number of requests. + */ +const MAX_CONCURRENT_FILE_UPLOADS = 5; + +/** + * Maximum total file size that can be uploaded concurrently. + * + * It avoids uploading too many blocks at the same time, ensuring that on poor + * connection we don't do too many things at the same time that all fail due + * to network issues. + */ +const MAX_CONCURRENT_UPLOAD_SIZE = 10 * FILE_CHUNK_SIZE; /** * A queue that limits the number of concurrent uploads. @@ -14,18 +33,24 @@ import { waitForCondition } from '../wait'; * uploaded. That is something we want to add in the future to be * more performant for many small file uploads. */ -const MAX_CONCURRENT_UPLOADS = 5; - export class UploadQueue { - private capacity = 0; + private totalFileUploads = 0; + + private totalExpectedSize = 0; - // TODO: use expected size to control the size of the queue - async waitForCapacity(signal?: AbortSignal) { - await waitForCondition(() => this.capacity < MAX_CONCURRENT_UPLOADS, signal); - this.capacity++; + async waitForCapacity(expectedSize: number, signal?: AbortSignal) { + await waitForCondition( + () => + this.totalFileUploads < MAX_CONCURRENT_FILE_UPLOADS && + this.totalExpectedSize < MAX_CONCURRENT_UPLOAD_SIZE, + signal, + ); + this.totalFileUploads++; + this.totalExpectedSize += expectedSize; } - releaseCapacity() { - this.capacity--; + releaseCapacity(expectedSize: number) { + this.totalFileUploads--; + this.totalExpectedSize -= expectedSize; } } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 387d068d..fb5e6ac9 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -1,5 +1,6 @@ -import { Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; +import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; import { IntegrityError } from '../../errors'; +import { getMockLogger } from '../../tests/logger'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; import { FILE_CHUNK_SIZE, StreamUploader } from './streamUploader'; import { UploadTelemetry } from './telemetry'; @@ -40,6 +41,7 @@ function mockUploadBlock( } describe('StreamUploader', () => { + let logger: Logger; let telemetry: UploadTelemetry; let apiService: jest.Mocked; let cryptoService: UploadCryptoService; @@ -54,14 +56,11 @@ describe('StreamUploader', () => { let uploader: StreamUploader; beforeEach(() => { + logger = getMockLogger(); + // @ts-expect-error No need to implement all methods for mocking telemetry = { - getLoggerForRevision: jest.fn().mockReturnValue({ - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }), + getLoggerForRevision: jest.fn().mockReturnValue(logger), logBlockVerificationError: jest.fn(), uploadFailed: jest.fn(), uploadFinished: jest.fn(), @@ -403,6 +402,80 @@ describe('StreamUploader', () => { await verifyOnProgress([1024, 4 * 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024]); }); + it('should handle timeout when uploading block', async () => { + const error = new Error('TimeoutError'); + error.name = 'TimeoutError'; + + let count = 0; + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:1' && count === 0) { + count++; + throw error; + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + expect((uploader as any).maxUploadingBlocks).toEqual(5); + await verifySuccess(); + expect(apiService.requestBlockUpload).toHaveBeenCalledTimes(1); + // 3 blocks + 1 timeout retry + 1 thumbnail + expect(apiService.uploadBlock).toHaveBeenCalledTimes(5); + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + 'block 1:token/block:1: Upload timeout, limiting upload capacity to 1 block', + ); + expect(logger.warn).toHaveBeenCalledWith('block 1:token/block:1: Upload timeout, retrying'); + expect((uploader as any).maxUploadingBlocks).toEqual(1); + }); + + it('limitUploadCapacity should wait for the previous blocks to finish', async () => { + const error = new Error('TimeoutError'); + error.name = 'TimeoutError'; + + const events: string[] = []; + let block1Resolver: (() => void) | undefined; + let block2FirstAttempt = true; + + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:1') { + events.push('block1:upload:start'); + await new Promise((resolve) => { + block1Resolver = resolve; + }); + events.push('block1:upload:end'); + return mockUploadBlock(bareUrl, token, block, onProgress); + } + if (token === 'token/block:2') { + if (block2FirstAttempt) { + block2FirstAttempt = false; + events.push('block2:timeout'); + // Resolve block 1 after a small delay to simulate real-world conditions + setTimeout(() => block1Resolver?.(), 100); + throw error; + } + events.push('block2:retry'); + return mockUploadBlock(bareUrl, token, block, onProgress); + } + // Block 3 and thumbnails proceed normally + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + await verifySuccess(); + + expect(events).toMatchObject([ + 'block1:upload:start', + 'block2:timeout', + 'block1:upload:end', + 'block2:retry', + ]); + + // Also verify the warning messages were logged + expect(logger.warn).toHaveBeenCalledWith( + 'block 2:token/block:2: Upload timeout, limiting upload capacity to 1 block', + ); + expect(logger.warn).toHaveBeenCalledWith('block 2:token/block:2: Upload timeout, retrying'); + }); + it('should handle expired token when uploading block', async () => { let count = 0; apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 44c92b38..dd1e8584 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -55,6 +55,8 @@ const MAX_BLOCK_UPLOAD_RETRIES = 3; * that the upload process is efficient and does not overload the server. */ export class StreamUploader { + protected maxUploadingBlocks = MAX_UPLOADING_BLOCKS; + protected logger: Logger; protected digests: UploadDigests; @@ -67,6 +69,7 @@ export class StreamUploader { protected ongoingUploads = new Map< string, { + index?: number; uploadPromise: Promise; encryptedBlock: EncryptedBlock | EncryptedThumbnail; } @@ -376,6 +379,7 @@ export class StreamUploader { const uploadKey = `block:${blockToken.index}`; this.ongoingUploads.set(uploadKey, { + index: blockToken.index, uploadPromise: this.uploadBlock(blockToken, encryptedBlock, onProgress).finally(() => { this.ongoingUploads.delete(uploadKey); @@ -500,6 +504,13 @@ export class StreamUploader { blockProgress = 0; } + if (error instanceof Error && error.name === 'TimeoutError') { + logger.warn(`Upload timeout, limiting upload capacity to 1 block`); + await this.limitUploadCapacity(uploadToken.index); + logger.warn(`Upload timeout, retrying`); + continue; + } + if ( (error instanceof APIHTTPError && error.statusCode === HTTPErrorCode.NOT_FOUND) || error instanceof NotFoundAPIError @@ -542,6 +553,27 @@ export class StreamUploader { logger.info(`Uploaded`); } + private async limitUploadCapacity(index: number) { + this.maxUploadingBlocks = 1; + + // This ensures that when the upload is downscaled, all ongoing block + // uploads are waiting for their turn one by one. + try { + await waitForCondition(() => { + const ongoingIndexes = Array.from(this.ongoingUploads.values()) + .map(({ index: ongoingIndex }) => ongoingIndex) + .filter((ongoingIndex) => ongoingIndex !== undefined); + ongoingIndexes.sort((a, b) => a - b); + return ongoingIndexes[0] === index; + }, this.abortController.signal); + } catch (error: unknown) { + if (error instanceof AbortError) { + return; + } + throw error; + } + } + private async waitForBufferCapacity() { if (this.encryptedBlocks.size >= MAX_BUFFERED_BLOCKS) { try { @@ -559,7 +591,7 @@ export class StreamUploader { } private async waitForUploadCapacityAndBufferedBlocks() { - while (this.ongoingUploads.size >= MAX_UPLOADING_BLOCKS) { + while (this.ongoingUploads.size >= this.maxUploadingBlocks) { await Promise.race(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); } try { diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 7a24d4ad..0ece4d6f 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -13,13 +13,12 @@ export class UploadTelemetry { private sharesService: SharesService, ) { this.telemetry = telemetry; - this.logger = this.telemetry.getLogger('download'); + this.logger = this.telemetry.getLogger('upload'); this.sharesService = sharesService; } getLoggerForRevision(revisionUid: string) { - const logger = this.telemetry.getLogger('upload'); - return new LoggerWithPrefix(logger, `revision ${revisionUid}`); + return new LoggerWithPrefix(this.logger, `revision ${revisionUid}`); } logBlockVerificationError(retryHelped: boolean) { From f722bf3a04b21dea2669fd42fae055a85147f555 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 07:22:01 +0100 Subject: [PATCH 417/791] js/v0.9.1 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index dff45ec8..f5c4fdec 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.9.0", + "version": "0.9.1", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index cdd43e4a..36705904 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.0", + "version": "0.9.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 463e7f84b9f198e2153ab449a63d88ee6227608b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 12:57:12 +0100 Subject: [PATCH 418/791] Interop and bindings for DownloadController.GetIsDownloadCompleteWithVerificationIssue --- .../InteropDownloadController.cs | 7 ++ .../InteropMessageHandler.cs | 3 + cs/sdk/src/protos/proton.drive.sdk.proto | 40 +++++++----- .../me/proton/drive/sdk/DownloadController.kt | 5 ++ .../drive/sdk/converter/BooleanConverter.kt | 10 +++ .../sdk/extension/CancellableContinuation.kt | 7 ++ .../sdk/internal/JniDownloadController.kt | 10 +++ .../FileOperations/DownloadOperation.swift | 65 ++++++++++++++----- .../Sources/Plumbing/Message+Packaging.swift | 5 ++ .../ProtonDriveClient/ProtonDriveClient.swift | 7 +- .../ProtonDriveSDKError.swift | 16 +++++ 11 files changed, 140 insertions(+), 35 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/BooleanConverter.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs index d0aecc53..cabca8c7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs @@ -41,6 +41,13 @@ public static IMessage HandleIsPaused(DownloadControllerIsPausedRequest request) return null; } + public static IMessage? HandleIsDownloadCompleteWithVerificationIssue(DownloadControllerIsDownloadCompleteWithVerificationIssueRequest request) + { + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); + + return new BoolValue { Value = downloadController.GetIsDownloadCompleteWithVerificationIssue() }; + } + public static IMessage? HandleFree(DownloadControllerFreeRequest request) { Interop.FreeHandle(request.DownloadControllerHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index b4953f35..21fe986c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -82,6 +82,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DownloadControllerIsPaused => InteropDownloadController.HandleIsPaused(request.DownloadControllerIsPaused), + Request.PayloadOneofCase.DownloadControllerIsDownloadCompleteWithVerificationIssue + => InteropDownloadController.HandleIsDownloadCompleteWithVerificationIssue(request.DownloadControllerIsDownloadCompleteWithVerificationIssue), + Request.PayloadOneofCase.DownloadControllerAwaitCompletion => await InteropDownloadController.HandleAwaitCompletion(request.DownloadControllerAwaitCompletion).ConfigureAwait(false), diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 699cd66c..305a4c5f 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -33,10 +33,11 @@ message Request { DownloadToFileRequest download_to_file = 1201; FileDownloaderFreeRequest file_downloader_free = 1202; DownloadControllerIsPausedRequest download_controller_is_paused = 1203; - DownloadControllerAwaitCompletionRequest download_controller_await_completion = 1204; - DownloadControllerPauseRequest download_controller_pause = 1205; - DownloadControllerResumeRequest download_controller_resume = 1206; - DownloadControllerFreeRequest download_controller_free = 1207; + DownloadControllerIsDownloadCompleteWithVerificationIssueRequest download_controller_is_download_complete_with_verification_issue = 1204; + DownloadControllerAwaitCompletionRequest download_controller_await_completion = 1205; + DownloadControllerPauseRequest download_controller_pause = 1206; + DownloadControllerResumeRequest download_controller_resume = 1207; + DownloadControllerFreeRequest download_controller_free = 1208; }; } @@ -175,7 +176,7 @@ message DriveClientCreateFromSessionRequest { int64 session_handle = 1; } -// The reponse must not have a value. +// The response must not have a value. message DriveClientFreeRequest { int64 client_handle = 1; } @@ -228,12 +229,12 @@ message UploadFromFileRequest { int64 cancellation_token_source_handle = 5; } -// The reponse must not have a value. +// The response must not have a value. message FileUploaderFreeRequest { int64 file_uploader_handle = 1; } -// The reponse message must be of type BoolValue. +// The response message must be of type BoolValue. message UploadControllerIsPausedRequest { int64 upload_controller_handle = 1; } @@ -243,22 +244,22 @@ message UploadControllerAwaitCompletionRequest { int64 upload_controller_handle = 1; } -// The reponse must not have a value. +// The response must not have a value. message UploadControllerPauseRequest { int64 upload_controller_handle = 1; } -// The reponse must not have a value. +// The response must not have a value. message UploadControllerResumeRequest { int64 upload_controller_handle = 1; } -// The reponse must not have a value. +// The response must not have a value. message UploadControllerDisposeRequest { int64 upload_controller_handle = 1; } -// The reponse must not have a value. +// The response must not have a value. message UploadControllerFreeRequest { int64 upload_controller_handle = 1; } @@ -312,32 +313,37 @@ message DownloadToFileRequest { int64 cancellation_token_source_handle = 4; } -// The reponse must not have a value. +// The response must not have a value. message FileDownloaderFreeRequest { int64 file_downloader_handle = 1; } -// The reponse message must be of type BoolValue. +// The response message must be of type BoolValue. message DownloadControllerIsPausedRequest { int64 download_controller_handle = 1; } -// The reponse must not have a value. +// The response message must be of type BoolValue. +message DownloadControllerIsDownloadCompleteWithVerificationIssueRequest { + int64 download_controller_handle = 1; +} + +// The response must not have a value. message DownloadControllerAwaitCompletionRequest { int64 download_controller_handle = 1; } -// The reponse must not have a value. +// The response must not have a value. message DownloadControllerPauseRequest { int64 download_controller_handle = 1; } -// The reponse must not have a value. +// The response must not have a value. message DownloadControllerResumeRequest { int64 download_controller_handle = 1; } -// The reponse must not have a value. +// The response must not have a value. message DownloadControllerFreeRequest { int64 download_controller_handle = 1; } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index 64d76b5f..150a6b85 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -32,6 +32,11 @@ class DownloadController internal constructor( bridge.pause(handle) } + suspend fun isDownloadCompleteWithVerificationIssue(): Boolean { + log(DEBUG, "isDownloadCompleteWithVerificationIssue") + return bridge.isDownloadCompleteWithVerificationIssue(handle) + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/BooleanConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/BooleanConverter.kt new file mode 100644 index 00000000..59c1a35b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/BooleanConverter.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import com.google.protobuf.BoolValue + +class BooleanConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/google.protobuf.BoolValue" + + override fun convert(any: Any): Boolean = BoolValue.parseFrom(any.value).value +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt index c2338cf9..d2e6a272 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk.extension import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.converter.BooleanConverter import me.proton.drive.sdk.converter.FileThumbnailListConverter import me.proton.drive.sdk.converter.IntConverter import me.proton.drive.sdk.converter.LongConverter @@ -24,6 +25,12 @@ fun CancellableContinuation.toIntResponse(): ResponseCallback = val IntResponseCallback: (CancellableContinuation) -> ResponseCallback = CancellableContinuation::toIntResponse +fun CancellableContinuation.toBooleanResponse(): ResponseCallback = + ContinuationValueOrErrorResponse(this, BooleanConverter()) + +val BooleanResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toBooleanResponse + fun CancellableContinuation.toLongResponse(): ResponseCallback = ContinuationValueOrErrorResponse(this, LongConverter()) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt index db0020ff..8cf244c9 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt @@ -1,8 +1,10 @@ package me.proton.drive.sdk.internal +import me.proton.drive.sdk.extension.BooleanResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback import proton.drive.sdk.downloadControllerAwaitCompletionRequest import proton.drive.sdk.downloadControllerFreeRequest +import proton.drive.sdk.downloadControllerIsDownloadCompleteWithVerificationIssueRequest import proton.drive.sdk.downloadControllerPauseRequest import proton.drive.sdk.downloadControllerResumeRequest @@ -27,6 +29,14 @@ class JniDownloadController internal constructor() : JniBaseProtonDriveSdk() { } } + suspend fun isDownloadCompleteWithVerificationIssue(handle: Long): Boolean = + executeOnce("isDownloadCompleteWithVerificationIssue", BooleanResponseCallback) { + downloadControllerIsDownloadCompleteWithVerificationIssue = + downloadControllerIsDownloadCompleteWithVerificationIssueRequest { + downloadControllerHandle = handle + } + } + fun free(handle: Long) { dispatch("free") { downloadControllerFree = downloadControllerFreeRequest { diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift index 26da0320..ee046070 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift @@ -1,7 +1,10 @@ import Foundation +public typealias VerificationIssue = ProtonDriveSDKDataIntegrityError + public enum DownloadOperationResult: Sendable { case succeeded + case completedWithVerificationError(VerificationIssue) case pausedOnError(Error) case failed(Error) } @@ -33,11 +36,14 @@ public final class DownloadOperation: Sendable { self.onOperationDispose = onOperationDispose } - // Wait for download completion and uses operational resilience to retry if needed + // Wait for download completion and uses operational resilience to retry if needed. + /// Returns `nil` in case of successful completed download. + /// Returns `VerificationIssue` object if the download completed, but could not be verified. + /// Throws error in case the download has not completed. public func awaitDownloadWithResilience( operationalResilience: OperationalResilience, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void - ) async throws { + ) async throws -> VerificationIssue? { try await awaitDownloadWithResilience( retryCounter: 0, operationalResilience: operationalResilience, onPauseErrorReceived: onRetriableErrorReceived ) @@ -47,11 +53,14 @@ public final class DownloadOperation: Sendable { retryCounter: UInt, operationalResilience: OperationalResilience, onPauseErrorReceived: @Sendable @escaping (Error) -> Void - ) async throws { + ) async throws -> VerificationIssue? { let result = await awaitDownloadCompletion() switch result { case .succeeded: - return + return nil + + case .completedWithVerificationError(let error): + return error case .failed(let error): throw error @@ -75,22 +84,39 @@ public final class DownloadOperation: Sendable { let awaitDownloadCompletionRequest = Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest.with { $0.downloadControllerHandle = downloadControllerHandleForProtos } - + try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void return .succeeded - } catch let error { - do { - let isPaused = try await isPaused() - if isPaused { - // if the operation is paused, we can try recovering from the error - return .pausedOnError(error) - } else { - return .failed(error) - } - } catch let isPausedError { - logger?.info("Checking isPaused status failed with: \(isPausedError.localizedDescription)", category: "DownloadOperation") + } catch { + return await processDownloadError(error) + } + } + + private func processDownloadError(_ error: Error) async -> DownloadOperationResult { + // handle the special case of the successful download of file that has not passed verification check + if let sdkError = error as? ProtonDriveSDKError, + let dataIntegrityError = sdkError.underlyingDataIntegrityError, + let isDownloadCompleteWithVerificationIssue = try? await isDownloadCompleteWithVerificationIssue() { + if isDownloadCompleteWithVerificationIssue { + logger?.info("DownloadCompleteWithVerificationIssue: \(dataIntegrityError.localizedDescription)", + category: "DownloadOperation") + return .completedWithVerificationError(dataIntegrityError) + } + } + + // check if operation can be resumed as the recovery flow + do { + guard try await isPaused() else { + // If the operation is not paused, we consider the operation failed. If we want to retry later, we will need a new operation. return .failed(error) } + // If the operation is paused, we can try recovering from the error by resuming the operation + return .pausedOnError(error) + + } catch let isPausedError { + logger?.info("Checking isPaused status failed with: \(isPausedError.localizedDescription)", + category: "DownloadOperation") + return .failed(error) } } @@ -115,6 +141,13 @@ public final class DownloadOperation: Sendable { return try await SDKRequestHandler.send(isPausedRequest, logger: logger) } + public func isDownloadCompleteWithVerificationIssue() async throws -> Bool { + let isDownloadCompleteWithVerificationIssueRequest = Proton_Drive_Sdk_DownloadControllerIsDownloadCompleteWithVerificationIssueRequest.with { + $0.downloadControllerHandle = downloadControllerHandleForProtos + } + return try await SDKRequestHandler.send(isDownloadCompleteWithVerificationIssueRequest, logger: logger) + } + // a convenience API allowing for cancelling the operation through DownloadOperation instance public func cancel() async throws { try await onOperationCancel() diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 44b2de7c..36717a00 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -139,6 +139,11 @@ extension Message { $0.payload = .downloadControllerIsPaused(request) } + case let request as Proton_Drive_Sdk_DownloadControllerIsDownloadCompleteWithVerificationIssueRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadControllerIsDownloadCompleteWithVerificationIssue(request) + } + case let request as Proton_Drive_Sdk_DownloadControllerAwaitCompletionRequest: Proton_Drive_Sdk_Request.with { $0.payload = .downloadControllerAwaitCompletion(request) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift index 9dc9a9bd..88c7ad62 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift @@ -99,14 +99,17 @@ public actor ProtonDriveClient: Sendable { return result } - /// Convenience API for when you don't need a more granular control over the download (pause, resume etc.) + /// Convenience API for when you don't need a more granular control over the download (pause, resume etc.). + /// Returns `nil` in case of successful completed download. + /// Returns `VerificationIssue` object if the download completed, but could not be verified. + /// Throws error in case the download has not completed. public func downloadFile( revisionUid: SDKRevisionUid, destinationUrl: URL, cancellationToken: UUID, progressCallback: @escaping ProgressCallback, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void - ) async throws { + ) async throws -> VerificationIssue? { let operation = try await downloadFileOperation( revisionUid: revisionUid, destinationUrl: destinationUrl, diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift index eea05dea..f209d516 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift @@ -179,6 +179,8 @@ public extension ProtonDriveSDKError { let nodeMetadataDecryptionErrorPrimaryCode = 2 let fileContentsDecryptionErrorPrimaryCode = 3 let uploadKeyMismatchErrorPrimaryCode = 4 + let manifestSignatureVerificationErrorPrimaryCode = 5 + let contentUploadIntegrityErrorPrimaryCode = 6 switch primaryCode { case shareMetadataDecryptionErrorPrimaryCode: return .shareMetadata(message: message, context: context) @@ -190,6 +192,10 @@ public extension ProtonDriveSDKError { return .fileContents(message: message, context: context) case uploadKeyMismatchErrorPrimaryCode: return .uploadKeyMismatch(message: message, context: context) + case manifestSignatureVerificationErrorPrimaryCode: + return .manifestSignatureVerification(message: message, context: context) + case contentUploadIntegrityErrorPrimaryCode: + return .contentUploadIntegrity(message: message, context: context) case unknownDecryptionErrorPrimaryCode: return .unknown(message: message, context: context) default: @@ -208,6 +214,8 @@ public enum ProtonDriveSDKDataIntegrityError: LocalizedError { case nodeMetadata(message: String, part: NodeMetadataPart?, context: String?) case fileContents(message: String, context: String?) case uploadKeyMismatch(message: String, context: String?) + case manifestSignatureVerification(message: String, context: String?) + case contentUploadIntegrity(message: String, context: String?) public enum NodeMetadataPart: Int, Sendable { case key = 0 @@ -219,6 +227,14 @@ public enum ProtonDriveSDKDataIntegrityError: LocalizedError { case blockSignature = 6 case thumbnail = 7 } + + public var errorDescription: String? { + switch self { + case .unknown(let message, _), .shareMetadata(let message, _), .nodeMetadata(let message, _, _), .fileContents(let message, _), + .uploadKeyMismatch(let message, _), .manifestSignatureVerification(let message, _), .contentUploadIntegrity(let message, _): + return message + } + } } // MARK: - Helpers for handling the network errors From 95273339ee6b5e0dbbb0cc1ea6e885b84eecd028 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 14:48:07 +0000 Subject: [PATCH 419/791] Implement initial photos client interop --- .../InteropMessageHandler.cs | 30 +++ .../InteropPhotosDownloader.cs | 52 +++++ .../InteropProtonPhotosClient.cs | 167 ++++++++++++++++ .../Proton.Drive.Sdk.CExports.csproj | 1 + cs/sdk/src/protos/proton.drive.sdk.proto | 136 +++++++++++++ .../ProtonDriveClient/AccountClient.swift | 17 +- .../HttpClientProtocol.swift | 0 .../HttpClientRequestProcessor.swift | 25 +-- .../HttpClientResponseProcessor.swift | 0 .../Model/BoxedCancellableTask.swift | 0 .../Model/BoxedDownloadStream.swift | 0 .../Networking/Model/BoxedRawBuffer.swift | 0 .../Networking/Model/BytesOrStream.swift | 0 .../Networking/Model/StreamForUpload.swift | 0 .../ProtonDriveClient/ProtonDriveClient.swift | 31 +-- .../ProtonDriveClientConfiguration.swift | 0 .../ProtonPhotosClient.swift | 185 ++++++++++++++++++ .../Sources/Client/ProtonSDKClient.swift | 37 ++++ .../Sources/Client/SDKClientProvider.swift | 27 +++ .../DownloadThumbnailsManager.swift | 31 +++ .../PhotoDownloadsManager.swift | 101 ++++++++++ .../Sources/Plumbing/FeatureFlags.swift | 8 +- .../Sources/Plumbing/InteropRequest.swift | 7 +- .../Sources/Plumbing/Message+Packaging.swift | 50 +++++ .../Sources/Plumbing/PublicTypes.swift | 44 +++++ .../Sources/Plumbing/SDKRequestHandler.swift | 14 ++ .../ProtonDriveSDKError.swift | 5 +- .../Sources/TelemetryAndLogging/Logger.swift | 10 +- .../TelemetryAndLogging/Telemetry.swift | 10 +- 29 files changed, 925 insertions(+), 63 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/AccountClient.swift (91%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/HttpClientProtocol.swift (100%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift (94%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift (100%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift (100%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift (100%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift (100%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/Networking/Model/BytesOrStream.swift (100%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/Networking/Model/StreamForUpload.swift (100%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/ProtonDriveClient.swift (92%) rename swift/ProtonDriveSDK/Sources/{ => Client}/ProtonDriveClient/ProtonDriveClientConfiguration.swift (100%) create mode 100644 swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift create mode 100644 swift/ProtonDriveSDK/Sources/Client/ProtonSDKClient.swift create mode 100644 swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 21fe986c..fff4a517 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -97,6 +97,36 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DownloadControllerFree => InteropDownloadController.HandleFree(request.DownloadControllerFree), + Request.PayloadOneofCase.DrivePhotosClientCreate + => InteropProtonPhotosClient.HandleCreate(request.DrivePhotosClientCreate, bindingsHandle), + + Request.PayloadOneofCase.DrivePhotosClientCreateFromSession + => InteropProtonPhotosClient.HandleCreate(request.DrivePhotosClientCreateFromSession), + + Request.PayloadOneofCase.DrivePhotosClientFree + => InteropProtonPhotosClient.HandleFree(request.DrivePhotosClientFree), + + Request.PayloadOneofCase.DrivePhotosClientGetPhotosRoot + => await InteropProtonPhotosClient.HandleGetPhotosRootAsync(request.DrivePhotosClientGetPhotosRoot).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientEnumeratePhotosThumbnails + => await InteropProtonPhotosClient.HandleEnumeratePhotosThumbnailsAsync(request.DrivePhotosClientEnumeratePhotosThumbnails).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientEnumeratePhotosTimeline + => await InteropProtonPhotosClient.HandleEnumeratePhotosTimelineAsync(request.DrivePhotosClientEnumeratePhotosTimeline).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientGetPhotoDownloader + => await InteropProtonPhotosClient.HandleGetPhotosDownloaderAsync(request.DrivePhotosClientGetPhotoDownloader).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientDownloadToStream + => InteropPhotosDownloader.HandleDownloadToStream(request.DrivePhotosClientDownloadToStream, bindingsHandle), + + Request.PayloadOneofCase.DrivePhotosClientDownloadToFile + => InteropPhotosDownloader.HandleDownloadToFile(request.DrivePhotosClientDownloadToFile, bindingsHandle), + + Request.PayloadOneofCase.DrivePhotosClientDownloaderFree + => InteropPhotosDownloader.HandleFree(request.DrivePhotosClientDownloaderFree), + Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs new file mode 100644 index 00000000..2ca87239 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -0,0 +1,52 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Photos.Sdk.Nodes; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropPhotosDownloader +{ + public static IMessage HandleDownloadToStream(DrivePhotosClientDownloadToStreamRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var downloader = Interop.GetFromHandle(request.DownloaderHandle); + + var stream = new InteropStream(bindingsHandle, new InteropAction, nint>(request.WriteAction)); + + var progressAction = new InteropAction>(request.ProgressAction); + + var downloadController = downloader.DownloadToStream( + stream, + (bytesCompleted, bytesInTotal) => progressAction.InvokeProgressUpdate(bindingsHandle, bytesCompleted, bytesInTotal), + cancellationToken); + + return new Int64Value { Value = Interop.AllocHandle(downloadController) }; + } + + public static IMessage HandleDownloadToFile(DrivePhotosClientDownloadToFileRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var downloader = Interop.GetFromHandle(request.DownloaderHandle); + + var progressAction = new InteropAction>(request.ProgressAction); + + var downloadController = downloader.DownloadToFile( + request.FilePath, + (bytesCompleted, bytesInTotal) => progressAction.InvokeProgressUpdate(bindingsHandle, bytesCompleted, bytesInTotal), + cancellationToken); + + return new Int64Value { Value = Interop.AllocHandle(downloadController) }; + } + + public static IMessage? HandleFree(DrivePhotosClientDownloaderFreeRequest request) + { + var fileDownloader = Interop.FreeHandle(request.FileDownloaderHandle); + + fileDownloader.Dispose(); + + return null; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs new file mode 100644 index 00000000..58ea94d6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -0,0 +1,167 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes; +using Proton.Photos.Sdk; +using Proton.Sdk; +using Proton.Sdk.Caching; +using Proton.Sdk.CExports; +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropProtonPhotosClient +{ + public static IMessage HandleCreate(DrivePhotosClientCreateRequest request, nint bindingsHandle) + { + if (!request.BaseUrl.EndsWith('/')) + { + throw new UriFormatException("Base URL must end with a '/'"); + } + + var httpClientFactory = new InteropHttpClientFactory( + bindingsHandle, + request.BaseUrl, + request.BindingsLanguage, + new InteropFunction, nint, nint>(request.HttpClient.RequestFunction), + new InteropAction, nint>(request.HttpClient.ResponseContentReadAction), + new InteropAction(request.HttpClient.CancellationAction)); + + var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction)); + + var entityCacheRepository = request.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.EntityCachePath) + : SqliteCacheRepository.OpenInMemory(); + + var secretCacheRepository = request.HasSecretCachePath + ? SqliteCacheRepository.OpenFile(request.SecretCachePath) + : SqliteCacheRepository.OpenInMemory(); + + ITelemetry telemetry = request.Telemetry.ToTelemetry(bindingsHandle) is { } interopTelemetry + ? new DriveInteropTelemetryDecorator(interopTelemetry) + : NullTelemetry.Instance; + + var featureFlagProvider = request.HasFeatureEnabledFunction + ? new InteropFeatureFlagProvider(bindingsHandle, new InteropFunction, int>(request.FeatureEnabledFunction)) + : AlwaysDisabledFeatureFlagProvider.Instance; + + var client = new ProtonPhotosClient( + httpClientFactory, + accountClient, + entityCacheRepository, + secretCacheRepository, + featureFlagProvider, + telemetry, + request.Uid); + + return new Int64Value + { + Value = Interop.AllocHandle(client), + }; + } + + public static IMessage HandleCreate(DrivePhotosClientCreateFromSessionRequest request) + { + var session = Interop.GetFromHandle(request.SessionHandle); + + var client = new ProtonPhotosClient(session, request.Uid); + + return new Int64Value { Value = Interop.AllocHandle(client) }; + } + + public static IMessage? HandleFree(DrivePhotosClientFreeRequest request) + { + Interop.FreeHandle(request.ClientHandle); + + return null; + } + + public static async ValueTask HandleGetPhotosRootAsync(DrivePhotosClientGetPhotosRootRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var client = Interop.GetFromHandle(request.ClientHandle); + + var folderNode = await client.GetPhotosRootAsync(cancellationToken).ConfigureAwait(false); + + return new FolderNode + { + Uid = folderNode.Uid.ToString(), + ParentUid = folderNode.ParentUid.ToString(), + TreeEventScopeId = folderNode.TreeEventScopeId, + Name = folderNode.Name, + CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = ParseAuthorResult(folderNode.NameAuthor), + Author = ParseAuthorResult(folderNode.Author), + }; + } + + public static async ValueTask HandleEnumeratePhotosTimelineAsync(DrivePhotosClientEnumeratePhotosTimelineRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var client = Interop.GetFromHandle(request.ClientHandle); + var timelineEnumerable = client.EnumeratePhotosTimelineAsync( + NodeUid.Parse(request.FolderUid), + cancellationToken); + + var items = await timelineEnumerable + .Select(x => new PhotosTimelineItem + { + NodeUid = x.Uid.ToString(), + CaptureTime = x.CaptureTime.ToUniversalTime().ToTimestamp(), + }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return new PhotosTimelineList { Items = { items } }; + } + + public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhotosClientGetPhotoDownloaderRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var downloader = await client.GetPhotosDownloaderAsync( + NodeUid.Parse(request.PhotoUid), + cancellationToken).ConfigureAwait(false); + + return new Int64Value { Value = Interop.AllocHandle(downloader) }; + } + + public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(DrivePhotosClientEnumeratePhotosThumbnailsRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var client = Interop.GetFromHandle(request.ClientHandle); + + var thumbnailsEnumerable = client.EnumeratePhotosThumbnailsAsync( + request.PhotoUids.Select(NodeUid.Parse), + (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, + cancellationToken); + + var thumbnails = await thumbnailsEnumerable + .Select(x => new FileThumbnail + { + FileUid = x.FileUid.ToString(), + Data = ByteString.CopyFrom(x.Data.Span), + }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return new FileThumbnailList { Thumbnails = { thumbnails } }; + } + + private static AuthorResult ParseAuthorResult(Result result) + { + var authorResult = new AuthorResult(); + + if (result.TryGetValueElseError(out var author, out var error)) + { + authorResult.Author = new Proton.Drive.Sdk.CExports.Author { EmailAddress = author.EmailAddress }; + } + else + { + authorResult.Author = new Proton.Drive.Sdk.CExports.Author { EmailAddress = error.ClaimedAuthor.EmailAddress }; + authorResult.SignatureVerificationError = error.Message; + } + + return authorResult; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index 7005408a..45ae545e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -17,6 +17,7 @@ + diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 305a4c5f..e91ebac6 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -38,6 +38,17 @@ message Request { DownloadControllerPauseRequest download_controller_pause = 1206; DownloadControllerResumeRequest download_controller_resume = 1207; DownloadControllerFreeRequest download_controller_free = 1208; + + DrivePhotosClientCreateRequest drive_photos_client_create = 1300; + DrivePhotosClientCreateFromSessionRequest drive_photos_client_create_from_session = 1301; + DrivePhotosClientFreeRequest drive_photos_client_free = 1302; + DrivePhotosClientGetPhotosRootRequest drive_photos_client_get_photos_root = 1303; + DrivePhotosClientEnumeratePhotosThumbnailsRequest drive_photos_client_enumerate_photos_thumbnails = 1304; + DrivePhotosClientGetPhotoDownloaderRequest drive_photos_client_get_photo_downloader = 1305; + DrivePhotosClientDownloadToStreamRequest drive_photos_client_download_to_stream = 1306; + DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1307; + DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1308; + DrivePhotosClientEnumeratePhotosTimelineRequest drive_photos_client_enumerate_photos_timeline = 1309; }; } @@ -102,6 +113,26 @@ message FileNode { FileRevision active_revision = 6; } +message FolderNode { + string uid = 1; + string parent_uid = 2; // optional + string tree_event_scope_id = 3; + string name = 4; + google.protobuf.Timestamp creation_time = 5; + google.protobuf.Timestamp trash_time = 6; // optional + AuthorResult name_author = 7; + AuthorResult author = 8; +} + +message AuthorResult { + Author author = 1; + string signature_verification_error = 2; +} + +message Author { + string email_address = 1; // optional +} + message ProgressUpdate { int64 bytes_completed = 1; int64 bytes_in_total = 2; @@ -348,6 +379,111 @@ message DownloadControllerFreeRequest { int64 download_controller_handle = 1; } +// Drive - Photos client + +message DrivePhotosClientCreateRequest { + string base_url = 1; + string bindings_language = 2; // Optional + + HttpClient http_client = 3; + + // Pointer to C function that will be called: + // void handle_account_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) + // bindings_handle: handle for the bindings + // account_request: Protobuf message of type proton.drive.sdk.AccountRequest carrying the request data + // sdk_handle: handle for the SDK + int64 account_request_action = 6; + + string entity_cache_path = 7; // Optional + string secret_cache_path = 8; // Optional + + proton.sdk.Telemetry telemetry = 9; // Optional + + // Client UID, optional + // If a null value is provided, the SDK automatically generates a UUID during initialization + string uid = 10; + + // Pointer to C function that will be called to check feature flags: + // int is_feature_flag_enabled(intptr_t bindings_handle, ByteArray flag_name) + // bindings_handle: handle for the bindings + // flag_name: UTF-8 encoded feature flag name + // returns: 0 for disabled, non-zero for enabled + int64 feature_enabled_function = 11; // Optional +} + +message DrivePhotosClientCreateFromSessionRequest { + int64 session_handle = 1; + + // Client UID, optional + // If a null value is provided, the SDK automatically generates a UUID during initialization + string uid = 2; +} + +// The response must not have a value. +message DrivePhotosClientFreeRequest { + int64 client_handle = 1; +} + +// The response message must be of type FolderNode +message DrivePhotosClientGetPhotosRootRequest { + int64 client_handle = 1; + int64 cancellation_token_source_handle = 2; +} + +// The response message must be of type FileThumbnailList. +message DrivePhotosClientEnumeratePhotosThumbnailsRequest { + int64 client_handle = 1; + repeated string photo_uids = 2; + ThumbnailType type = 3; + int64 cancellation_token_source_handle = 4; +} + +// The response message must be of type PhotosTimelineList. +message DrivePhotosClientEnumeratePhotosTimelineRequest { + int64 client_handle = 1; + string folder_uid = 2; + int64 cancellation_token_source_handle = 3; +} + +message PhotosTimelineList { + repeated PhotosTimelineItem items = 1; +} + +message PhotosTimelineItem { + string node_uid = 1; + google.protobuf.Timestamp capture_time = 2; +} + +// The response value must be an Int64Value carrying a handle to an instance of PhotosDownloader. +message DrivePhotosClientGetPhotoDownloaderRequest { + int64 client_handle = 1; + string photo_uid = 2; + int64 cancellation_token_source_handle = 3; +} + +// Photo downloader + +// The response value must be an Int64Value carrying a handle to an instance of DownloadController. +message DrivePhotosClientDownloadToStreamRequest { + int64 downloader_handle = 1; + int64 write_action = 2; // C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); + int64 progress_action = 3; // See array_action in C header file for signature + int64 cancellation_token_source_handle = 4; +} + +// The response value must be an Int64Value carrying a handle to an instance of DownloadController. +message DrivePhotosClientDownloadToFileRequest { + int64 downloader_handle = 1; + string file_path = 2; + int64 progress_action = 3; // See array_action in C header file for signature + int64 cancellation_token_source_handle = 4; +} + +// The response must not have a value. +message DrivePhotosClientDownloaderFreeRequest { + int64 file_downloader_handle = 1; +} + enum VolumeType { VOLUME_TYPE_OWN_VOLUME = 0; VOLUME_TYPE_SHARED = 1; diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift similarity index 91% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift index dc237b25..b33a5812 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/AccountClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift @@ -16,16 +16,15 @@ let cCompatibleAccountClientRequest: CCallbackWithCallbackPointer = { statePoint callbackPointer: callbackPointer) return } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) - let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state - - let driveClient = ProtonDriveClient.unbox( - callbackPointer: callbackPointer, releaseBox: { + let stateTypedPointer = Unmanaged>.fromOpaque(stateRawPointer) + let provider: SDKClientProvider = stateTypedPointer.takeUnretainedValue().state + + guard + let driveClient = provider.get(callbackPointer: callbackPointer, releaseBox: { // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation -// stateTypedPointer.release() - }, weakDriveClient: weakDriveClient - ) - guard let driveClient else { return } + // stateTypedPointer.release() + }) + else { return } Task { [driveClient] in let accountClient = driveClient.accountClient diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/HttpClientProtocol.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/HttpClientProtocol.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/HttpClientProtocol.swift diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift similarity index 94% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift index 68bf3418..e2694221 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -7,15 +7,16 @@ enum HttpClientRequestProcessor { callbackPointer: callbackPointer) return -1 } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) - let weakDriveClient: WeakReference = stateTypedPointer.takeUnretainedValue().state + let stateTypedPointer = Unmanaged>.fromOpaque(stateRawPointer) + let provider: SDKClientProvider = stateTypedPointer.takeUnretainedValue().state + + guard + let driveClient = provider.get(callbackPointer: callbackPointer, releaseBox: { + // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation + // stateTypedPointer.release() + }) + else { return -1 } - let driveClient = ProtonDriveClient.unbox(callbackPointer: callbackPointer, releaseBox: { - // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation -// stateTypedPointer.release() - }, weakDriveClient: weakDriveClient) - guard let driveClient else { return -1 } - let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) // Create a boxed task with the HTTP work @@ -62,7 +63,7 @@ enum HttpClientRequestProcessor { } fileprivate static func perform( - client: ProtonDriveClient, + client: ProtonSDKClient, httpRequestData: Proton_Sdk_HttpRequest, callbackPointer: Int ) async throws { @@ -98,7 +99,7 @@ enum HttpClientRequestProcessor { /// the API calls are performed in a non-streaming way. both request body and response data are buffered in memory fileprivate static func callDriveApi( driveRelativePath: String, - client: ProtonDriveClient, + client: ProtonSDKClient, httpRequestData: Proton_Sdk_HttpRequest, callbackPointer: Int ) async throws { @@ -165,7 +166,7 @@ enum HttpClientRequestProcessor { /// the storage upload calls are using stream to upload request body, but cache the whole response in memory fileprivate static func uploadToStorage( - client: ProtonDriveClient, + client: ProtonSDKClient, httpRequestData: Proton_Sdk_HttpRequest, callbackPointer: Int ) async throws { @@ -224,7 +225,7 @@ enum HttpClientRequestProcessor { /// the download upload calls are caching the whole request body in-memory, but stream the response data fileprivate static func downloadFromStorage( - client: ProtonDriveClient, + client: ProtonSDKClient, httpRequestData: Proton_Sdk_HttpRequest, callbackPointer: Int ) async throws { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BytesOrStream.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/BytesOrStream.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BytesOrStream.swift diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/Networking/Model/StreamForUpload.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift similarity index 92% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 88c7ad62..379de8e8 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -3,7 +3,7 @@ import Foundation /// Main entry point for all SDK functionality. /// /// Create a single object of this class and use it to perform downloads, uploads and all other supported operations. -public actor ProtonDriveClient: Sendable { +public actor ProtonDriveClient: Sendable, ProtonSDKClient { private var clientHandle: ObjectHandle = 0 @@ -12,8 +12,8 @@ public actor ProtonDriveClient: Sendable { private var thumbnailsManager: DownloadThumbnailsManager! let logger: ProtonDriveSDK.Logger - private let recordMetricEventCallback: RecordMetricEventCallback - private let featureFlagProviderCallback: FeatureFlagProviderCallback + let recordMetricEventCallback: RecordMetricEventCallback + let featureFlagProviderCallback: FeatureFlagProviderCallback let httpClient: HttpClientProtocol let accountClient: AccountClientProtocol @@ -65,9 +65,9 @@ public actor ProtonDriveClient: Sendable { // we pass the weak reference as the state because we don't want the interop layer // to prolong the client object existence - let weakSelf = WeakReference(value: self) + let provider = SDKClientProvider(client: self) let handle: Proton_Drive_Sdk_DriveClientCreateRequest.CallResultType = try await SDKRequestHandler.sendInteropRequest( - clientCreateRequest, state: weakSelf, includesLongLivedCallback: true, logger: logger + clientCreateRequest, state: provider, includesLongLivedCallback: true, logger: logger ) assert(handle != 0) self.clientHandle = ObjectHandle(handle) @@ -78,27 +78,6 @@ public actor ProtonDriveClient: Sendable { self.thumbnailsManager = DownloadThumbnailsManager(clientHandle: clientHandle, logger: logger) } - nonisolated func log(_ logEvent: LogEvent) { - logger.logCallback(logEvent) - } - - nonisolated func record(_ metricEvent: MetricEvent) { - recordMetricEventCallback(metricEvent) - } - - nonisolated func isFlagEnabled(_ flagName: String) -> Bool { - // Since the C# callback expects a synchronous return but our Swift callback has completion block, - // we need to block and wait for the async result using a semaphore - let semaphore = DispatchSemaphore(value: 0) - var result = false - featureFlagProviderCallback(flagName) { resultValue in - result = resultValue - semaphore.signal() - } - semaphore.wait() - return result - } - /// Convenience API for when you don't need a more granular control over the download (pause, resume etc.). /// Returns `nil` in case of successful completed download. /// Returns `VerificationIssue` object if the download completed, but could not be verified. diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/ProtonDriveClient/ProtonDriveClientConfiguration.swift rename to swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift new file mode 100644 index 00000000..0c4a860b --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -0,0 +1,185 @@ +import Foundation + +public actor ProtonPhotosClient: Sendable, ProtonSDKClient { + + private var clientHandle: ObjectHandle = 0 + private var downloadsManager: PhotoDownloadsManager! + private var thumbnailsManager: DownloadThumbnailsManager! + + let accountClient: AccountClientProtocol + let configuration: ProtonDriveClientConfiguration + let httpClient: HttpClientProtocol + let logger: ProtonDriveSDK.Logger + let recordMetricEventCallback: RecordMetricEventCallback + let featureFlagProviderCallback: FeatureFlagProviderCallback + + public init( + accountClient: AccountClientProtocol, + configuration: ProtonDriveClientConfiguration, + httpClient: HttpClientProtocol, + logCallback: @escaping LogCallback, + featureFlagProviderCallback: @escaping FeatureFlagProviderCallback, + recordMetricEventCallback: @escaping RecordMetricEventCallback + ) async throws { + self.accountClient = accountClient + self.configuration = configuration + self.httpClient = httpClient + self.logger = try await Logger(logCallback: logCallback) + + self.recordMetricEventCallback = recordMetricEventCallback + self.featureFlagProviderCallback = featureFlagProviderCallback + + let clientCreateRequest = Proton_Drive_Sdk_DrivePhotosClientCreateRequest.with { + $0.baseURL = configuration.baseURL + + $0.httpClient = Proton_Drive_Sdk_HttpClient.with { httpClient in + httpClient.requestFunction = Int64(ObjectHandle(callback: HttpClientRequestProcessor.cCompatibleHttpRequest)) + httpClient.responseContentReadAction = Int64(ObjectHandle(callback: HttpClientResponseProcessor.cCompatibleHttpResponseRead)) + httpClient.cancellationAction = Int64(ObjectHandle(callback: HttpClientRequestProcessor.cCompatibleHttpCancellationAction)) + } + $0.accountRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) + + if let entityCachePath = configuration.entityCachePath { + $0.entityCachePath = entityCachePath + } + if let secretCachePath = configuration.secretCachePath { + $0.secretCachePath = secretCachePath + } + + $0.telemetry = Proton_Sdk_Telemetry.with { + $0.logAction = Int64(ObjectHandle(callback: cCompatibleLogCallback)) + $0.recordMetricAction = Int64(ObjectHandle(callback: cCompatibleTelemetryRecordMetricCallback)) + } + + $0.uid = configuration.clientUID + + $0.featureEnabledFunction = Int64(ObjectHandle(callback: cCompatibleFeatureFlagProviderCallback)) + } + + // we pass the weak reference as the state because we don't want the interop layer + // to prolong the client object existence + let provider = SDKClientProvider(client: self) + let handle: Proton_Drive_Sdk_DrivePhotosClientCreateRequest.CallResultType = try await SDKRequestHandler + .sendInteropRequest( + clientCreateRequest, + state: provider, + includesLongLivedCallback: true, + logger: logger + ) + + assert(handle != 0) + self.clientHandle = ObjectHandle(handle) + logger.trace("client handle: \(clientHandle)", category: "ProtonDriveClient") + + self.downloadsManager = PhotoDownloadsManager(clientHandle: clientHandle, logger: logger) + self.thumbnailsManager = DownloadThumbnailsManager(clientHandle: clientHandle, logger: logger) + } + + deinit { + guard clientHandle != 0 else { return } + Self.freeProtonPhotosClient(Int64(clientHandle), logger) + } + + private static func freeProtonPhotosClient(_ clientHandle: Int64, _ logger: Logger?) { + Task { + let freeRequest = Proton_Drive_Sdk_DrivePhotosClientFreeRequest.with { + $0.clientHandle = clientHandle + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the client failed, we have a memory leak, but not much else can be done. + logger?.error( + "Proton_Drive_Sdk_DrivePhotosClientFreeRequest failed: \(error)", + category: "ProtonPhotosClient.freeProtonPhotosClient" + ) + } + } + } +} + +extension ProtonPhotosClient { + public func getPhotosRoot() async throws -> FolderNode { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + defer { + cancellationTokenSource.free() + } + + let cancellationHandle = cancellationTokenSource.handle + + let request = Proton_Drive_Sdk_DrivePhotosClientGetPhotosRootRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + let sdkNode: Proton_Drive_Sdk_FolderNode = try await SDKRequestHandler.send(request, logger: logger) + return try FolderNode(sdkFolderNode: sdkNode) + } + + public func enumerateTimeline(in folderUid: SDKNodeUid) async throws -> [PhotoTimelineItem] { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + defer { + cancellationTokenSource.free() + } + + let cancellationHandle = cancellationTokenSource.handle + + let request = Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosTimelineRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.folderUid = folderUid.sdkCompatibleIdentifier + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let list: Proton_Drive_Sdk_PhotosTimelineList = try await SDKRequestHandler.send(request, logger: logger) + return list.items.compactMap { PhotoTimelineItem(item: $0) } + } + + public func downloadThumbnails( + photoUids: [SDKNodeUid], + type: ThumbnailData.ThumbnailType, + cancellationToken: UUID + ) async throws -> [ThumbnailDataWithId] { + try await thumbnailsManager.downloadPhotoThumbnails( + photoUids: photoUids, + type: type, + cancellationToken: cancellationToken + ) + } + + /// Convenience API for when you don't need a more granular control over the download (pause, resume etc.) + public func download( + photoUid: SDKNodeUid, + destinationUrl: URL, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> VerificationIssue? { + let operation = try await downloadOperation( + photoUid: photoUid, + destinationUrl: destinationUrl, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + return try await operation.awaitDownloadWithResilience( + operationalResilience: configuration.downloadOperationalResilience, + onRetriableErrorReceived: onRetriableErrorReceived + ) + } + + public func cancelPhotoDownload(cancellationToken: UUID) async throws { + try await downloadsManager.cancelDownload(with: cancellationToken) + } + + public func downloadOperation( + photoUid: SDKNodeUid, + destinationUrl: URL, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> DownloadOperation { + try await downloadsManager.downloadPhotoOperation( + photoUid: photoUid, + destinationUrl: destinationUrl, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + } +} diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonSDKClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonSDKClient.swift new file mode 100644 index 00000000..e26b1564 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonSDKClient.swift @@ -0,0 +1,37 @@ +import Foundation + +protocol ProtonSDKClient: AnyObject, Sendable { + var accountClient: AccountClientProtocol { get } + var configuration: ProtonDriveClientConfiguration { get } + var httpClient: HttpClientProtocol { get } + var logger: ProtonDriveSDK.Logger { get } + var recordMetricEventCallback: RecordMetricEventCallback { get } + var featureFlagProviderCallback: FeatureFlagProviderCallback { get } + + func log(_ logEvent: LogEvent) + func record(_ metricEvent: MetricEvent) + func isFlagEnabled(_ flagName: String) -> Bool +} + +extension ProtonSDKClient { + func log(_ logEvent: LogEvent) { + logger.logCallback(logEvent) + } + + func record(_ metricEvent: MetricEvent) { + recordMetricEventCallback(metricEvent) + } + + func isFlagEnabled(_ flagName: String) -> Bool { + // Since the C# callback expects a synchronous return but our Swift callback has completion block, + // we need to block and wait for the async result using a semaphore + let semaphore = DispatchSemaphore(value: 0) + var result = false + featureFlagProviderCallback(flagName) { resultValue in + result = resultValue + semaphore.signal() + } + semaphore.wait() + return result + } +} diff --git a/swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift b/swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift new file mode 100644 index 00000000..01606582 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift @@ -0,0 +1,27 @@ +import Foundation + +final class SDKClientProvider { + private weak var client: (any ProtonSDKClient)? + + init(client: any ProtonSDKClient) { + self.client = client + } + + func get(callbackPointer: Int, releaseBox: () -> Void) -> (any ProtonSDKClient)? { + guard let client else { + releaseBox() + let message = "callback called after the proton client object was deallocated" + SDKResponseHandler.sendInteropErrorToSDK( + message: message, + callbackPointer: callbackPointer, + assert: false + ) + return nil + } + return client + } + + func get() -> (any ProtonSDKClient)? { + client + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift index fd716205..b022d764 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift @@ -50,6 +50,37 @@ actor DownloadThumbnailsManager { } } + func downloadPhotoThumbnails( + photoUids: [SDKNodeUid], + type: ThumbnailData.ThumbnailType, + cancellationToken: UUID + ) async throws -> [ThumbnailDataWithId] { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeDownloads[cancellationToken] = cancellationTokenSource + + defer { + if let cancellationTokenSource = activeDownloads[cancellationToken] { + activeDownloads[cancellationToken] = nil + cancellationTokenSource.free() + } + } + + let thumbnailsRequest = Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosThumbnailsRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.photoUids = photoUids.map(\.sdkCompatibleIdentifier) + $0.type = type.sdkType + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + } + + let thumbnailsList: Proton_Drive_Sdk_FileThumbnailList = try await SDKRequestHandler.send( + thumbnailsRequest, + logger: logger + ) + return thumbnailsList.thumbnails.compactMap { + ThumbnailDataWithId(fileThumbnail: $0) + } + } + func cancelDownload(with cancellationToken: UUID) async throws { guard let downloadCancellationToken = activeDownloads[cancellationToken] else { throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "thumbnails download")) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift new file mode 100644 index 00000000..5c41a472 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Handles photo download operations for ProtonDrive +actor PhotoDownloadsManager { + + private let clientHandle: ObjectHandle + private let logger: Logger? + private var activeDownloads: [UUID: CancellationTokenSource] = [:] + + init(clientHandle: ObjectHandle, logger: Logger?) { + self.clientHandle = clientHandle + self.logger = logger + } + + deinit { + activeDownloads.values.forEach { + $0.free() + } + } + + func downloadPhotoOperation( + photoUid: SDKNodeUid, + destinationUrl: URL, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> DownloadOperation { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeDownloads[cancellationToken] = cancellationTokenSource + + let downloaderHandle = try await buildDownloader( + photoUid: photoUid.sdkCompatibleIdentifier, + fileURL: destinationUrl, + cancellationHandle: cancellationTokenSource.handle + ) + + let downloaderRequest = Proton_Drive_Sdk_DrivePhotosClientDownloadToFileRequest.with { + $0.downloaderHandle = Int64(downloaderHandle) + $0.filePath = destinationUrl.path(percentEncoded: false) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + } + + let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + downloaderRequest, + state: WeakReference(value: callbackState), + includesLongLivedCallback: true, + logger: logger + ) + + return DownloadOperation( + fileDownloaderHandle: downloaderHandle, + downloadControllerHandle: downloadControllerHandle, + progressCallbackWrapper: callbackState, + logger: logger, + onOperationCancel: { [weak self] in + guard let self else { return } + try await self.cancelDownload(with: cancellationToken) + }, + onOperationDispose: { [weak self] in + guard let self else { return } + await self.freeCancellationTokenSourceIfNeeded(cancellationToken: cancellationToken) + } + ) + } + + // API to cancel operation when the client does not use the DownloadOperation + func cancelDownload(with cancellationToken: UUID) async throws { + guard let downloadCancellationToken = activeDownloads[cancellationToken] else { + throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "download")) + } + + try await downloadCancellationToken.cancel() + + activeDownloads[cancellationToken] = nil + downloadCancellationToken.free() + } + + private func freeCancellationTokenSourceIfNeeded(cancellationToken: UUID) { + guard let cancellationTokenSource = activeDownloads[cancellationToken] else { return } + activeDownloads[cancellationToken] = nil + cancellationTokenSource.free() + } + + /// Get a photo downloader for downloading files from Drive + private func buildDownloader( + photoUid: String, + fileURL: URL, + cancellationHandle: ObjectHandle + ) async throws -> ObjectHandle { + let downloaderRequest = Proton_Drive_Sdk_DrivePhotosClientGetPhotoDownloaderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.photoUid = photoUid + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let downloaderHandle: ObjectHandle = try await SDKRequestHandler.send(downloaderRequest, logger: logger) + assert(downloaderHandle != 0) + return downloaderHandle + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift index 6f016011..c59dd358 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift @@ -10,12 +10,12 @@ let cCompatibleFeatureFlagProviderCallback: CCallbackWithIntReturn = { statePoin return 0 } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) - let weakDriveClient = stateTypedPointer.takeUnretainedValue().state + let stateTypedPointer = Unmanaged>.fromOpaque(stateRawPointer) + let provider = stateTypedPointer.takeUnretainedValue().state - guard let driveClient = weakDriveClient.value else { + guard let driveClient = provider.get() else { // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation -// stateTypedPointer.release() + // stateTypedPointer.release() return 0 } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift index 64c676a1..9c0d7602 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift @@ -9,7 +9,12 @@ extension InteropRequest { extension Proton_Drive_Sdk_DriveClientCreateRequest: InteropRequest { typealias CallResultType = ObjectHandle - typealias StateType = WeakReference + typealias StateType = SDKClientProvider +} + +extension Proton_Drive_Sdk_DrivePhotosClientCreateRequest: InteropRequest { + typealias CallResultType = ObjectHandle + typealias StateType = SDKClientProvider } extension Proton_Sdk_CancellationTokenSourceCreateRequest: InteropRequest { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 36717a00..c098db9e 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -163,6 +163,56 @@ extension Message { Proton_Drive_Sdk_Request.with { $0.payload = .downloadControllerFree(request) } + + case let request as Proton_Drive_Sdk_DrivePhotosClientCreateRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientCreate(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientCreateFromSessionRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientCreateFromSession(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientFreeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientFree(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientGetPhotosRootRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientGetPhotosRoot(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosThumbnailsRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientEnumeratePhotosThumbnails(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosTimelineRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientEnumeratePhotosTimeline(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientGetPhotoDownloaderRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientGetPhotoDownloader(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientDownloadToFileRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientDownloadToFile(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientDownloadToStreamRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientDownloadToStream(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientDownloaderFreeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientDownloaderFree(request) + } default: assertionFailure("Unknown request") diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 2591215c..7f9da2ff 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -109,6 +109,39 @@ public struct ThumbnailData: Sendable { } } +public struct FolderNode: Sendable { + public let uid: SDKNodeUid + public let parentUid: SDKNodeUid? + public let name: String + public let creationTime: Double + public let trashTime: Double? + public let nameAuthor: Author + public let author: Author + + init(sdkFolderNode: Proton_Drive_Sdk_FolderNode) throws { + guard let uid = SDKNodeUid(sdkCompatibleIdentifier: sdkFolderNode.uid) else { + throw ProtonDriveSDKError(interopError: .incorrectIDFormat(id: sdkFolderNode.uid)) + } + self.uid = uid + self.parentUid = sdkFolderNode.parentUid == nil ? nil : .init(sdkCompatibleIdentifier: sdkFolderNode.parentUid) + self.name = sdkFolderNode.name + self.creationTime = sdkFolderNode.creationTime.timeIntervalSince1970 + self.trashTime = sdkFolderNode.trashTime.timeIntervalSince1970 + self.nameAuthor = Author(result: sdkFolderNode.nameAuthor) + self.author = Author(result: sdkFolderNode.author) + } +} + +public struct Author: Sendable { + let emailAddress: String? + let signatureVerificationError: String? + + init(result: Proton_Drive_Sdk_AuthorResult) { + self.emailAddress = result.author.emailAddress + self.signatureVerificationError = result.hasSignatureVerificationError ? result.signatureVerificationError : nil + } +} + public struct FileNode: Sendable { let uid: String let parentUid: String @@ -156,6 +189,17 @@ public struct UploadedFileIdentifiers: Sendable { } } +public struct PhotoTimelineItem: Sendable { + public let nodeUid: SDKNodeUid + public let captureTime: Double + + init?(item: Proton_Drive_Sdk_PhotosTimelineItem) { + guard let nodeUid = SDKNodeUid(sdkCompatibleIdentifier: item.nodeUid) else { return nil } + self.nodeUid = nodeUid + self.captureTime = item.captureTime.timeIntervalSince1970 + } +} + /// Callback for progress updates public typealias ProgressCallback = @Sendable (FileOperationProgress) -> Void diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 264c421c..92d42aee 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -188,6 +188,20 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in } uploadResultBox.resume(returning: unpackedValue) + case .value(let value) where value.isA(Proton_Drive_Sdk_PhotosTimelineList.self): + let unpackedValue = try Proton_Drive_Sdk_PhotosTimelineList(unpackingAny: value) + guard let uploadResultBox = box as? any Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) + } + uploadResultBox.resume(returning: unpackedValue) + + case .value(let value) where value.isA(Proton_Drive_Sdk_FolderNode.self): + let unpackedValue = try Proton_Drive_Sdk_FolderNode(unpackingAny: value) + guard let resultBox = box as? any Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) + } + resultBox.resume(returning: unpackedValue) + case .value: // unknown value type throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unknown SDK call response value type")) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift index f209d516..e39a7ea2 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift @@ -59,13 +59,15 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { case wrongProto(message: String) case wrongSDKResponse(message: String) case wrongResult(message: String) - + case incorrectIDFormat(id: String) + var typeName: String { switch self { case .noCancellationTokenForIdentifier(let operation): return "NoCancellationTokenFor\(operation.capitalized.replacingOccurrences(of: " ", with: ""))" case .wrongProto: return "WrongProtoMessageType" case .wrongSDKResponse: return "WrongSDKResponseType" case .wrongResult: return "WrongSDKRequestResult" + case .incorrectIDFormat: return "IncorrectIDFormat" } } @@ -75,6 +77,7 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { case .wrongProto(let message): return message case .wrongSDKResponse(let message): return message case .wrongResult(let message): return message + case .incorrectIDFormat(let id): return "ID \(id) is not in the correct format" } } } diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift index bf0c5144..bd057173 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift @@ -33,12 +33,12 @@ let cCompatibleLogCallback: CCallback = { statePointer, byteArray in return } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) - let weakDriveClient = stateTypedPointer.takeUnretainedValue().state - - guard let driveClient = weakDriveClient.value else { + let stateTypedPointer = Unmanaged>.fromOpaque(stateRawPointer) + let provider = stateTypedPointer.takeUnretainedValue().state + + guard let driveClient = provider.get() else { // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation -// stateTypedPointer.release() + // stateTypedPointer.release() return } diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift index 22309e66..3265bd38 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift @@ -8,12 +8,12 @@ let cCompatibleTelemetryRecordMetricCallback: CCallback = { statePointer, byteAr return } - let stateTypedPointer = Unmanaged>>.fromOpaque(stateRawPointer) - let weakDriveClient = stateTypedPointer.takeUnretainedValue().state - - guard let driveClient = weakDriveClient.value else { + let stateTypedPointer = Unmanaged>.fromOpaque(stateRawPointer) + let provider = stateTypedPointer.takeUnretainedValue().state + + guard let driveClient = provider.get() else { // we don't release the stateTypedPointer by design — there might be some calls coming from the SDK racing with the client deallocation -// stateTypedPointer.release() + // stateTypedPointer.release() return } From 20a108e6b239b6afb537093ac99f8acc1dbe74ba Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 15:33:26 +0100 Subject: [PATCH 420/791] Fix InteropStream length initialization for write streams --- .../src/Proton.Sdk.CExports/InteropStream.cs | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs index 1e62c72c..e06dd938 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -23,6 +23,7 @@ public InteropStream(long? length, nint bindingsHandle, InteropAction, nint>? writeAction) { + _length = 0; _bindingsHandle = bindingsHandle; _readAction = null; _writeAction = writeAction; @@ -51,35 +52,13 @@ public static async ValueTask HandleReadAsync(StreamReadRequest reques return new Int32Value { Value = bytesRead }; } - public override void Flush() - { - } - - public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) - { - return base.CopyToAsync(destination, bufferSize, cancellationToken); - } - - public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) - { - return base.BeginRead(buffer, offset, count, callback, state); - } - - public override int ReadByte() - { - return base.ReadByte(); - } + public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) { return ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); } - public override int Read(Span buffer) - { - return base.Read(buffer); - } - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); From c63aa93663258af114c48449216e19828c983143 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 17:17:56 +0100 Subject: [PATCH 421/791] Handle 429 responses on block uploads --- .../src/Proton.Drive.Sdk/Nodes/TaskControl.cs | 3 -- .../Nodes/Upload/BlockUploader.cs | 50 ++++++++++++++++--- .../Http/HttpResponseMessageExtensions.cs | 32 +++++++++++- .../Proton.Sdk/TooManyRequestsException.cs | 29 +++++++++++ 4 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/TooManyRequestsException.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs index 9f12a427..a36e5418 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs @@ -43,7 +43,6 @@ public void Pause() _resumeSignalSource = new TaskCompletionSource(); - // TODO: write unit test to verify that we reset the pause exception signal if and only if the previous one is faulted if (PauseExceptionSignal.IsFaulted) { _pauseExceptionSignalSource = new TaskCompletionSource(); @@ -55,7 +54,6 @@ public void Pause() public void PauseOnError(Exception ex) { - // TODO: write unit test to check that we don't use the new signal source set by the Pause() call var pauseExceptionSignalSource = _pauseExceptionSignalSource; Pause(); @@ -87,7 +85,6 @@ public void Resume() return; } - // TODO: write unit test to justify that the fields must be set to the new state before signaling resume _pauseCancellationTokenSource.Dispose(); _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index f26039a8..34117af5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -10,7 +10,9 @@ using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Resilience; +using Proton.Sdk; using Proton.Sdk.Addresses; +using Proton.Sdk.Api; using Proton.Sdk.Drive; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -193,22 +195,53 @@ private async ValueTask UploadBlobAsync( await using (nonDisposableDataPacketStream.ConfigureAwait(false)) { await Policy - // TODO: add unit tests to verify the retry conditions - .Handle(ex => !cancellationToken.IsCancellationRequested && ex is not FileContentsDecryptionException) + .Handle(IsExceptionHandledByRetry) .WaitAndRetryAsync( retryCount: 1, sleepDurationProvider: RetryPolicy.GetAttemptDelay, - onRetry: (exception, _, retryNumber, _) => + onRetryAsync: async (exception, _, retryNumber, _) => { - var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); - var blockIndex = request.Blocks.Count > 0 ? request.Blocks[0].Index : 0; - LogBlobUploadRetry(blockIndex, revisionUid, retryNumber, exception.FlattenMessage()); + await WaitOnRetryAfterIfNeeded(exception).ConfigureAwait(false); + + var blockInfo = GetBlockInfoForRequest(); + LogBlobUploadRetry(blockInfo.BlockIndex, blockInfo.RevisionUid, retryNumber, exception.FlattenMessage()); }) .ExecuteAsync(ExecuteUploadAsync).ConfigureAwait(false); } return; + (int BlockIndex, RevisionUid RevisionUid) GetBlockInfoForRequest() + { + var blockIndex = request.Blocks.Count > 0 ? request.Blocks[0].Index : 0; + var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); + + return (blockIndex, revisionUid); + } + + bool IsExceptionHandledByRetry(Exception ex) + { + return !cancellationToken.IsCancellationRequested + && ex is not FileContentsDecryptionException; + } + + async Task WaitOnRetryAfterIfNeeded(Exception ex) + { + if (ex is TooManyRequestsException exception) + { + var currentTime = DateTimeOffset.UtcNow; + + if (exception.RetryAfter is { } retryAfter && retryAfter > currentTime) + { + var delayDuration = retryAfter - currentTime; + var blockInfo = GetBlockInfoForRequest(); + + LogBlobUploadWaitingForRetryAfter(blockInfo.BlockIndex, blockInfo.RevisionUid, delayDuration); + await Task.Delay(delayDuration, cancellationToken).ConfigureAwait(false); + } + } + } + async Task ExecuteUploadAsync() { // FIXME: request multiple blocks at once @@ -233,4 +266,9 @@ await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Tok Level = LogLevel.Information, Message = "Retrying blob upload for block #{BlockIndex} of revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt error: {ErrorMessage}")] private partial void LogBlobUploadRetry(int blockIndex, RevisionUid revisionUid, int retryNumber, string errorMessage); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Waiting {DelayDuration} before retrying blob upload for block #{BlockIndex} of revision \"{RevisionUid}\" due to 429 response")] + private partial void LogBlobUploadWaitingForRetryAfter(int blockIndex, RevisionUid revisionUid, TimeSpan delayDuration); } diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs index b3e0a503..dc0421fe 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Runtime.Serialization; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Proton.Sdk.Api; @@ -26,7 +27,7 @@ public static async Task EnsureApiSuccessAsync( throw new ProtonApiException(responseMessage.StatusCode, response); } - case HttpStatusCode.BadRequest or HttpStatusCode.TooManyRequests: + case HttpStatusCode.BadRequest: { var response = await responseMessage.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken) .ConfigureAwait(false) ?? throw new JsonException(); @@ -34,9 +35,38 @@ public static async Task EnsureApiSuccessAsync( throw new ProtonApiException(responseMessage.StatusCode, response); } + case HttpStatusCode.TooManyRequests: + { + var response = await responseMessage.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken) + .ConfigureAwait(false) ?? throw new JsonException(); + + throw new TooManyRequestsException(responseMessage.StatusCode, response, GetRetryAfter(responseMessage)); + } + default: responseMessage.EnsureSuccessStatusCode(); break; } } + + private static DateTime? GetRetryAfter(HttpResponseMessage responseMessage) + { + var retryAfter = responseMessage.Headers.RetryAfter; + if (retryAfter == null) + { + return null; + } + + if (retryAfter.Delta is { } offset) + { + return DateTime.UtcNow.Add(offset); + } + + if (retryAfter.Date is { } date) + { + return date.UtcDateTime; + } + + throw new SerializationException("Invalid Retry-After header"); + } } diff --git a/cs/sdk/src/Proton.Sdk/TooManyRequestsException.cs b/cs/sdk/src/Proton.Sdk/TooManyRequestsException.cs new file mode 100644 index 00000000..e943fd67 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/TooManyRequestsException.cs @@ -0,0 +1,29 @@ +using System.Net; +using Proton.Sdk.Api; + +namespace Proton.Sdk; + +public class TooManyRequestsException : ProtonApiException +{ + public TooManyRequestsException() + { + } + + public TooManyRequestsException(string message) + : base(message) + { + } + + public TooManyRequestsException(string message, Exception innerException) + : base(message, innerException) + { + } + + internal TooManyRequestsException(HttpStatusCode statusCode, ApiResponse response, DateTime? retryAfter = null) + : base(statusCode, response) + { + RetryAfter = retryAfter; + } + + public DateTime? RetryAfter { get; } +} From fbc8f4d1e96ae9cbfce721a38d3d7a9dad102020 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 8 Jan 2026 06:33:11 +0000 Subject: [PATCH 422/791] Fix builds for Kotlin and Swift bindings broken due to Experimental attribute --- .../Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index 45ae545e..0f2b9134 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -6,6 +6,7 @@ true false proton_crypto + Photos From 66f3228d228b83a1229dc7cfb729e0cb24d924b3 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 9 Jan 2026 10:57:29 +0000 Subject: [PATCH 423/791] Add Kotlin bindings for isPaused --- .../me/proton/drive/sdk/DownloadController.kt | 24 +++++++++++++------ .../me/proton/drive/sdk/UploadController.kt | 21 +++++++++++----- .../sdk/internal/JniDownloadController.kt | 7 ++++++ .../drive/sdk/internal/JniUploadController.kt | 8 +++++++ 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index 150a6b85..51b09a11 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -1,8 +1,8 @@ package me.proton.drive.sdk +import kotlinx.coroutines.flow.MutableStateFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO -import me.proton.drive.sdk.LoggerProvider.Level.WARN import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.toLogId @@ -13,25 +13,34 @@ class DownloadController internal constructor( override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(downloader), AutoCloseable, Cancellable { + val isPausedFlow = MutableStateFlow(false) + suspend fun awaitCompletion() { log(DEBUG, "await completion") - runCatching { + return runCatching { + isPaused() bridge.awaitCompletion(handle) - }.onSuccess { log(INFO, "completed") } - .onFailure { log(INFO, "cancelled or failed") } - .getOrThrow() + }.onSuccess { + log(INFO, "completed") + }.onFailure { + log(INFO, "cancelled or failed") + isPaused() + }.getOrThrow() } suspend fun resume() { log(INFO, "resume") - bridge.resume(handle) + bridge.resume(handle).also { isPaused() } } suspend fun pause() { log(INFO, "pause") - bridge.pause(handle) + bridge.pause(handle).also { isPaused() } } + suspend fun isPaused() = bridge.isPaused(handle) + .also { isPausedFlow.emit(it) } + suspend fun isDownloadCompleteWithVerificationIssue(): Boolean { log(DEBUG, "isDownloadCompleteWithVerificationIssue") return bridge.isDownloadCompleteWithVerificationIssue(handle) @@ -40,6 +49,7 @@ class DownloadController internal constructor( override fun close() { log(DEBUG, "close") bridge.free(handle) + super.close() } override suspend fun cancel() { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index 03ca059b..ce0f1583 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -1,8 +1,8 @@ package me.proton.drive.sdk +import kotlinx.coroutines.flow.MutableStateFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO -import me.proton.drive.sdk.LoggerProvider.Level.WARN import me.proton.drive.sdk.entity.UploadResult import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId @@ -16,25 +16,34 @@ class UploadController internal constructor( override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(uploader), AutoCloseable, Cancellable { + val isPausedFlow = MutableStateFlow(false) + suspend fun awaitCompletion(): UploadResult { log(DEBUG, "await completion") return runCatching { + isPaused() bridge.awaitCompletion(handle) - }.onSuccess { log(INFO, "completed") } - .onFailure { log(INFO, "cancelled or failed") } - .getOrThrow() + }.onSuccess { + log(INFO, "completed") + }.onFailure { + log(INFO, "cancelled or failed") + isPaused() + }.getOrThrow() } suspend fun resume() { log(INFO, "resume") - bridge.resume(handle) + bridge.resume(handle).also { isPaused() } } suspend fun pause() { log(INFO, "pause") - bridge.pause(handle) + bridge.pause(handle).also { isPaused() } } + suspend fun isPaused() = bridge.isPaused(handle) + .also { isPausedFlow.emit(it) } + suspend fun dispose() = bridge.dispose(handle) override fun close() { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt index 8cf244c9..8194b8bc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt @@ -5,6 +5,7 @@ import me.proton.drive.sdk.extension.UnitResponseCallback import proton.drive.sdk.downloadControllerAwaitCompletionRequest import proton.drive.sdk.downloadControllerFreeRequest import proton.drive.sdk.downloadControllerIsDownloadCompleteWithVerificationIssueRequest +import proton.drive.sdk.downloadControllerIsPausedRequest import proton.drive.sdk.downloadControllerPauseRequest import proton.drive.sdk.downloadControllerResumeRequest @@ -29,6 +30,12 @@ class JniDownloadController internal constructor() : JniBaseProtonDriveSdk() { } } + suspend fun isPaused(handle: Long) = executeOnce("isPaused", BooleanResponseCallback) { + downloadControllerIsPaused = downloadControllerIsPausedRequest { + downloadControllerHandle = handle + } + } + suspend fun isDownloadCompleteWithVerificationIssue(handle: Long): Boolean = executeOnce("isDownloadCompleteWithVerificationIssue", BooleanResponseCallback) { downloadControllerIsDownloadCompleteWithVerificationIssue = diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt index edea9949..7fd55756 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt @@ -1,12 +1,14 @@ package me.proton.drive.sdk.internal import me.proton.drive.sdk.entity.UploadResult +import me.proton.drive.sdk.extension.BooleanResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback import me.proton.drive.sdk.extension.UploadResultResponseCallback import me.proton.drive.sdk.extension.toEntity import proton.drive.sdk.uploadControllerAwaitCompletionRequest import proton.drive.sdk.uploadControllerDisposeRequest import proton.drive.sdk.uploadControllerFreeRequest +import proton.drive.sdk.uploadControllerIsPausedRequest import proton.drive.sdk.uploadControllerPauseRequest import proton.drive.sdk.uploadControllerResumeRequest @@ -31,6 +33,12 @@ class JniUploadController internal constructor() : JniBaseProtonDriveSdk() { } } + suspend fun isPaused(handle: Long) = executeOnce("isPaused", BooleanResponseCallback) { + uploadControllerIsPaused = uploadControllerIsPausedRequest { + uploadControllerHandle = handle + } + } + suspend fun dispose(handle: Long) = executeOnce("dispose", UnitResponseCallback) { uploadControllerDispose = uploadControllerDisposeRequest { uploadControllerHandle = handle From b1f5884de6c54cdf2e48c12f48003c065d6ef6af Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 15:27:42 +0100 Subject: [PATCH 424/791] Prevent download cancellation from blocking future downloads --- .../internal/ProtonDriveSdkNativeClient.kt | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index faefbedf..15d8d7e3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -198,19 +198,23 @@ class ProtonDriveSdkNativeClient internal constructor( sdkHandle: Long, block: suspend () -> Response, ): Job = coroutineScope(operation).launch { - try { - handleResponse(sdkHandle, block()) - } catch (error: CancellationException) { - logger(DEBUG, "Operation $operation was cancelled") - handleResponse(sdkHandle, response { - this@response.error = - error.toProtonSdkError("Operation $operation was cancelled") - }) - throw error - } catch (error: Throwable) { - handleResponse(sdkHandle, response { - this@response.error = error.toProtonSdkError("Error while $operation") - }) + handleResponse(sdkHandle, block()) + }.also { job -> + job.invokeOnCompletion { error -> + when (error) { + null -> Unit // job completed normally + is CancellationException -> { + logger(DEBUG, "Operation $operation was cancelled") + handleResponse(sdkHandle, response { + this@response.error = + error.toProtonSdkError("Operation $operation was cancelled") + }) + } + + else -> handleResponse(sdkHandle, response { + this@response.error = error.toProtonSdkError("Error while $operation") + }) + } } } @@ -246,14 +250,18 @@ class ProtonDriveSdkNativeClient internal constructor( // parsing of protobuf needs to be done serially val value = parser(data) coroutineScope(callback).launch { - try { - block(value) - } catch (error: CancellationException) { - logger(DEBUG, "Callback $callback was cancelled") - throw error - } catch (error: Throwable) { - logger(WARN, "Error while $callback") - logger(WARN, error.stackTraceToString()) + block(value) + }.invokeOnCompletion { error -> + when (error) { + null -> Unit // job completed normally + is CancellationException -> { + logger(DEBUG, "Callback $callback was cancelled") + } + + else -> { + logger(WARN, "Error while $callback") + logger(WARN, error.stackTraceToString()) + } } } } catch (error: Throwable) { From ec0375903e1ef9661dbf3f6a57b2d2186029bc03 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 9 Jan 2026 12:48:09 +0000 Subject: [PATCH 425/791] Multiple public fixes --- js/sdk/src/internal/nodes/apiService.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.ts | 9 ++- js/sdk/src/internal/sharingPublic/index.ts | 14 +++- js/sdk/src/internal/sharingPublic/nodes.ts | 67 +++++++++++++++++-- .../sharingPublic/session/apiService.ts | 2 +- .../sharingPublic/session/interface.ts | 2 +- .../internal/sharingPublic/session/manager.ts | 9 ++- js/sdk/src/protonDriveClient.ts | 3 +- js/sdk/src/protonDrivePublicLinkClient.ts | 4 ++ 9 files changed, 98 insertions(+), 14 deletions(-) diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 44e4334f..03ae0ac7 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -138,7 +138,7 @@ export abstract class NodeAPIServiceBase< this.clientUid = clientUid; } - async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise { + async getNode(nodeUid: string, ownVolumeId: string | undefined, signal?: AbortSignal): Promise { const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, undefined, signal); const result = await nodesGenerator.next(); if (!result.value) { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index e76ea36e..142596fe 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -235,7 +235,7 @@ export abstract class NodesAccessBase< private async loadNode(nodeUid: string): Promise<{ node: TDecryptedNode; keys?: DecryptedNodeKeys }> { this.debouncer.loadingNode(nodeUid); try { - const { volumeId: ownVolumeId } = await this.shareService.getRootIDs(); + const ownVolumeId = await this.getOwnVolumeId(); const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); return this.decryptNode(encryptedNode); } finally { @@ -256,6 +256,11 @@ export abstract class NodesAccessBase< } } + protected async getOwnVolumeId(): Promise { + const { volumeId } = await this.shareService.getRootIDs(); + return volumeId; + } + private async *loadNodesWithMissingReport( nodeUids: string[], filterOptions?: FilterOptions, @@ -264,7 +269,7 @@ export abstract class NodesAccessBase< const returnedNodeUids: string[] = []; const errors = []; - const { volumeId: ownVolumeId } = await this.shareService.getRootIDs(); + const ownVolumeId = await this.getOwnVolumeId(); const apiNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal); diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 09155db8..4e5bd8e0 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -4,9 +4,10 @@ import { ProtonDriveTelemetry, ProtonDriveAccount, ProtonDriveEntitiesCache, + MemberRole, } from '../../interface'; import { DriveAPIService } from '../apiService'; -import { NodeAPIService } from '../nodes/apiService'; +import { SharingPublicNodesAPIService } from './nodes'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; @@ -38,6 +39,7 @@ export function initSharingPublicModule( token: string, publicShareKey: PrivateKey, publicRootNodeUid: string, + publicRole: MemberRole, isAnonymousContext: boolean, ) { const shares = new SharingPublicSharesManager(account, publicShareKey, publicRootNodeUid); @@ -53,6 +55,7 @@ export function initSharingPublicModule( token, publicShareKey, publicRootNodeUid, + publicRole, isAnonymousContext, ); @@ -80,10 +83,17 @@ export function initSharingPublicNodesModule( token: string, publicShareKey: PrivateKey, publicRootNodeUid: string, + publicRole: MemberRole, isAnonymousContext: boolean, ) { const clientUid = undefined; // No client UID for public context yet. - const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService, clientUid); + const api = new SharingPublicNodesAPIService( + telemetry.getLogger('nodes-api'), + apiService, + clientUid, + publicRootNodeUid, + publicRole, + ); const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new SharingPublicCryptoReporter(telemetry); diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index 9be4edfc..8484a23b 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -1,15 +1,61 @@ -import { NodeResult, ProtonDriveTelemetry } from '../../interface'; -import { NodeAPIService } from '../nodes/apiService'; +import { type Logger, MemberRole, NodeResult, ProtonDriveTelemetry } from '../../interface'; +import { NodeAPIService, linkToEncryptedNode } from '../nodes/apiService'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; import { NodesAccess } from '../nodes/nodesAccess'; import { NodesManagement } from '../nodes/nodesManagement'; import { isProtonDocument, isProtonSheet } from '../nodes/mediaTypes'; -import { splitNodeUid } from '../uids'; +import { makeNodeUid, splitNodeUid } from '../uids'; import { SharingPublicSharesManager } from './shares'; -import { DecryptedNode, DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface'; +import { DecryptedNode, DecryptedNodeKeys, NodeSigningKeys, EncryptedNode } from '../nodes/interface'; import { PrivateKey } from '../../crypto'; +import { type DriveAPIService, drivePaths } from '../apiService'; + +type PostLoadLinksMetadataResponse = + drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; + +/** + * Custom API service for public links that handles permission injection. + * + * TEMPORARY: This is a workaround for the backend sending DirectPermissions as null + * for public requests. + * + * The service injects publicPermissions into the root node's directRole to ensure + * correct permission handling throughout the SDK. + */ +export class SharingPublicNodesAPIService extends NodeAPIService { + constructor( + logger: Logger, + apiService: DriveAPIService, + clientUid: string | undefined, + private publicRootNodeUid: string, + private publicRole: MemberRole, + ) { + super(logger, apiService, clientUid); + this.publicRootNodeUid = publicRootNodeUid; + this.publicRole = publicRole; + } + + protected linkToEncryptedNode( + volumeId: string, + link: PostLoadLinksMetadataResponse['Links'][0], + isOwnVolumeId: boolean, + ): EncryptedNode { + const nodeUid = makeNodeUid(volumeId, link.Link.LinkID); + const encryptedNode = linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); + + // TEMPORARY: Inject public permissions for the root node only. + // This ensures the root node has the correct directRole instead of + // incorrectly falling back to 'admin' due to null DirectPermissions. + // May be fixed by backend later. + if (this.publicRootNodeUid === nodeUid) { + encryptedNode.directRole = this.publicRole; + } + + return encryptedNode; + } +} export class SharingPublicNodesAccess extends NodesAccess { constructor( @@ -32,6 +78,19 @@ export class SharingPublicNodesAccess extends NodesAccess { this.isAnonymousContext = isAnonymousContext; } + /** + * Returns undefined for public link context to prevent incorrect volume ownership detection. + * + * TEMPORARY: When requesting nodes in public link context, we need to ensure nodes are not + * incorrectly marked as owned by the user. In public context (especially for anonymous users), + * there is no "own volume", so we return undefined to prevent the SDK from comparing + * volumeId === ownVolumeId and incorrectly granting admin permissions. + * May be fixed by backend later. + */ + protected async getOwnVolumeId(): Promise { + return undefined; + } + async getParentKeys( node: Pick, ): Promise> { diff --git a/js/sdk/src/internal/sharingPublic/session/apiService.ts b/js/sdk/src/internal/sharingPublic/session/apiService.ts index 2eb42b29..96261ee2 100644 --- a/js/sdk/src/internal/sharingPublic/session/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/session/apiService.ts @@ -88,7 +88,7 @@ export class SharingPublicSessionAPIService { base64UrlPasswordSalt: response.Share.SharePasswordSalt, armoredKey: response.Share.ShareKey, armoredPassphrase: response.Share.SharePassphrase, - publicRole: permissionsToMemberRole(this.logger, response.Share.PublicPermissions), + publicPermissions: response.Share.PublicPermissions, }, rootUid: makeNodeUid(response.Share.VolumeID, response.Share.LinkID), }; diff --git a/js/sdk/src/internal/sharingPublic/session/interface.ts b/js/sdk/src/internal/sharingPublic/session/interface.ts index d717b909..7a87eed9 100644 --- a/js/sdk/src/internal/sharingPublic/session/interface.ts +++ b/js/sdk/src/internal/sharingPublic/session/interface.ts @@ -36,5 +36,5 @@ export type EncryptedShareCrypto = { base64UrlPasswordSalt: string; armoredKey: string; armoredPassphrase: string; - publicRole: MemberRole; + publicPermissions?: number; }; diff --git a/js/sdk/src/internal/sharingPublic/session/manager.ts b/js/sdk/src/internal/sharingPublic/session/manager.ts index c17f23f9..fb592e57 100644 --- a/js/sdk/src/internal/sharingPublic/session/manager.ts +++ b/js/sdk/src/internal/sharingPublic/session/manager.ts @@ -1,6 +1,6 @@ -import { MemberRole, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../../interface'; +import { Logger, MemberRole, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../../interface'; import { DriveCrypto, PrivateKey, SRPModule } from '../../../crypto'; -import { DriveAPIService } from '../../apiService'; +import { DriveAPIService, permissionsToMemberRole } from '../../apiService'; import { SharingPublicSessionAPIService } from './apiService'; import { SharingPublicSessionHttpClient } from './httpClient'; import { EncryptedShareCrypto, PublicLinkInfo } from './interface'; @@ -17,6 +17,8 @@ export class SharingPublicSessionManager { private infosPerToken: Map = new Map(); + private logger: Logger; + constructor( telemetry: ProtonDriveTelemetry, private httpClient: ProtonDriveHTTPClient, @@ -24,6 +26,7 @@ export class SharingPublicSessionManager { private srpModule: SRPModule, apiService: DriveAPIService, ) { + this.logger = telemetry.getLogger('sharingPublicSession'); this.httpClient = httpClient; this.driveCrypto = driveCrypto; this.srpModule = srpModule; @@ -85,6 +88,7 @@ export class SharingPublicSessionManager { httpClient: SharingPublicSessionHttpClient; shareKey: PrivateKey; rootUid: string; + publicRole: MemberRole; }> { const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url); @@ -105,6 +109,7 @@ export class SharingPublicSessionManager { httpClient: new SharingPublicSessionHttpClient(this.httpClient, session), shareKey, rootUid, + publicRole: permissionsToMemberRole(this.logger, encryptedShare.publicPermissions), }; } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 539d62d7..d83aa36b 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -228,7 +228,7 @@ export class ProtonDriveClient { }, authPublicLink: async (url: string, customPassword?: string, isAnonymousContext: boolean = false) => { this.logger.info(`Authenticating public link ${url}`); - const { httpClient, token, shareKey, rootUid } = await this.publicSessionManager.auth( + const { httpClient, token, shareKey, rootUid, publicRole } = await this.publicSessionManager.auth( url, customPassword, ); @@ -244,6 +244,7 @@ export class ProtonDriveClient { publicShareKey: shareKey, publicRootNodeUid: rootUid, isAnonymousContext, + publicRole, }); }, }; diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 31a6a1aa..c763ade4 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -19,6 +19,7 @@ import { FileUploader, NodeResult, SDKEvent, + MemberRole, } from './interface'; import { Telemetry } from './telemetry'; import { @@ -82,6 +83,7 @@ export class ProtonDrivePublicLinkClient { publicShareKey, publicRootNodeUid, isAnonymousContext, + publicRole, }: { httpClient: ProtonDriveHTTPClient; account: ProtonDriveAccount; @@ -94,6 +96,7 @@ export class ProtonDrivePublicLinkClient { publicShareKey: PrivateKey; publicRootNodeUid: string; isAnonymousContext: boolean; + publicRole: MemberRole; }) { if (!telemetry) { telemetry = new Telemetry(); @@ -126,6 +129,7 @@ export class ProtonDrivePublicLinkClient { token, publicShareKey, publicRootNodeUid, + publicRole, isAnonymousContext, ); this.download = initDownloadModule( From 5889e7b0b866ba8a2352eb2dfc3ccb69a6049992 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 12 Jan 2026 14:36:56 +0100 Subject: [PATCH 426/791] Add support for photo decryption through album key packet --- .../src/Proton.Drive.Sdk/Api/Files/FileDto.cs | 2 +- .../Proton.Drive.Sdk/Api/Links/LinkType.cs | 1 + .../Caching/IDriveEntityCache.cs | 10 +- .../Caching/IDriveSecretCache.cs | 2 + .../Proton.Drive.Sdk/Caching/IEntityCache.cs | 15 ++ .../Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs | 4 +- .../Nodes/Cryptography/NodeCrypto.cs | 21 +- .../Nodes/DtoToMetadataConverter.cs | 86 +++++--- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 2 +- .../Proton.Photos.Sdk/Api/IPhotosApiClient.cs | 8 +- .../Api/PhotoDetailsResponse.cs | 8 + .../Api/PhotoLinkDetailsDto.cs | 35 ++++ .../Proton.Photos.Sdk/Api/Photos/PhotoDto.cs | 17 +- .../Api/Photos/PhotoListResponse.cs | 6 - .../Proton.Photos.Sdk/Api/Photos/PhotoTag.cs | 2 +- .../Api/Photos/RelatedPhotoDto.cs | 19 ++ .../Api/Photos/TimelinePhotoDto.cs | 23 +++ ...Request.cs => TimelinePhotoListRequest.cs} | 2 +- .../Api/Photos/TimelinePhotoListResponse.cs | 6 + .../Proton.Photos.Sdk/Api/PhotosApiClient.cs | 16 +- .../Caching/IPhotosClientCache.cs | 6 +- .../Caching/IPhotosEntityCache.cs | 11 +- .../Caching/IPhotosSecretCache.cs | 18 -- .../Caching/PhotosClientCache.cs | 5 +- .../Caching/PhotosSecretCache.cs | 28 ++- .../Nodes/PhotoDtoToMetadataConverter.cs | 184 ++++++++++++++++++ .../Nodes/PhotoNodeBatchLoader.cs | 38 ++++ .../Nodes/PhotosDownloader.cs | 6 +- .../Nodes/PhotosNodeOperations.cs | 46 ++++- .../Proton.Photos.Sdk/ProtonPhotosClient.cs | 10 +- .../PhotosApiSerializerContext.cs | 6 +- 31 files changed, 533 insertions(+), 110 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoListResponse.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs rename cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/{PhotoTimelineRequest.cs => TimelinePhotoListRequest.cs} (86%) create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs index 6c3b2228..c1fd10b8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs @@ -3,7 +3,7 @@ namespace Proton.Drive.Sdk.Api.Files; -internal sealed class FileDto +internal class FileDto { public required string MediaType { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs index 83d8270f..76c0a2db 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs @@ -4,4 +4,5 @@ internal enum LinkType { Folder = 1, File = 2, + Album = 3, } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index 35c49ffb..8230ec90 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -2,11 +2,10 @@ using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; -using Proton.Sdk; namespace Proton.Drive.Sdk.Caching; -internal interface IDriveEntityCache +internal interface IDriveEntityCache : IEntityCache { ValueTask SetClientUidAsync(string clientUid, CancellationToken cancellationToken); ValueTask TryGetClientUidAsync(CancellationToken cancellationToken); @@ -20,13 +19,6 @@ internal interface IDriveEntityCache ValueTask SetShareAsync(Share share, CancellationToken cancellationToken); ValueTask TryGetShareAsync(ShareId shareId, CancellationToken cancellationToken); - ValueTask SetNodeAsync( - NodeUid nodeId, - Result nodeProvisionResult, - ShareId? membershipShareId, - ReadOnlyMemory nameHashDigest, - CancellationToken cancellationToken); - ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs index d1970666..59542613 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -8,6 +8,7 @@ namespace Proton.Drive.Sdk.Caching; internal interface IDriveSecretCache { ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken); + ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken); ValueTask SetFolderSecretsAsync( @@ -18,5 +19,6 @@ ValueTask SetFolderSecretsAsync( ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); ValueTask SetFileSecretsAsync(NodeUid nodeId, Result secretsProvisionResult, CancellationToken cancellationToken); + ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs new file mode 100644 index 00000000..ee1503b2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs @@ -0,0 +1,15 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Caching; + +internal interface IEntityCache +{ + ValueTask SetNodeAsync( + NodeUid nodeId, + Result nodeProvisionResult, + ShareId? membershipShareId, + ReadOnlyMemory nameHashDigest, + CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs index ee2be2ea..c8722c53 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs @@ -11,7 +11,7 @@ internal readonly struct AuthorshipClaim(Author author, IReadOnlyList CreateAsync( - ProtonDriveClient client, + IAccountClient accountClient, string? claimedAuthorEmailAddress, CancellationToken cancellationToken) { @@ -22,7 +22,7 @@ public static async ValueTask CreateAsync( try { - var keys = await client.Account.GetAddressPublicKeysAsync(claimedAuthorEmailAddress, cancellationToken).ConfigureAwait(false); + var keys = await accountClient.GetAddressPublicKeysAsync(claimedAuthorEmailAddress, cancellationToken).ConfigureAwait(false); return new AuthorshipClaim(new Author { EmailAddress = claimedAuthorEmailAddress }, keys); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index 950c4e15..c92b3b54 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; -using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Serialization; using Proton.Sdk; @@ -14,15 +13,15 @@ namespace Proton.Drive.Sdk.Nodes.Cryptography; internal static class NodeCrypto { public static async ValueTask DecryptFolderAsync( - ProtonDriveClient client, + IAccountClient accountClient, LinkDto link, - FolderDto folder, + PgpArmoredMessage folderHashKey, Result parentKeyResult, CancellationToken cancellationToken) { - var linkDecryptionResult = await DecryptLinkAsync(client, link, parentKeyResult, cancellationToken).ConfigureAwait(false); + var linkDecryptionResult = await DecryptLinkAsync(accountClient, link, parentKeyResult, cancellationToken).ConfigureAwait(false); - var hashKeyResult = DecryptHashKey(folder.HashKey, linkDecryptionResult.NodeKey.GetValueOrDefault(), linkDecryptionResult.NodeAuthorshipClaim); + var hashKeyResult = DecryptHashKey(folderHashKey, linkDecryptionResult.NodeKey.GetValueOrDefault(), linkDecryptionResult.NodeAuthorshipClaim); return new FolderDecryptionResult { @@ -32,7 +31,7 @@ public static async ValueTask DecryptFolderAsync( } public static async ValueTask DecryptFileAsync( - ProtonDriveClient client, + IAccountClient accountClient, LinkDto linkDto, FileDto fileDto, ActiveRevisionDto activeRevisionDto, @@ -40,9 +39,9 @@ public static async ValueTask DecryptFileAsync( CancellationToken cancellationToken) { var contentAuthorshipClaim = - await AuthorshipClaim.CreateAsync(client, activeRevisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + await AuthorshipClaim.CreateAsync(accountClient, activeRevisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); - var linkDecryptionResult = await DecryptLinkAsync(client, linkDto, parentKeyResult, cancellationToken).ConfigureAwait(false); + var linkDecryptionResult = await DecryptLinkAsync(accountClient, linkDto, parentKeyResult, cancellationToken).ConfigureAwait(false); var nodeKey = linkDecryptionResult.NodeKey.GetValueOrDefault(); @@ -80,15 +79,15 @@ public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHa } private static async ValueTask DecryptLinkAsync( - ProtonDriveClient client, + IAccountClient accountClient, LinkDto link, Result parentKeyResult, CancellationToken cancellationToken) { - var nodeAuthorshipClaim = await AuthorshipClaim.CreateAsync(client, link.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + var nodeAuthorshipClaim = await AuthorshipClaim.CreateAsync(accountClient, link.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); var nameAuthorshipClaim = link.NameSignatureEmailAddress != link.SignatureEmailAddress - ? await AuthorshipClaim.CreateAsync(client, link.NameSignatureEmailAddress, cancellationToken).ConfigureAwait(false) + ? await AuthorshipClaim.CreateAsync(accountClient, link.NameSignatureEmailAddress, cancellationToken).ConfigureAwait(false) : nodeAuthorshipClaim; Result, string> nameResult; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 1e270b69..409b52ba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -1,6 +1,7 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes.Cryptography; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; @@ -40,11 +41,25 @@ public static async Task> ConvertDtoT return linkType switch { LinkType.Folder => - (await ConvertDtoToFolderMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false)) + (await ConvertDtoToFolderMetadataAsync( + client.Account, + client.Cache.Entities, + client.Cache.Secrets, + volumeId, + linkDetailsDto, + parentKeyResult, + cancellationToken).ConfigureAwait(false)) .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), LinkType.File => - (await ConvertDtoToFileMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false)) + (await ConvertDtoToFileMetadataAsync( + client.Account, + client.Cache.Entities, + client.Cache.Secrets, + volumeId, + linkDetailsDto, + parentKeyResult, + cancellationToken).ConfigureAwait(false)) .Convert(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), // FIXME: handle other existing node types, and determine a way for forward compatibility or degraded result in case a new node type is introduced @@ -53,13 +68,17 @@ public static async Task> ConvertDtoT } public static async ValueTask> ConvertDtoToFolderMetadataAsync( - ProtonDriveClient client, + IAccountClient accountClient, + IEntityCache entityCache, + IDriveSecretCache secretCache, VolumeId volumeId, LinkDetailsDto linkDetailsDto, Result parentKeyResult, CancellationToken cancellationToken) { - var (linkDto, folderDto, _, _, membershipDto) = linkDetailsDto; + var linkDto = linkDetailsDto.Link; + var folderDto = linkDetailsDto.Folder; + var membershipDto = linkDetailsDto.Membership; if (folderDto is null) { @@ -70,7 +89,7 @@ public static async ValueTask> Co var uid = new NodeUid(volumeId, linkDto.Id); var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; - var decryptionResult = await NodeCrypto.DecryptFolderAsync(client, linkDto, folderDto, parentKeyResult, cancellationToken).ConfigureAwait(false); + var decryptionResult = await NodeCrypto.DecryptFolderAsync(accountClient, linkDto, folderDto.HashKey, parentKeyResult, cancellationToken).ConfigureAwait(false); var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); var nodeKeyIsInvalid = !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); @@ -123,7 +142,7 @@ public static async ValueTask> Co HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), }; - await client.Cache.Secrets.SetFolderSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); + await secretCache.SetFolderSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); // FIXME: remove entity cache or cache degraded node @@ -139,7 +158,7 @@ public static async ValueTask> Co PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous ? passphraseOutput.Data : null, }; - await client.Cache.Secrets.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + await secretCache.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); var node = new FolderNode { @@ -152,19 +171,23 @@ public static async ValueTask> Co TrashTime = linkDto.TrashTime, }; - await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + await entityCache.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } public static async Task> ConvertDtoToFileMetadataAsync( - ProtonDriveClient client, + IAccountClient accountClient, + IEntityCache entityCache, + IDriveSecretCache secretCache, VolumeId volumeId, LinkDetailsDto linkDetailsDto, Result parentKeyResult, CancellationToken cancellationToken) { - var (linkDto, _, fileDto, _, membershipDto) = linkDetailsDto; + var linkDto = linkDetailsDto.Link; + var fileDto = linkDetailsDto.File; + var membershipDto = linkDetailsDto.Membership; if (fileDto is null) { @@ -187,7 +210,7 @@ public static async Task> ConvertDtoT var uid = new NodeUid(volumeId, linkDto.Id); var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; - var decryptionResult = await NodeCrypto.DecryptFileAsync(client, linkDto, fileDto, activeRevisionDto, parentKeyResult, cancellationToken) + var decryptionResult = await NodeCrypto.DecryptFileAsync(accountClient, linkDto, fileDto, activeRevisionDto, parentKeyResult, cancellationToken) .ConfigureAwait(false); var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); @@ -281,7 +304,7 @@ public static async Task> ConvertDtoT ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), }; - await client.Cache.Secrets.SetFileSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); + await secretCache.SetFileSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); // FIXME: remove entity cache or cache degraded node return new DegradedFileMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); @@ -325,9 +348,9 @@ public static async Task> ConvertDtoT TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, }; - await client.Cache.Secrets.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + await secretCache.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); - await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + await entityCache.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } @@ -338,6 +361,8 @@ private static async ValueTask> GetParen LinkId? parentId, ShareAndKey? shareAndKeyToUse, ShareId? childShareId, + IDriveSecretCache secretCache, + Func, CancellationToken, Task> getLinkDetails, CancellationToken cancellationToken) { if (childShareId is not null && childShareId == shareAndKeyToUse?.Share.Id) @@ -362,8 +387,9 @@ private static async ValueTask> GetParen break; } - var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(new NodeUid(volumeId, currentId.Value), cancellationToken) - .ConfigureAwait(false); + var nodeUid = new NodeUid(volumeId, currentId.Value); + + var folderSecretsResult = await secretCache.TryGetFolderSecretsAsync(nodeUid, cancellationToken).ConfigureAwait(false); var folderKey = folderSecretsResult?.Merge(x => x.Key, x => x.Key); @@ -373,17 +399,13 @@ private static async ValueTask> GetParen break; } - var linkDetailsResponse = await client.Api.Links.GetDetailsAsync(volumeId, [currentId.Value], cancellationToken).ConfigureAwait(false); - - var linkDetails = linkDetailsResponse.Links[0]; + var linkDetails = await getLinkDetails([currentId.Value], cancellationToken).ConfigureAwait(false); linkAncestry.Push(linkDetails); - var (link, _, _, sharing, _) = linkDetails; - - currentShareId = sharing?.ShareId; + currentShareId = linkDetails.Sharing?.ShareId; - currentId = link.ParentId; + currentId = linkDetails.Link.ParentId; } } catch (Exception e) @@ -428,4 +450,22 @@ private static async ValueTask> GetParen return currentParentKey; } + + private static async ValueTask> GetParentKeyAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkId? parentId, + ShareAndKey? shareAndKeyToUse, + ShareId? childShareId, + CancellationToken cancellationToken) + { + return await GetParentKeyAsync(client, volumeId, parentId, shareAndKeyToUse, childShareId, client.Cache.Secrets, GetLinkDetailsAsync, cancellationToken) + .ConfigureAwait(false); + + async Task GetLinkDetailsAsync(IEnumerable links, CancellationToken ct) + { + var response = await client.Api.Links.GetDetailsAsync(volumeId, links, ct).ConfigureAwait(false); + return response.Links[0]; + } + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 56a4b767..c9dfcabe 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -539,7 +539,7 @@ private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriv await client.Cache.Secrets.SetShareKeyAsync(share.Id, shareKey, cancellationToken).ConfigureAwait(false); await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); - var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync(client, volumeDto.Id, linkDetailsDto, shareKey, cancellationToken) + var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync(client.Account, client.Cache.Entities, client.Cache.Secrets, volumeDto.Id, linkDetailsDto, shareKey, cancellationToken) .ConfigureAwait(false); return metadataResult.GetValueOrThrow().Node; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs index db25d4b0..bf281a8e 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs @@ -1,5 +1,7 @@ -using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Volumes; using Proton.Photos.Sdk.Api.Photos; namespace Proton.Photos.Sdk.Api; @@ -10,5 +12,7 @@ internal interface IPhotosApiClient ValueTask GetRootShareAsync(CancellationToken cancellationToken); - ValueTask GetPhotosTimelineAsync(PhotoTimelineRequest request, CancellationToken cancellationToken); + ValueTask GetTimelinePhotosAsync(TimelinePhotoListRequest request, CancellationToken cancellationToken); + + ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs new file mode 100644 index 00000000..ccca8237 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Photos.Sdk.Api; + +internal sealed class PhotoDetailsResponse : ApiResponse +{ + public required IReadOnlyList Links { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs new file mode 100644 index 00000000..8e388263 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs @@ -0,0 +1,35 @@ +using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Links; +using Proton.Photos.Sdk.Api.Photos; + +namespace Proton.Photos.Sdk.Api; + +internal sealed class PhotoLinkDetailsDto +{ + public required LinkDto Link { get; init; } + public PhotoDto? Photo { get; init; } + public FolderDto? Album { get; init; } + public LinkSharingDto? Sharing { get; init; } + public ShareMembershipSummaryDto? Membership { get; init; } + + public void Deconstruct(out LinkDto link, out PhotoDto? photo, out FolderDto? album, out LinkSharingDto? sharing, out ShareMembershipSummaryDto? membership) + { + link = Link; + photo = Photo; + album = Album; + sharing = Sharing; + membership = Membership; + } + + public LinkDetailsDto ToLinkDetailsDto() + { + return new LinkDetailsDto + { + Link = Link, + Folder = Album, + File = Photo, + Sharing = Sharing, + Membership = Membership, + }; + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs index 51ae5a51..7d0706e2 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs @@ -1,29 +1,30 @@ using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Links; using Proton.Sdk.Serialization; namespace Proton.Photos.Sdk.Api.Photos; -internal sealed class PhotoDto +internal sealed class PhotoDto : FileDto { [JsonPropertyName("LinkID")] - public required LinkId Id { get; init; } + public LinkId? Id { get; init; } [JsonConverter(typeof(EpochSecondsJsonConverter))] public required DateTime CaptureTime { get; init; } - [JsonPropertyName("Hash")] - public required string NameHash { get; init; } + public string? ContentHash { get; init; } - public required string ContentHash { get; init; } + [JsonPropertyName("Hash")] + public string? NameHash { get; init; } [JsonPropertyName("MainPhotoLinkID")] public string? MainPhotoLinkId { get; init; } [JsonPropertyName("RelatedPhotosLinkIDs")] - public IReadOnlyList RelatedPhotosLinkIds { get; init; } = []; + public required IReadOnlyList RelatedPhotosLinkIds { get; init; } = []; - public IReadOnlyList Tags { get; init; } = []; + public required IReadOnlyList Tags { get; init; } = []; - public IReadOnlyList Albums { get; init; } = []; + public required IReadOnlyList Albums { get; init; } = []; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoListResponse.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoListResponse.cs deleted file mode 100644 index 96779587..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoListResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Proton.Photos.Sdk.Api.Photos; - -internal sealed class PhotoListResponse -{ - public required IReadOnlyList Photos { get; init; } -} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs index 461c0bcf..7c4ca1df 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs @@ -12,4 +12,4 @@ public enum PhotoTag Bursts = 7, Panoramas = 8, Raw = 9, -}; +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs new file mode 100644 index 00000000..2a391066 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Serialization; + +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class RelatedPhotoDto +{ + [JsonPropertyName("LinkID")] + public required LinkId Id { get; init; } + + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CaptureTime { get; init; } + + [JsonPropertyName("Hash")] + public required string NameHash { get; init; } + + public string? ContentHash { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs new file mode 100644 index 00000000..71d7bdc8 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Serialization; + +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class TimelinePhotoDto +{ + [JsonPropertyName("LinkID")] + public required LinkId Id { get; init; } + + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CaptureTime { get; init; } + + [JsonPropertyName("Hash")] + public required string NameHash { get; init; } + + public string? ContentHash { get; init; } + + public required IReadOnlyList RelatedPhotos { get; init; } = []; + + public required IReadOnlyList Tags { get; init; } = []; +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTimelineRequest.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs similarity index 86% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTimelineRequest.cs rename to cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs index 8e36d78c..a723aa42 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTimelineRequest.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs @@ -4,7 +4,7 @@ namespace Proton.Photos.Sdk.Api.Photos; -internal sealed class PhotoTimelineRequest +internal sealed class TimelinePhotoListRequest { public required VolumeId VolumeId { get; init; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs new file mode 100644 index 00000000..203b3023 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Photos.Sdk.Api.Photos; + +internal sealed class TimelinePhotoListResponse +{ + public required IReadOnlyList Photos { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs index e7064a32..2eca4709 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs @@ -1,6 +1,8 @@ -using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; using Proton.Photos.Sdk.Api.Photos; using Proton.Photos.Sdk.Serialization; using Proton.Sdk.Http; @@ -25,12 +27,20 @@ public async ValueTask GetRootShareAsync(CancellationToken canc .GetAsync("v2/shares/photos", cancellationToken).ConfigureAwait(false); } - public async ValueTask GetPhotosTimelineAsync(PhotoTimelineRequest request, CancellationToken cancellationToken) + public async ValueTask GetTimelinePhotosAsync(TimelinePhotoListRequest request, CancellationToken cancellationToken) { var query = request.PreviousPageLastLinkId is not null ? $"?PreviousPageLastLinkID={request.PreviousPageLastLinkId}" : string.Empty; return await _httpClient - .Expecting(PhotosApiSerializerContext.Default.PhotoListResponse) + .Expecting(PhotosApiSerializerContext.Default.TimelinePhotoListResponse) .GetAsync($"volumes/{request.VolumeId}/photos{query}", cancellationToken).ConfigureAwait(false); } + + public async ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(PhotosApiSerializerContext.Default.PhotoDetailsResponse) + .PostAsync($"photos/volumes/{volumeId}/links", new LinkDetailsRequest(linkIds), DriveApiSerializerContext.Default.LinkDetailsRequest, cancellationToken) + .ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs index e02a139b..df8a68e9 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs @@ -1,7 +1,9 @@ -namespace Proton.Photos.Sdk.Caching; +using Proton.Drive.Sdk.Caching; + +namespace Proton.Photos.Sdk.Caching; internal interface IPhotosClientCache { IPhotosEntityCache Entities { get; } - IPhotosSecretCache Secrets { get; } + IDriveSecretCache Secrets { get; } } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs index 8b51fb86..f6121286 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs @@ -1,12 +1,12 @@ using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; -using Proton.Sdk; namespace Proton.Photos.Sdk.Caching; -internal interface IPhotosEntityCache +internal interface IPhotosEntityCache : IEntityCache { ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); @@ -16,12 +16,5 @@ internal interface IPhotosEntityCache ValueTask SetShareAsync(Share share, CancellationToken cancellationToken); - ValueTask SetNodeAsync( - NodeUid nodeId, - Result nodeProvisionResult, - ShareId? membershipShareId, - ReadOnlyMemory nameHashDigest, - CancellationToken cancellationToken); - ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs deleted file mode 100644 index d9a392d9..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosSecretCache.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Nodes; -using Proton.Sdk; - -namespace Proton.Photos.Sdk.Caching; - -internal interface IPhotosSecretCache -{ - ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken); - - ValueTask SetFolderSecretsAsync( - NodeUid nodeId, - Result secretsProvisionResult, - CancellationToken cancellationToken); - - ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); -} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs index 9d93455a..a318a1bd 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs @@ -1,4 +1,5 @@ -using Proton.Sdk.Caching; +using Proton.Drive.Sdk.Caching; +using Proton.Sdk.Caching; namespace Proton.Photos.Sdk.Caching; @@ -7,5 +8,5 @@ internal class PhotosClientCache( ICacheRepository secretCacheRepository) : IPhotosClientCache { public IPhotosEntityCache Entities { get; } = new PhotosEntityCache(entityCacheRepository); - public IPhotosSecretCache Secrets { get; } = new PhotosSecretCache(secretCacheRepository); + public IDriveSecretCache Secrets { get; } = new DriveSecretCache(secretCacheRepository); } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs index 0ad36937..c3167ae2 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Serialization; using Proton.Sdk; @@ -9,7 +10,7 @@ namespace Proton.Photos.Sdk.Caching; -internal sealed class PhotosSecretCache(ICacheRepository repository) : IPhotosSecretCache +internal sealed class PhotosSecretCache(ICacheRepository repository) : IDriveSecretCache { private readonly ICacheRepository _repository = repository; @@ -20,6 +21,11 @@ public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, Cance return _repository.SetAsync(GetShareKeyCacheKey(shareId), serializedValue, cancellationToken); } + public ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + public ValueTask SetFolderSecretsAsync( NodeUid nodeId, Result secretsProvisionResult, @@ -39,6 +45,21 @@ public ValueTask SetFolderSecretsAsync( : null; } + public ValueTask SetFileSecretsAsync( + NodeUid nodeId, + Result secretsProvisionResult, + CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(secretsProvisionResult, DriveSecretsSerializerContext.Default.ResultFileSecretsDegradedFileSecrets); + + return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); + } + + public ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + private static string GetShareKeyCacheKey(ShareId shareId) { return $"share:{shareId}:key"; @@ -48,4 +69,9 @@ private static string GetFolderSecretsCacheKey(NodeUid nodeId) { return $"folder:{nodeId}:secrets"; } + + private static string GetFileSecretsCacheKey(NodeUid nodeId) + { + return $"file:{nodeId}:secrets"; + } } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs new file mode 100644 index 00000000..ccabef0d --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs @@ -0,0 +1,184 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Photos.Sdk.Api; +using Proton.Sdk; + +namespace Proton.Photos.Sdk.Nodes; + +internal static class PhotoDtoToMetadataConverter +{ + public static async Task> ConvertDtoToNodeMetadataAsync( + ProtonPhotosClient client, + VolumeId volumeId, + PhotoLinkDetailsDto photoLinkDetailsDto, + ShareAndKey? knownShareAndKey, + CancellationToken cancellationToken) + { + if (photoLinkDetailsDto.Link.ParentId == null && photoLinkDetailsDto.Sharing?.ShareId == null && photoLinkDetailsDto.Photo?.Albums.Count == 0) + { + throw new InvalidOperationException("Photo node has no parent, share or album"); + } + + LinkId? parentId; + + if (photoLinkDetailsDto.Link.ParentId != null || photoLinkDetailsDto.Sharing?.ShareId != null) + { + parentId = photoLinkDetailsDto.Link.ParentId; + } + else + { + // TODO: Optimization + // If more than one album is available, select an album with a cached key to avoid a redundant HTTP request and decryption. + parentId = photoLinkDetailsDto.Photo?.Albums[0].Id; + } + + var parentKeyResult = await GetParentKeyAsync( + client, + volumeId, + parentId, + knownShareAndKey, + photoLinkDetailsDto.Sharing?.ShareId, + cancellationToken).ConfigureAwait(false); + + return await ConvertDtoToNodeMetadataAsync(client, volumeId, photoLinkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false); + } + + private static async Task> ConvertDtoToNodeMetadataAsync( + ProtonPhotosClient client, + VolumeId volumeId, + PhotoLinkDetailsDto photoLinkDetailsDto, + Result parentKeyResult, + CancellationToken cancellationToken) + { + var linkType = photoLinkDetailsDto.Link.Type; + var linkDetailsDto = photoLinkDetailsDto.ToLinkDetailsDto(); + + return linkType switch + { + LinkType.File => + (await DtoToMetadataConverter.ConvertDtoToFileMetadataAsync( + client.DriveClient.Account, + client.Cache.Entities, + client.Cache.Secrets, + volumeId, + linkDetailsDto, + parentKeyResult, + cancellationToken).ConfigureAwait(false)) + .Convert(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), + + LinkType.Album => + (await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( + client.DriveClient.Account, + client.Cache.Entities, + client.Cache.Secrets, + volumeId, + linkDetailsDto, + parentKeyResult, + cancellationToken).ConfigureAwait(false)) + .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), + + _ => throw new NotSupportedException($"Link type {linkType} is not supported."), + }; + } + + private static async ValueTask> GetParentKeyAsync( + ProtonPhotosClient client, + VolumeId volumeId, + LinkId? parentId, + ShareAndKey? shareAndKeyToUse, + ShareId? childShareId, + CancellationToken cancellationToken) + { + if (childShareId is not null && childShareId == shareAndKeyToUse?.Share.Id) + { + return shareAndKeyToUse.Value.Key; + } + + var currentId = parentId; + var currentShareId = childShareId; + + var linkAncestry = new Stack(8); + + PgpPrivateKey? lastKey = null; + + try + { + while (currentId is not null) + { + if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) + { + lastKey = shareKeyToUse; + break; + } + + var nodeUid = new NodeUid(volumeId, currentId.Value); + + var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(nodeUid, cancellationToken).ConfigureAwait(false); + + var folderKey = folderSecretsResult?.Merge(x => x.Key, x => x.Key); + + if (folderKey is not null) + { + lastKey = folderKey.Value; + break; + } + + var response = await client.PhotosApi.GetDetailsAsync(volumeId, [currentId.Value], cancellationToken).ConfigureAwait(false); + + var photoLinkDetails = response.Links[0]; + + linkAncestry.Push(photoLinkDetails); + + currentShareId = photoLinkDetails.Sharing?.ShareId; + + currentId = photoLinkDetails.Link.ParentId; + } + } + catch (Exception e) + { + return new ProtonDriveError(e.Message); + } + + if (lastKey is not { } currentParentKey) + { + if (shareAndKeyToUse is not null) + { + currentParentKey = shareAndKeyToUse.Value.Key; + } + else + { + if (currentShareId is null) + { + return new ProtonDriveError("No share available to access node"); + } + + (_, currentParentKey) = await ShareOperations.GetShareAsync(client.DriveClient, currentShareId.Value, cancellationToken).ConfigureAwait(false); + } + } + + while (linkAncestry.TryPop(out var ancestorLinkDetails)) + { + var decryptionResult = await ConvertDtoToNodeMetadataAsync( + client, + volumeId, + ancestorLinkDetails, + currentParentKey, + cancellationToken).ConfigureAwait(false); + + if (!decryptionResult.TryGetFolderKeyElseError(out var folderKey, out var error)) + { + // TODO: wrap error for more context? + return error; + } + + currentParentKey = folderKey.Value; + } + + return currentParentKey; + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs new file mode 100644 index 00000000..32cf2393 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs @@ -0,0 +1,38 @@ +using System.Runtime.InteropServices; +using Proton.Drive.Sdk; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; + +namespace Proton.Photos.Sdk.Nodes; + +internal sealed class PhotoNodeBatchLoader(ProtonPhotosClient client, VolumeId volumeId) : BatchLoaderBase> +{ + private readonly ProtonPhotosClient _client = client; + + protected override async ValueTask>> LoadBatchAsync( + ReadOnlyMemory ids, + CancellationToken cancellationToken) + { + var nodeResults = new List>(ids.Length); + + var response = await _client.PhotosApi.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); + + foreach (var linkDetails in response.Links) + { + var nodeMetadataResult = await PhotoDtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + _client, + volumeId, + linkDetails, + knownShareAndKey: null, + cancellationToken).ConfigureAwait(false); + + var nodeResult = nodeMetadataResult.ToNodeResult(); + + nodeResults.Add(nodeResult); + } + + return nodeResults; + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs index 0a7cf7d2..4f2fd0d9 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs @@ -68,7 +68,11 @@ private async Task DownloadToStreamAsync(Stream contentOutputStream, Action GetPhotosFolderAsync(ProtonPhotosClien return (FolderNode)metadata.Node; } - public static async IAsyncEnumerable EnumeratePhotosAsync( + public static async IAsyncEnumerable> EnumerateNodesAsync( + ProtonPhotosClient client, + VolumeId volumeId, + IEnumerable linkIds, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var batchLoader = new PhotoNodeBatchLoader(client, volumeId); + + foreach (var linkId in linkIds) + { + var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(new NodeUid(volumeId, linkId), cancellationToken).ConfigureAwait(false); + + if (cachedChildNodeInfo is null) + { + foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + else + { + yield return cachedChildNodeInfo.Value.NodeProvisionResult; + } + } + + foreach (var nodeResult in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + + public static async IAsyncEnumerable EnumeratePhotosTimelineAsync( ProtonPhotosClient client, NodeUid folderUid, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -39,8 +71,8 @@ public static async IAsyncEnumerable EnumeratePhotosAsync( do { - var request = new PhotoTimelineRequest { VolumeId = folderUid.VolumeId, PreviousPageLastLinkId = anchorLinkId }; - var response = await client.PhotosApi.GetPhotosTimelineAsync(request, cancellationToken).ConfigureAwait(false); + var request = new TimelinePhotoListRequest { VolumeId = folderUid.VolumeId, PreviousPageLastLinkId = anchorLinkId }; + var response = await client.PhotosApi.GetTimelinePhotosAsync(request, cancellationToken).ConfigureAwait(false); anchorLinkId = response.Photos.Count == PhotosPageSize ? response.Photos[^1].Id : null; @@ -85,7 +117,9 @@ private static async ValueTask GetFreshPhotosFolderAsync(ProtonPhoto await photosClient.DriveClient.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - photosClient.DriveClient, + photosClient.DriveClient.Account, + photosClient.Cache.Entities, + photosClient.Cache.Secrets, volumeDto.Id, linkDetailsDto, shareKey, diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs index 9f09235d..9edb8b34 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs @@ -63,10 +63,18 @@ public ValueTask GetPhotosRootAsync(CancellationToken cancellationTo return PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken); } + public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + { + return PhotosNodeOperations + .EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken) + .Select(x => (Result?)x) + .FirstOrDefaultAsync(cancellationToken: cancellationToken); + } + [Experimental("Photos")] public IAsyncEnumerable EnumeratePhotosTimelineAsync(NodeUid uid, CancellationToken cancellationToken) { - return PhotosNodeOperations.EnumeratePhotosAsync(this, uid, cancellationToken); + return PhotosNodeOperations.EnumeratePhotosTimelineAsync(this, uid, cancellationToken); } public async ValueTask GetPhotosDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs index 6265366a..b768ace6 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Proton.Photos.Sdk.Api; using Proton.Photos.Sdk.Api.Photos; using Proton.Sdk.Serialization; @@ -20,6 +21,7 @@ namespace Proton.Photos.Sdk.Serialization; [JsonSerializable(typeof(PhotosVolumeCreationRequest))] [JsonSerializable(typeof(PhotosVolumeShareCreationParameters))] [JsonSerializable(typeof(PhotosVolumeLinkCreationParameters))] -[JsonSerializable(typeof(PhotoTimelineRequest))] -[JsonSerializable(typeof(PhotoListResponse))] +[JsonSerializable(typeof(TimelinePhotoListRequest))] +[JsonSerializable(typeof(TimelinePhotoListResponse))] +[JsonSerializable(typeof(PhotoDetailsResponse))] internal sealed partial class PhotosApiSerializerContext : JsonSerializerContext; From f2c65ef589559081b25e5e25bc7d31f8a63f3454 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 Jan 2026 06:01:04 +0000 Subject: [PATCH 427/791] Add tree structure to diagnostics --- js/sdk/src/diagnostic/diagnostic.ts | 12 ++++- js/sdk/src/diagnostic/interface.ts | 13 ++++++ js/sdk/src/diagnostic/sdkDiagnosticMain.ts | 43 ++++++++++++++++- js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts | 49 +++++++++++++++++++- 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/diagnostic/diagnostic.ts b/js/sdk/src/diagnostic/diagnostic.ts index 93fd9cd8..6158f079 100644 --- a/js/sdk/src/diagnostic/diagnostic.ts +++ b/js/sdk/src/diagnostic/diagnostic.ts @@ -2,7 +2,7 @@ import { MaybeNode } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; import { DiagnosticHTTPClient } from './httpClient'; -import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult } from './interface'; +import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult, TreeNode } from './interface'; import { SDKDiagnosticMain } from './sdkDiagnosticMain'; import { SDKDiagnosticPhotos } from './sdkDiagnosticPhotos'; import { DiagnosticTelemetry } from './telemetry'; @@ -57,4 +57,14 @@ export class Diagnostic { private async *internalGenerator(): AsyncGenerator { yield* zipGenerators(this.telemetry.iterateEvents(), this.httpClient.iterateEvents()); } + + async getNodeTreeStructure(node: MaybeNode): Promise { + const diagnostic = new SDKDiagnosticMain(this.protonDriveClient); + return diagnostic.getStructure(node); + } + + async getPhotosTimelineStructure(): Promise { + const diagnostic = new SDKDiagnosticPhotos(this.protonDrivePhotosClient); + return diagnostic.getStructure(); + } } diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index 8551ba02..fd024993 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -15,6 +15,8 @@ export interface Diagnostic { options?: DiagnosticOptions, onProgress?: DiagnosticProgressCallback, ): AsyncGenerator; + getNodeTreeStructure(node: MaybeNode): Promise; + getPhotosTimelineStructure(): Promise; } export type DiagnosticOptions = { @@ -36,6 +38,17 @@ export type ExpectedTreeNode = { children?: ExpectedTreeNode[]; }; +export type TreeNode = { + uid: string; + type: NodeType; + // If node is degraded, error will be set. + error?: unknown; + name: string; + claimedSha1?: string; + claimedSizeInBytes?: number; + children?: TreeNode[]; +}; + export type ExpectedAuthor = string | 'anonymous'; export type DiagnosticProgressCallback = (progress: { diff --git a/js/sdk/src/diagnostic/sdkDiagnosticMain.ts b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts index 409cd8d8..30a1c784 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticMain.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts @@ -1,8 +1,14 @@ import { MaybeNode, NodeType } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; -import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult, ExpectedTreeNode } from './interface'; +import { + DiagnosticOptions, + DiagnosticProgressCallback, + DiagnosticResult, + ExpectedTreeNode, + TreeNode, +} from './interface'; import { zipGenerators } from './zipGenerators'; -import { getNodeType, getNodeName, getTreeNodeChildByNodeName } from './nodeUtils'; +import { getNodeType, getNodeName, getTreeNodeChildByNodeName, getActiveRevision } from './nodeUtils'; import { SDKDiagnosticBase } from './sdkDiagnosticBase'; /** @@ -92,4 +98,37 @@ export class SDKDiagnosticMain extends SDKDiagnosticBase { } } } + + async getStructure(node: MaybeNode): Promise { + const nodeType = getNodeType(node); + const treeNode: TreeNode = { + uid: node.ok ? node.value.uid : node.error.uid, + type: nodeType, + name: getNodeName(node), + }; + + if (!node.ok) { + treeNode.error = node.error || 'degraded node'; + } + + if (nodeType === NodeType.Folder) { + const children = []; + + for await (const child of this.protonDriveClient.iterateFolderChildren(node)) { + children.push(child); + } + + treeNode.children = []; + for (const child of children) { + const childStructure = await this.getStructure(child); + treeNode.children.push(childStructure); + } + } else if (nodeType === NodeType.File) { + const activeRevision = getActiveRevision(node); + treeNode.claimedSha1 = activeRevision?.claimedDigests?.sha1; + treeNode.claimedSizeInBytes = activeRevision?.claimedSize; + } + + return treeNode; + } } diff --git a/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts index 10458fbb..324d88b6 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts @@ -1,8 +1,14 @@ import { MaybeNode } from '../interface'; import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; -import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult, ExpectedTreeNode } from './interface'; +import { + DiagnosticOptions, + DiagnosticProgressCallback, + DiagnosticResult, + ExpectedTreeNode, + TreeNode, +} from './interface'; import { zipGenerators } from './zipGenerators'; -import { getNodeName, getTreeNodeChildByNodeName } from './nodeUtils'; +import { getNodeName, getTreeNodeChildByNodeName, getActiveRevision, getNodeType } from './nodeUtils'; import { SDKDiagnosticBase } from './sdkDiagnosticBase'; /** @@ -67,4 +73,43 @@ export class SDKDiagnosticPhotos extends SDKDiagnosticBase { this.allNodesLoaded = true; } + + async getStructure(): Promise { + const myPhotosRootFolder = await this.protonDrivePhotosClient.getMyPhotosRootFolder(); + + const treeNode: TreeNode = { + uid: myPhotosRootFolder.ok ? myPhotosRootFolder.value.uid : myPhotosRootFolder.error.uid, + type: getNodeType(myPhotosRootFolder), + name: getNodeName(myPhotosRootFolder), + }; + const children = []; + + const results = await Array.fromAsync(this.protonDrivePhotosClient.iterateTimeline()); + const nodeUids = results.map((result) => result.nodeUid); + + for await (const maybeMissingNode of this.protonDrivePhotosClient.iterateNodes(nodeUids)) { + if (!maybeMissingNode.ok && 'missingUid' in maybeMissingNode.error) { + continue; + } + const node = maybeMissingNode as MaybeNode; + + const activeRevision = getActiveRevision(node); + const childNode: TreeNode = { + uid: node.ok ? node.value.uid : node.error.uid, + name: getNodeName(node), + type: getNodeType(node), + claimedSha1: activeRevision?.claimedDigests?.sha1, + claimedSizeInBytes: activeRevision?.claimedSize, + }; + + if (!node.ok) { + childNode.error = node.error || 'degraded node'; + } + + children.push(childNode); + } + + treeNode.children = children; + return treeNode; + } } From a1226c531603b338314b1151e33523060d0eaac3 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 Jan 2026 06:06:54 +0000 Subject: [PATCH 428/791] Fix typing of CryptoProxy and CLI --- js/sdk/src/crypto/interface.ts | 45 ++--------- js/sdk/src/crypto/openPGPCrypto.ts | 79 ++++++++++--------- .../src/internal/nodes/cryptoService.test.ts | 2 +- 3 files changed, 50 insertions(+), 76 deletions(-) diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 749bb88c..78ef1dbe 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -2,35 +2,6 @@ export interface PublicKey { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly _idx: any; - readonly _keyContentHash: [string, string]; - - getVersion(): number; - getFingerprint(): string; - getSHA256Fingerprints(): string[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getKeyID(): any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getKeyIDs(): any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getAlgorithmInfo(): any; - getCreationTime(): Date; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isPrivate: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isPrivateKeyV4: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isPrivateKeyV6: any; - getExpirationTime(): Date | number | null; - getUserIDs(): string[]; - isWeak(): boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - equals(otherKey: any, ignoreOtherCerts?: boolean): boolean; - subkeys: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getAlgorithmInfo(): any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getKeyID(): any; - }[]; } export interface PrivateKey extends PublicKey { @@ -39,10 +10,6 @@ export interface PrivateKey extends PublicKey { export interface SessionKey { data: Uint8Array; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - algorithm: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - aeadAlgorithm?: any; } export enum VERIFICATION_STATUS { @@ -93,7 +60,7 @@ export interface OpenPGPCrypto { */ generatePassphrase: () => string; - generateSessionKey: (encryptionKeys: PrivateKey[]) => Promise; + generateSessionKey: (encryptionKeys: PublicKey[]) => Promise; encryptSessionKey: ( sessionKey: SessionKey, @@ -121,7 +88,7 @@ export interface OpenPGPCrypto { encryptArmored: ( data: Uint8Array, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], sessionKey?: SessionKey, ) => Promise<{ armoredData: string; @@ -130,7 +97,7 @@ export interface OpenPGPCrypto { encryptAndSign: ( data: Uint8Array, sessionKey: SessionKey, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], signingKey: PrivateKey, ) => Promise<{ encryptedData: Uint8Array; @@ -139,7 +106,7 @@ export interface OpenPGPCrypto { encryptAndSignArmored: ( data: Uint8Array, sessionKey: SessionKey | undefined, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], signingKey: PrivateKey, options?: { compress?: boolean }, ) => Promise<{ @@ -149,7 +116,7 @@ export interface OpenPGPCrypto { encryptAndSignDetached: ( data: Uint8Array, sessionKey: SessionKey, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], signingKey: PrivateKey, ) => Promise<{ encryptedData: Uint8Array; @@ -159,7 +126,7 @@ export interface OpenPGPCrypto { encryptAndSignDetachedArmored: ( data: Uint8Array, sessionKey: SessionKey, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], signingKey: PrivateKey, ) => Promise<{ armoredData: string; diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index f22a8993..b0200747 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -1,4 +1,5 @@ import { c } from 'ttag'; + import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; import { uint8ArrayToBase64String } from './utils'; @@ -10,7 +11,7 @@ export interface OpenPGPCryptoProxy { generateKey: (options: { userIDs: { name: string }[]; type: 'ecc'; curve: 'ed25519Legacy' }) => Promise; exportPrivateKey: (options: { privateKey: PrivateKey; passphrase: string | null }) => Promise; importPrivateKey: (options: { armoredKey: string; passphrase: string | null }) => Promise; - generateSessionKey: (options: { recipientKeys: PrivateKey[] }) => Promise; + generateSessionKey: (options: { recipientKeys: PublicKey[] }) => Promise; encryptSessionKey: ( options: SessionKey & { format: 'binary'; encryptionKeys?: PublicKey | PublicKey[]; passwords?: string[] }, ) => Promise; @@ -19,20 +20,26 @@ export interface OpenPGPCryptoProxy { binaryMessage?: Uint8Array; decryptionKeys: PrivateKey | PrivateKey[]; }) => Promise; - encryptMessage: (options: { - format?: 'armored' | 'binary'; + encryptMessage: (options: { + format?: Format; binaryData: Uint8Array; sessionKey?: SessionKey; - encryptionKeys: PrivateKey[]; + encryptionKeys: PublicKey[]; signingKeys?: PrivateKey; - detached?: boolean; + detached?: Detached; compress?: boolean; - }) => Promise<{ - message: string | Uint8Array; - signature?: string | Uint8Array; - }>; - decryptMessage: (options: { - format: 'utf8' | 'binary'; + }) => Promise< + Detached extends true + ? { + message: Format extends 'binary' ? Uint8Array : string; + signature: Format extends 'binary' ? Uint8Array : string; + } + : { + message: Format extends 'binary' ? Uint8Array : string; + } + >; + decryptMessage: (options: { + format: Format; armoredMessage?: string; binaryMessage?: Uint8Array; armoredSignature?: string; @@ -42,20 +49,20 @@ export interface OpenPGPCryptoProxy { decryptionKeys?: PrivateKey | PrivateKey[]; verificationKeys?: PublicKey | PublicKey[]; }) => Promise<{ - data: Uint8Array | string; + data: Format extends 'binary' ? Uint8Array : string; // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. verified?: VERIFICATION_STATUS; verificationStatus?: VERIFICATION_STATUS; verificationErrors?: Error[]; }>; - signMessage: (options: { - format: 'binary' | 'armored'; + signMessage: (options: { + format: Format; binaryData: Uint8Array; signingKeys: PrivateKey | PrivateKey[]; detached: boolean; signatureContext?: { critical: boolean; value: string }; - }) => Promise; + }) => Promise; verifyMessage: (options: { binaryData: Uint8Array; armoredSignature?: string; @@ -88,7 +95,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return uint8ArrayToBase64String(value); } - async generateSessionKey(encryptionKeys: PrivateKey[]) { + async generateSessionKey(encryptionKeys: PublicKey[]) { return this.cryptoProxy.generateSessionKey({ recipientKeys: encryptionKeys }); } @@ -132,21 +139,21 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async encryptArmored(data: Uint8Array, encryptionKeys: PrivateKey[], sessionKey?: SessionKey) { + async encryptArmored(data: Uint8Array, encryptionKeys: PublicKey[], sessionKey?: SessionKey) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, sessionKey, encryptionKeys, }); return { - armoredData: armoredData as string, + armoredData: armoredData, }; } async encryptAndSign( data: Uint8Array, sessionKey: SessionKey, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], signingKey: PrivateKey, ) { const { message: encryptedData } = await this.cryptoProxy.encryptMessage({ @@ -158,14 +165,14 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { detached: false, }); return { - encryptedData: encryptedData as Uint8Array, + encryptedData: encryptedData, }; } async encryptAndSignArmored( data: Uint8Array, sessionKey: SessionKey | undefined, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], signingKey: PrivateKey, options: { compress?: boolean } = {}, ) { @@ -178,14 +185,14 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { compress: options.compress || false, }); return { - armoredData: armoredData as string, + armoredData: armoredData, }; } async encryptAndSignDetached( data: Uint8Array, sessionKey: SessionKey, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], signingKey: PrivateKey, ) { const { message: encryptedData, signature } = await this.cryptoProxy.encryptMessage({ @@ -197,15 +204,15 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { detached: true, }); return { - encryptedData: encryptedData as Uint8Array, - signature: signature as Uint8Array, + encryptedData: encryptedData, + signature: signature, }; } async encryptAndSignDetachedArmored( data: Uint8Array, sessionKey: SessionKey, - encryptionKeys: PrivateKey[], + encryptionKeys: PublicKey[], signingKey: PrivateKey, ) { const { message: armoredData, signature: armoredSignature } = await this.cryptoProxy.encryptMessage({ @@ -216,8 +223,8 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { detached: true, }); return { - armoredData: armoredData as string, - armoredSignature: armoredSignature as string, + armoredData: armoredData, + armoredSignature: armoredSignature, }; } @@ -230,7 +237,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { signatureContext: { critical: true, value: signatureContext }, }); return { - signature: signature as Uint8Array, + signature: signature, }; } @@ -242,7 +249,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { format: 'armored', }); return { - signature: signature as string, + signature: signature, }; } @@ -329,7 +336,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }); return { - data: decryptedData as Uint8Array, + data: decryptedData, // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, @@ -357,7 +364,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }); return { - data: decryptedData as Uint8Array, + data: decryptedData, // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, @@ -371,7 +378,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { decryptionKeys, format: 'binary', }); - return data as Uint8Array; + return data; } async decryptArmoredAndVerify( @@ -387,7 +394,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }); return { - data: data as Uint8Array, + data: data, // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, @@ -410,7 +417,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }); return { - data: data as Uint8Array, + data: data, // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. // Proper typing is too complex, it will be removed to support only newer pmcrypto. verified: verified || verificationStatus!, @@ -426,6 +433,6 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { passwords: [password], format: 'binary', }); - return data as Uint8Array; + return data; } } diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 958a27bc..62674cae 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -72,8 +72,8 @@ describe('nodesCryptoService', () => { }), ), }; + // @ts-expect-error No need to implement all methods for mocking account = { - // @ts-expect-error No need to implement all methods for mocking getPublicKeys: jest.fn(async () => [publicAddressKey]), getOwnAddresses: jest.fn(async () => [ { From b3ccc7f86d37d22723a977fdb141bfe8cb88d23a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 Jan 2026 07:09:32 +0100 Subject: [PATCH 429/791] js/v0.9.2 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index f5c4fdec..d38fe7e8 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.1", + "version": "0.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.9.1", + "version": "0.9.2", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index 36705904..c836d593 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.1", + "version": "0.9.2", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 6530ed327f764655aed58721c010ac72eec685a7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 12 Jan 2026 12:00:00 +0100 Subject: [PATCH 430/791] Expose folder creation in interop and Swift bindings --- .../InteropMessageHandler.cs | 3 + .../InteropProtonDriveClient.cs | 42 ++++++ .../InteropProtonPhotosClient.cs | 21 +-- .../Api/Folders/FolderCreationRequest.cs | 4 + .../Nodes/FolderOperations.cs | 18 ++- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 4 +- cs/sdk/src/protos/proton.drive.sdk.proto | 11 ++ .../ProtonDriveClient/ProtonDriveClient.swift | 131 ++++++++++++++---- .../Sources/Plumbing/Message+Packaging.swift | 5 + 9 files changed, 191 insertions(+), 48 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index fff4a517..9f86f1dd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -40,6 +40,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientRename => await InteropProtonDriveClient.HandleRenameAsync(request.DriveClientRename).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientCreateFolder + => await InteropProtonDriveClient.HandleCreateFolderAsync(request.DriveClientCreateFolder).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientGetThumbnails => await InteropProtonDriveClient.HandleGetThumbnailsAsync(request.DriveClientGetThumbnails).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index f6ba2b5c..315297e9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -69,6 +69,31 @@ public static IMessage HandleCreate(DriveClientCreateFromSessionRequest request) return new Int64Value { Value = Interop.AllocHandle(client) }; } + public static async ValueTask HandleCreateFolderAsync(DriveClientCreateFolderRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var createdFolder = await client.CreateFolderAsync( + NodeUid.Parse(request.ParentFolderUid), + request.FolderName, + request.LastModificationTime.Seconds != 0 ? request.LastModificationTime.ToDateTime() : null, + cancellationToken).ConfigureAwait(false); + + return new FolderNode + { + Uid = createdFolder.Uid.ToString(), + ParentUid = createdFolder.ParentUid.ToString(), + TreeEventScopeId = createdFolder.TreeEventScopeId, + Name = createdFolder.Name, + CreationTime = createdFolder.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = createdFolder.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = ParseAuthorResult(createdFolder.NameAuthor), + Author = ParseAuthorResult(createdFolder.Author), + }; + } + public static async ValueTask HandleGetFileUploaderAsync(DriveClientGetFileUploaderRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); @@ -181,4 +206,21 @@ await client.RenameNodeAsync( return null; } + + public static AuthorResult ParseAuthorResult(Result result) + { + var authorResult = new AuthorResult(); + + if (result.TryGetValueElseError(out var author, out var error)) + { + authorResult.Author = new Proton.Drive.Sdk.CExports.Author { EmailAddress = author.EmailAddress }; + } + else + { + authorResult.Author = new Proton.Drive.Sdk.CExports.Author { EmailAddress = error.ClaimedAuthor.EmailAddress }; + authorResult.SignatureVerificationError = error.Message; + } + + return authorResult; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 58ea94d6..288d4911 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -90,8 +90,8 @@ public static async ValueTask HandleGetPhotosRootAsync(DrivePhotosClie Name = folderNode.Name, CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), - NameAuthor = ParseAuthorResult(folderNode.NameAuthor), - Author = ParseAuthorResult(folderNode.Author), + NameAuthor = InteropProtonDriveClient.ParseAuthorResult(folderNode.NameAuthor), + Author = InteropProtonDriveClient.ParseAuthorResult(folderNode.Author), }; } @@ -147,21 +147,4 @@ public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(Dri return new FileThumbnailList { Thumbnails = { thumbnails } }; } - - private static AuthorResult ParseAuthorResult(Result result) - { - var authorResult = new AuthorResult(); - - if (result.TryGetValueElseError(out var author, out var error)) - { - authorResult.Author = new Proton.Drive.Sdk.CExports.Author { EmailAddress = author.EmailAddress }; - } - else - { - authorResult.Author = new Proton.Drive.Sdk.CExports.Author { EmailAddress = error.ClaimedAuthor.EmailAddress }; - authorResult.SignatureVerificationError = error.Message; - } - - return authorResult; - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs index c5b8f33e..fb246030 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Links; using Proton.Sdk.Cryptography; @@ -11,4 +12,7 @@ internal sealed class FolderCreationRequest : NodeCreationRequest [JsonPropertyName("SignatureEmail")] public required string SignatureEmailAddress { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index e9251ace..11bca52d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -1,8 +1,11 @@ using System.Runtime.CompilerServices; +using System.Text.Json; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Serialization; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; @@ -55,7 +58,7 @@ public static async IAsyncEnumerable> EnumerateChildr } } - public static async ValueTask CreateAsync(ProtonDriveClient client, NodeUid parentUid, string name, CancellationToken cancellationToken) + public static async ValueTask CreateAsync(ProtonDriveClient client, NodeUid parentUid, string name, DateTimeOffset? lastModificationTime, CancellationToken cancellationToken) { var parentSecrets = await GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); @@ -82,6 +85,18 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, out var keyPassphraseSignature, out var armoredKey); + var extendedAttributes = new ExtendedAttributes + { + Common = new CommonExtendedAttributes + { + ModificationTime = lastModificationTime?.UtcDateTime, + } + }; + + var extendedAttributesUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(extendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); + + var encryptedExtendedAttributes = key.EncryptAndSign(extendedAttributesUtf8Bytes, signingKey, outputCompression: PgpCompression.Default); + var request = new FolderCreationRequest { Name = encryptedName, @@ -92,6 +107,7 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, SignatureEmailAddress = membershipAddress.EmailAddress, Key = armoredKey, HashKey = key.EncryptAndSign(hashKey, key), + ExtendedAttributes = encryptedExtendedAttributes, }; var response = await client.Api.Folders.CreateFolderAsync(parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 1bbdd4bc..daf241e2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -148,9 +148,9 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio return NodeOperations.EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken).Select(x => (Result?)x).FirstOrDefaultAsync(); } - public ValueTask CreateFolderAsync(NodeUid parentId, string name, CancellationToken cancellationToken) + public ValueTask CreateFolderAsync(NodeUid parentId, string name, DateTime? lastModificationTime, CancellationToken cancellationToken) { - return FolderOperations.CreateAsync(this, parentId, name, cancellationToken); + return FolderOperations.CreateAsync(this, parentId, name, lastModificationTime, cancellationToken); } public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index e91ebac6..b3d495c1 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -18,6 +18,7 @@ message Request { DriveClientGetAvailableNameRequest drive_client_get_available_name = 1006; DriveClientGetThumbnailsRequest drive_client_get_thumbnails = 1007; DriveClientRenameRequest drive_client_rename = 1008; + DriveClientCreateFolderRequest drive_client_create_folder = 1009; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -303,6 +304,7 @@ message DriveClientGetAvailableNameRequest { int64 cancellation_token_source_handle = 4; } +// The response must not have a value. message DriveClientRenameRequest { int64 client_handle = 1; string node_uid = 2; @@ -311,6 +313,15 @@ message DriveClientRenameRequest { int64 cancellation_token_source_handle = 5; } +// The response message must be of type FolderNode +message DriveClientCreateFolderRequest { + int64 client_handle = 1; + string parent_folder_uid = 2; + string folder_name = 3; + google.protobuf.Timestamp last_modification_time = 4; // Optional + int64 cancellation_token_source_handle = 5; +} + // The response message must be of type FileThumbnailList. message DriveClientGetThumbnailsRequest { int64 client_handle = 1; diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 379de8e8..1bf4ecc5 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftProtobuf /// Main entry point for all SDK functionality. /// @@ -18,6 +19,22 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { let httpClient: HttpClientProtocol let accountClient: AccountClientProtocol let configuration: ProtonDriveClientConfiguration + + private enum OperationIdentifier: Hashable { + case createFolder(UUID) + case rename(UUID) + case getAvailableName(UUID) + + var operationName: String { + switch self { + case .createFolder: return "createFolder" + case .rename: return "rename" + case .getAvailableName: return "getAvailableName" + } + } + } + + private var activeOperations: [OperationIdentifier: CancellationTokenSource] = [:] public init( configuration: ProtonDriveClientConfiguration, @@ -229,29 +246,6 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { try await uploadsManager.cancelUpload(with: cancellationToken) } - public func getAvailableName( - parentFolderUid: SDKNodeUid, - name: String - ) async throws -> String { - let cancellationTokenSource = try await CancellationTokenSource(logger: logger) - defer { - cancellationTokenSource.free() - } - - let cancellationHandle = cancellationTokenSource.handle - - let getAvailableNameRequest = Proton_Drive_Sdk_DriveClientGetAvailableNameRequest.with { - $0.clientHandle = Int64(clientHandle) - $0.parentFolderUid = parentFolderUid.sdkCompatibleIdentifier - $0.name = name - $0.cancellationTokenSourceHandle = Int64(cancellationHandle) - } - - let nameResult: String = try await SDKRequestHandler.send(getAvailableNameRequest, - logger: logger) - return nameResult - } - static func unbox( callbackPointer: Int, releaseBox: () -> Void, weakDriveClient: WeakReference @@ -283,6 +277,29 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { guard clientHandle != 0 else { return } Self.freeProtonDriveClient(Int64(clientHandle), logger) } + + private func cancelOperation(identifier: OperationIdentifier) async throws { + guard let cancellationToken = activeOperations[identifier] else { + throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: identifier.operationName)) + } + + try await cancellationToken.cancel() + + activeOperations[identifier] = nil + cancellationToken.free() + } + + private func createCancellationTokenSource(_ operationIdentifier: OperationIdentifier, _ logger: Logger) async throws -> CancellationTokenSource { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeOperations[operationIdentifier] = cancellationTokenSource + return cancellationTokenSource + } + + private func freeCancellationTokenSourceIfNeeded(identifier: OperationIdentifier) { + guard let cancellationTokenSource = activeOperations[identifier] else { return } + activeOperations[identifier] = nil + cancellationTokenSource.free() + } private static func freeProtonDriveClient(_ clientHandle: Int64, _ logger: Logger?) { Task { @@ -302,10 +319,68 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { // MARK: - Node action extension ProtonDriveClient { - public func rename(nodeUid: SDKNodeUid, newName: String, newMediaType: String?) async throws { - let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + + public func createFolder( + parentFolderUid: SDKNodeUid, + folderName: String, + lastModificationTime: Date, + cancellationToken: UUID + ) async throws -> FolderNode { + let cancellationTokenSource = try await createCancellationTokenSource(.createFolder(cancellationToken), logger) defer { - cancellationTokenSource.free() + freeCancellationTokenSourceIfNeeded(identifier: .createFolder(cancellationToken)) + } + + let cancellationHandle = cancellationTokenSource.handle + + let createFolderRequest = Proton_Drive_Sdk_DriveClientCreateFolderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.parentFolderUid = parentFolderUid.sdkCompatibleIdentifier + $0.folderName = folderName + $0.lastModificationTime = Google_Protobuf_Timestamp(date: lastModificationTime) + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let sdkFolderNode: Proton_Drive_Sdk_FolderNode = try await SDKRequestHandler.send(createFolderRequest, logger: logger) + return try FolderNode(sdkFolderNode: sdkFolderNode) + } + + public func cancelCreateFolder(cancellationToken: UUID) async throws { + try await cancelOperation(identifier: .createFolder(cancellationToken)) + } + + public func getAvailableName( + parentFolderUid: SDKNodeUid, + name: String, + cancellationToken: UUID + ) async throws -> String { + let cancellationTokenSource = try await createCancellationTokenSource(.getAvailableName(cancellationToken), logger) + defer { + freeCancellationTokenSourceIfNeeded(identifier: .getAvailableName(cancellationToken)) + } + + let cancellationHandle = cancellationTokenSource.handle + + let getAvailableNameRequest = Proton_Drive_Sdk_DriveClientGetAvailableNameRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.parentFolderUid = parentFolderUid.sdkCompatibleIdentifier + $0.name = name + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let nameResult: String = try await SDKRequestHandler.send(getAvailableNameRequest, + logger: logger) + return nameResult + } + + public func cancelGetAvailableName(cancellationToken: UUID) async throws { + try await cancelOperation(identifier: .getAvailableName(cancellationToken)) + } + + public func rename(nodeUid: SDKNodeUid, newName: String, newMediaType: String?, cancellationToken: UUID) async throws { + let cancellationTokenSource = try await createCancellationTokenSource(.rename(cancellationToken), logger) + defer { + freeCancellationTokenSourceIfNeeded(identifier: .rename(cancellationToken)) } let cancellationHandle = cancellationTokenSource.handle @@ -320,4 +395,8 @@ extension ProtonDriveClient { } let result: Void = try await SDKRequestHandler.send(renameRequest, logger: logger) } + + public func cancelRename(cancellationToken: UUID) async throws { + try await cancelOperation(identifier: .rename(cancellationToken)) + } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index c098db9e..d9010eec 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -53,6 +53,11 @@ extension Message { Proton_Drive_Sdk_Request.with { $0.payload = .driveClientFree(request) } + + case let request as Proton_Drive_Sdk_DriveClientCreateFolderRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientCreateFolder(request) + } case let request as Proton_Drive_Sdk_DriveClientGetFileUploaderRequest: Proton_Drive_Sdk_Request.with { From af105f623ded3a628140eae8df45a118a401c199 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 Jan 2026 16:13:29 +0000 Subject: [PATCH 431/791] Add Kotlin bindings for rename --- .../InteropProtonDriveClient.cs | 4 ++-- .../kotlin/me/proton/drive/sdk/DriveClient.kt | 20 +++++++++++++++++++ .../drive/sdk/internal/JniDriveClient.kt | 7 +++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 315297e9..fc1fa3d0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -186,7 +186,7 @@ public static async ValueTask HandleGetFileDownloaderAsync(DriveClient return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; } - public static async ValueTask HandleRenameAsync(DriveClientRenameRequest request) + public static async ValueTask HandleRenameAsync(DriveClientRenameRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); @@ -197,7 +197,7 @@ await client.RenameNodeAsync( request.NewName, request.NewMediaType, cancellationToken).ConfigureAwait(false); - return new Empty(); + return null; } public static IMessage? HandleFree(DriveClientFreeRequest request) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt index b81d36b5..fbb8fa22 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt @@ -9,6 +9,7 @@ import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetThumbnailsRequest +import proton.drive.sdk.driveClientRenameRequest import java.io.OutputStream class DriveClient internal constructor( @@ -52,6 +53,25 @@ class DriveClient internal constructor( } } + suspend fun rename( + nodeUid: String, + name: String, + mediaType: String? = null, + ): Unit = cancellationCoroutineScope { source -> + log(DEBUG, "rename") + bridge.rename( + driveClientRenameRequest { + this.nodeUid = nodeUid + newName = name + mediaType?.let { + newMediaType = mediaType + } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ) + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index 20db5608..dc2f2b6c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -6,6 +6,7 @@ import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.extension.FileThumbnailListResponseCallback import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.StringResponseCallback +import me.proton.drive.sdk.extension.UnitResponseCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.driveClientCreateFromSessionRequest @@ -75,6 +76,12 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { driveClientGetAvailableName = request } + suspend fun rename( + request: ProtonDriveSdk.DriveClientRenameRequest, + ): Unit = executeOnce("rename", UnitResponseCallback) { + driveClientRename = request + } + suspend fun getThumbnails( request: ProtonDriveSdk.DriveClientGetThumbnailsRequest, ): ProtonDriveSdk.FileThumbnailList = From 518d8a6f05cae424c160a4843c7079462a65e365 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 10:45:05 +0100 Subject: [PATCH 432/791] Simplify implementation for pausing uploads --- cs/Directory.Packages.props | 1 + .../InteropPhotosDownloader.cs | 2 +- .../InteropUploadController.cs | 4 +- .../Api/Files/FilesApiClient.cs | 25 +- .../Api/Files/IFilesApiClient.cs | 4 +- .../Proton.Drive.Sdk/ExceptionExtensions.cs | 2 +- ...edDownloadManifestVerificationException.cs | 11 +- .../Nodes/Download/DataIntegrityException.cs | 9 + .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 1 - .../Proton.Drive.Sdk/Nodes/ITaskControl.cs | 9 - .../Nodes/RevisionOperations.cs | 24 +- .../src/Proton.Drive.Sdk/Nodes/TaskControl.cs | 103 +--- .../Nodes/Upload/BlockUploadPlainData.cs | 12 + .../Nodes/Upload/BlockUploadResult.cs | 2 +- .../Nodes/Upload/BlockUploader.cs | 281 +++++------ .../Nodes/Upload/FileUploader.cs | 197 ++++---- .../Nodes/Upload/IFileDraftProvider.cs | 10 - .../Nodes/Upload/IRevisionDraftProvider.cs | 6 + .../Nodes/Upload/NewFileDraftProvider.cs | 50 +- .../Nodes/Upload/NewRevisionDraftProvider.cs | 42 +- .../Nodes/Upload/RevisionDraft.cs | 142 ++++++ .../Nodes/Upload/RevisionWriter.cs | 473 ++++++++---------- .../Upload/UnreadableContentException.cs | 18 + .../Nodes/Upload/UploadController.cs | 143 ++++-- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 13 +- .../src/Proton.Drive.Sdk/Telemetry/Privacy.cs | 28 ++ .../InteropCancellationTokenSource.cs | 4 +- .../Logging/InteropLogger.cs | 3 +- .../Proton.Sdk/Caching/NullCacheRepository.cs | 2 +- cs/sdk/src/Proton.Sdk/Either.cs | 35 ++ .../Http/SocketsHttpHandlerExtensions.cs | 3 +- 31 files changed, 919 insertions(+), 740 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadPlainData.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UnreadableContentException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs create mode 100644 cs/sdk/src/Proton.Sdk/Either.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index f03aad12..5c3dfb6a 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs index 2ca87239..603b9807 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -40,7 +40,7 @@ public static IMessage HandleDownloadToFile(DrivePhotosClientDownloadToFileReque return new Int64Value { Value = Interop.AllocHandle(downloadController) }; } - + public static IMessage? HandleFree(DrivePhotosClientDownloaderFreeRequest request) { var fileDownloader = Interop.FreeHandle(request.FileDownloaderHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs index d12c5028..5b505c3c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -18,9 +18,9 @@ public static IMessage HandleIsPaused(UploadControllerIsPausedRequest request) { var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); - var (nodeUid, revisionUid) = await uploadController.Completion.ConfigureAwait(false); + var uploadResult = await uploadController.Completion.ConfigureAwait(false); - return new UploadResult { NodeUid = nodeUid.ToString(), RevisionUid = revisionUid.ToString() }; + return new UploadResult { NodeUid = uploadResult.NodeUid.ToString(), RevisionUid = uploadResult.RevisionUid.ToString() }; } public static IMessage? HandlePause(UploadControllerPauseRequest request) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index b58d4d76..72a4e04a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -1,3 +1,4 @@ +using System.Text; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Volumes; @@ -62,16 +63,30 @@ public async ValueTask GetRevisionAsync( VolumeId volumeId, LinkId linkId, RevisionId revisionId, - int fromBlockIndex, - int pageSize, + int? fromBlockIndex, + int? pageSize, bool withoutBlockUrls, CancellationToken cancellationToken) { + var routeBuilder = new StringBuilder(); + + routeBuilder.Append($"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}?"); + + if (fromBlockIndex is not null) + { + routeBuilder.Append($"FromBlockIndex={fromBlockIndex}&"); + } + + if (pageSize is not null) + { + routeBuilder.Append($"PageSize={pageSize}&"); + } + + routeBuilder.Append($"NoBlockUrls={(withoutBlockUrls ? 1 : 0)}"); + return await _httpClient .Expecting(DriveApiSerializerContext.Default.RevisionResponse) - .GetAsync( - $"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}?FromBlockIndex={fromBlockIndex}&PageSize={pageSize}&NoBlockUrls={(withoutBlockUrls ? 1 : 0)}", - cancellationToken).ConfigureAwait(false); + .GetAsync(routeBuilder.ToString(), cancellationToken).ConfigureAwait(false); } public async ValueTask DeleteRevisionAsync(VolumeId volumeId, LinkId linkId, RevisionId revisionId, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs index 6682d29d..f85f3b88 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -27,8 +27,8 @@ ValueTask GetRevisionAsync( VolumeId volumeId, LinkId linkId, RevisionId revisionId, - int fromBlockIndex, - int pageSize, + int? fromBlockIndex, + int? pageSize, bool withoutBlockUrls, CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs index ffafc4c0..bc9b0ea8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs @@ -76,7 +76,7 @@ private static string GetExceptionType(Exception exception) : typeName; } - public static bool TryGetRelevantFormattedErrorCode(this Exception ex, [MaybeNullWhen(false)] out string formattedErrorCode) + private static bool TryGetRelevantFormattedErrorCode(this Exception ex, [MaybeNullWhen(false)] out string formattedErrorCode) { return ex switch { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs index 33ecfeb3..f4fc4652 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs @@ -1,9 +1,18 @@ namespace Proton.Drive.Sdk.Nodes.Download; -internal sealed class CompletedDownloadManifestVerificationException : Exception +public sealed class CompletedDownloadManifestVerificationException : Exception { public CompletedDownloadManifestVerificationException(string message) : base(message) { } + + public CompletedDownloadManifestVerificationException(string message, Exception innerException) + : base(message, innerException) + { + } + + public CompletedDownloadManifestVerificationException() + { + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DataIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DataIntegrityException.cs index 6bee8a82..8d1fab6e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DataIntegrityException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DataIntegrityException.cs @@ -2,8 +2,17 @@ namespace Proton.Drive.Sdk.Nodes.Download; public sealed class DataIntegrityException : ProtonDriveException { + public DataIntegrityException() + { + } + public DataIntegrityException(string message) : base(message) { } + + public DataIntegrityException(string message, Exception innerException) + : base(message, innerException) + { + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index ee510090..2c24f9e0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -17,7 +17,6 @@ public static async ValueTask> GetSecre .ConfigureAwait(false); fileSecretsResult = metadataResult.GetFileSecretsOrThrow(); - } return (Result)fileSecretsResult; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs deleted file mode 100644 index 776bd428..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -internal interface ITaskControl : IDisposable -{ - bool IsPaused { get; } - Task PauseExceptionSignal { get; } - void Pause(); - void Resume(); -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index ba370cb9..8fec32b5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -7,32 +7,18 @@ internal static class RevisionOperations { public static async ValueTask OpenForWritingAsync( ProtonDriveClient client, - RevisionUid revisionUid, - FileSecrets fileSecrets, + RevisionDraft draft, Action releaseBlocksAction, - TaskControl taskControl) + CancellationToken cancellationToken) { - var (membershipAddress, signingKey) = await taskControl.HandlePauseAsync(async ct => - { - var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, revisionUid.NodeUid, ct).ConfigureAwait(false); - var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, ct).ConfigureAwait(false); - - return (membershipAddress, signingKey); - }).ConfigureAwait(false); - - await client.BlockUploader.Queue.StartFileAsync(taskControl.CancellationToken).ConfigureAwait(false); + await client.BlockUploader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); return new RevisionWriter( client, - revisionUid, - fileSecrets.Key, - fileSecrets.ContentKey, - signingKey, - membershipAddress, + draft, releaseBlocksAction, () => client.BlockUploader.Queue.FinishFile(), - client.TargetBlockSize, - client.MaxBlockSize); + client.TargetBlockSize); } internal static async ValueTask OpenForReadingAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs index a36e5418..0498e4b2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs @@ -1,13 +1,12 @@ namespace Proton.Drive.Sdk.Nodes; -internal sealed class TaskControl(CancellationToken cancellationToken) : ITaskControl +internal sealed class TaskControl(CancellationToken cancellationToken) : IDisposable { private readonly Lock _pauseLock = new(); + private bool _isDisposed; private TaskCompletionSource? _resumeSignalSource; - private TaskCompletionSource _pauseExceptionSignalSource = new(); private CancellationTokenSource _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - private bool _isDisposed; public bool IsPaused => _resumeSignalSource is { Task.IsCompleted: false } && !IsCanceled; public bool IsCanceled => CancellationToken.IsCancellationRequested; @@ -15,15 +14,8 @@ internal sealed class TaskControl(CancellationToken cancellationToken) : ITas public CancellationToken CancellationToken { get; } = cancellationToken; public CancellationToken PauseOrCancellationToken => _pauseCancellationTokenSource.Token; - public Task PauseExceptionSignal => _pauseExceptionSignalSource.Task; - public void Pause() { - if (_isDisposed) - { - return; - } - if (IsPaused) { return; @@ -31,11 +23,6 @@ public void Pause() lock (_pauseLock) { - if (_isDisposed) - { - return; - } - if (IsPaused) { return; @@ -43,94 +30,34 @@ public void Pause() _resumeSignalSource = new TaskCompletionSource(); - if (PauseExceptionSignal.IsFaulted) - { - _pauseExceptionSignalSource = new TaskCompletionSource(); - } - _pauseCancellationTokenSource.Cancel(); } } - public void PauseOnError(Exception ex) + public bool TryResume() { - var pauseExceptionSignalSource = _pauseExceptionSignalSource; - - Pause(); - - pauseExceptionSignalSource.TrySetException(ex); - } - - public void Resume() - { - if (_isDisposed) - { - return; - } - if (!IsPaused) { - return; + return false; } lock (_pauseLock) { - if (_isDisposed) - { - return; - } - if (!IsPaused) { - return; + return false; } _pauseCancellationTokenSource.Dispose(); _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); - _pauseExceptionSignalSource = new TaskCompletionSource(); - var resumeSignalSource = _resumeSignalSource; _resumeSignalSource = null; resumeSignalSource?.SetResult(); } - } - - public async ValueTask WaitWhilePausedAsync() - { - var resumeTask = _resumeSignalSource?.Task; - if (resumeTask is not null) - { - await resumeTask.WaitAsync(CancellationToken).ConfigureAwait(false); - } - } - - public async ValueTask HandlePauseAsync(Func> function, Func? exceptionTriggersPause = null) - { - await WaitWhilePausedAsync().ConfigureAwait(false); - - while (true) - { - try - { - return await function.Invoke(PauseOrCancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (IsPaused) - { - await WaitWhilePausedAsync().ConfigureAwait(false); - } - catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) when (exceptionTriggersPause?.Invoke(ex) == true) - { - PauseOnError(ex); - await WaitWhilePausedAsync().ConfigureAwait(false); - } - } + return true; } public void Dispose() @@ -140,22 +67,10 @@ public void Dispose() return; } - lock (_pauseLock) - { - var pauseExceptionSignal = _pauseExceptionSignalSource.Task; + _resumeSignalSource?.TrySetCanceled(); - if (pauseExceptionSignal.IsFaulted && pauseExceptionSignal.Exception.InnerException is { } innerException) - { - _resumeSignalSource?.TrySetException(innerException); - } - else - { - _resumeSignalSource?.TrySetCanceled(); - } + _pauseCancellationTokenSource.Dispose(); - _pauseCancellationTokenSource.Dispose(); - - _isDisposed = true; - } + _isDisposed = true; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadPlainData.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadPlainData.cs new file mode 100644 index 00000000..38b02009 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadPlainData.cs @@ -0,0 +1,12 @@ +using System.Buffers; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal readonly record struct BlockUploadPlainData(Stream Stream, byte[] PrefixForVerification) : IAsyncDisposable +{ + public async ValueTask DisposeAsync() + { + ArrayPool.Shared.Return(PrefixForVerification); + await Stream.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadResult.cs index c53cce63..66ba0f78 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadResult.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploadResult.cs @@ -1,3 +1,3 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -internal readonly record struct BlockUploadResult(int PlaintextSize, byte[] Sha256Digest, bool IsFileContent); +internal readonly record struct BlockUploadResult(int PlaintextSize, byte[] Sha256Digest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 34117af5..8f76aaa6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -8,11 +8,8 @@ using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Http; using Proton.Drive.Sdk.Nodes.Download; -using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Resilience; using Proton.Sdk; -using Proton.Sdk.Addresses; -using Proton.Sdk.Api; using Proton.Sdk.Drive; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -33,152 +30,158 @@ internal BlockUploader(ProtonDriveClient client, int maxDegreeOfParallelism) public TransferQueue Queue { get; } public async ValueTask UploadContentAsync( - RevisionUid revisionUid, - int index, - PgpSessionKey contentKey, - PgpPrivateKey signingKey, - AddressId membershipAddressId, - PgpKey signatureEncryptionKey, - Stream plainDataStream, - IBlockVerifier verifier, - byte[] plainDataPrefix, - int plainDataPrefixLength, + RevisionDraft draft, + int blockNumber, + BlockUploadPlainData plainData, Action? onBlockProgress, CancellationToken cancellationToken) { - var plainDataLength = plainDataStream.Length; - - var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (dataPacketStream.ConfigureAwait(false)) + using (_logger.BeginScope("Content block #{BlockNumber} of revision #{RevisionUid}", draft.Uid, blockNumber)) { - var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + var plainDataLength = plainData.Stream.Length; - await using (signatureStream.ConfigureAwait(false)) + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) { - using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - - var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); + var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (hashingStream.ConfigureAwait(false)) + await using (signatureStream.ConfigureAwait(false)) { - var signatureEncryptingStream = signatureEncryptionKey.OpenEncryptingStream(signatureStream); + using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); - await using (signatureEncryptingStream.ConfigureAwait(false)) + await using (hashingStream.ConfigureAwait(false)) { - var pgpProfile = contentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; - var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signatureEncryptingStream, signingKey, profile: pgpProfile, aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); + var signatureEncryptingStream = draft.FileKey.OpenEncryptingStream(signatureStream); - await using (encryptingStream.ConfigureAwait(false)) + await using (signatureEncryptingStream.ConfigureAwait(false)) { - await plainDataStream.CopyToAsync(encryptingStream, cancellationToken).ConfigureAwait(false); + var pgpProfile = draft.ContentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + var encryptingStream = draft.ContentKey.OpenEncryptingAndSigningStream( + hashingStream, + signatureEncryptingStream, + draft.SigningKey, + profile: pgpProfile, + aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); + + await using (encryptingStream.ConfigureAwait(false)) + { + await plainData.Stream.CopyToAsync(encryptingStream, cancellationToken).ConfigureAwait(false); + } } } - } - - var sha256Digest = sha256.GetCurrentHash(); - - var result = new BlockUploadResult((int)plainDataStream.Length, sha256Digest, IsFileContent: true); - // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, - // leading to a garbage signature. - var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; + var sha256Digest = sha256.GetCurrentHash(); - // FIXME: retry upon verification failure + var result = new BlockUploadResult((int)plainData.Stream.Length, sha256Digest); - const long AeadChunkSize = - 1 + // packet header: packet type - 1 + // packet header: partial length - 4 + // SEIPDv2 header: packet version, cipher ID, algo Id, chunk size - 32 + // SEIPDv2 header: salt - PgpAeadStreamingChunkLength.ChunkLength + - 1 + // chunk size header - 36 + // end of chunk - 16; // Aead Tag + // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, + // leading to a garbage signature. + var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; - var verificationToken = verifier.VerifyBlock(dataPacketStream.GetFirstBytes(AeadChunkSize), plainDataPrefix.AsSpan()[..plainDataPrefixLength]); + // FIXME: retry upon verification failure - var request = new BlockUploadPreparationRequest - { - VolumeId = revisionUid.NodeUid.VolumeId, - LinkId = revisionUid.NodeUid.LinkId, - RevisionId = revisionUid.RevisionId, - AddressId = membershipAddressId, - Blocks = - [ - new BlockCreationRequest - { - Index = index, - Size = (int)dataPacketStream.Length, - HashDigest = result.Sha256Digest, - EncryptedSignature = signature, - VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, - }, - ], - Thumbnails = [], - }; - - await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); + const long AeadChunkSize = + 1 + // packet header: packet type + 1 + // packet header: partial length + 4 + // SEIPDv2 header: packet version, cipher ID, algo Id, chunk size + 32 + // SEIPDv2 header: salt + PgpAeadStreamingChunkLength.ChunkLength + + 1 + // chunk size header + 36 + // end of chunk + 16; // Aead Tag - onBlockProgress?.Invoke(plainDataLength); + var plainDataPrefixLength = (int)Math.Min(draft.BlockVerifier.DataPacketPrefixMaxLength, plainData.Stream.Length); - LogContentBlobUploaded(index, revisionUid); + var verificationToken = draft.BlockVerifier.VerifyBlock( + dataPacketStream.GetFirstBytes(AeadChunkSize), + plainData.PrefixForVerification.AsSpan()[..plainDataPrefixLength]); - return result; + var request = new BlockUploadPreparationRequest + { + VolumeId = draft.Uid.NodeUid.VolumeId, + LinkId = draft.Uid.NodeUid.LinkId, + RevisionId = draft.Uid.RevisionId, + AddressId = draft.MembershipAddress.Id, + Blocks = + [ + new BlockCreationRequest + { + Index = blockNumber, + Size = (int)dataPacketStream.Length, + HashDigest = result.Sha256Digest, + EncryptedSignature = signature, + VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, + }, + ], + Thumbnails = [], + }; + + await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); + + onBlockProgress?.Invoke(plainDataLength); + + LogBlobUploaded(); + + return result; + } } } } - public async ValueTask UploadThumbnailAsync( - RevisionUid revisionUid, - PgpSessionKey contentKey, - PgpPrivateKey signingKey, - AddressId membershipAddressId, - Thumbnail thumbnail, - CancellationToken cancellationToken) + public async ValueTask UploadThumbnailAsync(RevisionDraft draft, Thumbnail thumbnail, CancellationToken cancellationToken) { - var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (dataPacketStream.ConfigureAwait(false)) + using (_logger.BeginScope("{ThumbnailType} block of revision #{RevisionUid}", thumbnail.Type, draft.Uid)) { - using var sha256 = SHA256.Create(); - - var hashingStream = new CryptoStream(dataPacketStream, sha256, CryptoStreamMode.Write, leaveOpen: true); - - await using (hashingStream.ConfigureAwait(false)) + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) { - var pgpProfile = contentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; - var encryptingStream = contentKey.OpenEncryptingAndSigningStream(hashingStream, signingKey, profile: pgpProfile, aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); + using var sha256 = SHA256.Create(); + + var hashingStream = new CryptoStream(dataPacketStream, sha256, CryptoStreamMode.Write, leaveOpen: true); - await using (encryptingStream.ConfigureAwait(false)) + await using (hashingStream.ConfigureAwait(false)) { - await encryptingStream.WriteAsync(thumbnail.Content, cancellationToken).ConfigureAwait(false); + var pgpProfile = draft.ContentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + var encryptingStream = draft.ContentKey.OpenEncryptingAndSigningStream( + hashingStream, + draft.SigningKey, + profile: pgpProfile, + aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); + + await using (encryptingStream.ConfigureAwait(false)) + { + await encryptingStream.WriteAsync(thumbnail.Content, cancellationToken).ConfigureAwait(false); + } } - } - var sha256Digest = sha256.Hash ?? []; + var sha256Digest = sha256.Hash ?? []; - var request = new BlockUploadPreparationRequest - { - VolumeId = revisionUid.NodeUid.VolumeId, - LinkId = revisionUid.NodeUid.LinkId, - RevisionId = revisionUid.RevisionId, - AddressId = membershipAddressId, - Blocks = [], - Thumbnails = - [ - new ThumbnailCreationRequest + var request = new BlockUploadPreparationRequest + { + VolumeId = draft.Uid.NodeUid.VolumeId, + LinkId = draft.Uid.NodeUid.LinkId, + RevisionId = draft.Uid.RevisionId, + AddressId = draft.MembershipAddress.Id, + Blocks = [], + Thumbnails = + [ + new ThumbnailCreationRequest { Size = (int)dataPacketStream.Length, Type = (Api.Files.ThumbnailType)thumbnail.Type, HashDigest = sha256Digest, }, ], - }; + }; - await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); + await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); - LogThumbnailBlobUploaded(revisionUid); + LogBlobUploaded(); - return new BlockUploadResult(0, sha256Digest, IsFileContent: false); + return new BlockUploadResult(0, sha256Digest); + } } } @@ -195,51 +198,24 @@ private async ValueTask UploadBlobAsync( await using (nonDisposableDataPacketStream.ConfigureAwait(false)) { await Policy - .Handle(IsExceptionHandledByRetry) + .Handle(ex => !cancellationToken.IsCancellationRequested && ExceptionIsRetriable(ex)) .WaitAndRetryAsync( retryCount: 1, sleepDurationProvider: RetryPolicy.GetAttemptDelay, onRetryAsync: async (exception, _, retryNumber, _) => { - await WaitOnRetryAfterIfNeeded(exception).ConfigureAwait(false); + await WaitOnRetryAfterIfNeededAsync(exception, cancellationToken).ConfigureAwait(false); - var blockInfo = GetBlockInfoForRequest(); - LogBlobUploadRetry(blockInfo.BlockIndex, blockInfo.RevisionUid, retryNumber, exception.FlattenMessage()); + LogBlobUploadRetry(retryNumber, exception.FlattenMessage()); }) .ExecuteAsync(ExecuteUploadAsync).ConfigureAwait(false); } return; - (int BlockIndex, RevisionUid RevisionUid) GetBlockInfoForRequest() + static bool ExceptionIsRetriable(Exception ex) { - var blockIndex = request.Blocks.Count > 0 ? request.Blocks[0].Index : 0; - var revisionUid = new RevisionUid(request.VolumeId, request.LinkId, request.RevisionId); - - return (blockIndex, revisionUid); - } - - bool IsExceptionHandledByRetry(Exception ex) - { - return !cancellationToken.IsCancellationRequested - && ex is not FileContentsDecryptionException; - } - - async Task WaitOnRetryAfterIfNeeded(Exception ex) - { - if (ex is TooManyRequestsException exception) - { - var currentTime = DateTimeOffset.UtcNow; - - if (exception.RetryAfter is { } retryAfter && retryAfter > currentTime) - { - var delayDuration = retryAfter - currentTime; - var blockInfo = GetBlockInfoForRequest(); - - LogBlobUploadWaitingForRetryAfter(blockInfo.BlockIndex, blockInfo.RevisionUid, delayDuration); - await Task.Delay(delayDuration, cancellationToken).ConfigureAwait(false); - } - } + return ex is not FileContentsDecryptionException; } async Task ExecuteUploadAsync() @@ -256,19 +232,32 @@ await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Tok } } - [LoggerMessage(Level = LogLevel.Trace, Message = "Uploaded blob for content block #{BlockIndex} for revision \"{RevisionUid}\"")] - private partial void LogContentBlobUploaded(int blockIndex, RevisionUid revisionUid); + private async Task WaitOnRetryAfterIfNeededAsync(Exception ex, CancellationToken cancellationToken) + { + if (ex is TooManyRequestsException exception) + { + var currentTime = DateTimeOffset.UtcNow; + + if (exception.RetryAfter is { } retryAfter && retryAfter > currentTime) + { + var delayDuration = retryAfter - currentTime; + + LogBlobUploadWaitingForRetryAfter(delayDuration); + await Task.Delay(delayDuration, cancellationToken).ConfigureAwait(false); + } + } + } - [LoggerMessage(Level = LogLevel.Trace, Message = "Uploaded blob for thumbnail block of revision \"{RevisionUid}\"")] - private partial void LogThumbnailBlobUploaded(RevisionUid revisionUid); + [LoggerMessage(Level = LogLevel.Trace, Message = "Uploaded blob")] + private partial void LogBlobUploaded(); [LoggerMessage( Level = LogLevel.Information, - Message = "Retrying blob upload for block #{BlockIndex} of revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt error: {ErrorMessage}")] - private partial void LogBlobUploadRetry(int blockIndex, RevisionUid revisionUid, int retryNumber, string errorMessage); + Message = "Retrying blob upload (retry number: {RetryNumber}). Previous attempt error: {ErrorMessage}")] + private partial void LogBlobUploadRetry(int retryNumber, string errorMessage); [LoggerMessage( Level = LogLevel.Information, - Message = "Waiting {DelayDuration} before retrying blob upload for block #{BlockIndex} of revision \"{RevisionUid}\" due to 429 response")] - private partial void LogBlobUploadWaitingForRetryAfter(int blockIndex, RevisionUid revisionUid, TimeSpan delayDuration); + Message = "Waiting {DelayDuration} before retrying blob upload due to 429 response")] + private partial void LogBlobUploadWaitingForRetryAfter(TimeSpan delayDuration); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 91be5a95..ca1627cb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Telemetry; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -6,7 +7,7 @@ namespace Proton.Drive.Sdk.Nodes.Upload; public sealed partial class FileUploader : IDisposable { private readonly ProtonDriveClient _client; - private readonly IFileDraftProvider _fileDraftProvider; + private readonly IRevisionDraftProvider _revisionDraftProvider; private readonly DateTimeOffset? _lastModificationTime; private readonly IEnumerable? _additionalMetadata; private readonly ILogger _logger; @@ -15,7 +16,7 @@ public sealed partial class FileUploader : IDisposable private FileUploader( ProtonDriveClient client, - IFileDraftProvider fileDraftProvider, + IRevisionDraftProvider revisionDraftProvider, long size, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, @@ -23,7 +24,7 @@ private FileUploader( ILogger logger) { _client = client; - _fileDraftProvider = fileDraftProvider; + _revisionDraftProvider = revisionDraftProvider; FileSize = size; _lastModificationTime = lastModificationTime; _additionalMetadata = additionalMetadata; @@ -39,19 +40,7 @@ public UploadController UploadFromStream( Action? onProgress, CancellationToken cancellationToken) { - var taskControl = new TaskControl(cancellationToken); - - var revisionUidTaskCompletionSource = new TaskCompletionSource(); - - var uploadTask = UploadFromStreamAsync( - contentStream, - thumbnails, - _additionalMetadata, - progress => onProgress?.Invoke(progress, FileSize), - revisionUidTaskCompletionSource, - taskControl); - - return new UploadController(_client.Api, _fileDraftProvider, revisionUidTaskCompletionSource.Task, uploadTask, taskControl, _logger); + return UploadFromStream(contentStream, ownsContentStream: false, thumbnails, onProgress, cancellationToken); } public UploadController UploadFromFile( @@ -60,19 +49,14 @@ public UploadController UploadFromFile( Action? onProgress, CancellationToken cancellationToken) { - var taskControl = new TaskControl(cancellationToken); - - var revisionUidTaskCompletionSource = new TaskCompletionSource(); + var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - var uploadTask = UploadFromFileAsync( - filePath, + return UploadFromStream( + contentStream, + ownsContentStream: true, thumbnails, - _additionalMetadata, - progress => onProgress?.Invoke(progress, FileSize), - revisionUidTaskCompletionSource, - taskControl); - - return new UploadController(_client.Api, _fileDraftProvider, revisionUidTaskCompletionSource.Task, uploadTask, taskControl, _logger); + onProgress, + cancellationToken); } public void Dispose() @@ -82,7 +66,7 @@ public void Dispose() internal static async ValueTask CreateAsync( ProtonDriveClient client, - IFileDraftProvider fileDraftProvider, + IRevisionDraftProvider revisionDraftProvider, long size, DateTime? lastModificationTime, IEnumerable? additionalExtendedAttributes, @@ -98,7 +82,7 @@ internal static async ValueTask CreateAsync( LogAcquiredRevisionCreationSemaphore(logger, expectedNumberOfBlocks); - return new FileUploader(client, fileDraftProvider, size, lastModificationTime, additionalExtendedAttributes, expectedNumberOfBlocks, logger); + return new FileUploader(client, revisionDraftProvider, size, lastModificationTime, additionalExtendedAttributes, expectedNumberOfBlocks, logger); } [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire {Count} from revision creation semaphore")] @@ -110,40 +94,91 @@ internal static async ValueTask CreateAsync( [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from revision creation semaphore")] private static partial void LogReleasedRevisionCreationSemaphore(ILogger logger, int count); + private UploadController UploadFromStream( + Stream contentStream, + bool ownsContentStream, + IEnumerable thumbnails, + Action? onProgress, + CancellationToken cancellationToken) + { + var taskControl = new TaskControl(cancellationToken); + + var revisionDraftTaskCompletionSource = new TaskCompletionSource(); + + var uploadEvent = new UploadEvent + { + ExpectedSize = contentStream.Length, + UploadedSize = 0, + ApproximateUploadedSize = 0, + VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type + }; + + var uploadFunction = (CancellationToken ct) => UploadFromStreamAsync( + contentStream, + thumbnails, + _additionalMetadata, + progress => onProgress?.Invoke(progress, FileSize), + revisionDraftTaskCompletionSource, + ct); + + var uploadController = new UploadController( + revisionDraftTaskCompletionSource.Task, + uploadFunction.Invoke(taskControl.PauseOrCancellationToken), + uploadFunction, + ownsContentStream ? contentStream : null, + taskControl); + + uploadController.UploadFailed += ex => + { + if (ex is NodeWithSameNameExistsException) + { + return; + } + + uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); + uploadEvent.OriginalError = ex.GetBaseException().ToString(); + RaiseTelemetryEvent(uploadEvent); + }; + + uploadController.UploadSucceeded += uploadedByteCount => + { + // TODO: deprecate UploadedSize in favor of ApproximateUploadedSize + uploadEvent.UploadedSize = uploadedByteCount; + uploadEvent.ApproximateUploadedSize = Privacy.ReduceSizePrecision(uploadedByteCount); + RaiseTelemetryEvent(uploadEvent); + }; + + return uploadController; + } + private async Task UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, IEnumerable? additionalExtendedAttributes, Action? onProgress, - TaskCompletionSource revisionUidTaskCompletionSource, - TaskControl taskControl) + TaskCompletionSource revisionDraftTaskCompletionSource, + CancellationToken cancellationToken) { - try + if (!revisionDraftTaskCompletionSource.Task.IsCompletedSuccessfully) { - var (draftRevisionUid, fileSecrets) = await taskControl.HandlePauseAsync(ct => _fileDraftProvider.GetDraftAsync(_client, ct)).ConfigureAwait(false); + revisionDraftTaskCompletionSource.SetResult( + await _revisionDraftProvider.GetDraftAsync(cancellationToken).ConfigureAwait(false)); + } - revisionUidTaskCompletionSource.SetResult(draftRevisionUid); + var revisionDraft = revisionDraftTaskCompletionSource.Task.Result; - await UploadAsync( - draftRevisionUid, - fileSecrets, - contentStream, - thumbnails, - _lastModificationTime, - additionalExtendedAttributes, - onProgress, - taskControl).ConfigureAwait(false); + await UploadAsync( + revisionDraftTaskCompletionSource.Task.Result, + contentStream, + thumbnails, + _lastModificationTime, + additionalExtendedAttributes, + onProgress, + cancellationToken).ConfigureAwait(false); - await UpdateActiveRevisionInCacheAsync(draftRevisionUid, contentStream.Length, taskControl.CancellationToken).ConfigureAwait(false); + await UpdateActiveRevisionInCacheAsync(revisionDraft.Uid, contentStream.Length, cancellationToken).ConfigureAwait(false); - return new UploadResult(draftRevisionUid.NodeUid, draftRevisionUid); - } - catch - { - // This will set it to canceled only if the result was not already set above - revisionUidTaskCompletionSource.TrySetCanceled(); - throw; - } + return new UploadResult(revisionDraft.Uid.NodeUid, revisionDraft.Uid); } private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid, long size, CancellationToken cancellationToken) @@ -172,47 +207,25 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid await _client.Cache.Entities.SetNodeAsync(fileNode.Uid, fileNode, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); } - private async Task UploadFromFileAsync( - string filePath, - IEnumerable thumbnails, - IEnumerable? additionalMetadata, - Action? onProgress, - TaskCompletionSource revisionUidTaskCompletionSource, - TaskControl taskControl) - { - var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - - await using (contentStream.ConfigureAwait(false)) - { - return await UploadFromStreamAsync( - contentStream, - thumbnails, - additionalMetadata, - onProgress, - revisionUidTaskCompletionSource, - taskControl).ConfigureAwait(false); - } - } - private async ValueTask UploadAsync( - RevisionUid revisionUid, - FileSecrets fileSecrets, + RevisionDraft revisionDraft, Stream contentStream, IEnumerable thumbnails, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, Action? onProgress, - TaskControl taskControl) + CancellationToken cancellationToken) { - using var revisionWriter = await RevisionOperations.OpenForWritingAsync( - _client, - revisionUid, - fileSecrets, - ReleaseBlocks, - taskControl).ConfigureAwait(false); - - await revisionWriter.WriteAsync(contentStream, FileSize, thumbnails, lastModificationTime, additionalMetadata, onProgress, taskControl) - .ConfigureAwait(false); + using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionDraft, ReleaseBlocks, cancellationToken).ConfigureAwait(false); + + await revisionWriter.WriteAsync( + contentStream, + FileSize, + thumbnails, + lastModificationTime, + additionalMetadata, + onProgress, + cancellationToken).ConfigureAwait(false); } private void ReleaseBlocks(int numberOfBlocks) @@ -239,4 +252,16 @@ private void ReleaseRemainingBlocks() _remainingNumberOfBlocks = 0; } + + private void RaiseTelemetryEvent(UploadEvent uploadEvent) + { + try + { + _client.Telemetry.RecordMetric(uploadEvent); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to record metric for upload event"); + } + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs deleted file mode 100644 index 68d4e579..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IFileDraftProvider.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Proton.Drive.Sdk.Api; - -namespace Proton.Drive.Sdk.Nodes.Upload; - -internal interface IFileDraftProvider -{ - ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync(ProtonDriveClient client, CancellationToken cancellationToken); - - ValueTask DeleteDraftAsync(IDriveApiClients apiClients, RevisionUid revisionUid, CancellationToken cancellationToken); -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs new file mode 100644 index 00000000..a99f4043 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal interface IRevisionDraftProvider +{ + ValueTask GetDraftAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 49eefd24..695fe40a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -1,54 +1,61 @@ using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Files; using Proton.Sdk; using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Nodes.Upload; -internal sealed class NewFileDraftProvider : IFileDraftProvider +internal sealed class NewFileDraftProvider : IRevisionDraftProvider { private const int MaxNumberOfDraftCreationAttempts = 3; + private readonly ProtonDriveClient _client; private readonly NodeUid _parentUid; private readonly string _name; private readonly string _mediaType; private readonly bool _overrideExistingDraftByOtherClient; internal NewFileDraftProvider( + ProtonDriveClient client, NodeUid parentUid, string name, string mediaType, bool overrideExistingDraftByOtherClient) { + _client = client; _parentUid = parentUid; _name = name; _mediaType = mediaType; _overrideExistingDraftByOtherClient = overrideExistingDraftByOtherClient; } - public async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync(ProtonDriveClient client, CancellationToken cancellationToken) + public async ValueTask GetDraftAsync(CancellationToken cancellationToken) { - var parentSecrets = await FolderOperations.GetSecretsAsync(client, _parentUid, cancellationToken).ConfigureAwait(false); + var parentSecrets = await FolderOperations.GetSecretsAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); - var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, _parentUid, cancellationToken).ConfigureAwait(false); + var membershipAddress = await NodeOperations.GetMembershipAddressAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); - var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + var signingKey = await _client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var (response, fileSecrets) = await CreateDraftAsync(client, parentSecrets, signingKey, membershipAddress.EmailAddress, cancellationToken) + var (response, fileSecrets) = await CreateDraftAsync(parentSecrets, signingKey, membershipAddress.EmailAddress, cancellationToken) .ConfigureAwait(false); var draftNodeUid = new NodeUid(_parentUid.VolumeId, response.Identifiers.LinkId); var draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); - await client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); + await _client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); - return (draftRevisionUid, fileSecrets); - } + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(draftRevisionUid, fileSecrets.Key, cancellationToken).ConfigureAwait(false); - public async ValueTask DeleteDraftAsync(IDriveApiClients apiClients, RevisionUid revisionUid, CancellationToken cancellationToken) - { - await apiClients.Links.DeleteMultipleAsync(revisionUid.NodeUid.VolumeId, [revisionUid.NodeUid.LinkId], cancellationToken).ConfigureAwait(false); + return new RevisionDraft( + draftRevisionUid, + fileSecrets.Key, + fileSecrets.ContentKey, + signingKey, + membershipAddress, + blockVerifier, + ct => DeleteDraftAsync(draftRevisionUid, ct), + _client.Telemetry.GetLogger("New file draft")); } private static FileCreationRequest GetFileCreationRequest( @@ -100,7 +107,6 @@ private static FileCreationRequest GetFileCreationRequest( } private async ValueTask<(FileCreationResponse Response, FileSecrets FileSecrets)> CreateDraftAsync( - ProtonDriveClient client, FolderSecrets parentSecrets, PgpPrivateKey signingKey, string membershipEmailAddress, @@ -110,12 +116,13 @@ private static FileCreationRequest GetFileCreationRequest( (FileCreationResponse Response, FileSecrets FileSecrets)? result = null; - var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken).ConfigureAwait(false); + var useAeadFeatureFlag = await _client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken) + .ConfigureAwait(false); while (result is null) { var request = GetFileCreationRequest( - client.Uid, + _client.Uid, _parentUid, _name, _mediaType, @@ -130,7 +137,7 @@ private static FileCreationRequest GetFileCreationRequest( try { - var response = await client.Api.Files.CreateFileAsync(_parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); + var response = await _client.Api.Files.CreateFileAsync(_parentUid.VolumeId, request, cancellationToken).ConfigureAwait(false); var fileSecrets = new FileSecrets { @@ -144,12 +151,12 @@ private static FileCreationRequest GetFileCreationRequest( } catch (ProtonApiException e) when (e.Response is { Conflict: { LinkId: { } conflictingLinkId, RevisionId: null, DraftRevisionId: not null } } - && (e.Response.Conflict.DraftClientUid == client.Uid || _overrideExistingDraftByOtherClient) + && (e.Response.Conflict.DraftClientUid == _client.Uid || _overrideExistingDraftByOtherClient) && remainingNumberOfAttempts-- > 0) { var conflictingNodeUid = new NodeUid(_parentUid.VolumeId, conflictingLinkId); - var deletionResults = await NodeOperations.DeleteAsync(client, [conflictingNodeUid], cancellationToken).ConfigureAwait(false); + var deletionResults = await NodeOperations.DeleteAsync(_client, [conflictingNodeUid], cancellationToken).ConfigureAwait(false); if (!deletionResults.TryGetValue(conflictingNodeUid, out var deletionResult)) { @@ -169,4 +176,9 @@ private static FileCreationRequest GetFileCreationRequest( return result.Value; } + + private async ValueTask DeleteDraftAsync(RevisionUid revisionUid, CancellationToken cancellationToken) + { + await _client.Api.Links.DeleteMultipleAsync(revisionUid.NodeUid.VolumeId, [revisionUid.NodeUid.LinkId], cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index e277a87c..ee1a7802 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -1,35 +1,35 @@ -using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Files; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Upload; -internal sealed class NewRevisionDraftProvider : IFileDraftProvider +internal sealed class NewRevisionDraftProvider : IRevisionDraftProvider { private const int MaxNumberOfDraftCreationAttempts = 3; + private readonly ProtonDriveClient _client; private readonly NodeUid _fileUid; private readonly RevisionId _lastKnownRevisionId; internal NewRevisionDraftProvider( + ProtonDriveClient client, NodeUid fileUid, RevisionId lastKnownRevisionId) { + _client = client; _fileUid = fileUid; _lastKnownRevisionId = lastKnownRevisionId; } - public async ValueTask<(RevisionUid RevisionUid, FileSecrets FileSecrets)> GetDraftAsync( - ProtonDriveClient client, - CancellationToken cancellationToken) + public async ValueTask GetDraftAsync(CancellationToken cancellationToken) { var parameters = new RevisionCreationRequest { CurrentRevisionId = _lastKnownRevisionId, - ClientId = client.Uid, + ClientId = _client.Uid, }; - var fileSecretsResult = await FileOperations.GetSecretsAsync(client, _fileUid, cancellationToken).ConfigureAwait(false); + var fileSecretsResult = await FileOperations.GetSecretsAsync(_client, _fileUid, cancellationToken).ConfigureAwait(false); if (!fileSecretsResult.TryGetValueElseError(out var fileSecrets, out _)) { @@ -43,17 +43,17 @@ internal NewRevisionDraftProvider( { try { - var revisionResponse = await client.Api.Files.CreateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, parameters, cancellationToken) + var revisionResponse = await _client.Api.Files.CreateRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, parameters, cancellationToken) .ConfigureAwait(false); revisionId = revisionResponse.Identity.RevisionId; } catch (ProtonApiException e) when (e.Response is { Conflict.DraftRevisionId: { } draftRevisionId } - && (e.Response.Conflict.DraftClientUid == client.Uid) + && (e.Response.Conflict.DraftClientUid == _client.Uid) && remainingNumberOfAttempts-- > 0) { - await client.Api.Files.DeleteRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, draftRevisionId, cancellationToken).ConfigureAwait(false); + await _client.Api.Files.DeleteRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, draftRevisionId, cancellationToken).ConfigureAwait(false); } catch (ProtonApiException e) { @@ -61,12 +61,28 @@ internal NewRevisionDraftProvider( } } - return (new RevisionUid(_fileUid, revisionId.Value), fileSecrets); + var draftRevisionUid = new RevisionUid(_fileUid, revisionId.Value); + + var membershipAddress = await NodeOperations.GetMembershipAddressAsync(_client, _fileUid, cancellationToken).ConfigureAwait(false); + + var signingKey = await _client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(draftRevisionUid, fileSecrets.Key, cancellationToken).ConfigureAwait(false); + + return new RevisionDraft( + draftRevisionUid, + fileSecrets.Key, + fileSecrets.ContentKey, + signingKey, + membershipAddress, + blockVerifier, + ct => DeleteDraftAsync(draftRevisionUid, ct), + _client.Telemetry.GetLogger("New file draft")); } - public async ValueTask DeleteDraftAsync(IDriveApiClients apiClients, RevisionUid revisionUid, CancellationToken cancellationToken) + private async ValueTask DeleteDraftAsync(RevisionUid revisionUid, CancellationToken cancellationToken) { - await apiClients.Files.DeleteRevisionAsync(revisionUid.NodeUid.VolumeId, revisionUid.NodeUid.LinkId, revisionUid.RevisionId, cancellationToken) + await _client.Api.Files.DeleteRevisionAsync(revisionUid.NodeUid.VolumeId, revisionUid.NodeUid.LinkId, revisionUid.RevisionId, cancellationToken) .ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs new file mode 100644 index 00000000..47401ce5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -0,0 +1,142 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Nodes.Upload.Verification; +using Proton.Sdk; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +internal sealed partial class RevisionDraft( + RevisionUid uid, + PgpPrivateKey fileKey, + PgpSessionKey contentKey, + PgpPrivateKey signingKey, + Address membershipAddress, + IBlockVerifier blockVerifier, + Func deleteDraftFunction, + ILogger logger) : IAsyncDisposable +{ + private readonly Dictionary _thumbnailUploadResults = []; + private readonly List> _contentBlockStates = []; + + private readonly Lock _blockUploadStatesLock = new(); + private readonly ILogger _logger = logger; + + public RevisionUid Uid { get; } = uid; + public PgpPrivateKey FileKey { get; } = fileKey; + public PgpSessionKey ContentKey { get; } = contentKey; + public PgpPrivateKey SigningKey { get; } = signingKey; + public Address MembershipAddress { get; } = membershipAddress; + public IBlockVerifier BlockVerifier { get; } = blockVerifier; + + public IncrementalHash Sha1 { get; } = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + + public IReadOnlyDictionary ThumbnailUploadResults => _thumbnailUploadResults; + public IReadOnlyList> ContentBlockStates => _contentBlockStates; + + public bool IsCompleted { get; set; } + public long NumberOfPlainBytesDone { get; set; } + + public void SetContentBlockPlainData(int blockNumber, BlockUploadPlainData plainData) + { + lock (_blockUploadStatesLock) + { + var blockStateIndex = blockNumber - 1; + + if (blockStateIndex < _contentBlockStates.Count) + { + throw new InvalidOperationException("Content block plain data has already been set."); + } + + _contentBlockStates.Insert(blockStateIndex, plainData); + } + } + + public void SetThumbnailUploadResult(ThumbnailType thumbnailType, BlockUploadResult result) + { + lock (_blockUploadStatesLock) + { + _thumbnailUploadResults[thumbnailType] = result; + } + } + + public void SetContentBlockUploadResult(int blockNumber, BlockUploadResult blockUploadResult) + { + lock (_blockUploadStatesLock) + { + var blockStateIndex = blockNumber - 1; + + if (blockStateIndex >= _contentBlockStates.Count) + { + throw new InvalidOperationException("Content block plain data must be set before uploading."); + } + + _contentBlockStates[blockStateIndex] = blockUploadResult; + } + } + + public bool ThumbnailBlockWasAlreadyUploaded(ThumbnailType thumbnailType) + { + lock (_blockUploadStatesLock) + { + return _thumbnailUploadResults.ContainsKey(thumbnailType); + } + } + + public int GetFirstNonUploadedContentBlockNumber() + { + return _contentBlockStates.TakeWhile(x => x.IsFirst).Count() + 1; + } + + public bool TryGetContentBlockPlainData(int blockNumber, [NotNullWhen(true)] out BlockUploadPlainData? blockPlainData) + { + var blockStateIndex = blockNumber - 1; + + if (blockStateIndex >= _contentBlockStates.Count + || _contentBlockStates[blockStateIndex].TryGetFirstElseSecond(out _, out var result)) + { + blockPlainData = null; + return false; + } + + blockPlainData = result; + return true; + } + + public async ValueTask DisposeAsync() + { + FileKey.Dispose(); + ContentKey.Dispose(); + SigningKey.Dispose(); + Sha1.Dispose(); + + var dataItemsToDispose = ContentBlockStates + .Select(x => !x.TryGetFirstElseSecond(out _, out var data) ? data : (BlockUploadPlainData?)null) + .Where(task => task is not null) + .Select(task => task!.Value); + + await Parallel.ForEachAsync(dataItemsToDispose, (data, _) => + { + ArrayPool.Shared.Return(data.PrefixForVerification); + return data.Stream.DisposeAsync(); + }).ConfigureAwait(false); + + if (!IsCompleted) + { + try + { + await deleteDraftFunction.Invoke(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + LogDraftDeletionFailure(ex, Uid); + } + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Draft deletion failed for revision {RevisionUid}")] + private partial void LogDraftDeletionFailure(Exception exception, RevisionUid revisionUid); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index b20ba072..19059847 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -5,11 +5,9 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Cryptography; -using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Serialization; -using Proton.Drive.Sdk.Telemetry; using Proton.Sdk; -using Proton.Sdk.Addresses; +using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -18,42 +16,27 @@ internal sealed partial class RevisionWriter : IDisposable public const int DefaultBlockSize = 1 << 22; // 4 MiB private readonly ProtonDriveClient _client; - private readonly RevisionUid _revisionUid; - private readonly PgpPrivateKey _fileKey; - private readonly PgpSessionKey _contentKey; - private readonly PgpPrivateKey _signingKey; - private readonly Address _membershipAddress; + private readonly RevisionDraft _draft; private readonly Action _releaseBlocksAction; private readonly Action _releaseFileSemaphoreAction; private readonly ILogger _logger; private readonly int _targetBlockSize; - private readonly int _maxBlockSize; private bool _fileReleased; internal RevisionWriter( ProtonDriveClient client, - RevisionUid revisionUid, - PgpPrivateKey fileKey, - PgpSessionKey contentKey, - PgpPrivateKey signingKey, - Address membershipAddress, + RevisionDraft draft, Action releaseBlocksAction, Action releaseFileSemaphoreAction, - int targetBlockSize = DefaultBlockSize, - int maxBlockSize = DefaultBlockSize) + int targetBlockSize = DefaultBlockSize) { _client = client; - _revisionUid = revisionUid; - _fileKey = fileKey; - _contentKey = contentKey; - _signingKey = signingKey; - _membershipAddress = membershipAddress; + _draft = draft; _releaseBlocksAction = releaseBlocksAction; _releaseFileSemaphoreAction = releaseFileSemaphoreAction; _targetBlockSize = targetBlockSize; - _maxBlockSize = maxBlockSize; _logger = client.Telemetry.GetLogger("Revision writer"); } @@ -64,159 +47,87 @@ public async ValueTask WriteAsync( DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, Action? onProgress, - TaskControl taskControl) + CancellationToken cancellationToken) { - var uploadEvent = new UploadEvent - { - ExpectedSize = contentStream.Length, - UploadedSize = 0, - ApproximateUploadedSize = 0, - VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type - }; + var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); - try - { - var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); - var blockUploadResults = new List(8); + var signingEmailAddress = _draft.MembershipAddress.EmailAddress; - var signingEmailAddress = _membershipAddress.EmailAddress; + var expectedThumbnailBlockCount = 0; - var blockIndex = 0; - long numberOfBytesUploaded = 0; - var expectedThumbnailBlockCount = 0; + var hashingContentStream = new HashingReadStream(contentStream, _draft.Sha1, leaveOpen: true); - using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + await using (hashingContentStream.ConfigureAwait(false)) + { + try + { + try + { + expectedThumbnailBlockCount = await UploadThumbnailBlocksAsync(thumbnails, uploadTasks, cancellationToken).ConfigureAwait(false); - var hashingContentStream = new HashingReadStream(contentStream, sha1, leaveOpen: true); + await UploadContentBlocksAsync(onProgress, hashingContentStream, uploadTasks, cancellationToken).ConfigureAwait(false); + } + finally + { + _releaseFileSemaphoreAction.Invoke(); + _fileReleased = true; + } - await using (hashingContentStream.ConfigureAwait(false)) + while (uploadTasks.TryDequeue(out var uploadTask)) + { + await uploadTask.ConfigureAwait(false); + } + } + catch when (uploadTasks.Count > 0) { - var blockVerifier = await taskControl.HandlePauseAsync(ct => _client.BlockVerifierFactory.CreateAsync(_revisionUid, _fileKey, ct)) - .ConfigureAwait(false); - - try + foreach (var uploadTask in uploadTasks) { try { - foreach (var thumbnail in thumbnails) - { - ++expectedThumbnailBlockCount; - - await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, taskControl).ConfigureAwait(false); - - var uploadTask = UploadThumbnailBlockAsync(thumbnail, taskControl).AsTask(); - - uploadTasks.Enqueue(uploadTask); - } - - while ( - await TryGetBlockPlainDataStreamAsync( - hashingContentStream, - blockVerifier.DataPacketPrefixMaxLength, - taskControl).ConfigureAwait(false) is var (plainDataStream, plainDataPrefixBuffer)) - { - try - { - await WaitForBlockUploaderAsync(uploadTasks, blockUploadResults, taskControl).ConfigureAwait(false); - - var onBlockProgress = onProgress is not null - ? progress => - { - numberOfBytesUploaded += progress; - - // TODO: move this to a decorator, wrap the progress action - uploadEvent.UploadedSize = numberOfBytesUploaded; - uploadEvent.ApproximateUploadedSize = ReduceSizePrecision(numberOfBytesUploaded); - - onProgress(numberOfBytesUploaded); - } - : default(Action?); - - var uploadTask = UploadContentBlockAsync( - ++blockIndex, - plainDataStream, - blockVerifier, - plainDataPrefixBuffer, - onBlockProgress, - taskControl).AsTask(); - - uploadTasks.Enqueue(uploadTask); - } - catch - { - ArrayPool.Shared.Return(plainDataPrefixBuffer); - throw; - } - } + await uploadTask.ConfigureAwait(false); } - finally + catch { - _releaseFileSemaphoreAction.Invoke(); - _fileReleased = true; + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw } - - while (uploadTasks.Count > 0) - { - await RegisterNextCompletedBlockAsync(uploadTasks, blockUploadResults).ConfigureAwait(false); - } - } - catch when (uploadTasks.Count > 0) - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw - await Task.WhenAll(uploadTasks).ContinueWith(task => task.Exception?.Handle(_ => true), TaskContinuationOptions.NotOnRanToCompletion).ConfigureAwait(false); - throw; } - } - await taskControl.WaitWhilePausedAsync().ConfigureAwait(false); + throw; + } + } - var request = GetRevisionUpdateRequest( - lastModificationTime, - blockUploadResults, - expectedContentLength, - expectedThumbnailBlockCount, - sha1.GetCurrentHash(), - signingEmailAddress, - additionalMetadata); + var request = CreateRevisionUpdateRequest( + lastModificationTime, + expectedContentLength, + expectedThumbnailBlockCount, + _draft.Sha1.GetCurrentHash(), + signingEmailAddress, + additionalMetadata); - LogSealingRevision(_revisionUid); + LogSealingRevision(_draft.Uid); + try + { await _client.Api.Files.UpdateRevisionAsync( - _revisionUid.NodeUid.VolumeId, - _revisionUid.NodeUid.LinkId, - _revisionUid.RevisionId, + _draft.Uid.NodeUid.VolumeId, + _draft.Uid.NodeUid.LinkId, + _draft.Uid.RevisionId, request, - taskControl.CancellationToken).ConfigureAwait(false); - - LogRevisionSealed(_revisionUid); + cancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (!taskControl.IsCanceled) + catch (ProtonApiException ex) when (ex.Code is ResponseCode.IncompatibleState) { - var exception = taskControl.PauseExceptionSignal.Exception?.InnerException ?? ex; - - if (TelemetryErrorResolver.GetUploadErrorFromException(exception) is { } uploadError) + // The revision might have been previously sealed without getting the response back due to a cancellation. + // Throw only if the revision is still not sealed. + if (!(await RevisionIsSealedAsync(cancellationToken).ConfigureAwait(false))) { - uploadEvent.Error = uploadError; - uploadEvent.OriginalError = ex.FlattenMessageWithExceptionType(); - } - - throw; - } - finally - { - if (!taskControl.IsCanceled) - { - try - { - // TODO: put this in a decorator - _client.Telemetry.RecordMetric(uploadEvent); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to record metric for upload event"); - } + throw; } } + + LogRevisionSealed(_draft.Uid); + + _draft.IsCompleted = true; } public void Dispose() @@ -227,72 +138,22 @@ public void Dispose() } } - private static long ReduceSizePrecision(long size) - { - const long precision = 100_000; - - if (size == 0) - { - return 0; - } - - // We care about very small files in metrics, thus we handle explicitly - // the very small files so they appear correctly in metrics. - if (size < 4096) - { - return 4095; - } - - if (size < precision) - { - return precision; - } - - return (size / precision) * precision; - } - - private static async ValueTask RegisterNextCompletedBlockAsync(Queue> uploadTasks, List blockUploadResults) - { - var blockUploadResult = await uploadTasks.Dequeue().ConfigureAwait(false); - - blockUploadResults.Add(blockUploadResult); - } - - private static bool IsResumableError(Exception ex) - { - return ex is not ProtonApiException { TransportCode: >= 400 and < 500 } - and not NodeKeyAndSessionKeyMismatchException - and not SessionKeyAndDataPacketMismatchException; - } - private async ValueTask UploadContentBlockAsync( - int index, - Stream plainDataStream, - IBlockVerifier blockVerifier, - byte[] plainDataPrefix, + int blockNumber, + BlockUploadPlainData plainData, Action? onBlockProgress, - TaskControl taskControl) + CancellationToken cancellationToken) { try { - await using (plainDataStream.ConfigureAwait(false)) - { - return await taskControl.HandlePauseAsync( - ct => _client.BlockUploader.UploadContentAsync( - _revisionUid, - index, - _contentKey, - _signingKey, - _membershipAddress.Id, - _fileKey, - plainDataStream, - blockVerifier, - plainDataPrefix, - (int)Math.Min(blockVerifier.DataPacketPrefixMaxLength, plainDataStream.Length), - onBlockProgress, - ct), - exceptionTriggersPause: IsResumableError).ConfigureAwait(false); - } + var result = await _client.BlockUploader.UploadContentAsync(_draft, blockNumber, plainData, onBlockProgress, cancellationToken) + .ConfigureAwait(false); + + _draft.SetContentBlockUploadResult(blockNumber, result); + + await plainData.DisposeAsync().ConfigureAwait(false); + + return result; } finally { @@ -302,31 +163,46 @@ private async ValueTask UploadContentBlockAsync( } finally { - try - { - ArrayPool.Shared.Return(plainDataPrefix); - } - finally - { - _releaseBlocksAction.Invoke(1); - } + _releaseBlocksAction.Invoke(1); + } + } + } + + private async ValueTask UploadThumbnailBlocksAsync( + IEnumerable thumbnails, + Queue> uploadTasks, + CancellationToken cancellationToken) + { + var blockCount = 0; + + foreach (var thumbnail in thumbnails) + { + ++blockCount; + + if (_draft.ThumbnailBlockWasAlreadyUploaded(thumbnail.Type)) + { + continue; } + + await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); + + var uploadTask = UploadThumbnailBlockAsync(thumbnail, cancellationToken).AsTask(); + + uploadTasks.Enqueue(uploadTask); } + + return blockCount; } - private async ValueTask UploadThumbnailBlockAsync(Thumbnail thumbnail, TaskControl taskControl) + private async ValueTask UploadThumbnailBlockAsync(Thumbnail thumbnail, CancellationToken cancellationToken) { try { - return await taskControl.HandlePauseAsync( - ct => _client.BlockUploader.UploadThumbnailAsync( - _revisionUid, - _contentKey, - _signingKey, - _membershipAddress.Id, - thumbnail, - ct), - exceptionTriggersPause: IsResumableError).ConfigureAwait(false); + var result = await _client.BlockUploader.UploadThumbnailAsync(_draft, thumbnail, cancellationToken).ConfigureAwait(false); + + _draft.SetThumbnailUploadResult(thumbnail.Type, result); + + return result; } finally { @@ -341,14 +217,48 @@ private async ValueTask UploadThumbnailBlockAsync(Thumbnail t } } - private async ValueTask<(Stream Stream, byte[] Prefix)?> TryGetBlockPlainDataStreamAsync( + private async ValueTask UploadContentBlocksAsync( + Action? onProgress, + HashingReadStream hashingContentStream, + Queue> uploadTasks, + CancellationToken cancellationToken) + { + var contentBlockNumber = _draft.GetFirstNonUploadedContentBlockNumber(); + + while ( + await TryGetBlockPlainDataAsync( + contentBlockNumber, + hashingContentStream, + _draft.BlockVerifier.DataPacketPrefixMaxLength).ConfigureAwait(false) is { } plainData) + { + await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); + + var onBlockProgress = onProgress is not null + ? progress => + { + _draft.NumberOfPlainBytesDone += progress; + onProgress(_draft.NumberOfPlainBytesDone); + } + : default(Action?); + + var uploadTask = UploadContentBlockAsync(contentBlockNumber, plainData, onBlockProgress, cancellationToken).AsTask(); + + uploadTasks.Enqueue(uploadTask); + + ++contentBlockNumber; + } + } + + private async ValueTask TryGetBlockPlainDataAsync( + int blockNumber, Stream contentStream, - int prefixLength, - TaskControl taskControl) + int prefixLength) { - // Prevent reading source content stream while paused. If pausing happens when execution has already passed further, - // content stream might be read after pausing is signaled to the external observer. - await taskControl.WaitWhilePausedAsync().ConfigureAwait(false); + if (_draft.TryGetContentBlockPlainData(blockNumber, out var plainData)) + { + plainData.Value.Stream.Seek(0, SeekOrigin.Begin); + return plainData; + } var plainDataPrefixBuffer = ArrayPool.Shared.Rent(prefixLength); try @@ -357,11 +267,13 @@ private async ValueTask UploadThumbnailBlockAsync(Thumbnail t try { + // Do not cancel reading from the content stream for now, to avoid leaving it in an unusable state for resuming + // TODO: allow cancellation while reading block plain data var bytesCopied = await contentStream.PartiallyCopyToAsync( plainDataStream, _targetBlockSize, plainDataPrefixBuffer, - taskControl.CancellationToken).ConfigureAwait(false); + CancellationToken.None).ConfigureAwait(false); if (bytesCopied == 0) { @@ -370,12 +282,17 @@ private async ValueTask UploadThumbnailBlockAsync(Thumbnail t plainDataStream.Seek(0, SeekOrigin.Begin); - return (plainDataStream, plainDataPrefixBuffer); + plainData = new BlockUploadPlainData(plainDataStream, plainDataPrefixBuffer); + + _draft.SetContentBlockPlainData(blockNumber, plainData.Value); + + return plainData; } - catch + catch (Exception ex) { await plainDataStream.DisposeAsync().ConfigureAwait(false); - throw; + + throw new UnreadableContentException("Reading block content for upload failed", ex); } } catch @@ -385,54 +302,59 @@ private async ValueTask UploadThumbnailBlockAsync(Thumbnail t } } - private async ValueTask WaitForBlockUploaderAsync( - Queue> uploadTasks, - List blockUploadResults, - TaskControl taskControl) + private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTasks, CancellationToken cancellationToken) { - await taskControl.WaitWhilePausedAsync().ConfigureAwait(false); - if (!_client.BlockUploader.Queue.TryStartBlock()) { - if (uploadTasks.Count > 0) + if (uploadTasks.TryDequeue(out var uploadTask)) { - await RegisterNextCompletedBlockAsync(uploadTasks, blockUploadResults).ConfigureAwait(false); + await uploadTask.ConfigureAwait(false); } - await _client.BlockUploader.Queue.StartBlockAsync(taskControl.CancellationToken).ConfigureAwait(false); + await _client.BlockUploader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } } - [LoggerMessage(Level = LogLevel.Debug, Message = "Sealing revision \"{RevisionUid}\"")] - private partial void LogSealingRevision(RevisionUid revisionUid); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Revision \"{RevisionUid}\" sealed")] - private partial void LogRevisionSealed(RevisionUid revisionUid); - - private RevisionUpdateRequest GetRevisionUpdateRequest( + private RevisionUpdateRequest CreateRevisionUpdateRequest( DateTimeOffset? lastModificationTime, - List blockUploadResults, long expectedContentLength, int expectedThumbnailBlockCount, byte[]? sha1Digest, string signingEmailAddress, IEnumerable? additionalMetadata) { - var manifest = new byte[blockUploadResults.Count * SHA256.HashSizeInBytes]; + var manifest = new byte[(_draft.ThumbnailUploadResults.Count + _draft.ContentBlockStates.Count) * SHA256.HashSizeInBytes]; using var manifestStream = new MemoryStream(manifest); - var contentBlockSizes = new List(blockUploadResults.Count); + var contentBlockSizes = new List(_draft.ContentBlockStates.Count); var uploadedContentSize = 0L; - foreach (var (plaintextSize, sha256Digest, isFileContent) in blockUploadResults) + var contentBlockUploadResults = _draft.ContentBlockStates + .Select((blockState, i) => + { + var blockNumber = i + 1; + + return blockState.TryGetFirstElseSecond(out var uploadResult, out _) + ? (Number: blockNumber, Value: uploadResult) + : throw new IntegrityException($"Missing content block #{blockNumber}"); + }); + + var blockUploadResults = _draft.ThumbnailUploadResults.Values.Select(x => (Number: 0, x)).Concat(contentBlockUploadResults); + + foreach (var (blockNumber, blockUploadResult) in blockUploadResults) { + var (plaintextSize, sha256Digest) = blockUploadResult; + manifestStream.Write(sha256Digest); - if (isFileContent) + if (blockNumber == 0) { - contentBlockSizes.Add(plaintextSize); - uploadedContentSize += plaintextSize; + // Not a content block + continue; } + + contentBlockSizes.Add(plaintextSize); + uploadedContentSize += plaintextSize; } if (uploadedContentSize != expectedContentLength) @@ -440,7 +362,7 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( throw new IntegrityException("Mismatch between uploaded size and expected size"); } - if (expectedThumbnailBlockCount != blockUploadResults.Count - contentBlockSizes.Count) + if (expectedThumbnailBlockCount != _draft.ThumbnailUploadResults.Count) { throw new IntegrityException("Unexpected number of thumbnail blocks"); } @@ -459,13 +381,36 @@ private RevisionUpdateRequest GetRevisionUpdateRequest( var extendedAttributesUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(extendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); - var encryptedExtendedAttributes = _fileKey.EncryptAndSign(extendedAttributesUtf8Bytes, _signingKey, outputCompression: PgpCompression.Default); + var encryptedExtendedAttributes = _draft.FileKey.EncryptAndSign( + extendedAttributesUtf8Bytes, + _draft.SigningKey, + outputCompression: PgpCompression.Default); return new RevisionUpdateRequest { - ManifestSignature = _signingKey.Sign(manifest), + ManifestSignature = _draft.SigningKey.Sign(manifest), SignatureEmailAddress = signingEmailAddress, ExtendedAttributes = encryptedExtendedAttributes, }; } + + private async ValueTask RevisionIsSealedAsync(CancellationToken cancellationToken) + { + var revisionResponse = await _client.Api.Files.GetRevisionAsync( + _draft.Uid.NodeUid.VolumeId, + _draft.Uid.NodeUid.LinkId, + _draft.Uid.RevisionId, + fromBlockIndex: null, + pageSize: null, + false, + cancellationToken).ConfigureAwait(false); + + return revisionResponse.Revision.State is RevisionState.Active or RevisionState.Superseded; + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Sealing revision \"{RevisionUid}\"")] + private partial void LogSealingRevision(RevisionUid revisionUid); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Revision \"{RevisionUid}\" sealed")] + private partial void LogRevisionSealed(RevisionUid revisionUid); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UnreadableContentException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UnreadableContentException.cs new file mode 100644 index 00000000..5d698f1b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UnreadableContentException.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +public class UnreadableContentException : ProtonDriveException +{ + public UnreadableContentException() + { + } + + public UnreadableContentException(string message) + : base(message) + { + } + + public UnreadableContentException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 9a03e1eb..43928176 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -1,101 +1,134 @@ -using Microsoft.Extensions.Logging; -using Proton.Drive.Sdk.Api; +using Proton.Drive.Sdk.Nodes.Upload.Verification; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Upload; -public sealed partial class UploadController : IAsyncDisposable +public sealed class UploadController : IAsyncDisposable { - private readonly IDriveApiClients _apiClients; - private readonly IFileDraftProvider _fileDraftProvider; - private readonly Task _revisionUidTask; - private readonly Task _uploadTask; - private readonly ITaskControl _taskControl; - private readonly ILogger _logger; + private readonly Task _revisionDraftTask; + private readonly Func> _resumeFunction; + private readonly TaskControl _taskControl; + private readonly Stream? _sourceStreamToDispose; + + private bool _isDisposed; internal UploadController( - IDriveApiClients apiClients, - IFileDraftProvider fileDraftProvider, - Task revisionUidTask, + Task revisionDraftTask, Task uploadTask, - ITaskControl taskControl, - ILogger logger) + Func> resumeFunction, + Stream? sourceStreamToDispose, + TaskControl taskControl) { - _apiClients = apiClients; - _fileDraftProvider = fileDraftProvider; - _revisionUidTask = revisionUidTask; - _uploadTask = uploadTask; + _revisionDraftTask = revisionDraftTask; + _resumeFunction = resumeFunction; _taskControl = taskControl; - _logger = logger; + _sourceStreamToDispose = sourceStreamToDispose; - Completion = Task.WhenAny(_taskControl.PauseExceptionSignal, _uploadTask).Unwrap(); + Completion = PauseOnResumableErrorAsync(uploadTask); } + internal event Action? UploadFailed; + internal event Action? UploadSucceeded; + public bool IsPaused => _taskControl.IsPaused; - // FIXME: Add unit test to ensure that the revision UID is of the new active revision public Task Completion { get; private set; } public void Pause() { _taskControl.Pause(); - - Completion = Task.WhenAny(_taskControl.PauseExceptionSignal, _uploadTask).Unwrap(); } public void Resume() { - _taskControl.Resume(); - - if (Completion.IsFaulted) + if (!_taskControl.TryResume()) { - Completion = Task.WhenAny(_taskControl.PauseExceptionSignal, _uploadTask).Unwrap(); + return; } + + Completion = PauseOnResumableErrorAsync(_resumeFunction.Invoke(_taskControl.PauseOrCancellationToken)); } public async ValueTask DisposeAsync() { - try + if (_isDisposed) { - var draftExists = _revisionUidTask.IsCompletedSuccessfully && !_uploadTask.IsCompletedSuccessfully; - if (!draftExists) - { - return; - } + return; + } - var revisionUid = _revisionUidTask.Result; + _isDisposed = true; + try + { try { - await _fileDraftProvider.DeleteDraftAsync(_apiClients, revisionUid, CancellationToken.None).ConfigureAwait(false); + if (Completion.IsCompletedSuccessfully) + { + return; + } + + if (Completion.IsFaulted) + { + UploadFailed?.Invoke(Completion.Exception.Flatten().InnerException ?? Completion.Exception); + } + + var draftExists = _revisionDraftTask.IsCompletedSuccessfully; + if (!draftExists) + { + return; + } + + await _revisionDraftTask.Result.DisposeAsync().ConfigureAwait(false); } - catch (Exception ex) + finally { - LogDraftDeletionFailure(ex, revisionUid); + _taskControl.Dispose(); } } finally { - var uploadTaskIsCompleted = _uploadTask.IsCompleted; - - _taskControl.Dispose(); - - // If the upload task is not yet completed, disposal of task control unblocks it from being paused. - // The unblocked upload task will complete unsuccessfully (either in faulted or cancelled state). - if (!uploadTaskIsCompleted) + if (_sourceStreamToDispose is not null) { - try - { - await _uploadTask.ConfigureAwait(false); - } - catch - { - // Upon upload controller disposal, the upload task is not expected to be observed, - // so we catch here to prevent escalation of unhandled exception. - } + await _sourceStreamToDispose.DisposeAsync().ConfigureAwait(false); } } } - [LoggerMessage(Level = LogLevel.Error, Message = "Draft deletion failed for revision {RevisionUid}")] - private partial void LogDraftDeletionFailure(Exception exception, RevisionUid revisionUid); + private static bool IsResumableError(Exception ex) + { + return ex is not ProtonApiException { TransportCode: > 400 and < 500 } + and not NodeKeyAndSessionKeyMismatchException + and not SessionKeyAndDataPacketMismatchException + and not UnreadableContentException + and not NodeWithSameNameExistsException; + } + + private async Task PauseOnResumableErrorAsync(Task uploadTask) + { + try + { + var result = await uploadTask.ConfigureAwait(false); + + await RaiseUploadSucceededAsync().ConfigureAwait(false); + + return result; + } + catch (Exception ex) when (IsResumableError(ex)) + { + _taskControl.Pause(); + throw; + } + } + + private async ValueTask RaiseUploadSucceededAsync() + { + var onSucceededHandler = UploadSucceeded; + if (onSucceededHandler is null) + { + return; + } + + var revisionDraft = await _revisionDraftTask.ConfigureAwait(false); + onSucceededHandler.Invoke(revisionDraft.NumberOfPlainBytesDone); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index daf241e2..302d39dd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -130,7 +130,6 @@ private ProtonDriveClient( internal FifoFlexibleSemaphore BlockListingSemaphore { get; } internal int TargetBlockSize { get; set; } = RevisionWriter.DefaultBlockSize; - internal int MaxBlockSize { get; set; } = RevisionWriter.DefaultBlockSize * 3 / 2; internal BlockUploader BlockUploader { get; } internal BlockDownloader BlockDownloader { get; } @@ -145,7 +144,9 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) { - return NodeOperations.EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken).Select(x => (Result?)x).FirstOrDefaultAsync(); + return NodeOperations.EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken) + .Select(x => (Result?)x) + .FirstOrDefaultAsync(cancellationToken); } public ValueTask CreateFolderAsync(NodeUid parentId, string name, DateTime? lastModificationTime, CancellationToken cancellationToken) @@ -176,7 +177,7 @@ public async ValueTask GetFileUploaderAsync( bool overrideExistingDraftByOtherClient, CancellationToken cancellationToken) { - var draftProvider = new NewFileDraftProvider(parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); + var draftProvider = new NewFileDraftProvider(this, parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); } @@ -188,7 +189,7 @@ public async ValueTask GetFileRevisionUploaderAsync( IEnumerable? additionalMetadata, CancellationToken cancellationToken) { - var draftProvider = new NewRevisionDraftProvider(currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); + var draftProvider = new NewRevisionDraftProvider(this, currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); } @@ -244,12 +245,12 @@ public ValueTask EmptyTrashAsync(CancellationToken cancellationToken) } private async ValueTask GetFileUploaderAsync( - IFileDraftProvider fileDraftProvider, + IRevisionDraftProvider revisionDraftProvider, long size, DateTime? lastModificationTime, IEnumerable? additionalMetadata, CancellationToken cancellationToken) { - return await FileUploader.CreateAsync(this, fileDraftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); + return await FileUploader.CreateAsync(this, revisionDraftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs new file mode 100644 index 00000000..32496568 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs @@ -0,0 +1,28 @@ +namespace Proton.Drive.Sdk.Telemetry; + +internal static class Privacy +{ + public static long ReduceSizePrecision(long size) + { + const long precision = 100_000; + + if (size == 0) + { + return 0; + } + + // We care about very small files in metrics, thus we handle explicitly + // the very small files so they appear correctly in metrics. + if (size < 4096) + { + return 4095; + } + + if (size < precision) + { + return precision; + } + + return (size / precision) * precision; + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs index 879a4132..6fa601f3 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs @@ -21,7 +21,9 @@ public static IMessage HandleCreate(CancellationTokenSourceCreateRequest request public static IMessage? HandleFree(CancellationTokenSourceFreeRequest request) { - Interop.FreeHandle(request.CancellationTokenSourceHandle); + var cancellationTokenSource = Interop.FreeHandle(request.CancellationTokenSourceHandle); + + cancellationTokenSource.Dispose(); return null; } diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index 74be3484..f74f859c 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -21,8 +21,9 @@ public unsafe void Log(LogLevel logLevel, EventId eventId, TState state, var message = formatter.Invoke(state, exception); if (exception != null) { - message = message + Environment.NewLine + exception.ToString(); + message = message + Environment.NewLine + exception; } + var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; var messageBytes = logEvent.ToByteArray(); diff --git a/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs index 0229fcef..f049918b 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs @@ -29,7 +29,7 @@ public ValueTask ClearAsync() return ValueTask.FromResult(default(string?)); } - public IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) + public IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken = default) { return AsyncEnumerable.Empty<(string, string)>(); } diff --git a/cs/sdk/src/Proton.Sdk/Either.cs b/cs/sdk/src/Proton.Sdk/Either.cs new file mode 100644 index 00000000..3c8d55a7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Either.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk; + +internal readonly struct Either +{ + private readonly T1? _first; + private readonly T2? _second; + + public Either(T1 first) + { + IsFirst = true; + _first = first; + _second = default; + } + + public Either(T2 second) + { + _first = default; + _second = second; + } + + public bool IsFirst { get; } + public bool IsSecond => !IsFirst; + + public static implicit operator Either(T1 first) => new(first); + public static implicit operator Either(T2 second) => new(second); + + public bool TryGetFirstElseSecond([NotNullWhen(true)] out T1? first, [NotNullWhen(false)] out T2? second) + { + first = _first; + second = _second; + return IsFirst; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs index e08b810f..0229e38e 100644 --- a/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Security; -using Microsoft.Extensions.DependencyInjection; namespace Proton.Sdk.Http; @@ -22,7 +21,7 @@ public static SocketsHttpHandler ConfigureCookies(this SocketsHttpHandler handle /// Configures the to apply server certificate public key pinning for an . /// /// The . - /// An that can be used to configure the client. + /// The handler passed as parameter, for fluent chaining. public static SocketsHttpHandler AddTlsPinning(this SocketsHttpHandler handler) { handler.SslOptions.RemoteCertificateValidationCallback = From 51ece3f48e004907a66e09be04e2ffbd5669028f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 10:44:02 +0000 Subject: [PATCH 433/791] Introduce PhotoDownloadOperation --- .../InteropMessageHandler.cs | 3 + .../InteropPhotosDownloader.cs | 10 ++ cs/sdk/src/protos/proton.drive.sdk.proto | 6 + .../ProtonPhotosClient.swift | 2 +- .../PhotoDownloadOperation.swift | 127 ++++++++++++++++++ .../PhotoDownloadsManager.swift | 6 +- .../Sources/Plumbing/Message+Packaging.swift | 5 + 7 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadOperation.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 9f86f1dd..a4e886df 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -130,6 +130,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DrivePhotosClientDownloaderFree => InteropPhotosDownloader.HandleFree(request.DrivePhotosClientDownloaderFree), + Request.PayloadOneofCase.DrivePhotosClientAwaitDownloadCompletion + => await InteropPhotosDownloader.HandleAwaitCompletion(request.DrivePhotosClientAwaitDownloadCompletion).ConfigureAwait(false), + Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs index 603b9807..bf7c59d0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -1,6 +1,7 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Photos.Sdk.Nodes; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Sdk.CExports; namespace Proton.Drive.Sdk.CExports; @@ -49,4 +50,13 @@ public static IMessage HandleDownloadToFile(DrivePhotosClientDownloadToFileReque return null; } + + public static async ValueTask HandleAwaitCompletion(DrivePhotosClientAwaitDownloadCompletionRequest request) + { + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); + + await downloadController.Completion.ConfigureAwait(false); + + return null; + } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index b3d495c1..39fa386c 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -50,6 +50,7 @@ message Request { DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1307; DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1308; DrivePhotosClientEnumeratePhotosTimelineRequest drive_photos_client_enumerate_photos_timeline = 1309; + DrivePhotosClientAwaitDownloadCompletionRequest drive_photos_client_await_download_completion = 1310; }; } @@ -495,6 +496,11 @@ message DrivePhotosClientDownloaderFreeRequest { int64 file_downloader_handle = 1; } +// The response must not have a value. +message DrivePhotosClientAwaitDownloadCompletionRequest { + int64 download_controller_handle = 1; +} + enum VolumeType { VOLUME_TYPE_OWN_VOLUME = 0; VOLUME_TYPE_SHARED = 1; diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 0c4a860b..d56dfae5 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -174,7 +174,7 @@ extension ProtonPhotosClient { destinationUrl: URL, cancellationToken: UUID, progressCallback: @escaping ProgressCallback - ) async throws -> DownloadOperation { + ) async throws -> PhotoDownloadOperation { try await downloadsManager.downloadPhotoOperation( photoUid: photoUid, destinationUrl: destinationUrl, diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadOperation.swift new file mode 100644 index 00000000..d0c87923 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadOperation.swift @@ -0,0 +1,127 @@ +import Foundation + +public final class PhotoDownloadOperation: Sendable { + + private let photoDownloaderHandle: ObjectHandle + private let downloadControllerHandle: ObjectHandle + private let logger: Logger? + private let progressCallbackWrapper: ProgressCallbackWrapper + private let onOperationCancel: @Sendable () async throws -> Void + private let onOperationDispose: @Sendable () async -> Void + + private var downloadControllerHandleForProtos: Int64 { Int64(downloadControllerHandle) } + + init(photoDownloaderHandle: ObjectHandle, + downloadControllerHandle: ObjectHandle, + progressCallbackWrapper: ProgressCallbackWrapper, + logger: Logger?, + onOperationCancel: @Sendable @escaping () async throws -> Void, + onOperationDispose: @Sendable @escaping () async -> Void) { + assert(photoDownloaderHandle != 0) + assert(downloadControllerHandle != 0) + self.photoDownloaderHandle = photoDownloaderHandle + self.downloadControllerHandle = downloadControllerHandle + self.progressCallbackWrapper = progressCallbackWrapper + self.logger = logger + self.onOperationCancel = onOperationCancel + self.onOperationDispose = onOperationDispose + } + + // Wait for download completion and uses operational resilience to retry if needed. + /// Returns `nil` in case of successful completed download. + /// Throws error in case the download has not completed. + public func awaitDownloadWithResilience( + operationalResilience: OperationalResilience, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> VerificationIssue? { + try await awaitDownloadWithResilience( + retryCounter: 0, operationalResilience: operationalResilience, onPauseErrorReceived: onRetriableErrorReceived + ) + } + + private func awaitDownloadWithResilience( + retryCounter: UInt, + operationalResilience: OperationalResilience, + onPauseErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> VerificationIssue? { + let result = await awaitDownloadCompletion() + switch result { + case .succeeded: + return nil + + case .completedWithVerificationError(let error): + return error + + case .failed(let error): + throw error + + case .pausedOnError(let error): + throw error + } + } + + /// Wait for download completion, no retries + public func awaitDownloadCompletion() async -> DownloadOperationResult { + do { + let awaitDownloadCompletionRequest = Proton_Drive_Sdk_DrivePhotosClientAwaitDownloadCompletionRequest.with { + $0.downloadControllerHandle = downloadControllerHandleForProtos + } + + try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void + return .succeeded + } catch { + return .failed(error) + } + } + + // a convenience API allowing for cancelling the operation through DownloadOperation instance + public func cancel() async throws { + try await onOperationCancel() + } + + deinit { + Self.freeSDKObjects(downloadControllerHandle, photoDownloaderHandle, logger, onOperationDispose) + } + + private static func freeSDKObjects( + _ downloadControllerHandle: ObjectHandle, + _ fileDownloaderHandle: ObjectHandle, + _ logger: Logger?, + _ onOperationDispose: @Sendable @escaping () async -> Void + ) { + Task { + await onOperationDispose() + await freeDownloadController(Int64(downloadControllerHandle), logger) + await freeFileDownloader(Int64(fileDownloaderHandle), logger) + } + } + + /// Free a file downloader when no longer needed + private static func freeFileDownloader(_ fileDownloaderHandle: Int64, _ logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_DrivePhotosClientDownloaderFreeRequest.with { + $0.fileDownloaderHandle = fileDownloaderHandle + } + + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the downloader failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_DrivePhotosClientDownloaderFreeRequest failed: \(error)", category: "DownloadManager.freeDownloader") + } + } + + /// Free a file download controller when no longer needed + private static func freeDownloadController(_ downloadControllerHandle: Int64, _ logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_DownloadControllerFreeRequest.with { + $0.downloadControllerHandle = downloadControllerHandle + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the download controller failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_DownloadControllerFreeRequest failed: \(error)", category: "DownloadController.freeDownloadController") + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift index 5c41a472..8da02821 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift @@ -23,7 +23,7 @@ actor PhotoDownloadsManager { destinationUrl: URL, cancellationToken: UUID, progressCallback: @escaping ProgressCallback - ) async throws -> DownloadOperation { + ) async throws -> PhotoDownloadOperation { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) activeDownloads[cancellationToken] = cancellationTokenSource @@ -48,8 +48,8 @@ actor PhotoDownloadsManager { logger: logger ) - return DownloadOperation( - fileDownloaderHandle: downloaderHandle, + return PhotoDownloadOperation( + photoDownloaderHandle: downloaderHandle, downloadControllerHandle: downloadControllerHandle, progressCallbackWrapper: callbackState, logger: logger, diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index d9010eec..bc3b1539 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -219,6 +219,11 @@ extension Message { $0.payload = .drivePhotosClientDownloaderFree(request) } + case let request as Proton_Drive_Sdk_DrivePhotosClientAwaitDownloadCompletionRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientAwaitDownloadCompletion(request) + } + default: assertionFailure("Unknown request") throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown request type: \(self)")) From 05c24b169db4f03914474af17ae4a2e2230e54f7 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 9 Jan 2026 16:58:09 +0100 Subject: [PATCH 434/791] Update coroutine scope when resume --- .../me/proton/drive/sdk/DownloadController.kt | 7 ++++++- .../kotlin/me/proton/drive/sdk/Downloader.kt | 6 ++++-- .../me/proton/drive/sdk/UploadController.kt | 7 ++++++- .../main/kotlin/me/proton/drive/sdk/Uploader.kt | 6 ++++-- .../proton/drive/sdk/internal/CoroutineScope.kt | 6 ++++++ .../proton/drive/sdk/internal/JniDownloader.kt | 5 ++--- .../proton/drive/sdk/internal/JniDriveClient.kt | 2 +- .../proton/drive/sdk/internal/JniHttpStream.kt | 2 +- .../me/proton/drive/sdk/internal/JniUploader.kt | 5 ++--- .../sdk/internal/ProtonDriveSdkNativeClient.kt | 17 +++++++++-------- .../drive/sdk/internal/ProtonSdkNativeClient.kt | 4 ++-- 11 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CoroutineScope.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index 51b09a11..3940af42 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -1,8 +1,10 @@ package me.proton.drive.sdk +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.internal.CoroutineScopeConsumer import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.toLogId @@ -10,6 +12,7 @@ class DownloadController internal constructor( downloader: Downloader, internal val handle: Long, private val bridge: JniDownloadController, + private val coroutineScopeConsumer: CoroutineScopeConsumer, override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(downloader), AutoCloseable, Cancellable { @@ -28,14 +31,16 @@ class DownloadController internal constructor( }.getOrThrow() } - suspend fun resume() { + suspend fun resume(coroutineScope: CoroutineScope) { log(INFO, "resume") + coroutineScopeConsumer(coroutineScope) bridge.resume(handle).also { isPaused() } } suspend fun pause() { log(INFO, "pause") bridge.pause(handle).also { isPaused() } + coroutineScopeConsumer(null) } suspend fun isPaused() = bridge.isPaused(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt index ba4ae7f0..73792945 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -8,8 +8,8 @@ import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniDownloader import me.proton.drive.sdk.internal.toLogId import java.io.OutputStream -import java.nio.ByteBuffer import java.nio.channels.Channels +import java.util.concurrent.atomic.AtomicReference class Downloader internal constructor( client: DriveClient, @@ -24,6 +24,7 @@ class Downloader internal constructor( progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> log(INFO, "downloadToStream") + val coroutineScopeReference = AtomicReference(coroutineScope) val channel = Channels.newChannel(outputStream) val handle = bridge.downloadToStream( handle = handle, @@ -35,13 +36,14 @@ class Downloader internal constructor( progress(bytesCompleted, bytesInTotal) } }, - coroutineScope = coroutineScope, + coroutineScopeProvider = coroutineScopeReference::get, ) DownloadController( downloader = this@Downloader, handle = handle, bridge = JniDownloadController(), cancellationTokenSource = cancellationTokenSource, + coroutineScopeConsumer = coroutineScopeReference::set, ) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index ce0f1583..ceee76c1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -1,9 +1,11 @@ package me.proton.drive.sdk +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.UploadResult +import me.proton.drive.sdk.internal.CoroutineScopeConsumer import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId import java.io.InputStream @@ -13,6 +15,7 @@ class UploadController internal constructor( internal val handle: Long, private val bridge: JniUploadController, private val inputStream: InputStream, + private val coroutineScopeConsumer: CoroutineScopeConsumer, override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(uploader), AutoCloseable, Cancellable { @@ -31,14 +34,16 @@ class UploadController internal constructor( }.getOrThrow() } - suspend fun resume() { + suspend fun resume(coroutineScope: CoroutineScope) { log(INFO, "resume") + coroutineScopeConsumer(coroutineScope) bridge.resume(handle).also { isPaused() } } suspend fun pause() { log(INFO, "pause") bridge.pause(handle).also { isPaused() } + coroutineScopeConsumer(null) } suspend fun isPaused() = bridge.isPaused(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 4732f2fd..ddaabdf9 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -11,8 +11,8 @@ import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.JniUploader import me.proton.drive.sdk.internal.toLogId import java.io.InputStream -import java.nio.ByteBuffer import java.nio.channels.Channels +import java.util.concurrent.atomic.AtomicReference class Uploader internal constructor( client: DriveClient, @@ -29,6 +29,7 @@ class Uploader internal constructor( ): UploadController = cancellationTokenSource().let { source -> log(INFO, "uploadFromStream") val channel = Channels.newChannel(inputStream) + val coroutineScopeReference = AtomicReference(coroutineScope) val handle = bridge.uploadFromStream( uploaderHandle = handle, cancellationTokenSourceHandle = source.handle, @@ -40,7 +41,7 @@ class Uploader internal constructor( progress(bytesCompleted, bytesInTotal) } }, - coroutineScope = coroutineScope + coroutineScopeProvider = coroutineScopeReference::get, ) UploadController( uploader = this@Uploader, @@ -48,6 +49,7 @@ class Uploader internal constructor( bridge = JniUploadController(), cancellationTokenSource = source, inputStream = inputStream, + coroutineScopeConsumer = coroutineScopeReference::set, ) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CoroutineScope.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CoroutineScope.kt new file mode 100644 index 00000000..b0148950 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CoroutineScope.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.CoroutineScope + +internal typealias CoroutineScopeProvider = () -> CoroutineScope? +internal typealias CoroutineScopeConsumer = (CoroutineScope?) -> Unit diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt index e8efd75f..a9cfd094 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt @@ -1,6 +1,5 @@ package me.proton.drive.sdk.internal -import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk @@ -29,7 +28,7 @@ class JniDownloader internal constructor() : JniBaseProtonDriveSdk() { cancellationTokenSourceHandle: Long, onWrite: suspend (ByteBuffer) -> Unit, onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, - coroutineScope: CoroutineScope + coroutineScopeProvider: CoroutineScopeProvider, ): Long = executePersistent( clientBuilder = { continuation -> ProtonDriveSdkNativeClient( @@ -38,7 +37,7 @@ class JniDownloader internal constructor() : JniBaseProtonDriveSdk() { write = onWrite, progress = onProgress, logger = internalLogger, - coroutineScope = coroutineScope, + coroutineScopeProvider = coroutineScopeProvider, ) }, requestBuilder = { client -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index dc2f2b6c..53eae072 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -45,7 +45,7 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { logger = internalLogger, recordMetric = onRecordMetric, featureEnabled = onFeatureEnabled, - coroutineScope = coroutineScope, + coroutineScopeProvider = { coroutineScope }, ) }, requestBuilder = { client -> request { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index 8cab67b2..79c5226e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -30,7 +30,7 @@ class JniHttpStream internal constructor( } } }, - coroutineScope = coroutineScope, + coroutineScopeProvider = { coroutineScope }, logger = internalLogger ).also { client = it diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt index d45e38be..3212bd75 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt @@ -1,6 +1,5 @@ package me.proton.drive.sdk.internal -import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType @@ -42,7 +41,7 @@ class JniUploader internal constructor() : JniBaseProtonDriveSdk() { thumbnails: Map, onRead: (ByteBuffer) -> Int, onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, - coroutineScope: CoroutineScope + coroutineScopeProvider: CoroutineScopeProvider, ): Long = executePersistent( clientBuilder = { continuation -> ProtonDriveSdkNativeClient( @@ -51,7 +50,7 @@ class JniUploader internal constructor() : JniBaseProtonDriveSdk() { read = onRead, progress = onProgress, logger = internalLogger, - coroutineScope = coroutineScope, + coroutineScopeProvider = coroutineScopeProvider, ) }, requestBuilder = { nativeClient -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 15d8d7e3..710f4780 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -36,7 +36,7 @@ class ProtonDriveSdkNativeClient internal constructor( val recordMetric: suspend (ProtonSdk.MetricEvent) -> Unit = { error("recordMetric not configured for $name") }, val featureEnabled: suspend (String) -> Boolean = { error("featureEnabled not configured for $name") }, val logger: (Level, String) -> Unit = { _, _ -> }, - private val coroutineScope: CoroutineScope? = null, + private val coroutineScopeProvider: CoroutineScopeProvider = { null }, ) { private val byteArrayPointers = ByteArrayPointers() @@ -48,7 +48,7 @@ class ProtonDriveSdkNativeClient internal constructor( fun handleRequest( request: ProtonDriveSdk.Request, ) { - logger(DEBUG, "handle request ${request.payloadCase.name} for $name") + logger(VERBOSE, "handle request ${request.payloadCase.name} for $name") handleRequest(request.toByteArray()) } @@ -78,7 +78,7 @@ class ProtonDriveSdkNativeClient internal constructor( @Suppress("unused") // Called by JNI fun onResponse(data: ByteBuffer) { - logger(DEBUG, "response for $name of size: ${data.capacity()}") + logger(VERBOSE, "response for $name of size: ${data.capacity()}") response(data) } @@ -114,7 +114,7 @@ class ProtonDriveSdkNativeClient internal constructor( data: ByteBuffer, sdkHandle: Long, ): Long = onRequest( - operation = "http", + operation = "http-request", data = data, sdkHandle = sdkHandle, parser = ProtonSdk.HttpRequest::parseFrom, @@ -133,7 +133,7 @@ class ProtonDriveSdkNativeClient internal constructor( @Suppress("unused") // Called by JNI fun onHttpResponseRead(buffer: ByteBuffer, sdkHandle: Long) { - onOperation("read", sdkHandle) { + onOperation("http-response", sdkHandle) { logger(VERBOSE, "http response read for $name of size: ${buffer.capacity()}") val bytesRead = readHttpBody(buffer).takeUnless { it < 0 } ?: 0 logger(VERBOSE, "$bytesRead bytes read for http response $name") @@ -272,13 +272,14 @@ class ProtonDriveSdkNativeClient internal constructor( } private fun coroutineScope(operation: String): CoroutineScope { - checkNotNull(coroutineScope) { + val scope = coroutineScopeProvider() + checkNotNull(scope) { "No coroutineScope was provided to ${javaClass.simpleName}, cannot execute $operation" } - if (!coroutineScope.isActive) { + if (!scope.isActive) { logger(DEBUG, "CoroutineScope not active for $operation") } - return coroutineScope + return scope } companion object { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt index af6c9c55..7673ca19 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -21,7 +21,7 @@ class ProtonSdkNativeClient internal constructor( fun handleRequest( request: Request, ) { - logger(DEBUG, "handle request ${request.payloadCase.name} for $name") + logger(VERBOSE, "handle request ${request.payloadCase.name} for $name") handleRequest(request.toByteArray()) } @@ -30,7 +30,7 @@ class ProtonSdkNativeClient internal constructor( ) fun onResponse(data: ByteBuffer) { - logger(DEBUG, "response for $name of size: ${data.capacity()}") + logger(VERBOSE, "response for $name of size: ${data.capacity()}") response(data) } From c0e7332016ca3036fb23acf62d7e1807dfa07c60 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 13:52:25 +0000 Subject: [PATCH 435/791] Expose folder creation in interop and Kotlin bindings --- .../InteropProtonDriveClient.cs | 2 +- .../kotlin/me/proton/drive/sdk/DriveClient.kt | 24 +++++++++++++ .../sdk/converter/FolderNodeConverter.kt | 11 ++++++ .../me/proton/drive/sdk/entity/Author.kt | 23 +++++++++++++ .../proton/drive/sdk/entity/AuthorResult.kt | 24 +++++++++++++ .../me/proton/drive/sdk/entity/FolderNode.kt | 13 +++++++ .../drive/sdk/extension/AnyConverter.kt | 29 ++++++++++++++++ .../sdk/extension/CancellableContinuation.kt | 16 --------- .../proton/drive/sdk/extension/FolderNode.kt | 34 +++++++++++++++++++ .../proton/drive/sdk/extension/NameAuthor.kt | 28 +++++++++++++++ .../drive/sdk/internal/JniDriveClient.kt | 12 +++++-- .../drive/sdk/internal/JniUploadController.kt | 5 +-- 12 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderNodeConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index fc1fa3d0..fa2fbb2e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -78,7 +78,7 @@ public static async ValueTask HandleCreateFolderAsync(DriveClientCreat var createdFolder = await client.CreateFolderAsync( NodeUid.Parse(request.ParentFolderUid), request.FolderName, - request.LastModificationTime.Seconds != 0 ? request.LastModificationTime.ToDateTime() : null, + request.LastModificationTime?.ToDateTime(), cancellationToken).ConfigureAwait(false); return new FolderNode diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt index fbb8fa22..64ee26f2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt @@ -1,12 +1,17 @@ package me.proton.drive.sdk +import com.google.protobuf.timestamp +import com.google.protobuf.value import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.entity.FolderNode import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniDriveClient import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.toLogId +import proton.drive.sdk.driveClientCreateFolderRequest import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetThumbnailsRequest import proton.drive.sdk.driveClientRenameRequest @@ -72,6 +77,25 @@ class DriveClient internal constructor( ) } + suspend fun createFolder( + parentFolderUid: String, + name: String, + lastModification: Long? = null, + ): FolderNode = cancellationCoroutineScope { source -> + log(DEBUG, "createFolder") + bridge.createFolder( + driveClientCreateFolderRequest { + this.parentFolderUid = parentFolderUid + folderName = name + lastModification?.let { + lastModificationTime = timestamp { seconds = lastModification} + } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderNodeConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderNodeConverter.kt new file mode 100644 index 00000000..414b8d2f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderNodeConverter.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class FolderNodeConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.FolderNode" + + override fun convert(any: Any): ProtonDriveSdk.FolderNode = + ProtonDriveSdk.FolderNode.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt new file mode 100644 index 00000000..4247a0b3 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.entity + +data class Author( + val emailAddress: String, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt new file mode 100644 index 00000000..f043c323 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.entity + +data class AuthorResult( + val author: Author, + val signatureVerificationError: String, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt new file mode 100644 index 00000000..5c2e9840 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt @@ -0,0 +1,13 @@ +package me.proton.drive.sdk.entity + +data class FolderNode( + var uid: String, + var parentUid: String?, + var treeEventScopeId: String, + var name: String, + var creationTime: Long, + var trashTime: Long?, + var nameAuthor: AuthorResult, + var author: AuthorResult, +) + diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt new file mode 100644 index 00000000..3c6633d1 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.converter.AnyConverter +import me.proton.drive.sdk.internal.ContinuationValueOrErrorResponse +import me.proton.drive.sdk.internal.ResponseCallback + +val AnyConverter.asCallback + get(): (CancellableContinuation) -> ResponseCallback = { continuation -> + ContinuationValueOrErrorResponse(continuation, this) + } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt index d2e6a272..109f0d07 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt @@ -2,16 +2,12 @@ package me.proton.drive.sdk.extension import kotlinx.coroutines.CancellableContinuation import me.proton.drive.sdk.converter.BooleanConverter -import me.proton.drive.sdk.converter.FileThumbnailListConverter import me.proton.drive.sdk.converter.IntConverter import me.proton.drive.sdk.converter.LongConverter import me.proton.drive.sdk.converter.StringConverter -import me.proton.drive.sdk.converter.UploadResultConverter import me.proton.drive.sdk.internal.ContinuationUnitOrErrorResponse import me.proton.drive.sdk.internal.ContinuationValueOrErrorResponse import me.proton.drive.sdk.internal.ResponseCallback -import proton.drive.sdk.ProtonDriveSdk -import proton.drive.sdk.ProtonDriveSdk.UploadResult fun CancellableContinuation.toUnitResponse(): ResponseCallback = ContinuationUnitOrErrorResponse(this) @@ -42,15 +38,3 @@ fun CancellableContinuation.toStringResponse(): ResponseCallback = val StringResponseCallback: (CancellableContinuation) -> ResponseCallback = CancellableContinuation::toStringResponse - -fun CancellableContinuation.toUploadResultResponse(): ResponseCallback = - ContinuationValueOrErrorResponse(this, UploadResultConverter()) - -val UploadResultResponseCallback: (CancellableContinuation) -> ResponseCallback = - CancellableContinuation::toUploadResultResponse - -fun CancellableContinuation.toFileThumbnailListResponse(): ResponseCallback = - ContinuationValueOrErrorResponse(this, FileThumbnailListConverter()) - -val FileThumbnailListResponseCallback: (CancellableContinuation) -> ResponseCallback = - CancellableContinuation::toFileThumbnailListResponse diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt new file mode 100644 index 00000000..5d90ac1b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.FolderNode +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.trashTimeOrNull + +fun ProtonDriveSdk.FolderNode.toEntity() = FolderNode( + uid = uid, + parentUid = parentUid, + treeEventScopeId = treeEventScopeId, + name = name, + creationTime = creationTime.seconds, + trashTime = trashTimeOrNull?.seconds, + nameAuthor = nameAuthor.toEntity(), + author = author.toEntity() +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt new file mode 100644 index 00000000..b80a74b9 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.Author +import me.proton.drive.sdk.entity.AuthorResult +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.AuthorResult.toEntity() = AuthorResult( + author = Author(emailAddress = author.emailAddress), + signatureVerificationError = signatureVerificationError, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index 53eae072..10470b84 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -2,11 +2,13 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.converter.FileThumbnailListConverter +import me.proton.drive.sdk.converter.FolderNodeConverter import me.proton.drive.sdk.entity.ClientCreateRequest -import me.proton.drive.sdk.extension.FileThumbnailListResponseCallback import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.StringResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback +import me.proton.drive.sdk.extension.asCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.driveClientCreateFromSessionRequest @@ -85,10 +87,16 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun getThumbnails( request: ProtonDriveSdk.DriveClientGetThumbnailsRequest, ): ProtonDriveSdk.FileThumbnailList = - executeOnce("getThumbnails", FileThumbnailListResponseCallback) { + executeOnce("getThumbnails", FileThumbnailListConverter().asCallback) { driveClientGetThumbnails = request } + suspend fun createFolder( + request: ProtonDriveSdk.DriveClientCreateFolderRequest, + ): ProtonDriveSdk.FolderNode = executeOnce("createFolder", FolderNodeConverter().asCallback) { + driveClientCreateFolder = request + } + fun free(handle: Long) { dispatch("free") { driveClientFree = driveClientFreeRequest { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt index 7fd55756..08abf1f9 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt @@ -1,9 +1,10 @@ package me.proton.drive.sdk.internal +import me.proton.drive.sdk.converter.UploadResultConverter import me.proton.drive.sdk.entity.UploadResult import me.proton.drive.sdk.extension.BooleanResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback -import me.proton.drive.sdk.extension.UploadResultResponseCallback +import me.proton.drive.sdk.extension.asCallback import me.proton.drive.sdk.extension.toEntity import proton.drive.sdk.uploadControllerAwaitCompletionRequest import proton.drive.sdk.uploadControllerDisposeRequest @@ -15,7 +16,7 @@ import proton.drive.sdk.uploadControllerResumeRequest class JniUploadController internal constructor() : JniBaseProtonDriveSdk() { suspend fun awaitCompletion(handle: Long): UploadResult = - executeOnce("awaitCompletion", UploadResultResponseCallback) { + executeOnce("awaitCompletion", UploadResultConverter().asCallback) { uploadControllerAwaitCompletion = uploadControllerAwaitCompletionRequest { uploadControllerHandle = handle } From 4928287fdea18168e42251d960e0bd6962a58899 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 14:53:02 +0100 Subject: [PATCH 436/791] Log paused status for each call --- .../src/main/kotlin/me/proton/drive/sdk/UploadController.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index ceee76c1..721ca066 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -46,8 +46,10 @@ class UploadController internal constructor( coroutineScopeConsumer(null) } - suspend fun isPaused() = bridge.isPaused(handle) - .also { isPausedFlow.emit(it) } + suspend fun isPaused() = bridge.isPaused(handle).also { paused -> + log(DEBUG, "isPaused: $paused") + isPausedFlow.emit(paused) + } suspend fun dispose() = bridge.dispose(handle) From ec81cb7d212ee3c87ab91c19c3b29822efae02b9 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 10:03:01 +0100 Subject: [PATCH 437/791] Implement 429 handling for block downloads --- .../Nodes/Download/BlockDownloader.cs | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index a66f73f7..d1b9616e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -4,6 +4,7 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Resilience; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Download; @@ -32,18 +33,24 @@ public async ValueTask> DownloadAsync( CancellationToken cancellationToken) { return await Policy - // TODO: add unit tests to verify the retry conditions - .Handle(ex => !cancellationToken.IsCancellationRequested && ex is not FileContentsDecryptionException) + .Handle(ex => !cancellationToken.IsCancellationRequested && IsExceptionRetriable(ex)) .WaitAndRetryAsync( retryCount: 4, sleepDurationProvider: RetryPolicy.GetAttemptDelay, - onRetry: (exception, _, retryNumber, _) => + onRetryAsync: async (exception, _, retryNumber, _) => { + await WaitOnRetryAfterIfNeededAsync(exception, cancellationToken).ConfigureAwait(false); + LogBlobDownloadRetry(index, revisionUid, retryNumber, exception.FlattenMessage()); outputStream.Seek(0, SeekOrigin.Begin); }) .ExecuteAsync(ExecuteDownloadAsync).ConfigureAwait(false); + static bool IsExceptionRetriable(Exception ex) + { + return ex is not FileContentsDecryptionException; + } + async Task ExecuteDownloadAsync() { try @@ -73,8 +80,30 @@ async Task ExecuteDownloadAsync() } } + private async Task WaitOnRetryAfterIfNeededAsync(Exception ex, CancellationToken cancellationToken) + { + if (ex is TooManyRequestsException exception) + { + var currentTime = DateTimeOffset.UtcNow; + + if (exception.RetryAfter is { } retryAfter && retryAfter > currentTime) + { + var delayDuration = retryAfter - currentTime; + + LogBlobDownloadWaitingForRetryAfter(delayDuration); + await Task.Delay(delayDuration, cancellationToken).ConfigureAwait(false); + } + } + } + [LoggerMessage( Level = LogLevel.Information, Message = "Retrying blob download for block #{BlockIndex} of revision \"{RevisionUid}\" (retry number: {RetryNumber}). Previous attempt error: {ErrorMessage}")] private partial void LogBlobDownloadRetry(int blockIndex, RevisionUid revisionUid, int retryNumber, string errorMessage); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Waiting {DelayDuration} before retrying blob download due to 429 response")] + private partial void LogBlobDownloadWaitingForRetryAfter(TimeSpan delayDuration); + } From d90600eb00cc5824a13ebe2c3749c92500520221 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 10:31:23 +0100 Subject: [PATCH 438/791] Fix failure to resume upload that has gaps in block upload completions --- .../Nodes/Upload/RevisionDraft.cs | 34 ++++++++++-------- .../Nodes/Upload/RevisionWriter.cs | 36 ++++++++++--------- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 9 +++-- cs/sdk/src/Proton.Sdk/Either.cs | 12 +++++++ 4 files changed, 54 insertions(+), 37 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs index 47401ce5..9d38710e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -20,7 +20,7 @@ internal sealed partial class RevisionDraft( ILogger logger) : IAsyncDisposable { private readonly Dictionary _thumbnailUploadResults = []; - private readonly List> _contentBlockStates = []; + private readonly List> _contentBlockStates = []; private readonly Lock _blockUploadStatesLock = new(); private readonly ILogger _logger = logger; @@ -35,7 +35,7 @@ internal sealed partial class RevisionDraft( public IncrementalHash Sha1 { get; } = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); public IReadOnlyDictionary ThumbnailUploadResults => _thumbnailUploadResults; - public IReadOnlyList> ContentBlockStates => _contentBlockStates; + public IReadOnlyList> ContentBlockStates => _contentBlockStates; public bool IsCompleted { get; set; } public long NumberOfPlainBytesDone { get; set; } @@ -86,24 +86,28 @@ public bool ThumbnailBlockWasAlreadyUploaded(ThumbnailType thumbnailType) } } - public int GetFirstNonUploadedContentBlockNumber() + public int GetNewContentBlockNumber() { - return _contentBlockStates.TakeWhile(x => x.IsFirst).Count() + 1; + return ContentBlockStates.Count + 1; } - public bool TryGetContentBlockPlainData(int blockNumber, [NotNullWhen(true)] out BlockUploadPlainData? blockPlainData) + public bool TryGetNextContentBlockPlainData( + int? currentBlockNumber, + [NotNullWhen(true)] out (int BlockNumber, BlockUploadPlainData PlainData)? result) { - var blockStateIndex = blockNumber - 1; - - if (blockStateIndex >= _contentBlockStates.Count - || _contentBlockStates[blockStateIndex].TryGetFirstElseSecond(out _, out var result)) + lock (_blockUploadStatesLock) { - blockPlainData = null; - return false; - } + var offset = currentBlockNumber ?? 0; - blockPlainData = result; - return true; + result = _contentBlockStates + .Skip(offset) + .Select((x, i) => x.TryGetFirst(out var plainData) + ? (offset + i + 1, plainData) + : default((int BlockNumber, BlockUploadPlainData PlainData)?)) + .FirstOrDefault(x => x is not null); + + return result is not null; + } } public async ValueTask DisposeAsync() @@ -114,7 +118,7 @@ public async ValueTask DisposeAsync() Sha1.Dispose(); var dataItemsToDispose = ContentBlockStates - .Select(x => !x.TryGetFirstElseSecond(out _, out var data) ? data : (BlockUploadPlainData?)null) + .Select(x => x.TryGetFirst(out var data) ? data : (BlockUploadPlainData?)null) .Where(task => task is not null) .Select(task => task!.Value); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 19059847..96c242c9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -53,7 +53,7 @@ public async ValueTask WriteAsync( var signingEmailAddress = _draft.MembershipAddress.EmailAddress; - var expectedThumbnailBlockCount = 0; + int expectedThumbnailBlockCount; var hashingContentStream = new HashingReadStream(contentStream, _draft.Sha1, leaveOpen: true); @@ -223,14 +223,16 @@ private async ValueTask UploadContentBlocksAsync( Queue> uploadTasks, CancellationToken cancellationToken) { - var contentBlockNumber = _draft.GetFirstNonUploadedContentBlockNumber(); + int? currentBlockNumber = null; while ( - await TryGetBlockPlainDataAsync( - contentBlockNumber, + await TryGetNextContentBlockPlainDataAsync( + currentBlockNumber, hashingContentStream, - _draft.BlockVerifier.DataPacketPrefixMaxLength).ConfigureAwait(false) is { } plainData) + _draft.BlockVerifier.DataPacketPrefixMaxLength).ConfigureAwait(false) is var (newBlockNumber, plainData)) { + currentBlockNumber = newBlockNumber; + await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); var onBlockProgress = onProgress is not null @@ -241,25 +243,25 @@ await TryGetBlockPlainDataAsync( } : default(Action?); - var uploadTask = UploadContentBlockAsync(contentBlockNumber, plainData, onBlockProgress, cancellationToken).AsTask(); + var uploadTask = UploadContentBlockAsync(currentBlockNumber.Value, plainData, onBlockProgress, cancellationToken).AsTask(); uploadTasks.Enqueue(uploadTask); - - ++contentBlockNumber; } } - private async ValueTask TryGetBlockPlainDataAsync( - int blockNumber, + private async ValueTask<(int BlockNumber, BlockUploadPlainData PlainData)?> TryGetNextContentBlockPlainDataAsync( + int? currentBlockNumber, Stream contentStream, int prefixLength) { - if (_draft.TryGetContentBlockPlainData(blockNumber, out var plainData)) + if (_draft.TryGetNextContentBlockPlainData(currentBlockNumber, out var result)) { - plainData.Value.Stream.Seek(0, SeekOrigin.Begin); - return plainData; + result.Value.PlainData.Stream.Seek(0, SeekOrigin.Begin); + return result; } + currentBlockNumber = _draft.GetNewContentBlockNumber(); + var plainDataPrefixBuffer = ArrayPool.Shared.Rent(prefixLength); try { @@ -282,11 +284,11 @@ await TryGetBlockPlainDataAsync( plainDataStream.Seek(0, SeekOrigin.Begin); - plainData = new BlockUploadPlainData(plainDataStream, plainDataPrefixBuffer); + var plainData = new BlockUploadPlainData(plainDataStream, plainDataPrefixBuffer); - _draft.SetContentBlockPlainData(blockNumber, plainData.Value); + _draft.SetContentBlockPlainData(currentBlockNumber.Value, plainData); - return plainData; + return (currentBlockNumber.Value, plainData); } catch (Exception ex) { @@ -334,7 +336,7 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( { var blockNumber = i + 1; - return blockState.TryGetFirstElseSecond(out var uploadResult, out _) + return blockState.TryGetSecond(out var uploadResult) ? (Number: blockNumber, Value: uploadResult) : throw new IntegrityException($"Missing content block #{blockNumber}"); }); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 302d39dd..9f4a7de3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -20,7 +20,6 @@ public sealed class ProtonDriveClient { private const int MinDegreeOfBlockTransferParallelism = 2; private const int MaxDegreeOfBlockTransferParallelism = 6; - private const int DefaultApiTimeoutSeconds = 30; private const int StorageApiTimeoutSeconds = 300; @@ -69,7 +68,8 @@ internal ProtonDriveClient( IBlockVerifierFactory blockVerifierFactory, IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, - string uid) + string uid, + int? blockTransferDegreeOfParallelism = null) { Uid = uid; @@ -80,9 +80,8 @@ internal ProtonDriveClient( Telemetry = telemetry; FeatureFlagProvider = featureFlagProvider; - var maxDegreeOfBlockTransferParallelism = Math.Max( - Math.Min(Environment.ProcessorCount / 2, MaxDegreeOfBlockTransferParallelism), - MinDegreeOfBlockTransferParallelism); + var maxDegreeOfBlockTransferParallelism = blockTransferDegreeOfParallelism + ?? Math.Max(Math.Min(Environment.ProcessorCount / 2, MaxDegreeOfBlockTransferParallelism), MinDegreeOfBlockTransferParallelism); var maxDegreeOfBlockProcessingParallelism = maxDegreeOfBlockTransferParallelism + Math.Min(Math.Max(maxDegreeOfBlockTransferParallelism / 2, 2), 4); diff --git a/cs/sdk/src/Proton.Sdk/Either.cs b/cs/sdk/src/Proton.Sdk/Either.cs index 3c8d55a7..1492a742 100644 --- a/cs/sdk/src/Proton.Sdk/Either.cs +++ b/cs/sdk/src/Proton.Sdk/Either.cs @@ -32,4 +32,16 @@ public bool TryGetFirstElseSecond([NotNullWhen(true)] out T1? first, [NotNullWhe second = _second; return IsFirst; } + + public bool TryGetFirst([NotNullWhen(true)] out T1? first) + { + first = _first; + return IsFirst; + } + + public bool TryGetSecond([NotNullWhen(true)] out T2? second) + { + second = _second; + return IsSecond; + } } From 32ec13368a98edc74f9500d6cdcb56bfad525b27 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 09:44:28 +0000 Subject: [PATCH 439/791] refactor: consolidate PhotoDownloadOperation into DownloadOperation --- .../InteropMessageHandler.cs | 3 - .../InteropPhotosDownloader.cs | 9 -- cs/sdk/src/protos/proton.drive.sdk.proto | 6 - .../ProtonPhotosClient.swift | 2 +- .../FileOperations/DownloadOperation.swift | 39 +++++- .../FileOperations/DownloadsManager.swift | 1 + .../PhotoDownloadOperation.swift | 127 ------------------ .../PhotoDownloadsManager.swift | 7 +- .../Sources/Plumbing/Message+Packaging.swift | 5 - 9 files changed, 41 insertions(+), 158 deletions(-) delete mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadOperation.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index a4e886df..9f86f1dd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -130,9 +130,6 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DrivePhotosClientDownloaderFree => InteropPhotosDownloader.HandleFree(request.DrivePhotosClientDownloaderFree), - Request.PayloadOneofCase.DrivePhotosClientAwaitDownloadCompletion - => await InteropPhotosDownloader.HandleAwaitCompletion(request.DrivePhotosClientAwaitDownloadCompletion).ConfigureAwait(false), - Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs index bf7c59d0..344c3f44 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -50,13 +50,4 @@ public static IMessage HandleDownloadToFile(DrivePhotosClientDownloadToFileReque return null; } - - public static async ValueTask HandleAwaitCompletion(DrivePhotosClientAwaitDownloadCompletionRequest request) - { - var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); - - await downloadController.Completion.ConfigureAwait(false); - - return null; - } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 39fa386c..b3d495c1 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -50,7 +50,6 @@ message Request { DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1307; DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1308; DrivePhotosClientEnumeratePhotosTimelineRequest drive_photos_client_enumerate_photos_timeline = 1309; - DrivePhotosClientAwaitDownloadCompletionRequest drive_photos_client_await_download_completion = 1310; }; } @@ -496,11 +495,6 @@ message DrivePhotosClientDownloaderFreeRequest { int64 file_downloader_handle = 1; } -// The response must not have a value. -message DrivePhotosClientAwaitDownloadCompletionRequest { - int64 download_controller_handle = 1; -} - enum VolumeType { VOLUME_TYPE_OWN_VOLUME = 0; VOLUME_TYPE_SHARED = 1; diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index d56dfae5..0c4a860b 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -174,7 +174,7 @@ extension ProtonPhotosClient { destinationUrl: URL, cancellationToken: UUID, progressCallback: @escaping ProgressCallback - ) async throws -> PhotoDownloadOperation { + ) async throws -> DownloadOperation { try await downloadsManager.downloadPhotoOperation( photoUid: photoUid, destinationUrl: destinationUrl, diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift index ee046070..96f07a3a 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift @@ -9,21 +9,30 @@ public enum DownloadOperationResult: Sendable { case failed(Error) } +/// Represents the type of downloader used for a download operation. +/// Determines which cleanup function is called when the operation is disposed. +public enum DownloaderType: Sendable { + case file + case photo +} + public final class DownloadOperation: Sendable { private let fileDownloaderHandle: ObjectHandle private let downloadControllerHandle: ObjectHandle private let logger: Logger? + private let downloaderType: DownloaderType private let progressCallbackWrapper: ProgressCallbackWrapper private let onOperationCancel: @Sendable () async throws -> Void private let onOperationDispose: @Sendable () async -> Void private var downloadControllerHandleForProtos: Int64 { Int64(downloadControllerHandle) } - + init(fileDownloaderHandle: ObjectHandle, downloadControllerHandle: ObjectHandle, progressCallbackWrapper: ProgressCallbackWrapper, logger: Logger?, + downloaderType: DownloaderType, onOperationCancel: @Sendable @escaping () async throws -> Void, onOperationDispose: @Sendable @escaping () async -> Void) { assert(fileDownloaderHandle != 0) @@ -32,6 +41,7 @@ public final class DownloadOperation: Sendable { self.downloadControllerHandle = downloadControllerHandle self.progressCallbackWrapper = progressCallbackWrapper self.logger = logger + self.downloaderType = downloaderType self.onOperationCancel = onOperationCancel self.onOperationDispose = onOperationDispose } @@ -154,19 +164,25 @@ public final class DownloadOperation: Sendable { } deinit { - Self.freeSDKObjects(downloadControllerHandle, fileDownloaderHandle, logger, onOperationDispose) + Self.freeSDKObjects(downloadControllerHandle, fileDownloaderHandle, logger, downloaderType, onOperationDispose) } private static func freeSDKObjects( _ downloadControllerHandle: ObjectHandle, _ fileDownloaderHandle: ObjectHandle, _ logger: Logger?, + _ downloaderType: DownloaderType, _ onOperationDispose: @Sendable @escaping () async -> Void ) { Task { await onOperationDispose() await freeDownloadController(Int64(downloadControllerHandle), logger) - await freeFileDownloader(Int64(fileDownloaderHandle), logger) + switch downloaderType { + case .file: + await freeFileDownloader(Int64(fileDownloaderHandle), logger) + case .photo: + await freePhotoDownloader(Int64(fileDownloaderHandle), logger) + } } } @@ -184,7 +200,22 @@ public final class DownloadOperation: Sendable { logger?.error("Proton_Drive_Sdk_FileDownloaderFreeRequest failed: \(error)", category: "DownloadManager.freeDownloader") } } - + + /// Free a photo downloader when no longer needed + private static func freePhotoDownloader(_ photoDownloaderHandle: Int64, _ logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_DrivePhotosClientDownloaderFreeRequest.with { + $0.fileDownloaderHandle = photoDownloaderHandle + } + + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the downloader failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_DrivePhotosClientDownloaderFreeRequest failed: \(error)", category: "DownloadManager.freeDownloader") + } + } + /// Free a file download controller when no longer needed private static func freeDownloadController(_ downloadControllerHandle: Int64, _ logger: Logger?) async { let freeRequest = Proton_Drive_Sdk_DownloadControllerFreeRequest.with { diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift index f7f2717f..388da24b 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift @@ -53,6 +53,7 @@ actor DownloadsManager { downloadControllerHandle: downloadControllerHandle, progressCallbackWrapper: callbackState, logger: logger, + downloaderType: .file, onOperationCancel: { [weak self] in guard let self else { return } try await self.cancelDownload(with: cancellationToken) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadOperation.swift deleted file mode 100644 index d0c87923..00000000 --- a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadOperation.swift +++ /dev/null @@ -1,127 +0,0 @@ -import Foundation - -public final class PhotoDownloadOperation: Sendable { - - private let photoDownloaderHandle: ObjectHandle - private let downloadControllerHandle: ObjectHandle - private let logger: Logger? - private let progressCallbackWrapper: ProgressCallbackWrapper - private let onOperationCancel: @Sendable () async throws -> Void - private let onOperationDispose: @Sendable () async -> Void - - private var downloadControllerHandleForProtos: Int64 { Int64(downloadControllerHandle) } - - init(photoDownloaderHandle: ObjectHandle, - downloadControllerHandle: ObjectHandle, - progressCallbackWrapper: ProgressCallbackWrapper, - logger: Logger?, - onOperationCancel: @Sendable @escaping () async throws -> Void, - onOperationDispose: @Sendable @escaping () async -> Void) { - assert(photoDownloaderHandle != 0) - assert(downloadControllerHandle != 0) - self.photoDownloaderHandle = photoDownloaderHandle - self.downloadControllerHandle = downloadControllerHandle - self.progressCallbackWrapper = progressCallbackWrapper - self.logger = logger - self.onOperationCancel = onOperationCancel - self.onOperationDispose = onOperationDispose - } - - // Wait for download completion and uses operational resilience to retry if needed. - /// Returns `nil` in case of successful completed download. - /// Throws error in case the download has not completed. - public func awaitDownloadWithResilience( - operationalResilience: OperationalResilience, - onRetriableErrorReceived: @Sendable @escaping (Error) -> Void - ) async throws -> VerificationIssue? { - try await awaitDownloadWithResilience( - retryCounter: 0, operationalResilience: operationalResilience, onPauseErrorReceived: onRetriableErrorReceived - ) - } - - private func awaitDownloadWithResilience( - retryCounter: UInt, - operationalResilience: OperationalResilience, - onPauseErrorReceived: @Sendable @escaping (Error) -> Void - ) async throws -> VerificationIssue? { - let result = await awaitDownloadCompletion() - switch result { - case .succeeded: - return nil - - case .completedWithVerificationError(let error): - return error - - case .failed(let error): - throw error - - case .pausedOnError(let error): - throw error - } - } - - /// Wait for download completion, no retries - public func awaitDownloadCompletion() async -> DownloadOperationResult { - do { - let awaitDownloadCompletionRequest = Proton_Drive_Sdk_DrivePhotosClientAwaitDownloadCompletionRequest.with { - $0.downloadControllerHandle = downloadControllerHandleForProtos - } - - try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void - return .succeeded - } catch { - return .failed(error) - } - } - - // a convenience API allowing for cancelling the operation through DownloadOperation instance - public func cancel() async throws { - try await onOperationCancel() - } - - deinit { - Self.freeSDKObjects(downloadControllerHandle, photoDownloaderHandle, logger, onOperationDispose) - } - - private static func freeSDKObjects( - _ downloadControllerHandle: ObjectHandle, - _ fileDownloaderHandle: ObjectHandle, - _ logger: Logger?, - _ onOperationDispose: @Sendable @escaping () async -> Void - ) { - Task { - await onOperationDispose() - await freeDownloadController(Int64(downloadControllerHandle), logger) - await freeFileDownloader(Int64(fileDownloaderHandle), logger) - } - } - - /// Free a file downloader when no longer needed - private static func freeFileDownloader(_ fileDownloaderHandle: Int64, _ logger: Logger?) async { - let freeRequest = Proton_Drive_Sdk_DrivePhotosClientDownloaderFreeRequest.with { - $0.fileDownloaderHandle = fileDownloaderHandle - } - - do { - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void - } catch { - // If the request to free the downloader failed, we have a memory leak, but not much else can be done. - // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_DrivePhotosClientDownloaderFreeRequest failed: \(error)", category: "DownloadManager.freeDownloader") - } - } - - /// Free a file download controller when no longer needed - private static func freeDownloadController(_ downloadControllerHandle: Int64, _ logger: Logger?) async { - let freeRequest = Proton_Drive_Sdk_DownloadControllerFreeRequest.with { - $0.downloadControllerHandle = downloadControllerHandle - } - do { - try await SDKRequestHandler.send(freeRequest, logger: logger) as Void - } catch { - // If the request to free the download controller failed, we have a memory leak, but not much else can be done. - // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_DownloadControllerFreeRequest failed: \(error)", category: "DownloadController.freeDownloadController") - } - } -} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift index 8da02821..becd3751 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift @@ -23,7 +23,7 @@ actor PhotoDownloadsManager { destinationUrl: URL, cancellationToken: UUID, progressCallback: @escaping ProgressCallback - ) async throws -> PhotoDownloadOperation { + ) async throws -> DownloadOperation { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) activeDownloads[cancellationToken] = cancellationTokenSource @@ -48,11 +48,12 @@ actor PhotoDownloadsManager { logger: logger ) - return PhotoDownloadOperation( - photoDownloaderHandle: downloaderHandle, + return DownloadOperation( + fileDownloaderHandle: downloaderHandle, downloadControllerHandle: downloadControllerHandle, progressCallbackWrapper: callbackState, logger: logger, + downloaderType: .photo, onOperationCancel: { [weak self] in guard let self else { return } try await self.cancelDownload(with: cancellationToken) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index bc3b1539..d9010eec 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -219,11 +219,6 @@ extension Message { $0.payload = .drivePhotosClientDownloaderFree(request) } - case let request as Proton_Drive_Sdk_DrivePhotosClientAwaitDownloadCompletionRequest: - Proton_Drive_Sdk_Request.with { - $0.payload = .drivePhotosClientAwaitDownloadCompletion(request) - } - default: assertionFailure("Unknown request") throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown request type: \(self)")) From 362969f160bb70ecf888a4ddf8135fcdd7c922e4 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 09:55:24 +0000 Subject: [PATCH 440/791] Expose functions to trash node through Swift package --- .../InteropMessageHandler.cs | 3 ++ .../InteropProtonDriveClient.cs | 30 +++++++++++++++++++ cs/sdk/src/protos/proton.drive.sdk.proto | 17 +++++++++++ .../ProtonDriveClient/ProtonDriveClient.swift | 29 +++++++++++++++++- .../Sources/Plumbing/Message+Packaging.swift | 5 ++++ .../Sources/Plumbing/PublicTypes.swift | 5 ++++ .../Sources/Plumbing/SDKRequestHandler.swift | 7 +++++ 7 files changed, 95 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 9f86f1dd..a147e9db 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -37,6 +37,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientGetAvailableName => await InteropProtonDriveClient.HandleGetAvailableNameAsync(request.DriveClientGetAvailableName).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientTrashNodes + => await InteropProtonDriveClient.HandleTrashNodesAsync(request.DriveClientTrashNodes).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientRename => await InteropProtonDriveClient.HandleRenameAsync(request.DriveClientRename).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index fa2fbb2e..1b0961f9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -200,6 +200,36 @@ await client.RenameNodeAsync( return null; } + public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNodesRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var results = await client.TrashNodesAsync( + request.NodeUids.Select(NodeUid.Parse), + cancellationToken).ConfigureAwait(false); + + var response = new TrashNodesResponse + { + Results = + { + results.Select(pair => + { + var result = new NodeResultPair(); + result.NodeUid = pair.Key.ToString(); + if (pair.Value.TryGetError(out var error)) + { + result.Error = error; + } + return result; + }) + } + }; + + return response; + } + public static IMessage? HandleFree(DriveClientFreeRequest request) { Interop.FreeHandle(request.ClientHandle); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index b3d495c1..946ff701 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -19,6 +19,7 @@ message Request { DriveClientGetThumbnailsRequest drive_client_get_thumbnails = 1007; DriveClientRenameRequest drive_client_rename = 1008; DriveClientCreateFolderRequest drive_client_create_folder = 1009; + DriveClientTrashNodesRequest drive_client_trash_nodes = 1010; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -322,6 +323,22 @@ message DriveClientCreateFolderRequest { int64 cancellation_token_source_handle = 5; } +// The response message must be of type TrashNodesResponse +message DriveClientTrashNodesRequest { + int64 client_handle = 1; + repeated string node_uids = 2; + int64 cancellation_token_source_handle = 3; +} + +message NodeResultPair { + string node_uid = 1; + string error = 2; +} + +message TrashNodesResponse { + repeated NodeResultPair results = 1; +} + // The response message must be of type FileThumbnailList. message DriveClientGetThumbnailsRequest { int64 client_handle = 1; diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 1bf4ecc5..c9b5b05a 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -24,12 +24,14 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { case createFolder(UUID) case rename(UUID) case getAvailableName(UUID) - + case trash(UUID) + var operationName: String { switch self { case .createFolder: return "createFolder" case .rename: return "rename" case .getAvailableName: return "getAvailableName" + case .trash: return "trash" } } } @@ -399,4 +401,29 @@ extension ProtonDriveClient { public func cancelRename(cancellationToken: UUID) async throws { try await cancelOperation(identifier: .rename(cancellationToken)) } + + public func trash(nodes: [SDKNodeUid], cancellationToken: UUID) async throws -> [TrashNodeResult] { + let cancellationTokenSource = try await createCancellationTokenSource(.trash(cancellationToken), logger) + defer { + freeCancellationTokenSourceIfNeeded(identifier: .trash(cancellationToken)) + } + + let cancellationHandle = cancellationTokenSource.handle + let trashRequest = Proton_Drive_Sdk_DriveClientTrashNodesRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.nodeUids = nodes.map { $0.sdkCompatibleIdentifier } + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + let result: Proton_Drive_Sdk_TrashNodesResponse = try await SDKRequestHandler.send(trashRequest, logger: logger) + let results: [TrashNodeResult] = result.results.compactMap { result in + guard let id = SDKNodeUid(sdkCompatibleIdentifier: result.nodeUid) else { return nil } + let error: String? = result.hasError ? result.error : nil + return TrashNodeResult(nodeUid: id, error: error) + } + return results + } + + public func cancelTrash(cancellationToken: UUID) async throws { + try await cancelOperation(identifier: .trash(cancellationToken)) + } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index d9010eec..145ed6b4 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -84,6 +84,11 @@ extension Message { $0.payload = .driveClientRename(request) } + case let request as Proton_Drive_Sdk_DriveClientTrashNodesRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientTrashNodes(request) + } + case let request as Proton_Drive_Sdk_DriveClientGetThumbnailsRequest: Proton_Drive_Sdk_Request.with { $0.payload = .driveClientGetThumbnails(request) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 7f9da2ff..d812dda7 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -200,6 +200,11 @@ public struct PhotoTimelineItem: Sendable { } } +public struct TrashNodeResult: Sendable { + public let nodeUid: SDKNodeUid + public let error: String? +} + /// Callback for progress updates public typealias ProgressCallback = @Sendable (FileOperationProgress) -> Void diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 92d42aee..e01c5d48 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -202,6 +202,13 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in } resultBox.resume(returning: unpackedValue) + case .value(let value) where value.isA(Proton_Drive_Sdk_TrashNodesResponse.self): + let unpackedValue = try Proton_Drive_Sdk_TrashNodesResponse(unpackingAny: value) + guard let uploadResultBox = box as? any Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) + } + uploadResultBox.resume(returning: unpackedValue) + case .value: // unknown value type throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unknown SDK call response value type")) From d9f4d884369b08a70ac96a3cfc7c6e91a7d324dd Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 11:27:16 +0100 Subject: [PATCH 441/791] Implement support for protecting SDK databases --- cs/Directory.Packages.props | 1 + .../InteropMessageHandler.cs | 6 + .../InteropProtonDriveClient.cs | 27 ++++ .../Caching/DriveEntityCache.cs | 5 + .../Caching/DriveSecretCache.cs | 5 + .../Caching/IDriveEntityCache.cs | 2 + .../Caching/IDriveSecretCache.cs | 2 + .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 14 ++ .../Caching/PhotosSecretCache.cs | 6 + .../Caching/EncryptedCacheRepository.cs | 139 ++++++++++++++++++ .../CryptoSecureNumberGenerator.cs | 16 ++ cs/sdk/src/protos/proton.drive.sdk.proto | 42 ++++-- 12 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs create mode 100644 cs/sdk/src/Proton.Sdk/Cryptography/CryptoSecureNumberGenerator.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 5c3dfb6a..ee61e865 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index a147e9db..419be9ab 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -46,6 +46,12 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientCreateFolder => await InteropProtonDriveClient.HandleCreateFolderAsync(request.DriveClientCreateFolder).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientClearSecrets + => await InteropProtonDriveClient.HandleClearSecretsAsync(request.DriveClientClearSecrets).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientClearEntityCache + => await InteropProtonDriveClient.HandleClearEntityCacheAsync(request.DriveClientClearEntityCache).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientGetThumbnails => await InteropProtonDriveClient.HandleGetThumbnailsAsync(request.DriveClientGetThumbnails).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 1b0961f9..29ab2170 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -36,6 +36,13 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi ? SqliteCacheRepository.OpenFile(request.SecretCachePath) : new InMemoryCacheRepository(); + if (request.HasSecretCacheEncryptionKey) + { + secretCacheRepository = new EncryptedCacheRepository( + secretCacheRepository, + request.SecretCacheEncryptionKey.ToByteArray()); + } + ITelemetry telemetry = request.Telemetry.ToTelemetry(bindingsHandle) is { } interopTelemetry ? new DriveInteropTelemetryDecorator(interopTelemetry) : NullTelemetry.Instance; @@ -200,6 +207,26 @@ await client.RenameNodeAsync( return null; } + public static async ValueTask HandleClearSecretsAsync(DriveClientClearSecretsRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + await client.ClearSecretsAsync(cancellationToken).ConfigureAwait(false); + + return new Empty(); + } + + public static async ValueTask HandleClearEntityCacheAsync(DriveClientClearEntityCacheRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + await client.ClearEntityCacheAsync(cancellationToken).ConfigureAwait(false); + + return new Empty(); + } + public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNodesRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 697d7af0..54222723 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -104,4 +104,9 @@ private static string GetNodeCacheKey(NodeUid nodeId) { return $"node:{nodeId}"; } + + public ValueTask ClearAsync() + { + return _repository.ClearAsync(); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs index 94976f79..8f7f7da0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -81,4 +81,9 @@ private static string GetFileSecretsCacheKey(NodeUid nodeId) { return $"file:{nodeId}:secrets"; } + + public ValueTask ClearAsync() + { + return _repository.ClearAsync(); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index 8230ec90..3495e7b4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -22,4 +22,6 @@ internal interface IDriveEntityCache : IEntityCache ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken); + + ValueTask ClearAsync(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs index 59542613..614ebd19 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -21,4 +21,6 @@ ValueTask SetFolderSecretsAsync( ValueTask SetFileSecretsAsync(NodeUid nodeId, Result secretsProvisionResult, CancellationToken cancellationToken); ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + + ValueTask ClearAsync(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 9f4a7de3..b880d5ba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -243,6 +243,20 @@ public ValueTask EmptyTrashAsync(CancellationToken cancellationToken) return VolumeOperations.EmptyTrashAsync(this, cancellationToken); } + // TODO: This should be reworked; ProtonDriveClient should not be responsible for clearing secrets. + // For more context, please check https://gitlab.protontech.ch/drive/sdk/-/merge_requests/642#note_3175380. + public ValueTask ClearSecretsAsync(CancellationToken cancellationToken = default) + { + return Cache.Secrets.ClearAsync(); + } + + // TODO: This should be reworked; ProtonDriveClient should not be responsible for clearing cache. + // For more context, please check https://gitlab.protontech.ch/drive/sdk/-/merge_requests/642#note_3175380. + public ValueTask ClearEntityCacheAsync(CancellationToken cancellationToken = default) + { + return Cache.Entities.ClearAsync(); + } + private async ValueTask GetFileUploaderAsync( IRevisionDraftProvider revisionDraftProvider, long size, diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs index c3167ae2..f9e47489 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs @@ -74,4 +74,10 @@ private static string GetFileSecretsCacheKey(NodeUid nodeId) { return $"file:{nodeId}:secrets"; } + + public ValueTask ClearAsync() + { + return _repository.ClearAsync(); + } + } diff --git a/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs new file mode 100644 index 00000000..56ed3ebe --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs @@ -0,0 +1,139 @@ +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Caching; + +public sealed class EncryptedCacheRepository(ICacheRepository inner, byte[] encryptionKey) : ICacheRepository +{ + private const int IvByteCount = 12; + private const int SaltByteCount = 16; + private const int TagByteCount = 16; + private const int KeyByteCount = 32; + + private readonly ICacheRepository _inner = inner; + private readonly byte[] _encryptionKey = encryptionKey; + + private static byte[] CacheEncryptionContext => "Drive.EncryptedCacheRepository"u8.ToArray(); + + public ValueTask SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) + { + var encryptedValue = Encrypt(key, value); + + return _inner.SetAsync(key, encryptedValue, tags, cancellationToken); + } + + public ValueTask RemoveAsync(string key, CancellationToken cancellationToken) + { + return _inner.RemoveAsync(key, cancellationToken); + } + + public ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken) + { + return _inner.RemoveByTagAsync(tag, cancellationToken); + } + + public ValueTask ClearAsync() + { + return _inner.ClearAsync(); + } + + public async ValueTask TryGetAsync(string key, CancellationToken cancellationToken) + { + var encryptedValue = await _inner.TryGetAsync(key, cancellationToken).ConfigureAwait(false); + + return encryptedValue is not null ? Decrypt(key, encryptedValue) : null; + } + + public async IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync( + IEnumerable tags, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var (key, encryptedValue) in _inner.GetByTagsAsync(tags, cancellationToken).ConfigureAwait(false)) + { + yield return (key, Decrypt(key, encryptedValue)); + } + } + + public ValueTask DisposeAsync() + { + return _inner.DisposeAsync(); + } + + private static byte[] Concatenate(byte[] a1, byte[] a2) + { + var stream = new MemoryStream( + new byte[a1.Length + a2.Length], + 0, + a1.Length + a2.Length, + true, + true); + + stream.Write(a1, 0, a1.Length); + stream.Write(a2, 0, a2.Length); + + return stream.ToArray(); + } + + private string Encrypt(string entryKey, string plaintext) + { + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var salt = CryptoSecureNumberGenerator.GetBytes(SaltByteCount); + + Span derivedMaterial = HKDF.DeriveKey( + HashAlgorithmName.SHA256, + _encryptionKey, + KeyByteCount + IvByteCount, + salt, + Concatenate(CacheEncryptionContext, Encoding.UTF8.GetBytes(entryKey))); + + var derivedKey = derivedMaterial[..KeyByteCount]; + var iv = derivedMaterial[KeyByteCount..]; + Span ciphertext = stackalloc byte[plaintextBytes.Length]; + Span tag = stackalloc byte[TagByteCount]; + + using var aesGcm = new AesGcm(derivedKey, TagByteCount); + aesGcm.Encrypt(iv, plaintextBytes, ciphertext, tag); + + // Format: [salt][ciphertext][tag] + var result = new byte[SaltByteCount + plaintextBytes.Length + TagByteCount]; + + salt.CopyTo(result.AsSpan()); + ciphertext.CopyTo(result.AsSpan(SaltByteCount)); + tag.CopyTo(result.AsSpan(SaltByteCount + plaintextBytes.Length)); + + return Convert.ToBase64String(result); + } + + private string Decrypt(string entryKey, string encryptedBase64) + { + var combined = Convert.FromBase64String(encryptedBase64); + + // Validate minimum length: salt + tag + if (combined.Length < SaltByteCount + TagByteCount) + { + throw new InvalidOperationException("Invalid encrypted data format"); + } + + var salt = combined[..SaltByteCount]; + var ciphertext = combined[SaltByteCount..^TagByteCount]; + var tag = combined[^TagByteCount..]; + + Span derivedMaterial = HKDF.DeriveKey( + HashAlgorithmName.SHA256, + _encryptionKey, + KeyByteCount + IvByteCount, + salt, + Concatenate(CacheEncryptionContext, Encoding.UTF8.GetBytes(entryKey))); + + var derivedKey = derivedMaterial[..KeyByteCount]; + var iv = derivedMaterial[KeyByteCount..]; + Span plaintextBytes = stackalloc byte[ciphertext.Length]; + + using var aesGcm = new AesGcm(derivedKey, TagByteCount); + aesGcm.Decrypt(iv, ciphertext, tag, plaintextBytes); + + return Encoding.UTF8.GetString(plaintextBytes); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/CryptoSecureNumberGenerator.cs b/cs/sdk/src/Proton.Sdk/Cryptography/CryptoSecureNumberGenerator.cs new file mode 100644 index 00000000..a750d847 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/CryptoSecureNumberGenerator.cs @@ -0,0 +1,16 @@ +using System.Security.Cryptography; + +namespace Proton.Sdk.Cryptography; + +public static class CryptoSecureNumberGenerator +{ + public static void Fill(byte[] buffer) + { + RandomNumberGenerator.Fill(buffer); + } + + public static byte[] GetBytes(int count) + { + return RandomNumberGenerator.GetBytes(count); + } +} diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 946ff701..2622af63 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -21,6 +21,11 @@ message Request { DriveClientCreateFolderRequest drive_client_create_folder = 1009; DriveClientTrashNodesRequest drive_client_trash_nodes = 1010; + // These are also in the DriveClient but we aim to remove them in the future, + // so they have a significant gap to not disturb other payload numbers. + DriveClientClearSecretsRequest drive_client_clear_secrets = 1030; + DriveClientClearEntityCacheRequest drive_client_clear_entity_cache = 1031; + UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; FileUploaderFreeRequest file_uploader_free = 1102; @@ -117,7 +122,7 @@ message FileNode { message FolderNode { string uid = 1; - string parent_uid = 2; // optional + string parent_uid = 2; // optional string tree_event_scope_id = 3; string name = 4; google.protobuf.Timestamp creation_time = 5; @@ -132,7 +137,7 @@ message AuthorResult { } message Author { - string email_address = 1; // optional + string email_address = 1; // optional } message ProgressUpdate { @@ -179,7 +184,7 @@ message DriveClientCreateRequest { string bindings_language = 2; // Optional HttpClient http_client = 3; - + // Pointer to C function that will be called: // void handle_account_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) // bindings_handle: handle for the bindings @@ -192,7 +197,7 @@ message DriveClientCreateRequest { proton.sdk.Telemetry telemetry = 9; // Optional - // Client UID, optional + // Client UID, optional // If a null value is provided, the SDK automatically generates a UUID during initialization string uid = 10; @@ -202,6 +207,8 @@ message DriveClientCreateRequest { // flag_name: UTF-8 encoded feature flag name // returns: 0 for disabled, non-zero for enabled int64 feature_enabled_function = 11; // Optional + + bytes secret_cache_encryption_key = 12; // Optional } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. @@ -321,6 +328,17 @@ message DriveClientCreateFolderRequest { string folder_name = 3; google.protobuf.Timestamp last_modification_time = 4; // Optional int64 cancellation_token_source_handle = 5; + +// The response must not have a value. +message DriveClientClearSecretsRequest { + int64 client_handle = 1; + int64 cancellation_token_source_handle = 2; +} + +// The response must not have a value. +message DriveClientClearEntityCacheRequest { + int64 client_handle = 1; + int64 cancellation_token_source_handle = 2; } // The response message must be of type TrashNodesResponse @@ -407,14 +425,14 @@ message DownloadControllerFreeRequest { int64 download_controller_handle = 1; } -// Drive - Photos client +// Drive - Photos client message DrivePhotosClientCreateRequest { string base_url = 1; string bindings_language = 2; // Optional HttpClient http_client = 3; - + // Pointer to C function that will be called: // void handle_account_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) // bindings_handle: handle for the bindings @@ -427,7 +445,7 @@ message DrivePhotosClientCreateRequest { proton.sdk.Telemetry telemetry = 9; // Optional - // Client UID, optional + // Client UID, optional // If a null value is provided, the SDK automatically generates a UUID during initialization string uid = 10; @@ -442,7 +460,7 @@ message DrivePhotosClientCreateRequest { message DrivePhotosClientCreateFromSessionRequest { int64 session_handle = 1; - // Client UID, optional + // Client UID, optional // If a null value is provided, the SDK automatically generates a UUID during initialization string uid = 2; } @@ -489,7 +507,7 @@ message DrivePhotosClientGetPhotoDownloaderRequest { int64 cancellation_token_source_handle = 3; } -// Photo downloader +// Photo downloader // The response value must be an Int64Value carrying a handle to an instance of DownloadController. message DrivePhotosClientDownloadToStreamRequest { @@ -555,7 +573,7 @@ message UploadEventPayload { int64 approximate_uploaded_size = 4; // To be used when exact size must not be communicated in order to prevent fingerprinting UploadError error = 5; // Optional string original_error = 6; // Optional -} +} message DownloadEventPayload { VolumeType volume_type = 1; @@ -564,7 +582,7 @@ message DownloadEventPayload { DownloadError error = 4; // Optional string original_error = 5; // Optional } - + message DecryptionErrorEventPayload { VolumeType volume_type = 1; EncryptedField field = 2; @@ -581,7 +599,7 @@ message VerificationErrorEventPayload { string error = 5; // Optional string uid = 6; } - + message BlockVerificationErrorEventPayload { bool retry_helped = 1; } From b61fe52a0158c0f06c7fc5239ab6b892b2cedbf8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 12:37:54 +0100 Subject: [PATCH 442/791] Fix build error due to missing brace in Protobuf definition --- cs/sdk/src/protos/proton.drive.sdk.proto | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 2622af63..138d548f 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -323,11 +323,12 @@ message DriveClientRenameRequest { // The response message must be of type FolderNode message DriveClientCreateFolderRequest { - int64 client_handle = 1; - string parent_folder_uid = 2; - string folder_name = 3; - google.protobuf.Timestamp last_modification_time = 4; // Optional - int64 cancellation_token_source_handle = 5; + int64 client_handle = 1; + string parent_folder_uid = 2; + string folder_name = 3; + google.protobuf.Timestamp last_modification_time = 4; // Optional + int64 cancellation_token_source_handle = 5; +} // The response must not have a value. message DriveClientClearSecretsRequest { From d42d5e68e1f0d8dc91b682ace9d95723d5e994a5 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 14:01:04 +0000 Subject: [PATCH 443/791] Upgrade CryptoProxy and SRP --- js/sdk/src/crypto/openPGPCrypto.ts | 50 ++++++++++-------------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index b0200747..64caf56c 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -13,7 +13,11 @@ export interface OpenPGPCryptoProxy { importPrivateKey: (options: { armoredKey: string; passphrase: string | null }) => Promise; generateSessionKey: (options: { recipientKeys: PublicKey[] }) => Promise; encryptSessionKey: ( - options: SessionKey & { format: 'binary'; encryptionKeys?: PublicKey | PublicKey[]; passwords?: string[] }, + options: SessionKey & { + format: 'binary'; + encryptionKeys?: PublicKey | PublicKey[]; + passwords?: string[]; + }, ) => Promise; decryptSessionKey: (options: { armoredMessage?: string; @@ -50,10 +54,7 @@ export interface OpenPGPCryptoProxy { verificationKeys?: PublicKey | PublicKey[]; }) => Promise<{ data: Format extends 'binary' ? Uint8Array : string; - // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. - // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. - verified?: VERIFICATION_STATUS; - verificationStatus?: VERIFICATION_STATUS; + verificationStatus: VERIFICATION_STATUS; verificationErrors?: Error[]; }>; signMessage: (options: { @@ -70,10 +71,7 @@ export interface OpenPGPCryptoProxy { verificationKeys: PublicKey | PublicKey[]; signatureContext?: { critical: boolean; value: string }; }) => Promise<{ - // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. - // Web clients are using newer pmcrypto, but CLI is using older version due to build issues with Bun. - verified?: VERIFICATION_STATUS; - verificationStatus?: VERIFICATION_STATUS; + verificationStatus: VERIFICATION_STATUS; errors?: Error[]; }>; } @@ -254,15 +252,13 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } async verify(data: Uint8Array, signature: Uint8Array, verificationKeys: PublicKey | PublicKey[]) { - const { verified, verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ + const { verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ binaryData: data, binarySignature: signature, verificationKeys, }); return { - // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. - // Proper typing is too complex, it will be removed to support only newer pmcrypto. - verified: verified || verificationStatus!, + verified: verificationStatus, verificationErrors: errors, }; } @@ -273,7 +269,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { verificationKeys: PublicKey | PublicKey[], signatureContext?: string, ) { - const { verified, verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ + const { verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ binaryData: data, armoredSignature, verificationKeys, @@ -281,9 +277,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }); return { - // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. - // Proper typing is too complex, it will be removed to support only newer pmcrypto. - verified: verified || verificationStatus!, + verified: verificationStatus, verificationErrors: errors, }; } @@ -325,7 +319,6 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { async decryptAndVerify(data: Uint8Array, sessionKey: SessionKey, verificationKeys: PublicKey[]) { const { data: decryptedData, - verified, verificationStatus, verificationErrors, } = await this.cryptoProxy.decryptMessage({ @@ -337,9 +330,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { data: decryptedData, - // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. - // Proper typing is too complex, it will be removed to support only newer pmcrypto. - verified: verified || verificationStatus!, + verified: verificationStatus, verificationErrors, }; } @@ -352,7 +343,6 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { ) { const { data: decryptedData, - verified, verificationStatus, verificationErrors, } = await this.cryptoProxy.decryptMessage({ @@ -365,9 +355,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { data: decryptedData, - // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. - // Proper typing is too complex, it will be removed to support only newer pmcrypto. - verified: verified || verificationStatus!, + verified: verificationStatus, verificationErrors, }; } @@ -386,7 +374,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { decryptionKeys: PrivateKey | PrivateKey[], verificationKeys: PublicKey | PublicKey[], ) { - const { data, verified, verificationStatus, verificationErrors } = await this.cryptoProxy.decryptMessage({ + const { data, verificationStatus, verificationErrors } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, decryptionKeys, verificationKeys, @@ -395,9 +383,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { data: data, - // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. - // Proper typing is too complex, it will be removed to support only newer pmcrypto. - verified: verified || verificationStatus!, + verified: verificationStatus, verificationErrors, }; } @@ -408,7 +394,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, verificationKeys: PublicKey | PublicKey[], ) { - const { data, verified, verificationStatus, verificationErrors } = await this.cryptoProxy.decryptMessage({ + const { data, verificationStatus, verificationErrors } = await this.cryptoProxy.decryptMessage({ armoredMessage: armoredData, armoredSignature, sessionKeys: sessionKey, @@ -418,9 +404,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return { data: data, - // pmcrypto 8.3.0 changes `verified` to `verificationStatus`. - // Proper typing is too complex, it will be removed to support only newer pmcrypto. - verified: verified || verificationStatus!, + verified: verificationStatus, verificationErrors: !armoredSignature ? [new Error(c('Error').t`Signature is missing`)] : verificationErrors, From 0b551ccf149f2986b7ceb03f81a8bc4cd9668ab4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 10:29:49 +0100 Subject: [PATCH 444/791] Fix invitation node type --- js/sdk/src/internal/apiService/driveTypes.ts | 876 ++++++++++-------- .../src/internal/apiService/transformers.ts | 18 +- js/sdk/src/internal/sharing/apiService.ts | 14 +- 3 files changed, 520 insertions(+), 388 deletions(-) diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index fb6ce834..8186069e 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -349,6 +349,7 @@ export interface paths { put?: never; /** * Create document + * @deprecated * @description Create a new proton document. */ post: operations["post_drive-shares-{shareID}-documents"]; @@ -472,6 +473,7 @@ export interface paths { put?: never; /** * Create a folder + * @deprecated * @description Create a new folder in a given share, under a given folder link. */ post: operations["post_drive-shares-{shareID}-folders"]; @@ -481,7 +483,7 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/folders/{linkID}/delete_multiple": { + "/drive/v2/volumes/{volumeID}/folders": { parameters: { query?: never; header?: never; @@ -491,38 +493,17 @@ export interface paths { get?: never; put?: never; /** - * Delete drafts from folder - * @deprecated - * @description Permanently delete children from folder, skipping trash. Can only be done for draft links. - */ - post: operations["post_drive-shares-{shareID}-folders-{linkID}-delete_multiple"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/drive/shares/{shareID}/folders/{linkID}/children": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * List folder children - * @description List children of a given folder. + * Create a folder (v2) + * @description Create a new folder under a given parent folder. */ - get: operations["get_drive-shares-{shareID}-folders-{linkID}-children"]; - put?: never; - post?: never; + post: operations["post_drive-v2-volumes-{volumeID}-folders"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/folders/{linkID}/trash_multiple": { + "/drive/shares/{shareID}/folders/{linkID}/delete_multiple": { parameters: { query?: never; header?: never; @@ -532,26 +513,31 @@ export interface paths { get?: never; put?: never; /** - * Trash children from folder + * Delete drafts from folder * @deprecated + * @description Permanently delete children from folder, skipping trash. Can only be done for draft links. */ - post: operations["post_drive-shares-{shareID}-folders-{linkID}-trash_multiple"]; + post: operations["post_drive-shares-{shareID}-folders-{linkID}-delete_multiple"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/shares/{shareID}/folders/{linkID}": { + "/drive/shares/{shareID}/folders/{linkID}/children": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Update folder attributes */ - put: operations["put_drive-shares-{shareID}-folders-{linkID}"]; + /** + * List folder children + * @deprecated + * @description List children of a given folder. + */ + get: operations["get_drive-shares-{shareID}-folders-{linkID}-children"]; + put?: never; post?: never; delete?: never; options?: never; @@ -559,40 +545,40 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/folders": { + "/drive/v2/volumes/{volumeID}/folders/{linkID}/children": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Create a folder (v2) - * @description Create a new folder in a given share, under a given folder link. + * List folder children (v2) + * @description List children IDs of a given folder. */ - post: operations["post_drive-v2-volumes-{volumeID}-folders"]; + get: operations["get_drive-v2-volumes-{volumeID}-folders-{linkID}-children"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/folders/{linkID}/children": { + "/drive/shares/{shareID}/folders/{linkID}/trash_multiple": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List folder children (v2) - * @description List children IDs of a given folder. + * Trash children from folder + * @deprecated */ - get: operations["get_drive-v2-volumes-{volumeID}-folders-{linkID}-children"]; - put?: never; - post?: never; + post: operations["post_drive-shares-{shareID}-folders-{linkID}-trash_multiple"]; delete?: never; options?: never; head?: never; @@ -632,6 +618,7 @@ export interface paths { put?: never; /** * Check available hashes + * @deprecated * @description Filter unavailable hashes out of a list of hashes under a given parent folder. * * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. @@ -692,7 +679,10 @@ export interface paths { }; get?: never; put?: never; - /** Fetch links in share */ + /** + * Fetch links in share + * @deprecated + */ post: operations["post_drive-shares-{shareID}-links-fetch_metadata"]; delete?: never; options?: never; @@ -726,6 +716,7 @@ export interface paths { }; /** * Get link data + * @deprecated * @description Retrieve individual link information. */ get: operations["get_drive-shares-{shareID}-links-{linkID}"]; @@ -744,10 +735,16 @@ export interface paths { path?: never; cookie?: never; }; - /** List folders with missing hash keys */ + /** + * List folders with missing hash keys + * @deprecated + */ get: operations["get_drive-sanitization-mhk"]; put?: never; - /** List folders with missing hash keys */ + /** + * List folders with missing hash keys + * @deprecated + */ post: operations["post_drive-sanitization-mhk"]; delete?: never; options?: never; @@ -802,6 +799,7 @@ export interface paths { get?: never; /** * Move link + * @deprecated * @description Move a file or folder. Client must provide new values for fields encrypted with parent NodeKey. * * Clients moving a file or folder MUST reuse the existing session keys @@ -816,6 +814,29 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/v2/volumes/{volumeID}/links/{linkID}/move": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Move link (v2) + * @description Move a file or folder. Client must provide new values for fields encrypted with parent NodeKey. + * Clients moving a file or folder MUST reuse the existing session keys + * for the name and passphrase as these are also used by shares pointing + * to the link. The passphrase should NOT be changed,reusing same session key as previously + */ + put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-move"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/v2/volumes/{volumeID}/remove-mine": { parameters: { query?: never; @@ -880,6 +901,7 @@ export interface paths { get?: never; /** * Rename link + * @deprecated * @description Rename a file or folder. Client must provide new values for fields linked to name. * * Clients renaming a file or folder MUST reuse the existing session key @@ -898,29 +920,6 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/v2/volumes/{volumeID}/links/{linkID}/move": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - /** - * Move link (v2) - * @description Move a file or folder. Client must provide new values for fields encrypted with parent NodeKey. - * Clients moving a file or folder MUST reuse the existing session keys - * for the name and passphrase as these are also used by shares pointing - * to the link. The passphrase should NOT be changed,reusing same session key as previously - */ - put: operations["put_drive-v2-volumes-{volumeID}-links-{linkID}-move"]; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}": { parameters: { query?: never; @@ -967,11 +966,13 @@ export interface paths { }; /** * Get revision + * @deprecated * @description Get detailed revision information. */ get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; /** * Commit a revision + * @deprecated * @description The revision becomes the current active one and the updated file content become available for reading. * * If NO `BlockNumber` parameter is passed when creating a new revision, @@ -985,6 +986,7 @@ export interface paths { post?: never; /** * Delete an obsolete/draft revision + * @deprecated * @description Only the volume owner can delete obsolete revisions. Members with write permission can only delete drafts. * This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active. Create or revert to * another revision first. You cannot delete a draft revision for a draft link. Delete the link instead. @@ -1021,7 +1023,10 @@ export interface paths { }; get?: never; put?: never; - /** Create a new draft file */ + /** + * Create a new draft file + * @deprecated + */ post: operations["post_drive-shares-{shareID}-files"]; delete?: never; options?: never; @@ -1064,11 +1069,15 @@ export interface paths { path?: never; cookie?: never; }; - /** List revisions */ + /** + * List revisions + * @deprecated + */ get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions"]; put?: never; /** * Create revision + * @deprecated * @description Create a new revision on an existing link. * Only one draft can be created at a time. A draft can be deleted using the DELETE revision endpoint if the new * draft should be created regardless. The error code indicates the reason for failure. @@ -1092,7 +1101,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get revision thumbnail */ + /** + * Get revision thumbnail + * @deprecated + */ get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail"]; put?: never; post?: never; @@ -1128,7 +1140,10 @@ export interface paths { }; get?: never; put?: never; - /** Restore a revision */ + /** + * Restore a revision + * @deprecated + */ post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore"]; delete?: never; options?: never; @@ -1165,6 +1180,7 @@ export interface paths { }; /** * Get verification data. + * @deprecated * @description Get data to verify encryption of the revision before committing. */ get: operations["get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification"]; @@ -1187,7 +1203,8 @@ export interface paths { put?: never; /** * Delete items from trash - * @description Permanently delete list of links from trash of a given share. + * @description Permanently delete a list of links from trash in a given volume. + * The user must be the owner of the volume or admin of the organization. */ post: operations["post_drive-v2-volumes-{volumeID}-trash-delete_multiple"]; delete?: never; @@ -1207,7 +1224,9 @@ export interface paths { put?: never; /** * Delete items from trash - * @description Permanently delete list of links from trash of a given share. + * @deprecated + * @description Permanently delete a list of links from trash in a given volume. + * The user must be the owner of the volume or admin of the organization. */ post: operations["post_drive-shares-{shareID}-trash-delete_multiple"]; delete?: never; @@ -1255,7 +1274,12 @@ export interface paths { path?: never; cookie?: never; }; - /** List volume trash */ + /** + * List volume trash + * @deprecated + * @description Requires the user to be the owner of the volume and thus does not work with org volumes. + * Deprecated: use GET /drive/v2/volumes/{volumeID}/trash instead + */ get: operations["get_drive-volumes-{volumeID}-trash"]; put?: never; post?: never; @@ -1263,6 +1287,7 @@ export interface paths { * Empty volume trash * @description When there are fewer items in trash than a certain threshold, trash will be deleted synchronously returning a 200 HTTP code. * Otherwise, it will happen async returning a 202 HTTP code. + * The user must be the owner of the volume or admin of the organization. */ delete: operations["delete_drive-volumes-{volumeID}-trash"]; options?: never; @@ -1270,6 +1295,27 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/v2/volumes/{volumeID}/trash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List volume trash (v2) + * @description In personal regular and photo volumes, you need to be the volume owner to access trash. + * In organization volumes, users can access trash of any files they have write-permission on. + */ + get: operations["get_drive-v2-volumes-{volumeID}-trash"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/v2/volumes/{volumeID}/trash/restore_multiple": { parameters: { query?: never; @@ -1281,8 +1327,8 @@ export interface paths { /** * Restore items from trash * @description Restore list of links from trash to original location. - * - * /shares endpoint should NOT be used on Photo-Volume -> use volume-trash + * In personal regular and photo volumes, you need to be the volume owner to restore from trash. + * In organization volumes, users can restore files from trash that they have write-permission on. */ put: operations["put_drive-v2-volumes-{volumeID}-trash-restore_multiple"]; post?: never; @@ -1302,9 +1348,10 @@ export interface paths { get?: never; /** * Restore items from trash + * @deprecated * @description Restore list of links from trash to original location. - * - * /shares endpoint should NOT be used on Photo-Volume -> use volume-trash + * In personal regular and photo volumes, you need to be the volume owner to restore from trash. + * In organization volumes, users can restore files from trash that they have write-permission on. */ put: operations["put_drive-shares-{shareID}-trash-restore_multiple"]; post?: never; @@ -3042,7 +3089,11 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** + * List org. volumes + * @description List all org. volumes the user belongs. The user can be an org. admin or any user belonging to the organization + */ + get: operations["get_drive-organization-volumes"]; put?: never; /** * Create Organization volume @@ -3100,7 +3151,7 @@ export interface paths { }; get?: never; /** - * Delete the whole volume if is locked or the locked root shares in the volume. + * Delete the locked root shares in the volume. * @description Web client calls this endpoint when the user decides to delete their locked data and not recover it. */ put: operations["put_drive-volumes-{volumeID}-delete_locked"]; @@ -3131,6 +3182,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/organization/volumes/admin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List volumes in an org + * @description List all volumes in an org where the user must be admin of. + */ + get: operations["get_drive-organization-volumes-admin"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/volumes/{volumeID}/restore": { parameters: { query?: never; @@ -3499,22 +3570,6 @@ export interface components { */ Code: 1000; }; - LinkIDsRequestDto: { - LinkIDs: components["schemas"]["EncryptedId"][]; - }; - OffsetPagination: { - /** The page size */ - PageSize: number; - /** - * The page index using 0-based indexing - * @default 0 - */ - Page: number; - }; - UpdateFolderRequestDto: { - /** @description Extended Attributes */ - XAttr: string; - }; CreateFolderRequestDto2: { /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ NodeHashKey: string; @@ -3537,6 +3592,18 @@ export interface components { */ SignatureEmail: components["schemas"]["AddressEmail"] | null; }; + LinkIDsRequestDto: { + LinkIDs: components["schemas"]["EncryptedId"][]; + }; + OffsetPagination: { + /** The page size */ + PageSize: number; + /** + * The page index using 0-based indexing + * @default 0 + */ + Page: number; + }; ListChildrenResponseDto: { LinkIDs: components["schemas"]["Id2"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ @@ -3723,6 +3790,38 @@ export interface components { */ SignatureEmail: string | null; }; + MoveLinkRequestDto2: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + /** @description Current name hash before move operation. Used to prevent race conditions. */ + OriginalHash: string; + /** + * Format: email + * @description Signature email address used for signing name + */ + NameSignatureEmail: string; + /** + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Signature email address used for the NodePassphraseSignature. + * @default null + */ + SignatureEmail: string | null; + }; RenameLinkRequestDto: { /** @description Name, reusing same session key as previously. */ Name: string; @@ -3756,38 +3855,6 @@ export interface components { UpdateMissingHashKeyRequestDto: { NodesWithMissingNodeHashKey: components["schemas"]["UpdateMissingHashKeyItemDto"][]; }; - MoveLinkRequestDto2: { - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; - /** @description Name hash */ - Hash: string; - ParentLinkID: components["schemas"]["Id"]; - /** @description Current name hash before move operation. Used to prevent race conditions. */ - OriginalHash: string; - /** - * Format: email - * @description Signature email address used for signing name - */ - NameSignatureEmail: string; - /** - * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] - * @default null - */ - ContentHash: string | null; - /** - * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. - * @default null - */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; - /** - * Format: email - * @description Signature email address used for the NodePassphraseSignature. - * @default null - */ - SignatureEmail: string | null; - }; CommitRevisionDto: { ManifestSignature: components["schemas"]["PGPSignature"]; /** @@ -3945,6 +4012,15 @@ export interface components { */ Code: 1000; }; + VolumeTrashListV2: { + TrashedLinkIDs: components["schemas"]["Id2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; RequestUploadInput: { LinkID: components["schemas"]["Id"]; RevisionID: components["schemas"]["Id"]; @@ -4038,6 +4114,8 @@ export interface components { }; FreshAccountResponseDto: { EndTime?: number | null; + /** @description Maximum available space for the free upload timer, in bytes (API allows going 10% over limit for zero-rating) */ + Quota?: number | null; /** * ProtonResponseCode * @example 1000 @@ -4273,6 +4351,7 @@ export interface components { * @default null */ ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + DocumentType?: components["schemas"]["DocumentType"]; }; CreateAnonymousDocumentResponseDto: { Document: components["schemas"]["DocumentDetailsDto"]; @@ -4981,6 +5060,24 @@ export interface components { */ ShareName: string | null; }; + ListOrgVolumesResponseDto: { + Volumes: components["schemas"]["OrgVolumeResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListOrgVolumesForAdminResponseDto: { + Volumes: components["schemas"]["OrgVolumeForAdminResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; ListVolumesResponseDto: { Volumes: components["schemas"]["VolumeResponseDto"][]; /** @@ -5002,16 +5099,20 @@ export interface components { AddressKeyID: string; }; AddPhotoToAlbumWithLinkIDResponseDto: Record; + MultiResponsesPerLinkFactory: { + /** @enum {integer} */ + Code: 1001; + Responses: { + LinkID: string; + Response: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + }[]; + }; RemovePhotoFromAlbumWithLinkIDResponseDto: Record; ConflictErrorResponseDto: { Details: components["schemas"]["ConflictErrorDetailsDto"]; Error: string; Code: number; }; - MultiDeleteTransformer: { - LinkID: string; - Response: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; - }; /** Link */ ExtendedLinkTransformer: { /** @@ -5766,12 +5867,8 @@ export interface components { /** @description Address ID */ AddressID: string; RequestUploadBlockInput: { - /** @description Block size in bytes */ - Size: number; /** @description Index of block in list (must be consecutive starting at 1) */ Index: number; - /** @description sha256 hash of encrypted block, base64 encoded */ - Hash: string; /** @default null */ Verifier: components["schemas"]["Verifier"] | null; /** @@ -5779,12 +5876,30 @@ export interface components { * @default null */ EncSignature: string | null; + /** + * @deprecated + * @description Block size in bytes + * @default null + */ + Size: number | null; + /** + * @deprecated + * @description sha256 hash of encrypted block, base64 encoded + */ + Hash: string; }; RequestUploadThumbnailInput: { - /** @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 */ - Size: number; Type: components["schemas"]["ThumbnailType"]; - /** @description sha256 hash of encrypted block, base64 encoded */ + /** + * @deprecated + * @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 + * @default null + */ + Size: number | null; + /** + * @deprecated + * @description sha256 hash of encrypted block, base64 encoded + */ Hash: string; }; BlockURL: { @@ -6530,6 +6645,7 @@ export interface components { ShareKey: components["schemas"]["PGPPrivateKey2"]; /** Format: email */ CreatorEmail: string; + ShareTargetType: components["schemas"]["TargetType2"]; }; LinkResponseDto: { Type: components["schemas"]["NodeType3"]; @@ -6649,6 +6765,19 @@ export interface components { Share: components["schemas"]["ShareReferenceResponseDto"]; Type: components["schemas"]["VolumeType2"]; }; + OrgVolumeResponseDto: { + ShareID: components["schemas"]["Id2"]; + VolumeID: components["schemas"]["Id2"]; + /** @description Name of the org. volume */ + Name: string; + /** @description Membership creation time */ + CreateTime: number; + }; + OrgVolumeForAdminResponseDto: { + VolumeID: components["schemas"]["Id2"]; + /** @description Name of the org. volume */ + Name: string; + }; RestoreMainShareDto: { /** @description ShareID of the existing, locked main share */ LockedShareID: string; @@ -6725,11 +6854,8 @@ export interface components { }; /** Link */ LinkTransformer: { - /** @description Encrypted link ID */ LinkID: string; - /** @description Encrypted parent link ID */ ParentLinkID: string | null; - /** @description Encrypted volume link ID */ VolumeID: string; /** * @description Node type (1=folder, 2=file) @@ -7832,21 +7958,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: { - /** @description Encrypted link ID */ - LinkID?: string; - Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; - }[]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; /** @description Unprocessable Entity */ @@ -7965,21 +8083,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: { - /** @description Encrypted link ID */ - LinkID?: string; - Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; - }[]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; /** @description Unprocessable Entity */ @@ -8015,21 +8125,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: { - /** @description Encrypted link ID */ - LinkID?: string; - Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; - }[]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; /** @description Unprocessable Entity */ @@ -8607,82 +8709,52 @@ export interface operations { }; }; }; - "post_drive-shares-{shareID}-folders-{linkID}-delete_multiple": { + "post_drive-v2-volumes-{volumeID}-folders": { parameters: { query?: never; header?: never; path: { - shareID: string; - linkID: string; + volumeID: string; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + "application/json": components["schemas"]["CreateFolderRequestDto2"]; }; }; responses: { - /** @description Ok */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; + "application/json": components["schemas"]["CreateFolderResponseDto"]; }; }; - }; - }; - "get_drive-shares-{shareID}-folders-{linkID}-children": { - parameters: { - query?: { - /** @description Field to sort by */ - Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; - /** @description Sort order */ - Desc?: 0 | 1; - /** @description Show all files including those in non-active (drafts) state. */ - ShowAll?: 0 | 1; - /** @description Show folders only */ - FoldersOnly?: 0 | 1; - /** - * @deprecated - * @description Get thumbnail download URLs - */ - Thumbnails?: 0 | 1; - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; - }; - header?: never; - path: { - shareID: string; - linkID: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Links */ - 200: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; content: { "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - /** @description Allow sorting of items in folder */ - AllowSorting: boolean; - Links: components["schemas"]["ExtendedLinkTransformer"][]; - }; + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - "post_drive-shares-{shareID}-folders-{linkID}-trash_multiple": { + "post_drive-shares-{shareID}-folders-{linkID}-delete_multiple": { parameters: { query?: never; header?: never; @@ -8698,92 +8770,57 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; }; }; - "put_drive-shares-{shareID}-folders-{linkID}": { + "get_drive-shares-{shareID}-folders-{linkID}-children": { parameters: { - query?: never; - header?: never; - path: { - shareID: string; - linkID: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["UpdateFolderRequestDto"]; - }; - }; - responses: { - /** @description Ok */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Link: components["schemas"]["ExtendedLinkTransformer"]; - }; - }; + query?: { + /** @description Field to sort by */ + Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; + /** @description Sort order */ + Desc?: 0 | 1; + /** @description Show all files including those in non-active (drafts) state. */ + ShowAll?: 0 | 1; + /** @description Show folders only */ + FoldersOnly?: 0 | 1; + /** + * @deprecated + * @description Get thumbnail download URLs + */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; }; - }; - }; - "post_drive-v2-volumes-{volumeID}-folders": { - parameters: { - query?: never; header?: never; path: { - volumeID: string; + shareID: string; + linkID: string; }; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["CreateFolderRequestDto2"]; - }; - }; + requestBody?: never; responses: { - /** @description Success */ + /** @description Links */ 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CreateFolderResponseDto"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { headers: { [name: string]: unknown; }; content: { "application/json": { - /** @description Potential codes and their meaning: - * - 2511: the link targeted is a photo link - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2501: parent folder was not found - * */ - Code?: number; - } | components["schemas"]["ConflictErrorResponseDto"]; + Code: components["schemas"]["ResponseCodeSuccess"]; + /** @description Allow sorting of items in folder */ + AllowSorting: boolean; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + }; }; }; }; @@ -8832,6 +8869,33 @@ export interface operations { }; }; }; + "post_drive-shares-{shareID}-folders-{linkID}-trash_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + }; + }; "post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes": { parameters: { query?: never; @@ -8956,17 +9020,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; }; @@ -9142,21 +9202,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: { - /** @description Encrypted link ID */ - LinkID?: string; - Response?: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; - }[]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; /** @description Unprocessable Entity */ @@ -9216,70 +9268,77 @@ export interface operations { }; }; }; - "post_drive-v2-volumes-{volumeID}-remove-mine": { + "put_drive-v2-volumes-{volumeID}-links-{linkID}-move": { parameters: { query?: never; header?: never; path: { volumeID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["LinkIDsRequestDto"]; + "application/json": components["schemas"]["MoveLinkRequestDto2"]; }; }; responses: { - /** @description Ok */ - 200: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; content: { "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2511: cannot move favorite photos from a share + * - 2501: parent folder was not found + * */ + Code?: number; + /** @description Error message */ + Error?: string; + } | components["schemas"]["ConflictErrorResponseDto"]; }; }; }; }; - "put_drive-v2-volumes-{volumeID}-links-{linkID}-rename": { + "post_drive-v2-volumes-{volumeID}-remove-mine": { parameters: { query?: never; header?: never; path: { volumeID: string; - linkID: string; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["RenameLinkRequestDto"]; + "application/json": components["schemas"]["LinkIDsRequestDto"]; }; }; responses: { - 200: components["responses"]["ProtonSuccessResponse"]; - /** @description Conflict, a file or folder with the new name already exists in the current folder. */ - 422: { + /** @description Multi responses */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConflictErrorResponseDto"]; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; }; }; - "put_drive-shares-{shareID}-links-{linkID}-rename": { + "put_drive-v2-volumes-{volumeID}-links-{linkID}-rename": { parameters: { query?: never; header?: never; path: { - shareID: string; + volumeID: string; linkID: string; }; cookie?: never; @@ -9302,41 +9361,30 @@ export interface operations { }; }; }; - "put_drive-v2-volumes-{volumeID}-links-{linkID}-move": { + "put_drive-shares-{shareID}-links-{linkID}-rename": { parameters: { query?: never; header?: never; path: { - volumeID: string; - linkID: components["schemas"]["Id"]; + shareID: string; + linkID: string; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["MoveLinkRequestDto2"]; + "application/json": components["schemas"]["RenameLinkRequestDto"]; }; }; responses: { 200: components["responses"]["ProtonSuccessResponse"]; - /** @description Unprocessable Entity */ + /** @description Conflict, a file or folder with the new name already exists in the current folder. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @description Potential codes and their meaning: - * - 200300: max folder size reached - * - 200301: max folder depth reached - * - 2500: file or folder with same name already exists - * - 2511: cannot move favorite photos from a share - * - 2501: parent folder was not found - * */ - Code?: number; - /** @description Error message */ - Error?: string; - } | components["schemas"]["ConflictErrorResponseDto"]; + "application/json": components["schemas"]["ConflictErrorResponseDto"]; }; }; }; @@ -9978,17 +10026,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; }; @@ -10008,17 +10052,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; }; @@ -10152,6 +10192,32 @@ export interface operations { }; }; }; + "get_drive-v2-volumes-{volumeID}-trash": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeTrashListV2"]; + }; + }; + }; + }; "put_drive-v2-volumes-{volumeID}-trash-restore_multiple": { parameters: { query?: never; @@ -10167,23 +10233,13 @@ export interface operations { }; }; responses: { - /** @description Responses */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: { - /** @description Encrypted link ID */ - LinkID?: string; - Response?: { - Code?: components["schemas"]["ResponseCodeSuccess"]; - }; - }[]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; }; @@ -10203,23 +10259,13 @@ export interface operations { }; }; responses: { - /** @description Responses */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: { - /** @description Encrypted link ID */ - LinkID?: string; - Response?: { - Code?: components["schemas"]["ResponseCodeSuccess"]; - }; - }[]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; }; @@ -10239,17 +10285,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; }; @@ -11475,17 +11517,13 @@ export interface operations { }; }; responses: { - /** @description Ok */ + /** @description Multi responses */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {integer} */ - Code?: 1001; - Responses?: components["schemas"]["MultiDeleteTransformer"][]; - }; + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; }; }; /** @description Unprocessable Entity */ @@ -13627,6 +13665,41 @@ export interface operations { }; }; }; + "get_drive-organization-volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListOrgVolumesResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2000: User does not belong to an organization + * */ + Code: number; + }; + }; + }; + }; + }; "post_drive-organization-volumes": { parameters: { query?: never; @@ -13749,6 +13822,41 @@ export interface operations { 422: components["responses"]["ProtonErrorResponse"]; }; }; + "get_drive-organization-volumes-admin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListOrgVolumesForAdminResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2000: User does not belong to an organization + * */ + Code: number; + }; + }; + }; + }; + }; "put_drive-volumes-{volumeID}-restore": { parameters: { query?: never; diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts index 1c7cc813..f518bc37 100644 --- a/js/sdk/src/internal/apiService/transformers.ts +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -1,10 +1,26 @@ import { Logger, NodeType, MemberRole } from '../../interface'; -export function nodeTypeNumberToNodeType(logger: Logger, nodeTypeNumber: number): NodeType { +enum ShareTargetType { + Root = 0, + Folder = 1, + File = 2, + Album = 3, + Photo = 4, + ProtonVendor = 5, +} + +export function nodeTypeNumberToNodeType( + logger: Logger, + nodeTypeNumber: number, + shareTargetType?: ShareTargetType, +): NodeType { switch (nodeTypeNumber) { case 1: return NodeType.Folder; case 2: + if (shareTargetType === ShareTargetType.Album || shareTargetType === ShareTargetType.Photo) { + return NodeType.Photo; + } return NodeType.File; case 3: return NodeType.Album; diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 49ca02e1..925cea2c 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -1,5 +1,5 @@ import { SRPVerifier } from '../../crypto'; -import { NodeType, MemberRole, NonProtonInvitationState, Logger } from '../../interface'; +import { MemberRole, NonProtonInvitationState, Logger } from '../../interface'; import { DriveAPIService, drivePaths, @@ -235,7 +235,7 @@ export class SharingAPIService { }, node: { uid: makeNodeUid(response.Share.VolumeID, response.Link.LinkID), - type: nodeTypeNumberToNodeType(this.logger, response.Link.Type), + type: nodeTypeNumberToNodeType(this.logger, response.Link.Type, response.Share.ShareTargetType), mediaType: response.Link.MIMEType || undefined, encryptedName: response.Link.Name, }, @@ -272,7 +272,15 @@ export class SharingAPIService { base64SharePasswordSalt: bookmark.Token.SharePasswordSalt, }, node: { - type: bookmark.Token.LinkType === 1 ? NodeType.Folder : NodeType.File, + // FIXME: Bookmarked directly shared photo from Photos + // section will be wrongly detected as file. The reason + // is that on the backend photo is file type and there + // is no ShareTargetType available. + // It is not crucial as only web client supports bookmarks + // and it simply opens the public link. Also, the plan + // is to remove bookmarks in the future in favor of copy + // to own volume. + type: nodeTypeNumberToNodeType(this.logger, bookmark.Token.LinkType), mediaType: bookmark.Token.MIMEType, encryptedName: bookmark.Token.Name, armoredKey: bookmark.Token.NodeKey, From 7d59e57b9a6922044f9b1cdba085888b23a1a9a1 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 17:01:14 +0100 Subject: [PATCH 445/791] Add ability to override HTTP timeouts --- .../InteropProtonDriveClient.cs | 11 +++- .../InteropProtonPhotosClient.cs | 12 +++- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 15 ++--- .../ProtonDriveClientOptions.cs | 7 +++ .../Proton.Drive.Sdk/ProtonDriveDefaults.cs | 3 + .../Proton.Photos.Sdk/ProtonPhotosClient.cs | 11 ++-- cs/sdk/src/protos/proton.drive.sdk.proto | 63 ++++++++++--------- .../ProtonDriveClient/ProtonDriveClient.swift | 14 ++++- .../ProtonDriveClientConfiguration.swift | 11 +++- .../ProtonPhotosClient.swift | 14 ++++- 10 files changed, 101 insertions(+), 60 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 29ab2170..e69e37b6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -18,10 +18,16 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi throw new UriFormatException("Base URL must end with a '/'"); } + var protonDriveClientOptions = new Sdk.ProtonDriveClientOptions( + request.ClientOptions.HasBindingsLanguage ? request.ClientOptions.BindingsLanguage : null, + request.ClientOptions.HasUid ? request.ClientOptions.Uid : null, + request.ClientOptions.HasApiCallTimeout ? request.ClientOptions.ApiCallTimeout : null, + request.ClientOptions.HasStorageCallTimeout ? request.ClientOptions.StorageCallTimeout : null); + var httpClientFactory = new InteropHttpClientFactory( bindingsHandle, request.BaseUrl, - request.BindingsLanguage, + protonDriveClientOptions.BindingsLanguage, new InteropFunction, nint, nint>(request.HttpClient.RequestFunction), new InteropAction, nint>(request.HttpClient.ResponseContentReadAction), new InteropAction(request.HttpClient.CancellationAction)); @@ -58,8 +64,7 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi secretCacheRepository, featureFlagProvider, telemetry, - request.BindingsLanguage, - request.Uid); + protonDriveClientOptions); return new Int64Value { diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 288d4911..9766a64b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -18,10 +18,16 @@ public static IMessage HandleCreate(DrivePhotosClientCreateRequest request, nint throw new UriFormatException("Base URL must end with a '/'"); } + var protonDriveClientOptions = new Sdk.ProtonDriveClientOptions( + request.ClientOptions.HasBindingsLanguage ? request.ClientOptions.BindingsLanguage : null, + request.ClientOptions.HasUid ? request.ClientOptions.Uid : null, + request.ClientOptions.HasApiCallTimeout ? request.ClientOptions.ApiCallTimeout : null, + request.ClientOptions.HasStorageCallTimeout ? request.ClientOptions.StorageCallTimeout : null); + var httpClientFactory = new InteropHttpClientFactory( bindingsHandle, request.BaseUrl, - request.BindingsLanguage, + protonDriveClientOptions.BindingsLanguage, new InteropFunction, nint, nint>(request.HttpClient.RequestFunction), new InteropAction, nint>(request.HttpClient.ResponseContentReadAction), new InteropAction(request.HttpClient.CancellationAction)); @@ -43,7 +49,7 @@ public static IMessage HandleCreate(DrivePhotosClientCreateRequest request, nint var featureFlagProvider = request.HasFeatureEnabledFunction ? new InteropFeatureFlagProvider(bindingsHandle, new InteropFunction, int>(request.FeatureEnabledFunction)) : AlwaysDisabledFeatureFlagProvider.Instance; - + var client = new ProtonPhotosClient( httpClientFactory, accountClient, @@ -51,7 +57,7 @@ public static IMessage HandleCreate(DrivePhotosClientCreateRequest request, nint secretCacheRepository, featureFlagProvider, telemetry, - request.Uid); + protonDriveClientOptions); return new Int64Value { diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index b880d5ba..851d829b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -20,8 +20,6 @@ public sealed class ProtonDriveClient { private const int MinDegreeOfBlockTransferParallelism = 2; private const int MaxDegreeOfBlockTransferParallelism = 6; - private const int DefaultApiTimeoutSeconds = 30; - private const int StorageApiTimeoutSeconds = 300; /// /// Creates a new instance of . @@ -31,8 +29,8 @@ public sealed class ProtonDriveClient /// If no UID is not provided, one will be generated for the duration of this instance. public ProtonDriveClient(ProtonApiSession session, string? uid = null) : this( - session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(DefaultApiTimeoutSeconds)), - session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(StorageApiTimeoutSeconds), TimeSpan.FromSeconds(StorageApiTimeoutSeconds)), + session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonDriveDefaults.DefaultApiTimeoutSeconds)), + session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonDriveDefaults.StorageApiTimeoutSeconds), TimeSpan.FromSeconds(ProtonDriveDefaults.StorageApiTimeoutSeconds)), new AccountClientAdapter(session), new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), session.ClientConfiguration.FeatureFlagProvider, @@ -48,16 +46,15 @@ public ProtonDriveClient( ICacheRepository secretCacheRepository, IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, - string? bindingsLanguage = null, - string? uid = null) + ProtonDriveClientOptions? creationParameters = null) : this( - new SdkHttpClientFactoryDecorator(httpClientFactory, bindingsLanguage).CreateClientWithTimeout(DefaultApiTimeoutSeconds), - new SdkHttpClientFactoryDecorator(httpClientFactory, bindingsLanguage).CreateClientWithTimeout(StorageApiTimeoutSeconds), + new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout(creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonDriveDefaults.DefaultApiTimeoutSeconds), + new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout(creationParameters?.OverrideStorageApiTimeoutSeconds ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), featureFlagProvider, telemetry, - uid ?? Guid.NewGuid().ToString()) + creationParameters?.Uid ?? Guid.NewGuid().ToString()) { } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs new file mode 100644 index 00000000..5358a565 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk; + +public record struct ProtonDriveClientOptions( + string? BindingsLanguage, + string? Uid, + int? OverrideDefaultApiTimeoutSeconds, + int? OverrideStorageApiTimeoutSeconds); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs index 802a21a9..e75776bb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs @@ -3,4 +3,7 @@ internal static class ProtonDriveDefaults { public const string DriveBaseRoute = "drive/"; + + public const int DefaultApiTimeoutSeconds = 30; + public const int StorageApiTimeoutSeconds = 300; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs index 9edb8b34..a40c6107 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Proton.Drive.Sdk; +using Proton.Drive.Sdk.Http; using Proton.Drive.Sdk.Nodes; using Proton.Photos.Sdk.Api; using Proton.Photos.Sdk.Caching; @@ -13,15 +14,13 @@ namespace Proton.Photos.Sdk; public sealed class ProtonPhotosClient : IDisposable { - private const int ApiTimeoutSeconds = 20; - private readonly HttpClient _httpClient; public ProtonPhotosClient(ProtonApiSession session, string? uid = null) { DriveClient = new ProtonDriveClient(session, uid); - _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ApiTimeoutSeconds)); + _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonDriveDefaults.DefaultApiTimeoutSeconds)); Cache = new PhotosClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository); PhotosApi = new PhotosApiClient(_httpClient); @@ -34,7 +33,7 @@ public ProtonPhotosClient( ICacheRepository secretCacheRepository, IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, - string? uid = null) + ProtonDriveClientOptions? creationParameters = null) { DriveClient = new ProtonDriveClient( httpClientFactory, @@ -43,9 +42,9 @@ public ProtonPhotosClient( secretCacheRepository, featureFlagProvider, telemetry, - uid); + creationParameters); - _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClient(); + _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout(creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonDriveDefaults.DefaultApiTimeoutSeconds); Cache = new PhotosClientCache(entityCacheRepository, secretCacheRepository); PhotosApi = new PhotosApiClient(_httpClient); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 138d548f..a54ff8d5 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -178,37 +178,43 @@ message HttpClient { int64 cancellation_action = 3; // C signature: void handle_http_cancellation(intptr_t bindings_operation_handle); } +message ProtonDriveClientOptions { + string bindings_language = 1; // Optional + + // Client UID, optional + // If a null value is provided, the SDK automatically generates a UUID during initialization + string uid = 2; + + int32 api_call_timeout = 3; // Optional + int32 storage_call_timeout = 4; // Optional +} + // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. message DriveClientCreateRequest { string base_url = 1; - string bindings_language = 2; // Optional - - HttpClient http_client = 3; + HttpClient http_client = 2; + ProtonDriveClientOptions client_options = 3; // Optional // Pointer to C function that will be called: // void handle_account_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) // bindings_handle: handle for the bindings // account_request: Protobuf message of type proton.drive.sdk.AccountRequest carrying the request data // sdk_handle: handle for the SDK - int64 account_request_action = 6; - - string entity_cache_path = 7; // Optional - string secret_cache_path = 8; // Optional - - proton.sdk.Telemetry telemetry = 9; // Optional + int64 account_request_action = 4; - // Client UID, optional - // If a null value is provided, the SDK automatically generates a UUID during initialization - string uid = 10; + string entity_cache_path = 5; // Optional + string secret_cache_path = 6; // Optional + proton.sdk.Telemetry telemetry = 7; // Optional + // Pointer to C function that will be called to check feature flags: // int is_feature_flag_enabled(intptr_t bindings_handle, ByteArray flag_name) // bindings_handle: handle for the bindings // flag_name: UTF-8 encoded feature flag name // returns: 0 for disabled, non-zero for enabled - int64 feature_enabled_function = 11; // Optional + int64 feature_enabled_function = 8; // Optional - bytes secret_cache_encryption_key = 12; // Optional + bytes secret_cache_encryption_key = 9; // Optional } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. @@ -323,11 +329,11 @@ message DriveClientRenameRequest { // The response message must be of type FolderNode message DriveClientCreateFolderRequest { - int64 client_handle = 1; - string parent_folder_uid = 2; - string folder_name = 3; - google.protobuf.Timestamp last_modification_time = 4; // Optional - int64 cancellation_token_source_handle = 5; + int64 client_handle = 1; + string parent_folder_uid = 2; + string folder_name = 3; + google.protobuf.Timestamp last_modification_time = 4; // Optional + int64 cancellation_token_source_handle = 5; } // The response must not have a value. @@ -430,32 +436,27 @@ message DownloadControllerFreeRequest { message DrivePhotosClientCreateRequest { string base_url = 1; - string bindings_language = 2; // Optional - - HttpClient http_client = 3; + HttpClient http_client = 2; + ProtonDriveClientOptions client_options = 3; // Optional // Pointer to C function that will be called: // void handle_account_request(intptr_t bindings_handle, ByteArray http_request, intptr_t sdk_handle) // bindings_handle: handle for the bindings // account_request: Protobuf message of type proton.drive.sdk.AccountRequest carrying the request data // sdk_handle: handle for the SDK - int64 account_request_action = 6; - - string entity_cache_path = 7; // Optional - string secret_cache_path = 8; // Optional + int64 account_request_action = 4; - proton.sdk.Telemetry telemetry = 9; // Optional + string entity_cache_path = 5; // Optional + string secret_cache_path = 6; // Optional - // Client UID, optional - // If a null value is provided, the SDK automatically generates a UUID during initialization - string uid = 10; + proton.sdk.Telemetry telemetry = 7; // Optional // Pointer to C function that will be called to check feature flags: // int is_feature_flag_enabled(intptr_t bindings_handle, ByteArray flag_name) // bindings_handle: handle for the bindings // flag_name: UTF-8 encoded feature flag name // returns: 0 for disabled, non-zero for enabled - int64 feature_enabled_function = 11; // Optional + int64 feature_enabled_function = 8; // Optional } message DrivePhotosClientCreateFromSessionRequest { diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index c9b5b05a..ec31ef90 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -44,7 +44,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { accountClient: AccountClientProtocol, logCallback: @escaping LogCallback, recordMetricEventCallback: @escaping RecordMetricEventCallback, - featureFlagProviderCallback: @escaping FeatureFlagProviderCallback + featureFlagProviderCallback: @escaping FeatureFlagProviderCallback, ) async throws { self.logger = try await Logger(logCallback: logCallback) self.recordMetricEventCallback = recordMetricEventCallback @@ -57,8 +57,6 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { let clientCreateRequest = Proton_Drive_Sdk_DriveClientCreateRequest.with { $0.baseURL = configuration.baseURL - $0.uid = configuration.clientUID - $0.accountRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) $0.httpClient = Proton_Drive_Sdk_HttpClient.with { httpClient in @@ -80,6 +78,16 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { if let secretCachePath = configuration.secretCachePath { $0.secretCachePath = secretCachePath } + + $0.clientOptions = Proton_Drive_Sdk_ProtonDriveClientOptions.with { + $0.uid = configuration.clientUID + if let httpApiCallsTimeout = configuration.httpApiCallsTimeout { + $0.apiCallTimeout = httpApiCallsTimeout + } + if let httpStorageCallsTimeout = configuration.httpStorageCallsTimeout { + $0.storageCallTimeout = httpStorageCallsTimeout + } + } } // we pass the weak reference as the state because we don't want the interop layer diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift index 2ea9efcc..cc995e1b 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift @@ -26,6 +26,9 @@ public struct ProtonDriveClientConfiguration: Sendable { let clientUID: String let httpTransferBufferSize: Int // Used for establishing buffer for http streams + let httpApiCallsTimeout: Int32? + let httpStorageCallsTimeout: Int32? + let downloadOperationalResilience: OperationalResilience let uploadOperationalResilience: OperationalResilience @@ -39,16 +42,20 @@ public struct ProtonDriveClientConfiguration: Sendable { baseURL: String, clientUID: String, httpTransferBufferSize: Int = defaultHttpTransportBufferSize, + httpApiCallsTimeout: Int32? = nil, // if not set, default value from SDK is used + httpStorageCallsTimeout: Int32? = nil, // if not set, default value from SDK is used downloadOperationalResilience: OperationalResilience = BasicOperationalResilience.default, uploadOperationalResilience: OperationalResilience = BasicOperationalResilience.default, boundStreamsCreator: @Sendable @escaping () throws -> (InputStream, OutputStream, Int) = defaultBoundStreamsCreator, downloadStreamCreator: @Sendable @escaping (URLSession.AsyncBytes) -> AnyAsyncSequence = defaultDownloadStreamCreator, - entityCachePath: String? = nil, - secretCachePath: String? = nil + entityCachePath: String? = nil, // if not set, in-memory cache is used + secretCachePath: String? = nil // if not set, in-memory cache is used ) { self.baseURL = baseURL self.clientUID = clientUID self.httpTransferBufferSize = httpTransferBufferSize + self.httpApiCallsTimeout = httpApiCallsTimeout + self.httpStorageCallsTimeout = httpStorageCallsTimeout self.downloadOperationalResilience = downloadOperationalResilience self.uploadOperationalResilience = uploadOperationalResilience self.boundStreamsCreator = boundStreamsCreator diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 0c4a860b..0a60e478 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -14,9 +14,9 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { let featureFlagProviderCallback: FeatureFlagProviderCallback public init( - accountClient: AccountClientProtocol, configuration: ProtonDriveClientConfiguration, httpClient: HttpClientProtocol, + accountClient: AccountClientProtocol, logCallback: @escaping LogCallback, featureFlagProviderCallback: @escaping FeatureFlagProviderCallback, recordMetricEventCallback: @escaping RecordMetricEventCallback @@ -51,9 +51,17 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { $0.recordMetricAction = Int64(ObjectHandle(callback: cCompatibleTelemetryRecordMetricCallback)) } - $0.uid = configuration.clientUID - $0.featureEnabledFunction = Int64(ObjectHandle(callback: cCompatibleFeatureFlagProviderCallback)) + + $0.clientOptions = Proton_Drive_Sdk_ProtonDriveClientOptions.with { + $0.uid = configuration.clientUID + if let httpApiCallsTimeout = configuration.httpApiCallsTimeout { + $0.apiCallTimeout = httpApiCallsTimeout + } + if let httpStorageCallsTimeout = configuration.httpStorageCallsTimeout { + $0.storageCallTimeout = httpStorageCallsTimeout + } + } } // we pass the weak reference as the state because we don't want the interop layer From d3a2089a785208995ca1bd84a0c7b947e26271d2 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 17:30:55 +0100 Subject: [PATCH 446/791] Fix download photos from album --- .../Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs | 4 ++-- .../Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs | 6 ++++-- .../Nodes/PhotoDtoToMetadataConverter.cs | 11 +++++++++++ .../Proton.Photos.Sdk/Nodes/PhotosDownloader.cs | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 409b52ba..63bd54cd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -82,8 +82,8 @@ public static async ValueTask> Co if (folderDto is null) { - // FIXME: handle missing file information with degraded node - throw new InvalidOperationException("Node is a file, but file properties are missing"); + // FIXME: handle missing folder information with degraded node + throw new InvalidOperationException("Node is a folder, but folder properties are missing"); } var uid = new NodeUid(volumeId, linkDto.Id); diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs index 8e388263..a43b176d 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs @@ -9,14 +9,16 @@ internal sealed class PhotoLinkDetailsDto public required LinkDto Link { get; init; } public PhotoDto? Photo { get; init; } public FolderDto? Album { get; init; } + public FolderDto? Folder { get; init; } public LinkSharingDto? Sharing { get; init; } public ShareMembershipSummaryDto? Membership { get; init; } - public void Deconstruct(out LinkDto link, out PhotoDto? photo, out FolderDto? album, out LinkSharingDto? sharing, out ShareMembershipSummaryDto? membership) + public void Deconstruct(out LinkDto link, out PhotoDto? photo, out FolderDto? album, out FolderDto? folder, out LinkSharingDto? sharing, out ShareMembershipSummaryDto? membership) { link = Link; photo = Photo; album = Album; + folder = Folder; sharing = Sharing; membership = Membership; } @@ -26,7 +28,7 @@ public LinkDetailsDto ToLinkDetailsDto() return new LinkDetailsDto { Link = Link, - Folder = Album, + Folder = Folder ?? Album, File = Photo, Sharing = Sharing, Membership = Membership, diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs index ccabef0d..adf9517c 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs @@ -82,6 +82,17 @@ private static async Task> ConvertDto cancellationToken).ConfigureAwait(false)) .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), + LinkType.Folder => + (await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( + client.DriveClient.Account, + client.Cache.Entities, + client.Cache.Secrets, + volumeId, + linkDetailsDto, + parentKeyResult, + cancellationToken).ConfigureAwait(false)) + .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), + _ => throw new NotSupportedException($"Link type {linkType} is not supported."), }; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs index 4f2fd0d9..ebc20729 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs @@ -61,7 +61,7 @@ internal static async ValueTask CreateAsync(ProtonPhotosClient private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { - var result = await _client.DriveClient.GetNodeAsync(_photoUid, cancellationToken).ConfigureAwait(false); + var result = await _client.GetNodeAsync(_photoUid, cancellationToken).ConfigureAwait(false); if (result is null || !result.Value.TryGetValueElseError(out var node, out _) || node is not FileNode fileNode) { From 4c30ae0a32aeb900cf25e69cce5473f0f8539988 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 18:17:49 +0100 Subject: [PATCH 447/791] Update driveClientCreate to use ProtonDriveClientOptions and timeouts --- .../me/proton/drive/sdk/entity/ClientCreateRequest.kt | 2 ++ .../me/proton/drive/sdk/internal/JniDriveClient.kt | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt index 80703531..0704127c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt @@ -9,4 +9,6 @@ data class ClientCreateRequest( val loggerProvider: LoggerProvider, val bindingsLanguage: String? = null, val uid: String? = null, + val apiCallTimeout: Int? = null, + val storageCallTimeout: Int? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt index 10470b84..2b3957dd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt @@ -15,6 +15,7 @@ import proton.drive.sdk.driveClientCreateFromSessionRequest import proton.drive.sdk.driveClientCreateRequest import proton.drive.sdk.driveClientFreeRequest import proton.drive.sdk.httpClient +import proton.drive.sdk.protonDriveClientOptions import proton.drive.sdk.request import proton.sdk.ProtonSdk import proton.sdk.ProtonSdk.HttpResponse @@ -66,8 +67,12 @@ class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { recordMetricAction = ProtonDriveSdkNativeClient.getRecordMetricPointer() } featureEnabledFunction = ProtonDriveSdkNativeClient.getFeatureEnabledPointer() - request.bindingsLanguage?.let { bindingsLanguage = it } - request.uid?.let { uid = it } + clientOptions = protonDriveClientOptions { + request.bindingsLanguage?.let { bindingsLanguage = it } + request.uid?.let { uid = it } + request.apiCallTimeout?.let { apiCallTimeout = it } + request.storageCallTimeout?.let { storageCallTimeout = it } + } } } }) From 9480ac5ea05c724c63f9d1bafe87c0583775c85d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 10:00:57 +0100 Subject: [PATCH 448/791] Improve on-disk cache handling --- .../InteropMessageHandler.cs | 6 - .../InteropProtonDriveClient.cs | 20 -- .../Caching/IDriveEntityCache.cs | 2 - .../Caching/IDriveSecretCache.cs | 2 - .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 14 -- .../Caching/EncryptedCacheRepository.cs | 29 ++- .../Caching/SqliteCacheRepository.cs | 195 ++++++++++++++---- cs/sdk/src/protos/proton.drive.sdk.proto | 19 +- .../ProtonDriveClientConfiguration.swift | 7 +- 9 files changed, 190 insertions(+), 104 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 419be9ab..a147e9db 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -46,12 +46,6 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientCreateFolder => await InteropProtonDriveClient.HandleCreateFolderAsync(request.DriveClientCreateFolder).ConfigureAwait(false), - Request.PayloadOneofCase.DriveClientClearSecrets - => await InteropProtonDriveClient.HandleClearSecretsAsync(request.DriveClientClearSecrets).ConfigureAwait(false), - - Request.PayloadOneofCase.DriveClientClearEntityCache - => await InteropProtonDriveClient.HandleClearEntityCacheAsync(request.DriveClientClearEntityCache).ConfigureAwait(false), - Request.PayloadOneofCase.DriveClientGetThumbnails => await InteropProtonDriveClient.HandleGetThumbnailsAsync(request.DriveClientGetThumbnails).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index e69e37b6..449e54c0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -212,26 +212,6 @@ await client.RenameNodeAsync( return null; } - public static async ValueTask HandleClearSecretsAsync(DriveClientClearSecretsRequest request) - { - var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - - var client = Interop.GetFromHandle(request.ClientHandle); - await client.ClearSecretsAsync(cancellationToken).ConfigureAwait(false); - - return new Empty(); - } - - public static async ValueTask HandleClearEntityCacheAsync(DriveClientClearEntityCacheRequest request) - { - var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - - var client = Interop.GetFromHandle(request.ClientHandle); - await client.ClearEntityCacheAsync(cancellationToken).ConfigureAwait(false); - - return new Empty(); - } - public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNodesRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index 3495e7b4..8230ec90 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -22,6 +22,4 @@ internal interface IDriveEntityCache : IEntityCache ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken); - - ValueTask ClearAsync(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs index 614ebd19..59542613 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -21,6 +21,4 @@ ValueTask SetFolderSecretsAsync( ValueTask SetFileSecretsAsync(NodeUid nodeId, Result secretsProvisionResult, CancellationToken cancellationToken); ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); - - ValueTask ClearAsync(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 851d829b..b45675ad 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -240,20 +240,6 @@ public ValueTask EmptyTrashAsync(CancellationToken cancellationToken) return VolumeOperations.EmptyTrashAsync(this, cancellationToken); } - // TODO: This should be reworked; ProtonDriveClient should not be responsible for clearing secrets. - // For more context, please check https://gitlab.protontech.ch/drive/sdk/-/merge_requests/642#note_3175380. - public ValueTask ClearSecretsAsync(CancellationToken cancellationToken = default) - { - return Cache.Secrets.ClearAsync(); - } - - // TODO: This should be reworked; ProtonDriveClient should not be responsible for clearing cache. - // For more context, please check https://gitlab.protontech.ch/drive/sdk/-/merge_requests/642#note_3175380. - public ValueTask ClearEntityCacheAsync(CancellationToken cancellationToken = default) - { - return Cache.Entities.ClearAsync(); - } - private async ValueTask GetFileUploaderAsync( IRevisionDraftProvider revisionDraftProvider, long size, diff --git a/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs index 56ed3ebe..e7327aa2 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs @@ -43,7 +43,18 @@ public ValueTask ClearAsync() { var encryptedValue = await _inner.TryGetAsync(key, cancellationToken).ConfigureAwait(false); - return encryptedValue is not null ? Decrypt(key, encryptedValue) : null; + try + { + return encryptedValue is not null ? Decrypt(key, encryptedValue) : null; + } + catch (AuthenticationTagMismatchException) + { + // If the tag is invalid, we assume either the cache has been tampered with or the + // encryption key has changed. Clear the cache and behave as if we had no value in cache. + await _inner.ClearAsync().ConfigureAwait(false); + } + + return null; } public async IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync( @@ -52,7 +63,21 @@ public ValueTask ClearAsync() { await foreach (var (key, encryptedValue) in _inner.GetByTagsAsync(tags, cancellationToken).ConfigureAwait(false)) { - yield return (key, Decrypt(key, encryptedValue)); + string decryptedValue; + + try + { + decryptedValue = Decrypt(key, encryptedValue); + } + catch (AuthenticationTagMismatchException) + { + // If the tag is invalid, we assume either the cache has been tampered with or the + // encryption key has changed. Clear the cache and behave as if we had no value in cache. + await _inner.ClearAsync().ConfigureAwait(false); + yield break; + } + + yield return (key, decryptedValue); } } diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index c6d4242a..ae07d77c 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -6,13 +6,15 @@ namespace Proton.Sdk.Caching; public sealed class SqliteCacheRepository : ICacheRepository, IDisposable { private readonly SqliteConnection _connection; + private readonly int? _maxCacheSize; - private SqliteCacheRepository(SqliteConnection connection) + private SqliteCacheRepository(SqliteConnection connection, int? maxCacheSize) { _connection = connection; + _maxCacheSize = maxCacheSize; } - public static SqliteCacheRepository OpenInMemory() + public static SqliteCacheRepository OpenInMemory(int? maxCacheSize = 1024) { var connectionStringBuilder = new SqliteConnectionStringBuilder { @@ -21,17 +23,17 @@ public static SqliteCacheRepository OpenInMemory() Cache = SqliteCacheMode.Shared, }; - return Open(connectionStringBuilder); + return Open(connectionStringBuilder, maxCacheSize); } - public static SqliteCacheRepository OpenFile(string path) + public static SqliteCacheRepository OpenFile(string path, int? maxCacheSize = 1024) { var connectionStringBuilder = new SqliteConnectionStringBuilder { DataSource = path, }; - return Open(connectionStringBuilder); + return Open(connectionStringBuilder, maxCacheSize); } ValueTask ICacheRepository.SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) @@ -121,18 +123,44 @@ public void Set(string key, string value, IEnumerable tags) using var transaction = connection.BeginTransaction(deferred: true); + // Check if eviction is needed (if LRU is enabled) + if (_maxCacheSize.HasValue) + { + var currentSize = GetCacheSize(connection, transaction); + + if (currentSize >= _maxCacheSize.Value) + { + // Check if key already exists (updates don't need eviction) + using var checkCommand = connection.CreateCommand(); + checkCommand.Transaction = transaction; + checkCommand.CommandText = "SELECT 1 FROM Entries WHERE Key = @key"; + checkCommand.Parameters.AddWithValue("@key", key); + var exists = checkCommand.ExecuteScalar() != null; + + if (!exists) + { + // Evict 25% of cache or at least 1 item + var evictionCount = Math.Max(1, _maxCacheSize.Value / 4); + EvictLeastRecentlyUsed(connection, transaction, evictionCount); + } + } + } + using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = """ - INSERT INTO Entries (Key, Value) - VALUES (@key, @value) - ON CONFLICT (Key) DO UPDATE SET Value = @value + INSERT INTO Entries (Key, Value, LastAccessedUtc) + VALUES (@key, @value, @timestamp) + ON CONFLICT (Key) DO UPDATE SET + Value = @value, + LastAccessedUtc = @timestamp """; command.Parameters.AddWithValue("@key", key); command.Parameters.AddWithValue("@value", value); + command.Parameters.AddWithValue("@timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); command.ExecuteNonQuery(); @@ -199,54 +227,97 @@ public void Clear() using var connection = new SqliteConnection(_connection.ConnectionString); connection.Open(); + using var transaction = connection.BeginTransaction(deferred: true); using var command = connection.CreateCommand(); + command.Transaction = transaction; + // Read value command.CommandText = "SELECT Value FROM Entries WHERE Key = @key"; command.Parameters.AddWithValue("@key", key); - var reader = command.ExecuteReader(); - return reader.Read() ? reader.GetFieldValue("Value") : null; + if (!reader.Read()) + { + return null; + } + + var value = reader.GetFieldValue("Value"); + reader.Close(); + + // Update timestamp + command.CommandText = "UPDATE Entries SET LastAccessedUtc = @timestamp WHERE Key = @key"; + command.Parameters.Clear(); + command.Parameters.AddWithValue("@timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("@key", key); + command.ExecuteNonQuery(); + + transaction.Commit(); + return value; } public IEnumerable<(string Key, string Value)> GetByTags(IEnumerable tags) { using var connection = new SqliteConnection(_connection.ConnectionString); - connection.Open(); - using var command = connection.CreateCommand(); - - command.Connection = connection; + // Collect all matching entries first (existing query logic) + var results = new List<(string Key, string Value)>(); - var i = 0; - foreach (var tag in tags) + using (var command = connection.CreateCommand()) { - command.Parameters.AddWithValue($"@tag{i++}", tag); + command.Connection = connection; + + var i = 0; + foreach (var tag in tags) + { + command.Parameters.AddWithValue($"@tag{i++}", tag); + } + + var inClause = string.Join(", ", command.Parameters.Cast().Select(x => x.ParameterName)); + + command.CommandText = + $""" + SELECT Key, Value + FROM Entries + WHERE Key IN ( + SELECT t.Key + FROM Tags t + WHERE t.Tag IN ({inClause}) + GROUP BY t.Key + HAVING COUNT(DISTINCT t.Tag) = @tagCount + ); + """; + + command.Parameters.AddWithValue("@tagCount", command.Parameters.Count); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + results.Add((reader.GetString(0), reader.GetString(1))); + } } - var inClause = string.Join(", ", command.Parameters.Cast().Select(x => x.ParameterName)); + // Batch update timestamps for all returned keys + if (results.Count > 0) + { + using var updateCommand = connection.CreateCommand(); + var keyParams = string.Join(",", Enumerable.Range(0, results.Count).Select(i => $"@key{i}")); + updateCommand.CommandText = + $"UPDATE Entries SET LastAccessedUtc = @timestamp WHERE Key IN ({keyParams})"; + + updateCommand.Parameters.AddWithValue("@timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + for (int i = 0; i < results.Count; i++) + { + updateCommand.Parameters.AddWithValue($"@key{i}", results[i].Key); + } + + updateCommand.ExecuteNonQuery(); + } - command.CommandText = - $""" - SELECT Key, Value - FROM Entries - WHERE Key IN ( - SELECT t.Key - FROM Tags t - WHERE t.Tag IN ({inClause}) - GROUP BY t.Key - HAVING COUNT(DISTINCT t.Tag) = @tagCount - ); - """; - - command.Parameters.AddWithValue("@tagCount", command.Parameters.Count); - - using var reader = command.ExecuteReader(); - - while (reader.Read()) + // Return via yield + foreach (var result in results) { - yield return (reader.GetString(0), reader.GetString(1)); + yield return result; } } @@ -257,8 +328,39 @@ public void Dispose() _connection.Dispose(); } - private static SqliteCacheRepository Open(SqliteConnectionStringBuilder connectionStringBuilder) + private static int GetCacheSize(SqliteConnection connection, SqliteTransaction transaction) + { + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "SELECT COUNT(*) FROM Entries"; + return Convert.ToInt32(command.ExecuteScalar()); + } + + private static void EvictLeastRecentlyUsed(SqliteConnection connection, SqliteTransaction transaction, int count) + { + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + """ + DELETE FROM Entries + WHERE Key IN ( + SELECT Key + FROM Entries + ORDER BY LastAccessedUtc ASC + LIMIT @count + ) + """; + command.Parameters.AddWithValue("@count", count); + command.ExecuteNonQuery(); + } + + private static SqliteCacheRepository Open(SqliteConnectionStringBuilder connectionStringBuilder, int? maxCacheSize) { + if (maxCacheSize is not null && maxCacheSize.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxCacheSize), "Max cache size must be greater than 0 or null to disable LRU."); + } + var connectionString = connectionStringBuilder.ConnectionString; var connection = new SqliteConnection(connectionString); @@ -269,7 +371,7 @@ private static SqliteCacheRepository Open(SqliteConnectionStringBuilder connecti InitializeDatabase(connection); - return new SqliteCacheRepository(connection); + return new SqliteCacheRepository(connection, maxCacheSize); } catch { @@ -318,5 +420,20 @@ PRIMARY KEY (Tag) """; command.ExecuteNonQuery(); + + command.CommandText = "PRAGMA user_version"; + var currentVersion = Convert.ToInt32(command.ExecuteScalar()); + + if (currentVersion < 1) + { + command.CommandText = "ALTER TABLE Entries ADD COLUMN LastAccessedUtc INTEGER NOT NULL DEFAULT 0"; + command.ExecuteNonQuery(); + + command.CommandText = "CREATE INDEX IF NOT EXISTS idx_entries_last_accessed ON Entries(LastAccessedUtc)"; + command.ExecuteNonQuery(); + + command.CommandText = "PRAGMA user_version = 1"; + command.ExecuteNonQuery(); + } } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index a54ff8d5..e53fc676 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -21,11 +21,6 @@ message Request { DriveClientCreateFolderRequest drive_client_create_folder = 1009; DriveClientTrashNodesRequest drive_client_trash_nodes = 1010; - // These are also in the DriveClient but we aim to remove them in the future, - // so they have a significant gap to not disturb other payload numbers. - DriveClientClearSecretsRequest drive_client_clear_secrets = 1030; - DriveClientClearEntityCacheRequest drive_client_clear_entity_cache = 1031; - UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; FileUploaderFreeRequest file_uploader_free = 1102; @@ -214,6 +209,8 @@ message DriveClientCreateRequest { // returns: 0 for disabled, non-zero for enabled int64 feature_enabled_function = 8; // Optional + // Encryption key used to encrypt the secrets cache. Must be a 32-byte + // random key. If a null value is provided, secrets cache won't be encrypted. bytes secret_cache_encryption_key = 9; // Optional } @@ -336,18 +333,6 @@ message DriveClientCreateFolderRequest { int64 cancellation_token_source_handle = 5; } -// The response must not have a value. -message DriveClientClearSecretsRequest { - int64 client_handle = 1; - int64 cancellation_token_source_handle = 2; -} - -// The response must not have a value. -message DriveClientClearEntityCacheRequest { - int64 client_handle = 1; - int64 cancellation_token_source_handle = 2; -} - // The response message must be of type TrashNodesResponse message DriveClientTrashNodesRequest { int64 client_handle = 1; diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift index cc995e1b..3ac9a5dd 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift @@ -34,7 +34,8 @@ public struct ProtonDriveClientConfiguration: Sendable { let entityCachePath: String? let secretCachePath: String? - + let secretCacheEncryptionKey: Data? + let boundStreamsCreator: @Sendable () throws -> (InputStream, OutputStream, Int) let downloadStreamCreator: @Sendable (URLSession.AsyncBytes) -> AnyAsyncSequence @@ -49,7 +50,8 @@ public struct ProtonDriveClientConfiguration: Sendable { boundStreamsCreator: @Sendable @escaping () throws -> (InputStream, OutputStream, Int) = defaultBoundStreamsCreator, downloadStreamCreator: @Sendable @escaping (URLSession.AsyncBytes) -> AnyAsyncSequence = defaultDownloadStreamCreator, entityCachePath: String? = nil, // if not set, in-memory cache is used - secretCachePath: String? = nil // if not set, in-memory cache is used + secretCachePath: String? = nil, // if not set, in-memory cache is used + secretCacheEncryptionKey: Data? = nil // if not set, no encryption will be used for secrets cache ) { self.baseURL = baseURL self.clientUID = clientUID @@ -62,5 +64,6 @@ public struct ProtonDriveClientConfiguration: Sendable { self.downloadStreamCreator = downloadStreamCreator self.entityCachePath = entityCachePath self.secretCachePath = secretCachePath + self.secretCacheEncryptionKey = secretCacheEncryptionKey } } From 06beef90a39570adb624b391b10721482a6b15fb Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 11:24:58 +0100 Subject: [PATCH 449/791] Propagate encryption key via client configuration in swift bindings --- .../Sources/Client/ProtonDriveClient/ProtonDriveClient.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index ec31ef90..e1977194 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -78,6 +78,9 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { if let secretCachePath = configuration.secretCachePath { $0.secretCachePath = secretCachePath } + if let secretCacheEncryptionKey = configuration.secretCacheEncryptionKey { + $0.secretCacheEncryptionKey = secretCacheEncryptionKey + } $0.clientOptions = Proton_Drive_Sdk_ProtonDriveClientOptions.with { $0.uid = configuration.clientUID From b42f416996a8d560e4790dc0e2b0c94943326c80 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 12:38:19 +0000 Subject: [PATCH 450/791] Adding Photos SDK bindings --- .../drive/sdk/CommonDownloadController.kt | 68 +++++++++++++++ .../me/proton/drive/sdk/DownloadController.kt | 68 ++------------- .../kotlin/me/proton/drive/sdk/Downloader.kt | 69 +--------------- .../me/proton/drive/sdk/FileDownloader.kt | 77 +++++++++++++++++ .../me/proton/drive/sdk/PhotosDownloader.kt | 81 ++++++++++++++++++ .../{DriveClient.kt => ProtonDriveClient.kt} | 13 ++- .../me/proton/drive/sdk/ProtonDriveSdk.kt | 39 +++++++-- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 31 +++++++ .../kotlin/me/proton/drive/sdk/Uploader.kt | 6 +- ...{JniDownloader.kt => JniFileDownloader.kt} | 2 +- .../drive/sdk/internal/JniPhotosDownloader.kt | 63 ++++++++++++++ ...DriveClient.kt => JniProtonDriveClient.kt} | 2 +- .../sdk/internal/JniProtonPhotosClient.kt | 82 +++++++++++++++++++ 13 files changed, 455 insertions(+), 146 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/{DriveClient.kt => ProtonDriveClient.kt} (91%) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/{JniDownloader.kt => JniFileDownloader.kt} (96%) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/{JniDriveClient.kt => JniProtonDriveClient.kt} (98%) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt new file mode 100644 index 00000000..132cd9bd --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -0,0 +1,68 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.internal.CoroutineScopeConsumer +import me.proton.drive.sdk.internal.JniDownloadController +import me.proton.drive.sdk.internal.toLogId + +class CommonDownloadController internal constructor( + downloader: SdkNode, + internal val handle: Long, + private val bridge: JniDownloadController, + private val coroutineScopeConsumer: CoroutineScopeConsumer, + override val cancellationTokenSource: CancellationTokenSource, +) : SdkNode(downloader), DownloadController { + + val isPausedFlow = MutableStateFlow(false) + + override suspend fun awaitCompletion() { + log(DEBUG, "await completion") + runCatching { + isPaused() + bridge.awaitCompletion(handle) + }.onSuccess { + log(INFO, "completed") + }.onFailure { + log(INFO, "cancelled or failed") + isPaused() + }.getOrThrow() + } + + override suspend fun resume(coroutineScope: CoroutineScope) { + log(INFO, "resume") + coroutineScopeConsumer(coroutineScope) + bridge.resume(handle).also { isPaused() } + } + + override suspend fun pause() { + log(INFO, "pause") + bridge.pause(handle).also { isPaused() } + coroutineScopeConsumer(null) + } + + override suspend fun isPaused() = bridge.isPaused(handle) + .also { isPausedFlow.emit(it) } + + override suspend fun isDownloadCompleteWithVerificationIssue(): Boolean { + log(DEBUG, "isDownloadCompleteWithVerificationIssue") + return bridge.isDownloadCompleteWithVerificationIssue(handle) + } + + override fun close() { + log(DEBUG, "close") + bridge.free(handle) + super.close() + } + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "CommonDownloadController(${handle.toLogId()}) $message") + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index 3940af42..d4462e12 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -1,68 +1,12 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import me.proton.drive.sdk.LoggerProvider.Level.DEBUG -import me.proton.drive.sdk.LoggerProvider.Level.INFO -import me.proton.drive.sdk.internal.CoroutineScopeConsumer -import me.proton.drive.sdk.internal.JniDownloadController -import me.proton.drive.sdk.internal.toLogId -class DownloadController internal constructor( - downloader: Downloader, - internal val handle: Long, - private val bridge: JniDownloadController, - private val coroutineScopeConsumer: CoroutineScopeConsumer, - override val cancellationTokenSource: CancellationTokenSource, -) : SdkNode(downloader), AutoCloseable, Cancellable { +interface DownloadController : AutoCloseable, Cancellable { - val isPausedFlow = MutableStateFlow(false) - - suspend fun awaitCompletion() { - log(DEBUG, "await completion") - return runCatching { - isPaused() - bridge.awaitCompletion(handle) - }.onSuccess { - log(INFO, "completed") - }.onFailure { - log(INFO, "cancelled or failed") - isPaused() - }.getOrThrow() - } - - suspend fun resume(coroutineScope: CoroutineScope) { - log(INFO, "resume") - coroutineScopeConsumer(coroutineScope) - bridge.resume(handle).also { isPaused() } - } - - suspend fun pause() { - log(INFO, "pause") - bridge.pause(handle).also { isPaused() } - coroutineScopeConsumer(null) - } - - suspend fun isPaused() = bridge.isPaused(handle) - .also { isPausedFlow.emit(it) } - - suspend fun isDownloadCompleteWithVerificationIssue(): Boolean { - log(DEBUG, "isDownloadCompleteWithVerificationIssue") - return bridge.isDownloadCompleteWithVerificationIssue(handle) - } - - override fun close() { - log(DEBUG, "close") - bridge.free(handle) - super.close() - } - - override suspend fun cancel() { - log(INFO, "cancel") - super.cancel() - } - - private fun log(level: LoggerProvider.Level, message: String) { - bridge.clientLogger(level, "DownloadController(${handle.toLogId()}) $message") - } + suspend fun awaitCompletion() + suspend fun pause() + suspend fun resume(coroutineScope: CoroutineScope) + suspend fun isPaused(): Boolean + suspend fun isDownloadCompleteWithVerificationIssue(): Boolean } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt index 73792945..56164789 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -1,78 +1,13 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import me.proton.drive.sdk.LoggerProvider.Level.DEBUG -import me.proton.drive.sdk.LoggerProvider.Level.INFO -import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource -import me.proton.drive.sdk.internal.JniDownloadController -import me.proton.drive.sdk.internal.JniDownloader -import me.proton.drive.sdk.internal.toLogId import java.io.OutputStream -import java.nio.channels.Channels -import java.util.concurrent.atomic.AtomicReference -class Downloader internal constructor( - client: DriveClient, - internal val handle: Long, - private val bridge: JniDownloader, - override val cancellationTokenSource: CancellationTokenSource -) : SdkNode(client), AutoCloseable, Cancellable { +interface Downloader : AutoCloseable, Cancellable { suspend fun downloadToStream( coroutineScope: CoroutineScope, outputStream: OutputStream, progress: suspend (Long, Long) -> Unit = { _, _ -> }, - ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> - log(INFO, "downloadToStream") - val coroutineScopeReference = AtomicReference(coroutineScope) - val channel = Channels.newChannel(outputStream) - val handle = bridge.downloadToStream( - handle = handle, - cancellationTokenSourceHandle = cancellationTokenSource.handle, - onWrite = channel::write, - onProgress = { progressUpdate -> - with(progressUpdate) { - bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") - progress(bytesCompleted, bytesInTotal) - } - }, - coroutineScopeProvider = coroutineScopeReference::get, - ) - DownloadController( - downloader = this@Downloader, - handle = handle, - bridge = JniDownloadController(), - cancellationTokenSource = cancellationTokenSource, - coroutineScopeConsumer = coroutineScopeReference::set, - ) - } - - override fun close() { - log(DEBUG, "close") - bridge.free(handle) - super.close() - } - - override suspend fun cancel() { - log(INFO, "cancel") - super.cancel() - } - - private fun log(level: LoggerProvider.Level, message: String) { - bridge.clientLogger(level, "FileDownloader(${handle.toLogId()}) $message") - } -} - -suspend fun DriveClient.downloader( - revisionUid: String -): Downloader = cancellationTokenSource().let { source -> - val client = this@downloader - JniDownloader().run { - Downloader( - client = client, - handle = create(handle, source.handle, revisionUid), - bridge = this, - cancellationTokenSource = source, - ) - } + ): DownloadController } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt new file mode 100644 index 00000000..c9547432 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -0,0 +1,77 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.internal.JniDownloadController +import me.proton.drive.sdk.internal.JniFileDownloader +import me.proton.drive.sdk.internal.toLogId +import java.io.OutputStream +import java.nio.channels.Channels +import java.util.concurrent.atomic.AtomicReference + +class FileDownloader internal constructor( + client: ProtonDriveClient, + internal val handle: Long, + private val bridge: JniFileDownloader, + override val cancellationTokenSource: CancellationTokenSource +) : SdkNode(client), Downloader { + + override suspend fun downloadToStream( + coroutineScope: CoroutineScope, + outputStream: OutputStream, + progress: suspend (Long, Long) -> Unit, + ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> + log(INFO, "downloadToStream") + val coroutineScopeReference = AtomicReference(coroutineScope) + val channel = Channels.newChannel(outputStream) + val handle = bridge.downloadToStream( + handle = handle, + cancellationTokenSourceHandle = cancellationTokenSource.handle, + onWrite = channel::write, + onProgress = { progressUpdate -> + with(progressUpdate) { + bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + progress(bytesCompleted, bytesInTotal) + } + }, + coroutineScopeProvider = coroutineScopeReference::get, + ) + CommonDownloadController( + downloader = this@FileDownloader, + handle = handle, + bridge = JniDownloadController(), + cancellationTokenSource = cancellationTokenSource, + coroutineScopeConsumer = coroutineScopeReference::set, + ) + } + + override fun close() { + log(DEBUG, "close") + bridge.free(handle) + super.close() + } + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "FileDownloader(${handle.toLogId()}) $message") + } +} + +suspend fun ProtonDriveClient.downloader( + revisionUid: String +): Downloader = cancellationTokenSource().let { source -> + JniFileDownloader().run { + FileDownloader( + client = this@downloader, + handle = create(handle, source.handle, revisionUid), + bridge = this, + cancellationTokenSource = source, + ) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt new file mode 100644 index 00000000..681119c1 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -0,0 +1,81 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.internal.JniDownloadController +import me.proton.drive.sdk.internal.JniPhotosDownloader +import me.proton.drive.sdk.internal.toLogId +import java.io.OutputStream +import java.nio.channels.Channels +import java.util.concurrent.atomic.AtomicReference + +class PhotosDownloader internal constructor( + client: ProtonPhotosClient, + internal val handle: Long, + private val bridge: JniPhotosDownloader, + override val cancellationTokenSource: CancellationTokenSource +) : SdkNode(client), Downloader { + + override suspend fun downloadToStream( + coroutineScope: CoroutineScope, + outputStream: OutputStream, + progress: suspend (Long, Long) -> Unit, + ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> + log(INFO, "downloadToStream") + val coroutineScopeReference = AtomicReference(coroutineScope) + val channel = Channels.newChannel(outputStream) + val handle = bridge.downloadToStream( + handle = handle, + cancellationTokenSourceHandle = cancellationTokenSource.handle, + onWrite = channel::write, + onProgress = { progressUpdate -> + with(progressUpdate) { + bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + progress(bytesCompleted, bytesInTotal) + } + }, + coroutineScopeProvider = coroutineScopeReference::get, + ) + CommonDownloadController( + downloader = this@PhotosDownloader, + handle = handle, + bridge = JniDownloadController(), + cancellationTokenSource = cancellationTokenSource, + coroutineScopeConsumer = coroutineScopeReference::set, + ) + } + + override fun close() { + log(DEBUG, "close") + bridge.free(handle) + super.close() + } + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "PhotosDownloader(${handle.toLogId()}) $message") + } +} + +suspend fun ProtonPhotosClient.downloader( + photoUid: String +): Downloader = cancellationTokenSource().let { source -> + JniPhotosDownloader().run { + PhotosDownloader( + client = this@downloader, + handle = create( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + photoUid = photoUid, + ), + bridge = this, + cancellationTokenSource = source, + ) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt similarity index 91% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 64ee26f2..88fcd9d4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -1,14 +1,13 @@ package me.proton.drive.sdk import com.google.protobuf.timestamp -import com.google.protobuf.value import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.FolderNode import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto -import me.proton.drive.sdk.internal.JniDriveClient +import me.proton.drive.sdk.internal.JniProtonDriveClient import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.driveClientCreateFolderRequest @@ -17,9 +16,9 @@ import proton.drive.sdk.driveClientGetThumbnailsRequest import proton.drive.sdk.driveClientRenameRequest import java.io.OutputStream -class DriveClient internal constructor( +class ProtonDriveClient internal constructor( internal val handle: Long, - private val bridge: JniDriveClient, + private val bridge: JniProtonDriveClient, session: Session? = null, ) : SdkNode(session), AutoCloseable { @@ -107,9 +106,9 @@ class DriveClient internal constructor( } } -suspend fun Session.driveClientCreate(): DriveClient = JniDriveClient().run { - val session = this@driveClientCreate - DriveClient( +suspend fun Session.protonDriveClientCreate(): ProtonDriveClient = JniProtonDriveClient().run { + val session = this@protonDriveClientCreate + ProtonDriveClient( session = session, handle = create(handle), bridge = this, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt index a190e35e..4a763690 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -10,7 +10,8 @@ import me.proton.drive.sdk.entity.SessionResumeRequest import me.proton.drive.sdk.internal.AccountClientBridge import me.proton.drive.sdk.internal.ApiProviderBridge import me.proton.drive.sdk.internal.JniCancellationTokenSource -import me.proton.drive.sdk.internal.JniDriveClient +import me.proton.drive.sdk.internal.JniProtonDriveClient +import me.proton.drive.sdk.internal.JniProtonPhotosClient import me.proton.drive.sdk.internal.JniLoggerProvider import me.proton.drive.sdk.internal.JniNativeLibrary import me.proton.drive.sdk.internal.JniSession @@ -44,7 +45,7 @@ object ProtonDriveSdk { } } - suspend fun driveClientCreate( + suspend fun protonDriveClientCreate( coroutineScope: CoroutineScope, userId: UserId, apiProvider: ApiProvider, @@ -53,9 +54,37 @@ object ProtonDriveSdk { publicAddressResolver: PublicAddressResolver, metricCallback: MetricCallback? = null, featureEnabled: suspend (String) -> Boolean = { false }, - ): DriveClient = JniDriveClient().run { - clientLogger(DEBUG, "ProtonDriveSdk driveClientCreate(${userId.id.take(8)})") - DriveClient( + ): ProtonDriveClient = JniProtonDriveClient().run { + clientLogger(DEBUG, "ProtonDriveSdk protonDriveClientCreate(${userId.id.take(8)})") + ProtonDriveClient( + create( + coroutineScope = coroutineScope, + request = request, + httpResponseReadPointer = ProtonDriveSdkNativeClient.getHttpResponseReadPointer(), + onHttpClientRequest = ApiProviderBridge( + userId = userId, + apiProvider = apiProvider, + coroutineScope = coroutineScope, + ), + onAccountRequest = AccountClientBridge(userAddressResolver, publicAddressResolver), + onRecordMetric = metricCallback?.let(::TelemetryBridge) ?: {}, + onFeatureEnabled = featureEnabled + ), this + ) + } + + suspend fun protonPhotosClientCreate( + coroutineScope: CoroutineScope, + userId: UserId, + apiProvider: ApiProvider, + request: ClientCreateRequest, + userAddressResolver: UserAddressResolver, + publicAddressResolver: PublicAddressResolver, + metricCallback: MetricCallback? = null, + featureEnabled: suspend (String) -> Boolean = { false }, + ): ProtonPhotosClient = JniProtonPhotosClient().run { + clientLogger(DEBUG, "ProtonDriveSdk protonPhotosClientCreate(${userId.id.take(8)})") + ProtonPhotosClient( create( coroutineScope = coroutineScope, request = request, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt new file mode 100644 index 00000000..c82bc13a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -0,0 +1,31 @@ +package me.proton.drive.sdk + +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.internal.JniProtonPhotosClient +import me.proton.drive.sdk.internal.toLogId + +class ProtonPhotosClient internal constructor( + internal val handle: Long, + private val bridge: JniProtonPhotosClient, + session: Session? = null, +) : SdkNode(session), AutoCloseable { + + override fun close() { + log(DEBUG, "close") + bridge.free(handle) + super.close() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "ProtonPhotosClient(${handle.toLogId()}) $message") + } +} + +suspend fun Session.protonPhotosClientCreate(): ProtonPhotosClient = JniProtonPhotosClient().run { + val session = this@protonPhotosClientCreate + ProtonPhotosClient( + session = session, + handle = create(handle), + bridge = this, + ) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index ddaabdf9..9c6d3cc0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -15,7 +15,7 @@ import java.nio.channels.Channels import java.util.concurrent.atomic.AtomicReference class Uploader internal constructor( - client: DriveClient, + client: ProtonDriveClient, internal val handle: Long, private val bridge: JniUploader, override val cancellationTokenSource: CancellationTokenSource, @@ -65,7 +65,7 @@ class Uploader internal constructor( } } -suspend fun DriveClient.uploader( +suspend fun ProtonDriveClient.uploader( request: FileUploaderRequest ): Uploader = cancellationTokenSource().let { source -> val client = this @@ -79,7 +79,7 @@ suspend fun DriveClient.uploader( } } -suspend fun DriveClient.uploader( +suspend fun ProtonDriveClient.uploader( request: FileRevisionUploaderRequest ): Uploader = cancellationTokenSource().let { source -> val client = this@uploader diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt similarity index 96% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt index a9cfd094..f7bc4646 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt @@ -9,7 +9,7 @@ import proton.drive.sdk.fileDownloaderFreeRequest import proton.drive.sdk.request import java.nio.ByteBuffer -class JniDownloader internal constructor() : JniBaseProtonDriveSdk() { +class JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { suspend fun create( clientHandle: Long, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt new file mode 100644 index 00000000..73ced0a2 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt @@ -0,0 +1,63 @@ +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.toLongResponse +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.drivePhotosClientDownloadToStreamRequest +import proton.drive.sdk.drivePhotosClientDownloaderFreeRequest +import proton.drive.sdk.drivePhotosClientGetPhotoDownloaderRequest +import proton.drive.sdk.request +import java.nio.ByteBuffer + +class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { + suspend fun create( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + photoUid: String, + ): Long = executeOnce("create", LongResponseCallback) { + drivePhotosClientGetPhotoDownloader = drivePhotosClientGetPhotoDownloaderRequest { + this.photoUid = photoUid + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + } + } + + suspend fun downloadToStream( + handle: Long, + cancellationTokenSourceHandle: Long, + onWrite: suspend (ByteBuffer) -> Unit, + onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, + coroutineScopeProvider: CoroutineScopeProvider, + ): Long = executePersistent( + clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + method("downloadToStream"), + continuation.toLongResponse(), + write = onWrite, + progress = onProgress, + logger = internalLogger, + coroutineScopeProvider = coroutineScopeProvider, + ) + }, + requestBuilder = { client -> + request { + drivePhotosClientDownloadToStream = drivePhotosClientDownloadToStreamRequest { + this.downloaderHandle = handle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + writeAction = ProtonDriveSdkNativeClient.getWritePointer() + progressAction = ProtonDriveSdkNativeClient.getProgressPointer() + } + } + } + ) + + fun free(handle: Long) { + dispatch("free") { + drivePhotosClientDownloaderFree = drivePhotosClientDownloaderFreeRequest { + fileDownloaderHandle = handle + } + } + releaseAll() + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt similarity index 98% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index 2b3957dd..cf521855 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -22,7 +22,7 @@ import proton.sdk.ProtonSdk.HttpResponse import proton.sdk.telemetry -class JniDriveClient internal constructor() : JniBaseProtonDriveSdk() { +class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun create(sessionHandle: Long) = executeOnce("createFromSession", LongResponseCallback) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt new file mode 100644 index 00000000..ae818999 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -0,0 +1,82 @@ +package me.proton.drive.sdk.internal + +import com.google.protobuf.Any +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.entity.ClientCreateRequest +import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.toLongResponse +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.drivePhotosClientCreateFromSessionRequest +import proton.drive.sdk.drivePhotosClientCreateRequest +import proton.drive.sdk.drivePhotosClientFreeRequest +import proton.drive.sdk.httpClient +import proton.drive.sdk.protonDriveClientOptions +import proton.drive.sdk.request +import proton.sdk.ProtonSdk +import proton.sdk.ProtonSdk.HttpResponse +import proton.sdk.telemetry + +class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun create(sessionHandle: Long) = + executeOnce("createFromSession", LongResponseCallback) { + drivePhotosClientCreateFromSession = drivePhotosClientCreateFromSessionRequest { + this.sessionHandle = sessionHandle + } + } + + suspend fun create( + coroutineScope: CoroutineScope, + request: ClientCreateRequest, + httpResponseReadPointer: Long, + onHttpClientRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse, + onAccountRequest: suspend (ProtonDriveSdk.AccountRequest) -> Any, + onRecordMetric: suspend (ProtonSdk.MetricEvent) -> Unit, + onFeatureEnabled: suspend (String) -> Boolean, + ) = executePersistent(clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + method("create"), + continuation.toLongResponse(), + httpClientRequest = onHttpClientRequest, + accountRequest = onAccountRequest, + logger = internalLogger, + recordMetric = onRecordMetric, + featureEnabled = onFeatureEnabled, + coroutineScopeProvider = { coroutineScope }, + ) + }, requestBuilder = { _ -> + request { + drivePhotosClientCreate = drivePhotosClientCreateRequest { + baseUrl = request.baseUrl + httpClient = httpClient { + requestFunction = ProtonDriveSdkNativeClient.getHttpClientRequestPointer() + responseContentReadAction = httpResponseReadPointer + cancellationAction = JniJob.getCancelPointer() + } + accountRequestAction = ProtonDriveSdkNativeClient.getAccountRequestPointer() + entityCachePath = request.entityCachePath + secretCachePath = request.secretCachePath + telemetry = telemetry { + loggerProviderHandle = request.loggerProvider.handle + recordMetricAction = ProtonDriveSdkNativeClient.getRecordMetricPointer() + } + featureEnabledFunction = ProtonDriveSdkNativeClient.getFeatureEnabledPointer() + clientOptions = protonDriveClientOptions { + request.bindingsLanguage?.let { bindingsLanguage = it } + request.uid?.let { uid = it } + request.apiCallTimeout?.let { apiCallTimeout = it } + request.storageCallTimeout?.let { storageCallTimeout = it } + } + } + } + }) + + fun free(handle: Long) { + dispatch("free") { + drivePhotosClientFree = drivePhotosClientFreeRequest { + clientHandle = handle + } + } + releaseAll() + } +} From 7513d846fa884246331ee9cedb84bfeeb52e2652 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 14:50:10 +0100 Subject: [PATCH 451/791] js/v0.9.3 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index d38fe7e8..9e547fd1 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.9.2", + "version": "0.9.3", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index c836d593..7e875606 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.2", + "version": "0.9.3", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From fd95bf3364a09e810999f6e4b2f9c9f5c2d24eb4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 15:07:17 +0100 Subject: [PATCH 452/791] Implement delayed cancellation for reading content during upload --- .../Nodes/Upload/RevisionWriter.cs | 61 ++++++++++++------- .../Upload/UnreadableContentException.cs | 18 ------ .../Upload/UploadContentReadingException.cs | 24 ++++++++ .../Nodes/Upload/UploadController.cs | 2 +- 4 files changed, 64 insertions(+), 41 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UnreadableContentException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadContentReadingException.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 96c242c9..d984b5bc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -14,6 +14,7 @@ namespace Proton.Drive.Sdk.Nodes.Upload; internal sealed partial class RevisionWriter : IDisposable { public const int DefaultBlockSize = 1 << 22; // 4 MiB + private const int SourceReadingCancellationDelayMilliseconds = 500; private readonly ProtonDriveClient _client; private readonly RevisionDraft _draft; @@ -223,36 +224,50 @@ private async ValueTask UploadContentBlocksAsync( Queue> uploadTasks, CancellationToken cancellationToken) { - int? currentBlockNumber = null; + using var delayedCancellationTokenSource = new CancellationTokenSource(); - while ( - await TryGetNextContentBlockPlainDataAsync( - currentBlockNumber, - hashingContentStream, - _draft.BlockVerifier.DataPacketPrefixMaxLength).ConfigureAwait(false) is var (newBlockNumber, plainData)) + // We use a delayed cancellation token to give the read operation a fair chance to complete when cancellation is triggered, + // so as to not leave the stream in an indeterminate state that would prevent resuming using the same stream later. + // ReSharper disable once AccessToDisposedClosure + await using (cancellationToken.Register(() => delayedCancellationTokenSource.CancelAfter(SourceReadingCancellationDelayMilliseconds))) { - currentBlockNumber = newBlockNumber; + int? currentBlockNumber = null; + + while ( + await TryGetNextContentBlockPlainDataAsync( + currentBlockNumber, + hashingContentStream, + _draft.BlockVerifier.DataPacketPrefixMaxLength, + delayedCancellationTokenSource.Token).ConfigureAwait(false) is var (newBlockNumber, plainData)) + { + cancellationToken.ThrowIfCancellationRequested(); - await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); + currentBlockNumber = newBlockNumber; - var onBlockProgress = onProgress is not null - ? progress => - { - _draft.NumberOfPlainBytesDone += progress; - onProgress(_draft.NumberOfPlainBytesDone); - } - : default(Action?); + // ReSharper disable once PossiblyMistakenUseOfCancellationToken + await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); + + var onBlockProgress = onProgress is not null + ? progress => + { + _draft.NumberOfPlainBytesDone += progress; + onProgress(_draft.NumberOfPlainBytesDone); + } + : default(Action?); - var uploadTask = UploadContentBlockAsync(currentBlockNumber.Value, plainData, onBlockProgress, cancellationToken).AsTask(); + // ReSharper disable once PossiblyMistakenUseOfCancellationToken + var uploadTask = UploadContentBlockAsync(currentBlockNumber.Value, plainData, onBlockProgress, cancellationToken).AsTask(); - uploadTasks.Enqueue(uploadTask); + uploadTasks.Enqueue(uploadTask); + } } } private async ValueTask<(int BlockNumber, BlockUploadPlainData PlainData)?> TryGetNextContentBlockPlainDataAsync( int? currentBlockNumber, Stream contentStream, - int prefixLength) + int prefixLength, + CancellationToken cancellationToken) { if (_draft.TryGetNextContentBlockPlainData(currentBlockNumber, out var result)) { @@ -269,13 +284,11 @@ await TryGetNextContentBlockPlainDataAsync( try { - // Do not cancel reading from the content stream for now, to avoid leaving it in an unusable state for resuming - // TODO: allow cancellation while reading block plain data var bytesCopied = await contentStream.PartiallyCopyToAsync( plainDataStream, _targetBlockSize, plainDataPrefixBuffer, - CancellationToken.None).ConfigureAwait(false); + cancellationToken).ConfigureAwait(false); if (bytesCopied == 0) { @@ -294,7 +307,11 @@ await TryGetNextContentBlockPlainDataAsync( { await plainDataStream.DisposeAsync().ConfigureAwait(false); - throw new UnreadableContentException("Reading block content for upload failed", ex); + throw new UploadContentReadingException( + ex is OperationCanceledException && cancellationToken.IsCancellationRequested + ? "Reading block content could not complete in time after cancellation" + : "Reading block content for upload failed", + ex); } } catch diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UnreadableContentException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UnreadableContentException.cs deleted file mode 100644 index 5d698f1b..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UnreadableContentException.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes.Upload; - -public class UnreadableContentException : ProtonDriveException -{ - public UnreadableContentException() - { - } - - public UnreadableContentException(string message) - : base(message) - { - } - - public UnreadableContentException(string message, Exception innerException) - : base(message, innerException) - { - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadContentReadingException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadContentReadingException.cs new file mode 100644 index 00000000..467998f3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadContentReadingException.cs @@ -0,0 +1,24 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +/// +/// Exception thrown when reading from the content source for the upload failed. +/// +/// +/// Catching this exception allows handling the case when the content source may be in an indeterminate state that would prevent from reusing it for resuming the upload. +/// +public class UploadContentReadingException : ProtonDriveException +{ + public UploadContentReadingException() + { + } + + public UploadContentReadingException(string message) + : base(message) + { + } + + public UploadContentReadingException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 43928176..da82edc6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -99,7 +99,7 @@ private static bool IsResumableError(Exception ex) return ex is not ProtonApiException { TransportCode: > 400 and < 500 } and not NodeKeyAndSessionKeyMismatchException and not SessionKeyAndDataPacketMismatchException - and not UnreadableContentException + and not UploadContentReadingException and not NodeWithSameNameExistsException; } From ff507799ef9c9cf377376c42815243ca4d486ecb Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 Jan 2026 06:03:16 +0000 Subject: [PATCH 453/791] i18n: Upgrade translations from crowdin (fd95bf33). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/pl_PL.json | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index fe3de90a..f30f2b71 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "563eb3a0ba3b5a70fcbb7d7bcd141992ba2f901c" + "locale": "fdde85e79fa737b8868535307f51f973f5c37ec7" } \ No newline at end of file diff --git a/js/sdk/locales/pl_PL.json b/js/sdk/locales/pl_PL.json index 44f89d40..066eaff7 100644 --- a/js/sdk/locales/pl_PL.json +++ b/js/sdk/locales/pl_PL.json @@ -17,6 +17,9 @@ "Copy operation aborted": [ "Operacja kopiowania zostaÅ‚a przerwana" ], + "Copying item to a non-folder is not allowed": [ + "Kopiowanie elementu do nie-folderu nie jest dozwolone" + ], "Creating files in non-folders is not allowed": [ "Tworzenie plików poza folderami nie jest dozwolone" ], @@ -71,6 +74,12 @@ "Failed to get inviter keys": [ "Nie udaÅ‚o siÄ™ pobrać kluczy zapraszajÄ…cego" ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Nie udaÅ‚o siÄ™ pobrać informacji o udostÄ™pnianiu dla wÄ™zÅ‚a ${ nodeUid }" + ], + "Failed to get verification keys": [ + "Nie udaÅ‚o siÄ™ pobrać kluczy weryfikacyjnych" + ], "Failed to load some nodes": [ "Nie udaÅ‚o siÄ™ zaÅ‚adować niektórych wÄ™złów" ], @@ -131,6 +140,9 @@ "Name must not contain the character '/'": [ "Nazwa nie może zawierać znaku '/'" ], + "No available name found": [ + "Nie znaleziono dostÄ™pnej nazwy" + ], "Node has no thumbnail": [ "WÄ™zeÅ‚ nie ma podglÄ…du" ], From 10017f8f279208961d63aa2c0fa44f9a57a8f1c8 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 Jan 2026 07:08:26 +0100 Subject: [PATCH 454/791] Fix default timeout on rate limit --- .../internal/apiService/apiService.test.ts | 59 ++++++++++++++++++- js/sdk/src/internal/apiService/apiService.ts | 2 +- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index ad89148d..c985df96 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -200,7 +200,8 @@ describe('DriveAPIService', () => { expectSDKEvents(); }); - it('on 429 response', async () => { + it('on 429 response with default timeout', async () => { + jest.useFakeTimers(); httpClient.fetchJson = jest .fn() .mockResolvedValueOnce( @@ -213,9 +214,63 @@ describe('DriveAPIService', () => { const result = api.get('test'); + // First request is made immediately + expect(httpClient.fetchJson).toHaveBeenCalledTimes(1); + + // After 9 seconds, still waiting (default is 10 seconds) + await jest.advanceTimersByTimeAsync(9 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(1); + + // After 10 seconds total, second request is made + await jest.advanceTimersByTimeAsync(1 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); + + // After another 10 seconds, third request is made + await jest.advanceTimersByTimeAsync(10 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expectSDKEvents(); + }); + + it('on 429 response with retry-after header', async () => { + jest.useFakeTimers(); + httpClient.fetchJson = jest + .fn() + .mockResolvedValueOnce( + new Response('', { + status: HTTPErrorCode.TOO_MANY_REQUESTS, + statusText: 'Some error', + headers: { 'retry-after': '5' }, + }), + ) + .mockResolvedValueOnce( + new Response('', { + status: HTTPErrorCode.TOO_MANY_REQUESTS, + statusText: 'Some error', + headers: { 'retry-after': '3' }, + }), + ) + .mockResolvedValueOnce(generateOkResponse()); + + const result = api.get('test'); + + // First request is made immediately + expect(httpClient.fetchJson).toHaveBeenCalledTimes(1); + + // After 4 seconds, still waiting (retry-after is 5 seconds) + await jest.advanceTimersByTimeAsync(4 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(1); + + // After 5 seconds total, second request is made + await jest.advanceTimersByTimeAsync(1 * 1000); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); + + // After another 3 seconds, third request is made (retry-after is 3 seconds) + await jest.advanceTimersByTimeAsync(3 * 1000); expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); - // No event is sent on random 429, only if limit of too many subsequent 429s is reached. + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expectSDKEvents(); }); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 14b9c55f..39c60689 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -313,7 +313,7 @@ export class DriveAPIService { if (response.status === HTTPErrorCode.TOO_MANY_REQUESTS) { this.tooManyRequestsErrorHappened(); - const timeout = parseInt(response.headers.get('retry-after') || '0', DEFAULT_429_RETRY_DELAY_SECONDS); + const timeout = parseInt(response.headers.get('retry-after') || '0', 10) || DEFAULT_429_RETRY_DELAY_SECONDS; await waitSeconds(timeout); return this.fetch(request, callback, attempt + 1); } else { From 94afb9a2e7d1e8b3d9569a0f5f36306c143ec9ed Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 Jan 2026 11:08:52 +0100 Subject: [PATCH 455/791] Improve cache DB transaction locking behavior --- .../Caching/SqliteCacheRepository.cs | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index ae07d77c..cdcbb0ef 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -121,7 +121,7 @@ public void Set(string key, string value, IEnumerable tags) using var connection = new SqliteConnection(_connection.ConnectionString); connection.Open(); - using var transaction = connection.BeginTransaction(deferred: true); + using var transaction = connection.BeginTransaction(deferred: false); // Check if eviction is needed (if LRU is enabled) if (_maxCacheSize.HasValue) @@ -227,7 +227,7 @@ public void Clear() using var connection = new SqliteConnection(_connection.ConnectionString); connection.Open(); - using var transaction = connection.BeginTransaction(deferred: true); + using var transaction = connection.BeginTransaction(deferred: false); using var command = connection.CreateCommand(); command.Transaction = transaction; @@ -393,12 +393,16 @@ private static void InitializeDatabase(SqliteConnection connection) CREATE TABLE IF NOT EXISTS Entries ( Key TEXT NOT NULL, Value TEXT NOT NULL, + LastAccessedUtc INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (Key) ) """; command.ExecuteNonQuery(); + command.CommandText = "CREATE INDEX IF NOT EXISTS idx_entries_last_accessed ON Entries(LastAccessedUtc)"; + command.ExecuteNonQuery(); + command.CommandText = """ CREATE TABLE IF NOT EXISTS Tags ( @@ -420,20 +424,5 @@ PRIMARY KEY (Tag) """; command.ExecuteNonQuery(); - - command.CommandText = "PRAGMA user_version"; - var currentVersion = Convert.ToInt32(command.ExecuteScalar()); - - if (currentVersion < 1) - { - command.CommandText = "ALTER TABLE Entries ADD COLUMN LastAccessedUtc INTEGER NOT NULL DEFAULT 0"; - command.ExecuteNonQuery(); - - command.CommandText = "CREATE INDEX IF NOT EXISTS idx_entries_last_accessed ON Entries(LastAccessedUtc)"; - command.ExecuteNonQuery(); - - command.CommandText = "PRAGMA user_version = 1"; - command.ExecuteNonQuery(); - } } } From d5be21208af9cb403ca28ae538bd66311590d5eb Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 Jan 2026 11:22:35 +0000 Subject: [PATCH 456/791] Remove unnecessary parameter from .BeginTransaction calls --- cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index cdcbb0ef..9ef32662 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -121,7 +121,7 @@ public void Set(string key, string value, IEnumerable tags) using var connection = new SqliteConnection(_connection.ConnectionString); connection.Open(); - using var transaction = connection.BeginTransaction(deferred: false); + using var transaction = connection.BeginTransaction(); // Check if eviction is needed (if LRU is enabled) if (_maxCacheSize.HasValue) @@ -227,7 +227,7 @@ public void Clear() using var connection = new SqliteConnection(_connection.ConnectionString); connection.Open(); - using var transaction = connection.BeginTransaction(deferred: false); + using var transaction = connection.BeginTransaction(); using var command = connection.CreateCommand(); command.Transaction = transaction; From e4f28c36d64f378587c500ae0dd7efa0063a8367 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 Jan 2026 07:25:25 +0100 Subject: [PATCH 457/791] Report metrics from photos as own_photo_volume --- js/sdk/src/interface/telemetry.ts | 1 + js/sdk/src/internal/photos/shares.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 07a15e8f..7cbab3b3 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -113,6 +113,7 @@ export interface MetricVolumeEventsSubscriptionsChangedEvent { export enum MetricVolumeType { OwnVolume = 'own_volume', + OwnPhotoVolume = 'own_photo_volume', Shared = 'shared', SharedPublic = 'shared_public', } diff --git a/js/sdk/src/internal/photos/shares.ts b/js/sdk/src/internal/photos/shares.ts index 98bb385d..85b5ac22 100644 --- a/js/sdk/src/internal/photos/shares.ts +++ b/js/sdk/src/internal/photos/shares.ts @@ -123,7 +123,7 @@ export class PhotoSharesManager { async getVolumeMetricContext(volumeId: string): Promise { const { volumeId: myVolumeId } = await this.getRootIDs(); if (volumeId === myVolumeId) { - return MetricVolumeType.OwnVolume; + return MetricVolumeType.OwnPhotoVolume; } return this.sharesService.getVolumeMetricContext(volumeId); } From c14030523ccd70eababdbaa6bf7357b6f1b50875 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 Jan 2026 12:43:22 +0000 Subject: [PATCH 458/791] Fix errors not caught in Kotlin bindings and crashing client --- .../internal/ProtonDriveSdkNativeClient.kt | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 710f4780..70d902c1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -198,21 +198,22 @@ class ProtonDriveSdkNativeClient internal constructor( sdkHandle: Long, block: suspend () -> Response, ): Job = coroutineScope(operation).launch { - handleResponse(sdkHandle, block()) + try { + handleResponse(sdkHandle, block()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + handleResponse(sdkHandle, response { + this@response.error = error.toProtonSdkError("Error while $operation") + }) + } }.also { job -> job.invokeOnCompletion { error -> - when (error) { - null -> Unit // job completed normally - is CancellationException -> { - logger(DEBUG, "Operation $operation was cancelled") - handleResponse(sdkHandle, response { - this@response.error = - error.toProtonSdkError("Operation $operation was cancelled") - }) - } - - else -> handleResponse(sdkHandle, response { - this@response.error = error.toProtonSdkError("Error while $operation") + if (error is CancellationException) { + logger(DEBUG, "Operation $operation was cancelled") + handleResponse(sdkHandle, response { + this@response.error = + error.toProtonSdkError("Operation $operation was cancelled") }) } } @@ -250,18 +251,17 @@ class ProtonDriveSdkNativeClient internal constructor( // parsing of protobuf needs to be done serially val value = parser(data) coroutineScope(callback).launch { - block(value) + try { + block(value) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + logger(WARN, "Error while $callback") + logger(WARN, error.stackTraceToString()) + } }.invokeOnCompletion { error -> - when (error) { - null -> Unit // job completed normally - is CancellationException -> { - logger(DEBUG, "Callback $callback was cancelled") - } - - else -> { - logger(WARN, "Error while $callback") - logger(WARN, error.stackTraceToString()) - } + if (error is CancellationException) { + logger(DEBUG, "Callback $callback was cancelled") } } } catch (error: Throwable) { From 74bda4fd7bf570b7c8c80388972dcf2ad79ed32b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 Jan 2026 13:26:34 +0100 Subject: [PATCH 459/791] Release lock after download and close the stream in diagnostics --- js/sdk/src/diagnostic/sdkDiagnosticBase.ts | 1 + js/sdk/src/internal/download/fileDownloader.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/js/sdk/src/diagnostic/sdkDiagnosticBase.ts b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts index 5e0e5e46..b9eeee5c 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticBase.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts @@ -320,6 +320,7 @@ export class SDKDiagnosticBase { try { await controller.completion(); + await integrityVerificationStream.close(); const computedSha1 = integrityVerificationStream.computedSha1; const computedSizeInBytes = integrityVerificationStream.computedSizeInBytes; diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index c4321354..fd31b7c8 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -235,6 +235,11 @@ export class FileDownloader { void this.telemetry.downloadFinished(this.revision.uid, fileProgress); this.logger.info(`Download succeeded`); + try { + writer.releaseLock(); + } catch (error: unknown) { + this.logger.error(`Failed to release writer lock`, error); + } } catch (error: unknown) { if (error instanceof SignatureVerificationError) { this.logger.warn(`Download finished with signature verification issues`); From 7964cd71991ac7722d7618ce7cb8182d6afdc1b4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 21 Jan 2026 16:09:36 +0100 Subject: [PATCH 460/791] Add function to scan for malware --- .../src/internal/sharingPublic/apiService.ts | 12 +++++++ js/sdk/src/internal/sharingPublic/index.ts | 5 +++ .../internal/sharingPublic/nodesSecurity.ts | 35 +++++++++++++++++++ js/sdk/src/protonDrivePublicLinkClient.ts | 9 +++++ 4 files changed, 61 insertions(+) create mode 100644 js/sdk/src/internal/sharingPublic/nodesSecurity.ts diff --git a/js/sdk/src/internal/sharingPublic/apiService.ts b/js/sdk/src/internal/sharingPublic/apiService.ts index d936e8bf..3b6bc263 100644 --- a/js/sdk/src/internal/sharingPublic/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/apiService.ts @@ -7,6 +7,12 @@ type PostTokenInfoRequest = Extract< type PostTokenInfoResponse = drivePaths['/drive/v2/urls/{token}/bookmark']['post']['responses']['200']['content']['application/json']; +type PostMalwareScanRequest = Extract< + drivePaths['/drive/urls/{token}/security']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostMalwareScanResponse = + drivePaths['/drive/urls/{token}/security']['post']['responses']['200']['content']['application/json']; /** * Provides API communication for actions on the public link. * @@ -35,4 +41,10 @@ export class SharingPublicAPIService { }, ); } + + async malwareScan(token: string, hashes: string[]) { + return this.apiService.post(`drive/urls/${token}/security`, { + Hashes: hashes, + }); + } } diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 4e5bd8e0..d3659b51 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -15,6 +15,8 @@ import { NodesRevisons } from '../nodes/nodesRevisions'; import { SharingPublicCryptoReporter } from './cryptoReporter'; import { SharingPublicNodesAccess, SharingPublicNodesManagement } from './nodes'; import { SharingPublicSharesManager } from './shares'; +import { SharingPublicAPIService } from './apiService'; +import { NodesSecurity } from './nodesSecurity'; export { SharingPublicSessionManager } from './session/manager'; export { UnauthDriveAPIService } from './unauthApiService'; @@ -113,10 +115,13 @@ export function initSharingPublicNodesModule( ); const nodesManagement = new SharingPublicNodesManagement(api, cryptoCache, cryptoService, nodesAccess); const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); + const sharingPublicApi = new SharingPublicAPIService(apiService); + const nodesSecurity = new NodesSecurity(sharingPublicApi, token); return { access: nodesAccess, management: nodesManagement, revisions: nodesRevisions, + security: nodesSecurity, }; } diff --git a/js/sdk/src/internal/sharingPublic/nodesSecurity.ts b/js/sdk/src/internal/sharingPublic/nodesSecurity.ts new file mode 100644 index 00000000..69102b16 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/nodesSecurity.ts @@ -0,0 +1,35 @@ +import { SharingPublicAPIService } from './apiService'; + +type ScannedHash = string; +export type NodesSecurityScanResult = Record; + +export class NodesSecurity { + constructor( + private apiService: SharingPublicAPIService, + private token: string, + ) { + this.apiService = apiService; + this.token = token; + } + + async scanHashes(hashes: string[]): Promise { + const response = await this.apiService.malwareScan(this.token, hashes); + const result: NodesSecurityScanResult = {}; + + response.Results.forEach(({ Hash, Safe }) => { + result[Hash] = { + ...(result[Hash] || {}), + safe: Safe, + }; + }); + + response.Errors.forEach(({ Hash, Error }) => { + result[Hash] = { + ...(result[Hash] || {}), + error: Error, + }; + }); + + return result; + } +} diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index c763ade4..54db83d1 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -33,6 +33,7 @@ import { initDownloadModule } from './internal/download'; import { SDKEvents } from './internal/sdkEvents'; import { initSharingPublicModule, UnauthDriveAPIService } from './internal/sharingPublic'; import { initUploadModule } from './internal/upload'; +import { NodesSecurityScanResult } from './internal/sharingPublic/nodesSecurity'; /** * ProtonDrivePublicLinkClient is the interface for the public link client. @@ -69,6 +70,10 @@ export class ProtonDrivePublicLinkClient { * This is used by Docs app to encrypt and decrypt document updates. */ getDocsKey: (nodeUid: NodeOrUid) => Promise; + /** + * Experimental feature to check if hashes match the malware database. + */ + scanHashes: (hashes: string[]) => Promise; }; constructor({ @@ -166,6 +171,10 @@ export class ProtonDrivePublicLinkClient { } return keys.contentKeyPacketSessionKey; }, + scanHashes: async (hashes: string[]): Promise => { + this.logger.debug(`Scanning ${hashes.length} hashes`); + return this.sharingPublic.nodes.security.scanHashes(hashes); + }, }; } From 51587e3b1db1b19d8a2298e587f47323a6c9f3c4 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 07:30:14 +0100 Subject: [PATCH 461/791] js/v0.9.4 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 9e547fd1..9402c0f5 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.3", + "version": "0.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.9.3", + "version": "0.9.4", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index 7e875606..ffddd112 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.3", + "version": "0.9.4", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From f7604a7fe20fbac45cf6a8a7cdc9f4afc6a09329 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 06:28:59 +0100 Subject: [PATCH 462/791] Fix file with content check for diagnostics --- js/sdk/src/diagnostic/sdkDiagnosticBase.ts | 23 ++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/diagnostic/sdkDiagnosticBase.ts b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts index b9eeee5c..79dec289 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticBase.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts @@ -1,4 +1,5 @@ import { Author, FileDownloader, MaybeNode, NodeOrUid, NodeType, ThumbnailType, ThumbnailResult } from '../interface'; +import { isProtonDocument, isProtonSheet } from '../internal/nodes/mediaTypes'; import { DiagnosticOptions, DiagnosticResult, @@ -210,12 +211,12 @@ export class SDKDiagnosticBase { ): AsyncGenerator { const activeRevision = getActiveRevision(node); - const expectedAttributes = getNodeType(node) === NodeType.File; + const isNodeWithContent = this.isNodeWithContent(node); const claimedSha1 = activeRevision?.claimedDigests?.sha1; const claimedSizeInBytes = activeRevision?.claimedSize; - if (claimedSha1 && !/^[0-9a-f]{40}$/i.test(claimedSha1)) { + if (isNodeWithContent && claimedSha1 && !/^[0-9a-f]{40}$/i.test(claimedSha1)) { yield { type: 'extended_attributes_error', field: 'sha1', @@ -224,7 +225,7 @@ export class SDKDiagnosticBase { }; } - if (expectedAttributes && !claimedSha1) { + if (isNodeWithContent && !claimedSha1) { yield { type: 'extended_attributes_missing_field', missingField: 'sha1', @@ -252,7 +253,7 @@ export class SDKDiagnosticBase { } private async *verifyContentPeak(node: MaybeNode): AsyncGenerator { - if (getNodeType(node) !== NodeType.File) { + if (!this.isNodeWithContent(node)) { return; } @@ -288,7 +289,7 @@ export class SDKDiagnosticBase { } private async *verifyContent(node: MaybeNode): AsyncGenerator { - if (getNodeType(node) !== NodeType.File) { + if (!this.isNodeWithContent(node)) { return; } const activeRevision = getActiveRevision(node); @@ -344,7 +345,7 @@ export class SDKDiagnosticBase { } private async *verifyThumbnails(node: MaybeNode): AsyncGenerator { - if (getNodeType(node) !== NodeType.File) { + if (!this.isNodeWithContent(node)) { return; } @@ -376,4 +377,14 @@ export class SDKDiagnosticBase { }; } } + + private isNodeWithContent(node: MaybeNode): boolean { + const nodeType = getNodeType(node); + const isFile = nodeType === NodeType.File || nodeType === NodeType.Photo; + + const mediaType = getMediaType(node); + const isDocs = isProtonDocument(mediaType) || isProtonSheet(mediaType); + + return isFile && !isDocs; + } } From f335eb9c4806c495db8389a53341de653e61464b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 16:08:12 +0100 Subject: [PATCH 463/791] Remove copyrights --- .../me/proton/drive/sdk/entity/Author.kt | 18 ------------------ .../me/proton/drive/sdk/entity/AuthorResult.kt | 18 ------------------ .../proton/drive/sdk/extension/AnyConverter.kt | 18 ------------------ .../proton/drive/sdk/extension/FolderNode.kt | 18 ------------------ .../proton/drive/sdk/extension/NameAuthor.kt | 18 ------------------ 5 files changed, 90 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt index 4247a0b3..99cbb7dc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2026 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.entity data class Author( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt index f043c323..b04ab9ae 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2026 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.entity data class AuthorResult( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt index 3c6633d1..5e7c81c8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2026 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import kotlinx.coroutines.CancellableContinuation diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt index 5d90ac1b..2f10b449 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2026 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.FolderNode diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt index b80a74b9..be02cfad 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt @@ -1,21 +1,3 @@ -/* - * Copyright (c) 2026 Proton AG. - * This file is part of Proton Drive. - * - * Proton Drive is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Drive is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . - */ - package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.Author From 449457382184513e64932710ffc4a5be0f8fdc96 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 12:42:23 +0100 Subject: [PATCH 464/791] Add getThumbnails to DrivePhotosClient --- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 26 +++++++++++++++++++ .../sdk/internal/JniProtonPhotosClient.kt | 9 +++++++ 2 files changed, 35 insertions(+) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index c82bc13a..a647f445 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -1,8 +1,14 @@ package me.proton.drive.sdk import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniProtonPhotosClient +import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.toLogId +import proton.drive.sdk.drivePhotosClientEnumeratePhotosThumbnailsRequest +import java.io.OutputStream class ProtonPhotosClient internal constructor( internal val handle: Long, @@ -10,6 +16,26 @@ class ProtonPhotosClient internal constructor( session: Session? = null, ) : SdkNode(session), AutoCloseable { + suspend fun getThumbnails( + photoUids: List, + type: ThumbnailType, + block: (String) -> OutputStream, + ): Unit = cancellationCoroutineScope { source -> + log(INFO, "getThumbnails($type)") + bridge.getThumbnails( + drivePhotosClientEnumeratePhotosThumbnailsRequest { + this.photoUids += photoUids + this.type = type.toProto() + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).thumbnailsList.forEach { photoThumbnail -> + block(photoThumbnail.fileUid).use { outputStream -> + outputStream.write(photoThumbnail.data.toByteArray()) + } + } + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index ae818999..4b370fb8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -2,8 +2,10 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.converter.FileThumbnailListConverter import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.asCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.drivePhotosClientCreateFromSessionRequest @@ -71,6 +73,13 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { } }) + suspend fun getThumbnails( + request: ProtonDriveSdk.DrivePhotosClientEnumeratePhotosThumbnailsRequest, + ): ProtonDriveSdk.FileThumbnailList = + executeOnce("getThumbnails", FileThumbnailListConverter().asCallback) { + drivePhotosClientEnumeratePhotosThumbnails = request + } + fun free(handle: Long) { dispatch("free") { drivePhotosClientFree = drivePhotosClientFreeRequest { From 4c8b18ff26eb76f6f6e567657fdd57958c95b9eb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 21 Jan 2026 19:32:26 +0100 Subject: [PATCH 465/791] Fix error not caught or returned to the sdk when scope was null --- .../proton/drive/sdk/extension/Throwable.kt | 2 + .../sdk/internal/NoCoroutineScopeException.kt | 6 +++ .../internal/ProtonDriveSdkNativeClient.kt | 50 ++++++++++++------- 3 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/NoCoroutineScopeException.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt index f57eafaa..e37854b1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -3,6 +3,7 @@ package me.proton.drive.sdk.extension import kotlinx.coroutines.CancellationException import me.proton.core.network.domain.ApiException import me.proton.core.network.domain.ApiResult +import me.proton.drive.sdk.internal.NoCoroutineScopeException import proton.sdk.ProtonSdk fun Throwable.toProtonSdkError(message: String) = proton.sdk.error { @@ -16,6 +17,7 @@ fun Throwable.toProtonSdkError(message: String) = proton.sdk.error { } private fun Throwable.domain(): ProtonSdk.ErrorDomain = when (this) { + is NoCoroutineScopeException -> ProtonSdk.ErrorDomain.SuccessfulCancellation is CancellationException -> ProtonSdk.ErrorDomain.SuccessfulCancellation is ApiException -> when (error) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/NoCoroutineScopeException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/NoCoroutineScopeException.kt new file mode 100644 index 00000000..0f046a1f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/NoCoroutineScopeException.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.internal + +class NoCoroutineScopeException( + message: String? = null, + cause: Throwable? = null, +) : Throwable(message, cause) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 70d902c1..8bba8e44 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -197,26 +197,35 @@ class ProtonDriveSdkNativeClient internal constructor( operation: String, sdkHandle: Long, block: suspend () -> Response, - ): Job = coroutineScope(operation).launch { - try { - handleResponse(sdkHandle, block()) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - handleResponse(sdkHandle, response { - this@response.error = error.toProtonSdkError("Error while $operation") - }) - } - }.also { job -> - job.invokeOnCompletion { error -> - if (error is CancellationException) { - logger(DEBUG, "Operation $operation was cancelled") + ): Job? = try { + coroutineScope(operation).launch { + try { + handleResponse(sdkHandle, block()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { handleResponse(sdkHandle, response { - this@response.error = - error.toProtonSdkError("Operation $operation was cancelled") + this@response.error = error.toProtonSdkError("Error while $operation") }) } + }.also { job -> + job.invokeOnCompletion { error -> + if (error is CancellationException) { + logger(DEBUG, "Operation $operation was cancelled") + handleResponse(sdkHandle, response { + this@response.error = + error.toProtonSdkError("Operation $operation was cancelled") + }) + } + } } + } catch (error: Throwable) { + handleResponse(sdkHandle, response { + this@response.error = error.toProtonSdkError( + "Error while scheduling $operation" + ) + }) + null } @Suppress("TooGenericExceptionCaught") @@ -264,6 +273,9 @@ class ProtonDriveSdkNativeClient internal constructor( logger(DEBUG, "Callback $callback was cancelled") } } + } catch (error: NoCoroutineScopeException) { + logger(ERROR, "Error while scheduling $callback") + logger(ERROR, error.stackTraceToString()) } catch (error: Throwable) { logger(ERROR, "Error while parsing value for $callback") logger(ERROR, error.stackTraceToString()) @@ -273,8 +285,10 @@ class ProtonDriveSdkNativeClient internal constructor( private fun coroutineScope(operation: String): CoroutineScope { val scope = coroutineScopeProvider() - checkNotNull(scope) { - "No coroutineScope was provided to ${javaClass.simpleName}, cannot execute $operation" + if (scope == null) { + throw NoCoroutineScopeException( + "No coroutineScope was provided to ${javaClass.simpleName}, cannot execute $operation" + ) } if (!scope.isActive) { logger(DEBUG, "CoroutineScope not active for $operation") From d431e5aae05d4b134634da07e75de41b42962932 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 20:03:22 +0100 Subject: [PATCH 466/791] Fix native clients getting garbage collected during long request to the sdk --- .../me/proton/drive/sdk/FileDownloader.kt | 5 ++-- .../me/proton/drive/sdk/PhotosDownloader.kt | 3 +- .../me/proton/drive/sdk/ProtonDriveClient.kt | 6 ++-- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 18 +++++++----- .../kotlin/me/proton/drive/sdk/SdkNode.kt | 4 +-- .../me/proton/drive/sdk/UploadController.kt | 5 +++- .../kotlin/me/proton/drive/sdk/Uploader.kt | 13 ++++----- .../internal/IgnoredIntegerOrErrorResponse.kt | 4 +-- .../me/proton/drive/sdk/internal/JniBase.kt | 3 ++ .../sdk/internal/JniBaseProtonDriveSdk.kt | 9 ++++-- .../drive/sdk/internal/JniBaseProtonSdk.kt | 20 +++++++++---- .../drive/sdk/internal/JniFileDownloader.kt | 4 +-- .../drive/sdk/internal/JniHttpStream.kt | 29 ++++++++++--------- .../drive/sdk/internal/JniLoggerProvider.kt | 4 +-- .../drive/sdk/internal/JniPhotosDownloader.kt | 5 ++-- .../sdk/internal/JniProtonDriveClient.kt | 4 +-- .../sdk/internal/JniProtonPhotosClient.kt | 4 +-- .../proton/drive/sdk/internal/JniUploader.kt | 4 +-- .../internal/ProtonDriveSdkNativeClient.kt | 4 +-- .../sdk/internal/ProtonSdkNativeClient.kt | 5 ++-- .../drive/sdk/internal/SdkNodeFactory.kt | 16 ++++++++++ 21 files changed, 104 insertions(+), 65 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/SdkNodeFactory.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index c9547432..3e43551c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -6,6 +6,7 @@ import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniFileDownloader +import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import java.io.OutputStream import java.nio.channels.Channels @@ -66,10 +67,10 @@ class FileDownloader internal constructor( suspend fun ProtonDriveClient.downloader( revisionUid: String ): Downloader = cancellationTokenSource().let { source -> - JniFileDownloader().run { + factory(JniFileDownloader()){ FileDownloader( client = this@downloader, - handle = create(handle, source.handle, revisionUid), + handle = this.create(handle, source.handle, revisionUid), bridge = this, cancellationTokenSource = source, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index 681119c1..b271a738 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -6,6 +6,7 @@ import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniPhotosDownloader +import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import java.io.OutputStream import java.nio.channels.Channels @@ -66,7 +67,7 @@ class PhotosDownloader internal constructor( suspend fun ProtonPhotosClient.downloader( photoUid: String ): Downloader = cancellationTokenSource().let { source -> - JniPhotosDownloader().run { + factory(JniPhotosDownloader()) { PhotosDownloader( client = this@downloader, handle = create( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 88fcd9d4..c1f5f7dd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -9,6 +9,7 @@ import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniProtonDriveClient import me.proton.drive.sdk.internal.cancellationCoroutineScope +import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.driveClientCreateFolderRequest import proton.drive.sdk.driveClientGetAvailableNameRequest @@ -106,10 +107,9 @@ class ProtonDriveClient internal constructor( } } -suspend fun Session.protonDriveClientCreate(): ProtonDriveClient = JniProtonDriveClient().run { - val session = this@protonDriveClientCreate +suspend fun Session.protonDriveClientCreate(): ProtonDriveClient = factory(JniProtonDriveClient()) { ProtonDriveClient( - session = session, + session = this@protonDriveClientCreate, handle = create(handle), bridge = this, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index a647f445..03dfcd58 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -6,6 +6,7 @@ import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniProtonPhotosClient import me.proton.drive.sdk.internal.cancellationCoroutineScope +import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.drivePhotosClientEnumeratePhotosThumbnailsRequest import java.io.OutputStream @@ -47,11 +48,12 @@ class ProtonPhotosClient internal constructor( } } -suspend fun Session.protonPhotosClientCreate(): ProtonPhotosClient = JniProtonPhotosClient().run { - val session = this@protonPhotosClientCreate - ProtonPhotosClient( - session = session, - handle = create(handle), - bridge = this, - ) -} +suspend fun Session.protonPhotosClientCreate(): ProtonPhotosClient = + factory(JniProtonPhotosClient()) { + val session = this@protonPhotosClientCreate + ProtonPhotosClient( + session = session, + handle = create(handle), + bridge = this, + ) + } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt index 13e4ffdc..65ad22d8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt @@ -5,10 +5,10 @@ abstract class SdkNode(val parent: SdkNode?) : AutoCloseable { private var children: List = emptyList() init { - parent?.run { children += this } + parent?.children += this } override fun close() { - parent?.run { children -= this } + parent?.children -= this } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index 721ca066..b5390e9f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -51,7 +51,10 @@ class UploadController internal constructor( isPausedFlow.emit(paused) } - suspend fun dispose() = bridge.dispose(handle) + suspend fun dispose() { + log(DEBUG, "dispose") + bridge.dispose(handle) + } override fun close() { log(DEBUG, "close") diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 9c6d3cc0..80850602 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -9,6 +9,7 @@ import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.JniUploader +import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import java.io.InputStream import java.nio.channels.Channels @@ -68,11 +69,10 @@ class Uploader internal constructor( suspend fun ProtonDriveClient.uploader( request: FileUploaderRequest ): Uploader = cancellationTokenSource().let { source -> - val client = this - JniUploader().run { + factory(JniUploader()) { Uploader( - client = client, - handle = getFile(client.handle, source.handle, request), + client = this@uploader, + handle = getFile(this@uploader.handle, source.handle, request), bridge = this, cancellationTokenSource = source, ) @@ -82,10 +82,9 @@ suspend fun ProtonDriveClient.uploader( suspend fun ProtonDriveClient.uploader( request: FileRevisionUploaderRequest ): Uploader = cancellationTokenSource().let { source -> - val client = this@uploader - JniUploader().run { + factory(JniUploader()) { Uploader( - client = client, + client = this@uploader, handle = getFileRevision(handle, source.handle, request), bridge = this, cancellationTokenSource = source, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt index 07159389..8d5d2337 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt @@ -2,6 +2,6 @@ package me.proton.drive.sdk.internal import java.nio.ByteBuffer -class IgnoredIntegerOrErrorResponse : ResponseCallback { - override fun invoke(data: ByteBuffer) = Unit +class IgnoredIntegerOrErrorResponse : ClientResponseCallback { + override fun invoke(client: T, data: ByteBuffer) = Unit } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt index 9c9cd55d..0f712438 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt @@ -5,6 +5,9 @@ import me.proton.drive.sdk.SdkLogger import java.nio.ByteBuffer typealias ResponseCallback = (ByteBuffer) -> Unit +typealias ClientResponseCallback = (T, ByteBuffer) -> Unit + +fun ResponseCallback.asClientResponseCallback(): ClientResponseCallback = { _, buffer -> this(buffer)} abstract class JniBase { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt index add264ea..525f31ca 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -2,7 +2,6 @@ package me.proton.drive.sdk.internal import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine -import me.proton.drive.sdk.LoggerProvider.Level import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE import proton.drive.sdk.ProtonDriveSdk.Request import proton.drive.sdk.RequestKt @@ -34,10 +33,14 @@ abstract class JniBaseProtonDriveSdk : JniBase() { check(released.not()) { "Cannot executeOnce ${method(name)} after release" } val nativeClient = ProtonDriveSdkNativeClient( name = method(name), - response = callback(continuation), + response = { client, buffer -> + callback(continuation).invoke(buffer) + client.release() + clients -= client + }, logger = internalLogger, ) - continuation.invokeOnCancellation { nativeClient.release() } + clients += nativeClient nativeClient.handleRequest(request(block)) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt index 0e2b6451..ffce2299 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt @@ -28,19 +28,29 @@ abstract class JniBaseProtonSdk : JniBase() { ): T = suspendCancellableCoroutine { continuation -> val nativeClient = ProtonSdkNativeClient( name = method(name), - response = callback(continuation), + response = { client, buffer -> + callback(continuation).invoke(buffer) + client.release() + clients -= client + }, logger = internalLogger, ) - continuation.invokeOnCancellation { nativeClient.release() } + clients += nativeClient nativeClient.handleRequest(request(block)) } suspend fun executeOnce( - clientBuilder: (CancellableContinuation) -> ProtonSdkNativeClient, + clientBuilder: (CancellableContinuation, ResponseCallback.() -> ClientResponseCallback) -> ProtonSdkNativeClient, requestBuilder: (ProtonSdkNativeClient) -> Request, ): T = suspendCancellableCoroutine { continuation -> - val nativeClient = clientBuilder(continuation) - continuation.invokeOnCancellation { nativeClient.release() } + val nativeClient = clientBuilder(continuation) { + { client, buffer -> + this(buffer) + client.release() + clients -= client + } + } + clients += nativeClient nativeClient.handleRequest(requestBuilder(nativeClient)) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt index f7bc4646..0c1f9e34 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt @@ -32,8 +32,8 @@ class JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { ): Long = executePersistent( clientBuilder = { continuation -> ProtonDriveSdkNativeClient( - method("downloadToStream"), - continuation.toLongResponse(), + name = method("downloadToStream"), + response = continuation.toLongResponse().asClientResponseCallback(), write = onWrite, progress = onProgress, logger = internalLogger, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index 79c5226e..a994b25c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -40,21 +40,24 @@ class JniHttpStream internal constructor( suspend fun read( handle: Long, buffer: ByteBuffer, - ): Int = executeOnce(clientBuilder = { continuation -> - ProtonSdkNativeClient( - name = method("read"), - response = continuation.toIntResponse(), - logger = internalLogger, - ) - }, requestBuilder = { client -> - request { - streamRead = streamReadRequest { - streamHandle = handle - bufferPointer = JniBuffer.getBufferPointer(buffer) - bufferLength = JniBuffer.getBufferSize(buffer).toInt() + ): Int = executeOnce( + clientBuilder = { continuation, asClientResponseCallback -> + ProtonSdkNativeClient( + name = method("read"), + response = continuation.toIntResponse().asClientResponseCallback(), + logger = internalLogger, + ) + }, + requestBuilder = { client -> + request { + streamRead = streamReadRequest { + streamHandle = handle + bufferPointer = JniBuffer.getBufferPointer(buffer) + bufferLength = JniBuffer.getBufferSize(buffer).toInt() + } } } - }) + ) fun release() { client = null diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt index 59bff0e6..97539d78 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt @@ -21,8 +21,8 @@ class JniLoggerProvider internal constructor( suspend fun create(): Long = executePersistent( clientBuilder = { continuation -> ProtonSdkNativeClient( - method("create"), - continuation.toLongResponse(), + name = method("create"), + response = continuation.toLongResponse().asClientResponseCallback(), callback = ::onLog, ) }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt index 73ced0a2..e35f5a35 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt @@ -1,6 +1,5 @@ package me.proton.drive.sdk.internal -import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk @@ -32,8 +31,8 @@ class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { ): Long = executePersistent( clientBuilder = { continuation -> ProtonDriveSdkNativeClient( - method("downloadToStream"), - continuation.toLongResponse(), + name = method("downloadToStream"), + response = continuation.toLongResponse().asClientResponseCallback(), write = onWrite, progress = onProgress, logger = internalLogger, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index cf521855..58548e71 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -41,8 +41,8 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { onFeatureEnabled: suspend (String) -> Boolean, ) = executePersistent(clientBuilder = { continuation -> ProtonDriveSdkNativeClient( - method("create"), - continuation.toLongResponse(), + name = method("create"), + response = continuation.toLongResponse().asClientResponseCallback(), httpClientRequest = onHttpClientRequest, accountRequest = onAccountRequest, logger = internalLogger, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index 4b370fb8..33248b71 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -37,8 +37,8 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { onFeatureEnabled: suspend (String) -> Boolean, ) = executePersistent(clientBuilder = { continuation -> ProtonDriveSdkNativeClient( - method("create"), - continuation.toLongResponse(), + name = method("create"), + response = continuation.toLongResponse().asClientResponseCallback(), httpClientRequest = onHttpClientRequest, accountRequest = onAccountRequest, logger = internalLogger, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt index 3212bd75..249aa3c5 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt @@ -45,8 +45,8 @@ class JniUploader internal constructor() : JniBaseProtonDriveSdk() { ): Long = executePersistent( clientBuilder = { continuation -> ProtonDriveSdkNativeClient( - method("uploadFromStream"), - continuation.toLongResponse(), + name = method("uploadFromStream"), + response = continuation.toLongResponse().asClientResponseCallback(), read = onRead, progress = onProgress, logger = internalLogger, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 8bba8e44..1650b263 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -26,7 +26,7 @@ import java.nio.ByteBuffer class ProtonDriveSdkNativeClient internal constructor( val name: String, - val response: ResponseCallback = { error("response not configured for $name") }, + val response: ClientResponseCallback = { _, _ -> error("response not configured for $name") }, val read: suspend (ByteBuffer) -> Int = { error("read not configured for $name") }, val write: suspend (ByteBuffer) -> Unit = { error("write not configured for $name") }, val httpClientRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse = { error("httpClientRequest not configured for $name") }, @@ -79,7 +79,7 @@ class ProtonDriveSdkNativeClient internal constructor( @Suppress("unused") // Called by JNI fun onResponse(data: ByteBuffer) { logger(VERBOSE, "response for $name of size: ${data.capacity()}") - response(data) + response(this, data) } @Suppress("unused") // Called by JNI diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt index 7673ca19..90eea5be 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -1,14 +1,13 @@ package me.proton.drive.sdk.internal import me.proton.drive.sdk.LoggerProvider.Level -import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE import proton.sdk.ProtonSdk.Request import java.nio.ByteBuffer class ProtonSdkNativeClient internal constructor( val name: String, - val response: ResponseCallback = { error("response not configured for $name") }, + val response: ClientResponseCallback = { _, _ -> error("response not configured for $name") }, val callback: (ByteBuffer) -> Unit = { error("callback not configured for $name") }, val logger: (Level, String) -> Unit = { _, _ -> } ) { @@ -31,7 +30,7 @@ class ProtonSdkNativeClient internal constructor( fun onResponse(data: ByteBuffer) { logger(VERBOSE, "response for $name of size: ${data.capacity()}") - response(data) + response(this, data) } fun onCallback(data: ByteBuffer) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/SdkNodeFactory.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/SdkNodeFactory.kt new file mode 100644 index 00000000..38d193ed --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/SdkNodeFactory.kt @@ -0,0 +1,16 @@ +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.SdkNode + +class SdkNodeFactory( + parent: SdkNode, private val bridge: T +) : SdkNode(parent) { + suspend fun create(block: suspend T.() -> R): R = use { + bridge.block() + } +} + +suspend fun SdkNode.factory( + bridge: T, + block: suspend T.() -> R, +): R = SdkNodeFactory(this, bridge).create(block) From 93ec1f002d171fbb5a56d8af232852de506b13f6 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 13:01:19 +0000 Subject: [PATCH 467/791] Add node metadata decryption error metrics --- .../DriveInteropTelemetryDecorator.cs | 1 + .../Api/Links/LinksApiClient.cs | 1 + .../Nodes/Download/BlockDownloader.cs | 1 - .../Nodes/DtoToMetadataConverter.cs | 123 ++++++++++++++---- .../Nodes/FolderChildrenBatchLoader.cs | 9 +- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 29 ++++- .../Nodes/TraversalOperations.cs | 28 ++++ cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs | 3 +- .../Proton.Drive.Sdk/Shares/ShareCrypto.cs | 4 +- .../Shares/ShareOperations.cs | 15 ++- .../Proton.Drive.Sdk/Telemetry/VolumeType.cs | 1 + .../Telemetry/VolumeTypeFactory.cs | 18 +++ .../Volumes/VolumeOperations.cs | 2 +- .../Volumes/VolumeTrashBatchLoader.cs | 9 +- .../Caching/PhotosSecretCache.cs | 1 - .../Nodes/PhotoDtoToMetadataConverter.cs | 7 +- .../Nodes/PhotosNodeOperations.cs | 15 ++- .../Volumes/VolumeOperations.cs | 3 +- 18 files changed, 223 insertions(+), 47 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeTypeFactory.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index 4c12a364..7e6a1e8b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -21,6 +21,7 @@ public void RecordMetric(IMetricEvent metricEvent) { UploadEvent me => GetUploadEventPayload(me), DownloadEvent me => GetDownloadEventPayload(me), + // FIXME support error metrics _ => null, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs index 04acad2e..406e8363 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -18,6 +18,7 @@ public async ValueTask GetDetailsAsync(VolumeId volumeId, I .ConfigureAwait(false); } + // FIXME use recursive lookup instead, remove this public async ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken) { return await _httpClient diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index d1b9616e..179b2627 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -105,5 +105,4 @@ private async Task WaitOnRetryAfterIfNeededAsync(Exception ex, CancellationToken Level = LogLevel.Information, Message = "Waiting {DelayDuration} before retrying blob download due to 429 response")] private partial void LogBlobDownloadWaitingForRetryAfter(TimeSpan delayDuration); - } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 63bd54cd..affdc4bb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -4,6 +4,7 @@ using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes.Cryptography; using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Telemetry; using Proton.Drive.Sdk.Volumes; using Proton.Sdk; @@ -26,11 +27,20 @@ public static async Task> ConvertDtoT linkDetailsDto.Sharing?.ShareId, cancellationToken).ConfigureAwait(false); - return await ConvertDtoToNodeMetadataAsync(client, volumeId, linkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false); + return await ConvertDtoToNodeMetadataAsync( + client, + client.Cache.Entities, + client.Cache.Secrets, + volumeId, + linkDetailsDto, + parentKeyResult, + cancellationToken).ConfigureAwait(false); } public static async Task> ConvertDtoToNodeMetadataAsync( ProtonDriveClient client, + IEntityCache entityCache, + IDriveSecretCache secretCache, VolumeId volumeId, LinkDetailsDto linkDetailsDto, Result parentKeyResult, @@ -38,13 +48,13 @@ public static async Task> ConvertDtoT { var linkType = linkDetailsDto.Link.Type; - return linkType switch + var nodeMetadata = linkType switch { LinkType.Folder => (await ConvertDtoToFolderMetadataAsync( - client.Account, - client.Cache.Entities, - client.Cache.Secrets, + client, + entityCache, + secretCache, volumeId, linkDetailsDto, parentKeyResult, @@ -53,9 +63,9 @@ public static async Task> ConvertDtoT LinkType.File => (await ConvertDtoToFileMetadataAsync( - client.Account, - client.Cache.Entities, - client.Cache.Secrets, + client, + entityCache, + secretCache, volumeId, linkDetailsDto, parentKeyResult, @@ -65,10 +75,12 @@ public static async Task> ConvertDtoT // FIXME: handle other existing node types, and determine a way for forward compatibility or degraded result in case a new node type is introduced _ => throw new NotSupportedException($"Link type {linkType} is not supported."), }; + + return nodeMetadata; } public static async ValueTask> ConvertDtoToFolderMetadataAsync( - IAccountClient accountClient, + ProtonDriveClient client, IEntityCache entityCache, IDriveSecretCache secretCache, VolumeId volumeId, @@ -89,7 +101,7 @@ public static async ValueTask> Co var uid = new NodeUid(volumeId, linkDto.Id); var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; - var decryptionResult = await NodeCrypto.DecryptFolderAsync(accountClient, linkDto, folderDto.HashKey, parentKeyResult, cancellationToken).ConfigureAwait(false); + var decryptionResult = await NodeCrypto.DecryptFolderAsync(client.Account, linkDto, folderDto.HashKey, parentKeyResult, cancellationToken).ConfigureAwait(false); var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); var nodeKeyIsInvalid = !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); @@ -107,19 +119,28 @@ public static async ValueTask> Co nameIsInvalid || nameSessionKey is null || nameOutput is null || passphraseIsInvalid || nodeKeyIsInvalid || hashKeyIsInvalid) { - var errors = new List(); + List failedDecryptionFields = new(); + List errors = new(); if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { errors.Add(new DecryptionError(passphraseError ?? "Failed to decrypt passphrase")); + failedDecryptionFields.Add(EncryptedField.NodeKey); } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { errors.Add(new DecryptionError(nodeKeyError ?? "Failed to decrypt node key")); + failedDecryptionFields.Add(EncryptedField.NodeKey); } else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) { errors.Add(new DecryptionError(hashKeyError ?? "Failed to decrypt hash key")); + failedDecryptionFields.Add(EncryptedField.NodeHashKey); + } + + if (nameResult.IsFailure) + { + failedDecryptionFields.Add(EncryptedField.NodeName); } var degradedNode = new DegradedFolderNode @@ -144,9 +165,13 @@ public static async ValueTask> Co await secretCache.SetFolderSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); - // FIXME: remove entity cache or cache degraded node + var degradedFolderMetadata = new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); + + await entityCache.SetNodeAsync(uid, degradedNode, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + + await ReportDecryptionError(client, DegradedNodeMetadata.FromFolder(degradedFolderMetadata), failedDecryptionFields, cancellationToken).ConfigureAwait(false); - return new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); + return degradedFolderMetadata; } var secrets = new FolderSecrets @@ -177,7 +202,7 @@ public static async ValueTask> Co } public static async Task> ConvertDtoToFileMetadataAsync( - IAccountClient accountClient, + ProtonDriveClient client, IEntityCache entityCache, IDriveSecretCache secretCache, VolumeId volumeId, @@ -210,7 +235,7 @@ public static async Task> ConvertDtoT var uid = new NodeUid(volumeId, linkDto.Id); var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; - var decryptionResult = await NodeCrypto.DecryptFileAsync(accountClient, linkDto, fileDto, activeRevisionDto, parentKeyResult, cancellationToken) + var decryptionResult = await NodeCrypto.DecryptFileAsync(client.Account, linkDto, fileDto, activeRevisionDto, parentKeyResult, cancellationToken) .ConfigureAwait(false); var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); @@ -250,20 +275,34 @@ public static async Task> ConvertDtoT || extendedAttributesIsInvalid || contentKeyIsInvalid) { - var errors = new List(); + List failedDecryptionFields = new(); + List errors = new(); + if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { errors.Add(new DecryptionError(passphraseError ?? "Failed to decrypt passphrase")); + failedDecryptionFields.Add(EncryptedField.NodeKey); } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { errors.Add(new DecryptionError(nodeKeyError ?? "Failed to decrypt node key")); + failedDecryptionFields.Add(EncryptedField.NodeKey); + } + else if (decryptionResult.ContentKey.IsFailure) + { + failedDecryptionFields.Add(EncryptedField.NodeContentKey); + } + + if (nameResult.IsFailure) + { + failedDecryptionFields.Add(EncryptedField.NodeName); } var revisionErrors = new List(); if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) { revisionErrors.Add(new DecryptionError(extendedAttributesError ?? "Failed to decrypt extended attributes key")); + failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes); } var degradedRevision = new DegradedRevision @@ -305,9 +344,14 @@ public static async Task> ConvertDtoT }; await secretCache.SetFileSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); - // FIXME: remove entity cache or cache degraded node - return new DegradedFileMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); + var degradedFileMetadata = new DegradedFileMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); + + await entityCache.SetNodeAsync(uid, degradedNode, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + + await ReportDecryptionError(client, DegradedNodeMetadata.FromFile(degradedFileMetadata), failedDecryptionFields, cancellationToken).ConfigureAwait(false); + + return degradedFileMetadata; } var secrets = new FileSecrets @@ -360,18 +404,18 @@ private static async ValueTask> GetParen VolumeId volumeId, LinkId? parentId, ShareAndKey? shareAndKeyToUse, - ShareId? childShareId, + ShareId? shareId, IDriveSecretCache secretCache, Func, CancellationToken, Task> getLinkDetails, CancellationToken cancellationToken) { - if (childShareId is not null && childShareId == shareAndKeyToUse?.Share.Id) + if (shareId is not null && shareId == shareAndKeyToUse?.Share.Id) { return shareAndKeyToUse.Value.Key; } var currentId = parentId; - var currentShareId = childShareId; + var currentShareId = shareId; var linkAncestry = new Stack(8); @@ -379,6 +423,7 @@ private static async ValueTask> GetParen try { + // FIXME this could go into an infinite loop if there's a structure issue in the cache. while (currentId is not null) { if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) @@ -434,6 +479,8 @@ private static async ValueTask> GetParen { var decryptionResult = await ConvertDtoToNodeMetadataAsync( client, + client.Cache.Entities, + client.Cache.Secrets, volumeId, ancestorLinkDetails, currentParentKey, @@ -456,10 +503,10 @@ private static async ValueTask> GetParen VolumeId volumeId, LinkId? parentId, ShareAndKey? shareAndKeyToUse, - ShareId? childShareId, + ShareId? shareId, CancellationToken cancellationToken) { - return await GetParentKeyAsync(client, volumeId, parentId, shareAndKeyToUse, childShareId, client.Cache.Secrets, GetLinkDetailsAsync, cancellationToken) + return await GetParentKeyAsync(client, volumeId, parentId, shareAndKeyToUse, shareId, client.Cache.Secrets, GetLinkDetailsAsync, cancellationToken) .ConfigureAwait(false); async Task GetLinkDetailsAsync(IEnumerable links, CancellationToken ct) @@ -468,4 +515,34 @@ async Task GetLinkDetailsAsync(IEnumerable links, Cancel return response.Links[0]; } } + + private static async Task ReportDecryptionError( + ProtonDriveClient client, + DegradedNodeMetadata degradedNode, + List failedDecryptionFields, + CancellationToken cancellationToken) + { + var legacyBoundary = new DateTime(2024, 1, 1, 0, 0, 0, 0, 0, DateTimeKind.Utc); + + try { + // FIXME won't work for photos in an album, this will need to be differentiated for photos. + var share = await ShareOperations.GetContextShareAsync(client, degradedNode, cancellationToken).ConfigureAwait(false); + + foreach (var failedField in failedDecryptionFields) + { + client.Telemetry.RecordMetric(new DecryptionErrorEvent + { + Uid = degradedNode.Node.Uid.ToString(), + Field = failedField, + VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), + FromBefore2024 = degradedNode.Node.CreationTime.CompareTo(legacyBoundary) < 1, + Error = "", + }); + } + } + catch + { + // Do nothing + } + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs index cb81f787..97a113d7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs @@ -23,7 +23,14 @@ protected override async ValueTask>> Lo foreach (var linkDetails in response.Links) { - var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync(_client, _volumeId, linkDetails, _parentKey, cancellationToken) + var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + _client, + _client.Cache.Entities, + _client.Cache.Secrets, + _volumeId, + linkDetails, + _parentKey, + cancellationToken) .ConfigureAwait(false); var nodeResult = nodeMetadataResult.ToNodeResult(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index c9dfcabe..e250a5a4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -45,6 +45,16 @@ public static async ValueTask GetNodeMetadataAsync( NodeUid uid, ShareAndKey? knownShareAndKey, CancellationToken cancellationToken) + { + var metadataResult = await GetNodeMetadataResultAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); + return metadataResult.GetValueOrThrow(); + } + + public static async ValueTask> GetNodeMetadataResultAsync( + ProtonDriveClient client, + NodeUid uid, + ShareAndKey? knownShareAndKey, + CancellationToken cancellationToken) { var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); @@ -54,7 +64,7 @@ public static async ValueTask GetNodeMetadataAsync( metadataResult ??= await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); - return metadataResult.Value.GetValueOrThrow(); + return (Result)metadataResult; } public static async IAsyncEnumerable> EnumerateNodesAsync( @@ -129,7 +139,12 @@ public static async ValueTask> GetFre { var response = await client.Api.Links.GetDetailsAsync(uid.VolumeId, [uid.LinkId], cancellationToken).ConfigureAwait(false); - return await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync(client, uid.VolumeId, response.Links[0], knownShareAndKey, cancellationToken) + return await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + client, + uid.VolumeId, + response.Links[0], + knownShareAndKey, + cancellationToken) .ConfigureAwait(false); } @@ -534,12 +549,20 @@ private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriv shareDto.Passphrase, shareDto.AddressId, nodeUid, + ShareType.Main, cancellationToken).ConfigureAwait(false); await client.Cache.Secrets.SetShareKeyAsync(share.Id, shareKey, cancellationToken).ConfigureAwait(false); await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); - var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync(client.Account, client.Cache.Entities, client.Cache.Secrets, volumeDto.Id, linkDetailsDto, shareKey, cancellationToken) + var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( + client, + client.Cache.Entities, + client.Cache.Secrets, + volumeDto.Id, + linkDetailsDto, + shareKey, + cancellationToken) .ConfigureAwait(false); return metadataResult.GetValueOrThrow().Node; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs new file mode 100644 index 00000000..2eaa2e56 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs @@ -0,0 +1,28 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class TraversalOperations +{ + public static async ValueTask> FindRootForNode( + ProtonDriveClient client, + Result nodeResult, + CancellationToken cancellationToken) + { + NodeUid? parentUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); + HashSet visitedNodes = new(); + + while (parentUid is not null) + { + if (!visitedNodes.Add((NodeUid)parentUid)) + { + throw new ProtonDriveException("Folder structure loop detected"); + } + + nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, (NodeUid)parentUid, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + parentUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); + } + + return nodeResult; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs index 1ad67465..64a388c3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs @@ -4,9 +4,10 @@ namespace Proton.Drive.Sdk.Shares; -internal sealed class Share(ShareId id, NodeUid rootFolderId, AddressId membershipAddressId) +internal sealed class Share(ShareId id, NodeUid rootFolderId, AddressId membershipAddressId, ShareType type) { public ShareId Id { get; } = id; public NodeUid RootFolderId { get; } = rootFolderId; public AddressId MembershipAddressId { get; } = membershipAddressId; + public ShareType Type { get; } = type; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs index a1bfbf8b..910fd468 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs @@ -15,15 +15,17 @@ internal static class ShareCrypto PgpArmoredMessage passphraseMessage, AddressId addressId, NodeUid rootFolderId, + ShareType shareType, CancellationToken cancellationToken) { var addressKeys = await client.Account.GetAddressPrivateKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + // FIXME use node if available instead of address key var passphrase = new PgpPrivateKeyRing(addressKeys).Decrypt(passphraseMessage); var key = PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase); - var share = new Share(shareId, rootFolderId, addressId); + var share = new Share(shareId, rootFolderId, addressId, shareType); return (share, key); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index b0a6d811..2d437f0d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -1,5 +1,6 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; namespace Proton.Drive.Sdk.Shares; @@ -26,6 +27,7 @@ public static async ValueTask GetShareAsync( response.Passphrase, response.AddressId, rootFolderId, + response.Type, cancellationToken).ConfigureAwait(false); await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); @@ -35,10 +37,17 @@ public static async ValueTask GetShareAsync( return new ShareAndKey(share, shareKey.Value); } - public static async ValueTask GetContextShareAsync(ProtonDriveClient client, NodeUid nodeUid, CancellationToken cancellationToken) + public static async ValueTask GetContextShareAsync(ProtonDriveClient client, Result nodeResult, CancellationToken cancellationToken) { - var response = await client.Api.Links.GetContextShareAsync(nodeUid.VolumeId, nodeUid.LinkId, cancellationToken).ConfigureAwait(false); - return await GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); + var contextRoot = await TraversalOperations.FindRootForNode(client, nodeResult, cancellationToken).ConfigureAwait(false); + ShareId? contextShareId = contextRoot.Merge(x => x.MembershipShareId, x => x.MembershipShareId); + + if (!contextShareId.HasValue) + { + throw new ProtonDriveException("Node does not have a valid context share"); + } + + return await ShareOperations.GetShareAsync(client, (ShareId)contextShareId, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs index a3b04067..9ebf4def 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs @@ -5,5 +5,6 @@ public enum VolumeType OwnVolume, Shared, SharedPublic, + OwnPhotoVolume, Photo, } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeTypeFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeTypeFactory.cs new file mode 100644 index 00000000..272047a4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeTypeFactory.cs @@ -0,0 +1,18 @@ +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Telemetry; + +internal static class VolumeTypeFactory +{ + internal static VolumeType FromShareType(ShareType shareType) + { + return shareType switch + { + ShareType.Main => VolumeType.OwnVolume, + ShareType.Photos => VolumeType.OwnPhotoVolume, + ShareType.Standard => VolumeType.Shared, + ShareType.Device => VolumeType.OwnVolume, + _ => throw new ArgumentOutOfRangeException(nameof(shareType), shareType, "Unknown share type"), + }; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 1a1b68d9..9c753b76 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -31,7 +31,7 @@ internal static class VolumeOperations var volume = new Volume(response.Volume); - var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id); + var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id, ShareType.Main); var rootFolder = new FolderNode { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs index a50e4cbc..95e41c78 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -44,7 +44,14 @@ protected override async ValueTask>> Lo parentKey = _shareKey; } - var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync(_client, _volumeId, linkDetails, parentKey, cancellationToken) + var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + _client, + _client.Cache.Entities, + _client.Cache.Secrets, + _volumeId, + linkDetails, + parentKey, + cancellationToken) .ConfigureAwait(false); var nodeResult = nodeMetadataResult.ToNodeResult(); diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs index f9e47489..2f611288 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs @@ -79,5 +79,4 @@ public ValueTask ClearAsync() { return _repository.ClearAsync(); } - } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs index adf9517c..7df52187 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs @@ -62,7 +62,7 @@ private static async Task> ConvertDto { LinkType.File => (await DtoToMetadataConverter.ConvertDtoToFileMetadataAsync( - client.DriveClient.Account, + client.DriveClient, client.Cache.Entities, client.Cache.Secrets, volumeId, @@ -73,7 +73,7 @@ private static async Task> ConvertDto LinkType.Album => (await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - client.DriveClient.Account, + client.DriveClient, client.Cache.Entities, client.Cache.Secrets, volumeId, @@ -84,7 +84,7 @@ private static async Task> ConvertDto LinkType.Folder => (await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - client.DriveClient.Account, + client.DriveClient, client.Cache.Entities, client.Cache.Secrets, volumeId, @@ -113,6 +113,7 @@ private static async ValueTask> GetParen var currentId = parentId; var currentShareId = childShareId; + // FIXME, we don't have nested folders in photos, max depth is 3 including photo. var linkAncestry = new Stack(8); PgpPrivateKey? lastKey = null; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs index 0d09e1b8..22702276 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs @@ -111,19 +111,20 @@ private static async ValueTask GetFreshPhotosFolderAsync(ProtonPhoto shareDto.Passphrase, shareDto.AddressId, nodeUid, + ShareType.Photos, cancellationToken).ConfigureAwait(false); await photosClient.DriveClient.Cache.Secrets.SetShareKeyAsync(share.Id, shareKey, cancellationToken).ConfigureAwait(false); await photosClient.DriveClient.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - photosClient.DriveClient.Account, - photosClient.Cache.Entities, - photosClient.Cache.Secrets, - volumeDto.Id, - linkDetailsDto, - shareKey, - cancellationToken) + photosClient.DriveClient, + photosClient.Cache.Entities, + photosClient.Cache.Secrets, + volumeDto.Id, + linkDetailsDto, + shareKey, + cancellationToken) .ConfigureAwait(false); return metadataResult.GetValueOrThrow().Node; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs index e7c54c78..db2996b9 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs @@ -1,5 +1,6 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk; +using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; @@ -29,7 +30,7 @@ internal static class VolumeOperations var volume = new Volume(response.Volume); - var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id); + var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id, ShareType.Photos); var rootFolder = new FolderNode { From 2430175788849e99c1f0027bf27217dc4f6eb134 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 13:16:33 +0000 Subject: [PATCH 468/791] Replace stream with channel --- .../me/proton/drive/sdk/CommonDownloadController.kt | 3 +++ kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt | 4 ++-- .../src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt | 7 +++---- .../main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt | 7 +++---- .../main/kotlin/me/proton/drive/sdk/UploadController.kt | 5 +++-- kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt | 8 +++----- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt index 132cd9bd..b7cc0f58 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -7,11 +7,13 @@ import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.internal.CoroutineScopeConsumer import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.toLogId +import java.nio.channels.Channel class CommonDownloadController internal constructor( downloader: SdkNode, internal val handle: Long, private val bridge: JniDownloadController, + private val channel: Channel, private val coroutineScopeConsumer: CoroutineScopeConsumer, override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(downloader), DownloadController { @@ -53,6 +55,7 @@ class CommonDownloadController internal constructor( override fun close() { log(DEBUG, "close") + channel.close() bridge.free(handle) super.close() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt index 56164789..6f2fe93a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -1,13 +1,13 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import java.io.OutputStream +import java.nio.channels.WritableByteChannel interface Downloader : AutoCloseable, Cancellable { suspend fun downloadToStream( coroutineScope: CoroutineScope, - outputStream: OutputStream, + channel: WritableByteChannel, progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): DownloadController } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index 3e43551c..06413604 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -8,8 +8,7 @@ import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniFileDownloader import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId -import java.io.OutputStream -import java.nio.channels.Channels +import java.nio.channels.WritableByteChannel import java.util.concurrent.atomic.AtomicReference class FileDownloader internal constructor( @@ -21,12 +20,11 @@ class FileDownloader internal constructor( override suspend fun downloadToStream( coroutineScope: CoroutineScope, - outputStream: OutputStream, + channel: WritableByteChannel, progress: suspend (Long, Long) -> Unit, ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> log(INFO, "downloadToStream") val coroutineScopeReference = AtomicReference(coroutineScope) - val channel = Channels.newChannel(outputStream) val handle = bridge.downloadToStream( handle = handle, cancellationTokenSourceHandle = cancellationTokenSource.handle, @@ -43,6 +41,7 @@ class FileDownloader internal constructor( downloader = this@FileDownloader, handle = handle, bridge = JniDownloadController(), + channel = channel, cancellationTokenSource = cancellationTokenSource, coroutineScopeConsumer = coroutineScopeReference::set, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index b271a738..5ad68e50 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -8,8 +8,7 @@ import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniPhotosDownloader import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId -import java.io.OutputStream -import java.nio.channels.Channels +import java.nio.channels.WritableByteChannel import java.util.concurrent.atomic.AtomicReference class PhotosDownloader internal constructor( @@ -21,12 +20,11 @@ class PhotosDownloader internal constructor( override suspend fun downloadToStream( coroutineScope: CoroutineScope, - outputStream: OutputStream, + channel: WritableByteChannel, progress: suspend (Long, Long) -> Unit, ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> log(INFO, "downloadToStream") val coroutineScopeReference = AtomicReference(coroutineScope) - val channel = Channels.newChannel(outputStream) val handle = bridge.downloadToStream( handle = handle, cancellationTokenSourceHandle = cancellationTokenSource.handle, @@ -43,6 +41,7 @@ class PhotosDownloader internal constructor( downloader = this@PhotosDownloader, handle = handle, bridge = JniDownloadController(), + channel = channel, cancellationTokenSource = cancellationTokenSource, coroutineScopeConsumer = coroutineScopeReference::set, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index b5390e9f..550725a7 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -9,12 +9,13 @@ import me.proton.drive.sdk.internal.CoroutineScopeConsumer import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId import java.io.InputStream +import java.nio.channels.Channel class UploadController internal constructor( uploader: Uploader, internal val handle: Long, private val bridge: JniUploadController, - private val inputStream: InputStream, + private val channel: Channel, private val coroutineScopeConsumer: CoroutineScopeConsumer, override val cancellationTokenSource: CancellationTokenSource, ) : SdkNode(uploader), AutoCloseable, Cancellable { @@ -58,7 +59,7 @@ class UploadController internal constructor( override fun close() { log(DEBUG, "close") - inputStream.close() + channel.close() bridge.free(handle) super.close() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 80850602..9115e8d6 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -11,8 +11,7 @@ import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.JniUploader import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId -import java.io.InputStream -import java.nio.channels.Channels +import java.nio.channels.ReadableByteChannel import java.util.concurrent.atomic.AtomicReference class Uploader internal constructor( @@ -24,12 +23,11 @@ class Uploader internal constructor( suspend fun uploadFromStream( coroutineScope: CoroutineScope, - inputStream: InputStream, + channel: ReadableByteChannel, thumbnails: Map = emptyMap(), progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): UploadController = cancellationTokenSource().let { source -> log(INFO, "uploadFromStream") - val channel = Channels.newChannel(inputStream) val coroutineScopeReference = AtomicReference(coroutineScope) val handle = bridge.uploadFromStream( uploaderHandle = handle, @@ -49,7 +47,7 @@ class Uploader internal constructor( handle = handle, bridge = JniUploadController(), cancellationTokenSource = source, - inputStream = inputStream, + channel = channel, coroutineScopeConsumer = coroutineScopeReference::set, ) } From 328fc08dde99c0c64070c261e24fcce27e632131 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 14:24:16 +0100 Subject: [PATCH 469/791] Replace stream by channel for thumbnails --- .../main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt | 8 ++++---- .../main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt | 6 +++--- .../main/kotlin/me/proton/drive/sdk/UploadController.kt | 1 - 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index c1f5f7dd..21d69999 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -15,7 +15,7 @@ import proton.drive.sdk.driveClientCreateFolderRequest import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetThumbnailsRequest import proton.drive.sdk.driveClientRenameRequest -import java.io.OutputStream +import java.nio.channels.WritableByteChannel class ProtonDriveClient internal constructor( internal val handle: Long, @@ -41,7 +41,7 @@ class ProtonDriveClient internal constructor( suspend fun getThumbnails( fileUids: List, type: ThumbnailType, - block: (String) -> OutputStream, + block: (String) -> WritableByteChannel, ): Unit = cancellationCoroutineScope { source -> log(INFO, "getThumbnails($type)") bridge.getThumbnails( @@ -52,8 +52,8 @@ class ProtonDriveClient internal constructor( cancellationTokenSourceHandle = source.handle } ).thumbnailsList.forEach { fileThumbnail -> - block(fileThumbnail.fileUid).use { outputStream -> - outputStream.write(fileThumbnail.data.toByteArray()) + block(fileThumbnail.fileUid).use { channel -> + channel.write(fileThumbnail.data.asReadOnlyByteBuffer()) } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 03dfcd58..918e1c54 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -9,7 +9,7 @@ import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.drivePhotosClientEnumeratePhotosThumbnailsRequest -import java.io.OutputStream +import java.nio.channels.WritableByteChannel class ProtonPhotosClient internal constructor( internal val handle: Long, @@ -20,7 +20,7 @@ class ProtonPhotosClient internal constructor( suspend fun getThumbnails( photoUids: List, type: ThumbnailType, - block: (String) -> OutputStream, + block: (String) -> WritableByteChannel, ): Unit = cancellationCoroutineScope { source -> log(INFO, "getThumbnails($type)") bridge.getThumbnails( @@ -32,7 +32,7 @@ class ProtonPhotosClient internal constructor( } ).thumbnailsList.forEach { photoThumbnail -> block(photoThumbnail.fileUid).use { outputStream -> - outputStream.write(photoThumbnail.data.toByteArray()) + outputStream.write(photoThumbnail.data.asReadOnlyByteBuffer()) } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index 550725a7..3dab01cd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -8,7 +8,6 @@ import me.proton.drive.sdk.entity.UploadResult import me.proton.drive.sdk.internal.CoroutineScopeConsumer import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId -import java.io.InputStream import java.nio.channels.Channel class UploadController internal constructor( From 403413c14b07e31b9eb8a88b4752f41973cd51c2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 13:48:06 +0100 Subject: [PATCH 470/791] Enforce static code analysis warnings as errors on release builds --- cs/.globalconfig | 88 +++++-------------- cs/Directory.Build.props | 2 +- cs/Directory.Packages.props | 14 +-- .../DriveInteropTelemetryDecorator.cs | 1 + .../InteropDriveErrorConverter.cs | 2 +- .../InteropPhotosDownloader.cs | 1 - .../InteropProtonDriveClient.cs | 12 ++- .../InteropProtonPhotosClient.cs | 2 +- .../Proton.Drive.Sdk.CExports.csproj | 6 +- .../Caching/DriveEntityCache.cs | 10 +-- .../Caching/DriveSecretCache.cs | 10 +-- .../Nodes/Download/DownloadController.cs | 28 +++--- .../Nodes/DtoToMetadataConverter.cs | 15 ++-- .../Nodes/FolderOperations.cs | 2 +- .../Nodes/TraversalOperations.cs | 4 +- .../Nodes/Upload/BlockUploader.cs | 1 - .../Shares/ShareOperations.cs | 3 +- .../Caching/PhotosSecretCache.cs | 10 +-- .../src/Proton.Sdk.CExports/InteropStream.cs | 12 ++- .../AlwaysDisabledFeatureFlagProvider.cs | 3 +- .../Caching/InMemoryCacheRepository.cs | 2 +- .../Caching/SqliteCacheRepository.cs | 26 ++---- .../Http/TlsRemoteCertificateValidator.cs | 2 + cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 10 +-- 24 files changed, 117 insertions(+), 149 deletions(-) diff --git a/cs/.globalconfig b/cs/.globalconfig index 01e77d9b..4034a275 100644 --- a/cs/.globalconfig +++ b/cs/.globalconfig @@ -3,10 +3,16 @@ is_global = true stylecop.layout.allowConsecutiveUsings = true stylecop.layout.allowDoWhileOnClosingBrace = true +# IDE0007: Use var instead of explicit type +dotnet_diagnostic.IDE0007.severity = warning + +# IDE0007: Use collection initializers or expressions +dotnet_diagnostic.IDE0028.severity = warning + dotnet_diagnostic.CA1032.severity = warning -dotnet_diagnostic.CA1711.severity = suggestion +dotnet_diagnostic.CA1711.severity = warning dotnet_diagnostic.CA2000.severity = suggestion -dotnet_diagnostic.CA2201.severity = suggestion +dotnet_diagnostic.CA2201.severity = warning dotnet_diagnostic.CA2215.severity = warning # StyleCop - Special @@ -23,13 +29,13 @@ dotnet_diagnostic.SA1009.severity = suggestion # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/ReadabilityRules.md dotnet_diagnostic.SA1101.severity = none -dotnet_diagnostic.SA1200.severity = none -dotnet_diagnostic.SA1216.severity = none -dotnet_diagnostic.SA1217.severity = none -dotnet_diagnostic.SA1413.severity = none -dotnet_diagnostic.SA1502.severity = none dotnet_diagnostic.SA1516.severity = none +# StyleCop - Naming +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/NamingRules.md + +dotnet_diagnostic.SA1309.severity = none + # StyleCop - Documentation # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/DocumentationRules.md @@ -77,70 +83,22 @@ dotnet_diagnostic.SA1648.severity = none dotnet_diagnostic.SX1101.severity = warning dotnet_diagnostic.SX1309.severity = warning -dotnet_diagnostic.SA1309.severity = none # Roslynator -dotnet_diagnostic.RCS1037.severity = none # Redundant with SA1028 -dotnet_diagnostic.RCS1194.severity = none # Redundant with CA1032 - -# To discuss, remove to enable: - -# IDE0058: Expression value is never used -dotnet_diagnostic.IDE0058.severity = none - -# CA1806: Do not ignore method results -dotnet_diagnostic.CA1806.severity = none - -# SA1005: Single line comments should begin with single space -dotnet_diagnostic.SA1005.severity = suggestion - -# SA1108: Block statements should not contain embedded comments -dotnet_diagnostic.SA1108.severity = suggestion -# SA1111: Closing parenthesis should be on line of last parameter -dotnet_diagnostic.SA1111.severity = suggestion - -# SA1122: Use string.Empty for empty strings -dotnet_diagnostic.SA1122.severity = suggestion - -# SA1127: Generic type constraints should be on their own line -dotnet_diagnostic.SA1127.severity = suggestion - -# SA1128: Put constructor initializers on their own line -dotnet_diagnostic.SA1128.severity = suggestion - -# SA1201: Elements should appear in the correct order -dotnet_diagnostic.SA1201.severity = suggestion - -# SA1202: Elements should be ordered by access -dotnet_diagnostic.SA1202.severity = suggestion - -# SA1204: Static elements should appear before instance elements -dotnet_diagnostic.SA1204.severity = suggestion - -# SA1214: Readonly fields should appear before non-readonly fields -dotnet_diagnostic.SA1214.severity = suggestion - -# SA1408: Conditional expressions should declare precedence -dotnet_diagnostic.SA1408.severity = suggestion - -# SA1503: Braces should not be omitted -dotnet_diagnostic.SA1503.severity = suggestion - -# SA1512: Single-line comments should not be followed by blank line -dotnet_diagnostic.SA1512.severity = suggestion +# RCS1037: Remove trailing white-space +dotnet_diagnostic.RCS1037.severity = none # Redundant with SA1028 -# SA1515: Single-line comment should be preceded by blank line -dotnet_diagnostic.SA1515.severity = suggestion +# RCS1047: Non-asynchronous method name should not end with 'Async' +dotnet_diagnostic.RCS1047.severity = warning -# SA1519: Braces should not be omitted from multi-line child statement -dotnet_diagnostic.SA1519.severity = suggestion +# RCS1090: Add parameter to exception constructor +dotnet_diagnostic.RCS1194.severity = none # Redundant with CA1032 -# RCS1139: Add summary element to documentation comment. -dotnet_diagnostic.RCS1139.severity = suggestion +# Sonar -# RCS1181: Convert comment to documentation comment. -dotnet_diagnostic.RCS1181.severity = none +# S1134: Track uses of "FIXME" tags +dotnet_diagnostic.S1134.severity = suggestion # S1135: Complete the task associated to this 'TODO' comment -dotnet_diagnostic.S1135.severity = suggestion \ No newline at end of file +dotnet_diagnostic.S1135.severity = suggestion diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 9fa559c6..cc66a9f9 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -19,7 +19,7 @@ enable en true - + true lib diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index ee61e865..c7ec4fdd 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -13,9 +13,9 @@ - - - + + + @@ -27,14 +27,14 @@ - + - + - - + + \ No newline at end of file diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index 7e6a1e8b..e4ba05b6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -21,6 +21,7 @@ public void RecordMetric(IMetricEvent metricEvent) { UploadEvent me => GetUploadEventPayload(me), DownloadEvent me => GetDownloadEventPayload(me), + // FIXME support error metrics _ => null, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs index c84eced6..369fe4ab 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -9,7 +9,6 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropDriveErrorConverter { private const int UnknownDecryptionErrorPrimaryCode = 0; - private const int ShareMetadataDecryptionErrorPrimaryCode = 1; private const int NodeMetadataDecryptionErrorPrimaryCode = 2; private const int FileContentsDecryptionErrorPrimaryCode = 3; private const int UploadKeyMismatchErrorPrimaryCode = 4; @@ -70,6 +69,7 @@ public static void SetDomainAndCodes(Error error, Exception exception) break; default: + error.PrimaryCode = UnknownDecryptionErrorPrimaryCode; InteropErrorConverter.SetDomainAndCodes(error, exception); break; } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs index 344c3f44..603b9807 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -1,7 +1,6 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Photos.Sdk.Nodes; -using Proton.Drive.Sdk.Nodes.Download; using Proton.Sdk.CExports; namespace Proton.Drive.Sdk.CExports; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 449e54c0..6623d5b4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -228,15 +228,19 @@ public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNo { results.Select(pair => { - var result = new NodeResultPair(); - result.NodeUid = pair.Key.ToString(); + var result = new NodeResultPair + { + NodeUid = pair.Key.ToString(), + }; + if (pair.Value.TryGetError(out var error)) { result.Error = error; } + return result; - }) - } + }), + }, }; return response; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 9766a64b..e5cfa5aa 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -49,7 +49,7 @@ public static IMessage HandleCreate(DrivePhotosClientCreateRequest request, nint var featureFlagProvider = request.HasFeatureEnabledFunction ? new InteropFeatureFlagProvider(bindingsHandle, new InteropFunction, int>(request.FeatureEnabledFunction)) : AlwaysDisabledFeatureFlagProvider.Instance; - + var client = new ProtonPhotosClient( httpClientFactory, accountClient, diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index 0f2b9134..553f5176 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -6,7 +6,11 @@ true false proton_crypto - Photos + + $(NoWarn);Photos + + + $(NoWarn);IL2113 diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 54222723..069e5646 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -95,6 +95,11 @@ public async ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancel await _repository.RemoveAsync(GetNodeCacheKey(nodeUid), cancellationToken).ConfigureAwait(false); } + public ValueTask ClearAsync() + { + return _repository.ClearAsync(); + } + private static string GetShareCacheKey(ShareId shareId) { return $"share:{shareId}"; @@ -104,9 +109,4 @@ private static string GetNodeCacheKey(NodeUid nodeId) { return $"node:{nodeId}"; } - - public ValueTask ClearAsync() - { - return _repository.ClearAsync(); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs index 8f7f7da0..4e0d7871 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -67,6 +67,11 @@ public ValueTask SetFileSecretsAsync( : null; } + public ValueTask ClearAsync() + { + return _repository.ClearAsync(); + } + private static string GetShareKeyCacheKey(ShareId shareId) { return $"share:{shareId}:key"; @@ -81,9 +86,4 @@ private static string GetFileSecretsCacheKey(NodeUid nodeId) { return $"file:{nodeId}:secrets"; } - - public ValueTask ClearAsync() - { - return _repository.ClearAsync(); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 322d007c..142bb6aa 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -14,12 +14,26 @@ internal DownloadController(Task downloadTask) // FIXME public bool IsPaused { get; } + public Task Completion { get; private set; } + public bool GetIsDownloadCompleteWithVerificationIssue() { return _isDownloadCompleteWithVerificationIssue; } - public Task Completion { get; private set; } +#pragma warning disable S2325 // Methods and properties that don't access instance data should be static: waiting for implementation + public void Pause() + { + // FIXME + throw new NotImplementedException(); + } + + public void Resume() + { + // FIXME + throw new NotImplementedException(); + } +#pragma warning restore S2325 // Methods and properties that don't access instance data should be static private async Task WrapDownloadTaskAsync() { @@ -33,16 +47,4 @@ private async Task WrapDownloadTaskAsync() throw new DataIntegrityException(error.Message); } } - - public void Pause() - { - // FIXME - throw new NotImplementedException(); - } - - public void Resume() - { - // FIXME - throw new NotImplementedException(); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index affdc4bb..2f047de7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -119,8 +119,8 @@ public static async ValueTask> Co nameIsInvalid || nameSessionKey is null || nameOutput is null || passphraseIsInvalid || nodeKeyIsInvalid || hashKeyIsInvalid) { - List failedDecryptionFields = new(); - List errors = new(); + List failedDecryptionFields = []; + List errors = []; if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { @@ -275,12 +275,12 @@ public static async Task> ConvertDtoT || extendedAttributesIsInvalid || contentKeyIsInvalid) { - List failedDecryptionFields = new(); - List errors = new(); + List failedDecryptionFields = []; + List errors = []; if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { - errors.Add(new DecryptionError(passphraseError ?? "Failed to decrypt passphrase")); + errors.Add(new DecryptionError(passphraseError)); failedDecryptionFields.Add(EncryptedField.NodeKey); } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) @@ -524,7 +524,8 @@ private static async Task ReportDecryptionError( { var legacyBoundary = new DateTime(2024, 1, 1, 0, 0, 0, 0, 0, DateTimeKind.Utc); - try { + try + { // FIXME won't work for photos in an album, this will need to be differentiated for photos. var share = await ShareOperations.GetContextShareAsync(client, degradedNode, cancellationToken).ConfigureAwait(false); @@ -536,7 +537,7 @@ private static async Task ReportDecryptionError( Field = failedField, VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), FromBefore2024 = degradedNode.Node.CreationTime.CompareTo(legacyBoundary) < 1, - Error = "", + Error = string.Empty, }); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 11bca52d..2e191fd9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -90,7 +90,7 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, Common = new CommonExtendedAttributes { ModificationTime = lastModificationTime?.UtcDateTime, - } + }, }; var extendedAttributesUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(extendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs index 2eaa2e56..a2f4b704 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs @@ -9,8 +9,8 @@ public static async ValueTask> FindRo Result nodeResult, CancellationToken cancellationToken) { - NodeUid? parentUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); - HashSet visitedNodes = new(); + var parentUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); + HashSet visitedNodes = []; while (parentUid is not null) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 8f76aaa6..c2559b29 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -81,7 +81,6 @@ public async ValueTask UploadContentAsync( var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; // FIXME: retry upon verification failure - const long AeadChunkSize = 1 + // packet header: packet type 1 + // packet header: partial length diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index 2d437f0d..a561e249 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -39,9 +39,8 @@ public static async ValueTask GetShareAsync( public static async ValueTask GetContextShareAsync(ProtonDriveClient client, Result nodeResult, CancellationToken cancellationToken) { - var contextRoot = await TraversalOperations.FindRootForNode(client, nodeResult, cancellationToken).ConfigureAwait(false); - ShareId? contextShareId = contextRoot.Merge(x => x.MembershipShareId, x => x.MembershipShareId); + var contextShareId = contextRoot.Merge(x => x.MembershipShareId, x => x.MembershipShareId); if (!contextShareId.HasValue) { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs index 2f611288..96c335d6 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs @@ -60,6 +60,11 @@ public ValueTask SetFileSecretsAsync( throw new NotSupportedException(); } + public ValueTask ClearAsync() + { + return _repository.ClearAsync(); + } + private static string GetShareKeyCacheKey(ShareId shareId) { return $"share:{shareId}:key"; @@ -74,9 +79,4 @@ private static string GetFileSecretsCacheKey(NodeUid nodeId) { return $"file:{nodeId}:secrets"; } - - public ValueTask ClearAsync() - { - return _repository.ClearAsync(); - } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs index e06dd938..4dbf32cf 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -52,7 +52,9 @@ public static async ValueTask HandleReadAsync(StreamReadRequest reques return new Int32Value { Value = bytesRead }; } - public override void Flush() { } + public override void Flush() + { + } public override int Read(byte[] buffer, int offset, int count) { @@ -138,8 +140,12 @@ public override MemoryHandle Pin(int elementIndex = 0) return new MemoryHandle(_pointer + elementIndex); } - public override void Unpin() { } + public override void Unpin() + { + } - protected override void Dispose(bool disposing) { } + protected override void Dispose(bool disposing) + { + } } } diff --git a/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs index d045f86f..ee48ae0f 100644 --- a/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs +++ b/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs @@ -9,7 +9,8 @@ internal sealed class AlwaysDisabledFeatureFlagProvider : IFeatureFlagProvider public static readonly IFeatureFlagProvider Instance = new AlwaysDisabledFeatureFlagProvider(); private AlwaysDisabledFeatureFlagProvider() - { } + { + } public Task IsEnabledAsync(string flagName, CancellationToken cancellationToken) { diff --git a/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs index 1959288a..339724e8 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs @@ -158,7 +158,7 @@ public void Clear() } else { - candidateKeys = new HashSet(keysWithTag); + candidateKeys = [.. keysWithTag]; } if (candidateKeys.Count == 0) diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index 9ef32662..51e11e94 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -16,24 +16,18 @@ private SqliteCacheRepository(SqliteConnection connection, int? maxCacheSize) public static SqliteCacheRepository OpenInMemory(int? maxCacheSize = 1024) { - var connectionStringBuilder = new SqliteConnectionStringBuilder - { - DataSource = Guid.NewGuid().ToString(), - Mode = SqliteOpenMode.Memory, - Cache = SqliteCacheMode.Shared, - }; + // Avoiding SqliteConnectionStringBuilder due to IL2113 warning in AOT scenarios + var connectionString = $"Data Source={Guid.NewGuid().ToString()};Mode=Memory;Cache=Shared"; - return Open(connectionStringBuilder, maxCacheSize); + return Open(connectionString, maxCacheSize); } public static SqliteCacheRepository OpenFile(string path, int? maxCacheSize = 1024) { - var connectionStringBuilder = new SqliteConnectionStringBuilder - { - DataSource = path, - }; + // Avoiding SqliteConnectionStringBuilder due to IL2113 warning in AOT scenarios + var connectionString = $"Data Source=\"{path}\""; - return Open(connectionStringBuilder, maxCacheSize); + return Open(connectionString, maxCacheSize); } ValueTask ICacheRepository.SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) @@ -306,7 +300,7 @@ HAVING COUNT(DISTINCT t.Tag) = @tagCount $"UPDATE Entries SET LastAccessedUtc = @timestamp WHERE Key IN ({keyParams})"; updateCommand.Parameters.AddWithValue("@timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - for (int i = 0; i < results.Count; i++) + for (var i = 0; i < results.Count; i++) { updateCommand.Parameters.AddWithValue($"@key{i}", results[i].Key); } @@ -354,15 +348,13 @@ LIMIT @count command.ExecuteNonQuery(); } - private static SqliteCacheRepository Open(SqliteConnectionStringBuilder connectionStringBuilder, int? maxCacheSize) + private static SqliteCacheRepository Open(string connectionString, int? maxCacheSize) { - if (maxCacheSize is not null && maxCacheSize.Value <= 0) + if (maxCacheSize <= 0) { throw new ArgumentOutOfRangeException(nameof(maxCacheSize), "Max cache size must be greater than 0 or null to disable LRU."); } - var connectionString = connectionStringBuilder.ConnectionString; - var connection = new SqliteConnection(connectionString); try diff --git a/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs b/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs index e1e5e1c2..848add70 100644 --- a/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs +++ b/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs @@ -38,6 +38,7 @@ private static bool IsValid(X509Certificate certificate) } var validHashFound = false; +#pragma warning disable S3267 // Loops should be simplified with "LINQ" expressions: LINQ cannot be used here because of Span foreach (var knownPublicKeyHashDigest in KnownPublicKeySha256Digests) { if (knownPublicKeyHashDigest.AsSpan().SequenceEqual(hashDigestBuffer)) @@ -46,6 +47,7 @@ private static bool IsValid(X509Certificate certificate) break; } } +#pragma warning restore S3267 // Loops should be simplified with "LINQ" expressions return validHashFound; } diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs index ed236486..6393de6f 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -45,6 +45,11 @@ public ValueTask
GetCurrentUserDefaultAddressAsync(CancellationToken ca return AddressOperations.GetCurrentUserDefaultAddressAsync(this, cancellationToken); } + public ValueTask GetAddressPrivateKeyAsync(AddressId addressId, int index, CancellationToken cancellationToken) + { + return AddressOperations.GetAddressPrivateKeyAsync(this, addressId, index, cancellationToken); + } + internal ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken) { return AddressOperations.GetAddressPrivateKeysAsync(this, addressId, cancellationToken); @@ -55,11 +60,6 @@ internal ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId add return AddressOperations.GetAddressPrimaryPrivateKeyAsync(this, addressId, cancellationToken); } - public ValueTask GetAddressPrivateKeyAsync(AddressId addressId, int index, CancellationToken cancellationToken) - { - return AddressOperations.GetAddressPrivateKeyAsync(this, addressId, index, cancellationToken); - } - internal ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) { return AddressOperations.GetPublicKeysAsync(this, emailAddress, cancellationToken); From ec484901856cfbabcd79f56203b432fe15488679 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 16:00:40 +0000 Subject: [PATCH 471/791] Replace stream with buffer for HTTP --- .../proton/drive/sdk/extension/HttpStream.kt | 23 ++++++++----------- .../drive/sdk/internal/ApiProviderBridge.kt | 11 +++++++-- .../proton/drive/sdk/internal/HttpStream.kt | 6 ++--- .../drive/sdk/internal/JniHttpStream.kt | 8 +++---- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt index 4292d679..f6ba3f5b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt @@ -5,37 +5,34 @@ import me.proton.drive.sdk.internal.HttpStream import okhttp3.MediaType import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody +import okio.Buffer import okio.BufferedSink import proton.sdk.ProtonSdk.HttpRequest -import java.io.ByteArrayOutputStream import java.nio.ByteBuffer -import java.nio.channels.Channels internal suspend fun HttpStream.read( request: HttpRequest ): RequestBody { - val outputStream = ByteArrayOutputStream() + val buffer = Buffer() if (request.hasSdkContentHandle()) { - val buffer = ByteBuffer.allocateDirect(64 * 1024) - val channel = Channels.newChannel(outputStream) + val byteBuffer = ByteBuffer.allocateDirect(64 * 1024) while (true) { - buffer.clear() - val bytesRead = read(request.sdkContentHandle, buffer) + byteBuffer.clear() + val bytesRead = read(request.sdkContentHandle, byteBuffer) if (bytesRead <= 0) break - buffer.position(bytesRead) + byteBuffer.position(bytesRead) // Flip so we can read bytes from ByteBuffer - buffer.flip() + byteBuffer.flip() - // Write directly from ByteBuffer to okio - channel.write(buffer) + // Write directly from ByteBuffer to okio Buffer + buffer.write(byteBuffer) } } - val body = outputStream.toByteArray().toRequestBody() - return body + return buffer.snapshot().toRequestBody() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt index e7e37a88..ed83e817 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -16,6 +16,7 @@ import proton.sdk.ProtonSdk.HttpResponse import proton.sdk.httpHeader import proton.sdk.httpResponse import retrofit2.Response +import java.nio.channels.Channels internal class ApiProviderBridge( private val userId: UserId, @@ -49,7 +50,10 @@ internal class ApiProviderBridge( } } response.body?.byteStream()?.let { inputStream -> - bindingsContentHandle = httpStream.write(coroutineScope, inputStream) + bindingsContentHandle = httpStream.write( + coroutineScope = coroutineScope, + channel = Channels.newChannel(inputStream), + ) } } } @@ -67,7 +71,10 @@ internal class ApiProviderBridge( } } response.body()?.byteStream()?.let { inputStream -> - bindingsContentHandle = httpStream.write(coroutineScope, inputStream) + bindingsContentHandle = httpStream.write( + coroutineScope = coroutineScope, + channel = Channels.newChannel(inputStream), + ) } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt index b29bce8e..9bf6b894 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt @@ -1,8 +1,8 @@ package me.proton.drive.sdk.internal import kotlinx.coroutines.CoroutineScope -import java.io.InputStream import java.nio.ByteBuffer +import java.nio.channels.ReadableByteChannel class HttpStream internal constructor( @@ -12,8 +12,8 @@ class HttpStream internal constructor( suspend fun read(sdkContentHandle: Long, buffer: ByteBuffer) = bridge.read(sdkContentHandle, buffer) - fun write(coroutineScope: CoroutineScope, inputStream: InputStream): Long = - bridge.write(coroutineScope, inputStream) + fun write(coroutineScope: CoroutineScope, channel: ReadableByteChannel): Long = + bridge.write(coroutineScope, channel) override fun close() { bridge.release() diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index a994b25c..dd145226 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -4,9 +4,8 @@ import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.extension.toIntResponse import proton.sdk.request import proton.sdk.streamReadRequest -import java.io.InputStream import java.nio.ByteBuffer -import java.nio.channels.Channels +import java.nio.channels.ReadableByteChannel class JniHttpStream internal constructor( ) : JniBaseProtonSdk() { @@ -17,15 +16,14 @@ class JniHttpStream internal constructor( fun write( coroutineScope: CoroutineScope, - inputStream: InputStream, + channel: ReadableByteChannel, ): Long { - val channel = Channels.newChannel(inputStream) return ProtonDriveSdkNativeClient( name = method("write"), readHttpBody = { buffer -> channel.read(buffer).also { numberOfByteRead -> if (numberOfByteRead == -1) { - inputStream.close() + channel.close() onBodyRead?.invoke() } } From 7ffd10d11c85a0293bcb23ae26c56b106a401e15 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 17:44:02 +0100 Subject: [PATCH 472/791] Add file upload methods to the Photos client --- .../InteropMessageHandler.cs | 15 ++ .../InteropPhotosDownloader.cs | 7 +- .../InteropPhotosUploader.cs | 70 ++++++++ .../InteropProtonPhotosClient.cs | 46 +++++ .../Nodes/FileUploadMetadata.cs | 9 + .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 5 +- .../PhotosFileDownloader.cs} | 10 +- .../Nodes/PhotosFileUploadMetadata.cs | 15 ++ .../Nodes/Upload/PhotosFileUploader.cs | 36 ++++ .../Proton.Photos.Sdk/ProtonPhotosClient.cs | 16 +- cs/sdk/src/protos/proton.drive.sdk.proto | 72 +++++++- .../{ => Downloads}/DownloadOperation.swift | 19 +-- .../DownloadThumbnailsManager.swift | 0 .../{ => Downloads}/DownloadsManager.swift | 2 +- .../PhotoDownloadsManager.swift | 2 +- .../FileOperations/OperationType.swift | 6 + .../Uploads/PhotoUploadsManager.swift | 159 ++++++++++++++++++ .../{ => Uploads}/UploadOperation.swift | 56 ++++-- .../{ => Uploads}/UploadsManager.swift | 1 + .../Sources/Plumbing/Message+Packaging.swift | 32 ++++ 20 files changed, 534 insertions(+), 44 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs rename cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/{PhotosDownloader.cs => Download/PhotosFileDownloader.cs} (91%) create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs create mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs rename swift/ProtonDriveSDK/Sources/FileOperations/{ => Downloads}/DownloadOperation.swift (95%) rename swift/ProtonDriveSDK/Sources/FileOperations/{ => Downloads}/DownloadThumbnailsManager.swift (100%) rename swift/ProtonDriveSDK/Sources/FileOperations/{ => Downloads}/DownloadsManager.swift (99%) rename swift/ProtonDriveSDK/Sources/FileOperations/{ => Downloads}/PhotoDownloadsManager.swift (99%) create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/OperationType.swift create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift rename swift/ProtonDriveSDK/Sources/FileOperations/{ => Uploads}/UploadOperation.swift (85%) rename swift/ProtonDriveSDK/Sources/FileOperations/{ => Uploads}/UploadsManager.swift (99%) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index a147e9db..1634bf0c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -133,6 +133,21 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DrivePhotosClientDownloaderFree => InteropPhotosDownloader.HandleFree(request.DrivePhotosClientDownloaderFree), + Request.PayloadOneofCase.DrivePhotosClientGetPhotoUploader + => await InteropProtonPhotosClient.HandleGetFileUploaderAsync(request.DrivePhotosClientGetPhotoUploader).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientFindDuplicates + => await InteropProtonPhotosClient.HandleFindDuplicatesAsync(request.DrivePhotosClientFindDuplicates, bindingsHandle).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientUploadFromStream + => InteropPhotosUploader.HandleUploadFromStream(request.DrivePhotosClientUploadFromStream, bindingsHandle), + + Request.PayloadOneofCase.DrivePhotosClientUploadFromFile + => InteropPhotosUploader.HandleUploadFromFile(request.DrivePhotosClientUploadFromFile, bindingsHandle), + + Request.PayloadOneofCase.DrivePhotosClientUploaderFree + => InteropPhotosUploader.HandleFree(request.DrivePhotosClientUploaderFree), + Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs index 603b9807..944bec51 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -1,6 +1,7 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Photos.Sdk.Nodes; +using Proton.Photos.Sdk.Nodes.Download; using Proton.Sdk.CExports; namespace Proton.Drive.Sdk.CExports; @@ -11,7 +12,7 @@ public static IMessage HandleDownloadToStream(DrivePhotosClientDownloadToStreamR { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - var downloader = Interop.GetFromHandle(request.DownloaderHandle); + var downloader = Interop.GetFromHandle(request.DownloaderHandle); var stream = new InteropStream(bindingsHandle, new InteropAction, nint>(request.WriteAction)); @@ -29,7 +30,7 @@ public static IMessage HandleDownloadToFile(DrivePhotosClientDownloadToFileReque { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - var downloader = Interop.GetFromHandle(request.DownloaderHandle); + var downloader = Interop.GetFromHandle(request.DownloaderHandle); var progressAction = new InteropAction>(request.ProgressAction); @@ -43,7 +44,7 @@ public static IMessage HandleDownloadToFile(DrivePhotosClientDownloadToFileReque public static IMessage? HandleFree(DrivePhotosClientDownloaderFreeRequest request) { - var fileDownloader = Interop.FreeHandle(request.FileDownloaderHandle); + var fileDownloader = Interop.FreeHandle(request.FileDownloaderHandle); fileDownloader.Dispose(); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs new file mode 100644 index 00000000..27e9d557 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs @@ -0,0 +1,70 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Photos.Sdk.Nodes.Upload; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropPhotosUploader +{ + public static IMessage HandleUploadFromStream(DrivePhotosClientUploadFromStreamRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var uploader = Interop.GetFromHandle(request.UploaderHandle); + + var stream = new InteropStream(uploader.FileSize, bindingsHandle, new InteropAction, nint>(request.ReadAction)); + + var thumbnails = request.Thumbnails.Select(t => + { + unsafe + { + var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; + return new Proton.Drive.Sdk.Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); + } + }); + + var progressAction = new InteropAction>(request.ProgressAction); + + var uploadController = PhotosFileUploader.UploadFromStream( + stream, + thumbnails, + (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), + cancellationToken); + + return new Int64Value { Value = Interop.AllocHandle(uploadController) }; + } + + public static IMessage HandleUploadFromFile(DrivePhotosClientUploadFromFileRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var thumbnails = request.Thumbnails.Select(t => + { + unsafe + { + var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; + return new Proton.Drive.Sdk.Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); + } + }); + + var progressAction = new InteropAction>(request.ProgressAction); + + var uploadController = PhotosFileUploader.UploadFromFile( + request.FilePath, + thumbnails, + (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), + cancellationToken); + + return new Int64Value { Value = Interop.AllocHandle(uploadController) }; + } + + public static IMessage? HandleFree(DrivePhotosClientUploaderFreeRequest request) + { + var fileUploader = Interop.FreeHandle(request.FileUploaderHandle); + + fileUploader.Dispose(); + + return null; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index e5cfa5aa..ef02b16a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -2,6 +2,8 @@ using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes; using Proton.Photos.Sdk; +using Proton.Photos.Sdk.Api.Photos; +using Proton.Photos.Sdk.Nodes; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.CExports; @@ -153,4 +155,48 @@ public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(Dri return new FileThumbnailList { Thumbnails = { thumbnails } }; } + + public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosClientGetPhotoUploaderRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var tags = request.Metadata.Tags is { Count: > 0 } + ? request.Metadata.Tags.Select(t => (Proton.Photos.Sdk.Api.Photos.PhotoTag)t) + : null; + + var metadata = new PhotosFileUploadMetadata + { + MediaType = request.Metadata.MediaType, + MainPhotoLinkId = request.Metadata.MainPhotoLinkId, + ExpectedSize = request.Size, + Tags = tags, + }; + + var uploader = await ProtonPhotosClient.GetFileUploaderAsync( + request.Name, + metadata, + cancellationToken).ConfigureAwait(false); + + return new Int64Value { Value = Interop.AllocHandle(uploader) }; + } + + public static async ValueTask HandleFindDuplicatesAsync(DrivePhotosClientFindDuplicatesRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + Action generateSha1Action = (sha1) => + { + // TODO: Implement SHA1 generation callback + }; + + var duplicates = await ProtonPhotosClient.FindDuplicatesAsync( + request.Name, + generateSha1Action, + cancellationToken).ConfigureAwait(false); + + var result = new ListValue(); + result.Values.AddRange(duplicates.Select(duplicate => Value.ForString(duplicate))); + + return result; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs new file mode 100644 index 00000000..bad0f768 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk.Nodes; + +public class FileUploadMetadata +{ + public required string MediaType { get; init; } + public DateTime? LastModificationTime { get; init; } + public IEnumerable? AdditionalMetadata { get; init; } + public bool OverrideExistingDraftByOtherClient { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index b45675ad..9fa1548c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -140,9 +140,10 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) { - return NodeOperations.EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken) + return NodeOperations + .EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken) .Select(x => (Result?)x) - .FirstOrDefaultAsync(cancellationToken); + .FirstOrDefaultAsync(cancellationToken: cancellationToken); } public ValueTask CreateFolderAsync(NodeUid parentId, string name, DateTime? lastModificationTime, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs similarity index 91% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs rename to cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs index ebc20729..7096d60c 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosDownloader.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -3,9 +3,9 @@ using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; -namespace Proton.Photos.Sdk.Nodes; +namespace Proton.Photos.Sdk.Nodes.Download; -public sealed partial class PhotosDownloader : IFileDownloader +public sealed partial class PhotosFileDownloader : IFileDownloader { private readonly ProtonPhotosClient _client; private readonly NodeUid _photoUid; @@ -13,7 +13,7 @@ public sealed partial class PhotosDownloader : IFileDownloader private volatile int _remainingNumberOfBlocksToList; - private PhotosDownloader(ProtonPhotosClient client, NodeUid photoUid, ILogger logger) + private PhotosFileDownloader(ProtonPhotosClient client, NodeUid photoUid, ILogger logger) { _client = client; _photoUid = photoUid; @@ -40,14 +40,14 @@ public void Dispose() ReleaseRemainingBlockListing(); } - internal static async ValueTask CreateAsync(ProtonPhotosClient client, NodeUid photoUid, CancellationToken cancellationToken) + internal static async ValueTask CreateAsync(ProtonPhotosClient client, NodeUid photoUid, CancellationToken cancellationToken) { var logger = client.DriveClient.Telemetry.GetLogger("Photo downloader"); LogEnteringBlockListingSemaphore(logger, photoUid, 1); await client.DriveClient.BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); LogEnteredBlockListingSemaphore(logger, photoUid, 1); - return new PhotosDownloader(client, photoUid, logger); + return new PhotosFileDownloader(client, photoUid, logger); } [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter block listing semaphore for photo {PhotoUid} with {Increment}")] diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs new file mode 100644 index 00000000..acd663b6 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs @@ -0,0 +1,15 @@ +using Proton.Drive.Sdk.Nodes; +using Proton.Photos.Sdk.Api.Photos; + +namespace Proton.Photos.Sdk.Nodes; + +public sealed class PhotosFileUploadMetadata : FileUploadMetadata +{ + public DateTime? CaptureTime { get; init; } + + public string? MainPhotoLinkId { get; init; } + + public long? ExpectedSize { get; init; } + + public IEnumerable? Tags { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs new file mode 100644 index 00000000..dbb78392 --- /dev/null +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs @@ -0,0 +1,36 @@ +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Nodes.Upload; + +namespace Proton.Photos.Sdk.Nodes.Upload; + +public sealed class PhotosFileUploader : IDisposable +{ + internal PhotosFileUploader(long fileSize) + { + FileSize = fileSize; + } + + public long FileSize { get; } + + public static UploadController UploadFromStream( + System.IO.Stream contentStream, + IEnumerable thumbnails, + Action? onProgress, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public static UploadController UploadFromFile( + string filePath, + IEnumerable thumbnails, + Action? onProgress, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public void Dispose() + { + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs index a40c6107..4b4c3078 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs @@ -5,6 +5,8 @@ using Proton.Photos.Sdk.Api; using Proton.Photos.Sdk.Caching; using Proton.Photos.Sdk.Nodes; +using Proton.Photos.Sdk.Nodes.Download; +using Proton.Photos.Sdk.Nodes.Upload; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.Http; @@ -56,6 +58,16 @@ public ProtonPhotosClient( internal ProtonDriveClient DriveClient { get; } + public static ValueTask GetFileUploaderAsync(string name, PhotosFileUploadMetadata metadata, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public static ValueTask> FindDuplicatesAsync(string name, Action generateSha1, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + [Experimental("Photos")] public ValueTask GetPhotosRootAsync(CancellationToken cancellationToken) { @@ -76,9 +88,9 @@ public IAsyncEnumerable EnumeratePhotosTimelineAsync(NodeUid return PhotosNodeOperations.EnumeratePhotosTimelineAsync(this, uid, cancellationToken); } - public async ValueTask GetPhotosDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) + public async ValueTask GetPhotosDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) { - return await PhotosDownloader.CreateAsync(this, photoUid, cancellationToken).ConfigureAwait(false); + return await PhotosFileDownloader.CreateAsync(this, photoUid, cancellationToken).ConfigureAwait(false); } public IAsyncEnumerable EnumeratePhotosThumbnailsAsync( diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index e53fc676..5e2260f7 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -51,6 +51,11 @@ message Request { DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1307; DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1308; DrivePhotosClientEnumeratePhotosTimelineRequest drive_photos_client_enumerate_photos_timeline = 1309; + DrivePhotosClientGetPhotoUploaderRequest drive_photos_client_get_photo_uploader = 1310; + DrivePhotosClientFindDuplicatesRequest drive_photos_client_find_duplicates = 1311; + DrivePhotosClientUploadFromStreamRequest drive_photos_client_upload_from_stream = 1312; + DrivePhotosClientUploadFromFileRequest drive_photos_client_upload_from_file = 1313; + DrivePhotosClientUploaderFreeRequest drive_photos_client_uploader_free = 1314; }; } @@ -487,7 +492,7 @@ message PhotosTimelineItem { google.protobuf.Timestamp capture_time = 2; } -// The response value must be an Int64Value carrying a handle to an instance of PhotosDownloader. +// The response value must be an Int64Value carrying a handle to an instance of PhotosFileDownloader. message DrivePhotosClientGetPhotoDownloaderRequest { int64 client_handle = 1; string photo_uid = 2; @@ -517,6 +522,71 @@ message DrivePhotosClientDownloaderFreeRequest { int64 file_downloader_handle = 1; } +// Photo uploader + +// The response value must be an Int64Value carrying a handle to an instance of PhotosFileUploader. +message DrivePhotosClientGetPhotoUploaderRequest { + int64 client_handle = 1; + string name = 2; + int64 size = 3; + PhotoFileUploadMetadata metadata = 4; + int64 cancellation_token_source_handle = 5; +} + +message PhotoFileUploadMetadata { + string media_type = 1; + google.protobuf.Timestamp last_modification_time = 2; // Optional + repeated AdditionalMetadataProperty additional_metadata = 3; // Optional + bool override_existing_draft_by_other_client = 4; + google.protobuf.Timestamp capture_time = 5; // Optional + string main_photo_link_id = 6; // Optional + repeated PhotoTag tags = 7; // Optional +} + +enum PhotoTag { + PHOTO_TAG_FAVORITES = 0; + PHOTO_TAG_SCREENSHOTS = 1; + PHOTO_TAG_VIDEOS = 2; + PHOTO_TAG_LIVE_PHOTOS = 3; + PHOTO_TAG_MOTION_PHOTOS = 4; + PHOTO_TAG_SELFIES = 5; + PHOTO_TAG_PORTRAITS = 6; + PHOTO_TAG_BURSTS = 7; + PHOTO_TAG_PANORAMAS = 8; + PHOTO_TAG_RAW = 9; +} + +// The response message must be of type google.protobuf.ListValue containing node UIDs of duplicate photos. +message DrivePhotosClientFindDuplicatesRequest { + int64 client_handle = 1; + string name = 2; + int64 generate_sha1_function = 3; // C signature: ByteArray generate_sha1(intptr_t bindings_handle); + int64 cancellation_token_source_handle = 4; +} + +// The response value must be an Int64Value carrying a handle to an instance of UploadController. +message DrivePhotosClientUploadFromStreamRequest { + int64 uploader_handle = 1; + repeated Thumbnail thumbnails = 2; + int64 read_action = 3; // C signature: void handle_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); + int64 progress_action = 4; // See array_action in C header file for signature + int64 cancellation_token_source_handle = 5; +} + +// The response value must be an Int64Value carrying a handle to an instance of UploadController. +message DrivePhotosClientUploadFromFileRequest { + int64 uploader_handle = 1; + repeated Thumbnail thumbnails = 2; + string file_path = 3; + int64 progress_action = 4; // See array_action in C header file for signature + int64 cancellation_token_source_handle = 5; +} + +// The response must not have a value. +message DrivePhotosClientUploaderFreeRequest { + int64 file_uploader_handle = 1; +} + enum VolumeType { VOLUME_TYPE_OWN_VOLUME = 0; VOLUME_TYPE_SHARED = 1; diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift similarity index 95% rename from swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift rename to swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift index 96f07a3a..5aeff83b 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift @@ -9,19 +9,12 @@ public enum DownloadOperationResult: Sendable { case failed(Error) } -/// Represents the type of downloader used for a download operation. -/// Determines which cleanup function is called when the operation is disposed. -public enum DownloaderType: Sendable { - case file - case photo -} - public final class DownloadOperation: Sendable { private let fileDownloaderHandle: ObjectHandle private let downloadControllerHandle: ObjectHandle private let logger: Logger? - private let downloaderType: DownloaderType + private let nodeType: NodeType private let progressCallbackWrapper: ProgressCallbackWrapper private let onOperationCancel: @Sendable () async throws -> Void private let onOperationDispose: @Sendable () async -> Void @@ -32,7 +25,7 @@ public final class DownloadOperation: Sendable { downloadControllerHandle: ObjectHandle, progressCallbackWrapper: ProgressCallbackWrapper, logger: Logger?, - downloaderType: DownloaderType, + nodeType: NodeType, onOperationCancel: @Sendable @escaping () async throws -> Void, onOperationDispose: @Sendable @escaping () async -> Void) { assert(fileDownloaderHandle != 0) @@ -41,7 +34,7 @@ public final class DownloadOperation: Sendable { self.downloadControllerHandle = downloadControllerHandle self.progressCallbackWrapper = progressCallbackWrapper self.logger = logger - self.downloaderType = downloaderType + self.nodeType = nodeType self.onOperationCancel = onOperationCancel self.onOperationDispose = onOperationDispose } @@ -164,20 +157,20 @@ public final class DownloadOperation: Sendable { } deinit { - Self.freeSDKObjects(downloadControllerHandle, fileDownloaderHandle, logger, downloaderType, onOperationDispose) + Self.freeSDKObjects(downloadControllerHandle, fileDownloaderHandle, logger, nodeType, onOperationDispose) } private static func freeSDKObjects( _ downloadControllerHandle: ObjectHandle, _ fileDownloaderHandle: ObjectHandle, _ logger: Logger?, - _ downloaderType: DownloaderType, + _ nodeType: NodeType, _ onOperationDispose: @Sendable @escaping () async -> Void ) { Task { await onOperationDispose() await freeDownloadController(Int64(downloadControllerHandle), logger) - switch downloaderType { + switch nodeType { case .file: await freeFileDownloader(Int64(fileDownloaderHandle), logger) case .photo: diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift similarity index 100% rename from swift/ProtonDriveSDK/Sources/FileOperations/DownloadThumbnailsManager.swift rename to swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift similarity index 99% rename from swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift rename to swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift index 388da24b..aee85e51 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/DownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift @@ -53,7 +53,7 @@ actor DownloadsManager { downloadControllerHandle: downloadControllerHandle, progressCallbackWrapper: callbackState, logger: logger, - downloaderType: .file, + nodeType: .file, onOperationCancel: { [weak self] in guard let self else { return } try await self.cancelDownload(with: cancellationToken) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift similarity index 99% rename from swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift rename to swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift index becd3751..e1bdbb85 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/PhotoDownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift @@ -53,7 +53,7 @@ actor PhotoDownloadsManager { downloadControllerHandle: downloadControllerHandle, progressCallbackWrapper: callbackState, logger: logger, - downloaderType: .photo, + nodeType: .photo, onOperationCancel: { [weak self] in guard let self else { return } try await self.cancelDownload(with: cancellationToken) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/OperationType.swift b/swift/ProtonDriveSDK/Sources/FileOperations/OperationType.swift new file mode 100644 index 00000000..76243b14 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/OperationType.swift @@ -0,0 +1,6 @@ + +/// Represents the type of operation, which determines which cleanup function will be called when the operation is disposed. +public enum NodeType: Sendable { + case file + case photo +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift new file mode 100644 index 00000000..c0d1e665 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift @@ -0,0 +1,159 @@ +import Foundation +import SwiftProtobuf + +/// Handles photo upload operations for ProtonDrive +actor PhotoUploadsManager { + + private let clientHandle: ObjectHandle + private let logger: Logger? + private var activeUploads: [UUID: CancellationTokenSource] = [:] + + init(clientHandle: ObjectHandle, logger: Logger?) { + self.clientHandle = clientHandle + self.logger = logger + } + + deinit { + activeUploads.values.forEach { + $0.free() + } + } + + func uploadPhotoOperation( + name: String, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + mediaType: String, + thumbnails: [ThumbnailData], + overrideExistingDraft: Bool, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> UploadOperation { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeUploads[cancellationToken] = cancellationTokenSource + + let uploaderHandle = try await buildUploader( + name: name, + fileSize: fileSize, + modificationDate: modificationDate, + mediaType: mediaType, + overrideExistingDraft: overrideExistingDraft, + cancellationHandle: cancellationTokenSource.handle + ) + + let uploadController = try await uploadFromFile( + fileUploaderHandle: uploaderHandle, + fileURL: fileURL, + progressCallback: progressCallback, + cancellationToken: cancellationToken, + cancellationHandle: cancellationTokenSource.handle, + thumbnails: thumbnails + ) + return uploadController + } + + private func uploadFromFile( + fileUploaderHandle: ObjectHandle, + fileURL: URL, + progressCallback: @escaping ProgressCallback, + cancellationToken: UUID, + cancellationHandle: ObjectHandle, + thumbnails: [ThumbnailData] + ) async throws -> UploadOperation { + let thumbnails = thumbnails.map { + let count = $0.data.count + let buffer = UnsafeMutablePointer.allocate(capacity: count) + $0.data.copyBytes(to: buffer, count: count) + return ($0.type, ObjectHandle(bitPattern: buffer), count) + } + let deallocateBuffers: @Sendable () -> Void = { + thumbnails.forEach { _, handle, count in + let pointer = UnsafeMutableRawPointer(bitPattern: handle) + UnsafeMutableRawBufferPointer(start: pointer, count: count).deallocate() + } + } + let uploaderRequest = Proton_Drive_Sdk_DrivePhotosClientUploadFromFileRequest.with { + $0.uploaderHandle = Int64(fileUploaderHandle) + $0.filePath = fileURL.path(percentEncoded: false) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + $0.thumbnails = thumbnails.map { type, handle, count in + Proton_Drive_Sdk_Thumbnail.with { + $0.type = type == .thumbnail ? .thumbnail : .preview + $0.dataPointer = Int64(handle) + $0.dataLength = Int64(count) + } + } + } + + let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + uploaderRequest, + state: WeakReference(value: callbackState), + includesLongLivedCallback: true, + logger: logger + ) + + return UploadOperation( + fileUploaderHandle: fileUploaderHandle, + uploadControllerHandle: uploadControllerHandle, + progressCallbackWrapper: callbackState, + logger: logger, + nodeType: .photo, + onOperationCancel: { [weak self] in + guard let self else { return } + try await self.cancelUpload(with: cancellationToken) + }, + onOperationDispose: { [weak self] in + guard let self else { return } + deallocateBuffers() + await self.freeCancellationTokenSourceIfNeeded(cancellationToken: cancellationToken) + } + ) + } + + // API to cancel operation when the client does not use the UploadOperation + func cancelUpload(with cancellationToken: UUID) async throws { + guard let uploadCancellationToken = activeUploads[cancellationToken] else { + throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "upload")) + } + + try await uploadCancellationToken.cancel() + + activeUploads[cancellationToken] = nil + uploadCancellationToken.free() + } + + private func freeCancellationTokenSourceIfNeeded(cancellationToken: UUID) { + guard let cancellationTokenSource = activeUploads[cancellationToken] else { return } + activeUploads[cancellationToken] = nil + cancellationTokenSource.free() + } + + /// Get a photo uploader for uploading files to Drive + private func buildUploader( + name: String, + fileSize: Int64, + modificationDate: Date, + mediaType: String, + overrideExistingDraft: Bool, + cancellationHandle: ObjectHandle + ) async throws -> ObjectHandle { + let uploaderRequest = Proton_Drive_Sdk_DrivePhotosClientGetPhotoUploaderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.name = name + $0.size = fileSize + $0.metadata = Proton_Drive_Sdk_PhotoFileUploadMetadata.with { metadata in + metadata.mediaType = mediaType + metadata.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + metadata.overrideExistingDraftByOtherClient = overrideExistingDraft + } + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let uploaderHandle: ObjectHandle = try await SDKRequestHandler.send(uploaderRequest, logger: logger) + assert(uploaderHandle != 0) + return uploaderHandle + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift similarity index 85% rename from swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift rename to swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift index 57c82255..da217103 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift @@ -11,6 +11,7 @@ public final class UploadOperation: Sendable { private let uploadControllerHandle: ObjectHandle private let progressCallbackWrapper: ProgressCallbackWrapper private let logger: Logger? + private let nodeType: NodeType private let onOperationCancel: @Sendable () async throws -> Void private let onOperationDispose: @Sendable () async -> Void @@ -20,6 +21,7 @@ public final class UploadOperation: Sendable { uploadControllerHandle: ObjectHandle, progressCallbackWrapper: ProgressCallbackWrapper, logger: Logger?, + nodeType: NodeType, onOperationCancel: @Sendable @escaping () async throws -> Void, onOperationDispose: @Sendable @escaping () async -> Void) { assert(fileUploaderHandle != 0) @@ -28,6 +30,7 @@ public final class UploadOperation: Sendable { self.uploadControllerHandle = uploadControllerHandle self.progressCallbackWrapper = progressCallbackWrapper self.logger = logger + self.nodeType = nodeType self.onOperationCancel = onOperationCancel self.onOperationDispose = onOperationDispose } @@ -104,7 +107,7 @@ public final class UploadOperation: Sendable { } } catch let isPausedError { logger?.info("Checking isPaused status failed with: \(isPausedError.localizedDescription)", - category: "DownloadOperation") + category: "UploadOperation") if cleanUpTemporaryState { try? await self.cleanUpTemporaryState() } @@ -156,48 +159,69 @@ public final class UploadOperation: Sendable { } deinit { - Self.freeSDKObjects(uploadControllerHandle, fileUploaderHandle, logger, onOperationDispose) + Self.freeSDKObjects(uploadControllerHandle, fileUploaderHandle, logger, nodeType, onOperationDispose) } private static func freeSDKObjects( _ uploadControllerHandle: ObjectHandle, _ fileUploaderHandle: ObjectHandle, _ logger: Logger?, + _ nodeType: NodeType, _ onOperationDispose: @Sendable @escaping () async -> Void ) { Task { await onOperationDispose() - await freeFileUploadController(Int64(uploadControllerHandle), logger: logger) - await freeFileUploader(Int64(fileUploaderHandle), logger) + await freeUploadController(Int64(uploadControllerHandle), logger: logger) + switch nodeType { + case .file: + await freeFileUploader(Int64(fileUploaderHandle), logger) + case .photo: + await freePhotoUploader(Int64(fileUploaderHandle), logger) + } } } - private static func freeFileUploadController(_ uploadControllerHandle: Int64, logger: Logger?) async { - let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { - $0.uploadControllerHandle = uploadControllerHandle + /// Free a file uploader when no longer needed + private static func freeFileUploader(_ fileUploaderHandle: Int64, _ logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_FileUploaderFreeRequest.with { + $0.fileUploaderHandle = fileUploaderHandle } do { try await SDKRequestHandler.send(freeRequest, logger: logger) as Void } catch { - // If the request to free the file upload controller failed, we have a memory leak, but not much else can be done. + // If the request to free the file uploader failed, we have a memory leak, but not much else can be done. // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_UploadControllerFreeRequest failed: \(error)", - category: "UploadController.freeFileUploadController") + logger?.error("Proton_Drive_Sdk_FileUploaderFreeRequest failed: \(error)", + category: "UploadManager.freeFileUploader") } } - /// Free a file uploader when no longer needed - private static func freeFileUploader(_ fileUploaderHandle: Int64, _ logger: Logger?) async { - let freeRequest = Proton_Drive_Sdk_FileUploaderFreeRequest.with { - $0.fileUploaderHandle = fileUploaderHandle + /// Free a photo uploader when no longer needed + private static func freePhotoUploader(_ photoUploaderHandle: Int64, _ logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_DrivePhotosClientUploaderFreeRequest.with { + $0.fileUploaderHandle = photoUploaderHandle } do { try await SDKRequestHandler.send(freeRequest, logger: logger) as Void } catch { - // If the request to free the file uploader failed, we have a memory leak, but not much else can be done. + // If the request to free the uploader failed, we have a memory leak, but not much else can be done. // It's not gonna break the app's functionality, so we just log the issue and continue. - logger?.error("Proton_Drive_Sdk_FileUploaderFreeRequest failed: \(error)", + logger?.error("Proton_Drive_Sdk_DrivePhotosClientUploaderFreeRequest failed: \(error)", category: "UploadManager.freeFileUploader") } } + + private static func freeUploadController(_ uploadControllerHandle: Int64, logger: Logger?) async { + let freeRequest = Proton_Drive_Sdk_UploadControllerFreeRequest.with { + $0.uploadControllerHandle = uploadControllerHandle + } + do { + try await SDKRequestHandler.send(freeRequest, logger: logger) as Void + } catch { + // If the request to free the file upload controller failed, we have a memory leak, but not much else can be done. + // It's not gonna break the app's functionality, so we just log the issue and continue. + logger?.error("Proton_Drive_Sdk_UploadControllerFreeRequest failed: \(error)", + category: "UploadController.freeFileUploadController") + } + } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift similarity index 99% rename from swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift rename to swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift index c07d4bf0..d9d47cfd 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/UploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift @@ -208,6 +208,7 @@ extension UploadsManager { uploadControllerHandle: uploadControllerHandle, progressCallbackWrapper: callbackState, logger: logger, + nodeType: .file, onOperationCancel: { [weak self] in guard let self else { return } try await self.cancelUpload(with: cancellationToken) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 145ed6b4..95e4c903 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -39,6 +39,8 @@ extension Message { $0.payload = .loggerProviderCreate(request) } + // MARK: - Drive Client + case let request as Proton_Drive_Sdk_DriveClientCreateRequest: Proton_Drive_Sdk_Request.with { $0.payload = .driveClientCreate(request) @@ -94,6 +96,8 @@ extension Message { $0.payload = .driveClientGetThumbnails(request) } + // MARK: - Uploads + case let request as Proton_Drive_Sdk_UploadFromFileRequest: Proton_Drive_Sdk_Request.with { $0.payload = .uploadFromFile(request) @@ -134,6 +138,8 @@ extension Message { $0.payload = .uploadControllerFree(request) } + // MARK: - Downloads + case let request as Proton_Drive_Sdk_DownloadToFileRequest: Proton_Drive_Sdk_Request.with { $0.payload = .downloadToFile(request) @@ -174,6 +180,8 @@ extension Message { $0.payload = .downloadControllerFree(request) } + // MARK: - Photo Client + case let request as Proton_Drive_Sdk_DrivePhotosClientCreateRequest: Proton_Drive_Sdk_Request.with { $0.payload = .drivePhotosClientCreate(request) @@ -204,6 +212,8 @@ extension Message { $0.payload = .drivePhotosClientEnumeratePhotosTimeline(request) } + // MARK: - Photo Downloads + case let request as Proton_Drive_Sdk_DrivePhotosClientGetPhotoDownloaderRequest: Proton_Drive_Sdk_Request.with { $0.payload = .drivePhotosClientGetPhotoDownloader(request) @@ -224,6 +234,28 @@ extension Message { $0.payload = .drivePhotosClientDownloaderFree(request) } + // MARK: - Photo Uploads + + case let request as Proton_Drive_Sdk_DrivePhotosClientGetPhotoUploaderRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientGetPhotoUploader(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientUploadFromFileRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientUploadFromFile(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientUploadFromStreamRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientUploadFromStream(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientUploaderFreeRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientUploaderFree(request) + } + default: assertionFailure("Unknown request") throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown request type: \(self)")) From 2b530da265fd840d33df3d3d0907df89f9a5d107 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 26 Jan 2026 11:34:07 +0100 Subject: [PATCH 473/791] Make cache optional --- .../kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt | 4 ++-- .../kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt | 2 +- .../me/proton/drive/sdk/extension/SessionBeginRequest.kt | 2 +- .../me/proton/drive/sdk/internal/JniProtonDriveClient.kt | 4 ++-- .../me/proton/drive/sdk/internal/JniProtonPhotosClient.kt | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt index 0704127c..614a3f0c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt @@ -4,9 +4,9 @@ import me.proton.drive.sdk.LoggerProvider data class ClientCreateRequest( val baseUrl: String, - val entityCachePath: String, - val secretCachePath: String, val loggerProvider: LoggerProvider, + val entityCachePath: String? = null, + val secretCachePath: String? = null, val bindingsLanguage: String? = null, val uid: String? = null, val apiCallTimeout: Int? = null, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt index 5b416725..8ad9255a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt @@ -6,6 +6,6 @@ data class SessionBeginRequest( val username: String, val password: String, val appVersion: String, - val secretCache: File, val options: ProtonClientOptions, + val secretCachePath: String? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt index 4bc8ae05..3b464056 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt @@ -8,6 +8,6 @@ fun SessionBeginRequest.toProtobuf(cancellationTokenSourceHandle: Long) = sessio this@sessionBeginRequest.password = this@toProtobuf.password appVersion = this@toProtobuf.appVersion options = this@toProtobuf.options.toProtobuf() - secretCachePath = secretCache.path + this@toProtobuf.secretCachePath?.let { secretCachePath = it } this.cancellationTokenSourceHandle = cancellationTokenSourceHandle } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index 58548e71..ea83ea0f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -60,8 +60,8 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { cancellationAction = JniJob.getCancelPointer() } accountRequestAction = ProtonDriveSdkNativeClient.getAccountRequestPointer() - entityCachePath = request.entityCachePath - secretCachePath = request.secretCachePath + request.entityCachePath?.let { entityCachePath = it } + request.secretCachePath?.let { secretCachePath = it } telemetry = telemetry { loggerProviderHandle = request.loggerProvider.handle recordMetricAction = ProtonDriveSdkNativeClient.getRecordMetricPointer() diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index 33248b71..f833ed14 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -56,8 +56,8 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { cancellationAction = JniJob.getCancelPointer() } accountRequestAction = ProtonDriveSdkNativeClient.getAccountRequestPointer() - entityCachePath = request.entityCachePath - secretCachePath = request.secretCachePath + request.entityCachePath?.let { entityCachePath = it } + request.secretCachePath?.let { secretCachePath = it } telemetry = telemetry { loggerProviderHandle = request.loggerProvider.handle recordMetricAction = ProtonDriveSdkNativeClient.getRecordMetricPointer() From 39e7dc4429ba6a90eecede1c1cae1f9d30d3ec54 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 26 Jan 2026 12:57:39 +0100 Subject: [PATCH 474/791] Fix location of Photos project --- cs/Proton.Drive.Sdk.slnx | 33 +++++++++++++++++++ .../Proton.Drive.Sdk.CExports.csproj | 2 +- .../Api/IPhotosApiClient.cs | 0 .../Api/PhotoDetailsResponse.cs | 0 .../Api/PhotoLinkDetailsDto.cs | 0 .../Api/Photos/AlbumDto.cs | 0 .../Api/Photos/PhotoDto.cs | 0 .../Api/Photos/PhotoTag.cs | 0 .../Api/Photos/PhotosVolumeCreationRequest.cs | 0 .../PhotosVolumeLinkCreationParameters.cs | 0 .../PhotosVolumeShareCreationParameters.cs | 0 .../Api/Photos/RelatedPhotoDto.cs | 0 .../Api/Photos/TimelinePhotoDto.cs | 0 .../Api/Photos/TimelinePhotoListRequest.cs | 0 .../Api/Photos/TimelinePhotoListResponse.cs | 0 .../Api/PhotosApiClient.cs | 0 .../Caching/IPhotosClientCache.cs | 0 .../Caching/IPhotosEntityCache.cs | 0 .../Caching/PhotosClientCache.cs | 0 .../Caching/PhotosEntityCache.cs | 0 .../Caching/PhotosSecretCache.cs | 0 .../Nodes/Download/PhotosFileDownloader.cs | 0 .../Nodes/PhotoDtoToMetadataConverter.cs | 0 .../Nodes/PhotoNode.cs | 0 .../Nodes/PhotoNodeBatchLoader.cs | 0 .../Nodes/PhotosFileUploadMetadata.cs | 0 .../Nodes/PhotosNodeOperations.cs | 0 .../Nodes/PhotosTimelineItem.cs | 0 .../Nodes/Upload/PhotosFileUploader.cs | 0 .../Proton.Photos.Sdk.csproj | 2 +- .../ProtonPhotosClient.cs | 0 .../PhotosApiSerializerContext.cs | 0 .../Volumes/VolumeOperations.cs | 0 33 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 cs/Proton.Drive.Sdk.slnx rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/IPhotosApiClient.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/PhotoDetailsResponse.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/PhotoLinkDetailsDto.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/AlbumDto.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/PhotoDto.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/PhotoTag.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/PhotosVolumeCreationRequest.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/PhotosVolumeLinkCreationParameters.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/PhotosVolumeShareCreationParameters.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/RelatedPhotoDto.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/TimelinePhotoDto.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/TimelinePhotoListRequest.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/Photos/TimelinePhotoListResponse.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Api/PhotosApiClient.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Caching/IPhotosClientCache.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Caching/IPhotosEntityCache.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Caching/PhotosClientCache.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Caching/PhotosEntityCache.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Caching/PhotosSecretCache.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Nodes/Download/PhotosFileDownloader.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Nodes/PhotoDtoToMetadataConverter.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Nodes/PhotoNode.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Nodes/PhotoNodeBatchLoader.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Nodes/PhotosFileUploadMetadata.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Nodes/PhotosNodeOperations.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Nodes/PhotosTimelineItem.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Nodes/Upload/PhotosFileUploader.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Proton.Photos.Sdk.csproj (87%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/ProtonPhotosClient.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Serialization/PhotosApiSerializerContext.cs (100%) rename cs/sdk/src/Proton.Photos.Sdk/{Proton.Photos.Sdk => }/Volumes/VolumeOperations.cs (100%) diff --git a/cs/Proton.Drive.Sdk.slnx b/cs/Proton.Drive.Sdk.slnx new file mode 100644 index 00000000..468ab5f6 --- /dev/null +++ b/cs/Proton.Drive.Sdk.slnx @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index 553f5176..f111da4d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -22,7 +22,7 @@ - + diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/IPhotosApiClient.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/IPhotosApiClient.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/IPhotosApiClient.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/PhotosApiClient.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Api/PhotosApiClient.cs rename to cs/sdk/src/Proton.Photos.Sdk/Api/PhotosApiClient.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs rename to cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs rename to cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosClientCache.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosClientCache.cs rename to cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosClientCache.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs rename to cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs rename to cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs rename to cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs rename to cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNode.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNode.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNode.cs rename to cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNode.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs rename to cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs rename to cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs rename to cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs rename to cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs rename to cs/sdk/src/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj similarity index 87% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj rename to cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj index 0b65e562..8051c62c 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj +++ b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj @@ -14,7 +14,7 @@ - + diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/ProtonPhotosClient.cs rename to cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs rename to cs/sdk/src/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Photos.Sdk/Volumes/VolumeOperations.cs similarity index 100% rename from cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk/Volumes/VolumeOperations.cs rename to cs/sdk/src/Proton.Photos.Sdk/Volumes/VolumeOperations.cs From a4c85757fd350d279027e4bab3a75c213b72518c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 16:11:54 +0100 Subject: [PATCH 475/791] Enable request body streaming for upload --- .../proton/drive/sdk/extension/HttpStream.kt | 20 +++++++++---------- .../drive/sdk/internal/ApiProviderBridge.kt | 5 ++--- .../proton/drive/sdk/internal/HttpStream.kt | 12 +++++++++-- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt index f6ba3f5b..c8353cac 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt @@ -56,19 +56,17 @@ private class StreamRequestBody( override fun writeTo(sink: BufferedSink) { if (request.hasSdkContentHandle()) { val buffer = ByteBuffer.allocateDirect(64 * 1024) - runBlocking { - while (true) { - buffer.clear() - val bytesRead = httpStream.read(request.sdkContentHandle, buffer) - if (bytesRead <= 0) break - buffer.position(bytesRead) + while (true) { + buffer.clear() + val bytesRead = httpStream.readBlocking(request.sdkContentHandle, buffer) + if (bytesRead <= 0) break + buffer.position(bytesRead) - // Flip so we can read bytes from ByteBuffer - buffer.flip() + // Flip so we can read bytes from ByteBuffer + buffer.flip() - // Write directly from ByteBuffer to okio - sink.write(buffer) - } + // Write directly from ByteBuffer to okio + sink.write(buffer) } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt index ed83e817..e345890b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -9,6 +9,7 @@ import me.proton.core.network.data.ProtonErrorException import me.proton.core.network.domain.ApiResult import me.proton.drive.sdk.HttpSdkApi import me.proton.drive.sdk.extension.read +import me.proton.drive.sdk.extension.readAsStream import okhttp3.ResponseBody import proton.sdk.ProtonSdk import proton.sdk.ProtonSdk.HttpRequest @@ -115,9 +116,7 @@ internal class ApiProviderBridge( header.name to header.valuesList.joinToString(",") } val body = if (request.isUploadBlock) { - httpStream.read(request) - // TODO: no working yet request is seen in the log but not send - //httpStream.readAsStream(request) + httpStream.readAsStream(request) } else { httpStream.read(request) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt index 9bf6b894..50dc8b3a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt @@ -1,6 +1,9 @@ package me.proton.drive.sdk.internal import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import java.nio.ByteBuffer import java.nio.channels.ReadableByteChannel @@ -9,8 +12,13 @@ class HttpStream internal constructor( private val bridge: JniHttpStream, ) : AutoCloseable { - suspend fun read(sdkContentHandle: Long, buffer: ByteBuffer) = - bridge.read(sdkContentHandle, buffer) + suspend fun read(sdkContentHandle: Long, buffer: ByteBuffer) = withContext(Dispatchers.IO){ + bridge.read(sdkContentHandle, buffer) + } + + fun readBlocking(sdkContentHandle: Long, buffer: ByteBuffer) = runBlocking(Dispatchers.IO) { + bridge.read(sdkContentHandle, buffer) + } fun write(coroutineScope: CoroutineScope, channel: ReadableByteChannel): Long = bridge.write(coroutineScope, channel) From c201648690f19645b3cf7e8e5326c52a9225784c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 26 Jan 2026 13:27:14 +0000 Subject: [PATCH 476/791] Handle and send decryption error telemetry to client --- .../DriveInteropTelemetryDecorator.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index e4ba05b6..de2f21b8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -21,6 +21,7 @@ public void RecordMetric(IMetricEvent metricEvent) { UploadEvent me => GetUploadEventPayload(me), DownloadEvent me => GetDownloadEventPayload(me), + DecryptionErrorEvent me => GetDecryptionErrorPayload(me), // FIXME support error metrics _ => null, @@ -79,4 +80,26 @@ private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me) return payload; } + + private static DecryptionErrorEventPayload GetDecryptionErrorPayload(DecryptionErrorEvent me) + { + var payload = new DecryptionErrorEventPayload + { + VolumeType = (VolumeType)me.VolumeType, + Field = (EncryptedField)me.Field, + Uid = me.Uid, + }; + + if (me.FromBefore2024.HasValue) + { + payload.FromBefore2024 = me.FromBefore2024.Value; + } + + if (me.Error is not null) + { + payload.Error = me.Error; + } + + return payload; + } } From cc0ce9424fb7da305513d943fac84b9f9530fc2c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 26 Jan 2026 18:50:05 +0000 Subject: [PATCH 477/791] Add photos client kotlin bindings for upload --- .../drive/sdk/CommonUploadController.kt | 72 ++++++++++++++ .../me/proton/drive/sdk/FileUploader.kt | 94 +++++++++++++++++++ .../me/proton/drive/sdk/PhotosUploader.kt | 80 ++++++++++++++++ .../me/proton/drive/sdk/UploadController.kt | 73 ++------------ .../kotlin/me/proton/drive/sdk/Uploader.kt | 80 +--------------- .../me/proton/drive/sdk/entity/PhotoTag.kt | 18 ++++ .../drive/sdk/entity/PhotosUploaderRequest.kt | 13 +++ ...ationRequest.kt => FileUploaderRequest.kt} | 0 .../me/proton/drive/sdk/extension/PhotoTag.kt | 17 ++++ .../sdk/extension/PhotosUploaderRequest.kt | 44 +++++++++ .../{JniUploader.kt => JniFileUploader.kt} | 2 +- .../drive/sdk/internal/JniPhotosUploader.kt | 91 ++++++++++++++++++ 12 files changed, 438 insertions(+), 146 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/{FileUploaderCreationRequest.kt => FileUploaderRequest.kt} (100%) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotoTag.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/{JniUploader.kt => JniFileUploader.kt} (97%) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt new file mode 100644 index 00000000..b1e00bcc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt @@ -0,0 +1,72 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.entity.UploadResult +import me.proton.drive.sdk.internal.CoroutineScopeConsumer +import me.proton.drive.sdk.internal.JniUploadController +import me.proton.drive.sdk.internal.toLogId +import java.io.InputStream +import java.nio.channels.Channel + +class CommonUploadController internal constructor( + uploader: SdkNode, + internal val handle: Long, + private val bridge: JniUploadController, + private val channel: Channel, + private val coroutineScopeConsumer: CoroutineScopeConsumer, + override val cancellationTokenSource: CancellationTokenSource, +) : SdkNode(uploader), UploadController { + + val isPausedFlow = MutableStateFlow(false) + + override suspend fun awaitCompletion(): UploadResult { + log(DEBUG, "await completion") + return runCatching { + isPaused() + bridge.awaitCompletion(handle) + }.onSuccess { + log(INFO, "completed") + }.onFailure { + log(INFO, "cancelled or failed") + isPaused() + }.getOrThrow() + } + + override suspend fun resume(coroutineScope: CoroutineScope) { + log(INFO, "resume") + coroutineScopeConsumer(coroutineScope) + bridge.resume(handle).also { isPaused() } + } + + override suspend fun pause() { + log(INFO, "pause") + bridge.pause(handle).also { isPaused() } + coroutineScopeConsumer(null) + } + + override suspend fun isPaused() = bridge.isPaused(handle).also { paused -> + log(DEBUG, "isPaused: $paused") + isPausedFlow.emit(paused) + } + + override suspend fun dispose() = bridge.dispose(handle) + + override fun close() { + log(DEBUG, "close") + channel.close() + bridge.free(handle) + super.close() + } + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "UploadController(${handle.toLogId()}) $message") + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt new file mode 100644 index 00000000..132b4b36 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -0,0 +1,94 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.entity.FileRevisionUploaderRequest +import me.proton.drive.sdk.entity.FileUploaderRequest +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.internal.JniUploadController +import me.proton.drive.sdk.internal.JniFileUploader +import me.proton.drive.sdk.internal.toLogId +import java.io.InputStream +import java.nio.channels.Channels +import java.nio.channels.ReadableByteChannel +import java.util.concurrent.atomic.AtomicReference + +class FileUploader internal constructor( + client: ProtonDriveClient, + internal val handle: Long, + private val bridge: JniFileUploader, + override val cancellationTokenSource: CancellationTokenSource, +) : SdkNode(client), Uploader { + + override suspend fun uploadFromStream( + coroutineScope: CoroutineScope, + channel: ReadableByteChannel, + thumbnails: Map, + progress: suspend (Long, Long) -> Unit, + ): UploadController = cancellationTokenSource().let { source -> + log(INFO, "uploadFromStream") + val coroutineScopeReference = AtomicReference(coroutineScope) + val handle = bridge.uploadFromStream( + uploaderHandle = handle, + cancellationTokenSourceHandle = source.handle, + thumbnails = thumbnails, + onRead = channel::read, + onProgress = { progressUpdate -> + with(progressUpdate) { + log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + progress(bytesCompleted, bytesInTotal) + } + }, + coroutineScopeProvider = coroutineScopeReference::get, + ) + CommonUploadController( + uploader = this@FileUploader, + handle = handle, + bridge = JniUploadController(), + cancellationTokenSource = source, + channel = channel, + coroutineScopeConsumer = coroutineScopeReference::set, + ) + } + + override fun close() = bridge.free(handle) + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "FileUploader(${handle.toLogId()}) $message") + } +} + +suspend fun ProtonDriveClient.uploader( + request: FileUploaderRequest +): Uploader = cancellationTokenSource().let { source -> + val client = this + JniFileUploader().run { + FileUploader( + client = client, + handle = getFile(client.handle, source.handle, request), + bridge = this, + cancellationTokenSource = source, + ) + } +} + +suspend fun ProtonDriveClient.uploader( + request: FileRevisionUploaderRequest +): Uploader = cancellationTokenSource().let { source -> + val client = this@uploader + JniFileUploader().run { + FileUploader( + client = client, + handle = getFileRevision(handle, source.handle, request), + bridge = this, + cancellationTokenSource = source, + ) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt new file mode 100644 index 00000000..772bc952 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt @@ -0,0 +1,80 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.entity.PhotosUploaderRequest +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.internal.JniPhotosUploader +import me.proton.drive.sdk.internal.JniUploadController +import me.proton.drive.sdk.internal.toLogId +import java.io.InputStream +import java.nio.channels.Channels +import java.nio.channels.ReadableByteChannel +import java.util.concurrent.atomic.AtomicReference + +class PhotosUploader( + client: ProtonPhotosClient, + internal val handle: Long, + private val bridge: JniPhotosUploader, + override val cancellationTokenSource: CancellationTokenSource, +) : SdkNode(client), Uploader { + + override suspend fun uploadFromStream( + coroutineScope: CoroutineScope, + channel: ReadableByteChannel, + thumbnails: Map, + progress: suspend (Long, Long) -> Unit + ): UploadController = + cancellationTokenSource().let { source -> + log(INFO, "uploadFromStream") + val coroutineScopeReference = AtomicReference(coroutineScope) + val handle = bridge.uploadFromStream( + uploaderHandle = handle, + cancellationTokenSourceHandle = source.handle, + thumbnails = thumbnails, + onRead = channel::read, + onProgress = { progressUpdate -> + with(progressUpdate) { + log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + progress(bytesCompleted, bytesInTotal) + } + }, + coroutineScopeProvider = coroutineScopeReference::get, + ) + CommonUploadController( + uploader = this@PhotosUploader, + handle = handle, + bridge = JniUploadController(), + cancellationTokenSource = source, + channel = channel, + coroutineScopeConsumer = coroutineScopeReference::set, + ) + } + + override fun close() = bridge.free(handle) + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "PhotosUploader(${handle.toLogId()}) $message") + } +} + +suspend fun ProtonPhotosClient.uploader( + request: PhotosUploaderRequest +): Uploader = cancellationTokenSource().let { source -> + val client = this + JniPhotosUploader().run { + PhotosUploader( + client = client, + handle = getPhoto(client.handle, source.handle, request), + bridge = this, + cancellationTokenSource = source, + ) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index 3dab01cd..87006d01 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -1,74 +1,13 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import me.proton.drive.sdk.LoggerProvider.Level.DEBUG -import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.UploadResult -import me.proton.drive.sdk.internal.CoroutineScopeConsumer -import me.proton.drive.sdk.internal.JniUploadController -import me.proton.drive.sdk.internal.toLogId -import java.nio.channels.Channel -class UploadController internal constructor( - uploader: Uploader, - internal val handle: Long, - private val bridge: JniUploadController, - private val channel: Channel, - private val coroutineScopeConsumer: CoroutineScopeConsumer, - override val cancellationTokenSource: CancellationTokenSource, -) : SdkNode(uploader), AutoCloseable, Cancellable { +interface UploadController : AutoCloseable, Cancellable { - val isPausedFlow = MutableStateFlow(false) - - suspend fun awaitCompletion(): UploadResult { - log(DEBUG, "await completion") - return runCatching { - isPaused() - bridge.awaitCompletion(handle) - }.onSuccess { - log(INFO, "completed") - }.onFailure { - log(INFO, "cancelled or failed") - isPaused() - }.getOrThrow() - } - - suspend fun resume(coroutineScope: CoroutineScope) { - log(INFO, "resume") - coroutineScopeConsumer(coroutineScope) - bridge.resume(handle).also { isPaused() } - } - - suspend fun pause() { - log(INFO, "pause") - bridge.pause(handle).also { isPaused() } - coroutineScopeConsumer(null) - } - - suspend fun isPaused() = bridge.isPaused(handle).also { paused -> - log(DEBUG, "isPaused: $paused") - isPausedFlow.emit(paused) - } - - suspend fun dispose() { - log(DEBUG, "dispose") - bridge.dispose(handle) - } - - override fun close() { - log(DEBUG, "close") - channel.close() - bridge.free(handle) - super.close() - } - - override suspend fun cancel() { - log(INFO, "cancel") - super.cancel() - } - - private fun log(level: LoggerProvider.Level, message: String) { - bridge.clientLogger(level, "UploadController(${handle.toLogId()}) $message") - } + suspend fun awaitCompletion(): UploadResult + suspend fun resume(coroutineScope: CoroutineScope) + suspend fun pause() + suspend fun isPaused(): Boolean + suspend fun dispose() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 9115e8d6..43cc1446 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -1,91 +1,15 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import me.proton.drive.sdk.LoggerProvider.Level.DEBUG -import me.proton.drive.sdk.LoggerProvider.Level.INFO -import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource -import me.proton.drive.sdk.entity.FileRevisionUploaderRequest -import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType -import me.proton.drive.sdk.internal.JniUploadController -import me.proton.drive.sdk.internal.JniUploader -import me.proton.drive.sdk.internal.factory -import me.proton.drive.sdk.internal.toLogId import java.nio.channels.ReadableByteChannel -import java.util.concurrent.atomic.AtomicReference -class Uploader internal constructor( - client: ProtonDriveClient, - internal val handle: Long, - private val bridge: JniUploader, - override val cancellationTokenSource: CancellationTokenSource, -) : SdkNode(client), AutoCloseable, Cancellable { +interface Uploader : AutoCloseable, Cancellable { suspend fun uploadFromStream( coroutineScope: CoroutineScope, channel: ReadableByteChannel, thumbnails: Map = emptyMap(), progress: suspend (Long, Long) -> Unit = { _, _ -> }, - ): UploadController = cancellationTokenSource().let { source -> - log(INFO, "uploadFromStream") - val coroutineScopeReference = AtomicReference(coroutineScope) - val handle = bridge.uploadFromStream( - uploaderHandle = handle, - cancellationTokenSourceHandle = source.handle, - thumbnails = thumbnails, - onRead = channel::read, - onProgress = { progressUpdate -> - with(progressUpdate) { - log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") - progress(bytesCompleted, bytesInTotal) - } - }, - coroutineScopeProvider = coroutineScopeReference::get, - ) - UploadController( - uploader = this@Uploader, - handle = handle, - bridge = JniUploadController(), - cancellationTokenSource = source, - channel = channel, - coroutineScopeConsumer = coroutineScopeReference::set, - ) - } - - override fun close() = bridge.free(handle) - - override suspend fun cancel() { - log(INFO, "cancel") - super.cancel() - } - - private fun log(level: LoggerProvider.Level, message: String) { - bridge.clientLogger(level, "FileUploader(${handle.toLogId()}) $message") - } -} - -suspend fun ProtonDriveClient.uploader( - request: FileUploaderRequest -): Uploader = cancellationTokenSource().let { source -> - factory(JniUploader()) { - Uploader( - client = this@uploader, - handle = getFile(this@uploader.handle, source.handle, request), - bridge = this, - cancellationTokenSource = source, - ) - } -} - -suspend fun ProtonDriveClient.uploader( - request: FileRevisionUploaderRequest -): Uploader = cancellationTokenSource().let { source -> - factory(JniUploader()) { - Uploader( - client = this@uploader, - handle = getFileRevision(handle, source.handle, request), - bridge = this, - cancellationTokenSource = source, - ) - } + ): UploadController } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt new file mode 100644 index 00000000..5b86c99b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt @@ -0,0 +1,18 @@ +package me.proton.drive.sdk.entity + +enum class PhotoTag(val value: Long) { + Favorites(0), + Screenshots(1), + Videos(2), + LivePhotos(3), + MotionPhotos(4), + Selfies(5), + Portraits(6), + Bursts(7), + Panoramas(8), + Raw(9); + + companion object { + fun fromLong(value: Long): PhotoTag? = entries.firstOrNull { entry -> entry.value == value } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt new file mode 100644 index 00000000..4702950e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt @@ -0,0 +1,13 @@ +package me.proton.drive.sdk.entity + +data class PhotosUploaderRequest( + val name: String, + val mediaType: String, + val fileSize: Long, + val lastModificationTime: Long?, // optional + val captureTime: Long?, // optional + val mainPhotoLinkId: String?, // optional + val tags: List = emptyList(), // optional + val overrideExistingDraftByOtherClient: Boolean, + val additionalMetadata: Map = emptyMap(), // optional +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt similarity index 100% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderCreationRequest.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotoTag.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotoTag.kt new file mode 100644 index 00000000..16d6ec9b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotoTag.kt @@ -0,0 +1,17 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.PhotoTag +import proton.drive.sdk.ProtonDriveSdk.PhotoTag as SdkPhotoTag + +fun PhotoTag.toSdkPhotoTag(): SdkPhotoTag = when (this) { + PhotoTag.Favorites -> SdkPhotoTag.PHOTO_TAG_FAVORITES + PhotoTag.Screenshots -> SdkPhotoTag.PHOTO_TAG_SCREENSHOTS + PhotoTag.Videos -> SdkPhotoTag.PHOTO_TAG_VIDEOS + PhotoTag.LivePhotos -> SdkPhotoTag.PHOTO_TAG_LIVE_PHOTOS + PhotoTag.MotionPhotos -> SdkPhotoTag.PHOTO_TAG_MOTION_PHOTOS + PhotoTag.Selfies -> SdkPhotoTag.PHOTO_TAG_SELFIES + PhotoTag.Portraits -> SdkPhotoTag.PHOTO_TAG_PORTRAITS + PhotoTag.Bursts -> SdkPhotoTag.PHOTO_TAG_BURSTS + PhotoTag.Panoramas -> SdkPhotoTag.PHOTO_TAG_PANORAMAS + PhotoTag.Raw -> SdkPhotoTag.PHOTO_TAG_RAW +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt new file mode 100644 index 00000000..07822596 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt @@ -0,0 +1,44 @@ +package me.proton.drive.sdk.extension + +import com.google.protobuf.kotlin.toByteString +import com.google.protobuf.timestamp +import me.proton.drive.sdk.entity.PhotosUploaderRequest +import proton.drive.sdk.additionalMetadataProperty +import proton.drive.sdk.drivePhotosClientGetPhotoUploaderRequest +import proton.drive.sdk.photoFileUploadMetadata + +internal fun PhotosUploaderRequest.toProtobuf( + clientHandle: Long, + cancellationTokenSourceHandle: Long, +) = drivePhotosClientGetPhotoUploaderRequest { + this.clientHandle = clientHandle + name = this@toProtobuf.name + size = this@toProtobuf.fileSize + metadata = photoFileUploadMetadata { + mediaType = this@toProtobuf.mediaType + this@toProtobuf.captureTime?.let { + captureTime = timestamp { + seconds = it + } + } + this@toProtobuf.lastModificationTime?.let { + lastModificationTime = timestamp { + seconds = it + } + } + overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient + additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> + additionalMetadataProperty { + this.name = name + this.utf8JsonValue = data.toByteString() + } + } + this@toProtobuf.mainPhotoLinkId?.let { + mainPhotoLinkId = it + } + tags += this@toProtobuf.tags.map { photoTag -> + photoTag.toSdkPhotoTag() + } + } + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt similarity index 97% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt index 249aa3c5..c421531c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt @@ -15,7 +15,7 @@ import proton.drive.sdk.thumbnail import proton.drive.sdk.uploadFromStreamRequest import java.nio.ByteBuffer -class JniUploader internal constructor() : JniBaseProtonDriveSdk() { +class JniFileUploader internal constructor() : JniBaseProtonDriveSdk() { suspend fun getFile( clientHandle: Long, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt new file mode 100644 index 00000000..883974ae --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt @@ -0,0 +1,91 @@ +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.entity.PhotosUploaderRequest +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.toLongResponse +import me.proton.drive.sdk.extension.toProtobuf +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_PREVIEW +import proton.drive.sdk.ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_THUMBNAIL +import proton.drive.sdk.drivePhotosClientFindDuplicatesRequest +import proton.drive.sdk.drivePhotosClientUploadFromStreamRequest +import proton.drive.sdk.drivePhotosClientUploaderFreeRequest +import proton.drive.sdk.request +import proton.drive.sdk.thumbnail +import java.nio.ByteBuffer +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.forEach + +class JniPhotosUploader internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun getPhoto( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + request: PhotosUploaderRequest, + ): Long = executeOnce("getPhoto", LongResponseCallback) { + drivePhotosClientGetPhotoUploader = + request.toProtobuf(clientHandle, cancellationTokenSourceHandle) + } + + suspend fun uploadFromStream( + uploaderHandle: Long, + cancellationTokenSourceHandle: Long, + thumbnails: Map, + onRead: (ByteBuffer) -> Int, + onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, + coroutineScopeProvider: CoroutineScopeProvider, + ): Long = executePersistent( + clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + name = method("uploadFromStream"), + response = continuation.toLongResponse().asClientResponseCallback(), + read = onRead, + progress = onProgress, + logger = internalLogger, + coroutineScopeProvider = coroutineScopeProvider, + ) + }, + requestBuilder = { nativeClient -> + request { + drivePhotosClientUploadFromStream = drivePhotosClientUploadFromStreamRequest { + this.uploaderHandle = uploaderHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + readAction = ProtonDriveSdkNativeClient.getReadPointer() + progressAction = ProtonDriveSdkNativeClient.getProgressPointer() + thumbnails.forEach { (type, data) -> + this.thumbnails.add(thumbnail { + this.type = when (type) { + ThumbnailType.THUMBNAIL -> THUMBNAIL_TYPE_THUMBNAIL + ThumbnailType.PREVIEW -> THUMBNAIL_TYPE_PREVIEW + } + dataPointer = nativeClient.getByteArrayPointer(data) + dataLength = data.size.toLong() + }) + } + } + } + } + ) +/* + suspend fun findDuplicates( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + ): Long = executeOnce("findDuplicates", LongResponseCallback) { + drivePhotosClientFindDuplicates = drivePhotosClientFindDuplicatesRequest { + this.name = "" + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + this.generateSha1Function = + } + } +*/ + fun free(handle: Long) { + dispatch("free") { + drivePhotosClientUploaderFree = + drivePhotosClientUploaderFreeRequest { fileUploaderHandle = handle } + } + releaseAll() + } +} From fa8d8b36b7a49d429e855f5e9ceaed114ffdbc81 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 27 Jan 2026 14:51:02 +0100 Subject: [PATCH 478/791] Implement pausing and resuming of downloads --- .../Nodes/Download/DownloadController.cs | 117 +++++++++++++++--- .../Nodes/Download/DownloadState.cs | 80 ++++++++++++ .../Nodes/Download/FileDownloader.cs | 106 ++++++++++++++-- .../Nodes/Download/RevisionReader.cs | 99 ++++++++------- .../Proton.Drive.Sdk/Nodes/ITaskControl.cs | 11 ++ .../Nodes/RevisionOperations.cs | 20 ++- .../src/Proton.Drive.Sdk/Nodes/TaskControl.cs | 2 +- .../Nodes/Upload/UploadController.cs | 4 +- .../Telemetry/DownloadEvent.cs | 2 + .../Nodes/Download/PhotosFileDownloader.cs | 93 +++++++++++--- 10 files changed, 440 insertions(+), 94 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 142bb6aa..da037963 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -1,18 +1,36 @@ -namespace Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; -public sealed class DownloadController +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed class DownloadController : IAsyncDisposable { - private readonly Task _downloadTask; + private readonly Task _downloadStateTask; + private readonly Func _resumeFunction; + private readonly ITaskControl _taskControl; + private readonly Stream? _outputStreamToDispose; + private bool _isDownloadCompleteWithVerificationIssue; - internal DownloadController(Task downloadTask) + internal DownloadController( + Task downloadStateTask, + Task downloadTask, + Func resumeFunction, + Stream? outputStreamToDispose, + ITaskControl taskControl) { - _downloadTask = downloadTask; - Completion = WrapDownloadTaskAsync(); + _downloadStateTask = downloadStateTask; + _resumeFunction = resumeFunction; + _taskControl = taskControl; + _outputStreamToDispose = outputStreamToDispose; + + Completion = PauseOnResumableErrorAsync(downloadTask); } - // FIXME - public bool IsPaused { get; } + internal event Action? DownloadFailed; + internal event Action? DownloadSucceeded; + + public bool IsPaused => _taskControl.IsPaused; public Task Completion { get; private set; } @@ -21,30 +39,95 @@ public bool GetIsDownloadCompleteWithVerificationIssue() return _isDownloadCompleteWithVerificationIssue; } -#pragma warning disable S2325 // Methods and properties that don't access instance data should be static: waiting for implementation public void Pause() { - // FIXME - throw new NotImplementedException(); + _taskControl.Pause(); } public void Resume() { - // FIXME - throw new NotImplementedException(); + if (!_taskControl.TryResume()) + { + return; + } + + Completion = PauseOnResumableErrorAsync(_resumeFunction.Invoke(_taskControl.PauseOrCancellationToken)); + } + + public async ValueTask DisposeAsync() + { + try + { + try + { + if (Completion.IsCompletedSuccessfully) + { + return; + } + + if (Completion.IsFaulted) + { + DownloadFailed?.Invoke(Completion.Exception.Flatten().InnerException ?? Completion.Exception); + } + + var stateExists = _downloadStateTask.IsCompletedSuccessfully; + if (!stateExists) + { + return; + } + + await _downloadStateTask.Result.DisposeAsync().ConfigureAwait(false); + } + finally + { + _taskControl.Dispose(); + } + } + finally + { + if (_outputStreamToDispose is not null) + { + await _outputStreamToDispose.DisposeAsync().ConfigureAwait(false); + } + } + } + + private static bool IsResumableError(Exception ex) + { + return ex is not DataIntegrityException + and not ProtonApiException { TransportCode: >= 400 and < 500 } + and not CompletedDownloadManifestVerificationException; } -#pragma warning restore S2325 // Methods and properties that don't access instance data should be static - private async Task WrapDownloadTaskAsync() + private async Task PauseOnResumableErrorAsync(Task downloadTask) { try { - await _downloadTask.ConfigureAwait(false); + await downloadTask.ConfigureAwait(false); + + await RaiseDownloadSucceededAsync().ConfigureAwait(false); } catch (CompletedDownloadManifestVerificationException error) { _isDownloadCompleteWithVerificationIssue = true; - throw new DataIntegrityException(error.Message); + throw new DataIntegrityException(error.Message, error); } + catch (Exception ex) when (IsResumableError(ex)) + { + _taskControl.Pause(); + throw; + } + } + + private async ValueTask RaiseDownloadSucceededAsync() + { + var onSucceededHandler = DownloadSucceeded; + if (onSucceededHandler is null) + { + return; + } + + var downloadState = await _downloadStateTask.ConfigureAwait(false); + onSucceededHandler.Invoke(downloadState.GetNumberOfBytesWritten()); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs new file mode 100644 index 00000000..47800140 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; + +namespace Proton.Drive.Sdk.Nodes.Download; + +internal sealed partial class DownloadState( + RevisionUid uid, + PgpPrivateKey nodeKey, + PgpSessionKey contentKey, + BlockListingRevisionDto revisionDto, + ILogger logger) : IAsyncDisposable +{ + private readonly List> _downloadedBlockDigests = []; + private readonly Lock _stateLock = new(); + private readonly ILogger _logger = logger; + + private long _numberOfBytesWritten; + private bool _isCompleted; + + public RevisionUid Uid { get; } = uid; + public BlockListingRevisionDto RevisionDto { get; } = revisionDto; + public PgpPrivateKey NodeKey { get; } = nodeKey; + public PgpSessionKey ContentKey { get; } = contentKey; + + public int GetNextBlockIndexToDownload() + { + lock (_stateLock) + { + return _downloadedBlockDigests.Count + 1; + } + } + + public IReadOnlyList> GetDownloadedBlockDigests() + { + lock (_stateLock) + { + return _downloadedBlockDigests; + } + } + + public void AddDownloadedBlockDigest(ReadOnlyMemory sha256Digest) + { + lock (_stateLock) + { + _downloadedBlockDigests.Add(sha256Digest); + } + } + + public long GetNumberOfBytesWritten() + { + return Interlocked.Read(ref _numberOfBytesWritten); + } + + public void AddNumberOfBytesWritten(long bytes) + { + Interlocked.Add(ref _numberOfBytesWritten, bytes); + } + + public void SetIsCompleted() + { + _isCompleted = true; + } + + public ValueTask DisposeAsync() + { + NodeKey.Dispose(); + ContentKey.Dispose(); + + if (!_isCompleted) + { + LogDownloadNotCompleted(Uid); + } + + return ValueTask.CompletedTask; + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Download disposed before completion for revision {RevisionUid}")] + private partial void LogDownloadNotCompleted(RevisionUid revisionUid); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index d9c1c2f8..816895f9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -1,4 +1,6 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Telemetry; +using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk.Nodes.Download; @@ -19,16 +21,14 @@ private FileDownloader(ProtonDriveClient client, RevisionUid revisionUid, ILogge public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { - var task = DownloadToStreamAsync(contentOutputStream, onProgress, cancellationToken); - - return new DownloadController(task); + return BuildDownloadController(contentOutputStream, ownsOutputStream: false, onProgress, cancellationToken); } public DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken) { - var task = DownloadToFileAsync(filePath, onProgress, cancellationToken); + var contentOutputStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); - return new DownloadController(task); + return BuildDownloadController(contentOutputStream, ownsOutputStream: true, onProgress, cancellationToken); } public void Dispose() @@ -58,21 +58,101 @@ internal static async ValueTask CreateAsync(ProtonDriveClient cl [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from block listing semaphore for revision \"{RevisionUid}\"")] private static partial void LogReleasedBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int count); - private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to record telemetry event")] + private static partial void LogTelemetryEventFailed(ILogger logger, Exception exception); + + private async Task DownloadToStreamAsync( + Stream contentOutputStream, + Action onProgress, + TaskCompletionSource downloadStateTaskCompletionSource, + CancellationToken cancellationToken) { - using var revisionReader = await RevisionOperations.OpenForReadingAsync(_client, _revisionUid, ReleaseBlockListing, cancellationToken) - .ConfigureAwait(false); + if (!downloadStateTaskCompletionSource.Task.IsCompletedSuccessfully) + { + var state = await RevisionOperations.CreateDownloadStateAsync( + _client, + _revisionUid, + ReleaseBlockListing, + cancellationToken).ConfigureAwait(false); + + downloadStateTaskCompletionSource.SetResult(state); + } + + var downloadState = downloadStateTaskCompletionSource.Task.Result; + + if (downloadState.GetNumberOfBytesWritten() > 0) + { + if (!contentOutputStream.CanSeek) + { + throw new InvalidOperationException("Cannot resume download to a non-seekable stream"); + } + + contentOutputStream.Seek(downloadState.GetNumberOfBytesWritten(), SeekOrigin.Begin); + } + + await _client.BlockDownloader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); + + using var revisionReader = RevisionOperations.OpenForReading(_client, downloadState, ReleaseBlockListing); await revisionReader.ReadAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); } - private async Task DownloadToFileAsync(string filePath, Action onProgress, CancellationToken cancellationToken) + private DownloadController BuildDownloadController( + Stream contentOutputStream, + bool ownsOutputStream, + Action onProgress, + CancellationToken cancellationToken) { - var contentOutputStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + var taskControl = new TaskControl(cancellationToken); + + var downloadStateTaskCompletionSource = new TaskCompletionSource(); + + var downloadEvent = new DownloadEvent + { + DownloadedSize = 0, + VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type + }; + + var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( + contentOutputStream, + onProgress, + downloadStateTaskCompletionSource, + ct); + + var downloadController = new DownloadController( + downloadStateTaskCompletionSource.Task, + downloadFunction.Invoke(taskControl.PauseOrCancellationToken), + downloadFunction, + ownsOutputStream ? contentOutputStream : null, + taskControl); + + downloadController.DownloadFailed += ex => + { + downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); + downloadEvent.OriginalError = ex.GetBaseException().ToString(); + RaiseTelemetryEvent(downloadEvent); + }; + + downloadController.DownloadSucceeded += downloadedByteCount => + { + // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize + downloadEvent.DownloadedSize = downloadedByteCount; + downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); + RaiseTelemetryEvent(downloadEvent); + }; + + return downloadController; + } - await using (contentOutputStream.ConfigureAwait(false)) + private void RaiseTelemetryEvent(DownloadEvent downloadEvent) + { + try + { + _client.Telemetry.RecordMetric(downloadEvent); + } + catch (Exception ex) { - await DownloadToStreamAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); + LogTelemetryEventFailed(_logger, ex); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index dcab654a..9c117f3d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -12,10 +12,7 @@ internal sealed partial class RevisionReader : IDisposable public const int DefaultBlockPageSize = 10; private readonly ProtonDriveClient _client; - private readonly PgpPrivateKey _nodeKey; - private readonly RevisionUid _revisionUid; - private readonly PgpSessionKey _contentKey; - private readonly BlockListingRevisionDto _revisionDto; + private readonly DownloadState _state; private readonly Action _releaseBlockListingAction; private readonly Action _releaseFileSemaphoreAction; private readonly int _blockPageSize; @@ -23,23 +20,15 @@ internal sealed partial class RevisionReader : IDisposable private bool _fileSemaphoreReleased; - private long _totalProgress; - internal RevisionReader( ProtonDriveClient client, - RevisionUid revisionUid, - PgpPrivateKey nodeKey, - PgpSessionKey contentKey, - BlockListingRevisionDto revisionDto, + DownloadState state, Action releaseBlockListingAction, Action releaseFileSemaphoreAction, int blockPageSize = DefaultBlockPageSize) { _client = client; - _nodeKey = nodeKey; - _revisionUid = revisionUid; - _contentKey = contentKey; - _revisionDto = revisionDto; + _state = state; _releaseBlockListingAction = releaseBlockListingAction; _releaseFileSemaphoreAction = releaseFileSemaphoreAction; _blockPageSize = blockPageSize; @@ -61,7 +50,11 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action await using (manifestStream) { - if (_revisionDto.Thumbnails is { } thumbnails) + var downloadedBlockDigests = _state.GetDownloadedBlockDigests(); + var revisionDto = _state.RevisionDto; + + // Write thumbnail digests to manifest (if any and on first call) + if (revisionDto.Thumbnails is { } thumbnails && downloadedBlockDigests.Count == 0) { foreach (var sha256Digest in thumbnails.OrderBy(t => t.Type).Select(x => x.HashDigest)) { @@ -69,11 +62,19 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action } } + // Write already-downloaded block digests to manifest (for resumed downloads) + foreach (var digest in downloadedBlockDigests) + { + manifestStream.Write(digest.Span); + } + try { try { - await foreach (var (block, _) in GetBlocksAsync(cancellationToken).ConfigureAwait(false)) + var startBlockIndex = _state.GetNextBlockIndexToDownload(); + + await foreach (var (block, _) in GetBlocksAsync(startBlockIndex, cancellationToken).ConfigureAwait(false)) { if (!_client.BlockDownloader.Queue.TryStartBlock()) { @@ -106,9 +107,12 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt catch when (downloadTasks.Count > 0) { try + { + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + catch { // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw - await Task.WhenAll(downloadTasks).ContinueWith(task => task.Exception?.Handle(_ => true), TaskContinuationOptions.NotOnRanToCompletion).ConfigureAwait(false); } finally { @@ -124,10 +128,12 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt if (manifestVerificationStatus is not PgpVerificationStatus.Ok) { - LogFailedManifestVerification(_revisionUid, manifestVerificationStatus); + LogFailedManifestVerification(_state.Uid, manifestVerificationStatus); throw new CompletedDownloadManifestVerificationException("File authenticity check failed"); } + + _state.SetIsCompleted(); } } catch (Exception ex) when (!cancellationToken.IsCancellationRequested && TelemetryErrorResolver.GetDownloadErrorFromException(ex) is { } error) @@ -179,15 +185,17 @@ private async Task WriteNextBlockToOutputAsync( try { + _state.AddDownloadedBlockDigest(downloadResult.Sha256Digest); + manifestStream.Write(downloadResult.Sha256Digest.Span); downloadedStream.Seek(0, SeekOrigin.Begin); await downloadedStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); - _totalProgress += downloadedStream.Length; + _state.AddNumberOfBytesWritten(downloadedStream.Length); - onProgress(_totalProgress, _revisionDto.Size); + onProgress(_state.GetNumberOfBytesWritten(), _state.RevisionDto.Size); } finally { @@ -205,27 +213,39 @@ private async Task DownloadBlockAsync(BlockDto block, Cance var blockOutputStream = ProtonDriveClient.MemoryStreamManager.GetStream(); var hashDigest = await _client.BlockDownloader.DownloadAsync( - _revisionUid, + _state.Uid, block.Index, block.BareUrl, block.Token, - _contentKey, + _state.ContentKey, blockOutputStream, cancellationToken).ConfigureAwait(false); - return new BlockDownloadResult(block.Index, blockOutputStream, hashDigest); + return new BlockDownloadResult(blockOutputStream, hashDigest); } - private async IAsyncEnumerable<(BlockDto Value, bool IsLast)> GetBlocksAsync([EnumeratorCancellation] CancellationToken cancellationToken) + private async IAsyncEnumerable<(BlockDto Value, bool IsLast)> GetBlocksAsync( + int startBlockIndex, + [EnumeratorCancellation] CancellationToken cancellationToken) { try { var mustTryNextPageOfBlocks = true; - var nextExpectedIndex = 1; + var nextExpectedIndex = startBlockIndex; var outstandingBlock = default(BlockDto); var currentPageBlocks = new List(_blockPageSize); - var revisionDto = _revisionDto; + // Fetch the first page of blocks starting from the desired index + var revisionResponse = await _client.Api.Files.GetRevisionAsync( + _state.Uid.NodeUid.VolumeId, + _state.Uid.NodeUid.LinkId, + _state.Uid.RevisionId, + startBlockIndex, + _blockPageSize, + withoutBlockUrls: false, + cancellationToken).ConfigureAwait(false); + + var revisionDto = revisionResponse.Revision; while (mustTryNextPageOfBlocks) { @@ -255,7 +275,7 @@ private async Task DownloadBlockAsync(BlockDto block, Cance if (block.Index != nextExpectedIndex) { - LogMissingBlock(block.Index, _revisionUid); + LogMissingBlock(block.Index, _state.Uid); throw new ProtonDriveException("File contents are incomplete"); } @@ -267,11 +287,11 @@ private async Task DownloadBlockAsync(BlockDto block, Cance if (mustTryNextPageOfBlocks) { - var revisionResponse = + revisionResponse = await _client.Api.Files.GetRevisionAsync( - _revisionUid.NodeUid.VolumeId, - _revisionUid.NodeUid.LinkId, - _revisionUid.RevisionId, + _state.Uid.NodeUid.VolumeId, + _state.Uid.NodeUid.LinkId, + _state.Uid.RevisionId, lastKnownIndex + 1, _blockPageSize, false, @@ -296,21 +316,21 @@ await _client.Api.Files.GetRevisionAsync( private async Task VerifyManifestAsync(Stream manifestStream, CancellationToken cancellationToken) { - if (_revisionDto.ManifestSignature is null) + if (_state.RevisionDto.ManifestSignature is null) { return PgpVerificationStatus.NotSigned; } - var verificationKeys = string.IsNullOrEmpty(_revisionDto.SignatureEmailAddress) - ? [_nodeKey.ToPublic()] - : await _client.Account.GetAddressPublicKeysAsync(_revisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + var verificationKeys = string.IsNullOrEmpty(_state.RevisionDto.SignatureEmailAddress) + ? [_state.NodeKey.ToPublic()] + : await _client.Account.GetAddressPublicKeysAsync(_state.RevisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); if (verificationKeys.Count == 0) { return PgpVerificationStatus.NoVerifier; } - var verificationResult = new PgpKeyRing(verificationKeys).Verify(manifestStream, _revisionDto.ManifestSignature.Value); + var verificationResult = new PgpKeyRing(verificationKeys).Verify(manifestStream, _state.RevisionDto.ManifestSignature.Value); return verificationResult.Status; } @@ -321,10 +341,5 @@ private async Task VerifyManifestAsync(Stream manifestStr [LoggerMessage(Level = LogLevel.Warning, Message = "Manifest verification failed for revision \"{RevisionUid}\": {VerificationStatus}")] private partial void LogFailedManifestVerification(RevisionUid revisionUid, PgpVerificationStatus verificationStatus); - private readonly struct BlockDownloadResult(int index, Stream stream, ReadOnlyMemory sha256Digest) - { - public int Index { get; } = index; - public Stream Stream { get; } = stream; - public ReadOnlyMemory Sha256Digest { get; } = sha256Digest; - } + private readonly record struct BlockDownloadResult(Stream Stream, ReadOnlyMemory Sha256Digest); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs new file mode 100644 index 00000000..916c1c13 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs @@ -0,0 +1,11 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal interface ITaskControl : IDisposable +{ + bool IsPaused { get; } + bool IsCanceled { get; } + CancellationToken CancellationToken { get; } + CancellationToken PauseOrCancellationToken { get; } + void Pause(); + bool TryResume(); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index 8fec32b5..a93aa240 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -1,4 +1,4 @@ -using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; namespace Proton.Drive.Sdk.Nodes; @@ -21,7 +21,7 @@ public static async ValueTask OpenForWritingAsync( client.TargetBlockSize); } - internal static async ValueTask OpenForReadingAsync( + internal static async ValueTask CreateDownloadStateAsync( ProtonDriveClient client, RevisionUid revisionUid, Action releaseBlockListingAction, @@ -45,14 +45,24 @@ internal static async ValueTask OpenForReadingAsync( withoutBlockUrls: false, cancellationToken).ConfigureAwait(false); - await client.BlockDownloader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); + releaseBlockListingAction.Invoke(1); - return new RevisionReader( - client, + return new DownloadState( revisionUid, key, contentKey, revisionResponse.Revision, + client.Telemetry.GetLogger("Download state")); + } + + internal static RevisionReader OpenForReading( + ProtonDriveClient client, + DownloadState downloadState, + Action releaseBlockListingAction) + { + return new RevisionReader( + client, + downloadState, releaseBlockListingAction, () => client.BlockDownloader.Queue.FinishFile()); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs index 0498e4b2..4a8868bc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes; -internal sealed class TaskControl(CancellationToken cancellationToken) : IDisposable +internal sealed class TaskControl(CancellationToken cancellationToken) : ITaskControl { private readonly Lock _pauseLock = new(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index da82edc6..befe9703 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -7,7 +7,7 @@ public sealed class UploadController : IAsyncDisposable { private readonly Task _revisionDraftTask; private readonly Func> _resumeFunction; - private readonly TaskControl _taskControl; + private readonly ITaskControl _taskControl; private readonly Stream? _sourceStreamToDispose; private bool _isDisposed; @@ -17,7 +17,7 @@ internal UploadController( Task uploadTask, Func> resumeFunction, Stream? sourceStreamToDispose, - TaskControl taskControl) + ITaskControl taskControl) { _revisionDraftTask = revisionDraftTask; _resumeFunction = resumeFunction; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs index 84309b5f..17fb5f45 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs @@ -10,6 +10,8 @@ public sealed class DownloadEvent : IMetricEvent public long DownloadedSize { get; set; } + public long ApproximateDownloadedSize { get; set; } + public long ClaimedFileSize { get; set; } public DownloadError? Error { get; set; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs index 7096d60c..589cefc7 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -2,6 +2,8 @@ using Proton.Drive.Sdk; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Telemetry; +using Proton.Sdk.Telemetry; namespace Proton.Photos.Sdk.Nodes.Download; @@ -23,16 +25,13 @@ private PhotosFileDownloader(ProtonPhotosClient client, NodeUid photoUid, ILogge public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { - var task = DownloadToStreamAsync(contentOutputStream, onProgress, cancellationToken); - - return new DownloadController(task); + return DownloadToStream(contentOutputStream, ownsOutputStream: false, onProgress, cancellationToken); } public DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken) { - var task = DownloadToFileAsync(filePath, onProgress, cancellationToken); - - return new DownloadController(task); + var stream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + return DownloadToStream(stream, ownsOutputStream: true, onProgress, cancellationToken); } public void Dispose() @@ -59,7 +58,14 @@ internal static async ValueTask CreateAsync(ProtonPhotosCl [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Decrement} from block listing semaphore for photo {PhotoUid}")] private static partial void LogReleasedBlockListingSemaphore(ILogger logger, NodeUid photoUid, int decrement); - private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to record telemetry event")] + private static partial void LogTelemetryEventFailed(ILogger logger, Exception exception); + + private async Task DownloadToStreamAsync( + Stream contentOutputStream, + Action onProgress, + TaskCompletionSource downloadStateTaskCompletionSource, + CancellationToken cancellationToken) { var result = await _client.GetNodeAsync(_photoUid, cancellationToken).ConfigureAwait(false); @@ -68,23 +74,82 @@ private async Task DownloadToStreamAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + private DownloadController DownloadToStream( + Stream contentOutputStream, + bool ownsOutputStream, + Action onProgress, + CancellationToken cancellationToken) { - var contentOutputStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + var taskControl = new TaskControl(cancellationToken); + + var downloadStateTaskCompletionSource = new TaskCompletionSource(); - await using (contentOutputStream.ConfigureAwait(false)) + var downloadEvent = new DownloadEvent + { + DownloadedSize = 0, + VolumeType = VolumeType.Photo, + }; + + var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( + contentOutputStream, + onProgress, + downloadStateTaskCompletionSource, + ct); + + var downloadController = new DownloadController( + downloadStateTaskCompletionSource.Task, + downloadFunction.Invoke(taskControl.PauseOrCancellationToken), + downloadFunction, + ownsOutputStream ? contentOutputStream : null, + taskControl); + + downloadController.DownloadFailed += ex => + { + downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); + downloadEvent.OriginalError = ex.GetBaseException().ToString(); + RaiseTelemetryEvent(downloadEvent); + }; + + downloadController.DownloadSucceeded += downloadedByteCount => + { + // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize + downloadEvent.DownloadedSize = downloadedByteCount; + downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); + RaiseTelemetryEvent(downloadEvent); + }; + + return downloadController; + } + + private void RaiseTelemetryEvent(DownloadEvent downloadEvent) + { + try + { + _client.DriveClient.Telemetry.RecordMetric(downloadEvent); + } + catch (Exception ex) { - await DownloadToStreamAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); + LogTelemetryEventFailed(_logger, ex); } } From 5e4505da1150a98a81f28e811e63ff5297408abd Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 27 Jan 2026 15:50:53 +0100 Subject: [PATCH 479/791] Transform progress callback to flow --- .../me/proton/drive/sdk/CommonDownloadController.kt | 8 ++++++++ .../me/proton/drive/sdk/CommonUploadController.kt | 9 ++++++++- .../kotlin/me/proton/drive/sdk/DownloadController.kt | 3 +++ .../src/main/kotlin/me/proton/drive/sdk/Downloader.kt | 1 - .../main/kotlin/me/proton/drive/sdk/FileDownloader.kt | 7 ++++--- .../main/kotlin/me/proton/drive/sdk/FileUploader.kt | 9 ++++----- .../kotlin/me/proton/drive/sdk/PhotosDownloader.kt | 9 +++++---- .../main/kotlin/me/proton/drive/sdk/PhotosUploader.kt | 9 ++++----- .../main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt | 6 ++++++ .../kotlin/me/proton/drive/sdk/UploadController.kt | 3 +++ .../src/main/kotlin/me/proton/drive/sdk/Uploader.kt | 1 - .../me/proton/drive/sdk/extension/ProgressUpdate.kt | 11 +++++++++++ 12 files changed, 56 insertions(+), 20 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt index b7cc0f58..b4c40b5a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -2,6 +2,7 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.internal.CoroutineScopeConsumer @@ -20,6 +21,13 @@ class CommonDownloadController internal constructor( val isPausedFlow = MutableStateFlow(false) + private val _progressFlow = MutableStateFlow(null) + override val progressFlow = _progressFlow.asStateFlow() + + internal suspend fun emitProgress(progress: ProgressUpdate?) { + _progressFlow.emit(progress) + } + override suspend fun awaitCompletion() { log(DEBUG, "await completion") runCatching { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt index b1e00bcc..bdac79c2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt @@ -2,13 +2,13 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.UploadResult import me.proton.drive.sdk.internal.CoroutineScopeConsumer import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId -import java.io.InputStream import java.nio.channels.Channel class CommonUploadController internal constructor( @@ -22,6 +22,13 @@ class CommonUploadController internal constructor( val isPausedFlow = MutableStateFlow(false) + private val _progressFlow = MutableStateFlow(null) + override val progressFlow = _progressFlow.asStateFlow() + + internal suspend fun emitProgress(progress: ProgressUpdate?) { + _progressFlow.emit(progress) + } + override suspend fun awaitCompletion(): UploadResult { log(DEBUG, "await completion") return runCatching { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index d4462e12..73d83b99 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -1,9 +1,12 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow interface DownloadController : AutoCloseable, Cancellable { + val progressFlow: Flow + suspend fun awaitCompletion() suspend fun pause() suspend fun resume(coroutineScope: CoroutineScope) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt index 6f2fe93a..e1208d0d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -8,6 +8,5 @@ interface Downloader : AutoCloseable, Cancellable { suspend fun downloadToStream( coroutineScope: CoroutineScope, channel: WritableByteChannel, - progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): DownloadController } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index 06413604..81717ba0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniFileDownloader import me.proton.drive.sdk.internal.factory @@ -21,10 +22,10 @@ class FileDownloader internal constructor( override suspend fun downloadToStream( coroutineScope: CoroutineScope, channel: WritableByteChannel, - progress: suspend (Long, Long) -> Unit, ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> log(INFO, "downloadToStream") val coroutineScopeReference = AtomicReference(coroutineScope) + val controllerReference = AtomicReference() val handle = bridge.downloadToStream( handle = handle, cancellationTokenSourceHandle = cancellationTokenSource.handle, @@ -32,7 +33,7 @@ class FileDownloader internal constructor( onProgress = { progressUpdate -> with(progressUpdate) { bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") - progress(bytesCompleted, bytesInTotal) + controllerReference.get()?.emitProgress(toEntity()) } }, coroutineScopeProvider = coroutineScopeReference::get, @@ -44,7 +45,7 @@ class FileDownloader internal constructor( channel = channel, cancellationTokenSource = cancellationTokenSource, coroutineScopeConsumer = coroutineScopeReference::set, - ) + ).also(controllerReference::set) } override fun close() { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt index 132b4b36..e441fda3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -7,11 +7,10 @@ import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.JniFileUploader import me.proton.drive.sdk.internal.toLogId -import java.io.InputStream -import java.nio.channels.Channels import java.nio.channels.ReadableByteChannel import java.util.concurrent.atomic.AtomicReference @@ -26,10 +25,10 @@ class FileUploader internal constructor( coroutineScope: CoroutineScope, channel: ReadableByteChannel, thumbnails: Map, - progress: suspend (Long, Long) -> Unit, ): UploadController = cancellationTokenSource().let { source -> log(INFO, "uploadFromStream") val coroutineScopeReference = AtomicReference(coroutineScope) + val controllerReference = AtomicReference() val handle = bridge.uploadFromStream( uploaderHandle = handle, cancellationTokenSourceHandle = source.handle, @@ -38,7 +37,7 @@ class FileUploader internal constructor( onProgress = { progressUpdate -> with(progressUpdate) { log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") - progress(bytesCompleted, bytesInTotal) + controllerReference.get()?.emitProgress(toEntity()) } }, coroutineScopeProvider = coroutineScopeReference::get, @@ -50,7 +49,7 @@ class FileUploader internal constructor( cancellationTokenSource = source, channel = channel, coroutineScopeConsumer = coroutineScopeReference::set, - ) + ).also(controllerReference::set) } override fun close() = bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index 5ad68e50..ea66bccc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniPhotosDownloader import me.proton.drive.sdk.internal.factory @@ -21,10 +22,10 @@ class PhotosDownloader internal constructor( override suspend fun downloadToStream( coroutineScope: CoroutineScope, channel: WritableByteChannel, - progress: suspend (Long, Long) -> Unit, ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> log(INFO, "downloadToStream") val coroutineScopeReference = AtomicReference(coroutineScope) + val controllerReference = AtomicReference() val handle = bridge.downloadToStream( handle = handle, cancellationTokenSourceHandle = cancellationTokenSource.handle, @@ -32,7 +33,7 @@ class PhotosDownloader internal constructor( onProgress = { progressUpdate -> with(progressUpdate) { bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") - progress(bytesCompleted, bytesInTotal) + controllerReference.get()?.emitProgress(toEntity()) } }, coroutineScopeProvider = coroutineScopeReference::get, @@ -42,9 +43,9 @@ class PhotosDownloader internal constructor( handle = handle, bridge = JniDownloadController(), channel = channel, - cancellationTokenSource = cancellationTokenSource, coroutineScopeConsumer = coroutineScopeReference::set, - ) + cancellationTokenSource = cancellationTokenSource, + ).also(controllerReference::set) } override fun close() { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt index 772bc952..3a87be3f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt @@ -6,11 +6,10 @@ import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.entity.PhotosUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.internal.JniPhotosUploader import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId -import java.io.InputStream -import java.nio.channels.Channels import java.nio.channels.ReadableByteChannel import java.util.concurrent.atomic.AtomicReference @@ -25,11 +24,11 @@ class PhotosUploader( coroutineScope: CoroutineScope, channel: ReadableByteChannel, thumbnails: Map, - progress: suspend (Long, Long) -> Unit ): UploadController = cancellationTokenSource().let { source -> log(INFO, "uploadFromStream") val coroutineScopeReference = AtomicReference(coroutineScope) + val controllerReference = AtomicReference() val handle = bridge.uploadFromStream( uploaderHandle = handle, cancellationTokenSourceHandle = source.handle, @@ -38,7 +37,7 @@ class PhotosUploader( onProgress = { progressUpdate -> with(progressUpdate) { log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") - progress(bytesCompleted, bytesInTotal) + controllerReference.get()?.emitProgress(toEntity()) } }, coroutineScopeProvider = coroutineScopeReference::get, @@ -50,7 +49,7 @@ class PhotosUploader( cancellationTokenSource = source, channel = channel, coroutineScopeConsumer = coroutineScopeReference::set, - ) + ).also(controllerReference::set) } override fun close() = bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt new file mode 100644 index 00000000..4b076ad0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk + +data class ProgressUpdate( + val bytesCompleted: Long, + val bytesInTotal: Long, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index 87006d01..9738f84a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -1,10 +1,13 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import me.proton.drive.sdk.entity.UploadResult interface UploadController : AutoCloseable, Cancellable { + val progressFlow: Flow + suspend fun awaitCompletion(): UploadResult suspend fun resume(coroutineScope: CoroutineScope) suspend fun pause() diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index 43cc1446..f4f04c13 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -10,6 +10,5 @@ interface Uploader : AutoCloseable, Cancellable { coroutineScope: CoroutineScope, channel: ReadableByteChannel, thumbnails: Map = emptyMap(), - progress: suspend (Long, Long) -> Unit = { _, _ -> }, ): UploadController } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt new file mode 100644 index 00000000..15ff210c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProgressUpdate +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.ProgressUpdate.toEntity() = takeIf { it.bytesInTotal > 0 }?.run { + ProgressUpdate( + bytesCompleted = bytesCompleted, + bytesInTotal = bytesInTotal, + ) +} From 7b330f8c5eb33a570db321709c9f3fd40e22c58b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 28 Jan 2026 16:55:57 +0100 Subject: [PATCH 480/791] Fix timeout reported as cancellation through interop --- .../Api/Storage/StorageApiClient.cs | 5 ++--- .../Http/HttpClientFactoryExtensions.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs | 13 +++++++++---- cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs | 1 - cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs | 5 +++-- .../Proton.Sdk.CExports/InteropMessageHandler.cs | 9 ++++++++- cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs | 8 +++----- cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs | 2 ++ 8 files changed, 28 insertions(+), 17 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index e335c728..dce38ccb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -52,10 +52,9 @@ public async ValueTask GetBlobStreamAsync(string baseUrl, string token, return await blobResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException e) when (e.InnerException is TimeoutException timeoutException) + catch (OperationCanceledException e) when (!cancellationToken.IsCancellationRequested) { - ExceptionDispatchInfo.Capture(timeoutException).Throw(); - throw; // This line is never reached + throw new TimeoutException("The operation has timed out.", e); } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Http/HttpClientFactoryExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Http/HttpClientFactoryExtensions.cs index bc3f98a6..5379fea8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Http/HttpClientFactoryExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Http/HttpClientFactoryExtensions.cs @@ -2,7 +2,7 @@ internal static class HttpClientFactoryExtensions { - public static HttpClient CreateClientWithTimeout(this IHttpClientFactory httpClientFactory, int timeoutSeconds) + public static HttpClient CreateClientWithTimeout(this IHttpClientFactory httpClientFactory, double timeoutSeconds) { var client = httpClientFactory.CreateClient(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 9fa1548c..d9500c89 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -29,8 +29,11 @@ public sealed class ProtonDriveClient /// If no UID is not provided, one will be generated for the duration of this instance. public ProtonDriveClient(ProtonApiSession session, string? uid = null) : this( - session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonDriveDefaults.DefaultApiTimeoutSeconds)), - session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonDriveDefaults.StorageApiTimeoutSeconds), TimeSpan.FromSeconds(ProtonDriveDefaults.StorageApiTimeoutSeconds)), + session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)), + session.GetHttpClient( + ProtonDriveDefaults.DriveBaseRoute, + TimeSpan.FromSeconds(ProtonDriveDefaults.StorageApiTimeoutSeconds), + TimeSpan.FromSeconds(ProtonDriveDefaults.StorageApiTimeoutSeconds)), new AccountClientAdapter(session), new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), session.ClientConfiguration.FeatureFlagProvider, @@ -48,8 +51,10 @@ public ProtonDriveClient( ITelemetry telemetry, ProtonDriveClientOptions? creationParameters = null) : this( - new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout(creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonDriveDefaults.DefaultApiTimeoutSeconds), - new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout(creationParameters?.OverrideStorageApiTimeoutSeconds ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), + new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( + creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds), + new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( + creationParameters?.OverrideStorageApiTimeoutSeconds ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), featureFlagProvider, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs index e75776bb..2274c144 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs @@ -4,6 +4,5 @@ internal static class ProtonDriveDefaults { public const string DriveBaseRoute = "drive/"; - public const int DefaultApiTimeoutSeconds = 30; public const int StorageApiTimeoutSeconds = 300; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs index 4b4c3078..74ed7f72 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs @@ -22,7 +22,7 @@ public ProtonPhotosClient(ProtonApiSession session, string? uid = null) { DriveClient = new ProtonDriveClient(session, uid); - _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonDriveDefaults.DefaultApiTimeoutSeconds)); + _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)); Cache = new PhotosClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository); PhotosApi = new PhotosApiClient(_httpClient); @@ -46,7 +46,8 @@ public ProtonPhotosClient( telemetry, creationParameters); - _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout(creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonDriveDefaults.DefaultApiTimeoutSeconds); + _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout( + creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds); Cache = new PhotosClientCache(entityCacheRepository, secretCacheRepository); PhotosApi = new PhotosApiClient(_httpClient); diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs index 11df1c0d..c36f0c87 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -147,6 +147,13 @@ private static void SetException(nint tcsHandle, Error error) { var tfs = Interop.GetFromHandleAndFree(tcsHandle); - tfs.SetException(new InteropErrorException(error)); + if (error.Domain == ErrorDomain.SuccessfulCancellation) + { + tfs.SetException(new OperationCanceledException()); + } + else + { + tfs.SetException(new InteropErrorException(error)); + } } } diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs index d956a215..d49a1fd0 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -1,5 +1,4 @@ -using System; -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Runtime.ExceptionServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -93,10 +92,9 @@ public async ValueTask SendAsync(HttpRequestMessage requestMessage, Ca return await responseMessage.Content.ReadFromJsonAsync(_successTypeInfo, cancellationToken) .ConfigureAwait(false) ?? throw new JsonException(); } - catch (OperationCanceledException e) when (e.InnerException is TimeoutException timeoutException) + catch (OperationCanceledException e) when (!cancellationToken.IsCancellationRequested) { - ExceptionDispatchInfo.Capture(timeoutException).Throw(); - throw; // This line is never reached + throw new TimeoutException("The operation has timed out.", e); } } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs b/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs index 4c3fde8c..cb5a58bd 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs @@ -2,6 +2,8 @@ internal static class ProtonApiDefaults { + public const double DefaultTimeoutSeconds = 30; + public static Uri BaseUrl { get; } = new("https://drive-api.proton.me/"); public static Uri RefreshRedirectUri { get; } = new("https://proton.me"); From f7084b472d5ae4c55e51c4ad4462583d34945fdc Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 07:27:17 +0000 Subject: [PATCH 481/791] Remove check of NodeType inside iterateThumbnails --- .../src/internal/download/thumbnailDownloader.test.ts | 2 +- js/sdk/src/internal/download/thumbnailDownloader.ts | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/js/sdk/src/internal/download/thumbnailDownloader.test.ts b/js/sdk/src/internal/download/thumbnailDownloader.test.ts index e0f41b29..a8252c12 100644 --- a/js/sdk/src/internal/download/thumbnailDownloader.test.ts +++ b/js/sdk/src/internal/download/thumbnailDownloader.test.ts @@ -117,7 +117,7 @@ describe('ThumbnailDownloader', () => { const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); - expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Node is not a file' }]); + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Node has no thumbnail' }]); expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); }); diff --git a/js/sdk/src/internal/download/thumbnailDownloader.ts b/js/sdk/src/internal/download/thumbnailDownloader.ts index ca6ed60b..35191484 100644 --- a/js/sdk/src/internal/download/thumbnailDownloader.ts +++ b/js/sdk/src/internal/download/thumbnailDownloader.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { NodeType, ThumbnailType, ProtonDriveTelemetry, Logger, ThumbnailResult } from '../../interface'; +import { ThumbnailType, ProtonDriveTelemetry, Logger, ThumbnailResult } from '../../interface'; import { ValidationError } from '../../errors'; import { LoggerWithPrefix } from '../../telemetry'; import { DownloadAPIService } from './apiService'; @@ -95,14 +95,6 @@ export class ThumbnailDownloader { }; continue; } - if (node.type !== NodeType.File) { - yield { - nodeUid: node.uid, - ok: false, - error: c('Error').t`Node is not a file`, - }; - continue; - } let thumbnail; if (node.activeRevision?.ok) { From 8643095f1f8f59bac96c9be2e04240b2b19659fa Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 08:28:44 +0100 Subject: [PATCH 482/791] js/v0.9.5 --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 9402c0f5..5fb3a199 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.4", + "version": "0.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.9.4", + "version": "0.9.5", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index ffddd112..3b10d348 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.4", + "version": "0.9.5", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 7c75b462a4fe5d8042cb8b4fa3b33cdd06f4533f Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 12:12:31 +0000 Subject: [PATCH 483/791] Automate open-sourcing --- README.md | 52 ++++++++++++++++++++++++--- SECURITY.md | 6 +++- cs/Proton.Drive.Sdk.slnx | 11 ------ swift/ProtonDriveSDK/Package.swift | 58 ++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 swift/ProtonDriveSDK/Package.swift diff --git a/README.md b/README.md index 596f51ef..fdb847f7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,53 @@ # Proton Drive SDK -Copyright (c) 2025 Proton AG +The Proton Drive SDK provides a high-level interface for interacting with Proton Drive. It is available in JavaScript and C#, with bindings for Swift and Kotlin. -This repository contains the Proton Drive SDK for JavaScript and C# (coming soon). +## Current Status -At this point, the SDK is not ready for use. It is a work in progress. +> **Note:** The SDK is not yet ready for third-party production use. -## Contributions +The SDK is actively being integrated into official Proton Drive clients. During this phase, the architecture continues to evolve. A forthcoming major update will introduce a new cryptographic model that significantly improves performance, simplifies the architecture, and enhances security. This update will be a **breaking change**—SDK versions prior to the new crypto model will cease to function. -Contributions are not accepted at the moment. +Once these changes are complete and the integration is stable, the SDK will be officially released for third-party use. + +## Usage Guidelines for Personal Projects + +The SDK may be used for personal, non-commercial projects. If you choose to build an application using Proton Drive, you **must** adhere to the following requirements: + +### Technical Requirements + +| Requirement | Description | +|-------------|-------------| +| **Use the SDK** | Always interact with Proton Drive through the SDK. Direct API calls are not permitted. | +| **Use official endpoints** | All HTTP requests must be directed to the official Proton Drive domain. Do not modify or proxy API endpoints to different domains. | +| **Identify your application** | Set the `x-pm-appversion` HTTP header using the format `external-drive-{projectname}@{version}` (e.g., `external-drive-myapp@1.2.3`). This header must accurately represent your application. Do not spoof or falsify this value. | +| **Use event-based sync** | Synchronize data using Drive events. Do not poll the API or perform frequent recursive traversals of the file tree. | + +### Branding and User Safety Requirements + +| Requirement | Description | +|-------------|-------------| +| **No Proton branding** | Your application must not use Proton logos, trademarks, or design elements. It must be clearly distinguishable as an unofficial, third-party product. | +| **Credential handling disclosure** | Users must be explicitly warned that they are entering credentials into a non-official application. Passwords must never be stored by your application. | + +Failure to comply with these requirements may result in access restrictions. + +## Scope and Limitations + +The SDK provides functionality for Proton Drive business logic only. It does **not** include: + +- Authentication or login flows +- Session management +- User address provider + +These dependencies must be supplied by the integrating application. Reference implementations are available in the official Proton Drive clients. Standalone integration support will be provided once the SDK reaches general availability. + +## Documentation + +We are preparing the documentation for the SDK. It will be available in the future. + +## License + +This project is licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details. + +Copyright (c) 2026 Proton AG diff --git a/SECURITY.md b/SECURITY.md index 7b5ad73d..46e68856 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,7 @@ # Security -TBD +If you discover a security vulnerability in the Proton Drive SDK, please report it through our official bug bounty program: + +**[Proton Bug Bounty Program](https://proton.me/security/bug-bounty)** + +For general security inquiries, you can reach us at [security@proton.me](mailto:security@proton.me). diff --git a/cs/Proton.Drive.Sdk.slnx b/cs/Proton.Drive.Sdk.slnx index 468ab5f6..42eec0a6 100644 --- a/cs/Proton.Drive.Sdk.slnx +++ b/cs/Proton.Drive.Sdk.slnx @@ -1,7 +1,4 @@ - - - @@ -9,14 +6,6 @@ - - - - - - - - diff --git a/swift/ProtonDriveSDK/Package.swift b/swift/ProtonDriveSDK/Package.swift new file mode 100644 index 00000000..584e520a --- /dev/null +++ b/swift/ProtonDriveSDK/Package.swift @@ -0,0 +1,58 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription +import Foundation + +let package = Package( + name: "ProtonDriveSDK", + platforms: [ + .macOS(.v13), + .iOS(.v16), + .tvOS(.v15), + .watchOS(.v8) + ], + products: [ + .library( + name: "ProtonDriveSDK", + targets: ["ProtonDriveSDK"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.33.3"), + .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.1.0"), + .package(url: "https://github.com/ProtonMail/protoncore_ios.git", exact: "34.2.2"), + ], + targets: [ + .binaryTarget( + name: "CProtonDriveSDK", + path: "./Libraries/ProtonDriveSDK.xcframework" + ), + .target( + name: "ProtonDriveSDK", + dependencies: [ + "CProtonDriveSDK", + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "GoLibsCryptoPatchedGo", package: "protoncore_ios"), + .product(name: "ProtonCoreDataModel", package: "protoncore_ios"), + ], + path: "Sources", + swiftSettings: [ + .unsafeFlags(["-strict-concurrency=complete"]), + ], + linkerSettings: [ + // GSS is required by dotNET runtime, not directly used by the Drive app + .linkedFramework("GSS"), + .linkedLibrary("sqlite3"), + .linkedLibrary("icucore"), + + .unsafeFlags([ + // the bootstrapper contains the code to start the dotNET runtime – it asks the system API + // to spawn a new thread for garbage collector, allocate the memory to be managed by dotNET etc. + "-llibbootstrapperdll.osx-arm64.o", + "-llibbootstrapperdll.osx-x64.o", + ], .when(platforms: [.macOS])), + ], + ), + ] +) From ddeb97ba78f4fac168f4b452e4c7d9f61a24b387 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 14:05:01 +0000 Subject: [PATCH 484/791] Add function to create bookmark --- js/sdk/src/crypto/driveCrypto.test.ts | 29 +++++++- js/sdk/src/crypto/driveCrypto.ts | 10 +++ js/sdk/src/internal/sharing/apiService.ts | 23 +++++++ .../internal/sharing/cryptoService.test.ts | 67 +++++++++++++++++++ js/sdk/src/internal/sharing/cryptoService.ts | 28 ++++++++ js/sdk/src/internal/sharing/interface.ts | 1 + .../internal/sharing/sharingAccess.test.ts | 41 ++++++++++++ js/sdk/src/internal/sharing/sharingAccess.ts | 5 ++ js/sdk/src/internal/sharingPublic/index.ts | 1 + .../internal/sharingPublic/session/manager.ts | 15 ++--- js/sdk/src/protonDriveClient.ts | 28 ++++++-- 11 files changed, 231 insertions(+), 17 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.test.ts b/js/sdk/src/crypto/driveCrypto.test.ts index 29b09139..076eadfc 100644 --- a/js/sdk/src/crypto/driveCrypto.test.ts +++ b/js/sdk/src/crypto/driveCrypto.test.ts @@ -1,4 +1,4 @@ -import { uint8ArrayToUtf8, arrayToHexString } from './driveCrypto'; +import { uint8ArrayToUtf8, arrayToHexString, DriveCrypto } from './driveCrypto'; describe('uint8ArrayToUtf8', () => { it('should convert a Uint8Array to a UTF-8 string', () => { @@ -43,3 +43,30 @@ describe('arrayToHexString', () => { expect(result).toBe(expectedOutput); }); }); + +describe('DriveCrypto.encryptShareUrlPassword', () => { + it('should encrypt and sign the password', async () => { + const mockOpenPGPCrypto = { + encryptAndSignArmored: jest.fn().mockResolvedValue({ + armoredData: '-----BEGIN PGP MESSAGE-----\nencrypted data\n-----END PGP MESSAGE-----', + }), + }; + + const mockSrpModule = jest.fn(); + const driveCrypto = new DriveCrypto(mockOpenPGPCrypto as any, mockSrpModule as any); + + const password = 'testPassword123'; + const encryptionKey = 'mockEncryptionKey' as any; + const signingKey = 'mockSigningKey' as any; + + const result = await driveCrypto.encryptShareUrlPassword(password, encryptionKey, signingKey); + + expect(result).toBe('-----BEGIN PGP MESSAGE-----\nencrypted data\n-----END PGP MESSAGE-----'); + expect(mockOpenPGPCrypto.encryptAndSignArmored).toHaveBeenCalledWith( + new TextEncoder().encode(password), + undefined, + [encryptionKey], + signingKey, + ); + }); +}); diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 83656c17..e09ba2a8 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -715,6 +715,16 @@ export class DriveCrypto { const password = await this.openPGPCrypto.decryptArmored(armoredPassword, decryptionKeys); return uint8ArrayToUtf8(password); } + + async encryptShareUrlPassword(password: string, encryptionKey: PrivateKey, signingKey: PrivateKey): Promise { + const { armoredData } = await this.openPGPCrypto.encryptAndSignArmored( + new TextEncoder().encode(password), + undefined, + [encryptionKey], + signingKey, + ); + return armoredData; + } } export function uint8ArrayToUtf8(input: Uint8Array): string { diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 925cea2c..cc21df17 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -61,6 +61,11 @@ type GetShareExternalInvitations = type GetShareMembers = drivePaths['/drive/v2/shares/{shareID}/members']['get']['responses']['200']['content']['application/json']; +type PostSharedBookmarksRequest = + Extract['content']['application/json']; +type PostSharedBookmarksResponse = + drivePaths['/drive/v2/urls/{token}/bookmark']['post']['responses']['200']['content']['application/json']; + type PostCreateShareRequest = Extract< drivePaths['/drive/volumes/{volumeID}/shares']['post']['requestBody'], { content: object } @@ -293,6 +298,24 @@ export class SharingAPIService { } } + async createBookmark(bookmark: { + token: string; + encryptedUrlPassword: string; + addressId: string; + addressKeyId: string; + }): Promise { + await this.apiService.post( + `drive/v2/urls/${bookmark.token}/bookmark`, + { + BookmarkShareURL: { + EncryptedUrlPassword: bookmark.encryptedUrlPassword, + AddressID: bookmark.addressId, + AddressKeyID: bookmark.addressKeyId, + }, + }, + ); + } + async deleteBookmark(tokenId: string): Promise { await this.apiService.delete(`drive/v2/urls/${tokenId}/bookmark`); } diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts index dbbcdde5..63d2356b 100644 --- a/js/sdk/src/internal/sharing/cryptoService.test.ts +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -40,6 +40,8 @@ describe('SharingCryptoService', () => { sharesService = { getMyFilesShareMemberEmailKey: jest.fn().mockResolvedValue({ addressId: 'addressId', + addressKey: 'addressKey' as unknown as PrivateKey, + addressKeyId: 'keyId', }), }; cryptoService = new SharingCryptoService(telemetry, driveCrypto, account, sharesService); @@ -181,4 +183,69 @@ describe('SharingCryptoService', () => { }); }); }); + + describe('encryptBookmark', () => { + const token = 'abc123token'; + const urlPassword = 'generatedPass'; + const customPassword = 'customPass123'; + + beforeEach(() => { + sharesService.getMyFilesShareMemberEmailKey = jest.fn().mockResolvedValue({ + addressId: 'addressId123', + addressKey: 'addressKey1' as unknown as PrivateKey, + addressKeyId: 'keyId1', + }); + driveCrypto.encryptShareUrlPassword = jest.fn().mockResolvedValue('encryptedPassword'); + }); + + it('should encrypt bookmark with token, url password and custom password', async () => { + const result = await cryptoService.encryptBookmark(token, urlPassword, customPassword); + + expect(result).toEqual({ + token: 'abc123token', + encryptedUrlPassword: 'encryptedPassword', + addressId: 'addressId123', + addressKeyId: 'keyId1', + }); + expect(sharesService.getMyFilesShareMemberEmailKey).toHaveBeenCalled(); + expect(driveCrypto.encryptShareUrlPassword).toHaveBeenCalledWith( + 'generatedPasscustomPass123', + 'addressKey1', + 'addressKey1', + ); + }); + + it('should encrypt bookmark without custom password', async () => { + const result = await cryptoService.encryptBookmark(token, urlPassword); + + expect(result).toEqual({ + token: 'abc123token', + encryptedUrlPassword: 'encryptedPassword', + addressId: 'addressId123', + addressKeyId: 'keyId1', + }); + expect(driveCrypto.encryptShareUrlPassword).toHaveBeenCalledWith( + 'generatedPass', + 'addressKey1', + 'addressKey1', + ); + }); + + it('should use primary key from share service', async () => { + sharesService.getMyFilesShareMemberEmailKey = jest.fn().mockResolvedValue({ + addressId: 'addressId123', + addressKey: 'addressKey3' as unknown as PrivateKey, + addressKeyId: 'keyId3', + }); + + const result = await cryptoService.encryptBookmark(token, urlPassword, customPassword); + + expect(result.addressKeyId).toBe('keyId3'); + expect(driveCrypto.encryptShareUrlPassword).toHaveBeenCalledWith( + 'generatedPasscustomPass123', + 'addressKey3', + 'addressKey3', + ); + }); + }); }); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 0da4a9e8..600b96b7 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -418,6 +418,29 @@ export class SharingCryptoService { } } + async encryptBookmark(token: string, urlPassword: string, customPassword?: string): Promise<{ + token: string; + encryptedUrlPassword: string; + addressId: string; + addressKeyId: string; + }> { + const { addressId, addressKey, addressKeyId } = await this.sharesService.getMyFilesShareMemberEmailKey(); + + const concanatedPassword = generateConcanatedPassword(urlPassword, customPassword); + const encryptedUrlPassword = await this.driveCrypto.encryptShareUrlPassword( + concanatedPassword, + addressKey, + addressKey, + ); + + return { + token, + encryptedUrlPassword, + addressId, + addressKeyId, + }; + } + async decryptBookmark(encryptedBookmark: EncryptedBookmark): Promise<{ url: Result; customPassword: Result; @@ -567,3 +590,8 @@ function splitGeneratedAndCustomPassword(concatenatedPassword: string): { const customPassword = concatenatedPassword.substring(PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) || undefined; return { password, customPassword }; } + +function generateConcanatedPassword(urlPassword: string, customPassword?: string): string { + const concatenatedPassword = urlPassword.concat(customPassword || '') + return concatenatedPassword +} diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 25a29261..3b36482e 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -148,6 +148,7 @@ export interface SharesService { email: string; addressId: string; addressKey: PrivateKey; + addressKeyId: string; }>; isOwnVolume(volumeId: string): Promise; } diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index 3b35bdb3..ee327cbe 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -424,6 +424,47 @@ describe('SharingAccess', () => { }); }); + describe('createBookmark', () => { + it('should create bookmark with token, url password and custom password', async () => { + const token = 'abc123token'; + const urlPassword = 'generatedPass'; + const customPassword = 'customPass123'; + const encryptedBookmark = { + token, + encryptedUrlPassword: 'encryptedPassword123', + addressId: 'addressId', + addressKeyId: 'keyId', + }; + + cryptoService.encryptBookmark = jest.fn().mockResolvedValue(encryptedBookmark); + apiService.createBookmark = jest.fn().mockResolvedValue(undefined); + + await sharingAccess.createBookmark(token, urlPassword, customPassword); + + expect(cryptoService.encryptBookmark).toHaveBeenCalledWith(token, urlPassword, customPassword); + expect(apiService.createBookmark).toHaveBeenCalledWith(encryptedBookmark); + }); + + it('should create bookmark without custom password', async () => { + const token = 'abc123token'; + const urlPassword = 'generatedPass'; + const encryptedBookmark = { + token, + encryptedUrlPassword: 'encryptedPassword123', + addressId: 'addressId', + addressKeyId: 'keyId', + }; + + cryptoService.encryptBookmark = jest.fn().mockResolvedValue(encryptedBookmark); + apiService.createBookmark = jest.fn().mockResolvedValue(undefined); + + await sharingAccess.createBookmark(token, urlPassword); + + expect(cryptoService.encryptBookmark).toHaveBeenCalledWith(token, urlPassword, undefined); + expect(apiService.createBookmark).toHaveBeenCalledWith(encryptedBookmark); + }); + }); + describe('deleteBookmark', () => { it('should delete bookmark using tokenId', async () => { const bookmarkUid = 'tokenId123'; diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 67c29706..4a3bbe42 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -182,6 +182,11 @@ export class SharingAccess { } } + async createBookmark(token: string, urlPassword: string, customPassword?: string): Promise { + const encryptedBookmark = await this.cryptoService.encryptBookmark(token, urlPassword, customPassword); + await this.apiService.createBookmark(encryptedBookmark); + } + async deleteBookmark(bookmarkUid: string): Promise { const tokenId = bookmarkUid; await this.apiService.deleteBookmark(tokenId); diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index d3659b51..8ada9149 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -19,6 +19,7 @@ import { SharingPublicAPIService } from './apiService'; import { NodesSecurity } from './nodesSecurity'; export { SharingPublicSessionManager } from './session/manager'; +export { getTokenAndPasswordFromUrl } from './session/url'; export { UnauthDriveAPIService } from './unauthApiService'; /** diff --git a/js/sdk/src/internal/sharingPublic/session/manager.ts b/js/sdk/src/internal/sharingPublic/session/manager.ts index fb592e57..3bfed228 100644 --- a/js/sdk/src/internal/sharingPublic/session/manager.ts +++ b/js/sdk/src/internal/sharingPublic/session/manager.ts @@ -5,7 +5,6 @@ import { SharingPublicSessionAPIService } from './apiService'; import { SharingPublicSessionHttpClient } from './httpClient'; import { EncryptedShareCrypto, PublicLinkInfo } from './interface'; import { SharingPublicLinkSession } from './session'; -import { getTokenAndPasswordFromUrl } from './url'; /** * Manages sessions for public links. @@ -42,9 +41,9 @@ export class SharingPublicSessionManager { * the vendor type (whether it is Proton Docs, for example, and should * be redirected to the public Docs app). * - * @param url - The URL of the public link. + * @param token - The public link token. */ - async getInfo(url: string): Promise<{ + async getInfo(token: string): Promise<{ isCustomPasswordProtected: boolean; isLegacy: boolean; vendorType: number; @@ -54,7 +53,6 @@ export class SharingPublicSessionManager { publicRole: MemberRole; }; }> { - const { token } = getTokenAndPasswordFromUrl(url); const info = await this.api.initPublicLinkSession(token); this.infosPerToken.set(token, info); @@ -76,22 +74,20 @@ export class SharingPublicSessionManager { * It returnes parsed token and full password (password from the URL + * custom password) that can be used for decrypting the share key. * - * @param url - The URL of the public link. + * @param token - The public link token. * @param customPassword - The custom password for the public link, if it is * custom password protected. */ async auth( - url: string, + token: string, + urlPassword: string, customPassword?: string, ): Promise<{ - token: string; httpClient: SharingPublicSessionHttpClient; shareKey: PrivateKey; rootUid: string; publicRole: MemberRole; }> { - const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url); - let info = this.infosPerToken.get(token); if (!info) { info = await this.api.initPublicLinkSession(token); @@ -105,7 +101,6 @@ export class SharingPublicSessionManager { const shareKey = await this.decryptShareKey(encryptedShare, password); return { - token, httpClient: new SharingPublicSessionHttpClient(this.httpClient, session), shareKey, rootUid, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index d83aa36b..5f7c24a8 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -49,7 +49,7 @@ import { initNodesModule } from './internal/nodes'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; import { initSharingModule } from './internal/sharing'; -import { SharingPublicSessionManager } from './internal/sharingPublic'; +import { SharingPublicSessionManager, getTokenAndPasswordFromUrl } from './internal/sharingPublic'; import { initUploadModule } from './internal/upload'; import { makeNodeUid } from './internal/uids'; import { ProtonDrivePublicLinkClient } from './protonDrivePublicLinkClient'; @@ -223,13 +223,17 @@ export class ProtonDriveClient { return keys.contentKeyPacketSessionKey; }, getPublicLinkInfo: async (url: string) => { - this.logger.info(`Getting info for public link ${url}`); - return this.publicSessionManager.getInfo(url); + const { token } = getTokenAndPasswordFromUrl(url) + this.logger.info(`Getting info for public link token ${token}`); + return this.publicSessionManager.getInfo(token); }, authPublicLink: async (url: string, customPassword?: string, isAnonymousContext: boolean = false) => { - this.logger.info(`Authenticating public link ${url}`); - const { httpClient, token, shareKey, rootUid, publicRole } = await this.publicSessionManager.auth( - url, + const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url) + this.logger.info(`Authenticating public link token ${token}`); + + const { httpClient, shareKey, rootUid, publicRole } = await this.publicSessionManager.auth( + token, + urlPassword, customPassword, ); return new ProtonDrivePublicLinkClient({ @@ -670,6 +674,18 @@ export class ProtonDriveClient { yield* this.sharing.access.iterateBookmarks(signal); } + /** + * Create a shared bookmark for a public link. + * + * @param url - The public link url. + * @param customPassword - The optional custom password. + */ + async createBookmark(url: string, customPassword?: string): Promise { + const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url) + this.logger.info(`Creating bookmark for token ${token}`); + await this.sharing.access.createBookmark(token, urlPassword, customPassword); + } + /** * Remove the shared bookmark. * From 8d14d7d6b0ff73d4e5572eb07c19bf56a1153c69 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 30 Jan 2026 08:56:18 +0100 Subject: [PATCH 485/791] Follow up on download pausing to address issues with hanging, seeking with interop and telemetry --- cs/.globalconfig | 3 + .../InteropFileDownloader.cs | 5 +- .../InteropFileUploader.cs | 4 +- .../InteropPhotosDownloader.cs | 4 +- .../InteropPhotosUploader.cs | 2 +- .../InteropProtonDriveClient.cs | 2 +- .../InteropProtonPhotosClient.cs | 2 +- .../Nodes/Download/DownloadController.cs | 28 ++- .../Nodes/Download/FileDownloader.cs | 18 +- .../Nodes/Download/RevisionReader.cs | 171 +++++++-------- .../Nodes/Upload/FileUploader.cs | 16 +- .../Nodes/Upload/UploadController.cs | 19 +- .../Nodes/Download/PhotosFileDownloader.cs | 17 +- .../InteropActionExtensions.cs | 36 ++++ .../InteropHttpClientFactory.cs | 8 +- .../InteropMessageHandler.cs | 10 +- .../src/Proton.Sdk.CExports/InteropStream.cs | 84 ++++++-- .../Caching/EncryptedCacheRepository.cs | 2 +- cs/sdk/src/protos/proton.drive.sdk.proto | 4 + cs/sdk/src/protos/proton.sdk.proto | 8 +- kt/sdk/src/main/jni/proton_drive_sdk.c | 23 +- .../me/proton/drive/sdk/FileDownloader.kt | 7 + .../sdk/extension/SeekableByteChannel.kt | 13 ++ .../drive/sdk/internal/JniFileDownloader.kt | 6 + .../drive/sdk/internal/JniFileUploader.kt | 1 + .../drive/sdk/internal/JniPhotosDownloader.kt | 1 + .../internal/ProtonDriveSdkNativeClient.kt | 45 ++-- .../ProtonDriveClient/ProtonDriveClient.swift | 22 ++ .../Downloads/DownloadOperation.swift | 52 ++++- .../Downloads/DownloadsManager.swift | 74 ++++++- .../FileOperations/SeekableOutputStream.swift | 105 ++++++++++ .../Sources/Plumbing/Message+Packaging.swift | 5 + .../Sources/Plumbing/SDKResponseHandler.swift | 26 ++- .../Plumbing/StreamCallbackWrapper.swift | 196 ++++++++++++++++++ 34 files changed, 821 insertions(+), 198 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SeekableByteChannel.kt create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/SeekableOutputStream.swift create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift diff --git a/cs/.globalconfig b/cs/.globalconfig index 4034a275..263b6c49 100644 --- a/cs/.globalconfig +++ b/cs/.globalconfig @@ -92,6 +92,9 @@ dotnet_diagnostic.RCS1037.severity = none # Redundant with SA1028 # RCS1047: Non-asynchronous method name should not end with 'Async' dotnet_diagnostic.RCS1047.severity = warning +# RCS1118: Mark local variable as const +dotnet_diagnostic.RCS1118.severity = warning + # RCS1090: Add parameter to exception constructor dotnet_diagnostic.RCS1194.severity = none # Redundant with CA1032 diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index 26aaadae..edc77d0c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -13,7 +13,10 @@ public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, n var downloader = Interop.GetFromHandle(request.DownloaderHandle); - var stream = new InteropStream(bindingsHandle, new InteropAction, nint>(request.WriteAction)); + var writeFunction = new InteropFunction, nint, nint>(request.WriteAction); + var seekAction = request.SeekAction != 0 ? new InteropAction, nint>(request.SeekAction) : (InteropAction, nint>?)null; + var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null; + var stream = new InteropStream(bindingsHandle, writeFunction, seekAction, cancelAction); var progressAction = new InteropAction>(request.ProgressAction); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index a17496e6..80ff6d09 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -13,7 +13,9 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var uploader = Interop.GetFromHandle(request.UploaderHandle); - var stream = new InteropStream(uploader.FileSize, bindingsHandle, new InteropAction, nint>(request.ReadAction)); + var readFunction = new InteropFunction, nint, nint>(request.ReadAction); + var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null; + var stream = new InteropStream(uploader.FileSize, bindingsHandle, readFunction, cancelAction); var thumbnails = request.Thumbnails.Select(t => { diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs index 944bec51..9e8137f5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -14,7 +14,9 @@ public static IMessage HandleDownloadToStream(DrivePhotosClientDownloadToStreamR var downloader = Interop.GetFromHandle(request.DownloaderHandle); - var stream = new InteropStream(bindingsHandle, new InteropAction, nint>(request.WriteAction)); + var writeFunction = new InteropFunction, nint, nint>(request.WriteAction); + var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null; + var stream = new InteropStream(bindingsHandle, writeFunction, cancelAction: cancelAction); var progressAction = new InteropAction>(request.ProgressAction); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs index 27e9d557..3a6b2d2f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs @@ -13,7 +13,7 @@ public static IMessage HandleUploadFromStream(DrivePhotosClientUploadFromStreamR var uploader = Interop.GetFromHandle(request.UploaderHandle); - var stream = new InteropStream(uploader.FileSize, bindingsHandle, new InteropAction, nint>(request.ReadAction)); + var stream = new InteropStream(uploader.FileSize, bindingsHandle, new InteropFunction, nint, nint>(request.ReadAction)); var thumbnails = request.Thumbnails.Select(t => { diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 6623d5b4..fa98fb40 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -29,7 +29,7 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi request.BaseUrl, protonDriveClientOptions.BindingsLanguage, new InteropFunction, nint, nint>(request.HttpClient.RequestFunction), - new InteropAction, nint>(request.HttpClient.ResponseContentReadAction), + new InteropFunction, nint, nint>(request.HttpClient.ResponseContentReadAction), new InteropAction(request.HttpClient.CancellationAction)); var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction)); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index ef02b16a..5f92974e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -31,7 +31,7 @@ public static IMessage HandleCreate(DrivePhotosClientCreateRequest request, nint request.BaseUrl, protonDriveClientOptions.BindingsLanguage, new InteropFunction, nint, nint>(request.HttpClient.RequestFunction), - new InteropAction, nint>(request.HttpClient.ResponseContentReadAction), + new InteropFunction, nint, nint>(request.HttpClient.ResponseContentReadAction), new InteropAction(request.HttpClient.CancellationAction)); var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction)); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index da037963..d324b6a9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -9,6 +9,8 @@ public sealed class DownloadController : IAsyncDisposable private readonly Func _resumeFunction; private readonly ITaskControl _taskControl; private readonly Stream? _outputStreamToDispose; + private readonly Action? _onFailed; + private readonly Action? _onSucceeded; private bool _isDownloadCompleteWithVerificationIssue; @@ -17,19 +19,20 @@ internal DownloadController( Task downloadTask, Func resumeFunction, Stream? outputStreamToDispose, - ITaskControl taskControl) + ITaskControl taskControl, + Action? onFailed = null, + Action? onSucceeded = null) { _downloadStateTask = downloadStateTask; _resumeFunction = resumeFunction; _taskControl = taskControl; _outputStreamToDispose = outputStreamToDispose; + _onFailed = onFailed; + _onSucceeded = onSucceeded; Completion = PauseOnResumableErrorAsync(downloadTask); } - internal event Action? DownloadFailed; - internal event Action? DownloadSucceeded; - public bool IsPaused => _taskControl.IsPaused; public Task Completion { get; private set; } @@ -67,7 +70,7 @@ public async ValueTask DisposeAsync() if (Completion.IsFaulted) { - DownloadFailed?.Invoke(Completion.Exception.Flatten().InnerException ?? Completion.Exception); + _onFailed?.Invoke(Completion.Exception.Flatten().InnerException ?? Completion.Exception); } var stateExists = _downloadStateTask.IsCompletedSuccessfully; @@ -105,7 +108,7 @@ private async Task PauseOnResumableErrorAsync(Task downloadTask) { await downloadTask.ConfigureAwait(false); - await RaiseDownloadSucceededAsync().ConfigureAwait(false); + await FinalizeDownloadAsync().ConfigureAwait(false); } catch (CompletedDownloadManifestVerificationException error) { @@ -119,15 +122,22 @@ private async Task PauseOnResumableErrorAsync(Task downloadTask) } } - private async ValueTask RaiseDownloadSucceededAsync() + private async ValueTask FinalizeDownloadAsync() { - var onSucceededHandler = DownloadSucceeded; + var onSucceededHandler = _onSucceeded; if (onSucceededHandler is null) { return; } + if (_outputStreamToDispose is not null) + { + await _outputStreamToDispose.FlushAsync().ConfigureAwait(false); + } + var downloadState = await _downloadStateTask.ConfigureAwait(false); - onSucceededHandler.Invoke(downloadState.GetNumberOfBytesWritten()); + onSucceededHandler.Invoke( + downloadState.RevisionDto.Size, + downloadState.GetNumberOfBytesWritten()); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 816895f9..6e3588ff 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -119,29 +119,31 @@ private DownloadController BuildDownloadController( downloadStateTaskCompletionSource, ct); - var downloadController = new DownloadController( + return new DownloadController( downloadStateTaskCompletionSource.Task, downloadFunction.Invoke(taskControl.PauseOrCancellationToken), downloadFunction, ownsOutputStream ? contentOutputStream : null, - taskControl); + taskControl, + OnFailed, + OnSucceeded); - downloadController.DownloadFailed += ex => + void OnFailed(Exception ex) { downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); downloadEvent.OriginalError = ex.GetBaseException().ToString(); RaiseTelemetryEvent(downloadEvent); - }; + } - downloadController.DownloadSucceeded += downloadedByteCount => + void OnSucceeded(long claimedFileSize, long downloadedByteCount) { // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.DownloadedSize = downloadedByteCount; downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); - RaiseTelemetryEvent(downloadEvent); - }; + downloadEvent.ClaimedFileSize = claimedFileSize; - return downloadController; + RaiseTelemetryEvent(downloadEvent); + } } private void RaiseTelemetryEvent(DownloadEvent downloadEvent) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 9c117f3d..09357849 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; -using Proton.Drive.Sdk.Telemetry; namespace Proton.Drive.Sdk.Nodes.Download; @@ -37,126 +36,93 @@ internal RevisionReader( public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { - var downloadEvent = new DownloadEvent - { - ClaimedFileSize = -1, - VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type - }; + var downloadTasks = new Queue>(_client.BlockDownloader.Queue.Depth); + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - try + await using (manifestStream) { - var downloadTasks = new Queue>(_client.BlockDownloader.Queue.Depth); - var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + var downloadedBlockDigests = _state.GetDownloadedBlockDigests(); + var revisionDto = _state.RevisionDto; - await using (manifestStream) + if (revisionDto.Thumbnails is { } thumbnails) { - var downloadedBlockDigests = _state.GetDownloadedBlockDigests(); - var revisionDto = _state.RevisionDto; - - // Write thumbnail digests to manifest (if any and on first call) - if (revisionDto.Thumbnails is { } thumbnails && downloadedBlockDigests.Count == 0) + foreach (var sha256Digest in thumbnails.OrderBy(t => t.Type).Select(x => x.HashDigest)) { - foreach (var sha256Digest in thumbnails.OrderBy(t => t.Type).Select(x => x.HashDigest)) - { - manifestStream.Write(sha256Digest.Span); - } + manifestStream.Write(sha256Digest.Span); } + } - // Write already-downloaded block digests to manifest (for resumed downloads) - foreach (var digest in downloadedBlockDigests) - { - manifestStream.Write(digest.Span); - } + foreach (var digest in downloadedBlockDigests) + { + manifestStream.Write(digest.Span); + } + try + { try { - try - { - var startBlockIndex = _state.GetNextBlockIndexToDownload(); + var startBlockIndex = _state.GetNextBlockIndexToDownload(); - await foreach (var (block, _) in GetBlocksAsync(startBlockIndex, cancellationToken).ConfigureAwait(false)) + await foreach (var (block, _) in GetBlocksAsync(startBlockIndex, cancellationToken).ConfigureAwait(false)) + { + if (!_client.BlockDownloader.Queue.TryStartBlock()) { - if (!_client.BlockDownloader.Queue.TryStartBlock()) + if (downloadTasks.Count > 0) { - if (downloadTasks.Count > 0) - { - await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) - .ConfigureAwait(false); - } - - await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); } - var downloadTask = DownloadBlockAsync(block, cancellationToken); - - downloadTasks.Enqueue(downloadTask); + await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } - } - finally - { - _releaseFileSemaphoreAction.Invoke(); - _fileSemaphoreReleased = true; - } - while (downloadTasks.Count > 0) - { - await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) - .ConfigureAwait(false); + var downloadTask = DownloadBlockAsync(block, cancellationToken); + + downloadTasks.Enqueue(downloadTask); } } - catch when (downloadTasks.Count > 0) + finally { - try - { - await Task.WhenAll(downloadTasks).ConfigureAwait(false); - } - catch - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw - } - finally - { - _client.BlockDownloader.Queue.FinishBlocks(downloadTasks.Count); - } - - throw; + _releaseFileSemaphoreAction.Invoke(); + _fileSemaphoreReleased = true; } - manifestStream.Seek(0, SeekOrigin.Begin); - - var manifestVerificationStatus = await VerifyManifestAsync(manifestStream, cancellationToken).ConfigureAwait(false); - - if (manifestVerificationStatus is not PgpVerificationStatus.Ok) + while (downloadTasks.Count > 0) { - LogFailedManifestVerification(_state.Uid, manifestVerificationStatus); - - throw new CompletedDownloadManifestVerificationException("File authenticity check failed"); + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); } - - _state.SetIsCompleted(); } - } - catch (Exception ex) when (!cancellationToken.IsCancellationRequested && TelemetryErrorResolver.GetDownloadErrorFromException(ex) is { } error) - { - downloadEvent.Error = error; - downloadEvent.OriginalError = ex.FlattenMessageWithExceptionType(); - throw; - } - finally - { - if (!cancellationToken.IsCancellationRequested) + catch when (downloadTasks.Count > 0) { try { - downloadEvent.ClaimedFileSize = contentOutputStream.Length; // FIXME: try to report actual claimed size from metadata - downloadEvent.DownloadedSize = contentOutputStream.Length; - _client.Telemetry.RecordMetric(downloadEvent); + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw } - catch (Exception ex) + finally { - _logger.LogWarning(ex, "Failed to record metric for download event"); + _client.BlockDownloader.Queue.FinishBlocks(downloadTasks.Count); } + + throw; + } + + manifestStream.Seek(0, SeekOrigin.Begin); + + var manifestVerificationStatus = await VerifyManifestAsync(manifestStream, cancellationToken).ConfigureAwait(false); + + if (manifestVerificationStatus is not PgpVerificationStatus.Ok) + { + LogFailedManifestVerification(_state.Uid, manifestVerificationStatus); + + throw new CompletedDownloadManifestVerificationException("File authenticity check failed"); } + + _state.SetIsCompleted(); } } @@ -179,27 +145,32 @@ private async Task WriteNextBlockToOutputAsync( try { - var downloadResult = await downloadTask.ConfigureAwait(false); - - var downloadedStream = downloadResult.Stream; + var (plaintextStream, blockDigest) = await downloadTask.ConfigureAwait(false); try { - _state.AddDownloadedBlockDigest(downloadResult.Sha256Digest); - - manifestStream.Write(downloadResult.Sha256Digest.Span); + plaintextStream.Seek(0, SeekOrigin.Begin); + var initialOutputPosition = outputStream.Position; - downloadedStream.Seek(0, SeekOrigin.Begin); - - await downloadedStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + try + { + await plaintextStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + } + catch + { + outputStream.Seek(initialOutputPosition, SeekOrigin.Begin); + throw; + } - _state.AddNumberOfBytesWritten(downloadedStream.Length); + _state.AddNumberOfBytesWritten(plaintextStream.Length); + _state.AddDownloadedBlockDigest(blockDigest); + manifestStream.Write(blockDigest.Span); onProgress(_state.GetNumberOfBytesWritten(), _state.RevisionDto.Size); } finally { - await downloadedStream.DisposeAsync().ConfigureAwait(false); + await plaintextStream.DisposeAsync().ConfigureAwait(false); } } finally diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index ca1627cb..1530d306 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -121,14 +121,16 @@ private UploadController UploadFromStream( revisionDraftTaskCompletionSource, ct); - var uploadController = new UploadController( + return new UploadController( revisionDraftTaskCompletionSource.Task, uploadFunction.Invoke(taskControl.PauseOrCancellationToken), uploadFunction, ownsContentStream ? contentStream : null, - taskControl); + taskControl, + OnFailed, + OnSucceeded); - uploadController.UploadFailed += ex => + void OnFailed(Exception ex) { if (ex is NodeWithSameNameExistsException) { @@ -138,17 +140,15 @@ private UploadController UploadFromStream( uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); uploadEvent.OriginalError = ex.GetBaseException().ToString(); RaiseTelemetryEvent(uploadEvent); - }; + } - uploadController.UploadSucceeded += uploadedByteCount => + void OnSucceeded(long uploadedByteCount) { // TODO: deprecate UploadedSize in favor of ApproximateUploadedSize uploadEvent.UploadedSize = uploadedByteCount; uploadEvent.ApproximateUploadedSize = Privacy.ReduceSizePrecision(uploadedByteCount); RaiseTelemetryEvent(uploadEvent); - }; - - return uploadController; + } } private async Task UploadFromStreamAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index befe9703..ae7ee90f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -9,6 +9,8 @@ public sealed class UploadController : IAsyncDisposable private readonly Func> _resumeFunction; private readonly ITaskControl _taskControl; private readonly Stream? _sourceStreamToDispose; + private readonly Action? _onFailed; + private readonly Action? _onSucceeded; private bool _isDisposed; @@ -17,19 +19,20 @@ internal UploadController( Task uploadTask, Func> resumeFunction, Stream? sourceStreamToDispose, - ITaskControl taskControl) + ITaskControl taskControl, + Action? onFailed = null, + Action? onSucceeded = null) { _revisionDraftTask = revisionDraftTask; _resumeFunction = resumeFunction; _taskControl = taskControl; _sourceStreamToDispose = sourceStreamToDispose; + _onFailed = onFailed; + _onSucceeded = onSucceeded; Completion = PauseOnResumableErrorAsync(uploadTask); } - internal event Action? UploadFailed; - internal event Action? UploadSucceeded; - public bool IsPaused => _taskControl.IsPaused; public Task Completion { get; private set; } @@ -69,7 +72,7 @@ public async ValueTask DisposeAsync() if (Completion.IsFaulted) { - UploadFailed?.Invoke(Completion.Exception.Flatten().InnerException ?? Completion.Exception); + _onFailed?.Invoke(Completion.Exception.Flatten().InnerException ?? Completion.Exception); } var draftExists = _revisionDraftTask.IsCompletedSuccessfully; @@ -109,7 +112,7 @@ private async Task PauseOnResumableErrorAsync(Task u { var result = await uploadTask.ConfigureAwait(false); - await RaiseUploadSucceededAsync().ConfigureAwait(false); + await InvokeOnSucceededAsync().ConfigureAwait(false); return result; } @@ -120,9 +123,9 @@ private async Task PauseOnResumableErrorAsync(Task u } } - private async ValueTask RaiseUploadSucceededAsync() + private async ValueTask InvokeOnSucceededAsync() { - var onSucceededHandler = UploadSucceeded; + var onSucceededHandler = _onSucceeded; if (onSucceededHandler is null) { return; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs index 589cefc7..1f6f0fb0 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -116,29 +116,30 @@ private DownloadController DownloadToStream( downloadStateTaskCompletionSource, ct); - var downloadController = new DownloadController( + return new DownloadController( downloadStateTaskCompletionSource.Task, downloadFunction.Invoke(taskControl.PauseOrCancellationToken), downloadFunction, ownsOutputStream ? contentOutputStream : null, - taskControl); + taskControl, + OnFailed, + OnSucceeded); - downloadController.DownloadFailed += ex => + void OnFailed(Exception ex) { downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); downloadEvent.OriginalError = ex.GetBaseException().ToString(); RaiseTelemetryEvent(downloadEvent); - }; + } - downloadController.DownloadSucceeded += downloadedByteCount => + void OnSucceeded(long claimedFileSize, long downloadedByteCount) { // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.DownloadedSize = downloadedByteCount; downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); + downloadEvent.ClaimedFileSize = claimedFileSize; RaiseTelemetryEvent(downloadEvent); - }; - - return downloadController; + } } private void RaiseTelemetryEvent(DownloadEvent downloadEvent) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs index 08bf9803..3bc18faf 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs @@ -95,4 +95,40 @@ public static unsafe ValueTask InvokeWithBufferAsync( return tcs.Task; } + + public static unsafe (ValueTask Task, nint OperationHandle) InvokeWithBuffer( + this InteropFunction, nint, nint> interopFunction, + nint bindingsHandle, + Span buffer) + { + var tcs = new ValueTaskCompletionSource(); + + var tcsHandle = Interop.AllocHandle(tcs); + + nint operationHandle; + fixed (byte* requestBytesPointer = buffer) + { + operationHandle = interopFunction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); + } + + return (tcs.Task, operationHandle); + } + + public static unsafe (ValueTask Task, nint OperationHandle) InvokeWithBuffer( + this InteropFunction, nint, nint> interopFunction, + nint bindingsHandle, + ReadOnlySpan buffer) + { + var tcs = new ValueTaskCompletionSource(); + + var tcsHandle = Interop.AllocHandle(tcs); + + nint operationHandle; + fixed (byte* requestBytesPointer = buffer) + { + operationHandle = interopFunction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, buffer.Length), (nint)tcsHandle); + } + + return (tcs.Task, operationHandle); + } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs index 58dd23ac..a9242d64 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -13,19 +13,19 @@ public InteropHttpClientFactory( string baseUrl, string? bindingsLanguage, InteropFunction, nint, nint> requestFunction, - InteropAction, nint> responseContentReadAction, + InteropFunction, nint, nint> responseContentReadFunction, InteropAction cancellationAction) { _baseUrl = baseUrl; BindingsHandle = bindingsHandle; RequestFunction = requestFunction; - ResponseContentReadAction = responseContentReadAction; + ResponseContentReadFunction = responseContentReadFunction; CancellationAction = cancellationAction; } private nint BindingsHandle { get; } private InteropFunction, nint, nint> RequestFunction { get; } - private InteropAction, nint> ResponseContentReadAction { get; } + private InteropFunction, nint, nint> ResponseContentReadFunction { get; } private InteropAction CancellationAction { get; } public HttpClient CreateClient(string name) @@ -107,7 +107,7 @@ private HttpResponseMessage ConvertHttpResponseFromInterop(HttpResponse interopH if (interopHttpResponse.HasBindingsContentHandle) { response.Content = new StreamContent( - new InteropStream(null, (nint)interopHttpResponse.BindingsContentHandle, _owner.ResponseContentReadAction)); + new InteropStream(null, (nint)interopHttpResponse.BindingsContentHandle, _owner.ResponseContentReadFunction)); } foreach (var interopHttpResponseHeader in interopHttpResponse.Headers) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs index c36f0c87..aa31cc2b 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -11,6 +11,7 @@ internal static class InteropMessageHandler { private static readonly TypeRegistry ResponseTypeRegistry = TypeRegistry.FromMessages( Int32Value.Descriptor, + Int64Value.Descriptor, StringValue.Descriptor, BytesValue.Descriptor, RepeatedBytesValue.Descriptor, @@ -65,7 +66,14 @@ Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; - responseAction.InvokeWithMessage(bindingsHandle, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); + var responseMessage = response switch + { + null => new Response(), + Empty => throw new InvalidOperationException("Use null instead of Empty"), + _ => new Response { Value = Any.Pack(response) }, + }; + + responseAction.InvokeWithMessage(bindingsHandle, responseMessage); } catch (Exception e) { diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs index 4dbf32cf..2e4b1308 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -7,32 +7,46 @@ namespace Proton.Sdk.CExports; internal sealed class InteropStream : Stream { private readonly nint _bindingsHandle; - private readonly InteropAction, nint>? _readAction; - private readonly InteropAction, nint>? _writeAction; + private readonly InteropFunction, nint, nint>? _readFunction; + private readonly InteropFunction, nint, nint>? _writeFunction; + private readonly InteropAction, nint>? _seekAction; + private readonly InteropAction? _cancelAction; private long _position; private long? _length; + private nint _operationHandle; - public InteropStream(long? length, nint bindingsHandle, InteropAction, nint>? readAction) + public InteropStream( + long? length, + nint bindingsHandle, + InteropFunction, nint, nint>? readFunction, + InteropAction? cancelAction = null) { _length = length; _bindingsHandle = bindingsHandle; - _readAction = readAction; - _writeAction = null; + _readFunction = readFunction; + _writeFunction = null; + _cancelAction = cancelAction; } - public InteropStream(nint bindingsHandle, InteropAction, nint>? writeAction) + public InteropStream( + nint bindingsHandle, + InteropFunction, nint, nint>? writeFunction, + InteropAction, nint>? seekAction = null, + InteropAction? cancelAction = null) { _length = 0; _bindingsHandle = bindingsHandle; - _readAction = null; - _writeAction = writeAction; + _readFunction = null; + _writeFunction = writeFunction; + _seekAction = seekAction; + _cancelAction = cancelAction; } - public override bool CanRead => _readAction != null; + public override bool CanRead => _readFunction != null; - public override bool CanSeek => _length is not null; - public override bool CanWrite => _writeAction != null; + public override bool CanSeek => _seekAction is not null; + public override bool CanWrite => _writeFunction != null; public override long Length => _length ?? throw new NotSupportedException("Getting length is not supported"); public override long Position @@ -68,28 +82,52 @@ public override Task ReadAsync(byte[] buffer, int offset, int count, Cancel public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - if (_readAction is null) + if (_readFunction is null) { throw new NotSupportedException("Reading not supported"); } using var memoryHandle = buffer.Pin(); - var response = await _readAction.Value.InvokeWithBufferAsync(_bindingsHandle, buffer.Span).ConfigureAwait(false); + var (readTask, operationHandle) = _readFunction.Value.InvokeWithBuffer(_bindingsHandle, buffer.Span); + _operationHandle = operationHandle; - if (response.Value < 0) + Int32Value readByteCount; + + await using (cancellationToken.Register(() => _cancelAction?.Invoke(_operationHandle))) + { + readByteCount = await readTask.AsTask().ConfigureAwait(false); + } + + if (readByteCount.Value < 0) { - throw new IOException($"Invalid number of bytes read: {response.Value}"); + throw new IOException($"Invalid number of bytes read: {readByteCount.Value}"); } - _position += response.Value; + _position += readByteCount.Value; - return response.Value; + return readByteCount.Value; } public override long Seek(long offset, SeekOrigin origin) { - throw new NotSupportedException("Seeking not supported"); + if (_seekAction is null) + { + throw new NotSupportedException("Seeking not supported"); + } + + var request = new StreamSeekRequest + { + Offset = offset, + Origin = (int)origin, + }; + + var requestBytes = request.ToByteArray(); + var newPosition = _seekAction.Value.InvokeWithBufferAsync(_bindingsHandle, requestBytes).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + + _position = newPosition.Value; + + return _position; } public override void SetLength(long value) @@ -109,14 +147,20 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - if (_writeAction == null) + if (_writeFunction == null) { throw new NotSupportedException("Writing not supported"); } using var memoryHandle = buffer.Pin(); - await _writeAction.Value.InvokeWithBufferAsync(_bindingsHandle, buffer.Span).ConfigureAwait(false); + var (writeTask, operationHandle) = _writeFunction.Value.InvokeWithBuffer(_bindingsHandle, buffer.Span); + _operationHandle = operationHandle; + + await using (cancellationToken.Register(() => _cancelAction?.Invoke(_operationHandle))) + { + await writeTask.AsTask().ConfigureAwait(false); + } _position += buffer.Length; _length = Math.Max(_length ?? 0, _position); diff --git a/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs index e7327aa2..865a0220 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs @@ -67,7 +67,7 @@ public ValueTask ClearAsync() try { - decryptedValue = Decrypt(key, encryptedValue); + decryptedValue = Decrypt(key, encryptedValue); } catch (AuthenticationTagMismatchException) { diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 5e2260f7..e357e931 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -266,6 +266,7 @@ message UploadFromStreamRequest { int64 read_action = 3; // C signature: void handle_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; + int64 cancel_action = 6; // Optional, C signature: void on_cancel(intptr_t bindings_handle); Signals the bindings to cancel the current stream operation. } // The response value must be an Int64Value carrying a handle to an instance of UploadController. @@ -377,6 +378,8 @@ message DownloadToStreamRequest { int64 write_action = 2; // C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 3; // See array_action in C header file for signature int64 cancellation_token_source_handle = 4; + int64 seek_action = 5; // Optional, C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); + int64 cancel_action = 6; // Optional, C signature: void on_cancel(intptr_t bindings_handle); Signals the bindings to cancel the current stream operation. } // The response value must be an Int64Value carrying a handle to an instance of DownloadController. @@ -507,6 +510,7 @@ message DrivePhotosClientDownloadToStreamRequest { int64 write_action = 2; // C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 3; // See array_action in C header file for signature int64 cancellation_token_source_handle = 4; + int64 cancel_action = 5; // Optional, C signature: void on_cancel(intptr_t bindings_handle); Signals the bindings to cancel the current stream operation. } // The response value must be an Int64Value carrying a handle to an instance of DownloadController. diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto index f668da85..56d13642 100644 --- a/cs/sdk/src/protos/proton.sdk.proto +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -223,6 +223,12 @@ message StreamReadRequest { int32 buffer_length = 3; } +// The response value must be an Int64Value carrying the new position in the stream. +message StreamSeekRequest { + int64 offset = 1; + int32 origin = 2; // 0 = Begin, 1 = Current, 2 = End (matches SeekOrigin enum) +} + enum HttpRequestType { HTTP_REQUEST_TYPE_REGULAR_API = 0; HTTP_REQUEST_TYPE_STORAGE_DOWNLOAD = 1; @@ -248,7 +254,7 @@ message HttpResponse { repeated HttpHeader headers = 2; int64 bindings_content_handle = 3; // Optional } - + message ApiRetrySucceededEventPayload { string url = 1; int32 failed_attempts = 2; diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index d32bedfa..480e1d68 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -47,20 +47,28 @@ void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleResponse (*env)->ReleaseByteArrayElements(env, response, bufferElems, 0); } -void onRead( +long onRead( intptr_t bindings_handle, ByteArray value, intptr_t sdk_handle ) { - pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onRead"); + return pushDataAndLongToLongMethod(bindings_handle, value, sdk_handle, "onRead"); } -void onWrite( +long onWrite( intptr_t bindings_handle, ByteArray value, intptr_t sdk_handle ) { - pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onWrite"); + return pushDataAndLongToLongMethod(bindings_handle, value, sdk_handle, "onWrite"); +} + +void onSeek( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onSeek"); } void onProgress(intptr_t bindings_handle, ByteArray value) { @@ -119,6 +127,13 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getWritePoint return (jlong) (intptr_t) &onWrite; } +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSeekPointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onSeek; +} + jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getProgressPointer( JNIEnv *env, jclass clazz diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index 81717ba0..c290300a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -4,11 +4,13 @@ import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.extension.seek import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniFileDownloader import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId +import java.nio.channels.SeekableByteChannel import java.nio.channels.WritableByteChannel import java.util.concurrent.atomic.AtomicReference @@ -30,6 +32,11 @@ class FileDownloader internal constructor( handle = handle, cancellationTokenSourceHandle = cancellationTokenSource.handle, onWrite = channel::write, + onSeek = if (channel is SeekableByteChannel) { + channel::seek + } else { + null + }, onProgress = { progressUpdate -> with(progressUpdate) { bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SeekableByteChannel.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SeekableByteChannel.kt new file mode 100644 index 00000000..9c80f380 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SeekableByteChannel.kt @@ -0,0 +1,13 @@ +package me.proton.drive.sdk.extension + +import java.nio.channels.SeekableByteChannel + +fun SeekableByteChannel.seek(offset: Long, origin: Int): Long { + val newPosition = when (origin) { + 0 -> offset + 1 -> position() + offset + 2 -> size() + offset + else -> throw IllegalArgumentException("Unknown seek origin: $origin") + } + return position(newPosition).position() +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt index 0c1f9e34..c39cb552 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt @@ -27,6 +27,7 @@ class JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { handle: Long, cancellationTokenSourceHandle: Long, onWrite: suspend (ByteBuffer) -> Unit, + onSeek: ((Long, Int) -> Long)? = null, onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, coroutineScopeProvider: CoroutineScopeProvider, ): Long = executePersistent( @@ -35,6 +36,7 @@ class JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { name = method("downloadToStream"), response = continuation.toLongResponse().asClientResponseCallback(), write = onWrite, + seek = onSeek, progress = onProgress, logger = internalLogger, coroutineScopeProvider = coroutineScopeProvider, @@ -47,6 +49,10 @@ class JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { this.cancellationTokenSourceHandle = cancellationTokenSourceHandle writeAction = ProtonDriveSdkNativeClient.getWritePointer() progressAction = ProtonDriveSdkNativeClient.getProgressPointer() + cancelAction = JniJob.getCancelPointer() + if (onSeek != null) { + seekAction = ProtonDriveSdkNativeClient.getSeekPointer() + } } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt index c421531c..2064c808 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt @@ -60,6 +60,7 @@ class JniFileUploader internal constructor() : JniBaseProtonDriveSdk() { this.cancellationTokenSourceHandle = cancellationTokenSourceHandle readAction = ProtonDriveSdkNativeClient.getReadPointer() progressAction = ProtonDriveSdkNativeClient.getProgressPointer() + cancelAction = JniJob.getCancelPointer() thumbnails.forEach { (type, data) -> this.thumbnails.add(thumbnail { this.type = when (type) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt index e35f5a35..1d28efc2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt @@ -46,6 +46,7 @@ class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { this.cancellationTokenSourceHandle = cancellationTokenSourceHandle writeAction = ProtonDriveSdkNativeClient.getWritePointer() progressAction = ProtonDriveSdkNativeClient.getProgressPointer() + cancelAction = JniJob.getCancelPointer() } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 1650b263..17fdd0fd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any +import com.google.protobuf.Int64Value import com.google.protobuf.Int32Value import com.google.protobuf.StringValue import kotlinx.coroutines.CancellationException @@ -29,6 +30,7 @@ class ProtonDriveSdkNativeClient internal constructor( val response: ClientResponseCallback = { _, _ -> error("response not configured for $name") }, val read: suspend (ByteBuffer) -> Int = { error("read not configured for $name") }, val write: suspend (ByteBuffer) -> Unit = { error("write not configured for $name") }, + val seek: (suspend (Long, Int) -> Long)? = null, val httpClientRequest: suspend (ProtonSdk.HttpRequest) -> HttpResponse = { error("httpClientRequest not configured for $name") }, val readHttpBody: suspend (ByteBuffer) -> Int = { error("readHttpBody not configured for $name") }, val accountRequest: suspend (ProtonDriveSdk.AccountRequest) -> Any = { error("accountRequest not configured for $name") }, @@ -91,21 +93,33 @@ class ProtonDriveSdkNativeClient internal constructor( ) @Suppress("unused") // Called by JNI - fun onRead(buffer: ByteBuffer, sdkHandle: Long) { - onOperation("read", sdkHandle) { - logger(VERBOSE, "read for $name of size: ${buffer.capacity()}") - val bytesRead = read(buffer).takeUnless { it < 0 } ?: 0 - logger(VERBOSE, "$bytesRead bytes read for $name") - response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } - } - } + fun onRead(buffer: ByteBuffer, sdkHandle: Long): Long = onOperation("read", sdkHandle) { + logger(VERBOSE, "read for $name of size: ${buffer.capacity()}") + val bytesRead = read(buffer).takeUnless { it < 0 } ?: 0 + logger(VERBOSE, "$bytesRead bytes read for $name") + response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } + }?.createWeakRef() ?: 0 + + @Suppress("unused") // Called by JNI + fun onWrite(data: ByteBuffer, sdkHandle: Long): Long = onOperation("write", sdkHandle) { + logger(VERBOSE, "write for $name of size: ${data.capacity()}") + write(data) + response {} + }?.createWeakRef() ?: 0 @Suppress("unused") // Called by JNI - fun onWrite(data: ByteBuffer, sdkHandle: Long) { - onOperation("write", sdkHandle) { - logger(VERBOSE, "write for $name of size: ${data.capacity()}") - write(data) - response {} + fun onSeek(data: ByteBuffer, sdkHandle: Long) { + onRequest( + operation = "seek", + data = data, + sdkHandle = sdkHandle, + parser = ProtonSdk.StreamSeekRequest::parseFrom, + ) { request -> + checkNotNull(seek) { "seek not configured for $name" } + logger(VERBOSE, "seek for $name: offset=${request.offset}, origin=${request.origin}") + val newPosition = seek(request.offset, request.origin) + logger(VERBOSE, "seek result for $name: newPosition=$newPosition") + response { value = Int64Value.of(newPosition).asAny("google.protobuf.Int64Value") } } } @@ -205,7 +219,7 @@ class ProtonDriveSdkNativeClient internal constructor( throw error } catch (error: Throwable) { handleResponse(sdkHandle, response { - this@response.error = error.toProtonSdkError("Error while $operation") + this@response.error = error.toProtonSdkError("Error while executing $operation") }) } }.also { job -> @@ -306,6 +320,9 @@ class ProtonDriveSdkNativeClient internal constructor( @JvmStatic external fun getWritePointer(): Long + @JvmStatic + external fun getSeekPointer(): Long + @JvmStatic external fun getProgressPointer(): Long diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index e1977194..d347e6ce 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -149,6 +149,28 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { try await downloadsManager.cancelDownload(with: cancellationToken) } + /// Downloads a file to a seekable output stream with support for pause/resume. + /// Use this method when you need pause/resume functionality with proper stream seeking. + /// - Parameters: + /// - revisionUid: The revision UID of the file to download + /// - outputStream: The seekable output stream to write data to + /// - cancellationToken: A unique identifier for this download operation + /// - progressCallback: Callback for progress updates + /// - Returns: A DownloadOperation that supports pause/resume via stream seeking + public func downloadToStreamOperation( + revisionUid: SDKRevisionUid, + outputStream: SeekableOutputStream, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> DownloadOperation { + try await downloadsManager.downloadToStreamOperation( + revisionUid: revisionUid, + outputStream: outputStream, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + } + /// Convenience API for when you don't need a more granular control over the upload (pause, resume etc.) public func uploadFile( parentFolderUid: SDKNodeUid, diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift index 5aeff83b..81f967aa 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift @@ -10,15 +10,20 @@ public enum DownloadOperationResult: Sendable { } public final class DownloadOperation: Sendable { - + private enum DownloadCallback: Sendable { + case progress(ProgressCallbackWrapper) + case stream(StreamDownloadState) + } + private let fileDownloaderHandle: ObjectHandle private let downloadControllerHandle: ObjectHandle private let logger: Logger? private let nodeType: NodeType - private let progressCallbackWrapper: ProgressCallbackWrapper + private let downloadCallback: DownloadCallback private let onOperationCancel: @Sendable () async throws -> Void private let onOperationDispose: @Sendable () async -> Void - + private let pauseState = PauseState() + private var downloadControllerHandleForProtos: Int64 { Int64(downloadControllerHandle) } init(fileDownloaderHandle: ObjectHandle, @@ -32,7 +37,25 @@ public final class DownloadOperation: Sendable { assert(downloadControllerHandle != 0) self.fileDownloaderHandle = fileDownloaderHandle self.downloadControllerHandle = downloadControllerHandle - self.progressCallbackWrapper = progressCallbackWrapper + self.downloadCallback = .progress(progressCallbackWrapper) + self.logger = logger + self.nodeType = nodeType + self.onOperationCancel = onOperationCancel + self.onOperationDispose = onOperationDispose + } + + init(fileDownloaderHandle: ObjectHandle, + downloadControllerHandle: ObjectHandle, + streamDownloadState: StreamDownloadState, + logger: Logger?, + nodeType: NodeType, + onOperationCancel: @Sendable @escaping () async throws -> Void, + onOperationDispose: @Sendable @escaping () async -> Void) { + assert(fileDownloaderHandle != 0) + assert(downloadControllerHandle != 0) + self.fileDownloaderHandle = fileDownloaderHandle + self.downloadControllerHandle = downloadControllerHandle + self.downloadCallback = .stream(streamDownloadState) self.logger = logger self.nodeType = nodeType self.onOperationCancel = onOperationCancel @@ -107,6 +130,12 @@ public final class DownloadOperation: Sendable { } } + if let sdkError = error as? ProtonDriveSDKError, + sdkError.domain == .successfulCancellation, + await pauseState.isRequested() { + return .pausedOnError(error) + } + // check if operation can be resumed as the recovery flow do { guard try await isPaused() else { @@ -124,6 +153,7 @@ public final class DownloadOperation: Sendable { } public func pause() async throws { + await pauseState.setRequested(true) let pauseRequest = Proton_Drive_Sdk_DownloadControllerPauseRequest.with { $0.downloadControllerHandle = downloadControllerHandleForProtos } @@ -131,6 +161,7 @@ public final class DownloadOperation: Sendable { } public func resume() async throws { + await pauseState.setRequested(false) let resumeRequest = Proton_Drive_Sdk_DownloadControllerResumeRequest.with { $0.downloadControllerHandle = downloadControllerHandleForProtos } @@ -222,4 +253,17 @@ public final class DownloadOperation: Sendable { logger?.error("Proton_Drive_Sdk_DownloadControllerFreeRequest failed: \(error)", category: "DownloadController.freeDownloadController") } } + +} + +private actor PauseState { + private var requested = false + + func setRequested(_ requested: Bool) { + self.requested = requested + } + + func isRequested() -> Bool { + requested + } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift index aee85e51..5ba0d815 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift @@ -48,7 +48,7 @@ actor DownloadsManager { logger: logger ) - return DownloadOperation( + let operation = DownloadOperation( fileDownloaderHandle: downloaderHandle, downloadControllerHandle: downloadControllerHandle, progressCallbackWrapper: callbackState, @@ -63,6 +63,62 @@ actor DownloadsManager { await self.freeCancellationTokenSourceIfNeeded(cancellationToken: cancellationToken) } ) + + return operation + } + + func downloadToStreamOperation( + revisionUid: SDKRevisionUid, + outputStream: SeekableOutputStream, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> DownloadOperation { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + activeDownloads[cancellationToken] = cancellationTokenSource + + let downloaderHandle = try await buildFileDownloaderForStream( + revisionUid: revisionUid.sdkCompatibleIdentifier, + cancellationHandle: cancellationTokenSource.handle + ) + + let downloaderRequest = Proton_Drive_Sdk_DownloadToStreamRequest.with { + $0.downloaderHandle = Int64(downloaderHandle) + $0.writeAction = Int64(ObjectHandle(callback: cStreamWriteCallback)) + $0.progressAction = Int64(ObjectHandle(callback: cStreamProgressCallback)) + $0.seekAction = Int64(ObjectHandle(callback: cStreamSeekCallback)) + $0.cancelAction = Int64(ObjectHandle(callback: cStreamCancelCallback)) + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + } + + let callbackState = StreamDownloadState( + outputStream: outputStream, + progressCallback: progressCallback + ) + let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + downloaderRequest, + state: WeakReference(value: callbackState), + includesLongLivedCallback: true, + logger: logger + ) + + let operation = DownloadOperation( + fileDownloaderHandle: downloaderHandle, + downloadControllerHandle: downloadControllerHandle, + streamDownloadState: callbackState, + logger: logger, + nodeType: .file, + onOperationCancel: { [weak self] in + guard let self else { return } + try await self.cancelDownload(with: cancellationToken) + }, + onOperationDispose: { [weak self] in + guard let self else { return } + await self.freeCancellationTokenSourceIfNeeded(cancellationToken: cancellationToken) + } + ) + + callbackState.markReady() + return operation } // API to cancel operation when the client does not use the DownloadOperation @@ -99,4 +155,20 @@ actor DownloadsManager { assert(downloaderHandle != 0) return downloaderHandle } + + /// Get a file downloader for stream-based downloads from Drive + private func buildFileDownloaderForStream( + revisionUid: String, + cancellationHandle: ObjectHandle + ) async throws -> ObjectHandle { + let downloaderRequest = Proton_Drive_Sdk_DriveClientGetFileDownloaderRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.revisionUid = revisionUid + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + + let downloaderHandle: ObjectHandle = try await SDKRequestHandler.send(downloaderRequest, logger: logger) + assert(downloaderHandle != 0) + return downloaderHandle + } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/SeekableOutputStream.swift b/swift/ProtonDriveSDK/Sources/FileOperations/SeekableOutputStream.swift new file mode 100644 index 00000000..eb18c52f --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/SeekableOutputStream.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Origin point for seek operations +public enum SeekOrigin: Int32, Sendable { + /// Seek from the beginning of the stream + case begin = 0 + /// Seek relative to the current position + case current = 1 + /// Seek relative to the end of the stream + case end = 2 +} + +/// A protocol for output streams that support seeking. +/// Used for download operations that may need to resume from a specific position. +public protocol SeekableOutputStream: AnyObject, Sendable { + /// Writes data to the stream. + /// - Parameter data: The data to write + /// - Throws: An error if the write operation fails + func write(_ data: Data) throws + + /// Seeks to a position in the stream. + /// - Parameters: + /// - offset: The offset to seek to + /// - origin: The origin point for the seek operation + /// - Returns: The new position in the stream + /// - Throws: An error if the seek operation fails + func seek(offset: Int64, origin: SeekOrigin) throws -> Int64 + + /// Flushes any buffered data to the underlying storage. + /// - Throws: An error if the flush operation fails + func flush() throws + + /// Closes the stream. + /// - Throws: An error if the close operation fails + func close() throws +} + +/// A seekable output stream implementation backed by a FileHandle. +public final class FileSeekableOutputStream: SeekableOutputStream, @unchecked Sendable { + enum Error: Swift.Error { + case failedToCreateFile + case invalidSeekPosition + } + + private let fileHandle: FileHandle + private let lock = NSLock() + + /// Creates a new FileSeekableOutputStream for the given file URL. + /// - Parameter fileURL: The URL of the file to write to + /// - Throws: An error if the file cannot be opened for writing + public init(fileURL: URL) throws { + // If file already exists, operation still succeeds + if !FileManager.default.createFile(atPath: fileURL.path, contents: nil) { + throw Error.failedToCreateFile + } + + self.fileHandle = try FileHandle(forWritingTo: fileURL) + } + + /// Creates a new FileSeekableOutputStream for the given file handle. + /// - Parameter fileHandle: The file handle to write to + public init(fileHandle: FileHandle) { + self.fileHandle = fileHandle + } + + public func write(_ data: Data) throws { + lock.lock() + defer { lock.unlock() } + try fileHandle.write(contentsOf: data) + } + + public func seek(offset: Int64, origin: SeekOrigin) throws -> Int64 { + lock.lock() + defer { lock.unlock() } + + let basePosition: Int64 = switch origin { + case .begin: + 0 + case .current: + Int64(try fileHandle.offset()) + case .end: + Int64(try fileHandle.seekToEnd()) + } + + let newPosition = basePosition + offset + guard newPosition >= 0 else { + throw Error.invalidSeekPosition + } + + try fileHandle.seek(toOffset: UInt64(newPosition)) + return newPosition + } + + public func flush() throws { + lock.lock() + defer { lock.unlock() } + try fileHandle.synchronize() + } + + public func close() throws { + lock.lock() + defer { lock.unlock() } + try fileHandle.close() + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 95e4c903..76ff2ed1 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -145,6 +145,11 @@ extension Message { $0.payload = .downloadToFile(request) } + case let request as Proton_Drive_Sdk_DownloadToStreamRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadToStream(request) + } + case let request as Proton_Drive_Sdk_FileDownloaderFreeRequest: Proton_Drive_Sdk_Request.with { $0.payload = .fileDownloaderFree(request) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift index f97dcc23..4b337d63 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift @@ -13,7 +13,20 @@ enum SDKResponseHandler { fatalError("SDKResponseHandler.send failed with \(error)") } } - + + /// Sends a void/nil response to indicate successful completion with no return value. + /// Use this instead of sending Google_Protobuf_Empty. + static func sendVoidResponse(callbackPointer: Int) { + do { + let emptyResponse = Proton_Sdk_Response() + let byteArray = ByteArray(data: try emptyResponse.serializedData()) + proton_sdk_handle_response(callbackPointer, byteArray) + byteArray.deallocate() + } catch { + fatalError("SDKResponseHandler.sendVoidResponse failed with \(error)") + } + } + static func sendErrorToSDK(_ error: Error, callbackPointer: Int) { let sdkError = Proton_Sdk_Error.from(nsError: error as NSError) SDKResponseHandler.send(callbackPointer: callbackPointer, message: sdkError) @@ -32,6 +45,17 @@ enum SDKResponseHandler { } SDKResponseHandler.send(callbackPointer: callbackPointer, message: sdkError) } + + /// A helper method to send a cancellation error from Swift bindings. + /// This is used when a stream operation is cancelled. + static func sendCancellationErrorToSDK(message: String, callbackPointer: Int) { + let sdkError = Proton_Sdk_Error.with { + $0.type = "Swift bindings" + $0.domain = Proton_Sdk_ErrorDomain.successfulCancellation + $0.message = message + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: sdkError) + } } extension Proton_Sdk_Error { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift new file mode 100644 index 00000000..519121fa --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift @@ -0,0 +1,196 @@ +import Foundation +import CProtonDriveSDK +import SwiftProtobuf + +/// Wrapper that holds a SeekableOutputStream and progress callback for stream download operations. +/// Provides C-compatible callbacks for write, seek, and progress operations. +final class StreamDownloadState: @unchecked Sendable { + let outputStream: SeekableOutputStream + let progressCallback: ProgressCallback + private let lock = NSLock() + private var isReady = false + private var bufferedProgress: [FileOperationProgress] = [] + + init(outputStream: SeekableOutputStream, progressCallback: @escaping ProgressCallback) { + self.outputStream = outputStream + self.progressCallback = progressCallback + } + + func markReady() { + let buffered: [FileOperationProgress] + lock.lock() + isReady = true + buffered = bufferedProgress + bufferedProgress.removeAll() + lock.unlock() + + for progress in buffered { + progressCallback(progress) + } + } + + func handleProgress(_ progress: FileOperationProgress) { + lock.lock() + if !isReady { + bufferedProgress.append(progress) + lock.unlock() + return + } + lock.unlock() + progressCallback(progress) + } +} + +/// C-compatible callback for writing data to the output stream. +/// The SDK calls this with data that should be written to the stream. +/// Returns an operation handle that can be used to cancel the operation. +let cStreamWriteCallback: CCallbackWithCallbackPointerAndObjectPointerReturn = { statePointer, byteArray, callbackPointer in + typealias BoxType = BoxedCompletionBlock> + + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + SDKResponseHandler.sendInteropErrorToSDK( + message: "cStreamWriteCallback.statePointer is nil", + callbackPointer: callbackPointer + ) + return 0 + } + + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state + + guard let state = weakWrapper.value else { + SDKResponseHandler.sendInteropErrorToSDK( + message: "StreamDownloadState was deallocated", + callbackPointer: callbackPointer + ) + return 0 + } + + // Capture data before entering the task + let data = Data(byteArray: byteArray) + + // Create a boxed task with the write work + let taskBox = BoxedCancellableTask { + do { + try state.outputStream.write(data) + SDKResponseHandler.sendVoidResponse(callbackPointer: callbackPointer) + } catch { + if Task.isCancelled { + SDKResponseHandler.sendCancellationErrorToSDK( + message: "Write operation was cancelled", + callbackPointer: callbackPointer + ) + } else { + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } + } + } + + // Retain the task box and return its address as the cancellation handle + let unmanaged = Unmanaged.passRetained(taskBox) + let handle = Int(bitPattern: unmanaged.toOpaque()) + + // Set completion handler to release the Unmanaged reference when done + taskBox.setCompletionHandler { + unmanaged.release() + } + + return handle +} + +/// C-compatible callback for seeking in the output stream. +/// The SDK calls this with a StreamSeekRequest containing offset and origin. +/// Returns an operation handle that can be used to cancel the operation. +let cStreamSeekCallback: CCallbackWithCallbackPointerAndObjectPointerReturn = { statePointer, byteArray, callbackPointer in + typealias BoxType = BoxedCompletionBlock> + + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + SDKResponseHandler.sendInteropErrorToSDK( + message: "cStreamSeekCallback.statePointer is nil", + callbackPointer: callbackPointer + ) + return 0 + } + + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state + + guard let state = weakWrapper.value else { + SDKResponseHandler.sendInteropErrorToSDK( + message: "StreamDownloadState was deallocated", + callbackPointer: callbackPointer + ) + return 0 + } + + // Parse the seek request before entering the task + let seekRequest = Proton_Sdk_StreamSeekRequest(byteArray: byteArray) + let origin = SeekOrigin(rawValue: seekRequest.origin) ?? .begin + + // Create a boxed task with the seek work + let taskBox = BoxedCancellableTask { + do { + let newPosition = try state.outputStream.seek(offset: seekRequest.offset, origin: origin) + let int64Value = Google_Protobuf_Int64Value.with { $0.value = newPosition } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: int64Value) + } catch { + if Task.isCancelled { + SDKResponseHandler.sendCancellationErrorToSDK( + message: "Seek operation was cancelled", + callbackPointer: callbackPointer + ) + } else { + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } + } + } + + // Retain the task box and return its address as the cancellation handle + let unmanaged = Unmanaged.passRetained(taskBox) + let handle = Int(bitPattern: unmanaged.toOpaque()) + + // Set completion handler to release the Unmanaged reference when done + taskBox.setCompletionHandler { + unmanaged.release() + } + + return handle +} + +/// C-compatible callback for progress updates during stream download. +/// The SDK calls this with progress information. +let cStreamProgressCallback: CCallback = { statePointer, byteArray in + typealias BoxType = BoxedCompletionBlock> + let progressUpdate = Proton_Drive_Sdk_ProgressUpdate(byteArray: byteArray) + let progress = FileOperationProgress( + bytesCompleted: progressUpdate.hasBytesCompleted ? progressUpdate.bytesCompleted : nil, + bytesTotal: progressUpdate.hasBytesInTotal ? progressUpdate.bytesInTotal : nil + ) + + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + assertionFailure("cStreamProgressCallback.statePointer is nil") + return + } + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state + weakWrapper.value?.handleProgress(progress) +} + +/// C-compatible callback for cancelling the stream operation. +/// The SDK calls this with the operation handle returned from write/seek callbacks. +let cStreamCancelCallback: CCallbackWithoutByteArray = { operationHandle in + // If operationHandle is 0, it means we've early returned from the callback + guard operationHandle != 0 else { return } + + // Convert the address back to the task box + guard let pointer = UnsafeRawPointer(bitPattern: operationHandle) else { + assertionFailure("cStreamCancelCallback.operationHandle is nil") + return + } + + // Get the task box and cancel it + let unmanaged = Unmanaged.fromOpaque(pointer) + let taskBox = unmanaged.takeUnretainedValue() + // Release of the task box is wrapped in completionHandler, which is called in cancel() + taskBox.cancel() +} From 791e91216e06a37f8505644609183625e2ea4c1a Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 17:54:04 +0100 Subject: [PATCH 486/791] Fix files being truncated when downloading to file path through interop --- .../Proton.Drive.Sdk.CExports/InteropDownloadController.cs | 6 ++++-- .../src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs index cabca8c7..26894c73 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs @@ -48,9 +48,11 @@ public static IMessage HandleIsPaused(DownloadControllerIsPausedRequest request) return new BoolValue { Value = downloadController.GetIsDownloadCompleteWithVerificationIssue() }; } - public static IMessage? HandleFree(DownloadControllerFreeRequest request) + public static async ValueTask HandleFree(DownloadControllerFreeRequest request) { - Interop.FreeHandle(request.DownloadControllerHandle); + var downloadController = Interop.FreeHandle(request.DownloadControllerHandle); + + await downloadController.DisposeAsync().ConfigureAwait(false); return null; } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 1634bf0c..32118580 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -101,7 +101,7 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint => InteropDownloadController.HandleResume(request.DownloadControllerResume), Request.PayloadOneofCase.DownloadControllerFree - => InteropDownloadController.HandleFree(request.DownloadControllerFree), + => await InteropDownloadController.HandleFree(request.DownloadControllerFree).ConfigureAwait(false), Request.PayloadOneofCase.DrivePhotosClientCreate => InteropProtonPhotosClient.HandleCreate(request.DrivePhotosClientCreate, bindingsHandle), From bb4958e16d864d23fe0e6cb97c5bdca3485a69a1 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 30 Jan 2026 14:48:49 +0000 Subject: [PATCH 487/791] Add experimental createDocument to create Docs/Sheets --- js/sdk/src/internal/sharingPublic/index.ts | 6 +- js/sdk/src/internal/sharingPublic/nodes.ts | 120 +++++++++++++++++++-- js/sdk/src/protonDrivePublicLinkClient.ts | 14 +++ 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 8ada9149..b6ff9f61 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -7,10 +7,9 @@ import { MemberRole, } from '../../interface'; import { DriveAPIService } from '../apiService'; -import { SharingPublicNodesAPIService } from './nodes'; +import { SharingPublicNodesAPIService, SharingPublicNodesCryptoService } from './nodes'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; -import { NodesCryptoService } from '../nodes/cryptoService'; import { NodesRevisons } from '../nodes/nodesRevisions'; import { SharingPublicCryptoReporter } from './cryptoReporter'; import { SharingPublicNodesAccess, SharingPublicNodesManagement } from './nodes'; @@ -96,11 +95,12 @@ export function initSharingPublicNodesModule( clientUid, publicRootNodeUid, publicRole, + token, ); const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new SharingPublicCryptoReporter(telemetry); - const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); + const cryptoService = new SharingPublicNodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); const nodesAccess = new SharingPublicNodesAccess( telemetry, api, diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index 8484a23b..0f04e6aa 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -1,20 +1,59 @@ +import { c } from 'ttag'; + +import { PrivateKey } from '../../crypto'; +import { ValidationError } from '../../errors'; import { type Logger, MemberRole, NodeResult, ProtonDriveTelemetry } from '../../interface'; +import { type DriveAPIService, drivePaths } from '../apiService'; import { NodeAPIService, linkToEncryptedNode } from '../nodes/apiService'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; +import { DecryptedNode, DecryptedNodeKeys, NodeSigningKeys, EncryptedNode } from '../nodes/interface'; import { NodesAccess } from '../nodes/nodesAccess'; import { NodesManagement } from '../nodes/nodesManagement'; import { isProtonDocument, isProtonSheet } from '../nodes/mediaTypes'; +import { validateNodeName } from '../nodes/validations'; import { makeNodeUid, splitNodeUid } from '../uids'; import { SharingPublicSharesManager } from './shares'; -import { DecryptedNode, DecryptedNodeKeys, NodeSigningKeys, EncryptedNode } from '../nodes/interface'; -import { PrivateKey } from '../../crypto'; -import { type DriveAPIService, drivePaths } from '../apiService'; + +export class SharingPublicNodesCryptoService extends NodesCryptoService { + async generateDocument( + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + signingKeys: NodeSigningKeys, + name: string, + ) { + const crypto = await this.createFolder(parentKeys, signingKeys, name); + + const contentKey = await this.driveCrypto.generateContentKey(crypto.keys.key); + const contentSigningKey = signingKeys.type === 'userAddress' ? signingKeys.key : crypto.keys.key; + // Proton Docs or Proton Sheets do not have any blocks, so we sign an empty array. + const { armoredManifestSignature } = await this.driveCrypto.signManifest(new Uint8Array(), contentSigningKey); + + return { + encryptedCrypto: { + ...crypto.encryptedCrypto, + base64ContentKeyPacket: contentKey.encrypted.base64ContentKeyPacket, + armoredContentKeyPacketSignature: contentKey.encrypted.armoredContentKeyPacketSignature, + armoredManifestSignature, + }, + keys: { + ...crypto.keys, + contentKeyPacketSessionKey: contentKey.decrypted.contentKeyPacketSessionKey, + }, + }; + } +} type PostLoadLinksMetadataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; +type PostCreateDocumentRequest = Extract< + drivePaths['/drive/urls/{token}/documents']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateDocumentResponse = + drivePaths['/drive/urls/{token}/documents']['post']['responses']['200']['content']['application/json']; + /** * Custom API service for public links that handles permission injection. * @@ -31,10 +70,12 @@ export class SharingPublicNodesAPIService extends NodeAPIService { clientUid: string | undefined, private publicRootNodeUid: string, private publicRole: MemberRole, + private token: string, ) { super(logger, apiService, clientUid); this.publicRootNodeUid = publicRootNodeUid; this.publicRole = publicRole; + this.token = token; } protected linkToEncryptedNode( @@ -55,6 +96,43 @@ export class SharingPublicNodesAPIService extends NodeAPIService { return encryptedNode; } + + async createDocument( + parentNodeUid: string, + newDocument: { + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + signatureEmail: string | null; + encryptedName: string; + hash: string; + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature: string; + armoredManifestSignature: string; + documentType: 1 | 2; + }, + ): Promise { + const { volumeId, nodeId: parentId } = splitNodeUid(parentNodeUid); + + const response = await this.apiService.post( + `drive/urls/${this.token}/documents`, + { + ParentLinkID: parentId, + NodeKey: newDocument.armoredKey, + NodePassphrase: newDocument.armoredNodePassphrase, + NodePassphraseSignature: newDocument.armoredNodePassphraseSignature, + SignatureEmail: newDocument.signatureEmail, + Name: newDocument.encryptedName, + Hash: newDocument.hash, + ContentKeyPacket: newDocument.base64ContentKeyPacket, + ContentKeyPacketSignature: newDocument.armoredContentKeyPacketSignature, + ManifestSignature: newDocument.armoredManifestSignature, + DocumentType: newDocument.documentType, + }, + ); + + return makeNodeUid(volumeId, response.Document.LinkID); + } } export class SharingPublicNodesAccess extends NodesAccess { @@ -138,12 +216,12 @@ export class SharingPublicNodesAccess extends NodesAccess { export class SharingPublicNodesManagement extends NodesManagement { constructor( - apiService: NodeAPIService, + private sharingPublicApiService: SharingPublicNodesAPIService, cryptoCache: NodesCryptoCache, - cryptoService: NodesCryptoService, + private sharingPublicCryptoService: SharingPublicNodesCryptoService, nodesAccess: SharingPublicNodesAccess, ) { - super(apiService, cryptoCache, cryptoService, nodesAccess); + super(sharingPublicApiService, cryptoCache, sharingPublicCryptoService, nodesAccess); } async *deleteMyNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { @@ -156,4 +234,34 @@ export class SharingPublicNodesManagement extends NodesManagement { yield result; } } + + async createDocument( + parentNodeUid: string, + documentName: string, + documentType: 1 | 2, + ): Promise { + validateNodeName(documentName); + + const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); + if (!parentKeys.hashKey) { + throw new ValidationError(c('Error').t`Creating documents in non-folders is not allowed`); + } + + const signingKeys = await this.nodesAccess.getNodeSigningKeys({ parentNodeUid }); + const { encryptedCrypto, keys } = await this.sharingPublicCryptoService.generateDocument( + { key: parentKeys.key, hashKey: parentKeys.hashKey }, + signingKeys, + documentName, + ); + + const nodeUid = await this.sharingPublicApiService.createDocument(parentNodeUid, { + ...encryptedCrypto, + documentType, + }); + + await this.nodesAccess.notifyChildCreated(parentNodeUid); + await this.cryptoCache.setNodeKeys(nodeUid, keys); + + return this.nodesAccess.getNode(nodeUid); + } } diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 54db83d1..c3f6be62 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -74,6 +74,10 @@ export class ProtonDrivePublicLinkClient { * Experimental feature to check if hashes match the malware database. */ scanHashes: (hashes: string[]) => Promise; + /** + * Experimental feature to create a document (Proton Docs or Proton Sheets) in the public link. + */ + createDocument: (parentNodeUid: NodeOrUid, documentName: string, documentType: 1 | 2) => Promise; }; constructor({ @@ -175,6 +179,16 @@ export class ProtonDrivePublicLinkClient { this.logger.debug(`Scanning ${hashes.length} hashes`); return this.sharingPublic.nodes.security.scanHashes(hashes); }, + createDocument: async ( + parentNodeUid: NodeOrUid, + documentName: string, + documentType: 1 | 2, + ): Promise => { + this.logger.debug(`Creating document in ${getUid(parentNodeUid)}`); + return convertInternalNodePromise( + this.sharingPublic.nodes.management.createDocument(getUid(parentNodeUid), documentName, documentType), + ); + }, }; } From dcba30d413e7735583a659f57570a86d7169807d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Feb 2026 06:03:26 +0000 Subject: [PATCH 488/791] i18n(weekly-mr): Upgrade translations from crowdin (31132796). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 6 +++--- js/sdk/locales/ca_ES.json | 6 +++--- js/sdk/locales/de_DE.json | 6 +++--- js/sdk/locales/el_GR.json | 6 +++--- js/sdk/locales/es_ES.json | 6 +++--- js/sdk/locales/es_LA.json | 6 +++--- js/sdk/locales/fr_FR.json | 6 +++--- js/sdk/locales/it_IT.json | 6 +++--- js/sdk/locales/ko_KR.json | 3 --- js/sdk/locales/nl_NL.json | 6 +++--- js/sdk/locales/pl_PL.json | 3 --- js/sdk/locales/pt_BR.json | 3 --- js/sdk/locales/pt_PT.json | 3 --- js/sdk/locales/ro_RO.json | 6 +++--- js/sdk/locales/ru_RU.json | 3 --- js/sdk/locales/sk_SK.json | 6 +++--- js/sdk/locales/tr_TR.json | 16 ++++++++-------- 18 files changed, 42 insertions(+), 57 deletions(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index f30f2b71..7037ea88 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "fdde85e79fa737b8868535307f51f973f5c37ec7" + "locale": "76adc64b3298ecd24b19101994f543a2b4edf085" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 7290474a..07d775e7 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "КапіÑванне Ñлементаў у аÑÑроддзі, Ñкое не належыць папкам забаронена" ], + "Creating documents in non-folders is not allowed": [ + "СтварÑнне дакументаў у аÑÑроддзі, Ñкое не належыць папкам забаронена" + ], "Creating files in non-folders is not allowed": [ "СтварÑнне файлаў у аÑÑроддзі, Ñкое не належыць папкам забаронена" ], @@ -146,9 +149,6 @@ "Node has no thumbnail": [ "У вузла адÑутнічае мініÑцюра" ], - "Node is not a file": [ - "Вузел не з'ÑўлÑецца файлам" - ], "Node is not accessible": [ "Вузел недаÑтупны" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index c6764d6f..6bef5f79 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "No està permès copiar un element a un lloc que no és una carpeta" ], + "Creating documents in non-folders is not allowed": [ + "La creació de documents fora de carpetes no està permesa" + ], "Creating files in non-folders is not allowed": [ "No es permet crear carpetes en llocs que no són carpetes" ], @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "El node no té miniatura" ], - "Node is not a file": [ - "El node no és un fitxer" - ], "Node is not accessible": [ "Aquest node no és accessible" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index b3e31dcf..1c62309d 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "Kopieren des Eintrags in einen Nicht-Ordner ist nicht erlaubt" ], + "Creating documents in non-folders is not allowed": [ + "Das Erstellen von Dokumenten außerhalb von Ordnern ist nicht erlaubt." + ], "Creating files in non-folders is not allowed": [ "Erstellen von Dateien in Nicht-Ordnern ist nicht erlaubt" ], @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "Node hat kein Vorschaubild" ], - "Node is not a file": [ - "Node ist keine Datei" - ], "Node is not accessible": [ "Node ist nicht erreichbar" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index 55bbd89f..cb4a7b18 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "Δεν επιτÏέπεται η αντιγÏαφή στοιχείου σε μη-φάκελο" ], + "Creating documents in non-folders is not allowed": [ + "Δεν επιτÏέπεται η δημιουÏγία εγγÏάφων σε μη-φακέλους." + ], "Creating files in non-folders is not allowed": [ "Δεν επιτÏέπεται η δημιουÏγία αÏχείων σε μη-φακέλους." ], @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "Ο κόμβος δεν έχει μικÏογÏαφία" ], - "Node is not a file": [ - "Ο κόμβος δεν είναι αÏχείο" - ], "Node is not accessible": [ "Ο κόμβος δεν είναι Ï€Ïοσβάσιμος" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 79bcec00..b688b16e 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "No está permitido copiar un elemento a una ubicación que no sea una carpeta." ], + "Creating documents in non-folders is not allowed": [ + "No está permitido crear documentos en elementos que no sean carpetas." + ], "Creating files in non-folders is not allowed": [ "No está permitido crear archivos en ubicaciones que no sean carpetas." ], @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "El nodo no tiene miniatura." ], - "Node is not a file": [ - "El nodo no es un archivo." - ], "Node is not accessible": [ "El nodo no es accesible." ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index f70ad4b5..8a4b6461 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "No está permitido copiar un elemento a una ubicación que no es una carpeta" ], + "Creating documents in non-folders is not allowed": [ + "No está permitido crear documentos en ubicaciones que no son carpetas" + ], "Creating files in non-folders is not allowed": [ "No está permitido crear archivos en ubicaciones que no son carpetas" ], @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "El nodo no tiene miniatura" ], - "Node is not a file": [ - "El nodo no es un archivo" - ], "Node is not accessible": [ "El nodo no es accesible" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index 476f3d22..178bfae5 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "La copie d'un élément vers un élément qui n'est pas un dossier n'est pas autorisée." ], + "Creating documents in non-folders is not allowed": [ + "La création de documents dans des éléments qui ne sont pas des dossiers n'est pas autorisée." + ], "Creating files in non-folders is not allowed": [ "La création de fichiers dans des éléments qui ne sont pas des dossiers n'est pas autorisée." ], @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "Le nÅ“ud n'a pas de vignette." ], - "Node is not a file": [ - "Le nÅ“ud n'est pas un fichier." - ], "Node is not accessible": [ "Le nÅ“ud n'est pas accessible." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index 2c20fa9b..e42da0b5 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "Non è consentito copiare l'elemento in un elemento che non è una cartella." ], + "Creating documents in non-folders is not allowed": [ + "La creazione di documenti in elementi non cartella non è consentita" + ], "Creating files in non-folders is not allowed": [ "Non è consentito creare file in elementi che non sono cartelle." ], @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "Il nodo non ha alcuna miniatura" ], - "Node is not a file": [ - "Il nodo non è un file" - ], "Node is not accessible": [ "Il nodo non è accessibile" ], diff --git a/js/sdk/locales/ko_KR.json b/js/sdk/locales/ko_KR.json index e66f8106..a6a11a6d 100644 --- a/js/sdk/locales/ko_KR.json +++ b/js/sdk/locales/ko_KR.json @@ -140,9 +140,6 @@ "Node has no thumbnail": [ "ë…¸ë“œì— ì„¬ë‚´ì¼ì´ 없습니다" ], - "Node is not a file": [ - "노드가 파ì¼ì´ 아닙니다" - ], "Node is not accessible": [ "ë…¸ë“œì— ì ‘ê·¼í•  수 없습니다" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index 2b86a2a1..baa7debf 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "Het kopiëren van een item naar een niet-map is niet toegestaan" ], + "Creating documents in non-folders is not allowed": [ + "Het aanmaken van documenten in niet-mappen is niet toegestaan" + ], "Creating files in non-folders is not allowed": [ "Het aanmaken van bestanden in niet-mappen is niet toegestaan" ], @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "Node heeft geen miniatuur" ], - "Node is not a file": [ - "Node is geen bestand" - ], "Node is not accessible": [ "Node is niet toegankelijk" ], diff --git a/js/sdk/locales/pl_PL.json b/js/sdk/locales/pl_PL.json index 066eaff7..5b9a0d6f 100644 --- a/js/sdk/locales/pl_PL.json +++ b/js/sdk/locales/pl_PL.json @@ -146,9 +146,6 @@ "Node has no thumbnail": [ "WÄ™zeÅ‚ nie ma podglÄ…du" ], - "Node is not a file": [ - "WÄ™zeÅ‚ nie jest plikiem" - ], "Node is not accessible": [ "WÄ™zeÅ‚ nie jest dostÄ™pny" ], diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index e25f8f93..6e6be3d4 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -141,9 +141,6 @@ "Node has no thumbnail": [ "Nó não tem miniatura" ], - "Node is not a file": [ - "O nó não é um arquivo" - ], "Node is not accessible": [ "O nó não está acessível" ], diff --git a/js/sdk/locales/pt_PT.json b/js/sdk/locales/pt_PT.json index 0847590c..4047b5d3 100644 --- a/js/sdk/locales/pt_PT.json +++ b/js/sdk/locales/pt_PT.json @@ -117,9 +117,6 @@ "Node has no thumbnail": [ "O nó não tem miniatura." ], - "Node is not a file": [ - "O nó não é um ficheiro." - ], "Node is not accessible": [ "O nó não está acessível." ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index 186cd872..a4a75559 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "Copierea articolului în alt loc decât un folder nu este permisă." ], + "Creating documents in non-folders is not allowed": [ + "Crearea documentelor în afara folderelor nu este permisă." + ], "Creating files in non-folders is not allowed": [ "Crearea fiÈ™ierelor în non-foldere nu este permisă." ], @@ -145,9 +148,6 @@ "Node has no thumbnail": [ "Nodul nu are miniatură." ], - "Node is not a file": [ - "Nodul nu este un fiÈ™ier." - ], "Node is not accessible": [ "Nodul nu este accesibil." ], diff --git a/js/sdk/locales/ru_RU.json b/js/sdk/locales/ru_RU.json index 70ba54bb..6fb7c1b0 100644 --- a/js/sdk/locales/ru_RU.json +++ b/js/sdk/locales/ru_RU.json @@ -128,9 +128,6 @@ "Node has no thumbnail": [ "У узла нет значка" ], - "Node is not a file": [ - "Узел не ÑвлÑетÑÑ Ñ„Ð°Ð¹Ð»Ð¾Ð¼" - ], "Node is not accessible": [ "Узел недоÑтупен" ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index 39ed7bc0..e7f5e348 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "Kopírovanie položky mimo prieÄinka nie je povolené." ], + "Creating documents in non-folders is not allowed": [ + "Vytváranie dokumentov mimo prieÄinkov nie je povolené." + ], "Creating files in non-folders is not allowed": [ "Vytváranie súborov mimo prieÄinkov nie je povolené." ], @@ -146,9 +149,6 @@ "Node has no thumbnail": [ "Uzol nemá miniatúru" ], - "Node is not a file": [ - "Uzol nie je súbor" - ], "Node is not accessible": [ "Uzol nie je dostupný" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index db1954cd..533ebf2a 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -18,16 +18,19 @@ "Kopyalama iÅŸlemi iptal edildi" ], "Copying item to a non-folder is not allowed": [ - "Öge klasör olmayan bir yere kopyalanamaz" + "Öge klasör olmayan bir ögeye kopyalanamaz" + ], + "Creating documents in non-folders is not allowed": [ + "Klasör olmayan ögeler içinde belge oluÅŸturulamaz" ], "Creating files in non-folders is not allowed": [ - "Klasör olmayanlar içinde dosya oluÅŸturulamaz" + "Klasör olmayan ögeler içinde dosya oluÅŸturulamaz" ], "Creating folders in non-folders is not allowed": [ - "Klasör olmayanlar içinde klasör oluÅŸturulamaz" + "Klasör olmayan ögeler içinde klasör oluÅŸturulamaz" ], "Creating revisions in non-files is not allowed": [ - "Dosya olmayanlar içinde sürüm oluÅŸturulamaz" + "Dosya olmayan ögelerde sürüm oluÅŸturulamaz" ], "Data integrity check failed": [ "Veri bütünlüğü denetlenemedi" @@ -123,7 +126,7 @@ "Taşıma iÅŸlemi iptal edildi" ], "Moving item to a non-folder is not allowed": [ - "Öge klasör olmayan bir yere taşınamaz" + "Öge klasör olmayan bir ögeye taşınamaz" ], "Moving root item is not allowed": [ "Kök öge taşınamaz" @@ -144,9 +147,6 @@ "Node has no thumbnail": [ "Düğümün küçük görseli yok" ], - "Node is not a file": [ - "Düğüm bir dosya deÄŸil" - ], "Node is not accessible": [ "Düğüme eriÅŸilemiyor" ], From 71dabdc0d56c9dc48580d4d28264b8332ba88085 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 28 Jan 2026 15:15:03 +0100 Subject: [PATCH 489/791] Rename Jni* methods to match proto requests --- .../main/kotlin/me/proton/drive/sdk/FileDownloader.kt | 6 +++++- .../main/kotlin/me/proton/drive/sdk/FileUploader.kt | 10 ++++------ .../kotlin/me/proton/drive/sdk/PhotosDownloader.kt | 2 +- .../main/kotlin/me/proton/drive/sdk/PhotosUploader.kt | 9 ++++++--- .../kotlin/me/proton/drive/sdk/ProtonDriveClient.kt | 2 +- .../kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt | 2 +- .../me/proton/drive/sdk/internal/JniFileDownloader.kt | 2 +- .../me/proton/drive/sdk/internal/JniFileUploader.kt | 4 ++-- .../proton/drive/sdk/internal/JniPhotosDownloader.kt | 2 +- .../me/proton/drive/sdk/internal/JniPhotosUploader.kt | 3 +-- .../proton/drive/sdk/internal/JniProtonDriveClient.kt | 2 +- .../proton/drive/sdk/internal/JniProtonPhotosClient.kt | 2 +- 12 files changed, 25 insertions(+), 21 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index c290300a..81a58af3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -77,7 +77,11 @@ suspend fun ProtonDriveClient.downloader( factory(JniFileDownloader()){ FileDownloader( client = this@downloader, - handle = this.create(handle, source.handle, revisionUid), + handle = getFileDownloader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + revisionUid = revisionUid, + ), bridge = this, cancellationTokenSource = source, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt index e441fda3..01896d29 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -67,11 +67,10 @@ class FileUploader internal constructor( suspend fun ProtonDriveClient.uploader( request: FileUploaderRequest ): Uploader = cancellationTokenSource().let { source -> - val client = this JniFileUploader().run { FileUploader( - client = client, - handle = getFile(client.handle, source.handle, request), + client = this@uploader, + handle = getFileUploader(handle, source.handle, request), bridge = this, cancellationTokenSource = source, ) @@ -81,11 +80,10 @@ suspend fun ProtonDriveClient.uploader( suspend fun ProtonDriveClient.uploader( request: FileRevisionUploaderRequest ): Uploader = cancellationTokenSource().let { source -> - val client = this@uploader JniFileUploader().run { FileUploader( - client = client, - handle = getFileRevision(handle, source.handle, request), + client = this@uploader, + handle = getFileRevisionUploader(handle, source.handle, request), bridge = this, cancellationTokenSource = source, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index ea66bccc..8b3d441a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -70,7 +70,7 @@ suspend fun ProtonPhotosClient.downloader( factory(JniPhotosDownloader()) { PhotosDownloader( client = this@downloader, - handle = create( + handle = getPhotoDownloader( clientHandle = handle, cancellationTokenSourceHandle = source.handle, photoUid = photoUid, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt index 3a87be3f..b60c9016 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt @@ -67,11 +67,14 @@ class PhotosUploader( suspend fun ProtonPhotosClient.uploader( request: PhotosUploaderRequest ): Uploader = cancellationTokenSource().let { source -> - val client = this JniPhotosUploader().run { PhotosUploader( - client = client, - handle = getPhoto(client.handle, source.handle, request), + client = this@uploader, + handle = getPhotoUploader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + request = request, + ), bridge = this, cancellationTokenSource = source, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 21d69999..96df7c96 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -110,7 +110,7 @@ class ProtonDriveClient internal constructor( suspend fun Session.protonDriveClientCreate(): ProtonDriveClient = factory(JniProtonDriveClient()) { ProtonDriveClient( session = this@protonDriveClientCreate, - handle = create(handle), + handle = createFromSession(sessionHandle = handle), bridge = this, ) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 918e1c54..82c451e0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -53,7 +53,7 @@ suspend fun Session.protonPhotosClientCreate(): ProtonPhotosClient = val session = this@protonPhotosClientCreate ProtonPhotosClient( session = session, - handle = create(handle), + handle = createFromSession(sessionHandle = handle), bridge = this, ) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt index c39cb552..b8f62e27 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt @@ -11,7 +11,7 @@ import java.nio.ByteBuffer class JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { - suspend fun create( + suspend fun getFileDownloader( clientHandle: Long, cancellationTokenSourceHandle: Long, revisionUid: String, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt index 2064c808..17364048 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt @@ -17,7 +17,7 @@ import java.nio.ByteBuffer class JniFileUploader internal constructor() : JniBaseProtonDriveSdk() { - suspend fun getFile( + suspend fun getFileUploader( clientHandle: Long, cancellationTokenSourceHandle: Long, request: FileUploaderRequest, @@ -26,7 +26,7 @@ class JniFileUploader internal constructor() : JniBaseProtonDriveSdk() { request.toProtobuf(clientHandle, cancellationTokenSourceHandle) } - suspend fun getFileRevision( + suspend fun getFileRevisionUploader( clientHandle: Long, cancellationTokenSourceHandle: Long, request: FileRevisionUploaderRequest, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt index 1d28efc2..20e3198c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt @@ -10,7 +10,7 @@ import proton.drive.sdk.request import java.nio.ByteBuffer class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { - suspend fun create( + suspend fun getPhotoDownloader( clientHandle: Long, cancellationTokenSourceHandle: Long, photoUid: String, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt index 883974ae..7bc93c01 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt @@ -8,7 +8,6 @@ import me.proton.drive.sdk.extension.toProtobuf import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_PREVIEW import proton.drive.sdk.ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_THUMBNAIL -import proton.drive.sdk.drivePhotosClientFindDuplicatesRequest import proton.drive.sdk.drivePhotosClientUploadFromStreamRequest import proton.drive.sdk.drivePhotosClientUploaderFreeRequest import proton.drive.sdk.request @@ -20,7 +19,7 @@ import kotlin.collections.forEach class JniPhotosUploader internal constructor() : JniBaseProtonDriveSdk() { - suspend fun getPhoto( + suspend fun getPhotoUploader( clientHandle: Long, cancellationTokenSourceHandle: Long, request: PhotosUploaderRequest, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index ea83ea0f..d41c4a53 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -24,7 +24,7 @@ import proton.sdk.telemetry class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { - suspend fun create(sessionHandle: Long) = + suspend fun createFromSession(sessionHandle: Long) = executeOnce("createFromSession", LongResponseCallback) { driveClientCreateFromSession = driveClientCreateFromSessionRequest { this.sessionHandle = sessionHandle diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index f833ed14..6bb1ada9 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -20,7 +20,7 @@ import proton.sdk.telemetry class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { - suspend fun create(sessionHandle: Long) = + suspend fun createFromSession(sessionHandle: Long) = executeOnce("createFromSession", LongResponseCallback) { drivePhotosClientCreateFromSession = drivePhotosClientCreateFromSessionRequest { this.sessionHandle = sessionHandle From 716cee6bb210c39176a945eb35c3f79586b1ce0a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 28 Jan 2026 11:25:39 +0100 Subject: [PATCH 490/791] Set coroutine context of operation and function to Dispatchers.IO --- .../main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt | 1 - .../proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt index c8353cac..1c8abeea 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt @@ -1,6 +1,5 @@ package me.proton.drive.sdk.extension -import kotlinx.coroutines.runBlocking import me.proton.drive.sdk.internal.HttpStream import okhttp3.MediaType import okhttp3.RequestBody diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 17fdd0fd..cc671232 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -6,6 +6,7 @@ import com.google.protobuf.Int32Value import com.google.protobuf.StringValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.isActive @@ -201,7 +202,7 @@ class ProtonDriveSdkNativeClient internal constructor( data: ByteBuffer, parser: (ByteBuffer) -> T, block: suspend (T) -> R - ): R = runBlocking { + ): R = runBlocking(Dispatchers.IO) { val value = parser(data) coroutineScope(operation).async { block(value) }.await() } @@ -212,7 +213,7 @@ class ProtonDriveSdkNativeClient internal constructor( sdkHandle: Long, block: suspend () -> Response, ): Job? = try { - coroutineScope(operation).launch { + coroutineScope(operation).launch(Dispatchers.IO) { try { handleResponse(sdkHandle, block()) } catch (error: CancellationException) { From 6912a33b13a30e95ba6fc518d31b3ee7f81d2f01 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Feb 2026 08:45:00 +0100 Subject: [PATCH 491/791] Automate changelog --- js/sdk/package-lock.json | 4 ++-- js/sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 5fb3a199..14edb5f7 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.5", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protontech/drive-sdk", - "version": "0.9.5", + "version": "0.0.1", "license": "GPL-3.0", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/js/sdk/package.json b/js/sdk/package.json index 3b10d348..89fa1d0b 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@protontech/drive-sdk", - "version": "0.9.5", + "version": "0.0.1", "description": "Proton Drive SDK", "license": "GPL-3.0", "main": "dist/index.js", From 7bfff1ff7e2488d94b25cef4669284c5f5528ec0 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 3 Feb 2026 13:14:59 +0100 Subject: [PATCH 492/791] Use unconfined dispatcher --- .../internal/ProtonDriveSdkNativeClient.kt | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index cc671232..3a01b21a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -1,8 +1,8 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any -import com.google.protobuf.Int64Value import com.google.protobuf.Int32Value +import com.google.protobuf.Int64Value import com.google.protobuf.StringValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -12,6 +12,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import me.proton.drive.sdk.LoggerProvider.Level import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.ERROR @@ -202,32 +203,50 @@ class ProtonDriveSdkNativeClient internal constructor( data: ByteBuffer, parser: (ByteBuffer) -> T, block: suspend (T) -> R - ): R = runBlocking(Dispatchers.IO) { + ): R = runBlocking(Dispatchers.Unconfined) { val value = parser(data) coroutineScope(operation).async { block(value) }.await() } + private inner class ResponseOnce(private val operation: String) { + private val responseSent = java.util.concurrent.atomic.AtomicBoolean(false) + + operator fun invoke(sdkHandle: Long, response: Response) { + if (responseSent.compareAndSet(false, true)) { + handleResponse(sdkHandle, response) + } else { + logger(WARN, "Response already sent for $operation") + } + } + } + @Suppress("TooGenericExceptionCaught") private fun onOperation( operation: String, sdkHandle: Long, + responseOnce: ResponseOnce = ResponseOnce(operation), block: suspend () -> Response, ): Job? = try { coroutineScope(operation).launch(Dispatchers.IO) { try { - handleResponse(sdkHandle, block()) + val response = block() + withContext(Dispatchers.Unconfined) { + responseOnce(sdkHandle, response) + } } catch (error: CancellationException) { throw error } catch (error: Throwable) { - handleResponse(sdkHandle, response { - this@response.error = error.toProtonSdkError("Error while executing $operation") - }) + withContext(Dispatchers.Unconfined) { + responseOnce(sdkHandle, response { + this@response.error = error.toProtonSdkError("Error while executing $operation") + }) + } } }.also { job -> job.invokeOnCompletion { error -> if (error is CancellationException) { logger(DEBUG, "Operation $operation was cancelled") - handleResponse(sdkHandle, response { + responseOnce(sdkHandle, response { this@response.error = error.toProtonSdkError("Operation $operation was cancelled") }) @@ -249,13 +268,14 @@ class ProtonDriveSdkNativeClient internal constructor( data: ByteBuffer, sdkHandle: Long, parser: (ByteBuffer) -> T, + responseOnce: ResponseOnce = ResponseOnce(operation), block: suspend (T) -> Response ): Job? = try { // parsing of protobuf needs to be done serially val request = parser(data) - onOperation(operation, sdkHandle) { block(request) } + onOperation(operation, sdkHandle, responseOnce) { block(request) } } catch (error: Throwable) { - handleResponse(sdkHandle, response { + responseOnce(sdkHandle, response { this@response.error = error.toProtonSdkError( "Error while parsing request for $operation" ) From 0a694e26247b08c885d6362788076e27814bf9cf Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Feb 2026 04:45:17 +0000 Subject: [PATCH 493/791] Add photo upload and xAttr support to Swift bindings --- .../ProtonPhotosClient.swift | 90 +++++++++++++++++++ .../Uploads/PhotoUploadsManager.swift | 18 ++++ .../Sources/Plumbing/PublicTypes.swift | 18 ++++ .../ProtonDriveSDKError.swift | 3 + 4 files changed, 129 insertions(+) diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 0a60e478..9e7fc9be 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -4,6 +4,7 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { private var clientHandle: ObjectHandle = 0 private var downloadsManager: PhotoDownloadsManager! + private var uploadManager: PhotoUploadsManager! private var thumbnailsManager: DownloadThumbnailsManager! let accountClient: AccountClientProtocol @@ -80,6 +81,7 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { logger.trace("client handle: \(clientHandle)", category: "ProtonDriveClient") self.downloadsManager = PhotoDownloadsManager(clientHandle: clientHandle, logger: logger) + self.uploadManager = PhotoUploadsManager(clientHandle: clientHandle, logger: logger) self.thumbnailsManager = DownloadThumbnailsManager(clientHandle: clientHandle, logger: logger) } @@ -140,7 +142,10 @@ extension ProtonPhotosClient { let list: Proton_Drive_Sdk_PhotosTimelineList = try await SDKRequestHandler.send(request, logger: logger) return list.items.compactMap { PhotoTimelineItem(item: $0) } } +} +// MARK: - Download +extension ProtonPhotosClient { public func downloadThumbnails( photoUids: [SDKNodeUid], type: ThumbnailData.ThumbnailType, @@ -191,3 +196,88 @@ extension ProtonPhotosClient { ) } } + +// MARK: - Upload +extension ProtonPhotosClient { + public func uploadPhoto( + name: String, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + captureTime: Date, + mainPhotoLinkID: String?, + mediaType: String, + thumbnails: [ThumbnailData], + tags: [Int], + additionalMetadata: [AdditionalMetadata], + overrideExistingDraft: Bool, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> UploadedFileIdentifiers { + let operation = try await uploadOperation( + name: name, + fileURL: fileURL, + fileSize: fileSize, + modificationDate: modificationDate, + captureTime: captureTime, + mainPhotoLinkID: mainPhotoLinkID, + mediaType: mediaType, + thumbnails: thumbnails, + tags: tags, + additionalMetadata: additionalMetadata, + overrideExistingDraft: overrideExistingDraft, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + + return try await operation.awaitUploadWithResilience( + operationalResilience: configuration.uploadOperationalResilience, + onRetriableErrorReceived: onRetriableErrorReceived + ) + } + + public func uploadOperation( + name: String, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + captureTime: Date, + mainPhotoLinkID: String?, + mediaType: String, + thumbnails: [ThumbnailData], + tags: [Int], + additionalMetadata: [AdditionalMetadata], + overrideExistingDraft: Bool, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> UploadOperation { + let mappedTags = tags.compactMap { Proton_Drive_Sdk_PhotoTag(rawValue: $0) } + guard mappedTags.count == tags.count else { + let inputTags = Set(tags) + let knownTags = Set(mappedTags.map(\.rawValue)) + let unknownTags = Array(inputTags.subtracting(knownTags)) + throw ProtonDriveSDKError(interopError: .containsUnknownPhotoTags(tags: unknownTags)) + } + + return try await uploadManager.uploadPhotoOperation( + name: name, + fileURL: fileURL, + fileSize: fileSize, + modificationDate: modificationDate, + captureTime: captureTime, + mainPhotoLinkID: mainPhotoLinkID, + mediaType: mediaType, + thumbnails: thumbnails, + tags: mappedTags, + additionalMetadata: additionalMetadata, + overrideExistingDraft: overrideExistingDraft, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + } + + public func cancelUpload(with token: UUID) async throws { + try await uploadManager.cancelUpload(with: token) + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift index c0d1e665..67c774a7 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift @@ -24,8 +24,12 @@ actor PhotoUploadsManager { fileURL: URL, fileSize: Int64, modificationDate: Date, + captureTime: Date, + mainPhotoLinkID: String?, mediaType: String, thumbnails: [ThumbnailData], + tags: [Proton_Drive_Sdk_PhotoTag], + additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, cancellationToken: UUID, progressCallback: @escaping ProgressCallback @@ -38,6 +42,10 @@ actor PhotoUploadsManager { fileSize: fileSize, modificationDate: modificationDate, mediaType: mediaType, + captureTime: captureTime, + mainPhotoLinkID: mainPhotoLinkID, + tags: tags, + additionalMetadata: additionalMetadata, overrideExistingDraft: overrideExistingDraft, cancellationHandle: cancellationTokenSource.handle ) @@ -137,6 +145,10 @@ actor PhotoUploadsManager { fileSize: Int64, modificationDate: Date, mediaType: String, + captureTime: Date, + mainPhotoLinkID: String?, + tags: [Proton_Drive_Sdk_PhotoTag], + additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, cancellationHandle: ObjectHandle ) async throws -> ObjectHandle { @@ -147,7 +159,13 @@ actor PhotoUploadsManager { $0.metadata = Proton_Drive_Sdk_PhotoFileUploadMetadata.with { metadata in metadata.mediaType = mediaType metadata.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + metadata.additionalMetadata = additionalMetadata.map { $0.toSDK } metadata.overrideExistingDraftByOtherClient = overrideExistingDraft + metadata.captureTime = Google_Protobuf_Timestamp(date: captureTime) + if let mainPhotoLinkID { + metadata.mainPhotoLinkID = mainPhotoLinkID + } + metadata.tags = tags } $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index d812dda7..e97f1731 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -109,6 +109,24 @@ public struct ThumbnailData: Sendable { } } +/// Extended attribute for photo upload +public struct AdditionalMetadata: Sendable { + public let name: String + public let utf8JsonValue: Data + + var toSDK: Proton_Drive_Sdk_AdditionalMetadataProperty { + Proton_Drive_Sdk_AdditionalMetadataProperty.with { + $0.name = name + $0.utf8JsonValue = utf8JsonValue + } + } + + public init(name: String, utf8JsonValue: Data) { + self.name = name + self.utf8JsonValue = utf8JsonValue + } +} + public struct FolderNode: Sendable { public let uid: SDKNodeUid public let parentUid: SDKNodeUid? diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift index e39a7ea2..fedb2861 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift @@ -60,6 +60,7 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { case wrongSDKResponse(message: String) case wrongResult(message: String) case incorrectIDFormat(id: String) + case containsUnknownPhotoTags(tags: [Int]) var typeName: String { switch self { @@ -68,6 +69,7 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { case .wrongSDKResponse: return "WrongSDKResponseType" case .wrongResult: return "WrongSDKRequestResult" case .incorrectIDFormat: return "IncorrectIDFormat" + case .containsUnknownPhotoTags: return "ContainsUnknownPhotoTags" } } @@ -78,6 +80,7 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { case .wrongSDKResponse(let message): return message case .wrongResult(let message): return message case .incorrectIDFormat(let id): return "ID \(id) is not in the correct format" + case .containsUnknownPhotoTags(let tags): return "Contains unknown photo tags \(tags)" } } } From d2e5aac30f512e3fae3f5169d677b0603d9abc20 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Feb 2026 07:19:03 +0000 Subject: [PATCH 494/791] Expose functions to get nodes and enumerate folder children through interop layer --- .../InteropMessageHandler.cs | 9 + .../InteropProtonDriveClient.cs | 247 ++++++++++++++++++ .../InteropProtonPhotosClient.cs | 17 ++ cs/sdk/src/protos/proton.drive.sdk.proto | 132 +++++++++- .../me/proton/drive/sdk/ProtonDriveClient.kt | 26 ++ .../proton/drive/sdk/ProtonDriveException.kt | 14 + .../me/proton/drive/sdk/ProtonPhotosClient.kt | 39 +++ .../converter/FolderChildrenListConverter.kt | 11 + .../sdk/converter/NodeResultConverter.kt | 11 + .../converter/PhotosTimelineListConverter.kt | 11 + .../sdk/entity/AdditionalMetadataProperty.kt | 8 + .../proton/drive/sdk/entity/AuthorResult.kt | 6 - .../drive/sdk/entity/DegradedFileNode.kt | 16 ++ .../drive/sdk/entity/DegradedFolderNode.kt | 13 + .../proton/drive/sdk/entity/DegradedNode.kt | 13 + .../drive/sdk/entity/DegradedRevision.kt | 15 ++ .../me/proton/drive/sdk/entity/DriveError.kt | 6 + .../drive/sdk/entity/FileContentDigests.kt | 5 + .../me/proton/drive/sdk/entity/FileNode.kt | 15 ++ .../me/proton/drive/sdk/entity/FolderNode.kt | 18 +- .../kotlin/me/proton/drive/sdk/entity/Node.kt | 12 + .../me/proton/drive/sdk/entity/NodeResult.kt | 6 + .../drive/sdk/entity/PhotosTimelineItem.kt | 6 + .../me/proton/drive/sdk/entity/Revision.kt | 13 + .../drive/sdk/entity/ThumbnailHeader.kt | 6 + .../extension/AdditionalMetadataProperty.kt | 10 + .../drive/sdk/extension/AnyConverter.kt | 6 + .../me/proton/drive/sdk/extension/Author.kt | 8 + .../drive/sdk/extension/AuthorResult.kt | 16 ++ .../drive/sdk/extension/DegradedFileNode.kt | 21 ++ .../drive/sdk/extension/DegradedFolderNode.kt | 17 ++ .../drive/sdk/extension/DegradedRevision.kt | 23 ++ .../proton/drive/sdk/extension/DriveError.kt | 9 + .../drive/sdk/extension/FileContentDigests.kt | 10 + .../me/proton/drive/sdk/extension/FileNode.kt | 19 ++ .../drive/sdk/extension/FolderChildrenList.kt | 7 + .../proton/drive/sdk/extension/NameAuthor.kt | 10 - .../proton/drive/sdk/extension/NodeResult.kt | 37 +++ .../drive/sdk/extension/PhotosTimelineList.kt | 12 + .../sdk/extension/ProtonDriveSdkNodeResult.kt | 31 +++ .../me/proton/drive/sdk/extension/Revision.kt | 20 ++ .../drive/sdk/extension/StringResult.kt | 18 ++ .../drive/sdk/extension/ThumbnailHeader.kt | 14 + .../ContinuationValueOrNullResponse.kt | 45 ++++ .../sdk/internal/JniProtonDriveClient.kt | 14 + .../sdk/internal/JniProtonPhotosClient.kt | 24 ++ 46 files changed, 1015 insertions(+), 31 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderChildrenListConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/PhotosTimelineListConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AdditionalMetadataProperty.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Revision.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailHeader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AdditionalMetadataProperty.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Author.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AuthorResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderChildrenList.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNodeResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailHeader.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 32118580..936c37f4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -49,6 +49,12 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientGetThumbnails => await InteropProtonDriveClient.HandleGetThumbnailsAsync(request.DriveClientGetThumbnails).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientEnumerateFolderChildren + => await InteropProtonDriveClient.HandleEnumerateFolderChildrenAsync(request.DriveClientEnumerateFolderChildren).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientGetMyFilesFolder + => await InteropProtonDriveClient.HandleGetMyFilesFolderAsync(request.DriveClientGetMyFilesFolder).ConfigureAwait(false), + Request.PayloadOneofCase.UploadFromStream => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, bindingsHandle), @@ -121,6 +127,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DrivePhotosClientEnumeratePhotosTimeline => await InteropProtonPhotosClient.HandleEnumeratePhotosTimelineAsync(request.DrivePhotosClientEnumeratePhotosTimeline).ConfigureAwait(false), + Request.PayloadOneofCase.DrivePhotosClientGetNode + => await InteropProtonPhotosClient.HandleGetNodeAsync(request.DrivePhotosClientGetNode).ConfigureAwait(false), + Request.PayloadOneofCase.DrivePhotosClientGetPhotoDownloader => await InteropProtonPhotosClient.HandleGetPhotosDownloaderAsync(request.DrivePhotosClientGetPhotoDownloader).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index fa98fb40..67e49295 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -187,6 +187,43 @@ public static async ValueTask HandleGetThumbnailsAsync(DriveClientGetT return new FileThumbnailList { Thumbnails = { thumbnails } }; } + public static async ValueTask HandleEnumerateFolderChildrenAsync(DriveClientEnumerateFolderChildrenRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var childrenEnumerable = client.EnumerateFolderChildrenAsync( + NodeUid.Parse(request.FolderUid), + cancellationToken); + + var children = await childrenEnumerable + .Select(ConvertToNodeResult) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return new FolderChildrenList { Children = { children } }; + } + + public static async ValueTask HandleGetMyFilesFolderAsync(DriveClientGetMyFilesFolderRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var client = Interop.GetFromHandle(request.ClientHandle); + + var folderNode = await client.GetMyFilesFolderAsync(cancellationToken).ConfigureAwait(false); + + return new FolderNode + { + Uid = folderNode.Uid.ToString(), + ParentUid = folderNode.ParentUid?.ToString() ?? string.Empty, + TreeEventScopeId = folderNode.TreeEventScopeId, + Name = folderNode.Name, + CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = ParseAuthorResult(folderNode.NameAuthor), + Author = ParseAuthorResult(folderNode.Author), + }; + } + public static async ValueTask HandleGetFileDownloaderAsync(DriveClientGetFileDownloaderRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); @@ -269,4 +306,214 @@ public static AuthorResult ParseAuthorResult(Result result) + { + var nodeResult = new NodeResult(); + + if (result.TryGetValueElseError(out var node, out var degradedNode)) + { + nodeResult.Value = ConvertToNode(node); + } + else + { + nodeResult.Error = ConvertToDegradedNode(degradedNode); + } + + return nodeResult; + } + + private static Node ConvertToNode(Proton.Drive.Sdk.Nodes.Node node) + { + var result = new Node(); + + switch (node) + { + case Proton.Drive.Sdk.Nodes.FolderNode folderNode: + result.Folder = new FolderNode + { + Uid = folderNode.Uid.ToString(), + ParentUid = folderNode.ParentUid?.ToString() ?? string.Empty, + TreeEventScopeId = folderNode.TreeEventScopeId, + Name = folderNode.Name, + CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = ParseAuthorResult(folderNode.NameAuthor), + Author = ParseAuthorResult(folderNode.Author), + }; + break; + + case Proton.Drive.Sdk.Nodes.FileNode fileNode: + var fileNodeProto = new FileNode + { + Uid = fileNode.Uid.ToString(), + ParentUid = fileNode.ParentUid?.ToString() ?? string.Empty, + TreeEventScopeId = fileNode.TreeEventScopeId, + Name = fileNode.Name, + MediaType = fileNode.MediaType, + CreationTime = fileNode.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = fileNode.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = ParseAuthorResult(fileNode.NameAuthor), + Author = ParseAuthorResult(fileNode.Author), + TotalSizeOnCloudStorage = fileNode.TotalSizeOnCloudStorage, + ActiveRevision = new FileRevision + { + Uid = fileNode.ActiveRevision.Uid.ToString(), + CreationTime = fileNode.ActiveRevision.CreationTime.ToUniversalTime().ToTimestamp(), + SizeOnCloudStorage = fileNode.ActiveRevision.SizeOnCloudStorage, + ClaimedSize = fileNode.ActiveRevision.ClaimedSize ?? 0, + ClaimedModificationTime = fileNode.ActiveRevision.ClaimedModificationTime?.ToUniversalTime().ToTimestamp(), + ClaimedDigests = new FileContentDigests(), + }, + }; + + if (fileNode.ActiveRevision.ClaimedDigests.Sha1.HasValue) + { + fileNodeProto.ActiveRevision.ClaimedDigests.Sha1 = ByteString.CopyFrom(fileNode.ActiveRevision.ClaimedDigests.Sha1.Value.Span); + } + + fileNodeProto.ActiveRevision.Thumbnails.AddRange( + fileNode.ActiveRevision.Thumbnails.Select(t => new ThumbnailHeader + { + Id = t.Id, + Type = (ThumbnailType)(int)t.Type, + })); + + if (fileNode.ActiveRevision.AdditionalClaimedMetadata is not null) + { + fileNodeProto.ActiveRevision.AdditionalClaimedMetadata.AddRange( + fileNode.ActiveRevision.AdditionalClaimedMetadata.Select(m => new AdditionalMetadataProperty + { + Name = m.Name, + Utf8JsonValue = ByteString.CopyFromUtf8(m.Value.ToString()), + })); + } + + if (fileNode.ActiveRevision.ContentAuthor.HasValue) + { + fileNodeProto.ActiveRevision.ContentAuthor = ParseAuthorResult(fileNode.ActiveRevision.ContentAuthor.Value); + } + + result.File = fileNodeProto; + break; + } + + return result; + } + + private static DegradedNode ConvertToDegradedNode(Proton.Drive.Sdk.Nodes.DegradedNode degradedNode) + { + var result = new DegradedNode(); + + switch (degradedNode) + { + case Proton.Drive.Sdk.Nodes.DegradedFolderNode degradedFolderNode: + var degradedFolder = new DegradedFolderNode + { + Uid = degradedFolderNode.Uid.ToString(), + ParentUid = degradedFolderNode.ParentUid?.ToString() ?? string.Empty, + TreeEventScopeId = degradedFolderNode.TreeEventScopeId, + Name = ConvertStringToStringResult(degradedFolderNode.Name), + CreationTime = degradedFolderNode.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = degradedFolderNode.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = ParseAuthorResult(degradedFolderNode.NameAuthor), + Author = ParseAuthorResult(degradedFolderNode.Author), + }; + + degradedFolder.Errors.AddRange(degradedFolderNode.Errors.Select(ConvertToDriveError)); + result.Folder = degradedFolder; + break; + + case Proton.Drive.Sdk.Nodes.DegradedFileNode degradedFileNode: + var degradedFile = new DegradedFileNode + { + Uid = degradedFileNode.Uid.ToString(), + ParentUid = degradedFileNode.ParentUid?.ToString() ?? string.Empty, + TreeEventScopeId = degradedFileNode.TreeEventScopeId, + Name = ConvertStringToStringResult(degradedFileNode.Name), + MediaType = degradedFileNode.MediaType, + CreationTime = degradedFileNode.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = degradedFileNode.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = ParseAuthorResult(degradedFileNode.NameAuthor), + Author = ParseAuthorResult(degradedFileNode.Author), + TotalStorageQuotaUsage = degradedFileNode.TotalStorageQuotaUsage, + }; + + if (degradedFileNode.ActiveRevision is not null) + { + degradedFile.ActiveRevision = new DegradedRevision + { + Uid = degradedFileNode.ActiveRevision.Uid.ToString(), + CreationTime = degradedFileNode.ActiveRevision.CreationTime.ToUniversalTime().ToTimestamp(), + SizeOnCloudStorage = degradedFileNode.ActiveRevision.SizeOnCloudStorage, + ClaimedSize = degradedFileNode.ActiveRevision.ClaimedSize ?? 0, + ClaimedModificationTime = degradedFileNode.ActiveRevision.ClaimedModificationTime?.ToUniversalTime().ToTimestamp(), + CanDecrypt = degradedFileNode.ActiveRevision.CanDecrypt, + }; + + if (degradedFileNode.ActiveRevision.ClaimedDigests.HasValue) + { + degradedFile.ActiveRevision.ClaimedDigests = new FileContentDigests(); + if (degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1.HasValue) + { + degradedFile.ActiveRevision.ClaimedDigests.Sha1 = ByteString.CopyFrom(degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1.Value.Span); + } + } + + degradedFile.ActiveRevision.Thumbnails.AddRange( + degradedFileNode.ActiveRevision.Thumbnails.Select(t => new ThumbnailHeader + { + Id = t.Id, + Type = (ThumbnailType)(int)t.Type, + })); + + if (degradedFileNode.ActiveRevision.AdditionalClaimedMetadata is not null) + { + degradedFile.ActiveRevision.AdditionalClaimedMetadata.AddRange( + degradedFileNode.ActiveRevision.AdditionalClaimedMetadata.Select(m => new AdditionalMetadataProperty + { + Name = m.Name, + Utf8JsonValue = ByteString.CopyFromUtf8(m.Value.ToString()), + })); + } + + if (degradedFileNode.ActiveRevision.ContentAuthor.HasValue) + { + degradedFile.ActiveRevision.ContentAuthor = ParseAuthorResult(degradedFileNode.ActiveRevision.ContentAuthor.Value); + } + + degradedFile.ActiveRevision.Errors.AddRange(degradedFileNode.ActiveRevision.Errors.Select(ConvertToDriveError)); + } + + degradedFile.Errors.AddRange(degradedFileNode.Errors.Select(ConvertToDriveError)); + result.File = degradedFile; + break; + } + + return result; + } + + private static DriveError ConvertToDriveError(ProtonDriveError error) + { + return new DriveError + { + Message = error.Message ?? string.Empty, + InnerError = error.InnerError != null ? ConvertToDriveError(error.InnerError) : null, + }; + } + + private static StringResult ConvertStringToStringResult(Result result) + { + var stringResult = new StringResult(); + if (result.TryGetValueElseError(out var value, out var error)) + { + stringResult.Value = value; + } + else + { + stringResult.Error = ConvertToDriveError(error); + } + + return stringResult; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 5f92974e..e2934fdc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -103,6 +103,23 @@ public static async ValueTask HandleGetPhotosRootAsync(DrivePhotosClie }; } + public static async ValueTask HandleGetNodeAsync(DrivePhotosClientGetNodeRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var client = Interop.GetFromHandle(request.ClientHandle); + + var nodeResult = await client.GetNodeAsync( + NodeUid.Parse(request.NodeUid), + cancellationToken).ConfigureAwait(false); + + if (nodeResult == null) + { + return null; + } + + return InteropProtonDriveClient.ConvertToNodeResult(nodeResult.Value); + } + public static async ValueTask HandleEnumeratePhotosTimelineAsync(DrivePhotosClientEnumeratePhotosTimelineRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index e357e931..b021c5fe 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -20,6 +20,8 @@ message Request { DriveClientRenameRequest drive_client_rename = 1008; DriveClientCreateFolderRequest drive_client_create_folder = 1009; DriveClientTrashNodesRequest drive_client_trash_nodes = 1010; + DriveClientEnumerateFolderChildrenRequest drive_client_enumerate_folder_children = 1011; + DriveClientGetMyFilesFolderRequest drive_client_get_my_files_folder = 1012; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -52,6 +54,7 @@ message Request { DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1308; DrivePhotosClientEnumeratePhotosTimelineRequest drive_photos_client_enumerate_photos_timeline = 1309; DrivePhotosClientGetPhotoUploaderRequest drive_photos_client_get_photo_uploader = 1310; + DrivePhotosClientGetNodeRequest drive_photos_client_get_node = 1315; DrivePhotosClientFindDuplicatesRequest drive_photos_client_find_duplicates = 1311; DrivePhotosClientUploadFromStreamRequest drive_photos_client_upload_from_stream = 1312; DrivePhotosClientUploadFromFileRequest drive_photos_client_upload_from_file = 1313; @@ -107,17 +110,26 @@ message FileRevision { string uid = 1; google.protobuf.Timestamp creation_time = 2; int64 size_on_cloud_storage = 3; - int64 claimed_size = 4; // Optional - google.protobuf.Timestamp claimed_modification_time = 5; // Optional + int64 claimed_size = 4; // optional + FileContentDigests claimed_digests = 5; + google.protobuf.Timestamp claimed_modification_time = 6; // optional + repeated ThumbnailHeader thumbnails = 7; + repeated AdditionalMetadataProperty additional_claimed_metadata = 8; // optional + AuthorResult content_author = 9; // optional } message FileNode { string uid = 1; string parent_uid = 2; - string name = 3; - string media_type = 4; - int64 total_size_on_cloud_storage = 5; - FileRevision active_revision = 6; + string tree_event_scope_id = 3; + string name = 4; + string media_type = 5; + google.protobuf.Timestamp creation_time = 6; + google.protobuf.Timestamp trash_time = 7; // optional + AuthorResult name_author = 8; + AuthorResult author = 9; + FileRevision active_revision = 10; + int64 total_size_on_cloud_storage = 11; } message FolderNode { @@ -363,6 +375,107 @@ message DriveClientGetThumbnailsRequest { int64 cancellation_token_source_handle = 4; } +// The response message must be of type FolderChildrenList. +message DriveClientEnumerateFolderChildrenRequest { + int64 client_handle = 1; + string folder_uid = 2; + int64 cancellation_token_source_handle = 3; +} + +// The response message must be of type FolderNode. +message DriveClientGetMyFilesFolderRequest { + int64 client_handle = 1; + int64 cancellation_token_source_handle = 2; +} + +message FolderChildrenList { + repeated NodeResult children = 1; +} + +// Generic result type for Node operations - contains either a successful node or a degraded node with errors +message NodeResult { + oneof result { + Node value = 1; + DegradedNode error = 2; + } +} + +message Node { + oneof node { + FolderNode folder = 1; + FileNode file = 2; + } +} + +message DegradedNode { + oneof node { + DegradedFolderNode folder = 1; + DegradedFileNode file = 2; + } +} + +message DriveError { + string message = 1; // optional + DriveError inner_error = 2; // optional +} + +message StringResult { + oneof result { + string value = 1; + DriveError error = 2; + } +} + +message DegradedFolderNode { + string uid = 1; + string parent_uid = 2; // optional + string tree_event_scope_id = 3; + StringResult name = 4; + google.protobuf.Timestamp creation_time = 5; + google.protobuf.Timestamp trash_time = 6; // optional + AuthorResult name_author = 7; + AuthorResult author = 8; + repeated DriveError errors = 9; +} + +message DegradedFileNode { + string uid = 1; + string parent_uid = 2; + string tree_event_scope_id = 3; + StringResult name = 4; + string media_type = 5; + google.protobuf.Timestamp creation_time = 6; + google.protobuf.Timestamp trash_time = 7; // optional + AuthorResult name_author = 8; + AuthorResult author = 9; + DegradedRevision active_revision = 10; // optional + int64 total_storage_quota_usage = 11; + repeated DriveError errors = 12; +} + +message FileContentDigests { + bytes sha1 = 1; // optional +} + +message ThumbnailHeader { + string id = 1; + ThumbnailType type = 2; +} + +message DegradedRevision { + string uid = 1; + google.protobuf.Timestamp creation_time = 2; + int64 size_on_cloud_storage = 3; + int64 claimed_size = 4; // optional + FileContentDigests claimed_digests = 5; // optional + google.protobuf.Timestamp claimed_modification_time = 6; // optional + repeated ThumbnailHeader thumbnails = 7; + repeated AdditionalMetadataProperty additional_claimed_metadata = 8; // optional + AuthorResult content_author = 9; // optional + bool can_decrypt = 10; + repeated DriveError errors = 11; +} + // Drive - downloads // The response value must be an Int64Value carrying a handle to an instance of FileDownloader. @@ -486,6 +599,13 @@ message DrivePhotosClientEnumeratePhotosTimelineRequest { int64 cancellation_token_source_handle = 3; } +// The response message must be of type NodeResult (nullable). +message DrivePhotosClientGetNodeRequest { + int64 client_handle = 1; + string node_uid = 2; + int64 cancellation_token_source_handle = 3; +} + message PhotosTimelineList { repeated PhotosTimelineItem items = 1; } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 96df7c96..ef63ca21 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -4,6 +4,7 @@ import com.google.protobuf.timestamp import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.FolderNode +import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto @@ -12,7 +13,9 @@ import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.driveClientCreateFolderRequest +import proton.drive.sdk.driveClientEnumerateFolderChildrenRequest import proton.drive.sdk.driveClientGetAvailableNameRequest +import proton.drive.sdk.driveClientGetMyFilesFolderRequest import proton.drive.sdk.driveClientGetThumbnailsRequest import proton.drive.sdk.driveClientRenameRequest import java.nio.channels.WritableByteChannel @@ -96,6 +99,29 @@ class ProtonDriveClient internal constructor( ).toEntity() } + suspend fun getMyFilesFolder(): FolderNode = cancellationCoroutineScope { source -> + log(DEBUG, "getMyFilesFolder") + bridge.getMyFilesFolder( + driveClientGetMyFilesFolderRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + suspend fun enumerateFolderChildren( + folderUid: String, + ): List = cancellationCoroutineScope { source -> + log(DEBUG, "enumerateFolderChildren") + bridge.enumerateFolderChildren( + driveClientEnumerateFolderChildrenRequest { + this.folderUid = folderUid + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt new file mode 100644 index 00000000..955107be --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk + +import me.proton.drive.sdk.entity.Author + +open class ProtonDriveException( + override val message: String? = null, + override val cause: Throwable? = null, +) : Throwable(message, cause) + +class SignatureVerificationException( + val claimedAuthor: Author, + override val message: String? = null, + override val cause: Throwable? = null, +) : ProtonDriveException(message, cause) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 82c451e0..651d2c0f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -2,13 +2,20 @@ package me.proton.drive.sdk import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.entity.FolderNode +import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.PhotosTimelineItem import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniProtonPhotosClient import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.drivePhotosClientEnumeratePhotosThumbnailsRequest +import proton.drive.sdk.drivePhotosClientEnumeratePhotosTimelineRequest +import proton.drive.sdk.drivePhotosClientGetNodeRequest +import proton.drive.sdk.drivePhotosClientGetPhotosRootRequest import java.nio.channels.WritableByteChannel class ProtonPhotosClient internal constructor( @@ -37,6 +44,38 @@ class ProtonPhotosClient internal constructor( } } + suspend fun getPhotosRoot(): FolderNode = cancellationCoroutineScope { source -> + log(DEBUG, "getPhotosRoot") + bridge.getPhotosRoot( + drivePhotosClientGetPhotosRootRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + suspend fun enumeratePhotosTimeline(folderUid: String): List = cancellationCoroutineScope { source -> + log(DEBUG, "enumeratePhotosTimeline") + bridge.enumeratePhotosTimeline( + drivePhotosClientEnumeratePhotosTimelineRequest { + this.folderUid = folderUid + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + suspend fun getNode(nodeUid: String): NodeResult? = cancellationCoroutineScope { source -> + log(DEBUG, "getNode") + bridge.getNode( + drivePhotosClientGetNodeRequest { + this.nodeUid = nodeUid + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + )?.toEntity() + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderChildrenListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderChildrenListConverter.kt new file mode 100644 index 00000000..c7263768 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderChildrenListConverter.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class FolderChildrenListConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.FolderChildrenList" + + override fun convert(any: Any): ProtonDriveSdk.FolderChildrenList = + ProtonDriveSdk.FolderChildrenList.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultConverter.kt new file mode 100644 index 00000000..394a7360 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultConverter.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class NodeResultConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.NodeResult" + + override fun convert(any: Any): ProtonDriveSdk.NodeResult = + ProtonDriveSdk.NodeResult.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/PhotosTimelineListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/PhotosTimelineListConverter.kt new file mode 100644 index 00000000..2429d94f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/PhotosTimelineListConverter.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class PhotosTimelineListConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.PhotosTimelineList" + + override fun convert(any: Any): ProtonDriveSdk.PhotosTimelineList = + ProtonDriveSdk.PhotosTimelineList.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AdditionalMetadataProperty.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AdditionalMetadataProperty.kt new file mode 100644 index 00000000..2e33d924 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AdditionalMetadataProperty.kt @@ -0,0 +1,8 @@ +package me.proton.drive.sdk.entity + +import kotlinx.serialization.json.JsonElement + +data class AdditionalMetadataProperty( + val name: String, + val value: JsonElement, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt deleted file mode 100644 index b04ab9ae..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/AuthorResult.kt +++ /dev/null @@ -1,6 +0,0 @@ -package me.proton.drive.sdk.entity - -data class AuthorResult( - val author: Author, - val signatureVerificationError: String, -) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt new file mode 100644 index 00000000..ad66a0ee --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt @@ -0,0 +1,16 @@ +package me.proton.drive.sdk.entity + +data class DegradedFileNode( + override val uid: String, + override val parentUid: String, + override val treeEventScopeId: String, + override val name: Result, + val mediaType: String, + override val creationTime: Long, + override val trashTime: Long?, + override val nameAuthor: Result, + override val author: Result, + val activeRevision: DegradedRevision?, + val totalStorageQuotaUsage: Long, + override val errors: List, +) : DegradedNode diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt new file mode 100644 index 00000000..55c751cc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt @@ -0,0 +1,13 @@ +package me.proton.drive.sdk.entity + +data class DegradedFolderNode( + override val uid: String, + override val parentUid: String?, + override val treeEventScopeId: String, + override val name: Result, + override val creationTime: Long, + override val trashTime: Long?, + override val nameAuthor: Result, + override val author: Result, + override val errors: List, +) : DegradedNode diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt new file mode 100644 index 00000000..8c7d4d92 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt @@ -0,0 +1,13 @@ +package me.proton.drive.sdk.entity + +sealed interface DegradedNode { + val uid: String + val parentUid: String? + val treeEventScopeId: String + val name: Result + val creationTime: Long + val trashTime: Long? + val nameAuthor: Result + val author: Result + val errors: List +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt new file mode 100644 index 00000000..e81ee27d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk.entity + +data class DegradedRevision( + val uid: String, + val creationTime: Long, + val sizeOnCloudStorage: Long, + val claimedSize: Long?, + val claimedDigests: FileContentDigests?, + val claimedModificationTime: Long?, + val thumbnails: List, + val additionalClaimedMetadata: List?, + val contentAuthor: Result?, + val canDecrypt: Boolean, + val errors: List, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt new file mode 100644 index 00000000..07a353d5 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +data class DriveError( + val message: String, + val innerError: DriveError? = null, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt new file mode 100644 index 00000000..0fcf1805 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt @@ -0,0 +1,5 @@ +package me.proton.drive.sdk.entity + +data class FileContentDigests( + val sha1: String?, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt new file mode 100644 index 00000000..888768ed --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk.entity + +data class FileNode( + override val uid: String, + override val parentUid: String, + override val treeEventScopeId: String, + override val name: String, + val mediaType: String, + override val creationTime: Long, + override val trashTime: Long?, + override val nameAuthor: Result, + override val author: Result, + val activeRevision: FileRevision, + val totalSizeOnCloudStorage: Long, +) : Node diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt index 5c2e9840..33714fbb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt @@ -1,13 +1,13 @@ package me.proton.drive.sdk.entity data class FolderNode( - var uid: String, - var parentUid: String?, - var treeEventScopeId: String, - var name: String, - var creationTime: Long, - var trashTime: Long?, - var nameAuthor: AuthorResult, - var author: AuthorResult, -) + override val uid: String, + override val parentUid: String?, + override val treeEventScopeId: String, + override val name: String, + override val creationTime: Long, + override val trashTime: Long?, + override val nameAuthor: Result, + override val author: Result, +) : Node diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt new file mode 100644 index 00000000..e3b9b49e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt @@ -0,0 +1,12 @@ +package me.proton.drive.sdk.entity + +sealed interface Node { + val uid: String + val parentUid: String? + val treeEventScopeId: String + val name: String + val creationTime: Long + val trashTime: Long? + val nameAuthor: Result + val author: Result +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResult.kt new file mode 100644 index 00000000..a25fcfbf --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResult.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +sealed interface NodeResult { + data class Value(val node: Node) : NodeResult + data class Error(val node: DegradedNode) : NodeResult +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt new file mode 100644 index 00000000..6016e104 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +data class PhotosTimelineItem( + val nodeUid: String, + val captureTime: Long, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Revision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Revision.kt new file mode 100644 index 00000000..3c80dd93 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Revision.kt @@ -0,0 +1,13 @@ +package me.proton.drive.sdk.entity + +data class FileRevision( + val uid: String, + val creationTime: Long, + val sizeOnCloudStorage: Long, + val claimedSize: Long?, + val claimedDigests: FileContentDigests, + val claimedModificationTime: Long?, + val thumbnails: List, + val additionalClaimedMetadata: List?, + val contentAuthor: Result?, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailHeader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailHeader.kt new file mode 100644 index 00000000..c4ccb891 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailHeader.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +data class ThumbnailHeader( + val id: String, + val type: ThumbnailType, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AdditionalMetadataProperty.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AdditionalMetadataProperty.kt new file mode 100644 index 00000000..f5bcf08a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AdditionalMetadataProperty.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.extension + +import kotlinx.serialization.json.Json +import me.proton.drive.sdk.entity.AdditionalMetadataProperty +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.AdditionalMetadataProperty.toEntity() = AdditionalMetadataProperty( + name = name, + value = Json.parseToJsonElement(utf8JsonValue.toStringUtf8()), +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt index 5e7c81c8..289d17a4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt @@ -3,9 +3,15 @@ package me.proton.drive.sdk.extension import kotlinx.coroutines.CancellableContinuation import me.proton.drive.sdk.converter.AnyConverter import me.proton.drive.sdk.internal.ContinuationValueOrErrorResponse +import me.proton.drive.sdk.internal.ContinuationValueOrNullResponse import me.proton.drive.sdk.internal.ResponseCallback val AnyConverter.asCallback get(): (CancellableContinuation) -> ResponseCallback = { continuation -> ContinuationValueOrErrorResponse(continuation, this) } + +val AnyConverter.asNullableCallback + get(): (CancellableContinuation) -> ResponseCallback = { continuation -> + ContinuationValueOrNullResponse(continuation, this) + } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Author.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Author.kt new file mode 100644 index 00000000..ca84b945 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Author.kt @@ -0,0 +1,8 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.Author +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.Author.toEntity() = Author( + emailAddress = emailAddress, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AuthorResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AuthorResult.kt new file mode 100644 index 00000000..a786a66c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AuthorResult.kt @@ -0,0 +1,16 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.SignatureVerificationException +import me.proton.drive.sdk.entity.Author +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.AuthorResult.toEntity(): Result = if (signatureVerificationError.isEmpty()) { + Result.success(author.toEntity()) +} else { + Result.failure( + SignatureVerificationException( + claimedAuthor = author.toEntity(), + message = signatureVerificationError + ) + ) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt new file mode 100644 index 00000000..736e5382 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt @@ -0,0 +1,21 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.DegradedFileNode +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.activeRevisionOrNull +import proton.drive.sdk.trashTimeOrNull + +fun ProtonDriveSdk.DegradedFileNode.toEntity() = DegradedFileNode( + uid = uid, + parentUid = parentUid, + treeEventScopeId = treeEventScopeId, + name = name.toEntity(), + mediaType = mediaType, + creationTime = creationTime.seconds, + trashTime = trashTimeOrNull?.seconds, + nameAuthor = nameAuthor.toEntity(), + author = author.toEntity(), + activeRevision = activeRevisionOrNull?.toEntity(), + totalStorageQuotaUsage = totalStorageQuotaUsage, + errors = errorsList.map { it.toEntity() }, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt new file mode 100644 index 00000000..47c0f95b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt @@ -0,0 +1,17 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.DegradedFolderNode +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.trashTimeOrNull + +fun ProtonDriveSdk.DegradedFolderNode.toEntity() = DegradedFolderNode( + uid = uid, + parentUid = parentUid, + treeEventScopeId = treeEventScopeId, + name = name.toEntity(), + creationTime = creationTime.seconds, + trashTime = trashTimeOrNull?.seconds, + nameAuthor = nameAuthor.toEntity(), + author = author.toEntity(), + errors = errorsList.map { it.toEntity() }, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt new file mode 100644 index 00000000..f775539d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt @@ -0,0 +1,23 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.DegradedRevision +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.claimedDigestsOrNull +import proton.drive.sdk.claimedModificationTimeOrNull +import proton.drive.sdk.contentAuthorOrNull + +fun ProtonDriveSdk.DegradedRevision.toEntity() = DegradedRevision( + uid = uid, + creationTime = creationTime.seconds, + sizeOnCloudStorage = sizeOnCloudStorage, + claimedSize = if (hasClaimedSize()) claimedSize else null, + claimedDigests = claimedDigestsOrNull?.toEntity(), + claimedModificationTime = claimedModificationTimeOrNull?.seconds, + thumbnails = thumbnailsList.map { it.toEntity() }, + additionalClaimedMetadata = if (additionalClaimedMetadataList.isNotEmpty()) { + additionalClaimedMetadataList.map { it.toEntity() } + } else null, + contentAuthor = contentAuthorOrNull?.toEntity(), + canDecrypt = canDecrypt, + errors = errorsList.map { it.toEntity() }, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt new file mode 100644 index 00000000..3d2ff347 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.DriveError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.DriveError.toEntity(): DriveError = DriveError( + message = message, + innerError = if (hasInnerError()) innerError.toEntity() else null, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt new file mode 100644 index 00000000..d9b56fe8 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.FileContentDigests +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.FileContentDigests.toEntity() = FileContentDigests( + sha1 = if (sha1.isEmpty) null else sha1.toByteArray().toHexString(), +) + +private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt new file mode 100644 index 00000000..5b08889a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt @@ -0,0 +1,19 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.FileNode +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.trashTimeOrNull + +fun ProtonDriveSdk.FileNode.toEntity() = FileNode( + uid = uid, + parentUid = parentUid, + treeEventScopeId = treeEventScopeId, + name = name, + mediaType = mediaType, + creationTime = creationTime.seconds, + trashTime = trashTimeOrNull?.seconds, + nameAuthor = nameAuthor.toEntity(), + author = author.toEntity(), + activeRevision = activeRevision.toEntity(), + totalSizeOnCloudStorage = totalSizeOnCloudStorage, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderChildrenList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderChildrenList.kt new file mode 100644 index 00000000..033a0331 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderChildrenList.kt @@ -0,0 +1,7 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.NodeResult +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.FolderChildrenList.toEntity(): List = + childrenList.map { it.toEntity() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt deleted file mode 100644 index be02cfad..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NameAuthor.kt +++ /dev/null @@ -1,10 +0,0 @@ -package me.proton.drive.sdk.extension - -import me.proton.drive.sdk.entity.Author -import me.proton.drive.sdk.entity.AuthorResult -import proton.drive.sdk.ProtonDriveSdk - -fun ProtonDriveSdk.AuthorResult.toEntity() = AuthorResult( - author = Author(emailAddress = author.emailAddress), - signatureVerificationError = signatureVerificationError, -) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt new file mode 100644 index 00000000..f325b440 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt @@ -0,0 +1,37 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonDriveException +import me.proton.drive.sdk.entity.DriveError +import me.proton.drive.sdk.entity.NodeResult + +fun NodeResult.getOrThrow(): NodeResult.Value = when (this) { + is NodeResult.Value -> this + is NodeResult.Error -> throw node.errors.toException("Node failure") +} + +fun NodeResult.getOrNull(): NodeResult.Value? = when (this) { + is NodeResult.Value -> this + is NodeResult.Error -> null +} + +private fun List.toException(message: String) = ProtonDriveException(message).apply { + this@toException.forEach { driveError -> + addSuppressed( + exception = ProtonDriveException( + message = driveError.message, + cause = driveError.innerError?.let { + ProtonDriveException( + message = it.message, + cause = it.innerError?.toException(), + ) + } + ) + ) + } +} + +private fun DriveError.toException(): ProtonDriveException = + ProtonDriveException( + message = message, + cause = innerError?.toException() + ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt new file mode 100644 index 00000000..23f9e11b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt @@ -0,0 +1,12 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.PhotosTimelineItem +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.PhotosTimelineList.toEntity(): List = + itemsList.map { it.toEntity() } + +fun ProtonDriveSdk.PhotosTimelineItem.toEntity() = PhotosTimelineItem( + nodeUid = nodeUid, + captureTime = captureTime.seconds, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNodeResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNodeResult.kt new file mode 100644 index 00000000..82b78760 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNodeResult.kt @@ -0,0 +1,31 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.DegradedNode +import me.proton.drive.sdk.entity.Node +import me.proton.drive.sdk.entity.NodeResult +import proton.drive.sdk.ProtonDriveSdk + + +fun ProtonDriveSdk.NodeResult.toEntity(): NodeResult = + when (resultCase) { + ProtonDriveSdk.NodeResult.ResultCase.VALUE -> NodeResult.Value(value.toEntity()) + ProtonDriveSdk.NodeResult.ResultCase.ERROR -> NodeResult.Error(error.toEntity()) + ProtonDriveSdk.NodeResult.ResultCase.RESULT_NOT_SET, null -> + error("Invalid NodeResult: result not set") + } + +fun ProtonDriveSdk.Node.toEntity(): Node = + when (nodeCase) { + ProtonDriveSdk.Node.NodeCase.FOLDER -> folder.toEntity() + ProtonDriveSdk.Node.NodeCase.FILE -> file.toEntity() + ProtonDriveSdk.Node.NodeCase.NODE_NOT_SET, null -> + error("Invalid Node: result not set") + } + +fun ProtonDriveSdk.DegradedNode.toEntity(): DegradedNode = + when (nodeCase) { + ProtonDriveSdk.DegradedNode.NodeCase.FOLDER -> folder.toEntity() + ProtonDriveSdk.DegradedNode.NodeCase.FILE -> file.toEntity() + ProtonDriveSdk.DegradedNode.NodeCase.NODE_NOT_SET, null -> + error("Invalid DegradedNode: result not set") + } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt new file mode 100644 index 00000000..72fa82d8 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt @@ -0,0 +1,20 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.FileRevision +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.claimedModificationTimeOrNull +import proton.drive.sdk.contentAuthorOrNull + +fun ProtonDriveSdk.FileRevision.toEntity() = FileRevision( + uid = uid, + creationTime = creationTime.seconds, + sizeOnCloudStorage = sizeOnCloudStorage, + claimedSize = if (hasClaimedSize()) claimedSize else null, + claimedDigests = claimedDigests.toEntity(), + claimedModificationTime = claimedModificationTimeOrNull?.seconds, + thumbnails = thumbnailsList.map { it.toEntity() }, + additionalClaimedMetadata = if (additionalClaimedMetadataList.isNotEmpty()) { + additionalClaimedMetadataList.map { it.toEntity() } + } else null, + contentAuthor = contentAuthorOrNull?.toEntity(), +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt new file mode 100644 index 00000000..4fc9c574 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt @@ -0,0 +1,18 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonDriveException +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.StringResult.toEntity(): Result = + when (resultCase) { + ProtonDriveSdk.StringResult.ResultCase.VALUE -> + Result.success(value) + + ProtonDriveSdk.StringResult.ResultCase.ERROR -> + Result.failure( + ProtonDriveException(error.message) + ) + + ProtonDriveSdk.StringResult.ResultCase.RESULT_NOT_SET, null -> + error("Invalid StringResult: result not set") + } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailHeader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailHeader.kt new file mode 100644 index 00000000..477166d9 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailHeader.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.ThumbnailHeader +import me.proton.drive.sdk.entity.ThumbnailType +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.ThumbnailHeader.toEntity() = ThumbnailHeader( + id = id, + type = when (type) { + ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_THUMBNAIL -> ThumbnailType.THUMBNAIL + ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_PREVIEW -> ThumbnailType.PREVIEW + else -> error("Invalid thumbnail type: $type") + }, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt new file mode 100644 index 00000000..ac4ee87d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt @@ -0,0 +1,45 @@ +package me.proton.drive.sdk.internal + +import com.google.protobuf.kotlin.toByteString +import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.ProtonDriveSdkException +import me.proton.drive.sdk.converter.AnyConverter +import me.proton.drive.sdk.extension.toException +import proton.sdk.ProtonSdk +import proton.sdk.ProtonSdk.Response.ResultCase.ERROR +import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET +import proton.sdk.ProtonSdk.Response.ResultCase.VALUE +import java.nio.ByteBuffer +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Suppress("TooGenericExceptionCaught") +class ContinuationValueOrNullResponse( + private val deferred: CancellableContinuation, + private val anyConverter: AnyConverter, +) : ResponseCallback { + override fun invoke(data: ByteBuffer) { + try { + val parseFrom = ProtonSdk.Response.parseFrom(data) + when (parseFrom.resultCase) { + VALUE -> { + check(parseFrom.value.typeUrl == anyConverter.typeUrl) { + "Wrong converter for ${parseFrom.value.typeUrl} (${anyConverter.typeUrl})" + } + deferred.resume(anyConverter.convert(parseFrom.value)) + } + + RESULT_NOT_SET -> deferred.resume(null) + ERROR -> deferred.resumeWithException(parseFrom.error.toException()) + null -> deferred.resume(null) + } + } catch (error: Throwable) { + deferred.resumeWithException( + ProtonDriveSdkException( + message = "Cannot parse message: ${data.toByteString().toStringUtf8()}", + cause = error, + ) + ) + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index d41c4a53..5de53e21 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -3,6 +3,7 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.converter.FileThumbnailListConverter +import me.proton.drive.sdk.converter.FolderChildrenListConverter import me.proton.drive.sdk.converter.FolderNodeConverter import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.extension.LongResponseCallback @@ -102,6 +103,19 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { driveClientCreateFolder = request } + suspend fun getMyFilesFolder( + request: ProtonDriveSdk.DriveClientGetMyFilesFolderRequest, + ): ProtonDriveSdk.FolderNode = executeOnce("getMyFilesFolder", FolderNodeConverter().asCallback) { + driveClientGetMyFilesFolder = request + } + + suspend fun enumerateFolderChildren( + request: ProtonDriveSdk.DriveClientEnumerateFolderChildrenRequest, + ): ProtonDriveSdk.FolderChildrenList = + executeOnce("enumerateFolderChildren", FolderChildrenListConverter().asCallback) { + driveClientEnumerateFolderChildren = request + } + fun free(handle: Long) { dispatch("free") { driveClientFree = driveClientFreeRequest { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index 6bb1ada9..bcd900c6 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -3,9 +3,13 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.converter.FileThumbnailListConverter +import me.proton.drive.sdk.converter.FolderNodeConverter +import me.proton.drive.sdk.converter.NodeResultConverter +import me.proton.drive.sdk.converter.PhotosTimelineListConverter import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.asCallback +import me.proton.drive.sdk.extension.asNullableCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.drivePhotosClientCreateFromSessionRequest @@ -80,6 +84,26 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { drivePhotosClientEnumeratePhotosThumbnails = request } + suspend fun getPhotosRoot( + request: ProtonDriveSdk.DrivePhotosClientGetPhotosRootRequest, + ): ProtonDriveSdk.FolderNode = executeOnce("getPhotosRoot", FolderNodeConverter().asCallback) { + drivePhotosClientGetPhotosRoot = request + } + + suspend fun enumeratePhotosTimeline( + request: ProtonDriveSdk.DrivePhotosClientEnumeratePhotosTimelineRequest, + ): ProtonDriveSdk.PhotosTimelineList = + executeOnce("enumeratePhotosTimeline", PhotosTimelineListConverter().asCallback) { + drivePhotosClientEnumeratePhotosTimeline = request + } + + suspend fun getNode( + request: ProtonDriveSdk.DrivePhotosClientGetNodeRequest, + ): ProtonDriveSdk.NodeResult? = + executeOnce("getNode", NodeResultConverter().asNullableCallback) { + drivePhotosClientGetNode = request + } + fun free(handle: Long) { dispatch("free") { drivePhotosClientFree = drivePhotosClientFreeRequest { From d74e9ca3c4d7aa979e6abb84327641fc096bacb2 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Feb 2026 15:50:11 +0000 Subject: [PATCH 495/791] Add seek to photo download --- .../Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs | 3 ++- .../src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs | 2 +- cs/sdk/src/protos/proton.drive.sdk.proto | 3 ++- .../main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt | 7 +++++++ .../me/proton/drive/sdk/internal/JniPhotosDownloader.kt | 5 +++++ 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs index 9e8137f5..891dfd35 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -15,8 +15,9 @@ public static IMessage HandleDownloadToStream(DrivePhotosClientDownloadToStreamR var downloader = Interop.GetFromHandle(request.DownloaderHandle); var writeFunction = new InteropFunction, nint, nint>(request.WriteAction); + var seekAction = request.SeekAction != 0 ? new InteropAction, nint>(request.SeekAction) : (InteropAction, nint>?)null; var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null; - var stream = new InteropStream(bindingsHandle, writeFunction, cancelAction: cancelAction); + var stream = new InteropStream(bindingsHandle, writeFunction, seekAction, cancelAction); var progressAction = new InteropAction>(request.ProgressAction); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 09357849..f6dc700a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -150,7 +150,7 @@ private async Task WriteNextBlockToOutputAsync( try { plaintextStream.Seek(0, SeekOrigin.Begin); - var initialOutputPosition = outputStream.Position; + var initialOutputPosition = outputStream.CanSeek ? outputStream.Position : 0; try { diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index b021c5fe..bf2b7f58 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -630,7 +630,8 @@ message DrivePhotosClientDownloadToStreamRequest { int64 write_action = 2; // C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 3; // See array_action in C header file for signature int64 cancellation_token_source_handle = 4; - int64 cancel_action = 5; // Optional, C signature: void on_cancel(intptr_t bindings_handle); Signals the bindings to cancel the current stream operation. + int64 seek_action = 5; // Optional, C signature: void on_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); + int64 cancel_action = 6; // Optional, C signature: void on_cancel(intptr_t bindings_handle); Signals the bindings to cancel the current stream operation. } // The response value must be an Int64Value carrying a handle to an instance of DownloadController. diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index 8b3d441a..6e65792f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -4,11 +4,13 @@ import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.extension.seek import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniPhotosDownloader import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId +import java.nio.channels.SeekableByteChannel import java.nio.channels.WritableByteChannel import java.util.concurrent.atomic.AtomicReference @@ -30,6 +32,11 @@ class PhotosDownloader internal constructor( handle = handle, cancellationTokenSourceHandle = cancellationTokenSource.handle, onWrite = channel::write, + onSeek = if (channel is SeekableByteChannel) { + channel::seek + } else { + null + }, onProgress = { progressUpdate -> with(progressUpdate) { bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt index 20e3198c..0e9b8ae0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt @@ -26,6 +26,7 @@ class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { handle: Long, cancellationTokenSourceHandle: Long, onWrite: suspend (ByteBuffer) -> Unit, + onSeek: ((Long, Int) -> Long)? = null, onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, coroutineScopeProvider: CoroutineScopeProvider, ): Long = executePersistent( @@ -34,6 +35,7 @@ class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { name = method("downloadToStream"), response = continuation.toLongResponse().asClientResponseCallback(), write = onWrite, + seek = onSeek, progress = onProgress, logger = internalLogger, coroutineScopeProvider = coroutineScopeProvider, @@ -47,6 +49,9 @@ class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { writeAction = ProtonDriveSdkNativeClient.getWritePointer() progressAction = ProtonDriveSdkNativeClient.getProgressPointer() cancelAction = JniJob.getCancelPointer() + if (onSeek != null) { + seekAction = ProtonDriveSdkNativeClient.getSeekPointer() + } } } } From fd3fe779511bacf186b78b2d6c208da18225c7fb Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 01:54:39 +0000 Subject: [PATCH 496/791] Remove Photo from telemetry VolumeType --- cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs | 1 - .../Nodes/Download/PhotosFileDownloader.cs | 2 +- cs/sdk/src/protos/proton.drive.sdk.proto | 2 +- .../kotlin/me/proton/drive/sdk/extension/VolumeType.kt | 2 +- .../kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt | 2 +- .../Sources/TelemetryAndLogging/TelemetryTypes.swift | 8 ++++---- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs index 9ebf4def..e381d6c1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs @@ -6,5 +6,4 @@ public enum VolumeType Shared, SharedPublic, OwnPhotoVolume, - Photo, } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs index 1f6f0fb0..880ed578 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -107,7 +107,7 @@ private DownloadController DownloadToStream( var downloadEvent = new DownloadEvent { DownloadedSize = 0, - VolumeType = VolumeType.Photo, + VolumeType = VolumeType.OwnPhotoVolume, }; var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index bf2b7f58..994fb1d5 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -716,7 +716,7 @@ enum VolumeType { VOLUME_TYPE_OWN_VOLUME = 0; VOLUME_TYPE_SHARED = 1; VOLUME_TYPE_SHARED_PUBLIC = 2; - VOLUME_TYPE_PHOTO = 3; + VOLUME_TYPE_OWN_PHOTO_VOLUME = 3; } enum DownloadError { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt index 63e4632f..10b81b78 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt @@ -7,6 +7,6 @@ fun ProtonDriveSdk.VolumeType.toEnum() = when (this) { ProtonDriveSdk.VolumeType.VOLUME_TYPE_OWN_VOLUME -> VolumeType.OWN_VOLUME ProtonDriveSdk.VolumeType.VOLUME_TYPE_SHARED -> VolumeType.SHARED ProtonDriveSdk.VolumeType.VOLUME_TYPE_SHARED_PUBLIC -> VolumeType.SHARED_PUBLIC - ProtonDriveSdk.VolumeType.VOLUME_TYPE_PHOTO -> VolumeType.PHOTO + ProtonDriveSdk.VolumeType.VOLUME_TYPE_OWN_PHOTO_VOLUME -> VolumeType.OWN_PHOTO_VOLUME ProtonDriveSdk.VolumeType.UNRECOGNIZED -> VolumeType.UNRECOGNIZED } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt index 0f1b049e..7ab38495 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt @@ -5,5 +5,5 @@ enum class VolumeType { OWN_VOLUME, SHARED, SHARED_PUBLIC, - PHOTO, + OWN_PHOTO_VOLUME, } diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift index 73333a8b..bc4c126b 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -141,8 +141,8 @@ public enum VolumeType: Int, Sendable { case ownVolume = 0 case shared = 1 case sharedPublic = 2 - case photo = 3 - + case ownPhotoVolume = 3 + init(sdkVolumeType: Proton_Drive_Sdk_VolumeType) { switch sdkVolumeType { case .ownVolume: @@ -151,8 +151,8 @@ public enum VolumeType: Int, Sendable { self = .shared case .sharedPublic: self = .sharedPublic - case .photo: - self = .photo + case .ownPhotoVolume: + self = .ownPhotoVolume case .UNRECOGNIZED(let value): assertionFailure("Received unrecognized VolumeType from the SDK \(value)") self = .unknown From 815cdf800cf0e4883c8548d1d3bc7bc37552d907 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Feb 2026 07:24:56 +0000 Subject: [PATCH 497/791] Make author and signature verification error mutually exclusive in interop --- .../InteropProtonDriveClient.cs | 15 ++++++++--- cs/sdk/src/protos/proton.drive.sdk.proto | 11 ++++++-- .../drive/sdk/extension/AuthorResult.kt | 26 ++++++++++++------- .../Sources/Plumbing/PublicTypes.swift | 14 ++++++++-- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 67e49295..58ef6f10 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -296,12 +296,21 @@ public static AuthorResult ParseAuthorResult(Result = if (signatureVerificationError.isEmpty()) { - Result.success(author.toEntity()) -} else { - Result.failure( - SignatureVerificationException( - claimedAuthor = author.toEntity(), - message = signatureVerificationError - ) - ) -} +fun ProtonDriveSdk.AuthorResult.toEntity(): Result = + when (resultCase) { + ProtonDriveSdk.AuthorResult.ResultCase.VALUE -> + Result.success(value.toEntity()) + + ProtonDriveSdk.AuthorResult.ResultCase.ERROR -> + Result.failure( + SignatureVerificationException( + claimedAuthor = error.claimedAuthor.toEntity(), + message = error.message + ) + ) + + ProtonDriveSdk.AuthorResult.ResultCase.RESULT_NOT_SET, null -> + error("Invalid AuthorResult: result not set") + } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index e97f1731..d58e6d9d 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -150,13 +150,23 @@ public struct FolderNode: Sendable { } } +// FIXME: Preserve distinction between verified and claimed email addresses to match original interface. public struct Author: Sendable { let emailAddress: String? let signatureVerificationError: String? init(result: Proton_Drive_Sdk_AuthorResult) { - self.emailAddress = result.author.emailAddress - self.signatureVerificationError = result.hasSignatureVerificationError ? result.signatureVerificationError : nil + switch result.result { + case .value(let author): + self.emailAddress = author.emailAddress + self.signatureVerificationError = nil + case .error(let error): + self.emailAddress = error.claimedAuthor.emailAddress + self.signatureVerificationError = error.message + case .none: + self.emailAddress = nil + self.signatureVerificationError = "Invalid AuthorResult: no value or error set" + } } } From f5cfd503e4222030f32fe7b084c1b0482aa9caa7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 09:22:06 +0100 Subject: [PATCH 498/791] Log "is paused" state for download too --- .../kotlin/me/proton/drive/sdk/CommonDownloadController.kt | 6 ++++-- .../kotlin/me/proton/drive/sdk/CommonUploadController.kt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt index b4c40b5a..4078383b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -53,8 +53,10 @@ class CommonDownloadController internal constructor( coroutineScopeConsumer(null) } - override suspend fun isPaused() = bridge.isPaused(handle) - .also { isPausedFlow.emit(it) } + override suspend fun isPaused() = bridge.isPaused(handle).also { paused -> + log(DEBUG, "isPaused: $paused") + isPausedFlow.emit(paused) + } override suspend fun isDownloadCompleteWithVerificationIssue(): Boolean { log(DEBUG, "isDownloadCompleteWithVerificationIssue") diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt index bdac79c2..f8325acf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt @@ -74,6 +74,6 @@ class CommonUploadController internal constructor( } private fun log(level: LoggerProvider.Level, message: String) { - bridge.clientLogger(level, "UploadController(${handle.toLogId()}) $message") + bridge.clientLogger(level, "CommonUploadController(${handle.toLogId()}) $message") } } From c9d1b72a054fc1c2a03280eb826dd6f215b5a12d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 08:53:44 +0000 Subject: [PATCH 499/791] Verify C# build for published source code --- cs/nuget.config | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 cs/nuget.config diff --git a/cs/nuget.config b/cs/nuget.config new file mode 100644 index 00000000..12130aac --- /dev/null +++ b/cs/nuget.config @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + From 1a8414ea50db493ba0e5a04a60b62eaf8c3cde9b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 14:08:18 +0100 Subject: [PATCH 500/791] [DRVWEB-5135] Add empty trash for photo volume --- .gitignore | 1 + README.md | 16 ++++++++-------- js/sdk/src/protonDrivePhotosClient.ts | 8 +++----- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index df5975e0..21989ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ cache*.sqlite # VS Code .vs +.vscode # Intellij .idea diff --git a/README.md b/README.md index fdb847f7..24a13946 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,18 @@ The SDK may be used for personal, non-commercial projects. If you choose to buil ### Technical Requirements -| Requirement | Description | -|-------------|-------------| -| **Use the SDK** | Always interact with Proton Drive through the SDK. Direct API calls are not permitted. | -| **Use official endpoints** | All HTTP requests must be directed to the official Proton Drive domain. Do not modify or proxy API endpoints to different domains. | +| Requirement | Description | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Use the SDK** | Always interact with Proton Drive through the SDK. Direct API calls are not permitted. | +| **Use official endpoints** | All HTTP requests must be directed to the official Proton Drive domain. Do not modify or proxy API endpoints to different domains. | | **Identify your application** | Set the `x-pm-appversion` HTTP header using the format `external-drive-{projectname}@{version}` (e.g., `external-drive-myapp@1.2.3`). This header must accurately represent your application. Do not spoof or falsify this value. | -| **Use event-based sync** | Synchronize data using Drive events. Do not poll the API or perform frequent recursive traversals of the file tree. | +| **Use event-based sync** | Synchronize data using Drive events. Do not poll the API or perform frequent recursive traversals of the file tree. | ### Branding and User Safety Requirements -| Requirement | Description | -|-------------|-------------| -| **No Proton branding** | Your application must not use Proton logos, trademarks, or design elements. It must be clearly distinguishable as an unofficial, third-party product. | +| Requirement | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **No Proton branding** | Your application must not use Proton logos, trademarks, or design elements. It must be clearly distinguishable as an unofficial, third-party product. | | **Credential handling disclosure** | Users must be explicitly warned that they are entering credentials into a non-official application. Passwords must never be stored by your application. | Failure to comply with these requirements may result in access restrictions. diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 8a74f128..61129db2 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -299,13 +299,11 @@ export class ProtonDrivePhotosClient { } /** - * Empty the trash. - * - * See `ProtonDriveClient.emptyTrash` for more information. + * Empty the trash for the photos volume. */ async emptyTrash(): Promise { - this.logger.info('Emptying trash'); - throw new Error('Method not implemented'); + this.logger.info('Emptying photo volume trash'); + return this.nodes.management.emptyTrash(); } /** From 59eb136b0fa2c2a3215feb8a57f8e5494285a643 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 09:41:38 +0000 Subject: [PATCH 501/791] Update changelog for cs/v0.7.0-alpha.5 --- cs/CHANGELOG.md | 331 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 cs/CHANGELOG.md diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md new file mode 100644 index 00000000..332b84f2 --- /dev/null +++ b/cs/CHANGELOG.md @@ -0,0 +1,331 @@ +# Changelog + +## cs/v0.7.0-alpha.5 (2026-02-05) + +* Verify C# build for published source code +* Log "is paused" state for download too +* Check is controller is paused instead of looking at the domain error +* Make author and signature verification error mutually exclusive in interop +* Remove Photo from telemetry VolumeType +* Add seek to photo download +* Use SDK to get nodes in tests +* Expose functions to get nodes and enumerate folder children through interop layer +* Add photo upload and xAttr support to Swift bindings +* Use unconfined dispatcher +* Set coroutine context of operation and function to Dispatchers.IO +* Rename Jni* methods to match proto requests + +## cs/v0.7.0-alpha.4 (2026-01-30) + +* Fix files being truncated when downloading to file path through interop +* Follow up on download pausing to address issues with hanging, seeking with interop and telemetry +* Automate open-sourcing +* Fix timeout reported as cancellation through interop + +## cs/v0.7.0-alpha.3 (2026-01-27) + +* Transform progress callback to flow +* Implement pausing and resuming of downloads +* Fix Swift package signing +* Add photos client kotlin bindings for upload +* Handle and send decryption error telemetry to client +* Enable request body streaming for upload + +## cs/v0.7.0-alpha.2 (2026-01-26) + +* Fix location of Photos project +* Make cache optional +* Set version of SDK in swift builds +* Log ignored errors +* Add file upload methods to the Photos client +* Replace stream with buffer for HTTP + +## cs/v0.7.0-alpha.1 (2026-01-23) + +* Enforce static code analysis warnings as errors on release builds +* Replace stream by channel for thumbnails +* Replace stream with channel +* Add node metadata decryption error metrics +* Get Swift signing certificate from CI variables +* Fix native clients getting garbage collected during long request to the sdk +* Add Kotlin tests for pausing and resuming downloads +* Fix error not caught or returned to the sdk when scope was null +* Add getThumbnails to DrivePhotosClient +* Remove copyrights + +## cs/v0.6.1-alpha.17 (2026-01-20) + +* Fix errors not caught in Kotlin bindings and crashing client +* Remove unnecessary parameter from .BeginTransaction calls + +## cs/v0.6.1-alpha.16 (2026-01-19) + +* Improve cache DB transaction locking behavior +* Implement delayed cancellation for reading content during upload + +## cs/v0.6.1-alpha.15 (2026-01-16) + +* Adding Photos SDK bindings +* Propagate encryption key via client configuration in swift bindings + +## cs/v0.6.1-alpha.14 (2026-01-16) + +* Improve on-disk cache handling +* Update driveClientCreate to use ProtonDriveClientOptions and timeouts +* Fix download photos from album +* Add ability to override HTTP timeouts + +## cs/v0.6.1-alpha.13 (2026-01-15) + +* Fix build error due to missing brace in Protobuf definition +* Implement support for protecting SDK databases +* Expose functions to trash node through Swift package +* refactor: consolidate PhotoDownloadOperation into DownloadOperation +* Fix failure to resume upload that has gaps in block upload completions +* Implement 429 handling for block downloads +* Log paused status for each call +* Expose folder creation in interop and Kotlin bindings +* Update coroutine scope when resume +* Introduce PhotoDownloadOperation +* Simplify implementation for pausing uploads +* Add Kotlin bindings for rename +* Ignore cancellation error after cancelling in download test +* Expose folder creation in interop and Swift bindings +* Add support for photo decryption through album key packet + +## cs/v0.6.1-alpha.12 (2026-01-09) + +* Prevent download cancellation from blocking future downloads +* Downloading empty file now report metric +* Add Kotlin bindings for isPaused +* Reduce network log level for tests from debug to verbose + +## cs/v0.6.1-alpha.11 (2026-01-08) + +* Fix builds for Kotlin and Swift bindings broken due to Experimental attribute +* Handle 429 responses on block uploads + +## cs/v0.6.1-alpha.10 (2026-01-07) + +* Fix InteropStream length initialization for write streams +* Implement initial photos client interop +* Interop and bindings for DownloadController.GetIsDownloadCompleteWithVerificationIssue +* Avoid logging storage body for test +* Map download integrity exception to integrity domain for interop + +## cs/v0.6.1-alpha.9 (2026-01-06) + +* Pause upload on timeout +* Fix progress logs in kotlin + +## cs/v0.6.1-alpha.8 (2026-01-04) + +* Switch to SQLite-free implementation for in-memory caching +* Expose function to rename node through Swift package +* Update download error handling +* Limit GC pressure by creating less Channel instances +* Add levels to logs + +## cs/v0.6.1-alpha.7 (2025-12-22) + +* Update swift dependencies +* Reapply removed upload controller dispose calls +* Move incomplete draft deletion to upload controller disposal +* Fix shares and share secrets not being cached +* Expose download integrity errors and download status + +## cs/v0.6.1-alpha.6 (2025-12-19) + +* Fix download retrying on cancellation +* Pass error when operation is paused to the client. Prevent crashes for calls after operation throws. + +## cs/v0.6.1-alpha.5 (2025-12-19) + +* Add cancellation message when CS cancels a job +* Fix download failures due to missing keys for manifest check +* Cancel CancellationTokenSource when coroutine scope is cancelled executing blocking function +* Add photos thumbnail downloader +* Update telemetry error mapping +* Implement pausing and resuming of uploads +* Fix exception on retrying thumbnail block upload +* Add photo downloader +* Add Photos client and Photos volume creation +* Extract Job code from JniDriveClient +* Test upload and download events +* Convert stateless JNI methods to static +* Log swallowed exceptions +* Propagate exception to interop logger + +## cs/v0.6.1-alpha.4 (2025-12-15) + +* No changes + +## cs/v0.6.1-alpha.3 (2025-12-15) + +* Prefix the SDK static lib name for Swift with `lib`. Use non-macOS runner for SPM release. +* Adds the pause, resume and isPaused calls to Swift bindings for upload and download + +## cs/v0.6.1-alpha.2 (2025-12-11) + +* No changes + +## cs/v0.6.1-alpha.1 (2025-12-11) + +* Fix build of Swift bindings on CI +* Attach current thread only when detached +* Reduce log level and normalize logs +* Keep reference to logger provider in Kotlin test +* Set error type to the name of the Kotlin exception +* Improve error generation and parsing in Swift bindings +* Check optional proto fields +* Add properties to query paused state of upload and download +* Prevent download from seeking back in output stream +* Add error handling for writing to output stream +* Add support to C# CLI for downloading by node UID +* Increase number of attempts for block transfers +* Revamp CI pipelines +* Remove debug log with fatal level + +## cs/v0.6.0-test.2 (2025-12-04) + +* Revamp CI pipelines + +## cs/v0.6.0-alpha.7 (2025-12-10) + +* Set error type to the name of the Kotlin exception + +## cs/v0.6.0-alpha.6 (2025-12-10) + +* Improve error generation and parsing in Swift bindings + +## cs/v0.6.0-alpha.5 (2025-12-09) + +* Check optional proto fields +* Add properties to query paused state of upload and download +* Prevent download from seeking back in output stream +* Add error handling for writing to output stream +* Add support to C# CLI for downloading by node UID + +## cs/v0.6.0-alpha.4 (2025-12-05) + +* Increase number of attempts for block transfers +* Revamp CI pipelines +* Remove debug log with fatal level +* Fix SPM deployment script +* Fix CLI lacking parallelism when downloading multiple files + +## cs/v0.6.0-alpha.3 (2025-12-04) + +* Upgrade version from 0.6.0-alpha.1 to 0.6.0-alpha.3 +* Bump crypto lib to handle decrypted AEAD session key exports +* Include source commit SHA in release's commit message +* Fix missing artifact requirements for publishing Kotlin package +* Improve performance of iterating over URLSession.AsyncBytes during download +* Handle degraded node + +## cs/v0.6.0-alpha.1 (2025-12-02) + +* Bump Kotlin package version +* Fix Kotlin build failure due to Protobuf changes +* Implement telemetry for download +* Fix crashes when download is interrupted +* Add Kotlin bindings for feature flags +* Remove unused parameter +* Fix CLI resilience retrying even on successful round trips +* Fix address verification happening too early +* Include the Swift's error message in the SDK interop error +* Add auto-retries into HTTP client bridge for certain HTTP errors: 401, 429, 5xx +* Add HTTP timeouts and ability to cancel requests through interop +* Handle diverging size on upload +* Address security review of C# crypto +* Preserve interop errors passing through SDK +* Allow multiple calls to override native library name +* Replace option to disable HTTP retries with a request type +* Delay opening upload stream until necessery +* Upgrade version from 0.4.0 to 0.5.0 +* Add hint to disable retries on HTTP requests +* Close properly response body when read +* Add proguard rules to keep protobuf classes to be optimized +* Fix the crypto library name +* Add more logging to transfer queues +* Use streaming in HTTP client +* Add AEAD support +* Add approximate upload size to upload metric event in kt binding +* Improve mapping of SDK exceptions to Kotlin errors +* Add approximate upload size to upload metric event +* Upgrade version from 0.3.1 to 0.4.0 +* Align rules of CI build jobs related to C# SDK +* Parse Protobuf request within the same JNI call +* Support client-injected feature flags in Swift +* Remove copyrights and optimize imports +* Add filtering by type to thumbnail enumeration +* Enable building Swift package with support for both Silicon and Intel iOS simulators +* Fix missing disposal of file uploader and file downloader through interop +* Add pause and resume API +* Add Kotlin bindings package for Android +* Make feature flag provision asynchronous +* Add feature flag support +* Fix cancellation token source being double-freed in the Swift interop +* Android/submodule +* Fix wrong additional metadata parameters in upload +* Tweak CI for SPM build +* Add possibility to provide additional metadata on file upload +* Add method to download thumbnails +* Add empty and thumbnail file uploads for cross client +* Pass node name conflict error data through interop +* Add unit test to verify fix for hanging download due to unreleased semaphore +* Fix blocks not being released during download +* Expose cancellation support in SDK bindings +* Add CI job to build and deploy Swift package +* Update client creation through interop to be able to set client UID +* Add telemetry for uploads +* Expose function to get available node name through Swift package +* Fix logger +* Feat/parse error swift interop +* Fix possibility of missing domain and type on interop errors +* Fix missing SDK version header when injecting HTTP client without interop +* Fix progress callback doesn't report issue +* Fix thumbnails causing upload to hang +* Fix deserialization error on getting available names +* Add Swift SDK package for iOS & macOS +* Fix download error due to misuse of new URL block fields +* Fix error on HTTP response with Expires header when using interop +* Fix deserialization error on download +* Apply server time to PGP when injecting the HTTP client through interop +* Improve logging and clean up some code +* Fix SHA1 extended attribute +* Align JSON output of the C# CLI with the JavaScript one +* Add support for 16KB pages and ARMv7 platform on Android +* Fix conflicting draft deletion failure +* Fix old revision UID being returned instead of new one after revision upload +* Fix various interop issues found after enabling HTTP client injection + +## cs/v0.1.0-alpha.3 (2025-10-14) + +* Fix conflicting draft deletion failure +* Fix old revision UID being returned instead of new one after revision upload +* Fix thumbnail type enum +* Allow logger provider handle for drive client creation +* Add logging for upload and session +* Make some naming clearer +* Make thumbnail type strongly-typed in Protobufs +* Fix exception when returning HTTP response through interop +* Improve error message in case of invalid cast from interop handle + +## cs/0.6.0-alpha.3 (2025-12-04) + +* Upgrade version from 0.6.0-alpha.1 to 0.6.0-alpha.3 +* Bump crypto lib to handle decrypted AEAD session key exports +* Include source commit SHA in release's commit message +* Fix missing artifact requirements for publishing Kotlin package +* Improve performance of iterating over URLSession.AsyncBytes during download +* Handle degraded node + +## cs/0.6.0-alpha.1 (2025-12-02) + +* Upgrade version from 0.6.0-alpha.1 to 0.6.0-alpha.3 +* Bump crypto lib to handle decrypted AEAD session key exports +* Include source commit SHA in release's commit message +* Fix missing artifact requirements for publishing Kotlin package +* Improve performance of iterating over URLSession.AsyncBytes during download +* Handle degraded node From dc00923ed37b773f8a9f1438f3f4c4da19691b11 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 13:10:24 +0000 Subject: [PATCH 502/791] Update changelog for js/v0.9.7 --- js/CHANGELOG.md | 339 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 js/CHANGELOG.md diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md new file mode 100644 index 00000000..5f1c50ca --- /dev/null +++ b/js/CHANGELOG.md @@ -0,0 +1,339 @@ +# Changelog + +## js/v0.9.7 (2026-02-05) + +* [DRVWEB-5135] Add empty trash for photo volume +* Automate changelog + +## js/v0.9.6 (2026-02-02) + +* i18n(weekly-mr): Upgrade translations from crowdin (31132796). +* Add experimental createDocument to create Docs/Sheets +* Add function to create bookmark + +## js/v0.9.5 (2026-01-29) + +* js/v0.9.5 +* Remove check of NodeType inside iterateThumbnails +* Fix file with content check for diagnostics + +## js/v0.9.4 (2026-01-22) + +* js/v0.9.4 +* Add function to scan for malware +* Release lock after download and close the stream in diagnostics +* Report metrics from photos as own_photo_volume +* Fix default timeout on rate limit +* i18n: Upgrade translations from crowdin (3e7a896b). + +## js/v0.9.3 (2026-01-16) + +* js/v0.9.3 +* Debounce Account key requests for CLI +* Fix invitation node type +* Upgrade CryptoProxy and SRP + +## js/v0.9.2 (2026-01-13) + +* js/v0.9.2 +* Fix typing of CryptoProxy and CLI +* Add tree structure to diagnostics +* Multiple public fixes + +## js/v0.9.1 (2026-01-07) + +* js/v0.9.1 +* Handle timeouts during uploads +* Fix buffered seekable stream +* i18n: Upgrade translations from crowdin (2cb75ecb). +* Catch TypeError when calling releaseLock + +## js/v0.9.0 (2025-12-17) + +* js/v0.9.0 +* Allow download with signature issues +* Add empty-trash Implementation +* Handle failed upload due to double-commit attempt + +## js/v0.8.0 (2025-12-15) + +* js/v0.8.0 +* Use remove-mine for deleting nodes on public page +* Fix old content key packet verification +* Compress extended attributes + +## js/v0.7.3 (2025-12-12) + +* js/v0.7.3 +* Create findPhotoDuplicates to get uids of duplicates + +## js/v0.7.2 (2025-12-11) + +* js/v0.7.2 +* i18n: Upgrade translations from crowdin (5f7f1f9c). +* Fix photo node type +* Add getMyPhotosRootFolder + +## js/v0.7.1 (2025-12-08) + +* js/v0.7.1 +* Photos entity to support full decryption and access to photo attributes +* Add support to C# CLI for downloading by node UID +* Add onMessage to ProtonDrivePublicLinkClient +* Add modification time to the node entity +* Add new name param to copy + +## js/v0.7.0 (2025-11-28) + +* js/v0.7.0 +* Add unauth prefix for all API calls from public link context +* Ignore missing signatures on legacy nodes +* Abort uploads properly + +## js/v0.6.2 (2025-11-21) + +* js/v0.6.2 +* Fix deleting draft +* CaptureTime unix time was in milliseconds instead of seconds +* Make feature flag provision asynchronous +* Add feature flag support + +## js/v0.6.1 (2025-11-20) + +* js/v0.6.1 +* Add isDuplicatePhoto method +* Refresh node when share already exists +* Add diagnostics for Photos timeline +* Rename getOwnVolumeIDs to getRootIDs +* Add rename and delete for public link SDK +* Fix typo in class name +* Add create folder & upload for public link SDK +* Add diagnostic progress +* Ignore TimeoutError and similar from decryption issues + +## js/v0.6.0 (2025-10-24) + +* js/v0.6.0 +* Unify CLI parameters and docs +* Parametrize shared with me and invitations for Photos SDK +* Expose sharing for Photos SDK +* Add getAvailableName method + +## js/v0.5.1 (2025-10-22) + +* js/v0.5.1 +* Add expectedStrcuture options for diagnostics +* Convert revisions to public interface +* Remove console handlers for JSON outputs +* Update public access to new APIs +* Align JSON output of the C# CLI with the JavaScript one +* Return new UID of copied node +* Throw NodeWithSameNameExists from createFolder +* Fix app version for CLI app +* Json mode for web CLI +* Use shares/photos endpoint to bootstrap photos +* Add telemetry for debouncer +* Fix aborting uploads & downloads +* Make deleting share with force explicit + +## js/v0.5.0 (2025-10-03) + +* js/v0.5.0 +* Do not send cleartext file size +* Add propagating offline error to SDK events +* fileUpload completion should return nodeUid and nodeRevisionUid +* Use npm ci instead install +* Batch and split per volume trash/restore/delete nodes +* Abort decrypting nodes +* Handle abort errors +* [JS] Use the same instance of uploadController in stream upload +* Add CLI commands for invitation accept/reject +* Add CLI commands for public access +* Reuse endpoints for public link +* Add debouncer to avoid parallel loading of the same node +* Add functions to upload from and download to a file path + +## js/v0.4.1 (2025-09-24) + +* js/v0.4.1 +* Add isSharedPublicly to node based on ShareURLID +* Implement CLI photo download +* Implement photo upload + +## js/v0.4.0 (2025-09-22) + +* js/v0.4.0 +* Implement ProtonDrivePhotosClient basics +* Add filter options for listing children +* Add copyNodes +* Handle node out of sync during rename +* Return FastForward event if there is no relevant core event + +## js/v0.3.2 (2025-09-17) + +* js/v0.3.2 +* Fix SharedWithMe cache +* Reuse Node entity for public link access +* Add cause to wrapped errors +* Provide file progress in onProgress callback + +## js/v0.3.1 (2025-09-11) + +* js/v0.3.1 +* NotFoundAPIError is inherited from ValidationError +* Fix decrpyting bookmark with custom password +* Fix cache shared by me +* Revamp docs guides +* Add public access + +## js/v0.3.0 (2025-09-04) + +* js/v0.3.0 +* Fix cache in CLI +* Improve performance of loading shared with me +* Fix what address is used to invite users into the share +* Rename NodeAlreadyExistsValidationError +* Fix accepting entities and UIDs in the interface +* Revamp documentation +* Remove quark types after merge +* Add node details to diagnostic results + +## js/v0.2.1 (2025-08-20) + +* js/v0.2.1 +* Separate custom password from bookmark url +* Fix parsing claimedModificationTime in NodesCache +* Invalid value code is ValidationError +* Fix direct member role in tests + +## js/v0.2.0 (2025-08-14) + +* js/v0.2.0 +* Add node membership +* Update telemetry object +* Fix download +* Add download unit tests +* Add seeking support for download +* Add events ready info into CLI + +## js/v0.1.2 (2025-08-04) + +* js/v0.1.2 +* Fix event subscriptions +* Fix invalidating cache after upload + +## js/v0.1.1 (2025-08-01) + +* js/v0.1.1 +* Improve loading nodes performance +* Remove obsolete signature check on block download +* Return nodes integration test +* Add node.uid to proton invitation + fix invitation accept +* Export event types +* Run pretty on all sdk and cli source code + +## js/v0.1.0 (2025-07-29) + +* Refactor event manager: +* js/v0.1.0 +* Add diagnostic tool +* Add support of client UID +* Add integration test for moving node +* Fix move twice +* Add NumAccess to publicLink +* Support multiple volumes thumbnails +* Add prettier + +## js/v0.0.13 (2025-07-18) + +* js/v0.0.13 +* Add album node type +* Fix test of asyncIteratorMap +* Create draft when starting upload +* Parse claimedModificationTime on cache +* Decrypt nodes in parallel +* Filter out photos and albums from shared with me listing +* Set admin role for all nodes in own volume +* Fix env variable names in readme +* add existingNodeUid on NodeAlreadyExistsValidationError +* Fix publishing of npm packages + +## js/v0.0.12 (2025-07-10) + +* js/v0.0.12 + +## js/v0.0.11 (2025-07-10) + +* release js/v0.0.11 +* Remove sensitive info from logs +* Implement bookmarks management +* i18n: Upgrade translations from crowdin (f8f00ca2). +* Feedback from old MR's +* Add deprecated share ID +* Add fallback unknown error message +* Fix returning public revision +* Fix parsing node from cache +* Add integration tests for web SDK using real crypto module +* update user fixtures for easier handling +* Use ExpirationTime instead of ExpirationDuration for public link management +* Align error categories for upload/download telemetry with definitions +* Switch to public npm registry +* Add missing re-export of the interface +* Migrate to playwright + +## js/v0.0.10 (2025-06-26) + +* adding a deprecated shareId prop to the Device object +* add management of public links +* fix stuck loop in download +* fix download copy + +## js/v0.0.9 (2025-06-24) + +* release js/v0.0.9 +* Add resend invite implementation +* implement getNodeUid +* Update decryption telemetry according to documentation +* L10N-4186 Add test/extract job ttag +* Js/fix proxy typing +* Create type structure for keys + +## js/v0.0.8 (2025-06-19) + +* Bump to js/v0.0.8 +* use nodeUid for external invite instead of volumeId +* Make use of incremental build of tsc +* Update type of CryptoProxy +* signMessage accept signatureContext and not context + +## js/v0.0.7 (2025-06-18) + +* Pass nameSessionKey to moveNode + +## js/v0.0.6 (2025-06-17) + +* Allow to pass either single or multiple key to match CryptoProxy Api + +## js/v0.0.5 (2025-06-11) + +* Update JS package version to 0.0.5 +* add getNode method +* add block verification telemetry +* configuration for npm package publishing +* add experimental getDocsKey +* reuse array buffer +* fix getting address key +* e2e tests for download module +* handle missing public address + +## js/v0.0.4 (2025-06-02) + +* Update JS package version to 0.0.5 +* add getNode method +* add block verification telemetry +* configuration for npm package publishing +* add experimental getDocsKey +* reuse array buffer +* fix getting address key +* e2e tests for download module +* handle missing public address From 933531bbdefe47e037eb27e11a265ceb0de1037d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Feb 2026 06:18:26 +0000 Subject: [PATCH 503/791] Add album management --- js/sdk/src/internal/nodes/mediaTypes.ts | 3 + js/sdk/src/internal/nodes/nodesManagement.ts | 3 +- js/sdk/src/internal/photos/albums.test.ts | 241 ++++++++++++++++++ js/sdk/src/internal/photos/albums.ts | 119 +++++++++ .../src/internal/photos/albumsCrypto.test.ts | 181 +++++++++++++ js/sdk/src/internal/photos/albumsCrypto.ts | 100 ++++++++ js/sdk/src/internal/photos/apiService.ts | 96 ++++++- js/sdk/src/internal/photos/index.ts | 10 +- js/sdk/src/protonDrivePhotosClient.ts | 65 ++++- 9 files changed, 810 insertions(+), 8 deletions(-) create mode 100644 js/sdk/src/internal/photos/albums.test.ts create mode 100644 js/sdk/src/internal/photos/albumsCrypto.test.ts create mode 100644 js/sdk/src/internal/photos/albumsCrypto.ts diff --git a/js/sdk/src/internal/nodes/mediaTypes.ts b/js/sdk/src/internal/nodes/mediaTypes.ts index 7cd68364..5ea1b57a 100644 --- a/js/sdk/src/internal/nodes/mediaTypes.ts +++ b/js/sdk/src/internal/nodes/mediaTypes.ts @@ -1,3 +1,6 @@ +export const FOLDER_MEDIA_TYPE = 'Folder'; +export const ALBUM_MEDIA_TYPE = 'Album'; + const PROTON_DOC_MEDIA_TYPE = 'application/vnd.proton.doc'; const PROTON_SHEET_MEDIA_TYPE = 'application/vnd.proton.sheet'; diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 2e46d698..065d4285 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -12,6 +12,7 @@ import { generateFolderExtendedAttributes } from './extendedAttributes'; import { DecryptedNode, EncryptedNode } from './interface'; import { splitExtension, joinNameAndExtension } from './nodeName'; import { NodesAccessBase } from './nodesAccess'; +import { FOLDER_MEDIA_TYPE } from './mediaTypes'; import { validateNodeName } from './validations'; const AVAILABLE_NAME_BATCH_SIZE = 10; @@ -373,7 +374,7 @@ export abstract class NodesManagementBase< uid: nodeUid, parentUid: parentNodeUid, type: NodeType.Folder, - mediaType: 'Folder', + mediaType: FOLDER_MEDIA_TYPE, creationTime: new Date(), modificationTime: new Date(), diff --git a/js/sdk/src/internal/photos/albums.test.ts b/js/sdk/src/internal/photos/albums.test.ts new file mode 100644 index 00000000..56dd6dc9 --- /dev/null +++ b/js/sdk/src/internal/photos/albums.test.ts @@ -0,0 +1,241 @@ +import { NodeType, MemberRole } from '../../interface'; +import { ValidationError } from '../../errors'; +import { Albums } from './albums'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { PhotosAPIService } from './apiService'; +import { DecryptedPhotoNode } from './interface'; +import { PhotosNodesAccess } from './nodes'; +import { PhotoSharesManager } from './shares'; + +describe('Albums', () => { + let apiService: PhotosAPIService; + let cryptoService: AlbumsCryptoService; + let photoShares: PhotoSharesManager; + let nodesService: PhotosNodesAccess; + let albums: Albums; + + let nodes: { [uid: string]: DecryptedPhotoNode }; + + beforeEach(() => { + nodes = { + rootNodeUid: { + uid: 'rootNodeUid', + parentUid: '', + hash: 'rootHash', + } as DecryptedPhotoNode, + albumNodeUid: { + uid: 'albumNodeUid', + parentUid: 'rootNodeUid', + name: { ok: true, value: 'old album name' }, + hash: 'albumHash', + encryptedName: 'encryptedAlbumName', + } as DecryptedPhotoNode, + }; + + // @ts-expect-error No need to implement all methods for mocking + apiService = { + createAlbum: jest.fn().mockResolvedValue('volumeId~newAlbumNodeId'), + updateAlbum: jest.fn(), + deleteAlbum: jest.fn(), + }; + + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + createAlbum: jest.fn().mockResolvedValue({ + encryptedCrypto: { + encryptedName: 'newEncryptedAlbumName', + hash: 'newAlbumHash', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', + signatureEmail: 'signature@example.com', + armoredHashKey: 'armoredHashKey', + }, + keys: { + passphrase: 'passphrase', + key: 'nodeKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: new Uint8Array([1, 2, 3]), + }, + }), + renameAlbum: jest.fn().mockResolvedValue({ + signatureEmail: 'newSignatureEmail', + armoredNodeName: 'newArmoredAlbumName', + hash: 'newHash', + }), + }; + + // @ts-expect-error No need to implement all methods for mocking + photoShares = { + getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId', rootNodeId: 'rootNodeId' }), + }; + + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + getVolumeRootFolder: jest.fn().mockResolvedValue(nodes.rootNodeUid), + getNode: jest.fn().mockImplementation((uid: string) => nodes[uid]), + getNodeKeys: jest.fn().mockImplementation((uid) => ({ + key: `${uid}-key`, + hashKey: `${uid}-hashKey`, + passphrase: `${uid}-passphrase`, + passphraseSessionKey: `${uid}-passphraseSessionKey`, + })), + getParentKeys: jest.fn().mockImplementation(({ parentUid }) => ({ + key: `${parentUid}-key`, + hashKey: `${parentUid}-hashKey`, + })), + getNodeSigningKeys: jest.fn().mockResolvedValue({ + type: 'userAddress', + email: 'user@example.com', + addressId: 'addressId', + key: 'addressKey', + }), + notifyNodeChanged: jest.fn(), + notifyNodeDeleted: jest.fn(), + notifyChildCreated: jest.fn(), + }; + + albums = new Albums(apiService, cryptoService, photoShares, nodesService); + }); + + describe('createAlbum', () => { + it('creates album and returns decrypted node', async () => { + const newAlbum = await albums.createAlbum('My New Album'); + + expect(newAlbum).toEqual( + expect.objectContaining({ + uid: 'volumeId~newAlbumNodeId', + parentUid: 'rootNodeUid', + type: NodeType.Album, + mediaType: 'Album', + name: { ok: true, value: 'My New Album' }, + hash: 'newAlbumHash', + encryptedName: 'newEncryptedAlbumName', + keyAuthor: { ok: true, value: 'signature@example.com' }, + nameAuthor: { ok: true, value: 'signature@example.com' }, + }), + ); + + expect(nodesService.getNodeSigningKeys).toHaveBeenCalledWith({ parentNodeUid: 'rootNodeUid' }); + expect(cryptoService.createAlbum).toHaveBeenCalledWith( + { key: 'rootNodeUid-key', hashKey: 'rootNodeUid-hashKey' }, + { type: 'userAddress', email: 'user@example.com', addressId: 'addressId', key: 'addressKey' }, + 'My New Album', + ); + expect(apiService.createAlbum).toHaveBeenCalledWith('rootNodeUid', { + encryptedName: 'newEncryptedAlbumName', + hash: 'newAlbumHash', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', + signatureEmail: 'signature@example.com', + armoredHashKey: 'armoredHashKey', + }); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('rootNodeUid'); + }); + + it('throws validation error for invalid album name', async () => { + await expect(albums.createAlbum('invalid/name')).rejects.toThrow(ValidationError); + }); + + it('throws error when parent hash key is not available', async () => { + nodesService.getNodeKeys = jest.fn().mockResolvedValue({ + key: 'rootNodeUid-key', + hashKey: undefined, + }); + + await expect(albums.createAlbum('My Album')).rejects.toThrow( + 'Cannot create album: parent folder hash key not available', + ); + }); + }); + + describe('updateAlbum', () => { + it('updates album name and notifies cache', async () => { + const updatedAlbum = await albums.updateAlbum('albumNodeUid', { name: 'new album name' }); + + expect(updatedAlbum).toEqual({ + ...nodes.albumNodeUid, + name: { ok: true, value: 'new album name' }, + encryptedName: 'newArmoredAlbumName', + nameAuthor: { ok: true, value: 'newSignatureEmail' }, + hash: 'newHash', + }); + expect(nodesService.getNodeSigningKeys).toHaveBeenCalledWith({ + nodeUid: 'albumNodeUid', + parentNodeUid: 'rootNodeUid', + }); + expect(cryptoService.renameAlbum).toHaveBeenCalledWith( + { key: 'rootNodeUid-key', hashKey: 'rootNodeUid-hashKey' }, + 'encryptedAlbumName', + { type: 'userAddress', email: 'user@example.com', addressId: 'addressId', key: 'addressKey' }, + 'new album name', + ); + expect(apiService.updateAlbum).toHaveBeenCalledWith( + 'albumNodeUid', + undefined, + { + encryptedName: 'newArmoredAlbumName', + hash: 'newHash', + originalHash: 'albumHash', + nameSignatureEmail: 'newSignatureEmail', + }, + ); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid'); + }); + + it('updates album cover photo only', async () => { + const updatedAlbum = await albums.updateAlbum('albumNodeUid', { coverPhotoNodeUid: 'photoNodeUid' }); + + expect(updatedAlbum).toEqual(nodes.albumNodeUid); + expect(cryptoService.renameAlbum).not.toHaveBeenCalled(); + expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', 'photoNodeUid', undefined); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid'); + }); + + it('updates album name and cover photo together', async () => { + const updatedAlbum = await albums.updateAlbum('albumNodeUid', { + name: 'new album name', + coverPhotoNodeUid: 'photoNodeUid', + }); + + expect(updatedAlbum).toEqual({ + ...nodes.albumNodeUid, + name: { ok: true, value: 'new album name' }, + encryptedName: 'newArmoredAlbumName', + nameAuthor: { ok: true, value: 'newSignatureEmail' }, + hash: 'newHash', + }); + expect(apiService.updateAlbum).toHaveBeenCalledWith( + 'albumNodeUid', + 'photoNodeUid', + { + encryptedName: 'newArmoredAlbumName', + hash: 'newHash', + originalHash: 'albumHash', + nameSignatureEmail: 'newSignatureEmail', + }, + ); + }); + + it('throws validation error for invalid album name', async () => { + await expect(albums.updateAlbum('albumNodeUid', { name: 'invalid/name' })).rejects.toThrow(ValidationError); + }); + }); + + describe('deleteAlbum', () => { + it('deletes album and notifies cache', async () => { + await albums.deleteAlbum('albumNodeUid'); + + expect(apiService.deleteAlbum).toHaveBeenCalledWith('albumNodeUid', {}); + expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid'); + }); + + it('deletes album with force option', async () => { + await albums.deleteAlbum('albumNodeUid', { force: true }); + + expect(apiService.deleteAlbum).toHaveBeenCalledWith('albumNodeUid', { force: true }); + expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid'); + }); + }); +}); diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index 51a72b24..e930a903 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -1,6 +1,12 @@ +import { MemberRole, NodeType, resultOk } from '../../interface'; import { BatchLoading } from '../batchLoading'; import { DecryptedNode } from '../nodes'; +import { ALBUM_MEDIA_TYPE } from '../nodes/mediaTypes'; +import { validateNodeName } from '../nodes/validations'; +import { splitNodeUid } from '../uids'; +import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; +import { DecryptedPhotoNode } from './interface'; import { PhotosNodesAccess } from './nodes'; import { PhotoSharesManager } from './shares'; @@ -12,10 +18,12 @@ const BATCH_LOADING_SIZE = 10; export class Albums { constructor( private apiService: PhotosAPIService, + private cryptoService: AlbumsCryptoService, private photoShares: PhotoSharesManager, private nodesService: PhotosNodesAccess, ) { this.apiService = apiService; + this.cryptoService = cryptoService; this.photoShares = photoShares; this.nodesService = nodesService; } @@ -33,6 +41,117 @@ export class Albums { yield* batchLoading.loadRest(); } + async createAlbum(name: string): Promise { + validateNodeName(name); + + const rootNode = await this.nodesService.getVolumeRootFolder(); + const parentKeys = await this.nodesService.getNodeKeys(rootNode.uid); + if (!parentKeys.hashKey) { + throw new Error('Cannot create album: parent folder hash key not available'); + } + + const signingKeys = await this.nodesService.getNodeSigningKeys({ parentNodeUid: rootNode.uid }); + const { encryptedCrypto } = await this.cryptoService.createAlbum( + { key: parentKeys.key, hashKey: parentKeys.hashKey }, + signingKeys, + name, + ); + + const nodeUid = await this.apiService.createAlbum(rootNode.uid, { + encryptedName: encryptedCrypto.encryptedName, + hash: encryptedCrypto.hash, + armoredKey: encryptedCrypto.armoredKey, + armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + signatureEmail: encryptedCrypto.signatureEmail, + armoredHashKey: encryptedCrypto.armoredHashKey, + }); + + await this.nodesService.notifyChildCreated(rootNode.uid); + + return { + // Internal metadata + hash: encryptedCrypto.hash, + encryptedName: encryptedCrypto.encryptedName, + + // Basic node metadata + uid: nodeUid, + parentUid: rootNode.uid, + type: NodeType.Album, + mediaType: ALBUM_MEDIA_TYPE, + creationTime: new Date(), + modificationTime: new Date(), + + // Share node metadata + isShared: false, + isSharedPublicly: false, + directRole: MemberRole.Inherited, + + // Decrypted metadata + isStale: false, + keyAuthor: resultOk(encryptedCrypto.signatureEmail), + nameAuthor: resultOk(encryptedCrypto.signatureEmail), + name: resultOk(name), + treeEventScopeId: splitNodeUid(nodeUid).volumeId, + }; + } + + async updateAlbum( + nodeUid: string, + updates: { + name?: string; + coverPhotoNodeUid?: string; + }, + ): Promise { + if (updates.name) { + validateNodeName(updates.name); + } + + const node = await this.nodesService.getNode(nodeUid); + const newNode = { ...node }; + + let nameUpdate: + | { + encryptedName: string; + hash: string; + originalHash: string; + nameSignatureEmail: string; + } + | undefined; + + if (updates.name) { + const parentKeys = await this.nodesService.getParentKeys(node); + const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid, parentNodeUid: node.parentUid }); + + const { signatureEmail, armoredNodeName, hash } = await this.cryptoService.renameAlbum( + { key: parentKeys.key, hashKey: parentKeys.hashKey }, + node.encryptedName, + signingKeys, + updates.name, + ); + + nameUpdate = { + encryptedName: armoredNodeName, + hash, + originalHash: node.hash || '', + nameSignatureEmail: signatureEmail, + }; + newNode.name = resultOk(updates.name); + newNode.encryptedName = nameUpdate.encryptedName; + newNode.nameAuthor = resultOk(nameUpdate.nameSignatureEmail); + newNode.hash = nameUpdate.hash; + } + + await this.apiService.updateAlbum(nodeUid, updates.coverPhotoNodeUid, nameUpdate); + await this.nodesService.notifyNodeChanged(nodeUid); + return newNode; + } + + async deleteAlbum(nodeUid: string, options: { force?: boolean } = {}): Promise { + await this.apiService.deleteAlbum(nodeUid, options); + await this.nodesService.notifyNodeDeleted(nodeUid); + } + private async *iterateNodesAndIgnoreMissingOnes( nodeUids: string[], signal?: AbortSignal, diff --git a/js/sdk/src/internal/photos/albumsCrypto.test.ts b/js/sdk/src/internal/photos/albumsCrypto.test.ts new file mode 100644 index 00000000..950f93ae --- /dev/null +++ b/js/sdk/src/internal/photos/albumsCrypto.test.ts @@ -0,0 +1,181 @@ +import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto'; +import { NodeSigningKeys } from '../nodes/interface'; +import { AlbumsCryptoService } from './albumsCrypto'; + +describe('AlbumsCryptoService', () => { + let driveCrypto: DriveCrypto; + let albumsCryptoService: AlbumsCryptoService; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error No need to implement all methods for mocking + driveCrypto = {}; + + albumsCryptoService = new AlbumsCryptoService(driveCrypto); + }); + + describe('createAlbum', () => { + let parentKeys: any; + + beforeEach(() => { + parentKeys = { + key: 'parentKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + driveCrypto.generateKey = jest.fn().mockResolvedValue({ + encrypted: { + armoredKey: 'encryptedNodeKey', + armoredPassphrase: 'encryptedPassphrase', + armoredPassphraseSignature: 'passphraseSignature', + }, + decrypted: { + key: 'nodeKey' as any, + passphrase: 'nodePassphrase', + passphraseSessionKey: 'passphraseSessionKey' as any, + }, + }); + driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({ + armoredNodeName: 'encryptedNodeName', + }); + driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('lookupHash'); + driveCrypto.generateHashKey = jest.fn().mockResolvedValue({ + armoredHashKey: 'encryptedHashKey', + hashKey: new Uint8Array([4, 5, 6]), + }); + }); + + it('should encrypt new album with user address key', async () => { + const signingKeys: NodeSigningKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'addressKey' as any, + }; + + const result = await albumsCryptoService.createAlbum(parentKeys, signingKeys, 'My Album'); + + expect(result).toEqual({ + encryptedCrypto: { + encryptedName: 'encryptedNodeName', + hash: 'lookupHash', + armoredKey: 'encryptedNodeKey', + armoredNodePassphrase: 'encryptedPassphrase', + armoredNodePassphraseSignature: 'passphraseSignature', + signatureEmail: 'test@example.com', + armoredHashKey: 'encryptedHashKey', + }, + keys: { + passphrase: 'nodePassphrase', + key: 'nodeKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: new Uint8Array([4, 5, 6]), + }, + }); + + expect(driveCrypto.generateKey).toHaveBeenCalledWith([parentKeys.key], signingKeys.key); + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'My Album', + undefined, + parentKeys.key, + signingKeys.key, + ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('My Album', parentKeys.hashKey); + expect(driveCrypto.generateHashKey).toHaveBeenCalledWith('nodeKey'); + }); + + it('should throw error when creating album by anonymous user', async () => { + const signingKeys: NodeSigningKeys = { + type: 'nodeKey', + nodeKey: 'nodeSigningKey' as any, + parentNodeKey: 'parentNodeKey' as any, + }; + + await expect(albumsCryptoService.createAlbum(parentKeys, signingKeys, 'My Album')).rejects.toThrow( + 'Creating album by anonymous user is not supported', + ); + }); + }); + + describe('renameAlbum', () => { + let parentKeys: any; + let nodeNameSessionKey: SessionKey; + + beforeEach(() => { + parentKeys = { + key: 'parentKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + nodeNameSessionKey = 'nameSessionKey' as any; + driveCrypto.decryptSessionKey = jest.fn().mockResolvedValue(nodeNameSessionKey); + driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({ + armoredNodeName: 'encryptedNewNodeName', + }); + driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('newHash'); + }); + + it('should encrypt new album name with user address key', async () => { + const signingKeys: NodeSigningKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'addressKey' as any, + }; + + const result = await albumsCryptoService.renameAlbum( + parentKeys, + 'oldEncryptedName', + signingKeys, + 'Renamed Album', + ); + + expect(result).toEqual({ + signatureEmail: 'test@example.com', + armoredNodeName: 'encryptedNewNodeName', + hash: 'newHash', + }); + + expect(driveCrypto.decryptSessionKey).toHaveBeenCalledWith('oldEncryptedName', parentKeys.key); + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'Renamed Album', + nodeNameSessionKey, + parentKeys.key, + signingKeys.key, + ); + expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('Renamed Album', parentKeys.hashKey); + }); + + it('should throw error when renaming album by anonymous user', async () => { + const signingKeys: NodeSigningKeys = { + type: 'nodeKey', + nodeKey: 'nodeSigningKey' as any, + parentNodeKey: 'parentNodeKey' as any, + }; + + await expect( + albumsCryptoService.renameAlbum(parentKeys, 'oldEncryptedName', signingKeys, 'Renamed Album'), + ).rejects.toThrow('Renaming album by anonymous user is not supported'); + }); + + it('should throw error when parent hash key is not available', async () => { + const parentKeysWithoutHashKey = { + key: 'parentKey' as any, + }; + const signingKeys: NodeSigningKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'addressKey' as any, + }; + + await expect( + albumsCryptoService.renameAlbum( + parentKeysWithoutHashKey, + 'oldEncryptedName', + signingKeys, + 'Renamed Album', + ), + ).rejects.toThrow('Cannot rename album: parent folder hash key not available'); + }); + }); +}); diff --git a/js/sdk/src/internal/photos/albumsCrypto.ts b/js/sdk/src/internal/photos/albumsCrypto.ts new file mode 100644 index 00000000..05df5ab8 --- /dev/null +++ b/js/sdk/src/internal/photos/albumsCrypto.ts @@ -0,0 +1,100 @@ +import { DriveCrypto, PrivateKey } from '../../crypto'; +import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface'; + +/** + * Provides crypto operations for albums. + * + * Albums are special folders in the photos volume. This service reuses + * the drive crypto module for key and name encryption operations. + */ +export class AlbumsCryptoService { + constructor(private driveCrypto: DriveCrypto) { + this.driveCrypto = driveCrypto; + } + + async createAlbum( + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + signingKeys: NodeSigningKeys, + name: string, + ): Promise<{ + encryptedCrypto: { + encryptedName: string; + hash: string; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + signatureEmail: string; + armoredHashKey: string; + }; + keys: DecryptedNodeKeys; + }> { + if (signingKeys.type !== 'userAddress') { + throw new Error('Creating album by anonymous user is not supported'); + } + const email = signingKeys.email; + const nameAndPassphraseSigningKey = signingKeys.key; + + const [nodeKeys, { armoredNodeName }, hash] = await Promise.all([ + this.driveCrypto.generateKey([parentKeys.key], nameAndPassphraseSigningKey), + this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, nameAndPassphraseSigningKey), + this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), + ]); + + const { armoredHashKey, hashKey } = await this.driveCrypto.generateHashKey(nodeKeys.decrypted.key); + + return { + encryptedCrypto: { + encryptedName: armoredNodeName, + hash, + armoredKey: nodeKeys.encrypted.armoredKey, + armoredNodePassphrase: nodeKeys.encrypted.armoredPassphrase, + armoredNodePassphraseSignature: nodeKeys.encrypted.armoredPassphraseSignature, + signatureEmail: email, + armoredHashKey, + }, + keys: { + passphrase: nodeKeys.decrypted.passphrase, + key: nodeKeys.decrypted.key, + passphraseSessionKey: nodeKeys.decrypted.passphraseSessionKey, + hashKey, + }, + }; + } + + async renameAlbum( + parentKeys: { key: PrivateKey; hashKey?: Uint8Array }, + encryptedName: string, + signingKeys: NodeSigningKeys, + newName: string, + ): Promise<{ + signatureEmail: string; + armoredNodeName: string; + hash: string; + }> { + if (!parentKeys.hashKey) { + throw new Error('Cannot rename album: parent folder hash key not available'); + } + if (signingKeys.type !== 'userAddress') { + throw new Error('Renaming album by anonymous user is not supported'); + } + const email = signingKeys.email; + const nameSigningKey = signingKeys.key; + + const nodeNameSessionKey = await this.driveCrypto.decryptSessionKey(encryptedName, parentKeys.key); + + const { armoredNodeName } = await this.driveCrypto.encryptNodeName( + newName, + nodeNameSessionKey, + parentKeys.key, + nameSigningKey, + ); + + const hash = await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey); + + return { + signatureEmail: email, + armoredNodeName, + hash, + }; + } +} diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index fbe74e3c..8db6a8f5 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,6 +1,9 @@ -import { DriveAPIService, drivePaths } from '../apiService'; +import { c } from 'ttag'; + +import { ValidationError } from '../../errors'; +import { APICodeError, DriveAPIService, drivePaths } from '../apiService'; import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; -import { makeNodeUid } from '../uids'; +import { makeNodeUid, splitNodeUid } from '../uids'; type GetPhotoShareResponse = drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json']; @@ -18,6 +21,18 @@ type GetTimelineResponse = type GetAlbumsResponse = drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json']; +type PostCreateAlbumRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateAlbumResponse = + drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['responses']['200']['content']['application/json']; + +type PutUpdateAlbumRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}']['put']['requestBody'], + { content: object } +>['content']['application/json']; + type PostPhotoDuplicateRequest = Extract< drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['requestBody'], { content: object } @@ -25,6 +40,8 @@ type PostPhotoDuplicateRequest = Extract< type PostPhotoDuplicateResponse = drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json']; +const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302; + /** * Provides API communication for fetching and manipulating photos and albums * metadata. @@ -188,4 +205,79 @@ export class PhotosAPIService { }; }).filter((duplicate) => duplicate !== undefined); } + + async createAlbum( + parentNodeUid: string, + album: { + encryptedName: string; + hash: string; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + signatureEmail: string; + armoredHashKey: string; + }, + ): Promise { + const { volumeId } = splitNodeUid(parentNodeUid); + const response = await this.apiService.post( + `drive/photos/volumes/${volumeId}/albums`, + { + Locked: false, + Link: { + Name: album.encryptedName, + Hash: album.hash, + NodeKey: album.armoredKey, + NodePassphrase: album.armoredNodePassphrase, + NodePassphraseSignature: album.armoredNodePassphraseSignature, + SignatureEmail: album.signatureEmail, + NodeHashKey: album.armoredHashKey, + XAttr: null, + }, + }, + ); + + return makeNodeUid(volumeId, response.Album.Link.LinkID); + } + + async updateAlbum( + albumNodeUid: string, + coverPhotoNodeUid?: string, + updatedName?: { + encryptedName: string; + hash: string; + originalHash: string; + nameSignatureEmail: string; + }, + ): Promise { + const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid); + const coverLinkId = coverPhotoNodeUid ? splitNodeUid(coverPhotoNodeUid).nodeId : undefined; + await this.apiService.put( + `drive/photos/volumes/${volumeId}/albums/${linkId}`, + { + CoverLinkID: coverLinkId, + Link: updatedName + ? { + Name: updatedName.encryptedName, + Hash: updatedName.hash, + OriginalHash: updatedName.originalHash, + NameSignatureEmail: updatedName.nameSignatureEmail, + } + : null, + }, + ); + } + + async deleteAlbum(albumNodeUid: string, options: { force?: boolean } = {}): Promise { + const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid); + try { + await this.apiService.delete( + `drive/photos/volumes/${volumeId}/albums/${linkId}?DeleteAlbumPhotos=${options.force ? 1 : 0}`, + ); + } catch (error) { + if (error instanceof APICodeError && error.code === ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE) { + throw new ValidationError(c('Error').t`Album contains photos not in timeline`); + } + throw error; + } + } } diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 7f081b31..a3bde16d 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -1,4 +1,3 @@ -import { DriveAPIService } from '../apiService'; import { DriveCrypto } from '../../crypto'; import { ProtonDriveAccount, @@ -6,9 +5,12 @@ import { ProtonDriveEntitiesCache, ProtonDriveTelemetry, } from '../../interface'; +import { DriveAPIService } from '../apiService'; import { NodesCryptoService } from '../nodes/cryptoService'; import { NodesCryptoReporter } from '../nodes/cryptoReporter'; import { NodesCryptoCache } from '../nodes/cryptoCache'; +import { NodesEventsHandler } from '../nodes/events'; +import { NodesRevisons } from '../nodes/nodesRevisions'; import { ShareTargetType } from '../shares'; import { SharesCache } from '../shares/cache'; import { SharesCryptoCache } from '../shares/cryptoCache'; @@ -17,6 +19,7 @@ import { NodesService as UploadNodesService } from '../upload/interface'; import { UploadTelemetry } from '../upload/telemetry'; import { UploadQueue } from '../upload/queue'; import { Albums } from './albums'; +import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; import { SharesService } from './interface'; import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes'; @@ -29,8 +32,6 @@ import { PhotoUploadManager, PhotoUploadMetadata, } from './upload'; -import { NodesRevisons } from '../nodes/nodesRevisions'; -import { NodesEventsHandler } from '../nodes/events'; export type { DecryptedPhotoNode } from './interface'; @@ -51,6 +52,7 @@ export function initPhotosModule( nodesService: PhotosNodesAccess, ) { const api = new PhotosAPIService(apiService); + const albumsCryptoService = new AlbumsCryptoService(driveCrypto); const timeline = new PhotosTimeline( telemetry.getLogger('photos-timeline'), api, @@ -58,7 +60,7 @@ export function initPhotosModule( photoShares, nodesService, ); - const albums = new Albums(api, photoShares, nodesService); + const albums = new Albums(api, albumsCryptoService, photoShares, nodesService); return { timeline, diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 61129db2..f4113393 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -117,7 +117,13 @@ export class ProtonDrivePhotosClient { this.photoShares, fullConfig.clientUid, ); - this.photos = initPhotosModule(telemetry, apiService, cryptoModule, this.photoShares, this.nodes.access); + this.photos = initPhotosModule( + telemetry, + apiService, + cryptoModule, + this.photoShares, + this.nodes.access, + ); this.sharing = initSharingModule( telemetry, apiService, @@ -507,6 +513,63 @@ export class ProtonDrivePhotosClient { return this.photos.timeline.findPhotoDuplicates(name, generateSha1, signal); } + /** + * Creates a new album with the given name. + * + * @param name - The name for the new album. + * @returns The created album node. + */ + async createAlbum(name: string): Promise { + this.logger.info('Creating album'); + return convertInternalPhotoNodePromise(this.photos.albums.createAlbum(name)); + } + + /** + * Updates an existing album. + * + * Updates can include a new name and/or a cover photo. + * + * @param nodeUid - The UID of the album to edit. + * @param updates - The updates to apply. + * @returns The updated album node. + */ + async updateAlbum( + nodeUid: NodeOrUid, + updates: { + name?: string; + coverPhotoNodeUid?: NodeOrUid; + }, + ): Promise { + this.logger.info(`Updating album ${getUid(nodeUid)}`); + const coverPhotoNodeUid = updates.coverPhotoNodeUid ? getUid(updates.coverPhotoNodeUid) : undefined; + return convertInternalPhotoNodePromise( + this.photos.albums.updateAlbum(getUid(nodeUid), { + name: updates.name, + coverPhotoNodeUid, + }), + ); + } + + /** + * Deletes an album. + * + * Photos in the timeline will not be deleted. If the album has photos + * that are not in the timeline (uploaded by another user), the method + * will throw an error. The photos must be moved to the timeline, or + * the album must be deleted with `force` option that deletes the photos + * not in the timeline as well. + * + * This operation is irreversible. Both the album and the photos will be + * permanently deleted, skipping the trash. + * + * @param nodeUid - The UID of the album to delete. + * @param force - Whether to force the deletion. + */ + async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean } = {}): Promise { + this.logger.info(`Deleting album ${getUid(nodeUid)}`); + await this.photos.albums.deleteAlbum(getUid(nodeUid), options); + } + /** * Iterates the albums. * From 1d4f3fc1ea9eb52515f8baae86245eacaf7bf99b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 07:32:26 +0000 Subject: [PATCH 504/791] Add SHA1 upload verification --- .../InteropProtonDriveClient.cs | 6 +++ .../InteropProtonPhotosClient.cs | 3 ++ .../Nodes/Upload/FileUploader.cs | 7 +++- .../Nodes/Upload/RevisionWriter.cs | 12 +++++- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 11 ++++-- .../Nodes/PhotosFileUploadMetadata.cs | 2 + cs/sdk/src/protos/proton.drive.sdk.proto | 3 ++ js/sdk/src/interface/upload.ts | 10 +++++ js/sdk/src/internal/photos/upload.ts | 5 ++- .../internal/upload/streamUploader.test.ts | 38 +++++++++++++++++++ js/sdk/src/internal/upload/streamUploader.ts | 13 +++++-- .../sdk/entity/FileRevisionUploaderRequest.kt | 1 + .../drive/sdk/entity/FileUploaderRequest.kt | 3 +- .../drive/sdk/entity/PhotosUploaderRequest.kt | 1 + .../sdk/extension/FileUploaderRequest.kt | 1 + .../GetFileRevisionUploaderRequest.kt | 2 + .../sdk/extension/PhotosUploaderRequest.kt | 1 + .../ProtonDriveClient/ProtonDriveClient.swift | 36 +++++++++++------- .../ProtonPhotosClient.swift | 8 +++- .../Uploads/PhotoUploadsManager.swift | 6 +++ .../Uploads/UploadsManager.swift | 25 +++++++++--- .../Sources/Plumbing/PublicTypes.swift | 12 +++--- 22 files changed, 167 insertions(+), 39 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 58ef6f10..ee2eb07b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -117,6 +117,8 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; + var expectedSha1 = request.HasExpectedSha1 ? request.ExpectedSha1.Memory : default(ReadOnlyMemory?); + var fileUploader = await client.GetFileUploaderAsync( NodeUid.Parse(request.ParentFolderUid), request.Name, @@ -125,6 +127,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe request.LastModificationTime.ToDateTime(), additionalMetadata, request.OverrideExistingDraftByOtherClient, + expectedSha1, cancellationToken).ConfigureAwait(false); return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; @@ -141,11 +144,14 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; + var expectedSha1 = request.HasExpectedSha1 ? request.ExpectedSha1.Memory : default(ReadOnlyMemory?); + var fileUploader = await client.GetFileRevisionUploaderAsync( RevisionUid.Parse(request.CurrentActiveRevisionUid), request.Size, request.LastModificationTime.ToDateTime(), additionalMetadata, + expectedSha1, cancellationToken).ConfigureAwait(false); return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index e2934fdc..5cecb414 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -181,11 +181,14 @@ public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosCl ? request.Metadata.Tags.Select(t => (Proton.Photos.Sdk.Api.Photos.PhotoTag)t) : null; + var expectedSha1 = request.Metadata.HasExpectedSha1 ? request.Metadata.ExpectedSha1.Memory : default(ReadOnlyMemory?); + var metadata = new PhotosFileUploadMetadata { MediaType = request.Metadata.MediaType, MainPhotoLinkId = request.Metadata.MainPhotoLinkId, ExpectedSize = request.Size, + ExpectedSha1 = expectedSha1, Tags = tags, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 1530d306..0d27d6d6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -10,6 +10,7 @@ public sealed partial class FileUploader : IDisposable private readonly IRevisionDraftProvider _revisionDraftProvider; private readonly DateTimeOffset? _lastModificationTime; private readonly IEnumerable? _additionalMetadata; + private readonly ReadOnlyMemory? _expectedSha1; private readonly ILogger _logger; private volatile int _remainingNumberOfBlocks; @@ -20,6 +21,7 @@ private FileUploader( long size, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, + ReadOnlyMemory? expectedSha1, int expectedNumberOfBlocks, ILogger logger) { @@ -28,6 +30,7 @@ private FileUploader( FileSize = size; _lastModificationTime = lastModificationTime; _additionalMetadata = additionalMetadata; + _expectedSha1 = expectedSha1; _remainingNumberOfBlocks = expectedNumberOfBlocks; _logger = logger; } @@ -70,6 +73,7 @@ internal static async ValueTask CreateAsync( long size, DateTime? lastModificationTime, IEnumerable? additionalExtendedAttributes, + ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { var logger = client.Telemetry.GetLogger("File uploader"); @@ -82,7 +86,7 @@ internal static async ValueTask CreateAsync( LogAcquiredRevisionCreationSemaphore(logger, expectedNumberOfBlocks); - return new FileUploader(client, revisionDraftProvider, size, lastModificationTime, additionalExtendedAttributes, expectedNumberOfBlocks, logger); + return new FileUploader(client, revisionDraftProvider, size, lastModificationTime, additionalExtendedAttributes, expectedSha1, expectedNumberOfBlocks, logger); } [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire {Count} from revision creation semaphore")] @@ -221,6 +225,7 @@ private async ValueTask UploadAsync( await revisionWriter.WriteAsync( contentStream, FileSize, + _expectedSha1, thumbnails, lastModificationTime, additionalMetadata, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index d984b5bc..a8196181 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -44,6 +44,7 @@ internal RevisionWriter( public async ValueTask WriteAsync( Stream contentStream, long expectedContentLength, + ReadOnlyMemory? expectedSha1, IEnumerable thumbnails, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, @@ -97,11 +98,14 @@ public async ValueTask WriteAsync( } } + var sha1Digest = _draft.Sha1.GetCurrentHash(); + var request = CreateRevisionUpdateRequest( lastModificationTime, expectedContentLength, expectedThumbnailBlockCount, - _draft.Sha1.GetCurrentHash(), + expectedSha1, + sha1Digest, signingEmailAddress, additionalMetadata); @@ -338,6 +342,7 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( DateTimeOffset? lastModificationTime, long expectedContentLength, int expectedThumbnailBlockCount, + ReadOnlyMemory? expectedSha1, byte[]? sha1Digest, string signingEmailAddress, IEnumerable? additionalMetadata) @@ -386,6 +391,11 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( throw new IntegrityException("Unexpected number of thumbnail blocks"); } + if (expectedSha1 is not null && (sha1Digest is null || !expectedSha1.Value.Span.SequenceEqual(sha1Digest))) + { + throw new IntegrityException("Mismatch between uploaded SHA1 and expected SHA1"); + } + var extendedAttributes = new ExtendedAttributes { Common = new CommonExtendedAttributes diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index d9500c89..0e6e882a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -169,6 +169,7 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( return FileOperations.EnumerateThumbnailsAsync(this, fileUids, type, cancellationToken); } + // FIXME: Group all parameterts bettween mediaType and expectedSha1 into uploadMetadata object. public async ValueTask GetFileUploaderAsync( NodeUid parentFolderUid, string name, @@ -177,23 +178,26 @@ public async ValueTask GetFileUploaderAsync( DateTime? lastModificationTime, IEnumerable? additionalMetadata, bool overrideExistingDraftByOtherClient, + ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { var draftProvider = new NewFileDraftProvider(this, parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); - return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, expectedSha1, cancellationToken).ConfigureAwait(false); } + // FIXME: Group all parameterts bettween mediaType and expectedSha1 into uploadMetadata object. public async ValueTask GetFileRevisionUploaderAsync( RevisionUid currentActiveRevisionUid, long size, DateTime? lastModificationTime, IEnumerable? additionalMetadata, + ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { var draftProvider = new NewRevisionDraftProvider(this, currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); - return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, expectedSha1, cancellationToken).ConfigureAwait(false); } public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) @@ -251,8 +255,9 @@ private async ValueTask GetFileUploaderAsync( long size, DateTime? lastModificationTime, IEnumerable? additionalMetadata, + ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { - return await FileUploader.CreateAsync(this, revisionDraftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); + return await FileUploader.CreateAsync(this, revisionDraftProvider, size, lastModificationTime, additionalMetadata, expectedSha1, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs index acd663b6..c71552c1 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs +++ b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs @@ -11,5 +11,7 @@ public sealed class PhotosFileUploadMetadata : FileUploadMetadata public long? ExpectedSize { get; init; } + public ReadOnlyMemory? ExpectedSha1 { get; init; } + public IEnumerable? Tags { get; init; } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 236c5f23..4037f6a1 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -266,6 +266,7 @@ message DriveClientGetFileUploaderRequest { repeated AdditionalMetadataProperty additional_metadata = 7; // Optional bool override_existing_draft_by_other_client = 8; int64 cancellation_token_source_handle = 9; + bytes expected_sha1 = 10; // Optional - SHA1 hash of the file content for verification } // The response value must be an Int64Value carrying a handle to an instance of FileUploader. @@ -276,6 +277,7 @@ message DriveClientGetFileRevisionUploaderRequest { google.protobuf.Timestamp last_modification_time = 4; repeated AdditionalMetadataProperty additional_metadata = 5; // Optional int64 cancellation_token_source_handle = 6; + bytes expected_sha1 = 7; // Optional - SHA1 hash of the file content for verification } // The response value must be an Int64Value carrying a handle to an instance of UploadController. @@ -673,6 +675,7 @@ message PhotoFileUploadMetadata { google.protobuf.Timestamp capture_time = 5; // Optional string main_photo_link_id = 6; // Optional repeated PhotoTag tags = 7; // Optional + bytes expected_sha1 = 8; // Optional - SHA1 hash of the file content for verification } enum PhotoTag { diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts index f38c8809..003bc99c 100644 --- a/js/sdk/src/interface/upload.ts +++ b/js/sdk/src/interface/upload.ts @@ -10,6 +10,16 @@ export type UploadMetadata = { * fail. */ expectedSize: number; + /** + * Expected SHA1 hash of the file content. + * + * If provided, the SDK will verify that the SHA1 hash of the uploaded + * content matches the expected SHA1 hash. If the hashes do not match, + * the upload will fail with an IntegrityError. + * + * The hash should be provided as a hexadecimal string (40 characters). + */ + expectedSha1?: string; /** * Modification time of the file. * diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index dd656aa0..5b615d5c 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -109,13 +109,14 @@ export class PhotoStreamUploader extends StreamUploader { } async commitFile(thumbnails: Thumbnail[]) { - this.verifyIntegrity(thumbnails); + const digests = this.digests.digests(); + this.verifyIntegrity(thumbnails, digests); const extendedAttributes = { modificationTime: this.metadata.modificationTime, size: this.metadata.expectedSize, blockSizes: this.uploadedBlockSizes, - digests: this.digests.digests(), + digests, }; await this.photoUploadManager.commitDraftPhoto(this.revisionDraft, this.manifest, extendedAttributes, this.photoMetadata); diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index fb5e6ac9..f9f3778a 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -564,6 +564,44 @@ describe('StreamUploader', () => { await verifyFailure('Some file bytes failed to upload', 10 * 1024 * 1024 + 1024); }); + + it('should succeed with matching expectedSha1', async () => { + metadata.expectedSha1 = '8c206a1a87599f532ce68675536f0b1546900d7a'; + + uploader = new StreamUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + blockVerifier, + revisionDraft, + metadata, + onFinish, + controller, + abortController, + ); + + await verifySuccess(); + }); + + it('should throw an error if SHA1 does not match', async () => { + metadata.expectedSha1 = 'wrong_sha1_hash_that_will_not_match'; + + uploader = new StreamUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + blockVerifier, + revisionDraft, + metadata, + onFinish, + controller, + abortController, + ); + + await verifyFailure('File hash does not match expected hash', 10 * 1024 * 1024 + 1024); + }); }); }); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index dd1e8584..ee912440 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -219,13 +219,14 @@ export class StreamUploader { } protected async commitFile(thumbnails: Thumbnail[]) { - this.verifyIntegrity(thumbnails); + const digests = this.digests.digests(); + this.verifyIntegrity(thumbnails, digests); const extendedAttributes = { modificationTime: this.metadata.modificationTime, size: this.metadata.expectedSize, blockSizes: this.uploadedBlockSizes, - digests: this.digests.digests(), + digests, }; await this.uploadManager.commitDraft( this.revisionDraft, @@ -607,7 +608,7 @@ export class StreamUploader { } } - protected verifyIntegrity(thumbnails: Thumbnail[]) { + protected verifyIntegrity(thumbnails: Thumbnail[], digests: { sha1: string }) { const expectedBlockCount = Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0); if (this.uploadedBlockCount !== expectedBlockCount) { @@ -622,6 +623,12 @@ export class StreamUploader { expectedFileSize: this.metadata.expectedSize, }); } + if (this.metadata.expectedSha1 && digests.sha1 !== this.metadata.expectedSha1) { + throw new IntegrityError(c('Error').t`File hash does not match expected hash`, { + uploadedSha1: digests.sha1, + expectedSha1: this.metadata.expectedSha1, + }); + } } /** diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt index 742b5cd4..a9994efd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt @@ -4,4 +4,5 @@ data class FileRevisionUploaderRequest( val currentActiveRevisionUid: String, val lastModificationTime: Long, val size: Long, + val expectedSha1: ByteArray? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt index d1012d80..b590018e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt @@ -7,5 +7,6 @@ data class FileUploaderRequest( val fileSize: Long, val lastModificationTime: Long, val overrideExistingDraftByOtherClient: Boolean, - val additionalMetadata: Map = emptyMap() + val additionalMetadata: Map = emptyMap(), + val expectedSha1: ByteArray? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt index 4702950e..33f0053b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt @@ -10,4 +10,5 @@ data class PhotosUploaderRequest( val tags: List = emptyList(), // optional val overrideExistingDraftByOtherClient: Boolean, val additionalMetadata: Map = emptyMap(), // optional + val expectedSha1: ByteArray? = null, // optional ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt index 28a64d22..8bdd1940 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt @@ -22,6 +22,7 @@ internal fun FileUploaderRequest.toProtobuf( this.utf8JsonValue = data.toByteString() } } + this@toProtobuf.expectedSha1?.let { expectedSha1 = it.toByteString() } this.clientHandle = clientHandle this.cancellationTokenSourceHandle = cancellationTokenSourceHandle } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt index 39db093c..d6bb2c47 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.extension +import com.google.protobuf.kotlin.toByteString import com.google.protobuf.timestamp import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import proton.drive.sdk.driveClientGetFileRevisionUploaderRequest @@ -13,4 +14,5 @@ internal fun FileRevisionUploaderRequest.toProtobuf( this.currentActiveRevisionUid = this@toProtobuf.currentActiveRevisionUid this.size = this@toProtobuf.size this.lastModificationTime = timestamp { seconds = this@toProtobuf.lastModificationTime } + this@toProtobuf.expectedSha1?.let { expectedSha1 = it.toByteString() } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt index 07822596..8f1557d0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt @@ -33,6 +33,7 @@ internal fun PhotosUploaderRequest.toProtobuf( this.utf8JsonValue = data.toByteString() } } + this@toProtobuf.expectedSha1?.let { expectedSha1 = it.toByteString() } this@toProtobuf.mainPhotoLinkId?.let { mainPhotoLinkId = it } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index d347e6ce..450a45d9 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -19,7 +19,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { let httpClient: HttpClientProtocol let accountClient: AccountClientProtocol let configuration: ProtonDriveClientConfiguration - + private enum OperationIdentifier: Hashable { case createFolder(UUID) case rename(UUID) @@ -35,7 +35,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { } } } - + private var activeOperations: [OperationIdentifier: CancellationTokenSource] = [:] public init( @@ -181,6 +181,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, + expectedSha1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void @@ -194,6 +195,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { mediaType: mediaType, thumbnails: thumbnails, overrideExistingDraft: overrideExistingDraft, + expectedSha1: expectedSha1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -213,6 +215,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, + expectedSha1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -225,6 +228,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { mediaType: mediaType, thumbnails: thumbnails, overrideExistingDraft: overrideExistingDraft, + expectedSha1: expectedSha1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -237,6 +241,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { fileSize: Int64, modificationDate: Date, thumbnails: [ThumbnailData], + expectedSha1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void @@ -247,6 +252,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { fileSize: fileSize, modificationDate: modificationDate, thumbnails: thumbnails, + expectedSha1: expectedSha1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -263,6 +269,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { fileSize: Int64, modificationDate: Date, thumbnails: [ThumbnailData], + expectedSha1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -272,6 +279,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { fileSize: fileSize, modificationDate: modificationDate, thumbnails: thumbnails, + expectedSha1: expectedSha1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -312,24 +320,24 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { guard clientHandle != 0 else { return } Self.freeProtonDriveClient(Int64(clientHandle), logger) } - + private func cancelOperation(identifier: OperationIdentifier) async throws { guard let cancellationToken = activeOperations[identifier] else { throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: identifier.operationName)) } try await cancellationToken.cancel() - + activeOperations[identifier] = nil cancellationToken.free() } - + private func createCancellationTokenSource(_ operationIdentifier: OperationIdentifier, _ logger: Logger) async throws -> CancellationTokenSource { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) activeOperations[operationIdentifier] = cancellationTokenSource return cancellationTokenSource } - + private func freeCancellationTokenSourceIfNeeded(identifier: OperationIdentifier) { guard let cancellationTokenSource = activeOperations[identifier] else { return } activeOperations[identifier] = nil @@ -354,7 +362,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { // MARK: - Node action extension ProtonDriveClient { - + public func createFolder( parentFolderUid: SDKNodeUid, folderName: String, @@ -365,9 +373,9 @@ extension ProtonDriveClient { defer { freeCancellationTokenSourceIfNeeded(identifier: .createFolder(cancellationToken)) } - + let cancellationHandle = cancellationTokenSource.handle - + let createFolderRequest = Proton_Drive_Sdk_DriveClientCreateFolderRequest.with { $0.clientHandle = Int64(clientHandle) $0.parentFolderUid = parentFolderUid.sdkCompatibleIdentifier @@ -375,11 +383,11 @@ extension ProtonDriveClient { $0.lastModificationTime = Google_Protobuf_Timestamp(date: lastModificationTime) $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } - + let sdkFolderNode: Proton_Drive_Sdk_FolderNode = try await SDKRequestHandler.send(createFolderRequest, logger: logger) return try FolderNode(sdkFolderNode: sdkFolderNode) } - + public func cancelCreateFolder(cancellationToken: UUID) async throws { try await cancelOperation(identifier: .createFolder(cancellationToken)) } @@ -407,11 +415,11 @@ extension ProtonDriveClient { logger: logger) return nameResult } - + public func cancelGetAvailableName(cancellationToken: UUID) async throws { try await cancelOperation(identifier: .getAvailableName(cancellationToken)) } - + public func rename(nodeUid: SDKNodeUid, newName: String, newMediaType: String?, cancellationToken: UUID) async throws { let cancellationTokenSource = try await createCancellationTokenSource(.rename(cancellationToken), logger) defer { @@ -430,7 +438,7 @@ extension ProtonDriveClient { } let result: Void = try await SDKRequestHandler.send(renameRequest, logger: logger) } - + public func cancelRename(cancellationToken: UUID) async throws { try await cancelOperation(identifier: .rename(cancellationToken)) } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 9e7fc9be..8f945888 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -53,7 +53,7 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { } $0.featureEnabledFunction = Int64(ObjectHandle(callback: cCompatibleFeatureFlagProviderCallback)) - + $0.clientOptions = Proton_Drive_Sdk_ProtonDriveClientOptions.with { $0.uid = configuration.clientUID if let httpApiCallsTimeout = configuration.httpApiCallsTimeout { @@ -75,7 +75,7 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { includesLongLivedCallback: true, logger: logger ) - + assert(handle != 0) self.clientHandle = ObjectHandle(handle) logger.trace("client handle: \(clientHandle)", category: "ProtonDriveClient") @@ -211,6 +211,7 @@ extension ProtonPhotosClient { tags: [Int], additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, + expectedSha1: Data?, cancellationToken: UUID, progressCallback: @escaping ProgressCallback, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void @@ -227,6 +228,7 @@ extension ProtonPhotosClient { tags: tags, additionalMetadata: additionalMetadata, overrideExistingDraft: overrideExistingDraft, + expectedSha1: expectedSha1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -249,6 +251,7 @@ extension ProtonPhotosClient { tags: [Int], additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, + expectedSha1: Data?, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -272,6 +275,7 @@ extension ProtonPhotosClient { tags: mappedTags, additionalMetadata: additionalMetadata, overrideExistingDraft: overrideExistingDraft, + expectedSha1: expectedSha1, cancellationToken: cancellationToken, progressCallback: progressCallback ) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift index 67c774a7..cd777c9d 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift @@ -31,6 +31,7 @@ actor PhotoUploadsManager { tags: [Proton_Drive_Sdk_PhotoTag], additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, + expectedSha1: Data?, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -47,6 +48,7 @@ actor PhotoUploadsManager { tags: tags, additionalMetadata: additionalMetadata, overrideExistingDraft: overrideExistingDraft, + expectedSha1: expectedSha1, cancellationHandle: cancellationTokenSource.handle ) @@ -150,6 +152,7 @@ actor PhotoUploadsManager { tags: [Proton_Drive_Sdk_PhotoTag], additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, + expectedSha1: Data?, cancellationHandle: ObjectHandle ) async throws -> ObjectHandle { let uploaderRequest = Proton_Drive_Sdk_DrivePhotosClientGetPhotoUploaderRequest.with { @@ -161,6 +164,9 @@ actor PhotoUploadsManager { metadata.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) metadata.additionalMetadata = additionalMetadata.map { $0.toSDK } metadata.overrideExistingDraftByOtherClient = overrideExistingDraft + if let expectedSha1 = expectedSha1 { + metadata.expectedSha1 = expectedSha1 + } metadata.captureTime = Google_Protobuf_Timestamp(date: captureTime) if let mainPhotoLinkID { metadata.mainPhotoLinkID = mainPhotoLinkID diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift index d9d47cfd..86128adf 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift @@ -18,7 +18,7 @@ actor UploadsManager { $0.free() } } - + func uploadFileOperation( parentFolderUid: SDKNodeUid, name: String, @@ -28,6 +28,7 @@ actor UploadsManager { mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, + expectedSha1: Data?, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -43,6 +44,7 @@ actor UploadsManager { fileSize: fileSize, modificationDate: modificationDate, overrideExistingDraft: overrideExistingDraft, + expectedSha1: expectedSha1, cancellationHandle: cancellationHandle, logger: logger ) @@ -57,13 +59,14 @@ actor UploadsManager { ) return uploadController } - + func uploadNewRevisionOperation( currentActiveRevisionUid: SDKRevisionUid, fileURL: URL, fileSize: Int64, modificationDate: Date, thumbnails: [ThumbnailData], + expectedSha1: Data?, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -76,9 +79,10 @@ actor UploadsManager { currentActiveRevisionUid: currentActiveRevisionUid, fileSize: fileSize, modificationDate: modificationDate, + expectedSha1: expectedSha1, cancellationHandle: cancellationHandle ) - + let uploadController = try await uploadFromFile( fileUploaderHandle: uploaderHandle, fileURL: fileURL, @@ -97,11 +101,11 @@ actor UploadsManager { } try await uploadCancellationToken.cancel() - + activeUploads[cancellationToken] = nil uploadCancellationToken.free() } - + private func freeCancellationTokenSourceIfNeeded(cancellationToken: UUID) { guard let cancellationTokenSource = activeUploads[cancellationToken] else { return } activeUploads[cancellationToken] = nil @@ -118,6 +122,7 @@ extension UploadsManager { fileSize: Int64, modificationDate: Date, overrideExistingDraft: Bool, + expectedSha1: Data?, cancellationHandle: ObjectHandle?, logger: Logger? ) async throws -> ObjectHandle { @@ -130,6 +135,10 @@ extension UploadsManager { $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) $0.overrideExistingDraftByOtherClient = overrideExistingDraft + if let expectedSha1 = expectedSha1 { + $0.expectedSha1 = expectedSha1 + } + if let cancellationHandle = cancellationHandle { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } @@ -144,6 +153,7 @@ extension UploadsManager { currentActiveRevisionUid: SDKRevisionUid, fileSize: Int64, modificationDate: Date, + expectedSha1: Data?, cancellationHandle: ObjectHandle? ) async throws -> ObjectHandle { let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileRevisionUploaderRequest.with { @@ -151,6 +161,9 @@ extension UploadsManager { $0.currentActiveRevisionUid = currentActiveRevisionUid.sdkCompatibleIdentifier $0.size = fileSize $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + if let expectedSha1 = expectedSha1 { + $0.expectedSha1 = expectedSha1 + } if let cancellationHandle = cancellationHandle { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } @@ -202,7 +215,7 @@ extension UploadsManager { includesLongLivedCallback: true, logger: logger ) - + return UploadOperation( fileUploaderHandle: fileUploaderHandle, uploadControllerHandle: uploadControllerHandle, diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index d58e6d9d..a281ee47 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -6,13 +6,13 @@ public struct SDKNodeUid: Sendable { public let volumeID: String public let nodeID: String public let sdkCompatibleIdentifier: String - + public init(volumeID: String, nodeID: String) { self.volumeID = volumeID self.nodeID = nodeID self.sdkCompatibleIdentifier = "\(volumeID)~\(nodeID)" } - + public init?(sdkCompatibleIdentifier: String) { guard let match = sdkCompatibleIdentifier.firstMatch(of: #/(.+)~(.+)/#) else { return nil } self.volumeID = String(match.output.1) @@ -26,18 +26,18 @@ public struct SDKRevisionUid: Sendable { public let nodeID: String public let revisionID: String public let sdkCompatibleIdentifier: String - + public init(sdkNodeUid: SDKNodeUid, revisionID: String) { self.init(volumeID: sdkNodeUid.volumeID, nodeID: sdkNodeUid.nodeID, revisionID: revisionID) } - + public init(volumeID: String, nodeID: String, revisionID: String) { self.volumeID = volumeID self.nodeID = nodeID self.revisionID = revisionID self.sdkCompatibleIdentifier = "\(volumeID)~\(nodeID)~\(revisionID)" } - + public init?(sdkCompatibleIdentifier: String) { guard let match = sdkCompatibleIdentifier.firstMatch(of: #/(.+)~(.+)~(.+)/#) else { return nil } self.volumeID = String(match.output.1) @@ -207,7 +207,7 @@ public struct FileRevision: Sendable { public struct UploadedFileIdentifiers: Sendable { public let nodeUid: SDKNodeUid public let revisionUid: SDKRevisionUid - + init?(interopUploadResult: Proton_Drive_Sdk_UploadResult) { guard let nodeUid = SDKNodeUid(sdkCompatibleIdentifier: interopUploadResult.nodeUid), let revisionUid = SDKRevisionUid(sdkCompatibleIdentifier: interopUploadResult.revisionUid) From de0a2b6a3313cca5850a64981af538028af75b2f Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 07:43:06 +0000 Subject: [PATCH 505/791] Update changelog for cs/v0.7.0-alpha.6 --- cs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 332b84f2..58dd62cb 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## cs/v0.7.0-alpha.6 (2026-02-10) + +* Add SHA1 upload verification +* Update changelog for cs/v0.7.0-alpha.5 + ## cs/v0.7.0-alpha.5 (2026-02-05) * Verify C# build for published source code From 866d97009c3539096ddf422351d567f66ea45a3d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 07:58:41 +0000 Subject: [PATCH 506/791] Add experimental getNodePassphrase --- js/sdk/src/protonDrivePublicLinkClient.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index c3f6be62..863557ae 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -70,6 +70,12 @@ export class ProtonDrivePublicLinkClient { * This is used by Docs app to encrypt and decrypt document updates. */ getDocsKey: (nodeUid: NodeOrUid) => Promise; + /** + * Experimental feature to get the passphrase for a node. + * + * This is used by public link page to report abuse. + */ + getNodePassphrase: (nodeUid: NodeOrUid) => Promise; /** * Experimental feature to check if hashes match the malware database. */ @@ -175,6 +181,14 @@ export class ProtonDrivePublicLinkClient { } return keys.contentKeyPacketSessionKey; }, + getNodePassphrase: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting node passphrase for ${getUid(nodeUid)}`); + const keys = await this.sharingPublic.nodes.access.getNodeKeys(getUid(nodeUid)); + if (!keys.passphrase) { + throw new Error('Node does not have a passphrase'); + } + return keys.passphrase + }, scanHashes: async (hashes: string[]): Promise => { this.logger.debug(`Scanning ${hashes.length} hashes`); return this.sharingPublic.nodes.security.scanHashes(hashes); From c3438aae0bc62f32bcdb4026604f959515d11859 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 08:11:21 +0000 Subject: [PATCH 507/791] Update changelog for js/v0.9.8 --- js/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 5f1c50ca..38ffb85d 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## js/v0.9.8 (2026-02-10) + +* Add experimental getNodePassphrase +* Add SHA1 upload verification +* Add album management +* Update changelog for js/v0.9.7 + ## js/v0.9.7 (2026-02-05) * [DRVWEB-5135] Add empty trash for photo volume From d2f7b57813f68c7716d52f0223aa59dda0963153 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 20:26:25 +0100 Subject: [PATCH 508/791] Exclude integrity errors from being resumable during upload --- .../src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs | 2 +- .../src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs | 6 ++---- .../Verification/NodeKeyAndSessionKeyMismatchException.cs | 2 +- .../SessionKeyAndDataPacketMismatchException.cs | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs index 87df9999..905a2c03 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -public class IntegrityException : Exception +public class IntegrityException : ProtonDriveException { public IntegrityException(string message) : base(message) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index ae7ee90f..76664cb7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -1,4 +1,3 @@ -using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -100,10 +99,9 @@ public async ValueTask DisposeAsync() private static bool IsResumableError(Exception ex) { return ex is not ProtonApiException { TransportCode: > 400 and < 500 } - and not NodeKeyAndSessionKeyMismatchException - and not SessionKeyAndDataPacketMismatchException and not UploadContentReadingException - and not NodeWithSameNameExistsException; + and not NodeWithSameNameExistsException + and not IntegrityException; } private async Task PauseOnResumableErrorAsync(Task uploadTask) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs index 7d482842..571b07e8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes.Upload.Verification; -public sealed class NodeKeyAndSessionKeyMismatchException : ProtonDriveException +public sealed class NodeKeyAndSessionKeyMismatchException : IntegrityException { public NodeKeyAndSessionKeyMismatchException(string message) : base(message) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs index 9506f826..04507213 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs @@ -1,6 +1,6 @@ namespace Proton.Drive.Sdk.Nodes.Upload.Verification; -public sealed class SessionKeyAndDataPacketMismatchException : ProtonDriveException +public sealed class SessionKeyAndDataPacketMismatchException : IntegrityException { public SessionKeyAndDataPacketMismatchException(string message) : base(message) From f7f41f96a915bcaa93525fa7c1ebf1175656af0e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 6 Feb 2026 16:09:30 +0100 Subject: [PATCH 509/791] Abort pause state on non-resumable upload errors --- .../Nodes/Download/DownloadController.cs | 9 +++++++++ cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs | 1 + cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs | 8 ++++++++ .../Proton.Drive.Sdk/Nodes/Upload/UploadController.cs | 9 +++++++++ 4 files changed, 27 insertions(+) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index d324b6a9..3f70c694 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -120,6 +120,15 @@ private async Task PauseOnResumableErrorAsync(Task downloadTask) _taskControl.Pause(); throw; } + catch + { + if (_taskControl.IsPaused) + { + _taskControl.AbortPause(); + } + + throw; + } } private async ValueTask FinalizeDownloadAsync() diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs index 916c1c13..332bc892 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs @@ -8,4 +8,5 @@ internal interface ITaskControl : IDisposable CancellationToken PauseOrCancellationToken { get; } void Pause(); bool TryResume(); + void AbortPause(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs index 4a8868bc..8e75b4a4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs @@ -60,6 +60,14 @@ public bool TryResume() return true; } + public void AbortPause() + { + var resumeSignalSource = _resumeSignalSource; + _resumeSignalSource = null; + + resumeSignalSource?.TrySetCanceled(); + } + public void Dispose() { if (_isDisposed) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 76664cb7..93be2d0b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -119,6 +119,15 @@ private async Task PauseOnResumableErrorAsync(Task u _taskControl.Pause(); throw; } + catch + { + if (_taskControl.IsPaused) + { + _taskControl.AbortPause(); + } + + throw; + } } private async ValueTask InvokeOnSucceededAsync() From 3ff3784b977a6f9f9e5b3e2ee2395491080997e9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 14:29:17 +0100 Subject: [PATCH 510/791] Refactor and fix support for Photos nodes --- cs/.editorconfig | 2 + cs/.globalconfig | 35 +- cs/Directory.Build.props | 6 + cs/Directory.Packages.props | 1 + .../InteropFileDownloader.cs | 6 +- .../InteropMessageHandler.cs | 14 +- .../InteropPhotosDownloader.cs | 9 +- .../InteropPhotosUploader.cs | 2 +- .../InteropProtonDriveClient.cs | 3 +- .../InteropProtonPhotosClient.cs | 33 +- .../Proton.Drive.Sdk.CExports.csproj | 3 +- .../Api/Folders/FolderCreationRequest.cs | 1 - .../Api/IPhotosApiClient.cs | 4 +- .../Api/Links/LinkDetailsDto.cs | 14 +- .../Api/PhotoDetailsResponse.cs | 9 + .../Api/Photos/PhotoAlbumInclusionDto.cs} | 4 +- .../Api/Photos/PhotoDto.cs | 5 +- .../Api/Photos/PhotoTag.cs | 2 +- .../Api/Photos/PhotosVolumeCreationRequest.cs | 2 +- .../PhotosVolumeLinkCreationParameters.cs | 2 +- .../PhotosVolumeShareCreationParameters.cs | 2 +- .../Api/Photos/RelatedPhotoDto.cs | 2 +- .../Api/Photos/TimelinePhotoDto.cs | 2 +- .../Api/Photos/TimelinePhotoListRequest.cs | 2 +- .../Api/Photos/TimelinePhotoListResponse.cs | 2 +- .../Api/PhotosApiClient.cs | 11 +- .../Api/Storage/StorageApiClient.cs | 4 +- .../Api/Volumes/VolumeShareDto.cs | 1 - .../{Nodes => Caching}/CachedNodeInfo.cs | 3 +- .../Caching/IDriveEntityCache.cs | 2 - .../Proton.Drive.Sdk/Caching/IEntityCache.cs | 2 + .../Caching/IPhotosClientCache.cs | 4 +- .../Caching/IPhotosEntityCache.cs | 13 + .../Caching/PhotosClientCache.cs | 5 +- .../Caching/PhotosEntityCache.cs | 37 ++ .../Caching/PhotosSecretCache.cs | 3 +- .../Nodes/Cryptography/NodeCrypto.cs | 16 +- .../Nodes/DegradedFileNode.cs | 6 +- .../Nodes/DegradedPhotoNode.cs | 6 + .../Nodes/DegradedPhotoNodeMetadata.cs | 3 + .../Nodes/Download/DownloadController.cs | 1 - .../Nodes/Download/FileDownloader.cs | 1 - .../Nodes/Download/PhotosFileDownloader.cs | 6 +- .../Nodes/DtoToMetadataConverter.cs | 356 +++++++++++------- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 6 +- .../Nodes/FolderOperations.cs | 10 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 1 + .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 11 + .../Nodes/PhotoNode.cs | 4 +- .../Nodes/PhotosFileUploadMetadata.cs | 5 +- .../Nodes/PhotosNodeOperations.cs | 52 +-- .../Nodes/PhotosTimelineItem.cs | 4 +- .../Nodes/TraversalOperations.cs | 4 +- .../Nodes/Upload/BlockUploader.cs | 1 - .../Nodes/Upload/FileUploader.cs | 10 +- .../Nodes/Upload/PhotosFileUploader.cs | 5 +- .../Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 1 - .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 14 +- .../ProtonPhotosClient.cs | 38 +- .../RecyclableMemoryStreamExtensions.cs | 2 +- .../DriveEntitiesSerializerContext.cs | 1 + .../PhotosApiSerializerContext.cs | 6 +- .../Shares/ShareOperations.cs | 5 +- .../Telemetry/TelemetryErrorResolver.cs | 4 + .../Volumes/VolumeOperations.cs | 106 ++++++ .../Api/PhotoDetailsResponse.cs | 8 - .../Api/PhotoLinkDetailsDto.cs | 37 -- .../Caching/IPhotosEntityCache.cs | 20 - .../Caching/PhotosEntityCache.cs | 82 ---- .../Nodes/PhotoDtoToMetadataConverter.cs | 196 ---------- .../Nodes/PhotoNodeBatchLoader.cs | 38 -- .../Proton.Photos.Sdk.csproj | 20 - .../Volumes/VolumeOperations.cs | 118 ------ .../InteropFeatureFlagProvider.cs | 1 - .../src/Proton.Sdk.CExports/InteropStream.cs | 4 +- .../Api/Addresses/AddressListResponse.cs | 4 +- .../Api/Addresses/AddressResponse.cs | 4 +- .../Authentication/AuthenticationResponse.cs | 1 - .../IAuthenticationApiClient.cs | 1 - .../Api/Authentication/ModulusResponse.cs | 1 - .../Api/Authentication/ScopesResponse.cs | 4 +- .../SessionInitiationResponse.cs | 1 - .../Authentication/SessionRefreshResponse.cs | 1 - .../Api/Events/EventListResponse.cs | 1 - .../Api/Events/LatestEventResponse.cs | 1 - .../Api/Keys/KeySaltListResponse.cs | 4 +- .../src/Proton.Sdk/Api/Users/UserResponse.cs | 4 +- .../src/Proton.Sdk/Http/HttpApiCallBuilder.cs | 1 - cs/sdk/src/Proton.Sdk/Result.cs | 2 +- cs/sdk/src/Proton.Sdk/ResultExtensions.cs | 2 +- cs/sdk/src/protos/proton.drive.sdk.proto | 26 +- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 23 +- .../sdk/internal/JniProtonPhotosClient.kt | 15 +- .../ProtonPhotosClient.swift | 19 +- .../Sources/Plumbing/Message+Packaging.swift | 9 +- 95 files changed, 658 insertions(+), 927 deletions(-) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/IPhotosApiClient.cs (90%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs rename cs/sdk/src/{Proton.Photos.Sdk/Api/Photos/AlbumDto.cs => Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs} (85%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/PhotoDto.cs (82%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/PhotoTag.cs (82%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/PhotosVolumeCreationRequest.cs (82%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/PhotosVolumeLinkCreationParameters.cs (91%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/PhotosVolumeShareCreationParameters.cs (93%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/RelatedPhotoDto.cs (92%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/TimelinePhotoDto.cs (94%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/TimelinePhotoListRequest.cs (88%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/Photos/TimelinePhotoListResponse.cs (74%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Api/PhotosApiClient.cs (86%) rename cs/sdk/src/Proton.Drive.Sdk/{Nodes => Caching}/CachedNodeInfo.cs (77%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Caching/IPhotosClientCache.cs (62%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosEntityCache.cs rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Caching/PhotosClientCache.cs (77%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosEntityCache.cs rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Caching/PhotosSecretCache.cs (97%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNodeMetadata.cs rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Nodes/Download/PhotosFileDownloader.cs (97%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Nodes/PhotoNode.cs (59%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Nodes/PhotosFileUploadMetadata.cs (76%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Nodes/PhotosNodeOperations.cs (69%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Nodes/PhotosTimelineItem.cs (52%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Nodes/Upload/PhotosFileUploader.cs (87%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/ProtonPhotosClient.cs (80%) rename cs/sdk/src/{Proton.Photos.Sdk => Proton.Drive.Sdk}/Serialization/PhotosApiSerializerContext.cs (89%) delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj delete mode 100644 cs/sdk/src/Proton.Photos.Sdk/Volumes/VolumeOperations.cs diff --git a/cs/.editorconfig b/cs/.editorconfig index 00528acb..70312fa4 100644 --- a/cs/.editorconfig +++ b/cs/.editorconfig @@ -129,6 +129,8 @@ csharp_new_line_before_members_in_object_initializers = false csharp_new_line_before_open_brace = all csharp_new_line_between_query_expression_clauses = true +roslynator_max_line_length = 160 + # Indentation preferences csharp_indent_block_contents = true csharp_indent_braces = false diff --git a/cs/.globalconfig b/cs/.globalconfig index 263b6c49..548ed2d4 100644 --- a/cs/.globalconfig +++ b/cs/.globalconfig @@ -3,16 +3,46 @@ is_global = true stylecop.layout.allowConsecutiveUsings = true stylecop.layout.allowDoWhileOnClosingBrace = true +# IDE0001: Simplify Names +dotnet_diagnostic.IDE0001.severity = warning + +# IDE0002: Simplify Member Access +dotnet_diagnostic.IDE0002.severity = warning + +# IDE0003: Remove qualification +dotnet_diagnostic.IDE0003.severity = warning + +# IDE0004: Remove Unnecessary Cast +dotnet_diagnostic.IDE0004.severity = warning + +# IDE0005: Using directive is unnecessary. +dotnet_diagnostic.IDE0005.severity = warning + # IDE0007: Use var instead of explicit type dotnet_diagnostic.IDE0007.severity = warning -# IDE0007: Use collection initializers or expressions +# IDE0028: Use collection initializers or expressions dotnet_diagnostic.IDE0028.severity = warning +# IDE0047: Remove unnecessary parentheses +dotnet_diagnostic.IDE0047.severity = warning + +# CA1032: Implement standard exception constructors dotnet_diagnostic.CA1032.severity = warning + +# CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1591.severity = none + +# CA1711: Identifiers should not have incorrect suffix dotnet_diagnostic.CA1711.severity = warning + +# CA2000: Dispose objects before losing scope dotnet_diagnostic.CA2000.severity = suggestion + +# CA2201: Do not raise reserved exception types dotnet_diagnostic.CA2201.severity = warning + +# CA2215: Dispose methods should call base class dispose dotnet_diagnostic.CA2215.severity = warning # StyleCop - Special @@ -86,6 +116,9 @@ dotnet_diagnostic.SX1309.severity = warning # Roslynator +# RCS0056: A line is too long +dotnet_diagnostic.RCS0056.severity = warning + # RCS1037: Remove trailing white-space dotnet_diagnostic.RCS1037.severity = none # Redundant with SA1028 diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index cc66a9f9..ac713987 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -3,6 +3,7 @@ net9.0 true + true false @@ -47,6 +48,11 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index c7ec4fdd..d2337c2a 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs index edc77d0c..6f7bb904 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -14,7 +14,11 @@ public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, n var downloader = Interop.GetFromHandle(request.DownloaderHandle); var writeFunction = new InteropFunction, nint, nint>(request.WriteAction); - var seekAction = request.SeekAction != 0 ? new InteropAction, nint>(request.SeekAction) : (InteropAction, nint>?)null; + + var seekAction = request.SeekAction != 0 + ? new InteropAction, nint>(request.SeekAction) + : (InteropAction, nint>?)null; + var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null; var stream = new InteropStream(bindingsHandle, writeFunction, seekAction, cancelAction); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 936c37f4..49791e35 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -95,7 +95,8 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint => InteropDownloadController.HandleIsPaused(request.DownloadControllerIsPaused), Request.PayloadOneofCase.DownloadControllerIsDownloadCompleteWithVerificationIssue - => InteropDownloadController.HandleIsDownloadCompleteWithVerificationIssue(request.DownloadControllerIsDownloadCompleteWithVerificationIssue), + => InteropDownloadController.HandleIsDownloadCompleteWithVerificationIssue( + request.DownloadControllerIsDownloadCompleteWithVerificationIssue), Request.PayloadOneofCase.DownloadControllerAwaitCompletion => await InteropDownloadController.HandleAwaitCompletion(request.DownloadControllerAwaitCompletion).ConfigureAwait(false), @@ -118,14 +119,13 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DrivePhotosClientFree => InteropProtonPhotosClient.HandleFree(request.DrivePhotosClientFree), - Request.PayloadOneofCase.DrivePhotosClientGetPhotosRoot - => await InteropProtonPhotosClient.HandleGetPhotosRootAsync(request.DrivePhotosClientGetPhotosRoot).ConfigureAwait(false), - Request.PayloadOneofCase.DrivePhotosClientEnumeratePhotosThumbnails - => await InteropProtonPhotosClient.HandleEnumeratePhotosThumbnailsAsync(request.DrivePhotosClientEnumeratePhotosThumbnails).ConfigureAwait(false), + => await InteropProtonPhotosClient.HandleEnumeratePhotosThumbnailsAsync( + request.DrivePhotosClientEnumeratePhotosThumbnails).ConfigureAwait(false), - Request.PayloadOneofCase.DrivePhotosClientEnumeratePhotosTimeline - => await InteropProtonPhotosClient.HandleEnumeratePhotosTimelineAsync(request.DrivePhotosClientEnumeratePhotosTimeline).ConfigureAwait(false), + Request.PayloadOneofCase.DrivePhotosClientEnumerateTimeline + => await InteropProtonPhotosClient.HandleEnumeratePhotosTimelineAsync( + request.DrivePhotosClientEnumerateTimeline).ConfigureAwait(false), Request.PayloadOneofCase.DrivePhotosClientGetNode => await InteropProtonPhotosClient.HandleGetNodeAsync(request.DrivePhotosClientGetNode).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs index 891dfd35..014a95e2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -1,7 +1,6 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; -using Proton.Photos.Sdk.Nodes; -using Proton.Photos.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Download; using Proton.Sdk.CExports; namespace Proton.Drive.Sdk.CExports; @@ -15,7 +14,11 @@ public static IMessage HandleDownloadToStream(DrivePhotosClientDownloadToStreamR var downloader = Interop.GetFromHandle(request.DownloaderHandle); var writeFunction = new InteropFunction, nint, nint>(request.WriteAction); - var seekAction = request.SeekAction != 0 ? new InteropAction, nint>(request.SeekAction) : (InteropAction, nint>?)null; + + var seekAction = request.SeekAction != 0 + ? new InteropAction, nint>(request.SeekAction) + : (InteropAction, nint>?)null; + var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null; var stream = new InteropStream(bindingsHandle, writeFunction, seekAction, cancelAction); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs index 3a6b2d2f..d4e519fc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs @@ -1,6 +1,6 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; -using Proton.Photos.Sdk.Nodes.Upload; +using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk.CExports; namespace Proton.Drive.Sdk.CExports; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index ee2eb07b..8e2fd61c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -471,7 +471,8 @@ private static DegradedNode ConvertToDegradedNode(Proton.Drive.Sdk.Nodes.Degrade degradedFile.ActiveRevision.ClaimedDigests = new FileContentDigests(); if (degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1.HasValue) { - degradedFile.ActiveRevision.ClaimedDigests.Sha1 = ByteString.CopyFrom(degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1.Value.Span); + degradedFile.ActiveRevision.ClaimedDigests.Sha1 = + ByteString.CopyFrom(degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1.Value.Span); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 5cecb414..a0af4f5e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -1,9 +1,6 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes; -using Proton.Photos.Sdk; -using Proton.Photos.Sdk.Api.Photos; -using Proton.Photos.Sdk.Nodes; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.CExports; @@ -83,26 +80,6 @@ public static IMessage HandleCreate(DrivePhotosClientCreateFromSessionRequest re return null; } - public static async ValueTask HandleGetPhotosRootAsync(DrivePhotosClientGetPhotosRootRequest request) - { - var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - var client = Interop.GetFromHandle(request.ClientHandle); - - var folderNode = await client.GetPhotosRootAsync(cancellationToken).ConfigureAwait(false); - - return new FolderNode - { - Uid = folderNode.Uid.ToString(), - ParentUid = folderNode.ParentUid.ToString(), - TreeEventScopeId = folderNode.TreeEventScopeId, - Name = folderNode.Name, - CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), - TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), - NameAuthor = InteropProtonDriveClient.ParseAuthorResult(folderNode.NameAuthor), - Author = InteropProtonDriveClient.ParseAuthorResult(folderNode.Author), - }; - } - public static async ValueTask HandleGetNodeAsync(DrivePhotosClientGetNodeRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); @@ -120,13 +97,11 @@ public static async ValueTask HandleGetPhotosRootAsync(DrivePhotosClie return InteropProtonDriveClient.ConvertToNodeResult(nodeResult.Value); } - public static async ValueTask HandleEnumeratePhotosTimelineAsync(DrivePhotosClientEnumeratePhotosTimelineRequest request) + public static async ValueTask HandleEnumeratePhotosTimelineAsync(DrivePhotosClientEnumerateTimelineRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); - var timelineEnumerable = client.EnumeratePhotosTimelineAsync( - NodeUid.Parse(request.FolderUid), - cancellationToken); + var timelineEnumerable = client.EnumerateTimelineAsync(cancellationToken); var items = await timelineEnumerable .Select(x => new PhotosTimelineItem @@ -157,7 +132,7 @@ public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(Dri var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); - var thumbnailsEnumerable = client.EnumeratePhotosThumbnailsAsync( + var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( request.PhotoUids.Select(NodeUid.Parse), (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, cancellationToken); @@ -178,7 +153,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosCl var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var tags = request.Metadata.Tags is { Count: > 0 } - ? request.Metadata.Tags.Select(t => (Proton.Photos.Sdk.Api.Photos.PhotoTag)t) + ? request.Metadata.Tags.Select(t => (Api.Photos.PhotoTag)t) : null; var expectedSha1 = request.Metadata.HasExpectedSha1 ? request.Metadata.ExpectedSha1.Memory : default(ReadOnlyMemory?); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj index f111da4d..7c293d6c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -17,12 +17,13 @@ + + - diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs index fb246030..64dff306 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Links; using Proton.Sdk.Cryptography; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/IPhotosApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IPhotosApiClient.cs similarity index 90% rename from cs/sdk/src/Proton.Photos.Sdk/Api/IPhotosApiClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/IPhotosApiClient.cs index bf281a8e..b3e1da7e 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/IPhotosApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IPhotosApiClient.cs @@ -1,10 +1,10 @@ using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Volumes; -using Proton.Photos.Sdk.Api.Photos; -namespace Proton.Photos.Sdk.Api; +namespace Proton.Drive.Sdk.Api; internal interface IPhotosApiClient { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs index 1e24c7bf..d639aaaf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs @@ -1,5 +1,6 @@ using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Photos; namespace Proton.Drive.Sdk.Api.Links; @@ -8,14 +9,25 @@ internal sealed class LinkDetailsDto public required LinkDto Link { get; init; } public FolderDto? Folder { get; init; } public FileDto? File { get; init; } + public PhotoDto? Photo { get; init; } + public FolderDto? Album { get; init; } public LinkSharingDto? Sharing { get; init; } public ShareMembershipSummaryDto? Membership { get; init; } - public void Deconstruct(out LinkDto link, out FolderDto? folder, out FileDto? file, out LinkSharingDto? sharing, out ShareMembershipSummaryDto? membership) + public void Deconstruct( + out LinkDto link, + out FolderDto? folder, + out FileDto? file, + out PhotoDto? photo, + out FolderDto? album, + out LinkSharingDto? sharing, + out ShareMembershipSummaryDto? membership) { link = Link; folder = Folder; file = File; + photo = Photo; + album = Album; sharing = Sharing; membership = Membership; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs new file mode 100644 index 00000000..229e119c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs @@ -0,0 +1,9 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api; + +internal sealed class PhotoDetailsResponse : ApiResponse +{ + public required IReadOnlyList Links { get; init; } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs similarity index 85% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs index fd77e375..6dc1cbb8 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/AlbumDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs @@ -2,9 +2,9 @@ using Proton.Drive.Sdk.Api.Links; using Proton.Sdk.Serialization; -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; -internal sealed class AlbumDto +internal sealed class PhotoAlbumInclusionDto { [JsonPropertyName("AlbumLinkID")] public required LinkId Id { get; init; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs similarity index 82% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs index 7d0706e2..57be5206 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs @@ -3,7 +3,7 @@ using Proton.Drive.Sdk.Api.Links; using Proton.Sdk.Serialization; -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class PhotoDto : FileDto { @@ -26,5 +26,6 @@ internal sealed class PhotoDto : FileDto public required IReadOnlyList Tags { get; init; } = []; - public required IReadOnlyList Albums { get; init; } = []; + [JsonPropertyName("Albums")] + public required IReadOnlyList AlbumInclusions { get; init; } = []; } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoTag.cs similarity index 82% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoTag.cs index 7c4ca1df..2bd92cef 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotoTag.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoTag.cs @@ -1,4 +1,4 @@ -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; public enum PhotoTag { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs similarity index 82% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs index 8af11aab..4f21b45d 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs @@ -1,4 +1,4 @@ -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class PhotosVolumeCreationRequest { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs similarity index 91% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs index cff78dbe..8b7bda71 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs @@ -1,6 +1,6 @@ using Proton.Sdk.Cryptography; -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class PhotosVolumeLinkCreationParameters { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs similarity index 93% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs index 241fc6e7..e1e45c92 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs @@ -2,7 +2,7 @@ using Proton.Sdk.Addresses; using Proton.Sdk.Cryptography; -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class PhotosVolumeShareCreationParameters { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/RelatedPhotoDto.cs similarity index 92% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/RelatedPhotoDto.cs index 2a391066..5d81280e 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/RelatedPhotoDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/RelatedPhotoDto.cs @@ -2,7 +2,7 @@ using Proton.Drive.Sdk.Api.Links; using Proton.Sdk.Serialization; -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class RelatedPhotoDto { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs similarity index 94% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs index 71d7bdc8..c6bed026 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs @@ -2,7 +2,7 @@ using Proton.Drive.Sdk.Api.Links; using Proton.Sdk.Serialization; -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class TimelinePhotoDto { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListRequest.cs similarity index 88% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListRequest.cs index a723aa42..3d137e8b 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListRequest.cs @@ -2,7 +2,7 @@ using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Volumes; -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class TimelinePhotoListRequest { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListResponse.cs similarity index 74% rename from cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListResponse.cs index 203b3023..b8ffb832 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/Photos/TimelinePhotoListResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListResponse.cs @@ -1,4 +1,4 @@ -namespace Proton.Photos.Sdk.Api.Photos; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class TimelinePhotoListResponse { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/PhotosApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/PhotosApiClient.cs similarity index 86% rename from cs/sdk/src/Proton.Photos.Sdk/Api/PhotosApiClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/PhotosApiClient.cs index 2eca4709..5317fe37 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/PhotosApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/PhotosApiClient.cs @@ -1,13 +1,12 @@ using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Volumes; -using Proton.Photos.Sdk.Api.Photos; -using Proton.Photos.Sdk.Serialization; using Proton.Sdk.Http; -namespace Proton.Photos.Sdk.Api; +namespace Proton.Drive.Sdk.Api; internal sealed class PhotosApiClient(HttpClient httpClient) : IPhotosApiClient { @@ -40,7 +39,11 @@ public async ValueTask GetDetailsAsync(VolumeId volumeId, { return await _httpClient .Expecting(PhotosApiSerializerContext.Default.PhotoDetailsResponse) - .PostAsync($"photos/volumes/{volumeId}/links", new LinkDetailsRequest(linkIds), DriveApiSerializerContext.Default.LinkDetailsRequest, cancellationToken) + .PostAsync( + $"photos/volumes/{volumeId}/links", + new LinkDetailsRequest(linkIds), + DriveApiSerializerContext.Default.LinkDetailsRequest, + cancellationToken) .ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs index dce38ccb..da5253fb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -1,6 +1,5 @@ using System.Net.Http.Headers; using System.Net.Mime; -using System.Runtime.ExceptionServices; using Proton.Sdk.Api; using Proton.Sdk.Http; using Proton.Sdk.Serialization; @@ -46,7 +45,8 @@ public async ValueTask GetBlobStreamAsync(string baseUrl, string token, try { // Because of HttpCompletionOption.ResponseHeadersRead option, the long timeout is not needed, so we don't use the storage HTTP client - var blobResponse = await _defaultHttpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var blobResponse = await _defaultHttpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); await blobResponse.EnsureApiSuccessAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs index 9a69966d..24dcd8c5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Volumes; namespace Proton.Drive.Sdk.Api.Volumes; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs similarity index 77% rename from cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs rename to cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs index 1dfc4d50..c8d6d842 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/CachedNodeInfo.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs @@ -1,7 +1,8 @@ using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; using Proton.Sdk; -namespace Proton.Drive.Sdk.Nodes; +namespace Proton.Drive.Sdk.Caching; internal readonly record struct CachedNodeInfo( Result NodeProvisionResult, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index 8230ec90..8e797cc4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -19,7 +19,5 @@ internal interface IDriveEntityCache : IEntityCache ValueTask SetShareAsync(Share share, CancellationToken cancellationToken); ValueTask TryGetShareAsync(ShareId shareId, CancellationToken cancellationToken); - ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); - ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs index ee1503b2..dbcdd206 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs @@ -12,4 +12,6 @@ ValueTask SetNodeAsync( ShareId? membershipShareId, ReadOnlyMemory nameHashDigest, CancellationToken cancellationToken); + + ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosClientCache.cs similarity index 62% rename from cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs rename to cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosClientCache.cs index df8a68e9..c869c794 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosClientCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosClientCache.cs @@ -1,6 +1,4 @@ -using Proton.Drive.Sdk.Caching; - -namespace Proton.Photos.Sdk.Caching; +namespace Proton.Drive.Sdk.Caching; internal interface IPhotosClientCache { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosEntityCache.cs new file mode 100644 index 00000000..3d4a7bab --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosEntityCache.cs @@ -0,0 +1,13 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Caching; + +internal interface IPhotosEntityCache +{ + ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); + + ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken); + + ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosClientCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosClientCache.cs similarity index 77% rename from cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosClientCache.cs rename to cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosClientCache.cs index a318a1bd..82fa07c0 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosClientCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosClientCache.cs @@ -1,7 +1,6 @@ -using Proton.Drive.Sdk.Caching; -using Proton.Sdk.Caching; +using Proton.Sdk.Caching; -namespace Proton.Photos.Sdk.Caching; +namespace Proton.Drive.Sdk.Caching; internal class PhotosClientCache( ICacheRepository entityCacheRepository, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosEntityCache.cs new file mode 100644 index 00000000..cfa721ad --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosEntityCache.cs @@ -0,0 +1,37 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Caching; + +namespace Proton.Drive.Sdk.Caching; + +internal sealed class PhotosEntityCache(ICacheRepository repository) : IPhotosEntityCache +{ + private const string PhotoVolumeIdCacheKey = "volume:photos:id"; + private const string PhotosShareIdCacheKey = "share:photos:id"; + + private readonly ICacheRepository _repository = repository; + + public ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken) + { + return _repository.SetAsync(PhotoVolumeIdCacheKey, volumeId.ToString(), cancellationToken); + } + + public async ValueTask TryGetPhotosVolumeIdAsync(CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(PhotoVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); + + return value is not null ? (VolumeId?)value : null; + } + + public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken) + { + return _repository.SetAsync(PhotosShareIdCacheKey, shareId.ToString(), cancellationToken); + } + + public async ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(PhotosShareIdCacheKey, cancellationToken).ConfigureAwait(false); + + return value is not null ? (ShareId)value : null; + } +} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosSecretCache.cs similarity index 97% rename from cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs rename to cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosSecretCache.cs index 96c335d6..7b1114e9 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosSecretCache.cs @@ -1,14 +1,13 @@ using System.Text.Json; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Serialization; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.Serialization; -namespace Proton.Photos.Sdk.Caching; +namespace Proton.Drive.Sdk.Caching; internal sealed class PhotosSecretCache(ICacheRepository repository) : IDriveSecretCache { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index c92b3b54..f9b2f02f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -125,7 +125,13 @@ private static Result>, string> Decr { try { - var passphrase = DecryptMessage(encryptedPassphrase, signature, parentNodeKey, authorshipClaim.GetKeyRing(parentNodeKey), out var sessionKey, out var author); + var passphrase = DecryptMessage( + encryptedPassphrase, + signature, + parentNodeKey, + authorshipClaim.GetKeyRing(parentNodeKey), + out var sessionKey, + out var author); return new PhasedDecryptionOutput>(sessionKey, passphrase, author); } @@ -159,7 +165,13 @@ private static Result, string> DecryptName( { try { - var nameUtf8Bytes = DecryptMessage(encryptedName, detachedSignature: null, parentNodeKey, authorshipClaim.GetKeyRing(parentNodeKey), out var sessionKey, out var author); + var nameUtf8Bytes = DecryptMessage( + encryptedName, + detachedSignature: null, + parentNodeKey, + authorshipClaim.GetKeyRing(parentNodeKey), + out var sessionKey, + out var author); var name = Encoding.UTF8.GetString(nameUtf8Bytes); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs index c9e42f12..20bc8c57 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs @@ -1,8 +1,6 @@ -using Proton.Sdk; +namespace Proton.Drive.Sdk.Nodes; -namespace Proton.Drive.Sdk.Nodes; - -public sealed record DegradedFileNode : DegradedNode +public record DegradedFileNode : DegradedNode { public required string MediaType { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs new file mode 100644 index 00000000..dd15edd4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed record DegradedPhotoNode : DegradedFileNode +{ + public required DateTime CaptureTime { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNodeMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNodeMetadata.cs new file mode 100644 index 00000000..96160b46 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNodeMetadata.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed record DegradedPhotoNodeMetadata(DegradedPhotoNode Node, DegradedFileSecrets Secrets); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 3f70c694..1d87ae11 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -1,4 +1,3 @@ -using Proton.Drive.Sdk.Nodes; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Download; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 6e3588ff..e61e4b25 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using Proton.Drive.Sdk.Telemetry; -using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk.Nodes.Download; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs similarity index 97% rename from cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index 880ed578..4aa6f7f2 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -1,11 +1,7 @@ using Microsoft.Extensions.Logging; -using Proton.Drive.Sdk; -using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Telemetry; -using Proton.Sdk.Telemetry; -namespace Proton.Photos.Sdk.Nodes.Download; +namespace Proton.Drive.Sdk.Nodes.Download; public sealed partial class PhotosFileDownloader : IFileDownloader { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 2f047de7..9375aef1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -1,4 +1,5 @@ using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Caching; @@ -72,6 +73,17 @@ public static async Task> ConvertDtoT cancellationToken).ConfigureAwait(false)) .Convert(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), + LinkType.Album => + (await ConvertDtoToAlbumMetadataAsync( + client, + client.Cache.Entities, + client.Cache.Secrets, + volumeId, + linkDetailsDto, + parentKeyResult, + cancellationToken).ConfigureAwait(false)) + .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), + // FIXME: handle other existing node types, and determine a way for forward compatibility or degraded result in case a new node type is introduced _ => throw new NotSupportedException($"Link type {linkType} is not supported."), }; @@ -88,117 +100,45 @@ public static async ValueTask> Co Result parentKeyResult, CancellationToken cancellationToken) { - var linkDto = linkDetailsDto.Link; - var folderDto = linkDetailsDto.Folder; - var membershipDto = linkDetailsDto.Membership; - - if (folderDto is null) + if (linkDetailsDto.Folder is null) { - // FIXME: handle missing folder information with degraded node throw new InvalidOperationException("Node is a folder, but folder properties are missing"); } - var uid = new NodeUid(volumeId, linkDto.Id); - var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; - - var decryptionResult = await NodeCrypto.DecryptFolderAsync(client.Account, linkDto, folderDto.HashKey, parentKeyResult, cancellationToken).ConfigureAwait(false); - - var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); - var nodeKeyIsInvalid = !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); - var passphraseIsInvalid = !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput); - var hashKeyIsInvalid = !decryptionResult.HashKey.TryGetValue(out var hashKeyOutput); - - var nameAuthor = !nameIsInvalid && nameOutput.HasValue - ? decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure) - : default; - var nodeAuthor = !passphraseIsInvalid && !hashKeyIsInvalid - ? decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure ?? hashKeyOutput.AuthorshipVerificationFailure) - : default; + return await ConvertDtoToFolderMetadataAsync( + client, + entityCache, + secretCache, + volumeId, + linkDetailsDto, + linkDetailsDto.Folder, + parentKeyResult, + cancellationToken).ConfigureAwait(false); + } - if ( - nameIsInvalid || nameSessionKey is null || nameOutput is null - || passphraseIsInvalid || nodeKeyIsInvalid || hashKeyIsInvalid) + public static async ValueTask> ConvertDtoToAlbumMetadataAsync( + ProtonDriveClient client, + IEntityCache entityCache, + IDriveSecretCache secretCache, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + Result parentKeyResult, + CancellationToken cancellationToken) + { + if (linkDetailsDto.Album is null) { - List failedDecryptionFields = []; - List errors = []; - - if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) - { - errors.Add(new DecryptionError(passphraseError ?? "Failed to decrypt passphrase")); - failedDecryptionFields.Add(EncryptedField.NodeKey); - } - else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) - { - errors.Add(new DecryptionError(nodeKeyError ?? "Failed to decrypt node key")); - failedDecryptionFields.Add(EncryptedField.NodeKey); - } - else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) - { - errors.Add(new DecryptionError(hashKeyError ?? "Failed to decrypt hash key")); - failedDecryptionFields.Add(EncryptedField.NodeHashKey); - } - - if (nameResult.IsFailure) - { - failedDecryptionFields.Add(EncryptedField.NodeName); - } - - var degradedNode = new DegradedFolderNode - { - Uid = uid, - ParentUid = parentUid, - Name = nameResult, - NameAuthor = nameAuthor, - CreationTime = linkDto.CreationTime, - TrashTime = linkDto.TrashTime, - Author = nodeAuthor, - Errors = errors, - }; - - var degradedSecrets = new DegradedFolderSecrets - { - Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), - PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), - NameSessionKey = nameSessionKey, - HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), - }; - - await secretCache.SetFolderSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); - - var degradedFolderMetadata = new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); - - await entityCache.SetNodeAsync(uid, degradedNode, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); - - await ReportDecryptionError(client, DegradedNodeMetadata.FromFolder(degradedFolderMetadata), failedDecryptionFields, cancellationToken).ConfigureAwait(false); - - return degradedFolderMetadata; + throw new InvalidOperationException("Node is an album, but album properties are missing"); } - var secrets = new FolderSecrets - { - Key = nodeKey, - PassphraseSessionKey = passphraseOutput.SessionKey, - NameSessionKey = nameSessionKey.Value, - HashKey = hashKeyOutput.Data, - PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous ? passphraseOutput.Data : null, - }; - - await secretCache.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); - - var node = new FolderNode - { - Uid = uid, - ParentUid = parentUid, - Name = nameOutput.Value.Data, - NameAuthor = nameAuthor, - Author = nodeAuthor, - CreationTime = linkDto.CreationTime, - TrashTime = linkDto.TrashTime, - }; - - await entityCache.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); - - return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); + return await ConvertDtoToFolderMetadataAsync( + client, + entityCache, + secretCache, + volumeId, + linkDetailsDto, + linkDetailsDto.Album, + parentKeyResult, + cancellationToken).ConfigureAwait(false); } public static async Task> ConvertDtoToFileMetadataAsync( @@ -249,7 +189,8 @@ public static async Task> ConvertDtoT : default; var nodeAuthor = !passphraseIsInvalid - ? decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure ?? contentKeyOutput.AuthorshipVerificationFailure) + ? decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure + ?? contentKeyOutput.AuthorshipVerificationFailure) : default; var contentAuthor = !extendedAttributesIsInvalid @@ -285,7 +226,7 @@ public static async Task> ConvertDtoT } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { - errors.Add(new DecryptionError(nodeKeyError ?? "Failed to decrypt node key")); + errors.Add(new DecryptionError(nodeKeyError)); failedDecryptionFields.Add(EncryptedField.NodeKey); } else if (decryptionResult.ContentKey.IsFailure) @@ -301,7 +242,7 @@ public static async Task> ConvertDtoT var revisionErrors = new List(); if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) { - revisionErrors.Add(new DecryptionError(extendedAttributesError ?? "Failed to decrypt extended attributes key")); + revisionErrors.Add(new DecryptionError(extendedAttributesError)); failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes); } @@ -317,23 +258,39 @@ public static async Task> ConvertDtoT AdditionalClaimedMetadata = additionalMetadata, ContentAuthor = contentAuthor, CanDecrypt = !contentKeyIsInvalid, - Errors = (IReadOnlyList)revisionErrors, + Errors = revisionErrors, }; - var degradedNode = new DegradedFileNode - { - Uid = uid, - ParentUid = parentUid, - Name = nameResult, - NameAuthor = nameAuthor, - CreationTime = linkDto.CreationTime, - TrashTime = linkDto.TrashTime, - Author = nodeAuthor, - MediaType = fileDto.MediaType, - ActiveRevision = degradedRevision, - TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, - Errors = errors, - }; + var degradedNode = linkDetailsDto.Photo is not null + ? new DegradedPhotoNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = nameAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + Author = nodeAuthor, + MediaType = fileDto.MediaType, + ActiveRevision = degradedRevision, + TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, + Errors = errors, + CaptureTime = linkDetailsDto.Photo.CaptureTime, + } + : new DegradedFileNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = nameAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + Author = nodeAuthor, + MediaType = fileDto.MediaType, + ActiveRevision = degradedRevision, + TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, + Errors = errors, + }; var degradedSecrets = new DegradedFileSecrets { @@ -349,7 +306,8 @@ public static async Task> ConvertDtoT await entityCache.SetNodeAsync(uid, degradedNode, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); - await ReportDecryptionError(client, DegradedNodeMetadata.FromFile(degradedFileMetadata), failedDecryptionFields, cancellationToken).ConfigureAwait(false); + await ReportDecryptionError(client, DegradedNodeMetadata.FromFile(degradedFileMetadata), failedDecryptionFields, cancellationToken) + .ConfigureAwait(false); return degradedFileMetadata; } @@ -378,7 +336,152 @@ public static async Task> ConvertDtoT ContentAuthor = contentAuthor, }; - var node = new FileNode + var node = linkDetailsDto.Photo is not null + ? new PhotoNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameOutput.Value.Data, + NameAuthor = nameAuthor, + Author = nodeAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + MediaType = fileDto.MediaType, + ActiveRevision = activeRevision, + TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, + CaptureTime = linkDetailsDto.Photo.CaptureTime, + } + : new FileNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameOutput.Value.Data, + NameAuthor = nameAuthor, + Author = nodeAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + MediaType = fileDto.MediaType, + ActiveRevision = activeRevision, + TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, + }; + + await secretCache.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + + await entityCache.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + + return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); + } + + private static async ValueTask> ConvertDtoToFolderMetadataAsync( + ProtonDriveClient client, + IEntityCache entityCache, + IDriveSecretCache secretCache, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + FolderDto folderDto, + Result parentKeyResult, + CancellationToken cancellationToken) + { + var linkDto = linkDetailsDto.Link; + var membershipDto = linkDetailsDto.Membership; + + if (folderDto is null) + { + var linkType = linkDetailsDto.Link.Type is LinkType.Folder ? "folder" : "album"; + throw new InvalidOperationException($"Node is a {linkType}, but {linkType} properties are missing"); + } + + var uid = new NodeUid(volumeId, linkDto.Id); + var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; + + var decryptionResult = await NodeCrypto.DecryptFolderAsync(client.Account, linkDto, folderDto.HashKey, parentKeyResult, cancellationToken) + .ConfigureAwait(false); + + var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); + var nodeKeyIsInvalid = !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); + var passphraseIsInvalid = !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput); + var hashKeyIsInvalid = !decryptionResult.HashKey.TryGetValue(out var hashKeyOutput); + + var nameAuthor = !nameIsInvalid && nameOutput.HasValue + ? decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure) + : default; + var nodeAuthor = !passphraseIsInvalid && !hashKeyIsInvalid + ? decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure + ?? hashKeyOutput.AuthorshipVerificationFailure) + : default; + + if ( + nameIsInvalid || nameSessionKey is null || nameOutput is null + || passphraseIsInvalid || nodeKeyIsInvalid || hashKeyIsInvalid) + { + List failedDecryptionFields = []; + List errors = []; + + if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) + { + errors.Add(new DecryptionError(passphraseError)); + failedDecryptionFields.Add(EncryptedField.NodeKey); + } + else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) + { + errors.Add(new DecryptionError(nodeKeyError)); + failedDecryptionFields.Add(EncryptedField.NodeKey); + } + else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) + { + errors.Add(new DecryptionError(hashKeyError)); + failedDecryptionFields.Add(EncryptedField.NodeHashKey); + } + + if (nameResult.IsFailure) + { + failedDecryptionFields.Add(EncryptedField.NodeName); + } + + var degradedNode = new DegradedFolderNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = nameAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + Author = nodeAuthor, + Errors = errors, + }; + + var degradedSecrets = new DegradedFolderSecrets + { + Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), + PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), + NameSessionKey = nameSessionKey, + HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), + }; + + await secretCache.SetFolderSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); + + var degradedFolderMetadata = new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); + + await entityCache.SetNodeAsync(uid, degradedNode, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + + await ReportDecryptionError(client, DegradedNodeMetadata.FromFolder(degradedFolderMetadata), failedDecryptionFields, cancellationToken) + .ConfigureAwait(false); + + return degradedFolderMetadata; + } + + var secrets = new FolderSecrets + { + Key = nodeKey, + PassphraseSessionKey = passphraseOutput.SessionKey, + NameSessionKey = nameSessionKey.Value, + HashKey = hashKeyOutput.Data, + PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous ? passphraseOutput.Data : null, + }; + + await secretCache.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + + var node = new FolderNode { Uid = uid, ParentUid = parentUid, @@ -387,16 +490,11 @@ public static async Task> ConvertDtoT Author = nodeAuthor, CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, - MediaType = fileDto.MediaType, - ActiveRevision = activeRevision, - TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, }; - await secretCache.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); - await entityCache.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); - return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); + return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } private static async ValueTask> GetParentKeyAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 2c24f9e0..96070737 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -7,7 +7,10 @@ namespace Proton.Drive.Sdk.Nodes; internal static partial class FileOperations { - public static async ValueTask> GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) + public static async ValueTask> GetSecretsAsync( + ProtonDriveClient client, + NodeUid fileUid, + CancellationToken cancellationToken) { var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); @@ -81,6 +84,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( tasks.Enqueue(DownloadThumbnailAsync(client, fileNode.ActiveRevision.Uid, block, cancellationToken)); } + // TODO: cancel other thumbnail downloads if one fails while (tasks.TryDequeue(out var task)) { yield return await task.ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 2e191fd9..7af41c05 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -58,7 +58,12 @@ public static async IAsyncEnumerable> EnumerateChildr } } - public static async ValueTask CreateAsync(ProtonDriveClient client, NodeUid parentUid, string name, DateTimeOffset? lastModificationTime, CancellationToken cancellationToken) + public static async ValueTask CreateAsync( + ProtonDriveClient client, + NodeUid parentUid, + string name, + DateTimeOffset? lastModificationTime, + CancellationToken cancellationToken) { var parentSecrets = await GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); @@ -68,7 +73,8 @@ public static async ValueTask CreateAsync(ProtonDriveClient client, var hashKey = CryptoGenerator.GenerateFolderHashKey(); - var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken).ConfigureAwait(false); + var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken) + .ConfigureAwait(false); NodeOperations.GetCommonCreationParameters( name, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index 080ae6e0..48272747 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -7,6 +7,7 @@ namespace Proton.Drive.Sdk.Nodes; [JsonDerivedType(typeof(FolderNode), typeDiscriminator: "folder")] [JsonDerivedType(typeof(FileNode), typeDiscriminator: "file")] [JsonDerivedType(typeof(FileDraftNode), typeDiscriminator: "fileDraft")] +[JsonDerivedType(typeof(PhotoNode), typeDiscriminator: "photo")] public abstract record Node { public required NodeUid Uid { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index e250a5a4..3cdb3c12 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -6,6 +6,7 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes.Cryptography; using Proton.Drive.Sdk.Shares; @@ -67,6 +68,16 @@ public static async ValueTask> GetNod return (Result)metadataResult; } + public static IAsyncEnumerable> EnumerateNodesAsync( + ProtonDriveClient client, + IEnumerable nodeUids, + CancellationToken cancellationToken = default) + { + return nodeUids.GroupBy(uid => uid.VolumeId, uid => uid.LinkId) + .ToAsyncEnumerable() + .SelectMany(linkGroup => EnumerateNodesAsync(client, linkGroup.Key, linkGroup, cancellationToken)); + } + public static async IAsyncEnumerable> EnumerateNodesAsync( ProtonDriveClient client, VolumeId volumeId, diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs similarity index 59% rename from cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNode.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs index f4c463df..b81ead74 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs @@ -1,6 +1,4 @@ -using Proton.Drive.Sdk.Nodes; - -namespace Proton.Photos.Sdk.Nodes; +namespace Proton.Drive.Sdk.Nodes; public sealed record PhotoNode : FileNode { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs similarity index 76% rename from cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs index c71552c1..0de40663 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosFileUploadMetadata.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs @@ -1,7 +1,6 @@ -using Proton.Drive.Sdk.Nodes; -using Proton.Photos.Sdk.Api.Photos; +using Proton.Drive.Sdk.Api.Photos; -namespace Proton.Photos.Sdk.Nodes; +namespace Proton.Drive.Sdk.Nodes; public sealed class PhotosFileUploadMetadata : FileUploadMetadata { diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs similarity index 69% rename from cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs index 22702276..c1e5e36e 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosNodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs @@ -1,19 +1,17 @@ using System.Runtime.CompilerServices; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; -using Proton.Photos.Sdk.Api.Photos; using Proton.Sdk; using Proton.Sdk.Api; -using VolumeOperations = Proton.Photos.Sdk.Volumes.VolumeOperations; -namespace Proton.Photos.Sdk.Nodes; +namespace Proton.Drive.Sdk.Nodes; internal static class PhotosNodeOperations { - private const int PhotosPageSize = 500; + private const int TimelinePageSize = 500; public static async ValueTask GetPhotosFolderAsync(ProtonPhotosClient client, CancellationToken cancellationToken) { @@ -31,54 +29,26 @@ public static async ValueTask GetPhotosFolderAsync(ProtonPhotosClien return (FolderNode)metadata.Node; } - public static async IAsyncEnumerable> EnumerateNodesAsync( - ProtonPhotosClient client, - VolumeId volumeId, - IEnumerable linkIds, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var batchLoader = new PhotoNodeBatchLoader(client, volumeId); - - foreach (var linkId in linkIds) - { - var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(new NodeUid(volumeId, linkId), cancellationToken).ConfigureAwait(false); - - if (cachedChildNodeInfo is null) - { - foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) - { - yield return nodeResult; - } - } - else - { - yield return cachedChildNodeInfo.Value.NodeProvisionResult; - } - } - - foreach (var nodeResult in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) - { - yield return nodeResult; - } - } - public static async IAsyncEnumerable EnumeratePhotosTimelineAsync( ProtonPhotosClient client, - NodeUid folderUid, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var anchorLinkId = default(LinkId?); do { - var request = new TimelinePhotoListRequest { VolumeId = folderUid.VolumeId, PreviousPageLastLinkId = anchorLinkId }; + var rootFolderNode = await GetPhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); + + var photosVolumeId = rootFolderNode.Uid.VolumeId; + + var request = new TimelinePhotoListRequest { VolumeId = photosVolumeId, PreviousPageLastLinkId = anchorLinkId }; var response = await client.PhotosApi.GetTimelinePhotosAsync(request, cancellationToken).ConfigureAwait(false); - anchorLinkId = response.Photos.Count == PhotosPageSize ? response.Photos[^1].Id : null; + anchorLinkId = response.Photos.Count == TimelinePageSize ? response.Photos[^1].Id : null; foreach (var photo in response.Photos) { - var photoUid = new NodeUid(folderUid.VolumeId, photo.Id); + var photoUid = new NodeUid(photosVolumeId, photo.Id); yield return new PhotosTimelineItem(photoUid, photo.CaptureTime); } @@ -119,7 +89,7 @@ private static async ValueTask GetFreshPhotosFolderAsync(ProtonPhoto var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( photosClient.DriveClient, - photosClient.Cache.Entities, + photosClient.DriveClient.Cache.Entities, photosClient.Cache.Secrets, volumeDto.Id, linkDetailsDto, diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosTimelineItem.cs similarity index 52% rename from cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosTimelineItem.cs index 74dd0d5e..de0c9490 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotosTimelineItem.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosTimelineItem.cs @@ -1,5 +1,3 @@ -using Proton.Drive.Sdk.Nodes; - -namespace Proton.Photos.Sdk.Nodes; +namespace Proton.Drive.Sdk.Nodes; public sealed record PhotosTimelineItem(NodeUid Uid, DateTime CaptureTime); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs index a2f4b704..7f6ec061 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs @@ -19,7 +19,9 @@ public static async ValueTask> FindRo throw new ProtonDriveException("Folder structure loop detected"); } - nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, (NodeUid)parentUid, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, (NodeUid)parentUid, knownShareAndKey: null, cancellationToken) + .ConfigureAwait(false); + parentUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index c2559b29..fa0b3e3b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -10,7 +10,6 @@ using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Resilience; using Proton.Sdk; -using Proton.Sdk.Drive; namespace Proton.Drive.Sdk.Nodes.Upload; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 0d27d6d6..4555f334 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -86,7 +86,15 @@ internal static async ValueTask CreateAsync( LogAcquiredRevisionCreationSemaphore(logger, expectedNumberOfBlocks); - return new FileUploader(client, revisionDraftProvider, size, lastModificationTime, additionalExtendedAttributes, expectedSha1, expectedNumberOfBlocks, logger); + return new FileUploader( + client, + revisionDraftProvider, + size, + lastModificationTime, + additionalExtendedAttributes, + expectedSha1, + expectedNumberOfBlocks, + logger); } [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire {Count} from revision creation semaphore")] diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs similarity index 87% rename from cs/sdk/src/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs rename to cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs index dbb78392..4467ca3f 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/Upload/PhotosFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs @@ -1,7 +1,4 @@ -using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Nodes.Upload; - -namespace Proton.Photos.Sdk.Nodes.Upload; +namespace Proton.Drive.Sdk.Nodes.Upload; public sealed class PhotosFileUploader : IDisposable { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj index 960bfe7a..6bc30714 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -18,7 +18,6 @@ - diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 0e6e882a..c62a339c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -65,7 +65,7 @@ public ProtonDriveClient( internal ProtonDriveClient( IAccountClient accountClient, - IDriveApiClients apiClients, + IDriveApiClients api, IDriveClientCache cache, IBlockVerifierFactory blockVerifierFactory, IFeatureFlagProvider featureFlagProvider, @@ -76,7 +76,7 @@ internal ProtonDriveClient( Uid = uid; Account = accountClient; - Api = apiClients; + Api = api; Cache = cache; BlockVerifierFactory = blockVerifierFactory; Telemetry = telemetry; @@ -148,7 +148,12 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio return NodeOperations .EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken) .Select(x => (Result?)x) - .FirstOrDefaultAsync(cancellationToken: cancellationToken); + .FirstOrDefaultAsync(cancellationToken); + } + + public IAsyncEnumerable> EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) + { + return NodeOperations.EnumerateNodesAsync(this, nodeUids, cancellationToken); } public ValueTask CreateFolderAsync(NodeUid parentId, string name, DateTime? lastModificationTime, CancellationToken cancellationToken) @@ -258,6 +263,7 @@ private async ValueTask GetFileUploaderAsync( ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { - return await FileUploader.CreateAsync(this, revisionDraftProvider, size, lastModificationTime, additionalMetadata, expectedSha1, cancellationToken).ConfigureAwait(false); + return await FileUploader.CreateAsync(this, revisionDraftProvider, size, lastModificationTime, additionalMetadata, expectedSha1, cancellationToken) + .ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs similarity index 80% rename from cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index 74ed7f72..0bbef6b9 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -1,18 +1,16 @@ using System.Diagnostics.CodeAnalysis; -using Proton.Drive.Sdk; +using Proton.Drive.Sdk.Api; +using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Http; using Proton.Drive.Sdk.Nodes; -using Proton.Photos.Sdk.Api; -using Proton.Photos.Sdk.Caching; -using Proton.Photos.Sdk.Nodes; -using Proton.Photos.Sdk.Nodes.Download; -using Proton.Photos.Sdk.Nodes.Upload; +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.Http; using Proton.Sdk.Telemetry; -namespace Proton.Photos.Sdk; +namespace Proton.Drive.Sdk; public sealed class ProtonPhotosClient : IDisposable { @@ -21,7 +19,6 @@ public sealed class ProtonPhotosClient : IDisposable public ProtonPhotosClient(ProtonApiSession session, string? uid = null) { DriveClient = new ProtonDriveClient(session, uid); - _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)); Cache = new PhotosClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository); @@ -45,7 +42,6 @@ public ProtonPhotosClient( featureFlagProvider, telemetry, creationParameters); - _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout( creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds); @@ -69,24 +65,20 @@ public static ValueTask> FindDuplicatesAsync(string name, throw new NotSupportedException(); } - [Experimental("Photos")] - public ValueTask GetPhotosRootAsync(CancellationToken cancellationToken) + public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) { - return PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken); + return DriveClient.GetNodeAsync(nodeUid, cancellationToken); } - public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + public IAsyncEnumerable> EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) { - return PhotosNodeOperations - .EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken) - .Select(x => (Result?)x) - .FirstOrDefaultAsync(cancellationToken: cancellationToken); + return NodeOperations.EnumerateNodesAsync(DriveClient, nodeUids, cancellationToken); } [Experimental("Photos")] - public IAsyncEnumerable EnumeratePhotosTimelineAsync(NodeUid uid, CancellationToken cancellationToken) + public IAsyncEnumerable EnumerateTimelineAsync(CancellationToken cancellationToken) { - return PhotosNodeOperations.EnumeratePhotosTimelineAsync(this, uid, cancellationToken); + return PhotosNodeOperations.EnumeratePhotosTimelineAsync(this, cancellationToken); } public async ValueTask GetPhotosDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) @@ -94,7 +86,7 @@ public async ValueTask GetPhotosDownloaderAsync(NodeUid ph return await PhotosFileDownloader.CreateAsync(this, photoUid, cancellationToken).ConfigureAwait(false); } - public IAsyncEnumerable EnumeratePhotosThumbnailsAsync( + public IAsyncEnumerable EnumerateThumbnailsAsync( IEnumerable photoUids, ThumbnailType thumbnailType = ThumbnailType.Thumbnail, CancellationToken cancellationToken = default) @@ -106,4 +98,10 @@ public void Dispose() { _httpClient.Dispose(); } + + [Experimental("Photos")] + internal ValueTask GetPhotosRootAsync(CancellationToken cancellationToken) + { + return PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs index 1d612717..3ab9d9c5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs @@ -1,7 +1,7 @@ using System.Buffers; using Microsoft.IO; -namespace Proton.Sdk.Drive; +namespace Proton.Drive.Sdk; public static class RecyclableMemoryStreamExtensions { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs index e6753a40..5ceba20b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; using Proton.Sdk.Serialization; diff --git a/cs/sdk/src/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs similarity index 89% rename from cs/sdk/src/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs rename to cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs index b768ace6..19ced0ab 100644 --- a/cs/sdk/src/Proton.Photos.Sdk/Serialization/PhotosApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -using Proton.Photos.Sdk.Api; -using Proton.Photos.Sdk.Api.Photos; +using Proton.Drive.Sdk.Api; +using Proton.Drive.Sdk.Api.Photos; using Proton.Sdk.Serialization; -namespace Proton.Photos.Sdk.Serialization; +namespace Proton.Drive.Sdk.Serialization; #pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines [JsonSourceGenerationOptions( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index a561e249..22a3b322 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -37,7 +37,10 @@ public static async ValueTask GetShareAsync( return new ShareAndKey(share, shareKey.Value); } - public static async ValueTask GetContextShareAsync(ProtonDriveClient client, Result nodeResult, CancellationToken cancellationToken) + public static async ValueTask GetContextShareAsync( + ProtonDriveClient client, + Result nodeResult, + CancellationToken cancellationToken) { var contextRoot = await TraversalOperations.FindRootForNode(client, nodeResult, cancellationToken).ConfigureAwait(false); var contextShareId = contextRoot.Merge(x => x.MembershipShareId, x => x.MembershipShareId); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index f53de33b..09d3ef46 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -20,7 +20,9 @@ internal static class TelemetryErrorResolver FileContentsDecryptionException => DownloadError.DecryptionError, CryptographicException => DownloadError.DecryptionError, +#pragma warning disable RCS0056 // Line too long HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => DownloadError.NetworkError, +#pragma warning restore RCS0056 HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => DownloadError.ServerError, HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => DownloadError.ServerError, HttpRequestException { StatusCode: >= (HttpStatusCode)400 and < (HttpStatusCode)500 } => DownloadError.HttpClientSideError, @@ -42,7 +44,9 @@ internal static class TelemetryErrorResolver // Upload errors NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => UploadError.IntegrityError, +#pragma warning disable RCS0056 // Line too long HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => UploadError.NetworkError, +#pragma warning restore RCS0056 HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => UploadError.ServerError, HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => UploadError.ServerError, HttpRequestException { StatusCode: >= (HttpStatusCode)400 and < (HttpStatusCode)500 } => UploadError.HttpClientSideError, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 9c753b76..fa306a7e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Cryptography; @@ -114,6 +115,51 @@ public static async IAsyncEnumerable> EnumerateTrashA } } + public static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreatePhotosVolumeAsync( + ProtonPhotosClient photosClient, + CancellationToken cancellationToken) + { + var defaultAddress = await photosClient.DriveClient.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); + + var addressKey = await photosClient.DriveClient.Account.GetAddressPrimaryPrivateKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); + + var addressKeyId = defaultAddress.GetPrimaryKey().AddressKeyId; + + var request = GetPhotosCreationRequest(defaultAddress.Id, addressKeyId, addressKey, out var rootShareKey, out var rootFolderSecrets); + + var response = await photosClient.PhotosApi.CreateVolumeAsync(request, cancellationToken).ConfigureAwait(false); + + var volume = new Volume(response.Volume); + + var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id, ShareType.Photos); + + var rootFolder = new FolderNode + { + Uid = volume.RootFolderId, + ParentUid = null, + Name = RootFolderName, + NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, + Author = new Author { EmailAddress = defaultAddress.EmailAddress }, + CreationTime = DateTime.UtcNow, + }; + + // The volume root folder never has siblings and does not need a name hash digest + var nameHashDigest = ReadOnlyMemory.Empty; + + await photosClient.Cache.Entities.SetPhotosVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); + + await photosClient.DriveClient.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share.Id, nameHashDigest, cancellationToken) + .ConfigureAwait(false); + + await photosClient.Cache.Entities.SetPhotosShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); + await photosClient.DriveClient.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + + await photosClient.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); + await photosClient.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); + + return (volume, share, rootFolder); + } + public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, CancellationToken cancellationToken) { var volumeId = await GetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); @@ -175,6 +221,66 @@ private static VolumeCreationRequest GetCreationRequest( }; } + private static PhotosVolumeCreationRequest GetPhotosCreationRequest( + AddressId addressId, + AddressKeyId addressKeyId, + PgpPrivateKey addressKey, + out PgpPrivateKey rootShareKey, + out FolderSecrets rootFolderSecrets) + { + rootShareKey = CryptoGenerator.GeneratePrivateKey(); + + rootFolderSecrets = new FolderSecrets + { + Key = CryptoGenerator.GeneratePrivateKey(), + PassphraseSessionKey = CryptoGenerator.GenerateSessionKey(), + NameSessionKey = CryptoGenerator.GenerateSessionKey(), + HashKey = CryptoGenerator.GenerateFolderHashKey(), + }; + + Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var sharePassphrase = CryptoGenerator.GeneratePassphrase(sharePassphraseBuffer); + using var lockedShareKey = rootShareKey.Lock(sharePassphrase); + + var encryptedSharePassphrase = addressKey.EncryptAndSign(sharePassphrase, addressKey, out var sharePassphraseSignature); + + Span folderPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var folderPassphrase = CryptoGenerator.GeneratePassphrase(folderPassphraseBuffer); + using var lockedFolderKey = rootFolderSecrets.Key.Lock(folderPassphrase); + + var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.PassphraseSessionKey); + var encryptedFolderPassphrase = PgpEncrypter.EncryptAndSign( + folderPassphrase, + folderPassphraseEncryptionSecrets, + addressKey, + out var folderPassphraseSignature); + + var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.NameSessionKey); + var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); + + var encryptedHashKey = rootFolderSecrets.Key.EncryptAndSign(rootFolderSecrets.HashKey.Span, addressKey); + + return new PhotosVolumeCreationRequest + { + Share = new PhotosVolumeShareCreationParameters + { + AddressId = addressId, + AddressKeyId = addressKeyId, + Key = lockedShareKey.ToBytes(), + Passphrase = encryptedSharePassphrase, + PassphraseSignature = sharePassphraseSignature, + }, + Link = new PhotosVolumeLinkCreationParameters + { + Name = encryptedName, + NodeKey = lockedFolderKey.ToBytes(), + NodePassphrase = encryptedFolderPassphrase, + NodePassphraseSignature = folderPassphraseSignature, + NodeHashKey = encryptedHashKey, + }, + }; + } + private static async ValueTask GetMainVolumeIdAsync(ProtonDriveClient client, CancellationToken cancellationToken) { // TODO: optimize this, which is overkill to just get the volume ID diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs deleted file mode 100644 index ccca8237..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/PhotoDetailsResponse.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Proton.Sdk.Api; - -namespace Proton.Photos.Sdk.Api; - -internal sealed class PhotoDetailsResponse : ApiResponse -{ - public required IReadOnlyList Links { get; init; } -} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs b/cs/sdk/src/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs deleted file mode 100644 index a43b176d..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Api/PhotoLinkDetailsDto.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Proton.Drive.Sdk.Api.Folders; -using Proton.Drive.Sdk.Api.Links; -using Proton.Photos.Sdk.Api.Photos; - -namespace Proton.Photos.Sdk.Api; - -internal sealed class PhotoLinkDetailsDto -{ - public required LinkDto Link { get; init; } - public PhotoDto? Photo { get; init; } - public FolderDto? Album { get; init; } - public FolderDto? Folder { get; init; } - public LinkSharingDto? Sharing { get; init; } - public ShareMembershipSummaryDto? Membership { get; init; } - - public void Deconstruct(out LinkDto link, out PhotoDto? photo, out FolderDto? album, out FolderDto? folder, out LinkSharingDto? sharing, out ShareMembershipSummaryDto? membership) - { - link = Link; - photo = Photo; - album = Album; - folder = Folder; - sharing = Sharing; - membership = Membership; - } - - public LinkDetailsDto ToLinkDetailsDto() - { - return new LinkDetailsDto - { - Link = Link, - Folder = Folder ?? Album, - File = Photo, - Sharing = Sharing, - Membership = Membership, - }; - } -} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs deleted file mode 100644 index f6121286..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Caching/IPhotosEntityCache.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Caching; -using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Shares; -using Proton.Drive.Sdk.Volumes; - -namespace Proton.Photos.Sdk.Caching; - -internal interface IPhotosEntityCache : IEntityCache -{ - ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); - - ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken); - - ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken); - - ValueTask SetShareAsync(Share share, CancellationToken cancellationToken); - - ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); -} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs b/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs deleted file mode 100644 index af70c2e8..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Caching/PhotosEntityCache.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Text.Json; -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Serialization; -using Proton.Drive.Sdk.Shares; -using Proton.Drive.Sdk.Volumes; -using Proton.Sdk; -using Proton.Sdk.Caching; - -namespace Proton.Photos.Sdk.Caching; - -internal sealed class PhotosEntityCache(ICacheRepository repository) : IPhotosEntityCache -{ - private const string PhotoVolumeIdCacheKey = "volume:photos:id"; - private const string PhotosShareIdCacheKey = "share:photos:id"; - - private readonly ICacheRepository _repository = repository; - - public ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken) - { - return _repository.SetAsync(PhotoVolumeIdCacheKey, volumeId.ToString(), cancellationToken); - } - - public async ValueTask TryGetPhotosVolumeIdAsync(CancellationToken cancellationToken) - { - var value = await _repository.TryGetAsync(PhotoVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); - - return value is not null ? (VolumeId?)value : null; - } - - public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken) - { - return _repository.SetAsync(PhotosShareIdCacheKey, shareId.ToString(), cancellationToken); - } - - public async ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken) - { - var value = await _repository.TryGetAsync(PhotosShareIdCacheKey, cancellationToken).ConfigureAwait(false); - - return value is not null ? (ShareId)value : null; - } - - public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) - { - var serializedValue = JsonSerializer.Serialize(share, DriveEntitiesSerializerContext.Default.Share); - - return _repository.SetAsync(GetShareCacheKey(share.Id), serializedValue, cancellationToken); - } - - public ValueTask SetNodeAsync( - NodeUid nodeId, - Result nodeProvisionResult, - ShareId? membershipShareId, - ReadOnlyMemory nameHashDigest, - CancellationToken cancellationToken) - { - var serializedValue = JsonSerializer.Serialize( - new CachedNodeInfo(nodeProvisionResult, membershipShareId, nameHashDigest), - DriveEntitiesSerializerContext.Default.CachedNodeInfo); - - return _repository.SetAsync(GetNodeCacheKey(nodeId), serializedValue, cancellationToken); - } - - public async ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken) - { - var serializedValue = await _repository.TryGetAsync(GetNodeCacheKey(nodeId), cancellationToken).ConfigureAwait(false); - - return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.CachedNodeInfo) - : null; - } - - private static string GetShareCacheKey(ShareId shareId) - { - return $"share:{shareId}"; - } - - private static string GetNodeCacheKey(NodeUid nodeId) - { - return $"node:{nodeId}"; - } -} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs deleted file mode 100644 index 7df52187..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoDtoToMetadataConverter.cs +++ /dev/null @@ -1,196 +0,0 @@ -using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk; -using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Shares; -using Proton.Drive.Sdk.Volumes; -using Proton.Photos.Sdk.Api; -using Proton.Sdk; - -namespace Proton.Photos.Sdk.Nodes; - -internal static class PhotoDtoToMetadataConverter -{ - public static async Task> ConvertDtoToNodeMetadataAsync( - ProtonPhotosClient client, - VolumeId volumeId, - PhotoLinkDetailsDto photoLinkDetailsDto, - ShareAndKey? knownShareAndKey, - CancellationToken cancellationToken) - { - if (photoLinkDetailsDto.Link.ParentId == null && photoLinkDetailsDto.Sharing?.ShareId == null && photoLinkDetailsDto.Photo?.Albums.Count == 0) - { - throw new InvalidOperationException("Photo node has no parent, share or album"); - } - - LinkId? parentId; - - if (photoLinkDetailsDto.Link.ParentId != null || photoLinkDetailsDto.Sharing?.ShareId != null) - { - parentId = photoLinkDetailsDto.Link.ParentId; - } - else - { - // TODO: Optimization - // If more than one album is available, select an album with a cached key to avoid a redundant HTTP request and decryption. - parentId = photoLinkDetailsDto.Photo?.Albums[0].Id; - } - - var parentKeyResult = await GetParentKeyAsync( - client, - volumeId, - parentId, - knownShareAndKey, - photoLinkDetailsDto.Sharing?.ShareId, - cancellationToken).ConfigureAwait(false); - - return await ConvertDtoToNodeMetadataAsync(client, volumeId, photoLinkDetailsDto, parentKeyResult, cancellationToken).ConfigureAwait(false); - } - - private static async Task> ConvertDtoToNodeMetadataAsync( - ProtonPhotosClient client, - VolumeId volumeId, - PhotoLinkDetailsDto photoLinkDetailsDto, - Result parentKeyResult, - CancellationToken cancellationToken) - { - var linkType = photoLinkDetailsDto.Link.Type; - var linkDetailsDto = photoLinkDetailsDto.ToLinkDetailsDto(); - - return linkType switch - { - LinkType.File => - (await DtoToMetadataConverter.ConvertDtoToFileMetadataAsync( - client.DriveClient, - client.Cache.Entities, - client.Cache.Secrets, - volumeId, - linkDetailsDto, - parentKeyResult, - cancellationToken).ConfigureAwait(false)) - .Convert(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), - - LinkType.Album => - (await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - client.DriveClient, - client.Cache.Entities, - client.Cache.Secrets, - volumeId, - linkDetailsDto, - parentKeyResult, - cancellationToken).ConfigureAwait(false)) - .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), - - LinkType.Folder => - (await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - client.DriveClient, - client.Cache.Entities, - client.Cache.Secrets, - volumeId, - linkDetailsDto, - parentKeyResult, - cancellationToken).ConfigureAwait(false)) - .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), - - _ => throw new NotSupportedException($"Link type {linkType} is not supported."), - }; - } - - private static async ValueTask> GetParentKeyAsync( - ProtonPhotosClient client, - VolumeId volumeId, - LinkId? parentId, - ShareAndKey? shareAndKeyToUse, - ShareId? childShareId, - CancellationToken cancellationToken) - { - if (childShareId is not null && childShareId == shareAndKeyToUse?.Share.Id) - { - return shareAndKeyToUse.Value.Key; - } - - var currentId = parentId; - var currentShareId = childShareId; - - // FIXME, we don't have nested folders in photos, max depth is 3 including photo. - var linkAncestry = new Stack(8); - - PgpPrivateKey? lastKey = null; - - try - { - while (currentId is not null) - { - if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) - { - lastKey = shareKeyToUse; - break; - } - - var nodeUid = new NodeUid(volumeId, currentId.Value); - - var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(nodeUid, cancellationToken).ConfigureAwait(false); - - var folderKey = folderSecretsResult?.Merge(x => x.Key, x => x.Key); - - if (folderKey is not null) - { - lastKey = folderKey.Value; - break; - } - - var response = await client.PhotosApi.GetDetailsAsync(volumeId, [currentId.Value], cancellationToken).ConfigureAwait(false); - - var photoLinkDetails = response.Links[0]; - - linkAncestry.Push(photoLinkDetails); - - currentShareId = photoLinkDetails.Sharing?.ShareId; - - currentId = photoLinkDetails.Link.ParentId; - } - } - catch (Exception e) - { - return new ProtonDriveError(e.Message); - } - - if (lastKey is not { } currentParentKey) - { - if (shareAndKeyToUse is not null) - { - currentParentKey = shareAndKeyToUse.Value.Key; - } - else - { - if (currentShareId is null) - { - return new ProtonDriveError("No share available to access node"); - } - - (_, currentParentKey) = await ShareOperations.GetShareAsync(client.DriveClient, currentShareId.Value, cancellationToken).ConfigureAwait(false); - } - } - - while (linkAncestry.TryPop(out var ancestorLinkDetails)) - { - var decryptionResult = await ConvertDtoToNodeMetadataAsync( - client, - volumeId, - ancestorLinkDetails, - currentParentKey, - cancellationToken).ConfigureAwait(false); - - if (!decryptionResult.TryGetFolderKeyElseError(out var folderKey, out var error)) - { - // TODO: wrap error for more context? - return error; - } - - currentParentKey = folderKey.Value; - } - - return currentParentKey; - } -} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs b/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs deleted file mode 100644 index 32cf2393..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Nodes/PhotoNodeBatchLoader.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Runtime.InteropServices; -using Proton.Drive.Sdk; -using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Volumes; -using Proton.Sdk; - -namespace Proton.Photos.Sdk.Nodes; - -internal sealed class PhotoNodeBatchLoader(ProtonPhotosClient client, VolumeId volumeId) : BatchLoaderBase> -{ - private readonly ProtonPhotosClient _client = client; - - protected override async ValueTask>> LoadBatchAsync( - ReadOnlyMemory ids, - CancellationToken cancellationToken) - { - var nodeResults = new List>(ids.Length); - - var response = await _client.PhotosApi.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); - - foreach (var linkDetails in response.Links) - { - var nodeMetadataResult = await PhotoDtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( - _client, - volumeId, - linkDetails, - knownShareAndKey: null, - cancellationToken).ConfigureAwait(false); - - var nodeResult = nodeMetadataResult.ToNodeResult(); - - nodeResults.Add(nodeResult); - } - - return nodeResults; - } -} diff --git a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj b/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj deleted file mode 100644 index 8051c62c..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Proton.Photos.Sdk.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - true - Cloud Storage Volume Photo Album - Provides the means to interact with the Proton Photos services. - true - true - snupkg - - - - - - - - - - - diff --git a/cs/sdk/src/Proton.Photos.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Photos.Sdk/Volumes/VolumeOperations.cs deleted file mode 100644 index db2996b9..00000000 --- a/cs/sdk/src/Proton.Photos.Sdk/Volumes/VolumeOperations.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk; -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Cryptography; -using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Shares; -using Proton.Drive.Sdk.Volumes; -using Proton.Photos.Sdk.Api.Photos; -using Proton.Sdk.Addresses; - -namespace Proton.Photos.Sdk.Volumes; - -internal static class VolumeOperations -{ - private const string RootFolderName = "root"; - - public static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreatePhotosVolumeAsync( - ProtonPhotosClient photosClient, - CancellationToken cancellationToken) - { - var defaultAddress = await photosClient.DriveClient.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); - - var addressKey = await photosClient.DriveClient.Account.GetAddressPrimaryPrivateKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); - - var addressKeyId = defaultAddress.GetPrimaryKey().AddressKeyId; - - var request = GetCreationRequest(defaultAddress.Id, addressKeyId, addressKey, out var rootShareKey, out var rootFolderSecrets); - - var response = await photosClient.PhotosApi.CreateVolumeAsync(request, cancellationToken).ConfigureAwait(false); - - var volume = new Volume(response.Volume); - - var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id, ShareType.Photos); - - var rootFolder = new FolderNode - { - Uid = volume.RootFolderId, - ParentUid = null, - Name = RootFolderName, - NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, - Author = new Author { EmailAddress = defaultAddress.EmailAddress }, - CreationTime = DateTime.UtcNow, - }; - - // The volume root folder never has siblings and does not need a name hash digest - var nameHashDigest = ReadOnlyMemory.Empty; - - await photosClient.Cache.Entities.SetPhotosVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); - await photosClient.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share.Id, nameHashDigest, cancellationToken).ConfigureAwait(false); - await photosClient.Cache.Entities.SetPhotosShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); - await photosClient.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); - - await photosClient.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); - await photosClient.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); - - return (volume, share, rootFolder); - } - - private static PhotosVolumeCreationRequest GetCreationRequest( - AddressId addressId, - AddressKeyId addressKeyId, - PgpPrivateKey addressKey, - out PgpPrivateKey rootShareKey, - out FolderSecrets rootFolderSecrets) - { - rootShareKey = CryptoGenerator.GeneratePrivateKey(); - - rootFolderSecrets = new FolderSecrets - { - Key = CryptoGenerator.GeneratePrivateKey(), - PassphraseSessionKey = CryptoGenerator.GenerateSessionKey(), - NameSessionKey = CryptoGenerator.GenerateSessionKey(), - HashKey = CryptoGenerator.GenerateFolderHashKey(), - }; - - Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; - var sharePassphrase = CryptoGenerator.GeneratePassphrase(sharePassphraseBuffer); - using var lockedShareKey = rootShareKey.Lock(sharePassphrase); - - var encryptedSharePassphrase = addressKey.EncryptAndSign(sharePassphrase, addressKey, out var sharePassphraseSignature); - - Span folderPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; - var folderPassphrase = CryptoGenerator.GeneratePassphrase(folderPassphraseBuffer); - using var lockedFolderKey = rootFolderSecrets.Key.Lock(folderPassphrase); - - var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.PassphraseSessionKey); - var encryptedFolderPassphrase = PgpEncrypter.EncryptAndSign( - folderPassphrase, - folderPassphraseEncryptionSecrets, - addressKey, - out var folderPassphraseSignature); - - var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.NameSessionKey); - var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); - - var encryptedHashKey = rootFolderSecrets.Key.EncryptAndSign(rootFolderSecrets.HashKey.Span, addressKey); - - return new PhotosVolumeCreationRequest - { - Share = new PhotosVolumeShareCreationParameters - { - AddressId = addressId, - AddressKeyId = addressKeyId, - Key = lockedShareKey.ToBytes(), - Passphrase = encryptedSharePassphrase, - PassphraseSignature = sharePassphraseSignature, - }, - Link = new PhotosVolumeLinkCreationParameters - { - Name = encryptedName, - NodeKey = lockedFolderKey.ToBytes(), - NodePassphrase = encryptedFolderPassphrase, - NodePassphraseSignature = folderPassphraseSignature, - NodeHashKey = encryptedHashKey, - }, - }; - } -} diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs index f7e2bad6..f54c2416 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs @@ -1,4 +1,3 @@ -using System.Runtime.InteropServices; using System.Text; namespace Proton.Sdk.CExports; diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs index 2e4b1308..1d880835 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -123,7 +123,9 @@ public override long Seek(long offset, SeekOrigin origin) }; var requestBytes = request.ToByteArray(); - var newPosition = _seekAction.Value.InvokeWithBufferAsync(_bindingsHandle, requestBytes).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + + // TODO: use sync call + var newPosition = _seekAction.Value.InvokeWithBufferAsync(_bindingsHandle, requestBytes).AsTask().GetAwaiter().GetResult(); _position = newPosition.Value; diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs index a602e967..5c4752e9 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs @@ -1,6 +1,4 @@ -using Proton.Sdk.Api; - -namespace Proton.Sdk.Api.Addresses; +namespace Proton.Sdk.Api.Addresses; internal sealed class AddressListResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs index 8661cd93..99177713 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs @@ -1,6 +1,4 @@ -using Proton.Sdk.Api; - -namespace Proton.Sdk.Api.Addresses; +namespace Proton.Sdk.Api.Addresses; internal sealed class AddressResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs index 252f1874..ea52cdff 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Api; using Proton.Sdk.Authentication; using Proton.Sdk.Events; using Proton.Sdk.Users; diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs index f19d7bdd..d505f4e9 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs @@ -1,5 +1,4 @@ using Proton.Cryptography.Srp; -using Proton.Sdk.Api; using Proton.Sdk.Authentication; namespace Proton.Sdk.Api.Authentication; diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs index 0834f3e6..b8f5a146 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Api; namespace Proton.Sdk.Api.Authentication; diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs index 2b8fd795..e439393e 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs @@ -1,6 +1,4 @@ -using Proton.Sdk.Api; - -namespace Proton.Sdk.Api.Authentication; +namespace Proton.Sdk.Api.Authentication; internal sealed class ScopesResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs index db52ae09..801cf18f 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Api; namespace Proton.Sdk.Api.Authentication; diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs index 0db64569..efe63b7a 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Api; using Proton.Sdk.Authentication; namespace Proton.Sdk.Api.Authentication; diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs index 60e9d03c..900e504d 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Api; using Proton.Sdk.Events; using Proton.Sdk.Serialization; diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs index 64d2ee35..5e2fbbb5 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Api; using Proton.Sdk.Events; namespace Proton.Sdk.Api.Events; diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs index d07dc19e..874593ed 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs @@ -1,6 +1,4 @@ -using Proton.Sdk.Api; - -namespace Proton.Sdk.Api.Keys; +namespace Proton.Sdk.Api.Keys; internal sealed class KeySaltListResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs index 92d13558..8d2e1c93 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs @@ -1,6 +1,4 @@ -using Proton.Sdk.Api; - -namespace Proton.Sdk.Api.Users; +namespace Proton.Sdk.Api.Users; internal sealed class UserResponse : ApiResponse { diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs index d49a1fd0..a51331c7 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -1,5 +1,4 @@ using System.Net.Http.Json; -using System.Runtime.ExceptionServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Proton.Sdk.Api; diff --git a/cs/sdk/src/Proton.Sdk/Result.cs b/cs/sdk/src/Proton.Sdk/Result.cs index 1e6ac5c6..6d28d608 100644 --- a/cs/sdk/src/Proton.Sdk/Result.cs +++ b/cs/sdk/src/Proton.Sdk/Result.cs @@ -42,7 +42,7 @@ public static Result Failure(TError error) return new Result(error); } - public bool TryGetValueElseError([MaybeNullWhen(false)] out T value, [MaybeNullWhen(true)] out TError error) + public bool TryGetValueElseError([NotNullWhen(true)] out T? value, [NotNullWhen(false)] out TError? error) { value = _value; error = _error; diff --git a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs index 6a6e3a71..a03695ad 100644 --- a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs @@ -37,7 +37,7 @@ public static bool TryGetValue(this Result result, [MaybeN } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryGetError(this Result result, [MaybeNullWhen(false)] out TError error) + public static bool TryGetError(this Result result, [NotNullWhen(true)] out TError? error) { return !result.TryGetValueElseError(out _, out error); } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 4037f6a1..55b093f1 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -46,15 +46,14 @@ message Request { DrivePhotosClientCreateRequest drive_photos_client_create = 1300; DrivePhotosClientCreateFromSessionRequest drive_photos_client_create_from_session = 1301; DrivePhotosClientFreeRequest drive_photos_client_free = 1302; - DrivePhotosClientGetPhotosRootRequest drive_photos_client_get_photos_root = 1303; - DrivePhotosClientEnumeratePhotosThumbnailsRequest drive_photos_client_enumerate_photos_thumbnails = 1304; - DrivePhotosClientGetPhotoDownloaderRequest drive_photos_client_get_photo_downloader = 1305; - DrivePhotosClientDownloadToStreamRequest drive_photos_client_download_to_stream = 1306; - DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1307; - DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1308; - DrivePhotosClientEnumeratePhotosTimelineRequest drive_photos_client_enumerate_photos_timeline = 1309; + DrivePhotosClientEnumeratePhotosThumbnailsRequest drive_photos_client_enumerate_photos_thumbnails = 1303; + DrivePhotosClientGetPhotoDownloaderRequest drive_photos_client_get_photo_downloader = 1304; + DrivePhotosClientDownloadToStreamRequest drive_photos_client_download_to_stream = 1305; + DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1306; + DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1307; + DrivePhotosClientGetNodeRequest drive_photos_client_get_node = 1308; + DrivePhotosClientEnumerateTimelineRequest drive_photos_client_enumerate_timeline = 1309; DrivePhotosClientGetPhotoUploaderRequest drive_photos_client_get_photo_uploader = 1310; - DrivePhotosClientGetNodeRequest drive_photos_client_get_node = 1315; DrivePhotosClientFindDuplicatesRequest drive_photos_client_find_duplicates = 1311; DrivePhotosClientUploadFromStreamRequest drive_photos_client_upload_from_stream = 1312; DrivePhotosClientUploadFromFileRequest drive_photos_client_upload_from_file = 1313; @@ -587,12 +586,6 @@ message DrivePhotosClientFreeRequest { int64 client_handle = 1; } -// The response message must be of type FolderNode -message DrivePhotosClientGetPhotosRootRequest { - int64 client_handle = 1; - int64 cancellation_token_source_handle = 2; -} - // The response message must be of type FileThumbnailList. message DrivePhotosClientEnumeratePhotosThumbnailsRequest { int64 client_handle = 1; @@ -602,10 +595,9 @@ message DrivePhotosClientEnumeratePhotosThumbnailsRequest { } // The response message must be of type PhotosTimelineList. -message DrivePhotosClientEnumeratePhotosTimelineRequest { +message DrivePhotosClientEnumerateTimelineRequest { int64 client_handle = 1; - string folder_uid = 2; - int64 cancellation_token_source_handle = 3; + int64 cancellation_token_source_handle = 2; } // The response message must be of type NodeResult (nullable). diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 651d2c0f..77089cb5 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -2,7 +2,6 @@ package me.proton.drive.sdk import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO -import me.proton.drive.sdk.entity.FolderNode import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.entity.PhotosTimelineItem import me.proton.drive.sdk.entity.ThumbnailType @@ -13,9 +12,8 @@ import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.drivePhotosClientEnumeratePhotosThumbnailsRequest -import proton.drive.sdk.drivePhotosClientEnumeratePhotosTimelineRequest +import proton.drive.sdk.drivePhotosClientEnumerateTimelineRequest import proton.drive.sdk.drivePhotosClientGetNodeRequest -import proton.drive.sdk.drivePhotosClientGetPhotosRootRequest import java.nio.channels.WritableByteChannel class ProtonPhotosClient internal constructor( @@ -44,21 +42,10 @@ class ProtonPhotosClient internal constructor( } } - suspend fun getPhotosRoot(): FolderNode = cancellationCoroutineScope { source -> - log(DEBUG, "getPhotosRoot") - bridge.getPhotosRoot( - drivePhotosClientGetPhotosRootRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun enumeratePhotosTimeline(folderUid: String): List = cancellationCoroutineScope { source -> - log(DEBUG, "enumeratePhotosTimeline") - bridge.enumeratePhotosTimeline( - drivePhotosClientEnumeratePhotosTimelineRequest { - this.folderUid = folderUid + suspend fun enumerateTimeline(): List = cancellationCoroutineScope { source -> + log(DEBUG, "enumerateTimeline") + bridge.enumerateTimeline( + drivePhotosClientEnumerateTimelineRequest { clientHandle = handle cancellationTokenSourceHandle = source.handle } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index bcd900c6..804f0451 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -3,7 +3,6 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.converter.FileThumbnailListConverter -import me.proton.drive.sdk.converter.FolderNodeConverter import me.proton.drive.sdk.converter.NodeResultConverter import me.proton.drive.sdk.converter.PhotosTimelineListConverter import me.proton.drive.sdk.entity.ClientCreateRequest @@ -84,17 +83,11 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { drivePhotosClientEnumeratePhotosThumbnails = request } - suspend fun getPhotosRoot( - request: ProtonDriveSdk.DrivePhotosClientGetPhotosRootRequest, - ): ProtonDriveSdk.FolderNode = executeOnce("getPhotosRoot", FolderNodeConverter().asCallback) { - drivePhotosClientGetPhotosRoot = request - } - - suspend fun enumeratePhotosTimeline( - request: ProtonDriveSdk.DrivePhotosClientEnumeratePhotosTimelineRequest, + suspend fun enumerateTimeline( + request: ProtonDriveSdk.DrivePhotosClientEnumerateTimelineRequest, ): ProtonDriveSdk.PhotosTimelineList = - executeOnce("enumeratePhotosTimeline", PhotosTimelineListConverter().asCallback) { - drivePhotosClientEnumeratePhotosTimeline = request + executeOnce("enumerateTimeline", PhotosTimelineListConverter().asCallback) { + drivePhotosClientEnumerateTimeline = request } suspend fun getNode( diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 8f945888..2dc9fd33 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -109,22 +109,6 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { } extension ProtonPhotosClient { - public func getPhotosRoot() async throws -> FolderNode { - let cancellationTokenSource = try await CancellationTokenSource(logger: logger) - defer { - cancellationTokenSource.free() - } - - let cancellationHandle = cancellationTokenSource.handle - - let request = Proton_Drive_Sdk_DrivePhotosClientGetPhotosRootRequest.with { - $0.clientHandle = Int64(clientHandle) - $0.cancellationTokenSourceHandle = Int64(cancellationHandle) - } - let sdkNode: Proton_Drive_Sdk_FolderNode = try await SDKRequestHandler.send(request, logger: logger) - return try FolderNode(sdkFolderNode: sdkNode) - } - public func enumerateTimeline(in folderUid: SDKNodeUid) async throws -> [PhotoTimelineItem] { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) defer { @@ -133,9 +117,8 @@ extension ProtonPhotosClient { let cancellationHandle = cancellationTokenSource.handle - let request = Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosTimelineRequest.with { + let request = Proton_Drive_Sdk_DrivePhotosClientEnumerateTimelineRequest.with { $0.clientHandle = Int64(clientHandle) - $0.folderUid = folderUid.sdkCompatibleIdentifier $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 76ff2ed1..a642b982 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -202,19 +202,14 @@ extension Message { $0.payload = .drivePhotosClientFree(request) } - case let request as Proton_Drive_Sdk_DrivePhotosClientGetPhotosRootRequest: - Proton_Drive_Sdk_Request.with { - $0.payload = .drivePhotosClientGetPhotosRoot(request) - } - case let request as Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosThumbnailsRequest: Proton_Drive_Sdk_Request.with { $0.payload = .drivePhotosClientEnumeratePhotosThumbnails(request) } - case let request as Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosTimelineRequest: + case let request as Proton_Drive_Sdk_DrivePhotosClientEnumerateTimelineRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .drivePhotosClientEnumeratePhotosTimeline(request) + $0.payload = .drivePhotosClientEnumerateTimeline(request) } // MARK: - Photo Downloads From 1c7ed5ac6800d8527ea978ee59240355009b5305 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 14:25:05 +0000 Subject: [PATCH 511/791] Add method to remove photos from an album --- js/sdk/src/internal/photos/albums.test.ts | 27 ++++++++++++++ js/sdk/src/internal/photos/albums.ts | 15 +++++++- js/sdk/src/internal/photos/apiService.ts | 45 +++++++++++++++++++++++ js/sdk/src/protonDrivePhotosClient.ts | 21 +++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/photos/albums.test.ts b/js/sdk/src/internal/photos/albums.test.ts index 56dd6dc9..362dcbc0 100644 --- a/js/sdk/src/internal/photos/albums.test.ts +++ b/js/sdk/src/internal/photos/albums.test.ts @@ -37,6 +37,7 @@ describe('Albums', () => { createAlbum: jest.fn().mockResolvedValue('volumeId~newAlbumNodeId'), updateAlbum: jest.fn(), deleteAlbum: jest.fn(), + removePhotosFromAlbum: jest.fn(), }; // @ts-expect-error No need to implement all methods for mocking @@ -238,4 +239,30 @@ describe('Albums', () => { expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid'); }); }); + + describe('removePhotos', () => { + it('notifies nodes service only for successfully removed photos', async () => { + apiService.removePhotosFromAlbum = jest.fn().mockImplementation(async function* () { + yield { uid: 'photo1', ok: true }; + yield { uid: 'photo2', ok: false, error: 'Some error' }; + yield { uid: 'photo3', ok: true }; + }); + + const results = []; + for await (const result of albums.removePhotos('albumNodeUid', ['photo1', 'photo2', 'photo3'])) { + results.push(result); + } + + expect(results).toEqual([ + { uid: 'photo1', ok: true }, + { uid: 'photo2', ok: false, error: 'Some error' }, + { uid: 'photo3', ok: true }, + ]); + expect(apiService.removePhotosFromAlbum).toHaveBeenCalledWith('albumNodeUid', ['photo1', 'photo2', 'photo3'], undefined); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo1'); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo3'); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalledWith('photo2'); + }); + }); }); diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index e930a903..1760d916 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -1,4 +1,4 @@ -import { MemberRole, NodeType, resultOk } from '../../interface'; +import { MemberRole, NodeResult, NodeType, resultOk } from '../../interface'; import { BatchLoading } from '../batchLoading'; import { DecryptedNode } from '../nodes'; import { ALBUM_MEDIA_TYPE } from '../nodes/mediaTypes'; @@ -152,6 +152,19 @@ export class Albums { await this.nodesService.notifyNodeDeleted(nodeUid); } + async *removePhotos( + albumNodeUid: string, + photoNodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { + for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) { + if (result.ok) { + await this.nodesService.notifyNodeChanged(result.uid); + } + yield result; + } + } + private async *iterateNodesAndIgnoreMissingOnes( nodeUids: string[], signal?: AbortSignal, diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index 8db6a8f5..b0d3a94e 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,7 +1,9 @@ import { c } from 'ttag'; import { ValidationError } from '../../errors'; +import { NodeResult } from '../../interface'; import { APICodeError, DriveAPIService, drivePaths } from '../apiService'; +import { batch } from '../batch'; import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; import { makeNodeUid, splitNodeUid } from '../uids'; @@ -40,6 +42,13 @@ type PostPhotoDuplicateRequest = Extract< type PostPhotoDuplicateResponse = drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json']; +type PostRemovePhotosFromAlbumRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostRemovePhotosFromAlbumResponse = + drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['responses']['200']['content']['application/json']; + const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302; /** @@ -280,4 +289,40 @@ export class PhotosAPIService { throw error; } } + + async *removePhotosFromAlbum( + albumNodeUid: string, + photoNodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { + const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid); + + const batchSize = 50; + + for (const photoNodeUidsBatch of batch(photoNodeUids, batchSize)) { + const linkIds = photoNodeUidsBatch.map((nodeUid) => splitNodeUid(nodeUid).nodeId); + + let errorMessage: string | undefined; + try { + await this.apiService.post( + `drive/photos/volumes/${volumeId}/albums/${albumLinkId}/remove-multiple`, + { + LinkIDs: linkIds, + }, + signal, + ); + } catch (error) { + errorMessage = error instanceof Error ? error.message : c('Error').t`Unknown error`; + } + + // The API does not return individual results for each photo. + for (const uid of photoNodeUidsBatch) { + if (errorMessage) { + yield { uid, ok: false, error: errorMessage }; + } else { + yield { uid, ok: true }; + } + } + } + } } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index f4113393..acc02be3 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -580,4 +580,25 @@ export class ProtonDrivePhotosClient { // TODO: expose album type yield * convertInternalPhotoNodeIterator(this.photos.albums.iterateAlbums(signal)); } + + /** + * Removes photos from an album. + * + * Photos are not deleted, they are just removed from the album. + * If a photo was added to the timeline by the user, it will remain + * in the timeline after being removed from the album. + * + * @param albumNodeUid - The UID of the album to remove photos from. + * @param photoNodeUids - The UIDs of the photos to remove from the album. + * @param signal - An optional abort signal to cancel the operation. + * @yields NodeResult for each photo as it is processed. + */ + async *removePhotosFromAlbum( + albumNodeUid: NodeOrUid, + photoNodeUids: NodeOrUid[], + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Removing ${photoNodeUids.length} photos from album ${getUid(albumNodeUid)}`); + yield* this.photos.albums.removePhotos(getUid(albumNodeUid), getUids(photoNodeUids), signal); + } } From b927f7943cc42f41047d365151fcb58bcfa0eb54 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 09:12:39 +0000 Subject: [PATCH 512/791] Update changelog for cs/v0.7.0-alpha.7 --- cs/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 58dd62cb..6462c4a0 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## cs/v0.7.0-alpha.7 (2026-02-11) + +* Abort pause state on non-resumable upload errors +* Exclude integrity errors from being resumable during upload +* Update changelog for cs/v0.7.0-alpha.6 + ## cs/v0.7.0-alpha.6 (2026-02-10) * Add SHA1 upload verification From 69ef9fd8329bd982ac6ba5335816efe84f0c0f42 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Feb 2026 07:54:38 +0100 Subject: [PATCH 513/791] Add iterator of album photos --- js/sdk/src/internal/photos/albums.ts | 6 ++++- js/sdk/src/internal/photos/apiService.ts | 29 ++++++++++++++++++++++++ js/sdk/src/internal/photos/index.ts | 2 +- js/sdk/src/internal/photos/interface.ts | 24 ++++++++++++++++++++ js/sdk/src/internal/photos/timeline.ts | 7 ++---- js/sdk/src/protonDrivePhotosClient.ts | 29 ++++++++++++++++-------- 6 files changed, 81 insertions(+), 16 deletions(-) diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index 1760d916..4ac021a3 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -6,7 +6,7 @@ import { validateNodeName } from '../nodes/validations'; import { splitNodeUid } from '../uids'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; -import { DecryptedPhotoNode } from './interface'; +import { AlbumItem, DecryptedPhotoNode } from './interface'; import { PhotosNodesAccess } from './nodes'; import { PhotoSharesManager } from './shares'; @@ -41,6 +41,10 @@ export class Albums { yield* batchLoading.loadRest(); } + async *iterateAlbum(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator { + yield* this.apiService.iterateAlbumChildren(albumNodeUid, signal); + } + async createAlbum(name: string): Promise { validateNodeName(name); diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index b0d3a94e..62f9d76e 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -6,6 +6,7 @@ import { APICodeError, DriveAPIService, drivePaths } from '../apiService'; import { batch } from '../batch'; import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; import { makeNodeUid, splitNodeUid } from '../uids'; +import { AlbumItem } from './interface'; type GetPhotoShareResponse = drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json']; @@ -23,6 +24,9 @@ type GetTimelineResponse = type GetAlbumsResponse = drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json']; +type GetAlbumChildrenResponse = + drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/children']['get']['responses']['200']['content']['application/json']; + type PostCreateAlbumRequest = Extract< drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['requestBody'], { content: object } @@ -182,6 +186,31 @@ export class PhotosAPIService { } } + async *iterateAlbumChildren( + albumNodeUid: string, + signal?: AbortSignal, + ): AsyncGenerator { + const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid); + let anchor = ''; + while (true) { + const response = await this.apiService.get( + `drive/photos/volumes/${volumeId}/albums/${linkId}/children?Sort=Captured&Desc=1${anchor ? `&AnchorID=${anchor}` : ''}`, + signal, + ); + for (const photo of response.Photos) { + yield { + nodeUid: makeNodeUid(volumeId, photo.LinkID), + captureTime: new Date(photo.CaptureTime * 1000), + }; + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } + } + async checkPhotoDuplicates( volumeId: string, nameHashes: string[], diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index a3bde16d..de3d3cfe 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -33,7 +33,7 @@ import { PhotoUploadMetadata, } from './upload'; -export type { DecryptedPhotoNode } from './interface'; +export type { DecryptedPhotoNode, TimelineItem, AlbumItem, PhotoTag } from './interface'; // Only photos and albums can be shared in photos volume. export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album]; diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 616be0a9..c3bccc5b 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -42,3 +42,27 @@ export type EcnryptedPhotoAttributes = Omit & { contentHash?: string; })[]; }; + +export type TimelineItem = { + nodeUid: string; + captureTime: Date; + tags: PhotoTag[]; +} + +export type AlbumItem = { + nodeUid: string; + captureTime: Date; +} + +export enum PhotoTag { + Favorites = 0, + Screenshots = 1, + Videos = 2, + LivePhotos = 3, + MotionPhotos = 4, + Selfies = 5, + Portraits = 6, + Bursts = 7, + Panoramas = 8, + Raw = 9, +} diff --git a/js/sdk/src/internal/photos/timeline.ts b/js/sdk/src/internal/photos/timeline.ts index 39df9acc..2d6f8199 100644 --- a/js/sdk/src/internal/photos/timeline.ts +++ b/js/sdk/src/internal/photos/timeline.ts @@ -2,6 +2,7 @@ import { DriveCrypto } from '../../crypto'; import { Logger } from '../../interface'; import { makeNodeUid } from '../uids'; import { PhotosAPIService } from './apiService'; +import { TimelineItem } from './interface'; import { PhotosNodesAccess } from './nodes'; import { PhotoSharesManager } from './shares'; @@ -23,11 +24,7 @@ export class PhotosTimeline { this.nodesService = nodesService; } - async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<{ - nodeUid: string; - captureTime: Date; - tags: number[]; - }> { + async *iterateTimeline(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.photoShares.getRootIDs(); yield* this.apiService.iterateTimeline(volumeId, signal); } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index acc02be3..f8e0dcd9 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -38,6 +38,9 @@ import { initPhotoSharesModule, initPhotoUploadModule, initPhotosNodesModule, + AlbumItem, + TimelineItem, + PhotoTag, } from './internal/photos'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; @@ -224,12 +227,7 @@ export class ProtonDrivePhotosClient { * The output is sorted by the capture time, starting from the * the most recent photos. */ - async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<{ - nodeUid: string; - captureTime: Date; - tags: number[]; - }> { - // TODO: expose better type + async *iterateTimeline(signal?: AbortSignal): AsyncGenerator { yield* this.photos.timeline.iterateTimeline(signal); } @@ -456,8 +454,7 @@ export class ProtonDrivePhotosClient { metadata: UploadMetadata & { captureTime?: Date; mainPhotoLinkID?: string; - // TODO: handle tags enum in the SDK - tags?: (0 | 3 | 1 | 2 | 7 | 4 | 5 | 6 | 8 | 9)[]; + tags?: PhotoTag[]; }, signal?: AbortSignal, ): Promise { @@ -581,6 +578,20 @@ export class ProtonDrivePhotosClient { yield * convertInternalPhotoNodeIterator(this.photos.albums.iterateAlbums(signal)); } + /** + * Iterates the photo placeholders of the given album. + * + * The output is sorted by the capture time, starting from the + * the most recent photos. + * + * @param albumNodeUid - The UID of the album. + * @param signal - An optional abort the operation. + */ + async *iterateAlbum(albumNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Iterating photos of album ${getUid(albumNodeUid)}`); + yield* this.photos.albums.iterateAlbum(getUid(albumNodeUid), signal); + } + /** * Removes photos from an album. * @@ -591,7 +602,7 @@ export class ProtonDrivePhotosClient { * @param albumNodeUid - The UID of the album to remove photos from. * @param photoNodeUids - The UIDs of the photos to remove from the album. * @param signal - An optional abort signal to cancel the operation. - * @yields NodeResult for each photo as it is processed. + * @returns An async generator of the removed photo results. */ async *removePhotosFromAlbum( albumNodeUid: NodeOrUid, From d8e5518cdab713d9773bd972ffa72417158403fc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 15:49:23 +0000 Subject: [PATCH 514/791] Provide expected SHA1 for upload through callback --- .../InteropFileUploader.cs | 18 ++++++ .../InteropMessageHandler.cs | 2 +- .../InteropPhotosUploader.cs | 7 +++ .../InteropProtonDriveClient.cs | 6 -- .../InteropProtonPhotosClient.cs | 3 - .../Nodes/PhotosFileUploadMetadata.cs | 2 - .../Nodes/Upload/FileUploader.cs | 23 +++++--- .../Nodes/Upload/PhotosFileUploader.cs | 2 + .../Nodes/Upload/RevisionWriter.cs | 14 +++-- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 13 ++--- .../Proton.Sdk.CExports/InteropFunction.cs | 33 +++++++++++ cs/sdk/src/protos/proton.drive.sdk.proto | 7 ++- kt/sdk/src/main/jni/global.c | 34 ++++++++++++ kt/sdk/src/main/jni/global.h | 5 ++ kt/sdk/src/main/jni/proton_drive_sdk.c | 13 +++++ .../me/proton/drive/sdk/FileUploader.kt | 4 +- .../me/proton/drive/sdk/PhotosUploader.kt | 55 ++++++++++--------- .../kotlin/me/proton/drive/sdk/Uploader.kt | 1 + .../sdk/entity/FileRevisionUploaderRequest.kt | 1 - .../drive/sdk/entity/FileUploaderRequest.kt | 1 - .../drive/sdk/entity/PhotosUploaderRequest.kt | 1 - .../sdk/extension/FileUploaderRequest.kt | 1 - .../GetFileRevisionUploaderRequest.kt | 2 - .../sdk/extension/PhotosUploaderRequest.kt | 53 +++++++++--------- .../drive/sdk/internal/JniFileUploader.kt | 5 ++ .../drive/sdk/internal/JniPhotosUploader.kt | 5 ++ .../internal/ProtonDriveSdkNativeClient.kt | 27 +++++++++ .../ProtonDriveClient/ProtonDriveClient.swift | 18 +++--- .../ProtonPhotosClient.swift | 8 +-- .../Downloads/DownloadsManager.swift | 2 +- .../Downloads/PhotoDownloadsManager.swift | 2 +- .../Uploads/PhotoUploadsManager.swift | 30 +++++----- .../Uploads/UploadOperation.swift | 34 +++++++++++- .../Uploads/UploadsManager.swift | 37 ++++++------- .../Sources/Plumbing/InternalTypes.swift | 1 + .../Plumbing/ProgressCallbackWrapper.swift | 27 +++++++-- .../Sources/Plumbing/PublicTypes.swift | 2 +- 37 files changed, 344 insertions(+), 155 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 80ff6d09..24ae268a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -28,10 +28,14 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var progressAction = new InteropAction>(request.ProgressAction); + var expectedSha1Provider = request.HasSha1Function ? + CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; + var uploadController = uploader.UploadFromStream( stream, thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), + expectedSha1Provider, cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; @@ -54,10 +58,14 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint var progressAction = new InteropAction>(request.ProgressAction); + var expectedSha1Provider = request.HasSha1Function ? + CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; + var uploadController = uploader.UploadFromFile( request.FilePath, thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), + expectedSha1Provider, cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; @@ -71,4 +79,14 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint return null; } + + internal static Func> CreateSha1Provider(nint bindingsHandle, long functionPointer) + { + var function = new InteropFunction>(functionPointer); + return () => + { + var result = function.Invoke(bindingsHandle); + return result.ToArray(); + }; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 49791e35..fa7ff0e0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Google.Protobuf.WellKnownTypes; using Proton.Sdk.CExports; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs index d4e519fc..d788564d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs @@ -26,10 +26,14 @@ public static IMessage HandleUploadFromStream(DrivePhotosClientUploadFromStreamR var progressAction = new InteropAction>(request.ProgressAction); + var expectedSha1Provider = request.HasSha1Function ? + InteropFileUploader.CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; + var uploadController = PhotosFileUploader.UploadFromStream( stream, thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), + expectedSha1Provider, cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; @@ -49,11 +53,14 @@ public static IMessage HandleUploadFromFile(DrivePhotosClientUploadFromFileReque }); var progressAction = new InteropAction>(request.ProgressAction); + var expectedSha1Provider = request.HasSha1Function ? + InteropFileUploader.CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; var uploadController = PhotosFileUploader.UploadFromFile( request.FilePath, thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), + expectedSha1Provider, cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 8e2fd61c..5e15cb90 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -117,8 +117,6 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; - var expectedSha1 = request.HasExpectedSha1 ? request.ExpectedSha1.Memory : default(ReadOnlyMemory?); - var fileUploader = await client.GetFileUploaderAsync( NodeUid.Parse(request.ParentFolderUid), request.Name, @@ -127,7 +125,6 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe request.LastModificationTime.ToDateTime(), additionalMetadata, request.OverrideExistingDraftByOtherClient, - expectedSha1, cancellationToken).ConfigureAwait(false); return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; @@ -144,14 +141,11 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; - var expectedSha1 = request.HasExpectedSha1 ? request.ExpectedSha1.Memory : default(ReadOnlyMemory?); - var fileUploader = await client.GetFileRevisionUploaderAsync( RevisionUid.Parse(request.CurrentActiveRevisionUid), request.Size, request.LastModificationTime.ToDateTime(), additionalMetadata, - expectedSha1, cancellationToken).ConfigureAwait(false); return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index a0af4f5e..b29834fd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -156,14 +156,11 @@ public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosCl ? request.Metadata.Tags.Select(t => (Api.Photos.PhotoTag)t) : null; - var expectedSha1 = request.Metadata.HasExpectedSha1 ? request.Metadata.ExpectedSha1.Memory : default(ReadOnlyMemory?); - var metadata = new PhotosFileUploadMetadata { MediaType = request.Metadata.MediaType, MainPhotoLinkId = request.Metadata.MainPhotoLinkId, ExpectedSize = request.Size, - ExpectedSha1 = expectedSha1, Tags = tags, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs index 0de40663..7e85ac7b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs @@ -10,7 +10,5 @@ public sealed class PhotosFileUploadMetadata : FileUploadMetadata public long? ExpectedSize { get; init; } - public ReadOnlyMemory? ExpectedSha1 { get; init; } - public IEnumerable? Tags { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 4555f334..9f06864e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -10,7 +10,6 @@ public sealed partial class FileUploader : IDisposable private readonly IRevisionDraftProvider _revisionDraftProvider; private readonly DateTimeOffset? _lastModificationTime; private readonly IEnumerable? _additionalMetadata; - private readonly ReadOnlyMemory? _expectedSha1; private readonly ILogger _logger; private volatile int _remainingNumberOfBlocks; @@ -21,7 +20,6 @@ private FileUploader( long size, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, - ReadOnlyMemory? expectedSha1, int expectedNumberOfBlocks, ILogger logger) { @@ -30,7 +28,6 @@ private FileUploader( FileSize = size; _lastModificationTime = lastModificationTime; _additionalMetadata = additionalMetadata; - _expectedSha1 = expectedSha1; _remainingNumberOfBlocks = expectedNumberOfBlocks; _logger = logger; } @@ -41,15 +38,23 @@ public UploadController UploadFromStream( Stream contentStream, IEnumerable thumbnails, Action? onProgress, + Func>? expectedSha1Provider, CancellationToken cancellationToken) { - return UploadFromStream(contentStream, ownsContentStream: false, thumbnails, onProgress, cancellationToken); + return UploadFromStream( + contentStream, + ownsContentStream: false, + thumbnails, + onProgress, + expectedSha1Provider, + cancellationToken); } public UploadController UploadFromFile( string filePath, IEnumerable thumbnails, Action? onProgress, + Func>? expectedSha1Provider, CancellationToken cancellationToken) { var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); @@ -59,6 +64,7 @@ public UploadController UploadFromFile( ownsContentStream: true, thumbnails, onProgress, + expectedSha1Provider: expectedSha1Provider, cancellationToken); } @@ -73,7 +79,6 @@ internal static async ValueTask CreateAsync( long size, DateTime? lastModificationTime, IEnumerable? additionalExtendedAttributes, - ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { var logger = client.Telemetry.GetLogger("File uploader"); @@ -92,7 +97,6 @@ internal static async ValueTask CreateAsync( size, lastModificationTime, additionalExtendedAttributes, - expectedSha1, expectedNumberOfBlocks, logger); } @@ -111,6 +115,7 @@ private UploadController UploadFromStream( bool ownsContentStream, IEnumerable thumbnails, Action? onProgress, + Func>? expectedSha1Provider, CancellationToken cancellationToken) { var taskControl = new TaskControl(cancellationToken); @@ -130,6 +135,7 @@ private UploadController UploadFromStream( thumbnails, _additionalMetadata, progress => onProgress?.Invoke(progress, FileSize), + expectedSha1Provider, revisionDraftTaskCompletionSource, ct); @@ -168,6 +174,7 @@ private async Task UploadFromStreamAsync( IEnumerable thumbnails, IEnumerable? additionalExtendedAttributes, Action? onProgress, + Func>? expectedSha1Provider, TaskCompletionSource revisionDraftTaskCompletionSource, CancellationToken cancellationToken) { @@ -186,6 +193,7 @@ await UploadAsync( _lastModificationTime, additionalExtendedAttributes, onProgress, + expectedSha1Provider, cancellationToken).ConfigureAwait(false); await UpdateActiveRevisionInCacheAsync(revisionDraft.Uid, contentStream.Length, cancellationToken).ConfigureAwait(false); @@ -226,6 +234,7 @@ private async ValueTask UploadAsync( DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, Action? onProgress, + Func>? expectedSha1Provider, CancellationToken cancellationToken) { using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionDraft, ReleaseBlocks, cancellationToken).ConfigureAwait(false); @@ -233,7 +242,7 @@ private async ValueTask UploadAsync( await revisionWriter.WriteAsync( contentStream, FileSize, - _expectedSha1, + expectedSha1Provider, thumbnails, lastModificationTime, additionalMetadata, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs index 4467ca3f..e37afc69 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs @@ -13,6 +13,7 @@ public static UploadController UploadFromStream( System.IO.Stream contentStream, IEnumerable thumbnails, Action? onProgress, + Func>? expectedSha1Provider, CancellationToken cancellationToken) { throw new NotSupportedException(); @@ -22,6 +23,7 @@ public static UploadController UploadFromFile( string filePath, IEnumerable thumbnails, Action? onProgress, + Func>? expectedSha1Provider, CancellationToken cancellationToken) { throw new NotSupportedException(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index a8196181..c0e89694 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -44,7 +44,7 @@ internal RevisionWriter( public async ValueTask WriteAsync( Stream contentStream, long expectedContentLength, - ReadOnlyMemory? expectedSha1, + Func>? expectedSha1Provider, IEnumerable thumbnails, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, @@ -104,7 +104,7 @@ public async ValueTask WriteAsync( lastModificationTime, expectedContentLength, expectedThumbnailBlockCount, - expectedSha1, + expectedSha1Provider, sha1Digest, signingEmailAddress, additionalMetadata); @@ -342,7 +342,7 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( DateTimeOffset? lastModificationTime, long expectedContentLength, int expectedThumbnailBlockCount, - ReadOnlyMemory? expectedSha1, + Func>? expectedSha1Provider, byte[]? sha1Digest, string signingEmailAddress, IEnumerable? additionalMetadata) @@ -391,9 +391,13 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( throw new IntegrityException("Unexpected number of thumbnail blocks"); } - if (expectedSha1 is not null && (sha1Digest is null || !expectedSha1.Value.Span.SequenceEqual(sha1Digest))) + if (expectedSha1Provider is not null) { - throw new IntegrityException("Mismatch between uploaded SHA1 and expected SHA1"); + var expectedSha1 = expectedSha1Provider(); + if (!expectedSha1.Span.SequenceEqual(sha1Digest)) + { + throw new IntegrityException("Mismatch between uploaded SHA1 and expected SHA1"); + } } var extendedAttributes = new ExtendedAttributes diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index c62a339c..682d220c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -174,7 +174,7 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( return FileOperations.EnumerateThumbnailsAsync(this, fileUids, type, cancellationToken); } - // FIXME: Group all parameterts bettween mediaType and expectedSha1 into uploadMetadata object. + // FIXME: Group all parameterts bettween mediaType and overrideExistingDraftByOtherClient into uploadMetadata object. public async ValueTask GetFileUploaderAsync( NodeUid parentFolderUid, string name, @@ -183,26 +183,24 @@ public async ValueTask GetFileUploaderAsync( DateTime? lastModificationTime, IEnumerable? additionalMetadata, bool overrideExistingDraftByOtherClient, - ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { var draftProvider = new NewFileDraftProvider(this, parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); - return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, expectedSha1, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); } - // FIXME: Group all parameterts bettween mediaType and expectedSha1 into uploadMetadata object. + // FIXME: Group all parameterts bettween size and additionalMetadata into uploadMetadata object. public async ValueTask GetFileRevisionUploaderAsync( RevisionUid currentActiveRevisionUid, long size, DateTime? lastModificationTime, IEnumerable? additionalMetadata, - ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { var draftProvider = new NewRevisionDraftProvider(this, currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); - return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, expectedSha1, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); } public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) @@ -260,10 +258,9 @@ private async ValueTask GetFileUploaderAsync( long size, DateTime? lastModificationTime, IEnumerable? additionalMetadata, - ReadOnlyMemory? expectedSha1, CancellationToken cancellationToken) { - return await FileUploader.CreateAsync(this, revisionDraftProvider, size, lastModificationTime, additionalMetadata, expectedSha1, cancellationToken) + return await FileUploader.CreateAsync(this, revisionDraftProvider, size, lastModificationTime, additionalMetadata, cancellationToken) .ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs index 087ccda7..f45a2166 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs @@ -2,6 +2,39 @@ namespace Proton.Sdk.CExports; +/// +/// Represents a function pointer that can be called from C# to Swift/other languages. +/// Similar to InteropAction but with a return value. +/// +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropFunction + where TArg : unmanaged + where TResult : unmanaged +{ + private readonly delegate* unmanaged[Cdecl] _pointer; + + public InteropFunction(delegate* unmanaged[Cdecl] pointer) + { + ArgumentNullException.ThrowIfNull(pointer); + _pointer = pointer; + } + + public InteropFunction(long pointer) + : this((delegate* unmanaged[Cdecl])pointer) + { + } + + public TResult Invoke(TArg arg) + { + return _pointer(arg); + } + + public override string ToString() + { + return $"0x{new nint(_pointer):x16}"; + } +} + /// /// Represents a function pointer that can be called from C# to Swift/other languages. /// Similar to InteropAction but with a return value. diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 55b093f1..a8a1c4f1 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -265,7 +265,6 @@ message DriveClientGetFileUploaderRequest { repeated AdditionalMetadataProperty additional_metadata = 7; // Optional bool override_existing_draft_by_other_client = 8; int64 cancellation_token_source_handle = 9; - bytes expected_sha1 = 10; // Optional - SHA1 hash of the file content for verification } // The response value must be an Int64Value carrying a handle to an instance of FileUploader. @@ -276,7 +275,6 @@ message DriveClientGetFileRevisionUploaderRequest { google.protobuf.Timestamp last_modification_time = 4; repeated AdditionalMetadataProperty additional_metadata = 5; // Optional int64 cancellation_token_source_handle = 6; - bytes expected_sha1 = 7; // Optional - SHA1 hash of the file content for verification } // The response value must be an Int64Value carrying a handle to an instance of UploadController. @@ -287,6 +285,7 @@ message UploadFromStreamRequest { int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; int64 cancel_action = 6; // Optional, C signature: void on_cancel(intptr_t bindings_handle); Signals the bindings to cancel the current stream operation. + int64 sha1_function = 7; // Optional - C signature: ByteArray get_sha1(intptr_t bindings_handle); } // The response value must be an Int64Value carrying a handle to an instance of UploadController. @@ -296,6 +295,7 @@ message UploadFromFileRequest { string file_path = 3; int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; + int64 sha1_function = 7; // Optional - C signature: ByteArray get_sha1(intptr_t bindings_handle); } // The response must not have a value. @@ -667,7 +667,6 @@ message PhotoFileUploadMetadata { google.protobuf.Timestamp capture_time = 5; // Optional string main_photo_link_id = 6; // Optional repeated PhotoTag tags = 7; // Optional - bytes expected_sha1 = 8; // Optional - SHA1 hash of the file content for verification } enum PhotoTag { @@ -698,6 +697,7 @@ message DrivePhotosClientUploadFromStreamRequest { int64 read_action = 3; // C signature: void handle_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; + int64 sha1_function = 7; // Optional - C signature: ByteArray get_sha1(intptr_t bindings_handle); } // The response value must be an Int64Value carrying a handle to an instance of UploadController. @@ -707,6 +707,7 @@ message DrivePhotosClientUploadFromFileRequest { string file_path = 3; int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; + int64 sha1_function = 7; // Optional - C signature: ByteArray get_sha1(intptr_t bindings_handle); } // The response must not have a value. diff --git a/kt/sdk/src/main/jni/global.c b/kt/sdk/src/main/jni/global.c index 61496915..a51f5059 100644 --- a/kt/sdk/src/main/jni/global.c +++ b/kt/sdk/src/main/jni/global.c @@ -166,3 +166,37 @@ long pushDataAndLongToLongMethod( return (*env)->CallLongMethod(env, obj, mid, buffer, caller_state); } } + +ByteArray callByteBufferMethod( + intptr_t bindings_handle, + const char *name +) { + ByteArray result = {NULL, 0}; + JNIEnv *env = getJNIEnv(); + jobject obj = (*env)->NewLocalRef(env, (jweak) bindings_handle); + if ((*env)->IsSameObject(env, obj, NULL)) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Object was recycled for: %s %ld", name, (long) bindings_handle + ); + return result; + } else { + jclass cls = (*env)->GetObjectClass(env, obj); + jmethodID mid = (*env)->GetMethodID(env, cls, name, "()Ljava/nio/ByteBuffer;"); + if (mid == 0) { + __android_log_print( + ANDROID_LOG_FATAL, + "drive.sdk.internal", + "Cannot found method: %s", name + ); + return result; + } + jobject buffer = (*env)->CallObjectMethod(env, obj, mid); + if (buffer != NULL) { + result.pointer = (const uint8_t *) (*env)->GetDirectBufferAddress(env, buffer); + result.length = (size_t) (*env)->GetDirectBufferCapacity(env, buffer); + } + return result; + } +} diff --git a/kt/sdk/src/main/jni/global.h b/kt/sdk/src/main/jni/global.h index 5396ae60..db9dac1b 100644 --- a/kt/sdk/src/main/jni/global.h +++ b/kt/sdk/src/main/jni/global.h @@ -32,4 +32,9 @@ long pushDataAndLongToLongMethod( const char *name ); +ByteArray callByteBufferMethod( + intptr_t bindings_handle, + const char *name +); + #endif //PROTONDRIVE_GLOBAL_H diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 480e1d68..96f720a9 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -113,6 +113,12 @@ long onFeatureEnabled( return pushDataToLongMethod(bindings_handle, value, "onFeatureEnabled"); } +ByteArray onSha1( + intptr_t bindings_handle +) { + return callByteBufferMethod(bindings_handle, "onSha1"); +} + jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getReadPointer( JNIEnv *env, jclass clazz @@ -176,6 +182,13 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getFeatureEna return (jlong) (intptr_t) &onFeatureEnabled; } +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSha1Pointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onSha1; +} + jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createWeakRef(JNIEnv* env, jobject obj) { jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); return (jlong)(intptr_t) weakRef; diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt index 01896d29..2a017d4a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -8,8 +8,8 @@ import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity -import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.JniFileUploader +import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId import java.nio.channels.ReadableByteChannel import java.util.concurrent.atomic.AtomicReference @@ -25,6 +25,7 @@ class FileUploader internal constructor( coroutineScope: CoroutineScope, channel: ReadableByteChannel, thumbnails: Map, + sha1Provider: (() -> ByteArray)?, ): UploadController = cancellationTokenSource().let { source -> log(INFO, "uploadFromStream") val coroutineScopeReference = AtomicReference(coroutineScope) @@ -40,6 +41,7 @@ class FileUploader internal constructor( controllerReference.get()?.emitProgress(toEntity()) } }, + sha1Provider = sha1Provider, coroutineScopeProvider = coroutineScopeReference::get, ) CommonUploadController( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt index b60c9016..3306f3f1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt @@ -24,33 +24,34 @@ class PhotosUploader( coroutineScope: CoroutineScope, channel: ReadableByteChannel, thumbnails: Map, - ): UploadController = - cancellationTokenSource().let { source -> - log(INFO, "uploadFromStream") - val coroutineScopeReference = AtomicReference(coroutineScope) - val controllerReference = AtomicReference() - val handle = bridge.uploadFromStream( - uploaderHandle = handle, - cancellationTokenSourceHandle = source.handle, - thumbnails = thumbnails, - onRead = channel::read, - onProgress = { progressUpdate -> - with(progressUpdate) { - log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") - controllerReference.get()?.emitProgress(toEntity()) - } - }, - coroutineScopeProvider = coroutineScopeReference::get, - ) - CommonUploadController( - uploader = this@PhotosUploader, - handle = handle, - bridge = JniUploadController(), - cancellationTokenSource = source, - channel = channel, - coroutineScopeConsumer = coroutineScopeReference::set, - ).also(controllerReference::set) - } + sha1Provider: (() -> ByteArray)?, + ): UploadController = cancellationTokenSource().let { source -> + log(INFO, "uploadFromStream") + val coroutineScopeReference = AtomicReference(coroutineScope) + val controllerReference = AtomicReference() + val handle = bridge.uploadFromStream( + uploaderHandle = handle, + cancellationTokenSourceHandle = source.handle, + thumbnails = thumbnails, + onRead = channel::read, + onProgress = { progressUpdate -> + with(progressUpdate) { + log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + controllerReference.get()?.emitProgress(toEntity()) + } + }, + sha1Provider = sha1Provider, + coroutineScopeProvider = coroutineScopeReference::get, + ) + CommonUploadController( + uploader = this@PhotosUploader, + handle = handle, + bridge = JniUploadController(), + cancellationTokenSource = source, + channel = channel, + coroutineScopeConsumer = coroutineScopeReference::set, + ).also(controllerReference::set) + } override fun close() = bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt index f4f04c13..c5d6e55c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -10,5 +10,6 @@ interface Uploader : AutoCloseable, Cancellable { coroutineScope: CoroutineScope, channel: ReadableByteChannel, thumbnails: Map = emptyMap(), + sha1Provider: (() -> ByteArray)? = null, ): UploadController } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt index a9994efd..742b5cd4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt @@ -4,5 +4,4 @@ data class FileRevisionUploaderRequest( val currentActiveRevisionUid: String, val lastModificationTime: Long, val size: Long, - val expectedSha1: ByteArray? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt index b590018e..da95ea0c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt @@ -8,5 +8,4 @@ data class FileUploaderRequest( val lastModificationTime: Long, val overrideExistingDraftByOtherClient: Boolean, val additionalMetadata: Map = emptyMap(), - val expectedSha1: ByteArray? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt index 33f0053b..4702950e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt @@ -10,5 +10,4 @@ data class PhotosUploaderRequest( val tags: List = emptyList(), // optional val overrideExistingDraftByOtherClient: Boolean, val additionalMetadata: Map = emptyMap(), // optional - val expectedSha1: ByteArray? = null, // optional ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt index 8bdd1940..28a64d22 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt @@ -22,7 +22,6 @@ internal fun FileUploaderRequest.toProtobuf( this.utf8JsonValue = data.toByteString() } } - this@toProtobuf.expectedSha1?.let { expectedSha1 = it.toByteString() } this.clientHandle = clientHandle this.cancellationTokenSourceHandle = cancellationTokenSourceHandle } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt index d6bb2c47..39db093c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt @@ -1,6 +1,5 @@ package me.proton.drive.sdk.extension -import com.google.protobuf.kotlin.toByteString import com.google.protobuf.timestamp import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import proton.drive.sdk.driveClientGetFileRevisionUploaderRequest @@ -14,5 +13,4 @@ internal fun FileRevisionUploaderRequest.toProtobuf( this.currentActiveRevisionUid = this@toProtobuf.currentActiveRevisionUid this.size = this@toProtobuf.size this.lastModificationTime = timestamp { seconds = this@toProtobuf.lastModificationTime } - this@toProtobuf.expectedSha1?.let { expectedSha1 = it.toByteString() } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt index 8f1557d0..fe29ff94 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt @@ -11,35 +11,34 @@ internal fun PhotosUploaderRequest.toProtobuf( clientHandle: Long, cancellationTokenSourceHandle: Long, ) = drivePhotosClientGetPhotoUploaderRequest { - this.clientHandle = clientHandle - name = this@toProtobuf.name - size = this@toProtobuf.fileSize - metadata = photoFileUploadMetadata { - mediaType = this@toProtobuf.mediaType - this@toProtobuf.captureTime?.let { - captureTime = timestamp { - seconds = it + this.clientHandle = clientHandle + name = this@toProtobuf.name + size = this@toProtobuf.fileSize + metadata = photoFileUploadMetadata { + mediaType = this@toProtobuf.mediaType + this@toProtobuf.captureTime?.let { + captureTime = timestamp { + seconds = it + } } - } - this@toProtobuf.lastModificationTime?.let { - lastModificationTime = timestamp { - seconds = it + this@toProtobuf.lastModificationTime?.let { + lastModificationTime = timestamp { + seconds = it + } } - } - overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient - additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> - additionalMetadataProperty { - this.name = name - this.utf8JsonValue = data.toByteString() + overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient + additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> + additionalMetadataProperty { + this.name = name + this.utf8JsonValue = data.toByteString() + } + } + this@toProtobuf.mainPhotoLinkId?.let { + mainPhotoLinkId = it + } + tags += this@toProtobuf.tags.map { photoTag -> + photoTag.toSdkPhotoTag() } } - this@toProtobuf.expectedSha1?.let { expectedSha1 = it.toByteString() } - this@toProtobuf.mainPhotoLinkId?.let { - mainPhotoLinkId = it - } - tags += this@toProtobuf.tags.map { photoTag -> - photoTag.toSdkPhotoTag() - } + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle } - this.cancellationTokenSourceHandle = cancellationTokenSourceHandle -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt index 17364048..8e9c6fa4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt @@ -41,6 +41,7 @@ class JniFileUploader internal constructor() : JniBaseProtonDriveSdk() { thumbnails: Map, onRead: (ByteBuffer) -> Int, onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, + sha1Provider: (() -> ByteArray)?, coroutineScopeProvider: CoroutineScopeProvider, ): Long = executePersistent( clientBuilder = { continuation -> @@ -49,6 +50,7 @@ class JniFileUploader internal constructor() : JniBaseProtonDriveSdk() { response = continuation.toLongResponse().asClientResponseCallback(), read = onRead, progress = onProgress, + sha1Provider = sha1Provider ?: { error("sha1Provider not configured for uploadFromStream") }, logger = internalLogger, coroutineScopeProvider = coroutineScopeProvider, ) @@ -61,6 +63,9 @@ class JniFileUploader internal constructor() : JniBaseProtonDriveSdk() { readAction = ProtonDriveSdkNativeClient.getReadPointer() progressAction = ProtonDriveSdkNativeClient.getProgressPointer() cancelAction = JniJob.getCancelPointer() + if (sha1Provider != null) { + sha1Function = ProtonDriveSdkNativeClient.getSha1Pointer() + } thumbnails.forEach { (type, data) -> this.thumbnails.add(thumbnail { this.type = when (type) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt index 7bc93c01..405f12bd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt @@ -34,6 +34,7 @@ class JniPhotosUploader internal constructor() : JniBaseProtonDriveSdk() { thumbnails: Map, onRead: (ByteBuffer) -> Int, onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, + sha1Provider: (() -> ByteArray)?, coroutineScopeProvider: CoroutineScopeProvider, ): Long = executePersistent( clientBuilder = { continuation -> @@ -42,6 +43,7 @@ class JniPhotosUploader internal constructor() : JniBaseProtonDriveSdk() { response = continuation.toLongResponse().asClientResponseCallback(), read = onRead, progress = onProgress, + sha1Provider = sha1Provider ?: { error("sha1Provider not configured for uploadFromStream") }, logger = internalLogger, coroutineScopeProvider = coroutineScopeProvider, ) @@ -53,6 +55,9 @@ class JniPhotosUploader internal constructor() : JniBaseProtonDriveSdk() { this.cancellationTokenSourceHandle = cancellationTokenSourceHandle readAction = ProtonDriveSdkNativeClient.getReadPointer() progressAction = ProtonDriveSdkNativeClient.getProgressPointer() + if (sha1Provider != null) { + sha1Function = ProtonDriveSdkNativeClient.getSha1Pointer() + } thumbnails.forEach { (type, data) -> this.thumbnails.add(thumbnail { this.type = when (type) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 3a01b21a..a3365d03 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -39,6 +39,7 @@ class ProtonDriveSdkNativeClient internal constructor( val progress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit = { error("progress not configured for $name") }, val recordMetric: suspend (ProtonSdk.MetricEvent) -> Unit = { error("recordMetric not configured for $name") }, val featureEnabled: suspend (String) -> Boolean = { error("featureEnabled not configured for $name") }, + val sha1Provider: suspend () -> ByteArray = { error("sha1Provider not configured for $name") }, val logger: (Level, String) -> Unit = { _, _ -> }, private val coroutineScopeProvider: CoroutineScopeProvider = { null }, ) { @@ -198,6 +199,29 @@ class ProtonDriveSdkNativeClient internal constructor( } } + @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI + fun onSha1(): ByteBuffer = onFunction(operation = "sha1Provider") { + runCatching { + sha1Provider().let { sha1 -> + ByteBuffer.allocateDirect(sha1.size).apply { + put(sha1) + flip() + } + } + }.getOrElse { error -> + logger(WARN, "Cannot get expected SHA1") + logger(WARN, error.stackTraceToString()) + ByteBuffer.allocateDirect(0) + } + } + + private fun onFunction( + operation: String, + block: suspend () -> R + ): R = runBlocking(Dispatchers.Unconfined) { + coroutineScope(operation).async { block() }.await() + } + private fun onFunction( operation: String, data: ByteBuffer, @@ -361,5 +385,8 @@ class ProtonDriveSdkNativeClient internal constructor( @JvmStatic external fun getFeatureEnabledPointer(): Long + + @JvmStatic + external fun getSha1Pointer(): Long } } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 450a45d9..176369d3 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -181,7 +181,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, - expectedSha1: Data? = nil, + expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void @@ -195,7 +195,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { mediaType: mediaType, thumbnails: thumbnails, overrideExistingDraft: overrideExistingDraft, - expectedSha1: expectedSha1, + expectedSHA1: expectedSHA1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -215,7 +215,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, - expectedSha1: Data? = nil, + expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -228,7 +228,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { mediaType: mediaType, thumbnails: thumbnails, overrideExistingDraft: overrideExistingDraft, - expectedSha1: expectedSha1, + expectedSHA1: expectedSHA1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -241,7 +241,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { fileSize: Int64, modificationDate: Date, thumbnails: [ThumbnailData], - expectedSha1: Data? = nil, + expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void @@ -252,7 +252,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { fileSize: fileSize, modificationDate: modificationDate, thumbnails: thumbnails, - expectedSha1: expectedSha1, + expectedSHA1: expectedSHA1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -269,7 +269,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { fileSize: Int64, modificationDate: Date, thumbnails: [ThumbnailData], - expectedSha1: Data? = nil, + expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -279,7 +279,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { fileSize: fileSize, modificationDate: modificationDate, thumbnails: thumbnails, - expectedSha1: expectedSha1, + expectedSHA1: expectedSHA1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -436,7 +436,7 @@ extension ProtonDriveClient { } $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } - let result: Void = try await SDKRequestHandler.send(renameRequest, logger: logger) + let _: Void = try await SDKRequestHandler.send(renameRequest, logger: logger) } public func cancelRename(cancellationToken: UUID) async throws { diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 2dc9fd33..3c7d116c 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -194,7 +194,7 @@ extension ProtonPhotosClient { tags: [Int], additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, - expectedSha1: Data?, + expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void @@ -211,7 +211,7 @@ extension ProtonPhotosClient { tags: tags, additionalMetadata: additionalMetadata, overrideExistingDraft: overrideExistingDraft, - expectedSha1: expectedSha1, + expectedSHA1: expectedSHA1, cancellationToken: cancellationToken, progressCallback: progressCallback ) @@ -234,7 +234,7 @@ extension ProtonPhotosClient { tags: [Int], additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, - expectedSha1: Data?, + expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -258,7 +258,7 @@ extension ProtonPhotosClient { tags: mappedTags, additionalMetadata: additionalMetadata, overrideExistingDraft: overrideExistingDraft, - expectedSha1: expectedSha1, + expectedSHA1: expectedSHA1, cancellationToken: cancellationToken, progressCallback: progressCallback ) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift index 5ba0d815..e1cbdb83 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift @@ -36,7 +36,7 @@ actor DownloadsManager { let downloaderRequest = Proton_Drive_Sdk_DownloadToFileRequest.with { $0.downloaderHandle = Int64(downloaderHandle) $0.filePath = destinationUrl.path(percentEncoded: false) - $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallbackForDownload)) $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift index e1bdbb85..6011bcd2 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift @@ -36,7 +36,7 @@ actor PhotoDownloadsManager { let downloaderRequest = Proton_Drive_Sdk_DrivePhotosClientDownloadToFileRequest.with { $0.downloaderHandle = Int64(downloaderHandle) $0.filePath = destinationUrl.path(percentEncoded: false) - $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallbackForDownload)) $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift index cd777c9d..9f1d4c67 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift @@ -31,13 +31,15 @@ actor PhotoUploadsManager { tags: [Proton_Drive_Sdk_PhotoTag], additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, - expectedSha1: Data?, + expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) activeUploads[cancellationToken] = cancellationTokenSource + let cancellationHandle = cancellationTokenSource.handle + let uploaderHandle = try await buildUploader( name: name, fileSize: fileSize, @@ -48,8 +50,7 @@ actor PhotoUploadsManager { tags: tags, additionalMetadata: additionalMetadata, overrideExistingDraft: overrideExistingDraft, - expectedSha1: expectedSha1, - cancellationHandle: cancellationTokenSource.handle + cancellationHandle: cancellationHandle ) let uploadController = try await uploadFromFile( @@ -57,8 +58,9 @@ actor PhotoUploadsManager { fileURL: fileURL, progressCallback: progressCallback, cancellationToken: cancellationToken, - cancellationHandle: cancellationTokenSource.handle, - thumbnails: thumbnails + cancellationHandle: cancellationHandle, + thumbnails: thumbnails, + expectedSHA1: expectedSHA1 ) return uploadController } @@ -69,7 +71,8 @@ actor PhotoUploadsManager { progressCallback: @escaping ProgressCallback, cancellationToken: UUID, cancellationHandle: ObjectHandle, - thumbnails: [ThumbnailData] + thumbnails: [ThumbnailData], + expectedSHA1: Data? = nil ) async throws -> UploadOperation { let thumbnails = thumbnails.map { let count = $0.data.count @@ -86,8 +89,11 @@ actor PhotoUploadsManager { let uploaderRequest = Proton_Drive_Sdk_DrivePhotosClientUploadFromFileRequest.with { $0.uploaderHandle = Int64(fileUploaderHandle) $0.filePath = fileURL.path(percentEncoded: false) - $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallbackForUpload)) $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + if expectedSHA1 != nil { + $0.sha1Function = Int64(ObjectHandle(callback: cExpectedSha1CallbackForUpload)) + } $0.thumbnails = thumbnails.map { type, handle, count in Proton_Drive_Sdk_Thumbnail.with { $0.type = type == .thumbnail ? .thumbnail : .preview @@ -97,10 +103,10 @@ actor PhotoUploadsManager { } } - let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let uploadOperationState = UploadOperationState(callback: progressCallback, expectedSHA1: expectedSHA1) let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( uploaderRequest, - state: WeakReference(value: callbackState), + state: WeakReference(value: uploadOperationState), includesLongLivedCallback: true, logger: logger ) @@ -108,7 +114,7 @@ actor PhotoUploadsManager { return UploadOperation( fileUploaderHandle: fileUploaderHandle, uploadControllerHandle: uploadControllerHandle, - progressCallbackWrapper: callbackState, + uploadOperationState: uploadOperationState, logger: logger, nodeType: .photo, onOperationCancel: { [weak self] in @@ -152,7 +158,6 @@ actor PhotoUploadsManager { tags: [Proton_Drive_Sdk_PhotoTag], additionalMetadata: [AdditionalMetadata], overrideExistingDraft: Bool, - expectedSha1: Data?, cancellationHandle: ObjectHandle ) async throws -> ObjectHandle { let uploaderRequest = Proton_Drive_Sdk_DrivePhotosClientGetPhotoUploaderRequest.with { @@ -164,9 +169,6 @@ actor PhotoUploadsManager { metadata.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) metadata.additionalMetadata = additionalMetadata.map { $0.toSDK } metadata.overrideExistingDraftByOtherClient = overrideExistingDraft - if let expectedSha1 = expectedSha1 { - metadata.expectedSha1 = expectedSha1 - } metadata.captureTime = Google_Protobuf_Timestamp(date: captureTime) if let mainPhotoLinkID { metadata.mainPhotoLinkID = mainPhotoLinkID diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift index da217103..195884b6 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift @@ -1,4 +1,5 @@ import Foundation +import CProtonDriveSDK public enum UploadOperationResult: Sendable { case succeeded(UploadedFileIdentifiers) @@ -9,9 +10,10 @@ public enum UploadOperationResult: Sendable { public final class UploadOperation: Sendable { private let fileUploaderHandle: ObjectHandle private let uploadControllerHandle: ObjectHandle - private let progressCallbackWrapper: ProgressCallbackWrapper + private let uploadOperationState: UploadOperationState private let logger: Logger? private let nodeType: NodeType + private let expectedSHA1: Data? private let onOperationCancel: @Sendable () async throws -> Void private let onOperationDispose: @Sendable () async -> Void @@ -19,18 +21,20 @@ public final class UploadOperation: Sendable { init(fileUploaderHandle: ObjectHandle, uploadControllerHandle: ObjectHandle, - progressCallbackWrapper: ProgressCallbackWrapper, + uploadOperationState: UploadOperationState, logger: Logger?, nodeType: NodeType, + expectedSHA1: Data? = nil, onOperationCancel: @Sendable @escaping () async throws -> Void, onOperationDispose: @Sendable @escaping () async -> Void) { assert(fileUploaderHandle != 0) assert(uploadControllerHandle != 0) self.fileUploaderHandle = fileUploaderHandle self.uploadControllerHandle = uploadControllerHandle - self.progressCallbackWrapper = progressCallbackWrapper + self.uploadOperationState = uploadOperationState self.logger = logger self.nodeType = nodeType + self.expectedSHA1 = expectedSHA1 self.onOperationCancel = onOperationCancel self.onOperationDispose = onOperationDispose } @@ -225,3 +229,27 @@ public final class UploadOperation: Sendable { } } } + +final class UploadOperationState: Sendable { + let callback: ProgressCallback + let expectedSHA1: Data? + + init(callback: @escaping ProgressCallback, expectedSHA1: Data?) { + self.callback = callback + self.expectedSHA1 = expectedSHA1 + } +} + +let cExpectedSha1CallbackForUpload: CCallbackWithByteArrayReturn = { statePointer in + typealias BoxType = BoxedCompletionBlock> + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + assertionFailure("cExpectedSha1ProviderCallback.statePointer is nil") + return ByteArray(pointer: nil, length: 0) + } + + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + guard let expectedSHA1 = stateTypedPointer.takeUnretainedValue().state.value?.expectedSHA1 else { + return ByteArray(pointer: nil, length: 0) + } + return ByteArray(data: expectedSHA1) +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift index 86128adf..bc147190 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift @@ -28,7 +28,7 @@ actor UploadsManager { mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, - expectedSha1: Data?, + expectedSHA1: Data?, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -44,7 +44,6 @@ actor UploadsManager { fileSize: fileSize, modificationDate: modificationDate, overrideExistingDraft: overrideExistingDraft, - expectedSha1: expectedSha1, cancellationHandle: cancellationHandle, logger: logger ) @@ -55,7 +54,8 @@ actor UploadsManager { progressCallback: progressCallback, cancellationToken: cancellationToken, cancellationHandle: cancellationHandle, - thumbnails: thumbnails + thumbnails: thumbnails, + expectedSHA1: expectedSHA1 ) return uploadController } @@ -66,7 +66,7 @@ actor UploadsManager { fileSize: Int64, modificationDate: Date, thumbnails: [ThumbnailData], - expectedSha1: Data?, + expectedSHA1: Data?, cancellationToken: UUID, progressCallback: @escaping ProgressCallback ) async throws -> UploadOperation { @@ -79,7 +79,6 @@ actor UploadsManager { currentActiveRevisionUid: currentActiveRevisionUid, fileSize: fileSize, modificationDate: modificationDate, - expectedSha1: expectedSha1, cancellationHandle: cancellationHandle ) @@ -89,7 +88,8 @@ actor UploadsManager { progressCallback: progressCallback, cancellationToken: cancellationToken, cancellationHandle: cancellationHandle, - thumbnails: thumbnails + thumbnails: thumbnails, + expectedSHA1: expectedSHA1 ) return uploadController } @@ -122,7 +122,6 @@ extension UploadsManager { fileSize: Int64, modificationDate: Date, overrideExistingDraft: Bool, - expectedSha1: Data?, cancellationHandle: ObjectHandle?, logger: Logger? ) async throws -> ObjectHandle { @@ -135,10 +134,6 @@ extension UploadsManager { $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) $0.overrideExistingDraftByOtherClient = overrideExistingDraft - if let expectedSha1 = expectedSha1 { - $0.expectedSha1 = expectedSha1 - } - if let cancellationHandle = cancellationHandle { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } @@ -153,7 +148,6 @@ extension UploadsManager { currentActiveRevisionUid: SDKRevisionUid, fileSize: Int64, modificationDate: Date, - expectedSha1: Data?, cancellationHandle: ObjectHandle? ) async throws -> ObjectHandle { let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileRevisionUploaderRequest.with { @@ -161,9 +155,7 @@ extension UploadsManager { $0.currentActiveRevisionUid = currentActiveRevisionUid.sdkCompatibleIdentifier $0.size = fileSize $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) - if let expectedSha1 = expectedSha1 { - $0.expectedSha1 = expectedSha1 - } + if let cancellationHandle = cancellationHandle { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } @@ -180,7 +172,8 @@ extension UploadsManager { progressCallback: @escaping ProgressCallback, cancellationToken: UUID, cancellationHandle: ObjectHandle, - thumbnails: [ThumbnailData] + thumbnails: [ThumbnailData], + expectedSHA1: Data? ) async throws -> UploadOperation { let thumbnails = thumbnails.map { let count = $0.data.count @@ -197,8 +190,11 @@ extension UploadsManager { let uploaderRequest = Proton_Drive_Sdk_UploadFromFileRequest.with { $0.uploaderHandle = Int64(fileUploaderHandle) $0.filePath = fileURL.path(percentEncoded: false) - $0.progressAction = Int64(ObjectHandle(callback: cProgressCallback)) + $0.progressAction = Int64(ObjectHandle(callback: cProgressCallbackForUpload)) $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + if expectedSHA1 != nil { + $0.sha1Function = Int64(ObjectHandle(callback: cExpectedSha1CallbackForUpload)) + } $0.thumbnails = thumbnails.map { type, handle, count in Proton_Drive_Sdk_Thumbnail.with { $0.type = type == .thumbnail ? .thumbnail : .preview @@ -208,10 +204,10 @@ extension UploadsManager { } } - let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let uploadOperationState = UploadOperationState(callback: progressCallback, expectedSHA1: expectedSHA1) let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( uploaderRequest, - state: WeakReference(value: callbackState), + state: WeakReference(value: uploadOperationState), includesLongLivedCallback: true, logger: logger ) @@ -219,9 +215,10 @@ extension UploadsManager { return UploadOperation( fileUploaderHandle: fileUploaderHandle, uploadControllerHandle: uploadControllerHandle, - progressCallbackWrapper: callbackState, + uploadOperationState: uploadOperationState, logger: logger, nodeType: .file, + expectedSHA1: expectedSHA1, onOperationCancel: { [weak self] in guard let self else { return } try await self.cancelUpload(with: cancellationToken) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index 6f5e19a3..5a38fa28 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -27,6 +27,7 @@ extension ObjectHandle { typealias CCallback = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray) -> Void typealias CCallbackWithoutByteArray = @convention(c) (_ statePointer: Int) -> Void typealias CCallbackWithIntReturn = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray) -> Int32 +typealias CCallbackWithByteArrayReturn = @convention(c) (_ statePointer: Int) -> ByteArray typealias CCallbackWithCallbackPointer = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray, _ callbackPointer: Int) -> Void typealias CCallbackWithCallbackPointerAndObjectPointerReturn = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray, _ callbackPointer: Int) -> Int diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift index a42ee8f8..b62981fb 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -9,7 +9,27 @@ final class ProgressCallbackWrapper: Sendable { } } -let cProgressCallback: CCallback = { statePointer, byteArray in +let cProgressCallbackForUpload: CCallback = { statePointer, byteArray in + typealias BoxType = BoxedCompletionBlock> + let progressUpdate = Proton_Drive_Sdk_ProgressUpdate(byteArray: byteArray) + let progress = FileOperationProgress( + bytesCompleted: progressUpdate.hasBytesCompleted ? progressUpdate.bytesCompleted : nil, + bytesTotal: progressUpdate.hasBytesInTotal ? progressUpdate.bytesInTotal : nil + ) + + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + let message = "cProgressCallback.statePointer is nil" + assertionFailure(message) + // there is no way we can inform the SDK back about the issue + return + } + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state + weakWrapper.value?.callback(progress) +} + + +let cProgressCallbackForDownload: CCallback = { statePointer, byteArray in typealias BoxType = BoxedCompletionBlock> let progressUpdate = Proton_Drive_Sdk_ProgressUpdate(byteArray: byteArray) let progress = FileOperationProgress( @@ -26,9 +46,4 @@ let cProgressCallback: CCallback = { statePointer, byteArray in let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state weakWrapper.value?.callback(progress) - - // TODO: release pointer when task is cancelled or completed - // if progress.isCompleted { - // stateTypedPointer.release() - // } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index a281ee47..2233aa58 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -141,7 +141,7 @@ public struct FolderNode: Sendable { throw ProtonDriveSDKError(interopError: .incorrectIDFormat(id: sdkFolderNode.uid)) } self.uid = uid - self.parentUid = sdkFolderNode.parentUid == nil ? nil : .init(sdkCompatibleIdentifier: sdkFolderNode.parentUid) + self.parentUid = sdkFolderNode.hasParentUid ? .init(sdkCompatibleIdentifier: sdkFolderNode.parentUid) : nil self.name = sdkFolderNode.name self.creationTime = sdkFolderNode.creationTime.timeIntervalSince1970 self.trashTime = sdkFolderNode.trashTime.timeIntervalSince1970 From 851b4103f83f55a941882e86713400521fe72018 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 16:44:47 +0000 Subject: [PATCH 515/791] Update changelog for cs/v0.7.0-alpha.8 --- cs/CHANGELOG.md | 133 ++++-------------------------------------------- 1 file changed, 9 insertions(+), 124 deletions(-) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 6462c4a0..8fb4ce1e 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,19 +1,20 @@ # Changelog +## cs/v0.7.0-alpha.8 (2026-02-11) + +* Provide expected SHA1 for upload through callback +* Refactor and fix support for Photos nodes + ## cs/v0.7.0-alpha.7 (2026-02-11) * Abort pause state on non-resumable upload errors -* Exclude integrity errors from being resumable during upload -* Update changelog for cs/v0.7.0-alpha.6 ## cs/v0.7.0-alpha.6 (2026-02-10) * Add SHA1 upload verification -* Update changelog for cs/v0.7.0-alpha.5 ## cs/v0.7.0-alpha.5 (2026-02-05) -* Verify C# build for published source code * Log "is paused" state for download too * Check is controller is paused instead of looking at the domain error * Make author and signature verification error mutually exclusive in interop @@ -28,36 +29,25 @@ ## cs/v0.7.0-alpha.4 (2026-01-30) -* Fix files being truncated when downloading to file path through interop * Follow up on download pausing to address issues with hanging, seeking with interop and telemetry -* Automate open-sourcing -* Fix timeout reported as cancellation through interop ## cs/v0.7.0-alpha.3 (2026-01-27) * Transform progress callback to flow -* Implement pausing and resuming of downloads -* Fix Swift package signing * Add photos client kotlin bindings for upload -* Handle and send decryption error telemetry to client * Enable request body streaming for upload ## cs/v0.7.0-alpha.2 (2026-01-26) -* Fix location of Photos project * Make cache optional -* Set version of SDK in swift builds * Log ignored errors * Add file upload methods to the Photos client * Replace stream with buffer for HTTP ## cs/v0.7.0-alpha.1 (2026-01-23) -* Enforce static code analysis warnings as errors on release builds * Replace stream by channel for thumbnails * Replace stream with channel -* Add node metadata decryption error metrics -* Get Swift signing certificate from CI variables * Fix native clients getting garbage collected during long request to the sdk * Add Kotlin tests for pausing and resuming downloads * Fix error not caught or returned to the sdk when scope was null @@ -67,12 +57,10 @@ ## cs/v0.6.1-alpha.17 (2026-01-20) * Fix errors not caught in Kotlin bindings and crashing client -* Remove unnecessary parameter from .BeginTransaction calls ## cs/v0.6.1-alpha.16 (2026-01-19) -* Improve cache DB transaction locking behavior -* Implement delayed cancellation for reading content during upload +* No changes ## cs/v0.6.1-alpha.15 (2026-01-16) @@ -83,26 +71,19 @@ * Improve on-disk cache handling * Update driveClientCreate to use ProtonDriveClientOptions and timeouts -* Fix download photos from album * Add ability to override HTTP timeouts ## cs/v0.6.1-alpha.13 (2026-01-15) -* Fix build error due to missing brace in Protobuf definition -* Implement support for protecting SDK databases * Expose functions to trash node through Swift package * refactor: consolidate PhotoDownloadOperation into DownloadOperation -* Fix failure to resume upload that has gaps in block upload completions -* Implement 429 handling for block downloads * Log paused status for each call * Expose folder creation in interop and Kotlin bindings * Update coroutine scope when resume * Introduce PhotoDownloadOperation -* Simplify implementation for pausing uploads * Add Kotlin bindings for rename * Ignore cancellation error after cancelling in download test * Expose folder creation in interop and Swift bindings -* Add support for photo decryption through album key packet ## cs/v0.6.1-alpha.12 (2026-01-09) @@ -113,41 +94,31 @@ ## cs/v0.6.1-alpha.11 (2026-01-08) -* Fix builds for Kotlin and Swift bindings broken due to Experimental attribute -* Handle 429 responses on block uploads +* No changes ## cs/v0.6.1-alpha.10 (2026-01-07) -* Fix InteropStream length initialization for write streams * Implement initial photos client interop * Interop and bindings for DownloadController.GetIsDownloadCompleteWithVerificationIssue * Avoid logging storage body for test -* Map download integrity exception to integrity domain for interop ## cs/v0.6.1-alpha.9 (2026-01-06) -* Pause upload on timeout * Fix progress logs in kotlin ## cs/v0.6.1-alpha.8 (2026-01-04) -* Switch to SQLite-free implementation for in-memory caching * Expose function to rename node through Swift package -* Update download error handling * Limit GC pressure by creating less Channel instances * Add levels to logs ## cs/v0.6.1-alpha.7 (2025-12-22) -* Update swift dependencies * Reapply removed upload controller dispose calls * Move incomplete draft deletion to upload controller disposal -* Fix shares and share secrets not being cached -* Expose download integrity errors and download status ## cs/v0.6.1-alpha.6 (2025-12-19) -* Fix download retrying on cancellation * Pass error when operation is paused to the client. Prevent crashes for calls after operation throws. ## cs/v0.6.1-alpha.5 (2025-12-19) @@ -155,17 +126,9 @@ * Add cancellation message when CS cancels a job * Fix download failures due to missing keys for manifest check * Cancel CancellationTokenSource when coroutine scope is cancelled executing blocking function -* Add photos thumbnail downloader -* Update telemetry error mapping -* Implement pausing and resuming of uploads -* Fix exception on retrying thumbnail block upload -* Add photo downloader -* Add Photos client and Photos volume creation * Extract Job code from JniDriveClient * Test upload and download events * Convert stateless JNI methods to static -* Log swallowed exceptions -* Propagate exception to interop logger ## cs/v0.6.1-alpha.4 (2025-12-15) @@ -189,17 +152,12 @@ * Set error type to the name of the Kotlin exception * Improve error generation and parsing in Swift bindings * Check optional proto fields -* Add properties to query paused state of upload and download -* Prevent download from seeking back in output stream * Add error handling for writing to output stream -* Add support to C# CLI for downloading by node UID -* Increase number of attempts for block transfers -* Revamp CI pipelines * Remove debug log with fatal level ## cs/v0.6.0-test.2 (2025-12-04) -* Revamp CI pipelines +* No changes ## cs/v0.6.0-alpha.7 (2025-12-10) @@ -212,80 +170,40 @@ ## cs/v0.6.0-alpha.5 (2025-12-09) * Check optional proto fields -* Add properties to query paused state of upload and download -* Prevent download from seeking back in output stream * Add error handling for writing to output stream -* Add support to C# CLI for downloading by node UID ## cs/v0.6.0-alpha.4 (2025-12-05) -* Increase number of attempts for block transfers -* Revamp CI pipelines * Remove debug log with fatal level -* Fix SPM deployment script -* Fix CLI lacking parallelism when downloading multiple files ## cs/v0.6.0-alpha.3 (2025-12-04) -* Upgrade version from 0.6.0-alpha.1 to 0.6.0-alpha.3 -* Bump crypto lib to handle decrypted AEAD session key exports -* Include source commit SHA in release's commit message -* Fix missing artifact requirements for publishing Kotlin package * Improve performance of iterating over URLSession.AsyncBytes during download -* Handle degraded node ## cs/v0.6.0-alpha.1 (2025-12-02) -* Bump Kotlin package version * Fix Kotlin build failure due to Protobuf changes -* Implement telemetry for download * Fix crashes when download is interrupted * Add Kotlin bindings for feature flags * Remove unused parameter -* Fix CLI resilience retrying even on successful round trips -* Fix address verification happening too early * Include the Swift's error message in the SDK interop error * Add auto-retries into HTTP client bridge for certain HTTP errors: 401, 429, 5xx * Add HTTP timeouts and ability to cancel requests through interop -* Handle diverging size on upload -* Address security review of C# crypto -* Preserve interop errors passing through SDK -* Allow multiple calls to override native library name -* Replace option to disable HTTP retries with a request type * Delay opening upload stream until necessery * Upgrade version from 0.4.0 to 0.5.0 -* Add hint to disable retries on HTTP requests * Close properly response body when read -* Add proguard rules to keep protobuf classes to be optimized -* Fix the crypto library name -* Add more logging to transfer queues * Use streaming in HTTP client -* Add AEAD support * Add approximate upload size to upload metric event in kt binding * Improve mapping of SDK exceptions to Kotlin errors -* Add approximate upload size to upload metric event -* Upgrade version from 0.3.1 to 0.4.0 -* Align rules of CI build jobs related to C# SDK * Parse Protobuf request within the same JNI call * Support client-injected feature flags in Swift * Remove copyrights and optimize imports * Add filtering by type to thumbnail enumeration -* Enable building Swift package with support for both Silicon and Intel iOS simulators -* Fix missing disposal of file uploader and file downloader through interop * Add pause and resume API * Add Kotlin bindings package for Android -* Make feature flag provision asynchronous -* Add feature flag support * Fix cancellation token source being double-freed in the Swift interop -* Android/submodule -* Fix wrong additional metadata parameters in upload -* Tweak CI for SPM build -* Add possibility to provide additional metadata on file upload * Add method to download thumbnails -* Add empty and thumbnail file uploads for cross client * Pass node name conflict error data through interop -* Add unit test to verify fix for hanging download due to unreleased semaphore -* Fix blocks not being released during download * Expose cancellation support in SDK bindings * Add CI job to build and deploy Swift package * Update client creation through interop to be able to set client UID @@ -293,50 +211,17 @@ * Expose function to get available node name through Swift package * Fix logger * Feat/parse error swift interop -* Fix possibility of missing domain and type on interop errors -* Fix missing SDK version header when injecting HTTP client without interop * Fix progress callback doesn't report issue -* Fix thumbnails causing upload to hang -* Fix deserialization error on getting available names * Add Swift SDK package for iOS & macOS -* Fix download error due to misuse of new URL block fields -* Fix error on HTTP response with Expires header when using interop -* Fix deserialization error on download -* Apply server time to PGP when injecting the HTTP client through interop -* Improve logging and clean up some code -* Fix SHA1 extended attribute -* Align JSON output of the C# CLI with the JavaScript one -* Add support for 16KB pages and ARMv7 platform on Android -* Fix conflicting draft deletion failure -* Fix old revision UID being returned instead of new one after revision upload -* Fix various interop issues found after enabling HTTP client injection ## cs/v0.1.0-alpha.3 (2025-10-14) -* Fix conflicting draft deletion failure -* Fix old revision UID being returned instead of new one after revision upload -* Fix thumbnail type enum -* Allow logger provider handle for drive client creation -* Add logging for upload and session -* Make some naming clearer -* Make thumbnail type strongly-typed in Protobufs -* Fix exception when returning HTTP response through interop -* Improve error message in case of invalid cast from interop handle +* No changes ## cs/0.6.0-alpha.3 (2025-12-04) -* Upgrade version from 0.6.0-alpha.1 to 0.6.0-alpha.3 -* Bump crypto lib to handle decrypted AEAD session key exports -* Include source commit SHA in release's commit message -* Fix missing artifact requirements for publishing Kotlin package * Improve performance of iterating over URLSession.AsyncBytes during download -* Handle degraded node ## cs/0.6.0-alpha.1 (2025-12-02) -* Upgrade version from 0.6.0-alpha.1 to 0.6.0-alpha.3 -* Bump crypto lib to handle decrypted AEAD session key exports -* Include source commit SHA in release's commit message -* Fix missing artifact requirements for publishing Kotlin package * Improve performance of iterating over URLSession.AsyncBytes during download -* Handle degraded node From 54ef7560c1e20835885244d17d280b45cc9591bf Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Feb 2026 11:15:44 +0000 Subject: [PATCH 516/791] Support getAvailableName for public client --- js/sdk/src/protonDrivePublicLinkClient.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 863557ae..f60d0a62 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -350,4 +350,17 @@ export class ProtonDrivePublicLinkClient { this.logger.info(`Getting file revision uploader for ${getUid(nodeUid)}`); return this.upload.getFileRevisionUploader(getUid(nodeUid), metadata, signal); } + + /** + * Returns the available name for the file in the given parent folder. + * + * The function will return a name that includes the original name with the + * available index. The name is guaranteed to be unique in the parent folder. + * + * Example new name: `file (2).txt`. + */ + async getAvailableName(parentFolderUid: NodeOrUid, name: string): Promise { + this.logger.info(`Getting available name in folder ${getUid(parentFolderUid)}`); + return this.sharingPublic.nodes.management.findAvailableName(getUid(parentFolderUid), name); + } } From 20fce9aed9f52d4f0dae52da85499a0fe17e06e1 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Feb 2026 12:04:11 +0000 Subject: [PATCH 517/791] Update changelog for js/v0.9.9 --- js/CHANGELOG.md | 70 +++++-------------------------------------------- 1 file changed, 7 insertions(+), 63 deletions(-) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 38ffb85d..f9be10e0 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,105 +1,91 @@ # Changelog +## js/v0.9.9 (2026-02-12) + +* Support getAvailableName for public client +* Add iterator of album photos +* Add method to remove photos from an album + ## js/v0.9.8 (2026-02-10) * Add experimental getNodePassphrase * Add SHA1 upload verification * Add album management -* Update changelog for js/v0.9.7 ## js/v0.9.7 (2026-02-05) * [DRVWEB-5135] Add empty trash for photo volume -* Automate changelog ## js/v0.9.6 (2026-02-02) -* i18n(weekly-mr): Upgrade translations from crowdin (31132796). * Add experimental createDocument to create Docs/Sheets * Add function to create bookmark ## js/v0.9.5 (2026-01-29) -* js/v0.9.5 * Remove check of NodeType inside iterateThumbnails * Fix file with content check for diagnostics ## js/v0.9.4 (2026-01-22) -* js/v0.9.4 * Add function to scan for malware * Release lock after download and close the stream in diagnostics * Report metrics from photos as own_photo_volume * Fix default timeout on rate limit -* i18n: Upgrade translations from crowdin (3e7a896b). ## js/v0.9.3 (2026-01-16) -* js/v0.9.3 -* Debounce Account key requests for CLI * Fix invitation node type * Upgrade CryptoProxy and SRP ## js/v0.9.2 (2026-01-13) -* js/v0.9.2 * Fix typing of CryptoProxy and CLI * Add tree structure to diagnostics * Multiple public fixes ## js/v0.9.1 (2026-01-07) -* js/v0.9.1 * Handle timeouts during uploads * Fix buffered seekable stream -* i18n: Upgrade translations from crowdin (2cb75ecb). * Catch TypeError when calling releaseLock ## js/v0.9.0 (2025-12-17) -* js/v0.9.0 * Allow download with signature issues * Add empty-trash Implementation * Handle failed upload due to double-commit attempt ## js/v0.8.0 (2025-12-15) -* js/v0.8.0 * Use remove-mine for deleting nodes on public page * Fix old content key packet verification * Compress extended attributes ## js/v0.7.3 (2025-12-12) -* js/v0.7.3 * Create findPhotoDuplicates to get uids of duplicates ## js/v0.7.2 (2025-12-11) -* js/v0.7.2 -* i18n: Upgrade translations from crowdin (5f7f1f9c). * Fix photo node type * Add getMyPhotosRootFolder ## js/v0.7.1 (2025-12-08) -* js/v0.7.1 * Photos entity to support full decryption and access to photo attributes -* Add support to C# CLI for downloading by node UID * Add onMessage to ProtonDrivePublicLinkClient * Add modification time to the node entity * Add new name param to copy ## js/v0.7.0 (2025-11-28) -* js/v0.7.0 * Add unauth prefix for all API calls from public link context * Ignore missing signatures on legacy nodes * Abort uploads properly ## js/v0.6.2 (2025-11-21) -* js/v0.6.2 * Fix deleting draft * CaptureTime unix time was in milliseconds instead of seconds * Make feature flag provision asynchronous @@ -107,7 +93,6 @@ ## js/v0.6.1 (2025-11-20) -* js/v0.6.1 * Add isDuplicatePhoto method * Refresh node when share already exists * Add diagnostics for Photos timeline @@ -120,24 +105,17 @@ ## js/v0.6.0 (2025-10-24) -* js/v0.6.0 -* Unify CLI parameters and docs * Parametrize shared with me and invitations for Photos SDK * Expose sharing for Photos SDK * Add getAvailableName method ## js/v0.5.1 (2025-10-22) -* js/v0.5.1 * Add expectedStrcuture options for diagnostics * Convert revisions to public interface -* Remove console handlers for JSON outputs * Update public access to new APIs -* Align JSON output of the C# CLI with the JavaScript one * Return new UID of copied node * Throw NodeWithSameNameExists from createFolder -* Fix app version for CLI app -* Json mode for web CLI * Use shares/photos endpoint to bootstrap photos * Add telemetry for debouncer * Fix aborting uploads & downloads @@ -145,16 +123,13 @@ ## js/v0.5.0 (2025-10-03) -* js/v0.5.0 * Do not send cleartext file size * Add propagating offline error to SDK events * fileUpload completion should return nodeUid and nodeRevisionUid -* Use npm ci instead install * Batch and split per volume trash/restore/delete nodes * Abort decrypting nodes * Handle abort errors * [JS] Use the same instance of uploadController in stream upload -* Add CLI commands for invitation accept/reject * Add CLI commands for public access * Reuse endpoints for public link * Add debouncer to avoid parallel loading of the same node @@ -162,14 +137,12 @@ ## js/v0.4.1 (2025-09-24) -* js/v0.4.1 * Add isSharedPublicly to node based on ShareURLID * Implement CLI photo download * Implement photo upload ## js/v0.4.0 (2025-09-22) -* js/v0.4.0 * Implement ProtonDrivePhotosClient basics * Add filter options for listing children * Add copyNodes @@ -178,7 +151,6 @@ ## js/v0.3.2 (2025-09-17) -* js/v0.3.2 * Fix SharedWithMe cache * Reuse Node entity for public link access * Add cause to wrapped errors @@ -186,7 +158,6 @@ ## js/v0.3.1 (2025-09-11) -* js/v0.3.1 * NotFoundAPIError is inherited from ValidationError * Fix decrpyting bookmark with custom password * Fix cache shared by me @@ -195,43 +166,35 @@ ## js/v0.3.0 (2025-09-04) -* js/v0.3.0 * Fix cache in CLI * Improve performance of loading shared with me * Fix what address is used to invite users into the share * Rename NodeAlreadyExistsValidationError * Fix accepting entities and UIDs in the interface * Revamp documentation -* Remove quark types after merge * Add node details to diagnostic results ## js/v0.2.1 (2025-08-20) -* js/v0.2.1 * Separate custom password from bookmark url * Fix parsing claimedModificationTime in NodesCache * Invalid value code is ValidationError -* Fix direct member role in tests ## js/v0.2.0 (2025-08-14) -* js/v0.2.0 * Add node membership * Update telemetry object * Fix download * Add download unit tests * Add seeking support for download -* Add events ready info into CLI ## js/v0.1.2 (2025-08-04) -* js/v0.1.2 * Fix event subscriptions * Fix invalidating cache after upload ## js/v0.1.1 (2025-08-01) -* js/v0.1.1 * Improve loading nodes performance * Remove obsolete signature check on block download * Return nodes integration test @@ -242,18 +205,15 @@ ## js/v0.1.0 (2025-07-29) * Refactor event manager: -* js/v0.1.0 * Add diagnostic tool * Add support of client UID * Add integration test for moving node * Fix move twice * Add NumAccess to publicLink * Support multiple volumes thumbnails -* Add prettier ## js/v0.0.13 (2025-07-18) -* js/v0.0.13 * Add album node type * Fix test of asyncIteratorMap * Create draft when starting upload @@ -261,32 +221,24 @@ * Decrypt nodes in parallel * Filter out photos and albums from shared with me listing * Set admin role for all nodes in own volume -* Fix env variable names in readme * add existingNodeUid on NodeAlreadyExistsValidationError -* Fix publishing of npm packages ## js/v0.0.12 (2025-07-10) -* js/v0.0.12 +* No changes ## js/v0.0.11 (2025-07-10) -* release js/v0.0.11 * Remove sensitive info from logs * Implement bookmarks management -* i18n: Upgrade translations from crowdin (f8f00ca2). -* Feedback from old MR's * Add deprecated share ID * Add fallback unknown error message * Fix returning public revision * Fix parsing node from cache * Add integration tests for web SDK using real crypto module -* update user fixtures for easier handling * Use ExpirationTime instead of ExpirationDuration for public link management * Align error categories for upload/download telemetry with definitions -* Switch to public npm registry * Add missing re-export of the interface -* Migrate to playwright ## js/v0.0.10 (2025-06-26) @@ -297,19 +249,15 @@ ## js/v0.0.9 (2025-06-24) -* release js/v0.0.9 * Add resend invite implementation * implement getNodeUid * Update decryption telemetry according to documentation * L10N-4186 Add test/extract job ttag -* Js/fix proxy typing * Create type structure for keys ## js/v0.0.8 (2025-06-19) -* Bump to js/v0.0.8 * use nodeUid for external invite instead of volumeId -* Make use of incremental build of tsc * Update type of CryptoProxy * signMessage accept signatureContext and not context @@ -323,24 +271,20 @@ ## js/v0.0.5 (2025-06-11) -* Update JS package version to 0.0.5 * add getNode method * add block verification telemetry * configuration for npm package publishing * add experimental getDocsKey * reuse array buffer * fix getting address key -* e2e tests for download module * handle missing public address ## js/v0.0.4 (2025-06-02) -* Update JS package version to 0.0.5 * add getNode method * add block verification telemetry * configuration for npm package publishing * add experimental getDocsKey * reuse array buffer * fix getting address key -* e2e tests for download module * handle missing public address From ff5eef8bd44c5beb26d613279c77166f41f81dc1 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 06:03:14 +0000 Subject: [PATCH 518/791] i18n(weekly-mr): Upgrade translations from crowdin (20fce9ae). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 6 ++++++ js/sdk/locales/ca_ES.json | 6 ++++++ js/sdk/locales/de_DE.json | 6 ++++++ js/sdk/locales/el_GR.json | 6 ++++++ js/sdk/locales/es_ES.json | 6 ++++++ js/sdk/locales/es_LA.json | 6 ++++++ js/sdk/locales/fr_FR.json | 6 ++++++ js/sdk/locales/it_IT.json | 6 ++++++ js/sdk/locales/nl_NL.json | 6 ++++++ js/sdk/locales/pt_BR.json | 3 +++ js/sdk/locales/ro_RO.json | 6 ++++++ js/sdk/locales/sk_SK.json | 6 ++++++ js/sdk/locales/tr_TR.json | 6 ++++++ 14 files changed, 76 insertions(+), 1 deletion(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index 7037ea88..889805df 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "76adc64b3298ecd24b19101994f543a2b4edf085" + "locale": "bc0eea33201570d9af77bfc607690c829543228f" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 07d775e7..71f1e9a8 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "Ðльбом змÑшчае фатаграфіі, Ñкіх нÑма Ñž шкале чаÑу" + ], "Bookmark password is not available": [ "Пароль закладкі недаÑтупны" ], @@ -98,6 +101,9 @@ "File has no content key": [ "Файл не мае ключа змеÑціва" ], + "File hash does not match expected hash": [ + "Ð¥Ñш файла не Ñупадае з чаканым Ñ…Ñшам" + ], "Invalid URL": [ "Памылковы URL-адраÑ" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 6bef5f79..75e5c931 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "L'àlbum conté fotos que no són a la línia temporal" + ], "Bookmark password is not available": [ "La contrasenya d'adreces d'interès no està disponible." ], @@ -98,6 +101,9 @@ "File has no content key": [ "El fitxer no té clau de contingut" ], + "File hash does not match expected hash": [ + "L'empremta electrònica del fitxer no coincideix amb l'esperada" + ], "Invalid URL": [ "L'URL no és vàlid" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 1c62309d..293e7ffc 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "Das Album enthält Fotos, die nicht in der Zeitleiste enthalten sind" + ], "Bookmark password is not available": [ "Lesezeichen-Passwort ist nicht verfügbar" ], @@ -98,6 +101,9 @@ "File has no content key": [ "Datei hat keinen Inhaltsschlüssel" ], + "File hash does not match expected hash": [ + "Datei-Hash stimmt nicht mit dem erwarteten Hash überein" + ], "Invalid URL": [ "Ungültige URL" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index cb4a7b18..b0bb9356 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "Το άλμπουμ πεÏιέχει φωτογÏαφίες που δε βÏίσκονται στο χÏονολόγιο" + ], "Bookmark password is not available": [ "Η σελιδοδείκτηση ÎºÏ‰Î´Î¹ÎºÎ¿Ï Î´ÎµÎ½ είναι διαθέσιμη" ], @@ -98,6 +101,9 @@ "File has no content key": [ "Το αÏχείο δεν έχει κλειδί πεÏιεχομένου" ], + "File hash does not match expected hash": [ + "Το hash του αÏχείου δεν ταιÏιάζει με τον αναμενόμενο hash" + ], "Invalid URL": [ "Μη έγκυÏη διεÏθυνση URL" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index b688b16e..3c240c1c 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "El álbum contiene fotos que no están en la línea de tiempo" + ], "Bookmark password is not available": [ "La contraseña del marcador no está disponible" ], @@ -98,6 +101,9 @@ "File has no content key": [ "El archivo no tiene clave de contenido." ], + "File hash does not match expected hash": [ + "El hash del archivo no coincide con el valor esperado" + ], "Invalid URL": [ "La URL no es válida." ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 8a4b6461..a928994b 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "El álbum contiene fotos que no están en la línea de tiempo" + ], "Bookmark password is not available": [ "La contraseña del marcador no está disponible." ], @@ -98,6 +101,9 @@ "File has no content key": [ "El archivo no tiene clave de contenido" ], + "File hash does not match expected hash": [ + "El hash del archivo no coincide con el valor esperado" + ], "Invalid URL": [ "URL inválida" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index 178bfae5..af159903 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "L'album contient des photos qui ne figurent pas dans la chronologie." + ], "Bookmark password is not available": [ "Le mot de passe du favori n'est pas disponible." ], @@ -98,6 +101,9 @@ "File has no content key": [ "Le fichier n'a pas de clé de contenu." ], + "File hash does not match expected hash": [ + "Le hachage du fichier ne correspond pas au hachage attendu." + ], "Invalid URL": [ "L'URL n'est pas valide." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index e42da0b5..cfd1dd34 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "L'album contiene foto non presenti nella cronologia" + ], "Bookmark password is not available": [ "La password del segnalibro non è disponibile." ], @@ -98,6 +101,9 @@ "File has no content key": [ "Il file non ha una chiave di contenuto" ], + "File hash does not match expected hash": [ + "L'hash del file non corrisponde all'hash atteso" + ], "Invalid URL": [ "URL non valido" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index baa7debf..887caa62 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "Album bevat foto's die niet in de tijdlijn staan" + ], "Bookmark password is not available": [ "Bladwijzerwachtwoord is niet beschikbaar" ], @@ -98,6 +101,9 @@ "File has no content key": [ "Bestand heeft geen inhoudssleutel" ], + "File hash does not match expected hash": [ + "Bestandhash komt niet overeen met verwachte hash" + ], "Invalid URL": [ "Ongeldige URL" ], diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index 6e6be3d4..a025b16e 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -20,6 +20,9 @@ "Copying item to a non-folder is not allowed": [ "Não é permitido copiar um item para um item que não seja uma pasta." ], + "Creating documents in non-folders is not allowed": [ + "Não é permitido criar documentos em itens que não sejam pastas." + ], "Creating files in non-folders is not allowed": [ "Não é permitido criar arquivos em itens que não sejam pastas." ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index a4a75559..1899d273 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "Albumul conÈ›ine fotografii care nu sunt în cronologie." + ], "Bookmark password is not available": [ "Parola semnului de carte nu este disponibilă." ], @@ -98,6 +101,9 @@ "File has no content key": [ "FiÈ™ierul nu are nicio cheie de conÈ›inut." ], + "File hash does not match expected hash": [ + "Codul de verificare al fiÈ™ierului nu corespunde cu cel aÈ™teptat." + ], "Invalid URL": [ "Adresă URL nevalidă." ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index e7f5e348..2b45ad7c 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "Album obsahuje fotografie, ktoré nie sú v Äasovej osi" + ], "Bookmark password is not available": [ "Heslo záložky nie je dostupné" ], @@ -98,6 +101,9 @@ "File has no content key": [ "Súbor nemá kÄ¾ÃºÄ obsahu" ], + "File hash does not match expected hash": [ + "Hash súboru nezodpovedá oÄakávanému hashu" + ], "Invalid URL": [ "Neplatná URL" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index 533ebf2a..cba54b89 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -5,6 +5,9 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "Albümde zaman akışınızda olmayan fotoÄŸraflar var" + ], "Bookmark password is not available": [ "Yer imi parolası kullanılamıyor" ], @@ -98,6 +101,9 @@ "File has no content key": [ "Dosyanın içerik anahtarı yok" ], + "File hash does not match expected hash": [ + "Dosya karma deÄŸeri beklenen deÄŸerle aynı deÄŸil" + ], "Invalid URL": [ "Adres geçersiz" ], From 7da074fc35a68ed2a9446d66eec0c1802adb8b48 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 16:50:30 +0100 Subject: [PATCH 519/791] Cleanup crypto utils and fix type errors Update helpers based on monorepo changes. And use native Uint8Array hex and base64 helpers in cli module --- js/sdk/src/crypto/interface.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 78ef1dbe..afcd0627 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -2,6 +2,35 @@ export interface PublicKey { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly _idx: any; + readonly _keyContentHash: [string, string]; + + getVersion(): number; + getFingerprint(): string; + getSHA256Fingerprints(): string[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKeyID(): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKeyIDs(): any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAlgorithmInfo(): any; + getCreationTime(): Date; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isPrivate: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isPrivateKeyV4: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isPrivateKeyV6: any; + getExpirationTime(): Date | number | null; + getUserIDs(): string[]; + isWeak(): boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + equals(otherKey: any, ignoreOtherCerts?: boolean): boolean; + subkeys: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAlgorithmInfo(): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKeyID(): any; + }[]; } export interface PrivateKey extends PublicKey { @@ -10,6 +39,8 @@ export interface PrivateKey extends PublicKey { export interface SessionKey { data: Uint8Array; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + algorithm: any; } export enum VERIFICATION_STATUS { From 451b985e0c0c58d626a01010c0d93805ae2cf2c0 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 Jan 2026 13:21:55 +0100 Subject: [PATCH 520/791] TS: declare Uint8Array over generic Uint8Array This is to limit the need to downcast Uint8Array in output when using them with e.g. WebCrypto and Blobs, following an incompatible interface change between ArrayBuffer and SharedArrayBuffer. Also update to TS v5.9 for better type support of e.g. TextEncoder (generic Uint8Array construct was added in v5.7). The ESLint plugin @protontech/enforce-uint8array-arraybuffer cannot be integrated until upgrade to eslint v9, due to eslint legacy config interop issues between the non-ESM repo and the ESM plugin. --- js/sdk/package-lock.json | 972 +++++------------- js/sdk/package.json | 6 +- js/sdk/src/crypto/driveCrypto.ts | 30 +- js/sdk/src/crypto/hmac.ts | 8 +- js/sdk/src/crypto/interface.ts | 54 +- js/sdk/src/crypto/openPGPCrypto.ts | 52 +- js/sdk/src/interface/index.ts | 2 +- js/sdk/src/interface/thumbnail.ts | 4 +- js/sdk/src/internal/download/apiService.ts | 2 +- js/sdk/src/internal/download/cryptoService.ts | 8 +- .../internal/download/fileDownloader.test.ts | 8 +- .../src/internal/download/fileDownloader.ts | 12 +- .../internal/download/thumbnailDownloader.ts | 8 +- .../src/internal/nodes/cryptoService.test.ts | 4 +- js/sdk/src/internal/nodes/cryptoService.ts | 10 +- js/sdk/src/internal/nodes/interface.ts | 2 +- js/sdk/src/internal/photos/upload.ts | 4 +- js/sdk/src/internal/upload/apiService.ts | 10 +- js/sdk/src/internal/upload/blockVerifier.ts | 6 +- .../internal/upload/chunkStreamReader.test.ts | 14 +- .../src/internal/upload/chunkStreamReader.ts | 6 +- js/sdk/src/internal/upload/cryptoService.ts | 18 +- js/sdk/src/internal/upload/interface.ts | 12 +- js/sdk/src/internal/upload/manager.ts | 4 +- js/sdk/src/internal/upload/streamUploader.ts | 2 +- 25 files changed, 396 insertions(+), 862 deletions(-) diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 14edb5f7..fdd5008b 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -18,7 +18,7 @@ "@swc/jest": "^0.2.38", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.10", - "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/eslint-plugin": "^8.53.1", "@web/dev-server-esbuild": "^1.0.3", "eslint": "^8.57.1", "eslint-plugin-tsdoc": "^0.3.0", @@ -27,8 +27,8 @@ "openapi-typescript": "^7.4.1", "prettier": "^3.6.2", "ttag-cli": "^1.10.18", - "typedoc": "^0.26.11", - "typescript": "^5.6.3" + "typedoc": "^0.28.16", + "typescript": "^5.9.3" } }, "node_modules/@ampproject/remapping": { @@ -49,7 +49,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -338,7 +338,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2004,7 +2004,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2491,9 +2491,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2510,9 +2510,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -2606,6 +2606,20 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.21.0.tgz", + "integrity": "sha512-9PrsT5DjZA+w3lur/aOIx3FlDeHdyCEFlv9U+fmsVyjPZh61G5SYURQ/1ebe2U63KbDmI2V8IhIUegWb8hjOyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.21.0", + "@shikijs/langs": "^3.21.0", + "@shikijs/themes": "^3.21.0", + "@shikijs/types": "^3.21.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3482,72 +3496,45 @@ "node": ">=10" } }, - "node_modules/@shikijs/core": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", - "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/engine-javascript": "1.29.2", - "@shikijs/engine-oniguruma": "1.29.2", - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz", - "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "oniguruma-to-es": "^2.2.0" - } - }, "node_modules/@shikijs/engine-oniguruma": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz", - "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.21.0.tgz", + "integrity": "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1" + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz", - "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.21.0.tgz", + "integrity": "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "1.29.2" + "@shikijs/types": "3.21.0" } }, "node_modules/@shikijs/themes": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz", - "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.21.0.tgz", + "integrity": "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "1.29.2" + "@shikijs/types": "3.21.0" } }, "node_modules/@shikijs/types": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz", - "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.21.0.tgz", + "integrity": "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/vscode-textmate": "^10.0.1", + "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, @@ -4067,16 +4054,6 @@ "@types/koa": "*" } }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4105,7 +4082,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/parse5": { @@ -4194,21 +4171,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz", - "integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", + "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/type-utils": "8.25.0", - "@typescript-eslint/utils": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/type-utils": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4218,24 +4194,34 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz", - "integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz", + "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/typescript-estree": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4246,18 +4232,40 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz", + "integrity": "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.1", + "@typescript-eslint/types": "^8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz", - "integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz", + "integrity": "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0" + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4267,17 +4275,35 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz", + "integrity": "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz", - "integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz", + "integrity": "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.25.0", - "@typescript-eslint/utils": "8.25.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4288,13 +4314,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz", - "integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.1.tgz", + "integrity": "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==", "dev": true, "license": "MIT", "engines": { @@ -4306,20 +4332,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", - "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz", + "integrity": "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/project-service": "8.53.1", + "@typescript-eslint/tsconfig-utils": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4329,13 +4356,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -4346,16 +4373,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz", - "integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.1.tgz", + "integrity": "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/typescript-estree": "8.25.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4366,18 +4393,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz", - "integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz", + "integrity": "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.53.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4388,9 +4415,9 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4715,23 +4742,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", @@ -5125,7 +5135,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5162,17 +5172,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5207,28 +5206,6 @@ "node": ">=10" } }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -5374,17 +5351,6 @@ "dev": true, "license": "MIT" }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5450,35 +5416,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -5517,9 +5454,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -5600,16 +5537,6 @@ "node": ">= 0.8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5631,20 +5558,6 @@ "node": ">=8" } }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5724,13 +5637,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true, - "license": "MIT" - }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -5768,7 +5674,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -6208,36 +6114,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -6421,7 +6297,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6678,7 +6554,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6687,44 +6563,6 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6732,17 +6570,6 @@ "dev": true, "license": "MIT" }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -6866,7 +6693,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -6955,14 +6782,14 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -7927,7 +7754,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -7967,7 +7794,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -8200,7 +8027,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/linkify-it": { @@ -8417,28 +8244,6 @@ "node": ">= 0.4" } }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -8463,16 +8268,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -8483,100 +8278,6 @@ "node": ">= 0.6" } }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", - "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -8807,18 +8508,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/oniguruma-to-es": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", - "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^5.1.1", - "regex-recursion": "^5.1.1" - } - }, "node_modules/only": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", @@ -9070,7 +8759,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -9083,7 +8772,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -9149,7 +8838,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -9190,7 +8879,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9200,7 +8889,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -9389,17 +9078,6 @@ "node": ">= 6" } }, - "node_modules/property-information": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", - "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -9625,34 +9303,6 @@ "@babel/runtime": "^7.8.4" } }, - "node_modules/regex": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", - "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", - "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex": "^5.1.1", - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, "node_modules/regexpu-core": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", @@ -9735,7 +9385,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -9779,7 +9429,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -10087,23 +9737,6 @@ "node": ">=8" } }, - "node_modules/shiki": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", - "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "1.29.2", - "@shikijs/engine-javascript": "1.29.2", - "@shikijs/engine-oniguruma": "1.29.2", - "@shikijs/langs": "1.29.2", - "@shikijs/themes": "1.29.2", - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "@types/hast": "^3.0.4" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -10225,17 +9858,6 @@ "source-map": "^0.6.0" } }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10331,21 +9953,6 @@ "node": ">=8" } }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10423,7 +10030,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10510,6 +10117,54 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -10560,21 +10215,10 @@ "dev": true, "license": "MIT" }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/ts-api-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", - "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -11047,32 +10691,33 @@ } }, "node_modules/typedoc": { - "version": "0.26.11", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.11.tgz", - "integrity": "sha512-sFEgRRtrcDl2FxVP58Ze++ZK2UQAEvtvvH8rRlig1Ja3o7dDaMHmaBfvJmdGnNEFaLTpQsN8dpvZaTqJSu/Ugw==", + "version": "0.28.16", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.16.tgz", + "integrity": "sha512-x4xW77QC3i5DUFMBp0qjukOTnr/sSg+oEs86nB3LjDslvAmwe/PUGDWbe3GrIqt59oTqoXK5GRK9tAa0sYMiog==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@gerrit0/mini-shiki": "^3.17.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", - "shiki": "^1.16.2", - "yaml": "^2.5.1" + "yaml": "^2.8.1" }, "bin": { "typedoc": "bin/typedoc" }, "engines": { - "node": ">= 18" + "node": ">= 18", + "pnpm": ">= 10" }, "peerDependencies": { - "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x" + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" } }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -11168,79 +10813,6 @@ "node": ">=4" } }, - "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -11324,36 +10896,6 @@ "node": ">= 0.8" } }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/vue-sfc-parser": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/vue-sfc-parser/-/vue-sfc-parser-0.1.2.tgz", @@ -11532,16 +11074,19 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yaml-ast-parser": { @@ -11602,17 +11147,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } } } } diff --git a/js/sdk/package.json b/js/sdk/package.json index 89fa1d0b..fe384c72 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -34,7 +34,7 @@ "@swc/jest": "^0.2.38", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.10", - "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/eslint-plugin": "^8.53.1", "@web/dev-server-esbuild": "^1.0.3", "eslint": "^8.57.1", "eslint-plugin-tsdoc": "^0.3.0", @@ -43,7 +43,7 @@ "openapi-typescript": "^7.4.1", "prettier": "^3.6.2", "ttag-cli": "^1.10.18", - "typedoc": "^0.26.11", - "typescript": "^5.6.3" + "typedoc": "^0.28.16", + "typescript": "^5.9.3" } } diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index e09ba2a8..8a23f1d3 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -343,7 +343,7 @@ export class DriveCrypto { * It encrypts and armors signature with provided session and encryption keys. */ async encryptSignature( - signature: Uint8Array, + signature: Uint8Array, encryptionKey: PrivateKey, sessionKey: SessionKey, ): Promise<{ @@ -365,7 +365,7 @@ export class DriveCrypto { */ async generateHashKey(encryptionAndSigningKey: PrivateKey): Promise<{ armoredHashKey: string; - hashKey: Uint8Array; + hashKey: Uint8Array; }> { // Once all clients can use non-ascii bytes, switch to simple // generating of random bytes without encoding it into base64: @@ -385,7 +385,7 @@ export class DriveCrypto { }; } - async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { + async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { const key = await importHmacKey(parentHashKey); const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); @@ -461,7 +461,7 @@ export class DriveCrypto { decryptionAndVerificationKey: PrivateKey, extraVerificationKeys: PublicKey[], ): Promise<{ - hashKey: Uint8Array; + hashKey: Uint8Array; verified: VERIFICATION_STATUS; verificationErrors?: Error[]; }> { @@ -611,11 +611,11 @@ export class DriveCrypto { } async encryptThumbnailBlock( - thumbnailData: Uint8Array, + thumbnailData: Uint8Array, sessionKey: SessionKey, signingKey: PrivateKey, ): Promise<{ - encryptedData: Uint8Array; + encryptedData: Uint8Array; }> { const { encryptedData } = await this.openPGPCrypto.encryptAndSign( thumbnailData, @@ -630,11 +630,11 @@ export class DriveCrypto { } async decryptThumbnailBlock( - encryptedThumbnail: Uint8Array, + encryptedThumbnail: Uint8Array, sessionKey: SessionKey, verificationKeys: PublicKey[], ): Promise<{ - decryptedThumbnail: Uint8Array; + decryptedThumbnail: Uint8Array; verified: VERIFICATION_STATUS; verificationErrors?: Error[]; }> { @@ -651,12 +651,12 @@ export class DriveCrypto { } async encryptBlock( - blockData: Uint8Array, + blockData: Uint8Array, encryptionKey: PrivateKey, sessionKey: SessionKey, signingKey: PrivateKey, ): Promise<{ - encryptedData: Uint8Array; + encryptedData: Uint8Array; armoredSignature: string; }> { const { encryptedData, signature } = await this.openPGPCrypto.encryptAndSignDetached( @@ -674,14 +674,14 @@ export class DriveCrypto { }; } - async decryptBlock(encryptedBlock: Uint8Array, sessionKey: SessionKey): Promise { + async decryptBlock(encryptedBlock: Uint8Array, sessionKey: SessionKey): Promise> { const { data: decryptedBlock } = await this.openPGPCrypto.decryptAndVerify(encryptedBlock, sessionKey, []); return decryptedBlock; } async signManifest( - manifest: Uint8Array, + manifest: Uint8Array, signingKey: PrivateKey, ): Promise<{ armoredManifestSignature: string; @@ -693,7 +693,7 @@ export class DriveCrypto { } async verifyManifest( - manifest: Uint8Array, + manifest: Uint8Array, armoredSignature: string, verificationKeys: PublicKey | PublicKey[], ): Promise<{ @@ -727,7 +727,7 @@ export class DriveCrypto { } } -export function uint8ArrayToUtf8(input: Uint8Array): string { +export function uint8ArrayToUtf8(input: Uint8Array): string { return new TextDecoder('utf-8', { fatal: true }).decode(input); } @@ -736,7 +736,7 @@ export function uint8ArrayToUtf8(input: Uint8Array): string { * @param bytes - Array of 8-bit integers to convert * @returns Hexadecimal representation of the array */ -export const arrayToHexString = (bytes: Uint8Array) => { +export const arrayToHexString = (bytes: Uint8Array) => { const hexAlphabet = '0123456789abcdef'; let s = ''; bytes.forEach((v) => { diff --git a/js/sdk/src/crypto/hmac.ts b/js/sdk/src/crypto/hmac.ts index 07608809..8cc89713 100644 --- a/js/sdk/src/crypto/hmac.ts +++ b/js/sdk/src/crypto/hmac.ts @@ -8,7 +8,7 @@ type HmacKeyUsage = 'sign' | 'verify'; * Import an HMAC-SHA256 key in order to use it with `signData` and `verifyData`. */ export const importHmacKey = async ( - key: Uint8Array, + key: Uint8Array, keyUsage: HmacKeyUsage[] = ['sign', 'verify'], ): Promise => { // From https://datatracker.ietf.org/doc/html/rfc2104: @@ -29,9 +29,9 @@ export const importHmacKey = async ( * @param data - data to sign * @param additionalData - additional data to authenticate */ -export const computeHmacSignature = async (key: HmacCryptoKey, data: Uint8Array) => { +export const computeHmacSignature = async (key: HmacCryptoKey, data: Uint8Array) => { const signatureBuffer = await crypto.subtle.sign({ name: 'HMAC', hash: HASH_ALGORITHM }, key, data); - return new Uint8Array(signatureBuffer); + return new Uint8Array(signatureBuffer); }; /** @@ -41,6 +41,6 @@ export const computeHmacSignature = async (key: HmacCryptoKey, data: Uint8Array) * @param data - data to verify * @param additionalData - additional data to authenticate */ -export const verifyData = async (key: HmacCryptoKey, signature: Uint8Array, data: Uint8Array) => { +export const verifyData = async (key: HmacCryptoKey, signature: Uint8Array, data: Uint8Array) => { return crypto.subtle.verify({ name: 'HMAC', hash: HASH_ALGORITHM }, key, signature, data); }; diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index afcd0627..05b7d0ef 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -38,7 +38,7 @@ export interface PrivateKey extends PublicKey { } export interface SessionKey { - data: Uint8Array; + data: Uint8Array; // eslint-disable-next-line @typescript-eslint/no-explicit-any algorithm: any; } @@ -97,14 +97,14 @@ export interface OpenPGPCrypto { sessionKey: SessionKey, encryptionKeys: PublicKey | PublicKey[], ) => Promise<{ - keyPacket: Uint8Array; + keyPacket: Uint8Array; }>; encryptSessionKeyWithPassword: ( sessionKey: SessionKey, password: string, ) => Promise<{ - keyPacket: Uint8Array; + keyPacket: Uint8Array; }>; /** @@ -118,7 +118,7 @@ export interface OpenPGPCrypto { }>; encryptArmored: ( - data: Uint8Array, + data: Uint8Array, encryptionKeys: PublicKey[], sessionKey?: SessionKey, ) => Promise<{ @@ -126,16 +126,16 @@ export interface OpenPGPCrypto { }>; encryptAndSign: ( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, ) => Promise<{ - encryptedData: Uint8Array; + encryptedData: Uint8Array; }>; encryptAndSignArmored: ( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey | undefined, encryptionKeys: PublicKey[], signingKey: PrivateKey, @@ -145,17 +145,17 @@ export interface OpenPGPCrypto { }>; encryptAndSignDetached: ( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, ) => Promise<{ - encryptedData: Uint8Array; - signature: Uint8Array; + encryptedData: Uint8Array; + signature: Uint8Array; }>; encryptAndSignDetachedArmored: ( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, @@ -165,23 +165,23 @@ export interface OpenPGPCrypto { }>; sign: ( - data: Uint8Array, + data: Uint8Array, signingKey: PrivateKey, signatureContext: string, ) => Promise<{ - signature: Uint8Array; + signature: Uint8Array; }>; signArmored: ( - data: Uint8Array, + data: Uint8Array, signingKey: PrivateKey | PrivateKey[], ) => Promise<{ signature: string; }>; verify: ( - data: Uint8Array, - signature: Uint8Array, + data: Uint8Array, + signature: Uint8Array, verificationKeys: PublicKey | PublicKey[], ) => Promise<{ verified: VERIFICATION_STATUS; @@ -189,7 +189,7 @@ export interface OpenPGPCrypto { }>; verifyArmored: ( - data: Uint8Array, + data: Uint8Array, armoredSignature: string, verificationKeys: PublicKey | PublicKey[], signatureContext?: string, @@ -198,41 +198,41 @@ export interface OpenPGPCrypto { verificationErrors?: Error[]; }>; - decryptSessionKey: (data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; + decryptSessionKey: (data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; decryptArmoredSessionKey: (armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; decryptKey: (armoredKey: string, passphrase: string) => Promise; decryptAndVerify( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey, verificationKeys: PublicKey | PublicKey[], ): Promise<{ - data: Uint8Array; + data: Uint8Array; verified: VERIFICATION_STATUS; verificationErrors?: Error[]; }>; decryptAndVerifyDetached( - data: Uint8Array, - signature: Uint8Array | undefined, + data: Uint8Array, + signature: Uint8Array | undefined, sessionKey: SessionKey, verificationKeys?: PublicKey | PublicKey[], ): Promise<{ - data: Uint8Array; + data: Uint8Array; verified: VERIFICATION_STATUS; verificationErrors?: Error[]; }>; - decryptArmored(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]): Promise; + decryptArmored(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]): Promise>; decryptArmoredAndVerify: ( armoredData: string, decryptionKeys: PrivateKey | PrivateKey[], verificationKeys: PublicKey | PublicKey[], ) => Promise<{ - data: Uint8Array; + data: Uint8Array; verified: VERIFICATION_STATUS; verificationErrors?: Error[]; }>; @@ -243,10 +243,10 @@ export interface OpenPGPCrypto { sessionKey: SessionKey, verificationKeys: PublicKey | PublicKey[], ) => Promise<{ - data: Uint8Array; + data: Uint8Array; verified: VERIFICATION_STATUS; verificationErrors?: Error[]; }>; - decryptArmoredWithPassword(armoredData: string, password: string): Promise; + decryptArmoredWithPassword(armoredData: string, password: string): Promise>; } diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index 64caf56c..f71b3311 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -18,15 +18,15 @@ export interface OpenPGPCryptoProxy { encryptionKeys?: PublicKey | PublicKey[]; passwords?: string[]; }, - ) => Promise; + ) => Promise>; decryptSessionKey: (options: { armoredMessage?: string; - binaryMessage?: Uint8Array; + binaryMessage?: Uint8Array; decryptionKeys: PrivateKey | PrivateKey[]; }) => Promise; encryptMessage: (options: { format?: Format; - binaryData: Uint8Array; + binaryData: Uint8Array; sessionKey?: SessionKey; encryptionKeys: PublicKey[]; signingKeys?: PrivateKey; @@ -35,39 +35,39 @@ export interface OpenPGPCryptoProxy { }) => Promise< Detached extends true ? { - message: Format extends 'binary' ? Uint8Array : string; - signature: Format extends 'binary' ? Uint8Array : string; + message: Format extends 'binary' ? Uint8Array : string; + signature: Format extends 'binary' ? Uint8Array : string; } : { - message: Format extends 'binary' ? Uint8Array : string; + message: Format extends 'binary' ? Uint8Array : string; } >; decryptMessage: (options: { format: Format; armoredMessage?: string; - binaryMessage?: Uint8Array; + binaryMessage?: Uint8Array; armoredSignature?: string; - binarySignature?: Uint8Array; + binarySignature?: Uint8Array; sessionKeys?: SessionKey; passwords?: string[]; decryptionKeys?: PrivateKey | PrivateKey[]; verificationKeys?: PublicKey | PublicKey[]; }) => Promise<{ - data: Format extends 'binary' ? Uint8Array : string; + data: Format extends 'binary' ? Uint8Array : string; verificationStatus: VERIFICATION_STATUS; verificationErrors?: Error[]; }>; signMessage: (options: { format: Format; - binaryData: Uint8Array; + binaryData: Uint8Array; signingKeys: PrivateKey | PrivateKey[]; detached: boolean; signatureContext?: { critical: boolean; value: string }; - }) => Promise; + }) => Promise : string>; verifyMessage: (options: { - binaryData: Uint8Array; + binaryData: Uint8Array; armoredSignature?: string; - binarySignature?: Uint8Array; + binarySignature?: Uint8Array; verificationKeys: PublicKey | PublicKey[]; signatureContext?: { critical: boolean; value: string }; }) => Promise<{ @@ -137,7 +137,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async encryptArmored(data: Uint8Array, encryptionKeys: PublicKey[], sessionKey?: SessionKey) { + async encryptArmored(data: Uint8Array, encryptionKeys: PublicKey[], sessionKey?: SessionKey) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, sessionKey, @@ -149,7 +149,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } async encryptAndSign( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, @@ -168,7 +168,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } async encryptAndSignArmored( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey | undefined, encryptionKeys: PublicKey[], signingKey: PrivateKey, @@ -188,7 +188,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } async encryptAndSignDetached( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, @@ -208,7 +208,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } async encryptAndSignDetachedArmored( - data: Uint8Array, + data: Uint8Array, sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, @@ -226,7 +226,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async sign(data: Uint8Array, signingKeys: PrivateKey | PrivateKey[], signatureContext: string) { + async sign(data: Uint8Array, signingKeys: PrivateKey | PrivateKey[], signatureContext: string) { const signature = await this.cryptoProxy.signMessage({ binaryData: data, signingKeys, @@ -239,7 +239,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async signArmored(data: Uint8Array, signingKeys: PrivateKey | PrivateKey[]) { + async signArmored(data: Uint8Array, signingKeys: PrivateKey | PrivateKey[]) { const signature = await this.cryptoProxy.signMessage({ binaryData: data, signingKeys, @@ -251,7 +251,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async verify(data: Uint8Array, signature: Uint8Array, verificationKeys: PublicKey | PublicKey[]) { + async verify(data: Uint8Array, signature: Uint8Array, verificationKeys: PublicKey | PublicKey[]) { const { verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ binaryData: data, binarySignature: signature, @@ -264,7 +264,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } async verifyArmored( - data: Uint8Array, + data: Uint8Array, armoredSignature: string, verificationKeys: PublicKey | PublicKey[], signatureContext?: string, @@ -282,7 +282,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async decryptSessionKey(data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) { + async decryptSessionKey(data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) { const sessionKey = await this.cryptoProxy.decryptSessionKey({ binaryMessage: data, decryptionKeys, @@ -316,7 +316,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return key; } - async decryptAndVerify(data: Uint8Array, sessionKey: SessionKey, verificationKeys: PublicKey[]) { + async decryptAndVerify(data: Uint8Array, sessionKey: SessionKey, verificationKeys: PublicKey[]) { const { data: decryptedData, verificationStatus, @@ -336,8 +336,8 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { } async decryptAndVerifyDetached( - data: Uint8Array, - signature: Uint8Array | undefined, + data: Uint8Array, + signature: Uint8Array | undefined, sessionKey: SessionKey, verificationKeys?: PublicKey[], ) { diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index a6a801fb..d65e659a 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -100,7 +100,7 @@ export type CachedCryptoMaterial = { key: PrivateKey; passphraseSessionKey: SessionKey; contentKeyPacketSessionKey?: SessionKey; - hashKey?: Uint8Array; + hashKey?: Uint8Array; }; shareKey?: { key: PrivateKey; diff --git a/js/sdk/src/interface/thumbnail.ts b/js/sdk/src/interface/thumbnail.ts index 6476a5dc..e8cafb94 100644 --- a/js/sdk/src/interface/thumbnail.ts +++ b/js/sdk/src/interface/thumbnail.ts @@ -1,6 +1,6 @@ export type Thumbnail = { type: ThumbnailType; - thumbnail: Uint8Array; + thumbnail: Uint8Array; }; export enum ThumbnailType { @@ -9,5 +9,5 @@ export enum ThumbnailType { } export type ThumbnailResult = - | { nodeUid: string; ok: true; thumbnail: Uint8Array } + | { nodeUid: string; ok: true; thumbnail: Uint8Array } | { nodeUid: string; ok: false; error: string }; diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index 4dd13117..149b8ccd 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -91,7 +91,7 @@ export class DownloadAPIService { token: string, onProgress?: (downloadedBytes: number) => void, signal?: AbortSignal, - ): Promise { + ): Promise> { const rawBlockStream = await this.apiService.getBlockStream(baseUrl, token, signal); const progressStream = new ObserverStream((value) => { onProgress?.(value.length); diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index 709c12f1..ec5bd514 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -34,7 +34,7 @@ export class DownloadCryptoService { }; } - async decryptBlock(encryptedBlock: Uint8Array, revisionKeys: RevisionKeys): Promise { + async decryptBlock(encryptedBlock: Uint8Array, revisionKeys: RevisionKeys): Promise> { let decryptedBlock; try { // We do not verify signatures on blocks. We only verify @@ -55,7 +55,7 @@ export class DownloadCryptoService { return decryptedBlock; } - async decryptThumbnail(thumbnail: Uint8Array, contentKeyPacketSessionKey: SessionKey): Promise { + async decryptThumbnail(thumbnail: Uint8Array, contentKeyPacketSessionKey: SessionKey): Promise> { let decryptedBlock; try { const result = await this.driveCrypto.decryptThumbnailBlock( @@ -72,7 +72,7 @@ export class DownloadCryptoService { return decryptedBlock; } - async verifyBlockIntegrity(encryptedBlock: Uint8Array, base64sha256Hash: string): Promise { + async verifyBlockIntegrity(encryptedBlock: Uint8Array, base64sha256Hash: string): Promise { const digest = await crypto.subtle.digest('SHA-256', encryptedBlock); const expectedHash = uint8ArrayToBase64String(new Uint8Array(digest)); @@ -87,7 +87,7 @@ export class DownloadCryptoService { async verifyManifest( revision: Revision, nodeKey: PrivateKey, - allBlockHashes: Uint8Array[], + allBlockHashes: Uint8Array[], armoredManifestSignature?: string, ): Promise { const hash = mergeUint8Arrays(allBlockHashes); diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index a62f39b8..fd8a330a 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -85,8 +85,8 @@ describe('FileDownloader', () => { let onFinish: () => void; let downloader: FileDownloader; - let writer: WritableStreamDefaultWriter; - let stream: WritableStream; + let writer: WritableStreamDefaultWriter>; + let stream: WritableStream>; const verifySuccess = async ( fileProgress: number = 6, // 3 blocks of length 1, 2, 3 @@ -360,8 +360,8 @@ describe('FileDownloader', () => { let onFinish: () => void; let downloader: FileDownloader; - let writer: WritableStreamDefaultWriter; - let stream: WritableStream; + let writer: WritableStreamDefaultWriter>; + let stream: WritableStream>; beforeEach(() => { onProgress = jest.fn(); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index fd31b7c8..7de097f9 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -30,7 +30,7 @@ export class FileDownloader { number, { downloadPromise: Promise; - decryptedBufferedBlock?: Uint8Array; + decryptedBufferedBlock?: Uint8Array; } >(); @@ -120,7 +120,7 @@ export class FileDownloader { claimedBlockSizes: number[], position: number, cryptoKeys: RevisionKeys, - ): Promise { + ): Promise | Error | undefined> { const { value, done } = getBlockIndex(claimedBlockSizes, position); if (done) { return; @@ -174,7 +174,7 @@ export class FileDownloader { // Collection of all block hashes for manifest verification. // This includes both thumbnail and regular blocks. - const allBlockHashes: Uint8Array[] = []; + const allBlockHashes: Uint8Array[] = []; let armoredManifestSignature: string | undefined; try { @@ -271,12 +271,12 @@ export class FileDownloader { ignoreIntegrityErrors: boolean, cryptoKeys: RevisionKeys, onProgress?: (downloadedBytes: number) => void, - ): Promise { + ): Promise> { const logger = new LoggerWithPrefix(this.logger, `block ${blockMetadata.index}`); logger.info(`Download started`); let blockProgress = 0; - let decryptedBlock: Uint8Array | null = null; + let decryptedBlock: Uint8Array | null = null; let retries = 0; while (!decryptedBlock) { @@ -369,7 +369,7 @@ export class FileDownloader { } } - private async flushCompletedBlocks(write: (chunk: Uint8Array) => void | Promise) { + private async flushCompletedBlocks(write: (chunk: Uint8Array) => void | Promise) { this.logger.debug(`Flushing completed blocks`); while (this.isNextBlockDownloaded) { const decryptedBlock = this.ongoingDownloads.get(this.nextBlockIndex)!.decryptedBufferedBlock!; diff --git a/js/sdk/src/internal/download/thumbnailDownloader.ts b/js/sdk/src/internal/download/thumbnailDownloader.ts index 35191484..56ecb539 100644 --- a/js/sdk/src/internal/download/thumbnailDownloader.ts +++ b/js/sdk/src/internal/download/thumbnailDownloader.ts @@ -24,7 +24,7 @@ export class ThumbnailDownloader { private batchThumbnailToNodeUids = new Map(); private ongoingDownloads = new Map>(); private bufferedThumbnails: ( - | { nodeUid: string; ok: true; thumbnail: Uint8Array } + | { nodeUid: string; ok: true; thumbnail: Uint8Array } | { nodeUid: string; ok: false; error: string } )[] = []; @@ -163,7 +163,7 @@ export class ThumbnailDownloader { private async *iterateThumbnailDownloads( signal?: AbortSignal, ): AsyncGenerator< - | { nodeUid: string; ok: true; downloadPromise: Promise } + | { nodeUid: string; ok: true; downloadPromise: Promise> } | { nodeUid: string; ok: false; error: string } > { const missingThumbnailUids = new Set(this.batchThumbnailToNodeUids.keys()); @@ -212,10 +212,10 @@ export class ThumbnailDownloader { bareUrl: string, token: string, signal?: AbortSignal, - ): Promise { + ): Promise> { const logger = new LoggerWithPrefix(this.logger, `thumbnail ${token}`); - let decryptedBlock: Uint8Array | null = null; + let decryptedBlock: Uint8Array | null = null; let attempt = 0; while (!decryptedBlock) { diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 62674cae..012afe12 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,4 +1,4 @@ -import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; import { MemberRole, ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; import { @@ -20,7 +20,7 @@ describe('nodesCryptoService', () => { let cryptoService: NodesCryptoService; - const publicAddressKey = { _idx: 21312 }; + const publicAddressKey = { _idx: 21312 } as PublicKey; const ownPrivateAddressKey = { id: 'id', key: 'key' as unknown as PrivateKey }; beforeEach(() => { diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index f1c8bb77..b80c3cb7 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -433,7 +433,7 @@ export class NodesCryptoService { nodeKey: PrivateKey, addressKeys: PublicKey[], ): Promise<{ - hashKey: Uint8Array; + hashKey: Uint8Array; author: Author; }> { if (!('folder' in node.encryptedCrypto)) { @@ -583,7 +583,7 @@ export class NodesCryptoService { } async createFolder( - parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, signingKeys: NodeSigningKeys, name: string, extendedAttributes?: string, @@ -648,7 +648,7 @@ export class NodesCryptoService { } async encryptNewName( - parentKeys: { key: PrivateKey; hashKey?: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey?: Uint8Array }, nodeNameSessionKey: SessionKey, signingKeys: NodeSigningKeys, newName: string, @@ -684,7 +684,7 @@ export class NodesCryptoService { async encryptNodeWithNewParent( nodeName: DecryptedNode['name'], keys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey }, - parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, signingKeys: NodeSigningKeys, ): Promise<{ encryptedName: string; @@ -732,7 +732,7 @@ export class NodesCryptoService { }; } - async generateNameHashes(parentHashKey: Uint8Array, names: string[]): Promise<{ name: string; hash: string }[]> { + async generateNameHashes(parentHashKey: Uint8Array, names: string[]): Promise<{ name: string; hash: string }[]> { return Promise.all( names.map(async (name) => ({ name, diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index c9104ea8..5f6adf12 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -157,7 +157,7 @@ export interface DecryptedNodeKeys { key: PrivateKey; passphraseSessionKey: SessionKey; contentKeyPacketSessionKey?: SessionKey; - hashKey?: Uint8Array; + hashKey?: Uint8Array; } interface BaseRevision { diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index 5b615d5c..c98e387e 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -141,7 +141,7 @@ export class PhotoUploadManager extends UploadManager { async commitDraftPhoto( nodeRevisionDraft: NodeRevisionDraft, - manifest: Uint8Array, + manifest: Uint8Array, extendedAttributes: { modificationTime?: Date; size: number; @@ -185,7 +185,7 @@ export class PhotoUploadCryptoService extends UploadCryptoService { super(driveCrypto, nodesService); } - async generateContentHash(sha1: string, parentHashKey: Uint8Array): Promise { + async generateContentHash(sha1: string, parentHashKey: Uint8Array): Promise { return this.driveCrypto.generateLookupHash(sha1, parentHashKey); } } diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 7a11f84d..69559af8 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -142,7 +142,7 @@ export class UploadAPIService { } async getVerificationData(draftNodeRevisionUid: string): Promise<{ - verificationCode: Uint8Array; + verificationCode: Uint8Array; base64ContentKeyPacket: string; }> { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); @@ -162,14 +162,14 @@ export class UploadAPIService { blocks: { contentBlocks: { index: number; - hash: Uint8Array; + hash: Uint8Array; encryptedSize: number; armoredSignature: string; - verificationToken: Uint8Array; + verificationToken: Uint8Array; }[]; thumbnails?: { type: ThumbnailType; - hash: Uint8Array; + hash: Uint8Array; encryptedSize: number; }[]; }, @@ -260,7 +260,7 @@ export class UploadAPIService { async uploadBlock( url: string, token: string, - block: Uint8Array, + block: Uint8Array, onProgress?: (uploadedBytes: number) => void, signal?: AbortSignal, ): Promise { diff --git a/js/sdk/src/internal/upload/blockVerifier.ts b/js/sdk/src/internal/upload/blockVerifier.ts index 25081c20..de620b2d 100644 --- a/js/sdk/src/internal/upload/blockVerifier.ts +++ b/js/sdk/src/internal/upload/blockVerifier.ts @@ -3,7 +3,7 @@ import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; export class BlockVerifier { - private verificationCode?: Uint8Array; + private verificationCode?: Uint8Array; private contentKeyPacketSessionKey?: SessionKey; constructor( @@ -26,8 +26,8 @@ export class BlockVerifier { ); } - async verifyBlock(encryptedBlock: Uint8Array): Promise<{ - verificationToken: Uint8Array; + async verifyBlock(encryptedBlock: Uint8Array): Promise<{ + verificationToken: Uint8Array; }> { if (!this.verificationCode || !this.contentKeyPacketSessionKey) { throw new Error('Verifying block before loading verification data'); diff --git a/js/sdk/src/internal/upload/chunkStreamReader.test.ts b/js/sdk/src/internal/upload/chunkStreamReader.test.ts index 7dae8620..874d843f 100644 --- a/js/sdk/src/internal/upload/chunkStreamReader.test.ts +++ b/js/sdk/src/internal/upload/chunkStreamReader.test.ts @@ -1,10 +1,10 @@ import { ChunkStreamReader } from './chunkStreamReader'; describe('ChunkStreamReader', () => { - let stream: ReadableStream; + let stream: ReadableStream>; beforeEach(() => { - stream = new ReadableStream({ + stream = new ReadableStream>({ start(controller) { controller.enqueue(new Uint8Array([1, 2, 3])); controller.enqueue(new Uint8Array([4, 5, 6])); @@ -18,7 +18,7 @@ describe('ChunkStreamReader', () => { it('should yield chunks as enqueued if matching the size', async () => { const reader = new ChunkStreamReader(stream, 3); - const chunks: Uint8Array[] = []; + const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { chunks.push(new Uint8Array(chunk)); } @@ -33,7 +33,7 @@ describe('ChunkStreamReader', () => { it('should yield smaller chunks than enqueued chunks', async () => { const reader = new ChunkStreamReader(stream, 2); - const chunks: Uint8Array[] = []; + const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { chunks.push(new Uint8Array(chunk)); } @@ -50,7 +50,7 @@ describe('ChunkStreamReader', () => { it('should yield bigger chunks than enqueued chunks', async () => { const reader = new ChunkStreamReader(stream, 4); - const chunks: Uint8Array[] = []; + const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { chunks.push(new Uint8Array(chunk)); } @@ -64,7 +64,7 @@ describe('ChunkStreamReader', () => { it('should yield last incomplete chunk', async () => { const reader = new ChunkStreamReader(stream, 5); - const chunks: Uint8Array[] = []; + const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { chunks.push(new Uint8Array(chunk)); } @@ -78,7 +78,7 @@ describe('ChunkStreamReader', () => { it('should yield as one big chunk', async () => { const reader = new ChunkStreamReader(stream, 100); - const chunks: Uint8Array[] = []; + const chunks: Uint8Array[] = []; for await (const chunk of reader.iterateChunks()) { chunks.push(new Uint8Array(chunk)); } diff --git a/js/sdk/src/internal/upload/chunkStreamReader.ts b/js/sdk/src/internal/upload/chunkStreamReader.ts index 861163cc..c068e7ba 100644 --- a/js/sdk/src/internal/upload/chunkStreamReader.ts +++ b/js/sdk/src/internal/upload/chunkStreamReader.ts @@ -6,16 +6,16 @@ * If you need to keep previous chunks, copy them to a new array. */ export class ChunkStreamReader { - private reader: ReadableStreamDefaultReader; + private reader: ReadableStreamDefaultReader>; private chunkSize: number; - constructor(stream: ReadableStream, chunkSize: number) { + constructor(stream: ReadableStream>, chunkSize: number) { this.reader = stream.getReader(); this.chunkSize = chunkSize; } - async *iterateChunks(): AsyncGenerator { + async *iterateChunks(): AsyncGenerator> { const buffer = new Uint8Array(this.chunkSize); let position = 0; diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 1406dcaa..5c9bb8ec 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -23,7 +23,7 @@ export class UploadCryptoService { async generateFileCrypto( parentUid: string, - parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, name: string, ): Promise { const signingKeys = await this.getSigningKeys({ parentNodeUid: parentUid }); @@ -118,14 +118,14 @@ export class UploadCryptoService { encryptedData: encryptedData, originalSize: thumbnail.thumbnail.length, encryptedSize: encryptedData.length, - hash: new Uint8Array(digest), + hash: new Uint8Array(digest), }; } async encryptBlock( - verifyBlock: (encryptedBlock: Uint8Array) => Promise<{ verificationToken: Uint8Array }>, + verifyBlock: (encryptedBlock: Uint8Array) => Promise<{ verificationToken: Uint8Array }>, nodeRevisionDraftKeys: NodeRevisionDraftKeys, - block: Uint8Array, + block: Uint8Array, index: number, ): Promise { const { encryptedData, armoredSignature } = await this.driveCrypto.encryptBlock( @@ -145,13 +145,13 @@ export class UploadCryptoService { verificationToken, originalSize: block.length, encryptedSize: encryptedData.length, - hash: new Uint8Array(digest), + hash: new Uint8Array(digest), }; } async commitFile( nodeRevisionDraftKeys: NodeRevisionDraftKeys, - manifest: Uint8Array, + manifest: Uint8Array, extendedAttributes?: string, ): Promise<{ armoredManifestSignature: string; @@ -190,10 +190,10 @@ export class UploadCryptoService { async verifyBlock( contentKeyPacketSessionKey: SessionKey, - verificationCode: Uint8Array, - encryptedData: Uint8Array, + verificationCode: Uint8Array, + encryptedData: Uint8Array, ): Promise<{ - verificationToken: Uint8Array; + verificationToken: Uint8Array; }> { // Attempt to decrypt data block, to try to detect bitflips / bad hardware // diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 9701f7f6..8cb4c5fc 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -8,7 +8,7 @@ export type NodeRevisionDraft = { nodeRevisionUid: string; nodeKeys: NodeRevisionDraftKeys; parentNodeKeys?: { - hashKey: Uint8Array; + hashKey: Uint8Array; }; // newNodeInfo is set only when revision is created with the new node. newNodeInfo?: { @@ -64,19 +64,19 @@ export type NodeCryptoSigningKeys = { export type EncryptedBlockMetadata = { encryptedSize: number; originalSize: number; - hash: Uint8Array; + hash: Uint8Array; }; export type EncryptedBlock = EncryptedBlockMetadata & { index: number; - encryptedData: Uint8Array; + encryptedData: Uint8Array; armoredSignature: string; - verificationToken: Uint8Array; + verificationToken: Uint8Array; }; export type EncryptedThumbnail = EncryptedBlockMetadata & { type: ThumbnailType; - encryptedData: Uint8Array; + encryptedData: Uint8Array; }; export type UploadTokens = { @@ -101,7 +101,7 @@ export interface NodesService { key: PrivateKey; passphraseSessionKey: SessionKey; contentKeyPacketSessionKey?: SessionKey; - hashKey?: Uint8Array; + hashKey?: Uint8Array; }>; getNodeSigningKeys( uids: { nodeUid: string; parentNodeUid?: string } | { nodeUid?: string; parentNodeUid: string }, diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index d21ebc7a..17dee253 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -73,7 +73,7 @@ export class UploadManager { private async createDraftOnAPI( parentFolderUid: string, - parentHashKey: Uint8Array, + parentHashKey: Uint8Array, name: string, metadata: UploadMetadata, generatedNodeCrypto: NodeCrypto, @@ -226,7 +226,7 @@ export class UploadManager { async commitDraft( nodeRevisionDraft: NodeRevisionDraft, - manifest: Uint8Array, + manifest: Uint8Array, extendedAttributes: { modificationTime?: Date; size: number; diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index ee912440..28831513 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -655,7 +655,7 @@ export class StreamUploader { return uploadedBlocks.map((block) => block.originalSize); } - protected get manifest(): Uint8Array { + protected get manifest(): Uint8Array { this.uploadedThumbnails.sort((a, b) => a.type - b.type); this.uploadedBlocks.sort((a, b) => a.index - b.index); const hashes = [ From 3b40c27b878d647656d3e193535e04e96e923218 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 09:33:26 +0100 Subject: [PATCH 521/791] Fix after rebase --- js/sdk/src/internal/photos/albumsCrypto.ts | 4 ++-- js/sdk/src/internal/sharingPublic/nodes.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/internal/photos/albumsCrypto.ts b/js/sdk/src/internal/photos/albumsCrypto.ts index 05df5ab8..23ceaafe 100644 --- a/js/sdk/src/internal/photos/albumsCrypto.ts +++ b/js/sdk/src/internal/photos/albumsCrypto.ts @@ -13,7 +13,7 @@ export class AlbumsCryptoService { } async createAlbum( - parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, signingKeys: NodeSigningKeys, name: string, ): Promise<{ @@ -62,7 +62,7 @@ export class AlbumsCryptoService { } async renameAlbum( - parentKeys: { key: PrivateKey; hashKey?: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey?: Uint8Array }, encryptedName: string, signingKeys: NodeSigningKeys, newName: string, diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index 0f04e6aa..14ca82fe 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -18,7 +18,7 @@ import { SharingPublicSharesManager } from './shares'; export class SharingPublicNodesCryptoService extends NodesCryptoService { async generateDocument( - parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, signingKeys: NodeSigningKeys, name: string, ) { From 1aef7ff6753fd5e89d523a7d20a827513a934b7b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 13:33:50 +0100 Subject: [PATCH 522/791] Fix deserialization of DegradedNode --- cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs index a853bf12..bfdb7761 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs @@ -1,7 +1,12 @@ -using Proton.Sdk; +using System.Text.Json.Serialization; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(DegradedFolderNode), typeDiscriminator: "folder")] +[JsonDerivedType(typeof(DegradedFileNode), typeDiscriminator: "file")] +[JsonDerivedType(typeof(DegradedPhotoNode), typeDiscriminator: "photo")] public abstract record DegradedNode { public required NodeUid Uid { get; init; } From e39bdb1ef25c09f0723508bae2e4c3fcca371848 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 15:24:09 +0100 Subject: [PATCH 523/791] Add method to get device --- js/sdk/src/internal/devices/manager.ts | 14 ++++++++++++++ js/sdk/src/protonDriveClient.ts | 18 +++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/internal/devices/manager.ts b/js/sdk/src/internal/devices/manager.ts index b531904a..2845930e 100644 --- a/js/sdk/src/internal/devices/manager.ts +++ b/js/sdk/src/internal/devices/manager.ts @@ -23,6 +23,20 @@ export class DevicesManager { this.nodesManagementService = nodesManagementService; } + async getDevice(deviceUid: string): Promise { + const device = await this.getDeviceMetadata(deviceUid); + + const [node] = await Array.fromAsync(this.nodesService.iterateNodes([device.rootFolderUid])); + if (!node || 'missingUid' in node) { + throw new ValidationError(c('Error').t`Device not found`); + } + + return { + ...device, + name: node.name, + }; + } + async *iterateDevices(signal?: AbortSignal): AsyncGenerator { const devices = await this.apiService.getDevices(signal); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 5f7c24a8..dde9c588 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -223,12 +223,12 @@ export class ProtonDriveClient { return keys.contentKeyPacketSessionKey; }, getPublicLinkInfo: async (url: string) => { - const { token } = getTokenAndPasswordFromUrl(url) + const { token } = getTokenAndPasswordFromUrl(url); this.logger.info(`Getting info for public link token ${token}`); return this.publicSessionManager.getInfo(token); }, authPublicLink: async (url: string, customPassword?: string, isAnonymousContext: boolean = false) => { - const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url) + const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url); this.logger.info(`Authenticating public link token ${token}`); const { httpClient, shareKey, rootUid, publicRole } = await this.publicSessionManager.auth( @@ -681,7 +681,7 @@ export class ProtonDriveClient { * @param customPassword - The optional custom password. */ async createBookmark(url: string, customPassword?: string): Promise { - const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url) + const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url); this.logger.info(`Creating bookmark for token ${token}`); await this.sharing.access.createBookmark(token, urlPassword, customPassword); } @@ -912,6 +912,18 @@ export class ProtonDriveClient { yield* this.devices.iterateDevices(signal); } + /** + * Get the device entity by its UID. + * + * @param deviceOrUid - Device entity or its UID string. + * @returns The device entity. + * @throws {@link ValidationError} If the device is not found. + */ + async getDevice(deviceOrUid: DeviceOrUid): Promise { + this.logger.info(`Getting device ${getUid(deviceOrUid)}`); + return this.devices.getDevice(getUid(deviceOrUid)); + } + /** * Creates a new device. * From 325b3ecdedd149b5a55f11e2b61b319188a711b3 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 13:39:23 +0100 Subject: [PATCH 524/791] Stop R8 from obfuscating cancel methods called be JNI --- kt/sdk/proguard-rules.pro | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kt/sdk/proguard-rules.pro b/kt/sdk/proguard-rules.pro index 2d86e78d..77ef212e 100644 --- a/kt/sdk/proguard-rules.pro +++ b/kt/sdk/proguard-rules.pro @@ -2,3 +2,9 @@ -dontwarn com.google.protobuf.** -keep class proton.sdk.** { *; } -keep class proton.drive.sdk.** { *; } + +# Keep Job signatures required by native code in job.c +-keep class kotlinx.coroutines.JobCancellationException +-keepclassmembers class kotlinx.coroutines.** { + void cancel(...); +} From 0db3656c9a1e86f521d5ce007bb97b29f4d2686e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 18:28:17 +0100 Subject: [PATCH 525/791] Expose errorToString --- .../proton/drive/sdk/ProtonDriveSdkException.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt index 5555c26c..7f0dc4ff 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt @@ -5,11 +5,16 @@ class ProtonDriveSdkException( override val cause: Throwable? = null, val error: ProtonSdkError? = null ) : Throwable(message, cause) { - override fun toString(): String { - return buildString { - appendLine(super.toString()) - appendError(error) - } + override fun toString(): String = buildString { + appendLine(super.toString()) + appendError(error) + } +} + +fun ProtonDriveSdkException.errorToString(): String = buildString { + error?.let { error -> + appendLine("SDK error: ${error.message}") + appendError(error) } } From 0a165d70d76ec445f958d4c2e74b574bcf77e8af Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 07:43:21 +0000 Subject: [PATCH 526/791] Add capability to add photos to albums --- js/sdk/src/interface/index.ts | 1 + js/sdk/src/interface/nodes.ts | 1 + js/sdk/src/internal/nodes/nodesManagement.ts | 1 - js/sdk/src/internal/photos/addToAlbum.test.ts | 515 ++++++++++++++++++ js/sdk/src/internal/photos/addToAlbum.ts | 341 ++++++++++++ js/sdk/src/internal/photos/albums.test.ts | 43 +- js/sdk/src/internal/photos/albums.ts | 33 +- js/sdk/src/internal/photos/albumsCrypto.ts | 53 +- js/sdk/src/internal/photos/apiService.test.ts | 233 ++++++++ js/sdk/src/internal/photos/apiService.ts | 199 ++++++- js/sdk/src/internal/photos/errors.ts | 11 + js/sdk/src/internal/photos/index.ts | 2 +- js/sdk/src/internal/photos/interface.ts | 16 +- js/sdk/src/internal/photos/nodes.ts | 6 +- js/sdk/src/internal/sharing/apiService.ts | 27 + js/sdk/src/protonDrivePhotosClient.ts | 54 +- 16 files changed, 1463 insertions(+), 73 deletions(-) create mode 100644 js/sdk/src/internal/photos/addToAlbum.test.ts create mode 100644 js/sdk/src/internal/photos/addToAlbum.ts create mode 100644 js/sdk/src/internal/photos/apiService.test.ts create mode 100644 js/sdk/src/internal/photos/errors.ts diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index d65e659a..0146f869 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -43,6 +43,7 @@ export type { NodeOrUid, RevisionOrUid, NodeResult, + NodeResultWithError, NodeResultWithNewUid, Membership, } from './nodes'; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index c68e57ce..a96585d0 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -229,4 +229,5 @@ export type RevisionOrUid = Revision | string; // TODO: Remove string from the result and use Error instead to be compatible with the NodeResultWithNewUid. export type NodeResult = { uid: string; ok: true } | { uid: string; ok: false; error: string }; +export type NodeResultWithError = { uid: string; ok: true } | { uid: string; ok: false; error: Error }; export type NodeResultWithNewUid = { uid: string; newUid: string; ok: true } | { uid: string; ok: false; error: Error }; diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 065d4285..9bc3f2f1 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -180,7 +180,6 @@ export abstract class NodesManagementBase< encryptedName: encryptedCrypto.encryptedName, nameSignatureEmail: encryptedCrypto.nameSignatureEmail, hash: encryptedCrypto.hash, - // TODO: When moving photos, we need to pass content hash. }, ); const newNode: TDecryptedNode = { diff --git a/js/sdk/src/internal/photos/addToAlbum.test.ts b/js/sdk/src/internal/photos/addToAlbum.test.ts new file mode 100644 index 00000000..c3786a43 --- /dev/null +++ b/js/sdk/src/internal/photos/addToAlbum.test.ts @@ -0,0 +1,515 @@ +import { NodeResultWithError } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { AddToAlbumProcess } from './addToAlbum'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { PhotosAPIService } from './apiService'; +import { MissingRelatedPhotosError } from './errors'; +import { DecryptedPhotoNode } from './interface'; +import { PhotosNodesAccess } from './nodes'; + +/** + * Helper to create a mock photo node with minimal required properties. + */ +function createMockPhotoNode( + uid: string, + overrides: Partial = {}, +): DecryptedPhotoNode { + return { + uid, + parentUid: 'volume1~parent', + hash: 'hash', + photo: { + captureTime: new Date(), + mainPhotoNodeUid: undefined, + relatedPhotoNodeUids: [], + tags: [], + albums: [], + }, + activeRevision: { + ok: true, + value: { + uid: 'rev1', + state: 'active' as const, + creationTime: new Date(), + storageSize: 100, + signatureEmail: 'test@example.com', + claimedModificationTime: new Date(), + claimedSize: 100, + claimedDigests: { sha1: 'sha1hash' }, + claimedBlockSizes: [100], + }, + }, + keyAuthor: { ok: true, value: 'test@example.com' }, + ...overrides, + } as DecryptedPhotoNode; +} + +describe('AddToAlbumProcess', () => { + let apiService: jest.Mocked; + let cryptoService: jest.Mocked; + let nodesService: jest.Mocked; + let albumKeys: { key: unknown; hashKey: Uint8Array; passphrase: string; passphraseSessionKey: unknown }; + let signingKeys: { type: 'userAddress'; email: string; addressId: string; key: unknown }; + + beforeEach(() => { + albumKeys = { + key: 'albumKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + passphrase: 'passphrase', + passphraseSessionKey: 'passphraseSessionKey' as any, + }; + + signingKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'signingKey' as any, + }; + + // @ts-expect-error Mocking for testing purposes + apiService = { + addPhotosToAlbum: jest.fn(), + copyPhotoToAlbum: jest.fn(), + }; + + // @ts-expect-error Mocking for testing purposes + cryptoService = { + encryptPhotoForAlbum: jest.fn(), + }; + + // @ts-expect-error Mocking for testing purposes + nodesService = { + iterateNodes: jest.fn(), + getNodePrivateAndSessionKeys: jest.fn(), + notifyNodeChanged: jest.fn(), + notifyChildCreated: jest.fn(), + }; + }); + + function executeProcess(photoUids: string[]): Promise { + const process = new AddToAlbumProcess( + 'volume1~album', + albumKeys as any, + signingKeys as any, + apiService, + cryptoService, + nodesService, + getMockLogger(), + ); + return Array.fromAsync(process.execute(photoUids)); + } + + beforeEach(() => { + nodesService.iterateNodes.mockImplementation(async function* (uids) { + for (const uid of uids) { + const photoNode = createMockPhotoNode(uid); + + // Handle uids in the form 'volumeId~mainPhoto-related:X' where X is the number of related photos + const relatedMatch = /^(.+)~(.+)-related:(\d+)$/.exec(uid); + if (relatedMatch) { + const [, volumeId, mainPhoto, countStr] = relatedMatch; + const count = parseInt(countStr, 10); + photoNode.photo!.relatedPhotoNodeUids = Array.from({ length: count }, (_, idx) => `${volumeId}~related${idx + 1}`); + } + + yield photoNode; + } + }); + + nodesService.getNodePrivateAndSessionKeys.mockResolvedValue({ + key: 'nodeKey' as any, + nameSessionKey: 'sessionKey' as any, + passphrase: 'passphrase', + passphraseSessionKey: 'passphraseSessionKey' as any, + }); + + cryptoService.encryptPhotoForAlbum.mockResolvedValue({ + contentHash: 'contentHash', + hash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + armoredNodePassphrase: 'passphrase', + armoredNodePassphraseSignature: 'signature', + signatureEmail: 'test@example.com', + }); + + let addToAlbumReturnedMissing = false; + apiService.addPhotosToAlbum.mockImplementation(async function* (albumUid, payloads) { + for (const payload of payloads) { + let error: Error | undefined; + if (payload.nodeUid.includes('missingRelatedTwice')) { + error = new MissingRelatedPhotosError(['volume1~missingRelatedTwice1']); + addToAlbumReturnedMissing = true; + } + if (!addToAlbumReturnedMissing && payload.nodeUid.includes('missingRelatedOnce')) { + error = new MissingRelatedPhotosError(['volume1~missingRelatedOnce1']); + addToAlbumReturnedMissing = true; + } + if (error) { + yield { uid: payload.nodeUid, ok: false, error }; + } else { + yield { uid: payload.nodeUid, ok: true }; + } + } + }); + + let copyToAlbumReturnedMissing = false; + apiService.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => { + let error: Error | undefined; + if (payload.nodeUid.includes('missingRelatedTwice')) { + error = new MissingRelatedPhotosError(['volume2~missingRelatedTwice1']); + copyToAlbumReturnedMissing = true; + } + if (!copyToAlbumReturnedMissing && payload.nodeUid.includes('missingRelatedOnce')) { + error = new MissingRelatedPhotosError(['volume2~missingRelatedOnce1']); + copyToAlbumReturnedMissing = true; + } + if (error) { + throw error; + } + return `volume1~copied${payload.nodeUid}`; + }); + }) + + describe('Adding photos to the same volume', () => { + it('should prepare photo payloads in parallel without blocking', async () => { + // Setup: 25 photos (more than BATCH_LOADING_SIZE of 20) + const photoUids = Array.from({ length: 25 }, (_, i) => `volume1~photo${i}`); + + let addPhotosCallCount = 0; + apiService.addPhotosToAlbum.mockImplementation(async function* (albumUid, payloads) { + addPhotosCallCount++; + + // First call should happen before all 25 photos are prepared + // (should only have first batch of 20 prepared) + if (addPhotosCallCount === 1) { + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(1); + } + + for (const payload of payloads) { + yield { uid: payload.nodeUid, ok: true }; + } + }); + + const results = await executeProcess(photoUids); + + expect(results).toHaveLength(25); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); + expect(nodesService.iterateNodes.mock.calls[0][0]).toHaveLength(20); + expect(nodesService.iterateNodes.mock.calls[1][0]).toHaveLength(5); + expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(3); + expect(apiService.addPhotosToAlbum.mock.calls[0][1].length).toBe(10); + expect(apiService.addPhotosToAlbum.mock.calls[1][1].length).toBe(10); + expect(apiService.addPhotosToAlbum.mock.calls[2][1].length).toBe(5); + }); + + it('should include related photos in the same batch even if it exceeds batch size', async () => { + // Create a photo with 15 related photos (total size = 16, which exceeds batch size of 10) + const mainPhotoUid = 'volume1~mainPhoto-related:15'; + + const results = await executeProcess([mainPhotoUid]); + + expect(results).toMatchObject([{ + uid: mainPhotoUid, + ok: true, + }]) + + expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(1); + const params = apiService.addPhotosToAlbum.mock.calls[0]; + expect(params[1].length).toBe(1); + expect(params[1][0].relatedPhotos?.length).toBe(15); + }); + + it('should re-queue photo when missing related photos error occurs', async () => { + const photoUid = 'volume1~mainPhoto-related:1-missingRelatedOnce'; + + const process = new AddToAlbumProcess( + 'volume1~album', + albumKeys as any, + signingKeys as any, + apiService, + cryptoService, + nodesService, + getMockLogger(), + ); + const results = await Array.fromAsync(process.execute([photoUid])); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: true, + }]) + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo + expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts + }); + + it('should return error if missing related photos error occurs twice', async () => { + const photoUid = 'volume1~photo1-missingRelatedTwice'; + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: false, + error: new MissingRelatedPhotosError(['volume1~missingRelatedOnce1']), + }]) + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo + expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts + }); + + it('should return error when crypto service fails', async () => { + const photoUid = 'volume1~photo1'; + + const cryptoError = new Error('Crypto operation failed'); + cryptoService.encryptPhotoForAlbum.mockRejectedValue(cryptoError); + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: false, + error: cryptoError, + }]) + }); + + it('should return error when getNodePrivateAndSessionKeys fails', async () => { + const photoUid = 'volume1~photo1'; + + const keysError = new Error('Failed to get keys'); + nodesService.getNodePrivateAndSessionKeys.mockRejectedValue(keysError); + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: false, + error: keysError, + }]) + }); + + it('should notify node changed for successfully added photos', async () => { + const photoUid = 'volume1~photo1'; + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: true, + }]) + expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(1); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(photoUid); + }); + + it('should not notify node changed for failed photos', async () => { + const photoUid = 'volume1~photo1'; + + apiService.addPhotosToAlbum.mockImplementation(async function* (albumUid, payloads) { + yield { uid: photoUid, ok: false, error: new Error('API error') }; + }); + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: false, + error: new Error('API error'), + }]) + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); + }); + }); + + describe('Adding photos to a different volume', () => { + it('should prepare photo payloads in parallel without blocking', async () => { + // Setup: 25 photos from different volume (more than BATCH_LOADING_SIZE of 20) + const photoUids = Array.from({ length: 25 }, (_, i) => `volume2~photo${i}`); + + let copyPhotoCallCount = 0; + apiService.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => { + copyPhotoCallCount++; + + // First few calls should happen before all 25 photos are prepared + if (copyPhotoCallCount <= 20) { + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(1); + } + + return `volume1~copied${copyPhotoCallCount}`; + }); + + const results = await executeProcess(photoUids); + + expect(results).toHaveLength(25); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); + expect(nodesService.iterateNodes.mock.calls[0][0]).toHaveLength(20); + expect(nodesService.iterateNodes.mock.calls[1][0]).toHaveLength(5); + expect(copyPhotoCallCount).toBe(25); + }); + + it('should include related photos in copy request', async () => { + const mainPhotoUid = 'volume2~mainPhoto-related:15'; + + const results = await executeProcess([mainPhotoUid]); + + expect(results).toMatchObject([{ + uid: mainPhotoUid, + ok: true, + }]) + expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(1); + const params = apiService.copyPhotoToAlbum.mock.calls[0]; + expect(params[1].relatedPhotos?.length).toBe(15); + }); + + it('should re-queue photo when missing related photos error occurs', async () => { + const photoUid = 'volume2~photo1-related:1-missingRelatedOnce'; + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: true, + }]); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo + expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts + }); + + it('should return error if missing related photos error occurs twice', async () => { + const photoUid = 'volume2~photo1-missingRelatedTwice'; + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: false, + error: new MissingRelatedPhotosError(['volume2~missingRelatedOnce1']), + }]); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo + expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts + }); + + it('should return error when crypto service fails', async () => { + const photoUid = 'volume2~photo1'; + + const cryptoError = new Error('Crypto operation failed'); + cryptoService.encryptPhotoForAlbum.mockRejectedValue(cryptoError); + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: false, + error: cryptoError, + }]); + }); + + it('should return error when getNodePrivateAndSessionKeys fails', async () => { + const photoUid = 'volume2~photo1'; + + const keysError = new Error('Failed to get keys'); + nodesService.getNodePrivateAndSessionKeys.mockRejectedValue(keysError); + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: false, + error: keysError, + }]); + }); + + it('should notify child created for successfully copied photos', async () => { + const photoUid = 'volume2~photo1'; + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: true, + }]) + expect(nodesService.notifyChildCreated).toHaveBeenCalledTimes(1); + expect(nodesService.notifyChildCreated.mock.calls[0][0]).toContain('volume1~copied'); + }); + + it('should not notify for failed photo copies', async () => { + const photoUid = 'volume2~photo1'; + + apiService.copyPhotoToAlbum.mockRejectedValue(new Error('API error')); + + const results = await executeProcess([photoUid]); + + expect(results).toMatchObject([{ + uid: photoUid, + ok: false, + error: new Error('API error'), + }]) + expect(nodesService.notifyChildCreated).not.toHaveBeenCalled(); + }); + }); + + describe('Adding photos from both same and different volumes', () => { + it('should process same volume photos first, then different volume photos', async () => { + const sameVolumeUids = ['volume1~photo1', 'volume1~photo2']; + const differentVolumeUids = ['volume2~photo3', 'volume2~photo4']; + const allUids = [...sameVolumeUids, ...differentVolumeUids]; + + const results = await executeProcess(allUids); + + expect(results).toMatchObject([{ + uid: sameVolumeUids[0], + ok: true, + }, { + uid: sameVolumeUids[1], + ok: true, + }, { + uid: differentVolumeUids[0], + ok: true, + }, { + uid: differentVolumeUids[1], + ok: true, + }]); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); + expect(nodesService.iterateNodes.mock.calls[0][0]).toMatchObject(sameVolumeUids); + expect(nodesService.iterateNodes.mock.calls[1][0]).toMatchObject(differentVolumeUids); + expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(1); + expect(apiService.addPhotosToAlbum.mock.calls[0][1].map(({ nodeUid }) => nodeUid)).toMatchObject(sameVolumeUids); + expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); + expect(apiService.copyPhotoToAlbum.mock.calls[0][1].nodeUid).toBe(differentVolumeUids[0]); + expect(apiService.copyPhotoToAlbum.mock.calls[1][1].nodeUid).toBe(differentVolumeUids[1]); + }); + + it('should prepare payloads in parallel for both queues', async () => { + // 25 photos from same volume, 25 from different volume + const sameVolumeUids = Array.from({ length: 25 }, (_, i) => `volume1~photo${i}`); + const differentVolumeUids = Array.from({ length: 25 }, (_, i) => `volume2~photo${i}`); + const allUids = [...sameVolumeUids, ...differentVolumeUids]; + + const results = await executeProcess(allUids); + + expect(results).toHaveLength(50); + // Each volume should have been loaded in 2 batches (20 + 5) + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2 + 2); + }); + + it('should handle retries correctly for both volumes', async () => { + const sameVolumeUid = 'volume1~photo1-related:1-missingRelatedOnce'; + const differentVolumeUid = 'volume2~photo2-related:1-missingRelatedOnce'; + + const results = await executeProcess([sameVolumeUid, differentVolumeUid]); + + expect(results).toHaveLength(2); + expect(results[0].ok).toBe(true); + expect(results[1].ok).toBe(true); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3 + 3); // main photo + related photo + missing related photo + expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts + expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts + }); + + it('should notify correctly for both volumes', async () => { + const sameVolumeUid = 'volume1~photo1'; + const differentVolumeUid = 'volume2~photo2'; + + const results = await executeProcess([sameVolumeUid, differentVolumeUid]); + + expect(results).toHaveLength(2); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(1); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(sameVolumeUid); + expect(nodesService.notifyChildCreated).toHaveBeenCalledTimes(1); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('volume1~copiedvolume2~photo2'); + }); + }); +}); diff --git a/js/sdk/src/internal/photos/addToAlbum.ts b/js/sdk/src/internal/photos/addToAlbum.ts new file mode 100644 index 00000000..feaad773 --- /dev/null +++ b/js/sdk/src/internal/photos/addToAlbum.ts @@ -0,0 +1,341 @@ +import { c } from 'ttag'; + +import { ValidationError } from '../../errors'; +import { Logger, NodeResultWithError } from '../../interface'; +import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface'; +import { splitNodeUid } from '../uids'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { PhotosAPIService } from './apiService'; +import { MissingRelatedPhotosError } from './errors'; +import { AddToAlbumEncryptedPhotoPayload, DecryptedPhotoNode } from './interface'; +import { PhotosNodesAccess } from './nodes'; + +/** + * The number of photos that are loaded in parallel to prepare the payloads. + */ +const BATCH_LOADING_SIZE = 20; + +/** + * The maximum number of photos that can be added to an album in a single + * request. The size includes the photo itself and its related photos. + */ +const ADD_PHOTOS_BATCH_SIZE = 10; + +/** + * Item in the processing queue representing a photo to add to an album. + */ +type PhotoQueueItem = { + photoNodeUid: string; + /** + * When retrying after a MissingRelatedPhotosError, these contain the + * node UIDs reported as missing by the server that need to be included + * as additional related photos. + */ + additionalRelatedPhotoNodeUids: string[]; +}; + +/** + * Manages the process of adding photos to an album. + * + * Photos are split into two queues based on volume: + * - Same volume: added in batches via the add-multiple endpoint. + * - Different volume: copied individually via the copy endpoint. + * + * Both paths handle MissingRelatedPhotosError by re-queuing the failed + * photo with updated related photo UIDs for one retry attempt. + */ +export class AddToAlbumProcess { + private readonly albumVolumeId: string; + private readonly retriedPhotoUids = new Set(); + + constructor( + private readonly albumNodeUid: string, + private readonly albumKeys: DecryptedNodeKeys, + private readonly signingKeys: NodeSigningKeys, + private readonly apiService: PhotosAPIService, + private readonly cryptoService: AlbumsCryptoService, + private readonly nodesService: PhotosNodesAccess, + private readonly logger: Logger, + private readonly signal?: AbortSignal, + ) { + this.albumVolumeId = splitNodeUid(albumNodeUid).volumeId; + } + + async *execute(photoNodeUids: string[]): AsyncGenerator { + const { sameVolumeQueue, differentVolumeQueue } = splitByVolume(photoNodeUids, this.albumVolumeId); + + yield* this.processSameVolumeQueue(sameVolumeQueue); + yield* this.processDifferentVolumeQueue(differentVolumeQueue); + } + + private async *processSameVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator { + while (queue.length > 0) { + const items = queue.splice(0, BATCH_LOADING_SIZE); + const { payloads, errors } = await this.preparePhotoPayloads(items); + + for (const [uid, error] of errors) { + yield { uid, ok: false, error }; + } + + for (const batch of createBatches(payloads)) { + for await (const result of this.apiService.addPhotosToAlbum(this.albumNodeUid, batch, this.signal)) { + const retryItem = this.handleMissingRelatedPhotosError(result); + if (retryItem) { + queue.push(retryItem); + continue; + } + + if (result.ok) { + await this.nodesService.notifyNodeChanged(result.uid); + } + yield result; + } + } + } + } + + private async *processDifferentVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator { + while (queue.length > 0) { + const items = queue.splice(0, BATCH_LOADING_SIZE); + const { payloads, errors } = await this.preparePhotoPayloads(items); + + for (const [uid, error] of errors) { + yield { uid, ok: false, error }; + } + + for (const payload of payloads) { + try { + const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum(this.albumNodeUid, payload, this.signal); + await this.nodesService.notifyChildCreated(newPhotoNodeUid); + yield { uid: payload.nodeUid, ok: true }; + } catch (error) { + if (error instanceof MissingRelatedPhotosError) { + const retryItem = this.createRetryQueueItem(payload.nodeUid, error); + if (retryItem) { + queue.push(retryItem); + continue; + } + } + yield { + uid: payload.nodeUid, + ok: false, + error: error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }), + }; + } + } + } + } + + private async preparePhotoPayloads(items: PhotoQueueItem[]): Promise<{ + payloads: AddToAlbumEncryptedPhotoPayload[]; + errors: Map; + }> { + const payloads: AddToAlbumEncryptedPhotoPayload[] = []; + const errors = new Map(); + + const additionalRelatedMap = new Map( + items.map((item) => [item.photoNodeUid, item.additionalRelatedPhotoNodeUids]), + ); + + const nodeUids = items.map((item) => item.photoNodeUid); + for await (const photoNode of this.nodesService.iterateNodes(nodeUids, this.signal)) { + if ('missingUid' in photoNode) { + errors.set(photoNode.missingUid, new ValidationError(c('Error').t`Photo not found`)); + continue; + } + + try { + const additionalRelated = additionalRelatedMap.get(photoNode.uid) || []; + const payload = await this.preparePhotoPayload(photoNode, additionalRelated); + payloads.push(payload); + } catch (error) { + errors.set( + photoNode.uid, + error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }), + ); + } + } + + return { payloads, errors }; + } + + private async preparePhotoPayload( + photoNode: DecryptedPhotoNode, + additionalRelatedPhotoNodeUids: string[], + ): Promise { + const photoData = await this.encryptPhotoForAlbum(photoNode); + + const relatedNodeUids = [...new Set([ + ...(photoNode.photo?.relatedPhotoNodeUids || []), + ...additionalRelatedPhotoNodeUids, + ])]; + + const relatedPhotos = + relatedNodeUids.length > 0 ? await this.prepareRelatedPhotoPayloads(relatedNodeUids) : []; + + return { + ...photoData, + relatedPhotos, + }; + } + + private async prepareRelatedPhotoPayloads( + nodeUids: string[], + ): Promise[]> { + const payloads: Omit[] = []; + + for await (const photoNode of this.nodesService.iterateNodes(nodeUids, this.signal)) { + // Missing related photos means that the related photo was deleted + // since the loading of the metadata. It can happen and should be + // ignored. The backend controls all the related photos are part + // of the request, thus the request will fail and be retried if + // there is any other race condition. + if ('missingUid' in photoNode) { + continue; + } + const payload = await this.encryptPhotoForAlbum(photoNode); + payloads.push(payload); + } + + return payloads; + } + + private async encryptPhotoForAlbum( + photoNode: DecryptedPhotoNode, + ): Promise { + const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(photoNode.uid); + + const contentSha1 = photoNode.activeRevision?.ok + ? photoNode.activeRevision.value.claimedDigests?.sha1 + : undefined; + + if (!contentSha1) { + throw new Error('Cannot add photo to album without a content hash'); + } + + const encryptedCrypto = await this.cryptoService.encryptPhotoForAlbum( + photoNode.name, + contentSha1, + nodeKeys, + { key: this.albumKeys.key, hashKey: this.albumKeys.hashKey! }, + this.signingKeys, + ); + + // Node could be uploaded or renamed by anonymous user and thus have + // missing signatures that must be added to the request. + // Node passphrase and signature email must be passed if and only if + // the signatures are missing (key author is null). + const anonymousKey = photoNode.keyAuthor.ok && photoNode.keyAuthor.value === null; + const keySignatureProperties = !anonymousKey + ? {} + : { + signatureEmail: encryptedCrypto.signatureEmail, + nodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + }; + + return { + nodeUid: photoNode.uid, + contentHash: encryptedCrypto.contentHash, + nameHash: encryptedCrypto.hash, + encryptedName: encryptedCrypto.encryptedName, + nameSignatureEmail: encryptedCrypto.nameSignatureEmail, + nodePassphrase: encryptedCrypto.armoredNodePassphrase, + ...keySignatureProperties, + }; + } + + /** + * If the result indicates a MissingRelatedPhotosError that hasn't + * been retried, returns a retry queue item. Otherwise returns undefined. + */ + private handleMissingRelatedPhotosError(result: NodeResultWithError): PhotoQueueItem | undefined { + if (!result.ok && result.error instanceof MissingRelatedPhotosError) { + return this.createRetryQueueItem(result.uid, result.error); + } + return undefined; + } + + /** + * Creates a retry queue item with the missing related photo UIDs. + * Returns undefined if the photo has already been retried, preventing + * infinite retry loops. + */ + private createRetryQueueItem( + photoNodeUid: string, + error: MissingRelatedPhotosError, + ): PhotoQueueItem | undefined { + if (this.retriedPhotoUids.has(photoNodeUid)) { + this.logger.warn(`Missing related photos for ${photoNodeUid}, already retried`); + return undefined; + } + + this.retriedPhotoUids.add(photoNodeUid); + this.logger.info( + `Missing related photos for ${photoNodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`, + ); + + return { + photoNodeUid, + additionalRelatedPhotoNodeUids: error.missingNodeUids, + }; + } +} + +/** + * Splits photo UIDs into same-volume and different-volume queues + * based on the album's volume ID. + */ +function splitByVolume( + photoNodeUids: string[], + albumVolumeId: string, +): { + sameVolumeQueue: PhotoQueueItem[]; + differentVolumeQueue: PhotoQueueItem[]; +} { + const sameVolumeQueue: PhotoQueueItem[] = []; + const differentVolumeQueue: PhotoQueueItem[] = []; + + for (const photoNodeUid of photoNodeUids) { + const { volumeId } = splitNodeUid(photoNodeUid); + const item: PhotoQueueItem = { + photoNodeUid, + additionalRelatedPhotoNodeUids: [], + }; + + if (volumeId === albumVolumeId) { + sameVolumeQueue.push(item); + } else { + differentVolumeQueue.push(item); + } + } + + return { sameVolumeQueue, differentVolumeQueue }; +} + +/** + * Groups payloads into batches respecting the API limit. + * Each payload's size counts itself plus its related photos. + */ +function* createBatches( + payloads: AddToAlbumEncryptedPhotoPayload[], +): Generator { + let batch: AddToAlbumEncryptedPhotoPayload[] = []; + let batchSize = 0; + + for (const payload of payloads) { + const payloadSize = 1 + (payload.relatedPhotos?.length || 0); + + if (batch.length > 0 && batchSize + payloadSize > ADD_PHOTOS_BATCH_SIZE) { + yield batch; + batch = []; + batchSize = 0; + } + + batch.push(payload); + batchSize += payloadSize; + } + + if (batch.length > 0) { + yield batch; + } +} diff --git a/js/sdk/src/internal/photos/albums.test.ts b/js/sdk/src/internal/photos/albums.test.ts index 362dcbc0..7c9aa6d9 100644 --- a/js/sdk/src/internal/photos/albums.test.ts +++ b/js/sdk/src/internal/photos/albums.test.ts @@ -1,5 +1,6 @@ -import { NodeType, MemberRole } from '../../interface'; +import { NodeType } from '../../interface'; import { ValidationError } from '../../errors'; +import { getMockTelemetry } from '../../tests/telemetry'; import { Albums } from './albums'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; @@ -96,7 +97,7 @@ describe('Albums', () => { notifyChildCreated: jest.fn(), }; - albums = new Albums(apiService, cryptoService, photoShares, nodesService); + albums = new Albums(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService); }); describe('createAlbum', () => { @@ -172,16 +173,12 @@ describe('Albums', () => { { type: 'userAddress', email: 'user@example.com', addressId: 'addressId', key: 'addressKey' }, 'new album name', ); - expect(apiService.updateAlbum).toHaveBeenCalledWith( - 'albumNodeUid', - undefined, - { - encryptedName: 'newArmoredAlbumName', - hash: 'newHash', - originalHash: 'albumHash', - nameSignatureEmail: 'newSignatureEmail', - }, - ); + expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', undefined, { + encryptedName: 'newArmoredAlbumName', + hash: 'newHash', + originalHash: 'albumHash', + nameSignatureEmail: 'newSignatureEmail', + }); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid'); }); @@ -207,16 +204,12 @@ describe('Albums', () => { nameAuthor: { ok: true, value: 'newSignatureEmail' }, hash: 'newHash', }); - expect(apiService.updateAlbum).toHaveBeenCalledWith( - 'albumNodeUid', - 'photoNodeUid', - { - encryptedName: 'newArmoredAlbumName', - hash: 'newHash', - originalHash: 'albumHash', - nameSignatureEmail: 'newSignatureEmail', - }, - ); + expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', 'photoNodeUid', { + encryptedName: 'newArmoredAlbumName', + hash: 'newHash', + originalHash: 'albumHash', + nameSignatureEmail: 'newSignatureEmail', + }); }); it('throws validation error for invalid album name', async () => { @@ -258,7 +251,11 @@ describe('Albums', () => { { uid: 'photo2', ok: false, error: 'Some error' }, { uid: 'photo3', ok: true }, ]); - expect(apiService.removePhotosFromAlbum).toHaveBeenCalledWith('albumNodeUid', ['photo1', 'photo2', 'photo3'], undefined); + expect(apiService.removePhotosFromAlbum).toHaveBeenCalledWith( + 'albumNodeUid', + ['photo1', 'photo2', 'photo3'], + undefined, + ); expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo1'); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo3'); diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albums.ts index 4ac021a3..6b518b94 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albums.ts @@ -1,9 +1,10 @@ -import { MemberRole, NodeResult, NodeType, resultOk } from '../../interface'; +import { Logger, MemberRole, NodeResultWithError, NodeType, ProtonDriveTelemetry, resultOk } from '../../interface'; import { BatchLoading } from '../batchLoading'; import { DecryptedNode } from '../nodes'; import { ALBUM_MEDIA_TYPE } from '../nodes/mediaTypes'; import { validateNodeName } from '../nodes/validations'; import { splitNodeUid } from '../uids'; +import { AddToAlbumProcess } from './addToAlbum'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; import { AlbumItem, DecryptedPhotoNode } from './interface'; @@ -16,12 +17,16 @@ const BATCH_LOADING_SIZE = 10; * Provides access and high-level actions for managing albums. */ export class Albums { + private logger: Logger; + constructor( + telemetry: ProtonDriveTelemetry, private apiService: PhotosAPIService, private cryptoService: AlbumsCryptoService, private photoShares: PhotoSharesManager, private nodesService: PhotosNodesAccess, ) { + this.logger = telemetry.getLogger('albums'); this.apiService = apiService; this.cryptoService = cryptoService; this.photoShares = photoShares; @@ -156,11 +161,35 @@ export class Albums { await this.nodesService.notifyNodeDeleted(nodeUid); } + async *addPhotos( + albumNodeUid: string, + photoNodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { + const albumKeys = await this.nodesService.getNodeKeys(albumNodeUid); + if (!albumKeys.hashKey) { + throw new Error('Cannot add photos to album: album hash key not available'); + } + const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: albumNodeUid }); + + const process = new AddToAlbumProcess( + albumNodeUid, + albumKeys, + signingKeys, + this.apiService, + this.cryptoService, + this.nodesService, + this.logger, + signal, + ); + yield * process.execute(photoNodeUids); + } + async *removePhotos( albumNodeUid: string, photoNodeUids: string[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) { if (result.ok) { await this.nodesService.notifyNodeChanged(result.uid); diff --git a/js/sdk/src/internal/photos/albumsCrypto.ts b/js/sdk/src/internal/photos/albumsCrypto.ts index 23ceaafe..f7bc37fd 100644 --- a/js/sdk/src/internal/photos/albumsCrypto.ts +++ b/js/sdk/src/internal/photos/albumsCrypto.ts @@ -1,4 +1,7 @@ -import { DriveCrypto, PrivateKey } from '../../crypto'; +import { c } from 'ttag'; +import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto'; +import { ValidationError } from '../../errors'; +import { InvalidNameError, Result } from '../../interface'; import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface'; /** @@ -97,4 +100,52 @@ export class AlbumsCryptoService { hash, }; } + + async encryptPhotoForAlbum( + nodeName: Result, + sha1: string, + nodeKeys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey }, + albumKeys: { key: PrivateKey; hashKey: Uint8Array }, + signingKeys: NodeSigningKeys, + ): Promise<{ + encryptedName: string; + hash: string; + contentHash: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + signatureEmail: string; + nameSignatureEmail: string; + }> { + if (!nodeName.ok) { + throw new ValidationError(c('Error').t`Cannot add photo to album without a valid name`); + } + if (signingKeys.type !== 'userAddress') { + throw new Error('Adding photos to album by anonymous user is not supported'); + } + const email = signingKeys.email; + const signingKey = signingKeys.key; + + const [{ armoredNodeName }, hash, contentHash, { armoredPassphrase, armoredPassphraseSignature }] = + await Promise.all([ + this.driveCrypto.encryptNodeName(nodeName.value, nodeKeys.nameSessionKey, albumKeys.key, signingKey), + this.driveCrypto.generateLookupHash(nodeName.value, albumKeys.hashKey), + this.driveCrypto.generateLookupHash(sha1, albumKeys.hashKey), + this.driveCrypto.encryptPassphrase( + nodeKeys.passphrase, + nodeKeys.passphraseSessionKey, + [albumKeys.key], + signingKey, + ), + ]); + + return { + encryptedName: armoredNodeName, + hash, + contentHash, + armoredNodePassphrase: armoredPassphrase, + armoredNodePassphraseSignature: armoredPassphraseSignature, + signatureEmail: email, + nameSignatureEmail: email, + }; + } } diff --git a/js/sdk/src/internal/photos/apiService.test.ts b/js/sdk/src/internal/photos/apiService.test.ts new file mode 100644 index 00000000..c628d707 --- /dev/null +++ b/js/sdk/src/internal/photos/apiService.test.ts @@ -0,0 +1,233 @@ +import { DriveAPIService } from '../apiService/apiService'; +import { APICodeError, InvalidRequirementsAPIError } from '../apiService/errors'; +import { PhotosAPIService } from './apiService'; +import { MissingRelatedPhotosError } from './errors'; + +describe('photosAPIService', () => { + let apiMock: DriveAPIService; + let api: PhotosAPIService; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error Mocking for testing purposes + apiMock = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + }; + + api = new PhotosAPIService(apiMock); + }); + + const albumNodeUid = 'volumeId1~albumNodeId'; + + describe('addPhotosToAlbum', () => { + const photoPayloads = [ + { + nodeUid: 'volumeId1~photoNodeId1', + contentHash: 'contentHash1', + nameHash: 'nameHash1', + encryptedName: 'encryptedName1', + nameSignatureEmail: 'nameSignatureEmail1', + nodePassphrase: 'nodePassphrase1', + nodePassphraseSignature: 'nodePassphraseSignature1', + signatureEmail: 'signatureEmail1', + relatedPhotos: [ + { + nodeUid: 'volumeId1~photoNodeId2', + contentHash: 'contentHash2', + nameHash: 'nameHash2', + encryptedName: 'encryptedName2', + nameSignatureEmail: 'nameSignatureEmail2', + nodePassphrase: 'nodePassphrase2', + nodePassphraseSignature: 'nodePassphraseSignature2', + signatureEmail: 'signatureEmail2', + }, + ], + }, + ]; + + it('should add photos to album', async () => { + apiMock.post = jest.fn().mockResolvedValue({ + Code: 1000, + Responses: [ + { + LinkID: 'photoNodeId1', + Response: { + Code: 1000, + }, + }, + ], + }); + + const result = await Array.fromAsync(api.addPhotosToAlbum(albumNodeUid, photoPayloads)); + + expect(result).toEqual([ + { + uid: 'volumeId1~photoNodeId1', + ok: true, + }, + ]); + expect(apiMock.post).toHaveBeenCalledWith( + `drive/photos/volumes/volumeId1/albums/albumNodeId/add-multiple`, + { + AlbumData: [ + expect.objectContaining({ + LinkID: 'photoNodeId1', + Hash: 'nameHash1', + Name: 'encryptedName1', + NameSignatureEmail: 'nameSignatureEmail1', + }), + expect.objectContaining({ + LinkID: 'photoNodeId2', + Hash: 'nameHash2', + Name: 'encryptedName2', + NameSignatureEmail: 'nameSignatureEmail2', + }), + ], + }, + undefined, + ); + }); + + it('should return MissingRelatedPhotosError if related photos are missing', async () => { + apiMock.post = jest.fn().mockResolvedValue({ + Code: 1000, + Responses: [ + { + LinkID: 'photoNodeId1', + Response: { + Code: 2000, + Details: { + Missing: ['photoNodeId3'], + }, + }, + }, + ], + }); + + const result = await Array.fromAsync(api.addPhotosToAlbum(albumNodeUid, photoPayloads)); + + expect(result).toEqual([ + { + uid: 'volumeId1~photoNodeId1', + ok: false, + error: new MissingRelatedPhotosError([]), + }, + ]); + expect((result[0] as any).error.missingNodeUids).toEqual(['volumeId1~photoNodeId3']); + }); + + it('should return error for unknown error', async () => { + apiMock.post = jest.fn().mockResolvedValue({ + Code: 1000, + Responses: [ + { + LinkID: 'photoNodeId1', + Response: { + Code: 3000, + Error: 'Some error', + }, + }, + ], + }); + + const result = await Array.fromAsync(api.addPhotosToAlbum(albumNodeUid, photoPayloads)); + + expect(result).toEqual([ + { + uid: 'volumeId1~photoNodeId1', + ok: false, + error: new APICodeError('Some error', 3000), + }, + ]); + }); + }); + + describe('copyPhotoToAlbum', () => { + const photoPayloads = [ + { + nodeUid: 'volumeId2~photoNodeId1', + contentHash: 'contentHash1', + nameHash: 'nameHash1', + encryptedName: 'encryptedName1', + nameSignatureEmail: 'nameSignatureEmail1', + nodePassphrase: 'nodePassphrase1', + nodePassphraseSignature: 'nodePassphraseSignature1', + signatureEmail: 'signatureEmail1', + relatedPhotos: [ + { + nodeUid: 'volumeId2~photoNodeId2', + contentHash: 'contentHash2', + nameHash: 'nameHash2', + encryptedName: 'encryptedName2', + nameSignatureEmail: 'nameSignatureEmail2', + nodePassphrase: 'nodePassphrase2', + nodePassphraseSignature: 'nodePassphraseSignature2', + signatureEmail: 'signatureEmail2', + }, + ], + }, + ]; + + it('should copy photo to album', async () => { + apiMock.post = jest.fn().mockResolvedValue({ + Code: 1000, + LinkID: 'photoNodeId1', + }); + + const result = await api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]); + + expect(result).toEqual('volumeId1~photoNodeId1'); + expect(apiMock.post).toHaveBeenCalledWith( + `drive/volumes/volumeId2/links/photoNodeId1/copy`, + expect.objectContaining({ + TargetVolumeID: 'volumeId1', + TargetParentLinkID: 'albumNodeId', + Hash: 'nameHash1', + Name: 'encryptedName1', + Photos: { + ContentHash: 'contentHash1', + RelatedPhotos: expect.arrayContaining([ + expect.objectContaining({ + LinkID: 'photoNodeId2', + Hash: 'nameHash2', + Name: 'encryptedName2', + }), + ]), + }, + }), + undefined, + ); + }); + + it('should return MissingRelatedPhotosError if related photos are missing', async () => { + apiMock.post = jest.fn().mockRejectedValue(new InvalidRequirementsAPIError( + 'Missing related photos', + 2000, + { + Missing: ['photoNodeId3'], + }, + )); + + const promise = api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]); + + await expect(promise).rejects.toThrow(MissingRelatedPhotosError); + try { + await promise; + } catch (error) { + expect((error as MissingRelatedPhotosError).missingNodeUids).toEqual(['volumeId2~photoNodeId3']); + } + }); + + it('should return error for unknown error', async () => { + const error = new APICodeError('Some error', 3000); + apiMock.post = jest.fn().mockRejectedValue(error); + + const promise = api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]); + + await expect(promise).rejects.toThrow(error); + }); + }); +}); diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index 62f9d76e..2443156c 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,12 +1,13 @@ import { c } from 'ttag'; import { ValidationError } from '../../errors'; -import { NodeResult } from '../../interface'; -import { APICodeError, DriveAPIService, drivePaths } from '../apiService'; +import { NodeResultWithError } from '../../interface'; +import { APICodeError, DriveAPIService, drivePaths, InvalidRequirementsAPIError, isCodeOk } from '../apiService'; import { batch } from '../batch'; import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; import { makeNodeUid, splitNodeUid } from '../uids'; -import { AlbumItem } from './interface'; +import { MissingRelatedPhotosError } from './errors'; +import { AddToAlbumEncryptedPhotoPayload, AlbumItem } from './interface'; type GetPhotoShareResponse = drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json']; @@ -46,6 +47,20 @@ type PostPhotoDuplicateRequest = Extract< type PostPhotoDuplicateResponse = drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json']; +type PostAddPhotosToAlbumRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostAddPhotosToAlbumResponse = + drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple']['post']['responses']['200']['content']['application/json']; + +type PostCopyLinkRequest = Extract< + drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCopyLinkResponse = + drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['responses']['200']['content']['application/json']; + type PostRemovePhotosFromAlbumRequest = Extract< drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['requestBody'], { content: object } @@ -186,10 +201,7 @@ export class PhotosAPIService { } } - async *iterateAlbumChildren( - albumNodeUid: string, - signal?: AbortSignal, - ): AsyncGenerator { + async *iterateAlbumChildren(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator { const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid); let anchor = ''; while (true) { @@ -289,20 +301,17 @@ export class PhotosAPIService { ): Promise { const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid); const coverLinkId = coverPhotoNodeUid ? splitNodeUid(coverPhotoNodeUid).nodeId : undefined; - await this.apiService.put( - `drive/photos/volumes/${volumeId}/albums/${linkId}`, - { - CoverLinkID: coverLinkId, - Link: updatedName - ? { - Name: updatedName.encryptedName, - Hash: updatedName.hash, - OriginalHash: updatedName.originalHash, - NameSignatureEmail: updatedName.nameSignatureEmail, - } - : null, - }, - ); + await this.apiService.put(`drive/photos/volumes/${volumeId}/albums/${linkId}`, { + CoverLinkID: coverLinkId, + Link: updatedName + ? { + Name: updatedName.encryptedName, + Hash: updatedName.hash, + OriginalHash: updatedName.originalHash, + NameSignatureEmail: updatedName.nameSignatureEmail, + } + : null, + }); } async deleteAlbum(albumNodeUid: string, options: { force?: boolean } = {}): Promise { @@ -319,11 +328,147 @@ export class PhotosAPIService { } } + /** + * Add photos from the same volume to an album. + * + * To add photos from different volumes, use the {@link copyPhotoToAlbum} method. + * + * In the future, these two methods will be merged into a single one. + */ + async *addPhotosToAlbum( + albumNodeUid: string, + photoPayloads: AddToAlbumEncryptedPhotoPayload[], + signal?: AbortSignal, + ): AsyncGenerator { + const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid); + + const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [ + photoPayload, + ...(photoPayload.relatedPhotos || []), + ]); + const allPhotoData = allPhotoPayloads.map((photoPayload) => { + const { nodeId } = splitNodeUid(photoPayload.nodeUid); + return { + LinkID: nodeId, + Hash: photoPayload.nameHash, + Name: photoPayload.encryptedName, + NameSignatureEmail: photoPayload.nameSignatureEmail, + NodePassphrase: photoPayload.nodePassphrase, + ContentHash: photoPayload.contentHash, + }; + }); + + const response = await this.apiService.post( + `drive/photos/volumes/${volumeId}/albums/${albumLinkId}/add-multiple`, + { + AlbumData: allPhotoData, + }, + signal, + ); + + const errors = new Map(); + + for (const r of response.Responses || []) { + // @ts-expect-error - API definition is not correct. + const details = r as { + LinkID: string; + Response: { + Code: number; + Error?: string; + Details: { Missing: string[] }; + }; + }; + + if (!details.Response.Code || !isCodeOk(details.Response.Code) || details.Response?.Error) { + const nodeUid = makeNodeUid(volumeId, details.LinkID); + + if (details.Response.Details?.Missing) { + const missingNodeUids = details.Response.Details.Missing.map((linkId) => + makeNodeUid(volumeId, linkId), + ); + errors.set(nodeUid, new MissingRelatedPhotosError(missingNodeUids)); + } else { + errors.set( + nodeUid, + new APICodeError(details.Response.Error || c('Error').t`Unknown error`, details.Response.Code), + ); + } + } + } + + for (const photoPayload of photoPayloads) { + const uid = photoPayload.nodeUid; + const error = errors.get(uid); + if (error) { + yield { uid, ok: false, error }; + } else { + yield { uid, ok: true }; + } + } + } + + /** + * Copy a photo to a shared album on a different volume. + * + * To add photos from the same volume to an album, use the {@link addPhotosToAlbum} method. + * + * In the future, these two methods will be merged into a single one. + */ + async copyPhotoToAlbum( + albumNodeUid: string, + payload: AddToAlbumEncryptedPhotoPayload, + signal?: AbortSignal, + ): Promise { + const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid); + const { volumeId: targetVolumeId, nodeId: targetAlbumLinkId } = splitNodeUid(albumNodeUid); + + try { + const response = await this.apiService.post( + `drive/volumes/${sourceVolumeId}/links/${sourceLinkId}/copy`, + { + TargetVolumeID: targetVolumeId, + TargetParentLinkID: targetAlbumLinkId, + Hash: payload.nameHash, + Name: payload.encryptedName, + NameSignatureEmail: payload.nameSignatureEmail, + NodePassphrase: payload.nodePassphrase, + // @ts-expect-error: API accepts NodePassphraseSignature as optional. + NodePassphraseSignature: payload.nodePassphraseSignature, + // @ts-expect-error: API accepts SignatureEmail as optional. + SignatureEmail: payload.signatureEmail, + Photos: { + ContentHash: payload.contentHash, + RelatedPhotos: + payload.relatedPhotos?.map((related) => ({ + LinkID: splitNodeUid(related.nodeUid).nodeId, + Hash: related.nameHash, + Name: related.encryptedName, + NodePassphrase: related.nodePassphrase, + ContentHash: related.contentHash, + })) || [], + }, + }, + signal, + ); + return makeNodeUid(targetVolumeId, response.LinkID); + } catch (error) { + if (error instanceof InvalidRequirementsAPIError) { + const { Missing: missingLinkIds } = error.details as { Missing: string[] }; + if (missingLinkIds.length > 0) { + throw new MissingRelatedPhotosError( + missingLinkIds.map((linkId) => makeNodeUid(sourceVolumeId, linkId)), + ); + } + } + throw error; + } + } + async *removePhotosFromAlbum( albumNodeUid: string, photoNodeUids: string[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid); const batchSize = 50; @@ -331,7 +476,7 @@ export class PhotosAPIService { for (const photoNodeUidsBatch of batch(photoNodeUids, batchSize)) { const linkIds = photoNodeUidsBatch.map((nodeUid) => splitNodeUid(nodeUid).nodeId); - let errorMessage: string | undefined; + let error: Error | undefined; try { await this.apiService.post( `drive/photos/volumes/${volumeId}/albums/${albumLinkId}/remove-multiple`, @@ -340,14 +485,14 @@ export class PhotosAPIService { }, signal, ); - } catch (error) { - errorMessage = error instanceof Error ? error.message : c('Error').t`Unknown error`; + } catch (e) { + error = e instanceof Error ? e : new Error(c('Error').t`Unknown error`); } // The API does not return individual results for each photo. for (const uid of photoNodeUidsBatch) { - if (errorMessage) { - yield { uid, ok: false, error: errorMessage }; + if (error) { + yield { uid, ok: false, error }; } else { yield { uid, ok: true }; } diff --git a/js/sdk/src/internal/photos/errors.ts b/js/sdk/src/internal/photos/errors.ts new file mode 100644 index 00000000..0af45d6d --- /dev/null +++ b/js/sdk/src/internal/photos/errors.ts @@ -0,0 +1,11 @@ +import { c } from 'ttag'; + +export class MissingRelatedPhotosError extends Error { + constructor(public missingNodeUids: string[]) { + // We do not want to leak the technical details of the error to the user. + // When this error happens, it is retried by the SDK, so very likely the + // user will not see this error unless the operation fails twice in a row. + super(c('Error').t`Operation failed, try again later`); + this.name = 'MissingRelatedPhotosError'; + } +} diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index de3d3cfe..5be3c5ff 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -60,7 +60,7 @@ export function initPhotosModule( photoShares, nodesService, ); - const albums = new Albums(api, albumsCryptoService, photoShares, nodesService); + const albums = new Albums(telemetry, api, albumsCryptoService, photoShares, nodesService); return { timeline, diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index c3bccc5b..4e8d7157 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -47,12 +47,12 @@ export type TimelineItem = { nodeUid: string; captureTime: Date; tags: PhotoTag[]; -} +}; export type AlbumItem = { nodeUid: string; captureTime: Date; -} +}; export enum PhotoTag { Favorites = 0, @@ -66,3 +66,15 @@ export enum PhotoTag { Panoramas = 8, Raw = 9, } + +export type AddToAlbumEncryptedPhotoPayload = { + nodeUid: string; + contentHash: string; + nameHash: string; + encryptedName: string; + nameSignatureEmail: string; + nodePassphrase: string; + nodePassphraseSignature?: string; + signatureEmail?: string; + relatedPhotos?: Omit[]; +}; diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 90db6965..944f375b 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -75,11 +75,15 @@ export class PhotosNodesAPIService extends NodeAPIServiceBase< }; } - if (link.Link.Type === 3) { + if (link.Link.Type === 3 && link.Album) { return { ...baseNodeMetadata, encryptedCrypto: { ...baseCryptoNodeMetadata, + folder: { + armoredExtendedAttributes: link.Album.XAttr || undefined, + armoredHashKey: link.Album.NodeHashKey as string, + }, }, }; } diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index cc21df17..5b863b83 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -36,6 +36,9 @@ type GetSharedNodesResponse = type GetSharedWithMeNodesResponse = drivePaths['/drive/v2/sharedwithme']['get']['responses']['200']['content']['application/json']; +type GetSharedAlbumsResponse = + drivePaths['/drive/photos/albums/shared-with-me']['get']['responses']['200']['content']['application/json']; + type GetInvitationsResponse = drivePaths['/drive/v2/shares/invitations']['get']['responses']['200']['content']['application/json']; @@ -184,6 +187,30 @@ export class SharingAPIService { } anchor = response.AnchorID; } + + if (this.shareTargetTypes.includes(ShareTargetType.Album)) { + yield* this.iterateSharedAlbumUids(signal); + } + } + + // TODO: Sharing cannot know about albums. We should remove this and use + // ShareTargetTypes when it is supported by the API. + private async *iterateSharedAlbumUids(signal?: AbortSignal): AsyncGenerator { + let anchor = ''; + while (true) { + const response = await this.apiService.get( + `drive/photos/albums/shared-with-me?${anchor ? `AnchorID=${anchor}` : ''}`, + signal, + ); + for (const album of response.Albums) { + yield makeNodeUid(album.VolumeID, album.LinkID); + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } } async *iterateInvitationUids(signal?: AbortSignal): AsyncGenerator { diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index f8e0dcd9..ad6665c3 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -17,6 +17,7 @@ import { NonProtonInvitationOrUid, ProtonInvitationWithNode, NodeResult, + NodeResultWithError, } from './interface'; import { getConfig } from './config'; import { DriveCrypto } from './crypto'; @@ -120,13 +121,7 @@ export class ProtonDrivePhotosClient { this.photoShares, fullConfig.clientUid, ); - this.photos = initPhotosModule( - telemetry, - apiService, - cryptoModule, - this.photoShares, - this.nodes.access, - ); + this.photos = initPhotosModule(telemetry, apiService, cryptoModule, this.photoShares, this.nodes.access); this.sharing = initSharingModule( telemetry, apiService, @@ -238,7 +233,7 @@ export class ProtonDrivePhotosClient { */ async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); - yield * convertInternalPhotoNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); + yield* convertInternalPhotoNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } /** @@ -249,7 +244,7 @@ export class ProtonDrivePhotosClient { async *iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Iterating ${nodeUids.length} nodes`); // TODO: expose photo type - yield * convertInternalMissingPhotoNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); + yield* convertInternalMissingPhotoNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); } /** @@ -299,7 +294,7 @@ export class ProtonDrivePhotosClient { */ async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Deleting ${nodeUids.length} nodes`); - yield * this.nodes.management.deleteTrashedNodes(getUids(nodeUids), signal); + yield* this.nodes.management.deleteTrashedNodes(getUids(nodeUids), signal); } /** @@ -317,7 +312,7 @@ export class ProtonDrivePhotosClient { */ async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes by me'); - yield * convertInternalPhotoNodeIterator(this.sharing.access.iterateSharedNodes(signal)); + yield* convertInternalPhotoNodeIterator(this.sharing.access.iterateSharedNodes(signal)); } /** @@ -484,7 +479,9 @@ export class ProtonDrivePhotosClient { */ async isDuplicatePhoto(name: string, generateSha1: () => Promise, signal?: AbortSignal): Promise { this.logger.info(`Checking if photo is a duplicate`); - return this.photos.timeline.findPhotoDuplicates(name, generateSha1, signal).then(nodeUids => nodeUids.length !== 0); + return this.photos.timeline + .findPhotoDuplicates(name, generateSha1, signal) + .then((nodeUids) => nodeUids.length !== 0); } /** @@ -505,7 +502,11 @@ export class ProtonDrivePhotosClient { * @param signal - An optional abort signal to cancel the operation. * @returns An array of node UIDs of duplicate photos. Empty array if no duplicates found. */ - async findPhotoDuplicates(name: string, generateSha1: () => Promise, signal?: AbortSignal): Promise { + async findPhotoDuplicates( + name: string, + generateSha1: () => Promise, + signal?: AbortSignal, + ): Promise { this.logger.info(`Checking if photo have duplicates`); return this.photos.timeline.findPhotoDuplicates(name, generateSha1, signal); } @@ -575,7 +576,7 @@ export class ProtonDrivePhotosClient { async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating albums'); // TODO: expose album type - yield * convertInternalPhotoNodeIterator(this.photos.albums.iterateAlbums(signal)); + yield* convertInternalPhotoNodeIterator(this.photos.albums.iterateAlbums(signal)); } /** @@ -592,6 +593,29 @@ export class ProtonDrivePhotosClient { yield* this.photos.albums.iterateAlbum(getUid(albumNodeUid), signal); } + /** + * Adds photos to an album. + * + * Photos are added in batches. Each photo's related photos (e.g., live + * photo components) are always included with the main photo. + * + * The album has a limit of 10,000 photos. If the limit is reached, + * a `ValidationError` is thrown. + * + * @param albumNodeUid - The UID of the album to add photos to. + * @param photoNodeUids - The UIDs of the photos to add to the album. + * @param signal - An optional abort signal to cancel the operation. + * @returns An async generator of the added photo results. + */ + async *addPhotosToAlbum( + albumNodeUid: NodeOrUid, + photoNodeUids: NodeOrUid[], + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Adding ${photoNodeUids.length} photos to album ${getUid(albumNodeUid)}`); + yield* this.photos.albums.addPhotos(getUid(albumNodeUid), getUids(photoNodeUids), signal); + } + /** * Removes photos from an album. * @@ -608,7 +632,7 @@ export class ProtonDrivePhotosClient { albumNodeUid: NodeOrUid, photoNodeUids: NodeOrUid[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { this.logger.info(`Removing ${photoNodeUids.length} photos from album ${getUid(albumNodeUid)}`); yield* this.photos.albums.removePhotos(getUid(albumNodeUid), getUids(photoNodeUids), signal); } From ca219648352b407e9b55a9da6326ed49cc3193ad Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 09:22:14 +0100 Subject: [PATCH 527/791] Add cause to re-thrown errors --- js/sdk/src/internal/nodes/cryptoService.ts | 9 ++++++--- js/sdk/src/internal/sharing/cryptoService.ts | 14 ++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index b80c3cb7..593f5ba2 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -145,7 +145,10 @@ export class NodesCryptoService { }), nameAuthor, membership, - activeRevision: 'file' in node.encryptedCrypto ? resultError(new Error(errorMessage)) : undefined, + activeRevision: + 'file' in node.encryptedCrypto + ? resultError(new Error(errorMessage, { cause: error })) + : undefined, folder: undefined, errors: [error], }, @@ -210,7 +213,7 @@ export class NodesCryptoService { void this.reporter.reportDecryptionError(node, 'nodeExtendedAttributes', error); const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt active revision: ${message}`; - activeRevision = resultError(new Error(errorMessage)); + activeRevision = resultError(new Error(errorMessage, { cause: error })); } try { @@ -361,7 +364,7 @@ export class NodesCryptoService { void this.reporter.reportDecryptionError(node, 'nodeName', error); const errorMessage = getErrorMessage(error); return { - name: resultError(new Error(errorMessage)), + name: resultError(new Error(errorMessage, { cause: error })), author: resultError({ claimedAuthor: getClaimedAuthor(nameSignatureEmail, verificationKeys.length === 0), error: errorMessage, diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 600b96b7..61b76c16 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -213,7 +213,7 @@ export class SharingCryptoService { } catch (error: unknown) { const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt item name: ${message}`; - nodeName = resultError(new Error(errorMessage)); + nodeName = resultError(new Error(errorMessage, { cause: error })); } return { @@ -458,7 +458,10 @@ export class SharingCryptoService { urlPassword = result.password; customPassword = resultOk(result.customPassword); } catch (originalError: unknown) { - const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`); + const error = + originalError instanceof Error + ? originalError + : new Error(c('Error').t`Unknown error`, { cause: originalError }); return { url: resultError(error), customPassword: resultError(error), @@ -473,7 +476,10 @@ export class SharingCryptoService { try { shareKey = await this.decryptBookmarkKey(encryptedBookmark, password); } catch (originalError: unknown) { - const error = originalError instanceof Error ? originalError : new Error(c('Error').t`Unknown error`); + const error = + originalError instanceof Error + ? originalError + : new Error(c('Error').t`Unknown error`, { cause: originalError }); return { url, customPassword, @@ -577,7 +583,7 @@ export class SharingCryptoService { const message = getErrorMessage(error); const errorMessage = c('Error').t`Failed to decrypt bookmark name: ${message}`; - return resultError(new Error(errorMessage)); + return resultError(new Error(errorMessage, { cause: error })); } } } From c8ccb75cfb53f519ffcb73ba9dad245865557fe3 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 07:38:48 +0000 Subject: [PATCH 528/791] Update changelog for cs/v0.7.0-alpha.9 --- cs/CHANGELOG.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 8fb4ce1e..c559d484 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## cs/v0.7.0-alpha.9 (2026-02-17) + +* Expose errorToString +* Fix deserialization of DegradedNode +* Add E2E tests for photo thumbnails in albums + ## cs/v0.7.0-alpha.8 (2026-02-11) * Provide expected SHA1 for upload through callback @@ -8,6 +14,7 @@ ## cs/v0.7.0-alpha.7 (2026-02-11) * Abort pause state on non-resumable upload errors +* Exclude integrity errors from being resumable during upload ## cs/v0.7.0-alpha.6 (2026-02-10) @@ -29,16 +36,21 @@ ## cs/v0.7.0-alpha.4 (2026-01-30) +* Fix files being truncated when downloading to file path through interop * Follow up on download pausing to address issues with hanging, seeking with interop and telemetry +* Fix timeout reported as cancellation through interop ## cs/v0.7.0-alpha.3 (2026-01-27) * Transform progress callback to flow +* Implement pausing and resuming of downloads * Add photos client kotlin bindings for upload +* Handle and send decryption error telemetry to client * Enable request body streaming for upload ## cs/v0.7.0-alpha.2 (2026-01-26) +* Fix location of Photos project * Make cache optional * Log ignored errors * Add file upload methods to the Photos client @@ -46,8 +58,10 @@ ## cs/v0.7.0-alpha.1 (2026-01-23) +* Enforce static code analysis warnings as errors on release builds * Replace stream by channel for thumbnails * Replace stream with channel +* Add node metadata decryption error metrics * Fix native clients getting garbage collected during long request to the sdk * Add Kotlin tests for pausing and resuming downloads * Fix error not caught or returned to the sdk when scope was null @@ -57,10 +71,12 @@ ## cs/v0.6.1-alpha.17 (2026-01-20) * Fix errors not caught in Kotlin bindings and crashing client +* Remove unnecessary parameter from .BeginTransaction calls ## cs/v0.6.1-alpha.16 (2026-01-19) -* No changes +* Improve cache DB transaction locking behavior +* Implement delayed cancellation for reading content during upload ## cs/v0.6.1-alpha.15 (2026-01-16) @@ -71,19 +87,26 @@ * Improve on-disk cache handling * Update driveClientCreate to use ProtonDriveClientOptions and timeouts +* Fix download photos from album * Add ability to override HTTP timeouts ## cs/v0.6.1-alpha.13 (2026-01-15) +* Fix build error due to missing brace in Protobuf definition +* Implement support for protecting SDK databases * Expose functions to trash node through Swift package * refactor: consolidate PhotoDownloadOperation into DownloadOperation +* Fix failure to resume upload that has gaps in block upload completions +* Implement 429 handling for block downloads * Log paused status for each call * Expose folder creation in interop and Kotlin bindings * Update coroutine scope when resume * Introduce PhotoDownloadOperation +* Simplify implementation for pausing uploads * Add Kotlin bindings for rename * Ignore cancellation error after cancelling in download test * Expose folder creation in interop and Swift bindings +* Add support for photo decryption through album key packet ## cs/v0.6.1-alpha.12 (2026-01-09) @@ -94,21 +117,27 @@ ## cs/v0.6.1-alpha.11 (2026-01-08) -* No changes +* Fix builds for Kotlin and Swift bindings broken due to Experimental attribute +* Handle 429 responses on block uploads ## cs/v0.6.1-alpha.10 (2026-01-07) +* Fix InteropStream length initialization for write streams * Implement initial photos client interop * Interop and bindings for DownloadController.GetIsDownloadCompleteWithVerificationIssue * Avoid logging storage body for test +* Map download integrity exception to integrity domain for interop ## cs/v0.6.1-alpha.9 (2026-01-06) +* Pause upload on timeout * Fix progress logs in kotlin ## cs/v0.6.1-alpha.8 (2026-01-04) +* Switch to SQLite-free implementation for in-memory caching * Expose function to rename node through Swift package +* Update download error handling * Limit GC pressure by creating less Channel instances * Add levels to logs @@ -116,9 +145,12 @@ * Reapply removed upload controller dispose calls * Move incomplete draft deletion to upload controller disposal +* Fix shares and share secrets not being cached +* Expose download integrity errors and download status ## cs/v0.6.1-alpha.6 (2025-12-19) +* Fix download retrying on cancellation * Pass error when operation is paused to the client. Prevent crashes for calls after operation throws. ## cs/v0.6.1-alpha.5 (2025-12-19) @@ -126,9 +158,17 @@ * Add cancellation message when CS cancels a job * Fix download failures due to missing keys for manifest check * Cancel CancellationTokenSource when coroutine scope is cancelled executing blocking function +* Add photos thumbnail downloader +* Update telemetry error mapping +* Implement pausing and resuming of uploads +* Fix exception on retrying thumbnail block upload +* Add photo downloader +* Add Photos client and Photos volume creation * Extract Job code from JniDriveClient * Test upload and download events * Convert stateless JNI methods to static +* Log swallowed exceptions +* Propagate exception to interop logger ## cs/v0.6.1-alpha.4 (2025-12-15) @@ -152,7 +192,11 @@ * Set error type to the name of the Kotlin exception * Improve error generation and parsing in Swift bindings * Check optional proto fields +* Add properties to query paused state of upload and download +* Prevent download from seeking back in output stream * Add error handling for writing to output stream +* Add support to C# CLI for downloading by node UID +* Increase number of attempts for block transfers * Remove debug log with fatal level ## cs/v0.6.0-test.2 (2025-12-04) @@ -170,40 +214,64 @@ ## cs/v0.6.0-alpha.5 (2025-12-09) * Check optional proto fields +* Add properties to query paused state of upload and download +* Prevent download from seeking back in output stream * Add error handling for writing to output stream +* Add support to C# CLI for downloading by node UID ## cs/v0.6.0-alpha.4 (2025-12-05) +* Increase number of attempts for block transfers * Remove debug log with fatal level ## cs/v0.6.0-alpha.3 (2025-12-04) +* Bump crypto lib to handle decrypted AEAD session key exports * Improve performance of iterating over URLSession.AsyncBytes during download +* Handle degraded node ## cs/v0.6.0-alpha.1 (2025-12-02) * Fix Kotlin build failure due to Protobuf changes +* Implement telemetry for download * Fix crashes when download is interrupted * Add Kotlin bindings for feature flags * Remove unused parameter +* Fix CLI resilience retrying even on successful round trips +* Fix address verification happening too early * Include the Swift's error message in the SDK interop error * Add auto-retries into HTTP client bridge for certain HTTP errors: 401, 429, 5xx * Add HTTP timeouts and ability to cancel requests through interop +* Handle diverging size on upload +* Address security review of C# crypto +* Preserve interop errors passing through SDK +* Allow multiple calls to override native library name +* Replace option to disable HTTP retries with a request type * Delay opening upload stream until necessery * Upgrade version from 0.4.0 to 0.5.0 +* Add hint to disable retries on HTTP requests * Close properly response body when read +* Add more logging to transfer queues * Use streaming in HTTP client +* Add AEAD support * Add approximate upload size to upload metric event in kt binding * Improve mapping of SDK exceptions to Kotlin errors +* Add approximate upload size to upload metric event * Parse Protobuf request within the same JNI call * Support client-injected feature flags in Swift * Remove copyrights and optimize imports * Add filtering by type to thumbnail enumeration +* Fix missing disposal of file uploader and file downloader through interop * Add pause and resume API * Add Kotlin bindings package for Android +* Make feature flag provision asynchronous +* Add feature flag support * Fix cancellation token source being double-freed in the Swift interop +* Fix wrong additional metadata parameters in upload +* Add possibility to provide additional metadata on file upload * Add method to download thumbnails * Pass node name conflict error data through interop +* Fix blocks not being released during download * Expose cancellation support in SDK bindings * Add CI job to build and deploy Swift package * Update client creation through interop to be able to set client UID @@ -211,17 +279,43 @@ * Expose function to get available node name through Swift package * Fix logger * Feat/parse error swift interop +* Fix possibility of missing domain and type on interop errors +* Fix missing SDK version header when injecting HTTP client without interop * Fix progress callback doesn't report issue +* Fix thumbnails causing upload to hang +* Fix deserialization error on getting available names * Add Swift SDK package for iOS & macOS +* Fix download error due to misuse of new URL block fields +* Fix error on HTTP response with Expires header when using interop +* Fix deserialization error on download +* Apply server time to PGP when injecting the HTTP client through interop +* Improve logging and clean up some code +* Fix SHA1 extended attribute +* Align JSON output of the C# CLI with the JavaScript one +* Fix conflicting draft deletion failure +* Fix old revision UID being returned instead of new one after revision upload +* Fix various interop issues found after enabling HTTP client injection ## cs/v0.1.0-alpha.3 (2025-10-14) -* No changes +* Fix conflicting draft deletion failure +* Fix old revision UID being returned instead of new one after revision upload +* Fix thumbnail type enum +* Allow logger provider handle for drive client creation +* Add logging for upload and session +* Make some naming clearer +* Make thumbnail type strongly-typed in Protobufs +* Fix exception when returning HTTP response through interop +* Improve error message in case of invalid cast from interop handle ## cs/0.6.0-alpha.3 (2025-12-04) +* Bump crypto lib to handle decrypted AEAD session key exports * Improve performance of iterating over URLSession.AsyncBytes during download +* Handle degraded node ## cs/0.6.0-alpha.1 (2025-12-02) +* Bump crypto lib to handle decrypted AEAD session key exports * Improve performance of iterating over URLSession.AsyncBytes during download +* Handle degraded node From 1607a284d385f0d564c455d079b85dd4ddb227ac Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 11:00:00 +0100 Subject: [PATCH 529/791] Ignore apiRetrySucceeded metric on offline or timeout errors --- js/sdk/src/interface/telemetry.ts | 1 + .../internal/apiService/apiService.test.ts | 44 +++++++++++++++--- js/sdk/src/internal/apiService/apiService.ts | 45 ++++++++++++++----- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 7cbab3b3..0606d696 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -24,6 +24,7 @@ export interface MetricAPIRetrySucceededEvent { eventName: 'apiRetrySucceeded'; url: string; failedAttempts: number; + previousError?: unknown; } export interface MetricDebounceLongWaitEvent { diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index c985df96..1508eebb 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -1,5 +1,5 @@ import { AbortError } from '../../errors'; -import { ProtonDriveHTTPClient, SDKEvent } from '../../interface'; +import { ProtonDriveHTTPClient, SDKEvent, Telemetry, MetricEvent } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; import { SDKEvents } from '../sdkEvents'; import { DriveAPIService } from './apiService'; @@ -12,13 +12,17 @@ function generateOkResponse() { } describe('DriveAPIService', () => { + let telemetry: Telemetry; let sdkEvents: SDKEvents; let httpClient: ProtonDriveHTTPClient; let api: DriveAPIService; + const baseUrl = 'https://drive.proton.me'; + beforeEach(() => { void jest.runAllTimersAsync(); + telemetry = getMockTelemetry(); // @ts-expect-error: No need to implement all methods for mocking sdkEvents = { transfersPaused: jest.fn(), @@ -30,7 +34,7 @@ describe('DriveAPIService', () => { fetchJson: jest.fn(() => Promise.resolve(generateOkResponse())), fetchBlob: jest.fn(() => Promise.resolve(new Response(new Uint8Array([1, 2, 3])))), }; - api = new DriveAPIService(getMockTelemetry(), sdkEvents, httpClient, 'http://drive.proton.me', 'en'); + api = new DriveAPIService(telemetry, sdkEvents, httpClient, baseUrl, 'en'); }); function expectSDKEvents(...events: SDKEvent[]) { @@ -42,6 +46,15 @@ describe('DriveAPIService', () => { ); } + function expectMetricEvent(previousError: unknown, failedAttempts: number) { + expect(telemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'apiRetrySucceeded', + failedAttempts, + url: `${baseUrl}/test`, + previousError, + }); + } + describe('should make', () => { it('GET request', async () => { const result = await api.get('test'); @@ -78,6 +91,7 @@ describe('DriveAPIService', () => { ); expect(await request.json).toEqual(data); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); } it('storage GET request', async () => { @@ -109,6 +123,7 @@ describe('DriveAPIService', () => { ); expect(request.body).toEqual(data); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); } }); @@ -121,6 +136,7 @@ describe('DriveAPIService', () => { await expect(api.get('test')).rejects.toThrow(new AbortError('Request aborted')); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('APIHTTPError on 4xx response without JSON body', async () => { @@ -129,6 +145,7 @@ describe('DriveAPIService', () => { ); await expect(api.get('test')).rejects.toThrow(new Error('Not found')); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('APIError on 4xx response with JSON body', async () => { @@ -137,6 +154,7 @@ describe('DriveAPIService', () => { ); await expect(api.get('test')).rejects.toThrow('General error'); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); }); @@ -155,6 +173,7 @@ describe('DriveAPIService', () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('on timeout error', async () => { @@ -171,19 +190,19 @@ describe('DriveAPIService', () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('on general error', async () => { - httpClient.fetchJson = jest - .fn() - .mockRejectedValueOnce(new Error('Error')) - .mockResolvedValueOnce(generateOkResponse()); + const error = new Error('Error'); + httpClient.fetchJson = jest.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(generateOkResponse()); const result = api.get('test'); await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); + expectMetricEvent(error, 1); }); it('only once on general error', async () => { @@ -198,6 +217,7 @@ describe('DriveAPIService', () => { await expect(result).rejects.toThrow('Second error'); expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('on 429 response with default timeout', async () => { @@ -231,6 +251,7 @@ describe('DriveAPIService', () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expectSDKEvents(); + expectMetricEvent(429, 2); }); it('on 429 response with retry-after header', async () => { @@ -272,6 +293,7 @@ describe('DriveAPIService', () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expectSDKEvents(); + expectMetricEvent(429, 2); }); it('on 5xx response', async () => { @@ -287,6 +309,7 @@ describe('DriveAPIService', () => { await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); + expectMetricEvent(500, 1); }); it('only once on 5xx response', async () => { @@ -301,6 +324,7 @@ describe('DriveAPIService', () => { await expect(result).rejects.toThrow('Some error'); expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); }); @@ -314,6 +338,7 @@ describe('DriveAPIService', () => { await expect(api.get('test')).rejects.toThrow(error); expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('limit 429 errors', async () => { @@ -336,6 +361,8 @@ describe('DriveAPIService', () => { httpClient.fetchJson = jest.fn().mockResolvedValue(generateOkResponse()); await api.get('test'); expect(sdkEvents.requestsThrottled).toHaveBeenCalledTimes(1); + + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('do not limit 429s when some pass', async () => { @@ -355,6 +382,7 @@ describe('DriveAPIService', () => { // 20 calls * 5 retries till OK response + 1 last successful call expect(httpClient.fetchJson).toHaveBeenCalledTimes(101); expectSDKEvents(); + expectMetricEvent(429, 4); }); it('limit server errors', async () => { @@ -371,6 +399,7 @@ describe('DriveAPIService', () => { await expect(api.get('test')).rejects.toThrow('Too many server errors, please try again later'); expect(httpClient.fetchJson).toHaveBeenCalledTimes(10); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('do not limit server errors when some pass', async () => { @@ -390,6 +419,7 @@ describe('DriveAPIService', () => { // 15 erroring calls * 2 attempts + 5 successful calls expect(httpClient.fetchJson).toHaveBeenCalledTimes(35); expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); it('notify about offline error', async () => { @@ -428,6 +458,8 @@ describe('DriveAPIService', () => { expectSDKEvents(SDKEvent.TransfersPaused, SDKEvent.TransfersResumed); await promise; + + expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); }); }); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 39c60689..348f8378 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -245,7 +245,15 @@ export class DriveAPIService { private async fetch( request: { method: string; url: string; signal?: AbortSignal }, callback: () => Promise, - attempt = 0, + { + attempt, + previousError, + }: { + attempt: number; + previousError?: unknown; + } = { + attempt: 0, + }, ): Promise { if (request.signal?.aborted) { throw new AbortError(c('Error').t`Request aborted`); @@ -282,19 +290,19 @@ export class DriveAPIService { this.offlineErrorHappened(); this.logger.info(`${request.method} ${request.url}: Offline error, retrying`); await waitSeconds(OFFLINE_RETRY_DELAY_SECONDS); - return this.fetch(request, callback, attempt + 1); + return this.fetch(request, callback, { attempt: attempt + 1, previousError: error }); } if (error.name === 'TimeoutError' && attempt + 1 < MAX_TIMEOUT_ERROR_RETRY_ATTEMPTS) { this.logger.warn(`${request.method} ${request.url}: Timeout error, retrying`); await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); - return this.fetch(request, callback, attempt + 1); + return this.fetch(request, callback, { attempt: attempt + 1, previousError: error }); } } if (attempt === 0) { this.logger.error(`${request.method} ${request.url}: failed, retrying once`, error); await waitSeconds(GENERAL_RETRY_DELAY_SECONDS); - return this.fetch(request, callback, attempt + 1); + return this.fetch(request, callback, { attempt: attempt + 1, previousError: error }); } this.logger.error(`${request.method} ${request.url}: failed`, error); throw error; @@ -315,7 +323,7 @@ export class DriveAPIService { this.tooManyRequestsErrorHappened(); const timeout = parseInt(response.headers.get('retry-after') || '0', 10) || DEFAULT_429_RETRY_DELAY_SECONDS; await waitSeconds(timeout); - return this.fetch(request, callback, attempt + 1); + return this.fetch(request, callback, { attempt: attempt + 1, previousError: response.status }); } else { this.clearSubsequentTooManyRequestsError(); } @@ -329,16 +337,29 @@ export class DriveAPIService { this.logger.warn(`${request.method} ${request.url}: ${response.status} - retry failed`); } else { await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); - return this.fetch(request, callback, attempt + 1); + return this.fetch(request, callback, { attempt: attempt + 1, previousError: response.status }); } } else { if (attempt > 0) { - this.telemetry.recordMetric({ - eventName: 'apiRetrySucceeded', - failedAttempts: attempt, - url: request.url, - }); - this.logger.warn(`${request.method} ${request.url}: ${response.status} - retry helped`); + const previousErrorMessage = + previousError instanceof Error ? previousError.message : String(previousError); + const isWarning = + !(previousError instanceof Error) || + (previousError instanceof Error && + previousError.name !== 'TimeoutError' && + previousError.name !== 'OfflineError'); + + if (isWarning) { + this.telemetry.recordMetric({ + eventName: 'apiRetrySucceeded', + failedAttempts: attempt, + url: request.url, + previousError, + }); + this.logger.warn(`${request.method} ${request.url}: ${previousErrorMessage} - retry helped`); + } else { + this.logger.debug(`${request.method} ${request.url}: ${previousErrorMessage} - retry helped`); + } } this.clearSubsequentServerErrors(); } From 8407c815686dffbb710722a32a258ad2c769d396 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 07:24:51 +0100 Subject: [PATCH 530/791] Introduce callback handle registry, separate callback lifecycle from object lifecycle --- .../HttpClientRequestProcessor.swift | 67 ++++------ .../HttpClientResponseProcessor.swift | 18 ++- .../Model/BoxedCancellableTask.swift | 37 +++++- .../ProtonDriveClient/ProtonDriveClient.swift | 10 +- .../ProtonPhotosClient.swift | 13 +- .../Sources/Client/SDKClientProvider.swift | 2 +- .../Downloads/DownloadsManager.swift | 6 +- .../Downloads/PhotoDownloadsManager.swift | 3 +- .../Uploads/PhotoUploadsManager.swift | 3 +- .../Uploads/UploadOperation.swift | 4 + .../Uploads/UploadsManager.swift | 3 +- .../Sources/Plumbing/BoxedContinuation.swift | 3 +- .../Plumbing/CallbackHandleRegistry.swift | 121 ++++++++++++++++++ .../Plumbing/ProgressCallbackWrapper.swift | 4 + .../Sources/Plumbing/SDKRequestHandler.swift | 47 ++++--- .../Plumbing/StreamCallbackWrapper.swift | 49 ++----- 16 files changed, 266 insertions(+), 124 deletions(-) create mode 100644 swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift index e2694221..85a37098 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -19,53 +19,29 @@ enum HttpClientRequestProcessor { let httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) - // Create a boxed task with the HTTP work - let taskBox = BoxedCancellableTask { + return BoxedCancellableTask.registered { do { try await HttpClientRequestProcessor.perform( client: driveClient, httpRequestData: httpRequestData, - callbackPointer: callbackPointer + callbackPointer: callbackPointer, + provider: provider ) } catch { SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) } } - - // Retain the task box and return its address as the cancellation handle - let unmanaged = Unmanaged.passRetained(taskBox) - let handle = Int(bitPattern: unmanaged.toOpaque()) - - // Set completion handler to release the Unmanaged reference when done - taskBox.setCompletionHandler { - unmanaged.release() - } - - return handle } - static let cCompatibleHttpCancellationAction: CCallbackWithoutByteArray = { statePointer in - // if statePointer is -1, it means we've early returned from cCompatibleHttpRequest - guard statePointer != -1 else { return } - // Convert the address back to the task box - guard let pointer = UnsafeRawPointer(bitPattern: statePointer) else { - let message = "cCompatibleHttpCancellationAction.statePointer is nil" - assertionFailure(message) - // there is no way we can inform the SDK back about the issue - return - } - - // Get the task box and cancel it - let unmanaged = Unmanaged.fromOpaque(pointer) - let taskBox = unmanaged.takeUnretainedValue() - // Release of the task box is wrapped in completionBlock (see `cCompatibleHttpRequest`), which is called in `cancel` - taskBox.cancel() + static let cCompatibleHttpCancellationAction: CCallbackWithoutByteArray = { callbackHandle in + CallbackHandleRegistry.shared.cancel(callbackHandle) } fileprivate static func perform( client: ProtonSDKClient, httpRequestData: Proton_Sdk_HttpRequest, - callbackPointer: Int + callbackPointer: Int, + provider: SDKClientProvider ) async throws { switch httpRequestData.type { @@ -77,19 +53,22 @@ enum HttpClientRequestProcessor { driveRelativePath: "/drive/" + relativeApiPath, client: client, httpRequestData: httpRequestData, - callbackPointer: callbackPointer + callbackPointer: callbackPointer, + provider: provider ) case .storageUpload: try await uploadToStorage( client: client, httpRequestData: httpRequestData, - callbackPointer: callbackPointer + callbackPointer: callbackPointer, + provider: provider ) case .storageDownload: try await downloadFromStorage( client: client, httpRequestData: httpRequestData, - callbackPointer: callbackPointer + callbackPointer: callbackPointer, + provider: provider ) case .UNRECOGNIZED(let int): fatalError("Unknown HttpRequestType: \(int)") @@ -101,7 +80,8 @@ enum HttpClientRequestProcessor { driveRelativePath: String, client: ProtonSDKClient, httpRequestData: Proton_Sdk_HttpRequest, - callbackPointer: Int + callbackPointer: Int, + provider: SDKClientProvider ) async throws { let headers: [(String, [String])] = httpRequestData.headers.map { header in (header.name, header.values) @@ -144,8 +124,7 @@ enum HttpClientRequestProcessor { let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) uploadBuffer.copyBytes(from: data) let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) - let pointer = Unmanaged.passRetained(bytesOrStream) - bindingsHandle = Int(rawPointer: pointer.toOpaque()) + bindingsHandle = CallbackHandleRegistry.shared.register(bytesOrStream, scope: .ownerManaged, owner: provider) } else { bindingsHandle = nil } @@ -168,7 +147,8 @@ enum HttpClientRequestProcessor { fileprivate static func uploadToStorage( client: ProtonSDKClient, httpRequestData: Proton_Sdk_HttpRequest, - callbackPointer: Int + callbackPointer: Int, + provider: SDKClientProvider ) async throws { let headers: [(String, [String])] = httpRequestData.headers.map { header in (header.name, header.values) @@ -203,8 +183,7 @@ enum HttpClientRequestProcessor { let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) uploadBuffer.copyBytes(from: data) let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) - let pointer = Unmanaged.passRetained(bytesOrStream) - bindingsHandle = Int(rawPointer: pointer.toOpaque()) + bindingsHandle = CallbackHandleRegistry.shared.register(bytesOrStream, scope: .ownerManaged, owner: provider) } else { bindingsHandle = nil } @@ -227,7 +206,8 @@ enum HttpClientRequestProcessor { fileprivate static func downloadFromStorage( client: ProtonSDKClient, httpRequestData: Proton_Sdk_HttpRequest, - callbackPointer: Int + callbackPointer: Int, + provider: SDKClientProvider ) async throws { let headers: [(String, [String])] = httpRequestData.headers.map { header in (header.name, header.values) @@ -264,6 +244,8 @@ enum HttpClientRequestProcessor { downloadStreamCreator: client.configuration.downloadStreamCreator ).get() + let bytesOrStream = BoxedStreamingData(downloadStream: response.stream, logger: client.logger) + let bindingsHandle = CallbackHandleRegistry.shared.register(bytesOrStream, scope: .ownerManaged, owner: provider) let httpResponse = Proton_Sdk_HttpResponse.with { $0.headers = response.headers.map { header in Proton_Sdk_HttpHeader.with { @@ -271,9 +253,6 @@ enum HttpClientRequestProcessor { $0.values = header.1 } } - let bytesOrStream = BoxedStreamingData(downloadStream: response.stream, logger: client.logger) - let pointer = Unmanaged.passRetained(bytesOrStream) - let bindingsHandle = Int(rawPointer: pointer.toOpaque()) $0.bindingsContentHandle = Int64(bindingsHandle) $0.statusCode = Int32(response.statusCode) } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift index 75c5a1ca..9e07779a 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift @@ -3,12 +3,11 @@ import SwiftProtobuf enum HttpClientResponseProcessor { - // statePointer is bindings content handle, + // statePointer is the registry handle for the BoxedStreamingData, // byteArray is buffer, // callbackPointer is used for calling sdk back to let it know we've filled the buffer static let cCompatibleHttpResponseRead: CCallbackWithCallbackPointer = { statePointer, byteArray, callbackPointer in - guard let bindingsContentHandle = UnsafeRawPointer(bitPattern: statePointer) - else { + guard statePointer != 0 else { let message = "cCompatibleHttpResponseRead.statePointer is null" SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer) return @@ -22,7 +21,13 @@ enum HttpClientResponseProcessor { } let bufferSize = byteArray.length - let boxedStreamingData = Unmanaged.fromOpaque(bindingsContentHandle).takeUnretainedValue() + guard let boxedStreamingData = CallbackHandleRegistry.shared.get(statePointer, as: BoxedStreamingData.self) else { + SDKResponseHandler.sendInteropErrorToSDK( + message: "cCompatibleHttpResponseRead: BoxedStreamingData not found in registry (handle: \(statePointer))", + callbackPointer: callbackPointer + ) + return + } if let boxedRawBuffer = boxedStreamingData.uploadBuffer { await HttpClientResponseProcessor.passResponseBytes( @@ -31,7 +36,7 @@ enum HttpClientResponseProcessor { bufferSize: bufferSize, callbackPointer: callbackPointer, releaseBox: { - _ = Unmanaged.fromOpaque(bindingsContentHandle).takeRetainedValue() + CallbackHandleRegistry.shared.remove(statePointer) } ) } else if let boxedDownloadStream = boxedStreamingData.downloadStream { @@ -41,10 +46,11 @@ enum HttpClientResponseProcessor { bufferSize: bufferSize, callbackPointer: callbackPointer, releaseBox: { - _ = Unmanaged.fromOpaque(bindingsContentHandle).takeRetainedValue() + CallbackHandleRegistry.shared.remove(statePointer) } ) } else { + CallbackHandleRegistry.shared.remove(statePointer) SDKResponseHandler.sendInteropErrorToSDK(message: "Failed to pass valid BytesOrStream", callbackPointer: callbackPointer) } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift index 28027296..36f44134 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift @@ -2,7 +2,7 @@ import Foundation /// Boxed task that can be cancelled via its memory address. /// Retained via Unmanaged until completion or cancellation. -final class BoxedCancellableTask: @unchecked Sendable { +final class BoxedCancellableTask: RegistryCancellable, @unchecked Sendable { private let lock = NSLock() private var task: Task? private var onComplete: (() -> Void)? @@ -26,10 +26,16 @@ final class BoxedCancellableTask: @unchecked Sendable { completionHandler?() } - func setCompletionHandler(_ handler: @escaping () -> Void) { + fileprivate func setCompletionHandler(_ handler: @escaping () -> Void) { lock.lock() - defer { lock.unlock() } + if task == nil { + // Task already completed/cancelled before the handler was set. + lock.unlock() + handler() + return + } onComplete = handler + lock.unlock() } func cancel() { @@ -44,4 +50,29 @@ final class BoxedCancellableTask: @unchecked Sendable { // Call completion handler since we're done with this task box (to release it) completionHandler?() } + + /// Creates a task that auto-registers in the shared registry and auto-removes on completion or cancellation. + static func registered( + work: @escaping @Sendable () async -> Void + ) -> RegistryHandle { + let (_, handleId) = CallbackHandleRegistry.shared.registerTask(work: work) + return handleId + } +} + +extension CallbackHandleRegistry { + /// Registers a cancellable task that auto-removes itself on completion or cancellation. + /// + /// This is the preferred way to register short-lived async work. It wires up the + /// cleanup handler so callers don't need to coordinate `register` / `remove` manually. + func registerTask( + work: @escaping @Sendable () async -> Void + ) -> (BoxedCancellableTask, RegistryHandle) { + let taskBox = BoxedCancellableTask(work: work) + let handleId = register(taskBox, scope: .operation) + taskBox.setCompletionHandler { + self.remove(handleId) + } + return (taskBox, handleId) + } } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 176369d3..fafd14f0 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -7,6 +7,7 @@ import SwiftProtobuf public actor ProtonDriveClient: Sendable, ProtonSDKClient { private var clientHandle: ObjectHandle = 0 + nonisolated(unsafe) var sdkClientProvider: SDKClientProvider! private var uploadsManager: UploadsManager! private var downloadsManager: DownloadsManager! @@ -95,9 +96,13 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { // we pass the weak reference as the state because we don't want the interop layer // to prolong the client object existence - let provider = SDKClientProvider(client: self) + // owner is nil: the client creation callback must outlive the client because C# may + // invoke secondary callbacks (log, telemetry, etc.) during teardown of operations that + // race with the client's deinit. SDKClientProvider.client is weak, so callbacks bail + // out safely once the client is gone; the small leak of the box is acceptable. + self.sdkClientProvider = SDKClientProvider(client: self) let handle: Proton_Drive_Sdk_DriveClientCreateRequest.CallResultType = try await SDKRequestHandler.sendInteropRequest( - clientCreateRequest, state: provider, includesLongLivedCallback: true, logger: logger + clientCreateRequest, state: sdkClientProvider, scope: .indefinite, owner: nil, logger: logger ) assert(handle != 0) self.clientHandle = ObjectHandle(handle) @@ -317,6 +322,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { } deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: sdkClientProvider) guard clientHandle != 0 else { return } Self.freeProtonDriveClient(Int64(clientHandle), logger) } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 3c7d116c..15c568d8 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -3,6 +3,7 @@ import Foundation public actor ProtonPhotosClient: Sendable, ProtonSDKClient { private var clientHandle: ObjectHandle = 0 + nonisolated(unsafe) var sdkClientProvider: SDKClientProvider! private var downloadsManager: PhotoDownloadsManager! private var uploadManager: PhotoUploadsManager! private var thumbnailsManager: DownloadThumbnailsManager! @@ -67,12 +68,17 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { // we pass the weak reference as the state because we don't want the interop layer // to prolong the client object existence - let provider = SDKClientProvider(client: self) + // owner is nil: the client creation callback must outlive the client because C# may + // invoke secondary callbacks (log, telemetry, etc.) during teardown of operations that + // race with the client's deinit. SDKClientProvider.client is weak, so callbacks bail + // out safely once the client is gone; the small leak of the box is acceptable. + self.sdkClientProvider = SDKClientProvider(client: self) let handle: Proton_Drive_Sdk_DrivePhotosClientCreateRequest.CallResultType = try await SDKRequestHandler .sendInteropRequest( clientCreateRequest, - state: provider, - includesLongLivedCallback: true, + state: sdkClientProvider, + scope: .indefinite, + owner: nil, logger: logger ) @@ -86,6 +92,7 @@ public actor ProtonPhotosClient: Sendable, ProtonSDKClient { } deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: sdkClientProvider) guard clientHandle != 0 else { return } Self.freeProtonPhotosClient(Int64(clientHandle), logger) } diff --git a/swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift b/swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift index 01606582..942bfb22 100644 --- a/swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift +++ b/swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift @@ -1,6 +1,6 @@ import Foundation -final class SDKClientProvider { +final class SDKClientProvider: @unchecked Sendable { private weak var client: (any ProtonSDKClient)? init(client: any ProtonSDKClient) { diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift index e1cbdb83..d8ed02c5 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift @@ -44,7 +44,8 @@ actor DownloadsManager { let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( downloaderRequest, state: WeakReference(value: callbackState), - includesLongLivedCallback: true, + scope: .ownerManaged, + owner: callbackState, logger: logger ) @@ -97,7 +98,8 @@ actor DownloadsManager { let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( downloaderRequest, state: WeakReference(value: callbackState), - includesLongLivedCallback: true, + scope: .ownerManaged, + owner: callbackState, logger: logger ) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift index 6011bcd2..d0492797 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift @@ -44,7 +44,8 @@ actor PhotoDownloadsManager { let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( downloaderRequest, state: WeakReference(value: callbackState), - includesLongLivedCallback: true, + scope: .ownerManaged, + owner: callbackState, logger: logger ) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift index 9f1d4c67..e2bda42b 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift @@ -107,7 +107,8 @@ actor PhotoUploadsManager { let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( uploaderRequest, state: WeakReference(value: uploadOperationState), - includesLongLivedCallback: true, + scope: .ownerManaged, + owner: uploadOperationState, logger: logger ) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift index 195884b6..d17b57e8 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift @@ -238,6 +238,10 @@ final class UploadOperationState: Sendable { self.callback = callback self.expectedSHA1 = expectedSHA1 } + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: self) + } } let cExpectedSha1CallbackForUpload: CCallbackWithByteArrayReturn = { statePointer in diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift index bc147190..4af5afa6 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift @@ -208,7 +208,8 @@ extension UploadsManager { let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( uploaderRequest, state: WeakReference(value: uploadOperationState), - includesLongLivedCallback: true, + scope: .ownerManaged, + owner: uploadOperationState, logger: logger ) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift index 64c4e24d..c660a66a 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift @@ -13,11 +13,12 @@ extension Resumable where ReturnType == Void { } // Boxed completion -final class BoxedCompletionBlock: Resumable { +final class BoxedCompletionBlock: RegistryTracking, Resumable { typealias CompletionBlock = (Result) -> Void private var completionBlock: CompletionBlock? let state: StateType + var registryHandleId: RegistryHandle? init(_ completionBlock: CompletionBlock?, state: StateType) { self.completionBlock = completionBlock diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift b/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift new file mode 100644 index 00000000..739fc416 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift @@ -0,0 +1,121 @@ +import Foundation + +/// Distinguishes registry-issued identifiers from raw memory addresses at the API level. +typealias RegistryHandle = Int + +protocol RegistryCancellable: AnyObject { + func cancel() +} + +/// Adopted by types whose extra lifetime reference is managed by `CallbackHandleRegistry`. +/// The response callback checks this after `takeRetainedValue()` to release the registry entry. +protocol RegistryTracking: AnyObject { + var registryHandleId: RegistryHandle? { get set } +} + +enum CallbackScope: Equatable { + /// Callback that finishes within a discrete operation (e.g. a single request/response). + /// The registry entry is removed automatically when the response callback fires. + case operation + + /// Callback whose lifetime is tied to a specific owner object. + /// The registry entry survives the response callback and is cleaned up when the owner + /// calls `removeAll(ownedBy:)` in its `deinit`. + case ownerManaged + + /// Callback that intentionally outlives every owner (e.g. client-creation state that must + /// stay alive for secondary C# callbacks during teardown). Not cleaned up by any owner. + case indefinite +} + +/// Thread-safe registry that manages object lifetimes across the Swift/C# interop boundary. +/// +/// Instead of passing raw `Unmanaged` pointers to C# (which can become dangling when Swift frees +/// the object), callers register objects here and pass the integer ID. Both sides of the boundary +/// interact through the ID — looking up, removing, or ignoring missing entries safely. +/// +/// Typical patterns: +/// - **Cancellable tasks:** `register` on creation, `remove` on natural completion, +/// `remove` + `cancel()` on cancellation. Only one side gets the object. +/// - **Long-lived state:** `register` on setup, `get` on each callback, `remove` on teardown. +final class CallbackHandleRegistry: @unchecked Sendable { + static let shared = CallbackHandleRegistry() + + private let lock = NSLock() + private var nextId: RegistryHandle = 1 + private var entries: [RegistryHandle: Entry] = [:] + + private struct Entry { + let object: AnyObject + let scope: CallbackScope + weak var owner: AnyObject? + } + + func register(_ object: AnyObject, scope: CallbackScope = .operation, owner: AnyObject? = nil) -> RegistryHandle { + switch scope { + case .ownerManaged where owner == nil: + assertionFailure("ownerManaged scope requires a non-nil owner") + case .operation where owner != nil, + .indefinite where owner != nil: + assertionFailure("\(scope) scope should not have an owner") + default: + break + } + + lock.lock() + let id = nextId + nextId += 1 + entries[id] = Entry(object: object, scope: scope, owner: owner) + lock.unlock() + return id + } + + /// Removes and returns the entry. Returns nil if already removed. + @discardableResult + func remove(_ id: RegistryHandle) -> AnyObject? { + lock.lock() + let object = entries.removeValue(forKey: id)?.object + lock.unlock() + return object + } + + /// Looks up without removing. Returns nil if the entry doesn't exist or isn't the expected type. + func get(_ id: RegistryHandle, as type: T.Type = T.self) -> T? { + lock.lock() + let object = entries[id]?.object as? T + lock.unlock() + return object + } + + /// Returns whether an entry with the given ID exists. + func contains(_ id: RegistryHandle) -> Bool { + lock.lock() + let result = entries[id] != nil + lock.unlock() + return result + } + + /// Returns the scope of the entry with the given ID, or nil if not found. + func scope(for id: RegistryHandle) -> CallbackScope? { + lock.lock() + let scope = entries[id]?.scope + lock.unlock() + return scope + } + + /// Removes the entry and cancels it if it conforms to `RegistryCancellable`. + func cancel(_ id: RegistryHandle) { + (remove(id) as? RegistryCancellable)?.cancel() + } + + /// Removes all entries owned by the given owner without cancelling them. + func removeAll(ownedBy owner: AnyObject) { + lock.lock() + let keysToRemove = entries.filter { $0.value.owner === owner }.map { $0.key } + for key in keysToRemove { + entries.removeValue(forKey: key) + } + lock.unlock() + } + +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift index b62981fb..4afb5f96 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -7,6 +7,10 @@ final class ProgressCallbackWrapper: Sendable { init(callback: @escaping ProgressCallback) { self.callback = callback } + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: self) + } } let cProgressCallbackForUpload: CCallback = { statePointer, byteArray in diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index e01c5d48..835ad066 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -29,35 +29,36 @@ enum SDKRequestHandler { static func send( _ request: T, logger: Logger?, - includesLongLivedCallback: Bool = false, + scope: CallbackScope = .operation, + owner: AnyObject? = nil, completionBlock: @escaping (Result) -> Void ) { - send(request, state: (), logger: logger, includesLongLivedCallback: includesLongLivedCallback, completionBlock: completionBlock) + send(request, state: (), logger: logger, scope: scope, owner: owner, completionBlock: completionBlock) } // MARK: - Requests with additional state - // `includesLongLivedCallback` property is used to know whether we need keep the box for state alive longer - // than just until this method finished /// Async/await API for request with state for types with the generics documented via InteropRequest protocol. static func sendInteropRequest( _ request: T, state: T.StateType, - includesLongLivedCallback: Bool = false, + scope: CallbackScope = .operation, + owner: AnyObject? = nil, logger: Logger? ) async throws -> T.CallResultType { - try await send(request, state: state, includesLongLivedCallback: includesLongLivedCallback, logger: logger) + try await send(request, state: state, scope: scope, owner: owner, logger: logger) } /// Async/await API for requests with state static func send( _ request: T, state: V, - includesLongLivedCallback: Bool = false, + scope: CallbackScope = .operation, + owner: AnyObject? = nil, logger: Logger? ) async throws -> U { try await withCheckedThrowingContinuation { continuation in - send(request, state: state, logger: logger, includesLongLivedCallback: includesLongLivedCallback) { (result: Result) in + send(request, state: state, logger: logger, scope: scope, owner: owner) { (result: Result) in switch result { case .success(let response): continuation.resume(returning: response) @@ -73,7 +74,8 @@ enum SDKRequestHandler { _ request: T, state: V, logger: Logger?, - includesLongLivedCallback: Bool = false, + scope: CallbackScope = .operation, + owner: AnyObject? = nil, completionBlock: @escaping (Result) -> Void ) { do { @@ -93,12 +95,7 @@ enum SDKRequestHandler { // Switch to InteropTypes.BoxedStateType once we use it for all requests let boxedState = BoxedCompletionBlock(completionBlock, state: state) let pointer = Unmanaged.passRetained(boxedState) - if includesLongLivedCallback { - // We double-retain to keep the box alive after the method finishes. - // Currently, the reference to the box will not be kept anywhere, - // so the deallocation must be done in the long-lived callback. Improve if necessary. - _ = pointer.retain() // fixes "result of call to 'retain()' is unused" warning - } + boxedState.registryHandleId = CallbackHandleRegistry.shared.register(boxedState, scope: scope, owner: owner) let bindingsHandle = Int(rawPointer: pointer.toOpaque()) if isDriveRequest { logger?.trace(" -> proton_drive_sdk_handle_request", category: "SDKRequestHandler") @@ -115,9 +112,23 @@ enum SDKRequestHandler { /// C-compatible callback function for SDK responses. let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in - guard let sdkPointer = UnsafeRawPointer(bitPattern: statePointer), - let box = Unmanaged.fromOpaque(sdkPointer).takeRetainedValue() as? any Resumable - else { + guard let sdkPointer = UnsafeRawPointer(bitPattern: statePointer) else { + assertionFailure("If the pointer is not Resumable, we cannot get the continuation") + return + } + + let rawBox = Unmanaged.fromOpaque(sdkPointer).takeRetainedValue() + + // Release the registry reference for operation-scoped entries only. + // ownerManaged entries are cleaned up by the owner's deinit via removeAll(ownedBy:). + // indefinite entries intentionally outlive every owner. + if let managed = rawBox as? RegistryTracking, let handleId = managed.registryHandleId { + if CallbackHandleRegistry.shared.scope(for: handleId) == .operation { + CallbackHandleRegistry.shared.remove(handleId) + } + } + + guard let box = rawBox as? any Resumable else { assertionFailure("If the pointer is not Resumable, we cannot get the continuation") return } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift index 519121fa..fe1aca96 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift @@ -16,6 +16,10 @@ final class StreamDownloadState: @unchecked Sendable { self.progressCallback = progressCallback } + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: self) + } + func markReady() { let buffered: [FileOperationProgress] lock.lock() @@ -69,8 +73,7 @@ let cStreamWriteCallback: CCallbackWithCallbackPointerAndObjectPointerReturn = { // Capture data before entering the task let data = Data(byteArray: byteArray) - // Create a boxed task with the write work - let taskBox = BoxedCancellableTask { + return BoxedCancellableTask.registered { do { try state.outputStream.write(data) SDKResponseHandler.sendVoidResponse(callbackPointer: callbackPointer) @@ -85,17 +88,6 @@ let cStreamWriteCallback: CCallbackWithCallbackPointerAndObjectPointerReturn = { } } } - - // Retain the task box and return its address as the cancellation handle - let unmanaged = Unmanaged.passRetained(taskBox) - let handle = Int(bitPattern: unmanaged.toOpaque()) - - // Set completion handler to release the Unmanaged reference when done - taskBox.setCompletionHandler { - unmanaged.release() - } - - return handle } /// C-compatible callback for seeking in the output stream. @@ -127,8 +119,7 @@ let cStreamSeekCallback: CCallbackWithCallbackPointerAndObjectPointerReturn = { let seekRequest = Proton_Sdk_StreamSeekRequest(byteArray: byteArray) let origin = SeekOrigin(rawValue: seekRequest.origin) ?? .begin - // Create a boxed task with the seek work - let taskBox = BoxedCancellableTask { + return BoxedCancellableTask.registered { do { let newPosition = try state.outputStream.seek(offset: seekRequest.offset, origin: origin) let int64Value = Google_Protobuf_Int64Value.with { $0.value = newPosition } @@ -144,17 +135,6 @@ let cStreamSeekCallback: CCallbackWithCallbackPointerAndObjectPointerReturn = { } } } - - // Retain the task box and return its address as the cancellation handle - let unmanaged = Unmanaged.passRetained(taskBox) - let handle = Int(bitPattern: unmanaged.toOpaque()) - - // Set completion handler to release the Unmanaged reference when done - taskBox.setCompletionHandler { - unmanaged.release() - } - - return handle } /// C-compatible callback for progress updates during stream download. @@ -178,19 +158,6 @@ let cStreamProgressCallback: CCallback = { statePointer, byteArray in /// C-compatible callback for cancelling the stream operation. /// The SDK calls this with the operation handle returned from write/seek callbacks. -let cStreamCancelCallback: CCallbackWithoutByteArray = { operationHandle in - // If operationHandle is 0, it means we've early returned from the callback - guard operationHandle != 0 else { return } - - // Convert the address back to the task box - guard let pointer = UnsafeRawPointer(bitPattern: operationHandle) else { - assertionFailure("cStreamCancelCallback.operationHandle is nil") - return - } - - // Get the task box and cancel it - let unmanaged = Unmanaged.fromOpaque(pointer) - let taskBox = unmanaged.takeUnretainedValue() - // Release of the task box is wrapped in completionHandler, which is called in cancel() - taskBox.cancel() +let cStreamCancelCallback: CCallbackWithoutByteArray = { callbackHandle in + CallbackHandleRegistry.shared.cancel(callbackHandle) } From f060d40b0b46099e0aa01b3fd7a9bae52cddb7e4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 10:26:52 +0000 Subject: [PATCH 531/791] Only set AEAD flag on file key creation --- cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs | 5 +---- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs | 3 +-- .../Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs | 4 +++- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 7af41c05..7cd7854e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -73,15 +73,12 @@ public static async ValueTask CreateAsync( var hashKey = CryptoGenerator.GenerateFolderHashKey(); - var useAeadFeatureFlag = await client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken) - .ConfigureAwait(false); - NodeOperations.GetCommonCreationParameters( name, parentSecrets.Key, parentSecrets.HashKey.Span, signingKey, - useAeadFeatureFlag, + PgpProfile.Proton, out var key, out var nameSessionKey, out var passphraseSessionKey, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 3cdb3c12..e3a2db17 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -114,7 +114,7 @@ public static void GetCommonCreationParameters( PgpPrivateKey parentFolderKey, ReadOnlySpan parentFolderHashKey, PgpPrivateKey signingKey, - bool useAeadFeatureFlag, + PgpProfile pgpProfile, out PgpPrivateKey key, out PgpSessionKey nameSessionKey, out PgpSessionKey passphraseSessionKey, @@ -124,7 +124,6 @@ public static void GetCommonCreationParameters( out ArraySegment passphraseSignature, out ArraySegment lockedKeyBytes) { - var pgpProfile = useAeadFeatureFlag ? PgpProfile.ProtonAead : PgpProfile.Proton; key = PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default, profile: pgpProfile); nameSessionKey = PgpSessionKey.Generate(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 695fe40a..f04a3f2b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -72,12 +72,14 @@ private static FileCreationRequest GetFileCreationRequest( out PgpSessionKey nameSessionKey, out PgpSessionKey contentKey) { + var pgpProfile = useAeadFeatureFlag ? PgpProfile.ProtonAead : PgpProfile.Proton; + NodeOperations.GetCommonCreationParameters( name, parentSecrets.Key, parentSecrets.HashKey.Span, signingKey, - useAeadFeatureFlag, + pgpProfile, out nodeKey, out nameSessionKey, out passphraseSessionKey, From 3d5b0a8b41c18aebf556239b99e7fd75a999f6a4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 10:37:42 +0000 Subject: [PATCH 532/791] Fix tranforming CompletedDownloadManifestVerificationException to... --- cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index 09d3ef46..92b942b1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -14,6 +14,7 @@ internal static class TelemetryErrorResolver { // Reported as download success CompletedDownloadManifestVerificationException => null, + DataIntegrityException => exception.GetBaseException() is CompletedDownloadManifestVerificationException ? null : DownloadError.IntegrityError, // Download errors NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => DownloadError.IntegrityError, From 48a41dd31a21e8a59dc52a1b358ff53437e14f9b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 15:20:37 +0100 Subject: [PATCH 533/791] Capture caller stack trace in ResponseCallback --- .../sdk/internal/BaseContinuationResponse.kt | 40 ++++++++++++++++++ .../drive/sdk/internal/CallerException.kt | 3 ++ .../ContinuationUnitOrErrorResponse.kt | 36 ++++------------ .../ContinuationValueOrErrorResponse.kt | 42 +++++++------------ .../ContinuationValueOrNullResponse.kt | 42 ++++++------------- .../sdk/internal/JniBaseProtonDriveSdk.kt | 4 +- .../drive/sdk/internal/JniBaseProtonSdk.kt | 4 +- 7 files changed, 85 insertions(+), 86 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CallerException.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt new file mode 100644 index 00000000..fe331d88 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt @@ -0,0 +1,40 @@ +package me.proton.drive.sdk.internal + +import com.google.protobuf.kotlin.toByteString +import me.proton.drive.sdk.ProtonDriveSdkException +import proton.sdk.ProtonSdk +import java.nio.ByteBuffer +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +abstract class BaseContinuationResponse( + private val continuation: Continuation, +) : ResponseCallback { + + private val callSite = CallerException("Called from") + + protected fun parse(data: ByteBuffer, block: (ProtonSdk.Response) -> T) { + runCatching { ProtonSdk.Response.parseFrom(data) } + .recoverCatching { error -> + throw ProtonDriveSdkException( + message = "Cannot parse message: ${data.toByteString().toStringUtf8()}", + cause = error, + ) + } + .mapCatching(block) + .onSuccess(continuation::resume) + .onFailure(::resumeWithException) + } + + private fun resumeWithException(exception: Throwable) { + continuation.resumeWithException(exception.apply { + addSuppressed(callSite.apply { + // Remove the first few frames that are internal to this function + stackTrace = stackTrace.dropWhile { element -> + element.className.startsWith("me.proton.drive.sdk.internal.Jni").not() + }.toTypedArray() + }) + }) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CallerException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CallerException.kt new file mode 100644 index 00000000..f4c6dc38 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/CallerException.kt @@ -0,0 +1,3 @@ +package me.proton.drive.sdk.internal + +class CallerException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt index 6e8d9b77..8c94ad9b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt @@ -1,40 +1,22 @@ package me.proton.drive.sdk.internal -import com.google.protobuf.kotlin.toByteString -import kotlinx.coroutines.CancellableContinuation import me.proton.drive.sdk.ProtonDriveSdkException import me.proton.drive.sdk.extension.toException -import proton.sdk.ProtonSdk import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET import proton.sdk.ProtonSdk.Response.ResultCase.VALUE import java.nio.ByteBuffer -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException +import kotlin.coroutines.Continuation -@Suppress("TooGenericExceptionCaught") class ContinuationUnitOrErrorResponse( - private val deferred: CancellableContinuation, -) : ResponseCallback { - override fun invoke(data: ByteBuffer) { - try { - val parseFrom = ProtonSdk.Response.parseFrom(data) - when (parseFrom.resultCase) { - VALUE -> deferred.resumeWithException( - ProtonDriveSdkException("No response was expected but: ${parseFrom.value.typeUrl}") - ) - - RESULT_NOT_SET -> deferred.resume(Unit) - ERROR -> deferred.resumeWithException(parseFrom.error.toException()) - null -> deferred.resumeWithException(ProtonDriveSdkException("No response (null)")) - } - } catch (error: Throwable) { - deferred.resumeWithException( - ProtonDriveSdkException( - message = "Cannot parse message: ${data.toByteString().toStringUtf8()}", - cause = error, - ) - ) + continuation: Continuation, +) : BaseContinuationResponse(continuation) { + override fun invoke(data: ByteBuffer) = parse(data) { response -> + when (response.resultCase) { + VALUE -> throw ProtonDriveSdkException("No response was expected but: ${response.value.typeUrl}") + RESULT_NOT_SET -> Unit + ERROR -> throw response.error.toException() + null -> throw ProtonDriveSdkException("No response (null)") } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt index d3e8d497..b61aa7cd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt @@ -1,45 +1,31 @@ package me.proton.drive.sdk.internal -import com.google.protobuf.kotlin.toByteString -import kotlinx.coroutines.CancellableContinuation import me.proton.drive.sdk.ProtonDriveSdkException import me.proton.drive.sdk.converter.AnyConverter import me.proton.drive.sdk.extension.toException -import proton.sdk.ProtonSdk import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET import proton.sdk.ProtonSdk.Response.ResultCase.VALUE import java.nio.ByteBuffer -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException +import kotlin.coroutines.Continuation -@Suppress("TooGenericExceptionCaught") class ContinuationValueOrErrorResponse( - private val deferred: CancellableContinuation, + continuation: Continuation, private val anyConverter: AnyConverter, -) : ResponseCallback { - override fun invoke(data: ByteBuffer) { - try { - val parseFrom = ProtonSdk.Response.parseFrom(data) - when (parseFrom.resultCase) { - VALUE -> { - check(parseFrom.value.typeUrl == anyConverter.typeUrl) { - "Wrong converter for ${parseFrom.value.typeUrl} (${anyConverter.typeUrl})" - } - deferred.resume(anyConverter.convert(parseFrom.value)) - } +) : BaseContinuationResponse(continuation) { - RESULT_NOT_SET -> deferred.resumeWithException(ProtonDriveSdkException("No response (not set)")) - ERROR -> deferred.resumeWithException(parseFrom.error.toException()) - null -> deferred.resumeWithException(ProtonDriveSdkException("No response (null)")) + override fun invoke(data: ByteBuffer) = parse(data) { response -> + when (response.resultCase) { + VALUE -> { + check(response.value.typeUrl == anyConverter.typeUrl) { + "Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})" + } + anyConverter.convert(response.value) } - } catch (error: Throwable) { - deferred.resumeWithException( - ProtonDriveSdkException( - message = "Cannot parse message: ${data.toByteString().toStringUtf8()}", - cause = error, - ) - ) + + RESULT_NOT_SET -> throw ProtonDriveSdkException("No response (not set)") + ERROR -> throw response.error.toException() + null -> throw ProtonDriveSdkException("No response (null)") } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt index ac4ee87d..ffd6fd35 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt @@ -1,45 +1,29 @@ package me.proton.drive.sdk.internal -import com.google.protobuf.kotlin.toByteString -import kotlinx.coroutines.CancellableContinuation -import me.proton.drive.sdk.ProtonDriveSdkException import me.proton.drive.sdk.converter.AnyConverter import me.proton.drive.sdk.extension.toException -import proton.sdk.ProtonSdk import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET import proton.sdk.ProtonSdk.Response.ResultCase.VALUE import java.nio.ByteBuffer -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException +import kotlin.coroutines.Continuation -@Suppress("TooGenericExceptionCaught") class ContinuationValueOrNullResponse( - private val deferred: CancellableContinuation, + continuation: Continuation, private val anyConverter: AnyConverter, -) : ResponseCallback { - override fun invoke(data: ByteBuffer) { - try { - val parseFrom = ProtonSdk.Response.parseFrom(data) - when (parseFrom.resultCase) { - VALUE -> { - check(parseFrom.value.typeUrl == anyConverter.typeUrl) { - "Wrong converter for ${parseFrom.value.typeUrl} (${anyConverter.typeUrl})" - } - deferred.resume(anyConverter.convert(parseFrom.value)) +) : BaseContinuationResponse(continuation) { + override fun invoke(data: ByteBuffer) = parse(data) { response -> + when (response.resultCase) { + VALUE -> { + check(response.value.typeUrl == anyConverter.typeUrl) { + "Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})" } - - RESULT_NOT_SET -> deferred.resume(null) - ERROR -> deferred.resumeWithException(parseFrom.error.toException()) - null -> deferred.resume(null) + anyConverter.convert(response.value) } - } catch (error: Throwable) { - deferred.resumeWithException( - ProtonDriveSdkException( - message = "Cannot parse message: ${data.toByteString().toStringUtf8()}", - cause = error, - ) - ) + + RESULT_NOT_SET -> null + ERROR -> throw response.error.toException() + null -> null } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt index 525f31ca..25eaa82c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -31,10 +31,12 @@ abstract class JniBaseProtonDriveSdk : JniBase() { block: RequestKt.Dsl.() -> Unit, ): T = suspendCancellableCoroutine { continuation -> check(released.not()) { "Cannot executeOnce ${method(name)} after release" } + // Create the callback here to capture the call stack trace + val responseCallback = callback(continuation) val nativeClient = ProtonDriveSdkNativeClient( name = method(name), response = { client, buffer -> - callback(continuation).invoke(buffer) + responseCallback(buffer) client.release() clients -= client }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt index ffce2299..7c7feaab 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt @@ -26,10 +26,12 @@ abstract class JniBaseProtonSdk : JniBase() { callback: (CancellableContinuation) -> ResponseCallback, block: RequestKt.Dsl.() -> Unit, ): T = suspendCancellableCoroutine { continuation -> + // Create the callback here to capture the call stack trace + val responseCallback = callback(continuation) val nativeClient = ProtonSdkNativeClient( name = method(name), response = { client, buffer -> - callback(continuation).invoke(buffer) + responseCallback.invoke(buffer) client.release() clients -= client }, From a0e66ca1d397fd554006047b30969110d549bbad Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 12:10:20 +0100 Subject: [PATCH 534/791] Fix download of photos and their thumbnails from shared albums --- cs/Directory.Build.props | 1 + .../Api/{ => Photos}/IPhotosApiClient.cs | 9 +-- .../Api/Photos/PhotoAlbumInclusionDto.cs | 3 +- .../Proton.Drive.Sdk/Api/Photos/PhotoDto.cs | 3 +- .../Api/{ => Photos}/PhotosApiClient.cs | 3 +- .../Api/Photos/PhotosApiClients.cs | 19 ++++++ .../Api/Photos/PhotosLinksApiClient.cs | 63 +++++++++++++++++++ .../Nodes/Cryptography/NodeCrypto.cs | 2 +- .../Nodes/DtoToMetadataConverter.cs | 16 +++-- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 41 +++++++++++- .../Proton.Drive.Sdk/ProtonPhotosClient.cs | 10 ++- cs/sdk/src/Proton.Sdk.CExports/Interop.cs | 4 +- .../Caching/InMemoryCacheRepository.cs | 2 +- cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 1 + .../src/Proton.Sdk/Telemetry/NullTelemetry.cs | 2 +- 15 files changed, 156 insertions(+), 23 deletions(-) rename cs/sdk/src/Proton.Drive.Sdk/Api/{ => Photos}/IPhotosApiClient.cs (58%) rename cs/sdk/src/Proton.Drive.Sdk/Api/{ => Photos}/PhotosApiClient.cs (96%) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClients.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosLinksApiClient.cs diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index ac713987..05de4fba 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -66,6 +66,7 @@ + diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IPhotosApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs similarity index 58% rename from cs/sdk/src/Proton.Drive.Sdk/Api/IPhotosApiClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs index b3e1da7e..f7d6f36b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/IPhotosApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs @@ -1,10 +1,7 @@ -using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Api.Photos; -using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; -using Proton.Drive.Sdk.Volumes; -namespace Proton.Drive.Sdk.Api; +namespace Proton.Drive.Sdk.Api.Photos; internal interface IPhotosApiClient { @@ -13,6 +10,4 @@ internal interface IPhotosApiClient ValueTask GetRootShareAsync(CancellationToken cancellationToken); ValueTask GetTimelinePhotosAsync(TimelinePhotoListRequest request, CancellationToken cancellationToken); - - ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs index 6dc1cbb8..ff3fe974 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs @@ -12,7 +12,8 @@ internal sealed class PhotoAlbumInclusionDto [JsonPropertyName("Hash")] public required string NameHash { get; init; } - public required string ContentHash { get; init; } + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory ContentHash { get; init; } [JsonConverter(typeof(EpochSecondsJsonConverter))] [JsonPropertyName("AddedTime")] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs index 57be5206..312e5e66 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs @@ -13,7 +13,8 @@ internal sealed class PhotoDto : FileDto [JsonConverter(typeof(EpochSecondsJsonConverter))] public required DateTime CaptureTime { get; init; } - public string? ContentHash { get; init; } + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public ReadOnlyMemory? ContentHash { get; init; } [JsonPropertyName("Hash")] public string? NameHash { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/PhotosApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs similarity index 96% rename from cs/sdk/src/Proton.Drive.Sdk/Api/PhotosApiClient.cs rename to cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs index 5317fe37..2aa8d66a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/PhotosApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs @@ -1,12 +1,11 @@ using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Http; -namespace Proton.Drive.Sdk.Api; +namespace Proton.Drive.Sdk.Api.Photos; internal sealed class PhotosApiClient(HttpClient httpClient) : IPhotosApiClient { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClients.cs new file mode 100644 index 00000000..3fc6f769 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClients.cs @@ -0,0 +1,19 @@ +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Storage; +using Proton.Drive.Sdk.Api.Volumes; + +namespace Proton.Drive.Sdk.Api.Photos; + +internal sealed class PhotosApiClients(HttpClient defaultHttpClient, HttpClient storageHttpClient) : IDriveApiClients +{ + public IVolumesApiClient Volumes { get; } = new VolumesApiClient(defaultHttpClient); + public ISharesApiClient Shares { get; } = new SharesApiClient(defaultHttpClient); + public ILinksApiClient Links { get; } = new PhotosLinksApiClient(defaultHttpClient); + public IFoldersApiClient Folders { get; } = new FoldersApiClient(defaultHttpClient); + public IFilesApiClient Files { get; } = new FilesApiClient(defaultHttpClient); + public IStorageApiClient Storage { get; } = new StorageApiClient(defaultHttpClient, storageHttpClient); + public ITrashApiClient Trash { get; } = new TrashApiClient(defaultHttpClient); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosLinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosLinksApiClient.cs new file mode 100644 index 00000000..e64db819 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosLinksApiClient.cs @@ -0,0 +1,63 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.Photos; + +internal sealed class PhotosLinksApiClient(HttpClient httpClient) : ILinksApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + private readonly ILinksApiClient _driveImplementation = new LinksApiClient(httpClient); + + public async ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.LinkDetailsResponse) + .PostAsync( + $"photos/volumes/{volumeId}/links", + new LinkDetailsRequest(linkIds), + DriveApiSerializerContext.Default.LinkDetailsRequest, + cancellationToken) + .ConfigureAwait(false); + } + + public ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken) + { + return _driveImplementation.GetContextShareAsync(volumeId, linkId, cancellationToken); + } + + public ValueTask MoveAsync(VolumeId volumeId, LinkId linkId, MoveSingleLinkRequest request, CancellationToken cancellationToken) + { + return _driveImplementation.MoveAsync(volumeId, linkId, request, cancellationToken); + } + + public ValueTask MoveMultipleAsync(VolumeId volumeId, MoveMultipleLinksRequest request, CancellationToken cancellationToken) + { + return _driveImplementation.MoveMultipleAsync(volumeId, request, cancellationToken); + } + + public ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken) + { + return _driveImplementation.RenameAsync(volumeId, linkId, request, cancellationToken); + } + + public ValueTask> DeleteMultipleAsync( + VolumeId volumeId, + IEnumerable linkIds, + CancellationToken cancellationToken) + { + return _driveImplementation.DeleteMultipleAsync(volumeId, linkIds, cancellationToken); + } + + public ValueTask GetAvailableNames( + VolumeId volumeId, + LinkId folderId, + NodeNameAvailabilityRequest request, + CancellationToken cancellationToken) + { + return _driveImplementation.GetAvailableNames(volumeId, folderId, request, cancellationToken); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index f9b2f02f..81fbb9ba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -43,7 +43,7 @@ public static async ValueTask DecryptFileAsync( var linkDecryptionResult = await DecryptLinkAsync(accountClient, linkDto, parentKeyResult, cancellationToken).ConfigureAwait(false); - var nodeKey = linkDecryptionResult.NodeKey.GetValueOrDefault(); + var nodeKey = linkDecryptionResult.NodeKey.Merge(x => x, _ => default(PgpPrivateKey?)); var contentKeyDecryptionResult = DecryptContentKey( nodeKey, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 9375aef1..90cd4e2b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -20,10 +20,18 @@ public static async Task> ConvertDtoT ShareAndKey? knownShareAndKey, CancellationToken cancellationToken) { + var parentId = linkDetailsDto.Link.ParentId; + + if (parentId is null && linkDetailsDto.Photo is not null) + { + // TODO: optimize by selecting the album that is in cache, if any + parentId = linkDetailsDto.Photo.AlbumInclusions is { Count: > 0 } albumInclusions ? albumInclusions[0].Id : null; + } + var parentKeyResult = await GetParentKeyAsync( client, volumeId, - linkDetailsDto.Link.ParentId, + parentId, knownShareAndKey, linkDetailsDto.Sharing?.ShareId, cancellationToken).ConfigureAwait(false); @@ -151,7 +159,7 @@ public static async Task> ConvertDtoT CancellationToken cancellationToken) { var linkDto = linkDetailsDto.Link; - var fileDto = linkDetailsDto.File; + var fileDto = linkDetailsDto.File ?? linkDetailsDto.Photo; var membershipDto = linkDetailsDto.Membership; if (fileDto is null) @@ -294,7 +302,7 @@ public static async Task> ConvertDtoT var degradedSecrets = new DegradedFileSecrets { - Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), + Key = decryptionResult.Link.NodeKey.Merge(x => (PgpPrivateKey?)x, _ => null), PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), NameSessionKey = nameSessionKey, ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), @@ -521,7 +529,7 @@ private static async ValueTask> GetParen try { - // FIXME this could go into an infinite loop if there's a structure issue in the cache. + // FIXME: this could go into an infinite loop if there's a structure issue in the cache. while (currentId is not null) { if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 682d220c..e1559e37 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -28,6 +28,39 @@ public sealed class ProtonDriveClient /// Unique ID for this client to allow it to resume drafts across instances. /// If no UID is not provided, one will be generated for the duration of this instance. public ProtonDriveClient(ProtonApiSession session, string? uid = null) + : this( + session, + (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), + uid) + { + } + + public ProtonDriveClient( + IHttpClientFactory httpClientFactory, + IAccountClient accountClient, + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository, + IFeatureFlagProvider featureFlagProvider, + ITelemetry telemetry, + ProtonDriveClientOptions? creationParameters = null) + : this( + new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( + creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds), + new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( + creationParameters?.OverrideStorageApiTimeoutSeconds ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), + accountClient, + new DriveClientCache(entityCacheRepository, secretCacheRepository), + featureFlagProvider, + telemetry, + (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), + creationParameters?.Uid ?? Guid.NewGuid().ToString()) + { + } + + internal ProtonDriveClient( + ProtonApiSession session, + Func driveApiClientsFactory, + string? uid = null) : this( session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)), session.GetHttpClient( @@ -38,17 +71,19 @@ public ProtonDriveClient(ProtonApiSession session, string? uid = null) new DriveClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository), session.ClientConfiguration.FeatureFlagProvider, session.ClientConfiguration.Telemetry, + driveApiClientsFactory, uid ?? Guid.NewGuid().ToString()) { } - public ProtonDriveClient( + internal ProtonDriveClient( IHttpClientFactory httpClientFactory, IAccountClient accountClient, ICacheRepository entityCacheRepository, ICacheRepository secretCacheRepository, IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, + Func driveApiClientsFactory, ProtonDriveClientOptions? creationParameters = null) : this( new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( @@ -59,6 +94,7 @@ public ProtonDriveClient( new DriveClientCache(entityCacheRepository, secretCacheRepository), featureFlagProvider, telemetry, + driveApiClientsFactory, creationParameters?.Uid ?? Guid.NewGuid().ToString()) { } @@ -103,10 +139,11 @@ private ProtonDriveClient( IDriveClientCache cache, IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, + Func driveApiClientsFactory, string uid) : this( accountClient, - new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), + driveApiClientsFactory.Invoke(defaultApiHttpClient, storageApiHttpClient), cache, new BlockVerifierFactory(defaultApiHttpClient), featureFlagProvider, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index 0bbef6b9..6687979a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -1,5 +1,5 @@ using System.Diagnostics.CodeAnalysis; -using Proton.Drive.Sdk.Api; +using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Http; using Proton.Drive.Sdk.Nodes; @@ -18,7 +18,11 @@ public sealed class ProtonPhotosClient : IDisposable public ProtonPhotosClient(ProtonApiSession session, string? uid = null) { - DriveClient = new ProtonDriveClient(session, uid); + DriveClient = new ProtonDriveClient( + session, + (defaultApiHttpClient, storageApiHttpClient) => new PhotosApiClients(defaultApiHttpClient, storageApiHttpClient), + uid); + _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)); Cache = new PhotosClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository); @@ -41,7 +45,9 @@ public ProtonPhotosClient( secretCacheRepository, featureFlagProvider, telemetry, + (defaultApiHttpClient, storageApiHttpClient) => new PhotosApiClients(defaultApiHttpClient, storageApiHttpClient), creationParameters); + _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout( creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds); diff --git a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs index fc13f044..3d6152aa 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs @@ -42,7 +42,9 @@ public static T FreeHandle(long handle) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static CancellationToken GetCancellationToken(long cancellationTokenSourceHandle) { - return GetFromHandle(cancellationTokenSourceHandle).Token; + return cancellationTokenSourceHandle != 0 + ? GetFromHandle(cancellationTokenSourceHandle).Token + : CancellationToken.None; } private static T GetFromHandle(long handle, bool free) diff --git a/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs index 339724e8..8ca3c65b 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs @@ -3,7 +3,7 @@ namespace Proton.Sdk.Caching; -internal sealed class InMemoryCacheRepository : ICacheRepository, IDisposable +public sealed class InMemoryCacheRepository : ICacheRepository, IDisposable { private readonly ConcurrentDictionary _entries = new(); private readonly ConcurrentDictionary> _keyToTags = new(); diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index 7d2c1393..84f1737a 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -35,6 +35,7 @@ + diff --git a/cs/sdk/src/Proton.Sdk/Telemetry/NullTelemetry.cs b/cs/sdk/src/Proton.Sdk/Telemetry/NullTelemetry.cs index 1a38718d..4ec1dbd5 100644 --- a/cs/sdk/src/Proton.Sdk/Telemetry/NullTelemetry.cs +++ b/cs/sdk/src/Proton.Sdk/Telemetry/NullTelemetry.cs @@ -3,7 +3,7 @@ namespace Proton.Sdk.Telemetry; -internal sealed class NullTelemetry : ITelemetry +public sealed class NullTelemetry : ITelemetry { public static NullTelemetry Instance { get; } = new(); From ef86e708a76ee7b189ec6cae6c599807f6830a76 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 12:21:42 +0000 Subject: [PATCH 535/791] Update changelog for cs/v0.7.0-alpha.11 --- cs/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index c559d484..12cc9572 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## cs/v0.7.0-alpha.11 (2026-02-18) + +* Fix download of photos and their thumbnails from shared albums +* Capture caller stack trace in ResponseCallback +* Fix tranforming CompletedDownloadManifestVerificationException to... +* Only set AEAD flag on file key creation + +## cs/v0.7.0-alpha.10 (2026-02-18) + +* Introduce callback handle registry, separate callback lifecycle from object lifecycle + ## cs/v0.7.0-alpha.9 (2026-02-17) * Expose errorToString From 7f31c0f4262d27dbb0d7ad6aeb07357d7da4b678 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 10:35:30 +0100 Subject: [PATCH 536/791] Accept null content key signatures --- .../Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs | 3 ++- cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs | 2 +- .../Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs | 10 ++++++---- .../Nodes/Upload/NewFileDraftProvider.cs | 3 +-- .../Serialization/PgpArmoredBlockJsonConverterBase.cs | 3 ++- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs index 1aed5436..69700606 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs @@ -11,7 +11,8 @@ internal sealed class FileCreationRequest : NodeCreationRequest public required ReadOnlyMemory ContentKeyPacket { get; init; } - public required PgpArmoredSignature ContentKeyPacketSignature { get; init; } + [JsonPropertyName("ContentKeyPacketSignature")] + public required PgpArmoredSignature ContentKeySignature { get; init; } [JsonPropertyName("ClientUID")] public string? ClientUid { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs index c1fd10b8..e67178b5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs @@ -13,7 +13,7 @@ internal class FileDto public required ReadOnlyMemory ContentKeyPacket { get; init; } [JsonPropertyName("ContentKeyPacketSignature")] - public PgpArmoredSignature ContentKeySignature { get; init; } + public PgpArmoredSignature? ContentKeySignature { get; init; } public ActiveRevisionDto? ActiveRevision { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index 81fbb9ba..41cc26f2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -225,7 +225,7 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK private static Result, string?> DecryptContentKey( PgpPrivateKey? nodeKey, ReadOnlyMemory contentKeyPacket, - PgpArmoredSignature contentKeySignature, + PgpArmoredSignature? contentKeySignature, AuthorshipClaim nodeAuthorshipClaim) { if (nodeKey is null) @@ -248,10 +248,12 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK AuthorshipVerificationFailure? verificationFailure; try { - var verificationResult = verificationKeyRing.Verify(contentKey.Export(), contentKeySignature); + var verificationStatus = contentKeySignature is not null + ? verificationKeyRing.Verify(contentKey.Export(), contentKeySignature.Value).Status + : PgpVerificationStatus.NotSigned; - verificationFailure = verificationResult.Status is not PgpVerificationStatus.Ok - ? new AuthorshipVerificationFailure(verificationResult.Status) + verificationFailure = verificationStatus is not PgpVerificationStatus.Ok + ? new AuthorshipVerificationFailure(verificationStatus) : null; } catch (Exception e) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index f04a3f2b..d3addce4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -90,7 +90,6 @@ private static FileCreationRequest GetFileCreationRequest( out var lockedKeyBytes); contentKey = useAeadFeatureFlag ? PgpSessionKey.GenerateForAead() : PgpSessionKey.Generate(); - var contentKeyToken = contentKey.Export(); return new FileCreationRequest { @@ -104,7 +103,7 @@ private static FileCreationRequest GetFileCreationRequest( Key = lockedKeyBytes, MediaType = mediaType, ContentKeyPacket = nodeKey.EncryptSessionKey(contentKey), - ContentKeyPacketSignature = nodeKey.Sign(contentKeyToken), + ContentKeySignature = nodeKey.Sign(contentKey.Export()), }; } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs index 37cfa747..66b5e40b 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs @@ -15,7 +15,8 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial { if (reader.TokenType != JsonTokenType.String) { - throw new JsonException($"Unexpected token type '{reader.TokenType}', expected '{nameof(JsonTokenType.String)}'"); + throw new JsonException( + $"Unexpected token type '{reader.TokenType}' when converting to {typeof(T).Name}, expected '{nameof(JsonTokenType.String)}'"); } var buffer = ArrayPool.Shared.Rent(reader.ValueSpan.Length); From 924bae0d4dca08cb8cd16bd6dd35415e627e1cc2 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 13:38:00 +0100 Subject: [PATCH 537/791] Fix build failure due to old Photos project remaining in solution file --- cs/Proton.Drive.Sdk.slnx | 1 - 1 file changed, 1 deletion(-) diff --git a/cs/Proton.Drive.Sdk.slnx b/cs/Proton.Drive.Sdk.slnx index 42eec0a6..dcdc2765 100644 --- a/cs/Proton.Drive.Sdk.slnx +++ b/cs/Proton.Drive.Sdk.slnx @@ -10,7 +10,6 @@ - From 9c87ab6a00477e32604df0b171b0284e0982dae1 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 09:12:18 +0100 Subject: [PATCH 538/791] Expose Album properties --- js/sdk/src/interface/index.ts | 9 +- js/sdk/src/interface/photos.ts | 20 +- js/sdk/src/internal/apiService/driveTypes.ts | 6192 +++++++++--------- js/sdk/src/internal/photos/interface.ts | 5 +- js/sdk/src/internal/photos/nodes.test.ts | 33 +- js/sdk/src/internal/photos/nodes.ts | 30 +- js/sdk/src/transformers.ts | 2 + 7 files changed, 3031 insertions(+), 3260 deletions(-) diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 0146f869..88749fb0 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -48,7 +48,14 @@ export type { Membership, } from './nodes'; export { NodeType, MemberRole, RevisionState } from './nodes'; -export type { MaybePhotoNode, MaybeMissingPhotoNode, PhotoNode, DegradedPhotoNode, PhotoAttributes } from './photos'; +export type { + MaybePhotoNode, + MaybeMissingPhotoNode, + PhotoNode, + DegradedPhotoNode, + PhotoAttributes, + AlbumAttributes, +} from './photos'; export type { ProtonInvitation, ProtonInvitationWithNode, diff --git a/js/sdk/src/interface/photos.ts b/js/sdk/src/interface/photos.ts index 8ef6bfee..da355cc0 100644 --- a/js/sdk/src/interface/photos.ts +++ b/js/sdk/src/interface/photos.ts @@ -21,8 +21,9 @@ export type MaybeMissingPhotoNode = ResultSee values descriptions
ValueDescription
1Active
3Locked
+ * @enum {integer} + */ + VolumeState: 1 | 3; + ShareReferenceResponseDto: { + ShareID: components["schemas"]["Id"]; + ID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; + /** + * @description
See values descriptions
ValueDescription
1Regular
2Photo
3Organization
+ * @enum {integer} + */ + VolumeType: 1 | 2 | 3; + PhotoVolumeResponseDto: { + VolumeID: components["schemas"]["Id"]; + CreateTime: number; + ModifyTime: number; + /** @description Used space in bytes */ + UsedSpace: number; + DownloadedBytes: number; + UploadedBytes: number; + State: components["schemas"]["VolumeState"]; + Share: components["schemas"]["ShareReferenceResponseDto"]; + Type: components["schemas"]["VolumeType"]; + }; GetPhotoVolumeResponseDto: { Volume: components["schemas"]["PhotoVolumeResponseDto"]; /** @@ -3298,6 +3423,24 @@ export interface components { /** @description List of Name HMACs to check */ NameHashes: string[]; }; + /** + * @description

Can be null if the Link was deleted

See values descriptions
ValueDescription
0Draft
1Active
2Trashed
+ * @enum {integer} + */ + LinkState: 0 | 1 | 2; + FoundDuplicate: { + /** @description NameHash of the found duplicate */ + Hash?: string | null; + /** @description ContentHash of the found duplicate */ + ContentHash?: string | null; + LinkState: components["schemas"]["LinkState"]; + /** @description Client defined UID for the draft. Null if no ClientUID passed, or Revision was already committed. */ + ClientUID?: string | null; + /** @description LinkID, null if deleted */ + LinkID: string; + /** @description RevisionID, null if deleted */ + RevisionID: string; + }; FindDuplicatesOutputCollection: { DuplicateHashes: components["schemas"]["FoundDuplicate"][]; /** @@ -3307,6 +3450,14 @@ export interface components { */ Code: 1000; }; + PhotoTagMigrationDataDto: { + LastProcessedLinkID: components["schemas"]["Id"]; + LastProcessedCaptureTime: number; + LastMigrationTimestamp: number; + /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. + * if null, client side migration is expired (client has not checked in for > 1h), any eligible client can continue migration */ + LastClientUID?: string | null; + }; PhotoTagMigrationStatusResponseDto: { Finished: boolean; Anchor?: components["schemas"]["PhotoTagMigrationDataDto"] | null; @@ -3317,6 +3468,17 @@ export interface components { */ Code: 1000; }; + AlbumResponseDto: { + Locked: boolean; + LastActivityTime: number; + PhotoCount: number; + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + /** @default null */ + ShareID: components["schemas"]["Id"] | null; + /** @default null */ + CoverLinkID: components["schemas"]["Id"] | null; + }; ListAlbumsResponseDto: { Albums: components["schemas"]["AlbumResponseDto"][]; AnchorID?: string | null; @@ -3328,6 +3490,11 @@ export interface components { */ Code: 1000; }; + /** + * @description
See values descriptions
ValueDescription
0Favorites
1Screenshots
2Videos
3LivePhotos
4MotionPhotos
5Selfies
6Portraits
7Bursts
8Panoramas
9Raw
+ * @enum {integer} + */ + TagType: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; ListPhotosAlbumQueryParameters: { /** @default null */ AnchorID: string | null; @@ -3345,11 +3512,26 @@ export interface components { /** @default false */ IncludeTrashed: boolean; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Favorites
1Screenshots
2Videos
3LivePhotos
4MotionPhotos
5Selfies
6Portraits
7Bursts
8Panoramas
9Raw
- * @enum {integer} - */ - TagType: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + ListPhotosAlbumRelatedPhotoItemResponseDto: { + LinkID: components["schemas"]["Id"]; + CaptureTime: number; + Hash: string; + ContentHash: string; + }; + ListPhotosAlbumItemResponseDto: { + LinkID: components["schemas"]["Id"]; + CaptureTime: number; + Hash: string; + ContentHash: string; + RelatedPhotos: components["schemas"]["ListPhotosAlbumRelatedPhotoItemResponseDto"][]; + AddedTime: number; + IsChildOfAlbum: boolean; + /** + * @description Tags assigned to the photo + * @default [] + */ + Tags: number[]; + }; ListPhotosAlbumResponseDto: { Photos: components["schemas"]["ListPhotosAlbumItemResponseDto"][]; AnchorID?: string | null; @@ -3361,6 +3543,27 @@ export interface components { */ Code: 1000; }; + TransferPhotoLinkInBatchRequestDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + /** @description Current name hash before move operation. Used to prevent race conditions. */ + OriginalHash: string; + /** + * @description Optional, when transferring an Album-Link, required when transferring photos. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + }; TransferPhotoLinksRequestDto: { ParentLinkID: components["schemas"]["Id"]; Links: components["schemas"]["TransferPhotoLinkInBatchRequestDto"][]; @@ -3379,12 +3582,17 @@ export interface components { RemovePhotosFromAlbumRequestDto: { LinkIDs: components["schemas"]["Id"][]; }; + PhotoTagMigrationUpdateDto: { + LastProcessedLinkID: components["schemas"]["Id"]; + LastProcessedCaptureTime: number; + CurrentTimestamp: number; + /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. */ + ClientUID: string; + }; UpdatePhotoTagMigrationStatusRequestDto: { Finished: boolean; Anchor: components["schemas"]["PhotoTagMigrationUpdateDto"]; }; - /** @description An encrypted ID */ - Id: string; SharedWithMeResponseDto: { Albums: components["schemas"]["AlbumResponseDto"][]; AnchorID?: string | null; @@ -3396,13 +3604,44 @@ export interface components { */ Code: 1000; }; + AlbumLinkUpdateDto: { + Name?: components["schemas"]["PGPMessage"] | null; + Hash?: string | null; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + NameSignatureEmail?: string | null; + OriginalHash?: string | null; + /** @description Extended attributes encrypted with link key */ + XAttr?: string | null; + }; UpdateAlbumRequestDto: { CoverLinkID?: components["schemas"]["Id"] | null; Link?: components["schemas"]["AlbumLinkUpdateDto"] | null; }; + BookmarkShareURLRequestDto: { + EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + AddressID: components["schemas"]["AddressID"]; + AddressKeyID: components["schemas"]["Id"]; + }; CreateBookmarkShareURLRequestDto: { BookmarkShareURL: components["schemas"]["BookmarkShareURLRequestDto"]; }; + /** + * @description
See values descriptions
ValueDescription
1Active
3Deleted
+ * @enum {integer} + */ + BookmarkShareURLState: 1 | 3; + BookmarkShareURLResponseDto: { + UserID: components["schemas"]["Id"]; + Token: string; + ShareURLID: components["schemas"]["Id"]; + EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + State: components["schemas"]["BookmarkShareURLState"]; + CreateTime: number; + ModifyTime: number; + }; CreateBookmarkShareURLResponseDto: { BookmarkShareURL: components["schemas"]["BookmarkShareURLResponseDto"]; /** @@ -3412,20 +3651,124 @@ export interface components { */ Code: 1000; }; - ListBookmarksOfUserResponseDto: { - Bookmarks: components["schemas"]["BookmarkShareURLInfoResponseDto"][]; + /** + * @description

Types: Folder - 1, File - 2}

See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType: 1 | 2 | 3; + /** @description Base64 encoded binary data */ + BinaryString: string; + ThumbnailURLInfoResponseDto: { /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @deprecated + * @description Download URL for the thumbnail */ - Code: 1000; - }; - CreateDeviceRequestDto: { - Device: components["schemas"]["DeviceDataDto"]; + URL?: string | null; + /** @description Bare Download URL for the thumbnail */ + BareURL?: string | null; + /** @description Token for the thumbnail URL */ + Token?: string | null; + }; + TokenResponseDto: { + /** + * @description Url Token + * @example YTZZRH7DA8 + */ + Token: string; + LinkType: components["schemas"]["NodeType"]; + VolumeID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + SharePasswordSalt: components["schemas"]["BinaryString"]; + SharePassphrase: components["schemas"]["PGPMessage"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + Name: components["schemas"]["PGPMessage"]; + /** @description Base64 encoded content key packet. Null for folders */ + ContentKeyPacket?: components["schemas"]["BinaryString"] | null; + /** @example text/plain */ + MIMEType: string; + /** + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * + * @enum {integer} + */ + Permissions: 4 | 6; + /** @description File size, null for folders */ + Size?: number | null; + /** @description File properties */ + ThumbnailURLInfo?: components["schemas"]["ThumbnailURLInfoResponseDto"] | null; + /** @default null */ + NodeHashKey: components["schemas"]["PGPMessage"] | null; + /** + * @description Signature email of the share owner. Only set for a ShareURL with read+write permissions. + * @default null + */ + SignatureEmail: string | null; + /** + * @description Only set for a ShareURL with read+write permissions. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + }; + BookmarkShareURLInfoResponseDto: { + EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + CreateTime: number; + Token: components["schemas"]["TokenResponseDto"]; + }; + ListBookmarksOfUserResponseDto: { + Bookmarks: components["schemas"]["BookmarkShareURLInfoResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @description
See values descriptions
ValueDescription
0Disabled
1Enabled
+ * @enum {integer} + */ + DeviceSyncState: 0 | 1; + /** + * @description
See values descriptions
ValueDescription
1Windows
2MacOS
3Linux
+ * @enum {integer} + */ + DeviceType: 1 | 2 | 3; + DeviceDataDto: { + SyncState: components["schemas"]["DeviceSyncState"]; + Type: components["schemas"]["DeviceType"]; + /** + * @deprecated + * @default null + */ + VolumeID: components["schemas"]["Id"] | null; + }; + ShareDataDto2: { + AddressID: components["schemas"]["AddressID"]; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ + AddressKeyID: string; + /** + * @deprecated + * @default null + */ + Name: string | null; + }; + CreateDeviceRequestDto: { + Device: components["schemas"]["DeviceDataDto"]; Share: components["schemas"]["ShareDataDto2"]; Link: components["schemas"]["LinkDataDto"]; }; + DeviceResponseDto: { + DeviceID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; CreateDeviceResponseDto: { Device: components["schemas"]["DeviceResponseDto"]; /** @@ -3435,6 +3778,31 @@ export interface components { */ Code: 1000; }; + DeviceDataDto2: { + DeviceID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + SyncState: components["schemas"]["DeviceSyncState"]; + Type: components["schemas"]["DeviceType"]; + /** @description UNIX timestamp when the Device got last synced */ + LastSyncTime?: number | null; + CreateTime: number; + ModifyTime: number; + /** + * @deprecated + * @description Deprecated: use `CreateTime` + */ + CreationTime: number; + }; + ShareDataDto3: { + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + /** @deprecated */ + Name: string; + }; + DeviceResponseDto2: { + Device: components["schemas"]["DeviceDataDto2"]; + Share: components["schemas"]["ShareDataDto3"]; + }; ListDevicesResponseDto: { Devices: components["schemas"]["DeviceResponseDto2"][]; /** @@ -3444,6 +3812,17 @@ export interface components { */ Code: 1000; }; + DeviceDto: { + DeviceID: components["schemas"]["Id"]; + CreateTime: number; + ModifyTime: number; + Type: components["schemas"]["DeviceType"]; + }; + DeviceResponseDto3: { + Device: components["schemas"]["DeviceDto"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; ListDevicesResponseDto2: { Devices: components["schemas"]["DeviceResponseDto3"][]; /** @@ -3453,15 +3832,36 @@ export interface components { */ Code: 1000; }; + DeviceDataDto3: { + /** @default null */ + SyncState: components["schemas"]["DeviceSyncState"] | null; + /** + * @description UNIX timestamp when the Device got last synced. Optional + * @default null + */ + LastSyncTime: number | null; + }; + ShareDataDto4: { + /** + * @deprecated + * @default null + */ + Name: string | null; + }; UpdateDeviceRequestDto: { /** @default null */ - Device: components["schemas"]["DeviceDataDto2"] | null; + Device: components["schemas"]["DeviceDataDto3"] | null; /** * @deprecated * @default null */ - Share: components["schemas"]["ShareDataDto3"] | null; + Share: components["schemas"]["ShareDataDto4"] | null; }; + /** + * @description

Document=1, Sheet=2

See values descriptions
ValueDescription
1Document
2Sheet
+ * @enum {integer} + */ + DocumentType: 1 | 2; CreateDocumentDto: { ContentKeyPacket: components["schemas"]["BinaryString"]; ManifestSignature: components["schemas"]["PGPSignature"]; @@ -3485,6 +3885,11 @@ export interface components { */ SignatureAddress: components["schemas"]["AddressEmail"] | null; }; + DocumentDetailsDto: { + VolumeID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + }; CreateDocumentResponseDto: { Document: components["schemas"]["DocumentDetailsDto"]; /** @@ -3495,7 +3900,7 @@ export interface components { Code: 1000; }; LatestEventIDResponseDto: { - EventID: components["schemas"]["Id2"]; + EventID: components["schemas"]["Id"]; /** * ProtonResponseCode * @example 1000 @@ -3503,416 +3908,440 @@ export interface components { */ Code: 1000; }; - ListEventsResponseDto: { - Events: components["schemas"]["EventResponseDto"][]; - /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ - EventID: string; - /** - * @description 1 if there is more to pull, i.e. there are more events than returned in one call - * @enum {integer} - */ - More: 0 | 1; - /** - * @description 1 if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync - * @enum {integer} - */ - Refresh: 0 | 1; + /** + * @description
See values descriptions
ValueDescription
0Delete
1Create
2Update
3UpdateMetadata
+ * @enum {integer} + */ + EventType: 0 | 1 | 2 | 3; + /** Thumbnail */ + ThumbnailTransformer: { + ThumbnailID: string; + /** @enum {integer} */ + Type: 1 | 2 | 3; + /** @description Base64 encoded thumbnail-content-hash */ + Hash: string; + Size: number; + }; + /** Photo */ + PhotoTransformer: { + LinkID: string; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + MainPhotoLinkID: string | null; + /** @description File name hash */ + Hash: string; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead */ - Code: 1000; + Exif?: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash: string | null; + /** @description LinkIDs of related Photos if there are any */ + RelatedPhotosLinkIDs: string[]; }; - ListEventsV2ResponseDto: { - Events: components["schemas"]["EventV2ResponseDto"][]; - /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ - EventID: string; - /** @description true if there is more to pull, i.e. there are more events than returned in one call */ - More: boolean; - /** @description true if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync */ - Refresh: boolean; + /** Link */ + LinkTransformer: { + LinkID: string; + ParentLinkID: string | null; + VolumeID: string; /** - * ProtonResponseCode - * @example 1000 + * @description Node type (1=folder, 2=file) * @enum {integer} */ - Code: 1000; - }; - CreateFolderRequestDto: { - /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; + Type: 1 | 2; /** - * @description Extended attributes encrypted with link key - * @default null + * @description Link name + * @example ----BEGIN PGP MESSAGE----... */ - XAttr: string | null; - Name: components["schemas"]["PGPMessage"]; - /** @description File/folder name Hash */ - Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - NodeKey: components["schemas"]["PGPPrivateKey"]; + Name: string; /** * Format: email - * @description Signature email address used to sign passphrase and name - * @default null + * @description Link name signature email (signed since 1st January 2021) */ - SignatureAddress: components["schemas"]["AddressEmail"] | null; - }; - CreateFolderResponseDto: { - Folder: components["schemas"]["FolderResponseDto"]; + NameSignatureEmail: string; + /** @description Name Hash */ + Hash: string | null; /** - * ProtonResponseCode - * @example 1000 + * @description State (0=draft, 1=active, 2=trashed) * @enum {integer} */ - Code: 1000; - }; - CreateFolderRequestDto2: { - /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; + State: 0 | 1 | 2; /** - * @description Extended attributes encrypted with link key - * @default null + * @deprecated + * @description [Deprecated] ExpirationTime (always null) */ - XAttr: string | null; - Name: components["schemas"]["PGPMessage"]; - /** @description File/folder name Hash */ - Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - NodeKey: components["schemas"]["PGPPrivateKey"]; + ExpirationTime: number | null; /** - * Format: email - * @description Signature email address used to sign passphrase and name - * @default null + * @deprecated + * @description Encrypted size (for files of active revisions, better to use FileProperties > ActiveRevision > Size) */ - SignatureEmail: components["schemas"]["AddressEmail"] | null; - }; - LinkIDsRequestDto: { - LinkIDs: components["schemas"]["EncryptedId"][]; - }; - OffsetPagination: { - /** The page size */ - PageSize: number; + Size: number; + /** @description Encrypted size of Node (all active and obsolete revisions for files) */ + TotalSize: number; /** - * The page index using 0-based indexing - * @default 0 + * @description Mime type + * @example application/ms-xls */ - Page: number; - }; - ListChildrenResponseDto: { - LinkIDs: components["schemas"]["Id2"][]; - /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; - /** @description Indicates if there is a next page of results */ - More: boolean; + MIMEType: string; /** - * ProtonResponseCode - * @example 1000 + * @deprecated + * @description Always returns 1 * @enum {integer} */ - Code: 1000; - }; - CheckAvailableHashesRequestDto: { - Hashes: string[]; + Attributes: 1; /** - * @description Client UID list to filter pending drafts with. If not provided, all conflicting draft hashes will be returned in `PendingHashes` - * @default null + * @deprecated + * @description Always returns 7, read+write+execute */ - ClientUID: string[] | null; - }; - AvailableHashesResponseDto: { - AvailableHashes: string[]; - /** @description Hashes of existing drafts excluding the ones of provided ClientUIDs */ - PendingHashes: components["schemas"]["PendingHashResponseDto"][]; + Permissions: number; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Node Key + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----... */ - Code: 1000; - }; - CopyLinkRequestDto: { - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; - /** @description Name hash */ - Hash: string; - /** @description Volume ID to copy to. */ - TargetVolumeID: string; - /** @description New parent link ID to copy to. */ - TargetParentLinkID: string; + NodeKey: string; /** - * Format: email - * @description Signature email address used for signing name. + * @description Node passphrase + * @example ----BEGIN PGP MESSAGE-----... */ - NameSignatureEmail: string; + NodePassphrase: string; /** - * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. - * @default null + * @description Node passphrase signature + * @example -----BEGIN PGP SIGNATURE-----... */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + NodePassphraseSignature: string; /** * Format: email - * @description Signature email address used for the NodePassphraseSignature. - * @default null - */ - SignatureEmail: string | null; - /** - * @description Optional, except when moving a Photo-Link. - * @default null - */ - Photos: components["schemas"]["PhotosDto"] | null; - /** - * @description Only for legacy files (signed by the user). Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. - * @default null - */ - ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; - /** - * @description Only for legacy folders (signed by the user). Node hash key should be unchanged, just re-signed with the NodeKey. - * @default null - */ - NodeHashKey: string | null; - }; - CopyLinkResponseDto: { - LinkID: components["schemas"]["Id2"]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Signature email address used for passphrase, should be the user's address associated with the Share. */ - Code: 1000; - }; - FetchLinksMetadataRequestDto: { + SignatureEmail: string; /** + * Format: email * @deprecated - * @description Get thumbnail download URLs - * @default 0 - * @enum {integer} - */ - Thumbnails: 0 | 1; - LinkIDs: components["schemas"]["EncryptedId"][]; - }; - FetchLinksMetadataResponseDto: { - Links: components["schemas"]["ExtendedLinkTransformer"][]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - ListMissingHashKeyResponseDto: { - NodesWithMissingNodeHashKey: components["schemas"]["ListMissingHashKeyItemDto"][]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description [Deprecated] Signature email address used for passphrase */ - Code: 1000; + SignatureAddress: string; + /** @description Creation timestamp */ + CreateTime: number; + /** @description Last modification timestamp (on API, real modify date is stored in XAttr) */ + ModifyTime: number; + /** @description Timestamp, time at which the file was trashed, null if file is not trashed. */ + Trashed: number | null; }; - LoadLinkDetailsResponseDto: { - Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"] | components["schemas"]["AlbumDetailsDto"])[]; + /** Link */ + ExtendedLinkTransformer: components["schemas"]["LinkTransformer"] & { /** - * ProtonResponseCode - * @example 1000 + * @deprecated + * @description Shared flag. 0 => No public URL, 1 => shared with a public URL. Deprecated, use SharingDetails properties instead. * @enum {integer} */ - Code: 1000; - }; - MoveLinkBatchRequestDto: { - ParentLinkID: components["schemas"]["Id"]; - Links: components["schemas"]["MoveLinkInBatchRequestDto"][]; - /** - * Format: email - * @description Signature email address used for signing name - * @default null - */ - NameSignatureEmail: string | null; - /** - * Format: email - * @description Signature email address used for the NodePassphraseSignature. - * @default null - */ - SignatureEmail: string | null; - }; - MoveLinkRequestDto: { - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; - /** @description Name hash */ - Hash: string; - ParentLinkID: components["schemas"]["Id"]; + Shared: 0 | 1; + /** @deprecated */ + ShareUrls: { + /** @deprecated */ + ShareUrlId?: string; + ShareURLID?: string; + /** @deprecated */ + ShareID?: string; + /** @description URL Token (not always provided) */ + Token?: string; + /** + * @deprecated + * @description Expiration time timestamp of ShareURL. + */ + ExpireTime?: number; + /** @description Expiration Timestamp */ + ExpirationTime?: number; + /** @description Creation time timestamp of ShareURL. */ + CreateTime?: number; + /** + * @description Number of Accesses (by access is meant download; first block is enough to increase the counter) + * @example 1 + */ + NumAccesses?: number; + }[]; + /** @description Link sharing details, null if not shared. */ + SharingDetails: { + ShareID?: string; + /** @description Share URL linking to this file or folder */ + ShareUrl?: { + /** @deprecated */ + ShareUrlId?: string; + ShareURLID?: string; + /** @deprecated */ + ShareID?: string; + /** @description URL Token (not always provided) */ + Token?: string; + /** + * @deprecated + * @description Expiration time timestamp of ShareURL. + */ + ExpireTime?: number | null; + /** @description Expiration Timestamp */ + ExpirationTime?: number | null; + /** @description Creation time timestamp of ShareURL. */ + CreateTime?: number; + /** @description Number of Accesses (by access is meant download; first block is enough to increase the counter) */ + NumAccesses?: number; + } | null; + } | null; /** - * Format: email - * @description Signature email address used for signing name; Required when not passing `SignatureAddress` - * @default null + * @deprecated + * @description Deprecated, use `SharingDetails.ShareID` since there will only be one share per link. List of Shares related to this link. */ - NameSignatureEmail: string | null; + ShareIDs: string[]; /** - * Format: email * @deprecated - * @description [DEPRECATED] since only the name is signed, use `NameSignatureEmail`. Signature email address used for the name. - * @default null + * @description Deprecated, use `SharingDetails.ShareURLs` and count URLs. Number of Share URLs linking to this file or folder. */ - SignatureAddress: string | null; + NbUrls: number; /** - * @description Current name hash before move operation. Used to prevent race conditions. - * @default null + * @deprecated + * @description Deprecated, use `SharingDetails.ShareURLs` and count valid URLs. Number of active urls */ - OriginalHash: string | null; + ActiveUrls: number; /** * @deprecated - * @description Deprecated: Target ShareID (for move between shares/devices). Determined on the backend automatically - * @default null + * @description Deprecated, use `SharingDetails.ShareURLs` and check for valid URLs. Set if all URLs are expired. 0 => at least one valid URL, 1 => no usable URL + * @enum {integer} */ - NewShareID: components["schemas"]["Id"] | null; + UrlsExpired: 0 | 1; + /** @description Extended attributes encrypted with link key */ + XAttr: string | null; + /** @description File properties */ + FileProperties: { + /** @description Content key packet */ + ContentKeyPacket?: string; + /** @description Signature of content key packet. Should be the signature of the (plain) Session Key, signed with the Node Key. Legacy versions must be accepted and can be a signature of the encrypted ContentKeyPacket and can be signed with the NodePassphraseEmail. */ + ContentKeyPacketSignature?: string; + /** @description Active revision */ + ActiveRevision?: { + /** @description Revision ID */ + ID?: string; + /** @description Creation time (UNIX timestamp) */ + CreateTime?: number; + /** @description Size of revision (in bytes) */ + Size?: number; + /** @description Signature of the manifest, signed with SignatureEmail */ + ManifestSignature?: string; + /** + * Format: email + * @description Signature email address for blocks, XAttributes and manifest + */ + SignatureEmail?: string; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] Signature email address for blocks, XAttributes, and manifest + */ + SignatureAddress?: string; + /** + * @description State; Will always be active; 1=active + * @enum {integer} + */ + State?: 1; + /** + * @deprecated + * @description Revision has a thumbnail + * @enum {integer} + */ + Thumbnail?: 0 | 1; + /** + * @deprecated + * @description Download URL for the thumbnail block + */ + ThumbnailDownloadUrl?: string; + /** + * @deprecated + * @description Thumbnail properties + */ + ThumbnailURLInfo?: { + /** + * @deprecated + * @description Bare Download URL for the thumbnail block + */ + BareURL?: string; + /** + * @deprecated + * @description Token for the thumbnail block + */ + Token?: string; + }; + Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; + Photo?: components["schemas"]["PhotoTransformer"] | null; + }; + } | null; + FolderProperties: { + /** @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) */ + NodeHashKey?: string; + } | null; + /** @description ProtonDocument properties; optional */ + DocumentProperties?: { + /** @description Document size */ + Size?: number; + } | null; + /** @description Album properties; optional */ + AlbumProperties?: { + /** @description Is the album locked */ + Locked?: boolean; + /** @description ID of the album cover link */ + CoverLinkID?: string | null; + /** @description Last time a Photo was added to the Album */ + LastActivityTime?: number; + /** @description Amount of photos in album */ + PhotoCount?: number; + /** @description Node hash key */ + NodeHashKey?: string; + } | null; + /** @description Photo properties; optional */ + PhotoProperties?: { + /** @description A list of Albums the Photo-Link is part of */ + Albums?: { + /** @description Album Link ID */ + AlbumLinkID?: string; + /** @description NameHash in Album context (encrypted with Album-Link-NodeKey) */ + Hash?: string; + /** @description ContentHash in Album context (encrypted with Album-Link-NodeKey) */ + ContentHash?: string; + /** @description Timestamp Photo-Link was added to this album */ + AddedTime?: number; + }[]; + /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ + Tags?: number[]; + } | null; + }; + EventResponseDto: { + EventID: components["schemas"]["Id"]; + EventType: components["schemas"]["EventType"]; + /** @description Event creation timestamp */ + CreateTime: number; + Link: { + LinkID: components["schemas"]["Id"]; + } | components["schemas"]["ExtendedLinkTransformer"]; /** - * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @description The share the user has access to that is closest to the root. Delete events do not have it but other events do. * @default null */ - ContentHash: string | null; + ContextShareID: string | null; /** - * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @description If a file was moved to a different context share, this shows the old, origin share * @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + FromContextShareID: string | null; /** - * Format: email - * @description Signature email address used for the NodePassphraseSignature. + * @description Optional event data * @default null */ - SignatureEmail: string | null; + Data: { + /** @description New or updated ShareURL */ + UrlID?: string; + /** + * @deprecated + * @description Corresponding ShareURL has been deleted + */ + DeletedURLID?: string[]; + /** @description Corresponding locked volume has been restored */ + FLAG_RESTORE_COMPLETE?: string; + /** @description Restoration has failed for corresponding locked volume */ + FLAG_RESTORE_FAILED?: string; + /** + * @deprecated + * @description Revision has been restored for this LinkID + */ + FLAG_RESTORE_REVISION_COMPLETE?: string; + /** @description Parent before the move */ + FromParentLinkID?: string; + } | null; }; - MoveLinkRequestDto2: { - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; - /** @description Name hash */ - Hash: string; - ParentLinkID: components["schemas"]["Id"]; - /** @description Current name hash before move operation. Used to prevent race conditions. */ - OriginalHash: string; + ListEventsResponseDto: { + Events: components["schemas"]["EventResponseDto"][]; + /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ + EventID: string; /** - * Format: email - * @description Signature email address used for signing name + * @description 1 if there is more to pull, i.e. there are more events than returned in one call + * @enum {integer} */ - NameSignatureEmail: string; + More: 0 | 1; /** - * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] - * @default null + * @description 1 if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync + * @enum {integer} */ - ContentHash: string | null; + Refresh: 0 | 1; /** - * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. - * @default null + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + Code: 1000; + }; + EventLinkDataDto: { + LinkID: components["schemas"]["Id"]; + ParentLinkID?: components["schemas"]["Id"] | null; + IsShared: boolean; + IsTrashed: boolean; + }; + EventV2ResponseDto: { + EventID: components["schemas"]["Id"]; + EventType: components["schemas"]["EventType"]; + Link: components["schemas"]["EventLinkDataDto"]; + }; + ListEventsV2ResponseDto: { + Events: components["schemas"]["EventV2ResponseDto"][]; + /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ + EventID: string; + /** @description true if there is more to pull, i.e. there are more events than returned in one call */ + More: boolean; + /** @description true if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync */ + Refresh: boolean; /** - * Format: email - * @description Signature email address used for the NodePassphraseSignature. - * @default null + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - SignatureEmail: string | null; + Code: 1000; }; - RenameLinkRequestDto: { - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Name hash; ignored/nullable for root-links */ - Hash?: string | null; + CreateFolderRequestDto: { + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: string; /** - * Format: email - * @description Signature email address used for signing name; Required when not passing `SignatureAddress` + * @description Extended attributes encrypted with link key * @default null */ - NameSignatureEmail: string | null; + XAttr: string | null; + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; /** * Format: email - * @deprecated - * @description [DEPRECATED] since only the name is signed, use `NameSignatureEmail`. Signature email address used for the name. + * @description Signature email address used to sign passphrase and name * @default null */ - SignatureAddress: string | null; + SignatureAddress: components["schemas"]["AddressEmail"] | null; + }; + FolderResponseDto: { + ID: components["schemas"]["Id"]; + }; + CreateFolderResponseDto: { + Folder: components["schemas"]["FolderResponseDto"]; /** - * @description Current name hash before move operation. Used to prevent race conditions. - * @default null + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - OriginalHash: string | null; + Code: 1000; + }; + CreateFolderRequestDto2: { + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: string; /** - * @description MIME type, optional, only on files. - * @default null - * @example text/plain - */ - MIMEType: string | null; - }; - UpdateMissingHashKeyRequestDto: { - NodesWithMissingNodeHashKey: components["schemas"]["UpdateMissingHashKeyItemDto"][]; - }; - CommitRevisionDto: { - ManifestSignature: components["schemas"]["PGPSignature"]; - /** - * Format: email - * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. - * @default null - */ - SignatureAddress: components["schemas"]["AddressEmail"] | null; - /** - * @deprecated - * @description Unused. Was meant for shorter partial revisions. - * @default null - */ - BlockNumber: number | null; - /** - * @description Extended attributes encrypted with link key + * @description Extended attributes encrypted with link key * @default null */ XAttr: string | null; - /** @default null */ - Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; - /** - * @deprecated - * @description Ignored entirely by API. Field can be removed from request by client. - * @default null - */ - BlockList: components["schemas"]["BlockTokenDto"][] | null; - /** - * @deprecated - * @default null - */ - ThumbnailToken: string | null; - /** - * @deprecated - * @description Ignored entirely by API, revision will always be committed (made active) - * @default null - */ - State: number | null; - }; - CreateFileDto: { - /** @example text/plain */ - MIMEType: string; - ContentKeyPacket: components["schemas"]["BinaryString"]; - /** - * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. - * @default null - */ - ContentKeyPacketSignature: string | null; - /** - * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. - * @default null - */ - ClientUID: string | null; - /** - * @description Intended upload file size, future BE size validation - * @default null - */ - IntendedUploadSize: number | null; Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; @@ -3925,50 +4354,28 @@ export interface components { * @description Signature email address used to sign passphrase and name * @default null */ - SignatureAddress: components["schemas"]["AddressEmail"] | null; - }; - CreateDraftFileResponseDto: { - File: components["schemas"]["FileResponseDto"]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; + SignatureEmail: components["schemas"]["AddressEmail"] | null; }; - CreateRevisionRequestDto: { - /** @default null */ - CurrentRevisionID: components["schemas"]["Id"] | null; - /** - * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. - * @default null - */ - ClientUID: string | null; - /** - * @description Intended upload file size, future BE size validation - * @default null - */ - IntendedUploadSize: number | null; + /** @description An encrypted ID */ + EncryptedId: string; + LinkIDsRequestDto: { + LinkIDs: components["schemas"]["EncryptedId"][]; }; - GetRevisionQueryParameters: { - /** - * @description Number of blocks - * @default null - */ - PageSize: number | null; - /** - * @description Block index from which to fetch block list - * @default null - */ - FromBlockIndex: number | null; + OffsetPagination: { + /** The page size */ + PageSize: number; /** - * @description Do not generate download URLs for blocks - * @default false + * The page index using 0-based indexing + * @default 0 */ - NoBlockUrls: boolean; + Page: number; }; - ListRevisionsResponseDto: { - Revisions: components["schemas"]["RevisionResponseDto"][]; + ListChildrenResponseDto: { + LinkIDs: components["schemas"]["Id"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; /** * ProtonResponseCode * @example 1000 @@ -3976,17 +4383,24 @@ export interface components { */ Code: 1000; }; - RestoreRevisionAcceptedResponse: { + CheckAvailableHashesRequestDto: { + Hashes: string[]; /** - * ProtonResponseCode - * @example 1002 - * @enum {integer} + * @description Client UID list to filter pending drafts with. If not provided, all conflicting draft hashes will be returned in `PendingHashes` + * @default null */ - Code: 1002; + ClientUID: string[] | null; }; - VerificationData: { - VerificationCode: components["schemas"]["BinaryString2"]; - ContentKeyPacket: components["schemas"]["BinaryString2"]; + PendingHashResponseDto: { + Hash: string; + RevisionID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + ClientUID?: string | null; + }; + AvailableHashesResponseDto: { + AvailableHashes: string[]; + /** @description Hashes of existing drafts excluding the ones of provided ClientUIDs */ + PendingHashes: components["schemas"]["PendingHashResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -3994,73 +4408,87 @@ export interface components { */ Code: 1000; }; - EmptyTrashAcceptedResponse: { + RelatedPhotoDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ + ContentHash: string; + }; + PhotosDto: { + /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ + ContentHash: string; + /** @default [] */ + RelatedPhotos: components["schemas"]["RelatedPhotoDto"][]; + }; + CopyLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + /** @description Volume ID to copy to. */ + TargetVolumeID: string; + /** @description New parent link ID to copy to. */ + TargetParentLinkID: string; /** - * ProtonResponseCode - * @example 1002 - * @enum {integer} + * Format: email + * @description Signature email address used for signing name. */ - Code: 1002; - }; - VolumeTrashList: { - /** @description Trash per share */ - Trash: components["schemas"]["ShareTrashList"][]; + NameSignatureEmail: string; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null */ - Code: 1000; - }; - VolumeTrashListV2: { - TrashedLinkIDs: components["schemas"]["Id2"][]; + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * Format: email + * @description Signature email address used for the NodePassphraseSignature. + * @default null */ - Code: 1000; - }; - RequestUploadInput: { - LinkID: components["schemas"]["Id"]; - RevisionID: components["schemas"]["Id"]; - AddressID?: components["schemas"]["AddressID"] | null; - /** @default null */ - VolumeID: components["schemas"]["Id"] | null; + SignatureEmail: string | null; /** - * @deprecated - * @description Deprecated, pass VolumeID instead + * @description Optional, except when moving a Photo-Link. * @default null */ - ShareID: components["schemas"]["Id"] | null; + Photos: components["schemas"]["PhotosDto"] | null; /** - * @deprecated - * @description Request for thumbnail upload - * @default 0 + * @description Only for legacy files (signed by the user). Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @default null */ - Thumbnail: number | null; + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; /** - * @deprecated - * @description sha256 hash of thumbnail contents + * @description Only for legacy folders (signed by the user). Node hash key should be unchanged, just re-signed with the NodeKey. * @default null */ - ThumbnailHash: string | null; + NodeHashKey: string | null; + }; + CopyLinkResponseDto: { + LinkID: components["schemas"]["Id"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + FetchLinksMetadataRequestDto: { /** * @deprecated - * @description Size of thumbnail contents + * @description Get thumbnail download URLs * @default 0 + * @enum {integer} */ - ThumbnailSize: number | null; - /** @default [] */ - BlockList: components["schemas"]["RequestUploadBlockInput"][]; - /** @default [] */ - ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + Thumbnails: 0 | 1; + LinkIDs: components["schemas"]["EncryptedId"][]; }; - RequestUploadResponse: { - UploadLinks: components["schemas"]["BlockURL"][]; - /** @deprecated */ - ThumbnailLink?: components["schemas"]["ThumbnailBlockURL"] | null; - ThumbnailLinks: components["schemas"]["ThumbnailBlockURL"][]; + FetchLinksMetadataResponseDto: { + Links: components["schemas"]["ExtendedLinkTransformer"][]; /** * ProtonResponseCode * @example 1000 @@ -4068,9 +4496,13 @@ export interface components { */ Code: 1000; }; - SmallUploadResponseDto: { - LinkID: components["schemas"]["Id2"]; - RevisionID: components["schemas"]["Id2"]; + ListMissingHashKeyItemDto: { + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + }; + ListMissingHashKeyResponseDto: { + NodesWithMissingNodeHashKey: components["schemas"]["ListMissingHashKeyItemDto"][]; /** * ProtonResponseCode * @example 1000 @@ -4078,44 +4510,155 @@ export interface components { */ Code: 1000; }; - AbuseReportDto: { - /** - * @description Reported ShareURL, complete including fragment - * @example https://drive.proton.me/urls/1F9BKXYDMA#yF7d7bn01GMM - */ - ShareURL: string; - /** @enum {string} */ - AbuseCategory: "spam" | "copyright" | "child-abuse" | "stolen-data" | "malware" | "other"; - /** @description Passphrase for reported Link's Node key, unencrypted, as a string, escaped for JSON. */ - ResourcePassphrase: string; + /** + * @description
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType2: 1 | 2 | 3; + /** + * @description
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
+ * @enum {integer} + */ + LinkState2: 0 | 1 | 2; + LinkDto: { + LinkID: components["schemas"]["Id"]; + Type: components["schemas"]["NodeType2"]; + ParentLinkID?: components["schemas"]["Id"] | null; + State: components["schemas"]["LinkState2"]; + CreateTime: number; + ModifyTime: number; + TrashTime?: number | null; + Name: components["schemas"]["PGPMessage"]; + NameHash?: string | null; + NodeKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** Format: email */ + SignatureEmail?: string | null; + /** Format: email */ + NameSignatureEmail?: string | null; + /** @default null */ + DirectPermissions: number | null; + }; + PhotoDto: { + CaptureTime: number; + MainPhotoLinkID?: components["schemas"]["Id"] | null; + ContentHash?: string | null; + RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + }; + /** + * @description
See values descriptions
ValueNameDescription
1Preview512 px, max. 65536 bytes in encrypted size
2HDPreview1920 px, max. 1048576 bytes in encrypted size
3MachineLearningmax. 65536 bytes in encrypted size
+ * @enum {integer} + */ + ThumbnailType: 1 | 2 | 3; + ThumbnailDto: { + ThumbnailID: components["schemas"]["Id"]; + Type: components["schemas"]["ThumbnailType"]; + Hash: components["schemas"]["BinaryString"]; + EncryptedSize: number; + }; + ActiveRevisionDto: { + /** @deprecated */ + Photo?: components["schemas"]["PhotoDto"] | null; + RevisionID: components["schemas"]["Id"]; + CreateTime: number; + EncryptedSize: number; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + Thumbnails: components["schemas"]["ThumbnailDto"][]; + /** Format: email */ + SignatureEmail?: string | null; + }; + FileDto: { + ActiveRevision?: components["schemas"]["ActiveRevisionDto"] | null; + TotalEncryptedSize: number; + ContentKeyPacket: components["schemas"]["BinaryString"]; + MediaType?: string | null; + ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + }; + SharingDto: { + ShareID: components["schemas"]["Id"]; + ShareURLID?: components["schemas"]["Id"] | null; + }; + MembershipDto: { + ShareID: components["schemas"]["Id"]; + MembershipID: components["schemas"]["Id"]; /** - * @description Full password, including custom part, as string, escaped for JSON - * @default + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} */ - Password: string; + Permissions: 4 | 6 | 22; + InviteTime: number; + /** Format: email */ + InviterEmail: string; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + MemberSharePassphraseKeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + InviterSharePassphraseKeyPacketSignature: string; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + InviteeSharePassphraseSessionKeySignature: string; + }; + FileDetailsDto: { + Link: components["schemas"]["LinkDto"]; + File: components["schemas"]["FileDto"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto"] | null; /** - * Format: email - * @description Reporter's email if provided + * @description Will be null if the user is not a member or is the owner. * @default null */ - ReporterEmail: string | null; + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + Folder: null | null; + /** @default null */ + Album: null | null; + }; + FolderDto: { + NodeHashKey?: components["schemas"]["PGPMessage"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + }; + FolderDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Folder: components["schemas"]["FolderDto"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto"] | null; /** - * @description User message about the report. Required for copyright or leak reports. + * @description Will be null if the user is not a member or is the owner. * @default null - * @example This is malware */ - ReporterMessage: string | null; + Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ - VolumeID: components["schemas"]["Id"] | null; + File: null | null; /** @default null */ - LinkID: components["schemas"]["Id"] | null; + Album: null | null; + }; + AlbumDto: { + Hidden: boolean; + Locked: boolean; + CoverLinkID?: components["schemas"]["Id"] | null; + LastActivityTime: number; + PhotoCount: number; + NodeHashKey: components["schemas"]["PGPMessage"]; + XAttr?: components["schemas"]["PGPMessage"] | null; + }; + AlbumDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Album: components["schemas"]["AlbumDto"]; /** @default null */ - RevisionID: components["schemas"]["Id"] | null; + Sharing: components["schemas"]["SharingDto"] | null; + /** @default null */ + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + File: null | null; + /** @default null */ + Folder: null | null; }; - FreshAccountResponseDto: { - EndTime?: number | null; - /** @description Maximum available space for the free upload timer, in bytes (API allows going 10% over limit for zero-rating) */ - Quota?: number | null; + LoadLinkDetailsResponseDto: { + Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"] | components["schemas"]["AlbumDetailsDto"])[]; /** * ProtonResponseCode * @example 1000 @@ -4123,263 +4666,234 @@ export interface components { */ Code: 1000; }; - ChecklistResponseDto: { - /** @description Array of completed checklist items */ - Items: string[]; - CreatedAt?: number | null; - ExpiresAt?: number | null; - /** @description User already has reward quota */ - UserWasRewarded: boolean; - /** @description Client has displayed completed checklist */ - Seen: boolean; - /** @description Client has completed checklist */ - Completed: boolean; + MoveLinkInBatchRequestDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; /** - * Format: float - * @description Amount of storage GB completion reward + * @description Current name hash before move operation. Used to prevent race conditions. + * @default null */ - RewardInGB: number; - /** @description Checklist should be visible to user */ - Visible: boolean; + OriginalHash: string | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null */ - Code: 1000; - }; - OnboardingResponseDto: { - /** @description `true` if the user has pending/rejected invitations or user_registered external invitation */ - HasPendingInvitations: boolean; - IsFreshAccount: boolean; + ContentHash: string | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null */ - Code: 1000; + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; }; - GetEntitlementResponseDto: { - Entitlements: components["schemas"]["EntitlementsDto"]; + MoveLinkBatchRequestDto: { + ParentLinkID: components["schemas"]["Id"]; + Links: components["schemas"]["MoveLinkInBatchRequestDto"][]; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * Format: email + * @description Signature email address used for signing name + * @default null */ - Code: 1000; - }; - AddTagsRequestDto: { - Tags: components["schemas"]["TagType"][]; - }; - FavoritePhotoRequestDto: { - PhotoData?: components["schemas"]["FavoritePhotoDataDto"] | null; - }; - FavoritePhotoResponseDto: { - LinkID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - RelatedPhotos: components["schemas"]["FavoriteRelatedPhotoResponseDto"][]; + NameSignatureEmail: string | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * Format: email + * @description Signature email address used for the NodePassphraseSignature. + * @default null */ - Code: 1000; + SignatureEmail: string | null; }; - GetMigrationStatusResponseDto: { - OldVolumeID: components["schemas"]["Id2"]; - NewVolumeID?: components["schemas"]["Id2"] | null; + MoveLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * Format: email + * @description Signature email address used for signing name; Required when not passing `SignatureAddress` + * @default null */ - Code: 1000; - }; - AcceptedResponse: { + NameSignatureEmail: string | null; /** - * ProtonResponseCode - * @example 1002 - * @enum {integer} + * Format: email + * @deprecated + * @description [DEPRECATED] since only the name is signed, use `NameSignatureEmail`. Signature email address used for the name. + * @default null */ - Code: 1002; - }; - ListPhotosParameters: { - /** @default true */ - Desc: boolean; - /** @default 500 */ - PageSize: number; + SignatureAddress: string | null; /** - * @description The link ID of the last photo from the previous page when requesting secondary pages + * @description Current name hash before move operation. Used to prevent race conditions. * @default null */ - PreviousPageLastLinkID: components["schemas"]["Id"] | null; + OriginalHash: string | null; /** - * @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) + * @deprecated + * @description Deprecated: Target ShareID (for move between shares/devices). Determined on the backend automatically * @default null */ - MinimumCaptureTime: number | null; - /** @default null */ - Tag: components["schemas"]["TagType"] | null; - }; - PhotoListingResponse: { - Photos: components["schemas"]["PhotoListingItemResponse"][]; + NewShareID: components["schemas"]["Id"] | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null */ - Code: 1000; - }; - LoadPhotoVolumeLinkDetailsResponseDto: { - Links: (components["schemas"]["PhotoDetailsDto"] | components["schemas"]["PhotoAlbumDetailsDto"] | components["schemas"]["PhotoRootFolderDetailsDto"])[]; + ContentHash: string | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null */ - Code: 1000; - }; - RemoveTagsRequestDto: { - Tags: components["schemas"]["TagType"][]; - }; - UpdateXAttrRequest: { + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; /** * Format: email - * @description Signature email address used to sign XAttributes; must be the same as the current revision signatureEmail, cannot be updated + * @description Signature email address used for the NodePassphraseSignature. + * @default null */ - SignatureEmail: string; - /** @description Extended attributes encrypted with link key */ - XAttr: string; + SignatureEmail: string | null; }; - AuthShareTokenRequestDto: { - ClientEphemeral: components["schemas"]["BinaryString"]; - ClientProof: components["schemas"]["BinaryString"]; - SRPSession: components["schemas"]["BinaryString"]; + MoveLinkRequestDto2: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: string; + /** @description Name hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + /** @description Current name hash before move operation. Used to prevent race conditions. */ + OriginalHash: string; + /** + * Format: email + * @description Signature email address used for signing name + */ + NameSignatureEmail: string; + /** + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Signature email address used for the NodePassphraseSignature. + * @default null + */ + SignatureEmail: string | null; }; - AuthShareTokenResponseDto: { - /** @description Session UID */ - UID: string; - ServerProof: components["schemas"]["BinaryString2"]; - Share: components["schemas"]["AuthShareDataResponseDto"]; + RenameLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Name hash; ignored/nullable for root-links */ + Hash?: string | null; /** - * @description Session Access token (present if new session) + * Format: email + * @description Signature email address used for signing name; Required when not passing `SignatureAddress` * @default null */ - AccessToken: string; + NameSignatureEmail: string | null; /** - * @description Duration of the session in seconds (present if new session) + * Format: email + * @deprecated + * @description [DEPRECATED] since only the name is signed, use `NameSignatureEmail`. Signature email address used for the name. * @default null */ - ExpiresIn: number; + SignatureAddress: string | null; /** - * @description Type of token (present if new session) + * @description Current name hash before move operation. Used to prevent race conditions. * @default null - * @example Bearer */ - TokenType: string; + OriginalHash: string | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description MIME type, optional, only on files. + * @default null + * @example text/plain */ - Code: 1000; + MIMEType: string | null; }; - ParentEncryptedLinkIDsResponseDto: { - ParentLinkIDs: components["schemas"]["Id2"][]; + UpdateMissingHashKeyItemDto: { + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + PGPArmoredEncryptedNodeHashKey: components["schemas"]["PGPMessage"]; + }; + UpdateMissingHashKeyRequestDto: { + NodesWithMissingNodeHashKey: components["schemas"]["UpdateMissingHashKeyItemDto"][]; + }; + CommitRevisionPhotoDto: { + /** @description Photo capture timestamp */ + CaptureTime: number; + /** @description Photo content hash, lowercase hex representation of HMAC SHA256 of SHA1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ + ContentHash: string; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Main photo LinkID reference. Pass null if none. + * @default null */ - Code: 1000; - }; - InitSRPSessionResponseDto: { - Modulus: string; - ServerEphemeral: components["schemas"]["BinaryString2"]; - UrlPasswordSalt: components["schemas"]["BinaryString2"]; - SRPSession: components["schemas"]["BinaryString2"]; - Version: number; - Flags: number; - /** @deprecated */ - IsDoc: boolean; - VendorType: components["schemas"]["VendorType"]; + MainPhotoLinkID: string | null; /** - * @description Only set if the user is authenticated AND has direct access to the share already + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead * @default null */ - DirectAccess: components["schemas"]["DirectAccessResponseDto"] | null; + Exif: components["schemas"]["BinaryString"] | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description List of tags to be assigned to the photo + * @default null */ - Code: 1000; + Tags: components["schemas"]["TagType"][] | null; }; - CommitAnonymousRevisionDto: { + BlockTokenDto: { + Index: number; + Token: string; + }; + CommitRevisionDto: { ManifestSignature: components["schemas"]["PGPSignature"]; /** * Format: email * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. + * @default null */ - SignatureEmail?: components["schemas"]["AddressEmail"] | null; - /** @description Extended attributes encrypted with link key */ - XAttr: string; + SignatureAddress: components["schemas"]["AddressEmail"] | null; /** - * @description Photo attributes + * @deprecated + * @description Unused. Was meant for shorter partial revisions. + * @default null + */ + BlockNumber: number | null; + /** + * @description Extended attributes encrypted with link key * @default null */ + XAttr: string | null; + /** @default null */ Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; - }; - CreateAnonymousDocumentDto: { - Name: components["schemas"]["PGPMessage"]; - /** @description File/folder name Hash */ - Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - NodeKey: components["schemas"]["PGPPrivateKey"]; - ContentKeyPacket: components["schemas"]["BinaryString"]; - ManifestSignature: components["schemas"]["PGPSignature"]; /** - * Format: email - * @description Signature email address used to sign passphrase and name + * @deprecated + * @description Ignored entirely by API. Field can be removed from request by client. * @default null */ - SignatureEmail: components["schemas"]["AddressEmail"] | null; + BlockList: components["schemas"]["BlockTokenDto"][] | null; /** - * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @deprecated * @default null */ - ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; - DocumentType?: components["schemas"]["DocumentType"]; - }; - CreateAnonymousDocumentResponseDto: { - Document: components["schemas"]["DocumentDetailsDto"]; - AuthorizationToken: string; + ThumbnailToken: string | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @deprecated + * @description Ignored entirely by API, revision will always be committed (made active) + * @default null */ - Code: 1000; + State: number | null; }; - CreateAnonymousFileRequestDto: { - Name: components["schemas"]["PGPMessage"]; - /** @description File/folder name Hash */ - Hash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - NodeKey: components["schemas"]["PGPPrivateKey"]; + CreateFileDto: { /** @example text/plain */ MIMEType: string; ContentKeyPacket: components["schemas"]["BinaryString"]; - /** - * Format: email - * @description Signature email address used to sign passphrase and name - * @default null - */ - SignatureEmail: components["schemas"]["AddressEmail"] | null; /** * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null @@ -4395,18 +4909,6 @@ export interface components { * @default null */ IntendedUploadSize: number | null; - }; - CreateAnonymousFileResponseDto: { - File: components["schemas"]["FileResponseDto"]; - AuthorizationToken: string; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - CreateAnonymousFolderRequestDto: { Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; @@ -4414,71 +4916,121 @@ export interface components { NodePassphrase: components["schemas"]["PGPMessage"]; NodePassphraseSignature: components["schemas"]["PGPSignature"]; NodeKey: components["schemas"]["PGPPrivateKey"]; - /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; /** * Format: email * @description Signature email address used to sign passphrase and name * @default null */ - SignatureEmail: components["schemas"]["AddressEmail"] | null; - /** - * @description Extended attributes encrypted with link key - * @default null - */ - XAttr: string | null; + SignatureAddress: components["schemas"]["AddressEmail"] | null; }; - CreateAnonymousFolderResponseDto: { - Folder: components["schemas"]["FolderResponseDto"]; - AuthorizationToken: string; - /** - * ProtonResponseCode - * @example 1000 + FileResponseDto: { + ID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + ClientUID?: string | null; + }; + CreateDraftFileResponseDto: { + File: components["schemas"]["FileResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 * @enum {integer} */ Code: 1000; }; - DeleteChildrenRequestDto: { - Links: components["schemas"]["LinkWithAuthorizationTokenDto"][]; + CreateRevisionRequestDto: { + /** @default null */ + CurrentRevisionID: components["schemas"]["Id"] | null; + /** + * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. + * @default null + */ + ClientUID: string | null; + /** + * @description Intended upload file size, future BE size validation + * @default null + */ + IntendedUploadSize: number | null; }; - RenameAnonymousLinkRequestDto: { - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Name hash */ - Hash: string; - /** @description Current name hash before move operation. Used to prevent race conditions. */ - OriginalHash: string; + RevisionResponseDto: { + ID: components["schemas"]["Id"]; + }; + CreateDraftRevisionResponseDto: { + Revision: components["schemas"]["RevisionResponseDto"]; /** - * Format: email - * @description Signature email address used for signing name + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetRevisionQueryParameters: { + /** + * @description Number of blocks * @default null */ - NameSignatureEmail: string | null; + PageSize: number | null; /** - * @description MIME type, optional, only on files. + * @description Block index from which to fetch block list * @default null - * @example text/plain */ - MIMEType: string | null; - /** @default null */ - AuthorizationToken: string | null; + FromBlockIndex: number | null; + /** + * @description Do not generate download URLs for blocks + * @default false + */ + NoBlockUrls: boolean; }; - RequestAnonymousUploadRequestDto: { - LinkID: components["schemas"]["Id"]; - RevisionID: components["schemas"]["Id"]; + /** + * @description
See values descriptions
ValueDescription
0Draft
1Active
2Obsolete
+ * @enum {integer} + */ + RevisionState: 0 | 1 | 2; + ThumbnailResponseDto: { + ThumbnailID: components["schemas"]["Id"]; + Type: components["schemas"]["ThumbnailType"]; + Hash: components["schemas"]["BinaryString"]; + Size: number; + }; + RevisionResponseDto2: { + ID: components["schemas"]["Id"]; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + /** @description Size of revision (in bytes) */ + Size: number; + State: components["schemas"]["RevisionState"]; + XAttr?: components["schemas"]["PGPMessage"] | null; + /** + * @deprecated + * @description Flag stating if revision has a thumbnail + * @enum {integer} + */ + Thumbnail: 0 | 1; + /** @deprecated */ + ThumbnailHash?: components["schemas"]["BinaryString"] | null; + /** + * @deprecated + * @description Size thumbnail in bytes; 0 if no thumbnail present + */ + ThumbnailSize: number; + Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; + ClientUID?: string | null; + /** @default null */ + CreateTime: number | null; /** * Format: email - * @description Signature email address used to sign the blocks content + * @description User's email associated with the share and used to sign the manifest and block contents. * @default null */ - SignatureEmail: components["schemas"]["AddressEmail"] | null; - /** @default [] */ - BlockList: components["schemas"]["RequestUploadBlockInput"][]; - /** @default [] */ - ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + SignatureEmail: string | null; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature + * @default null + */ + SignatureAddress: string | null; }; - BootstrapShareTokenResponseDto: { - Token: components["schemas"]["TokenResponseDto"]; + ListRevisionsResponseDto: { + Revisions: components["schemas"]["RevisionResponseDto2"][]; /** * ProtonResponseCode * @example 1000 @@ -4486,27 +5038,17 @@ export interface components { */ Code: 1000; }; - GetRevisionResponseDto: { - Revision: components["schemas"]["DetailedRevisionResponseDto"]; + RestoreRevisionAcceptedResponse: { /** * ProtonResponseCode - * @example 1000 + * @example 1002 * @enum {integer} */ - Code: 1000; - }; - GetSharedFileInfoRequestDto: { - /** @default 1 */ - FromBlockIndex: number; - /** @default null */ - PageSize: number | null; - ClientEphemeral: components["schemas"]["BinaryString"]; - ClientProof: components["schemas"]["BinaryString"]; - SRPSession: components["schemas"]["BinaryString"]; + Code: 1002; }; - GetSharedFileInfoResponseDto: { - ServerProof: components["schemas"]["BinaryString2"]; - Payload: components["schemas"]["GetSharedFileInfoPayloadDto"]; + VerificationData: { + VerificationCode: components["schemas"]["BinaryString"]; + ContentKeyPacket: components["schemas"]["BinaryString"]; /** * ProtonResponseCode * @example 1000 @@ -4514,23 +5056,24 @@ export interface components { */ Code: 1000; }; - ShareURLContextsCollection: { - ShareURLContexts: components["schemas"]["ShareURLContext"][]; - /** @description Indicates there may be more ShareURLs */ - More: boolean; + EmptyTrashAcceptedResponse: { /** * ProtonResponseCode - * @example 1000 + * @example 1002 * @enum {integer} */ - Code: 1000; + Code: 1002; }; - ListShareURLsResponseDto: { - ShareURLs: components["schemas"]["ShareURLResponseDto2"][]; - /** @description If the Recursive query parameter is set, also returns the related links and ancestors up to the share as a dictionary by LinkID. */ - Links: { - [key: string]: components["schemas"]["ExtendedLinkTransformer2"]; - }; + ShareTrashList: { + ShareID: components["schemas"]["Id"]; + /** @description List of trashed link IDs for that share */ + LinkIDs: components["schemas"]["Id"][]; + /** @description List of trashed link's parentLinkIDs */ + ParentIDs: components["schemas"]["Id"][]; + }; + VolumeTrashList: { + /** @description Trash per share */ + Trash: components["schemas"]["ShareTrashList"][]; /** * ProtonResponseCode * @example 1000 @@ -4538,116 +5081,108 @@ export interface components { */ Code: 1000; }; - CreateShareURLRequestDto: { - CreatorEmail: components["schemas"]["AddressEmail"]; + VolumeTrashListV2: { + TrashedLinkIDs: components["schemas"]["Id"][]; /** - * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - Permissions: 4 | 6; - UrlPasswordSalt: components["schemas"]["BinaryString"]; - SharePasswordSalt: components["schemas"]["BinaryString"]; - SRPVerifier: components["schemas"]["BinaryString"]; - SRPModulusID: components["schemas"]["Id"]; - /** @description Bitmap: 1 = custom password set, 2 = random password set */ - Flags: number; - SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; - /** @description PGP encrypted password. The password is encrypted with the user's address key. */ - Password: string; - /** @description Maximum number of times this link can be accessed. 0 for infinite */ - MaxAccesses: number; + Code: 1000; + }; + Verifier: { + Token: components["schemas"]["BinaryString"]; + }; + RequestUploadBlockInput: { + /** @description Index of block in list (must be consecutive starting at 1) */ + Index: number; + /** @default null */ + Verifier: components["schemas"]["Verifier"] | null; /** - * @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. Optional + * @description Encrypted PGP Signature of the raw block content. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. * @default null */ - ExpirationTime: number | null; + EncSignature: string | null; /** - * @description Number of seconds after which this link is no longer accessible. Maximum 90 days. Optional + * @deprecated + * @description Block size in bytes * @default null */ - ExpirationDuration: number | null; + Size: number | null; /** - * @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. - * @default null + * @deprecated + * @description sha256 hash of encrypted block, base64 encoded */ - Name: string | null; + Hash: string; }; - UpdateShareURLRequestDto: { - /** @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. */ - ExpirationTime: number; - /** @description Number of seconds after which this link is no longer accessible. Maximum 90 days. */ - ExpirationDuration?: number | null; - /** @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. */ - Name: number; + RequestUploadThumbnailInput: { + Type: components["schemas"]["ThumbnailType"]; /** - * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * + * @deprecated + * @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 * @default null - * @enum {integer|null} */ - Permissions: 4 | 6 | null; - /** @default null */ - UrlPasswordSalt: components["schemas"]["BinaryString"] | null; - /** @default null */ - SharePasswordSalt: components["schemas"]["BinaryString"] | null; - /** @default null */ - SRPVerifier: components["schemas"]["BinaryString"] | null; - /** @default null */ - SRPModulusID: components["schemas"]["Id"] | null; + Size: number | null; /** - * @description Bitmap: 1 = custom password set, 2 = random password set - * @default null + * @deprecated + * @description sha256 hash of encrypted block, base64 encoded */ - Flags: number | null; + Hash: string; + }; + RequestUploadInput: { + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + AddressID?: components["schemas"]["AddressID"] | null; /** @default null */ - SharePassphraseKeyPacket: components["schemas"]["BinaryString"] | null; + VolumeID: components["schemas"]["Id"] | null; /** - * @description PGP encrypted password. The password is encrypted with the user's address key. + * @deprecated + * @description Deprecated, pass VolumeID instead * @default null */ - Password: components["schemas"]["PGPMessage"] | null; + ShareID: components["schemas"]["Id"] | null; /** - * @description Maximum number of times this link can be accessed. 0 for infinite + * @deprecated + * @description Request for thumbnail upload + * @default 0 + */ + Thumbnail: number | null; + /** + * @deprecated + * @description sha256 hash of thumbnail contents * @default null */ - MaxAccesses: number | null; - }; - DeleteMultipleShareURLsRequestDto: { - /** @description List of ShareURL ids to delete. */ - ShareURLIDs: components["schemas"]["EncryptedId"][]; - }; - ThumbnailIDsListInput: { - /** @description List of encrypted ThumbnailIDs. Maximum 30. */ - ThumbnailIDs: components["schemas"]["Id"][]; - }; - ListThumbnailsResponse: { - Thumbnails: components["schemas"]["ThumbnailResponse"][]; - Errors: components["schemas"]["ThumbnailErrorResponse"][]; + ThumbnailHash: string | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @deprecated + * @description Size of thumbnail contents + * @default 0 */ - Code: 1000; + ThumbnailSize: number | null; + /** @default [] */ + BlockList: components["schemas"]["RequestUploadBlockInput"][]; + /** @default [] */ + ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; }; - LinkMapQueryParameters: { - /** @default null */ - SessionName: string | null; - /** @default null */ - LastIndex: number | null; - /** @default 500 */ - PageSize: number; + BlockURL: { + BareURL: string; + Token: string; + /** @deprecated */ + URL: string; + Index: number; }; - LinkMapResponse: { - SessionName: string; - More: number; - Total: number; - Links: components["schemas"]["LinkMapItemResponse"][]; + ThumbnailBlockURL: { + BareURL: string; + Token: string; + /** @deprecated */ + URL: string; + ThumbnailType: components["schemas"]["ThumbnailType"]; + }; + RequestUploadResponse: { + UploadLinks: components["schemas"]["BlockURL"][]; + /** @deprecated */ + ThumbnailLink?: components["schemas"]["ThumbnailBlockURL"] | null; + ThumbnailLinks: components["schemas"]["ThumbnailBlockURL"][]; /** * ProtonResponseCode * @example 1000 @@ -4655,10 +5190,9 @@ export interface components { */ Code: 1000; }; - PrimaryRootShareResponseDto: { - Volume: components["schemas"]["VolumeDto"]; - Share: components["schemas"]["ShareDto"]; - Link: components["schemas"]["FolderDetailsDto2"]; + SmallUploadResponseDto: { + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; /** * ProtonResponseCode * @example 1000 @@ -4666,59 +5200,59 @@ export interface components { */ Code: 1000; }; - BootstrapShareResponseDto: { - ShareID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - Type: components["schemas"]["ShareType"]; - State: components["schemas"]["ShareState"]; - VolumeType: components["schemas"]["VolumeType"]; - /** Format: email */ - Creator: string; - Locked?: boolean | null; - CreateTime: number; - ModifyTime: number; - LinkID: components["schemas"]["Id2"]; + /** + * @description
See values descriptions
ValueDescription
1Ongoing
2Finished
3Failed
+ * @enum {integer} + */ + HealthCheckState: 1 | 2 | 3; + ReportHashCheckProgressDto: { + ClientUID: string; + /** @description Number of files that had to be redownloaded due to an issue */ + RefreshedItemCount: number; + /** @description Number of files that were suspicious and had to be inspected for issues */ + InspectedItemCount: number; + /** @description Number of files that could not be analysed or repaired */ + FailedItemCount: number; + State: components["schemas"]["HealthCheckState"]; + }; + AbuseReportDto: { /** - * @deprecated - * @description Deprecated: Use `CreateTime` + * @description Reported ShareURL, complete including fragment + * @example https://drive.proton.me/urls/1F9BKXYDMA#yF7d7bn01GMM */ - CreationTime: number; - /** @deprecated */ - PermissionsMask: number; - LinkType: components["schemas"]["NodeType"]; - /** @deprecated */ - Flags: number; - /** @deprecated */ - BlockSize: number; - /** @deprecated */ - VolumeSoftDeleted: boolean; - Key: components["schemas"]["PGPPrivateKey2"]; - Passphrase: components["schemas"]["PGPMessage2"]; - PassphraseSignature: components["schemas"]["PGPSignature2"]; - /** @description Address ID of the current user's address for the membership of this share. Can be missing if the user is not a direct member of the share. */ - AddressID?: string | null; + ShareURL: string; + /** @enum {string} */ + AbuseCategory: "spam" | "copyright" | "child-abuse" | "stolen-data" | "malware" | "other"; + /** @description Passphrase for reported Link's Node key, unencrypted, as a string, escaped for JSON. */ + ResourcePassphrase: string; /** - * @deprecated - * @description Clients should not use this field but pass the address keyring when validating and decrypting related fields. + * @description Full password, including custom part, as string, escaped for JSON + * @default */ - AddressKeyID?: string | null; - /** @description Your own memberships */ - Memberships: components["schemas"]["MemberResponseDto"][]; + Password: string; /** - * @deprecated - * @description Deprecated, use `Memberships` instead + * Format: email + * @description Reporter's email if provided + * @default null */ - PossibleKeyPackets: components["schemas"]["KeyPacketResponseDto"][]; - RootLinkRecoveryPassphrase?: components["schemas"]["PGPMessage2"] | null; + ReporterEmail: string | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description User message about the report. Required for copyright or leak reports. + * @default null + * @example This is malware */ - Code: 1000; + ReporterMessage: string | null; + /** @default null */ + VolumeID: components["schemas"]["Id"] | null; + /** @default null */ + LinkID: components["schemas"]["Id"] | null; + /** @default null */ + RevisionID: components["schemas"]["Id"] | null; }; - GetHighestContextForDocumentResponse: { - ContextShareID: components["schemas"]["Id2"]; + FreshAccountResponseDto: { + EndTime?: number | null; + /** @description Maximum available space for the free upload timer, in bytes (API allows going 10% over limit for zero-rating) */ + Quota?: number | null; /** * ProtonResponseCode * @example 1000 @@ -4726,8 +5260,24 @@ export interface components { */ Code: 1000; }; - ListSharesResponseDto: { - Shares: components["schemas"]["ShareResponseDto"][]; + ChecklistResponseDto: { + /** @description Array of completed checklist items */ + Items: string[]; + CreatedAt?: number | null; + ExpiresAt?: number | null; + /** @description User already has reward quota */ + UserWasRewarded: boolean; + /** @description Client has displayed completed checklist */ + Seen: boolean; + /** @description Client has completed checklist */ + Completed: boolean; + /** + * Format: float + * @description Amount of storage GB completion reward + */ + RewardInGB: number; + /** @description Checklist should be visible to user */ + Visible: boolean; /** * ProtonResponseCode * @example 1000 @@ -4735,33 +5285,10 @@ export interface components { */ Code: 1000; }; - TransferInput: { - /** @description The ID of the new address */ - AddressID: string; - /** @description The ID of the new key */ - KeyID: string; - /** @description Armored signature of the share passphrase, signed with the users's address with AddressID. */ - SharePassphraseSignature: string; - /** @description Base64 encoded key packet for the share passphrase, reusing the same session key as previously, and encrypted for the key referenced by the KeyID. */ - MemberKeyPacket: string; - }; - MigrateSharesRequestDto: { - /** - * @description The sum of PassphraseNodeKeyPacket-pairs and UnreadableShareIDs should not exceed 50 - * @default [] - */ - PassphraseNodeKeyPackets: components["schemas"]["ShareKPMigrationData"][]; - /** - * @description ShareIDs of unmigrated Shares that the client could not decrypt and should be locked - * @default [] - */ - UnreadableShareIDs: components["schemas"]["Id"][]; - }; - MigrateSharesResponseDto: { - /** @description ShareIDs successfully migrated */ - ShareIDs: components["schemas"]["Id2"][]; - /** @description ShareIDs not migrated with reason and error code */ - Errors: components["schemas"]["ShareKPMigrationError"][]; + OnboardingResponseDto: { + /** @description `true` if the user has pending/rejected invitations or user_registered external invitation */ + HasPendingInvitations: boolean; + IsFreshAccount: boolean; /** * ProtonResponseCode * @example 1000 @@ -4769,9 +5296,16 @@ export interface components { */ Code: 1000; }; - UnmigratedSharesResponseDto: { - /** @description ShareIDs that can be migrated */ - ShareIDs: components["schemas"]["Id2"][]; + EntitlementsDto: { + /** @description Maximum number of days revision history can be kept */ + MaxRevisionCount: number; + /** @description Maximum amount of revisions on a single link that can be kept */ + MaxRevisionDays: number; + /** @description Allow or not the user to create writable ShareURLs */ + PublicCollaboration: boolean; + }; + GetEntitlementResponseDto: { + Entitlements: components["schemas"]["EntitlementsDto"]; /** * ProtonResponseCode * @example 1000 @@ -4779,28 +5313,41 @@ export interface components { */ Code: 1000; }; - CreateShareRequestDto: { - AddressID: components["schemas"]["AddressID"]; - RootLinkID: components["schemas"]["Id"]; - ShareKey: components["schemas"]["PGPPrivateKey"]; - /** @description Full PGP message containing (optionally) PassphraseNodeKP and SharePassphrase-KP and data-packet (encrypted SharePassphrase) -> in this exact order */ - SharePassphrase: string; - SharePassphraseSignature: components["schemas"]["PGPSignature"]; - /** @description Key packet for passphrase of referenced link's node key passphrase */ - PassphraseKeyPacket: string; - NameKeyPacket: components["schemas"]["BinaryString"]; + AddTagsRequestDto: { + Tags: components["schemas"]["TagType"][]; + }; + FavoritePhotoDataDto: { + /** @description Name Hash */ + Hash: string; + Name: string; /** - * @deprecated - * @default null + * Format: email + * @description Email address used for signing name */ - Name: string | null; + NameSignatureEmail: string; + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @description Photo content hash */ + ContentHash: string; + /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ + NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature + */ + SignatureEmail?: string | null; + /** @default [] */ + RelatedPhotos: components["schemas"]["AlbumPhotoLinkDataDto"][]; }; - SharedByMeResponseDto: { - Links: components["schemas"]["LinkSharedByMeResponseDto"][]; - /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; - /** @description Indicates if there is a next page of results */ - More: boolean; + FavoritePhotoRequestDto: { + PhotoData?: components["schemas"]["FavoritePhotoDataDto"] | null; + }; + FavoriteRelatedPhotoResponseDto: { + LinkID: components["schemas"]["Id"]; + }; + FavoritePhotoResponseDto: { + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + RelatedPhotos: components["schemas"]["FavoriteRelatedPhotoResponseDto"][]; /** * ProtonResponseCode * @example 1000 @@ -4808,12 +5355,9 @@ export interface components { */ Code: 1000; }; - SharedWithMeResponseDto2: { - Links: components["schemas"]["LinkSharedWithMeResponseDto"][]; - /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; - /** @description Indicates if there is a next page of results */ - More: boolean; + GetMigrationStatusResponseDto: { + OldVolumeID: components["schemas"]["Id"]; + NewVolumeID?: components["schemas"]["Id"] | null; /** * ProtonResponseCode * @example 1000 @@ -4821,35 +5365,59 @@ export interface components { */ Code: 1000; }; - InviteExternalUserRequestDto: { - ExternalInvitation: components["schemas"]["ExternalInvitationRequestDto"]; - /** @default null */ - EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; - }; - InviteExternalUserResponseDto: { - ExternalInvitation: components["schemas"]["ExternalInvitationResponseDto"]; + AcceptedResponse: { /** * ProtonResponseCode - * @example 1000 + * @example 1002 * @enum {integer} */ - Code: 1000; + Code: 1002; }; - ListShareExternalInvitationsResponseDto: { - ExternalInvitations: components["schemas"]["ExternalInvitationResponseDto"][]; + ListPhotosParameters: { + /** @default true */ + Desc: boolean; + /** @default 500 */ + PageSize: number; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description The link ID of the last photo from the previous page when requesting secondary pages + * @default null */ - Code: 1000; + PreviousPageLastLinkID: components["schemas"]["Id"] | null; + /** + * @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) + * @default null + */ + MinimumCaptureTime: number | null; + /** @default null */ + Tag: components["schemas"]["TagType"] | null; }; - ListUserRegisteredExternalInvitationResponseDto: { - ExternalInvitations: components["schemas"]["UserRegisteredExternalInvitationItemDto"][]; - /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; - /** @description Indicates if there is a next page of results */ - More: boolean; + PhotoListingRelatedItemResponse: { + LinkID: components["schemas"]["Id"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + /** @description File name hash */ + Hash: string; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + }; + PhotoListingItemResponse: { + LinkID: components["schemas"]["Id"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + /** @description File name hash */ + Hash: string; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + /** + * @description Tags assigned to the photo + * @default [] + */ + Tags: number[]; + /** @default [] */ + RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; + }; + PhotoListingResponse: { + Photos: components["schemas"]["PhotoListingItemResponse"][]; /** * ProtonResponseCode * @example 1000 @@ -4857,37 +5425,76 @@ export interface components { */ Code: 1000; }; - UpdateExternalInvitationRequestDto: { + ActivePhotoRevisionDto: { + RevisionID: components["schemas"]["Id"]; + CreateTime: number; + EncryptedSize: number; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + XAttr?: components["schemas"]["PGPMessage"] | null; + Thumbnails: components["schemas"]["ThumbnailDto"][]; + /** Format: email */ + SignatureEmail?: string | null; + }; + PhotoAlbumDto: { + AlbumLinkID: components["schemas"]["Id"]; + Hash: string; + ContentHash: string; + AddedTime: number; + }; + PhotoFileDto: { + ActiveRevision?: components["schemas"]["ActivePhotoRevisionDto"] | null; + CaptureTime: number; + MainPhotoLinkID?: components["schemas"]["Id"] | null; + ContentHash?: string | null; + RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + Albums: components["schemas"]["PhotoAlbumDto"][]; + /** @description Will be empty if the user is not the owner. */ + Tags: components["schemas"]["TagType"][]; + TotalEncryptedSize: number; + ContentKeyPacket: components["schemas"]["BinaryString"]; + MediaType?: string | null; + ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + }; + PhotoDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Photo: components["schemas"]["PhotoFileDto"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto"] | null; /** - * @description Permission bitfield, valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * - * @enum {integer} + * @description Will be null if the user is not a member or is the owner. + * @default null */ - Permissions: 4 | 6 | 22; - }; - AcceptInvitationRequestDto: { - /** @description Signature of the share passphrase's session key with the private key of the user (invitee) and the signature context `drive.share-member.member`, base64 encoded */ - SessionKeySignature: string; + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + Album: null | null; }; - InviteUserRequestDto: { - Invitation: components["schemas"]["InvitationRequestDto"]; + PhotoAlbumDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Album: components["schemas"]["AlbumDto"]; /** @default null */ - EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; + Sharing: components["schemas"]["SharingDto"] | null; + /** @default null */ + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + Photo: null | null; }; - InviteUserResponseDto: { - Invitation: components["schemas"]["InvitationResponseDto"]; + PhotoRootFolderDetailsDto: { + Link: components["schemas"]["LinkDto"]; + Folder: components["schemas"]["FolderDto"]; + /** @default null */ + Sharing: components["schemas"]["SharingDto"] | null; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * @description Will be null if the user is not a member or is the owner. + * @default null */ - Code: 1000; + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + Photo: null | null; + /** @default null */ + Album: null | null; }; - ListShareInvitationsResponseDto: { - Invitations: components["schemas"]["InvitationResponseDto"][]; + LoadPhotoVolumeLinkDetailsResponseDto: { + Links: (components["schemas"]["PhotoDetailsDto"] | components["schemas"]["PhotoAlbumDetailsDto"] | components["schemas"]["PhotoRootFolderDetailsDto"])[]; /** * ProtonResponseCode * @example 1000 @@ -4895,58 +5502,56 @@ export interface components { */ Code: 1000; }; - ListPendingInvitationQueryParameters: { - AnchorID?: components["schemas"]["Id"] | null; - /** @default 150 */ - PageSize: number; - /** @default null */ - ShareTargetTypes: components["schemas"]["TargetType"][] | null; + RemoveTagsRequestDto: { + Tags: components["schemas"]["TagType"][]; }; - /** - * @description
See values descriptions
See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
- * @enum {integer} - */ - TargetType: 0 | 1 | 2 | 3 | 4 | 5; - ListPendingInvitationResponseDto: { - Invitations: components["schemas"]["PendingInvitationItemDto"][]; - /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; - /** @description Indicates if there is a next page of results */ - More: boolean; + UpdateXAttrRequest: { /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * Format: email + * @description Signature email address used to sign XAttributes; must be the same as the current revision signatureEmail, cannot be updated */ - Code: 1000; + SignatureEmail: string; + /** @description Extended attributes encrypted with link key */ + XAttr: string; }; - PendingInvitationResponseDto: { - Invitation: components["schemas"]["InvitationResponseDto"]; - Share: components["schemas"]["ShareResponseDto2"]; - Link: components["schemas"]["LinkResponseDto"]; + AuthShareTokenRequestDto: { + ClientEphemeral: components["schemas"]["BinaryString"]; + ClientProof: components["schemas"]["BinaryString"]; + SRPSession: components["schemas"]["BinaryString"]; + }; + AuthShareDataResponseDto: { + VolumeID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + SharePasswordSalt: components["schemas"]["BinaryString"]; + SharePassphrase: components["schemas"]["PGPMessage"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; /** - * ProtonResponseCode - * @example 1000 + * @description Permission bitfield of the share URL * @enum {integer} */ - Code: 1000; + PublicPermissions: 4 | 6; }; - UpdateInvitationRequestDto: { + AuthShareTokenResponseDto: { + /** @description Session UID */ + UID: string; + ServerProof: components["schemas"]["BinaryString"]; + Share: components["schemas"]["AuthShareDataResponseDto"]; /** - * @description Permission bitfield, valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * - * @enum {integer} + * @description Session Access token (present if new session) + * @default null */ - Permissions: 4 | 6 | 22; - }; - LinkAccessesResponseDto: { - /** @default null */ - ContextShare: components["schemas"]["ContextShareDto"] | null; - /** @default null */ - Invitations: components["schemas"]["PendingInvitationItemDto"][] | null; + AccessToken: string; + /** + * @description Duration of the session in seconds (present if new session) + * @default null + */ + ExpiresIn: number; + /** + * @description Type of token (present if new session) + * @default null + * @example Bearer + */ + TokenType: string; /** * ProtonResponseCode * @example 1000 @@ -4954,8 +5559,8 @@ export interface components { */ Code: 1000; }; - ListShareMembersResponseDto: { - Members: components["schemas"]["MemberResponseDto2"][]; + ParentEncryptedLinkIDsResponseDto: { + ParentLinkIDs: components["schemas"]["Id"][]; /** * ProtonResponseCode * @example 1000 @@ -4963,34 +5568,40 @@ export interface components { */ Code: 1000; }; - UpdateShareMemberRequestDto: { + /** + * @description
See values descriptions
ValueDescription
0ProtonDrive
1ProtonDoc
2ProtonSheet
+ * @enum {integer} + */ + VendorType: 0 | 1 | 2; + DirectAccessResponseDto: { + VolumeID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; /** - * @description Permission bitfield, cannot exceed the current user's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * + * @description Permission bitfield the user has on the node the share URL points to * @enum {integer} */ - Permissions: 4 | 6 | 22; - }; - SecurityRequestDto: { - Hashes: string[]; - }; - /** @description For each hash from the request, response contains either result or error entry */ - SecurityResponseDto: { - Results: components["schemas"]["SecurityResponseResultDto"][]; - Errors: components["schemas"]["SecurityResponseErrorDto"][]; + DirectPermissions: 4 | 6 | 22; /** - * ProtonResponseCode - * @example 1000 + * @description Permission bitfield of the share URL * @enum {integer} */ - Code: 1000; + PublicPermissions: 4 | 6; }; - SettingsResponse: { - UserSettings: components["schemas"]["UserSettings"]; - Defaults: components["schemas"]["Defaults"]; + InitSRPSessionResponseDto: { + Modulus: string; + ServerEphemeral: components["schemas"]["BinaryString"]; + UrlPasswordSalt: components["schemas"]["BinaryString"]; + SRPSession: components["schemas"]["BinaryString"]; + Version: number; + Flags: number; + /** @deprecated */ + IsDoc: boolean; + VendorType: components["schemas"]["VendorType"]; + /** + * @description Only set if the user is authenticated AND has direct access to the share already + * @default null + */ + DirectAccess: components["schemas"]["DirectAccessResponseDto"] | null; /** * ProtonResponseCode * @example 1000 @@ -4998,38 +5609,47 @@ export interface components { */ Code: 1000; }; - UserSettingsRequest: { - Layout?: components["schemas"]["LayoutSetting"] | null; - Sort?: components["schemas"]["SortSetting"] | null; - /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ - RevisionRetentionDays?: components["schemas"]["RevisionRetentionDays"] | null; - /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ - DocsCommentsNotificationsEnabled?: boolean | null; - /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ - DocsCommentsNotificationsIncludeDocumentName?: boolean | null; - /** @description Indicates user-preferred font in Proton Docs. */ - DocsFontPreference?: string | null; - /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ - PhotoTags?: components["schemas"]["TagType"][] | null; + CommitAnonymousRevisionDto: { + ManifestSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Address used to sign the manifest, blocks, and XAttributes. Must be the address in the membership of the context share. + */ + SignatureEmail?: components["schemas"]["AddressEmail"] | null; + /** @description Extended attributes encrypted with link key */ + XAttr: string; + /** + * @description Photo attributes + * @default null + */ + Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; }; - CreateOrgVolumeRequestDto: { - AddressID: components["schemas"]["AddressID"]; - /** @description XX's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: string; - ShareKey: components["schemas"]["PGPPrivateKey"]; - SharePassphrase: components["schemas"]["PGPMessage"]; - SharePassphraseSignature: components["schemas"]["PGPSignature"]; - FolderName: components["schemas"]["PGPMessage"]; - FolderKey: components["schemas"]["PGPPrivateKey"]; - FolderPassphrase: components["schemas"]["PGPMessage"]; - FolderPassphraseSignature: components["schemas"]["PGPSignature"]; - FolderHashKey: components["schemas"]["PGPMessage"]; - OrganizationID: components["schemas"]["Id"]; - /** @description Name of the org. volume. It's plain text so that name can be displayed in UI menu */ - VolumeName: string; + CreateAnonymousDocumentDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + ContentKeyPacket: components["schemas"]["BinaryString"]; + ManifestSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + * @default null + */ + SignatureEmail: components["schemas"]["AddressEmail"] | null; + /** + * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @default null + */ + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + DocumentType?: components["schemas"]["DocumentType"]; }; - GetVolumeResponseDto: { - Volume: components["schemas"]["VolumeResponseDto"]; + CreateAnonymousDocumentResponseDto: { + Document: components["schemas"]["DocumentDetailsDto"]; + AuthorizationToken: string; /** * ProtonResponseCode * @example 1000 @@ -5037,31 +5657,42 @@ export interface components { */ Code: 1000; }; - CreateVolumeRequestDto: { - AddressID: components["schemas"]["AddressID"]; - ShareKey: components["schemas"]["PGPPrivateKey"]; - SharePassphrase: components["schemas"]["PGPMessage"]; - SharePassphraseSignature: components["schemas"]["PGPSignature"]; - FolderName: components["schemas"]["PGPMessage"]; - FolderKey: components["schemas"]["PGPPrivateKey"]; - FolderPassphrase: components["schemas"]["PGPMessage"]; - FolderPassphraseSignature: components["schemas"]["PGPSignature"]; - FolderHashKey: components["schemas"]["PGPMessage"]; - /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: string; + CreateAnonymousFileRequestDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @example text/plain */ + MIMEType: string; + ContentKeyPacket: components["schemas"]["BinaryString"]; /** - * @deprecated + * Format: email + * @description Signature email address used to sign passphrase and name * @default null */ - VolumeName: string | null; + SignatureEmail: components["schemas"]["AddressEmail"] | null; /** - * @deprecated + * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ShareName: string | null; + ContentKeyPacketSignature: string | null; + /** + * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. + * @default null + */ + ClientUID: string | null; + /** + * @description Intended upload file size, future BE size validation + * @default null + */ + IntendedUploadSize: number | null; }; - ListOrgVolumesResponseDto: { - Volumes: components["schemas"]["OrgVolumeResponseDto"][]; + CreateAnonymousFileResponseDto: { + File: components["schemas"]["FileResponseDto"]; + AuthorizationToken: string; /** * ProtonResponseCode * @example 1000 @@ -5069,17 +5700,31 @@ export interface components { */ Code: 1000; }; - ListOrgVolumesForAdminResponseDto: { - Volumes: components["schemas"]["OrgVolumeForAdminResponseDto"][]; + CreateAnonymousFolderRequestDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: string; /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} + * Format: email + * @description Signature email address used to sign passphrase and name + * @default null */ - Code: 1000; + SignatureEmail: components["schemas"]["AddressEmail"] | null; + /** + * @description Extended attributes encrypted with link key + * @default null + */ + XAttr: string | null; }; - ListVolumesResponseDto: { - Volumes: components["schemas"]["VolumeResponseDto"][]; + CreateAnonymousFolderResponseDto: { + Folder: components["schemas"]["FolderResponseDto"]; + AuthorizationToken: string; /** * ProtonResponseCode * @example 1000 @@ -5087,2031 +5732,935 @@ export interface components { */ Code: 1000; }; - RestoreVolumeDto: { - SignatureAddress: components["schemas"]["AddressEmail"]; - /** @default [] */ - MainShares: components["schemas"]["RestoreMainShareDto"][]; - /** @default [] */ - Devices: components["schemas"]["RestoreRootShareDto"][]; - /** @default [] */ - PhotoShares: components["schemas"]["RestoreRootShareDto"][]; - /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ - AddressKeyID: string; + LinkWithAuthorizationTokenDto: { + LinkID: components["schemas"]["Id"]; + /** @default null */ + AuthorizationToken: string | null; }; - AddPhotoToAlbumWithLinkIDResponseDto: Record; - MultiResponsesPerLinkFactory: { - /** @enum {integer} */ - Code: 1001; - Responses: { - LinkID: string; - Response: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; - }[]; + DeleteChildrenRequestDto: { + Links: components["schemas"]["LinkWithAuthorizationTokenDto"][]; }; - RemovePhotoFromAlbumWithLinkIDResponseDto: Record; - ConflictErrorResponseDto: { - Details: components["schemas"]["ConflictErrorDetailsDto"]; - Error: string; - Code: number; + RenameAnonymousLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: string; + /** @description Name hash */ + Hash: string; + /** @description Current name hash before move operation. Used to prevent race conditions. */ + OriginalHash: string; + /** + * Format: email + * @description Signature email address used for signing name + * @default null + */ + NameSignatureEmail: string | null; + /** + * @description MIME type, optional, only on files. + * @default null + * @example text/plain + */ + MIMEType: string | null; + /** @default null */ + AuthorizationToken: string | null; }; - /** Link */ - ExtendedLinkTransformer: { + RequestAnonymousUploadRequestDto: { + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; /** - * @deprecated - * @description Shared flag. 0 => No public URL, 1 => shared with a public URL. Deprecated, use SharingDetails properties instead. + * Format: email + * @description Signature email address used to sign the blocks content + * @default null + */ + SignatureEmail: components["schemas"]["AddressEmail"] | null; + /** @default [] */ + BlockList: components["schemas"]["RequestUploadBlockInput"][]; + /** @default [] */ + ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + }; + BootstrapShareTokenResponseDto: { + Token: components["schemas"]["TokenResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - Shared: 0 | 1; + Code: 1000; + }; + BlockResponseDto: { + Index: number; + Hash: components["schemas"]["BinaryString"]; + Token?: string | null; /** @deprecated */ - ShareUrls: { - /** @deprecated */ - ShareUrlId?: string; - ShareURLID?: string; - /** @deprecated */ - ShareID?: string; - /** @description URL Token (not always provided) */ - Token?: string; - /** - * @deprecated - * @description Expiration time timestamp of ShareURL. - */ - ExpireTime?: number; - /** @description Expiration Timestamp */ - ExpirationTime?: number; - /** @description Creation time timestamp of ShareURL. */ - CreateTime?: number; - /** - * @description Number of Accesses (by access is meant download; first block is enough to increase the counter) - * @example 1 - */ - NumAccesses?: number; - }[]; - /** @description Link sharing details, null if not shared. */ - SharingDetails: { - ShareID?: string; - /** @description Share URL linking to this file or folder */ - ShareUrl?: { - /** @deprecated */ - ShareUrlId?: string; - ShareURLID?: string; - /** @deprecated */ - ShareID?: string; - /** @description URL Token (not always provided) */ - Token?: string; - /** - * @deprecated - * @description Expiration time timestamp of ShareURL. - */ - ExpireTime?: number | null; - /** @description Expiration Timestamp */ - ExpirationTime?: number | null; - /** @description Creation time timestamp of ShareURL. */ - CreateTime?: number; - /** @description Number of Accesses (by access is meant download; first block is enough to increase the counter) */ - NumAccesses?: number; - } | null; - } | null; - /** - * @deprecated - * @description Deprecated, use `SharingDetails.ShareID` since there will only be one share per link. List of Shares related to this link. - */ - ShareIDs: string[]; - /** - * @deprecated - * @description Deprecated, use `SharingDetails.ShareURLs` and count URLs. Number of Share URLs linking to this file or folder. - */ - NbUrls: number; - /** - * @deprecated - * @description Deprecated, use `SharingDetails.ShareURLs` and count valid URLs. Number of active urls - */ - ActiveUrls: number; - /** - * @deprecated - * @description Deprecated, use `SharingDetails.ShareURLs` and check for valid URLs. Set if all URLs are expired. 0 => at least one valid URL, 1 => no usable URL - * @enum {integer} - */ - UrlsExpired: 0 | 1; - /** @description Extended attributes encrypted with link key */ - XAttr: string | null; - /** @description File properties */ - FileProperties: { - /** @description Content key packet */ - ContentKeyPacket?: string; - /** @description Signature of content key packet. Should be the signature of the (plain) Session Key, signed with the Node Key. Legacy versions must be accepted and can be a signature of the encrypted ContentKeyPacket and can be signed with the NodePassphraseEmail. */ - ContentKeyPacketSignature?: string; - /** @description Active revision */ - ActiveRevision?: { - /** @description Revision ID */ - ID?: string; - /** @description Creation time (UNIX timestamp) */ - CreateTime?: number; - /** @description Size of revision (in bytes) */ - Size?: number; - /** @description Signature of the manifest, signed with SignatureEmail */ - ManifestSignature?: string; - /** - * Format: email - * @description Signature email address for blocks, XAttributes and manifest - */ - SignatureEmail?: string; - /** - * Format: email - * @deprecated - * @description [DEPRECATED] Signature email address for blocks, XAttributes, and manifest - */ - SignatureAddress?: string; - /** - * @description State; Will always be active; 1=active - * @enum {integer} - */ - State?: 1; - /** - * @deprecated - * @description Revision has a thumbnail - * @enum {integer} - */ - Thumbnail?: 0 | 1; - /** - * @deprecated - * @description Download URL for the thumbnail block - */ - ThumbnailDownloadUrl?: string; - /** - * @deprecated - * @description Thumbnail properties - */ - ThumbnailURLInfo?: { - /** - * @deprecated - * @description Bare Download URL for the thumbnail block - */ - BareURL?: string; - /** - * @deprecated - * @description Token for the thumbnail block - */ - Token?: string; - }; - Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; - Photo?: components["schemas"]["PhotoTransformer"] | null; - }; - } | null; - FolderProperties: { - /** @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) */ - NodeHashKey?: string; - } | null; - /** @description ProtonDocument properties; optional */ - DocumentProperties?: { - /** @description Document size */ - Size?: number; - } | null; - /** @description Album properties; optional */ - AlbumProperties?: { - /** @description Is the album locked */ - Locked?: boolean; - /** @description ID of the album cover link */ - CoverLinkID?: string | null; - /** @description Last time a Photo was added to the Album */ - LastActivityTime?: number; - /** @description Amount of photos in album */ - PhotoCount?: number; - /** @description Node hash key */ - NodeHashKey?: string; - } | null; - /** @description Photo properties; optional */ - PhotoProperties?: { - /** @description A list of Albums the Photo-Link is part of */ - Albums?: { - /** @description Album Link ID */ - AlbumLinkID?: string; - /** @description NameHash in Album context (encrypted with Album-Link-NodeKey) */ - Hash?: string; - /** @description ContentHash in Album context (encrypted with Album-Link-NodeKey) */ - ContentHash?: string; - /** @description Timestamp Photo-Link was added to this album */ - AddedTime?: number; - }[]; - /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ - Tags?: number[]; - } | null; - } & components["schemas"]["LinkTransformer"]; - GetRevisionResponseDto2: { - Revision: components["schemas"]["DetailedRevisionResponseDto2"]; - /** - * ProtonResponseCode - * @example 1000 - * @enum {integer} - */ - Code: 1000; - }; - /** @description Conflict, a share already exists for the file or folder. */ - ShareConflictErrorResponseDto: { - Details: components["schemas"]["ShareConflictErrorDetailsDto"]; - Error: string; - Code: number; - }; - SmallFileUploadMetadataRequestDto: { - Name: components["schemas"]["PGPMessage"]; - NameHash: string; - ParentLinkID: components["schemas"]["Id"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - /** - * Format: email - * @description Address used to sign passphrase, name, manifest, block, and xAttr. Is null for anonymous users. - */ - SignatureEmail?: components["schemas"]["AddressEmail"] | null; - NodeKey: components["schemas"]["PGPPrivateKey"]; - /** @example text/plain */ - MIMEType: string; - ContentKeyPacket: components["schemas"]["BinaryString"]; - /** @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. */ - ContentKeyPacketSignature?: string | null; - ManifestSignature: components["schemas"]["PGPSignature"]; - ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; - /** - * @description Extended attributes encrypted with link key - * @default null - */ - XAttr: string | null; - /** @default null */ - Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; - /** - * @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. - * @default null - */ - ContentBlockEncSignature: string | null; - }; - SmallRevisionUploadMetadataRequestDto: { - CurrentRevisionID: components["schemas"]["Id"]; - /** - * Format: email - * @description Address used to sign manifest, block, and xAttr. Is null for anonymous users. - */ - SignatureEmail?: components["schemas"]["AddressEmail"] | null; - ManifestSignature: components["schemas"]["PGPSignature"]; - /** @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. */ - ContentBlockEncSignature?: components["schemas"]["PGPMessage"] | null; - ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; - /** - * @description File extended attributes encrypted with link key - * @default null - */ - XAttr: string | null; - }; - ShareURLResponseDto: { - Token: string; - ShareURLID: components["schemas"]["Id"]; - ShareID: components["schemas"]["Id"]; - /** @description URL to use to access the ShareURL */ - PublicUrl: string; - ExpirationTime?: number | null; - LastAccessTime?: number | null; - CreateTime: number; - MaxAccesses: number; - NumAccesses: number; - Name?: components["schemas"]["PGPMessage"] | null; - CreatorEmail: string; - /** - * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * - * @enum {integer} - */ - Permissions: 4 | 6; - /** @description Bitmap: - * - `1`: FLAG_CUSTOM_PASSWORD, - * - `2`: FLAG_RANDOM_PASSWORD */ - Flags: number; - UrlPasswordSalt: components["schemas"]["BinaryString"]; - SharePasswordSalt: components["schemas"]["BinaryString"]; - SRPVerifier: components["schemas"]["BinaryString"]; - SRPModulusID: components["schemas"]["Id"]; - Password: components["schemas"]["PGPMessage"]; - SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; - }; - AlbumPhotoLinkDataDto: { - LinkID: components["schemas"]["Id"]; - /** @description Name Hash */ - Hash: string; - Name: string; - /** - * Format: email - * @description Email address used for signing name - */ - NameSignatureEmail: string; - NodePassphrase: components["schemas"]["PGPMessage"]; - /** @description Photo content hash */ - ContentHash: string; - /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ - NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; - /** - * Format: email - * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature - */ - SignatureEmail?: components["schemas"]["AddressEmail"] | null; - }; - AlbumLinkDto: { - Name: components["schemas"]["PGPMessage"]; - /** @description Album name Hash */ - Hash: string; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - SignatureEmail: components["schemas"]["AddressEmail"]; - NodeKey: components["schemas"]["PGPPrivateKey"]; - /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; - /** - * @description Extended attributes encrypted with link key - * @default null - */ - XAttr: string | null; - }; - AlbumShortResponseDto: { - Link: components["schemas"]["AlbumLinkResponseDto"]; - }; - ShareDataDto: { - AddressID: components["schemas"]["AddressID"]; - Key: components["schemas"]["PGPPrivateKey"]; - Passphrase: components["schemas"]["PGPMessage"]; - PassphraseSignature: components["schemas"]["PGPSignature"]; - /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: string; - }; - LinkDataDto: { - /** @description Root folder name */ - Name: string; - NodeKey: components["schemas"]["PGPPrivateKey"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - NodeHashKey: components["schemas"]["PGPMessage"]; - }; - PhotoVolumeResponseDto: { - VolumeID: components["schemas"]["Id2"]; - CreateTime: number; - ModifyTime: number; - /** @description Used space in bytes */ - UsedSpace: number; - DownloadedBytes: number; - UploadedBytes: number; - State: components["schemas"]["VolumeState"]; - Share: components["schemas"]["ShareReferenceResponseDto"]; - Type: components["schemas"]["VolumeType2"]; - }; - FoundDuplicate: { - /** @description NameHash of the found duplicate */ - Hash?: string | null; - /** @description ContentHash of the found duplicate */ - ContentHash?: string | null; - /** - * @description Can be null if the Link was deleted - * @enum {unknown|null} - */ - LinkState?: 0 | 1 | 2 | null; - /** @description Client defined UID for the draft. Null if no ClientUID passed, or Revision was already committed. */ - ClientUID?: string | null; - /** @description LinkID, null if deleted */ - LinkID: string; - /** @description RevisionID, null if deleted */ - RevisionID: string; - }; - PhotoTagMigrationDataDto: { - LastProcessedLinkID: components["schemas"]["Id2"]; - LastProcessedCaptureTime: number; - LastMigrationTimestamp: number; - /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. - * if null, client side migration is expired (client has not checked in for > 1h), any eligible client can continue migration */ - LastClientUID?: string | null; - }; - AlbumResponseDto: { - Locked: boolean; - LastActivityTime: number; - PhotoCount: number; - LinkID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - /** @default null */ - ShareID: components["schemas"]["Id2"] | null; - /** @default null */ - CoverLinkID: components["schemas"]["Id2"] | null; - }; - ListPhotosAlbumItemResponseDto: { - LinkID: components["schemas"]["Id2"]; - CaptureTime: number; - Hash: string; - ContentHash: string; - RelatedPhotos: components["schemas"]["ListPhotosAlbumRelatedPhotoItemResponseDto"][]; - AddedTime: number; - IsChildOfAlbum: boolean; - /** - * @description Tags assigned to the photo - * @default [] - */ - Tags: number[]; - }; - TransferPhotoLinkInBatchRequestDto: { - LinkID: components["schemas"]["Id"]; - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; - /** @description Name hash */ - Hash: string; - /** @description Current name hash before move operation. Used to prevent race conditions. */ - OriginalHash: string; - /** - * @description Optional, when transferring an Album-Link, required when transferring photos. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] - * @default null - */ - ContentHash: string | null; - /** - * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. - * @default null - */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; - }; - PhotoTagMigrationUpdateDto: { - LastProcessedLinkID: components["schemas"]["Id"]; - LastProcessedCaptureTime: number; - CurrentTimestamp: number; - /** @description Client unique ID. Indicates which client started migration, and thus can/should continue. */ - ClientUID: string; - }; - AlbumLinkUpdateDto: { - Name?: components["schemas"]["PGPMessage"] | null; - Hash?: string | null; - /** - * Format: email - * @description Signature email address used to sign passphrase and name - */ - NameSignatureEmail?: string | null; - OriginalHash?: string | null; - /** @description Extended attributes encrypted with link key */ - XAttr?: string | null; - }; - BookmarkShareURLRequestDto: { - EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; - AddressID: components["schemas"]["AddressID"]; - AddressKeyID: components["schemas"]["Id"]; - }; - BookmarkShareURLResponseDto: { - UserID: components["schemas"]["Id2"]; - Token: string; - ShareURLID: components["schemas"]["Id2"]; - EncryptedUrlPassword?: components["schemas"]["PGPMessage2"] | null; - State: components["schemas"]["BookmarkShareURLState"]; - CreateTime: number; - ModifyTime: number; - }; - BookmarkShareURLInfoResponseDto: { - EncryptedUrlPassword?: components["schemas"]["PGPMessage2"] | null; - CreateTime: number; - Token: components["schemas"]["TokenResponseDto"]; - }; - DeviceDataDto: { - SyncState: components["schemas"]["DeviceSyncState"]; - Type: components["schemas"]["DeviceType"]; - /** - * @deprecated - * @default null - */ - VolumeID: components["schemas"]["Id"] | null; - }; - ShareDataDto2: { - AddressID: components["schemas"]["AddressID"]; - Key: components["schemas"]["PGPPrivateKey"]; - Passphrase: components["schemas"]["PGPMessage"]; - PassphraseSignature: components["schemas"]["PGPSignature"]; - /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: string; - /** - * @deprecated - * @default null - */ - Name: string | null; - }; - DeviceResponseDto: { - DeviceID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - }; - DeviceResponseDto2: { - Device: components["schemas"]["DeviceDataDto3"]; - Share: components["schemas"]["ShareDataDto4"]; - }; - DeviceResponseDto3: { - Device: components["schemas"]["DeviceDto"]; - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - }; - DeviceDataDto2: { - /** @default null */ - SyncState: components["schemas"]["DeviceSyncState"] | null; - /** - * @description UNIX timestamp when the Device got last synced. Optional - * @default null - */ - LastSyncTime: number | null; - }; - ShareDataDto3: { - /** - * @deprecated - * @default null - */ - Name: string | null; - }; - /** @description Base64 encoded binary data */ - BinaryString: string; - /** @description An armored PGP Signature */ - PGPSignature: string; - /** - * @description

Document=1, Sheet=2

See values descriptions
See values descriptions
ValueDescription
1Document
2Sheet
- * @enum {integer} - */ - DocumentType: 1 | 2; - /** @description An armored PGP Message */ - PGPMessage: string; - /** @description An armored PGP Private Key */ - PGPPrivateKey: string; - /** - * Format: email - * @description Address Email - */ - AddressEmail: string; - DocumentDetailsDto: { - VolumeID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - RevisionID: components["schemas"]["Id2"]; - }; - /** @description An encrypted ID */ - Id2: string; - EventResponseDto: { - EventID: components["schemas"]["Id2"]; - EventType: components["schemas"]["EventType"]; - /** @description Event creation timestamp */ - CreateTime: number; - Link: { - LinkID: components["schemas"]["Id"]; - } | components["schemas"]["ExtendedLinkTransformer2"]; - /** - * @description The share the user has access to that is closest to the root. Delete events do not have it but other events do. - * @default null - */ - ContextShareID: string | null; - /** - * @description If a file was moved to a different context share, this shows the old, origin share - * @default null - */ - FromContextShareID: string | null; - /** - * @description Optional event data - * @default null - */ - Data: { - /** @description New or updated ShareURL */ - UrlID?: string; - /** - * @deprecated - * @description Corresponding ShareURL has been deleted - */ - DeletedURLID?: string[]; - /** @description Corresponding locked volume has been restored */ - FLAG_RESTORE_COMPLETE?: string; - /** @description Restoration has failed for corresponding locked volume */ - FLAG_RESTORE_FAILED?: string; - /** - * @deprecated - * @description Revision has been restored for this LinkID - */ - FLAG_RESTORE_REVISION_COMPLETE?: string; - /** @description Parent before the move */ - FromParentLinkID?: string; - } | null; - }; - EventV2ResponseDto: { - EventID: components["schemas"]["Id2"]; - EventType: components["schemas"]["EventType"]; - Link: components["schemas"]["EventLinkDataDto"]; - }; - FolderResponseDto: { - ID: components["schemas"]["Id2"]; - }; - /** @description An encrypted ID */ - EncryptedId: string; - PendingHashResponseDto: { - Hash: string; - RevisionID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - ClientUID?: string | null; - }; - PhotosDto: { - /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ - ContentHash: string; - /** @default [] */ - RelatedPhotos: components["schemas"]["RelatedPhotoDto"][]; - }; - ListMissingHashKeyItemDto: { - LinkID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - }; - FileDetailsDto: { - Link: components["schemas"]["LinkDto"]; - File: components["schemas"]["FileDto"]; - /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; - /** - * @description Will be null if the user is not a member or is the owner. - * @default null - */ - Membership: components["schemas"]["MembershipDto"] | null; - /** @default null */ - Folder: null | null; - /** @default null */ - Album: null | null; - }; - FolderDetailsDto: { - Link: components["schemas"]["LinkDto"]; - Folder: components["schemas"]["FolderDto"]; - /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; - /** - * @description Will be null if the user is not a member or is the owner. - * @default null - */ - Membership: components["schemas"]["MembershipDto"] | null; - /** @default null */ - File: null | null; - /** @default null */ - Album: null | null; - }; - AlbumDetailsDto: { - Link: components["schemas"]["LinkDto"]; - Album: components["schemas"]["AlbumDto"]; - /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; - /** @default null */ - Membership: components["schemas"]["MembershipDto"] | null; - /** @default null */ - File: null | null; - /** @default null */ - Folder: null | null; - }; - MoveLinkInBatchRequestDto: { - LinkID: components["schemas"]["Id"]; - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; - /** @description Name hash */ - Hash: string; - /** - * @description Current name hash before move operation. Used to prevent race conditions. - * @default null - */ - OriginalHash: string | null; - /** - * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] - * @default null - */ - ContentHash: string | null; - /** - * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. - * @default null - */ - NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; - }; - UpdateMissingHashKeyItemDto: { - LinkID: components["schemas"]["Id"]; - VolumeID: components["schemas"]["Id"]; - PGPArmoredEncryptedNodeHashKey: components["schemas"]["PGPMessage"]; - }; - CommitRevisionPhotoDto: { - /** @description Photo capture timestamp */ - CaptureTime: number; - /** @description Photo content hash, lowercase hex representation of HMAC SHA256 of SHA1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ - ContentHash: string; - /** - * @description Main photo LinkID reference. Pass null if none. - * @default null - */ - MainPhotoLinkID: string | null; - /** - * @deprecated - * @description Deprecated: Clients persist exif information in xAttr instead - * @default null - */ - Exif: components["schemas"]["BinaryString"] | null; - /** - * @description List of tags to be assigned to the photo - * @default null - */ - Tags: components["schemas"]["TagType"][] | null; - }; - BlockTokenDto: { - Index: number; - Token: string; - }; - FileResponseDto: { - ID: components["schemas"]["Id2"]; - RevisionID: components["schemas"]["Id2"]; - ClientUID?: string | null; - }; - RevisionResponseDto: { - ID: components["schemas"]["Id2"]; - ManifestSignature?: components["schemas"]["PGPSignature2"] | null; - /** @description Size of revision (in bytes) */ - Size: number; - State: components["schemas"]["RevisionState"]; - XAttr?: components["schemas"]["PGPMessage2"] | null; - /** - * @deprecated - * @description Flag stating if revision has a thumbnail - * @enum {integer} - */ - Thumbnail: 0 | 1; - /** @deprecated */ - ThumbnailHash?: components["schemas"]["BinaryString2"] | null; - /** - * @deprecated - * @description Size thumbnail in bytes; 0 if no thumbnail present - */ - ThumbnailSize: number; - Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; - ClientUID?: string | null; - /** @default null */ - CreateTime: number | null; - /** - * Format: email - * @description User's email associated with the share and used to sign the manifest and block contents. - * @default null - */ - SignatureEmail: string | null; - /** - * Format: email - * @deprecated - * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature - * @default null - */ - SignatureAddress: string | null; - }; - /** @description Base64 encoded binary data */ - BinaryString2: string; - ShareTrashList: { - ShareID: components["schemas"]["Id2"]; - /** @description List of trashed link IDs for that share */ - LinkIDs: components["schemas"]["Id2"][]; - /** @description List of trashed link's parentLinkIDs */ - ParentIDs: components["schemas"]["Id2"][]; - }; - /** @description Address ID */ - AddressID: string; - RequestUploadBlockInput: { - /** @description Index of block in list (must be consecutive starting at 1) */ - Index: number; - /** @default null */ - Verifier: components["schemas"]["Verifier"] | null; - /** - * @description Encrypted PGP Signature of the raw block content. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. - * @default null - */ - EncSignature: string | null; - /** - * @deprecated - * @description Block size in bytes - * @default null - */ - Size: number | null; - /** - * @deprecated - * @description sha256 hash of encrypted block, base64 encoded - */ - Hash: string; - }; - RequestUploadThumbnailInput: { - Type: components["schemas"]["ThumbnailType"]; - /** - * @deprecated - * @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 - * @default null - */ - Size: number | null; - /** - * @deprecated - * @description sha256 hash of encrypted block, base64 encoded - */ - Hash: string; - }; - BlockURL: { - BareURL: string; - Token: string; - /** @deprecated */ - URL: string; - Index: number; - }; - ThumbnailBlockURL: { - BareURL: string; - Token: string; - /** @deprecated */ - URL: string; - ThumbnailType: components["schemas"]["ThumbnailType2"]; - }; - EntitlementsDto: { - /** @description Maximum number of days revision history can be kept */ - MaxRevisionCount: number; - /** @description Maximum amount of revisions on a single link that can be kept */ - MaxRevisionDays: number; - /** @description Allow or not the user to create writable ShareURLs */ - PublicCollaboration: boolean; - }; - FavoritePhotoDataDto: { - /** @description Name Hash */ - Hash: string; - Name: string; - /** - * Format: email - * @description Email address used for signing name - */ - NameSignatureEmail: string; - NodePassphrase: components["schemas"]["PGPMessage"]; - /** @description Photo content hash */ - ContentHash: string; - /** @description Nullable; Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. */ - NodePassphraseSignature?: components["schemas"]["PGPSignature"] | null; - /** - * Format: email - * @description Nullable: Required when moving an anonymous link. Email address used for the NodePassphraseSignature - */ - SignatureEmail?: string | null; - /** @default [] */ - RelatedPhotos: components["schemas"]["AlbumPhotoLinkDataDto"][]; - }; - FavoriteRelatedPhotoResponseDto: { - LinkID: components["schemas"]["Id2"]; - }; - PhotoListingItemResponse: { - LinkID: components["schemas"]["Id2"]; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ - CaptureTime: number; - /** @description File name hash */ - Hash: string; - /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash?: string | null; - /** - * @description Tags assigned to the photo - * @default [] - */ - Tags: number[]; - /** @default [] */ - RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; - }; - PhotoDetailsDto: { - Link: components["schemas"]["LinkDto"]; - Photo: components["schemas"]["PhotoFileDto"]; - /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; - /** - * @description Will be null if the user is not a member or is the owner. - * @default null - */ - Membership: components["schemas"]["MembershipDto"] | null; - /** @default null */ - Album: null | null; - }; - PhotoAlbumDetailsDto: { - Link: components["schemas"]["LinkDto"]; - Album: components["schemas"]["AlbumDto"]; - /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; - /** @default null */ - Membership: components["schemas"]["MembershipDto"] | null; - /** @default null */ - Photo: null | null; - }; - PhotoRootFolderDetailsDto: { - Link: components["schemas"]["LinkDto"]; - Folder: components["schemas"]["FolderDto"]; - /** @default null */ - Sharing: components["schemas"]["SharingDto"] | null; - /** - * @description Will be null if the user is not a member or is the owner. - * @default null - */ - Membership: components["schemas"]["MembershipDto"] | null; - /** @default null */ - Photo: null | null; - /** @default null */ - Album: null | null; - }; - AuthShareDataResponseDto: { - VolumeID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - SharePasswordSalt: components["schemas"]["BinaryString2"]; - SharePassphrase: components["schemas"]["PGPMessage2"]; - ShareKey: components["schemas"]["PGPPrivateKey2"]; - /** - * @description Permission bitfield of the share URL - * @enum {integer} - */ - PublicPermissions: 4 | 6; - }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0ProtonDrive
1ProtonDoc
2ProtonSheet
- * @enum {integer} - */ - VendorType: 0 | 1 | 2; - DirectAccessResponseDto: { - VolumeID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - /** - * @description Permission bitfield the user has on the node the share URL points to - * @enum {integer} - */ - DirectPermissions: 4 | 6 | 22; - /** - * @description Permission bitfield of the share URL - * @enum {integer} - */ - PublicPermissions: 4 | 6; - }; - LinkWithAuthorizationTokenDto: { - LinkID: components["schemas"]["Id"]; - /** @default null */ - AuthorizationToken: string | null; - }; - TokenResponseDto: { - /** - * @description Url Token - * @example YTZZRH7DA8 - */ - Token: string; - LinkType: components["schemas"]["NodeType2"]; - VolumeID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - SharePasswordSalt: components["schemas"]["BinaryString2"]; - SharePassphrase: components["schemas"]["PGPMessage2"]; - ShareKey: components["schemas"]["PGPPrivateKey2"]; - NodePassphrase: components["schemas"]["PGPMessage2"]; - NodeKey: components["schemas"]["PGPPrivateKey2"]; - Name: components["schemas"]["PGPMessage2"]; - /** @description Base64 encoded content key packet. Null for folders */ - ContentKeyPacket?: components["schemas"]["BinaryString2"] | null; - /** @example text/plain */ - MIMEType: string; - /** - * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * - * @enum {integer} - */ - Permissions: 4 | 6; - /** @description File size, null for folders */ - Size?: number | null; - /** @description File properties */ - ThumbnailURLInfo?: components["schemas"]["ThumbnailURLInfoResponseDto"] | null; - /** @default null */ - NodeHashKey: components["schemas"]["PGPMessage2"] | null; - /** - * @description Signature email of the share owner. Only set for a ShareURL with read+write permissions. - * @default null - */ - SignatureEmail: string | null; - /** - * @description Only set for a ShareURL with read+write permissions. - * @default null - */ - NodePassphraseSignature: components["schemas"]["PGPSignature2"] | null; - }; - DetailedRevisionResponseDto: { - Blocks: components["schemas"]["BlockResponseDto"][]; - Photo?: components["schemas"]["PhotoResponseDto"] | null; - ID: components["schemas"]["Id2"]; - ManifestSignature?: components["schemas"]["PGPSignature2"] | null; - /** @description Size of revision (in bytes) */ - Size: number; - State: components["schemas"]["RevisionState"]; - XAttr?: components["schemas"]["PGPMessage2"] | null; - /** - * @deprecated - * @description Flag stating if revision has a thumbnail - * @enum {integer} - */ - Thumbnail: 0 | 1; - /** @deprecated */ - ThumbnailHash?: components["schemas"]["BinaryString2"] | null; - /** - * @deprecated - * @description Size thumbnail in bytes; 0 if no thumbnail present - */ - ThumbnailSize: number; - Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; - ClientUID?: string | null; - /** @default null */ - CreateTime: number | null; - /** - * Format: email - * @description User's email associated with the share and used to sign the manifest and block contents. - * @default null - */ - SignatureEmail: string | null; - /** - * Format: email - * @deprecated - * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature - * @default null - */ - SignatureAddress: string | null; - }; - GetSharedFileInfoPayloadDto: { - SharePasswordSalt: components["schemas"]["BinaryString2"]; - SharePassphrase: components["schemas"]["PGPMessage2"]; - ShareKey: components["schemas"]["PGPPrivateKey2"]; - NodePassphrase: components["schemas"]["PGPMessage2"]; - NodeKey: components["schemas"]["PGPPrivateKey2"]; - Name: components["schemas"]["PGPMessage2"]; - Size: number; - MIMEType: string; - /** @description UNIX timestamp after which this link is no longer accessible */ - ExpirationTime?: number | null; - ContentKeyPacket: components["schemas"]["BinaryString2"]; - BlockURLs: components["schemas"]["ThumbnailURLInfoResponseDto"][]; - ThumbnailURLInfo: components["schemas"]["ThumbnailURLInfoResponseDto"]; - /** @deprecated */ - Blocks: string[]; - /** @deprecated */ - ThumbnailURL?: string | null; - }; - ShareURLContext: { - /** @description Share ID of the share highest in the tree with permissions */ - ContextShareID: string; - ShareURLs: components["schemas"]["ShareURLResponseDto2"][]; - /** @description Related link IDs and ancestors up to the share. */ - LinkIDs: components["schemas"]["Id2"][]; - }; - ShareURLResponseDto2: { - Token: string; - ShareURLID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - /** @description URL to use to access the ShareURL */ - PublicUrl: string; - ExpirationTime?: number | null; - LastAccessTime?: number | null; - CreateTime: number; - MaxAccesses: number; - NumAccesses: number; - Name?: components["schemas"]["PGPMessage2"] | null; - CreatorEmail: string; - /** - * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * - * @enum {integer} - */ - Permissions: 4 | 6; - /** @description Bitmap: - * - `1`: FLAG_CUSTOM_PASSWORD, - * - `2`: FLAG_RANDOM_PASSWORD */ - Flags: number; - UrlPasswordSalt: components["schemas"]["BinaryString2"]; - SharePasswordSalt: components["schemas"]["BinaryString2"]; - SRPVerifier: components["schemas"]["BinaryString2"]; - SRPModulusID: components["schemas"]["Id2"]; - Password: components["schemas"]["PGPMessage2"]; - SharePassphraseKeyPacket: components["schemas"]["BinaryString2"]; - }; - /** Link */ - ExtendedLinkTransformer2: { - /** - * @deprecated - * @description Shared flag. 0 => No public URL, 1 => shared with a public URL. Deprecated, use SharingDetails properties instead. - * @enum {integer} - */ - Shared: 0 | 1; - /** @deprecated */ - ShareUrls: { - /** @deprecated */ - ShareUrlId?: string; - ShareURLID?: string; - /** @deprecated */ - ShareID?: string; - /** @description URL Token (not always provided) */ - Token?: string; - /** - * @deprecated - * @description Expiration time timestamp of ShareURL. - */ - ExpireTime?: number; - /** @description Expiration Timestamp */ - ExpirationTime?: number; - /** @description Creation time timestamp of ShareURL. */ - CreateTime?: number; - /** - * @description Number of Accesses (by access is meant download; first block is enough to increase the counter) - * @example 1 - */ - NumAccesses?: number; - }[]; - /** @description Link sharing details, null if not shared. */ - SharingDetails: { - ShareID?: string; - /** @description Share URL linking to this file or folder */ - ShareUrl?: { - /** @deprecated */ - ShareUrlId?: string; - ShareURLID?: string; - /** @deprecated */ - ShareID?: string; - /** @description URL Token (not always provided) */ - Token?: string; - /** - * @deprecated - * @description Expiration time timestamp of ShareURL. - */ - ExpireTime?: number | null; - /** @description Expiration Timestamp */ - ExpirationTime?: number | null; - /** @description Creation time timestamp of ShareURL. */ - CreateTime?: number; - /** @description Number of Accesses (by access is meant download; first block is enough to increase the counter) */ - NumAccesses?: number; - } | null; - } | null; - /** - * @deprecated - * @description Deprecated, use `SharingDetails.ShareID` since there will only be one share per link. List of Shares related to this link. - */ - ShareIDs: string[]; - /** - * @deprecated - * @description Deprecated, use `SharingDetails.ShareURLs` and count URLs. Number of Share URLs linking to this file or folder. - */ - NbUrls: number; - /** - * @deprecated - * @description Deprecated, use `SharingDetails.ShareURLs` and count valid URLs. Number of active urls - */ - ActiveUrls: number; - /** - * @deprecated - * @description Deprecated, use `SharingDetails.ShareURLs` and check for valid URLs. Set if all URLs are expired. 0 => at least one valid URL, 1 => no usable URL - * @enum {integer} - */ - UrlsExpired: 0 | 1; - /** @description Extended attributes encrypted with link key */ - XAttr: string | null; - /** @description File properties */ - FileProperties: { - /** @description Content key packet */ - ContentKeyPacket?: string; - /** @description Signature of content key packet. Should be the signature of the (plain) Session Key, signed with the Node Key. Legacy versions must be accepted and can be a signature of the encrypted ContentKeyPacket and can be signed with the NodePassphraseEmail. */ - ContentKeyPacketSignature?: string; - /** @description Active revision */ - ActiveRevision?: { - /** @description Revision ID */ - ID?: string; - /** @description Creation time (UNIX timestamp) */ - CreateTime?: number; - /** @description Size of revision (in bytes) */ - Size?: number; - /** @description Signature of the manifest, signed with SignatureEmail */ - ManifestSignature?: string; - /** - * Format: email - * @description Signature email address for blocks, XAttributes and manifest - */ - SignatureEmail?: string; - /** - * Format: email - * @deprecated - * @description [DEPRECATED] Signature email address for blocks, XAttributes, and manifest - */ - SignatureAddress?: string; - /** - * @description State; Will always be active; 1=active - * @enum {integer} - */ - State?: 1; - /** - * @deprecated - * @description Revision has a thumbnail - * @enum {integer} - */ - Thumbnail?: 0 | 1; - /** - * @deprecated - * @description Download URL for the thumbnail block - */ - ThumbnailDownloadUrl?: string; - /** - * @deprecated - * @description Thumbnail properties - */ - ThumbnailURLInfo?: { - /** - * @deprecated - * @description Bare Download URL for the thumbnail block - */ - BareURL?: string; - /** - * @deprecated - * @description Token for the thumbnail block - */ - Token?: string; - }; - Thumbnails?: components["schemas"]["ThumbnailTransformer"][]; - Photo?: components["schemas"]["PhotoTransformer"] | null; - }; - } | null; - FolderProperties: { - /** @description Node hash key (signed since 1st August 2021 with either node or address key, after 1st May 2022 (on web, iOS unknown) changed to node key) */ - NodeHashKey?: string; - } | null; - /** @description ProtonDocument properties; optional */ - DocumentProperties?: { - /** @description Document size */ - Size?: number; - } | null; - /** @description Album properties; optional */ - AlbumProperties?: { - /** @description Is the album locked */ - Locked?: boolean; - /** @description ID of the album cover link */ - CoverLinkID?: string | null; - /** @description Last time a Photo was added to the Album */ - LastActivityTime?: number; - /** @description Amount of photos in album */ - PhotoCount?: number; - /** @description Node hash key */ - NodeHashKey?: string; - } | null; - /** @description Photo properties; optional */ - PhotoProperties?: { - /** @description A list of Albums the Photo-Link is part of */ - Albums?: { - /** @description Album Link ID */ - AlbumLinkID?: string; - /** @description NameHash in Album context (encrypted with Album-Link-NodeKey) */ - Hash?: string; - /** @description ContentHash in Album context (encrypted with Album-Link-NodeKey) */ - ContentHash?: string; - /** @description Timestamp Photo-Link was added to this album */ - AddedTime?: number; - }[]; - /** @description A list of tags assigned to the photo. The list will always be empty when requested by a user that is not the volume-owner. */ - Tags?: number[]; - } | null; - } & components["schemas"]["LinkTransformer"]; - ThumbnailResponse: { - ThumbnailID: components["schemas"]["Id2"]; - BareURL: string; - Token: string; - }; - ThumbnailErrorResponse: { - ThumbnailID: components["schemas"]["Id2"]; - Error: string; - Code: number; + URL?: string | null; + BareURL?: string | null; + /** + * @deprecated + * @default null + */ + EncSignature: components["schemas"]["PGPMessage"] | null; + /** + * Format: email + * @deprecated + * @description Email used to sign block + * @default null + */ + SignatureEmail: string | null; }; - LinkMapItemResponse: { - Index: number; - LinkID: components["schemas"]["Id2"]; - ParentLinkID?: components["schemas"]["Id2"] | null; - Type: components["schemas"]["NodeType3"]; - Name: components["schemas"]["PGPMessage2"]; + PhotoResponseDto: { + LinkID: components["schemas"]["Id"]; + /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + CaptureTime: number; + MainPhotoLinkID?: components["schemas"]["Id"] | null; + /** @description File name hash */ Hash?: string | null; - State: components["schemas"]["LinkState2"]; - Size: number; - MIMEType: string; - CreateTime: number; - ModifyTime: number; - /** @default null */ - NodeKey: components["schemas"]["PGPPrivateKey2"]; - /** @default null */ - NodePassphrase: components["schemas"]["PGPMessage2"]; - /** @default null */ - NodePassphraseSignature: components["schemas"]["PGPSignature2"]; - /** @default null */ - NodeSignatureEmail: string; - }; - VolumeDto: { - VolumeID: components["schemas"]["Id2"]; - UsedSpace: number; - }; - ShareDto: { - ShareID: components["schemas"]["Id2"]; - /** Format: email */ - CreatorEmail: string; - Key: components["schemas"]["PGPPrivateKey2"]; - Passphrase: components["schemas"]["PGPMessage2"]; - PassphraseSignature: components["schemas"]["PGPSignature2"]; - AddressID: components["schemas"]["Id2"]; - InviterSharePassphraseKeyPacketSignature?: components["schemas"]["PGPSignature2"] | null; - InviteeSharePassphraseSessionKeySignature?: components["schemas"]["PGPSignature2"] | null; - }; - FolderDetailsDto2: { - Link: components["schemas"]["LinkDto2"]; - Folder: components["schemas"]["FolderDto2"]; - /** @default null */ - Sharing: components["schemas"]["SharingDto2"] | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash?: string | null; + /** @description LinkIDs of related Photos if there are any */ + RelatedPhotosLinkIDs: components["schemas"]["Id"][]; /** - * @description Will be null if the user is not a member or is the owner. + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead * @default null */ - Membership: components["schemas"]["MembershipDto2"] | null; - /** @default null */ - File: null | null; - /** @default null */ - Album: null | null; + Exif: string | null; }; - /** - * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
5Organization* Root share for organization
- * @enum {integer} - */ - ShareType: 1 | 2 | 3 | 4 | 5; - /** - * @description

1=Active, 3=Restored

See values descriptions
See values descriptions
ValueDescription
1Active
2Deleted
3Restored
6Locked
- * @enum {integer} - */ - ShareState: 1 | 2 | 3 | 6; - /** - * @description

1=Regular, 2=Photo

See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
3Organization
- * @enum {integer} - */ - VolumeType: 1 | 2 | 3; - /** - * @description

1=folder, 2=file

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
- * @enum {integer} - */ - NodeType: 1 | 2 | 3; - /** @description An armored PGP Private Key */ - PGPPrivateKey2: string; - /** @description An armored PGP Message */ - PGPMessage2: string; - /** @description An armored PGP Signature */ - PGPSignature2: string; - MemberResponseDto: { - MemberID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - AddressID: components["schemas"]["Id2"]; - AddressKeyID: components["schemas"]["Id2"]; - /** Format: email */ - Inviter: string; + DetailedRevisionResponseDto: { + Blocks: components["schemas"]["BlockResponseDto"][]; + Photo?: components["schemas"]["PhotoResponseDto"] | null; + ID: components["schemas"]["Id"]; + ManifestSignature?: components["schemas"]["PGPSignature"] | null; + /** @description Size of revision (in bytes) */ + Size: number; + State: components["schemas"]["RevisionState"]; + XAttr?: components["schemas"]["PGPMessage"] | null; /** - * @description Permission bitfield, valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * + * @deprecated + * @description Flag stating if revision has a thumbnail * @enum {integer} */ - Permissions: 4 | 6 | 22; - /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - KeyPacket: string; - /** @description PGP signature of the member key packet (encrypted) by inviter */ - KeyPacketSignature: string; - /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ - SessionKeySignature: string; - State: components["schemas"]["ShareMemberState"]; - CreateTime: number; - ModifyTime: number; + Thumbnail: 0 | 1; /** @deprecated */ - CreationTime: number; + ThumbnailHash?: components["schemas"]["BinaryString"] | null; /** * @deprecated - * @description Deprecated and always null + * @description Size thumbnail in bytes; 0 if no thumbnail present + */ + ThumbnailSize: number; + Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; + ClientUID?: string | null; + /** @default null */ + CreateTime: number | null; + /** + * Format: email + * @description User's email associated with the share and used to sign the manifest and block contents. * @default null */ - Unlockable: boolean | null; - }; - KeyPacketResponseDto: { - AddressID: components["schemas"]["Id2"]; - AddressKeyID: components["schemas"]["Id2"]; - KeyPacket: components["schemas"]["BinaryString2"]; - State: components["schemas"]["ShareMemberState"]; + SignatureEmail: string | null; /** + * Format: email * @deprecated - * @description Deprecated and always null + * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature * @default null */ - Unlockable: boolean | null; + SignatureAddress: string | null; }; - ShareResponseDto: { - ShareID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - Type: components["schemas"]["ShareType"]; - State: components["schemas"]["ShareState"]; - VolumeType: components["schemas"]["VolumeType"]; - /** Format: email */ - Creator: string; - Locked?: boolean | null; - CreateTime: number; - ModifyTime: number; - LinkID: components["schemas"]["Id2"]; + GetRevisionResponseDto: { + Revision: components["schemas"]["DetailedRevisionResponseDto"]; /** - * @deprecated - * @description Deprecated: Use `CreateTime` + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - CreationTime: number; - /** @deprecated */ - PermissionsMask: number; - /** @deprecated */ - LinkType: number; - /** @deprecated */ - Flags: number; - /** @deprecated */ - BlockSize: number; - /** @deprecated */ - VolumeSoftDeleted: boolean; - }; - ShareKPMigrationData: { - /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ - ShareID: string; - /** @description Key packet to decrypt the share passphrase, encrypted with the node key, base64 encoded */ - PassphraseNodeKeyPacket: string; + Code: 1000; }; - /** @description Share unable to be migrated with reason and code; NOT_EXISTS, INCOMPATIBLE_STATE, PERMISSION_DENIED, ENCRYPTION_VERIFICATION_FAILED */ - ShareKPMigrationError: { - ShareID: components["schemas"]["Id2"]; - Error: string; - Code: number; + GetSharedFileInfoRequestDto: { + /** @default 1 */ + FromBlockIndex: number; + /** @default null */ + PageSize: number | null; + ClientEphemeral: components["schemas"]["BinaryString"]; + ClientProof: components["schemas"]["BinaryString"]; + SRPSession: components["schemas"]["BinaryString"]; }; - LinkSharedByMeResponseDto: { - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - ContextShareID: components["schemas"]["Id2"]; + GetSharedFileInfoPayloadDto: { + SharePasswordSalt: components["schemas"]["BinaryString"]; + SharePassphrase: components["schemas"]["PGPMessage"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + Name: components["schemas"]["PGPMessage"]; + Size: number; + MIMEType: string; + /** @description UNIX timestamp after which this link is no longer accessible */ + ExpirationTime?: number | null; + ContentKeyPacket: components["schemas"]["BinaryString"]; + BlockURLs: components["schemas"]["ThumbnailURLInfoResponseDto"][]; + ThumbnailURLInfo: components["schemas"]["ThumbnailURLInfoResponseDto"]; + /** @deprecated */ + Blocks: string[]; + /** @deprecated */ + ThumbnailURL?: string | null; }; - LinkSharedWithMeResponseDto: { - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - ShareTargetType: components["schemas"]["TargetType2"]; + GetSharedFileInfoResponseDto: { + ServerProof: components["schemas"]["BinaryString"]; + Payload: components["schemas"]["GetSharedFileInfoPayloadDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; - ExternalInvitationRequestDto: { - InviterAddressID: components["schemas"]["Id"]; - /** Format: email */ - InviteeEmail: string; + ShareURLResponseDto: { + Token: string; + ShareURLID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + /** @description URL to use to access the ShareURL */ + PublicUrl: string; + ExpirationTime?: number | null; + LastAccessTime?: number | null; + CreateTime: number; + MaxAccesses: number; + NumAccesses: number; + Name?: components["schemas"]["PGPMessage"] | null; + CreatorEmail: string; /** - * @description Permission bitfield, valid permissions: + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: * - 4: read access * - 6: read + write access - * - 22: read + write + admin access * * @enum {integer} */ - Permissions: 4 | 6 | 22; - /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ - ExternalInvitationSignature: string; + Permissions: 4 | 6; + /** @description Bitmap: + * - `1`: FLAG_CUSTOM_PASSWORD, + * - `2`: FLAG_RANDOM_PASSWORD */ + Flags: number; + UrlPasswordSalt: components["schemas"]["BinaryString"]; + SharePasswordSalt: components["schemas"]["BinaryString"]; + SRPVerifier: components["schemas"]["BinaryString"]; + SRPModulusID: components["schemas"]["Id"]; + Password: components["schemas"]["PGPMessage"]; + SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; }; - InvitationEmailDetailsRequestDto: { - Message?: string | null; - ItemName?: string | null; + ShareURLContext: { + /** @description Share ID of the share highest in the tree with permissions */ + ContextShareID: string; + ShareURLs: components["schemas"]["ShareURLResponseDto"][]; + /** @description Related link IDs and ancestors up to the share. */ + LinkIDs: components["schemas"]["Id"][]; }; - ExternalInvitationResponseDto: { - ExternalInvitationID: components["schemas"]["Id2"]; - /** Format: email */ - InviterEmail: string; - /** Format: email */ - InviteeEmail: string; + ShareURLContextsCollection: { + ShareURLContexts: components["schemas"]["ShareURLContext"][]; + /** @description Indicates there may be more ShareURLs */ + More: boolean; /** - * @description Permission bitfield, valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - Permissions: 4 | 6 | 22; - /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ - ExternalInvitationSignature: string; - State: components["schemas"]["ExternalInvitationState"]; - CreateTime: number; + Code: 1000; }; - UserRegisteredExternalInvitationItemDto: { - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - ExternalInvitationID: components["schemas"]["Id2"]; + ListShareURLsResponseDto: { + ShareURLs: components["schemas"]["ShareURLResponseDto"][]; + /** + * @deprecated + * @description If the Recursive query parameter is set, also returns the related links and ancestors up to the share as a dictionary by LinkID. + */ + Links: { + [key: string]: components["schemas"]["ExtendedLinkTransformer"]; + }; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; - InvitationRequestDto: { - InviterEmail: components["schemas"]["AddressEmail"]; - InviteeEmail: components["schemas"]["AddressEmail"]; + CreateShareURLRequestDto: { + CreatorEmail: components["schemas"]["AddressEmail"]; /** - * @description Permission bitfield, valid permissions: + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: * - 4: read access * - 6: read + write access - * - 22: read + write + admin access * * @enum {integer} */ - Permissions: 4 | 6 | 22; - /** @description Encrypting the share passphrase's session key with the invitee's public address key, base64 encoded */ - KeyPacket: string; - /** @description Signature of the above member key packet with the private key of the user (inviter) and the signature context `drive.share-member.inviter`, base64 encoded */ - KeyPacketSignature: string; - /** @default null */ - ExternalInvitationID: components["schemas"]["Id"] | null; + Permissions: 4 | 6; + UrlPasswordSalt: components["schemas"]["BinaryString"]; + SharePasswordSalt: components["schemas"]["BinaryString"]; + SRPVerifier: components["schemas"]["BinaryString"]; + SRPModulusID: components["schemas"]["Id"]; + /** @description Bitmap: 1 = custom password set, 2 = random password set */ + Flags: number; + SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; + /** @description PGP encrypted password. The password is encrypted with the user's address key. */ + Password: string; + /** @description Maximum number of times this link can be accessed. 0 for infinite */ + MaxAccesses: number; + /** + * @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. Optional + * @default null + */ + ExpirationTime: number | null; + /** + * @description Number of seconds after which this link is no longer accessible. Maximum 90 days. Optional + * @default null + */ + ExpirationDuration: number | null; + /** + * @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. + * @default null + */ + Name: string | null; }; - InvitationResponseDto: { - InvitationID: components["schemas"]["Id2"]; - /** Format: email */ - InviterEmail: string; - /** Format: email */ - InviteeEmail: string; + UpdateShareURLRequestDto: { + /** @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. */ + ExpirationTime: number; + /** @description Number of seconds after which this link is no longer accessible. Maximum 90 days. */ + ExpirationDuration?: number | null; + /** @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. */ + Name: number; /** - * @description Permission bitfield, valid permissions: + * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: * - 4: read access * - 6: read + write access - * - 22: read + write + admin access * - * @enum {integer} + * @default null + * @enum {integer|null} */ - Permissions: 4 | 6 | 22; - /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - KeyPacket: string; - /** @description PGP signature of the member key packet (encrypted) by inviter */ - KeyPacketSignature: string; - CreateTime: number; + Permissions: 4 | 6 | null; + /** @default null */ + UrlPasswordSalt: components["schemas"]["BinaryString"] | null; + /** @default null */ + SharePasswordSalt: components["schemas"]["BinaryString"] | null; + /** @default null */ + SRPVerifier: components["schemas"]["BinaryString"] | null; + /** @default null */ + SRPModulusID: components["schemas"]["Id"] | null; + /** + * @description Bitmap: 1 = custom password set, 2 = random password set + * @default null + */ + Flags: number | null; + /** @default null */ + SharePassphraseKeyPacket: components["schemas"]["BinaryString"] | null; + /** + * @description PGP encrypted password. The password is encrypted with the user's address key. + * @default null + */ + Password: components["schemas"]["PGPMessage"] | null; + /** + * @description Maximum number of times this link can be accessed. 0 for infinite + * @default null + */ + MaxAccesses: number | null; }; - PendingInvitationItemDto: { - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - InvitationID: components["schemas"]["Id2"]; - ShareTargetType: components["schemas"]["TargetType2"]; + DeleteMultipleShareURLsRequestDto: { + /** @description List of ShareURL ids to delete. */ + ShareURLIDs: components["schemas"]["EncryptedId"][]; }; - ShareResponseDto2: { - ShareID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - Passphrase: components["schemas"]["PGPMessage2"]; - ShareKey: components["schemas"]["PGPPrivateKey2"]; - /** Format: email */ - CreatorEmail: string; - ShareTargetType: components["schemas"]["TargetType2"]; + ThumbnailIDsListInput: { + /** @description List of encrypted ThumbnailIDs. Maximum 30. */ + ThumbnailIDs: components["schemas"]["Id"][]; }; - LinkResponseDto: { - Type: components["schemas"]["NodeType3"]; - LinkID: components["schemas"]["Id2"]; - Name: components["schemas"]["PGPMessage2"]; - MIMEType?: string | null; + ThumbnailResponse: { + ThumbnailID: components["schemas"]["Id"]; + BareURL: string; + Token: string; }; - ContextShareDto: { - VolumeID: components["schemas"]["Id2"]; - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; + ThumbnailErrorResponse: { + ThumbnailID: components["schemas"]["Id"]; + Error: string; + Code: number; }; - MemberResponseDto2: { - MemberID: components["schemas"]["Id2"]; - /** Format: email */ - InviterEmail: string; - /** Format: email */ - Email: string; + ListThumbnailsResponse: { + Thumbnails: components["schemas"]["ThumbnailResponse"][]; + Errors: components["schemas"]["ThumbnailErrorResponse"][]; /** - * @description Permission bitfield, cannot exceed the inviter's permissions. Valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - Permissions: 4 | 6 | 22; - /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - KeyPacket: string; - /** @description PGP signature of the member key packet (encrypted) by inviter */ - KeyPacketSignature: string; - /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ - SessionKeySignature: string; - CreateTime: number; + Code: 1000; }; - SecurityResponseResultDto: { - Hash: string; - /** @description Whether file is safe or not, true if yes, false if not */ - Safe: boolean; + LinkMapQueryParameters: { + /** @default null */ + SessionName: string | null; + /** @default null */ + LastIndex: number | null; + /** @default 500 */ + PageSize: number; }; - SecurityResponseErrorDto: { - Hash: string; - /** - * @description An error message describing the error, translated. Can be displayed directly to user. - * @example We cannot check this file at present, please proceed with caution - */ - Error: string; + LinkMapItemResponse: { + Index: number; + LinkID: components["schemas"]["Id"]; + ParentLinkID?: components["schemas"]["Id"] | null; + Type: components["schemas"]["NodeType2"]; + Name: components["schemas"]["PGPMessage"]; + Hash?: string | null; + State: components["schemas"]["LinkState2"]; + Size: number; + MIMEType: string; + CreateTime: number; + ModifyTime: number; + /** @default null */ + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @default null */ + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @default null */ + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** @default null */ + NodeSignatureEmail: string; }; - UserSettings: { + LinkMapResponse: { + SessionName: string; + More: number; + Total: number; + Links: components["schemas"]["LinkMapItemResponse"][]; /** - * @deprecated - * @description [DEPRECATED] Always NULL + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - B2BPhotosEnabled: null; - Layout?: components["schemas"]["LayoutSetting2"] | null; - Sort?: components["schemas"]["SortSetting2"] | null; - /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ - RevisionRetentionDays?: components["schemas"]["RevisionRetentionDays2"] | null; - /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ - DocsCommentsNotificationsEnabled?: boolean | null; - /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ - DocsCommentsNotificationsIncludeDocumentName?: boolean | null; - /** @description Indicates user-preferred font in Proton Docs. */ - DocsFontPreference?: string | null; - /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ - PhotoTags?: number[] | null; + Code: 1000; }; - Defaults: { + VolumeDto: { + VolumeID: components["schemas"]["Id"]; + UsedSpace: number; + }; + ShareDto: { + ShareID: components["schemas"]["Id"]; + /** Format: email */ + CreatorEmail: string; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; + AddressID: components["schemas"]["Id"]; + InviterSharePassphraseKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + InviteeSharePassphraseSessionKeySignature?: components["schemas"]["PGPSignature"] | null; + }; + PrimaryRootShareResponseDto: { + Volume: components["schemas"]["VolumeDto"]; + Share: components["schemas"]["ShareDto"]; + Link: components["schemas"]["FolderDetailsDto"]; /** - * @deprecated - * @description [DEPRECATED] Always true + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - B2BPhotosEnabled: boolean; - RevisionRetentionDays: components["schemas"]["RevisionRetentionDays3"]; - /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ - DocsCommentsNotificationsEnabled: boolean; - /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. */ - DocsCommentsNotificationsIncludeDocumentName: boolean; - /** @description Default order and visibility of Photo Tags. */ - PhotoTags: number[]; + Code: 1000; }; /** - * @description
See values descriptions
See values descriptions
ValueDescription
0List
1Grid
+ * @description

1=Main, 2=Standard, 3=Device, 4=Photo

See values descriptions
ValueNameDescription
1Main* Root share for my files
2Standard* Collaborative share anywhere in the link tree (but not at the root folder as it cannot be shared)
3Device* Root share of devices
4Photo* Root share for photos
5Organization* Root share for organization
* @enum {integer} */ - LayoutSetting: 0 | 1; + ShareType: 1 | 2 | 3 | 4 | 5; /** - * @description
See values descriptions
See values descriptions
ValueDescription
-4ModifiedDesc
-2SizeDesc
-1NameDesc
1NameAsc
2SizeAsc
4ModifiedAsc
+ * @description

1=Active, 3=Restored

See values descriptions
ValueDescription
1Active
2Deleted
3Restored
6Locked
* @enum {integer} */ - SortSetting: -4 | -2 | -1 | 1 | 2 | 4; + ShareState: 1 | 2 | 3 | 6; + /** + * @description

1=Regular, 2=Photo

See values descriptions
ValueDescription
1Regular
2Photo
3Organization
+ * @enum {integer} + */ + VolumeType2: 1 | 2 | 3; + /** + * @description

1=folder, 2=file

See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @enum {integer} + */ + NodeType3: 1 | 2 | 3; /** - * @description

Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default.

See values descriptions
See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
+ * @description

1=active, 3=locked

See values descriptions
ValueNameDescription
1Active
2Deleted
3Locked* Locked membership can have two reasons: + * * - either the associated address was disabled/deleted, e.g. due to account deletion + * * - or the associated address key was made inactive due to a password reset + * * + * * It means the membership cannot be used for decryption unless it is restored with account recovery.
* @enum {integer} */ - RevisionRetentionDays: 0 | 7 | 30 | 180 | 365 | 3650; - VolumeResponseDto: { - ID: components["schemas"]["Id2"]; + ShareMemberState: 1 | 2 | 3; + MemberResponseDto: { + MemberID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + AddressID: components["schemas"]["Id"]; + AddressKeyID: components["schemas"]["Id"]; + /** Format: email */ + Inviter: string; /** - * @deprecated - * @description Deprecated, use `CreateTime` instead + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} */ + Permissions: 4 | 6 | 22; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + KeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: string; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + SessionKeySignature: string; + State: components["schemas"]["ShareMemberState"]; + CreateTime: number; + ModifyTime: number; + /** @deprecated */ CreationTime: number; /** * @deprecated + * @description Deprecated and always null * @default null */ - MaxSpace: number | null; - VolumeID: components["schemas"]["Id2"]; - CreateTime: number; - ModifyTime: number; - /** @description Used space in bytes */ - UsedSpace: number; - DownloadedBytes: number; - UploadedBytes: number; - State: components["schemas"]["VolumeState"]; - Share: components["schemas"]["ShareReferenceResponseDto"]; - Type: components["schemas"]["VolumeType2"]; - }; - OrgVolumeResponseDto: { - ShareID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - /** @description Name of the org. volume */ - Name: string; - /** @description Membership creation time */ - CreateTime: number; - }; - OrgVolumeForAdminResponseDto: { - VolumeID: components["schemas"]["Id2"]; - /** @description Name of the org. volume */ - Name: string; + Unlockable: boolean | null; }; - RestoreMainShareDto: { - /** @description ShareID of the existing, locked main share */ - LockedShareID: string; - /** @description Folder name as armored PGP message */ - Name: string; - /** @description Hash of the name */ - Hash: string; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; + KeyPacketResponseDto: { + AddressID: components["schemas"]["Id"]; + AddressKeyID: components["schemas"]["Id"]; + KeyPacket: components["schemas"]["BinaryString"]; + State: components["schemas"]["ShareMemberState"]; /** - * @description Node Hash Key should be provided if it needs to be signed because it was unsigned or signed with the address key (legacy). It should be signed with the new parent's node key. If it was properly signed with the parent node key, it should not be updated. Armored PGP message. + * @deprecated + * @description Deprecated and always null * @default null */ - NodeHashKey: string | null; - }; - RestoreRootShareDto: { - /** @description ShareID of the existing share on the old volume */ - LockedShareID: string; - /** @description Key packet for the share passphrase, encrypted with the active key associated with the new volume. Encoded with Base64. */ - ShareKeyPacket: string; - /** @description Signed with new key as armored PGP signature */ - PassphraseSignature: string; + Unlockable: boolean | null; }; - ConflictErrorDetailsDto: { - ConflictLinkID: components["schemas"]["Id"]; - /** - * @description A conflicting Revision in Active state. - * @default null - */ - ConflictRevisionID: string | null; - /** - * @description A conflicting Revision in Draft state. - * @default null - */ - ConflictDraftRevisionID: string | null; + BootstrapShareResponseDto: { + ShareID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + Type: components["schemas"]["ShareType"]; + State: components["schemas"]["ShareState"]; + VolumeType: components["schemas"]["VolumeType2"]; + /** Format: email */ + Creator: string; + Locked?: boolean | null; + CreateTime: number; + ModifyTime: number; + LinkID: components["schemas"]["Id"]; /** - * @description ClientUID of conflicting Revision if in Draft state. - * @default null + * @deprecated + * @description Deprecated: Use `CreateTime` */ - ConflictDraftClientUID: string | null; + CreationTime: number; + /** @deprecated */ + PermissionsMask: number; + LinkType: components["schemas"]["NodeType3"]; + /** @deprecated */ + Flags: number; + /** @deprecated */ + BlockSize: number; + /** @deprecated */ + VolumeSoftDeleted: boolean; + Key: components["schemas"]["PGPPrivateKey"]; + Passphrase: components["schemas"]["PGPMessage"]; + PassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description Address ID of the current user's address for the membership of this share. Can be missing if the user is not a direct member of the share. */ + AddressID?: string | null; /** * @deprecated - * @description [DEPRECATED] for backwards compatibility on create revision, same value as ConflictDraftRevisionID - * @default null + * @description Clients should not use this field but pass the address keyring when validating and decrypting related fields. */ - RevisionID: string | null; - }; - /** Thumbnail */ - ThumbnailTransformer: { - ThumbnailID: string; - /** @enum {integer} */ - Type: 1 | 2 | 3; - /** @description Base64 encoded thumbnail-content-hash */ - Hash: string; - Size: number; - }; - /** Photo */ - PhotoTransformer: { - LinkID: string; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ - CaptureTime: number; - MainPhotoLinkID: string | null; - /** @description File name hash */ - Hash: string; + AddressKeyID?: string | null; + /** @description Your own memberships */ + Memberships: components["schemas"]["MemberResponseDto"][]; /** * @deprecated - * @description Deprecated: Clients persist exif information in xAttr instead + * @description Deprecated, use `Memberships` instead */ - Exif?: string | null; - /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash: string | null; - /** @description LinkIDs of related Photos if there are any */ - RelatedPhotosLinkIDs: string[]; - }; - /** Link */ - LinkTransformer: { - LinkID: string; - ParentLinkID: string | null; - VolumeID: string; + PossibleKeyPackets: components["schemas"]["KeyPacketResponseDto"][]; + RootLinkRecoveryPassphrase?: components["schemas"]["PGPMessage"] | null; + /** @description Indicates if editor members of this share could reshare it or not */ + EditorsCanShare: boolean; /** - * @description Node type (1=folder, 2=file) + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - Type: 1 | 2; + Code: 1000; + }; + GetHighestContextForDocumentResponse: { + ContextShareID: components["schemas"]["Id"]; /** - * @description Link name - * @example ----BEGIN PGP MESSAGE----... + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - Name: string; + Code: 1000; + }; + ShareResponseDto: { + ShareID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + Type: components["schemas"]["ShareType"]; + State: components["schemas"]["ShareState"]; + VolumeType: components["schemas"]["VolumeType2"]; + /** Format: email */ + Creator: string; + Locked?: boolean | null; + CreateTime: number; + ModifyTime: number; + LinkID: components["schemas"]["Id"]; /** - * Format: email - * @description Link name signature email (signed since 1st January 2021) + * @deprecated + * @description Deprecated: Use `CreateTime` */ - NameSignatureEmail: string; - /** @description Name Hash */ - Hash: string | null; + CreationTime: number; + /** @deprecated */ + PermissionsMask: number; + /** @deprecated */ + LinkType: number; + /** @deprecated */ + Flags: number; + /** @deprecated */ + BlockSize: number; + /** @deprecated */ + VolumeSoftDeleted: boolean; + }; + ListSharesResponseDto: { + Shares: components["schemas"]["ShareResponseDto"][]; /** - * @description State (0=draft, 1=active, 2=trashed) + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - State: 0 | 1 | 2; + Code: 1000; + }; + TransferInput: { + /** @description The ID of the new address */ + AddressID: string; + /** @description The ID of the new key */ + KeyID: string; + /** @description Armored signature of the share passphrase, signed with the users's address with AddressID. */ + SharePassphraseSignature: string; + /** @description Base64 encoded key packet for the share passphrase, reusing the same session key as previously, and encrypted for the key referenced by the KeyID. */ + MemberKeyPacket: string; + }; + ShareKPMigrationData: { + /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ + ShareID: string; + /** @description Key packet to decrypt the share passphrase, encrypted with the node key, base64 encoded */ + PassphraseNodeKeyPacket: string; + }; + MigrateSharesRequestDto: { /** - * @deprecated - * @description [Deprecated] ExpirationTime (always null) + * @description The sum of PassphraseNodeKeyPacket-pairs and UnreadableShareIDs should not exceed 50 + * @default [] */ - ExpirationTime: number | null; + PassphraseNodeKeyPackets: components["schemas"]["ShareKPMigrationData"][]; /** - * @deprecated - * @description Encrypted size (for files of active revisions, better to use FileProperties > ActiveRevision > Size) + * @description ShareIDs of unmigrated Shares that the client could not decrypt and should be locked + * @default [] */ - Size: number; - /** @description Encrypted size of Node (all active and obsolete revisions for files) */ - TotalSize: number; + UnreadableShareIDs: components["schemas"]["Id"][]; + }; + /** @description Share unable to be migrated with reason and code; NOT_EXISTS, INCOMPATIBLE_STATE, PERMISSION_DENIED, ENCRYPTION_VERIFICATION_FAILED */ + ShareKPMigrationError: { + ShareID: components["schemas"]["Id"]; + Error: string; + Code: number; + }; + MigrateSharesResponseDto: { + /** @description ShareIDs successfully migrated */ + ShareIDs: components["schemas"]["Id"][]; + /** @description ShareIDs not migrated with reason and error code */ + Errors: components["schemas"]["ShareKPMigrationError"][]; /** - * @description Mime type - * @example application/ms-xls + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - MIMEType: string; + Code: 1000; + }; + UnmigratedSharesResponseDto: { + /** @description ShareIDs that can be migrated */ + ShareIDs: components["schemas"]["Id"][]; /** - * @deprecated - * @description Always returns 1 + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - Attributes: 1; + Code: 1000; + }; + CreateShareRequestDto: { + AddressID: components["schemas"]["AddressID"]; + RootLinkID: components["schemas"]["Id"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + /** @description Full PGP message containing (optionally) PassphraseNodeKP and SharePassphrase-KP and data-packet (encrypted SharePassphrase) -> in this exact order */ + SharePassphrase: string; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description Key packet for passphrase of referenced link's node key passphrase */ + PassphraseKeyPacket: string; + NameKeyPacket: components["schemas"]["BinaryString"]; /** * @deprecated - * @description Always returns 7, read+write+execute - */ - Permissions: number; - /** - * @description Node Key - * @example -----BEGIN PGP PRIVATE KEY BLOCK-----... + * @default null */ - NodeKey: string; + Name: string | null; + }; + LinkSharedByMeResponseDto: { + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + ContextShareID: components["schemas"]["Id"]; + }; + SharedByMeResponseDto: { + Links: components["schemas"]["LinkSharedByMeResponseDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; /** - * @description Node passphrase - * @example ----BEGIN PGP MESSAGE-----... + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - NodePassphrase: string; + Code: 1000; + }; + /** + * @description

The target type of the Share that is corresponding to this invitation.
+ * This should not be used as source of information to know what NodeType or MIMEType the targeted Share is.

See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
+ * @enum {integer} + */ + TargetType: 0 | 1 | 2 | 3 | 4 | 5; + LinkSharedWithMeResponseDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + ShareTargetType: components["schemas"]["TargetType"]; + }; + SharedWithMeResponseDto2: { + Links: components["schemas"]["LinkSharedWithMeResponseDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; /** - * @description Node passphrase signature - * @example -----BEGIN PGP SIGNATURE-----... + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - NodePassphraseSignature: string; + Code: 1000; + }; + ExternalInvitationRequestDto: { + InviterAddressID: components["schemas"]["Id"]; + /** Format: email */ + InviteeEmail: string; /** - * Format: email - * @description Signature email address used for passphrase, should be the user's address associated with the Share. + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} */ - SignatureEmail: string; + Permissions: 4 | 6 | 22; + /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ + ExternalInvitationSignature: string; + }; + InvitationEmailDetailsRequestDto: { + Message?: string | null; + ItemName?: string | null; + }; + InviteExternalUserRequestDto: { + ExternalInvitation: components["schemas"]["ExternalInvitationRequestDto"]; + /** @default null */ + EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; + }; + /** + * @description
See values descriptions
ValueDescription
1Pending
2UserRegistered
4Deleted
+ * @enum {integer} + */ + ExternalInvitationState: 1 | 2 | 4; + ExternalInvitationResponseDto: { + ExternalInvitationID: components["schemas"]["Id"]; + /** Format: email */ + InviterEmail: string; + /** Format: email */ + InviteeEmail: string; /** - * Format: email - * @deprecated - * @description [Deprecated] Signature email address used for passphrase + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} */ - SignatureAddress: string; - /** @description Creation timestamp */ + Permissions: 4 | 6 | 22; + /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ + ExternalInvitationSignature: string; + State: components["schemas"]["ExternalInvitationState"]; CreateTime: number; - /** @description Last modification timestamp (on API, real modify date is stored in XAttr) */ - ModifyTime: number; - /** @description Timestamp, time at which the file was trashed, null if file is not trashed. */ - Trashed: number | null; }; - DetailedRevisionResponseDto2: { - Blocks: components["schemas"]["BlockResponseDto2"][]; - Photo?: components["schemas"]["PhotoResponseDto2"] | null; - ID: components["schemas"]["Id"]; - ManifestSignature?: components["schemas"]["PGPSignature"] | null; - /** @description Size of revision (in bytes) */ - Size: number; - State: components["schemas"]["RevisionState2"]; - XAttr?: components["schemas"]["PGPMessage"] | null; + InviteExternalUserResponseDto: { + ExternalInvitation: components["schemas"]["ExternalInvitationResponseDto"]; /** - * @deprecated - * @description Flag stating if revision has a thumbnail + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - Thumbnail: 0 | 1; - /** @deprecated */ - ThumbnailHash?: components["schemas"]["BinaryString"] | null; + Code: 1000; + }; + ListShareExternalInvitationsResponseDto: { + ExternalInvitations: components["schemas"]["ExternalInvitationResponseDto"][]; /** - * @deprecated - * @description Size thumbnail in bytes; 0 if no thumbnail present + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - ThumbnailSize: number; - Thumbnails: components["schemas"]["ThumbnailResponseDto2"][]; - ClientUID?: string | null; - /** @default null */ - CreateTime: number | null; + Code: 1000; + }; + UserRegisteredExternalInvitationItemDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + ExternalInvitationID: components["schemas"]["Id"]; + }; + ListUserRegisteredExternalInvitationResponseDto: { + ExternalInvitations: components["schemas"]["UserRegisteredExternalInvitationItemDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; /** - * Format: email - * @description User's email associated with the share and used to sign the manifest and block contents. - * @default null + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - SignatureEmail: string | null; + Code: 1000; + }; + UpdateExternalInvitationRequestDto: { /** - * Format: email - * @deprecated - * @description [DEPRECATED] use `SignatureEmail` Email address corresponding to the signature - * @default null + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} */ - SignatureAddress: string | null; - }; - ShareConflictErrorDetailsDto: { - ConflictLinkID: components["schemas"]["Id"]; - /** @description A conflicting Share on the Link. */ - ConflictShareID: string; - }; - AlbumLinkResponseDto: { - LinkID: components["schemas"]["Id2"]; + Permissions: 4 | 6 | 22; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Active
3Locked
- * @enum {integer} - */ - VolumeState: 1 | 3; - ShareReferenceResponseDto: { - ShareID: components["schemas"]["Id2"]; - ID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; + AcceptInvitationRequestDto: { + /** @description Signature of the share passphrase's session key with the private key of the user (invitee) and the signature context `drive.share-member.member`, base64 encoded */ + SessionKeySignature: string; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Regular
2Photo
3Organization
- * @enum {integer} - */ - VolumeType2: 1 | 2 | 3; - /** - * @description

Can be null if the Link was deleted

See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
- * @enum {integer} - */ - LinkState: 0 | 1 | 2; - ListPhotosAlbumRelatedPhotoItemResponseDto: { - LinkID: components["schemas"]["Id2"]; - CaptureTime: number; - Hash: string; - ContentHash: string; + InvitationRequestDto: { + InviterEmail: components["schemas"]["AddressEmail"]; + InviteeEmail: components["schemas"]["AddressEmail"]; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description Encrypting the share passphrase's session key with the invitee's public address key, base64 encoded */ + KeyPacket: string; + /** @description Signature of the above member key packet with the private key of the user (inviter) and the signature context `drive.share-member.inviter`, base64 encoded */ + KeyPacketSignature: string; + /** @default null */ + ExternalInvitationID: components["schemas"]["Id"] | null; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Active
3Deleted
- * @enum {integer} - */ - BookmarkShareURLState: 1 | 3; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Disabled
1Enabled
- * @enum {integer} - */ - DeviceSyncState: 0 | 1; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Windows
2MacOS
3Linux
- * @enum {integer} - */ - DeviceType: 1 | 2 | 3; - DeviceDataDto3: { - DeviceID: components["schemas"]["Id2"]; - VolumeID: components["schemas"]["Id2"]; - SyncState: components["schemas"]["DeviceSyncState2"]; - Type: components["schemas"]["DeviceType2"]; - /** @description UNIX timestamp when the Device got last synced */ - LastSyncTime?: number | null; - CreateTime: number; - ModifyTime: number; + InviteUserRequestDto: { + Invitation: components["schemas"]["InvitationRequestDto"]; + /** @default null */ + EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; + }; + InvitationResponseDto: { + InvitationID: components["schemas"]["Id"]; + /** Format: email */ + InviterEmail: string; + /** Format: email */ + InviteeEmail: string; /** - * @deprecated - * @description Deprecated: use `CreateTime` + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} */ - CreationTime: number; + Permissions: 4 | 6 | 22; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + KeyPacket: string; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: string; + CreateTime: number; }; - ShareDataDto4: { - ShareID: components["schemas"]["Id2"]; - LinkID: components["schemas"]["Id2"]; - /** @deprecated */ - Name: string; + InviteUserResponseDto: { + Invitation: components["schemas"]["InvitationResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; - DeviceDto: { - DeviceID: components["schemas"]["Id2"]; - CreateTime: number; - ModifyTime: number; - Type: components["schemas"]["DeviceType2"]; + ListShareInvitationsResponseDto: { + Invitations: components["schemas"]["InvitationResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Delete
1Create
2Update
3UpdateMetadata
+ * @description
See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
* @enum {integer} */ - EventType: 0 | 1 | 2 | 3; - EventLinkDataDto: { - LinkID: components["schemas"]["Id2"]; - ParentLinkID?: components["schemas"]["Id2"] | null; - IsShared: boolean; - IsTrashed: boolean; + TargetType2: 0 | 1 | 2 | 3 | 4 | 5; + ListPendingInvitationQueryParameters: { + AnchorID?: components["schemas"]["Id"] | null; + /** @default 150 */ + PageSize: number; + /** @default null */ + ShareTargetTypes: components["schemas"]["TargetType2"][] | null; }; - RelatedPhotoDto: { - LinkID: components["schemas"]["Id"]; - /** @description Name, reusing same session key as previously. */ - Name: string; - /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; - /** @description Name hash */ - Hash: string; - /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ - ContentHash: string; + PendingInvitationItemDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + InvitationID: components["schemas"]["Id"]; + ShareTargetType: components["schemas"]["TargetType"]; }; - LinkDto: { - LinkID: components["schemas"]["Id"]; - Type: components["schemas"]["NodeType4"]; - ParentLinkID?: components["schemas"]["Id"] | null; - State: components["schemas"]["LinkState3"]; - CreateTime: number; - ModifyTime: number; - TrashTime?: number | null; - Name: components["schemas"]["PGPMessage"]; - NameHash?: string | null; - NodeKey: components["schemas"]["PGPPrivateKey"]; - NodePassphrase: components["schemas"]["PGPMessage"]; - NodePassphraseSignature: components["schemas"]["PGPSignature"]; - /** Format: email */ - SignatureEmail?: string | null; + ListPendingInvitationResponseDto: { + Invitations: components["schemas"]["PendingInvitationItemDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID?: string | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ShareResponseDto2: { + ShareID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + Passphrase: components["schemas"]["PGPMessage"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; /** Format: email */ - NameSignatureEmail?: string | null; - /** @default null */ - DirectPermissions: number | null; + CreatorEmail: string; + ShareTargetType: components["schemas"]["TargetType"]; }; - FileDto: { - ActiveRevision?: components["schemas"]["ActiveRevisionDto"] | null; - TotalEncryptedSize: number; - ContentKeyPacket: components["schemas"]["BinaryString"]; - MediaType?: string | null; - ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + LinkResponseDto: { + Type: components["schemas"]["NodeType2"]; + LinkID: components["schemas"]["Id"]; + Name: components["schemas"]["PGPMessage"]; + MIMEType?: string | null; }; - SharingDto: { - ShareID: components["schemas"]["Id"]; - ShareURLID?: components["schemas"]["Id"] | null; + PendingInvitationResponseDto: { + Invitation: components["schemas"]["InvitationResponseDto"]; + Share: components["schemas"]["ShareResponseDto2"]; + Link: components["schemas"]["LinkResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; - MembershipDto: { - ShareID: components["schemas"]["Id"]; - MembershipID: components["schemas"]["Id"]; + UpdateInvitationRequestDto: { /** * @description Permission bitfield, valid permissions: * - 4: read access @@ -7121,333 +6670,412 @@ export interface components { * @enum {integer} */ Permissions: 4 | 6 | 22; - InviteTime: number; + }; + ContextShareDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + }; + LinkAccessesResponseDto: { + /** @default null */ + ContextShare: components["schemas"]["ContextShareDto"] | null; + /** @default null */ + Invitations: components["schemas"]["PendingInvitationItemDto"][] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + MemberResponseDto2: { + MemberID: components["schemas"]["Id"]; /** Format: email */ InviterEmail: string; + /** Format: email */ + Email: string; + /** + * @description Permission bitfield, cannot exceed the inviter's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - MemberSharePassphraseKeyPacket: string; + KeyPacket: string; /** @description PGP signature of the member key packet (encrypted) by inviter */ - InviterSharePassphraseKeyPacketSignature: string; + KeyPacketSignature: string; /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ - InviteeSharePassphraseSessionKeySignature: string; + SessionKeySignature: string; + CreateTime: number; }; - FolderDto: { - NodeHashKey?: components["schemas"]["PGPMessage"] | null; - XAttr?: components["schemas"]["PGPMessage"] | null; + ListShareMembersResponseDto: { + Members: components["schemas"]["MemberResponseDto2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; - AlbumDto: { - NodeHashKey?: components["schemas"]["PGPMessage"] | null; - XAttr?: components["schemas"]["PGPMessage"] | null; + UpdateShareMemberRequestDto: { + /** + * @description Permission bitfield, cannot exceed the current user's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; + SecurityRequestDto: { + Hashes: string[]; + }; + SecurityResponseResultDto: { + Hash: string; + /** @description Whether file is safe or not, true if yes, false if not */ + Safe: boolean; + }; + SecurityResponseErrorDto: { + Hash: string; + /** + * @description An error message describing the error, translated. Can be displayed directly to user. + * @example We cannot check this file at present, please proceed with caution + */ + Error: string; + }; + /** @description For each hash from the request, response contains either result or error entry */ + SecurityResponseDto: { + Results: components["schemas"]["SecurityResponseResultDto"][]; + Errors: components["schemas"]["SecurityResponseErrorDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Obsolete
+ * @description
See values descriptions
ValueDescription
0List
1Grid
* @enum {integer} */ - RevisionState: 0 | 1 | 2; - ThumbnailResponseDto: { - ThumbnailID: components["schemas"]["Id2"]; - Type: components["schemas"]["ThumbnailType2"]; - Hash: components["schemas"]["BinaryString2"]; - Size: number; - }; - Verifier: { - Token: components["schemas"]["BinaryString"]; - }; + LayoutSetting: 0 | 1; /** - * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px, max. 65536 bytes in encrypted size
2HDPreview1920 px, max. 1048576 bytes in encrypted size
3MachineLearningmax. 65536 bytes in encrypted size
+ * @description
See values descriptions
ValueDescription
-4ModifiedDesc
-2SizeDesc
-1NameDesc
1NameAsc
2SizeAsc
4ModifiedAsc
* @enum {integer} */ - ThumbnailType: 1 | 2 | 3; + SortSetting: -4 | -2 | -1 | 1 | 2 | 4; /** - * @description
See values descriptions
See values descriptions
ValueNameDescription
1Preview512 px, max. 65536 bytes in encrypted size
2HDPreview1920 px, max. 1048576 bytes in encrypted size
3MachineLearningmax. 65536 bytes in encrypted size
+ * @description

Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default.

See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
* @enum {integer} */ - ThumbnailType2: 1 | 2 | 3; - PhotoListingRelatedItemResponse: { - LinkID: components["schemas"]["Id2"]; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ - CaptureTime: number; - /** @description File name hash */ - Hash: string; - /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash?: string | null; - }; - PhotoFileDto: { - ActiveRevision?: components["schemas"]["ActivePhotoRevisionDto"] | null; - CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id"] | null; - ContentHash?: string | null; - RelatedPhotosLinkIDs: components["schemas"]["Id"][]; - Albums: components["schemas"]["PhotoAlbumDto"][]; - /** @description Will be empty if the user is not the owner. */ - Tags: components["schemas"]["TagType"][]; - TotalEncryptedSize: number; - ContentKeyPacket: components["schemas"]["BinaryString"]; - MediaType?: string | null; - ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + RevisionRetentionDays: 0 | 7 | 30 | 180 | 365 | 3650; + UserSettings: { + /** + * @deprecated + * @description [DEPRECATED] Always NULL + */ + B2BPhotosEnabled: null; + Layout: components["schemas"]["LayoutSetting"]; + Sort: components["schemas"]["SortSetting"]; + RevisionRetentionDays: components["schemas"]["RevisionRetentionDays"]; + /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsEnabled?: boolean | null; + /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsIncludeDocumentName?: boolean | null; + /** @description Indicates user-preferred font in Proton Docs. */ + DocsFontPreference?: string | null; + /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ + PhotoTags?: number[] | null; }; /** - * @description

Types: Folder - 1, File - 2}

See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
+ * @description

Number of days revisions should be retained if not defined by the user. Default ALWAYS used for free users, even if different value is set (premium feature).

See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
* @enum {integer} */ - NodeType2: 1 | 2 | 3; - ThumbnailURLInfoResponseDto: { + RevisionRetentionDays2: 0 | 7 | 30 | 180 | 365 | 3650; + Defaults: { /** * @deprecated - * @description Download URL for the thumbnail + * @description [DEPRECATED] Always true */ - URL?: string | null; - /** @description Bare Download URL for the thumbnail */ - BareURL?: string | null; - /** @description Token for the thumbnail URL */ - Token?: string | null; + B2BPhotosEnabled: boolean; + RevisionRetentionDays: components["schemas"]["RevisionRetentionDays2"]; + /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsEnabled: boolean; + /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. */ + DocsCommentsNotificationsIncludeDocumentName: boolean; + /** @description Default order and visibility of Photo Tags. */ + PhotoTags: number[]; }; - BlockResponseDto: { - Index: number; - Hash: components["schemas"]["BinaryString2"]; - Token?: string | null; - /** @deprecated */ - URL?: string | null; - BareURL?: string | null; + SettingsResponse: { + UserSettings: components["schemas"]["UserSettings"]; + Defaults: components["schemas"]["Defaults"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UserSettingsRequest: { + Layout: components["schemas"]["LayoutSetting"]; + Sort: components["schemas"]["SortSetting"]; + RevisionRetentionDays: components["schemas"]["RevisionRetentionDays"]; + /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsEnabled?: boolean | null; + /** @description Indicates if email notifications for comment activity in Proton Docs should include the document name. If null, the default value to 0 = false will be used by backend. */ + DocsCommentsNotificationsIncludeDocumentName?: boolean | null; + /** @description Indicates user-preferred font in Proton Docs. */ + DocsFontPreference?: string | null; + /** @description Order and visibility of Photo Tags, tags not in the list should not be shown; Use defaults when NULL; Show no tags if empty array. */ + PhotoTags?: components["schemas"]["TagType"][] | null; + }; + CreateOrgVolumeRequestDto: { + AddressID: components["schemas"]["AddressID"]; + /** @description XX's encrypted AddressKeyID. Must be the primary key from the AddressID */ + AddressKeyID: string; + ShareKey: components["schemas"]["PGPPrivateKey"]; + SharePassphrase: components["schemas"]["PGPMessage"]; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + FolderName: components["schemas"]["PGPMessage"]; + FolderKey: components["schemas"]["PGPPrivateKey"]; + FolderPassphrase: components["schemas"]["PGPMessage"]; + FolderPassphraseSignature: components["schemas"]["PGPSignature"]; + FolderHashKey: components["schemas"]["PGPMessage"]; + OrganizationID: components["schemas"]["Id"]; + /** @description Name of the org. volume. It's plain text so that name can be displayed in UI menu */ + VolumeName: string; + }; + VolumeResponseDto: { + ID: components["schemas"]["Id"]; /** * @deprecated - * @default null + * @description Deprecated, use `CreateTime` instead */ - EncSignature: components["schemas"]["PGPMessage2"] | null; + CreationTime: number; /** - * Format: email * @deprecated - * @description Email used to sign block * @default null */ - SignatureEmail: string | null; + MaxSpace: number | null; + VolumeID: components["schemas"]["Id"]; + CreateTime: number; + ModifyTime: number; + /** @description Used space in bytes */ + UsedSpace: number; + DownloadedBytes: number; + UploadedBytes: number; + State: components["schemas"]["VolumeState"]; + Share: components["schemas"]["ShareReferenceResponseDto"]; + Type: components["schemas"]["VolumeType"]; }; - PhotoResponseDto: { - LinkID: components["schemas"]["Id2"]; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ - CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id2"] | null; - /** @description File name hash */ - Hash?: string | null; - /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash?: string | null; - /** @description LinkIDs of related Photos if there are any */ - RelatedPhotosLinkIDs: components["schemas"]["Id2"][]; + GetVolumeResponseDto: { + Volume: components["schemas"]["VolumeResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateVolumeRequestDto: { + AddressID: components["schemas"]["AddressID"]; + ShareKey: components["schemas"]["PGPPrivateKey"]; + SharePassphrase: components["schemas"]["PGPMessage"]; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + FolderName: components["schemas"]["PGPMessage"]; + FolderKey: components["schemas"]["PGPPrivateKey"]; + FolderPassphrase: components["schemas"]["PGPMessage"]; + FolderPassphraseSignature: components["schemas"]["PGPSignature"]; + FolderHashKey: components["schemas"]["PGPMessage"]; + /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ + AddressKeyID: string; /** * @deprecated - * @description Deprecated: Clients persist exif information in xAttr instead * @default null */ - Exif: string | null; + VolumeName: string | null; + /** + * @deprecated + * @default null + */ + ShareName: string | null; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
- * @enum {integer} - */ - NodeType3: 1 | 2 | 3; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
- * @enum {integer} - */ - LinkState2: 0 | 1 | 2; - LinkDto2: { - LinkID: components["schemas"]["Id2"]; - Type: components["schemas"]["NodeType3"]; - ParentLinkID?: components["schemas"]["Id2"] | null; - State: components["schemas"]["LinkState2"]; + OrgVolumeResponseDto: { + ShareID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + /** @description Name of the org. volume */ + Name: string; + /** @description Membership creation time */ CreateTime: number; - ModifyTime: number; - TrashTime?: number | null; - Name: components["schemas"]["PGPMessage2"]; - NameHash?: string | null; - NodeKey: components["schemas"]["PGPPrivateKey2"]; - NodePassphrase: components["schemas"]["PGPMessage2"]; - NodePassphraseSignature: components["schemas"]["PGPSignature2"]; - /** Format: email */ - SignatureEmail?: string | null; - /** Format: email */ - NameSignatureEmail?: string | null; - /** @default null */ - DirectPermissions: number | null; }; - FolderDto2: { - NodeHashKey?: components["schemas"]["PGPMessage2"] | null; - XAttr?: components["schemas"]["PGPMessage2"] | null; + ListOrgVolumesResponseDto: { + Volumes: components["schemas"]["OrgVolumeResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; - SharingDto2: { - ShareID: components["schemas"]["Id2"]; - ShareURLID?: components["schemas"]["Id2"] | null; + OrgVolumeForAdminResponseDto: { + VolumeID: components["schemas"]["Id"]; + /** @description Name of the org. volume */ + Name: string; }; - MembershipDto2: { - ShareID: components["schemas"]["Id2"]; - MembershipID: components["schemas"]["Id2"]; + ListOrgVolumesForAdminResponseDto: { + Volumes: components["schemas"]["OrgVolumeForAdminResponseDto"][]; /** - * @description Permission bitfield, valid permissions: - * - 4: read access - * - 6: read + write access - * - 22: read + write + admin access - * + * ProtonResponseCode + * @example 1000 * @enum {integer} */ - Permissions: 4 | 6 | 22; - InviteTime: number; - /** Format: email */ - InviterEmail: string; - /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - MemberSharePassphraseKeyPacket: string; - /** @description PGP signature of the member key packet (encrypted) by inviter */ - InviterSharePassphraseKeyPacketSignature: string; - /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ - InviteeSharePassphraseSessionKeySignature: string; + Code: 1000; }; - /** - * @description

1=active, 3=locked

See values descriptions
See values descriptions
ValueNameDescription
1Active
2Deleted
3Locked* Locked membership can have two reasons: - * * - either the associated address was disabled/deleted, e.g. due to account deletion - * * - or the associated address key was made inactive due to a password reset - * * - * * It means the membership cannot be used for decryption unless it is restored with account recovery.
- * @enum {integer} - */ - ShareMemberState: 1 | 2 | 3; - /** - * @description

The target type of the Share that is corresponding to this invitation.
- * This should not be used as source of information to know what NodeType or MIMEType the targeted Share is.

See values descriptions
See values descriptions
ValueNameDescription
0Rootmain, device or photo root share
1Folder
2File
3Album
4Photo
5ProtonVendordocuments and sheets
- * @enum {integer} - */ - TargetType2: 0 | 1 | 2 | 3 | 4 | 5; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Pending
2UserRegistered
4Deleted
- * @enum {integer} - */ - ExternalInvitationState: 1 | 2 | 4; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0List
1Grid
- * @enum {integer} - */ - LayoutSetting2: 0 | 1; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
-4ModifiedDesc
-2SizeDesc
-1NameDesc
1NameAsc
2SizeAsc
4ModifiedAsc
- * @enum {integer} - */ - SortSetting2: -4 | -2 | -1 | 1 | 2 | 4; - /** - * @description

Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default.

See values descriptions
See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
- * @enum {integer} - */ - RevisionRetentionDays2: 0 | 7 | 30 | 180 | 365 | 3650; - /** - * @description

Number of days revisions should be retained if not defined by the user. Default ALWAYS used for free users, even if different value is set (premium feature).

See values descriptions
See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
- * @enum {integer} - */ - RevisionRetentionDays3: 0 | 7 | 30 | 180 | 365 | 3650; - BlockResponseDto2: { - Index: number; - Hash: components["schemas"]["BinaryString"]; - Token?: string | null; - /** @deprecated */ - URL?: string | null; - BareURL?: string | null; + ListVolumesResponseDto: { + Volumes: components["schemas"]["VolumeResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + RestoreMainShareDto: { + /** @description ShareID of the existing, locked main share */ + LockedShareID: string; + /** @description Folder name as armored PGP message */ + Name: string; + /** @description Hash of the name */ + Hash: string; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** + * @description Node Hash Key should be provided if it needs to be signed because it was unsigned or signed with the address key (legacy). It should be signed with the new parent's node key. If it was properly signed with the parent node key, it should not be updated. Armored PGP message. + * @default null + */ + NodeHashKey: string | null; + }; + RestoreRootShareDto: { + /** @description ShareID of the existing share on the old volume */ + LockedShareID: string; + /** @description Key packet for the share passphrase, encrypted with the active key associated with the new volume. Encoded with Base64. */ + ShareKeyPacket: string; + /** @description Signed with new key as armored PGP signature */ + PassphraseSignature: string; + }; + RestoreVolumeDto: { + SignatureAddress: components["schemas"]["AddressEmail"]; + /** @default [] */ + MainShares: components["schemas"]["RestoreMainShareDto"][]; + /** @default [] */ + Devices: components["schemas"]["RestoreRootShareDto"][]; + /** @default [] */ + PhotoShares: components["schemas"]["RestoreRootShareDto"][]; + /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ + AddressKeyID: string; + }; + AddPhotoToAlbumWithLinkIDResponseDto: Record; + MultiResponsesPerLinkFactory: { + /** @enum {integer} */ + Code: 1001; + Responses: { + LinkID: string; + Response: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + }[]; + }; + RemovePhotoFromAlbumWithLinkIDResponseDto: Record; + ConflictErrorDetailsDto: { + ConflictLinkID: components["schemas"]["Id"]; /** - * @deprecated + * @description A conflicting Revision in Active state. * @default null */ - EncSignature: components["schemas"]["PGPMessage"] | null; + ConflictRevisionID: string | null; /** - * Format: email - * @deprecated - * @description Email used to sign block + * @description A conflicting Revision in Draft state. * @default null */ - SignatureEmail: string | null; - }; - PhotoResponseDto2: { - LinkID: components["schemas"]["Id"]; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ - CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id"] | null; - /** @description File name hash */ - Hash?: string | null; - /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash?: string | null; - /** @description LinkIDs of related Photos if there are any */ - RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + ConflictDraftRevisionID: string | null; + /** + * @description ClientUID of conflicting Revision if in Draft state. + * @default null + */ + ConflictDraftClientUID: string | null; /** * @deprecated - * @description Deprecated: Clients persist exif information in xAttr instead + * @description [DEPRECATED] for backwards compatibility on create revision, same value as ConflictDraftRevisionID * @default null */ - Exif: string | null; - }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Obsolete
- * @enum {integer} - */ - RevisionState2: 0 | 1 | 2; - ThumbnailResponseDto2: { - ThumbnailID: components["schemas"]["Id"]; - Type: components["schemas"]["ThumbnailType"]; - Hash: components["schemas"]["BinaryString"]; - Size: number; + RevisionID: string | null; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Disabled
1Enabled
- * @enum {integer} - */ - DeviceSyncState2: 0 | 1; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Windows
2MacOS
3Linux
- * @enum {integer} - */ - DeviceType2: 1 | 2 | 3; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Folder
2File
3Album
- * @enum {integer} - */ - NodeType4: 1 | 2 | 3; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Draft
1Active
2Trashed
- * @enum {integer} - */ - LinkState3: 0 | 1 | 2; - ActiveRevisionDto: { - /** @deprecated */ - Photo?: components["schemas"]["PhotoDto"] | null; - RevisionID: components["schemas"]["Id"]; - CreateTime: number; - EncryptedSize: number; - ManifestSignature?: components["schemas"]["PGPSignature"] | null; - XAttr?: components["schemas"]["PGPMessage"] | null; - Thumbnails: components["schemas"]["ThumbnailDto"][]; - /** Format: email */ - SignatureEmail?: string | null; + ConflictErrorResponseDto: { + Details: components["schemas"]["ConflictErrorDetailsDto"]; + Error: string; + Code: number; }; - ActivePhotoRevisionDto: { - RevisionID: components["schemas"]["Id"]; - CreateTime: number; - EncryptedSize: number; - ManifestSignature?: components["schemas"]["PGPSignature"] | null; - XAttr?: components["schemas"]["PGPMessage"] | null; - Thumbnails: components["schemas"]["ThumbnailDto"][]; - /** Format: email */ - SignatureEmail?: string | null; + ShareConflictErrorDetailsDto: { + ConflictLinkID: components["schemas"]["Id"]; + /** @description A conflicting Share on the Link. */ + ConflictShareID: string; }; - PhotoAlbumDto: { - AlbumLinkID: components["schemas"]["Id"]; - Hash: string; - ContentHash: string; - AddedTime: number; + /** @description Conflict, a share already exists for the file or folder. */ + ShareConflictErrorResponseDto: { + Details: components["schemas"]["ShareConflictErrorDetailsDto"]; + Error: string; + Code: number; }; - PhotoDto: { - CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id"] | null; - ContentHash?: string | null; - RelatedPhotosLinkIDs: components["schemas"]["Id"][]; + SmallFileUploadMetadataRequestDto: { + Name: components["schemas"]["PGPMessage"]; + NameHash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** + * Format: email + * @description Address used to sign passphrase, name, manifest, block, and xAttr. Is null for anonymous users. + */ + SignatureEmail?: components["schemas"]["AddressEmail"] | null; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @example text/plain */ + MIMEType: string; + ContentKeyPacket: components["schemas"]["BinaryString"]; + /** @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. */ + ContentKeyPacketSignature?: string | null; + ManifestSignature: components["schemas"]["PGPSignature"]; + ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; + /** + * @description Extended attributes encrypted with link key + * @default null + */ + XAttr: string | null; + /** @default null */ + Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; + /** + * @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. + * @default null + */ + ContentBlockEncSignature: string | null; }; - ThumbnailDto: { - ThumbnailID: components["schemas"]["Id"]; - Type: components["schemas"]["ThumbnailType"]; - Hash: components["schemas"]["BinaryString"]; - EncryptedSize: number; + SmallRevisionUploadMetadataRequestDto: { + CurrentRevisionID: components["schemas"]["Id"]; + /** + * Format: email + * @description Address used to sign manifest, block, and xAttr. Is null for anonymous users. + */ + SignatureEmail?: components["schemas"]["AddressEmail"] | null; + ManifestSignature: components["schemas"]["PGPSignature"]; + /** @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. */ + ContentBlockEncSignature?: components["schemas"]["PGPMessage"] | null; + ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; + /** + * @description File extended attributes encrypted with link key + * @default null + */ + XAttr: string | null; }; }; responses: { @@ -7902,7 +7530,6 @@ export interface operations { AnchorID?: components["schemas"]["ListPhotosAlbumQueryParameters"]["AnchorID"]; Sort?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Sort"]; Desc?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Desc"]; - Tag?: components["schemas"]["ListPhotosAlbumQueryParameters"]["Tag"]; OnlyChildren?: components["schemas"]["ListPhotosAlbumQueryParameters"]["OnlyChildren"]; IncludeTrashed?: components["schemas"]["ListPhotosAlbumQueryParameters"]["IncludeTrashed"]; }; @@ -9339,7 +8966,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9367,7 +8994,7 @@ export interface operations { header?: never; path: { shareID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -9415,7 +9042,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetRevisionResponseDto2"]; + "application/json": components["schemas"]["GetRevisionResponseDto"]; }; }; }; @@ -9531,7 +9158,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetRevisionResponseDto2"]; + "application/json": components["schemas"]["GetRevisionResponseDto"]; }; }; }; @@ -9751,19 +9378,14 @@ export interface operations { }; }; responses: { - /** @description Revision */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Revision: { - /** @description Revision ID */ - ID: string; - }; - }; + "application/json": components["schemas"]["CreateDraftRevisionResponseDto"]; }; }; /** @description Conflict, the submitted revision is no longer up to date or another draft is open. */ @@ -9831,19 +9453,14 @@ export interface operations { }; }; responses: { - /** @description Revision */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Revision: { - /** @description Revision ID */ - ID: string; - }; - }; + "application/json": components["schemas"]["CreateDraftRevisionResponseDto"]; }; }; /** @description Conflict, the submitted revision is no longer up to date or another draft is open. */ @@ -10447,7 +10064,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -10561,6 +10178,72 @@ export interface operations { }; }; }; + "get_drive-health-hash-check": { + parameters: { + query?: { + ClientUID?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Hash Check */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Check: boolean; + }; + }; + }; + }; + }; + "post_drive-health-hash-check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReportHashCheckProgressDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The FF is disabled or there is no entry for the user in the health-check table. + * - 2511: The health check for the user/clientUID pair was already marked complete. + * */ + Code: number; + }; + }; + }; + }; + }; "get_drive-me-active": { parameters: { query?: never; @@ -11017,7 +10700,6 @@ export interface operations { PreviousPageLastLinkID?: components["schemas"]["ListPhotosParameters"]["PreviousPageLastLinkID"]; /** @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) */ MinimumCaptureTime?: components["schemas"]["ListPhotosParameters"]["MinimumCaptureTime"]; - Tag?: components["schemas"]["ListPhotosParameters"]["Tag"]; }; header?: never; path: { @@ -11589,7 +11271,7 @@ export interface operations { header?: never; path: { token: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; @@ -11848,9 +11530,15 @@ export interface operations { "get_drive-shares-{shareID}-urls": { parameters: { query?: { - /** @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). */ + /** + * @deprecated + * @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). + */ Recursive?: 0 | 1; - /** @description Fetch Thumbnail URLs */ + /** + * @deprecated + * @description Fetch Thumbnail URLs + */ Thumbnails?: 0 | 1; PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; @@ -12203,11 +11891,15 @@ export interface operations { }; }; responses: { - default: { + /** @description Success */ + 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CreateDraftRevisionResponseDto"]; + }; }; }; }; @@ -12344,7 +12036,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 4e8d7157..845b67c2 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey } from '../../crypto'; -import { MetricVolumeType, PhotoAttributes } from '../../interface'; +import { MetricVolumeType, PhotoAttributes, AlbumAttributes } from '../../interface'; import { DecryptedNode, EncryptedNode, DecryptedUnparsedNode } from '../nodes/interface'; import { EncryptedShare } from '../shares'; @@ -25,14 +25,17 @@ export interface SharesService { export type EncryptedPhotoNode = EncryptedNode & { photo?: EcnryptedPhotoAttributes; + album?: AlbumAttributes; }; export type DecryptedUnparsedPhotoNode = DecryptedUnparsedNode & { photo?: PhotoAttributes; + album?: AlbumAttributes; }; export type DecryptedPhotoNode = DecryptedNode & { photo?: PhotoAttributes; + album?: AlbumAttributes; }; export type EcnryptedPhotoAttributes = Omit & { diff --git a/js/sdk/src/internal/photos/nodes.test.ts b/js/sdk/src/internal/photos/nodes.test.ts index ea6eadfc..1698529c 100644 --- a/js/sdk/src/internal/photos/nodes.test.ts +++ b/js/sdk/src/internal/photos/nodes.test.ts @@ -44,6 +44,10 @@ function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) { ...node, Link: { ...node.Link, Type: 3, ...linkOverrides }, Photo: null, + Album: { + PhotoCount: 1, + CoverLinkID: 'coverLinkId', + }, Folder: null, ...overrides, }; @@ -103,6 +107,8 @@ describe('PhotosNodesAPIService', () => { const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId')); expect(nodes).toHaveLength(1); expect(nodes[0].type).toBe(expectedType); + + return nodes; } it('should convert folder (type 1) to folder node', async () => { @@ -110,16 +116,16 @@ describe('PhotosNodesAPIService', () => { }); it('should convert album (type 3) to album node', async () => { - await testIterateNodes(generateAPIAlbumNode(), NodeType.Album); + const nodes = await testIterateNodes(generateAPIAlbumNode(), NodeType.Album); + + expect(nodes[0].album).toBeDefined(); + expect(nodes[0].album?.photoCount).toEqual(1); + expect(nodes[0].album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId'); }); it('should convert photo (type 2) to photo node with photo attributes', async () => { - apiMock.post = jest.fn().mockResolvedValue({ Links: [generateAPIPhotoNode()] }); - - const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId')); + const nodes = await testIterateNodes(generateAPIPhotoNode(), NodeType.Photo); - expect(nodes).toHaveLength(1); - expect(nodes[0].type).toBe(NodeType.Photo); expect(nodes[0].photo).toBeDefined(); expect(nodes[0].photo?.captureTime).toEqual(new Date(1700000000 * 1000)); expect(nodes[0].photo?.tags).toEqual([1, 2]); @@ -161,6 +167,10 @@ describe('PhotosNodesCache', () => { }, ], }, + album: { + photoCount: 1, + coverPhotoNodeUid: 'volumeId~coverLinkId', + }, }); const node = cache.deserialiseNode(serialisedNode); @@ -170,6 +180,9 @@ describe('PhotosNodesCache', () => { expect(node.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z')); expect(node.photo?.albums[0].additionTime).toBeInstanceOf(Date); expect(node.photo?.albums[0].additionTime).toEqual(new Date('2023-11-15T10:00:00.000Z')); + expect(node.album).toBeDefined(); + expect(node.album?.photoCount).toEqual(1); + expect(node.album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId'); }); it('should handle node without photo attributes', () => { @@ -187,6 +200,7 @@ describe('PhotosNodesCache', () => { const node = cache.deserialiseNode(serialisedNode); expect(node.photo).toBeUndefined(); + expect(node.album).toBeUndefined(); }); }); }); @@ -244,6 +258,10 @@ describe('PhotosNodesAccess', () => { tags: [1, 2], albums: [], }, + album: { + photoCount: 1, + coverPhotoNodeUid: 'volumeId~coverLinkId', + }, }; // @ts-expect-error Accessing protected method for testing @@ -253,6 +271,9 @@ describe('PhotosNodesAccess', () => { expect(parsedNode.photo).toBeDefined(); expect(parsedNode.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z')); expect(parsedNode.photo?.tags).toEqual([1, 2]); + expect(parsedNode.album).toBeDefined(); + expect(parsedNode.album?.photoCount).toEqual(1); + expect(parsedNode.album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId'); }); }); }); diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 944f375b..09fbcf10 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -78,6 +78,12 @@ export class PhotosNodesAPIService extends NodeAPIServiceBase< if (link.Link.Type === 3 && link.Album) { return { ...baseNodeMetadata, + album: { + photoCount: link.Album.PhotoCount, + coverPhotoNodeUid: link.Album.CoverLinkID + ? makeNodeUid(volumeId, link.Album.CoverLinkID) + : undefined, + }, encryptedCrypto: { ...baseCryptoNodeMetadata, folder: { @@ -115,7 +121,8 @@ export class PhotosNodesCache extends NodesCacheBase { typeof node !== 'object' || (typeof node.photo !== 'object' && node.photo !== undefined) || (typeof node.photo?.captureTime !== 'string' && node.folder?.captureTime !== undefined) || - (typeof node.photo?.albums !== 'object' && node.photo?.albums !== undefined) + (typeof node.photo?.albums !== 'object' && node.photo?.albums !== undefined) || + (typeof node.album !== 'object' && node.album !== undefined) ) { throw new Error(`Invalid node data: ${nodeData}`); } @@ -194,6 +201,18 @@ export class PhotosNodesAccess extends NodesAccessBase Date: Wed, 18 Feb 2026 15:43:40 +0100 Subject: [PATCH 539/791] Add x-pm-appversion regex --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 24a13946..bca97491 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ The SDK may be used for personal, non-commercial projects. If you choose to buil | **Identify your application** | Set the `x-pm-appversion` HTTP header using the format `external-drive-{projectname}@{version}` (e.g., `external-drive-myapp@1.2.3`). This header must accurately represent your application. Do not spoof or falsify this value. | | **Use event-based sync** | Synchronize data using Drive events. Do not poll the API or perform frequent recursive traversals of the file tree. | +Note: The full `x-pm-appversion` string must conform to the regex: + +``` +/^(external-drive)+(-[a-z_]+)+@[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?-((stable|beta|RC|alpha)(([.-]?\d+)*)?)?([.-]?dev)?(\+.*)?$/i +``` + ### Branding and User Safety Requirements | Requirement | Description | From 0a6d7f6fee38e377e680c3de5464563559840063 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Feb 2026 09:54:03 +0100 Subject: [PATCH 540/791] Log progress as percentage --- .../src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt | 3 ++- kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt | 3 ++- .../src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt | 3 ++- .../src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt | 3 ++- .../kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt | 6 ++++++ 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index 81a58af3..dd4127f1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -6,6 +6,7 @@ import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.extension.seek import me.proton.drive.sdk.extension.toEntity +import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniFileDownloader import me.proton.drive.sdk.internal.factory @@ -39,7 +40,7 @@ class FileDownloader internal constructor( }, onProgress = { progressUpdate -> with(progressUpdate) { - bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + bridge.internalLogger(DEBUG, "progress: ${progressUpdate.toPercentageString()}") controllerReference.get()?.emitProgress(toEntity()) } }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt index 2a017d4a..f00a3089 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -8,6 +8,7 @@ import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity +import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniFileUploader import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId @@ -37,7 +38,7 @@ class FileUploader internal constructor( onRead = channel::read, onProgress = { progressUpdate -> with(progressUpdate) { - log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + log(DEBUG, "progress: ${progressUpdate.toPercentageString()}") controllerReference.get()?.emitProgress(toEntity()) } }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index 6e65792f..4c852ba2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -6,6 +6,7 @@ import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.extension.seek import me.proton.drive.sdk.extension.toEntity +import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniPhotosDownloader import me.proton.drive.sdk.internal.factory @@ -39,7 +40,7 @@ class PhotosDownloader internal constructor( }, onProgress = { progressUpdate -> with(progressUpdate) { - bridge.internalLogger(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + bridge.internalLogger(DEBUG, "progress: ${progressUpdate.toPercentageString()}") controllerReference.get()?.emitProgress(toEntity()) } }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt index 3306f3f1..aa57bf2b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt @@ -7,6 +7,7 @@ import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource import me.proton.drive.sdk.entity.PhotosUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity +import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniPhotosUploader import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId @@ -36,7 +37,7 @@ class PhotosUploader( onRead = channel::read, onProgress = { progressUpdate -> with(progressUpdate) { - log(DEBUG, "progress: $bytesCompleted/$bytesInTotal") + log(DEBUG, "progress: ${progressUpdate.toPercentageString()}") controllerReference.get()?.emitProgress(toEntity()) } }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt index 15ff210c..7c280179 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt @@ -9,3 +9,9 @@ fun ProtonDriveSdk.ProgressUpdate.toEntity() = takeIf { it.bytesInTotal > 0 }?.r bytesInTotal = bytesInTotal, ) } + +internal fun ProtonDriveSdk.ProgressUpdate.toPercentageString(): String = if (bytesInTotal > 0) { + (bytesCompleted * 100.0 / bytesInTotal).toInt() +} else { + 0 +}.let { percentage -> "$percentage%" } From 0e9e11ba23f1740dd0912f638120c505e0e6a924 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Feb 2026 15:08:04 +0100 Subject: [PATCH 541/791] Add option for editors to manage share settings --- js/sdk/src/interface/sharing.ts | 2 + js/sdk/src/internal/apiService/driveTypes.ts | 79 +++++++++++++++++-- js/sdk/src/internal/shares/apiService.ts | 1 + js/sdk/src/internal/shares/interface.ts | 1 + js/sdk/src/internal/sharing/apiService.ts | 27 +++++-- .../sharing/sharingManagement.test.ts | 38 +++++++-- .../src/internal/sharing/sharingManagement.ts | 34 ++++++-- 7 files changed, 158 insertions(+), 24 deletions(-) diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index b74e3fa2..f0b555e9 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -85,6 +85,7 @@ export type ShareNodeSettings = { message?: string; includeNodeName?: boolean; }; + editorsCanShare?: boolean; }; export type ShareMembersSettings = @@ -107,6 +108,7 @@ export type ShareResult = { nonProtonInvitations: NonProtonInvitation[]; members: Member[]; publicLink?: PublicLink; + editorsCanShare: boolean; }; export type UnshareNodeSettings = { diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index a2034635..d1d7c8ec 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -2628,6 +2628,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/shares/{shareID}/property": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update properties of a share + * @description Update the values on one or more properties of the Share. For now, only allowed changing editorsCanShare attribute + */ + post: operations["post_drive-shares-{shareID}-property"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/migrations/shareaccesswithnode": { parameters: { query?: never; @@ -6334,6 +6354,13 @@ export interface components { /** @description Base64 encoded key packet for the share passphrase, reusing the same session key as previously, and encrypted for the key referenced by the KeyID. */ MemberKeyPacket: string; }; + UpdateSharePropertyRequestDto: { + /** + * @description Indicates if editor members of this share could reshare it or not + * @default null + */ + EditorsCanShare: boolean | null; + }; ShareKPMigrationData: { /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ ShareID: string; @@ -6396,6 +6423,20 @@ export interface components { */ Name: string | null; }; + StandardShareResponseDto: { + ID: components["schemas"]["Id"]; + /** @description Indicates if editor members of this share could reshare it or not */ + EditorsCanShare: boolean; + }; + CreateStandardShareResponseDto: { + Share: components["schemas"]["StandardShareResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; LinkSharedByMeResponseDto: { ShareID: components["schemas"]["Id"]; LinkID: components["schemas"]["Id"]; @@ -12331,6 +12372,33 @@ export interface operations { }; }; }; + "post_drive-shares-{shareID}-property": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateSharePropertyRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; "post_drive-migrations-shareaccesswithnode": { parameters: { query?: never; @@ -12392,19 +12460,14 @@ export interface operations { }; }; responses: { - /** @description Share */ + /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - "application/json": { - Code: components["schemas"]["ResponseCodeSuccess"]; - Share: { - /** @description Share ID */ - ID: string; - }; - }; + "application/json": components["schemas"]["CreateStandardShareResponseDto"]; }; }; /** @description Unprocessable Entity */ diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index e7f97f7d..b6e0a56f 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -170,6 +170,7 @@ function convertSharePayload(response: GetShareResponse): EncryptedShare { } : undefined, type: convertShareTypeNumberToEnum(response.Type), + editorsCanShare: response.EditorsCanShare }; } diff --git a/js/sdk/src/internal/shares/interface.ts b/js/sdk/src/internal/shares/interface.ts index dba5b48d..7dceafc6 100644 --- a/js/sdk/src/internal/shares/interface.ts +++ b/js/sdk/src/internal/shares/interface.ts @@ -78,6 +78,7 @@ export interface EncryptedShare extends BaseShare { creatorEmail: string; encryptedCrypto: EncryptedShareCrypto; membership?: ShareMembership; + editorsCanShare: boolean; } interface ShareMembership { diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 5b863b83..796f4321 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -64,8 +64,10 @@ type GetShareExternalInvitations = type GetShareMembers = drivePaths['/drive/v2/shares/{shareID}/members']['get']['responses']['200']['content']['application/json']; -type PostSharedBookmarksRequest = - Extract['content']['application/json']; +type PostSharedBookmarksRequest = Extract< + drivePaths['/drive/v2/urls/{token}/bookmark']['post']['requestBody'], + { content: object } +>['content']['application/json']; type PostSharedBookmarksResponse = drivePaths['/drive/v2/urls/{token}/bookmark']['post']['responses']['200']['content']['application/json']; @@ -76,6 +78,14 @@ type PostCreateShareRequest = Extract< type PostCreateShareResponse = drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; +type PostChangeSharePropertiesRequest = Extract< + drivePaths['/drive/shares/{shareID}/property']['post']['requestBody'], + { content: object } +>['content']['application/json']; + +type PostChangeSharePropertiesResponse = + drivePaths['/drive/shares/{shareID}/property']['post']['responses']['200']['content']['application/json']; + type PostInviteProtonUserRequest = Extract< drivePaths['/drive/v2/shares/{shareID}/invitations']['post']['requestBody'], { content: object } @@ -331,7 +341,7 @@ export class SharingAPIService { addressId: string; addressKeyId: string; }): Promise { - await this.apiService.post( + await this.apiService.post( `drive/v2/urls/${bookmark.token}/bookmark`, { BookmarkShareURL: { @@ -390,7 +400,7 @@ export class SharingAPIService { base64PassphraseKeyPacket: string; base64NameKeyPacket: string; }, - ): Promise { + ): Promise<{ shareId: string; editorsCanShare: boolean }> { const { volumeId, nodeId } = splitNodeUid(nodeUid); const response = await this.apiService.post( `drive/volumes/${volumeId}/shares`, @@ -405,13 +415,20 @@ export class SharingAPIService { NameKeyPacket: node.base64NameKeyPacket, }, ); - return response.Share.ID; + return { shareId: response.Share.ID, editorsCanShare: response.Share.EditorsCanShare }; } async deleteShare(shareId: string, force: boolean = false): Promise { await this.apiService.delete(`drive/shares/${shareId}?Force=${force ? 1 : 0}`); } + async changeShareProperties(shareId: string, { editorsCanShare }: { editorsCanShare: boolean }) { + await this.apiService.post( + `drive/shares/${shareId}/property`, + { EditorsCanShare: editorsCanShare }, + ); + } + async inviteProtonUser( shareId: string, invitation: EncryptedInvitationRequest, diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index e941183c..790d698d 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -18,6 +18,8 @@ import { SharingManagement } from './sharingManagement'; import { ValidationError } from '../../errors'; import { ErrorCode } from '../apiService'; +const DEFAULT_SHARE_ID = 'shareId'; + describe('SharingManagement', () => { let logger: Logger; let apiService: SharingAPIService; @@ -34,7 +36,7 @@ describe('SharingManagement', () => { // @ts-expect-error No need to implement all methods for mocking apiService = { - createStandardShare: jest.fn().mockReturnValue('newShareId'), + createStandardShare: jest.fn().mockReturnValue({ shareId: 'newShareId', editorsCanShare: false }), getShareInvitations: jest.fn().mockResolvedValue([]), getShareExternalInvitations: jest.fn().mockResolvedValue([]), getShareMembers: jest.fn().mockResolvedValue([]), @@ -63,6 +65,7 @@ describe('SharingManagement', () => { publicUrl: 'publicLinkUrl', }), updatePublicLink: jest.fn(), + changeShareProperties: jest.fn(), }; // @ts-expect-error No need to implement all methods for mocking cache = { @@ -98,7 +101,7 @@ describe('SharingManagement', () => { // @ts-expect-error No need to implement all methods for mocking sharesService = { loadEncryptedShare: jest.fn().mockResolvedValue({ - id: 'shareId', + id: DEFAULT_SHARE_ID, addressId: 'addressId', creatorEmail: 'address@example.com', passphraseSessionKey: 'sharePassphraseSessionKey', @@ -106,9 +109,11 @@ describe('SharingManagement', () => { }; // @ts-expect-error No need to implement all methods for mocking nodesService = { - getNode: jest - .fn() - .mockImplementation((nodeUid) => ({ nodeUid, shareId: 'shareId', name: { ok: true, value: 'name' } })), + getNode: jest.fn().mockImplementation((nodeUid) => ({ + nodeUid, + shareId: DEFAULT_SHARE_ID, + name: { ok: true, value: 'name' }, + })), getNodeKeys: jest.fn().mockImplementation((nodeUid) => ({ key: 'node-key' })), getNodePrivateAndSessionKeys: jest.fn().mockImplementation((nodeUid) => ({})), getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: 'volume-email', addressKey: 'volume-key' }), @@ -225,6 +230,7 @@ describe('SharingManagement', () => { nonProtonInvitations: [], members: [], publicLink: undefined, + editorsCanShare: false, }); expect(apiService.updateInvitation).not.toHaveBeenCalled(); expect(apiService.inviteProtonUser).toHaveBeenCalled(); @@ -395,6 +401,28 @@ describe('SharingManagement', () => { expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); }); + it('should update editorsCanChange', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + editorsCanShare: true, + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [ + { + ...invitation, + role: 'viewer', + }, + ], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + editorsCanShare: true, + }); + expect(apiService.changeShareProperties).toHaveBeenCalledWith(DEFAULT_SHARE_ID, { + editorsCanShare: true, + }); + }); + it('should be no-op if no change', async () => { const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: [{ email: 'internal-email', role: MemberRole.Viewer }], diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index d5421e03..85666be9 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -77,11 +77,12 @@ export class SharingManagement { return; } - const [protonInvitations, nonProtonInvitations, members, publicLink] = await Promise.all([ + const [protonInvitations, nonProtonInvitations, members, publicLink, share] = await Promise.all([ Array.fromAsync(this.iterateShareInvitations(node.shareId)), Array.fromAsync(this.iterateShareExternalInvitations(node.shareId)), Array.fromAsync(this.iterateShareMembers(node.shareId)), this.getPublicLink(node.shareId), + this.sharesService.loadEncryptedShare(node.shareId), ]); return { @@ -89,6 +90,7 @@ export class SharingManagement { nonProtonInvitations, members, publicLink, + editorsCanShare: share.editorsCanShare, }; } @@ -161,6 +163,7 @@ export class SharingManagement { nonProtonInvitations: [], members: [], publicLink: undefined, + editorsCanShare: result.editorsCanShare, }; contextShareAddress = result.contextShareAddress; } catch (error: unknown) { @@ -184,6 +187,11 @@ export class SharingManagement { contextShareAddress = await this.nodesService.getRootNodeEmailKey(nodeUid); } + if (settings.editorsCanShare !== undefined) { + await this.setEditorsCanShare(currentSharing.share.shareId, settings.editorsCanShare); + currentSharing.editorsCanShare = settings.editorsCanShare; + } + const emailOptions: EmailOptions = { message: settings.emailOptions?.message, nodeName: settings.emailOptions?.includeNodeName ? currentSharing.nodeName : undefined, @@ -294,6 +302,7 @@ export class SharingManagement { nonProtonInvitations: currentSharing.nonProtonInvitations, members: currentSharing.members, publicLink: currentSharing.publicLink, + editorsCanShare: currentSharing.editorsCanShare, }; } @@ -385,6 +394,7 @@ export class SharingManagement { nonProtonInvitations: currentSharing.nonProtonInvitations, members: currentSharing.members, publicLink: currentSharing.publicLink, + editorsCanShare: currentSharing.editorsCanShare, }; } @@ -415,7 +425,9 @@ export class SharingManagement { }; } - private async createShare(nodeUid: string): Promise<{ share: Share; contextShareAddress: ContextShareAddress }> { + private async createShare( + nodeUid: string, + ): Promise<{ share: Share; contextShareAddress: ContextShareAddress; editorsCanShare: boolean }> { const node = await this.nodesService.getNode(nodeUid); if (!node.parentUid) { throw new ValidationError(c('Error').t`Cannot share root folder`); @@ -426,10 +438,15 @@ export class SharingManagement { const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(nodeUid); const keys = await this.cryptoService.generateShareKeys(nodeKeys, addressKey); - const shareId = await this.apiService.createStandardShare(nodeUid, addressId, keys.shareKey.encrypted, { - base64PassphraseKeyPacket: keys.base64PpassphraseKeyPacket, - base64NameKeyPacket: keys.base64NameKeyPacket, - }); + const { shareId, editorsCanShare } = await this.apiService.createStandardShare( + nodeUid, + addressId, + keys.shareKey.encrypted, + { + base64PassphraseKeyPacket: keys.base64PpassphraseKeyPacket, + base64NameKeyPacket: keys.base64NameKeyPacket, + }, + ); await this.nodesService.notifyNodeChanged(nodeUid); if (await this.cache.hasSharedByMeNodeUidsLoaded()) { await this.cache.addSharedByMeNodeUid(nodeUid); @@ -449,9 +466,14 @@ export class SharingManagement { return { share, contextShareAddress, + editorsCanShare, }; } + private async setEditorsCanShare(shareId: string, editorsCanShare: boolean) { + await this.apiService.changeShareProperties(shareId, { editorsCanShare }); + } + /** * Deletes the share even if it is not empty. */ From 095268cef85c9023f130d710ca6078cd7acec16a Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Feb 2026 14:11:29 +0000 Subject: [PATCH 542/791] Update changelog for js/v0.10.0 --- js/CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index f9be10e0..f0fc0bcf 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## js/v0.10.0 (2026-02-19) + +* Add option for editors to manage share settings +* Expose Album properties +* Ignore apiRetrySucceeded metric on offline or timeout errors +* Add cause to re-thrown errors +* Add capability to add photos to albums +* Add method to get device +* Fix after rebase +* TS: declare Uint8Array over generic Uint8Array +* Cleanup crypto utils and fix type errors + ## js/v0.9.9 (2026-02-12) * Support getAvailableName for public client From caf32b8c627c0aec9f1999866c47047f6676d553 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Feb 2026 06:03:13 +0000 Subject: [PATCH 543/791] i18n(weekly-mr): Upgrade translations from crowdin (095268ce). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 9 +++++++++ js/sdk/locales/ca_ES.json | 9 +++++++++ js/sdk/locales/de_DE.json | 9 +++++++++ js/sdk/locales/el_GR.json | 9 +++++++++ js/sdk/locales/es_ES.json | 9 +++++++++ js/sdk/locales/es_LA.json | 9 +++++++++ js/sdk/locales/fr_FR.json | 9 +++++++++ js/sdk/locales/it_IT.json | 9 +++++++++ js/sdk/locales/nl_NL.json | 9 +++++++++ js/sdk/locales/pt_BR.json | 6 ++++++ js/sdk/locales/ro_RO.json | 9 +++++++++ js/sdk/locales/sk_SK.json | 9 +++++++++ js/sdk/locales/tr_TR.json | 9 +++++++++ 14 files changed, 115 insertions(+), 1 deletion(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index 889805df..2c4db43c 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "bc0eea33201570d9af77bfc607690c829543228f" + "locale": "d0304af653cbc5f5805d2c61d434ef56b90581f9" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 71f1e9a8..ce7abd60 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "Пароль закладкі недаÑтупны" ], + "Cannot add photo to album without a valid name": [ + "Ðельга дадаць фатаграфію Ñž альбом без Ñапраўднай назвы" + ], "Cannot download a folder": [ "Ðемагчыма Ñпампаваць папку" ], @@ -167,9 +170,15 @@ "Operation aborted": [ "ÐÐ¿ÐµÑ€Ð°Ñ†Ñ‹Ñ Ð¿ÐµÑ€Ð°Ñ€Ð²Ð°Ð½Ð°" ], + "Operation failed, try again later": [ + "Збой аперацыі. ПаÑпрабуйце ÑÑˆÑ‡Ñ Ñ€Ð°Ð· пазней" + ], "Parent cannot be decrypted": [ "Ðемагчыма раÑшыфраваць бацькоўÑкі вузел" ], + "Photo not found": [ + "Ð¤Ð°Ñ‚Ð°Ð³Ñ€Ð°Ñ„Ñ–Ñ Ð½Ðµ знойдзена" + ], "Renaming root item is not allowed": [ "Перайменаванне каранёвага Ñлемента забаронена" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 75e5c931..57372990 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "La contrasenya d'adreces d'interès no està disponible." ], + "Cannot add photo to album without a valid name": [ + "No es pot afegir la fotografia a l'àlbum sense un nom vàlid" + ], "Cannot download a folder": [ "No es pot descarregar una carpeta" ], @@ -165,9 +168,15 @@ "Operation aborted": [ "S'ha interromput l'operació" ], + "Operation failed, try again later": [ + "L'operació ha fallat, torneu-ho a provar més tard" + ], "Parent cannot be decrypted": [ "L'element principal no es pot desxifrar" ], + "Photo not found": [ + "No s'ha trobat la fotografia" + ], "Renaming root item is not allowed": [ "No està permès canviar el nom d'un element arrel" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 293e7ffc..8a0e8bd7 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "Lesezeichen-Passwort ist nicht verfügbar" ], + "Cannot add photo to album without a valid name": [ + "Foto kann ohne gültigen Namen nicht zum Album hinzugefügt werden" + ], "Cannot download a folder": [ "Ordner kann nicht heruntergeladen werden" ], @@ -165,9 +168,15 @@ "Operation aborted": [ "Vorgang abgebrochen" ], + "Operation failed, try again later": [ + "Vorgang fehlgeschlagen, bitte versuche es später erneut" + ], "Parent cannot be decrypted": [ "Übergeordnetes Element kann nicht entschlüsselt werden" ], + "Photo not found": [ + "Foto nicht gefunden" + ], "Renaming root item is not allowed": [ "Umbenennen des Stammeintrags ist nicht erlaubt." ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index b0bb9356..679ce854 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "Η σελιδοδείκτηση ÎºÏ‰Î´Î¹ÎºÎ¿Ï Î´ÎµÎ½ είναι διαθέσιμη" ], + "Cannot add photo to album without a valid name": [ + "Δεν είναι δυνατή η Ï€Ïοσθήκη φωτογÏαφίας στο άλμπουμ χωÏίς έγκυÏο όνομα" + ], "Cannot download a folder": [ "Δεν είναι δυνατή η λήψη ενός φακέλου" ], @@ -165,9 +168,15 @@ "Operation aborted": [ "Η λειτουÏγία ακυÏώθηκε" ], + "Operation failed, try again later": [ + "Η λειτουÏγία απέτυχε, δοκιμάστε ξανά αÏγότεÏα" + ], "Parent cannot be decrypted": [ "Το γονικό αντικείμενο δεν μποÏεί να αποκÏυπτογÏαφηθεί" ], + "Photo not found": [ + "Η φωτογÏαφία δεν βÏέθηκε" + ], "Renaming root item is not allowed": [ "Δεν επιτÏέπεται η μετονομασία του ÏÎ¹Î¶Î¹ÎºÎ¿Ï ÏƒÏ„Î¿Î¹Ï‡ÎµÎ¯Î¿Ï…" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 3c240c1c..fc7e92ca 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "La contraseña del marcador no está disponible" ], + "Cannot add photo to album without a valid name": [ + "No se puede añadir una foto a un álbum sin un nombre válido" + ], "Cannot download a folder": [ "No se puede descargar una carpeta." ], @@ -165,9 +168,15 @@ "Operation aborted": [ "Operación cancelada" ], + "Operation failed, try again later": [ + "Operación fallida, inténtalo de nuevo más tarde" + ], "Parent cannot be decrypted": [ "No se puede descifrar el elemento principal." ], + "Photo not found": [ + "Foto no encontrada" + ], "Renaming root item is not allowed": [ "No se permite cambiar el nombre del elemento principal." ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index a928994b..10cac80e 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "La contraseña del marcador no está disponible." ], + "Cannot add photo to album without a valid name": [ + "No se puede añadir una foto a un álbum sin un nombre válido" + ], "Cannot download a folder": [ "No se puede descargar una carpeta." ], @@ -165,9 +168,15 @@ "Operation aborted": [ "Operación cancelada" ], + "Operation failed, try again later": [ + "Operación fallida, inténtelo de nuevo más tarde" + ], "Parent cannot be decrypted": [ "No se puede descifrar el elemento principal" ], + "Photo not found": [ + "Foto no encontrada" + ], "Renaming root item is not allowed": [ "No se permite cambiar el nombre del elemento principal." ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index af159903..1c8bd7b0 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "Le mot de passe du favori n'est pas disponible." ], + "Cannot add photo to album without a valid name": [ + "Impossible d'ajouter une photo à l'album sans un nom valide." + ], "Cannot download a folder": [ "Le téléchargement d'un dossier n'a pas abouti." ], @@ -165,9 +168,15 @@ "Operation aborted": [ "L'opération a été annulée." ], + "Operation failed, try again later": [ + "L'opération n'a pas abouti, veuillez réessayer plus tard." + ], "Parent cannot be decrypted": [ "L'élément principal ne peut pas être déchiffré." ], + "Photo not found": [ + "La photo est introuvable." + ], "Renaming root item is not allowed": [ "La modification du nom de l'élément principal n'est pas autorisée." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index cfd1dd34..afdb37c4 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "La password del segnalibro non è disponibile." ], + "Cannot add photo to album without a valid name": [ + "Impossibile aggiungere la foto all’album senza un nome valido." + ], "Cannot download a folder": [ "Impossibile scaricare una cartella" ], @@ -165,9 +168,15 @@ "Operation aborted": [ "Operazione annullata" ], + "Operation failed, try again later": [ + "Operazione non riuscita, riprova più tardi." + ], "Parent cannot be decrypted": [ "Impossibile decriptare il genitore" ], + "Photo not found": [ + "Foto non trovata" + ], "Renaming root item is not allowed": [ "Non è consentito rinominare l'elemento radice" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index 887caa62..a4418b7b 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "Bladwijzerwachtwoord is niet beschikbaar" ], + "Cannot add photo to album without a valid name": [ + "Kan geen foto aan album toevoegen zonder geldige naam" + ], "Cannot download a folder": [ "Kan geen map downloaden" ], @@ -165,9 +168,15 @@ "Operation aborted": [ "Handeling afgebroken" ], + "Operation failed, try again later": [ + "Actie mislukt, probeer het later opnieuw" + ], "Parent cannot be decrypted": [ "Bovenliggend onderdeel kan niet worden ontsleuteld" ], + "Photo not found": [ + "Foto niet gevonden" + ], "Renaming root item is not allowed": [ "Het hernoemen van het hoofditem is niet toegelaten" ], diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index a025b16e..759bbcc7 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -95,6 +95,9 @@ "File has no content key": [ "O arquivo não tem chave de conteúdo." ], + "File hash does not match expected hash": [ + "O hash do arquivo não corresponde ao hash esperado" + ], "Invalid URL": [ "O URL não é válido." ], @@ -159,6 +162,9 @@ "Parent cannot be decrypted": [ "O elemento principal não pode ser descriptografado." ], + "Photo not found": [ + "Foto não encontrada" + ], "Renaming root item is not allowed": [ "Não é permitido alterar o nome do item raiz" ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index 1899d273..a6fd8614 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "Parola semnului de carte nu este disponibilă." ], + "Cannot add photo to album without a valid name": [ + "Nu se poate adăuga o fotografie în album fără un nume valid." + ], "Cannot download a folder": [ "Nu se poate descărca un folder." ], @@ -166,9 +169,15 @@ "Operation aborted": [ "OperaÈ›ie anulată" ], + "Operation failed, try again later": [ + "OperaÈ›iunea a eÈ™uat. ReîncercaÈ›i mai târziu." + ], "Parent cannot be decrypted": [ "Părintele nu poate fi decriptat." ], + "Photo not found": [ + "Fotografia nu a fost găsită" + ], "Renaming root item is not allowed": [ "Redenumirea rădăcinii nu este permisă." ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index 2b45ad7c..842e5448 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "Heslo záložky nie je dostupné" ], + "Cannot add photo to album without a valid name": [ + "Nie je možné pridaÅ¥ fotografiu do albumu bez platného názvu" + ], "Cannot download a folder": [ "Nie je možné stiahnuÅ¥ prieÄinok" ], @@ -167,9 +170,15 @@ "Operation aborted": [ "Operácia preruÅ¡ená" ], + "Operation failed, try again later": [ + "Operácia zlyhala, skúste to neskôr" + ], "Parent cannot be decrypted": [ "Nadradenú položku nemožno deÅ¡ifrovaÅ¥" ], + "Photo not found": [ + "Fotka sa nenaÅ¡la" + ], "Renaming root item is not allowed": [ "Premenovanie koreňovej položky nie je povolené" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index cba54b89..3ac6c6d6 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -11,6 +11,9 @@ "Bookmark password is not available": [ "Yer imi parolası kullanılamıyor" ], + "Cannot add photo to album without a valid name": [ + "Geçerli bir ad olmadan fotoÄŸraf albüme eklenemez" + ], "Cannot download a folder": [ "Bir klasör indirilemedi" ], @@ -165,9 +168,15 @@ "Operation aborted": [ "İşlem iptal edildi" ], + "Operation failed, try again later": [ + "İşlem yapılamadı. Lütfen bir süre sonra yeniden deneyin" + ], "Parent cannot be decrypted": [ "Üst ögenin ÅŸifresi çözülemedi" ], + "Photo not found": [ + "FotoÄŸraf bulunamadı" + ], "Renaming root item is not allowed": [ "Kök öge yeniden adlandırılamaz" ], From 3a5cb689690073e7c11d9013b6dd8d37bd947adc Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Feb 2026 14:18:28 +0100 Subject: [PATCH 544/791] Do not block upload block reuqest by computing digest --- js/sdk/src/internal/photos/upload.ts | 7 ++++++- js/sdk/src/internal/upload/apiService.ts | 16 +++++++--------- js/sdk/src/internal/upload/cryptoService.ts | 9 ++++----- js/sdk/src/internal/upload/interface.ts | 2 +- .../src/internal/upload/streamUploader.test.ts | 6 ++---- js/sdk/src/internal/upload/streamUploader.ts | 18 ++++++------------ 6 files changed, 26 insertions(+), 32 deletions(-) diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index c98e387e..74ca664f 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -119,7 +119,12 @@ export class PhotoStreamUploader extends StreamUploader { digests, }; - await this.photoUploadManager.commitDraftPhoto(this.revisionDraft, this.manifest, extendedAttributes, this.photoMetadata); + await this.photoUploadManager.commitDraftPhoto( + this.revisionDraft, + await this.getManifest(), + extendedAttributes, + this.photoMetadata, + ); } } diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 69559af8..012b48c8 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -162,22 +162,24 @@ export class UploadAPIService { blocks: { contentBlocks: { index: number; - hash: Uint8Array; - encryptedSize: number; armoredSignature: string; verificationToken: Uint8Array; }[]; thumbnails?: { type: ThumbnailType; - hash: Uint8Array; - encryptedSize: number; }[]; }, ): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); const result = await this.apiService.post< // TODO: Deprected fields but not properly marked in the types. - Omit, + Omit< + PostRequestBlockUploadRequest, + 'ShareID' | 'Thumbnail' | 'ThumbnailHash' | 'ThumbnailSize' | 'BlockList' | 'ThumbnailList' + > & { + BlockList: Omit[]; + ThumbnailList: Omit[]; + }, PostRequestBlockUploadResponse >('drive/blocks', { AddressID: addressId, @@ -186,16 +188,12 @@ export class UploadAPIService { RevisionID: revisionId, BlockList: blocks.contentBlocks.map((block) => ({ Index: block.index, - Hash: uint8ArrayToBase64String(block.hash), EncSignature: block.armoredSignature, - Size: block.encryptedSize, Verifier: { Token: uint8ArrayToBase64String(block.verificationToken), }, })), ThumbnailList: (blocks.thumbnails || []).map((block) => ({ - Hash: uint8ArrayToBase64String(block.hash), - Size: block.encryptedSize, Type: block.type, })), }); diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 5c9bb8ec..9cbdcff8 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -111,14 +111,14 @@ export class UploadCryptoService { nodeRevisionDraftKeys.signingKeys.contentSigningKey, ); - const digest = await crypto.subtle.digest('SHA-256', encryptedData); + const digestPromise = crypto.subtle.digest('SHA-256', encryptedData); return { type: thumbnail.type, encryptedData: encryptedData, originalSize: thumbnail.thumbnail.length, encryptedSize: encryptedData.length, - hash: new Uint8Array(digest), + hashPromise: digestPromise.then((digest) => new Uint8Array(digest)), }; } @@ -134,8 +134,7 @@ export class UploadCryptoService { nodeRevisionDraftKeys.contentKeyPacketSessionKey, nodeRevisionDraftKeys.signingKeys.contentSigningKey, ); - - const digest = await crypto.subtle.digest('SHA-256', encryptedData); + const digestPromise = crypto.subtle.digest('SHA-256', encryptedData); const { verificationToken } = await verifyBlock(encryptedData); return { @@ -145,7 +144,7 @@ export class UploadCryptoService { verificationToken, originalSize: block.length, encryptedSize: encryptedData.length, - hash: new Uint8Array(digest), + hashPromise: digestPromise.then((digest) => new Uint8Array(digest)), }; } diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index 8cb4c5fc..deec35e9 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -64,7 +64,7 @@ export type NodeCryptoSigningKeys = { export type EncryptedBlockMetadata = { encryptedSize: number; originalSize: number; - hash: Uint8Array; + hashPromise: Promise>; }; export type EncryptedBlock = EncryptedBlockMetadata & { diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index f9f3778a..a0ebd300 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -27,7 +27,7 @@ async function mockEncryptBlock( verificationToken: 'verificationToken', originalSize: block.length, encryptedSize: block.length + BLOCK_ENCRYPTION_OVERHEAD, - hash: 'blockHash', + hashPromise: Promise.resolve('blockHash'), }; } @@ -90,7 +90,7 @@ describe('StreamUploader', () => { encryptedData: thumbnail.thumbnail, originalSize: thumbnail.thumbnail.length, encryptedSize: thumbnail.thumbnail + 1000, - hash: 'thumbnailHash', + hashPromise: Promise.resolve('thumbnailHash'), })), encryptBlock: jest.fn().mockImplementation(mockEncryptBlock), }; @@ -496,8 +496,6 @@ describe('StreamUploader', () => { contentBlocks: [ { index: 2, - encryptedSize: 4 * 1024 * 1024 + 10000, - hash: 'blockHash', armoredSignature: 'signature', verificationToken: 'verificationToken', }, diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 28831513..e81f58f2 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -230,7 +230,7 @@ export class StreamUploader { }; await this.uploadManager.commitDraft( this.revisionDraft, - this.manifest, + await this.getManifest(), extendedAttributes, this.metadata.additionalMetadata, ); @@ -328,8 +328,6 @@ export class StreamUploader { contentBlocks: Array.from( this.encryptedBlocks.values().map((block) => ({ index: block.index, - encryptedSize: block.encryptedSize, - hash: block.hash, armoredSignature: block.armoredSignature, verificationToken: block.verificationToken, })), @@ -337,8 +335,6 @@ export class StreamUploader { thumbnails: Array.from( this.encryptedThumbnails.values().map((block) => ({ type: block.type, - encryptedSize: block.encryptedSize, - hash: block.hash, })), ), }, @@ -422,7 +418,7 @@ export class StreamUploader { ); this.uploadedThumbnails.push({ type: encryptedThumbnail.type, - hash: encryptedThumbnail.hash, + hashPromise: encryptedThumbnail.hashPromise, encryptedSize: encryptedThumbnail.encryptedSize, originalSize: encryptedThumbnail.originalSize, }); @@ -489,7 +485,7 @@ export class StreamUploader { ); this.uploadedBlocks.push({ index: encryptedBlock.index, - hash: encryptedBlock.hash, + hashPromise: encryptedBlock.hashPromise, encryptedSize: encryptedBlock.encryptedSize, originalSize: encryptedBlock.originalSize, }); @@ -524,8 +520,6 @@ export class StreamUploader { contentBlocks: [ { index: encryptedBlock.index, - encryptedSize: encryptedBlock.encryptedSize, - hash: encryptedBlock.hash, armoredSignature: encryptedBlock.armoredSignature, verificationToken: encryptedBlock.verificationToken, }, @@ -655,12 +649,12 @@ export class StreamUploader { return uploadedBlocks.map((block) => block.originalSize); } - protected get manifest(): Uint8Array { + protected async getManifest(): Promise> { this.uploadedThumbnails.sort((a, b) => a.type - b.type); this.uploadedBlocks.sort((a, b) => a.index - b.index); const hashes = [ - ...this.uploadedThumbnails.map(({ hash }) => hash), - ...this.uploadedBlocks.map(({ hash }) => hash), + ...(await Promise.all(this.uploadedThumbnails.map(({ hashPromise }) => hashPromise))), + ...(await Promise.all(this.uploadedBlocks.map(({ hashPromise }) => hashPromise))), ]; return mergeUint8Arrays(hashes); } From 22690e0f4776ec307963279b5505044a6ae8a9b2 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Feb 2026 15:34:28 +0100 Subject: [PATCH 545/791] Add context to timestamp conversion errors --- .../InteropProtonDriveClient.cs | 6 +++--- .../TimestampExtensions.cs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/TimestampExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 5e15cb90..4c228c4b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -90,7 +90,7 @@ public static async ValueTask HandleCreateFolderAsync(DriveClientCreat var createdFolder = await client.CreateFolderAsync( NodeUid.Parse(request.ParentFolderUid), request.FolderName, - request.LastModificationTime?.ToDateTime(), + request.LastModificationTime?.ToDateTimeFixed(), cancellationToken).ConfigureAwait(false); return new FolderNode @@ -122,7 +122,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe request.Name, request.MediaType, request.Size, - request.LastModificationTime.ToDateTime(), + request.LastModificationTime.ToDateTimeFixed(), additionalMetadata, request.OverrideExistingDraftByOtherClient, cancellationToken).ConfigureAwait(false); @@ -144,7 +144,7 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive var fileUploader = await client.GetFileRevisionUploaderAsync( RevisionUid.Parse(request.CurrentActiveRevisionUid), request.Size, - request.LastModificationTime.ToDateTime(), + request.LastModificationTime.ToDateTimeFixed(), additionalMetadata, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/TimestampExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/TimestampExtensions.cs new file mode 100644 index 00000000..0563f7d6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/TimestampExtensions.cs @@ -0,0 +1,19 @@ +using Google.Protobuf.WellKnownTypes; + +namespace Proton.Drive.Sdk.CExports; + +internal static class TimestampExtensions +{ + // Workaround for issue: http://github.com/protocolbuffers/protobuf/issues/26006 + public static DateTime ToDateTimeFixed(this Timestamp timestamp) + { + try + { + return timestamp.ToDateTime(); + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException($"Timestamp contains invalid values: Seconds={timestamp.Seconds}; Nanos={timestamp.Nanos}", e); + } + } +} From fb14d59905ea31af4cc6dae2f74d8370ece42c60 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Feb 2026 16:11:37 +0100 Subject: [PATCH 546/791] Set caller exception as cause to be reported in Sentry --- .../sdk/internal/BaseContinuationResponse.kt | 29 ++++++++++++------- .../ContinuationUnitOrErrorResponse.kt | 8 ++--- .../ContinuationValueOrErrorResponse.kt | 12 ++++---- .../ContinuationValueOrNullResponse.kt | 7 ++--- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt index fe331d88..e4f75155 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt @@ -2,6 +2,7 @@ package me.proton.drive.sdk.internal import com.google.protobuf.kotlin.toByteString import me.proton.drive.sdk.ProtonDriveSdkException +import me.proton.drive.sdk.extension.toError import proton.sdk.ProtonSdk import java.nio.ByteBuffer import kotlin.coroutines.Continuation @@ -24,17 +25,25 @@ abstract class BaseContinuationResponse( } .mapCatching(block) .onSuccess(continuation::resume) - .onFailure(::resumeWithException) + .onFailure(continuation::resumeWithException) } - private fun resumeWithException(exception: Throwable) { - continuation.resumeWithException(exception.apply { - addSuppressed(callSite.apply { - // Remove the first few frames that are internal to this function - stackTrace = stackTrace.dropWhile { element -> - element.className.startsWith("me.proton.drive.sdk.internal.Jni").not() - }.toTypedArray() - }) - }) + protected fun error(message: String): Nothing = throw ProtonDriveSdkException( + message = message, + cause = prepareCallSite(), + error = null, + ) + + protected fun error(error: ProtonSdk.Error): Nothing = throw ProtonDriveSdkException( + message = error.message, + cause = prepareCallSite(), + error = error.toError(), + ) + + private fun prepareCallSite(): CallerException = callSite.apply { + // Remove the first few frames that are internal to this function + stackTrace = stackTrace.dropWhile { element -> + element.className.startsWith("me.proton.drive.sdk.internal.Jni").not() + }.toTypedArray() } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt index 8c94ad9b..77d9af47 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt @@ -1,7 +1,5 @@ package me.proton.drive.sdk.internal -import me.proton.drive.sdk.ProtonDriveSdkException -import me.proton.drive.sdk.extension.toException import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET import proton.sdk.ProtonSdk.Response.ResultCase.VALUE @@ -13,10 +11,10 @@ class ContinuationUnitOrErrorResponse( ) : BaseContinuationResponse(continuation) { override fun invoke(data: ByteBuffer) = parse(data) { response -> when (response.resultCase) { - VALUE -> throw ProtonDriveSdkException("No response was expected but: ${response.value.typeUrl}") + VALUE -> error("No response was expected but: ${response.value.typeUrl}") RESULT_NOT_SET -> Unit - ERROR -> throw response.error.toException() - null -> throw ProtonDriveSdkException("No response (null)") + ERROR -> error(response.error) + null -> error("No response (null)") } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt index b61aa7cd..5d965e77 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt @@ -1,8 +1,6 @@ package me.proton.drive.sdk.internal -import me.proton.drive.sdk.ProtonDriveSdkException import me.proton.drive.sdk.converter.AnyConverter -import me.proton.drive.sdk.extension.toException import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET import proton.sdk.ProtonSdk.Response.ResultCase.VALUE @@ -17,15 +15,15 @@ class ContinuationValueOrErrorResponse( override fun invoke(data: ByteBuffer) = parse(data) { response -> when (response.resultCase) { VALUE -> { - check(response.value.typeUrl == anyConverter.typeUrl) { - "Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})" + if (response.value.typeUrl == anyConverter.typeUrl) { + error("Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})") } anyConverter.convert(response.value) } - RESULT_NOT_SET -> throw ProtonDriveSdkException("No response (not set)") - ERROR -> throw response.error.toException() - null -> throw ProtonDriveSdkException("No response (null)") + RESULT_NOT_SET -> error("No response (not set)") + ERROR -> error(response.error) + null -> error("No response (null)") } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt index ffd6fd35..157720de 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt @@ -1,7 +1,6 @@ package me.proton.drive.sdk.internal import me.proton.drive.sdk.converter.AnyConverter -import me.proton.drive.sdk.extension.toException import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET import proton.sdk.ProtonSdk.Response.ResultCase.VALUE @@ -15,14 +14,14 @@ class ContinuationValueOrNullResponse( override fun invoke(data: ByteBuffer) = parse(data) { response -> when (response.resultCase) { VALUE -> { - check(response.value.typeUrl == anyConverter.typeUrl) { - "Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})" + if (response.value.typeUrl == anyConverter.typeUrl) { + error("Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})") } anyConverter.convert(response.value) } RESULT_NOT_SET -> null - ERROR -> throw response.error.toException() + ERROR -> error(response.error) null -> null } } From 04b30364c4e8b1817e956acb881d47138c3a32f7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Feb 2026 16:59:11 +0100 Subject: [PATCH 547/791] Fix value type check --- .../drive/sdk/internal/ContinuationValueOrErrorResponse.kt | 2 +- .../drive/sdk/internal/ContinuationValueOrNullResponse.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt index 5d965e77..9618e604 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt @@ -15,7 +15,7 @@ class ContinuationValueOrErrorResponse( override fun invoke(data: ByteBuffer) = parse(data) { response -> when (response.resultCase) { VALUE -> { - if (response.value.typeUrl == anyConverter.typeUrl) { + if (response.value.typeUrl != anyConverter.typeUrl) { error("Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})") } anyConverter.convert(response.value) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt index 157720de..54a9de8d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt @@ -14,7 +14,7 @@ class ContinuationValueOrNullResponse( override fun invoke(data: ByteBuffer) = parse(data) { response -> when (response.resultCase) { VALUE -> { - if (response.value.typeUrl == anyConverter.typeUrl) { + if (response.value.typeUrl != anyConverter.typeUrl) { error("Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})") } anyConverter.convert(response.value) From 77053f2e7d9b5bc3fbd134a06c77bce73d84c787 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Feb 2026 07:27:26 +0100 Subject: [PATCH 548/791] Add node context to error about missing parent key --- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- js/sdk/src/internal/photos/nodes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 142596fe..77ed9210 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -412,7 +412,7 @@ export abstract class NodesAccessBase< } // This is bug that should not happen. // API cannot provide node without parent or share. - throw new Error('Node has neither parent node nor share'); + throw new Error(`Node has neither parent node nor share: ${node.uid}`); } async getNodeKeys(nodeUid: string): Promise { diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 09fbcf10..901bd417 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -178,7 +178,7 @@ export class PhotosNodesAccess extends NodesAccessBase Date: Mon, 23 Feb 2026 16:29:58 +0100 Subject: [PATCH 549/791] Upgrade android core to the last version (36.3.0) --- kt/libs.versions.toml | 2 +- kt/sdk/build.gradle.kts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/kt/libs.versions.toml b/kt/libs.versions.toml index 649ee525..9f6d8d73 100644 --- a/kt/libs.versions.toml +++ b/kt/libs.versions.toml @@ -5,7 +5,7 @@ androidx-room = "2.7.2" androidx-test = "1.5.0" # Android tools android-tools = "1.1.5" -core = "35.0.0" +core = "36.3.2" # Dagger dagger = "2.53.1" # Desugar diff --git a/kt/sdk/build.gradle.kts b/kt/sdk/build.gradle.kts index 27118276..9e75ad81 100644 --- a/kt/sdk/build.gradle.kts +++ b/kt/sdk/build.gradle.kts @@ -73,7 +73,9 @@ dependencies { androidTestImplementation(files("$rootDir/gopenpgp-v2-v3/gopenpgp.aar")) androidTestImplementation(libs.core.auth.domain) androidTestImplementation(libs.core.network.data) - androidTestImplementation(libs.core.crypto.android) + androidTestImplementation(libs.core.crypto.android) { + exclude("me.proton.crypto", "android-golib") + } androidTestImplementation(libs.core.domain) androidTestImplementation(libs.core.account.dagger) androidTestImplementation(libs.core.accountManager.dagger) { @@ -83,7 +85,9 @@ dependencies { exclude("me.proton.core", "auth-presentation") } androidTestImplementation(libs.core.accountRecovery.dagger) - androidTestImplementation(libs.core.crypto.dagger) + androidTestImplementation(libs.core.crypto.dagger){ + exclude("me.proton.crypto", "android-golib") + } androidTestImplementation(libs.core.featureFlag.dagger) androidTestImplementation(libs.core.key.dagger) androidTestImplementation(libs.core.plan.dagger) From 41d9abbaae8bfe0e097c5e41cc917f5af3c518c3 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Feb 2026 11:13:28 +0000 Subject: [PATCH 550/791] Clean native memory of global weak references --- kt/sdk/src/main/jni/job.c | 10 +++++++- kt/sdk/src/main/jni/proton_drive_sdk.c | 6 ++++- .../drive/sdk/internal/JniHttpStream.kt | 3 ++- .../me/proton/drive/sdk/internal/JniJob.kt | 11 +++++++-- .../internal/ProtonDriveSdkNativeClient.kt | 24 +++++++++++++++---- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/kt/sdk/src/main/jni/job.c b/kt/sdk/src/main/jni/job.c index 09436396..34f5f2a1 100644 --- a/kt/sdk/src/main/jni/job.c +++ b/kt/sdk/src/main/jni/job.c @@ -68,7 +68,7 @@ jlong Java_me_proton_drive_sdk_internal_JniJob_getCancelPointer( return (jlong) (intptr_t) &onCancel; } -jlong Java_me_proton_drive_sdk_internal_JniJob_createWeakRef( +jlong Java_me_proton_drive_sdk_internal_JniJob_createWeakGlobalRef( JNIEnv *env, jclass clazz, jobject obj @@ -76,3 +76,11 @@ jlong Java_me_proton_drive_sdk_internal_JniJob_createWeakRef( jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); return (jlong) (intptr_t) weakRef; } + +void Java_me_proton_drive_sdk_internal_JniJob_deleteWeakGlobalRef( + JNIEnv *env, + jclass clazz, + jlong ref +) { + (*env)->DeleteWeakGlobalRef(env, (jweak) ref); +} diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 96f720a9..f848e927 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -189,7 +189,11 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSha1Pointe return (jlong) (intptr_t) &onSha1; } -jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createWeakRef(JNIEnv* env, jobject obj) { +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createWeakGlobalRef(JNIEnv* env, jobject obj) { jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); return (jlong)(intptr_t) weakRef; } + +void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_deleteWeakGlobalRef(JNIEnv* env, jclass clazz, jlong weakRef) { + (*env)->DeleteWeakGlobalRef(env, (jweak) weakRef); +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index dd145226..82a3564a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -32,7 +32,7 @@ class JniHttpStream internal constructor( logger = internalLogger ).also { client = it - }.createWeakRef() + }.asWeakReference() } suspend fun read( @@ -58,6 +58,7 @@ class JniHttpStream internal constructor( ) fun release() { + client?.release() client = null } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt index 8977546f..8de8a6a8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt @@ -8,7 +8,14 @@ object JniJob { external fun getCancelPointer(): Long @JvmStatic - external fun createWeakRef(job: Job): Long + external fun createWeakGlobalRef(job: Job): Long + + @JvmStatic + external fun deleteWeakGlobalRef(ref: Long) } -fun Job.createWeakRef() = JniJob.createWeakRef(this) +fun Job.asWeakReference() = JniJob.createWeakGlobalRef(this).also { ref -> + invokeOnCompletion { + JniJob.deleteWeakGlobalRef(ref) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index a3365d03..6ec20678 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -43,11 +43,20 @@ class ProtonDriveSdkNativeClient internal constructor( val logger: (Level, String) -> Unit = { _, _ -> }, private val coroutineScopeProvider: CoroutineScopeProvider = { null }, ) { + @Volatile + private var nativeWeakReference: Long? = null + private val weakReferenceLock = Any() private val byteArrayPointers = ByteArrayPointers() fun release() { byteArrayPointers.releaseAll() + synchronized(weakReferenceLock) { + nativeWeakReference?.let { ref -> + deleteWeakGlobalRef(ref) + nativeWeakReference = null + } + } } fun handleRequest( @@ -79,7 +88,11 @@ class ProtonDriveSdkNativeClient internal constructor( fun getByteArrayPointer(data: ByteArray): Long = byteArrayPointers.allocate(data) - external fun createWeakRef(): Long + fun asWeakReference(): Long = synchronized(weakReferenceLock) { + nativeWeakReference ?: createWeakGlobalRef().also { ref -> nativeWeakReference = ref } + } + + external fun createWeakGlobalRef(): Long @Suppress("unused") // Called by JNI fun onResponse(data: ByteBuffer) { @@ -101,14 +114,14 @@ class ProtonDriveSdkNativeClient internal constructor( val bytesRead = read(buffer).takeUnless { it < 0 } ?: 0 logger(VERBOSE, "$bytesRead bytes read for $name") response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } - }?.createWeakRef() ?: 0 + }?.asWeakReference() ?: 0 @Suppress("unused") // Called by JNI fun onWrite(data: ByteBuffer, sdkHandle: Long): Long = onOperation("write", sdkHandle) { logger(VERBOSE, "write for $name of size: ${data.capacity()}") write(data) response {} - }?.createWeakRef() ?: 0 + }?.asWeakReference() ?: 0 @Suppress("unused") // Called by JNI fun onSeek(data: ByteBuffer, sdkHandle: Long) { @@ -146,7 +159,7 @@ class ProtonDriveSdkNativeClient internal constructor( "receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}" ) response { value = httpResponse.asAny("proton.sdk.HttpResponse") } - }?.createWeakRef() ?: 0 + }?.asWeakReference() ?: 0 @Suppress("unused") // Called by JNI fun onHttpResponseRead(buffer: ByteBuffer, sdkHandle: Long) { @@ -388,5 +401,8 @@ class ProtonDriveSdkNativeClient internal constructor( @JvmStatic external fun getSha1Pointer(): Long + + @JvmStatic + external fun deleteWeakGlobalRef(ref: Long) } } From 2211a6f208c3e2e9b201eef0719cc37f103b7e98 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Feb 2026 13:08:38 +0000 Subject: [PATCH 551/791] Add crypto performance metrics --- js/sdk/src/crypto/driveCrypto.test.ts | 3 +- js/sdk/src/crypto/driveCrypto.ts | 38 +++++++++++++++++++++-- js/sdk/src/interface/telemetry.ts | 15 ++++++++- js/sdk/src/protonDriveClient.ts | 2 +- js/sdk/src/protonDrivePhotosClient.ts | 2 +- js/sdk/src/protonDrivePublicLinkClient.ts | 2 +- 6 files changed, 55 insertions(+), 7 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.test.ts b/js/sdk/src/crypto/driveCrypto.test.ts index 076eadfc..b54587c4 100644 --- a/js/sdk/src/crypto/driveCrypto.test.ts +++ b/js/sdk/src/crypto/driveCrypto.test.ts @@ -1,4 +1,5 @@ import { uint8ArrayToUtf8, arrayToHexString, DriveCrypto } from './driveCrypto'; +import { getMockTelemetry } from '../tests/telemetry'; describe('uint8ArrayToUtf8', () => { it('should convert a Uint8Array to a UTF-8 string', () => { @@ -53,7 +54,7 @@ describe('DriveCrypto.encryptShareUrlPassword', () => { }; const mockSrpModule = jest.fn(); - const driveCrypto = new DriveCrypto(mockOpenPGPCrypto as any, mockSrpModule as any); + const driveCrypto = new DriveCrypto(getMockTelemetry(), mockOpenPGPCrypto as any, mockSrpModule as any); const password = 'testPassword123'; const encryptionKey = 'mockEncryptionKey' as any; diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 8a23f1d3..0434d9cc 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,3 +1,4 @@ +import { ProtonDriveTelemetry } from '../interface'; import { OpenPGPCrypto, PrivateKey, @@ -29,9 +30,11 @@ enum SIGNING_CONTEXTS { */ export class DriveCrypto { constructor( + private telemetry: ProtonDriveTelemetry, private openPGPCrypto: OpenPGPCrypto, private srpModule: SRPModule, ) { + this.telemetry = telemetry; this.openPGPCrypto = openPGPCrypto; this.srpModule = srpModule; } @@ -617,12 +620,14 @@ export class DriveCrypto { ): Promise<{ encryptedData: Uint8Array; }> { + const start = performance.now(); const { encryptedData } = await this.openPGPCrypto.encryptAndSign( thumbnailData, sessionKey, [], // Thumbnails use the session key so we do not send encryption key. signingKey, ); + this.recordPerformance('content_encryption', thumbnailData.length, start); return { encryptedData, @@ -638,11 +643,13 @@ export class DriveCrypto { verified: VERIFICATION_STATUS; verificationErrors?: Error[]; }> { + const start = performance.now(); const { data: decryptedThumbnail, verified, verificationErrors, } = await this.openPGPCrypto.decryptAndVerify(encryptedThumbnail, sessionKey, verificationKeys); + this.recordPerformance('content_decryption', decryptedThumbnail.length, start); return { decryptedThumbnail, verified, @@ -659,12 +666,14 @@ export class DriveCrypto { encryptedData: Uint8Array; armoredSignature: string; }> { + const start = performance.now(); const { encryptedData, signature } = await this.openPGPCrypto.encryptAndSignDetached( blockData, sessionKey, [], // Blocks use the session key so we do not send encryption key. signingKey, ); + this.recordPerformance('content_encryption', blockData.length, start); const { armoredSignature } = await this.encryptSignature(signature, encryptionKey, sessionKey); @@ -674,8 +683,13 @@ export class DriveCrypto { }; } - async decryptBlock(encryptedBlock: Uint8Array, sessionKey: SessionKey): Promise> { + async decryptBlock( + encryptedBlock: Uint8Array, + sessionKey: SessionKey, + ): Promise> { + const start = performance.now(); const { data: decryptedBlock } = await this.openPGPCrypto.decryptAndVerify(encryptedBlock, sessionKey, []); + this.recordPerformance('content_decryption', decryptedBlock.length, start); return decryptedBlock; } @@ -716,7 +730,11 @@ export class DriveCrypto { return uint8ArrayToUtf8(password); } - async encryptShareUrlPassword(password: string, encryptionKey: PrivateKey, signingKey: PrivateKey): Promise { + async encryptShareUrlPassword( + password: string, + encryptionKey: PrivateKey, + signingKey: PrivateKey, + ): Promise { const { armoredData } = await this.openPGPCrypto.encryptAndSignArmored( new TextEncoder().encode(password), undefined, @@ -725,6 +743,22 @@ export class DriveCrypto { ); return armoredData; } + + private recordPerformance( + type: 'content_encryption' | 'content_decryption', + bytesProcessed: number, + start: number, + ) { + const end = performance.now(); + const duration = end - start; + this.telemetry.recordMetric({ + eventName: 'performance', + type, + cryptoModel: 'v1', + bytesProcessed, + milliseconds: Math.round(duration), + }); + } } export function uint8ArrayToUtf8(input: Uint8Array): string { diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 0606d696..99da4a6d 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -18,7 +18,8 @@ export type MetricEvent = | MetricDecryptionErrorEvent | MetricVerificationErrorEvent | MetricBlockVerificationErrorEvent - | MetricVolumeEventsSubscriptionsChangedEvent; + | MetricVolumeEventsSubscriptionsChangedEvent + | MetricPerformanceEvent; export interface MetricAPIRetrySucceededEvent { eventName: 'apiRetrySucceeded'; @@ -118,3 +119,15 @@ export enum MetricVolumeType { Shared = 'shared', SharedPublic = 'shared_public', } + +/** + * Experimental metrics to track performance of encryption and decryption + * operations of the file content. + */ +export interface MetricPerformanceEvent { + eventName: 'performance'; + type: 'content_encryption' | 'content_decryption'; + cryptoModel: 'v1' | 'v1.5'; + bytesProcessed: number; + milliseconds: number; +} diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index dde9c588..cb583a4c 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -135,7 +135,7 @@ export class ProtonDriveClient { const fullConfig = getConfig(config); this.sdkEvents = new SDKEvents(telemetry); - const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); + const cryptoModule = new DriveCrypto(telemetry, openPGPCryptoModule, srpModule); const apiService = new DriveAPIService( telemetry, this.sdkEvents, diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index ad6665c3..96c8ef3a 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -93,7 +93,7 @@ export class ProtonDrivePhotosClient { const fullConfig = getConfig(config); this.sdkEvents = new SDKEvents(telemetry); - const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); + const cryptoModule = new DriveCrypto(telemetry, openPGPCryptoModule, srpModule); const apiService = new DriveAPIService( telemetry, this.sdkEvents, diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index f60d0a62..7e9e21dd 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -132,7 +132,7 @@ export class ProtonDrivePublicLinkClient { fullConfig.baseUrl, fullConfig.language, ); - const cryptoModule = new DriveCrypto(openPGPCryptoModule, srpModule); + const cryptoModule = new DriveCrypto(telemetry, openPGPCryptoModule, srpModule); this.sharingPublic = initSharingPublicModule( telemetry, apiService, From 8ee48b55c475a820d86ed99ac07ba7b07072dc99 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 07:22:32 +0000 Subject: [PATCH 552/791] Stop reporting progress after failed upload --- .../internal/upload/streamUploader.test.ts | 31 +++++++++++++++++++ js/sdk/src/internal/upload/streamUploader.ts | 8 ++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index a0ebd300..94f35093 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -428,6 +428,37 @@ describe('StreamUploader', () => { expect((uploader as any).maxUploadingBlocks).toEqual(1); }); + it('should not call onProgress after upload has failed', async () => { + let firstBlockPromise; + + // Block 1 delays before reporting progress; block 2 fails immediately. + // This simulates block 1's progress callback firing after we've already + // entered the catch block. + apiService.uploadBlock = jest.fn().mockImplementation(async function (bareUrl, token, block, onProgress) { + if (token === 'token/block:1') { + firstBlockPromise = new Promise((resolve) => setTimeout(resolve, 100)); + await firstBlockPromise; + return mockUploadBlock(bareUrl, token, block, onProgress); + } + if (token === 'token/block:2') { + throw new Error('Failed to upload block'); + } + return mockUploadBlock(bareUrl, token, block, onProgress); + }); + + const startPromise = uploader.start(stream, thumbnails, onProgress); + await expect(startPromise).rejects.toThrow('Failed to upload block'); + + expect(firstBlockPromise).toBeDefined(); + await firstBlockPromise!; + + // Mocked file has 3 blocks - 2x 4 MB blocks and 1x 2 MB block + // First block is delayed - should not be reported. + // Second block is failed - should not be reported. + // Third block is successfull before second block is failed - should be reported. + await verifyOnProgress([thumbnailSize, 2 * 1024 * 1024]); + }); + it('limitUploadCapacity should wait for the previous blocks to finish', async () => { const error = new Error('TimeoutError'); error.name = 'TimeoutError'; diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index e81f58f2..0b2c355b 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -121,7 +121,9 @@ export class StreamUploader { this.logger.info(`Starting upload`); await this.encryptAndUploadBlocks(stream, thumbnails, (uploadedBytes) => { fileProgress += uploadedBytes; - onProgress?.(fileProgress); + if (!failure) { + onProgress?.(fileProgress); + } }); this.logger.debug(`All blocks uploaded, committing`); @@ -470,6 +472,10 @@ export class StreamUploader { let attempt = 0; while (true) { + if (this.isUploadAborted) { + throw this.error || new AbortError(); + } + attempt++; try { logger.debug(`Uploading`); From 2b6de18002f13d9613bd4f0617275d767629f09d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 10:30:48 +0100 Subject: [PATCH 553/791] Fix failures due to empty authorship results on degraded nodes --- .../Nodes/DtoToMetadataConverter.cs | 427 ++++++++++-------- cs/sdk/src/Proton.Sdk/Result.cs | 21 +- cs/sdk/src/Proton.Sdk/Result{TError}.cs | 25 +- 3 files changed, 277 insertions(+), 196 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 90cd4e2b..2b999dad 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -1,4 +1,6 @@ -using Proton.Cryptography.Pgp; +using System.Collections.ObjectModel; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Shares; @@ -186,133 +188,41 @@ public static async Task> ConvertDtoT var decryptionResult = await NodeCrypto.DecryptFileAsync(client.Account, linkDto, fileDto, activeRevisionDto, parentKeyResult, cancellationToken) .ConfigureAwait(false); - var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); - var nodeKeyIsInvalid = !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); - var passphraseIsInvalid = !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput); - var extendedAttributesIsInvalid = !decryptionResult.ExtendedAttributes.TryGetValue(out var extendedAttributesOutput); - var contentKeyIsInvalid = !decryptionResult.ContentKey.TryGetValue(out var contentKeyOutput); + var nodeKeyIsValid = decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); + var passphraseIsValid = decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput); + var extendedAttributesIsValid = decryptionResult.ExtendedAttributes.TryGetValue(out var extendedAttributesOutput); + var contentKeyIsValid = decryptionResult.ContentKey.TryGetValue(out var contentKeyOutput); - var nameAuthor = !nameIsInvalid && nameOutput.HasValue - ? decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure) - : default; - - var nodeAuthor = !passphraseIsInvalid - ? decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure - ?? contentKeyOutput.AuthorshipVerificationFailure) - : default; - - var contentAuthor = !extendedAttributesIsInvalid - ? decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(extendedAttributesOutput.AuthorshipVerificationFailure) - : default; + var thumbnails = activeRevisionDto.Thumbnails.Select(dto => new ThumbnailHeader(dto.Id, (ThumbnailType)dto.Type)).ToList().AsReadOnly(); var extendedAttributes = extendedAttributesOutput.Data; - - var thumbnails = activeRevisionDto.Thumbnails.Count > 0 ? new ThumbnailHeader[activeRevisionDto.Thumbnails.Count] : []; - var additionalMetadata = extendedAttributes?.AdditionalMetadata?.Select(x => new AdditionalMetadataProperty(x.Key, x.Value)).ToList().AsReadOnly(); - for (var i = 0; i < activeRevisionDto.Thumbnails.Count; ++i) - { - var thumbnailDto = activeRevisionDto.Thumbnails[i]; - thumbnails[i] = new ThumbnailHeader(thumbnailDto.Id, (ThumbnailType)thumbnailDto.Type); - } - - if ( - nameIsInvalid || (nameSessionKey is null) || nameOutput is null - || passphraseIsInvalid - || nodeKeyIsInvalid - || extendedAttributesIsInvalid - || contentKeyIsInvalid) + if (!NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey) + || !nodeKeyIsValid + || !passphraseIsValid + || !extendedAttributesIsValid + || !contentKeyIsValid) { - List failedDecryptionFields = []; - List errors = []; - - if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) - { - errors.Add(new DecryptionError(passphraseError)); - failedDecryptionFields.Add(EncryptedField.NodeKey); - } - else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) - { - errors.Add(new DecryptionError(nodeKeyError)); - failedDecryptionFields.Add(EncryptedField.NodeKey); - } - else if (decryptionResult.ContentKey.IsFailure) - { - failedDecryptionFields.Add(EncryptedField.NodeContentKey); - } - - if (nameResult.IsFailure) - { - failedDecryptionFields.Add(EncryptedField.NodeName); - } - - var revisionErrors = new List(); - if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) - { - revisionErrors.Add(new DecryptionError(extendedAttributesError)); - failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes); - } - - var degradedRevision = new DegradedRevision - { - Uid = new RevisionUid(uid, activeRevisionDto.Id), - CreationTime = activeRevisionDto.CreationTime, - SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, - ClaimedSize = extendedAttributes?.Common?.Size, - ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, - ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, - Thumbnails = thumbnails.AsReadOnly(), - AdditionalClaimedMetadata = additionalMetadata, - ContentAuthor = contentAuthor, - CanDecrypt = !contentKeyIsInvalid, - Errors = revisionErrors, - }; - - var degradedNode = linkDetailsDto.Photo is not null - ? new DegradedPhotoNode - { - Uid = uid, - ParentUid = parentUid, - Name = nameResult, - NameAuthor = nameAuthor, - CreationTime = linkDto.CreationTime, - TrashTime = linkDto.TrashTime, - Author = nodeAuthor, - MediaType = fileDto.MediaType, - ActiveRevision = degradedRevision, - TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, - Errors = errors, - CaptureTime = linkDetailsDto.Photo.CaptureTime, - } - : new DegradedFileNode - { - Uid = uid, - ParentUid = parentUid, - Name = nameResult, - NameAuthor = nameAuthor, - CreationTime = linkDto.CreationTime, - TrashTime = linkDto.TrashTime, - Author = nodeAuthor, - MediaType = fileDto.MediaType, - ActiveRevision = degradedRevision, - TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, - Errors = errors, - }; - - var degradedSecrets = new DegradedFileSecrets - { - Key = decryptionResult.Link.NodeKey.Merge(x => (PgpPrivateKey?)x, _ => null), - PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), - NameSessionKey = nameSessionKey, - ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), - }; - - await secretCache.SetFileSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); - - var degradedFileMetadata = new DegradedFileMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); - - await entityCache.SetNodeAsync(uid, degradedNode, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + var (degradedFileMetadata, failedDecryptionFields) = CreateDegradedFileMetadata( + linkDetailsDto, + decryptionResult, + nameResult, + uid, + activeRevisionDto, + extendedAttributes, + thumbnails, + additionalMetadata, + parentUid, + linkDto, + fileDto, + nameSessionKey, + membershipDto); + + await secretCache.SetFileSecretsAsync(uid, degradedFileMetadata.Secrets, cancellationToken).ConfigureAwait(false); + + await entityCache.SetNodeAsync(uid, degradedFileMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) + .ConfigureAwait(false); await ReportDecryptionError(client, DegradedNodeMetadata.FromFile(degradedFileMetadata), failedDecryptionFields, cancellationToken) .ConfigureAwait(false); @@ -331,6 +241,10 @@ await ReportDecryptionError(client, DegradedNodeMetadata.FromFile(degradedFileMe : (ReadOnlyMemory?)null, }; + var nodeAuthor = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure); + var nameAuthor = decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure); + var contentAuthor = decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(contentKeyOutput.AuthorshipVerificationFailure); + var activeRevision = new Revision { Uid = new RevisionUid(uid, activeRevisionDto.Id), @@ -380,73 +294,95 @@ await ReportDecryptionError(client, DegradedNodeMetadata.FromFile(degradedFileMe return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } - private static async ValueTask> ConvertDtoToFolderMetadataAsync( - ProtonDriveClient client, - IEntityCache entityCache, - IDriveSecretCache secretCache, - VolumeId volumeId, + private static (DegradedFileMetadata Metadata, List FailedDecryptionFields) CreateDegradedFileMetadata( LinkDetailsDto linkDetailsDto, - FolderDto folderDto, - Result parentKeyResult, - CancellationToken cancellationToken) + FileDecryptionResult decryptionResult, + Result nameResult, + NodeUid uid, + ActiveRevisionDto activeRevisionDto, + ExtendedAttributes? extendedAttributes, + ReadOnlyCollection thumbnails, + ReadOnlyCollection? additionalMetadata, + NodeUid? parentUid, + LinkDto linkDto, + FileDto fileDto, + PgpSessionKey? nameSessionKey, + ShareMembershipSummaryDto? membershipDto) { - var linkDto = linkDetailsDto.Link; - var membershipDto = linkDetailsDto.Membership; + List failedDecryptionFields = []; + List errors = []; - if (folderDto is null) + if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { - var linkType = linkDetailsDto.Link.Type is LinkType.Folder ? "folder" : "album"; - throw new InvalidOperationException($"Node is a {linkType}, but {linkType} properties are missing"); + errors.Add(new DecryptionError(passphraseError)); + failedDecryptionFields.Add(EncryptedField.NodeKey); + } + else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) + { + errors.Add(new DecryptionError(nodeKeyError)); + failedDecryptionFields.Add(EncryptedField.NodeKey); + } + else if (decryptionResult.ContentKey.IsFailure) + { + failedDecryptionFields.Add(EncryptedField.NodeContentKey); } - var uid = new NodeUid(volumeId, linkDto.Id); - var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; + if (nameResult.IsFailure) + { + failedDecryptionFields.Add(EncryptedField.NodeName); + } - var decryptionResult = await NodeCrypto.DecryptFolderAsync(client.Account, linkDto, folderDto.HashKey, parentKeyResult, cancellationToken) - .ConfigureAwait(false); + var revisionErrors = new List(); + if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) + { + revisionErrors.Add(new DecryptionError(extendedAttributesError)); + failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes); + } - var nameIsInvalid = !NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey); - var nodeKeyIsInvalid = !decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); - var passphraseIsInvalid = !decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput); - var hashKeyIsInvalid = !decryptionResult.HashKey.TryGetValue(out var hashKeyOutput); + var nodeAuthor = decryptionResult.Link.Passphrase.Merge( + x => decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + _ => new SignatureVerificationError(decryptionResult.Link.NodeAuthorshipClaim.Author, "Passphrase decryption failed")); - var nameAuthor = !nameIsInvalid && nameOutput.HasValue - ? decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure) - : default; - var nodeAuthor = !passphraseIsInvalid && !hashKeyIsInvalid - ? decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure - ?? hashKeyOutput.AuthorshipVerificationFailure) - : default; + var nameAuthor = decryptionResult.Link.Name.Merge( + x => decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + _ => new SignatureVerificationError(decryptionResult.Link.NameAuthorshipClaim.Author, "Name decryption failed")); - if ( - nameIsInvalid || nameSessionKey is null || nameOutput is null - || passphraseIsInvalid || nodeKeyIsInvalid || hashKeyIsInvalid) - { - List failedDecryptionFields = []; - List errors = []; + var contentAuthor = decryptionResult.ContentKey.Merge( + x => decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + _ => new SignatureVerificationError(decryptionResult.ContentAuthorshipClaim.Author, "Content key decryption failed")); - if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) - { - errors.Add(new DecryptionError(passphraseError)); - failedDecryptionFields.Add(EncryptedField.NodeKey); - } - else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) - { - errors.Add(new DecryptionError(nodeKeyError)); - failedDecryptionFields.Add(EncryptedField.NodeKey); - } - else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) - { - errors.Add(new DecryptionError(hashKeyError)); - failedDecryptionFields.Add(EncryptedField.NodeHashKey); - } + var degradedRevision = new DegradedRevision + { + Uid = new RevisionUid(uid, activeRevisionDto.Id), + CreationTime = activeRevisionDto.CreationTime, + SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, + ClaimedSize = extendedAttributes?.Common?.Size, + ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, + ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, + Thumbnails = thumbnails.AsReadOnly(), + AdditionalClaimedMetadata = additionalMetadata, + ContentAuthor = contentAuthor, + CanDecrypt = decryptionResult.ContentKey.IsSuccess, + Errors = revisionErrors, + }; - if (nameResult.IsFailure) + var degradedNode = linkDetailsDto.Photo is not null + ? new DegradedPhotoNode { - failedDecryptionFields.Add(EncryptedField.NodeName); + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = nameAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + Author = nodeAuthor, + MediaType = fileDto.MediaType, + ActiveRevision = degradedRevision, + TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, + Errors = errors, + CaptureTime = linkDetailsDto.Photo.CaptureTime, } - - var degradedNode = new DegradedFolderNode + : new DegradedFileNode { Uid = uid, ParentUid = parentUid, @@ -455,22 +391,64 @@ private static async ValueTask> C CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, Author = nodeAuthor, + MediaType = fileDto.MediaType, + ActiveRevision = degradedRevision, + TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, Errors = errors, }; - var degradedSecrets = new DegradedFolderSecrets - { - Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), - PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), - NameSessionKey = nameSessionKey, - HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), - }; + var degradedSecrets = new DegradedFileSecrets + { + Key = decryptionResult.Link.NodeKey.Merge(x => (PgpPrivateKey?)x, _ => null), + PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), + NameSessionKey = nameSessionKey, + ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), + }; - await secretCache.SetFolderSecretsAsync(uid, degradedSecrets, cancellationToken).ConfigureAwait(false); + return (new DegradedFileMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); + } - var degradedFolderMetadata = new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest); + private static async ValueTask> ConvertDtoToFolderMetadataAsync( + ProtonDriveClient client, + IEntityCache entityCache, + IDriveSecretCache secretCache, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + FolderDto folderDto, + Result parentKeyResult, + CancellationToken cancellationToken) + { + var linkDto = linkDetailsDto.Link; + var membershipDto = linkDetailsDto.Membership; - await entityCache.SetNodeAsync(uid, degradedNode, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + var uid = new NodeUid(volumeId, linkDto.Id); + var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; + + var decryptionResult = await NodeCrypto.DecryptFolderAsync(client.Account, linkDto, folderDto.HashKey, parentKeyResult, cancellationToken) + .ConfigureAwait(false); + + var nodeKeyIsValid = decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); + var passphraseIsValid = decryptionResult.Link.Passphrase.TryGetValue(out var passphraseOutput); + var hashKeyIsValid = decryptionResult.HashKey.TryGetValue(out var hashKeyOutput); + + if (!NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey) + || !passphraseIsValid + || !nodeKeyIsValid + || !hashKeyIsValid) + { + var (degradedFolderMetadata, failedDecryptionFields) = CreateDegradedFolderMetadata( + decryptionResult, + nameResult, + uid, + parentUid, + linkDto, + nameSessionKey, + membershipDto); + + await secretCache.SetFolderSecretsAsync(uid, degradedFolderMetadata.Secrets, cancellationToken).ConfigureAwait(false); + + await entityCache.SetNodeAsync(uid, degradedFolderMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) + .ConfigureAwait(false); await ReportDecryptionError(client, DegradedNodeMetadata.FromFolder(degradedFolderMetadata), failedDecryptionFields, cancellationToken) .ConfigureAwait(false); @@ -487,7 +465,12 @@ await ReportDecryptionError(client, DegradedNodeMetadata.FromFolder(degradedFold PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous ? passphraseOutput.Data : null, }; - await secretCache.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + var nodeAuthorFromPassphrase = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(passphraseOutput.AuthorshipVerificationFailure); + var nodeAuthorFromHashKey = decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(hashKeyOutput.AuthorshipVerificationFailure); + + var nodeAuthor = nodeAuthorFromHashKey.IsFailure ? nodeAuthorFromHashKey : nodeAuthorFromPassphrase; + + var nameAuthor = decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(nameOutput.Value.AuthorshipVerificationFailure); var node = new FolderNode { @@ -500,11 +483,83 @@ await ReportDecryptionError(client, DegradedNodeMetadata.FromFolder(degradedFold TrashTime = linkDto.TrashTime, }; + await secretCache.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + await entityCache.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } + private static (DegradedFolderMetadata Metadata, List FailedDecryptionFields) CreateDegradedFolderMetadata( + FolderDecryptionResult decryptionResult, + Result nameResult, + NodeUid uid, + NodeUid? parentUid, + LinkDto linkDto, + PgpSessionKey? nameSessionKey, + ShareMembershipSummaryDto? membershipDto) + { + List failedDecryptionFields = []; + List errors = []; + + if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) + { + errors.Add(new DecryptionError(passphraseError)); + failedDecryptionFields.Add(EncryptedField.NodeKey); + } + else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) + { + errors.Add(new DecryptionError(nodeKeyError)); + failedDecryptionFields.Add(EncryptedField.NodeKey); + } + else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) + { + errors.Add(new DecryptionError(hashKeyError)); + failedDecryptionFields.Add(EncryptedField.NodeHashKey); + } + + if (nameResult.IsFailure) + { + failedDecryptionFields.Add(EncryptedField.NodeName); + } + + var nodeAuthorFromPassphrase = decryptionResult.Link.Passphrase.Merge( + x => decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + _ => new SignatureVerificationError(decryptionResult.Link.NodeAuthorshipClaim.Author, "Passphrase decryption failed")); + + var nodeAuthorFromHashKey = decryptionResult.HashKey.Merge( + x => decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + _ => new SignatureVerificationError(decryptionResult.Link.NodeAuthorshipClaim.Author, "Hash key decryption failed")); + + var nodeAuthor = nodeAuthorFromHashKey.IsFailure ? nodeAuthorFromHashKey : nodeAuthorFromPassphrase; + + var nameAuthor = decryptionResult.Link.Name.Merge( + x => decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + _ => new SignatureVerificationError(decryptionResult.Link.NameAuthorshipClaim.Author, "Name decryption failed")); + + var degradedNode = new DegradedFolderNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = nameAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + Author = nodeAuthor, + Errors = errors, + }; + + var degradedSecrets = new DegradedFolderSecrets + { + Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), + PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), + NameSessionKey = nameSessionKey, + HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), + }; + + return (new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); + } + private static async ValueTask> GetParentKeyAsync( ProtonDriveClient client, VolumeId volumeId, diff --git a/cs/sdk/src/Proton.Sdk/Result.cs b/cs/sdk/src/Proton.Sdk/Result.cs index 6d28d608..04e40f86 100644 --- a/cs/sdk/src/Proton.Sdk/Result.cs +++ b/cs/sdk/src/Proton.Sdk/Result.cs @@ -4,25 +4,38 @@ namespace Proton.Sdk; public readonly struct Result { + private readonly ResultStatus _status; private readonly T? _value; private readonly TError? _error; public Result(T value) { - IsSuccess = true; + _status = ResultStatus.Success; _value = value; _error = default; } public Result(TError error) { - IsSuccess = false; + _status = ResultStatus.Failure; _error = error; _value = default; } - public bool IsSuccess { get; } - public bool IsFailure => !IsSuccess; + private enum ResultStatus : byte + { + Invalid = 0, + Success = 1, + Failure = 2, + } + + public bool IsSuccess => ValidStatus is ResultStatus.Success; + public bool IsFailure => ValidStatus is ResultStatus.Failure; + + private ResultStatus ValidStatus => + _status is not ResultStatus.Invalid + ? _status + : throw new InvalidOperationException("Result is in an invalid state."); public static implicit operator Result(T value) => new(value); public static implicit operator Result(TError error) => new(error); diff --git a/cs/sdk/src/Proton.Sdk/Result{TError}.cs b/cs/sdk/src/Proton.Sdk/Result{TError}.cs index cae74e16..8b66951c 100644 --- a/cs/sdk/src/Proton.Sdk/Result{TError}.cs +++ b/cs/sdk/src/Proton.Sdk/Result{TError}.cs @@ -6,22 +6,35 @@ public readonly struct Result { public static readonly Result Success = new(); + private readonly ResultStatus _status; private readonly TError? _error; + public Result() + { + _status = ResultStatus.Success; + _error = default; + } + public Result(TError error) { - IsSuccess = false; + _status = ResultStatus.Failure; _error = error; } - public Result() + private enum ResultStatus : byte { - IsSuccess = true; - _error = default; + Invalid = 0, + Success = 1, + Failure = 2, } - public bool IsSuccess { get; } - public bool IsFailure => !IsSuccess; + public bool IsSuccess => ValidStatus is ResultStatus.Success; + public bool IsFailure => ValidStatus is ResultStatus.Failure; + + private ResultStatus ValidStatus => + _status is not ResultStatus.Invalid + ? _status + : throw new InvalidOperationException("Result is in an invalid state."); public static implicit operator Result(TError error) => new(error); From ff3ad9eb12221d84f63932006274ac149c4d87b7 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 10:33:11 +0100 Subject: [PATCH 554/791] Ignore performance metrics in diagnostics tool --- js/sdk/src/diagnostic/telemetry.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/js/sdk/src/diagnostic/telemetry.ts b/js/sdk/src/diagnostic/telemetry.ts index 0c9a8ba6..ea0c30c9 100644 --- a/js/sdk/src/diagnostic/telemetry.ts +++ b/js/sdk/src/diagnostic/telemetry.ts @@ -27,6 +27,9 @@ export class DiagnosticTelemetry extends EventsGenerator { if (event.eventName === 'volumeEventsSubscriptionsChanged') { return; } + if (event.eventName === 'performance') { + return; + } this.enqueueEvent({ type: 'metric', From 85b5d1eba0b0dc0a6420f0c7c3edb5d52f57beac Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 09:57:42 +0100 Subject: [PATCH 555/791] Clean native memory of weak references after release --- .../me/proton/drive/sdk/internal/JniJob.kt | 6 ---- .../internal/ProtonDriveSdkNativeClient.kt | 28 +++++++++++++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt index 8de8a6a8..1daf1832 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt @@ -13,9 +13,3 @@ object JniJob { @JvmStatic external fun deleteWeakGlobalRef(ref: Long) } - -fun Job.asWeakReference() = JniJob.createWeakGlobalRef(this).also { ref -> - invokeOnCompletion { - JniJob.deleteWeakGlobalRef(ref) - } -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 6ec20678..affb9225 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -46,6 +46,7 @@ class ProtonDriveSdkNativeClient internal constructor( @Volatile private var nativeWeakReference: Long? = null private val weakReferenceLock = Any() + val inactiveJobWeakReferences = ArrayDeque() private val byteArrayPointers = ByteArrayPointers() @@ -56,6 +57,10 @@ class ProtonDriveSdkNativeClient internal constructor( deleteWeakGlobalRef(ref) nativeWeakReference = null } + inactiveJobWeakReferences.forEach { ref -> + JniJob.deleteWeakGlobalRef(ref) + } + inactiveJobWeakReferences.clear() } } @@ -114,14 +119,14 @@ class ProtonDriveSdkNativeClient internal constructor( val bytesRead = read(buffer).takeUnless { it < 0 } ?: 0 logger(VERBOSE, "$bytesRead bytes read for $name") response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } - }?.asWeakReference() ?: 0 + }?.trackWeakReference() ?: 0 @Suppress("unused") // Called by JNI fun onWrite(data: ByteBuffer, sdkHandle: Long): Long = onOperation("write", sdkHandle) { logger(VERBOSE, "write for $name of size: ${data.capacity()}") write(data) response {} - }?.asWeakReference() ?: 0 + }?.trackWeakReference() ?: 0 @Suppress("unused") // Called by JNI fun onSeek(data: ByteBuffer, sdkHandle: Long) { @@ -159,7 +164,7 @@ class ProtonDriveSdkNativeClient internal constructor( "receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}" ) response { value = httpResponse.asAny("proton.sdk.HttpResponse") } - }?.asWeakReference() ?: 0 + }?.trackWeakReference() ?: 0 @Suppress("unused") // Called by JNI fun onHttpResponseRead(buffer: ByteBuffer, sdkHandle: Long) { @@ -368,7 +373,24 @@ class ProtonDriveSdkNativeClient internal constructor( return scope } + private fun Job.trackWeakReference(): Long = JniJob.createWeakGlobalRef(this).also { ref -> + invokeOnCompletion { + synchronized(weakReferenceLock) { + inactiveJobWeakReferences.addLast(ref) + // Clean up oldest refs if we exceed the limit + while (inactiveJobWeakReferences.size > MAX_INACTIVE_JOB_WEAK_REFERENCES) { + inactiveJobWeakReferences.removeFirstOrNull()?.let { oldestRef -> + JniJob.deleteWeakGlobalRef(oldestRef) + } + } + } + } + } + + @Suppress("TooManyFunctions") companion object { + private const val MAX_INACTIVE_JOB_WEAK_REFERENCES = 128 + @JvmStatic external fun handleResponse(sdkHandle: Long, response: ByteArray) From 6013a3fa50c77b8e37a19562ea654ca29b9ba0b1 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 16:39:19 +0000 Subject: [PATCH 556/791] Improve error reporting with full exception details --- .../DriveInteropTelemetryDecorator.cs | 78 +++++++++++++++++-- .../Nodes/Download/FileDownloader.cs | 2 +- .../Nodes/Download/PhotosFileDownloader.cs | 2 +- .../Nodes/Upload/FileUploader.cs | 2 +- .../Telemetry/DownloadEvent.cs | 4 +- .../Proton.Drive.Sdk/Telemetry/UploadEvent.cs | 4 +- 6 files changed, 81 insertions(+), 11 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index de2f21b8..95e0b31b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -46,14 +46,19 @@ private static UploadEventPayload GetUploadEventPayload(UploadEvent me) ExpectedSize = me.ExpectedSize, }; - if (me.Error is not null) + // Check if we should translate InteropErrorException when error is Unknown + var error = me is { Error: Sdk.Telemetry.UploadError.Unknown, OriginalError: InteropErrorException interopError } + ? TranslateToUploadError(interopError) + : me.Error; + + if (error is not null) { - payload.Error = (UploadError)me.Error; + payload.Error = (UploadError)error; } if (me.OriginalError is not null) { - payload.OriginalError = me.OriginalError; + payload.OriginalError = me.OriginalError.GetBaseException().ToString(); } return payload; @@ -68,14 +73,19 @@ private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me) ClaimedFileSize = me.ClaimedFileSize, }; - if (me.Error is not null) + // Check if we should translate InteropErrorException when error is Unknown + var error = me is { Error: Sdk.Telemetry.DownloadError.Unknown, OriginalError: InteropErrorException interopError } + ? TranslateToDownloadError(interopError) + : me.Error; + + if (error is not null) { - payload.Error = (DownloadError)me.Error; + payload.Error = (DownloadError)error; } if (me.OriginalError is not null) { - payload.OriginalError = me.OriginalError; + payload.OriginalError = me.OriginalError.GetBaseException().ToString(); } return payload; @@ -102,4 +112,60 @@ private static DecryptionErrorEventPayload GetDecryptionErrorPayload(DecryptionE return payload; } + + private static Sdk.Telemetry.UploadError? TranslateToUploadError(InteropErrorException exception) + { + if (exception.Error is null) + { + return Sdk.Telemetry.UploadError.Unknown; + } + + var error = exception.Error; + return exception.Error.Domain switch + { + ErrorDomain.Api => TranslateApiErrorToUploadError(error.SecondaryCode), + ErrorDomain.Network or ErrorDomain.Transport => Sdk.Telemetry.UploadError.NetworkError, + ErrorDomain.Serialization => Sdk.Telemetry.UploadError.HttpClientSideError, + ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Sdk.Telemetry.UploadError.IntegrityError, + _ => Sdk.Telemetry.UploadError.Unknown, + }; + } + + private static Sdk.Telemetry.UploadError TranslateApiErrorToUploadError(long statusCode) + { + return statusCode switch + { + 429 => Sdk.Telemetry.UploadError.RateLimited, + >= 400 and < 500 => Sdk.Telemetry.UploadError.HttpClientSideError, + _ => Sdk.Telemetry.UploadError.ServerError, + }; + } + + private static Sdk.Telemetry.DownloadError? TranslateToDownloadError(InteropErrorException exception) + { + if (exception.Error is null) + { + return Sdk.Telemetry.DownloadError.Unknown; + } + + var error = exception.Error; + return exception.Error.Domain switch + { + ErrorDomain.Api => TranslateApiErrorToDownloadError(error.SecondaryCode), + ErrorDomain.Network or ErrorDomain.Transport => Sdk.Telemetry.DownloadError.NetworkError, + ErrorDomain.Serialization => Sdk.Telemetry.DownloadError.HttpClientSideError, + ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Sdk.Telemetry.DownloadError.IntegrityError, + _ => Sdk.Telemetry.DownloadError.Unknown, + }; + } + + private static Sdk.Telemetry.DownloadError TranslateApiErrorToDownloadError(long statusCode) + { + return statusCode switch + { + 429 => Sdk.Telemetry.DownloadError.RateLimited, + >= 400 and < 500 => Sdk.Telemetry.DownloadError.HttpClientSideError, + _ => Sdk.Telemetry.DownloadError.ServerError, + }; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index e61e4b25..b3e8eac3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -130,7 +130,7 @@ private DownloadController BuildDownloadController( void OnFailed(Exception ex) { downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); - downloadEvent.OriginalError = ex.GetBaseException().ToString(); + downloadEvent.OriginalError = ex; RaiseTelemetryEvent(downloadEvent); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index 4aa6f7f2..edcb4cdf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -124,7 +124,7 @@ private DownloadController DownloadToStream( void OnFailed(Exception ex) { downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); - downloadEvent.OriginalError = ex.GetBaseException().ToString(); + downloadEvent.OriginalError = ex; RaiseTelemetryEvent(downloadEvent); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 9f06864e..b5cfc370 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -156,7 +156,7 @@ void OnFailed(Exception ex) } uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); - uploadEvent.OriginalError = ex.GetBaseException().ToString(); + uploadEvent.OriginalError = ex; RaiseTelemetryEvent(uploadEvent); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs index 17fb5f45..a0f693e8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk.Telemetry; @@ -16,5 +17,6 @@ public sealed class DownloadEvent : IMetricEvent public DownloadError? Error { get; set; } - public string? OriginalError { get; set; } + [JsonIgnore] + public Exception? OriginalError { get; set; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs index cf6a9ac7..515f110d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk.Telemetry; @@ -16,5 +17,6 @@ public sealed class UploadEvent : IMetricEvent public UploadError? Error { get; set; } - public string? OriginalError { get; set; } + [JsonIgnore] + public Exception? OriginalError { get; set; } } From 3593d84af006b20db15aa1131f7c986513e30d0d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 16:43:22 +0000 Subject: [PATCH 557/791] Provide clearer context when canceling operations --- cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs index aa31cc2b..287c0195 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -157,7 +157,9 @@ private static void SetException(nint tcsHandle, Error error) if (error.Domain == ErrorDomain.SuccessfulCancellation) { - tfs.SetException(new OperationCanceledException()); + tfs.SetException(new OperationCanceledException( + "The operation was canceled by the client", + new InteropErrorException(error))); } else { From 6a36c173fea23c69429bd21928a55596926e184a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 16:58:07 +0100 Subject: [PATCH 558/791] Transmit api codes through interop --- .../kotlin/me/proton/drive/sdk/extension/Throwable.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt index e37854b1..010a234e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -13,6 +13,8 @@ fun Throwable.toProtonSdkError(message: String) = proton.sdk.error { "$message, caused by ${exception.message}" } ?: message domain = exception.domain() + exception.primaryCode()?.let { primaryCode = it } + exception.secondaryCode()?.let { secondaryCode = it } context = stackTraceToString() } @@ -29,3 +31,9 @@ private fun Throwable.domain(): ProtonSdk.ErrorDomain = when (this) { else -> ProtonSdk.ErrorDomain.Undefined } + +private fun Throwable.primaryCode(): Long? = + ((this as? ApiException)?.error as? ApiResult.Error.Http)?.proton?.code?.toLong() + +private fun Throwable.secondaryCode(): Long? = + ((this as? ApiException)?.error as? ApiResult.Error.Http)?.httpCode?.toLong() From 3c7d2f0e866d0d8428d48d91c79a87d48ba60b12 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Feb 2026 11:03:21 +0000 Subject: [PATCH 559/791] Update changelog for cs/v0.7.0-alpha.12 --- cs/CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 12cc9572..0110ce05 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## cs/v0.7.0-alpha.12 (2026-02-25) + +* Transmit api codes through interop +* Provide clearer context when canceling operations +* Improve error reporting with full exception details +* Clean native memory of weak references after release +* Fix failures due to empty authorship results on degraded nodes +* Clean native memory of global weak references +* Upgrade android core to the last version (36.3.0) +* Fix value type check +* Set caller exception as cause to be reported in Sentry +* Add context to timestamp conversion errors +* Raise the timeout to 5min to upload 100MB file +* Log progress as percentage +* Accept null content key signatures + ## cs/v0.7.0-alpha.11 (2026-02-18) * Fix download of photos and their thumbnails from shared albums From 7d7ecd96cad07acd04d838d8104cd4279ab5978d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Feb 2026 12:55:44 +0000 Subject: [PATCH 560/791] Add method to update photo tags --- js/sdk/src/interface/index.ts | 1 + js/sdk/src/interface/photos.ts | 15 +- js/sdk/src/internal/apiService/apiService.ts | 8 +- js/sdk/src/internal/nodes/apiService.ts | 4 +- js/sdk/src/internal/photos/addToAlbum.ts | 165 ++------ .../{albums.test.ts => albumsManager.test.ts} | 6 +- .../photos/{albums.ts => albumsManager.ts} | 2 +- js/sdk/src/internal/photos/apiService.ts | 89 +++- js/sdk/src/internal/photos/index.ts | 9 +- js/sdk/src/internal/photos/interface.ts | 31 +- .../src/internal/photos/photosManager.test.ts | 266 ++++++++++++ js/sdk/src/internal/photos/photosManager.ts | 144 +++++++ .../photosTransferPayloadBuilder.test.ts | 380 ++++++++++++++++++ .../photos/photosTransferPayloadBuilder.ts | 203 ++++++++++ js/sdk/src/protonDrivePhotosClient.ts | 34 +- 15 files changed, 1164 insertions(+), 193 deletions(-) rename js/sdk/src/internal/photos/{albums.test.ts => albumsManager.test.ts} (98%) rename js/sdk/src/internal/photos/{albums.ts => albumsManager.ts} (99%) create mode 100644 js/sdk/src/internal/photos/photosManager.test.ts create mode 100644 js/sdk/src/internal/photos/photosManager.ts create mode 100644 js/sdk/src/internal/photos/photosTransferPayloadBuilder.test.ts create mode 100644 js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 88749fb0..5573e756 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -56,6 +56,7 @@ export type { PhotoAttributes, AlbumAttributes, } from './photos'; +export { PhotoTag } from './photos'; export type { ProtonInvitation, ProtonInvitationWithNode, diff --git a/js/sdk/src/interface/photos.ts b/js/sdk/src/interface/photos.ts index da355cc0..b9e68f2f 100644 --- a/js/sdk/src/interface/photos.ts +++ b/js/sdk/src/interface/photos.ts @@ -65,7 +65,20 @@ export type PhotoAttributes = { /** * List of tags assigned to the photo. */ - tags: number[]; // TODO: enum + tags: PhotoTag[]; +}; + +export enum PhotoTag { + Favorites = 0, + Screenshots = 1, + Videos = 2, + LivePhotos = 3, + MotionPhotos = 4, + Selfies = 5, + Portraits = 6, + Bursts = 7, + Panoramas = 8, + Raw = 9, } /** diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 348f8378..64ddce74 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -137,8 +137,12 @@ export class DriveAPIService { return this.makeRequest(url, 'PUT', data, signal); } - async delete(url: string, signal?: AbortSignal): Promise { - return this.makeRequest(url, 'DELETE', undefined, signal); + async delete( + url: string, + data?: RequestPayload, + signal?: AbortSignal, + ): Promise { + return this.makeRequest(url, 'DELETE', data, signal); } protected async makeRequest( diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 03ae0ac7..1c7403e4 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -440,7 +440,7 @@ export abstract class NodeAPIServiceBase< } async emptyTrash(volumeId: string): Promise { - await this.apiService.delete(`drive/volumes/${volumeId}/trash`); + await this.apiService.delete(`drive/volumes/${volumeId}/trash`); } async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { @@ -561,7 +561,7 @@ export abstract class NodeAPIServiceBase< async deleteRevision(nodeRevisionUid: string): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); - await this.apiService.delete( + await this.apiService.delete( `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, ); } diff --git a/js/sdk/src/internal/photos/addToAlbum.ts b/js/sdk/src/internal/photos/addToAlbum.ts index feaad773..9e409227 100644 --- a/js/sdk/src/internal/photos/addToAlbum.ts +++ b/js/sdk/src/internal/photos/addToAlbum.ts @@ -1,13 +1,12 @@ import { c } from 'ttag'; -import { ValidationError } from '../../errors'; import { Logger, NodeResultWithError } from '../../interface'; import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface'; import { splitNodeUid } from '../uids'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; import { MissingRelatedPhotosError } from './errors'; -import { AddToAlbumEncryptedPhotoPayload, DecryptedPhotoNode } from './interface'; +import { PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; import { PhotosNodesAccess } from './nodes'; /** @@ -47,18 +46,20 @@ type PhotoQueueItem = { export class AddToAlbumProcess { private readonly albumVolumeId: string; private readonly retriedPhotoUids = new Set(); + private readonly payloadBuilder: PhotoTransferPayloadBuilder; constructor( private readonly albumNodeUid: string, private readonly albumKeys: DecryptedNodeKeys, private readonly signingKeys: NodeSigningKeys, private readonly apiService: PhotosAPIService, - private readonly cryptoService: AlbumsCryptoService, + cryptoService: AlbumsCryptoService, private readonly nodesService: PhotosNodesAccess, private readonly logger: Logger, private readonly signal?: AbortSignal, ) { this.albumVolumeId = splitNodeUid(albumNodeUid).volumeId; + this.payloadBuilder = new PhotoTransferPayloadBuilder(cryptoService, nodesService); } async *execute(photoNodeUids: string[]): AsyncGenerator { @@ -71,7 +72,13 @@ export class AddToAlbumProcess { private async *processSameVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator { while (queue.length > 0) { const items = queue.splice(0, BATCH_LOADING_SIZE); - const { payloads, errors } = await this.preparePhotoPayloads(items); + const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads( + items, + this.albumNodeUid, + this.albumKeys, + this.signingKeys, + this.signal, + ); for (const [uid, error] of errors) { yield { uid, ok: false, error }; @@ -97,7 +104,13 @@ export class AddToAlbumProcess { private async *processDifferentVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator { while (queue.length > 0) { const items = queue.splice(0, BATCH_LOADING_SIZE); - const { payloads, errors } = await this.preparePhotoPayloads(items); + const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads( + items, + this.albumNodeUid, + this.albumKeys, + this.signingKeys, + this.signal, + ); for (const [uid, error] of errors) { yield { uid, ok: false, error }; @@ -105,7 +118,11 @@ export class AddToAlbumProcess { for (const payload of payloads) { try { - const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum(this.albumNodeUid, payload, this.signal); + const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum( + this.albumNodeUid, + payload, + this.signal, + ); await this.nodesService.notifyChildCreated(newPhotoNodeUid); yield { uid: payload.nodeUid, ok: true }; } catch (error) { @@ -119,131 +136,14 @@ export class AddToAlbumProcess { yield { uid: payload.nodeUid, ok: false, - error: error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }), + error: + error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }), }; } } } } - private async preparePhotoPayloads(items: PhotoQueueItem[]): Promise<{ - payloads: AddToAlbumEncryptedPhotoPayload[]; - errors: Map; - }> { - const payloads: AddToAlbumEncryptedPhotoPayload[] = []; - const errors = new Map(); - - const additionalRelatedMap = new Map( - items.map((item) => [item.photoNodeUid, item.additionalRelatedPhotoNodeUids]), - ); - - const nodeUids = items.map((item) => item.photoNodeUid); - for await (const photoNode of this.nodesService.iterateNodes(nodeUids, this.signal)) { - if ('missingUid' in photoNode) { - errors.set(photoNode.missingUid, new ValidationError(c('Error').t`Photo not found`)); - continue; - } - - try { - const additionalRelated = additionalRelatedMap.get(photoNode.uid) || []; - const payload = await this.preparePhotoPayload(photoNode, additionalRelated); - payloads.push(payload); - } catch (error) { - errors.set( - photoNode.uid, - error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }), - ); - } - } - - return { payloads, errors }; - } - - private async preparePhotoPayload( - photoNode: DecryptedPhotoNode, - additionalRelatedPhotoNodeUids: string[], - ): Promise { - const photoData = await this.encryptPhotoForAlbum(photoNode); - - const relatedNodeUids = [...new Set([ - ...(photoNode.photo?.relatedPhotoNodeUids || []), - ...additionalRelatedPhotoNodeUids, - ])]; - - const relatedPhotos = - relatedNodeUids.length > 0 ? await this.prepareRelatedPhotoPayloads(relatedNodeUids) : []; - - return { - ...photoData, - relatedPhotos, - }; - } - - private async prepareRelatedPhotoPayloads( - nodeUids: string[], - ): Promise[]> { - const payloads: Omit[] = []; - - for await (const photoNode of this.nodesService.iterateNodes(nodeUids, this.signal)) { - // Missing related photos means that the related photo was deleted - // since the loading of the metadata. It can happen and should be - // ignored. The backend controls all the related photos are part - // of the request, thus the request will fail and be retried if - // there is any other race condition. - if ('missingUid' in photoNode) { - continue; - } - const payload = await this.encryptPhotoForAlbum(photoNode); - payloads.push(payload); - } - - return payloads; - } - - private async encryptPhotoForAlbum( - photoNode: DecryptedPhotoNode, - ): Promise { - const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(photoNode.uid); - - const contentSha1 = photoNode.activeRevision?.ok - ? photoNode.activeRevision.value.claimedDigests?.sha1 - : undefined; - - if (!contentSha1) { - throw new Error('Cannot add photo to album without a content hash'); - } - - const encryptedCrypto = await this.cryptoService.encryptPhotoForAlbum( - photoNode.name, - contentSha1, - nodeKeys, - { key: this.albumKeys.key, hashKey: this.albumKeys.hashKey! }, - this.signingKeys, - ); - - // Node could be uploaded or renamed by anonymous user and thus have - // missing signatures that must be added to the request. - // Node passphrase and signature email must be passed if and only if - // the signatures are missing (key author is null). - const anonymousKey = photoNode.keyAuthor.ok && photoNode.keyAuthor.value === null; - const keySignatureProperties = !anonymousKey - ? {} - : { - signatureEmail: encryptedCrypto.signatureEmail, - nodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, - }; - - return { - nodeUid: photoNode.uid, - contentHash: encryptedCrypto.contentHash, - nameHash: encryptedCrypto.hash, - encryptedName: encryptedCrypto.encryptedName, - nameSignatureEmail: encryptedCrypto.nameSignatureEmail, - nodePassphrase: encryptedCrypto.armoredNodePassphrase, - ...keySignatureProperties, - }; - } - /** * If the result indicates a MissingRelatedPhotosError that hasn't * been retried, returns a retry queue item. Otherwise returns undefined. @@ -260,19 +160,14 @@ export class AddToAlbumProcess { * Returns undefined if the photo has already been retried, preventing * infinite retry loops. */ - private createRetryQueueItem( - photoNodeUid: string, - error: MissingRelatedPhotosError, - ): PhotoQueueItem | undefined { + private createRetryQueueItem(photoNodeUid: string, error: MissingRelatedPhotosError): PhotoQueueItem | undefined { if (this.retriedPhotoUids.has(photoNodeUid)) { this.logger.warn(`Missing related photos for ${photoNodeUid}, already retried`); return undefined; } this.retriedPhotoUids.add(photoNodeUid); - this.logger.info( - `Missing related photos for ${photoNodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`, - ); + this.logger.info(`Missing related photos for ${photoNodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`); return { photoNodeUid, @@ -316,10 +211,8 @@ function splitByVolume( * Groups payloads into batches respecting the API limit. * Each payload's size counts itself plus its related photos. */ -function* createBatches( - payloads: AddToAlbumEncryptedPhotoPayload[], -): Generator { - let batch: AddToAlbumEncryptedPhotoPayload[] = []; +function* createBatches(payloads: TransferEncryptedPhotoPayload[]): Generator { + let batch: TransferEncryptedPhotoPayload[] = []; let batchSize = 0; for (const payload of payloads) { diff --git a/js/sdk/src/internal/photos/albums.test.ts b/js/sdk/src/internal/photos/albumsManager.test.ts similarity index 98% rename from js/sdk/src/internal/photos/albums.test.ts rename to js/sdk/src/internal/photos/albumsManager.test.ts index 7c9aa6d9..bc9d6d1c 100644 --- a/js/sdk/src/internal/photos/albums.test.ts +++ b/js/sdk/src/internal/photos/albumsManager.test.ts @@ -1,7 +1,7 @@ import { NodeType } from '../../interface'; import { ValidationError } from '../../errors'; import { getMockTelemetry } from '../../tests/telemetry'; -import { Albums } from './albums'; +import { AlbumsManager } from './albumsManager'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; import { DecryptedPhotoNode } from './interface'; @@ -13,7 +13,7 @@ describe('Albums', () => { let cryptoService: AlbumsCryptoService; let photoShares: PhotoSharesManager; let nodesService: PhotosNodesAccess; - let albums: Albums; + let albums: AlbumsManager; let nodes: { [uid: string]: DecryptedPhotoNode }; @@ -97,7 +97,7 @@ describe('Albums', () => { notifyChildCreated: jest.fn(), }; - albums = new Albums(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService); + albums = new AlbumsManager(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService); }); describe('createAlbum', () => { diff --git a/js/sdk/src/internal/photos/albums.ts b/js/sdk/src/internal/photos/albumsManager.ts similarity index 99% rename from js/sdk/src/internal/photos/albums.ts rename to js/sdk/src/internal/photos/albumsManager.ts index 6b518b94..73470284 100644 --- a/js/sdk/src/internal/photos/albums.ts +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -16,7 +16,7 @@ const BATCH_LOADING_SIZE = 10; /** * Provides access and high-level actions for managing albums. */ -export class Albums { +export class AlbumsManager { private logger: Logger; constructor( diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index 2443156c..bd452094 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,13 +1,14 @@ import { c } from 'ttag'; import { ValidationError } from '../../errors'; -import { NodeResultWithError } from '../../interface'; +import { NodeResultWithError, PhotoTag } from '../../interface'; import { APICodeError, DriveAPIService, drivePaths, InvalidRequirementsAPIError, isCodeOk } from '../apiService'; import { batch } from '../batch'; import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; import { makeNodeUid, splitNodeUid } from '../uids'; import { MissingRelatedPhotosError } from './errors'; -import { AddToAlbumEncryptedPhotoPayload, AlbumItem } from './interface'; +import { AlbumItem } from './interface'; +import { TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; type GetPhotoShareResponse = drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json']; @@ -68,6 +69,19 @@ type PostRemovePhotosFromAlbumRequest = Extract< type PostRemovePhotosFromAlbumResponse = drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['responses']['200']['content']['application/json']; +type PostAddPhotoTagsRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/tags']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostRemovePhotoTagsRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/tags']['delete']['requestBody'], + { content: object } +>['content']['application/json']; +type PostFavoritePhotoRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/favorite']['post']['requestBody'], + { content: object } +>['content']['application/json']; + const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302; /** @@ -337,15 +351,12 @@ export class PhotosAPIService { */ async *addPhotosToAlbum( albumNodeUid: string, - photoPayloads: AddToAlbumEncryptedPhotoPayload[], + photoPayloads: TransferEncryptedPhotoPayload[], signal?: AbortSignal, ): AsyncGenerator { const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid); - const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [ - photoPayload, - ...(photoPayload.relatedPhotos || []), - ]); + const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [photoPayload, ...photoPayload.relatedPhotos]); const allPhotoData = allPhotoPayloads.map((photoPayload) => { const { nodeId } = splitNodeUid(photoPayload.nodeUid); return { @@ -416,7 +427,7 @@ export class PhotosAPIService { */ async copyPhotoToAlbum( albumNodeUid: string, - payload: AddToAlbumEncryptedPhotoPayload, + payload: TransferEncryptedPhotoPayload, signal?: AbortSignal, ): Promise { const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid); @@ -438,14 +449,13 @@ export class PhotosAPIService { SignatureEmail: payload.signatureEmail, Photos: { ContentHash: payload.contentHash, - RelatedPhotos: - payload.relatedPhotos?.map((related) => ({ - LinkID: splitNodeUid(related.nodeUid).nodeId, - Hash: related.nameHash, - Name: related.encryptedName, - NodePassphrase: related.nodePassphrase, - ContentHash: related.contentHash, - })) || [], + RelatedPhotos: payload.relatedPhotos.map((related) => ({ + LinkID: splitNodeUid(related.nodeUid).nodeId, + Hash: related.nameHash, + Name: related.encryptedName, + NodePassphrase: related.nodePassphrase, + ContentHash: related.contentHash, + })), }, }, signal, @@ -499,4 +509,51 @@ export class PhotosAPIService { } } } + + async addPhotoTags(nodeUid: string, tags: PhotoTag[]): Promise { + const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid); + await this.apiService.post( + `drive/photos/volumes/${volumeId}/links/${linkId}/tags`, + { Tags: tags }, + ); + } + + async removePhotoTags(nodeUid: string, tags: PhotoTag[]): Promise { + const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid); + await this.apiService.delete( + `drive/photos/volumes/${volumeId}/links/${linkId}/tags`, + { Tags: tags }, + ); + } + + async setPhotoFavorite(nodeUid: string, payload?: TransferEncryptedPhotoPayload): Promise { + const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid); + const requestBody = payload + ? { + PhotoData: { + Hash: payload.nameHash, + Name: payload.encryptedName, + NameSignatureEmail: payload.nameSignatureEmail, + NodePassphrase: payload.nodePassphrase, + ContentHash: payload.contentHash, + NodePassphraseSignature: payload.nodePassphraseSignature ?? null, + SignatureEmail: payload.signatureEmail ?? null, + RelatedPhotos: payload.relatedPhotos.map((related) => ({ + LinkID: splitNodeUid(related.nodeUid).nodeId, + Hash: related.nameHash, + Name: related.encryptedName, + NameSignatureEmail: related.nameSignatureEmail, + NodePassphrase: related.nodePassphrase, + ContentHash: related.contentHash, + NodePassphraseSignature: related.nodePassphraseSignature ?? null, + SignatureEmail: related.signatureEmail ?? null, + })), + }, + } + : undefined; + await this.apiService.post( + `drive/photos/volumes/${volumeId}/links/${linkId}/favorite`, + requestBody, + ); + } } diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 5be3c5ff..a999140d 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -18,13 +18,14 @@ import { SharesCryptoService } from '../shares/cryptoService'; import { NodesService as UploadNodesService } from '../upload/interface'; import { UploadTelemetry } from '../upload/telemetry'; import { UploadQueue } from '../upload/queue'; -import { Albums } from './albums'; +import { AlbumsManager } from './albumsManager'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; import { SharesService } from './interface'; import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes'; import { PhotoSharesManager } from './shares'; import { PhotosTimeline } from './timeline'; +import { PhotosManager } from './photosManager'; import { PhotoFileUploader, PhotoUploadAPIService, @@ -33,7 +34,7 @@ import { PhotoUploadMetadata, } from './upload'; -export type { DecryptedPhotoNode, TimelineItem, AlbumItem, PhotoTag } from './interface'; +export type { DecryptedPhotoNode, TimelineItem, AlbumItem } from './interface'; // Only photos and albums can be shared in photos volume. export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album]; @@ -60,11 +61,13 @@ export function initPhotosModule( photoShares, nodesService, ); - const albums = new Albums(telemetry, api, albumsCryptoService, photoShares, nodesService); + const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService); + const photos = new PhotosManager(telemetry.getLogger('photos-update'), api, albumsCryptoService, nodesService); return { timeline, albums, + photos, }; } diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 845b67c2..041bdd84 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -1,5 +1,5 @@ import { PrivateKey } from '../../crypto'; -import { MetricVolumeType, PhotoAttributes, AlbumAttributes } from '../../interface'; +import { MetricVolumeType, PhotoAttributes, AlbumAttributes, PhotoTag } from '../../interface'; import { DecryptedNode, EncryptedNode, DecryptedUnparsedNode } from '../nodes/interface'; import { EncryptedShare } from '../shares'; @@ -24,7 +24,7 @@ export interface SharesService { } export type EncryptedPhotoNode = EncryptedNode & { - photo?: EcnryptedPhotoAttributes; + photo?: EncryptedPhotoAttributes; album?: AlbumAttributes; }; @@ -38,7 +38,7 @@ export type DecryptedPhotoNode = DecryptedNode & { album?: AlbumAttributes; }; -export type EcnryptedPhotoAttributes = Omit & { +export type EncryptedPhotoAttributes = Omit & { contentHash?: string; albums: (PhotoAttributes['albums'][0] & { nameHash?: string; @@ -56,28 +56,3 @@ export type AlbumItem = { nodeUid: string; captureTime: Date; }; - -export enum PhotoTag { - Favorites = 0, - Screenshots = 1, - Videos = 2, - LivePhotos = 3, - MotionPhotos = 4, - Selfies = 5, - Portraits = 6, - Bursts = 7, - Panoramas = 8, - Raw = 9, -} - -export type AddToAlbumEncryptedPhotoPayload = { - nodeUid: string; - contentHash: string; - nameHash: string; - encryptedName: string; - nameSignatureEmail: string; - nodePassphrase: string; - nodePassphraseSignature?: string; - signatureEmail?: string; - relatedPhotos?: Omit[]; -}; diff --git a/js/sdk/src/internal/photos/photosManager.test.ts b/js/sdk/src/internal/photos/photosManager.test.ts new file mode 100644 index 00000000..fbaf430f --- /dev/null +++ b/js/sdk/src/internal/photos/photosManager.test.ts @@ -0,0 +1,266 @@ +import { resultOk, PhotoTag } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { PhotosManager, UpdatePhotoSettings } from './photosManager'; +import { PhotosAPIService } from './apiService'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { PhotosNodesAccess } from './nodes'; +import { DecryptedPhotoNode } from './interface'; + +function createMockPhotoNode(uid: string, overrides: Partial = {}): DecryptedPhotoNode { + return { + uid, + parentUid: 'volume1~parent', + hash: 'hash', + name: resultOk('photo.jpg'), + photo: { + captureTime: new Date(), + mainPhotoNodeUid: undefined, + relatedPhotoNodeUids: [], + tags: [], + albums: [], + }, + activeRevision: { + ok: true, + value: { + uid: 'rev1', + state: 'active' as const, + creationTime: new Date(), + storageSize: 100, + signatureEmail: 'test@example.com', + claimedModificationTime: new Date(), + claimedSize: 100, + claimedDigests: { sha1: 'sha1hash' }, + claimedBlockSizes: [100], + }, + }, + keyAuthor: { ok: true, value: 'test@example.com' }, + ...overrides, + } as DecryptedPhotoNode; +} + +async function collectUpdateResults(manager: PhotosManager, photos: UpdatePhotoSettings[], signal?: AbortSignal) { + const results = []; + for await (const result of manager.updatePhotos(photos, signal)) { + results.push(result); + } + return results; +} + +describe('PhotosManager', () => { + let logger: ReturnType; + let apiService: jest.Mocked>; + let cryptoService: jest.Mocked>; + let nodesService: jest.Mocked< + Pick< + PhotosNodesAccess, + | 'getVolumeRootFolder' + | 'getNodeKeys' + | 'getNodeSigningKeys' + | 'iterateNodes' + | 'getNodePrivateAndSessionKeys' + | 'notifyNodeChanged' + > + >; + let manager: PhotosManager; + + const volumeRootKeys = { + key: 'rootKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + const signingKeys = { + type: 'userAddress' as const, + email: 'test@example.com', + addressId: 'addressId', + key: 'signingKey' as any, + }; + beforeEach(() => { + logger = getMockLogger(); + + apiService = { + addPhotoTags: jest.fn().mockResolvedValue(undefined), + removePhotoTags: jest.fn().mockResolvedValue(undefined), + setPhotoFavorite: jest.fn().mockResolvedValue(undefined), + }; + + cryptoService = { + encryptPhotoForAlbum: jest.fn().mockResolvedValue({ + contentHash: 'contentHash', + hash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + armoredNodePassphrase: 'passphrase', + armoredNodePassphraseSignature: 'signature', + signatureEmail: 'test@example.com', + }), + }; + + nodesService = { + getVolumeRootFolder: jest.fn().mockResolvedValue({ uid: 'volume1~root' }), + getNodeKeys: jest.fn().mockResolvedValue(volumeRootKeys), + getNodeSigningKeys: jest.fn().mockResolvedValue(signingKeys), + iterateNodes: jest.fn().mockImplementation(async function* (uids: string[]) { + for (const uid of uids) { + yield createMockPhotoNode(uid); + } + }), + getNodePrivateAndSessionKeys: jest.fn().mockResolvedValue({ + key: 'nodeKey' as any, + nameSessionKey: 'sessionKey' as any, + passphrase: 'passphrase', + passphraseSessionKey: 'passphraseSessionKey' as any, + }), + notifyNodeChanged: jest.fn().mockResolvedValue(undefined), + }; + + manager = new PhotosManager(logger, apiService as any, cryptoService as any, nodesService as any); + }); + + describe('updatePhotos', () => { + describe('add tags only', () => { + it('calls addPhotoTags and notifyNodeChanged for each photo', async () => { + const results = await collectUpdateResults(manager, [ + { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Screenshots], tagsToRemove: [] }, + { nodeUid: 'volume1~photo2', tagsToAdd: [PhotoTag.LivePhotos], tagsToRemove: [] }, + ]); + + expect(results).toEqual([ + { uid: 'volume1~photo1', ok: true }, + { uid: 'volume1~photo2', ok: true }, + ]); + expect(apiService.addPhotoTags).toHaveBeenCalledTimes(2); + expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]); + expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo2', [PhotoTag.LivePhotos]); + expect(nodesService.getVolumeRootFolder).not.toHaveBeenCalled(); + expect(apiService.setPhotoFavorite).not.toHaveBeenCalled(); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1'); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo2'); + }); + + it('filters Favorites from addTags and calls setPhotoFavorite with payload', async () => { + const results = await collectUpdateResults(manager, [ + { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] }, + ]); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]); + expect(nodesService.getVolumeRootFolder).toHaveBeenCalled(); + expect(nodesService.getNodeKeys).toHaveBeenCalledWith('volume1~root'); + expect(nodesService.getNodeSigningKeys).toHaveBeenCalledWith({ nodeUid: 'volume1~root' }); + expect(apiService.setPhotoFavorite).toHaveBeenCalledTimes(1); + expect(apiService.setPhotoFavorite).toHaveBeenCalledWith( + 'volume1~photo1', + expect.objectContaining({ + nodeUid: 'volume1~photo1', + contentHash: 'contentHash', + nameHash: 'nameHash', + relatedPhotos: [], + }), + ); + expect(apiService.addPhotoTags).not.toHaveBeenCalled(); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1'); + }); + + it('calls setPhotoFavorite and addPhotoTags when addTags includes Favorites and other tags', async () => { + const results = await collectUpdateResults(manager, [ + { + nodeUid: 'volume1~photo1', + tagsToAdd: [PhotoTag.Favorites, PhotoTag.Screenshots], + tagsToRemove: [], + }, + ]); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]); + expect(apiService.setPhotoFavorite).toHaveBeenCalledWith('volume1~photo1', expect.any(Object)); + expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]); + }); + + it('calls setPhotoFavorite when payload builder returns PhotoAlreadyInTargetError (photo already in root)', async () => { + nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) { + for (const uid of uids) { + yield createMockPhotoNode(uid, { parentUid: 'volume1~root' }); + } + }); + + const results = await collectUpdateResults(manager, [ + { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] }, + ]); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]); + expect(apiService.setPhotoFavorite).toHaveBeenCalledWith('volume1~photo1', undefined); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1'); + }); + }); + + describe('remove tags only', () => { + it('calls removePhotoTags and notifyNodeChanged for each photo', async () => { + const results = await collectUpdateResults(manager, [ + { nodeUid: 'volume1~photo1', tagsToAdd: [], tagsToRemove: [PhotoTag.Screenshots] }, + ]); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]); + expect(apiService.removePhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]); + expect(apiService.addPhotoTags).not.toHaveBeenCalled(); + expect(nodesService.getVolumeRootFolder).not.toHaveBeenCalled(); + expect(apiService.setPhotoFavorite).not.toHaveBeenCalled(); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1'); + }); + }); + + describe('add and remove tags together', () => { + it('calls addPhotoTags and removePhotoTags and notifyNodeChanged', async () => { + const results = await collectUpdateResults(manager, [ + { + nodeUid: 'volume1~photo1', + tagsToAdd: [PhotoTag.Panoramas], + tagsToRemove: [PhotoTag.Screenshots], + }, + ]); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]); + expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Panoramas]); + expect(apiService.removePhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]); + expect(apiService.setPhotoFavorite).not.toHaveBeenCalled(); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1'); + }); + }); + + describe('API failures', () => { + it('yields error result and logs when setPhotoFavorite fails', async () => { + const apiError = new Error('Favorite API failed'); + apiService.setPhotoFavorite.mockRejectedValue(apiError); + + const results = await collectUpdateResults(manager, [ + { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] }, + ]); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]); + expect(logger.error).toHaveBeenCalledWith('Update photos failed for volume1~photo1', apiError); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); + }); + + it('yields error result when addPhotoTags fails', async () => { + const apiError = new Error('Add tags failed'); + apiService.addPhotoTags.mockRejectedValue(apiError); + + const results = await collectUpdateResults(manager, [ + { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Screenshots], tagsToRemove: [] }, + ]); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); + }); + + it('yields error result when removePhotoTags fails', async () => { + const apiError = new Error('Remove tags failed'); + apiService.removePhotoTags.mockRejectedValue(apiError); + + const results = await collectUpdateResults(manager, [ + { nodeUid: 'volume1~photo1', tagsToAdd: [], tagsToRemove: [PhotoTag.Videos] }, + ]); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/js/sdk/src/internal/photos/photosManager.ts b/js/sdk/src/internal/photos/photosManager.ts new file mode 100644 index 00000000..492d4911 --- /dev/null +++ b/js/sdk/src/internal/photos/photosManager.ts @@ -0,0 +1,144 @@ +import { c } from 'ttag'; + +import { Logger, NodeResultWithError, PhotoTag } from '../../interface'; +import { PhotosAPIService } from './apiService'; +import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; +import { PhotosNodesAccess } from './nodes'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { AbortError } from '../../errors'; +import { BATCH_LOADING_SIZE } from '../sharing/sharingAccess'; +import { batch } from '../batch'; + +export type UpdatePhotoSettings = { + nodeUid: string; + tagsToAdd: PhotoTag[]; + tagsToRemove: PhotoTag[]; +}; + +/** + * Manages updating photos: adding/removing tags and favoriting. + * Uses the same encrypted payload as add-to-album/copy for the favorite endpoint. + */ +export class PhotosManager { + private readonly payloadBuilder: PhotoTransferPayloadBuilder; + + constructor( + private readonly logger: Logger, + private readonly apiService: PhotosAPIService, + albumsCryptoService: AlbumsCryptoService, + private readonly nodesService: PhotosNodesAccess, + ) { + this.payloadBuilder = new PhotoTransferPayloadBuilder(albumsCryptoService, nodesService); + } + + async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator { + for await (const { + photoSettings: { nodeUid, tagsToAdd, tagsToRemove }, + payloadForFavorite, + error, + } of this.iterateNodeUidsWithFavoritePayloads(photos, signal)) { + if (signal?.aborted) { + throw new AbortError(); + } + + if (error) { + yield { uid: nodeUid, ok: false, error }; + continue; + } + + try { + if (tagsToAdd.includes(PhotoTag.Favorites)) { + await this.apiService.setPhotoFavorite(nodeUid, payloadForFavorite); + } + const addTags = tagsToAdd.filter((tag) => tag !== PhotoTag.Favorites); + if (addTags.length) { + await this.apiService.addPhotoTags(nodeUid, addTags); + } + if (tagsToRemove.length) { + await this.apiService.removePhotoTags(nodeUid, tagsToRemove); + } + + await this.nodesService.notifyNodeChanged(nodeUid); + yield { uid: nodeUid, ok: true }; + } catch (error) { + this.logger.error(`Update photos failed for ${nodeUid}`, error); + yield { + uid: nodeUid, + ok: false, + error: error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }), + }; + } + } + } + + private async *iterateNodeUidsWithFavoritePayloads( + photosSettings: UpdatePhotoSettings[], + signal?: AbortSignal, + ): AsyncGenerator<{ + photoSettings: UpdatePhotoSettings; + payloadForFavorite?: TransferEncryptedPhotoPayload; + error?: Error; + }> { + const photosSettingsWithoutFavorite = photosSettings.filter( + (photoSettings) => !photoSettings.tagsToAdd?.includes(PhotoTag.Favorites), + ); + const photosSettingsWithFavorite = photosSettings.filter((photoSettings) => + photoSettings.tagsToAdd?.includes(PhotoTag.Favorites), + ); + + for (const photoSettings of photosSettingsWithoutFavorite) { + yield { photoSettings }; + } + + if (!photosSettingsWithFavorite.length) { + return; + } + + const rootNode = await this.nodesService.getVolumeRootFolder(); + const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid); + const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid }); + + // Batch iteration to fetch metadata for preparing the payloads in parallel. + for (const photoSettingsBatch of batch(photosSettingsWithFavorite, BATCH_LOADING_SIZE)) { + if (signal?.aborted) { + throw new AbortError(); + } + + const result = await this.payloadBuilder.preparePhotoPayloads( + photoSettingsBatch.map(({ nodeUid }) => ({ photoNodeUid: nodeUid })), + rootNode.uid, + volumeRootKeys, + signingKeys, + signal, + ); + + for (const [nodeUid, error] of result.errors) { + const photoSettings = photosSettingsWithFavorite.find( + (photoSettings) => photoSettings.nodeUid === nodeUid, + ); + if (!photoSettings) { + this.logger.error(`Photo settings not found for ${nodeUid}, unexpected error`); + continue; + } + + // If the photo is already in the root node, we only set the favorite tag. + if (error instanceof PhotoAlreadyInTargetError) { + yield { photoSettings }; + continue; + } + yield { photoSettings, error }; + } + + for (const payloadForFavorite of result.payloads) { + const photoSettings = photosSettingsWithFavorite.find( + (photoSettings) => photoSettings.nodeUid === payloadForFavorite.nodeUid, + ); + if (!photoSettings) { + this.logger.error(`Photo settings not found for ${payloadForFavorite.nodeUid}, unexpected payload`); + continue; + } + yield { photoSettings, payloadForFavorite }; + } + } + } +} diff --git a/js/sdk/src/internal/photos/photosTransferPayloadBuilder.test.ts b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.test.ts new file mode 100644 index 00000000..951debc1 --- /dev/null +++ b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.test.ts @@ -0,0 +1,380 @@ +import { ValidationError } from '../../errors'; +import { resultOk } from '../../interface'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { DecryptedPhotoNode } from './interface'; +import { PhotoTransferPayloadBuilder } from './photosTransferPayloadBuilder'; +import { PhotosNodesAccess } from './nodes'; + +/** + * Helper to create a mock photo node with minimal required properties. + */ +function createMockPhotoNode( + uid: string, + overrides: Partial = {}, +): DecryptedPhotoNode { + return { + uid, + parentUid: 'volume1~parent', + hash: 'hash', + name: resultOk('photo.jpg'), + photo: { + captureTime: new Date(), + mainPhotoNodeUid: undefined, + relatedPhotoNodeUids: [], + tags: [], + albums: [], + }, + activeRevision: { + ok: true, + value: { + uid: 'rev1', + state: 'active' as const, + creationTime: new Date(), + storageSize: 100, + signatureEmail: 'test@example.com', + claimedModificationTime: new Date(), + claimedSize: 100, + claimedDigests: { sha1: 'sha1hash' }, + claimedBlockSizes: [100], + }, + }, + keyAuthor: { ok: true, value: 'test@example.com' }, + ...overrides, + } as DecryptedPhotoNode; +} + +describe('PhotoTransferPayloadBuilder', () => { + let cryptoService: jest.Mocked; + let nodesService: jest.Mocked; + let targetKeys: { key: unknown; hashKey: Uint8Array }; + let signingKeys: { type: 'userAddress'; email: string; addressId: string; key: unknown }; + let builder: PhotoTransferPayloadBuilder; + + beforeEach(() => { + targetKeys = { + key: 'targetKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + + signingKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'signingKey' as any, + }; + + // @ts-expect-error Mocking for testing purposes + cryptoService = { + encryptPhotoForAlbum: jest.fn(), + }; + + // @ts-expect-error Mocking for testing purposes + nodesService = { + iterateNodes: jest.fn(), + getNodePrivateAndSessionKeys: jest.fn(), + }; + + builder = new PhotoTransferPayloadBuilder(cryptoService, nodesService); + }); + + describe('preparePhotoPayloads', () => { + beforeEach(() => { + nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) { + for (const uid of uids) { + if (uid === 'volume1~missing') { + yield { missingUid: uid }; + continue; + } + + const photoNode = createMockPhotoNode(uid); + + // Handle uids in the form 'volumeId~mainPhoto-related:N' where N is the number of related photos + const relatedMatch = /^(.+)~(.+)-related:(\d+)$/.exec(uid); + if (relatedMatch) { + const [, volumeId, , countStr] = relatedMatch; + const count = parseInt(countStr, 10); + photoNode.photo!.relatedPhotoNodeUids = Array.from( + { length: count }, + (_, idx) => `${volumeId}~related${idx + 1}`, + ); + } + + yield photoNode; + } + }); + + nodesService.getNodePrivateAndSessionKeys.mockResolvedValue({ + key: 'nodeKey' as any, + nameSessionKey: 'sessionKey' as any, + passphrase: 'passphrase', + passphraseSessionKey: 'passphraseSessionKey' as any, + }); + + cryptoService.encryptPhotoForAlbum.mockResolvedValue({ + contentHash: 'contentHash', + hash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + armoredNodePassphrase: 'passphrase', + armoredNodePassphraseSignature: 'signature', + signatureEmail: 'test@example.com', + }); + }); + + it('should return payloads and empty errors for a single photo without related photos', async () => { + const items = [{ photoNodeUid: 'volume1~photo1' }]; + + const result = await builder.preparePhotoPayloads( + items, + 'volume1~root', + targetKeys as any, + signingKeys as any, + ); + + expect(result).toMatchObject({ + payloads: [{ + nodeUid: 'volume1~photo1', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + relatedPhotos: [], + }], + errors: new Map(), + }); + expect(nodesService.iterateNodes).toHaveBeenCalledWith(['volume1~photo1'], undefined); + expect(nodesService.getNodePrivateAndSessionKeys).toHaveBeenCalledWith('volume1~photo1'); + expect(cryptoService.encryptPhotoForAlbum).toHaveBeenCalledTimes(1); + }); + + it('should include related photos in payload when photo has relatedPhotoNodeUids', async () => { + const items = [{ photoNodeUid: 'volume1~mainPhoto-related:3' }]; + + const result = await builder.preparePhotoPayloads( + items, + 'volume1~root', + targetKeys as any, + signingKeys as any, + ); + + expect(result).toMatchObject({ + payloads: [{ + nodeUid: 'volume1~mainPhoto-related:3', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + relatedPhotos: [{ + nodeUid: 'volume1~related1', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + }, { + nodeUid: 'volume1~related2', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + }, { + nodeUid: 'volume1~related3', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + }], + }], + errors: new Map(), + }); + expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); + expect(nodesService.iterateNodes).toHaveBeenNthCalledWith(1, ['volume1~mainPhoto-related:3'], undefined); + expect(nodesService.iterateNodes).toHaveBeenNthCalledWith( + 2, + ['volume1~related1', 'volume1~related2', 'volume1~related3'], + undefined, + ); + expect(cryptoService.encryptPhotoForAlbum).toHaveBeenCalledTimes(4); + }); + + it('should merge additionalRelatedPhotoNodeUids with photo relatedPhotoNodeUids', async () => { + const items = [ + { + photoNodeUid: 'volume1~photo1', + additionalRelatedPhotoNodeUids: ['volume1~extraRelated1'], + }, + ]; + + const result = await builder.preparePhotoPayloads( + items, + 'volume1~root', + targetKeys as any, + signingKeys as any, + ); + + expect(result).toMatchObject({ + payloads: [{ + nodeUid: 'volume1~photo1', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + relatedPhotos: [{ + nodeUid: 'volume1~extraRelated1', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + }], + }], + errors: new Map(), + }); + }); + + it('should put missing node UIDs in errors with ValidationError', async () => { + const items = [ + { photoNodeUid: 'volume1~photo1' }, + { photoNodeUid: 'volume1~missing' }, + ]; + + const result = await builder.preparePhotoPayloads( + items, + 'volume1~root', + targetKeys as any, + signingKeys as any, + ); + + expect(result).toMatchObject({ + payloads: [{ + nodeUid: 'volume1~photo1', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + relatedPhotos: [], + }], + errors: new Map([['volume1~missing', new ValidationError('Photo not found')]]), + }); + }); + + it('should throw when targetKeys.hashKey is missing', async () => { + const items = [{ photoNodeUid: 'volume1~photo1' }]; + const keysWithoutHashKey = { ...targetKeys, hashKey: undefined }; + + await expect( + builder.preparePhotoPayloads(items, 'volume1~root', keysWithoutHashKey as any, signingKeys as any), + ).rejects.toThrow('Target hash key is required to build photo payloads'); + + expect(nodesService.iterateNodes).not.toHaveBeenCalled(); + }); + + it('should put error in errors map when encryptPhotoForAlbum fails', async () => { + const items = [{ photoNodeUid: 'volume1~photo1' }]; + const cryptoError = new Error('Crypto operation failed'); + cryptoService.encryptPhotoForAlbum.mockRejectedValue(cryptoError); + + const result = await builder.preparePhotoPayloads( + items, + 'volume1~root', + targetKeys as any, + signingKeys as any, + ); + + expect(result).toMatchObject({ + payloads: [], + errors: new Map([['volume1~photo1', cryptoError]]), + }); + }); + + it('should put error in errors map when getNodePrivateAndSessionKeys fails', async () => { + const items = [{ photoNodeUid: 'volume1~photo1' }]; + const keysError = new Error('Failed to get keys'); + nodesService.getNodePrivateAndSessionKeys.mockRejectedValue(keysError); + + const result = await builder.preparePhotoPayloads( + items, + 'volume1~root', + targetKeys as any, + signingKeys as any, + ); + + expect(result).toMatchObject({ + payloads: [], + errors: new Map([['volume1~photo1', keysError]]), + }); + }); + + it('should put error in errors map when photo has no content hash', async () => { + const items = [{ photoNodeUid: 'volume1~photo1' }]; + nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) { + const node = createMockPhotoNode(uids[0]); + node.activeRevision = { ok: true, value: { ...(node.activeRevision as any).value, claimedDigests: {} } } as any; + yield node; + }); + + const result = await builder.preparePhotoPayloads( + items, + 'volume1~root', + targetKeys as any, + signingKeys as any, + ); + + expect(result).toMatchObject({ + payloads: [], + errors: new Map([['volume1~photo1', new Error('Cannot build photo payload without a content hash')]]), + }); + }); + + it('should include signatureEmail and nodePassphraseSignature only for anonymous key author', async () => { + const items = [{ photoNodeUid: 'volume1~anonymous' }, { photoNodeUid: 'volume1~signed' }]; + nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) { + for (const uid of uids) { + const node = createMockPhotoNode(uid); + if (uid === 'volume1~anonymous') { + node.keyAuthor = { ok: true, value: null }; + } else { + node.keyAuthor = { ok: true, value: 'test@example.com' }; + } + yield node; + } + }); + + const result = await builder.preparePhotoPayloads( + items, + 'volume1~root', + targetKeys as any, + signingKeys as any, + ); + + expect(result).toMatchObject({ + payloads: [{ + nodeUid: 'volume1~anonymous', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + signatureEmail: 'test@example.com', + nodePassphraseSignature: 'signature', + relatedPhotos: [], + }, { + nodeUid: 'volume1~signed', + contentHash: 'contentHash', + nameHash: 'nameHash', + encryptedName: 'encryptedName', + nameSignatureEmail: 'test@example.com', + nodePassphrase: 'passphrase', + relatedPhotos: [], + }], + errors: new Map(), + }); + }); + }); +}); diff --git a/js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts new file mode 100644 index 00000000..26a2b7d6 --- /dev/null +++ b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts @@ -0,0 +1,203 @@ +import { c } from 'ttag'; + +import { ValidationError } from '../../errors'; +import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { DecryptedPhotoNode } from './interface'; +import { PhotosNodesAccess } from './nodes'; + +export type TransferEncryptedPhotoPayload = TransferEncryptedRelatedPhotoPayload & { + relatedPhotos: TransferEncryptedRelatedPhotoPayload[]; +}; + +type TransferEncryptedRelatedPhotoPayload = { + nodeUid: string; + contentHash: string; + nameHash: string; + encryptedName: string; + nameSignatureEmail: string; + nodePassphrase: string; + nodePassphraseSignature?: string; + signatureEmail?: string; +} + +/** + * Item representing a photo to build a payload for. + * Used when preparing payloads for add-to-album (with optional retry related UIDs) + * or for favoriting. + */ +export type PhotoPayloadItem = { + photoNodeUid: string; + /** + * Additional related photo node UIDs to include (e.g. when retrying after + * MissingRelatedPhotosError). + */ + additionalRelatedPhotoNodeUids?: string[]; +}; + +/** + * Builds encrypted photo payloads (TransferEncryptedPhotoPayload) for a set of + * photos, including their related photos. Reused by add-to-album and favorite + * flows, which only differ by the target keys used for encryption. + */ +export class PhotoTransferPayloadBuilder { + constructor( + private readonly cryptoService: AlbumsCryptoService, + private readonly nodesService: PhotosNodesAccess, + ) {} + + /** + * Prepares encrypted payloads for the given photo items using the provided + * target keys and signing keys. Used for add-to-album (album keys) and + * favoriting (volume root keys). + */ + async preparePhotoPayloads( + items: PhotoPayloadItem[], + targetNodeUid: string, + targetKeys: DecryptedNodeKeys, + signingKeys: NodeSigningKeys, + signal?: AbortSignal, + ): Promise<{ + payloads: TransferEncryptedPhotoPayload[]; + errors: Map; + }> { + const payloads: TransferEncryptedPhotoPayload[] = []; + const errors = new Map(); + + if (!targetKeys.hashKey) { + throw new Error('Target hash key is required to build photo payloads'); + } + + const additionalRelatedMap = new Map( + items.map((item) => [item.photoNodeUid, item.additionalRelatedPhotoNodeUids || []]), + ); + + const nodeUids = items.map((item) => item.photoNodeUid); + for await (const photoNode of this.nodesService.iterateNodes(nodeUids, signal)) { + if ('missingUid' in photoNode) { + errors.set(photoNode.missingUid, new ValidationError(c('Error').t`Photo not found`)); + continue; + } + + if (photoNode.parentUid === targetNodeUid) { + errors.set(photoNode.uid, new PhotoAlreadyInTargetError()); + continue; + } + + try { + const additionalRelated = additionalRelatedMap.get(photoNode.uid) || []; + const payload = await this.preparePhotoPayload( + photoNode, + additionalRelated, + targetKeys, + signingKeys, + signal, + ); + payloads.push(payload); + } catch (error) { + errors.set( + photoNode.uid, + error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }), + ); + } + } + + return { payloads, errors }; + } + + private async preparePhotoPayload( + photoNode: DecryptedPhotoNode, + additionalRelatedPhotoNodeUids: string[], + targetKeys: DecryptedNodeKeys, + signingKeys: NodeSigningKeys, + signal?: AbortSignal, + ): Promise { + const photoData = await this.encryptPhotoForTarget(photoNode, targetKeys, signingKeys); + + const relatedNodeUids = [ + ...new Set([ + ...(photoNode.photo?.relatedPhotoNodeUids || []), + ...additionalRelatedPhotoNodeUids, + ]), + ]; + + const relatedPhotos = + relatedNodeUids.length > 0 + ? await this.prepareRelatedPhotoPayloads(relatedNodeUids, targetKeys, signingKeys, signal) + : []; + + return { + ...photoData, + relatedPhotos, + }; + } + + private async prepareRelatedPhotoPayloads( + nodeUids: string[], + targetKeys: DecryptedNodeKeys, + signingKeys: NodeSigningKeys, + signal?: AbortSignal, + ): Promise[]> { + const payloads: Omit[] = []; + + for await (const photoNode of this.nodesService.iterateNodes(nodeUids, signal)) { + if ('missingUid' in photoNode) { + continue; + } + const payload = await this.encryptPhotoForTarget(photoNode, targetKeys, signingKeys); + payloads.push(payload); + } + + return payloads; + } + + private async encryptPhotoForTarget( + photoNode: DecryptedPhotoNode, + targetKeys: DecryptedNodeKeys, + signingKeys: NodeSigningKeys, + ): Promise> { + const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(photoNode.uid); + + const contentSha1 = photoNode.activeRevision?.ok + ? photoNode.activeRevision.value.claimedDigests?.sha1 + : undefined; + + if (!contentSha1) { + throw new Error('Cannot build photo payload without a content hash'); + } + + const encryptedCrypto = await this.cryptoService.encryptPhotoForAlbum( + photoNode.name, + contentSha1, + nodeKeys, + { key: targetKeys.key, hashKey: targetKeys.hashKey! }, + signingKeys, + ); + + const anonymousKey = photoNode.keyAuthor.ok && photoNode.keyAuthor.value === null; + const keySignatureProperties = !anonymousKey + ? {} + : { + signatureEmail: encryptedCrypto.signatureEmail, + nodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + }; + + return { + nodeUid: photoNode.uid, + contentHash: encryptedCrypto.contentHash, + nameHash: encryptedCrypto.hash, + encryptedName: encryptedCrypto.encryptedName, + nameSignatureEmail: encryptedCrypto.nameSignatureEmail, + nodePassphrase: encryptedCrypto.armoredNodePassphrase, + ...keySignatureProperties, + }; + } +} + +export class PhotoAlreadyInTargetError extends ValidationError { + name = 'PhotoAlreadyInTargetError'; + + constructor() { + super(c('Error').t`Photo is already in the target album`); + } +} diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 96c8ef3a..6b78a4be 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -18,6 +18,7 @@ import { ProtonInvitationWithNode, NodeResult, NodeResultWithError, + PhotoTag, } from './interface'; import { getConfig } from './config'; import { DriveCrypto } from './crypto'; @@ -41,7 +42,6 @@ import { initPhotosNodesModule, AlbumItem, TimelineItem, - PhotoTag, } from './internal/photos'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; @@ -636,4 +636,36 @@ export class ProtonDrivePhotosClient { this.logger.info(`Removing ${photoNodeUids.length} photos from album ${getUid(albumNodeUid)}`); yield* this.photos.albums.removePhotos(getUid(albumNodeUid), getUids(photoNodeUids), signal); } + + /** + * Updates photos with the given settings: add or remove tags. + * + * Assigning a favorite tag to a photo that is not in the timeline will + * result in a move operation to the timeline. The photo will stay in + * the album. + * + * @param nodeUids - The UIDs of the photos to update. + * @param settings - addTags: tags to add, removeTags: tags to remove. + * @param signal - An optional abort signal to cancel the operation. + * @returns An async generator of per-photo results. + */ + async *updatePhotos( + photos: { + nodeUid: NodeOrUid; + tagsToAdd?: PhotoTag[]; + tagsToRemove?: PhotoTag[]; + }[], + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Updating ${photos.length} photos`); + yield * + this.photos.photos.updatePhotos( + photos.map((p) => ({ + nodeUid: getUid(p.nodeUid), + tagsToAdd: p.tagsToAdd || [], + tagsToRemove: p.tagsToRemove || [], + })), + signal, + ); + } } From 2212c10b7bdebfaca8c403c5e85d1e79c87a1181 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Feb 2026 12:58:21 +0000 Subject: [PATCH 561/791] Categorize upload integrity exception properly --- .../src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index 92b942b1..e47845b6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -1,6 +1,7 @@ using System.Net; using System.Security.Cryptography; using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Upload; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk; @@ -43,7 +44,7 @@ internal static class TelemetryErrorResolver return exception switch { // Upload errors - NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => UploadError.IntegrityError, + IntegrityException => UploadError.IntegrityError, #pragma warning disable RCS0056 // Line too long HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => UploadError.NetworkError, From b49dac73964e8db51821bbeab5015fcded9e6341 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Feb 2026 06:55:29 +0000 Subject: [PATCH 562/791] Update changelog for js/v0.11.0 --- js/CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index f0fc0bcf..7fa4f31c 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## js/v0.11.0 (2026-02-26) + +* Add method to update photo tags +* Ignore performance metrics in diagnostics tool +* Stop reporting progress after failed upload +* Add crypto performance metrics +* Add node context to error about missing parent key +* Do not block upload block reuqest by computing digest + ## js/v0.10.0 (2026-02-19) * Add option for editors to manage share settings From edbfd58c16cb444c1524a9f405ef72b788ce2e43 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Feb 2026 10:02:05 +0000 Subject: [PATCH 563/791] Support AEAD block encryption --- .gitignore | 8 +-- js/sdk/src/crypto/driveCrypto.test.ts | 1 + js/sdk/src/crypto/driveCrypto.ts | 61 +++++++++++++++--- js/sdk/src/crypto/interface.ts | 19 ++++-- js/sdk/src/crypto/openPGPCrypto.ts | 70 ++++++++++++++++++--- js/sdk/src/interface/featureFlags.ts | 6 +- js/sdk/src/interface/index.ts | 1 + js/sdk/src/internal/photos/index.ts | 4 +- js/sdk/src/internal/photos/upload.ts | 20 ++++-- js/sdk/src/internal/upload/cryptoService.ts | 26 +++++++- js/sdk/src/internal/upload/index.ts | 5 +- js/sdk/src/protonDriveClient.ts | 1 + js/sdk/src/protonDrivePhotosClient.ts | 6 ++ js/sdk/src/protonDrivePublicLinkClient.ts | 8 +++ 14 files changed, 196 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 21989ab3..55fa8a87 100644 --- a/.gitignore +++ b/.gitignore @@ -19,16 +19,14 @@ auth.txt cache*.sqlite *.log *.bun-build +config.json -# VS Code +# IDEs .vs .vscode - -# Intellij .idea +*.swp # Tests tests/storage - -# Test reporter tests/test-results diff --git a/js/sdk/src/crypto/driveCrypto.test.ts b/js/sdk/src/crypto/driveCrypto.test.ts index b54587c4..2e09e44f 100644 --- a/js/sdk/src/crypto/driveCrypto.test.ts +++ b/js/sdk/src/crypto/driveCrypto.test.ts @@ -68,6 +68,7 @@ describe('DriveCrypto.encryptShareUrlPassword', () => { undefined, [encryptionKey], signingKey, + { enableAeadWithEncryptionKeys: false }, ); }); }); diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 0434d9cc..bca05c90 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -27,6 +27,19 @@ enum SIGNING_CONTEXTS { * but we do share same key generation across shares and nodes modules, * for example, which we can generelise here and in each module just * call with specific arguments. + * + * Note about AEAD encryption: + * + * The algorithm of generated session key or encrypted data is defined by + * the encryption key preferences. If encryption key was generated with + * `aeadProtect` set to true, session key or encrypted data should use + * AEAD algorithm. + * + * However, in Drive, we do not want to use the AEAD algorithm everywhere, + * only for file content. Thus, we must pass the `enableAeadWithEncryptionKeys` + * flag explicitely to control whether to use the encryption key preferences + * to avoid using AEAD on places where it would not be supported. It should + * be set to false by default everywhere except for content encryption. */ export class DriveCrypto { constructor( @@ -55,6 +68,7 @@ export class DriveCrypto { async generateKey( encryptionKeys: PrivateKey[], signingKey: PrivateKey, + { enableAead }: { enableAead: boolean } = { enableAead: false }, ): Promise<{ encrypted: { armoredKey: string; @@ -69,8 +83,9 @@ export class DriveCrypto { }> { const passphrase = this.openPGPCrypto.generatePassphrase(); const [{ privateKey, armoredKey }, passphraseSessionKey] = await Promise.all([ - this.openPGPCrypto.generateKey(passphrase), - this.openPGPCrypto.generateSessionKey(encryptionKeys), + this.openPGPCrypto.generateKey(passphrase, { enableAead }), + // See note in the interface documentation about AEAD encryption. + this.openPGPCrypto.generateSessionKey(encryptionKeys, { enableAeadWithEncryptionKeys: false }), ]); const { armoredPassphrase, armoredPassphraseSignature } = await this.encryptPassphrase( @@ -109,7 +124,10 @@ export class DriveCrypto { contentKeyPacketSessionKey: SessionKey; }; }> { - const contentKeyPacketSessionKey = await this.openPGPCrypto.generateSessionKey([encryptionKey]); + // See note in the interface documentation about AEAD encryption. + const contentKeyPacketSessionKey = await this.openPGPCrypto.generateSessionKey([encryptionKey], { + enableAeadWithEncryptionKeys: true, + }); const { signature: armoredContentKeyPacketSignature } = await this.openPGPCrypto.signArmored( contentKeyPacketSessionKey.data, [encryptionKey], @@ -149,6 +167,8 @@ export class DriveCrypto { sessionKey, encryptionKeys, signingKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, ); return { @@ -242,7 +262,13 @@ export class DriveCrypto { srp: SRPVerifier; }> { const [{ armoredData: armoredPassword }, { keyPacket }, srp] = await Promise.all([ - this.openPGPCrypto.encryptArmored(new TextEncoder().encode(password), [addressKey]), + this.openPGPCrypto.encryptArmored( + new TextEncoder().encode(password), + [addressKey], + undefined, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, + ), this.openPGPCrypto.encryptSessionKeyWithPassword(sharePassphraseSessionKey, bcryptPassphrase), this.srpModule.getSrpVerifier(password), ]); @@ -356,6 +382,8 @@ export class DriveCrypto { signature, [encryptionKey], sessionKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, ); return { armoredSignature, @@ -381,6 +409,8 @@ export class DriveCrypto { undefined, [encryptionAndSigningKey], encryptionAndSigningKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, ); return { armoredHashKey, @@ -420,6 +450,8 @@ export class DriveCrypto { sessionKey, encryptionKey ? [encryptionKey] : [], signingKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, ); return { armoredNodeName, @@ -502,7 +534,8 @@ export class DriveCrypto { undefined, [encryptionKey], signingKey, - { compress: true }, + // See note in the interface documentation about AEAD encryption. + { compress: true, enableAeadWithEncryptionKeys: false }, ); return { armoredExtendedAttributes, @@ -626,8 +659,10 @@ export class DriveCrypto { sessionKey, [], // Thumbnails use the session key so we do not send encryption key. signingKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: true }, ); - this.recordPerformance('content_encryption', thumbnailData.length, start); + this.recordPerformance('content_encryption', sessionKey, thumbnailData.length, start); return { encryptedData, @@ -649,7 +684,7 @@ export class DriveCrypto { verified, verificationErrors, } = await this.openPGPCrypto.decryptAndVerify(encryptedThumbnail, sessionKey, verificationKeys); - this.recordPerformance('content_decryption', decryptedThumbnail.length, start); + this.recordPerformance('content_decryption', sessionKey, decryptedThumbnail.length, start); return { decryptedThumbnail, verified, @@ -672,8 +707,10 @@ export class DriveCrypto { sessionKey, [], // Blocks use the session key so we do not send encryption key. signingKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: true }, ); - this.recordPerformance('content_encryption', blockData.length, start); + this.recordPerformance('content_encryption', sessionKey, blockData.length, start); const { armoredSignature } = await this.encryptSignature(signature, encryptionKey, sessionKey); @@ -689,7 +726,7 @@ export class DriveCrypto { ): Promise> { const start = performance.now(); const { data: decryptedBlock } = await this.openPGPCrypto.decryptAndVerify(encryptedBlock, sessionKey, []); - this.recordPerformance('content_decryption', decryptedBlock.length, start); + this.recordPerformance('content_decryption', sessionKey, decryptedBlock.length, start); return decryptedBlock; } @@ -740,21 +777,25 @@ export class DriveCrypto { undefined, [encryptionKey], signingKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, ); return armoredData; } private recordPerformance( type: 'content_encryption' | 'content_decryption', + sessionKey: SessionKey, bytesProcessed: number, start: number, ) { const end = performance.now(); const duration = end - start; + const cryptoModel = sessionKey.aeadAlgorithm ? 'v1.5' : 'v1'; this.telemetry.recordMetric({ eventName: 'performance', type, - cryptoModel: 'v1', + cryptoModel, bytesProcessed, milliseconds: Math.round(duration), }); diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 05b7d0ef..105e9a8f 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -39,8 +39,8 @@ export interface PrivateKey extends PublicKey { export interface SessionKey { data: Uint8Array; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - algorithm: any; + algorithm: string | null; + aeadAlgorithm: string | null; } export enum VERIFICATION_STATUS { @@ -91,7 +91,7 @@ export interface OpenPGPCrypto { */ generatePassphrase: () => string; - generateSessionKey: (encryptionKeys: PublicKey[]) => Promise; + generateSessionKey: (encryptionKeys: PublicKey[], options: { enableAeadWithEncryptionKeys: boolean }) => Promise; encryptSessionKey: ( sessionKey: SessionKey, @@ -112,7 +112,10 @@ export interface OpenPGPCrypto { * * The key pair is generated using the Curve25519 algorithm. */ - generateKey: (passphrase: string) => Promise<{ + generateKey: ( + passphrase: string, + options: { enableAead: boolean }, + ) => Promise<{ privateKey: PrivateKey; armoredKey: string; }>; @@ -120,7 +123,8 @@ export interface OpenPGPCrypto { encryptArmored: ( data: Uint8Array, encryptionKeys: PublicKey[], - sessionKey?: SessionKey, + sessionKey: SessionKey | undefined, + options: { enableAeadWithEncryptionKeys: boolean }, ) => Promise<{ armoredData: string; }>; @@ -130,6 +134,7 @@ export interface OpenPGPCrypto { sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, ) => Promise<{ encryptedData: Uint8Array; }>; @@ -139,7 +144,7 @@ export interface OpenPGPCrypto { sessionKey: SessionKey | undefined, encryptionKeys: PublicKey[], signingKey: PrivateKey, - options?: { compress?: boolean }, + options: { compress?: boolean, enableAeadWithEncryptionKeys: boolean }, ) => Promise<{ armoredData: string; }>; @@ -149,6 +154,7 @@ export interface OpenPGPCrypto { sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, ) => Promise<{ encryptedData: Uint8Array; signature: Uint8Array; @@ -159,6 +165,7 @@ export interface OpenPGPCrypto { sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, ) => Promise<{ armoredData: string; armoredSignature: string; diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index f71b3311..feb9cee2 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -8,10 +8,18 @@ import { uint8ArrayToBase64String } from './utils'; * clients/packages/crypto/lib/proxy/proxy.ts. */ export interface OpenPGPCryptoProxy { - generateKey: (options: { userIDs: { name: string }[]; type: 'ecc'; curve: 'ed25519Legacy' }) => Promise; + generateKey: (options: { + userIDs: { name: string }[]; + type: 'ecc'; + curve: 'ed25519Legacy'; + config?: { aeadProtect: boolean }; + }) => Promise; exportPrivateKey: (options: { privateKey: PrivateKey; passphrase: string | null }) => Promise; importPrivateKey: (options: { armoredKey: string; passphrase: string | null }) => Promise; - generateSessionKey: (options: { recipientKeys: PublicKey[] }) => Promise; + generateSessionKey: (options: { + recipientKeys: PublicKey[]; + config?: { ignoreSEIPDv2FeatureFlag: boolean }; + }) => Promise; encryptSessionKey: ( options: SessionKey & { format: 'binary'; @@ -32,6 +40,7 @@ export interface OpenPGPCryptoProxy { signingKeys?: PrivateKey; detached?: Detached; compress?: boolean; + config?: { ignoreSEIPDv2FeatureFlag: boolean }; }) => Promise< Detached extends true ? { @@ -93,8 +102,15 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { return uint8ArrayToBase64String(value); } - async generateSessionKey(encryptionKeys: PublicKey[]) { - return this.cryptoProxy.generateSessionKey({ recipientKeys: encryptionKeys }); + async generateSessionKey(encryptionKeys: PublicKey[], options: { enableAeadWithEncryptionKeys: boolean }) { + return this.cryptoProxy.generateSessionKey({ + recipientKeys: encryptionKeys, + // `ignoreSEIPDv2FeatureFlag` means that the key preferences are + // ignored. If set to `true`, the session key will be generated + // the standard non-AEAD algorithm. If set to `false`, the session + // key will always follow the encryption key preferences. + config: { ignoreSEIPDv2FeatureFlag: !options.enableAeadWithEncryptionKeys }, + }); } async encryptSessionKey(sessionKey: SessionKey, encryptionKeys: PublicKey | PublicKey[]) { @@ -119,11 +135,12 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async generateKey(passphrase: string) { + async generateKey(passphrase: string, options: { enableAead: boolean }) { const privateKey = await this.cryptoProxy.generateKey({ userIDs: [{ name: 'Drive key' }], type: 'ecc', curve: 'ed25519Legacy', + config: { aeadProtect: options.enableAead }, }); const armoredKey = await this.cryptoProxy.exportPrivateKey({ @@ -137,11 +154,21 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async encryptArmored(data: Uint8Array, encryptionKeys: PublicKey[], sessionKey?: SessionKey) { + async encryptArmored( + data: Uint8Array, + encryptionKeys: PublicKey[], + sessionKey: SessionKey | undefined, + options: { enableAeadWithEncryptionKeys: boolean }, + ) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, sessionKey, encryptionKeys, + // `ignoreSEIPDv2FeatureFlag` means that the key preferences are + // ignored. If set to `true`, the encrypted data will be generated + // the standard non-AEAD algorithm. If set to `false`, the session + // key will always follow the encryption key preferences. + config: { ignoreSEIPDv2FeatureFlag: !options.enableAeadWithEncryptionKeys }, }); return { armoredData: armoredData, @@ -153,6 +180,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, + options: { compress?: boolean, enableAeadWithEncryptionKeys: boolean }, ) { const { message: encryptedData } = await this.cryptoProxy.encryptMessage({ binaryData: data, @@ -161,6 +189,11 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { encryptionKeys, format: 'binary', detached: false, + // `ignoreSEIPDv2FeatureFlag` means that the key preferences are + // ignored. If set to `true`, the encrypted data will be generated + // the standard non-AEAD algorithm. If set to `false`, the session + // key will always follow the encryption key preferences. + config: { ignoreSEIPDv2FeatureFlag: !options.enableAeadWithEncryptionKeys }, }); return { encryptedData: encryptedData, @@ -172,7 +205,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey | undefined, encryptionKeys: PublicKey[], signingKey: PrivateKey, - options: { compress?: boolean } = {}, + options: { compress?: boolean, enableAeadWithEncryptionKeys: boolean }, ) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, @@ -181,6 +214,11 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { signingKeys: signingKey, detached: false, compress: options.compress || false, + // `ignoreSEIPDv2FeatureFlag` means that the key preferences are + // ignored. If set to `true`, the encrypted data will be generated + // the standard non-AEAD algorithm. If set to `false`, the session + // key will always follow the encryption key preferences. + config: { ignoreSEIPDv2FeatureFlag: !options.enableAeadWithEncryptionKeys }, }); return { armoredData: armoredData, @@ -192,6 +230,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, ) { const { message: encryptedData, signature } = await this.cryptoProxy.encryptMessage({ binaryData: data, @@ -200,6 +239,11 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { encryptionKeys, format: 'binary', detached: true, + // `ignoreSEIPDv2FeatureFlag` means that the key preferences are + // ignored. If set to `true`, the encrypted data will be generated + // the standard non-AEAD algorithm. If set to `false`, the session + // key will always follow the encryption key preferences. + config: { ignoreSEIPDv2FeatureFlag: !options.enableAeadWithEncryptionKeys }, }); return { encryptedData: encryptedData, @@ -212,6 +256,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, ) { const { message: armoredData, signature: armoredSignature } = await this.cryptoProxy.encryptMessage({ binaryData: data, @@ -219,6 +264,11 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { signingKeys: signingKey, encryptionKeys, detached: true, + // `ignoreSEIPDv2FeatureFlag` means that the key preferences are + // ignored. If set to `true`, the encrypted data will be generated + // the standard non-AEAD algorithm. If set to `false`, the session + // key will always follow the encryption key preferences. + config: { ignoreSEIPDv2FeatureFlag: !options.enableAeadWithEncryptionKeys }, }); return { armoredData: armoredData, @@ -292,6 +342,12 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { throw new Error('Could not decrypt session key'); } + // Encrypted OpenPGP v6 session keys used for AEAD do not store algorithm information, so we hardcode it + if (sessionKey.algorithm === null) { + sessionKey.algorithm = 'aes256'; + sessionKey.aeadAlgorithm = 'gcm'; + } + return sessionKey; } diff --git a/js/sdk/src/interface/featureFlags.ts b/js/sdk/src/interface/featureFlags.ts index d821f922..220dfe41 100644 --- a/js/sdk/src/interface/featureFlags.ts +++ b/js/sdk/src/interface/featureFlags.ts @@ -3,5 +3,9 @@ * Applications must supply their own implementation. */ export interface FeatureFlagProvider { - isEnabled(flagName: string, signal?: AbortSignal): Promise; + isEnabled(flagName: FeatureFlags, signal?: AbortSignal): Promise; +} + +export enum FeatureFlags { + DriveCryptoEncryptBlocksWithPgpAead = 'DriveCryptoEncryptBlocksWithPgpAead', } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 5573e756..dc358944 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -14,6 +14,7 @@ export type { Author, UnverifiedAuthorError, AnonymousUser } from './author'; export type { ProtonDriveConfig } from './config'; export type { Device, DeviceOrUid } from './devices'; export type { FeatureFlagProvider } from './featureFlags'; +export { FeatureFlags } from './featureFlags'; export { DeviceType } from './devices'; export type { FileDownloader, DownloadController, SeekableReadableStream } from './download'; export type { diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index a999140d..8f2ab308 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -1,5 +1,6 @@ import { DriveCrypto } from '../../crypto'; import { + FeatureFlagProvider, ProtonDriveAccount, ProtonDriveCryptoCache, ProtonDriveEntitiesCache, @@ -148,10 +149,11 @@ export function initPhotoUploadModule( driveCrypto: DriveCrypto, sharesService: SharesService, nodesService: UploadNodesService, + featureFlagProvider: FeatureFlagProvider, clientUid?: string, ) { const api = new PhotoUploadAPIService(apiService, clientUid); - const cryptoService = new PhotoUploadCryptoService(driveCrypto, nodesService); + const cryptoService = new PhotoUploadCryptoService(telemetry, driveCrypto, nodesService, featureFlagProvider); const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); const manager = new PhotoUploadManager(telemetry, api, cryptoService, nodesService, clientUid); diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index 74ca664f..783fb2dc 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -1,5 +1,5 @@ import { DriveCrypto } from '../../crypto'; -import { ProtonDriveTelemetry, UploadMetadata, Thumbnail, AnonymousUser } from '../../interface'; +import { ProtonDriveTelemetry, UploadMetadata, Thumbnail, AnonymousUser, FeatureFlagProvider } from '../../interface'; import { DriveAPIService, drivePaths } from '../apiService'; import { generateFileExtendedAttributes } from '../nodes'; import { splitNodeRevisionUid } from '../uids'; @@ -162,7 +162,10 @@ export class PhotoUploadManager extends UploadManager { } // TODO: handle photo extended attributes in the SDK - now it must be passed from the client - const generatedExtendedAttributes = generateFileExtendedAttributes(extendedAttributes, uploadMetadata.additionalMetadata); + const generatedExtendedAttributes = generateFileExtendedAttributes( + extendedAttributes, + uploadMetadata.additionalMetadata, + ); const nodeCommitCrypto = await this.cryptoService.commitFile( nodeRevisionDraft.nodeKeys, manifest, @@ -170,13 +173,16 @@ export class PhotoUploadManager extends UploadManager { ); const sha1 = extendedAttributes.digests.sha1; - const contentHash = await this.photoCryptoService.generateContentHash(sha1, nodeRevisionDraft.parentNodeKeys?.hashKey); + const contentHash = await this.photoCryptoService.generateContentHash( + sha1, + nodeRevisionDraft.parentNodeKeys?.hashKey, + ); const photo = { contentHash, - captureTime: uploadMetadata.captureTime || extendedAttributes.modificationTime, + captureTime: uploadMetadata.captureTime || extendedAttributes.modificationTime, mainPhotoLinkID: uploadMetadata.mainPhotoLinkID, tags: uploadMetadata.tags, - } + }; await this.photoApiService.commitDraftPhoto(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto, photo); await this.notifyNodeUploaded(nodeRevisionDraft); } @@ -184,10 +190,12 @@ export class PhotoUploadManager extends UploadManager { export class PhotoUploadCryptoService extends UploadCryptoService { constructor( + telemetry: ProtonDriveTelemetry, driveCrypto: DriveCrypto, nodesService: NodesService, + featureFlagProvider: FeatureFlagProvider, ) { - super(driveCrypto, nodesService); + super(telemetry, driveCrypto, nodesService, featureFlagProvider); } async generateContentHash(sha1: string, parentHashKey: Uint8Array): Promise { diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index 9cbdcff8..fe749ca5 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -2,7 +2,14 @@ import { c } from 'ttag'; import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto'; import { IntegrityError } from '../../errors'; -import { Thumbnail, AnonymousUser } from '../../interface'; +import { + Thumbnail, + AnonymousUser, + FeatureFlagProvider, + FeatureFlags, + ProtonDriveTelemetry, + Logger, +} from '../../interface'; import { EncryptedBlock, EncryptedThumbnail, @@ -13,12 +20,18 @@ import { } from './interface'; export class UploadCryptoService { + protected logger: Logger; + constructor( + telemetry: ProtonDriveTelemetry, protected driveCrypto: DriveCrypto, protected nodesService: NodesService, + protected featureFlagProvider: FeatureFlagProvider, ) { + this.logger = telemetry.getLogger('upload'); this.driveCrypto = driveCrypto; this.nodesService = nodesService; + this.featureFlagProvider = featureFlagProvider; } async generateFileCrypto( @@ -26,6 +39,13 @@ export class UploadCryptoService { parentKeys: { key: PrivateKey; hashKey: Uint8Array }, name: string, ): Promise { + const useAeadFeatureFlag = await this.featureFlagProvider.isEnabled( + FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, + ); + if (useAeadFeatureFlag) { + this.logger.info('Generating file crypto with AEAD enabled'); + } + const signingKeys = await this.getSigningKeys({ parentNodeUid: parentUid }); if (!signingKeys.nameAndPassphraseSigningKey) { @@ -33,7 +53,9 @@ export class UploadCryptoService { } const [nodeKeys, { armoredNodeName }, hash] = await Promise.all([ - this.driveCrypto.generateKey([parentKeys.key], signingKeys.nameAndPassphraseSigningKey), + this.driveCrypto.generateKey([parentKeys.key], signingKeys.nameAndPassphraseSigningKey, { + enableAead: useAeadFeatureFlag, + }), this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, signingKeys.nameAndPassphraseSigningKey), this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), ]); diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index dbfdd90b..32b2a6fc 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -1,4 +1,4 @@ -import { ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { FeatureFlagProvider, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; import { DriveAPIService } from '../apiService'; import { DriveCrypto } from '../../crypto'; import { UploadAPIService } from './apiService'; @@ -22,10 +22,11 @@ export function initUploadModule( driveCrypto: DriveCrypto, sharesService: SharesService, nodesService: NodesService, + featureFlagProvider: FeatureFlagProvider, clientUid?: string, ) { const api = new UploadAPIService(apiService, clientUid); - const cryptoService = new UploadCryptoService(driveCrypto, nodesService); + const cryptoService = new UploadCryptoService(telemetry, driveCrypto, nodesService, featureFlagProvider); const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); const manager = new UploadManager(telemetry, api, cryptoService, nodesService, clientUid); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index cb583a4c..3ef34e96 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -178,6 +178,7 @@ export class ProtonDriveClient { cryptoModule, this.shares, this.nodes.access, + featureFlagProvider, fullConfig.clientUid, ); this.devices = initDevicesModule( diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 6b78a4be..c9977c60 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -46,6 +46,7 @@ import { import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; import { initSharingModule } from './internal/sharing'; +import { NullFeatureFlagProvider } from './featureFlags'; /** * ProtonDrivePhotosClient is the interface to access Photos functionality. @@ -84,11 +85,15 @@ export class ProtonDrivePhotosClient { srpModule, config, telemetry, + featureFlagProvider, latestEventIdProvider, }: ProtonDriveClientContructorParameters) { if (!telemetry) { telemetry = new Telemetry(); } + if (!featureFlagProvider) { + featureFlagProvider = new NullFeatureFlagProvider(); + } this.logger = telemetry.getLogger('photos-interface'); const fullConfig = getConfig(config); @@ -147,6 +152,7 @@ export class ProtonDrivePhotosClient { cryptoModule, this.photoShares, this.nodes.access, + featureFlagProvider, fullConfig.clientUid, ); diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 7e9e21dd..46d1b161 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -20,6 +20,7 @@ import { NodeResult, SDKEvent, MemberRole, + FeatureFlagProvider, } from './interface'; import { Telemetry } from './telemetry'; import { @@ -33,6 +34,7 @@ import { initDownloadModule } from './internal/download'; import { SDKEvents } from './internal/sdkEvents'; import { initSharingPublicModule, UnauthDriveAPIService } from './internal/sharingPublic'; import { initUploadModule } from './internal/upload'; +import { NullFeatureFlagProvider } from './featureFlags'; import { NodesSecurityScanResult } from './internal/sharingPublic/nodesSecurity'; /** @@ -93,6 +95,7 @@ export class ProtonDrivePublicLinkClient { srpModule, config, telemetry, + featureFlagProvider, url, token, publicShareKey, @@ -106,6 +109,7 @@ export class ProtonDrivePublicLinkClient { srpModule: SRPModule; config?: ProtonDriveConfig; telemetry?: ProtonDriveTelemetry; + featureFlagProvider?: FeatureFlagProvider; url: string; token: string; publicShareKey: PrivateKey; @@ -116,6 +120,9 @@ export class ProtonDrivePublicLinkClient { if (!telemetry) { telemetry = new Telemetry(); } + if (!featureFlagProvider) { + featureFlagProvider = new NullFeatureFlagProvider(); + } this.logger = telemetry.getLogger('publicLink-interface'); // Use only in memory cache for public link as there are no events to keep it up to date if persisted. @@ -165,6 +172,7 @@ export class ProtonDrivePublicLinkClient { cryptoModule, this.sharingPublic.shares, this.sharingPublic.nodes.access, + featureFlagProvider, fullConfig.clientUid, ); From 4812ed32ce287174c2e641f992a36685763d6949 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Feb 2026 12:57:37 +0100 Subject: [PATCH 564/791] Fix second-attempt file upload failing due to signature key disposal --- cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs index 9d38710e..7a7d18e9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -112,9 +112,6 @@ public bool TryGetNextContentBlockPlainData( public async ValueTask DisposeAsync() { - FileKey.Dispose(); - ContentKey.Dispose(); - SigningKey.Dispose(); Sha1.Dispose(); var dataItemsToDispose = ContentBlockStates From 59d2c2f603679e95f20a548ded3f14630905eb9b Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Feb 2026 14:58:50 +0100 Subject: [PATCH 565/791] Improve error reporting for trash and restore operations --- .../InteropProtonDriveClient.cs | 4 ++-- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs | 12 ++++++------ cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs | 4 ++-- cs/sdk/src/protos/proton.drive.sdk.proto | 2 +- .../Client/ProtonDriveClient/ProtonDriveClient.swift | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 4c228c4b..5c55d17c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -270,9 +270,9 @@ public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNo NodeUid = pair.Key.ToString(), }; - if (pair.Value.TryGetError(out var error)) + if (pair.Value.TryGetError(out var exception)) { - result.Error = error; + result.Error = exception.ToErrorMessage(InteropDriveErrorConverter.SetDomainAndCodes); } return result; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index e3a2db17..cc77f210 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -332,14 +332,14 @@ public static async ValueTask RenameAsync( await client.Cache.Entities.SetNodeAsync(uid, node with { Name = newName }, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); } - public static async ValueTask>> TrashAsync( + public static async ValueTask>> TrashAsync( ProtonDriveClient client, IEnumerable uids, CancellationToken cancellationToken) { var uidsByVolumeId = uids.GroupBy(x => x.VolumeId); - var results = new ConcurrentDictionary>(); + var results = new ConcurrentDictionary>(); var tasks = uidsByVolumeId.Select(async uidGroup => { @@ -366,7 +366,7 @@ await client.Cache.Entities.SetNodeAsync(uid, newNodeProvisionResult, membership .ConfigureAwait(false); } - var result = response.IsSuccess ? Result.Success : response.ErrorMessage; + var result = response.IsSuccess ? Result.Success : new ProtonApiException(response); results.TryAdd(uid, result); } @@ -411,14 +411,14 @@ public static async ValueTask>> D return results; } - public static async ValueTask>> RestoreAsync( + public static async ValueTask>> RestoreAsync( ProtonDriveClient client, IEnumerable uids, CancellationToken cancellationToken) { var uidsByVolumeId = uids.GroupBy(x => x.VolumeId); - var results = new ConcurrentDictionary>(); + var results = new ConcurrentDictionary>(); var tasks = uidsByVolumeId.Select(async uidGroup => { @@ -432,7 +432,7 @@ public static async ValueTask>> D { var uid = new NodeUid(uidGroup.Key, linkId); - var result = response.IsSuccess ? Result.Success : response.ErrorMessage; + var result = response.IsSuccess ? Result.Success : new ProtonApiException(response); results.TryAdd(uid, result); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index e1559e37..7e8fd0a6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -265,7 +265,7 @@ public ValueTask RenameNodeAsync(NodeUid uid, string newName, string? newMediaTy return NodeOperations.RenameAsync(this, uid, newName, newMediaType, cancellationToken); } - public ValueTask>> TrashNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + public ValueTask>> TrashNodesAsync(IEnumerable uids, CancellationToken cancellationToken) { return NodeOperations.TrashAsync(this, uids, cancellationToken); } @@ -275,7 +275,7 @@ public ValueTask>> DeleteNodesAsy return NodeOperations.DeleteAsync(this, uids, cancellationToken); } - public ValueTask>> RestoreNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + public ValueTask>> RestoreNodesAsync(IEnumerable uids, CancellationToken cancellationToken) { return NodeOperations.RestoreAsync(this, uids, cancellationToken); } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index a8a1c4f1..f6665e8f 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -368,7 +368,7 @@ message DriveClientTrashNodesRequest { message NodeResultPair { string node_uid = 1; - string error = 2; + proton.sdk.Error error = 2; } message TrashNodesResponse { diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index fafd14f0..fdcf280d 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -464,7 +464,7 @@ extension ProtonDriveClient { let result: Proton_Drive_Sdk_TrashNodesResponse = try await SDKRequestHandler.send(trashRequest, logger: logger) let results: [TrashNodeResult] = result.results.compactMap { result in guard let id = SDKNodeUid(sdkCompatibleIdentifier: result.nodeUid) else { return nil } - let error: String? = result.hasError ? result.error : nil + let error: String? = result.hasError ? result.error.message : nil return TrashNodeResult(nodeUid: id, error: error) } return results From f1fa527bda88a4ae5ac30b57179eab49d31543a5 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 06:03:14 +0000 Subject: [PATCH 566/791] i18n(weekly-mr): Upgrade translations from crowdin (2a3e4b9f). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 3 +++ js/sdk/locales/ca_ES.json | 3 +++ js/sdk/locales/de_DE.json | 3 +++ js/sdk/locales/el_GR.json | 3 +++ js/sdk/locales/es_ES.json | 3 +++ js/sdk/locales/es_LA.json | 3 +++ js/sdk/locales/fr_FR.json | 3 +++ js/sdk/locales/nl_NL.json | 3 +++ js/sdk/locales/ro_RO.json | 3 +++ js/sdk/locales/sk_SK.json | 3 +++ js/sdk/locales/tr_TR.json | 3 +++ 12 files changed, 34 insertions(+), 1 deletion(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index 2c4db43c..5e4e8fc4 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "d0304af653cbc5f5805d2c61d434ef56b90581f9" + "locale": "91602862052d56fc01bf946271bbc3ffbc84d3bd" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index ce7abd60..7d300b46 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -176,6 +176,9 @@ "Parent cannot be decrypted": [ "Ðемагчыма раÑшыфраваць бацькоўÑкі вузел" ], + "Photo is already in the target album": [ + "Фатаграфіі ўжо знаходзÑцца Ñž мÑтавым альбоме" + ], "Photo not found": [ "Ð¤Ð°Ñ‚Ð°Ð³Ñ€Ð°Ñ„Ñ–Ñ Ð½Ðµ знойдзена" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 57372990..84a34193 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -174,6 +174,9 @@ "Parent cannot be decrypted": [ "L'element principal no es pot desxifrar" ], + "Photo is already in the target album": [ + "La foto ja és a l'àlbum de destinació" + ], "Photo not found": [ "No s'ha trobat la fotografia" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 8a0e8bd7..c569418f 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -174,6 +174,9 @@ "Parent cannot be decrypted": [ "Übergeordnetes Element kann nicht entschlüsselt werden" ], + "Photo is already in the target album": [ + "Foto ist bereits im Zielalbum" + ], "Photo not found": [ "Foto nicht gefunden" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index 679ce854..c3136342 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -174,6 +174,9 @@ "Parent cannot be decrypted": [ "Το γονικό αντικείμενο δεν μποÏεί να αποκÏυπτογÏαφηθεί" ], + "Photo is already in the target album": [ + "Η φωτογÏαφία βÏίσκεται ήδη στο άλμπουμ Ï€ÏοοÏισμοÏ" + ], "Photo not found": [ "Η φωτογÏαφία δεν βÏέθηκε" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index fc7e92ca..0e4bdd77 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -174,6 +174,9 @@ "Parent cannot be decrypted": [ "No se puede descifrar el elemento principal." ], + "Photo is already in the target album": [ + "La foto ya está en el álbum de destino" + ], "Photo not found": [ "Foto no encontrada" ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 10cac80e..547f52e3 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -174,6 +174,9 @@ "Parent cannot be decrypted": [ "No se puede descifrar el elemento principal" ], + "Photo is already in the target album": [ + "La foto ya está en el álbum de destino" + ], "Photo not found": [ "Foto no encontrada" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index 1c8bd7b0..79086857 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -174,6 +174,9 @@ "Parent cannot be decrypted": [ "L'élément principal ne peut pas être déchiffré." ], + "Photo is already in the target album": [ + "La photo est déjà dans l’album cible." + ], "Photo not found": [ "La photo est introuvable." ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index a4418b7b..d13fc001 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -174,6 +174,9 @@ "Parent cannot be decrypted": [ "Bovenliggend onderdeel kan niet worden ontsleuteld" ], + "Photo is already in the target album": [ + "Foto staat al in het doelalbum" + ], "Photo not found": [ "Foto niet gevonden" ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index a6fd8614..fe45402d 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -175,6 +175,9 @@ "Parent cannot be decrypted": [ "Părintele nu poate fi decriptat." ], + "Photo is already in the target album": [ + "Fotografia există deja în albumul È›intă." + ], "Photo not found": [ "Fotografia nu a fost găsită" ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index 842e5448..f0c35247 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -176,6 +176,9 @@ "Parent cannot be decrypted": [ "Nadradenú položku nemožno deÅ¡ifrovaÅ¥" ], + "Photo is already in the target album": [ + "Fotografia sa už v cieľovom albume nachádza" + ], "Photo not found": [ "Fotka sa nenaÅ¡la" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index 3ac6c6d6..e8da6230 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -174,6 +174,9 @@ "Parent cannot be decrypted": [ "Üst ögenin ÅŸifresi çözülemedi" ], + "Photo is already in the target album": [ + "FotoÄŸraf zaten hedef albümde bulunuyor" + ], "Photo not found": [ "FotoÄŸraf bulunamadı" ], From 9b42004ff996e87c0db14b0e0bda8c2c81dc5470 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 06:19:43 +0000 Subject: [PATCH 567/791] Override parentUid for root node of public link --- js/sdk/src/internal/sharingPublic/nodes.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index 14ca82fe..5f8a818b 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -86,12 +86,19 @@ export class SharingPublicNodesAPIService extends NodeAPIService { const nodeUid = makeNodeUid(volumeId, link.Link.LinkID); const encryptedNode = linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); - // TEMPORARY: Inject public permissions for the root node only. - // This ensures the root node has the correct directRole instead of - // incorrectly falling back to 'admin' due to null DirectPermissions. - // May be fixed by backend later. + // TODO: This affects the cache. At this moment, the public link is not cached + // anywhere, thus OK. To avoid issues when public links reuses the same cache, + // we need to move this either to the interface of given instance, or leave + // this as a responsibility to the client. if (this.publicRootNodeUid === nodeUid) { + // Inject public permissions for the root node only. + // This ensures the root node has the correct directRole instead of + // incorrectly falling back to 'admin' due to null DirectPermissions. encryptedNode.directRole = this.publicRole; + // This prevent to have parentUid in case user visited parent folder public link of a public link + // Since the session got permissions to get the parentNode, + // when visiting children it will return the parentLinkID in links request. + encryptedNode.parentUid = undefined; } return encryptedNode; From 0c4d4987c28d7209528e4f659e9a4eaee9765f50 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 09:52:42 +0000 Subject: [PATCH 568/791] Add Kotlin bindings for trash nodes --- .../me/proton/drive/sdk/ProtonDriveClient.kt | 15 +++++++++++++++ .../sdk/converter/TrashNodesResponseConverter.kt | 11 +++++++++++ .../me/proton/drive/sdk/entity/NodeResultPair.kt | 10 ++++++++++ .../drive/sdk/extension/TrashNodesResponse.kt | 14 ++++++++++++++ .../drive/sdk/internal/JniProtonDriveClient.kt | 8 ++++++++ 5 files changed, 58 insertions(+) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashNodesResponseConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashNodesResponse.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index ef63ca21..ee1bb028 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -6,6 +6,7 @@ import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.FolderNode import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.entity.NodeResultPair import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniProtonDriveClient @@ -18,6 +19,7 @@ import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetMyFilesFolderRequest import proton.drive.sdk.driveClientGetThumbnailsRequest import proton.drive.sdk.driveClientRenameRequest +import proton.drive.sdk.driveClientTrashNodesRequest import java.nio.channels.WritableByteChannel class ProtonDriveClient internal constructor( @@ -122,6 +124,19 @@ class ProtonDriveClient internal constructor( ).toEntity() } + suspend fun trashNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(DEBUG, "trashNodes(${nodeUids.size} nodes)") + bridge.trashNodes( + driveClientTrashNodesRequest { + this.nodeUids += nodeUids + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashNodesResponseConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashNodesResponseConverter.kt new file mode 100644 index 00000000..f23f8844 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashNodesResponseConverter.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class TrashNodesResponseConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.TrashNodesResponse" + + override fun convert(any: Any): ProtonDriveSdk.TrashNodesResponse = + ProtonDriveSdk.TrashNodesResponse.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt new file mode 100644 index 00000000..129bfab1 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.entity + +import me.proton.drive.sdk.ProtonDriveSdkException + +sealed interface NodeResultPair { + val nodeUid: String + + data class Success(override val nodeUid: String) : NodeResultPair + data class Failure(override val nodeUid: String, val error: ProtonDriveSdkException) : NodeResultPair +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashNodesResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashNodesResponse.kt new file mode 100644 index 00000000..00e9ef8f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashNodesResponse.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.NodeResultPair +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.TrashNodesResponse.toEntity(): List = + resultsList.map { it.toEntity() } + +fun ProtonDriveSdk.NodeResultPair.toEntity(): NodeResultPair = + if (hasError()) { + NodeResultPair.Failure(nodeUid = nodeUid, error = error.toException()) + } else { + NodeResultPair.Success(nodeUid = nodeUid) + } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index 5de53e21..12aae778 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope import me.proton.drive.sdk.converter.FileThumbnailListConverter import me.proton.drive.sdk.converter.FolderChildrenListConverter import me.proton.drive.sdk.converter.FolderNodeConverter +import me.proton.drive.sdk.converter.TrashNodesResponseConverter import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.StringResponseCallback @@ -116,6 +117,13 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { driveClientEnumerateFolderChildren = request } + suspend fun trashNodes( + request: ProtonDriveSdk.DriveClientTrashNodesRequest, + ): ProtonDriveSdk.TrashNodesResponse = + executeOnce("trashNodes", TrashNodesResponseConverter().asCallback) { + driveClientTrashNodes = request + } + fun free(handle: Long) { dispatch("free") { driveClientFree = driveClientFreeRequest { From ed620e4d48f0ada7ecc707032f5e5cf9868eb609 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 10:25:16 +0000 Subject: [PATCH 569/791] Add AEAD crypto test and FF management --- js/sdk/src/crypto/interface.ts | 14 ++++++++++---- js/sdk/src/crypto/openPGPCrypto.ts | 10 +++++++--- js/sdk/src/protonDrivePhotosClient.ts | 17 ++++++++--------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index 105e9a8f..a522e289 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -2,7 +2,7 @@ export interface PublicKey { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly _idx: any; - readonly _keyContentHash: [string, string]; + readonly _keyContentHash: [string, string]; getVersion(): number; getFingerprint(): string; @@ -91,7 +91,10 @@ export interface OpenPGPCrypto { */ generatePassphrase: () => string; - generateSessionKey: (encryptionKeys: PublicKey[], options: { enableAeadWithEncryptionKeys: boolean }) => Promise; + generateSessionKey: ( + encryptionKeys: PublicKey[], + options: { enableAeadWithEncryptionKeys: boolean }, + ) => Promise; encryptSessionKey: ( sessionKey: SessionKey, @@ -144,7 +147,7 @@ export interface OpenPGPCrypto { sessionKey: SessionKey | undefined, encryptionKeys: PublicKey[], signingKey: PrivateKey, - options: { compress?: boolean, enableAeadWithEncryptionKeys: boolean }, + options: { compress?: boolean; enableAeadWithEncryptionKeys: boolean }, ) => Promise<{ armoredData: string; }>; @@ -205,7 +208,10 @@ export interface OpenPGPCrypto { verificationErrors?: Error[]; }>; - decryptSessionKey: (data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; + decryptSessionKey: ( + data: Uint8Array, + decryptionKeys: PrivateKey | PrivateKey[], + ) => Promise; decryptArmoredSessionKey: (armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index feb9cee2..cdd7e708 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -180,7 +180,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey, encryptionKeys: PublicKey[], signingKey: PrivateKey, - options: { compress?: boolean, enableAeadWithEncryptionKeys: boolean }, + options: { compress?: boolean; enableAeadWithEncryptionKeys: boolean }, ) { const { message: encryptedData } = await this.cryptoProxy.encryptMessage({ binaryData: data, @@ -205,7 +205,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { sessionKey: SessionKey | undefined, encryptionKeys: PublicKey[], signingKey: PrivateKey, - options: { compress?: boolean, enableAeadWithEncryptionKeys: boolean }, + options: { compress?: boolean; enableAeadWithEncryptionKeys: boolean }, ) { const { message: armoredData } = await this.cryptoProxy.encryptMessage({ binaryData: data, @@ -301,7 +301,11 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { }; } - async verify(data: Uint8Array, signature: Uint8Array, verificationKeys: PublicKey | PublicKey[]) { + async verify( + data: Uint8Array, + signature: Uint8Array, + verificationKeys: PublicKey | PublicKey[], + ) { const { verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ binaryData: data, binarySignature: signature, diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index c9977c60..f4dec772 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -664,14 +664,13 @@ export class ProtonDrivePhotosClient { signal?: AbortSignal, ): AsyncGenerator { this.logger.info(`Updating ${photos.length} photos`); - yield * - this.photos.photos.updatePhotos( - photos.map((p) => ({ - nodeUid: getUid(p.nodeUid), - tagsToAdd: p.tagsToAdd || [], - tagsToRemove: p.tagsToRemove || [], - })), - signal, - ); + yield* this.photos.photos.updatePhotos( + photos.map((p) => ({ + nodeUid: getUid(p.nodeUid), + tagsToAdd: p.tagsToAdd || [], + tagsToRemove: p.tagsToRemove || [], + })), + signal, + ); } } From 467d35411fcd31293e6c5fb06f13f59fdc0aa2f6 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 10:27:44 +0000 Subject: [PATCH 570/791] Update changelog for js/v0.12.0 --- js/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 7fa4f31c..acd0fd03 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## js/v0.12.0 (2026-03-02) + +* Add AEAD crypto test and FF management +* Override parentUid for root node of public link +* Support AEAD block encryption + ## js/v0.11.0 (2026-02-26) * Add method to update photo tags From 4eff34b5a21c05dbd9dc92f495b775922c903edd Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 10:43:32 +0000 Subject: [PATCH 571/791] Update changelog for cs/v0.7.0-alpha.13 --- cs/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 0110ce05..d77fe1dd 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## cs/v0.7.0-alpha.13 (2026-03-02) + +* Add Kotlin bindings for trash nodes +* Test should not failed when SDK is aborted +* Improve error reporting for trash and restore operations +* Fix second-attempt file upload failing due to signature key disposal +* Categorize upload integrity exception properly + ## cs/v0.7.0-alpha.12 (2026-02-25) * Transmit api codes through interop From b11be1806c4039549b77757d86f356930e3edc1f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 17:07:19 +0100 Subject: [PATCH 572/791] Improve the way drafts are considered non-resumable to pass through original exceptions --- .../Nodes/Upload/RevisionDraft.cs | 1 + .../Nodes/Upload/RevisionWriter.cs | 141 ++++++++++-------- .../Upload/UploadContentReadingException.cs | 24 --- .../Nodes/Upload/UploadController.cs | 1 - 4 files changed, 78 insertions(+), 89 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadContentReadingException.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs index 7a7d18e9..c7d5a680 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -38,6 +38,7 @@ internal sealed partial class RevisionDraft( public IReadOnlyList> ContentBlockStates => _contentBlockStates; public bool IsCompleted { get; set; } + public bool IsResumable { get; set; } = true; public long NumberOfPlainBytesDone { get; set; } public void SetContentBlockPlainData(int blockNumber, BlockUploadPlainData plainData) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index c0e89694..971ec32e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -51,88 +51,96 @@ public async ValueTask WriteAsync( Action? onProgress, CancellationToken cancellationToken) { - var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); + try + { + var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); - var signingEmailAddress = _draft.MembershipAddress.EmailAddress; + var signingEmailAddress = _draft.MembershipAddress.EmailAddress; - int expectedThumbnailBlockCount; + int expectedThumbnailBlockCount; - var hashingContentStream = new HashingReadStream(contentStream, _draft.Sha1, leaveOpen: true); + var hashingContentStream = new HashingReadStream(contentStream, _draft.Sha1, leaveOpen: true); - await using (hashingContentStream.ConfigureAwait(false)) - { - try + await using (hashingContentStream.ConfigureAwait(false)) { try { - expectedThumbnailBlockCount = await UploadThumbnailBlocksAsync(thumbnails, uploadTasks, cancellationToken).ConfigureAwait(false); + try + { + expectedThumbnailBlockCount = await UploadThumbnailBlocksAsync(thumbnails, uploadTasks, cancellationToken).ConfigureAwait(false); - await UploadContentBlocksAsync(onProgress, hashingContentStream, uploadTasks, cancellationToken).ConfigureAwait(false); - } - finally - { - _releaseFileSemaphoreAction.Invoke(); - _fileReleased = true; - } + await UploadContentBlocksAsync(onProgress, hashingContentStream, uploadTasks, cancellationToken).ConfigureAwait(false); + } + finally + { + _releaseFileSemaphoreAction.Invoke(); + _fileReleased = true; + } - while (uploadTasks.TryDequeue(out var uploadTask)) - { - await uploadTask.ConfigureAwait(false); - } - } - catch when (uploadTasks.Count > 0) - { - foreach (var uploadTask in uploadTasks) - { - try + while (uploadTasks.TryDequeue(out var uploadTask)) { await uploadTask.ConfigureAwait(false); } - catch + } + catch when (uploadTasks.Count > 0) + { + foreach (var uploadTask in uploadTasks) { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + try + { + await uploadTask.ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + } } - } - throw; + throw; + } } - } - var sha1Digest = _draft.Sha1.GetCurrentHash(); + var sha1Digest = _draft.Sha1.GetCurrentHash(); - var request = CreateRevisionUpdateRequest( - lastModificationTime, - expectedContentLength, - expectedThumbnailBlockCount, - expectedSha1Provider, - sha1Digest, - signingEmailAddress, - additionalMetadata); + var request = CreateRevisionUpdateRequest( + lastModificationTime, + expectedContentLength, + expectedThumbnailBlockCount, + expectedSha1Provider, + sha1Digest, + signingEmailAddress, + additionalMetadata); - LogSealingRevision(_draft.Uid); + LogSealingRevision(_draft.Uid); - try - { - await _client.Api.Files.UpdateRevisionAsync( - _draft.Uid.NodeUid.VolumeId, - _draft.Uid.NodeUid.LinkId, - _draft.Uid.RevisionId, - request, - cancellationToken).ConfigureAwait(false); - } - catch (ProtonApiException ex) when (ex.Code is ResponseCode.IncompatibleState) - { - // The revision might have been previously sealed without getting the response back due to a cancellation. - // Throw only if the revision is still not sealed. - if (!(await RevisionIsSealedAsync(cancellationToken).ConfigureAwait(false))) + try { - throw; + await _client.Api.Files.UpdateRevisionAsync( + _draft.Uid.NodeUid.VolumeId, + _draft.Uid.NodeUid.LinkId, + _draft.Uid.RevisionId, + request, + cancellationToken).ConfigureAwait(false); + } + catch (ProtonApiException ex) when (ex.Code is ResponseCode.IncompatibleState) + { + // The revision might have been previously sealed without getting the response back due to a cancellation. + // Throw only if the revision is still not sealed. + if (!await RevisionIsSealedAsync(cancellationToken).ConfigureAwait(false)) + { + throw; + } } - } - LogRevisionSealed(_draft.Uid); + LogRevisionSealed(_draft.Uid); - _draft.IsCompleted = true; + _draft.IsCompleted = true; + } + catch (Exception ex) when (!IsResumableError(ex)) + { + _draft.IsResumable = false; + throw; + } } public void Dispose() @@ -143,6 +151,13 @@ public void Dispose() } } + private static bool IsResumableError(Exception ex) + { + return ex is not ProtonApiException { TransportCode: >= 400 and < 500 } + and not NodeWithSameNameExistsException + and not IntegrityException; + } + private async ValueTask UploadContentBlockAsync( int blockNumber, BlockUploadPlainData plainData, @@ -307,15 +322,13 @@ await TryGetNextContentBlockPlainDataAsync( return (currentBlockNumber.Value, plainData); } - catch (Exception ex) + catch (Exception) { + _draft.IsResumable = false; + await plainDataStream.DisposeAsync().ConfigureAwait(false); - throw new UploadContentReadingException( - ex is OperationCanceledException && cancellationToken.IsCancellationRequested - ? "Reading block content could not complete in time after cancellation" - : "Reading block content for upload failed", - ex); + throw; } } catch diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadContentReadingException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadContentReadingException.cs deleted file mode 100644 index 467998f3..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadContentReadingException.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes.Upload; - -/// -/// Exception thrown when reading from the content source for the upload failed. -/// -/// -/// Catching this exception allows handling the case when the content source may be in an indeterminate state that would prevent from reusing it for resuming the upload. -/// -public class UploadContentReadingException : ProtonDriveException -{ - public UploadContentReadingException() - { - } - - public UploadContentReadingException(string message) - : base(message) - { - } - - public UploadContentReadingException(string message, Exception innerException) - : base(message, innerException) - { - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 93be2d0b..44ef5cf1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -99,7 +99,6 @@ public async ValueTask DisposeAsync() private static bool IsResumableError(Exception ex) { return ex is not ProtonApiException { TransportCode: > 400 and < 500 } - and not UploadContentReadingException and not NodeWithSameNameExistsException and not IntegrityException; } From c80dcfd9918aee0420a822ca6e4745ba5d121e79 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 16:22:48 +0000 Subject: [PATCH 573/791] Update changelog for cs/v0.7.0-alpha.14 --- cs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index d77fe1dd..00054fe2 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## cs/v0.7.0-alpha.14 (2026-03-02) + +* Improve the way drafts are considered non-resumable to pass through original exceptions + ## cs/v0.7.0-alpha.13 (2026-03-02) * Add Kotlin bindings for trash nodes From a327d8a2f803d2536f005503e56f3bbdcb2e7d67 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 3 Mar 2026 10:20:03 +0100 Subject: [PATCH 574/791] Fix registry not removing objects when the removeAll call happens from the owner's deinit --- .../Plumbing/CallbackHandleRegistry.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift b/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift index 739fc416..89142472 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift @@ -45,10 +45,13 @@ final class CallbackHandleRegistry: @unchecked Sendable { private var nextId: RegistryHandle = 1 private var entries: [RegistryHandle: Entry] = [:] + private var registrationsSinceLastSweep = 0 + private struct Entry { let object: AnyObject let scope: CallbackScope weak var owner: AnyObject? + let ownerIdentity: ObjectIdentifier? } func register(_ object: AnyObject, scope: CallbackScope = .operation, owner: AnyObject? = nil) -> RegistryHandle { @@ -63,9 +66,14 @@ final class CallbackHandleRegistry: @unchecked Sendable { } lock.lock() + registrationsSinceLastSweep += 1 + if registrationsSinceLastSweep >= 100 { + entries = entries.filter { $0.value.scope != .ownerManaged || $0.value.owner != nil } + registrationsSinceLastSweep = 0 + } let id = nextId nextId += 1 - entries[id] = Entry(object: object, scope: scope, owner: owner) + entries[id] = Entry(object: object, scope: scope, owner: owner, ownerIdentity: owner.map(ObjectIdentifier.init)) lock.unlock() return id } @@ -109,9 +117,13 @@ final class CallbackHandleRegistry: @unchecked Sendable { } /// Removes all entries owned by the given owner without cancelling them. + /// + /// Uses `ObjectIdentifier` for matching because weak references to the owner + /// are already zeroed by the time `deinit` runs, making `===` always fail. func removeAll(ownedBy owner: AnyObject) { + let identity = ObjectIdentifier(owner) lock.lock() - let keysToRemove = entries.filter { $0.value.owner === owner }.map { $0.key } + let keysToRemove = entries.filter { $0.value.ownerIdentity == identity }.map { $0.key } for key in keysToRemove { entries.removeValue(forKey: key) } From 614789481c48601af8d1395c8c825a4c02d1259a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 3 Mar 2026 10:22:40 +0000 Subject: [PATCH 575/791] Update changelog for cs/v0.7.0-alpha.15 --- cs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 00054fe2..adbb8295 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## cs/v0.7.0-alpha.15 (2026-03-03) + +* Fix registry not removing objects when the removeAll call happens from the owner's deinit + ## cs/v0.7.0-alpha.14 (2026-03-02) * Improve the way drafts are considered non-resumable to pass through original exceptions From 9af7b87ef106413ffea3f8a408a086cc7db2146e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Mar 2026 13:03:21 +0000 Subject: [PATCH 576/791] Ensure cancelled uploads/downloads don't block queue --- .../me/proton/drive/sdk/FileDownloader.kt | 38 +++++++------ .../me/proton/drive/sdk/FileUploader.kt | 53 ++++++++++++------- .../me/proton/drive/sdk/PhotosDownloader.kt | 39 ++++++++------ .../me/proton/drive/sdk/PhotosUploader.kt | 32 ++++++----- .../me/proton/drive/sdk/ProtonDriveSdk.kt | 11 ++-- 5 files changed, 107 insertions(+), 66 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index dd4127f1..f306a338 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource @@ -9,11 +10,13 @@ import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniFileDownloader +import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import java.nio.channels.SeekableByteChannel import java.nio.channels.WritableByteChannel import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration class FileDownloader internal constructor( client: ProtonDriveClient, @@ -25,13 +28,13 @@ class FileDownloader internal constructor( override suspend fun downloadToStream( coroutineScope: CoroutineScope, channel: WritableByteChannel, - ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> + ): DownloadController = cancellationTokenSource().let { source -> log(INFO, "downloadToStream") val coroutineScopeReference = AtomicReference(coroutineScope) val controllerReference = AtomicReference() val handle = bridge.downloadToStream( handle = handle, - cancellationTokenSourceHandle = cancellationTokenSource.handle, + cancellationTokenSourceHandle = source.handle, onWrite = channel::write, onSeek = if (channel is SeekableByteChannel) { channel::seek @@ -51,8 +54,8 @@ class FileDownloader internal constructor( handle = handle, bridge = JniDownloadController(), channel = channel, - cancellationTokenSource = cancellationTokenSource, coroutineScopeConsumer = coroutineScopeReference::set, + cancellationTokenSource = source, ).also(controllerReference::set) } @@ -73,18 +76,21 @@ class FileDownloader internal constructor( } suspend fun ProtonDriveClient.downloader( - revisionUid: String -): Downloader = cancellationTokenSource().let { source -> - factory(JniFileDownloader()){ - FileDownloader( - client = this@downloader, - handle = getFileDownloader( - clientHandle = handle, - cancellationTokenSourceHandle = source.handle, - revisionUid = revisionUid, - ), - bridge = this, - cancellationTokenSource = source, - ) + revisionUid: String, + timeout: Duration, +): Downloader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + factory(JniFileDownloader()) { + FileDownloader( + client = this@downloader, + handle = getFileDownloader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + revisionUid = revisionUid, + ), + bridge = this, + cancellationTokenSource = source, + ) + } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt index f00a3089..13a32533 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource @@ -11,9 +12,11 @@ import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniFileUploader import me.proton.drive.sdk.internal.JniUploadController +import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.toLogId import java.nio.channels.ReadableByteChannel import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration class FileUploader internal constructor( client: ProtonDriveClient, @@ -68,27 +71,41 @@ class FileUploader internal constructor( } suspend fun ProtonDriveClient.uploader( - request: FileUploaderRequest -): Uploader = cancellationTokenSource().let { source -> - JniFileUploader().run { - FileUploader( - client = this@uploader, - handle = getFileUploader(handle, source.handle, request), - bridge = this, - cancellationTokenSource = source, - ) + request: FileUploaderRequest, + timeout: Duration, +): Uploader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + JniFileUploader().run { + FileUploader( + client = this@uploader, + handle = getFileUploader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + request = request + ), + bridge = this, + cancellationTokenSource = source, + ) + } } } suspend fun ProtonDriveClient.uploader( - request: FileRevisionUploaderRequest -): Uploader = cancellationTokenSource().let { source -> - JniFileUploader().run { - FileUploader( - client = this@uploader, - handle = getFileRevisionUploader(handle, source.handle, request), - bridge = this, - cancellationTokenSource = source, - ) + request: FileRevisionUploaderRequest, + timeout: Duration, +): Uploader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + JniFileUploader().run { + FileUploader( + client = this@uploader, + handle = getFileRevisionUploader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + request = request + ), + bridge = this, + cancellationTokenSource = source, + ) + } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index 4c852ba2..16f0ed43 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource @@ -9,11 +10,14 @@ import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniPhotosDownloader +import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import java.nio.channels.SeekableByteChannel import java.nio.channels.WritableByteChannel import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class PhotosDownloader internal constructor( client: ProtonPhotosClient, @@ -25,13 +29,13 @@ class PhotosDownloader internal constructor( override suspend fun downloadToStream( coroutineScope: CoroutineScope, channel: WritableByteChannel, - ): DownloadController = cancellationTokenSource().let { cancellationTokenSource -> + ): DownloadController = cancellationTokenSource().let { source -> log(INFO, "downloadToStream") val coroutineScopeReference = AtomicReference(coroutineScope) val controllerReference = AtomicReference() val handle = bridge.downloadToStream( handle = handle, - cancellationTokenSourceHandle = cancellationTokenSource.handle, + cancellationTokenSourceHandle = source.handle, onWrite = channel::write, onSeek = if (channel is SeekableByteChannel) { channel::seek @@ -52,7 +56,7 @@ class PhotosDownloader internal constructor( bridge = JniDownloadController(), channel = channel, coroutineScopeConsumer = coroutineScopeReference::set, - cancellationTokenSource = cancellationTokenSource, + cancellationTokenSource = source, ).also(controllerReference::set) } @@ -73,18 +77,21 @@ class PhotosDownloader internal constructor( } suspend fun ProtonPhotosClient.downloader( - photoUid: String -): Downloader = cancellationTokenSource().let { source -> - factory(JniPhotosDownloader()) { - PhotosDownloader( - client = this@downloader, - handle = getPhotoDownloader( - clientHandle = handle, - cancellationTokenSourceHandle = source.handle, - photoUid = photoUid, - ), - bridge = this, - cancellationTokenSource = source, - ) + photoUid: String, + timeout: Duration, +): Downloader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + factory(JniPhotosDownloader()) { + PhotosDownloader( + client = this@downloader, + handle = getPhotoDownloader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + photoUid = photoUid, + ), + bridge = this, + cancellationTokenSource = source, + ) + } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt index aa57bf2b..c660cb0a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource @@ -10,9 +11,11 @@ import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniPhotosUploader import me.proton.drive.sdk.internal.JniUploadController +import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.toLogId import java.nio.channels.ReadableByteChannel import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration class PhotosUploader( client: ProtonPhotosClient, @@ -67,18 +70,21 @@ class PhotosUploader( } suspend fun ProtonPhotosClient.uploader( - request: PhotosUploaderRequest -): Uploader = cancellationTokenSource().let { source -> - JniPhotosUploader().run { - PhotosUploader( - client = this@uploader, - handle = getPhotoUploader( - clientHandle = handle, - cancellationTokenSourceHandle = source.handle, - request = request, - ), - bridge = this, - cancellationTokenSource = source, - ) + request: PhotosUploaderRequest, + timeout: Duration, +): Uploader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + JniPhotosUploader().run { + PhotosUploader( + client = this@uploader, + handle = getPhotoUploader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + request = request, + ), + bridge = this, + cancellationTokenSource = source, + ) + } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt index 4a763690..9b4202fa 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -16,6 +16,7 @@ import me.proton.drive.sdk.internal.JniLoggerProvider import me.proton.drive.sdk.internal.JniNativeLibrary import me.proton.drive.sdk.internal.JniSession import me.proton.drive.sdk.internal.ProtonDriveSdkNativeClient +import me.proton.drive.sdk.internal.cancellationCoroutineScope object ProtonDriveSdk { init { @@ -29,7 +30,7 @@ object ProtonDriveSdk { suspend fun sessionBegin( request: SessionBeginRequest, - ): Session = cancellationTokenSource().let { source -> + ): Session = cancellationCoroutineScope { source -> JniSession().run { clientLogger(DEBUG, "ProtonDriveSdk sessionBegin") Session(begin(source.handle, request), this, source) @@ -38,10 +39,14 @@ object ProtonDriveSdk { suspend fun sessionResume( request: SessionResumeRequest, - ): Session = cancellationTokenSource().let { source -> + ): Session = cancellationCoroutineScope { source -> JniSession().run { clientLogger(DEBUG, "ProtonDriveSdk sessionResume") - Session(resume(request), this, source) + Session( + handle = resume(request), + bridge = this, + cancellationTokenSource = source + ) } } From a8a2b7d25b8b7bafd53903ff9a9a9261d7b7e59b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Mar 2026 13:35:19 +0000 Subject: [PATCH 577/791] Update changelog for cs/v0.7.0-alpha.16 --- cs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index adbb8295..204ef6a2 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## cs/v0.7.0-alpha.16 (2026-03-04) + +* Ensure cancelled uploads/downloads don't block queue + ## cs/v0.7.0-alpha.15 (2026-03-03) * Fix registry not removing objects when the removeAll call happens from the owner's deinit From 29be03eec3c5a781d72a4463e63b116048b68151 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Mar 2026 06:21:18 +0000 Subject: [PATCH 578/791] Align telemetry with the web SDK --- .../DriveInteropTelemetryDecorator.cs | 3 +++ .../Nodes/Download/DownloadController.cs | 10 +++++++--- .../Nodes/Download/FileDownloader.cs | 10 ++++++++-- .../Nodes/Download/PhotosFileDownloader.cs | 10 ++++++++-- .../Nodes/Upload/FileUploader.cs | 6 ++++-- .../Nodes/Upload/UploadController.cs | 9 ++++++--- .../Telemetry/DownloadEvent.cs | 2 ++ .../Proton.Drive.Sdk/Telemetry/UploadEvent.cs | 2 ++ cs/sdk/src/protos/proton.drive.sdk.proto | 19 +++++++++++-------- .../sdk/extension/DownloadEventPayload.kt | 2 ++ .../drive/sdk/extension/UploadEventPayload.kt | 1 + .../drive/sdk/telemetry/DownloadEvent.kt | 2 ++ .../proton/drive/sdk/telemetry/UploadEvent.kt | 1 + .../TelemetryAndLogging/TelemetryTypes.swift | 16 ++++++++-------- 14 files changed, 65 insertions(+), 28 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index 95e0b31b..760328f2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -44,6 +44,7 @@ private static UploadEventPayload GetUploadEventPayload(UploadEvent me) UploadedSize = me.UploadedSize, ApproximateUploadedSize = me.ApproximateUploadedSize, ExpectedSize = me.ExpectedSize, + ApproximateExpectedSize = me.ApproximateExpectedSize, }; // Check if we should translate InteropErrorException when error is Unknown @@ -70,7 +71,9 @@ private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me) { VolumeType = (VolumeType)me.VolumeType, DownloadedSize = me.DownloadedSize, + ApproximateDownloadedSize = me.ApproximateDownloadedSize, ClaimedFileSize = me.ClaimedFileSize, + ApproximateClaimedFileSize = me.ApproximateClaimedFileSize, }; // Check if we should translate InteropErrorException when error is Unknown diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 1d87ae11..02818dbb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -8,7 +8,7 @@ public sealed class DownloadController : IAsyncDisposable private readonly Func _resumeFunction; private readonly ITaskControl _taskControl; private readonly Stream? _outputStreamToDispose; - private readonly Action? _onFailed; + private readonly Action? _onFailed; private readonly Action? _onSucceeded; private bool _isDownloadCompleteWithVerificationIssue; @@ -19,7 +19,7 @@ internal DownloadController( Func resumeFunction, Stream? outputStreamToDispose, ITaskControl taskControl, - Action? onFailed = null, + Action? onFailed = null, Action? onSucceeded = null) { _downloadStateTask = downloadStateTask; @@ -69,7 +69,11 @@ public async ValueTask DisposeAsync() if (Completion.IsFaulted) { - _onFailed?.Invoke(Completion.Exception.Flatten().InnerException ?? Completion.Exception); + var downloadState = await _downloadStateTask.ConfigureAwait(false); + _onFailed?.Invoke( + Completion.Exception.Flatten().InnerException ?? Completion.Exception, + downloadState.RevisionDto.Size, + downloadState.GetNumberOfBytesWritten()); } var stateExists = _downloadStateTask.IsCompletedSuccessfully; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index b3e8eac3..64d12a8a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -127,8 +127,13 @@ private DownloadController BuildDownloadController( OnFailed, OnSucceeded); - void OnFailed(Exception ex) + void OnFailed(Exception ex, long claimedFileSize, long downloadedByteCount) { + // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize + downloadEvent.ClaimedFileSize = claimedFileSize; + downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); + downloadEvent.DownloadedSize = downloadedByteCount; + downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); downloadEvent.OriginalError = ex; RaiseTelemetryEvent(downloadEvent); @@ -137,9 +142,10 @@ void OnFailed(Exception ex) void OnSucceeded(long claimedFileSize, long downloadedByteCount) { // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize + downloadEvent.ClaimedFileSize = claimedFileSize; + downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); downloadEvent.DownloadedSize = downloadedByteCount; downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); - downloadEvent.ClaimedFileSize = claimedFileSize; RaiseTelemetryEvent(downloadEvent); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index edcb4cdf..2b77526e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -121,8 +121,13 @@ private DownloadController DownloadToStream( OnFailed, OnSucceeded); - void OnFailed(Exception ex) + void OnFailed(Exception ex, long claimedFileSize, long downloadedByteCount) { + // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize + downloadEvent.ClaimedFileSize = claimedFileSize; + downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); + downloadEvent.DownloadedSize = downloadedByteCount; + downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); downloadEvent.OriginalError = ex; RaiseTelemetryEvent(downloadEvent); @@ -131,9 +136,10 @@ void OnFailed(Exception ex) void OnSucceeded(long claimedFileSize, long downloadedByteCount) { // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize + downloadEvent.ClaimedFileSize = claimedFileSize; + downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); downloadEvent.DownloadedSize = downloadedByteCount; downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); - downloadEvent.ClaimedFileSize = claimedFileSize; RaiseTelemetryEvent(downloadEvent); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index b5cfc370..d993997e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -125,6 +125,7 @@ private UploadController UploadFromStream( var uploadEvent = new UploadEvent { ExpectedSize = contentStream.Length, + ApproximateExpectedSize = Privacy.ReduceSizePrecision(contentStream.Length), UploadedSize = 0, ApproximateUploadedSize = 0, VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type @@ -148,13 +149,15 @@ private UploadController UploadFromStream( OnFailed, OnSucceeded); - void OnFailed(Exception ex) + void OnFailed(Exception ex, long uploadedByteCount) { if (ex is NodeWithSameNameExistsException) { return; } + uploadEvent.UploadedSize = uploadedByteCount; + uploadEvent.ApproximateUploadedSize = Privacy.ReduceSizePrecision(uploadedByteCount); uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); uploadEvent.OriginalError = ex; RaiseTelemetryEvent(uploadEvent); @@ -162,7 +165,6 @@ void OnFailed(Exception ex) void OnSucceeded(long uploadedByteCount) { - // TODO: deprecate UploadedSize in favor of ApproximateUploadedSize uploadEvent.UploadedSize = uploadedByteCount; uploadEvent.ApproximateUploadedSize = Privacy.ReduceSizePrecision(uploadedByteCount); RaiseTelemetryEvent(uploadEvent); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 44ef5cf1..7df3cecd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -8,7 +8,7 @@ public sealed class UploadController : IAsyncDisposable private readonly Func> _resumeFunction; private readonly ITaskControl _taskControl; private readonly Stream? _sourceStreamToDispose; - private readonly Action? _onFailed; + private readonly Action? _onFailed; private readonly Action? _onSucceeded; private bool _isDisposed; @@ -19,7 +19,7 @@ internal UploadController( Func> resumeFunction, Stream? sourceStreamToDispose, ITaskControl taskControl, - Action? onFailed = null, + Action? onFailed = null, Action? onSucceeded = null) { _revisionDraftTask = revisionDraftTask; @@ -71,7 +71,10 @@ public async ValueTask DisposeAsync() if (Completion.IsFaulted) { - _onFailed?.Invoke(Completion.Exception.Flatten().InnerException ?? Completion.Exception); + var revisionDraft = await _revisionDraftTask.ConfigureAwait(false); + _onFailed?.Invoke( + Completion.Exception.Flatten().InnerException ?? Completion.Exception, + revisionDraft.NumberOfPlainBytesDone); } var draftExists = _revisionDraftTask.IsCompletedSuccessfully; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs index a0f693e8..53084bd0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs @@ -15,6 +15,8 @@ public sealed class DownloadEvent : IMetricEvent public long ClaimedFileSize { get; set; } + public long ApproximateClaimedFileSize { get; set; } + public DownloadError? Error { get; set; } [JsonIgnore] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs index 515f110d..f1b5d696 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs @@ -15,6 +15,8 @@ public sealed class UploadEvent : IMetricEvent public required long ExpectedSize { get; set; } + public required long ApproximateExpectedSize { get; set; } + public UploadError? Error { get; set; } [JsonIgnore] diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index f6665e8f..3281c57f 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -754,18 +754,21 @@ enum EncryptedField { message UploadEventPayload { VolumeType volume_type = 1; int64 expected_size = 2; - int64 uploaded_size = 3; - int64 approximate_uploaded_size = 4; // To be used when exact size must not be communicated in order to prevent fingerprinting - UploadError error = 5; // Optional - string original_error = 6; // Optional + int64 approximate_expected_size = 3; // To be used when exact size must not be communicated in order to prevent fingerprinting + int64 uploaded_size = 4; + int64 approximate_uploaded_size = 5; // To be used when exact size must not be communicated in order to prevent fingerprinting + UploadError error = 6; // Optional + string original_error = 7; // Optional } message DownloadEventPayload { VolumeType volume_type = 1; - int64 claimed_file_size = 2; // -1 if unknown - int64 downloaded_size = 3; - DownloadError error = 4; // Optional - string original_error = 5; // Optional + int64 claimed_file_size = 2; + int64 approximate_claimed_file_size = 3; // To be used when exact size must not be communicated in order to prevent fingerprinting + int64 downloaded_size = 4; + int64 approximate_downloaded_size = 5; // To be used when exact size must not be communicated in order to prevent fingerprinting + DownloadError error = 6; // Optional + string original_error = 7; // Optional } message DecryptionErrorEventPayload { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt index dbba21d1..3507bd1c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt @@ -6,7 +6,9 @@ import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.DownloadEventPayload.toEvent() = DownloadEvent( volumeType = volumeType.toEnum(), claimedFileSize = claimedFileSize, + approximateClaimedFileSize = approximateClaimedFileSize, downloadedSize = downloadedSize, + approximateDownloadedSize = approximateDownloadedSize, error = takeIf { hasError() }?.error?.toEnum(), originalError = takeIf { hasOriginalError() }?.originalError, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt index 5947e175..94d71e13 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt @@ -6,6 +6,7 @@ import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.UploadEventPayload.toEvent() = UploadEvent( volumeType = volumeType.toEnum(), expectedSize = expectedSize, + approximateExpectedSize = approximateExpectedSize, uploadedSize = uploadedSize, approximateUploadedSize = approximateUploadedSize, error = takeIf { hasError() }?.error?.toEnum(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt index 6f0a9dcb..dc181c11 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt @@ -3,7 +3,9 @@ package me.proton.drive.sdk.telemetry data class DownloadEvent( val volumeType: VolumeType, val claimedFileSize: Long, + val approximateClaimedFileSize: Long, val downloadedSize: Long, + val approximateDownloadedSize: Long, val error: DownloadError? = null, val originalError: String? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt index 108ba07f..8ff7c086 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt @@ -3,6 +3,7 @@ package me.proton.drive.sdk.telemetry data class UploadEvent( val volumeType: VolumeType, val expectedSize: Long, + val approximateExpectedSize: Long, val uploadedSize: Long, val approximateUploadedSize: Long, val error: UploadError? = null, diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift index bc4c126b..356ac1b6 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -85,15 +85,15 @@ public struct DecryptionErrorEventPayload: Sendable { public struct DownloadEventPayload: Sendable { public let volumeType: VolumeType - public let claimedFileSize: Int64 - public let downloadedSize: Int64 + public let approximateClaimedFileSize: Int64 + public let approximateDownloadedSize: Int64 public let error: DownloadError? public let originalError: String? init(sdkDownloadEventPayload: Proton_Drive_Sdk_DownloadEventPayload) { self.volumeType = .init(sdkVolumeType: sdkDownloadEventPayload.volumeType) - self.claimedFileSize = sdkDownloadEventPayload.claimedFileSize - self.downloadedSize = sdkDownloadEventPayload.downloadedSize + self.approximateClaimedFileSize = sdkDownloadEventPayload.approximateClaimedFileSize + self.approximateDownloadedSize = sdkDownloadEventPayload.approximateDownloadedSize self.error = sdkDownloadEventPayload.hasError ? .init(sdkDownloadError: sdkDownloadEventPayload.error) : nil self.originalError = sdkDownloadEventPayload.hasOriginalError ? sdkDownloadEventPayload.originalError : nil } @@ -102,15 +102,15 @@ public struct DownloadEventPayload: Sendable { public struct UploadEventPayload: Sendable { public let volumeType: VolumeType - public let expectedSize: Int64 - public let uploadedSize: Int64 + public let approximateExpectedSize: Int64 + public let approximateUploadedSize: Int64 public let error: UploadError? public let originalError: String? init(sdkUploadEventPayload: Proton_Drive_Sdk_UploadEventPayload) { self.volumeType = .init(sdkVolumeType: sdkUploadEventPayload.volumeType) - self.expectedSize = sdkUploadEventPayload.expectedSize - self.uploadedSize = sdkUploadEventPayload.uploadedSize + self.approximateExpectedSize = sdkUploadEventPayload.approximateExpectedSize + self.approximateUploadedSize = sdkUploadEventPayload.approximateUploadedSize self.error = sdkUploadEventPayload.hasError ? .init(sdkUploadError: sdkUploadEventPayload.error) : nil self.originalError = sdkUploadEventPayload.hasOriginalError ? sdkUploadEventPayload.originalError : nil } From 55207bf3da65ee15e44f42dab348ac74e4b7761f Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Mar 2026 14:08:59 +0000 Subject: [PATCH 579/791] Implement small file upload endpoint --- js/sdk/src/crypto/driveCrypto.ts | 2 + js/sdk/src/interface/featureFlags.ts | 1 + js/sdk/src/interface/httpClient.ts | 1 + .../internal/apiService/apiService.test.ts | 30 ++ js/sdk/src/internal/apiService/apiService.ts | 30 +- js/sdk/src/internal/nodes/cryptoService.ts | 12 +- .../internal/nodes/extendedAttributes.test.ts | 50 +- .../src/internal/nodes/extendedAttributes.ts | 29 +- js/sdk/src/internal/nodes/interface.ts | 1 + js/sdk/src/internal/upload/apiService.ts | 163 ++++++- js/sdk/src/internal/upload/blockVerifier.ts | 12 + js/sdk/src/internal/upload/cryptoService.ts | 20 +- js/sdk/src/internal/upload/fileUploader.ts | 4 +- js/sdk/src/internal/upload/index.test.ts | 99 ++++ js/sdk/src/internal/upload/index.ts | 49 +- js/sdk/src/internal/upload/interface.ts | 2 + js/sdk/src/internal/upload/manager.test.ts | 299 +++++++++++- js/sdk/src/internal/upload/manager.ts | 298 ++++++++---- .../internal/upload/smallFileUploader.test.ts | 435 ++++++++++++++++++ .../src/internal/upload/smallFileUploader.ts | 341 ++++++++++++++ .../src/internal/upload/streamReader.test.ts | 109 +++++ js/sdk/src/internal/upload/streamReader.ts | 38 ++ js/sdk/src/internal/upload/streamUploader.ts | 2 +- js/sdk/src/internal/upload/telemetry.ts | 6 +- js/sdk/src/protonDrivePublicLinkClient.ts | 2 + 25 files changed, 1885 insertions(+), 150 deletions(-) create mode 100644 js/sdk/src/internal/upload/index.test.ts create mode 100644 js/sdk/src/internal/upload/smallFileUploader.test.ts create mode 100644 js/sdk/src/internal/upload/smallFileUploader.ts create mode 100644 js/sdk/src/internal/upload/streamReader.test.ts create mode 100644 js/sdk/src/internal/upload/streamReader.ts diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index bca05c90..220b566d 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -117,6 +117,7 @@ export class DriveCrypto { */ async generateContentKey(encryptionKey: PrivateKey): Promise<{ encrypted: { + contentKeyPacket: Uint8Array; base64ContentKeyPacket: string; armoredContentKeyPacketSignature: string; }; @@ -136,6 +137,7 @@ export class DriveCrypto { return { encrypted: { + contentKeyPacket: keyPacket, base64ContentKeyPacket: uint8ArrayToBase64String(keyPacket), armoredContentKeyPacketSignature, }, diff --git a/js/sdk/src/interface/featureFlags.ts b/js/sdk/src/interface/featureFlags.ts index 220dfe41..33e28e48 100644 --- a/js/sdk/src/interface/featureFlags.ts +++ b/js/sdk/src/interface/featureFlags.ts @@ -8,4 +8,5 @@ export interface FeatureFlagProvider { export enum FeatureFlags { DriveCryptoEncryptBlocksWithPgpAead = 'DriveCryptoEncryptBlocksWithPgpAead', + DriveSmallFileUpload = 'DriveSmallFileUpload', } diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts index 3c9af8dd..d7dab545 100644 --- a/js/sdk/src/interface/httpClient.ts +++ b/js/sdk/src/interface/httpClient.ts @@ -5,6 +5,7 @@ export interface ProtonDriveHTTPClient { export type ProtonDriveHTTPClientJsonRequest = ProtonDriveHTTPClientBaseRequest & { json?: object; + body?: XMLHttpRequestBodyInit; }; export type ProtonDriveHTTPClientBlobRequest = ProtonDriveHTTPClientBaseRequest & { diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 1508eebb..689573de 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -94,6 +94,36 @@ describe('DriveAPIService', () => { expect(telemetry.recordMetric).not.toHaveBeenCalled(); } + it('POST FormData request', async () => { + const formData = new FormData(); + formData.set('field', 'value'); + const result = await api.postFormData('test', formData); + expect(result).toEqual({ Code: ErrorCode.OK }); + await expectFetchFormDataToBeCalledWith(formData); + }); + + async function expectFetchFormDataToBeCalledWith(formData: FormData) { + // @ts-expect-error: Fetch is mock. + const request = httpClient.fetchJson.mock.calls[0][0]; + expect(request.method).toEqual('POST'); + expect(request.timeoutMs).toEqual(30000); + expect(request.body).toEqual(formData); + expect(request.json).toBeUndefined(); + // FormData must not have Content-Type set (runtime sets it with boundary) + expect(request.headers.has('Content-Type')).toBe(false); + expect(Array.from(request.headers.entries())).toEqual( + Array.from( + new Headers({ + Accept: 'application/vnd.protonmail.v1+json', + Language: 'en', + 'x-pm-drive-sdk-version': `js@${process.env.npm_package_version}`, + }).entries(), + ), + ); + expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + } + it('storage GET request', async () => { const stream = await api.getBlockStream('test', 'token'); const result = await Array.fromAsync(stream); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 64ddce74..1d87d33f 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -129,6 +129,14 @@ export class DriveAPIService { return this.makeRequest(url, 'POST', data, signal); } + async postFormData( + url: string, + formData: FormData, + signal?: AbortSignal, + ): Promise { + return this.makeRequest(url, 'POST', formData, signal); + } + async put( url: string, data: RequestPayload, @@ -151,16 +159,24 @@ export class DriveAPIService { data?: RequestPayload, signal?: AbortSignal, ): Promise { + const isJson = !(data instanceof FormData); + + const headers = new Headers({ + Accept: 'application/vnd.protonmail.v1+json', + Language: this.language, + 'x-pm-drive-sdk-version': `js@${VERSION}`, + }); + // FormData must not get a manual Content-Type: the runtime sets it with the boundary. + if (isJson) { + headers.set('Content-Type', 'application/json'); + } + const request = { url: `${this.baseUrl}/${url}`, method, - headers: new Headers({ - Accept: 'application/vnd.protonmail.v1+json', - 'Content-Type': 'application/json', - Language: this.language, - 'x-pm-drive-sdk-version': `js@${VERSION}`, - }), - json: data || undefined, + headers, + json: isJson && data ? data : undefined, + body: !isJson && data ? data : undefined, timeoutMs: DEFAULT_TIMEOUT_MS, signal, }; diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 593f5ba2..f5883cc3 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,6 +1,13 @@ import { c } from 'ttag'; -import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; +import { + base64StringToUint8Array, + DriveCrypto, + PrivateKey, + PublicKey, + SessionKey, + VERIFICATION_STATUS, +} from '../../crypto'; import { resultOk, resultError, @@ -201,7 +208,9 @@ export class NodesCryptoService { let activeRevision: Result | undefined; let contentKeyPacketSessionKey; let contentKeyPacketAuthor; + let contentKeyPacket: Uint8Array | undefined; if ('file' in node.encryptedCrypto) { + contentKeyPacket = base64StringToUint8Array(node.encryptedCrypto.file.base64ContentKeyPacket); const [activeRevisionPromise, contentKeyPacketSessionKeyPromise] = [ this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key), this.decryptContentKeyPacket(node, node.encryptedCrypto, key, keyVerificationKeys), @@ -284,6 +293,7 @@ export class NodesCryptoService { passphrase, key, passphraseSessionKey, + contentKeyPacket, contentKeyPacketSessionKey, hashKey, }, diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index 6936ab7c..3ac39eb0 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -51,19 +51,15 @@ describe('extended attrbiutes', () => { }); describe('should generate file attributes without additional metadata', () => { - const testCases: [object, string | undefined][] = [ - [{}, undefined], + const testCases: [any, string | undefined][] = [ [ - { modificationTime: new Date(1234567890000) }, - '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z"}}', + { size: 0, blockSizes: [], digests: { sha1: 'abcdef' } }, + '{"Common":{"Size":0,"BlockSizes":[],"Digests":{"SHA1":"abcdef"}}}', + ], + [ + { size: 1234, blockSizes: [1200, 34], digests: { sha1: 'gedcba' } }, + '{"Common":{"Size":1234,"BlockSizes":[1200,34],"Digests":{"SHA1":"gedcba"}}}', ], - [{ size: undefined }, undefined], - [{ size: 0 }, '{"Common":{"Size":0}}'], - [{ size: 1234 }, '{"Common":{"Size":1234}}'], - [{ blockSizes: [] }, undefined], - [{ blockSizes: [4, 4, 4, 2] }, '{"Common":{"BlockSizes":[4,4,4,2]}}'], - [{ digests: {} }, undefined], - [{ digests: { sha1: 'abcdef' } }, '{"Common":{"Digests":{"SHA1":"abcdef"}}}'], [ { modificationTime: new Date(1234567890000), @@ -75,7 +71,7 @@ describe('extended attrbiutes', () => { ], ]; testCases.forEach(([input, expectedAttributes]) => { - it(`should generate ${input}`, () => { + it(`should generate ${JSON.stringify(input)}`, () => { const output = generateFileExtendedAttributes(input); expect(output).toBe(expectedAttributes); }); @@ -83,24 +79,28 @@ describe('extended attrbiutes', () => { }); describe('should generate file attributes with additional metadata', () => { - const testCases: [object, string | undefined][] = [ - [{}, '{"Media":{"Width":100,"Height":100}}'], - [{ size: undefined }, '{"Media":{"Width":100,"Height":100}}'], - [{ size: 123 }, '{"Common":{"Size":123},"Media":{"Width":100,"Height":100}}'], - ]; - testCases.forEach(([input, expectedAttributes]) => { - it(`should generate ${input}`, () => { - const output = generateFileExtendedAttributes(input, { Media: { Width: 100, Height: 100 } }); - expect(output).toBe(expectedAttributes); - }); + const input = { + size: 1234, + blockSizes: [1200, 34], + digests: { sha1: 'abcdef' }, + }; + + it(`should generate ${JSON.stringify(input)}`, () => { + const output = generateFileExtendedAttributes(input, { Media: { Width: 100, Height: 100 } }); + expect(output).toBe( + '{"Common":{"Size":1234,"BlockSizes":[1200,34],"Digests":{"SHA1":"abcdef"}},"Media":{"Width":100,"Height":100}}', + ); }); }); describe('should throw an error if additional metadata contains common attributes', () => { it('should throw an error', () => { - expect(() => generateFileExtendedAttributes({ size: 123 }, { Common: { Hello: 'World' } })).toThrow( - 'Common attributes are not allowed in additional metadata', - ); + expect(() => + generateFileExtendedAttributes( + { size: 123, blockSizes: [], digests: { sha1: 'abcdef' } }, + { Common: { Hello: 'World' } }, + ), + ).toThrow('Common attributes are not allowed in additional metadata'); }); }); diff --git a/js/sdk/src/internal/nodes/extendedAttributes.ts b/js/sdk/src/internal/nodes/extendedAttributes.ts index af4cde34..13ea24ba 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -86,14 +86,14 @@ export function parseFolderExtendedAttributes(logger: Logger, extendedAttributes export function generateFileExtendedAttributes( common: { modificationTime?: Date; - size?: number; - blockSizes?: number[]; - digests?: { - sha1?: string; + size: number; + blockSizes: number[]; + digests: { + sha1: string; }; }, additionalMetadata?: object, -): string | undefined { +): string { if (additionalMetadata && 'Common' in additionalMetadata) { throw new Error('Common attributes are not allowed in additional metadata'); } @@ -102,20 +102,11 @@ export function generateFileExtendedAttributes( if (common.modificationTime) { commonAttributes.ModificationTime = dateToIsoString(common.modificationTime); } - if (common.size !== undefined) { - commonAttributes.Size = common.size; - } - if (common.blockSizes?.length) { - commonAttributes.BlockSizes = common.blockSizes; - } - if (common.digests?.sha1) { - commonAttributes.Digests = { - SHA1: common.digests.sha1, - }; - } - if (!Object.keys(commonAttributes).length && !additionalMetadata) { - return undefined; - } + commonAttributes.Size = common.size; + commonAttributes.BlockSizes = common.blockSizes; + commonAttributes.Digests = { + SHA1: common.digests.sha1, + }; return JSON.stringify({ ...(Object.keys(commonAttributes).length ? { Common: commonAttributes } : {}), ...(additionalMetadata ? { ...additionalMetadata } : {}), diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 5f6adf12..bdccc9b2 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -156,6 +156,7 @@ export interface DecryptedNodeKeys { passphrase: string; key: PrivateKey; passphraseSessionKey: SessionKey; + contentKeyPacket?: Uint8Array; contentKeyPacketSessionKey?: SessionKey; hashKey?: Uint8Array; } diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 012b48c8..22363f77 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -52,6 +52,26 @@ type PostLoadLinksMetadataRequest = Extract< type PostLoadLinksMetadataResponse = drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; +type PostSmallFileFormData = Extract< + Extract< + drivePaths['/drive/v2/volumes/{volumeID}/files/small']['post']['requestBody'], + { content: object } + >['content']['multipart/form-data'], + { Metadata: object } +>; +type PostSmallFileResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/small']['post']['responses']['200']['content']['application/json']; + +type PostSmallRevisionFormData = Extract< + Extract< + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small']['post']['requestBody'], + { content: object } + >['content']['multipart/form-data'], + { Metadata: object } +>; +type PostSmallRevisionResponse = + drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small']['post']['responses']['200']['content']['application/json']; + export class UploadAPIService { constructor( protected apiService: DriveAPIService, @@ -218,7 +238,7 @@ export class UploadAPIService { options: { armoredManifestSignature: string; signatureEmail: string | AnonymousUser; - armoredExtendedAttributes?: string; + armoredExtendedAttributes: string; }, ): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); @@ -229,7 +249,7 @@ export class UploadAPIService { >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, { ManifestSignature: options.armoredManifestSignature, SignatureAddress: options.signatureEmail, - XAttr: options.armoredExtendedAttributes || null, + XAttr: options.armoredExtendedAttributes, Photo: null, // Only used for photos in the Photo volume. }); } @@ -285,4 +305,143 @@ export class UploadAPIService { link.File?.ActiveRevision?.RevisionID === revisionId ); } + + async uploadSmallFile( + parentFolderUid: string, + metadata: { + armoredEncryptedName: string; + hash: string; + mediaType: string; + armoredNodeKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature: string; + armoredExtendedAttributes: string; + signatureEmail: string | AnonymousUser; + }, + content: { + armoredManifestSignature: string; + block: { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + }; + thumbnails: { + type: ThumbnailType; + encryptedData: Uint8Array; + }[]; + }, + signal?: AbortSignal, + ): Promise<{ nodeUid: string; nodeRevisionUid: string }> { + const { volumeId, nodeId: parentNodeId } = splitNodeUid(parentFolderUid); + + const metadataPayload: PostSmallFileFormData['Metadata'] = { + ParentLinkID: parentNodeId, + Name: metadata.armoredEncryptedName, + NameHash: metadata.hash, + NodePassphrase: metadata.armoredNodePassphrase, + NodePassphraseSignature: metadata.armoredNodePassphraseSignature, + SignatureEmail: metadata.signatureEmail, + NodeKey: metadata.armoredNodeKey, + MIMEType: metadata.mediaType, + ContentKeyPacket: metadata.base64ContentKeyPacket, + ContentKeyPacketSignature: metadata.armoredContentKeyPacketSignature, + ManifestSignature: content.armoredManifestSignature, + ContentBlockEncSignature: content.block.encryptedData.length > 0 ? content.block.armoredSignature : null, + ContentBlockVerificationToken: uint8ArrayToBase64String(content.block.verificationToken), + XAttr: metadata.armoredExtendedAttributes, + Photo: null, // TODO + }; + + const formData = new FormData(); + formData.append( + 'Metadata', + new Blob([JSON.stringify(metadataPayload)], { type: 'application/json' }), + 'Metadata', + ); + if (content.block.encryptedData.length > 0) { + formData.append('ContentBlock', new Blob([content.block.encryptedData]), 'ContentBlock'); + } + for (const thumb of content.thumbnails) { + formData.append( + `ThumbnailBlockType_${thumb.type}`, + new Blob([thumb.encryptedData]), + `ThumbnailBlockType_${thumb.type}`, + ); + } + + const result = await this.apiService.postFormData( + `drive/v2/volumes/${volumeId}/files/small`, + formData, + signal, + ); + + return { + nodeUid: makeNodeUid(volumeId, result.LinkID), + nodeRevisionUid: makeNodeRevisionUid(volumeId, result.LinkID, result.RevisionID), + }; + } + + async uploadSmallRevision( + nodeUid: string, + currentRevisionUid: string, + metadata: { + signatureEmail: string | AnonymousUser | null; + armoredExtendedAttributes: string; + }, + content: { + armoredManifestSignature: string; + block: { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + }; + thumbnails: { + type: ThumbnailType; + encryptedData: Uint8Array; + }[]; + }, + signal?: AbortSignal, + ): Promise<{ nodeUid: string; nodeRevisionUid: string }> { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const { revisionId: currentRevisionId } = splitNodeRevisionUid(currentRevisionUid); + + const metadataPayload: PostSmallRevisionFormData['Metadata'] = { + CurrentRevisionID: currentRevisionId, + SignatureEmail: metadata.signatureEmail, + ManifestSignature: content.armoredManifestSignature, + ContentBlockEncSignature: content.block.armoredSignature, + ContentBlockVerificationToken: uint8ArrayToBase64String(content.block.verificationToken), + XAttr: metadata.armoredExtendedAttributes, + }; + + const formData = new FormData(); + formData.append( + 'Metadata', + new Blob([JSON.stringify(metadataPayload)], { type: 'application/json' }), + 'Metadata', + ); + if (content.block.encryptedData.length > 0) { + formData.append('ContentBlock', new Blob([content.block.encryptedData]), 'ContentBlock'); + } + for (const thumb of content.thumbnails) { + formData.append( + `ThumbnailBlockType_${thumb.type}`, + new Blob([thumb.encryptedData]), + `ThumbnailBlockType_${thumb.type}`, + ); + } + + const result = await this.apiService.postFormData( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/small`, + formData, + signal, + ); + + return { + nodeUid: makeNodeUid(volumeId, result.LinkID), + nodeRevisionUid: makeNodeRevisionUid(volumeId, result.LinkID, result.RevisionID), + }; + } } diff --git a/js/sdk/src/internal/upload/blockVerifier.ts b/js/sdk/src/internal/upload/blockVerifier.ts index de620b2d..fc8f49b8 100644 --- a/js/sdk/src/internal/upload/blockVerifier.ts +++ b/js/sdk/src/internal/upload/blockVerifier.ts @@ -2,6 +2,18 @@ import { PrivateKey, SessionKey } from '../../crypto'; import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; +export async function verifyBlockWithContentKey( + cryptoService: UploadCryptoService, + contentKeyPacket: Uint8Array, + contentKeyPacketSessionKey: SessionKey, + encryptedBlock: Uint8Array, +): Promise<{ + verificationToken: Uint8Array; +}> { + const verificationCode = contentKeyPacket.subarray(-32); + return cryptoService.verifyBlock(contentKeyPacketSessionKey, verificationCode, encryptedBlock); +} + export class BlockVerifier { private verificationCode?: Uint8Array; private contentKeyPacketSessionKey?: SessionKey; diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index fe749ca5..c704635c 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -145,7 +145,9 @@ export class UploadCryptoService { } async encryptBlock( - verifyBlock: (encryptedBlock: Uint8Array) => Promise<{ verificationToken: Uint8Array }>, + verifyBlock: ( + encryptedBlock: Uint8Array, + ) => Promise<{ verificationToken: Uint8Array }>, nodeRevisionDraftKeys: NodeRevisionDraftKeys, block: Uint8Array, index: number, @@ -173,24 +175,22 @@ export class UploadCryptoService { async commitFile( nodeRevisionDraftKeys: NodeRevisionDraftKeys, manifest: Uint8Array, - extendedAttributes?: string, + extendedAttributes: string, ): Promise<{ armoredManifestSignature: string; signatureEmail: string | AnonymousUser; - armoredExtendedAttributes?: string; + armoredExtendedAttributes: string; }> { const { armoredManifestSignature } = await this.driveCrypto.signManifest( manifest, nodeRevisionDraftKeys.signingKeys.contentSigningKey, ); - const { armoredExtendedAttributes } = extendedAttributes - ? await this.driveCrypto.encryptExtendedAttributes( - extendedAttributes, - nodeRevisionDraftKeys.key, - nodeRevisionDraftKeys.signingKeys.contentSigningKey, - ) - : { armoredExtendedAttributes: undefined }; + const { armoredExtendedAttributes } = await this.driveCrypto.encryptExtendedAttributes( + extendedAttributes, + nodeRevisionDraftKeys.key, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, + ); return { armoredManifestSignature, diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 71fd25d9..e41a4197 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -13,9 +13,9 @@ import { UploadTelemetry } from './telemetry'; * and initiate the upload process for a file object or a stream. * * This class is not meant to be used directly, but rather to be extended - * by `FileUploader` and `FileRevisionUploader`. + * by `FileUploader`, `FileRevisionUploader`, or `SmallFileUploader`. */ -abstract class Uploader { +export abstract class Uploader { protected controller: UploadController; protected abortController: AbortController; diff --git a/js/sdk/src/internal/upload/index.test.ts b/js/sdk/src/internal/upload/index.test.ts new file mode 100644 index 00000000..6db24bd5 --- /dev/null +++ b/js/sdk/src/internal/upload/index.test.ts @@ -0,0 +1,99 @@ +import { FeatureFlagProvider, FeatureFlags, UploadMetadata } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { FileRevisionUploader, FileUploader } from './fileUploader'; +import { initUploadModule } from './index'; +import { SmallFileRevisionUploader, SmallFileUploader } from './smallFileUploader'; + +const SMALL_FILE_SIZE_LIMIT = 128 * 1024; // 128 KiB, must match index.ts + +describe('initUploadModule - uploader selection', () => { + const parentFolderUid = 'parent-folder-uid'; + const name = 'test-file.txt'; + const nodeUid = 'node-uid'; + + let featureFlagProvider: jest.Mocked; + let uploadModule: ReturnType; + + beforeEach(() => { + const apiService = {}; + const driveCrypto = {}; + const sharesService = {}; + const nodesService = {}; + featureFlagProvider = { + isEnabled: jest.fn().mockResolvedValue(true), + }; + + uploadModule = initUploadModule( + getMockTelemetry(), + apiService as any, + driveCrypto as any, + sharesService as any, + nodesService as any, + featureFlagProvider as any, + ); + }); + + describe('getFileUploader', () => { + it('returns SmallFileUploader when feature flag is enabled and file size is below limit', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(true); + + const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; + const uploader = await uploadModule.getFileUploader(parentFolderUid, name, metadata); + + expect(uploader).toBeInstanceOf(SmallFileUploader); + }); + + it('returns FileUploader when feature flag is enabled but file size exceeds limit', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(true); + + const metadata: UploadMetadata = { + expectedSize: SMALL_FILE_SIZE_LIMIT, + mediaType: 'text/plain', + }; + const uploader = await uploadModule.getFileUploader(parentFolderUid, name, metadata); + + expect(uploader).toBeInstanceOf(FileUploader); + }); + + it('returns FileUploader when feature flag is disabled even for small file', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(false); + + const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; + const uploader = await uploadModule.getFileUploader(parentFolderUid, name, metadata); + + expect(uploader).toBeInstanceOf(FileUploader); + }); + }); + + describe('getFileRevisionUploader', () => { + it('returns SmallFileRevisionUploader when feature flag is enabled and file size is below limit', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(true); + + const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; + const uploader = await uploadModule.getFileRevisionUploader(nodeUid, metadata); + + expect(uploader).toBeInstanceOf(SmallFileRevisionUploader); + }); + + it('returns FileRevisionUploader when feature flag is enabled but file size exceeds limit', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(true); + + const metadata: UploadMetadata = { + expectedSize: SMALL_FILE_SIZE_LIMIT + 1, + mediaType: 'text/plain', + }; + const uploader = await uploadModule.getFileRevisionUploader(nodeUid, metadata); + + expect(uploader).toBeInstanceOf(FileRevisionUploader); + }); + + it('returns FileRevisionUploader when feature flag is disabled even for small file', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(false); + + const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; + const uploader = await uploadModule.getFileRevisionUploader(nodeUid, metadata); + + expect(uploader).toBeInstanceOf(FileRevisionUploader); + }); + }); +}); diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 32b2a6fc..17d91356 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -1,14 +1,18 @@ -import { FeatureFlagProvider, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { FeatureFlagProvider, FeatureFlags, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import type { FileUploader } from '../../interface'; import { DriveAPIService } from '../apiService'; import { DriveCrypto } from '../../crypto'; import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; -import { FileUploader, FileRevisionUploader } from './fileUploader'; +import { FileUploader as FileUploaderClass, FileRevisionUploader } from './fileUploader'; import { NodesService, SharesService } from './interface'; import { UploadManager } from './manager'; import { UploadQueue } from './queue'; +import { SmallFileRevisionUploader, SmallFileUploader } from './smallFileUploader'; import { UploadTelemetry } from './telemetry'; +const SMALL_FILE_SIZE_LIMIT = 128 * 1024; // 128 KiB + /** * Provides facade for the upload module. * @@ -24,6 +28,7 @@ export function initUploadModule( nodesService: NodesService, featureFlagProvider: FeatureFlagProvider, clientUid?: string, + allowSmallFileUpload: boolean = true, ) { const api = new UploadAPIService(apiService, clientUid); const cryptoService = new UploadCryptoService(telemetry, driveCrypto, nodesService, featureFlagProvider); @@ -33,6 +38,15 @@ export function initUploadModule( const queue = new UploadQueue(); + async function useSmallFileUpload(metadata: UploadMetadata): Promise { + const isEnabled = + allowSmallFileUpload && (await featureFlagProvider.isEnabled(FeatureFlags.DriveSmallFileUpload)); + if (!isEnabled) { + return false; + } + return metadata.expectedSize < SMALL_FILE_SIZE_LIMIT; + } + /** * Returns a FileUploader instance that can be used to upload a file to * a parent folder. @@ -52,7 +66,21 @@ export function initUploadModule( queue.releaseCapacity(metadata.expectedSize); }; - return new FileUploader( + if (await useSmallFileUpload(metadata)) { + return new SmallFileUploader( + uploadTelemetry, + api, + cryptoService, + manager, + metadata, + onFinish, + signal, + parentFolderUid, + name, + ); + } + + return new FileUploaderClass( uploadTelemetry, api, cryptoService, @@ -76,13 +104,26 @@ export function initUploadModule( nodeUid: string, metadata: UploadMetadata, signal?: AbortSignal, - ): Promise { + ): Promise { await queue.waitForCapacity(metadata.expectedSize, signal); const onFinish = () => { queue.releaseCapacity(metadata.expectedSize); }; + if (await useSmallFileUpload(metadata)) { + return new SmallFileRevisionUploader( + uploadTelemetry, + api, + cryptoService, + manager, + metadata, + onFinish, + signal, + nodeUid, + ); + } + return new FileRevisionUploader( uploadTelemetry, api, diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index deec35e9..f92c79bb 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -40,6 +40,7 @@ export type NodeCrypto = { }; contentKey: { encrypted: { + contentKeyPacket: Uint8Array; base64ContentKeyPacket: string; armoredContentKeyPacketSignature: string; }; @@ -100,6 +101,7 @@ export interface NodesService { getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey; passphraseSessionKey: SessionKey; + contentKeyPacket?: Uint8Array; contentKeyPacketSessionKey?: SessionKey; hashKey?: Uint8Array; }>; diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index e480ab18..596b3012 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -1,5 +1,5 @@ import { ValidationError } from '../../errors'; -import { ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { ProtonDriveTelemetry, ThumbnailType, UploadMetadata } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; import { ErrorCode } from '../apiService'; import { UploadAPIService } from './apiService'; @@ -27,6 +27,14 @@ describe('UploadManager', () => { }), deleteDraft: jest.fn(), commitDraftRevision: jest.fn(), + uploadSmallFile: jest.fn().mockResolvedValue({ + nodeUid: 'uploaded:nodeUid', + nodeRevisionUid: 'uploaded:nodeRevisionUid', + }), + uploadSmallRevision: jest.fn().mockResolvedValue({ + nodeUid: 'revised:nodeUid', + nodeRevisionUid: 'revised:nodeRevisionUid', + }), }; // @ts-expect-error No need to implement all methods for mocking cryptoService = { @@ -59,6 +67,12 @@ describe('UploadManager', () => { signatureEmail: 'signatureEmail', armoredExtendedAttributes: 'newNode:armoredExtendedAttributes', }), + getSigningKeysForExistingNode: jest.fn().mockResolvedValue({ + email: 'signatureEmail', + addressId: 'addressId', + nameAndPassphraseSigningKey: {} as any, + contentSigningKey: {} as any, + }), }; nodesService = { getNode: jest.fn(async (nodeUid: string) => ({ @@ -263,6 +277,289 @@ describe('UploadManager', () => { }); }); + describe('generateNewFileCrypto', () => { + it('should throw when parent is not a folder (no hashKey)', async () => { + nodesService.getNodeKeys = jest.fn().mockResolvedValue({ hashKey: undefined }); + + const result = manager.generateNewFileCrypto('parentUid', 'fileName'); + + await expect(result).rejects.toThrow('Creating files in non-folders is not allowed'); + expect(nodesService.getNodeKeys).toHaveBeenCalledWith('parentUid'); + expect(cryptoService.generateFileCrypto).not.toHaveBeenCalled(); + }); + + it('should return generated crypto with parentHashKey when parent is folder', async () => { + const result = await manager.generateNewFileCrypto('parentUid', 'fileName'); + + expect(nodesService.getNodeKeys).toHaveBeenCalledWith('parentUid'); + expect(cryptoService.generateFileCrypto).toHaveBeenCalledWith( + 'parentUid', + { key: 'parentNode:nodekey', hashKey: 'parentNode:hashKey' }, + 'fileName', + ); + expect(result).toMatchObject({ + parentHashKey: 'parentNode:hashKey', + encryptedNode: { encryptedName: 'newNode:encryptedName', hash: 'newNode:hash' }, + nodeKeys: expect.anything(), + contentKey: expect.anything(), + signingKeys: { email: 'signatureEmail' }, + }); + }); + }); + + describe('getExistingFileNodeCrypto', () => { + it('should throw when node has no active revision', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + uid: 'fileNodeUid', + parentUid: 'parentUid', + activeRevision: { ok: false, error: new Error('No revision') }, + }); + + const result = manager.getExistingFileNodeCrypto('fileNodeUid'); + + await expect(result).rejects.toThrow('Creating revisions in non-files is not allowed'); + }); + + it('should throw when nodeKeys has no contentKeyPacketSessionKey', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + uid: 'fileNodeUid', + parentUid: 'parentUid', + activeRevision: { ok: true, value: { uid: 'revisionUid' } }, + }); + nodesService.getNodeKeys = jest.fn().mockResolvedValue({ + key: 'nodeKey', + contentKeyPacket: new Uint8Array([1, 2, 3]), + hashKey: 'hashKey', + }); + + const result = manager.getExistingFileNodeCrypto('fileNodeUid'); + + await expect(result).rejects.toThrow('Creating revisions in non-files is not allowed'); + }); + + it('should throw when nodeKeys has no contentKeyPacket', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + uid: 'fileNodeUid', + parentUid: 'parentUid', + activeRevision: { ok: true, value: { uid: 'revisionUid' } }, + }); + nodesService.getNodeKeys = jest.fn().mockResolvedValue({ + key: 'nodeKey', + contentKeyPacketSessionKey: 'sessionKey', + hashKey: 'hashKey', + }); + + const result = manager.getExistingFileNodeCrypto('fileNodeUid'); + + await expect(result).rejects.toThrow('Content key packet is required for small revision upload'); + }); + + it('should return key, contentKeyPacket, contentKeyPacketSessionKey and signingKeys', async () => { + const contentKeyPacket = new Uint8Array([1, 2, 3]); + nodesService.getNode = jest.fn().mockResolvedValue({ + uid: 'fileNodeUid', + parentUid: 'parentUid', + activeRevision: { ok: true, value: { uid: 'revisionUid' } }, + }); + nodesService.getNodeKeys = jest.fn().mockResolvedValue({ + key: 'nodeKey', + contentKeyPacket, + contentKeyPacketSessionKey: 'sessionKey', + hashKey: 'hashKey', + }); + + const result = await manager.getExistingFileNodeCrypto('fileNodeUid'); + + expect(cryptoService.getSigningKeysForExistingNode).toHaveBeenCalledWith({ + nodeUid: 'fileNodeUid', + parentNodeUid: 'parentUid', + }); + expect(result).toEqual({ + key: 'nodeKey', + contentKeyPacket, + contentKeyPacketSessionKey: 'sessionKey', + signingKeys: { + email: 'signatureEmail', + addressId: 'addressId', + nameAndPassphraseSigningKey: {}, + contentSigningKey: {}, + }, + }); + }); + }); + + describe('uploadFile', () => { + const nodeCrypto = { + encryptedNode: { encryptedName: 'encName', hash: 'hash' }, + nodeKeys: { + encrypted: { + armoredKey: 'armoredKey', + armoredPassphrase: 'armoredPassphrase', + armoredPassphraseSignature: 'armoredPassphraseSignature', + }, + }, + contentKey: { + encrypted: { + base64ContentKeyPacket: 'base64ContentKeyPacket', + armoredContentKeyPacketSignature: 'armoredContentKeyPacketSignature', + }, + }, + signingKeys: { email: 'signatureEmail' }, + } as any; + const metadata = { mediaType: 'application/octet-stream', expectedSize: 100 } as UploadMetadata; + const commitPayload = { + armoredManifestSignature: 'manifestSignature', + armoredExtendedAttributes: 'extAttr', + }; + const encryptedBlock = { + encryptedData: new Uint8Array([1, 2, 3]), + armoredSignature: 'blockSig', + verificationToken: new Uint8Array([4, 5, 6]), + }; + const encryptedThumbnails = [{ type: ThumbnailType.Type1, encryptedData: new Uint8Array([7, 8, 9]) }]; + + it('should call uploadSmallFile and notifyChildCreated on success', async () => { + const result = await manager.uploadFile( + 'parentUid', + nodeCrypto, + metadata, + commitPayload, + encryptedBlock, + encryptedThumbnails, + ); + + expect(result).toEqual({ + nodeUid: 'uploaded:nodeUid', + nodeRevisionUid: 'uploaded:nodeRevisionUid', + }); + expect(apiService.uploadSmallFile).toHaveBeenCalledWith( + 'parentUid', + { + armoredEncryptedName: 'encName', + hash: 'hash', + mediaType: 'application/octet-stream', + armoredNodeKey: 'armoredKey', + armoredNodePassphrase: 'armoredPassphrase', + armoredNodePassphraseSignature: 'armoredPassphraseSignature', + base64ContentKeyPacket: 'base64ContentKeyPacket', + armoredContentKeyPacketSignature: 'armoredContentKeyPacketSignature', + armoredExtendedAttributes: 'extAttr', + signatureEmail: 'signatureEmail', + }, + { + armoredManifestSignature: 'manifestSignature', + block: encryptedBlock, + thumbnails: encryptedThumbnails, + }, + undefined, + ); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid'); + }); + + it('should delete existing draft and retry on ALREADY_EXISTS when own draft', async () => { + let firstCall = true; + apiService.uploadSmallFile = jest.fn().mockImplementation(() => { + if (firstCall) { + firstCall = false; + throw new ValidationError('Already exists', ErrorCode.ALREADY_EXISTS, { + ConflictLinkID: 'existingLinkId', + ConflictDraftRevisionID: 'existingDraftRevisionId', + ConflictDraftClientUID: clientUid, + }); + } + return { + nodeUid: 'uploaded:nodeUid', + nodeRevisionUid: 'uploaded:nodeRevisionUid', + }; + }); + + const result = await manager.uploadFile( + 'volumeId~parentUid', + nodeCrypto, + { ...metadata, overrideExistingDraftByOtherClient: false }, + commitPayload, + encryptedBlock, + encryptedThumbnails, + ); + + expect(result).toEqual({ + nodeUid: 'uploaded:nodeUid', + nodeRevisionUid: 'uploaded:nodeRevisionUid', + }); + expect(apiService.deleteDraft).toHaveBeenCalledWith('volumeId~existingLinkId'); + expect(apiService.uploadSmallFile).toHaveBeenCalledTimes(2); + }); + }); + + describe('uploadSmallRevision', () => { + const nodeCrypto = { signingKeys: { email: 'signatureEmail' } } as any; + const commitPayload = { + armoredManifestSignature: 'manifestSig', + armoredExtendedAttributes: 'extAttr', + }; + const encryptedBlock = { + encryptedData: new Uint8Array([1, 2, 3]), + armoredSignature: 'blockSig', + verificationToken: new Uint8Array([4, 5, 6]), + }; + const encryptedThumbnails = [{ type: ThumbnailType.Type1, encryptedData: new Uint8Array([7, 8, 9]) }]; + + it('should throw when file has no revision', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + uid: 'fileNodeUid', + parentUid: 'parentUid', + activeRevision: { ok: false, error: new Error('No revision') }, + }); + + const result = manager.uploadSmallRevision( + 'fileNodeUid', + nodeCrypto, + commitPayload, + encryptedBlock, + encryptedThumbnails, + ); + + await expect(result).rejects.toThrow('File has no revision'); + expect(apiService.uploadSmallRevision).not.toHaveBeenCalled(); + }); + + it('should call uploadSmallRevision and notifyNodeChanged on success', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + uid: 'fileNodeUid', + parentUid: 'parentUid', + activeRevision: { ok: true, value: { uid: 'currentRevisionUid' } }, + }); + + const result = await manager.uploadSmallRevision( + 'fileNodeUid', + nodeCrypto, + commitPayload, + encryptedBlock, + encryptedThumbnails, + ); + + expect(result).toEqual({ + nodeUid: 'revised:nodeUid', + nodeRevisionUid: 'revised:nodeRevisionUid', + }); + expect(apiService.uploadSmallRevision).toHaveBeenCalledWith( + 'fileNodeUid', + 'currentRevisionUid', + { + signatureEmail: 'signatureEmail', + armoredExtendedAttributes: 'extAttr', + }, + { + armoredManifestSignature: 'manifestSig', + block: encryptedBlock, + thumbnails: encryptedThumbnails, + }, + undefined, + ); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('fileNodeUid'); + }); + }); + describe('commit draft', () => { const nodeRevisionDraft = { nodeUid: 'newNode:nodeUid', diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 17dee253..63cf5216 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -1,6 +1,7 @@ import { c } from 'ttag'; -import { Logger, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { PrivateKey, SessionKey } from '../../crypto'; +import { Logger, ProtonDriveTelemetry, ThumbnailType, UploadMetadata } from '../../interface'; import { ValidationError, NodeWithSameNameExistsValidationError } from '../../errors'; import { ErrorCode } from '../apiService'; import { generateFileExtendedAttributes } from '../nodes'; @@ -32,20 +33,11 @@ export class UploadManager { } async createDraftNode(parentFolderUid: string, name: string, metadata: UploadMetadata): Promise { - const parentKeys = await this.nodesService.getNodeKeys(parentFolderUid); - if (!parentKeys.hashKey) { - throw new ValidationError(c('Error').t`Creating files in non-folders is not allowed`); - } - - const generatedNodeCrypto = await this.cryptoService.generateFileCrypto( - parentFolderUid, - { key: parentKeys.key, hashKey: parentKeys.hashKey }, - name, - ); + const { parentHashKey, ...generatedNodeCrypto } = await this.generateNewFileCrypto(parentFolderUid, name); const { nodeUid, nodeRevisionUid } = await this.createDraftOnAPI( parentFolderUid, - parentKeys.hashKey, + parentHashKey, name, metadata, generatedNodeCrypto, @@ -60,7 +52,7 @@ export class UploadManager { signingKeys: generatedNodeCrypto.signingKeys, }, parentNodeKeys: { - hashKey: parentKeys.hashKey, + hashKey: parentHashKey, }, newNodeInfo: { parentUid: parentFolderUid, @@ -71,6 +63,57 @@ export class UploadManager { }; } + async generateNewFileCrypto( + parentFolderUid: string, + name: string, + ): Promise }> { + const parentKeys = await this.nodesService.getNodeKeys(parentFolderUid); + if (!parentKeys.hashKey) { + throw new ValidationError(c('Error').t`Creating files in non-folders is not allowed`); + } + + const generatedNodeCrypto = await this.cryptoService.generateFileCrypto( + parentFolderUid, + { key: parentKeys.key, hashKey: parentKeys.hashKey }, + name, + ); + + return { + ...generatedNodeCrypto, + parentHashKey: parentKeys.hashKey, + }; + } + + async getExistingFileNodeCrypto(nodeUid: string): Promise<{ + key: PrivateKey; + contentKeyPacket: Uint8Array; + contentKeyPacketSessionKey: SessionKey; + signingKeys: NodeCrypto['signingKeys']; + }> { + const node = await this.nodesService.getNode(nodeUid); + const nodeKeys = await this.nodesService.getNodeKeys(nodeUid); + + if (!node.activeRevision?.ok || !nodeKeys.contentKeyPacketSessionKey) { + throw new ValidationError(c('Error').t`Creating revisions in non-files is not allowed`); + } + + if (!nodeKeys.contentKeyPacket) { + throw new ValidationError(c('Error').t`Content key packet is required for small revision upload`); + } + + const signingKeys = await this.cryptoService.getSigningKeysForExistingNode({ + nodeUid, + parentNodeUid: node.parentUid, + }); + + return { + key: nodeKeys.key, + contentKeyPacket: nodeKeys.contentKeyPacket, + contentKeyPacketSessionKey: nodeKeys.contentKeyPacketSessionKey, + signingKeys, + }; + } + private async createDraftOnAPI( parentFolderUid: string, parentHashKey: Uint8Array, @@ -97,80 +140,181 @@ export class UploadManager { }); return result; } catch (error: unknown) { - if (error instanceof ValidationError) { - if (error.code === ErrorCode.ALREADY_EXISTS) { - this.logger.info(`Node with given name already exists`); - - const typedDetails = error.details as - | { - ConflictLinkID: string; - ConflictRevisionID?: string; - ConflictDraftRevisionID?: string; - ConflictDraftClientUID?: string; - } - | undefined; - - // If the client doesn't specify the client UID, it should - // never be considered own draft. - const isOwnDraftConflict = - typedDetails?.ConflictDraftRevisionID && - this.clientUid && - typedDetails?.ConflictDraftClientUID === this.clientUid; - - // If there is existing draft created by this client, - // automatically delete it and try to create a new one - // with the same name again. - if ( - typedDetails?.ConflictDraftRevisionID && - (isOwnDraftConflict || metadata.overrideExistingDraftByOtherClient) - ) { - const existingDraftNodeUid = makeNodeUid( - splitNodeUid(parentFolderUid).volumeId, - typedDetails.ConflictLinkID, - ); + return this.handleConflictError(parentFolderUid, metadata, error, async () => { + return this.createDraftOnAPI(parentFolderUid, parentHashKey, name, metadata, generatedNodeCrypto); + }); + } + } - let deleteFailed = false; - try { - this.logger.warn( - `Deleting existing draft node ${existingDraftNodeUid} by ${typedDetails.ConflictDraftClientUID}`, - ); - await this.apiService.deleteDraft(existingDraftNodeUid); - } catch (deleteDraftError: unknown) { - // Do not throw, let throw the conflict error. - deleteFailed = true; - this.logger.error('Failed to delete existing draft node', deleteDraftError); - } - if (!deleteFailed) { - return this.createDraftOnAPI( - parentFolderUid, - parentHashKey, - name, - metadata, - generatedNodeCrypto, - ); - } - } + async uploadFile( + parentFolderUid: string, + nodeCrypto: NodeCrypto, + metadata: UploadMetadata, + commitPayload: { + armoredManifestSignature: string; + armoredExtendedAttributes: string; + }, + encryptedBlock: { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + }, + encryptedThumbnails: { type: ThumbnailType; encryptedData: Uint8Array }[], + signal?: AbortSignal, + ): Promise<{ nodeUid: string; nodeRevisionUid: string }> { + try { + const result = await this.apiService.uploadSmallFile( + parentFolderUid, + { + armoredEncryptedName: nodeCrypto.encryptedNode.encryptedName, + hash: nodeCrypto.encryptedNode.hash, + mediaType: metadata.mediaType ?? 'application/octet-stream', + armoredNodeKey: nodeCrypto.nodeKeys.encrypted.armoredKey, + armoredNodePassphrase: nodeCrypto.nodeKeys.encrypted.armoredPassphrase, + armoredNodePassphraseSignature: nodeCrypto.nodeKeys.encrypted.armoredPassphraseSignature, + base64ContentKeyPacket: nodeCrypto.contentKey.encrypted.base64ContentKeyPacket, + armoredContentKeyPacketSignature: nodeCrypto.contentKey.encrypted.armoredContentKeyPacketSignature, + armoredExtendedAttributes: commitPayload.armoredExtendedAttributes, + signatureEmail: nodeCrypto.signingKeys.email ?? null, + }, + { + armoredManifestSignature: commitPayload.armoredManifestSignature, + block: { + encryptedData: encryptedBlock.encryptedData, + armoredSignature: encryptedBlock.armoredSignature, + verificationToken: encryptedBlock.verificationToken, + }, + thumbnails: encryptedThumbnails, + }, + signal, + ); + await this.nodesService.notifyChildCreated(parentFolderUid); + return result; + } catch (error: unknown) { + return this.handleConflictError(parentFolderUid, metadata, error, async () => { + return this.uploadFile( + parentFolderUid, + nodeCrypto, + metadata, + commitPayload, + encryptedBlock, + encryptedThumbnails, + signal, + ); + }); + } + } - if (isOwnDraftConflict) { + async uploadSmallRevision( + nodeUid: string, + nodeCrypto: Pick, + commitPayload: { + armoredManifestSignature: string; + armoredExtendedAttributes: string; + }, + encryptedBlock: { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + }, + encryptedThumbnails: { type: ThumbnailType; encryptedData: Uint8Array }[], + signal?: AbortSignal, + ): Promise<{ nodeUid: string; nodeRevisionUid: string }> { + const node = await this.nodesService.getNode(nodeUid); + if (!node.activeRevision?.ok) { + throw new ValidationError(c('Error').t`File has no revision`); + } + const result = await this.apiService.uploadSmallRevision( + nodeUid, + node.activeRevision.value.uid, + { + signatureEmail: nodeCrypto.signingKeys.email ?? null, + armoredExtendedAttributes: commitPayload.armoredExtendedAttributes, + }, + { + armoredManifestSignature: commitPayload.armoredManifestSignature, + block: encryptedBlock, + thumbnails: encryptedThumbnails, + }, + signal, + ); + await this.nodesService.notifyNodeChanged(nodeUid); + return result; + } + + private async handleConflictError( + parentFolderUid: string, + metadata: UploadMetadata, + error: unknown, + onRetryAfterDraftDeleted: () => Promise<{ nodeUid: string; nodeRevisionUid: string }>, + ): Promise<{ nodeUid: string; nodeRevisionUid: string }> { + if (error instanceof ValidationError) { + if (error.code === ErrorCode.ALREADY_EXISTS) { + this.logger.info(`Node with given name already exists`); + + const typedDetails = error.details as + | { + ConflictLinkID: string; + ConflictRevisionID?: string; + ConflictDraftRevisionID?: string; + ConflictDraftClientUID?: string; + } + | undefined; + + // If the client doesn't specify the client UID, it should + // never be considered own draft. + const isOwnDraftConflict = + typedDetails?.ConflictDraftRevisionID && + this.clientUid && + typedDetails?.ConflictDraftClientUID === this.clientUid; + + // If there is existing draft created by this client, + // automatically delete it and try to create a new one + // with the same name again. + if ( + typedDetails?.ConflictDraftRevisionID && + (isOwnDraftConflict || metadata.overrideExistingDraftByOtherClient) + ) { + const existingDraftNodeUid = makeNodeUid( + splitNodeUid(parentFolderUid).volumeId, + typedDetails.ConflictLinkID, + ); + + let deleteFailed = false; + try { this.logger.warn( - `Existing draft conflict by another client ${typedDetails.ConflictDraftClientUID}`, + `Deleting existing draft node ${existingDraftNodeUid} by ${typedDetails.ConflictDraftClientUID}`, ); + await this.apiService.deleteDraft(existingDraftNodeUid); + } catch (deleteDraftError: unknown) { + // Do not throw, let throw the conflict error. + deleteFailed = true; + this.logger.error('Failed to delete existing draft node', deleteDraftError); } + if (!deleteFailed) { + return onRetryAfterDraftDeleted(); + } + } - const existingNodeUid = typedDetails - ? makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID) - : undefined; - - throw new NodeWithSameNameExistsValidationError( - error.message, - error.code, - existingNodeUid, - !!typedDetails?.ConflictDraftRevisionID, + if (isOwnDraftConflict) { + this.logger.warn( + `Existing draft conflict by another client ${typedDetails.ConflictDraftClientUID}`, ); } + + const existingNodeUid = typedDetails + ? makeNodeUid(splitNodeUid(parentFolderUid).volumeId, typedDetails.ConflictLinkID) + : undefined; + + throw new NodeWithSameNameExistsValidationError( + error.message, + error.code, + existingNodeUid, + !!typedDetails?.ConflictDraftRevisionID, + ); } - throw error; } + throw error; } async deleteDraftNode(nodeUid: string): Promise { diff --git a/js/sdk/src/internal/upload/smallFileUploader.test.ts b/js/sdk/src/internal/upload/smallFileUploader.test.ts new file mode 100644 index 00000000..1e4eb24e --- /dev/null +++ b/js/sdk/src/internal/upload/smallFileUploader.test.ts @@ -0,0 +1,435 @@ +import { IntegrityError } from '../../errors'; +import { Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; +import { SmallFileUploader, SmallFileRevisionUploader } from './smallFileUploader'; +import { UploadTelemetry } from './telemetry'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { UploadManager } from './manager'; +import { NodeCrypto } from './interface'; + +const MOCK_BLOCK_HASH = new Uint8Array(32).fill(1); +const MOCK_VERIFICATION_TOKEN = new Uint8Array(16).fill(2); + +function createStream(bytes: number[]): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(bytes)); + controller.close(); + }, + }); +} + +function mockEncryptBlock( + verifyBlock: (block: Uint8Array) => Promise<{ verificationToken: Uint8Array }>, + _nodeKeys: unknown, + block: Uint8Array, + _index: number, +) { + const encryptedData = new Uint8Array(block); + return (async () => { + await verifyBlock(encryptedData); + return { + index: 0, + encryptedData, + armoredSignature: 'mockBlockSignature', + verificationToken: MOCK_VERIFICATION_TOKEN, + originalSize: block.length, + encryptedSize: block.length + 100, + hash: 'blockHash', + hashPromise: Promise.resolve(MOCK_BLOCK_HASH), + }; + })(); +} + +describe('SmallFileUploader', () => { + let telemetry: UploadTelemetry; + let apiService: jest.Mocked; + let cryptoService: jest.Mocked; + let uploadManager: jest.Mocked; + let metadata: UploadMetadata; + let onFinish: jest.Mock; + let abortController: AbortController; + + const parentFolderUid = 'parentFolderUid'; + const name = 'test-file.txt'; + + const mockNodeCrypto = { + nodeKeys: { + decrypted: { key: {} as any }, + encrypted: { + armoredKey: 'armoredKey', + armoredPassphrase: 'armoredPassphrase', + armoredPassphraseSignature: 'armoredPassphraseSignature', + }, + }, + contentKey: { + encrypted: { + contentKeyPacket: new Uint8Array(10), + base64ContentKeyPacket: 'base64ContentKeyPacket', + armoredContentKeyPacketSignature: 'armoredContentKeyPacketSignature', + }, + decrypted: { contentKeyPacketSessionKey: {} as any }, + }, + encryptedNode: { + encryptedName: 'encryptedName', + hash: 'hash', + }, + signingKeys: { email: 'test@test.com', addressId: 'addr', nameAndPassphraseSigningKey: {} as any, contentSigningKey: {} as any }, + } as NodeCrypto & { parentHashKey?: Uint8Array }; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + telemetry = { + getLoggerForRevision: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + getLoggerForSmallUpload: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + logBlockVerificationError: jest.fn(), + uploadFailed: jest.fn(), + uploadFinished: jest.fn(), + uploadInitFailed: jest.fn(), + }; + + // @ts-expect-error No need to implement all methods for mocking + apiService = {}; + + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + encryptThumbnail: jest.fn().mockImplementation(async (_nodeKeys, thumbnail: Thumbnail) => ({ + type: thumbnail.type, + encryptedData: new Uint8Array(thumbnail.thumbnail), + originalSize: thumbnail.thumbnail.length, + encryptedSize: thumbnail.thumbnail.length + 100, + hash: 'thumbnailHash', + })), + encryptBlock: jest.fn().mockImplementation(mockEncryptBlock), + verifyBlock: jest.fn().mockResolvedValue({ verificationToken: MOCK_VERIFICATION_TOKEN }), + commitFile: jest.fn().mockResolvedValue({ + armoredManifestSignature: 'mockManifestSignature', + armoredExtendedAttributes: 'mockExtendedAttributes', + }), + }; + + uploadManager = { + generateNewFileCrypto: jest.fn().mockResolvedValue(mockNodeCrypto), + uploadFile: jest.fn().mockResolvedValue({ + nodeUid: 'nodeUid', + nodeRevisionUid: 'nodeRevisionUid', + }), + } as unknown as jest.Mocked; + + metadata = { + expectedSize: 3, + mediaType: 'application/octet-stream', + } as UploadMetadata; + + onFinish = jest.fn(); + abortController = new AbortController(); + }); + + function createUploader() { + return new SmallFileUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + metadata, + onFinish, + abortController.signal, + parentFolderUid, + name, + ); + } + + describe('uploadFromStream', () => { + const thumbnails: Thumbnail[] = []; + const onProgress = jest.fn(); + + it('should start upload and call manager.generateNewFileCrypto and manager.uploadFile', async () => { + const uploader = createUploader(); + const stream = createStream([1, 2, 3]); + + const controller = await uploader.uploadFromStream(stream, thumbnails, onProgress); + const result = await controller.completion(); + + expect(uploadManager.generateNewFileCrypto).toHaveBeenCalledWith(parentFolderUid, name); + expect(uploadManager.uploadFile).toHaveBeenCalledTimes(1); + expect(result).toEqual({ nodeUid: 'nodeUid', nodeRevisionUid: 'nodeRevisionUid' }); + expect(onProgress).toHaveBeenCalledWith(metadata.expectedSize); + }); + + it('should throw if upload already started', async () => { + const uploader = createUploader(); + const stream = createStream([1, 2, 3]); + + await uploader.uploadFromStream(stream, thumbnails, onProgress); + await expect(uploader.uploadFromStream(stream, thumbnails, onProgress)).rejects.toThrow( + 'Upload already started', + ); + }); + }); + + describe('buildPayloads (via upload flow)', () => { + it('should build commitPayload, encryptedBlock, and encryptedThumbnails from stream and pass to manager.uploadFile', async () => { + const uploader = createUploader(); + const stream = createStream([1, 2, 3]); + const thumbnails: Thumbnail[] = [ + { type: ThumbnailType.Type1, thumbnail: new Uint8Array([10, 20]) }, + { type: ThumbnailType.Type2, thumbnail: new Uint8Array([30, 40, 50]) }, + ]; + + await uploader.uploadFromStream(stream, thumbnails, undefined); + await (uploader as any).controller.completion(); + + expect(uploadManager.uploadFile).toHaveBeenCalledWith( + parentFolderUid, + mockNodeCrypto, + metadata, + expect.objectContaining({ + armoredManifestSignature: 'mockManifestSignature', + armoredExtendedAttributes: 'mockExtendedAttributes', + }), + expect.objectContaining({ + encryptedData: expect.any(Uint8Array), + armoredSignature: 'mockBlockSignature', + verificationToken: MOCK_VERIFICATION_TOKEN, + }), + [ + { type: ThumbnailType.Type1, encryptedData: expect.any(Uint8Array) }, + { type: ThumbnailType.Type2, encryptedData: expect.any(Uint8Array) }, + ], + ); + + expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(1); + expect(cryptoService.encryptThumbnail).toHaveBeenCalledTimes(2); + expect(cryptoService.commitFile).toHaveBeenCalledWith( + expect.anything(), + MOCK_BLOCK_HASH, + expect.any(String), + ); + }); + + it('should pass encrypted block data matching stream content to crypto.encryptBlock', async () => { + const uploader = createUploader(); + const content = [5, 6, 7, 8, 9]; + metadata.expectedSize = content.length; + const stream = createStream(content); + + await uploader.uploadFromStream(stream, [], undefined); + await (uploader as any).controller.completion(); + + expect(cryptoService.encryptBlock).toHaveBeenCalledWith( + expect.any(Function), + expect.anything(), + new Uint8Array(content), + 0, + ); + }); + + it('should pass each thumbnail to crypto.encryptThumbnail with nodeKeys', async () => { + const uploader = createUploader(); + const thumbnails: Thumbnail[] = [ + { type: ThumbnailType.Type1, thumbnail: new Uint8Array([1]) }, + ]; + const stream = createStream([1, 2, 3]); + + await uploader.uploadFromStream(stream, thumbnails, undefined); + await (uploader as any).controller.completion(); + + expect(cryptoService.encryptThumbnail).toHaveBeenCalledWith( + expect.objectContaining({ + key: mockNodeCrypto.nodeKeys.decrypted.key, + contentKeyPacket: mockNodeCrypto.contentKey.encrypted.contentKeyPacket, + contentKeyPacketSessionKey: mockNodeCrypto.contentKey.decrypted.contentKeyPacketSessionKey, + signingKeys: mockNodeCrypto.signingKeys, + }), + { type: ThumbnailType.Type1, thumbnail: new Uint8Array([1]) }, + ); + }); + + it('should call commitFile with manifest and extended attributes', async () => { + const uploader = createUploader(); + const stream = createStream([1, 2, 3]); + + await uploader.uploadFromStream(stream, [], undefined); + await (uploader as any).controller.completion(); + + const [nodeKeys, manifest, extendedAttributes] = (cryptoService.commitFile as jest.Mock).mock.calls[0]; + expect(manifest).toEqual(MOCK_BLOCK_HASH); + expect(extendedAttributes).toBeDefined(); + expect(nodeKeys).toBeDefined(); + }); + }); + + describe('stream integrity', () => { + it('should throw IntegrityError when stream size does not match expectedSize', async () => { + const uploader = createUploader(); + metadata.expectedSize = 5; + const stream = createStream([1, 2, 3]); // only 3 bytes + + const controller = await uploader.uploadFromStream(stream, [], undefined); + + await expect(controller.completion()).rejects.toThrow(IntegrityError); + await expect(controller.completion()).rejects.toMatchObject({ + debug: { actual: 3, expected: 5 }, + }); + }); + + it('should throw IntegrityError when stream sha1 does not match expectedSha1', async () => { + const uploader = createUploader(); + metadata.expectedSha1 = 'a'.repeat(40); // wrong sha1 + const stream = createStream([1, 2, 3]); + + const controller = await uploader.uploadFromStream(stream, [], undefined); + + await expect(controller.completion()).rejects.toThrow(IntegrityError); + await expect(controller.completion()).rejects.toMatchObject({ + debug: expect.objectContaining({ + expectedSha1: 'a'.repeat(40), + }), + }); + }); + }); +}); + +describe('SmallFileRevisionUploader', () => { + let telemetry: UploadTelemetry; + let apiService: jest.Mocked; + let cryptoService: jest.Mocked; + let uploadManager: jest.Mocked; + let metadata: UploadMetadata; + let onFinish: jest.Mock; + let abortController: AbortController; + + const nodeUid = 'nodeUid'; + + const mockNodeKeys = { + key: {} as any, + contentKeyPacket: new Uint8Array(10), + contentKeyPacketSessionKey: {} as any, + signingKeys: { email: 'test@test.com', addressId: 'addr', nameAndPassphraseSigningKey: {} as any, contentSigningKey: {} as any }, + }; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + telemetry = { + getLoggerForRevision: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + getLoggerForSmallUpload: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + logBlockVerificationError: jest.fn(), + uploadFailed: jest.fn(), + uploadFinished: jest.fn(), + uploadInitFailed: jest.fn(), + }; + + // @ts-expect-error No need to implement all methods for mocking + apiService = {}; + + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + encryptThumbnail: jest.fn().mockImplementation(async (_nodeKeys, thumbnail: Thumbnail) => ({ + type: thumbnail.type, + encryptedData: new Uint8Array(thumbnail.thumbnail), + originalSize: thumbnail.thumbnail.length, + encryptedSize: thumbnail.thumbnail.length + 100, + hash: 'thumbnailHash', + })), + encryptBlock: jest.fn().mockImplementation( + async ( + verifyBlock: (b: Uint8Array) => Promise<{ verificationToken: Uint8Array }>, + _: unknown, + block: Uint8Array, + ) => { + await verifyBlock(block); + return { + index: 0, + encryptedData: block, + armoredSignature: 'mockBlockSignature', + verificationToken: MOCK_VERIFICATION_TOKEN, + originalSize: block.length, + encryptedSize: block.length + 100, + hash: 'blockHash', + hashPromise: Promise.resolve(MOCK_BLOCK_HASH), + }; + }, + ), + verifyBlock: jest.fn().mockResolvedValue({ verificationToken: MOCK_VERIFICATION_TOKEN }), + commitFile: jest.fn().mockResolvedValue({ + armoredManifestSignature: 'mockManifestSignature', + armoredExtendedAttributes: 'mockExtendedAttributes', + }), + }; + + uploadManager = { + getExistingFileNodeCrypto: jest.fn().mockResolvedValue(mockNodeKeys), + uploadSmallRevision: jest.fn().mockResolvedValue({ + nodeUid: 'nodeUid', + nodeRevisionUid: 'nodeRevisionUid', + }), + } as unknown as jest.Mocked; + + metadata = { + expectedSize: 3, + mediaType: 'application/octet-stream', + } as UploadMetadata; + + onFinish = jest.fn(); + abortController = new AbortController(); + }); + + function createUploader() { + return new SmallFileRevisionUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + metadata, + onFinish, + abortController.signal, + nodeUid, + ); + } + + it('should get node crypto, build payloads, and call uploadSmallRevision', async () => { + const uploader = createUploader(); + const stream = createStream([1, 2, 3]); + + const controller = await uploader.uploadFromStream(stream, [], undefined); + const result = await controller.completion(); + + expect(result).toEqual({ nodeUid: 'nodeUid', nodeRevisionUid: 'nodeRevisionUid' }); + expect(cryptoService.encryptBlock).toHaveBeenCalledWith(expect.any(Function), expect.anything(), Uint8Array.from([1, 2, 3]), 0); + expect(uploadManager.getExistingFileNodeCrypto).toHaveBeenCalledWith(nodeUid); + expect(uploadManager.uploadSmallRevision).toHaveBeenCalledWith( + nodeUid, + mockNodeKeys, + expect.objectContaining({ + armoredManifestSignature: 'mockManifestSignature', + armoredExtendedAttributes: 'mockExtendedAttributes', + }), + expect.objectContaining({ + encryptedData: expect.any(Uint8Array), + armoredSignature: 'mockBlockSignature', + verificationToken: MOCK_VERIFICATION_TOKEN, + }), + [], + ); + }); +}); diff --git a/js/sdk/src/internal/upload/smallFileUploader.ts b/js/sdk/src/internal/upload/smallFileUploader.ts new file mode 100644 index 00000000..128bc7cd --- /dev/null +++ b/js/sdk/src/internal/upload/smallFileUploader.ts @@ -0,0 +1,341 @@ +import { PrivateKey, SessionKey } from '../../crypto'; +import { AbortError, IntegrityError } from '../../errors'; +import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; +import { getErrorMessage } from '../errors'; +import { generateFileExtendedAttributes } from '../nodes'; +import { UploadAPIService } from './apiService'; +import { BlockVerifier, verifyBlockWithContentKey } from './blockVerifier'; +import { UploadCryptoService } from './cryptoService'; +import { UploadDigests } from './digests'; +import { Uploader } from './fileUploader'; +import { NodeRevisionDraft, NodeCrypto } from './interface'; +import { UploadManager } from './manager'; +import { readStreamToUint8Array } from './streamReader'; +import { MAX_BLOCK_ENCRYPTION_RETRIES } from './streamUploader'; +import { UploadTelemetry } from './telemetry'; + +export type NodeKeys = { + key: PrivateKey; + contentKeyPacket: Uint8Array; + contentKeyPacketSessionKey: SessionKey; + signingKeys: NodeCrypto['signingKeys']; +}; + +/** + * Base uploader for small file and small revision uploads. + * Shares the single-request flow: read content, get node crypto, encrypt, then call API. + */ +abstract class SmallUploader extends Uploader { + protected logger: Logger; + + constructor( + telemetry: UploadTelemetry, + apiService: UploadAPIService, + cryptoService: UploadCryptoService, + manager: UploadManager, + metadata: UploadMetadata, + onFinish: () => void, + signal: AbortSignal | undefined, + ) { + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); + this.logger = telemetry.getLoggerForSmallUpload(); + } + protected async createRevisionDraft(): Promise<{ + revisionDraft: NodeRevisionDraft; + blockVerifier: BlockVerifier; + }> { + throw new Error('Small upload does not use revision draft'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise { + throw new Error('Small upload does not use revision draft'); + } + + protected async startUpload( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise<{ nodeRevisionUid: string; nodeUid: string }> { + try { + const result = await this.handleUpload(stream, thumbnails); + + onProgress?.(this.metadata.expectedSize); + void this.telemetry.uploadFinished(result.nodeRevisionUid, this.metadata.expectedSize); + return result; + } catch (error) { + void this.telemetry.uploadInitFailed(this.getTelemetryContextUid(), error, this.metadata.expectedSize); + throw error; + } finally { + this.onFinish(); + } + } + + protected abstract getTelemetryContextUid(): string; + + protected abstract handleUpload( + stream: ReadableStream, + thumbnails: Thumbnail[], + ): Promise<{ + nodeUid: string; + nodeRevisionUid: string; + }>; + + protected async buildPayloads( + nodeKeys: NodeKeys, + stream: ReadableStream, + thumbnails: Thumbnail[], + ): Promise<{ + commitPayload: { + armoredManifestSignature: string; + armoredExtendedAttributes: string; + }; + encryptedBlock: { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + }; + encryptedThumbnails: { type: ThumbnailType; encryptedData: Uint8Array }[]; + }> { + const content = await this.readStreamContent(stream); + + const [encryptedThumbnails, encryptedBlock] = await Promise.all([ + this.encryptThumbnails(nodeKeys, thumbnails), + this.encryptContentBlock(nodeKeys, content.data), + ]); + const commitPayload = await this.encryptCommitPayload(nodeKeys, content.sha1, encryptedBlock); + + return { + commitPayload, + encryptedBlock, + encryptedThumbnails, + }; + } + + private async readStreamContent(stream: ReadableStream): Promise<{ + data: Uint8Array; + sha1: string; + }> { + const content = await readStreamToUint8Array(stream, this.abortController.signal); + + if (content.length !== this.metadata.expectedSize) { + throw new IntegrityError(new Error('Stream size does not match expected size').message, { + actual: content.length, + expected: this.metadata.expectedSize, + }); + } + + const digests = new UploadDigests(); + digests.update(content); + const contentSha1 = digests.digests().sha1; + + if (this.metadata.expectedSha1 && contentSha1 !== this.metadata.expectedSha1) { + throw new IntegrityError(new Error('File hash does not match expected hash').message, { + uploadedSha1: contentSha1, + expectedSha1: this.metadata.expectedSha1, + }); + } + + return { + data: content, + sha1: contentSha1, + }; + } + + private async encryptThumbnails( + nodeKeys: NodeKeys, + thumbnails: Thumbnail[], + ): Promise<{ type: ThumbnailType; encryptedData: Uint8Array }[]> { + const result = []; + for (const thumbnail of thumbnails) { + this.logger.debug(`Encrypting thumbnail ${thumbnail.type}`); + const enc = await this.cryptoService.encryptThumbnail(nodeKeys, thumbnail); + result.push({ type: thumbnail.type, encryptedData: enc.encryptedData }); + } + return result; + } + + private async encryptContentBlock( + nodeKeys: NodeKeys, + content: Uint8Array, + ): Promise<{ + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + blockHash: Uint8Array; + }> { + this.logger.debug(`Encrypting block`); + let attempt = 0; + let integrityError = false; + let encrypted; + while (!encrypted) { + attempt++; + try { + encrypted = await this.cryptoService.encryptBlock( + (encryptedBlock) => + verifyBlockWithContentKey( + this.cryptoService, + nodeKeys.contentKeyPacket, + nodeKeys.contentKeyPacketSessionKey, + encryptedBlock, + ), + nodeKeys, + content, + 0, + ); + if (integrityError) { + void this.telemetry.logBlockVerificationError(true); + } + } catch (error: unknown) { + // Do not retry or report anything if the upload was aborted. + if (error instanceof AbortError) { + throw error; + } + + if (error instanceof IntegrityError) { + integrityError = true; + } + + if (attempt <= MAX_BLOCK_ENCRYPTION_RETRIES) { + this.logger.warn(`Block encryption failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } + + this.logger.error(`Failed to encrypt block`, error); + if (integrityError) { + void this.telemetry.logBlockVerificationError(false); + } + throw error; + } + } + + const blockHash = await encrypted.hashPromise; + return { + encryptedData: encrypted.encryptedData, + armoredSignature: encrypted.armoredSignature, + verificationToken: encrypted.verificationToken, + blockHash, + }; + } + + private async encryptCommitPayload( + nodeKeys: NodeKeys, + contentSha1: string, + encryptedBlock: { + blockHash: Uint8Array; + }, + ): Promise<{ + armoredManifestSignature: string; + armoredExtendedAttributes: string; + }> { + this.logger.debug(`Preparing commit payload`); + + const manifest = encryptedBlock.blockHash ? new Uint8Array(encryptedBlock.blockHash) : new Uint8Array(0); + const extendedAttributes = generateFileExtendedAttributes( + { + modificationTime: this.metadata.modificationTime, + size: this.metadata.expectedSize, + blockSizes: this.metadata.expectedSize > 0 ? [this.metadata.expectedSize] : [], + digests: { sha1: contentSha1 }, + }, + this.metadata.additionalMetadata, + ); + const commitCrypto = await this.cryptoService.commitFile(nodeKeys, manifest, extendedAttributes); + return { + armoredManifestSignature: commitCrypto.armoredManifestSignature, + armoredExtendedAttributes: commitCrypto.armoredExtendedAttributes, + }; + } +} + +/** + * Uploader for small new files using the single-request small file endpoint. + */ +export class SmallFileUploader extends SmallUploader { + constructor( + telemetry: UploadTelemetry, + apiService: UploadAPIService, + cryptoService: UploadCryptoService, + manager: UploadManager, + metadata: UploadMetadata, + onFinish: () => void, + signal: AbortSignal | undefined, + private parentFolderUid: string, + private name: string, + ) { + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); + this.parentFolderUid = parentFolderUid; + this.name = name; + } + + protected getTelemetryContextUid(): string { + return this.parentFolderUid; + } + + protected async handleUpload( + stream: ReadableStream, + thumbnails: Thumbnail[], + ): Promise<{ + nodeUid: string; + nodeRevisionUid: string; + }> { + const nodeCrypto = await this.manager.generateNewFileCrypto(this.parentFolderUid, this.name); + const nodeKeys = { + key: nodeCrypto.nodeKeys.decrypted.key, + contentKeyPacket: nodeCrypto.contentKey.encrypted.contentKeyPacket, + contentKeyPacketSessionKey: nodeCrypto.contentKey.decrypted.contentKeyPacketSessionKey, + signingKeys: nodeCrypto.signingKeys, + }; + const payloads = await this.buildPayloads(nodeKeys, stream, thumbnails); + return this.manager.uploadFile( + this.parentFolderUid, + nodeCrypto, + this.metadata, + payloads.commitPayload, + payloads.encryptedBlock, + payloads.encryptedThumbnails, + ); + } +} + +/** + * Uploader for small new revisions using the single-request small revision endpoint. + * Reuses the existing file's keys. + */ +export class SmallFileRevisionUploader extends SmallUploader { + constructor( + telemetry: UploadTelemetry, + apiService: UploadAPIService, + cryptoService: UploadCryptoService, + manager: UploadManager, + metadata: UploadMetadata, + onFinish: () => void, + signal: AbortSignal | undefined, + private nodeUid: string, + ) { + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); + this.nodeUid = nodeUid; + } + + protected getTelemetryContextUid(): string { + return this.nodeUid; + } + + protected async handleUpload( + stream: ReadableStream, + thumbnails: Thumbnail[], + ): Promise<{ + nodeUid: string; + nodeRevisionUid: string; + }> { + const nodeKeys = await this.manager.getExistingFileNodeCrypto(this.nodeUid); + const payloads = await this.buildPayloads(nodeKeys, stream, thumbnails); + return this.manager.uploadSmallRevision( + this.nodeUid, + nodeKeys, + payloads.commitPayload, + payloads.encryptedBlock, + payloads.encryptedThumbnails, + ); + } +} diff --git a/js/sdk/src/internal/upload/streamReader.test.ts b/js/sdk/src/internal/upload/streamReader.test.ts new file mode 100644 index 00000000..d987a930 --- /dev/null +++ b/js/sdk/src/internal/upload/streamReader.test.ts @@ -0,0 +1,109 @@ +import { AbortError } from '../../errors'; +import { readStreamToUint8Array } from './streamReader'; + +describe('readStreamToUint8Array', () => { + it('should return empty Uint8Array for empty stream', async () => { + const stream = new ReadableStream>({ + start(controller) { + controller.close(); + }, + }); + + const result = await readStreamToUint8Array(stream); + + expect(result).toEqual(new Uint8Array([])); + expect(result.length).toBe(0); + }); + + it('should read single chunk into Uint8Array', async () => { + const stream = new ReadableStream>({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + + const result = await readStreamToUint8Array(stream); + + expect(result).toEqual(new Uint8Array([1, 2, 3])); + expect(result.length).toBe(3); + }); + + it('should concatenate multiple chunks into single Uint8Array', async () => { + const stream = new ReadableStream>({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.enqueue(new Uint8Array([4, 5, 6])); + controller.enqueue(new Uint8Array([7, 8, 9])); + controller.close(); + }, + }); + + const result = await readStreamToUint8Array(stream); + + expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9])); + expect(result.length).toBe(9); + }); + + it('should work without abort signal', async () => { + const stream = new ReadableStream>({ + start(controller) { + controller.enqueue(new Uint8Array([42])); + controller.close(); + }, + }); + + const result = await readStreamToUint8Array(stream); + + expect(result).toEqual(new Uint8Array([42])); + }); + + it('should throw AbortError when signal is aborted during read', async () => { + const controller = new AbortController(); + const stream = new ReadableStream>({ + start(streamController) { + streamController.enqueue(new Uint8Array([1, 2, 3])); + setTimeout(() => { + streamController.enqueue(new Uint8Array([4, 5, 6])); + streamController.close(); + }, 50); + }, + }); + + setTimeout(() => controller.abort(), 10); + + await expect(readStreamToUint8Array(stream, controller.signal)).rejects.toThrow(AbortError); + }); + + it('should throw AbortError when signal is already aborted before read', async () => { + const controller = new AbortController(); + controller.abort(); + + const stream = new ReadableStream>({ + start(streamController) { + streamController.enqueue(new Uint8Array([1, 2, 3])); + streamController.close(); + }, + }); + + await expect(readStreamToUint8Array(stream, controller.signal)).rejects.toThrow(AbortError); + }); + + it('should release reader lock so stream can be consumed once', async () => { + const stream = new ReadableStream>({ + start(controller) { + controller.enqueue(new Uint8Array([1])); + controller.close(); + }, + }); + + const result = await readStreamToUint8Array(stream); + + expect(result).toEqual(new Uint8Array([1])); + + const reader = stream.getReader(); + const { done } = await reader.read(); + expect(done).toBe(true); + reader.releaseLock(); + }); +}); diff --git a/js/sdk/src/internal/upload/streamReader.ts b/js/sdk/src/internal/upload/streamReader.ts new file mode 100644 index 00000000..5615af8c --- /dev/null +++ b/js/sdk/src/internal/upload/streamReader.ts @@ -0,0 +1,38 @@ +import { AbortError } from "../../errors"; + +/** + * Reads a ReadableStream into a Uint8Array. + */ +export async function readStreamToUint8Array( + stream: ReadableStream>, + signal?: AbortSignal, +): Promise> { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let totalLength = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (signal?.aborted) { + throw new AbortError(); + } + const chunk = value; + totalLength += chunk.length; + chunks.push(chunk); + } + + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; + } finally { + reader.releaseLock(); + } +} diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 0b2c355b..80a5721d 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -39,7 +39,7 @@ const MAX_UPLOADING_BLOCKS = 5; * This is to automatically retry random errors that can happen * during encryption, for example bitflips. */ -const MAX_BLOCK_ENCRYPTION_RETRIES = 1; +export const MAX_BLOCK_ENCRYPTION_RETRIES = 1; /** * Maximum number of retries for block upload. diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 0ece4d6f..ea4d8457 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -6,7 +6,7 @@ import { splitNodeUid, splitNodeRevisionUid } from '../uids'; import { SharesService } from './interface'; export class UploadTelemetry { - private logger: Logger; + readonly logger: Logger; constructor( private telemetry: ProtonDriveTelemetry, @@ -17,6 +17,10 @@ export class UploadTelemetry { this.sharesService = sharesService; } + getLoggerForSmallUpload() { + return new LoggerWithPrefix(this.logger, `small upload`); + } + getLoggerForRevision(revisionUid: string) { return new LoggerWithPrefix(this.logger, `revision ${revisionUid}`); } diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 46d1b161..81a0e557 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -174,6 +174,8 @@ export class ProtonDrivePublicLinkClient { this.sharingPublic.nodes.access, featureFlagProvider, fullConfig.clientUid, + // Public links do not support small file upload. + false, ); this.experimental = { From b497d7787cd3ac37413b8d5ee02d270a062f8687 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 6 Mar 2026 15:21:56 +0000 Subject: [PATCH 580/791] Log failed attempts to report decryption errors to telemetry --- cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 2b999dad..3f723023 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; @@ -702,9 +703,9 @@ private static async Task ReportDecryptionError( }); } } - catch + catch (Exception e) { - // Do nothing + client.Telemetry.GetLogger("Metric").LogWarning(e, "Failed to record metric for decryption event"); } } } From d0a9bf3ae88630b2acfc076f7ee68fe6d628bf66 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 6 Mar 2026 15:47:19 +0000 Subject: [PATCH 581/791] Add context traversal for photo nodes and set telemetry volume type --- .../Nodes/DegradedPhotoNode.cs | 2 + .../Nodes/Download/FileDownloader.cs | 7 +- .../Nodes/DtoToMetadataConverter.cs | 66 +++------ .../src/Proton.Drive.Sdk/Nodes/PhotoNode.cs | 2 + .../Nodes/TraversalOperations.cs | 19 ++- .../Nodes/Upload/FileUploader.cs | 15 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 29 +++- .../Telemetry/TelemetryEventFactory.cs | 130 ++++++++++++++++++ .../Telemetry/TelemetryRecorder.cs | 88 ++++++++++++ 9 files changed, 290 insertions(+), 68 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs index dd15edd4..5608a4d4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs @@ -3,4 +3,6 @@ public sealed record DegradedPhotoNode : DegradedFileNode { public required DateTime CaptureTime { get; init; } + + public required IReadOnlyList AlbumUids { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 64d12a8a..1800d8df 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -106,11 +106,8 @@ private DownloadController BuildDownloadController( var downloadStateTaskCompletionSource = new TaskCompletionSource(); - var downloadEvent = new DownloadEvent - { - DownloadedSize = 0, - VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type - }; + var downloadEvent = TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken) + .ConfigureAwait(false).GetAwaiter().GetResult(); var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( contentOutputStream, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 3f723023..9268b0f7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; @@ -23,18 +22,18 @@ public static async Task> ConvertDtoT ShareAndKey? knownShareAndKey, CancellationToken cancellationToken) { - var parentId = linkDetailsDto.Link.ParentId; + var sourceNodeEntryPointId = linkDetailsDto.Link.ParentId; - if (parentId is null && linkDetailsDto.Photo is not null) + if (sourceNodeEntryPointId is null && linkDetailsDto.Photo is not null) { // TODO: optimize by selecting the album that is in cache, if any - parentId = linkDetailsDto.Photo.AlbumInclusions is { Count: > 0 } albumInclusions ? albumInclusions[0].Id : null; + sourceNodeEntryPointId = linkDetailsDto.Photo.AlbumInclusions is { Count: > 0 } albumInclusions ? albumInclusions[0].Id : null; } - var parentKeyResult = await GetParentKeyAsync( + var entryPointKeyResult = await GetEntryPointKeyAsync( client, volumeId, - parentId, + sourceNodeEntryPointId, knownShareAndKey, linkDetailsDto.Sharing?.ShareId, cancellationToken).ConfigureAwait(false); @@ -45,7 +44,7 @@ public static async Task> ConvertDtoT client.Cache.Secrets, volumeId, linkDetailsDto, - parentKeyResult, + entryPointKeyResult, cancellationToken).ConfigureAwait(false); } @@ -225,8 +224,11 @@ public static async Task> ConvertDtoT await entityCache.SetNodeAsync(uid, degradedFileMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) .ConfigureAwait(false); - await ReportDecryptionError(client, DegradedNodeMetadata.FromFile(degradedFileMetadata), failedDecryptionFields, cancellationToken) - .ConfigureAwait(false); + await TelemetryRecorder.TryRecordDecryptionErrorAsync( + client, + DegradedNodeMetadata.FromFile(degradedFileMetadata), + failedDecryptionFields, + cancellationToken).ConfigureAwait(false); return degradedFileMetadata; } @@ -273,6 +275,7 @@ await ReportDecryptionError(client, DegradedNodeMetadata.FromFile(degradedFileMe ActiveRevision = activeRevision, TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, CaptureTime = linkDetailsDto.Photo.CaptureTime, + AlbumUids = linkDetailsDto.Photo.AlbumInclusions.Select(a => new NodeUid(uid.VolumeId, a.Id)).ToList(), } : new FileNode { @@ -382,6 +385,7 @@ private static (DegradedFileMetadata Metadata, List FailedDecryp TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, Errors = errors, CaptureTime = linkDetailsDto.Photo.CaptureTime, + AlbumUids = linkDetailsDto.Photo.AlbumInclusions.Select(a => new NodeUid(uid.VolumeId, a.Id)).ToList(), } : new DegradedFileNode { @@ -451,8 +455,11 @@ private static async ValueTask> C await entityCache.SetNodeAsync(uid, degradedFolderMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) .ConfigureAwait(false); - await ReportDecryptionError(client, DegradedNodeMetadata.FromFolder(degradedFolderMetadata), failedDecryptionFields, cancellationToken) - .ConfigureAwait(false); + await TelemetryRecorder.TryRecordDecryptionErrorAsync( + client, + DegradedNodeMetadata.FromFolder(degradedFolderMetadata), + failedDecryptionFields, + cancellationToken).ConfigureAwait(false); return degradedFolderMetadata; } @@ -561,7 +568,7 @@ private static (DegradedFolderMetadata Metadata, List FailedDecr return (new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); } - private static async ValueTask> GetParentKeyAsync( + private static async ValueTask> GetEntryPointKeyAsync( ProtonDriveClient client, VolumeId volumeId, LinkId? parentId, @@ -660,7 +667,7 @@ private static async ValueTask> GetParen return currentParentKey; } - private static async ValueTask> GetParentKeyAsync( + private static async ValueTask> GetEntryPointKeyAsync( ProtonDriveClient client, VolumeId volumeId, LinkId? parentId, @@ -668,7 +675,7 @@ private static async ValueTask> GetParen ShareId? shareId, CancellationToken cancellationToken) { - return await GetParentKeyAsync(client, volumeId, parentId, shareAndKeyToUse, shareId, client.Cache.Secrets, GetLinkDetailsAsync, cancellationToken) + return await GetEntryPointKeyAsync(client, volumeId, parentId, shareAndKeyToUse, shareId, client.Cache.Secrets, GetLinkDetailsAsync, cancellationToken) .ConfigureAwait(false); async Task GetLinkDetailsAsync(IEnumerable links, CancellationToken ct) @@ -677,35 +684,4 @@ async Task GetLinkDetailsAsync(IEnumerable links, Cancel return response.Links[0]; } } - - private static async Task ReportDecryptionError( - ProtonDriveClient client, - DegradedNodeMetadata degradedNode, - List failedDecryptionFields, - CancellationToken cancellationToken) - { - var legacyBoundary = new DateTime(2024, 1, 1, 0, 0, 0, 0, 0, DateTimeKind.Utc); - - try - { - // FIXME won't work for photos in an album, this will need to be differentiated for photos. - var share = await ShareOperations.GetContextShareAsync(client, degradedNode, cancellationToken).ConfigureAwait(false); - - foreach (var failedField in failedDecryptionFields) - { - client.Telemetry.RecordMetric(new DecryptionErrorEvent - { - Uid = degradedNode.Node.Uid.ToString(), - Field = failedField, - VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), - FromBefore2024 = degradedNode.Node.CreationTime.CompareTo(legacyBoundary) < 1, - Error = string.Empty, - }); - } - } - catch (Exception e) - { - client.Telemetry.GetLogger("Metric").LogWarning(e, "Failed to record metric for decryption event"); - } - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs index b81ead74..369a16df 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs @@ -3,4 +3,6 @@ public sealed record PhotoNode : FileNode { public required DateTime CaptureTime { get; init; } + + public required IReadOnlyList AlbumUids { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs index 7f6ec061..1f3c154c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs @@ -9,22 +9,31 @@ public static async ValueTask> FindRo Result nodeResult, CancellationToken cancellationToken) { - var parentUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); + var entryPointUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid) + ?? GetAlbumEntryPointUid(nodeResult); + HashSet visitedNodes = []; - while (parentUid is not null) + while (entryPointUid is not null) { - if (!visitedNodes.Add((NodeUid)parentUid)) + if (!visitedNodes.Add((NodeUid)entryPointUid)) { throw new ProtonDriveException("Folder structure loop detected"); } - nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, (NodeUid)parentUid, knownShareAndKey: null, cancellationToken) + nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, (NodeUid)entryPointUid, knownShareAndKey: null, cancellationToken) .ConfigureAwait(false); - parentUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); + entryPointUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); } return nodeResult; } + + private static NodeUid? GetAlbumEntryPointUid(Result nodeResult) + { + return nodeResult.Merge( + x => x.Node is PhotoNode photo && photo.AlbumUids.Count > 0 ? photo.AlbumUids[0] : (NodeUid?)null, + x => x.Node is DegradedPhotoNode photo && photo.AlbumUids.Count > 0 ? photo.AlbumUids[0] : null); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index d993997e..6d527d19 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -8,6 +8,7 @@ public sealed partial class FileUploader : IDisposable { private readonly ProtonDriveClient _client; private readonly IRevisionDraftProvider _revisionDraftProvider; + private readonly NodeUid _telemetryContextNodeUid; private readonly DateTimeOffset? _lastModificationTime; private readonly IEnumerable? _additionalMetadata; private readonly ILogger _logger; @@ -17,6 +18,7 @@ public sealed partial class FileUploader : IDisposable private FileUploader( ProtonDriveClient client, IRevisionDraftProvider revisionDraftProvider, + NodeUid telemetryContextNodeUid, long size, DateTimeOffset? lastModificationTime, IEnumerable? additionalMetadata, @@ -25,6 +27,7 @@ private FileUploader( { _client = client; _revisionDraftProvider = revisionDraftProvider; + _telemetryContextNodeUid = telemetryContextNodeUid; FileSize = size; _lastModificationTime = lastModificationTime; _additionalMetadata = additionalMetadata; @@ -76,6 +79,7 @@ public void Dispose() internal static async ValueTask CreateAsync( ProtonDriveClient client, IRevisionDraftProvider revisionDraftProvider, + NodeUid telemetryContextNodeUid, long size, DateTime? lastModificationTime, IEnumerable? additionalExtendedAttributes, @@ -94,6 +98,7 @@ internal static async ValueTask CreateAsync( return new FileUploader( client, revisionDraftProvider, + telemetryContextNodeUid, size, lastModificationTime, additionalExtendedAttributes, @@ -122,14 +127,8 @@ private UploadController UploadFromStream( var revisionDraftTaskCompletionSource = new TaskCompletionSource(); - var uploadEvent = new UploadEvent - { - ExpectedSize = contentStream.Length, - ApproximateExpectedSize = Privacy.ReduceSizePrecision(contentStream.Length), - UploadedSize = 0, - ApproximateUploadedSize = 0, - VolumeType = VolumeType.OwnVolume, // FIXME: figure out how to get the actual volume type - }; + var uploadEvent = TelemetryEventFactory.CreateUploadEventAsync(_client, _telemetryContextNodeUid, contentStream.Length, cancellationToken) + .ConfigureAwait(false).GetAwaiter().GetResult(); var uploadFunction = (CancellationToken ct) => UploadFromStreamAsync( contentStream, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 7e8fd0a6..5f6ead81 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -211,7 +211,7 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( return FileOperations.EnumerateThumbnailsAsync(this, fileUids, type, cancellationToken); } - // FIXME: Group all parameterts bettween mediaType and overrideExistingDraftByOtherClient into uploadMetadata object. + // FIXME: Group all parameters between mediaType and overrideExistingDraftByOtherClient into uploadMetadata object. public async ValueTask GetFileUploaderAsync( NodeUid parentFolderUid, string name, @@ -224,7 +224,13 @@ public async ValueTask GetFileUploaderAsync( { var draftProvider = new NewFileDraftProvider(this, parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); - return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync( + draftProvider, + parentFolderUid, + size, + lastModificationTime, + additionalMetadata, + cancellationToken).ConfigureAwait(false); } // FIXME: Group all parameterts bettween size and additionalMetadata into uploadMetadata object. @@ -237,7 +243,13 @@ public async ValueTask GetFileRevisionUploaderAsync( { var draftProvider = new NewRevisionDraftProvider(this, currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); - return await GetFileUploaderAsync(draftProvider, size, lastModificationTime, additionalMetadata, cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync( + draftProvider, + currentActiveRevisionUid.NodeUid, + size, + lastModificationTime, + additionalMetadata, + cancellationToken).ConfigureAwait(false); } public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) @@ -292,12 +304,19 @@ public ValueTask EmptyTrashAsync(CancellationToken cancellationToken) private async ValueTask GetFileUploaderAsync( IRevisionDraftProvider revisionDraftProvider, + NodeUid telemetryContextNodeUid, long size, DateTime? lastModificationTime, IEnumerable? additionalMetadata, CancellationToken cancellationToken) { - return await FileUploader.CreateAsync(this, revisionDraftProvider, size, lastModificationTime, additionalMetadata, cancellationToken) - .ConfigureAwait(false); + return await FileUploader.CreateAsync( + this, + revisionDraftProvider, + telemetryContextNodeUid, + size, + lastModificationTime, + additionalMetadata, + cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs new file mode 100644 index 00000000..a745514f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs @@ -0,0 +1,130 @@ +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; + +namespace Proton.Drive.Sdk.Telemetry; + +internal static class TelemetryEventFactory +{ + private static readonly DateTime LegacyBoundary = new(2024, 1, 1, 0, 0, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Creates DecryptionErrorEvent objects for a degraded node with multiple failed fields. + /// + public static async Task> CreateDecryptionErrorEventsAsync( + ProtonDriveClient client, + DegradedNodeMetadata degradedNode, + IEnumerable failedFields, + CancellationToken cancellationToken) + { + // FIXME won't work for photos in an album, this will need to be differentiated for photos. + var share = await ShareOperations.GetContextShareAsync(client, degradedNode, cancellationToken).ConfigureAwait(false); + var fromBefore2024 = degradedNode.Node.CreationTime.CompareTo(LegacyBoundary) < 1; + + return failedFields.Select(field => new DecryptionErrorEvent + { + Uid = degradedNode.Node.Uid.ToString(), + Field = field, + VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), + FromBefore2024 = fromBefore2024, + Error = string.Empty, + }).ToList(); + } + + /// + /// Creates a DecryptionErrorEvent for a single field using a node UID. + /// + public static async Task CreateDecryptionErrorEventAsync( + ProtonDriveClient client, + NodeUid nodeUid, + EncryptedField field, + DateTime creationTime, + CancellationToken cancellationToken) + { + var nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, nodeUid, null, cancellationToken).ConfigureAwait(false); + var share = await ShareOperations.GetContextShareAsync(client, nodeResult, cancellationToken).ConfigureAwait(false); + + return new DecryptionErrorEvent + { + Uid = nodeUid.ToString(), + Field = field, + VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), + FromBefore2024 = creationTime.CompareTo(LegacyBoundary) < 1, + Error = string.Empty, + }; + } + + /// + /// Creates a VerificationErrorEvent using a node UID. + /// + public static async Task CreateVerificationErrorEventAsync( + ProtonDriveClient client, + NodeUid nodeUid, + EncryptedField field, + DateTime creationTime, + CancellationToken cancellationToken) + { + var nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, nodeUid, null, cancellationToken).ConfigureAwait(false); + var share = await ShareOperations.GetContextShareAsync(client, nodeResult, cancellationToken).ConfigureAwait(false); + + return new VerificationErrorEvent + { + Uid = nodeUid.ToString(), + Field = field, + VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), + FromBefore2024 = creationTime.CompareTo(LegacyBoundary) < 1, + Error = string.Empty, + }; + } + + /// + /// Creates an UploadEvent with the correct VolumeType for the given node. + /// + public static async Task CreateUploadEventAsync( + ProtonDriveClient client, + NodeUid nodeUid, + long expectedSize, + CancellationToken cancellationToken) + { + return new UploadEvent + { + ExpectedSize = expectedSize, + ApproximateExpectedSize = Privacy.ReduceSizePrecision(expectedSize), + UploadedSize = 0, + ApproximateUploadedSize = 0, + VolumeType = await ResolveVolumeTypeAsync(client, nodeUid, cancellationToken).ConfigureAwait(false), + }; + } + + /// + /// Creates a DownloadEvent with the correct VolumeType for the given node. + /// + public static async Task CreateDownloadEventAsync( + ProtonDriveClient client, + NodeUid nodeUid, + CancellationToken cancellationToken) + { + return new DownloadEvent + { + DownloadedSize = 0, + VolumeType = await ResolveVolumeTypeAsync(client, nodeUid, cancellationToken).ConfigureAwait(false), + }; + } + + internal static async Task ResolveVolumeTypeAsync( + ProtonDriveClient client, + NodeUid nodeUid, + CancellationToken cancellationToken) + { + try + { + var nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, nodeUid, null, cancellationToken).ConfigureAwait(false); + var share = await ShareOperations.GetContextShareAsync(client, nodeResult, cancellationToken).ConfigureAwait(false); + + return VolumeTypeFactory.FromShareType(share.Share.Type); + } + catch + { + return VolumeType.OwnVolume; + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs new file mode 100644 index 00000000..ecc2b2c7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs @@ -0,0 +1,88 @@ +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Telemetry; + +internal static class TelemetryRecorder +{ + /// + /// Attempts to record decryption error events for a degraded node with multiple failed fields. + /// + public static async Task TryRecordDecryptionErrorAsync( + ProtonDriveClient client, + DegradedNodeMetadata degradedNode, + IEnumerable failedFields, + CancellationToken cancellationToken) + { + try + { + var events = await TelemetryEventFactory.CreateDecryptionErrorEventsAsync( + client, + degradedNode, + failedFields, + cancellationToken).ConfigureAwait(false); + + foreach (var @event in events) + { + client.Telemetry.RecordMetric(@event); + } + } + catch + { + // Do nothing - telemetry failures should not break the main flow + } + } + + /// + /// Attempts to record a decryption error event for a single field using a node UID. + /// + public static async Task TryRecordDecryptionErrorAsync( + ProtonDriveClient client, + NodeUid nodeUid, + EncryptedField field, + DateTime creationTime, + CancellationToken cancellationToken) + { + try + { + var @event = await TelemetryEventFactory.CreateDecryptionErrorEventAsync( + client, + nodeUid, + field, + creationTime, + cancellationToken).ConfigureAwait(false); + + client.Telemetry.RecordMetric(@event); + } + catch + { + // Do nothing - telemetry failures should not break the main flow + } + } + + /// + /// Attempts to record a verification error event using a node UID. + /// + public static async Task TryRecordVerificationErrorAsync( + ProtonDriveClient client, + NodeUid nodeUid, + EncryptedField field, + DateTime creationTime, + CancellationToken cancellationToken) + { + try + { + var @event = await TelemetryEventFactory.CreateVerificationErrorEventAsync( + client, + nodeUid, + field, + creationTime, + cancellationToken).ConfigureAwait(false); + + client.Telemetry.RecordMetric(@event); + } + catch + { + // Do nothing - telemetry failures should not break the main flow + } + } +} From 7dbf3bfbc44af74829641af72b562891f74065ea Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 06:03:16 +0000 Subject: [PATCH 582/791] i18n(weekly-mr): Upgrade translations from crowdin (d0a9bf3a). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/ca_ES.json | 6 ++++++ js/sdk/locales/de_DE.json | 6 ++++++ js/sdk/locales/el_GR.json | 6 ++++++ js/sdk/locales/es_ES.json | 6 ++++++ js/sdk/locales/es_LA.json | 6 ++++++ js/sdk/locales/fr_FR.json | 6 ++++++ js/sdk/locales/it_IT.json | 9 +++++++++ js/sdk/locales/nl_NL.json | 6 ++++++ js/sdk/locales/sk_SK.json | 6 ++++++ js/sdk/locales/tr_TR.json | 6 ++++++ 11 files changed, 64 insertions(+), 1 deletion(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index 5e4e8fc4..e602ae1f 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "91602862052d56fc01bf946271bbc3ffbc84d3bd" + "locale": "e871598193d788c9f1c41fa6c521a410622abf91" } \ No newline at end of file diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 84a34193..a6944395 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "No es pot compartir la carpeta arrel" ], + "Content key packet is required for small revision upload": [ + "Es requereix el paquet de claus de contingut per carregar revisions petites" + ], "Copy operation aborted": [ "S'ha cancel·lat l'operació de còpia" ], @@ -104,6 +107,9 @@ "File has no content key": [ "El fitxer no té clau de contingut" ], + "File has no revision": [ + "Aquest fitxer no té cap revisió" + ], "File hash does not match expected hash": [ "L'empremta electrònica del fitxer no coincideix amb l'esperada" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index c569418f..35bcdabc 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "Stammordner kann nicht geteilt werden" ], + "Content key packet is required for small revision upload": [ + "Für das Hochladen kleinerer Überarbeitungen ist ein Content-Key-Paket erforderlich" + ], "Copy operation aborted": [ "Kopiervorgang abgebrochen" ], @@ -104,6 +107,9 @@ "File has no content key": [ "Datei hat keinen Inhaltsschlüssel" ], + "File has no revision": [ + "Datei noch nicht überarbeitet" + ], "File hash does not match expected hash": [ "Datei-Hash stimmt nicht mit dem erwarteten Hash überein" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index c3136342..56801238 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "Δεν είναι δυνατή η κοινοποίηση του κεντÏÎ¹ÎºÎ¿Ï Ï†Î±ÎºÎ­Î»Î¿Ï…" ], + "Content key packet is required for small revision upload": [ + "Το πακέτο ÎºÎ»ÎµÎ¹Î´Î¹Î¿Ï Ï€ÎµÏιεχομένου απαιτείται για τη μεταφόÏτωση μικÏής αναθεώÏησης" + ], "Copy operation aborted": [ "Η αντιγÏαφή ακυÏώθηκε" ], @@ -104,6 +107,9 @@ "File has no content key": [ "Το αÏχείο δεν έχει κλειδί πεÏιεχομένου" ], + "File has no revision": [ + "Το αÏχείο δεν έχει αναθεώÏηση" + ], "File hash does not match expected hash": [ "Το hash του αÏχείου δεν ταιÏιάζει με τον αναμενόμενο hash" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 0e4bdd77..8fd821a0 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "No se puede compartir la carpeta principal." ], + "Content key packet is required for small revision upload": [ + "Se requiere el paquete de claves de contenido para subir una revisión pequeña" + ], "Copy operation aborted": [ "Se ha cancelado la copia." ], @@ -104,6 +107,9 @@ "File has no content key": [ "El archivo no tiene clave de contenido." ], + "File has no revision": [ + "El archivo no tiene revisiones" + ], "File hash does not match expected hash": [ "El hash del archivo no coincide con el valor esperado" ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 547f52e3..71ae6eaf 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "No se puede compartir la carpeta raíz" ], + "Content key packet is required for small revision upload": [ + "Se requiere el paquete de claves de contenido para subir una revisión pequeña" + ], "Copy operation aborted": [ "Se canceló la operación de copia" ], @@ -104,6 +107,9 @@ "File has no content key": [ "El archivo no tiene clave de contenido" ], + "File has no revision": [ + "El archivo no tiene revisiones" + ], "File hash does not match expected hash": [ "El hash del archivo no coincide con el valor esperado" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index 79086857..504bfa4a 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "Le partager du dossier principal n'a pas abouti." ], + "Content key packet is required for small revision upload": [ + "Un paquet de clés de contenu est requis pour importer de petites révisions." + ], "Copy operation aborted": [ "L'opération de copie a été annulée." ], @@ -104,6 +107,9 @@ "File has no content key": [ "Le fichier n'a pas de clé de contenu." ], + "File has no revision": [ + "Le fichier n'a pas de révision." + ], "File hash does not match expected hash": [ "Le hachage du fichier ne correspond pas au hachage attendu." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index afdb37c4..cdbb93fd 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "Impossibile condividere la cartella principale" ], + "Content key packet is required for small revision upload": [ + "Il pacchetto chiave di contenuto è richiesto per il caricamento di piccole revisioni" + ], "Copy operation aborted": [ "Copia interrotta" ], @@ -104,6 +107,9 @@ "File has no content key": [ "Il file non ha una chiave di contenuto" ], + "File has no revision": [ + "Il file non ha una revisione" + ], "File hash does not match expected hash": [ "L'hash del file non corrisponde all'hash atteso" ], @@ -174,6 +180,9 @@ "Parent cannot be decrypted": [ "Impossibile decriptare il genitore" ], + "Photo is already in the target album": [ + "La foto è già nell'album di destinazione" + ], "Photo not found": [ "Foto non trovata" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index d13fc001..15e83976 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "Kan hoofdmap niet delen" ], + "Content key packet is required for small revision upload": [ + "Content key packet is vereist voor kleine revisie upload" + ], "Copy operation aborted": [ "Kopieeractie afgebroken" ], @@ -104,6 +107,9 @@ "File has no content key": [ "Bestand heeft geen inhoudssleutel" ], + "File has no revision": [ + "Bestand heeft geen revisie" + ], "File hash does not match expected hash": [ "Bestandhash komt niet overeen met verwachte hash" ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index f0c35247..785ec7dc 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "Nie je možné zdieľaÅ¥ koreňový prieÄinok" ], + "Content key packet is required for small revision upload": [ + "Na nahratie malých revízií je potrebný balík kľúÄov obsahu." + ], "Copy operation aborted": [ "Operácia kopírovania bola preruÅ¡ená" ], @@ -104,6 +107,9 @@ "File has no content key": [ "Súbor nemá kÄ¾ÃºÄ obsahu" ], + "File has no revision": [ + "Súbor nemá žiadnu revíziu" + ], "File hash does not match expected hash": [ "Hash súboru nezodpovedá oÄakávanému hashu" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index e8da6230..f0c74990 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "Kök klasör paylaşılamaz" ], + "Content key packet is required for small revision upload": [ + "Küçük sürüm yüklemeleri için içerik anahtarı paketi gereklidir" + ], "Copy operation aborted": [ "Kopyalama iÅŸlemi iptal edildi" ], @@ -104,6 +107,9 @@ "File has no content key": [ "Dosyanın içerik anahtarı yok" ], + "File has no revision": [ + "Dosyanın sürümü yok" + ], "File hash does not match expected hash": [ "Dosya karma deÄŸeri beklenen deÄŸerle aynı deÄŸil" ], From e0ed33dcfe2c7dd7f2ea1ce26c9139ffd5306b48 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 09:44:19 +0000 Subject: [PATCH 583/791] Change main photo reference to UID instead of link ID --- js/sdk/src/internal/photos/upload.ts | 33 +++++++++++++++++++-------- js/sdk/src/protonDrivePhotosClient.ts | 2 +- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index 783fb2dc..4e8bc727 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -1,8 +1,15 @@ import { DriveCrypto } from '../../crypto'; -import { ProtonDriveTelemetry, UploadMetadata, Thumbnail, AnonymousUser, FeatureFlagProvider } from '../../interface'; +import { + ProtonDriveTelemetry, + UploadMetadata, + Thumbnail, + AnonymousUser, + FeatureFlagProvider, + PhotoTag, +} from '../../interface'; import { DriveAPIService, drivePaths } from '../apiService'; import { generateFileExtendedAttributes } from '../nodes'; -import { splitNodeRevisionUid } from '../uids'; +import { splitNodeRevisionUid, splitNodeUid } from '../uids'; import { UploadAPIService } from '../upload/apiService'; import { BlockVerifier } from '../upload/blockVerifier'; import { UploadController } from '../upload/controller'; @@ -22,9 +29,8 @@ type PostCommitRevisionResponse = export type PhotoUploadMetadata = UploadMetadata & { captureTime?: Date; - mainPhotoLinkID?: string; - // TODO: handle tags enum in the SDK - tags?: (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9)[]; + mainPhotoNodeUid?: string; + tags?: PhotoTag[]; }; export class PhotoFileUploader extends FileUploader { @@ -180,7 +186,7 @@ export class PhotoUploadManager extends UploadManager { const photo = { contentHash, captureTime: uploadMetadata.captureTime || extendedAttributes.modificationTime, - mainPhotoLinkID: uploadMetadata.mainPhotoLinkID, + mainPhotoNodeUid: uploadMetadata.mainPhotoNodeUid, tags: uploadMetadata.tags, }; await this.photoApiService.commitDraftPhoto(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto, photo); @@ -218,12 +224,19 @@ export class PhotoUploadAPIService extends UploadAPIService { photo: { contentHash: string; captureTime?: Date; - mainPhotoLinkID?: string; - // TODO: handle tags enum in the SDK - tags?: (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9)[]; + mainPhotoNodeUid?: string; + tags?: PhotoTag[]; }, ): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + const { volumeId: mainPhotoVolumeId, nodeId: mainPhotoNodeId } = photo.mainPhotoNodeUid + ? splitNodeUid(photo.mainPhotoNodeUid) + : { volumeId: null, nodeId: null }; + + if (mainPhotoVolumeId !== null && mainPhotoVolumeId !== volumeId) { + throw new Error('mainPhotoNodeUid must belong to the same volume as the draft'); + } + await this.apiService.put< // TODO: Deprected fields but not properly marked in the types. Omit, @@ -235,7 +248,7 @@ export class PhotoUploadAPIService extends UploadAPIService { Photo: { ContentHash: photo.contentHash, CaptureTime: photo.captureTime ? Math.floor(photo.captureTime?.getTime() / 1000) : 0, - MainPhotoLinkID: photo.mainPhotoLinkID || null, + MainPhotoLinkID: mainPhotoNodeId, Tags: photo.tags || [], Exif: null, // Deprecated field, not used. }, diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index f4dec772..76c29aa1 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -454,7 +454,7 @@ export class ProtonDrivePhotosClient { name: string, metadata: UploadMetadata & { captureTime?: Date; - mainPhotoLinkID?: string; + mainPhotoNodeUid?: string; tags?: PhotoTag[]; }, signal?: AbortSignal, From 3575b62f76f199c3a0816d3bc4de7b53314b9457 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 15:28:41 +0000 Subject: [PATCH 584/791] Add interop and Kotlin bindings for trash management --- .../InteropMessageHandler.cs | 14 ++- .../InteropProtonDriveClient.cs | 111 +++++++++++++----- .../Api/Files/TrashApiClient.cs | 2 +- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 39 +++++- .../Nodes/Upload/NewFileDraftProvider.cs | 2 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 4 +- .../ExceptionExtensions.cs | 4 +- .../InteropMessageHandler.cs | 2 +- cs/sdk/src/protos/proton.drive.sdk.proto | 38 +++++- .../me/proton/drive/sdk/ProtonDriveClient.kt | 56 ++++++++- .../NodeResultListResponseConverter.kt | 11 ++ .../converter/TrashChildrenListConverter.kt | 11 ++ .../converter/TrashNodesResponseConverter.kt | 11 -- ...sResponse.kt => NodeResultListResponse.kt} | 2 +- .../drive/sdk/extension/TrashChildrenList.kt | 7 ++ .../sdk/internal/JniProtonDriveClient.kt | 34 +++++- .../ProtonDriveClient/ProtonDriveClient.swift | 2 +- .../Sources/Plumbing/SDKRequestHandler.swift | 8 +- 18 files changed, 294 insertions(+), 64 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultListResponseConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashChildrenListConverter.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashNodesResponseConverter.kt rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/{TrashNodesResponse.kt => NodeResultListResponse.kt} (83%) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashChildrenList.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index fa7ff0e0..bdc761fb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -40,6 +40,18 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientTrashNodes => await InteropProtonDriveClient.HandleTrashNodesAsync(request.DriveClientTrashNodes).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientDeleteNodes + => await InteropProtonDriveClient.HandleDeleteNodesAsync(request.DriveClientDeleteNodes).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientRestoreNodes + => await InteropProtonDriveClient.HandleRestoreNodesAsync(request.DriveClientRestoreNodes).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientEnumerateTrash + => await InteropProtonDriveClient.HandleEnumerateTrashAsync(request.DriveClientEnumerateTrash).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientEmptyTrash + => await InteropProtonDriveClient.HandleEmptyTrashAsync(request.DriveClientEmptyTrash).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientRename => await InteropProtonDriveClient.HandleRenameAsync(request.DriveClientRename).ConfigureAwait(false), @@ -165,7 +177,7 @@ Request.PayloadOneofCase.None or _ } catch (Exception e) { - var error = e.ToErrorMessage(InteropDriveErrorConverter.SetDomainAndCodes); + var error = e.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes); responseAction.InvokeWithMessage(bindingsHandle, new Response { Error = error }); } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 5c55d17c..48014fc1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -259,7 +259,87 @@ public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNo request.NodeUids.Select(NodeUid.Parse), cancellationToken).ConfigureAwait(false); - var response = new TrashNodesResponse + return ConvertToNodeResultListResponse(results); + } + + public static async ValueTask HandleDeleteNodesAsync(DriveClientDeleteNodesRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var results = await client.DeleteNodesAsync( + request.NodeUids.Select(NodeUid.Parse), + cancellationToken).ConfigureAwait(false); + + return ConvertToNodeResultListResponse(results); + } + + public static async ValueTask HandleRestoreNodesAsync(DriveClientRestoreNodesRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var results = await client.RestoreNodesAsync( + request.NodeUids.Select(NodeUid.Parse), + cancellationToken).ConfigureAwait(false); + + return ConvertToNodeResultListResponse(results); + } + + public static async ValueTask HandleEnumerateTrashAsync(DriveClientEnumerateTrashRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var trashEnumerable = client.EnumerateTrashAsync(cancellationToken); + + var children = await trashEnumerable + .Select(ConvertToNodeResult) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return new TrashChildrenList { Children = { children } }; + } + + public static async ValueTask HandleEmptyTrashAsync(DriveClientEmptyTrashRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + await client.EmptyTrashAsync(cancellationToken).ConfigureAwait(false); + + return null; + } + + public static IMessage? HandleFree(DriveClientFreeRequest request) + { + Interop.FreeHandle(request.ClientHandle); + + return null; + } + + public static NodeResult ConvertToNodeResult(Result result) + { + var nodeResult = new NodeResult(); + + if (result.TryGetValueElseError(out var node, out var degradedNode)) + { + nodeResult.Value = ConvertToNode(node); + } + else + { + nodeResult.Error = ConvertToDegradedNode(degradedNode); + } + + return nodeResult; + } + + private static NodeResultListResponse ConvertToNodeResultListResponse(IReadOnlyDictionary> results) + { + return new NodeResultListResponse { Results = { @@ -272,25 +352,16 @@ public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNo if (pair.Value.TryGetError(out var exception)) { - result.Error = exception.ToErrorMessage(InteropDriveErrorConverter.SetDomainAndCodes); + result.Error = exception.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes); } return result; }), }, }; - - return response; } - public static IMessage? HandleFree(DriveClientFreeRequest request) - { - Interop.FreeHandle(request.ClientHandle); - - return null; - } - - public static AuthorResult ParseAuthorResult(Result result) + private static AuthorResult ParseAuthorResult(Result result) { var authorResult = new AuthorResult(); @@ -316,22 +387,6 @@ public static AuthorResult ParseAuthorResult(Result result) - { - var nodeResult = new NodeResult(); - - if (result.TryGetValueElseError(out var node, out var degradedNode)) - { - nodeResult.Value = ConvertToNode(node); - } - else - { - nodeResult.Error = ConvertToDegradedNode(degradedNode); - } - - return nodeResult; - } - private static Node ConvertToNode(Proton.Drive.Sdk.Nodes.Node node) { var result = new Node(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs index 8261765d..ae6b856f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs @@ -64,6 +64,6 @@ public async ValueTask EmptyAsync(VolumeId volumeId, CancellationTo { return await _httpClient .Expecting(ProtonApiSerializerContext.Default.ApiResponse) - .DeleteAsync("volumes/trash", cancellationToken).ConfigureAwait(false); + .DeleteAsync($"volumes/{volumeId}/trash", cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index cc77f210..c838432a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -332,6 +332,39 @@ public static async ValueTask RenameAsync( await client.Cache.Entities.SetNodeAsync(uid, node with { Name = newName }, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); } + public static async ValueTask>> DeleteDraftAsync( + ProtonDriveClient client, + IEnumerable uids, + CancellationToken cancellationToken) + { + var uidsByVolumeId = uids.GroupBy(x => x.VolumeId); + + var results = new ConcurrentDictionary>(); + + var tasks = uidsByVolumeId.Select(async uidGroup => + { + foreach (var batch in uidGroup.Select(x => x.LinkId).Chunk(MaximumBatchCount)) + { + var request = new MultipleLinksNullaryRequest { LinkIds = batch }; + + var aggregateResponse = await client.Api.Links.DeleteMultipleAsync(uidGroup.Key, request.LinkIds, cancellationToken).ConfigureAwait(false); + + foreach (var (linkId, response) in aggregateResponse.Responses) + { + var uid = new NodeUid(uidGroup.Key, linkId); + + var result = response.IsSuccess ? Result.Success : new ProtonApiException(response); + + results.TryAdd(uid, result); + } + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + return results; + } + public static async ValueTask>> TrashAsync( ProtonDriveClient client, IEnumerable uids, @@ -378,7 +411,7 @@ await client.Cache.Entities.SetNodeAsync(uid, newNodeProvisionResult, membership return results; } - public static async ValueTask>> DeleteAsync( + public static async ValueTask>> DeleteFromTrashAsync( ProtonDriveClient client, IEnumerable uids, CancellationToken cancellationToken) @@ -393,7 +426,7 @@ public static async ValueTask>> D { var request = new MultipleLinksNullaryRequest { LinkIds = batch }; - var aggregateResponse = await client.Api.Links.DeleteMultipleAsync(uidGroup.Key, request.LinkIds, cancellationToken).ConfigureAwait(false); + var aggregateResponse = await client.Api.Trash.DeleteMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); foreach (var (linkId, response) in aggregateResponse.Responses) { @@ -411,7 +444,7 @@ public static async ValueTask>> D return results; } - public static async ValueTask>> RestoreAsync( + public static async ValueTask>> RestoreFromTrashAsync( ProtonDriveClient client, IEnumerable uids, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index d3addce4..25b284cf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -157,7 +157,7 @@ private static FileCreationRequest GetFileCreationRequest( { var conflictingNodeUid = new NodeUid(_parentUid.VolumeId, conflictingLinkId); - var deletionResults = await NodeOperations.DeleteAsync(_client, [conflictingNodeUid], cancellationToken).ConfigureAwait(false); + var deletionResults = await NodeOperations.DeleteDraftAsync(_client, [conflictingNodeUid], cancellationToken).ConfigureAwait(false); if (!deletionResults.TryGetValue(conflictingNodeUid, out var deletionResult)) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 5f6ead81..20cf93f2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -284,12 +284,12 @@ public ValueTask>> TrashNodesAsyn public ValueTask>> DeleteNodesAsync(IEnumerable uids, CancellationToken cancellationToken) { - return NodeOperations.DeleteAsync(this, uids, cancellationToken); + return NodeOperations.DeleteFromTrashAsync(this, uids, cancellationToken); } public ValueTask>> RestoreNodesAsync(IEnumerable uids, CancellationToken cancellationToken) { - return NodeOperations.RestoreAsync(this, uids, cancellationToken); + return NodeOperations.RestoreFromTrashAsync(this, uids, cancellationToken); } public IAsyncEnumerable> EnumerateTrashAsync(CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs index d6012fd9..e5ef8968 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs @@ -2,7 +2,7 @@ internal static class ExceptionExtensions { - public static Error ToErrorMessage(this Exception exception, Action setDomainAndCodesFunction) + public static Error ToProtoError(this Exception exception, Action setDomainAndCodesFunction) { if (exception is InteropErrorException { Error: not null } interopErrorException) { @@ -23,7 +23,7 @@ public static Error ToErrorMessage(this Exception exception, Action - log(DEBUG, "rename") + log(INFO, "rename") bridge.rename( driveClientRenameRequest { this.nodeUid = nodeUid @@ -87,7 +91,7 @@ class ProtonDriveClient internal constructor( name: String, lastModification: Long? = null, ): FolderNode = cancellationCoroutineScope { source -> - log(DEBUG, "createFolder") + log(INFO, "createFolder") bridge.createFolder( driveClientCreateFolderRequest { this.parentFolderUid = parentFolderUid @@ -127,7 +131,7 @@ class ProtonDriveClient internal constructor( suspend fun trashNodes( nodeUids: List, ): List = cancellationCoroutineScope { source -> - log(DEBUG, "trashNodes(${nodeUids.size} nodes)") + log(INFO, "trashNodes(${nodeUids.size} nodes)") bridge.trashNodes( driveClientTrashNodesRequest { this.nodeUids += nodeUids @@ -137,6 +141,52 @@ class ProtonDriveClient internal constructor( ).toEntity() } + suspend fun deleteNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "deleteNodes(${nodeUids.size} nodes)") + bridge.deleteNodes( + driveClientDeleteNodesRequest { + this.nodeUids += nodeUids + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + suspend fun restoreNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "restoreNodes(${nodeUids.size} nodes)") + bridge.restoreNodes( + driveClientRestoreNodesRequest { + this.nodeUids += nodeUids + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + suspend fun enumerateTrash(): List = cancellationCoroutineScope { source -> + log(DEBUG, "enumerateTrash") + bridge.enumerateTrash( + driveClientEnumerateTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + suspend fun emptyTrash(): Unit = cancellationCoroutineScope { source -> + log(INFO, "emptyTrash") + bridge.emptyTrash( + driveClientEmptyTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ) + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultListResponseConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultListResponseConverter.kt new file mode 100644 index 00000000..5976979d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultListResponseConverter.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class NodeResultListResponseConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.NodeResultListResponse" + + override fun convert(any: Any): ProtonDriveSdk.NodeResultListResponse = + ProtonDriveSdk.NodeResultListResponse.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashChildrenListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashChildrenListConverter.kt new file mode 100644 index 00000000..92376131 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashChildrenListConverter.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class TrashChildrenListConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.TrashChildrenList" + + override fun convert(any: Any): ProtonDriveSdk.TrashChildrenList = + ProtonDriveSdk.TrashChildrenList.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashNodesResponseConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashNodesResponseConverter.kt deleted file mode 100644 index f23f8844..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashNodesResponseConverter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.proton.drive.sdk.converter - -import com.google.protobuf.Any -import proton.drive.sdk.ProtonDriveSdk - -class TrashNodesResponseConverter : AnyConverter { - override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.TrashNodesResponse" - - override fun convert(any: Any): ProtonDriveSdk.TrashNodesResponse = - ProtonDriveSdk.TrashNodesResponse.parseFrom(any.value) -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashNodesResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResultListResponse.kt similarity index 83% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashNodesResponse.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResultListResponse.kt index 00e9ef8f..793eb6d8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashNodesResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResultListResponse.kt @@ -3,7 +3,7 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.NodeResultPair import proton.drive.sdk.ProtonDriveSdk -fun ProtonDriveSdk.TrashNodesResponse.toEntity(): List = +fun ProtonDriveSdk.NodeResultListResponse.toEntity(): List = resultsList.map { it.toEntity() } fun ProtonDriveSdk.NodeResultPair.toEntity(): NodeResultPair = diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashChildrenList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashChildrenList.kt new file mode 100644 index 00000000..6fbca875 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashChildrenList.kt @@ -0,0 +1,7 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.NodeResult +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.TrashChildrenList.toEntity(): List = + childrenList.map { it.toEntity() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index 12aae778..d2b0b677 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -2,10 +2,11 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.converter.NodeResultListResponseConverter import me.proton.drive.sdk.converter.FileThumbnailListConverter import me.proton.drive.sdk.converter.FolderChildrenListConverter import me.proton.drive.sdk.converter.FolderNodeConverter -import me.proton.drive.sdk.converter.TrashNodesResponseConverter +import me.proton.drive.sdk.converter.TrashChildrenListConverter import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.StringResponseCallback @@ -119,11 +120,38 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun trashNodes( request: ProtonDriveSdk.DriveClientTrashNodesRequest, - ): ProtonDriveSdk.TrashNodesResponse = - executeOnce("trashNodes", TrashNodesResponseConverter().asCallback) { + ): ProtonDriveSdk.NodeResultListResponse = + executeOnce("trashNodes", NodeResultListResponseConverter().asCallback) { driveClientTrashNodes = request } + suspend fun deleteNodes( + request: ProtonDriveSdk.DriveClientDeleteNodesRequest, + ): ProtonDriveSdk.NodeResultListResponse = + executeOnce("deleteNodes", NodeResultListResponseConverter().asCallback) { + driveClientDeleteNodes = request + } + + suspend fun restoreNodes( + request: ProtonDriveSdk.DriveClientRestoreNodesRequest, + ): ProtonDriveSdk.NodeResultListResponse = + executeOnce("restoreNodes", NodeResultListResponseConverter().asCallback) { + driveClientRestoreNodes = request + } + + suspend fun enumerateTrash( + request: ProtonDriveSdk.DriveClientEnumerateTrashRequest, + ): ProtonDriveSdk.TrashChildrenList = + executeOnce("enumerateTrash", TrashChildrenListConverter().asCallback) { + driveClientEnumerateTrash = request + } + + suspend fun emptyTrash( + request: ProtonDriveSdk.DriveClientEmptyTrashRequest, + ): Unit = executeOnce("emptyTrash", UnitResponseCallback) { + driveClientEmptyTrash = request + } + fun free(handle: Long) { dispatch("free") { driveClientFree = driveClientFreeRequest { diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index fdcf280d..5edb2595 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -461,7 +461,7 @@ extension ProtonDriveClient { $0.nodeUids = nodes.map { $0.sdkCompatibleIdentifier } $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } - let result: Proton_Drive_Sdk_TrashNodesResponse = try await SDKRequestHandler.send(trashRequest, logger: logger) + let result: Proton_Drive_Sdk_NodeResultListResponse = try await SDKRequestHandler.send(trashRequest, logger: logger) let results: [TrashNodeResult] = result.results.compactMap { result in guard let id = SDKNodeUid(sdkCompatibleIdentifier: result.nodeUid) else { return nil } let error: String? = result.hasError ? result.error.message : nil diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 835ad066..3094148c 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -213,10 +213,10 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in } resultBox.resume(returning: unpackedValue) - case .value(let value) where value.isA(Proton_Drive_Sdk_TrashNodesResponse.self): - let unpackedValue = try Proton_Drive_Sdk_TrashNodesResponse(unpackingAny: value) - guard let uploadResultBox = box as? any Resumable else { - throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) + case .value(let value) where value.isA(Proton_Drive_Sdk_NodeResultListResponse.self): + let unpackedValue = try Proton_Drive_Sdk_NodeResultListResponse(unpackingAny: value) + guard let uploadResultBox = box as? any Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } uploadResultBox.resume(returning: unpackedValue) From fb6a3c07921053acbf86e76689edcec59a9f276e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 20:07:08 +0100 Subject: [PATCH 585/791] Use java Instant instead for Long to describe time --- .../me/proton/drive/sdk/ProtonDriveClient.kt | 6 ++++-- .../proton/drive/sdk/entity/DegradedFileNode.kt | 6 ++++-- .../proton/drive/sdk/entity/DegradedFolderNode.kt | 6 ++++-- .../me/proton/drive/sdk/entity/DegradedNode.kt | 6 ++++-- .../proton/drive/sdk/entity/DegradedRevision.kt | 6 ++++-- .../kotlin/me/proton/drive/sdk/entity/FileNode.kt | 6 ++++-- .../sdk/entity/{Revision.kt => FileRevision.kt} | 6 ++++-- .../sdk/entity/FileRevisionUploaderRequest.kt | 4 +++- .../drive/sdk/entity/FileUploaderRequest.kt | 4 +++- .../me/proton/drive/sdk/entity/FolderNode.kt | 6 ++++-- .../kotlin/me/proton/drive/sdk/entity/Node.kt | 6 ++++-- .../proton/drive/sdk/entity/PhotosTimelineItem.kt | 4 +++- .../drive/sdk/entity/PhotosUploaderRequest.kt | 6 ++++-- .../drive/sdk/extension/DegradedFileNode.kt | 4 ++-- .../drive/sdk/extension/DegradedFolderNode.kt | 4 ++-- .../drive/sdk/extension/DegradedRevision.kt | 4 ++-- .../me/proton/drive/sdk/extension/FileNode.kt | 4 ++-- .../drive/sdk/extension/FileUploaderRequest.kt | 3 +-- .../me/proton/drive/sdk/extension/FolderNode.kt | 4 ++-- .../extension/GetFileRevisionUploaderRequest.kt | 2 +- .../drive/sdk/extension/PhotosTimelineList.kt | 2 +- .../drive/sdk/extension/PhotosUploaderRequest.kt | 8 ++------ .../me/proton/drive/sdk/extension/Revision.kt | 4 ++-- .../me/proton/drive/sdk/extension/Timestamp.kt | 15 +++++++++++++++ 24 files changed, 81 insertions(+), 45 deletions(-) rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/{Revision.kt => FileRevision.kt} (77%) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Timestamp.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 3b09a42c..38092246 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -9,6 +9,7 @@ import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.entity.NodeResultPair import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto +import me.proton.drive.sdk.extension.toTimestamp import me.proton.drive.sdk.internal.JniProtonDriveClient import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory @@ -25,6 +26,7 @@ import proton.drive.sdk.driveClientRenameRequest import proton.drive.sdk.driveClientRestoreNodesRequest import proton.drive.sdk.driveClientTrashNodesRequest import java.nio.channels.WritableByteChannel +import java.time.Instant class ProtonDriveClient internal constructor( internal val handle: Long, @@ -89,7 +91,7 @@ class ProtonDriveClient internal constructor( suspend fun createFolder( parentFolderUid: String, name: String, - lastModification: Long? = null, + lastModification: Instant? = null, ): FolderNode = cancellationCoroutineScope { source -> log(INFO, "createFolder") bridge.createFolder( @@ -97,7 +99,7 @@ class ProtonDriveClient internal constructor( this.parentFolderUid = parentFolderUid folderName = name lastModification?.let { - lastModificationTime = timestamp { seconds = lastModification} + lastModificationTime = lastModification.toTimestamp() } clientHandle = handle cancellationTokenSourceHandle = source.handle diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt index ad66a0ee..0d443a1a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt @@ -1,13 +1,15 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class DegradedFileNode( override val uid: String, override val parentUid: String, override val treeEventScopeId: String, override val name: Result, val mediaType: String, - override val creationTime: Long, - override val trashTime: Long?, + override val creationTime: Instant, + override val trashTime: Instant?, override val nameAuthor: Result, override val author: Result, val activeRevision: DegradedRevision?, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt index 55c751cc..a33923ec 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt @@ -1,12 +1,14 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class DegradedFolderNode( override val uid: String, override val parentUid: String?, override val treeEventScopeId: String, override val name: Result, - override val creationTime: Long, - override val trashTime: Long?, + override val creationTime: Instant, + override val trashTime: Instant?, override val nameAuthor: Result, override val author: Result, override val errors: List, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt index 8c7d4d92..5ea29e9b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt @@ -1,12 +1,14 @@ package me.proton.drive.sdk.entity +import java.time.Instant + sealed interface DegradedNode { val uid: String val parentUid: String? val treeEventScopeId: String val name: Result - val creationTime: Long - val trashTime: Long? + val creationTime: Instant + val trashTime: Instant? val nameAuthor: Result val author: Result val errors: List diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt index e81ee27d..ad828418 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt @@ -1,12 +1,14 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class DegradedRevision( val uid: String, - val creationTime: Long, + val creationTime: Instant, val sizeOnCloudStorage: Long, val claimedSize: Long?, val claimedDigests: FileContentDigests?, - val claimedModificationTime: Long?, + val claimedModificationTime: Instant?, val thumbnails: List, val additionalClaimedMetadata: List?, val contentAuthor: Result?, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt index 888768ed..863f67df 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt @@ -1,13 +1,15 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class FileNode( override val uid: String, override val parentUid: String, override val treeEventScopeId: String, override val name: String, val mediaType: String, - override val creationTime: Long, - override val trashTime: Long?, + override val creationTime: Instant, + override val trashTime: Instant?, override val nameAuthor: Result, override val author: Result, val activeRevision: FileRevision, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Revision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt similarity index 77% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Revision.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt index 3c80dd93..784d4078 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Revision.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt @@ -1,12 +1,14 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class FileRevision( val uid: String, - val creationTime: Long, + val creationTime: Instant, val sizeOnCloudStorage: Long, val claimedSize: Long?, val claimedDigests: FileContentDigests, - val claimedModificationTime: Long?, + val claimedModificationTime: Instant?, val thumbnails: List, val additionalClaimedMetadata: List?, val contentAuthor: Result?, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt index 742b5cd4..6a62a8d2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt @@ -1,7 +1,9 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class FileRevisionUploaderRequest( val currentActiveRevisionUid: String, - val lastModificationTime: Long, + val lastModificationTime: Instant, val size: Long, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt index da95ea0c..eb996cda 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt @@ -1,11 +1,13 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class FileUploaderRequest( val parentFolderUid: String, val name: String, val mediaType: String, val fileSize: Long, - val lastModificationTime: Long, + val lastModificationTime: Instant, val overrideExistingDraftByOtherClient: Boolean, val additionalMetadata: Map = emptyMap(), ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt index 33714fbb..f9aaa26f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt @@ -1,12 +1,14 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class FolderNode( override val uid: String, override val parentUid: String?, override val treeEventScopeId: String, override val name: String, - override val creationTime: Long, - override val trashTime: Long?, + override val creationTime: Instant, + override val trashTime: Instant?, override val nameAuthor: Result, override val author: Result, ) : Node diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt index e3b9b49e..27b0502a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt @@ -1,12 +1,14 @@ package me.proton.drive.sdk.entity +import java.time.Instant + sealed interface Node { val uid: String val parentUid: String? val treeEventScopeId: String val name: String - val creationTime: Long - val trashTime: Long? + val creationTime: Instant + val trashTime: Instant? val nameAuthor: Result val author: Result } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt index 6016e104..624bb2c7 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt @@ -1,6 +1,8 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class PhotosTimelineItem( val nodeUid: String, - val captureTime: Long, + val captureTime: Instant, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt index 4702950e..2949fe35 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt @@ -1,11 +1,13 @@ package me.proton.drive.sdk.entity +import java.time.Instant + data class PhotosUploaderRequest( val name: String, val mediaType: String, val fileSize: Long, - val lastModificationTime: Long?, // optional - val captureTime: Long?, // optional + val lastModificationTime: Instant?, // optional + val captureTime: Instant?, // optional val mainPhotoLinkId: String?, // optional val tags: List = emptyList(), // optional val overrideExistingDraftByOtherClient: Boolean, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt index 736e5382..a866c3ca 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt @@ -11,8 +11,8 @@ fun ProtonDriveSdk.DegradedFileNode.toEntity() = DegradedFileNode( treeEventScopeId = treeEventScopeId, name = name.toEntity(), mediaType = mediaType, - creationTime = creationTime.seconds, - trashTime = trashTimeOrNull?.seconds, + creationTime = creationTime.toInstant(), + trashTime = trashTimeOrNull?.toInstant(), nameAuthor = nameAuthor.toEntity(), author = author.toEntity(), activeRevision = activeRevisionOrNull?.toEntity(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt index 47c0f95b..a784c678 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt @@ -9,8 +9,8 @@ fun ProtonDriveSdk.DegradedFolderNode.toEntity() = DegradedFolderNode( parentUid = parentUid, treeEventScopeId = treeEventScopeId, name = name.toEntity(), - creationTime = creationTime.seconds, - trashTime = trashTimeOrNull?.seconds, + creationTime = creationTime.toInstant(), + trashTime = trashTimeOrNull?.toInstant(), nameAuthor = nameAuthor.toEntity(), author = author.toEntity(), errors = errorsList.map { it.toEntity() }, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt index f775539d..c8310a9c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt @@ -8,11 +8,11 @@ import proton.drive.sdk.contentAuthorOrNull fun ProtonDriveSdk.DegradedRevision.toEntity() = DegradedRevision( uid = uid, - creationTime = creationTime.seconds, + creationTime = creationTime.toInstant(), sizeOnCloudStorage = sizeOnCloudStorage, claimedSize = if (hasClaimedSize()) claimedSize else null, claimedDigests = claimedDigestsOrNull?.toEntity(), - claimedModificationTime = claimedModificationTimeOrNull?.seconds, + claimedModificationTime = claimedModificationTimeOrNull?.toInstant(), thumbnails = thumbnailsList.map { it.toEntity() }, additionalClaimedMetadata = if (additionalClaimedMetadataList.isNotEmpty()) { additionalClaimedMetadataList.map { it.toEntity() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt index 5b08889a..fc32d871 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt @@ -10,8 +10,8 @@ fun ProtonDriveSdk.FileNode.toEntity() = FileNode( treeEventScopeId = treeEventScopeId, name = name, mediaType = mediaType, - creationTime = creationTime.seconds, - trashTime = trashTimeOrNull?.seconds, + creationTime = creationTime.toInstant(), + trashTime = trashTimeOrNull?.toInstant(), nameAuthor = nameAuthor.toEntity(), author = author.toEntity(), activeRevision = activeRevision.toEntity(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt index 28a64d22..eece3d4f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt @@ -1,7 +1,6 @@ package me.proton.drive.sdk.extension import com.google.protobuf.kotlin.toByteString -import com.google.protobuf.timestamp import me.proton.drive.sdk.entity.FileUploaderRequest import proton.drive.sdk.additionalMetadataProperty import proton.drive.sdk.driveClientGetFileUploaderRequest @@ -14,7 +13,7 @@ internal fun FileUploaderRequest.toProtobuf( mediaType = this@toProtobuf.mediaType size = this@toProtobuf.fileSize parentFolderUid = this@toProtobuf.parentFolderUid - lastModificationTime = timestamp { seconds = this@toProtobuf.lastModificationTime } + lastModificationTime = this@toProtobuf.lastModificationTime.toTimestamp() overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> additionalMetadataProperty { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt index 2f10b449..29ed066f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt @@ -9,8 +9,8 @@ fun ProtonDriveSdk.FolderNode.toEntity() = FolderNode( parentUid = parentUid, treeEventScopeId = treeEventScopeId, name = name, - creationTime = creationTime.seconds, - trashTime = trashTimeOrNull?.seconds, + creationTime = creationTime.toInstant(), + trashTime = trashTimeOrNull?.toInstant(), nameAuthor = nameAuthor.toEntity(), author = author.toEntity() ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt index 39db093c..f768dc1e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt @@ -12,5 +12,5 @@ internal fun FileRevisionUploaderRequest.toProtobuf( this.cancellationTokenSourceHandle = cancellationTokenSourceHandle this.currentActiveRevisionUid = this@toProtobuf.currentActiveRevisionUid this.size = this@toProtobuf.size - this.lastModificationTime = timestamp { seconds = this@toProtobuf.lastModificationTime } + this.lastModificationTime = this@toProtobuf.lastModificationTime.toTimestamp() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt index 23f9e11b..5497d3c8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt @@ -8,5 +8,5 @@ fun ProtonDriveSdk.PhotosTimelineList.toEntity(): List = fun ProtonDriveSdk.PhotosTimelineItem.toEntity() = PhotosTimelineItem( nodeUid = nodeUid, - captureTime = captureTime.seconds, + captureTime = captureTime.toInstant(), ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt index fe29ff94..5726aa70 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt @@ -17,14 +17,10 @@ internal fun PhotosUploaderRequest.toProtobuf( metadata = photoFileUploadMetadata { mediaType = this@toProtobuf.mediaType this@toProtobuf.captureTime?.let { - captureTime = timestamp { - seconds = it - } + captureTime = it.toTimestamp() } this@toProtobuf.lastModificationTime?.let { - lastModificationTime = timestamp { - seconds = it - } + lastModificationTime = it.toTimestamp() } overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt index 72fa82d8..4cfb74ec 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt @@ -7,11 +7,11 @@ import proton.drive.sdk.contentAuthorOrNull fun ProtonDriveSdk.FileRevision.toEntity() = FileRevision( uid = uid, - creationTime = creationTime.seconds, + creationTime = creationTime.toInstant(), sizeOnCloudStorage = sizeOnCloudStorage, claimedSize = if (hasClaimedSize()) claimedSize else null, claimedDigests = claimedDigests.toEntity(), - claimedModificationTime = claimedModificationTimeOrNull?.seconds, + claimedModificationTime = claimedModificationTimeOrNull?.toInstant(), thumbnails = thumbnailsList.map { it.toEntity() }, additionalClaimedMetadata = if (additionalClaimedMetadataList.isNotEmpty()) { additionalClaimedMetadataList.map { it.toEntity() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Timestamp.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Timestamp.kt new file mode 100644 index 00000000..a0b6ff9f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Timestamp.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk.extension + +import com.google.protobuf.Timestamp +import com.google.protobuf.timestamp +import java.time.Instant + +fun Timestamp.toInstant(): Instant = Instant.ofEpochSecond( + seconds, + nanos.toLong(), +) + +fun Instant.toTimestamp(): Timestamp = timestamp { + seconds = epochSecond + nanos = nano +} From 9dace9fbde4523f1b926e78d24df4975503200e5 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Mar 2026 14:06:54 +0000 Subject: [PATCH 586/791] Prevent resumed uploads from being paused by a stale previous attempt --- .../Nodes/Download/DownloadController.cs | 23 +++++++++++++++---- .../Proton.Drive.Sdk/Nodes/ITaskControl.cs | 1 + .../src/Proton.Drive.Sdk/Nodes/TaskControl.cs | 13 +++++++++-- .../Nodes/Upload/UploadController.cs | 23 +++++++++++++++---- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 02818dbb..843a148c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -29,7 +29,7 @@ internal DownloadController( _onFailed = onFailed; _onSucceeded = onSucceeded; - Completion = PauseOnResumableErrorAsync(downloadTask); + Completion = PauseOnResumableErrorAsync(downloadTask, taskControl.Attempt); } public bool IsPaused => _taskControl.IsPaused; @@ -53,7 +53,8 @@ public void Resume() return; } - Completion = PauseOnResumableErrorAsync(_resumeFunction.Invoke(_taskControl.PauseOrCancellationToken)); + var previousCompletion = Completion; + Completion = ResumeAfterPreviousCompletionAsync(previousCompletion, _taskControl.Attempt); } public async ValueTask DisposeAsync() @@ -105,7 +106,17 @@ private static bool IsResumableError(Exception ex) and not CompletedDownloadManifestVerificationException; } - private async Task PauseOnResumableErrorAsync(Task downloadTask) + private async Task ResumeAfterPreviousCompletionAsync(Task previousCompletion, int attempt) + { + await previousCompletion.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + + await PauseOnResumableErrorAsync( + _resumeFunction.Invoke(_taskControl.PauseOrCancellationToken), + attempt) + .ConfigureAwait(false); + } + + private async Task PauseOnResumableErrorAsync(Task downloadTask, int attempt) { try { @@ -120,7 +131,11 @@ private async Task PauseOnResumableErrorAsync(Task downloadTask) } catch (Exception ex) when (IsResumableError(ex)) { - _taskControl.Pause(); + if (_taskControl.Attempt == attempt) + { + _taskControl.Pause(); + } + throw; } catch diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs index 332bc892..83734d29 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs @@ -5,6 +5,7 @@ internal interface ITaskControl : IDisposable bool IsPaused { get; } bool IsCanceled { get; } CancellationToken CancellationToken { get; } + int Attempt { get; } CancellationToken PauseOrCancellationToken { get; } void Pause(); bool TryResume(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs index 8e75b4a4..71830a6e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs @@ -6,8 +6,10 @@ internal sealed class TaskControl(CancellationToken cancellationToken) : ITaskCo private bool _isDisposed; private TaskCompletionSource? _resumeSignalSource; + private int _attemptCount; private CancellationTokenSource _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + public int Attempt => _attemptCount; public bool IsPaused => _resumeSignalSource is { Task.IsCompleted: false } && !IsCanceled; public bool IsCanceled => CancellationToken.IsCancellationRequested; @@ -51,6 +53,8 @@ public bool TryResume() _pauseCancellationTokenSource.Dispose(); _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); + Interlocked.Increment(ref _attemptCount); + var resumeSignalSource = _resumeSignalSource; _resumeSignalSource = null; @@ -62,8 +66,13 @@ public bool TryResume() public void AbortPause() { - var resumeSignalSource = _resumeSignalSource; - _resumeSignalSource = null; + TaskCompletionSource? resumeSignalSource; + + lock (_pauseLock) + { + resumeSignalSource = _resumeSignalSource; + _resumeSignalSource = null; + } resumeSignalSource?.TrySetCanceled(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 7df3cecd..365f8d27 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -29,7 +29,7 @@ internal UploadController( _onFailed = onFailed; _onSucceeded = onSucceeded; - Completion = PauseOnResumableErrorAsync(uploadTask); + Completion = PauseOnResumableErrorAsync(uploadTask, taskControl.Attempt); } public bool IsPaused => _taskControl.IsPaused; @@ -48,7 +48,8 @@ public void Resume() return; } - Completion = PauseOnResumableErrorAsync(_resumeFunction.Invoke(_taskControl.PauseOrCancellationToken)); + var previousCompletion = Completion; + Completion = ResumeAfterPreviousCompletionAsync(previousCompletion, _taskControl.Attempt); } public async ValueTask DisposeAsync() @@ -106,7 +107,17 @@ and not NodeWithSameNameExistsException and not IntegrityException; } - private async Task PauseOnResumableErrorAsync(Task uploadTask) + private async Task ResumeAfterPreviousCompletionAsync(Task previousCompletion, int attempt) + { + await previousCompletion.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + + return await PauseOnResumableErrorAsync( + _resumeFunction.Invoke(_taskControl.PauseOrCancellationToken), + attempt) + .ConfigureAwait(false); + } + + private async Task PauseOnResumableErrorAsync(Task uploadTask, int attempt) { try { @@ -118,7 +129,11 @@ private async Task PauseOnResumableErrorAsync(Task u } catch (Exception ex) when (IsResumableError(ex)) { - _taskControl.Pause(); + if (_taskControl.Attempt == attempt) + { + _taskControl.Pause(); + } + throw; } catch From 9fbf0cedba56090a2d31bce45d98ccbc853af75c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Mar 2026 14:09:26 +0000 Subject: [PATCH 587/791] Fix manifest verification errors due to wrong thumbnail order in manifest --- .../src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs | 10 +++++----- .../Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs index c7d5a680..c67b1a68 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -19,7 +19,7 @@ internal sealed partial class RevisionDraft( Func deleteDraftFunction, ILogger logger) : IAsyncDisposable { - private readonly Dictionary _thumbnailUploadResults = []; + private readonly SortedDictionary _thumbnailUploadResults = []; private readonly List> _contentBlockStates = []; private readonly Lock _blockUploadStatesLock = new(); @@ -34,8 +34,8 @@ internal sealed partial class RevisionDraft( public IncrementalHash Sha1 { get; } = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); - public IReadOnlyDictionary ThumbnailUploadResults => _thumbnailUploadResults; - public IReadOnlyList> ContentBlockStates => _contentBlockStates; + public IReadOnlyCollection OrderedThumbnailUploadResults => _thumbnailUploadResults.Values; + public IReadOnlyList> OrderedContentBlockStates => _contentBlockStates; public bool IsCompleted { get; set; } public bool IsResumable { get; set; } = true; @@ -89,7 +89,7 @@ public bool ThumbnailBlockWasAlreadyUploaded(ThumbnailType thumbnailType) public int GetNewContentBlockNumber() { - return ContentBlockStates.Count + 1; + return OrderedContentBlockStates.Count + 1; } public bool TryGetNextContentBlockPlainData( @@ -115,7 +115,7 @@ public async ValueTask DisposeAsync() { Sha1.Dispose(); - var dataItemsToDispose = ContentBlockStates + var dataItemsToDispose = OrderedContentBlockStates .Select(x => x.TryGetFirst(out var data) ? data : (BlockUploadPlainData?)null) .Where(task => task is not null) .Select(task => task!.Value); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 971ec32e..0e8d8b36 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -360,13 +360,13 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( string signingEmailAddress, IEnumerable? additionalMetadata) { - var manifest = new byte[(_draft.ThumbnailUploadResults.Count + _draft.ContentBlockStates.Count) * SHA256.HashSizeInBytes]; + var manifest = new byte[(_draft.OrderedThumbnailUploadResults.Count + _draft.OrderedContentBlockStates.Count) * SHA256.HashSizeInBytes]; using var manifestStream = new MemoryStream(manifest); - var contentBlockSizes = new List(_draft.ContentBlockStates.Count); + var contentBlockSizes = new List(_draft.OrderedContentBlockStates.Count); var uploadedContentSize = 0L; - var contentBlockUploadResults = _draft.ContentBlockStates + var contentBlockUploadResults = _draft.OrderedContentBlockStates .Select((blockState, i) => { var blockNumber = i + 1; @@ -376,7 +376,7 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( : throw new IntegrityException($"Missing content block #{blockNumber}"); }); - var blockUploadResults = _draft.ThumbnailUploadResults.Values.Select(x => (Number: 0, x)).Concat(contentBlockUploadResults); + var blockUploadResults = _draft.OrderedThumbnailUploadResults.Select(x => (Number: 0, x)).Concat(contentBlockUploadResults); foreach (var (blockNumber, blockUploadResult) in blockUploadResults) { @@ -399,7 +399,7 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( throw new IntegrityException("Mismatch between uploaded size and expected size"); } - if (expectedThumbnailBlockCount != _draft.ThumbnailUploadResults.Count) + if (expectedThumbnailBlockCount != _draft.OrderedThumbnailUploadResults.Count) { throw new IntegrityException("Unexpected number of thumbnail blocks"); } From d39a1759404cb1a982f0f13e75b4782f2d59895a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 08:28:48 +0000 Subject: [PATCH 588/791] Update changelog for cs/v0.7.0-alpha.17 --- cs/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 204ef6a2..1098b42d 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## cs/v0.7.0-alpha.17 (2026-03-10) + +* Fix manifest verification errors due to wrong thumbnail order in manifest +* Prevent resumed uploads from being paused by a stale previous attempt +* Use java Instant instead for Long to describe time +* Add interop and Kotlin bindings for trash management +* Add context traversal for photo nodes and set telemetry volume type +* Log failed attempts to report decryption errors to telemetry +* Align telemetry with the web SDK + ## cs/v0.7.0-alpha.16 (2026-03-04) * Ensure cancelled uploads/downloads don't block queue From 8b420ab65ac8631cb021a4ff344e088718366ee9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 09:19:32 +0000 Subject: [PATCH 589/791] Handle empty file using single-request-file-upload endpoint --- js/sdk/src/internal/upload/apiService.ts | 66 ++++++++++-------- js/sdk/src/internal/upload/fileUploader.ts | 23 +++++-- js/sdk/src/internal/upload/manager.test.ts | 69 +++++++++++++++++++ js/sdk/src/internal/upload/manager.ts | 36 ++++++---- .../internal/upload/smallFileUploader.test.ts | 56 +++++++++++++++ .../src/internal/upload/smallFileUploader.ts | 42 +++++++---- 6 files changed, 227 insertions(+), 65 deletions(-) diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 22363f77..fefb9b60 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -322,11 +322,13 @@ export class UploadAPIService { }, content: { armoredManifestSignature: string; - block: { - encryptedData: Uint8Array; - armoredSignature: string; - verificationToken: Uint8Array; - }; + block: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined; thumbnails: { type: ThumbnailType; encryptedData: Uint8Array; @@ -348,23 +350,24 @@ export class UploadAPIService { ContentKeyPacket: metadata.base64ContentKeyPacket, ContentKeyPacketSignature: metadata.armoredContentKeyPacketSignature, ManifestSignature: content.armoredManifestSignature, - ContentBlockEncSignature: content.block.encryptedData.length > 0 ? content.block.armoredSignature : null, - ContentBlockVerificationToken: uint8ArrayToBase64String(content.block.verificationToken), + ContentBlockEncSignature: content.block ? content.block.armoredSignature : null, + ContentBlockVerificationToken: content.block + ? uint8ArrayToBase64String(content.block.verificationToken) + : null, XAttr: metadata.armoredExtendedAttributes, Photo: null, // TODO }; const formData = new FormData(); - formData.append( - 'Metadata', - new Blob([JSON.stringify(metadataPayload)], { type: 'application/json' }), - 'Metadata', - ); - if (content.block.encryptedData.length > 0) { - formData.append('ContentBlock', new Blob([content.block.encryptedData]), 'ContentBlock'); + formData.set('Metadata', new Blob([JSON.stringify(metadataPayload)], { type: 'application/json' }), 'Metadata'); + if (content.block) { + formData.set('ContentBlock', new Blob([content.block.encryptedData]), 'ContentBlock'); } for (const thumb of content.thumbnails) { - formData.append( + if (formData.get(`ThumbnailBlockType_${thumb.type}`)) { + throw new Error('Duplicate thumbnail types'); + } + formData.set( `ThumbnailBlockType_${thumb.type}`, new Blob([thumb.encryptedData]), `ThumbnailBlockType_${thumb.type}`, @@ -392,11 +395,13 @@ export class UploadAPIService { }, content: { armoredManifestSignature: string; - block: { - encryptedData: Uint8Array; - armoredSignature: string; - verificationToken: Uint8Array; - }; + block: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined; thumbnails: { type: ThumbnailType; encryptedData: Uint8Array; @@ -411,22 +416,23 @@ export class UploadAPIService { CurrentRevisionID: currentRevisionId, SignatureEmail: metadata.signatureEmail, ManifestSignature: content.armoredManifestSignature, - ContentBlockEncSignature: content.block.armoredSignature, - ContentBlockVerificationToken: uint8ArrayToBase64String(content.block.verificationToken), + ContentBlockEncSignature: content.block ? content.block.armoredSignature : null, + ContentBlockVerificationToken: content.block + ? uint8ArrayToBase64String(content.block.verificationToken) + : null, XAttr: metadata.armoredExtendedAttributes, }; const formData = new FormData(); - formData.append( - 'Metadata', - new Blob([JSON.stringify(metadataPayload)], { type: 'application/json' }), - 'Metadata', - ); - if (content.block.encryptedData.length > 0) { - formData.append('ContentBlock', new Blob([content.block.encryptedData]), 'ContentBlock'); + formData.set('Metadata', new Blob([JSON.stringify(metadataPayload)], { type: 'application/json' }), 'Metadata'); + if (content.block) { + formData.set('ContentBlock', new Blob([content.block.encryptedData]), 'ContentBlock'); } for (const thumb of content.thumbnails) { - formData.append( + if (formData.get(`ThumbnailBlockType_${thumb.type}`)) { + throw new Error('Duplicate thumbnail types'); + } + formData.set( `ThumbnailBlockType_${thumb.type}`, new Blob([thumb.encryptedData]), `ThumbnailBlockType_${thumb.type}`, diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index e41a4197..4441e434 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -51,9 +51,9 @@ export abstract class Uploader { thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, ): Promise { - if (this.controller.promise) { - throw new Error(`Upload already started`); - } + this.assertNotStartedYet(); + this.assertUniqueThumbnailTypes(thumbnails); + if (!this.metadata.mediaType) { this.metadata.mediaType = fileObject.type; } @@ -72,11 +72,24 @@ export abstract class Uploader { thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, ): Promise { + this.assertNotStartedYet(); + this.assertUniqueThumbnailTypes(thumbnails); + + this.controller.promise = this.startUpload(stream, thumbnails, onProgress); + return this.controller; + } + + private assertNotStartedYet(): void { if (this.controller.promise) { throw new Error(`Upload already started`); } - this.controller.promise = this.startUpload(stream, thumbnails, onProgress); - return this.controller; + } + + private assertUniqueThumbnailTypes(thumbnails: Thumbnail[]): void { + const uniqueThumbnailTypes = new Set(thumbnails.map(({ type }) => type)); + if (uniqueThumbnailTypes.size !== thumbnails.length) { + throw new Error('Duplicate thumbnail types'); + } } protected async startUpload( diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 596b3012..1e6d7514 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -489,6 +489,39 @@ describe('UploadManager', () => { expect(apiService.deleteDraft).toHaveBeenCalledWith('volumeId~existingLinkId'); expect(apiService.uploadSmallFile).toHaveBeenCalledTimes(2); }); + + it('should call uploadSmallFile with block undefined for zero-byte file', async () => { + const result = await manager.uploadFile( + 'parentUid', + nodeCrypto, + { ...metadata, expectedSize: 0 }, + commitPayload, + undefined, + [], + ); + + expect(result).toEqual({ + nodeUid: 'uploaded:nodeUid', + nodeRevisionUid: 'uploaded:nodeRevisionUid', + }); + expect(apiService.uploadSmallFile).toHaveBeenCalledWith( + 'parentUid', + expect.objectContaining({ + armoredEncryptedName: 'encName', + hash: 'hash', + mediaType: 'application/octet-stream', + armoredExtendedAttributes: 'extAttr', + signatureEmail: 'signatureEmail', + }), + { + armoredManifestSignature: 'manifestSignature', + block: undefined, + thumbnails: [], + }, + undefined, + ); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid'); + }); }); describe('uploadSmallRevision', () => { @@ -558,6 +591,42 @@ describe('UploadManager', () => { ); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('fileNodeUid'); }); + + it('should call uploadSmallRevision with block undefined for zero-byte revision', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + uid: 'fileNodeUid', + parentUid: 'parentUid', + activeRevision: { ok: true, value: { uid: 'currentRevisionUid' } }, + }); + + const result = await manager.uploadSmallRevision( + 'fileNodeUid', + nodeCrypto, + commitPayload, + undefined, + [], + ); + + expect(result).toEqual({ + nodeUid: 'revised:nodeUid', + nodeRevisionUid: 'revised:nodeRevisionUid', + }); + expect(apiService.uploadSmallRevision).toHaveBeenCalledWith( + 'fileNodeUid', + 'currentRevisionUid', + { + signatureEmail: 'signatureEmail', + armoredExtendedAttributes: 'extAttr', + }, + { + armoredManifestSignature: 'manifestSig', + block: undefined, + thumbnails: [], + }, + undefined, + ); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('fileNodeUid'); + }); }); describe('commit draft', () => { diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 63cf5216..d35fdbc6 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -154,11 +154,13 @@ export class UploadManager { armoredManifestSignature: string; armoredExtendedAttributes: string; }, - encryptedBlock: { - encryptedData: Uint8Array; - armoredSignature: string; - verificationToken: Uint8Array; - }, + encryptedBlock: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined, encryptedThumbnails: { type: ThumbnailType; encryptedData: Uint8Array }[], signal?: AbortSignal, ): Promise<{ nodeUid: string; nodeRevisionUid: string }> { @@ -179,11 +181,13 @@ export class UploadManager { }, { armoredManifestSignature: commitPayload.armoredManifestSignature, - block: { - encryptedData: encryptedBlock.encryptedData, - armoredSignature: encryptedBlock.armoredSignature, - verificationToken: encryptedBlock.verificationToken, - }, + block: encryptedBlock + ? { + encryptedData: encryptedBlock.encryptedData, + armoredSignature: encryptedBlock.armoredSignature, + verificationToken: encryptedBlock.verificationToken, + } + : undefined, thumbnails: encryptedThumbnails, }, signal, @@ -212,11 +216,13 @@ export class UploadManager { armoredManifestSignature: string; armoredExtendedAttributes: string; }, - encryptedBlock: { - encryptedData: Uint8Array; - armoredSignature: string; - verificationToken: Uint8Array; - }, + encryptedBlock: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined, encryptedThumbnails: { type: ThumbnailType; encryptedData: Uint8Array }[], signal?: AbortSignal, ): Promise<{ nodeUid: string; nodeRevisionUid: string }> { diff --git a/js/sdk/src/internal/upload/smallFileUploader.test.ts b/js/sdk/src/internal/upload/smallFileUploader.test.ts index 1e4eb24e..5a719ede 100644 --- a/js/sdk/src/internal/upload/smallFileUploader.test.ts +++ b/js/sdk/src/internal/upload/smallFileUploader.test.ts @@ -298,6 +298,39 @@ describe('SmallFileUploader', () => { }); }); }); + + describe('zero-byte file', () => { + it('should upload zero-byte file without calling encryptBlock and pass undefined block to manager.uploadFile', async () => { + metadata.expectedSize = 0; + const uploader = createUploader(); + const stream = createStream([]); + const onProgress = jest.fn(); + + const controller = await uploader.uploadFromStream(stream, [], onProgress); + const result = await controller.completion(); + + expect(result).toEqual({ nodeUid: 'nodeUid', nodeRevisionUid: 'nodeRevisionUid' }); + expect(cryptoService.encryptBlock).not.toHaveBeenCalled(); + expect(uploadManager.uploadFile).toHaveBeenCalledWith( + parentFolderUid, + mockNodeCrypto, + metadata, + expect.objectContaining({ + armoredManifestSignature: 'mockManifestSignature', + armoredExtendedAttributes: 'mockExtendedAttributes', + }), + undefined, + [], + ); + expect(cryptoService.commitFile).toHaveBeenCalledWith( + expect.anything(), + new Uint8Array(0), + expect.any(String), + ); + expect(onFinish).toHaveBeenCalled(); + expect(onProgress).toHaveBeenCalledWith(0); + }); + }); }); describe('SmallFileRevisionUploader', () => { @@ -432,4 +465,27 @@ describe('SmallFileRevisionUploader', () => { [], ); }); + + it('should upload zero-byte revision without calling encryptBlock and pass undefined block to uploadSmallRevision', async () => { + metadata.expectedSize = 0; + const uploader = createUploader(); + const stream = createStream([]); + + const controller = await uploader.uploadFromStream(stream, [], undefined); + const result = await controller.completion(); + + expect(result).toEqual({ nodeUid: 'nodeUid', nodeRevisionUid: 'nodeRevisionUid' }); + expect(cryptoService.encryptBlock).not.toHaveBeenCalled(); + expect(uploadManager.uploadSmallRevision).toHaveBeenCalledWith( + nodeUid, + mockNodeKeys, + expect.objectContaining({ + armoredManifestSignature: 'mockManifestSignature', + armoredExtendedAttributes: 'mockExtendedAttributes', + }), + undefined, + [], + ); + expect(cryptoService.commitFile).toHaveBeenCalledWith(expect.anything(), new Uint8Array(0), expect.any(String)); + }); }); diff --git a/js/sdk/src/internal/upload/smallFileUploader.ts b/js/sdk/src/internal/upload/smallFileUploader.ts index 128bc7cd..5f654f8e 100644 --- a/js/sdk/src/internal/upload/smallFileUploader.ts +++ b/js/sdk/src/internal/upload/smallFileUploader.ts @@ -90,11 +90,13 @@ abstract class SmallUploader extends Uploader { armoredManifestSignature: string; armoredExtendedAttributes: string; }; - encryptedBlock: { - encryptedData: Uint8Array; - armoredSignature: string; - verificationToken: Uint8Array; - }; + encryptedBlock: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined; encryptedThumbnails: { type: ThumbnailType; encryptedData: Uint8Array }[]; }> { const content = await this.readStreamContent(stream); @@ -158,13 +160,21 @@ abstract class SmallUploader extends Uploader { private async encryptContentBlock( nodeKeys: NodeKeys, content: Uint8Array, - ): Promise<{ - encryptedData: Uint8Array; - armoredSignature: string; - verificationToken: Uint8Array; - blockHash: Uint8Array; - }> { + ): Promise< + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + blockHash: Uint8Array; + } + | undefined + > { this.logger.debug(`Encrypting block`); + + if (content.length === 0) { + return; + } + let attempt = 0; let integrityError = false; let encrypted; @@ -221,16 +231,18 @@ abstract class SmallUploader extends Uploader { private async encryptCommitPayload( nodeKeys: NodeKeys, contentSha1: string, - encryptedBlock: { - blockHash: Uint8Array; - }, + encryptedBlock: + | { + blockHash: Uint8Array; + } + | undefined, ): Promise<{ armoredManifestSignature: string; armoredExtendedAttributes: string; }> { this.logger.debug(`Preparing commit payload`); - const manifest = encryptedBlock.blockHash ? new Uint8Array(encryptedBlock.blockHash) : new Uint8Array(0); + const manifest = encryptedBlock ? encryptedBlock.blockHash : new Uint8Array(0); const extendedAttributes = generateFileExtendedAttributes( { modificationTime: this.metadata.modificationTime, From 454fbd41a161b5731991951502236ac0858a76dc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 11:16:27 +0000 Subject: [PATCH 590/791] Add owned by property --- .../InteropProtonDriveClient.cs | 16 ++ .../src/Proton.Drive.Sdk/Api/Links/LinkDto.cs | 5 +- .../Proton.Drive.Sdk/Api/Links/OwnedByDto.cs | 12 + .../Nodes/DegradedFolderNode.cs | 3 +- .../Proton.Drive.Sdk/Nodes/DegradedNode.cs | 4 +- .../Nodes/DtoToMetadataConverter.cs | 12 +- .../Nodes/FolderOperations.cs | 11 + cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 4 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/OwnedBy.cs | 8 + .../Volumes/VolumeOperations.cs | 4 +- cs/sdk/src/protos/proton.drive.sdk.proto | 9 + js/sdk/src/interface/nodes.ts | 7 + js/sdk/src/internal/apiService/driveTypes.ts | 211 +++++++++--------- js/sdk/src/internal/nodes/apiService.test.ts | 9 + js/sdk/src/internal/nodes/apiService.ts | 4 + js/sdk/src/internal/nodes/interface.ts | 4 + js/sdk/src/internal/nodes/nodesManagement.ts | 14 +- js/sdk/src/internal/photos/albumsManager.ts | 1 + js/sdk/src/internal/photos/nodes.ts | 4 +- js/sdk/src/transformers.ts | 2 + 20 files changed, 229 insertions(+), 115 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Links/OwnedByDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/OwnedBy.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 48014fc1..20fde88c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -103,6 +103,7 @@ public static async ValueTask HandleCreateFolderAsync(DriveClientCreat TrashTime = createdFolder.TrashTime?.ToUniversalTime().ToTimestamp(), NameAuthor = ParseAuthorResult(createdFolder.NameAuthor), Author = ParseAuthorResult(createdFolder.Author), + OwnedBy = MapOwnedByToProto(createdFolder.OwnedBy), }; } @@ -221,6 +222,7 @@ public static async ValueTask HandleGetMyFilesFolderAsync(DriveClientG TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), NameAuthor = ParseAuthorResult(folderNode.NameAuthor), Author = ParseAuthorResult(folderNode.Author), + OwnedBy = MapOwnedByToProto(folderNode.OwnedBy), }; } @@ -387,6 +389,16 @@ private static AuthorResult ParseAuthorResult(Result Author { get; init; } + public required OwnedBy OwnedBy { get; init; } + public required IReadOnlyList Errors { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 9268b0f7..81ebdece 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; @@ -261,6 +261,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( ContentAuthor = contentAuthor, }; + var ownedBy = MapOwnedBy(linkDto.OwnedBy); var node = linkDetailsDto.Photo is not null ? new PhotoNode { @@ -276,6 +277,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, CaptureTime = linkDetailsDto.Photo.CaptureTime, AlbumUids = linkDetailsDto.Photo.AlbumInclusions.Select(a => new NodeUid(uid.VolumeId, a.Id)).ToList(), + OwnedBy = ownedBy, } : new FileNode { @@ -289,6 +291,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( MediaType = fileDto.MediaType, ActiveRevision = activeRevision, TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, + OwnedBy = ownedBy, }; await secretCache.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); @@ -370,6 +373,7 @@ private static (DegradedFileMetadata Metadata, List FailedDecryp Errors = revisionErrors, }; + var ownedBy = MapOwnedBy(linkDto.OwnedBy); var degradedNode = linkDetailsDto.Photo is not null ? new DegradedPhotoNode { @@ -386,6 +390,7 @@ private static (DegradedFileMetadata Metadata, List FailedDecryp Errors = errors, CaptureTime = linkDetailsDto.Photo.CaptureTime, AlbumUids = linkDetailsDto.Photo.AlbumInclusions.Select(a => new NodeUid(uid.VolumeId, a.Id)).ToList(), + OwnedBy = ownedBy, } : new DegradedFileNode { @@ -400,6 +405,7 @@ private static (DegradedFileMetadata Metadata, List FailedDecryp ActiveRevision = degradedRevision, TotalStorageQuotaUsage = fileDto.TotalSizeOnStorage, Errors = errors, + OwnedBy = ownedBy, }; var degradedSecrets = new DegradedFileSecrets @@ -489,6 +495,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( Author = nodeAuthor, CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, + OwnedBy = MapOwnedBy(linkDto.OwnedBy), }; await secretCache.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); @@ -555,6 +562,7 @@ private static (DegradedFolderMetadata Metadata, List FailedDecr TrashTime = linkDto.TrashTime, Author = nodeAuthor, Errors = errors, + OwnedBy = MapOwnedBy(linkDto.OwnedBy), }; var degradedSecrets = new DegradedFolderSecrets @@ -684,4 +692,6 @@ async Task GetLinkDetailsAsync(IEnumerable links, Cancel return response.Links[0]; } } + + private static OwnedBy MapOwnedBy(OwnedByDto? dto) => new(dto?.Email, dto?.Organization); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 7cd7854e..1e7b78dc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -65,6 +65,16 @@ public static async ValueTask CreateAsync( DateTimeOffset? lastModificationTime, CancellationToken cancellationToken) { + var parentResult = await client.GetNodeAsync(parentUid, cancellationToken).ConfigureAwait(false); + if (parentResult is null) + { + throw new InvalidOperationException("Parent node not found."); + } + + var parentOwnedBy = parentResult.Value.TryGetValueElseError(out var parentNode, out var parentDegraded) + ? parentNode.OwnedBy + : parentDegraded.OwnedBy; + var parentSecrets = await GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, parentUid, cancellationToken).ConfigureAwait(false); @@ -137,6 +147,7 @@ public static async ValueTask CreateAsync( NameAuthor = author, Author = author, CreationTime = DateTime.UtcNow, + OwnedBy = parentOwnedBy, }; await client.Cache.Entities.SetNodeAsync(folderUid, folderNode, membershipShareId: null, nameHashDigest, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index 48272747..e80145b8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; @@ -25,4 +25,6 @@ public abstract record Node public required Result NameAuthor { get; init; } public required Result Author { get; init; } + + public required OwnedBy OwnedBy { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/OwnedBy.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/OwnedBy.cs new file mode 100644 index 00000000..670f2490 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/OwnedBy.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes; + +/// +/// Owner of the node (who owns the volume where the node is located). +/// +/// Email of the owner for regular and photo volumes, null otherwise. +/// Organization name for org. volumes, null otherwise. +public sealed record OwnedBy(string? Email = null, string? Organization = null); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index fa306a7e..74e40e74 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; @@ -42,6 +42,7 @@ internal static class VolumeOperations NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, Author = new Author { EmailAddress = defaultAddress.EmailAddress }, CreationTime = DateTime.UtcNow, + OwnedBy = new OwnedBy(Email: defaultAddress.EmailAddress), }; // The volume root folder never has siblings and does not need a name hash digest @@ -141,6 +142,7 @@ public static async IAsyncEnumerable> EnumerateTrashA NameAuthor = new Author { EmailAddress = defaultAddress.EmailAddress }, Author = new Author { EmailAddress = defaultAddress.EmailAddress }, CreationTime = DateTime.UtcNow, + OwnedBy = new OwnedBy(Email: defaultAddress.EmailAddress), }; // The volume root folder never has siblings and does not need a name hash digest diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index fa14ce2f..972c7eab 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -133,6 +133,7 @@ message FileNode { AuthorResult author = 9; FileRevision active_revision = 10; int64 total_size_on_cloud_storage = 11; + OwnedBy owned_by = 12; } message FolderNode { @@ -144,6 +145,7 @@ message FolderNode { google.protobuf.Timestamp trash_time = 6; // optional AuthorResult name_author = 7; AuthorResult author = 8; + OwnedBy owned_by = 9; } message SignatureVerificationError { @@ -162,6 +164,11 @@ message Author { string email_address = 1; // optional } +message OwnedBy { + string email = 1; // optional - owner email for regular and photo volumes + string organization = 2; // optional - organization name for org. volumes +} + message ProgressUpdate { int64 bytes_completed = 1; int64 bytes_in_total = 2; @@ -478,6 +485,7 @@ message DegradedFolderNode { AuthorResult name_author = 7; AuthorResult author = 8; repeated DriveError errors = 9; + OwnedBy owned_by = 10; } message DegradedFileNode { @@ -493,6 +501,7 @@ message DegradedFileNode { DegradedRevision active_revision = 10; // optional int64 total_storage_quota_usage = 11; repeated DriveError errors = 12; + OwnedBy owned_by = 13; } message FileContentDigests { diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index a96585d0..b47c0fa4 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -68,6 +68,13 @@ export type NodeEntity = { * membership is inherited from the parent node. */ membership?: Membership; + /** + * Owner of the node (who owns the volume where the node is located). + */ + ownedBy: { + email?: string; + organization?: string; + }; type: NodeType; mediaType?: string; /** diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index d1d7c8ec..3d91e8d3 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -1392,7 +1392,7 @@ export interface paths { put?: never; /** * Request block upload - * @description Request upload information for a set of blocks. + * @description Request upload URLs for a set of blocks/thumbnails of a given draft revision. */ post: operations["post_drive-blocks"]; delete?: never; @@ -1410,10 +1410,7 @@ export interface paths { }; get?: never; put?: never; - /** - * Upload small file - * @description This does not support anonymous uploads (yet) - */ + /** Upload small file */ post: operations["post_drive-v2-volumes-{volumeID}-files-small"]; delete?: never; options?: never; @@ -1430,10 +1427,7 @@ export interface paths { }; get?: never; put?: never; - /** - * Upload small revision - * @description This does not support anonymous uploads (yet) - */ + /** Upload small revision */ post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small"]; delete?: never; options?: never; @@ -2148,7 +2142,10 @@ export interface paths { path?: never; cookie?: never; }; - /** List URLs on share */ + /** + * List URLs on share + * @description There can only be one or zero share URLs on a given share. + */ get: operations["get_drive-shares-{shareID}-urls"]; put?: never; /** Share by URL */ @@ -2465,6 +2462,46 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/unauth/v2/volumes/{volumeID}/files/small": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload small file + * @description See /drive/v2/volumes/{volumeID}/files/small for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-files-small"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/v2/volumes/{volumeID}/files/{linkID}/revisions/small": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload small revision + * @description See /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-small"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/unauth/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification": { parameters: { query?: never; @@ -4540,6 +4577,15 @@ export interface components { * @enum {integer} */ LinkState2: 0 | 1 | 2; + OwnedByDto: { + /** + * Format: email + * @description OwnerUser email for regular and photo volumes, null otherwise + */ + Email?: string | null; + /** @description OwnerOrganization name for org. volumes, null otherwise */ + Organization?: string | null; + }; LinkDto: { LinkID: components["schemas"]["Id"]; Type: components["schemas"]["NodeType2"]; @@ -4557,6 +4603,7 @@ export interface components { SignatureEmail?: string | null; /** Format: email */ NameSignatureEmail?: string | null; + OwnedBy: components["schemas"]["OwnedByDto"]; /** @default null */ DirectPermissions: number | null; }; @@ -5236,6 +5283,8 @@ export interface components { State: components["schemas"]["HealthCheckState"]; }; AbuseReportDto: { + /** @description Passphrase for reported Link's Node key, unencrypted, as a string, escaped for JSON. */ + ResourcePassphrase: string; /** * @description Reported ShareURL, complete including fragment * @example https://drive.proton.me/urls/1F9BKXYDMA#yF7d7bn01GMM @@ -5243,8 +5292,6 @@ export interface components { ShareURL: string; /** @enum {string} */ AbuseCategory: "spam" | "copyright" | "child-abuse" | "stolen-data" | "malware" | "other"; - /** @description Passphrase for reported Link's Node key, unencrypted, as a string, escaped for JSON. */ - ResourcePassphrase: string; /** * @description Full password, including custom part, as string, escaped for JSON * @default @@ -5984,11 +6031,10 @@ export interface components { ShareURLs: components["schemas"]["ShareURLResponseDto"][]; /** * @deprecated - * @description If the Recursive query parameter is set, also returns the related links and ancestors up to the share as a dictionary by LinkID. + * @description Unused and deprecated. Always empty. + * @default [] */ - Links: { - [key: string]: components["schemas"]["ExtendedLinkTransformer"]; - }; + Links: Record; /** * ProtonResponseCode * @example 1000 @@ -7275,23 +7321,6 @@ export interface operations { }; }; }; - /** @description Failed dependency */ - 424: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** - * @description Potential codes: - * - 2032 - * - * @enum {integer} - */ - Code: 2032; - }; - }; - }; }; }; "post_drive-photos-volumes": { @@ -7333,23 +7362,6 @@ export interface operations { }; }; }; - /** @description Failed dependency */ - 424: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** - * @description Potential codes: - * - 2032 - * - * @enum {integer} - */ - Code: 2032; - }; - }; - }; }; }; "put_drive-photos-volumes-{volumeID}-albums-{linkID}": { @@ -7393,26 +7405,6 @@ export interface operations { }; }; }; - /** @description Failed dependency */ - 424: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** - * @description Potential codes: - * - 2501: File or folder not found - * - 2001: Invalid PGP message - * - 200501: Operation failed: Please retry - * - 2032 - * - * @enum {integer} - */ - Code: 2032; - }; - }; - }; }; }; "delete_drive-photos-volumes-{volumeID}-albums-{linkID}": { @@ -10832,23 +10824,6 @@ export interface operations { }; }; }; - /** @description Failed dependency */ - 424: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** - * @description Potential codes: - * - 2032 - * - * @enum {integer} - */ - Code: 2032; - }; - }; - }; }; }; "post_drive-urls-{token}-auth": { @@ -11570,20 +11545,7 @@ export interface operations { }; "get_drive-shares-{shareID}-urls": { parameters: { - query?: { - /** - * @deprecated - * @description By default, only shareURL pointing to the share are returned. With Recursive=1, list all shareURLs in the subtree reachable from the Share. 1 (true) or 0 (false). - */ - Recursive?: 0 | 1; - /** - * @deprecated - * @description Fetch Thumbnail URLs - */ - Thumbnails?: 0 | 1; - PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; - Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; - }; + query?: never; header?: never; path: { shareID: string; @@ -12120,6 +12082,53 @@ export interface operations { }; }; }; + "post_drive-unauth-v2-volumes-{volumeID}-files-small": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmallUploadResponseDto"]; + }; + }; + }; + }; + "post_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-small": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmallUploadResponseDto"]; + }; + }; + }; + }; "get_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification": { parameters: { query?: never; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index e19c6c14..92edabc7 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -74,6 +74,11 @@ function generateAPINode() { NodeKey: 'nodeKey', NodePassphrase: 'nodePass', NodePassphraseSignature: 'nodePassSig', + + OwnedBy: { + Email: 'ownerByEmail', + Organization: null, + }, }, SharingSummary: null, }; @@ -149,6 +154,10 @@ function generateNode() { isSharedPublicly: false, directRole: MemberRole.Admin, membership: undefined, + ownedBy: { + email: 'ownerByEmail', + organization: undefined, + }, encryptedCrypto: { armoredKey: 'nodeKey', diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 1c7403e4..2a8a1d72 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -780,6 +780,10 @@ export function linkToEncryptedNodeBaseMetadata( inviteTime: new Date(link.Membership.InviteTime * 1000), } : undefined, + ownedBy: { + email: link.Link.OwnedBy?.Email || undefined, + organization: link.Link.OwnedBy?.Organization || undefined, + }, }; const baseCryptoNodeMetadata = { diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index bdccc9b2..b4099b1d 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -47,6 +47,10 @@ interface BaseNode { inviteTime: Date; // TODO: acceptedBy: Author; }; + ownedBy: { + email?: string; + organization?: string; + }; } /** diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 9bc3f2f1..8143aa91 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -312,6 +312,7 @@ export abstract class NodesManagementBase< async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { validateNodeName(folderName); + const parentNode = await this.nodesAccess.getNode(parentNodeUid); const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); if (!parentKeys.hashKey) { throw new ValidationError(c('Error').t`Creating folders in non-folders is not allowed`); @@ -338,14 +339,14 @@ export abstract class NodesManagementBase< }); await this.nodesAccess.notifyChildCreated(parentNodeUid); - const node = this.generateNodeFolder(nodeUid, parentNodeUid, folderName, encryptedCrypto); + const node = this.generateNodeFolder(parentNode, nodeUid, folderName, encryptedCrypto); await this.cryptoCache.setNodeKeys(nodeUid, keys); return node; } protected abstract generateNodeFolder( + parentNode: TDecryptedNode, nodeUid: string, - parentUid: string, name: string, encryptedCrypto: { hash: string; @@ -355,8 +356,8 @@ export abstract class NodesManagementBase< ): TDecryptedNode; protected generateNodeFolderBase( + parentNode: TDecryptedNode, nodeUid: string, - parentNodeUid: string, name: string, encryptedCrypto: { hash: string; @@ -371,7 +372,7 @@ export abstract class NodesManagementBase< // Basic node metadata uid: nodeUid, - parentUid: parentNodeUid, + parentUid: parentNode.uid, type: NodeType.Folder, mediaType: FOLDER_MEDIA_TYPE, creationTime: new Date(), @@ -381,6 +382,7 @@ export abstract class NodesManagementBase< isShared: false, isSharedPublicly: false, directRole: MemberRole.Inherited, + ownedBy: parentNode.ownedBy, // Decrypted metadata isStale: false, @@ -432,8 +434,8 @@ export abstract class NodesManagementBase< export class NodesManagement extends NodesManagementBase { protected generateNodeFolder( + parentNode: DecryptedNode, nodeUid: string, - parentNodeUid: string, name: string, encryptedCrypto: { hash: string; @@ -441,6 +443,6 @@ export class NodesManagement extends NodesManagementBase { signatureEmail: string | null; }, ): DecryptedNode { - return this.generateNodeFolderBase(nodeUid, parentNodeUid, name, encryptedCrypto); + return this.generateNodeFolderBase(parentNode, nodeUid, name, encryptedCrypto); } } diff --git a/js/sdk/src/internal/photos/albumsManager.ts b/js/sdk/src/internal/photos/albumsManager.ts index 73470284..7e198f47 100644 --- a/js/sdk/src/internal/photos/albumsManager.ts +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -95,6 +95,7 @@ export class AlbumsManager { isShared: false, isSharedPublicly: false, directRole: MemberRole.Inherited, + ownedBy: rootNode.ownedBy, // Decrypted metadata isStale: false, diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 901bd417..417435c6 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -252,8 +252,8 @@ export class PhotosNodesManagement extends NodesManagementBase< PhotosNodesCryptoService > { protected generateNodeFolder( + parentNode: DecryptedPhotoNode, nodeUid: string, - parentNodeUid: string, name: string, encryptedCrypto: { hash: string; @@ -261,6 +261,6 @@ export class PhotosNodesManagement extends NodesManagementBase< signatureEmail: string | null; }, ): DecryptedPhotoNode { - return this.generateNodeFolderBase(nodeUid, parentNodeUid, name, encryptedCrypto); + return this.generateNodeFolderBase(parentNode, nodeUid, name, encryptedCrypto); } } diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index f2eac039..d6f2d1d4 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -24,6 +24,7 @@ type InternalPartialNode = Pick< | 'nameAuthor' | 'directRole' | 'membership' + | 'ownedBy' | 'type' | 'mediaType' | 'isShared' @@ -93,6 +94,7 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode nameAuthor: node.nameAuthor, directRole: node.directRole, membership: node.membership, + ownedBy: node.ownedBy, type: node.type, mediaType: node.mediaType, isShared: node.isShared, From 58434d3732264a81e8a5a3e2244d3cecb8a6c882 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 11:29:06 +0000 Subject: [PATCH 591/791] Propagate individual thumbnail errors to callers instead of silently skipping them --- .../InteropProtonDriveClient.cs | 33 ++++-- .../InteropProtonPhotosClient.cs | 15 ++- .../Proton.Drive.Sdk/ExceptionExtensions.cs | 5 + .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 107 +++++++++++++++--- .../Proton.Drive.Sdk/Nodes/FileThumbnail.cs | 6 +- .../src/Proton.Drive.Sdk/ProtonDriveError.cs | 5 + cs/sdk/src/protos/proton.drive.sdk.proto | 5 +- .../me/proton/drive/sdk/ProtonDriveClient.kt | 10 +- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 11 +- .../proton/drive/sdk/entity/FileThumbnail.kt | 6 + .../proton/drive/sdk/extension/DriveError.kt | 1 + .../drive/sdk/extension/FileThumbnail.kt | 18 +++ .../proton/drive/sdk/extension/NodeResult.kt | 2 +- .../Sources/Plumbing/PublicTypes.swift | 12 +- .../ProtonDriveSDKDriveError.swift | 33 ++++++ 15 files changed, 218 insertions(+), 51 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileThumbnail.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt create mode 100644 swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 20fde88c..d9436fbc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -178,10 +178,19 @@ public static async ValueTask HandleGetThumbnailsAsync(DriveClientGetT cancellationToken); var thumbnails = await thumbnailsEnumerable - .Select(x => new FileThumbnail + .Select(x => { - FileUid = x.FileUid.ToString(), - Data = ByteString.CopyFrom(x.Data.Span), + var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() }; + if (x.Result.TryGetValueElseError(out var data, out var error)) + { + thumbnail.Data = ByteString.CopyFrom(data.Span); + } + else + { + thumbnail.Error = ConvertToDriveError(error); + } + + return thumbnail; }) .ToListAsync(cancellationToken).ConfigureAwait(false); @@ -339,6 +348,15 @@ public static NodeResult ConvertToNodeResult(Result> results) { return new NodeResultListResponse @@ -574,15 +592,6 @@ private static DegradedNode ConvertToDegradedNode(Proton.Drive.Sdk.Nodes.Degrade return result; } - private static DriveError ConvertToDriveError(ProtonDriveError error) - { - return new DriveError - { - Message = error.Message ?? string.Empty, - InnerError = error.InnerError != null ? ConvertToDriveError(error.InnerError) : null, - }; - } - private static StringResult ConvertStringToStringResult(Result result) { var stringResult = new StringResult(); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index b29834fd..4b0a8bbf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -138,10 +138,19 @@ public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(Dri cancellationToken); var thumbnails = await thumbnailsEnumerable - .Select(x => new FileThumbnail + .Select(x => { - FileUid = x.FileUid.ToString(), - Data = ByteString.CopyFrom(x.Data.Span), + var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() }; + if (x.Result.TryGetValueElseError(out var data, out var error)) + { + thumbnail.Data = ByteString.CopyFrom(data.Span); + } + else + { + thumbnail.Error = InteropProtonDriveClient.ConvertToDriveError(error); + } + + return thumbnail; }) .ToListAsync(cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs index bc9b0ea8..2c33288f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs @@ -24,6 +24,11 @@ private enum ErrorCodeFormat Adaptive, } + public static ProtonDriveError ToProtonDriveError(this Exception exception) + { + return new ProtonDriveError(exception.Message, exception.InnerException?.ToProtonDriveError()); + } + public static string FlattenMessage(this Exception exception) { var previousMessage = string.Empty; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 96070737..0e08577a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -1,11 +1,10 @@ -using System.Runtime.CompilerServices; -using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; using Proton.Drive.Sdk.Api.Files; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; -internal static partial class FileOperations +internal static class FileOperations { public static async ValueTask> GetSecretsAsync( ProtonDriveClient client, @@ -31,34 +30,88 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( ThumbnailType thumbnailType, [EnumeratorCancellation] CancellationToken cancellationToken) { - var logger = client.Telemetry.GetLogger("Thumbnail enumeration"); - // TODO: optimize parallelization for when UIDs are scattered over many volumes foreach (var volumeLinkIdGroup in fileUids.GroupBy(uid => uid.VolumeId, uid => uid.LinkId)) { var volumeId = volumeLinkIdGroup.Key; - var nodeResults = NodeOperations.EnumerateNodesAsync(client, volumeId, volumeLinkIdGroup, cancellationToken); + var unprocessedLinkIds = volumeLinkIdGroup.ToHashSet(); + + var nodeResults = NodeOperations.EnumerateNodesAsync(client, volumeId, unprocessedLinkIds, cancellationToken); + + var errors = new List(); var thumbnailIds = await nodeResults - .Select(nodeResult => nodeResult.TryGetValue(out var node) ? node as FileNode : null) - .Where(fileNode => fileNode is not null) - .SelectMany(fileNode => + .Select(FileNodeInfo? (nodeResult) => { - var thumbnails = fileNode!.ActiveRevision.Thumbnails; + nodeResult.TryGetValueElseError(out var node, out var degradedNode); + + if ((node?.Uid.LinkId ?? degradedNode?.Uid.LinkId) is { } processedLinkId) + { + unprocessedLinkIds.Remove(processedLinkId); + } + + if (node is FileNode fileNode) + { + return new FileNodeInfo(fileNode.Uid, fileNode.ActiveRevision.Uid, fileNode.ActiveRevision.Thumbnails); + } + + if (degradedNode is DegradedFileNode { ActiveRevision: { } degradedRevision } degradedFileNode) + { + if (degradedRevision.CanDecrypt) + { + return new FileNodeInfo(degradedFileNode.Uid, degradedRevision.Uid, degradedRevision.Thumbnails); + } + + // TODO: yield error results immediately instead of collecting them in a list, + // to stream results back to the client as fast as possible (similarly to thumbnail content). + errors.Add( + degradedRevision.ContentAuthor?.TryGetValueElseError(out _, out var error) == false + ? new FileThumbnail(degradedFileNode.Uid, new ProtonDriveError("Cannot decrypt degraded file", error)) + : new FileThumbnail(degradedFileNode.Uid, new ProtonDriveError("Cannot decrypt degraded file"))); + + return null; + } + + if (node?.Uid is { } nonFileNodeUid) + { + errors.Add(new FileThumbnail(nonFileNodeUid, new ProtonDriveError("Node is not a file"))); + } + + return null; + }) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .SelectMany(fileNodeInfo => + { + var thumbnails = fileNodeInfo.Thumbnails; if (thumbnails.Count == 0) { - LogNoThumbnailOnNode(logger, fileNode.Uid); + errors.Add(new FileThumbnail(fileNodeInfo.Uid, new ProtonDriveError("Node has no thumbnails"))); + } + else if (thumbnails.All(thumbnail => thumbnail.Type != thumbnailType)) + { + errors.Add(new FileThumbnail(fileNodeInfo.Uid, new ProtonDriveError($"Node has no thumbnail of type {thumbnailType}"))); } return thumbnails .Where(thumbnail => thumbnail.Type == thumbnailType) - .Select(thumbnail => (thumbnail.Id, Node: fileNode)) + .Select(thumbnail => (thumbnail.Id, Info: fileNodeInfo)) .ToAsyncEnumerable(); }) - .ToDictionaryAsync(thumbnail => thumbnail.Id, thumbnail => thumbnail.Node, cancellationToken) + .ToDictionaryAsync(x => x.Id, x => x.Info, cancellationToken) .ConfigureAwait(false); + errors.AddRange( + unprocessedLinkIds + .Select(missingLinkId => + new FileThumbnail(new NodeUid(volumeId, missingLinkId), new ProtonDriveError("Node not found")))); + + foreach (var error in errors) + { + yield return error; + } + if (thumbnailIds.Count == 0) { continue; @@ -67,9 +120,11 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( var response = await client.Api.Files.GetThumbnailBlocksAsync(volumeId, thumbnailIds.Keys, cancellationToken).ConfigureAwait(false); var tasks = new Queue>(); + var processedThumbnailIds = new HashSet(); foreach (var block in response.Blocks) { - var fileNode = thumbnailIds[block.ThumbnailId]; + processedThumbnailIds.Add(block.ThumbnailId); + var nodeInfo = thumbnailIds[block.ThumbnailId]; if (!client.ThumbnailBlockDownloader.Queue.TryStartBlock()) { @@ -81,7 +136,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( await client.ThumbnailBlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } - tasks.Enqueue(DownloadThumbnailAsync(client, fileNode.ActiveRevision.Uid, block, cancellationToken)); + tasks.Enqueue(DownloadThumbnailAsync(client, nodeInfo.ActiveRevisionUid, block, cancellationToken)); } // TODO: cancel other thumbnail downloads if one fails @@ -89,6 +144,14 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( { yield return await task.ConfigureAwait(false); } + + foreach (var (thumbnailId, nodeInfo) in thumbnailIds) + { + if (!processedThumbnailIds.Contains(thumbnailId)) + { + yield return new FileThumbnail(nodeInfo.Uid, new ProtonDriveError("Thumbnail not found")); + } + } } } @@ -121,15 +184,23 @@ await client.ThumbnailBlockDownloader.DownloadAsync( cancellationToken).ConfigureAwait(false); var thumbnailData = outputStream.TryGetBuffer(out var outputBuffer) ? outputBuffer : outputStream.ToArray(); - return new FileThumbnail(revisionUid.NodeUid, thumbnailData); + return new FileThumbnail(revisionUid.NodeUid, (ReadOnlyMemory)thumbnailData); } } + catch (Exception ex) + { + return new FileThumbnail(revisionUid.NodeUid, ex.ToProtonDriveError()); + } finally { client.ThumbnailBlockDownloader.Queue.FinishBlocks(1); } } - [LoggerMessage(Level = LogLevel.Trace, Message = "No thumbnail on node {NodeUid}")] - private static partial void LogNoThumbnailOnNode(ILogger logger, NodeUid nodeUid); + private readonly struct FileNodeInfo(NodeUid uid, RevisionUid activeRevisionUid, IReadOnlyList thumbnails) + { + public NodeUid Uid { get; } = uid; + public RevisionUid ActiveRevisionUid { get; } = activeRevisionUid; + public IReadOnlyList Thumbnails { get; } = thumbnails; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs index 5840154f..b22edfbf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs @@ -1,3 +1,7 @@ +using Proton.Sdk; + namespace Proton.Drive.Sdk.Nodes; -public sealed record FileThumbnail(NodeUid FileUid, ReadOnlyMemory Data); +public sealed record FileThumbnail( + NodeUid FileUid, + Result, ProtonDriveError> Result); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs index 383dc54f..c4324192 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs @@ -9,4 +9,9 @@ public class ProtonDriveError(string? message, ProtonDriveError? innerError = nu [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ProtonDriveError? InnerError { get; } = innerError; + + public Exception ToException() + { + return new InvalidOperationException(Message, InnerError?.ToException()); + } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 972c7eab..9e7a76c6 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -182,7 +182,10 @@ message Thumbnail { message FileThumbnail { string file_uid = 1; - bytes data = 2; + oneof result { + bytes data = 2; + DriveError error = 3; + } } message FileThumbnailList { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 38092246..7fe66057 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -3,6 +3,7 @@ package me.proton.drive.sdk import com.google.protobuf.timestamp import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.entity.FileThumbnail import me.proton.drive.sdk.entity.FolderNode import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.entity.ThumbnailType @@ -52,8 +53,7 @@ class ProtonDriveClient internal constructor( suspend fun getThumbnails( fileUids: List, type: ThumbnailType, - block: (String) -> WritableByteChannel, - ): Unit = cancellationCoroutineScope { source -> + ): List = cancellationCoroutineScope { source -> log(INFO, "getThumbnails($type)") bridge.getThumbnails( driveClientGetThumbnailsRequest { @@ -62,10 +62,8 @@ class ProtonDriveClient internal constructor( clientHandle = handle cancellationTokenSourceHandle = source.handle } - ).thumbnailsList.forEach { fileThumbnail -> - block(fileThumbnail.fileUid).use { channel -> - channel.write(fileThumbnail.data.asReadOnlyByteBuffer()) - } + ).thumbnailsList.map { fileThumbnail -> + fileThumbnail.toEntity() } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 77089cb5..9b3592a4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -2,6 +2,7 @@ package me.proton.drive.sdk import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.entity.FileThumbnail import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.entity.PhotosTimelineItem import me.proton.drive.sdk.entity.ThumbnailType @@ -14,7 +15,6 @@ import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.drivePhotosClientEnumeratePhotosThumbnailsRequest import proton.drive.sdk.drivePhotosClientEnumerateTimelineRequest import proton.drive.sdk.drivePhotosClientGetNodeRequest -import java.nio.channels.WritableByteChannel class ProtonPhotosClient internal constructor( internal val handle: Long, @@ -25,8 +25,7 @@ class ProtonPhotosClient internal constructor( suspend fun getThumbnails( photoUids: List, type: ThumbnailType, - block: (String) -> WritableByteChannel, - ): Unit = cancellationCoroutineScope { source -> + ): List = cancellationCoroutineScope { source -> log(INFO, "getThumbnails($type)") bridge.getThumbnails( drivePhotosClientEnumeratePhotosThumbnailsRequest { @@ -35,10 +34,8 @@ class ProtonPhotosClient internal constructor( clientHandle = handle cancellationTokenSourceHandle = source.handle } - ).thumbnailsList.forEach { photoThumbnail -> - block(photoThumbnail.fileUid).use { outputStream -> - outputStream.write(photoThumbnail.data.asReadOnlyByteBuffer()) - } + ).thumbnailsList.map { fileThumbnail -> + fileThumbnail.toEntity() } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileThumbnail.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileThumbnail.kt new file mode 100644 index 00000000..1c635068 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileThumbnail.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +data class FileThumbnail( + val uid: String, + val result: Result +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt index 3d2ff347..232f7d91 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.extension +import me.proton.drive.sdk.ProtonDriveException import me.proton.drive.sdk.entity.DriveError import proton.drive.sdk.ProtonDriveSdk diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt new file mode 100644 index 00000000..4d3a3f1d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt @@ -0,0 +1,18 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonDriveSdkException +import me.proton.drive.sdk.entity.FileThumbnail +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.FileThumbnail.toEntity(): FileThumbnail = FileThumbnail( + uid = fileUid, + result = when (resultCase) { + ProtonDriveSdk.FileThumbnail.ResultCase.DATA -> Result.success(data.toByteArray()) + ProtonDriveSdk.FileThumbnail.ResultCase.ERROR -> Result.failure(error.toEntity().toException()) + else -> Result.failure( + ProtonDriveSdkException( + "Undefined result type for ${ProtonDriveSdk.FileThumbnail::class.simpleName}" + ) + ) + } +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt index f325b440..1cf9b8ef 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt @@ -30,7 +30,7 @@ private fun List.toException(message: String) = ProtonDriveException } } -private fun DriveError.toException(): ProtonDriveException = +fun DriveError.toException(): ProtonDriveException = ProtonDriveException( message = message, cause = innerError?.toException() diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 2233aa58..3fd47883 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -260,13 +260,21 @@ public struct FileOperationProgress { /// Thumbnail with file id public struct ThumbnailDataWithId: Sendable { public let fileUid: SDKNodeUid - public let data: Data + public let result: Result init?(fileThumbnail: Proton_Drive_Sdk_FileThumbnail) { guard let fileUid = SDKNodeUid(sdkCompatibleIdentifier: fileThumbnail.fileUid) else { return nil } self.fileUid = fileUid - self.data = fileThumbnail.data + switch fileThumbnail.result { + case .data(let data): + self.result = .success(data) + case .error(let error): + self.result = .failure(ProtonDriveSDKDriveError(error: error)) + case .none: + assert(false, "Unexpected case") + return nil + } } } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift new file mode 100644 index 00000000..1e3b41e7 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Proton AG +// +// This file is part of Proton Drive. +// +// Proton Drive is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Drive is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Drive. If not, see https://www.gnu.org/licenses/. + +import Foundation + +public final class ProtonDriveSDKDriveError: Error { + public let message: String? + public let innerError: ProtonDriveSDKDriveError? + + public init(message: String? = nil, innerError: ProtonDriveSDKDriveError? = nil) { + self.message = message + self.innerError = innerError + } + + init(error: Proton_Drive_Sdk_DriveError) { + self.message = error.hasMessage ? error.message : nil + self.innerError = error.hasInnerError ? ProtonDriveSDKDriveError(error: error.innerError) : nil + } +} From aeab09abc1f792a4d1858f63f8ee4b07ef6c4907 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 12:24:35 +0000 Subject: [PATCH 592/791] Update changelog for js/v0.13.0 --- js/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index acd0fd03..342b8f79 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## js/v0.13.0 (2026-03-11) + +* Add owned by property +* Handle empty file using single-request-file-upload endpoint +* Change main photo reference to UID instead of link ID +* Implement small file upload endpoint + +## js/v0.12.1 (2026-03-04) + +* No changes + ## js/v0.12.0 (2026-03-02) * Add AEAD crypto test and FF management From 2418781510b7cc64d1168bbae16e501b1fee966e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 14:23:39 +0000 Subject: [PATCH 593/791] Handle nullable OwnedBy fields when mapping to proto --- .../InteropProtonDriveClient.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index d9436fbc..627bc1a8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -407,14 +407,25 @@ private static AuthorResult ParseAuthorResult(Result Date: Wed, 11 Mar 2026 22:44:26 +0800 Subject: [PATCH 594/791] Set swift error message --- .../ProtonDriveSDKError/ProtonDriveSDKDriveError.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift index 1e3b41e7..f716f238 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift @@ -17,7 +17,7 @@ import Foundation -public final class ProtonDriveSDKDriveError: Error { +public final class ProtonDriveSDKDriveError: Error, LocalizedError { public let message: String? public let innerError: ProtonDriveSDKDriveError? @@ -30,4 +30,9 @@ public final class ProtonDriveSDKDriveError: Error { self.message = error.hasMessage ? error.message : nil self.innerError = error.hasInnerError ? ProtonDriveSDKDriveError(error: error.innerError) : nil } + + public var errorDescription: String? { + var desc: [String] = [message, innerError?.localizedDescription].compactMap { $0 } + return desc.joined(separator: ", ") + } } From d7917f400fe5bfdda32e0a58328dbe8bd76914f6 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Mar 2026 12:41:38 +0100 Subject: [PATCH 595/791] Implement upload to Photos --- cs/Directory.Packages.props | 10 - .../InteropFileUploader.cs | 4 +- .../InteropPhotosUploader.cs | 18 +- .../InteropProtonDriveClient.cs | 76 ++++--- .../InteropProtonPhotosClient.cs | 43 ++-- .../Api/Files/PhotosAttributesDto.cs | 21 ++ .../Api/Files/RevisionUpdateRequest.cs | 3 + .../Proton.Drive.Sdk/Api/Photos/PhotoDto.cs | 1 + .../Proton.Drive.Sdk/Api/Photos/PhotoTag.cs | 15 -- .../Api/Photos/TimelinePhotoDto.cs | 1 + .../Nodes/FileUploadMetadata.cs | 4 +- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 15 ++ cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoTag.cs | 15 ++ .../Nodes/PhotosFileUploadMetadata.cs | 8 +- .../Nodes/Upload/FileUploader.cs | 26 +-- .../Nodes/Upload/NewFileDraftProvider.cs | 1 + .../Nodes/Upload/NewRevisionDraftProvider.cs | 1 + .../Nodes/Upload/PhotosFileUploader.cs | 35 --- .../Nodes/Upload/RevisionDraft.cs | 2 + .../Nodes/Upload/RevisionWriter.cs | 208 ++++++++++-------- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 36 +-- .../Proton.Drive.Sdk/ProtonPhotosClient.cs | 35 ++- .../ProtonApiSessionRequestHandler.cs | 6 +- cs/sdk/src/protos/proton.drive.sdk.proto | 40 ++-- .../me/proton/drive/sdk/entity/PhotoTag.kt | 18 +- .../drive/sdk/entity/PhotosUploaderRequest.kt | 2 +- .../me/proton/drive/sdk/extension/PhotoTag.kt | 18 +- .../sdk/extension/PhotosUploaderRequest.kt | 12 +- .../ProtonPhotosClient.swift | 12 +- .../Uploads/PhotoUploadsManager.swift | 19 +- 30 files changed, 355 insertions(+), 350 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/PhotosAttributesDto.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoTag.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoTag.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index d2337c2a..1cc2ea2d 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -5,7 +5,6 @@ - @@ -15,7 +14,6 @@ - @@ -27,14 +25,6 @@ - - - - - - - - diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 24ae268a..2b6900c6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -21,7 +21,7 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n { unsafe { - var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; + var thumbnailType = (Nodes.ThumbnailType)t.Type; return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); } }); @@ -51,7 +51,7 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint { unsafe { - var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; + var thumbnailType = (Nodes.ThumbnailType)t.Type; return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); } }); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs index d788564d..52ea5305 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs @@ -11,7 +11,7 @@ public static IMessage HandleUploadFromStream(DrivePhotosClientUploadFromStreamR { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - var uploader = Interop.GetFromHandle(request.UploaderHandle); + var uploader = Interop.GetFromHandle(request.UploaderHandle); var stream = new InteropStream(uploader.FileSize, bindingsHandle, new InteropFunction, nint, nint>(request.ReadAction)); @@ -19,8 +19,8 @@ public static IMessage HandleUploadFromStream(DrivePhotosClientUploadFromStreamR { unsafe { - var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; - return new Proton.Drive.Sdk.Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); + var thumbnailType = (Nodes.ThumbnailType)t.Type; + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); } }); @@ -29,7 +29,7 @@ public static IMessage HandleUploadFromStream(DrivePhotosClientUploadFromStreamR var expectedSha1Provider = request.HasSha1Function ? InteropFileUploader.CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; - var uploadController = PhotosFileUploader.UploadFromStream( + var uploadController = uploader.UploadFromStream( stream, thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), @@ -47,8 +47,8 @@ public static IMessage HandleUploadFromFile(DrivePhotosClientUploadFromFileReque { unsafe { - var thumbnailType = (Proton.Drive.Sdk.Nodes.ThumbnailType)t.Type; - return new Proton.Drive.Sdk.Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); + var thumbnailType = (Nodes.ThumbnailType)t.Type; + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); } }); @@ -56,7 +56,9 @@ public static IMessage HandleUploadFromFile(DrivePhotosClientUploadFromFileReque var expectedSha1Provider = request.HasSha1Function ? InteropFileUploader.CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; - var uploadController = PhotosFileUploader.UploadFromFile( + var uploader = Interop.GetFromHandle(request.UploaderHandle); + + var uploadController = uploader.UploadFromFile( request.FilePath, thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), @@ -68,7 +70,7 @@ public static IMessage HandleUploadFromFile(DrivePhotosClientUploadFromFileReque public static IMessage? HandleFree(DrivePhotosClientUploaderFreeRequest request) { - var fileUploader = Interop.FreeHandle(request.FileUploaderHandle); + var fileUploader = Interop.FreeHandle(request.FileUploaderHandle); fileUploader.Dispose(); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 627bc1a8..d1829764 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -115,7 +115,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe var additionalMetadata = request.AdditionalMetadata is { Count: > 0 } ? request.AdditionalMetadata.Select(x => - new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) + new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; var fileUploader = await client.GetFileUploaderAsync( @@ -123,8 +123,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe request.Name, request.MediaType, request.Size, - request.LastModificationTime.ToDateTimeFixed(), - additionalMetadata, + new FileUploadMetadata { LastModificationTime = request.LastModificationTime.ToDateTimeFixed(), AdditionalMetadata = additionalMetadata }, request.OverrideExistingDraftByOtherClient, cancellationToken).ConfigureAwait(false); @@ -139,14 +138,13 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive var additionalMetadata = request.AdditionalMetadata.Count > 0 ? request.AdditionalMetadata.Select(x => - new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) + new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; var fileUploader = await client.GetFileRevisionUploaderAsync( RevisionUid.Parse(request.CurrentActiveRevisionUid), request.Size, - request.LastModificationTime.ToDateTimeFixed(), - additionalMetadata, + new FileUploadMetadata { LastModificationTime = request.LastModificationTime.ToDateTimeFixed(), AdditionalMetadata = additionalMetadata }, cancellationToken).ConfigureAwait(false); return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; @@ -174,7 +172,7 @@ public static async ValueTask HandleGetThumbnailsAsync(DriveClientGetT var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( request.FileUids.Select(NodeUid.Parse), - (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, + (Nodes.ThumbnailType)request.Type, cancellationToken); var thumbnails = await thumbnailsEnumerable @@ -348,6 +346,32 @@ public static NodeResult ConvertToNodeResult(Result result) + { + var authorResult = new AuthorResult(); + + if (result.TryGetValueElseError(out var author, out var error)) + { + authorResult.Value = new Author + { + EmailAddress = author.EmailAddress, + }; + } + else + { + authorResult.Error = new SignatureVerificationError + { + ClaimedAuthor = new Author + { + EmailAddress = error.ClaimedAuthor.EmailAddress, + }, + Message = error.Message, + }; + } + + return authorResult; + } + internal static DriveError ConvertToDriveError(ProtonDriveError error) { return new DriveError @@ -381,32 +405,6 @@ private static NodeResultListResponse ConvertToNodeResultListResponse(IReadOnlyD }; } - private static AuthorResult ParseAuthorResult(Result result) - { - var authorResult = new AuthorResult(); - - if (result.TryGetValueElseError(out var author, out var error)) - { - authorResult.Value = new Author - { - EmailAddress = author.EmailAddress, - }; - } - else - { - authorResult.Error = new SignatureVerificationError - { - ClaimedAuthor = new Author - { - EmailAddress = error.ClaimedAuthor.EmailAddress, - }, - Message = error.Message, - }; - } - - return authorResult; - } - private static OwnedBy MapOwnedByToProto(Proton.Drive.Sdk.Nodes.OwnedBy? ownedBy) { if (ownedBy is null) @@ -428,13 +426,13 @@ private static OwnedBy MapOwnedByToProto(Proton.Drive.Sdk.Nodes.OwnedBy? ownedBy return result; } - private static Node ConvertToNode(Proton.Drive.Sdk.Nodes.Node node) + private static Node ConvertToNode(Nodes.Node node) { var result = new Node(); switch (node) { - case Proton.Drive.Sdk.Nodes.FolderNode folderNode: + case Nodes.FolderNode folderNode: result.Folder = new FolderNode { Uid = folderNode.Uid.ToString(), @@ -449,7 +447,7 @@ private static Node ConvertToNode(Proton.Drive.Sdk.Nodes.Node node) }; break; - case Proton.Drive.Sdk.Nodes.FileNode fileNode: + case Nodes.FileNode fileNode: var fileNodeProto = new FileNode { Uid = fileNode.Uid.ToString(), @@ -508,13 +506,13 @@ private static Node ConvertToNode(Proton.Drive.Sdk.Nodes.Node node) return result; } - private static DegradedNode ConvertToDegradedNode(Proton.Drive.Sdk.Nodes.DegradedNode degradedNode) + private static DegradedNode ConvertToDegradedNode(Nodes.DegradedNode degradedNode) { var result = new DegradedNode(); switch (degradedNode) { - case Proton.Drive.Sdk.Nodes.DegradedFolderNode degradedFolderNode: + case Nodes.DegradedFolderNode degradedFolderNode: var degradedFolder = new DegradedFolderNode { Uid = degradedFolderNode.Uid.ToString(), @@ -532,7 +530,7 @@ private static DegradedNode ConvertToDegradedNode(Proton.Drive.Sdk.Nodes.Degrade result.Folder = degradedFolder; break; - case Proton.Drive.Sdk.Nodes.DegradedFileNode degradedFileNode: + case Nodes.DegradedFileNode degradedFileNode: var degradedFile = new DegradedFileNode { Uid = degradedFileNode.Uid.ToString(), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 4b0a8bbf..09041411 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes; @@ -134,7 +135,7 @@ public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(Dri var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( request.PhotoUids.Select(NodeUid.Parse), - (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, + (Nodes.ThumbnailType)request.Type, cancellationToken); var thumbnails = await thumbnailsEnumerable @@ -162,20 +163,31 @@ public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosCl var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var tags = request.Metadata.Tags is { Count: > 0 } - ? request.Metadata.Tags.Select(t => (Api.Photos.PhotoTag)t) + ? request.Metadata.Tags.Select(t => (Nodes.PhotoTag)t) : null; - var metadata = new PhotosFileUploadMetadata + var additionalMetadata = request.Metadata.AdditionalMetadata is { Count: > 0 } + ? request.Metadata.AdditionalMetadata.Select(x => + new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) + : null; + + var metadata = new Nodes.PhotosFileUploadMetadata { - MediaType = request.Metadata.MediaType, - MainPhotoLinkId = request.Metadata.MainPhotoLinkId, - ExpectedSize = request.Size, + AdditionalMetadata = additionalMetadata, + LastModificationTime = request.Metadata.LastModificationTime.ToDateTimeFixed(), + CaptureTime = request.Metadata.CaptureTime.ToDateTimeFixed(), + MainPhotoUid = request.Metadata.HasMainPhotoUid ? NodeUid.Parse(request.Metadata.MainPhotoUid) : null, Tags = tags, }; - var uploader = await ProtonPhotosClient.GetFileUploaderAsync( + var client = Interop.GetFromHandle(request.ClientHandle); + + var uploader = await client.GetFileUploaderAsync( request.Name, + request.MediaType, + request.Size, metadata, + request.OverrideExistingDraftByOtherClient, cancellationToken).ConfigureAwait(false); return new Int64Value { Value = Interop.AllocHandle(uploader) }; @@ -185,19 +197,18 @@ public static async ValueTask HandleFindDuplicatesAsync(DrivePhotosCli { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - Action generateSha1Action = (sha1) => - { - // TODO: Implement SHA1 generation callback - }; + var client = Interop.GetFromHandle(request.ClientHandle); - var duplicates = await ProtonPhotosClient.FindDuplicatesAsync( - request.Name, - generateSha1Action, - cancellationToken).ConfigureAwait(false); + var duplicates = await client.FindDuplicatesAsync(request.Name, GenerateSha1Action, cancellationToken).ConfigureAwait(false); var result = new ListValue(); - result.Values.AddRange(duplicates.Select(duplicate => Value.ForString(duplicate))); + result.Values.AddRange(duplicates.Select(Value.ForString)); return result; + + static void GenerateSha1Action(string sha1) + { + // TODO: Implement SHA1 generation callback + } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/PhotosAttributesDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/PhotosAttributesDto.cs new file mode 100644 index 00000000..37463368 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/PhotosAttributesDto.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class PhotosAttributesDto +{ + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CaptureTime { get; init; } + + [JsonPropertyName("ContentHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory ContentHashDigest { get; init; } + + [JsonPropertyName("MainPhotoLinkID")] + public LinkId? MainPhotoLinkId { get; init; } + + public IReadOnlySet? Tags { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs index 92869748..a994b06f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs @@ -12,4 +12,7 @@ internal sealed class RevisionUpdateRequest [JsonPropertyName("XAttr")] public PgpArmoredMessage? ExtendedAttributes { get; init; } + + [JsonPropertyName("Photo")] + public PhotosAttributesDto? PhotosAttributes { get; set; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs index 312e5e66..3c3c9871 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Api.Photos; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoTag.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoTag.cs deleted file mode 100644 index 2bd92cef..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoTag.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Proton.Drive.Sdk.Api.Photos; - -public enum PhotoTag -{ - Favorites = 0, - Screenshots = 1, - Videos = 2, - LivePhotos = 3, - MotionPhotos = 4, - Selfies = 5, - Portraits = 6, - Bursts = 7, - Panoramas = 8, - Raw = 9, -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs index c6bed026..3dbf20a9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Api.Photos; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs index bad0f768..33e2717b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs @@ -2,8 +2,6 @@ namespace Proton.Drive.Sdk.Nodes; public class FileUploadMetadata { - public required string MediaType { get; init; } - public DateTime? LastModificationTime { get; init; } + public DateTimeOffset? LastModificationTime { get; init; } public IEnumerable? AdditionalMetadata { get; init; } - public bool OverrideExistingDraftByOtherClient { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index c838432a..1399bb16 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -566,6 +566,21 @@ public static bool ValidateName( return true; } + public static async Task> GetParentFolderHashKeyAsync(ProtonDriveClient client, NodeUid uid, CancellationToken cancellationToken) + { + var nodeMetadataResult = await GetNodeMetadataResultAsync(client, uid, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + + var parentUid = nodeMetadataResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); + if (parentUid is null) + { + throw new InvalidOperationException("Root node does not have a parent folder"); + } + + var parentFolderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid.Value, cancellationToken).ConfigureAwait(false); + + return parentFolderSecrets.HashKey; + } + private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { ShareVolumeDto volumeDto; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoTag.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoTag.cs new file mode 100644 index 00000000..3ef91fa0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoTag.cs @@ -0,0 +1,15 @@ +namespace Proton.Drive.Sdk.Nodes; + +public enum PhotoTag +{ + Favorite = 0, + Screenshot = 1, + Video = 2, + LivePhoto = 3, + MotionPhoto = 4, + Selfie = 5, + Portrait = 6, + Burst = 7, + Panorama = 8, + Raw = 9, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs index 7e85ac7b..17a03aa6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs @@ -1,14 +1,8 @@ -using Proton.Drive.Sdk.Api.Photos; - namespace Proton.Drive.Sdk.Nodes; public sealed class PhotosFileUploadMetadata : FileUploadMetadata { public DateTime? CaptureTime { get; init; } - - public string? MainPhotoLinkId { get; init; } - - public long? ExpectedSize { get; init; } - + public NodeUid? MainPhotoUid { get; init; } public IEnumerable? Tags { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 6d527d19..279e2166 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -9,8 +9,7 @@ public sealed partial class FileUploader : IDisposable private readonly ProtonDriveClient _client; private readonly IRevisionDraftProvider _revisionDraftProvider; private readonly NodeUid _telemetryContextNodeUid; - private readonly DateTimeOffset? _lastModificationTime; - private readonly IEnumerable? _additionalMetadata; + private readonly FileUploadMetadata _metadata; private readonly ILogger _logger; private volatile int _remainingNumberOfBlocks; @@ -20,8 +19,7 @@ private FileUploader( IRevisionDraftProvider revisionDraftProvider, NodeUid telemetryContextNodeUid, long size, - DateTimeOffset? lastModificationTime, - IEnumerable? additionalMetadata, + FileUploadMetadata metadata, int expectedNumberOfBlocks, ILogger logger) { @@ -29,8 +27,7 @@ private FileUploader( _revisionDraftProvider = revisionDraftProvider; _telemetryContextNodeUid = telemetryContextNodeUid; FileSize = size; - _lastModificationTime = lastModificationTime; - _additionalMetadata = additionalMetadata; + _metadata = metadata; _remainingNumberOfBlocks = expectedNumberOfBlocks; _logger = logger; } @@ -81,8 +78,7 @@ internal static async ValueTask CreateAsync( IRevisionDraftProvider revisionDraftProvider, NodeUid telemetryContextNodeUid, long size, - DateTime? lastModificationTime, - IEnumerable? additionalExtendedAttributes, + FileUploadMetadata metadata, CancellationToken cancellationToken) { var logger = client.Telemetry.GetLogger("File uploader"); @@ -100,8 +96,7 @@ internal static async ValueTask CreateAsync( revisionDraftProvider, telemetryContextNodeUid, size, - lastModificationTime, - additionalExtendedAttributes, + metadata, expectedNumberOfBlocks, logger); } @@ -133,7 +128,6 @@ private UploadController UploadFromStream( var uploadFunction = (CancellationToken ct) => UploadFromStreamAsync( contentStream, thumbnails, - _additionalMetadata, progress => onProgress?.Invoke(progress, FileSize), expectedSha1Provider, revisionDraftTaskCompletionSource, @@ -173,7 +167,6 @@ void OnSucceeded(long uploadedByteCount) private async Task UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, - IEnumerable? additionalExtendedAttributes, Action? onProgress, Func>? expectedSha1Provider, TaskCompletionSource revisionDraftTaskCompletionSource, @@ -191,8 +184,6 @@ await UploadAsync( revisionDraftTaskCompletionSource.Task.Result, contentStream, thumbnails, - _lastModificationTime, - additionalExtendedAttributes, onProgress, expectedSha1Provider, cancellationToken).ConfigureAwait(false); @@ -219,7 +210,7 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid { Uid = revisionUid, ClaimedSize = size, - ClaimedModificationTime = _lastModificationTime?.UtcDateTime, + ClaimedModificationTime = _metadata.LastModificationTime?.UtcDateTime, // FIXME: update remaining metadata in cache, but this is not critical because this metadata will soon be invalidated by the event anyway }, @@ -232,8 +223,6 @@ private async ValueTask UploadAsync( RevisionDraft revisionDraft, Stream contentStream, IEnumerable thumbnails, - DateTimeOffset? lastModificationTime, - IEnumerable? additionalMetadata, Action? onProgress, Func>? expectedSha1Provider, CancellationToken cancellationToken) @@ -245,8 +234,7 @@ await revisionWriter.WriteAsync( FileSize, expectedSha1Provider, thumbnails, - lastModificationTime, - additionalMetadata, + _metadata, onProgress, cancellationToken).ConfigureAwait(false); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 25b284cf..14aac93a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -52,6 +52,7 @@ public async ValueTask GetDraftAsync(CancellationToken cancellati fileSecrets.Key, fileSecrets.ContentKey, signingKey, + parentSecrets.HashKey, membershipAddress, blockVerifier, ct => DeleteDraftAsync(draftRevisionUid, ct), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index ee1a7802..d5942aa0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -74,6 +74,7 @@ public async ValueTask GetDraftAsync(CancellationToken cancellati fileSecrets.Key, fileSecrets.ContentKey, signingKey, + hashKey: null, membershipAddress, blockVerifier, ct => DeleteDraftAsync(draftRevisionUid, ct), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs deleted file mode 100644 index e37afc69..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/PhotosFileUploader.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes.Upload; - -public sealed class PhotosFileUploader : IDisposable -{ - internal PhotosFileUploader(long fileSize) - { - FileSize = fileSize; - } - - public long FileSize { get; } - - public static UploadController UploadFromStream( - System.IO.Stream contentStream, - IEnumerable thumbnails, - Action? onProgress, - Func>? expectedSha1Provider, - CancellationToken cancellationToken) - { - throw new NotSupportedException(); - } - - public static UploadController UploadFromFile( - string filePath, - IEnumerable thumbnails, - Action? onProgress, - Func>? expectedSha1Provider, - CancellationToken cancellationToken) - { - throw new NotSupportedException(); - } - - public void Dispose() - { - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs index c67b1a68..b40ae004 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -14,6 +14,7 @@ internal sealed partial class RevisionDraft( PgpPrivateKey fileKey, PgpSessionKey contentKey, PgpPrivateKey signingKey, + ReadOnlyMemory? hashKey, Address membershipAddress, IBlockVerifier blockVerifier, Func deleteDraftFunction, @@ -29,6 +30,7 @@ internal sealed partial class RevisionDraft( public PgpPrivateKey FileKey { get; } = fileKey; public PgpSessionKey ContentKey { get; } = contentKey; public PgpPrivateKey SigningKey { get; } = signingKey; + public ReadOnlyMemory? HashKey { get; } = hashKey; public Address MembershipAddress { get; } = membershipAddress; public IBlockVerifier BlockVerifier { get; } = blockVerifier; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 0e8d8b36..72ae37b0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; @@ -46,8 +47,7 @@ public async ValueTask WriteAsync( long expectedContentLength, Func>? expectedSha1Provider, IEnumerable thumbnails, - DateTimeOffset? lastModificationTime, - IEnumerable? additionalMetadata, + FileUploadMetadata metadata, Action? onProgress, CancellationToken cancellationToken) { @@ -102,14 +102,17 @@ public async ValueTask WriteAsync( var sha1Digest = _draft.Sha1.GetCurrentHash(); + var hashKey = _draft.HashKey + ?? await NodeOperations.GetParentFolderHashKeyAsync(_client, _draft.Uid.NodeUid, cancellationToken).ConfigureAwait(false); + var request = CreateRevisionUpdateRequest( - lastModificationTime, + metadata, expectedContentLength, expectedThumbnailBlockCount, expectedSha1Provider, sha1Digest, - signingEmailAddress, - additionalMetadata); + hashKey, + signingEmailAddress); LogSealingRevision(_draft.Uid); @@ -158,6 +161,110 @@ and not NodeWithSameNameExistsException and not IntegrityException; } + private RevisionUpdateRequest CreateRevisionUpdateRequest( + FileUploadMetadata metadata, + long expectedContentLength, + int expectedThumbnailBlockCount, + Func>? expectedSha1Provider, + byte[] sha1Digest, + ReadOnlyMemory hashKey, + string signingEmailAddress) + { + var manifest = new byte[(_draft.OrderedThumbnailUploadResults.Count + _draft.OrderedContentBlockStates.Count) * SHA256.HashSizeInBytes]; + using var manifestStream = new MemoryStream(manifest); + + var contentBlockSizes = new List(_draft.OrderedContentBlockStates.Count); + var uploadedContentSize = 0L; + + var contentBlockUploadResults = _draft.OrderedContentBlockStates + .Select((blockState, i) => + { + var blockNumber = i + 1; + + return blockState.TryGetSecond(out var uploadResult) + ? (Number: blockNumber, Value: uploadResult) + : throw new IntegrityException($"Missing content block #{blockNumber}"); + }); + + var blockUploadResults = _draft.OrderedThumbnailUploadResults.Select(x => (Number: 0, Value: x)).Concat(contentBlockUploadResults); + + foreach (var (blockNumber, blockUploadResult) in blockUploadResults) + { + var (plaintextSize, sha256Digest) = blockUploadResult; + + manifestStream.Write(sha256Digest); + + if (blockNumber == 0) + { + // Not a content block + continue; + } + + contentBlockSizes.Add(plaintextSize); + uploadedContentSize += plaintextSize; + } + + if (uploadedContentSize != expectedContentLength) + { + throw new IntegrityException("Mismatch between uploaded size and expected size"); + } + + if (expectedThumbnailBlockCount != _draft.OrderedThumbnailUploadResults.Count) + { + throw new IntegrityException("Unexpected number of thumbnail blocks"); + } + + if (expectedSha1Provider is not null) + { + var expectedSha1 = expectedSha1Provider(); + if (!expectedSha1.Span.SequenceEqual(sha1Digest)) + { + throw new IntegrityException("Mismatch between uploaded SHA1 and expected SHA1"); + } + } + + var extendedAttributes = new ExtendedAttributes + { + Common = new CommonExtendedAttributes + { + Size = uploadedContentSize, + ModificationTime = metadata.LastModificationTime?.UtcDateTime, + BlockSizes = contentBlockSizes, + Digests = new FileContentDigestsDto { Sha1 = sha1Digest }, + }, + AdditionalMetadata = metadata.AdditionalMetadata?.ToDictionary(x => x.Name, x => x.Value), + }; + + var extendedAttributesUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(extendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); + + var encryptedExtendedAttributes = _draft.FileKey.EncryptAndSign( + extendedAttributesUtf8Bytes, + _draft.SigningKey, + outputCompression: PgpCompression.Default); + + var request = new RevisionUpdateRequest + { + ManifestSignature = _draft.SigningKey.Sign(manifest), + SignatureEmailAddress = signingEmailAddress, + ExtendedAttributes = encryptedExtendedAttributes, + }; + + if (metadata is PhotosFileUploadMetadata photoMetadata) + { + var captureTime = photoMetadata.CaptureTime ?? metadata.LastModificationTime ?? DateTime.UtcNow; + + request.PhotosAttributes = new PhotosAttributesDto + { + CaptureTime = captureTime.UtcDateTime, + ContentHashDigest = HMACSHA256.HashData(hashKey.Span, Encoding.ASCII.GetBytes(Convert.ToHexStringLower(sha1Digest))), + MainPhotoLinkId = photoMetadata.MainPhotoUid?.LinkId, + Tags = photoMetadata.Tags?.ToHashSet() ?? [], + }; + } + + return request; + } + private async ValueTask UploadContentBlockAsync( int blockNumber, BlockUploadPlainData plainData, @@ -246,7 +353,7 @@ private async ValueTask UploadContentBlocksAsync( using var delayedCancellationTokenSource = new CancellationTokenSource(); // We use a delayed cancellation token to give the read operation a fair chance to complete when cancellation is triggered, - // so as to not leave the stream in an indeterminate state that would prevent resuming using the same stream later. + // to not leave the stream in an indeterminate state that would prevent resuming using the same stream later. // ReSharper disable once AccessToDisposedClosure await using (cancellationToken.Register(() => delayedCancellationTokenSource.CancelAfter(SourceReadingCancellationDelayMilliseconds))) { @@ -351,95 +458,6 @@ private async ValueTask WaitForBlockUploaderAsync(Queue> } } - private RevisionUpdateRequest CreateRevisionUpdateRequest( - DateTimeOffset? lastModificationTime, - long expectedContentLength, - int expectedThumbnailBlockCount, - Func>? expectedSha1Provider, - byte[]? sha1Digest, - string signingEmailAddress, - IEnumerable? additionalMetadata) - { - var manifest = new byte[(_draft.OrderedThumbnailUploadResults.Count + _draft.OrderedContentBlockStates.Count) * SHA256.HashSizeInBytes]; - using var manifestStream = new MemoryStream(manifest); - - var contentBlockSizes = new List(_draft.OrderedContentBlockStates.Count); - var uploadedContentSize = 0L; - - var contentBlockUploadResults = _draft.OrderedContentBlockStates - .Select((blockState, i) => - { - var blockNumber = i + 1; - - return blockState.TryGetSecond(out var uploadResult) - ? (Number: blockNumber, Value: uploadResult) - : throw new IntegrityException($"Missing content block #{blockNumber}"); - }); - - var blockUploadResults = _draft.OrderedThumbnailUploadResults.Select(x => (Number: 0, x)).Concat(contentBlockUploadResults); - - foreach (var (blockNumber, blockUploadResult) in blockUploadResults) - { - var (plaintextSize, sha256Digest) = blockUploadResult; - - manifestStream.Write(sha256Digest); - - if (blockNumber == 0) - { - // Not a content block - continue; - } - - contentBlockSizes.Add(plaintextSize); - uploadedContentSize += plaintextSize; - } - - if (uploadedContentSize != expectedContentLength) - { - throw new IntegrityException("Mismatch between uploaded size and expected size"); - } - - if (expectedThumbnailBlockCount != _draft.OrderedThumbnailUploadResults.Count) - { - throw new IntegrityException("Unexpected number of thumbnail blocks"); - } - - if (expectedSha1Provider is not null) - { - var expectedSha1 = expectedSha1Provider(); - if (!expectedSha1.Span.SequenceEqual(sha1Digest)) - { - throw new IntegrityException("Mismatch between uploaded SHA1 and expected SHA1"); - } - } - - var extendedAttributes = new ExtendedAttributes - { - Common = new CommonExtendedAttributes - { - Size = uploadedContentSize, - ModificationTime = lastModificationTime?.UtcDateTime, - BlockSizes = contentBlockSizes, - Digests = new FileContentDigestsDto { Sha1 = sha1Digest }, - }, - AdditionalMetadata = additionalMetadata?.ToDictionary(x => x.Name, x => x.Value), - }; - - var extendedAttributesUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(extendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); - - var encryptedExtendedAttributes = _draft.FileKey.EncryptAndSign( - extendedAttributesUtf8Bytes, - _draft.SigningKey, - outputCompression: PgpCompression.Default); - - return new RevisionUpdateRequest - { - ManifestSignature = _draft.SigningKey.Sign(manifest), - SignatureEmailAddress = signingEmailAddress, - ExtendedAttributes = encryptedExtendedAttributes, - }; - } - private async ValueTask RevisionIsSealedAsync(CancellationToken cancellationToken) { var revisionResponse = await _client.Api.Files.GetRevisionAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 20cf93f2..45998941 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -211,45 +211,29 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( return FileOperations.EnumerateThumbnailsAsync(this, fileUids, type, cancellationToken); } - // FIXME: Group all parameters between mediaType and overrideExistingDraftByOtherClient into uploadMetadata object. public async ValueTask GetFileUploaderAsync( NodeUid parentFolderUid, string name, string mediaType, long size, - DateTime? lastModificationTime, - IEnumerable? additionalMetadata, + FileUploadMetadata metadata, bool overrideExistingDraftByOtherClient, CancellationToken cancellationToken) { var draftProvider = new NewFileDraftProvider(this, parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); - return await GetFileUploaderAsync( - draftProvider, - parentFolderUid, - size, - lastModificationTime, - additionalMetadata, - cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, parentFolderUid, size, metadata, cancellationToken).ConfigureAwait(false); } - // FIXME: Group all parameterts bettween size and additionalMetadata into uploadMetadata object. public async ValueTask GetFileRevisionUploaderAsync( RevisionUid currentActiveRevisionUid, long size, - DateTime? lastModificationTime, - IEnumerable? additionalMetadata, + FileUploadMetadata metadata, CancellationToken cancellationToken) { var draftProvider = new NewRevisionDraftProvider(this, currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); - return await GetFileUploaderAsync( - draftProvider, - currentActiveRevisionUid.NodeUid, - size, - lastModificationTime, - additionalMetadata, - cancellationToken).ConfigureAwait(false); + return await GetFileUploaderAsync(draftProvider, currentActiveRevisionUid.NodeUid, size, metadata, cancellationToken).ConfigureAwait(false); } public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) @@ -306,17 +290,9 @@ private async ValueTask GetFileUploaderAsync( IRevisionDraftProvider revisionDraftProvider, NodeUid telemetryContextNodeUid, long size, - DateTime? lastModificationTime, - IEnumerable? additionalMetadata, + FileUploadMetadata metadata, CancellationToken cancellationToken) { - return await FileUploader.CreateAsync( - this, - revisionDraftProvider, - telemetryContextNodeUid, - size, - lastModificationTime, - additionalMetadata, - cancellationToken).ConfigureAwait(false); + return await FileUploader.CreateAsync(this, revisionDraftProvider, telemetryContextNodeUid, size, metadata, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index 6687979a..7a65e8d0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -61,14 +61,25 @@ public ProtonPhotosClient( internal ProtonDriveClient DriveClient { get; } - public static ValueTask GetFileUploaderAsync(string name, PhotosFileUploadMetadata metadata, CancellationToken cancellationToken) + public async ValueTask GetFileUploaderAsync( + string name, + string mediaType, + long size, + PhotosFileUploadMetadata metadata, + bool overrideExistingDraftByOtherClient, + CancellationToken cancellationToken) { - throw new NotSupportedException(); + var photosRoot = await PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken).ConfigureAwait(false); + + var draftProvider = new NewFileDraftProvider(DriveClient, photosRoot.Uid, name, mediaType, overrideExistingDraftByOtherClient); + + return await GetFileUploaderAsync(draftProvider, photosRoot.Uid, size, metadata, cancellationToken).ConfigureAwait(false); } - public static ValueTask> FindDuplicatesAsync(string name, Action generateSha1, CancellationToken cancellationToken) + public ValueTask> FindDuplicatesAsync(string name, Action generateSha1, CancellationToken cancellationToken) { - throw new NotSupportedException(); + _ = DriveClient; + throw new NotImplementedException(); } public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) @@ -110,4 +121,20 @@ internal ValueTask GetPhotosRootAsync(CancellationToken cancellation { return PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken); } + + private async ValueTask GetFileUploaderAsync( + IRevisionDraftProvider revisionDraftProvider, + NodeUid telemetryContextNodeUid, + long size, + PhotosFileUploadMetadata metadata, + CancellationToken cancellationToken) + { + return await FileUploader.CreateAsync( + DriveClient, + revisionDraftProvider, + telemetryContextNodeUid, + size, + metadata, + cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs index 7b6e100b..5a1fe489 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs @@ -28,7 +28,7 @@ internal static class ProtonApiSessionRequestHandler UserAgent = request.Options.UserAgent, BindingsLanguage = request.Options.BindingsLanguage, Telemetry = telemetry, - TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, + TlsPolicy = (Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, EntityCacheRepository = entityCacheRepository, SecretCacheRepository = secretCacheRepository, }; @@ -53,13 +53,13 @@ public static IMessage HandleResume(SessionResumeRequest request, nint bindingsH ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) : new InMemoryCacheRepository(); - var options = new Proton.Sdk.ProtonClientOptions + var options = new Sdk.ProtonClientOptions { BaseUrl = new Uri(request.Options.BaseUrl), UserAgent = request.Options.UserAgent, BindingsLanguage = request.Options.BindingsLanguage, Telemetry = telemetry, - TlsPolicy = (Proton.Sdk.Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, + TlsPolicy = (Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, EntityCacheRepository = entityCacheRepository, SecretCacheRepository = secretCacheRepository, }; diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 9e7a76c6..377ca9c8 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -700,31 +700,31 @@ message DrivePhotosClientDownloaderFreeRequest { message DrivePhotosClientGetPhotoUploaderRequest { int64 client_handle = 1; string name = 2; - int64 size = 3; - PhotoFileUploadMetadata metadata = 4; - int64 cancellation_token_source_handle = 5; + string media_type = 3; + int64 size = 4; + PhotosFileUploadMetadata metadata = 5; + bool override_existing_draft_by_other_client = 6; + int64 cancellation_token_source_handle = 7; } -message PhotoFileUploadMetadata { - string media_type = 1; - google.protobuf.Timestamp last_modification_time = 2; // Optional - repeated AdditionalMetadataProperty additional_metadata = 3; // Optional - bool override_existing_draft_by_other_client = 4; - google.protobuf.Timestamp capture_time = 5; // Optional - string main_photo_link_id = 6; // Optional - repeated PhotoTag tags = 7; // Optional +message PhotosFileUploadMetadata { + google.protobuf.Timestamp last_modification_time = 1; // Optional + repeated AdditionalMetadataProperty additional_metadata = 2; // Optional + google.protobuf.Timestamp capture_time = 3; // Optional + string main_photo_uid = 4; // Optional + repeated PhotoTag tags = 5; // Optional } enum PhotoTag { - PHOTO_TAG_FAVORITES = 0; - PHOTO_TAG_SCREENSHOTS = 1; - PHOTO_TAG_VIDEOS = 2; - PHOTO_TAG_LIVE_PHOTOS = 3; - PHOTO_TAG_MOTION_PHOTOS = 4; - PHOTO_TAG_SELFIES = 5; - PHOTO_TAG_PORTRAITS = 6; - PHOTO_TAG_BURSTS = 7; - PHOTO_TAG_PANORAMAS = 8; + PHOTO_TAG_FAVORITE = 0; + PHOTO_TAG_SCREENSHOT = 1; + PHOTO_TAG_VIDEO = 2; + PHOTO_TAG_LIVE_PHOTO = 3; + PHOTO_TAG_MOTION_PHOTO = 4; + PHOTO_TAG_SELFIE = 5; + PHOTO_TAG_PORTRAIT = 6; + PHOTO_TAG_BURST = 7; + PHOTO_TAG_PANORAMA = 8; PHOTO_TAG_RAW = 9; } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt index 5b86c99b..bbfb79ed 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt @@ -1,15 +1,15 @@ package me.proton.drive.sdk.entity enum class PhotoTag(val value: Long) { - Favorites(0), - Screenshots(1), - Videos(2), - LivePhotos(3), - MotionPhotos(4), - Selfies(5), - Portraits(6), - Bursts(7), - Panoramas(8), + Favorite(0), + Screenshot(1), + Video(2), + LivePhoto(3), + MotionPhoto(4), + Selfie(5), + Portrait(6), + Burst(7), + Panorama(8), Raw(9); companion object { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt index 2949fe35..9e6775f9 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt @@ -8,7 +8,7 @@ data class PhotosUploaderRequest( val fileSize: Long, val lastModificationTime: Instant?, // optional val captureTime: Instant?, // optional - val mainPhotoLinkId: String?, // optional + val mainPhotoUid: String? = null, // optional val tags: List = emptyList(), // optional val overrideExistingDraftByOtherClient: Boolean, val additionalMetadata: Map = emptyMap(), // optional diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotoTag.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotoTag.kt index 16d6ec9b..2bc62fbb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotoTag.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotoTag.kt @@ -4,14 +4,14 @@ import me.proton.drive.sdk.entity.PhotoTag import proton.drive.sdk.ProtonDriveSdk.PhotoTag as SdkPhotoTag fun PhotoTag.toSdkPhotoTag(): SdkPhotoTag = when (this) { - PhotoTag.Favorites -> SdkPhotoTag.PHOTO_TAG_FAVORITES - PhotoTag.Screenshots -> SdkPhotoTag.PHOTO_TAG_SCREENSHOTS - PhotoTag.Videos -> SdkPhotoTag.PHOTO_TAG_VIDEOS - PhotoTag.LivePhotos -> SdkPhotoTag.PHOTO_TAG_LIVE_PHOTOS - PhotoTag.MotionPhotos -> SdkPhotoTag.PHOTO_TAG_MOTION_PHOTOS - PhotoTag.Selfies -> SdkPhotoTag.PHOTO_TAG_SELFIES - PhotoTag.Portraits -> SdkPhotoTag.PHOTO_TAG_PORTRAITS - PhotoTag.Bursts -> SdkPhotoTag.PHOTO_TAG_BURSTS - PhotoTag.Panoramas -> SdkPhotoTag.PHOTO_TAG_PANORAMAS + PhotoTag.Favorite -> SdkPhotoTag.PHOTO_TAG_FAVORITE + PhotoTag.Screenshot -> SdkPhotoTag.PHOTO_TAG_SCREENSHOT + PhotoTag.Video -> SdkPhotoTag.PHOTO_TAG_VIDEO + PhotoTag.LivePhoto -> SdkPhotoTag.PHOTO_TAG_LIVE_PHOTO + PhotoTag.MotionPhoto -> SdkPhotoTag.PHOTO_TAG_MOTION_PHOTO + PhotoTag.Selfie -> SdkPhotoTag.PHOTO_TAG_SELFIE + PhotoTag.Portrait -> SdkPhotoTag.PHOTO_TAG_PORTRAIT + PhotoTag.Burst -> SdkPhotoTag.PHOTO_TAG_BURST + PhotoTag.Panorama -> SdkPhotoTag.PHOTO_TAG_PANORAMA PhotoTag.Raw -> SdkPhotoTag.PHOTO_TAG_RAW } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt index 5726aa70..3ed5ede2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt @@ -5,7 +5,7 @@ import com.google.protobuf.timestamp import me.proton.drive.sdk.entity.PhotosUploaderRequest import proton.drive.sdk.additionalMetadataProperty import proton.drive.sdk.drivePhotosClientGetPhotoUploaderRequest -import proton.drive.sdk.photoFileUploadMetadata +import proton.drive.sdk.photosFileUploadMetadata internal fun PhotosUploaderRequest.toProtobuf( clientHandle: Long, @@ -13,28 +13,28 @@ internal fun PhotosUploaderRequest.toProtobuf( ) = drivePhotosClientGetPhotoUploaderRequest { this.clientHandle = clientHandle name = this@toProtobuf.name + mediaType = this@toProtobuf.mediaType size = this@toProtobuf.fileSize - metadata = photoFileUploadMetadata { - mediaType = this@toProtobuf.mediaType + metadata = photosFileUploadMetadata { this@toProtobuf.captureTime?.let { captureTime = it.toTimestamp() } this@toProtobuf.lastModificationTime?.let { lastModificationTime = it.toTimestamp() } - overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> additionalMetadataProperty { this.name = name this.utf8JsonValue = data.toByteString() } } - this@toProtobuf.mainPhotoLinkId?.let { - mainPhotoLinkId = it + this@toProtobuf.mainPhotoUid?.let { + mainPhotoUid = it } tags += this@toProtobuf.tags.map { photoTag -> photoTag.toSdkPhotoTag() } } + overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient this.cancellationTokenSourceHandle = cancellationTokenSourceHandle } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 15c568d8..602bac14 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -195,12 +195,11 @@ extension ProtonPhotosClient { fileSize: Int64, modificationDate: Date, captureTime: Date, - mainPhotoLinkID: String?, + mainPhotoUid: SDKNodeUid?, mediaType: String, thumbnails: [ThumbnailData], tags: [Int], additionalMetadata: [AdditionalMetadata], - overrideExistingDraft: Bool, expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback, @@ -212,12 +211,11 @@ extension ProtonPhotosClient { fileSize: fileSize, modificationDate: modificationDate, captureTime: captureTime, - mainPhotoLinkID: mainPhotoLinkID, + mainPhotoUid: mainPhotoUid, mediaType: mediaType, thumbnails: thumbnails, tags: tags, additionalMetadata: additionalMetadata, - overrideExistingDraft: overrideExistingDraft, expectedSHA1: expectedSHA1, cancellationToken: cancellationToken, progressCallback: progressCallback @@ -235,12 +233,11 @@ extension ProtonPhotosClient { fileSize: Int64, modificationDate: Date, captureTime: Date, - mainPhotoLinkID: String?, + mainPhotoUid: SDKNodeUid?, mediaType: String, thumbnails: [ThumbnailData], tags: [Int], additionalMetadata: [AdditionalMetadata], - overrideExistingDraft: Bool, expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback @@ -259,12 +256,11 @@ extension ProtonPhotosClient { fileSize: fileSize, modificationDate: modificationDate, captureTime: captureTime, - mainPhotoLinkID: mainPhotoLinkID, + mainPhotoUid: mainPhotoUid, mediaType: mediaType, thumbnails: thumbnails, tags: mappedTags, additionalMetadata: additionalMetadata, - overrideExistingDraft: overrideExistingDraft, expectedSHA1: expectedSHA1, cancellationToken: cancellationToken, progressCallback: progressCallback diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift index e2bda42b..1f354aa3 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift @@ -25,12 +25,11 @@ actor PhotoUploadsManager { fileSize: Int64, modificationDate: Date, captureTime: Date, - mainPhotoLinkID: String?, + mainPhotoUid: SDKNodeUid?, mediaType: String, thumbnails: [ThumbnailData], tags: [Proton_Drive_Sdk_PhotoTag], additionalMetadata: [AdditionalMetadata], - overrideExistingDraft: Bool, expectedSHA1: Data? = nil, cancellationToken: UUID, progressCallback: @escaping ProgressCallback @@ -46,10 +45,9 @@ actor PhotoUploadsManager { modificationDate: modificationDate, mediaType: mediaType, captureTime: captureTime, - mainPhotoLinkID: mainPhotoLinkID, + mainPhotoUid: mainPhotoUid, tags: tags, additionalMetadata: additionalMetadata, - overrideExistingDraft: overrideExistingDraft, cancellationHandle: cancellationHandle ) @@ -155,24 +153,23 @@ actor PhotoUploadsManager { modificationDate: Date, mediaType: String, captureTime: Date, - mainPhotoLinkID: String?, + mainPhotoUid: SDKNodeUid?, tags: [Proton_Drive_Sdk_PhotoTag], additionalMetadata: [AdditionalMetadata], - overrideExistingDraft: Bool, cancellationHandle: ObjectHandle ) async throws -> ObjectHandle { let uploaderRequest = Proton_Drive_Sdk_DrivePhotosClientGetPhotoUploaderRequest.with { $0.clientHandle = Int64(clientHandle) $0.name = name + $0.mediaType = mediaType $0.size = fileSize - $0.metadata = Proton_Drive_Sdk_PhotoFileUploadMetadata.with { metadata in - metadata.mediaType = mediaType + + $0.metadata = Proton_Drive_Sdk_PhotosFileUploadMetadata.with { metadata in metadata.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) metadata.additionalMetadata = additionalMetadata.map { $0.toSDK } - metadata.overrideExistingDraftByOtherClient = overrideExistingDraft metadata.captureTime = Google_Protobuf_Timestamp(date: captureTime) - if let mainPhotoLinkID { - metadata.mainPhotoLinkID = mainPhotoLinkID + if let mainPhotoUid { + metadata.mainPhotoUid = mainPhotoUid.sdkCompatibleIdentifier } metadata.tags = tags } From 5f2a5667eecaf3c5624616fd5acc5f3c52186ad3 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Mar 2026 11:52:57 +0000 Subject: [PATCH 596/791] Update changelog for cs/v0.8.0 --- cs/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 1098b42d..bcd7198c 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## cs/v0.8.0 (2026-03-12) + +* Implement upload to Photos +* Set swift error message +* Handle nullable OwnedBy fields when mapping to proto +* Propagate individual thumbnail errors to callers instead of silently skipping them +* Add owned by property + ## cs/v0.7.0-alpha.17 (2026-03-10) * Fix manifest verification errors due to wrong thumbnail order in manifest From 69ad027c8a544c53702b17773c1a9c44f2a77662 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 13 Mar 2026 15:34:49 +0000 Subject: [PATCH 597/791] Add streaming thumbnails enumeration for Drive and Photos clients --- .../InteropMessageHandler.cs | 9 +++++ .../InteropProtonDriveClient.cs | 30 ++++++++++++++++ .../InteropProtonPhotosClient.cs | 32 ++++++++++++++++- cs/sdk/src/protos/proton.drive.sdk.proto | 24 +++++++++++-- kt/sdk/src/main/jni/proton_drive_sdk.c | 11 ++++++ .../me/proton/drive/sdk/ProtonDriveClient.kt | 32 +++++++++++++++-- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 35 +++++++++++++++++-- .../sdk/internal/JniBaseProtonDriveSdk.kt | 27 ++++++++++++++ .../sdk/internal/JniProtonDriveClient.kt | 14 ++++++++ .../sdk/internal/JniProtonPhotosClient.kt | 17 ++++++++- .../internal/ProtonDriveSdkNativeClient.kt | 12 +++++++ .../Downloads/DownloadThumbnailsManager.swift | 2 +- .../Sources/Plumbing/Message+Packaging.swift | 2 +- 13 files changed, 237 insertions(+), 10 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index bdc761fb..3950ef1d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -61,6 +61,11 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientGetThumbnails => await InteropProtonDriveClient.HandleGetThumbnailsAsync(request.DriveClientGetThumbnails).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientEnumerateThumbnails + => await InteropProtonDriveClient.HandleEnumerateThumbnailsAsync( + request.DriveClientEnumerateThumbnails, + bindingsHandle).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientEnumerateFolderChildren => await InteropProtonDriveClient.HandleEnumerateFolderChildrenAsync(request.DriveClientEnumerateFolderChildren).ConfigureAwait(false), @@ -135,6 +140,10 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint => await InteropProtonPhotosClient.HandleEnumeratePhotosThumbnailsAsync( request.DrivePhotosClientEnumeratePhotosThumbnails).ConfigureAwait(false), + Request.PayloadOneofCase.DrivePhotosClientEnumerateThumbnails + => await InteropProtonPhotosClient.HandleEnumerateThumbnailsAsync( + request.DrivePhotosClientEnumerateThumbnails, bindingsHandle).ConfigureAwait(false), + Request.PayloadOneofCase.DrivePhotosClientEnumerateTimeline => await InteropProtonPhotosClient.HandleEnumeratePhotosTimelineAsync( request.DrivePhotosClientEnumerateTimeline).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index d1829764..af4a78a0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -195,6 +195,36 @@ public static async ValueTask HandleGetThumbnailsAsync(DriveClientGetT return new FileThumbnailList { Thumbnails = { thumbnails } }; } + public static async ValueTask HandleEnumerateThumbnailsAsync(DriveClientEnumerateThumbnailsRequest request, nint bindingsHandle) + { + var iterateFunction = new InteropAction>(request.IterateAction); + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( + request.FileUids.Select(NodeUid.Parse), + (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, + cancellationToken); + + await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false)) + { + var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() }; + if (x.Result.TryGetValueElseError(out var data, out var error)) + { + thumbnail.Data = ByteString.CopyFrom(data.Span); + } + else + { + thumbnail.Error = ConvertToDriveError(error); + } + + iterateFunction.InvokeWithMessage(bindingsHandle, thumbnail); + } + + return null; + } + public static async ValueTask HandleEnumerateFolderChildrenAsync(DriveClientEnumerateFolderChildrenRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 09041411..fd198922 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -128,7 +128,7 @@ public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhot return new Int64Value { Value = Interop.AllocHandle(downloader) }; } - public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(DrivePhotosClientEnumeratePhotosThumbnailsRequest request) + public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(DrivePhotosClientGetThumbnailsRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); @@ -158,6 +158,36 @@ public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(Dri return new FileThumbnailList { Thumbnails = { thumbnails } }; } + public static async ValueTask HandleEnumerateThumbnailsAsync(DrivePhotosClientEnumerateThumbnailsRequest request, nint bindingsHandle) + { + var iterateFunction = new InteropAction>(request.IterateAction); + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( + request.PhotoUids.Select(NodeUid.Parse), + (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, + cancellationToken); + + await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false)) + { + var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() }; + if (x.Result.TryGetValueElseError(out var data, out var error)) + { + thumbnail.Data = ByteString.CopyFrom(data.Span); + } + else + { + thumbnail.Error = InteropProtonDriveClient.ConvertToDriveError(error); + } + + iterateFunction.InvokeWithMessage(bindingsHandle, thumbnail); + } + + return null; + } + public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosClientGetPhotoUploaderRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 377ca9c8..c9b47657 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -26,6 +26,7 @@ message Request { DriveClientRestoreNodesRequest drive_client_restore_nodes = 1014; DriveClientEnumerateTrashRequest drive_client_enumerate_trash = 1015; DriveClientEmptyTrashRequest drive_client_empty_trash = 1016; + DriveClientEnumerateThumbnailsRequest drive_client_enumerate_thumbnails = 1017; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -50,7 +51,7 @@ message Request { DrivePhotosClientCreateRequest drive_photos_client_create = 1300; DrivePhotosClientCreateFromSessionRequest drive_photos_client_create_from_session = 1301; DrivePhotosClientFreeRequest drive_photos_client_free = 1302; - DrivePhotosClientEnumeratePhotosThumbnailsRequest drive_photos_client_enumerate_photos_thumbnails = 1303; + DrivePhotosClientGetThumbnailsRequest drive_photos_client_enumerate_photos_thumbnails = 1303; DrivePhotosClientGetPhotoDownloaderRequest drive_photos_client_get_photo_downloader = 1304; DrivePhotosClientDownloadToStreamRequest drive_photos_client_download_to_stream = 1305; DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1306; @@ -62,6 +63,7 @@ message Request { DrivePhotosClientUploadFromStreamRequest drive_photos_client_upload_from_stream = 1312; DrivePhotosClientUploadFromFileRequest drive_photos_client_upload_from_file = 1313; DrivePhotosClientUploaderFreeRequest drive_photos_client_uploader_free = 1314; + DrivePhotosClientEnumerateThumbnailsRequest drive_photos_client_enumerate_thumbnails = 1315; }; } @@ -427,6 +429,15 @@ message DriveClientGetThumbnailsRequest { int64 cancellation_token_source_handle = 4; } +// The response must not have a value, iterate_action will be call for each item. +message DriveClientEnumerateThumbnailsRequest { + int64 client_handle = 1; + repeated string file_uids = 2; + ThumbnailType type = 3; + int64 cancellation_token_source_handle = 4; + int64 iterate_action = 5; +} + // The response message must be of type FolderChildrenList. message DriveClientEnumerateFolderChildrenRequest { int64 client_handle = 1; @@ -633,11 +644,20 @@ message DrivePhotosClientFreeRequest { } // The response message must be of type FileThumbnailList. -message DrivePhotosClientEnumeratePhotosThumbnailsRequest { +message DrivePhotosClientGetThumbnailsRequest { + int64 client_handle = 1; + repeated string photo_uids = 2; + ThumbnailType type = 3; + int64 cancellation_token_source_handle = 4; +} + +// The response must not have a value, iterate_action will be call for each item. +message DrivePhotosClientEnumerateThumbnailsRequest { int64 client_handle = 1; repeated string photo_uids = 2; ThumbnailType type = 3; int64 cancellation_token_source_handle = 4; + int64 iterate_action = 5; } // The response message must be of type PhotosTimelineList. diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index f848e927..37a31d8a 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -71,6 +71,10 @@ void onSeek( pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onSeek"); } +void onEnumerate(intptr_t bindings_handle, ByteArray value) { + pushDataToVoidMethod(bindings_handle, value, "onEnumerate"); +} + void onProgress(intptr_t bindings_handle, ByteArray value) { pushDataToVoidMethod(bindings_handle, value, "onProgress"); } @@ -140,6 +144,13 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSeekPointe return (jlong) (intptr_t) &onSeek; } +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getEnumeratePointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onEnumerate; +} + jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getProgressPointer( JNIEnv *env, jclass clazz diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 7fe66057..1829ac12 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk -import com.google.protobuf.timestamp +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.FileThumbnail @@ -12,6 +13,7 @@ import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.extension.toTimestamp import me.proton.drive.sdk.internal.JniProtonDriveClient +import me.proton.drive.sdk.internal.ProtonDriveSdkNativeClient import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId @@ -19,6 +21,7 @@ import proton.drive.sdk.driveClientCreateFolderRequest import proton.drive.sdk.driveClientEnumerateFolderChildrenRequest import proton.drive.sdk.driveClientDeleteNodesRequest import proton.drive.sdk.driveClientEmptyTrashRequest +import proton.drive.sdk.driveClientEnumerateThumbnailsRequest import proton.drive.sdk.driveClientEnumerateTrashRequest import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetMyFilesFolderRequest @@ -26,7 +29,6 @@ import proton.drive.sdk.driveClientGetThumbnailsRequest import proton.drive.sdk.driveClientRenameRequest import proton.drive.sdk.driveClientRestoreNodesRequest import proton.drive.sdk.driveClientTrashNodesRequest -import java.nio.channels.WritableByteChannel import java.time.Instant class ProtonDriveClient internal constructor( @@ -50,6 +52,10 @@ class ProtonDriveClient internal constructor( ) } + @Deprecated( + message = "Use enumerateThumbnails instead for streaming results.", + replaceWith = ReplaceWith("enumerateThumbnails(fileUids, type)"), + ) suspend fun getThumbnails( fileUids: List, type: ThumbnailType, @@ -67,6 +73,28 @@ class ProtonDriveClient internal constructor( } } + fun enumerateThumbnails( + fileUids: List, + type: ThumbnailType, + ): Flow = channelFlow { + log(INFO, "enumerateThumbnails($type)") + cancellationCoroutineScope { source -> + bridge.enumerateThumbnails( + coroutineScope = this@channelFlow, + request = driveClientEnumerateThumbnailsRequest { + this.fileUids += fileUids + this.type = type.toProto() + clientHandle = handle + cancellationTokenSourceHandle = source.handle + iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + }, + enumerate = { fileThumbnail -> + send(fileThumbnail.toEntity()) + } + ) + } + } + suspend fun rename( nodeUid: String, name: String, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 9b3592a4..1ca51167 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -1,5 +1,8 @@ package me.proton.drive.sdk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.FileThumbnail @@ -9,12 +12,14 @@ import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.internal.JniProtonPhotosClient +import me.proton.drive.sdk.internal.ProtonDriveSdkNativeClient import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId -import proton.drive.sdk.drivePhotosClientEnumeratePhotosThumbnailsRequest +import proton.drive.sdk.drivePhotosClientEnumerateThumbnailsRequest import proton.drive.sdk.drivePhotosClientEnumerateTimelineRequest import proton.drive.sdk.drivePhotosClientGetNodeRequest +import proton.drive.sdk.drivePhotosClientGetThumbnailsRequest class ProtonPhotosClient internal constructor( internal val handle: Long, @@ -22,13 +27,17 @@ class ProtonPhotosClient internal constructor( session: Session? = null, ) : SdkNode(session), AutoCloseable { + @Deprecated( + message = "Use enumerateThumbnails instead for streaming results.", + replaceWith = ReplaceWith("enumerateThumbnails(photoUids, type)"), + ) suspend fun getThumbnails( photoUids: List, type: ThumbnailType, ): List = cancellationCoroutineScope { source -> log(INFO, "getThumbnails($type)") bridge.getThumbnails( - drivePhotosClientEnumeratePhotosThumbnailsRequest { + drivePhotosClientGetThumbnailsRequest { this.photoUids += photoUids this.type = type.toProto() clientHandle = handle @@ -39,6 +48,28 @@ class ProtonPhotosClient internal constructor( } } + fun enumerateThumbnails( + photoUids: List, + type: ThumbnailType, + ): Flow = channelFlow { + log(INFO, "enumerateThumbnails($type)") + cancellationCoroutineScope { source -> + bridge.enumerateThumbnails( + coroutineScope = this@channelFlow, + request = drivePhotosClientEnumerateThumbnailsRequest { + this.photoUids += photoUids + this.type = type.toProto() + clientHandle = handle + cancellationTokenSourceHandle = source.handle + iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + }, + enumerate = { fileThumbnail -> + send(fileThumbnail.toEntity()) + } + ) + } + } + suspend fun enumerateTimeline(): List = cancellationCoroutineScope { source -> log(DEBUG, "enumerateTimeline") bridge.enumerateTimeline( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt index 25eaa82c..dc757dd1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -6,6 +6,7 @@ import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE import proton.drive.sdk.ProtonDriveSdk.Request import proton.drive.sdk.RequestKt import proton.drive.sdk.request +import java.nio.ByteBuffer abstract class JniBaseProtonDriveSdk : JniBase() { @@ -46,6 +47,32 @@ abstract class JniBaseProtonDriveSdk : JniBase() { nativeClient.handleRequest(request(block)) } + suspend fun executeEnumerate( + name: String, + callback: (CancellableContinuation) -> ResponseCallback, + enumerate: suspend (E) -> Unit, + parser: (ByteBuffer) -> E, + coroutineScopeProvider: CoroutineScopeProvider, + block: RequestKt.Dsl.() -> Unit, + ): T = suspendCancellableCoroutine { continuation -> + check(released.not()) { "Cannot executeOnce ${method(name)} after release" } + // Create the callback here to capture the call stack trace + val responseCallback = callback(continuation) + val nativeClient = ProtonDriveSdkNativeClient( + name = method(name), + response = { client, buffer -> + responseCallback(buffer) + client.release() + clients -= client + }, + enumerate = { data -> enumerate(parser(data)) }, + logger = internalLogger, + coroutineScopeProvider = coroutineScopeProvider, + ) + clients += nativeClient + nativeClient.handleRequest(request(block)) + } + suspend fun executePersistent( clientBuilder: (CancellableContinuation) -> ProtonDriveSdkNativeClient, requestBuilder: (ProtonDriveSdkNativeClient) -> Request, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index d2b0b677..72ab5d91 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -99,6 +99,20 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { driveClientGetThumbnails = request } + suspend fun enumerateThumbnails( + coroutineScope: CoroutineScope, + request: ProtonDriveSdk.DriveClientEnumerateThumbnailsRequest, + enumerate: suspend (ProtonDriveSdk.FileThumbnail) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateThumbnails", + callback = UnitResponseCallback, + enumerate = enumerate, + parser = ProtonDriveSdk.FileThumbnail::parseFrom, + coroutineScopeProvider = { coroutineScope }, + ) { + driveClientEnumerateThumbnails = request + } + suspend fun createFolder( request: ProtonDriveSdk.DriveClientCreateFolderRequest, ): ProtonDriveSdk.FolderNode = executeOnce("createFolder", FolderNodeConverter().asCallback) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index 804f0451..11470baa 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -7,6 +7,7 @@ import me.proton.drive.sdk.converter.NodeResultConverter import me.proton.drive.sdk.converter.PhotosTimelineListConverter import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.extension.LongResponseCallback +import me.proton.drive.sdk.extension.UnitResponseCallback import me.proton.drive.sdk.extension.asCallback import me.proton.drive.sdk.extension.asNullableCallback import me.proton.drive.sdk.extension.toLongResponse @@ -77,12 +78,26 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { }) suspend fun getThumbnails( - request: ProtonDriveSdk.DrivePhotosClientEnumeratePhotosThumbnailsRequest, + request: ProtonDriveSdk.DrivePhotosClientGetThumbnailsRequest, ): ProtonDriveSdk.FileThumbnailList = executeOnce("getThumbnails", FileThumbnailListConverter().asCallback) { drivePhotosClientEnumeratePhotosThumbnails = request } + suspend fun enumerateThumbnails( + coroutineScope: CoroutineScope, + request: ProtonDriveSdk.DrivePhotosClientEnumerateThumbnailsRequest, + enumerate: suspend (ProtonDriveSdk.FileThumbnail) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateThumbnails", + callback = UnitResponseCallback, + enumerate = enumerate, + parser = ProtonDriveSdk.FileThumbnail::parseFrom, + coroutineScopeProvider = { coroutineScope }, + ) { + drivePhotosClientEnumerateThumbnails = request + } + suspend fun enumerateTimeline( request: ProtonDriveSdk.DrivePhotosClientEnumerateTimelineRequest, ): ProtonDriveSdk.PhotosTimelineList = diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index affb9225..707c7532 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -30,6 +30,7 @@ import java.nio.ByteBuffer class ProtonDriveSdkNativeClient internal constructor( val name: String, val response: ClientResponseCallback = { _, _ -> error("response not configured for $name") }, + val enumerate: suspend (ByteBuffer) -> Unit = { error("enumerate not configured for $name") }, val read: suspend (ByteBuffer) -> Int = { error("read not configured for $name") }, val write: suspend (ByteBuffer) -> Unit = { error("write not configured for $name") }, val seek: (suspend (Long, Int) -> Long)? = null, @@ -105,6 +106,14 @@ class ProtonDriveSdkNativeClient internal constructor( response(this, data) } + @Suppress("unused") // Called by JNI + fun onEnumerate(data: ByteBuffer) = onCallback( + callback = "enumerate", + data = data, + parser = { it }, + block = enumerate, + ) + @Suppress("unused") // Called by JNI fun onProgress(data: ByteBuffer) = onCallback( callback = "progress", @@ -403,6 +412,9 @@ class ProtonDriveSdkNativeClient internal constructor( @JvmStatic external fun getSeekPointer(): Long + @JvmStatic + external fun getEnumeratePointer(): Long + @JvmStatic external fun getProgressPointer(): Long diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift index b022d764..c8d1d926 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift @@ -65,7 +65,7 @@ actor DownloadThumbnailsManager { } } - let thumbnailsRequest = Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosThumbnailsRequest.with { + let thumbnailsRequest = Proton_Drive_Sdk_DrivePhotosClientGetThumbnailsRequest.with { $0.clientHandle = Int64(clientHandle) $0.photoUids = photoUids.map(\.sdkCompatibleIdentifier) $0.type = type.sdkType diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index a642b982..43a6772b 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -202,7 +202,7 @@ extension Message { $0.payload = .drivePhotosClientFree(request) } - case let request as Proton_Drive_Sdk_DrivePhotosClientEnumeratePhotosThumbnailsRequest: + case let request as Proton_Drive_Sdk_DrivePhotosClientGetThumbnailsRequest: Proton_Drive_Sdk_Request.with { $0.payload = .drivePhotosClientEnumeratePhotosThumbnails(request) } From f0b933300e77a0d78cd72bfa354a86bdc44ec2d7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 07:00:21 +0000 Subject: [PATCH 598/791] Fix disposal of upload controller and update upload bindings api --- .../Nodes/Upload/UploadController.cs | 12 +++++++++--- .../ProtonDriveClient/ProtonDriveClient.swift | 14 ++++++++++++-- .../ProtonPhotosClient/ProtonPhotosClient.swift | 10 ++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 365f8d27..133b545a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -70,15 +70,21 @@ public async ValueTask DisposeAsync() return; } + var draftExists = _revisionDraftTask.IsCompletedSuccessfully; if (Completion.IsFaulted) { - var revisionDraft = await _revisionDraftTask.ConfigureAwait(false); + long numberOfPlainBytesDone = 0; + if (draftExists) + { + var revisionDraft = await _revisionDraftTask.ConfigureAwait(false); + numberOfPlainBytesDone = revisionDraft.NumberOfPlainBytesDone; + } + _onFailed?.Invoke( Completion.Exception.Flatten().InnerException ?? Completion.Exception, - revisionDraft.NumberOfPlainBytesDone); + numberOfPlainBytesDone); } - var draftExists = _revisionDraftTask.IsCompletedSuccessfully; if (!draftExists) { return; diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 5edb2595..8f214e56 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -205,8 +205,8 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { progressCallback: progressCallback ) - return try await operation.awaitUploadWithResilience( - operationalResilience: configuration.uploadOperationalResilience, + return try await startUpload( + operation: operation, onRetriableErrorReceived: onRetriableErrorReceived ) } @@ -239,6 +239,16 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { ) } + public func startUpload( + operation: UploadOperation, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> UploadedFileIdentifiers { + return try await operation.awaitUploadWithResilience( + operationalResilience: configuration.uploadOperationalResilience, + onRetriableErrorReceived: onRetriableErrorReceived + ) + } + /// Convenience API for when you don't need a more granular control over the upload (pause, resume etc.) public func uploadNewRevision( currentActiveRevisionUid: SDKRevisionUid, diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 602bac14..ed6c6e06 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -221,6 +221,16 @@ extension ProtonPhotosClient { progressCallback: progressCallback ) + return try await startUpload( + operation: operation, + onRetriableErrorReceived: onRetriableErrorReceived + ) + } + + public func startUpload( + operation: UploadOperation, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> UploadedFileIdentifiers { return try await operation.awaitUploadWithResilience( operationalResilience: configuration.uploadOperationalResilience, onRetriableErrorReceived: onRetriableErrorReceived From dbe3a8c4d3fb450e6e6f2d8bee779e5ce5389dc7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 10:32:36 +0100 Subject: [PATCH 599/791] Log number of ids when enumerate thumbnails --- kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt | 2 +- .../src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 1829ac12..e11d9333 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -77,7 +77,7 @@ class ProtonDriveClient internal constructor( fileUids: List, type: ThumbnailType, ): Flow = channelFlow { - log(INFO, "enumerateThumbnails($type)") + log(INFO, "enumerateThumbnails(${fileUids.size}, $type)") cancellationCoroutineScope { source -> bridge.enumerateThumbnails( coroutineScope = this@channelFlow, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 1ca51167..2b970d72 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -52,7 +52,7 @@ class ProtonPhotosClient internal constructor( photoUids: List, type: ThumbnailType, ): Flow = channelFlow { - log(INFO, "enumerateThumbnails($type)") + log(INFO, "enumerateThumbnails(${photoUids.size}, $type)") cancellationCoroutineScope { source -> bridge.enumerateThumbnails( coroutineScope = this@channelFlow, From 82a8b98d78c4e8f1601eb25677d9ae3b73603481 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 06:03:17 +0000 Subject: [PATCH 600/791] i18n(weekly-mr): Upgrade translations from crowdin (69ad027c). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 3 +++ js/sdk/locales/config/locales.json | 8 +++---- js/sdk/locales/es_LA.json | 2 +- js/sdk/locales/ko_KR.json | 30 +++++++++++++++++++++++++++ js/sdk/locales/ro_RO.json | 6 ++++++ 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index e602ae1f..6513d855 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "e871598193d788c9f1c41fa6c521a410622abf91" + "locale": "a9f94fb09851c792e91809805f16ae69dcf1913f" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 7d300b46..3b257349 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -104,6 +104,9 @@ "File has no content key": [ "Файл не мае ключа змеÑціва" ], + "File has no revision": [ + "У файла адÑутнічае Ñ€ÑдакцыÑ" + ], "File hash does not match expected hash": [ "Ð¥Ñш файла не Ñупадае з чаканым Ñ…Ñшам" ], diff --git a/js/sdk/locales/config/locales.json b/js/sdk/locales/config/locales.json index 9cd44924..1459644f 100644 --- a/js/sdk/locales/config/locales.json +++ b/js/sdk/locales/config/locales.json @@ -2,19 +2,19 @@ "en_US": "English", "de_DE": "Deutsch", "fr_FR": "Français", - "nl_NL": "Nederlands", "es_ES": "Español (España)", "es_LA": "Español (Latinoamérica)", "it_IT": "Italiano", + "nl_NL": "Nederlands", "pl_PL": "Polski", "pt_BR": "Português (Brasil)", "ru_RU": "РуÑÑкий", - "tr_TR": "Türkçe", + "ko_KR": "한국어", "ca_ES": "Català", "pt_PT": "Português (Portugal)", "ro_RO": "Română", + "tr_TR": "Türkçe", "sk_SK": "SlovenÄina", "el_GR": "Ελληνικά", - "be_BY": "БеларуÑкаÑ", - "ko_KR": "한국어" + "be_BY": "БеларуÑкаÑ" } \ No newline at end of file diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 71ae6eaf..46b8f247 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -21,7 +21,7 @@ "No se puede compartir la carpeta raíz" ], "Content key packet is required for small revision upload": [ - "Se requiere el paquete de claves de contenido para subir una revisión pequeña" + "Se requiere el paquete de claves de contenido para cargar una revisión pequeña" ], "Copy operation aborted": [ "Se canceló la operación de copia" diff --git a/js/sdk/locales/ko_KR.json b/js/sdk/locales/ko_KR.json index a6a11a6d..83db06fa 100644 --- a/js/sdk/locales/ko_KR.json +++ b/js/sdk/locales/ko_KR.json @@ -5,21 +5,33 @@ }, "contexts": { "Error": { + "Album contains photos not in timeline": [ + "ì•¨ë²”ì— íƒ€ìž„ë¼ì¸ì— 존재하지 않는 ì‚¬ì§„ì´ í¬í•¨ë˜ì–´ 있습니다" + ], "Bookmark password is not available": [ "ë¶ë§ˆí¬ 비밀번호를 사용할 수 없습니다" ], + "Cannot add photo to album without a valid name": [ + "유효한 ì´ë¦„ ì—†ì´ëŠ” ì•¨ë²”ì— ì‚¬ì§„ì„ ì¶”ê°€í•  수 없습니다." + ], "Cannot download a folder": [ "í´ë”를 다운로드할 수 ì—†ìŒ" ], "Cannot share root folder": [ "ìƒìœ„ í´ë”를 공유할 수 없습니다" ], + "Content key packet is required for small revision upload": [ + "콘í…츠 키 íŒ¨í‚·ì€ ì†Œê·œëª¨ 수정 ì—…ë¡œë“œì— í•„ìš”í•©ë‹ˆë‹¤." + ], "Copy operation aborted": [ "복사 작업 중단ë¨" ], "Copying item to a non-folder is not allowed": [ "í´ë”ê°€ 아닌 곳으로 í•­ëª©ì„ ë³µì‚¬í•  수 없습니다" ], + "Creating documents in non-folders is not allowed": [ + "í´ë”ê°€ 아닌 ê³³ì— ë¬¸ì„œë¥¼ ìƒì„±í•  수 없습니다." + ], "Creating files in non-folders is not allowed": [ "í´ë”ê°€ 아닌 ê³³ì— í´ë”를 ìƒì„±í•  수 없습니다" ], @@ -77,6 +89,9 @@ "Failed to get sharing info for node ${ nodeUid }": [ "노드 ${ nodeUid }ì— ëŒ€í•œ 공유 정보를 가져오지 못했습니다" ], + "Failed to get verification keys": [ + "ì¸ì¦ 키를 가져올 수 없습니다" + ], "Failed to load some nodes": [ "ì¼ë¶€ 로드 불러오기 실패" ], @@ -92,6 +107,12 @@ "File has no content key": [ "파ì¼ì— 콘í…츠 키가 없습니다" ], + "File has no revision": [ + "파ì¼ì— 수정 ë²„ì „ì´ ì—†ìŠµë‹ˆë‹¤" + ], + "File hash does not match expected hash": [ + "íŒŒì¼ í•´ì‹œê°€ 예측한 해시값과 ì¼ì¹˜í•˜ì§€ 않습니다." + ], "Invalid URL": [ "ìž˜ëª»ëœ URL" ], @@ -152,9 +173,18 @@ "Operation aborted": [ "작업 중단ë¨" ], + "Operation failed, try again later": [ + "작업 실패, ë‚˜ì¤‘ì— ë‹¤ì‹œ 시ë„í•´ 주세요." + ], "Parent cannot be decrypted": [ "ìƒìœ„ í•­ëª©ì„ ë³µí˜¸í™”í•  수 없습니다" ], + "Photo is already in the target album": [ + "ì‚¬ì§„ì´ ì´ë¯¸ ëŒ€ìƒ ì•¨ë²”ì— ì¡´ìž¬í•©ë‹ˆë‹¤." + ], + "Photo not found": [ + "ì‚¬ì§„ì„ ì°¾ì„ ìˆ˜ ì—†ìŒ" + ], "Renaming root item is not allowed": [ "ìƒìœ„ í•­ëª©ì˜ ì´ë¦„ ë³€ê²½ì´ í—ˆìš©ë˜ì§€ 않습니다" ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index fe45402d..27b540c8 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -20,6 +20,9 @@ "Cannot share root folder": [ "Nu se poate partaja folderul rădăcină." ], + "Content key packet is required for small revision upload": [ + "Pentru încărcarea revizuirilor mici este necesar pachetul cheii conÈ›inutului." + ], "Copy operation aborted": [ "Copierea a fost anulată." ], @@ -104,6 +107,9 @@ "File has no content key": [ "FiÈ™ierul nu are nicio cheie de conÈ›inut." ], + "File has no revision": [ + "FiÈ™ierul nu are nicio revizie." + ], "File hash does not match expected hash": [ "Codul de verificare al fiÈ™ierului nu corespunde cu cel aÈ™teptat." ], From 8fe2d132d91f081d617710932f7d60213ca627ac Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 08:51:10 +0000 Subject: [PATCH 601/791] Update changelog for cs/v0.8.1 --- cs/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index bcd7198c..6945f29e 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## cs/v0.8.1 (2026-03-16) + +* Fix disposal of upload controller and update upload bindings api +* Add streaming thumbnails enumeration for Drive and Photos clients +* Update download event values for tests + ## cs/v0.8.0 (2026-03-12) * Implement upload to Photos From adf62205877fb155e6e2b5ae0f27dd8cf542972b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Mar 2026 08:20:42 +0100 Subject: [PATCH 602/791] Add approximate sizes to telemetry events --- js/sdk/src/interface/telemetry.ts | 4 ++ .../src/internal/download/telemetry.test.ts | 5 +++ js/sdk/src/internal/download/telemetry.ts | 6 ++- js/sdk/src/internal/upload/manager.test.ts | 2 +- js/sdk/src/internal/upload/manager.ts | 5 ++- js/sdk/src/internal/upload/telemetry.test.ts | 6 +++ js/sdk/src/internal/upload/telemetry.ts | 4 +- js/sdk/src/telemetry.test.ts | 40 +++++++++++++++++++ js/sdk/src/telemetry.ts | 22 ++++++++++ 9 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 js/sdk/src/telemetry.test.ts diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 99da4a6d..282646d2 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -36,7 +36,9 @@ export interface MetricUploadEvent { eventName: 'upload'; volumeType?: MetricVolumeType; uploadedSize: number; + approximateUploadedSize: number; expectedSize: number; + approximateExpectedSize: number; error?: MetricsUploadErrorType; originalError?: unknown; } @@ -52,7 +54,9 @@ export interface MetricDownloadEvent { eventName: 'download'; volumeType?: MetricVolumeType; downloadedSize: number; + approximateDownloadedSize: number; claimedFileSize?: number; + approximateClaimedFileSize?: number; error?: MetricsDownloadErrorType; originalError?: unknown; } diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts index 4fe3b03b..5b03a9f3 100644 --- a/js/sdk/src/internal/download/telemetry.test.ts +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -38,6 +38,7 @@ describe('DownloadTelemetry', () => { eventName: 'download', volumeType: 'own_volume', downloadedSize: 0, + approximateDownloadedSize: 0, error: 'unknown', originalError: error, }); @@ -51,7 +52,9 @@ describe('DownloadTelemetry', () => { eventName: 'download', volumeType: 'own_volume', downloadedSize: 123, + approximateDownloadedSize: 4095, claimedFileSize: 456, + approximateClaimedFileSize: 4095, error: 'unknown', originalError: error, }); @@ -64,7 +67,9 @@ describe('DownloadTelemetry', () => { eventName: 'download', volumeType: 'own_volume', downloadedSize: 500, + approximateDownloadedSize: 4095, claimedFileSize: 500, + approximateClaimedFileSize: 4095, }); }); diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index faa73321..fda2f133 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -1,6 +1,6 @@ import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from '../../errors'; import { ProtonDriveTelemetry, MetricsDownloadErrorType, Logger } from '../../interface'; -import { LoggerWithPrefix } from '../../telemetry'; +import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry'; import { APIHTTPError } from '../apiService'; import { splitNodeRevisionUid, splitNodeUid } from '../uids'; import { SharesService } from './interface'; @@ -83,6 +83,10 @@ export class DownloadTelemetry { this.telemetry.recordMetric({ eventName: 'download', volumeType, + approximateDownloadedSize: reduceSizePrecision(options.downloadedSize), + approximateClaimedFileSize: options.claimedFileSize + ? reduceSizePrecision(options.claimedFileSize) + : undefined, ...options, }); } diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts index 1e6d7514..a5ee40ed 100644 --- a/js/sdk/src/internal/upload/manager.test.ts +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -133,7 +133,7 @@ describe('UploadManager', () => { armoredEncryptedName: 'newNode:encryptedName', hash: 'newNode:hash', mediaType: 'myMimeType', - intendedUploadSize: 123456, + intendedUploadSize: 100_000, armoredNodeKey: 'newNode:armoredKey', armoredNodePassphrase: 'newNode:armoredPassphrase', armoredNodePassphraseSignature: 'newNode:armoredPassphraseSignature', diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index d35fdbc6..382f31d8 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -9,6 +9,7 @@ import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; import { NodeRevisionDraft, NodesService, NodeCrypto } from './interface'; import { makeNodeUid, splitNodeUid } from '../uids'; +import { reduceSizePrecision } from '../../telemetry'; /** * UploadManager is responsible for creating and deleting draft nodes @@ -129,7 +130,7 @@ export class UploadManager { armoredEncryptedName: generatedNodeCrypto.encryptedNode.encryptedName, hash: generatedNodeCrypto.encryptedNode.hash, mediaType: metadata.mediaType, - intendedUploadSize: metadata.expectedSize, + intendedUploadSize: reduceSizePrecision(metadata.expectedSize), armoredNodeKey: generatedNodeCrypto.nodeKeys.encrypted.armoredKey, armoredNodePassphrase: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphrase, armoredNodePassphraseSignature: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphraseSignature, @@ -349,7 +350,7 @@ export class UploadManager { const { nodeRevisionUid } = await this.apiService.createDraftRevision(nodeUid, { currentRevisionUid: node.activeRevision.value.uid, - intendedUploadSize: metadata.expectedSize, + intendedUploadSize: reduceSizePrecision(metadata.expectedSize), }); return { diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index c0a84f7c..9d5612d5 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -38,7 +38,9 @@ describe('UploadTelemetry', () => { eventName: 'upload', volumeType: 'own_volume', uploadedSize: 0, + approximateUploadedSize: 0, expectedSize: 1000, + approximateExpectedSize: 4095, error: 'unknown', originalError: error, }); @@ -52,7 +54,9 @@ describe('UploadTelemetry', () => { eventName: 'upload', volumeType: 'own_volume', uploadedSize: 500, + approximateUploadedSize: 4095, expectedSize: 1000, + approximateExpectedSize: 4095, error: 'unknown', originalError: error, }); @@ -65,7 +69,9 @@ describe('UploadTelemetry', () => { eventName: 'upload', volumeType: 'own_volume', uploadedSize: 1000, + approximateUploadedSize: 4095, expectedSize: 1000, + approximateExpectedSize: 4095, }); }); diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index ea4d8457..a9e849e0 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -1,6 +1,6 @@ import { RateLimitedError, ValidationError, IntegrityError } from '../../errors'; import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from '../../interface'; -import { LoggerWithPrefix } from '../../telemetry'; +import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry'; import { APIHTTPError } from '../apiService'; import { splitNodeUid, splitNodeRevisionUid } from '../uids'; import { SharesService } from './interface'; @@ -95,6 +95,8 @@ export class UploadTelemetry { this.telemetry.recordMetric({ eventName: 'upload', volumeType, + approximateUploadedSize: reduceSizePrecision(options.uploadedSize), + approximateExpectedSize: reduceSizePrecision(options.expectedSize), ...options, }); } diff --git a/js/sdk/src/telemetry.test.ts b/js/sdk/src/telemetry.test.ts new file mode 100644 index 00000000..d7638864 --- /dev/null +++ b/js/sdk/src/telemetry.test.ts @@ -0,0 +1,40 @@ +import { reduceSizePrecision } from './telemetry'; + +describe('reduceSizePrecision', () => { + it('returns 0 for size 0', () => { + expect(reduceSizePrecision(0)).toBe(0); + }); + + it('returns 4095 for very small files (size < 4096)', () => { + expect(reduceSizePrecision(1)).toBe(4095); + expect(reduceSizePrecision(100)).toBe(4095); + expect(reduceSizePrecision(4095)).toBe(4095); + }); + + it('returns precision (100_000) for sizes from 4096 to below precision', () => { + expect(reduceSizePrecision(4096)).toBe(100_000); + expect(reduceSizePrecision(50_000)).toBe(100_000); + expect(reduceSizePrecision(99_999)).toBe(100_000); + }); + + it('returns size unchanged when size equals precision', () => { + expect(reduceSizePrecision(100_000)).toBe(100_000); + }); + + it('rounds down to nearest 100_000 for sizes above precision', () => { + expect(reduceSizePrecision(100_001)).toBe(100_000); + expect(reduceSizePrecision(150_000)).toBe(100_000); + expect(reduceSizePrecision(199_999)).toBe(100_000); + expect(reduceSizePrecision(200_000)).toBe(200_000); + expect(reduceSizePrecision(250_000)).toBe(200_000); + expect(reduceSizePrecision(299_999)).toBe(200_000); + expect(reduceSizePrecision(300_000)).toBe(300_000); + }); + + it('handles large sizes', () => { + expect(reduceSizePrecision(1_000_000)).toBe(1_000_000); + expect(reduceSizePrecision(1_500_000)).toBe(1_500_000); + expect(reduceSizePrecision(1_999_999)).toBe(1_900_000); + expect(reduceSizePrecision(10_000_000)).toBe(10_000_000); + }); +}); diff --git a/js/sdk/src/telemetry.ts b/js/sdk/src/telemetry.ts index bb300195..6bdca714 100644 --- a/js/sdk/src/telemetry.ts +++ b/js/sdk/src/telemetry.ts @@ -352,3 +352,25 @@ class ConsoleMetricHandler implements MetricHandler { ); } } + +export function reduceSizePrecision(size: number): number { + // The client shouldn't send the clear text size of the file. + // The intented upload size is needed only for early validation that + // the file can fit in the remaining quota to avoid data transfer when + // the upload would be rejected. The backend will still validate + // the quota during block upload and revision commit. + const precision = 100_000; // bytes + + if (size === 0) { + return 0; + } + // We care about very small files in metrics, thus we handle explicitely + // the very small files so they appear correctly in metrics. + if (size < 4096) { + return 4095; + } + if (size < precision) { + return precision; + } + return Math.floor(size / precision) * precision; +} From bde0dc3a3386b2c0b721c55ad24154494cf6ad37 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Mar 2026 11:24:19 +0100 Subject: [PATCH 603/791] Clarify exception for missing node when looking up entry point --- .../Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 81ebdece..c7c6bfd0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -583,7 +583,7 @@ private static async ValueTask> GetEntry ShareAndKey? shareAndKeyToUse, ShareId? shareId, IDriveSecretCache secretCache, - Func, CancellationToken, Task> getLinkDetails, + Func> getLinkDetails, CancellationToken cancellationToken) { if (shareId is not null && shareId == shareAndKeyToUse?.Share.Id) @@ -621,7 +621,7 @@ private static async ValueTask> GetEntry break; } - var linkDetails = await getLinkDetails([currentId.Value], cancellationToken).ConfigureAwait(false); + var linkDetails = await getLinkDetails(currentId.Value, cancellationToken).ConfigureAwait(false); linkAncestry.Push(linkDetails); @@ -686,10 +686,11 @@ private static async ValueTask> GetEntry return await GetEntryPointKeyAsync(client, volumeId, parentId, shareAndKeyToUse, shareId, client.Cache.Secrets, GetLinkDetailsAsync, cancellationToken) .ConfigureAwait(false); - async Task GetLinkDetailsAsync(IEnumerable links, CancellationToken ct) + async Task GetLinkDetailsAsync(LinkId linkId, CancellationToken ct) { - var response = await client.Api.Links.GetDetailsAsync(volumeId, links, ct).ConfigureAwait(false); - return response.Links[0]; + var response = await client.Api.Links.GetDetailsAsync(volumeId, [linkId], ct).ConfigureAwait(false); + + return response.Links is { Count: > 0 } links ? links[0] : throw new ProtonDriveException($"Node \"{new NodeUid(volumeId, linkId)}\" not found"); } } From 7895d5322f4a56151e897c6524d2a7bff7097572 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 11:47:06 +0100 Subject: [PATCH 604/791] Change API endpoint that updates 'editors can share' value --- js/sdk/src/internal/apiService/driveTypes.ts | 52 ++++++++++++++++++++ js/sdk/src/internal/sharing/apiService.ts | 10 ++-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 3d91e8d3..72f6ce99 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -2644,6 +2644,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/shares/{shareID}/editors-can-share": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update editorsCanShare property of a share + * @description Only allowed to volume owners and members with Admin rights on the Parent + */ + put: operations["put_drive-shares-{shareID}-editors-can-share"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/shares/{shareID}/owner": { parameters: { query?: never; @@ -2676,6 +2696,7 @@ export interface paths { put?: never; /** * Update properties of a share + * @deprecated * @description Update the values on one or more properties of the Share. For now, only allowed changing editorsCanShare attribute */ post: operations["post_drive-shares-{shareID}-property"]; @@ -6390,6 +6411,10 @@ export interface components { */ Code: 1000; }; + UpdateShareEditorsCanShareRequestDto: { + /** @description Indicates if editor members of this share could reshare it or not */ + Value: boolean; + }; TransferInput: { /** @description The ID of the new address */ AddressID: string; @@ -12354,6 +12379,33 @@ export interface operations { }; }; }; + "put_drive-shares-{shareID}-editors-can-share": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateShareEditorsCanShareRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; "post_drive-shares-{shareID}-owner": { parameters: { query?: never; diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 796f4321..4c2380f1 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -79,12 +79,12 @@ type PostCreateShareResponse = drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json']; type PostChangeSharePropertiesRequest = Extract< - drivePaths['/drive/shares/{shareID}/property']['post']['requestBody'], + drivePaths['/drive/shares/{shareID}/editors-can-share']['put']['requestBody'], { content: object } >['content']['application/json']; type PostChangeSharePropertiesResponse = - drivePaths['/drive/shares/{shareID}/property']['post']['responses']['200']['content']['application/json']; + drivePaths['/drive/shares/{shareID}/editors-can-share']['put']['responses']['200']['content']['application/json']; type PostInviteProtonUserRequest = Extract< drivePaths['/drive/v2/shares/{shareID}/invitations']['post']['requestBody'], @@ -423,9 +423,9 @@ export class SharingAPIService { } async changeShareProperties(shareId: string, { editorsCanShare }: { editorsCanShare: boolean }) { - await this.apiService.post( - `drive/shares/${shareId}/property`, - { EditorsCanShare: editorsCanShare }, + await this.apiService.put( + `drive/shares/${shareId}/editors-can-share`, + { Value: editorsCanShare }, ); } From cc30c8c20ab09609bb9997c1134d1ecfec88b647 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Mar 2026 09:22:02 +0100 Subject: [PATCH 605/791] Parse enumerate result synchronously --- .../drive/sdk/internal/EnumerateHandler.kt | 24 +++++++++++++++++++ .../sdk/internal/JniBaseProtonDriveSdk.kt | 12 +++++----- .../drive/sdk/internal/JniHttpStream.kt | 4 ++-- .../internal/ProtonDriveSdkNativeClient.kt | 10 ++++---- 4 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/EnumerateHandler.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/EnumerateHandler.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/EnumerateHandler.kt new file mode 100644 index 00000000..864ea2e6 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/EnumerateHandler.kt @@ -0,0 +1,24 @@ +package me.proton.drive.sdk.internal + +import java.nio.ByteBuffer + +interface EnumerateHandler { + val callback: suspend (T) -> Unit + val parser: (ByteBuffer) -> T + + companion object { + fun notConfigured(name: String) = object: EnumerateHandler { + override val callback: suspend (T) -> Unit + get() = error("EnumerateHandler not configured for $name") + override val parser: (ByteBuffer) -> T + get() = error("EnumerateHandler not configured for $name") + } + fun create( + enumerate: suspend (T) -> Unit, + parser: (ByteBuffer) -> T + ) = object : EnumerateHandler { + override val callback = enumerate + override val parser = parser + } + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt index dc757dd1..a3842eb7 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -11,14 +11,14 @@ import java.nio.ByteBuffer abstract class JniBaseProtonDriveSdk : JniBase() { private var released = false - private var clients = emptyList() + private var clients = emptyList>() fun dispatch( name: String, block: RequestKt.Dsl.() -> Unit, ) { check(released.not()) { "Cannot dispatch ${method(name)} after release" } - val nativeClient = ProtonDriveSdkNativeClient( + val nativeClient = ProtonDriveSdkNativeClient( method(name), IgnoredIntegerOrErrorResponse(), logger = internalLogger, @@ -34,7 +34,7 @@ abstract class JniBaseProtonDriveSdk : JniBase() { check(released.not()) { "Cannot executeOnce ${method(name)} after release" } // Create the callback here to capture the call stack trace val responseCallback = callback(continuation) - val nativeClient = ProtonDriveSdkNativeClient( + val nativeClient = ProtonDriveSdkNativeClient( name = method(name), response = { client, buffer -> responseCallback(buffer) @@ -65,7 +65,7 @@ abstract class JniBaseProtonDriveSdk : JniBase() { client.release() clients -= client }, - enumerate = { data -> enumerate(parser(data)) }, + enumerateHandler = EnumerateHandler.create(enumerate, parser) , logger = internalLogger, coroutineScopeProvider = coroutineScopeProvider, ) @@ -74,8 +74,8 @@ abstract class JniBaseProtonDriveSdk : JniBase() { } suspend fun executePersistent( - clientBuilder: (CancellableContinuation) -> ProtonDriveSdkNativeClient, - requestBuilder: (ProtonDriveSdkNativeClient) -> Request, + clientBuilder: (CancellableContinuation) -> ProtonDriveSdkNativeClient, + requestBuilder: (ProtonDriveSdkNativeClient) -> Request, ): T = suspendCancellableCoroutine { continuation -> val nativeClient = clientBuilder(continuation) check(released.not()) { "Cannot executePersistent ${method(nativeClient.name)} after release" } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt index 82a3564a..8e3708b5 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -10,7 +10,7 @@ import java.nio.channels.ReadableByteChannel class JniHttpStream internal constructor( ) : JniBaseProtonSdk() { - private var client: ProtonDriveSdkNativeClient? = null + private var client: ProtonDriveSdkNativeClient<*>? = null internal var onBodyRead: (suspend () -> Unit)? = null @@ -18,7 +18,7 @@ class JniHttpStream internal constructor( coroutineScope: CoroutineScope, channel: ReadableByteChannel, ): Long { - return ProtonDriveSdkNativeClient( + return ProtonDriveSdkNativeClient( name = method("write"), readHttpBody = { buffer -> channel.read(buffer).also { numberOfByteRead -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 707c7532..af724b03 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -27,10 +27,10 @@ import proton.sdk.ProtonSdk.Response import proton.sdk.response import java.nio.ByteBuffer -class ProtonDriveSdkNativeClient internal constructor( +class ProtonDriveSdkNativeClient internal constructor( val name: String, - val response: ClientResponseCallback = { _, _ -> error("response not configured for $name") }, - val enumerate: suspend (ByteBuffer) -> Unit = { error("enumerate not configured for $name") }, + val response: ClientResponseCallback> = { _, _ -> error("response not configured for $name") }, + val enumerateHandler: EnumerateHandler = EnumerateHandler.notConfigured(name), val read: suspend (ByteBuffer) -> Int = { error("read not configured for $name") }, val write: suspend (ByteBuffer) -> Unit = { error("write not configured for $name") }, val seek: (suspend (Long, Int) -> Long)? = null, @@ -110,8 +110,8 @@ class ProtonDriveSdkNativeClient internal constructor( fun onEnumerate(data: ByteBuffer) = onCallback( callback = "enumerate", data = data, - parser = { it }, - block = enumerate, + parser = enumerateHandler.parser, + block = enumerateHandler.callback, ) @Suppress("unused") // Called by JNI From 9d3a7194a337d9a758530d533a1a9d3bfe8d86ae Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Mar 2026 19:28:20 +0000 Subject: [PATCH 606/791] Throw error if node is not found --- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 1399bb16..ef2229c3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -152,7 +152,7 @@ public static async ValueTask> GetFre return await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( client, uid.VolumeId, - response.Links[0], + response.Links is { Count: > 0 } links ? links[0] : throw new ProtonDriveException($"Node \"{uid}\" not found"), knownShareAndKey, cancellationToken) .ConfigureAwait(false); From 318be69a3443f7dd3ebbecc1a4d9aa3d75b191b6 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 08:03:25 +0000 Subject: [PATCH 607/791] Expose structured data on upload integrity errors --- .../InteropDriveErrorConverter.cs | 124 +++++++++++++++--- .../ChecksumMismatchIntegrityException.cs | 29 ++++ .../ContentSizeMismatchIntegrityException.cs | 29 ++++ .../MissingContentBlockIntegrityException.cs | 26 ++++ .../Nodes/Upload/RevisionWriter.cs | 14 +- ...humbnailCountMismatchIntegrityException.cs | 29 ++++ cs/sdk/src/protos/proton.drive.sdk.proto | 19 +++ .../drive/sdk/ProtonDriveSdkException.kt | 20 ++- .../me/proton/drive/sdk/ProtonSdkError.kt | 90 ++++++++++++- .../extension/ChecksumMismatchErrorData.kt | 9 ++ .../extension/ContentSizeMismatchErrorData.kt | 9 ++ .../me/proton/drive/sdk/extension/Error.kt | 12 ++ .../extension/MissingContentBlockErrorData.kt | 8 ++ .../ThumbnailCountMismatchErrorData.kt | 9 ++ 14 files changed, 392 insertions(+), 35 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ContentSizeMismatchIntegrityException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/MissingContentBlockIntegrityException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ThumbnailCountMismatchIntegrityException.cs create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ChecksumMismatchErrorData.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ContentSizeMismatchErrorData.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MissingContentBlockErrorData.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailCountMismatchErrorData.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs index 369fe4ab..1999ed42 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -1,4 +1,5 @@ -using Google.Protobuf.WellKnownTypes; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Drive.Sdk.Nodes.Upload.Verification; @@ -41,31 +42,38 @@ public static void SetDomainAndCodes(Error error, Exception exception) error.PrimaryCode = ManifestSignatureVerificationErrorPrimaryCode; break; - case IntegrityException: + case MissingContentBlockIntegrityException e: error.Domain = ErrorDomain.DataIntegrity; error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); break; - case NodeWithSameNameExistsException e: - error.Domain = ErrorDomain.BusinessLogic; + case ContentSizeMismatchIntegrityException e: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); + break; - var additionalData = new NodeNameConflictErrorData(); - if (e.ConflictingNodeIsFileDraft is { } conflictingNodeIsFileDraft) - { - additionalData.ConflictingNodeIsFileDraft = conflictingNodeIsFileDraft; - } + case ThumbnailCountMismatchIntegrityException e: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); + break; - if (e.ConflictingNodeUid is { } conflictingNodeUid) - { - additionalData.ConflictingNodeUid = conflictingNodeUid.ToString(); - } + case ChecksumMismatchIntegrityException e: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); + break; - if (e.ConflictingRevisionUid is { } conflictingRevisionUid) - { - additionalData.ConflictingRevisionUid = conflictingRevisionUid.ToString(); - } + case IntegrityException: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + break; - error.AdditionalData = Any.Pack(additionalData); + case NodeWithSameNameExistsException e: + error.Domain = ErrorDomain.BusinessLogic; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); break; default: @@ -74,4 +82,84 @@ public static void SetDomainAndCodes(Error error, Exception exception) break; } } + + private static MissingContentBlockErrorData ToAdditionalData(MissingContentBlockIntegrityException e) + { + var data = new MissingContentBlockErrorData(); + if (e.BlockNumber is { } blockNumber) + { + data.BlockNumber = blockNumber; + } + + return data; + } + + private static ContentSizeMismatchErrorData ToAdditionalData(ContentSizeMismatchIntegrityException e) + { + var data = new ContentSizeMismatchErrorData(); + if (e.UploadedSize is { } uploadedSize) + { + data.UploadedSize = uploadedSize; + } + + if (e.ExpectedSize is { } expectedSize) + { + data.ExpectedSize = expectedSize; + } + + return data; + } + + private static ThumbnailCountMismatchErrorData ToAdditionalData(ThumbnailCountMismatchIntegrityException e) + { + var data = new ThumbnailCountMismatchErrorData(); + if (e.UploadedBlockCount is { } uploadedBlockCount) + { + data.UploadedBlockCount = uploadedBlockCount; + } + + if (e.ExpectedBlockCount is { } expectedBlockCount) + { + data.ExpectedBlockCount = expectedBlockCount; + } + + return data; + } + + private static ChecksumMismatchErrorData ToAdditionalData(ChecksumMismatchIntegrityException e) + { + var data = new ChecksumMismatchErrorData(); + if (e.ActualChecksum is not null) + { + data.ActualChecksum = ByteString.CopyFrom(e.ActualChecksum); + } + + if (e.ExpectedChecksum is not null) + { + data.ExpectedChecksum = ByteString.CopyFrom(e.ExpectedChecksum); + } + + return data; + } + + private static NodeNameConflictErrorData ToAdditionalData(NodeWithSameNameExistsException e) + { + var data = new NodeNameConflictErrorData(); + if (e.ConflictingNodeIsFileDraft is { } conflictingNodeIsFileDraft) + { + data.ConflictingNodeIsFileDraft = conflictingNodeIsFileDraft; + } + + if (e.ConflictingNodeUid is { } conflictingNodeUid) + { + data.ConflictingNodeUid = conflictingNodeUid.ToString(); + } + + if (e.ConflictingRevisionUid is { } conflictingRevisionUid) + { + data.ConflictingRevisionUid = conflictingRevisionUid.ToString(); + } + + return data; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs new file mode 100644 index 00000000..03525f4a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs @@ -0,0 +1,29 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +public class ChecksumMismatchIntegrityException : IntegrityException +{ + public ChecksumMismatchIntegrityException() + { + } + + public ChecksumMismatchIntegrityException(string message) + : base(message) + { + } + + public ChecksumMismatchIntegrityException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ChecksumMismatchIntegrityException(byte[]? actualChecksum, byte[]? expectedChecksum) + : base("Mismatch between uploaded checksum and expected checksum") + { + ActualChecksum = actualChecksum; + ExpectedChecksum = expectedChecksum; + } + + public byte[]? ActualChecksum { get; } + + public byte[]? ExpectedChecksum { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ContentSizeMismatchIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ContentSizeMismatchIntegrityException.cs new file mode 100644 index 00000000..823aedde --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ContentSizeMismatchIntegrityException.cs @@ -0,0 +1,29 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +public class ContentSizeMismatchIntegrityException : IntegrityException +{ + public ContentSizeMismatchIntegrityException() + { + } + + public ContentSizeMismatchIntegrityException(string message) + : base(message) + { + } + + public ContentSizeMismatchIntegrityException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ContentSizeMismatchIntegrityException(long uploadedSize, long expectedSize) + : base("Mismatch between uploaded size and expected size") + { + UploadedSize = uploadedSize; + ExpectedSize = expectedSize; + } + + public long? UploadedSize { get; } + + public long? ExpectedSize { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/MissingContentBlockIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/MissingContentBlockIntegrityException.cs new file mode 100644 index 00000000..4b4767e6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/MissingContentBlockIntegrityException.cs @@ -0,0 +1,26 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +public class MissingContentBlockIntegrityException : IntegrityException +{ + public MissingContentBlockIntegrityException() + { + } + + public MissingContentBlockIntegrityException(string message) + : base(message) + { + } + + public MissingContentBlockIntegrityException(string message, Exception innerException) + : base(message, innerException) + { + } + + public MissingContentBlockIntegrityException(int blockNumber) + : base($"Missing content block #{blockNumber}") + { + BlockNumber = blockNumber; + } + + public int? BlockNumber { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 72ae37b0..68cdb642 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -183,7 +183,7 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( return blockState.TryGetSecond(out var uploadResult) ? (Number: blockNumber, Value: uploadResult) - : throw new IntegrityException($"Missing content block #{blockNumber}"); + : throw new MissingContentBlockIntegrityException(blockNumber); }); var blockUploadResults = _draft.OrderedThumbnailUploadResults.Select(x => (Number: 0, Value: x)).Concat(contentBlockUploadResults); @@ -206,12 +206,16 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( if (uploadedContentSize != expectedContentLength) { - throw new IntegrityException("Mismatch between uploaded size and expected size"); + throw new ContentSizeMismatchIntegrityException( + uploadedSize: uploadedContentSize, + expectedSize: expectedContentLength); } if (expectedThumbnailBlockCount != _draft.OrderedThumbnailUploadResults.Count) { - throw new IntegrityException("Unexpected number of thumbnail blocks"); + throw new ThumbnailCountMismatchIntegrityException( + uploadedBlockCount: _draft.OrderedThumbnailUploadResults.Count, + expectedBlockCount: expectedThumbnailBlockCount); } if (expectedSha1Provider is not null) @@ -219,7 +223,9 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( var expectedSha1 = expectedSha1Provider(); if (!expectedSha1.Span.SequenceEqual(sha1Digest)) { - throw new IntegrityException("Mismatch between uploaded SHA1 and expected SHA1"); + throw new ChecksumMismatchIntegrityException( + actualChecksum: sha1Digest, + expectedChecksum: expectedSha1.ToArray()); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ThumbnailCountMismatchIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ThumbnailCountMismatchIntegrityException.cs new file mode 100644 index 00000000..3505476b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ThumbnailCountMismatchIntegrityException.cs @@ -0,0 +1,29 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +public class ThumbnailCountMismatchIntegrityException : IntegrityException +{ + public ThumbnailCountMismatchIntegrityException() + { + } + + public ThumbnailCountMismatchIntegrityException(string message) + : base(message) + { + } + + public ThumbnailCountMismatchIntegrityException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ThumbnailCountMismatchIntegrityException(int uploadedBlockCount, int expectedBlockCount) + : base("Some file parts failed to upload") + { + UploadedBlockCount = uploadedBlockCount; + ExpectedBlockCount = expectedBlockCount; + } + + public int? UploadedBlockCount { get; } + + public int? ExpectedBlockCount { get; } +} diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index c9b47657..12574738 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -863,3 +863,22 @@ message NodeNameConflictErrorData { string conflicting_node_uid = 2; string conflicting_revision_uid = 3; } + +message MissingContentBlockErrorData { + int32 block_number = 1; +} + +message ContentSizeMismatchErrorData { + int64 uploaded_size = 1; + int64 expected_size = 2; +} + +message ThumbnailCountMismatchErrorData { + int32 uploaded_block_count = 1; + int32 expected_block_count = 2; +} + +message ChecksumMismatchErrorData { + bytes actual_checksum = 1; + bytes expected_checksum = 2; +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt index 7f0dc4ff..041b2a8b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt @@ -7,28 +7,36 @@ class ProtonDriveSdkException( ) : Throwable(message, cause) { override fun toString(): String = buildString { appendLine(super.toString()) - appendError(error) + appendError(error, logMode = LogMode.Full) } } -fun ProtonDriveSdkException.errorToString(): String = buildString { +enum class LogMode { + Safe, Full +} + +fun ProtonDriveSdkException.errorToString(logMode: LogMode = LogMode.Safe): String = buildString { error?.let { error -> appendLine("SDK error: ${error.message}") - appendError(error) + appendError(error, logMode) } } -private fun StringBuilder.appendError(error: ProtonSdkError?) { +private fun StringBuilder.appendError(error: ProtonSdkError?, logMode: LogMode) { error?.run { appendLine("type: $type") appendLine("domain: $domain") appendLine("primaryCode: $primaryCode") appendLine("secondaryCode: $secondaryCode") - appendLine("additionalData: $additionalData") + val data = when (logMode) { + LogMode.Safe -> additionalData?.toSafe() + LogMode.Full -> additionalData + } + appendLine("additionalData: ${data}") appendLine(context) if (innerError != null) { appendLine("Caused by: ${innerError.message}") - appendError(innerError) + appendError(innerError, logMode) } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt index e4e03bc0..b83a96e6 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt @@ -8,7 +8,7 @@ data class ProtonSdkError( val secondaryCode: Long? = null, val context: String? = null, val innerError: ProtonSdkError? = null, - val additionalData: Data? = null, + val additionalData: Data? = null, ) { enum class ErrorDomain { @@ -24,11 +24,87 @@ data class ProtonSdkError( UNRECOGNIZED, } - sealed interface Data { - data class NodeNameConflict ( - val conflictingNodeIsFileDraft: Boolean, - val conflictingNodeUid: String, - val conflictingRevisionUid: String, - ): Data + sealed interface Data { + fun toSafe(): S + + data class NodeNameConflict( + val conflictingNodeIsFileDraft: Boolean, + val conflictingNodeUid: String, + val conflictingRevisionUid: String, + ) : Data { + override fun toSafe() = this + } + + data class MissingContentBlock( + val blockNumber: Int?, + ) : Data { + override fun toSafe() = this + } + + data class ContentSizeMismatch( + val uploadedSize: Long?, + val expectedSize: Long?, + ) : Data { + data class Safe(val delta: Long?) + + override fun toSafe() = Safe( + delta = if (uploadedSize != null && expectedSize != null) { + expectedSize - uploadedSize + } else { + null + } + ) + } + + data class ThumbnailCountMismatch( + val uploadedBlockCount: Int?, + val expectedBlockCount: Int?, + ) : Data { + override fun toSafe() = this + } + + class ChecksumMismatch( + val actualChecksum: ByteArray?, + val expectedChecksum: ByteArray?, + ) : Data { + data class Safe(val actualChecksumPrefix: String?, val expectedChecksumPrefix: String?) + + override fun toSafe() = Safe( + actualChecksumPrefix = actualChecksum?.toHexPrefix(), + expectedChecksumPrefix = expectedChecksum?.toHexPrefix(), + ) + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other !is ChecksumMismatch) { + return false + } + return actualChecksum.contentEquals(other.actualChecksum) && + expectedChecksum.contentEquals(other.expectedChecksum) + } + + override fun hashCode(): Int { + var result = actualChecksum.contentHashCode() + result = 31 * result + expectedChecksum.contentHashCode() + return result + } + + override fun toString(): String = + "ChecksumMismatch(" + + "actualChecksum=${actualChecksum?.toHex()}, " + + "expectedChecksum=${expectedChecksum?.toHex()})" + + private companion object { + private const val PREFIX_BYTES = 2 + + private fun ByteArray.toHexPrefix() = + take(PREFIX_BYTES).joinToString("") { "%02x".format(it) } + "..." + + private fun ByteArray.toHex() = + joinToString("") { "%02x".format(it) } + } + } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ChecksumMismatchErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ChecksumMismatchErrorData.kt new file mode 100644 index 00000000..9ad77ac0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ChecksumMismatchErrorData.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonSdkError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.ChecksumMismatchErrorData.toEntity() = ProtonSdkError.Data.ChecksumMismatch( + actualChecksum = takeIf { hasActualChecksum() }?.actualChecksum?.toByteArray(), + expectedChecksum = takeIf { hasExpectedChecksum() }?.expectedChecksum?.toByteArray(), +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ContentSizeMismatchErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ContentSizeMismatchErrorData.kt new file mode 100644 index 00000000..43e2b393 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ContentSizeMismatchErrorData.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonSdkError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.ContentSizeMismatchErrorData.toEntity() = ProtonSdkError.Data.ContentSizeMismatch( + uploadedSize = takeIf { hasUploadedSize() }?.uploadedSize, + expectedSize = takeIf { hasExpectedSize() }?.expectedSize, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt index 897ba8e1..dbfb9e1a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt @@ -39,5 +39,17 @@ private fun Any.toData() = when (typeUrl) { "type.googleapis.com/proton.drive.sdk.NodeNameConflictErrorData" -> ProtonDriveSdk.NodeNameConflictErrorData.parseFrom(value).toEntity() + "type.googleapis.com/proton.drive.sdk.MissingContentBlockErrorData" -> + ProtonDriveSdk.MissingContentBlockErrorData.parseFrom(value).toEntity() + + "type.googleapis.com/proton.drive.sdk.ContentSizeMismatchErrorData" -> + ProtonDriveSdk.ContentSizeMismatchErrorData.parseFrom(value).toEntity() + + "type.googleapis.com/proton.drive.sdk.ThumbnailCountMismatchErrorData" -> + ProtonDriveSdk.ThumbnailCountMismatchErrorData.parseFrom(value).toEntity() + + "type.googleapis.com/proton.drive.sdk.ChecksumMismatchErrorData" -> + ProtonDriveSdk.ChecksumMismatchErrorData.parseFrom(value).toEntity() + else -> null } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MissingContentBlockErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MissingContentBlockErrorData.kt new file mode 100644 index 00000000..3a9b1582 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MissingContentBlockErrorData.kt @@ -0,0 +1,8 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonSdkError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.MissingContentBlockErrorData.toEntity() = ProtonSdkError.Data.MissingContentBlock( + blockNumber = takeIf { hasBlockNumber() }?.blockNumber, +) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailCountMismatchErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailCountMismatchErrorData.kt new file mode 100644 index 00000000..0b70426f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailCountMismatchErrorData.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonSdkError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.ThumbnailCountMismatchErrorData.toEntity() = ProtonSdkError.Data.ThumbnailCountMismatch( + uploadedBlockCount = takeIf { hasUploadedBlockCount() }?.uploadedBlockCount, + expectedBlockCount = takeIf { hasExpectedBlockCount() }?.expectedBlockCount, +) From e17e8352591cbb732103902ccf94fcc9fd9c86e4 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 09:33:30 +0100 Subject: [PATCH 608/791] Fix telemetry causing deadlock on uploads and downloads --- cs/.globalconfig | 3 + .../Proton.Drive.Sdk/FifoFlexibleSemaphore.cs | 12 ++-- .../Nodes/Download/DownloadController.cs | 58 +++++++++++-------- .../Nodes/Download/FileDownloader.cs | 25 ++++---- .../Nodes/Download/PhotosFileDownloader.cs | 12 ++-- .../Nodes/Upload/FileUploader.cs | 29 +++++----- .../Nodes/Upload/UploadController.cs | 57 ++++++++++-------- .../Caching/InMemoryCacheRepository.cs | 4 +- .../ReferenceResultTaskExtensions.cs | 44 ++++++++++++++ .../Threading/ValueResultTaskExtensions.cs | 44 ++++++++++++++ 10 files changed, 202 insertions(+), 86 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Threading/ReferenceResultTaskExtensions.cs create mode 100644 cs/sdk/src/Proton.Sdk/Threading/ValueResultTaskExtensions.cs diff --git a/cs/.globalconfig b/cs/.globalconfig index 548ed2d4..cb95dd6e 100644 --- a/cs/.globalconfig +++ b/cs/.globalconfig @@ -36,6 +36,9 @@ dotnet_diagnostic.CS1591.severity = none # CA1711: Identifiers should not have incorrect suffix dotnet_diagnostic.CA1711.severity = warning +# CA1849: Call async methods when in an async method +dotnet_diagnostic.CA1849.severity = warning + # CA2000: Dispose objects before losing scope dotnet_diagnostic.CA2000.severity = suggestion diff --git a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs index 0057321c..a1fd6add 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs @@ -18,7 +18,7 @@ public FifoFlexibleSemaphore(int maximumCount) public int MaximumCount { get; } public int CurrentCount { get; private set; } - public ValueTask EnterAsync(int count, CancellationToken cancellationToken = default) + public async ValueTask EnterAsync(int count, CancellationToken cancellationToken = default) { ArgumentOutOfRangeException.ThrowIfNegative(count); @@ -28,7 +28,7 @@ public ValueTask EnterAsync(int count, CancellationToken cancellationToken = def if (CurrentCount > 0) { CurrentCount -= count; - return ValueTask.CompletedTask; + return; } tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -39,11 +39,13 @@ public ValueTask EnterAsync(int count, CancellationToken cancellationToken = def if (cancellationToken.IsCancellationRequested) { - cancellationTokenRegistration.Dispose(); - return ValueTask.FromCanceled(cancellationToken); + await cancellationTokenRegistration.DisposeAsync().ConfigureAwait(false); + return; } - return WaitAsync(); + await WaitAsync().ConfigureAwait(false); + + return; async ValueTask WaitAsync() { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 843a148c..04f2477b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -1,4 +1,5 @@ using Proton.Sdk; +using Proton.Sdk.Threading; namespace Proton.Drive.Sdk.Nodes.Download; @@ -8,8 +9,8 @@ public sealed class DownloadController : IAsyncDisposable private readonly Func _resumeFunction; private readonly ITaskControl _taskControl; private readonly Stream? _outputStreamToDispose; - private readonly Action? _onFailed; - private readonly Action? _onSucceeded; + private readonly Func? _onFailedAsync; + private readonly Func? _onSucceededAsync; private bool _isDownloadCompleteWithVerificationIssue; @@ -19,15 +20,15 @@ internal DownloadController( Func resumeFunction, Stream? outputStreamToDispose, ITaskControl taskControl, - Action? onFailed = null, - Action? onSucceeded = null) + Func? onFailedAsync = null, + Func? onSucceededAsync = null) { _downloadStateTask = downloadStateTask; _resumeFunction = resumeFunction; _taskControl = taskControl; _outputStreamToDispose = outputStreamToDispose; - _onFailed = onFailed; - _onSucceeded = onSucceeded; + _onFailedAsync = onFailedAsync; + _onSucceededAsync = onSucceededAsync; Completion = PauseOnResumableErrorAsync(downloadTask, taskControl.Attempt); } @@ -63,27 +64,35 @@ public async ValueTask DisposeAsync() { try { - if (Completion.IsCompletedSuccessfully) + Exception? exception = null; + try { - return; + await Completion.ConfigureAwait(false); } - - if (Completion.IsFaulted) + catch (Exception ex) { - var downloadState = await _downloadStateTask.ConfigureAwait(false); - _onFailed?.Invoke( - Completion.Exception.Flatten().InnerException ?? Completion.Exception, - downloadState.RevisionDto.Size, - downloadState.GetNumberOfBytesWritten()); + exception = ex; } - var stateExists = _downloadStateTask.IsCompletedSuccessfully; - if (!stateExists) + var downloadState = _downloadStateTask.GetResultIfCompletedSuccessfully(); + + try { - return; + if (exception is not null && _onFailedAsync is not null) + { + await _onFailedAsync.Invoke( + exception, + downloadState?.RevisionDto.Size ?? 0, + downloadState?.GetNumberOfBytesWritten() ?? 0).ConfigureAwait(false); + } + } + catch + { + if (downloadState is not null) + { + await downloadState.DisposeAsync().ConfigureAwait(false); + } } - - await _downloadStateTask.Result.DisposeAsync().ConfigureAwait(false); } finally { @@ -131,7 +140,7 @@ private async Task PauseOnResumableErrorAsync(Task downloadTask, int attempt) } catch (Exception ex) when (IsResumableError(ex)) { - if (_taskControl.Attempt == attempt) + if (_taskControl.Attempt == attempt && !_taskControl.IsPaused) { _taskControl.Pause(); } @@ -151,7 +160,7 @@ private async Task PauseOnResumableErrorAsync(Task downloadTask, int attempt) private async ValueTask FinalizeDownloadAsync() { - var onSucceededHandler = _onSucceeded; + var onSucceededHandler = _onSucceededAsync; if (onSucceededHandler is null) { return; @@ -163,8 +172,9 @@ private async ValueTask FinalizeDownloadAsync() } var downloadState = await _downloadStateTask.ConfigureAwait(false); - onSucceededHandler.Invoke( + + await onSucceededHandler.Invoke( downloadState.RevisionDto.Size, - downloadState.GetNumberOfBytesWritten()); + downloadState.GetNumberOfBytesWritten()).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 1800d8df..e88ebd03 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Proton.Drive.Sdk.Telemetry; +using Proton.Sdk.Threading; namespace Proton.Drive.Sdk.Nodes.Download; @@ -66,19 +67,18 @@ private async Task DownloadToStreamAsync( TaskCompletionSource downloadStateTaskCompletionSource, CancellationToken cancellationToken) { - if (!downloadStateTaskCompletionSource.Task.IsCompletedSuccessfully) + var downloadState = downloadStateTaskCompletionSource.Task.GetResultIfCompletedSuccessfully(); + if (downloadState is null) { - var state = await RevisionOperations.CreateDownloadStateAsync( + downloadState = await RevisionOperations.CreateDownloadStateAsync( _client, _revisionUid, ReleaseBlockListing, cancellationToken).ConfigureAwait(false); - downloadStateTaskCompletionSource.SetResult(state); + downloadStateTaskCompletionSource.SetResult(downloadState); } - var downloadState = downloadStateTaskCompletionSource.Task.Result; - if (downloadState.GetNumberOfBytesWritten() > 0) { if (!contentOutputStream.CanSeek) @@ -106,9 +106,6 @@ private DownloadController BuildDownloadController( var downloadStateTaskCompletionSource = new TaskCompletionSource(); - var downloadEvent = TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken) - .ConfigureAwait(false).GetAwaiter().GetResult(); - var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( contentOutputStream, onProgress, @@ -121,11 +118,13 @@ private DownloadController BuildDownloadController( downloadFunction, ownsOutputStream ? contentOutputStream : null, taskControl, - OnFailed, - OnSucceeded); + OnFailedAsync, + OnSucceededAsync); - void OnFailed(Exception ex, long claimedFileSize, long downloadedByteCount) + async ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloadedByteCount) { + var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.ClaimedFileSize = claimedFileSize; downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); @@ -136,8 +135,10 @@ void OnFailed(Exception ex, long claimedFileSize, long downloadedByteCount) RaiseTelemetryEvent(downloadEvent); } - void OnSucceeded(long claimedFileSize, long downloadedByteCount) + async ValueTask OnSucceededAsync(long claimedFileSize, long downloadedByteCount) { + var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.ClaimedFileSize = claimedFileSize; downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index 2b77526e..b5a3ae20 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -81,7 +81,7 @@ private async Task DownloadToStreamAsync( downloadStateTaskCompletionSource.SetResult(state); } - var downloadState = downloadStateTaskCompletionSource.Task.Result; + var downloadState = await downloadStateTaskCompletionSource.Task.ConfigureAwait(false); await _client.DriveClient.BlockDownloader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); @@ -118,10 +118,10 @@ private DownloadController DownloadToStream( downloadFunction, ownsOutputStream ? contentOutputStream : null, taskControl, - OnFailed, - OnSucceeded); + OnFailedAsync, + OnSucceededAsync); - void OnFailed(Exception ex, long claimedFileSize, long downloadedByteCount) + ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloadedByteCount) { // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.ClaimedFileSize = claimedFileSize; @@ -131,9 +131,10 @@ void OnFailed(Exception ex, long claimedFileSize, long downloadedByteCount) downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); downloadEvent.OriginalError = ex; RaiseTelemetryEvent(downloadEvent); + return ValueTask.CompletedTask; } - void OnSucceeded(long claimedFileSize, long downloadedByteCount) + ValueTask OnSucceededAsync(long claimedFileSize, long downloadedByteCount) { // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.ClaimedFileSize = claimedFileSize; @@ -141,6 +142,7 @@ void OnSucceeded(long claimedFileSize, long downloadedByteCount) downloadEvent.DownloadedSize = downloadedByteCount; downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); RaiseTelemetryEvent(downloadEvent); + return ValueTask.CompletedTask; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 279e2166..5e115d54 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Proton.Drive.Sdk.Telemetry; using Proton.Sdk; +using Proton.Sdk.Threading; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -122,9 +123,6 @@ private UploadController UploadFromStream( var revisionDraftTaskCompletionSource = new TaskCompletionSource(); - var uploadEvent = TelemetryEventFactory.CreateUploadEventAsync(_client, _telemetryContextNodeUid, contentStream.Length, cancellationToken) - .ConfigureAwait(false).GetAwaiter().GetResult(); - var uploadFunction = (CancellationToken ct) => UploadFromStreamAsync( contentStream, thumbnails, @@ -139,16 +137,19 @@ private UploadController UploadFromStream( uploadFunction, ownsContentStream ? contentStream : null, taskControl, - OnFailed, - OnSucceeded); + OnFailedAsync, + OnSucceededAsync); - void OnFailed(Exception ex, long uploadedByteCount) + async ValueTask OnFailedAsync(Exception ex, long uploadedByteCount) { if (ex is NodeWithSameNameExistsException) { return; } + var uploadEvent = await TelemetryEventFactory.CreateUploadEventAsync(_client, _telemetryContextNodeUid, contentStream.Length, cancellationToken) + .ConfigureAwait(false); + uploadEvent.UploadedSize = uploadedByteCount; uploadEvent.ApproximateUploadedSize = Privacy.ReduceSizePrecision(uploadedByteCount); uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); @@ -156,8 +157,11 @@ void OnFailed(Exception ex, long uploadedByteCount) RaiseTelemetryEvent(uploadEvent); } - void OnSucceeded(long uploadedByteCount) + async ValueTask OnSucceededAsync(long uploadedByteCount) { + var uploadEvent = await TelemetryEventFactory.CreateUploadEventAsync(_client, _telemetryContextNodeUid, contentStream.Length, cancellationToken) + .ConfigureAwait(false); + uploadEvent.UploadedSize = uploadedByteCount; uploadEvent.ApproximateUploadedSize = Privacy.ReduceSizePrecision(uploadedByteCount); RaiseTelemetryEvent(uploadEvent); @@ -172,16 +176,15 @@ private async Task UploadFromStreamAsync( TaskCompletionSource revisionDraftTaskCompletionSource, CancellationToken cancellationToken) { - if (!revisionDraftTaskCompletionSource.Task.IsCompletedSuccessfully) + var revisionDraft = revisionDraftTaskCompletionSource.Task.GetResultIfCompletedSuccessfully(); + if (revisionDraft is null) { - revisionDraftTaskCompletionSource.SetResult( - await _revisionDraftProvider.GetDraftAsync(cancellationToken).ConfigureAwait(false)); + revisionDraft = await _revisionDraftProvider.GetDraftAsync(cancellationToken).ConfigureAwait(false); + revisionDraftTaskCompletionSource.SetResult(revisionDraft); } - var revisionDraft = revisionDraftTaskCompletionSource.Task.Result; - await UploadAsync( - revisionDraftTaskCompletionSource.Task.Result, + revisionDraft, contentStream, thumbnails, onProgress, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 133b545a..71053adc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -1,4 +1,5 @@ using Proton.Sdk; +using Proton.Sdk.Threading; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -8,8 +9,8 @@ public sealed class UploadController : IAsyncDisposable private readonly Func> _resumeFunction; private readonly ITaskControl _taskControl; private readonly Stream? _sourceStreamToDispose; - private readonly Action? _onFailed; - private readonly Action? _onSucceeded; + private readonly Func? _onFailedAsync; + private readonly Func? _onSucceededAsync; private bool _isDisposed; @@ -19,15 +20,15 @@ internal UploadController( Func> resumeFunction, Stream? sourceStreamToDispose, ITaskControl taskControl, - Action? onFailed = null, - Action? onSucceeded = null) + Func? onFailedAsync = null, + Func? onSucceededAsync = null) { _revisionDraftTask = revisionDraftTask; _resumeFunction = resumeFunction; _taskControl = taskControl; _sourceStreamToDispose = sourceStreamToDispose; - _onFailed = onFailed; - _onSucceeded = onSucceeded; + _onFailedAsync = onFailedAsync; + _onSucceededAsync = onSucceededAsync; Completion = PauseOnResumableErrorAsync(uploadTask, taskControl.Attempt); } @@ -65,32 +66,37 @@ public async ValueTask DisposeAsync() { try { - if (Completion.IsCompletedSuccessfully) + Exception? exception = null; + try { - return; + await Completion.ConfigureAwait(false); } + catch (Exception ex) + { + exception = ex; + } + + var draft = _revisionDraftTask.GetResultIfCompletedSuccessfully(); - var draftExists = _revisionDraftTask.IsCompletedSuccessfully; - if (Completion.IsFaulted) + try { - long numberOfPlainBytesDone = 0; - if (draftExists) + if (exception is not null) { - var revisionDraft = await _revisionDraftTask.ConfigureAwait(false); - numberOfPlainBytesDone = revisionDraft.NumberOfPlainBytesDone; - } + var numberOfPlainBytesDone = draft?.NumberOfPlainBytesDone ?? 0; - _onFailed?.Invoke( - Completion.Exception.Flatten().InnerException ?? Completion.Exception, - numberOfPlainBytesDone); + if (_onFailedAsync is not null) + { + await _onFailedAsync.Invoke(exception, numberOfPlainBytesDone).ConfigureAwait(false); + } + } } - - if (!draftExists) + catch { - return; + if (draft is not null) + { + await draft.DisposeAsync().ConfigureAwait(false); + } } - - await _revisionDraftTask.Result.DisposeAsync().ConfigureAwait(false); } finally { @@ -155,13 +161,14 @@ private async Task PauseOnResumableErrorAsync(Task u private async ValueTask InvokeOnSucceededAsync() { - var onSucceededHandler = _onSucceeded; + var onSucceededHandler = _onSucceededAsync; if (onSucceededHandler is null) { return; } var revisionDraft = await _revisionDraftTask.ConfigureAwait(false); - onSucceededHandler.Invoke(revisionDraft.NumberOfPlainBytesDone); + + await onSucceededHandler.Invoke(revisionDraft.NumberOfPlainBytesDone).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs index 8ca3c65b..f047f5ed 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs @@ -17,7 +17,7 @@ public sealed class InMemoryCacheRepository : ICacheRepository, IDisposable ValueTask ICacheRepository.SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) { - Set(key, value, tags, cancellationToken); + Set(key, value, tags); return ValueTask.CompletedTask; } @@ -54,7 +54,7 @@ ValueTask IAsyncDisposable.DisposeAsync() return ValueTask.CompletedTask; } - public void Set(string key, string value, IEnumerable tags, CancellationToken cancellationToken) + public void Set(string key, string value, IEnumerable tags) { _lock.EnterWriteLock(); try diff --git a/cs/sdk/src/Proton.Sdk/Threading/ReferenceResultTaskExtensions.cs b/cs/sdk/src/Proton.Sdk/Threading/ReferenceResultTaskExtensions.cs new file mode 100644 index 00000000..0772b034 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Threading/ReferenceResultTaskExtensions.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk.Threading; + +internal static class ReferenceResultTaskExtensions +{ + public static bool TryGetResult(this Task task, [MaybeNullWhen(false)] out T result) + where T : class + { + if (!task.IsCompletedSuccessfully) + { + result = null; + return false; + } + + result = task.Result; + return true; + } + + public static bool TryGetResult(this ValueTask task, [MaybeNullWhen(false)] out T result) + where T : class + { + if (!task.IsCompletedSuccessfully) + { + result = null; + return false; + } + + result = task.Result; + return true; + } + + public static T? GetResultIfCompletedSuccessfully(this Task task) + where T : class + { + return task.TryGetResult(out var result) ? result : null; + } + + public static T? GetResultIfCompletedSuccessfully(this ValueTask task) + where T : class + { + return task.TryGetResult(out var result) ? result : null; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Threading/ValueResultTaskExtensions.cs b/cs/sdk/src/Proton.Sdk/Threading/ValueResultTaskExtensions.cs new file mode 100644 index 00000000..82ceb1cc --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Threading/ValueResultTaskExtensions.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk.Threading; + +internal static class ValueResultTaskExtensions +{ + public static bool TryGetResult(this Task task, [NotNullWhen(true)] out T? result) + where T : struct + { + if (!task.IsCompletedSuccessfully) + { + result = null; + return false; + } + + result = task.Result; + return true; + } + + public static bool TryGetResult(this ValueTask task, [NotNullWhen(true)] out T? result) + where T : struct + { + if (!task.IsCompletedSuccessfully) + { + result = null; + return false; + } + + result = task.Result; + return true; + } + + public static T? GetResultIfCompletedSuccessfully(this Task task) + where T : struct + { + return task.TryGetResult(out var result) ? result : null; + } + + public static T? GetResultIfCompletedSuccessfully(this ValueTask task) + where T : struct + { + return task.TryGetResult(out var result) ? result : null; + } +} From 283377b75a4038f6601acb786e7894140b261856 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 08:49:06 +0100 Subject: [PATCH 609/791] Improve error details for drive errors --- .../me/proton/drive/sdk/ProtonDriveException.kt | 7 ++++++- .../proton/drive/sdk/extension/FileThumbnail.kt | 4 +++- .../me/proton/drive/sdk/extension/NodeResult.kt | 17 +++++++++++------ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt index 955107be..6bca7c14 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt @@ -5,7 +5,12 @@ import me.proton.drive.sdk.entity.Author open class ProtonDriveException( override val message: String? = null, override val cause: Throwable? = null, -) : Throwable(message, cause) +) : Throwable( + /* message = */ message, + /* cause = */ cause, + /* enableSuppression = */ true, + /* writableStackTrace = */ false, +) class SignatureVerificationException( val claimedAuthor: Author, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt index 4d3a3f1d..115e96bb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt @@ -8,7 +8,9 @@ fun ProtonDriveSdk.FileThumbnail.toEntity(): FileThumbnail = FileThumbnail( uid = fileUid, result = when (resultCase) { ProtonDriveSdk.FileThumbnail.ResultCase.DATA -> Result.success(data.toByteArray()) - ProtonDriveSdk.FileThumbnail.ResultCase.ERROR -> Result.failure(error.toEntity().toException()) + ProtonDriveSdk.FileThumbnail.ResultCase.ERROR -> Result.failure( + error.toEntity().toException("File thumbnail failure") + ) else -> Result.failure( ProtonDriveSdkException( "Undefined result type for ${ProtonDriveSdk.FileThumbnail::class.simpleName}" diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt index 1cf9b8ef..38ca6c91 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.ProtonDriveException +import me.proton.drive.sdk.ProtonDriveSdkException import me.proton.drive.sdk.entity.DriveError import me.proton.drive.sdk.entity.NodeResult @@ -14,7 +15,7 @@ fun NodeResult.getOrNull(): NodeResult.Value? = when (this) { is NodeResult.Error -> null } -private fun List.toException(message: String) = ProtonDriveException(message).apply { +private fun List.toException(message: String) = ProtonDriveSdkException(message).apply { this@toException.forEach { driveError -> addSuppressed( exception = ProtonDriveException( @@ -30,8 +31,12 @@ private fun List.toException(message: String) = ProtonDriveException } } -fun DriveError.toException(): ProtonDriveException = - ProtonDriveException( - message = message, - cause = innerError?.toException() - ) +fun DriveError.toException(message: String): ProtonDriveSdkException = ProtonDriveSdkException( + message = message, + cause = toException() +) + +fun DriveError.toException(): ProtonDriveException = ProtonDriveException( + message = message, + cause = innerError?.toException() +) From b88d031e33b40140050afd505237c7afc9b01d87 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 11:59:35 +0100 Subject: [PATCH 610/791] Handle missing timestamps in photo upload metadata --- .../Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index fd198922..a2c8da64 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -204,8 +204,8 @@ public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosCl var metadata = new Nodes.PhotosFileUploadMetadata { AdditionalMetadata = additionalMetadata, - LastModificationTime = request.Metadata.LastModificationTime.ToDateTimeFixed(), - CaptureTime = request.Metadata.CaptureTime.ToDateTimeFixed(), + LastModificationTime = request.Metadata.LastModificationTime?.ToDateTimeFixed(), + CaptureTime = request.Metadata.CaptureTime?.ToDateTimeFixed(), MainPhotoUid = request.Metadata.HasMainPhotoUid ? NodeUid.Parse(request.Metadata.MainPhotoUid) : null, Tags = tags, }; From 9140afb4062d23df0dc0adf7a9b33fb2a2867054 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 13:10:26 +0100 Subject: [PATCH 611/791] Make LatestEventIdProvider.getLatestEventId async to support IndexedDB --- js/sdk/src/interface/events.ts | 2 +- js/sdk/src/internal/events/index.ts | 4 ++-- js/sdk/src/internal/events/interface.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index 2800f67c..442d5434 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -6,7 +6,7 @@ export enum SDKEvent { } export interface LatestEventIdProvider { - getLatestEventId(treeEventScopeId: string): string | null; + getLatestEventId(treeEventScopeId: string): Promise; } /** diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index d3e9eac1..558c2fc8 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -62,7 +62,7 @@ export class DriveEventsService { } const coreEventManager = new CoreEventManager(this.logger, this.apiService); - const latestEventId = this.latestEventIdProvider.getLatestEventId('core') ?? null; + const latestEventId = await this.latestEventIdProvider.getLatestEventId('core'); const eventManager = new EventManager(coreEventManager, CORE_POLLING_INTERVAL, latestEventId); for (const listener of this.cacheEventListeners) { @@ -105,7 +105,7 @@ export class DriveEventsService { const isOwnVolume = await this.sharesService.isOwnVolume(volumeId); const pollingInterval = this.getDefaultVolumePollingInterval(isOwnVolume); - const latestEventId = this.latestEventIdProvider.getLatestEventId(volumeId); + const latestEventId = await this.latestEventIdProvider.getLatestEventId(volumeId); const eventManager = new EventManager(volumeEventManager, pollingInterval, latestEventId); for (const listener of this.cacheEventListeners) { diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index 9b8b3750..f3a0f919 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -18,7 +18,7 @@ export interface EventSubscription { } export interface LatestEventIdProvider { - getLatestEventId(treeEventScopeId: string): string | null; + getLatestEventId(treeEventScopeId: string): Promise; } /** From 833a83132b90b0e07d082dec18672d89efae80b0 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 13:27:24 +0000 Subject: [PATCH 612/791] Try all album inclusions to find the entry point key --- .../Nodes/DtoToMetadataConverter.cs | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index c7c6bfd0..722dcf7b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; @@ -24,19 +25,45 @@ public static async Task> ConvertDtoT { var sourceNodeEntryPointId = linkDetailsDto.Link.ParentId; - if (sourceNodeEntryPointId is null && linkDetailsDto.Photo is not null) + Result entryPointKeyResult; + + if (sourceNodeEntryPointId is null && linkDetailsDto.Photo is { AlbumInclusions: { Count: > 0 } albumInclusions }) { + entryPointKeyResult = new ProtonDriveError("No album entry point key found"); + // TODO: optimize by selecting the album that is in cache, if any - sourceNodeEntryPointId = linkDetailsDto.Photo.AlbumInclusions is { Count: > 0 } albumInclusions ? albumInclusions[0].Id : null; - } + // TODO: getting entry point key from the first album should be enough when backend only returns accessible album ids + foreach (var albumInclusionId in albumInclusions.Select(albumInclusion => albumInclusion.Id)) + { + entryPointKeyResult = await GetEntryPointKeyAsync( + client, + volumeId, + albumInclusionId, + knownShareAndKey, + linkDetailsDto.Sharing?.ShareId, + cancellationToken).ConfigureAwait(false); - var entryPointKeyResult = await GetEntryPointKeyAsync( - client, - volumeId, - sourceNodeEntryPointId, - knownShareAndKey, - linkDetailsDto.Sharing?.ShareId, - cancellationToken).ConfigureAwait(false); + if (entryPointKeyResult.TryGetError(out var error)) + { + var uid = new NodeUid(volumeId, albumInclusionId); + client.Telemetry.GetLogger("Node metadata").LogError(error.ToException(), "Album \"{Uid}\" not found", uid); + } + else + { + break; + } + } + } + else + { + entryPointKeyResult = await GetEntryPointKeyAsync( + client, + volumeId, + sourceNodeEntryPointId, + knownShareAndKey, + linkDetailsDto.Sharing?.ShareId, + cancellationToken).ConfigureAwait(false); + } return await ConvertDtoToNodeMetadataAsync( client, From 9ff10d2c27a3ca43ba321fd0a4f14849dbf19f50 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 06:21:30 +0000 Subject: [PATCH 613/791] Update changelog for js/v0.13.1 --- js/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 342b8f79..98ad3e01 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## js/v0.13.1 (2026-03-19) + +* Make LatestEventIdProvider.getLatestEventId async to support IndexedDB +* Change API endpoint that updates 'editors can share' value +* Add approximate sizes to telemetry events + ## js/v0.13.0 (2026-03-11) * Add owned by property From bbe01b1fb824f703c23860268edf1ef456702e36 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 08:02:42 +0100 Subject: [PATCH 614/791] Fail node provision when parent key could not be obtained --- .../DriveInteropTelemetryDecorator.cs | 46 ++-- .../InteropProtonPhotosClient.cs | 2 +- .../Proton.Drive.Sdk/Api/DriveApiClients.cs | 2 + .../Api/Folders/FolderChildrenResponse.cs | 1 + .../Proton.Drive.Sdk/Api/IDriveApiClients.cs | 2 + .../Api/Photos/PhotosApiClients.cs | 19 -- .../Api/Shares/ISharesApiClient.cs | 5 +- .../Api/Shares/ShareListItemDto.cs | 39 +++ .../Api/Shares/ShareListResponse.cs | 8 + .../Api/Shares/ShareResponse.cs | 3 +- .../Api/Shares/SharesApiClient.cs | 10 + .../Caching/DriveEntityCache.cs | 44 +++- .../Caching/IDriveEntityCache.cs | 10 +- .../Caching/IPhotosClientCache.cs | 7 - .../Caching/IPhotosEntityCache.cs | 13 - .../Caching/PhotosClientCache.cs | 11 - .../Caching/PhotosEntityCache.cs | 37 --- .../Caching/PhotosSecretCache.cs | 81 ------- .../Nodes/Cryptography/NodeCrypto.cs | 30 +-- .../Nodes/Download/PhotosFileDownloader.cs | 2 +- .../Nodes/DtoToMetadataConverter.cs | 224 ++++++++---------- .../Nodes/FolderChildrenBatchLoader.cs | 2 - .../Nodes/NodeMetadataResultExtensions.cs | 30 +-- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 116 ++++----- .../Nodes/PhotosNodeOperations.cs | 69 +++--- .../Nodes/TraversalOperations.cs | 7 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 17 +- .../Proton.Drive.Sdk/ProtonPhotosClient.cs | 16 +- .../DriveApiSerializerContext.cs | 1 + .../DriveEntitiesSerializerContext.cs | 2 + .../Shares/ShareOperations.cs | 22 +- .../{Api => }/Shares/ShareType.cs | 2 +- .../Telemetry/DecryptionErrorEvent.cs | 3 +- .../Telemetry/TelemetryEventFactory.cs | 52 ++-- .../Telemetry/TelemetryRecorder.cs | 2 +- .../Telemetry/VerificationErrorEvent.cs | 3 +- .../Proton.Drive.Sdk/Telemetry/VolumeType.cs | 3 +- .../Telemetry/VolumeTypeFactory.cs | 18 -- .../src/Proton.Drive.Sdk/Volumes/VolumeId.cs | 2 +- .../Volumes/VolumeOperations.cs | 77 +++--- .../Volumes/VolumeTrashBatchLoader.cs | 2 - cs/sdk/src/protos/proton.drive.sdk.proto | 9 +- .../proton/drive/sdk/extension/VolumeType.kt | 1 + .../proton/drive/sdk/telemetry/VolumeType.kt | 1 + .../TelemetryAndLogging/TelemetryTypes.swift | 15 +- 45 files changed, 493 insertions(+), 575 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClients.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareListItemDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareListResponse.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosClientCache.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosEntityCache.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosClientCache.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosEntityCache.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosSecretCache.cs rename cs/sdk/src/Proton.Drive.Sdk/{Api => }/Shares/ShareType.cs (68%) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeTypeFactory.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index 760328f2..99810cb6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -48,7 +48,7 @@ private static UploadEventPayload GetUploadEventPayload(UploadEvent me) }; // Check if we should translate InteropErrorException when error is Unknown - var error = me is { Error: Sdk.Telemetry.UploadError.Unknown, OriginalError: InteropErrorException interopError } + var error = me is { Error: Telemetry.UploadError.Unknown, OriginalError: InteropErrorException interopError } ? TranslateToUploadError(interopError) : me.Error; @@ -77,7 +77,7 @@ private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me) }; // Check if we should translate InteropErrorException when error is Unknown - var error = me is { Error: Sdk.Telemetry.DownloadError.Unknown, OriginalError: InteropErrorException interopError } + var error = me is { Error: Telemetry.DownloadError.Unknown, OriginalError: InteropErrorException interopError } ? TranslateToDownloadError(interopError) : me.Error; @@ -100,7 +100,7 @@ private static DecryptionErrorEventPayload GetDecryptionErrorPayload(DecryptionE { VolumeType = (VolumeType)me.VolumeType, Field = (EncryptedField)me.Field, - Uid = me.Uid, + Uid = me.Uid.ToString(), }; if (me.FromBefore2024.HasValue) @@ -116,59 +116,59 @@ private static DecryptionErrorEventPayload GetDecryptionErrorPayload(DecryptionE return payload; } - private static Sdk.Telemetry.UploadError? TranslateToUploadError(InteropErrorException exception) + private static Telemetry.UploadError? TranslateToUploadError(InteropErrorException exception) { if (exception.Error is null) { - return Sdk.Telemetry.UploadError.Unknown; + return Telemetry.UploadError.Unknown; } var error = exception.Error; return exception.Error.Domain switch { ErrorDomain.Api => TranslateApiErrorToUploadError(error.SecondaryCode), - ErrorDomain.Network or ErrorDomain.Transport => Sdk.Telemetry.UploadError.NetworkError, - ErrorDomain.Serialization => Sdk.Telemetry.UploadError.HttpClientSideError, - ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Sdk.Telemetry.UploadError.IntegrityError, - _ => Sdk.Telemetry.UploadError.Unknown, + ErrorDomain.Network or ErrorDomain.Transport => Telemetry.UploadError.NetworkError, + ErrorDomain.Serialization => Telemetry.UploadError.HttpClientSideError, + ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Telemetry.UploadError.IntegrityError, + _ => Telemetry.UploadError.Unknown, }; } - private static Sdk.Telemetry.UploadError TranslateApiErrorToUploadError(long statusCode) + private static Telemetry.UploadError TranslateApiErrorToUploadError(long statusCode) { return statusCode switch { - 429 => Sdk.Telemetry.UploadError.RateLimited, - >= 400 and < 500 => Sdk.Telemetry.UploadError.HttpClientSideError, - _ => Sdk.Telemetry.UploadError.ServerError, + 429 => Telemetry.UploadError.RateLimited, + >= 400 and < 500 => Telemetry.UploadError.HttpClientSideError, + _ => Telemetry.UploadError.ServerError, }; } - private static Sdk.Telemetry.DownloadError? TranslateToDownloadError(InteropErrorException exception) + private static Telemetry.DownloadError? TranslateToDownloadError(InteropErrorException exception) { if (exception.Error is null) { - return Sdk.Telemetry.DownloadError.Unknown; + return Telemetry.DownloadError.Unknown; } var error = exception.Error; return exception.Error.Domain switch { ErrorDomain.Api => TranslateApiErrorToDownloadError(error.SecondaryCode), - ErrorDomain.Network or ErrorDomain.Transport => Sdk.Telemetry.DownloadError.NetworkError, - ErrorDomain.Serialization => Sdk.Telemetry.DownloadError.HttpClientSideError, - ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Sdk.Telemetry.DownloadError.IntegrityError, - _ => Sdk.Telemetry.DownloadError.Unknown, + ErrorDomain.Network or ErrorDomain.Transport => Telemetry.DownloadError.NetworkError, + ErrorDomain.Serialization => Telemetry.DownloadError.HttpClientSideError, + ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Telemetry.DownloadError.IntegrityError, + _ => Telemetry.DownloadError.Unknown, }; } - private static Sdk.Telemetry.DownloadError TranslateApiErrorToDownloadError(long statusCode) + private static Telemetry.DownloadError TranslateApiErrorToDownloadError(long statusCode) { return statusCode switch { - 429 => Sdk.Telemetry.DownloadError.RateLimited, - >= 400 and < 500 => Sdk.Telemetry.DownloadError.HttpClientSideError, - _ => Sdk.Telemetry.DownloadError.ServerError, + 429 => Telemetry.DownloadError.RateLimited, + >= 400 and < 500 => Telemetry.DownloadError.HttpClientSideError, + _ => Telemetry.DownloadError.ServerError, }; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index a2c8da64..b0075b02 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -167,7 +167,7 @@ public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(Dri var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( request.PhotoUids.Select(NodeUid.Parse), - (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, + (Sdk.Nodes.ThumbnailType)request.Type, cancellationToken); await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false)) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs index 13fa3380..55a21d17 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -1,6 +1,7 @@ using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Storage; using Proton.Drive.Sdk.Api.Volumes; @@ -16,4 +17,5 @@ internal sealed class DriveApiClients(HttpClient defaultHttpClient, HttpClient s public IFilesApiClient Files { get; } = new FilesApiClient(defaultHttpClient); public IStorageApiClient Storage { get; } = new StorageApiClient(defaultHttpClient, storageHttpClient); public ITrashApiClient Trash { get; } = new TrashApiClient(defaultHttpClient); + public IPhotosApiClient Photos { get; } = new PhotosApiClient(defaultHttpClient); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs index d13d182a..be7becd0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs @@ -9,6 +9,7 @@ internal sealed class FolderChildrenResponse : ApiResponse [JsonPropertyName("LinkIDs")] public required IReadOnlyList LinkIds { get; init; } + [JsonPropertyName("AnchorID")] public LinkId? AnchorId { get; init; } [JsonPropertyName("More")] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs index 18af01e3..f1892029 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs @@ -1,6 +1,7 @@ using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Storage; using Proton.Drive.Sdk.Api.Volumes; @@ -16,4 +17,5 @@ internal interface IDriveApiClients IFilesApiClient Files { get; } IStorageApiClient Storage { get; } ITrashApiClient Trash { get; } + IPhotosApiClient Photos { get; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClients.cs deleted file mode 100644 index 3fc6f769..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClients.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Proton.Drive.Sdk.Api.Files; -using Proton.Drive.Sdk.Api.Folders; -using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Api.Storage; -using Proton.Drive.Sdk.Api.Volumes; - -namespace Proton.Drive.Sdk.Api.Photos; - -internal sealed class PhotosApiClients(HttpClient defaultHttpClient, HttpClient storageHttpClient) : IDriveApiClients -{ - public IVolumesApiClient Volumes { get; } = new VolumesApiClient(defaultHttpClient); - public ISharesApiClient Shares { get; } = new SharesApiClient(defaultHttpClient); - public ILinksApiClient Links { get; } = new PhotosLinksApiClient(defaultHttpClient); - public IFoldersApiClient Folders { get; } = new FoldersApiClient(defaultHttpClient); - public IFilesApiClient Files { get; } = new FilesApiClient(defaultHttpClient); - public IStorageApiClient Storage { get; } = new StorageApiClient(defaultHttpClient, storageHttpClient); - public ITrashApiClient Trash { get; } = new TrashApiClient(defaultHttpClient); -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs index d2c68e13..1be8cafe 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs @@ -1,7 +1,10 @@ -namespace Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Shares; + +namespace Proton.Drive.Sdk.Api.Shares; internal interface ISharesApiClient { ValueTask GetMyFilesShareAsync(CancellationToken cancellationToken); ValueTask GetShareAsync(ShareId id, CancellationToken cancellationToken); + ValueTask GetSharesAsync(ShareType? typeFilter, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareListItemDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareListItemDto.cs new file mode 100644 index 00000000..6458065d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareListItemDto.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareListItemDto +{ + [JsonPropertyName("ShareID")] + public required ShareId Id { get; init; } + + [JsonPropertyName("VolumeID")] + public required VolumeId VolumeId { get; init; } + + public required ShareType Type { get; init; } + + public required ShareState State { get; init; } + + public required VolumeType VolumeType { get; init; } + + [JsonPropertyName("Creator")] + public required string CreatorEmailAddress { get; init; } + + [JsonPropertyName("Locked")] + public bool? IsLocked { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + [JsonPropertyName("ModifyTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime ModificationTime { get; init; } + + [JsonPropertyName("LinkID")] + public required LinkId RootLinkId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareListResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareListResponse.cs new file mode 100644 index 00000000..ad0b55ed --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareListResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareListResponse : ApiResponse +{ + public required IReadOnlyList Shares { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs index 20512a95..9dc9583a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Addresses; using Proton.Sdk.Api; @@ -24,7 +25,7 @@ internal sealed class ShareResponse : ApiResponse public required string CreatorEmailAddress { get; init; } [JsonPropertyName("Locked")] - public bool IsLocked { get; init; } + public bool? IsLocked { get; init; } [JsonPropertyName("CreateTime")] [JsonConverter(typeof(EpochSecondsJsonConverter))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs index 4370747a..c07d45b0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs @@ -1,4 +1,5 @@ using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Shares; using Proton.Sdk.Http; namespace Proton.Drive.Sdk.Api.Shares; @@ -20,4 +21,13 @@ public async ValueTask GetShareAsync(ShareId id, CancellationToke .Expecting(DriveApiSerializerContext.Default.ShareResponse) .GetAsync($"shares/{id}", cancellationToken).ConfigureAwait(false); } + + public async ValueTask GetSharesAsync(ShareType? typeFilter, CancellationToken cancellationToken) + { + var queryParameters = typeFilter is not null ? $"?ShareType={(int)typeFilter}" : string.Empty; + + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ShareListResponse) + .GetAsync($"shares{queryParameters}", cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 069e5646..d0186d57 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -13,7 +13,9 @@ internal sealed class DriveEntityCache(ICacheRepository repository) : IDriveEnti { private const string ClientUidKey = "client:id"; private const string MainVolumeIdCacheKey = "volume:main:id"; + private const string PhotosVolumeIdCacheKey = "volume:photos:id"; private const string MyFilesShareIdCacheKey = "share:my-files:id"; + private const string PhotosShareIdCacheKey = "share:photos:id"; private readonly ICacheRepository _repository = repository; @@ -27,16 +29,36 @@ public ValueTask SetClientUidAsync(string clientUid, CancellationToken cancellat return _repository.TryGetAsync(ClientUidKey, cancellationToken); } - public ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken) + public ValueTask SetMainVolumeIdAsync(VolumeId? volumeId, CancellationToken cancellationToken) { - return _repository.SetAsync(MainVolumeIdCacheKey, volumeId.ToString(), cancellationToken); + var serializedValue = JsonSerializer.Serialize(volumeId, DriveEntitiesSerializerContext.Default.NullableVolumeId); + + return _repository.SetAsync(MainVolumeIdCacheKey, serializedValue, cancellationToken); + } + + public async ValueTask<(bool Exists, VolumeId? VolumeId)> TryGetMainVolumeIdAsync(CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(MainVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? (true, JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.NullableVolumeId)) + : (false, null); } - public async ValueTask TryGetMainVolumeIdAsync(CancellationToken cancellationToken) + public ValueTask SetPhotosVolumeIdAsync(VolumeId? volumeId, CancellationToken cancellationToken) { - var value = await _repository.TryGetAsync(MainVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); + var serializedValue = JsonSerializer.Serialize(volumeId, DriveEntitiesSerializerContext.Default.NullableVolumeId); - return value is not null ? (VolumeId?)value : null; + return _repository.SetAsync(PhotosVolumeIdCacheKey, serializedValue, cancellationToken); + } + + public async ValueTask<(bool Exists, VolumeId? VolumeId)> TryGetPhotosVolumeIdAsync(CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(PhotosVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? (true, JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.NullableVolumeId)) + : (false, null); } public ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cancellationToken) @@ -51,6 +73,18 @@ public ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cance return value is not null ? (ShareId)value : null; } + public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken) + { + return _repository.SetAsync(PhotosShareIdCacheKey, shareId.ToString(), cancellationToken); + } + + public async ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(PhotosShareIdCacheKey, cancellationToken).ConfigureAwait(false); + + return value is not null ? (ShareId)value : null; + } + public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) { var serializedValue = JsonSerializer.Serialize(share, DriveEntitiesSerializerContext.Default.Share); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs index 8e797cc4..1bca1f32 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -10,12 +10,18 @@ internal interface IDriveEntityCache : IEntityCache ValueTask SetClientUidAsync(string clientUid, CancellationToken cancellationToken); ValueTask TryGetClientUidAsync(CancellationToken cancellationToken); - ValueTask SetMainVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); - ValueTask TryGetMainVolumeIdAsync(CancellationToken cancellationToken); + ValueTask SetMainVolumeIdAsync(VolumeId? volumeId, CancellationToken cancellationToken); + ValueTask<(bool Exists, VolumeId? VolumeId)> TryGetMainVolumeIdAsync(CancellationToken cancellationToken); + + ValueTask SetPhotosVolumeIdAsync(VolumeId? volumeId, CancellationToken cancellationToken); + ValueTask<(bool Exists, VolumeId? VolumeId)> TryGetPhotosVolumeIdAsync(CancellationToken cancellationToken); ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cancellationToken); ValueTask TryGetMyFilesShareIdAsync(CancellationToken cancellationToken); + ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken); + ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken); + ValueTask SetShareAsync(Share share, CancellationToken cancellationToken); ValueTask TryGetShareAsync(ShareId shareId, CancellationToken cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosClientCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosClientCache.cs deleted file mode 100644 index c869c794..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosClientCache.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Proton.Drive.Sdk.Caching; - -internal interface IPhotosClientCache -{ - IPhotosEntityCache Entities { get; } - IDriveSecretCache Secrets { get; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosEntityCache.cs deleted file mode 100644 index 3d4a7bab..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IPhotosEntityCache.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Volumes; - -namespace Proton.Drive.Sdk.Caching; - -internal interface IPhotosEntityCache -{ - ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken); - - ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken); - - ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken); -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosClientCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosClientCache.cs deleted file mode 100644 index 82fa07c0..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosClientCache.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Proton.Sdk.Caching; - -namespace Proton.Drive.Sdk.Caching; - -internal class PhotosClientCache( - ICacheRepository entityCacheRepository, - ICacheRepository secretCacheRepository) : IPhotosClientCache -{ - public IPhotosEntityCache Entities { get; } = new PhotosEntityCache(entityCacheRepository); - public IDriveSecretCache Secrets { get; } = new DriveSecretCache(secretCacheRepository); -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosEntityCache.cs deleted file mode 100644 index cfa721ad..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosEntityCache.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Volumes; -using Proton.Sdk.Caching; - -namespace Proton.Drive.Sdk.Caching; - -internal sealed class PhotosEntityCache(ICacheRepository repository) : IPhotosEntityCache -{ - private const string PhotoVolumeIdCacheKey = "volume:photos:id"; - private const string PhotosShareIdCacheKey = "share:photos:id"; - - private readonly ICacheRepository _repository = repository; - - public ValueTask SetPhotosVolumeIdAsync(VolumeId volumeId, CancellationToken cancellationToken) - { - return _repository.SetAsync(PhotoVolumeIdCacheKey, volumeId.ToString(), cancellationToken); - } - - public async ValueTask TryGetPhotosVolumeIdAsync(CancellationToken cancellationToken) - { - var value = await _repository.TryGetAsync(PhotoVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); - - return value is not null ? (VolumeId?)value : null; - } - - public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken) - { - return _repository.SetAsync(PhotosShareIdCacheKey, shareId.ToString(), cancellationToken); - } - - public async ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken) - { - var value = await _repository.TryGetAsync(PhotosShareIdCacheKey, cancellationToken).ConfigureAwait(false); - - return value is not null ? (ShareId)value : null; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosSecretCache.cs deleted file mode 100644 index 7b1114e9..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/PhotosSecretCache.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Text.Json; -using Proton.Cryptography.Pgp; -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Serialization; -using Proton.Sdk; -using Proton.Sdk.Caching; -using Proton.Sdk.Serialization; - -namespace Proton.Drive.Sdk.Caching; - -internal sealed class PhotosSecretCache(ICacheRepository repository) : IDriveSecretCache -{ - private readonly ICacheRepository _repository = repository; - - public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken) - { - var serializedValue = JsonSerializer.Serialize(shareKey, SecretsSerializerContext.Default.PgpPrivateKey); - - return _repository.SetAsync(GetShareKeyCacheKey(shareId), serializedValue, cancellationToken); - } - - public ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken) - { - throw new NotSupportedException(); - } - - public ValueTask SetFolderSecretsAsync( - NodeUid nodeId, - Result secretsProvisionResult, - CancellationToken cancellationToken) - { - var serializedValue = JsonSerializer.Serialize(secretsProvisionResult, DriveSecretsSerializerContext.Default.ResultFolderSecretsDegradedFolderSecrets); - - return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); - } - - public async ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) - { - var serializedValue = await _repository.TryGetAsync(GetFolderSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); - - return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableResultFolderSecretsDegradedFolderSecrets) - : null; - } - - public ValueTask SetFileSecretsAsync( - NodeUid nodeId, - Result secretsProvisionResult, - CancellationToken cancellationToken) - { - var serializedValue = JsonSerializer.Serialize(secretsProvisionResult, DriveSecretsSerializerContext.Default.ResultFileSecretsDegradedFileSecrets); - - return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); - } - - public ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) - { - throw new NotSupportedException(); - } - - public ValueTask ClearAsync() - { - return _repository.ClearAsync(); - } - - private static string GetShareKeyCacheKey(ShareId shareId) - { - return $"share:{shareId}:key"; - } - - private static string GetFolderSecretsCacheKey(NodeUid nodeId) - { - return $"folder:{nodeId}:secrets"; - } - - private static string GetFileSecretsCacheKey(NodeUid nodeId) - { - return $"file:{nodeId}:secrets"; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index 41cc26f2..c666931c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -16,12 +16,14 @@ public static async ValueTask DecryptFolderAsync( IAccountClient accountClient, LinkDto link, PgpArmoredMessage folderHashKey, - Result parentKeyResult, + PgpPrivateKey parentKey, CancellationToken cancellationToken) { - var linkDecryptionResult = await DecryptLinkAsync(accountClient, link, parentKeyResult, cancellationToken).ConfigureAwait(false); + var linkDecryptionResult = await DecryptLinkAsync(accountClient, link, parentKey, cancellationToken).ConfigureAwait(false); - var hashKeyResult = DecryptHashKey(folderHashKey, linkDecryptionResult.NodeKey.GetValueOrDefault(), linkDecryptionResult.NodeAuthorshipClaim); + var hashKeyResult = linkDecryptionResult.NodeKey.TryGetValue(out var nodeKey) + ? DecryptHashKey(folderHashKey, nodeKey, linkDecryptionResult.NodeAuthorshipClaim) + : "Node key not available"; return new FolderDecryptionResult { @@ -35,13 +37,13 @@ public static async ValueTask DecryptFileAsync( LinkDto linkDto, FileDto fileDto, ActiveRevisionDto activeRevisionDto, - Result parentKeyResult, + PgpPrivateKey parentKey, CancellationToken cancellationToken) { var contentAuthorshipClaim = await AuthorshipClaim.CreateAsync(accountClient, activeRevisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); - var linkDecryptionResult = await DecryptLinkAsync(accountClient, linkDto, parentKeyResult, cancellationToken).ConfigureAwait(false); + var linkDecryptionResult = await DecryptLinkAsync(accountClient, linkDto, parentKey, cancellationToken).ConfigureAwait(false); var nodeKey = linkDecryptionResult.NodeKey.Merge(x => x, _ => default(PgpPrivateKey?)); @@ -81,7 +83,7 @@ public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHa private static async ValueTask DecryptLinkAsync( IAccountClient accountClient, LinkDto link, - Result parentKeyResult, + PgpPrivateKey parentKey, CancellationToken cancellationToken) { var nodeAuthorshipClaim = await AuthorshipClaim.CreateAsync(accountClient, link.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); @@ -90,20 +92,8 @@ private static async ValueTask DecryptLinkAsync( ? await AuthorshipClaim.CreateAsync(accountClient, link.NameSignatureEmailAddress, cancellationToken).ConfigureAwait(false) : nodeAuthorshipClaim; - Result, string> nameResult; - Result>, string> passphraseResult; - - if (parentKeyResult.TryGetValueElseError(out var parentKey, out var parentNodeKeyInnerError)) - { - nameResult = DecryptName(link.Name, parentKey, nameAuthorshipClaim); - passphraseResult = DecryptPassphrase(parentKey, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); - } - else - { - var errorMessage = parentNodeKeyInnerError.Message ?? "Decryption key unavailable"; - nameResult = errorMessage; - passphraseResult = errorMessage; - } + var nameResult = DecryptName(link.Name, parentKey, nameAuthorshipClaim); + var passphraseResult = DecryptPassphrase(parentKey, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); var nodeKeyResult = UnlockNodeKey(link.Key, passphraseResult.Merge(x => (ReadOnlyMemory?)x.Data, _ => null)); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index b5a3ae20..0463aaef 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -103,7 +103,7 @@ private DownloadController DownloadToStream( var downloadEvent = new DownloadEvent { DownloadedSize = 0, - VolumeType = VolumeType.OwnPhotoVolume, + VolumeType = VolumeType.OwnPhotosVolume, }; var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 722dcf7b..846297f1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -4,6 +4,7 @@ using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes.Cryptography; @@ -23,65 +24,30 @@ public static async Task> ConvertDtoT ShareAndKey? knownShareAndKey, CancellationToken cancellationToken) { - var sourceNodeEntryPointId = linkDetailsDto.Link.ParentId; - - Result entryPointKeyResult; - - if (sourceNodeEntryPointId is null && linkDetailsDto.Photo is { AlbumInclusions: { Count: > 0 } albumInclusions }) - { - entryPointKeyResult = new ProtonDriveError("No album entry point key found"); - - // TODO: optimize by selecting the album that is in cache, if any - // TODO: getting entry point key from the first album should be enough when backend only returns accessible album ids - foreach (var albumInclusionId in albumInclusions.Select(albumInclusion => albumInclusion.Id)) - { - entryPointKeyResult = await GetEntryPointKeyAsync( - client, - volumeId, - albumInclusionId, - knownShareAndKey, - linkDetailsDto.Sharing?.ShareId, - cancellationToken).ConfigureAwait(false); - - if (entryPointKeyResult.TryGetError(out var error)) - { - var uid = new NodeUid(volumeId, albumInclusionId); - client.Telemetry.GetLogger("Node metadata").LogError(error.ToException(), "Album \"{Uid}\" not found", uid); - } - else - { - break; - } - } - } - else - { - entryPointKeyResult = await GetEntryPointKeyAsync( + var entryPointKey = linkDetailsDto.Link.ParentId is not null || linkDetailsDto.Photo is not { AlbumInclusions: { Count: > 0 } albumInclusions } + ? await GetEntryPointKeyOrThrowAsync( client, volumeId, - sourceNodeEntryPointId, + linkDetailsDto.Link.ParentId, knownShareAndKey, linkDetailsDto.Sharing?.ShareId, - cancellationToken).ConfigureAwait(false); - } + cancellationToken).ConfigureAwait(false) + : await GetAlbumEntryPointKeyOrThrowAsync(client, volumeId, linkDetailsDto, knownShareAndKey, albumInclusions, cancellationToken) + .ConfigureAwait(false); return await ConvertDtoToNodeMetadataAsync( client, - client.Cache.Entities, - client.Cache.Secrets, volumeId, linkDetailsDto, - entryPointKeyResult, + entryPointKey, cancellationToken).ConfigureAwait(false); } public static async Task> ConvertDtoToNodeMetadataAsync( ProtonDriveClient client, - IEntityCache entityCache, - IDriveSecretCache secretCache, VolumeId volumeId, LinkDetailsDto linkDetailsDto, - Result parentKeyResult, + PgpPrivateKey parentKey, CancellationToken cancellationToken) { var linkType = linkDetailsDto.Link.Type; @@ -91,33 +57,27 @@ public static async Task> ConvertDtoT LinkType.Folder => (await ConvertDtoToFolderMetadataAsync( client, - entityCache, - secretCache, volumeId, linkDetailsDto, - parentKeyResult, + parentKey, cancellationToken).ConfigureAwait(false)) .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), LinkType.File => (await ConvertDtoToFileMetadataAsync( client, - entityCache, - secretCache, volumeId, linkDetailsDto, - parentKeyResult, + parentKey, cancellationToken).ConfigureAwait(false)) .Convert(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), LinkType.Album => (await ConvertDtoToAlbumMetadataAsync( client, - client.Cache.Entities, - client.Cache.Secrets, volumeId, linkDetailsDto, - parentKeyResult, + parentKey, cancellationToken).ConfigureAwait(false)) .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), @@ -130,11 +90,9 @@ public static async Task> ConvertDtoT public static async ValueTask> ConvertDtoToFolderMetadataAsync( ProtonDriveClient client, - IEntityCache entityCache, - IDriveSecretCache secretCache, VolumeId volumeId, LinkDetailsDto linkDetailsDto, - Result parentKeyResult, + PgpPrivateKey parentKey, CancellationToken cancellationToken) { if (linkDetailsDto.Folder is null) @@ -144,22 +102,18 @@ public static async ValueTask> Co return await ConvertDtoToFolderMetadataAsync( client, - entityCache, - secretCache, volumeId, linkDetailsDto, linkDetailsDto.Folder, - parentKeyResult, + parentKey, cancellationToken).ConfigureAwait(false); } public static async ValueTask> ConvertDtoToAlbumMetadataAsync( ProtonDriveClient client, - IEntityCache entityCache, - IDriveSecretCache secretCache, VolumeId volumeId, LinkDetailsDto linkDetailsDto, - Result parentKeyResult, + PgpPrivateKey parentKey, CancellationToken cancellationToken) { if (linkDetailsDto.Album is null) @@ -169,22 +123,18 @@ public static async ValueTask> Co return await ConvertDtoToFolderMetadataAsync( client, - entityCache, - secretCache, volumeId, linkDetailsDto, linkDetailsDto.Album, - parentKeyResult, + parentKey, cancellationToken).ConfigureAwait(false); } public static async Task> ConvertDtoToFileMetadataAsync( ProtonDriveClient client, - IEntityCache entityCache, - IDriveSecretCache secretCache, VolumeId volumeId, LinkDetailsDto linkDetailsDto, - Result parentKeyResult, + PgpPrivateKey parentKey, CancellationToken cancellationToken) { var linkDto = linkDetailsDto.Link; @@ -212,7 +162,7 @@ public static async Task> ConvertDtoT var uid = new NodeUid(volumeId, linkDto.Id); var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; - var decryptionResult = await NodeCrypto.DecryptFileAsync(client.Account, linkDto, fileDto, activeRevisionDto, parentKeyResult, cancellationToken) + var decryptionResult = await NodeCrypto.DecryptFileAsync(client.Account, linkDto, fileDto, activeRevisionDto, parentKey, cancellationToken) .ConfigureAwait(false); var nodeKeyIsValid = decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); @@ -246,14 +196,14 @@ public static async Task> ConvertDtoT nameSessionKey, membershipDto); - await secretCache.SetFileSecretsAsync(uid, degradedFileMetadata.Secrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFileSecretsAsync(uid, degradedFileMetadata.Secrets, cancellationToken).ConfigureAwait(false); - await entityCache.SetNodeAsync(uid, degradedFileMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) + await client.Cache.Entities.SetNodeAsync(uid, degradedFileMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) .ConfigureAwait(false); await TelemetryRecorder.TryRecordDecryptionErrorAsync( client, - DegradedNodeMetadata.FromFile(degradedFileMetadata), + DegradedNodeMetadata.FromFile(degradedFileMetadata).Node, failedDecryptionFields, cancellationToken).ConfigureAwait(false); @@ -321,9 +271,9 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( OwnedBy = ownedBy, }; - await secretCache.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); - await entityCache.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } @@ -448,12 +398,10 @@ private static (DegradedFileMetadata Metadata, List FailedDecryp private static async ValueTask> ConvertDtoToFolderMetadataAsync( ProtonDriveClient client, - IEntityCache entityCache, - IDriveSecretCache secretCache, VolumeId volumeId, LinkDetailsDto linkDetailsDto, FolderDto folderDto, - Result parentKeyResult, + PgpPrivateKey parentKey, CancellationToken cancellationToken) { var linkDto = linkDetailsDto.Link; @@ -462,7 +410,7 @@ private static async ValueTask> C var uid = new NodeUid(volumeId, linkDto.Id); var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; - var decryptionResult = await NodeCrypto.DecryptFolderAsync(client.Account, linkDto, folderDto.HashKey, parentKeyResult, cancellationToken) + var decryptionResult = await NodeCrypto.DecryptFolderAsync(client.Account, linkDto, folderDto.HashKey, parentKey, cancellationToken) .ConfigureAwait(false); var nodeKeyIsValid = decryptionResult.Link.NodeKey.TryGetValue(out var nodeKey); @@ -483,14 +431,14 @@ private static async ValueTask> C nameSessionKey, membershipDto); - await secretCache.SetFolderSecretsAsync(uid, degradedFolderMetadata.Secrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFolderSecretsAsync(uid, degradedFolderMetadata.Secrets, cancellationToken).ConfigureAwait(false); - await entityCache.SetNodeAsync(uid, degradedFolderMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) + await client.Cache.Entities.SetNodeAsync(uid, degradedFolderMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) .ConfigureAwait(false); await TelemetryRecorder.TryRecordDecryptionErrorAsync( client, - DegradedNodeMetadata.FromFolder(degradedFolderMetadata), + DegradedNodeMetadata.FromFolder(degradedFolderMetadata).Node, failedDecryptionFields, cancellationToken).ConfigureAwait(false); @@ -525,9 +473,9 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( OwnedBy = MapOwnedBy(linkDto.OwnedBy), }; - await secretCache.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); - await entityCache.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetNodeAsync(uid, node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken).ConfigureAwait(false); return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } @@ -594,7 +542,7 @@ private static (DegradedFolderMetadata Metadata, List FailedDecr var degradedSecrets = new DegradedFolderSecrets { - Key = decryptionResult.Link.NodeKey.GetValueOrDefault(), + Key = decryptionResult.Link.NodeKey.TryGetValue(out var key) ? key : null, PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), NameSessionKey = nameSessionKey, HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), @@ -603,7 +551,7 @@ private static (DegradedFolderMetadata Metadata, List FailedDecr return (new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); } - private static async ValueTask> GetEntryPointKeyAsync( + private static async ValueTask GetEntryPointKeyOrThrowAsync( ProtonDriveClient client, VolumeId volumeId, LinkId? parentId, @@ -625,41 +573,34 @@ private static async ValueTask> GetEntry PgpPrivateKey? lastKey = null; - try + // FIXME: this could go into an infinite loop if there's a structure issue in the cache. + while (currentId is not null) { - // FIXME: this could go into an infinite loop if there's a structure issue in the cache. - while (currentId is not null) + if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) { - if (shareAndKeyToUse is var (shareToUse, shareKeyToUse) && currentId == shareToUse.RootFolderId.LinkId) - { - lastKey = shareKeyToUse; - break; - } + lastKey = shareKeyToUse; + break; + } - var nodeUid = new NodeUid(volumeId, currentId.Value); + var nodeUid = new NodeUid(volumeId, currentId.Value); - var folderSecretsResult = await secretCache.TryGetFolderSecretsAsync(nodeUid, cancellationToken).ConfigureAwait(false); + var folderSecretsResult = await secretCache.TryGetFolderSecretsAsync(nodeUid, cancellationToken).ConfigureAwait(false); - var folderKey = folderSecretsResult?.Merge(x => x.Key, x => x.Key); + var folderKey = folderSecretsResult?.Merge(x => x.Key, x => x.Key); - if (folderKey is not null) - { - lastKey = folderKey.Value; - break; - } + if (folderKey is not null) + { + lastKey = folderKey.Value; + break; + } - var linkDetails = await getLinkDetails(currentId.Value, cancellationToken).ConfigureAwait(false); + var linkDetails = await getLinkDetails(currentId.Value, cancellationToken).ConfigureAwait(false); - linkAncestry.Push(linkDetails); + linkAncestry.Push(linkDetails); - currentShareId = linkDetails.Sharing?.ShareId; + currentShareId = linkDetails.Sharing?.ShareId; - currentId = linkDetails.Link.ParentId; - } - } - catch (Exception e) - { - return new ProtonDriveError(e.Message); + currentId = linkDetails.Link.ParentId; } if (lastKey is not { } currentParentKey) @@ -672,10 +613,11 @@ private static async ValueTask> GetEntry { if (currentShareId is null) { - return new ProtonDriveError("No share available to access node"); + throw new ProtonDriveException("No share available to access node"); } - (_, currentParentKey) = await ShareOperations.GetShareAsync(client, currentShareId.Value, cancellationToken).ConfigureAwait(false); + (_, currentParentKey) = await ShareOperations.GetShareAsync(client, currentShareId.Value, useCacheOnly: false, cancellationToken) + .ConfigureAwait(false); } } @@ -683,26 +625,18 @@ private static async ValueTask> GetEntry { var decryptionResult = await ConvertDtoToNodeMetadataAsync( client, - client.Cache.Entities, - client.Cache.Secrets, volumeId, ancestorLinkDetails, currentParentKey, cancellationToken).ConfigureAwait(false); - if (!decryptionResult.TryGetFolderKeyElseError(out var folderKey, out var error)) - { - // TODO: wrap error for more context? - return error; - } - - currentParentKey = folderKey.Value; + currentParentKey = decryptionResult.GetFolderKeyOrThrow(); } return currentParentKey; } - private static async ValueTask> GetEntryPointKeyAsync( + private static async ValueTask GetEntryPointKeyOrThrowAsync( ProtonDriveClient client, VolumeId volumeId, LinkId? parentId, @@ -710,15 +644,57 @@ private static async ValueTask> GetEntry ShareId? shareId, CancellationToken cancellationToken) { - return await GetEntryPointKeyAsync(client, volumeId, parentId, shareAndKeyToUse, shareId, client.Cache.Secrets, GetLinkDetailsAsync, cancellationToken) - .ConfigureAwait(false); + return await GetEntryPointKeyOrThrowAsync( + client, + volumeId, + parentId, + shareAndKeyToUse, + shareId, + client.Cache.Secrets, + GetLinkDetailsAsync, + cancellationToken).ConfigureAwait(false); async Task GetLinkDetailsAsync(LinkId linkId, CancellationToken ct) { var response = await client.Api.Links.GetDetailsAsync(volumeId, [linkId], ct).ConfigureAwait(false); - return response.Links is { Count: > 0 } links ? links[0] : throw new ProtonDriveException($"Node \"{new NodeUid(volumeId, linkId)}\" not found"); + return response.Links is { Count: > 0 } links + ? links[0] + : throw new ProtonDriveException($"Node \"{new NodeUid(volumeId, linkId)}\" not found"); + } + } + + private static async Task GetAlbumEntryPointKeyOrThrowAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + ShareAndKey? knownShareAndKey, + IReadOnlyList albumInclusions, + CancellationToken cancellationToken) + { + var logger = client.Telemetry.GetLogger("Node metadata"); + + // TODO: optimize by selecting the album that is in cache, if any + // TODO: getting entry point key from the first album should be enough when back-end only returns accessible album IDs + foreach (var albumInclusionId in albumInclusions.Select(albumInclusion => albumInclusion.Id)) + { + try + { + return await GetEntryPointKeyOrThrowAsync( + client, + volumeId, + albumInclusionId, + knownShareAndKey, + linkDetailsDto.Sharing?.ShareId, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Album \"{Uid}\" not found", new NodeUid(volumeId, albumInclusionId)); + } } + + throw new ProtonDriveException("No album entry point key found"); } private static OwnedBy MapOwnedBy(OwnedByDto? dto) => new(dto?.Email, dto?.Organization); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs index 97a113d7..50adf687 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs @@ -25,8 +25,6 @@ protected override async ValueTask>> Lo { var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( _client, - _client.Cache.Entities, - _client.Cache.Secrets, _volumeId, linkDetails, _parentKey, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs index 6849bae8..109adf35 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs @@ -1,5 +1,4 @@ -using System.Diagnostics.CodeAnalysis; -using Proton.Cryptography.Pgp; +using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; using Proton.Sdk; @@ -60,42 +59,29 @@ public static Result GetFileSecretsOrThrow(thi } } - public static bool TryGetFolderKeyElseError( - this Result metadataResult, - [NotNullWhen(true)] out PgpPrivateKey? folderKey, - [MaybeNullWhen(true)] out ProtonDriveError error) + public static PgpPrivateKey GetFolderKeyOrThrow(this Result metadataResult) { if (!metadataResult.TryGetValueElseError(out var nodeAndSecrets, out var degradedNodeAndSecrets)) { if (degradedNodeAndSecrets.TryGetFileElseFolder(out var degradedFileNode, out _, out var degradedFolderNode, out var degradedFolderSecrets)) { - folderKey = null; - error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(degradedFileNode.Uid, LinkType.File)); - return false; + throw new InvalidNodeTypeException(degradedFileNode.Uid, LinkType.File); } - if (degradedFolderSecrets.Key is null) + if (degradedFolderSecrets.Key is not { } folderKey) { - folderKey = null; - error = degradedFolderNode.Errors[0]; - return false; + throw new ProtonDriveException($"Degraded node does not have a key: {degradedFolderNode.Errors[0]}"); } - folderKey = degradedFolderSecrets.Key; - error = null; - return true; + return folderKey; } if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) { - folderKey = null; - error = new ProtonDriveError(InvalidNodeTypeException.GetMessage(fileNode.Uid, LinkType.File)); - return false; + throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); } - folderKey = folderSecrets.Key; - error = null; - return true; + return folderSecrets.Key; } public static Result ToNodeResult(this Result metadataResult) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index ef2229c3..2a9093b3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -5,8 +5,6 @@ using System.Text; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; -using Proton.Drive.Sdk.Api.Shares; -using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes.Cryptography; using Proton.Drive.Sdk.Shares; @@ -21,49 +19,55 @@ internal static class NodeOperations { private const int MaximumBatchCount = 150; - public static async ValueTask GetMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + public static async ValueTask GetOrCreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var existingFolder = await TryGetExistingMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + + return existingFolder ?? await CreateMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + } + + public static async ValueTask TryGetExistingMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { var shareId = await client.Cache.Entities.TryGetMyFilesShareIdAsync(cancellationToken).ConfigureAwait(false); if (shareId is null) { - return await GetFreshMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + try + { + return await GetFreshExistingMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + } + catch (ProtonApiException e) when (e.Code is ResponseCode.DoesNotExist) + { + await client.Cache.Entities.SetMainVolumeIdAsync(null, cancellationToken).AsTask().ConfigureAwait(false); + return null; + } } - var shareAndKey = await ShareOperations.GetShareAsync(client, shareId.Value, cancellationToken).ConfigureAwait(false); - - var metadata = await GetNodeMetadataAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, cancellationToken).ConfigureAwait(false); + var shareAndKey = await ShareOperations.GetShareAsync(client, shareId.Value, useCacheOnly: false, cancellationToken).ConfigureAwait(false); - return (FolderNode)metadata.Node; - } + var metadata = await GetNodeMetadataAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, useCacheOnly: false, cancellationToken) + .ConfigureAwait(false); - public static ValueTask GetNodeMetadataAsync(ProtonDriveClient client, NodeUid uid, CancellationToken cancellationToken) - { - return GetNodeMetadataAsync(client, uid, knownShareAndKey: null, cancellationToken); + return (FolderNode)metadata.GetValueOrThrow().Node; } - public static async ValueTask GetNodeMetadataAsync( + public static async ValueTask> GetNodeMetadataAsync( ProtonDriveClient client, NodeUid uid, ShareAndKey? knownShareAndKey, + bool useCacheOnly, CancellationToken cancellationToken) { - var metadataResult = await GetNodeMetadataResultAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); - return metadataResult.GetValueOrThrow(); - } + var metadataResult = await TryGetNodeMetadataFromCacheAsync(client, uid, cancellationToken).ConfigureAwait(false); - public static async ValueTask> GetNodeMetadataResultAsync( - ProtonDriveClient client, - NodeUid uid, - ShareAndKey? knownShareAndKey, - CancellationToken cancellationToken) - { - var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); - - var metadataResult = cachedNodeInfo is not null - ? await GetNodeMetadataAsync(client, uid, cachedNodeInfo.Value, cancellationToken).ConfigureAwait(false) - : null; + if (metadataResult is null) + { + if (useCacheOnly) + { + throw new ProtonDriveException("Node \"{uid}\" not found in cache"); + } - metadataResult ??= await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); + metadataResult = await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); + } return (Result)metadataResult; } @@ -182,8 +186,9 @@ public static async ValueTask MoveSingleAsync( throw new InvalidOperationException($"Node {uid} cannot have destination node {newParentUid} as parent as they are not on the same volume"); } - var (originNode, originSecrets, membershipShareId, originNameHashDigest) = await GetNodeMetadataAsync(client, uid, null, cancellationToken) - .ConfigureAwait(false); + // FIXME: Try to use the degraded node if it has enough for the move to be successful + var (originNode, originSecrets, membershipShareId, originNameHashDigest) = + (await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, cancellationToken).ConfigureAwait(false)).GetValueOrThrow(); GetNameParameters( newName ?? originNode.Name, // FIXME: validate name @@ -248,8 +253,9 @@ public static async Task MoveMultipleAsync( throw new InvalidOperationException($"Node {uid} cannot have destination node {newParentUid} as parent as they are not on the same volume"); } - var (originNode, originSecrets, _, originNameHashDigest) = await GetNodeMetadataAsync(client, uid, null, cancellationToken) - .ConfigureAwait(false); + // FIXME: Try to use the degraded node if it has enough for the move to be successful + var (originNode, originSecrets, _, originNameHashDigest) = + (await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, cancellationToken).ConfigureAwait(false)).GetValueOrThrow(); GetNameParameters( newName ?? originNode.Name, // FIXME: validate name @@ -295,8 +301,9 @@ public static async ValueTask RenameAsync( string? newMediaType, CancellationToken cancellationToken) { - // TODO: support renaming degraded nodes - var (node, secrets, membershipShareId, originalNameHashDigest) = await GetNodeMetadataAsync(client, uid, cancellationToken).ConfigureAwait(false); + // FIXME: Try to use the degraded node if it has enough for the move to be successful + var (node, secrets, membershipShareId, originalNameHashDigest) = + (await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, cancellationToken).ConfigureAwait(false)).GetValueOrThrow(); if (node.ParentUid is not { } parentUid) { @@ -526,7 +533,7 @@ public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClie // FIXME: try to get the information from cache first var response = await client.Api.Links.GetContextShareAsync(nodeUid.VolumeId, nodeUid.LinkId, cancellationToken).ConfigureAwait(false); - var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, cancellationToken).ConfigureAwait(false); + var (share, _) = await ShareOperations.GetShareAsync(client, response.ContextShareId, useCacheOnly: false, cancellationToken).ConfigureAwait(false); return await client.Account.GetAddressAsync(share.MembershipAddressId, cancellationToken).ConfigureAwait(false); } @@ -568,7 +575,7 @@ public static bool ValidateName( public static async Task> GetParentFolderHashKeyAsync(ProtonDriveClient client, NodeUid uid, CancellationToken cancellationToken) { - var nodeMetadataResult = await GetNodeMetadataResultAsync(client, uid, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); + var nodeMetadataResult = await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); var parentUid = nodeMetadataResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); if (parentUid is null) @@ -581,22 +588,12 @@ public static async Task> GetParentFolderHashKeyAsync(Proto return parentFolderSecrets.HashKey; } - private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + private static async ValueTask GetFreshExistingMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { - ShareVolumeDto volumeDto; - ShareDto shareDto; - LinkDetailsDto linkDetailsDto; - - try - { - (volumeDto, shareDto, linkDetailsDto) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); - } - catch (ProtonApiException e) when (e.Code == ResponseCode.DoesNotExist) - { - return await CreateMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); - } + var (volumeDto, shareDto, linkDetailsDto) = await client.Api.Shares.GetMyFilesShareAsync(cancellationToken).ConfigureAwait(false); await client.Cache.Entities.SetMyFilesShareIdAsync(shareDto.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetMainVolumeIdAsync(volumeDto.Id, cancellationToken).ConfigureAwait(false); var nodeUid = new NodeUid(volumeDto.Id, linkDetailsDto.Link.Id); @@ -614,13 +611,11 @@ private static async ValueTask GetFreshMyFilesFolderAsync(ProtonDriv await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - client, - client.Cache.Entities, - client.Cache.Secrets, - volumeDto.Id, - linkDetailsDto, - shareKey, - cancellationToken) + client, + volumeDto.Id, + linkDetailsDto, + shareKey, + cancellationToken) .ConfigureAwait(false); return metadataResult.GetValueOrThrow().Node; @@ -651,12 +646,17 @@ private static void GetNameParameters( } } - private static async ValueTask?> GetNodeMetadataAsync( + private static async ValueTask?> TryGetNodeMetadataFromCacheAsync( ProtonDriveClient client, NodeUid uid, - CachedNodeInfo cachedNodeInfo, CancellationToken cancellationToken) { + var cachedNodeInfoResult = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); + if (cachedNodeInfoResult is not { } cachedNodeInfo) + { + return null; + } + if (!cachedNodeInfo.NodeProvisionResult.TryGetValueElseError(out var node, out var degradedNode)) { switch (degradedNode) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs index c1e5e36e..2b1d4bac 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs @@ -1,7 +1,6 @@ using System.Runtime.CompilerServices; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Api.Photos; -using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; using Proton.Sdk; @@ -13,36 +12,55 @@ internal static class PhotosNodeOperations { private const int TimelinePageSize = 500; - public static async ValueTask GetPhotosFolderAsync(ProtonPhotosClient client, CancellationToken cancellationToken) + public static async ValueTask GetOrCreatePhotosFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var existingFolder = await TryGetExistingPhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); + + return existingFolder ?? await CreatePhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); + } + + public static async ValueTask TryGetExistingPhotosFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { var shareId = await client.Cache.Entities.TryGetPhotosShareIdAsync(cancellationToken).ConfigureAwait(false); if (shareId is null) { - return await GetFreshPhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); + try + { + return await GetFreshExistingPhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); + } + catch (ProtonApiException e) when (e.Code is ResponseCode.DoesNotExist) + { + await client.Cache.Entities.SetPhotosVolumeIdAsync(null, cancellationToken).AsTask().ConfigureAwait(false); + return null; + } } - var shareAndKey = await ShareOperations.GetShareAsync(client.DriveClient, shareId.Value, cancellationToken).ConfigureAwait(false); + var shareAndKey = await ShareOperations.GetShareAsync(client, shareId.Value, useCacheOnly: false, cancellationToken).ConfigureAwait(false); - var metadata = await NodeOperations.GetNodeMetadataAsync(client.DriveClient, shareAndKey.Share.RootFolderId, shareAndKey, cancellationToken) - .ConfigureAwait(false); + var metadata = await NodeOperations.GetNodeMetadataAsync( + client, + shareAndKey.Share.RootFolderId, + shareAndKey, + useCacheOnly: false, + cancellationToken).ConfigureAwait(false); - return (FolderNode)metadata.Node; + return (FolderNode)metadata.GetValueOrThrow().Node; } public static async IAsyncEnumerable EnumeratePhotosTimelineAsync( - ProtonPhotosClient client, + ProtonDriveClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var anchorLinkId = default(LinkId?); do { - var rootFolderNode = await GetPhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); + var rootFolderNode = await GetOrCreatePhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); var photosVolumeId = rootFolderNode.Uid.VolumeId; var request = new TimelinePhotoListRequest { VolumeId = photosVolumeId, PreviousPageLastLinkId = anchorLinkId }; - var response = await client.PhotosApi.GetTimelinePhotosAsync(request, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Photos.GetTimelinePhotosAsync(request, cancellationToken).ConfigureAwait(false); anchorLinkId = response.Photos.Count == TimelinePageSize ? response.Photos[^1].Id : null; @@ -55,27 +73,16 @@ public static async IAsyncEnumerable EnumeratePhotosTimeline } while (anchorLinkId is not null); } - private static async ValueTask GetFreshPhotosFolderAsync(ProtonPhotosClient photosClient, CancellationToken cancellationToken) + private static async ValueTask GetFreshExistingPhotosFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { - ShareVolumeDto volumeDto; - ShareDto shareDto; - LinkDetailsDto linkDetailsDto; - - try - { - (volumeDto, shareDto, linkDetailsDto) = await photosClient.PhotosApi.GetRootShareAsync(cancellationToken).ConfigureAwait(false); - } - catch (ProtonApiException e) when (e.Code == ResponseCode.DoesNotExist) - { - return await CreatePhotosFolderAsync(photosClient, cancellationToken).ConfigureAwait(false); - } + var (volumeDto, shareDto, linkDetailsDto) = await client.Api.Photos.GetRootShareAsync(cancellationToken).ConfigureAwait(false); - await photosClient.Cache.Entities.SetPhotosShareIdAsync(shareDto.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetPhotosShareIdAsync(shareDto.Id, cancellationToken).ConfigureAwait(false); var nodeUid = new NodeUid(volumeDto.Id, linkDetailsDto.Link.Id); var (share, shareKey) = await ShareCrypto.DecryptShareAsync( - photosClient.DriveClient, + client, shareDto.Id, shareDto.Key, shareDto.Passphrase, @@ -84,13 +91,11 @@ private static async ValueTask GetFreshPhotosFolderAsync(ProtonPhoto ShareType.Photos, cancellationToken).ConfigureAwait(false); - await photosClient.DriveClient.Cache.Secrets.SetShareKeyAsync(share.Id, shareKey, cancellationToken).ConfigureAwait(false); - await photosClient.DriveClient.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetShareKeyAsync(share.Id, shareKey, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - photosClient.DriveClient, - photosClient.DriveClient.Cache.Entities, - photosClient.Cache.Secrets, + client, volumeDto.Id, linkDetailsDto, shareKey, @@ -100,9 +105,9 @@ private static async ValueTask GetFreshPhotosFolderAsync(ProtonPhoto return metadataResult.GetValueOrThrow().Node; } - private static async ValueTask CreatePhotosFolderAsync(ProtonPhotosClient photosClient, CancellationToken cancellationToken) + private static async ValueTask CreatePhotosFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { - var (_, _, folderNode) = await VolumeOperations.CreatePhotosVolumeAsync(photosClient, cancellationToken).ConfigureAwait(false); + var (_, _, folderNode) = await VolumeOperations.CreatePhotosVolumeAsync(client, cancellationToken).ConfigureAwait(false); return folderNode; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs index 1f3c154c..73cfe22c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs @@ -7,6 +7,7 @@ internal static class TraversalOperations public static async ValueTask> FindRootForNode( ProtonDriveClient client, Result nodeResult, + bool useCacheOnly, CancellationToken cancellationToken) { var entryPointUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid) @@ -21,7 +22,7 @@ public static async ValueTask> FindRo throw new ProtonDriveException("Folder structure loop detected"); } - nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, (NodeUid)entryPointUid, knownShareAndKey: null, cancellationToken) + nodeResult = await NodeOperations.GetNodeMetadataAsync(client, (NodeUid)entryPointUid, knownShareAndKey: null, useCacheOnly, cancellationToken) .ConfigureAwait(false); entryPointUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); @@ -33,7 +34,7 @@ public static async ValueTask> FindRo private static NodeUid? GetAlbumEntryPointUid(Result nodeResult) { return nodeResult.Merge( - x => x.Node is PhotoNode photo && photo.AlbumUids.Count > 0 ? photo.AlbumUids[0] : (NodeUid?)null, - x => x.Node is DegradedPhotoNode photo && photo.AlbumUids.Count > 0 ? photo.AlbumUids[0] : null); + x => x.Node is PhotoNode { AlbumUids.Count: > 0 } photo ? photo.AlbumUids[0] : (NodeUid?)null, + x => x.Node is DegradedPhotoNode { AlbumUids.Count: > 0 } photo ? photo.AlbumUids[0] : null); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 45998941..3c6d2555 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api; @@ -177,7 +178,7 @@ private ProtonDriveClient( public ValueTask GetMyFilesFolderAsync(CancellationToken cancellationToken) { - return NodeOperations.GetMyFilesFolderAsync(this, cancellationToken); + return NodeOperations.GetOrCreateMyFilesFolderAsync(this, cancellationToken); } public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) @@ -276,9 +277,19 @@ public ValueTask>> RestoreNodesAs return NodeOperations.RestoreFromTrashAsync(this, uids, cancellationToken); } - public IAsyncEnumerable> EnumerateTrashAsync(CancellationToken cancellationToken) + public async IAsyncEnumerable> EnumerateTrashAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - return VolumeOperations.EnumerateTrashAsync(this, cancellationToken); + var volumeId = await VolumeOperations.TryGetMainVolumeIdAsync(this, cancellationToken).ConfigureAwait(false); + if (volumeId is null) + { + // Nothing to enumerate if the main volume doesn't exist + yield break; + } + + await foreach (var entry in VolumeOperations.EnumerateTrashAsync(this, volumeId.Value, cancellationToken).ConfigureAwait(false)) + { + yield return entry; + } } public ValueTask EmptyTrashAsync(CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index 7a65e8d0..c4c2d990 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Photos; -using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Http; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; @@ -20,12 +20,11 @@ public ProtonPhotosClient(ProtonApiSession session, string? uid = null) { DriveClient = new ProtonDriveClient( session, - (defaultApiHttpClient, storageApiHttpClient) => new PhotosApiClients(defaultApiHttpClient, storageApiHttpClient), + (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), uid); _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)); - Cache = new PhotosClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository); PhotosApi = new PhotosApiClient(_httpClient); } @@ -45,20 +44,17 @@ public ProtonPhotosClient( secretCacheRepository, featureFlagProvider, telemetry, - (defaultApiHttpClient, storageApiHttpClient) => new PhotosApiClients(defaultApiHttpClient, storageApiHttpClient), + (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), creationParameters); _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout( creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds); - Cache = new PhotosClientCache(entityCacheRepository, secretCacheRepository); PhotosApi = new PhotosApiClient(_httpClient); } internal IPhotosApiClient PhotosApi { get; } - internal IPhotosClientCache Cache { get; } - internal ProtonDriveClient DriveClient { get; } public async ValueTask GetFileUploaderAsync( @@ -69,7 +65,7 @@ public async ValueTask GetFileUploaderAsync( bool overrideExistingDraftByOtherClient, CancellationToken cancellationToken) { - var photosRoot = await PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken).ConfigureAwait(false); + var photosRoot = await PhotosNodeOperations.GetOrCreatePhotosFolderAsync(DriveClient, cancellationToken).ConfigureAwait(false); var draftProvider = new NewFileDraftProvider(DriveClient, photosRoot.Uid, name, mediaType, overrideExistingDraftByOtherClient); @@ -95,7 +91,7 @@ public IAsyncEnumerable> EnumerateNodesAsync(IEnumera [Experimental("Photos")] public IAsyncEnumerable EnumerateTimelineAsync(CancellationToken cancellationToken) { - return PhotosNodeOperations.EnumeratePhotosTimelineAsync(this, cancellationToken); + return PhotosNodeOperations.EnumeratePhotosTimelineAsync(DriveClient, cancellationToken); } public async ValueTask GetPhotosDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) @@ -119,7 +115,7 @@ public void Dispose() [Experimental("Photos")] internal ValueTask GetPhotosRootAsync(CancellationToken cancellationToken) { - return PhotosNodeOperations.GetPhotosFolderAsync(this, cancellationToken); + return PhotosNodeOperations.GetOrCreatePhotosFolderAsync(DriveClient, cancellationToken); } private async ValueTask GetFileUploaderAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 70d1d7d3..61be9b6b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -29,6 +29,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(LinkDetailsResponse))] [JsonSerializable(typeof(ExtendedAttributes))] [JsonSerializable(typeof(ShareResponse))] +[JsonSerializable(typeof(ShareListResponse))] [JsonSerializable(typeof(ShareResponseV2))] [JsonSerializable(typeof(ContextShareResponse))] [JsonSerializable(typeof(FolderChildrenResponse))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs index 5ceba20b..f5e96bf1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -2,6 +2,7 @@ using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Serialization; @@ -20,6 +21,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(Share))] [JsonSerializable(typeof(FolderNode))] [JsonSerializable(typeof(CachedNodeInfo))] +[JsonSerializable(typeof(VolumeId?))] [JsonSerializable(typeof(SerializableRefResult))] [JsonSerializable(typeof(SerializableValResult))] [JsonSerializable(typeof(SerializableRefResult))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index 22a3b322..8bd52f98 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -6,16 +6,18 @@ namespace Proton.Drive.Sdk.Shares; internal static class ShareOperations { - public static async ValueTask GetShareAsync( - ProtonDriveClient client, - ShareId shareId, - CancellationToken cancellationToken) + public static async ValueTask GetShareAsync(ProtonDriveClient client, ShareId shareId, bool useCacheOnly, CancellationToken cancellationToken) { var share = await client.Cache.Entities.TryGetShareAsync(shareId, cancellationToken).ConfigureAwait(false); var shareKey = await client.Cache.Secrets.TryGetShareKeyAsync(shareId, cancellationToken).ConfigureAwait(false); if (share is null || shareKey is null) { + if (useCacheOnly) + { + throw new ProtonDriveException("Share \"{shareId}\" not found in cache"); + } + var response = await client.Api.Shares.GetShareAsync(shareId, cancellationToken).ConfigureAwait(false); var rootFolderId = new NodeUid(response.VolumeId, response.RootLinkId); @@ -37,12 +39,20 @@ public static async ValueTask GetShareAsync( return new ShareAndKey(share, shareKey.Value); } + public static async ValueTask> GetSharesAsync(ProtonDriveClient client, ShareType? typeFilter, CancellationToken cancellationToken) + { + var response = await client.Api.Shares.GetSharesAsync(typeFilter, cancellationToken).ConfigureAwait(false); + + return response.Shares.Select(dto => new Share(dto.Id, new NodeUid(dto.VolumeId, dto.RootLinkId), default, dto.Type)).ToList(); + } + public static async ValueTask GetContextShareAsync( ProtonDriveClient client, Result nodeResult, + bool useCacheOnly, CancellationToken cancellationToken) { - var contextRoot = await TraversalOperations.FindRootForNode(client, nodeResult, cancellationToken).ConfigureAwait(false); + var contextRoot = await TraversalOperations.FindRootForNode(client, nodeResult, useCacheOnly, cancellationToken).ConfigureAwait(false); var contextShareId = contextRoot.Merge(x => x.MembershipShareId, x => x.MembershipShareId); if (!contextShareId.HasValue) @@ -50,6 +60,6 @@ public static async ValueTask GetContextShareAsync( throw new ProtonDriveException("Node does not have a valid context share"); } - return await ShareOperations.GetShareAsync(client, (ShareId)contextShareId, cancellationToken).ConfigureAwait(false); + return await GetShareAsync(client, (ShareId)contextShareId, useCacheOnly, cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareType.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareType.cs similarity index 68% rename from cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareType.cs rename to cs/sdk/src/Proton.Drive.Sdk/Shares/ShareType.cs index a26d9f2f..8243d1c0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareType.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareType.cs @@ -1,4 +1,4 @@ -namespace Proton.Drive.Sdk.Api.Shares; +namespace Proton.Drive.Sdk.Shares; public enum ShareType { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs index ab9dcc0c..78cf46e4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs @@ -1,3 +1,4 @@ +using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk.Telemetry; @@ -14,5 +15,5 @@ public sealed class DecryptionErrorEvent : IMetricEvent public string? Error { get; init; } - public required string Uid { get; init; } + public required NodeUid Uid { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs index a745514f..a63ed601 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs @@ -1,5 +1,5 @@ using Proton.Drive.Sdk.Nodes; -using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; namespace Proton.Drive.Sdk.Telemetry; @@ -12,22 +12,21 @@ internal static class TelemetryEventFactory ///
public static async Task> CreateDecryptionErrorEventsAsync( ProtonDriveClient client, - DegradedNodeMetadata degradedNode, + DegradedNode degradedNode, IEnumerable failedFields, CancellationToken cancellationToken) { - // FIXME won't work for photos in an album, this will need to be differentiated for photos. - var share = await ShareOperations.GetContextShareAsync(client, degradedNode, cancellationToken).ConfigureAwait(false); - var fromBefore2024 = degradedNode.Node.CreationTime.CompareTo(LegacyBoundary) < 1; + var fromBefore2024 = degradedNode.CreationTime.CompareTo(LegacyBoundary) < 1; + + var volumeType = await ResolveVolumeTypeAsync(client, degradedNode.Uid, cancellationToken).ConfigureAwait(false); return failedFields.Select(field => new DecryptionErrorEvent { - Uid = degradedNode.Node.Uid.ToString(), + Uid = degradedNode.Uid, Field = field, - VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), + VolumeType = volumeType, FromBefore2024 = fromBefore2024, - Error = string.Empty, - }).ToList(); + }); } /// @@ -40,16 +39,12 @@ public static async Task CreateDecryptionErrorEventAsync( DateTime creationTime, CancellationToken cancellationToken) { - var nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, nodeUid, null, cancellationToken).ConfigureAwait(false); - var share = await ShareOperations.GetContextShareAsync(client, nodeResult, cancellationToken).ConfigureAwait(false); - return new DecryptionErrorEvent { - Uid = nodeUid.ToString(), + Uid = nodeUid, Field = field, - VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), + VolumeType = await ResolveVolumeTypeAsync(client, nodeUid, cancellationToken).ConfigureAwait(false), FromBefore2024 = creationTime.CompareTo(LegacyBoundary) < 1, - Error = string.Empty, }; } @@ -63,16 +58,12 @@ public static async Task CreateVerificationErrorEventAsy DateTime creationTime, CancellationToken cancellationToken) { - var nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, nodeUid, null, cancellationToken).ConfigureAwait(false); - var share = await ShareOperations.GetContextShareAsync(client, nodeResult, cancellationToken).ConfigureAwait(false); - return new VerificationErrorEvent { - Uid = nodeUid.ToString(), + Uid = nodeUid, Field = field, - VolumeType = VolumeTypeFactory.FromShareType(share.Share.Type), + VolumeType = await ResolveVolumeTypeAsync(client, nodeUid, cancellationToken).ConfigureAwait(false), FromBefore2024 = creationTime.CompareTo(LegacyBoundary) < 1, - Error = string.Empty, }; } @@ -117,14 +108,25 @@ internal static async Task ResolveVolumeTypeAsync( { try { - var nodeResult = await NodeOperations.GetNodeMetadataResultAsync(client, nodeUid, null, cancellationToken).ConfigureAwait(false); - var share = await ShareOperations.GetContextShareAsync(client, nodeResult, cancellationToken).ConfigureAwait(false); + var mainVolumeId = await VolumeOperations.TryGetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); + + if (mainVolumeId is not null && nodeUid.VolumeId == mainVolumeId) + { + return VolumeType.OwnVolume; + } + + var photosVolumeId = await VolumeOperations.TryGetPhotosVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); + + if (photosVolumeId is not null && nodeUid.VolumeId == photosVolumeId) + { + return VolumeType.OwnPhotosVolume; + } - return VolumeTypeFactory.FromShareType(share.Share.Type); + return VolumeType.Shared; } catch { - return VolumeType.OwnVolume; + return VolumeType.Unknown; } } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs index ecc2b2c7..52d39645 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs @@ -9,7 +9,7 @@ internal static class TelemetryRecorder /// public static async Task TryRecordDecryptionErrorAsync( ProtonDriveClient client, - DegradedNodeMetadata degradedNode, + DegradedNode degradedNode, IEnumerable failedFields, CancellationToken cancellationToken) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs index a830ee9c..2e606a29 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs @@ -1,3 +1,4 @@ +using Proton.Drive.Sdk.Nodes; using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk.Telemetry; @@ -16,5 +17,5 @@ public sealed class VerificationErrorEvent : IMetricEvent public string? Error { get; set; } - public required string Uid { get; set; } + public required NodeUid Uid { get; set; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs index e381d6c1..ea53151c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs @@ -2,8 +2,9 @@ namespace Proton.Drive.Sdk.Telemetry; public enum VolumeType { + Unknown, OwnVolume, + OwnPhotosVolume, Shared, SharedPublic, - OwnPhotoVolume, } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeTypeFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeTypeFactory.cs deleted file mode 100644 index 272047a4..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeTypeFactory.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Proton.Drive.Sdk.Api.Shares; - -namespace Proton.Drive.Sdk.Telemetry; - -internal static class VolumeTypeFactory -{ - internal static VolumeType FromShareType(ShareType shareType) - { - return shareType switch - { - ShareType.Main => VolumeType.OwnVolume, - ShareType.Photos => VolumeType.OwnPhotoVolume, - ShareType.Standard => VolumeType.Shared, - ShareType.Device => VolumeType.OwnVolume, - _ => throw new ArgumentOutOfRangeException(nameof(shareType), shareType, "Unknown share type"), - }; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs index 9c916e16..ad133e18 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk.Volumes; [JsonConverter(typeof(StrongIdJsonConverter))] -public readonly record struct VolumeId : IStrongId +internal readonly record struct VolumeId : IStrongId { private readonly string? _value; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 74e40e74..7bf93cef 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -1,7 +1,6 @@ using System.Runtime.CompilerServices; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Photos; -using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes; @@ -59,19 +58,11 @@ internal static class VolumeOperations return (volume, share, rootFolder); } - public static async ValueTask GetVolumeMainShareIdAsync(ProtonDriveClient client, VolumeId volumeId, CancellationToken cancellationToken) - { - var response = await client.Api.Volumes.GetVolumeAsync(volumeId, cancellationToken).ConfigureAwait(false); - - return response.Volume.Share.ShareId; - } - public static async IAsyncEnumerable> EnumerateTrashAsync( ProtonDriveClient client, + VolumeId volumeId, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var volumeId = await GetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); - var page = 0; var mustTryMoreResults = true; @@ -83,14 +74,13 @@ public static async IAsyncEnumerable> EnumerateTrashA foreach (var (shareId, linkIds, _) in response.TrashByShare) { - var (_, shareKey) = await ShareOperations.GetShareAsync(client, shareId, cancellationToken).ConfigureAwait(false); + var (_, shareKey) = await ShareOperations.GetShareAsync(client, shareId, useCacheOnly: false, cancellationToken).ConfigureAwait(false); var batchLoader = new VolumeTrashBatchLoader(client, volumeId, shareKey); foreach (var linkId in linkIds) { var uid = new NodeUid(volumeId, linkId); - var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); if (cachedNodeInfo is null) @@ -117,18 +107,18 @@ public static async IAsyncEnumerable> EnumerateTrashA } public static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreatePhotosVolumeAsync( - ProtonPhotosClient photosClient, + ProtonDriveClient client, CancellationToken cancellationToken) { - var defaultAddress = await photosClient.DriveClient.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); + var defaultAddress = await client.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); - var addressKey = await photosClient.DriveClient.Account.GetAddressPrimaryPrivateKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); + var addressKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(defaultAddress.Id, cancellationToken).ConfigureAwait(false); var addressKeyId = defaultAddress.GetPrimaryKey().AddressKeyId; var request = GetPhotosCreationRequest(defaultAddress.Id, addressKeyId, addressKey, out var rootShareKey, out var rootFolderSecrets); - var response = await photosClient.PhotosApi.CreateVolumeAsync(request, cancellationToken).ConfigureAwait(false); + var response = await client.Api.Photos.CreateVolumeAsync(request, cancellationToken).ConfigureAwait(false); var volume = new Volume(response.Volume); @@ -148,25 +138,56 @@ public static async IAsyncEnumerable> EnumerateTrashA // The volume root folder never has siblings and does not need a name hash digest var nameHashDigest = ReadOnlyMemory.Empty; - await photosClient.Cache.Entities.SetPhotosVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetPhotosVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); - await photosClient.DriveClient.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share.Id, nameHashDigest, cancellationToken) + await client.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share.Id, nameHashDigest, cancellationToken) .ConfigureAwait(false); - await photosClient.Cache.Entities.SetPhotosShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); - await photosClient.DriveClient.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetPhotosShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); - await photosClient.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); - await photosClient.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); return (volume, share, rootFolder); } public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, CancellationToken cancellationToken) { - var volumeId = await GetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); + var volumeId = await TryGetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); + if (volumeId is null) + { + // No trash to empty if the main volume doesn't exist + return; + } + + await client.Api.Trash.EmptyAsync(volumeId.Value, cancellationToken).ConfigureAwait(false); + } + + public static async ValueTask TryGetMainVolumeIdAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var (cacheEntryExists, volumeId) = await client.Cache.Entities.TryGetMainVolumeIdAsync(cancellationToken).ConfigureAwait(false); + if (cacheEntryExists) + { + return volumeId; + } + + var myFilesFolder = await NodeOperations.TryGetExistingMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); + + return myFilesFolder?.Uid.VolumeId; + } + + public static async ValueTask TryGetPhotosVolumeIdAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var (cacheEntryExists, volumeId) = await client.Cache.Entities.TryGetPhotosVolumeIdAsync(cancellationToken).ConfigureAwait(false); + if (cacheEntryExists) + { + return volumeId; + } + + var myFilesFolder = await PhotosNodeOperations.TryGetExistingPhotosFolderAsync(client, cancellationToken).ConfigureAwait(false); - await client.Api.Trash.EmptyAsync(volumeId, cancellationToken).ConfigureAwait(false); + return myFilesFolder?.Uid.VolumeId; } private static VolumeCreationRequest GetCreationRequest( @@ -282,12 +303,4 @@ private static PhotosVolumeCreationRequest GetPhotosCreationRequest( }, }; } - - private static async ValueTask GetMainVolumeIdAsync(ProtonDriveClient client, CancellationToken cancellationToken) - { - // TODO: optimize this, which is overkill to just get the volume ID - var myFilesFolder = await NodeOperations.GetMyFilesFolderAsync(client, cancellationToken).ConfigureAwait(false); - - return myFilesFolder.Uid.VolumeId; - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs index 95e41c78..727415eb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -46,8 +46,6 @@ protected override async ValueTask>> Lo var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( _client, - _client.Cache.Entities, - _client.Cache.Secrets, _volumeId, linkDetails, parentKey, diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 12574738..20727c22 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -782,10 +782,11 @@ message DrivePhotosClientUploaderFreeRequest { } enum VolumeType { - VOLUME_TYPE_OWN_VOLUME = 0; - VOLUME_TYPE_SHARED = 1; - VOLUME_TYPE_SHARED_PUBLIC = 2; - VOLUME_TYPE_OWN_PHOTO_VOLUME = 3; + VOLUME_TYPE_UNKNOWN = 0; + VOLUME_TYPE_OWN_VOLUME = 1; + VOLUME_TYPE_SHARED = 2; + VOLUME_TYPE_SHARED_PUBLIC = 3; + VOLUME_TYPE_OWN_PHOTO_VOLUME = 4; } enum DownloadError { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt index 10b81b78..68252abf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt @@ -4,6 +4,7 @@ import me.proton.drive.sdk.telemetry.VolumeType import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.VolumeType.toEnum() = when (this) { + ProtonDriveSdk.VolumeType.VOLUME_TYPE_UNKNOWN -> VolumeType.UNKNOWN ProtonDriveSdk.VolumeType.VOLUME_TYPE_OWN_VOLUME -> VolumeType.OWN_VOLUME ProtonDriveSdk.VolumeType.VOLUME_TYPE_SHARED -> VolumeType.SHARED ProtonDriveSdk.VolumeType.VOLUME_TYPE_SHARED_PUBLIC -> VolumeType.SHARED_PUBLIC diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt index 7ab38495..7b559508 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt @@ -2,6 +2,7 @@ package me.proton.drive.sdk.telemetry enum class VolumeType { UNRECOGNIZED, + UNKNOWN, OWN_VOLUME, SHARED, SHARED_PUBLIC, diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift index 356ac1b6..38b7a37b 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -137,14 +137,17 @@ public struct VerificationErrorEventPayload: Sendable { public enum VolumeType: Int, Sendable { - case unknown = -1 - case ownVolume = 0 - case shared = 1 - case sharedPublic = 2 - case ownPhotoVolume = 3 + case unrecognized = -1 + case unknown = 0 + case ownVolume = 1 + case shared = 2 + case sharedPublic = 3 + case ownPhotoVolume = 4 init(sdkVolumeType: Proton_Drive_Sdk_VolumeType) { switch sdkVolumeType { + case .unknown: + self = .unknown case .ownVolume: self = .ownVolume case .shared: @@ -155,7 +158,7 @@ public enum VolumeType: Int, Sendable { self = .ownPhotoVolume case .UNRECOGNIZED(let value): assertionFailure("Received unrecognized VolumeType from the SDK \(value)") - self = .unknown + self = .unrecognized } } } From b9b9398bcc87a1b17624d7c1620b49b85e3abf7d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 08:54:17 +0100 Subject: [PATCH 615/791] Make unknown telemetry volume type explicit --- js/sdk/src/interface/telemetry.ts | 9 +++++---- js/sdk/src/internal/download/telemetry.ts | 4 ++-- js/sdk/src/internal/nodes/cryptoReporter.ts | 6 ++++-- js/sdk/src/internal/upload/telemetry.ts | 4 ++-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 282646d2..6d8b0e21 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -34,7 +34,7 @@ export interface MetricDebounceLongWaitEvent { export interface MetricUploadEvent { eventName: 'upload'; - volumeType?: MetricVolumeType; + volumeType: MetricVolumeType; uploadedSize: number; approximateUploadedSize: number; expectedSize: number; @@ -52,7 +52,7 @@ export type MetricsUploadErrorType = export interface MetricDownloadEvent { eventName: 'download'; - volumeType?: MetricVolumeType; + volumeType: MetricVolumeType; downloadedSize: number; approximateDownloadedSize: number; claimedFileSize?: number; @@ -71,7 +71,7 @@ export type MetricsDownloadErrorType = export interface MetricDecryptionErrorEvent { eventName: 'decryptionError'; - volumeType?: MetricVolumeType; + volumeType: MetricVolumeType; field: MetricsDecryptionErrorField; fromBefore2024?: boolean; error?: unknown; @@ -89,7 +89,7 @@ export type MetricsDecryptionErrorField = export interface MetricVerificationErrorEvent { eventName: 'verificationError'; - volumeType?: MetricVolumeType; + volumeType: MetricVolumeType; field: MetricVerificationErrorField; addressMatchingDefaultShare?: boolean; fromBefore2024?: boolean; @@ -118,6 +118,7 @@ export interface MetricVolumeEventsSubscriptionsChangedEvent { } export enum MetricVolumeType { + Unknown = 'unknown', OwnVolume = 'own_volume', OwnPhotoVolume = 'own_photo_volume', Shared = 'shared', diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index fda2f133..fbf21b69 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -1,5 +1,5 @@ import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from '../../errors'; -import { ProtonDriveTelemetry, MetricsDownloadErrorType, Logger } from '../../interface'; +import { ProtonDriveTelemetry, MetricsDownloadErrorType, Logger, MetricVolumeType } from '../../interface'; import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry'; import { APIHTTPError } from '../apiService'; import { splitNodeRevisionUid, splitNodeUid } from '../uids'; @@ -73,7 +73,7 @@ export class DownloadTelemetry { originalError?: unknown; }, ) { - let volumeType; + let volumeType = MetricVolumeType.Unknown; try { volumeType = await this.sharesService.getVolumeMetricContext(volumeId); } catch (error: unknown) { diff --git a/js/sdk/src/internal/nodes/cryptoReporter.ts b/js/sdk/src/internal/nodes/cryptoReporter.ts index 430cd2ef..6238bdba 100644 --- a/js/sdk/src/internal/nodes/cryptoReporter.ts +++ b/js/sdk/src/internal/nodes/cryptoReporter.ts @@ -8,6 +8,7 @@ import { Logger, MetricsDecryptionErrorField, MetricVerificationErrorField, + MetricVolumeType, } from '../../interface'; import { getVerificationMessage, isNotApplicationError } from '../errors'; import { splitNodeUid } from '../uids'; @@ -63,7 +64,8 @@ export class NodesCryptoReporter { const fromBefore2024 = node.creationTime < new Date('2024-01-01'); - let addressMatchingDefaultShare, volumeType; + let addressMatchingDefaultShare, + volumeType = MetricVolumeType.Unknown; try { const { volumeId } = splitNodeUid(node.uid); const { email } = await this.shareService.getMyFilesShareMemberEmailKey(); @@ -99,7 +101,7 @@ export class NodesCryptoReporter { const fromBefore2024 = node.creationTime < new Date('2024-01-01'); - let volumeType; + let volumeType = MetricVolumeType.Unknown; try { const { volumeId } = splitNodeUid(node.uid); volumeType = await this.shareService.getVolumeMetricContext(volumeId); diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index a9e849e0..7cb551b5 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -1,5 +1,5 @@ import { RateLimitedError, ValidationError, IntegrityError } from '../../errors'; -import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from '../../interface'; +import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger, MetricVolumeType } from '../../interface'; import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry'; import { APIHTTPError } from '../apiService'; import { splitNodeUid, splitNodeRevisionUid } from '../uids'; @@ -85,7 +85,7 @@ export class UploadTelemetry { originalError?: unknown; }, ) { - let volumeType; + let volumeType = MetricVolumeType.Unknown; try { volumeType = await this.sharesService.getVolumeMetricContext(volumeId); } catch (error: unknown) { From 14092be6f16d434ea6cdea17001847d20708f929 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 07:51:21 +0000 Subject: [PATCH 616/791] Update changelog for cs/v0.9.0 --- cs/CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 6945f29e..0f9953d4 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## cs/v0.9.0 (2026-03-20) + +* Fail node provision when parent key could not be obtained +* Try all album inclusions to find the entry point key +* Handle missing timestamps in photo upload metadata +* Improve error details for drive errors +* Remove failing test data +* Fix telemetry causing deadlock on uploads and downloads +* Expose structured data on upload integrity errors +* Throw error if node is not found +* Parse enumerate result synchronously +* Clarify exception for missing node when looking up entry point +* Fix setup for timeouts in test +* Log number of ids when enumerate thumbnails + ## cs/v0.8.1 (2026-03-16) * Fix disposal of upload controller and update upload bindings api From a7c9cc0c4ad4dbd54e435c967cae124be7f833f5 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 09:38:01 +0100 Subject: [PATCH 617/791] Improve error details for node decryption failures --- .../Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs | 6 +- .../Nodes/AuthorshipClaimExtensions.cs | 4 +- .../Cryptography/FileDecryptionResult.cs | 4 +- .../Cryptography/FolderDecryptionResult.cs | 2 +- .../Cryptography/LinkDecryptionResult.cs | 6 +- .../Nodes/Cryptography/NodeCrypto.cs | 88 +++++++++---------- .../Cryptography/PhasedDecryptionOutput.cs | 2 +- .../Nodes/DtoToMetadataConverter.cs | 20 ++--- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 6 +- .../Nodes/SignatureVerificationError.cs | 12 ++- .../Nodes/Upload/NewRevisionDraftProvider.cs | 2 +- 11 files changed, 76 insertions(+), 76 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs index c8722c53..42c1fd11 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs @@ -2,13 +2,13 @@ namespace Proton.Drive.Sdk.Nodes; -internal readonly struct AuthorshipClaim(Author author, IReadOnlyList keys, string? keyRetrievalErrorMessage = null) +internal readonly struct AuthorshipClaim(Author author, IReadOnlyList keys, ProtonDriveError? keyRetrievalError = null) { public readonly IReadOnlyList Keys { get; } = keys; public Author Author { get; } = author; - public string? KeyRetrievalErrorMessage { get; } = keyRetrievalErrorMessage; + public ProtonDriveError? KeyRetrievalError { get; } = keyRetrievalError; public static async ValueTask CreateAsync( IAccountClient accountClient, @@ -28,7 +28,7 @@ public static async ValueTask CreateAsync( } catch (Exception e) { - return new AuthorshipClaim(new Author { EmailAddress = claimedAuthorEmailAddress }, [], e.Message); + return new AuthorshipClaim(new Author { EmailAddress = claimedAuthorEmailAddress }, [], e.ToProtonDriveError()); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs index 47dfc81c..4137189d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs @@ -11,9 +11,9 @@ public static Result ToAuthorshipResult( { if (verificationFailure is not null) { - var errorMessage = authorshipClaim.KeyRetrievalErrorMessage ?? verificationFailure.Value.Message; + var error = authorshipClaim.KeyRetrievalError ?? verificationFailure.Value.Error; - return new SignatureVerificationError(authorshipClaim.Author, verificationFailure.Value.Status, errorMessage); + return new SignatureVerificationError(authorshipClaim.Author, verificationFailure.Value.Status, "Authorship failure", error); } return authorshipClaim.Author; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs index 1d2cd9ca..95d53d2b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs @@ -7,7 +7,7 @@ namespace Proton.Drive.Sdk.Nodes.Cryptography; internal sealed class FileDecryptionResult { public required LinkDecryptionResult Link { get; init; } - public required Result, string?> ContentKey { get; init; } - public required Result, string?> ExtendedAttributes { get; init; } + public required Result, ProtonDriveError> ContentKey { get; init; } + public required Result, ProtonDriveError> ExtendedAttributes { get; init; } public required AuthorshipClaim ContentAuthorshipClaim { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs index c017812a..49b3f4b8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs @@ -5,5 +5,5 @@ namespace Proton.Drive.Sdk.Nodes.Cryptography; internal sealed class FolderDecryptionResult { public required LinkDecryptionResult Link { get; init; } - public required Result>, string?> HashKey { get; init; } + public required Result>, ProtonDriveError> HashKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs index 920cf8b6..b8c52848 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs @@ -5,9 +5,9 @@ namespace Proton.Drive.Sdk.Nodes.Cryptography; internal sealed class LinkDecryptionResult { - public required Result>, string> Passphrase { get; init; } + public required Result>, ProtonDriveError> Passphrase { get; init; } public required AuthorshipClaim NodeAuthorshipClaim { get; init; } - public required Result, string> Name { get; init; } + public required Result, ProtonDriveError> Name { get; init; } public required AuthorshipClaim NameAuthorshipClaim { get; init; } - public required Result NodeKey { get; init; } + public required Result NodeKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index c666931c..26f4f21d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -21,9 +21,7 @@ public static async ValueTask DecryptFolderAsync( { var linkDecryptionResult = await DecryptLinkAsync(accountClient, link, parentKey, cancellationToken).ConfigureAwait(false); - var hashKeyResult = linkDecryptionResult.NodeKey.TryGetValue(out var nodeKey) - ? DecryptHashKey(folderHashKey, nodeKey, linkDecryptionResult.NodeAuthorshipClaim) - : "Node key not available"; + var hashKeyResult = DecryptHashKey(folderHashKey, linkDecryptionResult.NodeKey, linkDecryptionResult.NodeAuthorshipClaim); return new FolderDecryptionResult { @@ -45,15 +43,16 @@ public static async ValueTask DecryptFileAsync( var linkDecryptionResult = await DecryptLinkAsync(accountClient, linkDto, parentKey, cancellationToken).ConfigureAwait(false); - var nodeKey = linkDecryptionResult.NodeKey.Merge(x => x, _ => default(PgpPrivateKey?)); - var contentKeyDecryptionResult = DecryptContentKey( - nodeKey, + linkDecryptionResult.NodeKey, fileDto.ContentKeyPacket, fileDto.ContentKeySignature, linkDecryptionResult.NodeAuthorshipClaim); - var extendedAttributesResult = DecryptExtendedAttributes(activeRevisionDto.ExtendedAttributes, nodeKey, contentAuthorshipClaim); + var extendedAttributesResult = DecryptExtendedAttributes( + activeRevisionDto.ExtendedAttributes, + linkDecryptionResult.NodeKey, + contentAuthorshipClaim); return new FileDecryptionResult { @@ -95,7 +94,7 @@ private static async ValueTask DecryptLinkAsync( var nameResult = DecryptName(link.Name, parentKey, nameAuthorshipClaim); var passphraseResult = DecryptPassphrase(parentKey, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); - var nodeKeyResult = UnlockNodeKey(link.Key, passphraseResult.Merge(x => (ReadOnlyMemory?)x.Data, _ => null)); + var nodeKeyResult = UnlockNodeKey(link.Key, passphraseResult); return new LinkDecryptionResult { @@ -107,7 +106,7 @@ private static async ValueTask DecryptLinkAsync( }; } - private static Result>, string> DecryptPassphrase( + private static Result>, ProtonDriveError> DecryptPassphrase( PgpPrivateKey parentNodeKey, PgpArmoredMessage encryptedPassphrase, PgpArmoredSignature? signature, @@ -127,28 +126,30 @@ private static Result>, string> Decr } catch (Exception e) { - return e.Message; + return new ProtonDriveError("Failed to decrypt passphrase", e.ToProtonDriveError()); } } - private static Result UnlockNodeKey(PgpArmoredPrivateKey lockedKey, ReadOnlyMemory? passphrase) + private static Result UnlockNodeKey( + PgpArmoredPrivateKey lockedKey, + Result>, ProtonDriveError> passphraseResult) { - if (passphrase is null) + if (!passphraseResult.TryGetValueElseError(out var passphrase, out var error)) { - return null; + return new ProtonDriveError("Cannot get passphrase", error); } try { - return PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase.Value.Span); + return PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase.Data.Span); } catch (Exception e) { - return e.Message; + return new ProtonDriveError("Failed to import and unlock passphrase", e.ToProtonDriveError()); } } - private static Result, string> DecryptName( + private static Result, ProtonDriveError> DecryptName( PgpArmoredMessage encryptedName, PgpPrivateKey parentNodeKey, AuthorshipClaim authorshipClaim) @@ -169,34 +170,29 @@ private static Result, string> DecryptName( } catch (Exception e) { - return e.Message; + return new ProtonDriveError("Failed to decrypt name", e.ToProtonDriveError()); } } - private static Result>, string?> DecryptHashKey( - PgpArmoredMessage? encryptedHashKey, - PgpPrivateKey? nodeKey, + private static Result>, ProtonDriveError> DecryptHashKey( + PgpArmoredMessage encryptedHashKey, + Result nodeKeyResult, AuthorshipClaim authorshipClaim) { - if (nodeKey is null) - { - return null; - } - - if (encryptedHashKey is null) + if (nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) { - return "Folder information missing for link of type Folder"; + return new ProtonDriveError("Cannot decrypt hash key without node key", error); } try { - var verificationKeyRing = GetContentKeyAndHashKeyVerificationKeyRing(nodeKey.Value, authorshipClaim); - var hashKey = DecryptMessage(encryptedHashKey.Value, detachedSignature: null, nodeKey.Value, verificationKeyRing, out _, out var author); + var verificationKeyRing = GetContentKeyAndHashKeyVerificationKeyRing(nodeKey, authorshipClaim); + var hashKey = DecryptMessage(encryptedHashKey, detachedSignature: null, nodeKey, verificationKeyRing, out _, out var author); return new DecryptionOutput>(hashKey, author); } catch (Exception e) { - return e.Message; + return new ProtonDriveError("Failed to decrypt hash key", e.ToProtonDriveError()); } } @@ -212,28 +208,28 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK return keyRing; } - private static Result, string?> DecryptContentKey( - PgpPrivateKey? nodeKey, + private static Result, ProtonDriveError> DecryptContentKey( + Result nodeKeyResult, ReadOnlyMemory contentKeyPacket, PgpArmoredSignature? contentKeySignature, AuthorshipClaim nodeAuthorshipClaim) { - if (nodeKey is null) + if (!nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) { - return null; + return new ProtonDriveError("Cannot get node key", error); } PgpSessionKey contentKey; try { - contentKey = nodeKey.Value.DecryptSessionKey(contentKeyPacket.Span); + contentKey = nodeKey.DecryptSessionKey(contentKeyPacket.Span); } catch (Exception e) { - return e.Message; + return new ProtonDriveError("Cannot decrypt session key", e.ToProtonDriveError()); } - var verificationKeyRing = GetContentKeyAndHashKeyVerificationKeyRing(nodeKey.Value, nodeAuthorshipClaim); + var verificationKeyRing = GetContentKeyAndHashKeyVerificationKeyRing(nodeKey, nodeAuthorshipClaim); AuthorshipVerificationFailure? verificationFailure; try @@ -248,15 +244,15 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK } catch (Exception e) { - verificationFailure = new AuthorshipVerificationFailure(PgpVerificationStatus.Failed, e.Message); + verificationFailure = new AuthorshipVerificationFailure(PgpVerificationStatus.Failed, e.ToProtonDriveError()); } return new DecryptionOutput(contentKey, verificationFailure); } - private static Result, string?> DecryptExtendedAttributes( + private static Result, ProtonDriveError> DecryptExtendedAttributes( PgpArmoredMessage? encryptedExtendedAttributes, - PgpPrivateKey? nodeKey, + Result nodeKeyResult, AuthorshipClaim authorshipClaim) { if (encryptedExtendedAttributes is null) @@ -264,9 +260,9 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK return new DecryptionOutput(null); } - if (nodeKey is null) + if (!nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) { - return null; + return new ProtonDriveError("Cannot get node key", error); } try @@ -274,8 +270,8 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK var serializedExtendedAttributes = DecryptMessage( encryptedExtendedAttributes.Value, detachedSignature: null, - nodeKey.Value, - authorshipClaim.GetKeyRing(nodeKey.Value), + nodeKey, + authorshipClaim.GetKeyRing(nodeKey), out _, out var author); @@ -287,12 +283,12 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK } catch (Exception e) { - return $"Failed to deserialize extended attributes: {e.Message}"; + return new ProtonDriveError("Failed to deserialize extended attributes", e.ToProtonDriveError()); } } catch (Exception e) { - return e.Message; + return new ProtonDriveError("Failed to decrypt extended attributes", e.ToProtonDriveError()); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs index 1360ec9b..ae5199ee 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs @@ -7,4 +7,4 @@ internal readonly record struct PhasedDecryptionOutput( TData Data, AuthorshipVerificationFailure? AuthorshipVerificationFailure = null); -internal readonly record struct AuthorshipVerificationFailure(PgpVerificationStatus Status, string? Message = null); +internal readonly record struct AuthorshipVerificationFailure(PgpVerificationStatus Status, ProtonDriveError? Error = null); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 846297f1..e14de17e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -298,12 +298,12 @@ private static (DegradedFileMetadata Metadata, List FailedDecryp if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { - errors.Add(new DecryptionError(passphraseError)); + errors.Add(new DecryptionError("Passphrase decryption failed", passphraseError)); failedDecryptionFields.Add(EncryptedField.NodeKey); } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { - errors.Add(new DecryptionError(nodeKeyError)); + errors.Add(new DecryptionError("Node key decryption failed", nodeKeyError)); failedDecryptionFields.Add(EncryptedField.NodeKey); } else if (decryptionResult.ContentKey.IsFailure) @@ -319,21 +319,21 @@ private static (DegradedFileMetadata Metadata, List FailedDecryp var revisionErrors = new List(); if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) { - revisionErrors.Add(new DecryptionError(extendedAttributesError)); + revisionErrors.Add(new DecryptionError("Extended attributes decryption failed", extendedAttributesError)); failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes); } var nodeAuthor = decryptionResult.Link.Passphrase.Merge( x => decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), - _ => new SignatureVerificationError(decryptionResult.Link.NodeAuthorshipClaim.Author, "Passphrase decryption failed")); + error => new SignatureVerificationError(decryptionResult.Link.NodeAuthorshipClaim.Author, "Passphrase decryption failed", error)); var nameAuthor = decryptionResult.Link.Name.Merge( x => decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), - _ => new SignatureVerificationError(decryptionResult.Link.NameAuthorshipClaim.Author, "Name decryption failed")); + error => new SignatureVerificationError(decryptionResult.Link.NameAuthorshipClaim.Author, "Name decryption failed", error)); var contentAuthor = decryptionResult.ContentKey.Merge( x => decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), - _ => new SignatureVerificationError(decryptionResult.ContentAuthorshipClaim.Author, "Content key decryption failed")); + error => new SignatureVerificationError(decryptionResult.ContentAuthorshipClaim.Author, "Content key decryption failed", error)); var degradedRevision = new DegradedRevision { @@ -494,17 +494,17 @@ private static (DegradedFolderMetadata Metadata, List FailedDecr if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { - errors.Add(new DecryptionError(passphraseError)); + errors.Add(new DecryptionError("Passphrase decryption failed", passphraseError)); failedDecryptionFields.Add(EncryptedField.NodeKey); } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { - errors.Add(new DecryptionError(nodeKeyError)); + errors.Add(new DecryptionError("Node key decryption failed", nodeKeyError)); failedDecryptionFields.Add(EncryptedField.NodeKey); } - else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) + else if (decryptionResult.HashKey.TryGetError(out var error)) { - errors.Add(new DecryptionError(hashKeyError)); + errors.Add(new DecryptionError("Hash key decryption failed", error)); failedDecryptionFields.Add(EncryptedField.NodeHashKey); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 2a9093b3..43c0615f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -539,15 +539,15 @@ public static async ValueTask
GetMembershipAddressAsync(ProtonDriveClie } public static bool ValidateName( - Result, string> decryptionResult, + Result, ProtonDriveError> decryptionResult, [NotNullWhen(true)] out PhasedDecryptionOutput? nameOutput, out Result nameResult, [NotNullWhen(true)] out PgpSessionKey? sessionKey) { - if (!decryptionResult.TryGetValueElseError(out var nameOutputValue, out var decryptionErrorMessage)) + if (!decryptionResult.TryGetValueElseError(out var nameOutputValue, out var decryptionError)) { nameOutput = null; - nameResult = new DecryptionError(decryptionErrorMessage); + nameResult = new DecryptionError("Name decryption failed", decryptionError); sessionKey = null; return false; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs index da13822a..52b16e89 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs @@ -4,11 +4,15 @@ namespace Proton.Drive.Sdk.Nodes; [method: JsonConstructor] -public sealed class SignatureVerificationError(Author claimedAuthor, string? message = null) - : ProtonDriveError(message) +public sealed class SignatureVerificationError(Author claimedAuthor, string? message = null, ProtonDriveError? innerError = null) + : ProtonDriveError(message, innerError) { - public SignatureVerificationError(Author claimedAuthor, PgpVerificationStatus? verificationStatus = null, string? message = null) - : this(claimedAuthor, GetMessage(verificationStatus, message)) + public SignatureVerificationError( + Author claimedAuthor, + PgpVerificationStatus? verificationStatus = null, + string? message = null, + ProtonDriveError? innerError = null) + : this(claimedAuthor, GetMessage(verificationStatus, message), innerError) { } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index d5942aa0..e35b532e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -57,7 +57,7 @@ public async ValueTask GetDraftAsync(CancellationToken cancellati } catch (ProtonApiException e) { - throw new RevisionDraftConflictException(e); + throw new RevisionDraftConflictException("Cannot create revision", e); } } From 5339c8d7f6dc6ab294ab3a7ac4eada08a0385947 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 13:29:13 +0100 Subject: [PATCH 618/791] Fix wrong link details endpoint being used for Photos --- cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs | 9 --------- .../src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs | 6 +++++- .../src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs | 4 ++-- .../Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs | 3 ++- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs | 7 +++++-- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs | 6 ++++-- .../src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs | 4 ++-- cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs | 6 +++--- cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs | 4 ++-- .../Serialization/PhotosApiSerializerContext.cs | 2 -- 11 files changed, 26 insertions(+), 27 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs deleted file mode 100644 index 229e119c..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/PhotoDetailsResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Proton.Drive.Sdk.Api.Links; -using Proton.Sdk.Api; - -namespace Proton.Drive.Sdk.Api; - -internal sealed class PhotoDetailsResponse : ApiResponse -{ - public required IReadOnlyList Links { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs index f7d6f36b..abaa925e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs @@ -1,5 +1,7 @@ -using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Volumes; namespace Proton.Drive.Sdk.Api.Photos; @@ -10,4 +12,6 @@ internal interface IPhotosApiClient ValueTask GetRootShareAsync(CancellationToken cancellationToken); ValueTask GetTimelinePhotosAsync(TimelinePhotoListRequest request, CancellationToken cancellationToken); + + ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs index 2aa8d66a..b6ec5e66 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs @@ -34,10 +34,10 @@ public async ValueTask GetTimelinePhotosAsync(Timelin .GetAsync($"volumes/{request.VolumeId}/photos{query}", cancellationToken).ConfigureAwait(false); } - public async ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken) + public async ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken) { return await _httpClient - .Expecting(PhotosApiSerializerContext.Default.PhotoDetailsResponse) + .Expecting(DriveApiSerializerContext.Default.LinkDetailsResponse) .PostAsync( $"photos/volumes/{volumeId}/links", new LinkDetailsRequest(linkIds), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index 26f4f21d..d2c3c559 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -179,7 +179,7 @@ private static Result>, ProtonDriveError> Result nodeKeyResult, AuthorshipClaim authorshipClaim) { - if (nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) + if (!nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) { return new ProtonDriveError("Cannot decrypt hash key without node key", error); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 0e08577a..6161ca15 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -28,6 +28,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( ProtonDriveClient client, IEnumerable fileUids, ThumbnailType thumbnailType, + bool forPhotos, [EnumeratorCancellation] CancellationToken cancellationToken) { // TODO: optimize parallelization for when UIDs are scattered over many volumes @@ -37,7 +38,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( var unprocessedLinkIds = volumeLinkIdGroup.ToHashSet(); - var nodeResults = NodeOperations.EnumerateNodesAsync(client, volumeId, unprocessedLinkIds, cancellationToken); + var nodeResults = NodeOperations.EnumerateNodesAsync(client, volumeId, unprocessedLinkIds, forPhotos, cancellationToken); var errors = new List(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs index 75c47158..6fe6ac99 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs @@ -5,9 +5,10 @@ namespace Proton.Drive.Sdk.Nodes; -internal sealed class NodeBatchLoader(ProtonDriveClient client, VolumeId volumeId) : BatchLoaderBase> +internal sealed class NodeBatchLoader(ProtonDriveClient client, VolumeId volumeId, bool forPhotos) : BatchLoaderBase> { private readonly ProtonDriveClient _client = client; + private readonly bool _forPhotos = forPhotos; protected override async ValueTask>> LoadBatchAsync( ReadOnlyMemory ids, @@ -15,7 +16,9 @@ protected override async ValueTask>> Lo { var nodeResults = new List>(ids.Length); - var response = await _client.Api.Links.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); + var response = _forPhotos + ? await _client.Api.Photos.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false) + : await _client.Api.Links.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); foreach (var linkDetails in response.Links) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 43c0615f..d1d59fbf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -75,20 +75,22 @@ public static async ValueTask> GetNod public static IAsyncEnumerable> EnumerateNodesAsync( ProtonDriveClient client, IEnumerable nodeUids, + bool forPhotos, CancellationToken cancellationToken = default) { return nodeUids.GroupBy(uid => uid.VolumeId, uid => uid.LinkId) .ToAsyncEnumerable() - .SelectMany(linkGroup => EnumerateNodesAsync(client, linkGroup.Key, linkGroup, cancellationToken)); + .SelectMany(linkGroup => EnumerateNodesAsync(client, linkGroup.Key, linkGroup, forPhotos, cancellationToken)); } public static async IAsyncEnumerable> EnumerateNodesAsync( ProtonDriveClient client, VolumeId volumeId, IEnumerable linkIds, + bool forPhotos, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var batchLoader = new NodeBatchLoader(client, volumeId); + var batchLoader = new NodeBatchLoader(client, volumeId, forPhotos); foreach (var linkId in linkIds) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs index 2b1d4bac..9b4be9ec 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs @@ -78,6 +78,7 @@ private static async ValueTask GetFreshExistingPhotosFolderAsync(Pro var (volumeDto, shareDto, linkDetailsDto) = await client.Api.Photos.GetRootShareAsync(cancellationToken).ConfigureAwait(false); await client.Cache.Entities.SetPhotosShareIdAsync(shareDto.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetPhotosVolumeIdAsync(volumeDto.Id, cancellationToken).ConfigureAwait(false); var nodeUid = new NodeUid(volumeDto.Id, linkDetailsDto.Link.Id); @@ -99,8 +100,7 @@ private static async ValueTask GetFreshExistingPhotosFolderAsync(Pro volumeDto.Id, linkDetailsDto, shareKey, - cancellationToken) - .ConfigureAwait(false); + cancellationToken).ConfigureAwait(false); return metadataResult.GetValueOrThrow().Node; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 3c6d2555..2492f435 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -184,14 +184,14 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) { return NodeOperations - .EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], cancellationToken) + .EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], forPhotos: false, cancellationToken) .Select(x => (Result?)x) .FirstOrDefaultAsync(cancellationToken); } public IAsyncEnumerable> EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) { - return NodeOperations.EnumerateNodesAsync(this, nodeUids, cancellationToken); + return NodeOperations.EnumerateNodesAsync(this, nodeUids, forPhotos: false, cancellationToken); } public ValueTask CreateFolderAsync(NodeUid parentId, string name, DateTime? lastModificationTime, CancellationToken cancellationToken) @@ -209,7 +209,7 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( ThumbnailType type, CancellationToken cancellationToken = default) { - return FileOperations.EnumerateThumbnailsAsync(this, fileUids, type, cancellationToken); + return FileOperations.EnumerateThumbnailsAsync(this, fileUids, type, forPhotos: false, cancellationToken); } public async ValueTask GetFileUploaderAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index c4c2d990..f079d48a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -85,7 +85,7 @@ public ValueTask> FindDuplicatesAsync(string name, Action< public IAsyncEnumerable> EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) { - return NodeOperations.EnumerateNodesAsync(DriveClient, nodeUids, cancellationToken); + return NodeOperations.EnumerateNodesAsync(DriveClient, nodeUids, forPhotos: true, cancellationToken); } [Experimental("Photos")] @@ -104,7 +104,7 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( ThumbnailType thumbnailType = ThumbnailType.Thumbnail, CancellationToken cancellationToken = default) { - return FileOperations.EnumerateThumbnailsAsync(DriveClient, photoUids, thumbnailType, cancellationToken); + return FileOperations.EnumerateThumbnailsAsync(DriveClient, photoUids, thumbnailType, forPhotos: true, cancellationToken); } public void Dispose() diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs index 19ced0ab..f9594ba0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Photos; using Proton.Sdk.Serialization; @@ -23,5 +22,4 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(PhotosVolumeLinkCreationParameters))] [JsonSerializable(typeof(TimelinePhotoListRequest))] [JsonSerializable(typeof(TimelinePhotoListResponse))] -[JsonSerializable(typeof(PhotoDetailsResponse))] internal sealed partial class PhotosApiSerializerContext : JsonSerializerContext; From 0b16c35b8587d6f6a3472509e54506e92223b244 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 13:36:04 +0000 Subject: [PATCH 619/791] Allow resuming download to non seekable data stream --- .../Nodes/Download/DownloadController.cs | 15 +- .../Nodes/Download/DownloadState.cs | 1 + .../Nodes/Download/FileDownloader.cs | 10 - .../Nodes/Download/RevisionReader.cs | 206 +++++++++++------- .../Nodes/Upload/RevisionWriter.cs | 9 +- .../Nodes/Upload/UploadController.cs | 15 +- 6 files changed, 147 insertions(+), 109 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 04f2477b..71af0e5d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -1,4 +1,3 @@ -using Proton.Sdk; using Proton.Sdk.Threading; namespace Proton.Drive.Sdk.Nodes.Download; @@ -108,13 +107,6 @@ await _onFailedAsync.Invoke( } } - private static bool IsResumableError(Exception ex) - { - return ex is not DataIntegrityException - and not ProtonApiException { TransportCode: >= 400 and < 500 } - and not CompletedDownloadManifestVerificationException; - } - private async Task ResumeAfterPreviousCompletionAsync(Task previousCompletion, int attempt) { await previousCompletion.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); @@ -138,7 +130,7 @@ private async Task PauseOnResumableErrorAsync(Task downloadTask, int attempt) _isDownloadCompleteWithVerificationIssue = true; throw new DataIntegrityException(error.Message, error); } - catch (Exception ex) when (IsResumableError(ex)) + catch (Exception) when (IsResumable()) { if (_taskControl.Attempt == attempt && !_taskControl.IsPaused) { @@ -177,4 +169,9 @@ await onSucceededHandler.Invoke( downloadState.RevisionDto.Size, downloadState.GetNumberOfBytesWritten()).ConfigureAwait(false); } + + private bool IsResumable() + { + return _downloadStateTask is { IsCompletedSuccessfully: true, Result.IsResumable: true }; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs index 47800140..ca67944e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs @@ -22,6 +22,7 @@ internal sealed partial class DownloadState( public BlockListingRevisionDto RevisionDto { get; } = revisionDto; public PgpPrivateKey NodeKey { get; } = nodeKey; public PgpSessionKey ContentKey { get; } = contentKey; + public bool IsResumable { get; set; } = true; public int GetNextBlockIndexToDownload() { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index e88ebd03..5a422399 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -79,16 +79,6 @@ private async Task DownloadToStreamAsync( downloadStateTaskCompletionSource.SetResult(downloadState); } - if (downloadState.GetNumberOfBytesWritten() > 0) - { - if (!contentOutputStream.CanSeek) - { - throw new InvalidOperationException("Cannot resume download to a non-seekable stream"); - } - - contentOutputStream.Seek(downloadState.GetNumberOfBytesWritten(), SeekOrigin.Begin); - } - await _client.BlockDownloader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); using var revisionReader = RevisionOperations.OpenForReading(_client, downloadState, ReleaseBlockListing); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index f6dc700a..2d53c0a4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Download; @@ -9,6 +10,7 @@ internal sealed partial class RevisionReader : IDisposable { public const int MinBlockIndex = 1; public const int DefaultBlockPageSize = 10; + private static readonly TimeSpan ContentOutputWritingCancellationDelay = TimeSpan.FromMilliseconds(500); private readonly ProtonDriveClient _client; private readonly DownloadState _state; @@ -36,93 +38,101 @@ internal RevisionReader( public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { - var downloadTasks = new Queue>(_client.BlockDownloader.Queue.Depth); - var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - - await using (manifestStream) + try { - var downloadedBlockDigests = _state.GetDownloadedBlockDigests(); - var revisionDto = _state.RevisionDto; + var downloadTasks = new Queue>(_client.BlockDownloader.Queue.Depth); + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - if (revisionDto.Thumbnails is { } thumbnails) + await using (manifestStream) { - foreach (var sha256Digest in thumbnails.OrderBy(t => t.Type).Select(x => x.HashDigest)) + var downloadedBlockDigests = _state.GetDownloadedBlockDigests(); + var revisionDto = _state.RevisionDto; + + if (revisionDto.Thumbnails is { } thumbnails) { - manifestStream.Write(sha256Digest.Span); + foreach (var sha256Digest in thumbnails.OrderBy(t => t.Type).Select(x => x.HashDigest)) + { + manifestStream.Write(sha256Digest.Span); + } } - } - foreach (var digest in downloadedBlockDigests) - { - manifestStream.Write(digest.Span); - } + foreach (var digest in downloadedBlockDigests) + { + manifestStream.Write(digest.Span); + } - try - { try { - var startBlockIndex = _state.GetNextBlockIndexToDownload(); - - await foreach (var (block, _) in GetBlocksAsync(startBlockIndex, cancellationToken).ConfigureAwait(false)) + try { - if (!_client.BlockDownloader.Queue.TryStartBlock()) + var startBlockIndex = _state.GetNextBlockIndexToDownload(); + + await foreach (var (block, _) in GetBlocksAsync(startBlockIndex, cancellationToken).ConfigureAwait(false)) { - if (downloadTasks.Count > 0) + if (!_client.BlockDownloader.Queue.TryStartBlock()) { - await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) - .ConfigureAwait(false); + if (downloadTasks.Count > 0) + { + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); + } + + await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } - await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); - } + var downloadTask = DownloadBlockAsync(block, cancellationToken); - var downloadTask = DownloadBlockAsync(block, cancellationToken); + downloadTasks.Enqueue(downloadTask); + } + } + finally + { + _releaseFileSemaphoreAction.Invoke(); + _fileSemaphoreReleased = true; + } - downloadTasks.Enqueue(downloadTask); + while (downloadTasks.Count > 0) + { + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); } } - finally + catch when (downloadTasks.Count > 0) { - _releaseFileSemaphoreAction.Invoke(); - _fileSemaphoreReleased = true; - } + try + { + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + } + finally + { + _client.BlockDownloader.Queue.FinishBlocks(downloadTasks.Count); + } - while (downloadTasks.Count > 0) - { - await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) - .ConfigureAwait(false); - } - } - catch when (downloadTasks.Count > 0) - { - try - { - await Task.WhenAll(downloadTasks).ConfigureAwait(false); - } - catch - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw - } - finally - { - _client.BlockDownloader.Queue.FinishBlocks(downloadTasks.Count); + throw; } - throw; - } + manifestStream.Seek(0, SeekOrigin.Begin); - manifestStream.Seek(0, SeekOrigin.Begin); + var manifestVerificationStatus = await VerifyManifestAsync(manifestStream, cancellationToken).ConfigureAwait(false); - var manifestVerificationStatus = await VerifyManifestAsync(manifestStream, cancellationToken).ConfigureAwait(false); + if (manifestVerificationStatus is not PgpVerificationStatus.Ok) + { + LogFailedManifestVerification(_state.Uid, manifestVerificationStatus); - if (manifestVerificationStatus is not PgpVerificationStatus.Ok) - { - LogFailedManifestVerification(_state.Uid, manifestVerificationStatus); + throw new CompletedDownloadManifestVerificationException("File authenticity check failed"); + } - throw new CompletedDownloadManifestVerificationException("File authenticity check failed"); + _state.SetIsCompleted(); } - - _state.SetIsCompleted(); + } + catch (Exception ex) when (!IsResumableError(ex)) + { + _state.IsResumable = false; + throw; } } @@ -134,6 +144,13 @@ public void Dispose() } } + private static bool IsResumableError(Exception ex) + { + return ex is not DataIntegrityException + and not ProtonApiException { TransportCode: >= 400 and < 500 } + and not CompletedDownloadManifestVerificationException; + } + private async Task WriteNextBlockToOutputAsync( Queue> downloadTasks, Stream outputStream, @@ -141,41 +158,74 @@ private async Task WriteNextBlockToOutputAsync( Action onProgress, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + var downloadTask = downloadTasks.Dequeue(); - try - { - var (plaintextStream, blockDigest) = await downloadTask.ConfigureAwait(false); + using var delayedCancellationTokenSource = new CancellationTokenSource(); + // We use a delayed cancellation token to give the write operation a fair chance to complete when cancellation is triggered, + // to not leave the stream in an indeterminate state that would prevent resuming using the same stream later. + // ReSharper disable once AccessToDisposedClosure + await using (cancellationToken.Register(() => delayedCancellationTokenSource.CancelAfter(ContentOutputWritingCancellationDelay))) + { try { - plaintextStream.Seek(0, SeekOrigin.Begin); - var initialOutputPosition = outputStream.CanSeek ? outputStream.Position : 0; + var (plaintextStream, blockDigest) = await downloadTask.ConfigureAwait(false); try { - await plaintextStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + plaintextStream.Seek(0, SeekOrigin.Begin); + var initialOutputPosition = outputStream.CanSeek ? outputStream.Position : 0; + + try + { + await plaintextStream.CopyToAsync(outputStream, delayedCancellationTokenSource.Token).ConfigureAwait(false); + } + catch + { + if (!TrySeekOutputStream(outputStream, initialOutputPosition)) + { + _state.IsResumable = false; + } + + throw; + } + + _state.AddNumberOfBytesWritten(plaintextStream.Length); + _state.AddDownloadedBlockDigest(blockDigest); + manifestStream.Write(blockDigest.Span); + + onProgress(_state.GetNumberOfBytesWritten(), _state.RevisionDto.Size); } - catch + finally { - outputStream.Seek(initialOutputPosition, SeekOrigin.Begin); - throw; + await plaintextStream.DisposeAsync().ConfigureAwait(false); } - - _state.AddNumberOfBytesWritten(plaintextStream.Length); - _state.AddDownloadedBlockDigest(blockDigest); - manifestStream.Write(blockDigest.Span); - - onProgress(_state.GetNumberOfBytesWritten(), _state.RevisionDto.Size); } finally { - await plaintextStream.DisposeAsync().ConfigureAwait(false); + _client.BlockDownloader.Queue.FinishBlocks(1); } } - finally + } + + private bool TrySeekOutputStream(Stream stream, long position) + { + if (!stream.CanSeek) + { + return false; + } + + try + { + stream.Seek(position, SeekOrigin.Begin); + return true; + } + catch (Exception ex) { - _client.BlockDownloader.Queue.FinishBlocks(1); + _logger.LogError(ex, "Seeking output stream failed"); + return false; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 68cdb642..4f1c57f6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -15,7 +15,7 @@ namespace Proton.Drive.Sdk.Nodes.Upload; internal sealed partial class RevisionWriter : IDisposable { public const int DefaultBlockSize = 1 << 22; // 4 MiB - private const int SourceReadingCancellationDelayMilliseconds = 500; + private static readonly TimeSpan SourceReadingCancellationDelay = TimeSpan.FromMilliseconds(500); private readonly ProtonDriveClient _client; private readonly RevisionDraft _draft; @@ -356,12 +356,14 @@ private async ValueTask UploadContentBlocksAsync( Queue> uploadTasks, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + using var delayedCancellationTokenSource = new CancellationTokenSource(); // We use a delayed cancellation token to give the read operation a fair chance to complete when cancellation is triggered, // to not leave the stream in an indeterminate state that would prevent resuming using the same stream later. // ReSharper disable once AccessToDisposedClosure - await using (cancellationToken.Register(() => delayedCancellationTokenSource.CancelAfter(SourceReadingCancellationDelayMilliseconds))) + await using (cancellationToken.Register(() => delayedCancellationTokenSource.CancelAfter(SourceReadingCancellationDelay))) { int? currentBlockNumber = null; @@ -435,8 +437,9 @@ await TryGetNextContentBlockPlainDataAsync( return (currentBlockNumber.Value, plainData); } - catch (Exception) + catch { + // TODO: Seek the content stream and allow resuming the upload. Currently, the HashingReadStream prevents seeking. _draft.IsResumable = false; await plainDataStream.DisposeAsync().ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 71053adc..0bcae404 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -1,4 +1,3 @@ -using Proton.Sdk; using Proton.Sdk.Threading; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -112,13 +111,6 @@ public async ValueTask DisposeAsync() } } - private static bool IsResumableError(Exception ex) - { - return ex is not ProtonApiException { TransportCode: > 400 and < 500 } - and not NodeWithSameNameExistsException - and not IntegrityException; - } - private async Task ResumeAfterPreviousCompletionAsync(Task previousCompletion, int attempt) { await previousCompletion.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); @@ -139,7 +131,7 @@ private async Task PauseOnResumableErrorAsync(Task u return result; } - catch (Exception ex) when (IsResumableError(ex)) + catch (Exception) when (IsResumable()) { if (_taskControl.Attempt == attempt) { @@ -171,4 +163,9 @@ private async ValueTask InvokeOnSucceededAsync() await onSucceededHandler.Invoke(revisionDraft.NumberOfPlainBytesDone).ConfigureAwait(false); } + + private bool IsResumable() + { + return _revisionDraftTask is { IsCompletedSuccessfully: true, Result.IsResumable: true }; + } } From a12164998a0319dd69faa05f9c58438c3a54802e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 13:55:58 +0000 Subject: [PATCH 620/791] Report unmapped HTTP errors as Network errors instead of Unknown --- .../Telemetry/TelemetryErrorResolver.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index e47845b6..0feca47e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -22,19 +22,22 @@ internal static class TelemetryErrorResolver FileContentsDecryptionException => DownloadError.DecryptionError, CryptographicException => DownloadError.DecryptionError, -#pragma warning disable RCS0056 // Line too long - HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => DownloadError.NetworkError, -#pragma warning restore RCS0056 HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => DownloadError.ServerError, HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => DownloadError.ServerError, HttpRequestException { StatusCode: >= (HttpStatusCode)400 and < (HttpStatusCode)500 } => DownloadError.HttpClientSideError, HttpRequestException { StatusCode: >= (HttpStatusCode)500 and < (HttpStatusCode)600 } => DownloadError.ServerError, + HttpRequestException => DownloadError.NetworkError, ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => DownloadError.RateLimited, ProtonApiException { TransportCode: >= 400 and < 500 } => DownloadError.HttpClientSideError, // TODO: How to better distinguish network errors, that were subject to retry in the HTTP request handler, but resulted in TimeoutException? TimeoutException => DownloadError.ServerError, + + // Windows client specific HTTP request handler errors + // TODO: The injected HTTP client should provide error categorization, at least for its own specific errors + Polly.CircuitBreaker.BrokenCircuitException => DownloadError.NetworkError, + _ => DownloadError.Unknown, }; } @@ -46,19 +49,22 @@ internal static class TelemetryErrorResolver // Upload errors IntegrityException => UploadError.IntegrityError, -#pragma warning disable RCS0056 // Line too long - HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError or HttpRequestError.ProxyTunnelError } => UploadError.NetworkError, -#pragma warning restore RCS0056 HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => UploadError.ServerError, HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => UploadError.ServerError, HttpRequestException { StatusCode: >= (HttpStatusCode)400 and < (HttpStatusCode)500 } => UploadError.HttpClientSideError, HttpRequestException { StatusCode: >= (HttpStatusCode)500 and < (HttpStatusCode)600 } => UploadError.ServerError, + HttpRequestException => UploadError.NetworkError, ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => UploadError.RateLimited, ProtonApiException { TransportCode: >= 400 and < 500 } => UploadError.HttpClientSideError, // TODO: How to better distinguish network errors, that were subject to retry in the HTTP request handler, but resulted in TimeoutException? TimeoutException => UploadError.ServerError, + + // Windows client specific HTTP request handler errors + // TODO: The injected HTTP client should provide error categorization, at least for its own specific errors + Polly.CircuitBreaker.BrokenCircuitException => UploadError.NetworkError, + _ => UploadError.Unknown, }; } From d65dcb977b1ec4f3bfafbd3507fa5cb218b82472 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 09:08:50 +0100 Subject: [PATCH 621/791] Report checksum verification state to back-end and client --- cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs | 2 ++ .../Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs | 2 ++ .../src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs | 7 ++++++- cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs | 1 + cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs | 6 +++++- 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs index 9f48eef4..cdb36c01 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs @@ -16,6 +16,8 @@ internal sealed class ActiveRevisionDto [JsonPropertyName("EncryptedSize")] public required long StorageQuotaConsumption { get; init; } + public required bool ChecksumVerified { get; init; } + public PgpArmoredSignature? ManifestSignature { get; init; } [JsonPropertyName("XAttr")] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs index a994b06f..9191cb68 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs @@ -10,6 +10,8 @@ internal sealed class RevisionUpdateRequest [JsonPropertyName("SignatureAddress")] public required string SignatureEmailAddress { get; init; } + public required bool ChecksumVerified { get; init; } + [JsonPropertyName("XAttr")] public PgpArmoredMessage? ExtendedAttributes { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index e14de17e..afd93b2a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -232,7 +232,12 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, ClaimedSize = extendedAttributes?.Common?.Size, ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, - ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, + ClaimedDigests = + new FileContentDigests + { + Sha1 = extendedAttributes?.Common?.Digests?.Sha1, + Sha1Verified = fileDto.ActiveRevision.ChecksumVerified, + }, Thumbnails = thumbnails.AsReadOnly(), AdditionalClaimedMetadata = additionalMetadata, ContentAuthor = contentAuthor, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs index 63fb217c..08648b68 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs @@ -3,4 +3,5 @@ public readonly struct FileContentDigests { public ReadOnlyMemory? Sha1 { get; init; } + public bool Sha1Verified { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 4f1c57f6..8d7a561f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -218,15 +218,18 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( expectedBlockCount: expectedThumbnailBlockCount); } + var checksumVerified = false; if (expectedSha1Provider is not null) { - var expectedSha1 = expectedSha1Provider(); + var expectedSha1 = expectedSha1Provider.Invoke(); if (!expectedSha1.Span.SequenceEqual(sha1Digest)) { throw new ChecksumMismatchIntegrityException( actualChecksum: sha1Digest, expectedChecksum: expectedSha1.ToArray()); } + + checksumVerified = true; } var extendedAttributes = new ExtendedAttributes @@ -251,6 +254,7 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( var request = new RevisionUpdateRequest { ManifestSignature = _draft.SigningKey.Sign(manifest), + ChecksumVerified = checksumVerified, SignatureEmailAddress = signingEmailAddress, ExtendedAttributes = encryptedExtendedAttributes, }; From 2358f674fe57dd6af5896bfcdf8378425700d580 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 15:12:19 +0000 Subject: [PATCH 622/791] Update changelog for cs/v0.9.2 --- cs/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 0f9953d4..57fce1e2 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## cs/v0.9.2 (2026-03-20) + +* Report checksum verification state to back-end and client + +## cs/v0.9.1 (2026-03-20) + +* Report unmapped HTTP errors as Network errors instead of Unknown +* Allow resuming download to non seekable data stream +* Fix wrong link details endpoint being used for Photos +* Improve error details for node decryption failures + ## cs/v0.9.0 (2026-03-20) * Fail node provision when parent key could not be obtained From 64593adfb5feeab6b19214cb61e0d795b1c88b3b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 08:56:04 +0100 Subject: [PATCH 623/791] Report checksum verification state to interop --- .../Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs | 7 ++++++- cs/sdk/src/protos/proton.drive.sdk.proto | 1 + .../me/proton/drive/sdk/entity/FileContentDigests.kt | 1 + .../me/proton/drive/sdk/extension/FileContentDigests.kt | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index af4a78a0..b622deb5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -507,6 +507,8 @@ private static Node ConvertToNode(Nodes.Node node) fileNodeProto.ActiveRevision.ClaimedDigests.Sha1 = ByteString.CopyFrom(fileNode.ActiveRevision.ClaimedDigests.Sha1.Value.Span); } + fileNodeProto.ActiveRevision.ClaimedDigests.Sha1Verified = fileNode.ActiveRevision.ClaimedDigests.Sha1Verified; + fileNodeProto.ActiveRevision.Thumbnails.AddRange( fileNode.ActiveRevision.Thumbnails.Select(t => new ThumbnailHeader { @@ -590,7 +592,10 @@ private static DegradedNode ConvertToDegradedNode(Nodes.DegradedNode degradedNod if (degradedFileNode.ActiveRevision.ClaimedDigests.HasValue) { - degradedFile.ActiveRevision.ClaimedDigests = new FileContentDigests(); + degradedFile.ActiveRevision.ClaimedDigests = new FileContentDigests + { + Sha1Verified = degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1Verified, + }; if (degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1.HasValue) { degradedFile.ActiveRevision.ClaimedDigests.Sha1 = diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 20727c22..41f837a5 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -520,6 +520,7 @@ message DegradedFileNode { message FileContentDigests { bytes sha1 = 1; // optional + bool sha1_verified = 2; } message ThumbnailHeader { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt index 0fcf1805..3db4bc0c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt @@ -2,4 +2,5 @@ package me.proton.drive.sdk.entity data class FileContentDigests( val sha1: String?, + val sha1Verified: Boolean, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt index d9b56fe8..edfe43fe 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt @@ -5,6 +5,7 @@ import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.FileContentDigests.toEntity() = FileContentDigests( sha1 = if (sha1.isEmpty) null else sha1.toByteArray().toHexString(), + sha1Verified = sha1Verified, ) private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } From fb43badf8e42d787bfc7307c9d24ef0a809d2f2a Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 12:31:56 +0100 Subject: [PATCH 624/791] Mark checksum verified as optional in the api --- cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs index cdb36c01..486640fd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs @@ -16,7 +16,7 @@ internal sealed class ActiveRevisionDto [JsonPropertyName("EncryptedSize")] public required long StorageQuotaConsumption { get; init; } - public required bool ChecksumVerified { get; init; } + public bool? ChecksumVerified { get; init; } public PgpArmoredSignature? ManifestSignature { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index afd93b2a..e6f6fded 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -236,7 +236,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1, - Sha1Verified = fileDto.ActiveRevision.ChecksumVerified, + Sha1Verified = fileDto.ActiveRevision.ChecksumVerified ?? false, }, Thumbnails = thumbnails.AsReadOnly(), AdditionalClaimedMetadata = additionalMetadata, From 5d168c7d8c5ae16275c52e7b2a9c6e306ac99639 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 12:05:08 +0000 Subject: [PATCH 625/791] Update changelog for cs/v0.9.3 --- cs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 57fce1e2..a03d4025 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## cs/v0.9.3 (2026-03-23) + +* Mark checksum verified as optional in the api +* Report checksum verification state to interop + ## cs/v0.9.2 (2026-03-20) * Report checksum verification state to back-end and client From 368285940679c4f29b827efbcf258736b47e992b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 12:35:02 +0000 Subject: [PATCH 626/791] Allow saving photos when deleting albums --- js/sdk/src/internal/photos/addToAlbum.ts | 2 +- .../src/internal/photos/albumsManager.test.ts | 62 ++++++- js/sdk/src/internal/photos/albumsManager.ts | 26 ++- js/sdk/src/internal/photos/apiService.test.ts | 155 ++++++++++++++++++ js/sdk/src/internal/photos/apiService.ts | 95 ++++++++++- js/sdk/src/internal/photos/errors.ts | 11 ++ js/sdk/src/internal/photos/index.ts | 2 +- .../src/internal/photos/photosManager.test.ts | 44 ++++- js/sdk/src/internal/photos/photosManager.ts | 64 +++++++- .../photos/photosTransferPayloadBuilder.ts | 4 +- js/sdk/src/protonDrivePhotosClient.ts | 8 +- 11 files changed, 455 insertions(+), 18 deletions(-) diff --git a/js/sdk/src/internal/photos/addToAlbum.ts b/js/sdk/src/internal/photos/addToAlbum.ts index 9e409227..b886ce3a 100644 --- a/js/sdk/src/internal/photos/addToAlbum.ts +++ b/js/sdk/src/internal/photos/addToAlbum.ts @@ -211,7 +211,7 @@ function splitByVolume( * Groups payloads into batches respecting the API limit. * Each payload's size counts itself plus its related photos. */ -function* createBatches(payloads: TransferEncryptedPhotoPayload[]): Generator { +export function* createBatches(payloads: TransferEncryptedPhotoPayload[]): Generator { let batch: TransferEncryptedPhotoPayload[] = []; let batchSize = 0; diff --git a/js/sdk/src/internal/photos/albumsManager.test.ts b/js/sdk/src/internal/photos/albumsManager.test.ts index bc9d6d1c..be0d86eb 100644 --- a/js/sdk/src/internal/photos/albumsManager.test.ts +++ b/js/sdk/src/internal/photos/albumsManager.test.ts @@ -4,8 +4,10 @@ import { getMockTelemetry } from '../../tests/telemetry'; import { AlbumsManager } from './albumsManager'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; +import { AlbumContainsPhotosNotInTimelineError } from './errors'; import { DecryptedPhotoNode } from './interface'; import { PhotosNodesAccess } from './nodes'; +import { PhotosManager } from './photosManager'; import { PhotoSharesManager } from './shares'; describe('Albums', () => { @@ -13,6 +15,7 @@ describe('Albums', () => { let cryptoService: AlbumsCryptoService; let photoShares: PhotoSharesManager; let nodesService: PhotosNodesAccess; + let photosService: PhotosManager; let albums: AlbumsManager; let nodes: { [uid: string]: DecryptedPhotoNode }; @@ -97,7 +100,19 @@ describe('Albums', () => { notifyChildCreated: jest.fn(), }; - albums = new AlbumsManager(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService); + // @ts-expect-error No need to implement all methods for mocking + photosService = { + saveToTimeline: jest.fn(), + }; + + albums = new AlbumsManager( + getMockTelemetry(), + apiService, + cryptoService, + photoShares, + nodesService, + photosService, + ); }); describe('createAlbum', () => { @@ -231,6 +246,51 @@ describe('Albums', () => { expect(apiService.deleteAlbum).toHaveBeenCalledWith('albumNodeUid', { force: true }); expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid'); }); + + it('when saveToTimeline is true, saves photos then retries delete', async () => { + const notInTimelineError = new AlbumContainsPhotosNotInTimelineError('msg', 1, ['p1', 'p2']); + (apiService.deleteAlbum as jest.Mock) + .mockRejectedValueOnce(notInTimelineError) + .mockResolvedValueOnce(undefined); + + photosService.saveToTimeline = jest.fn().mockImplementation(async function* () { + yield { uid: 'p1', ok: true }; + yield { uid: 'p2', ok: true }; + }); + + await albums.deleteAlbum('albumNodeUid', { saveToTimeline: true }); + + expect(apiService.deleteAlbum).toHaveBeenCalledTimes(2); + expect(photosService.saveToTimeline).toHaveBeenCalledWith(['p1', 'p2']); + expect(nodesService.notifyNodeDeleted).toHaveBeenCalledTimes(1); + expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid'); + }); + + it('throws AlbumContainsPhotosNotInTimelineError when saveToTimeline is false', async () => { + const notInTimelineError = new AlbumContainsPhotosNotInTimelineError('msg', 1, ['p1']); + (apiService.deleteAlbum as jest.Mock).mockRejectedValueOnce(notInTimelineError); + + await expect(albums.deleteAlbum('albumNodeUid')).rejects.toBe(notInTimelineError); + + expect(apiService.deleteAlbum).toHaveBeenCalledTimes(1); + expect(photosService.saveToTimeline).not.toHaveBeenCalled(); + expect(nodesService.notifyNodeDeleted).not.toHaveBeenCalled(); + }); + + it('throws when saveToTimeline step fails with error', async () => { + const notInTimelineError = new AlbumContainsPhotosNotInTimelineError('msg', 1, ['p1']); + (apiService.deleteAlbum as jest.Mock).mockRejectedValue(notInTimelineError); + + const saveError = new Error('save failed'); + photosService.saveToTimeline = jest.fn().mockImplementation(async function* () { + yield { uid: 'p1', ok: false, error: saveError }; + }); + + await expect(albums.deleteAlbum('albumNodeUid', { saveToTimeline: true })).rejects.toBe(saveError); + + expect(apiService.deleteAlbum).toHaveBeenCalledTimes(1); + expect(nodesService.notifyNodeDeleted).not.toHaveBeenCalled(); + }); }); describe('removePhotos', () => { diff --git a/js/sdk/src/internal/photos/albumsManager.ts b/js/sdk/src/internal/photos/albumsManager.ts index 7e198f47..06afb2cf 100644 --- a/js/sdk/src/internal/photos/albumsManager.ts +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -7,8 +7,10 @@ import { splitNodeUid } from '../uids'; import { AddToAlbumProcess } from './addToAlbum'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; +import { AlbumContainsPhotosNotInTimelineError } from './errors'; import { AlbumItem, DecryptedPhotoNode } from './interface'; import { PhotosNodesAccess } from './nodes'; +import { PhotosManager } from './photosManager'; import { PhotoSharesManager } from './shares'; const BATCH_LOADING_SIZE = 10; @@ -25,6 +27,7 @@ export class AlbumsManager { private cryptoService: AlbumsCryptoService, private photoShares: PhotoSharesManager, private nodesService: PhotosNodesAccess, + private photos: PhotosManager, ) { this.logger = telemetry.getLogger('albums'); this.apiService = apiService; @@ -157,8 +160,25 @@ export class AlbumsManager { return newNode; } - async deleteAlbum(nodeUid: string, options: { force?: boolean } = {}): Promise { - await this.apiService.deleteAlbum(nodeUid, options); + async deleteAlbum(nodeUid: string, options: { force?: boolean; saveToTimeline?: boolean } = {}): Promise { + try { + await this.apiService.deleteAlbum(nodeUid, options); + } catch (error) { + if ( + options.saveToTimeline && + error instanceof AlbumContainsPhotosNotInTimelineError && + error.photosOnlyInAlbumNodeUids.length > 0 + ) { + for await (const result of this.photos.saveToTimeline(error.photosOnlyInAlbumNodeUids)) { + if (!result.ok) { + throw result.error; + } + } + await this.apiService.deleteAlbum(nodeUid, options); + } else { + throw error; + } + } await this.nodesService.notifyNodeDeleted(nodeUid); } @@ -183,7 +203,7 @@ export class AlbumsManager { this.logger, signal, ); - yield * process.execute(photoNodeUids); + yield* process.execute(photoNodeUids); } async *removePhotos( diff --git a/js/sdk/src/internal/photos/apiService.test.ts b/js/sdk/src/internal/photos/apiService.test.ts index c628d707..0b19769e 100644 --- a/js/sdk/src/internal/photos/apiService.test.ts +++ b/js/sdk/src/internal/photos/apiService.test.ts @@ -28,6 +28,7 @@ describe('photosAPIService', () => { nodeUid: 'volumeId1~photoNodeId1', contentHash: 'contentHash1', nameHash: 'nameHash1', + originalNameHash: 'originalNameHash1', encryptedName: 'encryptedName1', nameSignatureEmail: 'nameSignatureEmail1', nodePassphrase: 'nodePassphrase1', @@ -38,6 +39,7 @@ describe('photosAPIService', () => { nodeUid: 'volumeId1~photoNodeId2', contentHash: 'contentHash2', nameHash: 'nameHash2', + originalNameHash: 'originalNameHash2', encryptedName: 'encryptedName2', nameSignatureEmail: 'nameSignatureEmail2', nodePassphrase: 'nodePassphrase2', @@ -151,6 +153,7 @@ describe('photosAPIService', () => { nodeUid: 'volumeId2~photoNodeId1', contentHash: 'contentHash1', nameHash: 'nameHash1', + originalNameHash: 'originalNameHash1', encryptedName: 'encryptedName1', nameSignatureEmail: 'nameSignatureEmail1', nodePassphrase: 'nodePassphrase1', @@ -161,6 +164,7 @@ describe('photosAPIService', () => { nodeUid: 'volumeId2~photoNodeId2', contentHash: 'contentHash2', nameHash: 'nameHash2', + originalNameHash: 'originalNameHash2', encryptedName: 'encryptedName2', nameSignatureEmail: 'nameSignatureEmail2', nodePassphrase: 'nodePassphrase2', @@ -230,4 +234,155 @@ describe('photosAPIService', () => { await expect(promise).rejects.toThrow(error); }); }); + + describe('transferPhotos', () => { + const photoPayloads = [ + { + nodeUid: 'volumeId1~photoNodeId1', + contentHash: 'contentHash1', + nameHash: 'nameHash1', + originalNameHash: 'originalNameHash1', + encryptedName: 'encryptedName1', + nameSignatureEmail: 'nameSignatureEmail1', + nodePassphrase: 'nodePassphrase1', + nodePassphraseSignature: 'nodePassphraseSignature1', + signatureEmail: 'signatureEmail1', + relatedPhotos: [ + { + nodeUid: 'volumeId1~photoNodeId2', + contentHash: 'contentHash2', + nameHash: 'nameHash2', + originalNameHash: 'originalNameHash2', + encryptedName: 'encryptedName2', + nameSignatureEmail: 'nameSignatureEmail2', + nodePassphrase: 'nodePassphrase2', + nodePassphraseSignature: 'nodePassphraseSignature2', + signatureEmail: 'signatureEmail2', + }, + ], + }, + ]; + + it('should transfer photos', async () => { + apiMock.put = jest.fn().mockResolvedValue({ + Code: 1000, + Responses: [ + { + LinkID: 'photoNodeId1', + Response: { + Code: 1000, + }, + }, + ], + }); + + const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads)); + + expect(result).toEqual([ + { + uid: 'volumeId1~photoNodeId1', + ok: true, + }, + ]); + expect(apiMock.put).toHaveBeenCalledWith( + `drive/photos/volumes/volumeId1/links/transfer-multiple`, + { + ParentLinkID: 'albumNodeId', + Links: [ + expect.objectContaining({ + LinkID: 'photoNodeId1', + Hash: 'nameHash1', + OriginalHash: 'originalNameHash1', + Name: 'encryptedName1', + NodePassphrase: 'nodePassphrase1', + ContentHash: 'contentHash1', + NodePassphraseSignature: null, + }), + expect.objectContaining({ + LinkID: 'photoNodeId2', + Hash: 'nameHash2', + OriginalHash: 'originalNameHash2', + Name: 'encryptedName2', + NodePassphrase: 'nodePassphrase2', + ContentHash: 'contentHash2', + NodePassphraseSignature: null, + }), + ], + NameSignatureEmail: 'nameSignatureEmail1', + SignatureEmail: null, + }, + undefined, + ); + }); + + it('should return MissingRelatedPhotosError if related photos are missing', async () => { + apiMock.put = jest.fn().mockResolvedValue({ + Code: 1000, + Responses: [ + { + LinkID: 'photoNodeId1', + Response: { + Code: 2000, + Details: { + Missing: ['photoNodeId3'], + }, + }, + }, + ], + }); + + const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads)); + + expect(result).toEqual([ + { + uid: 'volumeId1~photoNodeId1', + ok: false, + error: new MissingRelatedPhotosError([]), + }, + ]); + expect((result[0] as any).error.missingNodeUids).toEqual(['volumeId1~photoNodeId3']); + }); + + it('should return error for unknown error', async () => { + apiMock.put = jest.fn().mockResolvedValue({ + Code: 1000, + Responses: [ + { + LinkID: 'photoNodeId1', + Response: { + Code: 3000, + Error: 'Some error', + }, + }, + ], + }); + + const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads)); + + expect(result).toEqual([ + { + uid: 'volumeId1~photoNodeId1', + ok: false, + error: new APICodeError('Some error', 3000), + }, + ]); + }); + + it('should throw if name signature emails differ', async () => { + const mixedPayloads = [ + photoPayloads[0], + { + ...photoPayloads[0], + nodeUid: 'volumeId1~photoNodeIdOther', + nameSignatureEmail: 'other@example.com', + relatedPhotos: [], + }, + ]; + + await expect(Array.fromAsync(api.transferPhotos(albumNodeUid, mixedPayloads))).rejects.toThrow( + 'All photos must have the same name signature email', + ); + expect(apiMock.put).not.toHaveBeenCalled(); + }); + }); }); diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index bd452094..22661c83 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,12 +1,11 @@ import { c } from 'ttag'; -import { ValidationError } from '../../errors'; import { NodeResultWithError, PhotoTag } from '../../interface'; import { APICodeError, DriveAPIService, drivePaths, InvalidRequirementsAPIError, isCodeOk } from '../apiService'; import { batch } from '../batch'; import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; import { makeNodeUid, splitNodeUid } from '../uids'; -import { MissingRelatedPhotosError } from './errors'; +import { AlbumContainsPhotosNotInTimelineError, MissingRelatedPhotosError } from './errors'; import { AlbumItem } from './interface'; import { TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; @@ -82,6 +81,13 @@ type PostFavoritePhotoRequest = Extract< { content: object } >['content']['application/json']; +type PutTransferPhotosRequest = Extract< + drivePaths['/drive/photos/volumes/{volumeID}/links/transfer-multiple']['put']['requestBody'], + { content: object } +>['content']['application/json']; +type PutTransferPhotosResponse = + drivePaths['/drive/photos/volumes/{volumeID}/links/transfer-multiple']['put']['responses']['200']['content']['application/json']; + const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302; /** @@ -336,7 +342,9 @@ export class PhotosAPIService { ); } catch (error) { if (error instanceof APICodeError && error.code === ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE) { - throw new ValidationError(c('Error').t`Album contains photos not in timeline`); + const childLinkIds = (error.debug as { ChildLinkIDs: string[] })?.ChildLinkIDs || []; + const nodeUids = childLinkIds.map((linkId) => makeNodeUid(volumeId, linkId)); + throw new AlbumContainsPhotosNotInTimelineError(error.message, error.code, nodeUids); } throw error; } @@ -556,4 +564,85 @@ export class PhotosAPIService { requestBody, ); } + + async *transferPhotos( + newParentNodeUid: string, + photoPayloads: TransferEncryptedPhotoPayload[], + signal?: AbortSignal, + ): AsyncGenerator { + const { volumeId, nodeId: newParentNodeId } = splitNodeUid(newParentNodeUid); + + if (photoPayloads.length === 0) { + return; + } + + const nameSignatureEmail = photoPayloads[0].nameSignatureEmail; + if (photoPayloads.some((photoPayload) => photoPayload.nameSignatureEmail !== nameSignatureEmail)) { + throw new Error('All photos must have the same name signature email'); + } + + const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [photoPayload, ...photoPayload.relatedPhotos]); + const allLinksData = allPhotoPayloads.map((photoPayload) => { + const { nodeId } = splitNodeUid(photoPayload.nodeUid); + return { + LinkID: nodeId, + Hash: photoPayload.nameHash, + OriginalHash: photoPayload.originalNameHash!, + Name: photoPayload.encryptedName, + NodePassphrase: photoPayload.nodePassphrase, + ContentHash: photoPayload.contentHash, + NodePassphraseSignature: null, // Required when moving an anonymous node. + }; + }); + + const response = await this.apiService.put( + `drive/photos/volumes/${volumeId}/links/transfer-multiple`, + { + ParentLinkID: newParentNodeId, + Links: allLinksData, + NameSignatureEmail: nameSignatureEmail, + SignatureEmail: null, // Required when moving an anonymous node. + }, + signal, + ); + + const errors = new Map(); + + for (const r of response.Responses || []) { + const details = r as { + LinkID: string; + Response: { + Code: number; + Error?: string; + Details: { Missing: string[] }; + }; + }; + + if (!details.Response.Code || !isCodeOk(details.Response.Code) || details.Response?.Error) { + const nodeUid = makeNodeUid(volumeId, details.LinkID); + + if (details.Response.Details?.Missing) { + const missingNodeUids = details.Response.Details.Missing.map((linkId) => + makeNodeUid(volumeId, linkId), + ); + errors.set(nodeUid, new MissingRelatedPhotosError(missingNodeUids)); + } else { + errors.set( + nodeUid, + new APICodeError(details.Response.Error || c('Error').t`Unknown error`, details.Response.Code), + ); + } + } + } + + for (const photoPayload of photoPayloads) { + const uid = photoPayload.nodeUid; + const error = errors.get(uid); + if (error) { + yield { uid, ok: false, error }; + } else { + yield { uid, ok: true }; + } + } + } } diff --git a/js/sdk/src/internal/photos/errors.ts b/js/sdk/src/internal/photos/errors.ts index 0af45d6d..03c57dd6 100644 --- a/js/sdk/src/internal/photos/errors.ts +++ b/js/sdk/src/internal/photos/errors.ts @@ -1,5 +1,7 @@ import { c } from 'ttag'; +import { ValidationError } from '../../errors'; + export class MissingRelatedPhotosError extends Error { constructor(public missingNodeUids: string[]) { // We do not want to leak the technical details of the error to the user. @@ -9,3 +11,12 @@ export class MissingRelatedPhotosError extends Error { this.name = 'MissingRelatedPhotosError'; } } + +export class AlbumContainsPhotosNotInTimelineError extends ValidationError { + public readonly photosOnlyInAlbumNodeUids: string[]; + + constructor(message: string, code: number, photosOnlyInAlbumNodeUids: string[]) { + super(message, code); + this.photosOnlyInAlbumNodeUids = photosOnlyInAlbumNodeUids; + } +} diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 8f2ab308..c410869e 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -62,8 +62,8 @@ export function initPhotosModule( photoShares, nodesService, ); - const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService); const photos = new PhotosManager(telemetry.getLogger('photos-update'), api, albumsCryptoService, nodesService); + const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService, photos); return { timeline, diff --git a/js/sdk/src/internal/photos/photosManager.test.ts b/js/sdk/src/internal/photos/photosManager.test.ts index fbaf430f..f0c600cd 100644 --- a/js/sdk/src/internal/photos/photosManager.test.ts +++ b/js/sdk/src/internal/photos/photosManager.test.ts @@ -5,6 +5,7 @@ import { PhotosAPIService } from './apiService'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosNodesAccess } from './nodes'; import { DecryptedPhotoNode } from './interface'; +import { MissingRelatedPhotosError } from './errors'; function createMockPhotoNode(uid: string, overrides: Partial = {}): DecryptedPhotoNode { return { @@ -46,9 +47,19 @@ async function collectUpdateResults(manager: PhotosManager, photos: UpdatePhotoS return results; } +async function collectSaveToTimelineResults(manager: PhotosManager, nodeUids: string[], signal?: AbortSignal) { + const results = []; + for await (const result of manager.saveToTimeline(nodeUids, signal)) { + results.push(result); + } + return results; +} + describe('PhotosManager', () => { let logger: ReturnType; - let apiService: jest.Mocked>; + let apiService: jest.Mocked< + Pick + >; let cryptoService: jest.Mocked>; let nodesService: jest.Mocked< Pick< @@ -80,6 +91,7 @@ describe('PhotosManager', () => { addPhotoTags: jest.fn().mockResolvedValue(undefined), removePhotoTags: jest.fn().mockResolvedValue(undefined), setPhotoFavorite: jest.fn().mockResolvedValue(undefined), + transferPhotos: jest.fn().mockImplementation(async function* () {}), }; cryptoService = { @@ -263,4 +275,34 @@ describe('PhotosManager', () => { }); }); }); + + describe('saveToTimeline', () => { + it('re-queues once on MissingRelatedPhotosError then succeeds without yielding the retry error', async () => { + const missingRelatedUid = 'volume1~related1'; + let transferCall = 0; + apiService.transferPhotos.mockImplementation(async function* (_rootUid, payloads) { + transferCall++; + for (const payload of payloads) { + if (transferCall === 1) { + yield { + uid: payload.nodeUid, + ok: false, + error: new MissingRelatedPhotosError([missingRelatedUid]), + }; + } else { + yield { uid: payload.nodeUid, ok: true }; + } + } + }); + + const results = await collectSaveToTimelineResults(manager, ['volume1~photo1']); + + expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]); + expect(apiService.transferPhotos).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenCalledWith( + `Missing related photos for saving volume1~photo1, re-queuing: ${missingRelatedUid}`, + ); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1'); + }); + }); }); diff --git a/js/sdk/src/internal/photos/photosManager.ts b/js/sdk/src/internal/photos/photosManager.ts index 492d4911..41944387 100644 --- a/js/sdk/src/internal/photos/photosManager.ts +++ b/js/sdk/src/internal/photos/photosManager.ts @@ -1,13 +1,19 @@ import { c } from 'ttag'; +import { AbortError } from '../../errors'; import { Logger, NodeResultWithError, PhotoTag } from '../../interface'; +import { batch } from '../batch'; import { PhotosAPIService } from './apiService'; import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; import { PhotosNodesAccess } from './nodes'; import { AlbumsCryptoService } from './albumsCrypto'; -import { AbortError } from '../../errors'; -import { BATCH_LOADING_SIZE } from '../sharing/sharingAccess'; -import { batch } from '../batch'; +import { createBatches } from './addToAlbum'; +import { MissingRelatedPhotosError } from './errors'; + +/** + * The number of photos that are loaded in parallel to prepare the payloads. + */ +const BATCH_LOADING_SIZE = 20; export type UpdatePhotoSettings = { nodeUid: string; @@ -31,6 +37,58 @@ export class PhotosManager { this.payloadBuilder = new PhotoTransferPayloadBuilder(albumsCryptoService, nodesService); } + async *saveToTimeline(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const rootNode = await this.nodesService.getVolumeRootFolder(); + const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid); + const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid }); + + const queue: { + photoNodeUid: string; + additionalRelatedPhotoNodeUids: string[]; + }[] = nodeUids.map((nodeUid) => ({ photoNodeUid: nodeUid, additionalRelatedPhotoNodeUids: [] })); + const retriedPhotoUids = new Set(); + + while (queue.length > 0) { + const items = queue.splice(0, BATCH_LOADING_SIZE); + const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads( + items, + rootNode.uid, + volumeRootKeys, + signingKeys, + signal, + ); + + for (const [uid, error] of errors) { + yield { uid, ok: false, error }; + } + + for (const batch of createBatches(payloads)) { + for await (const result of this.apiService.transferPhotos(rootNode.uid, batch, signal)) { + if ( + !result.ok && + result.error instanceof MissingRelatedPhotosError && + !retriedPhotoUids.has(result.uid) + ) { + retriedPhotoUids.add(result.uid); + this.logger.info( + `Missing related photos for saving ${result.uid}, re-queuing: ${result.error.missingNodeUids.join(', ')}`, + ); + queue.push({ + photoNodeUid: result.uid, + additionalRelatedPhotoNodeUids: result.error.missingNodeUids, + }); + continue; + } + + if (result.ok) { + await this.nodesService.notifyNodeChanged(result.uid); + } + yield result; + } + } + } + } + async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator { for await (const { photoSettings: { nodeUid, tagsToAdd, tagsToRemove }, diff --git a/js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts index 26a2b7d6..7d183284 100644 --- a/js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts +++ b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts @@ -14,12 +14,13 @@ type TransferEncryptedRelatedPhotoPayload = { nodeUid: string; contentHash: string; nameHash: string; + originalNameHash: string | undefined; encryptedName: string; nameSignatureEmail: string; nodePassphrase: string; nodePassphraseSignature?: string; signatureEmail?: string; -} +}; /** * Item representing a photo to build a payload for. @@ -186,6 +187,7 @@ export class PhotoTransferPayloadBuilder { nodeUid: photoNode.uid, contentHash: encryptedCrypto.contentHash, nameHash: encryptedCrypto.hash, + originalNameHash: photoNode.hash, encryptedName: encryptedCrypto.encryptedName, nameSignatureEmail: encryptedCrypto.nameSignatureEmail, nodePassphrase: encryptedCrypto.armoredNodePassphrase, diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 76c29aa1..62a680be 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -559,9 +559,9 @@ export class ProtonDrivePhotosClient { * * Photos in the timeline will not be deleted. If the album has photos * that are not in the timeline (uploaded by another user), the method - * will throw an error. The photos must be moved to the timeline, or - * the album must be deleted with `force` option that deletes the photos - * not in the timeline as well. + * will throw an error. Then, either the photos must be saved to the + * timelines with `saveToTimeline` option, or the album must be deleted + * with `force` option that deletes the photos not in the timeline as well. * * This operation is irreversible. Both the album and the photos will be * permanently deleted, skipping the trash. @@ -569,7 +569,7 @@ export class ProtonDrivePhotosClient { * @param nodeUid - The UID of the album to delete. * @param force - Whether to force the deletion. */ - async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean } = {}): Promise { + async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean; saveToTimeline?: boolean } = {}): Promise { this.logger.info(`Deleting album ${getUid(nodeUid)}`); await this.photos.albums.deleteAlbum(getUid(nodeUid), options); } From 1756e7460339ba6c3b26b364b9cffb40d5d763bc Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 12:44:18 +0000 Subject: [PATCH 627/791] Update changelog for js/v0.14.0 --- js/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 98ad3e01..419da131 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## js/v0.14.0 (2026-03-23) + +* Allow saving photos when deleting albums +* Make unknown telemetry volume type explicit + ## js/v0.13.1 (2026-03-19) * Make LatestEventIdProvider.getLatestEventId async to support IndexedDB From 731d6465ac41626ba156751ff4a3fcb332079592 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 18:27:30 +0800 Subject: [PATCH 628/791] Expose structured data on upload integrity errors to Swift binding --- .../AdditionalErrorData.swift | 132 +++++++++++++++++- .../ProtonDriveSDKError.swift | 24 +--- 2 files changed, 135 insertions(+), 21 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift index 9e2c4bf7..a7f3f940 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift @@ -4,11 +4,17 @@ import SwiftProtobuf struct AdditionalErrorDataFactory { func make(data: Google_Protobuf_Any) -> AdditionalErrorData? { return NodeNameConflictErrorData(data: data) -// ?? SomeOtherErrorData(data: data) + ?? MissingContentBlockErrorData(data: data) + ?? ContentSizeMismatchErrorData(data: data) + ?? ThumbnailCountMismatchErrorData(data: data) + ?? ChecksumMismatchErrorData(data: data) } } -public protocol AdditionalErrorData: Sendable { } +public protocol AdditionalErrorData: Sendable { + func toProtobufAny() -> Google_Protobuf_Any? + func errorDescription() -> String +} public struct NodeNameConflictErrorData: AdditionalErrorData { public let isFileDraft: Bool @@ -29,4 +35,126 @@ public struct NodeNameConflictErrorData: AdditionalErrorData { return nil } } + + public func toProtobufAny() -> Google_Protobuf_Any? { + let errorData = Proton_Drive_Sdk_NodeNameConflictErrorData.with { + $0.conflictingNodeIsFileDraft = isFileDraft + if let conflictingNodeId = nodeUID { + $0.conflictingNodeUid = conflictingNodeId.sdkCompatibleIdentifier + } + if let conflictingRevisionUid = revisionUID { + $0.conflictingRevisionUid = conflictingRevisionUid.sdkCompatibleIdentifier + } + } + return try? Google_Protobuf_Any(message: errorData) + } + + public func errorDescription() -> String { + "isFileDraft: \(isFileDraft)), nodeUID: \(String(describing: nodeUID)), revisionUID: \(String(describing: revisionUID))" + } +} + +public struct MissingContentBlockErrorData: AdditionalErrorData { + public let blockNumber: Int + + init?(data: Google_Protobuf_Any) { + do { + let errorData = try Proton_Drive_Sdk_MissingContentBlockErrorData(unpackingAny: data) + self.blockNumber = errorData.hasBlockNumber ? Int(errorData.blockNumber) : 0 + } catch { + return nil + } + } + + public func toProtobufAny() -> Google_Protobuf_Any? { + let errorData = Proton_Drive_Sdk_MissingContentBlockErrorData.with { + $0.blockNumber = Int32(blockNumber) + } + return try? Google_Protobuf_Any(message: errorData) + } + + public func errorDescription() -> String { + "block number: \(blockNumber)" + } +} + +public struct ContentSizeMismatchErrorData: AdditionalErrorData { + public let uploadedSize: Int64 + public let expectedSize: Int64 + + init?(data: Google_Protobuf_Any) { + do { + let errorData = try Proton_Drive_Sdk_ContentSizeMismatchErrorData(unpackingAny: data) + self.uploadedSize = errorData.hasUploadedSize ? errorData.uploadedSize : 0 + self.expectedSize = errorData.hasExpectedSize ? errorData.expectedSize : 0 + } catch { + return nil + } + } + + public func toProtobufAny() -> Google_Protobuf_Any? { + let errorData = Proton_Drive_Sdk_ContentSizeMismatchErrorData.with { + $0.uploadedSize = uploadedSize + $0.expectedSize = expectedSize + } + return try? Google_Protobuf_Any(message: errorData) + } + + public func errorDescription() -> String { + "uploadedSize: \(uploadedSize), expectedSize: \(expectedSize)" + } +} + +public struct ThumbnailCountMismatchErrorData: AdditionalErrorData { + public let uploadedBlockCount: Int + public let expectedBlockCount: Int + + init?(data: Google_Protobuf_Any) { + do { + let errorData = try Proton_Drive_Sdk_ThumbnailCountMismatchErrorData(unpackingAny: data) + self.uploadedBlockCount = errorData.hasUploadedBlockCount ? Int(errorData.uploadedBlockCount) : 0 + self.expectedBlockCount = errorData.hasExpectedBlockCount ? Int(errorData.expectedBlockCount) : 0 + } catch { + return nil + } + } + + public func toProtobufAny() -> Google_Protobuf_Any? { + let errorData = Proton_Drive_Sdk_ThumbnailCountMismatchErrorData.with { + $0.uploadedBlockCount = Int32(uploadedBlockCount) + $0.expectedBlockCount = Int32(expectedBlockCount) + } + return try? Google_Protobuf_Any(message: errorData) + } + + public func errorDescription() -> String { + "uploadedBlockCount: \(uploadedBlockCount), expectedBlockCount: \(expectedBlockCount)" + } +} + +public struct ChecksumMismatchErrorData: AdditionalErrorData { + public let actualChecksum: Data + public let expectedChecksum: Data + + init?(data: Google_Protobuf_Any) { + do { + let errorData = try Proton_Drive_Sdk_ChecksumMismatchErrorData(unpackingAny: data) + self.actualChecksum = errorData.hasActualChecksum ? errorData.actualChecksum : Data() + self.expectedChecksum = errorData.hasExpectedChecksum ? errorData.expectedChecksum : Data() + } catch { + return nil + } + } + + public func toProtobufAny() -> Google_Protobuf_Any? { + let errorData = Proton_Drive_Sdk_ChecksumMismatchErrorData.with { + $0.actualChecksum = actualChecksum + $0.expectedChecksum = expectedChecksum + } + return try? Google_Protobuf_Any(message: errorData) + } + + public func errorDescription() -> String { + "actual checksum: \(actualChecksum.map { String(format: "%02x", $0) }.joined()), expected checksum: \(expectedChecksum.map { String(format: "%02x", $0) }.joined())" + } } diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift index fedb2861..03baca07 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift @@ -123,22 +123,8 @@ public struct ProtonDriveSDKError: LocalizedError, Sendable { if let innerError = innerErrorBox?.innerError.asProton_Sdk_Error { $0.innerError = innerError } - switch additionalErrorData { - case .some(let data as NodeNameConflictErrorData): - let errorData = Proton_Drive_Sdk_NodeNameConflictErrorData.with { - $0.conflictingNodeIsFileDraft = data.isFileDraft - if let conflictingNodeId = data.nodeUID { - $0.conflictingNodeUid = conflictingNodeId.sdkCompatibleIdentifier - } - if let conflictingRevisionUid = data.revisionUID { - $0.conflictingRevisionUid = conflictingRevisionUid.sdkCompatibleIdentifier - } - } - if let additionalData = try? Google_Protobuf_Any(message: errorData) { - $0.additionalData = additionalData - } - - case .some, nil: break + if let data = additionalErrorData?.toProtobufAny() { + $0.additionalData = data } } } @@ -201,7 +187,7 @@ public extension ProtonDriveSDKError { case manifestSignatureVerificationErrorPrimaryCode: return .manifestSignatureVerification(message: message, context: context) case contentUploadIntegrityErrorPrimaryCode: - return .contentUploadIntegrity(message: message, context: context) + return .contentUploadIntegrity(message: message, context: context, additionalData: additionalErrorData?.errorDescription()) case unknownDecryptionErrorPrimaryCode: return .unknown(message: message, context: context) default: @@ -221,7 +207,7 @@ public enum ProtonDriveSDKDataIntegrityError: LocalizedError { case fileContents(message: String, context: String?) case uploadKeyMismatch(message: String, context: String?) case manifestSignatureVerification(message: String, context: String?) - case contentUploadIntegrity(message: String, context: String?) + case contentUploadIntegrity(message: String, context: String?, additionalData: String?) public enum NodeMetadataPart: Int, Sendable { case key = 0 @@ -237,7 +223,7 @@ public enum ProtonDriveSDKDataIntegrityError: LocalizedError { public var errorDescription: String? { switch self { case .unknown(let message, _), .shareMetadata(let message, _), .nodeMetadata(let message, _, _), .fileContents(let message, _), - .uploadKeyMismatch(let message, _), .manifestSignatureVerification(let message, _), .contentUploadIntegrity(let message, _): + .uploadKeyMismatch(let message, _), .manifestSignatureVerification(let message, _), .contentUploadIntegrity(let message, _, _): return message } } From 329cc8968e5730f001b3a7b31b9a9e6083b31a2f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 15:43:13 +0000 Subject: [PATCH 629/791] Fix wrong volume type for photo events --- .../Nodes/Download/PhotosFileDownloader.cs | 17 +++++++---------- cs/sdk/src/protos/proton.drive.sdk.proto | 7 ++++--- .../me/proton/drive/sdk/extension/VolumeType.kt | 2 +- .../me/proton/drive/sdk/telemetry/VolumeType.kt | 2 +- .../TelemetryAndLogging/TelemetryTypes.swift | 10 +++++----- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index 0463aaef..c176a419 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -100,12 +100,6 @@ private DownloadController DownloadToStream( var downloadStateTaskCompletionSource = new TaskCompletionSource(); - var downloadEvent = new DownloadEvent - { - DownloadedSize = 0, - VolumeType = VolumeType.OwnPhotosVolume, - }; - var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( contentOutputStream, onProgress, @@ -121,8 +115,10 @@ private DownloadController DownloadToStream( OnFailedAsync, OnSucceededAsync); - ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloadedByteCount) + async ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloadedByteCount) { + var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client.DriveClient, _photoUid, cancellationToken).ConfigureAwait(false); + // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.ClaimedFileSize = claimedFileSize; downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); @@ -131,18 +127,19 @@ ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloadedByteC downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); downloadEvent.OriginalError = ex; RaiseTelemetryEvent(downloadEvent); - return ValueTask.CompletedTask; } - ValueTask OnSucceededAsync(long claimedFileSize, long downloadedByteCount) + async ValueTask OnSucceededAsync(long claimedFileSize, long downloadedByteCount) { + var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client.DriveClient, _photoUid, cancellationToken).ConfigureAwait(false); + // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.ClaimedFileSize = claimedFileSize; downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); downloadEvent.DownloadedSize = downloadedByteCount; downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); + RaiseTelemetryEvent(downloadEvent); - return ValueTask.CompletedTask; } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 41f837a5..086dc745 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -782,12 +782,13 @@ message DrivePhotosClientUploaderFreeRequest { int64 file_uploader_handle = 1; } +// The list must match the order from Telemetry/VolumeTypes to properly match it over the interop. enum VolumeType { VOLUME_TYPE_UNKNOWN = 0; VOLUME_TYPE_OWN_VOLUME = 1; - VOLUME_TYPE_SHARED = 2; - VOLUME_TYPE_SHARED_PUBLIC = 3; - VOLUME_TYPE_OWN_PHOTO_VOLUME = 4; + VOLUME_TYPE_OWN_PHOTO_VOLUME = 2; + VOLUME_TYPE_SHARED = 3; + VOLUME_TYPE_SHARED_PUBLIC = 4; } enum DownloadError { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt index 68252abf..72d15f33 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt @@ -6,8 +6,8 @@ import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.VolumeType.toEnum() = when (this) { ProtonDriveSdk.VolumeType.VOLUME_TYPE_UNKNOWN -> VolumeType.UNKNOWN ProtonDriveSdk.VolumeType.VOLUME_TYPE_OWN_VOLUME -> VolumeType.OWN_VOLUME + ProtonDriveSdk.VolumeType.VOLUME_TYPE_OWN_PHOTO_VOLUME -> VolumeType.OWN_PHOTO_VOLUME ProtonDriveSdk.VolumeType.VOLUME_TYPE_SHARED -> VolumeType.SHARED ProtonDriveSdk.VolumeType.VOLUME_TYPE_SHARED_PUBLIC -> VolumeType.SHARED_PUBLIC - ProtonDriveSdk.VolumeType.VOLUME_TYPE_OWN_PHOTO_VOLUME -> VolumeType.OWN_PHOTO_VOLUME ProtonDriveSdk.VolumeType.UNRECOGNIZED -> VolumeType.UNRECOGNIZED } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt index 7b559508..26d1c621 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt @@ -4,7 +4,7 @@ enum class VolumeType { UNRECOGNIZED, UNKNOWN, OWN_VOLUME, + OWN_PHOTO_VOLUME, SHARED, SHARED_PUBLIC, - OWN_PHOTO_VOLUME, } diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift index 38b7a37b..9dc8445e 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -140,9 +140,9 @@ public enum VolumeType: Int, Sendable { case unrecognized = -1 case unknown = 0 case ownVolume = 1 - case shared = 2 - case sharedPublic = 3 - case ownPhotoVolume = 4 + case ownPhotoVolume = 2 + case shared = 3 + case sharedPublic = 4 init(sdkVolumeType: Proton_Drive_Sdk_VolumeType) { switch sdkVolumeType { @@ -150,12 +150,12 @@ public enum VolumeType: Int, Sendable { self = .unknown case .ownVolume: self = .ownVolume + case .ownPhotoVolume: + self = .ownPhotoVolume case .shared: self = .shared case .sharedPublic: self = .sharedPublic - case .ownPhotoVolume: - self = .ownPhotoVolume case .UNRECOGNIZED(let value): assertionFailure("Received unrecognized VolumeType from the SDK \(value)") self = .unrecognized From af36b1dcd38a5d485234b795255998de0329ae87 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 16:25:28 +0000 Subject: [PATCH 630/791] Update changelog for cs/v0.9.4 --- cs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index a03d4025..26e6a5b9 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## cs/v0.9.4 (2026-03-23) + +* Fix wrong volume type for photo events +* Expose structured data on upload integrity errors to Swift binding + ## cs/v0.9.3 (2026-03-23) * Mark checksum verified as optional in the api From b9dd68295f249e9b083ecf56f63dbe26d19ffca4 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 18:26:50 +0800 Subject: [PATCH 631/791] Upgrade ProtonCore_iOS to 36.0.3 --- swift/ProtonDriveSDK/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/ProtonDriveSDK/Package.swift b/swift/ProtonDriveSDK/Package.swift index 584e520a..8afedb89 100644 --- a/swift/ProtonDriveSDK/Package.swift +++ b/swift/ProtonDriveSDK/Package.swift @@ -21,7 +21,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.33.3"), .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.1.0"), - .package(url: "https://github.com/ProtonMail/protoncore_ios.git", exact: "34.2.2"), + .package(url: "https://github.com/ProtonMail/protoncore_ios.git", exact: "36.0.3"), ], targets: [ .binaryTarget( From 8b628dd3c705c23b29b41bd4a6c9e8d0f66c0535 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Mar 2026 11:04:53 +0000 Subject: [PATCH 632/791] Enable resuming of uploads from Swift bindings --- .../ProtonDriveClient/ProtonDriveClient.swift | 3 +++ .../ProtonPhotosClient.swift | 3 +++ .../Uploads/PhotoUploadsManager.swift | 4 ++-- .../Uploads/UploadOperation.swift | 22 +++++++++++++++---- .../Uploads/UploadsManager.swift | 4 ++-- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 8f214e56..4fa4db7c 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -243,6 +243,9 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { operation: UploadOperation, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void ) async throws -> UploadedFileIdentifiers { + if try await operation.isPaused() { + try await operation.resume() + } return try await operation.awaitUploadWithResilience( operationalResilience: configuration.uploadOperationalResilience, onRetriableErrorReceived: onRetriableErrorReceived diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index ed6c6e06..7903664b 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -231,6 +231,9 @@ extension ProtonPhotosClient { operation: UploadOperation, onRetriableErrorReceived: @Sendable @escaping (Error) -> Void ) async throws -> UploadedFileIdentifiers { + if try await operation.isPaused() { + try await operation.resume() + } return try await operation.awaitUploadWithResilience( operationalResilience: configuration.uploadOperationalResilience, onRetriableErrorReceived: onRetriableErrorReceived diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift index 1f354aa3..81cd6ad5 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift @@ -51,7 +51,7 @@ actor PhotoUploadsManager { cancellationHandle: cancellationHandle ) - let uploadController = try await uploadFromFile( + let uploadOperation = try await uploadFromFile( fileUploaderHandle: uploaderHandle, fileURL: fileURL, progressCallback: progressCallback, @@ -60,7 +60,7 @@ actor PhotoUploadsManager { thumbnails: thumbnails, expectedSHA1: expectedSHA1 ) - return uploadController + return uploadOperation } private func uploadFromFile( diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift index d17b57e8..8e120ed6 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift @@ -4,6 +4,7 @@ import CProtonDriveSDK public enum UploadOperationResult: Sendable { case succeeded(UploadedFileIdentifiers) case pausedOnError(Error) + case pausedByClient(Error) case failed(Error) } @@ -54,12 +55,19 @@ public final class UploadOperation: Sendable { let result = await awaitUploadCompletion(cleanUpTemporaryState: true) switch result { case .succeeded(let uploadResult): + // Sucesfully completed return uploadResult case .failed(let error): + // Non-retriable error + throw error + + case let .pausedByClient(error): + // Throw the cancellation error, the caller will be able to handle it and keep reference to `UploadOperation` throw error case .pausedOnError(let error): + // This should be retriable error. We retry with resilience and only clean temporary state when needed do { onPauseErrorReceived(error) return try await operationalResilience.performRetry(retryCounter, error) { @@ -99,10 +107,16 @@ public final class UploadOperation: Sendable { do { let isPaused = try await isPaused() if isPaused { - // if the operation is paused, we can try recovering from the error - // this is why this is the only scenario in which we do NOT clean up the temporary state - // the resume relies on that temporary state to be there - return .pausedOnError(error) + // The operation was paused, either due to retriable error or explicitly by the client + // We don't want to clean up local state to allow resumability + if let sdkError = error as? ProtonDriveSDKError, + sdkError.domain == .successfulCancellation { + // The operation was explicitly paused + return .pausedByClient(error) + } else { + // The SDK paused the operation due to encountering a recoverable error + return .pausedOnError(error) + } } else { if cleanUpTemporaryState { try? await self.cleanUpTemporaryState() diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift index 4af5afa6..02ed4643 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift @@ -48,7 +48,7 @@ actor UploadsManager { logger: logger ) - let uploadController = try await uploadFromFile( + let uploadOperation = try await uploadFromFile( fileUploaderHandle: uploaderHandle, fileURL: fileURL, progressCallback: progressCallback, @@ -57,7 +57,7 @@ actor UploadsManager { thumbnails: thumbnails, expectedSHA1: expectedSHA1 ) - return uploadController + return uploadOperation } func uploadNewRevisionOperation( From a220358c9236cd28d55d9c706dc5def7e6ca01a7 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Mar 2026 15:10:45 +0100 Subject: [PATCH 633/791] Surface non-resumable upload and download as typed exceptions --- .../drive/sdk/CommonDownloadController.kt | 22 ++++++++++++++----- .../drive/sdk/CommonUploadController.kt | 22 ++++++++++++++----- .../me/proton/drive/sdk/DownloadController.kt | 2 +- .../drive/sdk/OperationAbortedException.kt | 9 ++++++++ .../me/proton/drive/sdk/UploadController.kt | 2 +- 5 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/OperationAbortedException.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt index 4078383b..625c03b3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,22 +36,33 @@ class CommonDownloadController internal constructor( bridge.awaitCompletion(handle) }.onSuccess { log(INFO, "completed") - }.onFailure { - log(INFO, "cancelled or failed") - isPaused() + }.recoverCatching { error -> + if (error is CancellationException) { + log(INFO, "interrupted") + throw error + } + if (isPaused()) { + log(INFO, "paused") + throw error + } + log(INFO, "aborted") + throw DownloadAbortedException(error) }.getOrThrow() } - override suspend fun resume(coroutineScope: CoroutineScope) { + override suspend fun tryResume(coroutineScope: CoroutineScope): Boolean { log(INFO, "resume") coroutineScopeConsumer(coroutineScope) + if (!isPaused()) { + return false + } bridge.resume(handle).also { isPaused() } + return true } override suspend fun pause() { log(INFO, "pause") bridge.pause(handle).also { isPaused() } - coroutineScopeConsumer(null) } override suspend fun isPaused() = bridge.isPaused(handle).also { paused -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt index f8325acf..27a60c61 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -36,22 +37,33 @@ class CommonUploadController internal constructor( bridge.awaitCompletion(handle) }.onSuccess { log(INFO, "completed") - }.onFailure { - log(INFO, "cancelled or failed") - isPaused() + }.recoverCatching { error -> + if (error is CancellationException) { + log(INFO, "interrupted") + throw error + } + if (isPaused()) { + log(INFO, "paused") + throw error + } + log(INFO, "aborted") + throw UploadAbortedException(error) }.getOrThrow() } - override suspend fun resume(coroutineScope: CoroutineScope) { + override suspend fun tryResume(coroutineScope: CoroutineScope): Boolean { log(INFO, "resume") coroutineScopeConsumer(coroutineScope) + if (!isPaused()) { + return false + } bridge.resume(handle).also { isPaused() } + return true } override suspend fun pause() { log(INFO, "pause") bridge.pause(handle).also { isPaused() } - coroutineScopeConsumer(null) } override suspend fun isPaused() = bridge.isPaused(handle).also { paused -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt index 73d83b99..1bf02cfc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -9,7 +9,7 @@ interface DownloadController : AutoCloseable, Cancellable { suspend fun awaitCompletion() suspend fun pause() - suspend fun resume(coroutineScope: CoroutineScope) + suspend fun tryResume(coroutineScope: CoroutineScope): Boolean suspend fun isPaused(): Boolean suspend fun isDownloadCompleteWithVerificationIssue(): Boolean } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/OperationAbortedException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/OperationAbortedException.kt new file mode 100644 index 00000000..30087371 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/OperationAbortedException.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk + +open class OperationAbortedException(message: String, cause: Throwable) : Exception(message, cause) + +class UploadAbortedException(cause: Throwable) : + OperationAbortedException("Upload was aborted and cannot be resumed", cause) + +class DownloadAbortedException(cause: Throwable) : + OperationAbortedException("Download was aborted and cannot be resumed", cause) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt index 9738f84a..95acf9ae 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -9,7 +9,7 @@ interface UploadController : AutoCloseable, Cancellable { val progressFlow: Flow suspend fun awaitCompletion(): UploadResult - suspend fun resume(coroutineScope: CoroutineScope) + suspend fun tryResume(coroutineScope: CoroutineScope): Boolean suspend fun pause() suspend fun isPaused(): Boolean suspend fun dispose() From d7c9d85d904c519e176bf740848281a59413ce09 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Mar 2026 14:34:44 +0000 Subject: [PATCH 634/791] Update changelog for cs/v0.10.0 --- cs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 26e6a5b9..a8ed5359 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## cs/v0.10.0 (2026-03-24) + +* Enable resuming of uploads from Swift bindings + ## cs/v0.9.4 (2026-03-23) * Fix wrong volume type for photo events From c896cf3a1c67fc48c00d76d33c033f77a787e660 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Mar 2026 10:38:33 +0000 Subject: [PATCH 635/791] Wrap SDK exception into IO exception for android network library to handle it --- .../me/proton/drive/sdk/internal/HttpStream.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt index 50dc8b3a..7eea4c5f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import me.proton.drive.sdk.ProtonDriveSdkException +import java.io.IOException import java.nio.ByteBuffer import java.nio.channels.ReadableByteChannel @@ -12,12 +14,18 @@ class HttpStream internal constructor( private val bridge: JniHttpStream, ) : AutoCloseable { - suspend fun read(sdkContentHandle: Long, buffer: ByteBuffer) = withContext(Dispatchers.IO){ - bridge.read(sdkContentHandle, buffer) + suspend fun read(sdkContentHandle: Long, buffer: ByteBuffer) = withContext(Dispatchers.IO) { + readOrThrow(sdkContentHandle, buffer) } fun readBlocking(sdkContentHandle: Long, buffer: ByteBuffer) = runBlocking(Dispatchers.IO) { - bridge.read(sdkContentHandle, buffer) + readOrThrow(sdkContentHandle, buffer) + } + + private suspend fun readOrThrow(sdkContentHandle: Long, buffer: ByteBuffer): Int = try { + bridge.read(sdkContentHandle, buffer) + } catch (error: ProtonDriveSdkException) { + throw IOException("Failed to read from SDK stream", error) } fun write(coroutineScope: CoroutineScope, channel: ReadableByteChannel): Long = From 7a04452ed9e3c4e7debb2935000776333a11d018 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Mar 2026 12:26:07 +0000 Subject: [PATCH 636/791] Update changelog for cs/v0.11.1 --- cs/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index a8ed5359..31e1a56e 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## cs/v0.11.1 (2026-03-25) + +* Wrap SDK exception into IO exception for android network library to handle it + +## cs/v0.11.0 (2026-03-24) + +* Surface non-resumable upload and download as typed exceptions + ## cs/v0.10.0 (2026-03-24) * Enable resuming of uploads from Swift bindings From 4dd91fdee104b93d22901e87b6ceba465fc42435 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Mar 2026 15:49:08 +0000 Subject: [PATCH 637/791] Add experimental getSessionInfo helper --- .../internal/sharingPublic/session/index.ts | 1 + .../internal/sharingPublic/session/manager.ts | 2 ++ js/sdk/src/protonDriveClient.ts | 3 ++- js/sdk/src/protonDrivePublicLinkClient.ts | 25 +++++++++++++++++-- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/internal/sharingPublic/session/index.ts b/js/sdk/src/internal/sharingPublic/session/index.ts index b5169c65..9ab77303 100644 --- a/js/sdk/src/internal/sharingPublic/session/index.ts +++ b/js/sdk/src/internal/sharingPublic/session/index.ts @@ -1 +1,2 @@ export { SharingPublicSessionManager } from './manager'; +export { SharingPublicLinkSession } from './session'; diff --git a/js/sdk/src/internal/sharingPublic/session/manager.ts b/js/sdk/src/internal/sharingPublic/session/manager.ts index 3bfed228..9b4c4645 100644 --- a/js/sdk/src/internal/sharingPublic/session/manager.ts +++ b/js/sdk/src/internal/sharingPublic/session/manager.ts @@ -87,6 +87,7 @@ export class SharingPublicSessionManager { shareKey: PrivateKey; rootUid: string; publicRole: MemberRole; + session: SharingPublicLinkSession; }> { let info = this.infosPerToken.get(token); if (!info) { @@ -105,6 +106,7 @@ export class SharingPublicSessionManager { shareKey, rootUid, publicRole: permissionsToMemberRole(this.logger, encryptedShare.publicPermissions), + session, }; } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 3ef34e96..5ca0ce6b 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -232,7 +232,7 @@ export class ProtonDriveClient { const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url); this.logger.info(`Authenticating public link token ${token}`); - const { httpClient, shareKey, rootUid, publicRole } = await this.publicSessionManager.auth( + const { httpClient, shareKey, rootUid, publicRole, session } = await this.publicSessionManager.auth( token, urlPassword, customPassword, @@ -250,6 +250,7 @@ export class ProtonDriveClient { publicRootNodeUid: rootUid, isAnonymousContext, publicRole, + session, }); }, }; diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 81a0e557..6d116ab1 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -33,6 +33,7 @@ import { import { initDownloadModule } from './internal/download'; import { SDKEvents } from './internal/sdkEvents'; import { initSharingPublicModule, UnauthDriveAPIService } from './internal/sharingPublic'; +import { SharingPublicLinkSession } from './internal/sharingPublic/session'; import { initUploadModule } from './internal/upload'; import { NullFeatureFlagProvider } from './featureFlags'; import { NodesSecurityScanResult } from './internal/sharingPublic/nodesSecurity'; @@ -56,6 +57,7 @@ export class ProtonDrivePublicLinkClient { private sharingPublic: ReturnType; private download: ReturnType; private upload: ReturnType; + private session: SharingPublicLinkSession; public experimental: { /** @@ -86,6 +88,14 @@ export class ProtonDrivePublicLinkClient { * Experimental feature to create a document (Proton Docs or Proton Sheets) in the public link. */ createDocument: (parentNodeUid: NodeOrUid, documentName: string, documentType: 1 | 2) => Promise; + /** + * Experimental feature to get the session info for the public link. + * + * This helper is used to set the session for metrics requests. + * Returns the session UID and access token that were obtained during + * authentication. + */ + getSessionInfo: () => { uid: string; accessToken: string | undefined }; }; constructor({ @@ -102,6 +112,7 @@ export class ProtonDrivePublicLinkClient { publicRootNodeUid, isAnonymousContext, publicRole, + session, }: { httpClient: ProtonDriveHTTPClient; account: ProtonDriveAccount; @@ -116,6 +127,7 @@ export class ProtonDrivePublicLinkClient { publicRootNodeUid: string; isAnonymousContext: boolean; publicRole: MemberRole; + session: SharingPublicLinkSession; }) { if (!telemetry) { telemetry = new Telemetry(); @@ -124,6 +136,7 @@ export class ProtonDrivePublicLinkClient { featureFlagProvider = new NullFeatureFlagProvider(); } this.logger = telemetry.getLogger('publicLink-interface'); + this.session = session; // Use only in memory cache for public link as there are no events to keep it up to date if persisted. const entitiesCache = new MemoryCache(); @@ -197,7 +210,7 @@ export class ProtonDrivePublicLinkClient { if (!keys.passphrase) { throw new Error('Node does not have a passphrase'); } - return keys.passphrase + return keys.passphrase; }, scanHashes: async (hashes: string[]): Promise => { this.logger.debug(`Scanning ${hashes.length} hashes`); @@ -210,9 +223,17 @@ export class ProtonDrivePublicLinkClient { ): Promise => { this.logger.debug(`Creating document in ${getUid(parentNodeUid)}`); return convertInternalNodePromise( - this.sharingPublic.nodes.management.createDocument(getUid(parentNodeUid), documentName, documentType), + this.sharingPublic.nodes.management.createDocument( + getUid(parentNodeUid), + documentName, + documentType, + ), ); }, + getSessionInfo: (): { uid: string; accessToken: string | undefined } => { + this.logger.debug(`Getting session info`); + return this.session.session; + }, }; } From 30f0d29b98988f300b2759ed9575e5bf37769be4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Mar 2026 15:58:04 +0000 Subject: [PATCH 638/791] Update changelog for js/v0.14.1 --- js/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 419da131..690a69c8 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## js/v0.14.1 (2026-03-25) + +* Add experimental getSessionInfo helper + ## js/v0.14.0 (2026-03-23) * Allow saving photos when deleting albums From aaf4efca8f0c628aaa8243e6ca71fdc2b529bba9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 15:47:20 +0800 Subject: [PATCH 639/791] Update Swift binding to get trash error --- .../Sources/Client/ProtonDriveClient/ProtonDriveClient.swift | 2 +- swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 4fa4db7c..0912c9e6 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -477,7 +477,7 @@ extension ProtonDriveClient { let result: Proton_Drive_Sdk_NodeResultListResponse = try await SDKRequestHandler.send(trashRequest, logger: logger) let results: [TrashNodeResult] = result.results.compactMap { result in guard let id = SDKNodeUid(sdkCompatibleIdentifier: result.nodeUid) else { return nil } - let error: String? = result.hasError ? result.error.message : nil + let error: ProtonDriveSDKError? = result.hasError ? ProtonDriveSDKError(protoError: result.error) : nil return TrashNodeResult(nodeUid: id, error: error) } return results diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 3fd47883..2eb69b8b 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -230,7 +230,7 @@ public struct PhotoTimelineItem: Sendable { public struct TrashNodeResult: Sendable { public let nodeUid: SDKNodeUid - public let error: String? + public let error: ProtonDriveSDKError? } /// Callback for progress updates From f97ad89b135597dfe2e913dcf9e017e2d02cbcbb Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Mar 2026 13:04:25 +0000 Subject: [PATCH 640/791] Fix regression in disposal of file transfer controllers --- .../Nodes/Download/DownloadController.cs | 14 +++++++------- .../Nodes/Download/FileDownloader.cs | 3 +-- .../Upload/ChecksumMismatchIntegrityException.cs | 2 +- .../Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs | 2 ++ .../Nodes/Upload/UploadController.cs | 9 +++------ .../Telemetry/TelemetryErrorResolver.cs | 2 +- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index 71af0e5d..a9d40c87 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -77,7 +77,7 @@ public async ValueTask DisposeAsync() try { - if (exception is not null && _onFailedAsync is not null) + if (exception is not null and not OperationCanceledException && _onFailedAsync is not null) { await _onFailedAsync.Invoke( exception, @@ -85,7 +85,7 @@ await _onFailedAsync.Invoke( downloadState?.GetNumberOfBytesWritten() ?? 0).ConfigureAwait(false); } } - catch + finally { if (downloadState is not null) { @@ -152,15 +152,15 @@ private async Task PauseOnResumableErrorAsync(Task downloadTask, int attempt) private async ValueTask FinalizeDownloadAsync() { - var onSucceededHandler = _onSucceededAsync; - if (onSucceededHandler is null) + if (_outputStreamToDispose is not null) { - return; + await _outputStreamToDispose.FlushAsync().ConfigureAwait(false); } - if (_outputStreamToDispose is not null) + var onSucceededHandler = _onSucceededAsync; + if (onSucceededHandler is null) { - await _outputStreamToDispose.FlushAsync().ConfigureAwait(false); + return; } var downloadState = await _downloadStateTask.ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 5a422399..127b1fed 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -115,13 +115,13 @@ async ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloade { var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.ClaimedFileSize = claimedFileSize; downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); downloadEvent.DownloadedSize = downloadedByteCount; downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); downloadEvent.OriginalError = ex; + RaiseTelemetryEvent(downloadEvent); } @@ -129,7 +129,6 @@ async ValueTask OnSucceededAsync(long claimedFileSize, long downloadedByteCount) { var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize downloadEvent.ClaimedFileSize = claimedFileSize; downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); downloadEvent.DownloadedSize = downloadedByteCount; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs index 03525f4a..7fff16b7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs @@ -16,7 +16,7 @@ public ChecksumMismatchIntegrityException(string message, Exception innerExcepti { } - public ChecksumMismatchIntegrityException(byte[]? actualChecksum, byte[]? expectedChecksum) + public ChecksumMismatchIntegrityException(byte[] actualChecksum, byte[] expectedChecksum) : base("Mismatch between uploaded checksum and expected checksum") { ActualChecksum = actualChecksum; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 5e115d54..0f6df1e1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -154,6 +154,7 @@ async ValueTask OnFailedAsync(Exception ex, long uploadedByteCount) uploadEvent.ApproximateUploadedSize = Privacy.ReduceSizePrecision(uploadedByteCount); uploadEvent.Error = TelemetryErrorResolver.GetUploadErrorFromException(ex); uploadEvent.OriginalError = ex; + RaiseTelemetryEvent(uploadEvent); } @@ -164,6 +165,7 @@ async ValueTask OnSucceededAsync(long uploadedByteCount) uploadEvent.UploadedSize = uploadedByteCount; uploadEvent.ApproximateUploadedSize = Privacy.ReduceSizePrecision(uploadedByteCount); + RaiseTelemetryEvent(uploadEvent); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs index 0bcae404..0ce4f5b5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -79,17 +79,14 @@ public async ValueTask DisposeAsync() try { - if (exception is not null) + if (exception is not null and not OperationCanceledException && _onFailedAsync is not null) { var numberOfPlainBytesDone = draft?.NumberOfPlainBytesDone ?? 0; - if (_onFailedAsync is not null) - { - await _onFailedAsync.Invoke(exception, numberOfPlainBytesDone).ConfigureAwait(false); - } + await _onFailedAsync.Invoke(exception, numberOfPlainBytesDone).ConfigureAwait(false); } } - catch + finally { if (draft is not null) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index 0feca47e..20253bfb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -42,7 +42,7 @@ internal static class TelemetryErrorResolver }; } - public static UploadError? GetUploadErrorFromException(Exception exception) + public static UploadError GetUploadErrorFromException(Exception exception) { return exception switch { From a05a7372001113b8c8a36485ee8a5a234e6d0a72 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Mar 2026 08:02:25 +0100 Subject: [PATCH 641/791] Stream trash enumeration instead of loading all items at once --- .../InteropMessageHandler.cs | 2 +- .../InteropProtonDriveClient.cs | 14 ++++++------- cs/sdk/src/protos/proton.drive.sdk.proto | 3 ++- .../me/proton/drive/sdk/ProtonDriveClient.kt | 21 ++++++++++++------- .../sdk/internal/JniProtonDriveClient.kt | 18 +++++++++++----- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 3950ef1d..26b6f5f9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -47,7 +47,7 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint => await InteropProtonDriveClient.HandleRestoreNodesAsync(request.DriveClientRestoreNodes).ConfigureAwait(false), Request.PayloadOneofCase.DriveClientEnumerateTrash - => await InteropProtonDriveClient.HandleEnumerateTrashAsync(request.DriveClientEnumerateTrash).ConfigureAwait(false), + => await InteropProtonDriveClient.HandleEnumerateTrashAsync(request.DriveClientEnumerateTrash, bindingsHandle).ConfigureAwait(false), Request.PayloadOneofCase.DriveClientEmptyTrash => await InteropProtonDriveClient.HandleEmptyTrashAsync(request.DriveClientEmptyTrash).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index b622deb5..c7d02db6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -327,19 +327,19 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto return ConvertToNodeResultListResponse(results); } - public static async ValueTask HandleEnumerateTrashAsync(DriveClientEnumerateTrashRequest request) + public static async ValueTask HandleEnumerateTrashAsync(DriveClientEnumerateTrashRequest request, nint bindingsHandle) { + var iterateFunction = new InteropAction>(request.IterateAction); var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); - var trashEnumerable = client.EnumerateTrashAsync(cancellationToken); - - var children = await trashEnumerable - .Select(ConvertToNodeResult) - .ToListAsync(cancellationToken).ConfigureAwait(false); + await foreach (var x in client.EnumerateTrashAsync(cancellationToken).ConfigureAwait(false)) + { + iterateFunction.InvokeWithMessage(bindingsHandle, ConvertToNodeResult(x)); + } - return new TrashChildrenList { Children = { children } }; + return null; } public static async ValueTask HandleEmptyTrashAsync(DriveClientEmptyTrashRequest request) diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 086dc745..d603ea82 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -405,10 +405,11 @@ message DriveClientRestoreNodesRequest { int64 cancellation_token_source_handle = 3; } -// The response message must be of type TrashChildrenList +// The response must not have a value, iterate_action will be called for each item. message DriveClientEnumerateTrashRequest { int64 client_handle = 1; int64 cancellation_token_source_handle = 2; + int64 iterate_action = 3; } message TrashChildrenList { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index e11d9333..acc175bc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -195,14 +195,21 @@ class ProtonDriveClient internal constructor( ).toEntity() } - suspend fun enumerateTrash(): List = cancellationCoroutineScope { source -> + fun enumerateTrash(): Flow = channelFlow { log(DEBUG, "enumerateTrash") - bridge.enumerateTrash( - driveClientEnumerateTrashRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() + cancellationCoroutineScope { source -> + bridge.enumerateTrash( + coroutineScope = this@channelFlow, + request = driveClientEnumerateTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + }, + enumerate = { nodeResult -> + send(nodeResult.toEntity()) + } + ) + } } suspend fun emptyTrash(): Unit = cancellationCoroutineScope { source -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index 72ab5d91..c5b829b1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -2,12 +2,13 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.ProducerScope import me.proton.drive.sdk.converter.NodeResultListResponseConverter import me.proton.drive.sdk.converter.FileThumbnailListConverter import me.proton.drive.sdk.converter.FolderChildrenListConverter import me.proton.drive.sdk.converter.FolderNodeConverter -import me.proton.drive.sdk.converter.TrashChildrenListConverter import me.proton.drive.sdk.entity.ClientCreateRequest +import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.StringResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback @@ -154,11 +155,18 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { } suspend fun enumerateTrash( + coroutineScope: ProducerScope, request: ProtonDriveSdk.DriveClientEnumerateTrashRequest, - ): ProtonDriveSdk.TrashChildrenList = - executeOnce("enumerateTrash", TrashChildrenListConverter().asCallback) { - driveClientEnumerateTrash = request - } + enumerate: suspend (ProtonDriveSdk.NodeResult) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateTrash", + callback = UnitResponseCallback, + enumerate = enumerate, + parser = ProtonDriveSdk.NodeResult::parseFrom, + coroutineScopeProvider = { coroutineScope } + ) { + driveClientEnumerateTrash = request + } suspend fun emptyTrash( request: ProtonDriveSdk.DriveClientEmptyTrashRequest, From dc2be211102bdcce5935f7bd927d1d706207930c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 11:13:55 +0800 Subject: [PATCH 642/791] Rename library to fix Xcode26.4 dependency issue --- swift/ProtonDriveSDK/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/ProtonDriveSDK/Package.swift b/swift/ProtonDriveSDK/Package.swift index 8afedb89..e9d05fc6 100644 --- a/swift/ProtonDriveSDK/Package.swift +++ b/swift/ProtonDriveSDK/Package.swift @@ -26,7 +26,7 @@ let package = Package( targets: [ .binaryTarget( name: "CProtonDriveSDK", - path: "./Libraries/ProtonDriveSDK.xcframework" + path: "./Libraries/CProtonDriveSDK.xcframework" ), .target( name: "ProtonDriveSDK", From d6b4c29611aac2c6ef551bb72f69f79a0c9735ec Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 08:52:34 +0000 Subject: [PATCH 643/791] Update changelog for cs/v0.11.2 --- cs/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 31e1a56e..5dc42786 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## cs/v0.11.2 (2026-03-27) + +* Stream trash enumeration instead of loading all items at once +* Fix regression in disposal of file transfer controllers +* Update Swift binding to get trash error + ## cs/v0.11.1 (2026-03-25) * Wrap SDK exception into IO exception for android network library to handle it From 13950663822965e30efd2ea59ffdecc07d595fbc Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 12:53:33 +0000 Subject: [PATCH 644/791] Handle thumbnails in small file upload --- js/sdk/src/internal/photos/index.ts | 2 + js/sdk/src/internal/photos/upload.ts | 14 +- .../src/internal/upload/fileUploader.test.ts | 1 + js/sdk/src/internal/upload/fileUploader.ts | 62 +++++- js/sdk/src/internal/upload/index.test.ts | 184 ++++++++++++------ js/sdk/src/internal/upload/index.ts | 34 +--- .../internal/upload/smallFileUploader.test.ts | 73 ++++--- .../src/internal/upload/smallFileUploader.ts | 83 ++++---- 8 files changed, 281 insertions(+), 172 deletions(-) diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index c410869e..3dda2118 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -181,6 +181,8 @@ export function initPhotoUploadModule( name, metadata, onFinish, + // Small-file upload is not supported for photos yet. + () => Promise.resolve(false), signal, ); } diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index 4e8bc727..6d3016be 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -47,9 +47,21 @@ export class PhotoFileUploader extends FileUploader { name: string, metadata: PhotoUploadMetadata, onFinish: () => void, + shouldUseSmallFileUpload: (expectedSize: number) => Promise, signal?: AbortSignal, ) { - super(telemetry, apiService, cryptoService, manager, parentFolderUid, name, metadata, onFinish, signal); + super( + telemetry, + apiService, + cryptoService, + manager, + parentFolderUid, + name, + metadata, + onFinish, + shouldUseSmallFileUpload, + signal, + ); this.photoApiService = apiService; this.photoManager = manager; this.photoMetadata = metadata; diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index 6bbc2b84..fcff916c 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -129,6 +129,7 @@ describe('FileUploader', () => { 'name', metadata, onFinish, + () => Promise.resolve(false), abortController.signal, ); diff --git a/js/sdk/src/internal/upload/fileUploader.ts b/js/sdk/src/internal/upload/fileUploader.ts index 4441e434..cc80eb22 100644 --- a/js/sdk/src/internal/upload/fileUploader.ts +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -5,6 +5,7 @@ import { UploadController } from './controller'; import { UploadCryptoService } from './cryptoService'; import { NodeRevisionDraft } from './interface'; import { UploadManager } from './manager'; +import { SmallFileRevisionUploader, SmallFileUploader } from './smallFileUploader'; import { StreamUploader } from './streamUploader'; import { UploadTelemetry } from './telemetry'; @@ -26,6 +27,7 @@ export abstract class Uploader { protected manager: UploadManager, protected metadata: UploadMetadata, protected onFinish: () => void, + protected shouldUseSmallFileUpload: (expectedSize: number) => Promise, protected signal?: AbortSignal, ) { this.telemetry = telemetry; @@ -34,6 +36,7 @@ export abstract class Uploader { this.manager = manager; this.metadata = metadata; this.onFinish = onFinish; + this.shouldUseSmallFileUpload = shouldUseSmallFileUpload; this.signal = signal; this.abortController = new AbortController(); @@ -97,10 +100,28 @@ export abstract class Uploader { thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, ): Promise<{ nodeRevisionUid: string; nodeUid: string }> { + const expectedEncryptedTotalSize = this.getExpectedEncryptedTotalSize(thumbnails); + if (await this.shouldUseSmallFileUpload(expectedEncryptedTotalSize)) { + return this.initSmallFileUploader(stream, thumbnails, onProgress); + } + const uploader = await this.initStreamUploader(); return uploader.start(stream, thumbnails, onProgress); } + private getExpectedEncryptedTotalSize(thumbnails: Thumbnail[]): number { + const thumbnailSize = thumbnails.reduce((acc, thumbnail) => acc + thumbnail.thumbnail.length, 0); + const totalSize = this.metadata.expectedSize + thumbnailSize; + const expectedEncryptedTotalSize = totalSize * 1.1; // 10% margin for encryption overhead + return expectedEncryptedTotalSize; + } + + protected abstract initSmallFileUploader( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise<{ nodeRevisionUid: string; nodeUid: string }>; + protected async initStreamUploader(): Promise { const { revisionDraft, blockVerifier } = await this.createRevisionDraft(); @@ -154,9 +175,10 @@ export class FileUploader extends Uploader { private name: string, metadata: UploadMetadata, onFinish: () => void, + protected shouldUseSmallFileUpload: (expectedSize: number) => Promise, signal?: AbortSignal, ) { - super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, shouldUseSmallFileUpload, signal); this.parentFolderUid = parentFolderUid; this.name = name; @@ -192,6 +214,24 @@ export class FileUploader extends Uploader { protected async deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise { await this.manager.deleteDraftNode(revisionDraft.nodeUid); } + + protected async initSmallFileUploader( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise<{ nodeRevisionUid: string; nodeUid: string }> { + const uploader = new SmallFileUploader( + this.telemetry, + this.cryptoService, + this.manager, + this.metadata, + this.onFinish, + this.signal, + this.parentFolderUid, + this.name, + ); + return uploader.upload(stream, thumbnails, onProgress); + } } /** @@ -206,9 +246,10 @@ export class FileRevisionUploader extends Uploader { private nodeUid: string, metadata: UploadMetadata, onFinish: () => void, + protected shouldUseSmallFileUpload: (expectedSize: number) => Promise, signal?: AbortSignal, ) { - super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, shouldUseSmallFileUpload, signal); this.nodeUid = nodeUid; } @@ -243,4 +284,21 @@ export class FileRevisionUploader extends Uploader { protected async deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise { await this.manager.deleteDraftRevision(revisionDraft.nodeRevisionUid); } + + protected async initSmallFileUploader( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise<{ nodeRevisionUid: string; nodeUid: string }> { + const uploader = new SmallFileRevisionUploader( + this.telemetry, + this.cryptoService, + this.manager, + this.metadata, + this.onFinish, + this.signal, + this.nodeUid, + ); + return uploader.upload(stream, thumbnails, onProgress); + } } diff --git a/js/sdk/src/internal/upload/index.test.ts b/js/sdk/src/internal/upload/index.test.ts index 6db24bd5..d81d3877 100644 --- a/js/sdk/src/internal/upload/index.test.ts +++ b/js/sdk/src/internal/upload/index.test.ts @@ -1,18 +1,23 @@ -import { FeatureFlagProvider, FeatureFlags, UploadMetadata } from '../../interface'; +import { FeatureFlagProvider, ThumbnailType, UploadMetadata } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; -import { FileRevisionUploader, FileUploader } from './fileUploader'; +import { FileRevisionUploader, FileUploader, Uploader } from './fileUploader'; import { initUploadModule } from './index'; -import { SmallFileRevisionUploader, SmallFileUploader } from './smallFileUploader'; -const SMALL_FILE_SIZE_LIMIT = 128 * 1024; // 128 KiB, must match index.ts +const RAW_SMALL_FILE_SIZE_LIMIT = (128 * 1024) / 1.1; // 128 KiB, must match index.ts -describe('initUploadModule - uploader selection', () => { +describe('initUploadModule', () => { const parentFolderUid = 'parent-folder-uid'; const name = 'test-file.txt'; const nodeUid = 'node-uid'; let featureFlagProvider: jest.Mocked; let uploadModule: ReturnType; + let initSmallFileSpy: jest.SpyInstance; + let initSmallRevisionSpy: jest.SpyInstance; + let initStreamSpy: jest.SpyInstance; + + let stream: ReadableStream; + const thumbnail100k = { type: ThumbnailType.Type1, thumbnail: new Uint8Array(100_000) }; beforeEach(() => { const apiService = {}; @@ -31,69 +36,122 @@ describe('initUploadModule - uploader selection', () => { nodesService as any, featureFlagProvider as any, ); - }); - - describe('getFileUploader', () => { - it('returns SmallFileUploader when feature flag is enabled and file size is below limit', async () => { - featureFlagProvider.isEnabled.mockResolvedValue(true); - const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; - const uploader = await uploadModule.getFileUploader(parentFolderUid, name, metadata); - - expect(uploader).toBeInstanceOf(SmallFileUploader); + initSmallFileSpy = jest.spyOn(FileUploader.prototype as any, 'initSmallFileUploader').mockResolvedValue({ + nodeRevisionUid: 'revision-uid', + nodeUid: 'node-uid', }); - - it('returns FileUploader when feature flag is enabled but file size exceeds limit', async () => { - featureFlagProvider.isEnabled.mockResolvedValue(true); - - const metadata: UploadMetadata = { - expectedSize: SMALL_FILE_SIZE_LIMIT, - mediaType: 'text/plain', - }; - const uploader = await uploadModule.getFileUploader(parentFolderUid, name, metadata); - - expect(uploader).toBeInstanceOf(FileUploader); - }); - - it('returns FileUploader when feature flag is disabled even for small file', async () => { - featureFlagProvider.isEnabled.mockResolvedValue(false); - - const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; - const uploader = await uploadModule.getFileUploader(parentFolderUid, name, metadata); - - expect(uploader).toBeInstanceOf(FileUploader); + initSmallRevisionSpy = jest + .spyOn(FileRevisionUploader.prototype as any, 'initSmallFileUploader') + .mockResolvedValue({ + nodeRevisionUid: 'revision-uid', + nodeUid: 'node-uid', + }); + initStreamSpy = jest.spyOn(Uploader.prototype as any, 'initStreamUploader').mockResolvedValue({ + start: jest.fn().mockResolvedValue({ + nodeRevisionUid: 'revision-uid', + nodeUid: 'node-uid', + }), + } as any); + + stream = new ReadableStream({ + start(controller) { + controller.close(); + }, }); }); - describe('getFileRevisionUploader', () => { - it('returns SmallFileRevisionUploader when feature flag is enabled and file size is below limit', async () => { - featureFlagProvider.isEnabled.mockResolvedValue(true); - - const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; - const uploader = await uploadModule.getFileRevisionUploader(nodeUid, metadata); - - expect(uploader).toBeInstanceOf(SmallFileRevisionUploader); - }); - - it('returns FileRevisionUploader when feature flag is enabled but file size exceeds limit', async () => { - featureFlagProvider.isEnabled.mockResolvedValue(true); - - const metadata: UploadMetadata = { - expectedSize: SMALL_FILE_SIZE_LIMIT + 1, - mediaType: 'text/plain', - }; - const uploader = await uploadModule.getFileRevisionUploader(nodeUid, metadata); - - expect(uploader).toBeInstanceOf(FileRevisionUploader); - }); - - it('returns FileRevisionUploader when feature flag is disabled even for small file', async () => { - featureFlagProvider.isEnabled.mockResolvedValue(false); - - const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; - const uploader = await uploadModule.getFileRevisionUploader(nodeUid, metadata); + afterEach(() => { + jest.restoreAllMocks(); + }); - expect(uploader).toBeInstanceOf(FileRevisionUploader); + async function drainUpload(controller: { completion(): Promise }) { + await controller.completion(); + } + + const suites = [ + { + method: 'getFileUploader', + getUploader: (metadata: UploadMetadata) => uploadModule.getFileUploader(parentFolderUid, name, metadata), + expect: (option: 'small' | 'stream') => { + if (option === 'stream') { + expect(initStreamSpy).toHaveBeenCalled(); + expect(initSmallFileSpy).not.toHaveBeenCalled(); + expect(initSmallRevisionSpy).not.toHaveBeenCalled(); + } else { + expect(initSmallFileSpy).toHaveBeenCalled(); + expect(initStreamSpy).not.toHaveBeenCalled(); + expect(initSmallRevisionSpy).not.toHaveBeenCalled(); + } + }, + }, + { + method: 'getFileRevisionUploader', + getUploader: (metadata: UploadMetadata) => uploadModule.getFileRevisionUploader(nodeUid, metadata), + expect: (option: 'small' | 'stream') => { + if (option === 'stream') { + expect(initStreamSpy).toHaveBeenCalled(); + expect(initSmallFileSpy).not.toHaveBeenCalled(); + expect(initSmallRevisionSpy).not.toHaveBeenCalled(); + } else { + expect(initSmallRevisionSpy).toHaveBeenCalled(); + expect(initSmallFileSpy).not.toHaveBeenCalled(); + expect(initStreamSpy).not.toHaveBeenCalled(); + } + }, + }, + ]; + for (const suite of suites) { + describe(suite.method, () => { + it('uses stream path when feature flag is disabled even for small file', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(false); + + const metadata: UploadMetadata = { expectedSize: 1, mediaType: 'text/plain' }; + const uploader = await suite.getUploader(metadata); + await drainUpload(await uploader.uploadFromStream(stream, [])); + + suite.expect('stream'); + }); + + it('uses small-file path when flag is on and encrypted total size is below cap', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(true); + + const metadata: UploadMetadata = { expectedSize: 100, mediaType: 'text/plain' }; + const uploader = await suite.getUploader(metadata); + await drainUpload(await uploader.uploadFromStream(stream, [])); + + suite.expect('small'); + }); + + it('uses small-file path when flag is on and encrypted total size with thumbnails is below cap', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(true); + + const metadata: UploadMetadata = { expectedSize: 100, mediaType: 'image/jpeg' }; + const uploader = await suite.getUploader(metadata); + await drainUpload(await uploader.uploadFromStream(stream, [thumbnail100k])); + + suite.expect('small'); + }); + + it('uses stream path when feature flag is enabled but raw file size exceeds limit', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(true); + + const metadata: UploadMetadata = { expectedSize: RAW_SMALL_FILE_SIZE_LIMIT, mediaType: 'text/plain' }; + const uploader = await suite.getUploader(metadata); + await drainUpload(await uploader.uploadFromStream(stream, [])); + + suite.expect('stream'); + }); + + it('uses stream path when thumbnail bytes push encrypted total size with thumbnail exceeds limit', async () => { + featureFlagProvider.isEnabled.mockResolvedValue(true); + + const metadata: UploadMetadata = { expectedSize: 100_000, mediaType: 'image/jpeg' }; + const uploader = await suite.getUploader(metadata); + await drainUpload(await uploader.uploadFromStream(stream, [thumbnail100k])); + + suite.expect('stream'); + }); }); - }); + } }); diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 17d91356..462710ef 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -8,7 +8,6 @@ import { FileUploader as FileUploaderClass, FileRevisionUploader } from './fileU import { NodesService, SharesService } from './interface'; import { UploadManager } from './manager'; import { UploadQueue } from './queue'; -import { SmallFileRevisionUploader, SmallFileUploader } from './smallFileUploader'; import { UploadTelemetry } from './telemetry'; const SMALL_FILE_SIZE_LIMIT = 128 * 1024; // 128 KiB @@ -38,13 +37,13 @@ export function initUploadModule( const queue = new UploadQueue(); - async function useSmallFileUpload(metadata: UploadMetadata): Promise { + async function shouldUseSmallFileUpload(expectedSize: number): Promise { const isEnabled = allowSmallFileUpload && (await featureFlagProvider.isEnabled(FeatureFlags.DriveSmallFileUpload)); if (!isEnabled) { return false; } - return metadata.expectedSize < SMALL_FILE_SIZE_LIMIT; + return expectedSize < SMALL_FILE_SIZE_LIMIT; } /** @@ -66,20 +65,6 @@ export function initUploadModule( queue.releaseCapacity(metadata.expectedSize); }; - if (await useSmallFileUpload(metadata)) { - return new SmallFileUploader( - uploadTelemetry, - api, - cryptoService, - manager, - metadata, - onFinish, - signal, - parentFolderUid, - name, - ); - } - return new FileUploaderClass( uploadTelemetry, api, @@ -89,6 +74,7 @@ export function initUploadModule( name, metadata, onFinish, + shouldUseSmallFileUpload, signal, ); } @@ -111,19 +97,6 @@ export function initUploadModule( queue.releaseCapacity(metadata.expectedSize); }; - if (await useSmallFileUpload(metadata)) { - return new SmallFileRevisionUploader( - uploadTelemetry, - api, - cryptoService, - manager, - metadata, - onFinish, - signal, - nodeUid, - ); - } - return new FileRevisionUploader( uploadTelemetry, api, @@ -132,6 +105,7 @@ export function initUploadModule( nodeUid, metadata, onFinish, + shouldUseSmallFileUpload, signal, ); } diff --git a/js/sdk/src/internal/upload/smallFileUploader.test.ts b/js/sdk/src/internal/upload/smallFileUploader.test.ts index 5a719ede..0ab9fd28 100644 --- a/js/sdk/src/internal/upload/smallFileUploader.test.ts +++ b/js/sdk/src/internal/upload/smallFileUploader.test.ts @@ -6,9 +6,10 @@ import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; import { UploadManager } from './manager'; import { NodeCrypto } from './interface'; +import { mergeUint8Arrays } from '../utils'; -const MOCK_BLOCK_HASH = new Uint8Array(32).fill(1); -const MOCK_VERIFICATION_TOKEN = new Uint8Array(16).fill(2); +const MOCK_BLOCK_HASH = new Uint8Array(32).fill(4); +const MOCK_VERIFICATION_TOKEN = new Uint8Array(16).fill(5); function createStream(bytes: number[]): ReadableStream { return new ReadableStream({ @@ -108,7 +109,7 @@ describe('SmallFileUploader', () => { encryptedData: new Uint8Array(thumbnail.thumbnail), originalSize: thumbnail.thumbnail.length, encryptedSize: thumbnail.thumbnail.length + 100, - hash: 'thumbnailHash', + hashPromise: Promise.resolve(new Uint8Array(32).fill(thumbnail.type)), })), encryptBlock: jest.fn().mockImplementation(mockEncryptBlock), verifyBlock: jest.fn().mockResolvedValue({ verificationToken: MOCK_VERIFICATION_TOKEN }), @@ -138,7 +139,6 @@ describe('SmallFileUploader', () => { function createUploader() { return new SmallFileUploader( telemetry, - apiService, cryptoService, uploadManager, metadata, @@ -157,24 +157,13 @@ describe('SmallFileUploader', () => { const uploader = createUploader(); const stream = createStream([1, 2, 3]); - const controller = await uploader.uploadFromStream(stream, thumbnails, onProgress); - const result = await controller.completion(); + const result = await uploader.upload(stream, thumbnails, onProgress); expect(uploadManager.generateNewFileCrypto).toHaveBeenCalledWith(parentFolderUid, name); expect(uploadManager.uploadFile).toHaveBeenCalledTimes(1); expect(result).toEqual({ nodeUid: 'nodeUid', nodeRevisionUid: 'nodeRevisionUid' }); expect(onProgress).toHaveBeenCalledWith(metadata.expectedSize); }); - - it('should throw if upload already started', async () => { - const uploader = createUploader(); - const stream = createStream([1, 2, 3]); - - await uploader.uploadFromStream(stream, thumbnails, onProgress); - await expect(uploader.uploadFromStream(stream, thumbnails, onProgress)).rejects.toThrow( - 'Upload already started', - ); - }); }); describe('buildPayloads (via upload flow)', () => { @@ -186,8 +175,7 @@ describe('SmallFileUploader', () => { { type: ThumbnailType.Type2, thumbnail: new Uint8Array([30, 40, 50]) }, ]; - await uploader.uploadFromStream(stream, thumbnails, undefined); - await (uploader as any).controller.completion(); + await uploader.upload(stream, thumbnails, undefined); expect(uploadManager.uploadFile).toHaveBeenCalledWith( parentFolderUid, @@ -203,8 +191,16 @@ describe('SmallFileUploader', () => { verificationToken: MOCK_VERIFICATION_TOKEN, }), [ - { type: ThumbnailType.Type1, encryptedData: expect.any(Uint8Array) }, - { type: ThumbnailType.Type2, encryptedData: expect.any(Uint8Array) }, + { + type: ThumbnailType.Type1, + encryptedData: expect.any(Uint8Array), + blockHash: new Uint8Array(32).fill(ThumbnailType.Type1), + }, + { + type: ThumbnailType.Type2, + encryptedData: expect.any(Uint8Array), + blockHash: new Uint8Array(32).fill(ThumbnailType.Type2), + }, ], ); @@ -212,7 +208,11 @@ describe('SmallFileUploader', () => { expect(cryptoService.encryptThumbnail).toHaveBeenCalledTimes(2); expect(cryptoService.commitFile).toHaveBeenCalledWith( expect.anything(), - MOCK_BLOCK_HASH, + mergeUint8Arrays([ + new Uint8Array(32).fill(ThumbnailType.Type1), + new Uint8Array(32).fill(ThumbnailType.Type2), + MOCK_BLOCK_HASH, + ]), expect.any(String), ); }); @@ -223,8 +223,7 @@ describe('SmallFileUploader', () => { metadata.expectedSize = content.length; const stream = createStream(content); - await uploader.uploadFromStream(stream, [], undefined); - await (uploader as any).controller.completion(); + await uploader.upload(stream, [], undefined); expect(cryptoService.encryptBlock).toHaveBeenCalledWith( expect.any(Function), @@ -241,8 +240,7 @@ describe('SmallFileUploader', () => { ]; const stream = createStream([1, 2, 3]); - await uploader.uploadFromStream(stream, thumbnails, undefined); - await (uploader as any).controller.completion(); + await uploader.upload(stream, thumbnails, undefined); expect(cryptoService.encryptThumbnail).toHaveBeenCalledWith( expect.objectContaining({ @@ -259,8 +257,7 @@ describe('SmallFileUploader', () => { const uploader = createUploader(); const stream = createStream([1, 2, 3]); - await uploader.uploadFromStream(stream, [], undefined); - await (uploader as any).controller.completion(); + await uploader.upload(stream, [], undefined); const [nodeKeys, manifest, extendedAttributes] = (cryptoService.commitFile as jest.Mock).mock.calls[0]; expect(manifest).toEqual(MOCK_BLOCK_HASH); @@ -275,10 +272,10 @@ describe('SmallFileUploader', () => { metadata.expectedSize = 5; const stream = createStream([1, 2, 3]); // only 3 bytes - const controller = await uploader.uploadFromStream(stream, [], undefined); + const promise = uploader.upload(stream, [], undefined); - await expect(controller.completion()).rejects.toThrow(IntegrityError); - await expect(controller.completion()).rejects.toMatchObject({ + await expect(promise).rejects.toThrow(IntegrityError); + await expect(promise).rejects.toMatchObject({ debug: { actual: 3, expected: 5 }, }); }); @@ -288,10 +285,10 @@ describe('SmallFileUploader', () => { metadata.expectedSha1 = 'a'.repeat(40); // wrong sha1 const stream = createStream([1, 2, 3]); - const controller = await uploader.uploadFromStream(stream, [], undefined); + const promise = uploader.upload(stream, [], undefined); - await expect(controller.completion()).rejects.toThrow(IntegrityError); - await expect(controller.completion()).rejects.toMatchObject({ + await expect(promise).rejects.toThrow(IntegrityError); + await expect(promise).rejects.toMatchObject({ debug: expect.objectContaining({ expectedSha1: 'a'.repeat(40), }), @@ -306,8 +303,7 @@ describe('SmallFileUploader', () => { const stream = createStream([]); const onProgress = jest.fn(); - const controller = await uploader.uploadFromStream(stream, [], onProgress); - const result = await controller.completion(); + const result = await uploader.upload(stream, [], onProgress); expect(result).toEqual({ nodeUid: 'nodeUid', nodeRevisionUid: 'nodeRevisionUid' }); expect(cryptoService.encryptBlock).not.toHaveBeenCalled(); @@ -430,7 +426,6 @@ describe('SmallFileRevisionUploader', () => { function createUploader() { return new SmallFileRevisionUploader( telemetry, - apiService, cryptoService, uploadManager, metadata, @@ -444,8 +439,7 @@ describe('SmallFileRevisionUploader', () => { const uploader = createUploader(); const stream = createStream([1, 2, 3]); - const controller = await uploader.uploadFromStream(stream, [], undefined); - const result = await controller.completion(); + const result = await uploader.upload(stream, [], undefined); expect(result).toEqual({ nodeUid: 'nodeUid', nodeRevisionUid: 'nodeRevisionUid' }); expect(cryptoService.encryptBlock).toHaveBeenCalledWith(expect.any(Function), expect.anything(), Uint8Array.from([1, 2, 3]), 0); @@ -471,8 +465,7 @@ describe('SmallFileRevisionUploader', () => { const uploader = createUploader(); const stream = createStream([]); - const controller = await uploader.uploadFromStream(stream, [], undefined); - const result = await controller.completion(); + const result = await uploader.upload(stream, [], undefined); expect(result).toEqual({ nodeUid: 'nodeUid', nodeRevisionUid: 'nodeRevisionUid' }); expect(cryptoService.encryptBlock).not.toHaveBeenCalled(); diff --git a/js/sdk/src/internal/upload/smallFileUploader.ts b/js/sdk/src/internal/upload/smallFileUploader.ts index 5f654f8e..02e27e4b 100644 --- a/js/sdk/src/internal/upload/smallFileUploader.ts +++ b/js/sdk/src/internal/upload/smallFileUploader.ts @@ -3,12 +3,11 @@ import { AbortError, IntegrityError } from '../../errors'; import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; import { getErrorMessage } from '../errors'; import { generateFileExtendedAttributes } from '../nodes'; -import { UploadAPIService } from './apiService'; -import { BlockVerifier, verifyBlockWithContentKey } from './blockVerifier'; +import { mergeUint8Arrays } from '../utils'; +import { verifyBlockWithContentKey } from './blockVerifier'; import { UploadCryptoService } from './cryptoService'; import { UploadDigests } from './digests'; -import { Uploader } from './fileUploader'; -import { NodeRevisionDraft, NodeCrypto } from './interface'; +import { NodeCrypto } from './interface'; import { UploadManager } from './manager'; import { readStreamToUint8Array } from './streamReader'; import { MAX_BLOCK_ENCRYPTION_RETRIES } from './streamUploader'; @@ -25,34 +24,23 @@ export type NodeKeys = { * Base uploader for small file and small revision uploads. * Shares the single-request flow: read content, get node crypto, encrypt, then call API. */ -abstract class SmallUploader extends Uploader { +abstract class SmallUploader { protected logger: Logger; + protected abortController: AbortController; constructor( - telemetry: UploadTelemetry, - apiService: UploadAPIService, - cryptoService: UploadCryptoService, - manager: UploadManager, - metadata: UploadMetadata, - onFinish: () => void, - signal: AbortSignal | undefined, + protected telemetry: UploadTelemetry, + protected cryptoService: UploadCryptoService, + protected manager: UploadManager, + protected metadata: UploadMetadata, + protected onFinish: () => void, + protected signal: AbortSignal | undefined, ) { - super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); this.logger = telemetry.getLoggerForSmallUpload(); - } - protected async createRevisionDraft(): Promise<{ - revisionDraft: NodeRevisionDraft; - blockVerifier: BlockVerifier; - }> { - throw new Error('Small upload does not use revision draft'); + this.abortController = new AbortController(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise { - throw new Error('Small upload does not use revision draft'); - } - - protected async startUpload( + async upload( stream: ReadableStream, thumbnails: Thumbnail[], onProgress?: (uploadedBytes: number) => void, @@ -105,7 +93,8 @@ abstract class SmallUploader extends Uploader { this.encryptThumbnails(nodeKeys, thumbnails), this.encryptContentBlock(nodeKeys, content.data), ]); - const commitPayload = await this.encryptCommitPayload(nodeKeys, content.sha1, encryptedBlock); + const manifest = await this.getManifest(encryptedBlock, encryptedThumbnails); + const commitPayload = await this.encryptCommitPayload(nodeKeys, content.sha1, manifest); return { commitPayload, @@ -147,12 +136,22 @@ abstract class SmallUploader extends Uploader { private async encryptThumbnails( nodeKeys: NodeKeys, thumbnails: Thumbnail[], - ): Promise<{ type: ThumbnailType; encryptedData: Uint8Array }[]> { + ): Promise< + { + type: ThumbnailType; + encryptedData: Uint8Array; + blockHash: Uint8Array; + }[] + > { const result = []; for (const thumbnail of thumbnails) { this.logger.debug(`Encrypting thumbnail ${thumbnail.type}`); const enc = await this.cryptoService.encryptThumbnail(nodeKeys, thumbnail); - result.push({ type: thumbnail.type, encryptedData: enc.encryptedData }); + result.push({ + type: thumbnail.type, + encryptedData: enc.encryptedData, + blockHash: await enc.hashPromise, + }); } return result; } @@ -228,21 +227,35 @@ abstract class SmallUploader extends Uploader { }; } - private async encryptCommitPayload( - nodeKeys: NodeKeys, - contentSha1: string, + private async getManifest( encryptedBlock: | { blockHash: Uint8Array; } | undefined, + encryptedThumbnails: { + type: ThumbnailType; + blockHash: Uint8Array; + }[], + ): Promise> { + encryptedThumbnails.sort((a, b) => a.type - b.type); + const hashes = [ + ...(await Promise.all(encryptedThumbnails.map(({ blockHash }) => blockHash))), + ...(encryptedBlock ? [encryptedBlock.blockHash] : []), + ]; + return mergeUint8Arrays(hashes); + } + + private async encryptCommitPayload( + nodeKeys: NodeKeys, + contentSha1: string, + manifest: Uint8Array, ): Promise<{ armoredManifestSignature: string; armoredExtendedAttributes: string; }> { this.logger.debug(`Preparing commit payload`); - const manifest = encryptedBlock ? encryptedBlock.blockHash : new Uint8Array(0); const extendedAttributes = generateFileExtendedAttributes( { modificationTime: this.metadata.modificationTime, @@ -266,7 +279,6 @@ abstract class SmallUploader extends Uploader { export class SmallFileUploader extends SmallUploader { constructor( telemetry: UploadTelemetry, - apiService: UploadAPIService, cryptoService: UploadCryptoService, manager: UploadManager, metadata: UploadMetadata, @@ -275,7 +287,7 @@ export class SmallFileUploader extends SmallUploader { private parentFolderUid: string, private name: string, ) { - super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); + super(telemetry, cryptoService, manager, metadata, onFinish, signal); this.parentFolderUid = parentFolderUid; this.name = name; } @@ -317,7 +329,6 @@ export class SmallFileUploader extends SmallUploader { export class SmallFileRevisionUploader extends SmallUploader { constructor( telemetry: UploadTelemetry, - apiService: UploadAPIService, cryptoService: UploadCryptoService, manager: UploadManager, metadata: UploadMetadata, @@ -325,7 +336,7 @@ export class SmallFileRevisionUploader extends SmallUploader { signal: AbortSignal | undefined, private nodeUid: string, ) { - super(telemetry, apiService, cryptoService, manager, metadata, onFinish, signal); + super(telemetry, cryptoService, manager, metadata, onFinish, signal); this.nodeUid = nodeUid; } From fefab4d5d345d89eb44802e87724a702b60d6f1c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 13:43:03 +0100 Subject: [PATCH 645/791] Update nodes after shared with me updated event --- js/sdk/src/internal/sharing/events.test.ts | 37 +++++++++++++- js/sdk/src/internal/sharing/events.ts | 57 ++++++++++++++++++---- js/sdk/src/internal/sharing/index.ts | 1 + 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index 087ad79a..86d0c3dc 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -4,6 +4,7 @@ import { SharingCache } from './cache'; import { SharingAccess } from './sharingAccess'; import { SharingEventHandler } from './events'; import { SharesManager } from '../shares/manager'; +import { NodesService } from './interface'; // FIXME: test tree_refresh and tree_remove @@ -11,6 +12,7 @@ describe('handleSharedByMeNodes', () => { let cache: SharingCache; let sharingEventHandler: SharingEventHandler; let sharesManager: SharesManager; + let nodesService: NodesService; beforeEach(() => { jest.clearAllMocks(); @@ -26,7 +28,11 @@ describe('handleSharedByMeNodes', () => { sharesManager = { isOwnVolume: jest.fn(async (volumeId: string) => volumeId === 'MyVolume1'), } as any; - sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager); + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + notifyNodeChanged: jest.fn(), + }; + sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService); }); it('should add if new own shared node is created', async () => { @@ -138,12 +144,14 @@ describe('handleSharedWithMeNodes', () => { let cache: SharingCache; let sharingAccess: SharingAccess; let sharesManager: SharesManager; + let nodesService: NodesService; beforeEach(() => { jest.clearAllMocks(); // @ts-expect-error No need to implement all methods for mocking cache = { + hasSharedWithMeNodeUidsLoaded: jest.fn().mockResolvedValue(false), getSharedWithMeNodeUids: jest.fn(), setSharedWithMeNodeUids: jest.fn(), }; @@ -154,6 +162,10 @@ describe('handleSharedWithMeNodes', () => { sharesManager = { isOwnVolume: jest.fn(async (volumeId: string) => volumeId === 'MyVolume1'), } as any; + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + notifyNodeChanged: jest.fn(), + }; }); it('should update cache', async () => { @@ -163,11 +175,32 @@ describe('handleSharedWithMeNodes', () => { treeEventScopeId: 'core', }; - const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager); + const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService); await sharingEventHandler.handleDriveEvent(event); expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); expect(cache.getSharedWithMeNodeUids).not.toHaveBeenCalled(); expect(sharingAccess.iterateSharedNodesWithMe).not.toHaveBeenCalled(); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); + }); + + it('should notify nodes changes', async () => { + cache.hasSharedWithMeNodeUidsLoaded = jest.fn().mockResolvedValue(true); + cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(['nodeUid1', 'nodeUid2']); + + const event: DriveEvent = { + type: DriveEventType.SharedWithMeUpdated, + eventId: 'event1', + treeEventScopeId: 'core', + }; + + const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService); + await sharingEventHandler.handleDriveEvent(event); + + expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); + expect(cache.getSharedWithMeNodeUids).toHaveBeenCalled(); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('nodeUid1'); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('nodeUid2'); }); }); diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index dc84f502..955fefa1 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -1,13 +1,14 @@ import { Logger } from '../../interface'; import { DriveEvent, DriveEventType } from '../events'; import { SharingCache } from './cache'; -import { SharesService } from './interface'; +import { NodesService, SharesService } from './interface'; export class SharingEventHandler { constructor( private logger: Logger, private cache: SharingCache, private shares: SharesService, + private nodesService: NodesService, ) {} /** @@ -26,23 +27,59 @@ export class SharingEventHandler { */ async handleDriveEvent(event: DriveEvent) { try { - if (event.type === DriveEventType.SharedWithMeUpdated) { - await this.cache.setSharedWithMeNodeUids(undefined); - return; - } + await this.handleSharedWithMeNodeUidsLoaded(event); await this.handleSharedByMeNodeUidsLoaded(event); } catch (error: unknown) { - this.logger.error(`Skipping shared by me node cache update`, error); + this.logger.error(`Skipping sharing cache update`, error); } } - private async handleSharedByMeNodeUidsLoaded(event: DriveEvent) { - if (event.type === DriveEventType.TreeRefresh || event.type === DriveEventType.TreeRemove) { - await this.cache.setSharedWithMeNodeUids(undefined); + private async handleSharedWithMeNodeUidsLoaded(event: DriveEvent) { + if ( + ![DriveEventType.SharedWithMeUpdated, DriveEventType.TreeRefresh, DriveEventType.TreeRemove].includes( + event.type, + ) + ) { return; } - if (![DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.NodeDeleted].includes(event.type)) { + // When user changes the membership (permissions) for a user, the + // backend emits both NodeUpdated and SharedWithMeUpdated events. + // Ideally, the SDK doesn't have to refresh all the shared nodes, + // only those that were changed via the NodeUpdated event. However, + // the client very likely will not be subscribed to all shared volumes. + // When the client only lists the list itself and not the trees, it + // is still required to refresh all the nodes to be sure to have the + // latest state. + // The sharing module doesn't have access to the nodes cache, thus + // it notifies the nodes via the service. If this fails, we need to + // log it, but it should not block the event handling. The node might + // be wrong at the "shared with me" listing, but it will be eventually + // updated once the user opens the volume tree and client processes + // the events for that volume. + // Ideally, in the future, the Drive API provides a custom event with + // indication of what node was added or removed or updated, instead + // of emitting destructive SharedWithMeUpdated event. + const hasSharedWithMeLoaded = await this.cache.hasSharedWithMeNodeUidsLoaded(); + if (event.type === DriveEventType.SharedWithMeUpdated && hasSharedWithMeLoaded) { + try { + const sharedWithMeNodeUids = await this.cache.getSharedWithMeNodeUids(); + this.logger.debug(`Shared with me updated, notifying ${sharedWithMeNodeUids.length} nodes`); + for (const nodeUid of sharedWithMeNodeUids) { + await this.nodesService.notifyNodeChanged(nodeUid); + } + } catch (error: unknown) { + this.logger.error(`Skipping shared with me node cache update`, error); + } + } + + await this.cache.setSharedWithMeNodeUids(undefined); + } + + private async handleSharedByMeNodeUidsLoaded(event: DriveEvent) { + if ( + ![DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.NodeDeleted].includes(event.type) + ) { return; } diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index 2951e288..8dadab4e 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -48,6 +48,7 @@ export function initSharingModule( telemetry.getLogger('sharing-event-handler'), cache, sharesService, + nodesService, ); return { From 7bb088e82069bd54eb11147566a8f6424bc70e2c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 17:01:53 +0100 Subject: [PATCH 646/791] Remove the need to dispose of Photos client --- .../src/Proton.Drive.Sdk/ProtonPhotosClient.cs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index f079d48a..b970ab03 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -12,10 +12,8 @@ namespace Proton.Drive.Sdk; -public sealed class ProtonPhotosClient : IDisposable +public sealed class ProtonPhotosClient { - private readonly HttpClient _httpClient; - public ProtonPhotosClient(ProtonApiSession session, string? uid = null) { DriveClient = new ProtonDriveClient( @@ -23,9 +21,9 @@ public ProtonPhotosClient(ProtonApiSession session, string? uid = null) (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), uid); - _httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)); + var httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)); - PhotosApi = new PhotosApiClient(_httpClient); + PhotosApi = new PhotosApiClient(httpClient); } public ProtonPhotosClient( @@ -47,10 +45,10 @@ public ProtonPhotosClient( (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), creationParameters); - _httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout( + var httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout( creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds); - PhotosApi = new PhotosApiClient(_httpClient); + PhotosApi = new PhotosApiClient(httpClient); } internal IPhotosApiClient PhotosApi { get; } @@ -107,11 +105,6 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( return FileOperations.EnumerateThumbnailsAsync(DriveClient, photoUids, thumbnailType, forPhotos: true, cancellationToken); } - public void Dispose() - { - _httpClient.Dispose(); - } - [Experimental("Photos")] internal ValueTask GetPhotosRootAsync(CancellationToken cancellationToken) { From 4827a04cb003e57da018103a7d3b4c4e96988314 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 05:01:39 +0000 Subject: [PATCH 647/791] Add streaming thumbnails enumeration to Swift bindings --- .../ProtonDriveClient/ProtonDriveClient.swift | 8 ++-- .../ProtonPhotosClient.swift | 8 ++-- .../Downloads/DownloadThumbnailsManager.swift | 38 +++++++++------ .../cThumbnailEnumerationCallback.swift | 46 +++++++++++++++++++ .../Sources/Plumbing/Message+Packaging.swift | 8 ++-- .../Sources/Plumbing/PublicTypes.swift | 17 +++++++ 6 files changed, 100 insertions(+), 25 deletions(-) create mode 100644 swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 0912c9e6..d05db90f 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -325,12 +325,14 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { public func downloadThumbnails( fileUids: [SDKNodeUid], type: ThumbnailData.ThumbnailType, - cancellationToken: UUID - ) async throws -> [ThumbnailDataWithId] { + cancellationToken: UUID, + onThumbnailDownloaded: @escaping ThumbnailCallback + ) async throws { try await thumbnailsManager.downloadThumbnails( fileUids: fileUids, type: type, - cancellationToken: cancellationToken + cancellationToken: cancellationToken, + onThumbnailDownloaded: onThumbnailDownloaded ) } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 7903664b..3d5c7896 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -139,12 +139,14 @@ extension ProtonPhotosClient { public func downloadThumbnails( photoUids: [SDKNodeUid], type: ThumbnailData.ThumbnailType, - cancellationToken: UUID - ) async throws -> [ThumbnailDataWithId] { + cancellationToken: UUID, + onThumbnailDownloaded: @escaping ThumbnailCallback + ) async throws { try await thumbnailsManager.downloadPhotoThumbnails( photoUids: photoUids, type: type, - cancellationToken: cancellationToken + cancellationToken: cancellationToken, + onThumbnailDownloaded: onThumbnailDownloaded ) } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift index c8d1d926..d0bc10df 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift @@ -22,8 +22,9 @@ actor DownloadThumbnailsManager { func downloadThumbnails( fileUids: [SDKNodeUid], type: ThumbnailData.ThumbnailType, - cancellationToken: UUID - ) async throws -> [ThumbnailDataWithId] { + cancellationToken: UUID, + onThumbnailDownloaded: @escaping ThumbnailCallback + ) async throws { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) activeDownloads[cancellationToken] = cancellationTokenSource @@ -34,27 +35,31 @@ actor DownloadThumbnailsManager { } } - let thumbnailsRequest = Proton_Drive_Sdk_DriveClientGetThumbnailsRequest.with { + let thumbnailsRequest = Proton_Drive_Sdk_DriveClientEnumerateThumbnailsRequest.with { $0.clientHandle = Int64(clientHandle) - $0.type = type.sdkType $0.fileUids = fileUids.map(\.sdkCompatibleIdentifier) + $0.type = type.sdkType $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + $0.iterateAction = Int64(ObjectHandle(callback: cThumbnailEnumerationCallback)) } - let thumbnailsList: Proton_Drive_Sdk_FileThumbnailList = try await SDKRequestHandler.send( + let callbackState = ThumbnailEnumerationCallbackWrapper(callback: onThumbnailDownloaded) + + let _: Void = try await SDKRequestHandler.send( thumbnailsRequest, + state: WeakReference(value: callbackState), + scope: .ownerManaged, + owner: callbackState, logger: logger ) - return thumbnailsList.thumbnails.compactMap { - ThumbnailDataWithId(fileThumbnail: $0) - } } func downloadPhotoThumbnails( photoUids: [SDKNodeUid], type: ThumbnailData.ThumbnailType, - cancellationToken: UUID - ) async throws -> [ThumbnailDataWithId] { + cancellationToken: UUID, + onThumbnailDownloaded: @escaping ThumbnailCallback + ) async throws { let cancellationTokenSource = try await CancellationTokenSource(logger: logger) activeDownloads[cancellationToken] = cancellationTokenSource @@ -65,20 +70,23 @@ actor DownloadThumbnailsManager { } } - let thumbnailsRequest = Proton_Drive_Sdk_DrivePhotosClientGetThumbnailsRequest.with { + let thumbnailsRequest = Proton_Drive_Sdk_DrivePhotosClientEnumerateThumbnailsRequest.with { $0.clientHandle = Int64(clientHandle) $0.photoUids = photoUids.map(\.sdkCompatibleIdentifier) $0.type = type.sdkType $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + $0.iterateAction = Int64(ObjectHandle(callback: cThumbnailEnumerationCallback)) } - let thumbnailsList: Proton_Drive_Sdk_FileThumbnailList = try await SDKRequestHandler.send( + let callbackState = ThumbnailEnumerationCallbackWrapper(callback: onThumbnailDownloaded) + + let _: Void = try await SDKRequestHandler.send( thumbnailsRequest, + state: WeakReference(value: callbackState), + scope: .ownerManaged, + owner: callbackState, logger: logger ) - return thumbnailsList.thumbnails.compactMap { - ThumbnailDataWithId(fileThumbnail: $0) - } } func cancelDownload(with cancellationToken: UUID) async throws { diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift new file mode 100644 index 00000000..785a0a2c --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Proton AG +// +// This file is part of Proton Drive. +// +// Proton Drive is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Drive is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Drive. If not, see https://www.gnu.org/licenses/. + +import Foundation + +final class ThumbnailEnumerationCallbackWrapper: Sendable { + let callback: ThumbnailCallback + + init(callback: @escaping ThumbnailCallback) { + self.callback = callback + } + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: self) + } +} + +let cThumbnailEnumerationCallback: CCallback = { statePointer, byteArray in + typealias BoxType = BoxedCompletionBlock> + let fileThumbnail = Proton_Drive_Sdk_FileThumbnail(byteArray: byteArray) + let result = ThumbnailDataWithId(fileThumbnail: fileThumbnail) + + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + let message = "cProgressCallback.statePointer is nil" + assertionFailure(message) + // there is no way we can inform the SDK back about the issue + return + } + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + let weakWrapper: WeakReference = stateTypedPointer.takeUnretainedValue().state + weakWrapper.value?.callback(.success(result)) +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift index 43a6772b..8354dff9 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -91,9 +91,9 @@ extension Message { $0.payload = .driveClientTrashNodes(request) } - case let request as Proton_Drive_Sdk_DriveClientGetThumbnailsRequest: + case let request as Proton_Drive_Sdk_DriveClientEnumerateThumbnailsRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .driveClientGetThumbnails(request) + $0.payload = .driveClientEnumerateThumbnails(request) } // MARK: - Uploads @@ -202,9 +202,9 @@ extension Message { $0.payload = .drivePhotosClientFree(request) } - case let request as Proton_Drive_Sdk_DrivePhotosClientGetThumbnailsRequest: + case let request as Proton_Drive_Sdk_DrivePhotosClientEnumerateThumbnailsRequest: Proton_Drive_Sdk_Request.with { - $0.payload = .drivePhotosClientEnumeratePhotosThumbnails(request) + $0.payload = .drivePhotosClientEnumerateThumbnails(request) } case let request as Proton_Drive_Sdk_DrivePhotosClientEnumerateTimelineRequest: diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 2eb69b8b..55183ae6 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -257,6 +257,9 @@ public struct FileOperationProgress { } } +/// Callback for thumbnail updates +public typealias ThumbnailCallback = @Sendable (Result) -> Void + /// Thumbnail with file id public struct ThumbnailDataWithId: Sendable { public let fileUid: SDKNodeUid @@ -277,4 +280,18 @@ public struct ThumbnailDataWithId: Sendable { return nil } } + + #if DEBUG + // Only for test + public init?(uid: SDKNodeUid, successData: Data?, errorMessage: String?) { + self.fileUid = uid + if let successData { + self.result = .success(successData) + } else if let errorMessage { + self.result = .failure(.init(message: errorMessage)) + } else { + return nil + } + } + #endif } From 7834b459bea8f2f1e3f3f8ac9d09f30b205a8274 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 05:03:42 +0000 Subject: [PATCH 648/791] i18n(weekly-mr): Upgrade translations from crowdin (4827a04c). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 3 --- js/sdk/locales/ca_ES.json | 3 --- js/sdk/locales/de_DE.json | 3 --- js/sdk/locales/el_GR.json | 3 --- js/sdk/locales/es_ES.json | 3 --- js/sdk/locales/es_LA.json | 3 --- js/sdk/locales/fr_FR.json | 3 --- js/sdk/locales/it_IT.json | 3 --- js/sdk/locales/ko_KR.json | 3 --- js/sdk/locales/nl_NL.json | 3 --- js/sdk/locales/ro_RO.json | 3 --- js/sdk/locales/sk_SK.json | 3 --- js/sdk/locales/tr_TR.json | 3 --- 14 files changed, 1 insertion(+), 40 deletions(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index 6513d855..d527bb42 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "a9f94fb09851c792e91809805f16ae69dcf1913f" + "locale": "b171444884dcc3d920fe0131556f7e38949a5c2a" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 3b257349..6a6560ae 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "Ðльбом змÑшчае фатаграфіі, Ñкіх нÑма Ñž шкале чаÑу" - ], "Bookmark password is not available": [ "Пароль закладкі недаÑтупны" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index a6944395..8d91b9b7 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "L'àlbum conté fotos que no són a la línia temporal" - ], "Bookmark password is not available": [ "La contrasenya d'adreces d'interès no està disponible." ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 35bcdabc..5c666147 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "Das Album enthält Fotos, die nicht in der Zeitleiste enthalten sind" - ], "Bookmark password is not available": [ "Lesezeichen-Passwort ist nicht verfügbar" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index 56801238..7aa40919 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "Το άλμπουμ πεÏιέχει φωτογÏαφίες που δε βÏίσκονται στο χÏονολόγιο" - ], "Bookmark password is not available": [ "Η σελιδοδείκτηση ÎºÏ‰Î´Î¹ÎºÎ¿Ï Î´ÎµÎ½ είναι διαθέσιμη" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 8fd821a0..6ce92e3d 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "El álbum contiene fotos que no están en la línea de tiempo" - ], "Bookmark password is not available": [ "La contraseña del marcador no está disponible" ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 46b8f247..5bef8b43 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "El álbum contiene fotos que no están en la línea de tiempo" - ], "Bookmark password is not available": [ "La contraseña del marcador no está disponible." ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index 504bfa4a..ee6983b4 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "L'album contient des photos qui ne figurent pas dans la chronologie." - ], "Bookmark password is not available": [ "Le mot de passe du favori n'est pas disponible." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index cdbb93fd..0ddb0cc4 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "L'album contiene foto non presenti nella cronologia" - ], "Bookmark password is not available": [ "La password del segnalibro non è disponibile." ], diff --git a/js/sdk/locales/ko_KR.json b/js/sdk/locales/ko_KR.json index 83db06fa..5bc7fac7 100644 --- a/js/sdk/locales/ko_KR.json +++ b/js/sdk/locales/ko_KR.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "ì•¨ë²”ì— íƒ€ìž„ë¼ì¸ì— 존재하지 않는 ì‚¬ì§„ì´ í¬í•¨ë˜ì–´ 있습니다" - ], "Bookmark password is not available": [ "ë¶ë§ˆí¬ 비밀번호를 사용할 수 없습니다" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index 15e83976..70c15343 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "Album bevat foto's die niet in de tijdlijn staan" - ], "Bookmark password is not available": [ "Bladwijzerwachtwoord is niet beschikbaar" ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index 27b540c8..c6c611fb 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "Albumul conÈ›ine fotografii care nu sunt în cronologie." - ], "Bookmark password is not available": [ "Parola semnului de carte nu este disponibilă." ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index 785ec7dc..cef8303f 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "Album obsahuje fotografie, ktoré nie sú v Äasovej osi" - ], "Bookmark password is not available": [ "Heslo záložky nie je dostupné" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index f0c74990..1e6827cd 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -5,9 +5,6 @@ }, "contexts": { "Error": { - "Album contains photos not in timeline": [ - "Albümde zaman akışınızda olmayan fotoÄŸraflar var" - ], "Bookmark password is not available": [ "Yer imi parolası kullanılamıyor" ], From 70163647491b895144386dcd7593ac654682007a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 09:23:00 +0100 Subject: [PATCH 649/791] Return all possible items from batch loading --- js/sdk/src/internal/batchLoading.test.ts | 104 ++++++++++++++++++ js/sdk/src/internal/batchLoading.ts | 26 ++++- js/sdk/src/internal/nodes/nodesAccess.test.ts | 7 +- js/sdk/src/internal/nodes/nodesAccess.ts | 2 +- 4 files changed, 130 insertions(+), 9 deletions(-) diff --git a/js/sdk/src/internal/batchLoading.test.ts b/js/sdk/src/internal/batchLoading.test.ts index 5f5f3f8d..86e07f7e 100644 --- a/js/sdk/src/internal/batchLoading.test.ts +++ b/js/sdk/src/internal/batchLoading.test.ts @@ -1,3 +1,4 @@ +import { ProtonDriveError } from '../errors'; import { BatchLoading } from './batchLoading'; describe('BatchLoading', () => { @@ -54,4 +55,107 @@ describe('BatchLoading', () => { expect(iterateItems).toHaveBeenNthCalledWith(2, ['c', 'd']); expect(iterateItems).toHaveBeenNthCalledWith(3, ['e']); }); + + it('should capture loadItems failure, continue with next batches, and throw at loadRest', async () => { + const loadItems = jest.fn((items: string[]) => { + if (items.includes('a')) { + return Promise.reject(new Error('loader failed')); + } + return Promise.resolve(items.map((item) => `loaded:${item}`)); + }); + + batchLoading = new BatchLoading({ loadItems, batchSize: 2 }); + + const result: string[] = []; + for (const item of ['a', 'b', 'c', 'd']) { + for await (const loadedItem of batchLoading.load(item)) { + result.push(loadedItem); + } + } + + let thrown: unknown; + try { + for await (const loadedItem of batchLoading.loadRest()) { + result.push(loadedItem); + } + } catch (e) { + thrown = e; + } + + expect(result).toEqual(['loaded:c', 'loaded:d']); + expect(loadItems).toHaveBeenCalledTimes(2); + expect(thrown).toBeInstanceOf(ProtonDriveError); + expect((thrown as ProtonDriveError).cause).toEqual([expect.objectContaining({ message: 'loader failed' })]); + }); + + it('should capture iterateItems failure, continue with next batches, and throw at loadRest', async () => { + const iterateItems = jest.fn(async function* (items: string[]) { + for (const item of items) { + if (item !== 'a') { + yield `loaded:${item}`; + } + } + if (items.includes('a')) { + throw new Error('iterator failed'); + } + }); + + batchLoading = new BatchLoading({ iterateItems, batchSize: 2 }); + + const result: string[] = []; + for (const item of ['a', 'b', 'c', 'd']) { + for await (const loadedItem of batchLoading.load(item)) { + result.push(loadedItem); + } + } + + let thrown: unknown; + try { + for await (const loadedItem of batchLoading.loadRest()) { + result.push(loadedItem); + } + } catch (e) { + thrown = e; + } + + expect(result).toEqual(['loaded:b', 'loaded:c', 'loaded:d']); + expect(iterateItems).toHaveBeenCalledTimes(2); + expect(thrown).toBeInstanceOf(ProtonDriveError); + expect((thrown as ProtonDriveError).cause).toEqual([expect.objectContaining({ message: 'iterator failed' })]); + }); + + it('should throw ProtonDriveError with causes when multiple batches fail', async () => { + const loadItems = jest.fn((items: string[]) => { + if (items.includes('a') || items.includes('e')) { + return Promise.reject(new Error(`failed:${items.join(',')}`)); + } + return Promise.resolve(items.map((item) => `loaded:${item}`)); + }); + + batchLoading = new BatchLoading({ loadItems, batchSize: 2 }); + + const result: string[] = []; + for (const item of ['a', 'b', 'c', 'd', 'e', 'f']) { + for await (const loadedItem of batchLoading.load(item)) { + result.push(loadedItem); + } + } + + let thrown: unknown; + try { + for await (const loadedItem of batchLoading.loadRest()) { + result.push(loadedItem); + } + } catch (e) { + thrown = e; + } + + expect(result).toEqual(['loaded:c', 'loaded:d']); + expect(thrown).toBeInstanceOf(ProtonDriveError); + expect((thrown as ProtonDriveError).cause).toEqual([ + expect.objectContaining({ message: 'failed:a,b' }), + expect.objectContaining({ message: 'failed:e,f' }), + ]); + expect(loadItems).toHaveBeenCalledTimes(3); + }); }); diff --git a/js/sdk/src/internal/batchLoading.ts b/js/sdk/src/internal/batchLoading.ts index fc54d08b..f45ad471 100644 --- a/js/sdk/src/internal/batchLoading.ts +++ b/js/sdk/src/internal/batchLoading.ts @@ -1,3 +1,7 @@ +import { c } from 'ttag'; + +import { ProtonDriveError } from '../errors'; + const DEFAULT_BATCH_LOADING = 10; /** @@ -28,6 +32,8 @@ export class BatchLoading { private itemsToFetch: ID[]; + private errors: unknown[] = []; + constructor(options: { loadItems?: (ids: ID[]) => Promise; iterateItems?: (ids: ID[]) => AsyncGenerator; @@ -58,17 +64,27 @@ export class BatchLoading { this.itemsToFetch.push(nodeUid); if (this.itemsToFetch.length >= this.batchSize) { - yield* this.iterateItems(this.itemsToFetch); + yield* this.iterateItemsWithErrorHandling(this.itemsToFetch); this.itemsToFetch = []; } } async *loadRest() { - if (this.itemsToFetch.length === 0) { - return; + if (this.itemsToFetch.length > 0) { + yield* this.iterateItemsWithErrorHandling(this.itemsToFetch); + this.itemsToFetch = []; } - yield* this.iterateItems(this.itemsToFetch); - this.itemsToFetch = []; + if (this.errors.length > 0) { + throw new ProtonDriveError(c('Error').t`Failed to load some items`, { cause: this.errors }); + } + } + + private async *iterateItemsWithErrorHandling(items: ID[]) { + try { + yield* this.iterateItems(items); + } catch (error) { + this.errors.push(error); + } } } diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 31b7a896..ff1d72b9 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -1,6 +1,6 @@ import { getMockTelemetry } from '../../tests/telemetry'; import { PrivateKey } from '../../crypto'; -import { DecryptionError } from '../../errors'; +import { DecryptionError, ProtonDriveError } from '../../errors'; import { NodeAPIService } from './apiService'; import { NodesCache } from './cache'; import { NodesCryptoCache } from './cryptoCache'; @@ -327,11 +327,12 @@ describe('nodesAccess', () => { const node2 = await generator.next(); expect(node2.value).toMatchObject({ uid: 'volumeId~node3' }); const node3 = generator.next(); - await expect(node3).rejects.toThrow('Failed to decrypt some nodes'); + await expect(node3).rejects.toThrow('Failed to load some items'); try { await node3; } catch (error: any) { - expect(error.cause).toEqual([new DecryptionError('Decryption failed')]); + expect(error.cause).toEqual([new ProtonDriveError('Failed to load some nodes')]); + expect(error.cause[0].cause).toEqual([new DecryptionError('Decryption failed')]); } }); diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 77ed9210..82fc32d8 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -304,7 +304,7 @@ export abstract class NodesAccessBase< if (errors.length > 0) { this.logger.error(`Failed to decrypt ${errors.length} nodes`, errors); - throw new DecryptionError(c('Error').t`Failed to decrypt some nodes`, { cause: errors }); + throw new ProtonDriveError(c('Error').t`Failed to load some nodes`, { cause: errors }); } const missingNodeUids = nodeUids.filter((nodeUid) => !returnedNodeUids.includes(nodeUid)); From 9acb6bbb62cd30cf3c5d07a587163ad5d88d081d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 05:29:59 +0000 Subject: [PATCH 650/791] Update changelog for js/v0.14.2 --- js/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 690a69c8..5a2f0e9c 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## js/v0.14.2 (2026-03-30) + +* Return all possible items from batch loading +* Update nodes after shared with me updated event +* Handle thumbnails in small file upload + ## js/v0.14.1 (2026-03-25) * Add experimental getSessionInfo helper From 05390cdbb93a6c785b6a7f06b7f8a300b66414ee Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Mar 2026 17:27:28 +0100 Subject: [PATCH 651/791] Log network calls with body size --- .../drive/sdk/internal/ApiProviderBridge.kt | 19 ++++++++++++++++++- .../internal/ProtonDriveSdkNativeClient.kt | 4 ++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt index e345890b..e7d40006 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -8,6 +8,7 @@ import me.proton.core.network.data.ApiProvider import me.proton.core.network.data.ProtonErrorException import me.proton.core.network.domain.ApiResult import me.proton.drive.sdk.HttpSdkApi +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.extension.read import me.proton.drive.sdk.extension.readAsStream import okhttp3.ResponseBody @@ -115,11 +116,19 @@ internal class ApiProviderBridge( val headers = request.headersList.associate { header -> header.name to header.valuesList.joinToString(",") } - val body = if (request.isUploadBlock) { + val streamingRequest = request.isUploadBlock + val body = if (streamingRequest) { httpStream.readAsStream(request) } else { httpStream.read(request) } + + val bodyMessage = when { + !request.hasSdkContentHandle() -> "no" + streamingRequest -> "streaming" + else -> "${body.contentLength()}-byte" + } + logger("--> $method $url ($bodyMessage body)") return when (method.uppercase()) { "GET" -> if (request.isDownloadBlock) { getStreaming(url, headers) @@ -131,7 +140,15 @@ internal class ApiProviderBridge( "PUT" -> put(url, headers, body) "DELETE" -> delete(url, headers, body) else -> throw IllegalArgumentException("Unsupported method: $method") + }.also { response -> + val contentLength = response.body()?.contentLength() + val bodySize = if (contentLength != -1L) "$contentLength-byte" else "unknown-length" + logger( + "<-- ${response.code()} ${response.message()} $url ($bodySize body)" + ) } } + + fun logger(message: String) = JniBase.globalSdkLogger(DEBUG, "network", message) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index af724b03..dfd92347 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -164,12 +164,12 @@ class ProtonDriveSdkNativeClient internal constructor( parser = ProtonSdk.HttpRequest::parseFrom, ) { httpRequest -> logger( - DEBUG, + VERBOSE, "send http request for ${httpRequest.method} ${httpRequest.url} of size: ${data.capacity()}" ) val httpResponse = httpClientRequest(httpRequest) logger( - DEBUG, + VERBOSE, "receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}" ) response { value = httpResponse.asAny("proton.sdk.HttpResponse") } From f29ac8a7717ed7dad72d885d80d7bf6842cb3a77 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 12:54:17 +0000 Subject: [PATCH 652/791] Fix cancellation in download and upload --- .../drive/sdk/CommonDownloadController.kt | 20 ++++++++++++++++++- .../drive/sdk/CommonUploadController.kt | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt index 625c03b3..d412e6c5 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -2,14 +2,19 @@ package me.proton.drive.sdk import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.internal.CoroutineScopeConsumer import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.toLogId import java.nio.channels.Channel +import kotlin.time.Duration.Companion.milliseconds class CommonDownloadController internal constructor( downloader: SdkNode, @@ -38,7 +43,10 @@ class CommonDownloadController internal constructor( log(INFO, "completed") }.recoverCatching { error -> if (error is CancellationException) { - log(INFO, "interrupted") + log(INFO, "interrupted, will pause") + withContext(NonCancellable) { + pause() + } throw error } if (isPaused()) { @@ -85,6 +93,16 @@ class CommonDownloadController internal constructor( override suspend fun cancel() { log(INFO, "cancel") super.cancel() + runCatching { + withTimeout(500.milliseconds) { awaitCompletion() } + }.recoverCatching { error -> + if (error is TimeoutCancellationException) { + log(DEBUG, "Stop waiting for completion: ${error.message}") + } else if (error is CancellationException) { + throw error + } + log(DEBUG, "Error during waiting for completion: ${error.message}") + } } private fun log(level: LoggerProvider.Level, message: String) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt index 27a60c61..1a198c24 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt @@ -2,8 +2,12 @@ package me.proton.drive.sdk import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.UploadResult @@ -11,6 +15,7 @@ import me.proton.drive.sdk.internal.CoroutineScopeConsumer import me.proton.drive.sdk.internal.JniUploadController import me.proton.drive.sdk.internal.toLogId import java.nio.channels.Channel +import kotlin.time.Duration.Companion.milliseconds class CommonUploadController internal constructor( uploader: SdkNode, @@ -39,7 +44,10 @@ class CommonUploadController internal constructor( log(INFO, "completed") }.recoverCatching { error -> if (error is CancellationException) { - log(INFO, "interrupted") + log(INFO, "interrupted, will pause") + withContext(NonCancellable) { + pause() + } throw error } if (isPaused()) { @@ -83,6 +91,16 @@ class CommonUploadController internal constructor( override suspend fun cancel() { log(INFO, "cancel") super.cancel() + runCatching { + withTimeout(500.milliseconds) { awaitCompletion() } + }.recoverCatching { error -> + if (error is TimeoutCancellationException) { + log(DEBUG, "Stop waiting for completion: ${error.message}") + } else if (error is CancellationException) { + throw error + } + log(DEBUG, "Error during waiting for completion: ${error.message}") + } } private fun log(level: LoggerProvider.Level, message: String) { From 9f9f33956b5d7b5679b75f2da4f9ee9cad0f16dc Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 15:48:40 +0200 Subject: [PATCH 653/791] Fix thumbnail enumeration to stay within API limits --- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 54 ++++++++++------- js/sdk/src/internal/download/apiService.ts | 60 ++++++++++--------- 2 files changed, 63 insertions(+), 51 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 6161ca15..9cdc3d3d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -6,6 +6,8 @@ namespace Proton.Drive.Sdk.Nodes; internal static class FileOperations { + private const int MaxThumbnailIdsPerRequest = 30; + public static async ValueTask> GetSecretsAsync( ProtonDriveClient client, NodeUid fileUid, @@ -118,39 +120,45 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( continue; } - var response = await client.Api.Files.GetThumbnailBlocksAsync(volumeId, thumbnailIds.Keys, cancellationToken).ConfigureAwait(false); - - var tasks = new Queue>(); - var processedThumbnailIds = new HashSet(); - foreach (var block in response.Blocks) + // Naive implementation: thumbnails from a batch won't start downloading until all thumbnails from the previous batch have finished downloading, + // even if there are available download slots in the queue. + // TODO: allow parallelization across the batch boundaries + foreach (var thumbnailIdBatch in thumbnailIds.Keys.Chunk(MaxThumbnailIdsPerRequest)) { - processedThumbnailIds.Add(block.ThumbnailId); - var nodeInfo = thumbnailIds[block.ThumbnailId]; + var response = await client.Api.Files.GetThumbnailBlocksAsync(volumeId, thumbnailIdBatch, cancellationToken).ConfigureAwait(false); - if (!client.ThumbnailBlockDownloader.Queue.TryStartBlock()) + var tasks = new Queue>(); + var processedThumbnailIds = new HashSet(); + foreach (var block in response.Blocks) { - if (tasks.Count > 0) + processedThumbnailIds.Add(block.ThumbnailId); + var nodeInfo = thumbnailIds[block.ThumbnailId]; + + if (!client.ThumbnailBlockDownloader.Queue.TryStartBlock()) { - yield return await tasks.Dequeue().ConfigureAwait(false); + if (tasks.Count > 0) + { + yield return await tasks.Dequeue().ConfigureAwait(false); + } + + await client.ThumbnailBlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); } - await client.ThumbnailBlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); + tasks.Enqueue(DownloadThumbnailAsync(client, nodeInfo.ActiveRevisionUid, block, cancellationToken)); } - tasks.Enqueue(DownloadThumbnailAsync(client, nodeInfo.ActiveRevisionUid, block, cancellationToken)); - } - - // TODO: cancel other thumbnail downloads if one fails - while (tasks.TryDequeue(out var task)) - { - yield return await task.ConfigureAwait(false); - } + // TODO: cancel other thumbnail downloads if one fails + while (tasks.TryDequeue(out var task)) + { + yield return await task.ConfigureAwait(false); + } - foreach (var (thumbnailId, nodeInfo) in thumbnailIds) - { - if (!processedThumbnailIds.Contains(thumbnailId)) + foreach (var (thumbnailId, nodeInfo) in thumbnailIds) { - yield return new FileThumbnail(nodeInfo.Uid, new ProtonDriveError("Thumbnail not found")); + if (!processedThumbnailIds.Contains(thumbnailId)) + { + yield return new FileThumbnail(nodeInfo.Uid, new ProtonDriveError("Thumbnail not found")); + } } } } diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index 149b8ccd..2760acac 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -1,8 +1,10 @@ import { DriveAPIService, drivePaths, ObserverStream } from '../apiService'; +import { batch } from '../batch'; import { makeNodeThumbnailUid, splitNodeRevisionUid, splitNodeThumbnailUid } from '../uids'; import { BlockMetadata } from './interface'; const BLOCKS_PAGE_SIZE = 20; +const MAX_THUMBNAIL_IDS_PER_REQUEST = 30; type GetRevisionResponse = drivePaths['/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}']['get']['responses']['200']['content']['application/json']; @@ -118,37 +120,39 @@ export class DownloadAPIService { } for (const [volumeId, thumbnailIds] of thumbnailIdsByVolumeId.entries()) { - const result = await this.apiService.post( - `drive/volumes/${volumeId}/thumbnails`, - { - ThumbnailIDs: thumbnailIds.map(({ thumbnailId }) => thumbnailId), - }, - signal, - ); - - for (const thumbnail of result.Thumbnails) { - const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === thumbnail.ThumbnailID); - if (!id) { - continue; + for (const thumbnailIdBatch of batch(thumbnailIds, MAX_THUMBNAIL_IDS_PER_REQUEST)) { + const result = await this.apiService.post( + `drive/volumes/${volumeId}/thumbnails`, + { + ThumbnailIDs: thumbnailIdBatch.map(({ thumbnailId }) => thumbnailId), + }, + signal, + ); + + for (const thumbnail of result.Thumbnails) { + const id = thumbnailIdBatch.find(({ thumbnailId }) => thumbnailId === thumbnail.ThumbnailID); + if (!id) { + continue; + } + yield { + uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, thumbnail.ThumbnailID), + ok: true, + bareUrl: thumbnail.BareURL, + token: thumbnail.Token, + }; } - yield { - uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, thumbnail.ThumbnailID), - ok: true, - bareUrl: thumbnail.BareURL, - token: thumbnail.Token, - }; - } - for (const error of result.Errors) { - const id = thumbnailIds.find(({ thumbnailId }) => thumbnailId === error.ThumbnailID); - if (!id) { - continue; + for (const error of result.Errors) { + const id = thumbnailIdBatch.find(({ thumbnailId }) => thumbnailId === error.ThumbnailID); + if (!id) { + continue; + } + yield { + uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, error.ThumbnailID), + ok: false, + error: error.Error, + }; } - yield { - uid: makeNodeThumbnailUid(id.volumeId, id.nodeId, error.ThumbnailID), - ok: false, - error: error.Error, - }; } } } From 4de6639ba1533279baba1395059d87673ad760e9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 14:56:17 +0000 Subject: [PATCH 654/791] Do not call interop functions if cancelled --- cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs index 1d880835..42fc9a9d 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -89,6 +89,8 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation using var memoryHandle = buffer.Pin(); + cancellationToken.ThrowIfCancellationRequested(); + var (readTask, operationHandle) = _readFunction.Value.InvokeWithBuffer(_bindingsHandle, buffer.Span); _operationHandle = operationHandle; @@ -156,6 +158,8 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella using var memoryHandle = buffer.Pin(); + cancellationToken.ThrowIfCancellationRequested(); + var (writeTask, operationHandle) = _writeFunction.Value.InvokeWithBuffer(_bindingsHandle, buffer.Span); _operationHandle = operationHandle; From 7f425b64ee3c0fafbadce3a2c9caeabf2166b50c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 17:40:35 +0100 Subject: [PATCH 655/791] Move native weak reference management to kotlin --- kt/sdk/src/main/jni/Android.mk | 2 +- kt/sdk/src/main/jni/job.c | 17 ------- kt/sdk/src/main/jni/proton_drive_sdk.c | 15 ++---- kt/sdk/src/main/jni/proton_sdk.c | 6 +-- kt/sdk/src/main/jni/weak_reference.c | 17 +++++++ .../sdk/internal/JniBaseProtonDriveSdk.kt | 26 ++++++++--- .../drive/sdk/internal/JniBaseProtonSdk.kt | 18 ++++++-- .../me/proton/drive/sdk/internal/JniJob.kt | 8 ---- .../drive/sdk/internal/JniWeakReference.kt | 10 ++++ .../internal/ProtonDriveSdkNativeClient.kt | 46 ++++++++----------- .../sdk/internal/ProtonSdkNativeClient.kt | 21 ++++++--- 11 files changed, 101 insertions(+), 85 deletions(-) create mode 100644 kt/sdk/src/main/jni/weak_reference.c create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniWeakReference.kt diff --git a/kt/sdk/src/main/jni/Android.mk b/kt/sdk/src/main/jni/Android.mk index 4ce6235e..9bb686bd 100644 --- a/kt/sdk/src/main/jni/Android.mk +++ b/kt/sdk/src/main/jni/Android.mk @@ -9,7 +9,7 @@ include $(PREBUILT_SHARED_LIBRARY) include $(CLEAR_VARS) LOCAL_MODULE := proton_drive_sdk_jni -LOCAL_SRC_FILES := global.c byte_array.c buffer.c job.c native_library.c proton_drive_sdk.c proton_sdk.c +LOCAL_SRC_FILES := global.c buffer.c byte_array.c job.c native_library.c proton_drive_sdk.c proton_sdk.c weak_reference.c LOCAL_SHARED_LIBRARIES := proton_drive_sdk LOCAL_C_INCLUDES += $(BUILD_DIR)/cs/includes LOCAL_LDLIBS := -llog diff --git a/kt/sdk/src/main/jni/job.c b/kt/sdk/src/main/jni/job.c index 34f5f2a1..b299556f 100644 --- a/kt/sdk/src/main/jni/job.c +++ b/kt/sdk/src/main/jni/job.c @@ -67,20 +67,3 @@ jlong Java_me_proton_drive_sdk_internal_JniJob_getCancelPointer( ) { return (jlong) (intptr_t) &onCancel; } - -jlong Java_me_proton_drive_sdk_internal_JniJob_createWeakGlobalRef( - JNIEnv *env, - jclass clazz, - jobject obj -) { - jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); - return (jlong) (intptr_t) weakRef; -} - -void Java_me_proton_drive_sdk_internal_JniJob_deleteWeakGlobalRef( - JNIEnv *env, - jclass clazz, - jlong ref -) { - (*env)->DeleteWeakGlobalRef(env, (jweak) ref); -} diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 37a31d8a..5e75111e 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -10,18 +10,18 @@ void onDriveSdkResponse(intptr_t bindings_handle, ByteArray value) { void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleRequest( JNIEnv *env, - jobject obj, + jclass clazz, + jlong ref, jbyteArray request ) { jbyte *bufferElems = (*env)->GetByteArrayElements(env, request, 0); ByteArray byteArray; byteArray.pointer = (const uint8_t *) bufferElems; byteArray.length = (*env)->GetArrayLength(env, request); - intptr_t weakObjRef = (intptr_t) (*env)->NewWeakGlobalRef(env, obj); proton_drive_sdk_handle_request( byteArray, - weakObjRef, + (intptr_t) ref, onDriveSdkResponse ); @@ -199,12 +199,3 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSha1Pointe ) { return (jlong) (intptr_t) &onSha1; } - -jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_createWeakGlobalRef(JNIEnv* env, jobject obj) { - jweak weakRef = (*env)->NewWeakGlobalRef(env, obj); - return (jlong)(intptr_t) weakRef; -} - -void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_deleteWeakGlobalRef(JNIEnv* env, jclass clazz, jlong weakRef) { - (*env)->DeleteWeakGlobalRef(env, (jweak) weakRef); -} diff --git a/kt/sdk/src/main/jni/proton_sdk.c b/kt/sdk/src/main/jni/proton_sdk.c index ff36d834..dcd0c18c 100644 --- a/kt/sdk/src/main/jni/proton_sdk.c +++ b/kt/sdk/src/main/jni/proton_sdk.c @@ -10,18 +10,18 @@ void onSdkResponse(intptr_t bindings_handle, ByteArray value) { void Java_me_proton_drive_sdk_internal_ProtonSdkNativeClient_handleRequest( JNIEnv *env, - jobject obj, + jclass clazz, + jlong ref, jbyteArray request ) { jbyte *bufferElems = (*env)->GetByteArrayElements(env, request, 0); ByteArray byteArray; byteArray.pointer = (const uint8_t *) bufferElems; byteArray.length = (*env)->GetArrayLength(env, request); - intptr_t weakObjRef = (intptr_t) (*env)->NewWeakGlobalRef(env, obj); proton_sdk_handle_request( byteArray, - weakObjRef, + (intptr_t) ref, onSdkResponse ); diff --git a/kt/sdk/src/main/jni/weak_reference.c b/kt/sdk/src/main/jni/weak_reference.c new file mode 100644 index 00000000..8be9a032 --- /dev/null +++ b/kt/sdk/src/main/jni/weak_reference.c @@ -0,0 +1,17 @@ +#include + +jlong Java_me_proton_drive_sdk_internal_JniWeakReference_create( + JNIEnv *env, + jclass clazz, + jobject obj +) { + return (jlong) (intptr_t) (*env)->NewWeakGlobalRef(env, obj); +} + +void Java_me_proton_drive_sdk_internal_JniWeakReference_delete( + JNIEnv *env, + jclass clazz, + jlong ref +) { + (*env)->DeleteWeakGlobalRef(env, (jweak) (intptr_t) ref); +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt index a3842eb7..11eb4f56 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -3,6 +3,7 @@ package me.proton.drive.sdk.internal import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE +import me.proton.drive.sdk.LoggerProvider.Level.WARN import proton.drive.sdk.ProtonDriveSdk.Request import proton.drive.sdk.RequestKt import proton.drive.sdk.request @@ -12,6 +13,7 @@ abstract class JniBaseProtonDriveSdk : JniBase() { private var released = false private var clients = emptyList>() + private var permanentClients = emptyList>() fun dispatch( name: String, @@ -19,8 +21,10 @@ abstract class JniBaseProtonDriveSdk : JniBase() { ) { check(released.not()) { "Cannot dispatch ${method(name)} after release" } val nativeClient = ProtonDriveSdkNativeClient( - method(name), - IgnoredIntegerOrErrorResponse(), + name = method(name), + response = { client, _ -> + client.release() + }, logger = internalLogger, ) nativeClient.handleRequest(request(block)) @@ -37,9 +41,9 @@ abstract class JniBaseProtonDriveSdk : JniBase() { val nativeClient = ProtonDriveSdkNativeClient( name = method(name), response = { client, buffer -> - responseCallback(buffer) client.release() clients -= client + responseCallback(buffer) }, logger = internalLogger, ) @@ -61,9 +65,9 @@ abstract class JniBaseProtonDriveSdk : JniBase() { val nativeClient = ProtonDriveSdkNativeClient( name = method(name), response = { client, buffer -> - responseCallback(buffer) client.release() clients -= client + responseCallback(buffer) }, enumerateHandler = EnumerateHandler.create(enumerate, parser) , logger = internalLogger, @@ -79,14 +83,22 @@ abstract class JniBaseProtonDriveSdk : JniBase() { ): T = suspendCancellableCoroutine { continuation -> val nativeClient = clientBuilder(continuation) check(released.not()) { "Cannot executePersistent ${method(nativeClient.name)} after release" } - clients += nativeClient + permanentClients += nativeClient nativeClient.handleRequest(requestBuilder(nativeClient)) } fun releaseAll() { internalLogger(VERBOSE, "Releasing all for ${javaClass.simpleName}") released = true - clients.forEach { client -> client.release() } - clients = emptyList() + permanentClients.forEach { client -> + client.release() + } + permanentClients = emptyList() + if (clients.isNotEmpty()) { + internalLogger( + WARN, + "Pending clients waiting for a response: ${clients.size}, ${clients.map { it.name }}" + ) + } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt index 7c7feaab..792d34ab 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt @@ -2,6 +2,7 @@ package me.proton.drive.sdk.internal import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine +import me.proton.drive.sdk.LoggerProvider.Level.WARN import proton.sdk.ProtonSdk.Request import proton.sdk.RequestKt import proton.sdk.request @@ -9,14 +10,17 @@ import proton.sdk.request abstract class JniBaseProtonSdk : JniBase() { private var clients = emptyList() + private var permanentClients = emptyList() fun dispatch( name: String, block: RequestKt.Dsl.() -> Unit, ) { val nativeClient = ProtonSdkNativeClient( - method(name), - IgnoredIntegerOrErrorResponse(), + name = method(name), + response = { client, _ -> + client.release() + }, ) nativeClient.handleRequest(request(block)) } @@ -66,7 +70,13 @@ abstract class JniBaseProtonSdk : JniBase() { } fun releaseAll() { - clients.forEach { client -> client.release() } - clients = emptyList() + permanentClients.forEach { client -> client.release() } + permanentClients = emptyList() + if (clients.isNotEmpty()) { + internalLogger( + WARN, + "Pending clients waiting for a response: ${clients.size}, ${clients.map { it.name }}" + ) + } } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt index 1daf1832..28fbe664 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt @@ -1,15 +1,7 @@ package me.proton.drive.sdk.internal -import kotlinx.coroutines.Job - object JniJob { @JvmStatic external fun getCancelPointer(): Long - - @JvmStatic - external fun createWeakGlobalRef(job: Job): Long - - @JvmStatic - external fun deleteWeakGlobalRef(ref: Long) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniWeakReference.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniWeakReference.kt new file mode 100644 index 00000000..0d00da71 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniWeakReference.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.internal + +object JniWeakReference { + + @JvmStatic + external fun create(obj: Any): Long + + @JvmStatic + external fun delete(ref: Long) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index dfd92347..21a2ca8b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -26,6 +26,7 @@ import proton.sdk.ProtonSdk.HttpResponse import proton.sdk.ProtonSdk.Response import proton.sdk.response import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicBoolean class ProtonDriveSdkNativeClient internal constructor( val name: String, @@ -44,24 +45,25 @@ class ProtonDriveSdkNativeClient internal constructor( val logger: (Level, String) -> Unit = { _, _ -> }, private val coroutineScopeProvider: CoroutineScopeProvider = { null }, ) { - @Volatile - private var nativeWeakReference: Long? = null + private val clientWeakRef: Long = JniWeakReference.create(this) + private val released = AtomicBoolean(false) private val weakReferenceLock = Any() val inactiveJobWeakReferences = ArrayDeque() private val byteArrayPointers = ByteArrayPointers() fun release() { - byteArrayPointers.releaseAll() - synchronized(weakReferenceLock) { - nativeWeakReference?.let { ref -> - deleteWeakGlobalRef(ref) - nativeWeakReference = null - } - inactiveJobWeakReferences.forEach { ref -> - JniJob.deleteWeakGlobalRef(ref) + if (released.compareAndSet(false, true)) { + JniWeakReference.delete(clientWeakRef) + byteArrayPointers.releaseAll() + synchronized(weakReferenceLock) { + inactiveJobWeakReferences.forEach { ref -> + JniWeakReference.delete(ref) + } + inactiveJobWeakReferences.clear() } - inactiveJobWeakReferences.clear() + } else { + logger(VERBOSE, "Native client for $name already release") } } @@ -69,13 +71,9 @@ class ProtonDriveSdkNativeClient internal constructor( request: ProtonDriveSdk.Request, ) { logger(VERBOSE, "handle request ${request.payloadCase.name} for $name") - handleRequest(request.toByteArray()) + handleRequest(clientWeakRef, request.toByteArray()) } - external fun handleRequest( - request: ByteArray, - ) - fun handleResponse( sdkHandle: Long, response: Response, @@ -94,11 +92,7 @@ class ProtonDriveSdkNativeClient internal constructor( fun getByteArrayPointer(data: ByteArray): Long = byteArrayPointers.allocate(data) - fun asWeakReference(): Long = synchronized(weakReferenceLock) { - nativeWeakReference ?: createWeakGlobalRef().also { ref -> nativeWeakReference = ref } - } - - external fun createWeakGlobalRef(): Long + fun asWeakReference(): Long = clientWeakRef @Suppress("unused") // Called by JNI fun onResponse(data: ByteBuffer) { @@ -382,14 +376,14 @@ class ProtonDriveSdkNativeClient internal constructor( return scope } - private fun Job.trackWeakReference(): Long = JniJob.createWeakGlobalRef(this).also { ref -> + private fun Job.trackWeakReference(): Long = JniWeakReference.create(this).also { ref -> invokeOnCompletion { synchronized(weakReferenceLock) { inactiveJobWeakReferences.addLast(ref) // Clean up oldest refs if we exceed the limit while (inactiveJobWeakReferences.size > MAX_INACTIVE_JOB_WEAK_REFERENCES) { inactiveJobWeakReferences.removeFirstOrNull()?.let { oldestRef -> - JniJob.deleteWeakGlobalRef(oldestRef) + JniWeakReference.delete(oldestRef) } } } @@ -400,6 +394,9 @@ class ProtonDriveSdkNativeClient internal constructor( companion object { private const val MAX_INACTIVE_JOB_WEAK_REFERENCES = 128 + @JvmStatic + external fun handleRequest(ref: Long, request: ByteArray) + @JvmStatic external fun handleResponse(sdkHandle: Long, response: ByteArray) @@ -435,8 +432,5 @@ class ProtonDriveSdkNativeClient internal constructor( @JvmStatic external fun getSha1Pointer(): Long - - @JvmStatic - external fun deleteWeakGlobalRef(ref: Long) } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt index 90eea5be..49011c7c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -4,6 +4,7 @@ import me.proton.drive.sdk.LoggerProvider.Level import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE import proton.sdk.ProtonSdk.Request import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicBoolean class ProtonSdkNativeClient internal constructor( val name: String, @@ -11,34 +12,40 @@ class ProtonSdkNativeClient internal constructor( val callback: (ByteBuffer) -> Unit = { error("callback not configured for $name") }, val logger: (Level, String) -> Unit = { _, _ -> } ) { + private val clientWeakRef: Long = JniWeakReference.create(this) + private val released = AtomicBoolean(false) fun release() { - // do nothing as C code use weak reference - // keep this method to force user to keep a strong reference to the native client until they are done + if (released.compareAndSet(false, true)) { + JniWeakReference.delete(clientWeakRef) + } else { + logger(VERBOSE, "Native client for $name already release") + } } fun handleRequest( request: Request, ) { logger(VERBOSE, "handle request ${request.payloadCase.name} for $name") - handleRequest(request.toByteArray()) + handleRequest(clientWeakRef, request.toByteArray()) } - external fun handleRequest( - request: ByteArray, - ) - + @Suppress("unused") // Called by JNI fun onResponse(data: ByteBuffer) { logger(VERBOSE, "response for $name of size: ${data.capacity()}") response(this, data) } + @Suppress("unused") // Called by JNI fun onCallback(data: ByteBuffer) { logger(VERBOSE, "callback for $name of size: ${data.capacity()}") callback(data) } companion object { + @JvmStatic + external fun handleRequest(ref: Long, request: ByteArray) + @JvmStatic external fun getCallbackPointer(): Long } From f8d08fbf923c38f300de47e928c2fb12a0437541 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 15:04:09 +0000 Subject: [PATCH 656/791] Introduce uids in the kotlin bindings --- .../me/proton/drive/sdk/FileDownloader.kt | 3 +- .../me/proton/drive/sdk/PhotosDownloader.kt | 3 +- .../me/proton/drive/sdk/ProtonDriveClient.kt | 37 ++++++++++--------- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 13 ++++--- .../me/proton/drive/sdk/ProtonSdkError.kt | 7 +++- .../main/kotlin/me/proton/drive/sdk/Uid.kt | 10 ----- .../drive/sdk/entity/DegradedFileNode.kt | 6 +-- .../drive/sdk/entity/DegradedFolderNode.kt | 6 +-- .../proton/drive/sdk/entity/DegradedNode.kt | 6 +-- .../me/proton/drive/sdk/entity/FileNode.kt | 6 +-- .../proton/drive/sdk/entity/FileRevision.kt | 2 +- .../sdk/entity/FileRevisionUploaderRequest.kt | 2 +- .../proton/drive/sdk/entity/FileThumbnail.kt | 2 +- .../drive/sdk/entity/FileUploaderRequest.kt | 2 +- .../me/proton/drive/sdk/entity/FolderNode.kt | 6 +-- .../proton/drive/sdk/entity/LegacyNodeUid.kt | 14 +++++++ .../drive/sdk/entity/LegacyParentNodeUid.kt | 14 +++++++ .../drive/sdk/entity/LegacyRevisionUid.kt | 21 +++++++++++ .../me/proton/drive/sdk/entity/LegacyUid.kt | 20 ++++++++++ .../kotlin/me/proton/drive/sdk/entity/Node.kt | 6 +-- .../proton/drive/sdk/entity/NodeResultPair.kt | 6 +-- .../me/proton/drive/sdk/entity/NodeUid.kt | 6 +++ .../proton/drive/sdk/entity/ParentNodeUid.kt | 6 +++ .../drive/sdk/entity/PhotosTimelineItem.kt | 2 +- .../me/proton/drive/sdk/entity/RevisionUid.kt | 6 +++ .../me/proton/drive/sdk/entity/ScopeId.kt | 3 ++ .../kotlin/me/proton/drive/sdk/entity/Uid.kt | 5 +++ .../proton/drive/sdk/entity/UploadResult.kt | 4 +- .../drive/sdk/extension/DegradedFileNode.kt | 9 +++-- .../drive/sdk/extension/DegradedFolderNode.kt | 9 +++-- .../me/proton/drive/sdk/extension/FileNode.kt | 9 +++-- .../drive/sdk/extension/FileThumbnail.kt | 3 +- .../sdk/extension/FileUploaderRequest.kt | 2 +- .../proton/drive/sdk/extension/FolderNode.kt | 9 +++-- .../GetFileRevisionUploaderRequest.kt | 2 +- .../extension/NodeNameConflictErrorData.kt | 6 ++- .../sdk/extension/NodeResultListResponse.kt | 5 ++- .../drive/sdk/extension/PhotosTimelineList.kt | 3 +- .../me/proton/drive/sdk/extension/Revision.kt | 3 +- .../drive/sdk/extension/UploadResult.kt | 6 ++- .../drive/sdk/internal/JniFileDownloader.kt | 5 ++- .../drive/sdk/internal/JniPhotosDownloader.kt | 5 ++- 42 files changed, 207 insertions(+), 93 deletions(-) delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyNodeUid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyParentNodeUid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyRevisionUid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyUid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeUid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ParentNodeUid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/RevisionUid.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ScopeId.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Uid.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index f306a338..12d0c8cf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.entity.RevisionUid import me.proton.drive.sdk.extension.seek import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString @@ -76,7 +77,7 @@ class FileDownloader internal constructor( } suspend fun ProtonDriveClient.downloader( - revisionUid: String, + revisionUid: RevisionUid, timeout: Duration, ): Downloader = withTimeout(timeout) { cancellationCoroutineScope { source -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index 16f0ed43..4605807a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource +import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.extension.seek import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString @@ -77,7 +78,7 @@ class PhotosDownloader internal constructor( } suspend fun ProtonPhotosClient.downloader( - photoUid: String, + photoUid: NodeUid, timeout: Duration, ): Downloader = withTimeout(timeout) { cancellationCoroutineScope { source -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index acc175bc..68911ef4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -9,6 +9,7 @@ import me.proton.drive.sdk.entity.FolderNode import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.entity.NodeResultPair +import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto import me.proton.drive.sdk.extension.toTimestamp @@ -38,13 +39,13 @@ class ProtonDriveClient internal constructor( ) : SdkNode(session), AutoCloseable { suspend fun getAvailableName( - parentFolderUid: String, + parentFolderUid: NodeUid, name: String, ): String = cancellationCoroutineScope { source -> log(DEBUG, "getAvailableName") bridge.getAvailableName( driveClientGetAvailableNameRequest { - this.parentFolderUid = parentFolderUid + this.parentFolderUid = parentFolderUid.value this.name = name clientHandle = handle cancellationTokenSourceHandle = source.handle @@ -57,13 +58,13 @@ class ProtonDriveClient internal constructor( replaceWith = ReplaceWith("enumerateThumbnails(fileUids, type)"), ) suspend fun getThumbnails( - fileUids: List, + fileUids: List, type: ThumbnailType, ): List = cancellationCoroutineScope { source -> log(INFO, "getThumbnails($type)") bridge.getThumbnails( driveClientGetThumbnailsRequest { - this.fileUids += fileUids + this.fileUids += fileUids.map { it.value } this.type = type.toProto() clientHandle = handle cancellationTokenSourceHandle = source.handle @@ -74,7 +75,7 @@ class ProtonDriveClient internal constructor( } fun enumerateThumbnails( - fileUids: List, + fileUids: List, type: ThumbnailType, ): Flow = channelFlow { log(INFO, "enumerateThumbnails(${fileUids.size}, $type)") @@ -82,7 +83,7 @@ class ProtonDriveClient internal constructor( bridge.enumerateThumbnails( coroutineScope = this@channelFlow, request = driveClientEnumerateThumbnailsRequest { - this.fileUids += fileUids + this.fileUids += fileUids.map { it.value } this.type = type.toProto() clientHandle = handle cancellationTokenSourceHandle = source.handle @@ -96,14 +97,14 @@ class ProtonDriveClient internal constructor( } suspend fun rename( - nodeUid: String, + nodeUid: NodeUid, name: String, mediaType: String? = null, ): Unit = cancellationCoroutineScope { source -> log(INFO, "rename") bridge.rename( driveClientRenameRequest { - this.nodeUid = nodeUid + this.nodeUid = nodeUid.value newName = name mediaType?.let { newMediaType = mediaType @@ -115,14 +116,14 @@ class ProtonDriveClient internal constructor( } suspend fun createFolder( - parentFolderUid: String, + parentFolderUid: NodeUid, name: String, lastModification: Instant? = null, ): FolderNode = cancellationCoroutineScope { source -> log(INFO, "createFolder") bridge.createFolder( driveClientCreateFolderRequest { - this.parentFolderUid = parentFolderUid + this.parentFolderUid = parentFolderUid.value folderName = name lastModification?.let { lastModificationTime = lastModification.toTimestamp() @@ -144,12 +145,12 @@ class ProtonDriveClient internal constructor( } suspend fun enumerateFolderChildren( - folderUid: String, + folderUid: NodeUid, ): List = cancellationCoroutineScope { source -> log(DEBUG, "enumerateFolderChildren") bridge.enumerateFolderChildren( driveClientEnumerateFolderChildrenRequest { - this.folderUid = folderUid + this.folderUid = folderUid.value clientHandle = handle cancellationTokenSourceHandle = source.handle } @@ -157,12 +158,12 @@ class ProtonDriveClient internal constructor( } suspend fun trashNodes( - nodeUids: List, + nodeUids: List, ): List = cancellationCoroutineScope { source -> log(INFO, "trashNodes(${nodeUids.size} nodes)") bridge.trashNodes( driveClientTrashNodesRequest { - this.nodeUids += nodeUids + this.nodeUids += nodeUids.map { it.value } clientHandle = handle cancellationTokenSourceHandle = source.handle } @@ -170,12 +171,12 @@ class ProtonDriveClient internal constructor( } suspend fun deleteNodes( - nodeUids: List, + nodeUids: List, ): List = cancellationCoroutineScope { source -> log(INFO, "deleteNodes(${nodeUids.size} nodes)") bridge.deleteNodes( driveClientDeleteNodesRequest { - this.nodeUids += nodeUids + this.nodeUids += nodeUids.map { it.value } clientHandle = handle cancellationTokenSourceHandle = source.handle } @@ -183,12 +184,12 @@ class ProtonDriveClient internal constructor( } suspend fun restoreNodes( - nodeUids: List, + nodeUids: List, ): List = cancellationCoroutineScope { source -> log(INFO, "restoreNodes(${nodeUids.size} nodes)") bridge.restoreNodes( driveClientRestoreNodesRequest { - this.nodeUids += nodeUids + this.nodeUids += nodeUids.map { it.value } clientHandle = handle cancellationTokenSourceHandle = source.handle } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 2b970d72..028e1727 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -7,6 +7,7 @@ import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.FileThumbnail import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.PhotosTimelineItem import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity @@ -32,13 +33,13 @@ class ProtonPhotosClient internal constructor( replaceWith = ReplaceWith("enumerateThumbnails(photoUids, type)"), ) suspend fun getThumbnails( - photoUids: List, + photoUids: List, type: ThumbnailType, ): List = cancellationCoroutineScope { source -> log(INFO, "getThumbnails($type)") bridge.getThumbnails( drivePhotosClientGetThumbnailsRequest { - this.photoUids += photoUids + this.photoUids += photoUids.map { it.value } this.type = type.toProto() clientHandle = handle cancellationTokenSourceHandle = source.handle @@ -49,7 +50,7 @@ class ProtonPhotosClient internal constructor( } fun enumerateThumbnails( - photoUids: List, + photoUids: List, type: ThumbnailType, ): Flow = channelFlow { log(INFO, "enumerateThumbnails(${photoUids.size}, $type)") @@ -57,7 +58,7 @@ class ProtonPhotosClient internal constructor( bridge.enumerateThumbnails( coroutineScope = this@channelFlow, request = drivePhotosClientEnumerateThumbnailsRequest { - this.photoUids += photoUids + this.photoUids += photoUids.map { it.value } this.type = type.toProto() clientHandle = handle cancellationTokenSourceHandle = source.handle @@ -80,11 +81,11 @@ class ProtonPhotosClient internal constructor( ).toEntity() } - suspend fun getNode(nodeUid: String): NodeResult? = cancellationCoroutineScope { source -> + suspend fun getNode(nodeUid: NodeUid): NodeResult? = cancellationCoroutineScope { source -> log(DEBUG, "getNode") bridge.getNode( drivePhotosClientGetNodeRequest { - this.nodeUid = nodeUid + this.nodeUid = nodeUid.value clientHandle = handle cancellationTokenSourceHandle = source.handle } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt index b83a96e6..73f28bf4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt @@ -1,5 +1,8 @@ package me.proton.drive.sdk +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.RevisionUid + data class ProtonSdkError( val message: String, val type: String, @@ -29,8 +32,8 @@ data class ProtonSdkError( data class NodeNameConflict( val conflictingNodeIsFileDraft: Boolean, - val conflictingNodeUid: String, - val conflictingRevisionUid: String, + val conflictingNodeUid: NodeUid, + val conflictingRevisionUid: RevisionUid, ) : Data { override fun toSafe() = this } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt deleted file mode 100644 index 533a9ebf..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uid.kt +++ /dev/null @@ -1,10 +0,0 @@ -package me.proton.drive.sdk - -object Uid { - - fun makeNodeUid(volumeId: String, nodeId: String) = makeUid(volumeId, nodeId) - fun makeNodeRevisionUid(volumeId: String, nodeId: String, revisionId: String) = - makeUid(volumeId, nodeId, revisionId) - - private fun makeUid(vararg ids: String) = ids.joinToString("~") -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt index 0d443a1a..80e25cb8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt @@ -3,9 +3,9 @@ package me.proton.drive.sdk.entity import java.time.Instant data class DegradedFileNode( - override val uid: String, - override val parentUid: String, - override val treeEventScopeId: String, + override val uid: NodeUid, + override val parentUid: ParentNodeUid, + override val treeEventScopeId: ScopeId, override val name: Result, val mediaType: String, override val creationTime: Instant, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt index a33923ec..e43c147c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt @@ -3,9 +3,9 @@ package me.proton.drive.sdk.entity import java.time.Instant data class DegradedFolderNode( - override val uid: String, - override val parentUid: String?, - override val treeEventScopeId: String, + override val uid: NodeUid, + override val parentUid: ParentNodeUid?, + override val treeEventScopeId: ScopeId, override val name: Result, override val creationTime: Instant, override val trashTime: Instant?, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt index 5ea29e9b..02937095 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt @@ -3,9 +3,9 @@ package me.proton.drive.sdk.entity import java.time.Instant sealed interface DegradedNode { - val uid: String - val parentUid: String? - val treeEventScopeId: String + val uid: NodeUid + val parentUid: ParentNodeUid? + val treeEventScopeId: ScopeId val name: Result val creationTime: Instant val trashTime: Instant? diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt index 863f67df..5c7c0af4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt @@ -3,9 +3,9 @@ package me.proton.drive.sdk.entity import java.time.Instant data class FileNode( - override val uid: String, - override val parentUid: String, - override val treeEventScopeId: String, + override val uid: NodeUid, + override val parentUid: ParentNodeUid, + override val treeEventScopeId: ScopeId, override val name: String, val mediaType: String, override val creationTime: Instant, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt index 784d4078..b92360cb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt @@ -3,7 +3,7 @@ package me.proton.drive.sdk.entity import java.time.Instant data class FileRevision( - val uid: String, + val uid: RevisionUid, val creationTime: Instant, val sizeOnCloudStorage: Long, val claimedSize: Long?, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt index 6a62a8d2..5bff0cf3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt @@ -3,7 +3,7 @@ package me.proton.drive.sdk.entity import java.time.Instant data class FileRevisionUploaderRequest( - val currentActiveRevisionUid: String, + val currentActiveRevisionUid: RevisionUid, val lastModificationTime: Instant, val size: Long, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileThumbnail.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileThumbnail.kt index 1c635068..326d5e8b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileThumbnail.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileThumbnail.kt @@ -1,6 +1,6 @@ package me.proton.drive.sdk.entity data class FileThumbnail( - val uid: String, + val uid: NodeUid, val result: Result ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt index eb996cda..149b0908 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt @@ -3,7 +3,7 @@ package me.proton.drive.sdk.entity import java.time.Instant data class FileUploaderRequest( - val parentFolderUid: String, + val parentFolderUid: NodeUid, val name: String, val mediaType: String, val fileSize: Long, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt index f9aaa26f..ff8b6728 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt @@ -3,9 +3,9 @@ package me.proton.drive.sdk.entity import java.time.Instant data class FolderNode( - override val uid: String, - override val parentUid: String?, - override val treeEventScopeId: String, + override val uid: NodeUid, + override val parentUid: ParentNodeUid?, + override val treeEventScopeId: ScopeId, override val name: String, override val creationTime: Instant, override val trashTime: Instant?, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyNodeUid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyNodeUid.kt new file mode 100644 index 00000000..0532113a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyNodeUid.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk.entity + +data class LegacyNodeUid( + override val value: String, +) : LegacyUid(value, numberOfParts = 2), NodeUid { + + val volumeId: String get() = parts[0] + val linkId: String get() = parts[1] + + constructor( + volumeId: String, + linkId: String, + ) : this(create(volumeId, linkId)) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyParentNodeUid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyParentNodeUid.kt new file mode 100644 index 00000000..a4b206ff --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyParentNodeUid.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk.entity + +data class LegacyParentNodeUid( + override val value: String, +) : LegacyUid(value, numberOfParts = 2), ParentNodeUid { + + val volumeId: String get() = parts[0] + val linkId: String get() = parts[1] + + constructor( + volumeId: String, + linkId: String, + ) : this(create(volumeId, linkId)) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyRevisionUid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyRevisionUid.kt new file mode 100644 index 00000000..15925d19 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyRevisionUid.kt @@ -0,0 +1,21 @@ +package me.proton.drive.sdk.entity + +data class LegacyRevisionUid( + override val value: String, +) : LegacyUid(value, numberOfParts = 3), RevisionUid { + + val nodeUid: NodeUid by lazy { + LegacyNodeUid( + volumeId = parts[0], + linkId = parts[1], + ) + } + + val revisionId: String get() = parts[2] + + constructor( + volumeId: String, + linkId: String, + revisionId: String, + ) : this(create(volumeId, linkId, revisionId)) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyUid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyUid.kt new file mode 100644 index 00000000..08945eeb --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/LegacyUid.kt @@ -0,0 +1,20 @@ +package me.proton.drive.sdk.entity + +abstract class LegacyUid( + override val value: String, + private val numberOfParts: Int, +) : Uid { + + internal val parts by lazy { + value.split(SEPARATOR).also { + check(it.size == numberOfParts) { + "Malformed value for ${javaClass.simpleName}, should contains $numberOfParts parts: $value" + } + } + } + + internal companion object { + private const val SEPARATOR: String = "~" + fun create(vararg parts: String) = parts.joinToString(SEPARATOR) + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt index 27b0502a..af311f7d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt @@ -3,9 +3,9 @@ package me.proton.drive.sdk.entity import java.time.Instant sealed interface Node { - val uid: String - val parentUid: String? - val treeEventScopeId: String + val uid: NodeUid + val parentUid: ParentNodeUid? + val treeEventScopeId: ScopeId val name: String val creationTime: Instant val trashTime: Instant? diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt index 129bfab1..bb42b965 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt @@ -3,8 +3,8 @@ package me.proton.drive.sdk.entity import me.proton.drive.sdk.ProtonDriveSdkException sealed interface NodeResultPair { - val nodeUid: String + val nodeUid: NodeUid - data class Success(override val nodeUid: String) : NodeResultPair - data class Failure(override val nodeUid: String, val error: ProtonDriveSdkException) : NodeResultPair + data class Success(override val nodeUid: NodeUid) : NodeResultPair + data class Failure(override val nodeUid: NodeUid, val error: ProtonDriveSdkException) : NodeResultPair } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeUid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeUid.kt new file mode 100644 index 00000000..45fe65b2 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeUid.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +interface NodeUid : Uid + +@Suppress("FunctionName") +fun NodeUid(value: String) = LegacyNodeUid(value) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ParentNodeUid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ParentNodeUid.kt new file mode 100644 index 00000000..a12ee473 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ParentNodeUid.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +interface ParentNodeUid : NodeUid + +@Suppress("FunctionName") +fun ParentNodeUid(value: String):ParentNodeUid = LegacyParentNodeUid(value) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt index 624bb2c7..9babcf56 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt @@ -3,6 +3,6 @@ package me.proton.drive.sdk.entity import java.time.Instant data class PhotosTimelineItem( - val nodeUid: String, + val nodeUid: NodeUid, val captureTime: Instant, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/RevisionUid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/RevisionUid.kt new file mode 100644 index 00000000..ef33f83c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/RevisionUid.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +interface RevisionUid : Uid + +@Suppress("FunctionName") +fun RevisionUid(value: String) = LegacyRevisionUid(value) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ScopeId.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ScopeId.kt new file mode 100644 index 00000000..03a6c711 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ScopeId.kt @@ -0,0 +1,3 @@ +package me.proton.drive.sdk.entity + +data class ScopeId(val id: String) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Uid.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Uid.kt new file mode 100644 index 00000000..4da149a4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Uid.kt @@ -0,0 +1,5 @@ +package me.proton.drive.sdk.entity + +interface Uid { + val value: String +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt index 64bbee1a..66005de2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt @@ -1,6 +1,6 @@ package me.proton.drive.sdk.entity data class UploadResult( - val nodeUid: String, - val revisionUid: String, + val nodeUid: NodeUid, + val revisionUid: RevisionUid, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt index a866c3ca..8113db17 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt @@ -1,14 +1,17 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.DegradedFileNode +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.ParentNodeUid +import me.proton.drive.sdk.entity.ScopeId import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.activeRevisionOrNull import proton.drive.sdk.trashTimeOrNull fun ProtonDriveSdk.DegradedFileNode.toEntity() = DegradedFileNode( - uid = uid, - parentUid = parentUid, - treeEventScopeId = treeEventScopeId, + uid = NodeUid(uid), + parentUid = ParentNodeUid(parentUid), + treeEventScopeId = ScopeId(treeEventScopeId), name = name.toEntity(), mediaType = mediaType, creationTime = creationTime.toInstant(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt index a784c678..ff9fbff4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt @@ -1,13 +1,16 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.DegradedFolderNode +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.ParentNodeUid +import me.proton.drive.sdk.entity.ScopeId import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.trashTimeOrNull fun ProtonDriveSdk.DegradedFolderNode.toEntity() = DegradedFolderNode( - uid = uid, - parentUid = parentUid, - treeEventScopeId = treeEventScopeId, + uid = NodeUid(uid), + parentUid = ParentNodeUid(parentUid), + treeEventScopeId = ScopeId(treeEventScopeId), name = name.toEntity(), creationTime = creationTime.toInstant(), trashTime = trashTimeOrNull?.toInstant(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt index fc32d871..fc896829 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt @@ -1,13 +1,16 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.FileNode +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.ParentNodeUid +import me.proton.drive.sdk.entity.ScopeId import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.trashTimeOrNull fun ProtonDriveSdk.FileNode.toEntity() = FileNode( - uid = uid, - parentUid = parentUid, - treeEventScopeId = treeEventScopeId, + uid = NodeUid(uid), + parentUid = ParentNodeUid(parentUid), + treeEventScopeId = ScopeId(treeEventScopeId), name = name, mediaType = mediaType, creationTime = creationTime.toInstant(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt index 115e96bb..be43f806 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt @@ -2,10 +2,11 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.ProtonDriveSdkException import me.proton.drive.sdk.entity.FileThumbnail +import me.proton.drive.sdk.entity.NodeUid import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.FileThumbnail.toEntity(): FileThumbnail = FileThumbnail( - uid = fileUid, + uid = NodeUid(fileUid), result = when (resultCase) { ProtonDriveSdk.FileThumbnail.ResultCase.DATA -> Result.success(data.toByteArray()) ProtonDriveSdk.FileThumbnail.ResultCase.ERROR -> Result.failure( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt index eece3d4f..d0720c9e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt @@ -12,7 +12,7 @@ internal fun FileUploaderRequest.toProtobuf( name = this@toProtobuf.name mediaType = this@toProtobuf.mediaType size = this@toProtobuf.fileSize - parentFolderUid = this@toProtobuf.parentFolderUid + parentFolderUid = this@toProtobuf.parentFolderUid.value lastModificationTime = this@toProtobuf.lastModificationTime.toTimestamp() overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt index 29ed066f..403d058b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt @@ -1,13 +1,16 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.FolderNode +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.ParentNodeUid +import me.proton.drive.sdk.entity.ScopeId import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.trashTimeOrNull fun ProtonDriveSdk.FolderNode.toEntity() = FolderNode( - uid = uid, - parentUid = parentUid, - treeEventScopeId = treeEventScopeId, + uid = NodeUid(uid), + parentUid = ParentNodeUid(parentUid), + treeEventScopeId = ScopeId(treeEventScopeId), name = name, creationTime = creationTime.toInstant(), trashTime = trashTimeOrNull?.toInstant(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt index f768dc1e..5100562a 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt @@ -10,7 +10,7 @@ internal fun FileRevisionUploaderRequest.toProtobuf( ) = driveClientGetFileRevisionUploaderRequest { this.clientHandle = clientHandle this.cancellationTokenSourceHandle = cancellationTokenSourceHandle - this.currentActiveRevisionUid = this@toProtobuf.currentActiveRevisionUid + this.currentActiveRevisionUid = this@toProtobuf.currentActiveRevisionUid.value this.size = this@toProtobuf.size this.lastModificationTime = this@toProtobuf.lastModificationTime.toTimestamp() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt index cbbccce3..4f0ec44f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt @@ -1,10 +1,12 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.ProtonSdkError +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.RevisionUid import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.NodeNameConflictErrorData.toEntity() = ProtonSdkError.Data.NodeNameConflict( conflictingNodeIsFileDraft = conflictingNodeIsFileDraft, - conflictingNodeUid = conflictingNodeUid, - conflictingRevisionUid = conflictingRevisionUid, + conflictingNodeUid = NodeUid(conflictingNodeUid), + conflictingRevisionUid = RevisionUid(conflictingRevisionUid), ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResultListResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResultListResponse.kt index 793eb6d8..cb928017 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResultListResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResultListResponse.kt @@ -1,6 +1,7 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.NodeResultPair +import me.proton.drive.sdk.entity.NodeUid import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.NodeResultListResponse.toEntity(): List = @@ -8,7 +9,7 @@ fun ProtonDriveSdk.NodeResultListResponse.toEntity(): List = fun ProtonDriveSdk.NodeResultPair.toEntity(): NodeResultPair = if (hasError()) { - NodeResultPair.Failure(nodeUid = nodeUid, error = error.toException()) + NodeResultPair.Failure(nodeUid = NodeUid(nodeUid), error = error.toException()) } else { - NodeResultPair.Success(nodeUid = nodeUid) + NodeResultPair.Success(nodeUid = NodeUid(nodeUid)) } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt index 5497d3c8..c858f651 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.extension +import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.PhotosTimelineItem import proton.drive.sdk.ProtonDriveSdk @@ -7,6 +8,6 @@ fun ProtonDriveSdk.PhotosTimelineList.toEntity(): List = itemsList.map { it.toEntity() } fun ProtonDriveSdk.PhotosTimelineItem.toEntity() = PhotosTimelineItem( - nodeUid = nodeUid, + nodeUid = NodeUid(nodeUid), captureTime = captureTime.toInstant(), ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt index 4cfb74ec..0ed32436 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt @@ -1,12 +1,13 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.FileRevision +import me.proton.drive.sdk.entity.RevisionUid import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.claimedModificationTimeOrNull import proton.drive.sdk.contentAuthorOrNull fun ProtonDriveSdk.FileRevision.toEntity() = FileRevision( - uid = uid, + uid = RevisionUid(uid), creationTime = creationTime.toInstant(), sizeOnCloudStorage = sizeOnCloudStorage, claimedSize = if (hasClaimedSize()) claimedSize else null, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt index 9d837039..01e37a4f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt @@ -1,9 +1,11 @@ package me.proton.drive.sdk.extension +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.RevisionUid import me.proton.drive.sdk.entity.UploadResult import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.UploadResult.toEntity() = UploadResult( - nodeUid = nodeUid, - revisionUid = revisionUid + nodeUid = NodeUid(nodeUid), + revisionUid = RevisionUid(revisionUid) ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt index b8f62e27..faf8c01f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.internal +import me.proton.drive.sdk.entity.RevisionUid import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk @@ -14,10 +15,10 @@ class JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { suspend fun getFileDownloader( clientHandle: Long, cancellationTokenSourceHandle: Long, - revisionUid: String, + revisionUid: RevisionUid, ): Long = executeOnce("create", LongResponseCallback) { driveClientGetFileDownloader = driveClientGetFileDownloaderRequest { - this.revisionUid = revisionUid + this.revisionUid = revisionUid.value this.clientHandle = clientHandle this.cancellationTokenSourceHandle = cancellationTokenSourceHandle } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt index 0e9b8ae0..8e24ff74 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.internal +import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk @@ -13,10 +14,10 @@ class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { suspend fun getPhotoDownloader( clientHandle: Long, cancellationTokenSourceHandle: Long, - photoUid: String, + photoUid: NodeUid, ): Long = executeOnce("create", LongResponseCallback) { drivePhotosClientGetPhotoDownloader = drivePhotosClientGetPhotoDownloaderRequest { - this.photoUid = photoUid + this.photoUid = photoUid.value this.clientHandle = clientHandle this.cancellationTokenSourceHandle = cancellationTokenSourceHandle } From 4a48d717dc784f621ac26ff064436ad5dd175a31 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 17:24:08 +0000 Subject: [PATCH 657/791] Remove get thumbnails in favor of enumerate thumbnails --- .../InteropMessageHandler.cs | 7 --- .../InteropProtonDriveClient.cs | 31 ---------- .../InteropProtonPhotosClient.cs | 30 --------- cs/sdk/src/protos/proton.drive.sdk.proto | 62 +++++++------------ .../me/proton/drive/sdk/ProtonDriveClient.kt | 22 ------- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 22 ------- .../converter/FileThumbnailListConverter.kt | 10 --- .../sdk/internal/JniProtonDriveClient.kt | 8 --- .../sdk/internal/JniProtonPhotosClient.kt | 8 --- 9 files changed, 22 insertions(+), 178 deletions(-) delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 26b6f5f9..459c4f15 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -58,9 +58,6 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientCreateFolder => await InteropProtonDriveClient.HandleCreateFolderAsync(request.DriveClientCreateFolder).ConfigureAwait(false), - Request.PayloadOneofCase.DriveClientGetThumbnails - => await InteropProtonDriveClient.HandleGetThumbnailsAsync(request.DriveClientGetThumbnails).ConfigureAwait(false), - Request.PayloadOneofCase.DriveClientEnumerateThumbnails => await InteropProtonDriveClient.HandleEnumerateThumbnailsAsync( request.DriveClientEnumerateThumbnails, @@ -136,10 +133,6 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DrivePhotosClientFree => InteropProtonPhotosClient.HandleFree(request.DrivePhotosClientFree), - Request.PayloadOneofCase.DrivePhotosClientEnumeratePhotosThumbnails - => await InteropProtonPhotosClient.HandleEnumeratePhotosThumbnailsAsync( - request.DrivePhotosClientEnumeratePhotosThumbnails).ConfigureAwait(false), - Request.PayloadOneofCase.DrivePhotosClientEnumerateThumbnails => await InteropProtonPhotosClient.HandleEnumerateThumbnailsAsync( request.DrivePhotosClientEnumerateThumbnails, bindingsHandle).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index c7d02db6..94e59291 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -164,37 +164,6 @@ public static async ValueTask HandleGetAvailableNameAsync(DriveClientG return new StringValue { Value = availableName }; } - public static async ValueTask HandleGetThumbnailsAsync(DriveClientGetThumbnailsRequest request) - { - var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - - var client = Interop.GetFromHandle(request.ClientHandle); - - var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( - request.FileUids.Select(NodeUid.Parse), - (Nodes.ThumbnailType)request.Type, - cancellationToken); - - var thumbnails = await thumbnailsEnumerable - .Select(x => - { - var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() }; - if (x.Result.TryGetValueElseError(out var data, out var error)) - { - thumbnail.Data = ByteString.CopyFrom(data.Span); - } - else - { - thumbnail.Error = ConvertToDriveError(error); - } - - return thumbnail; - }) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - return new FileThumbnailList { Thumbnails = { thumbnails } }; - } - public static async ValueTask HandleEnumerateThumbnailsAsync(DriveClientEnumerateThumbnailsRequest request, nint bindingsHandle) { var iterateFunction = new InteropAction>(request.IterateAction); diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index b0075b02..442d96c9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -128,36 +128,6 @@ public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhot return new Int64Value { Value = Interop.AllocHandle(downloader) }; } - public static async ValueTask HandleEnumeratePhotosThumbnailsAsync(DrivePhotosClientGetThumbnailsRequest request) - { - var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - var client = Interop.GetFromHandle(request.ClientHandle); - - var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( - request.PhotoUids.Select(NodeUid.Parse), - (Nodes.ThumbnailType)request.Type, - cancellationToken); - - var thumbnails = await thumbnailsEnumerable - .Select(x => - { - var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() }; - if (x.Result.TryGetValueElseError(out var data, out var error)) - { - thumbnail.Data = ByteString.CopyFrom(data.Span); - } - else - { - thumbnail.Error = InteropProtonDriveClient.ConvertToDriveError(error); - } - - return thumbnail; - }) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - return new FileThumbnailList { Thumbnails = { thumbnails } }; - } - public static async ValueTask HandleEnumerateThumbnailsAsync(DrivePhotosClientEnumerateThumbnailsRequest request, nint bindingsHandle) { var iterateFunction = new InteropAction>(request.IterateAction); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index d603ea82..31572406 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -16,17 +16,16 @@ message Request { DriveClientGetFileRevisionUploaderRequest drive_client_get_file_revision_uploader = 1004; DriveClientGetFileDownloaderRequest drive_client_get_file_downloader = 1005; DriveClientGetAvailableNameRequest drive_client_get_available_name = 1006; - DriveClientGetThumbnailsRequest drive_client_get_thumbnails = 1007; - DriveClientRenameRequest drive_client_rename = 1008; - DriveClientCreateFolderRequest drive_client_create_folder = 1009; - DriveClientTrashNodesRequest drive_client_trash_nodes = 1010; - DriveClientEnumerateFolderChildrenRequest drive_client_enumerate_folder_children = 1011; - DriveClientGetMyFilesFolderRequest drive_client_get_my_files_folder = 1012; - DriveClientDeleteNodesRequest drive_client_delete_nodes = 1013; - DriveClientRestoreNodesRequest drive_client_restore_nodes = 1014; - DriveClientEnumerateTrashRequest drive_client_enumerate_trash = 1015; - DriveClientEmptyTrashRequest drive_client_empty_trash = 1016; - DriveClientEnumerateThumbnailsRequest drive_client_enumerate_thumbnails = 1017; + DriveClientRenameRequest drive_client_rename = 1007; + DriveClientCreateFolderRequest drive_client_create_folder = 1008; + DriveClientTrashNodesRequest drive_client_trash_nodes = 1009; + DriveClientEnumerateFolderChildrenRequest drive_client_enumerate_folder_children = 1010; + DriveClientGetMyFilesFolderRequest drive_client_get_my_files_folder = 1011; + DriveClientDeleteNodesRequest drive_client_delete_nodes = 1012; + DriveClientRestoreNodesRequest drive_client_restore_nodes = 1013; + DriveClientEnumerateTrashRequest drive_client_enumerate_trash = 1014; + DriveClientEmptyTrashRequest drive_client_empty_trash = 1015; + DriveClientEnumerateThumbnailsRequest drive_client_enumerate_thumbnails = 1016; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -51,19 +50,18 @@ message Request { DrivePhotosClientCreateRequest drive_photos_client_create = 1300; DrivePhotosClientCreateFromSessionRequest drive_photos_client_create_from_session = 1301; DrivePhotosClientFreeRequest drive_photos_client_free = 1302; - DrivePhotosClientGetThumbnailsRequest drive_photos_client_enumerate_photos_thumbnails = 1303; - DrivePhotosClientGetPhotoDownloaderRequest drive_photos_client_get_photo_downloader = 1304; - DrivePhotosClientDownloadToStreamRequest drive_photos_client_download_to_stream = 1305; - DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1306; - DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1307; - DrivePhotosClientGetNodeRequest drive_photos_client_get_node = 1308; - DrivePhotosClientEnumerateTimelineRequest drive_photos_client_enumerate_timeline = 1309; - DrivePhotosClientGetPhotoUploaderRequest drive_photos_client_get_photo_uploader = 1310; - DrivePhotosClientFindDuplicatesRequest drive_photos_client_find_duplicates = 1311; - DrivePhotosClientUploadFromStreamRequest drive_photos_client_upload_from_stream = 1312; - DrivePhotosClientUploadFromFileRequest drive_photos_client_upload_from_file = 1313; - DrivePhotosClientUploaderFreeRequest drive_photos_client_uploader_free = 1314; - DrivePhotosClientEnumerateThumbnailsRequest drive_photos_client_enumerate_thumbnails = 1315; + DrivePhotosClientGetPhotoDownloaderRequest drive_photos_client_get_photo_downloader = 1303; + DrivePhotosClientDownloadToStreamRequest drive_photos_client_download_to_stream = 1304; + DrivePhotosClientDownloadToFileRequest drive_photos_client_download_to_file = 1305; + DrivePhotosClientDownloaderFreeRequest drive_photos_client_downloader_free = 1306; + DrivePhotosClientGetNodeRequest drive_photos_client_get_node = 1307; + DrivePhotosClientEnumerateTimelineRequest drive_photos_client_enumerate_timeline = 1308; + DrivePhotosClientGetPhotoUploaderRequest drive_photos_client_get_photo_uploader = 1309; + DrivePhotosClientFindDuplicatesRequest drive_photos_client_find_duplicates = 1310; + DrivePhotosClientUploadFromStreamRequest drive_photos_client_upload_from_stream = 1311; + DrivePhotosClientUploadFromFileRequest drive_photos_client_upload_from_file = 1312; + DrivePhotosClientUploaderFreeRequest drive_photos_client_uploader_free = 1313; + DrivePhotosClientEnumerateThumbnailsRequest drive_photos_client_enumerate_thumbnails = 1314; }; } @@ -422,14 +420,6 @@ message DriveClientEmptyTrashRequest { int64 cancellation_token_source_handle = 2; } -// The response message must be of type FileThumbnailList. -message DriveClientGetThumbnailsRequest { - int64 client_handle = 1; - repeated string file_uids = 2; - ThumbnailType type = 3; - int64 cancellation_token_source_handle = 4; -} - // The response must not have a value, iterate_action will be call for each item. message DriveClientEnumerateThumbnailsRequest { int64 client_handle = 1; @@ -645,14 +635,6 @@ message DrivePhotosClientFreeRequest { int64 client_handle = 1; } -// The response message must be of type FileThumbnailList. -message DrivePhotosClientGetThumbnailsRequest { - int64 client_handle = 1; - repeated string photo_uids = 2; - ThumbnailType type = 3; - int64 cancellation_token_source_handle = 4; -} - // The response must not have a value, iterate_action will be call for each item. message DrivePhotosClientEnumerateThumbnailsRequest { int64 client_handle = 1; diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 68911ef4..26127902 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -26,7 +26,6 @@ import proton.drive.sdk.driveClientEnumerateThumbnailsRequest import proton.drive.sdk.driveClientEnumerateTrashRequest import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetMyFilesFolderRequest -import proton.drive.sdk.driveClientGetThumbnailsRequest import proton.drive.sdk.driveClientRenameRequest import proton.drive.sdk.driveClientRestoreNodesRequest import proton.drive.sdk.driveClientTrashNodesRequest @@ -53,27 +52,6 @@ class ProtonDriveClient internal constructor( ) } - @Deprecated( - message = "Use enumerateThumbnails instead for streaming results.", - replaceWith = ReplaceWith("enumerateThumbnails(fileUids, type)"), - ) - suspend fun getThumbnails( - fileUids: List, - type: ThumbnailType, - ): List = cancellationCoroutineScope { source -> - log(INFO, "getThumbnails($type)") - bridge.getThumbnails( - driveClientGetThumbnailsRequest { - this.fileUids += fileUids.map { it.value } - this.type = type.toProto() - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).thumbnailsList.map { fileThumbnail -> - fileThumbnail.toEntity() - } - } - fun enumerateThumbnails( fileUids: List, type: ThumbnailType, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 028e1727..cfde9ff1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -20,7 +20,6 @@ import me.proton.drive.sdk.internal.toLogId import proton.drive.sdk.drivePhotosClientEnumerateThumbnailsRequest import proton.drive.sdk.drivePhotosClientEnumerateTimelineRequest import proton.drive.sdk.drivePhotosClientGetNodeRequest -import proton.drive.sdk.drivePhotosClientGetThumbnailsRequest class ProtonPhotosClient internal constructor( internal val handle: Long, @@ -28,27 +27,6 @@ class ProtonPhotosClient internal constructor( session: Session? = null, ) : SdkNode(session), AutoCloseable { - @Deprecated( - message = "Use enumerateThumbnails instead for streaming results.", - replaceWith = ReplaceWith("enumerateThumbnails(photoUids, type)"), - ) - suspend fun getThumbnails( - photoUids: List, - type: ThumbnailType, - ): List = cancellationCoroutineScope { source -> - log(INFO, "getThumbnails($type)") - bridge.getThumbnails( - drivePhotosClientGetThumbnailsRequest { - this.photoUids += photoUids.map { it.value } - this.type = type.toProto() - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).thumbnailsList.map { fileThumbnail -> - fileThumbnail.toEntity() - } - } - fun enumerateThumbnails( photoUids: List, type: ThumbnailType, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt deleted file mode 100644 index 12a52f98..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FileThumbnailListConverter.kt +++ /dev/null @@ -1,10 +0,0 @@ -package me.proton.drive.sdk.converter - -import com.google.protobuf.Any -import proton.drive.sdk.ProtonDriveSdk - -class FileThumbnailListConverter : AnyConverter { - override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.FileThumbnailList" - - override fun convert(any: Any): ProtonDriveSdk.FileThumbnailList = ProtonDriveSdk.FileThumbnailList.parseFrom(any.value) -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index c5b829b1..26129701 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -4,7 +4,6 @@ import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.ProducerScope import me.proton.drive.sdk.converter.NodeResultListResponseConverter -import me.proton.drive.sdk.converter.FileThumbnailListConverter import me.proton.drive.sdk.converter.FolderChildrenListConverter import me.proton.drive.sdk.converter.FolderNodeConverter import me.proton.drive.sdk.entity.ClientCreateRequest @@ -93,13 +92,6 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { driveClientRename = request } - suspend fun getThumbnails( - request: ProtonDriveSdk.DriveClientGetThumbnailsRequest, - ): ProtonDriveSdk.FileThumbnailList = - executeOnce("getThumbnails", FileThumbnailListConverter().asCallback) { - driveClientGetThumbnails = request - } - suspend fun enumerateThumbnails( coroutineScope: CoroutineScope, request: ProtonDriveSdk.DriveClientEnumerateThumbnailsRequest, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index 11470baa..5163d7fe 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -2,7 +2,6 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope -import me.proton.drive.sdk.converter.FileThumbnailListConverter import me.proton.drive.sdk.converter.NodeResultConverter import me.proton.drive.sdk.converter.PhotosTimelineListConverter import me.proton.drive.sdk.entity.ClientCreateRequest @@ -77,13 +76,6 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { } }) - suspend fun getThumbnails( - request: ProtonDriveSdk.DrivePhotosClientGetThumbnailsRequest, - ): ProtonDriveSdk.FileThumbnailList = - executeOnce("getThumbnails", FileThumbnailListConverter().asCallback) { - drivePhotosClientEnumeratePhotosThumbnails = request - } - suspend fun enumerateThumbnails( coroutineScope: CoroutineScope, request: ProtonDriveSdk.DrivePhotosClientEnumerateThumbnailsRequest, From e0ba5ac8cb4d924dcc67ac0cb5d3bbef28d89d72 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 31 Mar 2026 05:44:21 +0000 Subject: [PATCH 658/791] Update changelog for cs/v0.12.0 --- cs/CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 5dc42786..7ac60c5e 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## cs/v0.12.0 (2026-03-30) + +* Remove get thumbnails in favor of enumerate thumbnails +* Introduce uids in the kotlin bindings +* Move native weak reference management to kotlin +* Do not call interop functions if cancelled +* Fix thumbnail enumeration to stay within API limits +* Fix cancellation in download and upload +* Log network calls with body size +* Add streaming thumbnails enumeration to Swift bindings +* Remove the need to dispose of Photos client + ## cs/v0.11.2 (2026-03-27) * Stream trash enumeration instead of loading all items at once From 158374048bacf1f2c91148edab66a540579485fe Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 31 Mar 2026 11:33:42 +0200 Subject: [PATCH 659/791] Remove casting for parentNodeUid --- js/sdk/src/internal/events/apiService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index 703480cd..ba5473f9 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -81,7 +81,9 @@ export class EventsAPIService { const type = VOLUME_EVENT_TYPE_MAP[event.EventType]; const uids = { nodeUid: makeNodeUid(volumeId, event.Link.LinkID), - parentNodeUid: makeNodeUid(volumeId, event.Link.ParentLinkID as string), + parentNodeUid: event.Link.ParentLinkID + ? makeNodeUid(volumeId, event.Link.ParentLinkID) + : undefined, }; return { type, From 4d4b51ec900603639a7ad5e52a5e785fd73f4b36 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 15:15:29 +0100 Subject: [PATCH 660/791] Add trash management to Photos --- .../InteropMessageHandler.cs | 15 ++++ .../InteropProtonPhotosClient.cs | 89 +++++++++++++++++++ .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 11 ++- .../Proton.Drive.Sdk/ProtonPhotosClient.cs | 46 ++++++++++ .../Volumes/VolumeOperations.cs | 11 +-- cs/sdk/src/protos/proton.drive.sdk.proto | 39 ++++++++ .../me/proton/drive/sdk/ProtonPhotosClient.kt | 73 ++++++++++++++- .../converter/TrashChildrenListConverter.kt | 11 --- .../sdk/internal/JniProtonPhotosClient.kt | 44 +++++++++ 9 files changed, 316 insertions(+), 23 deletions(-) delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashChildrenListConverter.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 459c4f15..8e08206b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -171,6 +171,21 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DrivePhotosClientUploaderFree => InteropPhotosUploader.HandleFree(request.DrivePhotosClientUploaderFree), + Request.PayloadOneofCase.DrivePhotosClientTrashNodes + => await InteropProtonPhotosClient.HandleTrashNodesAsync(request.DrivePhotosClientTrashNodes).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientDeleteNodes + => await InteropProtonPhotosClient.HandleDeleteNodesAsync(request.DrivePhotosClientDeleteNodes).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientRestoreNodes + => await InteropProtonPhotosClient.HandleRestoreNodesAsync(request.DrivePhotosClientRestoreNodes).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientEnumerateTrash + => await InteropProtonPhotosClient.HandleEnumerateTrashAsync(request.DrivePhotosClientEnumerateTrash, bindingsHandle).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientEmptyTrash + => await InteropProtonPhotosClient.HandleEmptyTrashAsync(request.DrivePhotosClientEmptyTrash).ConfigureAwait(false), + Request.PayloadOneofCase.None or _ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 442d96c9..d36f538d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -74,6 +74,71 @@ public static IMessage HandleCreate(DrivePhotosClientCreateFromSessionRequest re return new Int64Value { Value = Interop.AllocHandle(client) }; } + public static async ValueTask HandleTrashNodesAsync(DrivePhotosClientTrashNodesRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var results = await client.TrashNodesAsync( + request.NodeUids.Select(NodeUid.Parse), + cancellationToken).ConfigureAwait(false); + + return ConvertToNodeResultListResponse(results); + } + + public static async ValueTask HandleDeleteNodesAsync(DrivePhotosClientDeleteNodesRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var results = await client.DeleteNodesAsync( + request.NodeUids.Select(NodeUid.Parse), + cancellationToken).ConfigureAwait(false); + + return ConvertToNodeResultListResponse(results); + } + + public static async ValueTask HandleRestoreNodesAsync(DrivePhotosClientRestoreNodesRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var results = await client.RestoreNodesAsync( + request.NodeUids.Select(NodeUid.Parse), + cancellationToken).ConfigureAwait(false); + + return ConvertToNodeResultListResponse(results); + } + + public static async ValueTask HandleEnumerateTrashAsync(DrivePhotosClientEnumerateTrashRequest request, nint bindingsHandle) + { + var iterateFunction = new InteropAction>(request.IterateAction); + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + await foreach (var x in client.EnumerateTrashAsync(cancellationToken).ConfigureAwait(false)) + { + iterateFunction.InvokeWithMessage(bindingsHandle, InteropProtonDriveClient.ConvertToNodeResult(x)); + } + + return null; + } + + public static async ValueTask HandleEmptyTrashAsync(DrivePhotosClientEmptyTrashRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + await client.EmptyTrashAsync(cancellationToken).ConfigureAwait(false); + + return null; + } + public static IMessage? HandleFree(DrivePhotosClientFreeRequest request) { Interop.FreeHandle(request.ClientHandle); @@ -211,4 +276,28 @@ static void GenerateSha1Action(string sha1) // TODO: Implement SHA1 generation callback } } + + private static NodeResultListResponse ConvertToNodeResultListResponse(IReadOnlyDictionary> results) + { + return new NodeResultListResponse + { + Results = + { + results.Select(pair => + { + var result = new NodeResultPair + { + NodeUid = pair.Key.ToString(), + }; + + if (pair.Value.TryGetError(out var exception)) + { + result.Error = exception.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes); + } + + return result; + }), + }, + }; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 2492f435..7b6fbc6e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -292,9 +292,16 @@ public async IAsyncEnumerable> EnumerateTrashAsync([E } } - public ValueTask EmptyTrashAsync(CancellationToken cancellationToken) + public async ValueTask EmptyTrashAsync(CancellationToken cancellationToken) { - return VolumeOperations.EmptyTrashAsync(this, cancellationToken); + var volumeId = await VolumeOperations.TryGetMainVolumeIdAsync(this, cancellationToken).ConfigureAwait(false); + + if (volumeId is null) + { + return; + } + + await VolumeOperations.EmptyTrashAsync(this, volumeId.Value, cancellationToken).ConfigureAwait(false); } private async ValueTask GetFileUploaderAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index b970ab03..95a1baaf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -1,10 +1,12 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Http; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Drive.Sdk.Volumes; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.Http; @@ -105,6 +107,50 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( return FileOperations.EnumerateThumbnailsAsync(DriveClient, photoUids, thumbnailType, forPhotos: true, cancellationToken); } + public ValueTask>> TrashNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.TrashAsync(DriveClient, uids, cancellationToken); + } + + public ValueTask>> DeleteNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.DeleteFromTrashAsync(DriveClient, uids, cancellationToken); + } + + public ValueTask>> RestoreNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.RestoreFromTrashAsync(DriveClient, uids, cancellationToken); + } + + public async IAsyncEnumerable> EnumerateTrashAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var volumeId = await VolumeOperations.TryGetPhotosVolumeIdAsync(DriveClient, cancellationToken).ConfigureAwait(false); + + if (volumeId is null) + { + // Nothing to enumerate if the main volume doesn't exist + yield break; + } + + await foreach (var item in VolumeOperations.EnumerateTrashAsync(DriveClient, volumeId.Value, cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + + public async ValueTask EmptyTrashAsync(CancellationToken cancellationToken) + { + var volumeId = await VolumeOperations.TryGetPhotosVolumeIdAsync(DriveClient, cancellationToken).ConfigureAwait(false); + + if (volumeId is null) + { + // Nothing to do if the photos volume doesn't exist + return; + } + + await VolumeOperations.EmptyTrashAsync(DriveClient, volumeId.Value, cancellationToken).ConfigureAwait(false); + } + [Experimental("Photos")] internal ValueTask GetPhotosRootAsync(CancellationToken cancellationToken) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 7bf93cef..2b8d7ee8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -152,16 +152,9 @@ await client.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share. return (volume, share, rootFolder); } - public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, CancellationToken cancellationToken) + public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, VolumeId volumeId, CancellationToken cancellationToken) { - var volumeId = await TryGetMainVolumeIdAsync(client, cancellationToken).ConfigureAwait(false); - if (volumeId is null) - { - // No trash to empty if the main volume doesn't exist - return; - } - - await client.Api.Trash.EmptyAsync(volumeId.Value, cancellationToken).ConfigureAwait(false); + await client.Api.Trash.EmptyAsync(volumeId, cancellationToken).ConfigureAwait(false); } public static async ValueTask TryGetMainVolumeIdAsync(ProtonDriveClient client, CancellationToken cancellationToken) diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 31572406..b680f352 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -62,6 +62,11 @@ message Request { DrivePhotosClientUploadFromFileRequest drive_photos_client_upload_from_file = 1312; DrivePhotosClientUploaderFreeRequest drive_photos_client_uploader_free = 1313; DrivePhotosClientEnumerateThumbnailsRequest drive_photos_client_enumerate_thumbnails = 1314; + DrivePhotosClientTrashNodesRequest drive_photos_client_trash_nodes = 1315; + DrivePhotosClientDeleteNodesRequest drive_photos_client_delete_nodes = 1316; + DrivePhotosClientRestoreNodesRequest drive_photos_client_restore_nodes = 1317; + DrivePhotosClientEnumerateTrashRequest drive_photos_client_enumerate_trash = 1318; + DrivePhotosClientEmptyTrashRequest drive_photos_client_empty_trash = 1319; }; } @@ -765,6 +770,40 @@ message DrivePhotosClientUploaderFreeRequest { int64 file_uploader_handle = 1; } +// The response message must be of type NodeResultListResponse +message DrivePhotosClientTrashNodesRequest { + int64 client_handle = 1; + repeated string node_uids = 2; + int64 cancellation_token_source_handle = 3; +} + +// The response message must be of type NodeResultListResponse +message DrivePhotosClientDeleteNodesRequest { + int64 client_handle = 1; + repeated string node_uids = 2; + int64 cancellation_token_source_handle = 3; +} + +// The response message must be of type NodeResultListResponse +message DrivePhotosClientRestoreNodesRequest { + int64 client_handle = 1; + repeated string node_uids = 2; + int64 cancellation_token_source_handle = 3; +} + +// The response message must be of type TrashChildrenList +message DrivePhotosClientEnumerateTrashRequest { + int64 client_handle = 1; + int64 cancellation_token_source_handle = 2; + int64 iterate_action = 3; +} + +// The response must not have a value. +message DrivePhotosClientEmptyTrashRequest { + int64 client_handle = 1; + int64 cancellation_token_source_handle = 2; +} + // The list must match the order from Telemetry/VolumeTypes to properly match it over the interop. enum VolumeType { VOLUME_TYPE_UNKNOWN = 0; diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index cfde9ff1..0a13650e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -1,12 +1,12 @@ package me.proton.drive.sdk -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.FileThumbnail import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.NodeResultPair import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.PhotosTimelineItem import me.proton.drive.sdk.entity.ThumbnailType @@ -17,9 +17,14 @@ import me.proton.drive.sdk.internal.ProtonDriveSdkNativeClient import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId +import proton.drive.sdk.drivePhotosClientDeleteNodesRequest +import proton.drive.sdk.drivePhotosClientEmptyTrashRequest import proton.drive.sdk.drivePhotosClientEnumerateThumbnailsRequest import proton.drive.sdk.drivePhotosClientEnumerateTimelineRequest +import proton.drive.sdk.drivePhotosClientEnumerateTrashRequest import proton.drive.sdk.drivePhotosClientGetNodeRequest +import proton.drive.sdk.drivePhotosClientRestoreNodesRequest +import proton.drive.sdk.drivePhotosClientTrashNodesRequest class ProtonPhotosClient internal constructor( internal val handle: Long, @@ -70,6 +75,72 @@ class ProtonPhotosClient internal constructor( )?.toEntity() } + suspend fun trashNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "trashNodes(${nodeUids.size} nodes)") + bridge.trashNodes( + drivePhotosClientTrashNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + suspend fun deleteNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "deleteNodes(${nodeUids.size} nodes)") + bridge.deleteNodes( + drivePhotosClientDeleteNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + suspend fun restoreNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "restoreNodes(${nodeUids.size} nodes)") + bridge.restoreNodes( + drivePhotosClientRestoreNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + fun enumerateTrash(): Flow = channelFlow { + log(DEBUG, "enumerateTrash") + cancellationCoroutineScope { source -> + bridge.enumerateTrash( + coroutineScope = this@channelFlow, + drivePhotosClientEnumerateTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + }, + enumerate = { nodeResult -> + send(nodeResult.toEntity()) + } + ) + } + } + + suspend fun emptyTrash(): Unit = cancellationCoroutineScope { source -> + log(INFO, "emptyTrash") + bridge.emptyTrash( + drivePhotosClientEmptyTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ) + } + override fun close() { log(DEBUG, "close") bridge.free(handle) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashChildrenListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashChildrenListConverter.kt deleted file mode 100644 index 92376131..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/TrashChildrenListConverter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.proton.drive.sdk.converter - -import com.google.protobuf.Any -import proton.drive.sdk.ProtonDriveSdk - -class TrashChildrenListConverter : AnyConverter { - override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.TrashChildrenList" - - override fun convert(any: Any): ProtonDriveSdk.TrashChildrenList = - ProtonDriveSdk.TrashChildrenList.parseFrom(any.value) -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index 5163d7fe..2cd45636 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -2,9 +2,12 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.ProducerScope import me.proton.drive.sdk.converter.NodeResultConverter +import me.proton.drive.sdk.converter.NodeResultListResponseConverter import me.proton.drive.sdk.converter.PhotosTimelineListConverter import me.proton.drive.sdk.entity.ClientCreateRequest +import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback import me.proton.drive.sdk.extension.asCallback @@ -104,6 +107,47 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { drivePhotosClientGetNode = request } + suspend fun trashNodes( + request: ProtonDriveSdk.DrivePhotosClientTrashNodesRequest, + ): ProtonDriveSdk.NodeResultListResponse = + executeOnce("trashNodes", NodeResultListResponseConverter().asCallback) { + drivePhotosClientTrashNodes = request + } + + suspend fun deleteNodes( + request: ProtonDriveSdk.DrivePhotosClientDeleteNodesRequest, + ): ProtonDriveSdk.NodeResultListResponse = + executeOnce("deleteNodes", NodeResultListResponseConverter().asCallback) { + drivePhotosClientDeleteNodes = request + } + + suspend fun restoreNodes( + request: ProtonDriveSdk.DrivePhotosClientRestoreNodesRequest, + ): ProtonDriveSdk.NodeResultListResponse = + executeOnce("restoreNodes", NodeResultListResponseConverter().asCallback) { + drivePhotosClientRestoreNodes = request + } + + suspend fun enumerateTrash( + coroutineScope: ProducerScope, + request: ProtonDriveSdk.DrivePhotosClientEnumerateTrashRequest, + enumerate: suspend (ProtonDriveSdk.NodeResult) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateTrash", + callback = UnitResponseCallback, + enumerate = enumerate, + parser = ProtonDriveSdk.NodeResult::parseFrom, + coroutineScopeProvider = { coroutineScope } + ) { + drivePhotosClientEnumerateTrash = request + } + + suspend fun emptyTrash( + request: ProtonDriveSdk.DrivePhotosClientEmptyTrashRequest, + ) = executeOnce("emptyTrash", UnitResponseCallback) { + drivePhotosClientEmptyTrash = request + } + fun free(handle: Long) { dispatch("free") { drivePhotosClientFree = drivePhotosClientFreeRequest { From e6606cbe829aba7e48fa2d8856df1b6b3edb6a93 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 31 Mar 2026 11:42:58 +0000 Subject: [PATCH 661/791] Extract clients interfaces --- .../me/proton/drive/sdk/FileDownloader.kt | 26 +- .../me/proton/drive/sdk/FileUploader.kt | 46 +-- .../me/proton/drive/sdk/PhotosDownloader.kt | 27 +- .../me/proton/drive/sdk/PhotosUploader.kt | 25 +- .../me/proton/drive/sdk/ProtonDriveClient.kt | 227 +------------- .../me/proton/drive/sdk/ProtonDriveSdk.kt | 10 +- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 164 +--------- .../me/proton/drive/sdk/ProtonSdkClient.kt | 17 ++ .../kotlin/me/proton/drive/sdk/Session.kt | 24 ++ .../sdk/internal/InteropProtonDriveClient.kt | 281 ++++++++++++++++++ .../sdk/internal/InteropProtonPhotosClient.kt | 199 +++++++++++++ 11 files changed, 553 insertions(+), 493 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt index 12d0c8cf..fa6a12a0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -1,26 +1,21 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource -import me.proton.drive.sdk.entity.RevisionUid import me.proton.drive.sdk.extension.seek import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniFileDownloader -import me.proton.drive.sdk.internal.cancellationCoroutineScope -import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import java.nio.channels.SeekableByteChannel import java.nio.channels.WritableByteChannel import java.util.concurrent.atomic.AtomicReference -import kotlin.time.Duration class FileDownloader internal constructor( - client: ProtonDriveClient, + client: SdkNode, internal val handle: Long, private val bridge: JniFileDownloader, override val cancellationTokenSource: CancellationTokenSource @@ -76,22 +71,3 @@ class FileDownloader internal constructor( } } -suspend fun ProtonDriveClient.downloader( - revisionUid: RevisionUid, - timeout: Duration, -): Downloader = withTimeout(timeout) { - cancellationCoroutineScope { source -> - factory(JniFileDownloader()) { - FileDownloader( - client = this@downloader, - handle = getFileDownloader( - clientHandle = handle, - cancellationTokenSourceHandle = source.handle, - revisionUid = revisionUid, - ), - bridge = this, - cancellationTokenSource = source, - ) - } - } -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt index 13a32533..eb33328b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -1,25 +1,20 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource -import me.proton.drive.sdk.entity.FileRevisionUploaderRequest -import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniFileUploader import me.proton.drive.sdk.internal.JniUploadController -import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.toLogId import java.nio.channels.ReadableByteChannel import java.util.concurrent.atomic.AtomicReference -import kotlin.time.Duration class FileUploader internal constructor( - client: ProtonDriveClient, + client: SdkNode, internal val handle: Long, private val bridge: JniFileUploader, override val cancellationTokenSource: CancellationTokenSource, @@ -70,42 +65,3 @@ class FileUploader internal constructor( } } -suspend fun ProtonDriveClient.uploader( - request: FileUploaderRequest, - timeout: Duration, -): Uploader = withTimeout(timeout) { - cancellationCoroutineScope { source -> - JniFileUploader().run { - FileUploader( - client = this@uploader, - handle = getFileUploader( - clientHandle = handle, - cancellationTokenSourceHandle = source.handle, - request = request - ), - bridge = this, - cancellationTokenSource = source, - ) - } - } -} - -suspend fun ProtonDriveClient.uploader( - request: FileRevisionUploaderRequest, - timeout: Duration, -): Uploader = withTimeout(timeout) { - cancellationCoroutineScope { source -> - JniFileUploader().run { - FileUploader( - client = this@uploader, - handle = getFileRevisionUploader( - clientHandle = handle, - cancellationTokenSourceHandle = source.handle, - request = request - ), - bridge = this, - cancellationTokenSource = source, - ) - } - } -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt index 4605807a..5e02fcc9 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -1,27 +1,21 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource -import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.extension.seek import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniDownloadController import me.proton.drive.sdk.internal.JniPhotosDownloader -import me.proton.drive.sdk.internal.cancellationCoroutineScope -import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId import java.nio.channels.SeekableByteChannel import java.nio.channels.WritableByteChannel import java.util.concurrent.atomic.AtomicReference -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds class PhotosDownloader internal constructor( - client: ProtonPhotosClient, + client: SdkNode, internal val handle: Long, private val bridge: JniPhotosDownloader, override val cancellationTokenSource: CancellationTokenSource @@ -77,22 +71,3 @@ class PhotosDownloader internal constructor( } } -suspend fun ProtonPhotosClient.downloader( - photoUid: NodeUid, - timeout: Duration, -): Downloader = withTimeout(timeout) { - cancellationCoroutineScope { source -> - factory(JniPhotosDownloader()) { - PhotosDownloader( - client = this@downloader, - handle = getPhotoDownloader( - clientHandle = handle, - cancellationTokenSourceHandle = source.handle, - photoUid = photoUid, - ), - bridge = this, - cancellationTokenSource = source, - ) - } - } -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt index c660cb0a..78f98cdf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt @@ -1,24 +1,20 @@ package me.proton.drive.sdk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withTimeout import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.ProtonDriveSdk.cancellationTokenSource -import me.proton.drive.sdk.entity.PhotosUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toPercentageString import me.proton.drive.sdk.internal.JniPhotosUploader import me.proton.drive.sdk.internal.JniUploadController -import me.proton.drive.sdk.internal.cancellationCoroutineScope import me.proton.drive.sdk.internal.toLogId import java.nio.channels.ReadableByteChannel import java.util.concurrent.atomic.AtomicReference -import kotlin.time.Duration class PhotosUploader( - client: ProtonPhotosClient, + client: SdkNode, internal val handle: Long, private val bridge: JniPhotosUploader, override val cancellationTokenSource: CancellationTokenSource, @@ -69,22 +65,3 @@ class PhotosUploader( } } -suspend fun ProtonPhotosClient.uploader( - request: PhotosUploaderRequest, - timeout: Duration, -): Uploader = withTimeout(timeout) { - cancellationCoroutineScope { source -> - JniPhotosUploader().run { - PhotosUploader( - client = this@uploader, - handle = getPhotoUploader( - clientHandle = handle, - cancellationTokenSourceHandle = source.handle, - request = request, - ), - bridge = this, - cancellationTokenSource = source, - ) - } - } -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index 26127902..b7081e10 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -1,221 +1,22 @@ package me.proton.drive.sdk -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import me.proton.drive.sdk.LoggerProvider.Level.DEBUG -import me.proton.drive.sdk.LoggerProvider.Level.INFO -import me.proton.drive.sdk.entity.FileThumbnail +import me.proton.drive.sdk.entity.FileRevisionUploaderRequest +import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.FolderNode import me.proton.drive.sdk.entity.NodeResult -import me.proton.drive.sdk.entity.ThumbnailType -import me.proton.drive.sdk.entity.NodeResultPair import me.proton.drive.sdk.entity.NodeUid -import me.proton.drive.sdk.extension.toEntity -import me.proton.drive.sdk.extension.toProto -import me.proton.drive.sdk.extension.toTimestamp -import me.proton.drive.sdk.internal.JniProtonDriveClient -import me.proton.drive.sdk.internal.ProtonDriveSdkNativeClient -import me.proton.drive.sdk.internal.cancellationCoroutineScope -import me.proton.drive.sdk.internal.factory -import me.proton.drive.sdk.internal.toLogId -import proton.drive.sdk.driveClientCreateFolderRequest -import proton.drive.sdk.driveClientEnumerateFolderChildrenRequest -import proton.drive.sdk.driveClientDeleteNodesRequest -import proton.drive.sdk.driveClientEmptyTrashRequest -import proton.drive.sdk.driveClientEnumerateThumbnailsRequest -import proton.drive.sdk.driveClientEnumerateTrashRequest -import proton.drive.sdk.driveClientGetAvailableNameRequest -import proton.drive.sdk.driveClientGetMyFilesFolderRequest -import proton.drive.sdk.driveClientRenameRequest -import proton.drive.sdk.driveClientRestoreNodesRequest -import proton.drive.sdk.driveClientTrashNodesRequest +import me.proton.drive.sdk.entity.RevisionUid import java.time.Instant - -class ProtonDriveClient internal constructor( - internal val handle: Long, - private val bridge: JniProtonDriveClient, - session: Session? = null, -) : SdkNode(session), AutoCloseable { - - suspend fun getAvailableName( - parentFolderUid: NodeUid, - name: String, - ): String = cancellationCoroutineScope { source -> - log(DEBUG, "getAvailableName") - bridge.getAvailableName( - driveClientGetAvailableNameRequest { - this.parentFolderUid = parentFolderUid.value - this.name = name - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ) - } - - fun enumerateThumbnails( - fileUids: List, - type: ThumbnailType, - ): Flow = channelFlow { - log(INFO, "enumerateThumbnails(${fileUids.size}, $type)") - cancellationCoroutineScope { source -> - bridge.enumerateThumbnails( - coroutineScope = this@channelFlow, - request = driveClientEnumerateThumbnailsRequest { - this.fileUids += fileUids.map { it.value } - this.type = type.toProto() - clientHandle = handle - cancellationTokenSourceHandle = source.handle - iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() - }, - enumerate = { fileThumbnail -> - send(fileThumbnail.toEntity()) - } - ) - } - } - - suspend fun rename( - nodeUid: NodeUid, - name: String, - mediaType: String? = null, - ): Unit = cancellationCoroutineScope { source -> - log(INFO, "rename") - bridge.rename( - driveClientRenameRequest { - this.nodeUid = nodeUid.value - newName = name - mediaType?.let { - newMediaType = mediaType - } - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ) - } - - suspend fun createFolder( - parentFolderUid: NodeUid, - name: String, - lastModification: Instant? = null, - ): FolderNode = cancellationCoroutineScope { source -> - log(INFO, "createFolder") - bridge.createFolder( - driveClientCreateFolderRequest { - this.parentFolderUid = parentFolderUid.value - folderName = name - lastModification?.let { - lastModificationTime = lastModification.toTimestamp() - } - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun getMyFilesFolder(): FolderNode = cancellationCoroutineScope { source -> - log(DEBUG, "getMyFilesFolder") - bridge.getMyFilesFolder( - driveClientGetMyFilesFolderRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun enumerateFolderChildren( - folderUid: NodeUid, - ): List = cancellationCoroutineScope { source -> - log(DEBUG, "enumerateFolderChildren") - bridge.enumerateFolderChildren( - driveClientEnumerateFolderChildrenRequest { - this.folderUid = folderUid.value - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun trashNodes( - nodeUids: List, - ): List = cancellationCoroutineScope { source -> - log(INFO, "trashNodes(${nodeUids.size} nodes)") - bridge.trashNodes( - driveClientTrashNodesRequest { - this.nodeUids += nodeUids.map { it.value } - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun deleteNodes( - nodeUids: List, - ): List = cancellationCoroutineScope { source -> - log(INFO, "deleteNodes(${nodeUids.size} nodes)") - bridge.deleteNodes( - driveClientDeleteNodesRequest { - this.nodeUids += nodeUids.map { it.value } - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun restoreNodes( - nodeUids: List, - ): List = cancellationCoroutineScope { source -> - log(INFO, "restoreNodes(${nodeUids.size} nodes)") - bridge.restoreNodes( - driveClientRestoreNodesRequest { - this.nodeUids += nodeUids.map { it.value } - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - fun enumerateTrash(): Flow = channelFlow { - log(DEBUG, "enumerateTrash") - cancellationCoroutineScope { source -> - bridge.enumerateTrash( - coroutineScope = this@channelFlow, - request = driveClientEnumerateTrashRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() - }, - enumerate = { nodeResult -> - send(nodeResult.toEntity()) - } - ) - } - } - - suspend fun emptyTrash(): Unit = cancellationCoroutineScope { source -> - log(INFO, "emptyTrash") - bridge.emptyTrash( - driveClientEmptyTrashRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ) - } - - override fun close() { - log(DEBUG, "close") - bridge.free(handle) - super.close() - } - - private fun log(level: LoggerProvider.Level, message: String) { - bridge.clientLogger(level, "DriveClient(${handle.toLogId()}) $message") - } +import kotlin.time.Duration + +interface ProtonDriveClient : ProtonSdkClient { + suspend fun getAvailableName(parentFolderUid: NodeUid, name: String): String + suspend fun rename(nodeUid: NodeUid, name: String, mediaType: String? = null) + suspend fun createFolder(parentFolderUid: NodeUid, name: String, lastModification: Instant? = null): FolderNode + suspend fun getMyFilesFolder(): FolderNode + suspend fun enumerateFolderChildren(folderUid: NodeUid): List + suspend fun downloader(revisionUid: RevisionUid, timeout: Duration): Downloader + suspend fun uploader(request: FileUploaderRequest, timeout: Duration): Uploader + suspend fun uploader(request: FileRevisionUploaderRequest, timeout: Duration): Uploader } -suspend fun Session.protonDriveClientCreate(): ProtonDriveClient = factory(JniProtonDriveClient()) { - ProtonDriveClient( - session = this@protonDriveClientCreate, - handle = createFromSession(sessionHandle = handle), - bridge = this, - ) -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt index 9b4202fa..e614c176 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -9,11 +9,13 @@ import me.proton.drive.sdk.entity.SessionBeginRequest import me.proton.drive.sdk.entity.SessionResumeRequest import me.proton.drive.sdk.internal.AccountClientBridge import me.proton.drive.sdk.internal.ApiProviderBridge +import me.proton.drive.sdk.internal.InteropProtonDriveClient +import me.proton.drive.sdk.internal.InteropProtonPhotosClient import me.proton.drive.sdk.internal.JniCancellationTokenSource -import me.proton.drive.sdk.internal.JniProtonDriveClient -import me.proton.drive.sdk.internal.JniProtonPhotosClient import me.proton.drive.sdk.internal.JniLoggerProvider import me.proton.drive.sdk.internal.JniNativeLibrary +import me.proton.drive.sdk.internal.JniProtonDriveClient +import me.proton.drive.sdk.internal.JniProtonPhotosClient import me.proton.drive.sdk.internal.JniSession import me.proton.drive.sdk.internal.ProtonDriveSdkNativeClient import me.proton.drive.sdk.internal.cancellationCoroutineScope @@ -61,7 +63,7 @@ object ProtonDriveSdk { featureEnabled: suspend (String) -> Boolean = { false }, ): ProtonDriveClient = JniProtonDriveClient().run { clientLogger(DEBUG, "ProtonDriveSdk protonDriveClientCreate(${userId.id.take(8)})") - ProtonDriveClient( + InteropProtonDriveClient( create( coroutineScope = coroutineScope, request = request, @@ -89,7 +91,7 @@ object ProtonDriveSdk { featureEnabled: suspend (String) -> Boolean = { false }, ): ProtonPhotosClient = JniProtonPhotosClient().run { clientLogger(DEBUG, "ProtonDriveSdk protonPhotosClientCreate(${userId.id.take(8)})") - ProtonPhotosClient( + InteropProtonPhotosClient( create( coroutineScope = coroutineScope, request = request, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 0a13650e..9600ba3c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -1,163 +1,15 @@ package me.proton.drive.sdk -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import me.proton.drive.sdk.LoggerProvider.Level.DEBUG -import me.proton.drive.sdk.LoggerProvider.Level.INFO -import me.proton.drive.sdk.entity.FileThumbnail import me.proton.drive.sdk.entity.NodeResult -import me.proton.drive.sdk.entity.NodeResultPair import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.PhotosTimelineItem -import me.proton.drive.sdk.entity.ThumbnailType -import me.proton.drive.sdk.extension.toEntity -import me.proton.drive.sdk.extension.toProto -import me.proton.drive.sdk.internal.JniProtonPhotosClient -import me.proton.drive.sdk.internal.ProtonDriveSdkNativeClient -import me.proton.drive.sdk.internal.cancellationCoroutineScope -import me.proton.drive.sdk.internal.factory -import me.proton.drive.sdk.internal.toLogId -import proton.drive.sdk.drivePhotosClientDeleteNodesRequest -import proton.drive.sdk.drivePhotosClientEmptyTrashRequest -import proton.drive.sdk.drivePhotosClientEnumerateThumbnailsRequest -import proton.drive.sdk.drivePhotosClientEnumerateTimelineRequest -import proton.drive.sdk.drivePhotosClientEnumerateTrashRequest -import proton.drive.sdk.drivePhotosClientGetNodeRequest -import proton.drive.sdk.drivePhotosClientRestoreNodesRequest -import proton.drive.sdk.drivePhotosClientTrashNodesRequest - -class ProtonPhotosClient internal constructor( - internal val handle: Long, - private val bridge: JniProtonPhotosClient, - session: Session? = null, -) : SdkNode(session), AutoCloseable { - - fun enumerateThumbnails( - photoUids: List, - type: ThumbnailType, - ): Flow = channelFlow { - log(INFO, "enumerateThumbnails(${photoUids.size}, $type)") - cancellationCoroutineScope { source -> - bridge.enumerateThumbnails( - coroutineScope = this@channelFlow, - request = drivePhotosClientEnumerateThumbnailsRequest { - this.photoUids += photoUids.map { it.value } - this.type = type.toProto() - clientHandle = handle - cancellationTokenSourceHandle = source.handle - iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() - }, - enumerate = { fileThumbnail -> - send(fileThumbnail.toEntity()) - } - ) - } - } - - suspend fun enumerateTimeline(): List = cancellationCoroutineScope { source -> - log(DEBUG, "enumerateTimeline") - bridge.enumerateTimeline( - drivePhotosClientEnumerateTimelineRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun getNode(nodeUid: NodeUid): NodeResult? = cancellationCoroutineScope { source -> - log(DEBUG, "getNode") - bridge.getNode( - drivePhotosClientGetNodeRequest { - this.nodeUid = nodeUid.value - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - )?.toEntity() - } - - suspend fun trashNodes( - nodeUids: List, - ): List = cancellationCoroutineScope { source -> - log(INFO, "trashNodes(${nodeUids.size} nodes)") - bridge.trashNodes( - drivePhotosClientTrashNodesRequest { - this.nodeUids += nodeUids.map { it.value } - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun deleteNodes( - nodeUids: List, - ): List = cancellationCoroutineScope { source -> - log(INFO, "deleteNodes(${nodeUids.size} nodes)") - bridge.deleteNodes( - drivePhotosClientDeleteNodesRequest { - this.nodeUids += nodeUids.map { it.value } - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - suspend fun restoreNodes( - nodeUids: List, - ): List = cancellationCoroutineScope { source -> - log(INFO, "restoreNodes(${nodeUids.size} nodes)") - bridge.restoreNodes( - drivePhotosClientRestoreNodesRequest { - this.nodeUids += nodeUids.map { it.value } - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() - } - - fun enumerateTrash(): Flow = channelFlow { - log(DEBUG, "enumerateTrash") - cancellationCoroutineScope { source -> - bridge.enumerateTrash( - coroutineScope = this@channelFlow, - drivePhotosClientEnumerateTrashRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() - }, - enumerate = { nodeResult -> - send(nodeResult.toEntity()) - } - ) - } - } - - suspend fun emptyTrash(): Unit = cancellationCoroutineScope { source -> - log(INFO, "emptyTrash") - bridge.emptyTrash( - drivePhotosClientEmptyTrashRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ) - } - - override fun close() { - log(DEBUG, "close") - bridge.free(handle) - super.close() - } - - private fun log(level: LoggerProvider.Level, message: String) { - bridge.clientLogger(level, "ProtonPhotosClient(${handle.toLogId()}) $message") - } +import me.proton.drive.sdk.entity.PhotosUploaderRequest +import kotlin.time.Duration + +interface ProtonPhotosClient : ProtonSdkClient { + suspend fun enumerateTimeline(): List + suspend fun getNode(nodeUid: NodeUid): NodeResult? + suspend fun downloader(photoUid: NodeUid, timeout: Duration): Downloader + suspend fun uploader(request: PhotosUploaderRequest, timeout: Duration): Uploader } -suspend fun Session.protonPhotosClientCreate(): ProtonPhotosClient = - factory(JniProtonPhotosClient()) { - val session = this@protonPhotosClientCreate - ProtonPhotosClient( - session = session, - handle = createFromSession(sessionHandle = handle), - bridge = this, - ) - } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt new file mode 100644 index 00000000..cd1b201f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt @@ -0,0 +1,17 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.flow.Flow +import me.proton.drive.sdk.entity.FileThumbnail +import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.NodeResultPair +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.ThumbnailType + +interface ProtonSdkClient : AutoCloseable { + fun enumerateThumbnails(nodeUids: List, type: ThumbnailType): Flow + suspend fun trashNodes(nodeUids: List): List + suspend fun deleteNodes(nodeUids: List): List + suspend fun restoreNodes(nodeUids: List): List + fun enumerateTrash(): Flow + suspend fun emptyTrash() +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt index 5783ab33..f1c94ffc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt @@ -3,7 +3,12 @@ package me.proton.drive.sdk import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.LoggerProvider.Level.INFO import me.proton.drive.sdk.entity.SessionRenewRequest +import me.proton.drive.sdk.internal.InteropProtonDriveClient +import me.proton.drive.sdk.internal.InteropProtonPhotosClient +import me.proton.drive.sdk.internal.JniProtonDriveClient +import me.proton.drive.sdk.internal.JniProtonPhotosClient import me.proton.drive.sdk.internal.JniSession +import me.proton.drive.sdk.internal.factory import me.proton.drive.sdk.internal.toLogId class Session internal constructor( @@ -36,3 +41,22 @@ class Session internal constructor( bridge.clientLogger(level, "Session(${handle.toLogId()}) $message") } } + +suspend fun Session.protonDriveClientCreate(): ProtonDriveClient = + factory(JniProtonDriveClient()) { + InteropProtonDriveClient( + session = this@protonDriveClientCreate, + handle = createFromSession(sessionHandle = handle), + bridge = this, + ) + } + +suspend fun Session.protonPhotosClientCreate(): ProtonPhotosClient = + factory(JniProtonPhotosClient()) { + val session = this@protonPhotosClientCreate + InteropProtonPhotosClient( + session = session, + handle = createFromSession(sessionHandle = handle), + bridge = this, + ) + } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt new file mode 100644 index 00000000..7400e626 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt @@ -0,0 +1,281 @@ +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.withTimeout +import me.proton.drive.sdk.Downloader +import me.proton.drive.sdk.FileDownloader +import me.proton.drive.sdk.FileUploader +import me.proton.drive.sdk.LoggerProvider +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.ProtonDriveClient +import me.proton.drive.sdk.SdkNode +import me.proton.drive.sdk.Session +import me.proton.drive.sdk.Uploader +import me.proton.drive.sdk.entity.FileRevisionUploaderRequest +import me.proton.drive.sdk.entity.FileThumbnail +import me.proton.drive.sdk.entity.FileUploaderRequest +import me.proton.drive.sdk.entity.FolderNode +import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.NodeResultPair +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.RevisionUid +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.toEntity +import me.proton.drive.sdk.extension.toProto +import me.proton.drive.sdk.extension.toTimestamp +import proton.drive.sdk.driveClientCreateFolderRequest +import proton.drive.sdk.driveClientDeleteNodesRequest +import proton.drive.sdk.driveClientEmptyTrashRequest +import proton.drive.sdk.driveClientEnumerateFolderChildrenRequest +import proton.drive.sdk.driveClientEnumerateThumbnailsRequest +import proton.drive.sdk.driveClientEnumerateTrashRequest +import proton.drive.sdk.driveClientGetAvailableNameRequest +import proton.drive.sdk.driveClientGetMyFilesFolderRequest +import proton.drive.sdk.driveClientRenameRequest +import proton.drive.sdk.driveClientRestoreNodesRequest +import proton.drive.sdk.driveClientTrashNodesRequest +import java.time.Instant +import kotlin.time.Duration + +internal class InteropProtonDriveClient internal constructor( + internal val handle: Long, + private val bridge: JniProtonDriveClient, + session: Session? = null, +) : SdkNode(session), ProtonDriveClient { + + override suspend fun getAvailableName( + parentFolderUid: NodeUid, + name: String, + ): String = cancellationCoroutineScope { source -> + log(DEBUG, "getAvailableName") + bridge.getAvailableName( + driveClientGetAvailableNameRequest { + this.parentFolderUid = parentFolderUid.value + this.name = name + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ) + } + + override fun enumerateThumbnails( + nodeUids: List, + type: ThumbnailType, + ): Flow = channelFlow { + log(INFO, "enumerateThumbnails(${nodeUids.size}, $type)") + cancellationCoroutineScope { source -> + bridge.enumerateThumbnails( + coroutineScope = this@channelFlow, + request = driveClientEnumerateThumbnailsRequest { + this.fileUids += nodeUids.map { it.value } + this.type = type.toProto() + clientHandle = handle + cancellationTokenSourceHandle = source.handle + iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + }, + enumerate = { fileThumbnail -> + send(fileThumbnail.toEntity()) + } + ) + } + } + + override suspend fun rename( + nodeUid: NodeUid, + name: String, + mediaType: String?, + ): Unit = cancellationCoroutineScope { source -> + log(INFO, "rename") + bridge.rename( + driveClientRenameRequest { + this.nodeUid = nodeUid.value + newName = name + mediaType?.let { + newMediaType = mediaType + } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ) + } + + override suspend fun createFolder( + parentFolderUid: NodeUid, + name: String, + lastModification: Instant?, + ): FolderNode = cancellationCoroutineScope { source -> + log(INFO, "createFolder") + bridge.createFolder( + driveClientCreateFolderRequest { + this.parentFolderUid = parentFolderUid.value + folderName = name + lastModification?.let { + lastModificationTime = lastModification.toTimestamp() + } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override suspend fun getMyFilesFolder(): FolderNode = cancellationCoroutineScope { source -> + log(DEBUG, "getMyFilesFolder") + bridge.getMyFilesFolder( + driveClientGetMyFilesFolderRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override suspend fun enumerateFolderChildren( + folderUid: NodeUid, + ): List = cancellationCoroutineScope { source -> + log(DEBUG, "enumerateFolderChildren") + bridge.enumerateFolderChildren( + driveClientEnumerateFolderChildrenRequest { + this.folderUid = folderUid.value + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override suspend fun trashNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "trashNodes(${nodeUids.size} nodes)") + bridge.trashNodes( + driveClientTrashNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override suspend fun deleteNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "deleteNodes(${nodeUids.size} nodes)") + bridge.deleteNodes( + driveClientDeleteNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override suspend fun restoreNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "restoreNodes(${nodeUids.size} nodes)") + bridge.restoreNodes( + driveClientRestoreNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override fun enumerateTrash(): Flow = channelFlow { + log(DEBUG, "enumerateTrash") + cancellationCoroutineScope { source -> + bridge.enumerateTrash( + coroutineScope = this@channelFlow, + request = driveClientEnumerateTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + }, + enumerate = { nodeResult -> + send(nodeResult.toEntity()) + } + ) + } + } + + override suspend fun emptyTrash(): Unit = cancellationCoroutineScope { source -> + log(INFO, "emptyTrash") + bridge.emptyTrash( + driveClientEmptyTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ) + } + + override suspend fun downloader( + revisionUid: RevisionUid, + timeout: Duration, + ): Downloader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + factory(JniFileDownloader()) { + FileDownloader( + client = this@InteropProtonDriveClient, + handle = getFileDownloader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + revisionUid = revisionUid, + ), + bridge = this, + cancellationTokenSource = source, + ) + } + } + } + + override suspend fun uploader( + request: FileUploaderRequest, + timeout: Duration, + ): Uploader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + JniFileUploader().run { + FileUploader( + client = this@InteropProtonDriveClient, + handle = getFileUploader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + request = request, + ), + bridge = this, + cancellationTokenSource = source, + ) + } + } + } + + override suspend fun uploader( + request: FileRevisionUploaderRequest, + timeout: Duration, + ): Uploader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + JniFileUploader().run { + FileUploader( + client = this@InteropProtonDriveClient, + handle = getFileRevisionUploader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + request = request, + ), + bridge = this, + cancellationTokenSource = source, + ) + } + } + } + + override fun close() { + log(DEBUG, "close") + bridge.free(handle) + super.close() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "DriveClient(${handle.toLogId()}) $message") + } +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt new file mode 100644 index 00000000..8c2b3cc0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt @@ -0,0 +1,199 @@ +package me.proton.drive.sdk.internal + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.withTimeout +import me.proton.drive.sdk.Downloader +import me.proton.drive.sdk.LoggerProvider +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import me.proton.drive.sdk.LoggerProvider.Level.INFO +import me.proton.drive.sdk.PhotosDownloader +import me.proton.drive.sdk.PhotosUploader +import me.proton.drive.sdk.ProtonPhotosClient +import me.proton.drive.sdk.SdkNode +import me.proton.drive.sdk.Session +import me.proton.drive.sdk.Uploader +import me.proton.drive.sdk.entity.FileThumbnail +import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.NodeResultPair +import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.PhotosTimelineItem +import me.proton.drive.sdk.entity.PhotosUploaderRequest +import me.proton.drive.sdk.entity.ThumbnailType +import me.proton.drive.sdk.extension.toEntity +import me.proton.drive.sdk.extension.toProto +import proton.drive.sdk.drivePhotosClientDeleteNodesRequest +import proton.drive.sdk.drivePhotosClientEmptyTrashRequest +import proton.drive.sdk.drivePhotosClientEnumerateThumbnailsRequest +import proton.drive.sdk.drivePhotosClientEnumerateTimelineRequest +import proton.drive.sdk.drivePhotosClientEnumerateTrashRequest +import proton.drive.sdk.drivePhotosClientGetNodeRequest +import proton.drive.sdk.drivePhotosClientRestoreNodesRequest +import proton.drive.sdk.drivePhotosClientTrashNodesRequest +import kotlin.time.Duration + +internal class InteropProtonPhotosClient internal constructor( + internal val handle: Long, + private val bridge: JniProtonPhotosClient, + session: Session? = null, +) : SdkNode(session), ProtonPhotosClient { + + override fun enumerateThumbnails( + nodeUids: List, + type: ThumbnailType, + ): Flow = channelFlow { + log(INFO, "enumerateThumbnails(${nodeUids.size}, $type)") + cancellationCoroutineScope { source -> + bridge.enumerateThumbnails( + coroutineScope = this@channelFlow, + request = drivePhotosClientEnumerateThumbnailsRequest { + this.photoUids += nodeUids.map { it.value } + this.type = type.toProto() + clientHandle = handle + cancellationTokenSourceHandle = source.handle + iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + }, + enumerate = { fileThumbnail -> + send(fileThumbnail.toEntity()) + } + ) + } + } + + override suspend fun enumerateTimeline(): List = cancellationCoroutineScope { source -> + log(DEBUG, "enumerateTimeline") + bridge.enumerateTimeline( + drivePhotosClientEnumerateTimelineRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override suspend fun getNode(nodeUid: NodeUid): NodeResult? = cancellationCoroutineScope { source -> + log(DEBUG, "getNode") + bridge.getNode( + drivePhotosClientGetNodeRequest { + this.nodeUid = nodeUid.value + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + )?.toEntity() + } + + override suspend fun trashNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "trashNodes(${nodeUids.size} nodes)") + bridge.trashNodes( + drivePhotosClientTrashNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override suspend fun deleteNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "deleteNodes(${nodeUids.size} nodes)") + bridge.deleteNodes( + drivePhotosClientDeleteNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override suspend fun restoreNodes( + nodeUids: List, + ): List = cancellationCoroutineScope { source -> + log(INFO, "restoreNodes(${nodeUids.size} nodes)") + bridge.restoreNodes( + drivePhotosClientRestoreNodesRequest { + this.nodeUids += nodeUids.map { it.value } + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ).toEntity() + } + + override fun enumerateTrash(): Flow = channelFlow { + log(DEBUG, "enumerateTrash") + cancellationCoroutineScope { source -> + bridge.enumerateTrash( + coroutineScope = this@channelFlow, + drivePhotosClientEnumerateTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + }, + enumerate = { nodeResult -> + send(nodeResult.toEntity()) + } + ) + } + } + + override suspend fun emptyTrash(): Unit = cancellationCoroutineScope { source -> + log(INFO, "emptyTrash") + bridge.emptyTrash( + drivePhotosClientEmptyTrashRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + ) + } + + override suspend fun downloader( + photoUid: NodeUid, + timeout: Duration, + ): Downloader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + factory(JniPhotosDownloader()) { + PhotosDownloader( + client = this@InteropProtonPhotosClient, + handle = getPhotoDownloader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + photoUid = photoUid, + ), + bridge = this, + cancellationTokenSource = source, + ) + } + } + } + + override suspend fun uploader( + request: PhotosUploaderRequest, + timeout: Duration, + ): Uploader = withTimeout(timeout) { + cancellationCoroutineScope { source -> + JniPhotosUploader().run { + PhotosUploader( + client = this@InteropProtonPhotosClient, + handle = getPhotoUploader( + clientHandle = handle, + cancellationTokenSourceHandle = source.handle, + request = request, + ), + bridge = this, + cancellationTokenSource = source, + ) + } + } + } + + override fun close() { + log(DEBUG, "close") + bridge.free(handle) + super.close() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "ProtonPhotosClient(${handle.toLogId()}) $message") + } +} From 88410bcff9c8385c08cf061fbb3467229d0ccd61 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 1 Apr 2026 06:58:08 +0000 Subject: [PATCH 662/791] Update changelog for js/v0.14.3 --- js/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 5a2f0e9c..1473e739 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## js/v0.14.3 (2026-03-31) + +* Remove casting for parentNodeUid +* Fix thumbnail enumeration to stay within API limits + ## js/v0.14.2 (2026-03-30) * Return all possible items from batch loading From b669acfb9b47c92622cdeaa8fe36f3ce93dedd7f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 1 Apr 2026 12:36:40 +0000 Subject: [PATCH 663/791] Fix function to get node from Photos client not using Photos API --- cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index 95a1baaf..6520e04e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -80,7 +80,10 @@ public ValueTask> FindDuplicatesAsync(string name, Action< public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) { - return DriveClient.GetNodeAsync(nodeUid, cancellationToken); + return NodeOperations + .EnumerateNodesAsync(DriveClient, nodeUid.VolumeId, [nodeUid.LinkId], forPhotos: true, cancellationToken) + .Select(x => (Result?)x) + .FirstOrDefaultAsync(cancellationToken); } public IAsyncEnumerable> EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) From 2a50fdc67b43f17044ff1efff3f074b9c2bdcdd4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 1 Apr 2026 13:21:37 +0000 Subject: [PATCH 664/791] Enable streaming of results when enumerating folder children and Photos timeline --- .../InteropMessageHandler.cs | 6 ++-- .../InteropProtonDriveClient.cs | 24 +++++++------- .../InteropProtonPhotosClient.cs | 23 ++++++------- cs/sdk/src/protos/proton.drive.sdk.proto | 30 +++++++++-------- kt/sdk/src/main/jni/proton_drive_sdk.c | 8 ++--- .../me/proton/drive/sdk/ProtonDriveClient.kt | 3 +- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 3 +- .../sdk/internal/InteropProtonDriveClient.kt | 33 +++++++++++-------- .../sdk/internal/InteropProtonPhotosClient.kt | 29 +++++++++------- .../sdk/internal/JniBaseProtonDriveSdk.kt | 4 +-- .../sdk/internal/JniProtonDriveClient.kt | 24 +++++++++----- .../sdk/internal/JniProtonPhotosClient.kt | 24 +++++++++----- .../internal/ProtonDriveSdkNativeClient.kt | 12 +++---- .../{EnumerateHandler.kt => YieldHandler.kt} | 14 ++++---- .../ProtonPhotosClient.swift | 13 ++++++-- .../cTimelineEnumerationCallback.swift | 23 +++++++++++++ .../Downloads/DownloadThumbnailsManager.swift | 4 +-- .../cThumbnailEnumerationCallback.swift | 17 ---------- .../ProtonDriveSDKDriveError.swift | 17 ---------- 19 files changed, 170 insertions(+), 141 deletions(-) rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/{EnumerateHandler.kt => YieldHandler.kt} (50%) create mode 100644 swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/cTimelineEnumerationCallback.swift diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index 8e08206b..e468012a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -64,7 +64,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint bindingsHandle).ConfigureAwait(false), Request.PayloadOneofCase.DriveClientEnumerateFolderChildren - => await InteropProtonDriveClient.HandleEnumerateFolderChildrenAsync(request.DriveClientEnumerateFolderChildren).ConfigureAwait(false), + => await InteropProtonDriveClient.HandleEnumerateFolderChildrenAsync( + request.DriveClientEnumerateFolderChildren, + bindingsHandle).ConfigureAwait(false), Request.PayloadOneofCase.DriveClientGetMyFilesFolder => await InteropProtonDriveClient.HandleGetMyFilesFolderAsync(request.DriveClientGetMyFilesFolder).ConfigureAwait(false), @@ -139,7 +141,7 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DrivePhotosClientEnumerateTimeline => await InteropProtonPhotosClient.HandleEnumeratePhotosTimelineAsync( - request.DrivePhotosClientEnumerateTimeline).ConfigureAwait(false), + request.DrivePhotosClientEnumerateTimeline, bindingsHandle).ConfigureAwait(false), Request.PayloadOneofCase.DrivePhotosClientGetNode => await InteropProtonPhotosClient.HandleGetNodeAsync(request.DrivePhotosClientGetNode).ConfigureAwait(false), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 94e59291..f6d83e42 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -166,7 +166,7 @@ public static async ValueTask HandleGetAvailableNameAsync(DriveClientG public static async ValueTask HandleEnumerateThumbnailsAsync(DriveClientEnumerateThumbnailsRequest request, nint bindingsHandle) { - var iterateFunction = new InteropAction>(request.IterateAction); + var yieldFunction = new InteropAction>(request.YieldAction); var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); @@ -188,27 +188,25 @@ public static async ValueTask HandleGetAvailableNameAsync(DriveClientG thumbnail.Error = ConvertToDriveError(error); } - iterateFunction.InvokeWithMessage(bindingsHandle, thumbnail); + yieldFunction.InvokeWithMessage(bindingsHandle, thumbnail); } return null; } - public static async ValueTask HandleEnumerateFolderChildrenAsync(DriveClientEnumerateFolderChildrenRequest request) + public static async ValueTask HandleEnumerateFolderChildrenAsync(DriveClientEnumerateFolderChildrenRequest request, nint bindingsHandle) { + var yieldFunction = new InteropAction>(request.YieldAction); var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); - var childrenEnumerable = client.EnumerateFolderChildrenAsync( - NodeUid.Parse(request.FolderUid), - cancellationToken); - - var children = await childrenEnumerable - .Select(ConvertToNodeResult) - .ToListAsync(cancellationToken).ConfigureAwait(false); + await foreach (var x in client.EnumerateFolderChildrenAsync(NodeUid.Parse(request.FolderUid), cancellationToken).ConfigureAwait(false)) + { + yieldFunction.InvokeWithMessage(bindingsHandle, ConvertToNodeResult(x)); + } - return new FolderChildrenList { Children = { children } }; + return null; } public static async ValueTask HandleGetMyFilesFolderAsync(DriveClientGetMyFilesFolderRequest request) @@ -298,14 +296,14 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto public static async ValueTask HandleEnumerateTrashAsync(DriveClientEnumerateTrashRequest request, nint bindingsHandle) { - var iterateFunction = new InteropAction>(request.IterateAction); + var yieldFunction = new InteropAction>(request.YieldAction); var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); await foreach (var x in client.EnumerateTrashAsync(cancellationToken).ConfigureAwait(false)) { - iterateFunction.InvokeWithMessage(bindingsHandle, ConvertToNodeResult(x)); + yieldFunction.InvokeWithMessage(bindingsHandle, ConvertToNodeResult(x)); } return null; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index d36f538d..6b9e1ea7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -115,14 +115,14 @@ public static async ValueTask HandleRestoreNodesAsync(DrivePhotosClien public static async ValueTask HandleEnumerateTrashAsync(DrivePhotosClientEnumerateTrashRequest request, nint bindingsHandle) { - var iterateFunction = new InteropAction>(request.IterateAction); + var yieldFunction = new InteropAction>(request.YieldAction); var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); await foreach (var x in client.EnumerateTrashAsync(cancellationToken).ConfigureAwait(false)) { - iterateFunction.InvokeWithMessage(bindingsHandle, InteropProtonDriveClient.ConvertToNodeResult(x)); + yieldFunction.InvokeWithMessage(bindingsHandle, InteropProtonDriveClient.ConvertToNodeResult(x)); } return null; @@ -163,21 +163,22 @@ public static async ValueTask HandleRestoreNodesAsync(DrivePhotosClien return InteropProtonDriveClient.ConvertToNodeResult(nodeResult.Value); } - public static async ValueTask HandleEnumeratePhotosTimelineAsync(DrivePhotosClientEnumerateTimelineRequest request) + public static async ValueTask HandleEnumeratePhotosTimelineAsync(DrivePhotosClientEnumerateTimelineRequest request, nint bindingsHandle) { + var yieldFunction = new InteropAction>(request.YieldAction); var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); - var timelineEnumerable = client.EnumerateTimelineAsync(cancellationToken); - var items = await timelineEnumerable - .Select(x => new PhotosTimelineItem + await foreach (var x in client.EnumerateTimelineAsync(cancellationToken).ConfigureAwait(false)) + { + yieldFunction.InvokeWithMessage(bindingsHandle, new PhotosTimelineItem { NodeUid = x.Uid.ToString(), CaptureTime = x.CaptureTime.ToUniversalTime().ToTimestamp(), - }) - .ToListAsync(cancellationToken).ConfigureAwait(false); + }); + } - return new PhotosTimelineList { Items = { items } }; + return null; } public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhotosClientGetPhotoDownloaderRequest request) @@ -195,7 +196,7 @@ public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhot public static async ValueTask HandleEnumerateThumbnailsAsync(DrivePhotosClientEnumerateThumbnailsRequest request, nint bindingsHandle) { - var iterateFunction = new InteropAction>(request.IterateAction); + var yieldFunction = new InteropAction>(request.YieldAction); var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); @@ -217,7 +218,7 @@ public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhot thumbnail.Error = InteropProtonDriveClient.ConvertToDriveError(error); } - iterateFunction.InvokeWithMessage(bindingsHandle, thumbnail); + yieldFunction.InvokeWithMessage(bindingsHandle, thumbnail); } return null; diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index b680f352..36834b94 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -408,11 +408,11 @@ message DriveClientRestoreNodesRequest { int64 cancellation_token_source_handle = 3; } -// The response must not have a value, iterate_action will be called for each item. +// The response must not have a value, yield_action will be called for each item. message DriveClientEnumerateTrashRequest { int64 client_handle = 1; - int64 cancellation_token_source_handle = 2; - int64 iterate_action = 3; + int64 yield_action = 2; + int64 cancellation_token_source_handle = 3; } message TrashChildrenList { @@ -425,20 +425,21 @@ message DriveClientEmptyTrashRequest { int64 cancellation_token_source_handle = 2; } -// The response must not have a value, iterate_action will be call for each item. +// The response must not have a value, yield_action will be call for each item. message DriveClientEnumerateThumbnailsRequest { int64 client_handle = 1; repeated string file_uids = 2; ThumbnailType type = 3; - int64 cancellation_token_source_handle = 4; - int64 iterate_action = 5; + int64 yield_action = 4; + int64 cancellation_token_source_handle = 5; } -// The response message must be of type FolderChildrenList. +// The response must not have a value, yield_action will be called for each item. message DriveClientEnumerateFolderChildrenRequest { int64 client_handle = 1; string folder_uid = 2; - int64 cancellation_token_source_handle = 3; + int64 yield_action = 3; + int64 cancellation_token_source_handle = 4; } // The response message must be of type FolderNode. @@ -640,19 +641,20 @@ message DrivePhotosClientFreeRequest { int64 client_handle = 1; } -// The response must not have a value, iterate_action will be call for each item. +// The response must not have a value, yield_action will be call for each item. message DrivePhotosClientEnumerateThumbnailsRequest { int64 client_handle = 1; repeated string photo_uids = 2; ThumbnailType type = 3; - int64 cancellation_token_source_handle = 4; - int64 iterate_action = 5; + int64 yield_action = 4; + int64 cancellation_token_source_handle = 5; } -// The response message must be of type PhotosTimelineList. +// The response must not have a value, yield_action will be called for each item. message DrivePhotosClientEnumerateTimelineRequest { int64 client_handle = 1; - int64 cancellation_token_source_handle = 2; + int64 yield_action = 2; + int64 cancellation_token_source_handle = 3; } // The response message must be of type NodeResult (nullable). @@ -794,8 +796,8 @@ message DrivePhotosClientRestoreNodesRequest { // The response message must be of type TrashChildrenList message DrivePhotosClientEnumerateTrashRequest { int64 client_handle = 1; + int64 yield_action = 3; int64 cancellation_token_source_handle = 2; - int64 iterate_action = 3; } // The response must not have a value. diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 5e75111e..5aaf6af4 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -71,8 +71,8 @@ void onSeek( pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onSeek"); } -void onEnumerate(intptr_t bindings_handle, ByteArray value) { - pushDataToVoidMethod(bindings_handle, value, "onEnumerate"); +void onYield(intptr_t bindings_handle, ByteArray value) { + pushDataToVoidMethod(bindings_handle, value, "onYield"); } void onProgress(intptr_t bindings_handle, ByteArray value) { @@ -144,11 +144,11 @@ jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSeekPointe return (jlong) (intptr_t) &onSeek; } -jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getEnumeratePointer( +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getYieldPointer( JNIEnv *env, jclass clazz ) { - return (jlong) (intptr_t) &onEnumerate; + return (jlong) (intptr_t) &onYield; } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getProgressPointer( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index b7081e10..aa3653ec 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk +import kotlinx.coroutines.flow.Flow import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.FolderNode @@ -14,7 +15,7 @@ interface ProtonDriveClient : ProtonSdkClient { suspend fun rename(nodeUid: NodeUid, name: String, mediaType: String? = null) suspend fun createFolder(parentFolderUid: NodeUid, name: String, lastModification: Instant? = null): FolderNode suspend fun getMyFilesFolder(): FolderNode - suspend fun enumerateFolderChildren(folderUid: NodeUid): List + fun enumerateFolderChildren(folderUid: NodeUid): Flow suspend fun downloader(revisionUid: RevisionUid, timeout: Duration): Downloader suspend fun uploader(request: FileUploaderRequest, timeout: Duration): Uploader suspend fun uploader(request: FileRevisionUploaderRequest, timeout: Duration): Uploader diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 9600ba3c..2c40e788 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk +import kotlinx.coroutines.flow.Flow import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.PhotosTimelineItem @@ -7,7 +8,7 @@ import me.proton.drive.sdk.entity.PhotosUploaderRequest import kotlin.time.Duration interface ProtonPhotosClient : ProtonSdkClient { - suspend fun enumerateTimeline(): List + fun enumerateTimeline(): Flow suspend fun getNode(nodeUid: NodeUid): NodeResult? suspend fun downloader(photoUid: NodeUid, timeout: Duration): Downloader suspend fun uploader(request: PhotosUploaderRequest, timeout: Duration): Uploader diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt index 7400e626..9aa3aef1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt @@ -73,9 +73,9 @@ internal class InteropProtonDriveClient internal constructor( this.type = type.toProto() clientHandle = handle cancellationTokenSourceHandle = source.handle - iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() }, - enumerate = { fileThumbnail -> + yield = { fileThumbnail -> send(fileThumbnail.toEntity()) } ) @@ -130,17 +130,24 @@ internal class InteropProtonDriveClient internal constructor( ).toEntity() } - override suspend fun enumerateFolderChildren( + override fun enumerateFolderChildren( folderUid: NodeUid, - ): List = cancellationCoroutineScope { source -> + ): Flow = channelFlow { log(DEBUG, "enumerateFolderChildren") - bridge.enumerateFolderChildren( - driveClientEnumerateFolderChildrenRequest { - this.folderUid = folderUid.value - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() + cancellationCoroutineScope { source -> + bridge.enumerateFolderChildren( + coroutineScope = this@channelFlow, + request = driveClientEnumerateFolderChildrenRequest { + this.folderUid = folderUid.value + clientHandle = handle + cancellationTokenSourceHandle = source.handle + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() + }, + yield = { nodeResult -> + send(nodeResult.toEntity()) + } + ) + } } override suspend fun trashNodes( @@ -190,9 +197,9 @@ internal class InteropProtonDriveClient internal constructor( request = driveClientEnumerateTrashRequest { clientHandle = handle cancellationTokenSourceHandle = source.handle - iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() }, - enumerate = { nodeResult -> + yield = { nodeResult -> send(nodeResult.toEntity()) } ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt index 8c2b3cc0..d02aac2d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt @@ -51,23 +51,30 @@ internal class InteropProtonPhotosClient internal constructor( this.type = type.toProto() clientHandle = handle cancellationTokenSourceHandle = source.handle - iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() }, - enumerate = { fileThumbnail -> + yield = { fileThumbnail -> send(fileThumbnail.toEntity()) } ) } } - override suspend fun enumerateTimeline(): List = cancellationCoroutineScope { source -> + override fun enumerateTimeline(): Flow = channelFlow { log(DEBUG, "enumerateTimeline") - bridge.enumerateTimeline( - drivePhotosClientEnumerateTimelineRequest { - clientHandle = handle - cancellationTokenSourceHandle = source.handle - } - ).toEntity() + cancellationCoroutineScope { source -> + bridge.enumerateTimeline( + coroutineScope = this@channelFlow, + request = drivePhotosClientEnumerateTimelineRequest { + clientHandle = handle + cancellationTokenSourceHandle = source.handle + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() + }, + yield = { timelineItem -> + send(timelineItem.toEntity()) + } + ) + } } override suspend fun getNode(nodeUid: NodeUid): NodeResult? = cancellationCoroutineScope { source -> @@ -128,9 +135,9 @@ internal class InteropProtonPhotosClient internal constructor( drivePhotosClientEnumerateTrashRequest { clientHandle = handle cancellationTokenSourceHandle = source.handle - iterateAction = ProtonDriveSdkNativeClient.getEnumeratePointer() + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() }, - enumerate = { nodeResult -> + yield = { nodeResult -> send(nodeResult.toEntity()) } ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt index 11eb4f56..ffd63b07 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -54,7 +54,7 @@ abstract class JniBaseProtonDriveSdk : JniBase() { suspend fun executeEnumerate( name: String, callback: (CancellableContinuation) -> ResponseCallback, - enumerate: suspend (E) -> Unit, + yield: suspend (E) -> Unit, parser: (ByteBuffer) -> E, coroutineScopeProvider: CoroutineScopeProvider, block: RequestKt.Dsl.() -> Unit, @@ -69,7 +69,7 @@ abstract class JniBaseProtonDriveSdk : JniBase() { clients -= client responseCallback(buffer) }, - enumerateHandler = EnumerateHandler.create(enumerate, parser) , + yieldHandler = YieldHandler.create(yield, parser) , logger = internalLogger, coroutineScopeProvider = coroutineScopeProvider, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index 26129701..75a1f917 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -4,7 +4,6 @@ import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.ProducerScope import me.proton.drive.sdk.converter.NodeResultListResponseConverter -import me.proton.drive.sdk.converter.FolderChildrenListConverter import me.proton.drive.sdk.converter.FolderNodeConverter import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.entity.NodeResult @@ -95,11 +94,11 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun enumerateThumbnails( coroutineScope: CoroutineScope, request: ProtonDriveSdk.DriveClientEnumerateThumbnailsRequest, - enumerate: suspend (ProtonDriveSdk.FileThumbnail) -> Unit, + yield: suspend (ProtonDriveSdk.FileThumbnail) -> Unit, ): Unit = executeEnumerate( name = "enumerateThumbnails", callback = UnitResponseCallback, - enumerate = enumerate, + yield = yield, parser = ProtonDriveSdk.FileThumbnail::parseFrom, coroutineScopeProvider = { coroutineScope }, ) { @@ -119,11 +118,18 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { } suspend fun enumerateFolderChildren( + coroutineScope: CoroutineScope, request: ProtonDriveSdk.DriveClientEnumerateFolderChildrenRequest, - ): ProtonDriveSdk.FolderChildrenList = - executeOnce("enumerateFolderChildren", FolderChildrenListConverter().asCallback) { - driveClientEnumerateFolderChildren = request - } + yield: suspend (ProtonDriveSdk.NodeResult) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateFolderChildren", + callback = UnitResponseCallback, + yield = yield, + parser = ProtonDriveSdk.NodeResult::parseFrom, + coroutineScopeProvider = { coroutineScope }, + ) { + driveClientEnumerateFolderChildren = request + } suspend fun trashNodes( request: ProtonDriveSdk.DriveClientTrashNodesRequest, @@ -149,11 +155,11 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun enumerateTrash( coroutineScope: ProducerScope, request: ProtonDriveSdk.DriveClientEnumerateTrashRequest, - enumerate: suspend (ProtonDriveSdk.NodeResult) -> Unit, + yield: suspend (ProtonDriveSdk.NodeResult) -> Unit, ): Unit = executeEnumerate( name = "enumerateTrash", callback = UnitResponseCallback, - enumerate = enumerate, + yield = yield, parser = ProtonDriveSdk.NodeResult::parseFrom, coroutineScopeProvider = { coroutineScope } ) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index 2cd45636..bf1df5ca 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.ProducerScope import me.proton.drive.sdk.converter.NodeResultConverter import me.proton.drive.sdk.converter.NodeResultListResponseConverter -import me.proton.drive.sdk.converter.PhotosTimelineListConverter import me.proton.drive.sdk.entity.ClientCreateRequest import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.extension.LongResponseCallback @@ -82,11 +81,11 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun enumerateThumbnails( coroutineScope: CoroutineScope, request: ProtonDriveSdk.DrivePhotosClientEnumerateThumbnailsRequest, - enumerate: suspend (ProtonDriveSdk.FileThumbnail) -> Unit, + yield: suspend (ProtonDriveSdk.FileThumbnail) -> Unit, ): Unit = executeEnumerate( name = "enumerateThumbnails", callback = UnitResponseCallback, - enumerate = enumerate, + yield = yield, parser = ProtonDriveSdk.FileThumbnail::parseFrom, coroutineScopeProvider = { coroutineScope }, ) { @@ -94,11 +93,18 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { } suspend fun enumerateTimeline( + coroutineScope: CoroutineScope, request: ProtonDriveSdk.DrivePhotosClientEnumerateTimelineRequest, - ): ProtonDriveSdk.PhotosTimelineList = - executeOnce("enumerateTimeline", PhotosTimelineListConverter().asCallback) { - drivePhotosClientEnumerateTimeline = request - } + yield: suspend (ProtonDriveSdk.PhotosTimelineItem) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateTimeline", + callback = UnitResponseCallback, + yield = yield, + parser = ProtonDriveSdk.PhotosTimelineItem::parseFrom, + coroutineScopeProvider = { coroutineScope }, + ) { + drivePhotosClientEnumerateTimeline = request + } suspend fun getNode( request: ProtonDriveSdk.DrivePhotosClientGetNodeRequest, @@ -131,11 +137,11 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun enumerateTrash( coroutineScope: ProducerScope, request: ProtonDriveSdk.DrivePhotosClientEnumerateTrashRequest, - enumerate: suspend (ProtonDriveSdk.NodeResult) -> Unit, + yield: suspend (ProtonDriveSdk.NodeResult) -> Unit, ): Unit = executeEnumerate( name = "enumerateTrash", callback = UnitResponseCallback, - enumerate = enumerate, + yield = yield, parser = ProtonDriveSdk.NodeResult::parseFrom, coroutineScopeProvider = { coroutineScope } ) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 21a2ca8b..ecdf470f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -31,7 +31,7 @@ import java.util.concurrent.atomic.AtomicBoolean class ProtonDriveSdkNativeClient internal constructor( val name: String, val response: ClientResponseCallback> = { _, _ -> error("response not configured for $name") }, - val enumerateHandler: EnumerateHandler = EnumerateHandler.notConfigured(name), + val yieldHandler: YieldHandler = YieldHandler.notConfigured(name), val read: suspend (ByteBuffer) -> Int = { error("read not configured for $name") }, val write: suspend (ByteBuffer) -> Unit = { error("write not configured for $name") }, val seek: (suspend (Long, Int) -> Long)? = null, @@ -101,11 +101,11 @@ class ProtonDriveSdkNativeClient internal constructor( } @Suppress("unused") // Called by JNI - fun onEnumerate(data: ByteBuffer) = onCallback( - callback = "enumerate", + fun onYield(data: ByteBuffer) = onCallback( + callback = "yield", data = data, - parser = enumerateHandler.parser, - block = enumerateHandler.callback, + parser = yieldHandler.parser, + block = yieldHandler.callback, ) @Suppress("unused") // Called by JNI @@ -410,7 +410,7 @@ class ProtonDriveSdkNativeClient internal constructor( external fun getSeekPointer(): Long @JvmStatic - external fun getEnumeratePointer(): Long + external fun getYieldPointer(): Long @JvmStatic external fun getProgressPointer(): Long diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/EnumerateHandler.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/YieldHandler.kt similarity index 50% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/EnumerateHandler.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/YieldHandler.kt index 864ea2e6..b0b10dbf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/EnumerateHandler.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/YieldHandler.kt @@ -2,22 +2,22 @@ package me.proton.drive.sdk.internal import java.nio.ByteBuffer -interface EnumerateHandler { +interface YieldHandler { val callback: suspend (T) -> Unit val parser: (ByteBuffer) -> T companion object { - fun notConfigured(name: String) = object: EnumerateHandler { + fun notConfigured(name: String) = object: YieldHandler { override val callback: suspend (T) -> Unit - get() = error("EnumerateHandler not configured for $name") + get() = error("YieldHandler not configured for $name") override val parser: (ByteBuffer) -> T - get() = error("EnumerateHandler not configured for $name") + get() = error("YieldHandler not configured for $name") } fun create( - enumerate: suspend (T) -> Unit, + callback: suspend (T) -> Unit, parser: (ByteBuffer) -> T - ) = object : EnumerateHandler { - override val callback = enumerate + ) = object : YieldHandler { + override val callback = callback override val parser = parser } } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift index 3d5c7896..8eccf1b6 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -123,14 +123,23 @@ extension ProtonPhotosClient { } let cancellationHandle = cancellationTokenSource.handle + let accumulator = TimelineItemAccumulator() let request = Proton_Drive_Sdk_DrivePhotosClientEnumerateTimelineRequest.with { $0.clientHandle = Int64(clientHandle) $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + $0.yieldAction = Int64(ObjectHandle(callback: cTimelineEnumerationCallback)) } - let list: Proton_Drive_Sdk_PhotosTimelineList = try await SDKRequestHandler.send(request, logger: logger) - return list.items.compactMap { PhotoTimelineItem(item: $0) } + let _: Void = try await SDKRequestHandler.send( + request, + state: WeakReference(value: accumulator), + scope: .ownerManaged, + owner: accumulator, + logger: logger + ) + + return accumulator.items } } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/cTimelineEnumerationCallback.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/cTimelineEnumerationCallback.swift new file mode 100644 index 00000000..fabc2e2f --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/cTimelineEnumerationCallback.swift @@ -0,0 +1,23 @@ +import Foundation + +final class TimelineItemAccumulator: Sendable { + nonisolated(unsafe) var items: [PhotoTimelineItem] = [] + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: self) + } +} + +let cTimelineEnumerationCallback: CCallback = { statePointer, byteArray in + typealias BoxType = BoxedCompletionBlock> + let protoItem = Proton_Drive_Sdk_PhotosTimelineItem(byteArray: byteArray) + guard let item = PhotoTimelineItem(item: protoItem) else { return } + + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + assertionFailure("cTimelineEnumerationCallback.statePointer is nil") + return + } + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + let weakAccumulator = stateTypedPointer.takeUnretainedValue().state + weakAccumulator.value?.items.append(item) +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift index d0bc10df..367242fb 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift @@ -40,7 +40,7 @@ actor DownloadThumbnailsManager { $0.fileUids = fileUids.map(\.sdkCompatibleIdentifier) $0.type = type.sdkType $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) - $0.iterateAction = Int64(ObjectHandle(callback: cThumbnailEnumerationCallback)) + $0.yieldAction = Int64(ObjectHandle(callback: cThumbnailEnumerationCallback)) } let callbackState = ThumbnailEnumerationCallbackWrapper(callback: onThumbnailDownloaded) @@ -75,7 +75,7 @@ actor DownloadThumbnailsManager { $0.photoUids = photoUids.map(\.sdkCompatibleIdentifier) $0.type = type.sdkType $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) - $0.iterateAction = Int64(ObjectHandle(callback: cThumbnailEnumerationCallback)) + $0.yieldAction = Int64(ObjectHandle(callback: cThumbnailEnumerationCallback)) } let callbackState = ThumbnailEnumerationCallbackWrapper(callback: onThumbnailDownloaded) diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift index 785a0a2c..5f1da986 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift @@ -1,20 +1,3 @@ -// Copyright (c) 2026 Proton AG -// -// This file is part of Proton Drive. -// -// Proton Drive is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Proton Drive is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Proton Drive. If not, see https://www.gnu.org/licenses/. - import Foundation final class ThumbnailEnumerationCallbackWrapper: Sendable { diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift index f716f238..488c3458 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift @@ -1,20 +1,3 @@ -// Copyright (c) 2026 Proton AG -// -// This file is part of Proton Drive. -// -// Proton Drive is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Proton Drive is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Proton Drive. If not, see https://www.gnu.org/licenses/. - import Foundation public final class ProtonDriveSDKDriveError: Error, LocalizedError { From a551208b125a7ca2d0582a016a2ea7c0bd4573be Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 1 Apr 2026 13:55:30 +0000 Subject: [PATCH 665/791] Fix illegal assignments of null values to Protobuf fields for authorship results --- .../InteropProtonDriveClient.cs | 71 ++++++++++++++----- .../drive/sdk/entity/DegradedFileNode.kt | 2 +- .../me/proton/drive/sdk/entity/DriveError.kt | 2 +- .../me/proton/drive/sdk/entity/FileNode.kt | 2 +- .../drive/sdk/extension/DegradedFileNode.kt | 2 +- .../drive/sdk/extension/DegradedFolderNode.kt | 2 +- .../proton/drive/sdk/extension/DriveError.kt | 2 +- .../me/proton/drive/sdk/extension/FileNode.kt | 2 +- .../proton/drive/sdk/extension/FolderNode.kt | 2 +- 9 files changed, 63 insertions(+), 24 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index f6d83e42..eb3ee3a9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -216,10 +216,9 @@ public static async ValueTask HandleGetMyFilesFolderAsync(DriveClientG var folderNode = await client.GetMyFilesFolderAsync(cancellationToken).ConfigureAwait(false); - return new FolderNode + var folderNodeProto = new FolderNode { Uid = folderNode.Uid.ToString(), - ParentUid = folderNode.ParentUid?.ToString() ?? string.Empty, TreeEventScopeId = folderNode.TreeEventScopeId, Name = folderNode.Name, CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), @@ -228,6 +227,13 @@ public static async ValueTask HandleGetMyFilesFolderAsync(DriveClientG Author = ParseAuthorResult(folderNode.Author), OwnedBy = MapOwnedByToProto(folderNode.OwnedBy), }; + + if (folderNode.ParentUid != null) + { + folderNodeProto.ParentUid = folderNode.ParentUid.ToString(); + } + + return folderNodeProto; } public static async ValueTask HandleGetFileDownloaderAsync(DriveClientGetFileDownloaderRequest request) @@ -349,21 +355,31 @@ public static AuthorResult ParseAuthorResult(Result> results) @@ -433,7 +455,6 @@ private static Node ConvertToNode(Nodes.Node node) result.Folder = new FolderNode { Uid = folderNode.Uid.ToString(), - ParentUid = folderNode.ParentUid?.ToString() ?? string.Empty, TreeEventScopeId = folderNode.TreeEventScopeId, Name = folderNode.Name, CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), @@ -442,13 +463,18 @@ private static Node ConvertToNode(Nodes.Node node) Author = ParseAuthorResult(folderNode.Author), OwnedBy = MapOwnedByToProto(folderNode.OwnedBy), }; + + if (folderNode.ParentUid != null) + { + result.Folder.ParentUid = folderNode.ParentUid.ToString(); + } + break; case Nodes.FileNode fileNode: var fileNodeProto = new FileNode { Uid = fileNode.Uid.ToString(), - ParentUid = fileNode.ParentUid?.ToString() ?? string.Empty, TreeEventScopeId = fileNode.TreeEventScopeId, Name = fileNode.Name, MediaType = fileNode.MediaType, @@ -469,6 +495,11 @@ private static Node ConvertToNode(Nodes.Node node) }, }; + if (fileNode.ParentUid != null) + { + fileNodeProto.ParentUid = fileNode.ParentUid.ToString(); + } + if (fileNode.ActiveRevision.ClaimedDigests.Sha1.HasValue) { fileNodeProto.ActiveRevision.ClaimedDigests.Sha1 = ByteString.CopyFrom(fileNode.ActiveRevision.ClaimedDigests.Sha1.Value.Span); @@ -515,7 +546,6 @@ private static DegradedNode ConvertToDegradedNode(Nodes.DegradedNode degradedNod var degradedFolder = new DegradedFolderNode { Uid = degradedFolderNode.Uid.ToString(), - ParentUid = degradedFolderNode.ParentUid?.ToString() ?? string.Empty, TreeEventScopeId = degradedFolderNode.TreeEventScopeId, Name = ConvertStringToStringResult(degradedFolderNode.Name), CreationTime = degradedFolderNode.CreationTime.ToUniversalTime().ToTimestamp(), @@ -525,6 +555,11 @@ private static DegradedNode ConvertToDegradedNode(Nodes.DegradedNode degradedNod OwnedBy = MapOwnedByToProto(degradedFolderNode.OwnedBy), }; + if (degradedFolderNode.ParentUid != null) + { + degradedFolder.ParentUid = degradedFolderNode.ParentUid.ToString(); + } + degradedFolder.Errors.AddRange(degradedFolderNode.Errors.Select(ConvertToDriveError)); result.Folder = degradedFolder; break; @@ -533,7 +568,6 @@ private static DegradedNode ConvertToDegradedNode(Nodes.DegradedNode degradedNod var degradedFile = new DegradedFileNode { Uid = degradedFileNode.Uid.ToString(), - ParentUid = degradedFileNode.ParentUid?.ToString() ?? string.Empty, TreeEventScopeId = degradedFileNode.TreeEventScopeId, Name = ConvertStringToStringResult(degradedFileNode.Name), MediaType = degradedFileNode.MediaType, @@ -545,6 +579,11 @@ private static DegradedNode ConvertToDegradedNode(Nodes.DegradedNode degradedNod OwnedBy = MapOwnedByToProto(degradedFileNode.OwnedBy), }; + if (degradedFileNode.ParentUid != null) + { + degradedFile.ParentUid = degradedFileNode.ParentUid.ToString(); + } + if (degradedFileNode.ActiveRevision is not null) { degradedFile.ActiveRevision = new DegradedRevision diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt index 80e25cb8..7428461d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt @@ -4,7 +4,7 @@ import java.time.Instant data class DegradedFileNode( override val uid: NodeUid, - override val parentUid: ParentNodeUid, + override val parentUid: ParentNodeUid?, override val treeEventScopeId: ScopeId, override val name: Result, val mediaType: String, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt index 07a353d5..a4c815ce 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt @@ -1,6 +1,6 @@ package me.proton.drive.sdk.entity data class DriveError( - val message: String, + val message: String? = null, val innerError: DriveError? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt index 5c7c0af4..201ed319 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt @@ -4,7 +4,7 @@ import java.time.Instant data class FileNode( override val uid: NodeUid, - override val parentUid: ParentNodeUid, + override val parentUid: ParentNodeUid?, override val treeEventScopeId: ScopeId, override val name: String, val mediaType: String, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt index 8113db17..f6083c8c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt @@ -10,7 +10,7 @@ import proton.drive.sdk.trashTimeOrNull fun ProtonDriveSdk.DegradedFileNode.toEntity() = DegradedFileNode( uid = NodeUid(uid), - parentUid = ParentNodeUid(parentUid), + parentUid = parentUid.takeIf { hasParentUid() }?.let(::ParentNodeUid), treeEventScopeId = ScopeId(treeEventScopeId), name = name.toEntity(), mediaType = mediaType, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt index ff9fbff4..6e1662df 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt @@ -9,7 +9,7 @@ import proton.drive.sdk.trashTimeOrNull fun ProtonDriveSdk.DegradedFolderNode.toEntity() = DegradedFolderNode( uid = NodeUid(uid), - parentUid = ParentNodeUid(parentUid), + parentUid = parentUid.takeIf { hasParentUid() }?.let(::ParentNodeUid), treeEventScopeId = ScopeId(treeEventScopeId), name = name.toEntity(), creationTime = creationTime.toInstant(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt index 232f7d91..5b754d32 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt @@ -5,6 +5,6 @@ import me.proton.drive.sdk.entity.DriveError import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.DriveError.toEntity(): DriveError = DriveError( - message = message, + message = takeIf { hasMessage() }?.message, innerError = if (hasInnerError()) innerError.toEntity() else null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt index fc896829..4d6e25da 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt @@ -9,7 +9,7 @@ import proton.drive.sdk.trashTimeOrNull fun ProtonDriveSdk.FileNode.toEntity() = FileNode( uid = NodeUid(uid), - parentUid = ParentNodeUid(parentUid), + parentUid = parentUid.takeIf { hasParentUid() }?.let(::ParentNodeUid), treeEventScopeId = ScopeId(treeEventScopeId), name = name, mediaType = mediaType, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt index 403d058b..49f8687e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt @@ -9,7 +9,7 @@ import proton.drive.sdk.trashTimeOrNull fun ProtonDriveSdk.FolderNode.toEntity() = FolderNode( uid = NodeUid(uid), - parentUid = ParentNodeUid(parentUid), + parentUid = parentUid.takeIf { hasParentUid() }?.let(::ParentNodeUid), treeEventScopeId = ScopeId(treeEventScopeId), name = name, creationTime = creationTime.toInstant(), From 6923273893def9c673bea09885863815afc87148 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 10:20:20 +0000 Subject: [PATCH 666/791] Keep http request body in kotlin memory for retries --- .../drive/sdk/internal/ApiProviderBridge.kt | 43 +++----------- .../drive/sdk/internal/PreparedRequest.kt | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/PreparedRequest.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt index e7d40006..53141eb1 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -9,10 +9,7 @@ import me.proton.core.network.data.ProtonErrorException import me.proton.core.network.domain.ApiResult import me.proton.drive.sdk.HttpSdkApi import me.proton.drive.sdk.LoggerProvider.Level.DEBUG -import me.proton.drive.sdk.extension.read -import me.proton.drive.sdk.extension.readAsStream import okhttp3.ResponseBody -import proton.sdk.ProtonSdk import proton.sdk.ProtonSdk.HttpRequest import proton.sdk.ProtonSdk.HttpResponse import proton.sdk.httpHeader @@ -31,11 +28,12 @@ internal class ApiProviderBridge( override suspend fun invoke(request: HttpRequest): HttpResponse { val httpStream = createHttpStream() - val apiResult = RetryAfterDelay(isEnabled = request.isRetryEnabled) { + val preparedRequest = request.prepare(httpStream) + val apiResult = RetryAfterDelay(isEnabled = preparedRequest.isRetryEnabled) { apiProvider.get(userId).invoke( forceNoRetryOnConnectionErrors = true ) { - execute(request, httpStream) + execute(preparedRequest) } } if (apiResult is ApiResult.Error) { @@ -81,15 +79,6 @@ internal class ApiProviderBridge( } } - private val HttpRequest.isUploadBlock: Boolean get() = - type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_STORAGE_UPLOAD - - private val HttpRequest.isDownloadBlock: Boolean get() = - type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_STORAGE_DOWNLOAD - - private val HttpRequest.isRetryEnabled get() = - type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_REGULAR_API - private suspend fun createHttpStream(): HttpStream { val jniHttpStream = JniHttpStream() val httpStream = HttpStream( @@ -108,29 +97,11 @@ internal class ApiProviderBridge( } private suspend fun HttpSdkApi.execute( - request: HttpRequest, - httpStream: HttpStream, - ): Response { - val method = request.method - val url = request.url - val headers = request.headersList.associate { header -> - header.name to header.valuesList.joinToString(",") - } - val streamingRequest = request.isUploadBlock - val body = if (streamingRequest) { - httpStream.readAsStream(request) - } else { - httpStream.read(request) - } - - val bodyMessage = when { - !request.hasSdkContentHandle() -> "no" - streamingRequest -> "streaming" - else -> "${body.contentLength()}-byte" - } + request: PreparedRequest, + ): Response = with(request) { logger("--> $method $url ($bodyMessage body)") - return when (method.uppercase()) { - "GET" -> if (request.isDownloadBlock) { + when (method.uppercase()) { + "GET" -> if (isDownloadBlock) { getStreaming(url, headers) } else { get(url, headers) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/PreparedRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/PreparedRequest.kt new file mode 100644 index 00000000..e58cf3a3 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/PreparedRequest.kt @@ -0,0 +1,56 @@ +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.extension.read +import me.proton.drive.sdk.extension.readAsStream +import okhttp3.RequestBody +import proton.sdk.ProtonSdk +import proton.sdk.ProtonSdk.HttpRequest + +internal data class PreparedRequest( + val request: HttpRequest, + val method: String, + val url: String, + val headers: Map, + val body: RequestBody, + val bodyMessage: String, +) { + val isUploadBlock: Boolean get() = request.isUploadBlock + val isDownloadBlock: Boolean get() = request.isDownloadBlock + val isRetryEnabled: Boolean get() = request.isRetryEnabled +} + +internal suspend fun HttpRequest.prepare(httpStream: HttpStream): PreparedRequest { + val streamingRequest = isUploadBlock + val body = if (streamingRequest) { + httpStream.readAsStream(this) + } else { + httpStream.read(this) + } + val bodyMessage = when { + !hasSdkContentHandle() -> "no" + streamingRequest -> "streaming" + else -> "${body.contentLength()}-byte" + } + return PreparedRequest( + request = this, + method = method, + url = url, + headers = headersList.associate { header -> + header.name to header.valuesList.joinToString(",") + }, + body = body, + bodyMessage = bodyMessage, + ) +} + +private val HttpRequest.isUploadBlock: Boolean + get() = + type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_STORAGE_UPLOAD + +private val HttpRequest.isDownloadBlock: Boolean + get() = + type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_STORAGE_DOWNLOAD + +private val HttpRequest.isRetryEnabled + get() = + type == ProtonSdk.HttpRequestType.HTTP_REQUEST_TYPE_REGULAR_API From 96213f8104d195231b4587440192e0abe1e514fe Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 14:38:16 +0200 Subject: [PATCH 667/791] Get public link of share only for my own nodes --- .../sharing/sharingManagement.test.ts | 35 ++++++++++++++++--- .../src/internal/sharing/sharingManagement.ts | 23 ++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 790d698d..79a91a9d 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -106,6 +106,7 @@ describe('SharingManagement', () => { creatorEmail: 'address@example.com', passphraseSessionKey: 'sharePassphraseSessionKey', }), + getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), }; // @ts-expect-error No need to implement all methods for mocking nodesService = { @@ -146,7 +147,7 @@ describe('SharingManagement', () => { const invitation = { uid: 'invitaiton', addedByEmail: 'email' }; apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]); - const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); + const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid'); expect(sharingInfo).toEqual({ protonInvitations: [invitation], @@ -161,7 +162,7 @@ describe('SharingManagement', () => { const externalInvitation = { uid: 'external-invitation', addedByEmail: 'email' }; apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); - const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); + const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid'); expect(sharingInfo).toEqual({ protonInvitations: [], @@ -176,7 +177,7 @@ describe('SharingManagement', () => { const member = { uid: 'member', addedByEmail: 'email' }; apiService.getShareMembers = jest.fn().mockResolvedValue([member]); - const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); + const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid'); expect(sharingInfo).toEqual({ protonInvitations: [], @@ -193,7 +194,7 @@ describe('SharingManagement', () => { }; apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); - const sharingInfo = await sharingManagement.getSharingInfo('nodeUid'); + const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid'); expect(sharingInfo).toEqual({ protonInvitations: [], @@ -203,6 +204,19 @@ describe('SharingManagement', () => { }); expect(cryptoService.decryptPublicLink).toHaveBeenCalledWith(publicLink); }); + + it('should NOT return public link when volume ID does not match', async () => { + apiService.getPublicLink = jest.fn().mockResolvedValue(null); + const sharingInfo = await sharingManagement.getSharingInfo('zolumeId~nodeUid'); + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [], + members: [], + publicLink: undefined, + }); + expect(apiService.getPublicLink).not.toHaveBeenCalled(); + expect(cryptoService.decryptPublicLink).not.toHaveBeenCalled(); + }); }); describe('shareNode with share creation', () => { @@ -889,6 +903,19 @@ describe('SharingManagement', () => { expect(apiService.createStandardShare).not.toHaveBeenCalled(); expect(apiService.createPublicLink).not.toHaveBeenCalled(); }); + + it('should not allow creating public link for volume not owned by user', async () => { + sharesService.getRootIDs = jest.fn().mockResolvedValue({ volumeId: 'differentVolumeId' }); + await expect( + sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Viewer, + }, + }), + ).rejects.toThrow('Cannot create public link for volume not owned by the user'); + + expect(apiService.createPublicLink).not.toHaveBeenCalled(); + }); }); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 85666be9..b6f0f2cf 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -77,11 +77,13 @@ export class SharingManagement { return; } + const { volumeId } = splitNodeUid(nodeUid); + const [protonInvitations, nonProtonInvitations, members, publicLink, share] = await Promise.all([ Array.fromAsync(this.iterateShareInvitations(node.shareId)), Array.fromAsync(this.iterateShareExternalInvitations(node.shareId)), Array.fromAsync(this.iterateShareMembers(node.shareId)), - this.getPublicLink(node.shareId), + this.getPublicLink(node.shareId, volumeId), this.sharesService.loadEncryptedShare(node.shareId), ]); @@ -115,11 +117,18 @@ export class SharingManagement { } } - private async getPublicLink(shareId: string): Promise { + private async getPublicLink(shareId: string, volumeId: string): Promise { + const rootIds = await this.sharesService.getRootIDs(); + // Public links are encrypted by address key, thus it can work only for the owner for now. + if (volumeId !== rootIds.volumeId) { + return; + } + const encryptedPublicLink = await this.apiService.getPublicLink(shareId); if (!encryptedPublicLink) { return; } + return this.cryptoService.decryptPublicLink(encryptedPublicLink); } @@ -604,6 +613,11 @@ export class SharingManagement { share: Share, options: SharePublicLinkSettingsObject, ): Promise { + const rootIds = await this.sharesService.getRootIDs(); + if (share.volumeId !== rootIds.volumeId) { + throw new ValidationError(c('Error').t`Cannot create public link for volume not owned by the user`); + } + const generatedPassword = await this.cryptoService.generatePublicLinkPassword(); const password = options.customPassword ? `${generatedPassword}${options.customPassword}` : generatedPassword; @@ -638,6 +652,11 @@ export class SharingManagement { publicLink: PublicLinkWithCreatorEmail, options: SharePublicLinkSettingsObject, ): Promise { + const rootIds = await this.sharesService.getRootIDs(); + if (share.volumeId !== rootIds.volumeId) { + throw new ValidationError(c('Error').t`Cannot update public link for volume not owned by the user`); + } + const generatedPassword = publicLink.url.split('#')[1]; // Legacy public links didn't have generated password or had various lengths. if (!generatedPassword || generatedPassword.length !== PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) { From 8f1ce60e6ca9ed6dc5a35cbeaf7a4fda7225db2d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 10:43:07 +0000 Subject: [PATCH 668/791] Update changelog for cs/v0.13.0 --- cs/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 7ac60c5e..1a3c650a 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## cs/v0.13.0 (2026-04-02) + +* Keep http request body in kotlin memory for retries +* Fix illegal assignments of null values to Protobuf fields for authorship results +* Enable streaming of results when enumerating folder children and Photos timeline +* Fix function to get node from Photos client not using Photos API +* Log network body for tests by chunk +* Extract clients interfaces +* Add trash management to Photos + ## cs/v0.12.0 (2026-03-30) * Remove get thumbnails in favor of enumerate thumbnails From 95c0522342ab633d0f917e8d5a229fd370302cda Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 12:40:43 +0000 Subject: [PATCH 669/791] Update changelog for js/v0.14.4 --- js/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 1473e739..9fc1d1c9 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## js/v0.14.4 (2026-04-02) + +* Get public link of share only for my own nodes + ## js/v0.14.3 (2026-03-31) * Remove casting for parentNodeUid From faeeb8578af7cc3a7296ff4422babf74b896fa1b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 18:03:19 +0200 Subject: [PATCH 670/791] Fix feature flag parsing in kotlin --- .../drive/sdk/internal/ProtonDriveSdkNativeClient.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index ecdf470f..5e621ed8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -3,7 +3,6 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import com.google.protobuf.Int32Value import com.google.protobuf.Int64Value -import com.google.protobuf.StringValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,6 +18,7 @@ import me.proton.drive.sdk.LoggerProvider.Level.ERROR import me.proton.drive.sdk.LoggerProvider.Level.VERBOSE import me.proton.drive.sdk.LoggerProvider.Level.WARN import me.proton.drive.sdk.extension.asAny +import me.proton.drive.sdk.extension.decodeToString import me.proton.drive.sdk.extension.toProtonSdkError import proton.drive.sdk.ProtonDriveSdk import proton.sdk.ProtonSdk @@ -208,9 +208,8 @@ class ProtonDriveSdkNativeClient internal constructor( fun onFeatureEnabled(data: ByteBuffer): Long = onFunction( operation = "featureEnabled", data = data, - parser = StringValue::parseFrom, - ) { value -> - val name = value.value + parser = { buffer -> buffer.decodeToString() }, + ) { name -> runCatching { if (featureEnabled(name)) 1L else 0L }.getOrElse { error -> From d5e23ea01da2fe5d2b515ea4cb3609816ea596e4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 3 Apr 2026 10:50:54 +0200 Subject: [PATCH 671/791] Update logs from kotlin resume api --- .../kotlin/me/proton/drive/sdk/CommonDownloadController.kt | 3 ++- .../main/kotlin/me/proton/drive/sdk/CommonUploadController.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt index d412e6c5..07077b92 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -59,11 +59,12 @@ class CommonDownloadController internal constructor( } override suspend fun tryResume(coroutineScope: CoroutineScope): Boolean { - log(INFO, "resume") + log(DEBUG, "tryResume") coroutineScopeConsumer(coroutineScope) if (!isPaused()) { return false } + log(INFO, "resume") bridge.resume(handle).also { isPaused() } return true } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt index 1a198c24..66154d73 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt @@ -60,11 +60,12 @@ class CommonUploadController internal constructor( } override suspend fun tryResume(coroutineScope: CoroutineScope): Boolean { - log(INFO, "resume") + log(DEBUG, "tryResume") coroutineScopeConsumer(coroutineScope) if (!isPaused()) { return false } + log(INFO, "resume") bridge.resume(handle).also { isPaused() } return true } From 90244f1f925df7338e1eecaf87ce24501a9f0474 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 7 Apr 2026 06:57:38 +0200 Subject: [PATCH 672/791] Change move function to support returning validation error --- .../internal/nodes/nodesManagement.test.ts | 40 ++++++++++++++++++- js/sdk/src/internal/nodes/nodesManagement.ts | 18 +++++++-- js/sdk/src/protonDriveClient.ts | 3 +- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index f997ee51..e4b50203 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -4,9 +4,9 @@ import { NodesCryptoService } from './cryptoService'; import { NodesAccess } from './nodesAccess'; import { DecryptedNode } from './interface'; import { NodesManagement } from './nodesManagement'; -import { NodeResult } from '../../interface'; +import { NodeResult, NodeResultWithError } from '../../interface'; import { NodeOutOfSyncError } from './errors'; -import { ValidationError } from '../../errors'; +import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; describe('NodesManagement', () => { let apiService: NodeAPIService; @@ -263,6 +263,42 @@ describe('NodesManagement', () => { ); }); + it('moveNodes yields NodeWithSameNameExistsValidationError in case of duplicate node name', async () => { + const encryptedCrypto = { + encryptedName: 'movedArmoredNodeName', + hash: 'movedHash', + armoredNodePassphrase: 'movedArmoredNodePassphrase', + armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature', + signatureEmail: 'movedSignatureEmail', + nameSignatureEmail: 'movedNameSignatureEmail', + }; + cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto); + const error = new NodeWithSameNameExistsValidationError('Node with same name exists', 2500, 'existingNodeUid'); + apiService.moveNode = jest.fn().mockRejectedValue(error); + + const results: NodeResultWithError[] = []; + for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) { + results.push(result); + } + + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ uid: 'nodeUid', ok: false, error }); + expect(results[0].ok === false && results[0].error).toBeInstanceOf(NodeWithSameNameExistsValidationError); + }); + + it('moveNodes yields NodeResultWithError with Error on failure', async () => { + const error = new Error('move failed'); + cryptoService.encryptNodeWithNewParent = jest.fn().mockRejectedValue(error); + + const results: NodeResultWithError[] = []; + for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) { + results.push(result); + } + + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ uid: 'nodeUid', ok: false, error }); + }); + it('copyNode manages copy and updates cache', async () => { const encryptedCrypto = { encryptedName: 'copiedArmoredNodeName', diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index 8143aa91..b617f417 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -1,6 +1,14 @@ import { c } from 'ttag'; -import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk, InvalidNameError } from '../../interface'; +import { + MemberRole, + NodeType, + NodeResult, + NodeResultWithNewUid, + resultOk, + InvalidNameError, + NodeResultWithError, +} from '../../interface'; import { AbortError, ValidationError } from '../../errors'; import { createErrorFromUnknown, getErrorMessage } from '../errors'; import { splitNodeUid } from '../uids'; @@ -107,7 +115,11 @@ export abstract class NodesManagementBase< } // Improvement requested: move nodes in parallel - async *moveNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator { + async *moveNodes( + nodeUids: string[], + newParentNodeUid: string, + signal?: AbortSignal, + ): AsyncGenerator { for (const nodeUid of nodeUids) { if (signal?.aborted) { throw new AbortError(c('Error').t`Move operation aborted`); @@ -122,7 +134,7 @@ export abstract class NodesManagementBase< yield { uid: nodeUid, ok: false, - error: getErrorMessage(error), + error: error instanceof Error ? error : new Error(getErrorMessage(error), { cause: error }), }; } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 5ca0ce6b..b4ff8a89 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -8,6 +8,7 @@ import { MaybeNode, MaybeMissingNode, NodeResult, + NodeResultWithError, NodeResultWithNewUid, Revision, RevisionOrUid, @@ -420,7 +421,7 @@ export class ProtonDriveClient { nodeUids: NodeOrUid[], newParentNodeUid: NodeOrUid, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { this.logger.info(`Moving ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`); yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal); } From 468be47b8daea6e82ee23557dc34d38d5d38c6dc Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 6 Apr 2026 05:03:17 +0000 Subject: [PATCH 673/791] i18n(weekly-mr): Upgrade translations from crowdin (d5e23ea0). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 15 ++++++++++++--- js/sdk/locales/ca_ES.json | 12 +++++++++--- js/sdk/locales/de_DE.json | 12 +++++++++--- js/sdk/locales/el_GR.json | 12 +++++++++--- js/sdk/locales/es_ES.json | 12 +++++++++--- js/sdk/locales/es_LA.json | 12 +++++++++--- js/sdk/locales/fr_FR.json | 12 +++++++++--- js/sdk/locales/it_IT.json | 12 +++++++++--- js/sdk/locales/ko_KR.json | 3 --- js/sdk/locales/nl_NL.json | 12 +++++++++--- js/sdk/locales/pl_PL.json | 3 --- js/sdk/locales/pt_BR.json | 12 +++++++++--- js/sdk/locales/pt_PT.json | 3 --- js/sdk/locales/ro_RO.json | 12 +++++++++--- js/sdk/locales/ru_RU.json | 3 --- js/sdk/locales/sk_SK.json | 12 +++++++++--- js/sdk/locales/tr_TR.json | 12 +++++++++--- 18 files changed, 121 insertions(+), 52 deletions(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index d527bb42..eea904d1 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "b171444884dcc3d920fe0131556f7e38949a5c2a" + "locale": "283d10d76fa4b2e213c3c844691468a6a1b6e853" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 6a6560ae..e95da611 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -11,12 +11,21 @@ "Cannot add photo to album without a valid name": [ "Ðельга дадаць фатаграфію Ñž альбом без Ñапраўднай назвы" ], + "Cannot create public link for volume not owned by the user": [ + "Ðемагчыма Ñтварыць публічную ÑпаÑылку Ð´Ð»Ñ Ñ‚Ð¾Ð¼Ð°, Ñкі не належыць карыÑтальніку" + ], "Cannot download a folder": [ "Ðемагчыма Ñпампаваць папку" ], "Cannot share root folder": [ "Ðемагчыма абагуліць каранёвую папку" ], + "Cannot update public link for volume not owned by the user": [ + "Ðемагчыма абнавіць публічную ÑпаÑылку Ð´Ð»Ñ Ñ‚Ð¾Ð¼Ð°, Ñкі не належыць карыÑтальніку" + ], + "Content key packet is required for small revision upload": [ + "Пакет ключа змеÑціва патрабуецца Ð´Ð»Ñ Ð·Ð°Ð¿Ð°Ð¼Ð¿Ð¾ÑžÐ²Ð°Ð½Ð½Ñ Ð½ÐµÐ²Ñлікіх Ñ€Ñдакцый" + ], "Copy operation aborted": [ "ÐÐ¿ÐµÑ€Ð°Ñ†Ñ‹Ñ ÐºÐ°Ð¿Ñ–ÑÐ²Ð°Ð½Ð½Ñ Ð¿ÐµÑ€Ð°Ñ€Ð²Ð°Ð½Ð°" ], @@ -71,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць ключ вузла: ${ message }" ], - "Failed to decrypt some nodes": [ - "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ Ð²ÑƒÐ·Ð»Ñ‹" - ], "Failed to decrypt thumbnail: ${ message }": [ "Ðе ўдалоÑÑ Ñ€Ð°Ñшыфраваць мініÑцюру: ${ message }" ], @@ -86,6 +92,9 @@ "Failed to get verification keys": [ "Ðе ўдалоÑÑ Ð°Ñ‚Ñ€Ñ‹Ð¼Ð°Ñ†ÑŒ ключы праверкі" ], + "Failed to load some items": [ + "Ðе ўдалоÑÑ Ð·Ð°Ð³Ñ€ÑƒÐ·Ñ–Ñ†ÑŒ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ Ñлементы" + ], "Failed to load some nodes": [ "Ðе ўдалоÑÑ Ð·Ð°Ð³Ñ€ÑƒÐ·Ñ–Ñ†ÑŒ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ Ð²ÑƒÐ·Ð»Ñ‹" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 8d91b9b7..3a701469 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "No es pot afegir la fotografia a l'àlbum sense un nom vàlid" ], + "Cannot create public link for volume not owned by the user": [ + "No es pot crear l'enllaç públic d'un volum que no és propietat de l'usuari" + ], "Cannot download a folder": [ "No es pot descarregar una carpeta" ], "Cannot share root folder": [ "No es pot compartir la carpeta arrel" ], + "Cannot update public link for volume not owned by the user": [ + "No es pot actualitzar l'enllaç públic d'un volum que no és propietat de l'usuari" + ], "Content key packet is required for small revision upload": [ "Es requereix el paquet de claus de contingut per carregar revisions petites" ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "No s'ha pogut desxifrar la clau del node: ${ message }" ], - "Failed to decrypt some nodes": [ - "No s'han pogut desxifrar alguns nodes" - ], "Failed to decrypt thumbnail: ${ message }": [ "No s'ha pogut desxifrar la miniatura: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "No s'han pogut obtenir les claus de verificació" ], + "Failed to load some items": [ + "No s'han pogut carregar alguns elements" + ], "Failed to load some nodes": [ "No s'han pogut carregar alguns nodes" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 5c666147..7610b040 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "Foto kann ohne gültigen Namen nicht zum Album hinzugefügt werden" ], + "Cannot create public link for volume not owned by the user": [ + "Für ein Volume, das nicht dem Benutzer gehört, kann kein öffentlicher Link erstellt werden" + ], "Cannot download a folder": [ "Ordner kann nicht heruntergeladen werden" ], "Cannot share root folder": [ "Stammordner kann nicht geteilt werden" ], + "Cannot update public link for volume not owned by the user": [ + "Für ein Volume, das nicht dem Benutzer gehört, kann kein öffentlicher Link aktualisiert werden" + ], "Content key packet is required for small revision upload": [ "Für das Hochladen kleinerer Überarbeitungen ist ein Content-Key-Paket erforderlich" ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Konnte Node-Schlüssel nicht entschlüsseln: ${ message }" ], - "Failed to decrypt some nodes": [ - "Konnte einige Nodes nicht entschlüsseln" - ], "Failed to decrypt thumbnail: ${ message }": [ "Konnte Vorschaubild nicht entschlüsseln: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "Fehler beim Abrufen der Verifizierungsschlüssel." ], + "Failed to load some items": [ + "Laden einiger Einträge fehlgeschlagen" + ], "Failed to load some nodes": [ "Konnte einige Nodes nicht laden" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index 7aa40919..44bffe5e 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "Δεν είναι δυνατή η Ï€Ïοσθήκη φωτογÏαφίας στο άλμπουμ χωÏίς έγκυÏο όνομα" ], + "Cannot create public link for volume not owned by the user": [ + "Δεν είναι δυνατή η δημιουÏγία του δημόσιου συνδέσμου για όγκο που δεν ανήκει στον χÏήστη" + ], "Cannot download a folder": [ "Δεν είναι δυνατή η λήψη ενός φακέλου" ], "Cannot share root folder": [ "Δεν είναι δυνατή η κοινοποίηση του κεντÏÎ¹ÎºÎ¿Ï Ï†Î±ÎºÎ­Î»Î¿Ï…" ], + "Cannot update public link for volume not owned by the user": [ + "Δεν είναι δυνατή η ενημέÏωση του δημόσιου συνδέσμου για όγκο που δεν ανήκει στον χÏήστη" + ], "Content key packet is required for small revision upload": [ "Το πακέτο ÎºÎ»ÎµÎ¹Î´Î¹Î¿Ï Ï€ÎµÏιεχομένου απαιτείται για τη μεταφόÏτωση μικÏής αναθεώÏησης" ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Αποτυχία αποκÏυπτογÏάφησης ÎºÎ»ÎµÎ¹Î´Î¹Î¿Ï ÎºÏŒÎ¼Î²Î¿Ï…: ${ message }" ], - "Failed to decrypt some nodes": [ - "Αποτυχία αποκÏυπτογÏάφησης κάποιων κόμβων" - ], "Failed to decrypt thumbnail: ${ message }": [ "Αποτυχία αποκÏυπτογÏάφησης μικÏογÏαφίας: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "Αποτυχία λήψης κλειδιών επαλήθευσης" ], + "Failed to load some items": [ + "Αποτυχία φόÏτωσης κάποιων στοιχείων" + ], "Failed to load some nodes": [ "Αποτυχία φόÏτωσης κάποιων κόμβων" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 6ce92e3d..bdcd007e 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "No se puede añadir una foto a un álbum sin un nombre válido" ], + "Cannot create public link for volume not owned by the user": [ + "No puedes crear un enlace público de un volumen que no te pertenece" + ], "Cannot download a folder": [ "No se puede descargar una carpeta." ], "Cannot share root folder": [ "No se puede compartir la carpeta principal." ], + "Cannot update public link for volume not owned by the user": [ + "No puedes actualizar el enlace público de un volumen que no te pertenece" + ], "Content key packet is required for small revision upload": [ "Se requiere el paquete de claves de contenido para subir una revisión pequeña" ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Error al descifrar la clave del nodo: ${ message }" ], - "Failed to decrypt some nodes": [ - "Error al descifrar algunos nodos" - ], "Failed to decrypt thumbnail: ${ message }": [ "Error al descifrar la miniatura: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "Error al obtener las claves de verificación" ], + "Failed to load some items": [ + "No se han podido cargar algunos elementos" + ], "Failed to load some nodes": [ "Error al cargar algunos nodos" ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 5bef8b43..242ec294 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "No se puede añadir una foto a un álbum sin un nombre válido" ], + "Cannot create public link for volume not owned by the user": [ + "No se puede crear el enlace público para un volumen que no es propiedad del usuario" + ], "Cannot download a folder": [ "No se puede descargar una carpeta." ], "Cannot share root folder": [ "No se puede compartir la carpeta raíz" ], + "Cannot update public link for volume not owned by the user": [ + "No se puede actualizar el enlace público para un volumen que no es propiedad del usuario" + ], "Content key packet is required for small revision upload": [ "Se requiere el paquete de claves de contenido para cargar una revisión pequeña" ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "No se pudo descifrar la clave de nodo: ${ message }" ], - "Failed to decrypt some nodes": [ - "No se pudieron descifrar algunos nodos" - ], "Failed to decrypt thumbnail: ${ message }": [ "Error al descifrar la miniatura: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "Error al obtener las claves de verificación" ], + "Failed to load some items": [ + "Error al cargar algunos elementos" + ], "Failed to load some nodes": [ "Error al cargar algunos nodos" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index ee6983b4..062c30d1 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "Impossible d'ajouter une photo à l'album sans un nom valide." ], + "Cannot create public link for volume not owned by the user": [ + "Impossible de créer un lien public pour un volume n'appartenant pas à l'utilisateur." + ], "Cannot download a folder": [ "Le téléchargement d'un dossier n'a pas abouti." ], "Cannot share root folder": [ "Le partager du dossier principal n'a pas abouti." ], + "Cannot update public link for volume not owned by the user": [ + "Impossible de mettre à jour un lien public pour un volume n'appartenant pas à l'utilisateur" + ], "Content key packet is required for small revision upload": [ "Un paquet de clés de contenu est requis pour importer de petites révisions." ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Le déchiffrement de la clé de nÅ“ud n'a pas abouti : ${ message }" ], - "Failed to decrypt some nodes": [ - "Le déchiffrement de certains nÅ“uds n'a pas abouti." - ], "Failed to decrypt thumbnail: ${ message }": [ "Le déchiffrement de la vignette n'a pas abouti : ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "Impossible d'obtenir les clés de vérification" ], + "Failed to load some items": [ + "Le chargement de certains éléments n'a pas abouti." + ], "Failed to load some nodes": [ "Le chargement de certains nÅ“uds n'a pas abouti." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index 0ddb0cc4..1fcfc735 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "Impossibile aggiungere la foto all’album senza un nome valido." ], + "Cannot create public link for volume not owned by the user": [ + "Impossibile creare un collegamento pubblico per un volume non di proprietà dell'utente" + ], "Cannot download a folder": [ "Impossibile scaricare una cartella" ], "Cannot share root folder": [ "Impossibile condividere la cartella principale" ], + "Cannot update public link for volume not owned by the user": [ + "Impossibile aggiornare il collegamento pubblico per un volume non di proprietà dell'utente" + ], "Content key packet is required for small revision upload": [ "Il pacchetto chiave di contenuto è richiesto per il caricamento di piccole revisioni" ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Impossibile decriptare la chiave del nodo: ${ message }" ], - "Failed to decrypt some nodes": [ - "Impossibile decriptare alcuni nodi" - ], "Failed to decrypt thumbnail: ${ message }": [ "Impossibile decriptare la miniatura: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "Impossibile ottenere le chiavi di verifica" ], + "Failed to load some items": [ + "Impossibile caricare alcuni elementi" + ], "Failed to load some nodes": [ "Impossibile caricare alcuni nodi" ], diff --git a/js/sdk/locales/ko_KR.json b/js/sdk/locales/ko_KR.json index 5bc7fac7..bd64c7ee 100644 --- a/js/sdk/locales/ko_KR.json +++ b/js/sdk/locales/ko_KR.json @@ -74,9 +74,6 @@ "Failed to decrypt node key: ${ message }": [ "노드 키 복호화 실패: ${ message }" ], - "Failed to decrypt some nodes": [ - "ì¼ë¶€ 노드를 복호화할 수 ì—†ìŒ" - ], "Failed to decrypt thumbnail: ${ message }": [ "ì„¬ë‚´ì¼ ë³µí˜¸í™” 실패: ${ message }" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index 70c15343..f00d5c13 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "Kan geen foto aan album toevoegen zonder geldige naam" ], + "Cannot create public link for volume not owned by the user": [ + "Kan geen openbare link aanmaken voor een volume dat geen eigendom is van de gebruiker." + ], "Cannot download a folder": [ "Kan geen map downloaden" ], "Cannot share root folder": [ "Kan hoofdmap niet delen" ], + "Cannot update public link for volume not owned by the user": [ + "Het is niet mogelijk om de openbare link bij te werken voor een volume dat geen eigendom is van de gebruiker." + ], "Content key packet is required for small revision upload": [ "Content key packet is vereist voor kleine revisie upload" ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Fout bij het ontsleutelen van de nodesleutel: ${ message }" ], - "Failed to decrypt some nodes": [ - "Fout bij het ontsleutelen van sommige nodes" - ], "Failed to decrypt thumbnail: ${ message }": [ "Fout bij het ontsleutelen van de miniatuur: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "Fout bij het ophalen van verificatiesleutels" ], + "Failed to load some items": [ + "Fout bij het laden van sommige items" + ], "Failed to load some nodes": [ "Fout bij het laden van sommige nodes" ], diff --git a/js/sdk/locales/pl_PL.json b/js/sdk/locales/pl_PL.json index 5b9a0d6f..fe69188e 100644 --- a/js/sdk/locales/pl_PL.json +++ b/js/sdk/locales/pl_PL.json @@ -65,9 +65,6 @@ "Failed to decrypt node key: ${ message }": [ "Nie udaÅ‚o siÄ™ odszyfrować klucza wÄ™zÅ‚a: ${ message }" ], - "Failed to decrypt some nodes": [ - "Nie udaÅ‚o siÄ™ odszyfrować niektórych wÄ™złów" - ], "Failed to decrypt thumbnail: ${ message }": [ "Nie udaÅ‚o siÄ™ odszyfrować podglÄ…du: ${ message }" ], diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index 759bbcc7..8f6f0c73 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -68,9 +68,6 @@ "Failed to decrypt node key: ${ message }": [ "Falha ao descriptografar a chave do nó: ${ message }" ], - "Failed to decrypt some nodes": [ - "Falha ao descriptografar alguns nós" - ], "Failed to decrypt thumbnail: ${ message }": [ "Erro ao descriptografar a miniatura: ${ message }" ], @@ -95,6 +92,9 @@ "File has no content key": [ "O arquivo não tem chave de conteúdo." ], + "File has no revision": [ + "O arquivo não possui revisão" + ], "File hash does not match expected hash": [ "O hash do arquivo não corresponde ao hash esperado" ], @@ -159,9 +159,15 @@ "Operation aborted": [ "Operação abortada" ], + "Operation failed, try again later": [ + "Falha na operação, tente novamente mais tarde" + ], "Parent cannot be decrypted": [ "O elemento principal não pode ser descriptografado." ], + "Photo is already in the target album": [ + "A foto já está no álbum de destino" + ], "Photo not found": [ "Foto não encontrada" ], diff --git a/js/sdk/locales/pt_PT.json b/js/sdk/locales/pt_PT.json index 4047b5d3..4ba91400 100644 --- a/js/sdk/locales/pt_PT.json +++ b/js/sdk/locales/pt_PT.json @@ -59,9 +59,6 @@ "Failed to decrypt node key: ${ message }": [ "Erro ao desencriptar chave do nó: ${ message }" ], - "Failed to decrypt some nodes": [ - "Erro ao desencriptar alguns nós" - ], "Failed to decrypt thumbnail: ${ message }": [ "Erro ao desencriptar a miniatura: ${ message }" ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index c6c611fb..1513efaf 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "Nu se poate adăuga o fotografie în album fără un nume valid." ], + "Cannot create public link for volume not owned by the user": [ + "Nu se poate crea legătura web publică pentru volumul care nu este deÈ›inut de utilizator." + ], "Cannot download a folder": [ "Nu se poate descărca un folder." ], "Cannot share root folder": [ "Nu se poate partaja folderul rădăcină." ], + "Cannot update public link for volume not owned by the user": [ + "Nu se poate actualiza legătura web publică pentru volumul care nu este deÈ›inut de utilizator." + ], "Content key packet is required for small revision upload": [ "Pentru încărcarea revizuirilor mici este necesar pachetul cheii conÈ›inutului." ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Nu s-a reuÈ™it decriptarea cheii nodului: ${ message }" ], - "Failed to decrypt some nodes": [ - "Nu s-a reuÈ™it decriptarea unor noduri." - ], "Failed to decrypt thumbnail: ${ message }": [ "Nu s-a reuÈ™it decriptarea miniaturii: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "ObÈ›inerea cheilor de verificare a eÈ™uat." ], + "Failed to load some items": [ + "Nu s-a reuÈ™it încărcarea unor articole." + ], "Failed to load some nodes": [ "Nu s-a reuÈ™it încărcarea unor noduri." ], diff --git a/js/sdk/locales/ru_RU.json b/js/sdk/locales/ru_RU.json index 6fb7c1b0..b18ffd7a 100644 --- a/js/sdk/locales/ru_RU.json +++ b/js/sdk/locales/ru_RU.json @@ -59,9 +59,6 @@ "Failed to decrypt node key: ${ message }": [ "Ðе удалоÑÑŒ раÑшифровать ключ узла: ${ message }" ], - "Failed to decrypt some nodes": [ - "Ðе удалоÑÑŒ раÑшифровать некоторые узлы" - ], "Failed to decrypt thumbnail: ${ message }": [ "Ðе удалоÑÑŒ раÑшифровать значок: ${ message }" ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index cef8303f..4f2c20c2 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "Nie je možné pridaÅ¥ fotografiu do albumu bez platného názvu" ], + "Cannot create public link for volume not owned by the user": [ + "Nie je možné vytvoriÅ¥ verejný odkaz pre zväzok, ktorý nevlastní daný používateľ" + ], "Cannot download a folder": [ "Nie je možné stiahnuÅ¥ prieÄinok" ], "Cannot share root folder": [ "Nie je možné zdieľaÅ¥ koreňový prieÄinok" ], + "Cannot update public link for volume not owned by the user": [ + "Nie je možné aktualizovaÅ¥ verejný odkaz pre zväzok, ktorý nevlastní daný používateľ" + ], "Content key packet is required for small revision upload": [ "Na nahratie malých revízií je potrebný balík kľúÄov obsahu." ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Nepodarilo sa deÅ¡ifrovaÅ¥ kÄ¾ÃºÄ uzla: ${ message }" ], - "Failed to decrypt some nodes": [ - "Nepodarilo sa deÅ¡ifrovaÅ¥ niektoré uzly" - ], "Failed to decrypt thumbnail: ${ message }": [ "Nepodarilo sa deÅ¡ifrovaÅ¥ miniatúru: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "Získanie overovacích kľúÄov zlyhalo" ], + "Failed to load some items": [ + "Nepodarilo sa naÄítaÅ¥ niektoré položky" + ], "Failed to load some nodes": [ "Nepodarilo sa naÄítaÅ¥ niektoré uzly" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index 1e6827cd..5b52406c 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -11,12 +11,18 @@ "Cannot add photo to album without a valid name": [ "Geçerli bir ad olmadan fotoÄŸraf albüme eklenemez" ], + "Cannot create public link for volume not owned by the user": [ + "Kullanıcıya ait olmayan birimin herkese açık baÄŸlantısı oluÅŸturulamaz" + ], "Cannot download a folder": [ "Bir klasör indirilemedi" ], "Cannot share root folder": [ "Kök klasör paylaşılamaz" ], + "Cannot update public link for volume not owned by the user": [ + "Kullanıcıya ait olmayan birimin herkese açık baÄŸlantısı güncellenemez" + ], "Content key packet is required for small revision upload": [ "Küçük sürüm yüklemeleri için içerik anahtarı paketi gereklidir" ], @@ -74,9 +80,6 @@ "Failed to decrypt node key: ${ message }": [ "Düğüm anahtarının ÅŸifresi çözülemedi: ${ message }" ], - "Failed to decrypt some nodes": [ - "Bazı düğümlerin ÅŸifresi çözülemedi" - ], "Failed to decrypt thumbnail: ${ message }": [ "Küçük görselin ÅŸifresi çözülemedi: ${ message }" ], @@ -89,6 +92,9 @@ "Failed to get verification keys": [ "DoÄŸrulama anahtarları alınamadı" ], + "Failed to load some items": [ + "Bazı ögeler yüklenemedi" + ], "Failed to load some nodes": [ "Bazı düğümler yüklenemedi" ], From e3a756ac451b2f06acd1629f5cb6c4c1e7bda1ed Mon Sep 17 00:00:00 2001 From: drive Date: Sat, 4 Apr 2026 09:01:58 +0200 Subject: [PATCH 674/791] Log network error and retries in kotlin --- .../drive/sdk/internal/ApiProviderBridge.kt | 54 ++++++++++++------- .../drive/sdk/internal/RetryAfterDelay.kt | 12 ++--- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt index 53141eb1..eb48a050 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -29,11 +29,11 @@ internal class ApiProviderBridge( override suspend fun invoke(request: HttpRequest): HttpResponse { val httpStream = createHttpStream() val preparedRequest = request.prepare(httpStream) - val apiResult = RetryAfterDelay(isEnabled = preparedRequest.isRetryEnabled) { + val apiResult = RetryAfterDelay(isEnabled = preparedRequest.isRetryEnabled) { attempt -> apiProvider.get(userId).invoke( forceNoRetryOnConnectionErrors = true ) { - execute(preparedRequest) + execute(preparedRequest, attempt) } } if (apiResult is ApiResult.Error) { @@ -98,25 +98,41 @@ internal class ApiProviderBridge( private suspend fun HttpSdkApi.execute( request: PreparedRequest, - ): Response = with(request) { - logger("--> $method $url ($bodyMessage body)") - when (method.uppercase()) { - "GET" -> if (isDownloadBlock) { - getStreaming(url, headers) - } else { - get(url, headers) + attempt: Int, + ): Response = executeLogged(request, attempt) { + with(request) { + when (method.uppercase()) { + "GET" -> if (isDownloadBlock) { + getStreaming(url, headers) + } else { + get(url, headers) + } + + "POST" -> post(url, headers, body) + "PUT" -> put(url, headers, body) + "DELETE" -> delete(url, headers, body) + else -> throw IllegalArgumentException("Unsupported method: $method") } + } + } - "POST" -> post(url, headers, body) - "PUT" -> put(url, headers, body) - "DELETE" -> delete(url, headers, body) - else -> throw IllegalArgumentException("Unsupported method: $method") - }.also { response -> - val contentLength = response.body()?.contentLength() - val bodySize = if (contentLength != -1L) "$contentLength-byte" else "unknown-length" - logger( - "<-- ${response.code()} ${response.message()} $url ($bodySize body)" - ) + @Suppress("TooGenericExceptionCaught") + suspend fun HttpSdkApi.executeLogged( + request: PreparedRequest, + attempt: Int, + block: suspend HttpSdkApi.(PreparedRequest) -> Response, + ) = with(request) { + val attemptSuffix = if (attempt > 0) " [retry $attempt]" else "" + try { + logger("--> $method $url ($bodyMessage body)$attemptSuffix") + block(request).also { response -> + val contentLength = response.body()?.contentLength() + val bodySize = if (contentLength != -1L) "$contentLength-byte" else "unknown-length" + logger("<-- ${response.code()} ${response.message()} $url ($bodySize body)$attemptSuffix") + } + } catch (e: Exception) { + logger("<-- HTTP FAILED: $url ($e)$attemptSuffix") + throw e } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/RetryAfterDelay.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/RetryAfterDelay.kt index c073029d..ba9762bf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/RetryAfterDelay.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/RetryAfterDelay.kt @@ -19,15 +19,15 @@ object RetryAfterDelay { suspend operator fun invoke( isEnabled: Boolean, strategy: Duration.(Int, Double) -> Duration = Duration::exponentialDelay, - block: suspend () -> ApiResult>, + block: suspend (Int) -> ApiResult>, ): ApiResult> { - var attempts = 0 + var attempt = 0 var remaining = MAX_DELAY_DURATION var result: ApiResult> do { - result = block() + result = block(attempt) if (!isEnabled) break - attempts++ + attempt++ val duration = when (result) { is ApiResult.Error.Http -> { when (result.httpCode) { @@ -36,7 +36,7 @@ object RetryAfterDelay { maxValue = MAX_RETRY_AFTER_DURATION, ) in 500..599 -> DEFAULT_SERVER_ERROR_DURATION - .strategy(attempts, 2.0) + .strategy(attempt, 2.0) .coerceAtMost(remaining) else -> break } @@ -45,7 +45,7 @@ object RetryAfterDelay { } remaining -= duration delay(duration) - } while (remaining.isPositive() && attempts < MAX_FAILURES) + } while (remaining.isPositive() && attempt < MAX_FAILURES) return result } } From 3dc687848fd8d792cc4cf123090134cc8e10549a Mon Sep 17 00:00:00 2001 From: drive Date: Sat, 4 Apr 2026 10:01:38 +0200 Subject: [PATCH 675/791] Resume continuation only when active --- .../sdk/internal/BaseContinuationResponse.kt | 28 ++++++++++++++++--- .../ContinuationUnitOrErrorResponse.kt | 3 +- .../ContinuationValueOrErrorResponse.kt | 3 +- .../ContinuationValueOrNullResponse.kt | 3 +- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt index e4f75155..50d57044 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt @@ -1,16 +1,17 @@ package me.proton.drive.sdk.internal import com.google.protobuf.kotlin.toByteString +import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.LoggerProvider.Level.DEBUG import me.proton.drive.sdk.ProtonDriveSdkException import me.proton.drive.sdk.extension.toError import proton.sdk.ProtonSdk import java.nio.ByteBuffer -import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException abstract class BaseContinuationResponse( - private val continuation: Continuation, + private val continuation: CancellableContinuation, ) : ResponseCallback { private val callSite = CallerException("Called from") @@ -24,10 +25,29 @@ abstract class BaseContinuationResponse( ) } .mapCatching(block) - .onSuccess(continuation::resume) - .onFailure(continuation::resumeWithException) + .onSuccess { value -> + if (continuation.isActive) { + continuation.resume(value) + } else { + logger("Cannot resume inactive continuation") + } + } + .onFailure { error -> + if (continuation.isActive) { + continuation.resumeWithException(error) + } else { + logger( + "Cannot resume with exception inactive continuation: ${error.message}" + + "\n${error.stackTraceToString()}" + ) + } + } } + private fun logger( + message: String, + ) = JniBase.globalSdkLogger(DEBUG, "drive.sdk.continuation", message) + protected fun error(message: String): Nothing = throw ProtonDriveSdkException( message = message, cause = prepareCallSite(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt index 77d9af47..164f92dc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.internal +import kotlinx.coroutines.CancellableContinuation import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET import proton.sdk.ProtonSdk.Response.ResultCase.VALUE @@ -7,7 +8,7 @@ import java.nio.ByteBuffer import kotlin.coroutines.Continuation class ContinuationUnitOrErrorResponse( - continuation: Continuation, + continuation: CancellableContinuation, ) : BaseContinuationResponse(continuation) { override fun invoke(data: ByteBuffer) = parse(data) { response -> when (response.resultCase) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt index 9618e604..1102b4a2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.internal +import kotlinx.coroutines.CancellableContinuation import me.proton.drive.sdk.converter.AnyConverter import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET @@ -8,7 +9,7 @@ import java.nio.ByteBuffer import kotlin.coroutines.Continuation class ContinuationValueOrErrorResponse( - continuation: Continuation, + continuation: CancellableContinuation, private val anyConverter: AnyConverter, ) : BaseContinuationResponse(continuation) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt index 54a9de8d..d80db288 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.internal +import kotlinx.coroutines.CancellableContinuation import me.proton.drive.sdk.converter.AnyConverter import proton.sdk.ProtonSdk.Response.ResultCase.ERROR import proton.sdk.ProtonSdk.Response.ResultCase.RESULT_NOT_SET @@ -8,7 +9,7 @@ import java.nio.ByteBuffer import kotlin.coroutines.Continuation class ContinuationValueOrNullResponse( - continuation: Continuation, + continuation: CancellableContinuation, private val anyConverter: AnyConverter, ) : BaseContinuationResponse(continuation) { override fun invoke(data: ByteBuffer) = parse(data) { response -> From cc29178f5569d795d041b6b8c65318c4896ce0ef Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 7 Apr 2026 06:42:44 +0000 Subject: [PATCH 676/791] Update changelog for cs/v0.13.2 --- cs/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 1a3c650a..952e15cf 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## cs/v0.13.2 (2026-04-07) + +* Resume continuation only when active +* Log network error and retries in kotlin + +## cs/v0.13.1 (2026-04-03) + +* Update logs from kotlin resume api +* Fix feature flag parsing in kotlin + ## cs/v0.13.0 (2026-04-02) * Keep http request body in kotlin memory for retries From 359341ec4546c6b5ab351427c6669908f41e2300 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 8 Apr 2026 07:24:45 +0200 Subject: [PATCH 677/791] Avoid crypto key fallback for non-owners --- .../src/internal/nodes/cryptoService.test.ts | 54 ++++++++++++++++++- js/sdk/src/internal/nodes/cryptoService.ts | 17 +++++- js/sdk/src/internal/nodes/index.ts | 2 +- js/sdk/src/internal/photos/index.ts | 2 +- js/sdk/src/internal/sharingPublic/index.ts | 8 ++- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index 012afe12..ddc9e63b 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -86,6 +86,10 @@ describe('nodesCryptoService', () => { }; // @ts-expect-error No need to implement all methods for mocking sharesService = { + getRootIDs: jest.fn(async () => ({ + volumeId: 'volumeId', + rootNodeId: 'rootNodeId', + })), getMyFilesShareMemberEmailKey: jest.fn(async () => ({ email: 'email', addressKey: 'key' as unknown as PrivateKey, @@ -94,7 +98,7 @@ describe('nodesCryptoService', () => { }; const nodesCryptoReporter = new NodesCryptoReporter(telemetry, sharesService); - cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, nodesCryptoReporter); + cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, nodesCryptoReporter); }); const parentKey = 'parentKey' as unknown as PrivateKey; @@ -579,6 +583,7 @@ describe('nodesCryptoService', () => { const encryptedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentId', + creationTime: new Date('2026-01-01'), encryptedCrypto: { signatureEmail: 'signatureEmail', nameSignatureEmail: 'nameSignatureEmail', @@ -786,7 +791,13 @@ describe('nodesCryptoService', () => { }) as any, ); - const result = await cryptoService.decryptNode(encryptedNode, parentKey); + const result = await cryptoService.decryptNode( + { + ...encryptedNode, + creationTime: new Date('2026-01-01'), + }, + parentKey, + ); verifyResult(result, { keyAuthor: { ok: false, @@ -796,12 +807,50 @@ describe('nodesCryptoService', () => { }, }, }); + expect(account.getOwnAddresses).not.toHaveBeenCalled(); verifyLogEventVerificationError({ field: 'nodeContentKey', error: 'verification error', }); }); + it('on content key packet with skipped fallback verification for non-own volume', async () => { + driveCrypto.decryptAndVerifySessionKey = jest.fn( + async () => + Promise.resolve({ + sessionKey: 'contentKeyPacketSessionKey', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }) as any, + ); + + const result = await cryptoService.decryptNode( + { + ...encryptedNode, + uid: 'otherVolumeId~nodeId', + creationTime: new Date('2022-01-01'), + }, + parentKey, + ); + + verifyResult(result, { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Signature verification for content key failed: verification error', + }, + }, + }); + expect(account.getOwnAddresses).not.toHaveBeenCalled(); + verifyLogEventVerificationError({ + field: 'nodeContentKey', + error: 'verification error', + uid: 'otherVolumeId~nodeId', + fromBefore2024: true, + }); + }); + it('on content key packet with successful fallback verification', async () => { driveCrypto.decryptAndVerifySessionKey = jest .fn() @@ -829,6 +878,7 @@ describe('nodesCryptoService', () => { parentKey, ); verifyResult(result); + expect(account.getOwnAddresses).toHaveBeenCalled(); expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledTimes(2); expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith( 'base64ContentKeyPacket', diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index f5883cc3..27d9243c 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -33,7 +33,9 @@ import { DecryptedUnparsedRevision, NodeSigningKeys, EncryptedNodeFileCrypto, + SharesService, } from './interface'; +import { splitNodeUid } from '../uids'; export interface NodesCryptoReporter { handleClaimedAuthor( @@ -76,6 +78,7 @@ export class NodesCryptoService { telemetry: ProtonDriveTelemetry, protected driveCrypto: DriveCrypto, private account: ProtonDriveAccount, + private sharesService: Pick, private reporter: NodesCryptoReporter, ) { this.logger = telemetry.getLogger('nodes-crypto'); @@ -538,6 +541,15 @@ export class NodesCryptoService { return result; } + const { volumeId: ownVolumeId } = await this.sharesService.getRootIDs(); + const { volumeId: nodesVolumeId } = splitNodeUid(node.uid); + + // If the node is not in the own volume, skip the fallback verification, + // because it is not possible to load all owners' address keys. + if (ownVolumeId !== nodesVolumeId) { + return result; + } + const allAddresses = await this.account.getOwnAddresses(); const allKeys = allAddresses.flatMap((address) => address.keys.map(({ key }) => key)); @@ -745,7 +757,10 @@ export class NodesCryptoService { }; } - async generateNameHashes(parentHashKey: Uint8Array, names: string[]): Promise<{ name: string; hash: string }[]> { + async generateNameHashes( + parentHashKey: Uint8Array, + names: string[], + ): Promise<{ name: string; hash: string }[]> { return Promise.all( names.map(async (name) => ({ name, diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index 471e7cf6..fd4f6fcc 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -43,7 +43,7 @@ export function initNodesModule( const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService); - const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); + const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, cryptoReporter); const nodesAccess = new NodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService); const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache); const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess); diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 3dda2118..77fb20ae 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -123,7 +123,7 @@ export function initPhotosNodesModule( const cache = new PhotosNodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService); - const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); + const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, cryptoReporter); const nodesAccess = new PhotosNodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService); const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache); const nodesManagement = new PhotosNodesManagement(api, cryptoCache, cryptoService, nodesAccess); diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index b6ff9f61..5f04e9a0 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -100,7 +100,13 @@ export function initSharingPublicNodesModule( const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache); const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache); const cryptoReporter = new SharingPublicCryptoReporter(telemetry); - const cryptoService = new SharingPublicNodesCryptoService(telemetry, driveCrypto, account, cryptoReporter); + const cryptoService = new SharingPublicNodesCryptoService( + telemetry, + driveCrypto, + account, + sharesService, + cryptoReporter, + ); const nodesAccess = new SharingPublicNodesAccess( telemetry, api, From 13f3f6714fb140e4735991da7e407ac809eb9bf9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 8 Apr 2026 13:59:27 +0000 Subject: [PATCH 678/791] Support NonProtonInvitation conversion --- js/sdk/src/interface/account.ts | 3 +- js/sdk/src/internal/sharing/apiService.ts | 3 +- js/sdk/src/internal/sharing/cryptoService.ts | 3 +- .../sharing/sharingManagement.test.ts | 73 +++++++++++++++++++ .../src/internal/sharing/sharingManagement.ts | 50 ++++++++++++- js/sdk/src/protonDriveClient.ts | 17 +++++ js/sdk/src/protonDrivePhotosClient.ts | 17 +++++ 7 files changed, 160 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index cfc3cb89..0065b9d2 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -28,9 +28,10 @@ export interface ProtonDriveAccount { * * Does not throw if there is no public key for given email, but returns empty array. * + * @param forceRefresh If true, bypasses the cache and fetches fresh keys from the API. * @throws Error Only if there is an error while fetching keys. */ - getPublicKeys(email: string): Promise; + getPublicKeys(email: string, forceRefresh?: boolean): Promise; } export interface ProtonDriveAccountAddress { diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 4c2380f1..4942325f 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -433,6 +433,7 @@ export class SharingAPIService { shareId: string, invitation: EncryptedInvitationRequest, emailDetails: { message?: string; nodeName?: string } = {}, + externalInvitationId: string | null = null, ): Promise { const response = await this.apiService.post( `drive/v2/shares/${shareId}/invitations`, @@ -443,7 +444,7 @@ export class SharingAPIService { Permissions: memberRoleToPermission(invitation.role), KeyPacket: invitation.base64KeyPacket, KeyPacketSignature: invitation.base64KeyPacketSignature, - ExternalInvitationID: null, + ExternalInvitationID: externalInvitationId, }, EmailDetails: { Message: emailDetails.message, diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 61b76c16..b8ff916b 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -182,11 +182,12 @@ export class SharingCryptoService { shareSessionKey: SessionKey, inviterKey: PrivateKey, inviteeEmail: string, + forceRefreshKeys?: boolean, ): Promise<{ base64KeyPacket: string; base64KeyPacketSignature: string; }> { - const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail); + const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail, forceRefreshKeys); const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKeys[0], inviterKey); return result; } diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 79a91a9d..7de59a60 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -1138,4 +1138,77 @@ describe('SharingManagement', () => { expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); }); }); + + describe('convertNonProtonInvitation', () => { + const nodeUid = 'volumeId~nodeId'; + const externalInvitationId = 'inv123'; + const externalInvitationUid = `${DEFAULT_SHARE_ID}~${externalInvitationId}`; + const externalInvitation: NonProtonInvitation = { + uid: externalInvitationUid, + inviteeEmail: 'external@example.com', + addedByEmail: resultOk('inviter@example.com'), + role: MemberRole.Viewer, + invitationTime: new Date(), + state: NonProtonInvitationState.Pending, + }; + + beforeEach(() => { + nodesService.getNode = jest.fn().mockResolvedValue({ + nodeUid, + shareId: DEFAULT_SHARE_ID, + directRole: MemberRole.Admin, + name: { ok: true, value: 'name' }, + }); + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); + }); + + it('should throw if caller is not admin', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + nodeUid, + shareId: DEFAULT_SHARE_ID, + directRole: MemberRole.Viewer, + name: { ok: true, value: 'name' }, + }); + + await expect( + sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid), + ).rejects.toThrow(ValidationError); + }); + + it('should throw if no sharing info found', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ + nodeUid, + shareId: undefined, + directRole: MemberRole.Admin, + name: { ok: true, value: 'name' }, + }); + + await expect( + sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid), + ).rejects.toThrow(ValidationError); + }); + + it('should throw if external invitation ID is not found', async () => { + await expect( + sharingManagement.convertNonProtonInvitation(nodeUid, 'unknownShareId~unknownInvId'), + ).rejects.toThrow(ValidationError); + }); + + it('should invite proton user with force-refreshed keys and the external invitation ID', async () => { + await sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid); + + expect(cryptoService.encryptInvitation).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + externalInvitation.inviteeEmail, + true, + ); + expect(apiService.inviteProtonUser).toHaveBeenCalledWith( + DEFAULT_SHARE_ID, + expect.objectContaining({ inviteeEmail: externalInvitation.inviteeEmail, role: externalInvitation.role }), + {}, + externalInvitationId, + ); + }); + }); }); diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index b6f0f2cf..1c24173e 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -16,7 +16,7 @@ import { SharePublicLinkSettingsObject, } from '../../interface'; import { ErrorCode } from '../apiService'; -import { splitNodeUid } from '../uids'; +import { splitNodeUid, splitInvitationUid } from '../uids'; import { getErrorMessage } from '../errors'; import { SharingAPIService } from './apiService'; import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService'; @@ -596,8 +596,52 @@ export class SharingManagement { await this.apiService.deleteExternalInvitation(invitationUid); } - private async convertExternalInvitationsToInternal(): Promise { - // FIXME + async convertNonProtonInvitation(nodeUid: string, nonProtonInvitationUid: string): Promise { + const { invitationId: externalInvitationId } = splitInvitationUid(nonProtonInvitationUid); + + const node = await this.nodesService.getNode(nodeUid); + if (node.directRole !== MemberRole.Admin) { + throw new ValidationError(c('Error').t`Only admins can convert non-Proton invitations`); + } + + const [currentSharing, inviter] = await Promise.all([ + this.getInternalSharingInfo(nodeUid), + this.nodesService.getRootNodeEmailKey(nodeUid), + ]); + if (!currentSharing) { + throw new ValidationError(c('Error').t`The node is not shared anymore`); + } + + const externalInvitation = currentSharing.nonProtonInvitations.find( + (invitation) => invitation.uid === nonProtonInvitationUid, + ); + if (!externalInvitation) { + throw new ValidationError(c('Error').t`Invitation not found`); + } + this.logger.info( + `Converting non-Proton invitation for ${externalInvitation.inviteeEmail} to internal for node ${nodeUid}`, + ); + const invitationCrypto = await this.cryptoService.encryptInvitation( + currentSharing.share.passphraseSessionKey, + inviter.addressKey, + externalInvitation.inviteeEmail, + true, // Force refresh keys: the invitee just created a Proton account, so we have "absent" keys in cache + ); + const encryptedInvitation = await this.apiService.inviteProtonUser( + currentSharing.share.shareId, + { + addedByEmail: inviter.email, + inviteeEmail: externalInvitation.inviteeEmail, + role: externalInvitation.role, + ...invitationCrypto, + }, + {}, + externalInvitationId, + ); + return { + ...encryptedInvitation, + addedByEmail: resultOk(encryptedInvitation.addedByEmail), + }; } private async removeMember(memberUid: string): Promise { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index b4ff8a89..b1d37808 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -14,6 +14,7 @@ import { RevisionOrUid, ShareNodeSettings, UnshareNodeSettings, + ProtonInvitation, ProtonInvitationOrUid, NonProtonInvitationOrUid, ProtonInvitationWithNode, @@ -750,6 +751,22 @@ export class ProtonDriveClient { return this.sharing.management.unshareNode(getUid(nodeUid), settings); } + /** + * Convert a non-Proton invitation to an internal invitation. + * This is called automatically in the background when the SDK receives + * a metadata update event, but can also be triggered manually. + * + * @param nodeUid - Node entity or its UID string. + * @param invitationOrUid - Non-Proton invitation entity or its UID string. + */ + async convertNonProtonInvitation( + nodeUid: NodeOrUid, + invitationOrUid: NonProtonInvitationOrUid, + ): Promise { + this.logger.info(`Converting non-Proton invitation ${getUid(invitationOrUid)} for node ${getUid(nodeUid)}`); + return this.sharing.management.convertNonProtonInvitation(getUid(nodeUid), getUid(invitationOrUid)); + } + /** * Resend the invitation email to shared node. * diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 62a680be..d238b012 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -13,6 +13,7 @@ import { ShareNodeSettings, ShareResult, UnshareNodeSettings, + ProtonInvitation, ProtonInvitationOrUid, NonProtonInvitationOrUid, ProtonInvitationWithNode, @@ -408,6 +409,22 @@ export class ProtonDrivePhotosClient { return this.sharing.management.unshareNode(getUid(nodeUid), settings); } + /** + * Convert a non-Proton invitation to an internal invitation. + * This is called automatically in the background when the SDK receives + * a metadata update event, but can also be triggered manually. + * + * @param nodeUid - Node entity or its UID string. + * @param invitationOrUid - Non-Proton invitation entity or its UID string. + */ + async convertNonProtonInvitation( + nodeUid: NodeOrUid, + invitationOrUid: NonProtonInvitationOrUid, + ): Promise { + this.logger.info(`Converting non-Proton invitation ${getUid(invitationOrUid)} for node ${getUid(nodeUid)}`); + return this.sharing.management.convertNonProtonInvitation(getUid(nodeUid), getUid(invitationOrUid)); + } + /** * Resend the invitation email to shared node. * From d66cc140ec8de10bb3e743bb9878e94f646609c9 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 9 Apr 2026 08:26:31 +0000 Subject: [PATCH 679/791] Fix issue when listing photos of shared album --- js/sdk/src/internal/photos/nodes.test.ts | 64 +++++++++++++++++++++++- js/sdk/src/internal/photos/nodes.ts | 17 ++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/photos/nodes.test.ts b/js/sdk/src/internal/photos/nodes.test.ts index 1698529c..759e596e 100644 --- a/js/sdk/src/internal/photos/nodes.test.ts +++ b/js/sdk/src/internal/photos/nodes.test.ts @@ -206,6 +206,61 @@ describe('PhotosNodesCache', () => { }); describe('PhotosNodesAccess', () => { + describe('getParentKeys', () => { + let access: PhotosNodesAccess; + let getNodeKeysMock: jest.Mock; + let getSharePrivateKeyMock: jest.Mock; + + beforeEach(() => { + getNodeKeysMock = jest.fn().mockResolvedValue({ key: 'key', hashKey: 'hashKey' }); + getSharePrivateKeyMock = jest.fn().mockResolvedValue('shareKey'); + access = new PhotosNodesAccess( + getMockTelemetry(), + // @ts-expect-error No need to implement for this test + {}, + {}, + { getNodeKeys: jest.fn().mockRejectedValue(new Error()) }, + {}, + { getSharePrivateKey: getSharePrivateKeyMock }, + ); + jest.spyOn(access, 'getNodeKeys').mockImplementation(getNodeKeysMock); + }); + + it('should use parentUid path when set, ignoring shareId', async () => { + await access.getParentKeys({ + uid: 'v~node', + parentUid: 'v~parent', + shareId: 'publicLinkShareId', + photo: undefined, + }); + expect(getNodeKeysMock).toHaveBeenCalledWith('v~parent'); + expect(getSharePrivateKeyMock).not.toHaveBeenCalled(); + }); + + it('should use album key when no parentUid but has albums, even when shareId is set', async () => { + await access.getParentKeys({ + uid: 'v~node', + parentUid: undefined, + shareId: 'publicLinkShareId', + // @ts-expect-error No need to implement for this test + photo: { albums: [{ nodeUid: 'v~album' }] }, + }); + expect(getNodeKeysMock).toHaveBeenCalledWith('v~album'); + expect(getSharePrivateKeyMock).not.toHaveBeenCalled(); + }); + + it('should fall back to shareId when no parentUid and no albums', async () => { + await access.getParentKeys({ + uid: 'v~node', + parentUid: undefined, + shareId: 'rootShareId', + // @ts-expect-error No need to implement for this test + photo: { albums: [] }, + }); + expect(getSharePrivateKeyMock).toHaveBeenCalledWith('rootShareId'); + }); + }); + describe('parseNode', () => { it('should keep photo type and add photo object', async () => { const telemetry = getMockTelemetry(); @@ -222,7 +277,14 @@ describe('PhotosNodesAccess', () => { const sharesService: SharesService = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nodesAccess = new PhotosNodesAccess(telemetry, apiService, cacheService, cryptoCache, cryptoService, sharesService); + const nodesAccess = new PhotosNodesAccess( + telemetry, + apiService, + cacheService, + cryptoCache, + cryptoService, + sharesService, + ); const unparsedNode = { uid: 'volumeId~linkId', diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 417435c6..ee403323 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -151,7 +151,18 @@ export class PhotosNodesAccess extends NodesAccessBase, ): Promise> { - if (node.parentUid || node.shareId) { + // In regular case, the parent should be used first as it is guaranteed that + // the root node without parent will have a share with direct membership for + // the user that can be used to decrypt the node. + // For photos, the parent might be missing but then an album (or more) plays + // the role of the parent. It must be used first before fallbacking to share + // because the node might be shared but user is not directly invited and thus + // cannot decrypt via the share (user's address cannot decrypt). + // Using parent path first should stay as if present, it will be fastest way + // to decrypt for the owner - all photos in the timeline can use already + // cached key without the need to load albums as well. + + if (node.parentUid) { return super.getParentKeys(node); } @@ -176,6 +187,10 @@ export class PhotosNodesAccess extends NodesAccessBase Date: Thu, 9 Apr 2026 09:40:04 +0000 Subject: [PATCH 680/791] Correctly catch AbortError in batchLoading --- js/sdk/src/internal/batchLoading.test.ts | 32 +++++++++++++++++++++++- js/sdk/src/internal/batchLoading.ts | 5 +++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/batchLoading.test.ts b/js/sdk/src/internal/batchLoading.test.ts index 86e07f7e..feeba855 100644 --- a/js/sdk/src/internal/batchLoading.test.ts +++ b/js/sdk/src/internal/batchLoading.test.ts @@ -1,4 +1,4 @@ -import { ProtonDriveError } from '../errors'; +import { AbortError, ProtonDriveError } from '../errors'; import { BatchLoading } from './batchLoading'; describe('BatchLoading', () => { @@ -124,6 +124,36 @@ describe('BatchLoading', () => { expect((thrown as ProtonDriveError).cause).toEqual([expect.objectContaining({ message: 'iterator failed' })]); }); + it('should rethrow AbortError immediately without accumulating', async () => { + const abortError = new AbortError(); + const result: string[] = []; + const iterateItems = jest.fn(async function* (items: string[]) { + if (items.includes('a')) { + throw abortError; + } + for (const item of items) { + yield `loaded:${item}`; + } + }); + + batchLoading = new BatchLoading({ iterateItems, batchSize: 2 }); + + let thrown: unknown; + try { + for (const item of ['a', 'b', 'c', 'd']) { + for await (const loadedItem of batchLoading.load(item)) { + result.push(loadedItem); + } + } + } catch (e) { + thrown = e; + } + + expect(result).toEqual([]); + expect(thrown).toBe(abortError); + expect(iterateItems).toHaveBeenCalledTimes(1); + }); + it('should throw ProtonDriveError with causes when multiple batches fail', async () => { const loadItems = jest.fn((items: string[]) => { if (items.includes('a') || items.includes('e')) { diff --git a/js/sdk/src/internal/batchLoading.ts b/js/sdk/src/internal/batchLoading.ts index f45ad471..a3ddcc0a 100644 --- a/js/sdk/src/internal/batchLoading.ts +++ b/js/sdk/src/internal/batchLoading.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { ProtonDriveError } from '../errors'; +import { AbortError, ProtonDriveError } from '../errors'; const DEFAULT_BATCH_LOADING = 10; @@ -84,6 +84,9 @@ export class BatchLoading { try { yield* this.iterateItems(items); } catch (error) { + if (error instanceof AbortError) { + throw error; + } this.errors.push(error); } } From 3dad510d2a39ba895ec1dd5b9ec1d053b45f3d95 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 9 Apr 2026 11:28:22 +0000 Subject: [PATCH 681/791] Update changelog for js/v0.14.6 --- js/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 9fc1d1c9..cd623e9e 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## js/v0.14.6 (2026-04-09) + +* Correctly catch AbortError in batchLoading +* Fix issue when listing photos of shared album + +## js/v0.14.5 (2026-04-08) + +* Support NonProtonInvitation conversion +* Avoid crypto key fallback for non-owners +* Change move function to support returning validation error + ## js/v0.14.4 (2026-04-02) * Get public link of share only for my own nodes From e3cde4cef64ee958fc237776c559ba3be8fa118c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 10 Apr 2026 05:14:58 +0000 Subject: [PATCH 682/791] Fix verifying signature contexts --- js/sdk/src/crypto/openPGPCrypto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index cdd7e708..b93415b3 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -78,7 +78,7 @@ export interface OpenPGPCryptoProxy { armoredSignature?: string; binarySignature?: Uint8Array; verificationKeys: PublicKey | PublicKey[]; - signatureContext?: { critical: boolean; value: string }; + signatureContext?: { required: boolean; value: string }; }) => Promise<{ verificationStatus: VERIFICATION_STATUS; errors?: Error[]; @@ -327,7 +327,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { binaryData: data, armoredSignature, verificationKeys, - signatureContext: signatureContext ? { critical: true, value: signatureContext } : undefined, + signatureContext: signatureContext ? { required: true, value: signatureContext } : undefined, }); return { From 9b4c3768928ed7f09bd548f6b7b954804c29da46 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Apr 2026 05:03:24 +0000 Subject: [PATCH 683/791] i18n(weekly-mr): Upgrade translations from crowdin (e3cde4ce). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 6 ++++++ js/sdk/locales/ca_ES.json | 6 ++++++ js/sdk/locales/de_DE.json | 6 ++++++ js/sdk/locales/el_GR.json | 6 ++++++ js/sdk/locales/es_LA.json | 6 ++++++ js/sdk/locales/fr_FR.json | 6 ++++++ js/sdk/locales/it_IT.json | 6 ++++++ js/sdk/locales/pt_BR.json | 6 ++++++ js/sdk/locales/ro_RO.json | 6 ++++++ js/sdk/locales/sk_SK.json | 6 ++++++ js/sdk/locales/tr_TR.json | 6 ++++++ 12 files changed, 67 insertions(+), 1 deletion(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index eea904d1..b378168f 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "283d10d76fa4b2e213c3c844691468a6a1b6e853" + "locale": "07fdc384813cccde3eedbeda3aac6e4709de95ae" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index e95da611..161e600b 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -176,6 +176,9 @@ "Node not found": [ "Вузел не знойдзены" ], + "Only admins can convert non-Proton invitations": [ + "Толькі адмініÑтратары могуць пераўтвараць запрашÑнні, ÑÐºÑ–Ñ Ð½Ðµ належаць Proton" + ], "Operation aborted": [ "ÐÐ¿ÐµÑ€Ð°Ñ†Ñ‹Ñ Ð¿ÐµÑ€Ð°Ñ€Ð²Ð°Ð½Ð°" ], @@ -218,6 +221,9 @@ "Some file parts failed to upload": [ "Збой Ð·Ð°Ð¿Ð°Ð¼Ð¿Ð¾ÑžÐ²Ð°Ð½Ð½Ñ Ð½ÐµÐºÐ°Ñ‚Ð¾Ñ€Ñ‹Ñ… чаÑтак файла" ], + "The node is not shared anymore": [ + "Вузел больш не абагулены" + ], "Thumbnail not found": [ "МініÑцюра не знойдзена" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 3a701469..1a9cb548 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -174,6 +174,9 @@ "Node not found": [ "No s'ha trobat el node" ], + "Only admins can convert non-Proton invitations": [ + "Només els administradors poden convertir invitacions que no siguin de Proton" + ], "Operation aborted": [ "S'ha interromput l'operació" ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Algunes parts del fitxer no s'han pogut carregar" ], + "The node is not shared anymore": [ + "Aquest node ja no està compartit" + ], "Thumbnail not found": [ "No s'ha trobat la miniatura" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 7610b040..c6eda05a 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -174,6 +174,9 @@ "Node not found": [ "Node nicht gefunden" ], + "Only admins can convert non-Proton invitations": [ + "Nur Administratoren können Nicht-Proton-Einladungen umwandeln" + ], "Operation aborted": [ "Vorgang abgebrochen" ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Einige Dateiteile konnten nicht hochgeladen werden." ], + "The node is not shared anymore": [ + "Der Knoten wird nicht mehr gemeinsam genutzt" + ], "Thumbnail not found": [ "Vorschaubild nicht gefunden" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index 44bffe5e..bb835e01 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -174,6 +174,9 @@ "Node not found": [ "Ο κόμβος δεν βÏέθηκε" ], + "Only admins can convert non-Proton invitations": [ + "Μόνο οι διαχειÏιστές μποÏοÏν να μετατÏέψουν Ï€Ïοσκλήσεις που δεν είναι της Proton" + ], "Operation aborted": [ "Η λειτουÏγία ακυÏώθηκε" ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Κάποια μέÏη αÏχείων απέτυχαν να μεταφοÏτωθοÏν" ], + "The node is not shared anymore": [ + "Ο κόμβος δεν είναι πλέον κοινόχÏηστος" + ], "Thumbnail not found": [ "Η μικÏογÏαφία δεν βÏέθηκε" ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 242ec294..f43f727d 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -174,6 +174,9 @@ "Node not found": [ "Nodo no encontrado" ], + "Only admins can convert non-Proton invitations": [ + "Solo administradores pueden convertir invitaciones externas Proton" + ], "Operation aborted": [ "Operación cancelada" ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Algunas partes del archivo no se pudieron cargar" ], + "The node is not shared anymore": [ + "El nodo ya no está compartido" + ], "Thumbnail not found": [ "No se encontró la miniatura" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index 062c30d1..d7053dcc 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -174,6 +174,9 @@ "Node not found": [ "Le nÅ“ud est introuvable." ], + "Only admins can convert non-Proton invitations": [ + "Seuls les administrateurs peuvent convertir des invitations non-Proton." + ], "Operation aborted": [ "L'opération a été annulée." ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Certaines parties de fichier n'ont pas pu être importées." ], + "The node is not shared anymore": [ + "Ce nÅ“ud n'est plus partagé." + ], "Thumbnail not found": [ "La vignette n'a pas été trouvée." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index 1fcfc735..a7f10927 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -174,6 +174,9 @@ "Node not found": [ "Nodo non trovato" ], + "Only admins can convert non-Proton invitations": [ + "Solo gli amministratori possono convertire gli inviti non Proton" + ], "Operation aborted": [ "Operazione annullata" ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Alcune parti di file non sono state caricate" ], + "The node is not shared anymore": [ + "Il nodo non è più condiviso." + ], "Thumbnail not found": [ "Miniatura non trovata" ], diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index 8f6f0c73..94b3050c 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -156,6 +156,9 @@ "Node not found": [ "Nó não encontrado" ], + "Only admins can convert non-Proton invitations": [ + "Apenas administradores podem converter convites que não sejam da Proton" + ], "Operation aborted": [ "Operação abortada" ], @@ -198,6 +201,9 @@ "Some file parts failed to upload": [ "Algumas partes do arquivo falharam ao enviar" ], + "The node is not shared anymore": [ + "O nó não é mais compartilhado" + ], "Thumbnail not found": [ "Miniatura não encontrada" ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index 1513efaf..d5daedb0 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -175,6 +175,9 @@ "Node not found": [ "Nod negăsit." ], + "Only admins can convert non-Proton invitations": [ + "Doar administratorii pot converti invitaÈ›iile non-Proton." + ], "Operation aborted": [ "OperaÈ›ie anulată" ], @@ -217,6 +220,9 @@ "Some file parts failed to upload": [ "Unele părÈ›i ale fiÈ™ierului nu s-au încărcat." ], + "The node is not shared anymore": [ + "Nodul nu mai este partajat." + ], "Thumbnail not found": [ "Miniatură negăsită" ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index 4f2c20c2..fe9972f9 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -176,6 +176,9 @@ "Node not found": [ "Uzol nebol nájdený" ], + "Only admins can convert non-Proton invitations": [ + "Pozvánky, ktoré nie sú od služby Proton, môžu zmeniÅ¥ len správcovia" + ], "Operation aborted": [ "Operácia preruÅ¡ená" ], @@ -218,6 +221,9 @@ "Some file parts failed to upload": [ "Niektoré Äasti súboru sa nepodarilo nahraÅ¥" ], + "The node is not shared anymore": [ + "Uzol už nie je zdieľaný" + ], "Thumbnail not found": [ "Miniatúra nenájdená" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index 5b52406c..e593879f 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -174,6 +174,9 @@ "Node not found": [ "Düğüm bulunamadı" ], + "Only admins can convert non-Proton invitations": [ + "Proton kullanmayanların davetiyelerini yalnızca yöneticiler dönüştürebilir" + ], "Operation aborted": [ "İşlem iptal edildi" ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Bazı dosya parçaları yüklenemedi" ], + "The node is not shared anymore": [ + "Düğüm artık paylaşılmıyor" + ], "Thumbnail not found": [ "Küçük görsel bulunamadı" ], From d240a06506f2115510d9e473cc7e3e76739f3610 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Apr 2026 13:00:15 +0000 Subject: [PATCH 684/791] Switch NPM package license to MIT --- LICENSE.md | 2 +- js/sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index c9e43613..8f8e23f2 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2025 Proton AG +Copyright (c) 2025-2026 Proton AG Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/js/sdk/package.json b/js/sdk/package.json index fe384c72..d15185da 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -2,7 +2,7 @@ "name": "@protontech/drive-sdk", "version": "0.0.1", "description": "Proton Drive SDK", - "license": "GPL-3.0", + "license": "MIT", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ From ec0b8e9960571f2b56568b69bb33868f9834c7eb Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 16 Apr 2026 12:00:30 +0200 Subject: [PATCH 685/791] Fix failure to upload new revision on single file sharing --- .../Nodes/Upload/NewRevisionDraftProvider.cs | 2 +- .../Nodes/Upload/RevisionDraft.cs | 4 +- .../Nodes/Upload/RevisionWriter.cs | 77 +++++++++++++------ 3 files changed, 57 insertions(+), 26 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index e35b532e..ff70d9ff 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -74,7 +74,7 @@ public async ValueTask GetDraftAsync(CancellationToken cancellati fileSecrets.Key, fileSecrets.ContentKey, signingKey, - hashKey: null, + parentHashKey: null, membershipAddress, blockVerifier, ct => DeleteDraftAsync(draftRevisionUid, ct), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs index b40ae004..bf5e6f3b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -14,7 +14,7 @@ internal sealed partial class RevisionDraft( PgpPrivateKey fileKey, PgpSessionKey contentKey, PgpPrivateKey signingKey, - ReadOnlyMemory? hashKey, + ReadOnlyMemory? parentHashKey, Address membershipAddress, IBlockVerifier blockVerifier, Func deleteDraftFunction, @@ -30,7 +30,7 @@ internal sealed partial class RevisionDraft( public PgpPrivateKey FileKey { get; } = fileKey; public PgpSessionKey ContentKey { get; } = contentKey; public PgpPrivateKey SigningKey { get; } = signingKey; - public ReadOnlyMemory? HashKey { get; } = hashKey; + public ReadOnlyMemory? ParentHashKey { get; } = parentHashKey; public Address MembershipAddress { get; } = membershipAddress; public IBlockVerifier BlockVerifier { get; } = blockVerifier; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 8d7a561f..033c6112 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -102,17 +102,32 @@ public async ValueTask WriteAsync( var sha1Digest = _draft.Sha1.GetCurrentHash(); - var hashKey = _draft.HashKey - ?? await NodeOperations.GetParentFolderHashKeyAsync(_client, _draft.Uid.NodeUid, cancellationToken).ConfigureAwait(false); - - var request = CreateRevisionUpdateRequest( - metadata, - expectedContentLength, - expectedThumbnailBlockCount, - expectedSha1Provider, - sha1Digest, - hashKey, - signingEmailAddress); + RevisionUpdateRequest request; + + if (metadata is PhotosFileUploadMetadata photoMetadata) + { + var hashKey = _draft.ParentHashKey + ?? await NodeOperations.GetParentFolderHashKeyAsync(_client, _draft.Uid.NodeUid, cancellationToken).ConfigureAwait(false); + + request = CreatePhotosRevisionUpdateRequest( + photoMetadata, + expectedContentLength, + expectedThumbnailBlockCount, + expectedSha1Provider, + sha1Digest, + hashKey, + signingEmailAddress); + } + else + { + request = CreateRevisionUpdateRequest( + metadata, + expectedContentLength, + expectedThumbnailBlockCount, + expectedSha1Provider, + sha1Digest, + signingEmailAddress); + } LogSealingRevision(_draft.Uid); @@ -167,7 +182,6 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( int expectedThumbnailBlockCount, Func>? expectedSha1Provider, byte[] sha1Digest, - ReadOnlyMemory hashKey, string signingEmailAddress) { var manifest = new byte[(_draft.OrderedThumbnailUploadResults.Count + _draft.OrderedContentBlockStates.Count) * SHA256.HashSizeInBytes]; @@ -259,18 +273,35 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( ExtendedAttributes = encryptedExtendedAttributes, }; - if (metadata is PhotosFileUploadMetadata photoMetadata) - { - var captureTime = photoMetadata.CaptureTime ?? metadata.LastModificationTime ?? DateTime.UtcNow; + return request; + } - request.PhotosAttributes = new PhotosAttributesDto - { - CaptureTime = captureTime.UtcDateTime, - ContentHashDigest = HMACSHA256.HashData(hashKey.Span, Encoding.ASCII.GetBytes(Convert.ToHexStringLower(sha1Digest))), - MainPhotoLinkId = photoMetadata.MainPhotoUid?.LinkId, - Tags = photoMetadata.Tags?.ToHashSet() ?? [], - }; - } + private RevisionUpdateRequest CreatePhotosRevisionUpdateRequest( + PhotosFileUploadMetadata metadata, + long expectedContentLength, + int expectedThumbnailBlockCount, + Func>? expectedSha1Provider, + byte[] sha1Digest, + ReadOnlyMemory parentHashKey, + string signingEmailAddress) + { + var request = CreateRevisionUpdateRequest( + metadata, + expectedContentLength, + expectedThumbnailBlockCount, + expectedSha1Provider, + sha1Digest, + signingEmailAddress); + + var captureTime = metadata.CaptureTime ?? metadata.LastModificationTime ?? DateTime.UtcNow; + + request.PhotosAttributes = new PhotosAttributesDto + { + CaptureTime = captureTime.UtcDateTime, + ContentHashDigest = HMACSHA256.HashData(parentHashKey.Span, Encoding.ASCII.GetBytes(Convert.ToHexStringLower(sha1Digest))), + MainPhotoLinkId = metadata.MainPhotoUid?.LinkId, + Tags = metadata.Tags?.ToHashSet() ?? [], + }; return request; } From 5e898ff4abab3fef17f560eb01b9b818976bfefe Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 16 Apr 2026 11:08:33 +0000 Subject: [PATCH 686/791] Update changelog for cs/v0.13.3 --- cs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 952e15cf..c8fd6706 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## cs/v0.13.3 (2026-04-16) + +* Fix failure to upload new revision on single file sharing + ## cs/v0.13.2 (2026-04-07) * Resume continuation only when active From 441103a51b0c1026c235f2aa8723d90e655bf43d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 17 Apr 2026 05:52:46 +0000 Subject: [PATCH 687/791] Add experimental iterate by uids for albums and shared with me albums --- js/sdk/src/interface/photos.ts | 10 +++++-- js/sdk/src/internal/photos/albumsManager.ts | 8 ++++++ js/sdk/src/internal/photos/nodes.test.ts | 7 +++++ js/sdk/src/internal/photos/nodes.ts | 7 +++++ js/sdk/src/internal/sharing/sharingAccess.ts | 2 +- js/sdk/src/protonDrivePhotosClient.ts | 30 ++++++++++++++++++++ 6 files changed, 60 insertions(+), 4 deletions(-) diff --git a/js/sdk/src/interface/photos.ts b/js/sdk/src/interface/photos.ts index b9e68f2f..2c7d8fce 100644 --- a/js/sdk/src/interface/photos.ts +++ b/js/sdk/src/interface/photos.ts @@ -1,5 +1,5 @@ -import { Result } from "./result"; -import { DegradedNode, NodeEntity, NodeType, MissingNode } from "./nodes"; +import { Result } from './result'; +import { DegradedNode, NodeEntity, NodeType, MissingNode } from './nodes'; /** * Node representing a photo or album for Photos SDK. @@ -95,4 +95,8 @@ export type AlbumAttributes = { * UID of the cover photo node of the album. */ coverPhotoNodeUid?: string; -} + /** + * Timestamp of the last activity in the album. + */ + lastActivityTime: Date; +}; diff --git a/js/sdk/src/internal/photos/albumsManager.ts b/js/sdk/src/internal/photos/albumsManager.ts index 06afb2cf..c6b9e199 100644 --- a/js/sdk/src/internal/photos/albumsManager.ts +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -49,6 +49,14 @@ export class AlbumsManager { yield* batchLoading.loadRest(); } + async *iterateAlbumUids(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.photoShares.getRootIDs(); + + for await (const album of this.apiService.iterateAlbums(volumeId, signal)) { + yield album.albumUid; + } + } + async *iterateAlbum(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator { yield* this.apiService.iterateAlbumChildren(albumNodeUid, signal); } diff --git a/js/sdk/src/internal/photos/nodes.test.ts b/js/sdk/src/internal/photos/nodes.test.ts index 759e596e..cd4cb1ce 100644 --- a/js/sdk/src/internal/photos/nodes.test.ts +++ b/js/sdk/src/internal/photos/nodes.test.ts @@ -47,6 +47,7 @@ function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) { Album: { PhotoCount: 1, CoverLinkID: 'coverLinkId', + LastActivityTime: 1700002000, }, Folder: null, ...overrides, @@ -121,6 +122,7 @@ describe('PhotosNodesAPIService', () => { expect(nodes[0].album).toBeDefined(); expect(nodes[0].album?.photoCount).toEqual(1); expect(nodes[0].album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId'); + expect(nodes[0].album?.lastActivityTime).toEqual(new Date(1700002000 * 1000)); }); it('should convert photo (type 2) to photo node with photo attributes', async () => { @@ -170,6 +172,7 @@ describe('PhotosNodesCache', () => { album: { photoCount: 1, coverPhotoNodeUid: 'volumeId~coverLinkId', + lastActivityTime: '2023-11-15T10:33:20.000Z', }, }); @@ -183,6 +186,8 @@ describe('PhotosNodesCache', () => { expect(node.album).toBeDefined(); expect(node.album?.photoCount).toEqual(1); expect(node.album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId'); + expect(node.album?.lastActivityTime).toBeInstanceOf(Date); + expect(node.album?.lastActivityTime).toEqual(new Date('2023-11-15T10:33:20.000Z')); }); it('should handle node without photo attributes', () => { @@ -323,6 +328,7 @@ describe('PhotosNodesAccess', () => { album: { photoCount: 1, coverPhotoNodeUid: 'volumeId~coverLinkId', + lastActivityTime: new Date('2023-11-15T10:33:20.000Z'), }, }; @@ -336,6 +342,7 @@ describe('PhotosNodesAccess', () => { expect(parsedNode.album).toBeDefined(); expect(parsedNode.album?.photoCount).toEqual(1); expect(parsedNode.album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId'); + expect(parsedNode.album?.lastActivityTime).toEqual(new Date('2023-11-15T10:33:20.000Z')); }); }); }); diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index ee403323..cfc629c8 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -83,6 +83,7 @@ export class PhotosNodesAPIService extends NodeAPIServiceBase< coverPhotoNodeUid: link.Album.CoverLinkID ? makeNodeUid(volumeId, link.Album.CoverLinkID) : undefined, + lastActivityTime: new Date(link.Album.LastActivityTime * 1000), }, encryptedCrypto: { ...baseCryptoNodeMetadata, @@ -143,6 +144,12 @@ export class PhotosNodesCache extends NodesCacheBase { additionTime: new Date(album.additionTime), })), }, + album: !node.album + ? undefined + : { + ...node.album, + lastActivityTime: new Date(node.album.lastActivityTime), + }, } as DecryptedPhotoNode; } } diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 4a3bbe42..8e761c8c 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -83,7 +83,7 @@ export class SharingAccess { setCache: (nodeUids: string[]) => Promise, signal?: AbortSignal, ): AsyncGenerator { - const loadedNodeUids = []; + const loadedNodeUids: string[] = []; const batchLoading = new BatchLoading({ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), batchSize: BATCH_LOADING_SIZE, diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index d238b012..b91d5dea 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -33,6 +33,7 @@ import { getUids, } from './transformers'; import { DriveAPIService } from './internal/apiService'; +import { makeNodeUid } from './internal/uids'; import { initDownloadModule } from './internal/download'; import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; import { @@ -75,6 +76,12 @@ export class ProtonDrivePhotosClient { * See `ProtonDriveClient.experimental.getNodeUrl` for more information. */ getNodeUrl: (nodeUid: NodeOrUid) => Promise; + /** + * Iterates albums sorted by last activity time (most recent first). + * + * @param signal - An optional abort signal to cancel the operation. + */ + iterateAlbumUids: (signal?: AbortSignal) => AsyncGenerator; }; constructor({ @@ -175,6 +182,10 @@ export class ProtonDrivePhotosClient { this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); return this.nodes.access.getNodeUrl(getUid(nodeUid)); }, + iterateAlbumUids: (signal?: AbortSignal) => { + this.logger.debug('Iterating album UIDs'); + return this.photos.albums.iterateAlbumUids(signal); + }, }; } @@ -208,6 +219,25 @@ export class ProtonDrivePhotosClient { return this.events.subscribeToCoreEvents(callback); } + /** + * Provides the node UID for the given raw share and node IDs. + * + * This is required only for the internal implementation to provide + * backward compatibility with the old Drive web setup. + * + * If you are having volume ID, use `generateNodeUid` instead. + * + * @deprecated This method is not part of the public API. + * @param shareId - Context share of the node. + * @param nodeId - Node/link ID (not UID). + * @returns The node UID. + */ + async getNodeUid(shareId: string, nodeId: string): Promise { + this.logger.info(`Getting node UID for share ${shareId} and node ${nodeId}`); + const share = await this.photoShares.loadEncryptedShare(shareId); + return makeNodeUid(share.volumeId, nodeId); + } + /** * @returns The root folder to Photos section of the user. */ From 89ee79f5d5494d140a7b1985ee7bcfdcb01a4057 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 17 Apr 2026 05:54:36 +0000 Subject: [PATCH 688/791] Update changelog for js/v0.14.7 --- js/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index cd623e9e..d6ed43f6 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## js/v0.14.7 (2026-04-17) + +* Add experimental iterate by uids for albums and shared with me albums +* Fix verifying signature contexts + ## js/v0.14.6 (2026-04-09) * Correctly catch AbortError in batchLoading From eec6ea7748787b351d09383f37e6058e9c28903f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 17 Apr 2026 13:20:39 +0000 Subject: [PATCH 689/791] Fix memory leak on SHA1 provision through interop --- .../InteropFileUploader.cs | 23 +++++++++++++------ .../Nodes/Download/RevisionReader.cs | 3 ++- .../Nodes/Upload/RevisionWriter.cs | 3 ++- cs/sdk/src/protos/proton.drive.sdk.proto | 8 +++---- kt/sdk/src/main/jni/proton_drive_sdk.c | 7 +++--- .../internal/ProtonDriveSdkNativeClient.kt | 16 ++++++------- .../Uploads/UploadOperation.swift | 22 +++++++++++++----- .../Sources/Plumbing/InternalTypes.swift | 1 - 8 files changed, 52 insertions(+), 31 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 2b6900c6..29867b74 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes.Upload; @@ -28,8 +29,7 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n var progressAction = new InteropAction>(request.ProgressAction); - var expectedSha1Provider = request.HasSha1Function ? - CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; + var expectedSha1Provider = request.HasSha1Function ? CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; var uploadController = uploader.UploadFromStream( stream, @@ -58,8 +58,7 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint var progressAction = new InteropAction>(request.ProgressAction); - var expectedSha1Provider = request.HasSha1Function ? - CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; + var expectedSha1Provider = request.HasSha1Function ? CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; var uploadController = uploader.UploadFromFile( request.FilePath, @@ -82,11 +81,21 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint internal static Func> CreateSha1Provider(nint bindingsHandle, long functionPointer) { - var function = new InteropFunction>(functionPointer); return () => { - var result = function.Invoke(bindingsHandle); - return result.ToArray(); + var sha1Buffer = new byte[SHA1.HashSizeInBytes]; + + unsafe + { + fixed (byte* sha1BufferPointer = sha1Buffer) + { + var function = new InteropAction>(functionPointer); + + function.Invoke(bindingsHandle, new InteropArray(sha1BufferPointer, sha1Buffer.Length)); + } + } + + return sha1Buffer; }; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 2d53c0a4..a38f373e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -148,7 +148,8 @@ private static bool IsResumableError(Exception ex) { return ex is not DataIntegrityException and not ProtonApiException { TransportCode: >= 400 and < 500 } - and not CompletedDownloadManifestVerificationException; + and not CompletedDownloadManifestVerificationException + and not InvalidOperationException; } private async Task WriteNextBlockToOutputAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 033c6112..9df6972a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -173,7 +173,8 @@ private static bool IsResumableError(Exception ex) { return ex is not ProtonApiException { TransportCode: >= 400 and < 500 } and not NodeWithSameNameExistsException - and not IntegrityException; + and not IntegrityException + and not InvalidOperationException; } private RevisionUpdateRequest CreateRevisionUpdateRequest( diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 36834b94..8f5c05bb 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -304,7 +304,7 @@ message UploadFromStreamRequest { int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; int64 cancel_action = 6; // Optional, C signature: void on_cancel(intptr_t bindings_handle); Signals the bindings to cancel the current stream operation. - int64 sha1_function = 7; // Optional - C signature: ByteArray get_sha1(intptr_t bindings_handle); + int64 sha1_function = 7; // Optional - C signature: void write_expected_sha1(intptr_t bindings_handle, ByteArray output_buffer); writes SHA-1 digest bytes into output_buffer (20 bytes). } // The response value must be an Int64Value carrying a handle to an instance of UploadController. @@ -314,7 +314,7 @@ message UploadFromFileRequest { string file_path = 3; int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; - int64 sha1_function = 7; // Optional - C signature: ByteArray get_sha1(intptr_t bindings_handle); + int64 sha1_function = 7; // Optional - C signature: void write_expected_sha1(intptr_t bindings_handle, ByteArray output_buffer); writes SHA-1 digest bytes into output_buffer (20 bytes). } // The response must not have a value. @@ -754,7 +754,7 @@ message DrivePhotosClientUploadFromStreamRequest { int64 read_action = 3; // C signature: void handle_stream_operation(intptr_t bindings_handle, ByteArray buffer, intptr_t sdk_handle); int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; - int64 sha1_function = 7; // Optional - C signature: ByteArray get_sha1(intptr_t bindings_handle); + int64 sha1_function = 7; // Optional - C signature: void write_expected_sha1(intptr_t bindings_handle, ByteArray output_buffer); writes SHA-1 digest bytes into output_buffer (20 bytes). } // The response value must be an Int64Value carrying a handle to an instance of UploadController. @@ -764,7 +764,7 @@ message DrivePhotosClientUploadFromFileRequest { string file_path = 3; int64 progress_action = 4; // See array_action in C header file for signature int64 cancellation_token_source_handle = 5; - int64 sha1_function = 7; // Optional - C signature: ByteArray get_sha1(intptr_t bindings_handle); + int64 sha1_function = 7; // Optional - C signature: void write_expected_sha1(intptr_t bindings_handle, ByteArray output_buffer); writes SHA-1 digest bytes into output_buffer (20 bytes). } // The response must not have a value. diff --git a/kt/sdk/src/main/jni/proton_drive_sdk.c b/kt/sdk/src/main/jni/proton_drive_sdk.c index 5aaf6af4..7ac9baa9 100644 --- a/kt/sdk/src/main/jni/proton_drive_sdk.c +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -117,10 +117,11 @@ long onFeatureEnabled( return pushDataToLongMethod(bindings_handle, value, "onFeatureEnabled"); } -ByteArray onSha1( - intptr_t bindings_handle +void onSha1( + intptr_t bindings_handle, + ByteArray output ) { - return callByteBufferMethod(bindings_handle, "onSha1"); + pushDataToVoidMethod(bindings_handle, output, "onSha1"); } jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getReadPointer( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt index 5e621ed8..97cd4769 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -220,18 +220,18 @@ class ProtonDriveSdkNativeClient internal constructor( } @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI - fun onSha1(): ByteBuffer = onFunction(operation = "sha1Provider") { + fun onSha1(output: ByteBuffer): Unit = onFunction(operation = "sha1Provider") { runCatching { - sha1Provider().let { sha1 -> - ByteBuffer.allocateDirect(sha1.size).apply { - put(sha1) - flip() - } + val sha1 = sha1Provider() + if (output.capacity() < sha1.size) { + logger(WARN, "SHA1 output buffer too small: ${output.capacity()} < ${sha1.size}") + return@onFunction } - }.getOrElse { error -> + output.put(sha1) + Unit + }.onFailure { error -> logger(WARN, "Cannot get expected SHA1") logger(WARN, error.stackTraceToString()) - ByteBuffer.allocateDirect(0) } } diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift index 8e120ed6..3ad34889 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import CProtonDriveSDK @@ -258,16 +259,25 @@ final class UploadOperationState: Sendable { } } -let cExpectedSha1CallbackForUpload: CCallbackWithByteArrayReturn = { statePointer in +let cExpectedSha1CallbackForUpload: CCallback = { statePointer, byteArray in typealias BoxType = BoxedCompletionBlock> guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { - assertionFailure("cExpectedSha1ProviderCallback.statePointer is nil") - return ByteArray(pointer: nil, length: 0) + assertionFailure("cExpectedSha1CallbackForUpload.statePointer is nil") + return } let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) - guard let expectedSHA1 = stateTypedPointer.takeUnretainedValue().state.value?.expectedSHA1 else { - return ByteArray(pointer: nil, length: 0) + guard + let expectedSHA1 = stateTypedPointer.takeUnretainedValue().state.value?.expectedSHA1, + let destBase = byteArray.pointer + else { return } + + let dest = UnsafeMutableRawPointer(mutating: destBase) + let outLen = Int(byteArray.length) + let n = min(outLen, expectedSHA1.count) + expectedSHA1.withUnsafeBytes { src in + if let p = src.baseAddress { + dest.copyMemory(from: p, byteCount: n) + } } - return ByteArray(data: expectedSHA1) } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift index 5a38fa28..6f5e19a3 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -27,7 +27,6 @@ extension ObjectHandle { typealias CCallback = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray) -> Void typealias CCallbackWithoutByteArray = @convention(c) (_ statePointer: Int) -> Void typealias CCallbackWithIntReturn = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray) -> Int32 -typealias CCallbackWithByteArrayReturn = @convention(c) (_ statePointer: Int) -> ByteArray typealias CCallbackWithCallbackPointer = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray, _ callbackPointer: Int) -> Void typealias CCallbackWithCallbackPointerAndObjectPointerReturn = @convention(c) (_ statePointer: Int, _ byteArray: ByteArray, _ callbackPointer: Int) -> Int From 61b600ef996567eaed6ccb5495b0230092584ec9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Apr 2026 05:03:23 +0000 Subject: [PATCH 690/791] i18n(weekly-mr): Upgrade translations from crowdin (eec6ea77). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/es_ES.json | 6 ++++++ js/sdk/locales/nl_NL.json | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index b378168f..db2b676f 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "07fdc384813cccde3eedbeda3aac6e4709de95ae" + "locale": "d15557efd9078dcb39b27bfef965f543bb169bcf" } \ No newline at end of file diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index bdcd007e..99542e0f 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -174,6 +174,9 @@ "Node not found": [ "Nodo no encontrado" ], + "Only admins can convert non-Proton invitations": [ + "Solo los administradores pueden convertir invitaciones externas a Proton" + ], "Operation aborted": [ "Operación cancelada" ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Algunas partes del archivo no se han podido cargar." ], + "The node is not shared anymore": [ + "Este elemento ya no está compartido" + ], "Thumbnail not found": [ "Miniatura no encontrada" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index f00d5c13..5bcc0cf0 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -174,6 +174,9 @@ "Node not found": [ "Node niet gevonden" ], + "Only admins can convert non-Proton invitations": [ + "Alleen beheerders kunnen uitnodigingen omzetten die niet van Proton zijn." + ], "Operation aborted": [ "Handeling afgebroken" ], @@ -216,6 +219,9 @@ "Some file parts failed to upload": [ "Sommige bestandsonderdelen konden niet worden geüpload" ], + "The node is not shared anymore": [ + "De node wordt niet langer gedeeld" + ], "Thumbnail not found": [ "Miniatuur niet gevonden" ], From df8decb541c2c16dded00740b0915337041bf99f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 17 Apr 2026 18:26:17 +0200 Subject: [PATCH 691/791] Add get node for Kotlin drive client --- .../InteropMessageHandler.cs | 3 +++ .../InteropProtonDriveClient.cs | 17 +++++++++++++++++ cs/sdk/src/protos/proton.drive.sdk.proto | 8 ++++++++ .../me/proton/drive/sdk/ProtonPhotosClient.kt | 2 -- .../me/proton/drive/sdk/ProtonSdkClient.kt | 1 + .../sdk/internal/InteropProtonDriveClient.kt | 15 +++++++++++++++ .../drive/sdk/internal/JniProtonDriveClient.kt | 9 +++++++++ 7 files changed, 53 insertions(+), 2 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs index e468012a..091b784b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -71,6 +71,9 @@ public static async void OnRequestReceived(InteropArray requestBytes, nint Request.PayloadOneofCase.DriveClientGetMyFilesFolder => await InteropProtonDriveClient.HandleGetMyFilesFolderAsync(request.DriveClientGetMyFilesFolder).ConfigureAwait(false), + Request.PayloadOneofCase.DriveClientGetNode + => await InteropProtonDriveClient.HandleGetNodeAsync(request.DriveClientGetNode).ConfigureAwait(false), + Request.PayloadOneofCase.UploadFromStream => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, bindingsHandle), diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index eb3ee3a9..b9c24af5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -315,6 +315,23 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto return null; } + public static async ValueTask HandleGetNodeAsync(DriveClientGetNodeRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var client = Interop.GetFromHandle(request.ClientHandle); + + var nodeResult = await client.GetNodeAsync( + NodeUid.Parse(request.NodeUid), + cancellationToken).ConfigureAwait(false); + + if (nodeResult == null) + { + return null; + } + + return ConvertToNodeResult(nodeResult.Value); + } + public static async ValueTask HandleEmptyTrashAsync(DriveClientEmptyTrashRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 8f5c05bb..a80225c8 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -26,6 +26,7 @@ message Request { DriveClientEnumerateTrashRequest drive_client_enumerate_trash = 1014; DriveClientEmptyTrashRequest drive_client_empty_trash = 1015; DriveClientEnumerateThumbnailsRequest drive_client_enumerate_thumbnails = 1016; + DriveClientGetNodeRequest drive_client_get_node = 1017; UploadFromStreamRequest upload_from_stream = 1100; UploadFromFileRequest upload_from_file = 1101; @@ -448,6 +449,13 @@ message DriveClientGetMyFilesFolderRequest { int64 cancellation_token_source_handle = 2; } +// The response message must be of type NodeResult (nullable). +message DriveClientGetNodeRequest { + int64 client_handle = 1; + string node_uid = 2; + int64 cancellation_token_source_handle = 3; +} + message FolderChildrenList { repeated NodeResult children = 1; } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt index 2c40e788..850a504b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -1,7 +1,6 @@ package me.proton.drive.sdk import kotlinx.coroutines.flow.Flow -import me.proton.drive.sdk.entity.NodeResult import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.PhotosTimelineItem import me.proton.drive.sdk.entity.PhotosUploaderRequest @@ -9,7 +8,6 @@ import kotlin.time.Duration interface ProtonPhotosClient : ProtonSdkClient { fun enumerateTimeline(): Flow - suspend fun getNode(nodeUid: NodeUid): NodeResult? suspend fun downloader(photoUid: NodeUid, timeout: Duration): Downloader suspend fun uploader(request: PhotosUploaderRequest, timeout: Duration): Uploader } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt index cd1b201f..06c5535b 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt @@ -9,6 +9,7 @@ import me.proton.drive.sdk.entity.ThumbnailType interface ProtonSdkClient : AutoCloseable { fun enumerateThumbnails(nodeUids: List, type: ThumbnailType): Flow + suspend fun getNode(nodeUid: NodeUid): NodeResult? suspend fun trashNodes(nodeUids: List): List suspend fun deleteNodes(nodeUids: List): List suspend fun restoreNodes(nodeUids: List): List diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt index 9aa3aef1..a08a6c6f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt @@ -33,9 +33,11 @@ import proton.drive.sdk.driveClientEnumerateThumbnailsRequest import proton.drive.sdk.driveClientEnumerateTrashRequest import proton.drive.sdk.driveClientGetAvailableNameRequest import proton.drive.sdk.driveClientGetMyFilesFolderRequest +import proton.drive.sdk.driveClientGetNodeRequest import proton.drive.sdk.driveClientRenameRequest import proton.drive.sdk.driveClientRestoreNodesRequest import proton.drive.sdk.driveClientTrashNodesRequest +import proton.drive.sdk.drivePhotosClientGetNodeRequest import java.time.Instant import kotlin.time.Duration @@ -150,6 +152,19 @@ internal class InteropProtonDriveClient internal constructor( } } + override suspend fun getNode( + nodeUid: NodeUid, + ): NodeResult? = cancellationCoroutineScope { source -> + log(DEBUG, "getNode") + bridge.getNode( + driveClientGetNodeRequest { + this.nodeUid = nodeUid.value + clientHandle = handle + cancellationTokenSourceHandle = source.handle + } + )?.toEntity() + } + override suspend fun trashNodes( nodeUids: List, ): List = cancellationCoroutineScope { source -> diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index 75a1f917..9688ecc3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -3,6 +3,7 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.ProducerScope +import me.proton.drive.sdk.converter.NodeResultConverter import me.proton.drive.sdk.converter.NodeResultListResponseConverter import me.proton.drive.sdk.converter.FolderNodeConverter import me.proton.drive.sdk.entity.ClientCreateRequest @@ -11,6 +12,7 @@ import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.StringResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback import me.proton.drive.sdk.extension.asCallback +import me.proton.drive.sdk.extension.asNullableCallback import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.driveClientCreateFromSessionRequest @@ -117,6 +119,13 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { driveClientGetMyFilesFolder = request } + suspend fun getNode( + request: ProtonDriveSdk.DriveClientGetNodeRequest, + ): ProtonDriveSdk.NodeResult? = + executeOnce("getNode", NodeResultConverter().asNullableCallback) { + driveClientGetNode = request + } + suspend fun enumerateFolderChildren( coroutineScope: CoroutineScope, request: ProtonDriveSdk.DriveClientEnumerateFolderChildrenRequest, From 29f5e519d7e8028bbb453d290b2e7630db15ef57 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Apr 2026 09:47:55 +0000 Subject: [PATCH 692/791] Improve download initialization speed by parallelizing some server round-trips --- .../Nodes/RevisionOperations.cs | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index a93aa240..ce90cb80 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -27,23 +27,31 @@ internal static async ValueTask CreateDownloadStateAsync( Action releaseBlockListingAction, CancellationToken cancellationToken) { - var fileSecretsResult = await FileOperations.GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - - var (key, contentKey) = fileSecretsResult.TryGetValueElseError(out var fileSecrets, out var degradedFileSecrets) - ? (fileSecrets.Key, fileSecrets.ContentKey) - : (degradedFileSecrets.Key ?? throw new InvalidOperationException($"Node key not available for file {revisionUid.NodeUid}"), - degradedFileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}")); - var (fileUid, revisionId) = revisionUid; - var revisionResponse = await client.Api.Files.GetRevisionAsync( + var secretsTask = FileOperations.GetSecretsAsync( + client, + revisionUid.NodeUid, + cancellationToken).AsTask(); + + var revisionTask = client.Api.Files.GetRevisionAsync( fileUid.VolumeId, fileUid.LinkId, revisionId, RevisionReader.MinBlockIndex, RevisionReader.DefaultBlockPageSize, withoutBlockUrls: false, - cancellationToken).ConfigureAwait(false); + cancellationToken).AsTask(); + + await Task.WhenAll(secretsTask, revisionTask).ConfigureAwait(false); + + var fileSecretsResult = await secretsTask.ConfigureAwait(false); + var revisionResponse = await revisionTask.ConfigureAwait(false); + + var (key, contentKey) = fileSecretsResult.TryGetValueElseError(out var fileSecrets, out var degradedFileSecrets) + ? (fileSecrets.Key, fileSecrets.ContentKey) + : (degradedFileSecrets.Key ?? throw new InvalidOperationException($"Node key not available for file {revisionUid.NodeUid}"), + degradedFileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}")); releaseBlockListingAction.Invoke(1); From 4348a42043adc2326111eb74b7194f3d019ffd42 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Apr 2026 10:32:54 +0000 Subject: [PATCH 693/791] Update changelog for cs/v0.13.4 --- cs/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index c8fd6706..90725903 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## cs/v0.13.4 (2026-04-20) + +* Improve download initialization speed by parallelizing some server round-trips +* Add get node for Kotlin drive client +* Fix memory leak on SHA1 provision through interop + ## cs/v0.13.3 (2026-04-16) * Fix failure to upload new revision on single file sharing From 431c97300ef3b0ab9f85f93bb803dbc6bc265035 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Apr 2026 16:24:55 +0200 Subject: [PATCH 694/791] Ensure expected SHA1 provider is called only once during upload --- .../InteropProtonDriveClient.cs | 6 +++--- .../Nodes/Upload/FileUploader.cs | 14 ++++++++------ .../Nodes/Upload/RevisionWriter.cs | 19 +++++++++---------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index b9c24af5..6cd3c5dc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -173,7 +173,7 @@ public static async ValueTask HandleGetAvailableNameAsync(DriveClientG var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( request.FileUids.Select(NodeUid.Parse), - (Proton.Drive.Sdk.Nodes.ThumbnailType)request.Type, + (Sdk.Nodes.ThumbnailType)request.Type, cancellationToken); await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false)) @@ -350,7 +350,7 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto return null; } - public static NodeResult ConvertToNodeResult(Result result) + public static NodeResult ConvertToNodeResult(Result result) { var nodeResult = new NodeResult(); @@ -441,7 +441,7 @@ private static NodeResultListResponse ConvertToNodeResultListResponse(IReadOnlyD }; } - private static OwnedBy MapOwnedByToProto(Proton.Drive.Sdk.Nodes.OwnedBy? ownedBy) + private static OwnedBy MapOwnedByToProto(Sdk.Nodes.OwnedBy? ownedBy) { if (ownedBy is null) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 0f6df1e1..c2950547 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -65,7 +65,7 @@ public UploadController UploadFromFile( ownsContentStream: true, thumbnails, onProgress, - expectedSha1Provider: expectedSha1Provider, + expectedSha1Provider, cancellationToken); } @@ -123,11 +123,13 @@ private UploadController UploadFromStream( var revisionDraftTaskCompletionSource = new TaskCompletionSource(); + var expectedSha1 = expectedSha1Provider is not null ? new Lazy>(expectedSha1Provider) : null; + var uploadFunction = (CancellationToken ct) => UploadFromStreamAsync( contentStream, thumbnails, progress => onProgress?.Invoke(progress, FileSize), - expectedSha1Provider, + expectedSha1, revisionDraftTaskCompletionSource, ct); @@ -174,7 +176,7 @@ private async Task UploadFromStreamAsync( Stream contentStream, IEnumerable thumbnails, Action? onProgress, - Func>? expectedSha1Provider, + Lazy>? expectedSha1, TaskCompletionSource revisionDraftTaskCompletionSource, CancellationToken cancellationToken) { @@ -190,7 +192,7 @@ await UploadAsync( contentStream, thumbnails, onProgress, - expectedSha1Provider, + expectedSha1, cancellationToken).ConfigureAwait(false); await UpdateActiveRevisionInCacheAsync(revisionDraft.Uid, contentStream.Length, cancellationToken).ConfigureAwait(false); @@ -229,7 +231,7 @@ private async ValueTask UploadAsync( Stream contentStream, IEnumerable thumbnails, Action? onProgress, - Func>? expectedSha1Provider, + Lazy>? expectedSha1, CancellationToken cancellationToken) { using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionDraft, ReleaseBlocks, cancellationToken).ConfigureAwait(false); @@ -237,7 +239,7 @@ private async ValueTask UploadAsync( await revisionWriter.WriteAsync( contentStream, FileSize, - expectedSha1Provider, + expectedSha1, thumbnails, _metadata, onProgress, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 9df6972a..ba724d6f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -45,7 +45,7 @@ internal RevisionWriter( public async ValueTask WriteAsync( Stream contentStream, long expectedContentLength, - Func>? expectedSha1Provider, + Lazy>? expectedSha1, IEnumerable thumbnails, FileUploadMetadata metadata, Action? onProgress, @@ -113,7 +113,7 @@ public async ValueTask WriteAsync( photoMetadata, expectedContentLength, expectedThumbnailBlockCount, - expectedSha1Provider, + expectedSha1, sha1Digest, hashKey, signingEmailAddress); @@ -124,7 +124,7 @@ public async ValueTask WriteAsync( metadata, expectedContentLength, expectedThumbnailBlockCount, - expectedSha1Provider, + expectedSha1, sha1Digest, signingEmailAddress); } @@ -181,7 +181,7 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( FileUploadMetadata metadata, long expectedContentLength, int expectedThumbnailBlockCount, - Func>? expectedSha1Provider, + Lazy>? expectedSha1, byte[] sha1Digest, string signingEmailAddress) { @@ -234,14 +234,13 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( } var checksumVerified = false; - if (expectedSha1Provider is not null) + if (expectedSha1 is not null) { - var expectedSha1 = expectedSha1Provider.Invoke(); - if (!expectedSha1.Span.SequenceEqual(sha1Digest)) + if (!expectedSha1.Value.Span.SequenceEqual(sha1Digest)) { throw new ChecksumMismatchIntegrityException( actualChecksum: sha1Digest, - expectedChecksum: expectedSha1.ToArray()); + expectedChecksum: expectedSha1.Value.ToArray()); } checksumVerified = true; @@ -281,7 +280,7 @@ private RevisionUpdateRequest CreatePhotosRevisionUpdateRequest( PhotosFileUploadMetadata metadata, long expectedContentLength, int expectedThumbnailBlockCount, - Func>? expectedSha1Provider, + Lazy>? expectedSha1, byte[] sha1Digest, ReadOnlyMemory parentHashKey, string signingEmailAddress) @@ -290,7 +289,7 @@ private RevisionUpdateRequest CreatePhotosRevisionUpdateRequest( metadata, expectedContentLength, expectedThumbnailBlockCount, - expectedSha1Provider, + expectedSha1, sha1Digest, signingEmailAddress); From 80fccdd28d2d20b1bea1421c973da0172427db6c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Apr 2026 09:56:18 +0000 Subject: [PATCH 695/791] Add encrypted credentials and cache --- .gitignore | 2 ++ js/sdk/package-lock.json | 8 ++++++++ js/sdk/package.json | 1 + js/sdk/typings/index.d.ts | 2 -- 4 files changed, 11 insertions(+), 2 deletions(-) delete mode 100644 js/sdk/typings/index.d.ts diff --git a/.gitignore b/.gitignore index 55fa8a87..729beb07 100644 --- a/.gitignore +++ b/.gitignore @@ -16,10 +16,12 @@ dist # JS CLI js/cli/proton-drive auth.txt +auth-session.json cache*.sqlite *.log *.bun-build config.json +*.map # IDEs .vs diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index fdd5008b..9fd03091 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -16,6 +16,7 @@ "devDependencies": { "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", + "@types/bcryptjs": "^2.4.6", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^8.53.1", @@ -3871,6 +3872,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", diff --git a/js/sdk/package.json b/js/sdk/package.json index d15185da..d626c6cf 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", + "@types/bcryptjs": "^2.4.6", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^8.53.1", diff --git a/js/sdk/typings/index.d.ts b/js/sdk/typings/index.d.ts deleted file mode 100644 index 76fe5848..00000000 --- a/js/sdk/typings/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -// TODO: Problem with importing pmcrypto - md5.js has no typing -declare module '*'; From 14918330218a54149e79fc9c34a2ec483b8722f2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 7 Apr 2026 08:46:36 +0200 Subject: [PATCH 696/791] Add public CLI --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 729beb07..d6aa1e01 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist # JS CLI js/cli/proton-drive +js/cli/proton-drive-internal auth.txt auth-session.json cache*.sqlite From 1b7ee69c24aee061d6d1a62309d011f6e438f53b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 05:58:02 +0000 Subject: [PATCH 697/791] Add thumbnail error handling from API response --- .../Api/Files/ThumbnailBlockError.cs | 15 +++++++++++++++ .../Api/Files/ThumbnailBlockListResponse.cs | 3 +++ .../src/Proton.Drive.Sdk/Nodes/FileOperations.cs | 11 +++++++++++ .../Serialization/DriveApiSerializerContext.cs | 1 + 4 files changed, 30 insertions(+) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockError.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockError.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockError.cs new file mode 100644 index 00000000..11ee8ab1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockError.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailBlockError +{ + [JsonPropertyName("ThumbnailID")] + public required string ThumbnailId { get; init; } + + [JsonPropertyName("Error")] + public required string Error { get; init; } + + [JsonPropertyName("Code")] + public required int Code { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs index ed7cbffd..4c0d5db7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs @@ -7,4 +7,7 @@ internal sealed class ThumbnailBlockListResponse : ApiResponse { [JsonPropertyName("Thumbnails")] public required IReadOnlyList Blocks { get; init; } + + [JsonPropertyName("Errors")] + public required IReadOnlyList Errors { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 9cdc3d3d..24e8d529 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -147,6 +147,17 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( tasks.Enqueue(DownloadThumbnailAsync(client, nodeInfo.ActiveRevisionUid, block, cancellationToken)); } + foreach (var error in response.Errors) + { + if (!thumbnailIds.TryGetValue(error.ThumbnailId, out var nodeInfo)) + { + continue; + } + + processedThumbnailIds.Add(error.ThumbnailId); + yield return new FileThumbnail(nodeInfo.Uid, new ProtonDriveError(error.Error)); + } + // TODO: cancel other thumbnail downloads if one fails while (tasks.TryDequeue(out var task)) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 61be9b6b..19836a01 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -49,6 +49,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(RevisionResponse))] [JsonSerializable(typeof(ThumbnailBlockListRequest))] [JsonSerializable(typeof(ThumbnailBlockListResponse))] +[JsonSerializable(typeof(ThumbnailBlockError))] [JsonSerializable(typeof(MoveSingleLinkRequest))] [JsonSerializable(typeof(MoveMultipleLinksRequest))] [JsonSerializable(typeof(RenameLinkRequest))] From 0aff88f250d68a4e0cd979a62ae74de12eb88f01 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 07:38:46 +0000 Subject: [PATCH 698/791] Prevent encrypted block buffers from leaking via onProgress closure --- js/sdk/src/internal/upload/streamUploader.ts | 60 +++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 80a5721d..90cec184 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -22,6 +22,39 @@ import { UploadManager } from './manager'; */ export const FILE_CHUNK_SIZE = 4 * 1024 * 1024; +/** + * Creates an upload progress callback isolated from the caller's scope. + * + * When a closure is defined inside a function, the JS engine attaches it to + * the entire lexical environment of that function — all variables in scope, + * whether the closure uses them or not. This means an inline `onProgress` + * lambda defined inside `uploadBlockData` would keep `encryptedData` (the + * 4 MB buffer) alive for as long as the HTTP client holds the callback, + * even though the lambda never references `encryptedData`. + * + * By defining this factory at module level, the returned closures only see + * `reported` and `onProgress`. The encrypted data is invisible to them and + * can be garbage collected as soon as the upload completes. + */ +function createProgressCallback(onProgress?: (n: number) => void): { + callback: (uploadedBytes: number) => void; + rollback: () => void; +} { + let reported = 0; + return { + callback: (uploadedBytes: number) => { + reported += uploadedBytes; + onProgress?.(uploadedBytes); + }, + rollback: () => { + if (reported !== 0) { + onProgress?.(-reported); + reported = 0; + } + }, + }; +} + /** * Maximum number of blocks that can be buffered before upload. * This is to prevent using too much memory. @@ -71,7 +104,6 @@ export class StreamUploader { { index?: number; uploadPromise: Promise; - encryptedBlock: EncryptedBlock | EncryptedThumbnail; } >(); protected uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = []; @@ -364,7 +396,6 @@ export class StreamUploader { // Help the garbage collector to clean up the memory. encryptedThumbnail = undefined; }), - encryptedBlock: encryptedThumbnail, }); } @@ -385,7 +416,6 @@ export class StreamUploader { // Help the garbage collector to clean up the memory. encryptedBlock = undefined; }), - encryptedBlock, }); } } @@ -401,8 +431,8 @@ export class StreamUploader { ); logger.info(`Upload started`); - let blockProgress = 0; let attempt = 0; + const { callback: progressCallback, rollback: rollbackProgress } = createProgressCallback(onProgress); while (true) { attempt++; @@ -412,10 +442,7 @@ export class StreamUploader { uploadToken.bareUrl, uploadToken.token, encryptedThumbnail.encryptedData, - (uploadedBytes) => { - blockProgress += uploadedBytes; - onProgress?.(uploadedBytes); - }, + progressCallback, this.abortController.signal, ); this.uploadedThumbnails.push({ @@ -431,10 +458,7 @@ export class StreamUploader { throw error; } - if (blockProgress !== 0) { - onProgress?.(-blockProgress); - blockProgress = 0; - } + rollbackProgress(); // Note: We don't handle token expiration for thumbnails, because // the API requires the thumbnails to be requested with the first @@ -468,8 +492,8 @@ export class StreamUploader { const logger = new LoggerWithPrefix(this.logger, `block ${uploadToken.index}:${uploadToken.token}`); logger.info(`Upload started`); - let blockProgress = 0; let attempt = 0; + const { callback: progressCallback, rollback: rollbackProgress } = createProgressCallback(onProgress); while (true) { if (this.isUploadAborted) { @@ -483,10 +507,7 @@ export class StreamUploader { uploadToken.bareUrl, uploadToken.token, encryptedBlock.encryptedData, - (uploadedBytes) => { - blockProgress += uploadedBytes; - onProgress?.(uploadedBytes); - }, + progressCallback, this.abortController.signal, ); this.uploadedBlocks.push({ @@ -502,10 +523,7 @@ export class StreamUploader { throw error; } - if (blockProgress !== 0) { - onProgress?.(-blockProgress); - blockProgress = 0; - } + rollbackProgress(); if (error instanceof Error && error.name === 'TimeoutError') { logger.warn(`Upload timeout, limiting upload capacity to 1 block`); From cc8c8192268beea4199b53a31931b4fa8345e1b6 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 13:21:33 +0100 Subject: [PATCH 699/791] Report checksum verification --- js/sdk/src/interface/nodes.ts | 1 + js/sdk/src/internal/apiService/driveTypes.ts | 555 ++++++++++-------- js/sdk/src/internal/nodes/apiService.ts | 1 + js/sdk/src/internal/nodes/cache.test.ts | 1 + js/sdk/src/internal/nodes/cryptoService.ts | 1 + js/sdk/src/internal/nodes/interface.ts | 2 + js/sdk/src/internal/nodes/nodesAccess.ts | 4 + js/sdk/src/internal/nodes/nodesRevisions.ts | 8 + js/sdk/src/internal/photos/apiService.ts | 7 +- js/sdk/src/internal/photos/upload.ts | 15 +- js/sdk/src/internal/upload/apiService.ts | 6 + js/sdk/src/internal/upload/manager.ts | 8 +- .../src/internal/upload/smallFileUploader.ts | 3 + .../internal/upload/streamUploader.test.ts | 3 + js/sdk/src/internal/upload/streamUploader.ts | 13 +- 15 files changed, 361 insertions(+), 267 deletions(-) diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index b47c0fa4..13ba13db 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -222,6 +222,7 @@ export type Revision = { claimedModificationTime?: Date; claimedDigests?: { sha1?: string; + sha1Verified: boolean; }; claimedAdditionalMetadata?: object; }; diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 72f6ce99..51b1cb29 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -1735,6 +1735,26 @@ export interface paths { patch?: never; trace?: never; }; + "/drive/photos/volumes/{volumeID}/links/{linkID}/capture-time": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update Photo Capture Time + * @description ONLY updates the clear text photo capture time. Any user with WRITE permissions may update the capture time. + */ + put: operations["put_drive-photos-volumes-{volumeID}-links-{linkID}-capture-time"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/drive/photos/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/xattr": { parameters: { query?: never; @@ -2664,27 +2684,6 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/owner": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Update ownership of a share - * @description Replace the signature and related membership of the share. - * This allows users to change the associated address & key they use for a share, so that they can get rid of it. - */ - post: operations["post_drive-shares-{shareID}-owner"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/drive/shares/{shareID}/property": { parameters: { query?: never; @@ -3331,7 +3330,7 @@ export interface components { schemas: { /** * ProtonResponseCode - * @enum {integer} + * @constant */ ResponseCodeSuccess: 1000; ProtonSuccess: { @@ -3346,17 +3345,17 @@ export interface components { Details: Record; }; DriveConstants: { - /** @enum {integer} */ + /** @constant */ BlockMaxSizeInBytes?: 5300000; - /** @enum {integer} */ + /** @constant */ ThumbnailMaxSizeInBytes?: 65536; - /** @enum {integer} */ + /** @constant */ DraftRevisionLifetimeInSec?: 14400; - /** @enum {integer} */ + /** @constant */ ExtendedAttributesMaxSizeInBytes?: 65535; - /** @enum {integer} */ + /** @constant */ UploadTokenExpirationTimeInSec?: 10800; - /** @enum {integer} */ + /** @constant */ DownloadTokenExpirationTimeInSec?: 1800; }; /** @description An encrypted ID */ @@ -3374,12 +3373,13 @@ export interface components { LinkID: components["schemas"]["Id"]; /** @description Name Hash */ Hash: string; - Name: string; + Name: components["schemas"]["PGPMessage"]; /** * Format: email * @description Email address used for signing name */ NameSignatureEmail: string; + /** @description Passphrase should be unchanged, reusing same session key as previously */ NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Photo content hash */ ContentHash: string; @@ -3402,15 +3402,16 @@ export interface components { Hash: string; NodePassphrase: components["schemas"]["PGPMessage"]; NodePassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description Signature email address used to sign passphrase and name */ SignatureEmail: components["schemas"]["AddressEmail"]; NodeKey: components["schemas"]["PGPPrivateKey"]; /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; + NodeHashKey: components["schemas"]["PGPMessage"]; /** * @description Extended attributes encrypted with link key * @default null */ - XAttr: string | null; + XAttr: components["schemas"]["PGPMessage"] | null; }; CreateAlbumRequestDto: { Locked: boolean; @@ -3439,11 +3440,11 @@ export interface components { Passphrase: components["schemas"]["PGPMessage"]; PassphraseSignature: components["schemas"]["PGPSignature"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: string; + AddressKeyID?: components["schemas"]["Id"] | null; }; LinkDataDto: { /** @description Root folder name */ - Name: string; + Name: components["schemas"]["PGPMessage"]; NodeKey: components["schemas"]["PGPPrivateKey"]; NodePassphrase: components["schemas"]["PGPMessage"]; NodePassphraseSignature: components["schemas"]["PGPSignature"]; @@ -3460,6 +3461,7 @@ export interface components { VolumeState: 1 | 3; ShareReferenceResponseDto: { ShareID: components["schemas"]["Id"]; + /** @description Deprecated, use `ShareID` instead */ ID: components["schemas"]["Id"]; LinkID: components["schemas"]["Id"]; }; @@ -3477,6 +3479,7 @@ export interface components { DownloadedBytes: number; UploadedBytes: number; State: components["schemas"]["VolumeState"]; + /** @description Main share of the volume */ Share: components["schemas"]["ShareReferenceResponseDto"]; Type: components["schemas"]["VolumeType"]; }; @@ -3508,16 +3511,17 @@ export interface components { LinkState: 0 | 1 | 2; FoundDuplicate: { /** @description NameHash of the found duplicate */ - Hash?: string | null; + Hash: string | null; /** @description ContentHash of the found duplicate */ - ContentHash?: string | null; + ContentHash: string | null; + /** @description Can be null if the Link was deleted */ LinkState: components["schemas"]["LinkState"]; /** @description Client defined UID for the draft. Null if no ClientUID passed, or Revision was already committed. */ - ClientUID?: string | null; + ClientUID: string | null; /** @description LinkID, null if deleted */ - LinkID: string; + LinkID: components["schemas"]["Id"] | null; /** @description RevisionID, null if deleted */ - RevisionID: string; + RevisionID: components["schemas"]["Id"] | null; }; FindDuplicatesOutputCollection: { DuplicateHashes: components["schemas"]["FoundDuplicate"][]; @@ -3538,7 +3542,7 @@ export interface components { }; PhotoTagMigrationStatusResponseDto: { Finished: boolean; - Anchor?: components["schemas"]["PhotoTagMigrationDataDto"] | null; + Anchor: components["schemas"]["PhotoTagMigrationDataDto"] | null; /** * ProtonResponseCode * @example 1000 @@ -3552,14 +3556,12 @@ export interface components { PhotoCount: number; LinkID: components["schemas"]["Id"]; VolumeID: components["schemas"]["Id"]; - /** @default null */ ShareID: components["schemas"]["Id"] | null; - /** @default null */ CoverLinkID: components["schemas"]["Id"] | null; }; ListAlbumsResponseDto: { Albums: components["schemas"]["AlbumResponseDto"][]; - AnchorID?: string | null; + AnchorID: string | null; More: boolean; /** * ProtonResponseCode @@ -3604,15 +3606,12 @@ export interface components { RelatedPhotos: components["schemas"]["ListPhotosAlbumRelatedPhotoItemResponseDto"][]; AddedTime: number; IsChildOfAlbum: boolean; - /** - * @description Tags assigned to the photo - * @default [] - */ + /** @description Tags assigned to the photo */ Tags: number[]; }; ListPhotosAlbumResponseDto: { Photos: components["schemas"]["ListPhotosAlbumItemResponseDto"][]; - AnchorID?: string | null; + AnchorID: string | null; More: boolean; /** * ProtonResponseCode @@ -3624,9 +3623,9 @@ export interface components { TransferPhotoLinkInBatchRequestDto: { LinkID: components["schemas"]["Id"]; /** @description Name, reusing same session key as previously. */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; + NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Name hash */ Hash: string; /** @description Current name hash before move operation. Used to prevent race conditions. */ @@ -3673,7 +3672,7 @@ export interface components { }; SharedWithMeResponseDto: { Albums: components["schemas"]["AlbumResponseDto"][]; - AnchorID?: string | null; + AnchorID: string | null; More: boolean; /** * ProtonResponseCode @@ -3692,7 +3691,7 @@ export interface components { NameSignatureEmail?: string | null; OriginalHash?: string | null; /** @description Extended attributes encrypted with link key */ - XAttr?: string | null; + XAttr?: components["schemas"]["PGPMessage"] | null; }; UpdateAlbumRequestDto: { CoverLinkID?: components["schemas"]["Id"] | null; @@ -3715,7 +3714,7 @@ export interface components { UserID: components["schemas"]["Id"]; Token: string; ShareURLID: components["schemas"]["Id"]; - EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + EncryptedUrlPassword: components["schemas"]["PGPMessage"] | null; State: components["schemas"]["BookmarkShareURLState"]; CreateTime: number; ModifyTime: number; @@ -3743,9 +3742,9 @@ export interface components { */ URL?: string | null; /** @description Bare Download URL for the thumbnail */ - BareURL?: string | null; + BareURL: string | null; /** @description Token for the thumbnail URL */ - Token?: string | null; + Token: string | null; }; TokenResponseDto: { /** @@ -3753,6 +3752,7 @@ export interface components { * @example YTZZRH7DA8 */ Token: string; + /** @description Types: Folder - 1, File - 2} */ LinkType: components["schemas"]["NodeType"]; VolumeID: components["schemas"]["Id"]; LinkID: components["schemas"]["Id"]; @@ -3763,7 +3763,7 @@ export interface components { NodeKey: components["schemas"]["PGPPrivateKey"]; Name: components["schemas"]["PGPMessage"]; /** @description Base64 encoded content key packet. Null for folders */ - ContentKeyPacket?: components["schemas"]["BinaryString"] | null; + ContentKeyPacket: components["schemas"]["BinaryString"] | null; /** @example text/plain */ MIMEType: string; /** @@ -3775,24 +3775,17 @@ export interface components { */ Permissions: 4 | 6; /** @description File size, null for folders */ - Size?: number | null; + Size: number | null; /** @description File properties */ - ThumbnailURLInfo?: components["schemas"]["ThumbnailURLInfoResponseDto"] | null; - /** @default null */ + ThumbnailURLInfo: components["schemas"]["ThumbnailURLInfoResponseDto"] | null; NodeHashKey: components["schemas"]["PGPMessage"] | null; - /** - * @description Signature email of the share owner. Only set for a ShareURL with read+write permissions. - * @default null - */ + /** @description Signature email of the share owner. Only set for a ShareURL with read+write permissions. */ SignatureEmail: string | null; - /** - * @description Only set for a ShareURL with read+write permissions. - * @default null - */ + /** @description Only set for a ShareURL with read+write permissions. */ NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; }; BookmarkShareURLInfoResponseDto: { - EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; + EncryptedUrlPassword: components["schemas"]["PGPMessage"] | null; CreateTime: number; Token: components["schemas"]["TokenResponseDto"]; }; @@ -3830,7 +3823,7 @@ export interface components { Passphrase: components["schemas"]["PGPMessage"]; PassphraseSignature: components["schemas"]["PGPSignature"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: string; + AddressKeyID?: components["schemas"]["Id"] | null; /** * @deprecated * @default null @@ -3948,6 +3941,7 @@ export interface components { * @default null */ ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + /** @description Document=1, Sheet=2 */ DocumentType?: components["schemas"]["DocumentType"]; Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ @@ -4218,6 +4212,8 @@ export interface components { * @enum {integer} */ State?: 1; + /** @description Whether the checksum in xattr of the revision content was verified by the client during upload */ + ChecksumVerified?: boolean; /** * @deprecated * @description Revision has a thumbnail @@ -4300,12 +4296,12 @@ export interface components { * @description The share the user has access to that is closest to the root. Delete events do not have it but other events do. * @default null */ - ContextShareID: string | null; + ContextShareID: components["schemas"]["Id"] | null; /** * @description If a file was moved to a different context share, this shows the old, origin share * @default null */ - FromContextShareID: string | null; + FromContextShareID: components["schemas"]["Id"]; /** * @description Optional event data * @default null @@ -4329,12 +4325,12 @@ export interface components { FLAG_RESTORE_REVISION_COMPLETE?: string; /** @description Parent before the move */ FromParentLinkID?: string; - } | null; + }; }; ListEventsResponseDto: { Events: components["schemas"]["EventResponseDto"][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ - EventID: string; + EventID: components["schemas"]["Id"]; /** * @description 1 if there is more to pull, i.e. there are more events than returned in one call * @enum {integer} @@ -4366,7 +4362,7 @@ export interface components { ListEventsV2ResponseDto: { Events: components["schemas"]["EventV2ResponseDto"][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ - EventID: string; + EventID: components["schemas"]["Id"]; /** @description true if there is more to pull, i.e. there are more events than returned in one call */ More: boolean; /** @description true if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync */ @@ -4380,12 +4376,12 @@ export interface components { }; CreateFolderRequestDto: { /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; + NodeHashKey: components["schemas"]["PGPMessage"]; /** * @description Extended attributes encrypted with link key * @default null */ - XAttr: string | null; + XAttr: components["schemas"]["PGPMessage"] | null; Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; @@ -4401,6 +4397,7 @@ export interface components { SignatureAddress: components["schemas"]["AddressEmail"] | null; }; FolderResponseDto: { + /** @description Link ID */ ID: components["schemas"]["Id"]; }; CreateFolderResponseDto: { @@ -4414,12 +4411,12 @@ export interface components { }; CreateFolderRequestDto2: { /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; + NodeHashKey: components["schemas"]["PGPMessage"]; /** * @description Extended attributes encrypted with link key * @default null */ - XAttr: string | null; + XAttr: components["schemas"]["PGPMessage"] | null; Name: components["schemas"]["PGPMessage"]; /** @description File/folder name Hash */ Hash: string; @@ -4451,7 +4448,7 @@ export interface components { ListChildrenResponseDto: { LinkIDs: components["schemas"]["Id"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; + AnchorID: components["schemas"]["Id"] | null; /** @description Indicates if there is a next page of results */ More: boolean; /** @@ -4473,7 +4470,7 @@ export interface components { Hash: string; RevisionID: components["schemas"]["Id"]; LinkID: components["schemas"]["Id"]; - ClientUID?: string | null; + ClientUID: string | null; }; AvailableHashesResponseDto: { AvailableHashes: string[]; @@ -4489,9 +4486,9 @@ export interface components { RelatedPhotoDto: { LinkID: components["schemas"]["Id"]; /** @description Name, reusing same session key as previously. */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; + NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Name hash */ Hash: string; /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ @@ -4500,20 +4497,19 @@ export interface components { PhotosDto: { /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ ContentHash: string; - /** @default [] */ RelatedPhotos: components["schemas"]["RelatedPhotoDto"][]; }; CopyLinkRequestDto: { /** @description Name, reusing same session key as previously. */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; + NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Name hash */ Hash: string; /** @description Volume ID to copy to. */ - TargetVolumeID: string; + TargetVolumeID: components["schemas"]["Id"]; /** @description New parent link ID to copy to. */ - TargetParentLinkID: string; + TargetParentLinkID: components["schemas"]["Id"]; /** * Format: email * @description Signature email address used for signing name. @@ -4544,7 +4540,7 @@ export interface components { * @description Only for legacy folders (signed by the user). Node hash key should be unchanged, just re-signed with the NodeKey. * @default null */ - NodeHashKey: string | null; + NodeHashKey: components["schemas"]["PGPMessage"] | null; }; CopyLinkResponseDto: { LinkID: components["schemas"]["Id"]; @@ -4603,35 +4599,34 @@ export interface components { * Format: email * @description OwnerUser email for regular and photo volumes, null otherwise */ - Email?: string | null; + Email: string | null; /** @description OwnerOrganization name for org. volumes, null otherwise */ - Organization?: string | null; + Organization: string | null; }; LinkDto: { LinkID: components["schemas"]["Id"]; Type: components["schemas"]["NodeType2"]; - ParentLinkID?: components["schemas"]["Id"] | null; + ParentLinkID: components["schemas"]["Id"] | null; State: components["schemas"]["LinkState2"]; CreateTime: number; ModifyTime: number; - TrashTime?: number | null; + TrashTime: number | null; Name: components["schemas"]["PGPMessage"]; - NameHash?: string | null; + NameHash: string | null; NodeKey: components["schemas"]["PGPPrivateKey"]; NodePassphrase: components["schemas"]["PGPMessage"]; NodePassphraseSignature: components["schemas"]["PGPSignature"]; /** Format: email */ - SignatureEmail?: string | null; + SignatureEmail: string | null; /** Format: email */ - NameSignatureEmail?: string | null; + NameSignatureEmail: string | null; OwnedBy: components["schemas"]["OwnedByDto"]; - /** @default null */ DirectPermissions: number | null; }; PhotoDto: { CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id"] | null; - ContentHash?: string | null; + MainPhotoLinkID: components["schemas"]["Id"] | null; + ContentHash: string | null; RelatedPhotosLinkIDs: components["schemas"]["Id"][]; }; /** @@ -4651,6 +4646,8 @@ export interface components { RevisionID: components["schemas"]["Id"]; CreateTime: number; EncryptedSize: number; + /** @description Whether the checksum in xattr of the revision content was verified by the client during upload */ + ChecksumVerified: boolean; ManifestSignature?: components["schemas"]["PGPSignature"] | null; XAttr?: components["schemas"]["PGPMessage"] | null; Thumbnails: components["schemas"]["ThumbnailDto"][]; @@ -4658,7 +4655,7 @@ export interface components { SignatureEmail?: string | null; }; FileDto: { - ActiveRevision?: components["schemas"]["ActiveRevisionDto"] | null; + ActiveRevision: components["schemas"]["ActiveRevisionDto"] | null; TotalEncryptedSize: number; ContentKeyPacket: components["schemas"]["BinaryString"]; MediaType?: string | null; @@ -4684,11 +4681,11 @@ export interface components { /** Format: email */ InviterEmail: string; /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - MemberSharePassphraseKeyPacket: string; + MemberSharePassphraseKeyPacket: components["schemas"]["BinaryString"]; /** @description PGP signature of the member key packet (encrypted) by inviter */ - InviterSharePassphraseKeyPacketSignature: string; + InviterSharePassphraseKeyPacketSignature: components["schemas"]["PGPSignature"]; /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ - InviteeSharePassphraseSessionKeySignature: string; + InviteeSharePassphraseSessionKeySignature: components["schemas"]["PGPSignature"]; }; FileDetailsDto: { Link: components["schemas"]["LinkDto"]; @@ -4701,9 +4698,9 @@ export interface components { */ Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ - Folder: null | null; + Folder: null; /** @default null */ - Album: null | null; + Album: null; }; FolderDto: { NodeHashKey?: components["schemas"]["PGPMessage"] | null; @@ -4720,18 +4717,18 @@ export interface components { */ Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ - File: null | null; + File: null; /** @default null */ - Album: null | null; + Album: null; }; AlbumDto: { Hidden: boolean; Locked: boolean; - CoverLinkID?: components["schemas"]["Id"] | null; + CoverLinkID: components["schemas"]["Id"] | null; LastActivityTime: number; PhotoCount: number; NodeHashKey: components["schemas"]["PGPMessage"]; - XAttr?: components["schemas"]["PGPMessage"] | null; + XAttr: components["schemas"]["PGPMessage"] | null; }; AlbumDetailsDto: { Link: components["schemas"]["LinkDto"]; @@ -4741,9 +4738,9 @@ export interface components { /** @default null */ Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ - File: null | null; + File: null; /** @default null */ - Folder: null | null; + Folder: null; }; LoadLinkDetailsResponseDto: { Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"] | components["schemas"]["AlbumDetailsDto"])[]; @@ -4757,9 +4754,9 @@ export interface components { MoveLinkInBatchRequestDto: { LinkID: components["schemas"]["Id"]; /** @description Name, reusing same session key as previously. */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; + NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Name hash */ Hash: string; /** @@ -4796,9 +4793,9 @@ export interface components { }; MoveLinkRequestDto: { /** @description Name, reusing same session key as previously. */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; + NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Name hash */ Hash: string; ParentLinkID: components["schemas"]["Id"]; @@ -4845,9 +4842,9 @@ export interface components { }; MoveLinkRequestDto2: { /** @description Name, reusing same session key as previously. */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Node passphrase, reusing same session key as previously. */ - NodePassphrase: string; + NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Name hash */ Hash: string; ParentLinkID: components["schemas"]["Id"]; @@ -4877,7 +4874,7 @@ export interface components { }; RenameLinkRequestDto: { /** @description Name, reusing same session key as previously. */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Name hash; ignored/nullable for root-links */ Hash?: string | null; /** @@ -4922,7 +4919,7 @@ export interface components { * @description Main photo LinkID reference. Pass null if none. * @default null */ - MainPhotoLinkID: string | null; + MainPhotoLinkID: components["schemas"]["Id"] | null; /** * @deprecated * @description Deprecated: Clients persist exif information in xAttr instead @@ -4957,7 +4954,7 @@ export interface components { * @description Extended attributes encrypted with link key * @default null */ - XAttr: string | null; + XAttr: components["schemas"]["PGPMessage"] | null; /** @default null */ Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; /** @@ -4977,6 +4974,11 @@ export interface components { * @default null */ State: number | null; + /** + * @description Whether the checksum in xattr of the revision content was verified by the client during upload + * @default false + */ + ChecksumVerified: boolean; }; CreateFileDto: { /** @example text/plain */ @@ -4986,7 +4988,7 @@ export interface components { * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ContentKeyPacketSignature: string | null; + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; /** * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. * @default null @@ -5012,9 +5014,10 @@ export interface components { SignatureAddress: components["schemas"]["AddressEmail"] | null; }; FileResponseDto: { + /** @description Link ID */ ID: components["schemas"]["Id"]; RevisionID: components["schemas"]["Id"]; - ClientUID?: string | null; + ClientUID: string | null; }; CreateDraftFileResponseDto: { File: components["schemas"]["FileResponseDto"]; @@ -5040,6 +5043,7 @@ export interface components { IntendedUploadSize: number | null; }; RevisionResponseDto: { + /** @description Revision ID */ ID: components["schemas"]["Id"]; }; CreateDraftRevisionResponseDto: { @@ -5081,11 +5085,11 @@ export interface components { }; RevisionResponseDto2: { ID: components["schemas"]["Id"]; - ManifestSignature?: components["schemas"]["PGPSignature"] | null; + ManifestSignature: components["schemas"]["PGPSignature"] | null; /** @description Size of revision (in bytes) */ Size: number; State: components["schemas"]["RevisionState"]; - XAttr?: components["schemas"]["PGPMessage"] | null; + XAttr: components["schemas"]["PGPMessage"] | null; /** * @deprecated * @description Flag stating if revision has a thumbnail @@ -5100,7 +5104,9 @@ export interface components { */ ThumbnailSize: number; Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; - ClientUID?: string | null; + /** @description Whether the checksum in xattr of the revision content was verified by the client during upload */ + ChecksumVerified: boolean; + ClientUID: string | null; /** @default null */ CreateTime: number | null; /** @@ -5179,6 +5185,7 @@ export interface components { Code: 1000; }; Verifier: { + /** @description Derived from verificationCode from GET /verification endpoint: base64(xor(verificationCode, padWithZeros(dataPacket, 32))) */ Token: components["schemas"]["BinaryString"]; }; RequestUploadBlockInput: { @@ -5190,7 +5197,7 @@ export interface components { * @description Encrypted PGP Signature of the raw block content. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. * @default null */ - EncSignature: string | null; + EncSignature: components["schemas"]["PGPMessage"] | null; /** * @deprecated * @description Block size in bytes @@ -5201,7 +5208,7 @@ export interface components { * @deprecated * @description sha256 hash of encrypted block, base64 encoded */ - Hash: string; + Hash?: components["schemas"]["BinaryString"] | null; }; RequestUploadThumbnailInput: { Type: components["schemas"]["ThumbnailType"]; @@ -5215,7 +5222,7 @@ export interface components { * @deprecated * @description sha256 hash of encrypted block, base64 encoded */ - Hash: string; + Hash?: components["schemas"]["BinaryString"] | null; }; RequestUploadInput: { LinkID: components["schemas"]["Id"]; @@ -5240,7 +5247,7 @@ export interface components { * @description sha256 hash of thumbnail contents * @default null */ - ThumbnailHash: string | null; + ThumbnailHash: components["schemas"]["BinaryString"] | null; /** * @deprecated * @description Size of thumbnail contents @@ -5303,6 +5310,8 @@ export interface components { FailedItemCount: number; State: components["schemas"]["HealthCheckState"]; }; + /** @enum {string} */ + AbuseDtoCategory: "spam" | "copyright" | "child-abuse" | "stolen-data" | "malware" | "other"; AbuseReportDto: { /** @description Passphrase for reported Link's Node key, unencrypted, as a string, escaped for JSON. */ ResourcePassphrase: string; @@ -5311,8 +5320,7 @@ export interface components { * @example https://drive.proton.me/urls/1F9BKXYDMA#yF7d7bn01GMM */ ShareURL: string; - /** @enum {string} */ - AbuseCategory: "spam" | "copyright" | "child-abuse" | "stolen-data" | "malware" | "other"; + AbuseCategory: components["schemas"]["AbuseDtoCategory"]; /** * @description Full password, including custom part, as string, escaped for JSON * @default @@ -5338,9 +5346,9 @@ export interface components { RevisionID: components["schemas"]["Id"] | null; }; FreshAccountResponseDto: { - EndTime?: number | null; + EndTime: number | null; /** @description Maximum available space for the free upload timer, in bytes (API allows going 10% over limit for zero-rating) */ - Quota?: number | null; + Quota: number | null; /** * ProtonResponseCode * @example 1000 @@ -5351,8 +5359,8 @@ export interface components { ChecklistResponseDto: { /** @description Array of completed checklist items */ Items: string[]; - CreatedAt?: number | null; - ExpiresAt?: number | null; + CreatedAt: number | null; + ExpiresAt: number | null; /** @description User already has reward quota */ UserWasRewarded: boolean; /** @description Client has displayed completed checklist */ @@ -5407,12 +5415,13 @@ export interface components { FavoritePhotoDataDto: { /** @description Name Hash */ Hash: string; - Name: string; + Name: components["schemas"]["PGPMessage"]; /** * Format: email * @description Email address used for signing name */ NameSignatureEmail: string; + /** @description Passphrase should be unchanged, reusing same session key as previously */ NodePassphrase: components["schemas"]["PGPMessage"]; /** @description Photo content hash */ ContentHash: string; @@ -5445,7 +5454,7 @@ export interface components { }; GetMigrationStatusResponseDto: { OldVolumeID: components["schemas"]["Id"]; - NewVolumeID?: components["schemas"]["Id"] | null; + NewVolumeID: components["schemas"]["Id"] | null; /** * ProtonResponseCode * @example 1000 @@ -5496,12 +5505,8 @@ export interface components { Hash: string; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ ContentHash?: string | null; - /** - * @description Tags assigned to the photo - * @default [] - */ + /** @description Tags assigned to the photo */ Tags: number[]; - /** @default [] */ RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; }; PhotoListingResponse: { @@ -5517,6 +5522,8 @@ export interface components { RevisionID: components["schemas"]["Id"]; CreateTime: number; EncryptedSize: number; + /** @description Whether the checksum in xattr of the revision content was verified by the client during upload */ + ChecksumVerified: boolean; ManifestSignature?: components["schemas"]["PGPSignature"] | null; XAttr?: components["schemas"]["PGPMessage"] | null; Thumbnails: components["schemas"]["ThumbnailDto"][]; @@ -5530,10 +5537,10 @@ export interface components { AddedTime: number; }; PhotoFileDto: { - ActiveRevision?: components["schemas"]["ActivePhotoRevisionDto"] | null; + ActiveRevision: components["schemas"]["ActivePhotoRevisionDto"] | null; CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id"] | null; - ContentHash?: string | null; + MainPhotoLinkID: components["schemas"]["Id"] | null; + ContentHash: string | null; RelatedPhotosLinkIDs: components["schemas"]["Id"][]; Albums: components["schemas"]["PhotoAlbumDto"][]; /** @description Will be empty if the user is not the owner. */ @@ -5554,7 +5561,7 @@ export interface components { */ Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ - Album: null | null; + Album: null; }; PhotoAlbumDetailsDto: { Link: components["schemas"]["LinkDto"]; @@ -5564,7 +5571,7 @@ export interface components { /** @default null */ Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ - Photo: null | null; + Photo: null; }; PhotoRootFolderDetailsDto: { Link: components["schemas"]["LinkDto"]; @@ -5577,9 +5584,9 @@ export interface components { */ Membership: components["schemas"]["MembershipDto"] | null; /** @default null */ - Photo: null | null; + Photo: null; /** @default null */ - Album: null | null; + Album: null; }; LoadPhotoVolumeLinkDetailsResponseDto: { Links: (components["schemas"]["PhotoDetailsDto"] | components["schemas"]["PhotoAlbumDetailsDto"] | components["schemas"]["PhotoRootFolderDetailsDto"])[]; @@ -5593,6 +5600,10 @@ export interface components { RemoveTagsRequestDto: { Tags: components["schemas"]["TagType"][]; }; + UpdatePhotoCaptureTimeRequestDto: { + /** @description Unix timestamp used to determine position in timeline */ + CaptureTime: number; + }; UpdateXAttrRequest: { /** * Format: email @@ -5600,7 +5611,7 @@ export interface components { */ SignatureEmail: string; /** @description Extended attributes encrypted with link key */ - XAttr: string; + XAttr: components["schemas"]["PGPMessage"]; }; AuthShareTokenRequestDto: { ClientEphemeral: components["schemas"]["BinaryString"]; @@ -5624,19 +5635,12 @@ export interface components { UID: string; ServerProof: components["schemas"]["BinaryString"]; Share: components["schemas"]["AuthShareDataResponseDto"]; - /** - * @description Session Access token (present if new session) - * @default null - */ + /** @description Session Access token (present if new session) */ AccessToken: string; - /** - * @description Duration of the session in seconds (present if new session) - * @default null - */ + /** @description Duration of the session in seconds (present if new session) */ ExpiresIn: number; /** * @description Type of token (present if new session) - * @default null * @example Bearer */ TokenType: string; @@ -5685,10 +5689,7 @@ export interface components { /** @deprecated */ IsDoc: boolean; VendorType: components["schemas"]["VendorType"]; - /** - * @description Only set if the user is authenticated AND has direct access to the share already - * @default null - */ + /** @description Only set if the user is authenticated AND has direct access to the share already */ DirectAccess: components["schemas"]["DirectAccessResponseDto"] | null; /** * ProtonResponseCode @@ -5705,12 +5706,17 @@ export interface components { */ SignatureEmail?: components["schemas"]["AddressEmail"] | null; /** @description Extended attributes encrypted with link key */ - XAttr: string; + XAttr: components["schemas"]["PGPMessage"]; /** * @description Photo attributes * @default null */ Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; + /** + * @description Whether the checksum in xattr of the revision content was verified by the client during upload + * @default false + */ + ChecksumVerified: boolean; }; CreateAnonymousDocumentDto: { Name: components["schemas"]["PGPMessage"]; @@ -5733,6 +5739,7 @@ export interface components { * @default null */ ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; + /** @description Document=1, Sheet=2 */ DocumentType?: components["schemas"]["DocumentType"]; }; CreateAnonymousDocumentResponseDto: { @@ -5766,7 +5773,7 @@ export interface components { * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. * @default null */ - ContentKeyPacketSignature: string | null; + ContentKeyPacketSignature: components["schemas"]["PGPSignature"] | null; /** * @description Client unique ID. Useful for marking client's drafts - in case of failure client can recognise its own draft and continue upload. * @default null @@ -5797,7 +5804,7 @@ export interface components { NodePassphraseSignature: components["schemas"]["PGPSignature"]; NodeKey: components["schemas"]["PGPPrivateKey"]; /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ - NodeHashKey: string; + NodeHashKey: components["schemas"]["PGPMessage"]; /** * Format: email * @description Signature email address used to sign passphrase and name @@ -5808,7 +5815,7 @@ export interface components { * @description Extended attributes encrypted with link key * @default null */ - XAttr: string | null; + XAttr: components["schemas"]["PGPMessage"] | null; }; CreateAnonymousFolderResponseDto: { Folder: components["schemas"]["FolderResponseDto"]; @@ -5830,7 +5837,7 @@ export interface components { }; RenameAnonymousLinkRequestDto: { /** @description Name, reusing same session key as previously. */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Name hash */ Hash: string; /** @description Current name hash before move operation. Used to prevent race conditions. */ @@ -5876,10 +5883,10 @@ export interface components { BlockResponseDto: { Index: number; Hash: components["schemas"]["BinaryString"]; - Token?: string | null; + Token: string | null; /** @deprecated */ URL?: string | null; - BareURL?: string | null; + BareURL: string | null; /** * @deprecated * @default null @@ -5897,11 +5904,11 @@ export interface components { LinkID: components["schemas"]["Id"]; /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ CaptureTime: number; - MainPhotoLinkID?: components["schemas"]["Id"] | null; + MainPhotoLinkID: components["schemas"]["Id"] | null; /** @description File name hash */ - Hash?: string | null; + Hash: string | null; /** @description Photo content hash, Hashmac of content using parent folder's hash key */ - ContentHash?: string | null; + ContentHash: string | null; /** @description LinkIDs of related Photos if there are any */ RelatedPhotosLinkIDs: components["schemas"]["Id"][]; /** @@ -5913,7 +5920,7 @@ export interface components { }; DetailedRevisionResponseDto: { Blocks: components["schemas"]["BlockResponseDto"][]; - Photo?: components["schemas"]["PhotoResponseDto"] | null; + Photo: components["schemas"]["PhotoResponseDto"] | null; ID: components["schemas"]["Id"]; ManifestSignature?: components["schemas"]["PGPSignature"] | null; /** @description Size of revision (in bytes) */ @@ -5934,6 +5941,8 @@ export interface components { */ ThumbnailSize: number; Thumbnails: components["schemas"]["ThumbnailResponseDto"][]; + /** @description Whether the checksum in xattr of the revision content was verified by the client during upload */ + ChecksumVerified: boolean; ClientUID?: string | null; /** @default null */ CreateTime: number | null; @@ -6004,12 +6013,12 @@ export interface components { ShareID: components["schemas"]["Id"]; /** @description URL to use to access the ShareURL */ PublicUrl: string; - ExpirationTime?: number | null; - LastAccessTime?: number | null; + ExpirationTime: number | null; + LastAccessTime: number | null; CreateTime: number; MaxAccesses: number; NumAccesses: number; - Name?: components["schemas"]["PGPMessage"] | null; + Name: components["schemas"]["PGPMessage"] | null; CreatorEmail: string; /** * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: @@ -6032,7 +6041,7 @@ export interface components { }; ShareURLContext: { /** @description Share ID of the share highest in the tree with permissions */ - ContextShareID: string; + ContextShareID: components["schemas"]["Id"]; ShareURLs: components["schemas"]["ShareURLResponseDto"][]; /** @description Related link IDs and ancestors up to the share. */ LinkIDs: components["schemas"]["Id"][]; @@ -6081,7 +6090,7 @@ export interface components { Flags: number; SharePassphraseKeyPacket: components["schemas"]["BinaryString"]; /** @description PGP encrypted password. The password is encrypted with the user's address key. */ - Password: string; + Password: components["schemas"]["PGPMessage"]; /** @description Maximum number of times this link can be accessed. 0 for infinite */ MaxAccesses: number; /** @@ -6098,7 +6107,7 @@ export interface components { * @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. * @default null */ - Name: string | null; + Name: components["schemas"]["PGPMessage"] | null; }; UpdateShareURLRequestDto: { /** @description UNIX timestamp after which this link is no longer accessible. Use this or ExpirationDuration for a relative expiration period. Max 90 days from now. */ @@ -6106,7 +6115,7 @@ export interface components { /** @description Number of seconds after which this link is no longer accessible. Maximum 90 days. */ ExpirationDuration?: number | null; /** @description PGP encrypted name. The name is encrypted with the user's address key. The name is only for user convenience. */ - Name: number; + Name?: components["schemas"]["PGPMessage"] | null; /** * @description Permission bitfield, cannot exceed the owner's permissions. Valid permissions: * - 4: read access @@ -6283,11 +6292,12 @@ export interface components { */ Permissions: 4 | 6 | 22; /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - KeyPacket: string; + KeyPacket: components["schemas"]["BinaryString"]; /** @description PGP signature of the member key packet (encrypted) by inviter */ - KeyPacketSignature: string; + KeyPacketSignature: components["schemas"]["PGPSignature"] | null; /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ - SessionKeySignature: string; + SessionKeySignature: components["schemas"]["PGPSignature"] | null; + /** @description 1=active, 3=locked */ State: components["schemas"]["ShareMemberState"]; CreateTime: number; ModifyTime: number; @@ -6304,6 +6314,7 @@ export interface components { AddressID: components["schemas"]["Id"]; AddressKeyID: components["schemas"]["Id"]; KeyPacket: components["schemas"]["BinaryString"]; + /** @description 1=active, 3=locked */ State: components["schemas"]["ShareMemberState"]; /** * @deprecated @@ -6315,12 +6326,15 @@ export interface components { BootstrapShareResponseDto: { ShareID: components["schemas"]["Id"]; VolumeID: components["schemas"]["Id"]; + /** @description 1=Main, 2=Standard, 3=Device, 4=Photo */ Type: components["schemas"]["ShareType"]; + /** @description 1=Active, 3=Restored */ State: components["schemas"]["ShareState"]; + /** @description 1=Regular, 2=Photo */ VolumeType: components["schemas"]["VolumeType2"]; /** Format: email */ Creator: string; - Locked?: boolean | null; + Locked: boolean | null; CreateTime: number; ModifyTime: number; LinkID: components["schemas"]["Id"]; @@ -6331,6 +6345,7 @@ export interface components { CreationTime: number; /** @deprecated */ PermissionsMask: number; + /** @description 1=folder, 2=file */ LinkType: components["schemas"]["NodeType3"]; /** @deprecated */ Flags: number; @@ -6342,12 +6357,12 @@ export interface components { Passphrase: components["schemas"]["PGPMessage"]; PassphraseSignature: components["schemas"]["PGPSignature"]; /** @description Address ID of the current user's address for the membership of this share. Can be missing if the user is not a direct member of the share. */ - AddressID?: string | null; + AddressID: components["schemas"]["Id"] | null; /** * @deprecated * @description Clients should not use this field but pass the address keyring when validating and decrypting related fields. */ - AddressKeyID?: string | null; + AddressKeyID?: components["schemas"]["Id"] | null; /** @description Your own memberships */ Memberships: components["schemas"]["MemberResponseDto"][]; /** @@ -6355,7 +6370,7 @@ export interface components { * @description Deprecated, use `Memberships` instead */ PossibleKeyPackets: components["schemas"]["KeyPacketResponseDto"][]; - RootLinkRecoveryPassphrase?: components["schemas"]["PGPMessage"] | null; + RootLinkRecoveryPassphrase: components["schemas"]["PGPMessage"] | null; /** @description Indicates if editor members of this share could reshare it or not */ EditorsCanShare: boolean; /** @@ -6366,6 +6381,7 @@ export interface components { Code: 1000; }; GetHighestContextForDocumentResponse: { + /** @description Context shareID of the highest level that the user is granted permission */ ContextShareID: components["schemas"]["Id"]; /** * ProtonResponseCode @@ -6377,12 +6393,15 @@ export interface components { ShareResponseDto: { ShareID: components["schemas"]["Id"]; VolumeID: components["schemas"]["Id"]; + /** @description 1=Main, 2=Standard, 3=Device, 4=Photo */ Type: components["schemas"]["ShareType"]; + /** @description 1=Active, 3=Restored */ State: components["schemas"]["ShareState"]; + /** @description 1=Regular, 2=Photo */ VolumeType: components["schemas"]["VolumeType2"]; /** Format: email */ Creator: string; - Locked?: boolean | null; + Locked: boolean | null; CreateTime: number; ModifyTime: number; LinkID: components["schemas"]["Id"]; @@ -6415,16 +6434,6 @@ export interface components { /** @description Indicates if editor members of this share could reshare it or not */ Value: boolean; }; - TransferInput: { - /** @description The ID of the new address */ - AddressID: string; - /** @description The ID of the new key */ - KeyID: string; - /** @description Armored signature of the share passphrase, signed with the users's address with AddressID. */ - SharePassphraseSignature: string; - /** @description Base64 encoded key packet for the share passphrase, reusing the same session key as previously, and encrypted for the key referenced by the KeyID. */ - MemberKeyPacket: string; - }; UpdateSharePropertyRequestDto: { /** * @description Indicates if editor members of this share could reshare it or not @@ -6434,9 +6443,9 @@ export interface components { }; ShareKPMigrationData: { /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ - ShareID: string; + ShareID: components["schemas"]["Id"]; /** @description Key packet to decrypt the share passphrase, encrypted with the node key, base64 encoded */ - PassphraseNodeKeyPacket: string; + PassphraseNodeKeyPacket: components["schemas"]["BinaryString"]; }; MigrateSharesRequestDto: { /** @@ -6483,10 +6492,10 @@ export interface components { RootLinkID: components["schemas"]["Id"]; ShareKey: components["schemas"]["PGPPrivateKey"]; /** @description Full PGP message containing (optionally) PassphraseNodeKP and SharePassphrase-KP and data-packet (encrypted SharePassphrase) -> in this exact order */ - SharePassphrase: string; + SharePassphrase: components["schemas"]["PGPMessage"]; SharePassphraseSignature: components["schemas"]["PGPSignature"]; /** @description Key packet for passphrase of referenced link's node key passphrase */ - PassphraseKeyPacket: string; + PassphraseKeyPacket: components["schemas"]["BinaryString"]; NameKeyPacket: components["schemas"]["BinaryString"]; /** * @deprecated @@ -6516,7 +6525,7 @@ export interface components { SharedByMeResponseDto: { Links: components["schemas"]["LinkSharedByMeResponseDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; + AnchorID: components["schemas"]["Id"] | null; /** @description Indicates if there is a next page of results */ More: boolean; /** @@ -6536,12 +6545,14 @@ export interface components { VolumeID: components["schemas"]["Id"]; ShareID: components["schemas"]["Id"]; LinkID: components["schemas"]["Id"]; + /** @description The target type of the Share that is corresponding to this invitation. + * This should not be used as source of information to know what NodeType or MIMEType the targeted Share is. */ ShareTargetType: components["schemas"]["TargetType"]; }; SharedWithMeResponseDto2: { Links: components["schemas"]["LinkSharedWithMeResponseDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; + AnchorID: components["schemas"]["Id"] | null; /** @description Indicates if there is a next page of results */ More: boolean; /** @@ -6565,7 +6576,7 @@ export interface components { */ Permissions: 4 | 6 | 22; /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ - ExternalInvitationSignature: string; + ExternalInvitationSignature: components["schemas"]["BinaryString"]; }; InvitationEmailDetailsRequestDto: { Message?: string | null; @@ -6597,7 +6608,7 @@ export interface components { */ Permissions: 4 | 6 | 22; /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ - ExternalInvitationSignature: string; + ExternalInvitationSignature: components["schemas"]["BinaryString"]; State: components["schemas"]["ExternalInvitationState"]; CreateTime: number; }; @@ -6627,7 +6638,7 @@ export interface components { ListUserRegisteredExternalInvitationResponseDto: { ExternalInvitations: components["schemas"]["UserRegisteredExternalInvitationItemDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; + AnchorID: components["schemas"]["Id"] | null; /** @description Indicates if there is a next page of results */ More: boolean; /** @@ -6650,7 +6661,7 @@ export interface components { }; AcceptInvitationRequestDto: { /** @description Signature of the share passphrase's session key with the private key of the user (invitee) and the signature context `drive.share-member.member`, base64 encoded */ - SessionKeySignature: string; + SessionKeySignature: components["schemas"]["BinaryString"]; }; InvitationRequestDto: { InviterEmail: components["schemas"]["AddressEmail"]; @@ -6665,9 +6676,9 @@ export interface components { */ Permissions: 4 | 6 | 22; /** @description Encrypting the share passphrase's session key with the invitee's public address key, base64 encoded */ - KeyPacket: string; + KeyPacket: components["schemas"]["BinaryString"]; /** @description Signature of the above member key packet with the private key of the user (inviter) and the signature context `drive.share-member.inviter`, base64 encoded */ - KeyPacketSignature: string; + KeyPacketSignature: components["schemas"]["BinaryString"]; /** @default null */ ExternalInvitationID: components["schemas"]["Id"] | null; }; @@ -6692,9 +6703,9 @@ export interface components { */ Permissions: 4 | 6 | 22; /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - KeyPacket: string; + KeyPacket: components["schemas"]["BinaryString"]; /** @description PGP signature of the member key packet (encrypted) by inviter */ - KeyPacketSignature: string; + KeyPacketSignature: components["schemas"]["BinaryString"]; CreateTime: number; }; InviteUserResponseDto: { @@ -6731,12 +6742,14 @@ export interface components { VolumeID: components["schemas"]["Id"]; ShareID: components["schemas"]["Id"]; InvitationID: components["schemas"]["Id"]; + /** @description The target type of the Share that is corresponding to this invitation. + * This should not be used as source of information to know what NodeType or MIMEType the targeted Share is. */ ShareTargetType: components["schemas"]["TargetType"]; }; ListPendingInvitationResponseDto: { Invitations: components["schemas"]["PendingInvitationItemDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID?: string | null; + AnchorID: components["schemas"]["Id"] | null; /** @description Indicates if there is a next page of results */ More: boolean; /** @@ -6753,13 +6766,15 @@ export interface components { ShareKey: components["schemas"]["PGPPrivateKey"]; /** Format: email */ CreatorEmail: string; + /** @description The target type of the Share that is corresponding to this invitation. + * This should not be used as source of information to know what NodeType or MIMEType the targeted Share is. */ ShareTargetType: components["schemas"]["TargetType"]; }; LinkResponseDto: { Type: components["schemas"]["NodeType2"]; LinkID: components["schemas"]["Id"]; Name: components["schemas"]["PGPMessage"]; - MIMEType?: string | null; + MIMEType: string | null; }; PendingInvitationResponseDto: { Invitation: components["schemas"]["InvitationResponseDto"]; @@ -6816,11 +6831,11 @@ export interface components { */ Permissions: 4 | 6 | 22; /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ - KeyPacket: string; + KeyPacket: components["schemas"]["BinaryString"]; /** @description PGP signature of the member key packet (encrypted) by inviter */ - KeyPacketSignature: string; + KeyPacketSignature: components["schemas"]["BinaryString"]; /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ - SessionKeySignature: string; + SessionKeySignature: components["schemas"]["BinaryString"]; CreateTime: number; }; ListShareMembersResponseDto: { @@ -6893,6 +6908,7 @@ export interface components { B2BPhotosEnabled: null; Layout: components["schemas"]["LayoutSetting"]; Sort: components["schemas"]["SortSetting"]; + /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ RevisionRetentionDays: components["schemas"]["RevisionRetentionDays"]; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsEnabled?: boolean | null; @@ -6914,6 +6930,7 @@ export interface components { * @description [DEPRECATED] Always true */ B2BPhotosEnabled: boolean; + /** @description Number of days revisions should be retained if not defined by the user. Default ALWAYS used for free users, even if different value is set (premium feature). */ RevisionRetentionDays: components["schemas"]["RevisionRetentionDays2"]; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsEnabled: boolean; @@ -6923,7 +6940,9 @@ export interface components { PhotoTags: number[]; }; SettingsResponse: { + /** @description User settings as defined by the user. */ UserSettings: components["schemas"]["UserSettings"]; + /** @description Defaults for certain settings (e.g. if not set by user). */ Defaults: components["schemas"]["Defaults"]; /** * ProtonResponseCode @@ -6935,6 +6954,7 @@ export interface components { UserSettingsRequest: { Layout: components["schemas"]["LayoutSetting"]; Sort: components["schemas"]["SortSetting"]; + /** @description Number of days revisions should be retained. If null, default will be used by backend. Changing the setting is only available to paid users, free users will always use the default. */ RevisionRetentionDays: components["schemas"]["RevisionRetentionDays"]; /** @description Indicates if email notifications for comment activity in Proton Docs are enabled. If null, the default value to 0 = false will be used by backend. */ DocsCommentsNotificationsEnabled?: boolean | null; @@ -6948,7 +6968,7 @@ export interface components { CreateOrgVolumeRequestDto: { AddressID: components["schemas"]["AddressID"]; /** @description XX's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: string; + AddressKeyID: components["schemas"]["Id"]; ShareKey: components["schemas"]["PGPPrivateKey"]; SharePassphrase: components["schemas"]["PGPMessage"]; SharePassphraseSignature: components["schemas"]["PGPSignature"]; @@ -6962,6 +6982,7 @@ export interface components { VolumeName: string; }; VolumeResponseDto: { + /** @description Deprecated, use `VolumeID` instead */ ID: components["schemas"]["Id"]; /** * @deprecated @@ -6981,6 +7002,7 @@ export interface components { DownloadedBytes: number; UploadedBytes: number; State: components["schemas"]["VolumeState"]; + /** @description Main share of the volume */ Share: components["schemas"]["ShareReferenceResponseDto"]; Type: components["schemas"]["VolumeType"]; }; @@ -7004,7 +7026,7 @@ export interface components { FolderPassphraseSignature: components["schemas"]["PGPSignature"]; FolderHashKey: components["schemas"]["PGPMessage"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: string; + AddressKeyID?: components["schemas"]["Id"] | null; /** * @deprecated * @default null @@ -7058,9 +7080,9 @@ export interface components { }; RestoreMainShareDto: { /** @description ShareID of the existing, locked main share */ - LockedShareID: string; + LockedShareID: components["schemas"]["Id"]; /** @description Folder name as armored PGP message */ - Name: string; + Name: components["schemas"]["PGPMessage"]; /** @description Hash of the name */ Hash: string; NodePassphrase: components["schemas"]["PGPMessage"]; @@ -7069,15 +7091,15 @@ export interface components { * @description Node Hash Key should be provided if it needs to be signed because it was unsigned or signed with the address key (legacy). It should be signed with the new parent's node key. If it was properly signed with the parent node key, it should not be updated. Armored PGP message. * @default null */ - NodeHashKey: string | null; + NodeHashKey: components["schemas"]["PGPMessage"] | null; }; RestoreRootShareDto: { /** @description ShareID of the existing share on the old volume */ - LockedShareID: string; + LockedShareID: components["schemas"]["Id"]; /** @description Key packet for the share passphrase, encrypted with the active key associated with the new volume. Encoded with Base64. */ - ShareKeyPacket: string; + ShareKeyPacket: components["schemas"]["BinaryString"]; /** @description Signed with new key as armored PGP signature */ - PassphraseSignature: string; + PassphraseSignature: components["schemas"]["PGPSignature"]; }; RestoreVolumeDto: { SignatureAddress: components["schemas"]["AddressEmail"]; @@ -7088,7 +7110,7 @@ export interface components { /** @default [] */ PhotoShares: components["schemas"]["RestoreRootShareDto"][]; /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ - AddressKeyID: string; + AddressKeyID?: components["schemas"]["Id"] | null; }; AddPhotoToAlbumWithLinkIDResponseDto: Record; MultiResponsesPerLinkFactory: { @@ -7106,12 +7128,12 @@ export interface components { * @description A conflicting Revision in Active state. * @default null */ - ConflictRevisionID: string | null; + ConflictRevisionID: components["schemas"]["Id"] | null; /** * @description A conflicting Revision in Draft state. * @default null */ - ConflictDraftRevisionID: string | null; + ConflictDraftRevisionID: components["schemas"]["Id"] | null; /** * @description ClientUID of conflicting Revision if in Draft state. * @default null @@ -7122,7 +7144,7 @@ export interface components { * @description [DEPRECATED] for backwards compatibility on create revision, same value as ConflictDraftRevisionID * @default null */ - RevisionID: string | null; + RevisionID: components["schemas"]["Id"] | null; }; ConflictErrorResponseDto: { Details: components["schemas"]["ConflictErrorDetailsDto"]; @@ -7132,7 +7154,7 @@ export interface components { ShareConflictErrorDetailsDto: { ConflictLinkID: components["schemas"]["Id"]; /** @description A conflicting Share on the Link. */ - ConflictShareID: string; + ConflictShareID: components["schemas"]["Id"]; }; /** @description Conflict, a share already exists for the file or folder. */ ShareConflictErrorResponseDto: { @@ -7156,21 +7178,26 @@ export interface components { MIMEType: string; ContentKeyPacket: components["schemas"]["BinaryString"]; /** @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. */ - ContentKeyPacketSignature?: string | null; + ContentKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; ManifestSignature: components["schemas"]["PGPSignature"]; ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; /** * @description Extended attributes encrypted with link key * @default null */ - XAttr: string | null; + XAttr: components["schemas"]["PGPMessage"] | null; /** @default null */ Photo: components["schemas"]["CommitRevisionPhotoDto"] | null; /** * @description Encrypted PGP Signature of the raw block content. Is null for empty files as they do not have blocks or when uploaded by anonymous users. Deprecated: Once clients do not validate the block signature, it should also not be calculated and uploaded anymore. * @default null */ - ContentBlockEncSignature: string | null; + ContentBlockEncSignature: components["schemas"]["PGPMessage"] | null; + /** + * @description Whether the checksum in xattr of the revision content was verified by the client during upload + * @default false + */ + ChecksumVerified: boolean; }; SmallRevisionUploadMetadataRequestDto: { CurrentRevisionID: components["schemas"]["Id"]; @@ -7187,7 +7214,12 @@ export interface components { * @description File extended attributes encrypted with link key * @default null */ - XAttr: string | null; + XAttr: components["schemas"]["PGPMessage"] | null; + /** + * @description Whether the checksum in xattr of the revision content was verified by the client during upload + * @default false + */ + ChecksumVerified: boolean; }; }; responses: { @@ -8514,7 +8546,7 @@ export interface operations { parameters: { query?: { /** @description Link ID use to indicate where to start the next page */ - AnchorID?: (string & components["schemas"]["Id"]) | null; + AnchorID?: string & (components["schemas"]["Id"] | null); /** @description Show folders only */ FoldersOnly?: 0 | 1; }; @@ -10806,6 +10838,34 @@ export interface operations { }; }; }; + "put_drive-photos-volumes-{volumeID}-links-{linkID}-capture-time": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdatePhotoCaptureTimeRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; "put_drive-photos-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-xattr": { parameters: { query?: never; @@ -12406,33 +12466,6 @@ export interface operations { }; }; }; - "post_drive-shares-{shareID}-owner": { - parameters: { - query?: never; - header?: never; - path: { - shareID: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["TransferInput"]; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SuccessfulResponse"]; - }; - }; - }; - }; "post_drive-shares-{shareID}-property": { parameters: { query?: never; diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 2a8a1d72..0806cf19 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -851,6 +851,7 @@ function transformRevisionResponse( signatureEmail: revision.SignatureEmail || undefined, armoredExtendedAttributes: revision.XAttr || undefined, thumbnails: revision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, nodeId, thumbnail)) || [], + sha1Verified: revision.ChecksumVerified, }; } diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 05e1cb58..46925a64 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -123,6 +123,7 @@ describe('nodesCache', () => { claimedSize: 100, claimedDigests: { sha1: 'hash', + sha1Verified: true, }, claimedBlockSizes: [100], claimedAdditionalMetadata: { diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 27d9243c..a9893c0e 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -509,6 +509,7 @@ export class NodesCryptoService { contentAuthor, extendedAttributes, thumbnails: encryptedRevision.thumbnails, + sha1Verified: encryptedRevision.sha1Verified, }; } diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index b4099b1d..72ce52e4 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -181,11 +181,13 @@ export type Thumbnail = { export interface EncryptedRevision extends BaseRevision { signatureEmail?: string; armoredExtendedAttributes?: string; + sha1Verified?: boolean; } export interface DecryptedUnparsedRevision extends BaseRevision { contentAuthor: Author; extendedAttributes?: string; + sha1Verified?: boolean; } export interface DecryptedRevision extends Revision { diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 82fc32d8..00059a81 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -549,6 +549,10 @@ export function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedNode): contentAuthor: unparsedNode.activeRevision.value.contentAuthor, thumbnails: unparsedNode.activeRevision.value.thumbnails, ...extendedAttributes, + claimedDigests: { + ...extendedAttributes?.claimedDigests, + sha1Verified: unparsedNode.activeRevision.value.sha1Verified || false, + }, }), folder: undefined, treeEventScopeId, diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index 6a2b840f..3134d9ed 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -36,6 +36,10 @@ export class NodesRevisons { return { ...revision, ...extendedAttributes, + claimedDigests: { + ...extendedAttributes?.claimedDigests, + sha1Verified: revision.sha1Verified || false, + }, }; } @@ -53,6 +57,10 @@ export class NodesRevisons { yield { ...revision, ...extendedAttributes, + claimedDigests: { + ...extendedAttributes?.claimedDigests, + sha1Verified: revision.sha1Verified || false, + }, }; } } diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index 22661c83..c627c0f5 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -264,7 +264,12 @@ export class PhotosAPIService { ); return response.DuplicateHashes.map((duplicate) => { - if (!duplicate.Hash || !duplicate.ContentHash || duplicate.LinkState !== 1 /* Active */) { + if ( + !duplicate.Hash || + !duplicate.ContentHash || + !duplicate.LinkID || + duplicate.LinkState !== 1 /* Active */ + ) { return undefined; } return { diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index 6d3016be..b2647537 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -128,7 +128,7 @@ export class PhotoStreamUploader extends StreamUploader { async commitFile(thumbnails: Thumbnail[]) { const digests = this.digests.digests(); - this.verifyIntegrity(thumbnails, digests); + const integrityInfo = this.verifyIntegrity(thumbnails, digests); const extendedAttributes = { modificationTime: this.metadata.modificationTime, @@ -142,6 +142,7 @@ export class PhotoStreamUploader extends StreamUploader { await this.getManifest(), extendedAttributes, this.photoMetadata, + integrityInfo, ); } } @@ -174,6 +175,7 @@ export class PhotoUploadManager extends UploadManager { }; }, uploadMetadata: PhotoUploadMetadata, + integrityInfo: { checksumVerified: boolean }, ): Promise { if (!nodeRevisionDraft.parentNodeKeys) { throw new Error('Parent node keys are required for photo upload'); @@ -201,7 +203,14 @@ export class PhotoUploadManager extends UploadManager { mainPhotoNodeUid: uploadMetadata.mainPhotoNodeUid, tags: uploadMetadata.tags, }; - await this.photoApiService.commitDraftPhoto(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto, photo); + await this.photoApiService.commitDraftPhoto( + nodeRevisionDraft.nodeRevisionUid, + { + ...nodeCommitCrypto, + ...integrityInfo, + }, + photo, + ); await this.notifyNodeUploaded(nodeRevisionDraft); } } @@ -232,6 +241,7 @@ export class PhotoUploadAPIService extends UploadAPIService { armoredManifestSignature: string; signatureEmail: string | AnonymousUser; armoredExtendedAttributes?: string; + checksumVerified?: boolean; }, photo: { contentHash: string; @@ -257,6 +267,7 @@ export class PhotoUploadAPIService extends UploadAPIService { ManifestSignature: options.armoredManifestSignature, SignatureAddress: options.signatureEmail, XAttr: options.armoredExtendedAttributes || null, + ChecksumVerified: options.checksumVerified || false, Photo: { ContentHash: photo.contentHash, CaptureTime: photo.captureTime ? Math.floor(photo.captureTime?.getTime() / 1000) : 0, diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index fefb9b60..5f2711d1 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -239,6 +239,7 @@ export class UploadAPIService { armoredManifestSignature: string; signatureEmail: string | AnonymousUser; armoredExtendedAttributes: string; + checksumVerified?: boolean; }, ): Promise { const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); @@ -250,6 +251,7 @@ export class UploadAPIService { ManifestSignature: options.armoredManifestSignature, SignatureAddress: options.signatureEmail, XAttr: options.armoredExtendedAttributes, + ChecksumVerified: options.checksumVerified || false, Photo: null, // Only used for photos in the Photo volume. }); } @@ -322,6 +324,7 @@ export class UploadAPIService { }, content: { armoredManifestSignature: string; + checksumVerified?: boolean; block: | { encryptedData: Uint8Array; @@ -355,6 +358,7 @@ export class UploadAPIService { ? uint8ArrayToBase64String(content.block.verificationToken) : null, XAttr: metadata.armoredExtendedAttributes, + ChecksumVerified: content.checksumVerified || false, Photo: null, // TODO }; @@ -395,6 +399,7 @@ export class UploadAPIService { }, content: { armoredManifestSignature: string; + checksumVerified?: boolean; block: | { encryptedData: Uint8Array; @@ -421,6 +426,7 @@ export class UploadAPIService { ? uint8ArrayToBase64String(content.block.verificationToken) : null, XAttr: metadata.armoredExtendedAttributes, + ChecksumVerified: content.checksumVerified || false, }; const formData = new FormData(); diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index 382f31d8..d782cdec 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -154,6 +154,7 @@ export class UploadManager { commitPayload: { armoredManifestSignature: string; armoredExtendedAttributes: string; + checksumVerified?: boolean; }, encryptedBlock: | { @@ -182,6 +183,7 @@ export class UploadManager { }, { armoredManifestSignature: commitPayload.armoredManifestSignature, + checksumVerified: commitPayload.checksumVerified, block: encryptedBlock ? { encryptedData: encryptedBlock.encryptedData, @@ -387,6 +389,7 @@ export class UploadManager { }; }, additionalExtendedAttributes?: object, + integrityInfo?: { checksumVerified: boolean }, ): Promise { const generatedExtendedAttributes = generateFileExtendedAttributes( extendedAttributes, @@ -398,7 +401,10 @@ export class UploadManager { generatedExtendedAttributes, ); try { - await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto); + await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, { + ...nodeCommitCrypto, + ...integrityInfo, + }); } catch (error: unknown) { // Commit might be sent but due to network error no response is // received. In this case, API service automatically retries the diff --git a/js/sdk/src/internal/upload/smallFileUploader.ts b/js/sdk/src/internal/upload/smallFileUploader.ts index 02e27e4b..2e701ab4 100644 --- a/js/sdk/src/internal/upload/smallFileUploader.ts +++ b/js/sdk/src/internal/upload/smallFileUploader.ts @@ -77,6 +77,7 @@ abstract class SmallUploader { commitPayload: { armoredManifestSignature: string; armoredExtendedAttributes: string; + checksumVerified?: boolean; }; encryptedBlock: | { @@ -253,6 +254,7 @@ abstract class SmallUploader { ): Promise<{ armoredManifestSignature: string; armoredExtendedAttributes: string; + checksumVerified?: boolean; }> { this.logger.debug(`Preparing commit payload`); @@ -269,6 +271,7 @@ abstract class SmallUploader { return { armoredManifestSignature: commitCrypto.armoredManifestSignature, armoredExtendedAttributes: commitCrypto.armoredExtendedAttributes, + checksumVerified: !!(this.metadata.expectedSha1 && contentSha1 === this.metadata.expectedSha1), }; } } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 94f35093..87085f74 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -172,6 +172,9 @@ describe('StreamUploader', () => { }, }, metadata.additionalMetadata, + { + checksumVerified: !!metadata.expectedSha1, + }, ); expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 90cec184..b7a51fbc 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -254,7 +254,7 @@ export class StreamUploader { protected async commitFile(thumbnails: Thumbnail[]) { const digests = this.digests.digests(); - this.verifyIntegrity(thumbnails, digests); + const integrityInfo = this.verifyIntegrity(thumbnails, digests); const extendedAttributes = { modificationTime: this.metadata.modificationTime, @@ -267,6 +267,7 @@ export class StreamUploader { await this.getManifest(), extendedAttributes, this.metadata.additionalMetadata, + integrityInfo, ); } @@ -626,7 +627,12 @@ export class StreamUploader { } } - protected verifyIntegrity(thumbnails: Thumbnail[], digests: { sha1: string }) { + protected verifyIntegrity( + thumbnails: Thumbnail[], + digests: { sha1: string }, + ): { + checksumVerified: boolean; + } { const expectedBlockCount = Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0); if (this.uploadedBlockCount !== expectedBlockCount) { @@ -647,6 +653,9 @@ export class StreamUploader { expectedSha1: this.metadata.expectedSha1, }); } + return { + checksumVerified: !!(this.metadata.expectedSha1 && digests.sha1 === this.metadata.expectedSha1), + }; } /** From bcfa545b3b60f49ec6d6182232e326a636e0d56f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 16:22:32 +0800 Subject: [PATCH 700/791] Upgrade protoncore_ios to 37.0.1 --- swift/ProtonDriveSDK/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/ProtonDriveSDK/Package.swift b/swift/ProtonDriveSDK/Package.swift index e9d05fc6..ff550684 100644 --- a/swift/ProtonDriveSDK/Package.swift +++ b/swift/ProtonDriveSDK/Package.swift @@ -21,7 +21,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.33.3"), .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.1.0"), - .package(url: "https://github.com/ProtonMail/protoncore_ios.git", exact: "36.0.3"), + .package(url: "https://github.com/ProtonMail/protoncore_ios.git", exact: "37.0.1"), ], targets: [ .binaryTarget( From cbb89223ddb87aafc138ef0ca0d7ff4d9cd6dd98 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 09:28:41 +0000 Subject: [PATCH 701/791] Update changelog for cs/v0.13.5 --- cs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 90725903..9ea2fb70 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## cs/v0.13.5 (2026-04-22) + +* Add thumbnail error handling from API response +* Ensure expected SHA1 provider is called only once during upload + ## cs/v0.13.4 (2026-04-20) * Improve download initialization speed by parallelizing some server round-trips From 141d00a9961c950ca2ce8ae184f5fa02147f4d51 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 15:40:40 +0200 Subject: [PATCH 702/791] Handle too many children exception when creating a new draft --- .../Api/Files/FilesApiClient.cs | 4 +-- .../Api/Files/RevisionConflictResponse.cs | 10 ------- .../Api/Files/RevisionErrorResponse.cs | 27 +++++++++++++++++++ .../NodeWithSameNameExistsException.cs | 2 +- .../Nodes/Upload/NewFileDraftProvider.cs | 8 ++++-- .../Nodes/Upload/NewRevisionDraftProvider.cs | 5 ++-- .../RevisionDraftConflictException.cs | 2 +- .../DriveApiSerializerContext.cs | 2 +- .../TooManyChildrenException.cs | 18 +++++++++++++ 9 files changed, 59 insertions(+), 19 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflictResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/TooManyChildrenException.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs index 72a4e04a..7bf8e324 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -15,7 +15,7 @@ internal sealed class FilesApiClient(HttpClient httpClient) : IFilesApiClient public async ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken) { return await _httpClient - .Expecting(DriveApiSerializerContext.Default.FileCreationResponse, DriveApiSerializerContext.Default.RevisionConflictResponse) + .Expecting(DriveApiSerializerContext.Default.FileCreationResponse, DriveApiSerializerContext.Default.RevisionErrorResponse) .PostAsync($"v2/volumes/{volumeId}/files", request, DriveApiSerializerContext.Default.FileCreationRequest, cancellationToken) .ConfigureAwait(false); } @@ -27,7 +27,7 @@ public async ValueTask CreateRevisionAsync( CancellationToken cancellationToken) { return await _httpClient - .Expecting(DriveApiSerializerContext.Default.RevisionCreationResponse, DriveApiSerializerContext.Default.RevisionConflictResponse) + .Expecting(DriveApiSerializerContext.Default.RevisionCreationResponse, DriveApiSerializerContext.Default.RevisionErrorResponse) .PostAsync( $"v2/volumes/{volumeId}/files/{linkId}/revisions", request, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflictResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflictResponse.cs deleted file mode 100644 index e7e2d012..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflictResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text.Json.Serialization; -using Proton.Sdk.Api; - -namespace Proton.Drive.Sdk.Api.Files; - -internal sealed class RevisionConflictResponse : ApiResponse -{ - [JsonPropertyName("Details")] - public required RevisionConflict Conflict { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs new file mode 100644 index 00000000..3930825b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionErrorResponse : ApiResponse +{ + private Lazy? _conflict; + + public JsonElement? Details { get; init; } + + public RevisionConflict? Conflict + { + get + { + return (_conflict ??= new Lazy(() => Code is ResponseCode.AlreadyExists && Details is not null + ? Details.Value.Deserialize(DriveApiSerializerContext.Default.RevisionConflict) + : null)).Value; + } + + init + { + _conflict = new Lazy(value); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs index 98cb65eb..4a04811c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs @@ -21,7 +21,7 @@ public NodeWithSameNameExistsException(string message, Exception innerException) { } - internal NodeWithSameNameExistsException(VolumeId volumeId, ProtonApiException innerException) + internal NodeWithSameNameExistsException(VolumeId volumeId, ProtonApiException innerException) : base(innerException.Message, innerException) { if (innerException.Response is not { } response) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 14aac93a..09a7be63 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -151,7 +151,7 @@ private static FileCreationRequest GetFileCreationRequest( result = (response, fileSecrets); } - catch (ProtonApiException e) + catch (ProtonApiException e) when (e.Response is { Conflict: { LinkId: { } conflictingLinkId, RevisionId: null, DraftRevisionId: not null } } && (e.Response.Conflict.DraftClientUid == _client.Uid || _overrideExistingDraftByOtherClient) && remainingNumberOfAttempts-- > 0) @@ -170,10 +170,14 @@ private static FileCreationRequest GetFileCreationRequest( throw deletionException; } } - catch (ProtonApiException e) + catch (ProtonApiException e) when (e.Code is ResponseCode.AlreadyExists) { throw new NodeWithSameNameExistsException(_parentUid.VolumeId, e); } + catch (ProtonApiException e) when (e.Code is ResponseCode.TooManyChildren) + { + throw new TooManyChildrenException(e.Message, e); + } } return result.Value; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index ff70d9ff..52fd9638 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -1,5 +1,6 @@ using Proton.Drive.Sdk.Api.Files; using Proton.Sdk; +using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -48,14 +49,14 @@ public async ValueTask GetDraftAsync(CancellationToken cancellati revisionId = revisionResponse.Identity.RevisionId; } - catch (ProtonApiException e) + catch (ProtonApiException e) when (e.Response is { Conflict.DraftRevisionId: { } draftRevisionId } && (e.Response.Conflict.DraftClientUid == _client.Uid) && remainingNumberOfAttempts-- > 0) { await _client.Api.Files.DeleteRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, draftRevisionId, cancellationToken).ConfigureAwait(false); } - catch (ProtonApiException e) + catch (ProtonApiException e) when (e.Code is ResponseCode.AlreadyExists) { throw new RevisionDraftConflictException("Cannot create revision", e); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs b/cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs index 2f124706..1f41665d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs @@ -19,7 +19,7 @@ public RevisionDraftConflictException(string message, Exception innerException) { } - internal RevisionDraftConflictException(ProtonApiException innerException) + internal RevisionDraftConflictException(ProtonApiException innerException) : base(innerException.Message, innerException) { } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 19836a01..02391d66 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -41,7 +41,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(NodeNameAvailabilityResponse))] [JsonSerializable(typeof(RevisionCreationRequest))] [JsonSerializable(typeof(RevisionCreationResponse))] -[JsonSerializable(typeof(RevisionConflictResponse))] +[JsonSerializable(typeof(RevisionErrorResponse))] [JsonSerializable(typeof(BlockUploadPreparationRequest))] [JsonSerializable(typeof(BlockUploadPreparationResponse))] [JsonSerializable(typeof(RevisionUpdateRequest))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/TooManyChildrenException.cs b/cs/sdk/src/Proton.Drive.Sdk/TooManyChildrenException.cs new file mode 100644 index 00000000..b89b4957 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/TooManyChildrenException.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk; + +public sealed class TooManyChildrenException : ProtonDriveException +{ + public TooManyChildrenException(string message) + : base(message) + { + } + + public TooManyChildrenException(string message, Exception innerException) + : base(message, innerException) + { + } + + public TooManyChildrenException() + { + } +} From 1dac7c7068c79e20aaf4e03a7d9a2f1dfd0f62cc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 15:20:00 +0000 Subject: [PATCH 703/791] Update changelog for cs/v0.13.6 --- cs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 9ea2fb70..1ecd334f 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## cs/v0.13.6 (2026-04-22) + +* Handle too many children exception when creating a new draft + ## cs/v0.13.5 (2026-04-22) * Add thumbnail error handling from API response From 90612bb8b4b83b033dacbd50858e545fe20ec2c1 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 04:29:57 +0000 Subject: [PATCH 704/791] Update album metadata cache after albums api request --- .../src/internal/photos/albumsManager.test.ts | 41 ++++++++++++++++++ js/sdk/src/internal/photos/albumsManager.ts | 9 ++++ js/sdk/src/internal/photos/nodes.test.ts | 42 +++++++++++++++++++ js/sdk/src/internal/photos/nodes.ts | 29 +++++++++++++ 4 files changed, 121 insertions(+) diff --git a/js/sdk/src/internal/photos/albumsManager.test.ts b/js/sdk/src/internal/photos/albumsManager.test.ts index be0d86eb..5d794521 100644 --- a/js/sdk/src/internal/photos/albumsManager.test.ts +++ b/js/sdk/src/internal/photos/albumsManager.test.ts @@ -293,6 +293,47 @@ describe('Albums', () => { }); }); + describe('iterateAlbumUids', () => { + it('yields album uids and patches metadata into cache', async () => { + const album1 = { + albumUid: 'volumeId~album1', + photoCount: 3, + coverNodeUid: 'volumeId~cover1', + lastActivityTime: new Date('2024-01-01T00:00:00.000Z'), + }; + const album2 = { + albumUid: 'volumeId~album2', + photoCount: 0, + coverNodeUid: undefined, + lastActivityTime: new Date('2024-02-01T00:00:00.000Z'), + }; + + apiService.iterateAlbums = jest.fn().mockImplementation(async function* () { + yield album1; + yield album2; + }); + nodesService.updateAlbumMetadataCache = jest.fn().mockResolvedValue(undefined); + + const uids: string[] = []; + for await (const uid of albums.iterateAlbumUids()) { + uids.push(uid); + } + + expect(uids).toEqual(['volumeId~album1', 'volumeId~album2']); + expect(nodesService.updateAlbumMetadataCache).toHaveBeenCalledTimes(2); + expect(nodesService.updateAlbumMetadataCache).toHaveBeenCalledWith('volumeId~album1', { + photoCount: 3, + coverNodeUid: 'volumeId~cover1', + lastActivityTime: album1.lastActivityTime, + }); + expect(nodesService.updateAlbumMetadataCache).toHaveBeenCalledWith('volumeId~album2', { + photoCount: 0, + coverNodeUid: undefined, + lastActivityTime: album2.lastActivityTime, + }); + }); + }); + describe('removePhotos', () => { it('notifies nodes service only for successfully removed photos', async () => { apiService.removePhotosFromAlbum = jest.fn().mockImplementation(async function* () { diff --git a/js/sdk/src/internal/photos/albumsManager.ts b/js/sdk/src/internal/photos/albumsManager.ts index c6b9e199..79effaae 100644 --- a/js/sdk/src/internal/photos/albumsManager.ts +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -53,6 +53,15 @@ export class AlbumsManager { const { volumeId } = await this.photoShares.getRootIDs(); for await (const album of this.apiService.iterateAlbums(volumeId, signal)) { + // Patch fresh album metadata into the node cache so that the subsequent + // iterateNodes call returns up-to-date photoCount/coverNodeUid without + // an extra API round-trip. The fresh data comes from the /albums endpoint + // which always reflects the current state. + void this.nodesService.updateAlbumMetadataCache(album.albumUid, { + photoCount: album.photoCount, + coverNodeUid: album.coverNodeUid, + lastActivityTime: album.lastActivityTime, + }); yield album.albumUid; } } diff --git a/js/sdk/src/internal/photos/nodes.test.ts b/js/sdk/src/internal/photos/nodes.test.ts index cd4cb1ce..efa4b7f5 100644 --- a/js/sdk/src/internal/photos/nodes.test.ts +++ b/js/sdk/src/internal/photos/nodes.test.ts @@ -3,6 +3,7 @@ import { NodeType, MemberRole } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { getMockTelemetry } from '../../tests/telemetry'; import { DriveAPIService } from '../apiService'; +import { DecryptedPhotoNode } from './interface'; import { PhotosNodesAPIService, PhotosNodesCache, PhotosNodesAccess, PhotosNodesCryptoService } from './nodes'; function generateAPINode() { @@ -266,6 +267,47 @@ describe('PhotosNodesAccess', () => { }); }); + describe('updateAlbumMetadataCache', () => { + let access: PhotosNodesAccess; + let mockCache: { getNode: jest.Mock; setNode: jest.Mock }; + + beforeEach(() => { + mockCache = { getNode: jest.fn(), setNode: jest.fn() }; + access = new PhotosNodesAccess( + getMockTelemetry(), + // @ts-expect-error Mocking for testing purposes + {}, + mockCache, + { getNodeKeys: jest.fn().mockRejectedValue(new Error()) }, + {}, + {}, + ); + }); + + it('updates album metadata in cache', async () => { + const existing = { uid: 'v~album1', type: NodeType.Album, album: { photoCount: 1, coverPhotoNodeUid: 'v~old', lastActivityTime: new Date('2024-01-01') } } as DecryptedPhotoNode; + mockCache.getNode.mockResolvedValue(existing); + + await access.updateAlbumMetadataCache('v~album1', { photoCount: 5, coverNodeUid: 'v~new', lastActivityTime: new Date('2024-06-01') }); + + expect(mockCache.setNode).toHaveBeenCalledWith(expect.objectContaining({ + album: { photoCount: 5, coverPhotoNodeUid: 'v~new', lastActivityTime: new Date('2024-06-01') }, + })); + }); + + it('does nothing when node is not in cache', async () => { + mockCache.getNode.mockRejectedValue(new Error('Entity not found')); + await expect(access.updateAlbumMetadataCache('v~missing', { photoCount: 3, coverNodeUid: undefined, lastActivityTime: new Date() })).resolves.toBeUndefined(); + expect(mockCache.setNode).not.toHaveBeenCalled(); + }); + + it('does nothing when cached node has no album field', async () => { + mockCache.getNode.mockResolvedValue({ uid: 'v~folder1', type: NodeType.Folder } as DecryptedPhotoNode); + await access.updateAlbumMetadataCache('v~folder1', { photoCount: 2, coverNodeUid: undefined, lastActivityTime: new Date() }); + expect(mockCache.setNode).not.toHaveBeenCalled(); + }); + }); + describe('parseNode', () => { it('should keep photo type and add photo object', async () => { const telemetry = getMockTelemetry(); diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index cfc629c8..5eafcc30 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -237,6 +237,35 @@ export class PhotosNodesAccess extends NodesAccessBase { + try { + const cached = await this.cache.getNode(albumUid); + if (!cached?.album) { + return; + } + await this.cache.setNode({ + ...cached, + album: { + ...cached.album, + photoCount: metadata.photoCount, + coverPhotoNodeUid: metadata.coverNodeUid, + lastActivityTime: metadata.lastActivityTime, + }, + }); + } catch { + // Cache miss is fine — node will be fetched fresh by iterateNodes anyway. + } + } } export class PhotosNodesCryptoService extends NodesCryptoService { From bb56fb714d516609bd4e94d631fe4df03e489fe9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 16:10:25 +0200 Subject: [PATCH 705/791] Log error when volume type is unknown --- .../src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs index a63ed601..3969f7f0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Volumes; @@ -124,8 +125,10 @@ internal static async Task ResolveVolumeTypeAsync( return VolumeType.Shared; } - catch + catch (Exception ex) { + client.Telemetry.GetLogger("TelemetryEventFactory") + .LogDebug(ex, "Failed to resolve volume type for node {NodeUid}", nodeUid); return VolumeType.Unknown; } } From e3617799f2532d2e73105c0129b0bfd89c41542a Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 07:28:30 +0000 Subject: [PATCH 706/791] Update changelog for js/v0.14.8 --- js/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index d6ed43f6..a0ea93b0 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## js/v0.14.8 (2026-04-23) + +* Update album metadata cache after albums api request +* Report checksum verification +* Prevent encrypted block buffers from leaking via onProgress closure + ## js/v0.14.7 (2026-04-17) * Add experimental iterate by uids for albums and shared with me albums From 3a5acccc8568fe86552ec5d04d2432c4aaac93b6 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 15:35:18 +0200 Subject: [PATCH 707/791] Remove unnecessary too many children exception --- .../Nodes/Upload/NewFileDraftProvider.cs | 4 ---- .../TooManyChildrenException.cs | 18 ------------------ 2 files changed, 22 deletions(-) delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/TooManyChildrenException.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 09a7be63..a2d15f5b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -174,10 +174,6 @@ private static FileCreationRequest GetFileCreationRequest( { throw new NodeWithSameNameExistsException(_parentUid.VolumeId, e); } - catch (ProtonApiException e) when (e.Code is ResponseCode.TooManyChildren) - { - throw new TooManyChildrenException(e.Message, e); - } } return result.Value; diff --git a/cs/sdk/src/Proton.Drive.Sdk/TooManyChildrenException.cs b/cs/sdk/src/Proton.Drive.Sdk/TooManyChildrenException.cs deleted file mode 100644 index b89b4957..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/TooManyChildrenException.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Proton.Drive.Sdk; - -public sealed class TooManyChildrenException : ProtonDriveException -{ - public TooManyChildrenException(string message) - : base(message) - { - } - - public TooManyChildrenException(string message, Exception innerException) - : base(message, innerException) - { - } - - public TooManyChildrenException() - { - } -} From c0594c93b6886613fcf45949fef6a8424cf51479 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 13:54:28 +0000 Subject: [PATCH 708/791] Update changelog for cs/v0.13.7 --- cs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 1ecd334f..08dbb987 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## cs/v0.13.7 (2026-04-23) + +* Remove unnecessary too many children exception +* Log error when volume type is unknown + ## cs/v0.13.6 (2026-04-22) * Handle too many children exception when creating a new draft From 716991a8ee618714a9be9a88a3b2059759213092 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 16:18:19 +0200 Subject: [PATCH 709/791] Improve exception type names in error reports --- .../Proton.Sdk.CExports/ExceptionExtensions.cs | 15 ++++++++++++++- .../Proton.Sdk.CExports.csproj | 1 + cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs index e5ef8968..f06fd48e 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs @@ -11,7 +11,7 @@ public static Error ToProtoError(this Exception exception, Action + diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index 84f1737a..3a748f75 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -36,6 +36,7 @@ + From 938f49b839615deae1d853c32df7cfac23baaa86 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Apr 2026 11:55:51 +0000 Subject: [PATCH 710/791] Reduce log in controllers --- .../proton/drive/sdk/CommonDownloadController.kt | 14 +++++++++----- .../me/proton/drive/sdk/CommonUploadController.kt | 14 +++++++++----- .../kotlin/me/proton/drive/sdk/FileUploader.kt | 5 ++++- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt index 07077b92..722a7c05 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -97,12 +97,16 @@ class CommonDownloadController internal constructor( runCatching { withTimeout(500.milliseconds) { awaitCompletion() } }.recoverCatching { error -> - if (error is TimeoutCancellationException) { - log(DEBUG, "Stop waiting for completion: ${error.message}") - } else if (error is CancellationException) { - throw error + when (error) { + is TimeoutCancellationException -> log( + DEBUG, + "Stop waiting for completion: ${error.message}" + ) + + is CancellationException -> throw error + is DownloadAbortedException -> Unit // do nothing + else -> log(DEBUG, "Error during waiting for completion: ${error.message}") } - log(DEBUG, "Error during waiting for completion: ${error.message}") } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt index 66154d73..01647e14 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt @@ -95,12 +95,16 @@ class CommonUploadController internal constructor( runCatching { withTimeout(500.milliseconds) { awaitCompletion() } }.recoverCatching { error -> - if (error is TimeoutCancellationException) { - log(DEBUG, "Stop waiting for completion: ${error.message}") - } else if (error is CancellationException) { - throw error + when (error) { + is TimeoutCancellationException -> log( + DEBUG, + "Stop waiting for completion: ${error.message}" + ) + + is CancellationException -> throw error + is UploadAbortedException -> Unit // do nothing + else -> log(DEBUG, "Error during waiting for completion: ${error.message}") } - log(DEBUG, "Error during waiting for completion: ${error.message}") } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt index eb33328b..efb9b1bc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -53,7 +53,10 @@ class FileUploader internal constructor( ).also(controllerReference::set) } - override fun close() = bridge.free(handle) + override fun close() { + log(DEBUG, "close") + bridge.free(handle) + } override suspend fun cancel() { log(INFO, "cancel") From 26c01114662ae13001d94d5473020e337def270d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Apr 2026 11:54:40 +0200 Subject: [PATCH 711/791] Add extension to aborted exception --- .../sdk/extension/OperationAbortedException.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/OperationAbortedException.kt diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/OperationAbortedException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/OperationAbortedException.kt new file mode 100644 index 00000000..d2caf892 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/OperationAbortedException.kt @@ -0,0 +1,16 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.OperationAbortedException +import me.proton.drive.sdk.ProtonDriveSdkException +import me.proton.drive.sdk.ProtonSdkError + +val OperationAbortedException.error: ProtonSdkError? + get() { + val abortedCause = cause + return if (abortedCause is ProtonDriveSdkException) { + abortedCause.error + } else { + null + } + } + From 7e05e81a5723b1b31db5a5a04dd1ebe6ce5b2852 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Apr 2026 17:08:39 +0200 Subject: [PATCH 712/791] Fix nullable data in name conflict error --- .../src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt | 6 +++--- .../proton/drive/sdk/extension/NodeNameConflictErrorData.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt index 73f28bf4..8be8d5b0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt @@ -31,9 +31,9 @@ data class ProtonSdkError( fun toSafe(): S data class NodeNameConflict( - val conflictingNodeIsFileDraft: Boolean, - val conflictingNodeUid: NodeUid, - val conflictingRevisionUid: RevisionUid, + val conflictingNodeIsFileDraft: Boolean?, + val conflictingNodeUid: NodeUid?, + val conflictingRevisionUid: RevisionUid?, ) : Data { override fun toSafe() = this } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt index 4f0ec44f..85e2d5be 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt @@ -6,7 +6,7 @@ import me.proton.drive.sdk.entity.RevisionUid import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.NodeNameConflictErrorData.toEntity() = ProtonSdkError.Data.NodeNameConflict( - conflictingNodeIsFileDraft = conflictingNodeIsFileDraft, - conflictingNodeUid = NodeUid(conflictingNodeUid), - conflictingRevisionUid = RevisionUid(conflictingRevisionUid), + conflictingNodeIsFileDraft = takeIf { hasConflictingNodeIsFileDraft() }?.let { conflictingNodeIsFileDraft }, + conflictingNodeUid = takeIf { hasConflictingNodeUid() }?.let { NodeUid(conflictingNodeUid) }, + conflictingRevisionUid = takeIf { hasConflictingRevisionUid() }?.let { RevisionUid(conflictingRevisionUid) }, ) From fd87afe3568a9151c60211e1d167469a820c8a1c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 05:31:21 +0000 Subject: [PATCH 713/791] Expose savePhotosToTimeline --- js/sdk/src/protonDrivePhotosClient.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index b91d5dea..85a975fe 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -690,6 +690,21 @@ export class ProtonDrivePhotosClient { yield* this.photos.albums.removePhotos(getUid(albumNodeUid), getUids(photoNodeUids), signal); } + /** + * Saves photos to the timeline. + * + * @param photoNodeUids - The UIDs of the photos to save to the timeline. + * @param signal - An optional abort signal to cancel the operation. + * @returns An async generator of per-photo results. + */ + async *savePhotosToTimeline( + photoNodeUids: NodeOrUid[], + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Saving ${photoNodeUids.length} photos to timeline`); + yield* this.photos.photos.saveToTimeline(getUids(photoNodeUids), signal); + } + /** * Updates photos with the given settings: add or remove tags. * From 8bd00b9db69595994e46262d7cc9c9e395d5f725 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 05:35:55 +0000 Subject: [PATCH 714/791] Update changelog for js/v0.14.9 --- js/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index a0ea93b0..ad879256 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## js/v0.14.9 (2026-04-27) + +* Expose savePhotosToTimeline + ## js/v0.14.8 (2026-04-23) * Update album metadata cache after albums api request From 9bd0fd64c62eda100b0c5133a8b6b3e0efe47bff Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 05:41:41 +0000 Subject: [PATCH 715/791] Update changelog for cs/v0.13.8 --- cs/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 08dbb987..db464b11 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## cs/v0.13.8 (2026-04-27) + +* Fix nullable data in name conflict error +* Add extension to aborted exception +* Reduce log in controllers +* Improve exception type names in error reports + ## cs/v0.13.7 (2026-04-23) * Remove unnecessary too many children exception From c894d1be4bff57ceba34edc2522be31cbc46bb79 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 10:09:57 +0200 Subject: [PATCH 716/791] Fix download queuing not blocking on full queue --- .../InteropDriveProtobufMetadata.cs | 16 + .../InteropProtonDriveClient.cs | 99 +++++-- .../InteropProtonPhotosClient.cs | 59 +++- .../Proton.Drive.Sdk/FifoFlexibleSemaphore.cs | 32 +- .../Nodes/Download/BlockDownloader.cs | 6 +- .../Nodes/Download/DownloadState.cs | 2 + .../Nodes/Download/FileDownloader.cs | 84 ++---- .../Nodes/Download/PhotosFileDownloader.cs | 77 ++--- .../Nodes/Download/RevisionReader.cs | 273 +++++++++--------- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 6 +- .../Nodes/RevisionOperations.cs | 30 +- .../Proton.Drive.Sdk/Nodes/TransferQueue.cs | 215 ++++++++++++-- .../Nodes/Upload/BlockUploader.cs | 6 +- .../Nodes/Upload/FileUploader.cs | 83 +++--- .../Nodes/Upload/RevisionWriter.cs | 148 +++++----- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 100 ++++--- .../ProtonDriveClientOptions.cs | 7 +- .../Proton.Drive.Sdk/ProtonPhotosClient.cs | 24 +- cs/sdk/src/protos/proton.drive.sdk.proto | 45 ++- 19 files changed, 780 insertions(+), 532 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveProtobufMetadata.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveProtobufMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveProtobufMetadata.cs new file mode 100644 index 00000000..ce50b909 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveProtobufMetadata.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using Google.Protobuf.Collections; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropDriveProtobufMetadata +{ + internal static IEnumerable? ParseAdditionalMetadata( + RepeatedField additionalMetadata) => + additionalMetadata.Count > 0 + ? additionalMetadata.Select(x => + new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty( + x.Name, + JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) + : null; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 6cd3c5dc..c693340f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -2,6 +2,8 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.CExports; @@ -19,10 +21,11 @@ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindi } var protonDriveClientOptions = new Sdk.ProtonDriveClientOptions( - request.ClientOptions.HasBindingsLanguage ? request.ClientOptions.BindingsLanguage : null, request.ClientOptions.HasUid ? request.ClientOptions.Uid : null, + request.ClientOptions.HasBindingsLanguage ? request.ClientOptions.BindingsLanguage : null, request.ClientOptions.HasApiCallTimeout ? request.ClientOptions.ApiCallTimeout : null, - request.ClientOptions.HasStorageCallTimeout ? request.ClientOptions.StorageCallTimeout : null); + request.ClientOptions.HasStorageCallTimeout ? request.ClientOptions.StorageCallTimeout : null, + request.ClientOptions.HasBlockTransferParallelism ? request.ClientOptions.BlockTransferParallelism : null); var httpClientFactory = new InteropHttpClientFactory( bindingsHandle, @@ -110,24 +113,45 @@ public static async ValueTask HandleCreateFolderAsync(DriveClientCreat public static async ValueTask HandleGetFileUploaderAsync(DriveClientGetFileUploaderRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - - var client = Interop.GetFromHandle(request.ClientHandle); - var additionalMetadata = request.AdditionalMetadata is { Count: > 0 } ? request.AdditionalMetadata.Select(x => new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; - var fileUploader = await client.GetFileUploaderAsync( - NodeUid.Parse(request.ParentFolderUid), - request.Name, - request.MediaType, - request.Size, - new FileUploadMetadata { LastModificationTime = request.LastModificationTime.ToDateTimeFixed(), AdditionalMetadata = additionalMetadata }, - request.OverrideExistingDraftByOtherClient, - cancellationToken).ConfigureAwait(false); + var metadata = new FileUploadMetadata + { + LastModificationTime = request.LastModificationTime.ToDateTimeFixed(), + AdditionalMetadata = additionalMetadata, + }; + + var client = Interop.GetFromHandle(request.ClientHandle); - return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; + FileUploader? fileUploader; + if (request is { HasNoWaiting: true, NoWaiting: true }) + { +#pragma warning disable TryTransferQueuing + fileUploader = client.TryGetFileUploader( + NodeUid.Parse(request.ParentFolderUid), + request.Name, + request.MediaType, + request.Size, + metadata, + request.OverrideExistingDraftByOtherClient); +#pragma warning restore TryTransferQueuing + } + else + { + fileUploader = await client.GetFileUploaderAsync( + NodeUid.Parse(request.ParentFolderUid), + request.Name, + request.MediaType, + request.Size, + metadata, + request.OverrideExistingDraftByOtherClient, + cancellationToken).ConfigureAwait(false); + } + + return new Int64Value { Value = fileUploader is null ? 0 : Interop.AllocHandle(fileUploader) }; } public static async ValueTask HandleGetFileRevisionUploaderAsync(DriveClientGetFileRevisionUploaderRequest request) @@ -141,13 +165,32 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) : null; - var fileUploader = await client.GetFileRevisionUploaderAsync( + var metadata = new FileUploadMetadata + { + LastModificationTime = request.LastModificationTime.ToDateTimeFixed(), + AdditionalMetadata = additionalMetadata, + }; + + FileUploader? fileUploader; + if (request is { HasNoWaiting: true, NoWaiting: true }) + { +#pragma warning disable TryTransferQueuing + fileUploader = client.TryGetFileRevisionUploader( + RevisionUid.Parse(request.CurrentActiveRevisionUid), + request.Size, + metadata); +#pragma warning restore TryTransferQueuing + } + else + { + fileUploader = await client.GetFileRevisionUploaderAsync( RevisionUid.Parse(request.CurrentActiveRevisionUid), request.Size, - new FileUploadMetadata { LastModificationTime = request.LastModificationTime.ToDateTimeFixed(), AdditionalMetadata = additionalMetadata }, + metadata, cancellationToken).ConfigureAwait(false); + } - return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; + return new Int64Value { Value = fileUploader is null ? 0 : Interop.AllocHandle(fileUploader) }; } public static async ValueTask HandleGetAvailableNameAsync(DriveClientGetAvailableNameRequest request) @@ -173,7 +216,7 @@ public static async ValueTask HandleGetAvailableNameAsync(DriveClientG var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( request.FileUids.Select(NodeUid.Parse), - (Sdk.Nodes.ThumbnailType)request.Type, + (Nodes.ThumbnailType)request.Type, cancellationToken); await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false)) @@ -239,12 +282,22 @@ public static async ValueTask HandleGetMyFilesFolderAsync(DriveClientG public static async ValueTask HandleGetFileDownloaderAsync(DriveClientGetFileDownloaderRequest request) { var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); - var client = Interop.GetFromHandle(request.ClientHandle); + var revisionUid = RevisionUid.Parse(request.RevisionUid); - var fileUploader = await client.GetFileDownloaderAsync(RevisionUid.Parse(request.RevisionUid), cancellationToken).ConfigureAwait(false); + FileDownloader? fileDownloader; + if (request is { HasNoWaiting: true, NoWaiting: true }) + { +#pragma warning disable TryTransferQueuing + fileDownloader = client.TryGetFileDownloader(revisionUid); +#pragma warning restore TryTransferQueuing + } + else + { + fileDownloader = await client.GetFileDownloaderAsync(revisionUid, cancellationToken).ConfigureAwait(false); + } - return new Int64Value { Value = Interop.AllocHandle(fileUploader) }; + return new Int64Value { Value = fileDownloader is null ? 0 : Interop.AllocHandle(fileDownloader) }; } public static async ValueTask HandleRenameAsync(DriveClientRenameRequest request) @@ -350,7 +403,7 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto return null; } - public static NodeResult ConvertToNodeResult(Result result) + public static NodeResult ConvertToNodeResult(Result result) { var nodeResult = new NodeResult(); @@ -441,7 +494,7 @@ private static NodeResultListResponse ConvertToNodeResultListResponse(IReadOnlyD }; } - private static OwnedBy MapOwnedByToProto(Sdk.Nodes.OwnedBy? ownedBy) + private static OwnedBy MapOwnedByToProto(Nodes.OwnedBy? ownedBy) { if (ownedBy is null) { diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 6b9e1ea7..5a44c098 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -2,6 +2,8 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Upload; using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.CExports; @@ -19,10 +21,11 @@ public static IMessage HandleCreate(DrivePhotosClientCreateRequest request, nint } var protonDriveClientOptions = new Sdk.ProtonDriveClientOptions( - request.ClientOptions.HasBindingsLanguage ? request.ClientOptions.BindingsLanguage : null, request.ClientOptions.HasUid ? request.ClientOptions.Uid : null, + request.ClientOptions.HasBindingsLanguage ? request.ClientOptions.BindingsLanguage : null, request.ClientOptions.HasApiCallTimeout ? request.ClientOptions.ApiCallTimeout : null, - request.ClientOptions.HasStorageCallTimeout ? request.ClientOptions.StorageCallTimeout : null); + request.ClientOptions.HasStorageCallTimeout ? request.ClientOptions.StorageCallTimeout : null, + request.ClientOptions.HasBlockTransferParallelism ? request.ClientOptions.BlockTransferParallelism : null); var httpClientFactory = new InteropHttpClientFactory( bindingsHandle, @@ -187,11 +190,21 @@ public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhot var client = Interop.GetFromHandle(request.ClientHandle); - var downloader = await client.GetPhotosDownloaderAsync( - NodeUid.Parse(request.PhotoUid), - cancellationToken).ConfigureAwait(false); + var photoUid = NodeUid.Parse(request.PhotoUid); - return new Int64Value { Value = Interop.AllocHandle(downloader) }; + PhotosFileDownloader? downloader; + if (request is { HasNoWaiting: true, NoWaiting: true }) + { +#pragma warning disable TryTransferQueuing + downloader = client.TryGetPhotosDownloader(photoUid); +#pragma warning restore TryTransferQueuing + } + else + { + downloader = await client.GetPhotosDownloaderAsync(photoUid, cancellationToken).ConfigureAwait(false); + } + + return new Int64Value { Value = downloader is null ? 0 : Interop.AllocHandle(downloader) }; } public static async ValueTask HandleEnumerateThumbnailsAsync(DrivePhotosClientEnumerateThumbnailsRequest request, nint bindingsHandle) @@ -203,7 +216,7 @@ public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhot var thumbnailsEnumerable = client.EnumerateThumbnailsAsync( request.PhotoUids.Select(NodeUid.Parse), - (Sdk.Nodes.ThumbnailType)request.Type, + (Nodes.ThumbnailType)request.Type, cancellationToken); await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false)) @@ -248,15 +261,31 @@ public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosCl var client = Interop.GetFromHandle(request.ClientHandle); - var uploader = await client.GetFileUploaderAsync( - request.Name, - request.MediaType, - request.Size, - metadata, - request.OverrideExistingDraftByOtherClient, - cancellationToken).ConfigureAwait(false); + FileUploader? uploader; + if (request is { HasNoWaiting: true, NoWaiting: true }) + { +#pragma warning disable TryTransferQueuing + uploader = await client.TryGetFileUploaderAsync( + request.Name, + request.MediaType, + request.Size, + metadata, + request.OverrideExistingDraftByOtherClient, + cancellationToken).ConfigureAwait(false); +#pragma warning restore TryTransferQueuing + } + else + { + uploader = await client.GetFileUploaderAsync( + request.Name, + request.MediaType, + request.Size, + metadata, + request.OverrideExistingDraftByOtherClient, + cancellationToken).ConfigureAwait(false); + } - return new Int64Value { Value = Interop.AllocHandle(uploader) }; + return new Int64Value { Value = uploader is null ? 0 : Interop.AllocHandle(uploader) }; } public static async ValueTask HandleFindDuplicatesAsync(DrivePhotosClientFindDuplicatesRequest request, nint bindingsHandle) diff --git a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs index a1fd6add..fe5fea92 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs @@ -18,6 +18,22 @@ public FifoFlexibleSemaphore(int maximumCount) public int MaximumCount { get; } public int CurrentCount { get; private set; } + public bool TryEnter(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + + lock (_waitingQueue) + { + if (CurrentCount <= 0) + { + return false; + } + + CurrentCount -= count; + return true; + } + } + public async ValueTask EnterAsync(int count, CancellationToken cancellationToken = default) { ArgumentOutOfRangeException.ThrowIfNegative(count); @@ -56,19 +72,27 @@ async ValueTask WaitAsync() } } + public void DecreaseCount(int count) + { + lock (_waitingQueue) + { + CurrentCount -= count; + } + } + public void Release(int count) { ArgumentOutOfRangeException.ThrowIfNegative(count); lock (_waitingQueue) { - CurrentCount += count; - - if (CurrentCount > MaximumCount) + if (CurrentCount + count > MaximumCount) { - CurrentCount = MaximumCount; + throw new InvalidOperationException("Releasing would increase the count beyond the maximum."); } + CurrentCount += count; + while (CurrentCount > 0 && _waitingQueue.TryDequeue(out var queuedEntry)) { var (countToDecrement, taskCompletionSource) = queuedEntry; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs index 179b2627..95bcd8f5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -13,16 +13,12 @@ internal sealed partial class BlockDownloader private readonly ProtonDriveClient _client; private readonly ILogger _logger; - internal BlockDownloader(ProtonDriveClient client, int maxDegreeOfParallelism) + internal BlockDownloader(ProtonDriveClient client) { _client = client; _logger = client.Telemetry.GetLogger("Block downloader"); - - Queue = new TransferQueue(maxDegreeOfParallelism, client.Telemetry.GetLogger("Block downloader queue")); } - public TransferQueue Queue { get; } - public async ValueTask> DownloadAsync( RevisionUid revisionUid, int index, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs index ca67944e..2b8014ed 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs @@ -9,6 +9,7 @@ internal sealed partial class DownloadState( PgpPrivateKey nodeKey, PgpSessionKey contentKey, BlockListingRevisionDto revisionDto, + long queueToken, ILogger logger) : IAsyncDisposable { private readonly List> _downloadedBlockDigests = []; @@ -20,6 +21,7 @@ internal sealed partial class DownloadState( public RevisionUid Uid { get; } = uid; public BlockListingRevisionDto RevisionDto { get; } = revisionDto; + public long QueueToken { get; } = queueToken; public PgpPrivateKey NodeKey { get; } = nodeKey; public PgpSessionKey ContentKey { get; } = contentKey; public bool IsResumable { get; set; } = true; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 127b1fed..aab044eb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -7,16 +7,16 @@ namespace Proton.Drive.Sdk.Nodes.Download; public sealed partial class FileDownloader : IFileDownloader { private readonly ProtonDriveClient _client; + private readonly long _queueToken; private readonly RevisionUid _revisionUid; private readonly ILogger _logger; - private volatile int _remainingNumberOfBlocksToList; - private FileDownloader(ProtonDriveClient client, RevisionUid revisionUid, ILogger logger) + private FileDownloader(ProtonDriveClient client, long queueToken, RevisionUid revisionUid, ILogger logger) { _client = client; + _queueToken = queueToken; _revisionUid = revisionUid; _logger = logger; - _remainingNumberOfBlocksToList = 1; } public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) @@ -33,30 +33,37 @@ public DownloadController DownloadToFile(string filePath, Action onP public void Dispose() { - ReleaseRemainingBlockListing(); + _client.DownloadQueue.RemoveFileFromQueue(_queueToken); } - internal static async ValueTask CreateAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) + internal static FileDownloader? TryCreate(ProtonDriveClient client, RevisionUid revisionUid) { - var logger = client.Telemetry.GetLogger("File downloader"); - - LogAcquiringBlockListingSemaphore(logger, revisionUid, 1); - - await client.BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); + const int initialEstimatedNumberOfBlocks = 1; - LogAcquiredBlockListingSemaphore(logger, revisionUid, 1); + if (client.DownloadQueue.TryEnqueueFile(initialEstimatedNumberOfBlocks) is not { } queueToken) + { + return null; + } - return new FileDownloader(client, revisionUid, logger); + return new FileDownloader( + client, + queueToken, + revisionUid, + client.Telemetry.GetLogger("File downloader")); } - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire {Count} from block listing semaphore for revision \"{RevisionUid}\"")] - private static partial void LogAcquiringBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int count); + internal static async ValueTask CreateAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) + { + const int initialEstimatedNumberOfBlocks = 1; - [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired {Count} from block listing semaphore for revision \"{RevisionUid}\"")] - private static partial void LogAcquiredBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int count); + var queueToken = await client.DownloadQueue.EnqueueFileAsync(initialEstimatedNumberOfBlocks, cancellationToken).ConfigureAwait(false); - [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from block listing semaphore for revision \"{RevisionUid}\"")] - private static partial void LogReleasedBlockListingSemaphore(ILogger logger, RevisionUid revisionUid, int count); + return new FileDownloader( + client, + queueToken, + revisionUid, + client.Telemetry.GetLogger("File downloader")); + } [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to record telemetry event")] private static partial void LogTelemetryEventFailed(ILogger logger, Exception exception); @@ -65,23 +72,22 @@ private async Task DownloadToStreamAsync( Stream contentOutputStream, Action onProgress, TaskCompletionSource downloadStateTaskCompletionSource, + long queueToken, CancellationToken cancellationToken) { var downloadState = downloadStateTaskCompletionSource.Task.GetResultIfCompletedSuccessfully(); if (downloadState is null) { downloadState = await RevisionOperations.CreateDownloadStateAsync( - _client, - _revisionUid, - ReleaseBlockListing, - cancellationToken).ConfigureAwait(false); + _client, + _revisionUid, + queueToken, + cancellationToken).ConfigureAwait(false); downloadStateTaskCompletionSource.SetResult(downloadState); } - await _client.BlockDownloader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); - - using var revisionReader = RevisionOperations.OpenForReading(_client, downloadState, ReleaseBlockListing); + var revisionReader = RevisionOperations.OpenForReading(_client, downloadState); await revisionReader.ReadAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); } @@ -100,6 +106,7 @@ private DownloadController BuildDownloadController( contentOutputStream, onProgress, downloadStateTaskCompletionSource, + _queueToken, ct); return new DownloadController( @@ -149,31 +156,4 @@ private void RaiseTelemetryEvent(DownloadEvent downloadEvent) LogTelemetryEventFailed(_logger, ex); } } - - private void ReleaseBlockListing(int numberOfBlockListings) - { - var newRemainingNumberOfBlocks = Interlocked.Add(ref _remainingNumberOfBlocksToList, -numberOfBlockListings); - - var amountToRelease = Math.Max( - newRemainingNumberOfBlocks >= 0 - ? numberOfBlockListings - : newRemainingNumberOfBlocks + numberOfBlockListings, - 0); - - _client.BlockListingSemaphore.Release(amountToRelease); - LogReleasedBlockListingSemaphore(_logger, _revisionUid, amountToRelease); - } - - private void ReleaseRemainingBlockListing() - { - if (_remainingNumberOfBlocksToList <= 0) - { - return; - } - - _client.BlockListingSemaphore.Release(_remainingNumberOfBlocksToList); - LogReleasedBlockListingSemaphore(_logger, _revisionUid, _remainingNumberOfBlocksToList); - - _remainingNumberOfBlocksToList = 0; - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index c176a419..045e2a25 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -7,16 +7,15 @@ public sealed partial class PhotosFileDownloader : IFileDownloader { private readonly ProtonPhotosClient _client; private readonly NodeUid _photoUid; + private readonly long _queueToken; private readonly ILogger _logger; - private volatile int _remainingNumberOfBlocksToList; - - private PhotosFileDownloader(ProtonPhotosClient client, NodeUid photoUid, ILogger logger) + private PhotosFileDownloader(ProtonPhotosClient client, NodeUid photoUid, long queueToken, ILogger logger) { _client = client; _photoUid = photoUid; + _queueToken = queueToken; _logger = logger; - _remainingNumberOfBlocksToList = 1; } public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) @@ -27,32 +26,43 @@ public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { var stream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + return DownloadToStream(stream, ownsOutputStream: true, onProgress, cancellationToken); } public void Dispose() { - ReleaseRemainingBlockListing(); + _client.DriveClient.DownloadQueue.RemoveFileFromQueue(_queueToken); } - internal static async ValueTask CreateAsync(ProtonPhotosClient client, NodeUid photoUid, CancellationToken cancellationToken) + internal static PhotosFileDownloader? TryCreate(ProtonPhotosClient client, NodeUid photoUid) { - var logger = client.DriveClient.Telemetry.GetLogger("Photo downloader"); - LogEnteringBlockListingSemaphore(logger, photoUid, 1); - await client.DriveClient.BlockListingSemaphore.EnterAsync(1, cancellationToken).ConfigureAwait(false); - LogEnteredBlockListingSemaphore(logger, photoUid, 1); + const int initialEstimatedNumberOfBlocks = 1; + + if (client.DriveClient.DownloadQueue.TryEnqueueFile(initialEstimatedNumberOfBlocks) is not { } queueToken) + { + return null; + } - return new PhotosFileDownloader(client, photoUid, logger); + return new PhotosFileDownloader( + client, + photoUid, + queueToken, + client.DriveClient.Telemetry.GetLogger("Photos file downloader")); } - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to enter block listing semaphore for photo {PhotoUid} with {Increment}")] - private static partial void LogEnteringBlockListingSemaphore(ILogger logger, NodeUid photoUid, int increment); + internal static async ValueTask CreateAsync(ProtonPhotosClient client, NodeUid photoUid, CancellationToken cancellationToken) + { + const int initialEstimatedNumberOfBlocks = 1; - [LoggerMessage(Level = LogLevel.Trace, Message = "Entered block listing semaphore for photo {PhotoUid} with {Increment}")] - private static partial void LogEnteredBlockListingSemaphore(ILogger logger, NodeUid photoUid, int increment); + var queuePosition = await client.DriveClient.DownloadQueue.EnqueueFileAsync(initialEstimatedNumberOfBlocks, cancellationToken).ConfigureAwait(false); - [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Decrement} from block listing semaphore for photo {PhotoUid}")] - private static partial void LogReleasedBlockListingSemaphore(ILogger logger, NodeUid photoUid, int decrement); + return new PhotosFileDownloader( + client, + photoUid, + queuePosition, + client.DriveClient.Telemetry.GetLogger("Photos file downloader")); + } [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to record telemetry event")] private static partial void LogTelemetryEventFailed(ILogger logger, Exception exception); @@ -75,7 +85,7 @@ private async Task DownloadToStreamAsync( var state = await RevisionOperations.CreateDownloadStateAsync( _client.DriveClient, fileNode.ActiveRevision.Uid, - ReleaseBlockListing, + _queueToken, cancellationToken).ConfigureAwait(false); downloadStateTaskCompletionSource.SetResult(state); @@ -83,9 +93,7 @@ private async Task DownloadToStreamAsync( var downloadState = await downloadStateTaskCompletionSource.Task.ConfigureAwait(false); - await _client.DriveClient.BlockDownloader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); - - using var revisionReader = RevisionOperations.OpenForReading(_client.DriveClient, downloadState, ReleaseBlockListing); + var revisionReader = RevisionOperations.OpenForReading(_client.DriveClient, downloadState); await revisionReader.ReadAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); } @@ -154,31 +162,4 @@ private void RaiseTelemetryEvent(DownloadEvent downloadEvent) LogTelemetryEventFailed(_logger, ex); } } - - private void ReleaseBlockListing(int numberOfBlockListings) - { - var newRemainingNumberOfBlocks = Interlocked.Add(ref _remainingNumberOfBlocksToList, -numberOfBlockListings); - - var amountToRelease = Math.Max( - newRemainingNumberOfBlocks >= 0 - ? numberOfBlockListings - : newRemainingNumberOfBlocks + numberOfBlockListings, - 0); - - _client.DriveClient.BlockListingSemaphore.Release(amountToRelease); - LogReleasedBlockListingSemaphore(_logger, _photoUid, amountToRelease); - } - - private void ReleaseRemainingBlockListing() - { - if (_remainingNumberOfBlocksToList <= 0) - { - return; - } - - _client.DriveClient.BlockListingSemaphore.Release(_remainingNumberOfBlocksToList); - LogReleasedBlockListingSemaphore(_logger, _photoUid, _remainingNumberOfBlocksToList); - - _remainingNumberOfBlocksToList = 0; - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index a38f373e..746b1793 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -1,12 +1,13 @@ using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; +using Microsoft.IO; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Download; -internal sealed partial class RevisionReader : IDisposable +internal sealed partial class RevisionReader { public const int MinBlockIndex = 1; public const int DefaultBlockPageSize = 10; @@ -14,24 +15,16 @@ internal sealed partial class RevisionReader : IDisposable private readonly ProtonDriveClient _client; private readonly DownloadState _state; - private readonly Action _releaseBlockListingAction; - private readonly Action _releaseFileSemaphoreAction; private readonly int _blockPageSize; private readonly ILogger _logger; - private bool _fileSemaphoreReleased; - internal RevisionReader( ProtonDriveClient client, DownloadState state, - Action releaseBlockListingAction, - Action releaseFileSemaphoreAction, int blockPageSize = DefaultBlockPageSize) { _client = client; _state = state; - _releaseBlockListingAction = releaseBlockListingAction; - _releaseFileSemaphoreAction = releaseFileSemaphoreAction; _blockPageSize = blockPageSize; _logger = client.Telemetry.GetLogger("Revision reader"); } @@ -40,14 +33,13 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action { try { - var downloadTasks = new Queue>(_client.BlockDownloader.Queue.Depth); + var revisionDto = _state.RevisionDto; + var downloadedBlockDigests = _state.GetDownloadedBlockDigests(); + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); await using (manifestStream) { - var downloadedBlockDigests = _state.GetDownloadedBlockDigests(); - var revisionDto = _state.RevisionDto; - if (revisionDto.Thumbnails is { } thumbnails) { foreach (var sha256Digest in thumbnails.OrderBy(t => t.Type).Select(x => x.HashDigest)) @@ -61,59 +53,7 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action manifestStream.Write(digest.Span); } - try - { - try - { - var startBlockIndex = _state.GetNextBlockIndexToDownload(); - - await foreach (var (block, _) in GetBlocksAsync(startBlockIndex, cancellationToken).ConfigureAwait(false)) - { - if (!_client.BlockDownloader.Queue.TryStartBlock()) - { - if (downloadTasks.Count > 0) - { - await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) - .ConfigureAwait(false); - } - - await _client.BlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); - } - - var downloadTask = DownloadBlockAsync(block, cancellationToken); - - downloadTasks.Enqueue(downloadTask); - } - } - finally - { - _releaseFileSemaphoreAction.Invoke(); - _fileSemaphoreReleased = true; - } - - while (downloadTasks.Count > 0) - { - await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) - .ConfigureAwait(false); - } - } - catch when (downloadTasks.Count > 0) - { - try - { - await Task.WhenAll(downloadTasks).ConfigureAwait(false); - } - catch - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw - } - finally - { - _client.BlockDownloader.Queue.FinishBlocks(downloadTasks.Count); - } - - throw; - } + await DownloadBlocks(contentOutputStream, onProgress, manifestStream, cancellationToken).ConfigureAwait(false); manifestStream.Seek(0, SeekOrigin.Begin); @@ -136,14 +76,6 @@ await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestSt } } - public void Dispose() - { - if (!_fileSemaphoreReleased) - { - _releaseFileSemaphoreAction.Invoke(); - } - } - private static bool IsResumableError(Exception ex) { return ex is not DataIntegrityException @@ -152,6 +84,70 @@ and not CompletedDownloadManifestVerificationException and not InvalidOperationException; } + private async ValueTask DownloadBlocks( + Stream contentOutputStream, + Action onProgress, + RecyclableMemoryStream manifestStream, + CancellationToken cancellationToken) + { + var startBlockIndex = _state.GetNextBlockIndexToDownload(); + + var downloadTasks = new Queue>(_client.DownloadQueue.Depth); + + try + { + await _client.DownloadQueue.StartBlockQueueingAsync(cancellationToken).ConfigureAwait(false); + + try + { + await foreach (var (block, _) in GetBlocksAsync(startBlockIndex, cancellationToken).ConfigureAwait(false)) + { + if (!_client.DownloadQueue.TryEnqueueBlock()) + { + if (downloadTasks.Count > 0) + { + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); + } + + await _client.DownloadQueue.EnqueueBlockAsync(cancellationToken).ConfigureAwait(false); + } + + var downloadTask = DownloadBlockAsync(block, cancellationToken); + + downloadTasks.Enqueue(downloadTask); + } + } + finally + { + _client.DownloadQueue.FinishBlockQueueing(); + } + + while (downloadTasks.Count > 0) + { + await WriteNextBlockToOutputAsync(downloadTasks, contentOutputStream, manifestStream, onProgress, cancellationToken) + .ConfigureAwait(false); + } + } + catch when (downloadTasks.Count > 0) + { + try + { + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + } + finally + { + _client.DownloadQueue.DequeueBlocks(downloadTasks.Count); + } + + throw; + } + } + private async Task WriteNextBlockToOutputAsync( Queue> downloadTasks, Stream outputStream, @@ -197,6 +193,8 @@ private async Task WriteNextBlockToOutputAsync( _state.AddDownloadedBlockDigest(blockDigest); manifestStream.Write(blockDigest.Span); + _client.DownloadQueue.DecreaseFileRemainingBlockCount(_state.QueueToken, 1); + onProgress(_state.GetNumberOfBytesWritten(), _state.RevisionDto.Size); } finally @@ -206,7 +204,7 @@ private async Task WriteNextBlockToOutputAsync( } finally { - _client.BlockDownloader.Queue.FinishBlocks(1); + _client.DownloadQueue.DequeueBlocks(1); } } } @@ -250,89 +248,88 @@ private async Task DownloadBlockAsync(BlockDto block, Cance int startBlockIndex, [EnumeratorCancellation] CancellationToken cancellationToken) { - try - { - var mustTryNextPageOfBlocks = true; - var nextExpectedIndex = startBlockIndex; - var outstandingBlock = default(BlockDto); - var currentPageBlocks = new List(_blockPageSize); - - // Fetch the first page of blocks starting from the desired index - var revisionResponse = await _client.Api.Files.GetRevisionAsync( - _state.Uid.NodeUid.VolumeId, - _state.Uid.NodeUid.LinkId, - _state.Uid.RevisionId, - startBlockIndex, - _blockPageSize, - withoutBlockUrls: false, - cancellationToken).ConfigureAwait(false); - - var revisionDto = revisionResponse.Revision; - - while (mustTryNextPageOfBlocks) - { - currentPageBlocks.Clear(); + var mustTryNextPageOfBlocks = true; + var nextExpectedIndex = startBlockIndex; + var outstandingBlock = default(BlockDto); + var currentPageBlocks = new List(_blockPageSize); + + // Fetch the first page of blocks starting from the desired index + var revisionResponse = await _client.Api.Files.GetRevisionAsync( + _state.Uid.NodeUid.VolumeId, + _state.Uid.NodeUid.LinkId, + _state.Uid.RevisionId, + startBlockIndex, + _blockPageSize, + withoutBlockUrls: false, + cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); + var revisionDto = revisionResponse.Revision; - if (revisionDto.Blocks.Count == 0) - { - break; - } + // The first block is already in the queue, so we subtract it from the first page of block results + var initialQueueCountToSubtract = 1; - mustTryNextPageOfBlocks = revisionDto.Blocks.Count >= _blockPageSize; + while (mustTryNextPageOfBlocks) + { + currentPageBlocks.Clear(); - currentPageBlocks.AddRange(revisionDto.Blocks); - currentPageBlocks.Sort((a, b) => a.Index.CompareTo(b.Index)); + cancellationToken.ThrowIfCancellationRequested(); - var blocksExceptLast = currentPageBlocks.Take(currentPageBlocks.Count - 1); - var blocksToReturn = outstandingBlock is not null ? blocksExceptLast.Prepend(outstandingBlock) : blocksExceptLast; + if (revisionDto.Blocks.Count == 0) + { + break; + } - outstandingBlock = currentPageBlocks[^1]; - var lastKnownIndex = outstandingBlock.Index; + mustTryNextPageOfBlocks = revisionDto.Blocks.Count >= _blockPageSize; - foreach (var block in blocksToReturn) - { - cancellationToken.ThrowIfCancellationRequested(); + currentPageBlocks.AddRange(revisionDto.Blocks); + currentPageBlocks.Sort((a, b) => a.Index.CompareTo(b.Index)); - if (block.Index != nextExpectedIndex) - { - LogMissingBlock(block.Index, _state.Uid); + _client.DownloadQueue.IncreaseFileRemainingBlockCount(_state.QueueToken, currentPageBlocks.Count - initialQueueCountToSubtract); + initialQueueCountToSubtract = 0; - throw new ProtonDriveException("File contents are incomplete"); - } + var blocksExceptLast = currentPageBlocks.Take(currentPageBlocks.Count - 1); + var blocksToReturn = outstandingBlock is not null ? blocksExceptLast.Prepend(outstandingBlock) : blocksExceptLast; - ++nextExpectedIndex; + outstandingBlock = currentPageBlocks[^1]; + var lastKnownIndex = outstandingBlock.Index; - yield return (block, false); - } + foreach (var block in blocksToReturn) + { + cancellationToken.ThrowIfCancellationRequested(); - if (mustTryNextPageOfBlocks) + if (block.Index != nextExpectedIndex) { - revisionResponse = - await _client.Api.Files.GetRevisionAsync( - _state.Uid.NodeUid.VolumeId, - _state.Uid.NodeUid.LinkId, - _state.Uid.RevisionId, - lastKnownIndex + 1, - _blockPageSize, - false, - cancellationToken).ConfigureAwait(false); - - revisionDto = revisionResponse.Revision; + LogMissingBlock(block.Index, _state.Uid); + + throw new ProtonDriveException("File contents are incomplete"); } + + ++nextExpectedIndex; + + yield return (block, false); } - if (outstandingBlock is not null) + if (mustTryNextPageOfBlocks) { - cancellationToken.ThrowIfCancellationRequested(); - - yield return (outstandingBlock, true); + revisionResponse = + await _client.Api.Files.GetRevisionAsync( + _state.Uid.NodeUid.VolumeId, + _state.Uid.NodeUid.LinkId, + _state.Uid.RevisionId, + lastKnownIndex + 1, + _blockPageSize, + false, + cancellationToken).ConfigureAwait(false); + + revisionDto = revisionResponse.Revision; } } - finally + + if (outstandingBlock is not null) { - _releaseBlockListingAction.Invoke(1); + cancellationToken.ThrowIfCancellationRequested(); + + yield return (outstandingBlock, true); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 24e8d529..f355b065 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -134,14 +134,14 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( processedThumbnailIds.Add(block.ThumbnailId); var nodeInfo = thumbnailIds[block.ThumbnailId]; - if (!client.ThumbnailBlockDownloader.Queue.TryStartBlock()) + if (!client.ThumbnailDownloadQueue.TryEnqueueBlock()) { if (tasks.Count > 0) { yield return await tasks.Dequeue().ConfigureAwait(false); } - await client.ThumbnailBlockDownloader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); + await client.ThumbnailDownloadQueue.EnqueueBlockAsync(cancellationToken).ConfigureAwait(false); } tasks.Enqueue(DownloadThumbnailAsync(client, nodeInfo.ActiveRevisionUid, block, cancellationToken)); @@ -213,7 +213,7 @@ await client.ThumbnailBlockDownloader.DownloadAsync( } finally { - client.ThumbnailBlockDownloader.Queue.FinishBlocks(1); + client.ThumbnailDownloadQueue.DequeueBlocks(1); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index ce90cb80..81476397 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -5,26 +5,18 @@ namespace Proton.Drive.Sdk.Nodes; internal static class RevisionOperations { - public static async ValueTask OpenForWritingAsync( + public static RevisionWriter OpenForWriting( ProtonDriveClient client, RevisionDraft draft, - Action releaseBlocksAction, - CancellationToken cancellationToken) + long queueToken) { - await client.BlockUploader.Queue.StartFileAsync(cancellationToken).ConfigureAwait(false); - - return new RevisionWriter( - client, - draft, - releaseBlocksAction, - () => client.BlockUploader.Queue.FinishFile(), - client.TargetBlockSize); + return new RevisionWriter(client, draft, queueToken, client.TargetBlockSize); } internal static async ValueTask CreateDownloadStateAsync( ProtonDriveClient client, RevisionUid revisionUid, - Action releaseBlockListingAction, + long queueToken, CancellationToken cancellationToken) { var (fileUid, revisionId) = revisionUid; @@ -53,25 +45,17 @@ internal static async ValueTask CreateDownloadStateAsync( : (degradedFileSecrets.Key ?? throw new InvalidOperationException($"Node key not available for file {revisionUid.NodeUid}"), degradedFileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}")); - releaseBlockListingAction.Invoke(1); - return new DownloadState( revisionUid, key, contentKey, revisionResponse.Revision, + queueToken, client.Telemetry.GetLogger("Download state")); } - internal static RevisionReader OpenForReading( - ProtonDriveClient client, - DownloadState downloadState, - Action releaseBlockListingAction) + internal static RevisionReader OpenForReading(ProtonDriveClient client, DownloadState downloadState) { - return new RevisionReader( - client, - downloadState, - releaseBlockListingAction, - () => client.BlockDownloader.Queue.FinishFile()); + return new RevisionReader(client, downloadState); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs index 7a0f3ebd..6f145524 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs @@ -2,76 +2,227 @@ namespace Proton.Drive.Sdk.Nodes; +/// +/// Manages the queueing of the transfer of files and their blocks. +/// +/// +/// +/// To use this queue, acquire a slot for a file with an initial number of blocks (the actual number of block may not be known initially) +/// using or . Acquisition of that file slot happens once there are enough free block upload slots +/// to accommodate at least one block of that file. Once the file slot is acquired, a queue token is returned. +/// +/// +/// Next, the transfer has to start queuing blocks, but only one file can be queuing blocks at a time, +/// so a call to is required. Once all the blocks have been queued, or if the queuing needs to be stopped for any reason, +/// a call to is required to allow other files to start queuing their blocks. +/// When new blocks are discovered for a file during queuing, they can be added to the file's block count using . +/// When blocks have been transferred, they can be removed from the file's block count using . +/// +/// +/// When a block is ready to be transferred, a slot for block transfer needs to be acquired +/// using or . Block transfer slots are acquired individually, and there can be multiple blocks +/// being transferred at the same time up to the maximum degree of parallelism specified for the queue. +/// Once a block transfer is completed, the slot for block transfer needs to be released using +/// to allow other blocks to be transferred. +/// +/// +/// The maximum number of blocks that can be transferred simultaneously +/// A logger internal sealed partial class TransferQueue(int maxDegreeOfParallelism, ILogger logger) { private readonly ILogger _logger = logger; + private readonly Dictionary _fileBlocks = []; + private readonly Lock _fileBlocksLock = new(); - public SemaphoreSlim FileSemaphore { get; } = new(1, 1); - public SemaphoreSlim BlockSemaphore { get; } = new(maxDegreeOfParallelism, maxDegreeOfParallelism); + private long _lastEntryId; + + public FifoFlexibleSemaphore FileQueueSemaphore { get; } = new(maxDegreeOfParallelism); + public SemaphoreSlim BlockQueueingSemaphore { get; } = new(1, 1); + public SemaphoreSlim BlockTransferSemaphore { get; } = new(maxDegreeOfParallelism, maxDegreeOfParallelism); public int Depth { get; } = maxDegreeOfParallelism; - public async ValueTask StartFileAsync(CancellationToken cancellationToken) + public long? TryEnqueueFile(int initialBlockCount) { - LogAcquiringFileSemaphore(FileSemaphore.CurrentCount); + ArgumentOutOfRangeException.ThrowIfNegative(initialBlockCount); + + LogTryingToAcquireFileQueueSemaphore(FileQueueSemaphore.CurrentCount); + + if (!FileQueueSemaphore.TryEnter(initialBlockCount)) + { + LogFailedToAcquireFileQueueSemaphore(FileQueueSemaphore.CurrentCount); + return null; + } + + LogAcquiredFileQueueSemaphore(FileQueueSemaphore.CurrentCount); + + var queuePosition = Interlocked.Increment(ref _lastEntryId); + + lock (_fileBlocksLock) + { + _fileBlocks.Add(queuePosition, initialBlockCount); + } + + return queuePosition; + } + + public async ValueTask EnqueueFileAsync(int initialBlockCount, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegative(initialBlockCount); + + LogAcquiringFileQueueSemaphore(FileQueueSemaphore.CurrentCount); - await FileSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + await FileQueueSemaphore.EnterAsync(initialBlockCount, cancellationToken).ConfigureAwait(false); - LogAcquiredFileSemaphore(FileSemaphore.CurrentCount); + LogAcquiredFileQueueSemaphore(FileQueueSemaphore.CurrentCount); + + var queuePosition = Interlocked.Increment(ref _lastEntryId); + + lock (_fileBlocksLock) + { + _fileBlocks.Add(queuePosition, initialBlockCount); + } + + return queuePosition; } - public void FinishFile() + public void IncreaseFileRemainingBlockCount(long queueToken, int additionalBlockCount) { - FileSemaphore.Release(); + ArgumentOutOfRangeException.ThrowIfNegative(additionalBlockCount); + + FileQueueSemaphore.DecreaseCount(additionalBlockCount); - LogReleasedFileSemaphore(FileSemaphore.CurrentCount); + LogDecreasedFileQueueSemaphoreCount(additionalBlockCount, FileQueueSemaphore.CurrentCount); + + lock (_fileBlocksLock) + { + var currentBlockCount = _fileBlocks.GetValueOrDefault(queueToken); + + _fileBlocks[queueToken] = currentBlockCount + additionalBlockCount; + } } - public bool TryStartBlock() + public void DecreaseFileRemainingBlockCount(long queueToken, int count) { - LogAcquiringBlockSemaphore(BlockSemaphore.CurrentCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + + lock (_fileBlocksLock) + { + if (!_fileBlocks.TryGetValue(queueToken, out var currentBlockCount)) + { + throw new InvalidOperationException($"Queue token {queueToken} not found in transfer queue."); + } - var result = BlockSemaphore.Wait(0); + _fileBlocks[queueToken] = currentBlockCount - count; + + RemoveBlocksFromFileQueue(count); + } + } + + public void RemoveFileFromQueue(long queueToken) + { + lock (_fileBlocksLock) + { + if (!_fileBlocks.Remove(queueToken, out var blockCount)) + { + throw new InvalidOperationException($"Queue token {queueToken} not found in transfer queue."); + } + + RemoveBlocksFromFileQueue(blockCount); + } + } + + public async ValueTask StartBlockQueueingAsync(CancellationToken cancellationToken) + { + LogAcquiringBlockQueueingSemaphore(BlockQueueingSemaphore.CurrentCount); + + await BlockQueueingSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + LogAcquiredBlockQueueingSemaphore(BlockQueueingSemaphore.CurrentCount); + } + + public void FinishBlockQueueing() + { + BlockQueueingSemaphore.Release(); + + LogReleasedBlockQueueingSemaphore(BlockQueueingSemaphore.CurrentCount); + } + + public bool TryEnqueueBlock() + { + LogAcquiringBlockTransferSemaphore(BlockTransferSemaphore.CurrentCount); + + var result = BlockTransferSemaphore.Wait(0); if (result) { - LogAcquiredBlockSemaphore(BlockSemaphore.CurrentCount); + LogAcquiredBlockTransferSemaphore(BlockTransferSemaphore.CurrentCount); } return result; } - public async ValueTask StartBlockAsync(CancellationToken cancellationToken) + public async ValueTask EnqueueBlockAsync(CancellationToken cancellationToken) + { + LogAcquiringBlockTransferSemaphore(BlockTransferSemaphore.CurrentCount); + + await BlockTransferSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + LogAcquiredBlockTransferSemaphore(BlockTransferSemaphore.CurrentCount); + } + + /// + /// Removes blocks from the block transfer queue, making room for new blocks to be queued. + /// + public void DequeueBlocks(int count) { - LogAcquiringBlockSemaphore(BlockSemaphore.CurrentCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); - await BlockSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + BlockTransferSemaphore.Release(count); - LogAcquiredBlockSemaphore(BlockSemaphore.CurrentCount); + LogReleasedBlockTransferSemaphore(count, BlockTransferSemaphore.CurrentCount); } - public void FinishBlocks(int count) + private void RemoveBlocksFromFileQueue(int blockCount) { - BlockSemaphore.Release(count); + FileQueueSemaphore.Release(blockCount); - LogReleasedBlockSemaphore(count, BlockSemaphore.CurrentCount); + LogReleasedFileQueueSemaphore(blockCount, FileQueueSemaphore.CurrentCount); } - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire file semaphore, current count is {CurrentCount}")] - private partial void LogAcquiringFileSemaphore(int currentCount); + [LoggerMessage(Level = LogLevel.Trace, Message = "Waiting to acquire file queue semaphore, current count is {CurrentCount}")] + private partial void LogAcquiringFileQueueSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire file queue semaphore, current count is {CurrentCount}")] + private partial void LogTryingToAcquireFileQueueSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired file queue semaphore, current count is {CurrentCount}")] + private partial void LogAcquiredFileQueueSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Failed to acquire file queue semaphore, current count is {CurrentCount}")] + private partial void LogFailedToAcquireFileQueueSemaphore(int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Increased file queue count by {Count}, current count is {CurrentCount}")] + private partial void LogDecreasedFileQueueSemaphoreCount(int count, int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from file queue semaphore, current count is {CurrentCount}")] + private partial void LogReleasedFileQueueSemaphore(int count, int currentCount); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Waiting to acquire block queueing semaphore, current count is {CurrentCount}")] + private partial void LogAcquiringBlockQueueingSemaphore(int currentCount); - [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired file semaphore, current count is {CurrentCount}")] - private partial void LogAcquiredFileSemaphore(int currentCount); + [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired block queueing semaphore, current count is {CurrentCount}")] + private partial void LogAcquiredBlockQueueingSemaphore(int currentCount); - [LoggerMessage(Level = LogLevel.Trace, Message = "Released file semaphore, current count is {CurrentCount}")] - private partial void LogReleasedFileSemaphore(int currentCount); + [LoggerMessage(Level = LogLevel.Trace, Message = "Released block queueing semaphore, current count is {CurrentCount}")] + private partial void LogReleasedBlockQueueingSemaphore(int currentCount); - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire block semaphore, current count is {CurrentCount}")] - private partial void LogAcquiringBlockSemaphore(int currentCount); + [LoggerMessage(Level = LogLevel.Trace, Message = "Waiting to acquire block transfer semaphore, current count is {CurrentCount}")] + private partial void LogAcquiringBlockTransferSemaphore(int currentCount); - [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired block semaphore, current count is {CurrentCount}")] - private partial void LogAcquiredBlockSemaphore(int currentCount); + [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired block transfer semaphore, current count is {CurrentCount}")] + private partial void LogAcquiredBlockTransferSemaphore(int currentCount); - [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from block semaphore, current count is {CurrentCount}")] - private partial void LogReleasedBlockSemaphore(int count, int currentCount); + [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from block transfer semaphore, current count is {CurrentCount}")] + private partial void LogReleasedBlockTransferSemaphore(int count, int currentCount); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index fa0b3e3b..20c0dc74 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -18,16 +18,12 @@ internal sealed partial class BlockUploader private readonly ProtonDriveClient _client; private readonly ILogger _logger; - internal BlockUploader(ProtonDriveClient client, int maxDegreeOfParallelism) + internal BlockUploader(ProtonDriveClient client) { _client = client; _logger = client.Telemetry.GetLogger("Block uploader"); - - Queue = new TransferQueue(maxDegreeOfParallelism, client.Telemetry.GetLogger("Block uploader queue")); } - public TransferQueue Queue { get; } - public async ValueTask UploadContentAsync( RevisionDraft draft, int blockNumber, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index c2950547..6212359d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -5,31 +5,30 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -public sealed partial class FileUploader : IDisposable +public sealed class FileUploader : IDisposable { private readonly ProtonDriveClient _client; + private readonly long _queueToken; private readonly IRevisionDraftProvider _revisionDraftProvider; private readonly NodeUid _telemetryContextNodeUid; private readonly FileUploadMetadata _metadata; private readonly ILogger _logger; - private volatile int _remainingNumberOfBlocks; - private FileUploader( ProtonDriveClient client, + long queueToken, IRevisionDraftProvider revisionDraftProvider, NodeUid telemetryContextNodeUid, long size, FileUploadMetadata metadata, - int expectedNumberOfBlocks, ILogger logger) { _client = client; + _queueToken = queueToken; _revisionDraftProvider = revisionDraftProvider; _telemetryContextNodeUid = telemetryContextNodeUid; FileSize = size; _metadata = metadata; - _remainingNumberOfBlocks = expectedNumberOfBlocks; _logger = logger; } @@ -71,45 +70,54 @@ public UploadController UploadFromFile( public void Dispose() { - ReleaseRemainingBlocks(); + _client.UploadQueue.RemoveFileFromQueue(_queueToken); } - internal static async ValueTask CreateAsync( + internal static FileUploader? TryCreate( ProtonDriveClient client, IRevisionDraftProvider revisionDraftProvider, NodeUid telemetryContextNodeUid, long size, - FileUploadMetadata metadata, - CancellationToken cancellationToken) + FileUploadMetadata metadata) { - var logger = client.Telemetry.GetLogger("File uploader"); - var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); - LogAcquiringRevisionCreationSemaphore(logger, expectedNumberOfBlocks); - - await client.RevisionCreationSemaphore.EnterAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); - - LogAcquiredRevisionCreationSemaphore(logger, expectedNumberOfBlocks); + if (client.UploadQueue.TryEnqueueFile(expectedNumberOfBlocks) is not { } queueToken) + { + return null; + } return new FileUploader( client, + queueToken, revisionDraftProvider, telemetryContextNodeUid, size, metadata, - expectedNumberOfBlocks, - logger); + client.Telemetry.GetLogger("File uploader")); } - [LoggerMessage(Level = LogLevel.Trace, Message = "Trying to acquire {Count} from revision creation semaphore")] - private static partial void LogAcquiringRevisionCreationSemaphore(ILogger logger, int count); + internal static async ValueTask CreateAsync( + ProtonDriveClient client, + IRevisionDraftProvider revisionDraftProvider, + NodeUid telemetryContextNodeUid, + long size, + FileUploadMetadata metadata, + CancellationToken cancellationToken) + { + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); - [LoggerMessage(Level = LogLevel.Trace, Message = "Acquired {Count} from revision creation semaphore")] - private static partial void LogAcquiredRevisionCreationSemaphore(ILogger logger, int count); + var queueToken = await client.UploadQueue.EnqueueFileAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); - [LoggerMessage(Level = LogLevel.Trace, Message = "Released {Count} from revision creation semaphore")] - private static partial void LogReleasedRevisionCreationSemaphore(ILogger logger, int count); + return new FileUploader( + client, + queueToken, + revisionDraftProvider, + telemetryContextNodeUid, + size, + metadata, + client.Telemetry.GetLogger("File uploader")); + } private UploadController UploadFromStream( Stream contentStream, @@ -234,7 +242,7 @@ private async ValueTask UploadAsync( Lazy>? expectedSha1, CancellationToken cancellationToken) { - using var revisionWriter = await RevisionOperations.OpenForWritingAsync(_client, revisionDraft, ReleaseBlocks, cancellationToken).ConfigureAwait(false); + var revisionWriter = RevisionOperations.OpenForWriting(_client, revisionDraft, _queueToken); await revisionWriter.WriteAsync( contentStream, @@ -246,31 +254,6 @@ await revisionWriter.WriteAsync( cancellationToken).ConfigureAwait(false); } - private void ReleaseBlocks(int numberOfBlocks) - { - var newRemainingNumberOfBlocks = Interlocked.Add(ref _remainingNumberOfBlocks, -numberOfBlocks); - - var amountToRelease = Math.Max(newRemainingNumberOfBlocks >= 0 ? numberOfBlocks : newRemainingNumberOfBlocks + numberOfBlocks, 0); - - _client.RevisionCreationSemaphore.Release(amountToRelease); - - LogReleasedRevisionCreationSemaphore(_logger, amountToRelease); - } - - private void ReleaseRemainingBlocks() - { - if (_remainingNumberOfBlocks <= 0) - { - return; - } - - _client.RevisionCreationSemaphore.Release(_remainingNumberOfBlocks); - - LogReleasedRevisionCreationSemaphore(_logger, _remainingNumberOfBlocks); - - _remainingNumberOfBlocks = 0; - } - private void RaiseTelemetryEvent(UploadEvent uploadEvent) { try diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index ba724d6f..ca9c3fc7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -12,32 +12,27 @@ namespace Proton.Drive.Sdk.Nodes.Upload; -internal sealed partial class RevisionWriter : IDisposable +internal sealed partial class RevisionWriter { public const int DefaultBlockSize = 1 << 22; // 4 MiB private static readonly TimeSpan SourceReadingCancellationDelay = TimeSpan.FromMilliseconds(500); private readonly ProtonDriveClient _client; private readonly RevisionDraft _draft; - private readonly Action _releaseBlocksAction; - private readonly Action _releaseFileSemaphoreAction; + private readonly long _queueToken; private readonly ILogger _logger; private readonly int _targetBlockSize; - private bool _fileReleased; - internal RevisionWriter( ProtonDriveClient client, RevisionDraft draft, - Action releaseBlocksAction, - Action releaseFileSemaphoreAction, + long queueToken, int targetBlockSize = DefaultBlockSize) { _client = client; _draft = draft; - _releaseBlocksAction = releaseBlocksAction; - _releaseFileSemaphoreAction = releaseFileSemaphoreAction; + _queueToken = queueToken; _targetBlockSize = targetBlockSize; _logger = client.Telemetry.GetLogger("Revision writer"); } @@ -53,52 +48,9 @@ public async ValueTask WriteAsync( { try { - var uploadTasks = new Queue>(_client.BlockUploader.Queue.Depth); - var signingEmailAddress = _draft.MembershipAddress.EmailAddress; - int expectedThumbnailBlockCount; - - var hashingContentStream = new HashingReadStream(contentStream, _draft.Sha1, leaveOpen: true); - - await using (hashingContentStream.ConfigureAwait(false)) - { - try - { - try - { - expectedThumbnailBlockCount = await UploadThumbnailBlocksAsync(thumbnails, uploadTasks, cancellationToken).ConfigureAwait(false); - - await UploadContentBlocksAsync(onProgress, hashingContentStream, uploadTasks, cancellationToken).ConfigureAwait(false); - } - finally - { - _releaseFileSemaphoreAction.Invoke(); - _fileReleased = true; - } - - while (uploadTasks.TryDequeue(out var uploadTask)) - { - await uploadTask.ConfigureAwait(false); - } - } - catch when (uploadTasks.Count > 0) - { - foreach (var uploadTask in uploadTasks) - { - try - { - await uploadTask.ConfigureAwait(false); - } - catch - { - // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw - } - } - - throw; - } - } + var expectedThumbnailBlockCount = await UploadBlocksAsync(contentStream, thumbnails, onProgress, cancellationToken).ConfigureAwait(false); var sha1Digest = _draft.Sha1.GetCurrentHash(); @@ -161,14 +113,6 @@ await _client.Api.Files.UpdateRevisionAsync( } } - public void Dispose() - { - if (!_fileReleased) - { - _releaseFileSemaphoreAction.Invoke(); - } - } - private static bool IsResumableError(Exception ex) { return ex is not ProtonApiException { TransportCode: >= 400 and < 500 } @@ -177,6 +121,60 @@ and not IntegrityException and not InvalidOperationException; } + private async ValueTask UploadBlocksAsync( + Stream contentStream, + IEnumerable thumbnails, + Action? onProgress, + CancellationToken cancellationToken) + { + int expectedThumbnailBlockCount; + var hashingContentStream = new HashingReadStream(contentStream, _draft.Sha1, leaveOpen: true); + + await using (hashingContentStream.ConfigureAwait(false)) + { + var uploadTasks = new Queue>(_client.UploadQueue.Depth); + + try + { + await _client.UploadQueue.StartBlockQueueingAsync(cancellationToken).ConfigureAwait(false); + + try + { + expectedThumbnailBlockCount = await UploadThumbnailBlocksAsync(thumbnails, uploadTasks, cancellationToken).ConfigureAwait(false); + + await UploadContentBlocksAsync(onProgress, hashingContentStream, uploadTasks, cancellationToken).ConfigureAwait(false); + } + finally + { + _client.UploadQueue.FinishBlockQueueing(); + } + + while (uploadTasks.TryDequeue(out var uploadTask)) + { + await uploadTask.ConfigureAwait(false); + } + } + catch when (uploadTasks.Count > 0) + { + foreach (var uploadTask in uploadTasks) + { + try + { + await uploadTask.ConfigureAwait(false); + } + catch + { + // Ignore exceptions because most if not all will just be cancellation-related, and we already have one to re-throw + } + } + + throw; + } + } + + return expectedThumbnailBlockCount; + } + private RevisionUpdateRequest CreateRevisionUpdateRequest( FileUploadMetadata metadata, long expectedContentLength, @@ -321,18 +319,13 @@ private async ValueTask UploadContentBlockAsync( await plainData.DisposeAsync().ConfigureAwait(false); + _client.UploadQueue.DecreaseFileRemainingBlockCount(_queueToken, 1); + return result; } finally { - try - { - _client.BlockUploader.Queue.FinishBlocks(1); - } - finally - { - _releaseBlocksAction.Invoke(1); - } + _client.UploadQueue.DequeueBlocks(1); } } @@ -345,6 +338,8 @@ private async ValueTask UploadThumbnailBlocksAsync( foreach (var thumbnail in thumbnails) { + _client.UploadQueue.IncreaseFileRemainingBlockCount(_queueToken, 1); + ++blockCount; if (_draft.ThumbnailBlockWasAlreadyUploaded(thumbnail.Type)) @@ -370,18 +365,13 @@ private async ValueTask UploadThumbnailBlockAsync(Thumbnail t _draft.SetThumbnailUploadResult(thumbnail.Type, result); + _client.UploadQueue.DecreaseFileRemainingBlockCount(_queueToken, 1); + return result; } finally { - try - { - _client.BlockUploader.Queue.FinishBlocks(1); - } - finally - { - _client.RevisionCreationSemaphore.Release(1); - } + _client.UploadQueue.DequeueBlocks(1); } } @@ -491,14 +481,14 @@ await TryGetNextContentBlockPlainDataAsync( private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTasks, CancellationToken cancellationToken) { - if (!_client.BlockUploader.Queue.TryStartBlock()) + if (!_client.UploadQueue.TryEnqueueBlock()) { if (uploadTasks.TryDequeue(out var uploadTask)) { await uploadTask.ConfigureAwait(false); } - await _client.BlockUploader.Queue.StartBlockAsync(cancellationToken).ConfigureAwait(false); + await _client.UploadQueue.EnqueueBlockAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 7b6fbc6e..6a72ed8b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.IO; using Proton.Cryptography.Pgp; @@ -19,8 +20,8 @@ namespace Proton.Drive.Sdk; public sealed class ProtonDriveClient { - private const int MinDegreeOfBlockTransferParallelism = 2; - private const int MaxDegreeOfBlockTransferParallelism = 6; + private const int DefaultDegreeOfBlockTransferParallelism = 6; + private const int MaxDegreeOfThumbnailDownloadParallelism = 8; /// /// Creates a new instance of . @@ -46,15 +47,16 @@ public ProtonDriveClient( ProtonDriveClientOptions? creationParameters = null) : this( new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( - creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds), + creationParameters?.DefaultApiTimeoutSecondsOverride ?? ProtonApiDefaults.DefaultTimeoutSeconds), new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( - creationParameters?.OverrideStorageApiTimeoutSeconds ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), + creationParameters?.StorageApiTimeoutSecondsOverride ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), featureFlagProvider, telemetry, (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), - creationParameters?.Uid ?? Guid.NewGuid().ToString()) + creationParameters?.Uid, + creationParameters?.DegreeOfBlockTransferParallelismOverride) { } @@ -73,7 +75,8 @@ internal ProtonDriveClient( session.ClientConfiguration.FeatureFlagProvider, session.ClientConfiguration.Telemetry, driveApiClientsFactory, - uid ?? Guid.NewGuid().ToString()) + uid, + degreeOfBlockTransferParallelism: null) { } @@ -88,15 +91,16 @@ internal ProtonDriveClient( ProtonDriveClientOptions? creationParameters = null) : this( new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( - creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds), + creationParameters?.DefaultApiTimeoutSecondsOverride ?? ProtonApiDefaults.DefaultTimeoutSeconds), new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( - creationParameters?.OverrideStorageApiTimeoutSeconds ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), + creationParameters?.StorageApiTimeoutSecondsOverride ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), accountClient, new DriveClientCache(entityCacheRepository, secretCacheRepository), featureFlagProvider, telemetry, driveApiClientsFactory, - creationParameters?.Uid ?? Guid.NewGuid().ToString()) + creationParameters?.Uid, + creationParameters?.DegreeOfBlockTransferParallelismOverride) { } @@ -107,10 +111,10 @@ internal ProtonDriveClient( IBlockVerifierFactory blockVerifierFactory, IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, - string uid, - int? blockTransferDegreeOfParallelism = null) + string? uid, + int? degreeOfBlockTransferParallelism = null) { - Uid = uid; + Uid = uid ?? Guid.NewGuid().ToString(); Account = accountClient; Api = api; @@ -119,17 +123,15 @@ internal ProtonDriveClient( Telemetry = telemetry; FeatureFlagProvider = featureFlagProvider; - var maxDegreeOfBlockTransferParallelism = blockTransferDegreeOfParallelism - ?? Math.Max(Math.Min(Environment.ProcessorCount / 2, MaxDegreeOfBlockTransferParallelism), MinDegreeOfBlockTransferParallelism); + var maxDegreeOfBlockTransferParallelism = degreeOfBlockTransferParallelism ?? DefaultDegreeOfBlockTransferParallelism; - var maxDegreeOfBlockProcessingParallelism = maxDegreeOfBlockTransferParallelism + Math.Min(Math.Max(maxDegreeOfBlockTransferParallelism / 2, 2), 4); + DownloadQueue = new TransferQueue(maxDegreeOfBlockTransferParallelism, telemetry.GetLogger("Download queue")); + UploadQueue = new TransferQueue(maxDegreeOfBlockTransferParallelism, telemetry.GetLogger("Upload queue")); + ThumbnailDownloadQueue = new TransferQueue(MaxDegreeOfThumbnailDownloadParallelism, telemetry.GetLogger("Thumbnail download queue")); - RevisionCreationSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism); - BlockListingSemaphore = new FifoFlexibleSemaphore(maxDegreeOfBlockProcessingParallelism); - - BlockUploader = new BlockUploader(this, maxDegreeOfBlockTransferParallelism); - BlockDownloader = new BlockDownloader(this, maxDegreeOfBlockTransferParallelism); - ThumbnailBlockDownloader = new BlockDownloader(this, 8); + BlockUploader = new BlockUploader(this); + BlockDownloader = new BlockDownloader(this); + ThumbnailBlockDownloader = new BlockDownloader(this); PgpEnvironment.DefaultAeadStreamingChunkLength = PgpAeadStreamingChunkLength.ChunkLength; } @@ -141,7 +143,8 @@ private ProtonDriveClient( IFeatureFlagProvider featureFlagProvider, ITelemetry telemetry, Func driveApiClientsFactory, - string uid) + string? uid, + int? degreeOfBlockTransferParallelism = null) : this( accountClient, driveApiClientsFactory.Invoke(defaultApiHttpClient, storageApiHttpClient), @@ -149,7 +152,8 @@ private ProtonDriveClient( new BlockVerifierFactory(defaultApiHttpClient), featureFlagProvider, telemetry, - uid) + uid, + degreeOfBlockTransferParallelism) { } @@ -165,8 +169,9 @@ private ProtonDriveClient( internal ITelemetry Telemetry { get; } internal IFeatureFlagProvider FeatureFlagProvider { get; } - internal FifoFlexibleSemaphore RevisionCreationSemaphore { get; } - internal FifoFlexibleSemaphore BlockListingSemaphore { get; } + internal TransferQueue UploadQueue { get; } + internal TransferQueue DownloadQueue { get; } + internal TransferQueue ThumbnailDownloadQueue { get; } internal int TargetBlockSize { get; set; } = RevisionWriter.DefaultBlockSize; @@ -212,6 +217,20 @@ public IAsyncEnumerable EnumerateThumbnailsAsync( return FileOperations.EnumerateThumbnailsAsync(this, fileUids, type, forPhotos: false, cancellationToken); } + [Experimental("TryTransferQueuing")] + public FileUploader? TryGetFileUploader( + NodeUid parentFolderUid, + string name, + string mediaType, + long size, + FileUploadMetadata metadata, + bool overrideExistingDraftByOtherClient) + { + var draftProvider = new NewFileDraftProvider(this, parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); + + return FileUploader.TryCreate(this, draftProvider, parentFolderUid, size, metadata); + } + public async ValueTask GetFileUploaderAsync( NodeUid parentFolderUid, string name, @@ -223,7 +242,18 @@ public async ValueTask GetFileUploaderAsync( { var draftProvider = new NewFileDraftProvider(this, parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); - return await GetFileUploaderAsync(draftProvider, parentFolderUid, size, metadata, cancellationToken).ConfigureAwait(false); + return await FileUploader.CreateAsync(this, draftProvider, parentFolderUid, size, metadata, cancellationToken).ConfigureAwait(false); + } + + [Experimental("TryTransferQueuing")] + public FileUploader? TryGetFileRevisionUploader( + RevisionUid currentActiveRevisionUid, + long size, + FileUploadMetadata metadata) + { + var draftProvider = new NewRevisionDraftProvider(this, currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); + + return FileUploader.TryCreate(this, draftProvider, currentActiveRevisionUid.NodeUid, size, metadata); } public async ValueTask GetFileRevisionUploaderAsync( @@ -234,7 +264,13 @@ public async ValueTask GetFileRevisionUploaderAsync( { var draftProvider = new NewRevisionDraftProvider(this, currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); - return await GetFileUploaderAsync(draftProvider, currentActiveRevisionUid.NodeUid, size, metadata, cancellationToken).ConfigureAwait(false); + return await FileUploader.CreateAsync(this, draftProvider, currentActiveRevisionUid.NodeUid, size, metadata, cancellationToken).ConfigureAwait(false); + } + + [Experimental("TryTransferQueuing")] + public FileDownloader? TryGetFileDownloader(RevisionUid revisionUid) + { + return FileDownloader.TryCreate(this, revisionUid); } public async ValueTask GetFileDownloaderAsync(RevisionUid revisionUid, CancellationToken cancellationToken) @@ -303,14 +339,4 @@ public async ValueTask EmptyTrashAsync(CancellationToken cancellationToken) await VolumeOperations.EmptyTrashAsync(this, volumeId.Value, cancellationToken).ConfigureAwait(false); } - - private async ValueTask GetFileUploaderAsync( - IRevisionDraftProvider revisionDraftProvider, - NodeUid telemetryContextNodeUid, - long size, - FileUploadMetadata metadata, - CancellationToken cancellationToken) - { - return await FileUploader.CreateAsync(this, revisionDraftProvider, telemetryContextNodeUid, size, metadata, cancellationToken).ConfigureAwait(false); - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs index 5358a565..4975a7c9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs @@ -1,7 +1,8 @@ namespace Proton.Drive.Sdk; public record struct ProtonDriveClientOptions( - string? BindingsLanguage, string? Uid, - int? OverrideDefaultApiTimeoutSeconds, - int? OverrideStorageApiTimeoutSeconds); + string? BindingsLanguage, + int? DefaultApiTimeoutSecondsOverride, + int? StorageApiTimeoutSecondsOverride, + int? DegreeOfBlockTransferParallelismOverride); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index 6520e04e..4082e997 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -48,7 +48,7 @@ public ProtonPhotosClient( creationParameters); var httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout( - creationParameters?.OverrideDefaultApiTimeoutSeconds ?? ProtonApiDefaults.DefaultTimeoutSeconds); + creationParameters?.DefaultApiTimeoutSecondsOverride ?? ProtonApiDefaults.DefaultTimeoutSeconds); PhotosApi = new PhotosApiClient(httpClient); } @@ -57,6 +57,22 @@ public ProtonPhotosClient( internal ProtonDriveClient DriveClient { get; } + [Experimental("TryTransferQueuing")] + public async ValueTask TryGetFileUploaderAsync( + string name, + string mediaType, + long size, + PhotosFileUploadMetadata metadata, + bool overrideExistingDraftByOtherClient, + CancellationToken cancellationToken) + { + var photosRoot = await PhotosNodeOperations.GetOrCreatePhotosFolderAsync(DriveClient, cancellationToken).ConfigureAwait(false); + + var draftProvider = new NewFileDraftProvider(DriveClient, photosRoot.Uid, name, mediaType, overrideExistingDraftByOtherClient); + + return FileUploader.TryCreate(DriveClient, draftProvider, photosRoot.Uid, size, metadata); + } + public async ValueTask GetFileUploaderAsync( string name, string mediaType, @@ -97,6 +113,12 @@ public IAsyncEnumerable EnumerateTimelineAsync(CancellationT return PhotosNodeOperations.EnumeratePhotosTimelineAsync(DriveClient, cancellationToken); } + [Experimental("TryTransferQueuing")] + public PhotosFileDownloader? TryGetPhotosDownloader(NodeUid photoUid) + { + return PhotosFileDownloader.TryCreate(this, photoUid); + } + public async ValueTask GetPhotosDownloaderAsync(NodeUid photoUid, CancellationToken cancellationToken) { return await PhotosFileDownloader.CreateAsync(this, photoUid, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index a80225c8..f8653820 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -217,14 +217,16 @@ message HttpClient { } message ProtonDriveClientOptions { - string bindings_language = 1; // Optional - // Client UID, optional // If a null value is provided, the SDK automatically generates a UUID during initialization - string uid = 2; - + string uid = 1; + + string bindings_language = 2; // Optional + int32 api_call_timeout = 3; // Optional int32 storage_call_timeout = 4; // Optional + + int32 block_transfer_parallelism = 5; // Optional } // The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. @@ -274,7 +276,7 @@ message AdditionalMetadataProperty { bytes utf8_json_value = 2; } -// The response value must be an Int64Value carrying a handle to an instance of FileUploader. +// The response value must be an Int64Value carrying a handle to an instance of FileUploader (or 0 if no_waiting is true and no slot was free).. message DriveClientGetFileUploaderRequest { int64 client_handle = 1; string parent_folder_uid = 2; @@ -284,17 +286,23 @@ message DriveClientGetFileUploaderRequest { google.protobuf.Timestamp last_modification_time = 6; repeated AdditionalMetadataProperty additional_metadata = 7; // Optional bool override_existing_draft_by_other_client = 8; - int64 cancellation_token_source_handle = 9; + // When unset or false, waits for a slot in the queue (uses cancellation_token_source_handle). + // When true, only reserves a slot if immediately available (cancellation_token_source_handle is ignored). + bool no_waiting = 9; + int64 cancellation_token_source_handle = 10; } -// The response value must be an Int64Value carrying a handle to an instance of FileUploader. +// The response value must be an Int64Value carrying a handle to an instance of FileUploader (or 0 if no_waiting is true and no slot was free). message DriveClientGetFileRevisionUploaderRequest { int64 client_handle = 1; string current_active_revision_uid = 2; int64 size = 3; google.protobuf.Timestamp last_modification_time = 4; repeated AdditionalMetadataProperty additional_metadata = 5; // Optional - int64 cancellation_token_source_handle = 6; + // When unset or false, waits for a slot in the queue (uses cancellation_token_source_handle). + // When true, only reserves a slot if immediately available (cancellation_token_source_handle is ignored). + bool no_waiting = 6; + int64 cancellation_token_source_handle = 7; } // The response value must be an Int64Value carrying a handle to an instance of UploadController. @@ -549,11 +557,14 @@ message DegradedRevision { // Drive - downloads -// The response value must be an Int64Value carrying a handle to an instance of FileDownloader. +// The response value must be an Int64Value carrying a handle to an instance of FileDownloader (or 0 if no_waiting is true and no slot was free). message DriveClientGetFileDownloaderRequest { int64 client_handle = 1; string revision_uid = 2; - int64 cancellation_token_source_handle = 3; + // When unset or false, waits for a slot in the queue (uses cancellation_token_source_handle). + // When true, only reserves a slot if immediately available (cancellation_token_source_handle is ignored). + bool no_waiting = 3; + int64 cancellation_token_source_handle = 4; } // The response value must be an Int64Value carrying a handle to an instance of DownloadController. @@ -681,14 +692,17 @@ message PhotosTimelineItem { google.protobuf.Timestamp capture_time = 2; } -// The response value must be an Int64Value carrying a handle to an instance of PhotosFileDownloader. +// The response value must be an Int64Value carrying a handle to an instance of PhotosFileDownloader (or 0 if no_waiting is true and no slot was free).. message DrivePhotosClientGetPhotoDownloaderRequest { int64 client_handle = 1; string photo_uid = 2; - int64 cancellation_token_source_handle = 3; + // When unset or false, waits for a slot in the queue (uses cancellation_token_source_handle). + // When true, only reserves a slot if immediately available (cancellation_token_source_handle is ignored). + bool no_waiting = 3; + int64 cancellation_token_source_handle = 4; } -// Photo downloader +// Photos file downloader // The response value must be an Int64Value carrying a handle to an instance of DownloadController. message DrivePhotosClientDownloadToStreamRequest { @@ -723,7 +737,10 @@ message DrivePhotosClientGetPhotoUploaderRequest { int64 size = 4; PhotosFileUploadMetadata metadata = 5; bool override_existing_draft_by_other_client = 6; - int64 cancellation_token_source_handle = 7; + // When unset or false, waits for a slot in the queue. + // When true, only reserves a slot if immediately available. + bool no_waiting = 7; + int64 cancellation_token_source_handle = 8; } message PhotosFileUploadMetadata { From 98ccc54af440ee955b6e4152c659721ec965d6e4 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 12:53:52 +0000 Subject: [PATCH 717/791] Update cached album photo count after adding or removing photo --- .../src/internal/photos/albumsManager.test.ts | 3 ++- js/sdk/src/internal/photos/albumsManager.ts | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/js/sdk/src/internal/photos/albumsManager.test.ts b/js/sdk/src/internal/photos/albumsManager.test.ts index 5d794521..dbad011f 100644 --- a/js/sdk/src/internal/photos/albumsManager.test.ts +++ b/js/sdk/src/internal/photos/albumsManager.test.ts @@ -357,9 +357,10 @@ describe('Albums', () => { ['photo1', 'photo2', 'photo3'], undefined, ); - expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(3); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo1'); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo3'); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid'); expect(nodesService.notifyNodeChanged).not.toHaveBeenCalledWith('photo2'); }); }); diff --git a/js/sdk/src/internal/photos/albumsManager.ts b/js/sdk/src/internal/photos/albumsManager.ts index 79effaae..03e58d93 100644 --- a/js/sdk/src/internal/photos/albumsManager.ts +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -220,7 +220,11 @@ export class AlbumsManager { this.logger, signal, ); - yield* process.execute(photoNodeUids); + try { + yield* process.execute(photoNodeUids); + } finally { + await this.nodesService.notifyNodeChanged(albumNodeUid); + } } async *removePhotos( @@ -228,11 +232,15 @@ export class AlbumsManager { photoNodeUids: string[], signal?: AbortSignal, ): AsyncGenerator { - for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) { - if (result.ok) { - await this.nodesService.notifyNodeChanged(result.uid); + try { + for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) { + if (result.ok) { + await this.nodesService.notifyNodeChanged(result.uid); + } + yield result; } - yield result; + } finally { + await this.nodesService.notifyNodeChanged(albumNodeUid); } } From 598bd10440c1ccb0e24bfa3cc38dc0ee62370830 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 12:56:45 +0000 Subject: [PATCH 718/791] Update changelog for js/v0.14.10 --- js/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index ad879256..7fbd72f8 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## js/v0.14.10 (2026-04-27) + +* Update cached album photo count after adding or removing photo + ## js/v0.14.9 (2026-04-27) * Expose savePhotosToTimeline From 1bd2eee009fe1d8b5ec9c90304aa6e7f47d33c91 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 16:53:20 +0200 Subject: [PATCH 719/791] Upgrade to .NET 10 --- cs/Directory.Build.props | 3 ++- cs/Directory.Packages.props | 26 +++++++++---------- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 2 +- cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj | 1 - 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props index 05de4fba..2b73b042 100644 --- a/cs/Directory.Build.props +++ b/cs/Directory.Build.props @@ -1,10 +1,11 @@ - net9.0 + net10.0 true true false + true true diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 1cc2ea2d..217fc1fc 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -3,29 +3,29 @@ true - + - + - - - - + + + + - + - - - - + + + + - - + + \ No newline at end of file diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index f355b065..5e3941a7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -102,7 +102,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( .Select(thumbnail => (thumbnail.Id, Info: fileNodeInfo)) .ToAsyncEnumerable(); }) - .ToDictionaryAsync(x => x.Id, x => x.Info, cancellationToken) + .ToDictionaryAsync(thumbnail => thumbnail.Id, thumbnail => thumbnail.Info, cancellationToken: cancellationToken) .ConfigureAwait(false); errors.AddRange( diff --git a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj index 3a748f75..4b01b42e 100644 --- a/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -15,7 +15,6 @@ - From b6934a37e2e76571156df5e84c7f9368b6f9733b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 8 Apr 2026 15:58:02 +0200 Subject: [PATCH 720/791] Evict non-deserializable entries from cache --- .../Caching/DriveEntityCache.cs | 52 +++++++++++-------- .../Caching/DriveSecretCache.cs | 29 ++++++----- .../Caching/CacheRepositoryExtensions.cs | 23 ++++++++ 3 files changed, 68 insertions(+), 36 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index d0186d57..1295fb2e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Serialization; @@ -38,11 +38,10 @@ public ValueTask SetMainVolumeIdAsync(VolumeId? volumeId, CancellationToken canc public async ValueTask<(bool Exists, VolumeId? VolumeId)> TryGetMainVolumeIdAsync(CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(MainVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); - - return serializedValue is not null - ? (true, JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.NullableVolumeId)) - : (false, null); + return await _repository.TryGetDeserializedValueAsync( + MainVolumeIdCacheKey, + DriveEntitiesSerializerContext.Default.NullableVolumeId, + cancellationToken).ConfigureAwait(false); } public ValueTask SetPhotosVolumeIdAsync(VolumeId? volumeId, CancellationToken cancellationToken) @@ -54,11 +53,10 @@ public ValueTask SetPhotosVolumeIdAsync(VolumeId? volumeId, CancellationToken ca public async ValueTask<(bool Exists, VolumeId? VolumeId)> TryGetPhotosVolumeIdAsync(CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(PhotosVolumeIdCacheKey, cancellationToken).ConfigureAwait(false); - - return serializedValue is not null - ? (true, JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.NullableVolumeId)) - : (false, null); + return await _repository.TryGetDeserializedValueAsync( + PhotosVolumeIdCacheKey, + DriveEntitiesSerializerContext.Default.NullableVolumeId, + cancellationToken).ConfigureAwait(false); } public ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cancellationToken) @@ -68,9 +66,12 @@ public ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cance public async ValueTask TryGetMyFilesShareIdAsync(CancellationToken cancellationToken) { - var value = await _repository.TryGetAsync(MyFilesShareIdCacheKey, cancellationToken).ConfigureAwait(false); + var (exists, value) = await _repository.TryGetDeserializedValueAsync( + MyFilesShareIdCacheKey, + DriveEntitiesSerializerContext.Default.ShareId, + cancellationToken).ConfigureAwait(false); - return value is not null ? (ShareId)value : null; + return exists ? value : null; } public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken) @@ -80,9 +81,12 @@ public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancel public async ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken) { - var value = await _repository.TryGetAsync(PhotosShareIdCacheKey, cancellationToken).ConfigureAwait(false); + var (exists, value) = await _repository.TryGetDeserializedValueAsync( + PhotosShareIdCacheKey, + DriveEntitiesSerializerContext.Default.ShareId, + cancellationToken).ConfigureAwait(false); - return value is not null ? (ShareId)value : null; + return exists ? value : null; } public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) @@ -94,11 +98,12 @@ public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) public async ValueTask TryGetShareAsync(ShareId shareId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetShareCacheKey(shareId), cancellationToken).ConfigureAwait(false); + var (exists, share) = await _repository.TryGetDeserializedValueAsync( + GetShareCacheKey(shareId), + DriveEntitiesSerializerContext.Default.Share, + cancellationToken).ConfigureAwait(false); - return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.Share) - : null; + return exists ? share : null; } public ValueTask SetNodeAsync( @@ -117,11 +122,12 @@ public ValueTask SetNodeAsync( public async ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetNodeCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + var (exists, node) = await _repository.TryGetDeserializedValueAsync( + GetNodeCacheKey(nodeId), + DriveEntitiesSerializerContext.Default.CachedNodeInfo, + cancellationToken).ConfigureAwait(false); - return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveEntitiesSerializerContext.Default.CachedNodeInfo) - : null; + return exists ? node : null; } public async ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs index 4e0d7871..196d23c5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; @@ -22,11 +22,12 @@ public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, Cance public async ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetShareKeyCacheKey(shareId), cancellationToken).ConfigureAwait(false); + var (exists, shareKey) = await _repository.TryGetDeserializedValueAsync( + GetShareKeyCacheKey(shareId), + SecretsSerializerContext.Default.PgpPrivateKey, + cancellationToken).ConfigureAwait(false); - return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, SecretsSerializerContext.Default.PgpPrivateKey) - : null; + return exists ? shareKey : null; } public ValueTask SetFolderSecretsAsync( @@ -41,11 +42,12 @@ public ValueTask SetFolderSecretsAsync( public async ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetFolderSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + var (exists, folderSecrets) = await _repository.TryGetDeserializedValueAsync( + GetFolderSecretsCacheKey(nodeId), + DriveSecretsSerializerContext.Default.NullableResultFolderSecretsDegradedFolderSecrets, + cancellationToken).ConfigureAwait(false); - return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableResultFolderSecretsDegradedFolderSecrets) - : null; + return exists ? folderSecrets : null; } public ValueTask SetFileSecretsAsync( @@ -60,11 +62,12 @@ public ValueTask SetFileSecretsAsync( public async ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { - var serializedValue = await _repository.TryGetAsync(GetFileSecretsCacheKey(nodeId), cancellationToken).ConfigureAwait(false); + var (exists, fileSecrets) = await _repository.TryGetDeserializedValueAsync( + GetFileSecretsCacheKey(nodeId), + DriveSecretsSerializerContext.Default.NullableResultFileSecretsDegradedFileSecrets, + cancellationToken).ConfigureAwait(false); - return serializedValue is not null - ? JsonSerializer.Deserialize(serializedValue, DriveSecretsSerializerContext.Default.NullableResultFileSecretsDegradedFileSecrets) - : null; + return exists ? fileSecrets : null; } public ValueTask ClearAsync() diff --git a/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs index a899276c..4e61da41 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs @@ -12,6 +12,29 @@ public static ValueTask SetAsync(this ICacheRepository repository, string key, s return repository.SetAsync(key, value, [], cancellationToken); } + public static async ValueTask<(bool Exists, T? Value)> TryGetDeserializedValueAsync( + this ICacheRepository repository, + string key, + JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + { + var serializedValue = await repository.TryGetAsync(key, cancellationToken).ConfigureAwait(false); + if (serializedValue is null) + { + return default; + } + + try + { + return (true, JsonSerializer.Deserialize(serializedValue, typeInfo)); + } + catch + { + await repository.RemoveAsync(key, cancellationToken).ConfigureAwait(false); + return default; + } + } + public static async ValueTask SetCompleteCollection( this ICacheRepository repository, IEnumerable values, From ea784e153c2793363dabe146248bc727f684ee29 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 09:23:09 +0200 Subject: [PATCH 721/791] Reduce log level for draft deletion failure from error to warning --- cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs index bf5e6f3b..69017e47 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -141,6 +141,6 @@ await Parallel.ForEachAsync(dataItemsToDispose, (data, _) => } } - [LoggerMessage(Level = LogLevel.Error, Message = "Draft deletion failed for revision {RevisionUid}")] + [LoggerMessage(Level = LogLevel.Warning, Message = "Draft deletion failed for revision {RevisionUid}")] private partial void LogDraftDeletionFailure(Exception exception, RevisionUid revisionUid); } From b36ca84f89d0e4fea7460bd4f98e950b1bcd8ce5 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 11:50:55 +0200 Subject: [PATCH 722/791] Fix name conflict handling regression --- .../Api/Files/RevisionConflict.cs | 12 +++++++++++- .../Api/Files/RevisionErrorResponse.cs | 18 ------------------ .../NodeWithSameNameExistsException.cs | 10 ++++++---- .../Nodes/Upload/FileUploader.cs | 16 +++++++++++++++- .../Nodes/Upload/NewFileDraftProvider.cs | 4 ++-- .../Nodes/Upload/NewRevisionDraftProvider.cs | 4 ++-- .../Serialization/DriveApiSerializerContext.cs | 1 + 7 files changed, 37 insertions(+), 28 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs index 3d506723..0d9ce361 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs @@ -1,5 +1,8 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Api.Files; @@ -16,4 +19,11 @@ internal sealed class RevisionConflict [JsonPropertyName("ConflictDraftClientUID")] public string? DraftClientUid { get; init; } + + public static RevisionConflict? FromErrorResponse(RevisionErrorResponse? errorResponse) + { + return errorResponse?.Code is ResponseCode.AlreadyExists + ? errorResponse.Details?.Deserialize(DriveApiSerializerContext.Default.RevisionConflict) + : null; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs index 3930825b..a97bb7b3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs @@ -1,27 +1,9 @@ using System.Text.Json; -using Proton.Drive.Sdk.Serialization; using Proton.Sdk.Api; namespace Proton.Drive.Sdk.Api.Files; internal sealed class RevisionErrorResponse : ApiResponse { - private Lazy? _conflict; - public JsonElement? Details { get; init; } - - public RevisionConflict? Conflict - { - get - { - return (_conflict ??= new Lazy(() => Code is ResponseCode.AlreadyExists && Details is not null - ? Details.Value.Deserialize(DriveApiSerializerContext.Default.RevisionConflict) - : null)).Value; - } - - init - { - _conflict = new Lazy(value); - } - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs index 4a04811c..2ef9b588 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs @@ -29,19 +29,21 @@ internal NodeWithSameNameExistsException(VolumeId volumeId, ProtonApiException e) - when (e.Response is { Conflict: { LinkId: { } conflictingLinkId, RevisionId: null, DraftRevisionId: not null } } - && (e.Response.Conflict.DraftClientUid == _client.Uid || _overrideExistingDraftByOtherClient) + when (RevisionConflict.FromErrorResponse(e.Response) is { LinkId: { } conflictingLinkId, RevisionId: null, DraftRevisionId: not null } conflict + && (conflict.DraftClientUid == _client.Uid || _overrideExistingDraftByOtherClient) && remainingNumberOfAttempts-- > 0) { var conflictingNodeUid = new NodeUid(_parentUid.VolumeId, conflictingLinkId); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index 52fd9638..ed424e21 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -50,8 +50,8 @@ public async ValueTask GetDraftAsync(CancellationToken cancellati revisionId = revisionResponse.Identity.RevisionId; } catch (ProtonApiException e) - when (e.Response is { Conflict.DraftRevisionId: { } draftRevisionId } - && (e.Response.Conflict.DraftClientUid == _client.Uid) + when (RevisionConflict.FromErrorResponse(e.Response) is { DraftRevisionId: { } draftRevisionId } conflict + && (conflict.DraftClientUid == _client.Uid) && remainingNumberOfAttempts-- > 0) { await _client.Api.Files.DeleteRevisionAsync(_fileUid.VolumeId, _fileUid.LinkId, draftRevisionId, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 02391d66..117d7e1c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -42,6 +42,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(RevisionCreationRequest))] [JsonSerializable(typeof(RevisionCreationResponse))] [JsonSerializable(typeof(RevisionErrorResponse))] +[JsonSerializable(typeof(RevisionConflict))] [JsonSerializable(typeof(BlockUploadPreparationRequest))] [JsonSerializable(typeof(BlockUploadPreparationResponse))] [JsonSerializable(typeof(RevisionUpdateRequest))] From c7d1f300a08ca5e4a6dffce65cc7075ae0118e9b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 12:01:40 +0000 Subject: [PATCH 723/791] Update changelog for cs/v0.14.0 --- cs/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index db464b11..8584551d 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## cs/v0.14.0 (2026-04-28) + +* Fix name conflict handling regression +* Reduce log level for draft deletion failure from error to warning +* Evict non-deserializable entries from cache +* Upgrade to .NET 10 +* Fix download queuing not blocking on full queue + ## cs/v0.13.8 (2026-04-27) * Fix nullable data in name conflict error From 06f448134950a7af8da282de6782024f46175ab6 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 12:44:33 +0000 Subject: [PATCH 724/791] Add upload and download commands --- js/sdk/src/interface/account.ts | 2 +- js/sdk/src/internal/upload/apiService.ts | 12 +++++++++++- js/sdk/src/protonDrivePhotosClient.ts | 10 ++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts index 0065b9d2..ba4df03a 100644 --- a/js/sdk/src/interface/account.ts +++ b/js/sdk/src/interface/account.ts @@ -28,7 +28,7 @@ export interface ProtonDriveAccount { * * Does not throw if there is no public key for given email, but returns empty array. * - * @param forceRefresh If true, bypasses the cache and fetches fresh keys from the API. + * @param forceRefresh - If true, bypasses the cache and fetches fresh keys from the API. * @throws Error Only if there is an error while fetching keys. */ getPublicKeys(email: string, forceRefresh?: boolean): Promise; diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 5f2711d1..920f510e 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -287,7 +287,17 @@ export class UploadAPIService { const formData = new FormData(); formData.append('Block', new Blob([block]), 'blob'); - await this.apiService.postBlockStream(url, token, formData, onProgress, signal); + let onProgressCalled = false; + const onProgressHandler = (uploadedBytes: number) => { + onProgressCalled = true; + onProgress?.(uploadedBytes); + }; + + await this.apiService.postBlockStream(url, token, formData, onProgressHandler, signal); + + if (!onProgressCalled) { + onProgress?.(block.length); + } } async isRevisionUploaded(nodeRevisionUid: string): Promise { diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 85a975fe..a043b706 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -511,6 +511,16 @@ export class ProtonDrivePhotosClient { return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); } + /** + * Returns an available name for a new node in the given parent folder. + * + * See `ProtonDriveClient.getAvailableName` for more information. + */ + async getAvailableName(parentFolderUid: NodeOrUid, name: string): Promise { + this.logger.info(`Getting available name in photos folder ${getUid(parentFolderUid)}`); + return this.nodes.management.findAvailableName(getUid(parentFolderUid), name); + } + /** * Check if the photo is a duplicate. * From abb9635624e023e25777519701ff6e31047cff06 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 12:48:19 +0000 Subject: [PATCH 725/791] Generate both thumbnails in CLI as WebP --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d6aa1e01..c7a1a661 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,7 @@ tsconfig.tsbuildinfo dist # JS CLI -js/cli/proton-drive -js/cli/proton-drive-internal +js/cli/release auth.txt auth-session.json cache*.sqlite From 008e6e723819a674dcdb2078e9bb8bb094fe74a9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Apr 2026 07:08:53 +0000 Subject: [PATCH 726/791] Pull crypto dependency from Maven --- kt/libs.versions.toml | 6 ++++++ kt/sdk/build.gradle.kts | 11 ++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/kt/libs.versions.toml b/kt/libs.versions.toml index 9f6d8d73..b6f28a00 100644 --- a/kt/libs.versions.toml +++ b/kt/libs.versions.toml @@ -5,7 +5,10 @@ androidx-room = "2.7.2" androidx-test = "1.5.0" # Android tools android-tools = "1.1.5" +# Core core = "36.3.2" +# Crypto +android-golib = "2.9.0-3" # Dagger dagger = "2.53.1" # Desugar @@ -62,6 +65,9 @@ core-userSettings-dagger = { module = "me.proton.core:user-settings-dagger", ver core-utilAndroidDatetime = { module = "me.proton.core:util-android-datetime", version.ref = "core" } core-utilKotlin = { module = "me.proton.core:util-kotlin", version.ref = "core" } +# Crypto +crypto-android-golib = { module = "me.proton.crypto:android-golib", version.ref = "android-golib" } + # Dagger dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger" } dagger-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "dagger" } diff --git a/kt/sdk/build.gradle.kts b/kt/sdk/build.gradle.kts index 9e75ad81..280509f5 100644 --- a/kt/sdk/build.gradle.kts +++ b/kt/sdk/build.gradle.kts @@ -65,17 +65,16 @@ dependencies { implementation(libs.retrofit) implementation(libs.core.user.domain) implementation(libs.core.network.data) + // used internally by csharp sdk, wanted as a transitive dependency + implementation(libs.crypto.android.golib) testImplementation(libs.bundles.test.jvm) androidTestImplementation(libs.coroutines.test) androidTestImplementation(libs.androidx.test.core.ktx) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.rules) - androidTestImplementation(files("$rootDir/gopenpgp-v2-v3/gopenpgp.aar")) androidTestImplementation(libs.core.auth.domain) androidTestImplementation(libs.core.network.data) - androidTestImplementation(libs.core.crypto.android) { - exclude("me.proton.crypto", "android-golib") - } + androidTestImplementation(libs.core.crypto.android) androidTestImplementation(libs.core.domain) androidTestImplementation(libs.core.account.dagger) androidTestImplementation(libs.core.accountManager.dagger) { @@ -85,9 +84,7 @@ dependencies { exclude("me.proton.core", "auth-presentation") } androidTestImplementation(libs.core.accountRecovery.dagger) - androidTestImplementation(libs.core.crypto.dagger){ - exclude("me.proton.crypto", "android-golib") - } + androidTestImplementation(libs.core.crypto.dagger) androidTestImplementation(libs.core.featureFlag.dagger) androidTestImplementation(libs.core.key.dagger) androidTestImplementation(libs.core.plan.dagger) From 777fbe248d86eefbd2cc5cd64614cb6887d5a9cd Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Apr 2026 09:49:54 +0000 Subject: [PATCH 727/791] Integrate @protontech/crypto --- js/sdk/.eslintrc.js | 21 +- js/sdk/README.md | 5 + js/sdk/jest.config.js | 3 +- js/sdk/package-lock.json | 645 +- js/sdk/package.json | 10 +- js/sdk/src/cache/index.ts | 2 +- js/sdk/src/cache/memoryCache.ts | 2 +- js/sdk/src/cache/nullCache.ts | 2 +- js/sdk/src/crypto/driveCrypto.test.ts | 25 +- js/sdk/src/crypto/driveCrypto.ts | 72 +- js/sdk/src/crypto/hmac.ts | 46 - js/sdk/src/crypto/index.ts | 6 +- js/sdk/src/crypto/interface.ts | 52 +- js/sdk/src/crypto/openPGPCrypto.ts | 92 +- js/sdk/src/crypto/utils.ts | 40 - js/sdk/src/diagnostic/index.ts | 6 +- .../diagnostic/integrityVerificationStream.ts | 3 +- js/sdk/src/diagnostic/interface.ts | 2 +- js/sdk/src/diagnostic/nodeUtils.ts | 2 +- js/sdk/src/diagnostic/sdkDiagnosticBase.ts | 12 +- js/sdk/src/diagnostic/sdkDiagnosticMain.ts | 4 +- js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts | 4 +- js/sdk/src/diagnostic/telemetry.ts | 2 +- js/sdk/src/index.ts | 6 +- js/sdk/src/interface/devices.ts | 2 +- js/sdk/src/interface/index.ts | 80 +- js/sdk/src/interface/nodes.ts | 2 +- js/sdk/src/interface/photos.ts | 2 +- js/sdk/src/interface/sharing.ts | 4 +- .../internal/apiService/apiService.test.ts | 4 +- js/sdk/src/internal/apiService/apiService.ts | 6 +- js/sdk/src/internal/apiService/coreTypes.ts | 15990 +++++++++------- js/sdk/src/internal/apiService/errors.test.ts | 4 +- js/sdk/src/internal/apiService/index.ts | 8 +- .../src/internal/apiService/transformers.ts | 2 +- js/sdk/src/internal/devices/index.ts | 2 +- js/sdk/src/internal/devices/manager.test.ts | 4 +- js/sdk/src/internal/download/cryptoService.ts | 17 +- .../internal/download/fileDownloader.test.ts | 6 +- .../src/internal/download/fileDownloader.ts | 14 +- js/sdk/src/internal/download/index.ts | 6 +- js/sdk/src/internal/download/interface.ts | 2 +- .../internal/download/seekableStream.test.ts | 2 +- .../src/internal/download/telemetry.test.ts | 2 +- js/sdk/src/internal/download/telemetry.ts | 4 +- .../download/thumbnailDownloader.test.ts | 2 +- .../internal/download/thumbnailDownloader.ts | 4 +- js/sdk/src/internal/events/apiService.ts | 4 +- .../internal/events/coreEventManager.test.ts | 2 +- js/sdk/src/internal/events/eventManager.ts | 2 +- js/sdk/src/internal/events/index.ts | 4 +- .../events/volumeEventManager.test.ts | 2 +- .../src/internal/events/volumeEventManager.ts | 2 +- js/sdk/src/internal/nodes/apiService.test.ts | 2 +- js/sdk/src/internal/nodes/apiService.ts | 4 +- js/sdk/src/internal/nodes/cache.test.ts | 2 +- js/sdk/src/internal/nodes/cache.ts | 2 +- js/sdk/src/internal/nodes/cryptoCache.test.ts | 2 +- js/sdk/src/internal/nodes/cryptoCache.ts | 2 +- js/sdk/src/internal/nodes/cryptoReporter.ts | 8 +- .../src/internal/nodes/cryptoService.test.ts | 4 +- js/sdk/src/internal/nodes/cryptoService.ts | 29 +- js/sdk/src/internal/nodes/events.test.ts | 2 +- .../internal/nodes/extendedAttributes.test.ts | 6 +- js/sdk/src/internal/nodes/index.test.ts | 16 +- js/sdk/src/internal/nodes/index.ts | 12 +- js/sdk/src/internal/nodes/interface.ts | 12 +- js/sdk/src/internal/nodes/nodeName.test.ts | 2 +- js/sdk/src/internal/nodes/nodesAccess.test.ts | 6 +- js/sdk/src/internal/nodes/nodesAccess.ts | 12 +- .../internal/nodes/nodesManagement.test.ts | 8 +- js/sdk/src/internal/nodes/nodesManagement.ts | 12 +- js/sdk/src/internal/nodes/nodesRevisions.ts | 2 +- js/sdk/src/internal/photos/addToAlbum.ts | 2 +- js/sdk/src/internal/photos/albumsCrypto.ts | 1 + .../src/internal/photos/albumsManager.test.ts | 4 +- js/sdk/src/internal/photos/index.ts | 14 +- js/sdk/src/internal/photos/interface.ts | 4 +- js/sdk/src/internal/photos/nodes.test.ts | 4 +- js/sdk/src/internal/photos/nodes.ts | 6 +- .../src/internal/photos/photosManager.test.ts | 10 +- js/sdk/src/internal/photos/photosManager.ts | 8 +- .../photosTransferPayloadBuilder.test.ts | 2 +- js/sdk/src/internal/photos/timeline.test.ts | 2 +- js/sdk/src/internal/photos/upload.ts | 6 +- js/sdk/src/internal/sdkEvents.ts | 2 +- js/sdk/src/internal/shares/apiService.ts | 2 +- js/sdk/src/internal/shares/cache.ts | 2 +- .../src/internal/shares/cryptoCache.test.ts | 2 +- .../src/internal/shares/cryptoService.test.ts | 2 +- js/sdk/src/internal/shares/cryptoService.ts | 16 +- js/sdk/src/internal/shares/index.ts | 10 +- js/sdk/src/internal/shares/manager.ts | 4 +- js/sdk/src/internal/sharing/apiService.ts | 20 +- .../internal/sharing/cryptoService.test.ts | 2 +- js/sdk/src/internal/sharing/cryptoService.ts | 49 +- js/sdk/src/internal/sharing/events.test.ts | 4 +- js/sdk/src/internal/sharing/index.ts | 6 +- js/sdk/src/internal/sharing/interface.ts | 4 +- .../internal/sharing/sharingAccess.test.ts | 8 +- js/sdk/src/internal/sharing/sharingAccess.ts | 6 +- .../sharing/sharingManagement.test.ts | 8 +- .../src/internal/sharing/sharingManagement.ts | 18 +- .../internal/sharingPublic/cryptoReporter.ts | 14 +- js/sdk/src/internal/sharingPublic/index.ts | 12 +- js/sdk/src/internal/sharingPublic/nodes.ts | 6 +- .../sharingPublic/session/apiService.ts | 2 +- .../internal/sharingPublic/session/manager.ts | 2 +- js/sdk/src/internal/upload/apiService.ts | 13 +- js/sdk/src/internal/upload/cryptoService.ts | 14 +- .../src/internal/upload/fileUploader.test.ts | 8 +- js/sdk/src/internal/upload/index.ts | 6 +- js/sdk/src/internal/upload/interface.ts | 3 +- js/sdk/src/internal/upload/manager.ts | 8 +- .../internal/upload/smallFileUploader.test.ts | 8 +- .../internal/upload/streamUploader.test.ts | 10 +- js/sdk/src/internal/upload/streamUploader.ts | 8 +- js/sdk/src/internal/upload/telemetry.test.ts | 2 +- js/sdk/src/internal/upload/telemetry.ts | 6 +- js/sdk/src/polyfill.ts | 1 + js/sdk/src/protonDriveClient.ts | 64 +- js/sdk/src/protonDrivePhotosClient.ts | 64 +- js/sdk/src/protonDrivePublicLinkClient.ts | 50 +- js/sdk/src/transformers.ts | 18 +- js/sdk/tsconfig.json | 10 +- 125 files changed, 9690 insertions(+), 8261 deletions(-) delete mode 100644 js/sdk/src/crypto/hmac.ts delete mode 100644 js/sdk/src/crypto/utils.ts create mode 100644 js/sdk/src/polyfill.ts diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js index dc19ea3c..9863f4ec 100644 --- a/js/sdk/.eslintrc.js +++ b/js/sdk/.eslintrc.js @@ -10,10 +10,28 @@ module.exports = { sourceType: "module" }, rules: { + "simple-import-sort/imports": [ + "error", + { + groups: [ + ["^\u0000"], + ["^node:"], + ["^(?!@protontech/)@?\\w"], + ["^@protontech/"], + ["^\\."], + ], + }, + ], + "simple-import-sort/exports": "error", + "comma-spacing": ["error", { before: false, after: true }], "tsdoc/syntax": "warn", "no-console": "error", "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/consistent-type-exports": "error", + 'no-restricted-properties': ['error', { + object: 'CryptoProxy', + message: '`CryptoProxy` is not meant to be used in the SDK. Use `OpenPGPCryptoWithCryptoProxy` instead.' + }], }, overrides: [ { @@ -30,6 +48,7 @@ module.exports = { ], plugins: [ "@typescript-eslint/eslint-plugin", - "eslint-plugin-tsdoc" + "eslint-plugin-tsdoc", + "simple-import-sort", ] }; diff --git a/js/sdk/README.md b/js/sdk/README.md index 2c6efa2b..1d9cbb31 100644 --- a/js/sdk/README.md +++ b/js/sdk/README.md @@ -15,3 +15,8 @@ const sdk = new ProtonDriveClient({ openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoProxy), }); ``` + +### Polyfills + +The library uses some modern JS features that might not be available across Node versions or browsers. +The corresponding polyfills are available under `src/polyfill`, which should be manually imported by library users if needed (NB: polyfills should be loaded only once in a given global JS context, which is why this is left as a manual step). diff --git a/js/sdk/jest.config.js b/js/sdk/jest.config.js index 74b32032..076f15c0 100644 --- a/js/sdk/jest.config.js +++ b/js/sdk/jest.config.js @@ -1,6 +1,6 @@ module.exports = { moduleDirectories: ['/node_modules', 'node_modules'], - testPathIgnorePatterns: [], + testPathIgnorePatterns: ['/dist'], collectCoverage: false, transformIgnorePatterns: [], transform: { @@ -8,4 +8,5 @@ module.exports = { }, moduleNameMapper: {}, reporters: ['default'], + setupFiles: ['/src/polyfill.ts'] }; diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json index 9fd03091..9b61fe7a 100644 --- a/js/sdk/package-lock.json +++ b/js/sdk/package-lock.json @@ -7,21 +7,22 @@ "": { "name": "@protontech/drive-sdk", "version": "0.0.1", - "license": "GPL-3.0", + "license": "MIT", "dependencies": { "@noble/hashes": "^1.8.0", - "bcryptjs": "^2.4.3", "ttag": "^1.8.7" }, "devDependencies": { + "@protontech/crypto": "^2.0.0", + "@protontech/global-types": "^1.0.0", "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", - "@types/bcryptjs": "^2.4.6", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.10", - "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/eslint-plugin": "^8.58.1", "@web/dev-server-esbuild": "^1.0.3", "eslint": "^8.57.1", + "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-tsdoc": "^0.3.0", "glob": "^11.0.3", "jest": "^29.7.0", @@ -30,6 +31,9 @@ "ttag-cli": "^1.10.18", "typedoc": "^0.28.16", "typescript": "^5.9.3" + }, + "peerDependencies": { + "@protontech/crypto": "^2.0.0" } }, "node_modules/@ampproject/remapping": { @@ -3431,6 +3435,69 @@ "node": ">= 8" } }, + "node_modules/@openpgp/web-stream-tools": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@openpgp/web-stream-tools/-/web-stream-tools-0.1.3.tgz", + "integrity": "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "typescript": ">=4.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@protontech/crypto": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@protontech/crypto/-/crypto-2.0.0.tgz", + "integrity": "sha512-D3M023jLq/aMNCSr5p1KVlLV0grAeVXpUh8WyN2HITCIX83KLJo5BReNbVT1mXMvodPf/qYIxUoYXyaEW3pfOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@openpgp/web-stream-tools": "~0.1.3", + "bcryptjs": "^3.0.3", + "comlink": "^4.4.2", + "core-js": "^3.49.0", + "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.2", + "openpgp": "npm:@protontech/openpgp@~6.3.1-0" + } + }, + "node_modules/@protontech/crypto/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@protontech/crypto/node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/@protontech/global-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protontech/global-types/-/global-types-1.1.0.tgz", + "integrity": "sha512-gMqOI4Vpr57wI5Th66D6okSDdNu93dvCc3tXnLQYN4BdZy/nc6mS/ih8yiLaw7L6anSDtoBEiQiadUNNVJVH6g==", + "dev": true + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -3872,13 +3939,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -4179,20 +4239,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", - "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.53.1", - "@typescript-eslint/type-utils": "8.53.1", - "@typescript-eslint/utils": "8.53.1", - "@typescript-eslint/visitor-keys": "8.53.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4202,9 +4262,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.53.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -4218,17 +4278,41 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz", - "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.53.1", - "@typescript-eslint/types": "8.53.1", - "@typescript-eslint/typescript-estree": "8.53.1", - "@typescript-eslint/visitor-keys": "8.53.1", + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "engines": { @@ -4239,19 +4323,188 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz", - "integrity": "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.53.1", - "@typescript-eslint/types": "^8.53.1", + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "engines": { @@ -4262,18 +4515,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz", - "integrity": "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.1", - "@typescript-eslint/visitor-keys": "8.53.1" + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4284,9 +4537,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz", - "integrity": "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", "dev": true, "license": "MIT", "engines": { @@ -4297,21 +4550,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz", - "integrity": "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.1", - "@typescript-eslint/typescript-estree": "8.53.1", - "@typescript-eslint/utils": "8.53.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4321,14 +4574,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.1.tgz", - "integrity": "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", "dev": true, "license": "MIT", "engines": { @@ -4340,21 +4593,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz", - "integrity": "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.53.1", - "@typescript-eslint/tsconfig-utils": "8.53.1", - "@typescript-eslint/types": "8.53.1", - "@typescript-eslint/visitor-keys": "8.53.1", + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4364,13 +4617,52 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4381,16 +4673,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.1.tgz", - "integrity": "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.53.1", - "@typescript-eslint/types": "8.53.1", - "@typescript-eslint/typescript-estree": "8.53.1" + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4400,19 +4692,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.53.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz", - "integrity": "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.1", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4423,13 +4715,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4482,6 +4774,19 @@ "node": ">=16.14" } }, + "node_modules/@web/dev-server-core/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@web/dev-server-esbuild": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-1.0.4.tgz", @@ -4656,6 +4961,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4980,12 +5298,6 @@ ], "license": "MIT" }, - "node_modules/bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -5359,6 +5671,13 @@ "dev": true, "license": "MIT" }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5410,6 +5729,18 @@ "node": ">= 0.8" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.40.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz", @@ -5856,6 +6187,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-plugin-simple-import-sort": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-13.0.0.tgz", + "integrity": "sha512-McAc+/Nlvcg4byY/CABGH8kqnefWBj8s3JA2okEtz8ixbECQgU46p0HkTUKa4YS7wvgGceimlc34p1nXqbWqtA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, "node_modules/eslint-plugin-tsdoc": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.3.0.tgz", @@ -6156,6 +6497,24 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -7658,6 +8017,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -7791,6 +8163,14 @@ "node": ">=6" } }, + "node_modules/jsmimeparser": { + "name": "@protontech/jsmimeparser", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@protontech/jsmimeparser/-/jsmimeparser-3.0.2.tgz", + "integrity": "sha512-PConkdRdY8xc1A8+oEmQ2pGC7nnJPqWwkUb/+odjFPbof2w3zpIws77D9J9assvZg8OdB8dRe/cZdBU+BCijgA==", + "dev": true, + "license": "MIT" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -8300,6 +8680,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -8600,6 +8993,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openpgp": { + "name": "@protontech/openpgp", + "version": "6.3.1-0", + "resolved": "https://registry.npmjs.org/@protontech/openpgp/-/openpgp-6.3.1-0.tgz", + "integrity": "sha512-TA4wAE86bxYKxrULnnv8Uepu9hKi0e0M/zn4cwiDdgVhEzsqhaKnn3uRSY8Ba9wNcD6gSVALMlUDXdd94k85rA==", + "dev": true, + "license": "LGPL-3.0+", + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8901,13 +9305,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -10126,14 +10530,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10142,37 +10546,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -10224,9 +10597,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { diff --git a/js/sdk/package.json b/js/sdk/package.json index d626c6cf..c6bb8a53 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -26,18 +26,19 @@ }, "dependencies": { "@noble/hashes": "^1.8.0", - "bcryptjs": "^2.4.3", "ttag": "^1.8.7" }, "devDependencies": { + "@protontech/crypto": "^2.0.0", + "@protontech/global-types": "^1.0.0", "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", - "@types/bcryptjs": "^2.4.6", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.10", - "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/eslint-plugin": "^8.58.1", "@web/dev-server-esbuild": "^1.0.3", "eslint": "^8.57.1", + "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-tsdoc": "^0.3.0", "glob": "^11.0.3", "jest": "^29.7.0", @@ -46,5 +47,8 @@ "ttag-cli": "^1.10.18", "typedoc": "^0.28.16", "typescript": "^5.9.3" + }, + "peerDependencies": { + "@protontech/crypto": "^2.0.0" } } diff --git a/js/sdk/src/cache/index.ts b/js/sdk/src/cache/index.ts index 3b403d79..fde52d19 100644 --- a/js/sdk/src/cache/index.ts +++ b/js/sdk/src/cache/index.ts @@ -1,3 +1,3 @@ -export type { ProtonDriveCache, EntityResult } from './interface'; +export type { EntityResult, ProtonDriveCache } from './interface'; export { MemoryCache } from './memoryCache'; export { NullCache } from './nullCache'; diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts index 442ff821..859c1061 100644 --- a/js/sdk/src/cache/memoryCache.ts +++ b/js/sdk/src/cache/memoryCache.ts @@ -1,4 +1,4 @@ -import type { ProtonDriveCache, EntityResult } from './interface'; +import type { EntityResult, ProtonDriveCache } from './interface'; type KeyValueCache = { [key: string]: T }; type TagsCache = { [tag: string]: string[] }; diff --git a/js/sdk/src/cache/nullCache.ts b/js/sdk/src/cache/nullCache.ts index fbbd78f6..83dbdf31 100644 --- a/js/sdk/src/cache/nullCache.ts +++ b/js/sdk/src/cache/nullCache.ts @@ -1,4 +1,4 @@ -import type { ProtonDriveCache, EntityResult } from './interface'; +import type { EntityResult, ProtonDriveCache } from './interface'; /** * Null cache implementation for Proton Drive SDK. diff --git a/js/sdk/src/crypto/driveCrypto.test.ts b/js/sdk/src/crypto/driveCrypto.test.ts index 2e09e44f..e9836bd2 100644 --- a/js/sdk/src/crypto/driveCrypto.test.ts +++ b/js/sdk/src/crypto/driveCrypto.test.ts @@ -1,5 +1,5 @@ -import { uint8ArrayToUtf8, arrayToHexString, DriveCrypto } from './driveCrypto'; import { getMockTelemetry } from '../tests/telemetry'; +import { DriveCrypto, uint8ArrayToUtf8 } from './driveCrypto'; describe('uint8ArrayToUtf8', () => { it('should convert a Uint8Array to a UTF-8 string', () => { @@ -22,29 +22,6 @@ describe('uint8ArrayToUtf8', () => { }); }); -describe('arrayToHexString', () => { - it('should convert a Uint8Array to a hex string', () => { - const input = new Uint8Array([0, 255, 16, 32]); - const expectedOutput = '00ff1020'; - const result = arrayToHexString(input); - expect(result).toBe(expectedOutput); - }); - - it('should handle an empty Uint8Array', () => { - const input = new Uint8Array([]); - const expectedOutput = ''; - const result = arrayToHexString(input); - expect(result).toBe(expectedOutput); - }); - - it('should handle a Uint8Array with one element', () => { - const input = new Uint8Array([1]); - const expectedOutput = '01'; - const result = arrayToHexString(input); - expect(result).toBe(expectedOutput); - }); -}); - describe('DriveCrypto.encryptShareUrlPassword', () => { it('should encrypt and sign the password', async () => { const mockOpenPGPCrypto = { diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 220b566d..3b13bbce 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -1,3 +1,5 @@ +import { importKey as importHmacKey, signData as computeHmacSignature } from '@protontech/crypto/subtle/hmac.ts'; + import { ProtonDriveTelemetry } from '../interface'; import { OpenPGPCrypto, @@ -8,9 +10,6 @@ import { SRPVerifier, VERIFICATION_STATUS, } from './interface'; -import { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; -// TODO: Switch to CryptoProxy module once available. -import { importHmacKey, computeHmacSignature } from './hmac'; enum SIGNING_CONTEXTS { SHARING_INVITER = 'drive.share-member.inviter', @@ -138,7 +137,7 @@ export class DriveCrypto { return { encrypted: { contentKeyPacket: keyPacket, - base64ContentKeyPacket: uint8ArrayToBase64String(keyPacket), + base64ContentKeyPacket: keyPacket.toBase64(), armoredContentKeyPacketSignature, }, decrypted: { @@ -244,25 +243,41 @@ export class DriveCrypto { }> { const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(sessionKey, [encryptionKey]); return { - base64KeyPacket: uint8ArrayToBase64String(keyPacket), + base64KeyPacket: keyPacket.toBase64(), + }; + } + + private async computeSrpKeySaltAndPassphrase(password: string) { + if (!password) { + throw new Error('Password required.'); + } + + const base64Salt = this.srpModule.generateKeySalt(); + const saltedPassphrase = await this.srpModule.computeKeyPassword(password, base64Salt); + + return { + base64Salt, + saltedPassphrase, }; } /** * It encrypts password with provided address key that can be used to * manage the public link, encrypts share passphrase session key using - * provided bcrypt passphrase and generates SRP verifier. + * the srp-compatible salted passphrase and generates the corresponding SRP verifier. */ async encryptPublicLinkPasswordAndSessionKey( password: string, addressKey: PrivateKey, - bcryptPassphrase: string, sharePassphraseSessionKey: SessionKey, ): Promise<{ armoredPassword: string; base64SharePassphraseKeyPacket: string; + base64SharePasswordSalt: string; srp: SRPVerifier; }> { + const { saltedPassphrase, base64Salt: base64SharePasswordSalt } = + await this.computeSrpKeySaltAndPassphrase(password); const [{ armoredData: armoredPassword }, { keyPacket }, srp] = await Promise.all([ this.openPGPCrypto.encryptArmored( new TextEncoder().encode(password), @@ -271,22 +286,23 @@ export class DriveCrypto { // See note in the interface documentation about AEAD encryption. { enableAeadWithEncryptionKeys: false }, ), - this.openPGPCrypto.encryptSessionKeyWithPassword(sharePassphraseSessionKey, bcryptPassphrase), + this.openPGPCrypto.encryptSessionKeyWithPassword(sharePassphraseSessionKey, saltedPassphrase), this.srpModule.getSrpVerifier(password), ]); return { armoredPassword, - base64SharePassphraseKeyPacket: uint8ArrayToBase64String(keyPacket), + base64SharePassphraseKeyPacket: keyPacket.toBase64(), + base64SharePasswordSalt, srp, }; } /** - * It decrypts the key using the password via SRP protocol. + * It decrypts the key using the password that was verified via SRP protocol. * - * The function follows the same functionality as `decryptKey` but uses SRP - * protocol to decrypt the passphrase of the key. It is used for saved + * The function follows the same functionality as `decryptKey` but it uses the password + * that was used for authentication via SRP protocol to decrypt the passphrase of the key. It is used for saved * public links where user saved the link with password and is not direct * member of the share. */ @@ -329,7 +345,7 @@ export class DriveCrypto { verified?: VERIFICATION_STATUS; verificationErrors?: Error[]; }> { - const data = base64StringToUint8Array(base64data); + const data = Uint8Array.fromBase64(base64data); const sessionKey = await this.openPGPCrypto.decryptSessionKey(data, decryptionKeys); @@ -424,7 +440,7 @@ export class DriveCrypto { const key = await importHmacKey(parentHashKey); const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); - return arrayToHexString(signature); + return signature.toHex(); } /** @@ -585,8 +601,8 @@ export class DriveCrypto { SIGNING_CONTEXTS.SHARING_INVITER, ); return { - base64KeyPacket: uint8ArrayToBase64String(keyPacket), - base64KeyPacketSignature: uint8ArrayToBase64String(keyPacketSignature), + base64KeyPacket: keyPacket.toBase64(), + base64KeyPacketSignature: keyPacketSignature.toBase64(), }; } @@ -599,7 +615,7 @@ export class DriveCrypto { verificationErrors?: Error[]; }> { const { verified, verificationErrors } = await this.openPGPCrypto.verifyArmored( - base64StringToUint8Array(base64KeyPacket), + Uint8Array.fromBase64(base64KeyPacket), armoredKeyPacketSignature, verificationKeys, SIGNING_CONTEXTS.SHARING_INVITER, @@ -614,7 +630,7 @@ export class DriveCrypto { base64SessionKeySignature: string; }> { const sessionKey = await this.openPGPCrypto.decryptSessionKey( - base64StringToUint8Array(base64KeyPacket), + Uint8Array.fromBase64(base64KeyPacket), signingKey, ); @@ -625,7 +641,7 @@ export class DriveCrypto { ); return { - base64SessionKeySignature: uint8ArrayToBase64String(signature), + base64SessionKeySignature: signature.toBase64(), }; } @@ -636,7 +652,7 @@ export class DriveCrypto { ): Promise<{ base64ExternalInvitationSignature: string; }> { - const data = inviteeEmail.concat('|').concat(uint8ArrayToBase64String(shareSessionKey.data)); + const data = inviteeEmail.concat('|').concat(shareSessionKey.data.toBase64()); const { signature: externalInviationSignature } = await this.openPGPCrypto.sign( new TextEncoder().encode(data), @@ -644,7 +660,7 @@ export class DriveCrypto { SIGNING_CONTEXTS.SHARING_INVITER_EXTERNAL_INVITATION, ); return { - base64ExternalInvitationSignature: uint8ArrayToBase64String(externalInviationSignature), + base64ExternalInvitationSignature: externalInviationSignature.toBase64(), }; } @@ -807,17 +823,3 @@ export class DriveCrypto { export function uint8ArrayToUtf8(input: Uint8Array): string { return new TextDecoder('utf-8', { fatal: true }).decode(input); } - -/** - * Convert an array of 8-bit integers to a hex string - * @param bytes - Array of 8-bit integers to convert - * @returns Hexadecimal representation of the array - */ -export const arrayToHexString = (bytes: Uint8Array) => { - const hexAlphabet = '0123456789abcdef'; - let s = ''; - bytes.forEach((v) => { - s += hexAlphabet[v >> 4] + hexAlphabet[v & 15]; - }); - return s; -}; diff --git a/js/sdk/src/crypto/hmac.ts b/js/sdk/src/crypto/hmac.ts deleted file mode 100644 index 8cc89713..00000000 --- a/js/sdk/src/crypto/hmac.ts +++ /dev/null @@ -1,46 +0,0 @@ -const HASH_ALGORITHM = 'SHA-256'; -const KEY_LENGTH_BYTES = 32; -export type HmacCryptoKey = CryptoKey; - -type HmacKeyUsage = 'sign' | 'verify'; - -/** - * Import an HMAC-SHA256 key in order to use it with `signData` and `verifyData`. - */ -export const importHmacKey = async ( - key: Uint8Array, - keyUsage: HmacKeyUsage[] = ['sign', 'verify'], -): Promise => { - // From https://datatracker.ietf.org/doc/html/rfc2104: - // The key for HMAC can be of any length (keys longer than B bytes are first hashed using H). - // However, less than L bytes (L = 32 bytes for SHA-256) is strongly discouraged as it would - // decrease the security strength of the function. Keys longer than L bytes are acceptable - // but the extra length would not significantly increase the function strength. - // (A longer key may be advisable if the randomness of the key is considered weak.) - if (key.length < KEY_LENGTH_BYTES) { - throw new Error('Unexpected HMAC key size: key is too short'); - } - return crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: HASH_ALGORITHM }, false, keyUsage); -}; - -/** - * Sign data using HMAC-SHA256 - * @param key - WebCrypto secret key for signing - * @param data - data to sign - * @param additionalData - additional data to authenticate - */ -export const computeHmacSignature = async (key: HmacCryptoKey, data: Uint8Array) => { - const signatureBuffer = await crypto.subtle.sign({ name: 'HMAC', hash: HASH_ALGORITHM }, key, data); - return new Uint8Array(signatureBuffer); -}; - -/** - * Verify data using HMAC-SHA256 - * @param key - WebCrypto secret key for verification - * @param signature - signature over data - * @param data - data to verify - * @param additionalData - additional data to authenticate - */ -export const verifyData = async (key: HmacCryptoKey, signature: Uint8Array, data: Uint8Array) => { - return crypto.subtle.verify({ name: 'HMAC', hash: HASH_ALGORITHM }, key, signature, data); -}; diff --git a/js/sdk/src/crypto/index.ts b/js/sdk/src/crypto/index.ts index c8a81970..2265b8f0 100644 --- a/js/sdk/src/crypto/index.ts +++ b/js/sdk/src/crypto/index.ts @@ -1,6 +1,4 @@ -export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, SRPModule, SRPVerifier } from './interface'; -export { VERIFICATION_STATUS } from './interface'; export { DriveCrypto } from './driveCrypto'; -export type { OpenPGPCryptoProxy } from './openPGPCrypto'; +export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, SRPModule, SRPVerifier } from './interface'; export { OpenPGPCryptoWithCryptoProxy } from './openPGPCrypto'; -export { uint8ArrayToBase64String, base64StringToUint8Array } from './utils'; +export { VERIFICATION_STATUS } from '@protontech/crypto'; diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index a522e289..c21890e4 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -1,53 +1,6 @@ -// TODO: Use CryptoProxy once available. -export interface PublicKey { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly _idx: any; - readonly _keyContentHash: [string, string]; +import type { CryptoApiInterface, PrivateKeyReference as PrivateKey, PublicKeyReference as PublicKey, SessionKey, VERIFICATION_STATUS } from '@protontech/crypto'; - getVersion(): number; - getFingerprint(): string; - getSHA256Fingerprints(): string[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getKeyID(): any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getKeyIDs(): any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getAlgorithmInfo(): any; - getCreationTime(): Date; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isPrivate: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isPrivateKeyV4: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isPrivateKeyV6: any; - getExpirationTime(): Date | number | null; - getUserIDs(): string[]; - isWeak(): boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - equals(otherKey: any, ignoreOtherCerts?: boolean): boolean; - subkeys: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getAlgorithmInfo(): any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getKeyID(): any; - }[]; -} - -export interface PrivateKey extends PublicKey { - readonly _dummyType: 'private'; -} - -export interface SessionKey { - data: Uint8Array; - algorithm: string | null; - aeadAlgorithm: string | null; -} - -export enum VERIFICATION_STATUS { - NOT_SIGNED = 0, - SIGNED_AND_VALID = 1, - SIGNED_AND_INVALID = 2, -} +export type { CryptoApiInterface, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS }; export interface SRPModule { getSrp: ( @@ -63,6 +16,7 @@ export interface SRPModule { }>; getSrpVerifier: (password: string) => Promise; computeKeyPassword: (password: string, salt: string) => Promise; + generateKeySalt: () => string; } export type SRPVerifier = { diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index b93415b3..dc9d97b2 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -1,97 +1,13 @@ import { c } from 'ttag'; -import { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from './interface'; -import { uint8ArrayToBase64String } from './utils'; - -/** - * Interface matching CryptoProxy interface from client's monorepo: - * clients/packages/crypto/lib/proxy/proxy.ts. - */ -export interface OpenPGPCryptoProxy { - generateKey: (options: { - userIDs: { name: string }[]; - type: 'ecc'; - curve: 'ed25519Legacy'; - config?: { aeadProtect: boolean }; - }) => Promise; - exportPrivateKey: (options: { privateKey: PrivateKey; passphrase: string | null }) => Promise; - importPrivateKey: (options: { armoredKey: string; passphrase: string | null }) => Promise; - generateSessionKey: (options: { - recipientKeys: PublicKey[]; - config?: { ignoreSEIPDv2FeatureFlag: boolean }; - }) => Promise; - encryptSessionKey: ( - options: SessionKey & { - format: 'binary'; - encryptionKeys?: PublicKey | PublicKey[]; - passwords?: string[]; - }, - ) => Promise>; - decryptSessionKey: (options: { - armoredMessage?: string; - binaryMessage?: Uint8Array; - decryptionKeys: PrivateKey | PrivateKey[]; - }) => Promise; - encryptMessage: (options: { - format?: Format; - binaryData: Uint8Array; - sessionKey?: SessionKey; - encryptionKeys: PublicKey[]; - signingKeys?: PrivateKey; - detached?: Detached; - compress?: boolean; - config?: { ignoreSEIPDv2FeatureFlag: boolean }; - }) => Promise< - Detached extends true - ? { - message: Format extends 'binary' ? Uint8Array : string; - signature: Format extends 'binary' ? Uint8Array : string; - } - : { - message: Format extends 'binary' ? Uint8Array : string; - } - >; - decryptMessage: (options: { - format: Format; - armoredMessage?: string; - binaryMessage?: Uint8Array; - armoredSignature?: string; - binarySignature?: Uint8Array; - sessionKeys?: SessionKey; - passwords?: string[]; - decryptionKeys?: PrivateKey | PrivateKey[]; - verificationKeys?: PublicKey | PublicKey[]; - }) => Promise<{ - data: Format extends 'binary' ? Uint8Array : string; - verificationStatus: VERIFICATION_STATUS; - verificationErrors?: Error[]; - }>; - signMessage: (options: { - format: Format; - binaryData: Uint8Array; - signingKeys: PrivateKey | PrivateKey[]; - detached: boolean; - signatureContext?: { critical: boolean; value: string }; - }) => Promise : string>; - verifyMessage: (options: { - binaryData: Uint8Array; - armoredSignature?: string; - binarySignature?: Uint8Array; - verificationKeys: PublicKey | PublicKey[]; - signatureContext?: { required: boolean; value: string }; - }) => Promise<{ - verificationStatus: VERIFICATION_STATUS; - errors?: Error[]; - }>; -} +import type { CryptoApiInterface, OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interface'; /** * Implementation of OpenPGPCrypto interface using CryptoProxy from clients - * monorepo that must be passed as dependency. In the future, CryptoProxy - * will be published separately and this implementation will use it directly. + * monorepo that must be passed as dependency. */ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { - constructor(private cryptoProxy: OpenPGPCryptoProxy) { + constructor(private cryptoProxy: CryptoApiInterface) { this.cryptoProxy = cryptoProxy; } @@ -99,7 +15,7 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { const value = crypto.getRandomValues(new Uint8Array(32)); // TODO: Once all clients can use non-ascii bytes, switch to simple // generating of random bytes without encoding it into base64. - return uint8ArrayToBase64String(value); + return value.toBase64(); } async generateSessionKey(encryptionKeys: PublicKey[], options: { enableAeadWithEncryptionKeys: boolean }) { diff --git a/js/sdk/src/crypto/utils.ts b/js/sdk/src/crypto/utils.ts deleted file mode 100644 index d1102114..00000000 --- a/js/sdk/src/crypto/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -// This file has copy-pasted utilities from CryptoProxy located in Proton web clients monorepo. - -export function uint8ArrayToBase64String(array: Uint8Array) { - return encodeBase64(arrayToBinaryString(array)); -} - -export function base64StringToUint8Array(string: string) { - return binaryStringToArray(decodeBase64(string) || ''); -} - -const ifDefined = - (cb: (input: T) => R) => - (input: U) => { - return (input !== undefined ? cb(input as T) : undefined) as U extends T ? R : undefined; - }; - -const encodeBase64 = ifDefined((input: string) => btoa(input).trim()); - -const decodeBase64 = ifDefined((input: string) => atob(input.trim())); - -const arrayToBinaryString = (bytes: Uint8Array) => { - const result = []; - const bs = 1 << 14; - const j = bytes.length; - - for (let i = 0; i < j; i += bs) { - // @ts-expect-error Uint8Array treated as number[] - // eslint-disable-next-line prefer-spread - result.push(String.fromCharCode.apply(String, bytes.subarray(i, i + bs < j ? i + bs : j))); - } - return result.join(''); -}; - -const binaryStringToArray = (str: string) => { - const result = new Uint8Array(str.length); - for (let i = 0; i < str.length; i++) { - result[i] = str.charCodeAt(i); - } - return result; -}; diff --git a/js/sdk/src/diagnostic/index.ts b/js/sdk/src/diagnostic/index.ts index 64a0bda8..d1eeb845 100644 --- a/js/sdk/src/diagnostic/index.ts +++ b/js/sdk/src/diagnostic/index.ts @@ -1,18 +1,18 @@ import { MemoryCache, NullCache } from '../cache'; import { ProtonDriveClientContructorParameters } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; +import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; import { Diagnostic as DiagnosticClass } from './diagnostic'; -import { Diagnostic } from './interface'; import { DiagnosticHTTPClient } from './httpClient'; +import { Diagnostic } from './interface'; import { DiagnosticTelemetry } from './telemetry'; -import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; export type { Diagnostic, DiagnosticOptions, - ExpectedTreeNode, DiagnosticProgressCallback, DiagnosticResult, + ExpectedTreeNode, } from './interface'; /** diff --git a/js/sdk/src/diagnostic/integrityVerificationStream.ts b/js/sdk/src/diagnostic/integrityVerificationStream.ts index b428d726..8db0db15 100644 --- a/js/sdk/src/diagnostic/integrityVerificationStream.ts +++ b/js/sdk/src/diagnostic/integrityVerificationStream.ts @@ -1,5 +1,4 @@ import { sha1 } from '@noble/hashes/legacy'; -import { bytesToHex } from '@noble/hashes/utils'; /** * A WritableStream that computes SHA1 hash on the fly. @@ -23,7 +22,7 @@ export class IntegrityVerificationStream extends WritableStream { }, close: () => { if (!this._isClosed) { - this._computedSha1 = bytesToHex(this.sha1Hash.digest()); + this._computedSha1 = this.sha1Hash.digest().toHex(); this._isClosed = true; } }, diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index fd024993..fb1a2067 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -1,4 +1,4 @@ -import { Author, MaybeNode, MetricEvent, NodeType, AnonymousUser } from '../interface'; +import { AnonymousUser, Author, MaybeNode, MetricEvent, NodeType } from '../interface'; import { LogRecord } from '../telemetry'; export interface Diagnostic { diff --git a/js/sdk/src/diagnostic/nodeUtils.ts b/js/sdk/src/diagnostic/nodeUtils.ts index 2cdc0a80..d8fef83f 100644 --- a/js/sdk/src/diagnostic/nodeUtils.ts +++ b/js/sdk/src/diagnostic/nodeUtils.ts @@ -1,7 +1,7 @@ import { MaybeNode, NodeType, Revision } from '../interface'; import { - NodeDetails, ExpectedTreeNode, + NodeDetails, } from './interface'; export function getNodeDetails(node: MaybeNode): NodeDetails { diff --git a/js/sdk/src/diagnostic/sdkDiagnosticBase.ts b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts index 79dec289..964c3199 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticBase.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts @@ -1,19 +1,19 @@ -import { Author, FileDownloader, MaybeNode, NodeOrUid, NodeType, ThumbnailType, ThumbnailResult } from '../interface'; +import { Author, FileDownloader, MaybeNode, NodeOrUid, NodeType, ThumbnailResult, ThumbnailType } from '../interface'; import { isProtonDocument, isProtonSheet } from '../internal/nodes/mediaTypes'; +import { IntegrityVerificationStream } from './integrityVerificationStream'; import { DiagnosticOptions, + DiagnosticProgressCallback, DiagnosticResult, ExpectedTreeNode, - DiagnosticProgressCallback, } from './interface'; -import { IntegrityVerificationStream } from './integrityVerificationStream'; import { - getNodeType, - getNodeDetails, getActiveRevision, - getMediaType, getExpectedTreeNodeDetails, + getMediaType, + getNodeDetails, getNodeName, + getNodeType, } from './nodeUtils'; const PROGRESS_REPORT_INTERVAL = 500; diff --git a/js/sdk/src/diagnostic/sdkDiagnosticMain.ts b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts index 30a1c784..33f712c0 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticMain.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts @@ -7,9 +7,9 @@ import { ExpectedTreeNode, TreeNode, } from './interface'; -import { zipGenerators } from './zipGenerators'; -import { getNodeType, getNodeName, getTreeNodeChildByNodeName, getActiveRevision } from './nodeUtils'; +import { getActiveRevision, getNodeName, getNodeType, getTreeNodeChildByNodeName } from './nodeUtils'; import { SDKDiagnosticBase } from './sdkDiagnosticBase'; +import { zipGenerators } from './zipGenerators'; /** * Diagnostic tool that uses the main Drive SDK to traverse and verify diff --git a/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts index 324d88b6..e185da0f 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts @@ -7,9 +7,9 @@ import { ExpectedTreeNode, TreeNode, } from './interface'; -import { zipGenerators } from './zipGenerators'; -import { getNodeName, getTreeNodeChildByNodeName, getActiveRevision, getNodeType } from './nodeUtils'; +import { getActiveRevision, getNodeName, getNodeType, getTreeNodeChildByNodeName } from './nodeUtils'; import { SDKDiagnosticBase } from './sdkDiagnosticBase'; +import { zipGenerators } from './zipGenerators'; /** * Diagnostic tool that uses the Photos SDK to traverse and verify diff --git a/js/sdk/src/diagnostic/telemetry.ts b/js/sdk/src/diagnostic/telemetry.ts index ea0c30c9..cb080bcf 100644 --- a/js/sdk/src/diagnostic/telemetry.ts +++ b/js/sdk/src/diagnostic/telemetry.ts @@ -1,5 +1,5 @@ import { MetricEvent } from '../interface'; -import { LogRecord, LogLevel } from '../telemetry'; +import { LogLevel, LogRecord } from '../telemetry'; import { EventsGenerator } from './eventsGenerator'; /** diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 9ea8e552..38b7015b 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -4,12 +4,12 @@ import { makeNodeUid } from './internal/uids'; -export * from './interface'; export * from './cache'; -export * from './errors'; -export type { OpenPGPCrypto, OpenPGPCryptoProxy } from './crypto'; +export type { OpenPGPCrypto } from './crypto'; export { OpenPGPCryptoWithCryptoProxy } from './crypto'; +export * from './errors'; export { NullFeatureFlagProvider } from './featureFlags'; +export * from './interface'; export { ProtonDriveClient } from './protonDriveClient'; export { VERSION } from './version'; diff --git a/js/sdk/src/interface/devices.ts b/js/sdk/src/interface/devices.ts index e8424f1f..ae3f17ad 100644 --- a/js/sdk/src/interface/devices.ts +++ b/js/sdk/src/interface/devices.ts @@ -1,5 +1,5 @@ -import { Result } from './result'; import { InvalidNameError } from './nodes'; +import { Result } from './result'; export type Device = { uid: string; diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index dc358944..e691e44b 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -5,99 +5,99 @@ import { ProtonDriveAccount } from './account'; import { ProtonDriveConfig } from './config'; import { FeatureFlagProvider } from './featureFlags'; import { ProtonDriveHTTPClient } from './httpClient'; -import { Telemetry, MetricEvent } from './telemetry'; +import { MetricEvent, Telemetry } from './telemetry'; -export type { Result } from './result'; -export { resultOk, resultError } from './result'; export type { ProtonDriveAccount, ProtonDriveAccountAddress } from './account'; -export type { Author, UnverifiedAuthorError, AnonymousUser } from './author'; +export type { AnonymousUser, Author, UnverifiedAuthorError } from './author'; export type { ProtonDriveConfig } from './config'; export type { Device, DeviceOrUid } from './devices'; -export type { FeatureFlagProvider } from './featureFlags'; -export { FeatureFlags } from './featureFlags'; export { DeviceType } from './devices'; -export type { FileDownloader, DownloadController, SeekableReadableStream } from './download'; +export type { DownloadController, FileDownloader, SeekableReadableStream } from './download'; export type { + DriveEvent, DriveListener, + FastForwardEvent, LatestEventIdProvider, - DriveEvent, NodeEvent, - FastForwardEvent, + SharedWithMeUpdated, TreeRefreshEvent, TreeRemovalEvent, - SharedWithMeUpdated, } from './events'; export { DriveEventType, SDKEvent } from './events'; +export type { FeatureFlagProvider } from './featureFlags'; +export { FeatureFlags } from './featureFlags'; export type { ProtonDriveHTTPClient, - ProtonDriveHTTPClientJsonRequest, ProtonDriveHTTPClientBlobRequest, + ProtonDriveHTTPClientJsonRequest, } from './httpClient'; export type { - MaybeNode, - NodeEntity, DegradedNode, + InvalidNameError, MaybeMissingNode, + MaybeNode, + Membership, MissingNode, - InvalidNameError, - Revision, + NodeEntity, NodeOrUid, - RevisionOrUid, NodeResult, NodeResultWithError, NodeResultWithNewUid, - Membership, + Revision, + RevisionOrUid, } from './nodes'; -export { NodeType, MemberRole, RevisionState } from './nodes'; +export { MemberRole, NodeType, RevisionState } from './nodes'; export type { - MaybePhotoNode, - MaybeMissingPhotoNode, - PhotoNode, + AlbumAttributes, DegradedPhotoNode, + MaybeMissingPhotoNode, + MaybePhotoNode, PhotoAttributes, - AlbumAttributes, + PhotoNode, } from './photos'; export { PhotoTag } from './photos'; +export type { Result } from './result'; +export { resultError, resultOk } from './result'; export type { - ProtonInvitation, - ProtonInvitationWithNode, - NonProtonInvitation, - Member, - PublicLink, - MaybeBookmark, Bookmark, + BookmarkOrUid, DegradedBookmark, - ProtonInvitationOrUid, + MaybeBookmark, + Member, + NonProtonInvitation, NonProtonInvitationOrUid, - BookmarkOrUid, - ShareNodeSettings, - UnshareNodeSettings, + ProtonInvitation, + ProtonInvitationOrUid, + ProtonInvitationWithNode, + PublicLink, ShareMembersSettings, + ShareNodeSettings, SharePublicLinkSettings, SharePublicLinkSettingsObject, ShareResult, + UnshareNodeSettings, } from './sharing'; export { NonProtonInvitationState } from './sharing'; export type { - Telemetry, Logger, MetricAPIRetrySucceededEvent, - MetricUploadEvent, - MetricsUploadErrorType, - MetricDownloadEvent, - MetricsDownloadErrorType, + MetricBlockVerificationErrorEvent, MetricDecryptionErrorEvent, + MetricDownloadEvent, + MetricEvent, MetricsDecryptionErrorField, + MetricsDownloadErrorType, + MetricsUploadErrorType, + MetricUploadEvent, MetricVerificationErrorEvent, MetricVerificationErrorField, - MetricBlockVerificationErrorEvent, MetricVolumeEventsSubscriptionsChangedEvent, - MetricEvent, + Telemetry, } from './telemetry'; export { MetricVolumeType } from './telemetry'; -export type { FileUploader, UploadController, UploadMetadata } from './upload'; export type { Thumbnail, ThumbnailResult } from './thumbnail'; export { ThumbnailType } from './thumbnail'; +export type { FileUploader, UploadController, UploadMetadata } from './upload'; export type ProtonDriveTelemetry = Telemetry; export type ProtonDriveEntitiesCache = ProtonDriveCache; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 13ba13db..e90bea7f 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -1,5 +1,5 @@ -import { Result } from './result'; import { Author } from './author'; +import { Result } from './result'; /** * Node representing a file or folder in the system. diff --git a/js/sdk/src/interface/photos.ts b/js/sdk/src/interface/photos.ts index 2c7d8fce..86955678 100644 --- a/js/sdk/src/interface/photos.ts +++ b/js/sdk/src/interface/photos.ts @@ -1,5 +1,5 @@ +import { DegradedNode, MissingNode, NodeEntity, NodeType } from './nodes'; import { Result } from './result'; -import { DegradedNode, NodeEntity, NodeType, MissingNode } from './nodes'; /** * Node representing a photo or album for Photos SDK. diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts index f0b555e9..8d2520b4 100644 --- a/js/sdk/src/interface/sharing.ts +++ b/js/sdk/src/interface/sharing.ts @@ -1,6 +1,6 @@ -import { Result } from './result'; import { UnverifiedAuthorError } from './author'; -import { NodeType, MemberRole, InvalidNameError } from './nodes'; +import { InvalidNameError, MemberRole, NodeType } from './nodes'; +import { Result } from './result'; export type Member = { uid: string; diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 689573de..660149c1 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -1,9 +1,9 @@ import { AbortError } from '../../errors'; -import { ProtonDriveHTTPClient, SDKEvent, Telemetry, MetricEvent } from '../../interface'; +import { MetricEvent, ProtonDriveHTTPClient, SDKEvent, Telemetry } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; import { SDKEvents } from '../sdkEvents'; import { DriveAPIService } from './apiService'; -import { HTTPErrorCode, ErrorCode } from './errorCodes'; +import { ErrorCode, HTTPErrorCode } from './errorCodes'; jest.useFakeTimers(); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 1d87d33f..18b9d3e0 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -1,10 +1,10 @@ import { c } from 'ttag'; +import { AbortError, ProtonDriveError, RateLimitedError, ServerError } from '../../errors'; +import { Logger, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../interface'; import { VERSION } from '../../version'; -import { ProtonDriveHTTPClient, ProtonDriveTelemetry, Logger } from '../../interface'; -import { AbortError, ServerError, RateLimitedError, ProtonDriveError } from '../../errors'; -import { waitSeconds } from '../wait'; import { SDKEvents } from '../sdkEvents'; +import { waitSeconds } from '../wait'; import { HTTPErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; import { apiErrorFactory } from './errors'; diff --git a/js/sdk/src/internal/apiService/coreTypes.ts b/js/sdk/src/internal/apiService/coreTypes.ts index b112711f..315d23bc 100644 --- a/js/sdk/src/internal/apiService/coreTypes.ts +++ b/js/sdk/src/internal/apiService/coreTypes.ts @@ -4,14 +4,14 @@ */ export interface paths { - '/core/{_version}/addresses/allowAddressDeletion': { + "/core/{_version}/addresses/allowAddressDeletion": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-addresses-allowAddressDeletion']; + get: operations["get_core-{_version}-addresses-allowAddressDeletion"]; put?: never; post?: never; delete?: never; @@ -20,7 +20,23 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/address/active': { + "/core/{_version}/keys/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-keys-setup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/address/active": { parameters: { query?: never; header?: never; @@ -29,7 +45,7 @@ export interface paths { }; get?: never; /** Update list of active keys per address */ - put: operations['put_core-{_version}-keys-address-active']; + put: operations["put_core-{_version}-keys-address-active"]; post?: never; delete?: never; options?: never; @@ -37,7 +53,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys': { + "/core/{_version}/keys": { parameters: { query?: never; header?: never; @@ -52,19 +68,19 @@ export interface paths { * * Deprecated! Please refer to https://confluence.protontech.ch/pages/viewpage.action?pageId=157816403 to upgrade */ - get: operations['get_core-{_version}-keys']; + get: operations["get_core-{_version}-keys"]; put?: never; /** POST /keys route (Deprecated, AddressKey migration step 1.2) * Only used for address-associated keys, otherwise this would be a backdoor way to change the mailbox password * Does not enforce key list validation. */ - post: operations['post_core-{_version}-keys']; + post: operations["post_core-{_version}-keys"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/address': { + "/core/{_version}/keys/address": { parameters: { query?: never; header?: never; @@ -78,14 +94,14 @@ export interface paths { * @description Locked route, only used for address-associated keys, * otherwise this would be a backdoor way to change the mailbox password. */ - post: operations['post_core-{_version}-keys-address']; + post: operations["post_core-{_version}-keys-address"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/group': { + "/core/{_version}/keys/group": { parameters: { query?: never; header?: never; @@ -95,14 +111,14 @@ export interface paths { get?: never; put?: never; /** Create a group key */ - post: operations['post_core-{_version}-keys-group']; + post: operations["post_core-{_version}-keys-group"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/setup': { + "/core/{_version}/keys/{enc_id}/delete": { parameters: { query?: never; header?: never; @@ -110,19 +126,20 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; /** - * Setup keys for new account, private user. - * @description Initial key setup for new private users. + * Delete address key. + * @deprecated + * @description Locked route */ - post: operations['post_core-{_version}-keys-setup']; + put: operations["put_core-{_version}-keys-{enc_id}-delete"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/{enc_id}/delete': { + "/core/{_version}/keys/address/{enc_id}/delete": { parameters: { query?: never; header?: never; @@ -130,20 +147,19 @@ export interface paths { cookie?: never; }; get?: never; + put?: never; /** * Delete address key. - * @deprecated * @description Locked route */ - put: operations['put_core-{_version}-keys-{enc_id}-delete']; - post?: never; + post: operations["post_core-{_version}-keys-address-{enc_id}-delete"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/address/{enc_id}/delete': { + "/core/{_version}/keys/private": { parameters: { query?: never; header?: never; @@ -151,34 +167,30 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; /** - * Delete address key. - * @description Locked route + * Update user keys for password change. + * @description Update private keys only, use for mailbox password/single password updates. + * + * This route can not be used to re-activate keys that we don't have access to, + * in that case the route "Activate Key" must be used first. */ - post: operations['post_core-{_version}-keys-address-{enc_id}-delete']; + put: operations["put_core-{_version}-keys-private"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/private': { + "/core/{_version}/events/latest": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** - * Update user keys for password change. - * @description Update private keys only, use for mailbox password/single password updates. - * - * This route can not be used to re-activate keys that we don't have access to, - * in that case the route "Activate Key" must be used first. - */ - put: operations['put_core-{_version}-keys-private']; + get: operations["get_core-{_version}-events-latest"]; + put?: never; post?: never; delete?: never; options?: never; @@ -186,7 +198,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/images/logo': { + "/core/{_version}/images/logo": { parameters: { query?: never; header?: never; @@ -194,7 +206,7 @@ export interface paths { cookie?: never; }; /** Get logo corresponding to an address or a domain. */ - get: operations['get_core-{_version}-images-logo']; + get: operations["get_core-{_version}-images-logo"]; put?: never; post?: never; delete?: never; @@ -203,7 +215,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/addresses': { + "/core/{_version}/members/{enc_id}/addresses": { parameters: { query?: never; header?: never; @@ -211,7 +223,7 @@ export interface paths { cookie?: never; }; /** Get addresses of a member. */ - get: operations['get_core-{_version}-members-{enc_id}-addresses']; + get: operations["get_core-{_version}-members-{enc_id}-addresses"]; put?: never; /** * Create new address. @@ -229,21 +241,21 @@ export interface paths { * } * ``` */ - post: operations['post_core-{_version}-members-{enc_id}-addresses']; + post: operations["post_core-{_version}-members-{enc_id}-addresses"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/addresses': { + "/core/{_version}/addresses": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-addresses']; + get: operations["get_core-{_version}-addresses"]; put?: never; /** * Create new address. @@ -261,14 +273,14 @@ export interface paths { * } * ``` */ - post: operations['post_core-{_version}-addresses']; + post: operations["post_core-{_version}-addresses"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/addresses/available': { + "/core/{_version}/members/addresses/available": { parameters: { query?: never; header?: never; @@ -278,14 +290,14 @@ export interface paths { get?: never; put?: never; /** Validates an address before creation (format and availability). */ - post: operations['post_core-{_version}-members-addresses-available']; + post: operations["post_core-{_version}-members-addresses-available"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/addresses/order': { + "/core/{_version}/addresses/order": { parameters: { query?: never; header?: never; @@ -294,7 +306,24 @@ export interface paths { }; get?: never; /** Reorder user's addresses. */ - put: operations['put_core-{_version}-addresses-order']; + put: operations["put_core-{_version}-addresses-order"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_memberId}/addresses/order": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Reorder member's addresses. */ + put: operations["put_core-{_version}-members-{enc_memberId}-addresses-order"]; post?: never; delete?: never; options?: never; @@ -302,7 +331,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/setup': { + "/core/{_version}/addresses/setup": { parameters: { query?: never; header?: never; @@ -312,14 +341,14 @@ export interface paths { get?: never; put?: never; /** Setup new non-subuser address. */ - post: operations['post_core-{_version}-addresses-setup']; + post: operations["post_core-{_version}-addresses-setup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/addresses/canonical': { + "/core/{_version}/addresses/canonical": { parameters: { query?: never; header?: never; @@ -327,7 +356,7 @@ export interface paths { cookie?: never; }; /** Get the canonical form of email addresses. */ - get: operations['get_core-{_version}-addresses-canonical']; + get: operations["get_core-{_version}-addresses-canonical"]; put?: never; post?: never; delete?: never; @@ -336,7 +365,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_id}': { + "/core/{_version}/addresses/{enc_id}": { parameters: { query?: never; header?: never; @@ -344,12 +373,12 @@ export interface paths { cookie?: never; }; /** Get a single address. */ - get: operations['get_core-{_version}-addresses-{enc_id}']; + get: operations["get_core-{_version}-addresses-{enc_id}"]; /** * Update address. * @description Update display name and/or signature. */ - put: operations['put_core-{_version}-addresses-{enc_id}']; + put: operations["put_core-{_version}-addresses-{enc_id}"]; post?: never; /** * Delete a Disabled Address. @@ -358,13 +387,13 @@ export interface paths { * * Warning - Locked route */ - delete: operations['delete_core-{_version}-addresses-{enc_id}']; + delete: operations["delete_core-{_version}-addresses-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains/{enc_id}/addresses': { + "/core/{_version}/domains/{enc_id}/addresses": { parameters: { query?: never; header?: never; @@ -372,7 +401,7 @@ export interface paths { cookie?: never; }; /** Get a specific domain's addresses. */ - get: operations['get_core-{_version}-domains-{enc_id}-addresses']; + get: operations["get_core-{_version}-domains-{enc_id}-addresses"]; put?: never; post?: never; delete?: never; @@ -381,7 +410,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/domains/{enc_id}/claimedAddresses': { + "/core/{_version}/domains/{enc_id}/claimedAddresses": { parameters: { query?: never; header?: never; @@ -390,7 +419,7 @@ export interface paths { }; /** Get external addresses belonging to users outside the organization * with the same domain name as the specified domain. */ - get: operations['get_core-{_version}-domains-{enc_id}-claimedAddresses']; + get: operations["get_core-{_version}-domains-{enc_id}-claimedAddresses"]; put?: never; post?: never; delete?: never; @@ -399,7 +428,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_id}/enable': { + "/core/{_version}/addresses/{enc_id}/enable": { parameters: { query?: never; header?: never; @@ -413,7 +442,7 @@ export interface paths { * * Warning - Locked route */ - put: operations['put_core-{_version}-addresses-{enc_id}-enable']; + put: operations["put_core-{_version}-addresses-{enc_id}-enable"]; post?: never; delete?: never; options?: never; @@ -421,7 +450,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_id}/disable': { + "/core/{_version}/addresses/{enc_id}/disable": { parameters: { query?: never; header?: never; @@ -435,7 +464,7 @@ export interface paths { * * Warning - Locked route */ - put: operations['put_core-{_version}-addresses-{enc_id}-disable']; + put: operations["put_core-{_version}-addresses-{enc_id}-disable"]; post?: never; delete?: never; options?: never; @@ -443,7 +472,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_id}/delete': { + "/core/{_version}/addresses/{enc_id}/delete": { parameters: { query?: never; header?: never; @@ -457,7 +486,7 @@ export interface paths { * * Warning - Locked route */ - put: operations['put_core-{_version}-addresses-{enc_id}-delete']; + put: operations["put_core-{_version}-addresses-{enc_id}-delete"]; post?: never; delete?: never; options?: never; @@ -465,7 +494,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_id}/type': { + "/core/{_version}/addresses/{enc_id}/type": { parameters: { query?: never; header?: never; @@ -477,7 +506,7 @@ export interface paths { * Change address type. * @description As of now it is possible only to convert an external address into a custom address when a domain has been activated. */ - put: operations['put_core-{_version}-addresses-{enc_id}-type']; + put: operations["put_core-{_version}-addresses-{enc_id}-type"]; post?: never; delete?: never; options?: never; @@ -485,7 +514,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_id}/rename/internal': { + "/core/{_version}/addresses/{enc_id}/rename/internal": { parameters: { query?: never; header?: never; @@ -494,7 +523,7 @@ export interface paths { }; get?: never; /** Rename address keeping the keys, keeping the same clean email */ - put: operations['put_core-{_version}-addresses-{enc_id}-rename-internal']; + put: operations["put_core-{_version}-addresses-{enc_id}-rename-internal"]; post?: never; delete?: never; options?: never; @@ -502,7 +531,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_id}/rename/external': { + "/core/{_version}/addresses/{enc_id}/rename/external": { parameters: { query?: never; header?: never; @@ -511,7 +540,7 @@ export interface paths { }; get?: never; /** Rename unverified external addresses freely (any change is allowed) */ - put: operations['put_core-{_version}-addresses-{enc_id}-rename-external']; + put: operations["put_core-{_version}-addresses-{enc_id}-rename-external"]; post?: never; delete?: never; options?: never; @@ -519,7 +548,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_addressId}/encryption': { + "/core/{_version}/addresses/{enc_addressId}/encryption": { parameters: { query?: never; header?: never; @@ -531,7 +560,7 @@ export interface paths { * Set encryption signature flags. * @description Allows setting "E2EE disabled" or "Do not expect signed" flags, address wide. */ - put: operations['put_core-{_version}-addresses-{enc_addressId}-encryption']; + put: operations["put_core-{_version}-addresses-{enc_addressId}-encryption"]; post?: never; delete?: never; options?: never; @@ -539,7 +568,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/addresses/permissions/organization/switch': { + "/core/{_version}/members/addresses/permissions/organization/switch": { parameters: { query?: never; header?: never; @@ -553,7 +582,7 @@ export interface paths { * Having both PERMISSIONS_SEND_ALL and PERMISSIONS_SEND_ORG in the permissions array is forbidden. * Having both PERMISSIONS_RECEIVE_ALL and PERMISSIONS_RECEIVE_ORG in the permissions array is forbidden. */ - put: operations['put_core-{_version}-members-addresses-permissions-organization-switch']; + put: operations["put_core-{_version}-members-addresses-permissions-organization-switch"]; post?: never; delete?: never; options?: never; @@ -561,7 +590,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/saml': { + "/core/{_version}/members/{memberId}/saml": { parameters: { query?: never; header?: never; @@ -570,14 +599,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-members-{memberId}-saml']; - delete: operations['delete_core-{_version}-members-{memberId}-saml']; + post: operations["post_core-{_version}-members-{memberId}-saml"]; + delete: operations["delete_core-{_version}-members-{memberId}-saml"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/devices/{deviceId}': { + "/core/{_version}/members/{memberId}/devices/{deviceId}": { parameters: { query?: never; header?: never; @@ -587,13 +616,13 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations['delete_core-{_version}-members-{memberId}-devices-{deviceId}']; + delete: operations["delete_core-{_version}-members-{memberId}-devices-{deviceId}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/devices': { + "/core/{_version}/members/{memberId}/devices": { parameters: { query?: never; header?: never; @@ -603,20 +632,36 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations['delete_core-{_version}-members-{memberId}-devices']; + delete: operations["delete_core-{_version}-members-{memberId}-devices"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{id}/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-{id}-devices"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{id}/devices': { + "/core/{_version}/members/devices/pending": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-members-{id}-devices']; + get: operations["get_core-{_version}-members-devices-pending"]; put?: never; post?: never; delete?: never; @@ -625,15 +670,31 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/devices/pending': { + "/core/{_version}/auth/refresh": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-members-devices-pending']; + get?: never; put?: never; + post: operations["post_core-{_version}-auth-refresh"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/devices/{deviceId}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-members-{memberId}-devices-{deviceId}-reject"]; post?: never; delete?: never; options?: never; @@ -641,7 +702,23 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/devices/{deviceId}/reject': { + "/core/{_version}/members/{memberId}/devices/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-members-{memberId}-devices-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth": { parameters: { query?: never; header?: never; @@ -649,7 +726,24 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-members-{memberId}-devices-{deviceId}-reject']; + put?: never; + /** Authenticate. */ + post: operations["post_core-{_version}-auth"]; + delete: operations["delete_core-{_version}-auth"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/sso/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-auth-sso-{token}"]; + put?: never; post?: never; delete?: never; options?: never; @@ -657,7 +751,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/devices/reset': { + "/core/{_version}/organization/communities": { parameters: { query?: never; header?: never; @@ -666,14 +760,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-members-{memberId}-devices-reset']; + post: operations["post_core-{_version}-organization-communities"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/keys': { + "/core/{_version}/members/{enc_id}/keys": { parameters: { query?: never; header?: never; @@ -682,30 +776,30 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-members-{enc_id}-keys']; + post: operations["post_core-{_version}-members-{enc_id}-keys"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/scim': { + "/core/{_version}/keys/user": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-organizations-scim']; - put: operations['put_core-{_version}-organizations-scim']; - post: operations['post_core-{_version}-organizations-scim']; + get?: never; + put?: never; + post: operations["post_core-{_version}-keys-user"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/user': { + "/core/{_version}/organization/{organization_id}": { parameters: { query?: never; header?: never; @@ -714,14 +808,47 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-keys-user']; + post?: never; + delete: operations["delete_core-{_version}-organization-{organization_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/highsecurity/summary/email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-settings-highsecurity-summary-email"]; + delete: operations["delete_core-{_version}-settings-highsecurity-summary-email"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}/flags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update domain flags. */ + put: operations["put_core-{_version}-domains-{enc_id}-flags"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains': { + "/core/{_version}/domains": { parameters: { query?: never; header?: never; @@ -732,7 +859,7 @@ export interface paths { * Get Domains. * @description Get all domains for this user's organization and check their DNS's */ - get: operations['get_core-{_version}-domains']; + get: operations["get_core-{_version}-domains"]; put?: never; /** * Create Domain. @@ -748,14 +875,14 @@ export interface paths { * } * ``` */ - post: operations['post_core-{_version}-domains']; + post: operations["post_core-{_version}-domains"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains/available': { + "/core/{_version}/domains/available": { parameters: { query?: never; header?: never; @@ -763,7 +890,7 @@ export interface paths { cookie?: never; }; /** Get available domains. */ - get: operations['get_core-{_version}-domains-available']; + get: operations["get_core-{_version}-domains-available"]; put?: never; post?: never; delete?: never; @@ -772,7 +899,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/domains/premium': { + "/core/{_version}/domains/premium": { parameters: { query?: never; header?: never; @@ -780,7 +907,7 @@ export interface paths { cookie?: never; }; /** Get premium domains. */ - get: operations['get_core-{_version}-domains-premium']; + get: operations["get_core-{_version}-domains-premium"]; put?: never; post?: never; delete?: never; @@ -789,7 +916,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/domains/optin': { + "/core/{_version}/domains/optin": { parameters: { query?: never; header?: never; @@ -797,7 +924,7 @@ export interface paths { cookie?: never; }; /** Get opt-in domain if user is eligible. */ - get: operations['get_core-{_version}-domains-optin']; + get: operations["get_core-{_version}-domains-optin"]; put?: never; post?: never; delete?: never; @@ -806,7 +933,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/domains/{enc_id}': { + "/core/{_version}/domains/{enc_id}": { parameters: { query?: never; header?: never; @@ -817,20 +944,20 @@ export interface paths { * Get Domain. * @description Get a specific domains and its check DNS */ - get: operations['get_core-{_version}-domains-{enc_id}']; + get: operations["get_core-{_version}-domains-{enc_id}"]; put?: never; post?: never; /** * Delete Domain. * @description Delete a Domain, locked route */ - delete: operations['delete_core-{_version}-domains-{enc_id}']; + delete: operations["delete_core-{_version}-domains-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains/{enc_id}/catchall': { + "/core/{_version}/domains/{enc_id}/catchall": { parameters: { query?: never; header?: never; @@ -839,7 +966,7 @@ export interface paths { }; get?: never; /** Set catch-all address, locked route. */ - put: operations['put_core-{_version}-domains-{enc_id}-catchall']; + put: operations["put_core-{_version}-domains-{enc_id}-catchall"]; post?: never; delete?: never; options?: never; @@ -847,15 +974,46 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations': { + "/core/{_version}/organizations/subsidiaries": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get information of current organization */ - get: operations['get_core-{_version}-organizations']; + get: operations["get_core-{_version}-organizations-subsidiaries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/scim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-organizations-scim"]; + put: operations["put_core-{_version}-organizations-scim"]; + post: operations["post_core-{_version}-organizations-scim"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-organizations"]; put?: never; post?: never; delete?: never; @@ -864,7 +1022,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/groups/external/{jwt}': { + "/core/{_version}/groups/external/{jwt}": { parameters: { query?: never; header?: never; @@ -872,15 +1030,47 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-groups-external-{jwt}']; + put: operations["put_core-{_version}-groups-external-{jwt}"]; post?: never; - delete: operations['delete_core-{_version}-groups-external-{jwt}']; + delete: operations["delete_core-{_version}-groups-external-{jwt}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/owners/accept/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-groups-owners-accept-{enc_id}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-groups-members"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/members': { + "/core/{_version}/groups/owners/add/{enc_id}": { parameters: { query?: never; header?: never; @@ -889,30 +1079,30 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-groups-members']; + post: operations["post_core-{_version}-groups-owners-add-{enc_id}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups': { + "/core/{_version}/groups": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-groups']; + get: operations["get_core-{_version}-groups"]; put?: never; - post: operations['post_core-{_version}-groups']; + post: operations["post_core-{_version}-groups"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/unsubscribe/{jwt}': { + "/core/{_version}/groups/unsubscribe/{jwt}": { parameters: { query?: never; header?: never; @@ -921,14 +1111,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-groups-unsubscribe-{jwt}']; + post: operations["post_core-{_version}-groups-unsubscribe-{jwt}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/{enc_id}': { + "/core/{_version}/groups/{enc_id}": { parameters: { query?: never; header?: never; @@ -936,15 +1126,15 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-groups-{enc_id}']; + put: operations["put_core-{_version}-groups-{enc_id}"]; post?: never; - delete: operations['delete_core-{_version}-groups-{enc_id}']; + delete: operations["delete_core-{_version}-groups-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/members/{enc_id}': { + "/core/{_version}/groups/members/{enc_id}": { parameters: { query?: never; header?: never; @@ -954,13 +1144,13 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations['delete_core-{_version}-groups-members-{enc_id}']; + delete: operations["delete_core-{_version}-groups-members-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/members/{groupMemberId}': { + "/core/{_version}/groups/members/{groupMemberId}": { parameters: { query?: never; header?: never; @@ -968,7 +1158,23 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-groups-members-{groupMemberId}']; + put: operations["put_core-{_version}-groups-members-{groupMemberId}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/groups/members/external/{jwt}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v4-groups-members-external-{jwt}"]; + put?: never; post?: never; delete?: never; options?: never; @@ -976,14 +1182,46 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/groups/members/external/{jwt}': { + "/core/v4/groups/{group_enc_id}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v4-groups-{group_enc_id}-members"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/owners/invites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-groups-owners-invites"]; + put?: never; + post: operations["post_core-{_version}-groups-owners-invites"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/groups/members/internal": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-v4-groups-members-external-{jwt}']; + get: operations["get_core-v4-groups-members-internal"]; put?: never; post?: never; delete?: never; @@ -992,14 +1230,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/groups/{group_enc_id}/members': { + "/core/{_version}/groups/{group_enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-v4-groups-{group_enc_id}-members']; + get: operations["get_core-{_version}-groups-{group_enc_id}"]; put?: never; post?: never; delete?: never; @@ -1008,14 +1246,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/groups/members/internal': { + "/core/v4/groups/members/{group_member_enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-v4-groups-members-internal']; + get: operations["get_core-v4-groups-members-{group_member_enc_id}"]; put?: never; post?: never; delete?: never; @@ -1024,7 +1262,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/groups/{enc_id}/reinvite': { + "/core/{_version}/groups/{enc_id}/reinvite": { parameters: { query?: never; header?: never; @@ -1032,7 +1270,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-groups-{enc_id}-reinvite']; + put: operations["put_core-{_version}-groups-{enc_id}-reinvite"]; post?: never; delete?: never; options?: never; @@ -1040,7 +1278,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/groups/members/{groupMemberId}/resume': { + "/core/{_version}/groups/members/{groupMemberId}/resume": { parameters: { query?: never; header?: never; @@ -1048,7 +1286,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-groups-members-{groupMemberId}-resume']; + put: operations["put_core-{_version}-groups-members-{groupMemberId}-resume"]; post?: never; delete?: never; options?: never; @@ -1056,7 +1294,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/invites': { + "/core/{_version}/invites": { parameters: { query?: never; header?: never; @@ -1065,14 +1303,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-invites']; + post: operations["post_core-{_version}-invites"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/invites/unused': { + "/core/{_version}/invites/unused": { parameters: { query?: never; header?: never; @@ -1081,14 +1319,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-invites-unused']; + post: operations["post_core-{_version}-invites-unused"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/invites/check': { + "/core/{_version}/invites/check": { parameters: { query?: never; header?: never; @@ -1097,14 +1335,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-invites-check']; + post: operations["post_core-{_version}-invites-check"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/all': { + "/core/{_version}/keys/all": { parameters: { query?: never; header?: never; @@ -1117,7 +1355,7 @@ export interface paths { * * This route replaces GET /keys. Please refer to https://confluence.protontech.ch/pages/viewpage.action?pageId=157816403 to upgrade */ - get: operations['get_core-{_version}-keys-all']; + get: operations["get_core-{_version}-keys-all"]; put?: never; post?: never; delete?: never; @@ -1126,7 +1364,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/signedkeylists': { + "/core/{_version}/keys/signedkeylists": { parameters: { query?: never; header?: never; @@ -1134,17 +1372,17 @@ export interface paths { cookie?: never; }; /** Get multiple signed key lists for different epochs */ - get: operations['get_core-{_version}-keys-signedkeylists']; + get: operations["get_core-{_version}-keys-signedkeylists"]; put?: never; /** Update signed key list. */ - post: operations['post_core-{_version}-keys-signedkeylists']; + post: operations["post_core-{_version}-keys-signedkeylists"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/signedkeylist': { + "/core/{_version}/keys/signedkeylist": { parameters: { query?: never; header?: never; @@ -1152,7 +1390,7 @@ export interface paths { cookie?: never; }; /** Get a single signed key lists for a specific epoch */ - get: operations['get_core-{_version}-keys-signedkeylist']; + get: operations["get_core-{_version}-keys-signedkeylist"]; put?: never; post?: never; delete?: never; @@ -1161,7 +1399,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/salts': { + "/core/{_version}/keys/salts": { parameters: { query?: never; header?: never; @@ -1172,7 +1410,7 @@ export interface paths { * Get key salts. * @description Locked route */ - get: operations['get_core-{_version}-keys-salts']; + get: operations["get_core-{_version}-keys-salts"]; put?: never; post?: never; delete?: never; @@ -1181,7 +1419,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/address/{enc_id}': { + "/core/{_version}/keys/address/{enc_id}": { parameters: { query?: never; header?: never; @@ -1190,7 +1428,7 @@ export interface paths { }; get?: never; /** (Migrated keys) Reactivate just an address key */ - put: operations['put_core-{_version}-keys-address-{enc_id}']; + put: operations["put_core-{_version}-keys-address-{enc_id}"]; post?: never; delete?: never; options?: never; @@ -1198,7 +1436,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/address/{enc_id}/subkeys': { + "/core/{_version}/keys/address/{enc_id}/subkeys": { parameters: { query?: never; header?: never; @@ -1207,7 +1445,7 @@ export interface paths { }; get?: never; /** Add subkeys to an existing keypair. */ - put: operations['put_core-{_version}-keys-address-{enc_id}-subkeys']; + put: operations["put_core-{_version}-keys-address-{enc_id}-subkeys"]; post?: never; delete?: never; options?: never; @@ -1215,7 +1453,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/signedkeylists/signature': { + "/core/{_version}/keys/signedkeylists/signature": { parameters: { query?: never; header?: never; @@ -1224,28 +1462,7 @@ export interface paths { }; get?: never; /** Update signed key list signature for a specific revision. */ - put: operations['put_core-{_version}-keys-signedkeylists-signature']; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/keys/{enc_id}/primary': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - /** - * Make address key primary. - * @description Locked route, only used for address-associated keys, - * otherwise this could be a backdoor way to revert to an earlier mailbox password. - */ - put: operations['put_core-{_version}-keys-{enc_id}-primary']; + put: operations["put_core-{_version}-keys-signedkeylists-signature"]; post?: never; delete?: never; options?: never; @@ -1253,7 +1470,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/{enc_id}/flags': { + "/core/{_version}/keys/{enc_id}/flags": { parameters: { query?: never; header?: never; @@ -1265,7 +1482,7 @@ export interface paths { * Update key flags. * @description Locked route */ - put: operations['put_core-{_version}-keys-{enc_id}-flags']; + put: operations["put_core-{_version}-keys-{enc_id}-flags"]; post?: never; delete?: never; options?: never; @@ -1273,7 +1490,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/tokens': { + "/core/{_version}/keys/tokens": { parameters: { query?: never; header?: never; @@ -1281,7 +1498,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-keys-tokens']; + put: operations["put_core-{_version}-keys-tokens"]; post?: never; delete?: never; options?: never; @@ -1289,7 +1506,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/user/{enc_id}': { + "/core/{_version}/keys/user/{enc_id}": { parameters: { query?: never; header?: never; @@ -1300,17 +1517,17 @@ export interface paths { /** * Reactivate inactive user key. * @description Reactivate inactive user key by sending a key copy encrypted with current mailbox password and the list - * of address key fingerprints to reactivate. Locked route. + * of address key fingerprints to reactivate. */ - put: operations['put_core-{_version}-keys-user-{enc_id}']; + put: operations["put_core-{_version}-keys-user-{enc_id}"]; post?: never; - delete: operations['delete_core-{_version}-keys-user-{enc_id}']; + delete: operations["delete_core-{_version}-keys-user-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/private/upgrade': { + "/core/{_version}/keys/private/upgrade": { parameters: { query?: never; header?: never; @@ -1326,14 +1543,14 @@ export interface paths { * This route can not be used to re-activate keys that we don't have access to, * in that case the route "Activate Key" must be used first. */ - post: operations['post_core-{_version}-keys-private-upgrade']; + post: operations["post_core-{_version}-keys-private-upgrade"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/migrate': { + "/core/{_version}/keys/migrate": { parameters: { query?: never; header?: never; @@ -1345,14 +1562,14 @@ export interface paths { /** Upgrade keys for key migration step 2 * This route can not be used to re-activate keys that we don't have access to, * in that case the route "Activate Key" must be used before or after. */ - post: operations['post_core-{_version}-keys-migrate']; + post: operations["post_core-{_version}-keys-migrate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/{enc_id}/activate': { + "/core/{_version}/keys/{enc_id}/activate": { parameters: { query?: never; header?: never; @@ -1362,7 +1579,7 @@ export interface paths { get?: never; /** (Legacy keys) Activate newly-provisioned member address key by sending a key copy encrypted with * current mailbox password. */ - put: operations['put_core-{_version}-keys-{enc_id}-activate']; + put: operations["put_core-{_version}-keys-{enc_id}-activate"]; post?: never; delete?: never; options?: never; @@ -1370,7 +1587,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/{enc_id}': { + "/core/{_version}/keys/{enc_id}": { parameters: { query?: never; header?: never; @@ -1379,7 +1596,7 @@ export interface paths { }; get?: never; /** (Legacy keys) Activate just an address key, when access to the user key is lost */ - put: operations['put_core-{_version}-keys-{enc_id}']; + put: operations["put_core-{_version}-keys-{enc_id}"]; post?: never; delete?: never; options?: never; @@ -1387,7 +1604,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/reset': { + "/core/{_version}/keys/reset": { parameters: { query?: never; header?: never; @@ -1397,25 +1614,41 @@ export interface paths { get?: never; put?: never; /** Install a new key for each address. */ - post: operations['post_core-{_version}-keys-reset']; + post: operations["post_core-{_version}-keys-reset"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members': { + "/core/{_version}/organizations/link/{organization_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get Members. + get?: never; + put: operations["put_core-{_version}-organizations-link-{organization_id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Members. * @description Get all members of user's organization */ - get: operations['get_core-{_version}-members']; + get: operations["get_core-{_version}-members"]; put?: never; /** * Create a new member. @@ -1423,14 +1656,14 @@ export interface paths { * * phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded */ - post: operations['post_core-{_version}-members']; + post: operations["post_core-{_version}-members"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/invitations': { + "/core/{_version}/members/invitations": { parameters: { query?: never; header?: never; @@ -1439,14 +1672,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-members-invitations']; + post: operations["post_core-{_version}-members-invitations"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/invitations/{enc_id}': { + "/core/{_version}/members/invitations/{enc_id}": { parameters: { query?: never; header?: never; @@ -1458,7 +1691,7 @@ export interface paths { * Edit a pending invitation. * @description Locked route */ - put: operations['put_core-{_version}-members-invitations-{enc_id}']; + put: operations["put_core-{_version}-members-invitations-{enc_id}"]; post?: never; delete?: never; options?: never; @@ -1466,7 +1699,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/disable': { + "/core/{_version}/members/{enc_id}/disable": { parameters: { query?: never; header?: never; @@ -1478,7 +1711,7 @@ export interface paths { * Disable a member. * @description Locked route */ - put: operations['put_core-{_version}-members-{enc_id}-disable']; + put: operations["put_core-{_version}-members-{enc_id}-disable"]; post?: never; delete?: never; options?: never; @@ -1486,7 +1719,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/enable': { + "/core/{_version}/members/{enc_id}/enable": { parameters: { query?: never; header?: never; @@ -1498,7 +1731,7 @@ export interface paths { * Enable a member. * @description Locked route */ - put: operations['put_core-{_version}-members-{enc_id}-enable']; + put: operations["put_core-{_version}-members-{enc_id}-enable"]; post?: never; delete?: never; options?: never; @@ -1506,7 +1739,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/quota': { + "/core/{_version}/members/{enc_id}/quota": { parameters: { query?: never; header?: never; @@ -1518,7 +1751,7 @@ export interface paths { * Update disk space quota in bytes. * @description Locked route */ - put: operations['put_core-{_version}-members-{enc_id}-quota']; + put: operations["put_core-{_version}-members-{enc_id}-quota"]; post?: never; delete?: never; options?: never; @@ -1526,7 +1759,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/name': { + "/core/{_version}/members/{enc_id}/name": { parameters: { query?: never; header?: never; @@ -1538,7 +1771,7 @@ export interface paths { * Update member name. * @description Locked route */ - put: operations['put_core-{_version}-members-{enc_id}-name']; + put: operations["put_core-{_version}-members-{enc_id}-name"]; post?: never; delete?: never; options?: never; @@ -1546,7 +1779,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/role': { + "/core/{_version}/members/{enc_id}/role": { parameters: { query?: never; header?: never; @@ -1555,7 +1788,7 @@ export interface paths { }; get?: never; /** Update member role. */ - put: operations['put_core-{_version}-members-{enc_id}-role']; + put: operations["put_core-{_version}-members-{enc_id}-role"]; post?: never; delete?: never; options?: never; @@ -1563,7 +1796,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/ai': { + "/core/{_version}/members/{memberId}/ai": { parameters: { query?: never; header?: never; @@ -1572,7 +1805,7 @@ export interface paths { }; get?: never; /** Update AI entitlement for member. */ - put: operations['put_core-{_version}-members-{memberId}-ai']; + put: operations["put_core-{_version}-members-{memberId}-ai"]; post?: never; delete?: never; options?: never; @@ -1580,7 +1813,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/privatize': { + "/core/{_version}/members/{enc_id}/privatize": { parameters: { query?: never; header?: never; @@ -1592,7 +1825,28 @@ export interface paths { * Make account private. * @description Locked route */ - put: operations['put_core-{_version}-members-{enc_id}-privatize']; + put: operations["put_core-{_version}-members-{enc_id}-privatize"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search all members of user's organization + * We only return the top `limit` members. + * @description There is no pagination support - this endpoint returns a single page of results. + */ + get: operations["get_core-{_version}-members-search"]; + put?: never; post?: never; delete?: never; options?: never; @@ -1600,7 +1854,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/me': { + "/core/{_version}/members/me": { parameters: { query?: never; header?: never; @@ -1608,7 +1862,7 @@ export interface paths { cookie?: never; }; /** Get user's member. */ - get: operations['get_core-{_version}-members-me']; + get: operations["get_core-{_version}-members-me"]; put?: never; post?: never; delete?: never; @@ -1617,7 +1871,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/me/unprivatize': { + "/core/{_version}/members/me/unprivatize": { parameters: { query?: never; header?: never; @@ -1625,18 +1879,18 @@ export interface paths { cookie?: never; }; /** Get unprivatization info for self */ - get: operations['get_core-{_version}-members-me-unprivatize']; + get: operations["get_core-{_version}-members-me-unprivatize"]; put?: never; /** Accept member unprivatization */ - post: operations['post_core-{_version}-members-me-unprivatize']; + post: operations["post_core-{_version}-members-me-unprivatize"]; /** Refuse unprivatization for self */ - delete: operations['delete_core-{_version}-members-me-unprivatize']; + delete: operations["delete_core-{_version}-members-me-unprivatize"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{id}/unprivatize/resend': { + "/core/{_version}/members/{id}/unprivatize/resend": { parameters: { query?: never; header?: never; @@ -1646,14 +1900,14 @@ export interface paths { get?: never; put?: never; /** Resend magic link email */ - post: operations['post_core-{_version}-members-{id}-unprivatize-resend']; + post: operations["post_core-{_version}-members-{id}-unprivatize-resend"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{id}/unprivatize': { + "/core/{_version}/members/{id}/unprivatize": { parameters: { query?: never; header?: never; @@ -1663,15 +1917,15 @@ export interface paths { get?: never; put?: never; /** Request unprivatization to existing member. */ - post: operations['post_core-{_version}-members-{id}-unprivatize']; + post: operations["post_core-{_version}-members-{id}-unprivatize"]; /** Cancel unprivatization for member */ - delete: operations['delete_core-{_version}-members-{id}-unprivatize']; + delete: operations["delete_core-{_version}-members-{id}-unprivatize"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}': { + "/core/{_version}/members/{enc_id}": { parameters: { query?: never; header?: never; @@ -1679,27 +1933,27 @@ export interface paths { cookie?: never; }; /** Get a specific member. */ - get: operations['get_core-{_version}-members-{enc_id}']; + get: operations["get_core-{_version}-members-{enc_id}"]; put?: never; post?: never; /** * Delete a member. * @description Remove member, deletes user if not PM user, locked route. */ - delete: operations['delete_core-{_version}-members-{enc_id}']; + delete: operations["delete_core-{_version}-members-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/details': { + "/core/{_version}/members/{enc_id}/details": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-members-{enc_id}-details']; + get: operations["get_core-{_version}-members-{enc_id}-details"]; put?: never; post?: never; delete?: never; @@ -1708,14 +1962,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/authlog': { + "/core/{_version}/members/{enc_id}/authlog": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-members-{enc_id}-authlog']; + get: operations["get_core-{_version}-members-{enc_id}-authlog"]; put?: never; post?: never; delete?: never; @@ -1724,7 +1978,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/require2fa': { + "/core/{_version}/members/{enc_id}/require2fa": { parameters: { query?: never; header?: never; @@ -1733,16 +1987,16 @@ export interface paths { }; get?: never; /** Enforce two-factor for a member based on the current organization two-factor grace period setting, locked route */ - put: operations['put_core-{_version}-members-{enc_id}-require2fa']; + put: operations["put_core-{_version}-members-{enc_id}-require2fa"]; post?: never; /** Do not enforce two-factor for a member, locked route */ - delete: operations['delete_core-{_version}-members-{enc_id}-require2fa']; + delete: operations["delete_core-{_version}-members-{enc_id}-require2fa"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/permissions/forwarding': { + "/core/{_version}/members/{enc_id}/permissions/forwarding": { parameters: { query?: never; header?: never; @@ -1752,15 +2006,15 @@ export interface paths { get?: never; put?: never; /** Allow member to use Email Forwarding */ - post: operations['post_core-{_version}-members-{enc_id}-permissions-forwarding']; + post: operations["post_core-{_version}-members-{enc_id}-permissions-forwarding"]; /** Forbid member to use Email Forwarding */ - delete: operations['delete_core-{_version}-members-{enc_id}-permissions-forwarding']; + delete: operations["delete_core-{_version}-members-{enc_id}-permissions-forwarding"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/permissions': { + "/core/{_version}/members/permissions": { parameters: { query?: never; header?: never; @@ -1769,7 +2023,7 @@ export interface paths { }; get?: never; /** Add or remove Permissions field for a list of MemberIDs */ - put: operations['put_core-{_version}-members-permissions']; + put: operations["put_core-{_version}-members-permissions"]; post?: never; delete?: never; options?: never; @@ -1777,7 +2031,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/keys/setup': { + "/core/{_version}/members/{enc_id}/keys/setup": { parameters: { query?: never; header?: never; @@ -1790,14 +2044,14 @@ export interface paths { * Setup Member Keys. * @description Setup new member keys, locked route. */ - post: operations['post_core-{_version}-members-{enc_id}-keys-setup']; + post: operations["post_core-{_version}-members-{enc_id}-keys-setup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/keys/migrate': { + "/core/{_version}/members/{enc_id}/keys/migrate": { parameters: { query?: never; header?: never; @@ -1811,14 +2065,14 @@ export interface paths { * @description This route can not be used to re-activate keys that we don't have access to, * in that case the route "Activate Key" must be used before or after. */ - post: operations['post_core-{_version}-members-{enc_id}-keys-migrate']; + post: operations["post_core-{_version}-members-{enc_id}-keys-migrate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/keys/signedkeylists': { + "/core/{_version}/members/{enc_id}/keys/signedkeylists": { parameters: { query?: never; header?: never; @@ -1828,14 +2082,14 @@ export interface paths { get?: never; put?: never; /** Update signed key lists for a subuser. */ - post: operations['post_core-{_version}-members-{enc_id}-keys-signedkeylists']; + post: operations["post_core-{_version}-members-{enc_id}-keys-signedkeylists"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/keys/unprivatize': { + "/core/{_version}/members/{enc_id}/keys/unprivatize": { parameters: { query?: never; header?: never; @@ -1848,14 +2102,14 @@ export interface paths { * Unprivatize member * @description Can be called from the background provided validation of InvitationData succeeds */ - post: operations['post_core-{_version}-members-{enc_id}-keys-unprivatize']; + post: operations["post_core-{_version}-members-{enc_id}-keys-unprivatize"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/auth': { + "/core/{_version}/members/{enc_id}/auth": { parameters: { query?: never; header?: never; @@ -1868,42 +2122,100 @@ export interface paths { * Create Session. * @description Login as non-private member, password route */ - post: operations['post_core-{_version}-members-{enc_id}-auth']; + post: operations["post_core-{_version}-members-{enc_id}-auth"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/sessions': { + "/core/{_version}/members/{enc_id}/sessions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get sessions route. - * @description Get active sessions. - */ - get: operations['get_core-{_version}-members-{enc_id}-sessions']; + get?: never; put?: never; - /** - * Create Session. - * @description Login as non-private member, password route - */ - post: operations['post_core-{_version}-members-{enc_id}-sessions']; + post?: never; /** * Revoke all sessions route. * @description Revoke all access tokens, locked. */ - delete: operations['delete_core-{_version}-members-{enc_id}-sessions']; + delete: operations["delete_core-{_version}-members-{enc_id}-sessions"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/settings/logo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-organizations-settings-logo"]; + delete: operations["delete_core-{_version}-organizations-settings-logo"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/settings/highsecurity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-organizations-settings-highsecurity"]; + delete: operations["delete_core-{_version}-organizations-settings-highsecurity"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get information of current organization */ + get: operations["get_core-{_version}-organizations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/logo/{logo_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Having {enc_id} in the route allows us to cache the logo without invalidating the cache when a new logo is uploaded */ + get: operations["get_core-{_version}-organizations-logo-{logo_id}"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/sessions/{uid}': { + "/core/{_version}/organizations/2fa/remind": { parameters: { query?: never; header?: never; @@ -1912,15 +2224,30 @@ export interface paths { }; get?: never; put?: never; + post: operations["post_core-{_version}-organizations-2fa-remind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/settings/logauth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-organizations-settings-logauth"]; post?: never; - /** Revoke a session by UID, locked. */ - delete: operations['delete_core-{_version}-members-{enc_id}-sessions-{uid}']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/keys': { + "/core/{_version}/organizations/keys": { parameters: { query?: never; header?: never; @@ -1931,24 +2258,24 @@ export interface paths { * Get organization keys. * @description Get PGP keys of the current organization */ - get: operations['get_core-{_version}-organizations-keys']; + get: operations["get_core-{_version}-organizations-keys"]; /** * Create or replace organization keys. * @description Replace current organization keys and member keys */ - put: operations['put_core-{_version}-organizations-keys']; + put: operations["put_core-{_version}-organizations-keys"]; /** * Create or replace organization keys. * @description Replace current organization keys and member keys */ - post: operations['post_core-{_version}-organizations-keys']; + post: operations["post_core-{_version}-organizations-keys"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/keys/backup': { + "/core/{_version}/organizations/keys/backup": { parameters: { query?: never; header?: never; @@ -1959,24 +2286,24 @@ export interface paths { * Get backup key. * @description Get current organization backup private key, locked route. */ - get: operations['get_core-{_version}-organizations-keys-backup']; + get: operations["get_core-{_version}-organizations-keys-backup"]; /** * Update backup key. * @description Update current organization backup private key, locked route. */ - put: operations['put_core-{_version}-organizations-keys-backup']; + put: operations["put_core-{_version}-organizations-keys-backup"]; /** * Update backup key. * @description Update current organization backup private key, locked route. */ - post: operations['post_core-{_version}-organizations-keys-backup']; + post: operations["post_core-{_version}-organizations-keys-backup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/name': { + "/core/{_version}/organizations/name": { parameters: { query?: never; header?: never; @@ -1988,7 +2315,7 @@ export interface paths { * Update organization name. * @description Update current organization name, locked route */ - put: operations['put_core-{_version}-organizations-name']; + put: operations["put_core-{_version}-organizations-name"]; post?: never; delete?: never; options?: never; @@ -1996,7 +2323,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/email': { + "/core/{_version}/organizations/email": { parameters: { query?: never; header?: never; @@ -2008,7 +2335,7 @@ export interface paths { * Update organization email. * @description Update current organization email, locked route. */ - put: operations['put_core-{_version}-organizations-email']; + put: operations["put_core-{_version}-organizations-email"]; post?: never; delete?: never; options?: never; @@ -2016,7 +2343,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/2fa': { + "/core/{_version}/organizations/2fa": { parameters: { query?: never; header?: never; @@ -2025,7 +2352,7 @@ export interface paths { }; get?: never; /** Update current organization two-factor grace period setting, locked route */ - put: operations['put_core-{_version}-organizations-2fa']; + put: operations["put_core-{_version}-organizations-2fa"]; post?: never; delete?: never; options?: never; @@ -2033,7 +2360,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/require2fa': { + "/core/{_version}/organizations/require2fa": { parameters: { query?: never; header?: never; @@ -2042,16 +2369,16 @@ export interface paths { }; get?: never; /** Enforce current organization two-factor authentication for a specific group of members, locked route */ - put: operations['put_core-{_version}-organizations-require2fa']; + put: operations["put_core-{_version}-organizations-require2fa"]; post?: never; /** Remove current organization two-factor authentication enforcement, locked route */ - delete: operations['delete_core-{_version}-organizations-require2fa']; + delete: operations["delete_core-{_version}-organizations-require2fa"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/keys/activate': { + "/core/{_version}/organizations/keys/activate": { parameters: { query?: never; header?: never; @@ -2063,7 +2390,7 @@ export interface paths { * Activate organization private key. * @description Update inactive private key with new copy encrypted with current mailbox password, locked route. */ - put: operations['put_core-{_version}-organizations-keys-activate']; + put: operations["put_core-{_version}-organizations-keys-activate"]; post?: never; delete?: never; options?: never; @@ -2071,7 +2398,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/membership': { + "/core/{_version}/organizations/membership": { parameters: { query?: never; header?: never; @@ -2085,30 +2412,13 @@ export interface paths { * Leave organization. * @description Lets a member delete themselves from an organization. */ - delete: operations['delete_core-{_version}-organizations-membership']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/organizations/2fa/remind': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Send a 2FA reminder email to all members without 2FA set. */ - post: operations['post_core-{_version}-organizations-2fa-remind']; - delete?: never; + delete: operations["delete_core-{_version}-organizations-membership"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/keys/migrate': { + "/core/{_version}/organizations/keys/migrate": { parameters: { query?: never; header?: never; @@ -2118,39 +2428,22 @@ export interface paths { get?: never; put?: never; /** Migrate organization key. */ - post: operations['post_core-{_version}-organizations-keys-migrate']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/organizations/keys/signature': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations['get_core-{_version}-organizations-keys-signature']; - put: operations['put_core-{_version}-organizations-keys-signature']; - post?: never; + post: operations["post_core-{_version}-organizations-keys-migrate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/logo/{logo_id}': { + "/core/{_version}/organizations/keys/signature": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Having {enc_id} in the route allows us to cache the logo without invalidating the cache when a new logo is uploaded */ - get: operations['get_core-{_version}-organizations-logo-{logo_id}']; - put?: never; + get: operations["get_core-{_version}-organizations-keys-signature"]; + put: operations["put_core-{_version}-organizations-keys-signature"]; post?: never; delete?: never; options?: never; @@ -2158,15 +2451,17 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/settings': { + "/core/{_version}/organizations/settings": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-organizations-settings']; - put: operations['put_core-{_version}-organizations-settings']; + /** Get Organization Settings. */ + get: operations["get_core-{_version}-organizations-settings"]; + /** Update Organization Settings. */ + put: operations["put_core-{_version}-organizations-settings"]; post?: never; delete?: never; options?: never; @@ -2174,23 +2469,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/settings/logo': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations['post_core-{_version}-organizations-settings-logo']; - delete: operations['delete_core-{_version}-organizations-settings-logo']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/captcha': { + "/core/{_version}/captcha": { parameters: { query?: never; header?: never; @@ -2201,7 +2480,7 @@ export interface paths { * Captcha page. * @deprecated */ - get: operations['get_core-{_version}-captcha']; + get: operations["get_core-{_version}-captcha"]; put?: never; post?: never; delete?: never; @@ -2210,7 +2489,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/resources/captcha': { + "/core/{_version}/resources/captcha": { parameters: { query?: never; header?: never; @@ -2218,7 +2497,7 @@ export interface paths { cookie?: never; }; /** Captcha page. */ - get: operations['get_core-{_version}-resources-captcha']; + get: operations["get_core-{_version}-resources-captcha"]; put?: never; post?: never; delete?: never; @@ -2227,7 +2506,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/resources/zendesk': { + "/core/{_version}/resources/zendesk": { parameters: { query?: never; header?: never; @@ -2235,7 +2514,7 @@ export interface paths { cookie?: never; }; /** Zendesk chat. */ - get: operations['get_core-{_version}-resources-zendesk']; + get: operations["get_core-{_version}-resources-zendesk"]; put?: never; post?: never; delete?: never; @@ -2244,7 +2523,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/setup/fields': { + "/core/{_version}/saml/setup/fields": { parameters: { query?: never; header?: never; @@ -2253,14 +2532,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-saml-setup-fields']; + post: operations["post_core-{_version}-saml-setup-fields"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/saml/setup/xml': { + "/core/{_version}/saml/setup/xml": { parameters: { query?: never; header?: never; @@ -2269,14 +2548,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-saml-setup-xml']; + post: operations["post_core-{_version}-saml-setup-xml"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/saml/setup/url': { + "/core/{_version}/saml/setup/url": { parameters: { query?: never; header?: never; @@ -2285,21 +2564,21 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-saml-setup-url']; + post: operations["post_core-{_version}-saml-setup-url"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/saml/configs': { + "/core/{_version}/saml/configs": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-saml-configs']; + get: operations["get_core-{_version}-saml-configs"]; put?: never; post?: never; delete?: never; @@ -2308,14 +2587,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/configs/{enc_id}': { + "/core/{_version}/saml/configs/{enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-saml-configs-{enc_id}']; + get: operations["get_core-{_version}-saml-configs-{enc_id}"]; put?: never; post?: never; delete?: never; @@ -2324,7 +2603,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/configs/{enc_id}/fields': { + "/core/{_version}/saml/configs/{enc_id}/fields": { parameters: { query?: never; header?: never; @@ -2332,7 +2611,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-saml-configs-{enc_id}-fields']; + put: operations["put_core-{_version}-saml-configs-{enc_id}-fields"]; post?: never; delete?: never; options?: never; @@ -2340,7 +2619,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/configs/{enc_id}/delete': { + "/core/{_version}/saml/configs/{enc_id}/delete": { parameters: { query?: never; header?: never; @@ -2348,7 +2627,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-saml-configs-{enc_id}-delete']; + put: operations["put_core-{_version}-saml-configs-{enc_id}-delete"]; post?: never; delete?: never; options?: never; @@ -2356,14 +2635,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/sp/info': { + "/core/{_version}/saml/sp/info": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-saml-sp-info']; + get: operations["get_core-{_version}-saml-sp-info"]; put?: never; post?: never; delete?: never; @@ -2372,14 +2651,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/edugain/info': { + "/core/{_version}/saml/edugain/info": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-saml-edugain-info']; + get: operations["get_core-{_version}-saml-edugain-info"]; put?: never; post?: never; delete?: never; @@ -2388,14 +2667,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/edugain/info/{domainName}': { + "/core/{_version}/saml/edugain/info/{domainName}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-saml-edugain-info-{domainName}']; + get: operations["get_core-{_version}-saml-edugain-info-{domainName}"]; put?: never; post?: never; delete?: never; @@ -2404,7 +2683,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/metadata': { + "/core/{_version}/saml/metadata": { parameters: { query?: never; header?: never; @@ -2412,7 +2691,7 @@ export interface paths { cookie?: never; }; /** Get the XML representation of the Service Provider metadata. */ - get: operations['get_core-{_version}-saml-metadata']; + get: operations["get_core-{_version}-saml-metadata"]; put?: never; post?: never; delete?: never; @@ -2421,7 +2700,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings': { + "/core/{_version}/settings": { parameters: { query?: never; header?: never; @@ -2429,7 +2708,7 @@ export interface paths { cookie?: never; }; /** Get general settings. */ - get: operations['get_core-{_version}-settings']; + get: operations["get_core-{_version}-settings"]; put?: never; post?: never; delete?: never; @@ -2438,7 +2717,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/password': { + "/core/{_version}/settings/password": { parameters: { query?: never; header?: never; @@ -2447,7 +2726,7 @@ export interface paths { }; get?: never; /** Update login password. Only called in 2-password mode (or onboarding to 2-password mode). */ - put: operations['put_core-{_version}-settings-password']; + put: operations["put_core-{_version}-settings-password"]; post?: never; delete?: never; options?: never; @@ -2455,7 +2734,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/password/upgrade': { + "/core/{_version}/settings/password/upgrade": { parameters: { query?: never; header?: never; @@ -2467,7 +2746,7 @@ export interface paths { * Upgrade Password. * @description Upgrade login password on login if version < 4. */ - put: operations['put_core-{_version}-settings-password-upgrade']; + put: operations["put_core-{_version}-settings-password-upgrade"]; post?: never; delete?: never; options?: never; @@ -2475,7 +2754,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/email': { + "/core/{_version}/settings/email": { parameters: { query?: never; header?: never; @@ -2483,7 +2762,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-email']; + put: operations["put_core-{_version}-settings-email"]; post?: never; delete?: never; options?: never; @@ -2491,7 +2770,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/email/verify': { + "/core/{_version}/settings/email/verify": { parameters: { query?: never; header?: never; @@ -2501,14 +2780,14 @@ export interface paths { get?: never; put?: never; /** Verify associated email address. */ - post: operations['post_core-{_version}-settings-email-verify']; + post: operations["post_core-{_version}-settings-email-verify"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/email/notify': { + "/core/{_version}/settings/email/notify": { parameters: { query?: never; header?: never; @@ -2517,7 +2796,7 @@ export interface paths { }; get?: never; /** Toggle email notifications. */ - put: operations['put_core-{_version}-settings-email-notify']; + put: operations["put_core-{_version}-settings-email-notify"]; post?: never; delete?: never; options?: never; @@ -2525,7 +2804,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/email/reset': { + "/core/{_version}/settings/email/reset": { parameters: { query?: never; header?: never; @@ -2534,7 +2813,7 @@ export interface paths { }; get?: never; /** Enable or disable login password reset by email. */ - put: operations['put_core-{_version}-settings-email-reset']; + put: operations["put_core-{_version}-settings-email-reset"]; post?: never; delete?: never; options?: never; @@ -2542,7 +2821,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/phone': { + "/core/{_version}/settings/phone": { parameters: { query?: never; header?: never; @@ -2550,7 +2829,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-phone']; + put: operations["put_core-{_version}-settings-phone"]; post?: never; delete?: never; options?: never; @@ -2558,7 +2837,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/phone/verify': { + "/core/{_version}/settings/phone/verify": { parameters: { query?: never; header?: never; @@ -2568,14 +2847,14 @@ export interface paths { get?: never; put?: never; /** Verify associated phone number. */ - post: operations['post_core-{_version}-settings-phone-verify']; + post: operations["post_core-{_version}-settings-phone-verify"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/phone/notify': { + "/core/{_version}/settings/phone/notify": { parameters: { query?: never; header?: never; @@ -2584,7 +2863,7 @@ export interface paths { }; get?: never; /** Toggle phone notifications. */ - put: operations['put_core-{_version}-settings-phone-notify']; + put: operations["put_core-{_version}-settings-phone-notify"]; post?: never; delete?: never; options?: never; @@ -2592,7 +2871,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/phone/reset': { + "/core/{_version}/settings/phone/reset": { parameters: { query?: never; header?: never; @@ -2601,7 +2880,7 @@ export interface paths { }; get?: never; /** Enable or disable login password reset by phone. */ - put: operations['put_core-{_version}-settings-phone-reset']; + put: operations["put_core-{_version}-settings-phone-reset"]; post?: never; delete?: never; options?: never; @@ -2609,7 +2888,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/locale': { + "/core/{_version}/settings/locale": { parameters: { query?: never; header?: never; @@ -2617,7 +2896,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-locale']; + put: operations["put_core-{_version}-settings-locale"]; post?: never; delete?: never; options?: never; @@ -2625,7 +2904,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/logauth': { + "/core/{_version}/settings/logauth": { parameters: { query?: never; header?: never; @@ -2634,7 +2913,7 @@ export interface paths { }; get?: never; /** Update authentication logging. */ - put: operations['put_core-{_version}-settings-logauth']; + put: operations["put_core-{_version}-settings-logauth"]; post?: never; delete?: never; options?: never; @@ -2642,7 +2921,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/devicerecovery': { + "/core/{_version}/settings/devicerecovery": { parameters: { query?: never; header?: never; @@ -2651,7 +2930,7 @@ export interface paths { }; get?: never; /** Update device recovery enabled preference. */ - put: operations['put_core-{_version}-settings-devicerecovery']; + put: operations["put_core-{_version}-settings-devicerecovery"]; post?: never; delete?: never; options?: never; @@ -2659,7 +2938,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/news': { + "/core/{_version}/settings/news": { parameters: { query?: never; header?: never; @@ -2671,16 +2950,16 @@ export interface paths { * Update newsletter subscription. * @deprecated */ - put: operations['put_core-{_version}-settings-news']; + put: operations["put_core-{_version}-settings-news"]; post?: never; delete?: never; options?: never; head?: never; /** Patch newsletter subscription. */ - patch: operations['patch_core-{_version}-settings-news']; + patch: operations["patch_core-{_version}-settings-news"]; trace?: never; }; - '/core/{_version}/settings/news/external': { + "/core/{_version}/settings/news/external": { parameters: { query?: never; header?: never; @@ -2688,21 +2967,21 @@ export interface paths { cookie?: never; }; /** Get newsletter subscription status as external user. */ - get: operations['get_core-{_version}-settings-news-external']; + get: operations["get_core-{_version}-settings-news-external"]; /** * Update newsletter subscription as external user. * @deprecated */ - put: operations['put_core-{_version}-settings-news-external']; + put: operations["put_core-{_version}-settings-news-external"]; post?: never; delete?: never; options?: never; head?: never; /** Patch newsletter subscription as external user. */ - patch: operations['patch_core-{_version}-settings-news-external']; + patch: operations["patch_core-{_version}-settings-news-external"]; trace?: never; }; - '/core/{_version}/settings/density': { + "/core/{_version}/settings/density": { parameters: { query?: never; header?: never; @@ -2711,7 +2990,7 @@ export interface paths { }; get?: never; /** Update the mail list density. */ - put: operations['put_core-{_version}-settings-density']; + put: operations["put_core-{_version}-settings-density"]; post?: never; delete?: never; options?: never; @@ -2719,7 +2998,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/invoicetext': { + "/core/{_version}/settings/invoicetext": { parameters: { query?: never; header?: never; @@ -2728,7 +3007,7 @@ export interface paths { }; get?: never; /** Update invoice user-defined text. */ - put: operations['put_core-{_version}-settings-invoicetext']; + put: operations["put_core-{_version}-settings-invoicetext"]; post?: never; delete?: never; options?: never; @@ -2736,7 +3015,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/codes': { + "/core/{_version}/settings/2fa/codes": { parameters: { query?: never; header?: never; @@ -2749,14 +3028,14 @@ export interface paths { * Regenerate recovery codes. * @description Replace current recovery codes with new ones. */ - post: operations['post_core-{_version}-settings-2fa-codes']; + post: operations["post_core-{_version}-settings-2fa-codes"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/totp': { + "/core/{_version}/settings/2fa/totp": { parameters: { query?: never; header?: never; @@ -2764,16 +3043,16 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-2fa-totp']; + put: operations["put_core-{_version}-settings-2fa-totp"]; /** Signup for TOTP. */ - post: operations['post_core-{_version}-settings-2fa-totp']; + post: operations["post_core-{_version}-settings-2fa-totp"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa': { + "/core/{_version}/settings/2fa": { parameters: { query?: never; header?: never; @@ -2782,16 +3061,35 @@ export interface paths { }; get?: never; /** Disable all the 2FA methods. */ - put: operations['put_core-{_version}-settings-2fa']; - /** Signup for TOTP. */ - post: operations['post_core-{_version}-settings-2fa']; + put: operations["put_core-{_version}-settings-2fa"]; + /** + * Signup for TOTP. + * @deprecated + */ + post: operations["post_core-{_version}-settings-2fa"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/totp/secret": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-settings-2fa-totp-secret"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/reset': { + "/core/{_version}/settings/2fa/reset": { parameters: { query?: never; header?: never; @@ -2804,14 +3102,14 @@ export interface paths { * Request Reset 2FA. * @description Reset all 2FA methods to disabled state. */ - post: operations['post_core-{_version}-settings-2fa-reset']; + post: operations["post_core-{_version}-settings-2fa-reset"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/register': { + "/core/{_version}/settings/2fa/register": { parameters: { query?: never; header?: never; @@ -2819,17 +3117,17 @@ export interface paths { cookie?: never; }; /** Get a challenge for registration of a FIDO2 credential. */ - get: operations['get_core-{_version}-settings-2fa-register']; + get: operations["get_core-{_version}-settings-2fa-register"]; put?: never; /** Register a FIDO2 credential. */ - post: operations['post_core-{_version}-settings-2fa-register']; + post: operations["post_core-{_version}-settings-2fa-register"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/{credentialID}/remove': { + "/core/{_version}/settings/2fa/{credentialID}/remove": { parameters: { query?: never; header?: never; @@ -2839,14 +3137,14 @@ export interface paths { get?: never; put?: never; /** Remove a FIDO2 credential. */ - post: operations['post_core-{_version}-settings-2fa-{credentialID}-remove']; + post: operations["post_core-{_version}-settings-2fa-{credentialID}-remove"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/{credentialID}/rename': { + "/core/{_version}/settings/2fa/{credentialID}/rename": { parameters: { query?: never; header?: never; @@ -2855,7 +3153,7 @@ export interface paths { }; get?: never; /** Rename a FIDO2 credential. */ - put: operations['put_core-{_version}-settings-2fa-{credentialID}-rename']; + put: operations["put_core-{_version}-settings-2fa-{credentialID}-rename"]; post?: never; delete?: never; options?: never; @@ -2863,7 +3161,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/hide-side-panel': { + "/core/{_version}/settings/hide-side-panel": { parameters: { query?: never; header?: never; @@ -2872,7 +3170,7 @@ export interface paths { }; get?: never; /** Update HideSidePanel for the current client. */ - put: operations['put_core-{_version}-settings-hide-side-panel']; + put: operations["put_core-{_version}-settings-hide-side-panel"]; post?: never; delete?: never; options?: never; @@ -2880,7 +3178,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/username': { + "/core/{_version}/settings/username": { parameters: { query?: never; header?: never; @@ -2889,7 +3187,7 @@ export interface paths { }; get?: never; /** Set username for external ProtonAccount. */ - put: operations['put_core-{_version}-settings-username']; + put: operations["put_core-{_version}-settings-username"]; post?: never; delete?: never; options?: never; @@ -2897,7 +3195,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/theme': { + "/core/{_version}/settings/theme": { parameters: { query?: never; header?: never; @@ -2905,7 +3203,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-theme']; + put: operations["put_core-{_version}-settings-theme"]; post?: never; delete?: never; options?: never; @@ -2913,7 +3211,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/themetype': { + "/core/{_version}/settings/themetype": { parameters: { query?: never; header?: never; @@ -2921,7 +3219,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-themetype']; + put: operations["put_core-{_version}-settings-themetype"]; post?: never; delete?: never; options?: never; @@ -2929,7 +3227,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/weekstart': { + "/core/{_version}/settings/weekstart": { parameters: { query?: never; header?: never; @@ -2937,7 +3235,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-weekstart']; + put: operations["put_core-{_version}-settings-weekstart"]; post?: never; delete?: never; options?: never; @@ -2945,7 +3243,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/dateformat': { + "/core/{_version}/settings/dateformat": { parameters: { query?: never; header?: never; @@ -2953,7 +3251,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-dateformat']; + put: operations["put_core-{_version}-settings-dateformat"]; post?: never; delete?: never; options?: never; @@ -2961,7 +3259,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/timeformat': { + "/core/{_version}/settings/timeformat": { parameters: { query?: never; header?: never; @@ -2969,7 +3267,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-timeformat']; + put: operations["put_core-{_version}-settings-timeformat"]; post?: never; delete?: never; options?: never; @@ -2977,7 +3275,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/welcome': { + "/core/{_version}/settings/welcome": { parameters: { query?: never; header?: never; @@ -2985,7 +3283,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-welcome']; + put: operations["put_core-{_version}-settings-welcome"]; post?: never; delete?: never; options?: never; @@ -2993,7 +3291,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/earlyaccess': { + "/core/{_version}/settings/earlyaccess": { parameters: { query?: never; header?: never; @@ -3002,7 +3300,7 @@ export interface paths { }; get?: never; /** Update BetaFlags. */ - put: operations['put_core-{_version}-settings-earlyaccess']; + put: operations["put_core-{_version}-settings-earlyaccess"]; post?: never; delete?: never; options?: never; @@ -3010,7 +3308,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/flags': { + "/core/{_version}/settings/flags": { parameters: { query?: never; header?: never; @@ -3018,7 +3316,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-flags']; + put: operations["put_core-{_version}-settings-flags"]; post?: never; delete?: never; options?: never; @@ -3026,7 +3324,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/telemetry': { + "/core/{_version}/settings/telemetry": { parameters: { query?: never; header?: never; @@ -3035,7 +3333,7 @@ export interface paths { }; get?: never; /** Update telemetry enabled preference. */ - put: operations['put_core-{_version}-settings-telemetry']; + put: operations["put_core-{_version}-settings-telemetry"]; post?: never; delete?: never; options?: never; @@ -3043,7 +3341,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/crashreports': { + "/core/{_version}/settings/crashreports": { parameters: { query?: never; header?: never; @@ -3052,7 +3350,7 @@ export interface paths { }; get?: never; /** Update crash reports enabled preference. */ - put: operations['put_core-{_version}-settings-crashreports']; + put: operations["put_core-{_version}-settings-crashreports"]; post?: never; delete?: never; options?: never; @@ -3060,7 +3358,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/highsecurity': { + "/core/{_version}/settings/highsecurity": { parameters: { query?: never; header?: never; @@ -3073,18 +3371,18 @@ export interface paths { * High Security program - enable * @description https://confluence.protontech.ch/display/MSA/High+Security+Program */ - post: operations['post_core-{_version}-settings-highsecurity']; + post: operations["post_core-{_version}-settings-highsecurity"]; /** * High Security program - disable * @description https://confluence.protontech.ch/display/MSA/High+Security+Program */ - delete: operations['delete_core-{_version}-settings-highsecurity']; + delete: operations["delete_core-{_version}-settings-highsecurity"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/breachalerts': { + "/core/{_version}/settings/breachalerts": { parameters: { query?: never; header?: never; @@ -3097,18 +3395,18 @@ export interface paths { * Breach Alert - enable * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting */ - post: operations['post_core-{_version}-settings-breachalerts']; + post: operations["post_core-{_version}-settings-breachalerts"]; /** * Breach Alert - disable * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting */ - delete: operations['delete_core-{_version}-settings-breachalerts']; + delete: operations["delete_core-{_version}-settings-breachalerts"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/sessionaccountrecovery': { + "/core/{_version}/settings/sessionaccountrecovery": { parameters: { query?: never; header?: never; @@ -3117,7 +3415,7 @@ export interface paths { }; get?: never; /** Update session account recovery preference. */ - put: operations['put_core-{_version}-settings-sessionaccountrecovery']; + put: operations["put_core-{_version}-settings-sessionaccountrecovery"]; post?: never; delete?: never; options?: never; @@ -3125,7 +3423,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/ai-assistant-flags': { + "/core/{_version}/settings/ai-assistant-flags": { parameters: { query?: never; header?: never; @@ -3134,7 +3432,7 @@ export interface paths { }; get?: never; /** Update setting to enable or disable AI Assistant. */ - put: operations['put_core-{_version}-settings-ai-assistant-flags']; + put: operations["put_core-{_version}-settings-ai-assistant-flags"]; post?: never; delete?: never; options?: never; @@ -3142,7 +3440,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/news/unsubscribe': { + "/core/{_version}/settings/news/unsubscribe": { parameters: { query?: never; header?: never; @@ -3151,21 +3449,21 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-settings-news-unsubscribe']; + post: operations["post_core-{_version}-settings-news-unsubscribe"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/support/schedulecall': { + "/core/{_version}/support/schedulecall": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-support-schedulecall']; + get: operations["get_core-{_version}-support-schedulecall"]; put?: never; post?: never; delete?: never; @@ -3174,7 +3472,28 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/lumo': { + "/core/{_version}/keys/{enc_id}/primary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Make address key primary. + * @description Locked route, only used for address-associated keys, + * otherwise this could be a backdoor way to revert to an earlier mailbox password. + */ + put: operations["put_core-{_version}-keys-{enc_id}-primary"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/lumo": { parameters: { query?: never; header?: never; @@ -3182,7 +3501,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-members-{memberId}-lumo']; + put: operations["put_core-{_version}-members-{memberId}-lumo"]; post?: never; delete?: never; options?: never; @@ -3190,7 +3509,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/product-disabled': { + "/core/{_version}/settings/product-disabled": { parameters: { query?: never; header?: never; @@ -3199,7 +3518,7 @@ export interface paths { }; get?: never; /** Update setting to enable or disable specific product for all platforms. */ - put: operations['put_core-{_version}-settings-product-disabled']; + put: operations["put_core-{_version}-settings-product-disabled"]; post?: never; delete?: never; options?: never; @@ -3207,7 +3526,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/delete': { + "/core/{_version}/users/delete": { parameters: { query?: never; header?: never; @@ -3228,18 +3547,18 @@ export interface paths { * * > 5. Managed user in a multi-user organization (non-proton): you can’t delete yourself */ - get: operations['get_core-{_version}-users-delete']; + get: operations["get_core-{_version}-users-delete"]; /** Delete self, will invalidate API access token. */ - put: operations['put_core-{_version}-users-delete']; + put: operations["put_core-{_version}-users-delete"]; post?: never; /** Delete self, will invalidate API access token. */ - delete: operations['delete_core-{_version}-users-delete']; + delete: operations["delete_core-{_version}-users-delete"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/users/reset': { + "/core/{_version}/users/reset": { parameters: { query?: never; header?: never; @@ -3247,7 +3566,34 @@ export interface paths { cookie?: never; }; /** Get available reset methods and account type. */ - get: operations['get_core-{_version}-users-reset']; + get: operations["get_core-{_version}-users-reset"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/reset/{username}/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Validate reset token. + * @description Error response example: + * ``` + * { + * "Code": 12031, + * "Error": "Invalid reset token", + * "Details": } + * } + * ``` + */ + get: operations["get_core-{_version}-reset-{username}-{token}"]; put?: never; post?: never; delete?: never; @@ -3256,7 +3602,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users': { + "/core/{_version}/users": { parameters: { query?: never; header?: never; @@ -3325,20 +3671,20 @@ export interface paths { * } * ``` */ - get: operations['get_core-{_version}-users']; + get: operations["get_core-{_version}-users"]; put?: never; /** * Create a user or ProtonID user with a 3rd party email as username. * @description TODO(fsalathe): Refactor this function into a service [refactor] */ - post: operations['post_core-{_version}-users']; + post: operations["post_core-{_version}-users"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/users/external': { + "/core/{_version}/users/external": { parameters: { query?: never; header?: never; @@ -3351,14 +3697,14 @@ export interface paths { * Create a user or ProtonID user with a 3rd party email as username. * @description TODO(fsalathe): Refactor this function into a service [refactor] */ - post: operations['post_core-{_version}-users-external']; + post: operations["post_core-{_version}-users-external"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/users/check': { + "/core/{_version}/users/check": { parameters: { query?: never; header?: never; @@ -3367,7 +3713,7 @@ export interface paths { }; get?: never; /** Check user creation token validity. */ - put: operations['put_core-{_version}-users-check']; + put: operations["put_core-{_version}-users-check"]; post?: never; delete?: never; options?: never; @@ -3375,7 +3721,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/availableExternal': { + "/core/{_version}/users/availableExternal": { parameters: { query?: never; header?: never; @@ -3383,7 +3729,7 @@ export interface paths { cookie?: never; }; /** Check if username already taken. */ - get: operations['get_core-{_version}-users-availableExternal']; + get: operations["get_core-{_version}-users-availableExternal"]; put?: never; post?: never; delete?: never; @@ -3392,7 +3738,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/available': { + "/core/{_version}/users/available": { parameters: { query?: never; header?: never; @@ -3400,7 +3746,7 @@ export interface paths { cookie?: never; }; /** Check if username already taken. */ - get: operations['get_core-{_version}-users-available']; + get: operations["get_core-{_version}-users-available"]; put?: never; post?: never; delete?: never; @@ -3409,7 +3755,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/available/{username}': { + "/core/{_version}/users/available/{username}": { parameters: { query?: never; header?: never; @@ -3417,7 +3763,7 @@ export interface paths { cookie?: never; }; /** @deprecated */ - get: operations['get_core-{_version}-users-available-{username}']; + get: operations["get_core-{_version}-users-available-{username}"]; put?: never; post?: never; delete?: never; @@ -3426,7 +3772,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/direct': { + "/core/{_version}/users/direct": { parameters: { query?: never; header?: never; @@ -3434,7 +3780,7 @@ export interface paths { cookie?: never; }; /** Deprecated. Placeholder left in place for handling old clients. */ - get: operations['get_core-{_version}-users-direct']; + get: operations["get_core-{_version}-users-direct"]; put?: never; post?: never; delete?: never; @@ -3443,7 +3789,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/code': { + "/core/{_version}/users/code": { parameters: { query?: never; header?: never; @@ -3453,14 +3799,14 @@ export interface paths { get?: never; put?: never; /** Send a verification code. */ - post: operations['post_core-{_version}-users-code']; + post: operations["post_core-{_version}-users-code"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/users/lock': { + "/core/{_version}/users/lock": { parameters: { query?: never; header?: never; @@ -3469,7 +3815,7 @@ export interface paths { }; get?: never; /** Lock sensitive settings for keys/organization. */ - put: operations['put_core-{_version}-users-lock']; + put: operations["put_core-{_version}-users-lock"]; post?: never; delete?: never; options?: never; @@ -3477,7 +3823,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/unlock': { + "/core/{_version}/users/unlock": { parameters: { query?: never; header?: never; @@ -3486,7 +3832,7 @@ export interface paths { }; get?: never; /** Unlock sensitive settings for keys/organization. */ - put: operations['put_core-{_version}-users-unlock']; + put: operations["put_core-{_version}-users-unlock"]; post?: never; delete?: never; options?: never; @@ -3494,7 +3840,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/password': { + "/core/{_version}/users/password": { parameters: { query?: never; header?: never; @@ -3503,7 +3849,7 @@ export interface paths { }; get?: never; /** Unlock password changes. */ - put: operations['put_core-{_version}-users-password']; + put: operations["put_core-{_version}-users-password"]; post?: never; delete?: never; options?: never; @@ -3511,7 +3857,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/captcha/{token}': { + "/core/{_version}/users/captcha/{token}": { parameters: { query?: never; header?: never; @@ -3519,7 +3865,7 @@ export interface paths { cookie?: never; }; /** Get captcha (javascript) (hv1). */ - get: operations['get_core-{_version}-users-captcha-{token}']; + get: operations["get_core-{_version}-users-captcha-{token}"]; put?: never; post?: never; delete?: never; @@ -3528,14 +3874,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/disable/{jwt}': { + "/core/{_version}/users/disable/{jwt}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-users-disable-{jwt}']; + get: operations["get_core-{_version}-users-disable-{jwt}"]; put?: never; post?: never; delete?: never; @@ -3544,19 +3890,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/vpn': { + "/core/{_version}/users/invitations/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-members-{enc_id}-vpn']; - /** - * Update max number of VPNs for member. - * @description Update number of maximum VPN connections, locked route. - */ - put: operations['put_core-{_version}-members-{enc_id}-vpn']; + get: operations["get_core-{_version}-users-invitations-{id}"]; + put?: never; post?: never; delete?: never; options?: never; @@ -3564,29 +3906,24 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/features': { + "/core/{_version}/users/invitations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get the list of the features (optionally filtered). - * @description TypeScript typing files: - * https://gitlab.protontech.ch/ProtonMail/Slim-API/-/blob/develop/bundles/FeatureBundle/tests/Mock/Feature.ts - */ - get: operations['get_core-v4-features']; + /** Gets organization invitations sent to a user. */ + get: operations["get_core-{_version}-users-invitations"]; put?: never; - /** Add a new feature definition. */ - post: operations['post_core-v4-features']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/v4/features/{id}': { + "/core/{_version}/users/invitations/{enc_id}/reject": { parameters: { query?: never; header?: never; @@ -3594,16 +3931,16 @@ export interface paths { cookie?: never; }; get?: never; - /** Update feature configuration. */ - put: operations['put_core-v4-features-{id}']; - post?: never; + put?: never; + /** Rejects an invitation. */ + post: operations["post_core-{_version}-users-invitations-{enc_id}-reject"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/v4/features/{featureID}': { + "/core/{_version}/users/invitations/{enc_id}/accept": { parameters: { query?: never; header?: never; @@ -3612,24 +3949,27 @@ export interface paths { }; get?: never; put?: never; - post?: never; - /** Remove a feature definition. */ - delete: operations['delete_core-v4-features-{featureID}']; + /** Accepts an invitation. */ + post: operations["post_core-{_version}-users-invitations-{enc_id}-accept"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/v4/features/{code}': { + "/core/{_version}/members/{enc_id}/vpn": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get a single feature by its code. */ - get: operations['get_core-v4-features-{code}']; - put?: never; + get: operations["get_core-{_version}-members-{enc_id}-vpn"]; + /** + * Update max number of VPNs for member. + * @description Update number of maximum VPN connections, locked route. + */ + put: operations["put_core-{_version}-members-{enc_id}-vpn"]; post?: never; delete?: never; options?: never; @@ -3637,7 +3977,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/features/{code}/value': { + "/core/{_version}/nps/dismiss": { parameters: { query?: never; header?: never; @@ -3645,17 +3985,16 @@ export interface paths { cookie?: never; }; get?: never; - /** Set the value of a single feature by its code. */ - put: operations['put_core-v4-features-{code}-value']; - post?: never; - /** Clear the value of a single feature by its code. */ - delete: operations['delete_core-v4-features-{code}-value']; + put?: never; + /** Dismiss NPS Survey Feedback (close without submitting) */ + post: operations["post_core-{_version}-nps-dismiss"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/v4/features/{code}/user/value': { + "/core/{_version}/nps/submit": { parameters: { query?: never; header?: never; @@ -3663,41 +4002,45 @@ export interface paths { cookie?: never; }; get?: never; - /** Set the value of a single feature by its code for a given list of users (selected by ID or Username). */ - put: operations['put_core-v4-features-{code}-user-value']; - post?: never; + put?: never; + /** Submit NPS Survey Feedback */ + post: operations["post_core-{_version}-nps-submit"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/info': { + "/core/v4/features": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get the list of the features (optionally filtered). + * @description TypeScript typing files: + * https://gitlab.protontech.ch/ProtonMail/Slim-API/-/blob/develop/bundles/FeatureBundle/tests/Mock/Feature.ts + */ + get: operations["get_core-v4-features"]; put?: never; - /** Set up SRP authentication request. */ - post: operations['post_core-{_version}-auth-info']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/sso/{token}': { + "/core/v4/features/{code}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Initiate SSO flow using token from POST /auth/info */ - get: operations['get_core-{_version}-auth-sso-{token}']; + /** Get a single feature by its code. */ + get: operations["get_core-v4-features-{code}"]; put?: never; post?: never; delete?: never; @@ -3706,7 +4049,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/auth/saml': { + "/core/v4/features/{code}/value": { parameters: { query?: never; header?: never; @@ -3714,16 +4057,17 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - /** HTTP-POST binding for SAML authentication. Only to be called by an IdP. */ - post: operations['post_core-{_version}-auth-saml']; - delete?: never; + /** Set the value of a single feature by its code. */ + put: operations["put_core-v4-features-{code}-value"]; + post?: never; + /** Clear the value of a single feature by its code. */ + delete: operations["delete_core-v4-features-{code}-value"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth': { + "/core/{_version}/auth/info": { parameters: { query?: never; header?: never; @@ -3732,16 +4076,15 @@ export interface paths { }; get?: never; put?: never; - /** Authenticate. */ - post: operations['post_core-{_version}-auth']; - /** Revoke a token. */ - delete: operations['delete_core-{_version}-auth']; + /** Set up SRP authentication request. */ + post: operations["post_core-{_version}-auth-info"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/jwt': { + "/core/{_version}/auth/saml": { parameters: { query?: never; header?: never; @@ -3750,15 +4093,15 @@ export interface paths { }; get?: never; put?: never; - /** Authenticate using pre-issued JWT. */ - post: operations['post_core-{_version}-auth-jwt']; + /** HTTP-POST binding for SAML authentication. Only to be called by an IdP. */ + post: operations["post_core-{_version}-auth-saml"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/2fa': { + "/core/{_version}/auth/jwt": { parameters: { query?: never; header?: never; @@ -3767,43 +4110,40 @@ export interface paths { }; get?: never; put?: never; - /** Submit second factor. */ - post: operations['post_core-{_version}-auth-2fa']; + /** Authenticate using pre-issued JWT. */ + post: operations["post_core-{_version}-auth-jwt"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/modulus': { + "/core/{_version}/auth/2fa": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get random SRP modulus. */ - get: operations['get_core-{_version}-auth-modulus']; + get?: never; put?: never; - post?: never; + /** Submit second factor. */ + post: operations["post_core-{_version}-auth-2fa"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/scopes': { + "/core/{_version}/auth/modulus": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get the current user scopes. - * @description Note that the bitmap of scopes is a string to avoid truncations of big numbers. - */ - get: operations['get_core-{_version}-auth-scopes']; + /** Get random SRP modulus. */ + get: operations["get_core-{_version}-auth-modulus"]; put?: never; post?: never; delete?: never; @@ -3812,38 +4152,27 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/auth/refresh': { + "/core/{_version}/auth/scopes": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Refresh an expired token. - * @description Other response 200 body example: - * ```js - * { - * "Code": 1000, - * "AccessToken": "8a2575fad8788d253543957073d494c86f22f829", // Decrypted if reset scope or no keys set up - * "ExpiresIn": 360000, // DEPRECATED - * "TokenType": "Bearer", - * "Scope": "reset", // DEPRECATED - * "Scopes": ["reset"], // Can only be used to reset mailbox password - * "RefreshToken": "b894b4c4f20003f12d486900d8b88c7d68e67235" - * } - * ``` + * Get the current user scopes. + * @description Note that the bitmap of scopes is a string to avoid truncations of big numbers. */ - post: operations['post_core-{_version}-auth-refresh']; + get: operations["get_core-{_version}-auth-scopes"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/cookies': { + "/core/{_version}/auth/cookies": { parameters: { query?: never; header?: never; @@ -3858,14 +4187,14 @@ export interface paths { * For non-persistent sessions cookie expiration is set to 0 and the client should garbage collect them at the end * of the session. */ - post: operations['post_core-{_version}-auth-cookies']; + post: operations["post_core-{_version}-auth-cookies"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/credentialless': { + "/core/{_version}/auth/credentialless": { parameters: { query?: never; header?: never; @@ -3875,14 +4204,14 @@ export interface paths { get?: never; put?: never; /** Create and authenticate a credential-less user. */ - post: operations['post_core-{_version}-auth-credentialless']; + post: operations["post_core-{_version}-auth-credentialless"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/mnemonic': { + "/core/{_version}/settings/mnemonic": { parameters: { query?: never; header?: never; @@ -3894,13 +4223,13 @@ export interface paths { * @description Returns the mnemonic keyring and its encryption salt, to allow re-enabling user keys if a logged in user * remembers an old mnemonic. */ - get: operations['get_core-{_version}-settings-mnemonic']; + get: operations["get_core-{_version}-settings-mnemonic"]; /** * Update or set mnemonic. * @description This route allows submission of a new mnemonic or update an existing mnemonic, alongside a backup keyring. * If a keyring already exists the keys will be merged (newer replaces older). */ - put: operations['put_core-{_version}-settings-mnemonic']; + put: operations["put_core-{_version}-settings-mnemonic"]; post?: never; delete?: never; options?: never; @@ -3908,7 +4237,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/mnemonic/reset': { + "/core/{_version}/settings/mnemonic/reset": { parameters: { query?: never; header?: never; @@ -3919,7 +4248,7 @@ export interface paths { * Get mnemonic keyring to restore keys. * @description Returns the mnemonic keyring and its encryption salt, to allow re-enabling user keys in the reset flow. */ - get: operations['get_core-{_version}-settings-mnemonic-reset']; + get: operations["get_core-{_version}-settings-mnemonic-reset"]; put?: never; /** * Reset account using a mnemonic. @@ -3927,14 +4256,14 @@ export interface paths { * to allow resetting an account. This will change the session's scopes to the regular user's scopes. * It logs out other sessions for security reasons. */ - post: operations['post_core-{_version}-settings-mnemonic-reset']; + post: operations["post_core-{_version}-settings-mnemonic-reset"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/mnemonic/disable': { + "/core/{_version}/settings/mnemonic/disable": { parameters: { query?: never; header?: never; @@ -3948,14 +4277,14 @@ export interface paths { * @description To re-enable it's needed to submit a new mnemonic via PUT /settings/mnemonic. * This route removes the PASSWORD scope from the token. */ - post: operations['post_core-{_version}-settings-mnemonic-disable']; + post: operations["post_core-{_version}-settings-mnemonic-disable"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/mnemonic/reactivate': { + "/core/{_version}/settings/mnemonic/reactivate": { parameters: { query?: never; header?: never; @@ -3972,7 +4301,7 @@ export interface paths { * It will work only if the mnemonic needs to be (re) activated and is to be prompted automatically (i.e. for * states MNEMONIC_ENABLED and MNEMONIC_OUTDATED). */ - put: operations['put_core-{_version}-settings-mnemonic-reactivate']; + put: operations["put_core-{_version}-settings-mnemonic-reactivate"]; post?: never; delete?: never; options?: never; @@ -3980,7 +4309,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/pushes': { + "/core/{_version}/pushes": { parameters: { query?: never; header?: never; @@ -3993,7 +4322,7 @@ export interface paths { * @description List of active notifications for the current logged user. * Can be used by the clients to always know what should still be showed as active notification. */ - get: operations['get_core-{_version}-pushes']; + get: operations["get_core-{_version}-pushes"]; put?: never; post?: never; delete?: never; @@ -4002,7 +4331,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/pushes/active': { + "/core/{_version}/pushes/active": { parameters: { query?: never; header?: never; @@ -4014,7 +4343,7 @@ export interface paths { * @description List of active notifications for the current logged user. * Can be used by the clients to always know what should still be showed as active notification. */ - get: operations['get_core-{_version}-pushes-active']; + get: operations["get_core-{_version}-pushes-active"]; put?: never; post?: never; delete?: never; @@ -4023,7 +4352,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/pushes/active/session': { + "/core/{_version}/pushes/active/session": { parameters: { query?: never; header?: never; @@ -4035,7 +4364,7 @@ export interface paths { * @description List of active notifications for the current logged user using the current session. * Can be used by the clients to always know what should still be showed as active notification. */ - get: operations['get_core-{_version}-pushes-active-session']; + get: operations["get_core-{_version}-pushes-active-session"]; put?: never; post?: never; delete?: never; @@ -4044,7 +4373,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/pushes/{enc_id}': { + "/core/{_version}/pushes/{enc_id}": { parameters: { query?: never; header?: never; @@ -4058,65 +4387,13 @@ export interface paths { * Delete the given push. * @description If the session belongs to a family, the pushes for the whole session family will be deleted. */ - delete: operations['delete_core-{_version}-pushes-{enc_id}']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/referrals': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List current user referrals. */ - get: operations['get_core-{_version}-referrals']; - put?: never; - /** Send referral invitation by email to a list of recipients. */ - post: operations['post_core-{_version}-referrals']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/referrals/status': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Current user referral status. */ - get: operations['get_core-{_version}-referrals-status']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/referrals/identifiers/{identifier}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Check if referrer identifier exists */ - get: operations['get_core-{_version}-referrals-identifiers-{identifier}']; - put?: never; - post?: never; - delete?: never; + delete: operations["delete_core-{_version}-pushes-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/devices': { + "/core/{_version}/devices": { parameters: { query?: never; header?: never; @@ -4128,18 +4405,18 @@ export interface paths { /** Register device. The registering will delete any duplicate having the same (UserID, Product, DeviceToken) from * different sessions. If the registering is done from a session already having a registered device, the existing * device will be replaced with the new one. */ - post: operations['post_core-{_version}-devices']; + post: operations["post_core-{_version}-devices"]; /** * Unregister device. * @description > Note: Please use the `DELETE /core/v4/devices` route */ - delete: operations['delete_core-{_version}-devices']; + delete: operations["delete_core-{_version}-devices"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/betas/{client_id}': { + "/core/{_version}/betas/{client_id}": { parameters: { query?: never; header?: never; @@ -4147,18 +4424,18 @@ export interface paths { cookie?: never; }; /** Get a specific beta registration. */ - get: operations['get_core-{_version}-betas-{client_id}']; + get: operations["get_core-{_version}-betas-{client_id}"]; /** Create or update beta registration. */ - put: operations['put_core-{_version}-betas-{client_id}']; + put: operations["put_core-{_version}-betas-{client_id}"]; post?: never; /** Delete a specific beta registration. */ - delete: operations['delete_core-{_version}-betas-{client_id}']; + delete: operations["delete_core-{_version}-betas-{client_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/betas': { + "/core/{_version}/betas": { parameters: { query?: never; header?: never; @@ -4166,51 +4443,17 @@ export interface paths { cookie?: never; }; /** Get all beta registrations. */ - get: operations['get_core-{_version}-betas']; + get: operations["get_core-{_version}-betas"]; put?: never; post?: never; /** Delete all beta registrations. */ - delete: operations['delete_core-{_version}-betas']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/geofeed/geofeed.csv': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get a CSV export for GeoFeed. */ - get: operations['get_core-{_version}-geofeed-geofeed-csv']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/geofeed/geofeed-public.csv': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get geofeed data containing only the custom admin-set data */ - get: operations['get_core-{_version}-geofeed-geofeed-public-csv']; - put?: never; - post?: never; - delete?: never; + delete: operations["delete_core-{_version}-betas"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/load': { + "/core/{_version}/load": { parameters: { query?: never; header?: never; @@ -4222,21 +4465,21 @@ export interface paths { * @description Placeholder route for app pages and modals that are loaded by front-end JavaScript instead of * obtained via a GET request. */ - get: operations['get_core-{_version}-load']; + get: operations["get_core-{_version}-load"]; put?: never; /** * Placeholder route. * @description Placeholder route for app pages and modals that are loaded by front-end JavaScript instead of * obtained via a GET request. */ - post: operations['post_core-{_version}-load']; + post: operations["post_core-{_version}-load"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/logs/auth': { + "/core/{_version}/logs/auth": { parameters: { query?: never; header?: never; @@ -4244,17 +4487,17 @@ export interface paths { cookie?: never; }; /** Get authentication logs. */ - get: operations['get_core-{_version}-logs-auth']; + get: operations["get_core-{_version}-logs-auth"]; put?: never; post?: never; /** Delete all authentication logs. */ - delete: operations['delete_core-{_version}-logs-auth']; + delete: operations["delete_core-{_version}-logs-auth"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/metrics': { + "/core/{_version}/metrics": { parameters: { query?: never; header?: never; @@ -4262,20 +4505,20 @@ export interface paths { cookie?: never; }; /** Send Simple Metrics. */ - get: operations['get_core-{_version}-metrics']; + get: operations["get_core-{_version}-metrics"]; put?: never; /** * Send Metrics Report. * @description The `Data` key can contain anything, that is what will be saved in the log (as context). */ - post: operations['post_core-{_version}-metrics']; + post: operations["post_core-{_version}-metrics"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/recovery/secret': { + "/core/{_version}/settings/recovery/secret": { parameters: { query?: never; header?: never; @@ -4288,18 +4531,18 @@ export interface paths { * Set secret when empty. * @description This route allows submission of new secrets when they are empty for the primary user key. */ - post: operations['post_core-{_version}-settings-recovery-secret']; + post: operations["post_core-{_version}-settings-recovery-secret"]; /** * Reset secrets to the null state, in case the files are (suspect) compromised. * @description To re-enable it's needed to submit new secrets. */ - delete: operations['delete_core-{_version}-settings-recovery-secret']; + delete: operations["delete_core-{_version}-settings-recovery-secret"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/form/{portal_id}/{form_id}': { + "/core/{_version}/reports/form/{portal_id}/{form_id}": { parameters: { query?: never; header?: never; @@ -4309,14 +4552,14 @@ export interface paths { get?: never; put?: never; /** Please refer to the Hubspot API docs for this route: https://legacydocs.hubspot.com/docs/methods/forms/submit_form */ - post: operations['post_core-{_version}-reports-form-{portal_id}-{form_id}']; + post: operations["post_core-{_version}-reports-form-{portal_id}-{form_id}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/bug': { + "/core/{_version}/reports/bug": { parameters: { query?: never; header?: never; @@ -4380,15 +4623,17 @@ export interface paths { * {attachment contents} * ----WebKitFormBoundary7MA4YWxkTrZu0gW * ``` + * + * phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded */ - post: operations['post_core-{_version}-reports-bug']; + post: operations["post_core-{_version}-reports-bug"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/bug/attachments': { + "/core/{_version}/reports/bug/attachments": { parameters: { query?: never; header?: never; @@ -4412,14 +4657,14 @@ export interface paths { * ----WebKitFormBoundary7MA4YWxkTrZu0gW * ``` */ - post: operations['post_core-{_version}-reports-bug-attachments']; + post: operations["post_core-{_version}-reports-bug-attachments"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/bug/{ticketId}': { + "/core/{_version}/reports/bug/{ticketId}": { parameters: { query?: never; header?: never; @@ -4430,13 +4675,13 @@ export interface paths { put?: never; post?: never; /** Solve ticket */ - delete: operations['delete_core-{_version}-reports-bug-{ticketId}']; + delete: operations["delete_core-{_version}-reports-bug-{ticketId}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/abuse': { + "/core/{_version}/reports/abuse": { parameters: { query?: never; header?: never; @@ -4476,14 +4721,14 @@ export interface paths { * ----WebKitFormBoundary7MA4YWxkTrZu0gW * ``` */ - post: operations['post_core-{_version}-reports-abuse']; + post: operations["post_core-{_version}-reports-abuse"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/crash': { + "/core/{_version}/reports/crash": { parameters: { query?: never; header?: never; @@ -4493,41 +4738,14 @@ export interface paths { get?: never; put?: never; /** Report a client crash. */ - post: operations['post_core-{_version}-reports-crash']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/reports/sentry/api/{id}/{type}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Report a client crash via Sentry Proxy (new). - * @description The interface proxies request generated by a Sentry client to a configured Sentry server. - * - * This endpoint uses the new version of Sentry (https://sentry-new.protontech.ch). - * - *
- * When configuring a Sentry client, the DSN should not be built with this URI but with: - * https://SENTRY_PUBLIC_KEY@api.protonmail.ch/core/v4/reports/sentry/{sentry_project_id} - *
- */ - post: operations['post_core-{_version}-reports-sentry-api-{id}-{type}']; + post: operations["post_core-{_version}-reports-crash"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/sentry/api/{id}/{type}/': { + "/core/{_version}/reports/sentry/api/{id}/{type}": { parameters: { query?: never; header?: never; @@ -4547,14 +4765,14 @@ export interface paths { * https://SENTRY_PUBLIC_KEY@api.protonmail.ch/core/v4/reports/sentry/{sentry_project_id} * */ - post: operations['post_core-{_version}-reports-sentry-api-{id}-{type}']; + post: operations["post_core-{_version}-reports-sentry-api-{id}-{type}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/phishing': { + "/core/{_version}/reports/phishing": { parameters: { query?: never; header?: never; @@ -4564,14 +4782,14 @@ export interface paths { get?: never; put?: never; /** Report a phishing email. */ - post: operations['post_core-{_version}-reports-phishing']; + post: operations["post_core-{_version}-reports-phishing"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/spam': { + "/core/{_version}/reports/cancel-plan": { parameters: { query?: never; header?: never; @@ -4580,15 +4798,14 @@ export interface paths { }; get?: never; put?: never; - /** Report spam. */ - post: operations['post_core-{_version}-reports-spam']; + post: operations["post_core-{_version}-reports-cancel-plan"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/cancel-plan': { + "/core/{_version}/reset": { parameters: { query?: never; header?: never; @@ -4597,31 +4814,15 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-reports-cancel-plan']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/reset/{username}/{token}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Validate reset token. */ - get: operations['get_core-{_version}-reset-{username}-{token}']; - put?: never; - post?: never; + /** Request login reset token. */ + post: operations["post_core-{_version}-reset"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reset': { + "/core/{_version}/reset/username": { parameters: { query?: never; header?: never; @@ -4630,39 +4831,38 @@ export interface paths { }; get?: never; put?: never; - /** Request login reset token. */ - post: operations['post_core-{_version}-reset']; + /** Send usernames to notification email. */ + post: operations["post_core-{_version}-reset-username"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reset/username': { + "/core/{_version}/system/config": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-system-config"]; put?: never; - /** Send usernames to notification email. */ - post: operations['post_core-{_version}-reset-username']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/system/config': { + "/core/{_version}/system/version": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-system-config']; + get: operations["get_core-{_version}-system-version"]; put?: never; post?: never; delete?: never; @@ -4671,14 +4871,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/system/version': { + "/core/{_version}/tests/exception": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-system-version']; + get: operations["get_core-{_version}-tests-exception"]; put?: never; post?: never; delete?: never; @@ -4687,14 +4887,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/exception': { + "/core/{_version}/tests/error": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-exception']; + get: operations["get_core-{_version}-tests-error"]; put?: never; post?: never; delete?: never; @@ -4703,14 +4903,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/error': { + "/core/{_version}/tests/notice": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-error']; + get: operations["get_core-{_version}-tests-notice"]; put?: never; post?: never; delete?: never; @@ -4719,14 +4919,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/notice': { + "/core/{_version}/tests/user-deprecation": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-notice']; + get: operations["get_core-{_version}-tests-user-deprecation"]; put?: never; post?: never; delete?: never; @@ -4735,7 +4935,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/memoryLeak': { + "/core/{_version}/tests/memoryLeak": { parameters: { query?: never; header?: never; @@ -4743,7 +4943,7 @@ export interface paths { cookie?: never; }; /** Simulate a memory leak. */ - get: operations['get_core-{_version}-tests-memoryLeak']; + get: operations["get_core-{_version}-tests-memoryLeak"]; put?: never; post?: never; delete?: never; @@ -4752,14 +4952,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/logger': { + "/core/{_version}/tests/logger": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-logger']; + get: operations["get_core-{_version}-tests-logger"]; put?: never; post?: never; delete?: never; @@ -4768,14 +4968,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/logger/observability': { + "/core/{_version}/tests/logger/observability": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-logger-observability']; + get: operations["get_core-{_version}-tests-logger-observability"]; put?: never; post?: never; delete?: never; @@ -4784,7 +4984,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/ping': { + "/core/{_version}/tests/ping": { parameters: { query?: never; header?: never; @@ -4796,7 +4996,7 @@ export interface paths { * @description More info about when to use this route: * https://confluence.protontech.ch/display/CP/When+and+How+to+Retry+API+Requests */ - get: operations['get_core-{_version}-tests-ping']; + get: operations["get_core-{_version}-tests-ping"]; put?: never; post?: never; delete?: never; @@ -4805,7 +5005,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/version': { + "/core/{_version}/tests/version": { parameters: { query?: never; header?: never; @@ -4813,7 +5013,7 @@ export interface paths { cookie?: never; }; /** @deprecated */ - get: operations['get_core-{_version}-tests-version']; + get: operations["get_core-{_version}-tests-version"]; put?: never; post?: never; delete?: never; @@ -4822,7 +5022,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/stream': { + "/core/{_version}/tests/stream": { parameters: { query?: never; header?: never; @@ -4830,23 +5030,7 @@ export interface paths { cookie?: never; }; /** Test endpoint to check streaming capabilities */ - get: operations['get_core-{_version}-tests-stream']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/update': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations['get_core-{_version}-update']; + get: operations["get_core-{_version}-tests-stream"]; put?: never; post?: never; delete?: never; @@ -4855,15 +5039,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/invitations': { + "/core/{_version}/update": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Gets organization invitations sent to a user. */ - get: operations['get_core-{_version}-users-invitations']; + get: operations["get_core-{_version}-update"]; put?: never; post?: never; delete?: never; @@ -4872,41 +5055,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/invitations/{enc_id}/reject': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Rejects an invitation. */ - post: operations['post_core-{_version}-users-invitations-{enc_id}-reject']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/users/invitations/{enc_id}/accept': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Accepts an invitation. */ - post: operations['post_core-{_version}-users-invitations-{enc_id}-accept']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/core/{_version}/validate/email': { + "/core/{_version}/validate/email": { parameters: { query?: never; header?: never; @@ -4916,14 +5065,14 @@ export interface paths { get?: never; put?: never; /** Validate email address. */ - post: operations['post_core-{_version}-validate-email']; + post: operations["post_core-{_version}-validate-email"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/validate/phone': { + "/core/{_version}/validate/phone": { parameters: { query?: never; header?: never; @@ -4933,14 +5082,14 @@ export interface paths { get?: never; put?: never; /** Validate phone number. */ - post: operations['post_core-{_version}-validate-phone']; + post: operations["post_core-{_version}-validate-phone"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verification/ownership/{token}': { + "/core/{_version}/verification/ownership/{token}": { parameters: { query?: never; header?: never; @@ -4948,17 +5097,17 @@ export interface paths { cookie?: never; }; /** Get details of a given Ownership Verification. */ - get: operations['get_core-{_version}-verification-ownership-{token}']; + get: operations["get_core-{_version}-verification-ownership-{token}"]; put?: never; /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-{token}']; + post: operations["post_core-{_version}-verification-ownership-{token}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verification/ownership-email/{token}': { + "/core/{_version}/verification/ownership-email/{token}": { parameters: { query?: never; header?: never; @@ -4966,17 +5115,17 @@ export interface paths { cookie?: never; }; /** Get details of a given Ownership Verification. */ - get: operations['get_core-{_version}-verification-ownership-email-{token}']; + get: operations["get_core-{_version}-verification-ownership-email-{token}"]; put?: never; /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-email-{token}']; + post: operations["post_core-{_version}-verification-ownership-email-{token}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verification/ownership-sms/{token}': { + "/core/{_version}/verification/ownership-sms/{token}": { parameters: { query?: never; header?: never; @@ -4984,17 +5133,17 @@ export interface paths { cookie?: never; }; /** Get details of a given Ownership Verification. */ - get: operations['get_core-{_version}-verification-ownership-sms-{token}']; + get: operations["get_core-{_version}-verification-ownership-sms-{token}"]; put?: never; /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-sms-{token}']; + post: operations["post_core-{_version}-verification-ownership-sms-{token}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verification/ownership/{token}/{code}': { + "/core/{_version}/verification/ownership/{token}/{code}": { parameters: { query?: never; header?: never; @@ -5004,14 +5153,14 @@ export interface paths { get?: never; put?: never; /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-{token}-{code}']; + post: operations["post_core-{_version}-verification-ownership-{token}-{code}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verification/ownership-email/{token}/{code}': { + "/core/{_version}/verification/ownership-email/{token}/{code}": { parameters: { query?: never; header?: never; @@ -5021,14 +5170,14 @@ export interface paths { get?: never; put?: never; /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-email-{token}-{code}']; + post: operations["post_core-{_version}-verification-ownership-email-{token}-{code}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verification/ownership-sms/{token}/{code}': { + "/core/{_version}/verification/ownership-sms/{token}/{code}": { parameters: { query?: never; header?: never; @@ -5038,21 +5187,21 @@ export interface paths { get?: never; put?: never; /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-sms-{token}-{code}']; + post: operations["post_core-{_version}-verification-ownership-sms-{token}-{code}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/v6/events/{id}': { + "/core/v6/events/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-v6-events-{id}']; + get: operations["get_core-v6-events-{id}"]; put?: never; post?: never; delete?: never; @@ -5061,14 +5210,18 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/events/latest': { + "/core/{_version}/events/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-events-latest']; + /** + * Get events since ID. + * @description Get a list of models to refresh for each event type. + */ + get: operations["get_core-{_version}-events-{id}"]; put?: never; post?: never; delete?: never; @@ -5077,7 +5230,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/events/{id}': { + "/core/v4/events/{id}": { parameters: { query?: never; header?: never; @@ -5086,9 +5239,10 @@ export interface paths { }; /** * Get events since ID. + * @deprecated * @description Get a list of models to refresh for each event type. */ - get: operations['get_core-{_version}-events-{id}']; + get: operations["get_core-v4-events-{id}"]; put?: never; post?: never; delete?: never; @@ -5097,28 +5251,24 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/events/{id}': { + "/core/{_version}/feedback": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get events since ID. - * @deprecated - * @description Get a list of models to refresh for each event type. - */ - get: operations['get_core-v4-events-{id}']; + get?: never; put?: never; - post?: never; + /** Log general user feedback. */ + post: operations["post_core-{_version}-feedback"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/feedback': { + "/core/{_version}/checklist/seen-completed-list/{checklistType}": { parameters: { query?: never; header?: never; @@ -5127,47 +5277,49 @@ export interface paths { }; get?: never; put?: never; - /** Log general user feedback. */ - post: operations['post_core-{_version}-feedback']; + /** Mark a completed checklist as seen. */ + post: operations["post_core-{_version}-checklist-seen-completed-list-{checklistType}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/get-started': { + "/core/{_version}/checklist/get-started/init": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-checklist-get-started']; + get?: never; put?: never; - post?: never; + /** Create a checklist for a Free user. */ + post: operations["post_core-{_version}-checklist-get-started-init"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/paying-user': { + "/core/{_version}/checklist/paying-user/init": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-checklist-paying-user']; + get?: never; put?: never; - post?: never; + /** Create a checklist for a paid user. */ + post: operations["post_core-{_version}-checklist-paying-user-init"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/get-started/seen-completed-list': { + "/core/{_version}/checklist/check-item": { parameters: { query?: never; header?: never; @@ -5175,15 +5327,16 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - post: operations['post_core-{_version}-checklist-get-started-seen-completed-list']; + /** Mark a checklist item as done. */ + put: operations["put_core-{_version}-checklist-check-item"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/paying-user/hide': { + "/core/{_version}/checklist/update-display": { parameters: { query?: never; header?: never; @@ -5191,15 +5344,16 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - post: operations['post_core-{_version}-checklist-paying-user-hide']; + /** Update a checklist display. */ + put: operations["put_core-{_version}-checklist-update-display"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/paying-user/seen-completed-list': { + "/core/{_version}/verify/send": { parameters: { query?: never; header?: never; @@ -5208,14 +5362,15 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-checklist-paying-user-seen-completed-list']; + /** Send a verification link. */ + post: operations["post_core-{_version}-verify-send"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/get-started/init': { + "/core/{_version}/verify/validate": { parameters: { query?: never; header?: never; @@ -5224,14 +5379,16 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-checklist-get-started-init']; - delete?: never; + /** Validate JWT token. */ + post: operations["post_core-{_version}-verify-validate"]; + /** Validate JWT token. */ + delete: operations["delete_core-{_version}-verify-validate"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/paying-user/init': { + "/core/{_version}/verify/email": { parameters: { query?: never; header?: never; @@ -5240,14 +5397,15 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-checklist-paying-user-init']; + /** Trigger ownership verification using email only. */ + post: operations["post_core-{_version}-verify-email"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/check-item': { + "/core/{_version}/verify/phone": { parameters: { query?: never; header?: never; @@ -5255,15 +5413,16 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-checklist-check-item']; - post?: never; + put?: never; + /** Trigger ownership verification on phone number only. */ + post: operations["post_core-{_version}-verify-phone"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/update-display': { + "/core/{_version}/verify/reauth/email": { parameters: { query?: never; header?: never; @@ -5271,15 +5430,16 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-checklist-update-display']; - post?: never; + put?: never; + /** Re-authenticate by verifying email and add Password scope to the session if the verification is successful. */ + post: operations["post_core-{_version}-verify-reauth-email"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/send': { + "/core/{_version}/verify/reauth/phone": { parameters: { query?: never; header?: never; @@ -5288,50 +5448,49 @@ export interface paths { }; get?: never; put?: never; - /** Send a verification link. */ - post: operations['post_core-{_version}-verify-send']; + /** Re-authenticate by verifying phone and add Password scope to the session if the verification is successful. */ + post: operations["post_core-{_version}-verify-reauth-phone"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/validate': { + "/core/{_version}/notifications": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get all the notifications. */ + get: operations["get_core-{_version}-notifications"]; put?: never; - /** Validate JWT token. */ - post: operations['post_core-{_version}-verify-validate']; - /** Validate JWT token. */ - delete: operations['delete_core-{_version}-verify-validate']; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/email': { + "/core/{_version}/connection-information": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Provides information about current user's connection, i.e. whether the user is connected to our VPN server. */ + get: operations["get_core-{_version}-connection-information"]; put?: never; - /** Trigger ownership verification using email only. */ - post: operations['post_core-{_version}-verify-email']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/phone': { + "/core/v4/labels/by-ids": { parameters: { query?: never; header?: never; @@ -5340,15 +5499,18 @@ export interface paths { }; get?: never; put?: never; - /** Trigger ownership verification on phone number only. */ - post: operations['post_core-{_version}-verify-phone']; + /** + * Get user labels by IDs. + * @deprecated + */ + post: operations["post_core-v4-labels-by-ids"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/reauth/email': { + "/core/v5/labels/by-ids": { parameters: { query?: never; header?: never; @@ -5357,40 +5519,50 @@ export interface paths { }; get?: never; put?: never; - /** Re-authenticate by verifying email and add Password scope to the session if the verification is successful. */ - post: operations['post_core-{_version}-verify-reauth-email']; + /** Get user labels by IDs. */ + post: operations["post_core-v5-labels-by-ids"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/reauth/phone': { + "/core/{_version}/labels": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get user's labels. */ + get: operations["get_core-{_version}-labels"]; put?: never; - /** Re-authenticate by verifying phone and add Password scope to the session if the verification is successful. */ - post: operations['post_core-{_version}-verify-reauth-phone']; - delete?: never; + /** Create new label. */ + post: operations["post_core-{_version}-labels"]; + /** Delete multiple labels. */ + delete: operations["delete_core-{_version}-labels"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/notifications': { + "/core/{_version}/labels/available": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get all the notifications. */ - get: operations['get_core-{_version}-notifications']; + /** + * Check Label name availability. + * @description Validates that a name is available for creation. + * For labels and folders, it must be a unique name at the root label. + * + * If a ParentID is passed, it must be for folders only and the uniqueness is checked only under that parent folder. + * + * The name can't be a reserved name like `Inbox`, `Sent`, ... + */ + get: operations["get_core-{_version}-labels-available"]; put?: never; post?: never; delete?: never; @@ -5399,7 +5571,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/labels/{enc_id}': { + "/core/{_version}/labels/order": { parameters: { query?: never; header?: never; @@ -5407,16 +5579,16 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; + /** Change label priority. */ + put: operations["put_core-{_version}-labels-order"]; post?: never; delete?: never; options?: never; head?: never; - /** Patch existing label. */ - patch: operations['patch_core-v4-labels-{enc_id}']; + patch?: never; trace?: never; }; - '/core/v4/labels/by-ids': { + "/core/{_version}/labels/order/tree/{startLabelId}": { parameters: { query?: never; header?: never; @@ -5424,52 +5596,61 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - /** Get user labels by IDs. */ - post: operations['post_core-v4-labels-by-ids']; + put: operations["put_core-{_version}-labels-order-tree-{startLabelId}"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/labels': { + "/core/{_version}/labels/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get user's labels. */ - get: operations['get_core-{_version}-labels']; + get?: never; + /** Update existing label. */ + put: operations["put_core-{_version}-labels-{id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/labels/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; put?: never; - /** Create new label. */ - post: operations['post_core-{_version}-labels']; - /** Delete multiple labels. */ - delete: operations['delete_core-{_version}-labels']; + post?: never; + /** Delete a label. */ + delete: operations["delete_core-{_version}-labels-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/labels/available': { + "/core/{_version}/labels/{enc_labelID}/detach": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; /** - * Check Label name availability. - * @description Validates that a name is available for creation. - * For labels and folders, it must be a unique name at the root label. - * - * If a ParentID is passed, it must be for folders only and the uniqueness is checked only under that parent folder. - * - * The name can't be a reserved name like `Inbox`, `Sent`, ... + * Detach messages from the label. + * @description Remove the label from all messages that have it. It deletes the MessageLabels entries in the db. */ - get: operations['get_core-{_version}-labels-available']; - put?: never; + put: operations["put_core-{_version}-labels-{enc_labelID}-detach"]; post?: never; delete?: never; options?: never; @@ -5477,7 +5658,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/labels/order': { + "/core/v4/labels/{enc_id}": { parameters: { query?: never; header?: never; @@ -5485,8 +5666,24 @@ export interface paths { cookie?: never; }; get?: never; - /** Change label priority. */ - put: operations['put_core-{_version}-labels-order']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch existing label */ + patch: operations["patch_core-v4-labels-{enc_id}"]; + trace?: never; + }; + "/core/{_version}/referrals/identifiers/{identifier}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-referrals-identifiers-{identifier}"]; + put?: never; post?: never; delete?: never; options?: never; @@ -5494,15 +5691,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/labels/order/tree/{startLabelId}': { + "/core/{_version}/referrals/info": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-labels-order-tree-{startLabelId}']; + get: operations["get_core-{_version}-referrals-info"]; + put?: never; post?: never; delete?: never; options?: never; @@ -5510,16 +5707,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/labels/{id}': { + "/core/{_version}/referrals/status": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Update existing label. */ - put: operations['put_core-{_version}-labels-{id}']; + get: operations["get_core-{_version}-referrals-status"]; + put?: never; post?: never; delete?: never; options?: never; @@ -5527,24 +5723,39 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/labels/{enc_id}': { + "/core/{_version}/trials/{referralIdentifier}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-trials-{referralIdentifier}"]; put?: never; post?: never; - /** Delete a label. */ - delete: operations['delete_core-{_version}-labels-{enc_id}']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/referrals": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-referrals"]; + put?: never; + post: operations["post_core-{_version}-referrals"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/labels/{enc_labelID}/detach': { + "/core/{_version}/referrals/register": { parameters: { query?: never; header?: never; @@ -5552,11 +5763,23 @@ export interface paths { cookie?: never; }; get?: never; - /** - * Detach messages from the label. - * @description Remove the label from all messages that have it. It deletes the MessageLabels entries in the db. - */ - put: operations['put_core-{_version}-labels-{enc_labelID}-detach']; + put?: never; + post: operations["post_core-{_version}-referrals-register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v5/entitlements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v5-entitlements"]; + put?: never; post?: never; delete?: never; options?: never; @@ -5564,7 +5787,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/images': { + "/core/{_version}/images": { parameters: { query?: never; header?: never; @@ -5572,7 +5795,24 @@ export interface paths { cookie?: never; }; /** Get image through proxy. */ - get: operations['get_core-{_version}-images']; + get: operations["get_core-{_version}-images"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/checklist/{checklistType}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a checklist. */ + get: operations["get_core-{_version}-checklist-{checklistType}"]; put?: never; post?: never; delete?: never; @@ -5587,11 +5827,11 @@ export interface components { schemas: { /** * ProtonResponseCode - * @enum {integer} + * @constant */ ResponseCodeSuccess: 1000; ProtonSuccess: { - Code: components['schemas']['ResponseCodeSuccess']; + Code: components["schemas"]["ResponseCodeSuccess"]; }; ProtonError: { /** ErrorCode */ @@ -5602,36 +5842,63 @@ export interface components { Details: Record; }; DriveConstants: { - /** @enum {integer} */ + /** @constant */ BlockMaxSizeInBytes?: 5300000; - /** @enum {integer} */ + /** @constant */ ThumbnailMaxSizeInBytes?: 65536; - /** @enum {integer} */ + /** @constant */ DraftRevisionLifetimeInSec?: 14400; - /** @enum {integer} */ + /** @constant */ ExtendedAttributesMaxSizeInBytes?: 65535; - /** @enum {integer} */ + /** @constant */ UploadTokenExpirationTimeInSec?: 10800; - /** @enum {integer} */ + /** @constant */ DownloadTokenExpirationTimeInSec?: 1800; }; - CreateLegacyKeyInput: { - AddressID: components['schemas']['EncryptedId']; - PrivateKey: components['schemas']['PGPPrivateKey']; - /** @example 1 */ - Primary?: number | null; - SignedKeyList: components['schemas']['SignedKeyListInput']; - AddressForwardingID: Record; - /** @default null */ - GroupMemberID: Record | null; - /** @default null */ - Signature: string | null; - /** @default null */ - OrgToken: string | null; - /** @default null */ - OrgSignature: string | null; - /** @default null */ - Token: string | null; + SignedKeyListInput: { + /** @example JSON.stringify([{""Fingerprint"": ""fde90483475164ec6353c93f767df53b0ca8395c"",""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Primary"": 1,""Flags"": 3}]) */ + Data: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature: string; + }; + AddressKeyInput: { + /** + * @description The address ID + * @example ACXDmTa...Bub14w== + */ + AddressID: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + /** @example -----BEGIN PGP MESSAGE-----.* */ + Token: string; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + Signature: string; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + /** @example 3 */ + Revision: Record; + }; + AuthInput: { + /** @example 4 */ + Version: number; + /** @example */ + ModulusID: string; + /** @example */ + Salt: string; + /** @example */ + Verifier: string; + }; + /** Signed Key List */ + KTAddressListTransformer: { + /** + * @description JSON-encoded content of the SAL + * @example [{"Email": "test@example.com","Flags": 1}] + */ + Data: string; + /** + * @description The armored signature over the JSON-serialized data with the primary user key + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + Signature: string; }; SetupKeyInput: { /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ @@ -5651,17 +5918,52 @@ export interface components { * @example -----BEGIN PGP MESSAGE-----.* */ OrgActivationToken: string; - AddressKeys: components['schemas']['AddressKeyInput5'][]; - Auth: components['schemas']['AuthInput2']; - AddressList: components['schemas']['KTAddressListTransformer']; + AddressKeys: components["schemas"]["AddressKeyInput"][]; + /** @description include to enable 1 password mode, otherwise 2 password mode */ + Auth: components["schemas"]["AuthInput"]; + AddressList: components["schemas"]["KTAddressListTransformer"]; /** * @description base64 encoded AES-GCM encrypted secret using the DeviceSecret as key * @example dzOtLW5psxgB8oNc8On...oFRykab4EW1ka3GtQPF9x */ EncryptedSecret: string; }; + /** @description An encrypted ID */ + EncryptedId: string; + /** @description An armored PGP Private Key */ + PGPPrivateKey: string; + CreateLegacyKeyInput: { + AddressID: components["schemas"]["EncryptedId"]; + PrivateKey: components["schemas"]["PGPPrivateKey"]; + /** @example 1 */ + Primary?: number | null; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + AddressForwardingID: Record; + /** @default null */ + GroupMemberID: Record | null; + /** @default null */ + Signature: string | null; + /** @default null */ + OrgToken: string | null; + /** @default null */ + OrgSignature: string | null; + /** @default null */ + Token: string | null; + }; SignedKeyListInputWrapper: { - SignedKeyList: components['schemas']['SignedKeyListInput']; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + }; + Fido2Input: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData: string; + /** @description signature (base64) returned from the client authentication library */ + Signature: string; + /** @description CredentialID used */ + CredentialID: Record[]; }; UpdateKeyInput: { /** @example */ @@ -5683,7 +5985,7 @@ export interface components { * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ OrganizationKey: string; - Auth: components['schemas']['AuthInput2']; + Auth: components["schemas"]["AuthInput"]; /** * @description Optional, for inline re-authentication * @example @@ -5704,13 +6006,25 @@ export interface components { * @example 123456 or recovery code */ TwoFactorCode: string; - FIDO2: components['schemas']['Fido2Input']; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2: components["schemas"]["Fido2Input"]; /** * @description Required only when the session is SSO, base64 encoded AES-GCM encrypted secret using the DeviceSecret as key * @example */ EncryptedSecret: string; }; + /** @description An encrypted ID */ + Id: string; + LatestEventResponse: { + EventID?: components["schemas"]["Id"] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; LogoRequest: { /** * The percent encoded address. Either Domain or Address are required. @@ -5734,7 +6048,7 @@ export interface components { * @default light * @enum {string} */ - Mode: 'light' | 'dark'; + Mode: "light" | "dark"; /** * The bimi-selector of the message * @default default @@ -5752,7 +6066,7 @@ export interface components { * @default null * @enum {string|null} */ - Format: 'png' | null; + Format: "png" | null; ComputedAddress: string; }; CreateAddressInput: { @@ -5775,14 +6089,19 @@ export interface components { Signature: string; MemberID: Record; RequesterMemberId?: number | null; - AddressList: components['schemas']['KTAddressListTransformer']; + AddressList: components["schemas"]["KTAddressListTransformer"]; + }; + BadRequestResponse: { + Error: string; + /** ProtonErrorResponseCode */ + Code: number; }; ReorderAddressesInput: { /** @description Will amend the order of addresses with the order of the corresponding AddressIDs */ AddressIDs: string[]; }; AddressListInput: { - AddressList: components['schemas']['KTAddressListTransformer']; + AddressList: components["schemas"]["KTAddressListTransformer"]; }; ChangeAddressTypeInput: { /** @@ -5791,7 +6110,7 @@ export interface components { */ Type: number; /** @default null */ - SignedKeyList: components['schemas']['SignedKeyListInput'] | null; + SignedKeyList: components["schemas"]["SignedKeyListInput"] | null; }; RenameUnverifiedAddressInput: { /** @example me */ @@ -5801,7 +6120,7 @@ export interface components { * @example funoccupied.com */ Domain: string; - AddressList: components['schemas']['KTAddressListTransformer']; + AddressList: components["schemas"]["KTAddressListTransformer"]; AddressKeys: { /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ ID?: string; @@ -5821,12 +6140,50 @@ export interface components { */ Signature: string; }; + /** Signed Key List */ + KTKeyList: { + /** + * @description Starting Epoch ID for SKL. Can be null, if the epoch is not yet released + * @example 125 + */ + MinEpochID?: number | null; + /** + * @description Ending Epoch ID for SKL. Can be null, if the epoch is not yet released + * @example 241 + */ + MaxEpochID?: number | null; + /** + * @description If epoch is not yet released this will be a future epoch ID + * @example 265 + */ + ExpectedMinEpochID?: number | null; + /** + * @description JSON-encoded content of the SKL. If null, this SKL contains an ObsolescenceToken + * @example [{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1},{""Fingerprint"": ""fde90483475164ec6353c93f767df53b0ca8395c"",""Primary"": 1,""Flags"": 3}] + */ + Data?: string | null; + /** + * @description Hex token to prove the obsolescence of the signed key list in the merkle tree or null. The first 16 characters are a committed big-endian hex-encoded unix timestamp, remaining is random + * @example 000000006243460497f838b649439b5f29c4e73014b9da096d0fe3ed + */ + ObsolescenceToken?: string | null; + /** + * @description Armored OpenPGP signature for the data. If null, proof contains an obsolescenceToken + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + Signature?: string | null; + /** + * @description Identifier of the revision version + * @example 42 + */ + Revision: number; + }; UpdateEncryptionSignatureFlagsInput: { /** @example 1 */ Encrypt: number; /** @example 1 */ Sign: number; - SignedKeyList: components['schemas']['KTKeyList']; + SignedKeyList: components["schemas"]["KTKeyList"]; }; AddressIdsInput: { /** @description List of encrypted addressIDs */ @@ -5834,16 +6191,42 @@ export interface components { /** @description Permissions bit to apply */ Permissions: (1 | 2 | 8 | 16)[]; }; - /** @description An encrypted ID */ - Id: string; - ResetAuthDevicesInput: { - AuthDeviceID: components['schemas']['Id']; - EncryptedSecret: components['schemas']['BinaryString']; - /** @description List of re-encrypted user keys secret to random generated secret (32 bytes, then hex encoded) */ - UserKeys: components['schemas']['ResetAuthDevicesUserKeyDto'][]; + UnprocessableResponse: { + Error: string; + /** ProtonErrorResponseCode */ + Code: number; }; - CreateMemberKeysInput: { - /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + /** @description Base64 encoded binary data */ + BinaryString: string; + ResetAuthDevicesUserKeyDto: { + ID: components["schemas"]["EncryptedId"]; + /** @description Re-encrypted user key secret to random generated secret (32 bytes, then b64 encoded) */ + PrivateKey: components["schemas"]["PGPPrivateKey"]; + }; + ResetAuthDevicesInput: { + /** @description The member's device making the request */ + AuthDeviceID: components["schemas"]["Id"]; + /** @description base64 encoded AES-GCM encrypted to a random secret */ + EncryptedSecret: components["schemas"]["BinaryString"]; + /** @description List of re-encrypted user keys secret to random generated secret (32 bytes, then hex encoded) */ + UserKeys: components["schemas"]["ResetAuthDevicesUserKeyDto"][]; + }; + CreateOrganizationInput: { + /** @description The encrypted address ID to associate with this organization */ + AddressID: components["schemas"]["EncryptedId"]; + }; + CreateOrganizationOutput: { + /** @description ID of the created organization */ + OrganizationID: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateMemberKeysInput: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ AddressID: string; /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ PrivateKey: string; @@ -5862,16 +6245,165 @@ export interface components { Signature?: string; }; }; - CreateScimTenantInput: { - /** @description The password for the SCIM tenant, used for the integration with the IdP */ - Password: string; - }; AddNewUserKeyInput: { /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ PrivateKey: string; /** @example 1 */ Primary: Record; }; + SuccessfulResponse: { + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + PutDomainFlagsInput: { + /** @default null */ + AllowedForMail: Record | null; + /** @default null */ + AllowedForSSO: Record | null; + }; + OffsetPagination: { + /** The page size */ + PageSize: number; + /** + * The page index using 0-based indexing + * @default 0 + */ + Page: number; + }; + /** + * @description
See values descriptions
ValueNameDescription
1Proton* Proton-owned consumer domain, e.g. pm.me
2ProtonSub* Subdomain on Proton-owned consumer domain, e.g. blah.pm.me
3Custom* Custom domain owned by an organization
+ * @enum {integer} + */ + DomainType: 1 | 2 | 3; + /** + * @description
See values descriptions
ValueNameDescription
0Default* A domain's default state before verify or after deactivation
1Active* Active once domain is verified
2Warning* Detected backward DNS change after Active
+ * @enum {integer} + */ + DomainState: 0 | 1 | 2; + /** + * @description
See values descriptions
ValueNameDescription
0Default* Domain is not verified
1Exists* There exists a verification code in DNS, but the code does not match the DB for the particular domain
2Good* There exists a verification code in DNS that matches the DB for the particular domain
+ * @enum {integer} + */ + DomainVerifyState: 0 | 1 | 2; + /** + * @description
See values descriptions
ValueDescription
0Default
1NotProton
2WrongPriority
3Good
4Backup
+ * @enum {integer} + */ + DomainMxState: 0 | 1 | 2 | 3 | 4; + /** + * @description
See values descriptions
ValueDescription
0Default
1NotProton
2Multiple
3Good
+ * @enum {integer} + */ + DomainSpfState: 0 | 1 | 2 | 3; + /** + * @description
See values descriptions
ValueDescription
0Default
1WrongFormat
2Multiple
3Good
4Relaxed
+ * @enum {integer} + */ + DomainDmarcState: 0 | 1 | 2 | 3 | 4; + /** + * @description
See values descriptions
ValueDescription
0Default
1FormatWrong
2Multiple
3Invalid
4Good
5Parent
6Warning
+ * @enum {integer} + */ + DomainDkimState: 0 | 1 | 2 | 3 | 4 | 5 | 6; + /** + * @description
See values descriptions
ValueDescription
0RSA1024
1RSA2048
+ * @enum {integer} + */ + KeyAlgorithm: 0 | 1; + /** + * @description
See values descriptions
ValueDescription
0Active
1Pending
2Retired
3Deceased
+ * @enum {integer} + */ + DomainKeyPairState: 0 | 1 | 2 | 3; + /** + * @description
See values descriptions
ValueDescription
0NotSet
1Good
2Invalid
+ * @enum {integer} + */ + DnsState: 0 | 1 | 2; + DkimConfigKeyOutput: { + ID: components["schemas"]["Id"]; + /** @example protonmail2 */ + Selector: string; + /** @example MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0zc0kqr7bnFOD1TmsjJmYthy41QeI1cqga5yU8... */ + PublicKey: string; + Algorithm: components["schemas"]["KeyAlgorithm"]; + State: components["schemas"]["DomainKeyPairState"]; + DNSState: components["schemas"]["DnsState"]; + /** + * Format: timestamp + * @example 1687942995 + */ + CreateTime: number; + }; + DkimConfigItemOutput: { + /** @example protonmail2._domainkey */ + Hostname: string; + /** @example protonmail2.domainkey.dhgge2q6ksokiqwomdn23r6nnjjwiwblsujm6bjdnj3hhaxlktpqa.domains.proton.ch. */ + CNAME?: string | null; + Key?: components["schemas"]["DkimConfigKeyOutput"] | null; + }; + DkimConfigsOutput: { + State: components["schemas"]["DomainDkimState"]; + /** @description Contains the domain's currently configured DKIM public keys and metadata */ + Config: components["schemas"]["DkimConfigItemOutput"][]; + }; + DomainFlagsOutput: { + /** @description If the domain is intended to be used for custom addresses */ + "mail-intent": boolean; + /** @description If the domain is intended to be used for SSO integration */ + "sso-intent": boolean; + /** @description If the domain is under the Dark Web Monitoring service */ + "dark-web-monitoring": boolean; + }; + DomainOutput: { + ID: components["schemas"]["Id"]; + /** @example protonvpn.ch */ + DomainName: string; + Type: components["schemas"]["DomainType"]; + State: components["schemas"]["DomainState"]; + /** + * Format: timestamp + * @example 1556136548 + */ + LastActiveTime: number; + /** + * Format: timestamp + * @example 1446095611 + */ + CheckTime: number; + /** + * Format: timestamp + * @example 1554807818 + */ + WarnTime: number; + /** @example protonmail-verification=c701a28e2bdd3358c6dda71a3008b806e41950b0 */ + VerifyCode: string; + VerifyState: components["schemas"]["DomainVerifyState"]; + MxState: components["schemas"]["DomainMxState"]; + SpfState: components["schemas"]["DomainSpfState"]; + DmarcState: components["schemas"]["DomainDmarcState"]; + DKIM: components["schemas"]["DkimConfigsOutput"]; + Flags: components["schemas"]["DomainFlagsOutput"]; + }; + GetDomainsResponse: { + Domains: components["schemas"]["DomainOutput"][]; + Total: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @description
See values descriptions
ValueDescription
1True
0False
+ * @enum {integer} + */ + BoolInt: 1 | 0; CreateDomainInput: { /** @example funoccupied.com */ Name: string; @@ -5892,18 +6424,20 @@ export interface components { * @example */ AddressID?: string | null; - AddressList: components['schemas']['KTAddressListTransformer']; + /** @description Both when setting and unsetting an address this has to be signed with the address owner's primary user key */ + AddressList: components["schemas"]["KTAddressListTransformer"]; }; - OffsetPagination: { - /** The page size */ - PageSize: number; - /** - * The page index using 0-based indexing - * @default 0 - */ - Page: number; + ChildOrganizationDto: { + /** @description The ID of the organization */ + ID: components["schemas"]["Id"]; + /** @description The name of the organization */ + Name: string; }; - SuccessfulResponse: { + GetChildOrganizationsResponse: { + /** @description List of child organizations */ + Organizations: components["schemas"]["ChildOrganizationDto"][]; + /** @description Total number of child organizations */ + Total: number; /** * ProtonResponseCode * @example 1000 @@ -5911,30 +6445,111 @@ export interface components { */ Code: 1000; }; + /** @description An armored PGP Signature */ + PGPSignature: string; + AcceptGroupOwnerInviteRequest: { + TokenKeyPacket: string; + TokenSignaturePacket: components["schemas"]["PGPSignature"]; + }; + /** + * @description
See values descriptions
ValueDescription
0Internal
1External
2InternalTypeExternal
+ * @enum {integer} + */ + GroupMemberType: 0 | 1 | 2; + /** @description An armored PGP Message */ + PGPMessage: string; + GroupProxyInstance: { + PgpVersion: number; + GroupAddressKeyFingerprint: string; + GroupMemberAddressKeyFingerprint: string; + ProxyParam: string; + }; AddGroupMemberRequest: { - Type: components['schemas']['GroupMemberType']; - GroupID: components['schemas']['Id']; + Type: components["schemas"]["GroupMemberType"]; + GroupID: components["schemas"]["Id"]; Email: string; - AddressSignaturePacket: components['schemas']['PGPSignature']; - GroupMemberAddressPrivateKey?: components['schemas']['PGPPrivateKey'] | null; - ActivationToken?: components['schemas']['PGPMessage'] | null; - ProxyInstances: components['schemas']['GroupProxyInstance'][]; - Token?: components['schemas']['PGPMessage'] | null; - Signature?: components['schemas']['PGPSignature'] | null; + AddressSignaturePacket: components["schemas"]["PGPSignature"]; + GroupMemberAddressPrivateKey?: components["schemas"]["PGPPrivateKey"] | null; + ActivationToken?: components["schemas"]["PGPMessage"] | null; + ProxyInstances: components["schemas"]["GroupProxyInstance"][]; + Token?: components["schemas"]["PGPMessage"] | null; + Signature?: components["schemas"]["PGPSignature"] | null; + }; + /** + * @description
See values descriptions
ValueDescription
0Pending
1Active
2Outdated
3Paused
4Rejected
+ * @enum {integer} + */ + GroupMemberState: 0 | 1 | 2 | 3 | 4; + Bitmask: Record; + GroupMemberPermissionsBitmask: { + Bitmask: components["schemas"]["Bitmask"]; + }; + /** GroupMemberResponse */ + GroupMemberOutput: { + ID: components["schemas"]["Id"]; + Type: components["schemas"]["GroupMemberType"]; + State: components["schemas"]["GroupMemberState"]; + CreateTime: number; + GroupID: components["schemas"]["Id"]; + AddressID?: components["schemas"]["Id"] | null; + Email?: string | null; + Permissions: components["schemas"]["GroupMemberPermissionsBitmask"]; + }; + GroupMemberResponse: { + GroupMember: components["schemas"]["GroupMemberOutput"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; }; + AddGroupOwnerRequest: { + MemberID: components["schemas"]["Id"]; + TokenKeyPacket: components["schemas"]["BinaryString"]; + TokenSignaturePacket: components["schemas"]["PGPSignature"]; + }; + /** + * @description
See values descriptions
ValueDescription
0NobodyCanSend
1GroupMembersCanSend
2OrgMembersCanSend
3EveryoneCanSend
+ * @enum {integer} + */ + GroupPermissions: 0 | 1 | 2 | 3; + /** + * @description
See values descriptions
ValueDescription
0OnlyAdmins
1OrgMembers
+ * @enum {integer} + */ + GroupVisibility: 0 | 1; CreateGroupRequest: { Email: string; Name: string; - Permissions: components['schemas']['GroupPermissions']; - Flags: components['schemas']['GroupFlags']; + Permissions: components["schemas"]["GroupPermissions"]; + Flags: number; + GroupVisibility?: components["schemas"]["GroupVisibility"]; + /** @default 3 */ + MemberVisibility: number; /** @default */ Description: string; }; EditGroupMemberRequest: { - Permissions: components['schemas']['GroupMemberPermissions']; + Permissions: components["schemas"]["GroupMemberPermissionsBitmask"]; + }; + GroupMembershipGroup: { + ID: components["schemas"]["Id"]; + Name: string; + Address: string; + }; + ExternalGroupMembership: { + ID: components["schemas"]["Id"]; + /** Format: timestamp */ + CreateTime: number; + State: components["schemas"]["GroupMemberState"]; + Type: components["schemas"]["GroupMemberType"]; + Email?: string | null; + Permissions: components["schemas"]["GroupMemberPermissionsBitmask"]; + Group: components["schemas"]["GroupMembershipGroup"]; }; ExternalGroupMembershipsResponse: { - Memberships: components['schemas']['ExternalGroupMembership'][]; + Memberships: components["schemas"]["ExternalGroupMembership"][]; Total: number; /** * ProtonResponseCode @@ -5943,10 +6558,8 @@ export interface components { */ Code: 1000; }; - /** @description An encrypted ID */ - EncryptedId: string; GroupMembersResponse: { - Members: components['schemas']['GroupMember'][]; + Members: components["schemas"]["GroupMemberOutput"][]; Total: number; /** * ProtonResponseCode @@ -5955,8 +6568,46 @@ export interface components { */ Code: 1000; }; + /** GroupOwnerInviteResponse */ + GroupOwnerInvite: { + GroupOwnerInviteID: components["schemas"]["Id"]; + EncryptionAddressID: components["schemas"]["Id"]; + SignatureAddress: string; + Token: components["schemas"]["PGPMessage"]; + TokenSignaturePacket: components["schemas"]["PGPSignature"]; + }; + GetGroupOwnerInvitesResponse: { + Invites: components["schemas"]["GroupOwnerInvite"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ForwardingKeys: { + PrivateKey?: components["schemas"]["PGPPrivateKey"] | null; + ActivationToken?: components["schemas"]["PGPMessage"] | null; + }; + InternalGroupMembership: { + ID: components["schemas"]["Id"]; + /** Format: timestamp */ + CreateTime: number; + State: components["schemas"]["GroupMemberState"]; + Type: components["schemas"]["GroupMemberType"]; + AddressID?: components["schemas"]["Id"] | null; + Email?: string | null; + Permissions: components["schemas"]["GroupMemberPermissionsBitmask"]; + TokenKeyPacket?: components["schemas"]["BinaryString"] | null; + TokenSignaturePacket?: components["schemas"]["BinaryString"] | null; + AddressSignaturePacket?: components["schemas"]["BinaryString"] | null; + Group: components["schemas"]["GroupMembershipGroup"]; + ForwardingKeys: components["schemas"]["ForwardingKeys"]; + GroupID: components["schemas"]["Id"]; + AddressId?: components["schemas"]["Id"] | null; + }; InternalGroupMembershipsResponse: { - Memberships: components['schemas']['InternalGroupMembership'][]; + Memberships: components["schemas"]["InternalGroupMembership"][]; Total: number; /** * ProtonResponseCode @@ -5965,6 +6616,12 @@ export interface components { */ Code: 1000; }; + InviteGroupOwnerRequest: { + GroupMemberID: components["schemas"]["Id"]; + EncryptionAddress: string; + TokenKeyPacket: string; + TokenSignaturePacket: components["schemas"]["PGPSignature"]; + }; UpdateGroupRequest: { /** @default null */ Name: string | null; @@ -5979,24 +6636,42 @@ export interface components { */ Email: string | null; /** @default null */ - Permissions: components['schemas']['GroupPermissions'] | null; + Permissions: components["schemas"]["GroupPermissions"] | null; + /** @default null */ + Flags: number | null; + /** @default null */ + GroupVisibility: components["schemas"]["GroupVisibility"] | null; /** @default null */ - Flags: components['schemas']['GroupFlags'] | null; + MemberVisibility: number | null; /** @default null */ Description: string | null; }; - AddressKeyInput: Record; AddressKeyInput2: Record; - AddressKeyInput3: Record; - AddressKeyInput4: Record; UpdateFlagsInput: { /** @example 1 */ Flags: number; - SignedKeyList: components['schemas']['SignedKeyListInput']; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + }; + AddressKeyToken: { + /** + * @description Encrypted Address key ID to replace the token + * @example G1MbEt3Ep5P_E...Wz8WbHVAOl_6h== + */ + AddressKeyID: string; + /** + * @description Base-64 encoded key packet + * @example slCpH6qWMKGQ7d...R4eLU2+2BZvK0UeG/QY2 + */ + KeyPacket: string; + /** + * @description Token signature produced with the primary user key + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + Signature: string; }; ReplaceAddressTokensInput: { /** @description List of address key tokens encrypted to the primary user key */ - AddressKeyTokens: components['schemas']['AddressKeyToken'][]; + AddressKeyTokens: components["schemas"]["AddressKeyToken"][]; }; MigrateKeyInput: { AddressKeys: { @@ -6009,18 +6684,18 @@ export interface components { /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ Signature?: string; }[]; - SignedKeyLists: components['schemas']['SignedKeyListInput'][]; + SignedKeyLists: components["schemas"]["SignedKeyListInput"][]; }; LegacyKeyInput: { /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ PrivateKey: string; - SignedKeyList: components['schemas']['SignedKeyListInput']; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; }; ReactivateUserKeyInput: { /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ PrivateKey: string; AddressKeyFingerprints: string[]; - SignedKeyLists: components['schemas']['SignedKeyListInput'][]; + SignedKeyLists: components["schemas"]["SignedKeyListInput"][]; }; ResetUserKeyInput: { /** @@ -6055,15 +6730,55 @@ export interface components { * @example ----BEGIN PGP SIGNATURE-----.* */ Signature?: string; - SignedKeyList?: components['schemas']['SignedKeyListInput']; + /** @description In the signed key list there is only the new keys */ + SignedKeyList?: components["schemas"]["SignedKeyListInput"]; }[]; - Auth: components['schemas']['AuthInput2']; - AddressList: components['schemas']['KTAddressListTransformer']; + /** @description Include to enable 1 password mode, otherwise 2 password mode */ + Auth: components["schemas"]["AuthInput"]; + AddressList: components["schemas"]["KTAddressListTransformer"]; /** @default null */ OrgPrimaryUserKey: string | null; /** @default null */ OrgActivationToken: string | null; }; + LinkOrganizationInput: { + /** + * @description ID of the parent organization + * @default null + */ + ParentID: number | null; + /** + * @description Token key packet from the parent organization, encrypted to the user key + * @default null + */ + ParentOrgTokenKeyPacket: components["schemas"]["BinaryString"] | null; + /** + * @description Signature of the token key packet made by the parent organization key + * @default null + */ + ParentOrgSignature: components["schemas"]["PGPSignature"] | null; + }; + /** + * @description

Either 1=PROTON or 2=MANAGED (default)

See values descriptions
ValueDescription
1Proton
2Managed
3External
4CredentialLess
+ * @enum {integer} + */ + UserType: 1 | 2 | 3 | 4; + MagicLinkInvitationInput: { + /** + * @description Invitation data containing address and expected KT revision + * @example {"Address":"member@internal-domain.com", "Revision":2} + */ + Data: Record; + Signature?: components["schemas"]["PGPSignature"] | null; + /** + * @description The email to send an invitation to + * @example some.user@example.com + */ + Email: string; + /** @description Whether the member should remain private after creation or be unprivatized */ + PrivateIntent: boolean; + }; + AuthInfoInput: Record; CreateMemberInput: { /** @example Jason */ Name: string; @@ -6077,15 +6792,15 @@ export interface components { /** @example 0 */ MaxVPN: number; /** @description Either 1=PROTON or 2=MANAGED (default) */ - Type?: components['schemas']['UserType'] | null; + Type: components["schemas"]["UserType"]; /** * @description Use only if type is 1=PROTON * @example user_name */ Username: string; /** @description Invitation object if created using magic link */ - Invitation?: components['schemas']['MagicLinkInvitationInput'] | null; - Auth: components['schemas']['AuthInfoInput']; + Invitation?: components["schemas"]["MagicLinkInvitationInput"] | null; + Auth: components["schemas"]["AuthInfoInput"]; /** * @default 0 * @enum {integer} @@ -6096,6 +6811,11 @@ export interface components { * @enum {integer} */ MaxLumo: 0 | 1; + /** + * @description True if the user has a temporary password, false otherwise + * @default false + */ + TemporaryPassword: boolean; }; CreateMemberInvitationInput: { /** @@ -6105,6 +6825,16 @@ export interface components { Email: string; /** @example 100 */ MaxSpace: number; + /** + * @default 0 + * @enum {integer} + */ + MaxAI: 0 | 1; + /** + * @default 0 + * @enum {integer} + */ + MaxLumo: 0 | 1; }; UpdateMemberInvitationInput: { /** @example 100 */ @@ -6116,31 +6846,59 @@ export interface components { }; AcceptMemberUnprivatizationInput: { /** @description The user keys encrypted to the token contained in OrgActivationToken */ - OrgUserKeys: components['schemas']['PGPPrivateKey'][]; - OrgActivationToken: components['schemas']['PGPMessage']; + OrgUserKeys: components["schemas"]["PGPPrivateKey"][]; + /** @description A 32-byte random token encoded as hex and encrypted to the organization key, signed with newly created address key. Context should be set to account.key-token.user-unprivatization */ + OrgActivationToken: components["schemas"]["PGPMessage"]; }; RequestMemberUnprivatizationInput: { /** * @description The invitation data - * @example {"Address":"member@internal-domain.com", "Revision":2} + * @example {"Address":"member@internal-domain.com", "Revision":2, "Admin":true} */ InvitationData: string; - InvitationSignature: components['schemas']['PGPSignature']; + /** @description The invitation signature */ + InvitationSignature: components["schemas"]["PGPSignature"]; }; + /** + * @description
See values descriptions
ValueDescription
1ManageForwarding
+ * @enum {integer} + */ + MemberPermission: 1; + /** + * @description
See values descriptions
ValueDescription
0Remove
1Add
+ * @enum {integer} + */ + MemberPermissionAction: 0 | 1; MemberManagePermissionsDto: { /** @description List of MemberIds */ Ids: string[]; - Permission: components['schemas']['MemberPermission']; - Action: components['schemas']['MemberPermissionAction']; + Permission: components["schemas"]["MemberPermission"]; + Action: components["schemas"]["MemberPermissionAction"]; }; - UpdateMemberKeysInput: { - /** - * Format: base64 - * @description random 16 bytes - * @example cmFuZGJhc2U2NHN0cmluZw== + UserKeyInput: { + PrivateKey: components["schemas"]["PGPPrivateKey"]; + OrgPrivateKey: components["schemas"]["PGPPrivateKey"]; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + OrgToken: string; + }; + AuthInfoInput2: { + /** @example */ + ModulusID: string; + /** @example */ + Salt: string; + /** @example */ + Verifier: string; + /** @description 4 is the current version, older versions are not accepted */ + Version: number; + }; + UpdateMemberKeysInput: { + /** + * Format: base64 + * @description random 16 bytes + * @example cmFuZGJhc2U2NHN0cmluZw== */ KeySalt: string; - UserKey: components['schemas']['UserKeyInput']; + UserKey: components["schemas"]["UserKeyInput"]; AddressKeys: { /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ AddressID?: string; @@ -6159,16 +6917,57 @@ export interface components { Signature?: string; }; }[]; - AddressList: components['schemas']['KTAddressListTransformer']; - Auth: components['schemas']['AuthInfoInput2']; + AddressList: components["schemas"]["KTAddressListTransformer"]; + /** @description Null when the member cannot login via password */ + Auth: components["schemas"]["AuthInfoInput2"]; + }; + UnprivatizeMemberUserKeyDto: { + OrgPrivateKey: components["schemas"]["PGPPrivateKey"]; + OrgToken: components["schemas"]["PGPMessage"]; + }; + UnprivatizeMemberAddressKeyDto: { + AddressKeyID: components["schemas"]["Id"]; + OrgTokenKeyPacket: components["schemas"]["BinaryString"]; + OrgSignature: components["schemas"]["PGPSignature"]; + }; + OrganizationKeyActivationDto: { + /** @description Token key packet encrypted to the user key of the member */ + TokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description Signature of the token key packet by the user key of the member */ + Signature: string; }; UnprivatizeMemberInput: { /** @deprecated */ - UserKey?: components['schemas']['UnprivatizeMemberUserKeyDto'] | null; + UserKey?: components["schemas"]["UnprivatizeMemberUserKeyDto"] | null; /** @description All active member's user keys, with a signed and encrypted token to access them via the org key */ - UserKeys?: components['schemas']['UnprivatizeMemberUserKeyDto'][] | null; + UserKeys?: components["schemas"]["UnprivatizeMemberUserKeyDto"][] | null; /** @description A token and signature for each address key to access them via the org key */ - AddressKeys: components['schemas']['UnprivatizeMemberAddressKeyDto'][]; + AddressKeys: components["schemas"]["UnprivatizeMemberAddressKeyDto"][]; + /** + * @description If the data requires them to become admin, the tokens to access the org key + * @default null + */ + OrganizationKeyActivation: components["schemas"]["OrganizationKeyActivationDto"] | null; + }; + CreateScimTenantInput: { + /** @description The password for the SCIM tenant, used for the integration with the IdP */ + Password: string; + }; + OrganizationLogo: { + /** + * @description The base64 encrypted logo + * @example iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjyPH+/x8ABZMCtpUrn90AAAAASUVORK5CYII= + */ + Image: string; + }; + UpdateScimTenantInput: { + /** @description The password for the SCIM tenant, used for the integration with the IdP. Unset or null will not modify the current password */ + Password?: string | null; + /** + * @description State of the SCIM integration: 0 for disabled, 1 for enabled + * @example 1 + */ + State: number; }; UpdateOrganizationKeyBackupInput: { /** @@ -6250,6 +7049,34 @@ export interface components { */ GracePeriod: number; }; + ReplaceOrganizationKeyInvitationDto: { + /** @description Member ID of the non-private admin */ + MemberID: components["schemas"]["Id"]; + /** @description Token key packet encrypted to the primary key of the recipient's primary address */ + TokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description Signature of the token key packet by the inviter's address key */ + Signature: components["schemas"]["PGPSignature"]; + /** @description The address ID of the signature address key */ + SignatureAddressID: components["schemas"]["Id"]; + /** @description The address ID of the address to which the token key packet is encrypted to */ + EncryptionAddressID: components["schemas"]["Id"]; + }; + ReplaceOrganizationKeyActivationDto: { + /** @description Member ID of the private admin */ + MemberID: components["schemas"]["Id"]; + /** @description Token key packet encrypted to the user key of the member */ + TokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description Signature of the token key packet by the user key of the member */ + Signature: components["schemas"]["PGPSignature"]; + }; + ReplaceOrganizationKeyLinkedOrgDto: { + /** @description ID of the linked sub-organization */ + OrganizationID: components["schemas"]["Id"]; + /** @description New ParentOrgTokenKeyPacket for the linked sub-organization, encrypted to the new primary org key */ + ParentOrgTokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description New ParentOrgTokenSignature for the linked sub-organization, signed with the new primary org key */ + ParentOrgTokenSignature: components["schemas"]["PGPSignature"]; + }; ReplaceOrganizationKeysInput: { /** * Format: base64 @@ -6354,28 +7181,59 @@ export interface components { OrgSignature?: string; }[]; }[]; + /** @description Array of AddressKey IDs, Tokens, and orgSignatures */ + GroupAddressKeyTokens: { + /** + * Format: encrypted string + * @description Encrypted Address key ID + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + ID?: string; + /** @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- */ + Token?: string; + /** @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ + OrgSignature?: string; + }[]; /** * @description Token needed to unlock the organization key, encrypted to the user key of the current user * @default null * @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- */ - Token: components['schemas']['PGPMessage'] | null; + Token: components["schemas"]["PGPMessage"] | null; /** * @description Signature of the token made by the user key of the current user * @default null * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ - Signature: components['schemas']['PGPSignature'] | null; + Signature: components["schemas"]["PGPSignature"] | null; /** * @description Invite all other private admins to the new key * @default null */ - AdminInvitations: components['schemas']['ReplaceOrganizationKeyInvitationDto'][] | null; + AdminInvitations: components["schemas"]["ReplaceOrganizationKeyInvitationDto"][] | null; /** * @description Activate new key for all other non-private admins * @default null */ - AdminActivations: components['schemas']['ReplaceOrganizationKeyActivationDto'][] | null; + AdminActivations: components["schemas"]["ReplaceOrganizationKeyActivationDto"][] | null; + /** + * @description For primary org key rotation: updated ParentOrgTokenKeyPacket and ParentOrgTokenSignature for each linked non-primary organization + * @default null + */ + LinkedOrganizations: components["schemas"]["ReplaceOrganizationKeyLinkedOrgDto"][] | null; + /** + * Format: base64 + * @description For non-primary org key rotation: new ParentOrgTokenKeyPacket encrypted to the parent org key + * @default null + * @example + */ + ParentOrgTokenKeyPacket: components["schemas"]["BinaryString"] | null; + /** + * @description For non-primary org key rotation: new ParentOrgTokenSignature signed with the parent org key + * @default null + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + ParentOrgTokenSignature: components["schemas"]["PGPSignature"] | null; }; ActivateOrganizationKeyInput: { /** @@ -6398,53 +7256,115 @@ export interface components { */ Signature: string | null; }; + MigrateOrganizationKeyInvitationDto: { + /** @description Member ID of the admin */ + MemberID: components["schemas"]["Id"]; + /** @description Token key packet encrypted with org key */ + TokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description Signature of the token key packet by org key */ + Signature: components["schemas"]["PGPSignature"]; + }; + MigrateOrganizationKeyActivationDto: { + /** @description Member ID of the admin */ + MemberID: components["schemas"]["Id"]; + /** @description Token key packet encrypted with primary user key */ + TokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description Signature of the token key packet by primary user key */ + Signature: components["schemas"]["PGPSignature"]; + }; MigrateOrganizationKeysInput: { - PrivateKey: components['schemas']['PGPPrivateKey']; - Token: components['schemas']['PGPMessage']; - Signature: components['schemas']['PGPSignature']; + /** @description Organization private key encrypted with token secret */ + PrivateKey: components["schemas"]["PGPPrivateKey"]; + /** @description Token needed to unlock the organization key, encrypted to the user key of the current user */ + Token: components["schemas"]["PGPMessage"]; + /** @description Signature of the token made by the user key of the current user */ + Signature: components["schemas"]["PGPSignature"]; /** * @description Activate key for other active private admins * @default null */ - AdminInvitations: components['schemas']['MigrateOrganizationKeyInvitationDto'][] | null; + AdminInvitations: components["schemas"]["MigrateOrganizationKeyInvitationDto"][] | null; /** * @description Activate new key for all other non-private admins * @default null */ - AdminActivations: components['schemas']['MigrateOrganizationKeyActivationDto'][] | null; + AdminActivations: components["schemas"]["MigrateOrganizationKeyActivationDto"][] | null; }; UpdateOrgKeyFingerprintSignatureInput: { - Signature: components['schemas']['PGPSignature']; - AddressID: components['schemas']['Id']; + /** @description The signature of the organization key fingerprint */ + Signature: components["schemas"]["PGPSignature"]; + /** @description ID of the address that signed the organization key fingerprint */ + AddressID: components["schemas"]["Id"]; + }; + /** + * @description

The state of the password policy. Disabled policies are not returned.

See values descriptions
ValueDescription
0Disabled
1Enabled
2Optional
+ * @enum {integer} + */ + PasswordPolicyState: 0 | 1 | 2; + OrganizationPasswordPolicyInputOutput: { + /** + * @description The name of the password policy. This serves as identifier. + * @example AtLeastOneSpecialCharacter + */ + PolicyName: string; + /** @description The state of the password policy. Disabled policies are not returned. */ + State: components["schemas"]["PasswordPolicyState"]; + /** + * @description The parameters of the policy. Most policies have no parameters. Here are the existing parameters per policy:
* AtLeastXCharacters: {"MinimumCharacters": \} + * @default null + * @example {"MinimumCharacters": 18} + */ + Parameters: unknown[] | null; }; - OrganizationSettings: { + UpdateOrganizationSettingsRequest: { /** * @description Whether to show organization name in sidebar or not - * @default false + * @default null * @example true */ - ShowName: boolean; + ShowName: boolean | null; /** * @description Whether to show the Scribe writing assistant or not - * @default true + * @default null * @example true */ - ShowScribeWritingAssistant: boolean; + ShowScribeWritingAssistant: boolean | null; /** - * @description Whether the video conferencing feature is enabled or not - * @default true + * @description Whether the Zoom video conferencing feature is enabled or not + * @default null * @example true */ - VideoConferencingEnabled: boolean; - /** @default null */ + VideoConferencingEnabled: boolean | null; + /** + * @description Whether the Meet video conferencing feature is enabled or not + * @default null + * @example true + */ + MeetVideoConferencingEnabled: boolean | null; + /** + * @description The ID of the organization's logo + * @default null + */ LogoID: string | null; - }; - OrganizationLogo: { /** - * @description The base64 encrypted logo - * @example iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjyPH+/x8ABZMCtpUrn90AAAAASUVORK5CYII= + * @description List of predefined products for which the non-admin members of the organization have access. If all products are allowed, the client must send ["All"]. The BE will never return ["All"]. + * @default null + * @example [ + * "VPN", + * "Pass" + * ] */ - Image: string; + AllowedProducts: unknown[] | null; + /** + * @description List of PasswordPolicies. Only the PasswordPolicies passed are updated. Absent PasswordPolicies remain in their current state. + * @default null + */ + PasswordPolicies: components["schemas"]["OrganizationPasswordPolicyInputOutput"][] | null; + /** + * @description Whether Mail Category view is enabled or not for members + * @default null + */ + MailCategoryViewEnabled: boolean | null; }; Sso: { /** @@ -6500,7 +7420,7 @@ export interface components { * @default [] */ EdugainAffiliations: string[]; - SsoId?: components['schemas']['Id'] | null; + SsoId?: components["schemas"]["Id"] | null; SendingSubject: boolean; }; SsoXml: { @@ -6599,11 +7519,22 @@ export interface components { * @default null */ WalletNews: boolean | null; + /** + * @description Proton Lumo new features + * @default null + */ + LumoNews: boolean | null; + /** + * @description Proton Meet new features + * @default null + */ + MeetNews: boolean | null; /** * @description In app notifications * @default null */ InAppNotifications: boolean | null; + Summary: unknown[]; }; UpdateNewsInput: { /** @@ -6674,9 +7605,25 @@ export interface components { */ SessionAccountRecovery: 0 | 1; }; + /** + * @description

Possible values:
- 0: Unset
- 1: Off
- 2: Server-Only
- 3: Client-Only

See values descriptions
ValueDescription
0Unset
1Off
2ServerOnly
3ClientOnly
+ * @enum {integer} + */ + AIAssistantFlags: 0 | 1 | 2 | 3; /** AIAssistantFlagsInput */ AIAssistantFlagsInput: { - AIAssistantFlags: components['schemas']['AIAssistantFlags']; + /** @description Possible values:
- 0: Unset
- 1: Off
- 2: Server-Only
- 3: Client-Only */ + AIAssistantFlags: components["schemas"]["AIAssistantFlags"]; + }; + /** + * @description

Whether the key should be made primary or non-primary

See values descriptions
ValueDescription
0NonPrimary
1Primary
+ * @enum {integer} + */ + KeyPriority: 0 | 1; + UpdateKeyPrimacyInput: { + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + /** @description Whether the key should be made primary or non-primary */ + Primary?: components["schemas"]["KeyPriority"]; }; UpdateMemberLumoEntitlementInput: { /** @enum {integer} */ @@ -6688,61 +7635,101 @@ export interface components { Product: number; Disabled: number; }; - UpdateScimTenantInput: { - /** @description The password for the SCIM tenant, used for the integration with the IdP. Unset or null will not modify the current password */ - Password?: string | null; - /** - * @description State of the SCIM integration: 0 for disabled, 1 for enabled - * @example 1 - */ - State: number; + AcceptInvitationValidation: { + /** @example true */ + Valid: boolean; + /** @example false */ + IsLifetimeAccount: boolean; + /** @example false */ + HasOrgWithMembers: boolean; + /** @example false */ + HasCustomDomains: boolean; + /** @example false */ + ExceedsMaxSpace: boolean; + /** @example false */ + ExceedsAddresses: boolean; + /** @example false */ + ExceedsMaxAcceptedInvitations: boolean; + /** @example false */ + OrgExceedsMaxAcceptedInvitations: boolean; + /** @example false */ + IsOnForbiddenPlan: boolean; + /** @example false */ + HasUnpaidInvoice: boolean; + /** @example false */ + IsExternalUser: boolean; + /** @example false */ + HasOrgWithRunningSubscription: boolean; }; - IdpResponseVO: { - SAMLResponse: string; + GetUserInvitationOutput: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + ID: string; + /** @example owner@family.org */ + InviterEmail: string; + /** @example 1000000000 */ + MaxSpace: number; + /** @example My Organization */ + OrganizationName: string; + /** @example family2022 | passfamily2024 */ + OrganizationPlanName: string; + Validation: components["schemas"]["AcceptInvitationValidation"]; }; - CreateCredentiallessUserInput: { + UserInvitationResponse: { + UserInvitation: components["schemas"]["GetUserInvitationOutput"]; /** - * @description Optional field, frontend fingerprints - * @default null + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - Payload: { - /** - * Format: base64 - * @example ++3dreJ+cHBSeEXvkxjLCRrf1... - */ - 'random-id-1'?: string; - /** - * Format: base64 - * @example Xv5df3dreJ+cHBvkxjSeEXvkx... - */ - 'random-id-2'?: string; - /** - * Format: base64 - * @example - */ - 'random-id-3'?: string; - /** - * Format: base64 - * @example - */ - 'random-id-4'?: string; - } | null; + Code: 1000; }; - SendInvitationsInput: { + DismissInput: { /** @default [] */ - Recipients: string[]; + InstalledApps: string[]; }; + SubmissionInput: { + Score: number; + /** @default */ + Comment: string; + /** @default [] */ + InstalledApps: string[]; + }; + IdpResponseVO: { + SAMLResponse: string; + }; + /** + * @description
See values descriptions
ValueDescription
4Google
6AppleProd
7AppleBeta
14AppleBetaET
16AppleDev
15AppleDevET
+ * @enum {integer} + */ + Environment: 4 | 6 | 7 | 14 | 16 | 15; + /** @description An armored PGP Public Key */ + PGPPublicKey: string; + /** + * @description
See values descriptions
ValueDescription
0Off
1On
+ * @enum {integer} + */ + PingNotificationStatus: 0 | 1; + /** + * @description
See values descriptions
ValueDescription
0Off
1On
+ * @enum {integer} + */ + PushNotificationStatus: 0 | 1; RegisterDeviceInput: { /** @example 2335fcc381ef78a20e580065...515f4e8 */ DeviceToken: string; - Environment: components['schemas']['Environment']; + Environment: components["schemas"]["Environment"]; /** @default null */ - PublicKey: components['schemas']['PGPPublicKey'] | null; + PublicKey: components["schemas"]["PGPPublicKey"] | null; /** @default null */ - PingNotificationStatus: components['schemas']['PingNotificationStatus'] | null; + PingNotificationStatus: components["schemas"]["PingNotificationStatus"] | null; /** @default null */ - PushNotificationStatus: components['schemas']['PushNotificationStatus'] | null; + PushNotificationStatus: components["schemas"]["PushNotificationStatus"] | null; }; + /** + * @description

1: email, 2: VPN, 3: calendar, 4: drive, 5: pass

See values descriptions
ValueDescription
1Mail
2VPN
3Calendar
4Drive
5Pass
6Wallet
7Neutron
8Contacts
9Lumo
10Authenticator
11Meet
12Docs
+ * @enum {integer} + */ + Product: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; UploadAttachment: { /** * @description Token return from create ticket api @@ -6751,7 +7738,8 @@ export interface components { Token: string; /** @description The body of attachment */ Body: string; - Product: components['schemas']['Product']; + /** @description 1: email, 2: VPN, 3: calendar, 4: drive, 5: pass */ + Product: components["schemas"]["Product"]; }; CancelPlanReport: { /** @@ -6782,18 +7770,64 @@ export interface components { ClientType: number; Tags: string[]; }; + /** + * @description
See values descriptions
ValueNameDescription
0DeleteWhen the model has been deleted since the last event loop poll
1CreateWhen the model has been created since the last event loop poll
2UpdateWhen the model was already known by the client before the last event loop pool and was updated since the last event loop poll
3UpdateFlagsWhen the model was already known by the client before the last event loop pool and only its metadata were updated since the last event loop poll
+ * @enum {integer} + */ + EventAction: 0 | 1 | 2 | 3; + EventOutput: { + ID?: components["schemas"]["Id"] | null; + Action: components["schemas"]["EventAction"]; + }; + EventCollectionOutput: components["schemas"]["EventOutput"][]; Stream: { /** @default null */ - Users: components['schemas']['EventCollectionOutput']; - Addresses: components['schemas']['EventCollectionOutput']; - Settings: components['schemas']['EventCollectionOutput']; + Users: components["schemas"]["EventCollectionOutput"]; + Addresses: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + UserSettings: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + Domains: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + Members: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + Organizations: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + OrganizationSettings: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + Subscriptions: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + Groups: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + PaymentsMethods: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + Sso: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + UserInvitations: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + Invoices: components["schemas"]["EventCollectionOutput"]; /** @default null */ - IncomingDefaults: components['schemas']['EventCollectionOutput']; + PaymentMethods: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + Imports: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + ImportReports: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + ImporterSyncs: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + GroupMembers: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + GroupOwners: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + OutgoingDelegatedAccess: components["schemas"]["EventCollectionOutput"]; + /** @default null */ + IncomingDelegatedAccess: components["schemas"]["EventCollectionOutput"]; /** true if there is more events to pull */ More: boolean; /** true if all data should be refreshed */ Refresh: boolean; - EventID: components['schemas']['Id']; + EventID: components["schemas"]["Id"]; + EventOrder: number; /** * ProtonResponseCode * @example 1000 @@ -6801,81 +7835,237 @@ export interface components { */ Code: 1000; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1True
0False
- * @enum {integer} - */ - BoolInt: 1 | 0; FeedbackVO: { FeedbackType: string; Score: number; Feedback?: string | null; }; - NotificationRequest: { - FullScreenImageSupport?: string | null; - FullScreenImageWidth?: number | null; - FullScreenImageHeight?: number | null; - SupportedFullScreenImageFormats: string[]; + /** + * @description
See values descriptions
ValueDescription
1MailFreeUser
2MailPaidUser
3DriveUser
4MailByoeUser
+ * @enum {integer} + */ + UserChecklistType: 1 | 2 | 3 | 4; + CheckItemInput: { + /** @example MobileApp */ + Item: string; + Type: components["schemas"]["UserChecklistType"]; + }; + UpdateDisplayInput: { + /** + * @example Hidden + * @enum {string} + */ + Display: "Hidden" | "Reduced"; + Type: components["schemas"]["UserChecklistType"]; + }; + NotificationRequest: { + FullScreenImageSupport?: string | null; + FullScreenImageWidth?: number | null; + FullScreenImageHeight?: number | null; + SupportedFullScreenImageFormats: string[]; Null: boolean; }; + ConnectionInformationResponse: { + IsVpnConnection: boolean; + IspProvider: string; + CountryCode: string; + Ip: string; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** @description An encrypted Label ID and default integer Label ID */ + LabelId: string; + LabelIDs: { + LabelIDs: components["schemas"]["LabelId"][]; + }; + /** + * @description

possible values:
0: collapse and hide sub-folders
1: expended and show sub-folders

See values descriptions
ValueDescription
1On
0Off
+ * @enum {integer} + */ + Expanded: 1 | 0; + /** + * @description

possible values:
0: no desktop/email notifications
1: notifications, folders only

See values descriptions
ValueDescription
1On
0Off
+ * @enum {integer} + */ + Notify: 1 | 0; PatchInput: { /** - * @description possible values: - * * - 0 => collapse and hide sub-folders - * * - 1 => expanded and show sub-folders + * @description possible values:
0: collapse and hide sub-folders
1: expended and show sub-folders * @default null - * @enum {integer|null} */ - Expanded: 1 | 0 | null; + Expanded: components["schemas"]["Expanded"] | null; /** - * @description possible values: - * * - 0 => no desktop/email notifications - * * - 1 => notifications, folders only + * @description possible values:
0: no desktop/email notifications
1: notifications, folders only * @default null - * @enum {integer|null} */ - Notify: 1 | 0 | null; + Notify: components["schemas"]["Notify"] | null; }; - LabelIDs: { - LabelIDs: components['schemas']['LabelID'][]; + /** + * @description
See values descriptions
ValueDescription
1MessageLabel
2Contact
3MessageFolder
4MessageSystemFolder
+ * @enum {integer} + */ + LabelType: 1 | 2 | 3 | 4; + Label: { + /** @example sadfaACXDmTaBub14w== */ + ID: string; + /** @example Event Label! */ + Name: string; + /** @example Folder/Event Label! */ + Path: string; + /** @example 1 */ + Type: number; + /** @example #f66 */ + Color: string; + /** @example 8 */ + Order: number; + /** @example 1 */ + Notify: number; + /** @example 1 */ + Expanded: number; + /** @example 1 */ + Sticky: number; + /** @example sadfaACXDmTaBub14w== */ + ParentID: string; + /** + * @description v3 only + * @example 1 + */ + Display: number; + /** + * @description v3 only + * @example 0 + */ + Exclusive: number; + UserID: number; + RawType: components["schemas"]["LabelType"]; + RawName: string; + RawColor: string; + Priority: number; + V3Order: boolean; + SystemFolder: boolean; + Category: boolean; + PrimaryCategory: boolean; }; - /** Signed Key List */ - KTKeyList: { + LabelResponse: { + Label: components["schemas"]["Label"]; /** - * @description Starting Epoch ID for SKL. Can be null, if the epoch is not yet released - * @example 125 + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - MinEpochID?: number | null; + Code: 1000; + }; + ListEligibleTrialsResponse: { + TrialPlans: unknown[]; + CreditCardRequiredPlans: unknown[]; /** - * @description Ending Epoch ID for SKL. Can be null, if the epoch is not yet released - * @example 241 + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - MaxEpochID?: number | null; + Code: 1000; + }; + BillingAddressDto: { /** - * @description If epoch is not yet released this will be a future epoch ID - * @example 265 + * @description The country code. + * @example NL */ - ExpectedMinEpochID?: number | null; + CountryCode: string; /** - * @description JSON-encoded content of the SKL. If null, this SKL contains an ObsolescenceToken - * @example [{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1},{""Fingerprint"": ""fde90483475164ec6353c93f767df53b0ca8395c"",""Primary"": 1,""Flags"": 3}] + * @description The state. + * @example Noord-Holland */ - Data?: string | null; + State?: string | null; /** - * @description Hex token to prove the obsolescence of the signed key list in the merkle tree or null. The first 16 characters are a committed big-endian hex-encoded unix timestamp, remaining is random - * @example 000000006243460497f838b649439b5f29c4e73014b9da096d0fe3ed + * @description The zip code. + * @example 1234AA */ - ObsolescenceToken?: string | null; + ZipCode?: string | null; /** - * @description Armored OpenPGP signature for the data. If null, proof contains an obsolescenceToken - * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + * @description The VAT identifier. + * @default null + * @example AA-1234 */ - Signature?: string | null; + VatId: string | null; + }; + RegisterReferralInput: { + /** @example 12 */ + Cycle: number; + /** @example bundle2022 */ + Plan: string; + ReferralIdentifier: string; + /** @default null */ + ReferralID: string | null; /** - * @description Identifier of the revision version - * @example 42 + * @default null + * @example payment-token */ - Revision: number; + PaymentToken: string | null; + /** @default null */ + BillingAddress: components["schemas"]["BillingAddressDto"] | null; + /** + * @default [] + * @example [ + * "CODE1", + * "CODE2" + * ] + */ + Codes: string[]; + /** + * @default null + * @example USD + */ + Currency: string | null; + }; + SendInvitationsInput: { + /** @default [] */ + Recipients: string[]; + }; + /** Product used space */ + UserUsage: { + Calendar: number; + Contact: number; + Drive: number; + Mail: number; + Pass: number; + Lumo: number; + }; + /** + * @description
See values descriptions
ValueDescription
0Paid
1Available
2Overdue
+ * @enum {integer} + */ + DelinquentState: 0 | 1 | 2; + UserKey: { + /** @example IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA== */ + ID: string; + /** @example 3 */ + Version: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + /** + * @deprecated + * @description Deprecated! Please compute the fingerprint from the key + * @example c93f767df53b0ca8395cfde90483475164ec6353 + */ + Fingerprint: string; + /** @example 1 */ + Primary: number; + /** + * @description Inactive keys (0) are kept for reactivation only, they are not trusted, and should not be unlocked + * @example 1 + */ + Active: number; + /** + * @description Base64-encoded secret, made up of 32 random bytes + * @example 1H8EGg3J1...Qwk243hf + */ + RecoverySecret: string; + /** @example -----BEGIN PGP SIGNATURE-----... */ + RecoverySecretSignature: string; }; User: { /** @example MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA== */ @@ -6915,7 +8105,7 @@ export interface components { * @example 70376905 */ UsedSpace: number; - ProductUsedSpace: components['schemas']['UserUsage']; + ProductUsedSpace: components["schemas"]["UserUsage"]; /** @description 1 when the user's member has an AI seat, 0 otherwise */ NumAI: number; /** @description the number of lumo seats attributed to the user, 0 otherwise */ @@ -6931,10 +8121,10 @@ export interface components { ToMigrate: 0 | 1; /** * @description - * * 0: Mnemonic is disabled, - * * 1: Mnemonic is enabled but not set, - * * 2: Mnemonic is enabled but needs to be re-activated, - * * 3: Mnemonic is enabled and set + * 0: Mnemonic is disabled, + * 1: Mnemonic is enabled but not set, + * 2: Mnemonic is enabled but needs to be re-activated, + * 3: Mnemonic is enabled and set * @example 1 */ MnemonicStatus: number; @@ -6944,52 +8134,112 @@ export interface components { */ Subscribed: number; /** - * @description Activated services (bitmap): - * * `1`: User has the mail product activated, - * * `4`: User has the VPN activated + * @description + * Activated services (bitmap): + * `1`: User has the mail product activated, + * `4`: User has the VPN activated * @example 5 */ Services: number; - Delinquent: components['schemas']['DelinquentState']; - Keys: components['schemas']['UserKey']; + Delinquent: components["schemas"]["DelinquentState"]; + Keys: components["schemas"]["UserKey"]; Flags: { protected?: boolean; - 'onboard-checklist-storage-granted'?: boolean; - 'has-temporary-password'?: boolean; - 'test-account'?: boolean; - 'no-login'?: boolean; - 'recovery-attempt'?: boolean; + "onboard-checklist-storage-granted"?: boolean; + "has-temporary-password"?: boolean; + "test-account"?: boolean; + "no-login"?: boolean; + "recovery-attempt"?: boolean; sso?: boolean; /** @description User have no or only external addresses */ - 'no-proton-address'?: boolean; + "no-proton-address"?: boolean; + /** @description Whether the user has at least one bring-your-own-email address */ + "has-a-byoe-address"?: boolean; }; }; - UserKey: { - /** @example IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA== */ + /** AddressKey */ + AddressKey: { + /** + * @description Encrypted AddressKey ID + * @example G1MbEt3...Ol_6h== + */ ID: string; - /** @example 3 */ + /** + * @description Latest version is 3 + * @example 3 + */ Version: number; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey: string; /** - * @description Deprecated! Please compute the fingerprint from the key + * @deprecated + * @description Deprecated! Do not rely on public keys returned from the API! + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.* + */ + PublicKey: string; + /** + * @description This parameter is missing ONLY in the key reset call + * @example -----BEGIN PGP PRIVATE KEY BLOCK----- + */ + PrivateKey?: string | null; + /** + * @description This can be the token to decrypt the address key via the user key + * or a legacy token if logging in as sub-user or null for private legacy keys user + * @example null or -----BEGIN PGP MESSAGE-----.* + */ + Token?: string | null; + /** + * @description If this field is present, the key is migrated. Use it to verify the token! + * @example null or -----BEGIN PGP SIGNATURE----- + */ + Signature?: string | null; + /** + * @deprecated + * @description Deprecated! Do not rely on fingerprints returned from the API! * @example c93f767df53b0ca8395cfde90483475164ec6353 */ Fingerprint: string; - /** @example 1 */ + /** + * @deprecated + * @description Deprecated! Do not rely on fingerprints returned from the API! + */ + Fingerprints: string[]; + /** + * @deprecated + * @description Deprecated! + * Migrated accounts do not have the activation field set, + * and they get migrated automatically on login. + * @example -----BEGIN PGP MESSAGE-----.* + */ + Activation?: string | null; + /** + * @description 0 or 1. There is only one primary key per address + * @example 1 + */ Primary: number; /** - * @description Inactive keys (0) are kept for reactivation only, they are not trusted, and should not be unlocked + * @description 0 or 1. + * All active keys should decrypt successfully and all inactive keys should not be decrypted. * @example 1 */ Active: number; /** - * @description Base64-encoded secret, made up of 32 random bytes - * @example 1H8EGg3J1...Qwk243hf + * @description Flags (bitmap): + * * `1`: Can use key to verify signatures; + * * `2`: Can use key to encrypt new data; + * * `4`: Can be used to encrypt email; + * * `8`: Do not expect signed email from this key; + * @example 3 */ - RecoverySecret: string; - /** @example -----BEGIN PGP SIGNATURE-----... */ - RecoverySecretSignature: string; + Flags: number; + /** + * @description If not null, it represents a valid associated Address Forwarding instance + * @example fWIio823...j45sL== + */ + AddressForwardingID: string; + /** + * @description If not null, it represents a valid associated Group Member instance + * @example fWIio823...j45sL== + */ + GroupMemberID: string; }; AddressUser: { /** @@ -7071,27 +8321,14 @@ export interface components { * @example true */ ProtonMX: Record; - SignedKeyList: components['schemas']['KTKeyList']; - Keys: components['schemas']['AddressKey'][]; + SignedKeyList: components["schemas"]["KTKeyList"]; + Keys: components["schemas"]["AddressKey"][]; /** * @description Bitflags representing noencrypt/nosign * @example 48 */ Flags: number; }; - /** Signed Key List */ - KTAddressListTransformer: { - /** - * @description JSON-encoded content of the SAL - * @example [{"Email": "test@example.com","Flags": 1}] - */ - Data: string; - /** - * @description The armored signature over the JSON-serialized data with the primary user key - * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- - */ - Signature: string; - }; /** LinkResponse */ SwitchAddressesOrganizationPermissionsTransformer: { AddressID: string; @@ -7103,12 +8340,19 @@ export interface components { Details?: Record; }; }; - AuthDeviceOutput: { - ID: components['schemas']['Id']; - State: components['schemas']['AuthDeviceState']; + /** + * @description

The current device state

See values descriptions
ValueDescription
0Inactive
1Active
2PendingActivation
3PendingAdminActivation
4Rejected
5NoSession
+ * @enum {integer} + */ + AuthDeviceState: 0 | 1 | 2 | 3 | 4 | 5; + AuthDeviceOutput: { + ID: components["schemas"]["Id"]; + /** @description The current device state */ + State: components["schemas"]["AuthDeviceState"]; /** @description The device name */ Name: string; - LocalizedClientName: components['schemas']['TranslatedStringInterface']; + /** @description The translated client name used for login */ + LocalizedClientName: string; /** @description The device platform */ Platform?: string | null; /** @@ -7132,123 +8376,144 @@ export interface components { */ LastActivityTime: string; /** @description PGP message encrypted to the AddressID containing a 64-char random hex-encoded token */ - ActivationToken?: components['schemas']['PGPMessage'] | null; - ActivationAddressID?: components['schemas']['Id'] | null; - MemberID?: components['schemas']['Id'] | null; + ActivationToken?: components["schemas"]["PGPMessage"] | null; + ActivationAddressID?: components["schemas"]["Id"] | null; + MemberID?: components["schemas"]["Id"] | null; /** * @description DeviceToken of the created device * @example wfih0367aa7dc0359bf5c42d15a93e6c */ DeviceToken?: string | null; }; - DomainTransformer: { - /** @example BKiAUbkGnUPiy2c37zjon_g== */ - ID: string; - /** @example protonvpn.ch */ - DomainName: string; + AuthInput2: { /** - * @description 1 is Proton, 2 is user-assigned Proton subdomain, 3 is custom domain - * @example 3 + * @description Token received from POST /auth/saml during SSO sign-in flow + * @default null + * @example */ - Type: number; + SSOResponseToken: string | null; /** - * @description 0 is default, 1 is active (verified), 2 is warn (dns issue) - * @example 1 + * @default null + * @example einstein */ - State: number; - /** @example 1556136548 */ - LastActiveTime: number; - /** @example 1446095611 */ - CheckTime: number; - /** @example 1554807818 */ - WarnTime: number; - /** @example protonmail-verification=c701a28e2bdd3358c6dda71a3008b806e41950b0 */ - VerifyCode: string; + Username: string | null; /** - * @description 0 is default, 1 is has code but wrong, 2 is good - * @example 2 + * Format: base64 + * @default null + * @example */ - VerifyState: number; + ClientEphemeral: string | null; /** - * @description 0 is default, 1 is set but no us, 2 has us but priority is wrong, 3 is good - * @example 3 + * Format: base64 + * @default null + * @example */ - MxState: number; + ClientProof: string | null; /** - * @description 0 is default, 1 and 2 means detected a record but wrong, 3 is good - * @example 3 + * @description Client-specific secret only necessary to access the admin panel + * @default null + * @example demopass + */ + ClientSecret: string | null; + /** + * Format: hex + * @default null + * @example */ - SpfState: number; + SRPSession: string | null; /** - * @description 0 is default, 1 and 2 means detected record but wrong, 3 is good, 4 is good and relaxed policy - * @example 3 + * @description defaults to 0 if not present, transforms cookies into persistent cookies + * @default null + * @example 1 + */ + PersistentCookies: number | null; + /** + * @default null + * @example 123456 or recovery code + */ + TwoFactorCode: string | null; + /** + * @description Either this or the TwoFactorCode + * @default null */ - DmarcState: number; - DKIM: { + FIDO2: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; /** - * @description 0 is default, 1 and 2 means detected record but wrong, 3 means key is wrong, 4 is good, 5 is good and inherited from parent - * @example 4 + * Format: base64 + * @description clientData (base64) returned from the client authentication library */ - State?: number; - /** @description Contains the domain's currently configured DKIM public keys and metadata */ - Config?: { - /** @example protonmail2._domainkey */ - Hostname?: string; - /** @example protonmail2.domainkey.dhgge2q6ksokiqwomdn23r6nnjjwiwblsujm6bjdnj3hhaxlktpqa.domains.proton.ch. */ - CNAME?: string; - Key?: { - /** @example BKiAUbkGnUPiy2c37zjon_g== */ - ID?: string; - /** @example protonmail2 */ - Selector?: string; - /** @example MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0zc0kqr7bnFOD1TmsjJmYthy41QeI1cqga5yU8... */ - PublicKey?: string; - /** - * @description 0 is RSA1024, 1 is RSA2048 - * @example 1 - */ - Algorithm?: number; - /** - * @description 0 is active, 1 is pending, 2 is retired, 3 is deceased - * @example 2 - */ - State?: number; - /** - * @description 0 is unset, 1 is good, 2 is invalid - * @example 1 - */ - DNSState?: number; - /** @example 1687942995 */ - CreateTime?: number; - }; - }[]; - }; - Flags: { - /** @description If the domain is intended to be used for custom addresses */ - 'mail-intent'?: boolean; - /** @description If the domain is intended to be used for SSO integration */ - 'sso-intent'?: boolean; - }; + ClientData?: string; + /** + * Format: base64 + * @description authenticatorData (base64) returned from the client authentication library + */ + AuthenticatorData?: string; + /** + * Format: base64 + * @description signature (base64) returned from the client authentication library + */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + } | null; + /** + * @description optional field, frontend fingerprints + * @default null + */ + Payload: { + /** + * Format: base64 + * @example ++3dreJ+cHBSeEXvkxjLCRrf1... + */ + "random-id-1"?: string; + /** + * Format: base64 + * @example Xv5df3dreJ+cHBvkxjSeEXvkx... + */ + "random-id-2"?: string; + /** + * Format: base64 + * @example + */ + "random-id-3"?: string; + /** + * Format: base64 + * @example + */ + "random-id-4"?: string; + } | null; + /** + * @deprecated + * @description optional field used together with Android fingerprinting + * @default null + * @example + */ + Salt: string | null; }; - /** GroupMemberResponse */ - GroupMemberResponse: { - ID: components['schemas']['Id']; - Type: components['schemas']['GroupMemberType']; - State: components['schemas']['GroupMemberState']; - CreateTime: number; - GroupID: components['schemas']['Id']; - AddressID?: components['schemas']['Id'] | null; - Email?: string | null; - Permissions: components['schemas']['GroupMemberPermissions']; + Fido2RegisteredKey: { + /** @example fido2-u2f */ + AttestationFormat: string; + CredentialID: Record[]; + /** @example My security key */ + Name: string; + /** + * @example 1 + * @enum {unknown} + */ + Flags: unknown; }; + DomainOutput2: Record; /** GroupResponse */ GroupResponse: { - ID: components['schemas']['Id']; + ID: components["schemas"]["Id"]; Name: string; Address: unknown[]; - Permissions: components['schemas']['GroupPermissions']; + Permissions: components["schemas"]["GroupPermissions"]; CreateTime: number; - Flags: components['schemas']['GroupFlags']; + Flags: number; + GroupVisibility: components["schemas"]["GroupVisibility"]; + MemberVisibility: number; Description?: string | null; }; MemberInfo: { @@ -7304,7 +8569,7 @@ export interface components { * @description bit map: 1=TOTP, 2=FIDO2 * @example 3 */ - '2faStatus': number; + "2faStatus": number; Keys: string[]; /** @example -----BEGIN PUBLIC KEY BLOCK-----.*-----END PUBLIC KEY BLOCK----- */ PublicKey: string; @@ -7326,12 +8591,22 @@ export interface components { /** @description The number of lumo seats allocated to the member */ NumLumo: Record; }; + OrganizationKeyInvitationDto: { + /** @description Token key packet encrypted to the recipient's address key */ + TokenKeyPacket: components["schemas"]["BinaryString"]; + /** @description Signature of the token key packet by the inviters address key */ + Signature: string; + /** @description The address ID of the signature address key */ + SignatureAddressID: components["schemas"]["EncryptedId"]; + /** @description The address ID of the address to which the token key packet is encrypted to */ + EncryptionAddressID: components["schemas"]["EncryptedId"]; + }; UpdateMemberRoleInput: { Role: number; /** @default null */ - OrganizationKeyInvitation: components['schemas']['OrganizationKeyInvitationDto'] | null; + OrganizationKeyInvitation: components["schemas"]["OrganizationKeyInvitationDto"] | null; /** @default null */ - OrganizationKeyActivation: components['schemas']['OrganizationKeyActivationDto'] | null; + OrganizationKeyActivation: components["schemas"]["OrganizationKeyActivationDto"] | null; }; GetMemberUnprivatizationOutput: { /** @description State of the Unprivatization (0: declined), 1: pending, 2: ready */ @@ -7342,18 +8617,28 @@ export interface components { */ InvitationData?: string | null; /** @description InvitationData signed with org key */ - InvitationSignature?: components['schemas']['PGPSignature'] | null; + InvitationSignature?: components["schemas"]["PGPSignature"] | null; /** @description Email to send the invitation to */ InvitationEmail?: string | null; /** @description Administrator email */ AdminEmail: string; /** @description Fingerprint of the org key signed with primary address key */ - OrgKeyFingerprintSignature?: components['schemas']['PGPSignature'] | null; + OrgKeyFingerprintSignature?: components["schemas"]["PGPSignature"] | null; /** @description Organization public key */ - OrgPublicKey?: components['schemas']['PGPPublicKey'] | null; + OrgPublicKey?: components["schemas"]["PGPPublicKey"] | null; /** @description Whether the member should remain private after creation or be unprivatized */ PrivateIntent: boolean; }; + /** @enum {string} */ + AuthLogStatus: "success" | "attempt" | "failure"; + /** + * @description

ID of protection applied.
+ * Can be missing. Only present if user has High Security enabled.
+ * See AuthLogProtection enum for possible values.

See values descriptions
ValueNameDescription
1Block
2Captcha
3OwnershipVerification
4DeviceVerification
5Ok* AuthLog action was protected by anti-abuse systems + * * and was evaluated as safe.
+ * @enum {integer} + */ + AuthLogProtection: 1 | 2 | 3 | 4 | 5; /** @description * An authentication logs entry. * `Protection` and `ProtectionDesc` fields are optional, only present if user has High Security enabled. @@ -7369,7 +8654,8 @@ export interface components { * @example 1683644736 */ Time: number; - Status: components['schemas']['AuthLogStatus']; + /** @description Status of the event. See AuthLogStatus enum for values. */ + Status: components["schemas"]["AuthLogStatus"]; /** * @description Various values. See AuthLogEvent constants. * @example 23 @@ -7390,13 +8676,10 @@ export interface components { Location?: string | null; /** @example AT&T Wireless */ InternetProvider?: string | null; - /** - * @description ID of protection applied. + /** @description ID of protection applied. * Can be missing. Only present if user has High Security enabled. - * See AuthLogProtection enum for possible values. - * @example 1 - */ - Protection?: components['schemas']['AuthLogProtection'] | null; + * See AuthLogProtection enum for possible values. */ + Protection: components["schemas"]["AuthLogProtection"]; /** * @description Localized description of protection applied. * Can be missing. Only present if user has High Security enabled. @@ -7404,18 +8687,11 @@ export interface components { */ ProtectionDesc?: string | null; }; - Session: { - /** @example cc0a3ec21c3af3461c9c310bf3f568795fdf6dc5 */ - UID: string; - /** @example Web */ - ClientID: string; - /** @example 1527262849 */ - CreateTime: number; - /** @example IhcUWoRxdY3S-6pfk2L1oSTeZx5kvpeqcxuii8h1ic1nYnSJa11LP8DABcgsRJCwXXDjxwPFSxEGJrlrvMWFpQ== */ - MemberID: string; - /** @example 0 */ - Revocable: number; - }; + /** + * @description

0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation

See values descriptions
ValueNameDescription
0NoKeyThe member does not and should not have access to the org key (e.g. not an admin)
1ActiveThe member has full access to the most recent copy of the org key
2MissingThe member does not have access to the most recent copy of the org key (including legacy keys)
3PendingThe member has been invited to but needs to activate the most recent copy of the org key
+ * @enum {integer} + */ + MemberOrgKeyStatus: 0 | 1 | 2 | 3; GetOrganizationKeysOutput: { /** * @description Organization private key encrypted with mailbox password hash @@ -7463,13 +8739,20 @@ export interface components { * @example someadmin@myorg.com */ FingerprintSignatureAddress?: string | null; - /** - * @description 0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation - * @example 1 - */ - AccessToOrgKey?: components['schemas']['MemberOrgKeyStatus'] | null; + /** @description 0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation */ + AccessToOrgKey: components["schemas"]["MemberOrgKeyStatus"]; /** @description Whether the organization has passwordless keys or not */ Passwordless: boolean; + /** + * @description Token for accessing via the parent organization key + * @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- + */ + ParentOrgToken?: string | null; + /** + * @description Signature of the parent organization token + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + ParentOrgTokenSignature?: string | null; }; GetOrganizationIdentityOutput: { /** @@ -7488,27 +8771,91 @@ export interface components { */ FingerprintSignatureAddress: string; }; - OrganizationSettings2: { + OrganizationSettingsInputOutput: { /** * @description Whether to show organization name in sidebar or not - * @default false + * @default null * @example true */ - ShowName: boolean; + ShowName: boolean | null; /** * @description Whether to show the Scribe writing assistant or not - * @default true + * @default null * @example true */ - ShowScribeWritingAssistant: boolean; + ShowScribeWritingAssistant: boolean | null; /** - * @description Whether the video conferencing feature is enabled or not - * @default true + * @description Whether the Zoom video conferencing feature is enabled or not + * @default null * @example true */ - VideoConferencingEnabled: boolean; - /** @default null */ + VideoConferencingEnabled: boolean | null; + /** + * @description Whether the Meet video conferencing feature is enabled or not + * @default null + * @example true + */ + MeetVideoConferencingEnabled: boolean | null; + /** + * @description The ID of the organization's logo + * @default null + */ LogoID: string | null; + /** + * @description List of predefined products for which the non-admin members of the organization have access. If all products are allowed, the client must send ["All"]. The BE will never return ["All"]. + * @default null + * @example [ + * "VPN", + * "Pass" + * ] + */ + AllowedProducts: unknown[] | null; + /** + * @description List of PasswordPolicies. Only the PasswordPolicies passed are updated. Absent PasswordPolicies remain in their current state. + * @default null + */ + PasswordPolicies: components["schemas"]["OrganizationPasswordPolicyInputOutput"][] | null; + /** + * @description Organization policy settings + * @default null + */ + OrganizationPolicy: { + /** + * @description 1 for business plans only organization settings override logAuth and highSecurity of user settings, 0 otherwise + * @example 1 + */ + Enforced?: number; + } | null; + /** + * @description For business plans only, organization settings logAuth override the one in user settings. + * @default null + * @example [ + * 0, + * 1, + * 2 + * ] + */ + LogAuth: number | null; + /** + * @description For business plans only, organization settings HighSecurity override the one in user settings. + * @default null + * @example [ + * 0, + * 1, + * 2 + * ] + */ + HighSecurity: number | null; + /** + * @description Whether Mail Category view is enabled or not for members + * @default false + */ + MailCategoryViewEnabled: boolean; + /** + * @description Whether Personal Access Token creation is disabled for members + * @default false + */ + PersonalAccessTokenCreationDisabled: boolean; }; SsoTransformer: Record; Info: { @@ -7517,6 +8864,8 @@ export interface components { /** @example https://sso.proton.me/auth/saml */ CallbackURL: string; }; + /** Theme */ + Theme2: Record; UserSettingsTransformer: { Email: { /** @example abc@gmail.com */ @@ -7547,7 +8896,7 @@ export interface components { /** @example 0 */ Reset?: number; }; - '2FA': { + "2FA": { /** * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both * @example 3 @@ -7573,7 +8922,7 @@ export interface components { Compromised?: number; }[]; /** @description Contains the user's currently registered FIDO2 credentials. */ - RegisteredKeys?: components['schemas']['Fido2RegisteredKey'][]; + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; }; /** * @description Bitmap informing which news the user is subscribed to: @@ -7602,7 +8951,7 @@ export interface components { * @example 0 */ Density: number; - Theme: components['schemas']['Theme2']; + Theme: components["schemas"]["Theme2"]; /** @example 1 */ ThemeType: number; /** @@ -7641,6 +8990,8 @@ export interface components { Welcomed?: number; /** @description 1, or 0 */ SupportPgpV6Keys?: number; + /** @description 1, or 0. When 1, disables easy-device-migration (as of now QR code sign-in) */ + EdmOptOut?: number; }; Referral: { /** @example https://pr.tn/ref/ERBYvlX8SC4KOyb */ @@ -7671,6 +9022,13 @@ export interface components { * @example 1 */ HideSidePanel: number; + OrganizationPolicy: { + /** + * @description 1 for business plans only organization settings override logAuth and highSecurity of user settings, 0 otherwise + * @example 1 + */ + Enforced?: number; + }; HighSecurity: { /** * @description 1 => user can enable High Security, 0 => can't enable @@ -7707,21 +9065,17 @@ export interface components { */ UsedClients: Record[]; }; - Fido2RegisteredKey: { - /** @example fido2-u2f */ - AttestationFormat: string; - CredentialID: Record[]; - /** @example My security key */ - Name: string; - }; ScheduleSupportCallOutput: { /** @example https://calendly.com/proton-schedule */ CalendlyLink: string; }; + GetPasswordPolicyOutput: Record; + OutgoingDelegatedAccessOutput: Record; AccountRecoveryAttempt: { /** * @description 0 => None, 1 => Grace, 2 => Cancelled, 3 => Insecure, 4 => Expired * @example 1 + * @enum {integer} */ State: number; /** @example 1686834569 */ @@ -7734,10 +9088,31 @@ export interface components { */ Reason: number; /** + * @deprecated * @description The session ID that triggered the process * @example qmi2ptbz4sefeahddjxghsxtu2orlgyf */ UID: string; + /** + * @description Is the current session the one that triggered the process + * @example true + */ + IsCurrentSession: boolean; + }; + GetUserInvitationsOutput: { + UserInvitations: components["schemas"]["GetUserInvitationOutput"][]; + }; + Session: { + /** @example cc0a3ec21c3af3461c9c310bf3f568795fdf6dc5 */ + UID: string; + /** @example Web */ + ClientID: string; + /** @example 1527262849 */ + CreateTime: number; + /** @example IhcUWoRxdY3S-6pfk2L1oSTeZx5kvpeqcxuii8h1ic1nYnSJa11LP8DABcgsRJCwXXDjxwPFSxEGJrlrvMWFpQ== */ + MemberID: string; + /** @example 0 */ + Revocable: number; }; VPNAuthenticationCertificateDetailedTransformer: { /** @@ -7766,7 +9141,7 @@ export interface components { */ Mode: string; SessionUID: string; - Session?: components['schemas']['Session'] | null; + Session?: components["schemas"]["Session"] | null; UserID: number; UserName: string; MaxTier: number; @@ -7791,7 +9166,7 @@ export interface components { * @example enumeration * @enum {string} */ - Type: 'boolean' | 'integer' | 'float' | 'string' | 'enumeration' | 'mixed'; + Type: "boolean" | "integer" | "float" | "string" | "enumeration" | "mixed"; /** @example 1 */ Minimum: Record; /** @example 100 */ @@ -7809,112 +9184,6 @@ export interface components { /** @example 1527262849 */ UpdateTime: number; }; - AuthInput: { - /** - * @description Token received from POST /auth/saml during SSO sign-in flow - * @default null - * @example - */ - SSOResponseToken: string | null; - /** - * @default null - * @example einstein - */ - Username: string | null; - /** - * Format: base64 - * @default null - * @example - */ - ClientEphemeral: string | null; - /** - * Format: base64 - * @default null - * @example - */ - ClientProof: string | null; - /** - * @description Client-specific secret only necessary to access the admin panel - * @default null - * @example demopass - */ - ClientSecret: string | null; - /** - * Format: hex - * @default null - * @example - */ - SRPSession: string | null; - /** - * @description defaults to 0 if not present, transforms cookies into persistent cookies - * @default null - * @example 1 - */ - PersistentCookies: number | null; - /** - * @default null - * @example 123456 or recovery code - */ - TwoFactorCode: string | null; - /** - * @description Either this or the TwoFactorCode - * @default null - */ - FIDO2: { - /** @description The same AuthenticationOptions received as a challenge from the server */ - AuthenticationOptions?: Record; - /** - * Format: base64 - * @description clientData (base64) returned from the client authentication library - */ - ClientData?: string; - /** - * Format: base64 - * @description authenticatorData (base64) returned from the client authentication library - */ - AuthenticatorData?: string; - /** - * Format: base64 - * @description signature (base64) returned from the client authentication library - */ - Signature?: string; - /** @description CredentialID used */ - CredentialID?: Record[]; - } | null; - /** - * @description optional field, frontend fingerprints - * @default null - */ - Payload: { - /** - * Format: base64 - * @example ++3dreJ+cHBSeEXvkxjLCRrf1... - */ - 'random-id-1'?: string; - /** - * Format: base64 - * @example Xv5df3dreJ+cHBvkxjSeEXvkx... - */ - 'random-id-2'?: string; - /** - * Format: base64 - * @example - */ - 'random-id-3'?: string; - /** - * Format: base64 - * @example - */ - 'random-id-4'?: string; - } | null; - /** - * @deprecated - * @description optional field used together with Android fingerprinting - * @default null - * @example - */ - Salt: string | null; - }; CreateCredentiallessUserOutput: { /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ UID: string; @@ -7932,19 +9201,6 @@ export interface components { /** @example wfih0367aa7dc0359bf5c42d15a93e6c */ RefreshToken: string; }; - AuthInfoInput: { - /** - * @description 4 is the current version, older versions are not accepted - * @example 4 - */ - Version: number; - /** @example */ - ModulusID: string; - /** @example */ - Salt: string; - /** @example */ - Verifier: string; - }; PushTransformer: { /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ PushID: string; @@ -7959,2705 +9215,3174 @@ export interface components { */ Type: string; }; - ReferralOutput: { - /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUss-tfNw== */ - ReferralID: string; - /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUss-tfNw== */ - UserID: string; - State: number; - /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUss-tfNw== */ - ReferredUserID?: string | null; - Email?: string | null; - CreateTime: number; - SignupTime?: number | null; - TrialTime?: number | null; - CompleteTime?: number | null; - RewardTime?: number | null; - RewardMonths: number; - InvoiceID?: string | null; - ReferredUserSubscriptionCycle?: number | null; + Sender: { + /** @example foo@protonmail.dev */ + Address: string; + /** @example Joe */ + Name: string; + /** + * @description Optional, whether to display the Proton badge.
Possible values:
1: Display the Proton badge
0: Do not display the Proton badge + * @example 1 + * @enum {integer} + */ + IsProton: 0 | 1; + /** + * @description Optional, whether to display the SenderImage.
Possible values:
1: Display the sender image
0: Do not display the sender image + * @example 1 + * @enum {integer} + */ + DisplaySenderImage: 0 | 1; + /** @description Optional, BIMI selector header, set if present on message or if domain has BIMI */ + BimiSelector?: string | null; + /** + * @description Whether the mail came through simple login + * @example 1 + * @enum {integer} + */ + IsSimpleLogin: 0 | 1; }; - ReferralStatus: { - /** @example 2 */ - RewardMonths: number; - /** @example 6 */ - RewardMonthsLimit: number; - /** @example 10 */ - EmailsAvailable: number; + Recipient: { + /** @example foo@protonmail.dev */ + Address: string; + /** @example Joe */ + Name: string; + /** @description Optional */ + Group?: string | null; + /** + * @description Optional, whether to display the Proton badge.
+ * Possible values:
+ * - 1: Display the Proton badge
+ * - 0: Do not display the Proton badge + * @example 1 + * @enum {integer} + */ + IsProton: 0 | 1; }; - GetUserInvitationsOutput: { - UserInvitations: components['schemas']['GetUserInvitationOutput'][]; + /** @description Attachment counts grouped by the MIME type and disposition. + * Listed types here are an example */ + GroupedAttachmentsCount: { + "image/jpeg": { + /** @example 2 */ + inline?: number; + /** @example 1 */ + attachment?: number; + }; + "text/calendar": { + /** @example 1 */ + attachment?: number; + }; }; - GetUserInvitationOutput: { - /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ - ID: string; - /** @example owner@family.org */ - InviterEmail: string; - /** @example 1000000000 */ - MaxSpace: number; - /** @example My Organization */ - OrganizationName: string; - /** @example family2022 | passfamily2024 */ - OrganizationPlanName: string; - Validation: components['schemas']['AcceptInvitationValidation']; + /** @enum {string} */ + Disposition: "attachment" | "inline"; + Metadata: { + ID: components["schemas"]["Id"]; + Name?: string | null; + Size: number; + MIMEType: string; + Disposition: components["schemas"]["Disposition"]; }; - EventInfo: { - Code: components['schemas']['ResponseCodeSuccess']; + MessageInfo: { + /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUsT_zzWKFI-adgMIhFhPaAukDm9fw3MAOfsds-tfNw== */ + ID: string; /** - * Format: byte - * @example ACXDmTaBub14w== + * @description This value is UserID + MessageID.
It gives the order in which the messages were created in our database + * @example 456 */ - EventID: string; + Order: number; + /** @example Wk30GtU7aIj8Gu6yWkSc3SacA== */ + ConversationID: string; + /** @example new subject */ + Subject: string; + /** @example 1 */ + Unread: number; /** - * @description Bitmask to know what to refresh
`0`: Nothing
`1`: MAIL
`2`: CONTACTS
`255`: Everything - * @example 0 + * @deprecated + * @example 1 */ - Refresh: number; + Type: string; /** - * @description `1` if there is more to pull - * @example 0 - * @enum {integer} + * @deprecated + * @example me@protonmail.com */ - More: 0 | 1; - Messages: { - /** @example KPlISx5MiML3XcSYPrREF-...-adgMIhFhPaAukDm9fw3MAOfsds-tfNw== */ - ID?: string; - /** - * @description Message action
`0`: `DELETE`
`1`: `CREATE`
`2`: `UPDATE`
`3`: `UPDATE_FLAGS` - * @example 1 - * @enum {integer} - */ - Action?: 0 | 1 | 2 | 3; - Message?: components['schemas']['MessageInfo'] & { - /** @deprecated */ - LabelIDsAdded?: string[]; - /** @deprecated */ - LabelIDsRemoved?: string[]; - }; - }[]; - Conversations: { - /** @example I6hgx3Ol-d3HYa3E394T...ACXDmTaBub14w== */ - ID?: string; - /** @example 1 */ - Action?: number; - Conversation?: { - /** @example AJuSqm0qvIL4LSMR9LWsqNO...a2OlAU_Iqr2Qcducsz-ZA== */ - AddressID?: string; - } & components['schemas']['Conversation'] & { - LabelIDsAdded?: string[]; - LabelIDsRemoved?: string[]; - /** - * @deprecated - * @description Not available in the Events API - */ - LabelIDs?: string[]; - } & components['schemas']['AttachmentsMetadata']; - }[]; - Importers: { - /** @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEhjBbUPDMHGU699fw== */ - ID?: string; - /** @example 1 */ - Action?: number; - Importer?: components['schemas']['ImporterTransformer']; - }[]; - ImportReports: { - /** @example ARy95iNxhniEgYJrRrGv...F49RxFpMqWE_ZGDXEvGV2CEkA== */ - ID?: string; - /** @example 1 */ - Action?: number; - ImportReport?: components['schemas']['ImportReportTransformer']; - }[]; - Contacts: { - /** @example afeaefaeTaBub14w== */ - ID?: string; - /** @example 1 */ - Action?: number; - Contact?: components['schemas']['Contact']; - }[]; - ContactEmails: { - /** @example sadfaACXDmTaBub14w== */ - ID?: string; - /** @example 1 */ - Action?: number; - ContactEmail?: components['schemas']['ContactEmail']; - }[]; - Filters: { - /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ - ID?: string; - /** @example 1 */ - Action?: number; - Filter?: components['schemas']['FilterOutput']; - }[]; - IncomingDefaults: { - /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ - ID?: string; - /** @example 1 */ - Action?: number; - Filter?: components['schemas']['IncomingDefault']; - }[]; - OrgIncomingDefaults: { - /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ - ID?: string; - /** @example 1 */ - Action?: number; - OrgIncomingDefault?: components['schemas']['IncomingDefaultResponse']; - }[]; + SenderAddress: string; + /** + * @deprecated + * @example Me + */ + SenderName: string; + Sender: components["schemas"]["Sender"]; + ToList: components["schemas"]["Recipient"]; + CcList: components["schemas"]["Recipient"]; + BccList: components["schemas"]["Recipient"]; + /** @example 1433890289 */ + Time: number; + /** @example 1433890289 */ + SnoozeTime: number; + /** @example 148 */ + Size: number; + /** + * @deprecated + * @example 1 + */ + IsEncrypted: number; + /** @example 0 */ + ExpirationTime: number; + /** @example 0 */ + IsReplied: number; + /** @example 0 */ + IsRepliedAll: number; + /** @example 0 */ + IsForwarded: number; + /** @example cO6RgDJwoHFScLqIkVnRD9luDVkh20EDto1aIHVHU43-dJlREzFcUjS-c7bB-_qlnxBgwAShddHZ4UDdu6RswQ== */ + AddressID: string; + /** @example cO6RgDJwoHFScLqIkVnRD9luDVkh20EDto1aIHVHU43-dJlREzFcUjS-c7bB-_qlnxBgwAShddHZ4UDdu6RswQ== */ + NewsletterSubscriptionID?: string | null; + LabelIDs: string[]; + /** @example 24 */ + CategoryID: string; + /** @example somesemirandomstringofchars */ + ExternalID: string; + /** + * @description The number of attachments in the message, excluding inline attachments + * @example 2 + */ + NumAttachments: number; + /** + * @description Bitmap of message flags.
+ * * Received = 2^0 Message was received
+ * * Sent = 2^1 Message was sent
+ * * Internal = 2^2 Message is internal
+ * * E2E = 2^3 Message is End-to-End encrypted
+ * * Auto = 2^4 Message was automatically generated
+ * * Replied = 2^5
+ * * RepliedAll = 2^6
+ * * Forwarded = 2^7 Message was forwarded
+ * * Auto replied = 2^8 Message is an automatic reply
+ * * Imported = 2^9 Message was imported
+ * * Opened = 2^10 Message has been opened
+ * * Receipt Sent = 2^11 Message receipt has been sent
+ * * Notified = 2^12 Historical, unused flag, kept here for reservation purposes
+ * * Touched = 2^13
+ * * Receipt = 2^14 Message is a recipt
+ * * Proton = 2^15
+ * * Receipt request = 2^16 Message request a recipt
+ * * Public key = 2^17
+ * * Sign = 2^18 Message is signed
+ * * Unsubscribed = 2^19 Message has been unsubscribed from
+ * * Scheduled send = 2^20 Message was scheduled sent
+ * * 2^21 Not used
+ * * Synced from Gmail = 2^22 Message was synced from Gmail
+ * * DMARC PASS = 2^23 DMARC check passed
+ * * SPF fail = 2^24 SPF check failed
+ * * DKIM fail = 2^25 DKIM check failed
+ * * DMARC fail = 2^26 DMARC check failed
+ * * Ham manual = 2^27 Message was manually marked as ham (non spam)
+ * * Spam auto = 2^28 Message was automatically marked as spam
+ * * Spam manual = 2^29 Message was manually marked as spam
+ * * Phishing auto = 2^30 Message was automatically marked as phishing
+ * * Phishing manual = 2^31 Message was manually marked as phishing
+ * * FrozenExpiration = 2^32 Message expiration time can't be manually edited
+ * * Suspicious = 2^33 Message was automatically marked as suspicious
+ * * Show Snooze Reminder = 2^34 Snooze reminder needs to be shown
+ * * Auto Forwarder = 2^35 Message has been automatically forwarded to another recipient
+ * * Auto Forwardee = 2^35 Message received was automatically forwarded by the sender
+ * * EO Reply = 2^36 Message is a reply to an Encrypted-Outside message
+ * @example 8198 + */ + Flags: number; + AttachmentInfo: components["schemas"]["GroupedAttachmentsCount"]; + /** @description null */ + AttachmentsMetadata: components["schemas"]["Metadata"][]; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 + */ + SenderImage: number; + /** @description Indicates if the client has to display the text saying that the message has been reminded */ + DisplaySnoozedReminder: boolean; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 + */ + IsProton: number; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example default + */ + BimiSelector?: string | null; + }; + Sender2: Record; + Recipient2: Record; + Conversation: { + /** + * @description The ID of the conversation + * @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== + */ + ID: string; + /** + * @description The order is the sum of the conversationID and corresponding userID + * @example 675 + */ + Order: number; + /** + * @description The subject of the conversation + * @example Testing + */ + Subject: string; + /** @description The list of senders */ + Senders: components["schemas"]["Sender2"][]; + /** @description The list of recipients */ + Recipients: components["schemas"]["Recipient2"][]; + /** + * @description The number of messages in the conversation. + * @example 5 + */ + NumMessages: number; + /** + * @description The number of unread messages in the conversation. + * @example 0 + */ + NumUnread: number; + /** + * @description The number of attachments of the messages in the conversation, excluding inline attachments + * @example 0 + */ + NumAttachments: number; + /** + * @description The lowest expiration time of the messages in the conversations.An expiration time of 0 means never. + * @example 0 + */ + ExpirationTime: number; + /** + * @description The sum of the sizes of all the messages in the conversation, expressed in bytes + * @example 3555 + */ + Size: number; + /** @deprecated */ + LabelIDs: string[]; + /** @description List of labels that the conversation has */ Labels: { - /** @example sadfaACXDmTaBub14w== */ - ID?: string; - /** @example 1 */ - Action?: number; - Label?: components['schemas']['Label']; - }[]; - Subscription: components['schemas']['Subscription']; - User: components['schemas']['User'] & { - AccountRecovery?: components['schemas']['AccountRecoveryAttempt']; - }; - UserSettings: components['schemas']['UserSettingsTransformer']; - MailSettings: components['schemas']['Response']; - VPNSettings: { - /** @example test-group */ - GroupID?: string; - } & components['schemas']['VPNSettings']; - Invoices: { - /** @example IlnTbqicN-...-4NvrrIc6GLvDv28aKYVRRrSgEFhR_zhlkA== */ - ID?: string; - /** @example 1 */ - Action?: number; - Invoice?: components['schemas']['Invoice']; - }[]; - Members: { - /** @example LO9aACXDmTaBub14w== */ - ID?: string; - /** @example 1 */ - Action?: number; - Member?: { - /** @example LO9aACXDmTaBub14w== */ - MemberID?: string; - /** @example 1 */ - Role?: number; - /** @example 0 */ - Private?: number; - /** @example 0 */ - Type?: number; - /** - * Format: int64 - * @example 0 - */ - MaxSpace?: number; - /** @example Jason */ - Name?: string; - /** - * Format: int64 - * @example 0 - */ - UsedSpace?: number; - Addresses?: string[]; - }; - }[]; - Domains: { - /** @example 9aACXDmTaBub14w== */ - ID?: string; - /** @example 2 */ - Action?: number; - Domain?: components['schemas']['DomainTransformer']; - }[]; - Addresses: components['schemas']['AddressUser'][]; - SignedAddressList?: components['schemas']['KTAddressListTransformer'] | null; - IncomingAddressForwardings: { - /** @example 9aACXDmTaBub14w== */ - ID?: string; - /** @example 2 */ - Action?: number; - IncomingAddressForwarding?: components['schemas']['IncomingAddressForwardingResponse']; - }[]; - OutgoingAddressForwardings: { - /** @example 9aACXDmTaBub14w== */ + /** @example 0 */ ID?: string; - /** @example 2 */ - Action?: number; - OutgoingAddressForwarding?: components['schemas']['OutgoingAddressForwardingResponse']; + /** @example 0 */ + ContextNumUnread?: number; + /** @example 5 */ + ContextNumMessages?: number; + /** @example 1578070879 */ + ContextTime?: number; + /** @example 0 */ + ContextExpirationTime?: number; + /** @example 541 */ + ContextSize?: number; + /** @example 0 */ + ContextNumAttachments?: number; + /** @example 1578070879 */ + ContextSnoozeTime?: number; }[]; - Organization: { - /** @example E-Corp */ - Name?: string; - /** @example E-Corp */ - DisplayName?: string; - /** @example plus */ - PlanName?: string; - /** - * @description Plan attribution to specific product, 1 = Mail, 2 = Drive, 4 = VPN - * @example 1 - */ - PlanFlags?: number; - /** @example null */ - TwoFactorGracePeriod?: number; - /** @example null */ - Theme?: number; - /** @example contact@e-corp.com */ - Email?: string; - /** @example 4 */ - MaxDomains?: number; - /** @example 20 */ - MaxAddresses?: number; - /** @example 25 */ - MaxCalendars?: number; - /** - * Format: int64 - * @example 10000000000 - */ - MaxSpace?: number; - /** @example 15 */ - MaxMembers?: number; - /** @example 5 */ - MaxVPN?: number; - /** @example 0 */ - Features?: number; + /** + * @description The ID of the category + * @example 24 + */ + CategoryID: string; + /** @description Indicates if the client has to display the text saying that the conversation has been reminded */ + DisplaySnoozedReminder: boolean; + /** + * @description Whether the conversation is expiring due to retention rule + * @example false + */ + ExpiringByRetention: boolean; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 + */ + DisplaySenderImage: Record; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 + */ + IsProton: Record; + /** + * @deprecated + * @description Deprecated, check Sender.* properties + * @example default + */ + BimiSelector?: string | null; + }; + AttachmentsMetadata: { + AttachmentInfo: components["schemas"]["GroupedAttachmentsCount"]; + AttachmentsMetadata: components["schemas"]["Metadata"][]; + }; + ContactEmail: { + /** + * @description ContactList.ContactID + * @example aefew4323jFv0BhSMw== + */ + ID: string; + /** @example test1 */ + Name: string; + /** @example features@protonmail.black */ + Email: string; + /** @description List of email types */ + Type: string[]; + /** + * @description 0 if contact contains custom sending preferences or keys, 1 otherwise + * @example 1 + */ + Defaults: number; + /** @example 1 */ + Order: number; + /** @example a29olIjFv0rnXxBhSMw== */ + ContactID: string; + /** @description Groups */ + LabelIDs: string[]; + /** @example features@protonmail.black */ + CanonicalEmail: string; + /** @description The last time the User sent a message to this ContactEmail */ + LastUsedTime: number; + /** + * @description Tells whether this is an official Proton address + * @example 1 + */ + IsProton: number; + }; + ContactData: { + /** + * @description Possible values: + *
- 0: clear text + *
- 1: encrypted + *
- 2: signed + *
- 3: encrypted and signed + * @example 2 + * @enum {integer} + */ + Type: 0 | 1 | 2 | 3; + /** + * @description VCard data + * @example BEGIN:VCARD + * VERSION:4.0 + * FN:ProtonMail Features + * UID:proton-legacy-139892c2-f691-4118-8c29-061196013e04 + * item1.EMAIL;TYPE=work;PREF=1:features@protonmail.black + * item2.EMAIL;TYPE=home;PREF=2:features@protonmail.ch + * END:VCARD + */ + Data: string; + /** + * @description PGP signature of the data + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + */ + Signature: string; + }; + Contact: { + /** + * @description Encrypted ID + * @example a29olIjFv0rnXxBhSMw== + */ + ID: string; + /** @example ProtonMail Features */ + Name: string; + /** @example proton-legacy-139892c2-f691-4118-8c29-061196013e04 */ + UID: string; + /** @example 1434 */ + Size: number; + /** + * Format: timestamp + * @example 1503815366 + */ + CreateTime: number; + /** + * Format: timestamp + * @example 1503815366 + */ + ModifyTime: number; + /** @description List of emails, only included when returning one record */ + ContactEmails: components["schemas"]["ContactEmail"][]; + /** @description Labels on Contact, ignore, maybe future feature */ + LabelIDs: string[]; + /** @description Only included when returning one record */ + Cards: components["schemas"]["ContactData"][]; + }; + Tree: { + List?: string[]; + /** @example Require */ + Type?: string; + }[]; + FilterOutput: { + ID: components["schemas"]["Id"]; + Name: string; + /** @example 1 */ + Status: number; + /** @example 3 */ + Priority: number; + /** @example require ["fileinto"]; + * + * if address :DOMAIN :is ["From", "Delivered-To"] "protonmail.ch" { + * fileinto "mylabel"; + * } else + * keep; + * } */ + Sieve: string; + Tree: components["schemas"]["Tree"]; + /** @example 1 */ + Version: number; + }; + IncomingDefault: Record; + IncomingDefaultResponse: { + /** ID */ + ID: string; + Location: number; + Type: number; + Time: number; + Email?: string | null; + }; + Label2: { + /** @example sadfaACXDmTaBub14w== */ + ID: string; + /** @example Event Label! */ + Name: string; + /** @example Folder/Event Label! */ + Path: string; + /** @example 1 */ + Type: number; + /** @example #f66 */ + Color: string; + /** @example 8 */ + Order: number; + /** @example 1 */ + Notify: number; + /** @example 1 */ + Expanded: number; + /** @example 1 */ + Sticky: number; + /** @example sadfaACXDmTaBub14w== */ + ParentID: string; + /** + * @description v3 only + * @example 1 + */ + Display: number; + /** + * @description v3 only + * @example 0 + */ + Exclusive: number; + }; + Response: { + /** @example Put Chinese Here */ + DisplayName: string; + /** @example This is my signature */ + Signature: string; + /** @example */ + Theme: string; + /** @description Automatically respond to incoming messages */ + AutoResponder: { /** @example 0 */ - Flags?: number; + StartTime?: number; /** @example 0 */ - UsedDomains?: number; + Endtime?: number; /** @example 0 */ - UsedAddresses?: number; - /** - * Format: int64 - * @example 81788997 - */ - UsedSpace?: number; - /** @example 10000000000 */ - AssignedSpace?: number; - /** @example 1 */ - UsedMembers?: number; - /** @example 5 */ - UsedVPN?: number; - /** @example 1 */ - HasKeys?: number; + Repeat?: number; + DaysSelected?: string[]; + /** @example Auto */ + Subject?: string; + /** @example */ + Message?: string; + /** @example null */ + IsEnabled?: boolean | null; + /** @example Europe/Zurich */ + Zone?: string; }; - MessageCounts: { - /** @example 0 */ - LabelID?: string; - /** @example 15 */ - Total?: number; - /** @example 6 */ - Unread?: number; - }[]; - ConversationCounts: { - /** @example 0 */ - LabelID?: string; - /** @example 4 */ - Total?: number; - /** @example 3 */ - Unread?: number; - }[]; /** - * Format: int64 - * @description Used space (in bytes) - * @example 70376905 + * @description Automatically save the recipients as contact. + * If enabled, when a user sends an email, the recipients are automatically added to his contact list. + * Implemented by the backend. + * Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + AutoSaveContacts: number; + /** + * @deprecated + * @description Automatically convert simple queries to wildcarded versions, such as `test` to `*test*`. + * Implemented by web client V3. With v4 everything is wildcarded by default. + * Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + AutoWildcardSearch: number; + /** + * @description Possible values: + * - 0: normal + * - 1: maximized + * @default 0 + */ + ComposerMode: number; + /** + * @description Possible values: + * - 0: read first + * - 1: unread first + * @default 0 + */ + MessageButtons: number; + /** + * @description Possible values: + * - 0: don't auto load + * - 1: auto-load remote content + * - 2: auto-load embedded images + * - 3: auto-load both + * @default 2 + */ + ShowImages: number; + /** + * @description Possible values: + * - 0: don't keep + * - 1: keep draft messages in Draft folder + * - 2: keep sent messages in Sent folder + * - 3: keep both draft and sent messages in their respective folders + * @default 0 + */ + ShowMoved: number; + /** + * @description delay in days before messages put in trash and spam are permanantly deleted + * + * - null: implicitly disabled + * - 0: explicitly disabled + * @default null + */ + AutoDeleteSpamAndTrashDays: number | null; + /** + * @description Possible values: + * - 0: Client should show the `ALL_MAIL` label + * - 1: Client should show the `ALMOST_ALL_MAIL` label + * @default 0 + */ + AlmostAllMail: number; + /** + * @description Whether to load next message when current message is moved somewhere else + * - null: implicitly disabled + * - 0: explicitly disabled + * - 1: implictly disabled + * - 2: explicitly enabled + * @default 0 + * @enum {integer|null} + */ + NextMessageOnMove: 0 | 1 | 2 | null; + /** + * @description Possible values: + * - 0: enable conversation mode + * - 1: no conversation grouping + * @default 0 + */ + ViewMode: number; + /** + * @description Possible values: + * - 0: column + * - 1: row + * @default 0 + */ + ViewLayout: number; + /** + * @description Swipe left action. + * Action taken when user swipes a message to the left on mobile. + * Implemented by the client. + * Possible values: + * - 0: Trash + * - 1: Spam + * - 2: Star + * - 3: Archive + * - 4: Mark as read + * @default 3 + */ + SwipeLeft: number; + /** + * @description Swipe right action. + * Action taken when user swipes a message to the right on mobile. + * Implemented by the client. + * Possible values: + * - 0: Trash + * - 1: Spam + * - 2: Star + * - 3: Archive + * - 4: Mark as read + * @default 0 + */ + SwipeRight: number; + /** + * @deprecated + * @example 0 + */ + AlsoArchive: number; + /** + * @deprecated + * @default 0 + */ + Hotkeys: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + Shortcuts: number; + /** + * @description Flags of the bitmap: + * - 1st bit: Enabled + * - 2nd bit: Locked + * @default 0 + */ + PMSignature: number; + /** + * @description Possible values: + * - 0: Disabled + * - 1: Enabled + * @default 0 + */ + PMSignatureReferralLink: number; + /** + * @description Bitmap of image proxy related settings. + * - IncorporateImages: 1 (2^0), whether remote images are downloaded and incorporated into mail at delivery. + * Implemented by the backend. + * - ProxyImages : 2 (2^1), whether loading remote images on the clients passes through the proton proxy. + * Implemented by the client. + * @default 0 + */ + ImageProxy: number; + /** @example 50 */ + NumMessagePerPage: number; + /** + * @description Default mime type of drafts. Implemented by the client. + * Possible values: + * - 'text/html' + * - 'text/plain' + * @example text/html + */ + DraftMIMEType: string; + /** + * @description Preferred mime type of received messages. Implemented by the backend. + * Possible values: + * - 'text/html' + * - 'text/plain' + * @example text/html + */ + ReceiveMIMEType: string; + /** @example text/html */ + ShowMIMEType: string; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + EnableFolderColor: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 1 + */ + InheritParentFolderColor: number; + /** + * @description Possible values: + * - 0: disabled + * - 1: enabled + * @default 0 + */ + SubmissionAccess: number; + /** + * @deprecated + * @default 0 + */ + TLS: number; + /** + * @description Composer text direction. + * The direction of the text inside the message composer. + * Implemented by the client. + * Possible values: + * - 0: left to right + * - 1: right to left + * @default 0 + */ + RightToLeft: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + AttachPublicKey: number; + /** + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + Sign: number; + /** + * @description Default PGP scheme to use when sending externally. Implemented by the client. + * Possible values: + * - 8: PGP Inline + * - 16: PGP Mime + * @default 16 + */ + PGPScheme: number; + /** + * @description Prompt to trust key. + * When opening a message from another protonmail user for which there is no pinned key, prompt to pin key. + * Pinning the key results in updating the contact. + * Implemented by the client. + * Possible values: + * - 0: disable + * - 1: enable + * @default 0 + */ + PromptPin: number; + /** + * @deprecated + * @default 0 */ - UsedSpace: number; - ProductUsedSpace: components['schemas']['UserUsage']; - VPNProfiles: { - /** @example q_9v-GXEPLagg81jsUz2mHQ== */ - ID?: string; - /** @example 2 */ - Action?: number; - VPNProfile?: components['schemas']['VPNProfile']; - }[]; - LogicalServers: components['schemas']['VPNLogical']; - Calendars: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - Calendar?: components['schemas']['CalendarWithMemberWithFlagsOutput']; - }[]; - CalendarMembers: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - Member?: components['schemas']['MemberWithFlagsOutput']; - }[]; - Pushes: { - /** @example 1H8EGg3J1QpSDL6K8hGs...hrHx6nnGQ== */ - PushID?: string; - /** - * @description Any objectID from the event feed (*WARNING*: the object can be on another page) - * @example KPlISx5MiML3Xc...3MAOfsds-tfNw== - */ - ObjectID?: string; - /** - * @description Type of the ObjectID - * @example Messages - */ - Type?: string; - }[]; - Notifications: components['schemas']['EventLoopNotificationTransformer'][]; - CalendarUserSettings: components['schemas']['UserSettingsTransformer2']; - Wallets: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - Wallet?: components['schemas']['WalletOutput']; - }[]; - WalletAccounts: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - WalletAccount?: components['schemas']['WalletAccountOutput']; - }[]; - WalletBitcoinAddresses: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - WalletBitcoinAddress?: components['schemas']['WalletBitcoinAddressOutput']; - }[]; - WalletKeys: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - WalletKey?: components['schemas']['WalletKeyOutput']; - }[]; - WalletSettings: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - WalletSettings?: components['schemas']['WalletSettingsOutput']; - }[]; - WalletTransactions: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - WalletTransaction?: components['schemas']['WalletTransactionOutput']; - }[]; - WalletUserSettings: components['schemas']['WalletUserSettingsOutput']; - Notices: string[]; - } & components['schemas']['DriveShareRefreshCoreEventService']; - NotificationVersionTransformer: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - NotificationID: string; - /** @example 1601582623 */ - StartTime: number; - /** @example 1845561234 */ - EndTime: number; + Autocrypt: number; /** - * @description Possible values:
- 0: offer - * @example 0 + * @description When a message is created, add to it all the labels of the other messages in its conversation. + * Possible values: + * - 0: disable + * - 1: enable + * @default 0 */ - Type: number; - /** @description Offer property will be present only when Type is 0 */ - Offer: { - /** @example https://protonvpn.com/black-friday */ - URL?: string; - /** @example https://protonvpn.com/resources/bf.png */ - Icon?: string; - /** - * @description Translated label based on the user's locale - * @example Black-Friday arrived! - */ - Label?: string; - }; - }; - Label: { - /** @example sadfaACXDmTaBub14w== */ - ID: string; - /** @example Event Label! */ - Name: string; - /** @example Folder/Event Label! */ - Path: string; - /** @example 1 */ - Type: number; - /** @example #f66 */ - Color: string; - /** @example 8 */ - Order: number; - /** @example 1 */ - Notify: number; - /** @example 1 */ - Expanded: number; - /** @example 1 */ - Sticky: number; - /** @example sadfaACXDmTaBub14w== */ - ParentID: string; + StickyLabels: number; /** - * @description v3 only - * @example 1 + * @description Possible values: + * - 0: disable + * - 1: enable + * @default 1 */ - Display: number; + ConfirmLink: number; /** - * @description v3 only - * @example 0 + * @description Possible values between 0 and 30 + * @default 10 */ - Exclusive: number; - }; - /** @description An armored PGP Private Key */ - PGPPrivateKey: string; - SignedKeyListInput: { - /** @example JSON.stringify([{""Fingerprint"": ""fde90483475164ec6353c93f767df53b0ca8395c"",""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Primary"": 1,""Flags"": 3}]) */ - Data: string; - /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature: string; - }; - AddressKeyInput5: { + DelaySendSeconds: number; + /** @default 0 */ + KT: number; /** - * @description The address ID - * @example ACXDmTa...Bub14w== + * @description Possible values between 10 and 26 + * @default null */ - AddressID: string; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey: string; - /** @example -----BEGIN PGP MESSAGE-----.* */ - Token: string; - /** @example -----BEGIN PGP SIGNATURE-----.* */ - Signature: string; - SignedKeyList: components['schemas']['SignedKeyListInput']; - /** @example 3 */ - Revision: Record; - }; - AuthInput2: { - /** @example 4 */ - Version: number; - /** @example */ - ModulusID: string; - /** @example */ - Salt: string; - /** @example */ - Verifier: string; - }; - Fido2Input: { - /** @description The same AuthenticationOptions received as a challenge from the server */ - AuthenticationOptions: Record; - /** @description clientData (base64) returned from the client authentication library */ - ClientData: string; - /** @description authenticatorData (base64) returned from the client authentication library */ - AuthenticatorData: string; - /** @description signature (base64) returned from the client authentication library */ - Signature: string; - /** @description CredentialID used */ - CredentialID: Record[]; - }; - /** @description Base64 encoded binary data */ - BinaryString: string; - ResetAuthDevicesUserKeyDto: { - ID: components['schemas']['EncryptedId']; - PrivateKey: components['schemas']['PGPPrivateKey']; - }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Internal
1External
2InternalTypeExternal
- * @enum {integer} - */ - GroupMemberType: 0 | 1 | 2; - /** @description An armored PGP Signature */ - PGPSignature: string; - /** @description An armored PGP Message */ - PGPMessage: string; - GroupProxyInstance: { - PgpVersion: number; - GroupAddressKeyFingerprint: string; - GroupMemberAddressKeyFingerprint: string; - ProxyParam: string; + FontSize: number | null; + /** @default null */ + FontFace: string; + /** + * @description Configure additional actions to take when messages or conversations are moved to spam + * Possible values: + * - null: ask what to do every time + * - 0: do nothing else + * - 1: unsubscribe with one-click list-unsubscribe if possible + * @default null + */ + SpamAction: number | null; + /** + * @description Whether the user wants to be asked for confirmation before blocking a sender + * Possible values: + * - null: ask for confirmation every time + * - 1: block sender without asking for confirmation + * @default null + */ + BlockSenderConfirmation: number | null; + /** @description Mobile-specific settings, only returned for mobile clients */ + MobileSettings: { + MessageToolbar?: { + IsCustom?: boolean; + Actions?: string[]; + }; + ConversationToolbar?: { + IsCustom?: boolean; + Actions?: string[]; + }; + ListToolbar?: { + IsCustom?: boolean; + Actions?: string[]; + }; + }; + /** + * @description Whether the user wants to have embedded-images hidden on this client. The default vlaue is 0. + * Possible values: + * - 0: Show embedded images + * - 1: Hide embedded images + * @default 0 + * @enum {integer} + */ + HideEmbeddedImages: 1 | 0; + /** + * @description Whether the user wants to have remote-images hidden on this client. The default vlaue is 0. + * Possible values: + * - 0: Show remote images + * - 1: Hide remote images + * @default 0 + * @enum {integer} + */ + HideRemoteImages: 1 | 0; + /** + * @description Whether the user wants to have sender-images hidden. The value is `0` by default. + * Possible values: + * - 0: Do not hide sender images + * - 1: Hide sender images + * @example 1 + * @enum {integer} + */ + HideSenderImages: 1 | 0; + /** + * @description Whether the user wants to remove metadata from image attachments. The value is `0` by default. + * Possible values: + * - false: Do not remove image metadata + * - true: Remove image metadata + * @example true + */ + RemoveImageMetadata: Record; + /** + * @description Whether the user wants to view his Inbox grouped by message category. The value is `true` by default. + * @example true + */ + MailCategoryView: Record; }; + OrganizationPasswordPolicyInputOutput2: Record; /** - * @description
See values descriptions
See values descriptions
ValueDescription
0NobodyCanSend
1GroupMembersCanSend
2OrgMembersCanSend
3EveryoneCanSend
+ * @description
See values descriptions
ValueDescription
1InternalEncrypted
2ExternalUnencrypted
3ExternalEncrypted
* @enum {integer} */ - GroupPermissions: 0 | 1 | 2 | 3; + AddressForwardingType: 1 | 2 | 3; /** - * @description
See values descriptions
See values descriptions
ValueDescription
0None
+ * @description
See values descriptions
ValueDescription
0Pending
1Active
2Outdated
3Paused
4Rejected
* @enum {integer} */ - GroupFlags: 0; + AddressForwardingState: 0 | 1 | 2 | 3 | 4; + ActivationForwardingKey: { + /** + * PGP message, encrypted with the forwardee address key and signed with the forwarder address key. + * @description The embedded secret is a 64-char hex string. + */ + ActivationToken: string; + /** Armored PGP private key, locked with the token */ + PrivateKey: string; + }; /** - * @description
See values descriptions
See values descriptions
ValueDescription
0None
1Send
2Leave
3SendAndLeave
+ * @description
See values descriptions
ValueDescription
2V2
* @enum {integer} */ - GroupMemberPermissions: 0 | 1 | 2 | 3; - ExternalGroupMembership: { - ID: components['schemas']['Id']; - /** Format: date-time */ - CreateTime: string; - State: components['schemas']['GroupMemberState']; - Type: components['schemas']['GroupMemberType']; - Email?: string | null; - Permissions: components['schemas']['GroupMemberPermissions']; - /** Format: date-time */ - JoinTime?: string | null; - Group: components['schemas']['GroupMembershipGroup']; + SieveVersion: 2; + /** AddressForwardingFilter */ + AddressForwardingFilter: { + Tree: components["schemas"]["Tree"]; + Sieve: string; + Version: components["schemas"]["SieveVersion"]; }; - GroupMember: { - ID: components['schemas']['Id']; - /** Format: date-time */ - CreateTime: string; - GroupID: components['schemas']['Id']; - State: components['schemas']['GroupMemberState']; - Type: components['schemas']['GroupMemberType']; - AddressID?: components['schemas']['Id'] | null; - Email?: string | null; - Permissions: components['schemas']['GroupMemberPermissions']; + /** IncomingAddressForwardingOutput */ + IncomingAddressForwardingOutput: { + ID: components["schemas"]["Id"]; + Type: components["schemas"]["AddressForwardingType"]; + State: components["schemas"]["AddressForwardingState"]; + /** When an email is received by forwarderEmail, it will be forwarded to forwardeeEmail or forwardeeAddressID */ + ForwarderEmail: string; + ForwardeeAddressID: components["schemas"]["Id"]; + CreateTime: number; + /** The forwarding keys encrypted to the tokens. They are present only for encrypted forwarding + * in the pending state. To activate the forwarding all of them must be re-encrypted to the user + * keys and added to the correct address keyring. */ + ForwardingKeys: components["schemas"]["ActivationForwardingKey"][]; + Filter?: components["schemas"]["AddressForwardingFilter"] | null; + }; + /** OutgoingAddressForwardingOutput */ + OutgoingAddressForwardingOutput: { + ID: components["schemas"]["Id"]; + Type: components["schemas"]["AddressForwardingType"]; + State: components["schemas"]["AddressForwardingState"]; + ForwarderAddressID: components["schemas"]["Id"]; + /** The final email address to forward messages to * */ + ForwardeeEmail: string; + CreateTime: number; + Filter?: components["schemas"]["AddressForwardingFilter"] | null; }; - InternalGroupMembership: { - ID: components['schemas']['Id']; - /** Format: date-time */ - CreateTime: string; - State: components['schemas']['GroupMemberState']; - Type: components['schemas']['GroupMemberType']; - AddressId?: components['schemas']['Id'] | null; - Email?: string | null; - Permissions: components['schemas']['GroupMemberPermissions']; - /** Format: date-time */ - JoinTime?: string | null; - TokenKeyPacket?: components['schemas']['BinaryString'] | null; - TokenSignaturePacket?: components['schemas']['BinaryString'] | null; - AddressSignaturePacket?: components['schemas']['BinaryString'] | null; - Group: components['schemas']['GroupMembershipGroup']; - ForwardingKeys: components['schemas']['ForwardingKeys']; - GroupID: components['schemas']['Id']; + EventLoopNotificationTransformer: { + /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ + ID: string; + /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ + UserID: string; + /** @example account_recovery */ + Type: string; + /** @description timestamp */ + Time: Record; + Payload: { + Title?: string; + Subtitle?: string; + Body?: string; + }; }; - AddressKeyToken: { + DriveShareRefreshCoreEventService: { + DriveShareRefresh: { + /** @enum {integer} */ + Action?: 2; + }; + }; + MemberWithFlagsOutput: { /** - * @description Encrypted Address key ID to replace the token - * @example G1MbEt3Ep5P_E...Wz8WbHVAOl_6h== + * @description The calendar flags bitmap:
- `0`: Inactive: the calendar keys are not accessible and the current user cannot fix it
- `1`: Active: the calendar is all good!
- `2`: Update passphrase: a deactivated passphrase is again accessible, you should re-encrypt the linked calendar key using the primary passphrase
- `4`: Reset needed: the calendar needs to be reset
- `8`: Incomplete setup: the calendar setup was not completed, need to setup the key and passphrase
- `16`: Lost access: the user lost access to the calendar but an admin can re-invite him
+ * @example 1 */ - AddressKeyID: string; + Flags: number; + ID: components["schemas"]["Id"]; /** - * @description Base-64 encoded key packet - * @example slCpH6qWMKGQ7d...R4eLU2+2BZvK0UeG/QY2 + * @description Flags bitmap:
- `1`: Super-owner
- `2`: Owner
- `4`: Admin
- `8`: Read member list
- `16`: Write events
- `32`: Read events (full details)
- `64`: Availability view only
+ * @example 63 */ - KeyPacket: string; + Permissions: number; + /** @example andy@pm.me */ + Email: string; + AddressId: components["schemas"]["Id"]; + CalendarId: components["schemas"]["Id"]; + /** @example Organizational Calendar */ + Name: string; + /** @example This text describes the calendar */ + Description: string; + /** @example #8989AC */ + Color: string; + /** @example 1 */ + Display: number; /** - * @description Token signature produced with the primary user key - * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + * @description Priority describing the order of the member, 1 is highest + * @example 1 */ - Signature: string; + Priority: number; }; /** - * @description

Either 1=PROTON or 2=MANAGED (default)

See values descriptions
See values descriptions
ValueDescription
1Proton
2Managed
3External
4CredentialLess
+ * @description

normal calendar: `0`, subscribed calendar: `1`

See values descriptions
ValueDescription
0Normal
1Subscription
* @enum {integer} */ - UserType: 1 | 2 | 3 | 4; - MagicLinkInvitationInput: { - /** - * @description Invitation data containing address and expected KT revision - * @example {"Address":"member@internal-domain.com", "Revision":2} - */ - Data: Record; - Signature?: components['schemas']['PGPSignature'] | null; + CalendarType: 0 | 1; + CalendarOwner: { /** - * @description The email to send an invitation to - * @example some.user@example.com + * @description owner's email + * @example owner@pm.me */ Email: string; - /** @description Whether the member should remain private after creation or be unprivatized */ - PrivateIntent: boolean; + }; + CalendarWithMemberWithFlagsOutput: { + Members: components["schemas"]["MemberWithFlagsOutput"][]; + ID: components["schemas"]["Id"]; + /** @description normal calendar: `0`, subscribed calendar: `1` */ + Type: components["schemas"]["CalendarType"]; + Owner: components["schemas"]["CalendarOwner"]; + /** Format: date-time */ + CreateTime: string; }; /** - * @description
See values descriptions
See values descriptions
ValueDescription
1ManageForwarding
+ * @description
See values descriptions
ValueDescription
0None
1Zoom
2Meet
* @enum {integer} */ - MemberPermission: 1; + AutoAddConferenceLinkProvider: 0 | 1 | 2; /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Remove
1Add
+ * @description

True if notification must be displayed to confirm enabling auto-add conference link feature

See values descriptions
ValueDescription
1True
0False
* @enum {integer} */ - MemberPermissionAction: 0 | 1; - UserKeyInput: { - PrivateKey: components['schemas']['PGPPrivateKey']; - OrgPrivateKey: components['schemas']['PGPPrivateKey']; - /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ - OrgToken: string; + BoolInt2: 1 | 0; + AutoAddConferenceLink: { + Provider: components["schemas"]["AutoAddConferenceLinkProvider"]; + /** @description True if notification must be displayed to confirm enabling auto-add conference link feature */ + DisplayNotification: components["schemas"]["BoolInt2"]; }; - AuthInfoInput2: { + /** CalendarUserSettings */ + UserSettingsTransformer2: { /** - * @description 4 is the current version, older versions are not accepted - * @example 4 + * @description `0`: 7 Days, `1`: 5 Days + * @example 0 + * @enum {integer} */ - Version: number; - /** @example */ - ModulusID: string; - /** @example */ - Salt: string; - /** @example */ - Verifier: string; - }; - UnprivatizeMemberUserKeyDto: { - OrgPrivateKey: components['schemas']['PGPPrivateKey']; - OrgToken: components['schemas']['PGPMessage']; - }; - UnprivatizeMemberAddressKeyDto: { - AddressKeyID: components['schemas']['Id']; - OrgTokenKeyPacket: components['schemas']['BinaryString']; - OrgSignature: components['schemas']['PGPSignature']; - }; - ReplaceOrganizationKeyInvitationDto: { - MemberID: components['schemas']['Id']; - TokenKeyPacket: components['schemas']['BinaryString']; - Signature: components['schemas']['PGPSignature']; - SignatureAddressID: components['schemas']['Id']; - EncryptionAddressID: components['schemas']['Id']; - }; - ReplaceOrganizationKeyActivationDto: { - MemberID: components['schemas']['Id']; - TokenKeyPacket: components['schemas']['BinaryString']; - Signature: components['schemas']['PGPSignature']; - }; - MigrateOrganizationKeyInvitationDto: { - MemberID: components['schemas']['Id']; - TokenKeyPacket: components['schemas']['BinaryString']; - Signature: components['schemas']['PGPSignature']; - }; - MigrateOrganizationKeyActivationDto: { - MemberID: components['schemas']['Id']; - TokenKeyPacket: components['schemas']['BinaryString']; - Signature: components['schemas']['PGPSignature']; - }; - /** - * @description

Possible values:
- 0: Unset
- 1: Off
- 2: Server-Only
- 3: Client-Only

See values descriptions
See values descriptions
ValueDescription
0Unset
1Off
2ServerOnly
3ClientOnly
- * @enum {integer} - */ - AIAssistantFlags: 0 | 1 | 2 | 3; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
4Google
6AppleProd
7AppleBeta
16AppleDev
- * @enum {integer} - */ - Environment: 4 | 6 | 7 | 16; - /** @description An armored PGP Public Key */ - PGPPublicKey: string; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Off
1On
- * @enum {integer} - */ - PingNotificationStatus: 0 | 1; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Off
1On
- * @enum {integer} - */ - PushNotificationStatus: 0 | 1; - /** - * @description

1: email, 2: VPN, 3: calendar, 4: drive, 5: pass

See values descriptions
See values descriptions
ValueDescription
1Mail
2VPN
3Calendar
4Drive
5Pass
6Wallet
7Neutron
8Contacts
9Lumo
- * @enum {integer} - */ - Product: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; - EventCollectionOutput: components['schemas']['EventOutput'][]; - /** @description An encrypted Label ID and default integer Label ID */ - LabelID: string; - /** Product used space */ - UserUsage: { - Calendar: number; - Contact: number; - Drive: number; - Mail: number; - Pass: number; - }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Paid
1Available
2Overdue
3Delinquent
4NotReceived
- * @enum {integer} - */ - DelinquentState: 0 | 1 | 2 | 3 | 4; - /** AddressKey */ - AddressKey: { + WeekLength: 0 | 1; /** - * @description Encrypted AddressKey ID - * @example G1MbEt3...Ol_6h== + * @description `0`: Off, `1`: On + * @example 1 + * @enum {integer} */ - ID: string; + DisplayWeekNumber: 0 | 1; /** - * @description Latest version is 3 - * @example 3 + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} */ - Version: number; + AutoDetectPrimaryTimezone: 0 | 1; + /** @example Antarctica/Macquarie */ + PrimaryTimezone: string; /** - * @deprecated - * @description Deprecated! Do not rely on public keys returned from the API! - * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.* + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} */ - PublicKey: string; + DisplaySecondaryTimezone: 0 | 1; /** - * @description This parameter is missing ONLY in the key reset call - * @example -----BEGIN PGP PRIVATE KEY BLOCK----- + * @description Can be null if DisplaySecondaryTimezone is 0 + * @example null */ - PrivateKey?: string | null; + SecondaryTimezone: string; /** - * @description This can be the token to decrypt the address key via the user key - * or a legacy token if logging in as sub-user or null for private legacy keys user - * @example null or -----BEGIN PGP MESSAGE-----.* + * @description `0`: DAILY, `1`: WEEKLY, `2`: MONTHLY, `3`: YEARLY, `4`: PLANNING + * @example 1 + * @enum {integer} */ - Token?: string | null; + ViewPreference: 0 | 1 | 2 | 3 | 4; /** - * @description If this field is present, the key is migrated. Use it to verify the token! - * @example null or -----BEGIN PGP SIGNATURE----- + * @description Can be null, if the calendar type is `subscription`, instead of `normal`, it cannot be set as the default calendar + * @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - Signature?: string | null; + DefaultCalendarID: string; + /** + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} + */ + ShowCancelled: 0 | 1; + /** + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} + */ + ShowDeclined: 0 | 1; /** - * @deprecated - * @description Deprecated! Do not rely on fingerprints returned from the API! - * @example c93f767df53b0ca8395cfde90483475164ec6353 + * @description `0`: Off, `1`: On + * @example 0 + * @enum {integer} */ - Fingerprint: string; + AutoImportInvite: 0 | 1; + /** @description Bitmap of whom to share busy-schedule with:
- 1 (2^0): To users in the same organization */ + ShareBusySchedule: number; + AutoAddConferenceLink: components["schemas"]["AutoAddConferenceLink"]; + }; + EventInfoCalendar: { + Calendars: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + Calendar?: components["schemas"]["CalendarWithMemberWithFlagsOutput"]; + }[]; + CalendarMembers: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + Member?: components["schemas"]["MemberWithFlagsOutput"]; + }[]; + CalendarUserSettings: components["schemas"]["UserSettingsTransformer2"]; + }; + /** Importer */ + ImporterTransformer: { + /** @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEp-fhjBbUPDMHGU699fw== */ + ID: string; + /** @example test@protonmail.dev */ + Account: string; + Product: string[]; /** - * @deprecated - * @description Deprecated! Do not rely on fingerprints returned from the API! + * @description 0: IMAP, 1: Google + * @example 1 */ - Fingerprints: string[]; + Provider: number; /** - * @deprecated - * @description Deprecated! - * Migrated accounts do not have the activation field set, - * and they get migrated automatically on login. - * @example -----BEGIN PGP MESSAGE-----.* + * @description nullable, present only with token flow + * @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEp-fhjBbUPDMHGU699fw== */ - Activation?: string | null; + TokenID: string; /** - * @description 0 or 1. There is only one primary key per address - * @example 1 + * @description Modify time of the importer + * @example 12345678 */ - Primary: number; + ModifyTime: number; /** - * @description 0 or 1. - * All active keys should decrypt successfully and all inactive keys should not be decrypted. - * @example 1 + * @description nullable, present only for IMAP flow + * @example imap.mail.ru */ - Active: number; + ImapHost: string; /** - * @description Flags (bitmap): - * * `1`: Can use key to verify signatures; - * * `2`: Can use key to encrypt new data; - * * `4`: Can be used to encrypt email; - * * `8`: Do not expect signed email from this key; - * @example 3 + * @description nullable, present only for IMAP flow + * @example 993 */ - Flags: number; + ImapPort: number; /** - * @description If not null, it represents a valid associated Address Forwarding instance - * @example fWIio823...j45sL== + * @description nullable, present only for IMAP flow + * @example PLAIN */ - AddressForwardingID: string; + Sasl: string; /** - * @description If not null, it represents a valid associated Group Member instance - * @example fWIio823...j45sL== + * @description nullable, present only for IMAP flow - 1 if certificate is not verified + * @example 0 */ - GroupMemberID: string; - }; - /** - * @description

The current device state

See values descriptions
See values descriptions
ValueDescription
0Inactive
1Active
2PendingActivation
3PendingAdminActivation
4Rejected
5NoSession
- * @enum {integer} - */ - AuthDeviceState: 0 | 1 | 2 | 3 | 4 | 5; - TranslatedStringInterface: Record; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Pending
1Active
2Outdated
3Paused
4Rejected
- * @enum {integer} - */ - GroupMemberState: 0 | 1 | 2 | 3 | 4; - OrganizationKeyInvitationDto: { - TokenKeyPacket: components['schemas']['BinaryString']; - /** @description Signature of the token key packet by the inviters address key */ - Signature: string; - SignatureAddressID: components['schemas']['EncryptedId']; - EncryptionAddressID: components['schemas']['EncryptedId']; - }; - OrganizationKeyActivationDto: { - TokenKeyPacket: components['schemas']['BinaryString']; - /** @description Signature of the token key packet by the user key of the member */ - Signature: string; + AllowSelfSigned: number; + /** @example 76844 */ + INBOX: number; + /** @example 0 */ + "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043D\u044B\u0435": number; + /** @example 0 */ + "\u0427\u0435\u0440\u043D\u043E\u0432\u0438\u043A\u0438": number; + /** @example 0 */ + "INBOX/Social": number; + /** @example 0 */ + "INBOX/Newsletters": number; + /** @description optional, present if there is an ongoing import */ + Active: { + Calendar?: { + /** @example 1601053249 */ + CreateTime?: number; + /** @example 1601053249 */ + AttemptTime?: number; + /** @example 1 */ + ReportID?: number; + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** + * @description `0`: No error, `1`: Connection error, `2`: Storage limit + * @example 0 + * @enum {integer} + */ + ErrorCode?: 0 | 1 | 2; + Mapping?: { + /** @example INBOX */ + Source?: string; + /** @example 21 */ + Processed?: number; + }[]; + }; + Contacts?: { + /** @example 1601053249 */ + CreateTime?: number; + /** @example 1601053249 */ + AttemptTime?: number; + /** @example 1 */ + ReportID?: number; + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** + * @description `0`: No error, `1`: Connection error, `2`: Storage limit + * @example 0 + * @enum {integer} + */ + ErrorCode?: 0 | 1 | 2; + /** @example 21 */ + NumContacts?: number; + /** @example 21 */ + Processed?: number; + /** @example 80 */ + Total?: number; + }; + Mail?: { + /** @example 1601053249 */ + CreateTime?: number; + /** @example 1601053249 */ + AttemptTime?: number; + /** @example 1 */ + ReportID?: number; + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** + * @description `0`: No error, `1`: Connection error, `2`: Storage limit + * @example 0 + * @enum {integer} + */ + ErrorCode?: 0 | 1 | 2; + /** @example qmhrlFY24BhSHiFplF0B...YnqgI4-MpAb8h3JhOOykKv8ZsuTH8X_SrUZSg== */ + AddressID?: string; + /** @example 1601053249 */ + FilterStartDate?: number; + /** @example 1601053249 */ + FilterEndDate?: number; + Mapping?: { + /** @example INBOX */ + Source?: string; + /** @example 21 */ + Processed?: number; + /** + * @description except for gmail + * @example 80 + */ + Total?: number; + }[]; + }; + }; }; - /** @enum {string} */ - AuthLogStatus: 'success' | 'attempt' | 'failure'; - /** - * @description

ID of protection applied.
- * Can be missing. Only present if user has High Security enabled.
- * See AuthLogProtection enum for possible values.

See values descriptions
See values descriptions
ValueNameDescription
1Block
2Captcha
3OwnershipVerification
4DeviceVerification
5Ok* AuthLog action was protected by anti-abuse systems - * * and was evaluated as safe.
- * @enum {integer} - */ - AuthLogProtection: 1 | 2 | 3 | 4 | 5; - /** - * @description

0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation

See values descriptions
See values descriptions
ValueNameDescription
0NoKeyThe member does not and should not have access to the org key (e.g. not an admin)
1ActiveThe member has full access to the most recent copy of the org key
2MissingThe member does not have access to the most recent copy of the org key (including legacy keys)
3PendingThe member has been invited to but needs to activate the most recent copy of the org key
- * @enum {integer} - */ - MemberOrgKeyStatus: 0 | 1 | 2 | 3; - /** Theme */ - Theme2: Record; - AcceptInvitationValidation: { - /** @example true */ - Valid: boolean; - /** @example false */ - IsLifetimeAccount: boolean; - /** @example false */ - HasOrgWithMembers: boolean; - /** @example false */ - HasCustomDomains: boolean; - /** @example false */ - ExceedsMaxSpace: boolean; - /** @example false */ - ExceedsAddresses: boolean; - /** @example false */ - ExceedsMaxAcceptedInvitations: boolean; - /** @example false */ - OrgExceedsMaxAcceptedInvitations: boolean; - /** @example false */ - IsOnForbiddenPlan: boolean; - /** @example false */ - HasUnpaidInvoice: boolean; - /** @example false */ - IsExternalUser: boolean; + ImportReportTransformer: { + /** @example ARy95iNxhniEgYJrRrGv...F49RxFpMqWE_ZGDXEvGV2CEkA== */ + ID: string; + /** @example 1 */ + Provider: number; + /** @example test@gmx.fr */ + Account: string; + /** @description Sent (1) or Not Sent (0) */ + State: number; + /** @example 1592827431 */ + CreateTime: number; + /** @example 1592829784 */ + EndTime: number; + /** @example 262612461 */ + TotalSize: number; + Summary: { + Calendar?: { + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** @example 1245 */ + NumEvents?: number; + /** @example 1245 */ + TotalSize?: number; + /** + * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE + * @example 1 + */ + RollbackState?: number; + }; + Contact?: { + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** @example 1245 */ + NumContacts?: number; + /** @example 1245 */ + NumGroups?: number; + /** @example 1245 */ + TotalSize?: number; + /** + * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE + * @example 1 + */ + RollbackState?: number; + }; + Mail?: { + /** + * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED + * @example 0 + * @enum {integer} + */ + State?: 0 | 1 | 2 | 3 | 4 | 5; + /** @example 1245 */ + NumMessages?: number; + /** @example 1245 */ + TotalSize?: number; + /** + * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE + * @example 1 + */ + RollbackState?: number; + /** @description 1 if source messages can be deleted */ + CanDeleteSource?: number; + }; + }; }; - MessageInfo: { - /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUsT_zzWKFI-adgMIhFhPaAukDm9fw3MAOfsds-tfNw== */ - ID: string; - /** - * @description This value is UserID + MessageID.
It gives the order in which the messages were created in our database - * @example 456 - */ - Order: number; - /** @example Wk30GtU7aIj8Gu6yWkSc3SacA== */ - ConversationID: string; - /** @example new subject */ - Subject: string; - /** @example 1 */ - Unread: number; - /** - * @deprecated - * @example 1 - */ - Type: string; - /** - * @deprecated - * @example me@protonmail.com - */ - SenderAddress: string; + EventInfoImporter: { + Importers: { + /** @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEhjBbUPDMHGU699fw== */ + ID?: string; + /** @example 1 */ + Action?: number; + Importer?: components["schemas"]["ImporterTransformer"]; + }[]; + ImportReports: { + /** @example ARy95iNxhniEgYJrRrGv...F49RxFpMqWE_ZGDXEvGV2CEkA== */ + ID?: string; + /** @example 1 */ + Action?: number; + ImportReport?: components["schemas"]["ImportReportTransformer"]; + }[]; + }; + /** User settings for VPN product */ + VPNSettings: { /** - * @deprecated - * @example Me + * @description OpenVPN / IKEv2 username + * @example 9rXSJiW7xf59U/OqUTjHRJy/ */ - SenderName: string; - Sender: components['schemas']['Sender']; - ToList: components['schemas']['Recipient']; - CcList: components['schemas']['Recipient']; - BccList: components['schemas']['Recipient']; - /** @example 1433890289 */ - Time: number; - /** @example 1433890289 */ - SnoozeTime: number; - /** @example 148 */ - Size: number; + Name: string; /** - * @deprecated - * @example 1 + * @description OpenVPN / IKEv2 password + * @example sHwX8ye/ipCFfj5K0xuZYTlD */ - IsEncrypted: number; - /** @example 0 */ - ExpirationTime: number; - /** @example 0 */ - IsReplied: number; - /** @example 0 */ - IsRepliedAll: number; - /** @example 0 */ - IsForwarded: number; - /** @example cO6RgDJwoHFScLqIkVnRD9luDVkh20EDto1aIHVHU43-dJlREzFcUjS-c7bB-_qlnxBgwAShddHZ4UDdu6RswQ== */ - AddressID: string; - LabelIDs: string[]; - /** @example somesemirandomstringofchars */ - ExternalID: string; + Password: string; /** - * @description The number of attachments in the message, excluding inline attachments + * @description Status + * `0`: no vpn access + * `1`: vpn access + * `2`: vpn access eligible + * `3`: vpn access requested (waitlist) * @example 2 */ - NumAttachments: number; - /** - * @description Bitmap of message flags.
- * * Received = 2^0 Message was received
- * * Sent = 2^1 Message was sent
- * * Internal = 2^2 Message is internal
- * * E2E = 2^3 Message is End-to-End encrypted
- * * Auto = 2^4 Message was automatically generated
- * * Replied = 2^5
- * * RepliedAll = 2^6
- * * Forwarded = 2^7 Message was forwarded
- * * Auto replied = 2^8 Message is an automatic reply
- * * Imported = 2^9 Message was imported
- * * Opened = 2^10 Message has been opened
- * * Receipt Sent = 2^11 Message receipt has been sent
- * * Notified = 2^12 Historical, unused flag, kept here for reservation purposes
- * * Touched = 2^13
- * * Receipt = 2^14 Message is a recipt
- * * Proton = 2^15
- * * Receipt request = 2^16 Message request a recipt
- * * Public key = 2^17
- * * Sign = 2^18 Message is signed
- * * Unsubscribed = 2^19 Message has been unsubscribed from
- * * Scheduled send = 2^20 Message was scheduled sent
- * * 2^21 Not used
- * * Synced from Gmail = 2^22 Message was synced from Gmail
- * * DMARC PASS = 2^23 DMARC check passed
- * * SPF fail = 2^24 SPF check failed
- * * DKIM fail = 2^25 DKIM check failed
- * * DMARC fail = 2^26 DMARC check failed
- * * Ham manual = 2^27 Message was manually marked as ham (non spam)
- * * Spam auto = 2^28 Message was automatically marked as spam
- * * Spam manual = 2^29 Message was manually marked as spam
- * * Phishing auto = 2^30 Message was automatically marked as phishing
- * * Phishing manual = 2^31 Message was manually marked as phishing
- * * FrozenExpiration = 2^32 Message expiration time can't be manually edited
- * * Suspicious = 2^33 Message was automatically marked as suspicious
- * * Show Snooze Reminder = 2^34 Snooze reminder needs to be shown
- * * Auto Forwarder = 2^35 Message has been automatically forwarded to another recipient
- * * Auto Forwardee = 2^35 Message received was automatically forwarded by the sender
- * * EO Reply = 2^36 Message is a reply to an Encrypted-Outside message
- * @example 8198 - */ - Flags: number; - AttachmentInfo: components['schemas']['GroupedAttachmentsCount']; - AttachmentsMetadata: components['schemas']['Metadata'][]; - /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example 1 - */ - SenderImage: number; - /** @description Indicates if the client has to display the text saying that the message has been reminded */ - DisplaySnoozedReminder: boolean; - /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example 1 - */ - IsProton: number; + Status: number; /** * @deprecated - * @description Deprecated, check Sender.* properties - * @example default - */ - BimiSelector?: string | null; - }; - Conversation: { - /** - * @description The ID of the conversation - * @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== + * @description Trial has been removed, you should stop using this property + * @example 0 */ - ID: string; + ExpirationTime: unknown; /** - * @description The order is the sum of the conversationID and corresponding userID - * @example 675 + * @description Code name of the plan (string unique identifier constant over time) + * the user has, or null if no subscription + * @example mail2022 */ - Order: number; + BasePlan?: string | null; /** - * @description The subject of the conversation - * @example Testing + * @description Code name of the VPN plan (string unique identifier constant over time), i.e. + * the plan giving to the user the more entitlement to VPN features (such as access to + * VPN paid servers) or 'free' if the user has no such subscription (either is free or + * have a non-VPN subscription, ex.: mail2022, drive2022) + * @example vpnbiz2023 */ - Subject: string; - /** @description The list of senders */ - Senders: components['schemas']['Sender2'][]; - /** @description The list of recipients */ - Recipients: components['schemas']['Recipient2'][]; + PlanName?: string | null; /** - * @description The number of messages in the conversation. - * @example 5 + * @description Title of the plan (PlanName) (for display only, the title of a + * plan can change over time, be translated, etc.) + * @example VPN Plus */ - NumMessages: number; + PlanTitle?: string | null; /** - * @description The number of unread messages in the conversation. - * @example 0 + * @description Maximum number of connections/devices the user plan allows + * @example 10 */ - NumUnread: number; + MaxConnect: number; /** - * @description The number of attachments of the messages in the conversation, excluding inline attachments - * @example 0 + * @description Maximum server tier level the user can access + * @example 2 */ - NumAttachments: number; + MaxTier?: number | null; + Groups: string[]; /** - * @description The lowest expiration time of the messages in the conversations. - * * An expiration time of 0 means never. - * @example 0 + * @description Either the user belong to an organization being on a business plan + * @example false */ - ExpirationTime: number; + IsBusiness: boolean; /** - * @description The sum of the sizes of all the messages in the conversation, expressed in bytes - * @example 3555 + * @description `true` if the user needs to allocate connection + * (to the sub-user via the VPN settings panel for instance) + * @example false */ - Size: number; - /** @deprecated */ - LabelIDs: string[]; - /** @description List of labels that the conversation has */ - Labels: { - /** @example 0 */ - ID?: string; - /** @example 0 */ - ContextNumUnread?: number; - /** @example 5 */ - ContextNumMessages?: number; - /** @example 1578070879 */ - ContextTime?: number; - /** @example 0 */ - ContextExpirationTime?: number; - /** @example 541 */ - ContextSize?: number; - /** @example 0 */ - ContextNumAttachments?: number; - /** @example 1578070879 */ - ContextSnoozeTime?: number; - }[]; - /** @description Indicates if the client has to display the text saying that the conversation has been reminded */ - DisplaySnoozedReminder: boolean; + NeedConnectionAllocation: boolean; /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example 1 + * @description `true` if the organization opted-in for telemetry) + * @example false */ - DisplaySenderImage: Record; + BusinessEvents: boolean; /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example 1 + * @description `true` if the current user plan allow to use the browser extension + * @example false */ - IsProton: Record; + BrowserExtension: boolean; /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example default + * @description A plan that the current user can buy/upgrade to in order to be able to use the browser extension + * @example vpnpro2023 */ - BimiSelector?: string | null; - }; - AttachmentsMetadata: { - AttachmentInfo: components['schemas']['GroupedAttachmentsCount2']; - AttachmentsMetadata: components['schemas']['Metadata2'][]; - }; - /** Importer */ - ImporterTransformer: { - /** @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEp-fhjBbUPDMHGU699fw== */ - ID: string; - /** @example test@protonmail.dev */ - Account: string; - Product: string[]; + BrowserExtensionPlan?: string | null; + /** @description What the NetShield feature can block */ + NetShield: { + /** + * @description Either malware blocking can be enabled + * @example false + */ + Malware?: boolean; + /** + * @description Either ads and trackers blocking can be enabled + * @example false + */ + AdsAndTrackers?: boolean; + /** + * @description Either adult content blocking can be enabled + * @example false + */ + AdultContent?: boolean; + }; + }; + VPNServerTransformerInterface: { /** - * @description 0: IMAP, 1: Google - * @example 1 + * @description An arbitrary string that uniquely identifies the circuit + * @example l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA== */ - Provider: number; + ID: string; /** - * @description nullable, present only with token flow - * @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEp-fhjBbUPDMHGU699fw== + * @description IP client calls (client opens a tunnel between user network and this IP) + * @example 95.215.61.163 */ - TokenID: string; + EntryIP: string; /** - * @description Modify time of the importer - * @example 12345678 + * @description IP that calls the world (what the user will seem to come from as per seen by geolocation services and websites they browse) + * @example 95.215.61.164 */ - ModifyTime: number; + ExitIP: string; /** - * @description nullable, present only for IMAP flow - * @example imap.mail.ru + * @description Qualified domain name + * @example es-04.protonvpn.com */ - ImapHost: string; + Domain: string; /** - * @description nullable, present only for IMAP flow - * @example 993 + * @description 1 if server is operational or 0 if it's down + * @example 1 */ - ImapPort: number; + Status: number; /** - * @description nullable, present only for IMAP flow - * @example PLAIN + * @description **Bitmap**
+ * where each service to be marked as down are flagged:
+ * - `1`: Bind
+ * - `2`: HostAlive
+ * - `4`: OpenVPN_TCP
+ * - `8`: OpenVPN_UDP
+ * - `16`: IKEv2
+ * - `32`: WireGuard + * @example 12 */ - Sasl: string; + ServicesDown: number; /** - * @description nullable, present only for IMAP flow - 1 if certificate is not verified + * @description Setup age of the given server * @example 0 */ - AllowSelfSigned: number; - /** @example 76844 */ - INBOX: number; - /** @example 0 */ - '\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043D\u044B\u0435': number; - /** @example 0 */ - '\u0427\u0435\u0440\u043D\u043E\u0432\u0438\u043A\u0438': number; - /** @example 0 */ - 'INBOX/Social': number; - /** @example 0 */ - 'INBOX/Newsletters': number; - }; - /** ImportReport */ - ImportReportTransformer: { - /** @example ARy95iNxhniEgYJrRrGv...F49RxFpMqWE_ZGDXEvGV2CEkA== */ - ID: string; - /** @example 1 */ - Provider: number; - /** @example test@gmx.fr */ - Account: string; - /** @description Sent (1) or Not Sent (0) */ - State: number; - /** @example 1592827431 */ - CreateTime: number; - /** @example 1592829784 */ - EndTime: number; - /** @example 262612461 */ - TotalSize: number; - Summary: { - Calendar?: { + Generation: number; + /** + * @description Short explanation about the current status + * @example Provisioning + */ + ServicesDownReason?: string | null; + /** + * @description To match username suffixes provided at authentication, if multiple circuits (to different exit IPs) are available on the same entry IP, the label passed alongside when connecting to the entry IP will allow the server to know where to redirect (to which exit IP) + * @example us-va-01 + */ + Label: string; + /** + * @description X25519 public key PEM (it’s used when connecting via WireGuard using a certificate, with this key the client ensures they are connecting to legit Proton server as the cryptographic handshake would fail with an usurpator: without the private key, a server receiving something crypted with this public key would not be able to decrypt so it would not be able to prove to the client he actually owns the private key matching this public key) + * @example -----BEGIN PUBLIC KEY----- ... + */ + X25519PublicKey?: string | null; + /** @description Optional list of protocol-specific relays */ + EntryPerProtocol?: { + OpenVPNUDP?: { /** - * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED - * @example 0 - * @enum {integer} + * @description IP of the relay + * @example 1.0.0.0 */ - State?: 0 | 1 | 2 | 3 | 4 | 5; - /** @example 1245 */ - NumEvents?: number; - /** @example 1245 */ - TotalSize?: number; + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: number[]; + }; + OpenVPNTCP?: { /** - * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE - * @example 1 + * @description IP of the relay + * @example 1.0.0.0 */ - RollbackState?: number; + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: number[]; }; - Contact?: { + IKEv2?: { /** - * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED - * @example 0 - * @enum {integer} + * @description IP of the relay + * @example 1.0.0.0 */ - State?: 0 | 1 | 2 | 3 | 4 | 5; - /** @example 1245 */ - NumContacts?: number; - /** @example 1245 */ - NumGroups?: number; - /** @example 1245 */ - TotalSize?: number; + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: number[]; + }; + WireGuardUDP?: { /** - * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE - * @example 1 + * @description IP of the relay + * @example 1.0.0.0 */ - RollbackState?: number; + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: number[]; }; - Mail?: { + WireGuardTCP?: { /** - * @description `0`: QUEUED, `1`: RUNNING, `2`: DONE, `3`: FAILED, `4`: PAUSED, `5`: CANCELED - * @example 0 - * @enum {integer} + * @description IP of the relay + * @example 1.0.0.0 */ - State?: 0 | 1 | 2 | 3 | 4 | 5; - /** @example 1245 */ - NumMessages?: number; - /** @example 1245 */ - TotalSize?: number; + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: number[]; + }; + WireGuardTLS?: { /** - * @description `0`: CANNOT_UNDO, `1`: CAN_UNDO, `2`: UNDO_IN_PROGRESS, `3`: UNDONE - * @example 1 + * @description IP of the relay + * @example 1.0.0.0 */ - RollbackState?: number; - /** @description 1 if source messages can be deleted */ - CanDeleteSource?: number; + IPv4?: string; + /** @description Port to connect to; if none are available, this property is not returned */ + Ports?: number[]; }; }; }; - Contact: { + VPNLogical: { /** - * @description Encrypted ID - * @example a29olIjFv0rnXxBhSMw== + * @description An arbitrary string that uniquely identifies the given logical server + * @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ ID: string; - /** @example ProtonMail Features */ + /** + * @description A visual name that has the typical intent of being displayed to the user; often matches: `[A-Z]{2}(-[A-Z]{2}|-FREE)?#{server number}`, such as ES#1 (simple server), CH-DE#23 (secure-core server with an entry country being different from the exit country, entry will then be in a privacy-friendly country: Island, Sweden, Switzerland). Some special cases:
+ * Free servers have `-FREE` after their country code.
+ * All servers in the USA include the state in their name: in US-FL#22, FL stands for Florida, in US-CA#45, CA stands for California (it’s not a secure-core server exiting from Canada as we have no secure-core server with entry in the USA and will likely never have).
+ * Also, the name can be customized with any alphanumeric prefix for business dedicated IPs.
+ * Entry and exit countries are given by an ISO 3166-1 alpha-2 country code (except the United Kingdom which, for no reason, uses UK instead of GB) + * @example US-FL#1 + */ Name: string; - /** @example proton-legacy-139892c2-f691-4118-8c29-061196013e04 */ - UID: string; - /** @example 1434 */ - Size: number; /** - * Format: timestamp - * @example 1503815366 + * Format: alpha-2 + * @description ISO 3166-1 alpha-2 country code (except the United Kingdom which, for no reason, uses UK instead of GB) corresponding to the entry IPs of the physical servers (for Secure Core this will be one among CH, IS, SE) + * @example CH */ - CreateTime: number; + EntryCountry: string; /** - * Format: timestamp - * @example 1503815366 + * Format: alpha-2 + * @description ISO 3166-1 alpha-2 country code (except the United Kingdom which, for no reason, uses UK instead of GB) corresponding to the exit IPs the user would appear on the Internet when connected. Typically, it’s the same as EntryCountry, but for Secure Core server this will be a different Country. + * @example CH */ - ModifyTime: number; - /** @description List of emails, only included when returning one record */ - ContactEmails: components['schemas']['ContactEmail'][]; - /** @description Labels on Contact, ignore, maybe future feature */ - LabelIDs: string[]; - /** @description Only included when returning one record */ - Cards: components['schemas']['ContactData'][]; - }; - ContactEmail: { + ExitCountry: string; /** - * @description ContactList.ContactID - * @example aefew4323jFv0BhSMw== + * Format: alpha-2 + * @description ISO 3166-1 alpha-2 country code (except the United Kingdom which, for no reason, uses UK instead of GB) of the country the server is really located when ExitCountry is a virtual location. If HostCountry is null, it means it matches ExitCountry, and if HostCountry matches ExitCountry, then it’s not a virtual location. + * @example CH */ - ID: string; - /** @example test1 */ - Name: string; - /** @example features@protonmail.black */ - Email: string; - /** @description List of email types */ - Type: string[]; + HostCountry?: string | null; + /** + * @deprecated + * @description Domain name + * @example es-05.protonvpn.com + */ + Domain: string; + /** + * @description A number representing the server tier. Users have access to certain tiers depending to their Plan + * @example 2 + * @enum {integer} + */ + Tier: 0 | 2 | 3; + /** + * @description **Bitmap**
+ * - `1`: Secure Core
+ * - `2`: Tor
+ * - `4`: P2P
+ * - `8`: Streaming
+ * - `16`: IPv6
+ * - `32`: Restricted
+ * - `64`: Partner
+ * - `128`: Double Restriction + * @example 2 + */ + Features: number; + /** + * @description `1` if at least one physical server server is up and running and usable, `0` otherwise + * @example 1 + * @enum {integer} + */ + Status: 0 | 1; + /** + * @deprecated + * @description Use City or Name instead for geographic information + * @example null + */ + Region?: string | null; + /** + * @description Where the user will seem to be coming from as per seen by geo-location services and websites they browse + * @example Stockholm + */ + City: string; + /** @description List of possible circuit that can be taken for the current logical server, each option will provide a different exit IP, client should pick one randomly when connecting. */ + Servers: components["schemas"]["VPNServerTransformerInterface"][]; + /** + * @description A number between 0 and 100 that represent how much the logical server is loaded. The smaller, the more the server is available to be used. It has certain correspondence with the current consumed bandwidth. + * @example 0 + */ + Load: number; + /** @description The coordinates (Lat-itude and Long-itude) where user will IP geo-localized when connected to the server. They can be used to place a position on a map. */ + Location: { + /** + * @description Latitude + * @example 39.4667 + */ + Lat?: Record; + /** + * @description Longitude + * @example -0.3667 + */ + Long?: Record; + }; + /** + * @description The lower is the score, the better is the server for the current user when they select "quick" or "fastest", maximal precision (64 bits) for this number must be kept + * @example 3.615154888897451 + */ + Score: Record; + }; + EventInfoVpn: { + VPNSettings: { + /** @example test-group */ + GroupID?: string; + } & components["schemas"]["VPNSettings"]; + LogicalServers: components["schemas"]["VPNLogical"]; + }; + /** + * @description
See values descriptions
ValueDescription
0Disabled
1Enabled
+ * @enum {integer} + */ + WalletStatus: 0 | 1; + /** + * @description
See values descriptions
ValueDescription
1OnChain
2Lightning
+ * @enum {integer} + */ + WalletType: 1 | 2; + WalletOutput: { + ID: components["schemas"]["Id"]; /** - * @description 0 if contact contains custom sending preferences or keys, 1 otherwise - * @example 1 + * @description 1 if the wallet has a passphrase + * @example 0 */ - Defaults: number; - /** @example 1 */ - Order: number; - /** @example a29olIjFv0rnXxBhSMw== */ - ContactID: string; - /** @description Groups */ - LabelIDs: string[]; - /** @example features@protonmail.black */ - CanonicalEmail: string; - /** @description The last time the User sent a message to this ContactEmail */ - LastUsedTime: number; + HasPassphrase: number; /** - * @description Tells whether this is an official Proton address - * @example 1 + * @description 0 if the wallet is created with Proton Wallet + * @example 0 */ - IsProton: number; - }; - FilterOutput: { - ID: components['schemas']['Id']; - Name: string; - /** @example 1 */ - Status: number; - /** @example 3 */ - Priority: number; - /** @example require ["fileinto"]; - * - * if address :DOMAIN :is ["From", "Delivered-To"] "protonmail.ch" { - * fileinto "mylabel"; - * } else - * keep; - * } */ - Sieve: string; - Tree: components['schemas']['Tree']; - /** @example 1 */ - Version: number; - }; - IncomingDefault: Record; - IncomingDefaultResponse: { - /** ID */ - ID: string; - Location: number; - Type: number; - Time: number; - Email?: string | null; - }; - Subscription: { - /** @example */ - ID: string; - /** @example */ - InvoiceID: string; - /** @example 1 */ - Cycle: number; - /** @example 1455617471 */ - PeriodStart: number; - /** @example 1458119471 */ - PeriodEnd: number; - /** @example null */ - CouponCode: string; - /** @example USD */ - Currency: string; - /** @example 1500 */ - Amount: number; - Plans: components['schemas']['Plan'][]; - /** @example 1 */ - Renew: boolean; - }; - Response: { - /** @example Put Chinese Here */ - DisplayName: string; - /** @example This is my signature */ - Signature: string; - /** @example */ - Theme: string; - /** @description Automatically respond to incoming messages */ - AutoResponder: { - /** @example 0 */ - StartTime?: number; - /** @example 0 */ - Endtime?: number; - /** @example 0 */ - Repeat?: number; - DaysSelected?: string[]; - /** @example Auto */ - Subject?: string; - /** @example */ - Message?: string; - /** @example null */ - IsEnabled?: boolean | null; - /** @example Europe/Zurich */ - Zone?: string; - }; + IsImported: number; /** - * @description Automatically save the recipients as contact. - * If enabled, when a user sends an email, the recipients are automatically added to his contact list. - * Implemented by the backend. - * Possible values: - * - 0: disable - * - 1: enable - * @default 1 + * Format: base64 + * @description Encrypted wallet mnemonic with the WalletKey, in base64 format + * @example */ - AutoSaveContacts: number; + Mnemonic?: components["schemas"]["BinaryString"] | null; /** - * @deprecated - * @description Automatically convert simple queries to wildcarded versions, such as `test` to `*test*`. - * Implemented by web client V3. With v4 everything is wildcarded by default. - * Possible values: - * - 0: disable - * - 1: enable - * @default 1 + * @description Unique identifier of the mnemonic, using the first 4 bytes of the master public key hash + * @example 912914fb */ - AutoWildcardSearch: number; + Fingerprint?: string | null; + /** @description Encrypted wallet name with the WalletKey, in base64 format */ + Name: components["schemas"]["BinaryString"]; /** - * @description Possible values: - * - 0: normal - * - 1: maximized - * @default 0 + * @description Order of priority + * @example 1 */ - ComposerMode: number; + Priority: number; /** - * @description Possible values: - * - 0: read first - * - 1: unread first - * @default 0 + * Format: base64 + * @description Encrypted wallet public key with the WalletKey, in base64 format, only if on-chain watch-only + * @example */ - MessageButtons: number; + PublicKey?: components["schemas"]["BinaryString"] | null; + Status: components["schemas"]["WalletStatus"]; + Type: components["schemas"]["WalletType"]; /** - * @description Possible values: - * - 0: don't auto load - * - 1: auto-load remote content - * - 2: auto-load embedded images - * - 3: auto-load both - * @default 2 + * @description Set to 1 if wallet key needs to be rotated + * @example 0 */ - ShowImages: number; + MigrationRequired: number; /** - * @description Possible values: - * - 0: don't keep - * - 1: keep draft messages in Draft folder - * - 2: keep sent messages in Sent folder - * - 3: keep both draft and sent messages in their respective folders - * @default 0 + * @description Set to 1 if mnemonic is encrypted with user key too + * @example 0 */ - ShowMoved: number; + Legacy: number; /** - * @description delay in days before messages put in trash and spam are permanantly deleted - * - * - null: implicitly disabled - * - 0: explicitly disabled - * @default null + * @description Set to 1 if wallet is imported from hardware wallet + * @example 0 */ - AutoDeleteSpamAndTrashDays: number | null; + IsHardwareWallet: number; + }; + /** + * @description Path used to generate a series of Bitcoin addresses from a single seed phrase or mnemonic, only BIP 44, 49, 84 and 86 are currently accepted + * @example m/44'/0'/0' + */ + DerivationPath: string; + /** + * @description
See values descriptions
ValueDescription
1Legacy
2NestedSegwit
3NativeSegwit
4Taproot
+ * @enum {integer} + */ + ScriptType: 1 | 2 | 3 | 4; + WalletAccountOutput: { + ID: components["schemas"]["Id"]; + WalletID: components["schemas"]["Id"]; /** - * @description Possible values: - * - 0: Client should show the `ALL_MAIL` label - * - 1: Client should show the `ALMOST_ALL_MAIL` label - * @default 0 + * @description Preferred fiat currency + * @example CHF */ - AlmostAllMail: number; + FiatCurrency: string; + DerivationPath: components["schemas"]["DerivationPath"]; + /** @description Encrypted label with the WalletKey, in base64 format */ + Label: components["schemas"]["BinaryString"]; + /** @description The index number that wallet last used to create address */ + LastUsedIndex: number; /** - * @description Whether to load next message when current message is moved somewhere else - * - null: implicitly disabled - * - 0: explicitly disabled - * - 1: implictly disabled - * - 2: explicitly enabled - * @default 0 - * @enum {integer|null} + * @description Size of Bitcoin address pool + * @example 10 */ - NextMessageOnMove: 0 | 1 | 2 | null; + PoolSize: number; /** - * @description Possible values: - * - 0: enable conversation mode - * - 1: no conversation grouping - * @default 0 + * @description Order of priority + * @example 1 */ - ViewMode: number; + Priority: number; + ScriptType: components["schemas"]["ScriptType"]; + StopGap: number; + Addresses: unknown[]; + }; + /** + * @description BTC address + * @example 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa + */ + BitcoinAddress: string; + WalletBitcoinAddressOutput: { + ID: components["schemas"]["Id"]; + WalletID: components["schemas"]["Id"]; + WalletAccountID: components["schemas"]["Id"]; + Fetched: number; + Used: number; + /** @default null */ + BitcoinAddress: components["schemas"]["BitcoinAddress"] | null; /** - * @description Possible values: - * - 0: column - * - 1: row - * @default 0 + * @description Detached signature of the bitcoin address + * @default null + * @example -----BEGIN PGP SIGNATURE-----... */ - ViewLayout: number; + BitcoinAddressSignature: components["schemas"]["PGPSignature"] | null; /** - * @description Swipe left action. - * Action taken when user swipes a message to the left on mobile. - * Implemented by the client. - * Possible values: - * - 0: Trash - * - 1: Spam - * - 2: Star - * - 3: Archive - * - 4: Mark as read - * @default 3 + * @description Index of the bitcoin address + * @default null + * @example 1 */ - SwipeLeft: number; + BitcoinAddressIndex: number | null; + }; + WalletKeyOutput: { + ID: components["schemas"]["Id"]; + WalletID: components["schemas"]["Id"]; + UserKeyID: components["schemas"]["Id"]; /** - * @description Swipe right action. - * Action taken when user swipes a message to the right on mobile. - * Implemented by the client. - * Possible values: - * - 0: Trash - * - 1: Spam - * - 2: Star - * - 3: Archive - * - 4: Mark as read - * @default 0 + * @description Encrypted AES-GCM 256 key used to encrypt the mnemonic or public key, as armored PGP + * @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ - SwipeRight: number; + WalletKey: string; /** - * @deprecated - * @example 0 + * @description Detached signature of the encrypted AES-GCM 256 key used to encrypt the mnemonic or public key, as armored PGP + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - AlsoArchive: number; + WalletKeySignature: string; + }; + WalletSettingsOutput: { + WalletID: components["schemas"]["Id"]; /** - * @deprecated - * @default 0 + * @description Hide accounts, only used for on-chain wallet + * @example 0 */ - Hotkeys: number; + HideAccounts: number; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 1 + * @description Invoice default description, only used for lightning wallet + * @example Lightning payment from John Doe. */ - Shortcuts: number; + InvoiceDefaultDescription?: string | null; /** - * @description Possible values: - * - 0: Disabled - * - 1: Enabled - * - 2: Enabled and Locked - * @default 0 + * @description Invoice expiration time, only used for lightning wallet + * @example 3600 */ - PMSignature: number; + InvoiceExpirationTime: number; /** - * @description Possible values: - * - 0: Disabled - * - 1: Enabled - * @default 0 + * @description Max fee for automatic channel opening with Proton Lightning node, expressed in SATS, only used for lightning wallet + * @example 5000 */ - PMSignatureReferralLink: number; + MaxChannelOpeningFee: number; /** - * @description Bitmap of image proxy related settings. - * - IncorporateImages: 1 (2^0), whether remote images are downloaded and incorporated into mail at delivery. - * Implemented by the backend. - * - ProxyImages : 2 (2^1), whether loading remote images on the clients passes through the proton proxy. - * Implemented by the client. - * @default 0 + * @description User should see wallet recovery phrase without 2FA + * @example false */ - ImageProxy: number; - /** @example 50 */ - NumMessagePerPage: number; + ShowWalletRecovery: boolean; + }; + /** + * @description
See values descriptions
ValueDescription
1ProtonToProtonSend
2ProtonToProtonReceive
3ExternalSend
4ExternalReceive
+ * @enum {integer} + */ + TransactionType: 1 | 2 | 3 | 4; + ExchangeRateOutput: { + ID: components["schemas"]["Id"]; /** - * @description Default mime type of drafts. Implemented by the client. - * Possible values: - * - 'text/html' - * - 'text/plain' - * @example text/html + * @description Bitcoin unit of the exchange rate + * @example BTC */ - DraftMIMEType: string; + BitcoinUnit: string; /** - * @description Preferred mime type of received messages. Implemented by the backend. - * Possible values: - * - 'text/html' - * - 'text/plain' - * @example text/html + * @description Fiat currency of the exchange rate + * @example CHF */ - ReceiveMIMEType: string; - /** @example text/html */ - ShowMIMEType: string; + FiatCurrency: string; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 0 + * @description Sign of the fiat currency (e.g. € for EUR) + * @example 100 */ - EnableFolderColor: number; + Sign: string; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 1 + * @description Time of the BTC/Fiat exchange rate + * @example 1707287982 */ - InheritParentFolderColor: number; + ExchangeRateTime?: string | null; /** - * @description Possible values: - * - 0: disabled - * - 1: enabled - * @default 0 + * @description Exchange rate BitcoinUnit/FiatCurrency + * @example 20000000 */ - SubmissionAccess: number; + ExchangeRate: number; /** - * @deprecated - * @default 0 + * @description Cents precision of the fiat currency (e.g. 1 for JPY, 100 for USD) + * @example 100 */ - TLS: number; + Cents: number; + }; + WalletTransactionOutput: { + ID: components["schemas"]["Id"]; + WalletID: components["schemas"]["Id"]; + WalletAccountID: components["schemas"]["Id"]; + TransactionID: components["schemas"]["PGPMessage"]; /** - * @description Composer text direction. - * The direction of the text inside the message composer. - * Implemented by the client. - * Possible values: - * - 0: left to right - * - 1: right to left - * @default 0 + * @description Unix timestamp of when the transaction got created in Proton Wallet or confirmed in blockchain for incoming ones + * @example 1707287982 */ - RightToLeft: number; + TransactionTime?: string | null; + /** @description Set to 1 if output amount is smaller than 1001 Sats, or output size is bigger than 20 blocks */ + IsSuspicious: number; + /** @description Set to 1 if user does not want to spend UTXO from this transaction */ + IsPrivate: number; + /** @description Set to 1 if user did not want to reveal its identify during sending */ + IsAnonymous: number; + Type: components["schemas"]["TransactionType"]; + HashedTransactionID?: components["schemas"]["BinaryString"] | null; + /** @default null */ + Label: components["schemas"]["BinaryString"] | null; + /** @default null */ + ExchangeRate: components["schemas"]["ExchangeRateOutput"] | null; + /** @default null */ + Sender: components["schemas"]["PGPMessage"] | null; + /** @default null */ + ToList: components["schemas"]["PGPMessage"] | null; + /** @default null */ + Subject: components["schemas"]["PGPMessage"] | null; + /** @default null */ + Body: components["schemas"]["PGPMessage"] | null; + }; + WalletUserSettingsOutput: { /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 0 + * @description Accept terms and conditions + * @example 1 */ - AttachPublicKey: number; + AcceptTermsAndConditions: number; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 0 + * @description Tell the client that it is allowed to show the review page + * @example 0 */ - Sign: number; + AllowReview: number; /** - * @description Default PGP scheme to use when sending externally. Implemented by the client. - * Possible values: - * - 8: PGP Inline - * - 16: PGP Mime - * @default 16 + * @description Preferred Bitcoin unit + * @example BTC */ - PGPScheme: number; + BitcoinUnit: string; /** - * @description Prompt to trust key. - * When opening a message from another protonmail user for which there is no pinned key, prompt to pin key. - * Pinning the key results in updating the contact. - * Implemented by the client. - * Possible values: - * - 0: disable - * - 1: enable - * @default 0 + * @description Preferred fiat currency + * @example CHF */ - PromptPin: number; + FiatCurrency: string; /** - * @deprecated - * @default 0 + * @description Hide empty used addresses + * @example 1 */ - Autocrypt: number; + HideEmptyUsedAddresses: number; /** - * @description When a message is created, add to it all the labels of the other messages in its conversation. - * Possible values: - * - 0: disable - * - 1: enable - * @default 0 + * @description Ask for 2FA verification when an amount threshold is reached + * @example 1000 */ - StickyLabels: number; + TwoFactorAmountThreshold?: number | null; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 1 + * @description Receive inviter notification + * @example 1 */ - ConfirmLink: number; + ReceiveInviterNotification: number; /** - * @description Possible values between 0 and 30 - * @default 10 + * @description Receive email integration notification + * @example 1 */ - DelaySendSeconds: number; - /** @default 0 */ - KT: number; + ReceiveEmailIntegrationNotification: number; /** - * @description Possible values between 10 and 26 - * @default null + * @description Receive transaction notification + * @example 1 */ - FontSize: number | null; - /** @default null */ - FontFace: string; + ReceiveTransactionNotification: number; /** - * @description Configure additional actions to take when messages or conversations are moved to spam - * Possible values: - * - null: ask what to do every time - * - 0: do nothing else - * - 1: unsubscribe with one-click list-unsubscribe if possible - * @default null + * Format: date-time + * @description Timestamp about when user saw the review page on client + * @example null */ - SpamAction: number | null; + ReviewTime?: string | null; /** - * @description Whether the user wants to be asked for confirmation before blocking a sender - * Possible values: - * - null: ask for confirmation every time - * - 1: block sender without asking for confirmation - * @default null + * @description User has already created a wallet once + * @example 1 */ - BlockSenderConfirmation: number | null; - /** @description Mobile-specific settings, only returned for mobile clients */ - MobileSettings: { - MessageToolbar?: { - IsCustom?: boolean; - Actions?: string[]; - }; - ConversationToolbar?: { - IsCustom?: boolean; - Actions?: string[]; - }; - ListToolbar?: { - IsCustom?: boolean; - Actions?: string[]; - }; - }; + WalletCreated: number; + }; + EventInfoWallet: { + Wallets: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + Wallet?: components["schemas"]["WalletOutput"]; + }[]; + WalletAccounts: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletAccount?: components["schemas"]["WalletAccountOutput"]; + }[]; + WalletBitcoinAddresses: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletBitcoinAddress?: components["schemas"]["WalletBitcoinAddressOutput"]; + }[]; + WalletKeys: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletKey?: components["schemas"]["WalletKeyOutput"]; + }[]; + WalletSettings: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletSettings?: components["schemas"]["WalletSettingsOutput"]; + }[]; + WalletTransactions: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + WalletTransaction?: components["schemas"]["WalletTransactionOutput"]; + }[]; + WalletUserSettings: components["schemas"]["WalletUserSettingsOutput"]; + }; + EventInfo: { + Code: components["schemas"]["ResponseCodeSuccess"]; /** - * @description Whether the user wants to have embedded-images hidden on this client. The default vlaue is 0. - * Possible values: - * - 0: Show embedded images - * - 1: Hide embedded images - * @default 0 - * @enum {integer} + * Format: byte + * @example ACXDmTaBub14w== */ - HideEmbeddedImages: 1 | 0; + EventID: string; /** - * @description Whether the user wants to have remote-images hidden on this client. The default vlaue is 0. - * Possible values: - * - 0: Show remote images - * - 1: Hide remote images - * @default 0 - * @enum {integer} + * @description Bitmask to know what to refresh
`0`: Nothing
`1`: MAIL
`2`: CONTACTS
`255`: Everything + * @example 0 */ - HideRemoteImages: 1 | 0; + Refresh: number; /** - * @description Whether the user wants to have sender-images hidden. The value is `0` by default. - * Possible values: - * - 0: Do not hide sender images - * - 1: Hide sender images - * @example 1 + * @description `1` if there is more to pull + * @example 0 * @enum {integer} */ - HideSenderImages: 1 | 0; - /** - * @description Whether the user wants to remove metadata from image attachments. The value is `0` by default. - * Possible values: - * - false: Do not remove image metadata - * - true: Remove image metadata - * @example true - */ - RemoveImageMetadata: Record; - }; - /** User settings for VPN product */ - VPNSettings: { - /** - * @description OpenVPN / IKEv2 username - * @example 9rXSJiW7xf59U/OqUTjHRJy/ - */ - Name: string; - /** - * @description OpenVPN / IKEv2 password - * @example sHwX8ye/ipCFfj5K0xuZYTlD - */ - Password: string; + More: 0 | 1; + Messages: { + /** @example KPlISx5MiML3XcSYPrREF-...-adgMIhFhPaAukDm9fw3MAOfsds-tfNw== */ + ID?: string; + /** + * @description Message action
`0`: `DELETE`
`1`: `CREATE`
`2`: `UPDATE`
`3`: `UPDATE_FLAGS` + * @example 1 + * @enum {integer} + */ + Action?: 0 | 1 | 2 | 3; + Message?: components["schemas"]["MessageInfo"] & { + /** @deprecated */ + LabelIDsAdded?: string[]; + /** @deprecated */ + LabelIDsRemoved?: string[]; + }; + }[]; + Conversations: { + /** @example I6hgx3Ol-d3HYa3E394T...ACXDmTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + Conversation?: { + /** @example AJuSqm0qvIL4LSMR9LWsqNO...a2OlAU_Iqr2Qcducsz-ZA== */ + AddressID?: string; + } & components["schemas"]["Conversation"] & { + LabelIDsAdded?: string[]; + LabelIDsRemoved?: string[]; + /** + * @deprecated + * @description Not available in the Events API + */ + LabelIDs?: string[]; + } & components["schemas"]["AttachmentsMetadata"]; + }[]; + Contacts: { + /** @example afeaefaeTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + Contact?: components["schemas"]["Contact"]; + }[]; + ContactEmails: { + /** @example sadfaACXDmTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + ContactEmail?: components["schemas"]["ContactEmail"]; + }[]; + Filters: { + /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ + ID?: string; + /** @example 1 */ + Action?: number; + Filter?: components["schemas"]["FilterOutput"]; + }[]; + IncomingDefaults: { + /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ + ID?: string; + /** @example 1 */ + Action?: number; + Filter?: components["schemas"]["IncomingDefault"]; + }[]; + OrgIncomingDefaults: { + /** @example Ik65N-aChBuWFd...UvkFTwJFJPQg== */ + ID?: string; + /** @example 1 */ + Action?: number; + OrgIncomingDefault?: components["schemas"]["IncomingDefaultResponse"]; + }[]; + Labels: { + /** @example sadfaACXDmTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + Label?: components["schemas"]["Label2"]; + }[]; + Subscription: Record; + User: components["schemas"]["User"] & { + AccountRecovery?: components["schemas"]["AccountRecoveryAttempt"]; + }; + UserSettings: components["schemas"]["UserSettingsTransformer"]; + MailSettings: components["schemas"]["Response"]; + Invoices: { + /** @example IlnTbqicN-...-4NvrrIc6GLvDv28aKYVRRrSgEFhR_zhlkA== */ + ID?: string; + /** @example 1 */ + Action?: number; + }[]; + Members: { + /** @example LO9aACXDmTaBub14w== */ + ID?: string; + /** @example 1 */ + Action?: number; + Member?: { + /** @example LO9aACXDmTaBub14w== */ + MemberID?: string; + /** @example 1 */ + Role?: number; + /** @example 0 */ + Private?: number; + /** @example 0 */ + Type?: number; + /** + * Format: int64 + * @example 0 + */ + MaxSpace?: number; + /** @example Jason */ + Name?: string; + /** + * Format: int64 + * @example 0 + */ + UsedSpace?: number; + Addresses?: string[]; + }; + }[]; + Domains: { + /** @example 9aACXDmTaBub14w== */ + ID?: string; + /** @example 2 */ + Action?: number; + Domain?: components["schemas"]["DomainOutput2"]; + }[]; + OrganizationSettings: { + /** @description The organization's ID */ + OrganizationID?: string; + /** + * @description Whether to show organization name in sidebar or not + * @example true + */ + ShowName?: Record; + /** + * @description Whether to show the Scribe writing assistant or not + * @example true + */ + ShowScribeWritingAssistant?: Record; + /** + * @description Whether the Zoom video conferencing feature is enabled or not + * @example true + */ + VideoConferencingEnabled?: Record; + /** + * @description Whether the Meet video conferencing feature is enabled or not + * @example true + */ + MeetVideoConferencingEnabled?: Record; + /** @description The ID of the organization's logo */ + LogoID?: string | null; + /** + * @description List of predefined products for which the non-admin members of the organization have access. + * @example [ + * "VPN", + * "Pass" + * ] + */ + AllowedProducts?: string[]; + /** + * @description List of PasswordPolicies. + * @example [] + */ + PasswordPolicies?: components["schemas"]["OrganizationPasswordPolicyInputOutput2"][]; + }[]; + Addresses: components["schemas"]["AddressUser"][]; + SignedAddressList?: components["schemas"]["KTAddressListTransformer"] | null; + IncomingAddressForwardings: { + /** @example 9aACXDmTaBub14w== */ + ID?: string; + /** @example 2 */ + Action?: number; + IncomingAddressForwarding?: components["schemas"]["IncomingAddressForwardingOutput"]; + }[]; + OutgoingAddressForwardings: { + /** @example 9aACXDmTaBub14w== */ + ID?: string; + /** @example 2 */ + Action?: number; + OutgoingAddressForwarding?: components["schemas"]["OutgoingAddressForwardingOutput"]; + }[]; + Organization: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** + * @description Plan attribution to specific product, 1 = Mail, 2 = Drive, 4 = VPN + * @example 1 + */ + PlanFlags?: number; + /** @example null */ + TwoFactorGracePeriod?: number; + /** @example null */ + Theme?: number; + /** @example contact@e-corp.com */ + Email?: string; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** + * Format: int64 + * @example 10000000000 + */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** + * Format: int64 + * @example 81788997 + */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + }; + MessageCounts: { + /** @example 0 */ + LabelID?: string; + /** @example 15 */ + Total?: number; + /** @example 6 */ + Unread?: number; + }[]; + ConversationCounts: { + /** @example 0 */ + LabelID?: string; + /** @example 4 */ + Total?: number; + /** @example 3 */ + Unread?: number; + }[]; /** - * @description Status - * `0`: no vpn access - * `1`: vpn access - * `2`: vpn access eligible - * `3`: vpn access requested (waitlist) - * @example 2 + * Format: int64 + * @description Used space (in bytes) + * @example 70376905 */ - Status: number; + UsedSpace: number; + ProductUsedSpace: components["schemas"]["UserUsage"]; + Pushes: { + /** @example 1H8EGg3J1QpSDL6K8hGs...hrHx6nnGQ== */ + PushID?: string; + /** + * @description Any objectID from the event feed (*WARNING*: the object can be on another page) + * @example KPlISx5MiML3Xc...3MAOfsds-tfNw== + */ + ObjectID?: string; + /** + * @description Type of the ObjectID + * @example Messages + */ + Type?: string; + }[]; + Notifications: components["schemas"]["EventLoopNotificationTransformer"][]; + Notices: string[]; + } & (components["schemas"]["DriveShareRefreshCoreEventService"] & components["schemas"]["EventInfoCalendar"] & components["schemas"]["EventInfoImporter"] & components["schemas"]["EventInfoVpn"] & components["schemas"]["EventInfoWallet"]); + NotificationVersionTransformer: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + NotificationID: string; + /** @example 1601582623 */ + StartTime: number; + /** @example 1845561234 */ + EndTime: number; /** - * @deprecated - * @description Trial has been removed, you should stop using this property + * @description Possible values:
- 0: offer * @example 0 */ - ExpirationTime: unknown; - /** - * @description Code name of the plan (string unique identifier constant over time) - * the user has, or null if no subscription - * @example mail2022 - */ - BasePlan?: string | null; - /** - * @description Code name of the VPN plan (string unique identifier constant over time), i.e. - * the plan giving to the user the more entitlement to VPN features (such as access to - * VPN paid servers) or 'free' if the user has no such subscription (either is free or - * have a non-VPN subscription, ex.: mail2022, drive2022) - * @example vpnbiz2023 - */ - PlanName?: string | null; - /** - * @description Title of the plan (PlanName) (for display only, the title of a - * plan can change over time, be translated, etc.) - * @example VPN Plus - */ - PlanTitle?: string | null; - /** - * @description Maximum number of connections/devices the user plan allows - * @example 10 - */ - MaxConnect: number; - /** - * @description Maximum server tier level the user can access - * @example 2 - */ - MaxTier?: number | null; - Groups: string[]; - /** - * @description `true` if the user needs to allocate connection - * (to the sub-user via the VPN settings panel for instance) - * @example false - */ - NeedConnectionAllocation: boolean; - /** - * @description `true` if the organization opted-in for telemetry) - * @example false - */ - BusinessEvents: boolean; - /** - * @description `true` if the current user plan allow to use the browser extension - * @example false - */ - BrowserExtension: boolean; - /** - * @description A plan that the current user can buy/upgrade to in order to be able to use the browser extension - * @example vpnpro2023 - */ - BrowserExtensionPlan?: string | null; - }; - Invoice: { - /** @example IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA== */ - ID: string; - /** @example 4 */ Type: number; - /** @example 1 */ - State: number; - /** @example USD */ - Currency: string; - /** @example 0 */ - AmountDue: number; - /** @example 0 */ - AmountCharged: number; - /** @example 1505758141 */ - CreateTime: number; - /** @example 1506449824 */ - ModifyTime: number; - /** @example 1506449824 */ - AttemptTime: number; - /** @example 1 */ - Attempts: number; + /** @description Offer property will be present only when Type is 0 */ + Offer: { + /** @example https://protonvpn.com/black-friday */ + URL?: string; + /** @example https://protonvpn.com/resources/bf.png */ + Icon?: string; + /** + * @description Translated label based on the user's locale + * @example Black-Friday arrived! + */ + Label?: string; + }; }; - /** IncomingAddressForwardingResponse */ - IncomingAddressForwardingResponse: { - ID: components['schemas']['Id']; - Type: components['schemas']['AddressForwardingType']; - State: components['schemas']['AddressForwardingState']; - /** When an email is received by forwarderEmail, it will be forwarded to forwardeeEmail or forwardeeAddressID */ - ForwarderEmail: string; - ForwardeeAddressID: components['schemas']['Id']; - CreateTime: number; - /** The forwarding keys encrypted to the tokens. They are present only for encrypted forwarding - * in the pending state. To activate the forwarding all of them must be re-encrypted to the user - * keys and added to the correct address keyring. */ - ForwardingKeys: components['schemas']['ActivationForwardingKey'][]; - Filter?: components['schemas']['AddressForwardingFilter'] | null; - }; - /** OutgoingAddressForwardingResponse */ - OutgoingAddressForwardingResponse: { - ID: components['schemas']['Id']; - Type: components['schemas']['AddressForwardingType']; - State: components['schemas']['AddressForwardingState']; - ForwarderAddressID: components['schemas']['Id']; - /** The final email address to forward messages to * */ - ForwardeeEmail: string; - CreateTime: number; - Filter?: components['schemas']['AddressForwardingFilter'] | null; + ReferralStatus: { + /** @example 2 */ + RewardMonths: number; + /** @example 6 */ + RewardMonthsLimit: number; + /** @example 40 */ + RewardAmount: number; + /** @example 1000 */ + RewardAmountLimit: number; + /** @example 10 */ + EmailsAvailable: number; + }; + ReferralOutput: Record; + }; + responses: { + /** @description Plain success response without additional information */ + ProtonSuccessResponse: { + headers: { + /** @description The same as the body code */ + "X-Pm-Code"?: 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + /** @description General Error */ + ProtonErrorResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "get_core-{_version}-addresses-allowAddressDeletion": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-keys-setup": { + parameters: { + query?: { + /** + * @description Flag indicating that /core/v4/welcome-mail-send and /core/v4/checklist/get-started/init endpoints are called by the client + * @example 1 + */ + AsyncUserInitialization?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SetupKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { + Keys?: components["schemas"]["UserKey"] & { + /** @example 3 */ + Flags?: number; + }; + }; + VPN?: { + /** @example 1 */ + Status?: number; + /** @example 0 */ + ExpirationTime?: number; + /** @example visionary */ + PlanName?: string; + /** @example 10 */ + MaxConnect?: number; + /** @example 2 */ + MaxTier?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-address-active": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description The address ID + * @example ACXDmTa...Bub14w== + */ + AddressID?: string; + Keys?: { + /** + * @description Encrypted AddressKey ID + * @example G1MbEt3Ep5P_E...Wz8WbHVAOl_6h== + */ + AddressKeyID?: string; + /** + * @description 1 if the FE can decrypt this key + * @example 1 + */ + Active?: number; + }[]; + SignedKeyList?: { + /** @example JSON.stringify([{"Fingerprint": "fde90483475164ec6353c93f767df53b0ca8395c","SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Primary": 1,"Flags": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + }; }; - VPNProfile: Record; - VPNLogical: { - /** @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ - ID: string; - /** - * @description Name `[A-Z]{2}(-[A-Z]{2})?#{server number}`, such as ES#1, US-FL#22, etc. The state suffix (i.e. `FL` in case of `US-FL`) is meant to be resolvable to a US state. - * @example US-FL#1 - */ - Name: string; - /** - * Format: alpha-2 - * @description alpha-2 country code - * @example CH - */ - EntryCountry: string; - /** - * Format: alpha-2 - * @description alpha-2 country code - * @example CH - */ - ExitCountry: string; - /** - * Format: alpha-2 - * @description alpha-2 country code - * @example CH - */ - HostCountry?: string | null; - /** - * @description Domain name - * @example es-05.protonvpn.com - */ - Domain: string; - /** - * @description A number representing the server tier. Users have access to certain tiers depending to their Plan - * @example 2 - * @enum {integer} - */ - Tier: 0 | 1 | 2; - /** - * @description **Bitmap** - * * `1`: Secure Core - * * `2`: Tor - * * `4`: P2P - * * `8`: Streaming - * * `16`: IPv6 - * * `32`: Restricted - * * `64`: Partner - * * `128`: Double Restriction - * @example 2 - */ - Features: number; - /** - * @description `1` if at least one physical server server is up and running and usable, `0` otherwise - * @example 1 - * @enum {integer} - */ - Status: 0 | 1; - /** - * @deprecated - * @description Use City or Name instead for geographic information - * @example null - */ - Region?: number | null; - /** - * @description Optional city - * @example Stockholm - */ - City?: number | null; - Servers: components['schemas']['VPNServerTransformerInterface'][]; - /** - * @description Describe in a spiritual way how much the logical server is loaded - * @example 0 - */ - Load: number; - /** @description The coordinate of the datacenter */ - Location: { - /** - * @description Latitude - * @example 39.4667 - */ - Lat?: Record; - /** - * @description Longitude - * @example -0.3667 - */ - Long?: Record; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyList?: components["schemas"]["KTKeyList"] | null; + }; + }; }; - /** - * @description The lower is the score, the better is the server for the current user, maximal precision (64 bits) for this number must be kept - * @example 3.615154888897451 - */ - Score: Record; }; - CalendarWithMemberWithFlagsOutput: { - Members: components['schemas']['MemberWithFlagsOutput'][]; - ID: components['schemas']['Id']; - Type: components['schemas']['CalendarType']; - Owner: components['schemas']['CalendarOwner']; - /** Format: date-time */ - CreateTime: string; + }; + "get_core-{_version}-keys": { + parameters: { + query?: { + Email?: string; + Fingerprint?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; }; - MemberWithFlagsOutput: { - /** - * @description The calendar flags bitmap:
- `0`: Inactive: the calendar keys are not accessible and the current user cannot fix it
- `1`: Active: the calendar is all good!
- `2`: Update passphrase: a deactivated passphrase is again accessible, you should re-encrypt the linked calendar key using the primary passphrase
- `4`: Reset needed: the calendar needs to be reset
- `8`: Incomplete setup: the calendar setup was not completed, need to setup the key and passphrase
- `16`: Lost access: the user lost access to the calendar but an admin can re-invite him
- * @example 1 - */ - Flags: number; - ID: components['schemas']['Id']; - /** - * @description Flags bitmap:
- `1`: Super-owner
- `2`: Owner
- `4`: Admin
- `8`: Read member list
- `16`: Write events
- `32`: Read events (full details)
- `64`: Availability view only
- * @example 63 - */ - Permissions: number; - /** @example andy@pm.me */ - Email: string; - AddressId: components['schemas']['Id']; - CalendarId: components['schemas']['Id']; - /** @example Organizational Calendar */ - Name: string; - /** @example This text describes the calendar */ - Description: string; - /** @example #8989AC */ - Color: string; - /** @example 1 */ - Display: number; - /** - * @description Priority describing the order of the member, 1 is highest - * @example 1 - */ - Priority: number; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description 1:Internal, 2:External + * @example 1 + */ + RecipientType?: number; + /** + * @description 0:KT is valid, 1: External address - keys omitted, 2: Catch all - wrong SKL + * @example 0 + */ + IgnoreKT?: number; + /** @example text/html */ + MIMEType?: string; + Keys?: { + /** + * @description Bitmap with the following values.
+ * Key is not compromised = 1 (2^0) (if the bit is set to one the key is not compromised)
+ * Key is not obsolete = 2 (2^1)
+ * @example 3 + */ + Flags?: number; + /** @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- */ + PublicKey?: string; + /** + * @description 0: Internal, 1: WKD, 2: KOO + * @example 0 + * @enum {integer} + */ + Source?: 0 | 1 | 2; + }[]; + SignedKeyList?: components["schemas"]["KTKeyList"]; + /** @example [] */ + Warnings?: string[]; + /** + * @description Tells whether this is an official Proton address, optional field + * @example 1 + */ + IsProton?: number; + }; + }; + }; }; - EventLoopNotificationTransformer: { - /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ - ID: string; - /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ - UserID: string; - /** @example account_recovery */ - Type: string; - /** @description timestamp */ - Time: Record; - Payload: { - Title?: string; - Subtitle?: string; - Body?: string; + }; + "post_core-{_version}-keys": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; }; + cookie?: never; }; - /** CalendarUserSettings */ - UserSettingsTransformer2: { - /** - * @description `0`: 7 Days, `1`: 5 Days - * @example 0 - * @enum {integer} - */ - WeekLength: 0 | 1; - /** - * @description `0`: Off, `1`: On - * @example 1 - * @enum {integer} - */ - DisplayWeekNumber: 0 | 1; - /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} - */ - AutoDetectPrimaryTimezone: 0 | 1; - /** @example Antarctica/Macquarie */ - PrimaryTimezone: string; - /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} - */ - DisplaySecondaryTimezone: 0 | 1; - /** - * @description Can be null if DisplaySecondaryTimezone is 0 - * @example null - */ - SecondaryTimezone: string; - /** - * @description `0`: DAILY, `1`: WEEKLY, `2`: MONTHLY, `3`: YEARLY, `4`: PLANNING - * @example 1 - * @enum {integer} - */ - ViewPreference: 0 | 1 | 2 | 3 | 4; - /** - * @description Can be null, if the calendar type is `subscription`, instead of `normal`, it cannot be set as the default calendar - * @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== - */ - DefaultCalendarID: string; - /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} - */ - ShowCancelled: 0 | 1; - /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} - */ - ShowDeclined: 0 | 1; - /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} - */ - AutoImportInvite: 0 | 1; - /** @description Bitmap of whom to share busy-schedule with:
- 1 (2^0): To users in the same organization */ - ShareBusySchedule: number; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateLegacyKeyInput"]; + }; }; - WalletOutput: { - ID: components['schemas']['Id']; - /** - * @description 1 if the wallet has a passphrase - * @example 0 - */ - HasPassphrase: number; - /** - * @description 0 if the wallet is created with Proton Wallet - * @example 0 - */ - IsImported: number; - /** - * Format: base64 - * @description Encrypted wallet mnemonic with the WalletKey, in base64 format - * @example - */ - Mnemonic?: components['schemas']['BinaryString'] | null; - /** - * @description Unique identifier of the mnemonic, using the first 4 bytes of the master public key hash - * @example 912914fb - */ - Fingerprint?: string | null; - Name: components['schemas']['BinaryString']; - /** - * @description Order of priority - * @example 1 - */ - Priority: number; - /** - * Format: base64 - * @description Encrypted wallet public key with the WalletKey, in base64 format, only if on-chain watch-only - * @example - */ - PublicKey?: components['schemas']['BinaryString'] | null; - Status: components['schemas']['WalletStatus']; - Type: components['schemas']['WalletType']; - /** - * @description Set to 1 if wallet key needs to be rotated - * @example 0 - */ - MigrationRequired: number; - /** - * @description Set to 1 if mnemonic is encrypted with user key too - * @example 0 - */ - Legacy: number; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Key?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Version?: number; + /** @example 3 */ + Flags?: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token?: string | null; + /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string | null; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + Fingerprints?: string[]; + /** @example null */ + Activation?: number; + /** @example 1 */ + Primary?: number; + }; + }; + }; + }; }; - WalletAccountOutput: { - ID: components['schemas']['Id']; - WalletID: components['schemas']['Id']; - /** - * @description Preferred fiat currency - * @example CHF - */ - FiatCurrency: string; - DerivationPath: components['schemas']['DerivationPath']; - Label: components['schemas']['BinaryString']; - /** @description The index number that wallet last used to create address */ - LastUsedIndex: number; - /** - * @description Size of Bitcoin address pool - * @example 10 - */ - PoolSize: number; - /** - * @description Order of priority - * @example 1 - */ - Priority: number; - ScriptType: components['schemas']['ScriptType']; - Addresses: unknown[]; + }; + "post_core-{_version}-keys-address": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; }; - WalletBitcoinAddressOutput: { - ID: components['schemas']['Id']; - WalletID: components['schemas']['Id']; - WalletAccountID: components['schemas']['Id']; - Fetched: number; - Used: number; - /** @default null */ - BitcoinAddress: components['schemas']['BitcoinAddress'] | null; - /** - * @description Detached signature of the bitcoin address - * @default null - * @example -----BEGIN PGP SIGNATURE-----... - */ - BitcoinAddressSignature: components['schemas']['PGPSignature'] | null; - /** - * @description Index of the bitcoin address - * @default null - * @example 1 - */ - BitcoinAddressIndex: number | null; + requestBody?: { + content: { + "application/json": { + /** @example xRvCGwFq_TW7...i8FtJaGyFEq0g== */ + AddressID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ + AddressForwardingID?: string; + /** @example 1 */ + Primary?: number; + /** @example -----BEGIN PGP MESSAGE-----.* */ + Token?: string; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + Signature?: string; + SignedKeyList?: { + /** @example JSON.stringify([{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + }; }; - WalletKeyOutput: { - ID: components['schemas']['Id']; - WalletID: components['schemas']['Id']; - UserKeyID: components['schemas']['Id']; - /** - * @description Encrypted AES-GCM 256 key used to encrypt the mnemonic or public key, as armored PGP - * @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- - */ - WalletKey: string; - /** - * @description Detached signature of the encrypted AES-GCM 256 key used to encrypt the mnemonic or public key, as armored PGP - * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- - */ - WalletKeySignature: string; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Key?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Version?: number; + /** @example 3 */ + Flags?: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token?: string | null; + /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string | null; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + Fingerprints?: string[]; + /** @example null */ + Activation?: number; + /** @example 1 */ + Primary?: number; + /** @example 1 */ + Active?: number; + }; + }; + }; + }; }; - WalletSettingsOutput: { - WalletID: components['schemas']['Id']; - /** - * @description Hide accounts, only used for on-chain wallet - * @example 0 - */ - HideAccounts: number; - /** - * @description Invoice default description, only used for lightning wallet - * @example Lightning payment from John Doe. - */ - InvoiceDefaultDescription?: string | null; - /** - * @description Invoice expiration time, only used for lightning wallet - * @example 3600 - */ - InvoiceExpirationTime: number; - /** - * @description Max fee for automatic channel opening with Proton Lightning node, expressed in SATS, only used for lightning wallet - * @example 5000 - */ - MaxChannelOpeningFee: number; - /** - * @description User should see wallet recovery phrase without 2FA - * @example false - */ - ShowWalletRecovery: boolean; + }; + "post_core-{_version}-keys-group": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example xRvCGwFq_TW7...i8FtJaGyFEq0g== */ + AddressID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example -----BEGIN PGP MESSAGE-----.* */ + OrgToken?: string; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + OrgSignature?: string; + SignedKeyList?: { + /** @example JSON.stringify([{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Key?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Flags?: number; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + OrgToken?: string; + /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + OrgSignature?: string; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + Fingerprints?: string[]; + /** @example 1 */ + Primary?: number; + /** @example 1 */ + Active?: number; + }; + }; + }; + }; }; - WalletTransactionOutput: { - ID: components['schemas']['Id']; - WalletID: components['schemas']['Id']; - WalletAccountID: components['schemas']['Id']; - TransactionID: components['schemas']['PGPMessage']; - /** - * @description Unix timestamp of when the transaction got created in Proton Wallet or confirmed in blockchain for incoming ones - * @example 1707287982 - */ - TransactionTime?: string | null; - /** @description Set to 1 if output amount is smaller than 1001 Sats, or output size is bigger than 20 blocks */ - IsSuspicious: number; - /** @description Set to 1 if user does not want to spend UTXO from this transaction */ - IsPrivate: number; - /** @description Set to 1 if user did not want to reveal its identify during sending */ - IsAnonymous: number; - Type: components['schemas']['TransactionType']; - HashedTransactionID?: components['schemas']['BinaryString'] | null; - /** @default null */ - Label: components['schemas']['BinaryString'] | null; - /** @default null */ - ExchangeRate: components['schemas']['ExchangeRateOutput'] | null; - /** @default null */ - Sender: components['schemas']['PGPMessage'] | null; - /** @default null */ - ToList: components['schemas']['PGPMessage'] | null; - /** @default null */ - Subject: components['schemas']['PGPMessage'] | null; - /** @default null */ - Body: components['schemas']['PGPMessage'] | null; + }; + "put_core-{_version}-keys-{enc_id}-delete": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example ACXDmTaBub14w== + */ + enc_id: string; + _version: string; + }; + cookie?: never; }; - WalletUserSettingsOutput: { - /** - * @description Accept terms and conditions - * @example 1 - */ - AcceptTermsAndConditions: number; - /** - * @description Preferred Bitcoin unit - * @example BTC - */ - BitcoinUnit: string; - /** - * @description Preferred fiat currency - * @example CHF - */ - FiatCurrency: string; - /** - * @description Hide empty used addresses - * @example 1 - */ - HideEmptyUsedAddresses: number; - /** - * @description Ask for 2FA verification when an amount threshold is reached - * @example 1000 - */ - TwoFactorAmountThreshold?: number | null; - /** - * @description Receive inviter notification - * @example 1 - */ - ReceiveInviterNotification: number; - /** - * @description Receive email integration notification - * @example 1 - */ - ReceiveEmailIntegrationNotification: number; - /** - * @description Receive transaction notification - * @example 1 - */ - ReceiveTransactionNotification: number; - /** - * @description User has already created a wallet once - * @example 1 - */ - WalletCreated: number; + requestBody?: { + content: { + "application/json": components["schemas"]["SignedKeyListInputWrapper"]; + }; }; - DriveShareRefreshCoreEventService: { - DriveShareRefresh: { - /** @enum {integer} */ - Action?: 2; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; }; - GroupMembershipGroup: { - ID: components['schemas']['Id']; - Name: string; - Address: string; + }; + "post_core-{_version}-keys-address-{enc_id}-delete": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example ACXDmTaBub14w== + */ + enc_id: string; + _version: string; + }; + cookie?: never; }; - ForwardingKeys: { - PrivateKey?: components['schemas']['PGPPrivateKey'] | null; - ActivationToken?: components['schemas']['PGPMessage'] | null; + requestBody?: { + content: { + "application/json": components["schemas"]["SignedKeyListInputWrapper"]; + }; }; - EventOutput: { - ID?: components['schemas']['Id'] | null; - Action: components['schemas']['EventAction']; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; }; - Sender: { - /** @example foo@protonmail.dev */ - Address: string; - /** @example Joe */ - Name: string; - /** - * @description Optional, whether to display the Proton badge.
- * * Possible values:
- * * - 1: Display the Proton badge
- * * - 0: Do not display the Proton badge - * @example 1 - * @enum {integer} - */ - IsProton: 0 | 1; - /** - * @description Optional, whether to display the SenderImage.
- * * Possible values:
- * * - 1: Display the sender image
- * * - 0: Do not display the sender image - * @example 1 - * @enum {integer} - */ - DisplaySenderImage: 0 | 1; - /** - * @description Optional, BIMI selector header, set if present on message or if domain has BIMI - * @example null - */ - BimiSelector?: string | null; - /** - * @description Whether the mail came through simple login - * @example 1 - * @enum {integer} - */ - IsSimpleLogin: 0 | 1; + }; + "put_core-{_version}-keys-private": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; }; - Recipient: { - /** @example foo@protonmail.dev */ - Address: string; - /** @example Joe */ - Name: string; - /** @description Optional */ - Group?: string | null; - /** - * @description Optional, whether to display the Proton badge.
- * Possible values:
- * - 1: Display the Proton badge
- * - 0: Do not display the Proton badge - * @example 1 - * @enum {integer} - */ - IsProton: 0 | 1; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateKeyInput"]; + }; }; - /** @description Attachment counts grouped by the MIME type and disposition. - * Listed types here are an example */ - GroupedAttachmentsCount: { - 'image/jpeg': { - /** @example 2 */ - inline?: number; - /** @example 1 */ - attachment?: number; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; }; - 'text/calendar': { - /** @example 1 */ - attachment?: number; + }; + }; + "get_core-{_version}-events-latest": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; }; + cookie?: never; }; - Metadata: { - ID: components['schemas']['Id2']; - Name?: string | null; - Size: number; - MIMEType: string; - Disposition?: components['schemas']['Disposition'] | null; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LatestEventResponse"]; + }; + }; }; - Sender2: Record; - Recipient2: Record; - /** @description Attachment counts grouped by the MIME type and disposition. - * Listed types here are an example */ - GroupedAttachmentsCount2: { - 'image/jpeg': { + }; + "get_core-{_version}-images-logo": { + parameters: { + query?: { + /** @example noreply%40amazon.com */ + Address?: components["schemas"]["LogoRequest"]["Address"]; + /** @example amazon.com */ + Domain?: components["schemas"]["LogoRequest"]["Domain"]; + /** @example 64 */ + Size?: components["schemas"]["LogoRequest"]["Size"]; + Mode?: components["schemas"]["LogoRequest"]["Mode"]; + BimiSelector?: components["schemas"]["LogoRequest"]["BimiSelector"]; /** @example 2 */ - inline?: number; - /** @example 1 */ - attachment?: number; + MaxScaleUpFactor?: components["schemas"]["LogoRequest"]["MaxScaleUpFactor"]; + Format?: components["schemas"]["LogoRequest"]["Format"]; + ComputedAddress?: components["schemas"]["LogoRequest"]["ComputedAddress"]; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Binary data of the image */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; }; - 'text/calendar': { - /** @example 1 */ - attachment?: number; + /** @description Return an empty image when we cannot find a valid logo */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; }; }; - Metadata2: { - ID: components['schemas']['Id3']; - Name?: string | null; - Size: number; - MIMEType: string; - Disposition?: components['schemas']['Disposition2'] | null; + }; + "get_core-{_version}-members-{enc_id}-addresses": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: number; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: number; + }; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; }; - ContactData: { - /** - * @description Possible values: - *
- 0: clear text - *
- 1: encrypted - *
- 2: signed - *
- 3: encrypted and signed - * @example 2 - * @enum {integer} - */ - Type: 0 | 1 | 2 | 3; - /** - * @description VCard data - * @example BEGIN:VCARD - * VERSION:4.0 - * FN:ProtonMail Features - * UID:proton-legacy-139892c2-f691-4118-8c29-061196013e04 - * item1.EMAIL;TYPE=work;PREF=1:features@protonmail.black - * item2.EMAIL;TYPE=home;PREF=2:features@protonmail.ch - * END:VCARD - */ - Data: string; - /** - * @description PGP signature of the data - * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- - */ - Signature: string; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Addresses?: components["schemas"]["AddressUser"][]; + }; + }; + }; }; - Tree: { - List?: string[]; - /** @example Require */ - Type?: string; - }[]; - Plan: { - /** @example */ - ID: string; - /** - * @description bits, 1 = primary plan, 0 = sub-plan (add-on) - * @example [ - * 0, - * 1 - * ] - */ - Type: number; - /** - * @description bits, 1 = plan available for subscription - * @example 1 - * @enum {integer} - */ - State: 0 | 1; - /** @example 1 */ - Cycle: number; - /** @example business */ - Name: string; - /** @example ProtonMail Business (monthly) */ - Title: string; - /** @example USD */ - Currency: string; - /** @example 1000 */ - Amount: number; - /** @example 1 */ - MaxDomains: number; - /** @example 5 */ - MaxAddresses: number; - /** @example 25 */ - MaxCalendars: number; - /** @example 10737418240 */ - MaxSpace: number; - /** @example 2 */ - MaxMembers: number; - /** @example 0 */ - MaxVPN: number; - /** - * @description bits, 1 = mail, 4 = VPN - * @example 1 - */ - Services: number; - /** - * @description bits, 1 = catch-all addresses - * @example 1 - */ - Features: number; + }; + "post_core-{_version}-members-{enc_id}-addresses": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1InternalEncrypted
2ExternalUnencrypted
3ExternalEncrypted
- * @enum {integer} - */ - AddressForwardingType: 1 | 2 | 3; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Pending
1Active
2Outdated
3Paused
4Rejected
- * @enum {integer} - */ - AddressForwardingState: 0 | 1 | 2 | 3 | 4; - ActivationForwardingKey: { - /** - * PGP message, encrypted with the forwardee address key and signed with the forwarder address key. - * @description The embedded secret is a 64-char hex string. - */ - ActivationToken: string; - /** Armored PGP private key, locked with the token */ - PrivateKey: string; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; + }; }; - /** AddressForwardingFilter */ - AddressForwardingFilter: { - Tree: components['schemas']['Tree']; - Sieve: string; - Version: components['schemas']['SieveVersion']; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: components["schemas"]["AddressUser"] & { + /** @example Fred */ + MemberName?: string; + /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + MemberID?: string; + }; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; }; - VPNServerTransformerInterface: { - /** - * @description Encrypted id - * @example l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA== - */ - ID: string; - /** - * @description IP client calls - * @example 95.215.61.163 - */ - EntryIP: string; - /** - * @description IP that calls the world - * @example 95.215.61.164 - */ - ExitIP: string; - /** - * @description Qualified domain name - * @example es-04.protonvpn.com - */ - Domain: string; - /** - * @description 1 if server is operational or 0 if it's down - * @example 1 - */ - Status: number; - /** - * @description **Bitmap** - * * where each service to be marked as down are flagged: - * * `1`: Bind - * * `2`: HostAlive - * * `4`: OpenVPN_TCP - * * `8`: OpenVPN_UDP - * * `16`: IKEv2 - * * `32`: WireGuard - * @example 12 - */ - ServicesDown: number; - /** - * @description Setup age of the given server - * @example 0 - */ - Generation: number; - /** - * @description Short explanation about the current status - * @example Provisionning - */ - ServicesDownReason?: string | null; - /** - * @description To match username suffixes provided at authentication - * @example us-va-01 - */ - Label: string; - /** - * @description X25519 public key PEM - * @example -----BEGIN PUBLIC KEY----- ... - */ - X25519PublicKey?: string | null; - /** @description Optional list of protocol-specific relays */ - EntryPerProtocol?: { - OpenVPNUDP?: { - /** - * @description IP of the relay - * @example 1.0.0.0 - */ - IPv4?: string; - /** @description Port to connect to; if none are available, this property is not returned */ - Ports?: Record[]; - } | null; - OpenVPNTCP?: { - /** - * @description IP of the relay - * @example 1.0.0.0 - */ - IPv4?: string; - /** @description Port to connect to; if none are available, this property is not returned */ - Ports?: Record[]; - } | null; - IKEv2?: { - /** - * @description IP of the relay - * @example 1.0.0.0 - */ - IPv4?: string; - /** @description Port to connect to; if none are available, this property is not returned */ - Ports?: Record[]; - } | null; - WireGuardUDP?: { - /** - * @description IP of the relay - * @example 1.0.0.0 - */ - IPv4?: string; - /** @description Port to connect to; if none are available, this property is not returned */ - Ports?: Record[]; - } | null; - WireGuardTCP?: { - /** - * @description IP of the relay - * @example 1.0.0.0 - */ - IPv4?: string; - /** @description Port to connect to; if none are available, this property is not returned */ - Ports?: Record[]; - } | null; - WireGuardTLS?: { - /** - * @description IP of the relay - * @example 1.0.0.0 - */ - IPv4?: string; - /** @description Port to connect to; if none are available, this property is not returned */ - Ports?: Record[]; - } | null; - } | null; + }; + "get_core-{_version}-addresses": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; }; - /** - * @description

normal calendar: `0`, subscribed calendar: `1`

See values descriptions
See values descriptions
ValueDescription
0Normal
1Subscription
- * @enum {integer} - */ - CalendarType: 0 | 1; - CalendarOwner: { - /** - * @description owner's email - * @example owner@pm.me - */ - Email: string; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Addresses?: components["schemas"]["AddressUser"][]; + SignedAddressList?: components["schemas"]["KTAddressListTransformer"]; + }; + }; + }; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Disabled
1Enabled
- * @enum {integer} - */ - WalletStatus: 0 | 1; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1OnChain
2Lightning
- * @enum {integer} - */ - WalletType: 1 | 2; - /** - * @description Path used to generate a series of Bitcoin addresses from a single seed phrase or mnemonic, only BIP 44, 49, 84 and 86 are currently accepted - * @example m/44'/0'/0' - */ - DerivationPath: string; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1Legacy
2NestedSegwit
3NativeSegwit
4Taproot
- * @enum {integer} - */ - ScriptType: 1 | 2 | 3 | 4; - /** - * @description BTC address - * @example 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa - */ - BitcoinAddress: string; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
1ProtonToProtonSend
2ProtonToProtonReceive
3ExternalSend
4ExternalReceive
- * @enum {integer} - */ - TransactionType: 1 | 2 | 3 | 4; - ExchangeRateOutput: { - ID: components['schemas']['Id']; - /** - * @description Bitcoin unit of the exchange rate - * @example BTC - */ - BitcoinUnit: string; - /** - * @description Fiat currency of the exchange rate - * @example CHF - */ - FiatCurrency: string; - /** - * @description Sign of the fiat currency (e.g. € for EUR) - * @example 100 - */ - Sign: string; - /** - * @description Time of the BTC/Fiat exchange rate - * @example 1707287982 - */ - ExchangeRateTime?: string | null; - /** - * @description Exchange rate BitcoinUnit/FiatCurrency - * @example 20000000 - */ - ExchangeRate: number; - /** - * @description Cents precision of the fiat currency (e.g. 1 for JPY, 100 for USD) - * @example 100 - */ - Cents: number; + }; + "post_core-{_version}-addresses": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: components["schemas"]["AddressUser"] & { + /** @example Fred */ + MemberName?: string; + /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + MemberID?: string; + }; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; }; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
0Delete
1Create
2Update
3UpdateFlags
- * @enum {integer} - */ - EventAction: 0 | 1 | 2 | 3; - /** @description An encrypted ID */ - Id2: string; - /** @enum {string} */ - Disposition: 'attachment' | 'inline'; - /** @description An encrypted ID */ - Id3: string; - /** @enum {string} */ - Disposition2: 'attachment' | 'inline'; - /** - * @description
See values descriptions
See values descriptions
ValueDescription
2V2
- * @enum {integer} - */ - SieveVersion: 2; }; - responses: { - /** @description Plain success response without additional information */ - ProtonSuccessResponse: { - headers: { - /** @description The same as the body code */ - 'X-Pm-Code'?: 1000; - [name: string]: unknown; + "post_core-{_version}-members-addresses-available": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; }; + cookie?: never; + }; + requestBody?: { content: { - 'application/json': components['schemas']['ProtonSuccess']; + "application/json": components["schemas"]["CreateAddressInput"]; }; }; - /** @description General Error */ - ProtonErrorResponse: { - headers: { - [name: string]: unknown; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-addresses-order": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReorderAddressesInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_memberId}-addresses-order": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_memberId: string; }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReorderAddressesInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-addresses-setup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { content: { - 'application/json': components['schemas']['ProtonError']; + "application/json": components["schemas"]["CreateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: { + /** @example vuGSa1zsx0kV0jsfhX_xKSDQ0dvcLdMduA_c2c9fhaC1ZYCZKe8gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + ID?: string; + /** @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== */ + DomainID?: string; + /** @example me@protonmail.com */ + Email?: string; + /** @example 0 */ + Send?: number; + /** + * @description 0 is disabled, 1 is enabled, can be set by user + * @example 1 + */ + Status?: number; + /** + * @description 1 is original PM, 2 is PM alias, 3 is custom domain address + * @example 1 + */ + Type?: number; + /** + * @description 1 is active address (Status=1 and has key), 0 is inactive (cannot send or receive) + * @example 0 + */ + Receive?: number; + /** @example 1 */ + Order?: number; + /** @example hi */ + DisplayName?: string; + /** @example signature */ + Signature?: string; + /** @example 0 */ + HasKeys?: number; + /** @example [] */ + Keys?: string[]; + }; + }; + }; }; }; }; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - 'get_core-{_version}-addresses-allowAddressDeletion': { + "get_core-{_version}-addresses-canonical": { parameters: { - query?: never; + query?: { + /** @description The list of email addresses, limited to maximum 100. They must be url encoded. */ + Emails?: string[]; + }; header?: never; path: { _version: string; @@ -10666,52 +12391,44 @@ export interface operations { }; requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example 1001 */ + Code?: number; + Responses?: { + /** @example john.doe+friend@gmail.com */ + Email?: string; + Response?: { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example johndoe@gmail.com */ + CanonicalEmail?: string; + }; + }[]; + }; + }; }; }; }; - 'put_core-{_version}-keys-address-active': { + "get_core-{_version}-addresses-{enc_id}": { parameters: { query?: never; header?: never; path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; _version: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** - * @description The address ID - * @example ACXDmTa...Bub14w== - */ - AddressID?: string; - Keys?: { - /** - * @description Encrypted AddressKey ID - * @example G1MbEt3Ep5P_E...Wz8WbHVAOl_6h== - */ - AddressKeyID?: string; - /** - * @description 1 if the FE can decrypt this key - * @example 1 - */ - Active?: number; - }[]; - SignedKeyList?: { - /** @example JSON.stringify([{"Fingerprint": "fde90483475164ec6353c93f767df53b0ca8395c","SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Primary": 1,"Flags": 3}]) */ - Data?: string; - /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature?: string; - }; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -10719,27 +12436,33 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SignedKeyList?: components['schemas']['KTKeyList'] | null; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: components["schemas"]["AddressUser"]; }; }; }; }; }; - 'get_core-{_version}-keys': { + "put_core-{_version}-addresses-{enc_id}": { parameters: { - query?: { - Email?: string; - Fingerprint?: string; - }; + query?: never; header?: never; path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; _version: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateAddressInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -10747,62 +12470,30 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @description 1:Internal, 2:External - * @example 1 - */ - RecipientType?: number; - /** - * @description 0:KT is valid, 1: External address - keys omitted, 2: Catch all - wrong SKL - * @example 0 - */ - IgnoreKT?: number; - /** @example text/html */ - MIMEType?: string; - Keys?: { - /** - * @description Bitmap with the following values.
- * Key is not compromised = 1 (2^0) (if the bit is set to one the key is not compromised)
- * Key is not obsolete = 2 (2^1)
- * @example 3 - */ - Flags?: number; - /** @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- */ - PublicKey?: string; - /** - * @description 0: Internal, 1: WKD, 2: KOO - * @example 0 - * @enum {integer} - */ - Source?: 0 | 1 | 2; - }[]; - SignedKeyList?: components['schemas']['KTKeyList']; - /** @example [] */ - Warnings?: string[]; - /** - * @description Tells whether this is an official Proton address, optional field - * @example 1 - */ - IsProton?: number; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-keys': { + "delete_core-{_version}-addresses-{enc_id}": { parameters: { query?: never; header?: never; path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['CreateLegacyKeyInput']; + "application/json": components["schemas"]["AddressListInput"]; }; }; responses: { @@ -10812,67 +12503,40 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Key?: { - /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ - ID?: string; - /** @example 3 */ - Version?: number; - /** @example 3 */ - Flags?: number; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ - Token?: string | null; - /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature?: string | null; - /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ - Fingerprint?: string; - Fingerprints?: string[]; - /** @example null */ - Activation?: number; - /** @example 1 */ - Primary?: number; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-keys-address': { + "get_core-{_version}-domains-{enc_id}-addresses": { parameters: { - query?: never; + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: number; + }; header?: never; path: { + /** + * @description the encrypted domain id + * @example lKJlejjlk== + */ + domainid: string; _version: string; + enc_id: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** @example xRvCGwFq_TW7...i8FtJaGyFEq0g== */ - AddressID?: string; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ - AddressForwardingID?: string; - /** @example 1 */ - Primary?: number; - /** @example -----BEGIN PGP MESSAGE-----.* */ - Token?: string; - /** @example -----BEGIN PGP SIGNATURE-----.* */ - Signature?: string; - SignedKeyList?: { - /** @example JSON.stringify([{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1}]) */ - Data?: string; - /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature?: string; - }; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -10880,65 +12544,63 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Key?: { - /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ - ID?: string; - /** @example 3 */ - Version?: number; - /** @example 3 */ - Flags?: number; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ - Token?: string | null; - /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature?: string | null; - /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ - Fingerprint?: string; - Fingerprints?: string[]; - /** @example null */ - Activation?: number; - /** @example 1 */ - Primary?: number; - /** @example 1 */ - Active?: number; - }; + "application/json": { + Addresses?: (components["schemas"]["AddressUser"] & { + /** + * @description whether this is the catch-all address for this domain + * @example 0 + */ + CatchAll?: number; + /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + MemberID?: string; + })[]; + }; + }; + }; + /** @description Domain does not exist */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Domain does not exist */ + Error?: string; + Details?: string[]; }; }; }; }; }; - 'post_core-{_version}-keys-group': { + "get_core-{_version}-domains-{enc_id}-claimedAddresses": { parameters: { - query?: never; + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: number; + }; header?: never; path: { + /** + * @description the encrypted domain id + * @example lKJle...jjlk== + */ + DomainId: string; _version: string; + enc_id: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** @example xRvCGwFq_TW7...i8FtJaGyFEq0g== */ - AddressID?: string; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - /** @example -----BEGIN PGP MESSAGE-----.* */ - OrgToken?: string; - /** @example -----BEGIN PGP SIGNATURE-----.* */ - OrgSignature?: string; - SignedKeyList?: { - /** @example JSON.stringify([{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1}]) */ - Data?: string; - /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature?: string; - }; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -10946,50 +12608,49 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Key?: { - /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ - ID?: string; - /** @example 3 */ - Flags?: number; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - /** @example null or -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ - OrgToken?: string; - /** @example null or -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - OrgSignature?: string; - /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ - Fingerprint?: string; - Fingerprints?: string[]; - /** @example 1 */ - Primary?: number; - /** @example 1 */ - Active?: number; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Addresses?: { + /** @example john.doe+friend@mydomain.com */ + Email?: string; + }[]; + }; + }; + }; + /** @description Domain does not exist */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Domain does not exist */ + Error?: string; + Details?: string[]; }; }; }; }; }; - 'post_core-{_version}-keys-setup': { + "put_core-{_version}-addresses-{enc_id}-enable": { parameters: { - query?: { - /** - * @description Flag indicating that /core/v4/welcome-mail-send and /core/v4/checklist/get-started/init endpoints are called by the client - * @example 1 - */ - AsyncUserInitialization?: number; - }; + query?: never; header?: never; path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['SetupKeyInput']; + "application/json": components["schemas"]["AddressListInput"]; }; }; responses: { @@ -10999,39 +12660,21 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - User?: components['schemas']['User'] & { - Keys?: components['schemas']['UserKey'] & { - /** @example 3 */ - Flags?: number; - }; - }; - VPN?: { - /** @example 1 */ - Status?: number; - /** @example 0 */ - ExpirationTime?: number; - /** @example visionary */ - PlanName?: string; - /** @example 10 */ - MaxConnect?: number; - /** @example 2 */ - MaxTier?: number; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-keys-{enc_id}-delete': { + "put_core-{_version}-addresses-{enc_id}-disable": { parameters: { query?: never; header?: never; path: { /** - * @description the key id - * @example ACXDmTaBub14w== + * @description the encrypted address id + * @example lKJlejjlk== */ enc_id: string; _version: string; @@ -11040,7 +12683,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SignedKeyListInputWrapper']; + "application/json": components["schemas"]["AddressListInput"]; }; }; responses: { @@ -11050,21 +12693,21 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-keys-address-{enc_id}-delete': { + "put_core-{_version}-addresses-{enc_id}-delete": { parameters: { query?: never; header?: never; path: { /** - * @description the key id - * @example ACXDmTaBub14w== + * @description the encrypted address id + * @example lKJlejjlk== */ enc_id: string; _version: string; @@ -11073,7 +12716,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SignedKeyListInputWrapper']; + "application/json": components["schemas"]["AddressListInput"]; }; }; responses: { @@ -11083,25 +12726,30 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-keys-private': { + "put_core-{_version}-addresses-{enc_id}-type": { parameters: { query?: never; header?: never; path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateKeyInput']; + "application/json": components["schemas"]["ChangeAddressTypeInput"]; }; }; responses: { @@ -11111,87 +12759,74 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @description Present only if inline re-authentication is submitted - * @example - */ - ServerProof?: string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-images-logo': { + "put_core-{_version}-addresses-{enc_id}-rename-internal": { parameters: { - query?: { - /** @example noreply%40amazon.com */ - Address?: components['schemas']['LogoRequest']['Address']; - /** @example amazon.com */ - Domain?: components['schemas']['LogoRequest']['Domain']; - /** @example 64 */ - Size?: components['schemas']['LogoRequest']['Size']; - Mode?: components['schemas']['LogoRequest']['Mode']; - BimiSelector?: components['schemas']['LogoRequest']['BimiSelector']; - /** @example 2 */ - MaxScaleUpFactor?: components['schemas']['LogoRequest']['MaxScaleUpFactor']; - Format?: components['schemas']['LogoRequest']['Format']; - ComputedAddress?: components['schemas']['LogoRequest']['ComputedAddress']; - }; + query?: never; header?: never; path: { + /** + * @description the encrypted id + * @example lKJl...ejjlk== + */ + enc_id: string; _version: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": { + /** @example john.doe */ + Local?: string; + AddressKeys?: { + /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + }[]; + }; + }; + }; responses: { - /** @description Binary data of the image */ + /** @description Success */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/octet-stream': string; - }; - }; - /** @description Return an empty image when we cannot find a valid logo */ - 204: { - headers: { - [name: string]: unknown; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; }; - content?: never; }; }; }; - 'get_core-{_version}-members-{enc_id}-addresses': { + "put_core-{_version}-addresses-{enc_id}-rename-external": { parameters: { - query?: { - /** - * @description the page index using 0-based indexing - * @example 0 - */ - Page?: number; - /** - * @description the page size, maximum 150 - * @example 150 - */ - PageSize?: number; - }; + query?: never; header?: never; path: { /** - * @description the member id - * @example ACXDmTaBub14w== + * @description the encrypted id + * @example lKJle...jjlk== */ - memberid: string; - _version: string; enc_id: string; + _version: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameUnverifiedAddressInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11199,27 +12834,31 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Addresses?: components['schemas']['AddressUser'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-members-{enc_id}-addresses': { + "put_core-{_version}-addresses-{enc_addressId}-encryption": { parameters: { query?: never; header?: never; path: { + /** + * @description the address id + * @example ACXDmTaBub14w== + */ + addressid: string; _version: string; - enc_id: string; + enc_addressId: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['CreateAddressInput']; + "application/json": components["schemas"]["UpdateEncryptionSignatureFlagsInput"]; }; }; responses: { @@ -11229,32 +12868,27 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Address?: components['schemas']['AddressUser'] & { - /** @example Fred */ - MemberName?: string; - /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ - MemberID?: string; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-addresses': { + "put_core-{_version}-members-addresses-permissions-organization-switch": { parameters: { - query?: { - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; - }; + query?: never; header?: never; path: { _version: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressIdsInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11262,29 +12896,26 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Addresses?: components['schemas']['AddressUser'][]; - SignedAddressList?: components['schemas']['KTAddressListTransformer']; + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["SwitchAddressesOrganizationPermissionsTransformer"][]; }; }; }; }; }; - 'post_core-{_version}-addresses': { + "post_core-{_version}-members-{memberId}-saml": { parameters: { query?: never; header?: never; path: { _version: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateAddressInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -11292,57 +12923,50 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Address?: components['schemas']['AddressUser'] & { - /** @example Fred */ - MemberName?: string; - /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ - MemberID?: string; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-members-addresses-available': { + "delete_core-{_version}-members-{memberId}-saml": { parameters: { query?: never; header?: never; path: { _version: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateAddressInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; }; }; - 'put_core-{_version}-addresses-order': { + "delete_core-{_version}-members-{memberId}-devices-{deviceId}": { parameters: { query?: never; header?: never; path: { _version: string; + memberId: components["schemas"]["Id"]; + deviceId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['ReorderAddressesInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -11350,27 +12974,24 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-addresses-setup': { + "delete_core-{_version}-members-{memberId}-devices": { parameters: { query?: never; header?: never; path: { _version: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateAddressInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -11378,57 +12999,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Address?: { - /** @example vuGSa1zsx0kV0jsfhX_xKSDQ0dvcLdMduA_c2c9fhaC1ZYCZKe8gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ - ID?: string; - /** @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== */ - DomainID?: string; - /** @example me@protonmail.com */ - Email?: string; - /** @example 0 */ - Send?: number; - /** - * @description 0 is disabled, 1 is enabled, can be set by user - * @example 1 - */ - Status?: number; - /** - * @description 1 is original PM, 2 is PM alias, 3 is custom domain address - * @example 1 - */ - Type?: number; - /** - * @description 1 is active address (Status=1 and has key), 0 is inactive (cannot send or receive) - * @example 0 - */ - Receive?: number; - /** @example 1 */ - Order?: number; - /** @example hi */ - DisplayName?: string; - /** @example signature */ - Signature?: string; - /** @example 0 */ - HasKeys?: number; - /** @example [] */ - Keys?: string[]; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-addresses-canonical': { + "get_core-{_version}-members-{id}-devices": { parameters: { - query?: { - /** @description The list of email addresses, limited to maximum 100. They must be url encoded. */ - Emails?: string[]; - }; + query?: never; header?: never; path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; _version: string; + id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -11440,33 +13029,19 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - /** @example 1001 */ - Code?: number; - Responses?: { - /** @example john.doe+friend@gmail.com */ - Email?: string; - Response?: { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example johndoe@gmail.com */ - CanonicalEmail?: string; - }; - }[]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; }; }; }; }; }; - 'get_core-{_version}-addresses-{enc_id}': { + "get_core-{_version}-members-devices-pending": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted address id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; }; cookie?: never; @@ -11479,31 +13054,39 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Address?: components['schemas']['AddressUser']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; }; }; }; }; }; - 'put_core-{_version}-addresses-{enc_id}': { + "post_core-{_version}-auth-refresh": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted address id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateAddressInput']; + "application/json": { + /** @example token */ + ResponseType?: string; + /** @example refresh_token */ + GrantType?: string; + /** @example eaad5a7059835aac32c0bf99c2e208a59b8c1a55 */ + RefreshToken?: string; + /** + * @deprecated + * @description This parameter is deprecated and should be passed via 'x-pm-uid' header instead + * @example m3mxv75of7tuy4na4c3fzkskaqnu35xj + */ + UID?: string; + }; }; }; responses: { @@ -11513,32 +13096,80 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example abcDecryptedTokenAndNoSaltAndNoPrivateKey123 */ + AccessToken?: string; + /** + * @deprecated + * @example 360000 + */ + ExpiresIn?: number; + /** @example Bearer */ + TokenType?: string; + /** + * @deprecated + * @example full other_scopes + */ + Scope?: string; + Scopes?: string[]; + /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ + UID?: string; + /** @example b894b4c4f20003f12d486900d8b88c7d68e67235 */ + RefreshToken?: string; + /** @example 0 */ + LocalID?: number; + /** + * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have + * @example 5 + */ + RefreshCounter?: number; }; }; }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; + }; }; }; - 'delete_core-{_version}-addresses-{enc_id}': { + "put_core-{_version}-members-{memberId}-devices-{deviceId}-reject": { parameters: { query?: never; header?: never; path: { /** - * @description the encrypted address id - * @example lKJlejjlk== + * @description the member id + * @example ACXDmTaBub14w== */ - enc_id: string; + memberid: string; + /** + * @description the device id + * @example ACXDmTaBub14w== + */ + deviceId: components["schemas"]["Id"]; _version: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['AddressListInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -11546,40 +13177,33 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-domains-{enc_id}-addresses': { + "post_core-{_version}-members-{memberId}-devices-reset": { parameters: { - query?: { - /** - * @description the page index using 0-based indexing - * @example 0 - */ - Page?: string; - /** - * @description the page size, maximum 150 - * @example 150 - */ - PageSize?: number; - }; + query?: never; header?: never; path: { /** - * @description the encrypted domain id - * @example lKJlejjlk== + * @description the member id + * @example ACXDmTaBub14w== */ - domainid: string; + memberid: string; _version: string; - enc_id: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["ResetAuthDevicesInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11587,63 +13211,27 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Addresses?: (components['schemas']['AddressUser'] & { - /** - * @description whether this is the catch-all address for this domain - * @example 0 - */ - CatchAll?: number; - /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ - MemberID?: string; - })[]; - }; - }; - }; - /** @description Domain does not exist */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2501 */ - Code?: number; - /** @example Domain does not exist */ - Error?: string; - Details?: string[]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-domains-{enc_id}-claimedAddresses': { + "post_core-{_version}-auth": { parameters: { - query?: { - /** - * @description the page index using 0-based indexing - * @example 0 - */ - Page?: string; - /** - * @description the page size, maximum 150 - * @example 150 - */ - PageSize?: number; - }; + query?: never; header?: never; path: { - /** - * @description the encrypted domain id - * @example lKJle...jjlk== - */ - DomainId: string; _version: string; - enc_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["AuthInput2"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11651,148 +13239,184 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Addresses?: { - /** @example john.doe+friend@mydomain.com */ - Email?: string; - }[]; - }; - }; - }; - /** @description Domain does not exist */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2501 */ - Code?: number; - /** @example Domain does not exist */ - Error?: string; - Details?: string[]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Session unique ID + * @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 + */ + UID?: string; + /** @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ + UserID?: string; + /** @example ACXDmTaBub14w== */ + EventID?: string; + /** @example */ + ServerProof?: string; + /** + * @description only if the session is not in cookie mode + * @example Bearer + */ + TokenType?: string; + /** + * @description only if the session is not in cookie mode + * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 + */ + AccessToken?: string; + /** + * @description only if the session is not in cookie mode + * @example wfih0367aa7dc0359bf5c42d15a93e6c + */ + RefreshToken?: string; + /** + * @deprecated + * @description only if the session is not in cookie mode + * @example 360000 + */ + ExpiresIn?: number; + /** @example 0 */ + LocalID?: number; + Scopes?: string[]; + /** + * @deprecated + * @example full other_scopes + */ + Scope?: string; + /** @example 2 */ + PasswordMode?: number; + /** + * @description If 1 the user should be prompted to enter a new password on login + * @example 0 + */ + TemporaryPassword?: number; + /** + * @description Whether the user has a recovery email set + * @example true + */ + HasRecoveryEmail?: boolean; + /** + * @description Whether the user has a recovery phone number set + * @example true + */ + HasRecoveryPhone?: boolean; + /** + * @description Whether the user has a recovery phrase (mnemonic) set + * @example true + */ + HasRecoveryPhrase?: boolean; + /** + * @deprecated + * @description Alias for 2FA.Enabled + * @example 1 + */ + TwoFactor?: number; + "2FA"?: { + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Enabled?: number; + FIDO2?: { + /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ + AuthenticationOptions?: Record; + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + }; + /** + * @deprecated + * @description 1 if TOTP is enabled, 0 otherwise + * @example 1 + */ + TOTP?: number; + }; }; }; }; }; }; - 'put_core-{_version}-addresses-{enc_id}-enable': { + "delete_core-{_version}-auth": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['AddressListInput']; - }; - }; + requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; + content?: never; }; }; }; - 'put_core-{_version}-addresses-{enc_id}-disable': { + "get_core-{_version}-auth-sso-{token}": { parameters: { - query?: never; + query?: { + FinalRedirectBaseUrl?: string | null; + }; header?: never; path: { /** - * @description the encrypted address id - * @example lKJlejjlk== + * @description Token received as SSOChallengeToken from POST /auth/info + * @example a5fd396fcbb */ - enc_id: string; + token: string; _version: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['AddressListInput']; - }; - }; + requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; + content?: never; }; }; }; - 'put_core-{_version}-addresses-{enc_id}-delete': { + "post_core-{_version}-organization-communities": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted address id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['AddressListInput']; + "application/json": components["schemas"]["CreateOrganizationInput"]; }; }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["CreateOrganizationOutput"]; }; }; }; }; - 'put_core-{_version}-addresses-{enc_id}-type': { + "post_core-{_version}-members-{enc_id}-keys": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; + enc_id: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['ChangeAddressTypeInput']; + "application/json": components["schemas"]["CreateMemberKeysInput"]; }; }; responses: { @@ -11802,39 +13426,44 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + MemberKey?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example 3 */ + Version?: number; + /** @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- */ + PublicKey?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + Fingerprints?: string[]; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Activation?: string; + /** @example 1 */ + Primary?: number; + /** @example 3 */ + Flags?: number; + }; }; }; }; }; }; - 'put_core-{_version}-addresses-{enc_id}-rename-internal': { + "post_core-{_version}-keys-user": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJl...ejjlk== - */ - enc_id: string; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': { - /** @example john.doe */ - Local?: string; - AddressKeys?: { - /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ - ID?: string; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - }[]; - }; + "application/json": components["schemas"]["AddNewUserKeyInput"]; }; }; responses: { @@ -11844,32 +13473,26 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + KeyID?: string; }; }; }; }; }; - 'put_core-{_version}-addresses-{enc_id}-rename-external': { + "delete_core-{_version}-organization-{organization_id}": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJle...jjlk== - */ - enc_id: string; _version: string; + organization_id: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['RenameUnverifiedAddressInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -11877,48 +13500,37 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-addresses-{enc_addressId}-encryption': { + "post_core-{_version}-settings-highsecurity-summary-email": { parameters: { query?: never; header?: never; path: { - /** - * @description the address id - * @example ACXDmTaBub14w== - */ - addressid: string; _version: string; - enc_addressId: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['UpdateEncryptionSignatureFlagsInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'put_core-{_version}-members-addresses-permissions-organization-switch': { + "delete_core-{_version}-settings-highsecurity-summary-email": { parameters: { query?: never; header?: never; @@ -11927,38 +13539,35 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['AddressIdsInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - /** @enum {integer} */ - Code?: 1001; - Responses?: components['schemas']['SwitchAddressesOrganizationPermissionsTransformer'][]; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-members-{memberId}-saml': { + "put_core-{_version}-domains-{enc_id}-flags": { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components['schemas']['Id']; + enc_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["PutDomainFlagsInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11966,20 +13575,23 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domain?: components["schemas"]["DomainOutput2"]; }; }; }; }; }; - 'delete_core-{_version}-members-{memberId}-saml': { + "get_core-{_version}-domains": { parameters: { - query?: never; + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; header?: never; path: { _version: string; - memberId: components['schemas']['Id']; }; cookie?: never; }; @@ -11988,28 +13600,29 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["GetDomainsResponse"]; }; }; }; }; - 'delete_core-{_version}-members-{memberId}-devices-{deviceId}': { + "post_core-{_version}-domains": { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components['schemas']['Id']; - deviceId: components['schemas']['Id']; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateDomainInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -12017,20 +13630,22 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domain?: components["schemas"]["DomainOutput2"]; }; }; }; }; }; - 'delete_core-{_version}-members-{memberId}-devices': { + "get_core-{_version}-domains-available": { parameters: { - query?: never; + query?: { + Type?: string | null; + }; header?: never; path: { _version: string; - memberId: components['schemas']['Id']; }; cookie?: never; }; @@ -12042,25 +13657,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domains?: string[]; }; }; }; }; }; - 'get_core-{_version}-members-{id}-devices': { + "get_core-{_version}-domains-premium": { parameters: { query?: never; header?: never; path: { - /** - * @description the member id - * @example ACXDmTaBub14w== - */ - memberid: string; _version: string; - id: components['schemas']['Id']; }; cookie?: never; }; @@ -12072,15 +13682,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - AuthDevices?: components['schemas']['AuthDeviceOutput'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domains?: string[]; }; }; }; }; }; - 'get_core-{_version}-members-devices-pending': { + "get_core-{_version}-domains-optin": { parameters: { query?: never; header?: never; @@ -12097,31 +13707,28 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - AuthDevices?: components['schemas']['AuthDeviceOutput'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example proton.me */ + Domain?: string; }; }; }; }; }; - 'put_core-{_version}-members-{memberId}-devices-{deviceId}-reject': { + "get_core-{_version}-domains-{enc_id}": { parameters: { - query?: never; + query?: { + Refresh?: components["schemas"]["BoolInt"]; + }; header?: never; path: { /** - * @description the member id - * @example ACXDmTaBub14w== - */ - memberid: string; - /** - * @description the device id - * @example ACXDmTaBub14w== + * @description the encrypted id + * @example lKJlejjlk== */ - deviceId: components['schemas']['Id']; + enc_id: string; _version: string; - memberId: components['schemas']['Id']; }; cookie?: never; }; @@ -12133,33 +13740,29 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domain?: components["schemas"]["DomainOutput2"]; }; }; }; }; }; - 'post_core-{_version}-members-{memberId}-devices-reset': { + "delete_core-{_version}-domains-{enc_id}": { parameters: { query?: never; header?: never; path: { /** - * @description the member id - * @example ACXDmTaBub14w== + * @description the encrypted id + * @example lKJlejjlk== */ - memberid: string; + enc_id: string; _version: string; - memberId: components['schemas']['Id']; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['ResetAuthDevicesInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -12167,26 +13770,40 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; + }; }; }; - 'post_core-{_version}-members-{enc_id}-keys': { + "put_core-{_version}-domains-{enc_id}-catchall": { parameters: { query?: never; header?: never; path: { - _version: string; + /** + * @description the encrypted id + * @example lKJlejjlk== + */ enc_id: string; + _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['CreateMemberKeysInput']; + "application/json": components["schemas"]["UpdateCatchAllAddressInput"]; }; }; responses: { @@ -12196,98 +13813,40 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - MemberKey?: { - /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ - ID?: string; - /** @example 3 */ - Version?: number; - /** @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- */ - PublicKey?: string; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ - Fingerprint?: string; - Fingerprints?: string[]; - /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ - Activation?: string; - /** @example 1 */ - Primary?: number; - /** @example 3 */ - Flags?: number; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-organizations-scim': { + "get_core-{_version}-organizations-subsidiaries": { parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; - }; - content?: never; + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; }; - }; - }; - 'put_core-{_version}-organizations-scim': { - parameters: { - query?: never; header?: never; path: { _version: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['UpdateScimTenantInput']; - }; - }; + requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; - content?: never; - }; - }; - }; - 'post_core-{_version}-organizations-scim': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateScimTenantInput']; - }; - }; - responses: { - default: { - headers: { - [name: string]: unknown; + content: { + "application/json": components["schemas"]["GetChildOrganizationsResponse"]; }; - content?: never; }; }; }; - 'post_core-{_version}-keys-user': { + "get_core-{_version}-organizations-scim": { parameters: { query?: never; header?: never; @@ -12296,28 +13855,17 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['AddNewUserKeyInput']; - }; - }; + requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ - KeyID?: string; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-domains': { + "put_core-{_version}-organizations-scim": { parameters: { query?: never; header?: never; @@ -12326,23 +13874,21 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateScimTenantInput"]; + }; + }; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Domains?: components['schemas']['DomainTransformer'][]; - }; - }; + content?: never; }; }; }; - 'post_core-{_version}-domains': { + "post_core-{_version}-organizations-scim": { parameters: { query?: never; header?: never; @@ -12353,29 +13899,21 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateDomainInput']; + "application/json": components["schemas"]["CreateScimTenantInput"]; }; }; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Domain?: components['schemas']['DomainTransformer']; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-domains-available': { + "get_core-{_version}-members-organizations": { parameters: { - query?: { - Type?: string | null; - }; + query?: never; header?: never; path: { _version: string; @@ -12384,26 +13922,26 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Domains?: string[]; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-domains-premium': { + "put_core-{_version}-groups-external-{jwt}": { parameters: { - query?: never; + query: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + /** @example ACXDmTaBub14w== */ + GroupID?: components["schemas"]["Id"] | null; + }; header?: never; path: { _version: string; + jwt: string; }; cookie?: never; }; @@ -12412,23 +13950,27 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Domains?: string[]; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'get_core-{_version}-domains-optin': { + "delete_core-{_version}-groups-external-{jwt}": { parameters: { - query?: never; + query: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + /** @example ACXDmTaBub14w== */ + GroupID?: components["schemas"]["Id"] | null; + }; header?: never; path: { _version: string; + jwt: string; }; cookie?: never; }; @@ -12437,111 +13979,91 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example proton.me */ - Domain?: string; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'get_core-{_version}-domains-{enc_id}': { + "post_core-{_version}-groups-owners-accept-{enc_id}": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["AcceptGroupOwnerInviteRequest"]; + }; + }; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Domain?: components['schemas']['DomainTransformer']; - }; - }; + content?: never; }; }; }; - 'delete_core-{_version}-domains-{enc_id}': { + "post_core-{_version}-groups-members": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["AddGroupMemberRequest"]; + }; + }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["GroupMemberResponse"]; }; }; }; }; - 'put_core-{_version}-domains-{enc_id}-catchall': { + "post_core-{_version}-groups-owners-add-{enc_id}": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateCatchAllAddressInput']; + "application/json": components["schemas"]["AddGroupOwnerRequest"]; }; }; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-organizations': { + "get_core-{_version}-groups": { parameters: { query?: never; header?: never; @@ -12558,140 +14080,51 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Organization?: { - /** @example My Org */ - Name?: string; - /** @example My Org */ - DisplayName?: string; - /** @example plus */ - PlanName?: string; - /** - * @description Plan attribution to specific product, 1 = Mail, 2 = Drive, 4 = VPN - * @example 1 - */ - PlanFlags?: number; - /** @example 0 */ - TwoFactorRequired?: number; - /** - * @description If non-null, number of seconds until 2FA setup enforced - * @example null - */ - TwoFactorGracePeriod?: number | null; - /** @example null */ - Theme?: string | null; - /** @example null */ - Email?: string | null; - /** @example 4 */ - MaxDomains?: number; - /** @example 20 */ - MaxAddresses?: number; - /** @example 25 */ - MaxCalendars?: number; - /** @example 10000000000 */ - MaxSpace?: number; - /** @example 15 */ - MaxMembers?: number; - /** @example 15 */ - MaxVPN?: number; - /** - * @description Bits, 1 = catch-all addresses - * @example 0 - */ - Features?: number; - /** - * @description Bits, 1 = loyalty - * @example 0 - */ - Flags?: number; - /** @example 0 */ - UsedDomains?: number; - /** @example 0 */ - UsedAddresses?: number; - /** @example 0 */ - UsedCalendars?: number; - /** @example 81788997 */ - UsedSpace?: number; - /** @example 10000000000 */ - AssignedSpace?: number; - /** @example 1 */ - UsedMembers?: number; - /** @example 1 */ - UsedVPN?: number; - /** @example 1 */ - HasKeys?: number; - /** - * @example 1 - * @enum {integer} - */ - ToMigrate?: 0 | 1; - /** - * @example 1 - * @enum {integer} - */ - BrokenSKL?: 0 | 1; - /** - * @description Number of invitations remaining of the org. This value is decremented when an invitee accepts an invitation - * @example 5 - */ - InvitationsRemaining?: number; - /** - * @description Whether the org requires a key to operate. An org requires a key if it can have public managed members. - * @example 1 - * @enum {integer} - */ - RequiresKey?: 0 | 1; - /** - * @description Whether the org requires a custom domain to operate. - * @example 1 - * @enum {integer} - */ - RequiresDomain?: 0 | 1; - /** @example 6 */ - MaxAI?: number; - /** @example 3 */ - UsedAI?: number; - /** @example 6 */ - MaxLumo?: number; - /** @example 3 */ - UsedLumo?: number; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Total?: number; + Groups?: components["schemas"]["GroupResponse"][]; }; }; }; }; }; - 'put_core-{_version}-groups-external-{jwt}': { + "post_core-{_version}-groups": { parameters: { - query?: { - GroupID?: components['schemas']['Id'] | null; - }; + query?: never; header?: never; path: { _version: string; - jwt: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateGroupRequest"]; + }; + }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; + }; }; }; }; }; - 'delete_core-{_version}-groups-external-{jwt}': { + "post_core-{_version}-groups-unsubscribe-{jwt}": { parameters: { - query?: { - GroupID?: components['schemas']['Id'] | null; + query: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + /** @example ACXDmTaBub14w== */ + GroupID?: components["schemas"]["Id"] | null; }; header?: never; path: { @@ -12705,27 +14138,28 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-groups-members': { + "put_core-{_version}-groups-{enc_id}": { parameters: { query?: never; header?: never; path: { _version: string; + enc_id: components["schemas"]["EncryptedId"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['AddGroupMemberRequest']; + "application/json": components["schemas"]["UpdateGroupRequest"]; }; }; responses: { @@ -12735,20 +14169,21 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - GroupMember?: components['schemas']['GroupMemberResponse']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; }; }; }; }; }; - 'get_core-{_version}-groups': { + "delete_core-{_version}-groups-{enc_id}": { parameters: { query?: never; header?: never; path: { _version: string; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -12760,29 +14195,24 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Total?: number; - Groups?: components['schemas']['GroupResponse'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-groups': { + "delete_core-{_version}-groups-members-{enc_id}": { parameters: { query?: never; header?: never; path: { _version: string; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateGroupRequest']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -12790,77 +14220,73 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Group?: components['schemas']['GroupResponse']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-groups-unsubscribe-{jwt}': { + "put_core-{_version}-groups-members-{groupMemberId}": { parameters: { - query?: { - GroupID?: components['schemas']['Id'] | null; - }; + query?: never; header?: never; path: { _version: string; - jwt: string; + groupMemberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["EditGroupMemberRequest"]; + }; + }; responses: { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["GroupMemberResponse"]; }; }; }; }; - 'put_core-{_version}-groups-{enc_id}': { + "get_core-v4-groups-members-external-{jwt}": { parameters: { query?: never; header?: never; path: { - _version: string; - enc_id: components['schemas']['EncryptedId']; + jwt: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['UpdateGroupRequest']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Group?: components['schemas']['GroupResponse']; - }; + "application/json": components["schemas"]["ExternalGroupMembershipsResponse"]; }; }; }; }; - 'delete_core-{_version}-groups-{enc_id}': { + "get_core-v4-groups-{group_enc_id}-members": { parameters: { - query?: never; + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; header?: never; path: { - _version: string; - enc_id: components['schemas']['Id']; + group_enc_id: components["schemas"]["EncryptedId"]; }; cookie?: never; }; @@ -12869,23 +14295,21 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["GroupMembersResponse"]; }; }; }; }; - 'delete_core-{_version}-groups-members-{enc_id}': { + "get_core-{_version}-groups-owners-invites": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components['schemas']['Id']; }; cookie?: never; }; @@ -12894,53 +14318,43 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["GetGroupOwnerInvitesResponse"]; }; }; }; }; - 'put_core-{_version}-groups-members-{groupMemberId}': { + "post_core-{_version}-groups-owners-invites": { parameters: { query?: never; header?: never; path: { _version: string; - groupMemberId: components['schemas']['Id']; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['EditGroupMemberRequest']; + "application/json": components["schemas"]["InviteGroupOwnerRequest"]; }; }; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - GroupMember?: components['schemas']['GroupMemberResponse']; - }; - }; + content?: never; }; }; }; - 'get_core-v4-groups-members-external-{jwt}': { + "get_core-v4-groups-members-internal": { parameters: { query?: never; header?: never; - path: { - jwt: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -12948,24 +14362,22 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ExternalGroupMembershipsResponse']; + "application/json": components["schemas"]["InternalGroupMembershipsResponse"]; }; }; }; }; - 'get_core-v4-groups-{group_enc_id}-members': { + "get_core-{_version}-groups-{group_enc_id}": { parameters: { - query?: { - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; - }; + query?: never; header?: never; path: { - group_enc_id: components['schemas']['EncryptedId']; + _version: string; + group_enc_id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -12974,20 +14386,24 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GroupMembersResponse']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; + }; }; }; }; }; - 'get_core-v4-groups-members-internal': { + "get_core-v4-groups-members-{group_member_enc_id}": { parameters: { query?: never; header?: never; - path?: never; + path: { + group_member_enc_id: components["schemas"]["Id"]; + }; cookie?: never; }; requestBody?: never; @@ -12995,22 +14411,22 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['InternalGroupMembershipsResponse']; + "application/json": components["schemas"]["GroupMemberResponse"]; }; }; }; }; - 'put_core-{_version}-groups-{enc_id}-reinvite': { + "put_core-{_version}-groups-{enc_id}-reinvite": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components['schemas']['Id']; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -13022,20 +14438,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-groups-members-{groupMemberId}-resume': { + "put_core-{_version}-groups-members-{groupMemberId}-resume": { parameters: { query?: never; header?: never; path: { _version: string; - groupMemberId: components['schemas']['Id']; + groupMemberId: components["schemas"]["Id"]; }; cookie?: never; }; @@ -13044,18 +14460,16 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - GroupMember?: components['schemas']['GroupMemberResponse']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-invites': { + "post_core-{_version}-invites": { parameters: { query?: never; header?: never; @@ -13066,7 +14480,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example notification@email */ Email?: string; /** @@ -13081,16 +14495,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-invites-unused': { + "post_core-{_version}-invites-unused": { parameters: { query?: never; header?: never; @@ -13109,7 +14523,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-invites-check': { + "post_core-{_version}-invites-check": { parameters: { query?: never; header?: never; @@ -13128,7 +14542,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-keys-all': { + "get_core-{_version}-keys-all": { parameters: { query: { /** @@ -13140,7 +14554,7 @@ export interface operations { * @description If 1, it will not perform any external lookup, and only provide information from the Proton DB * @example 1 */ - InternalOnly?: '0' | '1'; + InternalOnly?: "0" | "1"; }; header?: never; path: { @@ -13156,7 +14570,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Success code * @example 1000 @@ -13188,7 +14602,7 @@ export interface operations { Source?: number; }[]; /** @description Signed metadata to verify the public key list */ - SignedKeyList?: components['schemas']['KTKeyList'] | null; + SignedKeyList?: components["schemas"]["KTKeyList"] | null; }; /** @description Information about the catch all address itself, if it exists. This can be null if the address keys are valid */ CatchAll?: { @@ -13216,7 +14630,7 @@ export interface operations { Source?: number; }[]; /** @description Signed metadata to verify the public key list */ - SignedKeyList?: components['schemas']['KTKeyList'] | null; + SignedKeyList?: components["schemas"]["KTKeyList"] | null; } | null; /** @description Any other key that cannot be verified, such as Proton legacy keys or WKD. This can be null if there are none. */ Unverified?: { @@ -13266,7 +14680,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Error code 33102 corresponds to a failed lookup. It is returned only when (a) internal only lookup is requested and the user does not exist or (b) when the address is routed towards an internal domain (with valid MX records) and it does not exist internally * @example 33102 @@ -13277,7 +14691,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-keys-signedkeylists': { + "get_core-{_version}-keys-signedkeylists": { parameters: { query?: { /** @deprecated */ @@ -13305,15 +14719,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SignedKeyLists?: components['schemas']['KTKeyList'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyLists?: components["schemas"]["KTKeyList"][]; }; }; }; }; }; - 'post_core-{_version}-keys-signedkeylists': { + "post_core-{_version}-keys-signedkeylists": { parameters: { query?: never; header?: never; @@ -13324,7 +14738,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AddressKeyInput3']; + "application/json": components["schemas"]["AddressKeyInput2"]; }; }; responses: { @@ -13334,15 +14748,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SignedKeyList?: components['schemas']['KTKeyList'] | null; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyList?: components["schemas"]["KTKeyList"] | null; }; }; }; }; }; - 'get_core-{_version}-keys-signedkeylist': { + "get_core-{_version}-keys-signedkeylist": { parameters: { query?: { /** @deprecated */ @@ -13365,15 +14779,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SignedKeyList?: components['schemas']['KTKeyList']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyList?: components["schemas"]["KTKeyList"]; }; }; }; }; }; - 'get_core-{_version}-keys-salts': { + "get_core-{_version}-keys-salts": { parameters: { query?: never; header?: never; @@ -13390,8 +14804,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; KeySalts?: { /** @example */ ID?: string; @@ -13403,7 +14817,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-keys-address-{enc_id}': { + "put_core-{_version}-keys-address-{enc_id}": { parameters: { query?: never; header?: never; @@ -13419,7 +14833,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AddressKeyInput']; + "application/json": components["schemas"]["AddressKeyInput2"]; }; }; responses: { @@ -13429,14 +14843,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-keys-address-{enc_id}-subkeys': { + "put_core-{_version}-keys-address-{enc_id}-subkeys": { parameters: { query?: never; header?: never; @@ -13448,35 +14862,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AddressKeyInput2']; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'put_core-{_version}-keys-signedkeylists-signature': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['AddressKeyInput4']; + "application/json": components["schemas"]["AddressKeyInput2"]; }; }; responses: { @@ -13486,30 +14872,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-keys-{enc_id}-primary': { + "put_core-{_version}-keys-signedkeylists-signature": { parameters: { query?: never; header?: never; path: { - /** - * @description the key id - * @example ACXDmTaBub14w== - */ - enc_id: string; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['SignedKeyListInputWrapper']; + "application/json": components["schemas"]["AddressKeyInput2"]; }; }; responses: { @@ -13519,14 +14900,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-keys-{enc_id}-flags': { + "put_core-{_version}-keys-{enc_id}-flags": { parameters: { query?: never; header?: never; @@ -13542,7 +14923,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateFlagsInput']; + "application/json": components["schemas"]["UpdateFlagsInput"]; }; }; responses: { @@ -13552,14 +14933,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-keys-tokens': { + "put_core-{_version}-keys-tokens": { parameters: { query?: never; header?: never; @@ -13570,7 +14951,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ReplaceAddressTokensInput']; + "application/json": components["schemas"]["ReplaceAddressTokensInput"]; }; }; responses: { @@ -13580,8 +14961,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -13594,7 +14975,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-keys-user-{enc_id}': { + "put_core-{_version}-keys-user-{enc_id}": { parameters: { query?: never; header?: never; @@ -13610,7 +14991,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ReactivateUserKeyInput']; + "application/json": components["schemas"]["ReactivateUserKeyInput"]; }; }; responses: { @@ -13620,14 +15001,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_core-{_version}-keys-user-{enc_id}': { + "delete_core-{_version}-keys-user-{enc_id}": { parameters: { query?: never; header?: never; @@ -13649,8 +15030,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -13663,7 +15044,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-keys-private-upgrade': { + "post_core-{_version}-keys-private-upgrade": { parameters: { query?: never; header?: never; @@ -13674,7 +15055,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ KeySalt?: string; Keys?: { @@ -13700,7 +15081,7 @@ export interface operations { Signature?: string; }[]; SignedKeyLists?: { - 'CasdiSFq_TW7i8FtJGuQyFEq0=='?: { + "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { /** @example JSON.stringify([{"SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353","Primary": 1,"Flags": 3}]) */ Data?: string; /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ @@ -13732,14 +15113,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-keys-migrate': { + "post_core-{_version}-keys-migrate": { parameters: { query?: never; header?: never; @@ -13750,7 +15131,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['MigrateKeyInput']; + "application/json": components["schemas"]["MigrateKeyInput"]; }; }; responses: { @@ -13760,14 +15141,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-keys-{enc_id}-activate': { + "put_core-{_version}-keys-{enc_id}-activate": { parameters: { query?: never; header?: never; @@ -13779,7 +15160,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LegacyKeyInput']; + "application/json": components["schemas"]["LegacyKeyInput"]; }; }; responses: { @@ -13789,14 +15170,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-keys-{enc_id}': { + "put_core-{_version}-keys-{enc_id}": { parameters: { query?: never; header?: never; @@ -13808,7 +15189,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LegacyKeyInput']; + "application/json": components["schemas"]["LegacyKeyInput"]; }; }; responses: { @@ -13818,14 +15199,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-keys-reset': { + "post_core-{_version}-keys-reset": { parameters: { query?: never; header?: never; @@ -13836,7 +15217,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ResetUserKeyInput']; + "application/json": components["schemas"]["ResetUserKeyInput"]; }; }; responses: { @@ -13846,14 +15227,62 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-members': { + "put_core-{_version}-organizations-link-{organization_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + organization_id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkOrganizationInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; + }; + }; + }; + "get_core-{_version}-members": { parameters: { query?: never; header?: never; @@ -13870,15 +15299,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Members?: components['schemas']['MemberInfo'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Members?: components["schemas"]["MemberInfo"][]; }; }; }; }; }; - 'post_core-{_version}-members': { + "post_core-{_version}-members": { parameters: { query?: never; header?: never; @@ -13889,7 +15318,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateMemberInput']; + "application/json": components["schemas"]["CreateMemberInput"]; }; }; responses: { @@ -13899,15 +15328,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Member?: components['schemas']['MemberInfo']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; }; }; }; }; }; - 'post_core-{_version}-members-invitations': { + "post_core-{_version}-members-invitations": { parameters: { query?: never; header?: never; @@ -13918,7 +15347,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateMemberInvitationInput']; + "application/json": components["schemas"]["CreateMemberInvitationInput"]; }; }; responses: { @@ -13928,15 +15357,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Member?: components['schemas']['MemberInfo']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; }; }; }; }; }; - 'put_core-{_version}-members-invitations-{enc_id}': { + "put_core-{_version}-members-invitations-{enc_id}": { parameters: { query?: never; header?: never; @@ -13953,7 +15382,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMemberInvitationInput']; + "application/json": components["schemas"]["UpdateMemberInvitationInput"]; }; }; responses: { @@ -13963,15 +15392,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Member?: components['schemas']['MemberInfo']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; }; }; }; }; }; - 'put_core-{_version}-members-{enc_id}-disable': { + "put_core-{_version}-members-{enc_id}-disable": { parameters: { query?: never; header?: never; @@ -13994,14 +15423,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-members-{enc_id}-enable': { + "put_core-{_version}-members-{enc_id}-enable": { parameters: { query?: never; header?: never; @@ -14024,14 +15453,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-members-{enc_id}-quota': { + "put_core-{_version}-members-{enc_id}-quota": { parameters: { query?: never; header?: never; @@ -14048,7 +15477,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example 9900000000 */ MaxSpace?: number; }; @@ -14061,14 +15490,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-members-{enc_id}-name': { + "put_core-{_version}-members-{enc_id}-name": { parameters: { query?: never; header?: never; @@ -14085,7 +15514,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example Jason */ Name?: string; }; @@ -14098,14 +15527,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-members-{enc_id}-role': { + "put_core-{_version}-members-{enc_id}-role": { parameters: { query?: never; header?: never; @@ -14122,7 +15551,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMemberRoleInput']; + "application/json": components["schemas"]["UpdateMemberRoleInput"]; }; }; responses: { @@ -14132,26 +15561,26 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-members-{memberId}-ai': { + "put_core-{_version}-members-{memberId}-ai": { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components['schemas']['Id']; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMemberAIEntitlementInput']; + "application/json": components["schemas"]["UpdateMemberAIEntitlementInput"]; }; }; responses: { @@ -14163,7 +15592,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-members-{enc_id}-privatize': { + "put_core-{_version}-members-{enc_id}-privatize": { parameters: { query?: never; header?: never; @@ -14186,14 +15615,53 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-search": { + parameters: { + query: { + /** + * @description Search to perform against organization members + * @example Tim + */ + q: string; + /** + * @description Maximum number of members to return + * @example 50 + */ + Limit?: number; + Q?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Members?: components["schemas"]["MemberInfo"][]; + /** @description Indicates if there are more results beyond the returned limit */ + More?: boolean; }; }; }; }; }; - 'get_core-{_version}-members-me': { + "get_core-{_version}-members-me": { parameters: { query?: never; header?: never; @@ -14210,15 +15678,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Member?: components['schemas']['MemberInfo']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; }; }; }; }; }; - 'get_core-{_version}-members-me-unprivatize': { + "get_core-{_version}-members-me-unprivatize": { parameters: { query?: never; header?: never; @@ -14235,14 +15703,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetMemberUnprivatizationOutput'] & { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": components["schemas"]["GetMemberUnprivatizationOutput"] & { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-members-me-unprivatize': { + "post_core-{_version}-members-me-unprivatize": { parameters: { query?: never; header?: never; @@ -14253,7 +15721,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AcceptMemberUnprivatizationInput']; + "application/json": components["schemas"]["AcceptMemberUnprivatizationInput"]; }; }; responses: { @@ -14265,7 +15733,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-members-me-unprivatize': { + "delete_core-{_version}-members-me-unprivatize": { parameters: { query?: never; header?: never; @@ -14284,13 +15752,13 @@ export interface operations { }; }; }; - 'post_core-{_version}-members-{id}-unprivatize-resend': { + "post_core-{_version}-members-{id}-unprivatize-resend": { parameters: { query?: never; header?: never; path: { _version: string; - id: components['schemas']['Id']; + id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -14304,19 +15772,19 @@ export interface operations { }; }; }; - 'post_core-{_version}-members-{id}-unprivatize': { + "post_core-{_version}-members-{id}-unprivatize": { parameters: { query?: never; header?: never; path: { _version: string; - id: components['schemas']['Id']; + id: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['RequestMemberUnprivatizationInput']; + "application/json": components["schemas"]["RequestMemberUnprivatizationInput"]; }; }; responses: { @@ -14328,13 +15796,13 @@ export interface operations { }; }; }; - 'delete_core-{_version}-members-{id}-unprivatize': { + "delete_core-{_version}-members-{id}-unprivatize": { parameters: { query?: never; header?: never; path: { _version: string; - id: components['schemas']['Id']; + id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -14348,7 +15816,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-members-{enc_id}': { + "get_core-{_version}-members-{enc_id}": { parameters: { query?: { /** @@ -14377,15 +15845,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Member?: components['schemas']['MemberInfo']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; }; }; }; }; }; - 'delete_core-{_version}-members-{enc_id}': { + "delete_core-{_version}-members-{enc_id}": { parameters: { query?: never; header?: never; @@ -14408,14 +15876,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-members-{enc_id}-details': { + "get_core-{_version}-members-{enc_id}-details": { parameters: { query?: never; header?: never; @@ -14438,7 +15906,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Last login time (unix timestamp) * @example 1654615966 @@ -14464,7 +15932,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-members-{enc_id}-authlog': { + "get_core-{_version}-members-{enc_id}-authlog": { parameters: { query?: { /** @@ -14498,15 +15966,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description List of authentication logs, ordered by "Time" (timestamp of the event) descending */ - Log?: components['schemas']['AuthLogResponse'][]; + Log?: components["schemas"]["AuthLogResponse"][]; }; }; }; }; }; - 'put_core-{_version}-members-{enc_id}-require2fa': { + "put_core-{_version}-members-{enc_id}-require2fa": { parameters: { query?: never; header?: never; @@ -14526,7 +15994,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-members-{enc_id}-require2fa': { + "delete_core-{_version}-members-{enc_id}-require2fa": { parameters: { query?: never; header?: never; @@ -14546,7 +16014,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-members-{enc_id}-permissions-forwarding': { + "post_core-{_version}-members-{enc_id}-permissions-forwarding": { parameters: { query?: never; header?: never; @@ -14569,14 +16037,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_core-{_version}-members-{enc_id}-permissions-forwarding': { + "delete_core-{_version}-members-{enc_id}-permissions-forwarding": { parameters: { query?: never; header?: never; @@ -14599,14 +16067,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-members-permissions': { + "put_core-{_version}-members-permissions": { parameters: { query?: never; header?: never; @@ -14617,7 +16085,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['MemberManagePermissionsDto']; + "application/json": components["schemas"]["MemberManagePermissionsDto"]; }; }; responses: { @@ -14627,14 +16095,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-members-{enc_id}-keys-setup': { + "post_core-{_version}-members-{enc_id}-keys-setup": { parameters: { query?: never; header?: never; @@ -14651,7 +16119,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMemberKeysInput']; + "application/json": components["schemas"]["UpdateMemberKeysInput"]; }; }; responses: { @@ -14661,8 +16129,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Member?: { /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ ID?: string; @@ -14708,9 +16176,19 @@ export interface operations { }; }; }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; }; }; - 'post_core-{_version}-members-{enc_id}-keys-migrate': { + "post_core-{_version}-members-{enc_id}-keys-migrate": { parameters: { query?: never; header?: never; @@ -14727,7 +16205,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { AddressKeys?: { /** @example adsft3Ep5P_EWz8WbasdkVAOl_6h== */ ID?: string; @@ -14742,7 +16220,7 @@ export interface operations { }[]; SignedKeyLists?: { /** @description AddressID */ - 'CasdiSFq_TW7i8FtJGuQyFEq0=='?: { + "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { /** @example JSON.stringify([{""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 1,""Flags"": 3}]) */ Data?: string; /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ @@ -14759,14 +16237,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-members-{enc_id}-keys-signedkeylists': { + "post_core-{_version}-members-{enc_id}-keys-signedkeylists": { parameters: { query?: never; header?: never; @@ -14783,10 +16261,10 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { SignedKeyLists?: { /** @description AddressID */ - 'CasdiSFq_TW7i8FtJGuQyFEq0=='?: { + "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { /** @example JSON.stringify([{""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 1,""Flags"": 3}]) */ Data?: string; /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ @@ -14803,14 +16281,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-members-{enc_id}-keys-unprivatize': { + "post_core-{_version}-members-{enc_id}-keys-unprivatize": { parameters: { query?: never; header?: never; @@ -14821,13 +16299,13 @@ export interface operations { */ memberid: string; _version: string; - enc_id: components['schemas']['Id']; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UnprivatizeMemberInput']; + "application/json": components["schemas"]["UnprivatizeMemberInput"]; }; }; responses: { @@ -14837,14 +16315,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-members-{enc_id}-auth': { + "post_core-{_version}-members-{enc_id}-auth": { parameters: { query?: never; header?: never; @@ -14856,7 +16334,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description If true, LOCKED and PASSWORD scopes will be assigned to the child session * @example false @@ -14905,8 +16383,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example f3804b2ad70c3992a9496ff07f3e9b93 */ UID?: string; /** @example 0 */ @@ -14926,7 +16404,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-members-{enc_id}-sessions': { + "delete_core-{_version}-members-{enc_id}-sessions": { parameters: { query?: never; header?: never; @@ -14944,126 +16422,253 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Sessions?: (components['schemas']['Session'] & { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-organizations-settings-logo": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["OrganizationLogo"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + }; + }; + "delete_core-{_version}-organizations-settings-logo": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + }; + }; + "post_core-{_version}-organizations-settings-highsecurity": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "delete_core-{_version}-organizations-settings-highsecurity": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-organizations": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example My Org */ + Name?: string; + /** @example My Org */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** + * @description Plan attribution to specific product, 1 = Mail, 2 = Drive, 4 = VPN + * @example 1 + */ + PlanFlags?: number; + /** @example 0 */ + TwoFactorRequired?: number; + /** + * @description If non-null, number of seconds until 2FA setup enforced + * @example null + */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 15 */ + MaxVPN?: number; + /** + * @description Bits, 1 = catch-all addresses + * @example 0 + */ + Features?: number; + /** + * @description Bits, 1 = loyalty + * @example 0 + */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 0 */ + UsedCalendars?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 1 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; /** - * @deprecated - * @example gony7nIW...KkhhFxA== + * @example 1 + * @enum {integer} */ - UserID?: string | null; + ToMigrate?: 0 | 1; /** - * @deprecated - * @example PlISx5...cSY-tfNw== + * @example 1 + * @enum {integer} */ - OwnerUserID?: string | null; + BrokenSKL?: 0 | 1; /** - * @description Localized name of ClientID used in the login process - * @example Proton Account for web + * @description Number of invitations remaining of the org. This value is decremented when an invitee accepts an invitation + * @example 5 */ - LocalizedClientName?: string; - })[]; - /** @example 0 */ - LocalID?: number; - /** - * @description Present only if inline re-authentication is submitted - * @example - */ - ServerProof?: string; + InvitationsRemaining?: number; + /** + * @description Whether the org requires a key to operate. An org requires a key if it can have public managed members. + * @example 1 + * @enum {integer} + */ + RequiresKey?: 0 | 1; + /** + * @description Whether the org requires a custom domain to operate. + * @example 1 + * @enum {integer} + */ + RequiresDomain?: 0 | 1; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + /** @example 1 */ + MaxMeet?: number; + /** @example 1 */ + UsedMeet?: number; + }; }; }; }; }; }; - 'post_core-{_version}-members-{enc_id}-sessions': { + "get_core-{_version}-organizations-logo-{logo_id}": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: string; + logo_id: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** - * @description If true, LOCKED and PASSWORD scopes will be assigned to the child session - * @example false - */ - Unlock?: boolean; - /** - * @description Optional, for inline re-authentication - * @example - */ - ClientEphemeral?: string; - /** - * @description Optional, for inline re-authentication - * @example - */ - ClientProof?: string; - /** - * @description Optional, for inline re-authentication - * @example - */ - SRPSession?: string; - /** - * @description Optional, for inline re-authentication, either this or the FIDO2 object - * @example 123456 or recovery code - */ - TwoFactorCode?: string; - /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ - FIDO2?: { - /** @description The same AuthenticationOptions received as a challenge from the server */ - AuthenticationOptions?: Record; - /** @description clientData (base64) returned from the client authentication library */ - ClientData?: string; - /** @description authenticatorData (base64) returned from the client authentication library */ - AuthenticatorData?: string; - /** @description signature (base64) returned from the client authentication library */ - Signature?: string; - /** @description CredentialID used */ - CredentialID?: Record[]; - }; - }; - }; - }; + requestBody?: never; responses: { - /** @description Success */ + /** @description Binary data of the image */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example f3804b2ad70c3992a9496ff07f3e9b93 */ - UID?: string; - /** @example 0 */ - LocalID?: number; - /** - * @description Present only if inline re-authentication is submitted - * @example - */ - ServerProof?: string; - /** - * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have - * @example 5 - */ - RefreshCounter?: number; - }; + "application/octet-stream": string; }; }; }; }; - 'delete_core-{_version}-members-{enc_id}-sessions': { + "post_core-{_version}-organizations-2fa-remind": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: string; }; cookie?: never; }; @@ -15075,40 +16680,33 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_core-{_version}-members-{enc_id}-sessions-{uid}': { + "put_core-{_version}-organizations-settings-logauth": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: string; - uid: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-organizations-keys': { + "get_core-{_version}-organizations-keys": { parameters: { query?: never; header?: never; @@ -15125,12 +16723,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['GetOrganizationKeysOutput']; + "application/json": components["schemas"]["GetOrganizationKeysOutput"]; }; }; }; }; - 'put_core-{_version}-organizations-keys': { + "put_core-{_version}-organizations-keys": { parameters: { query?: never; header?: never; @@ -15141,7 +16739,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ReplaceOrganizationKeysInput']; + "application/json": components["schemas"]["ReplaceOrganizationKeysInput"]; }; }; responses: { @@ -15151,8 +16749,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -15161,9 +16759,19 @@ export interface operations { }; }; }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; }; }; - 'post_core-{_version}-organizations-keys': { + "post_core-{_version}-organizations-keys": { parameters: { query?: never; header?: never; @@ -15174,7 +16782,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ReplaceOrganizationKeysInput']; + "application/json": components["schemas"]["ReplaceOrganizationKeysInput"]; }; }; responses: { @@ -15184,8 +16792,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -15194,9 +16802,19 @@ export interface operations { }; }; }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; }; }; - 'get_core-{_version}-organizations-keys-backup': { + "get_core-{_version}-organizations-keys-backup": { parameters: { query?: never; header?: never; @@ -15213,8 +16831,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ PrivateKey?: string; /** @example 0123456789abcdef */ @@ -15224,7 +16842,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-organizations-keys-backup': { + "put_core-{_version}-organizations-keys-backup": { parameters: { query?: never; header?: never; @@ -15235,7 +16853,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationKeyBackupInput']; + "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; }; }; responses: { @@ -15245,8 +16863,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -15257,7 +16875,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-organizations-keys-backup': { + "post_core-{_version}-organizations-keys-backup": { parameters: { query?: never; header?: never; @@ -15268,7 +16886,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationKeyBackupInput']; + "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; }; }; responses: { @@ -15278,8 +16896,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -15290,7 +16908,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-organizations-name': { + "put_core-{_version}-organizations-name": { parameters: { query?: never; header?: never; @@ -15301,7 +16919,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationNameInput']; + "application/json": components["schemas"]["UpdateOrganizationNameInput"]; }; }; responses: { @@ -15311,8 +16929,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Organization?: { /** @example E-Corp */ Name?: string; @@ -15366,13 +16984,27 @@ export interface operations { MaxLumo?: number; /** @example 3 */ UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: number; }; }; }; }; + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; + }; }; }; - 'put_core-{_version}-organizations-email': { + "put_core-{_version}-organizations-email": { parameters: { query?: never; header?: never; @@ -15383,7 +17015,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationEmailInput']; + "application/json": components["schemas"]["UpdateOrganizationEmailInput"]; }; }; responses: { @@ -15393,8 +17025,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Organization?: { /** @example E-Corp */ Name?: string; @@ -15448,13 +17080,17 @@ export interface operations { MaxLumo?: number; /** @example 3 */ UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: number; }; }; }; }; }; }; - 'put_core-{_version}-organizations-2fa': { + "put_core-{_version}-organizations-2fa": { parameters: { query?: never; header?: never; @@ -15465,7 +17101,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationTwoFactorGracePeriodInput']; + "application/json": components["schemas"]["UpdateOrganizationTwoFactorGracePeriodInput"]; }; }; responses: { @@ -15475,8 +17111,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Organization?: { /** @example E-Corp */ Name?: string; @@ -15530,13 +17166,17 @@ export interface operations { MaxLumo?: number; /** @example 3 */ UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: number; }; }; }; }; }; }; - 'put_core-{_version}-organizations-require2fa': { + "put_core-{_version}-organizations-require2fa": { parameters: { query?: never; header?: never; @@ -15547,7 +17187,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 1 = at least enforced for admin members, 2 = enforced for all members * @example 1 @@ -15563,8 +17203,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Organization?: { /** @example E-Corp */ Name?: string; @@ -15618,13 +17258,17 @@ export interface operations { MaxLumo?: number; /** @example 3 */ UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: number; }; }; }; }; }; }; - 'delete_core-{_version}-organizations-require2fa': { + "delete_core-{_version}-organizations-require2fa": { parameters: { query?: never; header?: never; @@ -15641,8 +17285,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Organization?: { /** @example E-Corp */ Name?: string; @@ -15696,13 +17340,17 @@ export interface operations { MaxLumo?: number; /** @example 3 */ UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: number; }; }; }; }; }; }; - 'put_core-{_version}-organizations-keys-activate': { + "put_core-{_version}-organizations-keys-activate": { parameters: { query?: never; header?: never; @@ -15713,33 +17361,9 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ActivateOrganizationKeyInput']; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'delete_core-{_version}-organizations-membership': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; + "application/json": components["schemas"]["ActivateOrganizationKeyInput"]; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Success */ 200: { @@ -15747,14 +17371,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-organizations-2fa-remind': { + "delete_core-{_version}-organizations-membership": { parameters: { query?: never; header?: never; @@ -15771,125 +17395,66 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'post_core-{_version}-organizations-keys-migrate': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['MigrateOrganizationKeysInput']; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - /** @description Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2011 */ - Code?: number; - /** @example Organization already migrated */ - Error?: string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; - }; - }; - 'get_core-{_version}-organizations-keys-signature': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ProtonSuccess'] & - components['schemas']['GetOrganizationIdentityOutput']; - }; - }; - }; - }; - 'put_core-{_version}-organizations-keys-signature': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['UpdateOrgKeyFingerprintSignatureInput']; - }; - }; - responses: { - default: { + /** @description Unprocessable Entity */ + 422: { headers: { + "x-pm-code": string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; }; }; }; - 'get_core-{_version}-organizations-logo-{logo_id}': { + "post_core-{_version}-organizations-keys-migrate": { parameters: { query?: never; header?: never; path: { _version: string; - logo_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateOrganizationKeysInput"]; + }; + }; responses: { - /** @description Binary data of the image */ + /** @description Success */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/octet-stream': string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Organization already migrated */ + Error?: string; + }; }; }; }; }; - 'get_core-{_version}-organizations-settings': { + "get_core-{_version}-organizations-keys-signature": { parameters: { query?: never; header?: never; @@ -15906,13 +17471,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonSuccess'] & - components['schemas']['OrganizationSettings2']; + "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["GetOrganizationIdentityOutput"]; }; }; }; }; - 'put_core-{_version}-organizations-settings': { + "put_core-{_version}-organizations-keys-signature": { parameters: { query?: never; header?: never; @@ -15923,22 +17487,19 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['OrganizationSettings']; + "application/json": components["schemas"]["UpdateOrgKeyFingerprintSignatureInput"]; }; }; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': components['schemas']['ProtonSuccess']; - }; + content?: never; }; }; }; - 'post_core-{_version}-organizations-settings-logo': { + "get_core-{_version}-organizations-settings": { parameters: { query?: never; header?: never; @@ -15947,11 +17508,7 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['OrganizationLogo']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -15959,12 +17516,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonSuccess']; + "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["OrganizationSettingsInputOutput"]; }; }; }; }; - 'delete_core-{_version}-organizations-settings-logo': { + "put_core-{_version}-organizations-settings": { parameters: { query?: never; header?: never; @@ -15973,7 +17530,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationSettingsRequest"]; + }; + }; responses: { /** @description Success */ 200: { @@ -15981,12 +17542,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonSuccess']; + "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["OrganizationSettingsInputOutput"]; }; }; }; }; - 'get_core-{_version}-captcha': { + "get_core-{_version}-captcha": { parameters: { query: { /** @example 1 */ @@ -15997,7 +17558,7 @@ export interface operations { Token: string; }; header?: { - 'x-pm-nonce'?: string | null; + "x-pm-nonce"?: string | null; host?: string; }; path: { @@ -16016,7 +17577,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-resources-captcha': { + "get_core-{_version}-resources-captcha": { parameters: { query: { /** @example 1 */ @@ -16027,7 +17588,7 @@ export interface operations { Token: string; }; header?: { - 'x-pm-nonce'?: string | null; + "x-pm-nonce"?: string | null; host?: string; }; path: { @@ -16046,14 +17607,14 @@ export interface operations { }; }; }; - 'get_core-{_version}-resources-zendesk': { + "get_core-{_version}-resources-zendesk": { parameters: { query?: { /** @example 83fabdab-1337-4fd7-85c0-39baf5c114fe */ Key?: string; }; header?: { - 'x-pm-nonce'?: string | null; + "x-pm-nonce"?: string | null; }; path: { _version: string; @@ -16071,7 +17632,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-saml-setup-fields': { + "post_core-{_version}-saml-setup-fields": { parameters: { query?: never; header?: never; @@ -16082,7 +17643,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['Sso']; + "application/json": components["schemas"]["Sso"]; }; }; responses: { @@ -16092,14 +17653,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-saml-setup-xml': { + "post_core-{_version}-saml-setup-xml": { parameters: { query?: never; header?: never; @@ -16110,7 +17671,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SsoXml']; + "application/json": components["schemas"]["SsoXml"]; }; }; responses: { @@ -16120,14 +17681,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-saml-setup-url': { + "post_core-{_version}-saml-setup-url": { parameters: { query?: never; header?: never; @@ -16138,7 +17699,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SsoUrl']; + "application/json": components["schemas"]["SsoUrl"]; }; }; responses: { @@ -16148,15 +17709,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SSO?: components['schemas']['SsoTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; }; }; }; }; }; - 'get_core-{_version}-saml-configs': { + "get_core-{_version}-saml-configs": { parameters: { query?: never; header?: never; @@ -16173,21 +17734,21 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SSO?: components['schemas']['SsoTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; }; }; }; }; }; - 'get_core-{_version}-saml-configs-{enc_id}': { + "get_core-{_version}-saml-configs-{enc_id}": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components['schemas']['Id']; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -16199,27 +17760,27 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SSO?: components['schemas']['SsoTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; }; }; }; }; }; - 'put_core-{_version}-saml-configs-{enc_id}-fields': { + "put_core-{_version}-saml-configs-{enc_id}-fields": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components['schemas']['Id']; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['Sso']; + "application/json": components["schemas"]["Sso"]; }; }; responses: { @@ -16229,21 +17790,21 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SSO?: components['schemas']['SsoTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; }; }; }; }; }; - 'put_core-{_version}-saml-configs-{enc_id}-delete': { + "put_core-{_version}-saml-configs-{enc_id}-delete": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: components['schemas']['Id']; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -16255,15 +17816,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SSO?: components['schemas']['SsoTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; }; }; }; }; }; - 'get_core-{_version}-saml-sp-info': { + "get_core-{_version}-saml-sp-info": { parameters: { query?: never; header?: never; @@ -16280,12 +17841,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['Info']; + "application/json": components["schemas"]["Info"]; }; }; }; }; - 'get_core-{_version}-saml-edugain-info': { + "get_core-{_version}-saml-edugain-info": { parameters: { query?: never; header?: never; @@ -16302,14 +17863,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-saml-edugain-info-{domainName}': { + "get_core-{_version}-saml-edugain-info-{domainName}": { parameters: { query?: never; header?: never; @@ -16327,14 +17888,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-saml-metadata': { + "get_core-{_version}-saml-metadata": { parameters: { query?: never; header?: never; @@ -16351,12 +17912,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'text/xml': string; + "text/xml": string; }; }; }; }; - 'get_core-{_version}-settings': { + "get_core-{_version}-settings": { parameters: { query?: never; header?: never; @@ -16373,15 +17934,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-password': { + "put_core-{_version}-settings-password": { parameters: { query?: never; header?: never; @@ -16392,7 +17953,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16446,9 +18007,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -16459,7 +18020,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-password-upgrade': { + "put_core-{_version}-settings-password-upgrade": { parameters: { query?: never; header?: never; @@ -16470,7 +18031,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { Auth?: { /** @example 4 */ Version?: number; @@ -16491,15 +18052,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-email': { + "put_core-{_version}-settings-email": { parameters: { query?: never; header?: never; @@ -16510,7 +18071,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16556,9 +18117,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -16569,7 +18130,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-email-verify': { + "post_core-{_version}-settings-email-verify": { parameters: { query?: never; header?: never; @@ -16580,7 +18141,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ Token?: string; }; @@ -16593,15 +18154,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-email-notify': { + "put_core-{_version}-settings-email-notify": { parameters: { query?: never; header?: never; @@ -16612,7 +18173,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @example 1 * @enum {integer} @@ -16628,15 +18189,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-email-reset': { + "put_core-{_version}-settings-email-reset": { parameters: { query?: never; header?: never; @@ -16647,7 +18208,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16696,9 +18257,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -16709,7 +18270,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-phone': { + "put_core-{_version}-settings-phone": { parameters: { query?: never; header?: never; @@ -16720,7 +18281,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16766,9 +18327,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -16779,7 +18340,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-phone-verify': { + "post_core-{_version}-settings-phone-verify": { parameters: { query?: never; header?: never; @@ -16790,7 +18351,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ Token?: string; }; @@ -16803,15 +18364,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-phone-notify': { + "put_core-{_version}-settings-phone-notify": { parameters: { query?: never; header?: never; @@ -16822,7 +18383,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @example 1 * @enum {integer} @@ -16838,15 +18399,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-phone-reset': { + "put_core-{_version}-settings-phone-reset": { parameters: { query?: never; header?: never; @@ -16857,7 +18418,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16906,9 +18467,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -16919,7 +18480,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-locale': { + "put_core-{_version}-settings-locale": { parameters: { query?: never; header?: never; @@ -16930,7 +18491,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example en_US */ Locale?: string; }; @@ -16943,15 +18504,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-logauth': { + "put_core-{_version}-settings-logauth": { parameters: { query?: never; header?: never; @@ -16962,7 +18523,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0 = off, 1 = on, 2 = on with IP logging * @example 0 @@ -16978,15 +18539,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-devicerecovery': { + "put_core-{_version}-settings-devicerecovery": { parameters: { query?: never; header?: never; @@ -16997,7 +18558,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description possible values:
- 0: disable
- 1: enable */ DeviceRecovery?: number; }; @@ -17010,15 +18571,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-news': { + "put_core-{_version}-settings-news": { parameters: { query?: never; header?: never; @@ -17029,7 +18590,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateNewsInput']; + "application/json": components["schemas"]["UpdateNewsInput"]; }; }; responses: { @@ -17039,15 +18600,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'patch_core-{_version}-settings-news': { + "patch_core-{_version}-settings-news": { parameters: { query?: never; header?: never; @@ -17058,7 +18619,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['PatchNewsInput']; + "application/json": components["schemas"]["PatchNewsInput"]; }; }; responses: { @@ -17068,15 +18629,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'get_core-{_version}-settings-news-external': { + "get_core-{_version}-settings-news-external": { parameters: { query?: never; header?: never; @@ -17093,8 +18654,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; UserSettings?: { /** * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. @@ -17107,7 +18668,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-news-external': { + "put_core-{_version}-settings-news-external": { parameters: { query?: never; header?: never; @@ -17118,7 +18679,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateNewsInput']; + "application/json": components["schemas"]["UpdateNewsInput"]; }; }; responses: { @@ -17128,8 +18689,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; UserSettings?: { /** * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. @@ -17142,7 +18703,7 @@ export interface operations { }; }; }; - 'patch_core-{_version}-settings-news-external': { + "patch_core-{_version}-settings-news-external": { parameters: { query?: never; header?: never; @@ -17153,7 +18714,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['PatchNewsInput']; + "application/json": components["schemas"]["PatchNewsInput"]; }; }; responses: { @@ -17163,8 +18724,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; UserSettings?: { /** * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. @@ -17177,7 +18738,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-density': { + "put_core-{_version}-settings-density": { parameters: { query?: never; header?: never; @@ -17188,7 +18749,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0:comfortable, 1:compact * @example 0 @@ -17204,15 +18765,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-invoicetext': { + "put_core-{_version}-settings-invoicetext": { parameters: { query?: never; header?: never; @@ -17223,7 +18784,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Maximum 5 lines * @example Mickey Mouse, Esq. @@ -17240,15 +18801,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'post_core-{_version}-settings-2fa-codes': { + "post_core-{_version}-settings-2fa-codes": { parameters: { query?: never; header?: never; @@ -17259,7 +18820,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17303,9 +18864,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -17322,7 +18883,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 10041 */ Code?: number; /** @example Two Factor confirmation failed */ @@ -17333,7 +18894,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-2fa-totp': { + "put_core-{_version}-settings-2fa-totp": { parameters: { query?: never; header?: never; @@ -17344,7 +18905,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17388,9 +18949,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -17401,7 +18962,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa-totp': { + "post_core-{_version}-settings-2fa-totp": { parameters: { query?: never; header?: never; @@ -17412,7 +18973,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17446,8 +19007,6 @@ export interface operations { /** @description CredentialID used */ CredentialID?: Record[]; }; - /** @example JBSWY3DPEHPK3PXP */ - TOTPSharedSecret?: string; /** @example 203941 */ TOTPConfirmation?: string; }; @@ -17460,9 +19019,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -17474,12 +19033,12 @@ export interface operations { }; }; /** @description Error */ - 400: { + 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 10041 */ Code?: number; /** @example Two Factor confirmation failed */ @@ -17490,7 +19049,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-2fa': { + "put_core-{_version}-settings-2fa": { parameters: { query?: never; header?: never; @@ -17501,7 +19060,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17545,9 +19104,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -17558,7 +19117,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa': { + "post_core-{_version}-settings-2fa": { parameters: { query?: never; header?: never; @@ -17569,7 +19128,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17603,8 +19162,6 @@ export interface operations { /** @description CredentialID used */ CredentialID?: Record[]; }; - /** @example JBSWY3DPEHPK3PXP */ - TOTPSharedSecret?: string; /** @example 203941 */ TOTPConfirmation?: string; }; @@ -17617,9 +19174,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -17631,12 +19188,12 @@ export interface operations { }; }; /** @description Error */ - 400: { + 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 10041 */ Code?: number; /** @example Two Factor confirmation failed */ @@ -17647,7 +19204,30 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa-reset': { + "get_core-{_version}-settings-2fa-totp-secret": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-reset": { parameters: { query?: never; header?: never; @@ -17658,7 +19238,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example user_name */ Username?: string; /** @@ -17676,8 +19256,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -17687,7 +19267,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 19502 */ Code?: number; /** @example Invalid reset token. Please request another token and try again */ @@ -17698,7 +19278,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-settings-2fa-register': { + "get_core-{_version}-settings-2fa-register": { parameters: { query?: { /** @@ -17721,9 +19301,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @description Contains the user's currently registered FIDO2 credentials. */ - RegisteredKeys?: components['schemas']['Fido2RegisteredKey'][]; + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; /** * @description Refer to the definition of PublicKeyCredentialCreationOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. * @example @@ -17736,7 +19316,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa-register': { + "post_core-{_version}-settings-2fa-register": { parameters: { query?: never; header?: never; @@ -17747,7 +19327,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Refer to the definition of PublicKeyCredentialCreationOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. * @example @@ -17816,9 +19396,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -17829,7 +19409,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa-{credentialID}-remove': { + "post_core-{_version}-settings-2fa-{credentialID}-remove": { parameters: { query?: never; header?: never; @@ -17841,7 +19421,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17885,9 +19465,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -17898,7 +19478,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-2fa-{credentialID}-rename': { + "put_core-{_version}-settings-2fa-{credentialID}-rename": { parameters: { query?: never; header?: never; @@ -17910,7 +19490,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description User defined name for the credential. * @example My FIDO2 key @@ -17926,15 +19506,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-hide-side-panel': { + "put_core-{_version}-settings-hide-side-panel": { parameters: { query?: never; header?: never; @@ -17945,7 +19525,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateHideSidePanelInput']; + "application/json": components["schemas"]["UpdateHideSidePanelInput"]; }; }; responses: { @@ -17955,15 +19535,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-username': { + "put_core-{_version}-settings-username": { parameters: { query?: never; header?: never; @@ -17974,7 +19554,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description Length <= 40 */ Username?: string; }; @@ -17987,14 +19567,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-settings-theme': { + "put_core-{_version}-settings-theme": { parameters: { query?: never; header?: never; @@ -18005,7 +19585,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['Theme']; + "application/json": components["schemas"]["Theme"]; }; }; responses: { @@ -18015,15 +19595,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-themetype': { + "put_core-{_version}-settings-themetype": { parameters: { query?: never; header?: never; @@ -18034,7 +19614,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example 1 */ ThemeType?: number; }; @@ -18047,15 +19627,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-weekstart': { + "put_core-{_version}-settings-weekstart": { parameters: { query?: never; header?: never; @@ -18066,7 +19646,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description `0`: Locale default, `1`: Monday, `6`: Saturday, `7`: Sunday * @example 1 @@ -18082,15 +19662,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-dateformat': { + "put_core-{_version}-settings-dateformat": { parameters: { query?: never; header?: never; @@ -18101,7 +19681,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0: Locale default, 1: DD_MM_YYYY, 2: MM_DD_YYYY, 3: YYYY_MM_DD * @example 1 @@ -18117,15 +19697,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-timeformat': { + "put_core-{_version}-settings-timeformat": { parameters: { query?: never; header?: never; @@ -18136,7 +19716,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0: Locale default, 1: 24H, 2: 12H * @example 1 @@ -18152,15 +19732,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-welcome': { + "put_core-{_version}-settings-welcome": { parameters: { query?: never; header?: never; @@ -18177,8 +19757,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -18188,7 +19768,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2000 */ Code?: number; /** @example Unknown client */ @@ -18199,7 +19779,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-earlyaccess': { + "put_core-{_version}-settings-earlyaccess": { parameters: { query?: never; header?: never; @@ -18210,7 +19790,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0: Disabled, 1: Enabled * @example 1 @@ -18226,8 +19806,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -18237,7 +19817,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2000 */ Code?: number; /** @example Invalid client */ @@ -18248,7 +19828,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-flags': { + "put_core-{_version}-settings-flags": { parameters: { query?: never; header?: never; @@ -18259,7 +19839,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0: Disabled, 1: Enabled * @example 1 @@ -18270,6 +19850,11 @@ export interface operations { * @example 0 */ SupportPgpV6Keys?: number; + /** + * @description 0: Disabled, 1: Enabled - disables easy-device-migration (as of now QR code sign-in) + * @example 0 + */ + EdmOptOut?: number; }; }; }; @@ -18280,14 +19865,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-settings-telemetry': { + "put_core-{_version}-settings-telemetry": { parameters: { query?: never; header?: never; @@ -18298,7 +19883,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description possible values:
- 0: disable
- 1: enable */ Telemetry?: number; }; @@ -18311,15 +19896,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-crashreports': { + "put_core-{_version}-settings-crashreports": { parameters: { query?: never; header?: never; @@ -18330,7 +19915,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description possible values:
- 0: disable
- 1: enable */ CrashReports?: number; }; @@ -18343,15 +19928,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'post_core-{_version}-settings-highsecurity': { + "post_core-{_version}-settings-highsecurity": { parameters: { query?: never; header?: never; @@ -18368,8 +19953,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -18379,7 +19964,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @default 2011 */ Code: number; /** @default You do not have an active subscription */ @@ -18389,7 +19974,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-settings-highsecurity': { + "delete_core-{_version}-settings-highsecurity": { parameters: { query?: never; header?: never; @@ -18406,14 +19991,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-settings-breachalerts': { + "post_core-{_version}-settings-breachalerts": { parameters: { query?: never; header?: never; @@ -18430,8 +20015,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -18441,7 +20026,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @default 2011 */ Code: number; /** @default You do not have an active subscription */ @@ -18451,7 +20036,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-settings-breachalerts': { + "delete_core-{_version}-settings-breachalerts": { parameters: { query?: never; header?: never; @@ -18468,14 +20053,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-settings-sessionaccountrecovery': { + "put_core-{_version}-settings-sessionaccountrecovery": { parameters: { query?: never; header?: never; @@ -18486,7 +20071,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SessionAccountRecoveryInput']; + "application/json": components["schemas"]["SessionAccountRecoveryInput"]; }; }; responses: { @@ -18496,15 +20081,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'put_core-{_version}-settings-ai-assistant-flags': { + "put_core-{_version}-settings-ai-assistant-flags": { parameters: { query?: never; header?: never; @@ -18515,7 +20100,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AIAssistantFlagsInput']; + "application/json": components["schemas"]["AIAssistantFlagsInput"]; }; }; responses: { @@ -18525,15 +20110,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - UserSettings?: components['schemas']['UserSettingsTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; }; }; }; }; }; - 'post_core-{_version}-settings-news-unsubscribe': { + "post_core-{_version}-settings-news-unsubscribe": { parameters: { query?: { News?: number; @@ -18550,16 +20135,16 @@ export interface operations { /** @description Success */ 200: { headers: { - 'x-pm-code': 1000; + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SuccessfulResponse']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'get_core-{_version}-support-schedulecall': { + "get_core-{_version}-support-schedulecall": { parameters: { query?: never; header?: never; @@ -18576,24 +20161,57 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ScheduleSupportCallOutput']; + "application/json": components["schemas"]["ScheduleSupportCallOutput"]; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}-primary": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example ACXDmTaBub14w== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateKeyPrimacyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; }; }; }; }; - 'put_core-{_version}-members-{memberId}-lumo': { + "put_core-{_version}-members-{memberId}-lumo": { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components['schemas']['Id']; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMemberLumoEntitlementInput']; + "application/json": components["schemas"]["UpdateMemberLumoEntitlementInput"]; }; }; responses: { @@ -18605,7 +20223,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-product-disabled': { + "put_core-{_version}-settings-product-disabled": { parameters: { query?: never; header?: never; @@ -18616,7 +20234,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ProductDisabledInput']; + "application/json": components["schemas"]["ProductDisabledInput"]; }; }; responses: { @@ -18628,7 +20246,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-delete': { + "get_core-{_version}-users-delete": { parameters: { query?: never; header?: never; @@ -18645,14 +20263,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-users-delete': { + "put_core-{_version}-users-delete": { parameters: { query?: never; header?: never; @@ -18663,7 +20281,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * Format: base64 * @description Optional, for inline re-authentication @@ -18726,8 +20344,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -18738,7 +20356,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-users-delete': { + "delete_core-{_version}-users-delete": { parameters: { query?: never; header?: never; @@ -18749,7 +20367,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * Format: base64 * @description Optional, for inline re-authentication @@ -18812,8 +20430,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -18824,7 +20442,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-reset': { + "get_core-{_version}-users-reset": { parameters: { query: { /** @@ -18847,8 +20465,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description internal or external * @example internal @@ -18856,15 +20474,114 @@ export interface operations { Type?: string; /** @description one or more values of: email, sms, login. `login` is used for external user with the same email as recovery address. */ Methods?: string[]; + /** + * @description Obfuscated recovery email address. Present when Methods contains `email`. + * @example a******@gmail.com + */ + Email?: string | null; + /** + * @description Obfuscated recovery phone number. Present when Methods contains `sms`. + * @example *****123 + */ + Phone?: string | null; }; }; }; }; }; - 'get_core-{_version}-users': { + "get_core-{_version}-reset-{username}-{token}": { parameters: { query?: never; header?: never; + path: { + /** @example bob */ + username: string; + /** + * @description 10-character reset token + * @example A194YN2F9R + */ + token: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @example 1 + * @enum {integer} + */ + ToMigrate?: 0 | 1; + /** @example l8vWAXHBQmv0u7OVtPbc...qMa4iwQaBqowINSQjPrxAr== */ + UserID?: string; + /** + * @example 1 + * @enum {integer} + */ + SupportPgpV6Keys?: 0 | 1; + /** @description NB: PrivateKey is null in keys */ + Addresses?: components["schemas"]["AddressUser"][]; + /** + * @description List of PasswordPolicies + * @example [] + */ + PasswordPolicies?: components["schemas"]["GetPasswordPolicyOutput"][]; + /** @description Active sessions of the user */ + Sessions?: { + /** @example 1710000000 */ + CreateTime?: number | null; + /** @example Proton Mail for web */ + LocalizedClientName?: string; + }[]; + /** @description Outgoing delegated accesses of the user */ + DelegatedAccesses?: components["schemas"]["OutgoingDelegatedAccessOutput"][]; + /** @description User keys with recovery secrets and fingerprints */ + UserKeys?: { + /** @example IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g== */ + ID?: string; + /** + * @example 1 + * @enum {integer} + */ + Primary?: 0 | 1; + /** + * @example 1 + * @enum {integer} + */ + Active?: 0 | 1; + /** @example c93f767df53b0ca8395cfde90483475164ec6353 */ + Fingerprint?: string; + /** @example 1H8EGg3J1...Qwk243hf */ + RecoverySecret?: string | null; + /** @example -----BEGIN PGP SIGNATURE-----... */ + RecoverySecretSignature?: string | null; + }[]; + "2FA"?: { + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Enabled?: number; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-users": { + parameters: { + query?: { + Locale?: boolean; + }; + header?: never; path: { _version: string; }; @@ -18878,15 +20595,24 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - User?: components['schemas']['User'] & { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { + Locale?: { + DateFormat?: number; + /** @example yyyy_MM_dd HH:mm z */ + DatetimeFormatIntl?: string; + HasRegisteredLocale?: boolean; + /** @example de_CH */ + Locale?: string; + TimeFormat?: number; + } | null; /** @example jason@protonmail.ch */ Email?: string; /** @example Jason */ DisplayName?: string; - Keys?: components['schemas']['UserKey'][]; - AccountRecovery?: components['schemas']['AccountRecoveryAttempt']; + Keys?: components["schemas"]["UserKey"][]; + AccountRecovery?: components["schemas"]["AccountRecoveryAttempt"]; }; VerifyMethods?: string[]; }; @@ -18894,7 +20620,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-users': { + "post_core-{_version}-users": { parameters: { query?: never; header?: never; @@ -18905,7 +20631,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example user_name */ Username?: string; /** @example proton.me */ @@ -18952,13 +20678,13 @@ export interface operations { /** @description optional field, frontend fingerprints */ Payload?: { /** @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ - 'random-id-1'?: string; + "random-id-1"?: string; /** @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ - 'random-id-2'?: string; + "random-id-2"?: string; /** @example */ - 'random-id-3'?: string; + "random-id-3"?: string; /** @example */ - 'random-id-4'?: string; + "random-id-4"?: string; }; /** * @deprecated @@ -18976,16 +20702,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - User?: components['schemas']['User'] & { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { /** @example 1 */ Services?: number; /** @example jason@protonmail.ch */ Email?: string; /** @example Jason */ DisplayName?: string; - Keys?: components['schemas']['UserKey'][]; + Keys?: components["schemas"]["UserKey"][]; /** * @description Token for external account creation. If it matches the created email it will be pre-verified * @example ASD3ldfa.asdfaoa3aw.asdfads @@ -18997,7 +20723,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-users-external': { + "post_core-{_version}-users-external": { parameters: { query?: never; header?: never; @@ -19008,7 +20734,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example user_name */ Username?: string; /** @example proton.me */ @@ -19055,13 +20781,13 @@ export interface operations { /** @description optional field, frontend fingerprints */ Payload?: { /** @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ - 'random-id-1'?: string; + "random-id-1"?: string; /** @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ - 'random-id-2'?: string; + "random-id-2"?: string; /** @example */ - 'random-id-3'?: string; + "random-id-3"?: string; /** @example */ - 'random-id-4'?: string; + "random-id-4"?: string; }; /** * @deprecated @@ -19079,16 +20805,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - User?: components['schemas']['User'] & { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { /** @example 1 */ Services?: number; /** @example jason@protonmail.ch */ Email?: string; /** @example Jason */ DisplayName?: string; - Keys?: components['schemas']['UserKey'][]; + Keys?: components["schemas"]["UserKey"][]; /** * @description Token for external account creation. If it matches the created email it will be pre-verified * @example ASD3ldfa.asdfaoa3aw.asdfads @@ -19100,7 +20826,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-users-check': { + "put_core-{_version}-users-check": { parameters: { query?: never; header?: never; @@ -19111,7 +20837,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description in case of an invite must be selector:token * @example @@ -19137,14 +20863,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-users-availableExternal': { + "get_core-{_version}-users-availableExternal": { parameters: { query?: { /** @@ -19158,7 +20884,7 @@ export interface operations { * @description Optional header containing a payment token value. When this value is set and the token is valid, the signup flow is started. * @example 1234567890abcdefghijklmn */ - 'X-PM-Payment-Info-Token'?: string; + "X-PM-Payment-Info-Token"?: string; }; path: { _version: string; @@ -19173,8 +20899,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -19184,7 +20910,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 12106 */ Code?: number; /** @example Username already used */ @@ -19197,7 +20923,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-available': { + "get_core-{_version}-users-available": { parameters: { query?: { /** @@ -19225,8 +20951,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -19236,7 +20962,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 12106 */ Code?: number; /** @example Username already used */ @@ -19249,7 +20975,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-available-{username}': { + "get_core-{_version}-users-available-{username}": { parameters: { query?: never; header?: never; @@ -19269,7 +20995,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-direct': { + "get_core-{_version}-users-direct": { parameters: { query?: { /** @@ -19292,8 +21018,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description 1 if enabled, 0 if disabled--client should show invite form * @example 1 @@ -19305,7 +21031,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-users-code': { + "post_core-{_version}-users-code": { parameters: { query?: never; header?: never; @@ -19316,13 +21042,13 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description email or sms * @example email * @enum {string} */ - Type?: 'email' | 'sms'; + Type?: "email" | "sms"; /** * @description Optional, can use android as well if link support * @example ios @@ -19350,14 +21076,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-users-lock': { + "put_core-{_version}-users-lock": { parameters: { query?: never; header?: never; @@ -19374,14 +21100,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-users-unlock': { + "put_core-{_version}-users-unlock": { parameters: { query?: never; header?: never; @@ -19392,7 +21118,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ ClientEphemeral?: string; /** @example */ @@ -19414,8 +21140,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -19423,7 +21149,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-users-password': { + "put_core-{_version}-users-password": { parameters: { query?: never; header?: never; @@ -19434,7 +21160,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ ClientEphemeral?: string; /** @example */ @@ -19477,8 +21203,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -19486,11 +21212,11 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-captcha-{token}': { + "get_core-{_version}-users-captcha-{token}": { parameters: { query?: never; header?: { - 'x-pm-nonce'?: string | null; + "x-pm-nonce"?: string | null; }; path: { _version: string; @@ -19508,7 +21234,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-disable-{jwt}': { + "get_core-{_version}-users-disable-{jwt}": { parameters: { query?: never; header?: never; @@ -19527,14 +21253,139 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-users-invitations-{id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserInvitationResponse"]; + }; + }; + /** @description Invitation not found */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: unknown; + }; + }; + }; + }; + }; + "get_core-{_version}-users-invitations": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + } & components["schemas"]["GetUserInvitationsOutput"]; + }; + }; + }; + }; + "post_core-{_version}-users-invitations-{enc_id}-reject": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-users-invitations-{enc_id}-accept": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Validation failed */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + Details?: { + Validation?: components["schemas"]["GetUserInvitationOutput"]; + }; }; }; }; }; }; - 'get_core-{_version}-members-{enc_id}-vpn': { + "get_core-{_version}-members-{enc_id}-vpn": { parameters: { query?: { /** @@ -19568,8 +21419,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ VPNName?: string; /** @example */ @@ -19579,14 +21430,14 @@ export interface operations { * @example 1654615966 */ LastVPNLogin?: number | null; - ActiveVPNSessions?: components['schemas']['VPNAuthenticationCertificateDetailedTransformer'][]; - AuthenticationCertificates?: components['schemas']['VPNAuthenticationCertificateDetailedTransformer'][]; + ActiveVPNSessions?: components["schemas"]["VPNAuthenticationCertificateDetailedTransformer"][]; + AuthenticationCertificates?: components["schemas"]["VPNAuthenticationCertificateDetailedTransformer"][]; }; }; }; }; }; - 'put_core-{_version}-members-{enc_id}-vpn': { + "put_core-{_version}-members-{enc_id}-vpn": { parameters: { query?: never; header?: never; @@ -19603,7 +21454,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example 2 */ MaxVPN?: number; }; @@ -19616,243 +21467,107 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'get_core-v4-features': { - parameters: { - query?: { - /** - * @description the page index using 0-based indexing, prefer using Offset which allow tostart from any precise position - * @example 0 - */ - Page?: string; - /** - * @deprecated - * @description the page size, maximum 150, prefer using Limit which is equivalent - * @example 50 - */ - PageSize?: string; - /** @description skip the given number of results */ - Offset?: string; - /** @description the number of features to return, defaults to page size (1 page), maximum 150 */ - Limit?: string; - /** @description the sorting criteria */ - Sort?: string; - /** - * @description 0 => ASC, 1 => DESC - * @example 1 - */ - Desc?: string; - /** @description return only features of the given type */ - Type?: string; - /** @description return only features newer or equal than BeginID */ - BeginID?: string; - /** @description return only features older than EndID */ - EndID?: string; - /** @description feature ID(s) to filter on */ - ID?: string; - /** @description feature code(s) to filter on */ - Code?: string; - /** @description feature code substring to search */ - SearchCode?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example 76 */ - Total?: number; - Features?: components['schemas']['FeatureTransformer'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-v4-features': { + "post_core-{_version}-nps-dismiss": { parameters: { query?: never; header?: never; - path?: never; + path: { + _version: string; + }; cookie?: never; }; requestBody?: { content: { - 'application/json': { - /** @example blackFriday */ - Code?: string; - /** - * @example string - * @enum {string} - */ - Type?: 'boolean' | 'integer' | 'float' | 'string' | 'enumeration' | 'mixed'; - /** @description List of the values if type is enumeration */ - Options?: string[]; - /** - * @description Required level to set a user-specific value for this feature such as TokenScope::ADMIN - * @example 131072 - */ - WriteLevelToken?: number; - /** - * @description Same value for all users - * @example false - */ - Global?: boolean; - /** - * @description Default value that can be used if 'Value' is not set - * @example start - */ - DefaultValue?: Record; - }; + "application/json": components["schemas"]["DismissInput"]; }; }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Feature?: components['schemas']['FeatureTransformer']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'put_core-v4-features-{id}': { + "post_core-{_version}-nps-submit": { parameters: { query?: never; header?: never; path: { - /** @example 123 */ - id: number; + _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': { - /** @example blackFriday */ - Code?: string; - /** - * @example string - * @enum {string} - */ - Type?: 'boolean' | 'integer' | 'float' | 'string' | 'enumeration' | 'mixed'; - /** @description List of the values if type is enumeration */ - Options?: string[]; - /** - * Format: float - * @description Minimum (included) value of length allowed - * @example 1 - */ - Minimum?: number; - /** - * Format: float - * @description Maximum (included) value of length allowed - * @example 100 - */ - Maximum?: number; - /** - * @description Required level to set a user-specific value for this feature such as TokenScope::ADMIN - * @example 131072 - */ - WriteLevelToken?: number; - /** - * @description Same value for all users - * @example false - */ - Global?: boolean; - /** - * @description Default value that can be used if 'Value' is not set - * @example start - */ - DefaultValue?: Record; - }; + "application/json": components["schemas"]["SubmissionInput"]; }; }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Feature?: components['schemas']['FeatureTransformer']; - }; - }; - }; - /** @description Feature not found */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2001 */ - Code?: number; - /** @example higher is not one of the possible options among [low, medium, high]. */ - Error?: string; - }; - }; - }; - /** @description Not allowed */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2026 */ - Code?: number; - /** @example You're not allowed to modify the value of this feature */ - Error?: string; - }; - }; - }; - /** @description Feature not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2501 */ - Code?: number; - /** @example Feature not found */ - Error?: string; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'delete_core-v4-features-{featureID}': { + "get_core-v4-features": { parameters: { - query?: never; - header?: never; - path: { - /** @example 123 */ - ID: string; - featureID: string; + query?: { + /** + * @description the page index using 0-based indexing, prefer using Offset which allow tostart from any precise position + * @example 0 + */ + Page?: string; + /** + * @deprecated + * @description the page size, maximum 150, prefer using Limit which is equivalent + * @example 50 + */ + PageSize?: string; + /** @description skip the given number of results */ + Offset?: string; + /** @description the number of features to return, defaults to page size (1 page), maximum 150 */ + Limit?: string; + /** @description the sorting criteria */ + Sort?: string; + /** + * @description 0 => ASC, 1 => DESC + * @example 1 + */ + Desc?: string; + /** @description return only features of the given type */ + Type?: string; + /** @description return only features newer or equal than BeginID */ + BeginID?: string; + /** @description return only features older than EndID */ + EndID?: string; + /** @description feature ID(s) to filter on */ + ID?: string; + /** @description feature code(s) to filter on */ + Code?: string; + /** @description feature code substring to search */ + SearchCode?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -19863,28 +21578,17 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - /** @description Feature not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2501 */ - Code?: number; - /** @example Feature not found */ - Error?: string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example 76 */ + Total?: number; + Features?: components["schemas"]["FeatureTransformer"][]; }; }; }; }; }; - 'get_core-v4-features-{code}': { + "get_core-v4-features-{code}": { parameters: { query?: never; header?: never; @@ -19901,9 +21605,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Feature?: components['schemas']['FeatureTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Feature?: components["schemas"]["FeatureTransformer"]; }; }; }; @@ -19913,7 +21617,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -19923,7 +21627,7 @@ export interface operations { }; }; }; - 'put_core-v4-features-{code}-value': { + "put_core-v4-features-{code}-value": { parameters: { query?: never; header?: never; @@ -19935,7 +21639,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example true */ Value?: Record; }; @@ -19947,77 +21651,24 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Feature?: components['schemas']['FeatureTransformer']; - }; - }; - }; - /** @description Bad request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2001 */ - Code?: number; - /** @example higher is not one of the possible options among [low, medium, high]. */ - Error?: string; - }; - }; - }; - /** @description Not allowed */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2026 */ - Code?: number; - /** @example You're not allowed to modify the value of this feature */ - Error?: string; - }; - }; - }; - /** @description Feature not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2501 */ - Code?: number; - /** @example Feature not found */ - Error?: string; - }; - }; - }; - }; - }; - 'delete_core-v4-features-{code}-value': { - parameters: { - query?: never; - header?: never; - path: { - code: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Feature?: components["schemas"]["FeatureTransformer"]; + }; + }; + }; + /** @description Bad request */ + 400: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Feature?: components['schemas']['FeatureTransformer']; + "application/json": { + /** @example 2001 */ + Code?: number; + /** @example higher is not one of the possible options among [low, medium, high]. */ + Error?: string; }; }; }; @@ -20027,7 +21678,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2026 */ Code?: number; /** @example You're not allowed to modify the value of this feature */ @@ -20041,7 +21692,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -20051,26 +21702,16 @@ export interface operations { }; }; }; - 'put_core-v4-features-{code}-user-value': { + "delete_core-v4-features-{code}-value": { parameters: { query?: never; header?: never; path: { - /** @example blackFriday */ code: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** @example true */ - Value?: Record; - UserIDs?: number[]; - UserNames?: string[]; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20078,27 +21719,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @description Number of touched users - * @example 2 - */ - Count?: number; - }; - }; - }; - /** @description Bad request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2001 */ - Code?: number; - /** @example higher is not one of the possible options among [low, medium, high]. */ - Error?: string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Feature?: components["schemas"]["FeatureTransformer"]; }; }; }; @@ -20108,7 +21731,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2026 */ Code?: number; /** @example You're not allowed to modify the value of this feature */ @@ -20117,12 +21740,12 @@ export interface operations { }; }; /** @description Feature not found */ - 422: { + 404: { headers: { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -20132,7 +21755,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-auth-info': { + "post_core-{_version}-auth-info": { parameters: { query?: never; header?: never; @@ -20143,7 +21766,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -20156,7 +21779,7 @@ export interface operations { * @example auto * @enum {string} */ - Intent?: 'Proton' | 'SSO' | 'Auto'; + Intent?: "Proton" | "SSO" | "Auto"; /** * @description optional field, to start a testing sso login flow * @example true @@ -20177,40 +21800,40 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** - * @description If Intent SSO or Auto, token to pass to GET /auth/sso/{token} for initiating the SSO flow - * @example a5fd396fcbb - */ - SSOChallengeToken?: string; - } - | { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example -----BEGIN PGP SIGNED MESSAGE-----*-----END SIGNATURE----- */ - Modulus?: string; - /** @example */ - ServerEphemeral?: string; - /** @example 4 */ - Version?: number; - /** @example */ - Salt?: string; - /** @example */ - SRPSession?: string; - /** @description Only if already authenticated (not on login) */ - '2FA'?: { - /** - * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both - * @example 3 - */ - Enabled?: number; - FIDO2?: { - /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ - AuthenticationOptions?: Record; - RegisteredKeys?: components['schemas']['Fido2RegisteredKey'][]; - }; - }; - }; + "application/json": { + /** + * @description If Intent SSO or Auto, token to pass to GET /auth/sso/{token} for initiating the SSO flow + * @example a5fd396fcbb + */ + SSOChallengeToken?: string; + } | { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example -----BEGIN PGP SIGNED MESSAGE-----*-----END SIGNATURE----- */ + Modulus?: string; + /** @example */ + ServerEphemeral?: string; + /** @example 4 */ + Version?: number; + /** @example */ + Salt?: string; + /** @example */ + SRPSession?: string; + /** @example user_name */ + Username?: string; + /** @description Only if already authenticated (not on login) */ + "2FA"?: { + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Enabled?: number; + FIDO2?: { + /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ + AuthenticationOptions?: Record; + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + }; + }; + }; }; }; /** @description Bad Request */ @@ -20219,7 +21842,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Session is not tied to a user and Username is null * @enum {integer} @@ -20238,210 +21861,65 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** - * @description User doesn't have SSO. Returned if Intent=SSO or Intent=Auto on backend's whim - * @enum {integer} - */ - Code?: 8101; - /** @example Email domain not found, please sign in with a password */ - Error?: string; - /** @description Empty */ - Details?: Record; - } - | { - /** - * @description User has SSO. Returned if Intent=Proton or Intent=Auto on backend's whim - * @enum {integer} - */ - Code?: 8100; - /** @example Email domain associated to an existing organization. Please sign in with SSO */ - Error?: string; - /** @description Empty */ - Details?: Record; - } - | { - /** - * @description Upgrade the app to call the endpoint this way - * @enum {integer} - */ - Code?: 5003; - /** @example You need to update this app in order to perform this operation */ - Error?: string; - /** @description Empty */ - Details?: Record; - }; - }; - }; - }; - }; - 'get_core-{_version}-auth-sso-{token}': { - parameters: { - query?: { - FinalRedirectBaseUrl?: string | null; - }; - header?: never; - path: { - /** - * @description Token received as SSOChallengeToken from POST /auth/info - * @example a5fd396fcbb - */ - token: string; - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - 'post_core-{_version}-auth-saml': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['IdpResponseVO']; - }; - }; - responses: { - default: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - 'post_core-{_version}-auth': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['AuthInput']; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @description Session unique ID - * @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 - */ - UID?: string; - /** @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ - UserID?: string; - /** @example ACXDmTaBub14w== */ - EventID?: string; - /** @example */ - ServerProof?: string; - /** - * @description only if the session is not in cookie mode - * @example Bearer - */ - TokenType?: string; - /** - * @description only if the session is not in cookie mode - * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 - */ - AccessToken?: string; - /** - * @description only if the session is not in cookie mode - * @example wfih0367aa7dc0359bf5c42d15a93e6c - */ - RefreshToken?: string; + "application/json": { /** - * @deprecated - * @description only if the session is not in cookie mode - * @example 360000 + * @description User doesn't have SSO. Returned if Intent=SSO or Intent=Auto on backend's whim + * @enum {integer} */ - ExpiresIn?: number; - /** @example 0 */ - LocalID?: number; - Scopes?: string[]; + Code?: 8101; + /** @example Email domain not found, please sign in with a password */ + Error?: string; + /** @description Empty */ + Details?: Record; + } | { /** - * @deprecated - * @example full other_scopes + * @description User has SSO. Returned if Intent=Proton or Intent=Auto on backend's whim + * @enum {integer} */ - Scope?: string; - /** @example 2 */ - PasswordMode?: number; + Code?: 8100; + /** @example Email domain associated to an existing organization. Please sign in with SSO */ + Error?: string; + /** @description Empty */ + Details?: Record; + } | { /** - * @description If 1 the user should be prompted to enter a new password on login - * @example 0 + * @description Upgrade the app to call the endpoint this way + * @enum {integer} */ - TemporaryPassword?: number; - '2FA'?: { - /** - * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both - * @example 3 - */ - Enabled?: number; - FIDO2?: { - /** @description Refer to the definition of PublicKeyCredentialRequestOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. */ - AuthenticationOptions?: Record; - RegisteredKeys?: components['schemas']['Fido2RegisteredKey'][]; - }; - }; + Code?: 5003; + /** @example You need to update this app in order to perform this operation */ + Error?: string; + /** @description Empty */ + Details?: Record; }; }; }; }; }; - 'delete_core-{_version}-auth': { + "post_core-{_version}-auth-saml": { parameters: { - query?: { - /** @description if 1 log out this child only */ - Child?: Record; - /** @description if 1 this will also delete the associated Auth Device */ - AuthDevice?: Record; - }; + query?: never; header?: never; path: { _version: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["IdpResponseVO"]; + }; + }; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; + content?: never; }; }; }; - 'post_core-{_version}-auth-jwt': { + "post_core-{_version}-auth-jwt": { parameters: { query?: never; header?: never; @@ -20452,7 +21930,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example eyJhbGciOiJIUzI1Ni...yJV_adQssw5c */ Token?: string; /** @@ -20470,8 +21948,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example 3af9b9780014cacb4b72bc5c73c1d7c4bad6c1e3 */ AccessToken?: string; /** @@ -20496,7 +21974,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-auth-2fa': { + "post_core-{_version}-auth-2fa": { parameters: { query?: never; header?: never; @@ -20507,7 +21985,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description either this or the FIDO2 object * @example 123456 or recovery code @@ -20535,8 +22013,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @deprecated * @example full @@ -20548,35 +22026,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-auth-modulus': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example -----BEGIN PGP SIGNED MESSAGE-----.*-----END PGP SIGNATURE----- */ - Modulus?: string; - /** @example Oq_JB_IkrOx5WlpxzlRPocN3_NhJ80V7DGav77eRtSDkOtLxW2jfI3nUpEqANGpboOyN-GuzEFXadlpxgVp7_g== */ - ModulusID?: string; - }; - }; - }; - }; - }; - 'get_core-{_version}-auth-scopes': { + "get_core-{_version}-auth-modulus": { parameters: { query?: never; header?: never; @@ -20592,47 +22042,28 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @deprecated - * @example 217017207043915776 - */ - Scope?: string; - Scopes?: string[]; - }; - }; - }; - }; - }; - 'post_core-{_version}-auth-refresh': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': { - /** @example token */ - ResponseType?: string; - /** @example refresh_token */ - GrantType?: string; - /** @example eaad5a7059835aac32c0bf99c2e208a59b8c1a55 */ - RefreshToken?: string; - /** - * @deprecated - * @description This parameter is deprecated and should be passed via 'x-pm-uid' header instead - * @example m3mxv75of7tuy4na4c3fzkskaqnu35xj - */ - UID?: string; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example -----BEGIN PGP SIGNED MESSAGE-----.*-----END PGP SIGNATURE----- */ + Modulus?: string; + /** @example Oq_JB_IkrOx5WlpxzlRPocN3_NhJ80V7DGav77eRtSDkOtLxW2jfI3nUpEqANGpboOyN-GuzEFXadlpxgVp7_g== */ + ModulusID?: string; + }; }; }; }; + }; + "get_core-{_version}-auth-scopes": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20640,40 +22071,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example abcDecryptedTokenAndNoSaltAndNoPrivateKey123 */ - AccessToken?: string; - /** - * @deprecated - * @example 360000 - */ - ExpiresIn?: number; - /** @example Bearer */ - TokenType?: string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @deprecated - * @example full other_scopes + * @example 217017207043915776 */ Scope?: string; Scopes?: string[]; - /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ - UID?: string; - /** @example b894b4c4f20003f12d486900d8b88c7d68e67235 */ - RefreshToken?: string; - /** @example 0 */ - LocalID?: number; - /** - * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have - * @example 5 - */ - RefreshCounter?: number; }; }; }; }; }; - 'post_core-{_version}-auth-cookies': { + "post_core-{_version}-auth-cookies": { parameters: { query?: never; header?: never; @@ -20684,7 +22095,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example token */ ResponseType?: string; /** @example refresh_token */ @@ -20706,8 +22117,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ UID?: string; /** @example 0 */ @@ -20722,7 +22133,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-auth-credentialless': { + "post_core-{_version}-auth-credentialless": { parameters: { query?: never; header?: never; @@ -20731,11 +22142,7 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateCredentiallessUserInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20743,12 +22150,32 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateCredentiallessUserOutput']; + "application/json": components["schemas"]["CreateCredentiallessUserOutput"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; }; }; }; }; - 'get_core-{_version}-settings-mnemonic': { + "get_core-{_version}-settings-mnemonic": { parameters: { query?: never; header?: never; @@ -20765,8 +22192,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; MnemonicUserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ ID?: string; @@ -20780,7 +22207,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-mnemonic': { + "put_core-{_version}-settings-mnemonic": { parameters: { query?: never; header?: never; @@ -20791,7 +22218,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { MnemonicUserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ ID?: string; @@ -20801,7 +22228,7 @@ export interface operations { /** @example 1H8EGg3J1Qwk243hf== */ MnemonicSalt?: string; /** @description The new mnemonic SRP verifier */ - MnemonicAuth?: components['schemas']['AuthInfoInput']; + MnemonicAuth?: components["schemas"]["AuthInfoInput"]; /** * @description Optional, for inline re-authentication * @example @@ -20845,8 +22272,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -20857,7 +22284,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-settings-mnemonic-reset': { + "get_core-{_version}-settings-mnemonic-reset": { parameters: { query?: never; header?: never; @@ -20874,8 +22301,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; MnemonicUserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ ID?: string; @@ -20889,7 +22316,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-mnemonic-reset': { + "post_core-{_version}-settings-mnemonic-reset": { parameters: { query?: never; header?: never; @@ -20900,7 +22327,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description The user keys encrypted with the account password */ UserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ @@ -20931,15 +22358,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Scopes?: string[]; }; }; }; }; }; - 'post_core-{_version}-settings-mnemonic-disable': { + "post_core-{_version}-settings-mnemonic-disable": { parameters: { query?: never; header?: never; @@ -20950,7 +22377,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -20994,8 +22421,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Present only if inline re-authentication is submitted * @example @@ -21006,7 +22433,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-mnemonic-reactivate': { + "put_core-{_version}-settings-mnemonic-reactivate": { parameters: { query?: never; header?: never; @@ -21017,7 +22444,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { MnemonicUserKeys?: { /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ ID?: string; @@ -21027,7 +22454,7 @@ export interface operations { /** @example 1H8EGg3J1Qwk243hf== */ MnemonicSalt?: string; /** @description The new mnemonic SRP verifier */ - MnemonicAuth?: components['schemas']['AuthInfoInput']; + MnemonicAuth?: components["schemas"]["AuthInfoInput"]; }; }; }; @@ -21038,14 +22465,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-pushes': { + "get_core-{_version}-pushes": { parameters: { query?: { /** @@ -21073,15 +22500,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Pushes?: components['schemas']['PushTransformer'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; }; }; }; }; }; - 'get_core-{_version}-pushes-active': { + "get_core-{_version}-pushes-active": { parameters: { query?: { /** @@ -21109,15 +22536,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Pushes?: components['schemas']['PushTransformer'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; }; }; }; }; }; - 'get_core-{_version}-pushes-active-session': { + "get_core-{_version}-pushes-active-session": { parameters: { query?: { /** @@ -21145,15 +22572,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Pushes?: components['schemas']['PushTransformer'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; }; }; }; }; }; - 'delete_core-{_version}-pushes-{enc_id}': { + "delete_core-{_version}-pushes-{enc_id}": { parameters: { query?: never; header?: never; @@ -21171,133 +22598,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Pushes?: string[]; }; }; }; }; }; - 'get_core-{_version}-referrals': { - parameters: { - query?: { - /** @description Skip the given number of results */ - Offset?: number; - /** @description The number of results to return, maximum 100 */ - Limit?: number; - }; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Referrals?: components['schemas']['ReferralOutput'][]; - Total?: number; - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'post_core-{_version}-referrals': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['SendInvitationsInput']; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Referrals?: components['schemas']['ReferralOutput'][]; - }; - }; - }; - }; - }; - 'get_core-{_version}-referrals-status': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Referrals?: components['schemas']['ReferralStatus'][]; - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'get_core-{_version}-referrals-identifiers-{identifier}': { - parameters: { - query?: never; - header?: never; - path: { - /** @example KPlISx5MiML3XcSYPrREF */ - identifier: string; - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The identifier exists */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description The identifier does not exist */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - }; - }; - 'post_core-{_version}-devices': { + "post_core-{_version}-devices": { parameters: { query?: never; header?: never; @@ -21308,7 +22617,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RegisterDeviceInput']; + "application/json": components["schemas"]["RegisterDeviceInput"]; }; }; responses: { @@ -21318,14 +22627,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_core-{_version}-devices': { + "delete_core-{_version}-devices": { parameters: { query?: never; header?: never; @@ -21336,7 +22645,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example 4b3403665fea6... */ DeviceToken?: string; /** @@ -21354,14 +22663,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-betas-{client_id}': { + "get_core-{_version}-betas-{client_id}": { parameters: { query?: never; header?: never; @@ -21383,8 +22692,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Beta?: { /** @example iOSVPN */ ClientID?: string; @@ -21400,7 +22709,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-betas-{client_id}': { + "put_core-{_version}-betas-{client_id}": { parameters: { query?: never; header?: never; @@ -21416,7 +22725,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example john@example.com */ Email?: string; }; @@ -21429,8 +22738,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Beta?: { /** @example iOSVPN */ ClientID?: string; @@ -21446,7 +22755,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-betas-{client_id}': { + "delete_core-{_version}-betas-{client_id}": { parameters: { query?: never; header?: never; @@ -21468,14 +22777,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-betas': { + "get_core-{_version}-betas": { parameters: { query?: never; header?: never; @@ -21492,8 +22801,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Betas?: { /** @example iOSVPN */ ClientID?: string; @@ -21509,7 +22818,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-betas': { + "delete_core-{_version}-betas": { parameters: { query?: never; header?: never; @@ -21526,52 +22835,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-geofeed-geofeed-csv': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - 'get_core-{_version}-geofeed-geofeed-public-csv': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - 'get_core-{_version}-load': { + "get_core-{_version}-load": { parameters: { query?: never; header?: never; @@ -21588,14 +22859,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-load': { + "post_core-{_version}-load": { parameters: { query?: never; header?: never; @@ -21612,14 +22883,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-logs-auth': { + "get_core-{_version}-logs-auth": { parameters: { query?: { /** @@ -21651,9 +22922,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Logs?: components['schemas']['AuthLogResponse'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Logs?: components["schemas"]["AuthLogResponse"][]; /** @example 1 */ Total?: number; }; @@ -21661,7 +22932,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-logs-auth': { + "delete_core-{_version}-logs-auth": { parameters: { query?: never; header?: never; @@ -21678,14 +22949,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-metrics': { + "get_core-{_version}-metrics": { parameters: { query?: { /** @example signup */ @@ -21709,14 +22980,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-metrics': { + "post_core-{_version}-metrics": { parameters: { query?: never; header?: never; @@ -21727,12 +22998,12 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @example encrypted_search * @enum {string} */ - Log?: 'signup' | 'encrypted_search' | 'dark_styles'; + Log?: "signup" | "encrypted_search" | "dark_styles"; /** * @description Optional title * @example index @@ -21752,14 +23023,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-settings-recovery-secret': { + "post_core-{_version}-settings-recovery-secret": { parameters: { query?: never; header?: never; @@ -21770,7 +23041,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Base64-encoded secret, decodes to 32 bytes * @example 1H8EGg3J1...Qwk243hf @@ -21788,14 +23059,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_core-{_version}-settings-recovery-secret': { + "delete_core-{_version}-settings-recovery-secret": { parameters: { query?: never; header?: never; @@ -21812,14 +23083,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reports-form-{portal_id}-{form_id}': { + "post_core-{_version}-reports-form-{portal_id}-{form_id}": { parameters: { query?: never; header?: never; @@ -21832,7 +23103,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { fields?: Record; context?: Record; legalConsentOptions?: Record; @@ -21846,14 +23117,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reports-bug': { + "post_core-{_version}-reports-bug": { parameters: { query?: never; header?: never; @@ -21864,7 +23135,7 @@ export interface operations { }; requestBody?: { content: { - 'multipart/form-data': { + "multipart/form-data": { /** * @description Client should supply if mobile app, ask user if web app * @example iOS @@ -21983,14 +23254,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reports-bug-attachments': { + "post_core-{_version}-reports-bug-attachments": { parameters: { query?: never; header?: never; @@ -22001,7 +23272,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UploadAttachment']; + "application/json": components["schemas"]["UploadAttachment"]; }; }; responses: { @@ -22011,14 +23282,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_core-{_version}-reports-bug-{ticketId}': { + "delete_core-{_version}-reports-bug-{ticketId}": { parameters: { query?: { RequesterID?: number; @@ -22028,7 +23299,7 @@ export interface operations { header?: never; path: { _version: string; - ticketId: Record; + ticketId: number; }; cookie?: never; }; @@ -22040,8 +23311,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -22051,7 +23322,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2501 */ Code?: number; /** @example Ticket does not exist */ @@ -22061,7 +23332,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-reports-abuse': { + "post_core-{_version}-reports-abuse": { parameters: { query?: never; header?: never; @@ -22072,7 +23343,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example harassment */ Category?: string; /** @example This person has been harassing me. */ @@ -22097,14 +23368,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reports-crash': { + "post_core-{_version}-reports-crash": { parameters: { query?: never; header?: never; @@ -22115,7 +23386,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional * @example iOS @@ -22154,7 +23425,7 @@ export interface operations { /** @description Client should supply */ Debug?: { /** @example you want */ - 'Whatever JSON'?: string; + "Whatever JSON"?: string; }; }; }; @@ -22166,14 +23437,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reports-sentry-api-{id}-{type}': { + "post_core-{_version}-reports-sentry-api-{id}-{type}": { parameters: { query?: never; header?: never; @@ -22194,7 +23465,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-reports-phishing': { + "post_core-{_version}-reports-phishing": { parameters: { query?: never; header?: never; @@ -22205,7 +23476,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== */ MessageID?: string; /** @@ -22225,33 +23496,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reports-spam': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - 'post_core-{_version}-reports-cancel-plan': { + "post_core-{_version}-reports-cancel-plan": { parameters: { query?: never; header?: never; @@ -22262,67 +23514,24 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CancelPlanReport']; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'get_core-{_version}-reset-{username}-{token}': { - parameters: { - query?: never; - header?: never; - path: { - /** @example bob */ - username: string; - /** - * @description 10-character reset token - * @example A194YN2F9R - */ - token: string; - _version: string; + "application/json": components["schemas"]["CancelPlanReport"]; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Success */ 200: { headers: { [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @example 1 - * @enum {integer} - */ - ToMigrate?: 0 | 1; - /** - * @example 1 - * @enum {integer} - */ - SupportPgpV6Keys?: 0 | 1; - /** @description NB: PrivateKey is null in keys */ - Addresses?: components['schemas']['AddressUser'][]; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reset': { + "post_core-{_version}-reset": { parameters: { query?: never; header?: never; @@ -22333,7 +23542,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example derp */ Username?: string; /** @@ -22356,8 +23565,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -22367,7 +23576,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 19305 */ Code?: number; /** @example Username and recovery email mismatch */ @@ -22378,7 +23587,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-reset-username': { + "post_core-{_version}-reset-username": { parameters: { query?: never; header?: never; @@ -22389,7 +23598,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description if Phone is not present * @example derp@gmail.com @@ -22410,14 +23619,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-system-config': { + "get_core-{_version}-system-config": { parameters: { query?: never; header?: never; @@ -22436,7 +23645,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-system-version': { + "get_core-{_version}-system-version": { parameters: { query?: never; header?: never; @@ -22455,7 +23664,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-tests-exception': { + "get_core-{_version}-tests-exception": { parameters: { query?: never; header?: never; @@ -22474,7 +23683,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-tests-error': { + "get_core-{_version}-tests-error": { parameters: { query?: never; header?: never; @@ -22493,7 +23702,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-tests-notice': { + "get_core-{_version}-tests-notice": { parameters: { query?: never; header?: never; @@ -22512,7 +23721,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-tests-memoryLeak': { + "get_core-{_version}-tests-user-deprecation": { parameters: { query?: never; header?: never; @@ -22531,7 +23740,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-tests-logger': { + "get_core-{_version}-tests-memoryLeak": { parameters: { query?: never; header?: never; @@ -22550,11 +23759,9 @@ export interface operations { }; }; }; - 'get_core-{_version}-tests-logger-observability': { + "get_core-{_version}-tests-logger": { parameters: { - query?: { - Level?: number; - }; + query?: never; header?: never; path: { _version: string; @@ -22571,33 +23778,11 @@ export interface operations { }; }; }; - 'get_core-{_version}-tests-ping': { + "get_core-{_version}-tests-logger-observability": { parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; + query?: { + Level?: number; }; - }; - }; - 'get_core-{_version}-tests-version': { - parameters: { - query?: never; header?: never; path: { _version: string; @@ -22614,7 +23799,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-tests-stream': { + "get_core-{_version}-tests-ping": { parameters: { query?: never; header?: never; @@ -22624,28 +23809,6 @@ export interface operations { cookie?: never; }; requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - 'get_core-{_version}-update': { - parameters: { - query?: { - /** @example 24m */ - cycle?: string; - }; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; responses: { /** @description Success */ 200: { @@ -22653,14 +23816,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-users-invitations': { + "get_core-{_version}-tests-version": { parameters: { query?: never; header?: never; @@ -22671,51 +23834,42 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - } & components['schemas']['GetUserInvitationsOutput']; - }; + content?: never; }; }; }; - 'post_core-{_version}-users-invitations-{enc_id}-reject': { + "get_core-{_version}-tests-stream": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; + content?: never; }; }; }; - 'post_core-{_version}-users-invitations-{enc_id}-accept': { + "get_core-{_version}-update": { parameters: { - query?: never; + query?: { + /** @example 24m */ + cycle?: string; + }; header?: never; path: { _version: string; - enc_id: string; }; cookie?: never; }; @@ -22727,29 +23881,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - /** @description Validation failed */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @example 2011 */ - Code?: number; - Details?: { - Validation?: components['schemas']['GetUserInvitationOutput']; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-validate-email': { + "post_core-{_version}-validate-email": { parameters: { query?: never; header?: never; @@ -22760,7 +23899,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Email address * @example einstein@pm.me @@ -22776,8 +23915,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -22787,7 +23926,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError'] & { + "application/json": components["schemas"]["ProtonError"] & { /** * @description Email address failed validation * @default 2050 @@ -22798,7 +23937,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-validate-phone': { + "post_core-{_version}-validate-phone": { parameters: { query?: never; header?: never; @@ -22809,7 +23948,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Phone number * @example +37012345678 @@ -22825,8 +23964,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -22836,7 +23975,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError'] & { + "application/json": components["schemas"]["ProtonError"] & { /** * @description Phone number failed validation * @default 2058 @@ -22847,7 +23986,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-verification-ownership-{token}': { + "get_core-{_version}-verification-ownership-{token}": { parameters: { query?: never; header?: never; @@ -22867,7 +24006,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-{token}': { + "post_core-{_version}-verification-ownership-{token}": { parameters: { query?: never; header?: never; @@ -22887,7 +24026,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-verification-ownership-email-{token}': { + "get_core-{_version}-verification-ownership-email-{token}": { parameters: { query?: never; header?: never; @@ -22907,7 +24046,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-email-{token}': { + "post_core-{_version}-verification-ownership-email-{token}": { parameters: { query?: never; header?: never; @@ -22927,7 +24066,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-verification-ownership-sms-{token}': { + "get_core-{_version}-verification-ownership-sms-{token}": { parameters: { query?: never; header?: never; @@ -22947,7 +24086,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-sms-{token}': { + "post_core-{_version}-verification-ownership-sms-{token}": { parameters: { query?: never; header?: never; @@ -22967,7 +24106,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-{token}-{code}': { + "post_core-{_version}-verification-ownership-{token}-{code}": { parameters: { query?: never; header?: never; @@ -22988,7 +24127,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-email-{token}-{code}': { + "post_core-{_version}-verification-ownership-email-{token}-{code}": { parameters: { query?: never; header?: never; @@ -23009,7 +24148,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-sms-{token}-{code}': { + "post_core-{_version}-verification-ownership-sms-{token}-{code}": { parameters: { query?: never; header?: never; @@ -23030,35 +24169,12 @@ export interface operations { }; }; }; - 'get_core-v6-events-{id}': { - parameters: { - query?: never; - header?: never; - path: { - id: components['schemas']['Id']; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - 'x-pm-code': 1000; - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Stream']; - }; - }; - }; - }; - 'get_core-{_version}-events-latest': { + "get_core-v6-events-{id}": { parameters: { query?: never; header?: never; path: { - _version: string; + id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -23067,29 +24183,27 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example ACXDmTaBub14w== */ - EventID?: string; - }; + "application/json": components["schemas"]["Stream"]; }; }; }; }; - 'get_core-{_version}-events-{id}': { + "get_core-{_version}-events-{id}": { parameters: { query?: { - MessageCounts?: components['schemas']['BoolInt']; - ConversationCounts?: components['schemas']['BoolInt']; + MessageCounts?: components["schemas"]["BoolInt"]; + ConversationCounts?: components["schemas"]["BoolInt"]; NoMetaData?: unknown[]; + OnlyInInboxForCategoriesCounts?: components["schemas"]["BoolInt"]; }; header?: never; path: { _version: string; - id: components['schemas']['Id']; + id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -23101,22 +24215,22 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['EventInfo']; + "application/json": components["schemas"]["EventInfo"]; }; }; }; }; - 'get_core-v4-events-{id}': { + "get_core-v4-events-{id}": { parameters: { query?: { MessageCounts?: boolean; ConversationCounts?: boolean; }; header?: { - 'x-pm-appversion'?: string; + "x-pm-appversion"?: string; }; path: { - id: components['schemas']['Id']; + id: components["schemas"]["Id"]; }; cookie?: never; }; @@ -23128,12 +24242,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['EventInfo']; + "application/json": components["schemas"]["EventInfo"]; }; }; }; }; - 'post_core-{_version}-feedback': { + "post_core-{_version}-feedback": { parameters: { query?: never; header?: never; @@ -23144,117 +24258,9 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['FeedbackVO']; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'get_core-{_version}-checklist-get-started': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @description Array of completed checklist items */ - Items?: string[]; - /** @description Timestamp of checklist creation */ - CreatedAt?: string; - /** @description Timestamp of checklist expiration */ - ExpiresAt?: string; - /** @description Amount of storage GB completion reward */ - RewardInGB?: number; - }; - }; - }; - }; - }; - 'get_core-{_version}-checklist-paying-user': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @description Array of completed checklist items */ - Items?: string[]; - /** @description Timestamp of checklist creation */ - CreatedAt?: string; - }; - }; - }; - }; - }; - 'post_core-{_version}-checklist-get-started-seen-completed-list': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; - }; - }; - }; - }; - 'post_core-{_version}-checklist-paying-user-hide': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; + "application/json": components["schemas"]["FeedbackVO"]; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Success */ 200: { @@ -23262,19 +24268,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-checklist-paying-user-seen-completed-list': { + "post_core-{_version}-checklist-seen-completed-list-{checklistType}": { parameters: { query?: never; header?: never; path: { _version: string; + checklistType: components["schemas"]["UserChecklistType"]; }; cookie?: never; }; @@ -23283,17 +24290,16 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-checklist-get-started-init': { + "post_core-{_version}-checklist-get-started-init": { parameters: { query?: never; header?: never; @@ -23307,17 +24313,16 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-checklist-paying-user-init': { + "post_core-{_version}-checklist-paying-user-init": { parameters: { query?: never; header?: never; @@ -23331,17 +24336,16 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'put_core-{_version}-checklist-check-item': { + "put_core-{_version}-checklist-check-item": { parameters: { query?: never; header?: never; @@ -23352,27 +24356,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { - /** @example MobileApp */ - Item?: string; - }; + "application/json": components["schemas"]["CheckItemInput"]; }; }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'put_core-{_version}-checklist-update-display': { + "put_core-{_version}-checklist-update-display": { parameters: { query?: never; header?: never; @@ -23383,27 +24383,23 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { - /** @example Hidden */ - Display?: string; - }; + "application/json": components["schemas"]["UpdateDisplayInput"]; }; }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-verify-send': { + "post_core-{_version}-verify-send": { parameters: { query?: never; header?: never; @@ -23414,12 +24410,12 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @example external_email * @enum {string} */ - Type?: 'external_email, recovery_email'; + Type?: "external_email, recovery_email"; /** @example me@example.com */ Destination?: string; }; @@ -23432,14 +24428,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-verify-validate': { + "post_core-{_version}-verify-validate": { parameters: { query?: never; header?: never; @@ -23450,7 +24446,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ JWT?: string; }; @@ -23463,8 +24459,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Previous confirmation state * @example 1 @@ -23475,7 +24471,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-verify-validate': { + "delete_core-{_version}-verify-validate": { parameters: { query?: never; header?: never; @@ -23486,7 +24482,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ JWT?: string; }; @@ -23499,8 +24495,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Previous confirmation state * @example 1 @@ -23511,7 +24507,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verify-email': { + "post_core-{_version}-verify-email": { parameters: { query?: never; header?: never; @@ -23528,8 +24524,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Previous confirmation state * @example 1 @@ -23540,7 +24536,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verify-phone': { + "post_core-{_version}-verify-phone": { parameters: { query?: never; header?: never; @@ -23557,8 +24553,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Previous confirmation state * @example 1 @@ -23569,7 +24565,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verify-reauth-email': { + "post_core-{_version}-verify-reauth-email": { parameters: { query?: never; header?: never; @@ -23586,8 +24582,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -23597,7 +24593,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 12087 */ Code?: number; /** @example Invalid or already used token */ @@ -23611,7 +24607,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 9001 */ Code?: number; /** @example Human verification required */ @@ -23621,7 +24617,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-verify-reauth-phone': { + "post_core-{_version}-verify-reauth-phone": { parameters: { query?: never; header?: never; @@ -23638,8 +24634,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -23649,7 +24645,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 12087 */ Code?: number; /** @example Invalid or already used token */ @@ -23663,7 +24659,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 9001 */ Code?: number; /** @example Human verification required */ @@ -23673,7 +24669,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-notifications': { + "get_core-{_version}-notifications": { parameters: { query?: { /** @@ -23681,11 +24677,11 @@ export interface operations { * @example 2 */ WithImageScale?: number; - FullScreenImageSupport?: components['schemas']['NotificationRequest']['FullScreenImageSupport']; - FullScreenImageWidth?: components['schemas']['NotificationRequest']['FullScreenImageWidth']; - FullScreenImageHeight?: components['schemas']['NotificationRequest']['FullScreenImageHeight']; - SupportedFullScreenImageFormats?: components['schemas']['NotificationRequest']['SupportedFullScreenImageFormats']; - Null?: components['schemas']['NotificationRequest']['Null']; + FullScreenImageSupport?: components["schemas"]["NotificationRequest"]["FullScreenImageSupport"]; + FullScreenImageWidth?: components["schemas"]["NotificationRequest"]["FullScreenImageWidth"]; + FullScreenImageHeight?: components["schemas"]["NotificationRequest"]["FullScreenImageHeight"]; + SupportedFullScreenImageFormats?: components["schemas"]["NotificationRequest"]["SupportedFullScreenImageFormats"]; + Null?: components["schemas"]["NotificationRequest"]["Null"]; }; header?: never; path: { @@ -23701,64 +24697,55 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Notifications?: components['schemas']['NotificationVersionTransformer'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Notifications?: components["schemas"]["NotificationVersionTransformer"][]; }; }; }; }; }; - 'patch_core-v4-labels-{enc_id}': { + "get_core-{_version}-connection-information": { parameters: { - query?: { - /** - * @description the encrypted label id - * @example lKJlejjlk== - */ - enc_id?: string; - }; + query?: never; header?: never; path: { - enc_id: string; + _version: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['PatchInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Label?: components['schemas']['Label']; - }; + "application/json": components["schemas"]["ConnectionInformationResponse"]; }; }; - /** @description Invalid request body */ + /** @description Connection information cannot be provided. */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - /** @example 2500 */ - Code?: number; - /** @example Attribute Expanded should be of type int, null (float given) */ + "application/json": { + /** + * @example 2900 + * @enum {integer} + */ + Code?: 2900 | 2051; + /** @example Connection information cannot be provided at this time. */ Error?: string; }; }; }; }; }; - 'post_core-v4-labels-by-ids': { + "post_core-v4-labels-by-ids": { parameters: { query?: never; header?: never; @@ -23767,7 +24754,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LabelIDs']; + "application/json": components["schemas"]["LabelIDs"]; }; }; responses: { @@ -23777,17 +24764,44 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Labels?: { - [key: string]: components['schemas']['Label']; + [key: string]: components["schemas"]["Label2"]; }; }; }; }; }; }; - 'get_core-{_version}-labels': { + "post_core-v5-labels-by-ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LabelIDs"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Labels?: components["schemas"]["Label2"][]; + }; + }; + }; + }; + }; + "get_core-{_version}-labels": { parameters: { query?: { /** @@ -23810,15 +24824,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Labels?: components['schemas']['Label'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Labels?: components["schemas"]["Label2"][]; }; }; }; }; }; - 'post_core-{_version}-labels': { + "post_core-{_version}-labels": { parameters: { query?: never; header?: never; @@ -23829,7 +24843,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description required, cannot be same as an existing label of this Type. Max length is 100 characters * @example Red Label @@ -23866,10 +24880,7 @@ export interface operations { */ Sticky?: number; /** - * @description - * * * 1 = show the label in the sidebar - * * * 0 = hide label from sidebar - * * + * @description 1 = show the label in the sidebar, 0 = hide label from sidebar * @example 0 */ Display?: number; @@ -23883,9 +24894,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Label?: components['schemas']['Label']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Label?: components["schemas"]["Label2"]; }; }; }; @@ -23895,7 +24906,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2011 */ Code?: number; /** @example Maximum 3 levels in the folder hierarchy */ @@ -23909,7 +24920,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2500 */ Code?: number; /** @example A label or folder with this name already exists */ @@ -23923,7 +24934,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2011 */ Code?: number; /** @example Invalid name */ @@ -23933,7 +24944,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-labels': { + "delete_core-{_version}-labels": { parameters: { query?: never; header?: never; @@ -23944,7 +24955,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { LabelIDs?: string[]; }; }; @@ -23956,7 +24967,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 1001 */ Code?: number; /** @description Array of responses, one element per label */ @@ -23965,7 +24976,7 @@ export interface operations { /** @example KPlISx5MiML3XcSY-tfNw== */ LabelID?: string; Response?: { - Code?: components['schemas']['ResponseCodeSuccess']; + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; 1?: { @@ -23988,32 +24999,30 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @default 2000 */ - Code: number; - /** @default The LabelIDs is required */ - Error: string; - Details?: { - /** @default The LabelIDs is required */ - LabelIDs: Record; - }; - } - | { - /** @default 2002 */ - Code: number; - /** @default The LabelIDs must be a array */ - Error: string; - Details?: { - /** @default The LabelIDs must be a array */ - LabelIDs: Record; - }; - }; + "application/json": { + /** @default 2000 */ + Code: number; + /** @default The LabelIDs is required */ + Error: string; + Details?: { + /** @default The LabelIDs is required */ + LabelIDs: Record; + }; + } | { + /** @default 2002 */ + Code: number; + /** @default The LabelIDs must be a array */ + Error: string; + Details?: { + /** @default The LabelIDs must be a array */ + LabelIDs: Record; + }; + }; }; }; }; }; - 'get_core-{_version}-labels-available': { + "get_core-{_version}-labels-available": { parameters: { query: { /** @description The name to check */ @@ -24037,8 +25046,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -24048,7 +25057,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2011 */ Code?: number; /** @example Maximum 3 levels in the folder hierarchy */ @@ -24062,7 +25071,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2500 */ Code?: number; /** @example A label or folder with this name already exists */ @@ -24076,7 +25085,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2011 */ Code?: number; /** @example Invalid name */ @@ -24086,7 +25095,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-labels-order': { + "put_core-{_version}-labels-order": { parameters: { query?: never; header?: never; @@ -24097,7 +25106,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description Will amend the order of labels with the order of the corresponding LabelIDs */ LabelIDs?: string[]; /** @@ -24120,20 +25129,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-labels-order-tree-{startLabelId}': { + "put_core-{_version}-labels-order-tree-{startLabelId}": { parameters: { query?: never; header?: never; path: { _version: string; - startLabelId: (string & components['schemas']['Id']) | null; + startLabelId: string & (components["schemas"]["Id"] | null); }; cookie?: never; }; @@ -24145,14 +25154,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-labels-{id}': { + "put_core-{_version}-labels-{id}": { parameters: { query?: { /** @@ -24175,10 +25184,9 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** - * @description required, cannot be same as an existing label of this Type. Max length is 100 characters. - * * Must be the same for Message System Folders (Type = 4) + * @description required, cannot be same as an existing label of this Type. Max length is 100 characters. Must be the same for Message System Folders (Type = 4) * @example Stuff */ Name?: string; @@ -24222,9 +25230,9 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Label?: components['schemas']['Label']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Label?: components["schemas"]["Label2"]; }; }; }; @@ -24234,7 +25242,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2011 */ Code?: number; /** @example Maximum 3 levels in the folder hierarchy */ @@ -24248,7 +25256,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2500 */ Code?: number; /** @example A sub-folder with this name already exists in the destination folder */ @@ -24258,7 +25266,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-labels-{enc_id}': { + "delete_core-{_version}-labels-{enc_id}": { parameters: { query?: { /** @@ -24282,14 +25290,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-labels-{enc_labelID}-detach': { + "put_core-{_version}-labels-{enc_labelID}-detach": { parameters: { query?: { /** @@ -24313,8 +25321,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example 3 */ NumMessages?: number; }; @@ -24326,29 +25334,27 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': - | { - /** @default 2001 */ - Code: number; - /** @default The action can't be performed on this label */ - Error: string; - Details?: { - /** @default LabelID corresponds to a global PM label, which can't be edited by this route */ - LabelID: Record; - }; - } - | { - /** @default 2002 */ - Code: number; - /** @default The action can't be performed on this label */ - Error: string; - Details?: { - /** @default LabelID must correspond to a label of the MessageLabel type */ - LabelID: Record; - /** @default Folder */ - LabelTypeReceived: Record; - }; - }; + "application/json": { + /** @default 2001 */ + Code: number; + /** @default The action can't be performed on this label */ + Error: string; + Details?: { + /** @default LabelID corresponds to a global PM label, which can't be edited by this route */ + LabelID: Record; + }; + } | { + /** @default 2002 */ + Code: number; + /** @default The action can't be performed on this label */ + Error: string; + Details?: { + /** @default LabelID must correspond to a label of the MessageLabel type */ + LabelID: Record; + /** @default Folder */ + LabelTypeReceived: Record; + }; + }; }; }; /** @description Unprocessable Entity */ @@ -24357,7 +25363,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @default 2501 */ Code: number; /** @default Label does not exist */ @@ -24367,7 +25373,260 @@ export interface operations { }; }; }; - 'get_core-{_version}-images': { + "patch_core-v4-labels-{enc_id}": { + parameters: { + query: { + /** + * @description the encrypted label id + * @example wSGAB7IOerWAaIItAfyAIbSWIaFSS + */ + enc_id: string; + }; + header?: never; + path: { + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PatchInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LabelResponse"]; + }; + }; + }; + }; + "get_core-{_version}-referrals-identifiers-{identifier}": { + parameters: { + query?: never; + header?: never; + path: { + /** @example KPlISx5MiML3XcSYPrREF */ + identifier: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The identifier exists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description The identifier does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "get_core-{_version}-referrals-info": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-referrals-status": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Referrals?: components["schemas"]["ReferralStatus"][]; + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-trials-{referralIdentifier}": { + parameters: { + query?: never; + header?: never; + path: { + /** @example KZPS5MML */ + referralIdentifier: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListEligibleTrialsResponse"]; + }; + }; + }; + }; + "get_core-{_version}-referrals": { + parameters: { + query?: { + /** @description Skip the given number of results */ + Offset?: number; + /** @description The number of results to return, maximum 100 */ + Limit?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Referrals?: components["schemas"]["ReferralOutput"][]; + Total?: number; + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-referrals": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SendInvitationsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Referrals?: components["schemas"]["ReferralOutput"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-referrals-register": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RegisterReferralInput"]; + }; + }; + responses: { + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; + }; + }; + }; + "get_core-v5-entitlements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_core-{_version}-images": { parameters: { query: { /** @@ -24377,15 +25636,15 @@ export interface operations { Url: string; /** * @description Whether tracked urls should be blocked (not downloaded). Acts as a boolean. Default is 1.
- * * - 0: don't block
- * * - 1: block
+ * - 0: don't block
+ * - 1: block
* @example 1 */ BlockTrackers?: number; /** * @description Whether remote data should not be downloaded. Acts as a boolean. Default is 0.
- * * - 0: download (while still respecting BlockTrackers)
- * * - 1: don't download
+ * - 0: download (while still respecting BlockTrackers)
+ * - 1: don't download
* @example 1 */ DryRun?: number; @@ -24402,12 +25661,12 @@ export interface operations { 200: { headers: { /** @description If this header is set, the image is being tracked. - * The value of the headers is the service providing the tracking. */ - 'X-Pm-Tracker-Provider'?: string; + * The value of the headers is the service providing the tracking. */ + "X-Pm-Tracker-Provider"?: string; [name: string]: unknown; }; content: { - 'application/octet-stream': string; + "application/octet-stream": string; }; }; /** @description Return an empty image when we cannot proxy the remote image */ @@ -24423,7 +25682,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2000 */ Code?: number; /** @example The Url is required */ @@ -24437,7 +25696,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2052 */ Code?: number; /** @example The Url is not valid URL */ @@ -24447,4 +25706,37 @@ export interface operations { }; }; }; + "get_core-{_version}-checklist-{checklistType}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + checklistType: components["schemas"]["UserChecklistType"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @description Array of completed checklist items */ + Items?: string[]; + /** @description Timestamp of checklist creation */ + CreatedAt?: string; + /** @description Timestamp of checklist expiration. Only for expiring checklists */ + ExpiresAt?: string; + /** @description Amount of storage GB completion reward. Only for checklists giving reward */ + RewardInGB?: number; + }; + }; + }; + }; + }; } diff --git a/js/sdk/src/internal/apiService/errors.test.ts b/js/sdk/src/internal/apiService/errors.test.ts index c799a046..c2d40400 100644 --- a/js/sdk/src/internal/apiService/errors.test.ts +++ b/js/sdk/src/internal/apiService/errors.test.ts @@ -1,7 +1,7 @@ import { AbortError } from '../../errors'; -import { apiErrorFactory } from './errors'; -import * as errors from './errors'; import { ErrorCode } from './errorCodes'; +import * as errors from './errors'; +import { apiErrorFactory } from './errors'; function mockAPIResponseAndResult(options: { httpStatusCode?: number; diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 1c5c9b0b..77575d05 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -1,7 +1,7 @@ export { DriveAPIService } from './apiService'; -export type { paths as drivePaths } from './driveTypes'; export type { paths as corePaths } from './coreTypes'; -export { HTTPErrorCode, ErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; -export { nodeTypeNumberToNodeType, permissionsToMemberRole, memberRoleToPermission } from './transformers'; -export { ObserverStream } from './observerStream'; +export type { paths as drivePaths } from './driveTypes'; +export { ErrorCode, HTTPErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; export * from './errors'; +export { ObserverStream } from './observerStream'; +export { memberRoleToPermission, nodeTypeNumberToNodeType, permissionsToMemberRole } from './transformers'; diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts index f518bc37..52eab0a4 100644 --- a/js/sdk/src/internal/apiService/transformers.ts +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -1,4 +1,4 @@ -import { Logger, NodeType, MemberRole } from '../../interface'; +import { Logger, MemberRole, NodeType } from '../../interface'; enum ShareTargetType { Root = 0, diff --git a/js/sdk/src/internal/devices/index.ts b/js/sdk/src/internal/devices/index.ts index b9d49ecc..574b736b 100644 --- a/js/sdk/src/internal/devices/index.ts +++ b/js/sdk/src/internal/devices/index.ts @@ -3,7 +3,7 @@ import { ProtonDriveTelemetry } from '../../interface'; import { DriveAPIService } from '../apiService'; import { DevicesAPIService } from './apiService'; import { DevicesCryptoService } from './cryptoService'; -import { SharesService, NodesService, NodesManagementService } from './interface'; +import { NodesManagementService, NodesService, SharesService } from './interface'; import { DevicesManager } from './manager'; /** diff --git a/js/sdk/src/internal/devices/manager.test.ts b/js/sdk/src/internal/devices/manager.test.ts index b44dc2d2..8f62802b 100644 --- a/js/sdk/src/internal/devices/manager.test.ts +++ b/js/sdk/src/internal/devices/manager.test.ts @@ -1,9 +1,9 @@ -import { Device, DeviceType, Logger } from '../../interface'; import { ValidationError } from '../../errors'; +import { Device, DeviceType, Logger } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { DevicesAPIService } from './apiService'; import { DevicesCryptoService } from './cryptoService'; -import { SharesService, NodesService, NodesManagementService, DeviceMetadata } from './interface'; +import { DeviceMetadata, NodesManagementService, NodesService, SharesService } from './interface'; import { DevicesManager } from './manager'; describe('DevicesManager', () => { diff --git a/js/sdk/src/internal/download/cryptoService.ts b/js/sdk/src/internal/download/cryptoService.ts index ec5bd514..94001797 100644 --- a/js/sdk/src/internal/download/cryptoService.ts +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -1,15 +1,10 @@ import { c } from 'ttag'; -import { - DriveCrypto, - PrivateKey, - PublicKey, - SessionKey, - uint8ArrayToBase64String, - VERIFICATION_STATUS, -} from '../../crypto'; -import { ProtonDriveAccount, Revision } from '../../interface'; +import { computeSHA256 } from '@protontech/crypto/subtle/hash.ts'; + +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; import { DecryptionError, IntegrityError } from '../../errors'; +import { ProtonDriveAccount, Revision } from '../../interface'; import { getErrorMessage } from '../errors'; import { mergeUint8Arrays } from '../utils'; import { RevisionKeys, SignatureVerificationError } from './interface'; @@ -73,8 +68,8 @@ export class DownloadCryptoService { } async verifyBlockIntegrity(encryptedBlock: Uint8Array, base64sha256Hash: string): Promise { - const digest = await crypto.subtle.digest('SHA-256', encryptedBlock); - const expectedHash = uint8ArrayToBase64String(new Uint8Array(digest)); + const digest = await computeSHA256(encryptedBlock); + const expectedHash = new Uint8Array(digest).toBase64(); if (expectedHash !== base64sha256Hash) { throw new IntegrityError(c('Error').t`Data integrity check of one part failed`, { diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts index fd8a330a..4a3c1b99 100644 --- a/js/sdk/src/internal/download/fileDownloader.test.ts +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -1,11 +1,11 @@ +import { IntegrityError } from '../..'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; import { DecryptedRevision } from '../nodes'; -import { FileDownloader } from './fileDownloader'; -import { DownloadTelemetry } from './telemetry'; import { DownloadAPIService } from './apiService'; import { DownloadCryptoService } from './cryptoService'; +import { FileDownloader } from './fileDownloader'; import { SignatureVerificationError } from './interface'; -import { IntegrityError } from '../..'; +import { DownloadTelemetry } from './telemetry'; function mockBlockDownload(_: string, token: string, onProgress: (downloadedBytes: number) => void) { const index = parseInt(token.slice(5, 6)); diff --git a/js/sdk/src/internal/download/fileDownloader.ts b/js/sdk/src/internal/download/fileDownloader.ts index 7de097f9..716448e0 100644 --- a/js/sdk/src/internal/download/fileDownloader.ts +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { PrivateKey, SessionKey, base64StringToUint8Array } from '../../crypto'; +import { PrivateKey, SessionKey } from '../../crypto'; import { AbortError, IntegrityError } from '../../errors'; import { Logger } from '../../interface'; import { LoggerWithPrefix } from '../../telemetry'; @@ -185,7 +185,7 @@ export class FileDownloader { continue; } - allBlockHashes.push(base64StringToUint8Array(blockMetadata.base64sha256Hash)); + allBlockHashes.push(Uint8Array.fromBase64(blockMetadata.base64sha256Hash)); if (blockMetadata.type === 'thumbnail') { continue; } @@ -235,10 +235,12 @@ export class FileDownloader { void this.telemetry.downloadFinished(this.revision.uid, fileProgress); this.logger.info(`Download succeeded`); - try { - writer.releaseLock(); - } catch (error: unknown) { - this.logger.error(`Failed to release writer lock`, error); + if ('releaseLock' in writer) { + try { + writer.releaseLock(); + } catch (error: unknown) { + this.logger.error(`Failed to release writer lock`, error); + } } } catch (error: unknown) { if (error instanceof SignatureVerificationError) { diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts index 4bf8f7be..4c45d780 100644 --- a/js/sdk/src/internal/download/index.ts +++ b/js/sdk/src/internal/download/index.ts @@ -2,15 +2,15 @@ import { c } from 'ttag'; import { DriveCrypto } from '../../crypto'; import { ValidationError } from '../../errors'; -import { ProtonDriveAccount, ProtonDriveTelemetry, NodeType, ThumbnailType, ThumbnailResult } from '../../interface'; +import { NodeType, ProtonDriveAccount, ProtonDriveTelemetry, ThumbnailResult, ThumbnailType } from '../../interface'; import { DriveAPIService } from '../apiService'; +import { makeNodeUidFromRevisionUid } from '../uids'; import { DownloadAPIService } from './apiService'; import { DownloadCryptoService } from './cryptoService'; -import { NodesService, RevisionsService, SharesService } from './interface'; import { FileDownloader } from './fileDownloader'; +import { NodesService, RevisionsService, SharesService } from './interface'; import { DownloadQueue } from './queue'; import { DownloadTelemetry } from './telemetry'; -import { makeNodeUidFromRevisionUid } from '../uids'; import { ThumbnailDownloader } from './thumbnailDownloader'; export function initDownloadModule( diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts index 61470885..f0f7612d 100644 --- a/js/sdk/src/internal/download/interface.ts +++ b/js/sdk/src/internal/download/interface.ts @@ -1,6 +1,6 @@ import { PrivateKey, PublicKey, SessionKey } from '../../crypto'; import { IntegrityError } from '../../errors'; -import { NodeType, Result, MissingNode, MetricVolumeType } from '../../interface'; +import { MetricVolumeType, MissingNode, NodeType, Result } from '../../interface'; import { DecryptedNode, DecryptedRevision } from '../nodes'; export type BlockMetadata = { diff --git a/js/sdk/src/internal/download/seekableStream.test.ts b/js/sdk/src/internal/download/seekableStream.test.ts index d045474b..10625bf0 100644 --- a/js/sdk/src/internal/download/seekableStream.test.ts +++ b/js/sdk/src/internal/download/seekableStream.test.ts @@ -1,4 +1,4 @@ -import { SeekableReadableStream, BufferedSeekableStream, UnderlyingSeekableSource } from './seekableStream'; +import { BufferedSeekableStream, SeekableReadableStream, UnderlyingSeekableSource } from './seekableStream'; describe('SeekableReadableStream', () => { it('should call the seek callback when seek is called', async () => { diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts index 5b03a9f3..c1b72b97 100644 --- a/js/sdk/src/internal/download/telemetry.test.ts +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -1,4 +1,4 @@ -import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from '../../errors'; +import { DecryptionError, IntegrityError, RateLimitedError, ValidationError } from '../../errors'; import { ProtonDriveTelemetry } from '../../interface'; import { APIHTTPError } from '../apiService'; import { SharesService } from './interface'; diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index fbf21b69..b544a88d 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -1,5 +1,5 @@ -import { RateLimitedError, ValidationError, DecryptionError, IntegrityError } from '../../errors'; -import { ProtonDriveTelemetry, MetricsDownloadErrorType, Logger, MetricVolumeType } from '../../interface'; +import { DecryptionError, IntegrityError, RateLimitedError, ValidationError } from '../../errors'; +import { Logger, MetricsDownloadErrorType, MetricVolumeType, ProtonDriveTelemetry } from '../../interface'; import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry'; import { APIHTTPError } from '../apiService'; import { splitNodeRevisionUid, splitNodeUid } from '../uids'; diff --git a/js/sdk/src/internal/download/thumbnailDownloader.test.ts b/js/sdk/src/internal/download/thumbnailDownloader.test.ts index a8252c12..488804b6 100644 --- a/js/sdk/src/internal/download/thumbnailDownloader.test.ts +++ b/js/sdk/src/internal/download/thumbnailDownloader.test.ts @@ -1,9 +1,9 @@ import { ProtonDriveTelemetry } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; -import { ThumbnailDownloader } from './thumbnailDownloader'; import { DownloadAPIService } from './apiService'; import { DownloadCryptoService } from './cryptoService'; import { NodesService } from './interface'; +import { ThumbnailDownloader } from './thumbnailDownloader'; describe('ThumbnailDownloader', () => { let telemetry: ProtonDriveTelemetry; diff --git a/js/sdk/src/internal/download/thumbnailDownloader.ts b/js/sdk/src/internal/download/thumbnailDownloader.ts index 56ecb539..db59b851 100644 --- a/js/sdk/src/internal/download/thumbnailDownloader.ts +++ b/js/sdk/src/internal/download/thumbnailDownloader.ts @@ -1,12 +1,12 @@ import { c } from 'ttag'; -import { ThumbnailType, ProtonDriveTelemetry, Logger, ThumbnailResult } from '../../interface'; import { ValidationError } from '../../errors'; +import { Logger, ProtonDriveTelemetry, ThumbnailResult, ThumbnailType } from '../../interface'; import { LoggerWithPrefix } from '../../telemetry'; +import { getErrorMessage } from '../errors'; import { DownloadAPIService } from './apiService'; import { DownloadCryptoService } from './cryptoService'; import { NodesService } from './interface'; -import { getErrorMessage } from '../errors'; /** * Maximum number of thumbnails that can be downloaded at the same time. diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index ba5473f9..e88045d0 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -1,6 +1,6 @@ -import { DriveAPIService, drivePaths, corePaths } from '../apiService'; +import { corePaths, DriveAPIService, drivePaths } from '../apiService'; import { makeNodeUid } from '../uids'; -import { DriveEventsListWithStatus, DriveEvent, DriveEventType, NodeEvent, NodeEventType } from './interface'; +import { DriveEvent, DriveEventsListWithStatus, DriveEventType, NodeEvent, NodeEventType } from './interface'; type GetCoreLatestEventResponse = corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json']; diff --git a/js/sdk/src/internal/events/coreEventManager.test.ts b/js/sdk/src/internal/events/coreEventManager.test.ts index 263dc927..52c2597a 100644 --- a/js/sdk/src/internal/events/coreEventManager.test.ts +++ b/js/sdk/src/internal/events/coreEventManager.test.ts @@ -1,7 +1,7 @@ import { getMockLogger } from '../../tests/logger'; import { EventsAPIService } from './apiService'; -import { DriveEvent, DriveEventsListWithStatus, DriveEventType } from './interface'; import { CoreEventManager } from './coreEventManager'; +import { DriveEvent, DriveEventsListWithStatus, DriveEventType } from './interface'; describe('CoreEventManager', () => { let mockApiService: jest.Mocked; diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts index e1d4720e..89f2092e 100644 --- a/js/sdk/src/internal/events/eventManager.ts +++ b/js/sdk/src/internal/events/eventManager.ts @@ -1,5 +1,5 @@ import { Logger } from '../../interface'; -import { EventManagerInterface, Event, EventSubscription } from './interface'; +import { Event, EventManagerInterface, EventSubscription } from './interface'; const FIBONACCI_LIST = [1, 1, 2, 3, 5, 8, 13]; diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 558c2fc8..c0dc95bb 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -1,10 +1,10 @@ import { Logger, ProtonDriveTelemetry } from '../../interface'; import { DriveAPIService } from '../apiService'; -import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider, SharesService } from './interface'; import { EventsAPIService } from './apiService'; import { CoreEventManager } from './coreEventManager'; -import { VolumeEventManager } from './volumeEventManager'; import { EventManager } from './eventManager'; +import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider, SharesService } from './interface'; +import { VolumeEventManager } from './volumeEventManager'; export type { DriveEvent, DriveListener, EventSubscription } from './interface'; export { DriveEventType } from './interface'; diff --git a/js/sdk/src/internal/events/volumeEventManager.test.ts b/js/sdk/src/internal/events/volumeEventManager.test.ts index 519a936a..0ae0fda0 100644 --- a/js/sdk/src/internal/events/volumeEventManager.test.ts +++ b/js/sdk/src/internal/events/volumeEventManager.test.ts @@ -1,8 +1,8 @@ import { getMockLogger } from '../../tests/logger'; import { NotFoundAPIError } from '../apiService'; import { EventsAPIService } from './apiService'; -import { VolumeEventManager } from './volumeEventManager'; import { DriveEventsListWithStatus, DriveEventType } from './interface'; +import { VolumeEventManager } from './volumeEventManager'; jest.mock('./apiService'); diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index c317f04a..f6694cff 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -1,5 +1,6 @@ import { Logger } from '../../interface'; import { LoggerWithPrefix } from '../../telemetry'; +import { NotFoundAPIError } from '../apiService'; import { EventsAPIService } from './apiService'; import { DriveEvent, @@ -8,7 +9,6 @@ import { EventManagerInterface, UnsubscribeFromEventsSourceError, } from './interface'; -import { NotFoundAPIError } from '../apiService'; /** * Combines API and event manager to provide a service for listening to diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 92edabc7..1d5091ff 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -2,7 +2,7 @@ import { NodeWithSameNameExistsValidationError, ValidationError } from '../../er import { MemberRole, NodeType } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService'; -import { NodeAPIService, groupNodeUidsByVolumeAndIteratePerBatch } from './apiService'; +import { groupNodeUidsByVolumeAndIteratePerBatch, NodeAPIService } from './apiService'; import { NodeOutOfSyncError } from './errors'; function generateAPIFileNode(linkOverrides = {}, overrides = {}) { diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 0806cf19..44f1a6ad 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { NodeWithSameNameExistsValidationError, ProtonDriveError, ValidationError } from '../../errors'; -import { Logger, NodeResult, MemberRole, RevisionState, AnonymousUser } from '../../interface'; +import { AnonymousUser, Logger, MemberRole, NodeResult, RevisionState } from '../../interface'; import { DriveAPIService, drivePaths, @@ -13,7 +13,7 @@ import { } from '../apiService'; import { asyncIteratorRace } from '../asyncIteratorRace'; import { batch } from '../batch'; -import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from '../uids'; +import { makeNodeRevisionUid, makeNodeThumbnailUid, makeNodeUid, splitNodeRevisionUid, splitNodeUid } from '../uids'; import { NodeOutOfSyncError } from './errors'; import { EncryptedNode, EncryptedRevision, FilterOptions, Thumbnail } from './interface'; diff --git a/js/sdk/src/internal/nodes/cache.test.ts b/js/sdk/src/internal/nodes/cache.test.ts index 46925a64..b5640c71 100644 --- a/js/sdk/src/internal/nodes/cache.test.ts +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -1,5 +1,5 @@ import { MemoryCache } from '../../cache'; -import { NodeType, MemberRole, RevisionState, resultOk, Result } from '../../interface'; +import { MemberRole, NodeType, Result, resultOk, RevisionState } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { CACHE_TAG_KEYS, NodesCache } from './cache'; import { DecryptedNode, DecryptedRevision } from './interface'; diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts index 2643b7cf..50ec7127 100644 --- a/js/sdk/src/internal/nodes/cache.ts +++ b/js/sdk/src/internal/nodes/cache.ts @@ -1,5 +1,5 @@ import { EntityResult } from '../../cache'; -import { ProtonDriveEntitiesCache, Logger, resultOk, Result } from '../../interface'; +import { Logger, ProtonDriveEntitiesCache, Result, resultOk } from '../../interface'; import { splitNodeUid } from '../uids'; import { DecryptedNode, DecryptedRevision } from './interface'; diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts index 8d9ba19a..e391c066 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.test.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -1,5 +1,5 @@ -import { PrivateKey, SessionKey } from '../../crypto'; import { MemoryCache } from '../../cache'; +import { PrivateKey, SessionKey } from '../../crypto'; import { CachedCryptoMaterial } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { NodesCryptoCache } from './cryptoCache'; diff --git a/js/sdk/src/internal/nodes/cryptoCache.ts b/js/sdk/src/internal/nodes/cryptoCache.ts index a0731b33..18c00f01 100644 --- a/js/sdk/src/internal/nodes/cryptoCache.ts +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveCryptoCache, Logger } from '../../interface'; +import { Logger, ProtonDriveCryptoCache } from '../../interface'; import { DecryptedNodeKeys } from './interface'; /** diff --git a/js/sdk/src/internal/nodes/cryptoReporter.ts b/js/sdk/src/internal/nodes/cryptoReporter.ts index 6238bdba..c5ab1712 100644 --- a/js/sdk/src/internal/nodes/cryptoReporter.ts +++ b/js/sdk/src/internal/nodes/cryptoReporter.ts @@ -1,14 +1,14 @@ import { VERIFICATION_STATUS } from '../../crypto'; import { - resultOk, - resultError, - Author, AnonymousUser, - ProtonDriveTelemetry, + Author, Logger, MetricsDecryptionErrorField, MetricVerificationErrorField, MetricVolumeType, + ProtonDriveTelemetry, + resultError, + resultOk, } from '../../interface'; import { getVerificationMessage, isNotApplicationError } from '../errors'; import { splitNodeUid } from '../uids'; diff --git a/js/sdk/src/internal/nodes/cryptoService.test.ts b/js/sdk/src/internal/nodes/cryptoService.test.ts index ddc9e63b..a00f714d 100644 --- a/js/sdk/src/internal/nodes/cryptoService.test.ts +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -1,6 +1,8 @@ import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; import { MemberRole, ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; +import { NodesCryptoReporter } from './cryptoReporter'; +import { NodesCryptoService } from './cryptoService'; import { DecryptedNode, DecryptedNodeKeys, @@ -9,8 +11,6 @@ import { NodeSigningKeys, SharesService, } from './interface'; -import { NodesCryptoService } from './cryptoService'; -import { NodesCryptoReporter } from './cryptoReporter'; describe('nodesCryptoService', () => { let telemetry: ProtonDriveTelemetry; diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index a9893c0e..e4e0c6f5 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,41 +1,40 @@ import { c } from 'ttag'; import { - base64StringToUint8Array, DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS, } from '../../crypto'; +import { ValidationError } from '../../errors'; import { - resultOk, - resultError, - Result, - Author, AnonymousUser, - ProtonDriveTelemetry, + Author, Logger, + Membership, MetricsDecryptionErrorField, MetricVerificationErrorField, - Membership, ProtonDriveAccount, + ProtonDriveTelemetry, + Result, + resultError, + resultOk, } from '../../interface'; -import { ValidationError } from '../../errors'; import { getErrorMessage } from '../errors'; +import { splitNodeUid } from '../uids'; import { - EncryptedNode, - EncryptedNodeFolderCrypto, - DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, - EncryptedRevision, + DecryptedUnparsedNode, DecryptedUnparsedRevision, - NodeSigningKeys, + EncryptedNode, EncryptedNodeFileCrypto, + EncryptedNodeFolderCrypto, + EncryptedRevision, + NodeSigningKeys, SharesService, } from './interface'; -import { splitNodeUid } from '../uids'; export interface NodesCryptoReporter { handleClaimedAuthor( @@ -213,7 +212,7 @@ export class NodesCryptoService { let contentKeyPacketAuthor; let contentKeyPacket: Uint8Array | undefined; if ('file' in node.encryptedCrypto) { - contentKeyPacket = base64StringToUint8Array(node.encryptedCrypto.file.base64ContentKeyPacket); + contentKeyPacket = Uint8Array.fromBase64(node.encryptedCrypto.file.base64ContentKeyPacket); const [activeRevisionPromise, contentKeyPacketSessionKeyPromise] = [ this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key), this.decryptContentKeyPacket(node, node.encryptedCrypto, key, keyVerificationKeys), diff --git a/js/sdk/src/internal/nodes/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts index ed8b79c4..de4bd9e3 100644 --- a/js/sdk/src/internal/nodes/events.test.ts +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -1,8 +1,8 @@ import { getMockLogger } from '../../tests/logger'; import { DriveEvent, DriveEventType } from '../events'; +import { NodesCache } from './cache'; import { NodesEventsHandler } from './events'; import { DecryptedNode } from './interface'; -import { NodesCache } from './cache'; describe('NodesEventsHandler', () => { const logger = getMockLogger(); diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts index 3ac39eb0..ecdc361b 100644 --- a/js/sdk/src/internal/nodes/extendedAttributes.test.ts +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -1,11 +1,11 @@ import { getMockLogger } from '../../tests/logger'; import { - FolderExtendedAttributes, FileExtendedAttributesParsed, - generateFolderExtendedAttributes, + FolderExtendedAttributes, generateFileExtendedAttributes, - parseFolderExtendedAttributes, + generateFolderExtendedAttributes, parseFileExtendedAttributes, + parseFolderExtendedAttributes, } from './extendedAttributes'; describe('extended attrbiutes', () => { diff --git a/js/sdk/src/internal/nodes/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts index 221c21db..0736909a 100644 --- a/js/sdk/src/internal/nodes/index.test.ts +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -1,20 +1,20 @@ +import { MemoryCache } from '../../cache'; +import { DriveCrypto } from '../../crypto'; import { - ProtonDriveEntitiesCache, - ProtonDriveCryptoCache, - ProtonDriveAccount, MemberRole, NodeType, + ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, } from '../../interface'; -import { DriveCrypto } from '../../crypto'; -import { MemoryCache } from '../../cache'; +import { getMockLogger } from '../../tests/logger'; import { getMockTelemetry } from '../../tests/telemetry'; import { DriveAPIService } from '../apiService'; import { DriveEventType } from '../events'; import { makeNodeUid } from '../uids'; -import { SharesService, DecryptedNode } from './interface'; -import { initNodesModule } from './index'; import { NodesCache } from './cache'; -import { getMockLogger } from '../../tests/logger'; +import { initNodesModule } from './index'; +import { DecryptedNode, SharesService } from './interface'; function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial = {}): DecryptedNode { return { diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts index fd4f6fcc..48c4e507 100644 --- a/js/sdk/src/internal/nodes/index.ts +++ b/js/sdk/src/internal/nodes/index.ts @@ -1,24 +1,24 @@ -import { DriveAPIService } from '../apiService'; import { DriveCrypto } from '../../crypto'; import { - ProtonDriveEntitiesCache, - ProtonDriveCryptoCache, ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, ProtonDriveTelemetry, } from '../../interface'; +import { DriveAPIService } from '../apiService'; import { NodeAPIService } from './apiService'; import { NodesCache } from './cache'; import { NodesCryptoCache } from './cryptoCache'; -import { NodesCryptoService } from './cryptoService'; import { NodesCryptoReporter } from './cryptoReporter'; +import { NodesCryptoService } from './cryptoService'; +import { NodesEventsHandler } from './events'; import { SharesService } from './interface'; import { NodesAccess } from './nodesAccess'; import { NodesManagement } from './nodesManagement'; import { NodesRevisons } from './nodesRevisions'; -import { NodesEventsHandler } from './events'; -export type { DecryptedNode, DecryptedRevision } from './interface'; export { generateFileExtendedAttributes } from './extendedAttributes'; +export type { DecryptedNode, DecryptedRevision } from './interface'; /** * Provides facade for the whole nodes module. diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts index 72ce52e4..b274ae3b 100644 --- a/js/sdk/src/internal/nodes/interface.ts +++ b/js/sdk/src/internal/nodes/interface.ts @@ -1,16 +1,16 @@ import { PrivateKey, SessionKey } from '../../crypto'; import { - NodeEntity, - Result, - InvalidNameError, + AnonymousUser, Author, + InvalidNameError, MemberRole, - NodeType, - ThumbnailType, MetricVolumeType, + NodeEntity, + NodeType, + Result, Revision, RevisionState, - AnonymousUser, + ThumbnailType, } from '../../interface'; export type FilterOptions = { diff --git a/js/sdk/src/internal/nodes/nodeName.test.ts b/js/sdk/src/internal/nodes/nodeName.test.ts index 5ace64f2..b0a21886 100644 --- a/js/sdk/src/internal/nodes/nodeName.test.ts +++ b/js/sdk/src/internal/nodes/nodeName.test.ts @@ -1,4 +1,4 @@ -import { splitExtension, joinNameAndExtension } from './nodeName'; +import { joinNameAndExtension, splitExtension } from './nodeName'; describe('nodeName', () => { describe('splitExtension', () => { diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index ff1d72b9..f3275d1f 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -1,13 +1,13 @@ -import { getMockTelemetry } from '../../tests/telemetry'; import { PrivateKey } from '../../crypto'; import { DecryptionError, ProtonDriveError } from '../../errors'; +import { NodeType } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; import { NodeAPIService } from './apiService'; import { NodesCache } from './cache'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; +import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from './interface'; import { NodesAccess } from './nodesAccess'; -import { SharesService, DecryptedNode, DecryptedUnparsedNode, EncryptedNode, DecryptedNodeKeys } from './interface'; -import { NodeType } from '../../interface'; describe('nodesAccess', () => { let apiService: NodeAPIService; diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 00059a81..ab872176 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -1,6 +1,7 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey } from '../../crypto'; +import { DecryptionError, ProtonDriveError } from '../../errors'; import { InvalidNameError, Logger, @@ -11,10 +12,9 @@ import { resultError, resultOk, } from '../../interface'; -import { DecryptionError, ProtonDriveError } from '../../errors'; import { asyncIteratorMap } from '../asyncIteratorMap'; -import { getErrorMessage } from '../errors'; import { BatchLoading } from '../batchLoading'; +import { getErrorMessage } from '../errors'; import { makeNodeUid, splitNodeUid } from '../uids'; import { NodeAPIServiceBase } from './apiService'; import { NodesCacheBase } from './cache'; @@ -23,16 +23,16 @@ import { NodesCryptoService } from './cryptoService'; import { NodesDebouncer } from './debouncer'; import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from './extendedAttributes'; import { - SharesService, - EncryptedNode, - DecryptedUnparsedNode, DecryptedNode, DecryptedNodeKeys, + DecryptedUnparsedNode, + EncryptedNode, FilterOptions, NodeSigningKeys, + SharesService, } from './interface'; -import { validateNodeName } from './validations'; import { isProtonDocument, isProtonSheet } from './mediaTypes'; +import { validateNodeName } from './validations'; // This is the number of nodes that are loaded in parallel. // It is a trade-off between initial wait time and overhead of API calls. diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index e4b50203..4886a63e 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -1,12 +1,12 @@ +import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; +import { NodeResult, NodeResultWithError } from '../../interface'; import { NodeAPIService } from './apiService'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; -import { NodesAccess } from './nodesAccess'; +import { NodeOutOfSyncError } from './errors'; import { DecryptedNode } from './interface'; +import { NodesAccess } from './nodesAccess'; import { NodesManagement } from './nodesManagement'; -import { NodeResult, NodeResultWithError } from '../../interface'; -import { NodeOutOfSyncError } from './errors'; -import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; describe('NodesManagement', () => { let apiService: NodeAPIService; diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index b617f417..af6d5323 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -1,15 +1,15 @@ import { c } from 'ttag'; +import { AbortError, ValidationError } from '../../errors'; import { + InvalidNameError, MemberRole, - NodeType, NodeResult, + NodeResultWithError, NodeResultWithNewUid, + NodeType, resultOk, - InvalidNameError, - NodeResultWithError, } from '../../interface'; -import { AbortError, ValidationError } from '../../errors'; import { createErrorFromUnknown, getErrorMessage } from '../errors'; import { splitNodeUid } from '../uids'; import { NodeAPIServiceBase } from './apiService'; @@ -18,9 +18,9 @@ import { NodesCryptoService } from './cryptoService'; import { NodeOutOfSyncError } from './errors'; import { generateFolderExtendedAttributes } from './extendedAttributes'; import { DecryptedNode, EncryptedNode } from './interface'; -import { splitExtension, joinNameAndExtension } from './nodeName'; -import { NodesAccessBase } from './nodesAccess'; import { FOLDER_MEDIA_TYPE } from './mediaTypes'; +import { joinNameAndExtension, splitExtension } from './nodeName'; +import { NodesAccessBase } from './nodesAccess'; import { validateNodeName } from './validations'; const AVAILABLE_NAME_BATCH_SIZE = 10; diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index 3134d9ed..ab149f35 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -2,9 +2,9 @@ import { Logger } from '../../interface'; import { makeNodeUidFromRevisionUid } from '../uids'; import { NodeAPIServiceBase } from './apiService'; import { NodesCryptoService } from './cryptoService'; -import { NodesAccess } from './nodesAccess'; import { parseFileExtendedAttributes } from './extendedAttributes'; import { DecryptedRevision } from './interface'; +import { NodesAccess } from './nodesAccess'; /** * Provides access to revisions metadata. diff --git a/js/sdk/src/internal/photos/addToAlbum.ts b/js/sdk/src/internal/photos/addToAlbum.ts index b886ce3a..af05619f 100644 --- a/js/sdk/src/internal/photos/addToAlbum.ts +++ b/js/sdk/src/internal/photos/addToAlbum.ts @@ -6,8 +6,8 @@ import { splitNodeUid } from '../uids'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; import { MissingRelatedPhotosError } from './errors'; -import { PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; import { PhotosNodesAccess } from './nodes'; +import { PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; /** * The number of photos that are loaded in parallel to prepare the payloads. diff --git a/js/sdk/src/internal/photos/albumsCrypto.ts b/js/sdk/src/internal/photos/albumsCrypto.ts index f7bc37fd..41ab25ec 100644 --- a/js/sdk/src/internal/photos/albumsCrypto.ts +++ b/js/sdk/src/internal/photos/albumsCrypto.ts @@ -1,4 +1,5 @@ import { c } from 'ttag'; + import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto'; import { ValidationError } from '../../errors'; import { InvalidNameError, Result } from '../../interface'; diff --git a/js/sdk/src/internal/photos/albumsManager.test.ts b/js/sdk/src/internal/photos/albumsManager.test.ts index dbad011f..62491432 100644 --- a/js/sdk/src/internal/photos/albumsManager.test.ts +++ b/js/sdk/src/internal/photos/albumsManager.test.ts @@ -1,8 +1,8 @@ -import { NodeType } from '../../interface'; import { ValidationError } from '../../errors'; +import { NodeType } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; -import { AlbumsManager } from './albumsManager'; import { AlbumsCryptoService } from './albumsCrypto'; +import { AlbumsManager } from './albumsManager'; import { PhotosAPIService } from './apiService'; import { AlbumContainsPhotosNotInTimelineError } from './errors'; import { DecryptedPhotoNode } from './interface'; diff --git a/js/sdk/src/internal/photos/index.ts b/js/sdk/src/internal/photos/index.ts index 77fb20ae..013c5955 100644 --- a/js/sdk/src/internal/photos/index.ts +++ b/js/sdk/src/internal/photos/index.ts @@ -7,9 +7,9 @@ import { ProtonDriveTelemetry, } from '../../interface'; import { DriveAPIService } from '../apiService'; -import { NodesCryptoService } from '../nodes/cryptoService'; -import { NodesCryptoReporter } from '../nodes/cryptoReporter'; import { NodesCryptoCache } from '../nodes/cryptoCache'; +import { NodesCryptoReporter } from '../nodes/cryptoReporter'; +import { NodesCryptoService } from '../nodes/cryptoService'; import { NodesEventsHandler } from '../nodes/events'; import { NodesRevisons } from '../nodes/nodesRevisions'; import { ShareTargetType } from '../shares'; @@ -17,16 +17,16 @@ import { SharesCache } from '../shares/cache'; import { SharesCryptoCache } from '../shares/cryptoCache'; import { SharesCryptoService } from '../shares/cryptoService'; import { NodesService as UploadNodesService } from '../upload/interface'; -import { UploadTelemetry } from '../upload/telemetry'; import { UploadQueue } from '../upload/queue'; -import { AlbumsManager } from './albumsManager'; +import { UploadTelemetry } from '../upload/telemetry'; import { AlbumsCryptoService } from './albumsCrypto'; +import { AlbumsManager } from './albumsManager'; import { PhotosAPIService } from './apiService'; import { SharesService } from './interface'; -import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes'; +import { PhotosNodesAccess, PhotosNodesAPIService, PhotosNodesCache, PhotosNodesManagement } from './nodes'; +import { PhotosManager } from './photosManager'; import { PhotoSharesManager } from './shares'; import { PhotosTimeline } from './timeline'; -import { PhotosManager } from './photosManager'; import { PhotoFileUploader, PhotoUploadAPIService, @@ -35,7 +35,7 @@ import { PhotoUploadMetadata, } from './upload'; -export type { DecryptedPhotoNode, TimelineItem, AlbumItem } from './interface'; +export type { AlbumItem, DecryptedPhotoNode, TimelineItem } from './interface'; // Only photos and albums can be shared in photos volume. export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album]; diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts index 041bdd84..4f404c9c 100644 --- a/js/sdk/src/internal/photos/interface.ts +++ b/js/sdk/src/internal/photos/interface.ts @@ -1,6 +1,6 @@ import { PrivateKey } from '../../crypto'; -import { MetricVolumeType, PhotoAttributes, AlbumAttributes, PhotoTag } from '../../interface'; -import { DecryptedNode, EncryptedNode, DecryptedUnparsedNode } from '../nodes/interface'; +import { AlbumAttributes, MetricVolumeType, PhotoAttributes, PhotoTag } from '../../interface'; +import { DecryptedNode, DecryptedUnparsedNode, EncryptedNode } from '../nodes/interface'; import { EncryptedShare } from '../shares'; export interface SharesService { diff --git a/js/sdk/src/internal/photos/nodes.test.ts b/js/sdk/src/internal/photos/nodes.test.ts index efa4b7f5..2b567db3 100644 --- a/js/sdk/src/internal/photos/nodes.test.ts +++ b/js/sdk/src/internal/photos/nodes.test.ts @@ -1,10 +1,10 @@ import { MemoryCache } from '../../cache'; -import { NodeType, MemberRole } from '../../interface'; +import { MemberRole, NodeType } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { getMockTelemetry } from '../../tests/telemetry'; import { DriveAPIService } from '../apiService'; import { DecryptedPhotoNode } from './interface'; -import { PhotosNodesAPIService, PhotosNodesCache, PhotosNodesAccess, PhotosNodesCryptoService } from './nodes'; +import { PhotosNodesAccess, PhotosNodesAPIService, PhotosNodesCache, PhotosNodesCryptoService } from './nodes'; function generateAPINode() { return { diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 5eafcc30..4cbbd17f 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -2,14 +2,14 @@ import { PrivateKey } from '../../crypto'; import { DecryptionError } from '../../errors'; import { NodeType } from '../../interface'; import { drivePaths } from '../apiService'; -import { NodeAPIServiceBase, linkToEncryptedNode, linkToEncryptedNodeBaseMetadata } from '../nodes/apiService'; -import { NodesCacheBase, serialiseNode, deserialiseNode } from '../nodes/cache'; +import { linkToEncryptedNode, linkToEncryptedNodeBaseMetadata, NodeAPIServiceBase } from '../nodes/apiService'; +import { deserialiseNode, NodesCacheBase, serialiseNode } from '../nodes/cache'; import { NodesCryptoService } from '../nodes/cryptoService'; import { DecryptedNodeKeys } from '../nodes/interface'; import { NodesAccessBase, parseNode as parseNodeBase } from '../nodes/nodesAccess'; import { NodesManagementBase } from '../nodes/nodesManagement'; import { makeNodeUid } from '../uids'; -import { EncryptedPhotoNode, DecryptedPhotoNode, DecryptedUnparsedPhotoNode } from './interface'; +import { DecryptedPhotoNode, DecryptedUnparsedPhotoNode, EncryptedPhotoNode } from './interface'; type PostLoadLinksMetadataRequest = Extract< drivePaths['/drive/photos/volumes/{volumeID}/links']['post']['requestBody'], diff --git a/js/sdk/src/internal/photos/photosManager.test.ts b/js/sdk/src/internal/photos/photosManager.test.ts index f0c600cd..331b367c 100644 --- a/js/sdk/src/internal/photos/photosManager.test.ts +++ b/js/sdk/src/internal/photos/photosManager.test.ts @@ -1,11 +1,11 @@ -import { resultOk, PhotoTag } from '../../interface'; +import { PhotoTag, resultOk } from '../../interface'; import { getMockLogger } from '../../tests/logger'; -import { PhotosManager, UpdatePhotoSettings } from './photosManager'; -import { PhotosAPIService } from './apiService'; import { AlbumsCryptoService } from './albumsCrypto'; -import { PhotosNodesAccess } from './nodes'; -import { DecryptedPhotoNode } from './interface'; +import { PhotosAPIService } from './apiService'; import { MissingRelatedPhotosError } from './errors'; +import { DecryptedPhotoNode } from './interface'; +import { PhotosNodesAccess } from './nodes'; +import { PhotosManager, UpdatePhotoSettings } from './photosManager'; function createMockPhotoNode(uid: string, overrides: Partial = {}): DecryptedPhotoNode { return { diff --git a/js/sdk/src/internal/photos/photosManager.ts b/js/sdk/src/internal/photos/photosManager.ts index 41944387..c32afe6e 100644 --- a/js/sdk/src/internal/photos/photosManager.ts +++ b/js/sdk/src/internal/photos/photosManager.ts @@ -3,12 +3,12 @@ import { c } from 'ttag'; import { AbortError } from '../../errors'; import { Logger, NodeResultWithError, PhotoTag } from '../../interface'; import { batch } from '../batch'; -import { PhotosAPIService } from './apiService'; -import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; -import { PhotosNodesAccess } from './nodes'; -import { AlbumsCryptoService } from './albumsCrypto'; import { createBatches } from './addToAlbum'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { PhotosAPIService } from './apiService'; import { MissingRelatedPhotosError } from './errors'; +import { PhotosNodesAccess } from './nodes'; +import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; /** * The number of photos that are loaded in parallel to prepare the payloads. diff --git a/js/sdk/src/internal/photos/photosTransferPayloadBuilder.test.ts b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.test.ts index 951debc1..aa3555c4 100644 --- a/js/sdk/src/internal/photos/photosTransferPayloadBuilder.test.ts +++ b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.test.ts @@ -2,8 +2,8 @@ import { ValidationError } from '../../errors'; import { resultOk } from '../../interface'; import { AlbumsCryptoService } from './albumsCrypto'; import { DecryptedPhotoNode } from './interface'; -import { PhotoTransferPayloadBuilder } from './photosTransferPayloadBuilder'; import { PhotosNodesAccess } from './nodes'; +import { PhotoTransferPayloadBuilder } from './photosTransferPayloadBuilder'; /** * Helper to create a mock photo node with minimal required properties. diff --git a/js/sdk/src/internal/photos/timeline.test.ts b/js/sdk/src/internal/photos/timeline.test.ts index cce42417..80e39ec4 100644 --- a/js/sdk/src/internal/photos/timeline.test.ts +++ b/js/sdk/src/internal/photos/timeline.test.ts @@ -1,5 +1,5 @@ -import { getMockLogger } from '../../tests/logger'; import { DriveCrypto } from '../../crypto'; +import { getMockLogger } from '../../tests/logger'; import { makeNodeUid } from '../uids'; import { PhotosAPIService } from './apiService'; import { PhotosNodesAccess } from './nodes'; diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts index b2647537..70bb6e68 100644 --- a/js/sdk/src/internal/photos/upload.ts +++ b/js/sdk/src/internal/photos/upload.ts @@ -1,11 +1,11 @@ import { DriveCrypto } from '../../crypto'; import { - ProtonDriveTelemetry, - UploadMetadata, - Thumbnail, AnonymousUser, FeatureFlagProvider, PhotoTag, + ProtonDriveTelemetry, + Thumbnail, + UploadMetadata, } from '../../interface'; import { DriveAPIService, drivePaths } from '../apiService'; import { generateFileExtendedAttributes } from '../nodes'; diff --git a/js/sdk/src/internal/sdkEvents.ts b/js/sdk/src/internal/sdkEvents.ts index 775c722b..5403c6b9 100644 --- a/js/sdk/src/internal/sdkEvents.ts +++ b/js/sdk/src/internal/sdkEvents.ts @@ -1,4 +1,4 @@ -import { ProtonDriveTelemetry, Logger, SDKEvent } from '../interface'; +import { Logger, ProtonDriveTelemetry, SDKEvent } from '../interface'; export class SDKEvents { private logger: Logger; diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts index b6e0a56f..d885e698 100644 --- a/js/sdk/src/internal/shares/apiService.ts +++ b/js/sdk/src/internal/shares/apiService.ts @@ -1,6 +1,6 @@ import { DriveAPIService, drivePaths } from '../apiService'; import { makeMemberUid } from '../uids'; -import { EncryptedShare, EncryptedRootShare, EncryptedShareCrypto, ShareType } from './interface'; +import { EncryptedRootShare, EncryptedShare, EncryptedShareCrypto, ShareType } from './interface'; type PostCreateVolumeRequest = Extract< drivePaths['/drive/volumes']['post']['requestBody'], diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts index 772c9318..6e278642 100644 --- a/js/sdk/src/internal/shares/cache.ts +++ b/js/sdk/src/internal/shares/cache.ts @@ -1,4 +1,4 @@ -import { ProtonDriveEntitiesCache, Logger } from '../../interface'; +import { Logger, ProtonDriveEntitiesCache } from '../../interface'; import { getErrorMessage } from '../errors'; import { Volume } from './interface'; diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts index f858057f..92fb4abf 100644 --- a/js/sdk/src/internal/shares/cryptoCache.test.ts +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -1,5 +1,5 @@ -import { PrivateKey, SessionKey } from '../../crypto'; import { MemoryCache } from '../../cache'; +import { PrivateKey, SessionKey } from '../../crypto'; import { CachedCryptoMaterial } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { SharesCryptoCache } from './cryptoCache'; diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts index b3db086b..a683058c 100644 --- a/js/sdk/src/internal/shares/cryptoService.test.ts +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -1,8 +1,8 @@ import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; import { ProtonDriveAccount, ProtonDriveTelemetry } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; -import { EncryptedRootShare, ShareType } from './interface'; import { SharesCryptoService } from './cryptoService'; +import { EncryptedRootShare, ShareType } from './interface'; describe('SharesCryptoService', () => { let telemetry: ProtonDriveTelemetry; diff --git a/js/sdk/src/internal/shares/cryptoService.ts b/js/sdk/src/internal/shares/cryptoService.ts index 73768e41..12ac2665 100644 --- a/js/sdk/src/internal/shares/cryptoService.ts +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -1,20 +1,20 @@ +import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from '../../crypto'; import { + Logger, + MetricVolumeType, ProtonDriveAccount, - resultOk, - resultError, + ProtonDriveTelemetry, Result, + resultError, + resultOk, UnverifiedAuthorError, - ProtonDriveTelemetry, - Logger, - MetricVolumeType, } from '../../interface'; -import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from '../../crypto'; import { getVerificationMessage, isNotApplicationError } from '../errors'; import { - EncryptedRootShare, DecryptedRootShare, - EncryptedShareCrypto, DecryptedShareKey, + EncryptedRootShare, + EncryptedShareCrypto, ShareType, } from './interface'; diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts index ddfe6c53..040b0e6c 100644 --- a/js/sdk/src/internal/shares/index.ts +++ b/js/sdk/src/internal/shares/index.ts @@ -1,19 +1,19 @@ +import { DriveCrypto } from '../../crypto'; import { - ProtonDriveEntitiesCache, - ProtonDriveCryptoCache, ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, ProtonDriveTelemetry, } from '../../interface'; -import { DriveCrypto } from '../../crypto'; import { DriveAPIService } from '../apiService'; import { SharesAPIService } from './apiService'; -import { SharesCryptoCache } from './cryptoCache'; import { SharesCache } from './cache'; +import { SharesCryptoCache } from './cryptoCache'; import { SharesCryptoService } from './cryptoService'; import { SharesManager } from './manager'; -export { ShareTargetType } from './interface'; export type { EncryptedShare } from './interface'; +export { ShareTargetType } from './interface'; /** * Provides facade for the whole shares module. diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts index d2bf2e2d..d2ccded0 100644 --- a/js/sdk/src/internal/shares/manager.ts +++ b/js/sdk/src/internal/shares/manager.ts @@ -1,11 +1,11 @@ -import { Logger, MetricVolumeType, ProtonDriveAccount } from '../../interface'; import { PrivateKey } from '../../crypto'; +import { Logger, MetricVolumeType, ProtonDriveAccount } from '../../interface'; import { NotFoundAPIError } from '../apiService'; import { SharesAPIService } from './apiService'; import { SharesCache } from './cache'; import { SharesCryptoCache } from './cryptoCache'; import { SharesCryptoService } from './cryptoService'; -import { VolumeShareNodeIDs, EncryptedShare, EncryptedRootShare } from './interface'; +import { EncryptedRootShare, EncryptedShare, VolumeShareNodeIDs } from './interface'; /** * Provides high-level actions for managing shares. diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts index 4942325f..41d460f0 100644 --- a/js/sdk/src/internal/sharing/apiService.ts +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -1,31 +1,31 @@ import { SRPVerifier } from '../../crypto'; -import { MemberRole, NonProtonInvitationState, Logger } from '../../interface'; +import { Logger, MemberRole, NonProtonInvitationState } from '../../interface'; import { DriveAPIService, drivePaths, + memberRoleToPermission, nodeTypeNumberToNodeType, permissionsToMemberRole, - memberRoleToPermission, } from '../apiService'; import { ShareTargetType } from '../shares'; import { - makeNodeUid, - splitNodeUid, makeInvitationUid, - splitInvitationUid, makeMemberUid, - splitMemberUid, + makeNodeUid, makePublicLinkUid, + splitInvitationUid, + splitMemberUid, + splitNodeUid, splitPublicLinkUid, } from '../uids'; import { - EncryptedInvitationRequest, + EncryptedBookmark, + EncryptedExternalInvitation, + EncryptedExternalInvitationRequest, EncryptedInvitation, + EncryptedInvitationRequest, EncryptedInvitationWithNode, - EncryptedExternalInvitation, EncryptedMember, - EncryptedBookmark, - EncryptedExternalInvitationRequest, EncryptedPublicLink, EncryptedPublicLinkCrypto, } from './interface'; diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts index 63d2356b..f0d24907 100644 --- a/js/sdk/src/internal/sharing/cryptoService.test.ts +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -8,8 +8,8 @@ import { resultOk, } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; -import { SharesService } from './interface'; import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService'; +import { SharesService } from './interface'; describe('SharingCryptoService', () => { let telemetry: ProtonDriveTelemetry; diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index b8ff916b..02f93cec 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -1,4 +1,3 @@ -import bcrypt from 'bcryptjs'; import { c } from 'ttag'; import { @@ -6,42 +5,37 @@ import { PrivateKey, SessionKey, SRPVerifier, - uint8ArrayToBase64String, VERIFICATION_STATUS, } from '../../crypto'; +import { DecryptionError } from '../../errors'; import { + Author, + InvalidNameError, + Member, + MetricVolumeType, + NonProtonInvitation, ProtonDriveAccount, + ProtonDriveTelemetry, ProtonInvitation, ProtonInvitationWithNode, - NonProtonInvitation, - Author, Result, - Member, - UnverifiedAuthorError, resultError, resultOk, - InvalidNameError, - ProtonDriveTelemetry, - MetricVolumeType, + UnverifiedAuthorError, } from '../../interface'; -import { validateNodeName } from '../nodes/validations'; import { getErrorMessage, getVerificationMessage } from '../errors'; +import { validateNodeName } from '../nodes/validations'; import { EncryptedShare } from '../shares'; import { + EncryptedBookmark, + EncryptedExternalInvitation, EncryptedInvitation, EncryptedInvitationWithNode, - EncryptedExternalInvitation, EncryptedMember, EncryptedPublicLink, PublicLinkWithCreatorEmail, - EncryptedBookmark, SharesService, } from './interface'; -import { DecryptionError } from '../../errors'; - -// Version 2 of bcrypt with 2**10 rounds. -// https://en.wikipedia.org/wiki/Bcrypt#Description -const BCRYPT_PREFIX = '$2y$10$'; export const PUBLIC_LINK_GENERATED_PASSWORD_LENGTH = 12; @@ -325,13 +319,10 @@ export class SharingCryptoService { const address = await this.account.getOwnAddress(creatorEmail); const addressKey = address.keys[address.primaryKeyIndex].key; - const { base64Salt: base64SharePasswordSalt, bcryptPassphrase } = - await this.computeKeySaltAndPassphrase(password); - const { base64SharePassphraseKeyPacket, armoredPassword, srp } = + const { base64SharePasswordSalt, base64SharePassphraseKeyPacket, armoredPassword, srp } = await this.driveCrypto.encryptPublicLinkPasswordAndSessionKey( password, addressKey, - bcryptPassphrase, shareSessionKey, ); @@ -357,22 +348,6 @@ export class SharingCryptoService { return result; } - private async computeKeySaltAndPassphrase(password: string) { - if (!password) { - throw new Error('Password required.'); - } - - const salt = crypto.getRandomValues(new Uint8Array(16)); - const hash: string = await bcrypt.hash(password, BCRYPT_PREFIX + bcrypt.encodeBase64(salt, 16)); - // Remove bcrypt prefix and salt (first 29 characters) - const bcryptPassphrase = hash.slice(29); - - return { - base64Salt: uint8ArrayToBase64String(salt), - bcryptPassphrase, - }; - } - async decryptPublicLink(encryptedPublicLink: EncryptedPublicLink): Promise { const address = await this.account.getOwnAddress(encryptedPublicLink.creatorEmail); const addressKeys = address.keys.map(({ key }) => key); diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index 86d0c3dc..884f1745 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -1,10 +1,10 @@ import { getMockLogger } from '../../tests/logger'; import { DriveEvent, DriveEventType } from '../events'; +import { SharesManager } from '../shares/manager'; import { SharingCache } from './cache'; -import { SharingAccess } from './sharingAccess'; import { SharingEventHandler } from './events'; -import { SharesManager } from '../shares/manager'; import { NodesService } from './interface'; +import { SharingAccess } from './sharingAccess'; // FIXME: test tree_refresh and tree_remove diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index 8dadab4e..af4f29b0 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -1,14 +1,14 @@ -import { ProtonDriveAccount, ProtonDriveEntitiesCache, ProtonDriveTelemetry } from '../../interface'; import { DriveCrypto } from '../../crypto'; +import { ProtonDriveAccount, ProtonDriveEntitiesCache, ProtonDriveTelemetry } from '../../interface'; import { DriveAPIService } from '../apiService'; import { ShareTargetType } from '../shares'; import { SharingAPIService } from './apiService'; import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; +import { SharingEventHandler } from './events'; +import { NodesService, SharesService } from './interface'; import { SharingAccess } from './sharingAccess'; import { SharingManagement } from './sharingManagement'; -import { SharesService, NodesService } from './interface'; -import { SharingEventHandler } from './events'; // Root shares are not allowed to be shared. // Photos and Albums are not supported in main volume (core Drive). diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts index 3b36482e..61be2bb1 100644 --- a/js/sdk/src/internal/sharing/interface.ts +++ b/js/sdk/src/internal/sharing/interface.ts @@ -1,7 +1,7 @@ -import { NodeType, MemberRole, NonProtonInvitationState, MissingNode, ShareResult, PublicLink } from '../../interface'; import { PrivateKey, SessionKey } from '../../crypto'; -import { EncryptedShare } from '../shares'; +import { MemberRole, MissingNode, NodeType, NonProtonInvitationState, PublicLink, ShareResult } from '../../interface'; import { DecryptedNode } from '../nodes'; +import { EncryptedShare } from '../shares'; export enum SharingType { SharedByMe = 'sharedByMe', diff --git a/js/sdk/src/internal/sharing/sharingAccess.test.ts b/js/sdk/src/internal/sharing/sharingAccess.test.ts index ee327cbe..05f9136c 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.test.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -1,11 +1,11 @@ -import { getMockLogger } from '../../tests/logger'; -import { NodeType, resultError, resultOk, MemberRole } from '../../interface'; import { ValidationError } from '../../errors'; +import { MemberRole, NodeType, resultError, resultOk } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; import { SharingAPIService } from './apiService'; import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; -import { SharesService, NodesService } from './interface'; -import { SharingAccess, BATCH_LOADING_SIZE } from './sharingAccess'; +import { NodesService, SharesService } from './interface'; +import { BATCH_LOADING_SIZE, SharingAccess } from './sharingAccess'; describe('SharingAccess', () => { let apiService: SharingAPIService; diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 8e761c8c..87cac928 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -1,13 +1,13 @@ import { c } from 'ttag'; -import { MaybeBookmark, ProtonInvitationWithNode, resultError, resultOk } from '../../interface'; import { ValidationError } from '../../errors'; -import { DecryptedNode } from '../nodes'; +import { MaybeBookmark, ProtonInvitationWithNode, resultError, resultOk } from '../../interface'; import { BatchLoading } from '../batchLoading'; +import { DecryptedNode } from '../nodes'; import { SharingAPIService } from './apiService'; import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; -import { SharesService, NodesService } from './interface'; +import { NodesService, SharesService } from './interface'; // This is the number of nodes that are loaded in parallel. // It is a trade-off between initial wait time and overhead of API calls. diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 7de59a60..79593031 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -1,4 +1,4 @@ -import { getMockLogger } from '../../tests/logger'; +import { ValidationError } from '../../errors'; import { Logger, Member, @@ -10,13 +10,13 @@ import { PublicLink, resultOk, } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { ErrorCode } from '../apiService'; import { SharingAPIService } from './apiService'; import { SharingCache } from './cache'; import { SharingCryptoService } from './cryptoService'; -import { SharesService, NodesService } from './interface'; +import { NodesService, SharesService } from './interface'; import { SharingManagement } from './sharingManagement'; -import { ValidationError } from '../../errors'; -import { ErrorCode } from '../apiService'; const DEFAULT_SHARE_ID = 'shareId'; diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 1c24173e..30f5fc92 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -4,24 +4,24 @@ import { PrivateKey, SessionKey } from '../../crypto'; import { ValidationError } from '../../errors'; import { Logger, + Member, MemberRole, - ShareNodeSettings, - UnshareNodeSettings, - ShareResult, - ProtonInvitation, NonProtonInvitation, - Member, - resultOk, ProtonDriveAccount, + ProtonInvitation, + resultOk, + ShareNodeSettings, SharePublicLinkSettingsObject, + ShareResult, + UnshareNodeSettings, } from '../../interface'; import { ErrorCode } from '../apiService'; -import { splitNodeUid, splitInvitationUid } from '../uids'; import { getErrorMessage } from '../errors'; +import { splitInvitationUid, splitNodeUid } from '../uids'; import { SharingAPIService } from './apiService'; -import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService'; -import { SharesService, NodesService, ShareResultWithCreatorEmail, PublicLinkWithCreatorEmail } from './interface'; import { SharingCache } from './cache'; +import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService'; +import { NodesService, PublicLinkWithCreatorEmail, ShareResultWithCreatorEmail, SharesService } from './interface'; interface InternalShareResult extends ShareResultWithCreatorEmail { share: Share; diff --git a/js/sdk/src/internal/sharingPublic/cryptoReporter.ts b/js/sdk/src/internal/sharingPublic/cryptoReporter.ts index e3fd7be3..ed7a0941 100644 --- a/js/sdk/src/internal/sharingPublic/cryptoReporter.ts +++ b/js/sdk/src/internal/sharingPublic/cryptoReporter.ts @@ -1,18 +1,18 @@ import { c } from 'ttag'; import { VERIFICATION_STATUS } from '../../crypto'; -import { getVerificationMessage, isNotApplicationError } from '../errors'; import { - resultOk, - resultError, - Author, AnonymousUser, - ProtonDriveTelemetry, + Author, + Logger, + MetricsDecryptionErrorField, MetricVerificationErrorField, MetricVolumeType, - MetricsDecryptionErrorField, - Logger, + ProtonDriveTelemetry, + resultError, + resultOk, } from '../../interface'; +import { getVerificationMessage, isNotApplicationError } from '../errors'; export class SharingPublicCryptoReporter { private logger: Logger; diff --git a/js/sdk/src/internal/sharingPublic/index.ts b/js/sdk/src/internal/sharingPublic/index.ts index 5f04e9a0..b243e2fc 100644 --- a/js/sdk/src/internal/sharingPublic/index.ts +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -1,21 +1,21 @@ import { DriveCrypto, PrivateKey } from '../../crypto'; import { - ProtonDriveCryptoCache, - ProtonDriveTelemetry, + MemberRole, ProtonDriveAccount, + ProtonDriveCryptoCache, ProtonDriveEntitiesCache, - MemberRole, + ProtonDriveTelemetry, } from '../../interface'; import { DriveAPIService } from '../apiService'; -import { SharingPublicNodesAPIService, SharingPublicNodesCryptoService } from './nodes'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesRevisons } from '../nodes/nodesRevisions'; +import { SharingPublicAPIService } from './apiService'; import { SharingPublicCryptoReporter } from './cryptoReporter'; +import { SharingPublicNodesAPIService, SharingPublicNodesCryptoService } from './nodes'; import { SharingPublicNodesAccess, SharingPublicNodesManagement } from './nodes'; -import { SharingPublicSharesManager } from './shares'; -import { SharingPublicAPIService } from './apiService'; import { NodesSecurity } from './nodesSecurity'; +import { SharingPublicSharesManager } from './shares'; export { SharingPublicSessionManager } from './session/manager'; export { getTokenAndPasswordFromUrl } from './session/url'; diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index 5f8a818b..2d0d536a 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -4,14 +4,14 @@ import { PrivateKey } from '../../crypto'; import { ValidationError } from '../../errors'; import { type Logger, MemberRole, NodeResult, ProtonDriveTelemetry } from '../../interface'; import { type DriveAPIService, drivePaths } from '../apiService'; -import { NodeAPIService, linkToEncryptedNode } from '../nodes/apiService'; +import { linkToEncryptedNode, NodeAPIService } from '../nodes/apiService'; import { NodesCache } from '../nodes/cache'; import { NodesCryptoCache } from '../nodes/cryptoCache'; import { NodesCryptoService } from '../nodes/cryptoService'; -import { DecryptedNode, DecryptedNodeKeys, NodeSigningKeys, EncryptedNode } from '../nodes/interface'; +import { DecryptedNode, DecryptedNodeKeys, EncryptedNode, NodeSigningKeys } from '../nodes/interface'; +import { isProtonDocument, isProtonSheet } from '../nodes/mediaTypes'; import { NodesAccess } from '../nodes/nodesAccess'; import { NodesManagement } from '../nodes/nodesManagement'; -import { isProtonDocument, isProtonSheet } from '../nodes/mediaTypes'; import { validateNodeName } from '../nodes/validations'; import { makeNodeUid, splitNodeUid } from '../uids'; import { SharingPublicSharesManager } from './shares'; diff --git a/js/sdk/src/internal/sharingPublic/session/apiService.ts b/js/sdk/src/internal/sharingPublic/session/apiService.ts index 96261ee2..4f46e0fc 100644 --- a/js/sdk/src/internal/sharingPublic/session/apiService.ts +++ b/js/sdk/src/internal/sharingPublic/session/apiService.ts @@ -1,7 +1,7 @@ import { Logger } from '../../../interface'; import { DriveAPIService, drivePaths, permissionsToMemberRole } from '../../apiService'; import { makeNodeUid } from '../../uids'; -import { PublicLinkInfo, PublicLinkSrpAuth, PublicLinkSession, EncryptedShareCrypto } from './interface'; +import { EncryptedShareCrypto, PublicLinkInfo, PublicLinkSession, PublicLinkSrpAuth } from './interface'; type GetPublicLinkInfoResponse = drivePaths['/drive/urls/{token}/info']['get']['responses']['200']['content']['application/json']; diff --git a/js/sdk/src/internal/sharingPublic/session/manager.ts b/js/sdk/src/internal/sharingPublic/session/manager.ts index 9b4c4645..0f8620e3 100644 --- a/js/sdk/src/internal/sharingPublic/session/manager.ts +++ b/js/sdk/src/internal/sharingPublic/session/manager.ts @@ -1,5 +1,5 @@ -import { Logger, MemberRole, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../../interface'; import { DriveCrypto, PrivateKey, SRPModule } from '../../../crypto'; +import { Logger, MemberRole, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../../interface'; import { DriveAPIService, permissionsToMemberRole } from '../../apiService'; import { SharingPublicSessionAPIService } from './apiService'; import { SharingPublicSessionHttpClient } from './httpClient'; diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts index 920f510e..17595df4 100644 --- a/js/sdk/src/internal/upload/apiService.ts +++ b/js/sdk/src/internal/upload/apiService.ts @@ -1,11 +1,10 @@ import { c } from 'ttag'; -import { base64StringToUint8Array, uint8ArrayToBase64String } from '../../crypto'; import { AnonymousUser } from '../../interface'; +import { ThumbnailType } from '../../interface'; import { APICodeError, DriveAPIService, drivePaths, isCodeOk } from '../apiService'; -import { splitNodeUid, makeNodeUid, splitNodeRevisionUid, makeNodeRevisionUid } from '../uids'; +import { makeNodeRevisionUid, makeNodeUid, splitNodeRevisionUid, splitNodeUid } from '../uids'; import { UploadTokens } from './interface'; -import { ThumbnailType } from '../../interface'; type PostCreateDraftRequest = Extract< drivePaths['/drive/v2/volumes/{volumeID}/files']['post']['requestBody'], @@ -171,7 +170,7 @@ export class UploadAPIService { ); return { - verificationCode: base64StringToUint8Array(result.VerificationCode), + verificationCode: Uint8Array.fromBase64(result.VerificationCode), base64ContentKeyPacket: result.ContentKeyPacket, }; } @@ -210,7 +209,7 @@ export class UploadAPIService { Index: block.index, EncSignature: block.armoredSignature, Verifier: { - Token: uint8ArrayToBase64String(block.verificationToken), + Token: block.verificationToken.toBase64(), }, })), ThumbnailList: (blocks.thumbnails || []).map((block) => ({ @@ -365,7 +364,7 @@ export class UploadAPIService { ManifestSignature: content.armoredManifestSignature, ContentBlockEncSignature: content.block ? content.block.armoredSignature : null, ContentBlockVerificationToken: content.block - ? uint8ArrayToBase64String(content.block.verificationToken) + ? content.block.verificationToken.toBase64() : null, XAttr: metadata.armoredExtendedAttributes, ChecksumVerified: content.checksumVerified || false, @@ -433,7 +432,7 @@ export class UploadAPIService { ManifestSignature: content.armoredManifestSignature, ContentBlockEncSignature: content.block ? content.block.armoredSignature : null, ContentBlockVerificationToken: content.block - ? uint8ArrayToBase64String(content.block.verificationToken) + ? content.block.verificationToken.toBase64() : null, XAttr: metadata.armoredExtendedAttributes, ChecksumVerified: content.checksumVerified || false, diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts index c704635c..ed0139f9 100644 --- a/js/sdk/src/internal/upload/cryptoService.ts +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -1,14 +1,16 @@ import { c } from 'ttag'; +import { computeSHA256 } from '@protontech/crypto/subtle/hash.ts'; + import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto'; import { IntegrityError } from '../../errors'; import { - Thumbnail, AnonymousUser, FeatureFlagProvider, FeatureFlags, - ProtonDriveTelemetry, Logger, + ProtonDriveTelemetry, + Thumbnail, } from '../../interface'; import { EncryptedBlock, @@ -133,14 +135,14 @@ export class UploadCryptoService { nodeRevisionDraftKeys.signingKeys.contentSigningKey, ); - const digestPromise = crypto.subtle.digest('SHA-256', encryptedData); + const digestPromise = computeSHA256(encryptedData); return { type: thumbnail.type, encryptedData: encryptedData, originalSize: thumbnail.thumbnail.length, encryptedSize: encryptedData.length, - hashPromise: digestPromise.then((digest) => new Uint8Array(digest)), + hashPromise: digestPromise, }; } @@ -158,7 +160,7 @@ export class UploadCryptoService { nodeRevisionDraftKeys.contentKeyPacketSessionKey, nodeRevisionDraftKeys.signingKeys.contentSigningKey, ); - const digestPromise = crypto.subtle.digest('SHA-256', encryptedData); + const digestPromise = computeSHA256(encryptedData); const { verificationToken } = await verifyBlock(encryptedData); return { @@ -168,7 +170,7 @@ export class UploadCryptoService { verificationToken, originalSize: block.length, encryptedSize: encryptedData.length, - hashPromise: digestPromise.then((digest) => new Uint8Array(digest)), + hashPromise: digestPromise, }; } diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts index fcff916c..05d9f67b 100644 --- a/js/sdk/src/internal/upload/fileUploader.test.ts +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -1,12 +1,12 @@ import { Thumbnail, UploadMetadata } from '../../interface'; -import { FileUploader } from './fileUploader'; -import { UploadTelemetry } from './telemetry'; import { UploadAPIService } from './apiService'; -import { UploadCryptoService } from './cryptoService'; -import { UploadController } from './controller'; import { BlockVerifier } from './blockVerifier'; +import { UploadController } from './controller'; +import { UploadCryptoService } from './cryptoService'; +import { FileUploader } from './fileUploader'; import { NodeRevisionDraft } from './interface'; import { UploadManager } from './manager'; +import { UploadTelemetry } from './telemetry'; const BLOCK_ENCRYPTION_OVERHEAD = 10000; diff --git a/js/sdk/src/internal/upload/index.ts b/js/sdk/src/internal/upload/index.ts index 462710ef..3249641f 100644 --- a/js/sdk/src/internal/upload/index.ts +++ b/js/sdk/src/internal/upload/index.ts @@ -1,10 +1,10 @@ -import { FeatureFlagProvider, FeatureFlags, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { DriveCrypto } from '../../crypto'; import type { FileUploader } from '../../interface'; +import { FeatureFlagProvider, FeatureFlags, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; import { DriveAPIService } from '../apiService'; -import { DriveCrypto } from '../../crypto'; import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; -import { FileUploader as FileUploaderClass, FileRevisionUploader } from './fileUploader'; +import { FileRevisionUploader, FileUploader as FileUploaderClass } from './fileUploader'; import { NodesService, SharesService } from './interface'; import { UploadManager } from './manager'; import { UploadQueue } from './queue'; diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts index f92c79bb..744178f9 100644 --- a/js/sdk/src/internal/upload/interface.ts +++ b/js/sdk/src/internal/upload/interface.ts @@ -1,6 +1,5 @@ import { PrivateKey, SessionKey } from '../../crypto'; - -import { MetricVolumeType, ThumbnailType, Result, Revision, AnonymousUser } from '../../interface'; +import { AnonymousUser, MetricVolumeType, Result, Revision, ThumbnailType } from '../../interface'; import { DecryptedNode } from '../nodes'; export type NodeRevisionDraft = { diff --git a/js/sdk/src/internal/upload/manager.ts b/js/sdk/src/internal/upload/manager.ts index d782cdec..fbbc7fdb 100644 --- a/js/sdk/src/internal/upload/manager.ts +++ b/js/sdk/src/internal/upload/manager.ts @@ -1,15 +1,15 @@ import { c } from 'ttag'; import { PrivateKey, SessionKey } from '../../crypto'; +import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; import { Logger, ProtonDriveTelemetry, ThumbnailType, UploadMetadata } from '../../interface'; -import { ValidationError, NodeWithSameNameExistsValidationError } from '../../errors'; +import { reduceSizePrecision } from '../../telemetry'; import { ErrorCode } from '../apiService'; import { generateFileExtendedAttributes } from '../nodes'; +import { makeNodeUid, splitNodeUid } from '../uids'; import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; -import { NodeRevisionDraft, NodesService, NodeCrypto } from './interface'; -import { makeNodeUid, splitNodeUid } from '../uids'; -import { reduceSizePrecision } from '../../telemetry'; +import { NodeCrypto, NodeRevisionDraft, NodesService } from './interface'; /** * UploadManager is responsible for creating and deleting draft nodes diff --git a/js/sdk/src/internal/upload/smallFileUploader.test.ts b/js/sdk/src/internal/upload/smallFileUploader.test.ts index 0ab9fd28..4ef81ec1 100644 --- a/js/sdk/src/internal/upload/smallFileUploader.test.ts +++ b/js/sdk/src/internal/upload/smallFileUploader.test.ts @@ -1,12 +1,12 @@ import { IntegrityError } from '../../errors'; import { Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; -import { SmallFileUploader, SmallFileRevisionUploader } from './smallFileUploader'; -import { UploadTelemetry } from './telemetry'; +import { mergeUint8Arrays } from '../utils'; import { UploadAPIService } from './apiService'; import { UploadCryptoService } from './cryptoService'; -import { UploadManager } from './manager'; import { NodeCrypto } from './interface'; -import { mergeUint8Arrays } from '../utils'; +import { UploadManager } from './manager'; +import { SmallFileRevisionUploader, SmallFileUploader } from './smallFileUploader'; +import { UploadTelemetry } from './telemetry'; const MOCK_BLOCK_HASH = new Uint8Array(32).fill(4); const MOCK_VERIFICATION_TOKEN = new Uint8Array(16).fill(5); diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 87085f74..2292f1c0 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -1,15 +1,15 @@ -import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; import { IntegrityError } from '../../errors'; +import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { APIHTTPError, HTTPErrorCode } from '../apiService'; -import { FILE_CHUNK_SIZE, StreamUploader } from './streamUploader'; -import { UploadTelemetry } from './telemetry'; import { UploadAPIService } from './apiService'; -import { UploadCryptoService } from './cryptoService'; -import { UploadController } from './controller'; import { BlockVerifier } from './blockVerifier'; +import { UploadController } from './controller'; +import { UploadCryptoService } from './cryptoService'; import { NodeRevisionDraft } from './interface'; import { UploadManager } from './manager'; +import { FILE_CHUNK_SIZE, StreamUploader } from './streamUploader'; +import { UploadTelemetry } from './telemetry'; const BLOCK_ENCRYPTION_OVERHEAD = 10000; diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index b7a51fbc..3f1b8a7b 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; -import { Thumbnail, Logger, ThumbnailType, UploadMetadata } from '../../interface'; import { AbortError, IntegrityError } from '../../errors'; +import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from '../apiService'; import { getErrorMessage } from '../errors'; @@ -9,13 +9,13 @@ import { mergeUint8Arrays } from '../utils'; import { waitForCondition } from '../wait'; import { UploadAPIService } from './apiService'; import { BlockVerifier } from './blockVerifier'; +import { ChunkStreamReader } from './chunkStreamReader'; import { UploadController } from './controller'; import { UploadCryptoService } from './cryptoService'; import { UploadDigests } from './digests'; -import { NodeRevisionDraft, EncryptedBlock, EncryptedThumbnail, EncryptedBlockMetadata } from './interface'; -import { UploadTelemetry } from './telemetry'; -import { ChunkStreamReader } from './chunkStreamReader'; +import { EncryptedBlock, EncryptedBlockMetadata, EncryptedThumbnail, NodeRevisionDraft } from './interface'; import { UploadManager } from './manager'; +import { UploadTelemetry } from './telemetry'; /** * File chunk size in bytes representing the size of each block. diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index 9d5612d5..eece9ff4 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -1,4 +1,4 @@ -import { RateLimitedError, ValidationError, IntegrityError } from '../../errors'; +import { IntegrityError, RateLimitedError, ValidationError } from '../../errors'; import { ProtonDriveTelemetry } from '../../interface'; import { APIHTTPError } from '../apiService'; import { SharesService } from './interface'; diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 7cb551b5..cc3f00c2 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -1,8 +1,8 @@ -import { RateLimitedError, ValidationError, IntegrityError } from '../../errors'; -import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger, MetricVolumeType } from '../../interface'; +import { IntegrityError, RateLimitedError, ValidationError } from '../../errors'; +import { Logger, MetricsUploadErrorType, MetricVolumeType, ProtonDriveTelemetry } from '../../interface'; import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry'; import { APIHTTPError } from '../apiService'; -import { splitNodeUid, splitNodeRevisionUid } from '../uids'; +import { splitNodeRevisionUid, splitNodeUid } from '../uids'; import { SharesService } from './interface'; export class UploadTelemetry { diff --git a/js/sdk/src/polyfill.ts b/js/sdk/src/polyfill.ts new file mode 100644 index 00000000..4cffb137 --- /dev/null +++ b/js/sdk/src/polyfill.ts @@ -0,0 +1 @@ +import '@protontech/crypto/polyfill'; \ No newline at end of file diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index b1d37808..ca6dfafc 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -2,47 +2,37 @@ import { getConfig } from './config'; import { DriveCrypto, SessionKey } from './crypto'; import { NullFeatureFlagProvider } from './featureFlags'; import { + BookmarkOrUid, + Device, + DeviceOrUid, + DeviceType, + FileDownloader, + FileUploader, Logger, - ProtonDriveClientContructorParameters, - NodeOrUid, - MaybeNode, + MaybeBookmark, MaybeMissingNode, + MaybeNode, + MemberRole, + NodeOrUid, NodeResult, NodeResultWithError, NodeResultWithNewUid, - Revision, - RevisionOrUid, - ShareNodeSettings, - UnshareNodeSettings, + NodeType, + NonProtonInvitationOrUid, + ProtonDriveClientContructorParameters, ProtonInvitation, ProtonInvitationOrUid, - NonProtonInvitationOrUid, ProtonInvitationWithNode, - MaybeBookmark, - BookmarkOrUid, + Revision, + RevisionOrUid, + SDKEvent, + ShareNodeSettings, ShareResult, - Device, - DeviceType, - DeviceOrUid, - UploadMetadata, - FileDownloader, - FileUploader, - ThumbnailType, ThumbnailResult, - SDKEvent, - NodeType, - MemberRole, + ThumbnailType, + UnshareNodeSettings, + UploadMetadata, } from './interface'; -import { - getUid, - getUids, - convertInternalNodePromise, - convertInternalNodeIterator, - convertInternalMissingNodeIterator, - convertInternalNode, - convertInternalRevisionIterator, -} from './transformers'; -import { Telemetry } from './telemetry'; import { DriveAPIService } from './internal/apiService'; import { initDevicesModule } from './internal/devices'; import { initDownloadModule } from './internal/download'; @@ -51,10 +41,20 @@ import { initNodesModule } from './internal/nodes'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; import { initSharingModule } from './internal/sharing'; -import { SharingPublicSessionManager, getTokenAndPasswordFromUrl } from './internal/sharingPublic'; -import { initUploadModule } from './internal/upload'; +import { getTokenAndPasswordFromUrl, SharingPublicSessionManager } from './internal/sharingPublic'; import { makeNodeUid } from './internal/uids'; +import { initUploadModule } from './internal/upload'; import { ProtonDrivePublicLinkClient } from './protonDrivePublicLinkClient'; +import { Telemetry } from './telemetry'; +import { + convertInternalMissingNodeIterator, + convertInternalNode, + convertInternalNodeIterator, + convertInternalNodePromise, + convertInternalRevisionIterator, + getUid, + getUids, +} from './transformers'; /** * ProtonDriveClient is the main interface for the ProtonDrive SDK. diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index a043b706..6e752ac8 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -1,54 +1,54 @@ +import { getConfig } from './config'; +import { DriveCrypto } from './crypto'; +import { NullFeatureFlagProvider } from './featureFlags'; import { - Logger, - ProtonDriveClientContructorParameters, - NodeOrUid, - MaybeMissingPhotoNode, - UploadMetadata, FileDownloader, FileUploader, - SDKEvent, + Logger, + MaybeMissingPhotoNode, MaybePhotoNode, - ThumbnailType, - ThumbnailResult, - ShareNodeSettings, - ShareResult, - UnshareNodeSettings, - ProtonInvitation, - ProtonInvitationOrUid, - NonProtonInvitationOrUid, - ProtonInvitationWithNode, + NodeOrUid, NodeResult, NodeResultWithError, + NonProtonInvitationOrUid, PhotoTag, + ProtonDriveClientContructorParameters, + ProtonInvitation, + ProtonInvitationOrUid, + ProtonInvitationWithNode, + SDKEvent, + ShareNodeSettings, + ShareResult, + ThumbnailResult, + ThumbnailType, + UnshareNodeSettings, + UploadMetadata, } from './interface'; -import { getConfig } from './config'; -import { DriveCrypto } from './crypto'; -import { Telemetry } from './telemetry'; -import { - convertInternalMissingPhotoNodeIterator, - convertInternalPhotoNode, - convertInternalPhotoNodeIterator, - convertInternalPhotoNodePromise, - getUid, - getUids, -} from './transformers'; import { DriveAPIService } from './internal/apiService'; -import { makeNodeUid } from './internal/uids'; import { initDownloadModule } from './internal/download'; import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; import { - PHOTOS_SHARE_TARGET_TYPES, - initPhotosModule, + AlbumItem, initPhotoSharesModule, - initPhotoUploadModule, + initPhotosModule, initPhotosNodesModule, - AlbumItem, + initPhotoUploadModule, + PHOTOS_SHARE_TARGET_TYPES, TimelineItem, } from './internal/photos'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; import { initSharingModule } from './internal/sharing'; -import { NullFeatureFlagProvider } from './featureFlags'; +import { makeNodeUid } from './internal/uids'; +import { Telemetry } from './telemetry'; +import { + convertInternalMissingPhotoNodeIterator, + convertInternalPhotoNode, + convertInternalPhotoNodeIterator, + convertInternalPhotoNodePromise, + getUid, + getUids, +} from './transformers'; /** * ProtonDrivePhotosClient is the interface to access Photos functionality. diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 6d116ab1..25515b9e 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -1,42 +1,42 @@ import { MemoryCache } from './cache'; import { getConfig } from './config'; -import { DriveCrypto, OpenPGPCrypto, PrivateKey, SRPModule, SessionKey } from './crypto'; +import { DriveCrypto, OpenPGPCrypto, PrivateKey, SessionKey, SRPModule } from './crypto'; +import { NullFeatureFlagProvider } from './featureFlags'; import { - ProtonDriveHTTPClient, - ProtonDriveTelemetry, - ProtonDriveConfig, - Logger, - NodeOrUid, - ProtonDriveAccount, - MaybeNode, - NodeType, CachedCryptoMaterial, - MaybeMissingNode, + FeatureFlagProvider, FileDownloader, - ThumbnailType, - ThumbnailResult, - UploadMetadata, FileUploader, + Logger, + MaybeMissingNode, + MaybeNode, + MemberRole, + NodeOrUid, NodeResult, + NodeType, + ProtonDriveAccount, + ProtonDriveConfig, + ProtonDriveHTTPClient, + ProtonDriveTelemetry, SDKEvent, - MemberRole, - FeatureFlagProvider, + ThumbnailResult, + ThumbnailType, + UploadMetadata, } from './interface'; -import { Telemetry } from './telemetry'; -import { - getUid, - convertInternalNodePromise, - convertInternalNodeIterator, - convertInternalMissingNodeIterator, - getUids, -} from './transformers'; import { initDownloadModule } from './internal/download'; import { SDKEvents } from './internal/sdkEvents'; import { initSharingPublicModule, UnauthDriveAPIService } from './internal/sharingPublic'; +import { NodesSecurityScanResult } from './internal/sharingPublic/nodesSecurity'; import { SharingPublicLinkSession } from './internal/sharingPublic/session'; import { initUploadModule } from './internal/upload'; -import { NullFeatureFlagProvider } from './featureFlags'; -import { NodesSecurityScanResult } from './internal/sharingPublic/nodesSecurity'; +import { Telemetry } from './telemetry'; +import { + convertInternalMissingNodeIterator, + convertInternalNodeIterator, + convertInternalNodePromise, + getUid, + getUids, +} from './transformers'; /** * ProtonDrivePublicLinkClient is the interface for the public link client. diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index d6f2d1d4..c3c8eb87 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,16 +1,16 @@ import { - MaybeNode as PublicMaybeNode, - MaybeMissingNode as PublicMaybeMissingNode, DegradedNode as PublicDegradedNode, - Revision as PublicRevision, - Result, - resultOk, - resultError, - MissingNode, - MaybePhotoNode as PublicMaybePhotoNode, + DegradedPhotoNode as PublicDegradedPhotoNode, + MaybeMissingNode as PublicMaybeMissingNode, MaybeMissingPhotoNode as PublicMaybeMissingPhotoNode, + MaybeNode as PublicMaybeNode, + MaybePhotoNode as PublicMaybePhotoNode, + MissingNode, PhotoNode as PublicPhotoNode, - DegradedPhotoNode as PublicDegradedPhotoNode, + Result, + resultError, + resultOk, + Revision as PublicRevision, } from './interface'; import { DecryptedNode as InternalNode, DecryptedRevision as InternalRevision } from './internal/nodes'; import { DecryptedPhotoNode as InternalPartialPhotoNode } from './internal/photos'; diff --git a/js/sdk/tsconfig.json b/js/sdk/tsconfig.json index 5ec6422c..0caf4691 100644 --- a/js/sdk/tsconfig.json +++ b/js/sdk/tsconfig.json @@ -4,8 +4,8 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "incremental": true, - "module": "commonjs", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "noEmit": false, "noImplicitAny": true, // Many variables are unused during prototyping - uncomment later once more modules are implemented. @@ -17,11 +17,13 @@ "sourceMap": true, "outDir": "dist", "rootDir": "src", - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["@protontech/global-types", "mocha", "jest", "node"], + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true }, "include": [ "src/**/*.ts", - "typings/index.d.ts", ], "exclude": [ "**/node_modules/*", From 1baaf57104e596d4d1a88b661623c57588759997 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Apr 2026 13:39:12 +0200 Subject: [PATCH 728/791] Fix TypeError not being recognized as NetworkError --- js/sdk/src/internal/download/telemetry.test.ts | 6 ++++++ js/sdk/src/internal/download/telemetry.ts | 7 ++----- js/sdk/src/internal/errors.ts | 15 +++++++++++++++ js/sdk/src/internal/upload/telemetry.test.ts | 6 ++++++ js/sdk/src/internal/upload/telemetry.ts | 7 ++----- 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts index c1b72b97..4f46e87e 100644 --- a/js/sdk/src/internal/download/telemetry.test.ts +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -139,5 +139,11 @@ describe('DownloadTelemetry', () => { await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); verifyErrorCategory('network_error'); }); + + it('should detect "network_error" for TypeError', async () => { + const error = new TypeError('Failed to fetch'); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + verifyErrorCategory('network_error'); + }); }); }); diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index b544a88d..11f45cc0 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -2,6 +2,7 @@ import { DecryptionError, IntegrityError, RateLimitedError, ValidationError } fr import { Logger, MetricsDownloadErrorType, MetricVolumeType, ProtonDriveTelemetry } from '../../interface'; import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry'; import { APIHTTPError } from '../apiService'; +import { isNetworkError } from '../errors'; import { splitNodeRevisionUid, splitNodeUid } from '../uids'; import { SharesService } from './interface'; @@ -117,11 +118,7 @@ function getErrorCategory(error: unknown): MetricsDownloadErrorType | undefined if (error.name === 'TimeoutError') { return 'server_error'; } - if ( - error.name === 'OfflineError' || - error.name === 'NetworkError' || - error.message?.toLowerCase() === 'network error' - ) { + if (isNetworkError(error)) { return 'network_error'; } if (error.name === 'AbortError') { diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index 7a439d20..3095161a 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -67,3 +67,18 @@ export function isNotApplicationError(error?: unknown): boolean { return false; } + +export function isNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return ( + error.name === 'OfflineError' || + error.name === 'NetworkError' || + error.message?.toLowerCase() === 'network error' || + (error.name === 'TypeError' && + ['Failed to fetch', 'NetworkError when attempting to fetch resource', 'Load failed'].includes( + error.message, + )) + ); +} diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index eece9ff4..232dd4ff 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -135,5 +135,11 @@ describe('UploadTelemetry', () => { await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); verifyErrorCategory('network_error'); }); + + it('should detect "network_error" for TypeError', async () => { + const error = new TypeError('Failed to fetch'); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + verifyErrorCategory('network_error'); + }); }); }); diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index cc3f00c2..156fe2a4 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -2,6 +2,7 @@ import { IntegrityError, RateLimitedError, ValidationError } from '../../errors' import { Logger, MetricsUploadErrorType, MetricVolumeType, ProtonDriveTelemetry } from '../../interface'; import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry'; import { APIHTTPError } from '../apiService'; +import { isNetworkError } from '../errors'; import { splitNodeRevisionUid, splitNodeUid } from '../uids'; import { SharesService } from './interface'; @@ -124,11 +125,7 @@ function getErrorCategory(error: unknown): MetricsUploadErrorType | undefined { if (error.name === 'TimeoutError') { return 'server_error'; } - if ( - error.name === 'OfflineError' || - error.name === 'NetworkError' || - error.message?.toLowerCase() === 'network error' - ) { + if (isNetworkError(error)) { return 'network_error'; } if (error.name === 'AbortError') { From a563ed68ae66af725cdf7176ae9715873c9aba84 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 08:22:24 +0200 Subject: [PATCH 729/791] Include error details in decryption telemetry events --- .../Nodes/DtoToMetadataConverter.cs | 36 +++++++++---------- .../Telemetry/TelemetryEventFactory.cs | 5 +-- .../Telemetry/TelemetryRecorder.cs | 2 +- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index e6f6fded..19f2e216 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -283,7 +283,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } - private static (DegradedFileMetadata Metadata, List FailedDecryptionFields) CreateDegradedFileMetadata( + private static (DegradedFileMetadata Metadata, Dictionary FailedDecryptionFields) CreateDegradedFileMetadata( LinkDetailsDto linkDetailsDto, FileDecryptionResult decryptionResult, Result nameResult, @@ -298,34 +298,34 @@ private static (DegradedFileMetadata Metadata, List FailedDecryp PgpSessionKey? nameSessionKey, ShareMembershipSummaryDto? membershipDto) { - List failedDecryptionFields = []; + Dictionary failedDecryptionFields = []; List errors = []; if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { errors.Add(new DecryptionError("Passphrase decryption failed", passphraseError)); - failedDecryptionFields.Add(EncryptedField.NodeKey); + failedDecryptionFields.Add(EncryptedField.NodeKey, passphraseError); } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { errors.Add(new DecryptionError("Node key decryption failed", nodeKeyError)); - failedDecryptionFields.Add(EncryptedField.NodeKey); + failedDecryptionFields.Add(EncryptedField.NodeKey, nodeKeyError); } - else if (decryptionResult.ContentKey.IsFailure) + else if (decryptionResult.ContentKey.TryGetError(out var contentKeyError)) { - failedDecryptionFields.Add(EncryptedField.NodeContentKey); + failedDecryptionFields.Add(EncryptedField.NodeContentKey, contentKeyError); } - if (nameResult.IsFailure) + if (nameResult.TryGetError(out var nameError)) { - failedDecryptionFields.Add(EncryptedField.NodeName); + failedDecryptionFields.Add(EncryptedField.NodeName, nameError); } var revisionErrors = new List(); if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) { revisionErrors.Add(new DecryptionError("Extended attributes decryption failed", extendedAttributesError)); - failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes); + failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes, extendedAttributesError); } var nodeAuthor = decryptionResult.Link.Passphrase.Merge( @@ -485,7 +485,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } - private static (DegradedFolderMetadata Metadata, List FailedDecryptionFields) CreateDegradedFolderMetadata( + private static (DegradedFolderMetadata Metadata, Dictionary FailedDecryptionFields) CreateDegradedFolderMetadata( FolderDecryptionResult decryptionResult, Result nameResult, NodeUid uid, @@ -494,28 +494,28 @@ private static (DegradedFolderMetadata Metadata, List FailedDecr PgpSessionKey? nameSessionKey, ShareMembershipSummaryDto? membershipDto) { - List failedDecryptionFields = []; + Dictionary failedDecryptionFields = []; List errors = []; if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { errors.Add(new DecryptionError("Passphrase decryption failed", passphraseError)); - failedDecryptionFields.Add(EncryptedField.NodeKey); + failedDecryptionFields.Add(EncryptedField.NodeKey, passphraseError); } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { errors.Add(new DecryptionError("Node key decryption failed", nodeKeyError)); - failedDecryptionFields.Add(EncryptedField.NodeKey); + failedDecryptionFields.Add(EncryptedField.NodeKey, nodeKeyError); } - else if (decryptionResult.HashKey.TryGetError(out var error)) + else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) { - errors.Add(new DecryptionError("Hash key decryption failed", error)); - failedDecryptionFields.Add(EncryptedField.NodeHashKey); + errors.Add(new DecryptionError("Hash key decryption failed", hashKeyError)); + failedDecryptionFields.Add(EncryptedField.NodeHashKey, hashKeyError); } - if (nameResult.IsFailure) + if (nameResult.TryGetError(out var nameError)) { - failedDecryptionFields.Add(EncryptedField.NodeName); + failedDecryptionFields.Add(EncryptedField.NodeName, nameError); } var nodeAuthorFromPassphrase = decryptionResult.Link.Passphrase.Merge( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs index 3969f7f0..9f5a0962 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs @@ -14,7 +14,7 @@ internal static class TelemetryEventFactory public static async Task> CreateDecryptionErrorEventsAsync( ProtonDriveClient client, DegradedNode degradedNode, - IEnumerable failedFields, + IReadOnlyDictionary failedFields, CancellationToken cancellationToken) { var fromBefore2024 = degradedNode.CreationTime.CompareTo(LegacyBoundary) < 1; @@ -24,9 +24,10 @@ public static async Task> CreateDecryptionErro return failedFields.Select(field => new DecryptionErrorEvent { Uid = degradedNode.Uid, - Field = field, + Field = field.Key, VolumeType = volumeType, FromBefore2024 = fromBefore2024, + Error = field.Value.Message, }); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs index 52d39645..41a081f3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs @@ -10,7 +10,7 @@ internal static class TelemetryRecorder public static async Task TryRecordDecryptionErrorAsync( ProtonDriveClient client, DegradedNode degradedNode, - IEnumerable failedFields, + IReadOnlyDictionary failedFields, CancellationToken cancellationToken) { try From bfee1a7dc367314f284abd154a9d45c0efa294bf Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 11:15:46 +0000 Subject: [PATCH 730/791] Add events subscriptions for CLI --- .gitignore | 2 ++ js/sdk/src/index.ts | 1 + js/sdk/src/internal/events/eventManager.ts | 1 + js/sdk/src/internal/events/interface.ts | 8 ++++++++ 4 files changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index c7a1a661..57db2497 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ js/cli/release auth.txt auth-session.json cache*.sqlite +events.json +events.lock *.log *.bun-build config.json diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 38b7015b..e317729c 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -10,6 +10,7 @@ export { OpenPGPCryptoWithCryptoProxy } from './crypto'; export * from './errors'; export { NullFeatureFlagProvider } from './featureFlags'; export * from './interface'; +export type { EventSubscription } from './internal/events'; export { ProtonDriveClient } from './protonDriveClient'; export { VERSION } from './version'; diff --git a/js/sdk/src/internal/events/eventManager.ts b/js/sdk/src/internal/events/eventManager.ts index 89f2092e..5e54ff88 100644 --- a/js/sdk/src/internal/events/eventManager.ts +++ b/js/sdk/src/internal/events/eventManager.ts @@ -54,6 +54,7 @@ export class EventManager { const index = this.listeners.indexOf(callback); this.listeners.splice(index, 1); }, + getLatestEventId: () => this.latestEventId ?? null, }; } diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index f3a0f919..5e369205 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -15,6 +15,14 @@ export interface Event { export interface EventSubscription { dispose(): void; + /** + * Returns the latest event ID for the subscription. + * + * @deprecated This is experimental to provide a way to the client to know + * the latest event ID before getting any events. It will be removed and + * replaced with a more robust solution. + */ + getLatestEventId(): string | null; } export interface LatestEventIdProvider { From 320de346e14edfe13454f37eb04da2a5b1010355 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 11:50:19 +0000 Subject: [PATCH 731/791] Persist client UID --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 57db2497..f51b449b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ auth-session.json cache*.sqlite events.json events.lock +clientUid.json *.log *.bun-build config.json From a0ba9f1cf2e5c05eb4d10503e98cbffe8a5d6236 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 14:42:35 +0200 Subject: [PATCH 732/791] Add error for verification error event --- .../Telemetry/TelemetryEventFactory.cs | 21 ++------------ .../Telemetry/TelemetryRecorder.cs | 29 ++----------------- 2 files changed, 4 insertions(+), 46 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs index 9f5a0962..5ef2984b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs @@ -31,25 +31,6 @@ public static async Task> CreateDecryptionErro }); } - /// - /// Creates a DecryptionErrorEvent for a single field using a node UID. - /// - public static async Task CreateDecryptionErrorEventAsync( - ProtonDriveClient client, - NodeUid nodeUid, - EncryptedField field, - DateTime creationTime, - CancellationToken cancellationToken) - { - return new DecryptionErrorEvent - { - Uid = nodeUid, - Field = field, - VolumeType = await ResolveVolumeTypeAsync(client, nodeUid, cancellationToken).ConfigureAwait(false), - FromBefore2024 = creationTime.CompareTo(LegacyBoundary) < 1, - }; - } - /// /// Creates a VerificationErrorEvent using a node UID. /// @@ -58,6 +39,7 @@ public static async Task CreateVerificationErrorEventAsy NodeUid nodeUid, EncryptedField field, DateTime creationTime, + string? error, CancellationToken cancellationToken) { return new VerificationErrorEvent @@ -66,6 +48,7 @@ public static async Task CreateVerificationErrorEventAsy Field = field, VolumeType = await ResolveVolumeTypeAsync(client, nodeUid, cancellationToken).ConfigureAwait(false), FromBefore2024 = creationTime.CompareTo(LegacyBoundary) < 1, + Error = error, }; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs index 41a081f3..e35fc381 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs @@ -32,33 +32,6 @@ public static async Task TryRecordDecryptionErrorAsync( } } - /// - /// Attempts to record a decryption error event for a single field using a node UID. - /// - public static async Task TryRecordDecryptionErrorAsync( - ProtonDriveClient client, - NodeUid nodeUid, - EncryptedField field, - DateTime creationTime, - CancellationToken cancellationToken) - { - try - { - var @event = await TelemetryEventFactory.CreateDecryptionErrorEventAsync( - client, - nodeUid, - field, - creationTime, - cancellationToken).ConfigureAwait(false); - - client.Telemetry.RecordMetric(@event); - } - catch - { - // Do nothing - telemetry failures should not break the main flow - } - } - /// /// Attempts to record a verification error event using a node UID. /// @@ -67,6 +40,7 @@ public static async Task TryRecordVerificationErrorAsync( NodeUid nodeUid, EncryptedField field, DateTime creationTime, + string? error, CancellationToken cancellationToken) { try @@ -76,6 +50,7 @@ public static async Task TryRecordVerificationErrorAsync( nodeUid, field, creationTime, + error, cancellationToken).ConfigureAwait(false); client.Telemetry.RecordMetric(@event); From a478b2eca60feb4c17a355a6537fa6d289fab1c4 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 15:00:29 +0200 Subject: [PATCH 733/791] BatchSize for remove_multiple on photos should be 10 --- js/sdk/src/internal/photos/apiService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index c627c0f5..92875b38 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -494,7 +494,7 @@ export class PhotosAPIService { ): AsyncGenerator { const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid); - const batchSize = 50; + const batchSize = 10; for (const photoNodeUidsBatch of batch(photoNodeUids, batchSize)) { const linkIds = photoNodeUidsBatch.map((nodeUid) => splitNodeUid(nodeUid).nodeId); From a0f3c02231758aba040b015f4ce248e329b0db96 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 1 May 2026 16:27:27 +0200 Subject: [PATCH 734/791] Refactor Proton API exception to consolidate constructor initialization --- cs/sdk/src/Proton.Sdk/ProtonApiException.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiException.cs b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs index 65f1108a..64d7616f 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonApiException.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs @@ -19,17 +19,21 @@ public ProtonApiException(string? message, Exception? innerException) { } + public ProtonApiException(string? message, int? transportCode, ResponseCode code) + : this(message) + { + Code = code; + TransportCode = transportCode; + } + internal ProtonApiException(HttpStatusCode statusCode, ApiResponse response) - : this(response.ErrorMessage) + : this(response.ErrorMessage, (int)statusCode, response.Code) { - Code = response.Code; - TransportCode = (int)statusCode; } internal ProtonApiException(ApiResponse response) - : this(response.ErrorMessage) + : this(response.ErrorMessage, null, response.Code) { - Code = response.Code; } public ResponseCode Code { get; } From 558674352840d243be5fefe03b6be390bc7019f3 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 1 May 2026 14:53:07 +0000 Subject: [PATCH 735/791] Update changelog for cs/v0.14.1 --- cs/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 8584551d..8e0770bf 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## cs/v0.14.1 (2026-05-01) + +* Refactor Proton API exception to consolidate constructor initialization +* Add error for verification error event +* Include error details in decryption telemetry events + ## cs/v0.14.0 (2026-04-28) * Fix name conflict handling regression From a112880ae662e646a53f585b70788af80d1754d4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 11:13:51 +0200 Subject: [PATCH 736/791] Handle loading drafts --- js/sdk/src/internal/apiService/driveTypes.ts | 139 +++++++------------ js/sdk/src/internal/nodes/apiService.test.ts | 9 +- js/sdk/src/internal/nodes/apiService.ts | 25 +++- js/sdk/src/internal/photos/nodes.ts | 19 ++- js/sdk/src/internal/sharingPublic/nodes.ts | 5 +- 5 files changed, 91 insertions(+), 106 deletions(-) diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index 51b1cb29..e26387fd 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -2684,27 +2684,6 @@ export interface paths { patch?: never; trace?: never; }; - "/drive/shares/{shareID}/property": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Update properties of a share - * @deprecated - * @description Update the values on one or more properties of the Share. For now, only allowed changing editorsCanShare attribute - */ - post: operations["post_drive-shares-{shareID}-property"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/drive/migrations/shareaccesswithnode": { parameters: { query?: never; @@ -3434,13 +3413,15 @@ export interface components { }; /** @description Address ID */ AddressID: string; + /** @description An encrypted ID */ + LongId: string; ShareDataDto: { AddressID: components["schemas"]["AddressID"]; Key: components["schemas"]["PGPPrivateKey"]; Passphrase: components["schemas"]["PGPMessage"]; PassphraseSignature: components["schemas"]["PGPSignature"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID?: components["schemas"]["Id"] | null; + AddressKeyID?: components["schemas"]["LongId"] | null; }; LinkDataDto: { /** @description Root folder name */ @@ -3700,7 +3681,7 @@ export interface components { BookmarkShareURLRequestDto: { EncryptedUrlPassword?: components["schemas"]["PGPMessage"] | null; AddressID: components["schemas"]["AddressID"]; - AddressKeyID: components["schemas"]["Id"]; + AddressKeyID: components["schemas"]["LongId"]; }; CreateBookmarkShareURLRequestDto: { BookmarkShareURL: components["schemas"]["BookmarkShareURLRequestDto"]; @@ -3711,7 +3692,7 @@ export interface components { */ BookmarkShareURLState: 1 | 3; BookmarkShareURLResponseDto: { - UserID: components["schemas"]["Id"]; + UserID: components["schemas"]["LongId"]; Token: string; ShareURLID: components["schemas"]["Id"]; EncryptedUrlPassword: components["schemas"]["PGPMessage"] | null; @@ -3823,7 +3804,7 @@ export interface components { Passphrase: components["schemas"]["PGPMessage"]; PassphraseSignature: components["schemas"]["PGPSignature"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID?: components["schemas"]["Id"] | null; + AddressKeyID?: components["schemas"]["LongId"] | null; /** * @deprecated * @default null @@ -3971,8 +3952,10 @@ export interface components { */ Code: 1000; }; + /** @description An encrypted ID */ + ShortId: string; LatestEventIDResponseDto: { - EventID: components["schemas"]["Id"]; + EventID: components["schemas"]["ShortId"]; /** * ProtonResponseCode * @example 1000 @@ -4285,7 +4268,7 @@ export interface components { } | null; }; EventResponseDto: { - EventID: components["schemas"]["Id"]; + EventID: components["schemas"]["ShortId"]; EventType: components["schemas"]["EventType"]; /** @description Event creation timestamp */ CreateTime: number; @@ -4330,7 +4313,7 @@ export interface components { ListEventsResponseDto: { Events: components["schemas"]["EventResponseDto"][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ - EventID: components["schemas"]["Id"]; + EventID: components["schemas"]["ShortId"]; /** * @description 1 if there is more to pull, i.e. there are more events than returned in one call * @enum {integer} @@ -4355,18 +4338,20 @@ export interface components { IsTrashed: boolean; }; EventV2ResponseDto: { - EventID: components["schemas"]["Id"]; + EventID: components["schemas"]["ShortId"]; EventType: components["schemas"]["EventType"]; Link: components["schemas"]["EventLinkDataDto"]; }; ListEventsV2ResponseDto: { Events: components["schemas"]["EventV2ResponseDto"][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ - EventID: components["schemas"]["Id"]; + EventID: components["schemas"]["ShortId"]; /** @description true if there is more to pull, i.e. there are more events than returned in one call */ More: boolean; /** @description true if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync */ Refresh: boolean; + /** @description true if client should skip SDK-side quota checks (e.g. user has an active free upload timer) */ + DisableSdkQuotaChecks: boolean; /** * ProtonResponseCode * @example 1000 @@ -4497,6 +4482,7 @@ export interface components { PhotosDto: { /** @description Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ ContentHash: string; + /** @default [] */ RelatedPhotos: components["schemas"]["RelatedPhotoDto"][]; }; CopyLinkRequestDto: { @@ -4667,7 +4653,7 @@ export interface components { }; MembershipDto: { ShareID: components["schemas"]["Id"]; - MembershipID: components["schemas"]["Id"]; + MembershipID: components["schemas"]["ShortId"]; /** * @description Permission bitfield, valid permissions: * - 4: read access @@ -4911,7 +4897,7 @@ export interface components { NodesWithMissingNodeHashKey: components["schemas"]["UpdateMissingHashKeyItemDto"][]; }; CommitRevisionPhotoDto: { - /** @description Photo capture timestamp */ + /** @description Photo capture timestamp, use negative values for times before 1970 */ CaptureTime: number; /** @description Photo content hash, lowercase hex representation of HMAC SHA256 of SHA1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] */ ContentHash: string; @@ -5538,8 +5524,10 @@ export interface components { }; PhotoFileDto: { ActiveRevision: components["schemas"]["ActivePhotoRevisionDto"] | null; - CaptureTime: number; + /** @description Timestamp of the photo capture in seconds since the Unix epoch; null on draft links */ + CaptureTime?: number | null; MainPhotoLinkID: components["schemas"]["Id"] | null; + /** @description Photo content hash, lowercase hex representation of HMAC SHA256 of SHA1 content using parent folder's hash key; Null on draft links */ ContentHash: string | null; RelatedPhotosLinkIDs: components["schemas"]["Id"][]; Albums: components["schemas"]["PhotoAlbumDto"][]; @@ -5902,7 +5890,7 @@ export interface components { }; PhotoResponseDto: { LinkID: components["schemas"]["Id"]; - /** @description Unix timestamp of when the photo was taken as extracted by client from exif */ + /** @description Unix timestamp of when the photo was taken as extracted by client from exif. Negative values represent times before 1970 */ CaptureTime: number; MainPhotoLinkID: components["schemas"]["Id"] | null; /** @description File name hash */ @@ -6231,7 +6219,7 @@ export interface components { Key: components["schemas"]["PGPPrivateKey"]; Passphrase: components["schemas"]["PGPMessage"]; PassphraseSignature: components["schemas"]["PGPSignature"]; - AddressID: components["schemas"]["Id"]; + AddressID: components["schemas"]["LongId"]; InviterSharePassphraseKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; InviteeSharePassphraseSessionKeySignature?: components["schemas"]["PGPSignature"] | null; }; @@ -6276,10 +6264,10 @@ export interface components { */ ShareMemberState: 1 | 2 | 3; MemberResponseDto: { - MemberID: components["schemas"]["Id"]; + MemberID: components["schemas"]["ShortId"]; ShareID: components["schemas"]["Id"]; - AddressID: components["schemas"]["Id"]; - AddressKeyID: components["schemas"]["Id"]; + AddressID: components["schemas"]["LongId"]; + AddressKeyID: components["schemas"]["LongId"]; /** Format: email */ Inviter: string; /** @@ -6311,8 +6299,8 @@ export interface components { Unlockable: boolean | null; }; KeyPacketResponseDto: { - AddressID: components["schemas"]["Id"]; - AddressKeyID: components["schemas"]["Id"]; + AddressID: components["schemas"]["LongId"]; + AddressKeyID: components["schemas"]["LongId"]; KeyPacket: components["schemas"]["BinaryString"]; /** @description 1=active, 3=locked */ State: components["schemas"]["ShareMemberState"]; @@ -6357,12 +6345,12 @@ export interface components { Passphrase: components["schemas"]["PGPMessage"]; PassphraseSignature: components["schemas"]["PGPSignature"]; /** @description Address ID of the current user's address for the membership of this share. Can be missing if the user is not a direct member of the share. */ - AddressID: components["schemas"]["Id"] | null; + AddressID: components["schemas"]["LongId"] | null; /** * @deprecated * @description Clients should not use this field but pass the address keyring when validating and decrypting related fields. */ - AddressKeyID?: components["schemas"]["Id"] | null; + AddressKeyID?: components["schemas"]["LongId"] | null; /** @description Your own memberships */ Memberships: components["schemas"]["MemberResponseDto"][]; /** @@ -6434,13 +6422,6 @@ export interface components { /** @description Indicates if editor members of this share could reshare it or not */ Value: boolean; }; - UpdateSharePropertyRequestDto: { - /** - * @description Indicates if editor members of this share could reshare it or not - * @default null - */ - EditorsCanShare: boolean | null; - }; ShareKPMigrationData: { /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ ShareID: components["schemas"]["Id"]; @@ -6563,7 +6544,7 @@ export interface components { Code: 1000; }; ExternalInvitationRequestDto: { - InviterAddressID: components["schemas"]["Id"]; + InviterAddressID: components["schemas"]["LongId"]; /** Format: email */ InviteeEmail: string; /** @@ -6593,7 +6574,7 @@ export interface components { */ ExternalInvitationState: 1 | 2 | 4; ExternalInvitationResponseDto: { - ExternalInvitationID: components["schemas"]["Id"]; + ExternalInvitationID: components["schemas"]["ShortId"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -6633,12 +6614,12 @@ export interface components { UserRegisteredExternalInvitationItemDto: { VolumeID: components["schemas"]["Id"]; ShareID: components["schemas"]["Id"]; - ExternalInvitationID: components["schemas"]["Id"]; + ExternalInvitationID: components["schemas"]["ShortId"]; }; ListUserRegisteredExternalInvitationResponseDto: { ExternalInvitations: components["schemas"]["UserRegisteredExternalInvitationItemDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID: components["schemas"]["Id"] | null; + AnchorID: components["schemas"]["ShortId"] | null; /** @description Indicates if there is a next page of results */ More: boolean; /** @@ -6680,7 +6661,7 @@ export interface components { /** @description Signature of the above member key packet with the private key of the user (inviter) and the signature context `drive.share-member.inviter`, base64 encoded */ KeyPacketSignature: components["schemas"]["BinaryString"]; /** @default null */ - ExternalInvitationID: components["schemas"]["Id"] | null; + ExternalInvitationID: components["schemas"]["ShortId"] | null; }; InviteUserRequestDto: { Invitation: components["schemas"]["InvitationRequestDto"]; @@ -6688,7 +6669,7 @@ export interface components { EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; }; InvitationResponseDto: { - InvitationID: components["schemas"]["Id"]; + InvitationID: components["schemas"]["ShortId"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -6741,7 +6722,7 @@ export interface components { PendingInvitationItemDto: { VolumeID: components["schemas"]["Id"]; ShareID: components["schemas"]["Id"]; - InvitationID: components["schemas"]["Id"]; + InvitationID: components["schemas"]["ShortId"]; /** @description The target type of the Share that is corresponding to this invitation. * This should not be used as source of information to know what NodeType or MIMEType the targeted Share is. */ ShareTargetType: components["schemas"]["TargetType"]; @@ -6749,7 +6730,7 @@ export interface components { ListPendingInvitationResponseDto: { Invitations: components["schemas"]["PendingInvitationItemDto"][]; /** @description Used for pagination, pass to the next call to get the next page of results */ - AnchorID: components["schemas"]["Id"] | null; + AnchorID: components["schemas"]["ShortId"] | null; /** @description Indicates if there is a next page of results */ More: boolean; /** @@ -6816,7 +6797,7 @@ export interface components { Code: 1000; }; MemberResponseDto2: { - MemberID: components["schemas"]["Id"]; + MemberID: components["schemas"]["ShortId"]; /** Format: email */ InviterEmail: string; /** Format: email */ @@ -6968,7 +6949,7 @@ export interface components { CreateOrgVolumeRequestDto: { AddressID: components["schemas"]["AddressID"]; /** @description XX's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID: components["schemas"]["Id"]; + AddressKeyID: components["schemas"]["LongId"]; ShareKey: components["schemas"]["PGPPrivateKey"]; SharePassphrase: components["schemas"]["PGPMessage"]; SharePassphraseSignature: components["schemas"]["PGPSignature"]; @@ -6977,7 +6958,7 @@ export interface components { FolderPassphrase: components["schemas"]["PGPMessage"]; FolderPassphraseSignature: components["schemas"]["PGPSignature"]; FolderHashKey: components["schemas"]["PGPMessage"]; - OrganizationID: components["schemas"]["Id"]; + OrganizationID: components["schemas"]["LongId"]; /** @description Name of the org. volume. It's plain text so that name can be displayed in UI menu */ VolumeName: string; }; @@ -7026,7 +7007,7 @@ export interface components { FolderPassphraseSignature: components["schemas"]["PGPSignature"]; FolderHashKey: components["schemas"]["PGPMessage"]; /** @description User's encrypted AddressKeyID. Must be the primary key from the AddressID */ - AddressKeyID?: components["schemas"]["Id"] | null; + AddressKeyID?: components["schemas"]["LongId"] | null; /** * @deprecated * @default null @@ -7039,8 +7020,8 @@ export interface components { ShareName: string | null; }; OrgVolumeResponseDto: { - ShareID: components["schemas"]["Id"]; - VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["ShortId"]; + VolumeID: components["schemas"]["ShortId"]; /** @description Name of the org. volume */ Name: string; /** @description Membership creation time */ @@ -7056,7 +7037,7 @@ export interface components { Code: 1000; }; OrgVolumeForAdminResponseDto: { - VolumeID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["ShortId"]; /** @description Name of the org. volume */ Name: string; }; @@ -7110,7 +7091,7 @@ export interface components { /** @default [] */ PhotoShares: components["schemas"]["RestoreRootShareDto"][]; /** @description User's encrypted AddressKeyID. Must be the primary key from the signatureAddress */ - AddressKeyID?: components["schemas"]["Id"] | null; + AddressKeyID?: components["schemas"]["LongId"] | null; }; AddPhotoToAlbumWithLinkIDResponseDto: Record; MultiResponsesPerLinkFactory: { @@ -12466,33 +12447,6 @@ export interface operations { }; }; }; - "post_drive-shares-{shareID}-property": { - parameters: { - query?: never; - header?: never; - path: { - shareID: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["UpdateSharePropertyRequestDto"]; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - "x-pm-code": 1000; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SuccessfulResponse"]; - }; - }; - }; - }; "post_drive-migrations-shareaccesswithnode": { parameters: { query?: never; @@ -13071,6 +13025,7 @@ export interface operations { * - 200502: key packet signature is invalid * - 200600: maximum number of invitations and members reached for current share * - 200202: Sharing with groups is not available yet. + * - 2011: You do not have permission to invite this group. Please reach out to the group admin. * */ Code: number; }; diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index 1d5091ff..be456277 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -5,7 +5,7 @@ import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiS import { groupNodeUidsByVolumeAndIteratePerBatch, NodeAPIService } from './apiService'; import { NodeOutOfSyncError } from './errors'; -function generateAPIFileNode(linkOverrides = {}, overrides = {}) { +function generateAPIFileNode(linkOverrides = {}, overrides = {}, fileOverrides = {}) { const node = generateAPINode(); return { Link: { @@ -25,6 +25,7 @@ function generateAPIFileNode(linkOverrides = {}, overrides = {}) { XAttr: '{file}', EncryptedSize: 12, }, + ...fileOverrides, }, ...overrides, }; @@ -225,7 +226,7 @@ describe('nodeAPIService', () => { ); const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], ownVolumeId)); - expect(nodes).toStrictEqual([expectedNode]); + expect(nodes).toStrictEqual(expectedNode ? [expectedNode] : []); } it('should get folder node', async () => { @@ -243,6 +244,10 @@ describe('nodeAPIService', () => { await testIterateNodes(generateAPIFileNode(), generateFileNode()); }); + fit('should skip file draft node without an error', async () => { + await testIterateNodes(generateAPIFileNode({}, {}, { ActiveRevision: null }), undefined); + }); + it('should get album node', async () => { await testIterateNodes(generateAPIAlbumNode(), generateAlbumNode()); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 44f1a6ad..7c5fe1d9 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -212,7 +212,7 @@ export abstract class NodeAPIServiceBase< for (const link of responseLinks) { try { const encryptedNode = this.linkToEncryptedNode(volumeId, link, isOwnVolumeId); - if (filterOptions?.type && encryptedNode.type !== filterOptions.type) { + if (!encryptedNode || (filterOptions?.type && encryptedNode.type !== filterOptions.type)) { continue; } yield encryptedNode; @@ -232,7 +232,17 @@ export abstract class NodeAPIServiceBase< signal?: AbortSignal, ): Promise; - protected abstract linkToEncryptedNode(volumeId: string, link: TMetadataResponseLink, isOwnVolumeId: boolean): T; + /** + * Converts a link from the API payload to an encrypted node entity. + * + * Returns undefined if the link is a draft as drafts are not exposed + * to the client and are internal to upload module only. + */ + protected abstract linkToEncryptedNode( + volumeId: string, + link: TMetadataResponseLink, + isOwnVolumeId: boolean, + ): T | undefined; // Improvement requested: load next page sooner before all IDs are yielded. async *iterateChildrenNodeUids( @@ -623,7 +633,7 @@ export class NodeAPIService extends NodeAPIServiceBase { volumeId: string, link: PostLoadLinksMetadataResponse['Links'][0], isOwnVolumeId: boolean, - ): EncryptedNode { + ): EncryptedNode | undefined { return linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); } } @@ -683,7 +693,7 @@ export function linkToEncryptedNode( volumeId: string, link: Pick, isAdmin: boolean, -): EncryptedNode { +): EncryptedNode | undefined { const { baseNodeMetadata, baseCryptoNodeMetadata } = linkToEncryptedNodeBaseMetadata( logger, volumeId, @@ -704,7 +714,12 @@ export function linkToEncryptedNode( }; } - if (link.Link.Type === 2 && link.File && link.File.ActiveRevision) { + if (link.Link.Type === 2 && link.File) { + if (!link.File.ActiveRevision) { + logger.warn(`Requested draft file node, skipping from the result`); + return undefined; + } + return { ...baseNodeMetadata, totalStorageSize: link.File.TotalEncryptedSize, diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 4cbbd17f..32343420 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -18,10 +18,7 @@ type PostLoadLinksMetadataRequest = Extract< type PostLoadLinksMetadataResponse = drivePaths['/drive/photos/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json']; -export class PhotosNodesAPIService extends NodeAPIServiceBase< - EncryptedPhotoNode, - PostLoadLinksMetadataResponse['Links'][0] -> { +export class PhotosNodesAPIService extends NodeAPIServiceBase { protected async fetchNodeMetadata(volumeId: string, linkIds: string[], signal?: AbortSignal) { const response = await this.apiService.post( `drive/photos/volumes/${volumeId}/links`, @@ -37,7 +34,7 @@ export class PhotosNodesAPIService extends NodeAPIServiceBase< volumeId: string, link: PostLoadLinksMetadataResponse['Links'][0], isOwnVolumeId: boolean, - ): EncryptedPhotoNode { + ): EncryptedPhotoNode | undefined { const { baseNodeMetadata, baseCryptoNodeMetadata } = linkToEncryptedNodeBaseMetadata( this.logger, volumeId, @@ -45,13 +42,23 @@ export class PhotosNodesAPIService extends NodeAPIServiceBase< isOwnVolumeId, ); - if (link.Link.Type === 2 && link.Photo && link.Photo.ActiveRevision) { + if (link.Link.Type === 2 && link.Photo) { const node = linkToEncryptedNode( this.logger, volumeId, { ...link, File: link.Photo, Folder: null }, isOwnVolumeId, ); + if (!node) { + return undefined; + } + // Capture time is not present only for draft nodes. + // Draft nodes are not exposed to the client and are internal to + // upload module only. + if (!link.Photo.CaptureTime) { + this.logger.warn(`Requested draft photo node, skipping from the result`); + return undefined; + } return { ...node, type: NodeType.Photo, diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index 2d0d536a..c70f057a 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -82,9 +82,12 @@ export class SharingPublicNodesAPIService extends NodeAPIService { volumeId: string, link: PostLoadLinksMetadataResponse['Links'][0], isOwnVolumeId: boolean, - ): EncryptedNode { + ): EncryptedNode | undefined { const nodeUid = makeNodeUid(volumeId, link.Link.LinkID); const encryptedNode = linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); + if (!encryptedNode) { + return undefined; + } // TODO: This affects the cache. At this moment, the public link is not cached // anywhere, thus OK. To avoid issues when public links reuses the same cache, From 41449e872528bf7a77f39b24cfbe7431749f1833 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 May 2026 14:07:44 +0000 Subject: [PATCH 737/791] Optional AccountClientProtocol + interop nil handling --- .../ProtonDriveClient/AccountClient.swift | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift index b33a5812..9ea3be76 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift @@ -3,10 +3,10 @@ import ProtonCoreDataModel import SwiftProtobuf public protocol AccountClientProtocol: Sendable { - func getAddress(addressId: String) -> Address - func getDefaultAddress() -> Address - func getAddressPrimaryPrivateKey(addressId: String) -> Data - func getAddressPrivateKeys(addressId: String) -> [Data] + func getAddress(addressId: String) -> Address? + func getDefaultAddress() -> Address? + func getAddressPrimaryPrivateKey(addressId: String) -> Data? + func getAddressPrivateKeys(addressId: String) -> [Data]? func getAddressPublicKeysRequest(emailAddress: String) -> [Data] } @@ -33,21 +33,37 @@ let cCompatibleAccountClientRequest: CCallbackWithCallbackPointer = { statePoint switch request.payload { case .getAddress(let request): - let address = accountClient.getAddress(addressId: request.addressID) + guard let address = accountClient.getAddress(addressId: request.addressID) else { + SDKResponseHandler.sendInteropErrorToSDK(message: "cCompatibleAccountClientRequest.address is null", + callbackPointer: callbackPointer) + return + } let protoAddress = address.makeProtoAddress() SDKResponseHandler.send(callbackPointer: callbackPointer, message: protoAddress) case .getDefaultAddress(let request): - let address = accountClient.getDefaultAddress() + guard let address = accountClient.getDefaultAddress() else { + SDKResponseHandler.sendInteropErrorToSDK(message: "cCompatibleAccountClientRequest.defaultAddress is null", + callbackPointer: callbackPointer) + return + } let protoAddress = address.makeProtoAddress() SDKResponseHandler.send(callbackPointer: callbackPointer, message: protoAddress) case .getAddressPrimaryPrivateKey(let request): - let key = accountClient.getAddressPrimaryPrivateKey(addressId: request.addressID) + guard let key = accountClient.getAddressPrimaryPrivateKey(addressId: request.addressID) else { + SDKResponseHandler.sendInteropErrorToSDK(message: "cCompatibleAccountClientRequest.key is null", + callbackPointer: callbackPointer) + return + } let bytesValue = Google_Protobuf_BytesValue.with { $0.value = key } SDKResponseHandler.send(callbackPointer: callbackPointer, message: bytesValue) case .getAddressPrivateKeys(let request): - let privateKeys = accountClient.getAddressPrivateKeys(addressId: request.addressID) + guard let privateKeys = accountClient.getAddressPrivateKeys(addressId: request.addressID) else { + SDKResponseHandler.sendInteropErrorToSDK(message: "cCompatibleAccountClientRequest.privateKeys is null", + callbackPointer: callbackPointer) + return + } let repeatedBytes = Proton_Sdk_RepeatedBytesValue.with { $0.value = privateKeys } From 58c9798ceb36bc24417e18e3ef788bd08a4a52b0 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 May 2026 16:00:47 +0000 Subject: [PATCH 738/791] Make cryptography time monotonic --- cs/Directory.Packages.props | 2 +- .../Http/CryptographyTimeProvider.cs | 31 +++++++++++++++++++ .../Http/CryptographyTimeProvisionHandler.cs | 16 ++-------- 3 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvider.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 217fc1fc..6ace8e8c 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -16,7 +16,7 @@ - + diff --git a/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvider.cs b/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvider.cs new file mode 100644 index 00000000..6899d192 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvider.cs @@ -0,0 +1,31 @@ +namespace Proton.Sdk.Http; + +internal sealed class CryptographyTimeProvider : TimeProvider +{ + private long _ticks; + + public override DateTimeOffset GetUtcNow() + { + return new DateTimeOffset(_ticks, TimeSpan.Zero); + } + + public override long GetTimestamp() + { + throw new NotSupportedException(); + } + + internal void UpdateTime(DateTimeOffset value) + { + var ticks = value.UtcTicks; + var originalValue = _ticks; + + do + { + if (ticks <= originalValue) + { + return; + } + } + while (originalValue != (originalValue = Interlocked.CompareExchange(ref _ticks, ticks, originalValue))); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs b/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs index 3fa7de92..d4445404 100644 --- a/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs +++ b/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs @@ -4,7 +4,7 @@ namespace Proton.Sdk.Http; internal sealed class CryptographyTimeProvisionHandler : DelegatingHandler { - private readonly CryptographyTimeProvider _cryptographyTimeProvider = new(); + private static readonly CryptographyTimeProvider CryptographyTimeProvider = new(); protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { @@ -12,20 +12,10 @@ protected override async Task SendAsync(HttpRequestMessage if (responseMessage.Headers.Date is { } time) { - _cryptographyTimeProvider.ServerTime = time; - PgpEnvironment.DefaultTimeProviderOverride = _cryptographyTimeProvider; + CryptographyTimeProvider.UpdateTime(time); + PgpEnvironment.DefaultTimeProviderOverride = CryptographyTimeProvider; } return responseMessage; } - - private sealed class CryptographyTimeProvider : TimeProvider - { - public DateTimeOffset ServerTime { get; set; } - - public override DateTimeOffset GetUtcNow() - { - return ServerTime; - } - } } From 2b4bb713b102b1037bb8e0fb1855f7fa836196a2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 May 2026 15:31:23 +0200 Subject: [PATCH 739/791] Fix detecting photo drafts --- js/sdk/src/internal/photos/nodes.test.ts | 24 ++++++++++++++++++++---- js/sdk/src/internal/photos/nodes.ts | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/js/sdk/src/internal/photos/nodes.test.ts b/js/sdk/src/internal/photos/nodes.test.ts index 2b567db3..b011d2a3 100644 --- a/js/sdk/src/internal/photos/nodes.test.ts +++ b/js/sdk/src/internal/photos/nodes.test.ts @@ -55,7 +55,7 @@ function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) { }; } -function generateAPIPhotoNode(linkOverrides = {}, overrides = {}) { +function generateAPIPhotoNode(linkOverrides = {}, photoOverrides = {}, overrides = {}) { const node = generateAPINode(); return { ...node, @@ -84,6 +84,7 @@ function generateAPIPhotoNode(linkOverrides = {}, overrides = {}) { MediaType: 'image/jpeg', ContentKeyPacket: 'contentKeyPacket', ContentKeyPacketSignature: 'contentKeyPacketSig', + ...photoOverrides, }, Folder: null, ...overrides, @@ -103,12 +104,16 @@ describe('PhotosNodesAPIService', () => { }); describe('linkToEncryptedNode', () => { - async function testIterateNodes(mockedLink: object, expectedType: NodeType) { + async function testIterateNodes(mockedLink: object, expectedType?: NodeType) { apiMock.post = jest.fn().mockResolvedValue({ Links: [mockedLink] }); const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId')); - expect(nodes).toHaveLength(1); - expect(nodes[0].type).toBe(expectedType); + if (expectedType) { + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe(expectedType); + } else { + expect(nodes).toHaveLength(0); + } return nodes; } @@ -136,6 +141,17 @@ describe('PhotosNodesAPIService', () => { expect(nodes[0].photo?.albums[0].nodeUid).toBe('volumeId~albumLinkId1'); expect(nodes[0].photo?.albums[0].additionTime).toEqual(new Date(1700001000 * 1000)); }); + + it('should handle photo node with null capture time', async () => { + await testIterateNodes(generateAPIPhotoNode({}, { CaptureTime: null }), undefined); + }); + + it('should handle photo node with capture time set to zero', async () => { + const nodes = await testIterateNodes(generateAPIPhotoNode({}, { CaptureTime: 0 }), NodeType.Photo); + + expect(nodes[0].photo).toBeDefined(); + expect(nodes[0].photo?.captureTime).toEqual(new Date(0)); + }); }); }); diff --git a/js/sdk/src/internal/photos/nodes.ts b/js/sdk/src/internal/photos/nodes.ts index 32343420..9066b783 100644 --- a/js/sdk/src/internal/photos/nodes.ts +++ b/js/sdk/src/internal/photos/nodes.ts @@ -55,7 +55,7 @@ export class PhotosNodesAPIService extends NodeAPIServiceBase Date: Wed, 6 May 2026 07:07:54 +0000 Subject: [PATCH 740/791] Update changelog for cs/v0.14.2 --- cs/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 8e0770bf..b95a0305 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## cs/v0.14.2 (2026-05-06) + +* Dispose upload controller in test to see events +* Make cryptography time monotonic +* Optional AccountClientProtocol + interop nil handling + ## cs/v0.14.1 (2026-05-01) * Refactor Proton API exception to consolidate constructor initialization From 8ee9841947c2b0e8a0c973d071429df6340ced16 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 6 May 2026 12:10:50 +0000 Subject: [PATCH 741/791] Update changelog for js/v0.15.0 --- js/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 7fbd72f8..9fd55ac0 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## js/v0.15.0 (2026-05-06) + +* Fix detecting photo drafts +* Handle loading drafts +* BatchSize for remove_multiple on photos should be 10 +* Add events subscriptions for CLI +* Fix TypeError not being recognized as NetworkError +* Integrate @protontech/crypto +* Add upload and download commands + ## js/v0.14.10 (2026-04-27) * Update cached album photo count after adding or removing photo From 353fc279696aa8c5f561b1d4eeb54cafde55f046 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 12:46:59 +0200 Subject: [PATCH 742/791] Remove slash validation name after decryption --- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs | 5 +++-- js/sdk/src/internal/nodes/nodesAccess.test.ts | 4 ++-- js/sdk/src/internal/nodes/nodesManagement.test.ts | 2 +- js/sdk/src/internal/nodes/nodesManagement.ts | 4 ++-- js/sdk/src/internal/nodes/validations.ts | 5 +---- js/sdk/src/internal/photos/albumsManager.test.ts | 4 ++-- js/sdk/src/internal/photos/albumsManager.ts | 2 +- js/sdk/src/internal/sharing/cryptoService.test.ts | 6 +++--- 8 files changed, 15 insertions(+), 17 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index d1d59fbf..61d18b88 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -18,6 +18,7 @@ namespace Proton.Drive.Sdk.Nodes; internal static class NodeOperations { private const int MaximumBatchCount = 150; + private const int MaxNodeNameLength = 255; public static async ValueTask GetOrCreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) { @@ -565,9 +566,9 @@ public static bool ValidateName( return false; } - if (name.Contains('/')) + if (name.Length > MaxNodeNameLength) { - nameResult = new InvalidNameError(name, "Name must not contain the character '/'"); + nameResult = new InvalidNameError(name, $"Name must be {MaxNodeNameLength} characters long at most"); return false; } diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index f3275d1f..83601204 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -134,11 +134,11 @@ describe('nodesAccess', () => { const decryptedUnparsedNode = { uid: 'volumeId~nodeId', parentUid: 'volumeId~parentNodeid', - name: { ok: true, value: 'foo/bar' }, + name: { ok: true, value: '' }, } as DecryptedUnparsedNode; const decryptedNode = { ...decryptedUnparsedNode, - name: { ok: false, error: { name: 'foo/bar', error: "Name must not contain the character '/'" } }, + name: { ok: false, error: { name: '', error: "Name must not be empty" } }, treeEventScopeId: 'volumeId', } as DecryptedNode; const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index 4886a63e..c71ec8f8 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -426,7 +426,7 @@ describe('NodesManagement', () => { }); it('copyNode throws error if name is invalid', async () => { - const promise = management.copyNode('nodeUid', 'newParentNodeUid', 'invalid/name'); + const promise = management.copyNode('nodeUid', 'newParentNodeUid', ''); await expect(promise).rejects.toThrow(ValidationError); }); diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index af6d5323..deff50c9 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -236,12 +236,12 @@ export abstract class NodesManagementBase< } async copyNode(nodeUid: string, newParentUid: string, name?: string): Promise { - if (name) { + if (name !== undefined) { validateNodeName(name); } const node = await this.nodesAccess.getNode(nodeUid); - const nodeName = name ? resultOk(name) : node.name; + const nodeName = name !== undefined ? resultOk(name) : node.name; const [keys, newParentKeys, signingKeys] = await Promise.all([ this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid), diff --git a/js/sdk/src/internal/nodes/validations.ts b/js/sdk/src/internal/nodes/validations.ts index 00ef5ac8..32f35b8b 100644 --- a/js/sdk/src/internal/nodes/validations.ts +++ b/js/sdk/src/internal/nodes/validations.ts @@ -5,7 +5,7 @@ import { ValidationError } from '../../errors'; const MAX_NODE_NAME_LENGTH = 255; /** - * @throws Error if the name is empty, long, or includes slash in the name. + * @throws Error if the name is empty or long. */ export function validateNodeName(name: string): void { if (!name) { @@ -20,7 +20,4 @@ export function validateNodeName(name: string): void { ), ); } - if (name.includes('/')) { - throw new ValidationError(c('Error').t`Name must not contain the character '/'`); - } } diff --git a/js/sdk/src/internal/photos/albumsManager.test.ts b/js/sdk/src/internal/photos/albumsManager.test.ts index 62491432..e3826e9f 100644 --- a/js/sdk/src/internal/photos/albumsManager.test.ts +++ b/js/sdk/src/internal/photos/albumsManager.test.ts @@ -152,7 +152,7 @@ describe('Albums', () => { }); it('throws validation error for invalid album name', async () => { - await expect(albums.createAlbum('invalid/name')).rejects.toThrow(ValidationError); + await expect(albums.createAlbum('')).rejects.toThrow(ValidationError); }); it('throws error when parent hash key is not available', async () => { @@ -228,7 +228,7 @@ describe('Albums', () => { }); it('throws validation error for invalid album name', async () => { - await expect(albums.updateAlbum('albumNodeUid', { name: 'invalid/name' })).rejects.toThrow(ValidationError); + await expect(albums.updateAlbum('albumNodeUid', { name: '' })).rejects.toThrow(ValidationError); }); }); diff --git a/js/sdk/src/internal/photos/albumsManager.ts b/js/sdk/src/internal/photos/albumsManager.ts index 03e58d93..53910262 100644 --- a/js/sdk/src/internal/photos/albumsManager.ts +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -133,7 +133,7 @@ export class AlbumsManager { coverPhotoNodeUid?: string; }, ): Promise { - if (updates.name) { + if (updates.name !== undefined) { validateNodeName(updates.name); } diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts index f0d24907..846cbbbf 100644 --- a/js/sdk/src/internal/sharing/cryptoService.test.ts +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -169,7 +169,7 @@ describe('SharingCryptoService', () => { it('should handle invalid node name', async () => { driveCrypto.decryptNodeName = jest.fn().mockResolvedValue({ - name: 'invalid/name', + name: '', }); const result = await cryptoService.decryptBookmark(encryptedBookmark); @@ -177,8 +177,8 @@ describe('SharingCryptoService', () => { expect(result).toMatchObject({ url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), nodeName: resultError({ - name: 'invalid/name', - error: "Name must not contain the character '/'", + name: '', + error: "Name must not be empty", }), }); }); From a69225ce9881185a773bd3ba5d75e06adef871f2 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 15:28:13 +0200 Subject: [PATCH 743/791] Fix handling of mismatch between uploaded and intended sizes --- .../Api/Files/RevisionCreationRequest.cs | 2 + .../Nodes/Download/RevisionReader.cs | 2 +- .../Proton.Drive.Sdk/Nodes/TransferQueue.cs | 52 ++++++++++++++----- .../Nodes/Upload/FileUploader.cs | 7 ++- .../Nodes/Upload/IRevisionDraftProvider.cs | 2 +- .../Nodes/Upload/NewFileDraftProvider.cs | 17 ++++-- .../Nodes/Upload/NewRevisionDraftProvider.cs | 6 ++- .../Nodes/Upload/RevisionDraft.cs | 3 ++ .../Nodes/Upload/RevisionWriter.cs | 20 ++++--- 9 files changed, 76 insertions(+), 35 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs index f9232cd5..41b4bf8c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs @@ -9,4 +9,6 @@ internal struct RevisionCreationRequest [JsonPropertyName("ClientUID")] public string? ClientId { get; init; } + + public long? IntendedUploadSize { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 746b1793..29661751 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -284,7 +284,7 @@ private async Task DownloadBlockAsync(BlockDto block, Cance currentPageBlocks.AddRange(revisionDto.Blocks); currentPageBlocks.Sort((a, b) => a.Index.CompareTo(b.Index)); - _client.DownloadQueue.IncreaseFileRemainingBlockCount(_state.QueueToken, currentPageBlocks.Count - initialQueueCountToSubtract); + _client.DownloadQueue.IncreaseFileBlockCount(_state.QueueToken, currentPageBlocks.Count - initialQueueCountToSubtract); initialQueueCountToSubtract = 0; var blocksExceptLast = currentPageBlocks.Take(currentPageBlocks.Count - 1); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs index 6f145524..ead6b27c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs @@ -15,7 +15,7 @@ namespace Proton.Drive.Sdk.Nodes; /// Next, the transfer has to start queuing blocks, but only one file can be queuing blocks at a time, /// so a call to is required. Once all the blocks have been queued, or if the queuing needs to be stopped for any reason, /// a call to is required to allow other files to start queuing their blocks. -/// When new blocks are discovered for a file during queuing, they can be added to the file's block count using . +/// When new blocks are discovered for a file during queuing, they can be added to the file's block count using . /// When blocks have been transferred, they can be removed from the file's block count using . /// /// @@ -31,7 +31,7 @@ namespace Proton.Drive.Sdk.Nodes; internal sealed partial class TransferQueue(int maxDegreeOfParallelism, ILogger logger) { private readonly ILogger _logger = logger; - private readonly Dictionary _fileBlocks = []; + private readonly Dictionary _fileBlocks = []; private readonly Lock _fileBlocksLock = new(); private long _lastEntryId; @@ -60,7 +60,7 @@ internal sealed partial class TransferQueue(int maxDegreeOfParallelism, ILogger lock (_fileBlocksLock) { - _fileBlocks.Add(queuePosition, initialBlockCount); + _fileBlocks.Add(queuePosition, (initialBlockCount, initialBlockCount)); } return queuePosition; @@ -80,31 +80,55 @@ public async ValueTask EnqueueFileAsync(int initialBlockCount, Cancellatio lock (_fileBlocksLock) { - _fileBlocks.Add(queuePosition, initialBlockCount); + _fileBlocks.Add(queuePosition, (initialBlockCount, initialBlockCount)); } return queuePosition; } - public void IncreaseFileRemainingBlockCount(long queueToken, int additionalBlockCount) + public void SetFileTotalBlockCount(long queueToken, int total) { - ArgumentOutOfRangeException.ThrowIfNegative(additionalBlockCount); + lock (_fileBlocksLock) + { + var (currentRemaining, currentTotal) = _fileBlocks.TryGetValue(queueToken, out var blockCount) + ? blockCount + : throw new InvalidOperationException($"Queue token {queueToken} not found in transfer queue."); + + var delta = total - currentTotal; + + if (delta > 0) + { + FileQueueSemaphore.DecreaseCount(delta); + LogDecreasedFileQueueSemaphoreCount(delta, FileQueueSemaphore.CurrentCount); + } + else + { + RemoveBlocksFromFileQueue(-delta); + } - FileQueueSemaphore.DecreaseCount(additionalBlockCount); + _fileBlocks[queueToken] = (currentRemaining + delta, total); + } + } - LogDecreasedFileQueueSemaphoreCount(additionalBlockCount, FileQueueSemaphore.CurrentCount); + public void IncreaseFileBlockCount(long queueToken, int amount) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + FileQueueSemaphore.DecreaseCount(amount); + + LogDecreasedFileQueueSemaphoreCount(amount, FileQueueSemaphore.CurrentCount); lock (_fileBlocksLock) { var currentBlockCount = _fileBlocks.GetValueOrDefault(queueToken); - _fileBlocks[queueToken] = currentBlockCount + additionalBlockCount; + _fileBlocks[queueToken] = (currentBlockCount.Remaining + amount, currentBlockCount.Total + amount); } } - public void DecreaseFileRemainingBlockCount(long queueToken, int count) + public void DecreaseFileRemainingBlockCount(long queueToken, int amount) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); lock (_fileBlocksLock) { @@ -113,9 +137,9 @@ public void DecreaseFileRemainingBlockCount(long queueToken, int count) throw new InvalidOperationException($"Queue token {queueToken} not found in transfer queue."); } - _fileBlocks[queueToken] = currentBlockCount - count; + RemoveBlocksFromFileQueue(amount); - RemoveBlocksFromFileQueue(count); + _fileBlocks[queueToken] = (currentBlockCount.Remaining - amount, currentBlockCount.Total); } } @@ -128,7 +152,7 @@ public void RemoveFileFromQueue(long queueToken) throw new InvalidOperationException($"Queue token {queueToken} not found in transfer queue."); } - RemoveBlocksFromFileQueue(blockCount); + RemoveBlocksFromFileQueue(blockCount.Remaining); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 2022453b..7f16f16f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -94,7 +94,7 @@ public void Dispose() long size, FileUploadMetadata metadata) { - var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(client.TargetBlockSize); if (client.UploadQueue.TryEnqueueFile(expectedNumberOfBlocks) is not { } queueToken) { @@ -119,7 +119,7 @@ internal static async ValueTask CreateAsync( FileUploadMetadata metadata, CancellationToken cancellationToken) { - var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(RevisionWriter.DefaultBlockSize); + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(client.TargetBlockSize); var queueToken = await client.UploadQueue.EnqueueFileAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); @@ -205,7 +205,7 @@ private async Task UploadFromStreamAsync( var revisionDraft = revisionDraftTaskCompletionSource.Task.GetResultIfCompletedSuccessfully(); if (revisionDraft is null) { - revisionDraft = await _revisionDraftProvider.GetDraftAsync(cancellationToken).ConfigureAwait(false); + revisionDraft = await _revisionDraftProvider.GetDraftAsync(FileSize, cancellationToken).ConfigureAwait(false); revisionDraftTaskCompletionSource.SetResult(revisionDraft); } @@ -260,7 +260,6 @@ private async ValueTask UploadAsync( await revisionWriter.WriteAsync( contentStream, - FileSize, expectedSha1, thumbnails, _metadata, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs index a99f4043..472d4921 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs @@ -2,5 +2,5 @@ namespace Proton.Drive.Sdk.Nodes.Upload; internal interface IRevisionDraftProvider { - ValueTask GetDraftAsync(CancellationToken cancellationToken); + ValueTask GetDraftAsync(long intendedUploadSize, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 300b8026..91eaa3b0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -29,16 +29,22 @@ internal NewFileDraftProvider( _overrideExistingDraftByOtherClient = overrideExistingDraftByOtherClient; } - public async ValueTask GetDraftAsync(CancellationToken cancellationToken) + public async ValueTask GetDraftAsync(long intendedUploadSize, CancellationToken cancellationToken) { + ArgumentOutOfRangeException.ThrowIfNegative(intendedUploadSize); + var parentSecrets = await FolderOperations.GetSecretsAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); var membershipAddress = await NodeOperations.GetMembershipAddressAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); var signingKey = await _client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var (response, fileSecrets) = await CreateDraftAsync(parentSecrets, signingKey, membershipAddress.EmailAddress, cancellationToken) - .ConfigureAwait(false); + var (response, fileSecrets) = await CreateDraftAsync( + intendedUploadSize, + parentSecrets, + signingKey, + membershipAddress.EmailAddress, + cancellationToken).ConfigureAwait(false); var draftNodeUid = new NodeUid(_parentUid.VolumeId, response.Identifiers.LinkId); var draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); @@ -55,11 +61,13 @@ public async ValueTask GetDraftAsync(CancellationToken cancellati parentSecrets.HashKey, membershipAddress, blockVerifier, + intendedUploadSize, ct => DeleteDraftAsync(draftRevisionUid, ct), _client.Telemetry.GetLogger("New file draft")); } private static FileCreationRequest GetFileCreationRequest( + long intendedUploadSize, string clientUid, NodeUid parentUid, string name, @@ -105,10 +113,12 @@ private static FileCreationRequest GetFileCreationRequest( MediaType = mediaType, ContentKeyPacket = nodeKey.EncryptSessionKey(contentKey), ContentKeySignature = nodeKey.Sign(contentKey.Export()), + IntendedUploadSize = intendedUploadSize, }; } private async ValueTask<(FileCreationResponse Response, FileSecrets FileSecrets)> CreateDraftAsync( + long intendedUploadSize, FolderSecrets parentSecrets, PgpPrivateKey signingKey, string membershipEmailAddress, @@ -124,6 +134,7 @@ private static FileCreationRequest GetFileCreationRequest( while (result is null) { var request = GetFileCreationRequest( + intendedUploadSize, _client.Uid, _parentUid, _name, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index ed424e21..2066beb0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -22,12 +22,15 @@ internal NewRevisionDraftProvider( _lastKnownRevisionId = lastKnownRevisionId; } - public async ValueTask GetDraftAsync(CancellationToken cancellationToken) + public async ValueTask GetDraftAsync(long intendedUploadSize, CancellationToken cancellationToken) { + ArgumentOutOfRangeException.ThrowIfNegative(intendedUploadSize); + var parameters = new RevisionCreationRequest { CurrentRevisionId = _lastKnownRevisionId, ClientId = _client.Uid, + IntendedUploadSize = intendedUploadSize, }; var fileSecretsResult = await FileOperations.GetSecretsAsync(_client, _fileUid, cancellationToken).ConfigureAwait(false); @@ -78,6 +81,7 @@ public async ValueTask GetDraftAsync(CancellationToken cancellati parentHashKey: null, membershipAddress, blockVerifier, + intendedUploadSize, ct => DeleteDraftAsync(draftRevisionUid, ct), _client.Telemetry.GetLogger("New file draft")); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs index 69017e47..83b932c8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -17,6 +17,7 @@ internal sealed partial class RevisionDraft( ReadOnlyMemory? parentHashKey, Address membershipAddress, IBlockVerifier blockVerifier, + long intendedUploadSize, Func deleteDraftFunction, ILogger logger) : IAsyncDisposable { @@ -43,6 +44,8 @@ internal sealed partial class RevisionDraft( public bool IsResumable { get; set; } = true; public long NumberOfPlainBytesDone { get; set; } + public long IntendedUploadSize { get; } = intendedUploadSize; + public void SetContentBlockPlainData(int blockNumber, BlockUploadPlainData plainData) { lock (_blockUploadStatesLock) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index ca9c3fc7..b5df09b2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -39,7 +39,6 @@ internal RevisionWriter( public async ValueTask WriteAsync( Stream contentStream, - long expectedContentLength, Lazy>? expectedSha1, IEnumerable thumbnails, FileUploadMetadata metadata, @@ -63,7 +62,6 @@ public async ValueTask WriteAsync( request = CreatePhotosRevisionUpdateRequest( photoMetadata, - expectedContentLength, expectedThumbnailBlockCount, expectedSha1, sha1Digest, @@ -74,7 +72,6 @@ public async ValueTask WriteAsync( { request = CreateRevisionUpdateRequest( metadata, - expectedContentLength, expectedThumbnailBlockCount, expectedSha1, sha1Digest, @@ -142,7 +139,8 @@ private async ValueTask UploadBlocksAsync( { expectedThumbnailBlockCount = await UploadThumbnailBlocksAsync(thumbnails, uploadTasks, cancellationToken).ConfigureAwait(false); - await UploadContentBlocksAsync(onProgress, hashingContentStream, uploadTasks, cancellationToken).ConfigureAwait(false); + await UploadContentBlocksAsync(onProgress, hashingContentStream, uploadTasks, expectedThumbnailBlockCount, cancellationToken) + .ConfigureAwait(false); } finally { @@ -177,7 +175,6 @@ private async ValueTask UploadBlocksAsync( private RevisionUpdateRequest CreateRevisionUpdateRequest( FileUploadMetadata metadata, - long expectedContentLength, int expectedThumbnailBlockCount, Lazy>? expectedSha1, byte[] sha1Digest, @@ -217,11 +214,11 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( uploadedContentSize += plaintextSize; } - if (uploadedContentSize != expectedContentLength) + if (uploadedContentSize != _draft.IntendedUploadSize) { throw new ContentSizeMismatchIntegrityException( uploadedSize: uploadedContentSize, - expectedSize: expectedContentLength); + expectedSize: _draft.IntendedUploadSize); } if (expectedThumbnailBlockCount != _draft.OrderedThumbnailUploadResults.Count) @@ -276,7 +273,6 @@ private RevisionUpdateRequest CreateRevisionUpdateRequest( private RevisionUpdateRequest CreatePhotosRevisionUpdateRequest( PhotosFileUploadMetadata metadata, - long expectedContentLength, int expectedThumbnailBlockCount, Lazy>? expectedSha1, byte[] sha1Digest, @@ -285,7 +281,6 @@ private RevisionUpdateRequest CreatePhotosRevisionUpdateRequest( { var request = CreateRevisionUpdateRequest( metadata, - expectedContentLength, expectedThumbnailBlockCount, expectedSha1, sha1Digest, @@ -338,8 +333,6 @@ private async ValueTask UploadThumbnailBlocksAsync( foreach (var thumbnail in thumbnails) { - _client.UploadQueue.IncreaseFileRemainingBlockCount(_queueToken, 1); - ++blockCount; if (_draft.ThumbnailBlockWasAlreadyUploaded(thumbnail.Type)) @@ -347,6 +340,8 @@ private async ValueTask UploadThumbnailBlocksAsync( continue; } + _client.UploadQueue.IncreaseFileBlockCount(_queueToken, 1); + await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); var uploadTask = UploadThumbnailBlockAsync(thumbnail, cancellationToken).AsTask(); @@ -379,6 +374,7 @@ private async ValueTask UploadContentBlocksAsync( Action? onProgress, HashingReadStream hashingContentStream, Queue> uploadTasks, + int expectedThumbnailBlockCount, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -403,6 +399,8 @@ await TryGetNextContentBlockPlainDataAsync( currentBlockNumber = newBlockNumber; + _client.UploadQueue.SetFileTotalBlockCount(_queueToken, currentBlockNumber.Value + expectedThumbnailBlockCount); + // ReSharper disable once PossiblyMistakenUseOfCancellationToken await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); From fd1ecbbee8c7fa8fdf888c2f593f12d21b58b24c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 21:54:24 +0000 Subject: [PATCH 744/791] Fix upload failing to resume when blocks were uploaded out of order --- .../Proton.Drive.Sdk/Nodes/TransferQueue.cs | 19 ++++++++++--------- .../Nodes/Upload/RevisionWriter.cs | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs index ead6b27c..f1b2e853 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs @@ -86,7 +86,10 @@ public async ValueTask EnqueueFileAsync(int initialBlockCount, Cancellatio return queuePosition; } - public void SetFileTotalBlockCount(long queueToken, int total) + /// + /// Increases the total and remaining block counts for a file if the given total is greater than the current one. + /// + public void ApplyFileMinimumTotalBlockCount(long queueToken, int total) { lock (_fileBlocksLock) { @@ -95,17 +98,15 @@ public void SetFileTotalBlockCount(long queueToken, int total) : throw new InvalidOperationException($"Queue token {queueToken} not found in transfer queue."); var delta = total - currentTotal; - - if (delta > 0) + if (delta <= 0) { - FileQueueSemaphore.DecreaseCount(delta); - LogDecreasedFileQueueSemaphoreCount(delta, FileQueueSemaphore.CurrentCount); - } - else - { - RemoveBlocksFromFileQueue(-delta); + return; } + FileQueueSemaphore.DecreaseCount(delta); + + LogDecreasedFileQueueSemaphoreCount(delta, FileQueueSemaphore.CurrentCount); + _fileBlocks[queueToken] = (currentRemaining + delta, total); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index b5df09b2..e36bdba6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -399,7 +399,7 @@ await TryGetNextContentBlockPlainDataAsync( currentBlockNumber = newBlockNumber; - _client.UploadQueue.SetFileTotalBlockCount(_queueToken, currentBlockNumber.Value + expectedThumbnailBlockCount); + _client.UploadQueue.ApplyFileMinimumTotalBlockCount(_queueToken, currentBlockNumber.Value + expectedThumbnailBlockCount); // ReSharper disable once PossiblyMistakenUseOfCancellationToken await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); From dc80f26ef207c44bc866c47d035980b5bb9210cf Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 6 May 2026 16:45:42 +0200 Subject: [PATCH 745/791] Add info log for uploader and downloader --- .../proton/drive/sdk/internal/InteropProtonDriveClient.kt | 4 +++- .../proton/drive/sdk/internal/InteropProtonPhotosClient.kt | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt index a08a6c6f..cdbd8ae8 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt @@ -37,7 +37,6 @@ import proton.drive.sdk.driveClientGetNodeRequest import proton.drive.sdk.driveClientRenameRequest import proton.drive.sdk.driveClientRestoreNodesRequest import proton.drive.sdk.driveClientTrashNodesRequest -import proton.drive.sdk.drivePhotosClientGetNodeRequest import java.time.Instant import kotlin.time.Duration @@ -235,6 +234,7 @@ internal class InteropProtonDriveClient internal constructor( revisionUid: RevisionUid, timeout: Duration, ): Downloader = withTimeout(timeout) { + log(INFO, "downloader") cancellationCoroutineScope { source -> factory(JniFileDownloader()) { FileDownloader( @@ -255,6 +255,7 @@ internal class InteropProtonDriveClient internal constructor( request: FileUploaderRequest, timeout: Duration, ): Uploader = withTimeout(timeout) { + log(INFO, "fileUploader") cancellationCoroutineScope { source -> JniFileUploader().run { FileUploader( @@ -275,6 +276,7 @@ internal class InteropProtonDriveClient internal constructor( request: FileRevisionUploaderRequest, timeout: Duration, ): Uploader = withTimeout(timeout) { + log(INFO, "fileRevisionUploader") cancellationCoroutineScope { source -> JniFileUploader().run { FileUploader( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt index d02aac2d..f3e40343 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt @@ -77,7 +77,9 @@ internal class InteropProtonPhotosClient internal constructor( } } - override suspend fun getNode(nodeUid: NodeUid): NodeResult? = cancellationCoroutineScope { source -> + override suspend fun getNode( + nodeUid: NodeUid, + ): NodeResult? = cancellationCoroutineScope { source -> log(DEBUG, "getNode") bridge.getNode( drivePhotosClientGetNodeRequest { @@ -158,6 +160,7 @@ internal class InteropProtonPhotosClient internal constructor( photoUid: NodeUid, timeout: Duration, ): Downloader = withTimeout(timeout) { + log(INFO, "downloader") cancellationCoroutineScope { source -> factory(JniPhotosDownloader()) { PhotosDownloader( @@ -178,6 +181,7 @@ internal class InteropProtonPhotosClient internal constructor( request: PhotosUploaderRequest, timeout: Duration, ): Uploader = withTimeout(timeout) { + log(INFO, "photosUploader") cancellationCoroutineScope { source -> JniPhotosUploader().run { PhotosUploader( From 250f36075a5070e9e9969c7b71e0dc4661ea2b04 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 05:03:20 +0000 Subject: [PATCH 746/791] i18n(weekly-mr): Upgrade translations from crowdin (a87f8802). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/be_BY.json | 3 --- js/sdk/locales/ca_ES.json | 3 --- js/sdk/locales/de_DE.json | 3 --- js/sdk/locales/el_GR.json | 3 --- js/sdk/locales/es_ES.json | 3 --- js/sdk/locales/es_LA.json | 3 --- js/sdk/locales/fr_FR.json | 3 --- js/sdk/locales/it_IT.json | 3 --- js/sdk/locales/ko_KR.json | 3 --- js/sdk/locales/nl_NL.json | 3 --- js/sdk/locales/pl_PL.json | 3 --- js/sdk/locales/pt_BR.json | 3 --- js/sdk/locales/pt_PT.json | 3 --- js/sdk/locales/ro_RO.json | 3 --- js/sdk/locales/ru_RU.json | 3 --- js/sdk/locales/sk_SK.json | 3 --- js/sdk/locales/tr_TR.json | 3 --- 18 files changed, 1 insertion(+), 52 deletions(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index db2b676f..4f068753 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "d15557efd9078dcb39b27bfef965f543bb169bcf" + "locale": "e3111b3cf7f5015cf8860b94978d21910ea2caa9" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 161e600b..702f07af 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -158,9 +158,6 @@ "Name must not be empty": [ "Ðазва не можа быць пуÑтой" ], - "Name must not contain the character '/'": [ - "Ðазва не павінна змÑшчаць Ñімвал '/'" - ], "No available name found": [ "ДаÑÑ‚ÑƒÐ¿Ð½Ð°Ñ Ð½Ð°Ð·Ð²Ð° не знойдзена" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 1a9cb548..56210cc2 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "El nom no pot estar buit." ], - "Name must not contain the character '/'": [ - "El nom no pot contenir el caràcter '/'" - ], "No available name found": [ "No s'ha trobat cap nom disponible" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index c6eda05a..2e0ef23b 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "Name darf nicht leer sein" ], - "Name must not contain the character '/'": [ - "Name darf das Zeichen \"/\" nicht enthalten." - ], "No available name found": [ "Kein verfügbarer Name gefunden" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index bb835e01..59e3a4c4 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "Το όνομα δεν Ï€Ïέπει να είναι κενό" ], - "Name must not contain the character '/'": [ - "Το όνομα δεν Ï€Ïέπει να πεÏιέχει τον χαÏακτήÏα '/'" - ], "No available name found": [ "Δεν βÏέθηκε διαθέσιμο όνομα" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 99542e0f..0dbb008e 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "El nombre no debe estar vacío." ], - "Name must not contain the character '/'": [ - "El nombre no debe contener el carácter «/»." - ], "No available name found": [ "No se encontró ningún nombre disponible" ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index f43f727d..97f47b56 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "El nombre no debe estar vacío" ], - "Name must not contain the character '/'": [ - "El nombre no debe contener el caracter «/»." - ], "No available name found": [ "No se encontró ningún nombre disponible" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index d7053dcc..e7a1c176 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "Le nom ne doit pas être vide." ], - "Name must not contain the character '/'": [ - "Le nom ne doit pas contenir le caractère « / »." - ], "No available name found": [ "Aucun nom disponible trouvé" ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index a7f10927..be831aea 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "Nome necessario" ], - "Name must not contain the character '/'": [ - "Il nome non deve contenere il carattere '/'" - ], "No available name found": [ "Non è stato trovato nessun nome disponibile" ], diff --git a/js/sdk/locales/ko_KR.json b/js/sdk/locales/ko_KR.json index bd64c7ee..2a18de1a 100644 --- a/js/sdk/locales/ko_KR.json +++ b/js/sdk/locales/ko_KR.json @@ -146,9 +146,6 @@ "Name must not be empty": [ "ì´ë¦„ì€ ë¹„ì›Œë‘˜ 수 없습니다" ], - "Name must not contain the character '/'": [ - "ì´ë¦„ì— ë¬¸ìž '/'ì„ í¬í•¨í•  수 없습니다" - ], "No available name found": [ "사용 가능한 ì´ë¦„ì„ ì°¾ì„ ìˆ˜ ì—†ìŒ" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index 5bcc0cf0..c202ae44 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "Naam mag niet leeg zijn" ], - "Name must not contain the character '/'": [ - "Naam mag het teken '/' niet bevatten" - ], "No available name found": [ "Geen beschikbare naam gevonden" ], diff --git a/js/sdk/locales/pl_PL.json b/js/sdk/locales/pl_PL.json index fe69188e..d4b3cf11 100644 --- a/js/sdk/locales/pl_PL.json +++ b/js/sdk/locales/pl_PL.json @@ -134,9 +134,6 @@ "Name must not be empty": [ "Nazwa nie może być pusta" ], - "Name must not contain the character '/'": [ - "Nazwa nie może zawierać znaku '/'" - ], "No available name found": [ "Nie znaleziono dostÄ™pnej nazwy" ], diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index 94b3050c..320a3f2b 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -138,9 +138,6 @@ "Name must not be empty": [ "O nome não pode estar vazio" ], - "Name must not contain the character '/'": [ - "O nome não pode conter o caractere \"/\"" - ], "No available name found": [ "Nenhum nome disponível encontrado" ], diff --git a/js/sdk/locales/pt_PT.json b/js/sdk/locales/pt_PT.json index 4ba91400..0b7cd641 100644 --- a/js/sdk/locales/pt_PT.json +++ b/js/sdk/locales/pt_PT.json @@ -108,9 +108,6 @@ "Name must not be empty": [ "O nome não pode estar vazio." ], - "Name must not contain the character '/'": [ - "O nome não deve conter barras «/»." - ], "Node has no thumbnail": [ "O nó não tem miniatura." ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index d5daedb0..1cd0692a 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -157,9 +157,6 @@ "Name must not be empty": [ "Numele nu poate fi gol." ], - "Name must not contain the character '/'": [ - "Numele nu trebuie să conÈ›ină caracterul „/â€." - ], "No available name found": [ "Nu a fost găsit niciun nume disponibil." ], diff --git a/js/sdk/locales/ru_RU.json b/js/sdk/locales/ru_RU.json index b18ffd7a..f6aaca60 100644 --- a/js/sdk/locales/ru_RU.json +++ b/js/sdk/locales/ru_RU.json @@ -119,9 +119,6 @@ "Name must not be empty": [ "Ðазвание не может быть пуÑтым" ], - "Name must not contain the character '/'": [ - "Ð˜Ð¼Ñ Ð½Ðµ должно Ñодержать Ñимвол «/»" - ], "Node has no thumbnail": [ "У узла нет значка" ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index fe9972f9..9f2850c8 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -158,9 +158,6 @@ "Name must not be empty": [ "Názov nesmie byÅ¥ prázdny" ], - "Name must not contain the character '/'": [ - "Názov nesmie obsahovaÅ¥ znak '/'" - ], "No available name found": [ "NenaÅ¡iel sa žiadny dostupný názov" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index e593879f..82ae1cbe 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -156,9 +156,6 @@ "Name must not be empty": [ "Ad boÅŸ olamaz" ], - "Name must not contain the character '/'": [ - "Ad içinde \"/\" karakteri bulunamaz." - ], "No available name found": [ "Kullanılabilecek bir ad bulunamadı" ], From d0bb1bd051b21f260797d41a4956d79dc0c1f577 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 09:51:57 +0000 Subject: [PATCH 747/791] Flatten messages of decryption errors reported to telemetry --- .../InteropProtonDriveClient.cs | 3 +- .../Proton.Drive.Sdk/ExceptionExtensions.cs | 1 + .../ProtonDriveErrorExtensions.cs | 34 +++++++++++++++++++ .../Telemetry/TelemetryEventFactory.cs | 2 +- 4 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ProtonDriveErrorExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index c693340f..45a30456 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -448,7 +448,8 @@ public static AuthorResult ParseAuthorResult(Result e.Message) + .OfType() + .Where(m => + { + if (m == previousMessage) + { + return false; + } + + previousMessage = m; + return true; + })); + } + + private static IEnumerable EnumerateErrorHierarchy(ProtonDriveError error) + { + for (var e = error; e != null; e = e.InnerError) + { + yield return e; + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs index 5ef2984b..e82f9016 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs @@ -27,7 +27,7 @@ public static async Task> CreateDecryptionErro Field = field.Key, VolumeType = volumeType, FromBefore2024 = fromBefore2024, - Error = field.Value.Message, + Error = field.Value.FlattenMessage(), }); } From 7455c0efe6592d4bcc83150065cfd25b63b62967 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 10:54:20 +0000 Subject: [PATCH 748/791] Classify HTTP response code 499 as server error --- .../DriveInteropTelemetryDecorator.cs | 10 +++-- .../Nodes/Download/RevisionReader.cs | 3 +- .../Nodes/Upload/RevisionWriter.cs | 3 +- .../Telemetry/TelemetryErrorResolver.cs | 19 ++++++--- cs/sdk/src/Proton.Sdk/Http/StatusCodes.cs | 39 +++++++++++++++++++ 5 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 cs/sdk/src/Proton.Sdk/Http/StatusCodes.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index 99810cb6..db9f0e1d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -1,7 +1,9 @@ +using System.Net; using Google.Protobuf; using Microsoft.Extensions.Logging; using Proton.Drive.Sdk.Telemetry; using Proton.Sdk.CExports; +using Proton.Sdk.Http; using Proton.Sdk.Telemetry; namespace Proton.Drive.Sdk.CExports; @@ -138,8 +140,8 @@ private static Telemetry.UploadError TranslateApiErrorToUploadError(long statusC { return statusCode switch { - 429 => Telemetry.UploadError.RateLimited, - >= 400 and < 500 => Telemetry.UploadError.HttpClientSideError, + (int)HttpStatusCode.TooManyRequests => Telemetry.UploadError.RateLimited, + >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode => Telemetry.UploadError.HttpClientSideError, _ => Telemetry.UploadError.ServerError, }; } @@ -166,8 +168,8 @@ private static Telemetry.DownloadError TranslateApiErrorToDownloadError(long sta { return statusCode switch { - 429 => Telemetry.DownloadError.RateLimited, - >= 400 and < 500 => Telemetry.DownloadError.HttpClientSideError, + (int)HttpStatusCode.TooManyRequests => Telemetry.DownloadError.RateLimited, + >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode => Telemetry.DownloadError.HttpClientSideError, _ => Telemetry.DownloadError.ServerError, }; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 29661751..1dd9fb79 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -4,6 +4,7 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Files; using Proton.Sdk; +using Proton.Sdk.Http; namespace Proton.Drive.Sdk.Nodes.Download; @@ -79,7 +80,7 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action private static bool IsResumableError(Exception ex) { return ex is not DataIntegrityException - and not ProtonApiException { TransportCode: >= 400 and < 500 } + and not ProtonApiException { TransportCode: >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode } and not CompletedDownloadManifestVerificationException and not InvalidOperationException; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index e36bdba6..5ac2a434 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -9,6 +9,7 @@ using Proton.Drive.Sdk.Serialization; using Proton.Sdk; using Proton.Sdk.Api; +using Proton.Sdk.Http; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -112,7 +113,7 @@ await _client.Api.Files.UpdateRevisionAsync( private static bool IsResumableError(Exception ex) { - return ex is not ProtonApiException { TransportCode: >= 400 and < 500 } + return ex is not ProtonApiException { TransportCode: >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode } and not NodeWithSameNameExistsException and not IntegrityException and not InvalidOperationException; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index 20253bfb..282cdeb2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -4,6 +4,7 @@ using Proton.Drive.Sdk.Nodes.Upload; using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Sdk; +using Proton.Sdk.Http; namespace Proton.Drive.Sdk.Telemetry; @@ -24,12 +25,15 @@ internal static class TelemetryErrorResolver HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => DownloadError.ServerError, HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => DownloadError.ServerError, - HttpRequestException { StatusCode: >= (HttpStatusCode)400 and < (HttpStatusCode)500 } => DownloadError.HttpClientSideError, - HttpRequestException { StatusCode: >= (HttpStatusCode)500 and < (HttpStatusCode)600 } => DownloadError.ServerError, + HttpRequestException { StatusCode: >= (HttpStatusCode)StatusCodes.MinClientErrorCode and <= (HttpStatusCode)StatusCodes.MaxClientErrorCode } => + DownloadError.HttpClientSideError, + HttpRequestException { StatusCode: >= (HttpStatusCode)StatusCodes.MinServerErrorCode and <= (HttpStatusCode)StatusCodes.MaxServerErrorCode } => + DownloadError.ServerError, HttpRequestException => DownloadError.NetworkError, ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => DownloadError.RateLimited, - ProtonApiException { TransportCode: >= 400 and < 500 } => DownloadError.HttpClientSideError, + ProtonApiException { TransportCode: >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode } => DownloadError.HttpClientSideError, + ProtonApiException { TransportCode: >= StatusCodes.MinServerErrorCode and <= StatusCodes.MaxServerErrorCode } => DownloadError.ServerError, // TODO: How to better distinguish network errors, that were subject to retry in the HTTP request handler, but resulted in TimeoutException? TimeoutException => DownloadError.ServerError, @@ -51,12 +55,15 @@ public static UploadError GetUploadErrorFromException(Exception exception) HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => UploadError.ServerError, HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => UploadError.ServerError, - HttpRequestException { StatusCode: >= (HttpStatusCode)400 and < (HttpStatusCode)500 } => UploadError.HttpClientSideError, - HttpRequestException { StatusCode: >= (HttpStatusCode)500 and < (HttpStatusCode)600 } => UploadError.ServerError, + HttpRequestException { StatusCode: >= (HttpStatusCode)StatusCodes.MinClientErrorCode and <= (HttpStatusCode)StatusCodes.MaxClientErrorCode } => + UploadError.HttpClientSideError, + HttpRequestException { StatusCode: >= (HttpStatusCode)StatusCodes.MinServerErrorCode and <= (HttpStatusCode)StatusCodes.MaxServerErrorCode } => + UploadError.ServerError, HttpRequestException => UploadError.NetworkError, ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => UploadError.RateLimited, - ProtonApiException { TransportCode: >= 400 and < 500 } => UploadError.HttpClientSideError, + ProtonApiException { TransportCode: >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode } => UploadError.HttpClientSideError, + ProtonApiException { TransportCode: >= StatusCodes.MinServerErrorCode and <= StatusCodes.MaxServerErrorCode } => UploadError.ServerError, // TODO: How to better distinguish network errors, that were subject to retry in the HTTP request handler, but resulted in TimeoutException? TimeoutException => UploadError.ServerError, diff --git a/cs/sdk/src/Proton.Sdk/Http/StatusCodes.cs b/cs/sdk/src/Proton.Sdk/Http/StatusCodes.cs new file mode 100644 index 00000000..0fed0558 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/StatusCodes.cs @@ -0,0 +1,39 @@ +using System.Net; + +namespace Proton.Sdk.Http; + +internal static class StatusCodes +{ + /// + /// Minimum HTTP status code that indicates a client error (400) + /// + public const int MinClientErrorCode = (int)HttpStatusCode.BadRequest; + + /// + /// Maximum HTTP status code that indicates a client error (498) + /// + public const int MaxClientErrorCode = MinServerErrorCode - 1; + + /// + /// Minimum HTTP status code that indicates a server error (499) + /// + /// + /// + /// HTTP status code 499 (ClientClosedRequest) is an unofficial status code originally defined by Nginx + /// and is commonly used in logs when the client has disconnected. + /// + /// + /// Ideally, this code should never be seen by client apps, it is supposed to be logged in server logs only. + /// When the client app disconnects, it does not get the error from the server. + /// If the client app occasionally gets 499, it can indicate something unexpected happening in communication + /// between different servers, like load balancer, etc., where the "client" is another server, rather than the client app. + /// + /// In the client app, we consider this code a server error rather than a client error. + /// + public const int MinServerErrorCode = 499; + + /// + /// Maximum HTTP status code that indicates a server error (599) + /// + public const int MaxServerErrorCode = 599; +} From 13b3baf37c79e96b1fd16cc7668594f78442f10b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 May 2026 16:33:19 +0200 Subject: [PATCH 749/791] Handle degraded folder secrets in upload and node operations --- .../Nodes/FolderOperations.cs | 47 +++++++++---- .../Nodes/NodeMetadataResultExtensions.cs | 22 +++++-- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 66 ++++++++++++++----- .../Nodes/Upload/NewFileDraftProvider.cs | 20 +++--- .../Volumes/VolumeTrashBatchLoader.cs | 6 +- 5 files changed, 118 insertions(+), 43 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 1e7b78dc..e4652914 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -20,9 +20,13 @@ public static async IAsyncEnumerable> EnumerateChildr var anchorLinkId = default(LinkId?); var mustTryMoreResults = true; - var folderSecrets = await GetSecretsAsync(client, folderUid, cancellationToken).ConfigureAwait(false); + var folderSecretsResult = await GetSecretsAsync(client, folderUid, cancellationToken).ConfigureAwait(false); - var batchLoader = new FolderChildrenBatchLoader(client, folderUid.VolumeId, folderSecrets.Key); + var folderKey = folderSecretsResult.TryGetValueElseError(out var folderSecrets, out var degradedFolderSecrets) + ? folderSecrets.Key + : degradedFolderSecrets.Key ?? throw new ProtonDriveException($"Folder key not available for {folderUid}"); + + var batchLoader = new FolderChildrenBatchLoader(client, folderUid.VolumeId, folderKey); while (mustTryMoreResults) { @@ -75,7 +79,7 @@ public static async ValueTask CreateAsync( ? parentNode.OwnedBy : parentDegraded.OwnedBy; - var parentSecrets = await GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var (parentKey, parentHashKey) = await GetKeyAndHashKeyAsync(client, parentUid, cancellationToken).ConfigureAwait(false); var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, parentUid, cancellationToken).ConfigureAwait(false); @@ -85,8 +89,8 @@ public static async ValueTask CreateAsync( NodeOperations.GetCommonCreationParameters( name, - parentSecrets.Key, - parentSecrets.HashKey.Span, + parentKey, + parentHashKey.Span, signingKey, PgpProfile.Proton, out var key, @@ -155,20 +159,39 @@ public static async ValueTask CreateAsync( return folderNode; } - public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid folderUid, CancellationToken cancellationToken) + public static async ValueTask> GetSecretsAsync( + ProtonDriveClient client, + NodeUid folderUid, + CancellationToken cancellationToken) { - var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderUid, cancellationToken).ConfigureAwait(false); + var result = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderUid, cancellationToken).ConfigureAwait(false); - var folderSecrets = folderSecretsResult?.GetValueOrDefault(); - - if (folderSecrets is null) + if (result is null) { var nodeProvisionResult = await NodeOperations.GetFreshNodeMetadataAsync(client, folderUid, knownShareAndKey: null, cancellationToken) .ConfigureAwait(false); - folderSecrets = nodeProvisionResult.GetFolderSecretsOrThrow(); + result = nodeProvisionResult.GetFolderSecretsOrThrow(); + } + + return result.Value; + } + + public static async ValueTask<(PgpPrivateKey Key, ReadOnlyMemory HashKey)> GetKeyAndHashKeyAsync( + ProtonDriveClient client, + NodeUid folderUid, + CancellationToken cancellationToken) + { + var secretsResult = await GetSecretsAsync(client, folderUid, cancellationToken).ConfigureAwait(false); + + if (secretsResult.TryGetValueElseError(out var secrets, out var degradedSecrets)) + { + return (secrets.Key, secrets.HashKey); } - return folderSecrets; + var key = degradedSecrets.Key ?? throw new ProtonDriveException($"Parent folder key not available for {folderUid}"); + var hashKey = degradedSecrets.HashKey ?? throw new ProtonDriveException($"Parent folder hash key not available for {folderUid}"); + + return (key, hashKey); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs index 109adf35..692d5fbc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs @@ -25,16 +25,26 @@ public static FolderNode GetFolderNodeOrThrow(this Result metadataResult) + public static Result GetFolderSecretsOrThrow(this Result metadataResult) { - var metadata = metadataResult.GetValueOrThrow(); - - if (metadata.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) + if (metadataResult.TryGetValueElseError(out var metadata, out var degradedMetadata)) { - throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); + if (metadata.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) + { + throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); + } + + return folderSecrets; } + else + { + if (degradedMetadata.TryGetFileElseFolder(out var degradedFileNode, out _, out _, out var degradedFolderSecrets)) + { + throw new InvalidNodeTypeException(degradedFileNode.Uid, LinkType.File); + } - return folderSecrets; + return degradedFolderSecrets; + } } public static Result GetFileSecretsOrThrow(this Result metadataResult) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 61d18b88..ef98c136 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -177,7 +177,23 @@ public static async ValueTask MoveSingleAsync( using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + var destinationFolderSecretsResult = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + + PgpPrivateKey destinationKey; + ReadOnlyMemory destinationHashKey; + + if (destinationFolderSecretsResult.TryGetValueElseError(out var destinationFolderSecrets, out var degradedDestinationFolderSecrets)) + { + destinationKey = destinationFolderSecrets.Key; + destinationHashKey = destinationFolderSecrets.HashKey; + } + else + { + destinationKey = degradedDestinationFolderSecrets.Key + ?? throw new ProtonDriveException($"Destination folder key not available for {newParentUid}"); + destinationHashKey = degradedDestinationFolderSecrets.HashKey + ?? throw new ProtonDriveException($"Destination folder hash key not available for {newParentUid}"); + } if (uid == newParentUid) { @@ -195,14 +211,14 @@ public static async ValueTask MoveSingleAsync( GetNameParameters( newName ?? originNode.Name, // FIXME: validate name - destinationFolderSecrets.Key, - destinationFolderSecrets.HashKey.Span, + destinationKey, + destinationHashKey.Span, originSecrets.NameSessionKey, signingKey, out var encryptedName, out var nameHashDigest); - var passphraseKeyPacket = destinationFolderSecrets.Key.EncryptSessionKey(originSecrets.PassphraseSessionKey); + var passphraseKeyPacket = destinationKey.EncryptSessionKey(originSecrets.PassphraseSessionKey); ReadOnlyMemory? passphraseSignature = null; string? signatureEmailAddress = null; @@ -245,7 +261,23 @@ public static async Task MoveMultipleAsync( using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + var destinationFolderSecretsResult = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + + PgpPrivateKey destinationKey; + ReadOnlyMemory destinationHashKey; + + if (destinationFolderSecretsResult.TryGetValueElseError(out var destinationFolderSecrets, out var degradedDestinationFolderSecrets)) + { + destinationKey = destinationFolderSecrets.Key; + destinationHashKey = destinationFolderSecrets.HashKey; + } + else + { + destinationKey = degradedDestinationFolderSecrets.Key + ?? throw new ProtonDriveException($"Destination folder key not available for {newParentUid}"); + destinationHashKey = degradedDestinationFolderSecrets.HashKey + ?? throw new ProtonDriveException($"Destination folder hash key not available for {newParentUid}"); + } var batch = new List(); @@ -262,14 +294,14 @@ public static async Task MoveMultipleAsync( GetNameParameters( newName ?? originNode.Name, // FIXME: validate name - destinationFolderSecrets.Key, - destinationFolderSecrets.HashKey.Span, + destinationKey, + destinationHashKey.Span, originSecrets.NameSessionKey, signingKey, out var encryptedName, out var nameHashDigest); - var passphraseKeyPacket = destinationFolderSecrets.Key.EncryptSessionKey(originSecrets.PassphraseSessionKey); + var passphraseKeyPacket = destinationKey.EncryptSessionKey(originSecrets.PassphraseSessionKey); var itemRequest = new MoveMultipleLinksItem { @@ -317,12 +349,12 @@ public static async ValueTask RenameAsync( var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var parentFolderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var (parentKey, parentHashKey) = await FolderOperations.GetKeyAndHashKeyAsync(client, parentUid, cancellationToken).ConfigureAwait(false); GetNameParameters( newName, // FIXME: validate name - parentFolderSecrets.Key, - parentFolderSecrets.HashKey.Span, + parentKey, + parentHashKey.Span, secrets.NameSessionKey, signingKey, out var encryptedName, @@ -492,7 +524,11 @@ public static async ValueTask GetAvailableNameAsync(ProtonDriveClient cl { const int batchSize = 10; - var folderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var folderSecretsResult = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + + var folderHashKey = folderSecretsResult.TryGetValueElseError(out var folderSecrets, out var degradedFolderSecrets) + ? folderSecrets.HashKey : degradedFolderSecrets.HashKey + ?? throw new ProtonDriveException($"Folder hash key not available for {parentUid}"); var digestsToNamesMap = new Dictionary(batchSize); @@ -508,7 +544,7 @@ public static async ValueTask GetAvailableNameAsync(ProtonDriveClient cl foreach (var candidateName in batchEnumerator.Current) { - var digest = Convert.ToHexStringLower(NodeCrypto.HashNodeName(candidateName, folderSecrets.HashKey.Span)); + var digest = Convert.ToHexStringLower(NodeCrypto.HashNodeName(candidateName, folderHashKey.Span)); digestsToNamesMap[digest] = candidateName; } @@ -586,9 +622,9 @@ public static async Task> GetParentFolderHashKeyAsync(Proto throw new InvalidOperationException("Root node does not have a parent folder"); } - var parentFolderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid.Value, cancellationToken).ConfigureAwait(false); + var (_, hashKey) = await FolderOperations.GetKeyAndHashKeyAsync(client, parentUid.Value, cancellationToken).ConfigureAwait(false); - return parentFolderSecrets.HashKey; + return hashKey; } private static async ValueTask GetFreshExistingMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 91eaa3b0..43e3d8b4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -33,7 +33,7 @@ public async ValueTask GetDraftAsync(long intendedUploadSize, Can { ArgumentOutOfRangeException.ThrowIfNegative(intendedUploadSize); - var parentSecrets = await FolderOperations.GetSecretsAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); + var (parentKey, parentHashKey) = await FolderOperations.GetKeyAndHashKeyAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); var membershipAddress = await NodeOperations.GetMembershipAddressAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); @@ -41,7 +41,8 @@ public async ValueTask GetDraftAsync(long intendedUploadSize, Can var (response, fileSecrets) = await CreateDraftAsync( intendedUploadSize, - parentSecrets, + parentKey, + parentHashKey, signingKey, membershipAddress.EmailAddress, cancellationToken).ConfigureAwait(false); @@ -58,7 +59,7 @@ public async ValueTask GetDraftAsync(long intendedUploadSize, Can fileSecrets.Key, fileSecrets.ContentKey, signingKey, - parentSecrets.HashKey, + parentHashKey, membershipAddress, blockVerifier, intendedUploadSize, @@ -72,7 +73,8 @@ private static FileCreationRequest GetFileCreationRequest( NodeUid parentUid, string name, string mediaType, - FolderSecrets parentSecrets, + PgpPrivateKey parentKey, + ReadOnlyMemory parentHashKey, PgpPrivateKey signingKey, string membershipEmailAddress, bool useAeadFeatureFlag, @@ -85,8 +87,8 @@ private static FileCreationRequest GetFileCreationRequest( NodeOperations.GetCommonCreationParameters( name, - parentSecrets.Key, - parentSecrets.HashKey.Span, + parentKey, + parentHashKey.Span, signingKey, pgpProfile, out nodeKey, @@ -119,7 +121,8 @@ private static FileCreationRequest GetFileCreationRequest( private async ValueTask<(FileCreationResponse Response, FileSecrets FileSecrets)> CreateDraftAsync( long intendedUploadSize, - FolderSecrets parentSecrets, + PgpPrivateKey parentKey, + ReadOnlyMemory parentHashKey, PgpPrivateKey signingKey, string membershipEmailAddress, CancellationToken cancellationToken) @@ -139,7 +142,8 @@ private static FileCreationRequest GetFileCreationRequest( _parentUid, _name, _mediaType, - parentSecrets, + parentKey, + parentHashKey, signingKey, membershipEmailAddress, useAeadFeatureFlag, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs index 727415eb..f4fe7218 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -31,10 +31,12 @@ protected override async ValueTask>> Lo { if (!_parentKeys.TryGetValue(parentId, out parentKey)) { - var folderSecrets = await FolderOperations.GetSecretsAsync(_client, new NodeUid(_volumeId, parentId), cancellationToken) + var folderSecretsResult = await FolderOperations.GetSecretsAsync(_client, new NodeUid(_volumeId, parentId), cancellationToken) .ConfigureAwait(false); - parentKey = folderSecrets.Key; + parentKey = folderSecretsResult.TryGetValueElseError(out var folderSecrets, out var degradedFolderSecrets) + ? folderSecrets.Key : degradedFolderSecrets.Key + ?? throw new ProtonDriveException($"Folder key not available for {parentId}"); _parentKeys[parentId] = parentKey; } From ffaf4b85dc26916d462262b0a2df9ae2dc019113 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 12:14:52 +0000 Subject: [PATCH 750/791] Update changelog for cs/v0.14.3 --- cs/CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index b95a0305..9051b05b 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## cs/v0.14.3 (2026-05-11) + +* Handle degraded folder secrets in upload and node operations +* Classify HTTP response code 499 as server error +* Flatten messages of decryption errors reported to telemetry +* Reproduce content size mismatch +* Add an E2E tests for conflict name with draft +* Add info log for uploader and downloader +* Fix upload failing to resume when blocks were uploaded out of order +* Fix handling of mismatch between uploaded and intended sizes +* Remove slash validation name after decryption + ## cs/v0.14.2 (2026-05-06) * Dispose upload controller in test to see events From 7652c33bf4ce9223df01941ba26babab63382585 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 15:14:44 +0000 Subject: [PATCH 751/791] Allow all address keys to be used for decryption when listing invitations --- js/sdk/src/crypto/driveCrypto.ts | 3 ++- js/sdk/src/internal/sharing/cryptoService.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index 3b13bbce..d16336e4 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -625,13 +625,14 @@ export class DriveCrypto { async acceptInvitation( base64KeyPacket: string, + decryptionKeys: PrivateKey[], signingKey: PrivateKey, ): Promise<{ base64SessionKeySignature: string; }> { const sessionKey = await this.openPGPCrypto.decryptSessionKey( Uint8Array.fromBase64(base64KeyPacket), - signingKey, + decryptionKeys, ); const { signature } = await this.openPGPCrypto.sign( diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 02f93cec..e4b9141c 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -193,12 +193,12 @@ export class SharingCryptoService { encryptedInvitation: EncryptedInvitationWithNode, ): Promise { const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); - const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key; + const inviteeKeys = inviteeAddress.keys.map(k => k.key); const shareKey = await this.driveCrypto.decryptUnsignedKey( encryptedInvitation.share.armoredKey, encryptedInvitation.share.armoredPassphrase, - inviteeKey, + inviteeKeys, ); let nodeName: Result; @@ -246,7 +246,8 @@ export class SharingCryptoService { }> { const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key; - const result = await this.driveCrypto.acceptInvitation(encryptedInvitation.base64KeyPacket, inviteeKey); + const inviteeKeys = inviteeAddress.keys.map(k => k.key); + const result = await this.driveCrypto.acceptInvitation(encryptedInvitation.base64KeyPacket, inviteeKeys, inviteeKey); return result; } From ba7263e21f60479bb0c492caa306ab1fc89a26d7 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 12 May 2026 06:11:52 +0000 Subject: [PATCH 752/791] Update changelog for js/v0.15.1 --- js/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 9fd55ac0..1ac7e638 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## js/v0.15.1 (2026-05-12) + +* Allow all address keys to be used for decryption when listing invitations +* Remove slash validation name after decryption + ## js/v0.15.0 (2026-05-06) * Fix detecting photo drafts From 41102bf140166d8674e443b4d746aa3998036bc4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 13 May 2026 13:52:27 +0200 Subject: [PATCH 753/791] Fix incorrect reporting of decryption errors --- .../Nodes/Cryptography/NodeCrypto.cs | 30 ++++++---- .../Nodes/DtoToMetadataConverter.cs | 55 +++++++++++++------ .../ExtendedAttributesDeserializationError.cs | 9 +++ 3 files changed, 65 insertions(+), 29 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributesDeserializationError.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index d2c3c559..e3ac6f6f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -265,30 +265,36 @@ private static Result, ProtonDriveError> Decrypt return new ProtonDriveError("Cannot get node key", error); } + ArraySegment serializedExtendedAttributes; + AuthorshipVerificationFailure? authorshipVerificationFailure; try { - var serializedExtendedAttributes = DecryptMessage( + serializedExtendedAttributes = DecryptMessage( encryptedExtendedAttributes.Value, detachedSignature: null, nodeKey, authorshipClaim.GetKeyRing(nodeKey), out _, - out var author); + out authorshipVerificationFailure); + } + catch (Exception e) + { + return new DecryptionError("Failed to decrypt extended attributes", e.ToProtonDriveError()); + } - try - { - var extendedAttributes = JsonSerializer.Deserialize(serializedExtendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); + try + { + var extendedAttributes = JsonSerializer.Deserialize(serializedExtendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); - return new DecryptionOutput(extendedAttributes, author); - } - catch (Exception e) - { - return new ProtonDriveError("Failed to deserialize extended attributes", e.ToProtonDriveError()); - } + return new DecryptionOutput(extendedAttributes, authorshipVerificationFailure); + } + catch (JsonException e) + { + return new ExtendedAttributesDeserializationError(e.ToProtonDriveError()); } catch (Exception e) { - return new ProtonDriveError("Failed to decrypt extended attributes", e.ToProtonDriveError()); + return new ProtonDriveError("Unknown error while deserializing extended attributes", e.ToProtonDriveError()); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 19f2e216..4bef9ed2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -299,17 +299,25 @@ private static (DegradedFileMetadata Metadata, Dictionary failedDecryptionFields = []; - List errors = []; + List nodeKeyErrors = []; if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { - errors.Add(new DecryptionError("Passphrase decryption failed", passphraseError)); - failedDecryptionFields.Add(EncryptedField.NodeKey, passphraseError); + nodeKeyErrors.Add(passphraseError); + + if (passphraseError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeKey, passphraseError); + } } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { - errors.Add(new DecryptionError("Node key decryption failed", nodeKeyError)); - failedDecryptionFields.Add(EncryptedField.NodeKey, nodeKeyError); + nodeKeyErrors.Add(nodeKeyError); + + if (passphraseError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeKey, nodeKeyError); + } } else if (decryptionResult.ContentKey.TryGetError(out var contentKeyError)) { @@ -324,8 +332,12 @@ private static (DegradedFileMetadata Metadata, Dictionary(); if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) { - revisionErrors.Add(new DecryptionError("Extended attributes decryption failed", extendedAttributesError)); - failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes, extendedAttributesError); + revisionErrors.Add(extendedAttributesError); + + if (extendedAttributesError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes, extendedAttributesError); + } } var nodeAuthor = decryptionResult.Link.Passphrase.Merge( @@ -369,7 +381,7 @@ private static (DegradedFileMetadata Metadata, Dictionary new NodeUid(uid.VolumeId, a.Id)).ToList(), OwnedBy = ownedBy, @@ -386,7 +398,7 @@ private static (DegradedFileMetadata Metadata, Dictionary failedDecryptionFields = []; - List errors = []; + List nodeKeyAndHashKeyErrors = []; if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { - errors.Add(new DecryptionError("Passphrase decryption failed", passphraseError)); - failedDecryptionFields.Add(EncryptedField.NodeKey, passphraseError); + nodeKeyAndHashKeyErrors.Add(passphraseError); + + if (passphraseError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeKey, passphraseError); + } } else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) { - errors.Add(new DecryptionError("Node key decryption failed", nodeKeyError)); - failedDecryptionFields.Add(EncryptedField.NodeKey, nodeKeyError); + nodeKeyAndHashKeyErrors.Add(nodeKeyError); + + if (nodeKeyError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeKey, nodeKeyError); + } } else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) { - errors.Add(new DecryptionError("Hash key decryption failed", hashKeyError)); + nodeKeyAndHashKeyErrors.Add(hashKeyError); + failedDecryptionFields.Add(EncryptedField.NodeHashKey, hashKeyError); } - if (nameResult.TryGetError(out var nameError)) + if (nameResult.TryGetError(out var nameError) && nameError is DecryptionError) { failedDecryptionFields.Add(EncryptedField.NodeName, nameError); } @@ -541,7 +562,7 @@ private static (DegradedFolderMetadata Metadata, Dictionary Date: Tue, 12 May 2026 08:04:54 +0200 Subject: [PATCH 754/791] Retry network errors more times and with bigger delay --- .../internal/apiService/apiService.test.ts | 16 ++++++++ js/sdk/src/internal/apiService/apiService.ts | 22 +++++++++- js/sdk/src/internal/errors.ts | 41 ++++++++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts index 660149c1..38615d4c 100644 --- a/js/sdk/src/internal/apiService/apiService.test.ts +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -223,6 +223,22 @@ describe('DriveAPIService', () => { expect(telemetry.recordMetric).not.toHaveBeenCalled(); }); + it('on transient socket / transport error', async () => { + const error = new Error('The socket connection was closed unexpectedly'); + httpClient.fetchJson = jest + .fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(generateOkResponse()); + + const result = api.get('test'); + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(3); + expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + it('on general error', async () => { const error = new Error('Error'); httpClient.fetchJson = jest.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(generateOkResponse()); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts index 18b9d3e0..e0825352 100644 --- a/js/sdk/src/internal/apiService/apiService.ts +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -3,6 +3,7 @@ import { c } from 'ttag'; import { AbortError, ProtonDriveError, RateLimitedError, ServerError } from '../../errors'; import { Logger, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../interface'; import { VERSION } from '../../version'; +import { isNetworkError } from '../errors'; import { SDKEvents } from '../sdkEvents'; import { waitSeconds } from '../wait'; import { HTTPErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes'; @@ -23,6 +24,11 @@ const DEFAULT_STORAGE_TIMEOUT_MS = 600_000; */ const MAX_TIMEOUT_ERROR_RETRY_ATTEMPTS = 3; +/** + * Maximum number of retry attempts for a network error. + */ +const MAX_NETWORK_ERROR_RETRY_ATTEMPTS = 3; + /** * How many subsequent 429 errors are allowed before we stop further requests. */ @@ -55,6 +61,11 @@ const TOO_MANY_SUBSEQUENT_OFFLINE_ERRORS = 10; */ const SERVER_ERROR_RETRY_DELAY_SECONDS = 1; +/** + * After how long to re-try after network error. + */ +const NETWORK_ERROR_RETRY_DELAY_SECONDS = 5; + /** * After how long to re-try after offline error. */ @@ -80,7 +91,7 @@ const GENERAL_RETRY_DELAY_SECONDS = 1; * * * exception from HTTP client * * retry on offline exc. (with delay from OFFLINE_RETRY_DELAY_SECONDS) - * * retry on timeout exc. (with delay from SERVER_ERROR_RETRY_DELAY_SECONDS) + * * retry on transient network exc. (with delay from SERVER_ERROR_RETRY_DELAY_SECONDS) * * retry ONCE on any exc. (with delay from GENERAL_RETRY_DELAY_SECONDS) * * HTTP status 429 * * retry (with delay from `retry-after` header or DEFAULT_429_RETRY_DELAY_SECONDS) @@ -318,6 +329,12 @@ export class DriveAPIService { await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); return this.fetch(request, callback, { attempt: attempt + 1, previousError: error }); } + + if (isNetworkError(error) && attempt + 1 < MAX_NETWORK_ERROR_RETRY_ATTEMPTS) { + this.logger.warn(`${request.method} ${request.url}: Network error, retrying`); + await waitSeconds(NETWORK_ERROR_RETRY_DELAY_SECONDS); + return this.fetch(request, callback, { attempt: attempt + 1, previousError: error }); + } } if (attempt === 0) { this.logger.error(`${request.method} ${request.url}: failed, retrying once`, error); @@ -367,7 +384,8 @@ export class DriveAPIService { !(previousError instanceof Error) || (previousError instanceof Error && previousError.name !== 'TimeoutError' && - previousError.name !== 'OfflineError'); + previousError.name !== 'OfflineError' && + !isNetworkError(previousError)); if (isWarning) { this.telemetry.recordMetric({ diff --git a/js/sdk/src/internal/errors.ts b/js/sdk/src/internal/errors.ts index 3095161a..01f05809 100644 --- a/js/sdk/src/internal/errors.ts +++ b/js/sdk/src/internal/errors.ts @@ -72,7 +72,7 @@ export function isNetworkError(error: unknown): boolean { if (!(error instanceof Error)) { return false; } - return ( + if ( error.name === 'OfflineError' || error.name === 'NetworkError' || error.message?.toLowerCase() === 'network error' || @@ -80,5 +80,44 @@ export function isNetworkError(error: unknown): boolean { ['Failed to fetch', 'NetworkError when attempting to fetch resource', 'Load failed'].includes( error.message, )) + ) { + return true; + } + if (errorMessageIndicatesTransientTransportFailure(error.message) || errorHasTransientTransportCode(error)) { + return true; + } + if (error.cause instanceof Error) { + return ( + errorMessageIndicatesTransientTransportFailure(error.cause.message) || + errorHasTransientTransportCode(error.cause) + ); + } + return false; +} + +function errorMessageIndicatesTransientTransportFailure(message: string | undefined): boolean { + if (!message) { + return false; + } + const lower = message.toLowerCase(); + return ( + // Remote end closed TLS/TCP without a complete response. + lower.includes('socket connection was closed unexpectedly') || + // Remote end sent RST or closed the write side mid-request. + lower.includes('other side closed') || + // Remote end closed the socket abruptly. + lower.includes('socket hang up') + ); +} + +function errorHasTransientTransportCode(error: Error): boolean { + const code = (error as NodeJS.ErrnoException).code; + return ( + // TCP RST or equivalent: common under flaky networks or after server restart. + code === 'ECONNRESET' || + // Writing to a socket whose other end is gone (often grouped with reset/hang-up). + code === 'EPIPE' || + // Socket-level failure after connect (e.g. unexpected close on the wire). + code === 'UND_ERR_SOCKET' ); } From 79b820935eaf436fa22bcd5f5fe775d36763b09f Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 May 2026 05:07:50 +0000 Subject: [PATCH 755/791] Update changelog for cs/v0.14.4 --- cs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 9051b05b..a923c293 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## cs/v0.14.4 (2026-05-14) + +* Fix incorrect reporting of decryption errors + ## cs/v0.14.3 (2026-05-11) * Handle degraded folder secrets in upload and node operations From 1252c5d1b9e30b15e48674472a54d4deb07cf24e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 May 2026 08:23:38 +0200 Subject: [PATCH 756/791] Fix error mapping for decryption --- cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 4bef9ed2..219f6559 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -314,7 +314,7 @@ private static (DegradedFileMetadata Metadata, Dictionary Date: Thu, 14 May 2026 08:00:39 +0000 Subject: [PATCH 757/791] Allow client to pass core events from external subscription --- js/sdk/src/index.ts | 2 +- js/sdk/src/internal/events/apiService.ts | 37 +++++++------ js/sdk/src/internal/events/index.test.ts | 67 ++++++++++++++++++++++++ js/sdk/src/internal/events/index.ts | 22 +++++++- js/sdk/src/protonDriveClient.ts | 19 ++++++- js/sdk/src/protonDrivePhotosClient.ts | 20 +++++-- 6 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 js/sdk/src/internal/events/index.test.ts diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index e317729c..93433b29 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -10,7 +10,7 @@ export { OpenPGPCryptoWithCryptoProxy } from './crypto'; export * from './errors'; export { NullFeatureFlagProvider } from './featureFlags'; export * from './interface'; -export type { EventSubscription } from './internal/events'; +export type { CoreApiEvent, EventSubscription } from './internal/events'; export { ProtonDriveClient } from './protonDriveClient'; export { VERSION } from './version'; diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index e88045d0..5a0f9c6e 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -4,7 +4,7 @@ import { DriveEvent, DriveEventsListWithStatus, DriveEventType, NodeEvent, NodeE type GetCoreLatestEventResponse = corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json']; -type GetCoreEventResponse = +export type CoreApiEvent = corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json']; type GetVolumeLatestEventResponse = @@ -40,28 +40,33 @@ export class EventsAPIService { async getCoreEvents(eventId: string): Promise { // TODO: Switch to v6 endpoint? - const result = await this.apiService.get(`core/v5/events/${eventId}`); + const result = await this.apiService.get(`core/v5/events/${eventId}`); + const driveEvents = EventsAPIService.getDriveEventsFromCoreEvent(result); // in core/v5/events, refresh is always all apps, value 255 const refresh = result.Refresh > 0; - const events: DriveEvent[] = - refresh || result.DriveShareRefresh?.Action === 2 - ? [ - { - type: DriveEventType.SharedWithMeUpdated, - eventId: result.EventID, - treeEventScopeId: 'core', - }, - ] - : []; - return { latestEventId: result.EventID, more: result.More === 1, refresh, - events, + events: driveEvents, }; } + static getDriveEventsFromCoreEvent(result: CoreApiEvent): DriveEvent[] { + // in core/v5/events, refresh is always all apps, value 255 + const refresh = result.Refresh > 0; + if (refresh || result.DriveShareRefresh?.Action === 2) { + return [ + { + type: DriveEventType.SharedWithMeUpdated, + eventId: result.EventID, + treeEventScopeId: 'core', + }, + ]; + } + return []; + } + async getVolumeLatestEventId(volumeId: string): Promise { const result = await this.apiService.get( `drive/volumes/${volumeId}/events/latest`, @@ -81,9 +86,7 @@ export class EventsAPIService { const type = VOLUME_EVENT_TYPE_MAP[event.EventType]; const uids = { nodeUid: makeNodeUid(volumeId, event.Link.LinkID), - parentNodeUid: event.Link.ParentLinkID - ? makeNodeUid(volumeId, event.Link.ParentLinkID) - : undefined, + parentNodeUid: event.Link.ParentLinkID ? makeNodeUid(volumeId, event.Link.ParentLinkID) : undefined, }; return { type, diff --git a/js/sdk/src/internal/events/index.test.ts b/js/sdk/src/internal/events/index.test.ts new file mode 100644 index 00000000..078f6368 --- /dev/null +++ b/js/sdk/src/internal/events/index.test.ts @@ -0,0 +1,67 @@ +import { getMockTelemetry } from '../../tests/telemetry'; +import { DriveAPIService } from '../apiService'; +import { CoreApiEvent } from './apiService'; +import { DriveEventsService } from './index'; +import { DriveEventType, DriveListener } from './interface'; + +describe('DriveEventsService', () => { + describe('processCoreEvent', () => { + function createService(cacheEventListeners: DriveListener[] = []) { + const telemetry = getMockTelemetry(); + const apiService = {} as unknown as DriveAPIService; + const sharesService = { isOwnVolume: jest.fn() }; + return new DriveEventsService(telemetry, apiService, sharesService, cacheEventListeners); + } + + it('returns no drive events and does not notify listeners when the raw event is not a refresh', async () => { + const listener: jest.MockedFunction = jest.fn().mockResolvedValue(undefined); + const service = createService([listener]); + const raw = { + EventID: 'event-no-refresh', + Refresh: 0, + } as CoreApiEvent; + + const result = await service.processCoreEvent(raw); + + expect(result).toEqual([]); + expect(listener).not.toHaveBeenCalled(); + }); + + it('returns SharedWithMeUpdated when Refresh is non-zero', async () => { + const service = createService(); + const raw = { + EventID: 'event-refresh', + Refresh: 255, + } as CoreApiEvent; + + const result = await service.processCoreEvent(raw); + + expect(result).toEqual([ + { + type: DriveEventType.SharedWithMeUpdated, + eventId: 'event-refresh', + treeEventScopeId: 'core', + }, + ]); + }); + + it('returns SharedWithMeUpdated when DriveShareRefresh.Action is 2', async () => { + const service = createService(); + const raw = { + EventID: 'event-share-refresh', + Refresh: 0, + DriveShareRefresh: { Action: 2 }, + } as CoreApiEvent; + + const result = await service.processCoreEvent(raw); + + expect(result).toEqual([ + { + type: DriveEventType.SharedWithMeUpdated, + eventId: 'event-share-refresh', + treeEventScopeId: 'core', + }, + ]); + }); + }); +}); diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index c0dc95bb..c4f879d4 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -1,11 +1,12 @@ import { Logger, ProtonDriveTelemetry } from '../../interface'; import { DriveAPIService } from '../apiService'; -import { EventsAPIService } from './apiService'; +import { CoreApiEvent, EventsAPIService } from './apiService'; import { CoreEventManager } from './coreEventManager'; import { EventManager } from './eventManager'; import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider, SharesService } from './interface'; import { VolumeEventManager } from './volumeEventManager'; +export type { CoreApiEvent } from './apiService'; export type { DriveEvent, DriveListener, EventSubscription } from './interface'; export { DriveEventType } from './interface'; @@ -37,7 +38,9 @@ export class DriveEventsService { this.volumeEventManagers = {}; } - // FIXME: Allow to pass own core events manager from the public interface. + /** + * @deprecated Use `processCoreEvent` instead. + */ async subscribeToCoreEvents(callback: DriveListener): Promise { let manager = this.coreEventManager; const started = !!manager; @@ -72,6 +75,21 @@ export class DriveEventsService { return eventManager; } + /** + * Process a raw core API event fetched by the caller's own event loop. + * The SDK derives drive-relevant events from it, updates internal caches, + * and notifies all listeners registered via `subscribeToPushedCoreEvents`. + */ + async processCoreEvent(rawEvent: CoreApiEvent): Promise { + const driveEvents = EventsAPIService.getDriveEventsFromCoreEvent(rawEvent); + for (const event of driveEvents) { + for (const listener of this.cacheEventListeners) { + await listener(event); + } + } + return driveEvents; + } + /** * Subscribe to drive events. The treeEventScopeId can be obtained from a node. */ diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index ca6dfafc..6e4a59c6 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -6,6 +6,7 @@ import { Device, DeviceOrUid, DeviceType, + DriveEvent, FileDownloader, FileUploader, Logger, @@ -36,7 +37,7 @@ import { import { DriveAPIService } from './internal/apiService'; import { initDevicesModule } from './internal/devices'; import { initDownloadModule } from './internal/download'; -import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; +import { CoreApiEvent, DriveEventsService, DriveListener, EventSubscription } from './internal/events'; import { initNodesModule } from './internal/nodes'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; @@ -113,6 +114,16 @@ export class ProtonDriveClient { customPassword?: string, isAnonymousContext?: boolean, ) => Promise; + /** + * Feed a raw core API event response into the SDK. + * + * The SDK will derive drive-relevant events (e.g. `SharedWithMeUpdated`) + * from it, update internal caches, and return the derived events. + * + * The `rawEvent` shape matches the response of the + * `core/v5/events/{id}` endpoint. + */ + processCoreEvent: (rawEvent: CoreApiEvent) => Promise; }; constructor({ @@ -255,6 +266,10 @@ export class ProtonDriveClient { session, }); }, + processCoreEvent: async (rawEvent: CoreApiEvent) => { + this.logger.debug(`Processing core event ${rawEvent.EventID}`); + return this.events.processCoreEvent(rawEvent); + }, }; } @@ -293,6 +308,8 @@ export class ProtonDriveClient { * Subscribes to the remote general data updates. * * Only one instance of the SDK should subscribe to updates. + * + * @deprecated Use `experimental.processCoreEvent` instead. */ async subscribeToDriveEvents(callback: DriveListener): Promise { this.logger.debug('Subscribing to core updates'); diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 6e752ac8..1b0b0253 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -2,6 +2,7 @@ import { getConfig } from './config'; import { DriveCrypto } from './crypto'; import { NullFeatureFlagProvider } from './featureFlags'; import { + DriveEvent, FileDownloader, FileUploader, Logger, @@ -26,7 +27,7 @@ import { } from './interface'; import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; -import { DriveEventsService, DriveListener, EventSubscription } from './internal/events'; +import { CoreApiEvent, DriveEventsService, DriveListener, EventSubscription } from './internal/events'; import { AlbumItem, initPhotoSharesModule, @@ -82,6 +83,12 @@ export class ProtonDrivePhotosClient { * @param signal - An optional abort signal to cancel the operation. */ iterateAlbumUids: (signal?: AbortSignal) => AsyncGenerator; + /** + * Feed a raw core API event response into the SDK. + * + * See `ProtonDriveClient.experimental.processCoreEvent` for more information. + */ + processCoreEvent: (rawEvent: CoreApiEvent) => Promise; }; constructor({ @@ -186,6 +193,10 @@ export class ProtonDrivePhotosClient { this.logger.debug('Iterating album UIDs'); return this.photos.albums.iterateAlbumUids(signal); }, + processCoreEvent: async (rawEvent: CoreApiEvent) => { + this.logger.debug(`Processing core event ${rawEvent.EventID}`); + return this.events.processCoreEvent(rawEvent); + }, }; } @@ -213,6 +224,8 @@ export class ProtonDrivePhotosClient { * Subscribes to the remote general data updates. * * See `ProtonDriveClient.subscribeToDriveEvents` for more information. + * + * @deprecated Use `experimental.processCoreEvent` instead. */ async subscribeToDriveEvents(callback: DriveListener): Promise { this.logger.debug('Subscribing to core updates'); @@ -707,10 +720,7 @@ export class ProtonDrivePhotosClient { * @param signal - An optional abort signal to cancel the operation. * @returns An async generator of per-photo results. */ - async *savePhotosToTimeline( - photoNodeUids: NodeOrUid[], - signal?: AbortSignal, - ): AsyncGenerator { + async *savePhotosToTimeline(photoNodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { this.logger.info(`Saving ${photoNodeUids.length} photos to timeline`); yield* this.photos.photos.saveToTimeline(getUids(photoNodeUids), signal); } From 57f1b5b1d42ed70375c69ccf422bd5bbe6410525 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 May 2026 08:33:47 +0200 Subject: [PATCH 758/791] Update cached node after revision restore --- js/sdk/src/internal/nodes/nodesRevisions.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts index ab149f35..deb7f774 100644 --- a/js/sdk/src/internal/nodes/nodesRevisions.ts +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -14,7 +14,7 @@ export class NodesRevisons { private logger: Logger, private apiService: NodeAPIServiceBase, private cryptoService: NodesCryptoService, - private nodesAccess: Pick, + private nodesAccess: Pick, ) { this.logger = logger; this.apiService = apiService; @@ -67,6 +67,10 @@ export class NodesRevisons { async restoreRevision(nodeRevisionUid: string): Promise { await this.apiService.restoreRevision(nodeRevisionUid); + + // Restoring a revision creates a new active revision. + const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid); + await this.nodesAccess.notifyNodeChanged(nodeUid); } async deleteRevision(nodeRevisionUid: string): Promise { From 685b85a0095d45f6af09e06a1d52889aa7e072c7 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 15 May 2026 09:50:13 +0000 Subject: [PATCH 759/791] Clarify README --- README.md | 86 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index bca97491..b61a7e93 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,76 @@ # Proton Drive SDK -The Proton Drive SDK provides a high-level interface for interacting with Proton Drive. It is available in JavaScript and C#, with bindings for Swift and Kotlin. +The Proton Drive SDK provides a high-level interface for interacting with Proton Drive. It is available in the following languages: + +- **TypeScript** — native SDK in [`js/sdk/`](./js/sdk/), available on npm as [`@protontech/drive-sdk`](https://www.npmjs.com/package/@protontech/drive-sdk). See [changelog](./js/CHANGELOG.md) for changes. +- **C#** — native SDK in [`cs/sdk/`](./cs/sdk/). See [changelog](./cs/CHANGELOG.md) for changes. +- **Kotlin** and **Swift** — bindings that wrap the C# SDK (see [`kt/`](./kt/) and [`swift/ProtonDriveSDK/`](./swift/ProtonDriveSDK/)). See [changelog](./cs/CHANGELOG.md) for changes to the C# SDK. + +### Who this is for + +| Audience | Expectations | +| --- | --- | +| **Proton first-party clients** | Primary focus today: this codebase is built for and validated alongside official Proton Drive apps. | +| **Personal, non-commercial projects** | Allowed under [Guidelines](#usage-guidelines-for-personal-projects) below. Expect interface changes and the upcoming cryptographic migration until general availability. | +| **Commercial or production third-party apps** | The SDK is not yet ready for third-party production use. | + +Using the SDK directly is still recommended over raw Drive API calls for any experimentation, so correctness, safety and rate-limit expectations stay aligned with first-party behavior. The SDK handles encryption and metadata processing, protecting uploaded data from corruption due to incorrect encryption or invalid metadata. ## Current Status -> **Note:** The SDK is not yet ready for third-party production use. +The SDK is actively being integrated into official Proton Drive clients. During this phase, the architecture and public interface may still change. + +**Upcoming cryptographic model change**: -The SDK is actively being integrated into official Proton Drive clients. During this phase, the architecture continues to evolve. A forthcoming major update will introduce a new cryptographic model that significantly improves performance, simplifies the architecture, and enhances security. This update will be a **breaking change**—SDK versions prior to the new crypto model will cease to function. +- **What changes:** Proton Drive will move to a new cryptographic model that improves performance, simplifies the architecture, and strengthens security. +- **When:** Currently targeted for the **end of 2026/early 2027**. This window is an estimate and may shift; final timing and migration steps will be documented in this README and in the changelogs when they are finalized. +- **What breaks:** Once the service uses the new model, any client that only implements the previous cryptography including older SDK releases will **not** interoperate until upgraded to a release that implements the new model. +- **How to stay informed:** Watch this repository and read changelogs and README for migration notes and definitive dates. Once these changes are complete and the integration is stable, the SDK will be officially released for third-party use. +Despite not being officially supported for third-party use at present, Proton strongly recommends integrating through this SDK rather than calling the Drive API directly. It is the same implementation used in Proton's first-party clients and is maintained to the same quality standards, even while the public interface continues to evolve. If you integrate without the SDK, you must still follow those guidelines; non-compliant clients may be rate-limited or blocked to protect Proton Drive and other users. + ## Usage Guidelines for Personal Projects -The SDK may be used for personal, non-commercial projects. If you choose to build an application using Proton Drive, you **must** adhere to the following requirements: +The SDK may be used for personal, non-commercial projects. If you choose to build an application using Proton Drive, you **must** adhere to the requirements below. -### Technical Requirements +### Operational requirements -| Requirement | Description | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Use the SDK** | Always interact with Proton Drive through the SDK. Direct API calls are not permitted. | -| **Use official endpoints** | All HTTP requests must be directed to the official Proton Drive domain. Do not modify or proxy API endpoints to different domains. | -| **Identify your application** | Set the `x-pm-appversion` HTTP header using the format `external-drive-{projectname}@{version}` (e.g., `external-drive-myapp@1.2.3`). This header must accurately represent your application. Do not spoof or falsify this value. | -| **Use event-based sync** | Synchronize data using Drive events. Do not poll the API or perform frequent recursive traversals of the file tree. | +These rules protect service availability and honest identification of clients. Rate limits are per session and user, thus third-party applications use the **same rate-limiting policy** as Proton first-party Drive clients. -Note: The full `x-pm-appversion` string must conform to the regex: +| Requirement | Description | +| --- | --- | +| **Use the SDK** | You are strongly encouraged to interact with Proton Drive through the SDK. If you make direct API calls, your application **must** implement the same correctness and safety guarantees as the SDK. Failing to use appropriate caching, event-based sync, parallelism limits, and exponential backoff may cause your application to be rate-limited to protect service availability. | +| **Use official endpoints** | All HTTP requests must go to the official Proton Drive domain. Do not modify or proxy API endpoints to different domains. | +| **Identify your application** | Set the `x-pm-appversion` HTTP header so it identifies your build honestly. Use the shape described below (for example, `external-drive-myapp@1.2.3-stable`). The value must accurately represent your application. Do not spoof or falsify this header. Third-party clients that seek to masquerade as official Proton first-party clients are forbidden and may stop working at any time. Customer support and development use the reported app version to troubleshoot requests; a **specific version may be blocked** if it is known to ship a serious bug. | +| **Use event-based sync** | Synchronize data using Drive events. Do not poll the API or perform frequent recursive traversals of the file tree. Excessive polling or recursion may cause your application and your account to be rate-limited to protect service availability. | -``` -/^(external-drive)+(-[a-z_]+)+@[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?-((stable|beta|RC|alpha)(([.-]?\d+)*)?)?([.-]?dev)?(\+.*)?$/i -``` +Use this pattern for `x-pm-appversion`: + +`external-drive-{name}@{semver}-{channel}+{suffix}` with optional SemVer build metadata `+{suffix}` (for example a short commit hash). -### Branding and User Safety Requirements +- **`{name}`** — your project identifier using lowercase letters and underscores (e.g. `my_app`). +- **`{semver}`** — `major.minor.patch` (e.g. `1.2.3`). +- (optional) **`{channel}`** — one of `stable`, `beta`, or `alpha`. +- (optional) **`+{suffix}`** — build metadata, for example a short commit hash (e.g. `+abc123f`). -| Requirement | Description | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **No Proton branding** | Your application must not use Proton logos, trademarks, or design elements. It must be clearly distinguishable as an unofficial, third-party product. | -| **Credential handling disclosure** | Users must be explicitly warned that they are entering credentials into a non-official application. Passwords must never be stored by your application. | +Examples: -Failure to comply with these requirements may result in access restrictions. +- `external-drive-myapp@1.2.3-stable` +- `external-drive-my_app@2.0.0-beta` +- `external-drive-photo_backup@1.0.0-alpha+abc123f` + +### Product and legal requirements + +These rules keep third-party apps distinguishable from official Proton products and transparent to users. + +| Requirement | Description | +| --- | --- | +| **No Proton branding** | Your application must not use Proton logos, trademarks, or design elements. It must be clearly distinguishable as an unofficial, third-party product. | +| **Credential handling disclosure** | When you prompt a user for account details (including but not limited to username and password) your application must clearly state that it is a third-party application not officially supported by Proton. Suggested text: _This is a third-party application not officially supported by Proton._ | + +To protect the availability of Proton Drive and to properly safeguard the Proton customer experience, failure to comply with these requirements may result in your third-party application being limited or blocked from accessing Proton services. If you believe your third-party application has been improperly limited and/or blocked, please contact customer support on [proton.me/support/contact](https://proton.me/support/contact). ## Scope and Limitations @@ -46,14 +80,22 @@ The SDK provides functionality for Proton Drive business logic only. It does **n - Session management - User address provider -These dependencies must be supplied by the integrating application. Reference implementations are available in the official Proton Drive clients. Standalone integration support will be provided once the SDK reaches general availability. +**Where to look first:** Official Proton Drive clients wire these pieces into the SDK; treat them as the living reference until this repository publishes standalone sample apps. Standalone integration support will be documented once the SDK reaches general availability. ## Documentation We are preparing the documentation for the SDK. It will be available in the future. +Until then, you can generate the code reference for the TypeScript SDK using the following command: + +```bash +cd js/sdk && OUTPUT_PATH=./doc npm run generate-doc:interface +``` + ## License This project is licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details. +> **Using Proton’s hosted services:** The MIT license governs **use of the source code in this repository** only. Access to **Proton’s hosted services** (including Proton Drive) remains subject to separate terms of service and operational policies. Integration rules and enforcement described in this README apply regardless of the OSS license. + Copyright (c) 2026 Proton AG From 19feb952f294b6b53f1d8bdcb865507b179b4435 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 18 May 2026 05:03:15 +0000 Subject: [PATCH 760/791] i18n(weekly-mr): Upgrade translations from crowdin (36ac83c9). --- js/sdk/locales/.locale-state.metadata | 2 +- js/sdk/locales/pt_BR.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata index 4f068753..121e7a33 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "e3111b3cf7f5015cf8860b94978d21910ea2caa9" + "locale": "a3571884f94608b8d67f20fee0002b27fd706a7f" } \ No newline at end of file diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index 320a3f2b..39b2d54f 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -77,6 +77,9 @@ "Failed to get verification keys": [ "Não foi possível obter a chave de verificação" ], + "Failed to load some items": [ + "Erro ao carregar alguns itens" + ], "Failed to load some nodes": [ "Erro ao carregar alguns nós" ], From dc7831b25de1ac9913da451a2f50d8186a286602 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 13 May 2026 14:48:02 +0200 Subject: [PATCH 761/791] Avoid content key packet verification fallback on publicly shared nodes --- js/sdk/src/internal/nodes/cryptoService.ts | 6 ++++++ js/sdk/src/internal/sharingPublic/nodes.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index e4e0c6f5..30241013 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -73,6 +73,8 @@ type NodesCryptoReporterNode = { export class NodesCryptoService { private logger: Logger; + protected allowContentKeyPacketFallbackVerification = true; + constructor( telemetry: ProtonDriveTelemetry, protected driveCrypto: DriveCrypto, @@ -541,6 +543,10 @@ export class NodesCryptoService { return result; } + if (!this.allowContentKeyPacketFallbackVerification) { + return result; + } + const { volumeId: ownVolumeId } = await this.sharesService.getRootIDs(); const { volumeId: nodesVolumeId } = splitNodeUid(node.uid); diff --git a/js/sdk/src/internal/sharingPublic/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts index c70f057a..cffdfc04 100644 --- a/js/sdk/src/internal/sharingPublic/nodes.ts +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -17,6 +17,9 @@ import { makeNodeUid, splitNodeUid } from '../uids'; import { SharingPublicSharesManager } from './shares'; export class SharingPublicNodesCryptoService extends NodesCryptoService { + // Do not allow fallback verification for public links, because it is not possible to load owners' address keys. + protected allowContentKeyPacketFallbackVerification = false; + async generateDocument( parentKeys: { key: PrivateKey; hashKey: Uint8Array }, signingKeys: NodeSigningKeys, From 54658d5be4f0a32b8c3d75aa402780ab40bc6618 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 18 May 2026 14:28:46 +0200 Subject: [PATCH 762/791] Fix missing disposal of reader in Sqlite cache repository --- cs/Directory.Packages.props | 2 +- .../Caching/SqliteCacheRepository.cs | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 6ace8e8c..512b1897 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -3,7 +3,7 @@ true - + diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs index 51e11e94..85718a70 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -228,15 +228,17 @@ public void Clear() // Read value command.CommandText = "SELECT Value FROM Entries WHERE Key = @key"; command.Parameters.AddWithValue("@key", key); - var reader = command.ExecuteReader(); - if (!reader.Read()) + string value; + using (var reader = command.ExecuteReader()) { - return null; - } + if (!reader.Read()) + { + return null; + } - var value = reader.GetFieldValue("Value"); - reader.Close(); + value = reader.GetFieldValue("Value"); + } // Update timestamp command.CommandText = "UPDATE Entries SET LastAccessedUtc = @timestamp WHERE Key = @key"; @@ -376,7 +378,11 @@ private static void InitializeDatabase(SqliteConnection connection) { using var command = connection.CreateCommand(); - command.CommandText = "PRAGMA journal_mode = 'wal'"; + command.CommandText = "PRAGMA journal_mode = WAL"; + + command.ExecuteNonQuery(); + + command.CommandText = "PRAGMA synchronous = NORMAL"; command.ExecuteNonQuery(); @@ -393,6 +399,7 @@ PRIMARY KEY (Key) command.ExecuteNonQuery(); command.CommandText = "CREATE INDEX IF NOT EXISTS idx_entries_last_accessed ON Entries(LastAccessedUtc)"; + command.ExecuteNonQuery(); command.CommandText = From fd1d510d3e09cdfd56fd6ac4efac0414e301ebc5 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 18 May 2026 13:30:52 +0000 Subject: [PATCH 763/791] Update changelog for cs/v0.14.5 --- cs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index a923c293..2549513a 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## cs/v0.14.5 (2026-05-18) + +* Fix missing disposal of reader in Sqlite cache repository +* Fix error mapping for decryption + ## cs/v0.14.4 (2026-05-14) * Fix incorrect reporting of decryption errors From 57bdc343a88a9aa3b8245b88b4d27755e5004d7a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 19 May 2026 05:31:08 +0000 Subject: [PATCH 764/791] Support copy on save for not owned album --- js/sdk/src/internal/photos/addToAlbum.test.ts | 24 ++++---- js/sdk/src/internal/photos/addToAlbum.ts | 2 +- js/sdk/src/internal/photos/apiService.test.ts | 8 +-- js/sdk/src/internal/photos/apiService.ts | 12 ++-- .../src/internal/photos/photosManager.test.ts | 37 ++++++++++++- js/sdk/src/internal/photos/photosManager.ts | 55 ++++++++++++++++--- 6 files changed, 107 insertions(+), 31 deletions(-) diff --git a/js/sdk/src/internal/photos/addToAlbum.test.ts b/js/sdk/src/internal/photos/addToAlbum.test.ts index c3786a43..8d6a1460 100644 --- a/js/sdk/src/internal/photos/addToAlbum.test.ts +++ b/js/sdk/src/internal/photos/addToAlbum.test.ts @@ -69,7 +69,7 @@ describe('AddToAlbumProcess', () => { // @ts-expect-error Mocking for testing purposes apiService = { addPhotosToAlbum: jest.fn(), - copyPhotoToAlbum: jest.fn(), + copyPhoto: jest.fn(), }; // @ts-expect-error Mocking for testing purposes @@ -154,7 +154,7 @@ describe('AddToAlbumProcess', () => { }); let copyToAlbumReturnedMissing = false; - apiService.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => { + apiService.copyPhoto.mockImplementation(async (albumUid, payload) => { let error: Error | undefined; if (payload.nodeUid.includes('missingRelatedTwice')) { error = new MissingRelatedPhotosError(['volume2~missingRelatedTwice1']); @@ -322,7 +322,7 @@ describe('AddToAlbumProcess', () => { const photoUids = Array.from({ length: 25 }, (_, i) => `volume2~photo${i}`); let copyPhotoCallCount = 0; - apiService.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => { + apiService.copyPhoto.mockImplementation(async (albumUid, payload) => { copyPhotoCallCount++; // First few calls should happen before all 25 photos are prepared @@ -351,8 +351,8 @@ describe('AddToAlbumProcess', () => { uid: mainPhotoUid, ok: true, }]) - expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(1); - const params = apiService.copyPhotoToAlbum.mock.calls[0]; + expect(apiService.copyPhoto).toHaveBeenCalledTimes(1); + const params = apiService.copyPhoto.mock.calls[0]; expect(params[1].relatedPhotos?.length).toBe(15); }); @@ -366,7 +366,7 @@ describe('AddToAlbumProcess', () => { ok: true, }]); expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo - expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts + expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts }); it('should return error if missing related photos error occurs twice', async () => { @@ -380,7 +380,7 @@ describe('AddToAlbumProcess', () => { error: new MissingRelatedPhotosError(['volume2~missingRelatedOnce1']), }]); expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo - expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts + expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts }); it('should return error when crypto service fails', async () => { @@ -428,7 +428,7 @@ describe('AddToAlbumProcess', () => { it('should not notify for failed photo copies', async () => { const photoUid = 'volume2~photo1'; - apiService.copyPhotoToAlbum.mockRejectedValue(new Error('API error')); + apiService.copyPhoto.mockRejectedValue(new Error('API error')); const results = await executeProcess([photoUid]); @@ -467,9 +467,9 @@ describe('AddToAlbumProcess', () => { expect(nodesService.iterateNodes.mock.calls[1][0]).toMatchObject(differentVolumeUids); expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(1); expect(apiService.addPhotosToAlbum.mock.calls[0][1].map(({ nodeUid }) => nodeUid)).toMatchObject(sameVolumeUids); - expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); - expect(apiService.copyPhotoToAlbum.mock.calls[0][1].nodeUid).toBe(differentVolumeUids[0]); - expect(apiService.copyPhotoToAlbum.mock.calls[1][1].nodeUid).toBe(differentVolumeUids[1]); + expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); + expect(apiService.copyPhoto.mock.calls[0][1].nodeUid).toBe(differentVolumeUids[0]); + expect(apiService.copyPhoto.mock.calls[1][1].nodeUid).toBe(differentVolumeUids[1]); }); it('should prepare payloads in parallel for both queues', async () => { @@ -496,7 +496,7 @@ describe('AddToAlbumProcess', () => { expect(results[1].ok).toBe(true); expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3 + 3); // main photo + related photo + missing related photo expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts - expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts + expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts }); it('should notify correctly for both volumes', async () => { diff --git a/js/sdk/src/internal/photos/addToAlbum.ts b/js/sdk/src/internal/photos/addToAlbum.ts index af05619f..44db056e 100644 --- a/js/sdk/src/internal/photos/addToAlbum.ts +++ b/js/sdk/src/internal/photos/addToAlbum.ts @@ -118,7 +118,7 @@ export class AddToAlbumProcess { for (const payload of payloads) { try { - const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum( + const newPhotoNodeUid = await this.apiService.copyPhoto( this.albumNodeUid, payload, this.signal, diff --git a/js/sdk/src/internal/photos/apiService.test.ts b/js/sdk/src/internal/photos/apiService.test.ts index 0b19769e..df3aba25 100644 --- a/js/sdk/src/internal/photos/apiService.test.ts +++ b/js/sdk/src/internal/photos/apiService.test.ts @@ -147,7 +147,7 @@ describe('photosAPIService', () => { }); }); - describe('copyPhotoToAlbum', () => { + describe('copyPhoto', () => { const photoPayloads = [ { nodeUid: 'volumeId2~photoNodeId1', @@ -181,7 +181,7 @@ describe('photosAPIService', () => { LinkID: 'photoNodeId1', }); - const result = await api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]); + const result = await api.copyPhoto(albumNodeUid, photoPayloads[0]); expect(result).toEqual('volumeId1~photoNodeId1'); expect(apiMock.post).toHaveBeenCalledWith( @@ -215,7 +215,7 @@ describe('photosAPIService', () => { }, )); - const promise = api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]); + const promise = api.copyPhoto(albumNodeUid, photoPayloads[0]); await expect(promise).rejects.toThrow(MissingRelatedPhotosError); try { @@ -229,7 +229,7 @@ describe('photosAPIService', () => { const error = new APICodeError('Some error', 3000); apiMock.post = jest.fn().mockRejectedValue(error); - const promise = api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]); + const promise = api.copyPhoto(albumNodeUid, photoPayloads[0]); await expect(promise).rejects.toThrow(error); }); diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index 92875b38..54ea7c9a 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -358,7 +358,7 @@ export class PhotosAPIService { /** * Add photos from the same volume to an album. * - * To add photos from different volumes, use the {@link copyPhotoToAlbum} method. + * To add photos from different volumes, use the {@link copyPhoto} method. * * In the future, these two methods will be merged into a single one. */ @@ -432,26 +432,26 @@ export class PhotosAPIService { } /** - * Copy a photo to a shared album on a different volume. + * Copy a photo from a different volume to an album or to the user's own timeline root. * * To add photos from the same volume to an album, use the {@link addPhotosToAlbum} method. * * In the future, these two methods will be merged into a single one. */ - async copyPhotoToAlbum( - albumNodeUid: string, + async copyPhoto( + targetNodeUid: string, payload: TransferEncryptedPhotoPayload, signal?: AbortSignal, ): Promise { const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid); - const { volumeId: targetVolumeId, nodeId: targetAlbumLinkId } = splitNodeUid(albumNodeUid); + const { volumeId: targetVolumeId, nodeId: targetNodeId } = splitNodeUid(targetNodeUid); try { const response = await this.apiService.post( `drive/volumes/${sourceVolumeId}/links/${sourceLinkId}/copy`, { TargetVolumeID: targetVolumeId, - TargetParentLinkID: targetAlbumLinkId, + TargetParentLinkID: targetNodeId, Hash: payload.nameHash, Name: payload.encryptedName, NameSignatureEmail: payload.nameSignatureEmail, diff --git a/js/sdk/src/internal/photos/photosManager.test.ts b/js/sdk/src/internal/photos/photosManager.test.ts index 331b367c..4b9baba0 100644 --- a/js/sdk/src/internal/photos/photosManager.test.ts +++ b/js/sdk/src/internal/photos/photosManager.test.ts @@ -58,7 +58,7 @@ async function collectSaveToTimelineResults(manager: PhotosManager, nodeUids: st describe('PhotosManager', () => { let logger: ReturnType; let apiService: jest.Mocked< - Pick + Pick >; let cryptoService: jest.Mocked>; let nodesService: jest.Mocked< @@ -70,6 +70,7 @@ describe('PhotosManager', () => { | 'iterateNodes' | 'getNodePrivateAndSessionKeys' | 'notifyNodeChanged' + | 'notifyChildCreated' > >; let manager: PhotosManager; @@ -92,6 +93,7 @@ describe('PhotosManager', () => { removePhotoTags: jest.fn().mockResolvedValue(undefined), setPhotoFavorite: jest.fn().mockResolvedValue(undefined), transferPhotos: jest.fn().mockImplementation(async function* () {}), + copyPhoto: jest.fn().mockResolvedValue('volume1~newPhoto'), }; cryptoService = { @@ -122,6 +124,7 @@ describe('PhotosManager', () => { passphraseSessionKey: 'passphraseSessionKey' as any, }), notifyNodeChanged: jest.fn().mockResolvedValue(undefined), + notifyChildCreated: jest.fn().mockResolvedValue(undefined), }; manager = new PhotosManager(logger, apiService as any, cryptoService as any, nodesService as any); @@ -304,5 +307,37 @@ describe('PhotosManager', () => { ); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1'); }); + + it('copies cross-volume photo and notifies parent root folder', async () => { + apiService.copyPhoto.mockResolvedValue('volume1~newPhoto1'); + + const results = await collectSaveToTimelineResults(manager, ['volume2~photo1']); + + expect(results).toEqual([{ uid: 'volume2~photo1', ok: true }]); + expect(apiService.copyPhoto).toHaveBeenCalledTimes(1); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('volume1~root'); + expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); + }); + + it('re-queues cross-volume photo once on MissingRelatedPhotosError then succeeds', async () => { + const missingRelatedUid = 'volume2~related1'; + let copyCall = 0; + apiService.copyPhoto.mockImplementation(async () => { + copyCall++; + if (copyCall === 1) { + throw new MissingRelatedPhotosError([missingRelatedUid]); + } + return 'volume1~newPhoto1'; + }); + + const results = await collectSaveToTimelineResults(manager, ['volume2~photo1']); + + expect(results).toEqual([{ uid: 'volume2~photo1', ok: true }]); + expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenCalledWith( + `Missing related photos for saving volume2~photo1, re-queuing: ${missingRelatedUid}`, + ); + expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('volume1~root'); + }); }); }); diff --git a/js/sdk/src/internal/photos/photosManager.ts b/js/sdk/src/internal/photos/photosManager.ts index c32afe6e..f1fe96f6 100644 --- a/js/sdk/src/internal/photos/photosManager.ts +++ b/js/sdk/src/internal/photos/photosManager.ts @@ -3,12 +3,17 @@ import { c } from 'ttag'; import { AbortError } from '../../errors'; import { Logger, NodeResultWithError, PhotoTag } from '../../interface'; import { batch } from '../batch'; +import { splitNodeUid } from '../uids'; import { createBatches } from './addToAlbum'; import { AlbumsCryptoService } from './albumsCrypto'; import { PhotosAPIService } from './apiService'; import { MissingRelatedPhotosError } from './errors'; import { PhotosNodesAccess } from './nodes'; -import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; +import { + PhotoAlreadyInTargetError, + PhotoTransferPayloadBuilder, + TransferEncryptedPhotoPayload, +} from './photosTransferPayloadBuilder'; /** * The number of photos that are loaded in parallel to prepare the payloads. @@ -39,13 +44,14 @@ export class PhotosManager { async *saveToTimeline(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { const rootNode = await this.nodesService.getVolumeRootFolder(); + const { volumeId: userVolumeId } = splitNodeUid(rootNode.uid); const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid); const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid }); - const queue: { - photoNodeUid: string; - additionalRelatedPhotoNodeUids: string[]; - }[] = nodeUids.map((nodeUid) => ({ photoNodeUid: nodeUid, additionalRelatedPhotoNodeUids: [] })); + const queue: { photoNodeUid: string; additionalRelatedPhotoNodeUids: string[] }[] = nodeUids.map((nodeUid) => ({ + photoNodeUid: nodeUid, + additionalRelatedPhotoNodeUids: [], + })); const retriedPhotoUids = new Set(); while (queue.length > 0) { @@ -62,7 +68,10 @@ export class PhotosManager { yield { uid, ok: false, error }; } - for (const batch of createBatches(payloads)) { + const sameVolumePayloads = payloads.filter((p) => splitNodeUid(p.nodeUid).volumeId === userVolumeId); + const crossVolumePayloads = payloads.filter((p) => splitNodeUid(p.nodeUid).volumeId !== userVolumeId); + + for (const batch of createBatches(sameVolumePayloads)) { for await (const result of this.apiService.transferPhotos(rootNode.uid, batch, signal)) { if ( !result.ok && @@ -79,16 +88,48 @@ export class PhotosManager { }); continue; } - if (result.ok) { await this.nodesService.notifyNodeChanged(result.uid); } yield result; } } + + // Cross-volume photos (e.g. from shared-with-me albums): copy into the user's own + // timeline root using the generic copy endpoint. + for (const payload of crossVolumePayloads) { + try { + await this.copyPhoto(payload, signal); + await this.nodesService.notifyChildCreated(rootNode.uid); + yield { uid: payload.nodeUid, ok: true }; + } catch (error) { + if (error instanceof MissingRelatedPhotosError && !retriedPhotoUids.has(payload.nodeUid)) { + retriedPhotoUids.add(payload.nodeUid); + this.logger.info( + `Missing related photos for saving ${payload.nodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`, + ); + queue.push({ + photoNodeUid: payload.nodeUid, + additionalRelatedPhotoNodeUids: error.missingNodeUids, + }); + continue; + } + yield { + uid: payload.nodeUid, + ok: false, + error: + error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }), + }; + } + } } } + private async copyPhoto(payload: TransferEncryptedPhotoPayload, signal?: AbortSignal): Promise { + const rootNode = await this.nodesService.getVolumeRootFolder(); + return this.apiService.copyPhoto(rootNode.uid, payload, signal); + } + async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator { for await (const { photoSettings: { nodeUid, tagsToAdd, tagsToRemove }, From afa97fba70765b4d1d6e4a8574e7e002361adeb5 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 19 May 2026 05:35:50 +0000 Subject: [PATCH 765/791] Update changelog for js/v0.15.2 --- js/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 1ac7e638..ed7f6acd 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## js/v0.15.2 (2026-05-19) + +* Support copy on save for not owned album +* Avoid content key packet verification fallback on publicly shared nodes +* Update cached node after revision restore +* Allow client to pass core events from external subscription +* Retry network errors more times and with bigger delay + ## js/v0.15.1 (2026-05-12) * Allow all address keys to be used for decryption when listing invitations From 0b6c986ee777add0e146071524a877eb2b6a5044 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 20 May 2026 04:46:55 +0000 Subject: [PATCH 766/791] Export CoreEventInput type to prevent casting on client --- js/sdk/src/internal/events/apiService.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index 5a0f9c6e..e1684d81 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -4,9 +4,11 @@ import { DriveEvent, DriveEventsListWithStatus, DriveEventType, NodeEvent, NodeE type GetCoreLatestEventResponse = corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json']; -export type CoreApiEvent = +type GetCoreApiEvent = corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json']; +export type CoreApiEvent = Pick; + type GetVolumeLatestEventResponse = drivePaths['/drive/volumes/{volumeID}/events/latest']['get']['responses']['200']['content']['application/json']; type GetVolumeEventResponse = @@ -40,7 +42,7 @@ export class EventsAPIService { async getCoreEvents(eventId: string): Promise { // TODO: Switch to v6 endpoint? - const result = await this.apiService.get(`core/v5/events/${eventId}`); + const result = await this.apiService.get(`core/v5/events/${eventId}`); const driveEvents = EventsAPIService.getDriveEventsFromCoreEvent(result); // in core/v5/events, refresh is always all apps, value 255 const refresh = result.Refresh > 0; From a19abd0b5f83de27b26a976e26d012470556dcf8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 21 May 2026 08:39:22 +0200 Subject: [PATCH 767/791] Show what was actually in the JSON when extended attributes cannot be parsed --- .../JsonExceptionExtensions.cs | 59 +++++++++++++++++++ .../Nodes/Cryptography/NodeCrypto.cs | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs new file mode 100644 index 00000000..e2254c8e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace Proton.Drive.Sdk; + +internal static class JsonExceptionExtensions +{ + internal static ProtonDriveError EnrichJsonException(this JsonException e, ReadOnlyMemory json) + { + if (e.Path is not { Length: > 0 }) + { + return e.ToProtonDriveError(); + } + + try + { + using var doc = JsonDocument.Parse(json); + + if (TryGetElementAtPath(doc.RootElement, e.Path, out var element)) + { + return new ProtonDriveError($"Actual token at path '{e.Path}' is {ValueKindToToken(element.ValueKind)}.", e.ToProtonDriveError()); + } + } + catch (JsonException) + { + // Secondary parse failed. + } + + return e.ToProtonDriveError(); + } + + private static bool TryGetElementAtPath(JsonElement root, string path, out JsonElement element) + { + element = root; + + var segments = path.Split('.'); + + // segments[0] is always the "$" root sigil — start from index 1 + for (var i = 1; i < segments.Length; i++) + { + if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(segments[i], out element)) + { + return false; + } + } + + return true; + } + + private static string ValueKindToToken(JsonValueKind kind) => kind switch + { + JsonValueKind.Object => "object '{'", + JsonValueKind.Array => "array '['", + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.Null => "null", + _ => kind.ToString(), + }; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index e3ac6f6f..089cc026 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -290,7 +290,7 @@ private static Result, ProtonDriveError> Decrypt } catch (JsonException e) { - return new ExtendedAttributesDeserializationError(e.ToProtonDriveError()); + return new ExtendedAttributesDeserializationError(e.EnrichJsonException(serializedExtendedAttributes)); } catch (Exception e) { From 03597f71ee06f5a1e65748c891b87a52ad03d67b Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 22 May 2026 11:22:45 +0200 Subject: [PATCH 768/791] Make last modification time optional for file uploads --- .../InteropProtonDriveClient.cs | 4 ++-- cs/sdk/src/protos/proton.drive.sdk.proto | 4 ++-- .../drive/sdk/entity/FileRevisionUploaderRequest.kt | 2 +- .../proton/drive/sdk/entity/FileUploaderRequest.kt | 2 +- .../drive/sdk/extension/FileUploaderRequest.kt | 2 +- .../sdk/extension/GetFileRevisionUploaderRequest.kt | 2 +- .../drive/sdk/internal/InteropProtonDriveClient.kt | 6 ++---- .../Client/ProtonDriveClient/ProtonDriveClient.swift | 8 ++++---- .../FileOperations/Uploads/UploadsManager.swift | 12 ++++++------ 9 files changed, 20 insertions(+), 22 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 45a30456..12eeaf41 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -120,7 +120,7 @@ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGe var metadata = new FileUploadMetadata { - LastModificationTime = request.LastModificationTime.ToDateTimeFixed(), + LastModificationTime = request.LastModificationTime?.ToDateTimeFixed(), AdditionalMetadata = additionalMetadata, }; @@ -167,7 +167,7 @@ public static async ValueTask HandleGetFileRevisionUploaderAsync(Drive var metadata = new FileUploadMetadata { - LastModificationTime = request.LastModificationTime.ToDateTimeFixed(), + LastModificationTime = request.LastModificationTime?.ToDateTimeFixed(), AdditionalMetadata = additionalMetadata, }; diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index f8653820..7fd21131 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -283,7 +283,7 @@ message DriveClientGetFileUploaderRequest { string name = 3; string mediaType = 4; int64 size = 5; - google.protobuf.Timestamp last_modification_time = 6; + google.protobuf.Timestamp last_modification_time = 6; // Optional repeated AdditionalMetadataProperty additional_metadata = 7; // Optional bool override_existing_draft_by_other_client = 8; // When unset or false, waits for a slot in the queue (uses cancellation_token_source_handle). @@ -297,7 +297,7 @@ message DriveClientGetFileRevisionUploaderRequest { int64 client_handle = 1; string current_active_revision_uid = 2; int64 size = 3; - google.protobuf.Timestamp last_modification_time = 4; + google.protobuf.Timestamp last_modification_time = 4; // Optional repeated AdditionalMetadataProperty additional_metadata = 5; // Optional // When unset or false, waits for a slot in the queue (uses cancellation_token_source_handle). // When true, only reserves a slot if immediately available (cancellation_token_source_handle is ignored). diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt index 5bff0cf3..88a3721e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt @@ -4,6 +4,6 @@ import java.time.Instant data class FileRevisionUploaderRequest( val currentActiveRevisionUid: RevisionUid, - val lastModificationTime: Instant, + val lastModificationTime: Instant?, val size: Long, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt index 149b0908..b77631c6 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt @@ -7,7 +7,7 @@ data class FileUploaderRequest( val name: String, val mediaType: String, val fileSize: Long, - val lastModificationTime: Instant, + val lastModificationTime: Instant?, val overrideExistingDraftByOtherClient: Boolean, val additionalMetadata: Map = emptyMap(), ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt index d0720c9e..25808021 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt @@ -13,7 +13,7 @@ internal fun FileUploaderRequest.toProtobuf( mediaType = this@toProtobuf.mediaType size = this@toProtobuf.fileSize parentFolderUid = this@toProtobuf.parentFolderUid.value - lastModificationTime = this@toProtobuf.lastModificationTime.toTimestamp() + this@toProtobuf.lastModificationTime?.toTimestamp()?.let { lastModificationTime = it } overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> additionalMetadataProperty { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt index 5100562a..e26c18f0 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt @@ -12,5 +12,5 @@ internal fun FileRevisionUploaderRequest.toProtobuf( this.cancellationTokenSourceHandle = cancellationTokenSourceHandle this.currentActiveRevisionUid = this@toProtobuf.currentActiveRevisionUid.value this.size = this@toProtobuf.size - this.lastModificationTime = this@toProtobuf.lastModificationTime.toTimestamp() + this@toProtobuf.lastModificationTime?.toTimestamp()?.let { lastModificationTime = it } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt index cdbd8ae8..59329982 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt @@ -105,16 +105,14 @@ internal class InteropProtonDriveClient internal constructor( override suspend fun createFolder( parentFolderUid: NodeUid, name: String, - lastModification: Instant?, + lastModificationTime: Instant?, ): FolderNode = cancellationCoroutineScope { source -> log(INFO, "createFolder") bridge.createFolder( driveClientCreateFolderRequest { this.parentFolderUid = parentFolderUid.value folderName = name - lastModification?.let { - lastModificationTime = lastModification.toTimestamp() - } + lastModificationTime?.toTimestamp()?.let { this.lastModificationTime = it } clientHandle = handle cancellationTokenSourceHandle = source.handle } diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index d05db90f..8fedbd56 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -182,7 +182,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { name: String, url: URL, fileSize: Int64, - modificationDate: Date, + modificationDate: Date?, mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, @@ -216,7 +216,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { name: String, url: URL, fileSize: Int64, - modificationDate: Date, + modificationDate: Date?, mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, @@ -257,7 +257,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { currentActiveRevisionUid: SDKRevisionUid, fileURL: URL, fileSize: Int64, - modificationDate: Date, + modificationDate: Date?, thumbnails: [ThumbnailData], expectedSHA1: Data? = nil, cancellationToken: UUID, @@ -285,7 +285,7 @@ public actor ProtonDriveClient: Sendable, ProtonSDKClient { currentActiveRevisionUid: SDKRevisionUid, fileURL: URL, fileSize: Int64, - modificationDate: Date, + modificationDate: Date?, thumbnails: [ThumbnailData], expectedSHA1: Data? = nil, cancellationToken: UUID, diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift index 02ed4643..e9fb9394 100644 --- a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift @@ -24,7 +24,7 @@ actor UploadsManager { name: String, fileURL: URL, fileSize: Int64, - modificationDate: Date, + modificationDate: Date?, mediaType: String, thumbnails: [ThumbnailData], overrideExistingDraft: Bool, @@ -64,7 +64,7 @@ actor UploadsManager { currentActiveRevisionUid: SDKRevisionUid, fileURL: URL, fileSize: Int64, - modificationDate: Date, + modificationDate: Date?, thumbnails: [ThumbnailData], expectedSHA1: Data?, cancellationToken: UUID, @@ -120,7 +120,7 @@ extension UploadsManager { name: String, mediaType: String, fileSize: Int64, - modificationDate: Date, + modificationDate: Date?, overrideExistingDraft: Bool, cancellationHandle: ObjectHandle?, logger: Logger? @@ -131,7 +131,7 @@ extension UploadsManager { $0.name = name $0.mediaType = mediaType $0.size = fileSize - $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + if let modificationDate { $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) } $0.overrideExistingDraftByOtherClient = overrideExistingDraft if let cancellationHandle = cancellationHandle { @@ -147,14 +147,14 @@ extension UploadsManager { private func getRevisionUploader( currentActiveRevisionUid: SDKRevisionUid, fileSize: Int64, - modificationDate: Date, + modificationDate: Date?, cancellationHandle: ObjectHandle? ) async throws -> ObjectHandle { let uploaderRequest = Proton_Drive_Sdk_DriveClientGetFileRevisionUploaderRequest.with { $0.clientHandle = Int64(clientHandle) $0.currentActiveRevisionUid = currentActiveRevisionUid.sdkCompatibleIdentifier $0.size = fileSize - $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + if let modificationDate { $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) } if let cancellationHandle = cancellationHandle { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) From dbcf7969e3d6abbb9b7a711dd8095b75a3781847 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 22 May 2026 16:18:28 +0000 Subject: [PATCH 769/791] Wrap node not found into a dedicated exception --- .../InteropDriveErrorConverter.cs | 27 +++++++++++++++++ .../NodeWithSameNameExistsException.cs | 4 ++- .../Nodes/Download/FileDownloader.cs | 5 ++++ .../Nodes/Download/PhotosFileDownloader.cs | 7 ++++- .../Nodes/DtoToMetadataConverter.cs | 2 +- .../Nodes/NodeNotFoundException.cs | 29 +++++++++++++++++++ .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 4 +-- .../Nodes/Upload/FileUploader.cs | 2 +- .../Proton.Drive.Sdk/ValidationException.cs | 22 ++++++++++++++ cs/sdk/src/protos/proton.drive.sdk.proto | 4 +++ .../me/proton/drive/sdk/ProtonSdkError.kt | 6 ++++ .../me/proton/drive/sdk/extension/Error.kt | 3 ++ .../sdk/extension/NodeNotFoundErrorData.kt | 9 ++++++ .../AdditionalErrorData.swift | 28 ++++++++++++++++++ 14 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/ValidationException.cs create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNotFoundErrorData.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs index 1999ed42..aa3bcd55 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -1,5 +1,6 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; using Proton.Drive.Sdk.Nodes.Upload.Verification; @@ -72,6 +73,21 @@ public static void SetDomainAndCodes(Error error, Exception exception) break; case NodeWithSameNameExistsException e: + if (e.Code is not null) + { + error.PrimaryCode = (long)e.Code.Value; + } + + error.Domain = ErrorDomain.BusinessLogic; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); + break; + + case NodeNotFoundException e: + if (e.Code is not null) + { + error.PrimaryCode = (long)e.Code.Value; + } + error.Domain = ErrorDomain.BusinessLogic; error.AdditionalData = Any.Pack(ToAdditionalData(e)); break; @@ -162,4 +178,15 @@ private static NodeNameConflictErrorData ToAdditionalData(NodeWithSameNameExists return data; } + + private static NodeNotFoundErrorData ToAdditionalData(NodeNotFoundException e) + { + var data = new NodeNotFoundErrorData(); + if (e.NodeUid is { } nodeUid) + { + data.NodeUid = nodeUid.ToString(); + } + + return data; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs index 2ef9b588..548a49ff 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs @@ -5,7 +5,7 @@ namespace Proton.Drive.Sdk; -public sealed class NodeWithSameNameExistsException : ProtonDriveException +public sealed class NodeWithSameNameExistsException : ValidationException { public NodeWithSameNameExistsException() { @@ -29,6 +29,8 @@ internal NodeWithSameNameExistsException(VolumeId volumeId, ProtonApiException GetLinkDetailsAsync(LinkId linkId, CancellationToken return response.Links is { Count: > 0 } links ? links[0] - : throw new ProtonDriveException($"Node \"{new NodeUid(volumeId, linkId)}\" not found"); + : throw new NodeNotFoundException(new NodeUid(volumeId, linkId)); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs new file mode 100644 index 00000000..12dbaa8c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs @@ -0,0 +1,29 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed class NodeNotFoundException : ValidationException +{ + public NodeNotFoundException() + { + } + + public NodeNotFoundException(string message) + : base(message) + { + } + + public NodeNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } + + public NodeNotFoundException(NodeUid nodeUid) + : base($"Node \"{nodeUid}\" not found") + { + Code = ResponseCode.DoesNotExist; + NodeUid = nodeUid; + } + + public NodeUid? NodeUid { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index ef98c136..8a114357 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -64,7 +64,7 @@ public static async ValueTask> GetNod { if (useCacheOnly) { - throw new ProtonDriveException("Node \"{uid}\" not found in cache"); + throw new NodeNotFoundException(uid); } metadataResult = await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); @@ -159,7 +159,7 @@ public static async ValueTask> GetFre return await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( client, uid.VolumeId, - response.Links is { Count: > 0 } links ? links[0] : throw new ProtonDriveException($"Node \"{uid}\" not found"), + response.Links is { Count: > 0 } links ? links[0] : throw new NodeNotFoundException(uid), knownShareAndKey, cancellationToken) .ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 7f16f16f..dfac1a64 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -166,7 +166,7 @@ private UploadController UploadFromStream( async ValueTask OnFailedAsync(Exception ex, long uploadedByteCount) { - if (ex is NodeWithSameNameExistsException) + if (ex is ValidationException) { return; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ValidationException.cs b/cs/sdk/src/Proton.Drive.Sdk/ValidationException.cs new file mode 100644 index 00000000..e4f61db3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ValidationException.cs @@ -0,0 +1,22 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk; + +public class ValidationException : ProtonDriveException +{ + public ValidationException() + { + } + + public ValidationException(string message) + : base(message) + { + } + + public ValidationException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ResponseCode? Code { get; protected init; } +} diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 7fd21131..0fd95a97 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -934,3 +934,7 @@ message ChecksumMismatchErrorData { bytes actual_checksum = 1; bytes expected_checksum = 2; } + +message NodeNotFoundErrorData { + string node_uid = 1; +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt index 8be8d5b0..c5ee8962 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt @@ -66,6 +66,12 @@ data class ProtonSdkError( override fun toSafe() = this } + data class NodeNotFound( + val nodeUid: NodeUid?, + ) : Data { + override fun toSafe() = this + } + class ChecksumMismatch( val actualChecksum: ByteArray?, val expectedChecksum: ByteArray?, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt index dbfb9e1a..cdce69a2 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt @@ -51,5 +51,8 @@ private fun Any.toData() = when (typeUrl) { "type.googleapis.com/proton.drive.sdk.ChecksumMismatchErrorData" -> ProtonDriveSdk.ChecksumMismatchErrorData.parseFrom(value).toEntity() + "type.googleapis.com/proton.drive.sdk.NodeNotFoundErrorData" -> + ProtonDriveSdk.NodeNotFoundErrorData.parseFrom(value).toEntity() + else -> null } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNotFoundErrorData.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNotFoundErrorData.kt new file mode 100644 index 00000000..837a426f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNotFoundErrorData.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonSdkError +import me.proton.drive.sdk.entity.NodeUid +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.NodeNotFoundErrorData.toEntity() = ProtonSdkError.Data.NodeNotFound( + nodeUid = takeIf { hasNodeUid() }?.let { NodeUid(nodeUid) }, +) diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift index a7f3f940..f0aaee50 100644 --- a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift @@ -8,6 +8,7 @@ struct AdditionalErrorDataFactory { ?? ContentSizeMismatchErrorData(data: data) ?? ThumbnailCountMismatchErrorData(data: data) ?? ChecksumMismatchErrorData(data: data) + ?? NodeNotFoundErrorData(data: data) } } @@ -132,6 +133,33 @@ public struct ThumbnailCountMismatchErrorData: AdditionalErrorData { } } +public struct NodeNotFoundErrorData: AdditionalErrorData { + public let nodeUID: SDKNodeUid? + + init?(data: Google_Protobuf_Any) { + do { + let errorData = try Proton_Drive_Sdk_NodeNotFoundErrorData(unpackingAny: data) + let nodeUIDStr = errorData.hasNodeUid ? errorData.nodeUid : "" + self.nodeUID = SDKNodeUid(sdkCompatibleIdentifier: nodeUIDStr) + } catch { + return nil + } + } + + public func toProtobufAny() -> Google_Protobuf_Any? { + let errorData = Proton_Drive_Sdk_NodeNotFoundErrorData.with { + if let nodeUID = nodeUID { + $0.nodeUid = nodeUID.sdkCompatibleIdentifier + } + } + return try? Google_Protobuf_Any(message: errorData) + } + + public func errorDescription() -> String { + "nodeUID: \(String(describing: nodeUID))" + } +} + public struct ChecksumMismatchErrorData: AdditionalErrorData { public let actualChecksum: Data public let expectedChecksum: Data From 904b8b34e8044fe3a1bc3f63b156dc9882bd90bd Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 22 May 2026 16:26:36 +0000 Subject: [PATCH 770/791] Retry block encryption and report metric --- .../DriveInteropTelemetryDecorator.cs | 12 +- .../Nodes/Upload/BlockUploader.cs | 184 +++++++++++------- .../Telemetry/BlockVerificationErrorEvent.cs | 2 + cs/sdk/src/protos/proton.drive.sdk.proto | 3 +- js/sdk/src/interface/telemetry.ts | 1 + .../src/internal/upload/smallFileUploader.ts | 4 +- .../internal/upload/streamUploader.test.ts | 16 +- js/sdk/src/internal/upload/streamUploader.ts | 5 +- js/sdk/src/internal/upload/telemetry.ts | 10 +- .../BlockVerificationErrorEventPayload.kt | 1 + .../telemetry/BlockVerificationErrorEvent.kt | 1 + .../TelemetryAndLogging/TelemetryTypes.swift | 6 +- 12 files changed, 162 insertions(+), 83 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index db9f0e1d..aa1648bd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -24,8 +24,7 @@ public void RecordMetric(IMetricEvent metricEvent) UploadEvent me => GetUploadEventPayload(me), DownloadEvent me => GetDownloadEventPayload(me), DecryptionErrorEvent me => GetDecryptionErrorPayload(me), - - // FIXME support error metrics + BlockVerificationErrorEvent me => GetBlockVerificationErrorPayload(me), _ => null, }; @@ -96,6 +95,15 @@ private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me) return payload; } + private static BlockVerificationErrorEventPayload GetBlockVerificationErrorPayload(BlockVerificationErrorEvent me) + { + return new BlockVerificationErrorEventPayload + { + VolumeType = (VolumeType)me.VolumeType, + RetryHelped = me.RetryHelped, + }; + } + private static DecryptionErrorEventPayload GetDecryptionErrorPayload(DecryptionErrorEvent me) { var payload = new DecryptionErrorEventPayload diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs index 20c0dc74..8b72d177 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -8,13 +8,17 @@ using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Http; using Proton.Drive.Sdk.Nodes.Download; +using Proton.Drive.Sdk.Nodes.Upload.Verification; using Proton.Drive.Sdk.Resilience; +using Proton.Drive.Sdk.Telemetry; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes.Upload; internal sealed partial class BlockUploader { + private const int MaxBlockVerificationRetries = 1; + private readonly ProtonDriveClient _client; private readonly ILogger _logger; @@ -34,91 +38,116 @@ public async ValueTask UploadContentAsync( using (_logger.BeginScope("Content block #{BlockNumber} of revision #{RevisionUid}", draft.Uid, blockNumber)) { var plainDataLength = plainData.Stream.Length; + var integrityErrorEncountered = false; + var attempt = 0; - var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (dataPacketStream.ConfigureAwait(false)) + while (true) { - var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + attempt++; + plainData.Stream.Seek(0, SeekOrigin.Begin); - await using (signatureStream.ConfigureAwait(false)) + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) { - using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - - var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); + var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (hashingStream.ConfigureAwait(false)) + await using (signatureStream.ConfigureAwait(false)) { - var signatureEncryptingStream = draft.FileKey.OpenEncryptingStream(signatureStream); + using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - await using (signatureEncryptingStream.ConfigureAwait(false)) + var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); + + await using (hashingStream.ConfigureAwait(false)) { - var pgpProfile = draft.ContentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; - var encryptingStream = draft.ContentKey.OpenEncryptingAndSigningStream( - hashingStream, - signatureEncryptingStream, - draft.SigningKey, - profile: pgpProfile, - aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); - - await using (encryptingStream.ConfigureAwait(false)) + var signatureEncryptingStream = draft.FileKey.OpenEncryptingStream(signatureStream); + + await using (signatureEncryptingStream.ConfigureAwait(false)) { - await plainData.Stream.CopyToAsync(encryptingStream, cancellationToken).ConfigureAwait(false); + var pgpProfile = draft.ContentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + var encryptingStream = draft.ContentKey.OpenEncryptingAndSigningStream( + hashingStream, + signatureEncryptingStream, + draft.SigningKey, + profile: pgpProfile, + aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); + + await using (encryptingStream.ConfigureAwait(false)) + { + await plainData.Stream.CopyToAsync(encryptingStream, cancellationToken).ConfigureAwait(false); + } } } - } - var sha256Digest = sha256.GetCurrentHash(); + var sha256Digest = sha256.GetCurrentHash(); - var result = new BlockUploadResult((int)plainData.Stream.Length, sha256Digest); + var result = new BlockUploadResult((int)plainData.Stream.Length, sha256Digest); - // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, - // leading to a garbage signature. - var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; + // The signature stream should not be closed until the signature is no longer needed, because the underlying buffer could be re-used, + // leading to a garbage signature. + var signature = signatureStream.GetBuffer().AsMemory()[..(int)signatureStream.Length]; - // FIXME: retry upon verification failure - const long AeadChunkSize = - 1 + // packet header: packet type - 1 + // packet header: partial length - 4 + // SEIPDv2 header: packet version, cipher ID, algo Id, chunk size - 32 + // SEIPDv2 header: salt - PgpAeadStreamingChunkLength.ChunkLength + - 1 + // chunk size header - 36 + // end of chunk - 16; // Aead Tag + const long AeadChunkSize = + 1 + // packet header: packet type + 1 + // packet header: partial length + 4 + // SEIPDv2 header: packet version, cipher ID, algo Id, chunk size + 32 + // SEIPDv2 header: salt + PgpAeadStreamingChunkLength.ChunkLength + + 1 + // chunk size header + 36 + // end of chunk + 16; // Aead Tag - var plainDataPrefixLength = (int)Math.Min(draft.BlockVerifier.DataPacketPrefixMaxLength, plainData.Stream.Length); + var plainDataPrefixLength = (int)Math.Min(draft.BlockVerifier.DataPacketPrefixMaxLength, plainData.Stream.Length); - var verificationToken = draft.BlockVerifier.VerifyBlock( - dataPacketStream.GetFirstBytes(AeadChunkSize), - plainData.PrefixForVerification.AsSpan()[..plainDataPrefixLength]); + try + { + var verificationToken = draft.BlockVerifier.VerifyBlock( + dataPacketStream.GetFirstBytes(AeadChunkSize), + plainData.PrefixForVerification.AsSpan()[..plainDataPrefixLength]); - var request = new BlockUploadPreparationRequest - { - VolumeId = draft.Uid.NodeUid.VolumeId, - LinkId = draft.Uid.NodeUid.LinkId, - RevisionId = draft.Uid.RevisionId, - AddressId = draft.MembershipAddress.Id, - Blocks = - [ - new BlockCreationRequest + if (integrityErrorEncountered) { - Index = blockNumber, - Size = (int)dataPacketStream.Length, - HashDigest = result.Sha256Digest, - EncryptedSignature = signature, - VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, - }, - ], - Thumbnails = [], - }; - - await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); - - onBlockProgress?.Invoke(plainDataLength); - - LogBlobUploaded(); + await RecordBlockVerificationErrorAsync(draft, retryHelped: true, cancellationToken).ConfigureAwait(false); + } - return result; + var request = new BlockUploadPreparationRequest + { + VolumeId = draft.Uid.NodeUid.VolumeId, + LinkId = draft.Uid.NodeUid.LinkId, + RevisionId = draft.Uid.RevisionId, + AddressId = draft.MembershipAddress.Id, + Blocks = + [ + new BlockCreationRequest + { + Index = blockNumber, + Size = (int)dataPacketStream.Length, + HashDigest = result.Sha256Digest, + EncryptedSignature = signature, + VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, + }, + ], + Thumbnails = [], + }; + + await UploadBlobAsync(request, dataPacketStream, cancellationToken).ConfigureAwait(false); + + onBlockProgress?.Invoke(plainDataLength); + + LogBlobUploaded(); + + return result; + } + catch (SessionKeyAndDataPacketMismatchException) when (attempt <= MaxBlockVerificationRetries) + { + integrityErrorEncountered = true; + LogBlockVerificationRetry(attempt); + } + catch (SessionKeyAndDataPacketMismatchException) + { + await RecordBlockVerificationErrorAsync(draft, retryHelped: false, cancellationToken).ConfigureAwait(false); + throw; + } + } } } } @@ -242,9 +271,34 @@ private async Task WaitOnRetryAfterIfNeededAsync(Exception ex, CancellationToken } } + private async Task RecordBlockVerificationErrorAsync(RevisionDraft draft, bool retryHelped, CancellationToken cancellationToken) + { + try + { + var volumeType = await TelemetryEventFactory.ResolveVolumeTypeAsync(_client, draft.Uid.NodeUid, cancellationToken).ConfigureAwait(false); + _client.Telemetry.RecordMetric(new BlockVerificationErrorEvent + { + VolumeType = volumeType, + RetryHelped = retryHelped, + }); + } + catch (Exception ex) + { + LogBlockVerificationErrorMetricFailed(ex); + } + } + [LoggerMessage(Level = LogLevel.Trace, Message = "Uploaded blob")] private partial void LogBlobUploaded(); + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to record metric for block verification error event")] + private partial void LogBlockVerificationErrorMetricFailed(Exception ex); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Block verification failed (attempt #{Attempt}), retrying encryption")] + private partial void LogBlockVerificationRetry(int attempt); + [LoggerMessage( Level = LogLevel.Information, Message = "Retrying blob upload (retry number: {RetryNumber}). Previous attempt error: {ErrorMessage}")] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs index 51ef60f6..d2eb7d0c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs @@ -6,5 +6,7 @@ public sealed class BlockVerificationErrorEvent : IMetricEvent { public string Name => "blockVerificationError"; + public VolumeType VolumeType { get; init; } + public bool RetryHelped { get; init; } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 0fd95a97..9f62d51b 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -907,7 +907,8 @@ message VerificationErrorEventPayload { } message BlockVerificationErrorEventPayload { - bool retry_helped = 1; + VolumeType volume_type = 1; + bool retry_helped = 2; } message NodeNameConflictErrorData { diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 6d8b0e21..2b1dd310 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -109,6 +109,7 @@ export type MetricVerificationErrorField = export interface MetricBlockVerificationErrorEvent { eventName: 'blockVerificationError'; + volumeType: MetricVolumeType; retryHelped: boolean; } diff --git a/js/sdk/src/internal/upload/smallFileUploader.ts b/js/sdk/src/internal/upload/smallFileUploader.ts index 2e701ab4..58d6f182 100644 --- a/js/sdk/src/internal/upload/smallFileUploader.ts +++ b/js/sdk/src/internal/upload/smallFileUploader.ts @@ -194,7 +194,7 @@ abstract class SmallUploader { 0, ); if (integrityError) { - void this.telemetry.logBlockVerificationError(true); + void this.telemetry.logBlockVerificationError(this.getTelemetryContextUid(), true); } } catch (error: unknown) { // Do not retry or report anything if the upload was aborted. @@ -213,7 +213,7 @@ abstract class SmallUploader { this.logger.error(`Failed to encrypt block`, error); if (integrityError) { - void this.telemetry.logBlockVerificationError(false); + void this.telemetry.logBlockVerificationError(this.getTelemetryContextUid(), false); } throw error; } diff --git a/js/sdk/src/internal/upload/streamUploader.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts index 2292f1c0..eccfc55b 100644 --- a/js/sdk/src/internal/upload/streamUploader.test.ts +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -106,8 +106,8 @@ describe('StreamUploader', () => { }; revisionDraft = { - nodeRevisionUid: 'revisionUid', - nodeUid: 'nodeUid', + nodeRevisionUid: 'testVol~testNode~testRev', + nodeUid: 'testVol~testNode', nodeKeys: { signingKeys: { addressId: 'addressId', @@ -149,8 +149,8 @@ describe('StreamUploader', () => { const result = await uploader.start(stream, thumbnails, onProgress); expect(result).toEqual({ - nodeRevisionUid: 'revisionUid', - nodeUid: 'nodeUid', + nodeRevisionUid: 'testVol~testNode~testRev', + nodeUid: 'testVol~testNode', }); const numberOfExpectedBlocks = Math.ceil(metadata.expectedSize / FILE_CHUNK_SIZE); @@ -177,7 +177,7 @@ describe('StreamUploader', () => { }, ); expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); - expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize); + expect(telemetry.uploadFinished).toHaveBeenCalledWith('testVol~testNode~testRev', metadata.expectedSize + thumbnailSize); expect(telemetry.uploadFailed).not.toHaveBeenCalled(); expect(onFinish).toHaveBeenCalledTimes(1); expect(onFinish).toHaveBeenCalledWith(false); @@ -194,7 +194,7 @@ describe('StreamUploader', () => { expect(telemetry.uploadFinished).not.toHaveBeenCalled(); expect(telemetry.uploadFailed).toHaveBeenCalledTimes(1); expect(telemetry.uploadFailed).toHaveBeenCalledWith( - 'revisionUid', + 'testVol~testNode~testRev', new Error(error), uploadedBytes === undefined ? expect.anything() : uploadedBytes, expectedSize, @@ -545,7 +545,7 @@ describe('StreamUploader', () => { it('should report block verification error', async () => { blockVerifier.verifyBlock = jest.fn().mockRejectedValue(new IntegrityError('Block verification error')); await verifyFailure('Block verification error', 0); - expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(false); + expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith('testVol~testNode', false); }); it('should report block verification error when retry helped', async () => { @@ -556,7 +556,7 @@ describe('StreamUploader', () => { verificationToken: new Uint8Array(), }); await verifySuccess(); - expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith(true); + expect(telemetry.logBlockVerificationError).toHaveBeenCalledWith('testVol~testNode', true); }); it('should throw an error if block count does not match', async () => { diff --git a/js/sdk/src/internal/upload/streamUploader.ts b/js/sdk/src/internal/upload/streamUploader.ts index 3f1b8a7b..e770f571 100644 --- a/js/sdk/src/internal/upload/streamUploader.ts +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -5,6 +5,7 @@ import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interfac import { LoggerWithPrefix } from '../../telemetry'; import { APIHTTPError, HTTPErrorCode, NotFoundAPIError } from '../apiService'; import { getErrorMessage } from '../errors'; +import { makeNodeUidFromRevisionUid } from '../uids'; import { mergeUint8Arrays } from '../utils'; import { waitForCondition } from '../wait'; import { UploadAPIService } from './apiService'; @@ -321,7 +322,7 @@ export class StreamUploader { index, ); if (integrityError) { - void this.telemetry.logBlockVerificationError(true); + void this.telemetry.logBlockVerificationError(makeNodeUidFromRevisionUid(this.revisionDraft.nodeRevisionUid), true); } } catch (error: unknown) { // Do not retry or report anything if the upload was aborted. @@ -342,7 +343,7 @@ export class StreamUploader { this.logger.error(`Failed to encrypt block ${index}`, error); if (integrityError) { - void this.telemetry.logBlockVerificationError(false); + void this.telemetry.logBlockVerificationError(makeNodeUidFromRevisionUid(this.revisionDraft.nodeRevisionUid), false); } throw error; } diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index 156fe2a4..baea7901 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -26,9 +26,17 @@ export class UploadTelemetry { return new LoggerWithPrefix(this.logger, `revision ${revisionUid}`); } - logBlockVerificationError(retryHelped: boolean) { + async logBlockVerificationError(nodeUid: string, retryHelped: boolean) { + const { volumeId } = splitNodeUid(nodeUid); + let volumeType = MetricVolumeType.Unknown; + try { + volumeType = await this.sharesService.getVolumeMetricContext(volumeId); + } catch (error: unknown) { + this.logger.error('Failed to get metric volume type', error); + } this.telemetry.recordMetric({ eventName: 'blockVerificationError', + volumeType, retryHelped, }); } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt index 3bb366a4..abdc8980 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt @@ -4,5 +4,6 @@ import me.proton.drive.sdk.telemetry.BlockVerificationErrorEvent import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.BlockVerificationErrorEventPayload.toEvent() = BlockVerificationErrorEvent( + volumeType = volumeType.toEnum(), retryHelped = retryHelped, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt index 3e46231b..d3590085 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt @@ -1,5 +1,6 @@ package me.proton.drive.sdk.telemetry data class BlockVerificationErrorEvent( + val volumeType: VolumeType, val retryHelped: Boolean, ) diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift index 9dc8445e..97dc87c9 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -57,10 +57,12 @@ public struct ApiRetrySucceededEventPayload: Sendable { } public struct BlockVerificationErrorEventPayload: Sendable { - + + public let volumeType: VolumeType public let retryHelped: Bool - + init(sdkEventPayload: Proton_Drive_Sdk_BlockVerificationErrorEventPayload) { + self.volumeType = .init(sdkVolumeType: sdkEventPayload.volumeType) self.retryHelped = sdkEventPayload.retryHelped } } From 8acfd69e7b6fd23650bc9e987df54e0c9bdcdb8c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 25 May 2026 05:06:01 +0000 Subject: [PATCH 771/791] Update docs --- cs/.gitignore | 5 +++++ js/sdk/package.json | 3 +-- js/sdk/src/interface/index.ts | 2 ++ js/sdk/src/protonDriveClient.ts | 7 ++++--- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cs/.gitignore b/cs/.gitignore index a1996f32..875100c3 100644 --- a/cs/.gitignore +++ b/cs/.gitignore @@ -351,6 +351,11 @@ vcpkg_installed/ build/output .vscode +# DocFX +docfx/api/ +docfx/_site/ +.tools/ + # macOS *.DS_Store .AppleDouble diff --git a/js/sdk/package.json b/js/sdk/package.json index c6bb8a53..7da419fc 100644 --- a/js/sdk/package.json +++ b/js/sdk/package.json @@ -13,8 +13,7 @@ "build": "tsc", "build:ci": "rm -rf dist tsconfig.tsbuildinfo && tsc", "check-types": "tsc --noEmit", - "generate-doc:interface": "typedoc src/index.ts --out ${OUTPUT_PATH}", - "generate-doc:internal": "typedoc src/**/*.ts --out ${OUTPUT_PATH}", + "generate-docs": "typedoc src/protonDriveClient.ts src/protonDrivePhotosClient.ts src/protonDrivePublicLinkClient.ts src/index.ts --out ${OUTPUT_PATH}", "generate-types": "openapi-typescript ../../api/openapi-drive.json -o ./src/internal/apiService/driveTypes.ts && openapi-typescript ../../api/openapi-core.json -o ./src/internal/apiService/coreTypes.ts", "lint": "eslint src --ext .ts --cache --ignore-pattern '**/apiService/*Types.ts'", "pretty": "prettier --write $(find src -type f -name '*.ts')", diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index e691e44b..68a40af6 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -82,9 +82,11 @@ export type { Logger, MetricAPIRetrySucceededEvent, MetricBlockVerificationErrorEvent, + MetricDebounceLongWaitEvent, MetricDecryptionErrorEvent, MetricDownloadEvent, MetricEvent, + MetricPerformanceEvent, MetricsDecryptionErrorField, MetricsDownloadErrorType, MetricsUploadErrorType, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 6e4a59c6..bbe52cae 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -459,7 +459,7 @@ export class ProtonDriveClient { * If one of the nodes fails to copy, the operation continues with the * rest of the nodes. Use `NodeResult` to check the status of the action. * - * @param nodeUids - List of node entities or their UIDs. + * @param nodesOrNodeUidsOrWithNames - List of node entities or their UIDs. * @param newParentNodeUid - Node entity or its UID string. * @param signal - Signal to abort the operation. * @returns An async generator of the results of the copy operation @@ -675,7 +675,7 @@ export class ProtonDriveClient { /** * Reject the invitation to the shared node. * - * @param invitationOrUid - Invitation entity or its UID string. + * @param invitationUid - Invitation entity or its UID string. */ async rejectInvitation(invitationUid: ProtonInvitationOrUid): Promise { this.logger.info(`Rejecting invitation ${getUid(invitationUid)}`); @@ -964,7 +964,8 @@ export class ProtonDriveClient { /** * Creates a new device. * - * @param nodeUid - Device entity or its UID string. + * @param name - Name of the device. + * @param deviceType - Type of the device. * @returns The created device entity. * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. */ From cdfb7260d734aa3c90fdd17cf90e62b64e500f34 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 May 2026 12:32:47 +0000 Subject: [PATCH 772/791] Prefer iterate over UIDs over nodes --- js/sdk/src/internal/nodes/nodesAccess.ts | 28 ++++++++- js/sdk/src/internal/sharing/sharingAccess.ts | 15 +++++ js/sdk/src/protonDriveClient.ts | 62 ++++++++++++++++++++ js/sdk/src/protonDrivePhotosClient.ts | 36 ++++++++++++ js/sdk/src/protonDrivePublicLinkClient.ts | 16 +++++ 5 files changed, 156 insertions(+), 1 deletion(-) diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index ab872176..77cefbe3 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -101,6 +101,30 @@ export abstract class NodesAccessBase< return node; } + async *iterateFolderChildrenNodeUids( + parentNodeUid: string, + filterOptions?: FilterOptions, + signal?: AbortSignal, + ): AsyncGenerator { + const parentNode = await this.getNode(parentNodeUid); + + // TODO: Requires API to support other types. + if (filterOptions?.type !== undefined && filterOptions.type !== NodeType.Folder) { + throw Error('Filter options supports only folders'); + } + + const onlyFolders = filterOptions?.type === NodeType.Folder; + yield* this.apiService.iterateChildrenNodeUids(parentNode.uid, onlyFolders, signal); + } + + async *iterateTrashedNodeUids(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.shareService.getRootIDs(); + yield* this.apiService.iterateTrashedNodeUids(volumeId, signal); + } + + /** + * @deprecated Use `iterateFolderChildrenNodeUids` instead. + */ async *iterateFolderChildren( parentNodeUid: string, filterOptions?: FilterOptions, @@ -157,7 +181,9 @@ export abstract class NodesAccessBase< } } - // Improvement requested: keep status of loaded trash and leverage cache. + /** + * @deprecated Use `iterateTrashedNodeUids` instead. + */ async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { const { volumeId } = await this.shareService.getRootIDs(); const batchLoading = new BatchLoading({ diff --git a/js/sdk/src/internal/sharing/sharingAccess.ts b/js/sdk/src/internal/sharing/sharingAccess.ts index 87cac928..18704b9b 100644 --- a/js/sdk/src/internal/sharing/sharingAccess.ts +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -35,6 +35,18 @@ export class SharingAccess { this.nodesService = nodesService; } + async *iterateSharedNodeUids(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.sharesService.getRootIDs(); + yield* this.apiService.iterateSharedNodeUids(volumeId, signal); + } + + async *iterateSharedWithMeNodeUids(signal?: AbortSignal): AsyncGenerator { + yield* this.apiService.iterateSharedWithMeNodeUids(signal); + } + + /** + * @deprecated Use `iterateSharedNodeUids` instead. + */ async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { try { const nodeUids = await this.cache.getSharedByMeNodeUids(); @@ -50,6 +62,9 @@ export class SharingAccess { } } + /** + * @deprecated Use `iterateSharedWithMeNodeUids` instead. + */ async *iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { try { const nodeUids = await this.cache.getSharedWithMeNodeUids(); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index bbe52cae..55339a7b 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -343,6 +343,38 @@ export class ProtonDriveClient { return convertInternalNodePromise(this.nodes.access.getVolumeRootFolder()); } + /** + * Iterates the UIDs of the children of the given parent node. + * + * The output is not sorted and the order of the UIDs is not guaranteed. + * + * @param parentNodeUid - Node entity or its UID string. + * @param filterOptions - Filter options. + * @param signal - Signal to abort the operation. + * @returns An async generator of the UIDs of the children of the given parent node. + */ + async *iterateFolderChildrenNodeUids( + parentNodeUid: NodeOrUid, + filterOptions?: { type?: NodeType }, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); + yield* this.nodes.access.iterateFolderChildrenNodeUids(getUid(parentNodeUid), filterOptions, signal); + } + + /** + * Iterates the UIDs of the trashed nodes. + * + * The output is not sorted and the order of the UIDs is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the UIDs of the trashed nodes. + */ + async *iterateTrashedNodeUids(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating trashed node UIDs'); + yield* this.nodes.access.iterateTrashedNodeUids(signal); + } + /** * Iterates the children of the given parent node. * @@ -351,6 +383,7 @@ export class ProtonDriveClient { * @param parentNodeUid - Node entity or its UID string. * @param signal - Signal to abort the operation. * @returns An async generator of the children of the given parent node. + * @deprecated Use `iterateFolderChildrenNodeUids` instead. */ async *iterateFolderChildren( parentNodeUid: NodeOrUid, @@ -372,6 +405,7 @@ export class ProtonDriveClient { * * @param signal - Signal to abort the operation. * @returns An async generator of the trashed nodes. + * @deprecated Use `iterateTrashedNodeUids` instead. */ async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); @@ -606,6 +640,32 @@ export class ProtonDriveClient { await this.nodes.revisions.deleteRevision(getUid(revisionUid)); } + /** + * Iterates the UIDs of the nodes shared by the user. + * + * The output is not sorted and the order of the UIDs is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the UIDs of the shared nodes by the user. + */ + async *iterateSharedNodeUids(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating shared nodes by me'); + yield* this.sharing.access.iterateSharedNodeUids(signal); + } + + /** + * Iterates the UIDs of the nodes shared with the user. + * + * The output is not sorted and the order of the UIDs is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the UIDs of the shared nodes with the user. + */ + async *iterateSharedWithMeNodeUids(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating shared nodes with me'); + yield* this.sharing.access.iterateSharedWithMeNodeUids(signal); + } + /** * Iterates the nodes shared by the user. * @@ -613,6 +673,7 @@ export class ProtonDriveClient { * * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. + * @deprecated Use `iterateSharedNodeUids` instead. */ async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes by me'); @@ -630,6 +691,7 @@ export class ProtonDriveClient { * * @param signal - Signal to abort the operation. * @returns An async generator of the shared nodes. + * @deprecated Use `iterateSharedWithMeNodeUids` instead. */ async *iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes with me'); diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 1b0b0253..a4792c71 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -276,10 +276,22 @@ export class ProtonDrivePhotosClient { yield* this.photos.timeline.iterateTimeline(signal); } + /** + * Iterates the UIDs of the trashed nodes. + * + * See `ProtonDriveClient.iterateTrashedNodeUids` for more information. + */ + async *iterateTrashedNodeUids(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating trashed node UIDs'); + yield* this.nodes.access.iterateTrashedNodeUids(signal); + } + /** * Iterates the trashed nodes. * * See `ProtonDriveClient.iterateTrashedNodes` for more information. + * + * @deprecated Use `iterateTrashedNodeUids` instead. */ async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); @@ -355,10 +367,32 @@ export class ProtonDrivePhotosClient { return this.nodes.management.emptyTrash(); } + /** + * Iterates the UIDs of the nodes shared by the user. + * + * See `ProtonDriveClient.iterateSharedNodeUids` for more information. + */ + async *iterateSharedNodeUids(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating shared nodes by me'); + yield* this.sharing.access.iterateSharedNodeUids(signal); + } + + /** + * Iterates the UIDs of the nodes shared with the user. + * + * See `ProtonDriveClient.iterateSharedWithMeNodeUids` for more information. + */ + async *iterateSharedWithMeNodeUids(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating shared nodes with me'); + yield* this.sharing.access.iterateSharedWithMeNodeUids(signal); + } + /** * Iterates the nodes shared by the user. * * See `ProtonDriveClient.iterateSharedNodes` for more information. + * + * @deprecated Use `iterateSharedNodeUids` instead. */ async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes by me'); @@ -369,6 +403,8 @@ export class ProtonDrivePhotosClient { * Iterates the nodes shared with the user. * * See `ProtonDriveClient.iterateSharedNodesWithMe` for more information. + * + * @deprecated Use `iterateSharedWithMeNodeUids` instead. */ async *iterateSharedNodesWithMe(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes with me'); diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 25515b9e..6c8b0bd1 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -256,10 +256,26 @@ export class ProtonDrivePublicLinkClient { return convertInternalNodePromise(this.sharingPublic.nodes.access.getNode(rootNodeUid)); } + /** + * Iterates the UIDs of the children of the given parent node. + * + * See `ProtonDriveClient.iterateFolderChildrenNodeUids` for more information. + */ + async *iterateFolderChildrenNodeUids( + parentUid: NodeOrUid, + filterOptions?: { type?: NodeType }, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating children of ${getUid(parentUid)}`); + yield* this.sharingPublic.nodes.access.iterateFolderChildrenNodeUids(getUid(parentUid), filterOptions, signal); + } + /** * Iterates the children of the given parent node. * * See `ProtonDriveClient.iterateFolderChildren` for more information. + * + * @deprecated Use `iterateFolderChildrenNodeUids` instead. */ async *iterateFolderChildren( parentUid: NodeOrUid, From 76f845258a5388dda0cd8b8e54c8840062ab84ec Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 May 2026 12:51:01 +0000 Subject: [PATCH 773/791] Add method to return node hierarchy --- js/sdk/src/internal/nodes/nodesAccess.test.ts | 85 +++++++++++++++++++ js/sdk/src/internal/nodes/nodesAccess.ts | 17 +++- js/sdk/src/protonDriveClient.ts | 15 ++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/nodes/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts index 83601204..0b0f5c40 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.test.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -155,6 +155,91 @@ describe('nodesAccess', () => { }); }); + describe('getNodeHierarchy', () => { + it('should reject when node does not exist', async () => { + cache.getNode = jest.fn(() => Promise.reject(new Error('Entity not found'))); + apiService.getNode = jest.fn(() => Promise.reject(new Error('Node not found'))); + + await expect(access.getNodeHierarchy('volumeId~missingNodeId')).rejects.toThrow('Node not found'); + }); + + it('should return single node when asking for root', async () => { + const rootNode = { uid: 'volumeId~rootNodeId', parentUid: undefined, isStale: false } as DecryptedNode; + cache.getNode = jest.fn(() => Promise.resolve(rootNode)); + + const result = await access.getNodeHierarchy('volumeId~rootNodeId'); + + expect(result).toEqual([rootNode]); + expect(cache.getNode).toHaveBeenCalledTimes(1); + expect(cache.getNode).toHaveBeenCalledWith('volumeId~rootNodeId'); + }); + + it('should return hierarchy from root to node', async () => { + const rootNode = { uid: 'volumeId~rootNodeId', parentUid: undefined, isStale: false } as DecryptedNode; + const parentNode = { + uid: 'volumeId~parentNodeId', + parentUid: 'volumeId~rootNodeId', + isStale: false, + } as DecryptedNode; + const childNode = { + uid: 'volumeId~childNodeId', + parentUid: 'volumeId~parentNodeId', + isStale: false, + } as DecryptedNode; + const nodes: Record = { + 'volumeId~rootNodeId': rootNode, + 'volumeId~parentNodeId': parentNode, + 'volumeId~childNodeId': childNode, + }; + cache.getNode = jest.fn((uid: string) => { + const node = nodes[uid]; + if (!node) { + return Promise.reject(new Error('Entity not found')); + } + return Promise.resolve(node); + }); + + const result = await access.getNodeHierarchy('volumeId~childNodeId'); + + expect(result).toEqual([rootNode, parentNode, childNode]); + expect(cache.getNode).toHaveBeenCalledTimes(3); + expect(cache.getNode).toHaveBeenNthCalledWith(1, 'volumeId~childNodeId'); + expect(cache.getNode).toHaveBeenNthCalledWith(2, 'volumeId~parentNodeId'); + expect(cache.getNode).toHaveBeenNthCalledWith(3, 'volumeId~rootNodeId'); + }); + + it('should reject when node hierarchy contains a loop', async () => { + const nodeA = { + uid: 'volumeId~nodeA', + parentUid: 'volumeId~nodeB', + isStale: false, + } as DecryptedNode; + const nodeB = { + uid: 'volumeId~nodeB', + parentUid: 'volumeId~nodeA', + isStale: false, + } as DecryptedNode; + const nodes: Record = { + 'volumeId~nodeA': nodeA, + 'volumeId~nodeB': nodeB, + }; + cache.getNode = jest.fn((uid: string) => { + const node = nodes[uid]; + if (!node) { + return Promise.reject(new Error('Entity not found')); + } + return Promise.resolve(node); + }); + + await expect(access.getNodeHierarchy('volumeId~nodeA')).rejects.toThrow( + 'Node hierarchy loop detected: volumeId~nodeA', + ); + expect(cache.getNode).toHaveBeenCalledTimes(2); + expect(cache.getNode).toHaveBeenNthCalledWith(1, 'volumeId~nodeA'); + expect(cache.getNode).toHaveBeenNthCalledWith(2, 'volumeId~nodeB'); + }); + }); + describe('iterate methods', () => { beforeEach(() => { cryptoCache.getNodeKeys = jest diff --git a/js/sdk/src/internal/nodes/nodesAccess.ts b/js/sdk/src/internal/nodes/nodesAccess.ts index 77cefbe3..9d0b6e74 100644 --- a/js/sdk/src/internal/nodes/nodesAccess.ts +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -101,6 +101,19 @@ export abstract class NodesAccessBase< return node; } + async getNodeHierarchy(nodeUid: string, visitedNodeUids: string[] = []): Promise { + if (visitedNodeUids.includes(nodeUid)) { + throw new Error(`Node hierarchy loop detected: ${nodeUid}`); + } + + const node = await this.getNode(nodeUid); + if (!node.parentUid) { + return [node]; + } + const parents = await this.getNodeHierarchy(node.parentUid, [...visitedNodeUids, nodeUid]); + return [...parents, node]; + } + async *iterateFolderChildrenNodeUids( parentNodeUid: string, filterOptions?: FilterOptions, @@ -522,8 +535,8 @@ export abstract class NodesAccessBase< } private async getRootNode(nodeUid: string): Promise { - const node = await this.getNode(nodeUid); - return node.parentUid ? this.getRootNode(node.parentUid) : node; + const hierarchy = await this.getNodeHierarchy(nodeUid); + return hierarchy[0]; } } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 55339a7b..a1dbd1ce 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -437,6 +437,21 @@ export class ProtonDriveClient { return convertInternalNodePromise(this.nodes.access.getNode(getUid(nodeUid))); } + /** + * Get the node hierarchy for the given node. + * + * The hierarchy is returned as a list of nodes. The first node is the root + * node, the last node is the given node. + * + * @param nodeUid - Node entity or its UID string. + * @returns The list of nodes from root to the given node. + */ + async getNodeHierarchy(nodeUid: NodeOrUid): Promise { + this.logger.info(`Getting node hierarchy for ${getUid(nodeUid)}`); + const hierarchy = await this.nodes.access.getNodeHierarchy(getUid(nodeUid)); + return hierarchy.map(convertInternalNode); + } + /** * Rename the node. * From bb49128b0452af3444a414809d6247ef5624c018 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 07:28:43 +0200 Subject: [PATCH 774/791] Use single type hierarchy for nodes --- .../InteropConversionExtensions.cs | 261 ++++++++++++ .../InteropProtonDriveClient.cs | 379 +----------------- .../InteropProtonPhotosClient.cs | 45 +-- .../src/Proton.Drive.Sdk/BatchLoaderBase.cs | 30 +- .../Caching/CachedNodeInfo.cs | 6 +- .../Caching/DriveEntityCache.cs | 5 +- .../Caching/DriveSecretCache.cs | 23 +- .../Caching/IDriveSecretCache.cs | 14 +- .../Proton.Drive.Sdk/Caching/IEntityCache.cs | 8 +- .../Nodes/DegradedFileMetadata.cs | 9 - .../Nodes/DegradedFileNode.cs | 10 - .../Nodes/DegradedFileSecrets.cs | 8 - .../Nodes/DegradedFolderMetadata.cs | 9 - .../Nodes/DegradedFolderNode.cs | 21 - .../Nodes/DegradedFolderSecrets.cs | 6 - .../Proton.Drive.Sdk/Nodes/DegradedNode.cs | 31 -- .../Nodes/DegradedNodeAndSecrets.cs | 39 -- .../Nodes/DegradedNodeMetadata.cs | 52 --- .../Nodes/DegradedNodeSecrets.cs | 10 - .../Nodes/DegradedPhotoNode.cs | 8 - .../Nodes/DegradedPhotoNodeMetadata.cs | 3 - .../Nodes/DegradedRevision.cs | 18 - .../Nodes/Download/PhotosFileDownloader.cs | 2 +- .../Nodes/DtoToMetadataConverter.cs | 99 +++-- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 78 +--- .../src/Proton.Drive.Sdk/Nodes/FileSecrets.cs | 2 +- .../Nodes/FolderChildrenBatchLoader.cs | 21 +- .../Nodes/FolderOperations.cs | 35 +- .../Nodes/FolderProvisionError.cs | 7 - .../Proton.Drive.Sdk/Nodes/FolderSecrets.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 4 +- .../Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs | 20 +- .../Nodes/NodeMetadataResultExtensions.cs | 100 +---- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 206 ++++------ .../Nodes/NodeResultExtensions.cs | 31 +- .../src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs | 6 +- .../Nodes/PhotosNodeOperations.cs | 4 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs | 2 +- .../Nodes/RevisionOperations.cs | 8 +- .../Nodes/TraversalOperations.cs | 28 +- .../Nodes/Upload/FileUploader.cs | 4 +- .../Nodes/Upload/NewFileDraftProvider.cs | 28 +- .../Nodes/Upload/NewRevisionDraftProvider.cs | 12 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 9 +- .../Proton.Drive.Sdk/ProtonPhotosClient.cs | 7 +- .../DriveEntitiesSerializerContext.cs | 5 +- .../DriveSecretsSerializerContext.cs | 9 +- .../Shares/ShareOperations.cs | 7 +- .../Telemetry/TelemetryEventFactory.cs | 8 +- .../Telemetry/TelemetryRecorder.cs | 17 +- .../Volumes/VolumeOperations.cs | 59 +-- .../Volumes/VolumeTrashBatchLoader.cs | 25 +- cs/sdk/src/protos/proton.drive.sdk.proto | 82 +--- .../me/proton/drive/sdk/ProtonDriveClient.kt | 4 +- .../me/proton/drive/sdk/ProtonSdkClient.kt | 6 +- .../converter/FolderChildrenListConverter.kt | 11 - .../sdk/converter/FolderNodeConverter.kt | 11 - .../drive/sdk/converter/NodeConverter.kt | 11 + .../sdk/converter/NodeResultConverter.kt | 11 - .../converter/PhotosTimelineListConverter.kt | 11 - .../drive/sdk/entity/DegradedFileNode.kt | 18 - .../drive/sdk/entity/DegradedFolderNode.kt | 15 - .../proton/drive/sdk/entity/DegradedNode.kt | 15 - .../drive/sdk/entity/DegradedRevision.kt | 17 - .../me/proton/drive/sdk/entity/FileNode.kt | 3 +- .../proton/drive/sdk/entity/FileRevision.kt | 2 +- .../me/proton/drive/sdk/entity/FolderNode.kt | 4 +- .../kotlin/me/proton/drive/sdk/entity/Node.kt | 3 +- .../me/proton/drive/sdk/entity/NodeResult.kt | 6 - .../drive/sdk/extension/DegradedFileNode.kt | 24 -- .../drive/sdk/extension/DegradedFolderNode.kt | 20 - .../drive/sdk/extension/DegradedRevision.kt | 23 -- .../me/proton/drive/sdk/extension/FileNode.kt | 3 +- .../drive/sdk/extension/FolderChildrenList.kt | 7 - .../proton/drive/sdk/extension/FolderNode.kt | 5 +- .../sdk/extension/{NodeResult.kt => Node.kt} | 29 +- .../drive/sdk/extension/PhotosTimelineList.kt | 3 - .../drive/sdk/extension/ProtonDriveSdkNode.kt | 17 + .../sdk/extension/ProtonDriveSdkNodeResult.kt | 31 -- .../me/proton/drive/sdk/extension/Revision.kt | 3 +- .../drive/sdk/extension/StringResult.kt | 5 +- .../drive/sdk/extension/TrashChildrenList.kt | 7 - .../sdk/internal/InteropProtonDriveClient.kt | 12 +- .../sdk/internal/InteropProtonPhotosClient.kt | 6 +- .../sdk/internal/JniProtonDriveClient.kt | 28 +- .../sdk/internal/JniProtonPhotosClient.kt | 14 +- .../ProtonDriveClient/ProtonDriveClient.swift | 5 +- .../Sources/Plumbing/PublicTypes.swift | 50 ++- .../Sources/Plumbing/SDKRequestHandler.swift | 15 +- 89 files changed, 795 insertions(+), 1582 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileMetadata.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileSecrets.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderMetadata.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderSecrets.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeMetadata.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeSecrets.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNodeMetadata.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedRevision.cs delete mode 100644 cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderProvisionError.cs delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderChildrenListConverter.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderNodeConverter.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeConverter.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultConverter.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/PhotosTimelineListConverter.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResult.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderChildrenList.kt rename kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/{NodeResult.kt => Node.kt} (62%) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNode.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNodeResult.kt delete mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashChildrenList.kt diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs new file mode 100644 index 00000000..9ba82aba --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs @@ -0,0 +1,261 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropConversionExtensions +{ + extension(Nodes.Node node) + { + public Node ToInterop() + { + var result = new Node(); + + switch (node) + { + case Nodes.FolderNode folderNode: + result.Folder = Nodes.Node.ToInterop(folderNode); + break; + case Nodes.FileNode fileNode: + result.File = Nodes.Node.ToInterop(fileNode); + break; + } + + return result; + } + + private static FolderNode ToInterop(Nodes.FolderNode folderNode) + { + var folderNodeProto = new FolderNode + { + Uid = folderNode.Uid.ToString(), + TreeEventScopeId = folderNode.TreeEventScopeId, + Name = folderNode.Name.ToInterop(), + CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = folderNode.NameAuthor.ToInterop(), + Author = folderNode.Author.ToInterop(), + OwnedBy = folderNode.OwnedBy.ToInterop(), + }; + + if (folderNode.ParentUid != null) + { + folderNodeProto.ParentUid = folderNode.ParentUid.ToString(); + } + + folderNodeProto.Errors.AddRange(folderNode.Errors.Select(ToInterop)); + + return folderNodeProto; + } + + private static FileNode ToInterop(Nodes.FileNode fileNode) + { + var fileNodeProto = new FileNode + { + Uid = fileNode.Uid.ToString(), + TreeEventScopeId = fileNode.TreeEventScopeId, + Name = fileNode.Name.ToInterop(), + MediaType = fileNode.MediaType, + CreationTime = fileNode.CreationTime.ToUniversalTime().ToTimestamp(), + TrashTime = fileNode.TrashTime?.ToUniversalTime().ToTimestamp(), + NameAuthor = fileNode.NameAuthor.ToInterop(), + Author = fileNode.Author.ToInterop(), + TotalSizeOnCloudStorage = fileNode.TotalSizeOnCloudStorage, + OwnedBy = fileNode.OwnedBy.ToInterop(), + }; + + if (fileNode.ParentUid != null) + { + fileNodeProto.ParentUid = fileNode.ParentUid.ToString(); + } + + fileNodeProto.ActiveRevision = fileNode.ActiveRevision.ToInterop(); + + fileNodeProto.Errors.AddRange(fileNode.Errors.Select(ToInterop)); + + return fileNodeProto; + } + } + + extension(ProtonDriveError error) + { + public DriveError ToInterop() + { + var driveError = new DriveError + { + InnerError = error.InnerError?.ToInterop(), + }; + + if (error.Message != null) + { + driveError.Message = error.Message; + } + + return driveError; + } + } + + extension(IReadOnlyDictionary> results) + { + public NodeResultListResponse ToInterop() + { + return new NodeResultListResponse + { + Results = + { + results.Select(pair => + { + var result = new NodeResultPair + { + NodeUid = pair.Key.ToString(), + }; + + if (pair.Value.TryGetError(out var exception)) + { + result.Error = exception.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes); + } + + return result; + }), + }, + }; + } + } + + extension(Revision revision) + { + public FileRevision ToInterop() + { + var protoRevision = new FileRevision + { + Uid = revision.Uid.ToString(), + CreationTime = revision.CreationTime.ToUniversalTime().ToTimestamp(), + SizeOnCloudStorage = revision.SizeOnCloudStorage, + ClaimedSize = revision.ClaimedSize ?? 0, + ClaimedModificationTime = revision.ClaimedModificationTime?.ToUniversalTime().ToTimestamp(), + }; + + if (revision.ClaimedDigests is { } claimedDigests) + { + protoRevision.ClaimedDigests = new FileContentDigests + { + Sha1Verified = claimedDigests.Sha1Verified, + }; + + if (claimedDigests.Sha1 is { } sha1) + { + protoRevision.ClaimedDigests.Sha1 = ByteString.CopyFrom(sha1.Span); + } + } + + protoRevision.Thumbnails.AddRange( + revision.Thumbnails.Select(t => new ThumbnailHeader + { + Id = t.Id, + Type = (ThumbnailType)(int)t.Type, + })); + + if (revision.AdditionalClaimedMetadata is not null) + { + protoRevision.AdditionalClaimedMetadata.AddRange( + revision.AdditionalClaimedMetadata.Select(m => new AdditionalMetadataProperty + { + Name = m.Name, + Utf8JsonValue = ByteString.CopyFromUtf8(m.Value.ToString()), + })); + } + + if (revision.ContentAuthor.HasValue) + { + protoRevision.ContentAuthor = revision.ContentAuthor.Value.ToInterop(); + } + + return protoRevision; + } + } + + extension(Result result) + { + public AuthorResult ToInterop() + { + var authorResult = new AuthorResult(); + + if (result.TryGetValueElseError(out var author, out var error)) + { + var authorResultValue = new Author(); + if (authorResultValue.EmailAddress != null) + { + authorResultValue.EmailAddress = author.EmailAddress; + } + + authorResult.Value = authorResultValue; + } + else + { + var claimedAuthor = new Author(); + if (error.ClaimedAuthor.EmailAddress != null) + { + claimedAuthor.EmailAddress = error.ClaimedAuthor.EmailAddress; + } + + authorResult.Error = new SignatureVerificationError + { + ClaimedAuthor = claimedAuthor, + }; + + if (error.Message != null) + { + // TODO change message to be a DriveError + authorResult.Error.Message = error.FlattenMessage(); + } + } + + return authorResult; + } + } + + extension(Nodes.OwnedBy? ownedBy) + { + public OwnedBy ToInterop() + { + if (ownedBy is null) + { + return new OwnedBy(); + } + + var result = new OwnedBy(); + if (ownedBy.Email != null) + { + result.Email = ownedBy.Email; + } + + if (ownedBy.Organization != null) + { + result.Organization = ownedBy.Organization; + } + + return result; + } + } + + extension(Result result) + { + public StringResult ToInterop() + { + var stringResult = new StringResult(); + if (result.TryGetValueElseError(out var value, out var error)) + { + stringResult.Value = value; + } + else + { + stringResult.Error = error.ToInterop(); + } + + return stringResult; + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs index 12eeaf41..3e3d90c0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -96,18 +96,7 @@ public static async ValueTask HandleCreateFolderAsync(DriveClientCreat request.LastModificationTime?.ToDateTimeFixed(), cancellationToken).ConfigureAwait(false); - return new FolderNode - { - Uid = createdFolder.Uid.ToString(), - ParentUid = createdFolder.ParentUid.ToString(), - TreeEventScopeId = createdFolder.TreeEventScopeId, - Name = createdFolder.Name, - CreationTime = createdFolder.CreationTime.ToUniversalTime().ToTimestamp(), - TrashTime = createdFolder.TrashTime?.ToUniversalTime().ToTimestamp(), - NameAuthor = ParseAuthorResult(createdFolder.NameAuthor), - Author = ParseAuthorResult(createdFolder.Author), - OwnedBy = MapOwnedByToProto(createdFolder.OwnedBy), - }; + return createdFolder.ToInterop(); } public static async ValueTask HandleGetFileUploaderAsync(DriveClientGetFileUploaderRequest request) @@ -222,13 +211,14 @@ public static async ValueTask HandleGetAvailableNameAsync(DriveClientG await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false)) { var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() }; + if (x.Result.TryGetValueElseError(out var data, out var error)) { thumbnail.Data = ByteString.CopyFrom(data.Span); } else { - thumbnail.Error = ConvertToDriveError(error); + thumbnail.Error = error.ToInterop(); } yieldFunction.InvokeWithMessage(bindingsHandle, thumbnail); @@ -246,7 +236,7 @@ public static async ValueTask HandleGetAvailableNameAsync(DriveClientG await foreach (var x in client.EnumerateFolderChildrenAsync(NodeUid.Parse(request.FolderUid), cancellationToken).ConfigureAwait(false)) { - yieldFunction.InvokeWithMessage(bindingsHandle, ConvertToNodeResult(x)); + yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop()); } return null; @@ -259,24 +249,7 @@ public static async ValueTask HandleGetMyFilesFolderAsync(DriveClientG var folderNode = await client.GetMyFilesFolderAsync(cancellationToken).ConfigureAwait(false); - var folderNodeProto = new FolderNode - { - Uid = folderNode.Uid.ToString(), - TreeEventScopeId = folderNode.TreeEventScopeId, - Name = folderNode.Name, - CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), - TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), - NameAuthor = ParseAuthorResult(folderNode.NameAuthor), - Author = ParseAuthorResult(folderNode.Author), - OwnedBy = MapOwnedByToProto(folderNode.OwnedBy), - }; - - if (folderNode.ParentUid != null) - { - folderNodeProto.ParentUid = folderNode.ParentUid.ToString(); - } - - return folderNodeProto; + return folderNode.ToInterop(); } public static async ValueTask HandleGetFileDownloaderAsync(DriveClientGetFileDownloaderRequest request) @@ -324,7 +297,7 @@ public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNo request.NodeUids.Select(NodeUid.Parse), cancellationToken).ConfigureAwait(false); - return ConvertToNodeResultListResponse(results); + return results.ToInterop(); } public static async ValueTask HandleDeleteNodesAsync(DriveClientDeleteNodesRequest request) @@ -337,7 +310,7 @@ public static async ValueTask HandleDeleteNodesAsync(DriveClientDelete request.NodeUids.Select(NodeUid.Parse), cancellationToken).ConfigureAwait(false); - return ConvertToNodeResultListResponse(results); + return results.ToInterop(); } public static async ValueTask HandleRestoreNodesAsync(DriveClientRestoreNodesRequest request) @@ -350,7 +323,7 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto request.NodeUids.Select(NodeUid.Parse), cancellationToken).ConfigureAwait(false); - return ConvertToNodeResultListResponse(results); + return results.ToInterop(); } public static async ValueTask HandleEnumerateTrashAsync(DriveClientEnumerateTrashRequest request, nint bindingsHandle) @@ -362,7 +335,7 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto await foreach (var x in client.EnumerateTrashAsync(cancellationToken).ConfigureAwait(false)) { - yieldFunction.InvokeWithMessage(bindingsHandle, ConvertToNodeResult(x)); + yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop()); } return null; @@ -373,16 +346,11 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); - var nodeResult = await client.GetNodeAsync( + var node = await client.GetNodeAsync( NodeUid.Parse(request.NodeUid), cancellationToken).ConfigureAwait(false); - if (nodeResult == null) - { - return null; - } - - return ConvertToNodeResult(nodeResult.Value); + return node?.ToInterop(); } public static async ValueTask HandleEmptyTrashAsync(DriveClientEmptyTrashRequest request) @@ -402,329 +370,4 @@ public static async ValueTask HandleRestoreNodesAsync(DriveClientResto return null; } - - public static NodeResult ConvertToNodeResult(Result result) - { - var nodeResult = new NodeResult(); - - if (result.TryGetValueElseError(out var node, out var degradedNode)) - { - nodeResult.Value = ConvertToNode(node); - } - else - { - nodeResult.Error = ConvertToDegradedNode(degradedNode); - } - - return nodeResult; - } - - public static AuthorResult ParseAuthorResult(Result result) - { - var authorResult = new AuthorResult(); - - if (result.TryGetValueElseError(out var author, out var error)) - { - var authorResultValue = new Author(); - if (authorResultValue.EmailAddress != null) - { - authorResultValue.EmailAddress = author.EmailAddress; - } - - authorResult.Value = authorResultValue; - } - else - { - var claimedAuthor = new Author(); - if (error.ClaimedAuthor.EmailAddress != null) - { - claimedAuthor.EmailAddress = error.ClaimedAuthor.EmailAddress; - } - - authorResult.Error = new SignatureVerificationError - { - ClaimedAuthor = claimedAuthor, - }; - - if (error.Message != null) - { - // TODO change message to be a DriveError - authorResult.Error.Message = error.FlattenMessage(); - } - } - - return authorResult; - } - - internal static DriveError ConvertToDriveError(ProtonDriveError error) - { - var driveError = new DriveError - { - InnerError = error.InnerError != null ? ConvertToDriveError(error.InnerError) : null, - }; - - if (error.Message != null) - { - driveError.Message = error.Message; - } - - return driveError; - } - - private static NodeResultListResponse ConvertToNodeResultListResponse(IReadOnlyDictionary> results) - { - return new NodeResultListResponse - { - Results = - { - results.Select(pair => - { - var result = new NodeResultPair - { - NodeUid = pair.Key.ToString(), - }; - - if (pair.Value.TryGetError(out var exception)) - { - result.Error = exception.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes); - } - - return result; - }), - }, - }; - } - - private static OwnedBy MapOwnedByToProto(Nodes.OwnedBy? ownedBy) - { - if (ownedBy is null) - { - return new OwnedBy(); - } - - var result = new OwnedBy(); - if (ownedBy.Email != null) - { - result.Email = ownedBy.Email; - } - - if (ownedBy.Organization != null) - { - result.Organization = ownedBy.Organization; - } - - return result; - } - - private static Node ConvertToNode(Nodes.Node node) - { - var result = new Node(); - - switch (node) - { - case Nodes.FolderNode folderNode: - result.Folder = new FolderNode - { - Uid = folderNode.Uid.ToString(), - TreeEventScopeId = folderNode.TreeEventScopeId, - Name = folderNode.Name, - CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), - TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), - NameAuthor = ParseAuthorResult(folderNode.NameAuthor), - Author = ParseAuthorResult(folderNode.Author), - OwnedBy = MapOwnedByToProto(folderNode.OwnedBy), - }; - - if (folderNode.ParentUid != null) - { - result.Folder.ParentUid = folderNode.ParentUid.ToString(); - } - - break; - - case Nodes.FileNode fileNode: - var fileNodeProto = new FileNode - { - Uid = fileNode.Uid.ToString(), - TreeEventScopeId = fileNode.TreeEventScopeId, - Name = fileNode.Name, - MediaType = fileNode.MediaType, - CreationTime = fileNode.CreationTime.ToUniversalTime().ToTimestamp(), - TrashTime = fileNode.TrashTime?.ToUniversalTime().ToTimestamp(), - NameAuthor = ParseAuthorResult(fileNode.NameAuthor), - Author = ParseAuthorResult(fileNode.Author), - TotalSizeOnCloudStorage = fileNode.TotalSizeOnCloudStorage, - OwnedBy = MapOwnedByToProto(fileNode.OwnedBy), - ActiveRevision = new FileRevision - { - Uid = fileNode.ActiveRevision.Uid.ToString(), - CreationTime = fileNode.ActiveRevision.CreationTime.ToUniversalTime().ToTimestamp(), - SizeOnCloudStorage = fileNode.ActiveRevision.SizeOnCloudStorage, - ClaimedSize = fileNode.ActiveRevision.ClaimedSize ?? 0, - ClaimedModificationTime = fileNode.ActiveRevision.ClaimedModificationTime?.ToUniversalTime().ToTimestamp(), - ClaimedDigests = new FileContentDigests(), - }, - }; - - if (fileNode.ParentUid != null) - { - fileNodeProto.ParentUid = fileNode.ParentUid.ToString(); - } - - if (fileNode.ActiveRevision.ClaimedDigests.Sha1.HasValue) - { - fileNodeProto.ActiveRevision.ClaimedDigests.Sha1 = ByteString.CopyFrom(fileNode.ActiveRevision.ClaimedDigests.Sha1.Value.Span); - } - - fileNodeProto.ActiveRevision.ClaimedDigests.Sha1Verified = fileNode.ActiveRevision.ClaimedDigests.Sha1Verified; - - fileNodeProto.ActiveRevision.Thumbnails.AddRange( - fileNode.ActiveRevision.Thumbnails.Select(t => new ThumbnailHeader - { - Id = t.Id, - Type = (ThumbnailType)(int)t.Type, - })); - - if (fileNode.ActiveRevision.AdditionalClaimedMetadata is not null) - { - fileNodeProto.ActiveRevision.AdditionalClaimedMetadata.AddRange( - fileNode.ActiveRevision.AdditionalClaimedMetadata.Select(m => new AdditionalMetadataProperty - { - Name = m.Name, - Utf8JsonValue = ByteString.CopyFromUtf8(m.Value.ToString()), - })); - } - - if (fileNode.ActiveRevision.ContentAuthor.HasValue) - { - fileNodeProto.ActiveRevision.ContentAuthor = ParseAuthorResult(fileNode.ActiveRevision.ContentAuthor.Value); - } - - result.File = fileNodeProto; - break; - } - - return result; - } - - private static DegradedNode ConvertToDegradedNode(Nodes.DegradedNode degradedNode) - { - var result = new DegradedNode(); - - switch (degradedNode) - { - case Nodes.DegradedFolderNode degradedFolderNode: - var degradedFolder = new DegradedFolderNode - { - Uid = degradedFolderNode.Uid.ToString(), - TreeEventScopeId = degradedFolderNode.TreeEventScopeId, - Name = ConvertStringToStringResult(degradedFolderNode.Name), - CreationTime = degradedFolderNode.CreationTime.ToUniversalTime().ToTimestamp(), - TrashTime = degradedFolderNode.TrashTime?.ToUniversalTime().ToTimestamp(), - NameAuthor = ParseAuthorResult(degradedFolderNode.NameAuthor), - Author = ParseAuthorResult(degradedFolderNode.Author), - OwnedBy = MapOwnedByToProto(degradedFolderNode.OwnedBy), - }; - - if (degradedFolderNode.ParentUid != null) - { - degradedFolder.ParentUid = degradedFolderNode.ParentUid.ToString(); - } - - degradedFolder.Errors.AddRange(degradedFolderNode.Errors.Select(ConvertToDriveError)); - result.Folder = degradedFolder; - break; - - case Nodes.DegradedFileNode degradedFileNode: - var degradedFile = new DegradedFileNode - { - Uid = degradedFileNode.Uid.ToString(), - TreeEventScopeId = degradedFileNode.TreeEventScopeId, - Name = ConvertStringToStringResult(degradedFileNode.Name), - MediaType = degradedFileNode.MediaType, - CreationTime = degradedFileNode.CreationTime.ToUniversalTime().ToTimestamp(), - TrashTime = degradedFileNode.TrashTime?.ToUniversalTime().ToTimestamp(), - NameAuthor = ParseAuthorResult(degradedFileNode.NameAuthor), - Author = ParseAuthorResult(degradedFileNode.Author), - TotalStorageQuotaUsage = degradedFileNode.TotalStorageQuotaUsage, - OwnedBy = MapOwnedByToProto(degradedFileNode.OwnedBy), - }; - - if (degradedFileNode.ParentUid != null) - { - degradedFile.ParentUid = degradedFileNode.ParentUid.ToString(); - } - - if (degradedFileNode.ActiveRevision is not null) - { - degradedFile.ActiveRevision = new DegradedRevision - { - Uid = degradedFileNode.ActiveRevision.Uid.ToString(), - CreationTime = degradedFileNode.ActiveRevision.CreationTime.ToUniversalTime().ToTimestamp(), - SizeOnCloudStorage = degradedFileNode.ActiveRevision.SizeOnCloudStorage, - ClaimedSize = degradedFileNode.ActiveRevision.ClaimedSize ?? 0, - ClaimedModificationTime = degradedFileNode.ActiveRevision.ClaimedModificationTime?.ToUniversalTime().ToTimestamp(), - CanDecrypt = degradedFileNode.ActiveRevision.CanDecrypt, - }; - - if (degradedFileNode.ActiveRevision.ClaimedDigests.HasValue) - { - degradedFile.ActiveRevision.ClaimedDigests = new FileContentDigests - { - Sha1Verified = degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1Verified, - }; - if (degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1.HasValue) - { - degradedFile.ActiveRevision.ClaimedDigests.Sha1 = - ByteString.CopyFrom(degradedFileNode.ActiveRevision.ClaimedDigests.Value.Sha1.Value.Span); - } - } - - degradedFile.ActiveRevision.Thumbnails.AddRange( - degradedFileNode.ActiveRevision.Thumbnails.Select(t => new ThumbnailHeader - { - Id = t.Id, - Type = (ThumbnailType)(int)t.Type, - })); - - if (degradedFileNode.ActiveRevision.AdditionalClaimedMetadata is not null) - { - degradedFile.ActiveRevision.AdditionalClaimedMetadata.AddRange( - degradedFileNode.ActiveRevision.AdditionalClaimedMetadata.Select(m => new AdditionalMetadataProperty - { - Name = m.Name, - Utf8JsonValue = ByteString.CopyFromUtf8(m.Value.ToString()), - })); - } - - if (degradedFileNode.ActiveRevision.ContentAuthor.HasValue) - { - degradedFile.ActiveRevision.ContentAuthor = ParseAuthorResult(degradedFileNode.ActiveRevision.ContentAuthor.Value); - } - - degradedFile.ActiveRevision.Errors.AddRange(degradedFileNode.ActiveRevision.Errors.Select(ConvertToDriveError)); - } - - degradedFile.Errors.AddRange(degradedFileNode.Errors.Select(ConvertToDriveError)); - result.File = degradedFile; - break; - } - - return result; - } - - private static StringResult ConvertStringToStringResult(Result result) - { - var stringResult = new StringResult(); - if (result.TryGetValueElseError(out var value, out var error)) - { - stringResult.Value = value; - } - else - { - stringResult.Error = ConvertToDriveError(error); - } - - return stringResult; - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs index 5a44c098..e58ed7a9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -87,7 +87,7 @@ public static async ValueTask HandleTrashNodesAsync(DrivePhotosClientT request.NodeUids.Select(NodeUid.Parse), cancellationToken).ConfigureAwait(false); - return ConvertToNodeResultListResponse(results); + return results.ToInterop(); } public static async ValueTask HandleDeleteNodesAsync(DrivePhotosClientDeleteNodesRequest request) @@ -100,7 +100,7 @@ public static async ValueTask HandleDeleteNodesAsync(DrivePhotosClient request.NodeUids.Select(NodeUid.Parse), cancellationToken).ConfigureAwait(false); - return ConvertToNodeResultListResponse(results); + return results.ToInterop(); } public static async ValueTask HandleRestoreNodesAsync(DrivePhotosClientRestoreNodesRequest request) @@ -113,7 +113,7 @@ public static async ValueTask HandleRestoreNodesAsync(DrivePhotosClien request.NodeUids.Select(NodeUid.Parse), cancellationToken).ConfigureAwait(false); - return ConvertToNodeResultListResponse(results); + return results.ToInterop(); } public static async ValueTask HandleEnumerateTrashAsync(DrivePhotosClientEnumerateTrashRequest request, nint bindingsHandle) @@ -125,7 +125,7 @@ public static async ValueTask HandleRestoreNodesAsync(DrivePhotosClien await foreach (var x in client.EnumerateTrashAsync(cancellationToken).ConfigureAwait(false)) { - yieldFunction.InvokeWithMessage(bindingsHandle, InteropProtonDriveClient.ConvertToNodeResult(x)); + yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop()); } return null; @@ -154,16 +154,9 @@ public static async ValueTask HandleRestoreNodesAsync(DrivePhotosClien var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); var client = Interop.GetFromHandle(request.ClientHandle); - var nodeResult = await client.GetNodeAsync( - NodeUid.Parse(request.NodeUid), - cancellationToken).ConfigureAwait(false); + var node = await client.GetNodeAsync(NodeUid.Parse(request.NodeUid), cancellationToken).ConfigureAwait(false); - if (nodeResult == null) - { - return null; - } - - return InteropProtonDriveClient.ConvertToNodeResult(nodeResult.Value); + return node?.ToInterop(); } public static async ValueTask HandleEnumeratePhotosTimelineAsync(DrivePhotosClientEnumerateTimelineRequest request, nint bindingsHandle) @@ -228,7 +221,7 @@ public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhot } else { - thumbnail.Error = InteropProtonDriveClient.ConvertToDriveError(error); + thumbnail.Error = error.ToInterop(); } yieldFunction.InvokeWithMessage(bindingsHandle, thumbnail); @@ -306,28 +299,4 @@ static void GenerateSha1Action(string sha1) // TODO: Implement SHA1 generation callback } } - - private static NodeResultListResponse ConvertToNodeResultListResponse(IReadOnlyDictionary> results) - { - return new NodeResultListResponse - { - Results = - { - results.Select(pair => - { - var result = new NodeResultPair - { - NodeUid = pair.Key.ToString(), - }; - - if (pair.Value.TryGetError(out var exception)) - { - result.Error = exception.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes); - } - - return result; - }), - }, - }; - } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs b/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs index cf564a21..4dfbad19 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Runtime.CompilerServices; namespace Proton.Drive.Sdk; @@ -19,16 +20,19 @@ protected BatchLoaderBase(int batchSize = DefaultBatchSize) /// Queues an item for loading. If the queue size reaches the batch size, calls the load function, clears the queue, and returns the loaded items. /// Otherwise, returns an empty enumerable. ///
- public async ValueTask> QueueAndTryLoadBatchAsync(TId id, CancellationToken cancellationToken) + public async IAsyncEnumerable QueueAndTryLoadBatchAsync(TId id, [EnumeratorCancellation] CancellationToken cancellationToken = default) { _queueWriter.Write(new ReadOnlySpan(ref id)); if (_queueWriter.FreeCapacity > 0) { - return []; + yield break; } - return await LoadQueuedBatchAsync(cancellationToken).ConfigureAwait(false); + await foreach (var value in EnumerateQueuedBatchAsync(cancellationToken).ConfigureAwait(false)) + { + yield return value; + } } /// @@ -38,24 +42,28 @@ public async ValueTask> QueueAndTryLoadBatchAsync(TId id, Ca /// /// Call this after no more items are expected to be queued. /// - public async ValueTask> LoadRemainingAsync(CancellationToken cancellationToken) + public async IAsyncEnumerable LoadRemainingAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { if (_queueWriter.WrittenCount == 0) { - return []; + yield break; } - return await LoadQueuedBatchAsync(cancellationToken).ConfigureAwait(false); + await foreach (var value in EnumerateQueuedBatchAsync(cancellationToken).ConfigureAwait(false)) + { + yield return value; + } } - protected abstract ValueTask> LoadBatchAsync(ReadOnlyMemory ids, CancellationToken cancellationToken); + protected abstract IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, CancellationToken cancellationToken); - private async ValueTask> LoadQueuedBatchAsync(CancellationToken cancellationToken) + private async IAsyncEnumerable EnumerateQueuedBatchAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { - var result = await LoadBatchAsync(_queueWriter.WrittenMemory, cancellationToken).ConfigureAwait(false); + await foreach (var value in LoadBatchAsync(_queueWriter.WrittenMemory, cancellationToken).ConfigureAwait(false)) + { + yield return value; + } _queueWriter.Clear(); - - return result; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs index c8d6d842..2110a032 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs @@ -1,10 +1,6 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; -using Proton.Sdk; namespace Proton.Drive.Sdk.Caching; -internal readonly record struct CachedNodeInfo( - Result NodeProvisionResult, - ShareId? MembershipShareId, - ReadOnlyMemory NameHashDigest); +internal readonly record struct CachedNodeInfo(Node Node, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 1295fb2e..029d991c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -4,7 +4,6 @@ using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Shares; using Proton.Drive.Sdk.Volumes; -using Proton.Sdk; using Proton.Sdk.Caching; namespace Proton.Drive.Sdk.Caching; @@ -108,13 +107,13 @@ public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) public ValueTask SetNodeAsync( NodeUid nodeId, - Result nodeProvisionResult, + Node node, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest, CancellationToken cancellationToken) { var serializedValue = JsonSerializer.Serialize( - new CachedNodeInfo(nodeProvisionResult, membershipShareId, nameHashDigest), + new CachedNodeInfo(node, membershipShareId, nameHashDigest), DriveEntitiesSerializerContext.Default.CachedNodeInfo); return _repository.SetAsync(GetNodeCacheKey(nodeId), serializedValue, cancellationToken); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs index 196d23c5..d5ed825d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -3,7 +3,6 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Serialization; -using Proton.Sdk; using Proton.Sdk.Caching; using Proton.Sdk.Serialization; @@ -30,41 +29,35 @@ public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, Cance return exists ? shareKey : null; } - public ValueTask SetFolderSecretsAsync( - NodeUid nodeId, - Result secretsProvisionResult, - CancellationToken cancellationToken) + public ValueTask SetFolderSecretsAsync(NodeUid nodeId, FolderSecrets secrets, CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(secretsProvisionResult, DriveSecretsSerializerContext.Default.ResultFolderSecretsDegradedFolderSecrets); + var serializedValue = JsonSerializer.Serialize(secrets, DriveSecretsSerializerContext.Default.FolderSecrets); return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { var (exists, folderSecrets) = await _repository.TryGetDeserializedValueAsync( GetFolderSecretsCacheKey(nodeId), - DriveSecretsSerializerContext.Default.NullableResultFolderSecretsDegradedFolderSecrets, + DriveSecretsSerializerContext.Default.FolderSecrets, cancellationToken).ConfigureAwait(false); return exists ? folderSecrets : null; } - public ValueTask SetFileSecretsAsync( - NodeUid nodeId, - Result secretsProvisionResult, - CancellationToken cancellationToken) + public ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets secrets, CancellationToken cancellationToken) { - var serializedValue = JsonSerializer.Serialize(secretsProvisionResult, DriveSecretsSerializerContext.Default.ResultFileSecretsDegradedFileSecrets); + var serializedValue = JsonSerializer.Serialize(secrets, DriveSecretsSerializerContext.Default.FileSecrets); return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); } - public async ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + public async ValueTask TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) { var (exists, fileSecrets) = await _repository.TryGetDeserializedValueAsync( GetFileSecretsCacheKey(nodeId), - DriveSecretsSerializerContext.Default.NullableResultFileSecretsDegradedFileSecrets, + DriveSecretsSerializerContext.Default.FileSecrets, cancellationToken).ConfigureAwait(false); return exists ? fileSecrets : null; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs index 59542613..87855553 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -1,7 +1,6 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; -using Proton.Sdk; namespace Proton.Drive.Sdk.Caching; @@ -11,14 +10,13 @@ internal interface IDriveSecretCache ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken); - ValueTask SetFolderSecretsAsync( - NodeUid nodeId, - Result secretsProvisionResult, - CancellationToken cancellationToken); + ValueTask SetFolderSecretsAsync(NodeUid nodeId, FolderSecrets secrets, CancellationToken cancellationToken); - ValueTask?> TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); - ValueTask SetFileSecretsAsync(NodeUid nodeId, Result secretsProvisionResult, CancellationToken cancellationToken); + ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets secrets, CancellationToken cancellationToken); - ValueTask?> TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + ValueTask TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + + ValueTask ClearAsync(); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs index dbcdd206..3453567f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs @@ -1,17 +1,11 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; -using Proton.Sdk; namespace Proton.Drive.Sdk.Caching; internal interface IEntityCache { - ValueTask SetNodeAsync( - NodeUid nodeId, - Result nodeProvisionResult, - ShareId? membershipShareId, - ReadOnlyMemory nameHashDigest, - CancellationToken cancellationToken); + ValueTask SetNodeAsync(NodeUid nodeId, Node node, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest, CancellationToken cancellationToken); ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileMetadata.cs deleted file mode 100644 index c320efe7..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileMetadata.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Proton.Drive.Sdk.Api.Shares; - -namespace Proton.Drive.Sdk.Nodes; - -internal sealed record DegradedFileMetadata( - DegradedFileNode Node, - DegradedFileSecrets Secrets, - ShareId? MembershipShareId, - ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs deleted file mode 100644 index 20bc8c57..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileNode.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -public record DegradedFileNode : DegradedNode -{ - public required string MediaType { get; init; } - - public required DegradedRevision? ActiveRevision { get; init; } - - public required long TotalStorageQuotaUsage { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileSecrets.cs deleted file mode 100644 index 9953e114..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFileSecrets.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Proton.Cryptography.Pgp; - -namespace Proton.Drive.Sdk.Nodes; - -internal sealed class DegradedFileSecrets : DegradedNodeSecrets -{ - public required PgpSessionKey? ContentKey { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderMetadata.cs deleted file mode 100644 index bc7902a9..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderMetadata.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Proton.Drive.Sdk.Api.Shares; - -namespace Proton.Drive.Sdk.Nodes; - -internal sealed record DegradedFolderMetadata( - DegradedFolderNode Node, - DegradedFolderSecrets Secrets, - ShareId? MembershipShareId, - ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs deleted file mode 100644 index fb704eb3..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderNode.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Proton.Sdk; - -namespace Proton.Drive.Sdk.Nodes; - -public sealed record DegradedFolderNode : DegradedNode -{ - public FolderNode ToNode(string substituteName) - { - return new FolderNode - { - Uid = Uid, - ParentUid = ParentUid, - Name = Name.TryGetValue(out var name) ? name : substituteName, - NameAuthor = NameAuthor, - CreationTime = CreationTime, - TrashTime = TrashTime, - Author = Author, - OwnedBy = OwnedBy, - }; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderSecrets.cs deleted file mode 100644 index 901e46ca..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedFolderSecrets.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -internal sealed class DegradedFolderSecrets : DegradedNodeSecrets -{ - public required ReadOnlyMemory? HashKey { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs deleted file mode 100644 index 2632ae76..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNode.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json.Serialization; -using Proton.Sdk; - -namespace Proton.Drive.Sdk.Nodes; - -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(DegradedFolderNode), typeDiscriminator: "folder")] -[JsonDerivedType(typeof(DegradedFileNode), typeDiscriminator: "file")] -[JsonDerivedType(typeof(DegradedPhotoNode), typeDiscriminator: "photo")] -public abstract record DegradedNode -{ - public required NodeUid Uid { get; init; } - - public required NodeUid? ParentUid { get; init; } - - public string TreeEventScopeId => Uid.VolumeId.ToString(); - - public required Result Name { get; init; } - - public required Result NameAuthor { get; init; } - - public required DateTime CreationTime { get; init; } - - public DateTime? TrashTime { get; init; } - - public required Result Author { get; init; } - - public required OwnedBy OwnedBy { get; init; } - - public required IReadOnlyList Errors { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs deleted file mode 100644 index 51f5196a..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeAndSecrets.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Proton.Drive.Sdk.Nodes; - -internal sealed class DegradedNodeAndSecrets -{ - private readonly (DegradedFileNode Node, DegradedFileSecrets Secrets)? _fileAndSecrets; - private readonly (DegradedFolderNode Node, DegradedFolderSecrets Secrets)? _folderAndSecrets; - - public DegradedNodeAndSecrets(DegradedFileNode node, DegradedFileSecrets secrets) - { - _fileAndSecrets = (node, secrets); - } - - public DegradedNodeAndSecrets(DegradedFolderNode node, DegradedFolderSecrets secrets) - { - _folderAndSecrets = (node, secrets); - } - - public bool TryGetFileElseFolder( - [MaybeNullWhen(false)] out DegradedFileNode fileNode, - [MaybeNullWhen(false)] out DegradedFileSecrets fileSecrets, - [MaybeNullWhen(true)] out DegradedFolderNode folderNode, - [MaybeNullWhen(true)] out DegradedFolderSecrets folderSecrets) - { - if (_fileAndSecrets is null) - { - (folderNode, folderSecrets) = _folderAndSecrets!.Value; - fileNode = null; - fileSecrets = null; - return false; - } - - (fileNode, fileSecrets) = _fileAndSecrets.Value; - folderNode = null; - folderSecrets = null; - return true; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeMetadata.cs deleted file mode 100644 index 09a16dc6..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeMetadata.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Proton.Drive.Sdk.Api.Shares; - -namespace Proton.Drive.Sdk.Nodes; - -internal sealed class DegradedNodeMetadata -{ - private readonly (DegradedFileNode Node, DegradedFileSecrets Secrets)? _fileAndSecrets; - private readonly (DegradedFolderNode Node, DegradedFolderSecrets Secrets)? _folderAndSecrets; - - public DegradedNodeMetadata(DegradedFileNode node, DegradedFileSecrets secrets, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest) - { - _fileAndSecrets = (node, secrets); - MembershipShareId = membershipShareId; - NameHashDigest = nameHashDigest; - } - - public DegradedNodeMetadata(DegradedFolderNode node, DegradedFolderSecrets secrets, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest) - { - _folderAndSecrets = (node, secrets); - MembershipShareId = membershipShareId; - NameHashDigest = nameHashDigest; - } - - public DegradedNode Node => _fileAndSecrets?.Node ?? (DegradedNode)_folderAndSecrets!.Value.Node; - public DegradedNodeSecrets Secrets => _fileAndSecrets?.Secrets ?? (DegradedNodeSecrets)_folderAndSecrets!.Value.Secrets; - public ShareId? MembershipShareId { get; } - public ReadOnlyMemory NameHashDigest { get; } - - public static DegradedNodeMetadata FromFile(DegradedFileMetadata m) => new(m.Node, m.Secrets, m.MembershipShareId, m.NameHashDigest); - public static DegradedNodeMetadata FromFolder(DegradedFolderMetadata m) => new(m.Node, m.Secrets, m.MembershipShareId, m.NameHashDigest); - - public bool TryGetFileElseFolder( - [MaybeNullWhen(false)] out DegradedFileNode fileNode, - [MaybeNullWhen(false)] out DegradedFileSecrets fileSecrets, - [MaybeNullWhen(true)] out DegradedFolderNode folderNode, - [MaybeNullWhen(true)] out DegradedFolderSecrets folderSecrets) - { - if (_fileAndSecrets is null) - { - (folderNode, folderSecrets) = _folderAndSecrets!.Value; - fileNode = null; - fileSecrets = null; - return false; - } - - (fileNode, fileSecrets) = _fileAndSecrets.Value; - folderNode = null; - folderSecrets = null; - return true; - } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeSecrets.cs deleted file mode 100644 index b643970b..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedNodeSecrets.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Proton.Cryptography.Pgp; - -namespace Proton.Drive.Sdk.Nodes; - -internal class DegradedNodeSecrets -{ - public required PgpPrivateKey? Key { get; init; } - public required PgpSessionKey? PassphraseSessionKey { get; init; } - public required PgpSessionKey? NameSessionKey { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs deleted file mode 100644 index 5608a4d4..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNode.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -public sealed record DegradedPhotoNode : DegradedFileNode -{ - public required DateTime CaptureTime { get; init; } - - public required IReadOnlyList AlbumUids { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNodeMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNodeMetadata.cs deleted file mode 100644 index 96160b46..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedPhotoNodeMetadata.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -internal sealed record DegradedPhotoNodeMetadata(DegradedPhotoNode Node, DegradedFileSecrets Secrets); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedRevision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedRevision.cs deleted file mode 100644 index 8cfe9009..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DegradedRevision.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Proton.Sdk; - -namespace Proton.Drive.Sdk.Nodes; - -public sealed record DegradedRevision -{ - public required RevisionUid Uid { get; init; } - public required DateTime CreationTime { get; init; } - public required long SizeOnCloudStorage { get; init; } - public long? ClaimedSize { get; init; } - public FileContentDigests? ClaimedDigests { get; init; } - public DateTime? ClaimedModificationTime { get; init; } - public required IReadOnlyList Thumbnails { get; init; } - public required IReadOnlyList? AdditionalClaimedMetadata { get; init; } - public Result? ContentAuthor { get; init; } - public bool CanDecrypt { get; init; } - public required IReadOnlyList Errors { get; init; } -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index 394b9552..50b9ba04 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -75,7 +75,7 @@ private async Task DownloadToStreamAsync( { var result = await _client.GetNodeAsync(_photoUid, cancellationToken).ConfigureAwait(false); - if (result is null || !result.Value.TryGetValueElseError(out var node, out _) || node is not FileNode fileNode) + if (result is not FileNode fileNode) { throw new NodeNotFoundException(_photoUid); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 81741877..3ac0e13c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -17,7 +17,7 @@ namespace Proton.Drive.Sdk.Nodes; internal static class DtoToMetadataConverter { - public static async Task> ConvertDtoToNodeMetadataAsync( + public static async Task ConvertDtoToNodeMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, @@ -43,7 +43,7 @@ public static async Task> ConvertDtoT cancellationToken).ConfigureAwait(false); } - public static async Task> ConvertDtoToNodeMetadataAsync( + public static async Task ConvertDtoToNodeMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, @@ -55,31 +55,28 @@ public static async Task> ConvertDtoT var nodeMetadata = linkType switch { LinkType.Folder => - (await ConvertDtoToFolderMetadataAsync( + NodeMetadata.FromFolder(await ConvertDtoToFolderMetadataAsync( client, volumeId, linkDetailsDto, parentKey, - cancellationToken).ConfigureAwait(false)) - .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), + cancellationToken).ConfigureAwait(false)), LinkType.File => - (await ConvertDtoToFileMetadataAsync( + NodeMetadata.FromFile(await ConvertDtoToFileMetadataAsync( client, volumeId, linkDetailsDto, parentKey, - cancellationToken).ConfigureAwait(false)) - .Convert(NodeMetadata.FromFile, DegradedNodeMetadata.FromFile), + cancellationToken).ConfigureAwait(false)), LinkType.Album => - (await ConvertDtoToAlbumMetadataAsync( + NodeMetadata.FromFolder(await ConvertDtoToAlbumMetadataAsync( client, volumeId, linkDetailsDto, parentKey, - cancellationToken).ConfigureAwait(false)) - .Convert(NodeMetadata.FromFolder, DegradedNodeMetadata.FromFolder), + cancellationToken).ConfigureAwait(false)), // FIXME: handle other existing node types, and determine a way for forward compatibility or degraded result in case a new node type is introduced _ => throw new NotSupportedException($"Link type {linkType} is not supported."), @@ -88,7 +85,7 @@ public static async Task> ConvertDtoT return nodeMetadata; } - public static async ValueTask> ConvertDtoToFolderMetadataAsync( + public static async ValueTask ConvertDtoToFolderMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, @@ -109,7 +106,7 @@ public static async ValueTask> Co cancellationToken).ConfigureAwait(false); } - public static async ValueTask> ConvertDtoToAlbumMetadataAsync( + public static async ValueTask ConvertDtoToAlbumMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, @@ -130,7 +127,7 @@ public static async ValueTask> Co cancellationToken).ConfigureAwait(false); } - public static async Task> ConvertDtoToFileMetadataAsync( + public static async Task ConvertDtoToFileMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, @@ -181,7 +178,7 @@ public static async Task> ConvertDtoT || !extendedAttributesIsValid || !contentKeyIsValid) { - var (degradedFileMetadata, failedDecryptionFields) = CreateDegradedFileMetadata( + var (partialFileMetadata, failedDecryptionFields) = CreatePartialFileMetadata( linkDetailsDto, decryptionResult, nameResult, @@ -196,18 +193,18 @@ public static async Task> ConvertDtoT nameSessionKey, membershipDto); - await client.Cache.Secrets.SetFileSecretsAsync(uid, degradedFileMetadata.Secrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFileSecretsAsync(uid, partialFileMetadata.Secrets, cancellationToken).ConfigureAwait(false); - await client.Cache.Entities.SetNodeAsync(uid, degradedFileMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) + await client.Cache.Entities.SetNodeAsync(uid, partialFileMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) .ConfigureAwait(false); await TelemetryRecorder.TryRecordDecryptionErrorAsync( client, - DegradedNodeMetadata.FromFile(degradedFileMetadata).Node, + partialFileMetadata.Node, failedDecryptionFields, cancellationToken).ConfigureAwait(false); - return degradedFileMetadata; + return partialFileMetadata; } var secrets = new FileSecrets @@ -260,6 +257,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( CaptureTime = linkDetailsDto.Photo.CaptureTime, AlbumUids = linkDetailsDto.Photo.AlbumInclusions.Select(a => new NodeUid(uid.VolumeId, a.Id)).ToList(), OwnedBy = ownedBy, + Errors = [], } : new FileNode { @@ -274,6 +272,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( ActiveRevision = activeRevision, TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, OwnedBy = ownedBy, + Errors = [], }; await client.Cache.Secrets.SetFileSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); @@ -283,7 +282,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( return new FileMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } - private static (DegradedFileMetadata Metadata, Dictionary FailedDecryptionFields) CreateDegradedFileMetadata( + private static (FileMetadata Metadata, Dictionary FailedDecryptionFields) CreatePartialFileMetadata( LinkDetailsDto linkDetailsDto, FileDecryptionResult decryptionResult, Result nameResult, @@ -299,11 +298,11 @@ private static (DegradedFileMetadata Metadata, Dictionary failedDecryptionFields = []; - List nodeKeyErrors = []; + List nodeErrors = []; if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) { - nodeKeyErrors.Add(passphraseError); + nodeErrors.Add(passphraseError); if (passphraseError is DecryptionError) { @@ -312,7 +311,7 @@ private static (DegradedFileMetadata Metadata, Dictionary(); if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) { - revisionErrors.Add(extendedAttributesError); + nodeErrors.Add(extendedAttributesError); if (extendedAttributesError is DecryptionError) { @@ -352,7 +350,7 @@ private static (DegradedFileMetadata Metadata, Dictionary decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), error => new SignatureVerificationError(decryptionResult.ContentAuthorshipClaim.Author, "Content key decryption failed", error)); - var degradedRevision = new DegradedRevision + var partialRevision = new Revision { Uid = new RevisionUid(uid, activeRevisionDto.Id), CreationTime = activeRevisionDto.CreationTime, @@ -363,13 +361,11 @@ private static (DegradedFileMetadata Metadata, Dictionary new NodeUid(uid.VolumeId, a.Id)).ToList(), OwnedBy = ownedBy, } - : new DegradedFileNode + : new FileNode { Uid = uid, ParentUid = parentUid, @@ -396,13 +392,13 @@ private static (DegradedFileMetadata Metadata, Dictionary (PgpPrivateKey?)x, _ => null), PassphraseSessionKey = decryptionResult.Link.Passphrase.Merge(x => (PgpSessionKey?)x.SessionKey, _ => null), @@ -410,10 +406,10 @@ private static (DegradedFileMetadata Metadata, Dictionary (PgpSessionKey?)x.Data, _ => null), }; - return (new DegradedFileMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); + return (new FileMetadata(partialNode, partialSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); } - private static async ValueTask> ConvertDtoToFolderMetadataAsync( + private static async ValueTask ConvertDtoToFolderMetadataAsync( ProtonDriveClient client, VolumeId volumeId, LinkDetailsDto linkDetailsDto, @@ -439,7 +435,7 @@ private static async ValueTask> C || !nodeKeyIsValid || !hashKeyIsValid) { - var (degradedFolderMetadata, failedDecryptionFields) = CreateDegradedFolderMetadata( + var (partialFolderMetadata, failedDecryptionFields) = CreatePartialFolderMetadata( decryptionResult, nameResult, uid, @@ -448,18 +444,18 @@ private static async ValueTask> C nameSessionKey, membershipDto); - await client.Cache.Secrets.SetFolderSecretsAsync(uid, degradedFolderMetadata.Secrets, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFolderSecretsAsync(uid, partialFolderMetadata.Secrets, cancellationToken).ConfigureAwait(false); - await client.Cache.Entities.SetNodeAsync(uid, degradedFolderMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) + await client.Cache.Entities.SetNodeAsync(uid, partialFolderMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) .ConfigureAwait(false); await TelemetryRecorder.TryRecordDecryptionErrorAsync( client, - DegradedNodeMetadata.FromFolder(degradedFolderMetadata).Node, + partialFolderMetadata.Node, failedDecryptionFields, cancellationToken).ConfigureAwait(false); - return degradedFolderMetadata; + return partialFolderMetadata; } var secrets = new FolderSecrets @@ -488,6 +484,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( CreationTime = linkDto.CreationTime, TrashTime = linkDto.TrashTime, OwnedBy = MapOwnedBy(linkDto.OwnedBy), + Errors = [], }; await client.Cache.Secrets.SetFolderSecretsAsync(uid, secrets, cancellationToken).ConfigureAwait(false); @@ -497,7 +494,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( return new FolderMetadata(node, secrets, membershipDto?.ShareId, linkDto.NameHashDigest); } - private static (DegradedFolderMetadata Metadata, Dictionary FailedDecryptionFields) CreateDegradedFolderMetadata( + private static (FolderMetadata Metadata, Dictionary FailedDecryptionFields) CreatePartialFolderMetadata( FolderDecryptionResult decryptionResult, Result nameResult, NodeUid uid, @@ -553,7 +550,7 @@ private static (DegradedFolderMetadata Metadata, Dictionary decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), _ => new SignatureVerificationError(decryptionResult.Link.NameAuthorshipClaim.Author, "Name decryption failed")); - var degradedNode = new DegradedFolderNode + var partialNode = new FolderNode { Uid = uid, ParentUid = parentUid, @@ -566,7 +563,7 @@ private static (DegradedFolderMetadata Metadata, Dictionary (PgpSessionKey?)x.SessionKey, _ => null), @@ -574,7 +571,7 @@ private static (DegradedFolderMetadata Metadata, Dictionary (ReadOnlyMemory?)x.Data, _ => null), }; - return (new DegradedFolderMetadata(degradedNode, degradedSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); + return (new FolderMetadata(partialNode, partialSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); } private static async ValueTask GetEntryPointKeyOrThrowAsync( @@ -610,9 +607,9 @@ private static async ValueTask GetEntryPointKeyOrThrowAsync( var nodeUid = new NodeUid(volumeId, currentId.Value); - var folderSecretsResult = await secretCache.TryGetFolderSecretsAsync(nodeUid, cancellationToken).ConfigureAwait(false); + var folderSecrets = await secretCache.TryGetFolderSecretsAsync(nodeUid, cancellationToken).ConfigureAwait(false); - var folderKey = folderSecretsResult?.Merge(x => x.Key, x => x.Key); + var folderKey = folderSecrets?.Key; if (folderKey is not null) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 5e3941a7..2a592389 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -1,6 +1,5 @@ using System.Runtime.CompilerServices; using Proton.Drive.Sdk.Api.Files; -using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; @@ -8,22 +7,19 @@ internal static class FileOperations { private const int MaxThumbnailIdsPerRequest = 30; - public static async ValueTask> GetSecretsAsync( - ProtonDriveClient client, - NodeUid fileUid, - CancellationToken cancellationToken) + public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) { - var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); + var fileSecrets = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); - if (fileSecretsResult is null) + if (fileSecrets is null) { - var metadataResult = await NodeOperations.GetFreshNodeMetadataAsync(client, fileUid, knownShareAndKey: null, cancellationToken) + var metadata = await NodeOperations.GetFreshNodeMetadataAsync(client, fileUid, knownShareAndKey: null, cancellationToken) .ConfigureAwait(false); - fileSecretsResult = metadataResult.GetFileSecretsOrThrow(); + fileSecrets = metadata.GetFileSecretsOrThrow(); } - return (Result)fileSecretsResult; + return fileSecrets; } public static async IAsyncEnumerable EnumerateThumbnailsAsync( @@ -45,56 +41,32 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( var errors = new List(); var thumbnailIds = await nodeResults - .Select(FileNodeInfo? (nodeResult) => + .Select(FileNodeInfo? (node) => { - nodeResult.TryGetValueElseError(out var node, out var degradedNode); - - if ((node?.Uid.LinkId ?? degradedNode?.Uid.LinkId) is { } processedLinkId) - { - unprocessedLinkIds.Remove(processedLinkId); - } + unprocessedLinkIds.Remove(node.Uid.LinkId); - if (node is FileNode fileNode) + if (!node.TryGetFileElseFolder(out var fileNode, out _)) { - return new FileNodeInfo(fileNode.Uid, fileNode.ActiveRevision.Uid, fileNode.ActiveRevision.Thumbnails); - } - - if (degradedNode is DegradedFileNode { ActiveRevision: { } degradedRevision } degradedFileNode) - { - if (degradedRevision.CanDecrypt) - { - return new FileNodeInfo(degradedFileNode.Uid, degradedRevision.Uid, degradedRevision.Thumbnails); - } - - // TODO: yield error results immediately instead of collecting them in a list, - // to stream results back to the client as fast as possible (similarly to thumbnail content). - errors.Add( - degradedRevision.ContentAuthor?.TryGetValueElseError(out _, out var error) == false - ? new FileThumbnail(degradedFileNode.Uid, new ProtonDriveError("Cannot decrypt degraded file", error)) - : new FileThumbnail(degradedFileNode.Uid, new ProtonDriveError("Cannot decrypt degraded file"))); - + errors.Add(new FileThumbnail(node.Uid, new ProtonDriveError("Node is not a file"))); return null; } - if (node?.Uid is { } nonFileNodeUid) - { - errors.Add(new FileThumbnail(nonFileNodeUid, new ProtonDriveError("Node is not a file"))); - } + var revision = fileNode.ActiveRevision; - return null; + return new FileNodeInfo(fileNode.Uid, revision.Uid, revision.Thumbnails); }) .Where(x => x.HasValue) .Select(x => x!.Value) .SelectMany(fileNodeInfo => { var thumbnails = fileNodeInfo.Thumbnails; - if (thumbnails.Count == 0) + if (thumbnails.All(thumbnail => thumbnail.Type != thumbnailType)) { - errors.Add(new FileThumbnail(fileNodeInfo.Uid, new ProtonDriveError("Node has no thumbnails"))); - } - else if (thumbnails.All(thumbnail => thumbnail.Type != thumbnailType)) - { - errors.Add(new FileThumbnail(fileNodeInfo.Uid, new ProtonDriveError($"Node has no thumbnail of type {thumbnailType}"))); + var errorMessage = thumbnails.Count != 0 + ? $"Node {fileNodeInfo.Uid} has no thumbnail of type {thumbnailType}" + : $"Node {fileNodeInfo.Uid} has no thumbnails"; + + errors.Add(new FileThumbnail(fileNodeInfo.Uid, new ProtonDriveError(errorMessage))); } return thumbnails @@ -188,11 +160,10 @@ private static async Task DownloadThumbnailAsync( var outputStream = new MemoryStream(initialBufferLength); await using (outputStream.ConfigureAwait(false)) { - var fileSecretsResult = await GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + var fileSecrets = await GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - var contentKey = fileSecretsResult.TryGetValueElseError(out var fileSecrets, out var degradedFileSecrets) - ? fileSecrets.ContentKey - : degradedFileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}"); + var contentKey = fileSecrets.ContentKey + ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}"); await client.ThumbnailBlockDownloader.DownloadAsync( revisionUid, @@ -217,10 +188,5 @@ await client.ThumbnailBlockDownloader.DownloadAsync( } } - private readonly struct FileNodeInfo(NodeUid uid, RevisionUid activeRevisionUid, IReadOnlyList thumbnails) - { - public NodeUid Uid { get; } = uid; - public RevisionUid ActiveRevisionUid { get; } = activeRevisionUid; - public IReadOnlyList Thumbnails { get; } = thumbnails; - } + private readonly record struct FileNodeInfo(NodeUid Uid, RevisionUid ActiveRevisionUid, IReadOnlyList Thumbnails); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs index be3c8a26..2c2b30e7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs @@ -4,5 +4,5 @@ namespace Proton.Drive.Sdk.Nodes; internal sealed class FileSecrets : NodeSecrets { - public required PgpSessionKey ContentKey { get; init; } + public PgpSessionKey? ContentKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs index 50adf687..eaf1c24e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs @@ -1,26 +1,22 @@ -using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Volumes; -using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; internal sealed class FolderChildrenBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey parentKey) - : BatchLoaderBase> + : BatchLoaderBase { private readonly ProtonDriveClient _client = client; private readonly VolumeId _volumeId = volumeId; private readonly PgpPrivateKey _parentKey = parentKey; - protected override async ValueTask>> LoadBatchAsync( - ReadOnlyMemory ids, - CancellationToken cancellationToken) + protected override async IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, [EnumeratorCancellation] CancellationToken cancellationToken) { var response = await _client.Api.Links.GetDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); - var nodeResults = new List>(ids.Length); - foreach (var linkDetails in response.Links) { var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( @@ -28,14 +24,9 @@ protected override async ValueTask>> Lo _volumeId, linkDetails, _parentKey, - cancellationToken) - .ConfigureAwait(false); - - var nodeResult = nodeMetadataResult.ToNodeResult(); + cancellationToken).ConfigureAwait(false); - nodeResults.Add(nodeResult); + yield return nodeMetadataResult.Node; } - - return nodeResults; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index e4652914..068fe773 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -6,13 +6,12 @@ using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Serialization; -using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; internal static class FolderOperations { - public static async IAsyncEnumerable> EnumerateChildrenAsync( + public static async IAsyncEnumerable EnumerateChildrenAsync( ProtonDriveClient client, NodeUid folderUid, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -22,9 +21,7 @@ public static async IAsyncEnumerable> EnumerateChildr var folderSecretsResult = await GetSecretsAsync(client, folderUid, cancellationToken).ConfigureAwait(false); - var folderKey = folderSecretsResult.TryGetValueElseError(out var folderSecrets, out var degradedFolderSecrets) - ? folderSecrets.Key - : degradedFolderSecrets.Key ?? throw new ProtonDriveException($"Folder key not available for {folderUid}"); + var folderKey = folderSecretsResult.Key ?? throw new ProtonDriveException($"Node key not available for folder {folderUid}"); var batchLoader = new FolderChildrenBatchLoader(client, folderUid.VolumeId, folderKey); @@ -44,19 +41,19 @@ public static async IAsyncEnumerable> EnumerateChildr if (cachedChildNodeInfo is null) { - foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(childLinkId, cancellationToken).ConfigureAwait(false)) + await foreach (var nodeResult in batchLoader.QueueAndTryLoadBatchAsync(childLinkId, cancellationToken).ConfigureAwait(false)) { yield return nodeResult; } } else { - yield return cachedChildNodeInfo.Value.NodeProvisionResult; + yield return cachedChildNodeInfo.Value.Node; } } } - foreach (var node in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var node in batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) { yield return node; } @@ -75,9 +72,7 @@ public static async ValueTask CreateAsync( throw new InvalidOperationException("Parent node not found."); } - var parentOwnedBy = parentResult.Value.TryGetValueElseError(out var parentNode, out var parentDegraded) - ? parentNode.OwnedBy - : parentDegraded.OwnedBy; + var parentOwnedBy = parentResult.OwnedBy; var (parentKey, parentHashKey) = await GetKeyAndHashKeyAsync(client, parentUid, cancellationToken).ConfigureAwait(false); @@ -152,6 +147,7 @@ public static async ValueTask CreateAsync( Author = author, CreationTime = DateTime.UtcNow, OwnedBy = parentOwnedBy, + Errors = [], }; await client.Cache.Entities.SetNodeAsync(folderUid, folderNode, membershipShareId: null, nameHashDigest, cancellationToken).ConfigureAwait(false); @@ -159,7 +155,7 @@ public static async ValueTask CreateAsync( return folderNode; } - public static async ValueTask> GetSecretsAsync( + public static async ValueTask GetSecretsAsync( ProtonDriveClient client, NodeUid folderUid, CancellationToken cancellationToken) @@ -168,13 +164,13 @@ public static async ValueTask> GetS if (result is null) { - var nodeProvisionResult = await NodeOperations.GetFreshNodeMetadataAsync(client, folderUid, knownShareAndKey: null, cancellationToken) + var nodeMetadata = await NodeOperations.GetFreshNodeMetadataAsync(client, folderUid, knownShareAndKey: null, cancellationToken) .ConfigureAwait(false); - result = nodeProvisionResult.GetFolderSecretsOrThrow(); + result = nodeMetadata.GetFolderSecretsOrThrow(); } - return result.Value; + return result; } public static async ValueTask<(PgpPrivateKey Key, ReadOnlyMemory HashKey)> GetKeyAndHashKeyAsync( @@ -184,13 +180,8 @@ public static async ValueTask> GetS { var secretsResult = await GetSecretsAsync(client, folderUid, cancellationToken).ConfigureAwait(false); - if (secretsResult.TryGetValueElseError(out var secrets, out var degradedSecrets)) - { - return (secrets.Key, secrets.HashKey); - } - - var key = degradedSecrets.Key ?? throw new ProtonDriveException($"Parent folder key not available for {folderUid}"); - var hashKey = degradedSecrets.HashKey ?? throw new ProtonDriveException($"Parent folder hash key not available for {folderUid}"); + var key = secretsResult.Key ?? throw new ProtonDriveException($"Parent folder key not available for {folderUid}"); + var hashKey = secretsResult.HashKey ?? throw new ProtonDriveException($"Parent folder hash key not available for {folderUid}"); return (key, hashKey); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderProvisionError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderProvisionError.cs deleted file mode 100644 index 8239c641..00000000 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderProvisionError.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Proton.Drive.Sdk.Nodes; - -public sealed class FolderProvisionError(DegradedFolderNode degradedNode, string? message, ProtonDriveError? innerError = null) - : ProtonDriveError(message, innerError) -{ - public DegradedFolderNode DegradedNode { get; } = degradedNode; -} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs index 3c3bccb9..20ce4437 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs @@ -2,5 +2,5 @@ internal sealed class FolderSecrets : NodeSecrets { - public required ReadOnlyMemory HashKey { get; init; } + public ReadOnlyMemory? HashKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index e80145b8..389efa00 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -16,7 +16,7 @@ public abstract record Node public string TreeEventScopeId => Uid.VolumeId.ToString(); - public required string Name { get; init; } + public required Result Name { get; init; } public required DateTime CreationTime { get; init; } @@ -27,4 +27,6 @@ public abstract record Node public required Result Author { get; init; } public required OwnedBy OwnedBy { get; init; } + + public required IReadOnlyList Errors { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs index 6fe6ac99..e2a7fa39 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs @@ -1,39 +1,31 @@ -using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Volumes; -using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; -internal sealed class NodeBatchLoader(ProtonDriveClient client, VolumeId volumeId, bool forPhotos) : BatchLoaderBase> +internal sealed class NodeBatchLoader(ProtonDriveClient client, VolumeId volumeId, bool forPhotos) : BatchLoaderBase { private readonly ProtonDriveClient _client = client; private readonly bool _forPhotos = forPhotos; - protected override async ValueTask>> LoadBatchAsync( - ReadOnlyMemory ids, - CancellationToken cancellationToken) + protected override async IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, [EnumeratorCancellation] CancellationToken cancellationToken) { - var nodeResults = new List>(ids.Length); - var response = _forPhotos ? await _client.Api.Photos.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false) : await _client.Api.Links.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); foreach (var linkDetails in response.Links) { - var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + var (node, _, _, _) = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( _client, volumeId, linkDetails, knownShareAndKey: null, cancellationToken).ConfigureAwait(false); - var nodeResult = nodeMetadataResult.ToNodeResult(); - - nodeResults.Add(nodeResult); + yield return node; } - - return nodeResults; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs index 692d5fbc..2e885672 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs @@ -1,106 +1,46 @@ using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; -using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; internal static class NodeMetadataResultExtensions { - public static Node GetNodeOrThrow(this Result metadataResult) + extension(NodeMetadata metadata) { - var metadata = metadataResult.GetValueOrThrow(); - - return metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? fileNode : folderNode; - } - - public static FolderNode GetFolderNodeOrThrow(this Result metadataResult) - { - var metadata = metadataResult.GetValueOrThrow(); - - if (metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _)) + public Node GetNodeOrThrow() { - throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); + return metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? fileNode : folderNode; } - return folderNode; - } - - public static Result GetFolderSecretsOrThrow(this Result metadataResult) - { - if (metadataResult.TryGetValueElseError(out var metadata, out var degradedMetadata)) - { - if (metadata.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) - { - throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); - } - - return folderSecrets; - } - else + public FolderNode GetFolderNodeOrThrow() { - if (degradedMetadata.TryGetFileElseFolder(out var degradedFileNode, out _, out _, out var degradedFolderSecrets)) - { - throw new InvalidNodeTypeException(degradedFileNode.Uid, LinkType.File); - } - - return degradedFolderSecrets; + return !metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) + ? folderNode + : throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); } - } - public static Result GetFileSecretsOrThrow(this Result metadataResult) - { - if (metadataResult.TryGetValueElseError(out var metadata, out var degradedMetadata)) + public FolderSecrets GetFolderSecretsOrThrow() { - if (!metadata.TryGetFileElseFolder(out _, out var fileSecrets, out var folderNode, out _)) - { - throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); - } - - return fileSecrets; + return !metadata.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets) + ? folderSecrets + : throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); } - else - { - if (!degradedMetadata.TryGetFileElseFolder(out _, out var degradedFileSecrets, out var folderNode, out _)) - { - throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); - } - return degradedFileSecrets; + public FileSecrets GetFileSecretsOrThrow() + { + return metadata.TryGetFileElseFolder(out _, out var fileSecrets, out var folderNode, out _) + ? fileSecrets + : throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); } - } - public static PgpPrivateKey GetFolderKeyOrThrow(this Result metadataResult) - { - if (!metadataResult.TryGetValueElseError(out var nodeAndSecrets, out var degradedNodeAndSecrets)) + public PgpPrivateKey GetFolderKeyOrThrow() { - if (degradedNodeAndSecrets.TryGetFileElseFolder(out var degradedFileNode, out _, out var degradedFolderNode, out var degradedFolderSecrets)) - { - throw new InvalidNodeTypeException(degradedFileNode.Uid, LinkType.File); - } - - if (degradedFolderSecrets.Key is not { } folderKey) + if (metadata.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) { - throw new ProtonDriveException($"Degraded node does not have a key: {degradedFolderNode.Errors[0]}"); + throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); } - return folderKey; - } - - if (nodeAndSecrets.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets)) - { - throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); + return folderSecrets.Key ?? throw new ProtonDriveException($"Folder node does not have a key: {metadata.Node.Errors[0]}"); } - - return folderSecrets.Key; - } - - public static Result ToNodeResult(this Result metadataResult) - { - return metadataResult.Convert(metadata => metadata.Node, metadata => metadata.Node); - } - - public static Result ToSecretsResult(this Result metadataResult) - { - return metadataResult.Convert(metadata => metadata.Secrets, metadata => metadata.Secrets); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 8a114357..3075541b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -48,10 +48,10 @@ public static async ValueTask GetOrCreateMyFilesFolderAsync(ProtonDr var metadata = await GetNodeMetadataAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, useCacheOnly: false, cancellationToken) .ConfigureAwait(false); - return (FolderNode)metadata.GetValueOrThrow().Node; + return metadata.GetFolderNodeOrThrow(); } - public static async ValueTask> GetNodeMetadataAsync( + public static async ValueTask GetNodeMetadataAsync( ProtonDriveClient client, NodeUid uid, ShareAndKey? knownShareAndKey, @@ -70,10 +70,10 @@ public static async ValueTask> GetNod metadataResult = await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); } - return (Result)metadataResult; + return metadataResult.Value; } - public static IAsyncEnumerable> EnumerateNodesAsync( + public static IAsyncEnumerable EnumerateNodesAsync( ProtonDriveClient client, IEnumerable nodeUids, bool forPhotos, @@ -84,7 +84,7 @@ public static IAsyncEnumerable> EnumerateNodesAsync( .SelectMany(linkGroup => EnumerateNodesAsync(client, linkGroup.Key, linkGroup, forPhotos, cancellationToken)); } - public static async IAsyncEnumerable> EnumerateNodesAsync( + public static async IAsyncEnumerable EnumerateNodesAsync( ProtonDriveClient client, VolumeId volumeId, IEnumerable linkIds, @@ -97,20 +97,20 @@ public static async IAsyncEnumerable> EnumerateNodesA { var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(new NodeUid(volumeId, linkId), cancellationToken).ConfigureAwait(false); - if (cachedChildNodeInfo is null) + if (cachedChildNodeInfo is not { Node: { } node }) { - foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) + await foreach (var nodeResult in batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) { yield return nodeResult; } + + continue; } - else - { - yield return cachedChildNodeInfo.Value.NodeProvisionResult; - } + + yield return node; } - foreach (var nodeResult in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var nodeResult in batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) { yield return nodeResult; } @@ -148,7 +148,7 @@ public static void GetCommonCreationParameters( GetNameParameters(name, parentFolderKey, parentFolderHashKey, nameSessionKey, signingKey, out encryptedName, out nameHashDigest); } - public static async ValueTask> GetFreshNodeMetadataAsync( + public static async ValueTask GetFreshNodeMetadataAsync( ProtonDriveClient client, NodeUid uid, ShareAndKey? knownShareAndKey, @@ -177,23 +177,13 @@ public static async ValueTask MoveSingleAsync( using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var destinationFolderSecretsResult = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); - PgpPrivateKey destinationKey; - ReadOnlyMemory destinationHashKey; + var destinationKey = destinationFolderSecrets.Key + ?? throw new ProtonDriveException($"Destination folder key not available for {newParentUid}"); - if (destinationFolderSecretsResult.TryGetValueElseError(out var destinationFolderSecrets, out var degradedDestinationFolderSecrets)) - { - destinationKey = destinationFolderSecrets.Key; - destinationHashKey = destinationFolderSecrets.HashKey; - } - else - { - destinationKey = degradedDestinationFolderSecrets.Key - ?? throw new ProtonDriveException($"Destination folder key not available for {newParentUid}"); - destinationHashKey = degradedDestinationFolderSecrets.HashKey - ?? throw new ProtonDriveException($"Destination folder hash key not available for {newParentUid}"); - } + var destinationHashKey = destinationFolderSecrets.HashKey + ?? throw new ProtonDriveException($"Destination folder hash key not available for {newParentUid}"); if (uid == newParentUid) { @@ -205,20 +195,27 @@ public static async ValueTask MoveSingleAsync( throw new InvalidOperationException($"Node {uid} cannot have destination node {newParentUid} as parent as they are not on the same volume"); } - // FIXME: Try to use the degraded node if it has enough for the move to be successful - var (originNode, originSecrets, membershipShareId, originNameHashDigest) = - (await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, cancellationToken).ConfigureAwait(false)).GetValueOrThrow(); + var originMetadata = await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + var (originNode, originSecrets, membershipShareId, originNameHashDigest) = originMetadata; + + var originName = originNode.Name.GetValueOrThrow(); + + var originNameSessionKey = originSecrets.NameSessionKey + ?? throw new ProtonDriveException($"Name session key not available for {uid}"); + + var originPassphraseSessionKey = originSecrets.PassphraseSessionKey + ?? throw new ProtonDriveException($"Passphrase session key not available for {uid}"); GetNameParameters( - newName ?? originNode.Name, // FIXME: validate name + newName ?? originName, // FIXME: validate name destinationKey, destinationHashKey.Span, - originSecrets.NameSessionKey, + originNameSessionKey, signingKey, out var encryptedName, out var nameHashDigest); - var passphraseKeyPacket = destinationKey.EncryptSessionKey(originSecrets.PassphraseSessionKey); + var passphraseKeyPacket = destinationKey.EncryptSessionKey(originPassphraseSessionKey); ReadOnlyMemory? passphraseSignature = null; string? signatureEmailAddress = null; @@ -243,7 +240,7 @@ public static async ValueTask MoveSingleAsync( await client.Api.Links.MoveAsync(newParentUid.VolumeId, uid.LinkId, request, cancellationToken).ConfigureAwait(false); - var newNode = originNode with { ParentUid = newParentUid, Name = newName ?? originNode.Name }; + var newNode = originNode with { ParentUid = newParentUid, Name = newName ?? originName }; await client.Cache.Entities.SetNodeAsync(uid, newNode, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); } @@ -261,23 +258,13 @@ public static async Task MoveMultipleAsync( using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var destinationFolderSecretsResult = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); - PgpPrivateKey destinationKey; - ReadOnlyMemory destinationHashKey; + var destinationKey = destinationFolderSecrets.Key + ?? throw new ProtonDriveException($"Destination folder key not available for {newParentUid}"); - if (destinationFolderSecretsResult.TryGetValueElseError(out var destinationFolderSecrets, out var degradedDestinationFolderSecrets)) - { - destinationKey = destinationFolderSecrets.Key; - destinationHashKey = destinationFolderSecrets.HashKey; - } - else - { - destinationKey = degradedDestinationFolderSecrets.Key - ?? throw new ProtonDriveException($"Destination folder key not available for {newParentUid}"); - destinationHashKey = degradedDestinationFolderSecrets.HashKey - ?? throw new ProtonDriveException($"Destination folder hash key not available for {newParentUid}"); - } + var destinationHashKey = destinationFolderSecrets.HashKey + ?? throw new ProtonDriveException($"Destination folder hash key not available for {newParentUid}"); var batch = new List(); @@ -290,18 +277,26 @@ public static async Task MoveMultipleAsync( // FIXME: Try to use the degraded node if it has enough for the move to be successful var (originNode, originSecrets, _, originNameHashDigest) = - (await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, cancellationToken).ConfigureAwait(false)).GetValueOrThrow(); + await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + + var originName = originNode.Name.GetValueOrThrow(); + + var originNameSessionKey = originSecrets.NameSessionKey + ?? throw new ProtonDriveException($"Name session key not available for {uid}"); + + var originPassphraseSessionKey = originSecrets.PassphraseSessionKey + ?? throw new ProtonDriveException($"Passphrase session key not available for {uid}"); GetNameParameters( - newName ?? originNode.Name, // FIXME: validate name + newName ?? originName, // FIXME: validate name destinationKey, destinationHashKey.Span, - originSecrets.NameSessionKey, + originNameSessionKey, signingKey, out var encryptedName, out var nameHashDigest); - var passphraseKeyPacket = destinationKey.EncryptSessionKey(originSecrets.PassphraseSessionKey); + var passphraseKeyPacket = destinationKey.EncryptSessionKey(originPassphraseSessionKey); var itemRequest = new MoveMultipleLinksItem { @@ -338,7 +333,7 @@ public static async ValueTask RenameAsync( { // FIXME: Try to use the degraded node if it has enough for the move to be successful var (node, secrets, membershipShareId, originalNameHashDigest) = - (await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, cancellationToken).ConfigureAwait(false)).GetValueOrThrow(); + await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); if (node.ParentUid is not { } parentUid) { @@ -351,11 +346,14 @@ public static async ValueTask RenameAsync( var (parentKey, parentHashKey) = await FolderOperations.GetKeyAndHashKeyAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var nameSessionKey = secrets.NameSessionKey + ?? throw new ProtonDriveException($"Name session key not available for {uid}"); + GetNameParameters( newName, // FIXME: validate name parentKey, parentHashKey.Span, - secrets.NameSessionKey, + nameSessionKey, signingKey, out var encryptedName, out var nameHashDigest); @@ -430,15 +428,15 @@ public static async ValueTask>> T var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); - if (cachedNodeInfo is var (nodeProvisionResult, membershipShareId, nameHashDigest)) + if (cachedNodeInfo is var (node, membershipShareId, nameHashDigest)) { // TODO: have the back-end return the trash time so that the cached value be exactly the same - var newNodeProvisionResult = nodeProvisionResult.Convert( - node => node with { TrashTime = DateTime.UtcNow }, - degradedNode => degradedNode with { TrashTime = DateTime.UtcNow }); - - await client.Cache.Entities.SetNodeAsync(uid, newNodeProvisionResult, membershipShareId, nameHashDigest, cancellationToken) - .ConfigureAwait(false); + await client.Cache.Entities.SetNodeAsync( + uid, + node with { TrashTime = DateTime.UtcNow }, + membershipShareId, + nameHashDigest, + cancellationToken).ConfigureAwait(false); } var result = response.IsSuccess ? Result.Success : new ProtonApiException(response); @@ -524,11 +522,9 @@ public static async ValueTask GetAvailableNameAsync(ProtonDriveClient cl { const int batchSize = 10; - var folderSecretsResult = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var folderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); - var folderHashKey = folderSecretsResult.TryGetValueElseError(out var folderSecrets, out var degradedFolderSecrets) - ? folderSecrets.HashKey : degradedFolderSecrets.HashKey - ?? throw new ProtonDriveException($"Folder hash key not available for {parentUid}"); + var folderHashKey = folderSecrets.HashKey ?? throw new ProtonDriveException($"Folder hash key not available for {parentUid}"); var digestsToNamesMap = new Dictionary(batchSize); @@ -614,15 +610,14 @@ public static bool ValidateName( public static async Task> GetParentFolderHashKeyAsync(ProtonDriveClient client, NodeUid uid, CancellationToken cancellationToken) { - var nodeMetadataResult = await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + var (node, _, _, _) = await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); - var parentUid = nodeMetadataResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); - if (parentUid is null) + if (node.ParentUid is not { } parentUid) { throw new InvalidOperationException("Root node does not have a parent folder"); } - var (_, hashKey) = await FolderOperations.GetKeyAndHashKeyAsync(client, parentUid.Value, cancellationToken).ConfigureAwait(false); + var (_, hashKey) = await FolderOperations.GetKeyAndHashKeyAsync(client, parentUid, cancellationToken).ConfigureAwait(false); return hashKey; } @@ -649,15 +644,14 @@ public static async Task> GetParentFolderHashKeyAsync(Proto await client.Cache.Secrets.SetShareKeyAsync(share.Id, shareKey, cancellationToken).ConfigureAwait(false); await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); - var metadataResult = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( - client, - volumeDto.Id, - linkDetailsDto, - shareKey, - cancellationToken) - .ConfigureAwait(false); + var (node, _, _, _) = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( + client, + volumeDto.Id, + linkDetailsDto, + shareKey, + cancellationToken).ConfigureAwait(false); - return metadataResult.GetValueOrThrow().Node; + return node; } private static void GetNameParameters( @@ -685,59 +679,29 @@ private static void GetNameParameters( } } - private static async ValueTask?> TryGetNodeMetadataFromCacheAsync( + private static async ValueTask TryGetNodeMetadataFromCacheAsync( ProtonDriveClient client, NodeUid uid, CancellationToken cancellationToken) { - var cachedNodeInfoResult = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); - if (cachedNodeInfoResult is not { } cachedNodeInfo) + var cachedNodeInfoOrNull = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); + if (cachedNodeInfoOrNull is not var (node, membershipShareId, nameHashDigest)) { return null; } - if (!cachedNodeInfo.NodeProvisionResult.TryGetValueElseError(out var node, out var degradedNode)) + return node switch { - switch (degradedNode) - { - case DegradedFolderNode degradedFolderNode: - var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(uid, cancellationToken).ConfigureAwait(false); - - return folderSecretsResult is not null && folderSecretsResult.Value.TryGetError(out var degradedFolderSecrets) - ? new DegradedNodeMetadata(degradedFolderNode, degradedFolderSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) - : (Result?)null; + FolderNode folderNode => await client.Cache.Secrets.TryGetFolderSecretsAsync(uid, cancellationToken).ConfigureAwait(false) is { } folderSecrets + ? new NodeMetadata(folderNode, folderSecrets, membershipShareId, nameHashDigest) + : null, - case DegradedFileNode degradedFileNode: - var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(uid, cancellationToken).ConfigureAwait(false); + FileNode fileNode => await client.Cache.Secrets.TryGetFileSecretsAsync(uid, cancellationToken).ConfigureAwait(false) is { } fileSecrets + ? new NodeMetadata(fileNode, fileSecrets, membershipShareId, nameHashDigest) + : null, - return fileSecretsResult is not null && fileSecretsResult.Value.TryGetError(out var degradedFileSecrets) - ? new DegradedNodeMetadata(degradedFileNode, degradedFileSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) - : (Result?)null; - - default: - throw new InvalidOperationException($"Degraded node type \"{node?.GetType().Name}\" is not supported"); - } - } - - switch (node) - { - case FolderNode folderNode: - var folderSecretsResult = await client.Cache.Secrets.TryGetFolderSecretsAsync(uid, cancellationToken).ConfigureAwait(false); - - return folderSecretsResult is not null && folderSecretsResult.Value.TryGetValue(out var folderSecrets) - ? new NodeMetadata(folderNode, folderSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) - : null; - - case FileNode fileNode: - var fileSecretsResult = await client.Cache.Secrets.TryGetFileSecretsAsync(uid, cancellationToken).ConfigureAwait(false); - - return fileSecretsResult is not null && fileSecretsResult.Value.TryGetValue(out var fileSecrets) - ? new NodeMetadata(fileNode, fileSecrets, cachedNodeInfo.MembershipShareId, cachedNodeInfo.NameHashDigest) - : null; - - default: - throw new InvalidOperationException($"Node type \"{node.GetType().Name}\" is not supported"); - } + _ => throw new InvalidOperationException($"Node type \"{node.GetType().Name}\" is not supported"), + }; } private static async ValueTask CreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs index c00f5dd0..11850a97 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs @@ -1,38 +1,23 @@ using System.Diagnostics.CodeAnalysis; -using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; public static class NodeResultExtensions { public static bool TryGetFileElseFolder( - this Result nodeResult, - [NotNullWhen(true)] out Result? fileResult, - [NotNullWhen(false)] out Result? folderResult) + this Node node, + [NotNullWhen(true)] out FileNode? fileNode, + [NotNullWhen(false)] out FolderNode? folderNode) { - if (!nodeResult.TryGetValueElseError(out var node, out var degradedNode)) + if (node is FolderNode folder) { - if (degradedNode is DegradedFolderNode degradedFolderNode) - { - fileResult = null; - folderResult = degradedFolderNode; - return false; - } - - fileResult = (DegradedFileNode)degradedNode; - folderResult = null; - return true; - } - - if (node is FolderNode folderNode) - { - fileResult = null; - folderResult = folderNode; + fileNode = null; + folderNode = folder; return false; } - fileResult = (FileNode)node; - folderResult = null; + fileNode = (FileNode)node; + folderNode = null; return true; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs index 820170a7..6cf513d4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs @@ -5,9 +5,9 @@ namespace Proton.Drive.Sdk.Nodes; internal class NodeSecrets { - public required PgpPrivateKey Key { get; init; } - public required PgpSessionKey PassphraseSessionKey { get; init; } - public required PgpSessionKey NameSessionKey { get; init; } + public PgpPrivateKey? Key { get; init; } + public PgpSessionKey? PassphraseSessionKey { get; init; } + public PgpSessionKey? NameSessionKey { get; init; } [JsonPropertyName("passphrase")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs index 9b4be9ec..dbe918c2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs @@ -44,7 +44,7 @@ public static async ValueTask GetOrCreatePhotosFolderAsync(ProtonDri useCacheOnly: false, cancellationToken).ConfigureAwait(false); - return (FolderNode)metadata.GetValueOrThrow().Node; + return metadata.GetFolderNodeOrThrow(); } public static async IAsyncEnumerable EnumeratePhotosTimelineAsync( @@ -102,7 +102,7 @@ private static async ValueTask GetFreshExistingPhotosFolderAsync(Pro shareKey, cancellationToken).ConfigureAwait(false); - return metadataResult.GetValueOrThrow().Node; + return metadataResult.Node; } private static async ValueTask CreatePhotosFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs index c449e0b7..d433ac72 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -8,7 +8,7 @@ public sealed record Revision public required DateTime CreationTime { get; init; } public required long SizeOnCloudStorage { get; init; } public long? ClaimedSize { get; init; } - public required FileContentDigests ClaimedDigests { get; init; } + public FileContentDigests ClaimedDigests { get; init; } public DateTime? ClaimedModificationTime { get; init; } public required IReadOnlyList Thumbnails { get; init; } public required IReadOnlyList? AdditionalClaimedMetadata { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index 81476397..352fc0ba 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -37,13 +37,11 @@ internal static async ValueTask CreateDownloadStateAsync( await Task.WhenAll(secretsTask, revisionTask).ConfigureAwait(false); - var fileSecretsResult = await secretsTask.ConfigureAwait(false); + var fileSecrets = await secretsTask.ConfigureAwait(false); var revisionResponse = await revisionTask.ConfigureAwait(false); - var (key, contentKey) = fileSecretsResult.TryGetValueElseError(out var fileSecrets, out var degradedFileSecrets) - ? (fileSecrets.Key, fileSecrets.ContentKey) - : (degradedFileSecrets.Key ?? throw new InvalidOperationException($"Node key not available for file {revisionUid.NodeUid}"), - degradedFileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}")); + var key = fileSecrets.Key ?? throw new InvalidOperationException($"Node key not available for file {revisionUid.NodeUid}"); + var contentKey = fileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}"); return new DownloadState( revisionUid, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs index 73cfe22c..61010644 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs @@ -1,17 +1,15 @@ -using Proton.Sdk; - namespace Proton.Drive.Sdk.Nodes; internal static class TraversalOperations { - public static async ValueTask> FindRootForNode( + public static async ValueTask FindRootForNode( ProtonDriveClient client, - Result nodeResult, + NodeMetadata nodeMetadata, bool useCacheOnly, CancellationToken cancellationToken) { - var entryPointUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid) - ?? GetAlbumEntryPointUid(nodeResult); + var currentMetadata = nodeMetadata; + var entryPointUid = currentMetadata.Node.ParentUid ?? GetAlbumEntryPointUid(currentMetadata); HashSet visitedNodes = []; @@ -22,19 +20,21 @@ public static async ValueTask> FindRo throw new ProtonDriveException("Folder structure loop detected"); } - nodeResult = await NodeOperations.GetNodeMetadataAsync(client, (NodeUid)entryPointUid, knownShareAndKey: null, useCacheOnly, cancellationToken) - .ConfigureAwait(false); + currentMetadata = await NodeOperations.GetNodeMetadataAsync( + client, + (NodeUid)entryPointUid, + knownShareAndKey: null, + useCacheOnly, + cancellationToken).ConfigureAwait(false); - entryPointUid = nodeResult.Merge(x => x.Node.ParentUid, x => x.Node.ParentUid); + entryPointUid = currentMetadata.Node.ParentUid ?? GetAlbumEntryPointUid(currentMetadata); } - return nodeResult; + return currentMetadata; } - private static NodeUid? GetAlbumEntryPointUid(Result nodeResult) + private static NodeUid? GetAlbumEntryPointUid(NodeMetadata nodeMetadata) { - return nodeResult.Merge( - x => x.Node is PhotoNode { AlbumUids.Count: > 0 } photo ? photo.AlbumUids[0] : (NodeUid?)null, - x => x.Node is DegradedPhotoNode { AlbumUids.Count: > 0 } photo ? photo.AlbumUids[0] : null); + return nodeMetadata.Node is PhotoNode { AlbumUids.Count: > 0 } photo ? photo.AlbumUids[0] : null; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index dfac1a64..ebe79ed4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using Proton.Drive.Sdk.Telemetry; -using Proton.Sdk; using Proton.Sdk.Threading; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -226,8 +225,7 @@ private async ValueTask UpdateActiveRevisionInCacheAsync(RevisionUid revisionUid { var cachedNodeInfo = await _client.Cache.Entities.TryGetNodeAsync(revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); - if (cachedNodeInfo is not var (nodeProvisionResult, membershipShareId, nameHashDigest) || !nodeProvisionResult.TryGetValue(out var node) || - node is not FileNode fileNode) + if (cachedNodeInfo is not (FileNode fileNode, var membershipShareId, var nameHashDigest)) { await _client.Cache.Entities.RemoveNodeAsync(revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); return; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 43e3d8b4..117785ab 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -39,7 +39,7 @@ public async ValueTask GetDraftAsync(long intendedUploadSize, Can var signingKey = await _client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var (response, fileSecrets) = await CreateDraftAsync( + var (revisionUid, key, contentKey) = await CreateDraftAsync( intendedUploadSize, parentKey, parentHashKey, @@ -47,23 +47,18 @@ public async ValueTask GetDraftAsync(long intendedUploadSize, Can membershipAddress.EmailAddress, cancellationToken).ConfigureAwait(false); - var draftNodeUid = new NodeUid(_parentUid.VolumeId, response.Identifiers.LinkId); - var draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); - - await _client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); - - var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(draftRevisionUid, fileSecrets.Key, cancellationToken).ConfigureAwait(false); + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(revisionUid, key, cancellationToken).ConfigureAwait(false); return new RevisionDraft( - draftRevisionUid, - fileSecrets.Key, - fileSecrets.ContentKey, + revisionUid, + key, + contentKey, signingKey, parentHashKey, membershipAddress, blockVerifier, intendedUploadSize, - ct => DeleteDraftAsync(draftRevisionUid, ct), + ct => DeleteDraftAsync(revisionUid, ct), _client.Telemetry.GetLogger("New file draft")); } @@ -119,7 +114,7 @@ private static FileCreationRequest GetFileCreationRequest( }; } - private async ValueTask<(FileCreationResponse Response, FileSecrets FileSecrets)> CreateDraftAsync( + private async ValueTask<(RevisionUid RevisionUid, PgpPrivateKey Key, PgpSessionKey ContentKey)> CreateDraftAsync( long intendedUploadSize, PgpPrivateKey parentKey, ReadOnlyMemory parentHashKey, @@ -129,7 +124,7 @@ private static FileCreationRequest GetFileCreationRequest( { var remainingNumberOfAttempts = MaxNumberOfDraftCreationAttempts; - (FileCreationResponse Response, FileSecrets FileSecrets)? result = null; + (RevisionUid RevisionUid, PgpPrivateKey Key, PgpSessionKey ContentKey)? result = null; var useAeadFeatureFlag = await _client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken) .ConfigureAwait(false); @@ -164,7 +159,12 @@ private static FileCreationRequest GetFileCreationRequest( ContentKey = contentKey, }; - result = (response, fileSecrets); + var draftNodeUid = new NodeUid(_parentUid.VolumeId, response.Identifiers.LinkId); + var draftRevisionUid = new RevisionUid(draftNodeUid, response.Identifiers.RevisionId); + + await _client.Cache.Secrets.SetFileSecretsAsync(draftNodeUid, fileSecrets, cancellationToken).ConfigureAwait(false); + + result = (draftRevisionUid, nodeKey, contentKey); } catch (ProtonApiException e) when (RevisionConflict.FromErrorResponse(e.Response) is { LinkId: { } conflictingLinkId, RevisionId: null, DraftRevisionId: not null } conflict diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index 2066beb0..5a7712d6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -33,11 +33,11 @@ public async ValueTask GetDraftAsync(long intendedUploadSize, Can IntendedUploadSize = intendedUploadSize, }; - var fileSecretsResult = await FileOperations.GetSecretsAsync(_client, _fileUid, cancellationToken).ConfigureAwait(false); + var fileSecrets = await FileOperations.GetSecretsAsync(_client, _fileUid, cancellationToken).ConfigureAwait(false); - if (!fileSecretsResult.TryGetValueElseError(out var fileSecrets, out _)) + if (fileSecrets is not { Key: { } nodeKey, ContentKey: { } contentKey }) { - throw new InvalidOperationException($"Cannot create draft for file {_fileUid} with degraded secrets"); + throw new InvalidOperationException($"Cannot create draft for file {_fileUid} with missing secrets"); } var remainingNumberOfAttempts = MaxNumberOfDraftCreationAttempts; @@ -71,12 +71,12 @@ public async ValueTask GetDraftAsync(long intendedUploadSize, Can var signingKey = await _client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(draftRevisionUid, fileSecrets.Key, cancellationToken).ConfigureAwait(false); + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(draftRevisionUid, nodeKey, cancellationToken).ConfigureAwait(false); return new RevisionDraft( draftRevisionUid, - fileSecrets.Key, - fileSecrets.ContentKey, + nodeKey, + contentKey, signingKey, parentHashKey: null, membershipAddress, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index 6a72ed8b..edd1085f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -186,15 +186,14 @@ public ValueTask GetMyFilesFolderAsync(CancellationToken cancellatio return NodeOperations.GetOrCreateMyFilesFolderAsync(this, cancellationToken); } - public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + public ValueTask GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) { return NodeOperations .EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], forPhotos: false, cancellationToken) - .Select(x => (Result?)x) .FirstOrDefaultAsync(cancellationToken); } - public IAsyncEnumerable> EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) + public IAsyncEnumerable EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) { return NodeOperations.EnumerateNodesAsync(this, nodeUids, forPhotos: false, cancellationToken); } @@ -204,7 +203,7 @@ public ValueTask CreateFolderAsync(NodeUid parentId, string name, Da return FolderOperations.CreateAsync(this, parentId, name, lastModificationTime, cancellationToken); } - public IAsyncEnumerable> EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) + public IAsyncEnumerable EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) { return FolderOperations.EnumerateChildrenAsync(this, folderId, cancellationToken); } @@ -313,7 +312,7 @@ public ValueTask>> RestoreNodesAs return NodeOperations.RestoreFromTrashAsync(this, uids, cancellationToken); } - public async IAsyncEnumerable> EnumerateTrashAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable EnumerateTrashAsync([EnumeratorCancellation] CancellationToken cancellationToken) { var volumeId = await VolumeOperations.TryGetMainVolumeIdAsync(this, cancellationToken).ConfigureAwait(false); if (volumeId is null) diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index 4082e997..b6d72f9e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -94,15 +94,14 @@ public ValueTask> FindDuplicatesAsync(string name, Action< throw new NotImplementedException(); } - public ValueTask?> GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + public ValueTask GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) { return NodeOperations .EnumerateNodesAsync(DriveClient, nodeUid.VolumeId, [nodeUid.LinkId], forPhotos: true, cancellationToken) - .Select(x => (Result?)x) .FirstOrDefaultAsync(cancellationToken); } - public IAsyncEnumerable> EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) + public IAsyncEnumerable EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) { return NodeOperations.EnumerateNodesAsync(DriveClient, nodeUids, forPhotos: true, cancellationToken); } @@ -147,7 +146,7 @@ public ValueTask>> RestoreNodesAs return NodeOperations.RestoreFromTrashAsync(DriveClient, uids, cancellationToken); } - public async IAsyncEnumerable> EnumerateTrashAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable EnumerateTrashAsync([EnumeratorCancellation] CancellationToken cancellationToken) { var volumeId = await VolumeOperations.TryGetPhotosVolumeIdAsync(DriveClient, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs index f5e96bf1..e075e7ca 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -14,15 +14,14 @@ namespace Proton.Drive.Sdk.Serialization; Converters = [ typeof(RefResultJsonConverter), + typeof(RefResultJsonConverter), typeof(ValResultJsonConverter), - typeof(RefResultJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(Share))] -[JsonSerializable(typeof(FolderNode))] [JsonSerializable(typeof(CachedNodeInfo))] [JsonSerializable(typeof(VolumeId?))] +[JsonSerializable(typeof(SerializableRefResult))] [JsonSerializable(typeof(SerializableRefResult))] [JsonSerializable(typeof(SerializableValResult))] -[JsonSerializable(typeof(SerializableRefResult))] internal sealed partial class DriveEntitiesSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs index 2b908f81..278612f9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Nodes; -using Proton.Sdk; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Serialization; @@ -13,13 +12,9 @@ namespace Proton.Drive.Sdk.Serialization; [ typeof(PgpPrivateKeyJsonConverter), typeof(PgpSessionKeyJsonConverter), - typeof(RefResultJsonConverter), - typeof(RefResultJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(IEnumerable))] -[JsonSerializable(typeof(Result?))] -[JsonSerializable(typeof(Result?))] -[JsonSerializable(typeof(SerializableRefResult))] -[JsonSerializable(typeof(SerializableRefResult))] +[JsonSerializable(typeof(FolderSecrets))] +[JsonSerializable(typeof(FileSecrets))] internal sealed partial class DriveSecretsSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs index 8bd52f98..285ffd27 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -1,6 +1,5 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; -using Proton.Sdk; namespace Proton.Drive.Sdk.Shares; @@ -48,12 +47,12 @@ public static async ValueTask> GetSharesAsync(ProtonDriveClient clie public static async ValueTask GetContextShareAsync( ProtonDriveClient client, - Result nodeResult, + NodeMetadata nodeMetadata, bool useCacheOnly, CancellationToken cancellationToken) { - var contextRoot = await TraversalOperations.FindRootForNode(client, nodeResult, useCacheOnly, cancellationToken).ConfigureAwait(false); - var contextShareId = contextRoot.Merge(x => x.MembershipShareId, x => x.MembershipShareId); + var contextRoot = await TraversalOperations.FindRootForNode(client, nodeMetadata, useCacheOnly, cancellationToken).ConfigureAwait(false); + var contextShareId = contextRoot.MembershipShareId; if (!contextShareId.HasValue) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs index e82f9016..02befaaf 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs @@ -13,17 +13,17 @@ internal static class TelemetryEventFactory /// public static async Task> CreateDecryptionErrorEventsAsync( ProtonDriveClient client, - DegradedNode degradedNode, + Node node, IReadOnlyDictionary failedFields, CancellationToken cancellationToken) { - var fromBefore2024 = degradedNode.CreationTime.CompareTo(LegacyBoundary) < 1; + var fromBefore2024 = node.CreationTime.CompareTo(LegacyBoundary) < 1; - var volumeType = await ResolveVolumeTypeAsync(client, degradedNode.Uid, cancellationToken).ConfigureAwait(false); + var volumeType = await ResolveVolumeTypeAsync(client, node.Uid, cancellationToken).ConfigureAwait(false); return failedFields.Select(field => new DecryptionErrorEvent { - Uid = degradedNode.Uid, + Uid = node.Uid, Field = field.Key, VolumeType = volumeType, FromBefore2024 = fromBefore2024, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs index e35fc381..bf3b77c9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs @@ -9,17 +9,13 @@ internal static class TelemetryRecorder ///
public static async Task TryRecordDecryptionErrorAsync( ProtonDriveClient client, - DegradedNode degradedNode, + Node node, IReadOnlyDictionary failedFields, CancellationToken cancellationToken) { try { - var events = await TelemetryEventFactory.CreateDecryptionErrorEventsAsync( - client, - degradedNode, - failedFields, - cancellationToken).ConfigureAwait(false); + var events = await TelemetryEventFactory.CreateDecryptionErrorEventsAsync(client, node, failedFields, cancellationToken).ConfigureAwait(false); foreach (var @event in events) { @@ -45,13 +41,8 @@ public static async Task TryRecordVerificationErrorAsync( { try { - var @event = await TelemetryEventFactory.CreateVerificationErrorEventAsync( - client, - nodeUid, - field, - creationTime, - error, - cancellationToken).ConfigureAwait(false); + var @event = await TelemetryEventFactory.CreateVerificationErrorEventAsync(client, nodeUid, field, creationTime, error, cancellationToken) + .ConfigureAwait(false); client.Telemetry.RecordMetric(@event); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 2b8d7ee8..ec92feb2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -5,7 +5,6 @@ using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; -using Proton.Sdk; using Proton.Sdk.Addresses; namespace Proton.Drive.Sdk.Volumes; @@ -42,6 +41,7 @@ internal static class VolumeOperations Author = new Author { EmailAddress = defaultAddress.EmailAddress }, CreationTime = DateTime.UtcNow, OwnedBy = new OwnedBy(Email: defaultAddress.EmailAddress), + Errors = [], }; // The volume root folder never has siblings and does not need a name hash digest @@ -58,7 +58,7 @@ internal static class VolumeOperations return (volume, share, rootFolder); } - public static async IAsyncEnumerable> EnumerateTrashAsync( + public static async IAsyncEnumerable EnumerateTrashAsync( ProtonDriveClient client, VolumeId volumeId, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -81,22 +81,22 @@ public static async IAsyncEnumerable> EnumerateTrashA foreach (var linkId in linkIds) { var uid = new NodeUid(volumeId, linkId); - var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); + var cachedNodeInfoOrNull = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); - if (cachedNodeInfo is null) + if (cachedNodeInfoOrNull is not var (node, _, _)) { - foreach (var nodeResult in await batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) + await foreach (var nodeResult in batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) { yield return nodeResult; } } else { - yield return cachedNodeInfo.Value.NodeProvisionResult; + yield return node; } } - foreach (var node in await batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var node in batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) { yield return node; } @@ -133,6 +133,7 @@ public static async IAsyncEnumerable> EnumerateTrashA Author = new Author { EmailAddress = defaultAddress.EmailAddress }, CreationTime = DateTime.UtcNow, OwnedBy = new OwnedBy(Email: defaultAddress.EmailAddress), + Errors = [], }; // The volume root folder never has siblings and does not need a name hash digest @@ -192,12 +193,17 @@ private static VolumeCreationRequest GetCreationRequest( { rootShareKey = CryptoGenerator.GeneratePrivateKey(); + var rootFolderKey = CryptoGenerator.GeneratePrivateKey(); + var rootFolderPassphraseSessionKey = CryptoGenerator.GenerateSessionKey(); + var rootFolderNameSessionKey = CryptoGenerator.GenerateSessionKey(); + var rootFolderHashKey = CryptoGenerator.GenerateFolderHashKey(); + rootFolderSecrets = new FolderSecrets { - Key = CryptoGenerator.GeneratePrivateKey(), - PassphraseSessionKey = CryptoGenerator.GenerateSessionKey(), - NameSessionKey = CryptoGenerator.GenerateSessionKey(), - HashKey = CryptoGenerator.GenerateFolderHashKey(), + Key = rootFolderKey, + PassphraseSessionKey = rootFolderPassphraseSessionKey, + NameSessionKey = rootFolderNameSessionKey, + HashKey = rootFolderHashKey, }; Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; @@ -208,19 +214,20 @@ private static VolumeCreationRequest GetCreationRequest( Span folderPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; var folderPassphrase = CryptoGenerator.GeneratePassphrase(folderPassphraseBuffer); - using var lockedFolderKey = rootFolderSecrets.Key.Lock(folderPassphrase); - var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.PassphraseSessionKey); + using var lockedFolderKey = rootFolderKey.Lock(folderPassphrase); + + var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderPassphraseSessionKey); var encryptedFolderPassphrase = PgpEncrypter.EncryptAndSign( folderPassphrase, folderPassphraseEncryptionSecrets, addressKey, out var folderPassphraseSignature); - var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.NameSessionKey); + var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderNameSessionKey); var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); - var encryptedHashKey = rootFolderSecrets.Key.EncryptAndSign(rootFolderSecrets.HashKey.Span, addressKey); + var encryptedHashKey = rootFolderKey.EncryptAndSign(rootFolderHashKey, addressKey); return new VolumeCreationRequest { @@ -246,12 +253,17 @@ private static PhotosVolumeCreationRequest GetPhotosCreationRequest( { rootShareKey = CryptoGenerator.GeneratePrivateKey(); + var rootFolderKey = CryptoGenerator.GeneratePrivateKey(); + var rootFolderPassphraseSessionKey = CryptoGenerator.GenerateSessionKey(); + var rootFolderNameSessionKey = CryptoGenerator.GenerateSessionKey(); + var rootFolderHashKey = CryptoGenerator.GenerateFolderHashKey(); + rootFolderSecrets = new FolderSecrets { - Key = CryptoGenerator.GeneratePrivateKey(), - PassphraseSessionKey = CryptoGenerator.GenerateSessionKey(), - NameSessionKey = CryptoGenerator.GenerateSessionKey(), - HashKey = CryptoGenerator.GenerateFolderHashKey(), + Key = rootFolderKey, + PassphraseSessionKey = rootFolderPassphraseSessionKey, + NameSessionKey = rootFolderNameSessionKey, + HashKey = rootFolderHashKey, }; Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; @@ -262,19 +274,20 @@ private static PhotosVolumeCreationRequest GetPhotosCreationRequest( Span folderPassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; var folderPassphrase = CryptoGenerator.GeneratePassphrase(folderPassphraseBuffer); - using var lockedFolderKey = rootFolderSecrets.Key.Lock(folderPassphrase); - var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.PassphraseSessionKey); + using var lockedFolderKey = rootFolderKey.Lock(folderPassphrase); + + var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderPassphraseSessionKey); var encryptedFolderPassphrase = PgpEncrypter.EncryptAndSign( folderPassphrase, folderPassphraseEncryptionSecrets, addressKey, out var folderPassphraseSignature); - var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderSecrets.NameSessionKey); + var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderNameSessionKey); var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); - var encryptedHashKey = rootFolderSecrets.Key.EncryptAndSign(rootFolderSecrets.HashKey.Span, addressKey); + var encryptedHashKey = rootFolderKey.EncryptAndSign(rootFolderHashKey, addressKey); return new PhotosVolumeCreationRequest { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs index f4fe7218..724be7d0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -1,13 +1,13 @@ -using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Proton.Cryptography.Pgp; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Nodes; -using Proton.Sdk; namespace Proton.Drive.Sdk.Volumes; internal sealed class VolumeTrashBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey shareKey) - : BatchLoaderBase> + : BatchLoaderBase { private readonly ProtonDriveClient _client = client; private readonly VolumeId _volumeId = volumeId; @@ -15,14 +15,10 @@ internal sealed class VolumeTrashBatchLoader(ProtonDriveClient client, VolumeId private readonly Dictionary _parentKeys = []; - protected override async ValueTask>> LoadBatchAsync( - ReadOnlyMemory ids, - CancellationToken cancellationToken) + protected override async IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, [EnumeratorCancellation] CancellationToken cancellationToken) { var response = await _client.Api.Links.GetDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); - var nodeResults = new List>(ids.Length); - foreach (var linkDetails in response.Links) { PgpPrivateKey parentKey; @@ -34,9 +30,8 @@ protected override async ValueTask>> Lo var folderSecretsResult = await FolderOperations.GetSecretsAsync(_client, new NodeUid(_volumeId, parentId), cancellationToken) .ConfigureAwait(false); - parentKey = folderSecretsResult.TryGetValueElseError(out var folderSecrets, out var degradedFolderSecrets) - ? folderSecrets.Key : degradedFolderSecrets.Key - ?? throw new ProtonDriveException($"Folder key not available for {parentId}"); + // FIXME: This should not throw, but rather return a Result with an appropriate error. + parentKey = folderSecretsResult.Key ?? throw new ProtonDriveException($"Folder key not available for {parentId}"); _parentKeys[parentId] = parentKey; } @@ -46,7 +41,7 @@ protected override async ValueTask>> Lo parentKey = _shareKey; } - var nodeMetadataResult = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + var (node, _, _, _) = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( _client, _volumeId, linkDetails, @@ -54,11 +49,7 @@ protected override async ValueTask>> Lo cancellationToken) .ConfigureAwait(false); - var nodeResult = nodeMetadataResult.ToNodeResult(); - - nodeResults.Add(nodeResult); + yield return node; } - - return nodeResults; } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 9f62d51b..b781bcef 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -131,7 +131,7 @@ message FileNode { string uid = 1; string parent_uid = 2; string tree_event_scope_id = 3; - string name = 4; + StringResult name = 4; string media_type = 5; google.protobuf.Timestamp creation_time = 6; google.protobuf.Timestamp trash_time = 7; // optional @@ -140,18 +140,20 @@ message FileNode { FileRevision active_revision = 10; int64 total_size_on_cloud_storage = 11; OwnedBy owned_by = 12; + repeated DriveError errors = 13; } message FolderNode { string uid = 1; string parent_uid = 2; // optional string tree_event_scope_id = 3; - string name = 4; + StringResult name = 4; google.protobuf.Timestamp creation_time = 5; google.protobuf.Timestamp trash_time = 6; // optional AuthorResult name_author = 7; AuthorResult author = 8; OwnedBy owned_by = 9; + repeated DriveError errors = 10; } message SignatureVerificationError { @@ -424,8 +426,8 @@ message DriveClientEnumerateTrashRequest { int64 cancellation_token_source_handle = 3; } -message TrashChildrenList { - repeated NodeResult children = 1; +message TrashedNodeList { + repeated Node nodes = 1; } // The response must not have a value. @@ -457,25 +459,13 @@ message DriveClientGetMyFilesFolderRequest { int64 cancellation_token_source_handle = 2; } -// The response message must be of type NodeResult (nullable). +// The response message must be of type Node (nullable). message DriveClientGetNodeRequest { int64 client_handle = 1; string node_uid = 2; int64 cancellation_token_source_handle = 3; } -message FolderChildrenList { - repeated NodeResult children = 1; -} - -// Generic result type for Node operations - contains either a successful node or a degraded node with errors -message NodeResult { - oneof result { - Node value = 1; - DegradedNode error = 2; - } -} - message Node { oneof node { FolderNode folder = 1; @@ -483,13 +473,6 @@ message Node { } } -message DegradedNode { - oneof node { - DegradedFolderNode folder = 1; - DegradedFileNode file = 2; - } -} - message DriveError { string message = 1; // optional DriveError inner_error = 2; // optional @@ -502,35 +485,6 @@ message StringResult { } } -message DegradedFolderNode { - string uid = 1; - string parent_uid = 2; // optional - string tree_event_scope_id = 3; - StringResult name = 4; - google.protobuf.Timestamp creation_time = 5; - google.protobuf.Timestamp trash_time = 6; // optional - AuthorResult name_author = 7; - AuthorResult author = 8; - repeated DriveError errors = 9; - OwnedBy owned_by = 10; -} - -message DegradedFileNode { - string uid = 1; - string parent_uid = 2; - string tree_event_scope_id = 3; - StringResult name = 4; - string media_type = 5; - google.protobuf.Timestamp creation_time = 6; - google.protobuf.Timestamp trash_time = 7; // optional - AuthorResult name_author = 8; - AuthorResult author = 9; - DegradedRevision active_revision = 10; // optional - int64 total_storage_quota_usage = 11; - repeated DriveError errors = 12; - OwnedBy owned_by = 13; -} - message FileContentDigests { bytes sha1 = 1; // optional bool sha1_verified = 2; @@ -541,20 +495,6 @@ message ThumbnailHeader { ThumbnailType type = 2; } -message DegradedRevision { - string uid = 1; - google.protobuf.Timestamp creation_time = 2; - int64 size_on_cloud_storage = 3; - int64 claimed_size = 4; // optional - FileContentDigests claimed_digests = 5; // optional - google.protobuf.Timestamp claimed_modification_time = 6; // optional - repeated ThumbnailHeader thumbnails = 7; - repeated AdditionalMetadataProperty additional_claimed_metadata = 8; // optional - AuthorResult content_author = 9; // optional - bool can_decrypt = 10; - repeated DriveError errors = 11; -} - // Drive - downloads // The response value must be an Int64Value carrying a handle to an instance of FileDownloader (or 0 if no_waiting is true and no slot was free). @@ -676,17 +616,13 @@ message DrivePhotosClientEnumerateTimelineRequest { int64 cancellation_token_source_handle = 3; } -// The response message must be of type NodeResult (nullable). +// The response message must be of type Node (nullable). message DrivePhotosClientGetNodeRequest { int64 client_handle = 1; string node_uid = 2; int64 cancellation_token_source_handle = 3; } -message PhotosTimelineList { - repeated PhotosTimelineItem items = 1; -} - message PhotosTimelineItem { string node_uid = 1; google.protobuf.Timestamp capture_time = 2; @@ -818,7 +754,7 @@ message DrivePhotosClientRestoreNodesRequest { int64 cancellation_token_source_handle = 3; } -// The response message must be of type TrashChildrenList +// The response must not have a value, yield_action will be called for each item. message DrivePhotosClientEnumerateTrashRequest { int64 client_handle = 1; int64 yield_action = 3; diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt index aa3653ec..fdc88def 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.Flow import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.FolderNode -import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.Node import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.RevisionUid import java.time.Instant @@ -15,7 +15,7 @@ interface ProtonDriveClient : ProtonSdkClient { suspend fun rename(nodeUid: NodeUid, name: String, mediaType: String? = null) suspend fun createFolder(parentFolderUid: NodeUid, name: String, lastModification: Instant? = null): FolderNode suspend fun getMyFilesFolder(): FolderNode - fun enumerateFolderChildren(folderUid: NodeUid): Flow + fun enumerateFolderChildren(folderUid: NodeUid): Flow suspend fun downloader(revisionUid: RevisionUid, timeout: Duration): Downloader suspend fun uploader(request: FileUploaderRequest, timeout: Duration): Uploader suspend fun uploader(request: FileRevisionUploaderRequest, timeout: Duration): Uploader diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt index 06c5535b..32e83b0c 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt @@ -2,17 +2,17 @@ package me.proton.drive.sdk import kotlinx.coroutines.flow.Flow import me.proton.drive.sdk.entity.FileThumbnail -import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.Node import me.proton.drive.sdk.entity.NodeResultPair import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.ThumbnailType interface ProtonSdkClient : AutoCloseable { fun enumerateThumbnails(nodeUids: List, type: ThumbnailType): Flow - suspend fun getNode(nodeUid: NodeUid): NodeResult? + suspend fun getNode(nodeUid: NodeUid): Node? suspend fun trashNodes(nodeUids: List): List suspend fun deleteNodes(nodeUids: List): List suspend fun restoreNodes(nodeUids: List): List - fun enumerateTrash(): Flow + fun enumerateTrash(): Flow suspend fun emptyTrash() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderChildrenListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderChildrenListConverter.kt deleted file mode 100644 index c7263768..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderChildrenListConverter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.proton.drive.sdk.converter - -import com.google.protobuf.Any -import proton.drive.sdk.ProtonDriveSdk - -class FolderChildrenListConverter : AnyConverter { - override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.FolderChildrenList" - - override fun convert(any: Any): ProtonDriveSdk.FolderChildrenList = - ProtonDriveSdk.FolderChildrenList.parseFrom(any.value) -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderNodeConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderNodeConverter.kt deleted file mode 100644 index 414b8d2f..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/FolderNodeConverter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.proton.drive.sdk.converter - -import com.google.protobuf.Any -import proton.drive.sdk.ProtonDriveSdk - -class FolderNodeConverter : AnyConverter { - override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.FolderNode" - - override fun convert(any: Any): ProtonDriveSdk.FolderNode = - ProtonDriveSdk.FolderNode.parseFrom(any.value) -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeConverter.kt new file mode 100644 index 00000000..fb71d88b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeConverter.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.converter + +import com.google.protobuf.Any +import proton.drive.sdk.ProtonDriveSdk + +class NodeConverter : AnyConverter { + override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.Node" + + override fun convert(any: Any): ProtonDriveSdk.Node = + ProtonDriveSdk.Node.parseFrom(any.value) +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultConverter.kt deleted file mode 100644 index 394a7360..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/NodeResultConverter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.proton.drive.sdk.converter - -import com.google.protobuf.Any -import proton.drive.sdk.ProtonDriveSdk - -class NodeResultConverter : AnyConverter { - override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.NodeResult" - - override fun convert(any: Any): ProtonDriveSdk.NodeResult = - ProtonDriveSdk.NodeResult.parseFrom(any.value) -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/PhotosTimelineListConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/PhotosTimelineListConverter.kt deleted file mode 100644 index 2429d94f..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/PhotosTimelineListConverter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.proton.drive.sdk.converter - -import com.google.protobuf.Any -import proton.drive.sdk.ProtonDriveSdk - -class PhotosTimelineListConverter : AnyConverter { - override val typeUrl: String = "type.googleapis.com/proton.drive.sdk.PhotosTimelineList" - - override fun convert(any: Any): ProtonDriveSdk.PhotosTimelineList = - ProtonDriveSdk.PhotosTimelineList.parseFrom(any.value) -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt deleted file mode 100644 index 7428461d..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFileNode.kt +++ /dev/null @@ -1,18 +0,0 @@ -package me.proton.drive.sdk.entity - -import java.time.Instant - -data class DegradedFileNode( - override val uid: NodeUid, - override val parentUid: ParentNodeUid?, - override val treeEventScopeId: ScopeId, - override val name: Result, - val mediaType: String, - override val creationTime: Instant, - override val trashTime: Instant?, - override val nameAuthor: Result, - override val author: Result, - val activeRevision: DegradedRevision?, - val totalStorageQuotaUsage: Long, - override val errors: List, -) : DegradedNode diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt deleted file mode 100644 index e43c147c..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedFolderNode.kt +++ /dev/null @@ -1,15 +0,0 @@ -package me.proton.drive.sdk.entity - -import java.time.Instant - -data class DegradedFolderNode( - override val uid: NodeUid, - override val parentUid: ParentNodeUid?, - override val treeEventScopeId: ScopeId, - override val name: Result, - override val creationTime: Instant, - override val trashTime: Instant?, - override val nameAuthor: Result, - override val author: Result, - override val errors: List, -) : DegradedNode diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt deleted file mode 100644 index 02937095..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedNode.kt +++ /dev/null @@ -1,15 +0,0 @@ -package me.proton.drive.sdk.entity - -import java.time.Instant - -sealed interface DegradedNode { - val uid: NodeUid - val parentUid: ParentNodeUid? - val treeEventScopeId: ScopeId - val name: Result - val creationTime: Instant - val trashTime: Instant? - val nameAuthor: Result - val author: Result - val errors: List -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt deleted file mode 100644 index ad828418..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DegradedRevision.kt +++ /dev/null @@ -1,17 +0,0 @@ -package me.proton.drive.sdk.entity - -import java.time.Instant - -data class DegradedRevision( - val uid: String, - val creationTime: Instant, - val sizeOnCloudStorage: Long, - val claimedSize: Long?, - val claimedDigests: FileContentDigests?, - val claimedModificationTime: Instant?, - val thumbnails: List, - val additionalClaimedMetadata: List?, - val contentAuthor: Result?, - val canDecrypt: Boolean, - val errors: List, -) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt index 201ed319..dd94dc81 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt @@ -6,7 +6,7 @@ data class FileNode( override val uid: NodeUid, override val parentUid: ParentNodeUid?, override val treeEventScopeId: ScopeId, - override val name: String, + override val name: Result, val mediaType: String, override val creationTime: Instant, override val trashTime: Instant?, @@ -14,4 +14,5 @@ data class FileNode( override val author: Result, val activeRevision: FileRevision, val totalSizeOnCloudStorage: Long, + override val errors: List, ) : Node diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt index b92360cb..77d85bf3 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt @@ -7,7 +7,7 @@ data class FileRevision( val creationTime: Instant, val sizeOnCloudStorage: Long, val claimedSize: Long?, - val claimedDigests: FileContentDigests, + val claimedDigests: FileContentDigests?, val claimedModificationTime: Instant?, val thumbnails: List, val additionalClaimedMetadata: List?, diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt index ff8b6728..9e809703 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt @@ -6,10 +6,10 @@ data class FolderNode( override val uid: NodeUid, override val parentUid: ParentNodeUid?, override val treeEventScopeId: ScopeId, - override val name: String, + override val name: Result, override val creationTime: Instant, override val trashTime: Instant?, override val nameAuthor: Result, override val author: Result, + override val errors: List, ) : Node - diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt index af311f7d..40737090 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt @@ -6,9 +6,10 @@ sealed interface Node { val uid: NodeUid val parentUid: ParentNodeUid? val treeEventScopeId: ScopeId - val name: String + val name: Result val creationTime: Instant val trashTime: Instant? val nameAuthor: Result val author: Result + val errors: List } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResult.kt deleted file mode 100644 index a25fcfbf..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResult.kt +++ /dev/null @@ -1,6 +0,0 @@ -package me.proton.drive.sdk.entity - -sealed interface NodeResult { - data class Value(val node: Node) : NodeResult - data class Error(val node: DegradedNode) : NodeResult -} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt deleted file mode 100644 index f6083c8c..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFileNode.kt +++ /dev/null @@ -1,24 +0,0 @@ -package me.proton.drive.sdk.extension - -import me.proton.drive.sdk.entity.DegradedFileNode -import me.proton.drive.sdk.entity.NodeUid -import me.proton.drive.sdk.entity.ParentNodeUid -import me.proton.drive.sdk.entity.ScopeId -import proton.drive.sdk.ProtonDriveSdk -import proton.drive.sdk.activeRevisionOrNull -import proton.drive.sdk.trashTimeOrNull - -fun ProtonDriveSdk.DegradedFileNode.toEntity() = DegradedFileNode( - uid = NodeUid(uid), - parentUid = parentUid.takeIf { hasParentUid() }?.let(::ParentNodeUid), - treeEventScopeId = ScopeId(treeEventScopeId), - name = name.toEntity(), - mediaType = mediaType, - creationTime = creationTime.toInstant(), - trashTime = trashTimeOrNull?.toInstant(), - nameAuthor = nameAuthor.toEntity(), - author = author.toEntity(), - activeRevision = activeRevisionOrNull?.toEntity(), - totalStorageQuotaUsage = totalStorageQuotaUsage, - errors = errorsList.map { it.toEntity() }, -) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt deleted file mode 100644 index 6e1662df..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedFolderNode.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.proton.drive.sdk.extension - -import me.proton.drive.sdk.entity.DegradedFolderNode -import me.proton.drive.sdk.entity.NodeUid -import me.proton.drive.sdk.entity.ParentNodeUid -import me.proton.drive.sdk.entity.ScopeId -import proton.drive.sdk.ProtonDriveSdk -import proton.drive.sdk.trashTimeOrNull - -fun ProtonDriveSdk.DegradedFolderNode.toEntity() = DegradedFolderNode( - uid = NodeUid(uid), - parentUid = parentUid.takeIf { hasParentUid() }?.let(::ParentNodeUid), - treeEventScopeId = ScopeId(treeEventScopeId), - name = name.toEntity(), - creationTime = creationTime.toInstant(), - trashTime = trashTimeOrNull?.toInstant(), - nameAuthor = nameAuthor.toEntity(), - author = author.toEntity(), - errors = errorsList.map { it.toEntity() }, -) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt deleted file mode 100644 index c8310a9c..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DegradedRevision.kt +++ /dev/null @@ -1,23 +0,0 @@ -package me.proton.drive.sdk.extension - -import me.proton.drive.sdk.entity.DegradedRevision -import proton.drive.sdk.ProtonDriveSdk -import proton.drive.sdk.claimedDigestsOrNull -import proton.drive.sdk.claimedModificationTimeOrNull -import proton.drive.sdk.contentAuthorOrNull - -fun ProtonDriveSdk.DegradedRevision.toEntity() = DegradedRevision( - uid = uid, - creationTime = creationTime.toInstant(), - sizeOnCloudStorage = sizeOnCloudStorage, - claimedSize = if (hasClaimedSize()) claimedSize else null, - claimedDigests = claimedDigestsOrNull?.toEntity(), - claimedModificationTime = claimedModificationTimeOrNull?.toInstant(), - thumbnails = thumbnailsList.map { it.toEntity() }, - additionalClaimedMetadata = if (additionalClaimedMetadataList.isNotEmpty()) { - additionalClaimedMetadataList.map { it.toEntity() } - } else null, - contentAuthor = contentAuthorOrNull?.toEntity(), - canDecrypt = canDecrypt, - errors = errorsList.map { it.toEntity() }, -) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt index 4d6e25da..6d9b53fc 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt @@ -11,7 +11,7 @@ fun ProtonDriveSdk.FileNode.toEntity() = FileNode( uid = NodeUid(uid), parentUid = parentUid.takeIf { hasParentUid() }?.let(::ParentNodeUid), treeEventScopeId = ScopeId(treeEventScopeId), - name = name, + name = name.toEntity(), mediaType = mediaType, creationTime = creationTime.toInstant(), trashTime = trashTimeOrNull?.toInstant(), @@ -19,4 +19,5 @@ fun ProtonDriveSdk.FileNode.toEntity() = FileNode( author = author.toEntity(), activeRevision = activeRevision.toEntity(), totalSizeOnCloudStorage = totalSizeOnCloudStorage, + errors = errorsList.map { it.toEntity() }, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderChildrenList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderChildrenList.kt deleted file mode 100644 index 033a0331..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderChildrenList.kt +++ /dev/null @@ -1,7 +0,0 @@ -package me.proton.drive.sdk.extension - -import me.proton.drive.sdk.entity.NodeResult -import proton.drive.sdk.ProtonDriveSdk - -fun ProtonDriveSdk.FolderChildrenList.toEntity(): List = - childrenList.map { it.toEntity() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt index 49f8687e..51546928 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt @@ -11,9 +11,10 @@ fun ProtonDriveSdk.FolderNode.toEntity() = FolderNode( uid = NodeUid(uid), parentUid = parentUid.takeIf { hasParentUid() }?.let(::ParentNodeUid), treeEventScopeId = ScopeId(treeEventScopeId), - name = name, + name = name.toEntity(), creationTime = creationTime.toInstant(), trashTime = trashTimeOrNull?.toInstant(), nameAuthor = nameAuthor.toEntity(), - author = author.toEntity() + author = author.toEntity(), + errors = errorsList.map { it.toEntity() }, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt similarity index 62% rename from kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt rename to kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt index 38ca6c91..df1b8e9f 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt @@ -3,16 +3,21 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.ProtonDriveException import me.proton.drive.sdk.ProtonDriveSdkException import me.proton.drive.sdk.entity.DriveError -import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.Node -fun NodeResult.getOrThrow(): NodeResult.Value = when (this) { - is NodeResult.Value -> this - is NodeResult.Error -> throw node.errors.toException("Node failure") -} +fun Node.getNameOrNull(): String? = name.getOrNull() + +fun Node.requireName(): String = + name.getOrElse { throw errors.toException("Node name unavailable") } -fun NodeResult.getOrNull(): NodeResult.Value? = when (this) { - is NodeResult.Value -> this - is NodeResult.Error -> null +fun Node.requireFullyProvisioned(): Node { + if (name.isFailure) { + throw name.exceptionOrNull() ?: errors.toException("Node name unavailable") + } + if (errors.isNotEmpty()) { + throw errors.toException("Node failure") + } + return this } private fun List.toException(message: String) = ProtonDriveSdkException(message).apply { @@ -25,18 +30,18 @@ private fun List.toException(message: String) = ProtonDriveSdkExcept message = it.message, cause = it.innerError?.toException(), ) - } - ) + }, + ), ) } } fun DriveError.toException(message: String): ProtonDriveSdkException = ProtonDriveSdkException( message = message, - cause = toException() + cause = toException(), ) fun DriveError.toException(): ProtonDriveException = ProtonDriveException( message = message, - cause = innerError?.toException() + cause = innerError?.toException(), ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt index c858f651..4abfb663 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt @@ -4,9 +4,6 @@ import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.PhotosTimelineItem import proton.drive.sdk.ProtonDriveSdk -fun ProtonDriveSdk.PhotosTimelineList.toEntity(): List = - itemsList.map { it.toEntity() } - fun ProtonDriveSdk.PhotosTimelineItem.toEntity() = PhotosTimelineItem( nodeUid = NodeUid(nodeUid), captureTime = captureTime.toInstant(), diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNode.kt new file mode 100644 index 00000000..82b7b41e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNode.kt @@ -0,0 +1,17 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.Node +import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.folderOrNull + +fun ProtonDriveSdk.Node.toEntity(): Node = + when (nodeCase) { + ProtonDriveSdk.Node.NodeCase.FOLDER -> folder.toEntity() + ProtonDriveSdk.Node.NodeCase.FILE -> file.toEntity() + ProtonDriveSdk.Node.NodeCase.NODE_NOT_SET, null -> + error("Invalid Node: node not set") + } + +fun ProtonDriveSdk.Node.toFolder(): ProtonDriveSdk.FolderNode = checkNotNull(folderOrNull) { + "Node must be a folder, not $nodeCase" +} diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNodeResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNodeResult.kt deleted file mode 100644 index 82b78760..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonDriveSdkNodeResult.kt +++ /dev/null @@ -1,31 +0,0 @@ -package me.proton.drive.sdk.extension - -import me.proton.drive.sdk.entity.DegradedNode -import me.proton.drive.sdk.entity.Node -import me.proton.drive.sdk.entity.NodeResult -import proton.drive.sdk.ProtonDriveSdk - - -fun ProtonDriveSdk.NodeResult.toEntity(): NodeResult = - when (resultCase) { - ProtonDriveSdk.NodeResult.ResultCase.VALUE -> NodeResult.Value(value.toEntity()) - ProtonDriveSdk.NodeResult.ResultCase.ERROR -> NodeResult.Error(error.toEntity()) - ProtonDriveSdk.NodeResult.ResultCase.RESULT_NOT_SET, null -> - error("Invalid NodeResult: result not set") - } - -fun ProtonDriveSdk.Node.toEntity(): Node = - when (nodeCase) { - ProtonDriveSdk.Node.NodeCase.FOLDER -> folder.toEntity() - ProtonDriveSdk.Node.NodeCase.FILE -> file.toEntity() - ProtonDriveSdk.Node.NodeCase.NODE_NOT_SET, null -> - error("Invalid Node: result not set") - } - -fun ProtonDriveSdk.DegradedNode.toEntity(): DegradedNode = - when (nodeCase) { - ProtonDriveSdk.DegradedNode.NodeCase.FOLDER -> folder.toEntity() - ProtonDriveSdk.DegradedNode.NodeCase.FILE -> file.toEntity() - ProtonDriveSdk.DegradedNode.NodeCase.NODE_NOT_SET, null -> - error("Invalid DegradedNode: result not set") - } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt index 0ed32436..a1fc8b82 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt @@ -3,6 +3,7 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.entity.FileRevision import me.proton.drive.sdk.entity.RevisionUid import proton.drive.sdk.ProtonDriveSdk +import proton.drive.sdk.claimedDigestsOrNull import proton.drive.sdk.claimedModificationTimeOrNull import proton.drive.sdk.contentAuthorOrNull @@ -11,7 +12,7 @@ fun ProtonDriveSdk.FileRevision.toEntity() = FileRevision( creationTime = creationTime.toInstant(), sizeOnCloudStorage = sizeOnCloudStorage, claimedSize = if (hasClaimedSize()) claimedSize else null, - claimedDigests = claimedDigests.toEntity(), + claimedDigests = claimedDigestsOrNull?.toEntity(), claimedModificationTime = claimedModificationTimeOrNull?.toInstant(), thumbnails = thumbnailsList.map { it.toEntity() }, additionalClaimedMetadata = if (additionalClaimedMetadataList.isNotEmpty()) { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt index 4fc9c574..f6ce2fdf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt @@ -1,6 +1,5 @@ package me.proton.drive.sdk.extension -import me.proton.drive.sdk.ProtonDriveException import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.StringResult.toEntity(): Result = @@ -9,9 +8,7 @@ fun ProtonDriveSdk.StringResult.toEntity(): Result = Result.success(value) ProtonDriveSdk.StringResult.ResultCase.ERROR -> - Result.failure( - ProtonDriveException(error.message) - ) + Result.failure(error.toEntity().toException("Name unavailable")) ProtonDriveSdk.StringResult.ResultCase.RESULT_NOT_SET, null -> error("Invalid StringResult: result not set") diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashChildrenList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashChildrenList.kt deleted file mode 100644 index 6fbca875..00000000 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/TrashChildrenList.kt +++ /dev/null @@ -1,7 +0,0 @@ -package me.proton.drive.sdk.extension - -import me.proton.drive.sdk.entity.NodeResult -import proton.drive.sdk.ProtonDriveSdk - -fun ProtonDriveSdk.TrashChildrenList.toEntity(): List = - childrenList.map { it.toEntity() } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt index 59329982..deb9c610 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt @@ -17,7 +17,7 @@ import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileThumbnail import me.proton.drive.sdk.entity.FileUploaderRequest import me.proton.drive.sdk.entity.FolderNode -import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.Node import me.proton.drive.sdk.entity.NodeResultPair import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.RevisionUid @@ -131,7 +131,7 @@ internal class InteropProtonDriveClient internal constructor( override fun enumerateFolderChildren( folderUid: NodeUid, - ): Flow = channelFlow { + ): Flow = channelFlow { log(DEBUG, "enumerateFolderChildren") cancellationCoroutineScope { source -> bridge.enumerateFolderChildren( @@ -142,8 +142,8 @@ internal class InteropProtonDriveClient internal constructor( cancellationTokenSourceHandle = source.handle yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() }, - yield = { nodeResult -> - send(nodeResult.toEntity()) + yield = { node -> + send(node.toEntity()) } ) } @@ -151,7 +151,7 @@ internal class InteropProtonDriveClient internal constructor( override suspend fun getNode( nodeUid: NodeUid, - ): NodeResult? = cancellationCoroutineScope { source -> + ): Node? = cancellationCoroutineScope { source -> log(DEBUG, "getNode") bridge.getNode( driveClientGetNodeRequest { @@ -201,7 +201,7 @@ internal class InteropProtonDriveClient internal constructor( ).toEntity() } - override fun enumerateTrash(): Flow = channelFlow { + override fun enumerateTrash(): Flow = channelFlow { log(DEBUG, "enumerateTrash") cancellationCoroutineScope { source -> bridge.enumerateTrash( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt index f3e40343..03c3effd 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt @@ -14,7 +14,7 @@ import me.proton.drive.sdk.SdkNode import me.proton.drive.sdk.Session import me.proton.drive.sdk.Uploader import me.proton.drive.sdk.entity.FileThumbnail -import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.Node import me.proton.drive.sdk.entity.NodeResultPair import me.proton.drive.sdk.entity.NodeUid import me.proton.drive.sdk.entity.PhotosTimelineItem @@ -79,7 +79,7 @@ internal class InteropProtonPhotosClient internal constructor( override suspend fun getNode( nodeUid: NodeUid, - ): NodeResult? = cancellationCoroutineScope { source -> + ): Node? = cancellationCoroutineScope { source -> log(DEBUG, "getNode") bridge.getNode( drivePhotosClientGetNodeRequest { @@ -129,7 +129,7 @@ internal class InteropProtonPhotosClient internal constructor( ).toEntity() } - override fun enumerateTrash(): Flow = channelFlow { + override fun enumerateTrash(): Flow = channelFlow { log(DEBUG, "enumerateTrash") cancellationCoroutineScope { source -> bridge.enumerateTrash( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt index 9688ecc3..8bf2dade 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -3,16 +3,16 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.ProducerScope -import me.proton.drive.sdk.converter.NodeResultConverter +import me.proton.drive.sdk.converter.NodeConverter import me.proton.drive.sdk.converter.NodeResultListResponseConverter -import me.proton.drive.sdk.converter.FolderNodeConverter import me.proton.drive.sdk.entity.ClientCreateRequest -import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.Node import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.StringResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback import me.proton.drive.sdk.extension.asCallback import me.proton.drive.sdk.extension.asNullableCallback +import me.proton.drive.sdk.extension.toFolder import me.proton.drive.sdk.extension.toLongResponse import proton.drive.sdk.ProtonDriveSdk import proton.drive.sdk.driveClientCreateFromSessionRequest @@ -109,32 +109,32 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun createFolder( request: ProtonDriveSdk.DriveClientCreateFolderRequest, - ): ProtonDriveSdk.FolderNode = executeOnce("createFolder", FolderNodeConverter().asCallback) { + ): ProtonDriveSdk.FolderNode = executeOnce("createFolder", NodeConverter().asCallback) { driveClientCreateFolder = request - } + }.toFolder() suspend fun getMyFilesFolder( request: ProtonDriveSdk.DriveClientGetMyFilesFolderRequest, - ): ProtonDriveSdk.FolderNode = executeOnce("getMyFilesFolder", FolderNodeConverter().asCallback) { + ): ProtonDriveSdk.FolderNode = executeOnce("getMyFilesFolder", NodeConverter().asCallback) { driveClientGetMyFilesFolder = request - } + }.toFolder() suspend fun getNode( request: ProtonDriveSdk.DriveClientGetNodeRequest, - ): ProtonDriveSdk.NodeResult? = - executeOnce("getNode", NodeResultConverter().asNullableCallback) { + ): ProtonDriveSdk.Node? = + executeOnce("getNode", NodeConverter().asNullableCallback) { driveClientGetNode = request } suspend fun enumerateFolderChildren( coroutineScope: CoroutineScope, request: ProtonDriveSdk.DriveClientEnumerateFolderChildrenRequest, - yield: suspend (ProtonDriveSdk.NodeResult) -> Unit, + yield: suspend (ProtonDriveSdk.Node) -> Unit, ): Unit = executeEnumerate( name = "enumerateFolderChildren", callback = UnitResponseCallback, yield = yield, - parser = ProtonDriveSdk.NodeResult::parseFrom, + parser = ProtonDriveSdk.Node::parseFrom, coroutineScopeProvider = { coroutineScope }, ) { driveClientEnumerateFolderChildren = request @@ -162,14 +162,14 @@ class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { } suspend fun enumerateTrash( - coroutineScope: ProducerScope, + coroutineScope: ProducerScope, request: ProtonDriveSdk.DriveClientEnumerateTrashRequest, - yield: suspend (ProtonDriveSdk.NodeResult) -> Unit, + yield: suspend (ProtonDriveSdk.Node) -> Unit, ): Unit = executeEnumerate( name = "enumerateTrash", callback = UnitResponseCallback, yield = yield, - parser = ProtonDriveSdk.NodeResult::parseFrom, + parser = ProtonDriveSdk.Node::parseFrom, coroutineScopeProvider = { coroutineScope } ) { driveClientEnumerateTrash = request diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt index bf1df5ca..22654660 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -3,10 +3,10 @@ package me.proton.drive.sdk.internal import com.google.protobuf.Any import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.ProducerScope -import me.proton.drive.sdk.converter.NodeResultConverter +import me.proton.drive.sdk.converter.NodeConverter import me.proton.drive.sdk.converter.NodeResultListResponseConverter import me.proton.drive.sdk.entity.ClientCreateRequest -import me.proton.drive.sdk.entity.NodeResult +import me.proton.drive.sdk.entity.Node import me.proton.drive.sdk.extension.LongResponseCallback import me.proton.drive.sdk.extension.UnitResponseCallback import me.proton.drive.sdk.extension.asCallback @@ -108,8 +108,8 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { suspend fun getNode( request: ProtonDriveSdk.DrivePhotosClientGetNodeRequest, - ): ProtonDriveSdk.NodeResult? = - executeOnce("getNode", NodeResultConverter().asNullableCallback) { + ): ProtonDriveSdk.Node? = + executeOnce("getNode", NodeConverter().asNullableCallback) { drivePhotosClientGetNode = request } @@ -135,14 +135,14 @@ class JniProtonPhotosClient internal constructor() : JniBaseProtonDriveSdk() { } suspend fun enumerateTrash( - coroutineScope: ProducerScope, + coroutineScope: ProducerScope, request: ProtonDriveSdk.DrivePhotosClientEnumerateTrashRequest, - yield: suspend (ProtonDriveSdk.NodeResult) -> Unit, + yield: suspend (ProtonDriveSdk.Node) -> Unit, ): Unit = executeEnumerate( name = "enumerateTrash", callback = UnitResponseCallback, yield = yield, - parser = ProtonDriveSdk.NodeResult::parseFrom, + parser = ProtonDriveSdk.Node::parseFrom, coroutineScopeProvider = { coroutineScope } ) { drivePhotosClientEnumerateTrash = request diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift index 8fedbd56..0bda1a15 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -405,7 +405,10 @@ extension ProtonDriveClient { $0.cancellationTokenSourceHandle = Int64(cancellationHandle) } - let sdkFolderNode: Proton_Drive_Sdk_FolderNode = try await SDKRequestHandler.send(createFolderRequest, logger: logger) + let sdkNode: Proton_Drive_Sdk_Node = try await SDKRequestHandler.send(createFolderRequest, logger: logger) + guard case .folder(let sdkFolderNode) = sdkNode.node else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "createFolder expected FolderNode, got \(sdkNode.node as Any)")) + } return try FolderNode(sdkFolderNode: sdkFolderNode) } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 55183ae6..4b21320d 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -127,14 +127,29 @@ public struct AdditionalMetadata: Sendable { } } +private struct StringResultParser { + func parse(_ result: Proton_Drive_Sdk_StringResult) -> Result { + switch result.result { + case .value(let string): + return .success(string) + case .error(let error): + return .failure(.init(error: error)) + case .none: + assertionFailure("Unexpected case") + return .failure(.init(message: "no value or error set")) + } + } +} + public struct FolderNode: Sendable { public let uid: SDKNodeUid public let parentUid: SDKNodeUid? - public let name: String + public let name: Result public let creationTime: Double public let trashTime: Double? public let nameAuthor: Author public let author: Author + public let errors: [ProtonDriveSDKDriveError] init(sdkFolderNode: Proton_Drive_Sdk_FolderNode) throws { guard let uid = SDKNodeUid(sdkCompatibleIdentifier: sdkFolderNode.uid) else { @@ -142,11 +157,12 @@ public struct FolderNode: Sendable { } self.uid = uid self.parentUid = sdkFolderNode.hasParentUid ? .init(sdkCompatibleIdentifier: sdkFolderNode.parentUid) : nil - self.name = sdkFolderNode.name + self.name = StringResultParser().parse(sdkFolderNode.name) self.creationTime = sdkFolderNode.creationTime.timeIntervalSince1970 - self.trashTime = sdkFolderNode.trashTime.timeIntervalSince1970 + self.trashTime = sdkFolderNode.hasTrashTime ? sdkFolderNode.trashTime.timeIntervalSince1970 : nil self.nameAuthor = Author(result: sdkFolderNode.nameAuthor) self.author = Author(result: sdkFolderNode.author) + self.errors = sdkFolderNode.errors.map { ProtonDriveSDKDriveError(error: $0) } } } @@ -173,18 +189,20 @@ public struct Author: Sendable { public struct FileNode: Sendable { let uid: String let parentUid: String - let name: String + public let name: Result let mediaType: String let totalSizeOnCloudStorage: Int64 let activeRevision: FileRevision + let errors: [ProtonDriveSDKDriveError] init(sdkFileNode: Proton_Drive_Sdk_FileNode) { self.uid = sdkFileNode.uid self.parentUid = sdkFileNode.parentUid - self.name = sdkFileNode.name + self.name = StringResultParser().parse(sdkFileNode.name) self.mediaType = sdkFileNode.mediaType self.totalSizeOnCloudStorage = sdkFileNode.totalSizeOnCloudStorage self.activeRevision = FileRevision(sdkFileRevision: sdkFileNode.activeRevision) + self.errors = sdkFileNode.errors.map { ProtonDriveSDKDriveError(error: $0) } } } @@ -199,8 +217,26 @@ public struct FileRevision: Sendable { self.uid = sdkFileRevision.uid self.creationTime = sdkFileRevision.creationTime.timeIntervalSince1970 self.sizeOnCloudStorage = sdkFileRevision.sizeOnCloudStorage - self.claimedSize = sdkFileRevision.claimedSize - self.claimedModificationTime = sdkFileRevision.claimedModificationTime.timeIntervalSince1970 + self.claimedSize = sdkFileRevision.hasClaimedSize ? sdkFileRevision.claimedSize : nil + self.claimedModificationTime = sdkFileRevision.hasClaimedModificationTime + ? sdkFileRevision.claimedModificationTime.timeIntervalSince1970 + : nil + } +} + +public enum DriveNode: Sendable { + case folder(FolderNode) + case file(FileNode) + + init(sdkNode: Proton_Drive_Sdk_Node) throws { + switch sdkNode.node { + case .folder(let folder): + self = .folder(try FolderNode(sdkFolderNode: folder)) + case .file(let file): + self = .file(try FileNode(sdkFileNode: file)) + case .none: + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Invalid Node: no folder or file set")) + } } } diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift index 3094148c..cb87cf04 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -199,17 +199,10 @@ let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in } uploadResultBox.resume(returning: unpackedValue) - case .value(let value) where value.isA(Proton_Drive_Sdk_PhotosTimelineList.self): - let unpackedValue = try Proton_Drive_Sdk_PhotosTimelineList(unpackingAny: value) - guard let uploadResultBox = box as? any Resumable else { - throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) - } - uploadResultBox.resume(returning: unpackedValue) - - case .value(let value) where value.isA(Proton_Drive_Sdk_FolderNode.self): - let unpackedValue = try Proton_Drive_Sdk_FolderNode(unpackingAny: value) - guard let resultBox = box as? any Resumable else { - throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) + case .value(let value) where value.isA(Proton_Drive_Sdk_Node.self): + let unpackedValue = try Proton_Drive_Sdk_Node(unpackingAny: value) + guard let resultBox = box as? any Resumable else { + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Received unexpected state in the response. We expected Resumable, we got \(type(of: box))")) } resultBox.resume(returning: unpackedValue) From 0d0b2640da76d14c37c0d43c7119a8c14f3af420 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 07:25:53 +0000 Subject: [PATCH 775/791] Fix interop account client requesting empty address instead of default address --- cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs index 226731a2..3674e2c8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs @@ -25,7 +25,7 @@ public async ValueTask
GetDefaultAddressAsync(CancellationToken cancell { var response = await _requestAction.SendRequestAsync( _bindingsHandle, - new AccountRequest { GetAddress = new GetAddressRequest() }).ConfigureAwait(false); + new AccountRequest { GetDefaultAddress = new GetDefaultAddressRequest() }).ConfigureAwait(false); return ConvertToAddress(response); } From 854cd6072e43de0c0ff1d5dd814af1af3ef00647 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 May 2026 15:46:37 +0200 Subject: [PATCH 776/791] Do not close the input stream in Swift's StreamForUpload --- .../Networking/Model/StreamForUpload.swift | 4 +- .../Sources/Plumbing/PublicTypes.swift | 99 ++++++++++++++++--- .../TelemetryAndLogging/LoggerTypes.swift | 9 +- 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift index bf00df78..b56b82c1 100644 --- a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift @@ -26,6 +26,8 @@ public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendabl private var remainingBytes: [UInt8] = [] private let writingQueue = DispatchQueue(label: "StreamForUpload.WritingQueue", qos: .userInitiated) + /// `inputStream`'s lifecycle is owned by URLSession (it opens, reads, and closes it). + /// Only `outputStream`'s lifecycle is owned by this class. init(inputStream: InputStream, outputStream: OutputStream, bufferLength: Int, sdkContentHandle: Int64, logger: Logger) throws { self.bufferLength = bufferLength self.sdkContentHandle = sdkContentHandle @@ -174,7 +176,7 @@ public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendabl } guard shouldClose else { return } output.close() - input.close() + // input is opened by URLSession (Apple Forum 76675); not the producer's to close. } deinit { diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift index 4b21320d..48bc1e41 100644 --- a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -151,6 +151,25 @@ public struct FolderNode: Sendable { public let author: Author public let errors: [ProtonDriveSDKDriveError] + public init(uid: SDKNodeUid, + parentUid: SDKNodeUid?, + name: Result, + creationTime: Double, + trashTime: Double?, + nameAuthor: Author, + author: Author, + errors: [ProtonDriveSDKDriveError]) + { + self.uid = uid + self.parentUid = parentUid + self.name = name + self.creationTime = creationTime + self.trashTime = trashTime + self.nameAuthor = nameAuthor + self.author = author + self.errors = errors + } + init(sdkFolderNode: Proton_Drive_Sdk_FolderNode) throws { guard let uid = SDKNodeUid(sdkCompatibleIdentifier: sdkFolderNode.uid) else { throw ProtonDriveSDKError(interopError: .incorrectIDFormat(id: sdkFolderNode.uid)) @@ -168,8 +187,13 @@ public struct FolderNode: Sendable { // FIXME: Preserve distinction between verified and claimed email addresses to match original interface. public struct Author: Sendable { - let emailAddress: String? - let signatureVerificationError: String? + public let emailAddress: String? + public let signatureVerificationError: String? + + public init(emailAddress: String?, signatureVerificationError: String?) { + self.emailAddress = emailAddress + self.signatureVerificationError = signatureVerificationError + } init(result: Proton_Drive_Sdk_AuthorResult) { switch result.result { @@ -187,13 +211,29 @@ public struct Author: Sendable { } public struct FileNode: Sendable { - let uid: String - let parentUid: String + public let uid: String + public let parentUid: String public let name: Result - let mediaType: String - let totalSizeOnCloudStorage: Int64 - let activeRevision: FileRevision - let errors: [ProtonDriveSDKDriveError] + public let mediaType: String + public let totalSizeOnCloudStorage: Int64 + public let activeRevision: FileRevision + public let errors: [ProtonDriveSDKDriveError] + + public init(uid: String, + parentUid: String, + name: Result, + mediaType: String, + totalSizeOnCloudStorage: Int64, + activeRevision: FileRevision, + errors: [ProtonDriveSDKDriveError]) { + self.uid = uid + self.parentUid = parentUid + self.name = name + self.mediaType = mediaType + self.totalSizeOnCloudStorage = totalSizeOnCloudStorage + self.activeRevision = activeRevision + self.errors = errors + } init(sdkFileNode: Proton_Drive_Sdk_FileNode) { self.uid = sdkFileNode.uid @@ -207,11 +247,23 @@ public struct FileNode: Sendable { } public struct FileRevision: Sendable { - let uid: String - let creationTime: Double - let sizeOnCloudStorage: Int64 - let claimedSize: Int64? - let claimedModificationTime: Double? + public let uid: String + public let creationTime: Double + public let sizeOnCloudStorage: Int64 + public let claimedSize: Int64? + public let claimedModificationTime: Double? + + public init(uid: String, + creationTime: Double, + sizeOnCloudStorage: Int64, + claimedSize: Int64?, + claimedModificationTime: Double?) { + self.uid = uid + self.creationTime = creationTime + self.sizeOnCloudStorage = sizeOnCloudStorage + self.claimedSize = claimedSize + self.claimedModificationTime = claimedModificationTime + } init(sdkFileRevision: Proton_Drive_Sdk_FileRevision) { self.uid = sdkFileRevision.uid @@ -244,6 +296,11 @@ public struct UploadedFileIdentifiers: Sendable { public let nodeUid: SDKNodeUid public let revisionUid: SDKRevisionUid + public init(nodeUid: SDKNodeUid, revisionUid: SDKRevisionUid) { + self.nodeUid = nodeUid + self.revisionUid = revisionUid + } + init?(interopUploadResult: Proton_Drive_Sdk_UploadResult) { guard let nodeUid = SDKNodeUid(sdkCompatibleIdentifier: interopUploadResult.nodeUid), let revisionUid = SDKRevisionUid(sdkCompatibleIdentifier: interopUploadResult.revisionUid) @@ -257,6 +314,11 @@ public struct PhotoTimelineItem: Sendable { public let nodeUid: SDKNodeUid public let captureTime: Double + public init(nodeUid: SDKNodeUid, captureTime: Double) { + self.nodeUid = nodeUid + self.captureTime = captureTime + } + init?(item: Proton_Drive_Sdk_PhotosTimelineItem) { guard let nodeUid = SDKNodeUid(sdkCompatibleIdentifier: item.nodeUid) else { return nil } self.nodeUid = nodeUid @@ -267,6 +329,11 @@ public struct PhotoTimelineItem: Sendable { public struct TrashNodeResult: Sendable { public let nodeUid: SDKNodeUid public let error: ProtonDriveSDKError? + + public init(nodeUid: SDKNodeUid, error: ProtonDriveSDKError?) { + self.nodeUid = nodeUid + self.error = error + } } /// Callback for progress updates @@ -301,6 +368,12 @@ public struct ThumbnailDataWithId: Sendable { public let fileUid: SDKNodeUid public let result: Result + public init(fileUid: SDKNodeUid, + result: Result) { + self.fileUid = fileUid + self.result = result + } + init?(fileThumbnail: Proton_Drive_Sdk_FileThumbnail) { guard let fileUid = SDKNodeUid(sdkCompatibleIdentifier: fileThumbnail.fileUid) else { return nil diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/LoggerTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/LoggerTypes.swift index 7f5ee8b6..ce4a4588 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/LoggerTypes.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/LoggerTypes.swift @@ -11,7 +11,14 @@ public struct LogEvent: Sendable { public let function: String public let line: UInt - public init(level: LogLevel, message: String, category: String, timestamp: Date = .now, thread: UInt, file: String, function: String, line: UInt) { + public init(level: LogLevel, + message: String, + category: String, + timestamp: Date = .now, + thread: UInt, + file: String, + function: String, + line: UInt) { self.level = level self.message = message self.category = category From b8ca745b1943fd18418321b3c72b26f64461a417 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 08:42:19 +0000 Subject: [PATCH 777/791] Publish Swift SDK to separate GitHub repository --- README.md | 3 ++- swift/ProtonDriveSDK/Package.swift | 32 +++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b61a7e93..6b5206a5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ The Proton Drive SDK provides a high-level interface for interacting with Proton - **TypeScript** — native SDK in [`js/sdk/`](./js/sdk/), available on npm as [`@protontech/drive-sdk`](https://www.npmjs.com/package/@protontech/drive-sdk). See [changelog](./js/CHANGELOG.md) for changes. - **C#** — native SDK in [`cs/sdk/`](./cs/sdk/). See [changelog](./cs/CHANGELOG.md) for changes. -- **Kotlin** and **Swift** — bindings that wrap the C# SDK (see [`kt/`](./kt/) and [`swift/ProtonDriveSDK/`](./swift/ProtonDriveSDK/)). See [changelog](./cs/CHANGELOG.md) for changes to the C# SDK. +- **Kotlin** — bindings that wrap the C# SDK in [`kt/`](./kt/). See [changelog](./cs/CHANGELOG.md) for changes to the C# SDK. +- **Swift** - bindings that wrap the C# SDK in [`swift/ProtonDriveSDK/`](./swift/ProtonDriveSDK/), available on github as [`sdk-swift`](https://github.com/ProtonDriveApps/sdk-swift). See [changelog](./cs/CHANGELOG.md) for changes to the C# SDK. ### Who this is for diff --git a/swift/ProtonDriveSDK/Package.swift b/swift/ProtonDriveSDK/Package.swift index ff550684..af8cf1da 100644 --- a/swift/ProtonDriveSDK/Package.swift +++ b/swift/ProtonDriveSDK/Package.swift @@ -2,7 +2,6 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription -import Foundation let package = Package( name: "ProtonDriveSDK", @@ -17,6 +16,10 @@ let package = Package( name: "ProtonDriveSDK", targets: ["ProtonDriveSDK"] ), + .library( + name: "ProtonDriveSDKTestingToolkit", + targets: ["ProtonDriveSDKTestingToolkit"] + ), ], dependencies: [ .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.33.3"), @@ -26,7 +29,8 @@ let package = Package( targets: [ .binaryTarget( name: "CProtonDriveSDK", - path: "./Libraries/CProtonDriveSDK.xcframework" + url: "https://github.com/ProtonDriveApps/sdk-swift/releases/download/{VERSION}/CProtonDriveSDK.xcframework.zip", + checksum: "{XCFRAMEWORK_CHECKSUM}" ), .target( name: "ProtonDriveSDK", @@ -45,7 +49,12 @@ let package = Package( .linkedFramework("GSS"), .linkedLibrary("sqlite3"), .linkedLibrary("icucore"), - + .unsafeFlags([ + // path used in normal builds + "-L${BUILD_DIR}/../../SourcePackages/checkouts/sdk-swift/Resources", + // path used in archive builds + "-L${BUILD_DIR}/../../../../../SourcePackages/checkouts/sdk-swift/Resources", + ]), .unsafeFlags([ // the bootstrapper contains the code to start the dotNET runtime – it asks the system API // to spawn a new thread for garbage collector, allocate the memory to be managed by dotNET etc. @@ -54,5 +63,22 @@ let package = Package( ], .when(platforms: [.macOS])), ], ), + .target( + name: "ProtonDriveSDKTestingToolkit", + path: "TestingToolkit", + linkerSettings: [ + .unsafeFlags([ + // path used in normal builds + "-L${BUILD_DIR}/../../SourcePackages/checkouts/sdk-swift/Resources", + // path used in archive builds + "-L${BUILD_DIR}/../../../../../SourcePackages/checkouts/sdk-swift/Resources", + ]), + .unsafeFlags([ + // the bootstrapper contains the code to start the dotNET runtime – it asks the system API + // to spawn a new thread for garbage collector, allocate the memory to be managed by dotNET etc. + "-llibbootstrapperdll.iossimulator-arm64.o", + ], .when(platforms: [.iOS])), + ] + ), ] ) From de54b53adf82173fa42666ec5ce58e93a2c3bd7f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 12:10:00 +0000 Subject: [PATCH 778/791] Add method to iterate events --- js/sdk/src/index.ts | 2 +- js/sdk/src/internal/events/apiService.ts | 3 +- .../internal/events/eventScheduler.test.ts | 142 ++++++++++++++++++ js/sdk/src/internal/events/eventScheduler.ts | 123 +++++++++++++++ js/sdk/src/internal/events/index.test.ts | 2 +- js/sdk/src/internal/events/index.ts | 49 +++++- js/sdk/src/internal/events/interface.ts | 1 + .../events/volumeEventManager.test.ts | 11 +- .../src/internal/events/volumeEventManager.ts | 4 +- js/sdk/src/protonDriveClient.ts | 47 +++++- js/sdk/src/protonDrivePhotosClient.ts | 30 +++- 11 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 js/sdk/src/internal/events/eventScheduler.test.ts create mode 100644 js/sdk/src/internal/events/eventScheduler.ts diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts index 93433b29..e6a06e7d 100644 --- a/js/sdk/src/index.ts +++ b/js/sdk/src/index.ts @@ -10,7 +10,7 @@ export { OpenPGPCryptoWithCryptoProxy } from './crypto'; export * from './errors'; export { NullFeatureFlagProvider } from './featureFlags'; export * from './interface'; -export type { CoreApiEvent, EventSubscription } from './internal/events'; +export type { CoreApiEvent, EventScheduler, EventSubscription } from './internal/events'; export { ProtonDriveClient } from './protonDriveClient'; export { VERSION } from './version'; diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index e1684d81..a1ba486f 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -76,9 +76,10 @@ export class EventsAPIService { return result.EventID; } - async getVolumeEvents(volumeId: string, eventId: string): Promise { + async getVolumeEvents(volumeId: string, eventId: string, signal?: AbortSignal): Promise { const result = await this.apiService.get( `drive/v2/volumes/${volumeId}/events/${eventId}`, + signal, ); return { latestEventId: result.EventID, diff --git a/js/sdk/src/internal/events/eventScheduler.test.ts b/js/sdk/src/internal/events/eventScheduler.test.ts new file mode 100644 index 00000000..0a166a8d --- /dev/null +++ b/js/sdk/src/internal/events/eventScheduler.test.ts @@ -0,0 +1,142 @@ +import { + EventScheduler, +} from './eventScheduler'; + +jest.useFakeTimers(); + +describe('EventScheduler', () => { + const callback = jest.fn, [string]>().mockResolvedValue(undefined); + const ownVolumeId = 'own-volume'; + let scheduler: EventScheduler; + + beforeEach(() => { + callback.mockReset(); + callback.mockResolvedValue(undefined); + jest.spyOn(Math, 'random').mockReturnValue(1); + scheduler = new EventScheduler(callback, ownVolumeId); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.restoreAllMocks(); + }); + + it('polls own volumes at the foreground interval', async () => { + scheduler.addScope('own-volume'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith('own-volume'); + + await jest.advanceTimersByTimeAsync(29_000); + expect(callback).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(2_000); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith('own-volume'); + }); + + it('polls shared volumes at the background interval by default', async () => { + scheduler.addScope('shared-volume'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith('shared-volume'); + + await jest.advanceTimersByTimeAsync(599_000); + expect(callback).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(2_000); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('promotes a shared scope to the foreground interval', async () => { + scheduler.addScope('shared-volume'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith('shared-volume'); + + await jest.advanceTimersByTimeAsync(32_000); + expect(callback).toHaveBeenCalledTimes(1); + + scheduler.setForeground('shared-volume'); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith('shared-volume'); + + await jest.advanceTimersByTimeAsync(32_000); + expect(callback).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenLastCalledWith('shared-volume'); + }); + + it('demotes the previous foreground shared scope when another is promoted', async () => { + scheduler.addScope('shared-a'); + scheduler.addScope('shared-b'); + + scheduler.setForeground('shared-a'); + scheduler.setForeground('shared-b'); + + callback.mockClear(); + + await jest.advanceTimersByTimeAsync(32_000); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).not.toHaveBeenCalledWith('shared-a'); + expect(callback).toHaveBeenCalledWith('shared-b'); + }); + + it('ignores setBackground for own volumes', async () => { + scheduler.addScope('own-volume'); + callback.mockClear(); + + scheduler.setBackground('own-volume'); + await jest.advanceTimersByTimeAsync(32_000); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith('own-volume'); + }); + + it('stops polling when a scope is removed', async () => { + scheduler.addScope('shared-volume'); + await Promise.resolve(); + callback.mockClear(); + + scheduler.removeScope('shared-volume'); + await jest.advanceTimersByTimeAsync(1_000_000); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not register the same scope twice', async () => { + scheduler.addScope('own-volume'); + scheduler.addScope('own-volume'); + + expect(callback).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(32_000); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('does not schedule the next poll until the callback is resolved', async () => { + let resolveCallback!: () => void; + callback.mockImplementation( + () => + new Promise((resolve) => { + resolveCallback = resolve; + }), + ); + + scheduler.addScope('own-volume'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + + await jest.advanceTimersByTimeAsync(1_000_000); + expect(callback).toHaveBeenCalledTimes(1); + + resolveCallback(); + await Promise.resolve(); + + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(32_000); + expect(callback).toHaveBeenCalledTimes(2); + }); +}); diff --git a/js/sdk/src/internal/events/eventScheduler.ts b/js/sdk/src/internal/events/eventScheduler.ts new file mode 100644 index 00000000..f7d53a56 --- /dev/null +++ b/js/sdk/src/internal/events/eventScheduler.ts @@ -0,0 +1,123 @@ +const FOREGROUND_POLLING_INTERVAL_SECONDS = 30; +const BACKGROUND_POLLING_INTERVAL_SECONDS = 10 * 60; +const JITTER_SECONDS = 1; + +type ScopeState = { + eventTreeScopeId: string; + isOwnVolume: boolean; + isForeground: boolean; + timeoutHandle?: ReturnType; +}; + +export class EventScheduler { + private scopes = new Map(); + + constructor( + private callback: (eventTreeScopeId: string) => Promise, + private ownVolumeId: string, + ) {} + + addScope(eventTreeScopeId: string): void { + if (this.scopes.has(eventTreeScopeId)) { + return; + } + + const isOwnVolume = eventTreeScopeId === this.ownVolumeId; + const scope = { + eventTreeScopeId, + isOwnVolume, + isForeground: isOwnVolume, + }; + this.scopes.set(eventTreeScopeId, scope); + + // We need to poll right away to get the initial events. + this.poll(scope); + } + + setForeground(eventTreeScopeId: string): void { + const scope = this.scopes.get(eventTreeScopeId); + if (!scope || scope.isOwnVolume || scope.isForeground) { + return; + } + + this.sendCurrentForegroundSharedScopesToBackground(); + + scope.isForeground = true; + this.stopPolling(scope); + + // We need to poll right away to notify the client that the scope is + // requested at this moment. + this.poll(scope); + } + + setBackground(eventTreeScopeId: string): void { + const scope = this.scopes.get(eventTreeScopeId); + if (!scope || scope.isOwnVolume || !scope.isForeground) { + return; + } + + scope.isForeground = false; + this.setPolling(scope); + + // No need to poll here as the scope is put back to background. + } + + removeScope(eventTreeScopeId: string): void { + const scope = this.scopes.get(eventTreeScopeId); + if (!scope) { + return; + } + + this.stopPolling(scope); + this.scopes.delete(eventTreeScopeId); + } + + private sendCurrentForegroundSharedScopesToBackground(): void { + const foregroundSharedScopes = Array.from( + this.scopes.values().filter((scope) => !scope.isOwnVolume && scope.isForeground), + ); + + if (foregroundSharedScopes.length === 0) { + return; + } + + for (const scope of foregroundSharedScopes) { + scope.isForeground = false; + this.setPolling(scope); + } + } + + private poll(scope: ScopeState): void { + const promise = this.callback(scope.eventTreeScopeId); + + // Setup timer for next poll only after the callback is resolved to + // avoid race conditions where the client is called before the events + // are processed. + void promise.finally(() => { + this.setPolling(scope); + }); + } + + private setPolling(scope: ScopeState): void { + this.stopPolling(scope); + + const pollingIntervalSeconds = scope.isForeground + ? FOREGROUND_POLLING_INTERVAL_SECONDS + : BACKGROUND_POLLING_INTERVAL_SECONDS; + const jitter = Math.random() * JITTER_SECONDS; + const timeout = (pollingIntervalSeconds + jitter) * 1000; + + scope.timeoutHandle = setTimeout(() => { + this.poll(scope); + }, timeout); + } + + private stopPolling(scope: ScopeState): void { + if (scope.timeoutHandle === undefined) { + return; + } + + clearTimeout(scope.timeoutHandle); + scope.timeoutHandle = undefined; + } +} diff --git a/js/sdk/src/internal/events/index.test.ts b/js/sdk/src/internal/events/index.test.ts index 078f6368..9fcefd83 100644 --- a/js/sdk/src/internal/events/index.test.ts +++ b/js/sdk/src/internal/events/index.test.ts @@ -9,7 +9,7 @@ describe('DriveEventsService', () => { function createService(cacheEventListeners: DriveListener[] = []) { const telemetry = getMockTelemetry(); const apiService = {} as unknown as DriveAPIService; - const sharesService = { isOwnVolume: jest.fn() }; + const sharesService = { isOwnVolume: jest.fn(), getRootIDs: jest.fn() }; return new DriveEventsService(telemetry, apiService, sharesService, cacheEventListeners); } diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index c4f879d4..352334fe 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -3,10 +3,19 @@ import { DriveAPIService } from '../apiService'; import { CoreApiEvent, EventsAPIService } from './apiService'; import { CoreEventManager } from './coreEventManager'; import { EventManager } from './eventManager'; -import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider, SharesService } from './interface'; +import { EventScheduler } from './eventScheduler'; +import { + DriveEvent, + DriveEventType, + DriveListener, + EventSubscription, + LatestEventIdProvider, + SharesService, +} from './interface'; import { VolumeEventManager } from './volumeEventManager'; export type { CoreApiEvent } from './apiService'; +export type { EventScheduler } from './eventScheduler'; export type { DriveEvent, DriveListener, EventSubscription } from './interface'; export { DriveEventType } from './interface'; @@ -90,8 +99,46 @@ export class DriveEventsService { return driveEvents; } + /** + * Returns a scheduler that invokes the callback on a timer for each + * registered tree event scope. Own volumes poll at the foreground rate; + * shared volumes poll at the background rate unless promoted via + * `setForeground`. Only one non-own volume can be in the foreground at + * a time. + */ + async getEventScheduler(callback: (eventTreeScopeId: string) => Promise): Promise { + const { volumeId: ownVolumeId } = await this.sharesService.getRootIDs(); + return new EventScheduler(callback, ownVolumeId); + } + + /** + * Provides drive events for a given tree scope. When no lastEventId is + * provided, the latest event ID is fetched and a FastForward event is + * yielded. + */ + async *iterateEvents( + treeEventScopeId: string, + lastEventId?: string, + signal?: AbortSignal, + ): AsyncGenerator { + const volumeId = treeEventScopeId; + const volumeEventManager = new VolumeEventManager(this.logger, this.apiService, volumeId); + if (!lastEventId) { + lastEventId = await volumeEventManager.getLatestEventId(); + yield { + type: DriveEventType.FastForward, + treeEventScopeId, + eventId: lastEventId, + }; + return; + } + yield* volumeEventManager.getEvents(lastEventId, signal); + } + /** * Subscribe to drive events. The treeEventScopeId can be obtained from a node. + * + * @deprecated Use `iterateEvents` instead. */ async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { const volumeId = treeEventScopeId; diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index 5e369205..86cc46ee 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -123,4 +123,5 @@ export interface EventManagerInterface { export interface SharesService { isOwnVolume(volumeId: string): Promise; + getRootIDs(): Promise<{ volumeId: string }>; } diff --git a/js/sdk/src/internal/events/volumeEventManager.test.ts b/js/sdk/src/internal/events/volumeEventManager.test.ts index 0ae0fda0..5824e48b 100644 --- a/js/sdk/src/internal/events/volumeEventManager.test.ts +++ b/js/sdk/src/internal/events/volumeEventManager.test.ts @@ -78,7 +78,7 @@ describe('VolumeEventManager', () => { } expect(events).toEqual(mockEventsResponse.events); - expect(mockEventsAPIService.getVolumeEvents).toHaveBeenCalledWith(volumeId, 'startEventId'); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenCalledWith(volumeId, 'startEventId', undefined); }); it('should continue fetching when more events are available', async () => { @@ -129,8 +129,13 @@ describe('VolumeEventManager', () => { expect(events[0]).toEqual(firstResponse.events[0]); expect(events[1]).toEqual(secondResponse.events[0]); expect(mockEventsAPIService.getVolumeEvents).toHaveBeenCalledTimes(2); - expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(1, volumeId, 'startEventId'); - expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(2, volumeId, 'eventId2'); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith( + 1, + volumeId, + 'startEventId', + undefined, + ); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(2, volumeId, 'eventId2', undefined); }); it('should yield TreeRefresh event when refresh is true', async () => { diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index f6694cff..05824164 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -30,12 +30,12 @@ export class VolumeEventManager implements EventManagerInterface { return this.logger; } - async *getEvents(eventId: string): AsyncIterable { + async *getEvents(eventId: string, signal?: AbortSignal): AsyncIterable { try { let events: DriveEventsListWithStatus; let more = true; while (more) { - events = await this.apiService.getVolumeEvents(this.volumeId, eventId); + events = await this.apiService.getVolumeEvents(this.volumeId, eventId, signal); more = events.more; if (events.refresh) { yield { diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index a1dbd1ce..dee45f06 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -37,7 +37,7 @@ import { import { DriveAPIService } from './internal/apiService'; import { initDevicesModule } from './internal/devices'; import { initDownloadModule } from './internal/download'; -import { CoreApiEvent, DriveEventsService, DriveListener, EventSubscription } from './internal/events'; +import { CoreApiEvent, DriveEventsService, DriveListener, EventScheduler, EventSubscription } from './internal/events'; import { initNodesModule } from './internal/nodes'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; @@ -288,6 +288,49 @@ export class ProtonDriveClient { return this.sdkEvents.addListener(eventName, callback); } + /** + * Provides the remote data updates for all files and folders in a given + * tree scope. + * + * In order to keep local data up to date, the client must call this method + * to receive events on updates and to keep the SDK cache in sync. + * + * When no lastEventId is provided, the FastForward with the latest event + * ID is yielded. + * + * Use `getEventScheduler` to schedule the polling of the events. + * + * @param treeEventScopeId - The scope ID of the tree to read events for (same as `treeEventScopeId` on nodes) + * @param lastEventId - The last event ID you have fully processed for this scope; omit to start from the latest event + * @param signal - Signal to abort the operation + * @returns An async generator of the events for the given scope. + */ + async *iterateEvents( + treeEventScopeId: string, + lastEventId?: string, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating events for tree scope ${treeEventScopeId}`); + yield* this.events.iterateEvents(treeEventScopeId, lastEventId, signal); + } + + /** + * Provides a scheduler that invokes the callback on a timer for each + * registered tree event scope. Own volumes poll at the foreground rate; + * shared volumes poll at the background rate unless promoted via + * `setForeground`. Only one non-own volume can be in the foreground at + * a time. + * + * Only one instance of the SDK should subscribe to updates. + * + * @param callback - Callback to be called when the events should be polled. + * @returns The event scheduler. + */ + async getEventScheduler(callback: (eventTreeScopeId: string) => Promise): Promise { + this.logger.info('Getting event scheduler'); + return this.events.getEventScheduler(callback); + } + /** * Subscribes to the remote data updates for all files and folders in a * tree. @@ -298,6 +341,8 @@ export class ProtonDriveClient { * The `treeEventScopeId` can be obtained from node properties. * * Only one instance of the SDK should subscribe to updates. + * + * @deprecated Use `iterateEvents` instead. */ async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { this.logger.debug('Subscribing to node updates'); diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index a4792c71..cbd37a94 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -27,7 +27,7 @@ import { } from './interface'; import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; -import { CoreApiEvent, DriveEventsService, DriveListener, EventSubscription } from './internal/events'; +import { CoreApiEvent, DriveEventsService, DriveListener, EventScheduler, EventSubscription } from './internal/events'; import { AlbumItem, initPhotoSharesModule, @@ -210,10 +210,38 @@ export class ProtonDrivePhotosClient { return this.sdkEvents.addListener(eventName, callback); } + /** + * Provides the remote data updates for all files and folders in a given + * tree scope. + * + * See `ProtonDriveClient.iterateEvents` for more information. + */ + async *iterateEvents( + treeEventScopeId: string, + lastEventId?: string, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating events for tree scope ${treeEventScopeId}`); + yield* this.events.iterateEvents(treeEventScopeId, lastEventId, signal); + } + + /** + * Provides a scheduler that invokes the callback on a timer for each + * registered tree event scope. + * + * See `ProtonDriveClient.getEventScheduler` for more information. + */ + async getEventScheduler(callback: (eventTreeScopeId: string) => Promise): Promise { + this.logger.info('Getting event scheduler'); + return this.events.getEventScheduler(callback); + } + /** * Subscribes to the remote data updates for all files in a tree. * * See `ProtonDriveClient.subscribeToTreeEvents` for more information. + * + * @deprecated Use `iterateEvents` instead. */ async subscribeToTreeEvents(treeEventScopeId: string, callback: DriveListener): Promise { this.logger.debug('Subscribing to node updates'); From d0f8ce1888ccff8f5445ac32c882deaeccbf6c42 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 14:11:22 +0200 Subject: [PATCH 779/791] Fix cache not evicting incompatible entries --- cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs | 5 ++++- .../src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs | 8 ++++++-- .../Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs | 2 ++ cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs | 7 ++++--- cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 1 + cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs | 8 ++++---- .../Nodes/Upload/NewFileDraftProvider.cs | 1 + .../Serialization/DriveApiSerializerContext.cs | 1 + .../Serialization/DriveEntitiesSerializerContext.cs | 1 + .../Serialization/DriveSecretsSerializerContext.cs | 1 + .../Serialization/PhotosApiSerializerContext.cs | 1 + .../src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs | 2 ++ .../Proton.Sdk/Caching/CacheRepositoryExtensions.cs | 3 ++- .../Serialization/AccountEntitiesSerializerContext.cs | 2 +- .../Serialization/ProtonApiSerializerContext.cs | 11 +---------- .../Serialization/SecretsSerializerContext.cs | 1 + 18 files changed, 35 insertions(+), 24 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs index 2110a032..b460c530 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs @@ -1,6 +1,9 @@ -using Proton.Drive.Sdk.Api.Shares; +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Nodes; namespace Proton.Drive.Sdk.Caching; +// This forces the deserializer to not use the implicit default constructor of the struct, thereby enabling required parameter enforcement +[method: JsonConstructor] internal readonly record struct CachedNodeInfo(Node Node, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs index 029d991c..89e32b78 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -60,7 +60,9 @@ public ValueTask SetPhotosVolumeIdAsync(VolumeId? volumeId, CancellationToken ca public ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cancellationToken) { - return _repository.SetAsync(MyFilesShareIdCacheKey, shareId.ToString(), cancellationToken); + var serializedValue = JsonSerializer.Serialize(shareId, DriveEntitiesSerializerContext.Default.ShareId); + + return _repository.SetAsync(MyFilesShareIdCacheKey, serializedValue, cancellationToken); } public async ValueTask TryGetMyFilesShareIdAsync(CancellationToken cancellationToken) @@ -75,7 +77,9 @@ public ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cance public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken) { - return _repository.SetAsync(PhotosShareIdCacheKey, shareId.ToString(), cancellationToken); + var serializedValue = JsonSerializer.Serialize(shareId, DriveEntitiesSerializerContext.Default.ShareId); + + return _repository.SetAsync(PhotosShareIdCacheKey, serializedValue, cancellationToken); } public async ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 3ac0e13c..34609a5e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -404,6 +404,7 @@ private static (FileMetadata Metadata, Dictionary (PgpSessionKey?)x.SessionKey, _ => null), NameSessionKey = nameSessionKey, ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), + PassphraseForAnonymousMove = null, }; return (new FileMetadata(partialNode, partialSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); @@ -569,6 +570,7 @@ private static (FolderMetadata Metadata, Dictionary (PgpSessionKey?)x.SessionKey, _ => null), NameSessionKey = nameSessionKey, HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), + PassphraseForAnonymousMove = null, }; return (new FolderMetadata(partialNode, partialSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs index 2c2b30e7..6946793c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs @@ -4,5 +4,5 @@ namespace Proton.Drive.Sdk.Nodes; internal sealed class FileSecrets : NodeSecrets { - public PgpSessionKey? ContentKey { get; init; } + public required PgpSessionKey? ContentKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 068fe773..5ccd210c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -37,9 +37,9 @@ public static async IAsyncEnumerable EnumerateChildrenAsync( { var childUid = new NodeUid(folderUid.VolumeId, childLinkId); - var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(childUid, cancellationToken).ConfigureAwait(false); + var cachedChildNodeInfoOrNull = await client.Cache.Entities.TryGetNodeAsync(childUid, cancellationToken).ConfigureAwait(false); - if (cachedChildNodeInfo is null) + if (cachedChildNodeInfoOrNull is not { } cachedChildNodeInfo) { await foreach (var nodeResult in batchLoader.QueueAndTryLoadBatchAsync(childLinkId, cancellationToken).ConfigureAwait(false)) { @@ -48,7 +48,7 @@ public static async IAsyncEnumerable EnumerateChildrenAsync( } else { - yield return cachedChildNodeInfo.Value.Node; + yield return cachedChildNodeInfo.Node; } } } @@ -132,6 +132,7 @@ public static async ValueTask CreateAsync( PassphraseSessionKey = passphraseSessionKey, NameSessionKey = nameSessionKey, HashKey = hashKey, + PassphraseForAnonymousMove = null, }; await client.Cache.Secrets.SetFolderSecretsAsync(folderUid, folderSecrets, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs index 20ce4437..eb363110 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs @@ -2,5 +2,5 @@ internal sealed class FolderSecrets : NodeSecrets { - public ReadOnlyMemory? HashKey { get; init; } + public required ReadOnlyMemory? HashKey { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index 389efa00..ed51e580 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -14,6 +14,7 @@ public abstract record Node public required NodeUid? ParentUid { get; init; } + [JsonIgnore] public string TreeEventScopeId => Uid.VolumeId.ToString(); public required Result Name { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs index 6cf513d4..8568f75b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs @@ -5,11 +5,11 @@ namespace Proton.Drive.Sdk.Nodes; internal class NodeSecrets { - public PgpPrivateKey? Key { get; init; } - public PgpSessionKey? PassphraseSessionKey { get; init; } - public PgpSessionKey? NameSessionKey { get; init; } + public required PgpPrivateKey? Key { get; init; } + public required PgpSessionKey? PassphraseSessionKey { get; init; } + public required PgpSessionKey? NameSessionKey { get; init; } [JsonPropertyName("passphrase")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ReadOnlyMemory? PassphraseForAnonymousMove { get; set; } + public required ReadOnlyMemory? PassphraseForAnonymousMove { get; set; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 117785ab..d4c03562 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -157,6 +157,7 @@ private static FileCreationRequest GetFileCreationRequest( PassphraseSessionKey = passphraseSessionKey, NameSessionKey = nameSessionKey, ContentKey = contentKey, + PassphraseForAnonymousMove = null, }; var draftNodeUid = new NodeUid(_parentUid.VolumeId, response.Identifiers.LinkId); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 117d7e1c..aa426eed 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -13,6 +13,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSourceGenerationOptions( #if DEBUG WriteIndented = true, + RespectRequiredConstructorParameters = true, #endif Converters = [ diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs index e075e7ca..cc8d6561 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -11,6 +11,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, + RespectRequiredConstructorParameters = true, Converters = [ typeof(RefResultJsonConverter), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs index 278612f9..3fe2c8cc 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs @@ -8,6 +8,7 @@ namespace Proton.Drive.Sdk.Serialization; #pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + RespectRequiredConstructorParameters = true, Converters = [ typeof(PgpPrivateKeyJsonConverter), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs index f9594ba0..f53d01a4 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -8,6 +8,7 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSourceGenerationOptions( #if DEBUG WriteIndented = true, + RespectRequiredConstructorParameters = true, #endif Converters = [ diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index ec92feb2..0b226db2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -204,6 +204,7 @@ private static VolumeCreationRequest GetCreationRequest( PassphraseSessionKey = rootFolderPassphraseSessionKey, NameSessionKey = rootFolderNameSessionKey, HashKey = rootFolderHashKey, + PassphraseForAnonymousMove = null, }; Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; @@ -264,6 +265,7 @@ private static PhotosVolumeCreationRequest GetPhotosCreationRequest( PassphraseSessionKey = rootFolderPassphraseSessionKey, NameSessionKey = rootFolderNameSessionKey, HashKey = rootFolderHashKey, + PassphraseForAnonymousMove = null, }; Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; diff --git a/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs index 4e61da41..3de3fc90 100644 --- a/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs @@ -99,7 +99,8 @@ public static async ValueTask SetCompleteCollection( /// /// /// This marking indicates that the results of a query by the given tag reflect the complete "truth" related to that tag at a point in time. - /// Consequently, if that marking is present and the query by that tag returns an empty set, then that emptiness is the information, rather than a lack of information in cache. + /// Consequently, if that marking is present and the query by that tag returns an empty set, then that emptiness is the information, + /// rather than a lack of information in cache. /// private static async ValueTask MarkTagAsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) { diff --git a/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs index 4e5700d7..e0dba82f 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs @@ -3,6 +3,6 @@ namespace Proton.Sdk.Serialization; -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, RespectRequiredConstructorParameters = true)] [JsonSerializable(typeof(Address))] internal sealed partial class AccountEntitiesSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs index 5b842b71..3fc84359 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs @@ -1,14 +1,10 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Addresses; using Proton.Sdk.Api; using Proton.Sdk.Api.Addresses; using Proton.Sdk.Api.Authentication; using Proton.Sdk.Api.Events; using Proton.Sdk.Api.Keys; using Proton.Sdk.Api.Users; -using Proton.Sdk.Authentication; -using Proton.Sdk.Events; -using Proton.Sdk.Users; namespace Proton.Sdk.Serialization; @@ -16,6 +12,7 @@ namespace Proton.Sdk.Serialization; [JsonSourceGenerationOptions( #if DEBUG WriteIndented = true, + RespectRequiredConstructorParameters = true, #endif Converters = [ @@ -23,12 +20,6 @@ namespace Proton.Sdk.Serialization; typeof(PgpArmoredSignatureJsonConverter), typeof(PgpArmoredPrivateKeyJsonConverter), typeof(PgpArmoredPublicKeyJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), - typeof(StrongIdJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(ApiResponse))] diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs index 3bf77431..517705f1 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs @@ -6,6 +6,7 @@ namespace Proton.Sdk.Serialization; #pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + RespectRequiredConstructorParameters = true, Converters = [ typeof(PgpPrivateKeyJsonConverter), From ab82b74d1872c5624fd7938abd8adf5a38f75e47 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 13:33:39 +0000 Subject: [PATCH 780/791] Report extended attributes size for download progress instead of revision size --- .../DriveInteropTelemetryDecorator.cs | 4 +- .../InteropActionExtensions.cs | 8 +- .../Nodes/Cryptography/NodeCrypto.cs | 96 +++++++++---------- .../Nodes/Download/DownloadController.cs | 12 +-- .../Nodes/Download/DownloadState.cs | 2 + .../Nodes/Download/FileDownloader.cs | 12 +-- .../Nodes/Download/IFileDownloader.cs | 4 +- .../Nodes/Download/PhotosFileDownloader.cs | 12 +-- .../Nodes/Download/RevisionReader.cs | 14 ++- .../Nodes/RevisionOperations.cs | 21 ++++ .../Telemetry/DownloadEvent.cs | 4 +- .../src/Proton.Drive.Sdk/Telemetry/Privacy.cs | 5 + cs/sdk/src/protos/proton.drive.sdk.proto | 2 +- .../me/proton/drive/sdk/ProgressUpdate.kt | 2 +- .../drive/sdk/extension/ProgressUpdate.kt | 13 ++- 15 files changed, 125 insertions(+), 86 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index aa1648bd..7cdd19f2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -73,8 +73,8 @@ private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me) VolumeType = (VolumeType)me.VolumeType, DownloadedSize = me.DownloadedSize, ApproximateDownloadedSize = me.ApproximateDownloadedSize, - ClaimedFileSize = me.ClaimedFileSize, - ApproximateClaimedFileSize = me.ApproximateClaimedFileSize, + ClaimedFileSize = me.ClaimedFileSize ?? 0, + ApproximateClaimedFileSize = me.ApproximateClaimedFileSize ?? 0, }; // Check if we should translate InteropErrorException when error is Unknown diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs index aeb6e5f1..13df84dd 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs @@ -5,14 +5,18 @@ namespace Proton.Drive.Sdk.CExports; internal static class InteropActionExtensions { - public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint bindingsHandle, long progress, long total) + public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint bindingsHandle, long progress, long? total) { var progressUpdate = new ProgressUpdate { BytesCompleted = progress, - BytesInTotal = total, }; + if (total is not null) + { + progressUpdate.BytesInTotal = total.Value; + } + var requestBytes = progressUpdate.ToByteArray(); fixed (byte* requestBytesPointer = requestBytes) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index 089cc026..b170a31c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -79,6 +79,54 @@ public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHa } } + public static Result, ProtonDriveError> DecryptExtendedAttributes( + PgpArmoredMessage? encryptedExtendedAttributes, + Result nodeKeyResult, + AuthorshipClaim authorshipClaim) + { + if (encryptedExtendedAttributes is null) + { + return new DecryptionOutput(null); + } + + if (!nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) + { + return new ProtonDriveError("Cannot get node key", error); + } + + ArraySegment serializedExtendedAttributes; + AuthorshipVerificationFailure? authorshipVerificationFailure; + try + { + serializedExtendedAttributes = DecryptMessage( + encryptedExtendedAttributes.Value, + detachedSignature: null, + nodeKey, + authorshipClaim.GetKeyRing(nodeKey), + out _, + out authorshipVerificationFailure); + } + catch (Exception e) + { + return new DecryptionError("Failed to decrypt extended attributes", e.ToProtonDriveError()); + } + + try + { + var extendedAttributes = JsonSerializer.Deserialize(serializedExtendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); + + return new DecryptionOutput(extendedAttributes, authorshipVerificationFailure); + } + catch (JsonException e) + { + return new ExtendedAttributesDeserializationError(e.EnrichJsonException(serializedExtendedAttributes)); + } + catch (Exception e) + { + return new ProtonDriveError("Unknown error while deserializing extended attributes", e.ToProtonDriveError()); + } + } + private static async ValueTask DecryptLinkAsync( IAccountClient accountClient, LinkDto link, @@ -250,54 +298,6 @@ private static Result, ProtonDriveError> Decrypt return new DecryptionOutput(contentKey, verificationFailure); } - private static Result, ProtonDriveError> DecryptExtendedAttributes( - PgpArmoredMessage? encryptedExtendedAttributes, - Result nodeKeyResult, - AuthorshipClaim authorshipClaim) - { - if (encryptedExtendedAttributes is null) - { - return new DecryptionOutput(null); - } - - if (!nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) - { - return new ProtonDriveError("Cannot get node key", error); - } - - ArraySegment serializedExtendedAttributes; - AuthorshipVerificationFailure? authorshipVerificationFailure; - try - { - serializedExtendedAttributes = DecryptMessage( - encryptedExtendedAttributes.Value, - detachedSignature: null, - nodeKey, - authorshipClaim.GetKeyRing(nodeKey), - out _, - out authorshipVerificationFailure); - } - catch (Exception e) - { - return new DecryptionError("Failed to decrypt extended attributes", e.ToProtonDriveError()); - } - - try - { - var extendedAttributes = JsonSerializer.Deserialize(serializedExtendedAttributes, DriveApiSerializerContext.Default.ExtendedAttributes); - - return new DecryptionOutput(extendedAttributes, authorshipVerificationFailure); - } - catch (JsonException e) - { - return new ExtendedAttributesDeserializationError(e.EnrichJsonException(serializedExtendedAttributes)); - } - catch (Exception e) - { - return new ProtonDriveError("Unknown error while deserializing extended attributes", e.ToProtonDriveError()); - } - } - private static ArraySegment DecryptMessage( PgpArmoredMessage encryptedMessage, PgpArmoredSignature? detachedSignature, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs index a9d40c87..142f90f2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -8,8 +8,8 @@ public sealed class DownloadController : IAsyncDisposable private readonly Func _resumeFunction; private readonly ITaskControl _taskControl; private readonly Stream? _outputStreamToDispose; - private readonly Func? _onFailedAsync; - private readonly Func? _onSucceededAsync; + private readonly Func? _onFailedAsync; + private readonly Func? _onSucceededAsync; private bool _isDownloadCompleteWithVerificationIssue; @@ -19,8 +19,8 @@ internal DownloadController( Func resumeFunction, Stream? outputStreamToDispose, ITaskControl taskControl, - Func? onFailedAsync = null, - Func? onSucceededAsync = null) + Func? onFailedAsync = null, + Func? onSucceededAsync = null) { _downloadStateTask = downloadStateTask; _resumeFunction = resumeFunction; @@ -81,7 +81,7 @@ public async ValueTask DisposeAsync() { await _onFailedAsync.Invoke( exception, - downloadState?.RevisionDto.Size ?? 0, + downloadState?.ClaimedSize, downloadState?.GetNumberOfBytesWritten() ?? 0).ConfigureAwait(false); } } @@ -166,7 +166,7 @@ private async ValueTask FinalizeDownloadAsync() var downloadState = await _downloadStateTask.ConfigureAwait(false); await onSucceededHandler.Invoke( - downloadState.RevisionDto.Size, + downloadState.ClaimedSize, downloadState.GetNumberOfBytesWritten()).ConfigureAwait(false); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs index 2b8014ed..b817da1a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs @@ -9,6 +9,7 @@ internal sealed partial class DownloadState( PgpPrivateKey nodeKey, PgpSessionKey contentKey, BlockListingRevisionDto revisionDto, + long? claimedSize, long queueToken, ILogger logger) : IAsyncDisposable { @@ -21,6 +22,7 @@ internal sealed partial class DownloadState( public RevisionUid Uid { get; } = uid; public BlockListingRevisionDto RevisionDto { get; } = revisionDto; + public long? ClaimedSize { get; } = claimedSize; public long QueueToken { get; } = queueToken; public PgpPrivateKey NodeKey { get; } = nodeKey; public PgpSessionKey ContentKey { get; } = contentKey; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index e8ea86a6..7a17bbbe 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -19,12 +19,12 @@ private FileDownloader(ProtonDriveClient client, long queueToken, RevisionUid re _logger = logger; } - public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { return BuildDownloadController(contentOutputStream, ownsOutputStream: false, onProgress, cancellationToken); } - public DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken) + public DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken) { var contentOutputStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); @@ -70,7 +70,7 @@ internal static async ValueTask CreateAsync(ProtonDriveClient cl private async Task DownloadToStreamAsync( Stream contentOutputStream, - Action onProgress, + Action onProgress, TaskCompletionSource downloadStateTaskCompletionSource, long queueToken, CancellationToken cancellationToken) @@ -95,7 +95,7 @@ private async Task DownloadToStreamAsync( private DownloadController BuildDownloadController( Stream contentOutputStream, bool ownsOutputStream, - Action onProgress, + Action onProgress, CancellationToken cancellationToken) { var taskControl = new TaskControl(cancellationToken); @@ -118,7 +118,7 @@ private DownloadController BuildDownloadController( OnFailedAsync, OnSucceededAsync); - async ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloadedByteCount) + async ValueTask OnFailedAsync(Exception ex, long? claimedFileSize, long downloadedByteCount) { if (ex is ValidationException) { @@ -137,7 +137,7 @@ async ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloade RaiseTelemetryEvent(downloadEvent); } - async ValueTask OnSucceededAsync(long claimedFileSize, long downloadedByteCount) + async ValueTask OnSucceededAsync(long? claimedFileSize, long downloadedByteCount) { var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/IFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/IFileDownloader.cs index c0457892..739f8875 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/IFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/IFileDownloader.cs @@ -2,7 +2,7 @@ public interface IFileDownloader : IDisposable { - DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken); + DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken); - DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken); + DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index 50b9ba04..9569018f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -18,12 +18,12 @@ private PhotosFileDownloader(ProtonPhotosClient client, NodeUid photoUid, long q _logger = logger; } - public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + public DownloadController DownloadToStream(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { return DownloadToStream(contentOutputStream, ownsOutputStream: false, onProgress, cancellationToken); } - public DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken) + public DownloadController DownloadToFile(string filePath, Action onProgress, CancellationToken cancellationToken) { var stream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); @@ -69,7 +69,7 @@ internal static async ValueTask CreateAsync(ProtonPhotosCl private async Task DownloadToStreamAsync( Stream contentOutputStream, - Action onProgress, + Action onProgress, TaskCompletionSource downloadStateTaskCompletionSource, CancellationToken cancellationToken) { @@ -101,7 +101,7 @@ private async Task DownloadToStreamAsync( private DownloadController DownloadToStream( Stream contentOutputStream, bool ownsOutputStream, - Action onProgress, + Action onProgress, CancellationToken cancellationToken) { var taskControl = new TaskControl(cancellationToken); @@ -123,7 +123,7 @@ private DownloadController DownloadToStream( OnFailedAsync, OnSucceededAsync); - async ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloadedByteCount) + async ValueTask OnFailedAsync(Exception ex, long? claimedFileSize, long downloadedByteCount) { if (ex is ValidationException) { @@ -142,7 +142,7 @@ async ValueTask OnFailedAsync(Exception ex, long claimedFileSize, long downloade RaiseTelemetryEvent(downloadEvent); } - async ValueTask OnSucceededAsync(long claimedFileSize, long downloadedByteCount) + async ValueTask OnSucceededAsync(long? claimedFileSize, long downloadedByteCount) { var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client.DriveClient, _photoUid, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs index 1dd9fb79..71e44584 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -30,7 +30,7 @@ internal RevisionReader( _logger = client.Telemetry.GetLogger("Revision reader"); } - public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) { try { @@ -54,7 +54,11 @@ public async ValueTask ReadAsync(Stream contentOutputStream, Action manifestStream.Write(digest.Span); } - await DownloadBlocks(contentOutputStream, onProgress, manifestStream, cancellationToken).ConfigureAwait(false); + await DownloadBlocks( + contentOutputStream, + downloaded => onProgress(downloaded, _state.ClaimedSize), + manifestStream, + cancellationToken).ConfigureAwait(false); manifestStream.Seek(0, SeekOrigin.Begin); @@ -87,7 +91,7 @@ and not CompletedDownloadManifestVerificationException private async ValueTask DownloadBlocks( Stream contentOutputStream, - Action onProgress, + Action onProgress, RecyclableMemoryStream manifestStream, CancellationToken cancellationToken) { @@ -153,7 +157,7 @@ private async Task WriteNextBlockToOutputAsync( Queue> downloadTasks, Stream outputStream, Stream manifestStream, - Action onProgress, + Action onProgress, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -196,7 +200,7 @@ private async Task WriteNextBlockToOutputAsync( _client.DownloadQueue.DecreaseFileRemainingBlockCount(_state.QueueToken, 1); - onProgress(_state.GetNumberOfBytesWritten(), _state.RevisionDto.Size); + onProgress(_state.GetNumberOfBytesWritten()); } finally { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index 352fc0ba..a3d3dbff 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -1,3 +1,6 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Nodes.Cryptography; using Proton.Drive.Sdk.Nodes.Download; using Proton.Drive.Sdk.Nodes.Upload; @@ -43,11 +46,14 @@ internal static async ValueTask CreateDownloadStateAsync( var key = fileSecrets.Key ?? throw new InvalidOperationException($"Node key not available for file {revisionUid.NodeUid}"); var contentKey = fileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}"); + var claimedSize = await GetClaimedSizeAsync(client, revisionResponse.Revision, key, cancellationToken).ConfigureAwait(false); + return new DownloadState( revisionUid, key, contentKey, revisionResponse.Revision, + claimedSize, queueToken, client.Telemetry.GetLogger("Download state")); } @@ -56,4 +62,19 @@ internal static RevisionReader OpenForReading(ProtonDriveClient client, Download { return new RevisionReader(client, downloadState); } + + private static async ValueTask GetClaimedSizeAsync( + ProtonDriveClient client, + RevisionDto revision, + PgpPrivateKey key, + CancellationToken cancellationToken) + { + var contentAuthorshipClaim = + await AuthorshipClaim.CreateAsync(client.Account, revision.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + + return NodeCrypto.DecryptExtendedAttributes(revision.ExtendedAttributes, key, contentAuthorshipClaim) + .TryGetValueElseError(out var extendedAttributesOutput, out _) + ? extendedAttributesOutput.Data?.Common?.Size + : null; + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs index 53084bd0..5a9bed75 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs @@ -13,9 +13,9 @@ public sealed class DownloadEvent : IMetricEvent public long ApproximateDownloadedSize { get; set; } - public long ClaimedFileSize { get; set; } + public long? ClaimedFileSize { get; set; } - public long ApproximateClaimedFileSize { get; set; } + public long? ApproximateClaimedFileSize { get; set; } public DownloadError? Error { get; set; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs index 32496568..2cae3510 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs @@ -25,4 +25,9 @@ public static long ReduceSizePrecision(long size) return (size / precision) * precision; } + + public static long? ReduceSizePrecision(long? size) + { + return size is null ? null : ReduceSizePrecision(size.Value); + } } diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index b781bcef..6054d91c 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -179,7 +179,7 @@ message OwnedBy { message ProgressUpdate { int64 bytes_completed = 1; - int64 bytes_in_total = 2; + int64 bytes_in_total = 2; // optional } message Thumbnail { diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt index 4b076ad0..6b660cfb 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProgressUpdate.kt @@ -2,5 +2,5 @@ package me.proton.drive.sdk data class ProgressUpdate( val bytesCompleted: Long, - val bytesInTotal: Long, + val bytesInTotal: Long?, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt index 7c280179..2e41d0e4 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt @@ -1,17 +1,20 @@ package me.proton.drive.sdk.extension import me.proton.drive.sdk.ProgressUpdate +import kotlin.math.roundToLong import proton.drive.sdk.ProtonDriveSdk fun ProtonDriveSdk.ProgressUpdate.toEntity() = takeIf { it.bytesInTotal > 0 }?.run { ProgressUpdate( bytesCompleted = bytesCompleted, - bytesInTotal = bytesInTotal, + bytesInTotal = takeIf { hasBytesInTotal() }?.let { bytesInTotal } ) } -internal fun ProtonDriveSdk.ProgressUpdate.toPercentageString(): String = if (bytesInTotal > 0) { - (bytesCompleted * 100.0 / bytesInTotal).toInt() +private const val BLOCK_SIZE = 1 shl 22 // 4 MiB + +internal fun ProtonDriveSdk.ProgressUpdate.toPercentageString(): String = if (hasBytesInTotal() && bytesInTotal > 0) { + (bytesCompleted * 100.0 / bytesInTotal).toInt().let { percentage -> "$percentage%" } } else { - 0 -}.let { percentage -> "$percentage%" } + (bytesCompleted.toDouble() / (BLOCK_SIZE)).roundToLong().let { blocks -> "indeterminate: $blocks" } +} From 3cc28d891cadcbb3ba377f338558be11befd305c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 14:10:15 +0000 Subject: [PATCH 781/791] Update changelog for cs/v0.15.0 --- cs/CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 2549513a..28226331 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## cs/v0.15.0 (2026-05-27) + +* Report extended attributes size for download progress instead of revision size +* Fix cache not evicting incompatible entries +* Do not close the input stream in Swift's StreamForUpload +* Fix interop account client requesting empty address instead of default address +* Use single type hierarchy for nodes +* Retry block encryption and report metric +* Wrap node not found into a dedicated exception + +## cs/v0.14.6 (2026-05-22) + +* Make last modification time optional for file uploads +* Show what was actually in the JSON when extended attributes cannot be parsed + ## cs/v0.14.5 (2026-05-18) * Fix missing disposal of reader in Sqlite cache repository From 428cb9f8a7fe89513b3fa0189fe1ebb819f87080 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 07:17:17 +0000 Subject: [PATCH 782/791] Verify added by email fields --- js/sdk/src/crypto/driveCrypto.ts | 44 +++++- js/sdk/src/crypto/interface.ts | 1 + js/sdk/src/crypto/openPGPCrypto.ts | 2 + js/sdk/src/internal/nodes/cryptoService.ts | 2 +- .../internal/sharing/cryptoService.test.ts | 138 +++++++++++++++++- js/sdk/src/internal/sharing/cryptoService.ts | 111 +++++++++++--- .../sharing/sharingManagement.test.ts | 9 +- .../src/internal/sharing/sharingManagement.ts | 19 ++- 8 files changed, 285 insertions(+), 41 deletions(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index d16336e4..fe5fe3ba 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -608,15 +608,26 @@ export class DriveCrypto { async verifyInvitation( base64KeyPacket: string, - armoredKeyPacketSignature: string, + // TODO: Make API consistent and use only one version. + keyPacketSignature: { armored: string } | { base64: string }, verificationKeys: PublicKey[], ): Promise<{ verified: VERIFICATION_STATUS; verificationErrors?: Error[]; }> { - const { verified, verificationErrors } = await this.openPGPCrypto.verifyArmored( + if ('armored' in keyPacketSignature) { + const { verified, verificationErrors } = await this.openPGPCrypto.verifyArmored( + Uint8Array.fromBase64(base64KeyPacket), + keyPacketSignature.armored, + verificationKeys, + SIGNING_CONTEXTS.SHARING_INVITER, + ); + return { verified, verificationErrors }; + } + + const { verified, verificationErrors } = await this.openPGPCrypto.verify( Uint8Array.fromBase64(base64KeyPacket), - armoredKeyPacketSignature, + Uint8Array.fromBase64(keyPacketSignature.base64), verificationKeys, SIGNING_CONTEXTS.SHARING_INVITER, ); @@ -653,10 +664,8 @@ export class DriveCrypto { ): Promise<{ base64ExternalInvitationSignature: string; }> { - const data = inviteeEmail.concat('|').concat(shareSessionKey.data.toBase64()); - const { signature: externalInviationSignature } = await this.openPGPCrypto.sign( - new TextEncoder().encode(data), + new TextEncoder().encode(externalInvitationSignaturePayload(inviteeEmail, shareSessionKey)), signingKey, SIGNING_CONTEXTS.SHARING_INVITER_EXTERNAL_INVITATION, ); @@ -665,6 +674,25 @@ export class DriveCrypto { }; } + async verifyExternalInvitation( + inviteeEmail: string, + shareSessionKey: SessionKey, + base64Signature: string, + verificationKeys: PublicKey[], + ): Promise<{ + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + const data = new TextEncoder().encode(externalInvitationSignaturePayload(inviteeEmail, shareSessionKey)); + const { verified, verificationErrors } = await this.openPGPCrypto.verify( + data, + Uint8Array.fromBase64(base64Signature), + verificationKeys, + SIGNING_CONTEXTS.SHARING_INVITER_EXTERNAL_INVITATION, + ); + return { verified, verificationErrors }; + } + async encryptThumbnailBlock( thumbnailData: Uint8Array, sessionKey: SessionKey, @@ -821,6 +849,10 @@ export class DriveCrypto { } } +function externalInvitationSignaturePayload(inviteeEmail: string, shareSessionKey: SessionKey): string { + return inviteeEmail.concat('|').concat(shareSessionKey.data.toBase64()); +} + export function uint8ArrayToUtf8(input: Uint8Array): string { return new TextDecoder('utf-8', { fatal: true }).decode(input); } diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts index c21890e4..b9aa0480 100644 --- a/js/sdk/src/crypto/interface.ts +++ b/js/sdk/src/crypto/interface.ts @@ -147,6 +147,7 @@ export interface OpenPGPCrypto { data: Uint8Array, signature: Uint8Array, verificationKeys: PublicKey | PublicKey[], + signatureContext?: string, ) => Promise<{ verified: VERIFICATION_STATUS; verificationErrors?: Error[]; diff --git a/js/sdk/src/crypto/openPGPCrypto.ts b/js/sdk/src/crypto/openPGPCrypto.ts index dc9d97b2..5191e5ac 100644 --- a/js/sdk/src/crypto/openPGPCrypto.ts +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -221,11 +221,13 @@ export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { data: Uint8Array, signature: Uint8Array, verificationKeys: PublicKey | PublicKey[], + signatureContext?: string, ) { const { verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ binaryData: data, binarySignature: signature, verificationKeys, + signatureContext: signatureContext ? { required: true, value: signatureContext } : undefined, }); return { verified: verificationStatus, diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index 30241013..eaaac590 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -412,7 +412,7 @@ export class NodesCryptoService { try { const { verified, verificationErrors } = await this.driveCrypto.verifyInvitation( node.encryptedCrypto.membership.base64MemberSharePassphraseKeyPacket, - node.encryptedCrypto.membership.armoredInviterSharePassphraseKeyPacketSignature, + { armored: node.encryptedCrypto.membership.armoredInviterSharePassphraseKeyPacketSignature }, inviterEmailKeys || [], ); diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts index 846cbbbf..d089879a 100644 --- a/js/sdk/src/internal/sharing/cryptoService.test.ts +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -1,14 +1,16 @@ -import { DriveCrypto, PrivateKey } from '../../crypto'; +import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; import { + MemberRole, MetricVolumeType, NodeType, + NonProtonInvitationState, ProtonDriveAccount, ProtonDriveTelemetry, resultError, resultOk, } from '../../interface'; import { getMockTelemetry } from '../../tests/telemetry'; -import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService'; +import { SharingCryptoService } from './cryptoService'; import { SharesService } from './interface'; describe('SharingCryptoService', () => { @@ -35,6 +37,7 @@ describe('SharingCryptoService', () => { getOwnAddress: jest.fn(async () => ({ keys: [{ key: 'addressKey' as unknown as PrivateKey }], })), + getPublicKeys: jest.fn(), }; // @ts-expect-error No need to implement all methods for mocking sharesService = { @@ -178,12 +181,141 @@ describe('SharingCryptoService', () => { url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), nodeName: resultError({ name: '', - error: "Name must not be empty", + error: 'Name must not be empty', }), }); }); }); + describe('decryptInvitation', () => { + const encryptedInvitation = { + uid: 'invitation-uid', + invitationTime: new Date(), + addedByEmail: 'inviter@example.com', + inviteeEmail: 'invitee@example.com', + role: MemberRole.Viewer, + base64KeyPacket: 'keyPacket', + base64KeyPacketSignature: 'keyPacketSignature', + }; + + beforeEach(() => { + account.getPublicKeys = jest.fn().mockResolvedValue(['publicKey']); + driveCrypto.verifyInvitation = jest.fn().mockResolvedValue({ + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }); + }); + + it('should verify addedByEmail when signature is valid', async () => { + const result = await cryptoService.decryptInvitation(encryptedInvitation); + + expect(result.addedByEmail).toEqual(resultOk('inviter@example.com')); + expect(driveCrypto.verifyInvitation).toHaveBeenCalledWith('keyPacket', { base64: 'keyPacketSignature' }, [ + 'publicKey', + ]); + }); + + it('should return unverified addedByEmail when signature is invalid', async () => { + driveCrypto.verifyInvitation = jest.fn().mockResolvedValue({ + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('Invalid signature')], + }); + + const result = await cryptoService.decryptInvitation(encryptedInvitation); + + expect(result.addedByEmail).toEqual( + resultError({ + claimedAuthor: 'inviter@example.com', + error: 'Signature verification failed: Invalid signature', + }), + ); + }); + + it('should return unverified addedByEmail when inviter keys cannot be loaded', async () => { + account.getPublicKeys = jest.fn().mockRejectedValue(new Error('Keys not found')); + driveCrypto.verifyInvitation = jest.fn().mockResolvedValue({ + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('Invalid signature')], + }); + + const result = await cryptoService.decryptInvitation(encryptedInvitation); + + expect(result.addedByEmail).toEqual( + resultError({ + claimedAuthor: 'inviter@example.com', + error: 'Verification keys are not available', + }), + ); + expect(driveCrypto.verifyInvitation).toHaveBeenCalledWith( + 'keyPacket', + { base64: 'keyPacketSignature' }, + [], + ); + }); + }); + + describe('decryptMember', () => { + const encryptedMember = { + uid: 'member-uid', + invitationTime: new Date(), + addedByEmail: 'inviter@example.com', + inviteeEmail: 'member@example.com', + role: MemberRole.Viewer, + base64KeyPacket: 'keyPacket', + base64KeyPacketSignature: 'keyPacketSignature', + }; + + beforeEach(() => { + account.getPublicKeys = jest.fn().mockResolvedValue(['publicKey']); + driveCrypto.verifyInvitation = jest.fn().mockResolvedValue({ + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }); + }); + + it('should verify addedByEmail when signature is valid', async () => { + const result = await cryptoService.decryptMember(encryptedMember); + + expect(result.addedByEmail).toEqual(resultOk('inviter@example.com')); + expect(driveCrypto.verifyInvitation).toHaveBeenCalledWith('keyPacket', { base64: 'keyPacketSignature' }, [ + 'publicKey', + ]); + }); + }); + + describe('decryptExternalInvitation', () => { + const encryptedInvitation = { + uid: 'external-invitation-uid', + invitationTime: new Date(), + addedByEmail: 'inviter@example.com', + inviteeEmail: 'invitee@example.com', + role: MemberRole.Viewer, + state: NonProtonInvitationState.Pending, + base64Signature: 'externalSignature', + }; + const sharePassphraseSessionKey = { data: new Uint8Array([1, 2, 3]) }; + + beforeEach(() => { + account.getPublicKeys = jest.fn().mockResolvedValue(['publicKey']); + driveCrypto.verifyExternalInvitation = jest.fn().mockResolvedValue({ + verified: VERIFICATION_STATUS.SIGNED_AND_VALID, + }); + }); + + it('should verify addedByEmail when signature is valid', async () => { + const result = await cryptoService.decryptExternalInvitation( + encryptedInvitation, + sharePassphraseSessionKey as SessionKey, + ); + + expect(result.addedByEmail).toEqual(resultOk('inviter@example.com')); + expect(driveCrypto.verifyExternalInvitation).toHaveBeenCalledWith( + 'invitee@example.com', + sharePassphraseSessionKey, + 'externalSignature', + ['publicKey'], + ); + }); + }); + describe('encryptBookmark', () => { const token = 'abc123token'; const urlPassword = 'generatedPass'; diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index e4b9141c..47cdb32a 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -1,16 +1,11 @@ import { c } from 'ttag'; -import { - DriveCrypto, - PrivateKey, - SessionKey, - SRPVerifier, - VERIFICATION_STATUS, -} from '../../crypto'; +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, SRPVerifier, VERIFICATION_STATUS } from '../../crypto'; import { DecryptionError } from '../../errors'; import { Author, InvalidNameError, + Logger, Member, MetricVolumeType, NonProtonInvitation, @@ -56,6 +51,8 @@ enum PublicLinkFlags { * shares, invitations, etc. */ export class SharingCryptoService { + private logger: Logger; + constructor( private telemetry: ProtonDriveTelemetry, private driveCrypto: DriveCrypto, @@ -63,6 +60,7 @@ export class SharingCryptoService { private sharesService: SharesService, ) { this.telemetry = telemetry; + this.logger = telemetry.getLogger('sharing-crypto'); this.driveCrypto = driveCrypto; this.account = account; this.sharesService = sharesService; @@ -193,7 +191,7 @@ export class SharingCryptoService { encryptedInvitation: EncryptedInvitationWithNode, ): Promise { const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); - const inviteeKeys = inviteeAddress.keys.map(k => k.key); + const inviteeKeys = inviteeAddress.keys.map((k) => k.key); const shareKey = await this.driveCrypto.decryptUnsignedKey( encryptedInvitation.share.armoredKey, @@ -226,8 +224,13 @@ export class SharingCryptoService { * Verifies an invitation. */ async decryptInvitation(encryptedInvitation: EncryptedInvitation): Promise { - // TODO: verify addedByEmail (current client doesnt do this) - const addedByEmail: Result = resultOk(encryptedInvitation.addedByEmail); + const addedByEmail = await this.verifyAddedByEmail(encryptedInvitation, async (publicKeys) => { + return this.driveCrypto.verifyInvitation( + encryptedInvitation.base64KeyPacket, + { base64: encryptedInvitation.base64KeyPacketSignature }, + publicKeys, + ); + }); return { uid: encryptedInvitation.uid, @@ -246,8 +249,12 @@ export class SharingCryptoService { }> { const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key; - const inviteeKeys = inviteeAddress.keys.map(k => k.key); - const result = await this.driveCrypto.acceptInvitation(encryptedInvitation.base64KeyPacket, inviteeKeys, inviteeKey); + const inviteeKeys = inviteeAddress.keys.map((k) => k.key); + const result = await this.driveCrypto.acceptInvitation( + encryptedInvitation.base64KeyPacket, + inviteeKeys, + inviteeKey, + ); return result; } @@ -275,9 +282,18 @@ export class SharingCryptoService { /** * Verifies an external invitation. */ - async decryptExternalInvitation(encryptedInvitation: EncryptedExternalInvitation): Promise { - // TODO: verify addedByEmail (current client doesnt do this) - const addedByEmail: Result = resultOk(encryptedInvitation.addedByEmail); + async decryptExternalInvitation( + encryptedInvitation: EncryptedExternalInvitation, + sharePassphraseSessionKey: SessionKey, + ): Promise { + const addedByEmail = await this.verifyAddedByEmail(encryptedInvitation, async (publicKeys) => { + return this.driveCrypto.verifyExternalInvitation( + encryptedInvitation.inviteeEmail, + sharePassphraseSessionKey, + encryptedInvitation.base64Signature, + publicKeys, + ); + }); return { uid: encryptedInvitation.uid, @@ -293,8 +309,13 @@ export class SharingCryptoService { * Verifies a member. */ async decryptMember(encryptedMember: EncryptedMember): Promise { - // TODO: verify addedByEmail (current client doesnt do this) - const addedByEmail: Result = resultOk(encryptedMember.addedByEmail); + const addedByEmail = await this.verifyAddedByEmail(encryptedMember, async (publicKeys) => { + return this.driveCrypto.verifyInvitation( + encryptedMember.base64KeyPacket, + { base64: encryptedMember.base64KeyPacketSignature }, + publicKeys, + ); + }); return { uid: encryptedMember.uid, @@ -305,6 +326,50 @@ export class SharingCryptoService { }; } + private async verifyAddedByEmail( + encryptedMetadata: { + uid: string; + addedByEmail: string; + invitationTime: Date; + }, + verifier: (publicKeys: PublicKey[]) => Promise<{ verified: VERIFICATION_STATUS; verificationErrors?: Error[] }>, + ): Promise> { + let addressPublicKeys; + try { + addressPublicKeys = await this.account.getPublicKeys(encryptedMetadata.addedByEmail); + } catch (error: unknown) { + this.logger.warn(`Failed to get inviter keys: ${getErrorMessage(error)}`); + } + + try { + const { verified, verificationErrors } = await verifier(addressPublicKeys || []); + + if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) { + return resultOk(encryptedMetadata.addedByEmail); + } + + this.telemetry.recordMetric({ + eventName: 'verificationError', + volumeType: MetricVolumeType.Unknown, + field: 'membershipInviter', + fromBefore2024: encryptedMetadata.invitationTime < new Date('2024-01-01'), + error: verificationErrors, + uid: encryptedMetadata.uid, + }); + + return resultError({ + claimedAuthor: encryptedMetadata.addedByEmail, + error: getVerificationMessage(verified, verificationErrors, undefined, !addressPublicKeys), + }); + } catch (error: unknown) { + this.logger.error(`Failed to verify added by email`, error); + return resultError({ + claimedAuthor: encryptedMetadata.addedByEmail, + error: c('Error').t`Failed to verify invitation`, + }); + } + } + async encryptPublicLink( creatorEmail: string, shareSessionKey: SessionKey, @@ -321,11 +386,7 @@ export class SharingCryptoService { const addressKey = address.keys[address.primaryKeyIndex].key; const { base64SharePasswordSalt, base64SharePassphraseKeyPacket, armoredPassword, srp } = - await this.driveCrypto.encryptPublicLinkPasswordAndSessionKey( - password, - addressKey, - shareSessionKey, - ); + await this.driveCrypto.encryptPublicLinkPasswordAndSessionKey(password, addressKey, shareSessionKey); return { crypto: { @@ -395,7 +456,11 @@ export class SharingCryptoService { } } - async encryptBookmark(token: string, urlPassword: string, customPassword?: string): Promise<{ + async encryptBookmark( + token: string, + urlPassword: string, + customPassword?: string, + ): Promise<{ token: string; encryptedUrlPassword: string; addressId: string; diff --git a/js/sdk/src/internal/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts index 79593031..53583576 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.test.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -78,7 +78,9 @@ describe('SharingManagement', () => { generateShareKeys: jest.fn().mockResolvedValue({ shareKey: { encrypted: 'encrypted-key', decrypted: { passphraseSessionKey: 'pass-session-key' } }, }), - decryptShare: jest.fn().mockImplementation((share) => share), + decryptShare: jest.fn().mockImplementation((share) => ({ + passphraseSessionKey: share.passphraseSessionKey, + })), decryptInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptExternalInvitation: jest.fn().mockImplementation((invitation) => invitation), decryptMember: jest.fn().mockImplementation((member) => member), @@ -170,7 +172,10 @@ describe('SharingManagement', () => { members: [], publicLink: undefined, }); - expect(cryptoService.decryptExternalInvitation).toHaveBeenCalledWith(externalInvitation); + expect(cryptoService.decryptExternalInvitation).toHaveBeenCalledWith( + externalInvitation, + 'sharePassphraseSessionKey', + ); }); it('should return members', async () => { diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index 30f5fc92..b9bcf1d2 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -78,13 +78,17 @@ export class SharingManagement { } const { volumeId } = splitNodeUid(nodeUid); + const [{ key: nodeKey }, encryptedShare] = await Promise.all([ + this.nodesService.getNodeKeys(nodeUid), + this.sharesService.loadEncryptedShare(node.shareId), + ]); + const { passphraseSessionKey } = await this.cryptoService.decryptShare(encryptedShare, nodeKey); - const [protonInvitations, nonProtonInvitations, members, publicLink, share] = await Promise.all([ + const [protonInvitations, nonProtonInvitations, members, publicLink] = await Promise.all([ Array.fromAsync(this.iterateShareInvitations(node.shareId)), - Array.fromAsync(this.iterateShareExternalInvitations(node.shareId)), + Array.fromAsync(this.iterateShareExternalInvitations(node.shareId, passphraseSessionKey)), Array.fromAsync(this.iterateShareMembers(node.shareId)), this.getPublicLink(node.shareId, volumeId), - this.sharesService.loadEncryptedShare(node.shareId), ]); return { @@ -92,7 +96,7 @@ export class SharingManagement { nonProtonInvitations, members, publicLink, - editorsCanShare: share.editorsCanShare, + editorsCanShare: encryptedShare.editorsCanShare, }; } @@ -103,10 +107,13 @@ export class SharingManagement { } } - private async *iterateShareExternalInvitations(shareId: string): AsyncGenerator { + private async *iterateShareExternalInvitations( + shareId: string, + sharePassphraseSessionKey: SessionKey, + ): AsyncGenerator { const invitations = await this.apiService.getShareExternalInvitations(shareId); for (const invitation of invitations) { - yield this.cryptoService.decryptExternalInvitation(invitation); + yield this.cryptoService.decryptExternalInvitation(invitation, sharePassphraseSessionKey); } } From e56de85cbd17b2122f8babf2c0ccf0888cc97263 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 09:51:16 +0200 Subject: [PATCH 783/791] Merge result error message with first error message --- kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt | 4 ++-- .../main/kotlin/me/proton/drive/sdk/extension/StringResult.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt index df1b8e9f..8feb93cf 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt @@ -37,8 +37,8 @@ private fun List.toException(message: String) = ProtonDriveSdkExcept } fun DriveError.toException(message: String): ProtonDriveSdkException = ProtonDriveSdkException( - message = message, - cause = toException(), + message = "$message: ${this@toException.message}", + cause = innerError?.toException(), ) fun DriveError.toException(): ProtonDriveException = ProtonDriveException( diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt index f6ce2fdf..aa7f702d 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt @@ -8,7 +8,7 @@ fun ProtonDriveSdk.StringResult.toEntity(): Result = Result.success(value) ProtonDriveSdk.StringResult.ResultCase.ERROR -> - Result.failure(error.toEntity().toException("Name unavailable")) + Result.failure(error.toEntity().toException("String result failure")) ProtonDriveSdk.StringResult.ResultCase.RESULT_NOT_SET, null -> error("Invalid StringResult: result not set") From aac35b16c3b30a8cbb45c99540c8ac1103cd0a68 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 14:12:07 +0000 Subject: [PATCH 784/791] Use photos API when fetching album and photo node details --- .../InteropFileUploader.cs | 2 ++ .../InteropPhotosUploader.cs | 2 ++ .../Api/DriveApiClientsExtensions.cs | 17 ++++++++++ .../Nodes/Download/FileDownloader.cs | 1 + .../Nodes/Download/PhotosFileDownloader.cs | 1 + .../Nodes/DtoToMetadataConverter.cs | 6 +++- .../Proton.Drive.Sdk/Nodes/FileOperations.cs | 9 ++--- .../Nodes/FolderOperations.cs | 10 +++--- .../Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs | 5 ++- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 33 +++++++++++-------- .../Nodes/PhotosNodeOperations.cs | 1 + .../Nodes/RevisionOperations.cs | 2 ++ .../Nodes/TraversalOperations.cs | 21 +++++++++--- .../Nodes/Upload/FileUploader.cs | 9 ++++- .../Nodes/Upload/IRevisionDraftProvider.cs | 2 +- .../Nodes/Upload/NewFileDraftProvider.cs | 4 +-- .../Nodes/Upload/NewRevisionDraftProvider.cs | 4 +-- .../Nodes/Upload/RevisionWriter.cs | 2 +- .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 2 +- .../Proton.Drive.Sdk/ProtonPhotosClient.cs | 2 +- .../Volumes/VolumeOperations.cs | 3 +- .../Volumes/VolumeTrashBatchLoader.cs | 8 +++-- 22 files changed, 104 insertions(+), 42 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClientsExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs index 29867b74..529a3b54 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -36,6 +36,7 @@ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, n thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), expectedSha1Provider, + forPhotos: false, cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; @@ -65,6 +66,7 @@ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), expectedSha1Provider, + forPhotos: false, cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs index 52ea5305..1a62f55a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs @@ -34,6 +34,7 @@ public static IMessage HandleUploadFromStream(DrivePhotosClientUploadFromStreamR thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), expectedSha1Provider, + forPhotos: true, cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; @@ -63,6 +64,7 @@ public static IMessage HandleUploadFromFile(DrivePhotosClientUploadFromFileReque thumbnails, (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), expectedSha1Provider, + forPhotos: true, cancellationToken); return new Int64Value { Value = Interop.AllocHandle(uploadController) }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClientsExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClientsExtensions.cs new file mode 100644 index 00000000..e1bfb451 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClientsExtensions.cs @@ -0,0 +1,17 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api; + +internal static class DriveApiClientsExtensions +{ + public static ValueTask GetLinkDetailsAsync( + this IDriveApiClients api, + VolumeId volumeId, + IEnumerable linkIds, + bool forPhotos, + CancellationToken cancellationToken) + => forPhotos + ? api.Photos.GetDetailsAsync(volumeId, linkIds, cancellationToken) + : api.Links.GetDetailsAsync(volumeId, linkIds, cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 7a17bbbe..092d820a 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -82,6 +82,7 @@ private async Task DownloadToStreamAsync( _client, _revisionUid, queueToken, + forPhotos: false, cancellationToken).ConfigureAwait(false); downloadStateTaskCompletionSource.SetResult(downloadState); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index 9569018f..55de16e9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -86,6 +86,7 @@ private async Task DownloadToStreamAsync( _client.DriveClient, fileNode.ActiveRevision.Uid, _queueToken, + forPhotos: true, cancellationToken).ConfigureAwait(false); downloadStateTaskCompletionSource.SetResult(state); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 34609a5e..94dd35ab 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; @@ -31,6 +32,7 @@ public static async Task ConvertDtoToNodeMetadataAsync( linkDetailsDto.Link.ParentId, knownShareAndKey, linkDetailsDto.Sharing?.ShareId, + forPhotos: false, cancellationToken).ConfigureAwait(false) : await GetAlbumEntryPointKeyOrThrowAsync(client, volumeId, linkDetailsDto, knownShareAndKey, albumInclusions, cancellationToken) .ConfigureAwait(false); @@ -667,6 +669,7 @@ private static async ValueTask GetEntryPointKeyOrThrowAsync( LinkId? parentId, ShareAndKey? shareAndKeyToUse, ShareId? shareId, + bool forPhotos, CancellationToken cancellationToken) { return await GetEntryPointKeyOrThrowAsync( @@ -681,7 +684,7 @@ private static async ValueTask GetEntryPointKeyOrThrowAsync( async Task GetLinkDetailsAsync(LinkId linkId, CancellationToken ct) { - var response = await client.Api.Links.GetDetailsAsync(volumeId, [linkId], ct).ConfigureAwait(false); + var response = await client.Api.GetLinkDetailsAsync(volumeId, [linkId], forPhotos, ct).ConfigureAwait(false); return response.Links is { Count: > 0 } links ? links[0] @@ -711,6 +714,7 @@ private static async Task GetAlbumEntryPointKeyOrThrowAsync( albumInclusionId, knownShareAndKey, linkDetailsDto.Sharing?.ShareId, + forPhotos: true, cancellationToken).ConfigureAwait(false); } catch (Exception ex) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 2a592389..9a713608 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -7,13 +7,13 @@ internal static class FileOperations { private const int MaxThumbnailIdsPerRequest = 30; - public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, CancellationToken cancellationToken) + public static async ValueTask GetSecretsAsync(ProtonDriveClient client, NodeUid fileUid, bool forPhotos, CancellationToken cancellationToken) { var fileSecrets = await client.Cache.Secrets.TryGetFileSecretsAsync(fileUid, cancellationToken).ConfigureAwait(false); if (fileSecrets is null) { - var metadata = await NodeOperations.GetFreshNodeMetadataAsync(client, fileUid, knownShareAndKey: null, cancellationToken) + var metadata = await NodeOperations.GetFreshNodeMetadataAsync(client, fileUid, knownShareAndKey: null, forPhotos, cancellationToken) .ConfigureAwait(false); fileSecrets = metadata.GetFileSecretsOrThrow(); @@ -116,7 +116,7 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( await client.ThumbnailDownloadQueue.EnqueueBlockAsync(cancellationToken).ConfigureAwait(false); } - tasks.Enqueue(DownloadThumbnailAsync(client, nodeInfo.ActiveRevisionUid, block, cancellationToken)); + tasks.Enqueue(DownloadThumbnailAsync(client, nodeInfo.ActiveRevisionUid, block, forPhotos, cancellationToken)); } foreach (var error in response.Errors) @@ -151,6 +151,7 @@ private static async Task DownloadThumbnailAsync( ProtonDriveClient client, RevisionUid revisionUid, ThumbnailBlock block, + bool forPhotos, CancellationToken cancellationToken) { const int initialBufferLength = 64 * 1024; @@ -160,7 +161,7 @@ private static async Task DownloadThumbnailAsync( var outputStream = new MemoryStream(initialBufferLength); await using (outputStream.ConfigureAwait(false)) { - var fileSecrets = await GetSecretsAsync(client, revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + var fileSecrets = await GetSecretsAsync(client, revisionUid.NodeUid, forPhotos, cancellationToken).ConfigureAwait(false); var contentKey = fileSecrets.ContentKey ?? throw new InvalidOperationException($"Content key not available for file {revisionUid.NodeUid}"); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index 5ccd210c..ead9a38e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -19,7 +19,7 @@ public static async IAsyncEnumerable EnumerateChildrenAsync( var anchorLinkId = default(LinkId?); var mustTryMoreResults = true; - var folderSecretsResult = await GetSecretsAsync(client, folderUid, cancellationToken).ConfigureAwait(false); + var folderSecretsResult = await GetSecretsAsync(client, folderUid, forPhotos: false, cancellationToken).ConfigureAwait(false); var folderKey = folderSecretsResult.Key ?? throw new ProtonDriveException($"Node key not available for folder {folderUid}"); @@ -74,7 +74,7 @@ public static async ValueTask CreateAsync( var parentOwnedBy = parentResult.OwnedBy; - var (parentKey, parentHashKey) = await GetKeyAndHashKeyAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var (parentKey, parentHashKey) = await GetKeyAndHashKeyAsync(client, parentUid, forPhotos: false, cancellationToken).ConfigureAwait(false); var membershipAddress = await NodeOperations.GetMembershipAddressAsync(client, parentUid, cancellationToken).ConfigureAwait(false); @@ -159,13 +159,14 @@ public static async ValueTask CreateAsync( public static async ValueTask GetSecretsAsync( ProtonDriveClient client, NodeUid folderUid, + bool forPhotos, CancellationToken cancellationToken) { var result = await client.Cache.Secrets.TryGetFolderSecretsAsync(folderUid, cancellationToken).ConfigureAwait(false); if (result is null) { - var nodeMetadata = await NodeOperations.GetFreshNodeMetadataAsync(client, folderUid, knownShareAndKey: null, cancellationToken) + var nodeMetadata = await NodeOperations.GetFreshNodeMetadataAsync(client, folderUid, knownShareAndKey: null, forPhotos, cancellationToken) .ConfigureAwait(false); result = nodeMetadata.GetFolderSecretsOrThrow(); @@ -177,9 +178,10 @@ public static async ValueTask GetSecretsAsync( public static async ValueTask<(PgpPrivateKey Key, ReadOnlyMemory HashKey)> GetKeyAndHashKeyAsync( ProtonDriveClient client, NodeUid folderUid, + bool forPhotos, CancellationToken cancellationToken) { - var secretsResult = await GetSecretsAsync(client, folderUid, cancellationToken).ConfigureAwait(false); + var secretsResult = await GetSecretsAsync(client, folderUid, forPhotos, cancellationToken).ConfigureAwait(false); var key = secretsResult.Key ?? throw new ProtonDriveException($"Parent folder key not available for {folderUid}"); var hashKey = secretsResult.HashKey ?? throw new ProtonDriveException($"Parent folder hash key not available for {folderUid}"); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs index e2a7fa39..fe32c910 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Volumes; @@ -12,9 +13,7 @@ internal sealed class NodeBatchLoader(ProtonDriveClient client, VolumeId volumeI protected override async IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, [EnumeratorCancellation] CancellationToken cancellationToken) { - var response = _forPhotos - ? await _client.Api.Photos.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false) - : await _client.Api.Links.GetDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); + var response = await _client.Api.GetLinkDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), _forPhotos, cancellationToken).ConfigureAwait(false); foreach (var linkDetails in response.Links) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 3075541b..4d48ecb9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using System.Text; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Cryptography; using Proton.Drive.Sdk.Nodes.Cryptography; @@ -45,7 +46,7 @@ public static async ValueTask GetOrCreateMyFilesFolderAsync(ProtonDr var shareAndKey = await ShareOperations.GetShareAsync(client, shareId.Value, useCacheOnly: false, cancellationToken).ConfigureAwait(false); - var metadata = await GetNodeMetadataAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, useCacheOnly: false, cancellationToken) + var metadata = await GetNodeMetadataAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, useCacheOnly: false, forPhotos: false, cancellationToken) .ConfigureAwait(false); return metadata.GetFolderNodeOrThrow(); @@ -56,6 +57,7 @@ public static async ValueTask GetNodeMetadataAsync( NodeUid uid, ShareAndKey? knownShareAndKey, bool useCacheOnly, + bool forPhotos, CancellationToken cancellationToken) { var metadataResult = await TryGetNodeMetadataFromCacheAsync(client, uid, cancellationToken).ConfigureAwait(false); @@ -67,7 +69,7 @@ public static async ValueTask GetNodeMetadataAsync( throw new NodeNotFoundException(uid); } - metadataResult = await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, cancellationToken).ConfigureAwait(false); + metadataResult = await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, forPhotos, cancellationToken).ConfigureAwait(false); } return metadataResult.Value; @@ -152,9 +154,10 @@ public static async ValueTask GetFreshNodeMetadataAsync( ProtonDriveClient client, NodeUid uid, ShareAndKey? knownShareAndKey, + bool forPhotos, CancellationToken cancellationToken) { - var response = await client.Api.Links.GetDetailsAsync(uid.VolumeId, [uid.LinkId], cancellationToken).ConfigureAwait(false); + var response = await client.Api.GetLinkDetailsAsync(uid.VolumeId, [uid.LinkId], forPhotos, cancellationToken).ConfigureAwait(false); return await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( client, @@ -177,7 +180,7 @@ public static async ValueTask MoveSingleAsync( using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, forPhotos: false, cancellationToken).ConfigureAwait(false); var destinationKey = destinationFolderSecrets.Key ?? throw new ProtonDriveException($"Destination folder key not available for {newParentUid}"); @@ -195,7 +198,7 @@ public static async ValueTask MoveSingleAsync( throw new InvalidOperationException($"Node {uid} cannot have destination node {newParentUid} as parent as they are not on the same volume"); } - var originMetadata = await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + var originMetadata = await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, forPhotos: false, cancellationToken).ConfigureAwait(false); var (originNode, originSecrets, membershipShareId, originNameHashDigest) = originMetadata; var originName = originNode.Name.GetValueOrThrow(); @@ -258,7 +261,7 @@ public static async Task MoveMultipleAsync( using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + var destinationFolderSecrets = await FolderOperations.GetSecretsAsync(client, newParentUid, forPhotos: false, cancellationToken).ConfigureAwait(false); var destinationKey = destinationFolderSecrets.Key ?? throw new ProtonDriveException($"Destination folder key not available for {newParentUid}"); @@ -277,7 +280,7 @@ public static async Task MoveMultipleAsync( // FIXME: Try to use the degraded node if it has enough for the move to be successful var (originNode, originSecrets, _, originNameHashDigest) = - await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, forPhotos: false, cancellationToken).ConfigureAwait(false); var originName = originNode.Name.GetValueOrThrow(); @@ -333,7 +336,7 @@ public static async ValueTask RenameAsync( { // FIXME: Try to use the degraded node if it has enough for the move to be successful var (node, secrets, membershipShareId, originalNameHashDigest) = - await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, forPhotos: false, cancellationToken).ConfigureAwait(false); if (node.ParentUid is not { } parentUid) { @@ -344,7 +347,9 @@ public static async ValueTask RenameAsync( var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); - var (parentKey, parentHashKey) = await FolderOperations.GetKeyAndHashKeyAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var (parentKey, parentHashKey) = await FolderOperations + .GetKeyAndHashKeyAsync(client, parentUid, forPhotos: false, cancellationToken) + .ConfigureAwait(false); var nameSessionKey = secrets.NameSessionKey ?? throw new ProtonDriveException($"Name session key not available for {uid}"); @@ -522,7 +527,7 @@ public static async ValueTask GetAvailableNameAsync(ProtonDriveClient cl { const int batchSize = 10; - var folderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var folderSecrets = await FolderOperations.GetSecretsAsync(client, parentUid, forPhotos: false, cancellationToken).ConfigureAwait(false); var folderHashKey = folderSecrets.HashKey ?? throw new ProtonDriveException($"Folder hash key not available for {parentUid}"); @@ -608,16 +613,18 @@ public static bool ValidateName( return true; } - public static async Task> GetParentFolderHashKeyAsync(ProtonDriveClient client, NodeUid uid, CancellationToken cancellationToken) + public static async Task> GetParentFolderHashKeyAsync( + ProtonDriveClient client, NodeUid uid, bool forPhotos, CancellationToken cancellationToken) { - var (node, _, _, _) = await GetNodeMetadataAsync(client, uid, knownShareAndKey: null, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + var (node, _, _, _) = await GetNodeMetadataAsync( + client, uid, knownShareAndKey: null, useCacheOnly: false, forPhotos, cancellationToken).ConfigureAwait(false); if (node.ParentUid is not { } parentUid) { throw new InvalidOperationException("Root node does not have a parent folder"); } - var (_, hashKey) = await FolderOperations.GetKeyAndHashKeyAsync(client, parentUid, cancellationToken).ConfigureAwait(false); + var (_, hashKey) = await FolderOperations.GetKeyAndHashKeyAsync(client, parentUid, forPhotos, cancellationToken).ConfigureAwait(false); return hashKey; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs index dbe918c2..c8c6b883 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs @@ -42,6 +42,7 @@ public static async ValueTask GetOrCreatePhotosFolderAsync(ProtonDri shareAndKey.Share.RootFolderId, shareAndKey, useCacheOnly: false, + forPhotos: true, cancellationToken).ConfigureAwait(false); return metadata.GetFolderNodeOrThrow(); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs index a3d3dbff..d3de2b4b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -20,6 +20,7 @@ internal static async ValueTask CreateDownloadStateAsync( ProtonDriveClient client, RevisionUid revisionUid, long queueToken, + bool forPhotos, CancellationToken cancellationToken) { var (fileUid, revisionId) = revisionUid; @@ -27,6 +28,7 @@ internal static async ValueTask CreateDownloadStateAsync( var secretsTask = FileOperations.GetSecretsAsync( client, revisionUid.NodeUid, + forPhotos, cancellationToken).AsTask(); var revisionTask = client.Api.Files.GetRevisionAsync( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs index 61010644..dd7f97ea 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs @@ -9,7 +9,9 @@ public static async ValueTask FindRootForNode( CancellationToken cancellationToken) { var currentMetadata = nodeMetadata; - var entryPointUid = currentMetadata.Node.ParentUid ?? GetAlbumEntryPointUid(currentMetadata); + var forPhotos = nodeMetadata.Node is PhotoNode; + var (entryPointUid, nextForPhotos) = GetNextEntryPoint(currentMetadata); + forPhotos |= nextForPhotos; HashSet visitedNodes = []; @@ -25,16 +27,27 @@ public static async ValueTask FindRootForNode( (NodeUid)entryPointUid, knownShareAndKey: null, useCacheOnly, + forPhotos, cancellationToken).ConfigureAwait(false); - entryPointUid = currentMetadata.Node.ParentUid ?? GetAlbumEntryPointUid(currentMetadata); + (entryPointUid, nextForPhotos) = GetNextEntryPoint(currentMetadata); + forPhotos |= nextForPhotos; } return currentMetadata; } - private static NodeUid? GetAlbumEntryPointUid(NodeMetadata nodeMetadata) + private static (NodeUid? Uid, bool ForPhotos) GetNextEntryPoint(NodeMetadata nodeMetadata) { - return nodeMetadata.Node is PhotoNode { AlbumUids.Count: > 0 } photo ? photo.AlbumUids[0] : null; + if (nodeMetadata.Node.ParentUid is { } parentUid) + { + return (parentUid, nodeMetadata.Node is PhotoNode); + } + + var albumUid = nodeMetadata.Node is PhotoNode { AlbumUids.Count: > 0 } photo + ? (NodeUid?)photo.AlbumUids[0] + : null; + + return (albumUid, albumUid is not null); } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index ebe79ed4..4f80724b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -40,6 +40,7 @@ public UploadController UploadFromStream( IEnumerable thumbnails, Action? onProgress, Func>? expectedSha1Provider, + bool forPhotos, CancellationToken cancellationToken) { return UploadFromStream( @@ -48,6 +49,7 @@ public UploadController UploadFromStream( thumbnails, onProgress, expectedSha1Provider, + forPhotos, cancellationToken); } @@ -56,6 +58,7 @@ public UploadController UploadFromFile( IEnumerable thumbnails, Action? onProgress, Func>? expectedSha1Provider, + bool forPhotos, CancellationToken cancellationToken) { var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); @@ -66,6 +69,7 @@ public UploadController UploadFromFile( thumbnails, onProgress, expectedSha1Provider, + forPhotos, cancellationToken); } @@ -138,6 +142,7 @@ private UploadController UploadFromStream( IEnumerable thumbnails, Action? onProgress, Func>? expectedSha1Provider, + bool forPhotos, CancellationToken cancellationToken) { var taskControl = new TaskControl(cancellationToken); @@ -152,6 +157,7 @@ private UploadController UploadFromStream( progress => onProgress?.Invoke(progress, FileSize), expectedSha1, revisionDraftTaskCompletionSource, + forPhotos, ct); return new UploadController( @@ -199,12 +205,13 @@ private async Task UploadFromStreamAsync( Action? onProgress, Lazy>? expectedSha1, TaskCompletionSource revisionDraftTaskCompletionSource, + bool forPhotos, CancellationToken cancellationToken) { var revisionDraft = revisionDraftTaskCompletionSource.Task.GetResultIfCompletedSuccessfully(); if (revisionDraft is null) { - revisionDraft = await _revisionDraftProvider.GetDraftAsync(FileSize, cancellationToken).ConfigureAwait(false); + revisionDraft = await _revisionDraftProvider.GetDraftAsync(FileSize, forPhotos, cancellationToken).ConfigureAwait(false); revisionDraftTaskCompletionSource.SetResult(revisionDraft); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs index 472d4921..67f76b17 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs @@ -2,5 +2,5 @@ namespace Proton.Drive.Sdk.Nodes.Upload; internal interface IRevisionDraftProvider { - ValueTask GetDraftAsync(long intendedUploadSize, CancellationToken cancellationToken); + ValueTask GetDraftAsync(long intendedUploadSize, bool forPhotos, CancellationToken cancellationToken); } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index d4c03562..656ab357 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -29,11 +29,11 @@ internal NewFileDraftProvider( _overrideExistingDraftByOtherClient = overrideExistingDraftByOtherClient; } - public async ValueTask GetDraftAsync(long intendedUploadSize, CancellationToken cancellationToken) + public async ValueTask GetDraftAsync(long intendedUploadSize, bool forPhotos, CancellationToken cancellationToken) { ArgumentOutOfRangeException.ThrowIfNegative(intendedUploadSize); - var (parentKey, parentHashKey) = await FolderOperations.GetKeyAndHashKeyAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); + var (parentKey, parentHashKey) = await FolderOperations.GetKeyAndHashKeyAsync(_client, _parentUid, forPhotos, cancellationToken).ConfigureAwait(false); var membershipAddress = await NodeOperations.GetMembershipAddressAsync(_client, _parentUid, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs index 5a7712d6..9edb55be 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -22,7 +22,7 @@ internal NewRevisionDraftProvider( _lastKnownRevisionId = lastKnownRevisionId; } - public async ValueTask GetDraftAsync(long intendedUploadSize, CancellationToken cancellationToken) + public async ValueTask GetDraftAsync(long intendedUploadSize, bool forPhotos, CancellationToken cancellationToken) { ArgumentOutOfRangeException.ThrowIfNegative(intendedUploadSize); @@ -33,7 +33,7 @@ public async ValueTask GetDraftAsync(long intendedUploadSize, Can IntendedUploadSize = intendedUploadSize, }; - var fileSecrets = await FileOperations.GetSecretsAsync(_client, _fileUid, cancellationToken).ConfigureAwait(false); + var fileSecrets = await FileOperations.GetSecretsAsync(_client, _fileUid, forPhotos, cancellationToken).ConfigureAwait(false); if (fileSecrets is not { Key: { } nodeKey, ContentKey: { } contentKey }) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs index 5ac2a434..2934c1a8 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -59,7 +59,7 @@ public async ValueTask WriteAsync( if (metadata is PhotosFileUploadMetadata photoMetadata) { var hashKey = _draft.ParentHashKey - ?? await NodeOperations.GetParentFolderHashKeyAsync(_client, _draft.Uid.NodeUid, cancellationToken).ConfigureAwait(false); + ?? await NodeOperations.GetParentFolderHashKeyAsync(_client, _draft.Uid.NodeUid, forPhotos: true, cancellationToken).ConfigureAwait(false); request = CreatePhotosRevisionUpdateRequest( photoMetadata, diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index edd1085f..c359d537 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -321,7 +321,7 @@ public async IAsyncEnumerable EnumerateTrashAsync([EnumeratorCancellation] yield break; } - await foreach (var entry in VolumeOperations.EnumerateTrashAsync(this, volumeId.Value, cancellationToken).ConfigureAwait(false)) + await foreach (var entry in VolumeOperations.EnumerateTrashAsync(this, volumeId.Value, forPhotos: false, cancellationToken).ConfigureAwait(false)) { yield return entry; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs index b6d72f9e..b1c6d088 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -156,7 +156,7 @@ public async IAsyncEnumerable EnumerateTrashAsync([EnumeratorCancellation] yield break; } - await foreach (var item in VolumeOperations.EnumerateTrashAsync(DriveClient, volumeId.Value, cancellationToken).ConfigureAwait(false)) + await foreach (var item in VolumeOperations.EnumerateTrashAsync(DriveClient, volumeId.Value, forPhotos: true, cancellationToken).ConfigureAwait(false)) { yield return item; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 0b226db2..d2a6cd77 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -61,6 +61,7 @@ internal static class VolumeOperations public static async IAsyncEnumerable EnumerateTrashAsync( ProtonDriveClient client, VolumeId volumeId, + bool forPhotos, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var page = 0; @@ -76,7 +77,7 @@ public static async IAsyncEnumerable EnumerateTrashAsync( { var (_, shareKey) = await ShareOperations.GetShareAsync(client, shareId, useCacheOnly: false, cancellationToken).ConfigureAwait(false); - var batchLoader = new VolumeTrashBatchLoader(client, volumeId, shareKey); + var batchLoader = new VolumeTrashBatchLoader(client, volumeId, shareKey, forPhotos); foreach (var linkId in linkIds) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs index 724be7d0..b750f315 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -1,23 +1,25 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api; using Proton.Drive.Sdk.Api.Links; using Proton.Drive.Sdk.Nodes; namespace Proton.Drive.Sdk.Volumes; -internal sealed class VolumeTrashBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey shareKey) +internal sealed class VolumeTrashBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey shareKey, bool forPhotos) : BatchLoaderBase { private readonly ProtonDriveClient _client = client; private readonly VolumeId _volumeId = volumeId; private readonly PgpPrivateKey _shareKey = shareKey; + private readonly bool _forPhotos = forPhotos; private readonly Dictionary _parentKeys = []; protected override async IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, [EnumeratorCancellation] CancellationToken cancellationToken) { - var response = await _client.Api.Links.GetDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), cancellationToken).ConfigureAwait(false); + var response = await _client.Api.GetLinkDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), _forPhotos, cancellationToken).ConfigureAwait(false); foreach (var linkDetails in response.Links) { @@ -27,7 +29,7 @@ protected override async IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory
  • Date: Thu, 28 May 2026 14:53:46 +0000 Subject: [PATCH 785/791] Fix node secrets not being read from cache --- cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs | 4 +--- cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs | 1 - cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs | 2 +- .../src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs | 1 - cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs | 2 -- 5 files changed, 2 insertions(+), 8 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 94dd35ab..9b489f8c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -406,7 +406,6 @@ private static (FileMetadata Metadata, Dictionary (PgpSessionKey?)x.SessionKey, _ => null), NameSessionKey = nameSessionKey, ContentKey = decryptionResult.ContentKey.Merge(x => (PgpSessionKey?)x.Data, _ => null), - PassphraseForAnonymousMove = null, }; return (new FileMetadata(partialNode, partialSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); @@ -572,7 +571,6 @@ private static (FolderMetadata Metadata, Dictionary (PgpSessionKey?)x.SessionKey, _ => null), NameSessionKey = nameSessionKey, HashKey = decryptionResult.HashKey.Merge(x => (ReadOnlyMemory?)x.Data, _ => null), - PassphraseForAnonymousMove = null, }; return (new FolderMetadata(partialNode, partialSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); @@ -621,7 +619,7 @@ private static async ValueTask GetEntryPointKeyOrThrowAsync( break; } - var linkDetails = await getLinkDetails(currentId.Value, cancellationToken).ConfigureAwait(false); + var linkDetails = await getLinkDetails.Invoke(currentId.Value, cancellationToken).ConfigureAwait(false); linkAncestry.Push(linkDetails); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index ead9a38e..a6b9a6b7 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -132,7 +132,6 @@ public static async ValueTask CreateAsync( PassphraseSessionKey = passphraseSessionKey, NameSessionKey = nameSessionKey, HashKey = hashKey, - PassphraseForAnonymousMove = null, }; await client.Cache.Secrets.SetFolderSecretsAsync(folderUid, folderSecrets, cancellationToken).ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs index 8568f75b..85cd2439 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs @@ -11,5 +11,5 @@ internal class NodeSecrets [JsonPropertyName("passphrase")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public required ReadOnlyMemory? PassphraseForAnonymousMove { get; set; } + public ReadOnlyMemory? PassphraseForAnonymousMove { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs index 656ab357..9e8b7d08 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -157,7 +157,6 @@ private static FileCreationRequest GetFileCreationRequest( PassphraseSessionKey = passphraseSessionKey, NameSessionKey = nameSessionKey, ContentKey = contentKey, - PassphraseForAnonymousMove = null, }; var draftNodeUid = new NodeUid(_parentUid.VolumeId, response.Identifiers.LinkId); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index d2a6cd77..e188646d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -205,7 +205,6 @@ private static VolumeCreationRequest GetCreationRequest( PassphraseSessionKey = rootFolderPassphraseSessionKey, NameSessionKey = rootFolderNameSessionKey, HashKey = rootFolderHashKey, - PassphraseForAnonymousMove = null, }; Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; @@ -266,7 +265,6 @@ private static PhotosVolumeCreationRequest GetPhotosCreationRequest( PassphraseSessionKey = rootFolderPassphraseSessionKey, NameSessionKey = rootFolderNameSessionKey, HashKey = rootFolderHashKey, - PassphraseForAnonymousMove = null, }; Span sharePassphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; From f695c766ebed0e523e17fc6a279ce6330be7cf12 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 16:48:02 +0000 Subject: [PATCH 786/791] Support more modification time formats and report invalid ones as node errors --- .../Api/Files/CommonExtendedAttributes.cs | 9 ++- .../JsonExceptionExtensions.cs | 11 ++-- .../Nodes/Cryptography/NodeCrypto.cs | 6 +- .../Nodes/DtoToMetadataConverter.cs | 16 ++++- .../ExtendedAttributesDeserializationError.cs | 8 ++- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 4 +- .../Iso8601DateTimeResultJsonConverter.cs | 63 +++++++++++++++++++ cs/sdk/src/Proton.Sdk/MemoryPolicy.cs | 32 ++++++++++ cs/sdk/src/Proton.Sdk/MemoryProvider.cs | 24 ------- .../ForgivingBytesToHexJsonConverter.cs | 45 +++++++------ .../PgpArmoredBlockJsonConverterBase.cs | 22 +++++-- .../Serialization/Utf8JsonReaderExtensions.cs | 25 ++++++++ 12 files changed, 194 insertions(+), 71 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Serialization/Iso8601DateTimeResultJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/MemoryPolicy.cs delete mode 100644 cs/sdk/src/Proton.Sdk/MemoryProvider.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/Utf8JsonReaderExtensions.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs index f2cd1514..a3628e85 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs @@ -1,10 +1,15 @@ -namespace Proton.Drive.Sdk.Api.Files; +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Api.Files; internal sealed class CommonExtendedAttributes { public long? Size { get; init; } - public DateTime? ModificationTime { get; init; } + [JsonConverter(typeof(Iso8601DateTimeResultJsonConverter))] + public Result? ModificationTime { get; init; } public IReadOnlyList? BlockSizes { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs index e2254c8e..86fea81e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs @@ -4,7 +4,7 @@ namespace Proton.Drive.Sdk; internal static class JsonExceptionExtensions { - internal static ProtonDriveError EnrichJsonException(this JsonException e, ReadOnlyMemory json) + internal static ProtonDriveError ToEnrichedProtonDriveError(this JsonException e, ReadOnlyMemory json) { if (e.Path is not { Length: > 0 }) { @@ -15,17 +15,18 @@ internal static ProtonDriveError EnrichJsonException(this JsonException e, ReadO { using var doc = JsonDocument.Parse(json); - if (TryGetElementAtPath(doc.RootElement, e.Path, out var element)) + if (!TryGetElementAtPath(doc.RootElement, e.Path, out var element)) { - return new ProtonDriveError($"Actual token at path '{e.Path}' is {ValueKindToToken(element.ValueKind)}.", e.ToProtonDriveError()); + return e.ToProtonDriveError(); } + + return new ProtonDriveError($"Actual token at path '{e.Path}' is {ValueKindToToken(element.ValueKind)}.", e.ToProtonDriveError()); } catch (JsonException) { // Secondary parse failed. + return e.ToProtonDriveError(); } - - return e.ToProtonDriveError(); } private static bool TryGetElementAtPath(JsonElement root, string path, out JsonElement element) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs index b170a31c..70a9c7e3 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -65,8 +65,8 @@ public static async ValueTask DecryptFileAsync( public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHashKey) { - var maxNameByteLength = Encoding.UTF8.GetByteCount(name); - var nameBytes = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) + var maxNameByteLength = Encoding.UTF8.GetMaxByteCount(name.Length); + var nameBytes = MemoryPolicy.GetRentedHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) ? nameHeapMemoryOwner.Memory.Span : stackalloc byte[maxNameByteLength]; @@ -119,7 +119,7 @@ public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHa } catch (JsonException e) { - return new ExtendedAttributesDeserializationError(e.EnrichJsonException(serializedExtendedAttributes)); + return new ExtendedAttributesDeserializationError(e.ToEnrichedProtonDriveError(serializedExtendedAttributes)); } catch (Exception e) { diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs index 9b489f8c..ee121ad9 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -173,12 +173,15 @@ public static async Task ConvertDtoToFileMetadataAsync( var extendedAttributes = extendedAttributesOutput.Data; var additionalMetadata = extendedAttributes?.AdditionalMetadata?.Select(x => new AdditionalMetadataProperty(x.Key, x.Value)).ToList().AsReadOnly(); + var modificationTimeResult = extendedAttributes?.Common?.ModificationTime; + var modificationTimeIsValid = modificationTimeResult?.IsSuccess ?? true; if (!NodeOperations.ValidateName(decryptionResult.Link.Name, out var nameOutput, out var nameResult, out var nameSessionKey) || !nodeKeyIsValid || !passphraseIsValid || !extendedAttributesIsValid - || !contentKeyIsValid) + || !contentKeyIsValid + || !modificationTimeIsValid) { var (partialFileMetadata, failedDecryptionFields) = CreatePartialFileMetadata( linkDetailsDto, @@ -187,6 +190,7 @@ public static async Task ConvertDtoToFileMetadataAsync( uid, activeRevisionDto, extendedAttributes, + modificationTimeResult, thumbnails, additionalMetadata, parentUid, @@ -230,7 +234,7 @@ await TelemetryRecorder.TryRecordDecryptionErrorAsync( CreationTime = activeRevisionDto.CreationTime, SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, ClaimedSize = extendedAttributes?.Common?.Size, - ClaimedModificationTime = extendedAttributes?.Common?.ModificationTime, + ClaimedModificationTime = modificationTimeResult?.GetValueOrDefault(), ClaimedDigests = new FileContentDigests { @@ -291,6 +295,7 @@ private static (FileMetadata Metadata, Dictionary? modificationTimeResult, ReadOnlyCollection thumbnails, ReadOnlyCollection? additionalMetadata, NodeUid? parentUid, @@ -330,6 +335,11 @@ private static (FileMetadata Metadata, Dictionary encryptedName, out ArraySegment nameHashDigest) { - var maxNameByteLength = Encoding.UTF8.GetByteCount(name); - var nameBytes = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) + var maxNameByteLength = Encoding.UTF8.GetMaxByteCount(name.Length); + var nameBytes = MemoryPolicy.GetRentedHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) ? nameHeapMemoryOwner.Memory.Span : stackalloc byte[maxNameByteLength]; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/Iso8601DateTimeResultJsonConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/Iso8601DateTimeResultJsonConverter.cs new file mode 100644 index 00000000..9eae1e6d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/Iso8601DateTimeResultJsonConverter.cs @@ -0,0 +1,63 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Proton.Sdk; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +internal sealed class Iso8601DateTimeResultJsonConverter : JsonConverter?> +{ + public override bool HandleNull => true; + + public override Result? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType is not JsonTokenType.String) + { + return new ProtonDriveError($"Expected token type {JsonTokenType.String}, received {reader.TokenType} instead."); + } + + if (!reader.TryGetDateTimeOffset(out var value) && !TryFallbackToDateTimeOffsetParser(ref reader, out value)) + { + var redactedValue = reader.GetString() is { } valueString + ? string.Concat(valueString.Select(c => char.IsDigit(c) ? '#' : c)) + : string.Empty; + + return new ProtonDriveError($"Failed to parse date and time from '{redactedValue}'."); + } + + return value.UtcDateTime; + } + + public override void Write(Utf8JsonWriter writer, Result? value, JsonSerializerOptions options) + { + if (value is { } result && result.TryGetValue(out var dateTime)) + { + writer.WriteStringValue(dateTime.ToUniversalTime().ToString("O")); + } + else + { + writer.WriteNullValue(); + } + } + + private static bool TryFallbackToDateTimeOffsetParser(ref Utf8JsonReader reader, out DateTimeOffset value) + { + var maxCharacterCount = reader.GetValueMaxCharacterCount(); + + var unescapedCharactersBuffer = MemoryPolicy.IsTooLargeForStack(maxCharacterCount) + ? new char[maxCharacterCount] + : stackalloc char[maxCharacterCount]; + + var unescapedCharacterCount = reader.CopyString(unescapedCharactersBuffer); + + var unescapedCharacters = unescapedCharactersBuffer[..unescapedCharacterCount]; + + return DateTimeOffset.TryParse(unescapedCharacters, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.RoundtripKind, out value); + } +} diff --git a/cs/sdk/src/Proton.Sdk/MemoryPolicy.cs b/cs/sdk/src/Proton.Sdk/MemoryPolicy.cs new file mode 100644 index 00000000..defd4c0d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/MemoryPolicy.cs @@ -0,0 +1,32 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using CommunityToolkit.HighPerformance.Buffers; + +namespace Proton.Sdk; + +internal static class MemoryPolicy +{ + private const int MaxStackBufferSize = 256; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsTooLargeForStack(int size) + where T : struct + { + return (size * Unsafe.SizeOf()) > MaxStackBufferSize; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool GetRentedHeapMemoryIfTooLargeForStack(int size, [MaybeNullWhen(false)] out IMemoryOwner heapMemoryOwner) + where T : struct + { + if (!IsTooLargeForStack(size)) + { + heapMemoryOwner = null; + return false; + } + + heapMemoryOwner = MemoryOwner.Allocate(size); + return true; + } +} diff --git a/cs/sdk/src/Proton.Sdk/MemoryProvider.cs b/cs/sdk/src/Proton.Sdk/MemoryProvider.cs deleted file mode 100644 index 74639d9b..00000000 --- a/cs/sdk/src/Proton.Sdk/MemoryProvider.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Buffers; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using CommunityToolkit.HighPerformance.Buffers; - -namespace Proton.Sdk; - -internal static class MemoryProvider -{ - private const int MaxStackBufferSize = 256; - - public static bool GetHeapMemoryIfTooLargeForStack(int size, [MaybeNullWhen(false)] out IMemoryOwner heapMemoryOwner) - where T : struct - { - if ((size * Unsafe.SizeOf()) <= MaxStackBufferSize) - { - heapMemoryOwner = null; - return false; - } - - heapMemoryOwner = MemoryOwner.Allocate(size); - return true; - } -} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs index 2061b23d..29a06f40 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs @@ -1,5 +1,4 @@ -using System.Text; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; namespace Proton.Sdk.Serialization; @@ -8,29 +7,28 @@ internal sealed class ForgivingBytesToHexJsonConverter : JsonConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.ValueSpan.Length == 0 || reader.TokenType == JsonTokenType.Null) + if (reader.TokenType == JsonTokenType.Null || reader.GetValueLength() is not (var valueLength and > 0)) { return ReadOnlyMemory.Empty; } - var maxCharacterCount = Encoding.UTF8.GetMaxCharCount(reader.ValueSpan.Length); - var characterBuffer = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxCharacterCount, out var charactersHeapMemoryOwner) - ? charactersHeapMemoryOwner.Memory.Span - : stackalloc char[maxCharacterCount]; - - using (charactersHeapMemoryOwner) + try { - var characterCount = reader.CopyString(characterBuffer); - - try - { - return Convert.FromHexString(characterBuffer[..characterCount]); - } - catch + if (reader.HasUnescapedValueSpan) { - // TODO: Use some explicit fallback mechanism on the DTO attribute instead, and make this converter non-forgiving - return ReadOnlyMemory.Empty; + return Convert.FromHexString(reader.ValueSpan); } + + var unescapedValueBuffer = MemoryPolicy.IsTooLargeForStack(valueLength) ? new byte[valueLength] : stackalloc byte[valueLength]; + + var unescapedValueLength = reader.CopyString(unescapedValueBuffer); + + return Convert.FromHexString(unescapedValueBuffer[..unescapedValueLength]); + } + catch + { + // TODO: Use some explicit fallback mechanism on the DTO attribute instead, and make this converter non-forgiving + return ReadOnlyMemory.Empty; } } @@ -42,16 +40,15 @@ public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, Js return; } - var maxCharacterCount = value.Length * 2; - var characterBuffer = MemoryProvider.GetHeapMemoryIfTooLargeForStack(maxCharacterCount, out var charactersHeapMemoryOwner) - ? charactersHeapMemoryOwner.Memory.Span - : stackalloc char[maxCharacterCount]; + var maxByteCount = value.Length * 2; + + var hexStringBuffer = MemoryPolicy.IsTooLargeForStack(maxByteCount) ? new byte[maxByteCount] : stackalloc byte[maxByteCount]; - if (!Convert.TryToHexStringLower(value.Span, characterBuffer, out var byteCount)) + if (!Convert.TryToHexStringLower(value.Span, hexStringBuffer, out var hexStringLength)) { throw new JsonException("Could not convert to hex string"); } - writer.WriteStringValue(characterBuffer[..byteCount]); + writer.WriteStringValue(hexStringBuffer[..hexStringLength]); } } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs index 66b5e40b..0b7e4344 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs @@ -19,19 +19,22 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial $"Unexpected token type '{reader.TokenType}' when converting to {typeof(T).Name}, expected '{nameof(JsonTokenType.String)}'"); } - var buffer = ArrayPool.Shared.Rent(reader.ValueSpan.Length); + if (reader.HasUnescapedValueSpan) + { + return Decode(reader.ValueSpan); + } + + var unescapedValueBuffer = ArrayPool.Shared.Rent(reader.GetValueLength()); try { - var numberOfBytesCopied = reader.CopyString(buffer); + var unescapedValueLength = reader.CopyString(unescapedValueBuffer); - var decodedBlock = PgpArmorDecoder.Decode(buffer.AsSpan()[..numberOfBytesCopied]); - - return CreateValue(decodedBlock); + return Decode(unescapedValueBuffer.AsSpan()[..unescapedValueLength]); } finally { - ArrayPool.Shared.Return(buffer); + ArrayPool.Shared.Return(unescapedValueBuffer); } } @@ -52,4 +55,11 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions } protected abstract T CreateValue(ReadOnlyMemory bytes); + + private T Decode(ReadOnlySpan bytes) + { + var decodedBlock = PgpArmorDecoder.Decode(bytes); + + return CreateValue(decodedBlock); + } } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/Utf8JsonReaderExtensions.cs b/cs/sdk/src/Proton.Sdk/Serialization/Utf8JsonReaderExtensions.cs new file mode 100644 index 00000000..3f339096 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/Utf8JsonReaderExtensions.cs @@ -0,0 +1,25 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; + +namespace Proton.Sdk.Serialization; + +internal static class Utf8JsonReaderExtensions +{ + extension(ref Utf8JsonReader reader) + { + public bool HasUnescapedValueSpan => !reader.HasValueSequence && !reader.ValueIsEscaped; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetValueMaxCharacterCount() + { + return Encoding.UTF8.GetMaxCharCount(reader.GetValueLength()); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetValueLength() + { + return reader.HasValueSequence ? checked((int)reader.ValueSequence.Length) : reader.ValueSpan.Length; + } + } +} From 24131ca660d961a2a0c103fa7691b7e8be74ac74 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 16:23:52 +0000 Subject: [PATCH 787/791] Update changelog for cs/v0.15.1 --- cs/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 28226331..32fd4ea7 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## cs/v0.15.1 (2026-05-28) + +* Fix node secrets not being read from cache +* Use photos API when fetching album and photo node details +* Merge result error message with first error message +* Fix E2E kotlin tests + ## cs/v0.15.0 (2026-05-27) * Report extended attributes size for download progress instead of revision size From c82b77e25e627ce2d3bc36d27a4015523f9b18eb Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 06:37:52 +0200 Subject: [PATCH 788/791] Drop rounding for crypto performance telemetry --- js/sdk/src/crypto/driveCrypto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts index fe5fe3ba..5c4dfc1a 100644 --- a/js/sdk/src/crypto/driveCrypto.ts +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -844,7 +844,7 @@ export class DriveCrypto { type, cryptoModel, bytesProcessed, - milliseconds: Math.round(duration), + milliseconds: duration, }); } } From 5f4e51947d5c35a3914cd612209bf4a463e6d55b Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 11:14:00 +0000 Subject: [PATCH 789/791] Add validation_error category for upload & download telemetry events --- .../DriveInteropTelemetryDecorator.cs | 2 + .../Nodes/Download/FileDownloader.cs | 5 -- .../Nodes/Download/PhotosFileDownloader.cs | 5 -- .../Nodes/Upload/FileUploader.cs | 5 -- .../Telemetry/DownloadError.cs | 1 + .../Telemetry/TelemetryErrorResolver.cs | 6 +++ .../Proton.Drive.Sdk/Telemetry/UploadError.cs | 1 + .../Telemetry/ValidationResponseCode.cs | 34 +++++++++++++ cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs | 29 +++++++---- cs/sdk/src/protos/proton.drive.sdk.proto | 2 + js/sdk/src/interface/telemetry.ts | 2 + .../src/internal/download/telemetry.test.ts | 6 +-- js/sdk/src/internal/download/telemetry.ts | 2 +- js/sdk/src/internal/upload/telemetry.test.ts | 6 +-- js/sdk/src/internal/upload/telemetry.ts | 2 +- .../drive/sdk/extension/DownloadError.kt | 1 + .../proton/drive/sdk/extension/UploadError.kt | 1 + .../drive/sdk/telemetry/DownloadError.kt | 1 + .../proton/drive/sdk/telemetry/UploadError.kt | 1 + .../TelemetryAndLogging/TelemetryTypes.swift | 48 +++++++++++-------- 20 files changed, 107 insertions(+), 53 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Telemetry/ValidationResponseCode.cs diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs index 7cdd19f2..3f322143 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -140,6 +140,7 @@ private static DecryptionErrorEventPayload GetDecryptionErrorPayload(DecryptionE ErrorDomain.Network or ErrorDomain.Transport => Telemetry.UploadError.NetworkError, ErrorDomain.Serialization => Telemetry.UploadError.HttpClientSideError, ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Telemetry.UploadError.IntegrityError, + ErrorDomain.BusinessLogic => Telemetry.UploadError.ValidationError, _ => Telemetry.UploadError.Unknown, }; } @@ -168,6 +169,7 @@ private static Telemetry.UploadError TranslateApiErrorToUploadError(long statusC ErrorDomain.Network or ErrorDomain.Transport => Telemetry.DownloadError.NetworkError, ErrorDomain.Serialization => Telemetry.DownloadError.HttpClientSideError, ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Telemetry.DownloadError.IntegrityError, + ErrorDomain.BusinessLogic => Telemetry.DownloadError.ValidationError, _ => Telemetry.DownloadError.Unknown, }; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs index 092d820a..7ae74c54 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -121,11 +121,6 @@ private DownloadController BuildDownloadController( async ValueTask OnFailedAsync(Exception ex, long? claimedFileSize, long downloadedByteCount) { - if (ex is ValidationException) - { - return; - } - var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); downloadEvent.ClaimedFileSize = claimedFileSize; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs index 55de16e9..ee0bac52 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -126,11 +126,6 @@ private DownloadController DownloadToStream( async ValueTask OnFailedAsync(Exception ex, long? claimedFileSize, long downloadedByteCount) { - if (ex is ValidationException) - { - return; - } - var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client.DriveClient, _photoUid, cancellationToken).ConfigureAwait(false); // TODO: deprecate DownloadedSize in favor of ApproximateDownloadedSize diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs index 4f80724b..5f962f52 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -171,11 +171,6 @@ private UploadController UploadFromStream( async ValueTask OnFailedAsync(Exception ex, long uploadedByteCount) { - if (ex is ValidationException) - { - return; - } - var uploadEvent = await TelemetryEventFactory.CreateUploadEventAsync(_client, _telemetryContextNodeUid, contentStream.Length, cancellationToken) .ConfigureAwait(false); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs index 4f0c324c..040697fa 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs @@ -7,6 +7,7 @@ public enum DownloadError DecryptionError, IntegrityError, RateLimited, + ValidationError, HttpClientSideError, Unknown, } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs index 282cdeb2..35698438 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -14,6 +14,8 @@ internal static class TelemetryErrorResolver { return exception switch { + ValidationException => DownloadError.ValidationError, + // Reported as download success CompletedDownloadManifestVerificationException => null, DataIntegrityException => exception.GetBaseException() is CompletedDownloadManifestVerificationException ? null : DownloadError.IntegrityError, @@ -31,6 +33,7 @@ internal static class TelemetryErrorResolver DownloadError.ServerError, HttpRequestException => DownloadError.NetworkError, + ProtonApiException { Code: var code } when ValidationResponseCode.IsValidationCode(code) => DownloadError.ValidationError, ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => DownloadError.RateLimited, ProtonApiException { TransportCode: >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode } => DownloadError.HttpClientSideError, ProtonApiException { TransportCode: >= StatusCodes.MinServerErrorCode and <= StatusCodes.MaxServerErrorCode } => DownloadError.ServerError, @@ -50,6 +53,8 @@ public static UploadError GetUploadErrorFromException(Exception exception) { return exception switch { + ValidationException => UploadError.ValidationError, + // Upload errors IntegrityException => UploadError.IntegrityError, @@ -61,6 +66,7 @@ public static UploadError GetUploadErrorFromException(Exception exception) UploadError.ServerError, HttpRequestException => UploadError.NetworkError, + ProtonApiException { Code: var code } when ValidationResponseCode.IsValidationCode(code) => UploadError.ValidationError, ProtonApiException { TransportCode: (int)HttpStatusCode.TooManyRequests } => UploadError.RateLimited, ProtonApiException { TransportCode: >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode } => UploadError.HttpClientSideError, ProtonApiException { TransportCode: >= StatusCodes.MinServerErrorCode and <= StatusCodes.MaxServerErrorCode } => UploadError.ServerError, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs index c2f8758a..61f62137 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs @@ -6,6 +6,7 @@ public enum UploadError NetworkError, IntegrityError, RateLimited, + ValidationError, HttpClientSideError, Unknown, } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/ValidationResponseCode.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/ValidationResponseCode.cs new file mode 100644 index 00000000..63deae60 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/ValidationResponseCode.cs @@ -0,0 +1,34 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Telemetry; + +internal static class ValidationResponseCode +{ + /// + /// API response codes that represent user-facing validation failures. + /// Kept in sync with JS apiErrorFactory validation cases. + /// + public static bool IsValidationCode(ResponseCode code) => code switch + { + ResponseCode.InvalidRequirements => true, + ResponseCode.InvalidValue => true, + ResponseCode.NotEnoughPermissions => true, + ResponseCode.NotEnoughPermissionsToGrantPermissions => true, + ResponseCode.AlreadyExists => true, + ResponseCode.DoesNotExist => true, + ResponseCode.InsufficientQuota => true, + ResponseCode.InsufficientSpace => true, + ResponseCode.MaxFileSizeForFreeUser => true, + ResponseCode.MaxPublicEditModeForFreeUser => true, + ResponseCode.InsufficientVolumeQuota => true, + ResponseCode.InsufficientDeviceQuota => true, + ResponseCode.AlreadyMemberOfShareInVolumeWithAnotherAddress => true, + ResponseCode.TooManyChildren => true, + ResponseCode.NestingTooDeep => true, + ResponseCode.InsufficientInvitationQuota => true, + ResponseCode.InsufficientShareQuota => true, + ResponseCode.InsufficientShareJoinedQuota => true, + ResponseCode.InsufficientBookmarksQuota => true, + _ => false, + }; +} diff --git a/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs b/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs index d9d7d6ea..5533976a 100644 --- a/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs +++ b/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs @@ -14,6 +14,8 @@ public enum ResponseCode MultipleResponses = 1001, InvalidRequirements = 2000, InvalidValue = 2001, + NotEnoughPermissions = 2011, + NotEnoughPermissionsToGrantPermissions = 2026, InvalidEncryptedIdFormat = 2061, AlreadyExists = 2500, DoesNotExist = 2501, @@ -27,27 +29,36 @@ public enum ResponseCode /// /// Account is disabled /// - AccountDeleted = 10002, + AccountDeleted = 10_002, /// /// Account is disabled due to abuse or fraud /// - AccountDisabled = 10003, + AccountDisabled = 10_003, InvalidRefreshToken = 10013, /// /// Free account /// - NoActiveSubscription = 22110, + NoActiveSubscription = 22_110, - UnknownAddress = 33102, + UnknownAddress = 33_102, - ProtonDriveUnknown = 200000, - InsufficientQuota = ProtonDriveUnknown + 1, - InsufficientSpace = ProtonDriveUnknown + 2, - MaxFileSizeForFreeUser = ProtonDriveUnknown + 3, - TooManyChildren = ProtonDriveUnknown + 300, + ProtonDriveUnknown = 200_000, + InsufficientQuota = 200_001, + InsufficientSpace = 200_002, + MaxFileSizeForFreeUser = 200_003, + MaxPublicEditModeForFreeUser = 200_004, + InsufficientVolumeQuota = 200_100, + InsufficientDeviceQuota = 200_101, + AlreadyMemberOfShareInVolumeWithAnotherAddress = 200_201, + TooManyChildren = 200_300, + NestingTooDeep = 200_301, + InsufficientInvitationQuota = 200_600, + InsufficientShareQuota = 200_601, + InsufficientShareJoinedQuota = 200_602, + InsufficientBookmarksQuota = 200_800, CustomCode = 10000000, SocketError = CustomCode + 1, diff --git a/cs/sdk/src/protos/proton.drive.sdk.proto b/cs/sdk/src/protos/proton.drive.sdk.proto index 6054d91c..1b31f041 100644 --- a/cs/sdk/src/protos/proton.drive.sdk.proto +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -784,6 +784,7 @@ enum DownloadError { DOWNLOAD_ERROR_RATE_LIMITED = 4; DOWNLOAD_ERROR_HTTP_CLIENT_SIDE_ERROR = 5; DOWNLOAD_ERROR_UNKNOWN = 6; + DOWNLOAD_ERROR_VALIDATION_ERROR = 7; } enum UploadError { @@ -793,6 +794,7 @@ enum UploadError { UPLOAD_ERROR_RATE_LIMITED = 3; UPLOAD_ERROR_HTTP_CLIENT_SIDE_ERROR = 4; UPLOAD_ERROR_UNKNOWN = 5; + UPLOAD_ERROR_VALIDATION_ERROR = 6; } enum EncryptedField { diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts index 2b1dd310..cacc16ce 100644 --- a/js/sdk/src/interface/telemetry.ts +++ b/js/sdk/src/interface/telemetry.ts @@ -47,6 +47,7 @@ export type MetricsUploadErrorType = | 'network_error' | 'integrity_error' | 'rate_limited' + | 'validation_error' | '4xx' | 'unknown'; @@ -66,6 +67,7 @@ export type MetricsDownloadErrorType = | 'decryption_error' | 'integrity_error' | 'rate_limited' + | 'validation_error' | '4xx' | 'unknown'; diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts index 4f46e87e..7a0f5dcf 100644 --- a/js/sdk/src/internal/download/telemetry.test.ts +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -82,10 +82,10 @@ describe('DownloadTelemetry', () => { ); }; - it('should ignore ValidationError', async () => { - const error = new ValidationError('Validation error'); + it('should detect "validation_error" for ValidationError', async () => { + const error = new ValidationError('file not found'); await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); - expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); + verifyErrorCategory('validation_error'); }); it('should ignore AbortError', async () => { diff --git a/js/sdk/src/internal/download/telemetry.ts b/js/sdk/src/internal/download/telemetry.ts index 11f45cc0..518b9ca1 100644 --- a/js/sdk/src/internal/download/telemetry.ts +++ b/js/sdk/src/internal/download/telemetry.ts @@ -95,7 +95,7 @@ export class DownloadTelemetry { function getErrorCategory(error: unknown): MetricsDownloadErrorType | undefined { if (error instanceof ValidationError) { - return undefined; + return 'validation_error'; } if (error instanceof RateLimitedError) { return 'rate_limited'; diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts index 232dd4ff..d83e5ebe 100644 --- a/js/sdk/src/internal/upload/telemetry.test.ts +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -84,10 +84,10 @@ describe('UploadTelemetry', () => { ); }; - it('should ignore ValidationError', async () => { - const error = new ValidationError('Validation error'); + it('should detect "validation_error" for ValidationError', async () => { + const error = new ValidationError('out of quota'); await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); - expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); + verifyErrorCategory('validation_error'); }); it('should ignore AbortError', async () => { diff --git a/js/sdk/src/internal/upload/telemetry.ts b/js/sdk/src/internal/upload/telemetry.ts index baea7901..819d5bd2 100644 --- a/js/sdk/src/internal/upload/telemetry.ts +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -113,7 +113,7 @@ export class UploadTelemetry { function getErrorCategory(error: unknown): MetricsUploadErrorType | undefined { if (error instanceof ValidationError) { - return undefined; + return 'validation_error'; } if (error instanceof RateLimitedError) { return 'rate_limited'; diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt index 406ca8db..d6ff02ae 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt @@ -9,6 +9,7 @@ fun ProtonDriveSdk.DownloadError.toEnum() = when (this) { ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_DECRYPTION_ERROR -> DownloadError.DECRYPTION_ERROR ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_INTEGRITY_ERROR -> DownloadError.INTEGRITY_ERROR ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_RATE_LIMITED -> DownloadError.RATE_LIMITED + ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_VALIDATION_ERROR -> DownloadError.VALIDATION_ERROR ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_HTTP_CLIENT_SIDE_ERROR -> DownloadError.HTTP_CLIENT_SIDE_ERROR ProtonDriveSdk.DownloadError.DOWNLOAD_ERROR_UNKNOWN -> DownloadError.UNKNOWN ProtonDriveSdk.DownloadError.UNRECOGNIZED -> DownloadError.UNRECOGNIZED diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt index 08945ffb..76bb0d37 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt @@ -8,6 +8,7 @@ fun ProtonDriveSdk.UploadError.toEnum() = when(this) { ProtonDriveSdk.UploadError.UPLOAD_ERROR_NETWORK_ERROR -> UploadError.NETWORK_ERROR ProtonDriveSdk.UploadError.UPLOAD_ERROR_INTEGRITY_ERROR -> UploadError.INTEGRITY_ERROR ProtonDriveSdk.UploadError.UPLOAD_ERROR_RATE_LIMITED -> UploadError.RATE_LIMITED + ProtonDriveSdk.UploadError.UPLOAD_ERROR_VALIDATION_ERROR -> UploadError.VALIDATION_ERROR ProtonDriveSdk.UploadError.UPLOAD_ERROR_HTTP_CLIENT_SIDE_ERROR -> UploadError.HTTP_CLIENT_SIDE_ERROR ProtonDriveSdk.UploadError.UPLOAD_ERROR_UNKNOWN -> UploadError.UNKNOWN ProtonDriveSdk.UploadError.UNRECOGNIZED -> UploadError.UNRECOGNIZED diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt index a36a3a18..d5d3e755 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt @@ -7,6 +7,7 @@ enum class DownloadError { DECRYPTION_ERROR, INTEGRITY_ERROR, RATE_LIMITED, + VALIDATION_ERROR, HTTP_CLIENT_SIDE_ERROR, UNKNOWN, } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt index f2594177..cbe9614e 100644 --- a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt @@ -6,6 +6,7 @@ enum class UploadError { NETWORK_ERROR, INTEGRITY_ERROR, RATE_LIMITED, + VALIDATION_ERROR, HTTP_CLIENT_SIDE_ERROR, UNKNOWN, } diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift index 97dc87c9..cd76d2d1 100644 --- a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -3,42 +3,42 @@ import Foundation public typealias RecordMetricEventCallback = @Sendable (MetricEvent) -> Void public enum MetricEvent: Sendable { - + case apiRetrySucceeded(ApiRetrySucceededEventPayload) case blockVerificationError(BlockVerificationErrorEventPayload) case decryptionError(DecryptionErrorEventPayload) case download(DownloadEventPayload) case upload(UploadEventPayload) case verificationError(VerificationErrorEventPayload) - + case other(name: String) - + init(sdkMetricEvent: Proton_Sdk_MetricEvent) throws { switch sdkMetricEvent.payload { case let proto where proto.isA(Proton_Sdk_ApiRetrySucceededEventPayload.self): let sdkPayload = try Proton_Sdk_ApiRetrySucceededEventPayload(unpackingAny: proto) self = .apiRetrySucceeded(ApiRetrySucceededEventPayload(sdkEventPayload: sdkPayload)) - + case let proto where proto.isA(Proton_Drive_Sdk_BlockVerificationErrorEventPayload.self): let sdkPayload = try Proton_Drive_Sdk_BlockVerificationErrorEventPayload(unpackingAny: proto) self = .blockVerificationError(BlockVerificationErrorEventPayload(sdkEventPayload: sdkPayload)) - + case let proto where proto.isA(Proton_Drive_Sdk_DecryptionErrorEventPayload.self): let sdkPayload = try Proton_Drive_Sdk_DecryptionErrorEventPayload(unpackingAny: proto) self = .decryptionError(DecryptionErrorEventPayload(sdkEventPayload: sdkPayload)) - + case let proto where proto.isA(Proton_Drive_Sdk_DownloadEventPayload.self): let sdkPayload = try Proton_Drive_Sdk_DownloadEventPayload(unpackingAny: proto) self = .download(DownloadEventPayload(sdkDownloadEventPayload: sdkPayload)) - + case let proto where proto.isA(Proton_Drive_Sdk_UploadEventPayload.self): let sdkPayload = try Proton_Drive_Sdk_UploadEventPayload(unpackingAny: proto) self = .upload(UploadEventPayload(sdkUploadEventPayload: sdkPayload)) - + case let proto where proto.isA(Proton_Drive_Sdk_VerificationErrorEventPayload.self): let sdkPayload = try Proton_Drive_Sdk_VerificationErrorEventPayload(unpackingAny: proto) self = .verificationError(VerificationErrorEventPayload(sdkEventPayload: sdkPayload)) - + default: self = .other(name: sdkMetricEvent.name) } @@ -49,7 +49,7 @@ public struct ApiRetrySucceededEventPayload: Sendable { public let url: String public let failedAttempts: Int - + init(sdkEventPayload: Proton_Sdk_ApiRetrySucceededEventPayload) { self.url = sdkEventPayload.url self.failedAttempts = Int(sdkEventPayload.failedAttempts) @@ -68,13 +68,13 @@ public struct BlockVerificationErrorEventPayload: Sendable { } public struct DecryptionErrorEventPayload: Sendable { - + public let volumeType: VolumeType public let field: EncryptedField public let fromBefore2024: Bool public let error: String? public let uid: String - + init(sdkEventPayload: Proton_Drive_Sdk_DecryptionErrorEventPayload) { self.volumeType = .init(sdkVolumeType: sdkEventPayload.volumeType) self.field = .init(sdkEncryptedField: sdkEventPayload.field) @@ -85,13 +85,13 @@ public struct DecryptionErrorEventPayload: Sendable { } public struct DownloadEventPayload: Sendable { - + public let volumeType: VolumeType public let approximateClaimedFileSize: Int64 public let approximateDownloadedSize: Int64 public let error: DownloadError? public let originalError: String? - + init(sdkDownloadEventPayload: Proton_Drive_Sdk_DownloadEventPayload) { self.volumeType = .init(sdkVolumeType: sdkDownloadEventPayload.volumeType) self.approximateClaimedFileSize = sdkDownloadEventPayload.approximateClaimedFileSize @@ -102,13 +102,13 @@ public struct DownloadEventPayload: Sendable { } public struct UploadEventPayload: Sendable { - + public let volumeType: VolumeType public let approximateExpectedSize: Int64 public let approximateUploadedSize: Int64 public let error: UploadError? public let originalError: String? - + init(sdkUploadEventPayload: Proton_Drive_Sdk_UploadEventPayload) { self.volumeType = .init(sdkVolumeType: sdkUploadEventPayload.volumeType) self.approximateExpectedSize = sdkUploadEventPayload.approximateExpectedSize @@ -119,14 +119,14 @@ public struct UploadEventPayload: Sendable { } public struct VerificationErrorEventPayload: Sendable { - + public let volumeType: VolumeType public let field: EncryptedField public let fromBefore2024: Bool public let addressMatchingDefaultShare: Bool public let error: String? public let uid: String - + init(sdkEventPayload: Proton_Drive_Sdk_VerificationErrorEventPayload) { self.volumeType = .init(sdkVolumeType: sdkEventPayload.volumeType) self.field = .init(sdkEncryptedField: sdkEventPayload.field) @@ -174,7 +174,7 @@ public enum EncryptedField: Int, Sendable { case nodeExtendedAttributes = 4 case nodeContentKey = 5 case content = 6 - + init(sdkEncryptedField: Proton_Drive_Sdk_EncryptedField) { switch sdkEncryptedField { case .shareKey: @@ -206,7 +206,8 @@ public enum DownloadError: Int, Sendable { case rateLimited = 4 case httpClientSideError = 5 case unknown = 6 - + case validationError = 7 + init(sdkDownloadError: Proton_Drive_Sdk_DownloadError) { switch sdkDownloadError { case .serverError: @@ -219,6 +220,8 @@ public enum DownloadError: Int, Sendable { self = .integrityError case .rateLimited: self = .rateLimited + case .validationError: + self = .validationError case .httpClientSideError: self = .httpClientSideError case .unknown: @@ -237,7 +240,8 @@ public enum UploadError: Int, Sendable { case rateLimited = 3 case httpClientSideError = 4 case unknown = 5 - + case validationError = 6 + init(sdkUploadError: Proton_Drive_Sdk_UploadError) { switch sdkUploadError { case .serverError: @@ -248,6 +252,8 @@ public enum UploadError: Int, Sendable { self = .integrityError case .rateLimited: self = .rateLimited + case .validationError: + self = .validationError case .httpClientSideError: self = .httpClientSideError case .unknown: From 9a4567413db55e432dbcce984d85c3f4f85e1869 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 11:16:33 +0000 Subject: [PATCH 790/791] Process automatically events converting external invitations --- js/sdk/src/interface/events.ts | 7 +- js/sdk/src/internal/apiService/driveTypes.ts | 30 +++--- js/sdk/src/internal/events/apiService.ts | 4 + .../internal/events/coreEventManager.test.ts | 2 + js/sdk/src/internal/events/index.test.ts | 9 +- js/sdk/src/internal/events/index.ts | 50 ++++++++-- js/sdk/src/internal/events/interface.ts | 31 ++++++- .../events/volumeEventManager.test.ts | 6 ++ .../src/internal/events/volumeEventManager.ts | 18 +++- js/sdk/src/internal/nodes/cryptoService.ts | 8 +- js/sdk/src/internal/nodes/events.ts | 4 +- js/sdk/src/internal/sharing/cryptoService.ts | 23 ++++- js/sdk/src/internal/sharing/events.test.ts | 6 +- js/sdk/src/internal/sharing/events.ts | 16 +++- js/sdk/src/internal/sharing/index.ts | 1 + .../src/internal/sharing/sharingManagement.ts | 92 +++++++++++++++++++ js/sdk/src/protonDriveClient.ts | 15 ++- js/sdk/src/protonDrivePhotosClient.ts | 15 ++- 18 files changed, 284 insertions(+), 53 deletions(-) diff --git a/js/sdk/src/interface/events.ts b/js/sdk/src/interface/events.ts index 442d5434..51957e4a 100644 --- a/js/sdk/src/interface/events.ts +++ b/js/sdk/src/interface/events.ts @@ -20,7 +20,12 @@ export interface LatestEventIdProvider { */ export type DriveListener = (event: DriveEvent) => Promise; -export type DriveEvent = NodeEvent | FastForwardEvent | TreeRefreshEvent | TreeRemovalEvent | SharedWithMeUpdated; +export type DriveEvent = + | NodeEvent + | FastForwardEvent + | TreeRefreshEvent + | TreeRemovalEvent + | SharedWithMeUpdated; export type NodeEvent = | { diff --git a/js/sdk/src/internal/apiService/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts index e26387fd..b48551dc 100644 --- a/js/sdk/src/internal/apiService/driveTypes.ts +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -3327,7 +3327,7 @@ export interface components { /** @constant */ BlockMaxSizeInBytes?: 5300000; /** @constant */ - ThumbnailMaxSizeInBytes?: 65536; + ThumbnailMaxSizeInBytes?: 69632; /** @constant */ DraftRevisionLifetimeInSec?: 14400; /** @constant */ @@ -3669,7 +3669,7 @@ export interface components { * Format: email * @description Signature email address used to sign passphrase and name */ - NameSignatureEmail?: string | null; + NameSignatureEmail?: components["schemas"]["AddressEmail"] | null; OriginalHash?: string | null; /** @description Extended attributes encrypted with link key */ XAttr?: components["schemas"]["PGPMessage"] | null; @@ -4342,8 +4342,12 @@ export interface components { EventType: components["schemas"]["EventType"]; Link: components["schemas"]["EventLinkDataDto"]; }; + LinkIDDto: { + LinkID: components["schemas"]["Id"]; + }; ListEventsV2ResponseDto: { Events: components["schemas"]["EventV2ResponseDto"][]; + ConvertibleExternalInvitations: components["schemas"]["LinkIDDto"][]; /** @description Last event ID that can be used on the next call. Will be latest/newest-event-id if requested last-event-id does not exist. */ EventID: components["schemas"]["ShortId"]; /** @description true if there is more to pull, i.e. there are more events than returned in one call */ @@ -4583,10 +4587,10 @@ export interface components { OwnedByDto: { /** * Format: email - * @description OwnerUser email for regular and photo volumes, null otherwise + * @description OwnerUser email for regular and photo volumes, null otherwise. Always null in public-sharing context. */ Email: string | null; - /** @description OwnerOrganization name for org. volumes, null otherwise */ + /** @description OwnerOrganization name for org. volumes, null otherwise. Always null in public-sharing context. */ Organization: string | null; }; LinkDto: { @@ -4616,7 +4620,7 @@ export interface components { RelatedPhotosLinkIDs: components["schemas"]["Id"][]; }; /** - * @description
    See values descriptions
    ValueNameDescription
    1Preview512 px, max. 65536 bytes in encrypted size
    2HDPreview1920 px, max. 1048576 bytes in encrypted size
    3MachineLearningmax. 65536 bytes in encrypted size
    + * @description
    See values descriptions
    ValueNameDescription
    1Preview512 px, max. 69632 bytes in encrypted size
    2HDPreview1920 px, max. 1052672 bytes in encrypted size
    3MachineLearningmax. 69632 bytes in encrypted size
    * @enum {integer} */ ThumbnailType: 1 | 2 | 3; @@ -4868,14 +4872,14 @@ export interface components { * @description Signature email address used for signing name; Required when not passing `SignatureAddress` * @default null */ - NameSignatureEmail: string | null; + NameSignatureEmail: components["schemas"]["AddressEmail"] | null; /** * Format: email * @deprecated * @description [DEPRECATED] since only the name is signed, use `NameSignatureEmail`. Signature email address used for the name. * @default null */ - SignatureAddress: string | null; + SignatureAddress: components["schemas"]["AddressEmail"] | null; /** * @description Current name hash before move operation. Used to prevent race conditions. * @default null @@ -5200,7 +5204,7 @@ export interface components { Type: components["schemas"]["ThumbnailType"]; /** * @deprecated - * @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 65536 + * @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 69632 * @default null */ Size: number | null; @@ -5835,7 +5839,7 @@ export interface components { * @description Signature email address used for signing name * @default null */ - NameSignatureEmail: string | null; + NameSignatureEmail: components["schemas"]["AddressEmail"] | null; /** * @description MIME type, optional, only on files. * @default null @@ -6544,7 +6548,7 @@ export interface components { Code: 1000; }; ExternalInvitationRequestDto: { - InviterAddressID: components["schemas"]["LongId"]; + InviterAddressID: components["schemas"]["AddressID"]; /** Format: email */ InviteeEmail: string; /** @@ -7394,7 +7398,7 @@ export interface operations { /** @description Potential codes and their meaning: * - 2500: A volume is already active * - 2001: Invalid PGP message - * - 200501: Operation failed: Please retry + * - 200501: Operation failed: Make sure you are using the latest version of Proton Drive and retry * */ Code: number; }; @@ -7901,7 +7905,7 @@ export interface operations { * - 200001: You have reached the maximum number of items you can save. * - 2501: Item link not found * - 2500: This item is already saved in your drive - * - 200501: Operation failed: Please retry + * - 200501: Operation failed: Make sure you are using the latest version of Proton Drive and retry * */ Code: number; }; @@ -8656,7 +8660,7 @@ export interface operations { header?: never; path: { volumeID: string; - linkID: string; + linkID: components["schemas"]["Id"]; }; cookie?: never; }; diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts index a1ba486f..fd503f37 100644 --- a/js/sdk/src/internal/events/apiService.ts +++ b/js/sdk/src/internal/events/apiService.ts @@ -50,6 +50,7 @@ export class EventsAPIService { latestEventId: result.EventID, more: result.More === 1, refresh, + convertibleExternalInvitationLinkIds: [], events: driveEvents, }; } @@ -85,6 +86,9 @@ export class EventsAPIService { latestEventId: result.EventID, more: result.More, refresh: result.Refresh, + convertibleExternalInvitationLinkIds: (result.ConvertibleExternalInvitations ?? []).map( + (item) => item.LinkID, + ), events: result.Events.map((event): NodeEvent => { const type = VOLUME_EVENT_TYPE_MAP[event.EventType]; const uids = { diff --git a/js/sdk/src/internal/events/coreEventManager.test.ts b/js/sdk/src/internal/events/coreEventManager.test.ts index 52c2597a..b8644077 100644 --- a/js/sdk/src/internal/events/coreEventManager.test.ts +++ b/js/sdk/src/internal/events/coreEventManager.test.ts @@ -61,6 +61,7 @@ describe('CoreEventManager', () => { latestEventId, more: false, refresh: false, + convertibleExternalInvitationLinkIds: [], events: [mockEvent1, mockEvent2], }; mockApiService.getCoreEvents.mockResolvedValue(mockEvents); @@ -77,6 +78,7 @@ describe('CoreEventManager', () => { latestEventId, more: false, refresh: false, + convertibleExternalInvitationLinkIds: [], events: [], }; mockApiService.getCoreEvents.mockResolvedValue(mockEvents); diff --git a/js/sdk/src/internal/events/index.test.ts b/js/sdk/src/internal/events/index.test.ts index 9fcefd83..9f020641 100644 --- a/js/sdk/src/internal/events/index.test.ts +++ b/js/sdk/src/internal/events/index.test.ts @@ -2,11 +2,13 @@ import { getMockTelemetry } from '../../tests/telemetry'; import { DriveAPIService } from '../apiService'; import { CoreApiEvent } from './apiService'; import { DriveEventsService } from './index'; -import { DriveEventType, DriveListener } from './interface'; +import { DriveEvent, DriveEventType, InternalDriveEvent } from './interface'; describe('DriveEventsService', () => { describe('processCoreEvent', () => { - function createService(cacheEventListeners: DriveListener[] = []) { + function createService( + cacheEventListeners: ((event: DriveEvent | InternalDriveEvent) => Promise)[] = [], + ) { const telemetry = getMockTelemetry(); const apiService = {} as unknown as DriveAPIService; const sharesService = { isOwnVolume: jest.fn(), getRootIDs: jest.fn() }; @@ -14,7 +16,8 @@ describe('DriveEventsService', () => { } it('returns no drive events and does not notify listeners when the raw event is not a refresh', async () => { - const listener: jest.MockedFunction = jest.fn().mockResolvedValue(undefined); + const listener: jest.MockedFunction<(event: DriveEvent | InternalDriveEvent) => Promise> = + jest.fn().mockResolvedValue(undefined); const service = createService([listener]); const raw = { EventID: 'event-no-refresh', diff --git a/js/sdk/src/internal/events/index.ts b/js/sdk/src/internal/events/index.ts index 352334fe..2c75ea11 100644 --- a/js/sdk/src/internal/events/index.ts +++ b/js/sdk/src/internal/events/index.ts @@ -9,6 +9,8 @@ import { DriveEventType, DriveListener, EventSubscription, + InternalDriveEvent, + isInternalDriveEvent, LatestEventIdProvider, SharesService, } from './interface'; @@ -16,7 +18,9 @@ import { VolumeEventManager } from './volumeEventManager'; export type { CoreApiEvent } from './apiService'; export type { EventScheduler } from './eventScheduler'; -export type { DriveEvent, DriveListener, EventSubscription } from './interface'; +export type { DriveEvent, DriveListener, EventSubscription, InternalDriveEvent } from './interface'; +export { isInternalDriveEvent } from './interface'; +export { InternalEventType } from './interface'; export { DriveEventType } from './interface'; const OWN_VOLUME_POLLING_INTERVAL = 30; @@ -30,15 +34,15 @@ const CORE_POLLING_INTERVAL = 30; */ export class DriveEventsService { private apiService: EventsAPIService; - private coreEventManager?: EventManager; - private volumeEventManagers: { [volumeId: string]: EventManager }; + private coreEventManager?: EventManager; + private volumeEventManagers: { [volumeId: string]: EventManager }; private logger: Logger; constructor( private telemetry: ProtonDriveTelemetry, apiService: DriveAPIService, private sharesService: SharesService, - private cacheEventListeners: DriveListener[] = [], + private cacheEventListeners: ((event: DriveEvent | InternalDriveEvent) => Promise)[] = [], private latestEventIdProvider?: LatestEventIdProvider, ) { this.telemetry = telemetry; @@ -59,7 +63,12 @@ export class DriveEventsService { this.coreEventManager = manager; } - const eventSubscription = manager.addListener(callback); + const eventSubscription = manager.addListener((event) => { + if (isInternalDriveEvent(event)) { + return Promise.resolve(); + } + return callback(event); + }); if (!started) { await manager.start(); } @@ -75,7 +84,11 @@ export class DriveEventsService { const coreEventManager = new CoreEventManager(this.logger, this.apiService); const latestEventId = await this.latestEventIdProvider.getLatestEventId('core'); - const eventManager = new EventManager(coreEventManager, CORE_POLLING_INTERVAL, latestEventId); + const eventManager = new EventManager( + coreEventManager, + CORE_POLLING_INTERVAL, + latestEventId, + ); for (const listener of this.cacheEventListeners) { eventManager.addListener(listener); @@ -132,7 +145,14 @@ export class DriveEventsService { }; return; } - yield* volumeEventManager.getEvents(lastEventId, signal); + for await (const event of volumeEventManager.getEvents(lastEventId, signal)) { + for (const listener of this.cacheEventListeners) { + await listener(event); + } + if (!isInternalDriveEvent(event)) { + yield event; + } + } } /** @@ -150,7 +170,13 @@ export class DriveEventsService { this.volumeEventManagers[volumeId] = manager; } - const eventSubscription = manager.addListener(callback); + const filteredCallback = (event: DriveEvent | InternalDriveEvent) => { + if (isInternalDriveEvent(event)) { + return Promise.resolve(); + } + return callback(event); + }; + const eventSubscription = manager.addListener(filteredCallback); if (!started) { await manager.start(); this.sendNumberOfVolumeSubscriptionsToTelemetry(); @@ -158,7 +184,7 @@ export class DriveEventsService { return eventSubscription; } - private async createVolumeEventManager(volumeId: string): Promise> { + private async createVolumeEventManager(volumeId: string): Promise> { if (!this.latestEventIdProvider) { throw new Error( 'Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization', @@ -171,7 +197,11 @@ export class DriveEventsService { const isOwnVolume = await this.sharesService.isOwnVolume(volumeId); const pollingInterval = this.getDefaultVolumePollingInterval(isOwnVolume); const latestEventId = await this.latestEventIdProvider.getLatestEventId(volumeId); - const eventManager = new EventManager(volumeEventManager, pollingInterval, latestEventId); + const eventManager = new EventManager( + volumeEventManager, + pollingInterval, + latestEventId, + ); for (const listener of this.cacheEventListeners) { eventManager.addListener(listener); diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts index 86cc46ee..90f72a15 100644 --- a/js/sdk/src/internal/events/interface.ts +++ b/js/sdk/src/internal/events/interface.ts @@ -44,7 +44,9 @@ export type EventsListWithStatus = { /** * Internal event interface representing a list of specific Drive events. */ -export type DriveEventsListWithStatus = EventsListWithStatus; +export type DriveEventsListWithStatus = EventsListWithStatus & { + convertibleExternalInvitationLinkIds: string[]; +}; type NodeCruEventType = DriveEventType.NodeCreated | DriveEventType.NodeUpdated; export type NodeEventType = NodeCruEventType | DriveEventType.NodeDeleted; @@ -96,7 +98,6 @@ export type DriveEvent = | FastForwardEvent | TreeRefreshEvent | TreeRemovalEvent - | FastForwardEvent | SharedWithMeUpdated; export enum DriveEventType { @@ -109,6 +110,32 @@ export enum DriveEventType { FastForward = 'fast_forward', } +/** + * Internal SDK events. These travel through the same fetch pipeline as + * DriveEvent but are dispatched to a separate listener registry and are + * never exposed to clients of the SDK. + * + * To add a new internal event: add a member to InternalEventType, add a + * new shape to InternalDriveEvent, and handle it in + * SharingEventHandler.handleInternalDriveEvent (or wherever appropriate). + */ +export enum InternalEventType { + ConvertibleExternalInvitations = 'convertible_external_invitations', +} + +export type InternalDriveEvent = { + type: InternalEventType.ConvertibleExternalInvitations; + treeEventScopeId: string; + eventId: string; + nodeUids: string[]; +}; + +export function isInternalDriveEvent( + event: DriveEvent | InternalDriveEvent, +): event is InternalDriveEvent { + return event.type === InternalEventType.ConvertibleExternalInvitations; +} + /** * This can happen if all shared nodes in that volume where unshared or if the * volume was deleted. diff --git a/js/sdk/src/internal/events/volumeEventManager.test.ts b/js/sdk/src/internal/events/volumeEventManager.test.ts index 5824e48b..757605b4 100644 --- a/js/sdk/src/internal/events/volumeEventManager.test.ts +++ b/js/sdk/src/internal/events/volumeEventManager.test.ts @@ -57,6 +57,7 @@ describe('VolumeEventManager', () => { latestEventId: 'eventId456', more: false, refresh: false, + convertibleExternalInvitationLinkIds: [], events: [ { type: DriveEventType.NodeCreated, @@ -86,6 +87,7 @@ describe('VolumeEventManager', () => { latestEventId: 'eventId2', more: true, refresh: false, + convertibleExternalInvitationLinkIds: [], events: [ { type: DriveEventType.NodeCreated, @@ -103,6 +105,7 @@ describe('VolumeEventManager', () => { latestEventId: 'eventId3', more: false, refresh: false, + convertibleExternalInvitationLinkIds: [], events: [ { type: DriveEventType.NodeUpdated, @@ -143,6 +146,7 @@ describe('VolumeEventManager', () => { latestEventId: 'eventId789', more: false, refresh: true, + convertibleExternalInvitationLinkIds: [], events: [], }; @@ -166,6 +170,7 @@ describe('VolumeEventManager', () => { latestEventId: 'newEventId', more: false, refresh: false, + convertibleExternalInvitationLinkIds: [], events: [], }; @@ -220,6 +225,7 @@ describe('VolumeEventManager', () => { latestEventId: 'sameEventId', more: false, refresh: false, + convertibleExternalInvitationLinkIds: [], events: [], }; diff --git a/js/sdk/src/internal/events/volumeEventManager.ts b/js/sdk/src/internal/events/volumeEventManager.ts index 05824164..54c985cb 100644 --- a/js/sdk/src/internal/events/volumeEventManager.ts +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -1,12 +1,15 @@ import { Logger } from '../../interface'; import { LoggerWithPrefix } from '../../telemetry'; import { NotFoundAPIError } from '../apiService'; +import { makeNodeUid } from '../uids'; import { EventsAPIService } from './apiService'; import { DriveEvent, DriveEventsListWithStatus, DriveEventType, EventManagerInterface, + InternalDriveEvent, + InternalEventType, UnsubscribeFromEventsSourceError, } from './interface'; @@ -15,7 +18,7 @@ import { * volume events. Volume events are all about nodes updates. Whenever * there is update to the node metadata or content, the event is emitted. */ -export class VolumeEventManager implements EventManagerInterface { +export class VolumeEventManager implements EventManagerInterface { constructor( private logger: Logger, private apiService: EventsAPIService, @@ -30,13 +33,24 @@ export class VolumeEventManager implements EventManagerInterface { return this.logger; } - async *getEvents(eventId: string, signal?: AbortSignal): AsyncIterable { + async *getEvents(eventId: string, signal?: AbortSignal): AsyncIterable { try { let events: DriveEventsListWithStatus; let more = true; while (more) { events = await this.apiService.getVolumeEvents(this.volumeId, eventId, signal); more = events.more; + if (events.convertibleExternalInvitationLinkIds.length > 0) { + const nodeUids = events.convertibleExternalInvitationLinkIds.map((linkId) => + makeNodeUid(this.volumeId, linkId), + ); + yield { + type: InternalEventType.ConvertibleExternalInvitations, + treeEventScopeId: this.volumeId, + eventId: events.latestEventId, + nodeUids, + }; + } if (events.refresh) { yield { type: DriveEventType.TreeRefresh, diff --git a/js/sdk/src/internal/nodes/cryptoService.ts b/js/sdk/src/internal/nodes/cryptoService.ts index eaaac590..69c36e9a 100644 --- a/js/sdk/src/internal/nodes/cryptoService.ts +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -1,12 +1,6 @@ import { c } from 'ttag'; -import { - DriveCrypto, - PrivateKey, - PublicKey, - SessionKey, - VERIFICATION_STATUS, -} from '../../crypto'; +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; import { ValidationError } from '../../errors'; import { AnonymousUser, diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts index ef36d7dc..71c9db15 100644 --- a/js/sdk/src/internal/nodes/events.ts +++ b/js/sdk/src/internal/nodes/events.ts @@ -1,5 +1,5 @@ import { Logger } from '../../interface'; -import { DriveEvent, DriveEventType } from '../events'; +import { DriveEvent, DriveEventType, InternalDriveEvent } from '../events'; import { NodesCacheBase } from './cache'; /** @@ -14,7 +14,7 @@ export class NodesEventsHandler { private cache: NodesCacheBase, ) {} - async updateNodesCacheOnEvent(event: DriveEvent): Promise { + async updateNodesCacheOnEvent(event: DriveEvent | InternalDriveEvent): Promise { try { if (event.type === DriveEventType.TreeRefresh) { await this.cache.setNodesStaleFromVolume(event.treeEventScopeId); diff --git a/js/sdk/src/internal/sharing/cryptoService.ts b/js/sdk/src/internal/sharing/cryptoService.ts index 47cdb32a..fcbd3e45 100644 --- a/js/sdk/src/internal/sharing/cryptoService.ts +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -279,6 +279,25 @@ export class SharingCryptoService { return result; } + async verifyExternalInvitationSignature( + inviteeEmail: string, + shareSessionKey: SessionKey, + base64Signature: string, + inviterEmail: string, + ): Promise { + const verificationKeys = await this.account.getPublicKeys(inviterEmail); + if (verificationKeys.length === 0) { + return false; + } + const { verified } = await this.driveCrypto.verifyExternalInvitation( + inviteeEmail, + shareSessionKey, + base64Signature, + verificationKeys, + ); + return verified === VERIFICATION_STATUS.SIGNED_AND_VALID; + } + /** * Verifies an external invitation. */ @@ -640,6 +659,6 @@ function splitGeneratedAndCustomPassword(concatenatedPassword: string): { } function generateConcanatedPassword(urlPassword: string, customPassword?: string): string { - const concatenatedPassword = urlPassword.concat(customPassword || '') - return concatenatedPassword + const concatenatedPassword = urlPassword.concat(customPassword || ''); + return concatenatedPassword; } diff --git a/js/sdk/src/internal/sharing/events.test.ts b/js/sdk/src/internal/sharing/events.test.ts index 884f1745..f43c1021 100644 --- a/js/sdk/src/internal/sharing/events.test.ts +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -32,7 +32,7 @@ describe('handleSharedByMeNodes', () => { nodesService = { notifyNodeChanged: jest.fn(), }; - sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService); + sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService, {} as any); }); it('should add if new own shared node is created', async () => { @@ -175,7 +175,7 @@ describe('handleSharedWithMeNodes', () => { treeEventScopeId: 'core', }; - const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService); + const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService, {} as any); await sharingEventHandler.handleDriveEvent(event); expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); @@ -194,7 +194,7 @@ describe('handleSharedWithMeNodes', () => { treeEventScopeId: 'core', }; - const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService); + const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService, {} as any); await sharingEventHandler.handleDriveEvent(event); expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(undefined); diff --git a/js/sdk/src/internal/sharing/events.ts b/js/sdk/src/internal/sharing/events.ts index 955fefa1..ced3a8eb 100644 --- a/js/sdk/src/internal/sharing/events.ts +++ b/js/sdk/src/internal/sharing/events.ts @@ -1,7 +1,8 @@ import { Logger } from '../../interface'; -import { DriveEvent, DriveEventType } from '../events'; +import { DriveEvent, DriveEventType, InternalDriveEvent, InternalEventType, isInternalDriveEvent } from '../events'; import { SharingCache } from './cache'; import { NodesService, SharesService } from './interface'; +import { SharingManagement } from './sharingManagement'; export class SharingEventHandler { constructor( @@ -9,6 +10,7 @@ export class SharingEventHandler { private cache: SharingCache, private shares: SharesService, private nodesService: NodesService, + private management: SharingManagement, ) {} /** @@ -25,7 +27,11 @@ export class SharingEventHandler { * * @throws Only if the client's callback throws. */ - async handleDriveEvent(event: DriveEvent) { + async handleDriveEvent(event: DriveEvent | InternalDriveEvent) { + if (isInternalDriveEvent(event)) { + await this.handleInternalDriveEvent(event); + return; + } try { await this.handleSharedWithMeNodeUidsLoaded(event); await this.handleSharedByMeNodeUidsLoaded(event); @@ -34,6 +40,12 @@ export class SharingEventHandler { } } + private async handleInternalDriveEvent(event: InternalDriveEvent) { + if (event.type === InternalEventType.ConvertibleExternalInvitations) { + await this.management.autoConvertExternalInvitations(event.nodeUids); + } + } + private async handleSharedWithMeNodeUidsLoaded(event: DriveEvent) { if ( ![DriveEventType.SharedWithMeUpdated, DriveEventType.TreeRefresh, DriveEventType.TreeRemove].includes( diff --git a/js/sdk/src/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts index af4f29b0..5b65f492 100644 --- a/js/sdk/src/internal/sharing/index.ts +++ b/js/sdk/src/internal/sharing/index.ts @@ -49,6 +49,7 @@ export function initSharingModule( cache, sharesService, nodesService, + sharingManagement, ); return { diff --git a/js/sdk/src/internal/sharing/sharingManagement.ts b/js/sdk/src/internal/sharing/sharingManagement.ts index b9bcf1d2..c4f1fc57 100644 --- a/js/sdk/src/internal/sharing/sharingManagement.ts +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -651,6 +651,98 @@ export class SharingManagement { }; } + /** + * Transparently converts convertible external invitations received from the event stream. + * + * For each link, loads external invitations and verifies that the inviter is still an + * active admin. Valid invitations are converted to Proton invitations; those whose + * signature cannot be verified are deleted per RFC-0080. + */ + async autoConvertExternalInvitations(nodeUids: string[]): Promise { + for (const nodeUid of nodeUids) { + await this.autoConvertExternalInvitationsForNode(nodeUid).catch((error: unknown) => { + this.logger.error( + `Failed to auto-convert external invitations for node ${nodeUid}: ${error instanceof Error ? error.message : error}`, + ); + }); + } + } + + private async autoConvertExternalInvitationsForNode(nodeUid: string): Promise { + const node = await this.nodesService.getNode(nodeUid); + if (!node.shareId) { + this.logger.debug(`Skipping auto-convert for node ${nodeUid}: no shareId`); + return; + } + + const [encryptedExternalInvitations, encryptedMembers, inviter, nodeKey] = await Promise.all([ + this.apiService.getShareExternalInvitations(node.shareId), + this.apiService.getShareMembers(node.shareId), + this.nodesService.getRootNodeEmailKey(nodeUid), + this.nodesService.getNodeKeys(nodeUid), + ]); + + if (encryptedExternalInvitations.length === 0) { + this.logger.debug(`Skipping auto-convert for node ${nodeUid}: no external invitations`); + return; + } + + const encryptedShare = await this.sharesService.loadEncryptedShare(node.shareId); + const { passphraseSessionKey } = await this.cryptoService.decryptShare(encryptedShare, nodeKey.key); + + const adminEmails = new Set( + encryptedMembers + .filter((member) => member.role === MemberRole.Admin) + .map((member) => member.inviteeEmail), + ); + adminEmails.add(encryptedShare.creatorEmail); + + await Promise.allSettled( + encryptedExternalInvitations.map(async (invitation) => { + const { invitationId: externalInvitationId } = splitInvitationUid(invitation.uid); + const inviterEmail = invitation.addedByEmail; + + const isValidAdmin = + adminEmails.has(inviterEmail) && + (await this.cryptoService.verifyExternalInvitationSignature( + invitation.inviteeEmail, + passphraseSessionKey, + invitation.base64Signature, + inviterEmail, + )); + + if (!isValidAdmin) { + this.logger.warn( + `Deleting external invitation for ${invitation.inviteeEmail} on node ${nodeUid}: inviter is not an active admin or signature invalid`, + ); + await this.apiService.deleteExternalInvitation(invitation.uid); + return; + } + + this.logger.info( + `Auto-converting external invitation for ${invitation.inviteeEmail} to internal for node ${nodeUid}`, + ); + const invitationCrypto = await this.cryptoService.encryptInvitation( + passphraseSessionKey, + inviter.addressKey, + invitation.inviteeEmail, + true, + ); + await this.apiService.inviteProtonUser( + node.shareId!, + { + addedByEmail: inviter.email, + inviteeEmail: invitation.inviteeEmail, + role: invitation.role, + ...invitationCrypto, + }, + {}, + externalInvitationId, + ); + }), + ); + } + private async removeMember(memberUid: string): Promise { await this.apiService.removeMember(memberUid); } diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index dee45f06..0e7c5068 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -37,7 +37,14 @@ import { import { DriveAPIService } from './internal/apiService'; import { initDevicesModule } from './internal/devices'; import { initDownloadModule } from './internal/download'; -import { CoreApiEvent, DriveEventsService, DriveListener, EventScheduler, EventSubscription } from './internal/events'; +import { + CoreApiEvent, + DriveEventsService, + DriveListener, + EventScheduler, + EventSubscription, + InternalDriveEvent, +} from './internal/events'; import { initNodesModule } from './internal/nodes'; import { SDKEvents } from './internal/sdkEvents'; import { initSharesModule } from './internal/shares'; @@ -202,8 +209,10 @@ export class ProtonDriveClient { this.nodes.access, this.nodes.management, ); - // These are used to keep the internal cache up to date - const cacheEventListeners: DriveListener[] = [ + // These are used to keep the internal cache up to date. + // Listeners receive both public DriveEvents and SDK-only + // InternalDriveEvents and should filter on event.type. + const cacheEventListeners: ((event: DriveEvent | InternalDriveEvent) => Promise)[] = [ this.nodes.eventHandler.updateNodesCacheOnEvent.bind(this.nodes.eventHandler), this.sharing.eventHandler.handleDriveEvent.bind(this.sharing.eventHandler), ]; diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index cbd37a94..4fad1e0a 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -27,7 +27,14 @@ import { } from './interface'; import { DriveAPIService } from './internal/apiService'; import { initDownloadModule } from './internal/download'; -import { CoreApiEvent, DriveEventsService, DriveListener, EventScheduler, EventSubscription } from './internal/events'; +import { + CoreApiEvent, + DriveEventsService, + DriveListener, + EventScheduler, + EventSubscription, + InternalDriveEvent, +} from './internal/events'; import { AlbumItem, initPhotoSharesModule, @@ -171,8 +178,10 @@ export class ProtonDrivePhotosClient { fullConfig.clientUid, ); - // These are used to keep the internal cache up to date - const cacheEventListeners: DriveListener[] = [ + // These are used to keep the internal cache up to date. + // Listeners receive both public DriveEvents and SDK-only + // InternalDriveEvents and should filter on event.type. + const cacheEventListeners: ((event: DriveEvent | InternalDriveEvent) => Promise)[] = [ this.nodes.eventHandler.updateNodesCacheOnEvent.bind(this.nodes.eventHandler), this.sharing.eventHandler.handleDriveEvent.bind(this.sharing.eventHandler), ]; From 2c82ba7ea9143fba844f79a6ff2b6741ebfc0d9c Mon Sep 17 00:00:00 2001 From: Roni Tuohino Date: Sun, 31 May 2026 15:29:04 +0300 Subject: [PATCH 791/791] Fix root README js/docs generation instructions --- .gitignore | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f51b449b..ea5bd5ce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__ docs/build /public js/public +doc # JS node_modules diff --git a/README.md b/README.md index 6b5206a5..3fd5f131 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ We are preparing the documentation for the SDK. It will be available in the futu Until then, you can generate the code reference for the TypeScript SDK using the following command: ```bash -cd js/sdk && OUTPUT_PATH=./doc npm run generate-doc:interface +cd js/sdk && OUTPUT_PATH=./doc npm run generate-docs ``` ## License